[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
@@ -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'
)
})