[studio] Complete editor uploads and queued cloud builds (#135)

* [studio] Complete editor uploads and queued cloud builds

* [studio] Protect unpublished source and remove unused notification RPC
This commit is contained in:
dukedotdev
2026-10-19 11:04:58 -04:00
committed by GitHub
parent 81c372135c
commit a3291bc9ce
30 changed files with 2553 additions and 142 deletions
+8
View File
@@ -1111,6 +1111,14 @@ export const SanitizeRequest = z.object({
/** `POST /api/sanitize/v1/isPure` — whether the text is free of profanity. */
export const IsPureResponse = z.object({ IsPure: z.boolean() })
/** Studio sends repeated Values form fields and consumes this result envelope. */
export const BulkAllowedRequest = z.object({ Values: z.array(z.string()) })
export const BulkAllowedResponse = z.object({
Success: z.boolean(),
Error: z.string().nullable(),
Value: z.object({ ArePure: z.array(z.boolean()) }).nullable(),
})
/** `GET /api/keepsakes/globalconfig` — the keepsake feature switches. */
export const KeepsakeConfig = z.object({
KeepsakeFeatureEnabled: z.boolean(),
+41
View File
@@ -1,6 +1,8 @@
import { Hono } from 'hono'
import { describeRoute } from 'hono-openapi'
import { validateStudioBuildValidationToken } from '@repo/jwt'
import charadesAprilWords from '../../static/charades-april.json'
import charadesWords from '../../static/charades.json'
import communityBoard from '../../static/community-board.json'
@@ -8,6 +10,9 @@ import { authedId, unauthorized } from '../http'
import {
AUTHED,
BareString,
BulkAllowedRequest,
BulkAllowedResponse,
form,
idParam,
IsPureResponse,
json,
@@ -144,6 +149,42 @@ export const gameplayRoutes = new Hono<App>({ strict: false })
}
)
// Studio checks all component text in one request, preserving duplicates and order.
.post(
'/api/sanitize/v2/isAllowed/bulk',
describeRoute({
tags: ['Gameplay'],
summary: 'Check Studio component text in bulk',
description:
'Checks repeated Values form fields using the existing profanity policy. ArePure matches the input order, including duplicate and empty strings. Accepts an account token or a dedicated Studio build-validation credential with no account identity.',
security: AUTHED,
requestBody: form(BulkAllowedRequest, 'Repeated Values fields'),
responses: {
200: json(BulkAllowedResponse, 'Ordered results in a Studio result envelope'),
400: json(BulkAllowedResponse, 'Invalid form values'),
401: UNAUTHORIZED_RESPONSE,
},
}),
async (c) => {
if (
(await authedId(c)) === null &&
!(await validateStudioBuildValidationToken(c.req.raw, await c.env.JWT_SECRET.get()))
)
return unauthorized(c)
const body = await c.req.parseBody({ all: true }).catch(() => null)
const field = body?.Values
const values = field === undefined ? [] : Array.isArray(field) ? field : [field]
if (body === null || values.some((value) => typeof value !== 'string')) {
return c.json({ Success: false, Error: 'Values must be strings.', Value: null }, 400)
}
return c.json({
Success: true,
Error: null,
Value: { ArePure: values.map((value) => !containsSwears(value as string)) },
})
}
)
// ---- Activities -----------------------------------------------------------
// Word bank for the Charades activity. The client requests the list by
// activity name (`.../words/Charades`); other activities have no data yet.
+54
View File
@@ -4756,6 +4756,59 @@ describe('public endpoints', () => {
})
})
describe('POST /api/sanitize/v2/isAllowed/bulk', () => {
const bulk = async (body: URLSearchParams | FormData, authed = true) =>
exports.default.fetch(`${ORIGIN}/api/sanitize/v2/isAllowed/bulk`, {
method: 'POST',
headers: authed ? await bearer('42') : {},
body,
})
test('401s without a token', async () => {
expect((await bulk(new URLSearchParams('Values=hello'), false)).status).toBe(401)
})
test('preserves repeated values, order, empty text and duplicates in the Studio envelope', async () => {
const body = new URLSearchParams()
for (const text of ['MakerRoom', 'fuck this', '', 'Scunthorpe', 'MakerRoom', 'sh1t'])
body.append('Values', text)
const res = await bulk(body)
expect(res.status).toBe(200)
expect(await res.json()).toEqual({
Success: true,
Error: null,
Value: { ArePure: [true, false, true, true, true, false] },
})
})
test('accepts a single multipart value and an empty batch', async () => {
const body = new FormData()
body.append('Values', 'a$$hole')
expect(await (await bulk(body)).json()).toEqual({
Success: true,
Error: null,
Value: { ArePure: [false] },
})
expect(await (await bulk(new URLSearchParams())).json()).toEqual({
Success: true,
Error: null,
Value: { ArePure: [] },
})
})
test('rejects file values rather than marking them clean', async () => {
const body = new FormData()
body.append('Values', new Blob(['hello']), 'text.txt')
const res = await bulk(body)
expect(res.status).toBe(400)
expect(await res.json()).toEqual({
Success: false,
Error: 'Values must be strings.',
Value: null,
})
})
})
describe('POST /api/sanitize/v1/isPure', () => {
const isPure = async (Value?: string, authed = true) =>
exports.default.fetch(`${ORIGIN}/api/sanitize/v1/isPure`, {
@@ -10343,6 +10396,7 @@ describe('openapi', () => {
'POST /api/rooms/v1/verifyRole',
'POST /api/sanitize/v1',
'POST /api/sanitize/v1/isPure',
'POST /api/sanitize/v2/isAllowed/bulk',
'POST /api/v1/progression/bulk',
'POST /outfits/bulk',
'POST /statsigUserProperties',
@@ -0,0 +1,52 @@
import { adminSecretsStore, env, SELF } from 'cloudflare:test'
import { beforeAll, expect, it } from 'vitest'
import { generateStudioBuildValidationToken, validateAndGetAccountId } from '@repo/jwt'
import type { Env } from '../../context'
declare module 'cloudflare:test' {
interface ProvidedEnv extends Env {}
}
beforeAll(async () => {
await adminSecretsStore(env.JWT_SECRET).create('test-key')
})
it('allows build validation credentials to check text without granting an account identity', async () => {
const token = await generateStudioBuildValidationToken('123456', 'test-key')
const headers = { Authorization: 'Bearer ' + token }
expect(
await validateAndGetAccountId(new Request('https://example.com', { headers }), 'test-key')
).toBeNull()
const response = await SELF.fetch('https://example.com/api/sanitize/v2/isAllowed/bulk', {
method: 'POST',
headers,
body: new URLSearchParams('Values=hello&Values=fuck'),
})
expect(response.status).toBe(200)
expect(await response.json()).toEqual({
Success: true,
Error: null,
Value: { ArePure: [true, false] },
})
expect(
(
await SELF.fetch('https://example.com/api/PlayerCheer/v1/create', {
method: 'POST',
headers,
body: new URLSearchParams(),
})
).status
).toBe(401)
})
it('rejects an invalid build validation signature', async () => {
const token = await generateStudioBuildValidationToken('123456', 'wrong-key')
expect(
(
await SELF.fetch('https://example.com/api/sanitize/v2/isAllowed/bulk', {
method: 'POST',
headers: { Authorization: 'Bearer ' + token },
body: new URLSearchParams('Values=hello'),
})
).status
).toBe(401)
})