[plus] enforce aura/paint/emoji/selling for plus, since client removed

This commit is contained in:
Devin Zuczek
2026-10-28 10:54:17 -04:00
parent 1b10c14801
commit 5b9520e4dd
10 changed files with 691 additions and 134 deletions
+7
View File
@@ -147,6 +147,13 @@ export const OUTFIT_TYPE_CUSTOM_SHIRT = 105
*/
export const MAX_PLAYER_ITEM_PRICE = 1000
/**
* The fewest tokens a player-made item may sell for: a price is 0 (free, open to anyone) or
* at least this. Served as `minPriceForPublicItem`, which the client's creation UI enforces,
* and checked on the create and the edit as well so the API can't price an item at 1.
*/
export const MIN_PLAYER_ITEM_PRICE = 100
/** 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. */
+10 -1
View File
@@ -1,4 +1,4 @@
import { validateAndGetAccountId, validateAndGetRoles } from '@repo/jwt'
import { validateAndGetAccountId, validateAndGetPlus, validateAndGetRoles } from '@repo/jwt'
import type { Context } from 'hono'
import type { App } from './context'
@@ -22,6 +22,15 @@ export async function authedRoles(c: Context<App>): Promise<string[] | null> {
return validateAndGetRoles(c.req.raw, await c.env.JWT_SECRET.get())
}
/**
* Whether the caller's token carries the `rn.plus` claim — Rec Room Plus, stamped by `auth`
* from `account.hasPlus` at sign-in (so a newly granted player must sign in again). The same
* test as `econ`'s `isSubscriber`; nothing re-reads the account for it.
*/
export async function authedPlus(c: Context<App>): Promise<boolean> {
return validateAndGetPlus(c.req.raw, await c.env.JWT_SECRET.get())
}
/** Results.Unauthorized() equivalent — 401 with empty body. */
export function unauthorized(c: Context<App>) {
return c.body(null, 401)
+134 -14
View File
@@ -25,11 +25,12 @@ import {
listFeaturedCustomAvatarItems,
listHotCustomAvatarItems,
MAX_PLAYER_ITEM_PRICE,
MIN_PLAYER_ITEM_PRICE,
searchCustomAvatarItems,
toQuestCustomAvatarItem,
updateCustomAvatarItem,
} from '../custom-avatar-items-db'
import { authedId, unauthorized } from '../http'
import { authedId, authedPlus, unauthorized } from '../http'
import {
createInvention,
deleteInvention,
@@ -135,6 +136,59 @@ import type { InventionTag, SavedInvention } from '../inventions-db'
*/
const BULK_CUSTOM_AVATAR_ITEM_CAP = 100
/** The all-zero Guid — what a C# client sends for an unset Guid field. */
const EMPTY_GUID = '00000000-0000-0000-0000-000000000000'
/**
* The refusal for putting a price above 0 on an invention without Rec Room Plus (the token's
* `rn.plus` claim). Selling for tokens is a Plus perk; making an invention free is not, so a
* player whose Plus lapsed can still take theirs off sale.
*/
const INVENTION_PRICE_NEEDS_PLUS = 'Rec Room Plus is required to sell an invention'
/** The same refusal for a custom avatar item — selling one for tokens is a Plus perk too. */
const CUSTOM_ITEM_PRICE_NEEDS_PLUS = 'Rec Room Plus is required to sell a custom avatar item'
/**
* The Rec Room Plus perks an outfit body wears, by name — empty when it wears none. Both
* live in `CustomizationSettings`, the client's JSON document encoded as a string (an
* object is accepted too, for a hand-written request):
*
* - an aura, `AuraData.Id`;
* - body paint, `BodyPaint` (a Guid string).
*
* The client's "none" spelling for either hasn't been observed, so every plausible one
* reads as none — a missing/null `AuraData` or an `Id` of 0, and a missing, null, empty or
* all-zero `BodyPaint`. Erring the other way would refuse every non-Plus save if that is
* how the client says "none". A settings string that isn't JSON names no perks either: it's
* stored verbatim and nothing here can read one out of it.
*/
function plusPerksWorn(body: Record<string, unknown>): string[] {
let settings: unknown = body.CustomizationSettings
if (typeof settings === 'string') {
try {
settings = JSON.parse(settings)
} catch {
return []
}
}
if (typeof settings !== 'object' || settings === null) return []
const { AuraData, BodyPaint } = settings as Record<string, unknown>
const perks: string[] = []
const auraId =
typeof AuraData === 'object' && AuraData !== null
? (AuraData as Record<string, unknown>).Id
: undefined
if (auraId !== undefined && auraId !== null && auraId !== 0 && auraId !== '' && auraId !== '0') {
perks.push('an aura')
}
if (typeof BodyPaint === 'string' && BodyPaint !== '' && BodyPaint !== EMPTY_GUID) {
perks.push('body paint')
}
return perks
}
/**
* The ids `POST /api/customAvatarItems/v1/bulk` was asked to resolve. They ride as repeated
* `customAvatarItemIds` form fields, and the same spelling is read off the query string
@@ -462,10 +516,11 @@ export const avatarRoutes = new Hono<App>({ strict: false })
tags: ['Avatar'],
summary: 'Minimum token price for a public custom item',
description:
'The floor the creation UI enforces when listing a custom item publicly. A fixed `100`.',
'The floor the creation UI enforces when listing a custom item publicly. A fixed `100`' +
' (`MIN_PLAYER_ITEM_PRICE`), which the create and edit enforce too: a price is 0 or at least this.',
responses: { 200: json(BareInteger, 'A bare `100`') },
}),
(c) => c.json(100)
(c) => c.json(MIN_PLAYER_ITEM_PRICE)
)
.post(
'/api/customAvatarItems/v1',
@@ -486,16 +541,21 @@ export const avatarRoutes = new Hono<App>({ strict: false })
'from that signature, and anything else is refused with a 400 before either file is ' +
'written.\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.',
'0, is refused with a 400 rather than clamped, and so is one between 1 and 99 — a ' +
'price is 0 (free) or at least 100 (`MIN_PLAYER_ITEM_PRICE`, the ' +
'`minPriceForPublicItem` floor). A `Price` above 0 needs Rec Room ' +
'Plus (the token’s `rn.plus` claim); without it the create is refused with a 403 ' +
'and nothing is written.',
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, a file that is not an image, or a `Price` outside 0–1000'
'Missing or malformed metadata / files, a file that is not an image, or a `Price` that is not 0 or 100–1000'
),
401: UNAUTHORIZED_RESPONSE,
403: json(CustomAvatarItemResponse, 'A `Price` above 0 without Rec Room Plus'),
413: json(CustomAvatarItemResponse, 'Either file exceeds the configured per-file limit'),
},
}),
@@ -526,6 +586,16 @@ export const avatarRoutes = new Hono<App>({ strict: false })
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 (meta.Price > 0 && meta.Price < MIN_PLAYER_ITEM_PRICE)
return fail(`Price must be 0 or >= ${MIN_PLAYER_ITEM_PRICE}`)
// Selling for tokens is a Rec Room Plus perk; a free item needs none. Checked
// before the files are read, so a refusal writes nothing to R2.
if (meta.Price > 0 && !(await authedPlus(c))) {
return c.json(
{ Value: null, Success: false, Error: CUSTOM_ITEM_PRICE_NEEDS_PLUS, error_id: null },
403
)
}
}
if (!(body.thumbnailImage instanceof File)) return fail('thumbnailImage is required')
if (!(body.design instanceof File)) return fail('design is required')
@@ -607,15 +677,22 @@ export const avatarRoutes = new Hono<App>({ strict: false })
'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. ' +
'`Price` is bound the same way as on the create: 0 to 1000 tokens, or a 400.',
'`Price` is bound the same way as on the create: 0, or 100 to 1000 tokens, or a 400; and ' +
'setting one above 0 needs Rec Room Plus (the token’s `rn.plus` claim), or a 403.',
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, or a `Price` outside 0–1000'),
400: json(
CustomAvatarItemResponse,
'Malformed body, or a `Price` that is not 0 or 100–1000'
),
401: UNAUTHORIZED_RESPONSE,
403: json(CustomAvatarItemResponse, 'Not the creator'),
403: json(
CustomAvatarItemResponse,
'Not the creator, or a `Price` above 0 without Rec Room Plus'
),
404: json(CustomAvatarItemResponse, 'No such item'),
},
}),
@@ -659,6 +736,12 @@ export const avatarRoutes = new Hono<App>({ strict: false })
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}`)
if (patch.price !== null && patch.price > 0 && patch.price < MIN_PLAYER_ITEM_PRICE)
return fail(400, `Price must be 0 or >= ${MIN_PLAYER_ITEM_PRICE}`)
// Setting a price above 0 is a Rec Room Plus perk. Null (leave it) and 0 (make it
// free) need none, so a creator whose Plus lapsed can still take an item off sale.
if (patch.price !== null && patch.price > 0 && !(await authedPlus(c)))
return fail(403, CUSTOM_ITEM_PRICE_NEEDS_PLUS)
const item = await updateCustomAvatarItem(c.env.DB, itemId, patch)
if (!item) return fail(404, 'No such item')
@@ -1075,13 +1158,23 @@ export const avatarRoutes = new Hono<App>({ strict: false })
'client’s own serializer, so nothing here parses or re-encodes them.\n\n' +
'The response is the base envelope with no `Value` key — three keys, and the outfit ' +
'is not echoed back. The mixed casing (`Success`/`Error` but `error_id`) is the ' +
'reference’s, not a typo.',
'reference’s, not a typo.\n\n' +
'An aura (`CustomizationSettings.AuraData.Id`) and body paint ' +
'(`CustomizationSettings.BodyPaint`) are Rec Room Plus perks: an outfit wearing ' +
'either, without the token’s `rn.plus` claim the save is refused with a 403 and ' +
'`Success: false`, and nothing is stored.',
security: AUTHED,
requestBody: jsonBody(OutfitsMeRequest, 'The outfit to save'),
responses: {
200: json(OutfitSaveResponse, 'Saved — `{ Success: true, Error: null, error_id: null }`'),
400: json(ErrorResponse, 'Unparseable body'),
401: UNAUTHORIZED_RESPONSE,
403: json(
OutfitSaveResponse,
'The outfit wears an aura (`CustomizationSettings.AuraData.Id`) or body paint ' +
'(`CustomizationSettings.BodyPaint`) and the caller’s ' +
'token has no Rec Room Plus (`rn.plus`) claim — `Success: false`, nothing saved'
),
},
}),
async (c) => {
@@ -1091,6 +1184,22 @@ export const avatarRoutes = new Hono<App>({ strict: false })
const body = (await c.req.json().catch(() => null)) as Record<string, unknown> | null
if (body === null) return c.json({ error: 'Invalid request body' }, 400)
// Auras and body paint are Plus perks: an outfit wearing either is refused outright
// for a caller without the token's `rn.plus` claim, rather than saved with the perk
// stripped — the client keeps what it sent, so a silent strip would show the player
// something nobody else sees.
const perks = plusPerksWorn(body)
if (perks.length > 0 && !(await authedPlus(c))) {
return c.json(
{
Success: false,
Error: `Rec Room Plus is required to wear ${perks.join(' or ')}`,
error_id: null,
},
403
)
}
// The client sends `Slot`; a body without one saves the worn outfit.
const outfit = {
...body,
@@ -1498,7 +1607,8 @@ export const avatarRoutes = new Hono<App>({ strict: false })
'What puts an invention into search and the feeds. Sets the permission other ' +
'players get (defaulting to UseOnly) and its price. Another GET that writes. A ' +
'price over 1000 tokens (`MAX_INVENTION_PRICE`) refuses the whole publish with a ' +
'400 rather than being clamped or dropped.',
'400 rather than being clamped or dropped. A price above 0 needs Rec Room Plus ' +
'(the token’s `rn.plus` claim); without it the publish is refused with a 403.',
security: AUTHED,
parameters: [
intQuery('inventionId', 'Invention id; required'),
@@ -1509,7 +1619,7 @@ export const avatarRoutes = new Hono<App>({ strict: false })
200: json(InventionSaveResult, 'The published invention, in the save envelope'),
400: json(ErrorResponse, 'A price over 1000'),
401: UNAUTHORIZED_RESPONSE,
403: json(ErrorResponse, 'Not the caller’s invention'),
403: json(ErrorResponse, 'Not the caller’s invention, or a price without Rec Room Plus'),
404: { description: 'No such invention' },
},
}),
@@ -1521,6 +1631,8 @@ export const avatarRoutes = new Hono<App>({ strict: false })
const price = Number.parseInt(c.req.query('price') ?? '', 10)
if (price > MAX_INVENTION_PRICE)
return c.json({ error: `Price must be <= ${MAX_INVENTION_PRICE}` }, 400)
if (price > 0 && !(await authedPlus(c)))
return c.json({ error: INVENTION_PRICE_NEEDS_PLUS }, 403)
const published = await publishInvention(c.env.DB, gate.invention.InventionId, {
permissionLevel:
@@ -1540,14 +1652,15 @@ export const avatarRoutes = new Hono<App>({ strict: false })
summary: 'Set an invention’s price',
description:
'Unlike update/publish, this one POSTs a JSON body. Creator only; a negative price ' +
'is rejected, as is one over 1000 tokens (`MAX_INVENTION_PRICE`).',
'is rejected, as is one over 1000 tokens (`MAX_INVENTION_PRICE`). A price above 0 ' +
'needs Rec Room Plus (the token’s `rn.plus` claim); without it, 403. Setting 0 does not.',
security: AUTHED,
requestBody: jsonBody(UpdatePriceRequest, 'The invention and its new price'),
responses: {
200: json(InventionSaveResult, 'The repriced invention, in the save envelope'),
400: json(ErrorResponse, 'Unparseable body, or a price below 0 or over 1000'),
401: UNAUTHORIZED_RESPONSE,
403: json(ErrorResponse, 'Not the caller’s invention'),
403: json(ErrorResponse, 'Not the caller’s invention, or a price without Rec Room Plus'),
404: { description: 'No such invention' },
},
}),
@@ -1563,6 +1676,8 @@ export const avatarRoutes = new Hono<App>({ strict: false })
if (Number.isNaN(price) || price < 0) return c.json({ error: 'Price must be >= 0' }, 400)
if (price > MAX_INVENTION_PRICE)
return c.json({ error: `Price must be <= ${MAX_INVENTION_PRICE}` }, 400)
if (price > 0 && !(await authedPlus(c)))
return c.json({ error: INVENTION_PRICE_NEEDS_PLUS }, 403)
const updated = await setInventionPrice(c.env.DB, gate.invention.InventionId, price)
return updated === null ? c.notFound() : c.json(toSaveResult(updated))
@@ -2348,7 +2463,8 @@ export const avatarRoutes = new Hono<App>({ strict: false })
'than zeroing it, so re-publishing something that was for sale doesn’t give it ' +
'away; every field but `InventionId` is nullable and an omitted one keeps what ' +
'the invention has. A price over 1000 tokens (`MAX_INVENTION_PRICE`) refuses the ' +
'publish in-band.\n\n' +
'publish in-band, and so does a `Price` above 0 without Rec Room Plus (the ' +
'token’s `rn.plus` claim).\n\n' +
'Publishing is undone by `v2/unpublish`, and re-publishing doesn’t re-date the first ' +
'publish. Refusals answer `Success: false` with a null `Value`, the way ' +
'`v9/save` does.',
@@ -2388,6 +2504,10 @@ export const avatarRoutes = new Hono<App>({ strict: false })
const price = int('Price')
if (price !== undefined && price > MAX_INVENTION_PRICE)
return c.json(inventionSaveV9Failure(`Price must be <= ${MAX_INVENTION_PRICE}`))
// Selling an invention is a Rec Room Plus perk. Refused in-band like the cap, so the
// client reads it out of the envelope. Null (leave it) and 0 (free) need no Plus.
if (price !== undefined && price > 0 && !(await authedPlus(c)))
return c.json(inventionSaveV9Failure(INVENTION_PRICE_NEEDS_PLUS))
const published = await publishInvention(c.env.DB, gate.invention.InventionId, {
permissionLevel: int('Permission'),
+171 -13
View File
@@ -229,11 +229,12 @@ function b64url(input: ArrayBuffer | string): string {
// off, the token carries none, which is what a plain player's looks like to the
// role-gated routes. `version` mints the `rn.ver` claim auth stamps from the build the
// client posted at login; left off, the token carries none, like one issued before the
// claim existed.
// claim existed. `plus` mints the `rn.plus` claim auth stamps from `account.hasPlus`.
async function bearer(
sub = '42',
roles?: string[],
version?: string
version?: string,
plus?: boolean
): Promise<Record<string, string>> {
const now = Math.floor(Date.now() / 1000)
const signingInput = `${b64url(JSON.stringify({ alg: 'HS256', typ: 'JWT' }))}.${b64url(
@@ -242,6 +243,7 @@ async function bearer(
exp: now + 3600,
...(roles && { role: roles }),
...(version && { 'rn.ver': version }),
...(plus && { 'rn.plus': true }),
})
)}`
const key = await crypto.subtle.importKey(
@@ -1986,6 +1988,60 @@ describe('public endpoints', () => {
expect(((await worn.json()) as { Name: string | null }).Name).toBe(null)
})
test('PUT /outfits/me refuses an aura or body paint without Plus', async () => {
const outfit = (auraId: number | null, bodyPaint: string | null, Slot: number) => ({
DataVersion: 10,
CustomizationSettings: JSON.stringify({
AvatarVersion: 10,
...(auraId !== null && { AuraData: { Id: auraId } }),
...(bodyPaint !== null && { BodyPaint: bodyPaint }),
}),
Selections: [],
Slot,
})
const put = async (body: unknown, headers: Record<string, string>) =>
exports.default.fetch(`${ORIGIN}/outfits/me`, {
method: 'PUT',
headers: { ...headers, 'content-type': 'application/json' },
body: JSON.stringify(body),
})
const paint = '44d7e2a4-ae8d-4b95-8686-63e0f7d1ef8b'
// No Plus: an aura, body paint, or both are refused and nothing lands in the slot.
const refused = await put(outfit(14, null, 5), await bearer('4444'))
expect(refused.status).toBe(403)
expect(await refused.json()).toEqual({
Success: false,
Error: 'Rec Room Plus is required to wear an aura',
error_id: null,
})
const painted = await put(outfit(null, paint, 5), await bearer('4444'))
expect(painted.status).toBe(403)
expect(((await painted.json()) as { Error: string }).Error).toBe(
'Rec Room Plus is required to wear body paint'
)
const both = await put(outfit(14, paint, 5), await bearer('4444'))
expect(((await both.json()) as { Error: string }).Error).toBe(
'Rec Room Plus is required to wear an aura or body paint'
)
const saved = await exports.default.fetch(`${ORIGIN}/outfits/me/saved`, {
headers: await bearer('4444'),
})
expect(((await saved.json()) as { Slot: number }[]).some((o) => o.Slot === 5)).toBe(false)
// No Plus, neither perk (absent, aura Id 0, empty or all-zero paint): saves as before.
expect((await put(outfit(null, null, 0), await bearer('4444'))).status).toBe(200)
expect((await put(outfit(0, '', 0), await bearer('4444'))).status).toBe(200)
expect(
(await put(outfit(0, '00000000-0000-0000-0000-000000000000', 0), await bearer('4444'))).status
).toBe(200)
// Plus: both save.
const plus = await put(outfit(14, paint, 5), await bearer('4444', undefined, undefined, true))
expect(plus.status).toBe(200)
expect(await plus.json()).toEqual({ Success: true, Error: null, error_id: null })
})
test('GET /outfits/me/saved lists every saved slot, ordered by slot', async () => {
const anon = await exports.default.fetch(`${ORIGIN}/outfits/me/saved`)
expect(anon.status).toBe(401)
@@ -3047,7 +3103,10 @@ describe('public endpoints', () => {
const publish = async (body: Record<string, unknown>) => {
const res = await exports.default.fetch(`${ORIGIN}/api/inventions/v4/publish`, {
method: 'POST',
headers: { ...(await bearer('5172')), 'Content-Type': 'application/json' },
headers: {
...(await bearer('5172', undefined, undefined, true)),
'Content-Type': 'application/json',
},
body: JSON.stringify({ InventionId: inventionId, ...body }),
})
const value = ((await res.json()) as InventionSaveV9Result).Value
@@ -3055,6 +3114,25 @@ describe('public endpoints', () => {
return value.Invention
}
// A price is a Rec Room Plus perk: without `rn.plus` the publish is refused in-band and
// nothing is published.
const unpaid = await exports.default.fetch(`${ORIGIN}/api/inventions/v4/publish`, {
method: 'POST',
headers: { ...(await bearer('5172')), 'Content-Type': 'application/json' },
body: JSON.stringify({
InventionId: inventionId,
Permission: 80,
Accessibility: 1,
Price: 250,
}),
})
expect(unpaid.status).toBe(200)
expect(await unpaid.json()).toMatchObject({
Success: false,
Value: null,
Error: 'Rec Room Plus is required to sell an invention',
})
const first = await publish({ Permission: 80, Accessibility: 1, Price: 250 })
expect(first.Price).toBe(250)
@@ -3071,7 +3149,10 @@ describe('public endpoints', () => {
// One over the cap refuses the publish in-band, and the price stands.
const over = await exports.default.fetch(`${ORIGIN}/api/inventions/v4/publish`, {
method: 'POST',
headers: { ...(await bearer('5172')), 'Content-Type': 'application/json' },
headers: {
...(await bearer('5172', undefined, undefined, true)),
'Content-Type': 'application/json',
},
body: JSON.stringify({ InventionId: inventionId, Price: 1001 }),
})
expect(over.status).toBe(200)
@@ -3082,6 +3163,14 @@ describe('public endpoints', () => {
})
expect((await publish({ Price: 1000 })).Price).toBe(1000)
// A republish without Plus that leaves the price alone (null) is not a sale being set.
const keep = await exports.default.fetch(`${ORIGIN}/api/inventions/v4/publish`, {
method: 'POST',
headers: { ...(await bearer('5172')), 'Content-Type': 'application/json' },
body: JSON.stringify({ InventionId: inventionId, Permission: 20, Price: null }),
})
expect(((await keep.json()) as InventionSaveV9Result).Success).toBe(true)
// Out of the published catalogue again — see the note in the publish test above.
await env.DB.prepare('DELETE FROM invention WHERE id = ?1').bind(inventionId).run()
})
@@ -4436,10 +4525,19 @@ describe('public endpoints', () => {
// A saved draft is invisible until it's published.
expect(await search()).toEqual([])
const res = await exports.default.fetch(
// Pricing is a Rec Room Plus perk: without `rn.plus` the publish is refused, unpublished.
const unpaid = await exports.default.fetch(
`${ORIGIN}/api/inventions/v3/publish?inventionId=${Invention.InventionId}&permissionLevel=charge&price=250`,
{ headers: await bearer('2121') }
)
expect(unpaid.status).toBe(403)
expect(await unpaid.json()).toEqual({ error: 'Rec Room Plus is required to sell an invention' })
expect(await search()).toEqual([])
const res = await exports.default.fetch(
`${ORIGIN}/api/inventions/v3/publish?inventionId=${Invention.InventionId}&permissionLevel=charge&price=250`,
{ headers: await bearer('2121', undefined, undefined, true) }
)
expect(res.status).toBe(200)
const published = (await res.json()) as InventionSaveResult
expect(published.Status).toBe(0)
@@ -4455,7 +4553,7 @@ describe('public endpoints', () => {
// A price over the cap refuses the whole publish, leaving the invention as it was.
const over = await exports.default.fetch(
`${ORIGIN}/api/inventions/v3/publish?inventionId=${Invention.InventionId}&price=1001`,
{ headers: await bearer('2121') }
{ headers: await bearer('2121', undefined, undefined, true) }
)
expect(over.status).toBe(400)
expect(await over.json()).toEqual({ error: 'Price must be <= 1000' })
@@ -4501,13 +4599,28 @@ describe('public endpoints', () => {
body: JSON.stringify({ name: 'Priced Lamp', inventionDataFilename: 'a.inv' }),
})
const { Invention } = (await save.json()) as InventionSaveResult
const updateprice = async (body: unknown, sub = '1212'): Promise<Response> =>
const updateprice = async (body: unknown, sub = '1212', plus = true): Promise<Response> =>
exports.default.fetch(`${ORIGIN}/api/inventions/v1/updateprice`, {
method: 'POST',
headers: { ...(await bearer(sub)), 'Content-Type': 'application/json' },
headers: {
...(await bearer(sub, undefined, undefined, plus)),
'Content-Type': 'application/json',
},
body: JSON.stringify(body),
})
// A price above 0 is a Rec Room Plus perk; making it free is not.
const unpaid = await updateprice(
{ InventionId: Invention.InventionId, Price: 500 },
'1212',
false
)
expect(unpaid.status).toBe(403)
expect(await unpaid.json()).toEqual({ error: 'Rec Room Plus is required to sell an invention' })
expect(
(await updateprice({ InventionId: Invention.InventionId, Price: 0 }, '1212', false)).status
).toBe(200)
const res = await updateprice({ InventionId: Invention.InventionId, Price: 500 })
expect(res.status).toBe(200)
const priced = (await res.json()) as InventionSaveResult
@@ -5050,11 +5163,28 @@ describe('custom avatar items', () => {
new Date('2026-08-01T00:00:00Z')
)
const url = `${ORIGIN}/api/customAvatarItems/v1/${item.CustomAvatarItemId}`
const res = await exports.default.fetch(url, {
// Pricing is a Rec Room Plus perk: without `rn.plus` the edit is refused, unchanged.
const unpaid = await exports.default.fetch(url, {
method: 'PUT',
headers: { ...(await bearer('205')), 'content-type': 'application/json' },
body: JSON.stringify({ Name: null, Description: null, Price: 100, Accessibility: 1 }),
})
expect(unpaid.status).toBe(403)
expect(await unpaid.json()).toMatchObject({
Success: false,
Value: null,
Error: 'Rec Room Plus is required to sell a custom avatar item',
})
expect((await getCustomAvatarItem(env.DB, item.CustomAvatarItemId))!.Price).toBe(0)
const res = await exports.default.fetch(url, {
method: 'PUT',
headers: {
...(await bearer('205', undefined, undefined, true)),
'content-type': 'application/json',
},
body: JSON.stringify({ Name: null, Description: null, Price: 100, Accessibility: 1 }),
})
expect(res.status).toBe(200)
const body = (await res.json()) as {
Value: Record<string, unknown>
@@ -5103,7 +5233,7 @@ describe('custom avatar items', () => {
// 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 create = async (Price: unknown, plus = true) => {
const form = new FormData()
form.set(
'metadata',
@@ -5113,10 +5243,18 @@ describe('custom avatar items', () => {
form.set('design', new File([PNG_BYTES], 'd.png', { type: 'image/png' }))
return exports.default.fetch(`${ORIGIN}/api/customAvatarItems/v1`, {
method: 'POST',
headers: await bearer('205'),
headers: await bearer('205', undefined, undefined, plus),
body: form,
})
}
// A price above 0 is a Rec Room Plus perk; without `rn.plus` the create is refused.
const unpaid = await create(100, false)
expect(unpaid.status).toBe(403)
expect(await unpaid.json()).toMatchObject({
Success: false,
Value: null,
Error: 'Rec Room Plus is required to sell a custom avatar item',
})
const over = await create(1001)
expect(over.status).toBe(400)
expect(await over.json()).toMatchObject({
@@ -5126,6 +5264,11 @@ describe('custom avatar items', () => {
})
expect((await create(-1)).status).toBe(400)
expect((await create(10.5)).status).toBe(400)
// Below the `minPriceForPublicItem` floor: a price is 0 or at least 100, Plus or not.
const cheap = await create(1)
expect(cheap.status).toBe(400)
expect(await cheap.json()).toMatchObject({ Error: 'Price must be 0 or >= 100' })
expect((await create(99)).status).toBe(400)
// Nothing was written for the refused ones.
expect(
await env.DB.prepare(
@@ -5148,20 +5291,35 @@ describe('custom avatar items', () => {
// 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) =>
const edit = async (Price: number, plus = true) =>
exports.default.fetch(url, {
method: 'PUT',
headers: { ...(await bearer('205')), 'content-type': 'application/json' },
headers: {
...(await bearer('205', undefined, undefined, plus)),
'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 edit(1)).status).toBe(400)
expect((await edit(99)).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)
// Without Plus a price can't be set, but the item can still be made free.
expect((await edit(500, false)).status).toBe(403)
const freed = await edit(0, false)
expect(freed.status).toBe(200)
expect(((await freed.json()) as { Value: { Price: number } }).Value.Price).toBe(0)
// And a free item can be created without Plus.
expect((await create(0, false)).status).toBe(200)
// The floor itself is allowed.
expect((await create(100)).status).toBe(200)
})
// The cap arrived after players had already priced shirts. Migration 0032 lowers every