v4 avatars

This commit is contained in:
Devin Zuczek
2026-07-23 02:19:17 -04:00
parent 23b78104e8
commit 7ab171b646
3 changed files with 101 additions and 10 deletions
+58 -10
View File
@@ -51,6 +51,7 @@ import {
JsonObject,
OpaqueJsonBody,
SaveOutfitRequest,
SaveOutfitV4Response,
SubscriptionResponse,
UNAUTHORIZED_RESPONSE,
} from './openapi'
@@ -88,6 +89,26 @@ function unauthorized(c: Context<App>) {
return c.body(null, 401)
}
/**
* Shared parse/validate/store for the save-outfit routes (v3 and v4). Persists the
* posted outfit into its `Slot` verbatim and returns the stored `Outfit`; on the
* unauth or bad-body path it returns the Response to send directly (401, or 400 for a
* non-object body or missing/non-integer `Slot`). Callers format the success body — v3
* echoes the whole outfit, v4 answers a lean `{ Success, Slot }` ack.
*/
async function persistPostedOutfit(c: Context<App>): Promise<Outfit | Response> {
const id = await authedId(c)
if (id === null) return unauthorized(c)
const body = (await c.req.json().catch(() => null)) as Record<string, unknown> | null
if (body === null || typeof body !== 'object' || Array.isArray(body)) {
return c.body(null, 400)
}
if (!Number.isInteger(body.Slot)) return c.body(null, 400)
const outfit = body as Outfit
await setOutfit(c.env.DB, id, outfit)
return outfit
}
/** The notifications hub is a single global DO instance (see the `notify` worker). */
const HUB_INSTANCE = 'global'
@@ -563,6 +584,11 @@ const app = new Hono<App>({ strict: false })
//
// A missing/non-integer `Slot` is a 400 rather than a default slot — guessing would
// silently overwrite an outfit the player didn't mean to touch.
//
// v3 and v4 share this handler: newer clients POST to /v4/saved/set with the same
// payload shape (Slot, PreviewImageName, OutfitSelections(V2), FaceFeatures, Skin/HairColor,
// CustomAvatarItems) and expect the same slot-overwrite semantics, so they store into the
// same outfit table and read back through /api/avatar/v3/saved.
.post(
'/api/avatar/v3/saved/set',
describeRoute({
@@ -583,16 +609,38 @@ const app = new Hono<App>({ strict: false })
},
}),
async (c) => {
const id = await authedId(c)
if (id === null) return unauthorized(c)
const body = (await c.req.json().catch(() => null)) as Record<string, unknown> | null
if (body === null || typeof body !== 'object' || Array.isArray(body)) {
return c.body(null, 400)
}
if (!Number.isInteger(body.Slot)) return c.body(null, 400)
const outfit = body as Outfit
await setOutfit(c.env.DB, id, outfit)
return c.json(outfit)
const result = await persistPostedOutfit(c)
if (result instanceof Response) return result
return c.json(result)
}
)
// v4 of the save-outfit route. Same payload, table and slot-overwrite semantics as v3
// (see above) — newer clients moved to /v4/saved/set. The one difference is the response:
// v4 answers a lean `{ Success, Slot }` acknowledgement rather than echoing the whole
// outfit back. The outfit is read back through /api/avatar/v3/saved either way.
.post(
'/api/avatar/v4/saved/set',
describeRoute({
tags: ['Avatar'],
summary: 'Save an outfit into a slot (v4)',
description: [
'Writes the posted outfit into the given `Slot` (overwriting it), same as',
'`POST /api/avatar/v3/saved/set`, but answers a lean `{ Success, Slot }` ack instead',
'of echoing the outfit. A missing/non-integer `Slot` is a 400.',
].join(' '),
security: AUTHED,
requestBody: jsonBody(SaveOutfitRequest, 'The outfit, with a target Slot'),
responses: {
200: json(SaveOutfitV4Response, 'Save acknowledgement'),
400: { description: 'Non-object body or missing/non-integer Slot (empty body)' },
401: UNAUTHORIZED_RESPONSE,
},
}),
async (c) => {
const result = await persistPostedOutfit(c)
if (result instanceof Response) return result
return c.json({ Success: true, Slot: result.Slot })
}
)
+9
View File
@@ -177,6 +177,15 @@ export const SaveOutfitRequest = z
.catchall(z.unknown())
.describe('Plus opaque outfit fields (OutfitSelectionsV2, FaceFeatures, …) stored verbatim')
/**
* `POST /api/avatar/v4/saved/set` response — a lean acknowledgement. Unlike v3 (which
* echoes the whole outfit), v4 answers just the success flag and the slot it wrote.
*/
export const SaveOutfitV4Response = z.object({
Success: z.boolean(),
Slot: z.int().describe('The slot that was written'),
})
/**
* `PUT /api/equipment/v1/update` JSON body — the client's favourite toggles. It echoes
* back the whole entry it was served, but only `Favorited` is written; the rest is
@@ -356,6 +356,39 @@ describe('econ endpoints', () => {
expect(res.status).toBe(400)
})
test('POST /api/avatar/v4/saved/set stores like v3 but acks with { Success, Slot }', async () => {
const anon = await exports.default.fetch(`${ORIGIN}/api/avatar/v4/saved/set`, {
method: 'POST',
body: JSON.stringify(SAVED_OUTFIT),
})
expect(anon.status).toBe(401)
const res = await exports.default.fetch(`${ORIGIN}/api/avatar/v4/saved/set`, {
method: 'POST',
headers: { ...(await bearer('25')), 'Content-Type': 'application/json' },
body: JSON.stringify(SAVED_OUTFIT),
})
expect(res.status).toBe(200)
// v4 answers a lean ack, not the echoed outfit.
expect(await res.json()).toEqual({ Success: true, Slot: SAVED_OUTFIT.Slot })
// Shares the v3 outfit table, so the v3 read serves the outfit back verbatim.
const saved = await exports.default.fetch(`${ORIGIN}/api/avatar/v3/saved`, {
headers: await bearer('25'),
})
expect(await saved.json()).toEqual([SAVED_OUTFIT])
})
test('POST /api/avatar/v4/saved/set 400s without an integer Slot', async () => {
const { Slot: _Slot, ...noSlot } = SAVED_OUTFIT
const res = await exports.default.fetch(`${ORIGIN}/api/avatar/v4/saved/set`, {
method: 'POST',
headers: { ...(await bearer('26')), 'Content-Type': 'application/json' },
body: JSON.stringify(noSlot),
})
expect(res.status).toBe(400)
})
test('GET /api/avatar/v2/gifts 401s without a token, returns [] with one', async () => {
const anon = await exports.default.fetch(`${ORIGIN}/api/avatar/v2/gifts`)
expect(anon.status).toBe(401)
@@ -1156,6 +1189,7 @@ describe('econ endpoints', () => {
'POST /api/avatar/v2/gifts/consume',
'POST /api/avatar/v2/set',
'POST /api/avatar/v3/saved/set',
'POST /api/avatar/v4/saved/set',
'POST /api/challenge/v2/updateProgress',
'POST /api/consumables/v1/consume',
'POST /api/gamerewards/v1/request',