diff --git a/apps/econ/src/econ.app.ts b/apps/econ/src/econ.app.ts index a5df436..1ce5e99 100644 --- a/apps/econ/src/econ.app.ts +++ b/apps/econ/src/econ.app.ts @@ -51,6 +51,7 @@ import { JsonObject, OpaqueJsonBody, SaveOutfitRequest, + SaveOutfitV4Response, SubscriptionResponse, UNAUTHORIZED_RESPONSE, } from './openapi' @@ -88,6 +89,26 @@ function unauthorized(c: Context) { 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): Promise { + const id = await authedId(c) + if (id === null) return unauthorized(c) + const body = (await c.req.json().catch(() => null)) as Record | 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({ 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({ 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 | 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 }) } ) diff --git a/apps/econ/src/openapi.ts b/apps/econ/src/openapi.ts index 6090622..ab0fdaf 100644 --- a/apps/econ/src/openapi.ts +++ b/apps/econ/src/openapi.ts @@ -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 diff --git a/apps/econ/src/test/integration/api.test.ts b/apps/econ/src/test/integration/api.test.ts index 5cfbad1..e03dd82 100644 --- a/apps/econ/src/test/integration/api.test.ts +++ b/apps/econ/src/test/integration/api.test.ts @@ -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',