[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
@@ -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;
+10
View File
@@ -131,6 +131,16 @@ export interface CustomAvatarItem {
*/ */
export const OUTFIT_TYPE_CUSTOM_SHIRT = 105 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. */ /** What `POST /api/customAvatarItems/v1` needs to create an item. */
export interface CreateCustomAvatarItemInput { export interface CreateCustomAvatarItemInput {
/** The item's id. Chosen by the caller because the upload keys are derived from it. */ /** 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({ export const CreateCustomAvatarItemMetadata = z.object({
Name: z.string(), Name: z.string(),
Description: z.string().optional(), 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(), BaseAvatarItemId: z.number().int(),
BaseAvatarItemColor: z.string().describe('Hex colour, e.g. `#F55C1A`'), BaseAvatarItemColor: z.string().describe('Hex colour, e.g. `#F55C1A`'),
Accessibility: z.number().int().optional(), Accessibility: z.number().int().optional(),
@@ -434,7 +434,7 @@ export const CustomAvatarItemDto = z.object({
export const UpdateCustomAvatarItemRequest = z.object({ export const UpdateCustomAvatarItemRequest = z.object({
Name: z.string().nullable().optional(), Name: z.string().nullable().optional(),
Description: 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(), Accessibility: z.number().int().nullable().optional(),
}) })
+21 -4
View File
@@ -23,6 +23,7 @@ import {
listCustomAvatarItemsByCreator, listCustomAvatarItemsByCreator,
listFeaturedCustomAvatarItems, listFeaturedCustomAvatarItems,
listHotCustomAvatarItems, listHotCustomAvatarItems,
MAX_PLAYER_ITEM_PRICE,
searchCustomAvatarItems, searchCustomAvatarItems,
toQuestCustomAvatarItem, toQuestCustomAvatarItem,
updateCustomAvatarItem, 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' + '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 ' + '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 ' + '`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, security: AUTHED,
requestBody: form(CreateCustomAvatarItemRequest, 'The metadata and the two files'), requestBody: form(CreateCustomAvatarItemRequest, 'The metadata and the two files'),
responses: { responses: {
200: json(CustomAvatarItemResponse, 'The created item'), 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, 401: UNAUTHORIZED_RESPONSE,
413: json(CustomAvatarItemResponse, 'Either file exceeds the configured per-file limit'), 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.BaseAvatarItemId !== 'number') return fail('BaseAvatarItemId is required')
if (typeof meta.BaseAvatarItemColor !== 'string') if (typeof meta.BaseAvatarItemColor !== 'string')
return fail('BaseAvatarItemColor is required') 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.thumbnailImage instanceof File)) return fail('thumbnailImage is required')
if (!(body.design instanceof File)) return fail('design is required') if (!(body.design instanceof File)) return fail('design is required')
const limit = maxApiUploadBytes(c.env) 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 ' + '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 ' + '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 ' + '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, security: AUTHED,
parameters: [stringParam('id', 'The `CustomAvatarItemId`')], parameters: [stringParam('id', 'The `CustomAvatarItemId`')],
requestBody: jsonBody(UpdateCustomAvatarItemRequest, 'The fields to change'), requestBody: jsonBody(UpdateCustomAvatarItemRequest, 'The fields to change'),
responses: { responses: {
200: json(CustomAvatarItemResponse, 'The updated item'), 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, 401: UNAUTHORIZED_RESPONSE,
403: json(CustomAvatarItemResponse, 'Not the creator'), 403: json(CustomAvatarItemResponse, 'Not the creator'),
404: json(CustomAvatarItemResponse, 'No such item'), 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() === '') if (patch.name !== null && patch.name?.trim() === '')
return fail(400, 'Name must not be blank') 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) const item = await updateCustomAvatarItem(c.env.DB, itemId, patch)
if (!item) return fail(404, 'No such item') if (!item) return fail(404, 'No such item')
+135
View File
@@ -42,9 +42,12 @@ import { banEvasionMatch, resolveBan } from '../../bans-db'
import { import {
createCustomAvatarItem, createCustomAvatarItem,
SCHEMA_DDL as CUSTOM_AVATAR_ITEM_SCHEMA_DDL, SCHEMA_DDL as CUSTOM_AVATAR_ITEM_SCHEMA_DDL,
getCustomAvatarItem,
importCustomAvatarItem, importCustomAvatarItem,
MAX_PLAYER_ITEM_PRICE,
} from '../../custom-avatar-items-db' } from '../../custom-avatar-items-db'
import { customAvatarItemRowLiteral } from '../../custom-avatar-items-load' import { customAvatarItemRowLiteral } from '../../custom-avatar-items-load'
import priceCapSql from '../../../migrations/0032_custom_avatar_item_price_cap.sql?raw'
import { import {
countGoing, countGoing,
SCHEMA_DDL as EVENTS_SCHEMA_DDL, 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) 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 () => { test('DELETE removes the creator’s item and its bucket objects', async () => {
// Create through the endpoint so the objects really exist in the bucket. // Create through the endpoint so the objects really exist in the bucket.
const form = new FormData() const form = new FormData()