[studio] avatar authoring, managed prefab builds (#140)

This commit is contained in:
dukedotdev
2026-10-23 15:25:31 -04:00
committed by GitHub
parent 16e5f61160
commit 3da35a8692
22 changed files with 2059 additions and 81 deletions
+2
View File
@@ -6,6 +6,7 @@ import { withCleanSpec, withDefaultCors, withNotFound, withOnError } from '@repo
import { accountRoutes } from './routes/account'
import { avatarRoutes } from './routes/avatar'
import { avatarStudioRoutes } from './routes/avatar-studio'
import { configRoutes } from './routes/config'
import { eventRoutes } from './routes/events'
import { gameplayRoutes } from './routes/gameplay'
@@ -56,6 +57,7 @@ const app = new Hono<App>({ strict: false })
.route('/', configRoutes)
.route('/', socialRoutes)
.route('/', progressionRoutes)
.route('/', avatarStudioRoutes)
.route('/', avatarRoutes)
.route('/', gameplayRoutes)
.route('/', eventRoutes)
+5
View File
@@ -72,6 +72,9 @@ export interface CustomAvatarItemSave {
UnityAssetHash: string
UnityAsset2: string | null
UnityAsset2Hash: string | null
/** Studio builds persist separate target hashes; legacy imports omit these. */
QuestUnityAssetHash?: string | null
QuestUnityAsset2Hash?: string | null
}
/** A tag on an item (`{ TagType: 0, Value: "export" }` on the imported first-party items). */
@@ -356,6 +359,8 @@ export function toQuestCustomAvatarItem(item: CustomAvatarItem): CustomAvatarIte
...save,
UnityAsset: questAsset(save.UnityAsset),
UnityAsset2: questAsset(save.UnityAsset2),
UnityAssetHash: save.QuestUnityAssetHash ?? save.UnityAssetHash,
UnityAsset2Hash: save.QuestUnityAsset2Hash ?? save.UnityAsset2Hash,
})),
}
}
+390
View File
@@ -0,0 +1,390 @@
import { Hono } from 'hono'
import {
AVATAR_STUDIO_SLOTS,
avatarItemAccessBit,
avatarSaveDto,
avatarStudioFlags,
avatarStudioItem,
avatarStudioSave,
canManageRoomById,
publishAvatarSave,
} from '@repo/domain'
import { parseMultipart } from '../../../studio/src/multipart'
import { getCustomAvatarItem, toQuestCustomAvatarItem } from '../custom-avatar-items-db'
import { authedId, authedRoles } from '../http'
import type { Context } from 'hono'
import type { AvatarStudioSave } from '@repo/domain'
import type { App } from '../context'
const uuid = '[0-9a-fA-F-]{36}'
const ok = (value: unknown) => ({ success: true, error: null, value })
const fail = (error: string) => ({ success: false, error, value: null })
const slots = new Set(AVATAR_STUDIO_SLOTS)
function validTags(value: unknown): boolean {
return (
value == null ||
(Array.isArray(value) &&
value.length <= 64 &&
value.every(
(t) =>
t &&
Number.isInteger(t.TagType) &&
t.TagType >= 0 &&
t.TagType <= 255 &&
typeof t.Value === 'string' &&
t.Value.length > 0 &&
t.Value.length <= 64
))
)
}
async function allowed(c: Context<App>, accountId: number, slot: number) {
const roles = await authedRoles(c)
return (
roles?.includes('betastudio') &&
(![104, 203].includes(slot) || roles.includes('developer')) &&
((await avatarStudioFlags(c.env.DB, accountId)) & avatarItemAccessBit(slot)) !== 0
)
}
const safeName = (value: unknown): value is string =>
typeof value === 'string' &&
/^[a-zA-Z0-9_./-]+$/.test(value) &&
!value.split('/').some((p) => p === '..' || p === '.' || p === '')
export const avatarStudioRoutes = new Hono<App>()
.get('/api/customAvatarItems/v1/me', async (c) => {
const id = await authedId(c)
if (id === null) return c.body(null, 401)
const rows = await c.env.DB.prepare(
'SELECT data FROM custom_avatar_item WHERE creator_account_id=? ORDER BY created_at DESC,custom_avatar_item_id'
)
.bind(id)
.all<{ data: string }>()
const all = rows.results.map((r) => JSON.parse(r.data))
const skip = Math.max(0, Number(c.req.query('skip')) || 0),
take = Math.min(10000, Math.max(1, Number(c.req.query('take')) || 100))
return c.json({ Results: all.slice(skip, skip + take), TotalResults: all.length })
})
.get(`/api/customAvatarItems/v1/:id{${uuid}}`, async (c) => {
const item = await getCustomAvatarItem(c.env.DB, c.req.param('id'))
if (!item) return c.notFound()
if (item.Accessibility === 0 && item.CreatorAccountId !== (await authedId(c)))
return c.body(null, 403)
return c.json(c.req.query('unityAssetTarget') === '2' ? toQuestCustomAvatarItem(item) : item)
})
.post('/api/customAvatarItems/v1', async (c, next) => {
// Preserve the existing in-game custom-shirt contract, including its errors.
const form = await parseMultipart(c.req.raw.clone())
let body: Record<string, unknown>
try {
body = JSON.parse(String(form.metadata))
} catch {
return next()
}
if (
!body ||
typeof body !== 'object' ||
body.BaseAvatarItemId !== undefined ||
form.design ||
form.thumbnailImage
)
return next()
const id = await authedId(c)
if (id === null) return c.body(null, 401)
const slot = Number(body.OutfitType)
if (!Number.isInteger(slot) || !slots.has(slot))
return c.json(fail('Unsupported avatar item type.'), 400)
if (!(await allowed(c, id, slot)))
return c.json(fail('Avatar Studio authorization is required.'), 403)
const badgeRoomId = Number(body.BadgeRoomId ?? 0)
if (
slot === 1000 &&
(!Number.isSafeInteger(badgeRoomId) ||
badgeRoomId < 0 ||
(badgeRoomId > 0 && !(await canManageRoomById(c.env.DB, badgeRoomId, id))))
)
return c.json(fail('Badge room is not managed by this account.'), 403)
if (
typeof body.Name !== 'string' ||
!body.Name.trim() ||
body.Name.length > 128 ||
(body.Description != null &&
(typeof body.Description !== 'string' || body.Description.length > 4000))
)
return c.json(fail('Invalid item name or description.'), 400)
if (
body.Price !== undefined &&
body.Price !== null &&
(!Number.isInteger(body.Price) || Number(body.Price) < 0 || Number(body.Price) > 1000)
)
return c.json(fail('Invalid item price.'), 400)
if (body.Accessibility !== undefined && ![0, 1, 2].includes(Number(body.Accessibility)))
return c.json(fail('Invalid accessibility.'), 400)
if (body.Accessibility && body.Accessibility !== 0)
return c.json(fail('Create the item privately and publish a built save first.'), 400)
if (!validTags(body.Tags)) return c.json(fail('Invalid avatar tags.'), 400)
const now = new Date().toISOString(),
itemId = crypto.randomUUID()
const item = {
CustomAvatarItemId: itemId,
CreatorAccountId: id,
Name: body.Name.trim(),
Description: body.Description ?? '',
Price: body.Price ?? 0,
Accessibility: body.Accessibility ?? 0,
ForceCannotPublish: false,
IsFeatured: false,
IsRecRoomApproved: false,
BaseAvatarItemId: null,
BaseAvatarItemColor: null,
DesignFilename: null,
ThumbnailImageFilename: null,
CreatedAt: now,
ModifiedAt: now,
PreviewOrientation: body.PreviewOrientation ?? 0,
RankingContext: null,
OutfitType: slot,
CurrentSaves: [],
Tags: body.Tags ?? [],
IsStudioAuthored: true,
CustomBadgeMetadata: slot === 1000 ? { RoomId: badgeRoomId } : null,
RankedEntityId: itemId,
PurchaseInfo: null,
}
await c.env.DB.prepare('INSERT INTO custom_avatar_item(data) VALUES (?)')
.bind(JSON.stringify(item))
.run()
return c.json(ok(item))
})
.put(`/api/customAvatarItems/v1/:id{${uuid}}`, async (c, next) => {
const itemId = c.req.param('id')
const existing = await avatarStudioItem(c.env.DB, itemId)
if (!existing?.IsStudioAuthored) return next()
const id = await authedId(c)
if (id === null) return c.body(null, 401)
if (existing.CreatorAccountId !== id || !(await allowed(c, id, existing.OutfitType)))
return c.body(null, 403)
const body = await c.req.json<Record<string, unknown>>().catch(() => null)
if (
!body ||
(body.Name != null &&
(typeof body.Name !== 'string' || !body.Name.trim() || body.Name.length > 128)) ||
(body.Description != null &&
(typeof body.Description !== 'string' || body.Description.length > 4000)) ||
(body.Price != null &&
(!Number.isInteger(body.Price) || Number(body.Price) < 0 || Number(body.Price) > 1000)) ||
(body.Accessibility != null && ![0, 1, 2].includes(Number(body.Accessibility))) ||
!validTags(body.Tags)
)
return c.json(fail('Invalid avatar metadata.'), 400)
for (let attempt = 0; attempt < 5; attempt++) {
const item = await avatarStudioItem(c.env.DB, itemId)
if (!item) return c.notFound()
if (body.Accessibility && !item.CurrentSaves?.length)
return c.json(fail('Publish a built save before making the item public.'), 400)
const prior = JSON.stringify(item)
for (const key of ['Name', 'Description', 'Price', 'Accessibility', 'Tags'])
if (body[key] != null) item[key] = key === 'Name' ? String(body[key]).trim() : body[key]
item.ModifiedAt = new Date().toISOString()
const result = await c.env.DB.prepare(
'UPDATE custom_avatar_item SET data=? WHERE custom_avatar_item_id=? AND json(data)=json(?)'
)
.bind(JSON.stringify(item), itemId, prior)
.run()
if (result.meta.changes) return c.json(ok(item))
}
return c.json(fail('The item changed concurrently. Retry the edit.'), 409)
})
.get(`/api/customAvatarItems/:id{${uuid}}/saves`, async (c) => {
const id = await authedId(c)
if (id === null) return c.body(null, 401)
const itemId = c.req.param('id'),
item = await avatarStudioItem(c.env.DB, itemId)
if (!item) return c.notFound()
if (item.CreatorAccountId !== id) return c.body(null, 403)
const rows = await c.env.DB.prepare(
'SELECT * FROM avatar_studio_save WHERE item_id=? ORDER BY id DESC'
)
.bind(itemId)
.all<AvatarStudioSave>()
const skip = Math.max(0, Number(c.req.query('skip')) || 0),
take = Math.min(10000, Math.max(1, Number(c.req.query('take')) || 100))
return c.json(
ok({
Results: await Promise.all(
rows.results.slice(skip, skip + take).map((r) => avatarSaveDto(c.env.DB, r))
),
TotalResults: rows.results.length,
})
)
})
.post(`/api/customAvatarItems/:id{${uuid}}/saves`, async (c) => {
const id = await authedId(c)
if (id === null) return c.body(null, 401)
const itemId = c.req.param('id'),
item = await avatarStudioItem(c.env.DB, itemId)
if (!item) return c.notFound()
if (item.CreatorAccountId !== id || !(await allowed(c, id, item.OutfitType)))
return c.body(null, 403)
const body = await c.req.json<Record<string, unknown>>().catch(() => null)
if (
!body ||
!Number.isInteger(body.BodyType) ||
![0, 1, 2, 3, 4].includes(Number(body.BodyType)) ||
!safeName(body.ThumbnailFileName) ||
typeof body.AdditionalConfiguration !== 'string' ||
body.AdditionalConfiguration.length > 1024 * 1024 ||
(body.SaveDescription != null &&
(typeof body.SaveDescription !== 'string' || body.SaveDescription.length > 4000))
)
return c.json(fail('Invalid avatar save.'), 400)
try {
JSON.parse(body.AdditionalConfiguration)
} catch {
return c.json(fail('Invalid additional configuration JSON.'), 400)
}
let filename: string, hash: string, unityId: string
if (body.NewUnityAssetFile && typeof body.NewUnityAssetFile === 'object') {
const file = body.NewUnityAssetFile as Record<string, unknown>
if (!safeName(file.Filename)) return c.json(fail('Invalid source filename.'), 400)
const object = await c.env.CDN_ASSETS.head('avatarunityasset/' + file.Filename)
if (!object || object.customMetadata?.ownerId !== String(id))
return c.json(fail('Source upload does not belong to this account.'), 403)
const checksum = object.checksums.sha256
hash = checksum ? btoa(String.fromCharCode(...new Uint8Array(checksum))) : ''
if (!hash || file.Hash !== hash)
return c.json(fail('Source hash does not match uploaded bytes.'), 400)
filename = file.Filename
unityId = crypto.randomUUID()
} else if (typeof body.ExistingUnityAssetId === 'string') {
const prior = await c.env.DB.prepare(
'SELECT * FROM avatar_studio_save WHERE item_id=? AND unity_asset_id=? ORDER BY id DESC LIMIT 1'
)
.bind(itemId, body.ExistingUnityAssetId)
.first<AvatarStudioSave>()
if (!prior) return c.json(fail('No matching source revision on this item.'), 400)
filename = prior.source_filename
hash = prior.source_hash
// A source can be reused across builds, but compiled outputs belong to
// one body/configuration. Only metadata-only revisions share their output.
unityId =
prior.body_type === body.BodyType && prior.configuration === body.AdditionalConfiguration
? prior.unity_asset_id
: crypto.randomUUID()
} else return c.json(fail('A new or existing source asset is required.'), 400)
if (item.OutfitType === 1000) {
const config = JSON.parse(body.AdditionalConfiguration as string)
const badgeRoom = config?.d?.d?.r ?? 0
if (
config?.v !== 1 ||
config?.d?.o !== 1000 ||
!Number.isSafeInteger(badgeRoom) ||
badgeRoom < 0
)
return c.json(fail('Invalid badge configuration.'), 400)
if (badgeRoom > 0 && !(await canManageRoomById(c.env.DB, badgeRoom, id)))
return c.json(fail('Badge room is not managed by this account.'), 403)
if (body.BodyType !== 4)
return c.json(fail('Badge images require the body-agnostic body type.'), 400)
const source = await c.env.CDN_ASSETS.get('avatarunityasset/' + filename)
if (!source || source.size > 8 * 1024 * 1024)
return c.json(fail('Missing or oversized badge source.'), 400)
const manifest = await source
.json<{
Version: number
MainAssetProjectRelativePath: string
Files: Array<{ p: string; n: string }>
}>()
.catch(() => null)
if (
!manifest ||
manifest.Version !== 3 ||
!Array.isArray(manifest.Files) ||
!manifest.Files.length ||
manifest.Files.length > 100000 ||
!safeName(manifest.MainAssetProjectRelativePath) ||
!manifest.MainAssetProjectRelativePath.startsWith('Assets/') ||
!/\.(png|jpe?g)$/i.test(manifest.MainAssetProjectRelativePath) ||
manifest.Files.some(
(f) =>
!f ||
!safeName(f.p) ||
!/^(Assets|Packages)\//.test(f.p) ||
/\.(cs|dll|exe|so|dylib|rsp|asmdef|asmref)$/i.test(f.p) ||
!/^[a-z0-9]{40}$/.test(f.n)
) ||
new Set(manifest.Files.map((f) => f.p.toLowerCase())).size !== manifest.Files.length
)
return c.json(fail('Invalid badge image source manifest.'), 400)
const main = manifest.Files.find((f) => f.p === manifest.MainAssetProjectRelativePath)
const image = main ? await c.env.CDN_ASSETS.get('unityasset/' + main.n) : null
if (!image || image.size > 16 * 1024 * 1024)
return c.json(fail('Badge source image is missing or oversized.'), 400)
const signature = new Uint8Array(await image.arrayBuffer()).subarray(0, 8)
if (
!(
signature[0] === 137 &&
signature[1] === 80 &&
signature[2] === 78 &&
signature[3] === 71
) &&
!(signature[0] === 255 && signature[1] === 216 && signature[2] === 255)
)
return c.json(fail('Badge source must be a PNG or JPEG image.'), 400)
}
for (const name of [body.ThumbnailFileName, body.ModerationImageFileName].filter(
(v) => v !== undefined && v !== null
)) {
if (!safeName(name)) return c.json(fail('Invalid image filename.'), 400)
const image = await c.env.CDN_ASSETS.get('image/' + name)
if (!image || image.customMetadata?.ownerId !== String(id))
return c.json(fail('Image upload does not belong to this account.'), 403)
if (image.size > 16 * 1024 * 1024) return c.json(fail('Avatar image exceeds limit.'), 413)
// Storage's image upload and the Images service use different buckets.
// Keep the SDK filename unchanged when publishing creator-owned thumbnails.
await c.env.IMAGES.put(name, image.body, { httpMetadata: image.httpMetadata })
}
const row = await c.env.DB.prepare(
'INSERT INTO avatar_studio_save(item_id,body_type,unity_asset_id,source_filename,source_hash,thumbnail,moderation_image,configuration,description,update_current,created_at) VALUES (?,?,?,?,?,?,?,?,?,?,?) RETURNING id'
)
.bind(
itemId,
body.BodyType,
unityId,
filename,
hash,
body.ThumbnailFileName,
typeof body.ModerationImageFileName === 'string' ? body.ModerationImageFileName : null,
body.AdditionalConfiguration,
body.SaveDescription ?? null,
body.UpdateCurrentSaveIfPrivate === true ? 1 : 0,
new Date().toISOString()
)
.first<{ id: number }>()
if (body.UpdateCurrentSaveIfPrivate === true && item.Accessibility === 0)
await publishAvatarSave(c.env.DB, itemId, row!.id, true)
return c.json(ok(row!.id))
})
.post(`/api/customAvatarItems/:id{${uuid}}/saves/:saveId{[1-9][0-9]*}/publish`, async (c) => {
const id = await authedId(c)
if (id === null) return c.body(null, 401)
const item = await avatarStudioItem(c.env.DB, c.req.param('id'))
if (!item) return c.notFound()
if (item.CreatorAccountId !== id || !(await allowed(c, id, item.OutfitType)))
return c.body(null, 403)
const save = await avatarStudioSave(
c.env.DB,
item.CustomAvatarItemId,
Number(c.req.param('saveId'))
)
if (item.OutfitType === 1000 && save) {
const badgeRoom = JSON.parse(save.configuration)?.d?.d?.r ?? 0
if (badgeRoom > 0 && !(await canManageRoomById(c.env.DB, badgeRoom, id)))
return c.body(null, 403)
}
if (!save || !(await publishAvatarSave(c.env.DB, item.CustomAvatarItemId, save.id)))
return c.json(fail('Save has not completed a build.'), 400)
return c.json(ok(null))
})
@@ -0,0 +1,338 @@
import { adminSecretsStore, env, SELF } from 'cloudflare:test'
import { beforeAll, beforeEach, expect, it } from 'vitest'
import {
AVATAR_STUDIO_SCHEMA,
avatarSaveDto,
avatarStudioSave,
publishAvatarSave,
ROOM_SCHEMA_DDL,
UNITY_ASSET_SCHEMA_DDL,
} from '@repo/domain'
import { generateToken } from '@repo/jwt'
import { SCHEMA_DDL } from '../../custom-avatar-items-db'
import type { Env } from '../../context'
declare module 'cloudflare:test' {
interface ProvidedEnv extends Env {}
}
const base = 'https://example.com/api/customAvatarItems'
async function headers(id = 42, roles = ['betastudio']) {
return {
Authorization: 'Bearer ' + (await generateToken(String(id), 'fixture', 0, 'test-key', roles)),
}
}
beforeAll(async () => {
await adminSecretsStore(env.JWT_SECRET).create('test-key')
for (const sql of [
...SCHEMA_DDL,
...AVATAR_STUDIO_SCHEMA,
...UNITY_ASSET_SCHEMA_DDL,
...ROOM_SCHEMA_DDL,
])
await env.DB.prepare(sql).run()
await env.DB.prepare("INSERT INTO avatar_studio_access VALUES (42,1,'now','now')").run()
})
beforeEach(async () => {
await env.DB.prepare('DELETE FROM avatar_studio_save').run()
await env.DB.prepare('DELETE FROM custom_avatar_item').run()
await env.DB.prepare('UPDATE avatar_studio_access SET flags=1 WHERE account_id=42').run()
})
it('saves and publishes image badges directly, while enforcing badge grants and room ownership', async () => {
expect((await create(42, ['betastudio'], 1000)).status).toBe(403)
await env.DB.prepare('UPDATE avatar_studio_access SET flags=5 WHERE account_id=42').run()
const foreign = new FormData()
foreign.set(
'metadata',
JSON.stringify({ Name: 'Foreign badge', OutfitType: 1000, BadgeRoomId: 98765 })
)
expect(
(await SELF.fetch(base + '/v1', { method: 'POST', headers: await headers(), body: foreign }))
.status
).toBe(403)
const { value: item } = await (
await create(42, ['betastudio'], 1000)
).json<{ value: { CustomAvatarItemId: string } }>()
const png = Uint8Array.from(
atob(
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVQIHWP4z8DwHwAFgAI/ScLbtAAAAABJRU5ErkJggg=='
),
(c) => c.charCodeAt(0)
)
const main = 'b'.repeat(40)
const bytes = new TextEncoder().encode(
JSON.stringify({
Version: 3,
MainAssetProjectRelativePath: 'Assets/Badge.png',
Files: [{ n: main, p: 'Assets/Badge.png' }],
})
)
const digest = await crypto.subtle.digest('SHA-256', bytes)
await env.CDN_ASSETS.put('avatarunityasset/badge-source', bytes, {
sha256: digest,
customMetadata: { ownerId: '42' },
})
await env.CDN_ASSETS.put('unityasset/' + main, png)
await env.CDN_ASSETS.put('image/badge.png', png, {
customMetadata: { ownerId: '42' },
httpMetadata: { contentType: 'image/png' },
})
const url = base + '/' + item.CustomAvatarItemId + '/saves'
const save = await SELF.fetch(url, {
method: 'POST',
headers: { ...(await headers()), 'Content-Type': 'application/json' },
body: JSON.stringify({
NewUnityAssetFile: {
Filename: 'badge-source',
Hash: btoa(String.fromCharCode(...new Uint8Array(digest))),
},
BodyType: 4,
ThumbnailFileName: 'badge.png',
AdditionalConfiguration: JSON.stringify({
v: 1,
d: { n: 'Badge', o: 1000, d: { t: '000000' } },
}),
UpdateCurrentSaveIfPrivate: true,
}),
})
expect(save.status).toBe(200)
const { value: saveId } = await save.json<{ value: number }>()
const detail = await SELF.fetch(base + '/v1/' + item.CustomAvatarItemId, {
headers: await headers(),
})
expect(await detail.json()).toMatchObject({
ThumbnailImageFilename: 'badge.png',
CurrentSaves: [{ CustomAvatarItemSaveId: saveId, BodyType: 4, UnityAsset: null }],
})
expect(
(
await SELF.fetch(url + '/' + saveId + '/publish', {
method: 'POST',
headers: await headers(),
})
).status
).toBe(200)
expect(await (await env.IMAGES.get('badge.png'))!.arrayBuffer()).toEqual(png.buffer)
const published = await SELF.fetch(base + '/v1/' + item.CustomAvatarItemId, {
method: 'PUT',
headers: { ...(await headers()), 'Content-Type': 'application/json' },
body: JSON.stringify({ Accessibility: 1 }),
})
expect(published.status).toBe(200)
expect((await SELF.fetch(base + '/v1/' + item.CustomAvatarItemId)).status).toBe(200)
})
async function create(id = 42, roles = ['betastudio'], outfitType = 0) {
const form = new FormData()
form.set(
'metadata',
JSON.stringify({ Name: 'Studio Hat', OutfitType: outfitType, Accessibility: 0, Price: 0 })
)
return SELF.fetch(base + '/v1', { method: 'POST', headers: await headers(id, roles), body: form })
}
it('accepts the SDK create request with nullable metadata fields', async () => {
const form = new FormData()
form.set(
'metadata',
JSON.stringify({
Name: 'SDK item',
OutfitType: 101,
BaseAvatarItemColor: '#101',
BadgeRoomId: null,
PreviewOrientation: null,
Description: null,
Tags: null,
Price: null,
Accessibility: null,
})
)
const response = await SELF.fetch(base + '/v1', {
method: 'POST',
headers: await headers(),
body: form,
})
expect(response.status).toBe(200)
expect(await response.json()).toMatchObject({
value: { Description: '', Tags: [], Price: 0, Accessibility: 0, CurrentSaves: [] },
})
})
it('keeps grants per-account, rejects forbidden item categories, and creates metadata-only Studio items', async () => {
expect((await create(43)).status).toBe(403)
expect((await create(42, [])).status).toBe(403)
expect((await create(42, ['betastudio'], 500)).status).toBe(403)
const response = await create()
expect(response.status).toBe(200)
const { value: item } = await response.json<{
value: { CustomAvatarItemId: string; OutfitType: number; CurrentSaves: unknown[] }
}>()
expect(item.OutfitType).toBe(0)
expect(item.CurrentSaves).toEqual([])
expect((await SELF.fetch(base + '/v1/' + item.CustomAvatarItemId)).status).toBe(403)
expect(
(await SELF.fetch(base + '/v1/' + item.CustomAvatarItemId, { headers: await headers() })).status
).toBe(200)
const page = await SELF.fetch(base + '/v1/me', { headers: await headers() })
expect(await page.json()).toMatchObject({
TotalResults: 1,
Results: [{ CustomAvatarItemId: item.CustomAvatarItemId }],
})
})
it('protects source history, verifies upload ownership and hash, and never publishes unbuilt revisions', async () => {
const { value: item } = await (await create()).json<{ value: { CustomAvatarItemId: string } }>()
const url = base + '/' + item.CustomAvatarItemId + '/saves'
const bytes = new TextEncoder().encode('{"Version":3}'),
digest = await crypto.subtle.digest('SHA-256', bytes)
await env.CDN_ASSETS.put('avatarunityasset/2026-10-18/source', bytes, {
sha256: digest,
customMetadata: { ownerId: '42' },
})
await env.CDN_ASSETS.put('image/thumb.png', 'thumbnail', {
customMetadata: { ownerId: '42' },
httpMetadata: { contentType: 'image/png' },
})
const body = {
NewUnityAssetFile: {
Filename: '2026-10-18/source',
Hash: btoa(String.fromCharCode(...new Uint8Array(digest))),
},
BodyType: 1,
ThumbnailFileName: 'thumb.png',
AdditionalConfiguration: '{}',
UpdateCurrentSaveIfPrivate: true,
}
const post = async (value: unknown, id = 42) =>
SELF.fetch(url, {
method: 'POST',
headers: { ...(await headers(id)), 'Content-Type': 'application/json' },
body: JSON.stringify(value),
})
expect((await post(body, 43)).status).toBe(403)
expect(
(await post({ ...body, NewUnityAssetFile: { ...body.NewUnityAssetFile, Hash: 'wrong' } }))
.status
).toBe(400)
const response = await post(body)
expect(response.status).toBe(200)
const { value: saveId } = await response.json<{ value: number }>()
expect(await (await env.IMAGES.get('thumb.png'))?.text()).toBe('thumbnail')
expect((await SELF.fetch(url, { headers: await headers(43) })).status).toBe(403)
const history = await SELF.fetch(url, { headers: await headers() })
expect(await history.json()).toMatchObject({
success: true,
value: {
TotalResults: 1,
Results: [
{
CustomAvatarItemSaveId: saveId,
SourceUnityAssetFilename: body.NewUnityAssetFile.Filename,
UnityAsset: null,
},
],
},
})
expect(
(
await SELF.fetch(url + '/' + saveId + '/publish', {
method: 'POST',
headers: await headers(),
})
).status
).toBe(400)
expect(
(await post({ ...body, NewUnityAssetFile: null, ExistingUnityAssetId: crypto.randomUUID() }))
.status
).toBe(400)
})
it('allows creator metadata edits without exposing unbuilt items or changing ownership', async () => {
const { value: item } = await (await create()).json<{ value: { CustomAvatarItemId: string } }>()
const edit = async (body: unknown, id = 42) =>
SELF.fetch(base + '/v1/' + item.CustomAvatarItemId, {
method: 'PUT',
headers: { ...(await headers(id)), 'Content-Type': 'application/json' },
body: JSON.stringify(body),
})
expect((await edit({ Name: 'Other owner' }, 43)).status).toBe(403)
expect((await edit({ Accessibility: 1 })).status).toBe(400)
expect((await edit({ Tags: [{ TagType: 1, Value: '' }] })).status).toBe(400)
const response = await edit({
Name: ' Updated hat ',
Description: 'Edited in Studio',
Tags: [{ TagType: 1, Value: 'hat' }],
CreatorAccountId: 43,
})
expect(response.status).toBe(200)
expect(await response.json()).toMatchObject({
value: {
Name: 'Updated hat',
Description: 'Edited in Studio',
CreatorAccountId: 42,
Accessibility: 0,
Tags: [{ TagType: 1, Value: 'hat' }],
},
})
})
it('isolates outputs across reused-source bodies/configurations and preserves original restore', async () => {
const { value: item } = await (await create()).json<{ value: { CustomAvatarItemId: string } }>()
const asset = crypto.randomUUID()
await env.DB.prepare(
"INSERT INTO avatar_studio_save(id,item_id,body_type,unity_asset_id,source_filename,source_hash,thumbnail,configuration,created_at) VALUES (100,?,1,?,'same-source','same-hash','thumb.png','{}','now')"
)
.bind(item.CustomAvatarItemId, asset)
.run()
await env.DB.prepare(
"INSERT INTO unity_asset VALUES (?,0,5,'main','original.bundle','original-hash')"
)
.bind(asset)
.run()
expect(await publishAvatarSave(env.DB, item.CustomAvatarItemId, 100)).toBe(true)
await env.CDN_ASSETS.put('image/thumb.png', 'fixture', { customMetadata: { ownerId: '42' } })
const url = base + '/' + item.CustomAvatarItemId + '/saves'
const save = async (bodyType: number, config: string) => {
const response = await SELF.fetch(url, {
method: 'POST',
headers: { ...(await headers()), 'Content-Type': 'application/json' },
body: JSON.stringify({
ExistingUnityAssetId: asset,
BodyType: bodyType,
AdditionalConfiguration: config,
ThumbnailFileName: 'thumb.png',
UpdateCurrentSaveIfPrivate: true,
}),
})
expect(response.status).toBe(200)
const { value: id } = await response.json<{ value: number }>()
return (await avatarStudioSave(env.DB, item.CustomAvatarItemId, id))!
}
const metadata = await save(1, '{}')
expect(metadata.unity_asset_id).toBe(asset)
const otherBody = await save(2, '{}'),
otherConfig = await save(1, '{"variant":2}')
for (const revision of [otherBody, otherConfig]) {
expect(revision.unity_asset_id).not.toBe(asset)
expect(revision.source_filename).toBe('same-source')
expect(revision.source_hash).toBe('same-hash')
expect((await avatarSaveDto(env.DB, revision)).UnityAsset).toBeNull()
expect(await publishAvatarSave(env.DB, item.CustomAvatarItemId, revision.id)).toBe(false)
}
expect(otherBody.unity_asset_id).not.toBe(otherConfig.unity_asset_id)
await env.DB.prepare(
"INSERT INTO unity_asset VALUES (?,0,5,'main','other-body.bundle','other-hash')"
)
.bind(otherBody.unity_asset_id)
.run()
expect(await publishAvatarSave(env.DB, item.CustomAvatarItemId, otherBody.id)).toBe(true)
expect(await publishAvatarSave(env.DB, item.CustomAvatarItemId, 100)).toBe(true)
const stored = JSON.parse(
(await env.DB.prepare('SELECT data FROM custom_avatar_item WHERE custom_avatar_item_id=?')
.bind(item.CustomAvatarItemId)
.first<{ data: string }>())!.data
)
expect(stored.CurrentSaves.find((s: { BodyType: number }) => s.BodyType === 1).UnityAsset).toBe(
'original.bundle'
)
expect(stored.CurrentSaves.find((s: { BodyType: number }) => s.BodyType === 2).UnityAsset).toBe(
'other-body.bundle'
)
})
+20 -6
View File
@@ -362,6 +362,13 @@ const app = new Hono<App>()
(c) => serveAsset(c, `avatar/${c.req.param('asset')}`)
)
// SDK source downloads use an opaque upload capability (no bearer on this
// request). Only creator-authenticated save history exposes these filenames.
.get('/avatarunityasset/:filename{.+}', (c) => {
const name = c.req.param('filename')
if (!/^\d{4}-\d{2}-\d{2}\/[0-9a-f-]{36}$/.test(name)) return c.notFound()
return serveAsset(c, `avatarunityasset/${name}`)
})
// Editable Unity source manifests. Baked bundles are served separately.
.get(
'/roomunityasset/:filename{.+}',
@@ -430,15 +437,22 @@ const app = new Hono<App>()
* a build is stored under it. Null when no build is, or the table has not been migrated
* yet (the rooms worker owns it).
*/
async function unityAssetR2Key(db: D1Database, filename: string): Promise<string | null> {
async function unityAssetR2Key(env: StudioAssetEnv, filename: string): Promise<string | null> {
try {
// Inlined rather than imported so this worker does not take a dependency on the
// domain package.
const row = await db
.prepare(`SELECT 1 AS stored FROM unity_asset WHERE filename = ?1 LIMIT 1`)
const row = await env.DB.prepare(
`SELECT 1 AS stored FROM unity_asset WHERE filename = ?1 LIMIT 1`
)
.bind(filename)
.first<{ stored: number }>()
return row ? filename : null
if (!row) return null
if (/^(quest\/)?studio-avatar\//.test(filename)) return `avatar/${filename}`
// Room builds and consumable exports use the game's /room namespace.
// Retain older installations that stored the same filename at the bucket root.
if (filename.startsWith('studio/') && (await env.CDN_ASSETS.head(`room/${filename}`)))
return `room/${filename}`
return filename
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
if (message.toLowerCase().includes('no such table')) return null
@@ -450,14 +464,14 @@ async function unityAssetR2Key(db: D1Database, filename: string): Promise<string
async function serveUnityAsset(c: Context<App>) {
const name = c.req.param('filename') ?? ''
if (name.includes('..')) return c.body(null, 400)
const key = await unityAssetR2Key(c.env.DB, name)
const key = await unityAssetR2Key(c.env, name)
return serveAsset(c, key ?? `unityasset/${name}`)
}
/** Build the Studio bundle HEAD response outside Hono's HEAD-to-GET fallback. */
async function studioUnityAssetHead(env: StudioAssetEnv, name: string): Promise<Response> {
if (name.includes('..')) return new Response(null, { status: 400 })
const key = await unityAssetR2Key(env.DB, name)
const key = await unityAssetR2Key(env, name)
const head = await env.CDN_ASSETS.head(key ?? `unityasset/${name}`)
if (!head) return new Response(null, { status: 404 })
+38
View File
@@ -12,6 +12,32 @@ declare module 'cloudflare:test' {
const ORIGIN = 'https://example.com'
test('serves opaque avatar source manifests and SDK avatar bundle GET/HEAD/ranges', async () => {
const source = '2026-10-18/11111111-1111-1111-1111-111111111111'
await env.CDN_ASSETS.put('avatarunityasset/' + source, '{"Version":3}')
expect(await (await exports.default.fetch(ORIGIN + '/avatarunityasset/' + source)).text()).toBe(
'{"Version":3}'
)
await env.DB.prepare(
'CREATE TABLE IF NOT EXISTS unity_asset(unity_asset_id TEXT NOT NULL,target INTEGER NOT NULL,version INTEGER NOT NULL,kind TEXT NOT NULL,filename TEXT NOT NULL,hash TEXT NOT NULL,PRIMARY KEY(unity_asset_id,target,version,kind))'
).run()
const name = 'quest/studio-avatar/test/item/lease.material.assetbundle'
await env.DB.prepare(
'INSERT INTO unity_asset(unity_asset_id,target,version,kind,filename,hash) VALUES (?,2,6,?,?,?)'
)
.bind('avatar-fixture', 'material', name, 'hash')
.run()
await env.CDN_ASSETS.put('avatar/' + name, 'UnityFS\0avatar')
expect(
(await exports.default.fetch(ORIGIN + '/unityasset/' + name, { method: 'HEAD' })).status
).toBe(200)
const response = await exports.default.fetch(ORIGIN + '/unityasset/' + name, {
headers: { Range: 'bytes=0-7' },
})
expect(response.status).toBe(206)
expect(await response.text()).toBe('UnityFS\0')
})
describe('cdn endpoints', () => {
test('GET / reports service status', async () => {
const res = await exports.default.fetch(`${ORIGIN}/`)
@@ -357,6 +383,18 @@ describe('cdn endpoints', () => {
expect(head.status).toBe(200)
expect(head.headers.get('content-length')).toBe('4')
expect(await head.arrayBuffer()).toEqual(new ArrayBuffer(0))
// Current room/consumable builds live in the game's room namespace.
await env.CDN_ASSETS.put('room/' + filename, new Uint8Array([5, 6, 7, 8, 9]))
const current = await exports.default.fetch(`${ORIGIN}/unityasset/${filename}`, {
headers: { Range: 'bytes=1-2' },
})
expect(current.status).toBe(206)
expect(new Uint8Array(await current.arrayBuffer())).toEqual(new Uint8Array([6, 7]))
const currentHead = await exports.default.fetch(`${ORIGIN}/unityasset/${filename}`, {
method: 'HEAD',
})
expect(currentHead.status).toBe(200)
expect(currentHead.headers.get('content-length')).toBe('5')
expect((await exports.default.fetch(`${ORIGIN}/unityasset/other.assetbundle`)).status).toBe(404)
expect(
@@ -7185,3 +7185,27 @@ describe('Studio source revision permissions', () => {
expect(await source.json()).toMatchObject({ filename: 'first-draft.json' })
})
})
it('omits hydrated Badges when persisting a room settings edit', async () => {
await seedRoomWithSubRooms(env.DB, {
RoomId: 987670,
Name: 'BadgeSerialization',
CreatorAccountId: 987670,
SubRooms: [],
})
await env.DB.prepare(
"UPDATE room SET data=json_set(data,'$.Badges',json(?)) WHERE room_id=987670"
)
.bind(JSON.stringify([{ Name: 'derived badge' }]))
.run()
const response = await SELF.fetch(`${ORIGIN}/rooms/987670/warning`, {
method: 'PUT',
headers: await bearer('987670'),
body: new URLSearchParams({ warningMask: '1' }),
})
expect(response.status).toBe(200)
const stored = await env.DB.prepare('SELECT data FROM room WHERE room_id=987670').first<{
data: string
}>()
expect(JSON.parse(stored!.data)).not.toHaveProperty('Badges')
})
+3
View File
@@ -56,6 +56,8 @@ const UPLOAD_SUBFOLDER: Record<number, string> = {
8: 'room',
9: 'roomunityasset',
10: 'unityasset',
13: 'avatar',
14: 'avatarunityasset',
}
/** Resolve the storage subfolder for a posted FileType, or `undefined` when unknown. */
@@ -380,6 +382,7 @@ const app = new Hono<App>()
contentType = image.contentType
}
await c.env.CDN_ASSETS.put(`${subfolder}/${filename}`, bytes, {
customMetadata: { ownerId: String(id) },
httpMetadata: { contentType },
// Record the SHA-256 on the object. R2 stores an md5 on its own, but the
// hashes the client is served (an invention's `BlobHash`) are SHA-256, and
@@ -21,6 +21,27 @@ beforeAll(async () => {
// storage worker's validation accepts it.
const TEST_SECRET = 'test-signing-key'
it('stores avatar source and baked uploads with immutable account ownership metadata', async () => {
for (const [type, prefix] of [
[13, 'avatar'],
[14, 'avatarunityasset'],
] as const) {
const form = new FormData()
form.set('fileType', String(type))
form.set('file', new File(['avatar source'], 'source.bin'))
const r = await SELF.fetch(ORIGIN + '/upload', {
method: 'POST',
headers: { ...(await bearer()), 'User-Agent': 'RRStudio' },
body: form,
})
expect(r.status).toBe(200)
const body = await r.json<{ Filename: string; Hash: string }>()
expect(body.Hash).toBeTruthy()
const object = await env.CDN_ASSETS.head(prefix + '/' + body.Filename)
expect(object?.customMetadata?.ownerId).toBe('42')
}
})
function b64url(input: ArrayBuffer | string): string {
const bytes = typeof input === 'string' ? new TextEncoder().encode(input) : new Uint8Array(input)
let binary = ''
+17 -2
View File
@@ -96,5 +96,20 @@ allocated asset IDs and requested platforms are accepted. SDK bundle version 5
is retained. Completion links the submitted save; auto-publish does not replace
a newer draft. Failed, cancelled, or expired jobs never become successful builds.
Claims include a signed, short-lived SDK validation token with no account subject
or roles. Deploy the paired API support: only bulk component-text validation
accepts this credential. It cannot authenticate as an account or publish a room.
or roles. Bulk component-text validation and job-scoped grant reads accept this
credential. It cannot authenticate as an account or publish a room.
## Avatar Studio and consumable validation
Avatar authoring uses per-account Studio grants, source revisions, and the managed
cloud queue. Apply `migrations/0004_avatar_studio.sql` to the shared database before
enabling these routes. The API owns creator saves and explicit publication; the
Studio service queues prefab builds and returns per-platform main/stripped bundles
at version 5 and material bundles at version 6. Image badges publish directly.
Changed body/configuration inputs receive distinct output IDs, and automatic
publication cannot replace a newer revision. Deploy the paired cloud-building
worker for prefab support; restricted validation credentials grant no account role.
The SDK's access override reads the target account's entitlement. Build-validation
credentials can inspect only the current owners of their active job's room; peers
can inspect grants only while they currently manage the same room.
@@ -0,0 +1,4 @@
CREATE TABLE IF NOT EXISTS avatar_studio_access (account_id INTEGER PRIMARY KEY, flags INTEGER NOT NULL DEFAULT 0, created_at TEXT NOT NULL, modified_at TEXT NOT NULL);
CREATE TABLE IF NOT EXISTS avatar_studio_save (id INTEGER PRIMARY KEY AUTOINCREMENT, item_id TEXT NOT NULL, body_type INTEGER NOT NULL, unity_asset_id TEXT NOT NULL, source_filename TEXT NOT NULL, source_hash TEXT NOT NULL, thumbnail TEXT NOT NULL, moderation_image TEXT, configuration TEXT NOT NULL, description TEXT, update_current INTEGER NOT NULL DEFAULT 0, created_at TEXT NOT NULL);
CREATE INDEX IF NOT EXISTS idx_avatar_studio_save_item ON avatar_studio_save(item_id,id);
CREATE TABLE IF NOT EXISTS studio_avatar_build (cloud_build_id TEXT PRIMARY KEY, item_id TEXT NOT NULL, save_id INTEGER NOT NULL);
+332 -58
View File
@@ -1,11 +1,18 @@
import { Hono } from 'hono'
import {
AVATAR_STUDIO_SLOTS,
avatarItemAccessBit,
avatarStudioFlags,
avatarStudioItem,
avatarStudioSave,
canManageRoom,
canManageRoomById,
getRoomById,
getSubRoom,
getSubRoomSaveById,
publicStudioBundleFilename,
publishAvatarSave,
studioBundleR2Key,
} from '@repo/domain'
import {
@@ -16,6 +23,7 @@ import {
import { parseMultipart } from './multipart'
import type { Context } from 'hono'
import type { App } from './context'
export const CLOUD_JOB_SCHEMA = `CREATE TABLE IF NOT EXISTS studio_cloud_build_job (
@@ -23,6 +31,20 @@ export const CLOUD_JOB_SCHEMA = `CREATE TABLE IF NOT EXISTS studio_cloud_build_j
lease_token TEXT, lease_until TEXT, attempts INTEGER NOT NULL DEFAULT 0, created_at TEXT NOT NULL
)`
interface Job {
kind?: 'avatar'
avatarItemId?: string
avatar?: {
itemId: string
outfitType: number
bodyType: number
configuration: string
name: string
createdAt: string
price: number
creatorAccountId: number
canCreateRoomieEquipment: boolean
permittedItemTypes: number[]
}
buildId: string
roomId: number
subRoomId: number
@@ -34,7 +56,8 @@ interface Job {
targets: number[]
manifest: {
Version: number
MainSceneProjectRelativePath: string
MainSceneProjectRelativePath?: string
MainPrefabProjectRelativePath?: string
ExportPartitionVersion: number
Files: Array<{ n: string; p: string; g?: string }>
Exports?: Array<{ e: string; d?: string[] }>
@@ -51,6 +74,7 @@ const ok = (value: unknown) => ({ success: true, error: null, value })
const fail = (error: string) => ({ success: false, error, value: null })
const ID = /^[1-9]\d*$/
const guid = () => crypto.randomUUID()
const textField = (value: unknown, fallback = '') => (typeof value === 'string' ? value : fallback)
interface Artifact {
field: string
unityAssetId: string
@@ -84,7 +108,6 @@ interface CloudBuildRecord {
unity_asset_id: string
created_by_account_id: number
}
const textField = (value: unknown, fallback = '') => (typeof value === 'string' ? value : fallback)
function wire(row: CloudBuildRecord) {
return {
cloudBuildId: row.cloud_build_id,
@@ -116,7 +139,48 @@ async function pendingBuild(db: D1Database, roomId: number, subRoomId: number, s
.bind(roomId, subRoomId, saveId)
.first<CloudBuildRecord>()
}
async function pendingAvatarBuild(db: D1Database, itemId: string, saveId: number) {
return db
.prepare(
"SELECT b.* FROM studio_cloud_build b JOIN studio_avatar_build a USING(cloud_build_id) JOIN studio_cloud_build_job j USING(cloud_build_id) WHERE a.item_id=? AND a.save_id=? AND j.status IN ('queued','building','publishing') LIMIT 1"
)
.bind(itemId, saveId)
.first<CloudBuildRecord>()
}
const app = new Hono<App>()
.get('/cloud-builds/custom-avatar-item/:itemId{[0-9a-fA-F-]{36}}', async (c) => {
const id = await validateAndGetAccountId(c.req.raw, await c.env.JWT_SECRET.get())
if (id === null) return c.body(null, 401)
const item = await avatarStudioItem(c.env.DB, c.req.param('itemId'))
if (!item || item.CreatorAccountId !== id) return c.body(null, 403)
const skip = Math.max(0, Number(c.req.query('skip')) || 0),
take = Math.min(10000, Math.max(1, Number(c.req.query('take')) || 100))
const { results } = await c.env.DB.prepare(
'SELECT b.* FROM studio_cloud_build b JOIN studio_avatar_build a USING(cloud_build_id) WHERE a.item_id=? ORDER BY b.started_at DESC'
)
.bind(item.CustomAvatarItemId)
.all<CloudBuildRecord>()
return c.json({
results: results.slice(skip, skip + take).map(wire),
totalResults: results.length,
})
})
.post(
'/cloud-builds/custom-avatar-item/:itemId{[0-9a-fA-F-]{36}}/save/:saveId{[1-9][0-9]*}',
(c) => enqueueAvatar(c, c.req.param('itemId'), Number(c.req.param('saveId')))
)
.post('/cloud-builds/custom-avatar-item/create-for-new-build-version', async (c) => {
const form = await c.req.parseBody()
const itemId = String(form.customAvatarItemId ?? ''),
assetId = String(form.unityAssetId ?? '')
const row = await c.env.DB.prepare(
'SELECT id FROM avatar_studio_save WHERE item_id=? AND unity_asset_id=? ORDER BY id DESC LIMIT 1'
)
.bind(itemId, assetId)
.first<{ id: number }>()
if (!row) return c.notFound()
return enqueueAvatar(c, itemId, row.id, form)
})
.post('/cloud-builds/create', async (c) => {
const secret = await c.env.JWT_SECRET.get()
const accountId = await validateAndGetAccountId(c.req.raw, secret)
@@ -272,11 +336,16 @@ const app = new Hono<App>()
const form = await c.req.parseBody()
const id = textField(form.cloudBuildId)
const row = await c.env.DB.prepare(
'SELECT room_id FROM studio_cloud_build WHERE cloud_build_id = ?'
'SELECT room_id,created_by_account_id FROM studio_cloud_build WHERE cloud_build_id = ?'
)
.bind(id)
.first<{ room_id: number }>()
if (!row || !(await canManageRoomById(c.env.DB, row.room_id, accountId)))
.first<{ room_id: number; created_by_account_id: number }>()
if (
!row ||
!(row.room_id === 0
? row.created_by_account_id === accountId
: await canManageRoomById(c.env.DB, row.room_id, accountId))
)
return c.json(fail('No accessible build.'), 404)
await c.env.DB.batch([
c.env.DB.prepare(
@@ -325,17 +394,13 @@ const app = new Hono<App>()
})
.post('/internal/cloud-builds/:id/heartbeat', async (c) => {
const { leaseToken } = await c.req.json<{ leaseToken: string }>()
const leaseUntil = new Date(Date.now() + 5 * 60 * 1000).toISOString()
const result = await c.env.DB.prepare(
"UPDATE studio_cloud_build_job SET lease_until=? WHERE cloud_build_id=? AND lease_token=? AND status IN ('building','publishing') AND lease_until>?"
)
.bind(
new Date(Date.now() + 5 * 60 * 1000).toISOString(),
c.req.param('id'),
leaseToken,
new Date().toISOString()
)
.bind(leaseUntil, c.req.param('id'), leaseToken, new Date().toISOString())
.run()
return c.json({ active: result.meta.changes > 0 })
return c.json(result.meta.changes > 0 ? { active: true, leaseUntil } : { active: false })
})
.post('/internal/cloud-builds/:id/finish', async (c) => {
const form = await parseMultipart(c.req.raw)
@@ -359,6 +424,26 @@ const app = new Hono<App>()
])
return c.json(ok(null))
}
if (job.kind === 'avatar') {
const item = await avatarStudioItem(c.env.DB, job.avatarItemId!)
const save = await avatarStudioSave(c.env.DB, job.avatarItemId!, job.saveId)
if (
!item ||
!save ||
item.CreatorAccountId !== job.accountId ||
!((await avatarStudioFlags(c.env.DB, job.accountId)) & avatarItemAccessBit(item.OutfitType))
) {
await c.env.DB.batch([
c.env.DB.prepare(
"UPDATE studio_cloud_build_job SET status='failed',lease_token=NULL WHERE cloud_build_id=? AND lease_token=? AND status='building'"
).bind(id, lease.lease_token),
c.env.DB.prepare(
"UPDATE studio_cloud_build SET completed_at=?,error='Avatar source or authorization was removed.' WHERE cloud_build_id=? AND completed_at=''"
).bind(new Date().toISOString(), id),
])
return c.json(fail('Avatar source or authorization was removed.'), 409)
}
}
const assets = new Set([job.unityAssetId, ...job.exports.map((item) => item.u)])
let entries: unknown
try {
@@ -368,7 +453,10 @@ const app = new Hono<App>()
}
if (!Array.isArray(entries) || !entries.every(isArtifact))
return c.json(fail('Invalid build artifact metadata.'), 400)
if (!entries.length || entries.length > assets.size * job.targets.length * 2)
if (
!entries.length ||
entries.length > assets.size * job.targets.length * (job.kind === 'avatar' ? 3 : 2)
)
return c.json(fail('Missing build artifacts.'), 400)
const seen = new Set<string>()
for (const entry of entries) {
@@ -377,8 +465,10 @@ const app = new Hono<App>()
if (
!assets.has(entry.unityAssetId) ||
!job.targets.includes(entry.target) ||
!['main', 'stripped'].includes(entry.kind) ||
entry.version !== 5 ||
!(job.kind === 'avatar' ? ['main', 'stripped', 'material'] : ['main', 'stripped']).includes(
entry.kind
) ||
entry.version !== (job.kind === 'avatar' && entry.kind === 'material' ? 6 : 5) ||
!(file instanceof File) ||
file.size === 0 ||
seen.has(key)
@@ -390,6 +480,11 @@ const app = new Hono<App>()
for (const target of job.targets)
if (!seen.has(`${asset}:${target}:main`))
return c.json(fail('A required asset bundle is missing.'), 400)
if (
job.kind === 'avatar' &&
job.targets.some((target) => !seen.has(`${job.unityAssetId}:${target}:material`))
)
return c.json(fail('An avatar material bundle is missing.'), 400)
const writes: D1PreparedStatement[] = []
for (const entry of entries) {
const file = form[entry.field] as File
@@ -416,8 +511,11 @@ const app = new Hono<App>()
entry.target === 2 ? 'android' : 'windows',
entry.kind
)
const filename = baseFilename.replace('.assetbundle', `.${lease.lease_token}.assetbundle`)
const key = studioBundleR2Key(filename)
const filename =
job.kind === 'avatar'
? `${entry.target === 2 ? 'quest/' : ''}studio-avatar/${job.avatarItemId}/${entry.unityAssetId}/${lease.lease_token}.${entry.kind}.assetbundle`
: baseFilename.replace('.assetbundle', `.${lease.lease_token}.assetbundle`)
const key = job.kind === 'avatar' ? `avatar/${filename}` : studioBundleR2Key(filename)
await c.env.CDN_ASSETS.put(key, bytes)
keys.push(key)
writes.push(
@@ -439,48 +537,52 @@ const app = new Hono<App>()
await c.env.CDN_ASSETS.put(serverDataKey, JSON.stringify({ Exports: job.exports }))
keys.push(serverDataKey)
// The exact submitted save is linked; a newer draft must never be replaced.
writes.push(
c.env.DB.prepare(
"UPDATE subroom_save SET data=json_set(data,'$.UnityAssetId',?,'$.ServerData',json(?)) WHERE sub_room_data_save_id=? AND sub_room_id=? AND EXISTS (SELECT 1 FROM studio_cloud_build_job WHERE cloud_build_id=? AND lease_token=? AND status='publishing')"
).bind(
job.unityAssetId,
JSON.stringify({ Exports: job.exports }),
job.saveId,
job.subRoomId,
id,
lease.lease_token
)
)
writes.push(
c.env.DB.prepare(
`UPDATE room SET data=json_set(data,'$.BecameRRStudioRoomAt',?)
WHERE room_id=? AND json_type(data,'$.BecameRRStudioRoomAt') IS NOT 'text'
AND EXISTS (SELECT 1 FROM studio_cloud_build_job WHERE cloud_build_id=? AND lease_token=? AND status='publishing')`
).bind(new Date().toISOString(), job.roomId, id, lease.lease_token)
)
if (job.autoPublish) {
// Test and update the staging slot in the same statement. A draft saved
// during compilation must not be replaced by this build's older save.
if (job.kind !== 'avatar')
writes.push(
c.env.DB.prepare(
`UPDATE subroom SET current_save_id=?,
staged_save_id=CASE WHEN staged_save_id=? THEN NULL ELSE staged_save_id END
WHERE room_id=? AND sub_room_id=? AND COALESCE(staged_save_id,current_save_id)=?
AND EXISTS (SELECT 1 FROM subroom_save WHERE sub_room_data_save_id=? AND sub_room_id=?)
AND EXISTS (SELECT 1 FROM studio_cloud_build_job WHERE cloud_build_id=? AND lease_token=? AND status='publishing')`
"UPDATE subroom_save SET data=json_set(data,'$.UnityAssetId',?,'$.ServerData',json(?)) WHERE sub_room_data_save_id=? AND sub_room_id=? AND EXISTS (SELECT 1 FROM studio_cloud_build_job WHERE cloud_build_id=? AND lease_token=? AND status='publishing')"
).bind(
job.saveId,
job.saveId,
job.roomId,
job.subRoomId,
job.saveId,
job.unityAssetId,
JSON.stringify({ Exports: job.exports }),
job.saveId,
job.subRoomId,
id,
lease.lease_token
)
)
if (job.kind !== 'avatar') {
writes.push(
c.env.DB.prepare(
`UPDATE room SET data=json_set(data,'$.BecameRRStudioRoomAt',?)
WHERE room_id=? AND json_type(data,'$.BecameRRStudioRoomAt') IS NOT 'text'
AND EXISTS (SELECT 1 FROM studio_cloud_build_job WHERE cloud_build_id=? AND lease_token=? AND status='publishing')`
).bind(new Date().toISOString(), job.roomId, id, lease.lease_token)
)
if (job.autoPublish) {
// Test and update the staging slot in the same statement. A draft saved
// during compilation must not be replaced by this build's older save.
writes.push(
c.env.DB.prepare(
`UPDATE subroom SET current_save_id=?,
staged_save_id=CASE WHEN staged_save_id=? THEN NULL ELSE staged_save_id END
WHERE room_id=? AND sub_room_id=? AND COALESCE(staged_save_id,current_save_id)=?
AND EXISTS (SELECT 1 FROM subroom_save WHERE sub_room_data_save_id=? AND sub_room_id=?)
AND EXISTS (SELECT 1 FROM studio_cloud_build_job WHERE cloud_build_id=? AND lease_token=? AND status='publishing')`
).bind(
job.saveId,
job.saveId,
job.roomId,
job.subRoomId,
job.saveId,
job.saveId,
job.subRoomId,
id,
lease.lease_token
)
)
}
}
writes.push(
c.env.DB.prepare(
"UPDATE studio_cloud_build SET completed_at=?,error=NULL WHERE cloud_build_id=? AND EXISTS (SELECT 1 FROM studio_cloud_build_job WHERE cloud_build_id=? AND lease_token=? AND status='publishing')"
@@ -491,20 +593,42 @@ const app = new Hono<App>()
"UPDATE studio_cloud_build_job SET status='completed',lease_token=NULL WHERE cloud_build_id=? AND lease_token=? AND status='publishing'"
).bind(id, lease.lease_token)
)
// Recheck the lease and target inside the publication transaction. R2
// uploads can outlast a lease, and a room can be deleted while compiling.
// Recheck authority, lease and target inside the final publication transaction.
const publicationTime = new Date().toISOString()
const roomRow =
job.kind !== 'avatar'
? await c.env.DB.prepare('SELECT data FROM room WHERE room_id=?')
.bind(job.roomId)
.first<{ data: string }>()
: null
const authorizedRoom =
roomRow && canManageRoom(JSON.parse(roomRow.data), job.accountId) ? roomRow.data : null
const targetExists =
job.kind === 'avatar'
? `SELECT 1 FROM avatar_studio_save s JOIN custom_avatar_item i ON i.custom_avatar_item_id=s.item_id
JOIN avatar_studio_access a ON a.account_id=json_extract(i.data,'$.CreatorAccountId')
WHERE s.id=? AND s.item_id=? AND s.unity_asset_id=? AND a.account_id=? AND (a.flags & ?)<>0`
: `SELECT 1 FROM subroom_save s JOIN subroom r ON r.sub_room_id=s.sub_room_id
JOIN room ON room.room_id=r.room_id
WHERE s.sub_room_data_save_id=? AND s.sub_room_id=? AND r.room_id=? AND room.data=?`
const targetBindings =
job.kind === 'avatar'
? [
job.saveId,
job.avatarItemId!,
job.unityAssetId,
job.accountId,
avatarItemAccessBit(job.avatar!.outfitType),
]
: [job.saveId, job.subRoomId, job.roomId, authorizedRoom]
const results = await c.env.DB.batch([
c.env.DB.prepare(
`UPDATE studio_cloud_build_job SET status='failed',lease_token=NULL
WHERE cloud_build_id=? AND lease_token=? AND status='publishing'
AND (lease_until<=? OR NOT EXISTS (
SELECT 1 FROM subroom_save s JOIN subroom r ON r.sub_room_id=s.sub_room_id
JOIN room ON room.room_id=r.room_id
WHERE s.sub_room_data_save_id=? AND s.sub_room_id=? AND r.room_id=?))`
).bind(id, lease.lease_token, publicationTime, job.saveId, job.subRoomId, job.roomId),
AND (lease_until<=? OR NOT EXISTS (${targetExists}))`
).bind(id, lease.lease_token, publicationTime, ...targetBindings),
c.env.DB.prepare(
`UPDATE studio_cloud_build SET completed_at=?,error='Build lease expired or target save removed.'
`UPDATE studio_cloud_build SET completed_at=?,error='Build lease expired or target authorization/save removed.'
WHERE cloud_build_id=? AND completed_at='' AND EXISTS (
SELECT 1 FROM studio_cloud_build_job WHERE cloud_build_id=? AND status='failed')`
).bind(publicationTime, id, id),
@@ -512,7 +636,7 @@ const app = new Hono<App>()
])
if (!results[results.length - 1].meta.changes) {
await Promise.all(keys.map((key) => c.env.CDN_ASSETS.delete(key)))
return c.json(fail('Build lease expired or target save removed.'), 409)
return c.json(fail('Build lease expired or target authorization/save removed.'), 409)
}
} catch (error) {
await Promise.all(keys.map((key) => c.env.CDN_ASSETS.delete(key)))
@@ -523,6 +647,156 @@ const app = new Hono<App>()
.run()
throw error
}
if (job.kind === 'avatar') {
const item = await avatarStudioItem(c.env.DB, job.avatarItemId!)
const save = await avatarStudioSave(c.env.DB, job.avatarItemId!, job.saveId)
if (
item &&
save &&
(job.autoPublish || (item.Accessibility === 0 && save.update_current === 1))
) {
await publishAvatarSave(c.env.DB, item.CustomAvatarItemId, save.id, true)
}
return c.json(ok(null))
}
return c.json(ok(null))
})
export default app
async function enqueueAvatar(
c: Context<App>,
itemId: string,
saveId: number,
supplied?: Record<string, unknown>
) {
const accountId = await validateAndGetAccountId(c.req.raw, await c.env.JWT_SECRET.get())
if (accountId === null) return c.body(null, 401)
const item = await avatarStudioItem(c.env.DB, itemId),
save = await avatarStudioSave(c.env.DB, itemId, saveId)
if (!item || !save) return c.notFound()
if (item.OutfitType === 1000)
return c.json(fail('Badge images publish directly and do not require a Unity build.'), 400)
const roles = await validateAndGetRoles(c.req.raw, await c.env.JWT_SECRET.get())
if (
item.CreatorAccountId !== accountId ||
!roles?.includes('betastudio') ||
!((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)
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)
if (!object || object.size > 8 * 1024 * 1024)
return c.json(fail('Missing or oversized avatar source manifest.'), 400)
let manifest: Job['manifest']
try {
manifest = await object.json<Job['manifest']>()
} catch {
return c.json(fail('Invalid source manifest JSON.'), 400)
}
const main = manifest?.MainPrefabProjectRelativePath
if (
!manifest ||
manifest.Version !== 3 ||
typeof main !== 'string' ||
!main.startsWith('Assets/') ||
!main.endsWith('.prefab') ||
!Array.isArray(manifest.Files) ||
!manifest.Files.length ||
manifest.Files.length > 100000 ||
!manifest.Files.some((f) => f?.p === main) ||
manifest.Files.some(
(f) =>
!f ||
typeof f.p !== 'string' ||
!/^(Assets|Packages)\//.test(f.p) ||
f.p.includes('\\') ||
f.p.includes(':') ||
f.p
.split('/')
.some(
(p) => p === '..' || p === '.' || p === '' || p.toLowerCase().endsWith('.bundle')
) ||
/\.(cs|dll|exe|so|dylib|rsp|asmdef|asmref)$/i.test(f.p) ||
!/^[a-z0-9]{40}$/.test(f.n)
) ||
new Set(manifest.Files.map((f) => f.p.toLowerCase())).size !== manifest.Files.length
)
return c.json(fail('Invalid avatar source file manifest.'), 400)
const form = supplied ?? (await c.req.parseBody())
if (form.pipelineVersion !== undefined && String(form.pipelineVersion) !== '2')
return c.json(fail('Unsupported avatar build pipeline.'), 400)
const targets = (c.env.CLOUD_BUILD_TARGETS ?? '0,2').split(',').map(Number)
if (!targets.length || targets.some((t) => t !== 0 && t !== 2))
return c.json(fail('Invalid build targets.'), 503)
const access = await avatarStudioFlags(c.env.DB, accountId)
const permittedItemTypes = AVATAR_STUDIO_SLOTS.filter(
(slot) =>
(access & avatarItemAccessBit(slot)) !== 0 &&
(![104, 203].includes(slot) || roles.includes('developer'))
)
const job: Job = {
kind: 'avatar',
avatarItemId: itemId,
avatar: {
itemId,
outfitType: item.OutfitType,
bodyType: save.body_type,
configuration: save.configuration,
name: String(item.Name ?? ''),
createdAt: save.created_at,
price: Number(item.Price ?? 0),
creatorAccountId: accountId,
canCreateRoomieEquipment: roles.includes('developer'),
permittedItemTypes,
},
buildId: numericId(),
roomId: 0,
subRoomId: 0,
saveId,
accountId,
unityAssetId: save.unity_asset_id,
autoPublish: textField(form.autoPublish).toLowerCase() === 'true',
templateVersion: Number(c.env.CLOUD_BUILD_TEMPLATE_VERSION ?? 169),
targets,
manifest,
room: null,
exports: [],
}
const now = new Date().toISOString()
// As for rooms, reserve the build and its queue/link rows in one transaction.
const created = await c.env.DB.batch([
c.env.DB.prepare(
`INSERT INTO studio_cloud_build(cloud_build_id,room_id,sub_room_id,sub_room_data_save_id,unity_asset_id,created_by_account_id,started_at,completed_at,error)
SELECT ?,0,0,?,?,?,?,'',NULL WHERE NOT EXISTS (
SELECT 1 FROM studio_avatar_build a JOIN studio_cloud_build_job j USING(cloud_build_id)
WHERE a.item_id=? AND a.save_id=? AND j.status IN ('queued','building','publishing'))`
).bind(job.buildId, saveId, job.unityAssetId, accountId, now, itemId, saveId),
c.env.DB.prepare(
'INSERT INTO studio_avatar_build(cloud_build_id,item_id,save_id) SELECT ?,?,? WHERE EXISTS (SELECT 1 FROM studio_cloud_build WHERE cloud_build_id=?)'
).bind(job.buildId, itemId, saveId, job.buildId),
c.env.DB.prepare(
'INSERT INTO studio_cloud_build_job(cloud_build_id,payload,created_at) SELECT ?,?,? WHERE EXISTS (SELECT 1 FROM studio_cloud_build WHERE cloud_build_id=?)'
).bind(job.buildId, JSON.stringify(job), now, job.buildId),
])
if (!created[0].meta.changes) {
const existing = await pendingAvatarBuild(c.env.DB, itemId, saveId)
return existing
? c.json(ok(wire(existing)))
: c.json(fail('Build state changed; retry creation.'), 409)
}
return c.json(
ok(
wire({
cloud_build_id: job.buildId,
started_at: now,
completed_at: null,
unity_asset_id: job.unityAssetId,
created_by_account_id: accountId,
error: null,
})
)
)
}
+12 -5
View File
@@ -109,22 +109,29 @@ export async function getUserBuildCount(
const end = new Date(start.getTime() + 60 * 60 * 1000)
const row = await db
.prepare(
`SELECT COUNT(*) AS total,
SUM(CASE WHEN error IS NOT NULL THEN 1 ELSE 0 END) AS failed
`SELECT SUM(CASE WHEN room_id!=0 THEN 1 ELSE 0 END) AS total,
SUM(CASE WHEN room_id!=0 AND error IS NOT NULL THEN 1 ELSE 0 END) AS failed,
SUM(CASE WHEN room_id=0 THEN 1 ELSE 0 END) AS avatar_total,
SUM(CASE WHEN room_id=0 AND error IS NOT NULL THEN 1 ELSE 0 END) AS avatar_failed
FROM studio_cloud_build
WHERE created_by_account_id = ?1
AND started_at >= ?2 AND started_at < ?3`
)
.bind(accountId, start.toISOString(), end.toISOString())
.first<{ total: number | string; failed: number | string | null }>()
.first<{
total: number | string
failed: number | string | null
avatar_total: number | null
avatar_failed: number | null
}>()
return {
accountId,
startTime: start.toISOString(),
endTime: end.toISOString(),
subRoomTotalBuildCount: Number(row?.total ?? 0),
subRoomFailedBuildCount: Number(row?.failed ?? 0),
avatarItemTotalBuildCount: 0,
avatarItemFailedBuildCount: 0,
avatarItemTotalBuildCount: Number(row?.avatar_total ?? 0),
avatarItemFailedBuildCount: Number(row?.avatar_failed ?? 0),
}
}
+72 -1
View File
@@ -3,12 +3,13 @@ import { useWorkersLogger } from 'workers-tagged-logger'
import {
canManageRoomById,
getAccount,
getPlayerIdsInRoomSubRoom,
getRoomCoOwnerIds,
getSubRoom,
} from '@repo/domain'
import { withNotFound, withOnError } from '@repo/hono-helpers'
import { validateAndGetAccountId, validateAndGetRoles } from '@repo/jwt'
import { studioBuildValidationId, validateAndGetAccountId, validateAndGetRoles } from '@repo/jwt'
import cloudBuilds from './cloud-builds'
import distribution from './distribution'
@@ -30,6 +31,13 @@ function positiveId(raw: string | undefined): number | null {
return Number.isSafeInteger(id) ? id : null
}
/** SQLite datetime() is UTC, but its space-separated output is not a .NET JSON DateTime. */
function accessTimestamp(value: string | undefined, fallback: string): string {
const normalized = value?.replace(/^(\d{4}-\d{2}-\d{2}) (\d{2}:\d{2}:\d{2}(?:\.\d+)?)$/, '$1T$2Z')
const time = normalized ? Date.parse(normalized) : NaN
return Number.isFinite(time) ? new Date(time).toISOString() : fallback
}
const BUNDLE_REQUIRED = 'Both a Windows bundle and an Android bundle are required.'
const UPLOAD_FIRST =
'Upload the room before building locally. The latest save is where this build is recorded.'
@@ -168,6 +176,69 @@ const app = new Hono<App>()
.get('/', async (c) => {
return c.text('hello, world!')
})
.get('/access/override/:accountId{[1-9][0-9]*}', async (c) => {
const secret = await c.env.JWT_SECRET.get()
const caller = await validateAndGetAccountId(c.req.raw, secret)
const id = Number(c.req.param('accountId'))
if (!Number.isSafeInteger(id)) return c.body(null, 400)
let roles = await validateAndGetRoles(c.req.raw, secret)
if (caller === null) {
const buildId = await studioBuildValidationId(c.req.raw, secret)
if (!buildId) return c.body(null, 401)
const job = await c.env.DB.prepare(
"SELECT payload FROM studio_cloud_build_job WHERE cloud_build_id=? AND status='building' AND lease_until>?"
)
.bind(buildId, new Date().toISOString())
.first<{ payload: string }>()
if (!job) return c.body(null, 403)
const payload = JSON.parse(job.payload) as {
roomId: number
room?: { Roles?: Array<{ AccountId: number; Role: number }> }
}
if (
!payload.roomId ||
!payload.room?.Roles?.some((r) => r.AccountId === id && [30, 255].includes(r.Role))
)
return c.body(null, 403)
if (!(await getRoomCoOwnerIds(c.env.DB, payload.roomId))?.includes(id))
return c.body(null, 403)
const account = await getAccount(c.env.DB, id)
roles = account?.hasStudio ? ['betastudio'] : []
}
if (
caller !== null &&
caller !== id &&
!roles?.some((r) => r === 'developer' || r === 'moderator')
) {
// The SDK validates a registry against every owner of its room. Allow
// peers to inspect grants only when both currently manage the same room.
const shared = await c.env.DB.prepare(
`SELECT 1 FROM room r WHERE
(r.creator_account_id=?1 OR EXISTS (SELECT 1 FROM json_each(r.data,'$.Roles') role WHERE json_extract(role.value,'$.AccountId')=?1 AND json_extract(role.value,'$.Role') IN (30,255))) AND
(r.creator_account_id=?2 OR EXISTS (SELECT 1 FROM json_each(r.data,'$.Roles') role WHERE json_extract(role.value,'$.AccountId')=?2 AND json_extract(role.value,'$.Role') IN (30,255))) LIMIT 1`
)
.bind(caller, id)
.first()
if (!shared) return c.body(null, 403)
}
// Inspection authorization above belongs to the caller; this grant belongs to the target.
if (caller !== null && caller !== id)
roles = (await getAccount(c.env.DB, id))?.hasStudio ? ['betastudio'] : []
const grant = await c.env.DB.prepare(
'SELECT flags,created_at,modified_at FROM avatar_studio_access WHERE account_id=?'
)
.bind(id)
.first<{ flags: number; created_at: string; modified_at: string }>()
const flags = roles?.includes('betastudio') ? (grant?.flags ?? 0) : 0
const now = new Date().toISOString()
return c.json({
AccountId: id,
StudioAccess: roles?.includes('betastudio') ? 1 : 2,
AvatarStudioAccess: flags,
CreatedAt: accessTimestamp(grant?.created_at, now),
ModifiedAt: accessTimestamp(grant?.modified_at, now),
})
})
// There is no circuit recommendation corpus on a fresh deployment. An empty
// list is the SDK's supported no-recommendations state, not a failed lookup.
.get('/commonQuery/rrstudio/chiprecommendations', (c) => c.json([]))
@@ -0,0 +1,348 @@
import { adminSecretsStore, env, SELF } from 'cloudflare:test'
import { beforeAll, beforeEach, expect, it } from 'vitest'
import {
AVATAR_STUDIO_SCHEMA,
avatarSaveDto,
avatarStudioSave,
publishAvatarSave,
ROOM_SCHEMA_DDL,
STUDIO_CLOUD_BUILD_SCHEMA_DDL,
UNITY_ASSET_SCHEMA_DDL,
} from '@repo/domain'
import { generateToken } from '@repo/jwt'
import { SCHEMA_DDL, toQuestCustomAvatarItem } from '../../../../api/src/custom-avatar-items-db'
import { CLOUD_JOB_SCHEMA } from '../../cloud-builds'
import type { Env } from '../../context'
declare module 'cloudflare:test' {
interface ProvidedEnv extends Env {}
}
const origin = 'https://example.com',
itemId = '11111111-1111-1111-1111-111111111111',
assetId = '22222222-2222-2222-2222-222222222222'
const worker = { Authorization: 'Bearer private-avatar-worker-key' }
async function auth(id = 42, roles = ['betastudio']) {
return {
Authorization: 'Bearer ' + (await generateToken(String(id), 'fixture', 0, 'test-key', roles)),
}
}
const manifest = {
Version: 3,
MainPrefabProjectRelativePath: 'Assets/Hat.prefab',
Files: [{ n: 'a'.repeat(40), p: 'Assets/Hat.prefab' }],
}
beforeAll(async () => {
await adminSecretsStore(env.JWT_SECRET).create('test-key')
env.CLOUD_BUILD_KEY = 'private-avatar-worker-key'
env.CLOUD_BUILD_TARGETS = '0,2'
for (const sql of [
...SCHEMA_DDL,
...ROOM_SCHEMA_DDL,
...AVATAR_STUDIO_SCHEMA,
...STUDIO_CLOUD_BUILD_SCHEMA_DDL,
...UNITY_ASSET_SCHEMA_DDL,
CLOUD_JOB_SCHEMA,
])
await env.DB.prepare(sql).run()
})
beforeEach(async () => {
for (const table of [
'studio_cloud_build_job',
'studio_cloud_build',
'studio_avatar_build',
'unity_asset',
'avatar_studio_save',
'avatar_studio_access',
'custom_avatar_item',
])
await env.DB.prepare('DELETE FROM ' + table).run()
await env.DB.prepare("INSERT INTO avatar_studio_access VALUES (42,1,'now','now')").run()
await env.DB.prepare('INSERT INTO custom_avatar_item(data) VALUES (?)')
.bind(
JSON.stringify({
CustomAvatarItemId: itemId,
CreatorAccountId: 42,
OutfitType: 0,
Accessibility: 0,
Name: 'Hat',
CurrentSaves: [],
})
)
.run()
await env.DB.prepare(
"INSERT INTO avatar_studio_save(id,item_id,body_type,unity_asset_id,source_filename,source_hash,thumbnail,configuration,update_current,created_at) VALUES (1,?,1,?,'source','hash','thumb','{}',1,'2026-10-18')"
)
.bind(itemId, assetId)
.run()
await env.CDN_ASSETS.put('avatarunityasset/source', JSON.stringify(manifest))
})
async function create(id = 42) {
return SELF.fetch(origin + '/cloud-builds/custom-avatar-item/' + itemId + '/save/1', {
method: 'POST',
headers: await auth(id),
body: new URLSearchParams({ pipelineVersion: '2', autoPublish: 'true' }),
})
}
async function claim() {
const r = await SELF.fetch(origin + '/internal/cloud-builds/claim', {
method: 'POST',
headers: worker,
})
return r.json<{ job: { buildId: string; kind: string; manifest: unknown }; leaseToken: string }>()
}
function artifacts(lease: { leaseToken: string }, materialVersion = 6) {
const form = new FormData()
form.set('leaseToken', lease.leaseToken)
const entries = []
for (const target of [0, 2])
for (const kind of ['main', 'material', 'stripped']) {
const field = `bundle${target}${kind}`
form.set(
field,
new File(['UnityFS\0avatar fixture ' + target + kind], field + '.assetbundle')
)
entries.push({
field,
unityAssetId: assetId,
target,
kind,
version: kind === 'material' ? materialVersion : 5,
})
}
form.set('artifacts', JSON.stringify(entries))
return form
}
it('authorizes per-account access and rejects builds from other owners', async () => {
expect((await SELF.fetch(origin + '/access/override/42')).status).toBe(401)
expect(
(await SELF.fetch(origin + '/access/override/42', { headers: await auth(43) })).status
).toBe(403)
expect(
await (await SELF.fetch(origin + '/access/override/42', { headers: await auth() })).json()
).toMatchObject({ AccountId: 42, StudioAccess: 1, AvatarStudioAccess: 1 })
expect(
await (await SELF.fetch(origin + '/access/override/42', { headers: await auth(42, []) })).json()
).toMatchObject({ AvatarStudioAccess: 0 })
expect((await create(43)).status).toBe(403)
})
it('serializes SQLite and ISO access timestamps as SDK-compatible UTC DateTimes', async () => {
await env.DB.prepare(
'UPDATE avatar_studio_access SET flags=15,created_at=?,modified_at=? WHERE account_id=42'
)
.bind('2026-10-19T00:45:00.372Z', '2026-10-19 16:44:56')
.run()
expect(
await (await SELF.fetch(origin + '/access/override/42', { headers: await auth() })).json()
).toMatchObject({
AvatarStudioAccess: 15,
CreatedAt: '2026-10-19T00:45:00.372Z',
ModifiedAt: '2026-10-19T16:44:56.000Z',
})
})
it('builds and publishes prefab revisions with real SDK main/material version distinctions', async () => {
expect((await create()).status).toBe(200)
const lease = await claim()
expect(lease.job.kind).toBe('avatar')
expect(lease.job.manifest).toMatchObject(manifest)
const finish = () => origin + '/internal/cloud-builds/' + lease.job.buildId + '/finish'
expect(
(await SELF.fetch(finish(), { method: 'POST', headers: worker, body: artifacts(lease, 5) }))
.status
).toBe(400)
expect(
(await SELF.fetch(finish(), { method: 'POST', headers: worker, body: artifacts(lease) })).status
).toBe(200)
const row = await env.DB.prepare('SELECT data FROM custom_avatar_item').first<{ data: string }>()
const item = JSON.parse(row!.data)
expect(item.CurrentSaves).toHaveLength(1)
expect(item.CurrentSaves[0]).toMatchObject({
CustomAvatarItemSaveId: 1,
BodyType: 1,
OutfitType: 0,
})
expect(item.CurrentSaves[0].UnityAsset).toMatch(/^studio-avatar\//)
expect(item.CurrentSaves[0].UnityAsset2).toContain('.material.assetbundle')
expect(item.CurrentSaves[0]).not.toHaveProperty('SourceUnityAssetFilename')
const saved = await avatarStudioSave(env.DB, itemId, 1),
dto = await avatarSaveDto(env.DB, saved!)
expect(dto.BakedAssets).toHaveLength(4)
expect(dto.BakedAssets.map((a) => a.Version).sort()).toEqual([5, 5, 6, 6])
const quest = toQuestCustomAvatarItem(item).CurrentSaves[0]
expect(quest.UnityAssetHash).toBe(
dto.BakedAssets.find((a) => a.Target === 2 && a.Version === 5)?.Hash
)
expect(quest.UnityAssetHash).not.toBe(item.CurrentSaves[0].UnityAssetHash)
expect((await env.CDN_ASSETS.get('avatar/' + dto.UnityAsset))?.size).toBeGreaterThan(8)
expect(
(await SELF.fetch(finish(), { method: 'POST', headers: worker, body: artifacts(lease) })).status
).toBe(409)
const count = await SELF.fetch(origin + '/cloud-builds/get-build-count-for-user', {
headers: await auth(),
})
expect(await count.json()).toMatchObject({
value: { subRoomTotalBuildCount: 0, avatarItemTotalBuildCount: 1 },
})
})
it('rejects source code paths and cannot cancel another account’s build', async () => {
await env.CDN_ASSETS.put(
'avatarunityasset/source',
JSON.stringify({ ...manifest, Files: [null] })
)
expect((await create()).status).toBe(400)
await env.CDN_ASSETS.put(
'avatarunityasset/source',
JSON.stringify({
...manifest,
Files: [...manifest.Files, { n: 'a'.repeat(40), p: 'Assets/Code.cs' }],
})
)
expect((await create()).status).toBe(400)
await env.CDN_ASSETS.put('avatarunityasset/source', JSON.stringify(manifest))
await create()
const lease = await claim()
expect(
(
await SELF.fetch(origin + '/cloud-builds/cancel', {
method: 'PUT',
body: new URLSearchParams({ cloudBuildId: lease.job.buildId }),
})
).status
).toBe(401)
expect(
(
await SELF.fetch(origin + '/cloud-builds/cancel', {
method: 'PUT',
headers: await auth(43),
body: new URLSearchParams({ cloudBuildId: lease.job.buildId }),
})
).status
).toBe(404)
expect(
(
await SELF.fetch(origin + '/cloud-builds/cancel', {
method: 'PUT',
headers: await auth(),
body: new URLSearchParams({ cloudBuildId: lease.job.buildId }),
})
).status
).toBe(200)
expect(
(
await SELF.fetch(origin + '/internal/cloud-builds/' + lease.job.buildId + '/finish', {
method: 'POST',
headers: worker,
body: artifacts(lease),
})
).status
).toBe(409)
})
it('does not publish a deleted item or an older revision after a newer save', async () => {
await create()
const lease = await claim()
await env.DB.prepare(
'INSERT INTO avatar_studio_save(item_id,body_type,unity_asset_id,source_filename,source_hash,thumbnail,configuration,created_at) SELECT item_id,body_type,unity_asset_id,source_filename,source_hash,thumbnail,configuration,created_at FROM avatar_studio_save WHERE id=1'
).run()
expect(
(
await SELF.fetch(origin + '/internal/cloud-builds/' + lease.job.buildId + '/finish', {
method: 'POST',
headers: worker,
body: artifacts(lease),
})
).status
).toBe(200)
const item = await env.DB.prepare('SELECT data FROM custom_avatar_item').first<{ data: string }>()
expect(JSON.parse(item!.data).CurrentSaves).toEqual([])
await env.DB.prepare('DELETE FROM studio_cloud_build_job').run()
await env.DB.prepare('DELETE FROM studio_cloud_build').run()
await env.DB.prepare('DELETE FROM studio_avatar_build').run()
await create()
const deleted = await claim()
await env.DB.prepare('DELETE FROM custom_avatar_item').run()
expect(
(
await SELF.fetch(origin + '/internal/cloud-builds/' + deleted.job.buildId + '/finish', {
method: 'POST',
headers: worker,
body: artifacts(deleted),
})
).status
).toBe(409)
})
it('deduplicates concurrent avatar requests and allows a new build after failure', async () => {
const responses = await Promise.all([create(), create(), create()])
const ids = await Promise.all(
responses.map(async (response) => {
expect(response.status).toBe(200)
return (await response.json<{ value: { cloudBuildId: string } }>()).value.cloudBuildId
})
)
expect(new Set(ids).size).toBe(1)
for (const table of ['studio_cloud_build', 'studio_avatar_build', 'studio_cloud_build_job'])
expect(await env.DB.prepare('SELECT COUNT(*) AS n FROM ' + table).first()).toEqual({ n: 1 })
await env.DB.prepare("UPDATE studio_cloud_build_job SET status='failed'").run()
expect(
(await (await create()).json<{ value: { cloudBuildId: string } }>()).value.cloudBuildId
).not.toBe(ids[0])
})
it.each([false, true])(
'checks automatic avatar revision ordering in the write, including CAS retry=%s, preserving explicit restore',
async (retry) => {
await env.DB.prepare("INSERT INTO unity_asset VALUES (?,0,5,'main','old.bundle','hash')")
.bind(assetId)
.run()
let intercepted = false
const DB = new Proxy(env.DB, {
get(target, key) {
if (key === 'prepare')
return (sql: string) => {
const wrap = (statement: D1PreparedStatement): D1PreparedStatement =>
new Proxy(statement, {
get(stmt, method) {
if (method === 'bind') return (...values: unknown[]) => wrap(stmt.bind(...values))
if (method === 'run' && sql.startsWith('UPDATE custom_avatar_item'))
return async () => {
if (!intercepted) {
intercepted = true
await target
.prepare(
"INSERT INTO avatar_studio_save(id,item_id,body_type,unity_asset_id,source_filename,source_hash,thumbnail,configuration,created_at) SELECT 2,item_id,body_type,'new-identity',source_filename,source_hash,thumbnail,configuration,created_at FROM avatar_studio_save WHERE id=1"
)
.run()
if (retry)
await target
.prepare(
"UPDATE custom_avatar_item SET data=json_set(data,'$.Name','Concurrent metadata')"
)
.run()
}
return stmt.run()
}
const value = Reflect.get(stmt, method)
return typeof value === 'function' ? value.bind(stmt) : value
},
})
return wrap(target.prepare(sql))
}
const value = Reflect.get(target, key)
return typeof value === 'function' ? value.bind(target) : value
},
})
expect(await publishAvatarSave(DB, itemId, 1, true)).toBe(false)
expect(intercepted).toBe(true)
let item = JSON.parse(
(await env.DB.prepare('SELECT data FROM custom_avatar_item').first<{ data: string }>())!.data
)
expect(item.CurrentSaves).toEqual([])
expect(await publishAvatarSave(env.DB, itemId, 1)).toBe(true)
item = JSON.parse(
(await env.DB.prepare('SELECT data FROM custom_avatar_item').first<{ data: string }>())!.data
)
expect(item.CurrentSaves[0].CustomAvatarItemSaveId).toBe(1)
if (retry) expect(item.Name).toBe('Concurrent metadata')
}
)
@@ -8,7 +8,7 @@ import {
UNITY_ASSET_SCHEMA_DDL,
} from '@repo/domain'
import { CLOUD_JOB_SCHEMA } from '../../cloud-builds'
import cloudBuilds, { CLOUD_JOB_SCHEMA } from '../../cloud-builds'
import type { Env } from '../../context'
@@ -468,3 +468,138 @@ it('does not complete a build whose submitted save was deleted during compilatio
.run()
}
})
function builtArtifacts(lease: Awaited<ReturnType<typeof claim>>) {
const form = new FormData()
form.set('leaseToken', lease.leaseToken)
form.set('bundle', new File(['UnityFS\0regression'], 'bundle.assetbundle'))
form.set(
'artifacts',
JSON.stringify([
{
field: 'bundle',
unityAssetId: lease.job.unityAssetId,
target: 0,
kind: 'main',
version: 5,
},
])
)
return form
}
function finishBuild(
lease: Awaited<ReturnType<typeof claim>>,
form = builtArtifacts(lease),
bindings = env
) {
return cloudBuilds.request(
origin + `/internal/cloud-builds/${lease.job.buildId}/finish`,
{ method: 'POST', headers: worker, body: form },
bindings
)
}
it.each(['before finish', 'before transaction'])(
'rejects removed room co-owner authority %s without linking or publishing',
async (timing) => {
const prior = await env.DB.prepare('SELECT data FROM room WHERE room_id=3000').first<{
data: string
}>()
const originalSave = await env.DB.prepare(
'SELECT data FROM subroom_save WHERE sub_room_data_save_id=3002'
).first<{ data: string }>()
await env.DB.prepare("UPDATE room SET data=json_set(data,'$.Roles',json(?)) WHERE room_id=3000")
.bind(JSON.stringify([{ AccountId: 43, Role: 30 }]))
.run()
await create(43, true, { autoPublish: 'true' })
const lease = await claim()
const revoke = () =>
env.DB.prepare(
"UPDATE room SET data=json_set(data,'$.Roles',json('[]')) WHERE room_id=3000"
).run()
if (timing === 'before finish') await revoke()
const DB = new Proxy(env.DB, {
get(target, key) {
if (key === 'batch')
return async (statements: D1PreparedStatement[]) => {
await revoke()
return target.batch(statements)
}
const value = Reflect.get(target, key)
return typeof value === 'function' ? value.bind(target) : value
},
})
try {
expect((await finishBuild(lease, builtArtifacts(lease), { ...env, DB })).status).toBe(409)
expect(
await env.DB.prepare(
'SELECT data FROM subroom_save WHERE sub_room_data_save_id=3002'
).first()
).toEqual(originalSave)
expect(
await env.DB.prepare('SELECT unity_asset_id FROM unity_asset WHERE unity_asset_id=?')
.bind(lease.job.unityAssetId)
.first()
).toBeNull()
expect(
await env.DB.prepare('SELECT status FROM studio_cloud_build_job WHERE cloud_build_id=?')
.bind(lease.job.buildId)
.first()
).toEqual({ status: 'failed' })
expect(
await env.CDN_ASSETS.get(
`roomunityasset/studio-builds/${lease.job.buildId}/server-data.json`
)
).toBeNull()
expect(
(await env.CDN_ASSETS.list({ prefix: `room/studio/3000/${lease.job.unityAssetId}` }))
.objects
).toEqual([])
} finally {
await env.DB.prepare('UPDATE room SET data=? WHERE room_id=3000').bind(prior!.data).run()
}
}
)
it.each(['expired lease', 'deleted save'])(
'cleans uploaded artifacts when final publication finds %s',
async (condition) => {
await create()
const lease = await claim()
const save = await env.DB.prepare(
'SELECT data FROM subroom_save WHERE sub_room_data_save_id=3002'
).first<{ data: string }>()
const DB = new Proxy(env.DB, {
get(target, key) {
if (key === 'batch')
return async (statements: D1PreparedStatement[]) => {
if (condition === 'expired lease')
await target
.prepare("UPDATE studio_cloud_build_job SET lease_until='2000-01-01'")
.run()
else
await target
.prepare('DELETE FROM subroom_save WHERE sub_room_data_save_id=3002')
.run()
return target.batch(statements)
}
const value = Reflect.get(target, key)
return typeof value === 'function' ? value.bind(target) : value
},
})
try {
expect((await finishBuild(lease, builtArtifacts(lease), { ...env, DB })).status).toBe(409)
expect(
await env.DB.prepare('SELECT unity_asset_id FROM unity_asset WHERE unity_asset_id=?')
.bind(lease.job.unityAssetId)
.first()
).toBeNull()
expect(
await env.CDN_ASSETS.get(
`roomunityasset/studio-builds/${lease.job.buildId}/server-data.json`
)
).toBeNull()
} finally {
if (condition === 'deleted save')
await env.DB.prepare('INSERT INTO subroom_save VALUES (3002,3001,?)').bind(save!.data).run()
}
}
)
@@ -0,0 +1,108 @@
import { adminSecretsStore, env, SELF } from 'cloudflare:test'
import { beforeAll, beforeEach, expect, it } from 'vitest'
import { SCHEMA_DDL as ACCOUNT_SCHEMA, AVATAR_STUDIO_SCHEMA, ROOM_SCHEMA_DDL } from '@repo/domain'
import { generateStudioBuildValidationToken, generateToken } from '@repo/jwt'
import { CLOUD_JOB_SCHEMA } from '../../cloud-builds'
import type { Env } from '../../context'
declare module 'cloudflare:test' {
interface ProvidedEnv extends Env {}
}
const base = 'https://example.com/access/override/'
beforeAll(async () => {
await adminSecretsStore(env.JWT_SECRET).create('test-key')
for (const sql of [
...ACCOUNT_SCHEMA,
...ROOM_SCHEMA_DDL,
...AVATAR_STUDIO_SCHEMA,
CLOUD_JOB_SCHEMA,
])
await env.DB.prepare(sql).run()
await env.DB.prepare('INSERT INTO account(data) VALUES (?)')
.bind(JSON.stringify({ accountId: 42, username: 'Fixture', hasStudio: true }))
.run()
await env.DB.prepare('INSERT INTO room(data) VALUES (?)')
.bind(
JSON.stringify({ RoomId: 3000, CreatorAccountId: 42, Roles: [{ AccountId: 42, Role: 255 }] })
)
.run()
await env.DB.prepare("INSERT INTO avatar_studio_access VALUES (42,8,'now','now')").run()
})
beforeEach(async () => {
await env.DB.prepare('DELETE FROM studio_cloud_build_job').run()
await env.DB.prepare(
'INSERT INTO studio_cloud_build_job(cloud_build_id,payload,status,lease_until,created_at) VALUES (?,?,?,?,?)'
)
.bind(
'123',
JSON.stringify({ roomId: 3000, room: { Roles: [{ AccountId: 42, Role: 255 }] } }),
'building',
new Date(Date.now() + 60000).toISOString(),
'now'
)
.run()
})
async function request(id: number, build = '123') {
return SELF.fetch(base + id, {
headers: {
Authorization: 'Bearer ' + (await generateStudioBuildValidationToken(build, 'test-key')),
},
})
}
it('allows read-only consumable validation only for an active job’s room owners', async () => {
expect(await (await request(42)).json()).toMatchObject({
AccountId: 42,
StudioAccess: 1,
AvatarStudioAccess: 8,
})
expect((await request(43)).status).toBe(403)
expect((await request(42, '124')).status).toBe(403)
expect((await SELF.fetch(base + '42')).status).toBe(401)
})
it('rejects cancelled and expired jobs', async () => {
await env.DB.prepare("UPDATE studio_cloud_build_job SET status='cancelled'").run()
expect((await request(42)).status).toBe(403)
await env.DB.prepare(
"UPDATE studio_cloud_build_job SET status='building',lease_until='2000-01-01'"
).run()
expect((await request(42)).status).toBe(403)
})
it('lets co-owners validate each other’s grants without exposing unrelated accounts', async () => {
await env.DB.prepare("UPDATE room SET data=json_set(data,'$.Roles',json(?)) WHERE room_id=3000")
.bind(
JSON.stringify([
{ AccountId: 42, Role: 255 },
{ AccountId: 43, Role: 30 },
])
)
.run()
const headers = {
Authorization:
'Bearer ' + (await generateToken('43', 'fixture', 0, 'test-key', ['betastudio'])),
}
expect(await (await SELF.fetch(base + '42', { headers })).json()).toMatchObject({
AvatarStudioAccess: 8,
})
expect((await SELF.fetch(base + '44', { headers })).status).toBe(403)
})
it('uses target entitlement for staff inspections in both directions', async () => {
await env.DB.prepare('INSERT INTO account(data) VALUES (?)')
.bind(JSON.stringify({ accountId: 44, username: 'Disabled', hasStudio: false }))
.run()
await env.DB.prepare("INSERT INTO avatar_studio_access VALUES (44,15,'now','now')").run()
const staff = async (roles: string[]) => ({
Authorization: 'Bearer ' + (await generateToken('99', 'staff', 0, 'test-key', roles)),
})
expect(
await (
await SELF.fetch(base + '44', { headers: await staff(['developer', 'betastudio']) })
).json()
).toMatchObject({ AccountId: 44, StudioAccess: 2, AvatarStudioAccess: 0 })
expect(
await (await SELF.fetch(base + '42', { headers: await staff(['moderator']) })).json()
).toMatchObject({ AccountId: 42, StudioAccess: 1, AvatarStudioAccess: 8 })
})