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

This commit is contained in:
dukedotdev
2026-10-23 15:25:31 -04:00
committed by GitHub
parent 16e5f61160
commit 3da35a8692
22 changed files with 2059 additions and 81 deletions
+17 -2
View File
@@ -96,5 +96,20 @@ allocated asset IDs and requested platforms are accepted. SDK bundle version 5
is retained. Completion links the submitted save; auto-publish does not replace
a newer draft. Failed, cancelled, or expired jobs never become successful builds.
Claims include a signed, short-lived SDK validation token with no account subject
or roles. Deploy the paired API support: only bulk component-text validation
accepts this credential. It cannot authenticate as an account or publish a room.
or roles. Bulk component-text validation and job-scoped grant reads accept this
credential. It cannot authenticate as an account or publish a room.
## Avatar Studio and consumable validation
Avatar authoring uses per-account Studio grants, source revisions, and the managed
cloud queue. Apply `migrations/0004_avatar_studio.sql` to the shared database before
enabling these routes. The API owns creator saves and explicit publication; the
Studio service queues prefab builds and returns per-platform main/stripped bundles
at version 5 and material bundles at version 6. Image badges publish directly.
Changed body/configuration inputs receive distinct output IDs, and automatic
publication cannot replace a newer revision. Deploy the paired cloud-building
worker for prefab support; restricted validation credentials grant no account role.
The SDK's access override reads the target account's entitlement. Build-validation
credentials can inspect only the current owners of their active job's room; peers
can inspect grants only while they currently manage the same room.
@@ -0,0 +1,4 @@
CREATE TABLE IF NOT EXISTS avatar_studio_access (account_id INTEGER PRIMARY KEY, flags INTEGER NOT NULL DEFAULT 0, created_at TEXT NOT NULL, modified_at TEXT NOT NULL);
CREATE TABLE IF NOT EXISTS avatar_studio_save (id INTEGER PRIMARY KEY AUTOINCREMENT, item_id TEXT NOT NULL, body_type INTEGER NOT NULL, unity_asset_id TEXT NOT NULL, source_filename TEXT NOT NULL, source_hash TEXT NOT NULL, thumbnail TEXT NOT NULL, moderation_image TEXT, configuration TEXT NOT NULL, description TEXT, update_current INTEGER NOT NULL DEFAULT 0, created_at TEXT NOT NULL);
CREATE INDEX IF NOT EXISTS idx_avatar_studio_save_item ON avatar_studio_save(item_id,id);
CREATE TABLE IF NOT EXISTS studio_avatar_build (cloud_build_id TEXT PRIMARY KEY, item_id TEXT NOT NULL, save_id INTEGER NOT NULL);
+332 -58
View File
@@ -1,11 +1,18 @@
import { Hono } from 'hono'
import {
AVATAR_STUDIO_SLOTS,
avatarItemAccessBit,
avatarStudioFlags,
avatarStudioItem,
avatarStudioSave,
canManageRoom,
canManageRoomById,
getRoomById,
getSubRoom,
getSubRoomSaveById,
publicStudioBundleFilename,
publishAvatarSave,
studioBundleR2Key,
} from '@repo/domain'
import {
@@ -16,6 +23,7 @@ import {
import { parseMultipart } from './multipart'
import type { Context } from 'hono'
import type { App } from './context'
export const CLOUD_JOB_SCHEMA = `CREATE TABLE IF NOT EXISTS studio_cloud_build_job (
@@ -23,6 +31,20 @@ export const CLOUD_JOB_SCHEMA = `CREATE TABLE IF NOT EXISTS studio_cloud_build_j
lease_token TEXT, lease_until TEXT, attempts INTEGER NOT NULL DEFAULT 0, created_at TEXT NOT NULL
)`
interface Job {
kind?: 'avatar'
avatarItemId?: string
avatar?: {
itemId: string
outfitType: number
bodyType: number
configuration: string
name: string
createdAt: string
price: number
creatorAccountId: number
canCreateRoomieEquipment: boolean
permittedItemTypes: number[]
}
buildId: string
roomId: number
subRoomId: number
@@ -34,7 +56,8 @@ interface Job {
targets: number[]
manifest: {
Version: number
MainSceneProjectRelativePath: string
MainSceneProjectRelativePath?: string
MainPrefabProjectRelativePath?: string
ExportPartitionVersion: number
Files: Array<{ n: string; p: string; g?: string }>
Exports?: Array<{ e: string; d?: string[] }>
@@ -51,6 +74,7 @@ const ok = (value: unknown) => ({ success: true, error: null, value })
const fail = (error: string) => ({ success: false, error, value: null })
const ID = /^[1-9]\d*$/
const guid = () => crypto.randomUUID()
const textField = (value: unknown, fallback = '') => (typeof value === 'string' ? value : fallback)
interface Artifact {
field: string
unityAssetId: string
@@ -84,7 +108,6 @@ interface CloudBuildRecord {
unity_asset_id: string
created_by_account_id: number
}
const textField = (value: unknown, fallback = '') => (typeof value === 'string' ? value : fallback)
function wire(row: CloudBuildRecord) {
return {
cloudBuildId: row.cloud_build_id,
@@ -116,7 +139,48 @@ async function pendingBuild(db: D1Database, roomId: number, subRoomId: number, s
.bind(roomId, subRoomId, saveId)
.first<CloudBuildRecord>()
}
async function pendingAvatarBuild(db: D1Database, itemId: string, saveId: number) {
return db
.prepare(
"SELECT b.* FROM studio_cloud_build b JOIN studio_avatar_build a USING(cloud_build_id) JOIN studio_cloud_build_job j USING(cloud_build_id) WHERE a.item_id=? AND a.save_id=? AND j.status IN ('queued','building','publishing') LIMIT 1"
)
.bind(itemId, saveId)
.first<CloudBuildRecord>()
}
const app = new Hono<App>()
.get('/cloud-builds/custom-avatar-item/:itemId{[0-9a-fA-F-]{36}}', async (c) => {
const id = await validateAndGetAccountId(c.req.raw, await c.env.JWT_SECRET.get())
if (id === null) return c.body(null, 401)
const item = await avatarStudioItem(c.env.DB, c.req.param('itemId'))
if (!item || item.CreatorAccountId !== id) return c.body(null, 403)
const skip = Math.max(0, Number(c.req.query('skip')) || 0),
take = Math.min(10000, Math.max(1, Number(c.req.query('take')) || 100))
const { results } = await c.env.DB.prepare(
'SELECT b.* FROM studio_cloud_build b JOIN studio_avatar_build a USING(cloud_build_id) WHERE a.item_id=? ORDER BY b.started_at DESC'
)
.bind(item.CustomAvatarItemId)
.all<CloudBuildRecord>()
return c.json({
results: results.slice(skip, skip + take).map(wire),
totalResults: results.length,
})
})
.post(
'/cloud-builds/custom-avatar-item/:itemId{[0-9a-fA-F-]{36}}/save/:saveId{[1-9][0-9]*}',
(c) => enqueueAvatar(c, c.req.param('itemId'), Number(c.req.param('saveId')))
)
.post('/cloud-builds/custom-avatar-item/create-for-new-build-version', async (c) => {
const form = await c.req.parseBody()
const itemId = String(form.customAvatarItemId ?? ''),
assetId = String(form.unityAssetId ?? '')
const row = await c.env.DB.prepare(
'SELECT id FROM avatar_studio_save WHERE item_id=? AND unity_asset_id=? ORDER BY id DESC LIMIT 1'
)
.bind(itemId, assetId)
.first<{ id: number }>()
if (!row) return c.notFound()
return enqueueAvatar(c, itemId, row.id, form)
})
.post('/cloud-builds/create', async (c) => {
const secret = await c.env.JWT_SECRET.get()
const accountId = await validateAndGetAccountId(c.req.raw, secret)
@@ -272,11 +336,16 @@ const app = new Hono<App>()
const form = await c.req.parseBody()
const id = textField(form.cloudBuildId)
const row = await c.env.DB.prepare(
'SELECT room_id FROM studio_cloud_build WHERE cloud_build_id = ?'
'SELECT room_id,created_by_account_id FROM studio_cloud_build WHERE cloud_build_id = ?'
)
.bind(id)
.first<{ room_id: number }>()
if (!row || !(await canManageRoomById(c.env.DB, row.room_id, accountId)))
.first<{ room_id: number; created_by_account_id: number }>()
if (
!row ||
!(row.room_id === 0
? row.created_by_account_id === accountId
: await canManageRoomById(c.env.DB, row.room_id, accountId))
)
return c.json(fail('No accessible build.'), 404)
await c.env.DB.batch([
c.env.DB.prepare(
@@ -325,17 +394,13 @@ const app = new Hono<App>()
})
.post('/internal/cloud-builds/:id/heartbeat', async (c) => {
const { leaseToken } = await c.req.json<{ leaseToken: string }>()
const leaseUntil = new Date(Date.now() + 5 * 60 * 1000).toISOString()
const result = await c.env.DB.prepare(
"UPDATE studio_cloud_build_job SET lease_until=? WHERE cloud_build_id=? AND lease_token=? AND status IN ('building','publishing') AND lease_until>?"
)
.bind(
new Date(Date.now() + 5 * 60 * 1000).toISOString(),
c.req.param('id'),
leaseToken,
new Date().toISOString()
)
.bind(leaseUntil, c.req.param('id'), leaseToken, new Date().toISOString())
.run()
return c.json({ active: result.meta.changes > 0 })
return c.json(result.meta.changes > 0 ? { active: true, leaseUntil } : { active: false })
})
.post('/internal/cloud-builds/:id/finish', async (c) => {
const form = await parseMultipart(c.req.raw)
@@ -359,6 +424,26 @@ const app = new Hono<App>()
])
return c.json(ok(null))
}
if (job.kind === 'avatar') {
const item = await avatarStudioItem(c.env.DB, job.avatarItemId!)
const save = await avatarStudioSave(c.env.DB, job.avatarItemId!, job.saveId)
if (
!item ||
!save ||
item.CreatorAccountId !== job.accountId ||
!((await avatarStudioFlags(c.env.DB, job.accountId)) & avatarItemAccessBit(item.OutfitType))
) {
await c.env.DB.batch([
c.env.DB.prepare(
"UPDATE studio_cloud_build_job SET status='failed',lease_token=NULL WHERE cloud_build_id=? AND lease_token=? AND status='building'"
).bind(id, lease.lease_token),
c.env.DB.prepare(
"UPDATE studio_cloud_build SET completed_at=?,error='Avatar source or authorization was removed.' WHERE cloud_build_id=? AND completed_at=''"
).bind(new Date().toISOString(), id),
])
return c.json(fail('Avatar source or authorization was removed.'), 409)
}
}
const assets = new Set([job.unityAssetId, ...job.exports.map((item) => item.u)])
let entries: unknown
try {
@@ -368,7 +453,10 @@ const app = new Hono<App>()
}
if (!Array.isArray(entries) || !entries.every(isArtifact))
return c.json(fail('Invalid build artifact metadata.'), 400)
if (!entries.length || entries.length > assets.size * job.targets.length * 2)
if (
!entries.length ||
entries.length > assets.size * job.targets.length * (job.kind === 'avatar' ? 3 : 2)
)
return c.json(fail('Missing build artifacts.'), 400)
const seen = new Set<string>()
for (const entry of entries) {
@@ -377,8 +465,10 @@ const app = new Hono<App>()
if (
!assets.has(entry.unityAssetId) ||
!job.targets.includes(entry.target) ||
!['main', 'stripped'].includes(entry.kind) ||
entry.version !== 5 ||
!(job.kind === 'avatar' ? ['main', 'stripped', 'material'] : ['main', 'stripped']).includes(
entry.kind
) ||
entry.version !== (job.kind === 'avatar' && entry.kind === 'material' ? 6 : 5) ||
!(file instanceof File) ||
file.size === 0 ||
seen.has(key)
@@ -390,6 +480,11 @@ const app = new Hono<App>()
for (const target of job.targets)
if (!seen.has(`${asset}:${target}:main`))
return c.json(fail('A required asset bundle is missing.'), 400)
if (
job.kind === 'avatar' &&
job.targets.some((target) => !seen.has(`${job.unityAssetId}:${target}:material`))
)
return c.json(fail('An avatar material bundle is missing.'), 400)
const writes: D1PreparedStatement[] = []
for (const entry of entries) {
const file = form[entry.field] as File
@@ -416,8 +511,11 @@ const app = new Hono<App>()
entry.target === 2 ? 'android' : 'windows',
entry.kind
)
const filename = baseFilename.replace('.assetbundle', `.${lease.lease_token}.assetbundle`)
const key = studioBundleR2Key(filename)
const filename =
job.kind === 'avatar'
? `${entry.target === 2 ? 'quest/' : ''}studio-avatar/${job.avatarItemId}/${entry.unityAssetId}/${lease.lease_token}.${entry.kind}.assetbundle`
: baseFilename.replace('.assetbundle', `.${lease.lease_token}.assetbundle`)
const key = job.kind === 'avatar' ? `avatar/${filename}` : studioBundleR2Key(filename)
await c.env.CDN_ASSETS.put(key, bytes)
keys.push(key)
writes.push(
@@ -439,48 +537,52 @@ const app = new Hono<App>()
await c.env.CDN_ASSETS.put(serverDataKey, JSON.stringify({ Exports: job.exports }))
keys.push(serverDataKey)
// The exact submitted save is linked; a newer draft must never be replaced.
writes.push(
c.env.DB.prepare(
"UPDATE subroom_save SET data=json_set(data,'$.UnityAssetId',?,'$.ServerData',json(?)) WHERE sub_room_data_save_id=? AND sub_room_id=? AND EXISTS (SELECT 1 FROM studio_cloud_build_job WHERE cloud_build_id=? AND lease_token=? AND status='publishing')"
).bind(
job.unityAssetId,
JSON.stringify({ Exports: job.exports }),
job.saveId,
job.subRoomId,
id,
lease.lease_token
)
)
writes.push(
c.env.DB.prepare(
`UPDATE room SET data=json_set(data,'$.BecameRRStudioRoomAt',?)
WHERE room_id=? AND json_type(data,'$.BecameRRStudioRoomAt') IS NOT 'text'
AND EXISTS (SELECT 1 FROM studio_cloud_build_job WHERE cloud_build_id=? AND lease_token=? AND status='publishing')`
).bind(new Date().toISOString(), job.roomId, id, lease.lease_token)
)
if (job.autoPublish) {
// Test and update the staging slot in the same statement. A draft saved
// during compilation must not be replaced by this build's older save.
if (job.kind !== 'avatar')
writes.push(
c.env.DB.prepare(
`UPDATE subroom SET current_save_id=?,
staged_save_id=CASE WHEN staged_save_id=? THEN NULL ELSE staged_save_id END
WHERE room_id=? AND sub_room_id=? AND COALESCE(staged_save_id,current_save_id)=?
AND EXISTS (SELECT 1 FROM subroom_save WHERE sub_room_data_save_id=? AND sub_room_id=?)
AND EXISTS (SELECT 1 FROM studio_cloud_build_job WHERE cloud_build_id=? AND lease_token=? AND status='publishing')`
"UPDATE subroom_save SET data=json_set(data,'$.UnityAssetId',?,'$.ServerData',json(?)) WHERE sub_room_data_save_id=? AND sub_room_id=? AND EXISTS (SELECT 1 FROM studio_cloud_build_job WHERE cloud_build_id=? AND lease_token=? AND status='publishing')"
).bind(
job.saveId,
job.saveId,
job.roomId,
job.subRoomId,
job.saveId,
job.unityAssetId,
JSON.stringify({ Exports: job.exports }),
job.saveId,
job.subRoomId,
id,
lease.lease_token
)
)
if (job.kind !== 'avatar') {
writes.push(
c.env.DB.prepare(
`UPDATE room SET data=json_set(data,'$.BecameRRStudioRoomAt',?)
WHERE room_id=? AND json_type(data,'$.BecameRRStudioRoomAt') IS NOT 'text'
AND EXISTS (SELECT 1 FROM studio_cloud_build_job WHERE cloud_build_id=? AND lease_token=? AND status='publishing')`
).bind(new Date().toISOString(), job.roomId, id, lease.lease_token)
)
if (job.autoPublish) {
// Test and update the staging slot in the same statement. A draft saved
// during compilation must not be replaced by this build's older save.
writes.push(
c.env.DB.prepare(
`UPDATE subroom SET current_save_id=?,
staged_save_id=CASE WHEN staged_save_id=? THEN NULL ELSE staged_save_id END
WHERE room_id=? AND sub_room_id=? AND COALESCE(staged_save_id,current_save_id)=?
AND EXISTS (SELECT 1 FROM subroom_save WHERE sub_room_data_save_id=? AND sub_room_id=?)
AND EXISTS (SELECT 1 FROM studio_cloud_build_job WHERE cloud_build_id=? AND lease_token=? AND status='publishing')`
).bind(
job.saveId,
job.saveId,
job.roomId,
job.subRoomId,
job.saveId,
job.saveId,
job.subRoomId,
id,
lease.lease_token
)
)
}
}
writes.push(
c.env.DB.prepare(
"UPDATE studio_cloud_build SET completed_at=?,error=NULL WHERE cloud_build_id=? AND EXISTS (SELECT 1 FROM studio_cloud_build_job WHERE cloud_build_id=? AND lease_token=? AND status='publishing')"
@@ -491,20 +593,42 @@ const app = new Hono<App>()
"UPDATE studio_cloud_build_job SET status='completed',lease_token=NULL WHERE cloud_build_id=? AND lease_token=? AND status='publishing'"
).bind(id, lease.lease_token)
)
// Recheck the lease and target inside the publication transaction. R2
// uploads can outlast a lease, and a room can be deleted while compiling.
// Recheck authority, lease and target inside the final publication transaction.
const publicationTime = new Date().toISOString()
const roomRow =
job.kind !== 'avatar'
? await c.env.DB.prepare('SELECT data FROM room WHERE room_id=?')
.bind(job.roomId)
.first<{ data: string }>()
: null
const authorizedRoom =
roomRow && canManageRoom(JSON.parse(roomRow.data), job.accountId) ? roomRow.data : null
const targetExists =
job.kind === 'avatar'
? `SELECT 1 FROM avatar_studio_save s JOIN custom_avatar_item i ON i.custom_avatar_item_id=s.item_id
JOIN avatar_studio_access a ON a.account_id=json_extract(i.data,'$.CreatorAccountId')
WHERE s.id=? AND s.item_id=? AND s.unity_asset_id=? AND a.account_id=? AND (a.flags & ?)<>0`
: `SELECT 1 FROM subroom_save s JOIN subroom r ON r.sub_room_id=s.sub_room_id
JOIN room ON room.room_id=r.room_id
WHERE s.sub_room_data_save_id=? AND s.sub_room_id=? AND r.room_id=? AND room.data=?`
const targetBindings =
job.kind === 'avatar'
? [
job.saveId,
job.avatarItemId!,
job.unityAssetId,
job.accountId,
avatarItemAccessBit(job.avatar!.outfitType),
]
: [job.saveId, job.subRoomId, job.roomId, authorizedRoom]
const results = await c.env.DB.batch([
c.env.DB.prepare(
`UPDATE studio_cloud_build_job SET status='failed',lease_token=NULL
WHERE cloud_build_id=? AND lease_token=? AND status='publishing'
AND (lease_until<=? OR NOT EXISTS (
SELECT 1 FROM subroom_save s JOIN subroom r ON r.sub_room_id=s.sub_room_id
JOIN room ON room.room_id=r.room_id
WHERE s.sub_room_data_save_id=? AND s.sub_room_id=? AND r.room_id=?))`
).bind(id, lease.lease_token, publicationTime, job.saveId, job.subRoomId, job.roomId),
AND (lease_until<=? OR NOT EXISTS (${targetExists}))`
).bind(id, lease.lease_token, publicationTime, ...targetBindings),
c.env.DB.prepare(
`UPDATE studio_cloud_build SET completed_at=?,error='Build lease expired or target save removed.'
`UPDATE studio_cloud_build SET completed_at=?,error='Build lease expired or target authorization/save removed.'
WHERE cloud_build_id=? AND completed_at='' AND EXISTS (
SELECT 1 FROM studio_cloud_build_job WHERE cloud_build_id=? AND status='failed')`
).bind(publicationTime, id, id),
@@ -512,7 +636,7 @@ const app = new Hono<App>()
])
if (!results[results.length - 1].meta.changes) {
await Promise.all(keys.map((key) => c.env.CDN_ASSETS.delete(key)))
return c.json(fail('Build lease expired or target save removed.'), 409)
return c.json(fail('Build lease expired or target authorization/save removed.'), 409)
}
} catch (error) {
await Promise.all(keys.map((key) => c.env.CDN_ASSETS.delete(key)))
@@ -523,6 +647,156 @@ const app = new Hono<App>()
.run()
throw error
}
if (job.kind === 'avatar') {
const item = await avatarStudioItem(c.env.DB, job.avatarItemId!)
const save = await avatarStudioSave(c.env.DB, job.avatarItemId!, job.saveId)
if (
item &&
save &&
(job.autoPublish || (item.Accessibility === 0 && save.update_current === 1))
) {
await publishAvatarSave(c.env.DB, item.CustomAvatarItemId, save.id, true)
}
return c.json(ok(null))
}
return c.json(ok(null))
})
export default app
async function enqueueAvatar(
c: Context<App>,
itemId: string,
saveId: number,
supplied?: Record<string, unknown>
) {
const accountId = await validateAndGetAccountId(c.req.raw, await c.env.JWT_SECRET.get())
if (accountId === null) return c.body(null, 401)
const item = await avatarStudioItem(c.env.DB, itemId),
save = await avatarStudioSave(c.env.DB, itemId, saveId)
if (!item || !save) return c.notFound()
if (item.OutfitType === 1000)
return c.json(fail('Badge images publish directly and do not require a Unity build.'), 400)
const roles = await validateAndGetRoles(c.req.raw, await c.env.JWT_SECRET.get())
if (
item.CreatorAccountId !== accountId ||
!roles?.includes('betastudio') ||
!((await avatarStudioFlags(c.env.DB, accountId)) & avatarItemAccessBit(item.OutfitType))
)
return c.body(null, 403)
if (!c.env.CLOUD_BUILD_KEY) return c.json(fail('Cloud building is not configured.'), 503)
const pending = await pendingAvatarBuild(c.env.DB, itemId, saveId)
if (pending) return c.json(ok(wire(pending)))
const object = await c.env.CDN_ASSETS.get('avatarunityasset/' + save.source_filename)
if (!object || object.size > 8 * 1024 * 1024)
return c.json(fail('Missing or oversized avatar source manifest.'), 400)
let manifest: Job['manifest']
try {
manifest = await object.json<Job['manifest']>()
} catch {
return c.json(fail('Invalid source manifest JSON.'), 400)
}
const main = manifest?.MainPrefabProjectRelativePath
if (
!manifest ||
manifest.Version !== 3 ||
typeof main !== 'string' ||
!main.startsWith('Assets/') ||
!main.endsWith('.prefab') ||
!Array.isArray(manifest.Files) ||
!manifest.Files.length ||
manifest.Files.length > 100000 ||
!manifest.Files.some((f) => f?.p === main) ||
manifest.Files.some(
(f) =>
!f ||
typeof f.p !== 'string' ||
!/^(Assets|Packages)\//.test(f.p) ||
f.p.includes('\\') ||
f.p.includes(':') ||
f.p
.split('/')
.some(
(p) => p === '..' || p === '.' || p === '' || p.toLowerCase().endsWith('.bundle')
) ||
/\.(cs|dll|exe|so|dylib|rsp|asmdef|asmref)$/i.test(f.p) ||
!/^[a-z0-9]{40}$/.test(f.n)
) ||
new Set(manifest.Files.map((f) => f.p.toLowerCase())).size !== manifest.Files.length
)
return c.json(fail('Invalid avatar source file manifest.'), 400)
const form = supplied ?? (await c.req.parseBody())
if (form.pipelineVersion !== undefined && String(form.pipelineVersion) !== '2')
return c.json(fail('Unsupported avatar build pipeline.'), 400)
const targets = (c.env.CLOUD_BUILD_TARGETS ?? '0,2').split(',').map(Number)
if (!targets.length || targets.some((t) => t !== 0 && t !== 2))
return c.json(fail('Invalid build targets.'), 503)
const access = await avatarStudioFlags(c.env.DB, accountId)
const permittedItemTypes = AVATAR_STUDIO_SLOTS.filter(
(slot) =>
(access & avatarItemAccessBit(slot)) !== 0 &&
(![104, 203].includes(slot) || roles.includes('developer'))
)
const job: Job = {
kind: 'avatar',
avatarItemId: itemId,
avatar: {
itemId,
outfitType: item.OutfitType,
bodyType: save.body_type,
configuration: save.configuration,
name: String(item.Name ?? ''),
createdAt: save.created_at,
price: Number(item.Price ?? 0),
creatorAccountId: accountId,
canCreateRoomieEquipment: roles.includes('developer'),
permittedItemTypes,
},
buildId: numericId(),
roomId: 0,
subRoomId: 0,
saveId,
accountId,
unityAssetId: save.unity_asset_id,
autoPublish: textField(form.autoPublish).toLowerCase() === 'true',
templateVersion: Number(c.env.CLOUD_BUILD_TEMPLATE_VERSION ?? 169),
targets,
manifest,
room: null,
exports: [],
}
const now = new Date().toISOString()
// As for rooms, reserve the build and its queue/link rows in one transaction.
const created = await c.env.DB.batch([
c.env.DB.prepare(
`INSERT INTO studio_cloud_build(cloud_build_id,room_id,sub_room_id,sub_room_data_save_id,unity_asset_id,created_by_account_id,started_at,completed_at,error)
SELECT ?,0,0,?,?,?,?,'',NULL WHERE NOT EXISTS (
SELECT 1 FROM studio_avatar_build a JOIN studio_cloud_build_job j USING(cloud_build_id)
WHERE a.item_id=? AND a.save_id=? AND j.status IN ('queued','building','publishing'))`
).bind(job.buildId, saveId, job.unityAssetId, accountId, now, itemId, saveId),
c.env.DB.prepare(
'INSERT INTO studio_avatar_build(cloud_build_id,item_id,save_id) SELECT ?,?,? WHERE EXISTS (SELECT 1 FROM studio_cloud_build WHERE cloud_build_id=?)'
).bind(job.buildId, itemId, saveId, job.buildId),
c.env.DB.prepare(
'INSERT INTO studio_cloud_build_job(cloud_build_id,payload,created_at) SELECT ?,?,? WHERE EXISTS (SELECT 1 FROM studio_cloud_build WHERE cloud_build_id=?)'
).bind(job.buildId, JSON.stringify(job), now, job.buildId),
])
if (!created[0].meta.changes) {
const existing = await pendingAvatarBuild(c.env.DB, itemId, saveId)
return existing
? c.json(ok(wire(existing)))
: c.json(fail('Build state changed; retry creation.'), 409)
}
return c.json(
ok(
wire({
cloud_build_id: job.buildId,
started_at: now,
completed_at: null,
unity_asset_id: job.unityAssetId,
created_by_account_id: accountId,
error: null,
})
)
)
}
+12 -5
View File
@@ -109,22 +109,29 @@ export async function getUserBuildCount(
const end = new Date(start.getTime() + 60 * 60 * 1000)
const row = await db
.prepare(
`SELECT COUNT(*) AS total,
SUM(CASE WHEN error IS NOT NULL THEN 1 ELSE 0 END) AS failed
`SELECT SUM(CASE WHEN room_id!=0 THEN 1 ELSE 0 END) AS total,
SUM(CASE WHEN room_id!=0 AND error IS NOT NULL THEN 1 ELSE 0 END) AS failed,
SUM(CASE WHEN room_id=0 THEN 1 ELSE 0 END) AS avatar_total,
SUM(CASE WHEN room_id=0 AND error IS NOT NULL THEN 1 ELSE 0 END) AS avatar_failed
FROM studio_cloud_build
WHERE created_by_account_id = ?1
AND started_at >= ?2 AND started_at < ?3`
)
.bind(accountId, start.toISOString(), end.toISOString())
.first<{ total: number | string; failed: number | string | null }>()
.first<{
total: number | string
failed: number | string | null
avatar_total: number | null
avatar_failed: number | null
}>()
return {
accountId,
startTime: start.toISOString(),
endTime: end.toISOString(),
subRoomTotalBuildCount: Number(row?.total ?? 0),
subRoomFailedBuildCount: Number(row?.failed ?? 0),
avatarItemTotalBuildCount: 0,
avatarItemFailedBuildCount: 0,
avatarItemTotalBuildCount: Number(row?.avatar_total ?? 0),
avatarItemFailedBuildCount: Number(row?.avatar_failed ?? 0),
}
}
+72 -1
View File
@@ -3,12 +3,13 @@ import { useWorkersLogger } from 'workers-tagged-logger'
import {
canManageRoomById,
getAccount,
getPlayerIdsInRoomSubRoom,
getRoomCoOwnerIds,
getSubRoom,
} from '@repo/domain'
import { withNotFound, withOnError } from '@repo/hono-helpers'
import { validateAndGetAccountId, validateAndGetRoles } from '@repo/jwt'
import { studioBuildValidationId, validateAndGetAccountId, validateAndGetRoles } from '@repo/jwt'
import cloudBuilds from './cloud-builds'
import distribution from './distribution'
@@ -30,6 +31,13 @@ function positiveId(raw: string | undefined): number | null {
return Number.isSafeInteger(id) ? id : null
}
/** SQLite datetime() is UTC, but its space-separated output is not a .NET JSON DateTime. */
function accessTimestamp(value: string | undefined, fallback: string): string {
const normalized = value?.replace(/^(\d{4}-\d{2}-\d{2}) (\d{2}:\d{2}:\d{2}(?:\.\d+)?)$/, '$1T$2Z')
const time = normalized ? Date.parse(normalized) : NaN
return Number.isFinite(time) ? new Date(time).toISOString() : fallback
}
const BUNDLE_REQUIRED = 'Both a Windows bundle and an Android bundle are required.'
const UPLOAD_FIRST =
'Upload the room before building locally. The latest save is where this build is recorded.'
@@ -168,6 +176,69 @@ const app = new Hono<App>()
.get('/', async (c) => {
return c.text('hello, world!')
})
.get('/access/override/:accountId{[1-9][0-9]*}', async (c) => {
const secret = await c.env.JWT_SECRET.get()
const caller = await validateAndGetAccountId(c.req.raw, secret)
const id = Number(c.req.param('accountId'))
if (!Number.isSafeInteger(id)) return c.body(null, 400)
let roles = await validateAndGetRoles(c.req.raw, secret)
if (caller === null) {
const buildId = await studioBuildValidationId(c.req.raw, secret)
if (!buildId) return c.body(null, 401)
const job = await c.env.DB.prepare(
"SELECT payload FROM studio_cloud_build_job WHERE cloud_build_id=? AND status='building' AND lease_until>?"
)
.bind(buildId, new Date().toISOString())
.first<{ payload: string }>()
if (!job) return c.body(null, 403)
const payload = JSON.parse(job.payload) as {
roomId: number
room?: { Roles?: Array<{ AccountId: number; Role: number }> }
}
if (
!payload.roomId ||
!payload.room?.Roles?.some((r) => r.AccountId === id && [30, 255].includes(r.Role))
)
return c.body(null, 403)
if (!(await getRoomCoOwnerIds(c.env.DB, payload.roomId))?.includes(id))
return c.body(null, 403)
const account = await getAccount(c.env.DB, id)
roles = account?.hasStudio ? ['betastudio'] : []
}
if (
caller !== null &&
caller !== id &&
!roles?.some((r) => r === 'developer' || r === 'moderator')
) {
// The SDK validates a registry against every owner of its room. Allow
// peers to inspect grants only when both currently manage the same room.
const shared = await c.env.DB.prepare(
`SELECT 1 FROM room r WHERE
(r.creator_account_id=?1 OR EXISTS (SELECT 1 FROM json_each(r.data,'$.Roles') role WHERE json_extract(role.value,'$.AccountId')=?1 AND json_extract(role.value,'$.Role') IN (30,255))) AND
(r.creator_account_id=?2 OR EXISTS (SELECT 1 FROM json_each(r.data,'$.Roles') role WHERE json_extract(role.value,'$.AccountId')=?2 AND json_extract(role.value,'$.Role') IN (30,255))) LIMIT 1`
)
.bind(caller, id)
.first()
if (!shared) return c.body(null, 403)
}
// Inspection authorization above belongs to the caller; this grant belongs to the target.
if (caller !== null && caller !== id)
roles = (await getAccount(c.env.DB, id))?.hasStudio ? ['betastudio'] : []
const grant = await c.env.DB.prepare(
'SELECT flags,created_at,modified_at FROM avatar_studio_access WHERE account_id=?'
)
.bind(id)
.first<{ flags: number; created_at: string; modified_at: string }>()
const flags = roles?.includes('betastudio') ? (grant?.flags ?? 0) : 0
const now = new Date().toISOString()
return c.json({
AccountId: id,
StudioAccess: roles?.includes('betastudio') ? 1 : 2,
AvatarStudioAccess: flags,
CreatedAt: accessTimestamp(grant?.created_at, now),
ModifiedAt: accessTimestamp(grant?.modified_at, now),
})
})
// There is no circuit recommendation corpus on a fresh deployment. An empty
// list is the SDK's supported no-recommendations state, not a failed lookup.
.get('/commonQuery/rrstudio/chiprecommendations', (c) => c.json([]))
@@ -0,0 +1,348 @@
import { adminSecretsStore, env, SELF } from 'cloudflare:test'
import { beforeAll, beforeEach, expect, it } from 'vitest'
import {
AVATAR_STUDIO_SCHEMA,
avatarSaveDto,
avatarStudioSave,
publishAvatarSave,
ROOM_SCHEMA_DDL,
STUDIO_CLOUD_BUILD_SCHEMA_DDL,
UNITY_ASSET_SCHEMA_DDL,
} from '@repo/domain'
import { generateToken } from '@repo/jwt'
import { SCHEMA_DDL, toQuestCustomAvatarItem } from '../../../../api/src/custom-avatar-items-db'
import { CLOUD_JOB_SCHEMA } from '../../cloud-builds'
import type { Env } from '../../context'
declare module 'cloudflare:test' {
interface ProvidedEnv extends Env {}
}
const origin = 'https://example.com',
itemId = '11111111-1111-1111-1111-111111111111',
assetId = '22222222-2222-2222-2222-222222222222'
const worker = { Authorization: 'Bearer private-avatar-worker-key' }
async function auth(id = 42, roles = ['betastudio']) {
return {
Authorization: 'Bearer ' + (await generateToken(String(id), 'fixture', 0, 'test-key', roles)),
}
}
const manifest = {
Version: 3,
MainPrefabProjectRelativePath: 'Assets/Hat.prefab',
Files: [{ n: 'a'.repeat(40), p: 'Assets/Hat.prefab' }],
}
beforeAll(async () => {
await adminSecretsStore(env.JWT_SECRET).create('test-key')
env.CLOUD_BUILD_KEY = 'private-avatar-worker-key'
env.CLOUD_BUILD_TARGETS = '0,2'
for (const sql of [
...SCHEMA_DDL,
...ROOM_SCHEMA_DDL,
...AVATAR_STUDIO_SCHEMA,
...STUDIO_CLOUD_BUILD_SCHEMA_DDL,
...UNITY_ASSET_SCHEMA_DDL,
CLOUD_JOB_SCHEMA,
])
await env.DB.prepare(sql).run()
})
beforeEach(async () => {
for (const table of [
'studio_cloud_build_job',
'studio_cloud_build',
'studio_avatar_build',
'unity_asset',
'avatar_studio_save',
'avatar_studio_access',
'custom_avatar_item',
])
await env.DB.prepare('DELETE FROM ' + table).run()
await env.DB.prepare("INSERT INTO avatar_studio_access VALUES (42,1,'now','now')").run()
await env.DB.prepare('INSERT INTO custom_avatar_item(data) VALUES (?)')
.bind(
JSON.stringify({
CustomAvatarItemId: itemId,
CreatorAccountId: 42,
OutfitType: 0,
Accessibility: 0,
Name: 'Hat',
CurrentSaves: [],
})
)
.run()
await env.DB.prepare(
"INSERT INTO avatar_studio_save(id,item_id,body_type,unity_asset_id,source_filename,source_hash,thumbnail,configuration,update_current,created_at) VALUES (1,?,1,?,'source','hash','thumb','{}',1,'2026-10-18')"
)
.bind(itemId, assetId)
.run()
await env.CDN_ASSETS.put('avatarunityasset/source', JSON.stringify(manifest))
})
async function create(id = 42) {
return SELF.fetch(origin + '/cloud-builds/custom-avatar-item/' + itemId + '/save/1', {
method: 'POST',
headers: await auth(id),
body: new URLSearchParams({ pipelineVersion: '2', autoPublish: 'true' }),
})
}
async function claim() {
const r = await SELF.fetch(origin + '/internal/cloud-builds/claim', {
method: 'POST',
headers: worker,
})
return r.json<{ job: { buildId: string; kind: string; manifest: unknown }; leaseToken: string }>()
}
function artifacts(lease: { leaseToken: string }, materialVersion = 6) {
const form = new FormData()
form.set('leaseToken', lease.leaseToken)
const entries = []
for (const target of [0, 2])
for (const kind of ['main', 'material', 'stripped']) {
const field = `bundle${target}${kind}`
form.set(
field,
new File(['UnityFS\0avatar fixture ' + target + kind], field + '.assetbundle')
)
entries.push({
field,
unityAssetId: assetId,
target,
kind,
version: kind === 'material' ? materialVersion : 5,
})
}
form.set('artifacts', JSON.stringify(entries))
return form
}
it('authorizes per-account access and rejects builds from other owners', async () => {
expect((await SELF.fetch(origin + '/access/override/42')).status).toBe(401)
expect(
(await SELF.fetch(origin + '/access/override/42', { headers: await auth(43) })).status
).toBe(403)
expect(
await (await SELF.fetch(origin + '/access/override/42', { headers: await auth() })).json()
).toMatchObject({ AccountId: 42, StudioAccess: 1, AvatarStudioAccess: 1 })
expect(
await (await SELF.fetch(origin + '/access/override/42', { headers: await auth(42, []) })).json()
).toMatchObject({ AvatarStudioAccess: 0 })
expect((await create(43)).status).toBe(403)
})
it('serializes SQLite and ISO access timestamps as SDK-compatible UTC DateTimes', async () => {
await env.DB.prepare(
'UPDATE avatar_studio_access SET flags=15,created_at=?,modified_at=? WHERE account_id=42'
)
.bind('2026-10-19T00:45:00.372Z', '2026-10-19 16:44:56')
.run()
expect(
await (await SELF.fetch(origin + '/access/override/42', { headers: await auth() })).json()
).toMatchObject({
AvatarStudioAccess: 15,
CreatedAt: '2026-10-19T00:45:00.372Z',
ModifiedAt: '2026-10-19T16:44:56.000Z',
})
})
it('builds and publishes prefab revisions with real SDK main/material version distinctions', async () => {
expect((await create()).status).toBe(200)
const lease = await claim()
expect(lease.job.kind).toBe('avatar')
expect(lease.job.manifest).toMatchObject(manifest)
const finish = () => origin + '/internal/cloud-builds/' + lease.job.buildId + '/finish'
expect(
(await SELF.fetch(finish(), { method: 'POST', headers: worker, body: artifacts(lease, 5) }))
.status
).toBe(400)
expect(
(await SELF.fetch(finish(), { method: 'POST', headers: worker, body: artifacts(lease) })).status
).toBe(200)
const row = await env.DB.prepare('SELECT data FROM custom_avatar_item').first<{ data: string }>()
const item = JSON.parse(row!.data)
expect(item.CurrentSaves).toHaveLength(1)
expect(item.CurrentSaves[0]).toMatchObject({
CustomAvatarItemSaveId: 1,
BodyType: 1,
OutfitType: 0,
})
expect(item.CurrentSaves[0].UnityAsset).toMatch(/^studio-avatar\//)
expect(item.CurrentSaves[0].UnityAsset2).toContain('.material.assetbundle')
expect(item.CurrentSaves[0]).not.toHaveProperty('SourceUnityAssetFilename')
const saved = await avatarStudioSave(env.DB, itemId, 1),
dto = await avatarSaveDto(env.DB, saved!)
expect(dto.BakedAssets).toHaveLength(4)
expect(dto.BakedAssets.map((a) => a.Version).sort()).toEqual([5, 5, 6, 6])
const quest = toQuestCustomAvatarItem(item).CurrentSaves[0]
expect(quest.UnityAssetHash).toBe(
dto.BakedAssets.find((a) => a.Target === 2 && a.Version === 5)?.Hash
)
expect(quest.UnityAssetHash).not.toBe(item.CurrentSaves[0].UnityAssetHash)
expect((await env.CDN_ASSETS.get('avatar/' + dto.UnityAsset))?.size).toBeGreaterThan(8)
expect(
(await SELF.fetch(finish(), { method: 'POST', headers: worker, body: artifacts(lease) })).status
).toBe(409)
const count = await SELF.fetch(origin + '/cloud-builds/get-build-count-for-user', {
headers: await auth(),
})
expect(await count.json()).toMatchObject({
value: { subRoomTotalBuildCount: 0, avatarItemTotalBuildCount: 1 },
})
})
it('rejects source code paths and cannot cancel another account’s build', async () => {
await env.CDN_ASSETS.put(
'avatarunityasset/source',
JSON.stringify({ ...manifest, Files: [null] })
)
expect((await create()).status).toBe(400)
await env.CDN_ASSETS.put(
'avatarunityasset/source',
JSON.stringify({
...manifest,
Files: [...manifest.Files, { n: 'a'.repeat(40), p: 'Assets/Code.cs' }],
})
)
expect((await create()).status).toBe(400)
await env.CDN_ASSETS.put('avatarunityasset/source', JSON.stringify(manifest))
await create()
const lease = await claim()
expect(
(
await SELF.fetch(origin + '/cloud-builds/cancel', {
method: 'PUT',
body: new URLSearchParams({ cloudBuildId: lease.job.buildId }),
})
).status
).toBe(401)
expect(
(
await SELF.fetch(origin + '/cloud-builds/cancel', {
method: 'PUT',
headers: await auth(43),
body: new URLSearchParams({ cloudBuildId: lease.job.buildId }),
})
).status
).toBe(404)
expect(
(
await SELF.fetch(origin + '/cloud-builds/cancel', {
method: 'PUT',
headers: await auth(),
body: new URLSearchParams({ cloudBuildId: lease.job.buildId }),
})
).status
).toBe(200)
expect(
(
await SELF.fetch(origin + '/internal/cloud-builds/' + lease.job.buildId + '/finish', {
method: 'POST',
headers: worker,
body: artifacts(lease),
})
).status
).toBe(409)
})
it('does not publish a deleted item or an older revision after a newer save', async () => {
await create()
const lease = await claim()
await env.DB.prepare(
'INSERT INTO avatar_studio_save(item_id,body_type,unity_asset_id,source_filename,source_hash,thumbnail,configuration,created_at) SELECT item_id,body_type,unity_asset_id,source_filename,source_hash,thumbnail,configuration,created_at FROM avatar_studio_save WHERE id=1'
).run()
expect(
(
await SELF.fetch(origin + '/internal/cloud-builds/' + lease.job.buildId + '/finish', {
method: 'POST',
headers: worker,
body: artifacts(lease),
})
).status
).toBe(200)
const item = await env.DB.prepare('SELECT data FROM custom_avatar_item').first<{ data: string }>()
expect(JSON.parse(item!.data).CurrentSaves).toEqual([])
await env.DB.prepare('DELETE FROM studio_cloud_build_job').run()
await env.DB.prepare('DELETE FROM studio_cloud_build').run()
await env.DB.prepare('DELETE FROM studio_avatar_build').run()
await create()
const deleted = await claim()
await env.DB.prepare('DELETE FROM custom_avatar_item').run()
expect(
(
await SELF.fetch(origin + '/internal/cloud-builds/' + deleted.job.buildId + '/finish', {
method: 'POST',
headers: worker,
body: artifacts(deleted),
})
).status
).toBe(409)
})
it('deduplicates concurrent avatar requests and allows a new build after failure', async () => {
const responses = await Promise.all([create(), create(), create()])
const ids = await Promise.all(
responses.map(async (response) => {
expect(response.status).toBe(200)
return (await response.json<{ value: { cloudBuildId: string } }>()).value.cloudBuildId
})
)
expect(new Set(ids).size).toBe(1)
for (const table of ['studio_cloud_build', 'studio_avatar_build', 'studio_cloud_build_job'])
expect(await env.DB.prepare('SELECT COUNT(*) AS n FROM ' + table).first()).toEqual({ n: 1 })
await env.DB.prepare("UPDATE studio_cloud_build_job SET status='failed'").run()
expect(
(await (await create()).json<{ value: { cloudBuildId: string } }>()).value.cloudBuildId
).not.toBe(ids[0])
})
it.each([false, true])(
'checks automatic avatar revision ordering in the write, including CAS retry=%s, preserving explicit restore',
async (retry) => {
await env.DB.prepare("INSERT INTO unity_asset VALUES (?,0,5,'main','old.bundle','hash')")
.bind(assetId)
.run()
let intercepted = false
const DB = new Proxy(env.DB, {
get(target, key) {
if (key === 'prepare')
return (sql: string) => {
const wrap = (statement: D1PreparedStatement): D1PreparedStatement =>
new Proxy(statement, {
get(stmt, method) {
if (method === 'bind') return (...values: unknown[]) => wrap(stmt.bind(...values))
if (method === 'run' && sql.startsWith('UPDATE custom_avatar_item'))
return async () => {
if (!intercepted) {
intercepted = true
await target
.prepare(
"INSERT INTO avatar_studio_save(id,item_id,body_type,unity_asset_id,source_filename,source_hash,thumbnail,configuration,created_at) SELECT 2,item_id,body_type,'new-identity',source_filename,source_hash,thumbnail,configuration,created_at FROM avatar_studio_save WHERE id=1"
)
.run()
if (retry)
await target
.prepare(
"UPDATE custom_avatar_item SET data=json_set(data,'$.Name','Concurrent metadata')"
)
.run()
}
return stmt.run()
}
const value = Reflect.get(stmt, method)
return typeof value === 'function' ? value.bind(stmt) : value
},
})
return wrap(target.prepare(sql))
}
const value = Reflect.get(target, key)
return typeof value === 'function' ? value.bind(target) : value
},
})
expect(await publishAvatarSave(DB, itemId, 1, true)).toBe(false)
expect(intercepted).toBe(true)
let item = JSON.parse(
(await env.DB.prepare('SELECT data FROM custom_avatar_item').first<{ data: string }>())!.data
)
expect(item.CurrentSaves).toEqual([])
expect(await publishAvatarSave(env.DB, itemId, 1)).toBe(true)
item = JSON.parse(
(await env.DB.prepare('SELECT data FROM custom_avatar_item').first<{ data: string }>())!.data
)
expect(item.CurrentSaves[0].CustomAvatarItemSaveId).toBe(1)
if (retry) expect(item.Name).toBe('Concurrent metadata')
}
)
@@ -8,7 +8,7 @@ import {
UNITY_ASSET_SCHEMA_DDL,
} from '@repo/domain'
import { CLOUD_JOB_SCHEMA } from '../../cloud-builds'
import cloudBuilds, { CLOUD_JOB_SCHEMA } from '../../cloud-builds'
import type { Env } from '../../context'
@@ -468,3 +468,138 @@ it('does not complete a build whose submitted save was deleted during compilatio
.run()
}
})
function builtArtifacts(lease: Awaited<ReturnType<typeof claim>>) {
const form = new FormData()
form.set('leaseToken', lease.leaseToken)
form.set('bundle', new File(['UnityFS\0regression'], 'bundle.assetbundle'))
form.set(
'artifacts',
JSON.stringify([
{
field: 'bundle',
unityAssetId: lease.job.unityAssetId,
target: 0,
kind: 'main',
version: 5,
},
])
)
return form
}
function finishBuild(
lease: Awaited<ReturnType<typeof claim>>,
form = builtArtifacts(lease),
bindings = env
) {
return cloudBuilds.request(
origin + `/internal/cloud-builds/${lease.job.buildId}/finish`,
{ method: 'POST', headers: worker, body: form },
bindings
)
}
it.each(['before finish', 'before transaction'])(
'rejects removed room co-owner authority %s without linking or publishing',
async (timing) => {
const prior = await env.DB.prepare('SELECT data FROM room WHERE room_id=3000').first<{
data: string
}>()
const originalSave = await env.DB.prepare(
'SELECT data FROM subroom_save WHERE sub_room_data_save_id=3002'
).first<{ data: string }>()
await env.DB.prepare("UPDATE room SET data=json_set(data,'$.Roles',json(?)) WHERE room_id=3000")
.bind(JSON.stringify([{ AccountId: 43, Role: 30 }]))
.run()
await create(43, true, { autoPublish: 'true' })
const lease = await claim()
const revoke = () =>
env.DB.prepare(
"UPDATE room SET data=json_set(data,'$.Roles',json('[]')) WHERE room_id=3000"
).run()
if (timing === 'before finish') await revoke()
const DB = new Proxy(env.DB, {
get(target, key) {
if (key === 'batch')
return async (statements: D1PreparedStatement[]) => {
await revoke()
return target.batch(statements)
}
const value = Reflect.get(target, key)
return typeof value === 'function' ? value.bind(target) : value
},
})
try {
expect((await finishBuild(lease, builtArtifacts(lease), { ...env, DB })).status).toBe(409)
expect(
await env.DB.prepare(
'SELECT data FROM subroom_save WHERE sub_room_data_save_id=3002'
).first()
).toEqual(originalSave)
expect(
await env.DB.prepare('SELECT unity_asset_id FROM unity_asset WHERE unity_asset_id=?')
.bind(lease.job.unityAssetId)
.first()
).toBeNull()
expect(
await env.DB.prepare('SELECT status FROM studio_cloud_build_job WHERE cloud_build_id=?')
.bind(lease.job.buildId)
.first()
).toEqual({ status: 'failed' })
expect(
await env.CDN_ASSETS.get(
`roomunityasset/studio-builds/${lease.job.buildId}/server-data.json`
)
).toBeNull()
expect(
(await env.CDN_ASSETS.list({ prefix: `room/studio/3000/${lease.job.unityAssetId}` }))
.objects
).toEqual([])
} finally {
await env.DB.prepare('UPDATE room SET data=? WHERE room_id=3000').bind(prior!.data).run()
}
}
)
it.each(['expired lease', 'deleted save'])(
'cleans uploaded artifacts when final publication finds %s',
async (condition) => {
await create()
const lease = await claim()
const save = await env.DB.prepare(
'SELECT data FROM subroom_save WHERE sub_room_data_save_id=3002'
).first<{ data: string }>()
const DB = new Proxy(env.DB, {
get(target, key) {
if (key === 'batch')
return async (statements: D1PreparedStatement[]) => {
if (condition === 'expired lease')
await target
.prepare("UPDATE studio_cloud_build_job SET lease_until='2000-01-01'")
.run()
else
await target
.prepare('DELETE FROM subroom_save WHERE sub_room_data_save_id=3002')
.run()
return target.batch(statements)
}
const value = Reflect.get(target, key)
return typeof value === 'function' ? value.bind(target) : value
},
})
try {
expect((await finishBuild(lease, builtArtifacts(lease), { ...env, DB })).status).toBe(409)
expect(
await env.DB.prepare('SELECT unity_asset_id FROM unity_asset WHERE unity_asset_id=?')
.bind(lease.job.unityAssetId)
.first()
).toBeNull()
expect(
await env.CDN_ASSETS.get(
`roomunityasset/studio-builds/${lease.job.buildId}/server-data.json`
)
).toBeNull()
} finally {
if (condition === 'deleted save')
await env.DB.prepare('INSERT INTO subroom_save VALUES (3002,3001,?)').bind(save!.data).run()
}
}
)
@@ -0,0 +1,108 @@
import { adminSecretsStore, env, SELF } from 'cloudflare:test'
import { beforeAll, beforeEach, expect, it } from 'vitest'
import { SCHEMA_DDL as ACCOUNT_SCHEMA, AVATAR_STUDIO_SCHEMA, ROOM_SCHEMA_DDL } from '@repo/domain'
import { generateStudioBuildValidationToken, generateToken } from '@repo/jwt'
import { CLOUD_JOB_SCHEMA } from '../../cloud-builds'
import type { Env } from '../../context'
declare module 'cloudflare:test' {
interface ProvidedEnv extends Env {}
}
const base = 'https://example.com/access/override/'
beforeAll(async () => {
await adminSecretsStore(env.JWT_SECRET).create('test-key')
for (const sql of [
...ACCOUNT_SCHEMA,
...ROOM_SCHEMA_DDL,
...AVATAR_STUDIO_SCHEMA,
CLOUD_JOB_SCHEMA,
])
await env.DB.prepare(sql).run()
await env.DB.prepare('INSERT INTO account(data) VALUES (?)')
.bind(JSON.stringify({ accountId: 42, username: 'Fixture', hasStudio: true }))
.run()
await env.DB.prepare('INSERT INTO room(data) VALUES (?)')
.bind(
JSON.stringify({ RoomId: 3000, CreatorAccountId: 42, Roles: [{ AccountId: 42, Role: 255 }] })
)
.run()
await env.DB.prepare("INSERT INTO avatar_studio_access VALUES (42,8,'now','now')").run()
})
beforeEach(async () => {
await env.DB.prepare('DELETE FROM studio_cloud_build_job').run()
await env.DB.prepare(
'INSERT INTO studio_cloud_build_job(cloud_build_id,payload,status,lease_until,created_at) VALUES (?,?,?,?,?)'
)
.bind(
'123',
JSON.stringify({ roomId: 3000, room: { Roles: [{ AccountId: 42, Role: 255 }] } }),
'building',
new Date(Date.now() + 60000).toISOString(),
'now'
)
.run()
})
async function request(id: number, build = '123') {
return SELF.fetch(base + id, {
headers: {
Authorization: 'Bearer ' + (await generateStudioBuildValidationToken(build, 'test-key')),
},
})
}
it('allows read-only consumable validation only for an active job’s room owners', async () => {
expect(await (await request(42)).json()).toMatchObject({
AccountId: 42,
StudioAccess: 1,
AvatarStudioAccess: 8,
})
expect((await request(43)).status).toBe(403)
expect((await request(42, '124')).status).toBe(403)
expect((await SELF.fetch(base + '42')).status).toBe(401)
})
it('rejects cancelled and expired jobs', async () => {
await env.DB.prepare("UPDATE studio_cloud_build_job SET status='cancelled'").run()
expect((await request(42)).status).toBe(403)
await env.DB.prepare(
"UPDATE studio_cloud_build_job SET status='building',lease_until='2000-01-01'"
).run()
expect((await request(42)).status).toBe(403)
})
it('lets co-owners validate each other’s grants without exposing unrelated accounts', async () => {
await env.DB.prepare("UPDATE room SET data=json_set(data,'$.Roles',json(?)) WHERE room_id=3000")
.bind(
JSON.stringify([
{ AccountId: 42, Role: 255 },
{ AccountId: 43, Role: 30 },
])
)
.run()
const headers = {
Authorization:
'Bearer ' + (await generateToken('43', 'fixture', 0, 'test-key', ['betastudio'])),
}
expect(await (await SELF.fetch(base + '42', { headers })).json()).toMatchObject({
AvatarStudioAccess: 8,
})
expect((await SELF.fetch(base + '44', { headers })).status).toBe(403)
})
it('uses target entitlement for staff inspections in both directions', async () => {
await env.DB.prepare('INSERT INTO account(data) VALUES (?)')
.bind(JSON.stringify({ accountId: 44, username: 'Disabled', hasStudio: false }))
.run()
await env.DB.prepare("INSERT INTO avatar_studio_access VALUES (44,15,'now','now')").run()
const staff = async (roles: string[]) => ({
Authorization: 'Bearer ' + (await generateToken('99', 'staff', 0, 'test-key', roles)),
})
expect(
await (
await SELF.fetch(base + '44', { headers: await staff(['developer', 'betastudio']) })
).json()
).toMatchObject({ AccountId: 44, StudioAccess: 2, AvatarStudioAccess: 0 })
expect(
await (await SELF.fetch(base + '42', { headers: await staff(['moderator']) })).json()
).toMatchObject({ AccountId: 42, StudioAccess: 1, AvatarStudioAccess: 8 })
})