mirror of
https://github.com/djdevin/recflare.git
synced 2026-10-25 23:18:51 -07:00
[studio] avatar authoring, managed prefab builds (#140)
This commit is contained in:
+17
-2
@@ -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
@@ -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,
|
||||
})
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 })
|
||||
})
|
||||
Reference in New Issue
Block a user