[api] prevent money laundering

This commit is contained in:
Devin Zuczek
2026-09-27 23:54:12 -04:00
parent ab15eb9768
commit 83ccb4bc12
5 changed files with 186 additions and 6 deletions
+10
View File
@@ -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. */
+2 -2
View File
@@ -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(),
})
+21 -4
View File
@@ -23,6 +23,7 @@ import {
listCustomAvatarItemsByCreator,
listFeaturedCustomAvatarItems,
listHotCustomAvatarItems,
MAX_PLAYER_ITEM_PRICE,
searchCustomAvatarItems,
toQuestCustomAvatarItem,
updateCustomAvatarItem,
@@ -473,12 +474,17 @@ export const avatarRoutes = new Hono<App>({ 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/<date>/<id>-thumb.png` and `avatar-item/<date>/<id>-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<App>({ 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<App>({ 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<App>({ 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')
+135
View File
@@ -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()