diff --git a/apps/api/src/api.app.ts b/apps/api/src/api.app.ts index a209cee..67c17c1 100644 --- a/apps/api/src/api.app.ts +++ b/apps/api/src/api.app.ts @@ -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({ strict: false }) .route('/', configRoutes) .route('/', socialRoutes) .route('/', progressionRoutes) + .route('/', avatarStudioRoutes) .route('/', avatarRoutes) .route('/', gameplayRoutes) .route('/', eventRoutes) diff --git a/apps/api/src/custom-avatar-items-db.ts b/apps/api/src/custom-avatar-items-db.ts index dd65f2a..f30e0df 100644 --- a/apps/api/src/custom-avatar-items-db.ts +++ b/apps/api/src/custom-avatar-items-db.ts @@ -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, })), } } diff --git a/apps/api/src/routes/avatar-studio.ts b/apps/api/src/routes/avatar-studio.ts new file mode 100644 index 0000000..769b57d --- /dev/null +++ b/apps/api/src/routes/avatar-studio.ts @@ -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, 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() + .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 + 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>().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() + 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>().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 + 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() + 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)) + }) diff --git a/apps/api/src/test/integration/avatar-studio.test.ts b/apps/api/src/test/integration/avatar-studio.test.ts new file mode 100644 index 0000000..2271b81 --- /dev/null +++ b/apps/api/src/test/integration/avatar-studio.test.ts @@ -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' + ) +}) diff --git a/apps/cdn/src/cdn.app.ts b/apps/cdn/src/cdn.app.ts index 498c292..eeb2e11 100644 --- a/apps/cdn/src/cdn.app.ts +++ b/apps/cdn/src/cdn.app.ts @@ -362,6 +362,13 @@ const app = new Hono() (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() * 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 { +async function unityAssetR2Key(env: StudioAssetEnv, filename: string): Promise { 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) { 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 { 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 }) diff --git a/apps/cdn/src/test/integration/api.test.ts b/apps/cdn/src/test/integration/api.test.ts index e3079cf..829107c 100644 --- a/apps/cdn/src/test/integration/api.test.ts +++ b/apps/cdn/src/test/integration/api.test.ts @@ -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( diff --git a/apps/rooms/src/test/integration/api.test.ts b/apps/rooms/src/test/integration/api.test.ts index cfc174d..5a6d473 100644 --- a/apps/rooms/src/test/integration/api.test.ts +++ b/apps/rooms/src/test/integration/api.test.ts @@ -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') +}) diff --git a/apps/storage/src/storage.app.ts b/apps/storage/src/storage.app.ts index b5695ac..40b469d 100644 --- a/apps/storage/src/storage.app.ts +++ b/apps/storage/src/storage.app.ts @@ -56,6 +56,8 @@ const UPLOAD_SUBFOLDER: Record = { 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() 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 diff --git a/apps/storage/src/test/integration/api.test.ts b/apps/storage/src/test/integration/api.test.ts index 03c4cb4..a91228e 100644 --- a/apps/storage/src/test/integration/api.test.ts +++ b/apps/storage/src/test/integration/api.test.ts @@ -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 = '' diff --git a/apps/studio/README.md b/apps/studio/README.md index 8fab3cf..1cdeb62 100644 --- a/apps/studio/README.md +++ b/apps/studio/README.md @@ -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. diff --git a/apps/studio/migrations/0004_avatar_studio.sql b/apps/studio/migrations/0004_avatar_studio.sql new file mode 100644 index 0000000..a391149 --- /dev/null +++ b/apps/studio/migrations/0004_avatar_studio.sql @@ -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); diff --git a/apps/studio/src/cloud-builds.ts b/apps/studio/src/cloud-builds.ts index f9300ef..a96c13e 100644 --- a/apps/studio/src/cloud-builds.ts +++ b/apps/studio/src/cloud-builds.ts @@ -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() } +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() +} const app = new Hono() + .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() + 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() 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() }) .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() ]) 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() } 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() for (const entry of entries) { @@ -377,8 +465,10 @@ const app = new Hono() 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() 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() 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() 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() "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() ]) 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() .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, + itemId: string, + saveId: number, + supplied?: Record +) { + 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() + } 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, + }) + ) + ) +} diff --git a/apps/studio/src/local-builds.ts b/apps/studio/src/local-builds.ts index 6d0aada..3aa8107 100644 --- a/apps/studio/src/local-builds.ts +++ b/apps/studio/src/local-builds.ts @@ -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), } } diff --git a/apps/studio/src/studio.app.ts b/apps/studio/src/studio.app.ts index 295c57a..d4aae3c 100644 --- a/apps/studio/src/studio.app.ts +++ b/apps/studio/src/studio.app.ts @@ -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() .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([])) diff --git a/apps/studio/src/test/integration/avatar-builds.test.ts b/apps/studio/src/test/integration/avatar-builds.test.ts new file mode 100644 index 0000000..2f2b92d --- /dev/null +++ b/apps/studio/src/test/integration/avatar-builds.test.ts @@ -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') + } +) diff --git a/apps/studio/src/test/integration/cloud-builds.test.ts b/apps/studio/src/test/integration/cloud-builds.test.ts index bb6c214..9beeda1 100644 --- a/apps/studio/src/test/integration/cloud-builds.test.ts +++ b/apps/studio/src/test/integration/cloud-builds.test.ts @@ -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>) { + 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>, + 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() + } + } +) diff --git a/apps/studio/src/test/integration/consumable-access.test.ts b/apps/studio/src/test/integration/consumable-access.test.ts new file mode 100644 index 0000000..210052c --- /dev/null +++ b/apps/studio/src/test/integration/consumable-access.test.ts @@ -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 }) +}) diff --git a/packages/domain/src/avatar-studio.ts b/packages/domain/src/avatar-studio.ts new file mode 100644 index 0000000..7f682db --- /dev/null +++ b/packages/domain/src/avatar-studio.ts @@ -0,0 +1,148 @@ +/** Shared persisted Studio authoring state. Existing storefront items remain unchanged. */ +export const AVATAR_STUDIO_SCHEMA = [ + `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)`, +] + +export interface AvatarStudioItem { + CustomAvatarItemId: string + CreatorAccountId: number + OutfitType: number + Accessibility: number + CurrentSaves: Array> + [key: string]: unknown +} +export interface AvatarStudioSave { + id: number + item_id: string + body_type: number + unity_asset_id: string + source_filename: string + source_hash: string + thumbnail: string + moderation_image: string | null + configuration: string + description: string | null + update_current: number + created_at: string +} +export async function avatarStudioFlags(db: D1Database, accountId: number): Promise { + const row = await db + .prepare('SELECT flags FROM avatar_studio_access WHERE account_id=?') + .bind(accountId) + .first<{ flags: number }>() + return row?.flags ?? 0 +} +export const AVATAR_STUDIO_SLOTS = [ + 0, 2, 3, 10, 20, 100, 101, 102, 103, 104, 200, 203, 300, 301, 500, 501, 502, 503, 504, 505, 506, + 507, 508, 509, 510, 511, 512, 1000, +] +export function avatarItemAccessBit(outfitType: number): number { + return outfitType === 1000 ? 4 : outfitType >= 500 && outfitType <= 512 ? 2 : 1 +} +export async function avatarStudioItem( + db: D1Database, + id: string +): Promise { + const row = await db + .prepare('SELECT data FROM custom_avatar_item WHERE custom_avatar_item_id=?') + .bind(id) + .first<{ data: string }>() + return row ? (JSON.parse(row.data) as AvatarStudioItem) : null +} +export async function avatarStudioSave( + db: D1Database, + itemId: string, + id: number +): Promise { + return db + .prepare('SELECT * FROM avatar_studio_save WHERE item_id=? AND id=?') + .bind(itemId, id) + .first() +} +export async function avatarSaveDto(db: D1Database, row: AvatarStudioSave, target = 0) { + const { results } = await db + .prepare( + 'SELECT unity_asset_id AS UnityAssetId,target AS Target,version AS Version,filename AS Filename,hash AS Hash,kind FROM unity_asset WHERE unity_asset_id=?' + ) + .bind(row.unity_asset_id) + .all<{ + UnityAssetId: string + Target: number + Version: number + Filename: string + Hash: string + kind: string + }>() + const main = results.find((a) => a.Target === target && a.kind === 'main') + const material = results.find((a) => a.Target === target && a.kind === 'material') + return { + CustomAvatarItemSaveId: row.id, + CustomAvatarItemId: row.item_id, + UnityAssetId: row.unity_asset_id, + BodyType: row.body_type, + CreatedAt: row.created_at, + ModifiedAt: row.created_at, + Description: row.description, + ThumbnailFileName: row.thumbnail, + AdditionalConfiguration: row.configuration, + SourceUnityAssetFilename: row.source_filename, + SourceUnityAssetHash: row.source_hash, + ModerationImageFileName: row.moderation_image, + UnityAsset: main?.Filename ?? null, + UnityAssetHash: main?.Hash ?? null, + UnityAsset2: material?.Filename ?? null, + UnityAsset2Hash: material?.Hash ?? null, + BakedAssets: results.filter((a) => a.kind !== 'stripped').map(({ kind: _kind, ...a }) => a), + } +} +/** Publish exactly this built revision for its body type, preserving other body types. */ +export async function publishAvatarSave( + db: D1Database, + itemId: string, + saveId: number, + automatic = false, + attempt = 0 +): Promise { + const item = await avatarStudioItem(db, itemId) + const save = await avatarStudioSave(db, itemId, saveId) + if (!item || !save) return false + const dto = await avatarSaveDto(db, save) + // Badge images are uploaded and published directly by the SDK, without a Unity build. + if (!dto.UnityAsset && item.OutfitType !== 1000) return false + const quest = await avatarSaveDto(db, save, 2) + const { + SourceUnityAssetFilename: _source, + SourceUnityAssetHash: _hash, + BakedAssets: _baked, + ...published + } = dto + // JSON mutation is conditional on the complete prior snapshot: concurrent body-type + // publications cannot overwrite each other or resurrect a deleted item. + const prior = JSON.stringify(item) + item.CurrentSaves = [ + ...(item.CurrentSaves ?? []).filter((s) => s.BodyType !== save.body_type), + { + ...published, + OutfitType: item.OutfitType, + QuestUnityAssetHash: quest.UnityAssetHash, + QuestUnityAsset2Hash: quest.UnityAsset2Hash, + }, + ] + item.ModifiedAt = new Date().toISOString() + item.ThumbnailImageFilename = save.thumbnail + if (item.OutfitType === 1000) + item.CustomBadgeMetadata = { RoomId: JSON.parse(save.configuration)?.d?.d?.r ?? 0 } + const result = await db + .prepare( + `UPDATE custom_avatar_item SET data=? WHERE custom_avatar_item_id=? AND json(data)=json(?) + AND (?=0 OR ?=(SELECT MAX(id) FROM avatar_studio_save WHERE item_id=? AND body_type=?))` + ) + .bind(JSON.stringify(item), itemId, prior, automatic ? 1 : 0, saveId, itemId, save.body_type) + .run() + if (!result.meta.changes && attempt < 5 && (await avatarStudioItem(db, itemId))) + return publishAvatarSave(db, itemId, saveId, automatic, attempt + 1) + return result.meta.changes > 0 +} diff --git a/packages/domain/src/index.ts b/packages/domain/src/index.ts index 42a98b4..0e733d6 100644 --- a/packages/domain/src/index.ts +++ b/packages/domain/src/index.ts @@ -31,3 +31,4 @@ export * from './outfits-db' export * from './progression-db' export * from './relationships-db' export * from './validation' +export * from './avatar-studio' diff --git a/packages/domain/src/rooms-db.ts b/packages/domain/src/rooms-db.ts index fb034b9..6cc985e 100644 --- a/packages/domain/src/rooms-db.ts +++ b/packages/domain/src/rooms-db.ts @@ -2450,7 +2450,7 @@ const serializeSubRoom = (sub: SubRoom, roomId: number): string => { * it. */ const serializeRoom = (room: Room): string => { - const { SubRooms: _subRooms, Tags: _tags, Stats: stats, ...rest } = room + const { SubRooms: _subRooms, Tags: _tags, Badges: _badges, Stats: stats, ...rest } = room return JSON.stringify({ ...rest, Stats: storedStats(stats) }) } @@ -2842,10 +2842,33 @@ async function hydrateRooms(db: D1Database, rooms: Room[]): Promise { // game's Nullable decoder and discards the entire owned-room list. if (room.PublishedAt === '') room.PublishedAt = null } - await Promise.all([attachSubRooms(db, rooms), attachTags(db, rooms)]) + await Promise.all([attachSubRooms(db, rooms), attachTags(db, rooms), attachBadges(db, rooms)]) return rooms } +/** Published room-linked image badges; drafts remain on the creator's Studio shelf. */ +async function attachBadges(db: D1Database, rooms: Room[]): Promise { + if (!rooms.length) return + // API owns this table's migration; room deployments can precede that migration. + const exists = await db + .prepare("SELECT 1 FROM sqlite_master WHERE type='table' AND name='custom_avatar_item'") + .first() + if (!exists) { + for (const room of rooms) room.Badges = [] + return + } + const rows = await db + .prepare( + "SELECT data,json_extract(data,'$.CustomBadgeMetadata.RoomId') AS room_id FROM custom_avatar_item WHERE outfit_type=1000 AND accessibility<>0 AND json_array_length(json_extract(data,'$.CurrentSaves'))>0 AND json_extract(data,'$.CustomBadgeMetadata.RoomId') IN (SELECT value FROM json_each(?))" + ) + .bind(JSON.stringify(rooms.map(roomIdOf))) + .all<{ data: string; room_id: number }>() + for (const room of rooms) + room.Badges = rows.results + .filter((r) => r.room_id === roomIdOf(room)) + .map((r) => JSON.parse(r.data)) +} + /** A single subroom of a room (columns authoritative), or null if it doesn't exist. */ export async function getSubRoom( db: D1Database, diff --git a/packages/jwt/src/index.ts b/packages/jwt/src/index.ts index 1012c4e..b6f607a 100644 --- a/packages/jwt/src/index.ts +++ b/packages/jwt/src/index.ts @@ -7,6 +7,7 @@ export { generateToken, generateStudioBuildValidationToken, validateStudioBuildValidationToken, + studioBuildValidationId, generatePhotonAuthToken, TOKEN_TTL_SECONDS, } from './jwt' diff --git a/packages/jwt/src/jwt.ts b/packages/jwt/src/jwt.ts index 06b7dd3..1d3e75e 100644 --- a/packages/jwt/src/jwt.ts +++ b/packages/jwt/src/jwt.ts @@ -18,7 +18,7 @@ import { GAME_VERSION, gameBuild } from '@repo/domain' // last a day so the client doesn't have to refresh it. export const TOKEN_TTL_SECONDS = 86400 -/** A build host can check component text, but cannot act as an account. */ +/** A build host can validate content and its job's grants, but cannot act as an account. */ export async function generateStudioBuildValidationToken( buildId: string, secret: string @@ -39,19 +39,27 @@ export async function validateStudioBuildValidationToken( request: Request, secret: string ): Promise { + return (await studioBuildValidationId(request, secret)) !== null +} + +/** Signed job identity for read-only, job-scoped validation routes. */ +export async function studioBuildValidationId( + request: Request, + secret: string +): Promise { const header = request.headers.get('Authorization') ?? '' - if (!header.startsWith('Bearer ')) return false + if (!header.startsWith('Bearer ')) return null try { const payload = await verify(header.slice(7), secret, 'HS256') - return ( - payload.sub === undefined && + return payload.sub === undefined && payload.aud === 'studio-build-validation' && payload.scope === 'studio.build.validate' && typeof payload.buildId === 'string' && /^[1-9]\d*$/.test(payload.buildId) - ) + ? (payload.buildId as string) + : null } catch { - return false + return null } }