diff --git a/apps/api/migrations/0032_custom_avatar_item_price_cap.sql b/apps/api/migrations/0032_custom_avatar_item_price_cap.sql new file mode 100644 index 0000000..f021587 --- /dev/null +++ b/apps/api/migrations/0032_custom_avatar_item_price_cap.sql @@ -0,0 +1,18 @@ +-- Player-made custom shirts are capped at 1000 tokens (`MAX_PLAYER_ITEM_PRICE` in +-- src/custom-avatar-items-db.ts): the create and the edit now refuse a price above it, and +-- this lowers every existing player-made row that is already over it to exactly the cap. +-- +-- Only PLAYER-MADE rows are touched, told apart by `BaseAvatarItemId`: a player-made shirt +-- is built on a base item, while a first-party item (imported from the official export, +-- Coach-authored) has it NULL and is priced from the storefront dump, where several sit +-- well above 1000. Those keep their prices. +-- +-- `ModifiedAt` is bumped as the edit endpoint bumps it, since the record did change. +-- `price` is a generated column off `data`, so rewriting the JSON is the whole update. + +UPDATE custom_avatar_item +SET data = json_set(data, + '$.Price', 1000, + '$.ModifiedAt', strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) +WHERE json_extract(data, '$.BaseAvatarItemId') IS NOT NULL + AND price > 1000; diff --git a/apps/api/src/custom-avatar-items-db.ts b/apps/api/src/custom-avatar-items-db.ts index dc6d914..e2b7a1a 100644 --- a/apps/api/src/custom-avatar-items-db.ts +++ b/apps/api/src/custom-avatar-items-db.ts @@ -131,6 +131,16 @@ export interface CustomAvatarItem { */ export const OUTFIT_TYPE_CUSTOM_SHIRT = 105 +/** + * The most tokens a PLAYER-MADE item may sell for, inclusive. Enforced on the create and + * the edit (`POST`/`PUT /api/customAvatarItems/v1`), the only two writes a player has; the + * client has no ceiling of its own — its `minPriceForPublicItem` is a floor. First-party + * items are not bound by it: their prices come off the storefront dump and several sit + * above it (migrations/0032_custom_avatar_item_price_cap.sql clamps only the player-made + * rows, told apart by `BaseAvatarItemId`). + */ +export const MAX_PLAYER_ITEM_PRICE = 1000 + /** What `POST /api/customAvatarItems/v1` needs to create an item. */ export interface CreateCustomAvatarItemInput { /** The item's id. Chosen by the caller because the upload keys are derived from it. */ diff --git a/apps/api/src/openapi.ts b/apps/api/src/openapi.ts index 193116c..f9a49f1 100644 --- a/apps/api/src/openapi.ts +++ b/apps/api/src/openapi.ts @@ -361,7 +361,7 @@ export const CheerPlayerResponse = z.object({ export const CreateCustomAvatarItemMetadata = z.object({ Name: z.string(), Description: z.string().optional(), - Price: z.number().int().optional(), + Price: z.number().int().min(0).max(1000).optional().describe('Tokens, 0–1000; 0 when left out'), BaseAvatarItemId: z.number().int(), BaseAvatarItemColor: z.string().describe('Hex colour, e.g. `#F55C1A`'), Accessibility: z.number().int().optional(), @@ -434,7 +434,7 @@ export const CustomAvatarItemDto = z.object({ export const UpdateCustomAvatarItemRequest = z.object({ Name: z.string().nullable().optional(), Description: z.string().nullable().optional(), - Price: z.number().int().nullable().optional(), + Price: z.number().int().min(0).max(1000).nullable().optional().describe('Tokens, 0–1000'), Accessibility: z.number().int().nullable().optional(), }) diff --git a/apps/api/src/routes/avatar.ts b/apps/api/src/routes/avatar.ts index d31219d..491e7ec 100644 --- a/apps/api/src/routes/avatar.ts +++ b/apps/api/src/routes/avatar.ts @@ -23,6 +23,7 @@ import { listCustomAvatarItemsByCreator, listFeaturedCustomAvatarItems, listHotCustomAvatarItems, + MAX_PLAYER_ITEM_PRICE, searchCustomAvatarItems, toQuestCustomAvatarItem, updateCustomAvatarItem, @@ -473,12 +474,17 @@ export const avatarRoutes = new Hono({ strict: false }) 'which is what the store’s user-generated-content tab searches for.\n\n' + 'The two files go to the shared image bucket (`recflare-img`) under ' + '`avatar-item//-thumb.png` and `avatar-item//-design.png`; those ' + - 'keys are the `ThumbnailImageFilename` / `DesignFilename` on the row.', + 'keys are the `ThumbnailImageFilename` / `DesignFilename` on the row.\n\n' + + '`Price` is capped at 1000 tokens (`MAX_PLAYER_ITEM_PRICE`); one above it, or below ' + + '0, is refused with a 400 rather than clamped.', security: AUTHED, requestBody: form(CreateCustomAvatarItemRequest, 'The metadata and the two files'), responses: { 200: json(CustomAvatarItemResponse, 'The created item'), - 400: json(CustomAvatarItemResponse, 'Missing or malformed metadata / files'), + 400: json( + CustomAvatarItemResponse, + 'Missing or malformed metadata / files, or a `Price` outside 0–1000' + ), 401: UNAUTHORIZED_RESPONSE, 413: json(CustomAvatarItemResponse, 'Either file exceeds the configured per-file limit'), }, @@ -504,6 +510,13 @@ export const avatarRoutes = new Hono({ strict: false }) if (typeof meta.BaseAvatarItemId !== 'number') return fail('BaseAvatarItemId is required') if (typeof meta.BaseAvatarItemColor !== 'string') return fail('BaseAvatarItemColor is required') + if (meta.Price !== undefined && meta.Price !== null) { + if (typeof meta.Price !== 'number' || !Number.isInteger(meta.Price)) + return fail('Price must be an integer') + if (meta.Price < 0) return fail('Price must be >= 0') + if (meta.Price > MAX_PLAYER_ITEM_PRICE) + return fail(`Price must be <= ${MAX_PLAYER_ITEM_PRICE}`) + } if (!(body.thumbnailImage instanceof File)) return fail('thumbnailImage is required') if (!(body.design instanceof File)) return fail('design is required') const limit = maxApiUploadBytes(c.env) @@ -572,13 +585,14 @@ export const avatarRoutes = new Hono({ strict: false }) 'A partial edit of `Name`, `Description`, `Price` and `Accessibility` — the client ' + 'sends every field and nulls the ones it is not changing, so null means "leave ' + 'alone". Only the creator may edit. `ModifiedAt` is bumped. Answers the updated ' + - 'item in the same `{ Value, Success, Error, error_id }` envelope as the create.', + 'item in the same `{ Value, Success, Error, error_id }` envelope as the create. ' + + '`Price` is bound the same way as on the create: 0 to 1000 tokens, or a 400.', security: AUTHED, parameters: [stringParam('id', 'The `CustomAvatarItemId`')], requestBody: jsonBody(UpdateCustomAvatarItemRequest, 'The fields to change'), responses: { 200: json(CustomAvatarItemResponse, 'The updated item'), - 400: json(CustomAvatarItemResponse, 'Malformed body'), + 400: json(CustomAvatarItemResponse, 'Malformed body, or a `Price` outside 0–1000'), 401: UNAUTHORIZED_RESPONSE, 403: json(CustomAvatarItemResponse, 'Not the creator'), 404: json(CustomAvatarItemResponse, 'No such item'), @@ -621,6 +635,9 @@ export const avatarRoutes = new Hono({ strict: false }) } if (patch.name !== null && patch.name?.trim() === '') return fail(400, 'Name must not be blank') + if (patch.price !== null && patch.price < 0) return fail(400, 'Price must be >= 0') + if (patch.price !== null && patch.price > MAX_PLAYER_ITEM_PRICE) + return fail(400, `Price must be <= ${MAX_PLAYER_ITEM_PRICE}`) const item = await updateCustomAvatarItem(c.env.DB, itemId, patch) if (!item) return fail(404, 'No such item') diff --git a/apps/api/src/test/integration/api.test.ts b/apps/api/src/test/integration/api.test.ts index a50aae1..933ffd4 100644 --- a/apps/api/src/test/integration/api.test.ts +++ b/apps/api/src/test/integration/api.test.ts @@ -42,9 +42,12 @@ import { banEvasionMatch, resolveBan } from '../../bans-db' import { createCustomAvatarItem, SCHEMA_DDL as CUSTOM_AVATAR_ITEM_SCHEMA_DDL, + getCustomAvatarItem, importCustomAvatarItem, + MAX_PLAYER_ITEM_PRICE, } from '../../custom-avatar-items-db' import { customAvatarItemRowLiteral } from '../../custom-avatar-items-load' +import priceCapSql from '../../../migrations/0032_custom_avatar_item_price_cap.sql?raw' import { countGoing, SCHEMA_DDL as EVENTS_SCHEMA_DDL, @@ -4606,6 +4609,138 @@ describe('custom avatar items', () => { expect(await (await exports.default.fetch(url, { method: 'PUT' })).status).toBe(401) }) + // A player-made item may not sell for more than MAX_PLAYER_ITEM_PRICE (1000) tokens. The + // client has no ceiling of its own (`minPriceForPublicItem` is a floor), so both writes a + // player has enforce it — refusing, not clamping, so the creator sees what was set. + test('the price is capped at 1000 tokens on create and edit', async () => { + expect(MAX_PLAYER_ITEM_PRICE).toBe(1000) + const create = async (Price: unknown) => { + const form = new FormData() + form.set( + 'metadata', + JSON.stringify({ Name: 'Pricey', BaseAvatarItemId: 1, BaseAvatarItemColor: '#fff', Price }) + ) + form.set('thumbnailImage', new File([new Uint8Array([1])], 't.png', { type: 'image/png' })) + form.set('design', new File([new Uint8Array([2])], 'd.png', { type: 'image/png' })) + return exports.default.fetch(`${ORIGIN}/api/customAvatarItems/v1`, { + method: 'POST', + headers: await bearer('205'), + body: form, + }) + } + const over = await create(1001) + expect(over.status).toBe(400) + expect(await over.json()).toMatchObject({ + Success: false, + Value: null, + Error: 'Price must be <= 1000', + }) + expect((await create(-1)).status).toBe(400) + expect((await create(10.5)).status).toBe(400) + // Nothing was written for the refused ones. + expect( + await env.DB.prepare(`SELECT count(*) AS n FROM custom_avatar_item WHERE name_lower = 'pricey'`) + .first<{ n: number }>() + .then((r) => r!.n) + ).toBe(0) + + // The cap itself is allowed, inclusive; a missing price is still 0. + const atCap = await create(1000) + expect(atCap.status).toBe(200) + const { Value } = (await atCap.json()) as { Value: { CustomAvatarItemId: string; Price: number } } + expect(Value.Price).toBe(1000) + expect(((await (await create(undefined)).json()) as { Value: { Price: number } }).Value.Price).toBe(0) + + // The edit is bound the same way, and a refused edit leaves the row as it was. + const url = `${ORIGIN}/api/customAvatarItems/v1/${Value.CustomAvatarItemId}` + const edit = async (Price: number) => + exports.default.fetch(url, { + method: 'PUT', + headers: { ...(await bearer('205')), 'content-type': 'application/json' }, + body: JSON.stringify({ Name: null, Description: null, Price, Accessibility: null }), + }) + const tooMuch = await edit(1001) + expect(tooMuch.status).toBe(400) + expect(await tooMuch.json()).toMatchObject({ Success: false, Error: 'Price must be <= 1000' }) + expect((await edit(-1)).status).toBe(400) + expect((await getCustomAvatarItem(env.DB, Value.CustomAvatarItemId))!.Price).toBe(1000) + const lowered = await edit(999) + expect(lowered.status).toBe(200) + expect(((await lowered.json()) as { Value: { Price: number } }).Value.Price).toBe(999) + }) + + // The cap arrived after players had already priced shirts. Migration 0032 lowers every + // PLAYER-MADE row over it to exactly the cap and leaves first-party items — priced from the + // storefront dump, several above 1000 — alone; the two are told apart by `BaseAvatarItemId`. + // Tests build the schema from SCHEMA_DDL rather than the migrations, so the file's UPDATE + // is run here by hand. + test('migration 0032 lowers existing player-made prices to the cap', async () => { + const shirt = (name: string, price: number) => + createCustomAvatarItem( + env.DB, + { + customAvatarItemId: crypto.randomUUID(), + creatorAccountId: 205, + name, + description: '', + price, + baseAvatarItemId: 1, + baseAvatarItemColor: '#fff', + accessibility: 1, + designFilename: 'd', + thumbnailImageFilename: 't', + }, + new Date('2026-08-01T00:00:00Z') + ) + const dear = await shirt('Dear', 5000) + const fair = await shirt('Fair', 1000) + const cheap = await shirt('Cheap', 50) + // A first-party item over the cap: no base item, Coach-authored. + const firstPartyId = crypto.randomUUID() + await importCustomAvatarItem(env.DB, { + CustomAvatarItemId: firstPartyId, + RankedEntityId: firstPartyId, + CreatorAccountId: 1, + Name: 'Studio Wings', + Description: null, + Price: 6000, + Accessibility: 1, + OutfitType: 100, + BaseAvatarItemId: null, + BaseAvatarItemColor: null, + DesignFilename: null, + ThumbnailImageFilename: null, + ForceCannotPublish: false, + IsFeatured: false, + IsRecRoomApproved: true, + PreviewOrientation: 0, + RankingContext: null, + CreatedAt: '2024-09-26T21:32:48.523Z', + ModifiedAt: '2025-02-19T05:19:28.328Z', + CurrentSaves: [], + Tags: [], + CustomBadgeMetadata: null, + }) + + const statements = priceCapSql + .split('\n') + .filter((line) => !line.startsWith('--')) + .join('\n') + .split(';') + .map((sql) => sql.trim()) + .filter(Boolean) + expect(statements).toHaveLength(1) + await env.DB.prepare(statements[0]!).run() + + const price = async (id: string) => (await getCustomAvatarItem(env.DB, id))! + expect((await price(dear.CustomAvatarItemId)).Price).toBe(1000) + // The lowered row is marked modified, as an edit would mark it; the untouched ones aren't. + expect((await price(dear.CustomAvatarItemId)).ModifiedAt).not.toBe(dear.ModifiedAt) + expect(await price(fair.CustomAvatarItemId)).toMatchObject({ Price: 1000, ModifiedAt: fair.ModifiedAt }) + expect(await price(cheap.CustomAvatarItemId)).toMatchObject({ Price: 50, ModifiedAt: cheap.ModifiedAt }) + expect((await price(firstPartyId)).Price).toBe(6000) + }) + test('DELETE removes the creator’s item and its bucket objects', async () => { // Create through the endpoint so the objects really exist in the bucket. const form = new FormData()