[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
+14 -2
View File
@@ -27,7 +27,7 @@ import {
withNotFound, withNotFound,
withOnError, withOnError,
} from '@repo/hono-helpers' } from '@repo/hono-helpers'
import { validateAndGetAccountId } from '@repo/jwt' import { validateAndGetAccountId, validateAndGetPlus } from '@repo/jwt'
// The notification-type ids the hub carries (owned by the `notify` worker). Imported as a // The notification-type ids the hub carries (owned by the `notify` worker). Imported as a
// value — the enum has no runtime dependencies. // value — the enum has no runtime dependencies.
@@ -1071,6 +1071,11 @@ const app = new Hono<App>()
// //
// Broadcast like every other public-DTO mutation here — the emoji rides along in the // Broadcast like every other public-DTO mutation here — the emoji rides along in the
// AccountUpdate payload, so it redraws beside the name without a refetch. // AccountUpdate payload, so it redraws beside the name without a refetch.
//
// SETTING an emoji is a Rec Room Plus perk: the token's `rn.plus` claim, the same one
// econ's `isSubscriber` reads (Plus has one source — see `validateAndGetPlus`). Anyone
// may CLEAR theirs, so a player who loses Plus can still take an old pick down. An emoji
// already stored is left alone when Plus lapses; this only gates the write.
.put( .put(
'/account/me/emoji', '/account/me/emoji',
describeRoute({ describeRoute({
@@ -1079,7 +1084,8 @@ const app = new Hono<App>()
description: [ description: [
'Persists the emoji shown beside the display name and broadcasts it in the', 'Persists the emoji shown beside the display name and broadcasts it in the',
'AccountUpdate payload. The value must be one the whitelist serves; an empty value', 'AccountUpdate payload. The value must be one the whitelist serves; an empty value',
'clears the pick.', 'clears the pick. Setting one requires Rec Room Plus (the token’s `rn.plus` claim);',
'clearing does not.',
].join(' '), ].join(' '),
security: AUTHED, security: AUTHED,
requestBody: form(EmojiRequest, 'A whitelisted emoji, or "" to clear'), requestBody: form(EmojiRequest, 'A whitelisted emoji, or "" to clear'),
@@ -1087,6 +1093,7 @@ const app = new Hono<App>()
200: json(SuccessResponse, 'Updated'), 200: json(SuccessResponse, 'Updated'),
400: { description: 'Not a whitelisted emoji (empty body)' }, 400: { description: 'Not a whitelisted emoji (empty body)' },
401: UNAUTHORIZED_RESPONSE, 401: UNAUTHORIZED_RESPONSE,
403: { description: 'Setting an emoji without Rec Room Plus (empty body)' },
}, },
}), }),
async (c) => { async (c) => {
@@ -1096,6 +1103,11 @@ const app = new Hono<App>()
// An empty value clears the pick; anything else has to be on the list. // An empty value clears the pick; anything else has to be on the list.
const displayEmoji = submitted === '' ? '' : resolveWhitelistedEmoji(submitted) const displayEmoji = submitted === '' ? '' : resolveWhitelistedEmoji(submitted)
if (displayEmoji === null) return c.body(null, 400) if (displayEmoji === null) return c.body(null, 400)
if (
displayEmoji !== '' &&
!(await validateAndGetPlus(c.req.raw, await c.env.JWT_SECRET.get()))
)
return c.body(null, 403)
const account = await updateAccount(c.env.DB, id, { displayEmoji }) const account = await updateAccount(c.env.DB, id, { displayEmoji })
await pushAccountUpdate(c, account) await pushAccountUpdate(c, account)
return c.json({ success: true }) return c.json({ success: true })
+60 -7
View File
@@ -50,10 +50,13 @@ function b64url(input: ArrayBuffer | string): string {
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '') return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
} }
async function bearer(sub = '42'): Promise<Record<string, string>> { async function bearer(
sub = '42',
claims: Record<string, unknown> = {}
): Promise<Record<string, string>> {
const now = Math.floor(Date.now() / 1000) const now = Math.floor(Date.now() / 1000)
const signingInput = `${b64url(JSON.stringify({ alg: 'HS256', typ: 'JWT' }))}.${b64url( const signingInput = `${b64url(JSON.stringify({ alg: 'HS256', typ: 'JWT' }))}.${b64url(
JSON.stringify({ sub, exp: now + 3600 }) JSON.stringify({ sub, exp: now + 3600, ...claims })
)}` )}`
const key = await crypto.subtle.importKey( const key = await crypto.subtle.importKey(
'raw', 'raw',
@@ -66,6 +69,9 @@ async function bearer(sub = '42'): Promise<Record<string, string>> {
return { Authorization: `Bearer ${signingInput}.${b64url(sig)}` } return { Authorization: `Bearer ${signingInput}.${b64url(sig)}` }
} }
/** The `rn.plus` claim `auth` stamps for a Rec Room Plus account. */
const PLUS = { 'rn.plus': true }
const form = (fields: Record<string, string>): RequestInit => ({ const form = (fields: Record<string, string>): RequestInit => ({
method: 'PUT', method: 'PUT',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
@@ -482,7 +488,10 @@ describe('auth-gated endpoints', () => {
// Exactly the body the client sends: one urlencoded field (`%F0%9F%A4%AA`). // Exactly the body the client sends: one urlencoded field (`%F0%9F%A4%AA`).
const res = await exports.default.fetch(`${ORIGIN}/account/me/emoji`, { const res = await exports.default.fetch(`${ORIGIN}/account/me/emoji`, {
method: 'PUT', method: 'PUT',
headers: { ...(await bearer('779')), 'Content-Type': 'application/x-www-form-urlencoded' }, headers: {
...(await bearer('779', PLUS)),
'Content-Type': 'application/x-www-form-urlencoded',
},
body: 'displayEmoji=%F0%9F%A4%AA', body: 'displayEmoji=%F0%9F%A4%AA',
}) })
expect(res.status).toBe(200) expect(res.status).toBe(200)
@@ -509,7 +518,10 @@ describe('auth-gated endpoints', () => {
test('PUT /account/me/emoji canonicalizes a pick sent without its variation selector', async () => { test('PUT /account/me/emoji canonicalizes a pick sent without its variation selector', async () => {
const res = await exports.default.fetch(`${ORIGIN}/account/me/emoji`, { const res = await exports.default.fetch(`${ORIGIN}/account/me/emoji`, {
...form({ displayEmoji: '\u{2764}' }), ...form({ displayEmoji: '\u{2764}' }),
headers: { ...(await bearer('780')), 'Content-Type': 'application/x-www-form-urlencoded' }, headers: {
...(await bearer('780', PLUS)),
'Content-Type': 'application/x-www-form-urlencoded',
},
}) })
expect(res.status).toBe(200) expect(res.status).toBe(200)
@@ -524,13 +536,19 @@ describe('auth-gated endpoints', () => {
test('PUT /account/me/emoji clears the pick on an empty value', async () => { test('PUT /account/me/emoji clears the pick on an empty value', async () => {
const set = await exports.default.fetch(`${ORIGIN}/account/me/emoji`, { const set = await exports.default.fetch(`${ORIGIN}/account/me/emoji`, {
...form({ displayEmoji: '\u{1F389}' }), ...form({ displayEmoji: '\u{1F389}' }),
headers: { ...(await bearer('781')), 'Content-Type': 'application/x-www-form-urlencoded' }, headers: {
...(await bearer('781', PLUS)),
'Content-Type': 'application/x-www-form-urlencoded',
},
}) })
expect(set.status).toBe(200) expect(set.status).toBe(200)
const cleared = await exports.default.fetch(`${ORIGIN}/account/me/emoji`, { const cleared = await exports.default.fetch(`${ORIGIN}/account/me/emoji`, {
...form({ displayEmoji: '' }), ...form({ displayEmoji: '' }),
headers: { ...(await bearer('781')), 'Content-Type': 'application/x-www-form-urlencoded' }, headers: {
...(await bearer('781', PLUS)),
'Content-Type': 'application/x-www-form-urlencoded',
},
}) })
expect(cleared.status).toBe(200) expect(cleared.status).toBe(200)
@@ -549,7 +567,10 @@ describe('auth-gated endpoints', () => {
for (const displayEmoji of ['not an emoji', '\u{1F92A}\u{1F92A}', '\u{1F595}\u{1F3FB}']) { for (const displayEmoji of ['not an emoji', '\u{1F92A}\u{1F92A}', '\u{1F595}\u{1F3FB}']) {
const res = await exports.default.fetch(`${ORIGIN}/account/me/emoji`, { const res = await exports.default.fetch(`${ORIGIN}/account/me/emoji`, {
...form({ displayEmoji }), ...form({ displayEmoji }),
headers: { ...(await bearer('782')), 'Content-Type': 'application/x-www-form-urlencoded' }, headers: {
...(await bearer('782', PLUS)),
'Content-Type': 'application/x-www-form-urlencoded',
},
}) })
expect(res.status).toBe(400) expect(res.status).toBe(400)
} }
@@ -559,6 +580,38 @@ describe('auth-gated endpoints', () => {
expect(((await me.json()) as { displayEmoji: string }).displayEmoji).toBe('') expect(((await me.json()) as { displayEmoji: string }).displayEmoji).toBe('')
}) })
// Setting an emoji is a Rec Room Plus perk; clearing one is not, so a player whose Plus
// lapsed can still take an old pick down.
test('PUT /account/me/emoji 403s a set without Rec Room Plus but allows a clear', async () => {
const headers = {
...(await bearer('783')),
'Content-Type': 'application/x-www-form-urlencoded',
}
const set = await exports.default.fetch(`${ORIGIN}/account/me/emoji`, {
...form({ displayEmoji: '\u{1F92A}' }),
headers,
})
expect(set.status).toBe(403)
const notPlus = await exports.default.fetch(`${ORIGIN}/account/me/emoji`, {
...form({ displayEmoji: '\u{1F92A}' }),
headers: {
...(await bearer('783', { 'rn.plus': 'true' })),
'Content-Type': 'application/x-www-form-urlencoded',
},
})
expect(notPlus.status).toBe(403)
const me = await exports.default.fetch(`${ORIGIN}/account/me`, { headers: await bearer('783') })
expect(((await me.json()) as { displayEmoji: string }).displayEmoji).toBe('')
const clear = await exports.default.fetch(`${ORIGIN}/account/me/emoji`, {
...form({ displayEmoji: '' }),
headers,
})
expect(clear.status).toBe(200)
})
test('PUT /account/me/bannerimage 401s without a token, 400s without an imageName', async () => { test('PUT /account/me/bannerimage 401s without a token, 400s without an imageName', async () => {
const anon = await exports.default.fetch(`${ORIGIN}/account/me/bannerimage`, { const anon = await exports.default.fetch(`${ORIGIN}/account/me/bannerimage`, {
...form({ imageName: 'x.jpg' }), ...form({ imageName: 'x.jpg' }),
+7
View File
@@ -147,6 +147,13 @@ export const OUTFIT_TYPE_CUSTOM_SHIRT = 105
*/ */
export const MAX_PLAYER_ITEM_PRICE = 1000 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. */ /** 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. */
+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 { Context } from 'hono'
import type { App } from './context' 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()) 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. */ /** Results.Unauthorized() equivalent — 401 with empty body. */
export function unauthorized(c: Context<App>) { export function unauthorized(c: Context<App>) {
return c.body(null, 401) return c.body(null, 401)
+134 -14
View File
@@ -25,11 +25,12 @@ import {
listFeaturedCustomAvatarItems, listFeaturedCustomAvatarItems,
listHotCustomAvatarItems, listHotCustomAvatarItems,
MAX_PLAYER_ITEM_PRICE, MAX_PLAYER_ITEM_PRICE,
MIN_PLAYER_ITEM_PRICE,
searchCustomAvatarItems, searchCustomAvatarItems,
toQuestCustomAvatarItem, toQuestCustomAvatarItem,
updateCustomAvatarItem, updateCustomAvatarItem,
} from '../custom-avatar-items-db' } from '../custom-avatar-items-db'
import { authedId, unauthorized } from '../http' import { authedId, authedPlus, unauthorized } from '../http'
import { import {
createInvention, createInvention,
deleteInvention, deleteInvention,
@@ -135,6 +136,59 @@ import type { InventionTag, SavedInvention } from '../inventions-db'
*/ */
const BULK_CUSTOM_AVATAR_ITEM_CAP = 100 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 * 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 * `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'], tags: ['Avatar'],
summary: 'Minimum token price for a public custom item', summary: 'Minimum token price for a public custom item',
description: 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`') }, responses: { 200: json(BareInteger, 'A bare `100`') },
}), }),
(c) => c.json(100) (c) => c.json(MIN_PLAYER_ITEM_PRICE)
) )
.post( .post(
'/api/customAvatarItems/v1', '/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 ' + 'from that signature, and anything else is refused with a 400 before either file is ' +
'written.\n\n' + 'written.\n\n' +
'`Price` is capped at 1000 tokens (`MAX_PLAYER_ITEM_PRICE`); one above it, or below ' + '`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, 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( 400: json(
CustomAvatarItemResponse, 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, 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'), 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 < 0) return fail('Price must be >= 0')
if (meta.Price > MAX_PLAYER_ITEM_PRICE) if (meta.Price > MAX_PLAYER_ITEM_PRICE)
return fail(`Price must be <= ${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.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')
@@ -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 ' + '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.', '`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, 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, or a `Price` outside 0–1000'), 400: json(
CustomAvatarItemResponse,
'Malformed body, or a `Price` that is not 0 or 100–1000'
),
401: UNAUTHORIZED_RESPONSE, 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'), 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 < 0) return fail(400, 'Price must be >= 0')
if (patch.price !== null && patch.price > MAX_PLAYER_ITEM_PRICE) if (patch.price !== null && patch.price > MAX_PLAYER_ITEM_PRICE)
return fail(400, `Price must be <= ${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) 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')
@@ -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' + '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 ' + '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 ' + '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, security: AUTHED,
requestBody: jsonBody(OutfitsMeRequest, 'The outfit to save'), requestBody: jsonBody(OutfitsMeRequest, 'The outfit to save'),
responses: { responses: {
200: json(OutfitSaveResponse, 'Saved — `{ Success: true, Error: null, error_id: null }`'), 200: json(OutfitSaveResponse, 'Saved — `{ Success: true, Error: null, error_id: null }`'),
400: json(ErrorResponse, 'Unparseable body'), 400: json(ErrorResponse, 'Unparseable body'),
401: UNAUTHORIZED_RESPONSE, 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) => { 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 const body = (await c.req.json().catch(() => null)) as Record<string, unknown> | null
if (body === null) return c.json({ error: 'Invalid request body' }, 400) 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. // The client sends `Slot`; a body without one saves the worn outfit.
const outfit = { const outfit = {
...body, ...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 ' + '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 ' + '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 ' + '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, security: AUTHED,
parameters: [ parameters: [
intQuery('inventionId', 'Invention id; required'), 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'), 200: json(InventionSaveResult, 'The published invention, in the save envelope'),
400: json(ErrorResponse, 'A price over 1000'), 400: json(ErrorResponse, 'A price over 1000'),
401: UNAUTHORIZED_RESPONSE, 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' }, 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) const price = Number.parseInt(c.req.query('price') ?? '', 10)
if (price > MAX_INVENTION_PRICE) if (price > MAX_INVENTION_PRICE)
return c.json({ error: `Price must be <= ${MAX_INVENTION_PRICE}` }, 400) 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, { const published = await publishInvention(c.env.DB, gate.invention.InventionId, {
permissionLevel: permissionLevel:
@@ -1540,14 +1652,15 @@ export const avatarRoutes = new Hono<App>({ strict: false })
summary: 'Set an invention’s price', summary: 'Set an invention’s price',
description: description:
'Unlike update/publish, this one POSTs a JSON body. Creator only; a negative price ' + '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, security: AUTHED,
requestBody: jsonBody(UpdatePriceRequest, 'The invention and its new price'), requestBody: jsonBody(UpdatePriceRequest, 'The invention and its new price'),
responses: { responses: {
200: json(InventionSaveResult, 'The repriced invention, in the save envelope'), 200: json(InventionSaveResult, 'The repriced invention, in the save envelope'),
400: json(ErrorResponse, 'Unparseable body, or a price below 0 or over 1000'), 400: json(ErrorResponse, 'Unparseable body, or a price below 0 or over 1000'),
401: UNAUTHORIZED_RESPONSE, 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' }, 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 (Number.isNaN(price) || price < 0) return c.json({ error: 'Price must be >= 0' }, 400)
if (price > MAX_INVENTION_PRICE) if (price > MAX_INVENTION_PRICE)
return c.json({ error: `Price must be <= ${MAX_INVENTION_PRICE}` }, 400) 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) const updated = await setInventionPrice(c.env.DB, gate.invention.InventionId, price)
return updated === null ? c.notFound() : c.json(toSaveResult(updated)) 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 ' + '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 ' + '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 ' + '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 ' + '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 ' + 'publish. Refusals answer `Success: false` with a null `Value`, the way ' +
'`v9/save` does.', '`v9/save` does.',
@@ -2388,6 +2504,10 @@ export const avatarRoutes = new Hono<App>({ strict: false })
const price = int('Price') const price = int('Price')
if (price !== undefined && price > MAX_INVENTION_PRICE) if (price !== undefined && price > MAX_INVENTION_PRICE)
return c.json(inventionSaveV9Failure(`Price must be <= ${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, { const published = await publishInvention(c.env.DB, gate.invention.InventionId, {
permissionLevel: int('Permission'), 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 // 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 // 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 // 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( async function bearer(
sub = '42', sub = '42',
roles?: string[], roles?: string[],
version?: string version?: string,
plus?: boolean
): Promise<Record<string, string>> { ): Promise<Record<string, string>> {
const now = Math.floor(Date.now() / 1000) const now = Math.floor(Date.now() / 1000)
const signingInput = `${b64url(JSON.stringify({ alg: 'HS256', typ: 'JWT' }))}.${b64url( const signingInput = `${b64url(JSON.stringify({ alg: 'HS256', typ: 'JWT' }))}.${b64url(
@@ -242,6 +243,7 @@ async function bearer(
exp: now + 3600, exp: now + 3600,
...(roles && { role: roles }), ...(roles && { role: roles }),
...(version && { 'rn.ver': version }), ...(version && { 'rn.ver': version }),
...(plus && { 'rn.plus': true }),
}) })
)}` )}`
const key = await crypto.subtle.importKey( const key = await crypto.subtle.importKey(
@@ -1986,6 +1988,60 @@ describe('public endpoints', () => {
expect(((await worn.json()) as { Name: string | null }).Name).toBe(null) 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 () => { test('GET /outfits/me/saved lists every saved slot, ordered by slot', async () => {
const anon = await exports.default.fetch(`${ORIGIN}/outfits/me/saved`) const anon = await exports.default.fetch(`${ORIGIN}/outfits/me/saved`)
expect(anon.status).toBe(401) expect(anon.status).toBe(401)
@@ -3047,7 +3103,10 @@ describe('public endpoints', () => {
const publish = async (body: Record<string, unknown>) => { const publish = async (body: Record<string, unknown>) => {
const res = await exports.default.fetch(`${ORIGIN}/api/inventions/v4/publish`, { const res = await exports.default.fetch(`${ORIGIN}/api/inventions/v4/publish`, {
method: 'POST', 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 }), body: JSON.stringify({ InventionId: inventionId, ...body }),
}) })
const value = ((await res.json()) as InventionSaveV9Result).Value const value = ((await res.json()) as InventionSaveV9Result).Value
@@ -3055,6 +3114,25 @@ describe('public endpoints', () => {
return value.Invention 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 }) const first = await publish({ Permission: 80, Accessibility: 1, Price: 250 })
expect(first.Price).toBe(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. // One over the cap refuses the publish in-band, and the price stands.
const over = await exports.default.fetch(`${ORIGIN}/api/inventions/v4/publish`, { const over = await exports.default.fetch(`${ORIGIN}/api/inventions/v4/publish`, {
method: 'POST', 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 }), body: JSON.stringify({ InventionId: inventionId, Price: 1001 }),
}) })
expect(over.status).toBe(200) expect(over.status).toBe(200)
@@ -3082,6 +3163,14 @@ describe('public endpoints', () => {
}) })
expect((await publish({ Price: 1000 })).Price).toBe(1000) 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. // 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() 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. // A saved draft is invisible until it's published.
expect(await search()).toEqual([]) 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`, `${ORIGIN}/api/inventions/v3/publish?inventionId=${Invention.InventionId}&permissionLevel=charge&price=250`,
{ headers: await bearer('2121') } { 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) expect(res.status).toBe(200)
const published = (await res.json()) as InventionSaveResult const published = (await res.json()) as InventionSaveResult
expect(published.Status).toBe(0) 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. // A price over the cap refuses the whole publish, leaving the invention as it was.
const over = await exports.default.fetch( const over = await exports.default.fetch(
`${ORIGIN}/api/inventions/v3/publish?inventionId=${Invention.InventionId}&price=1001`, `${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(over.status).toBe(400)
expect(await over.json()).toEqual({ error: 'Price must be <= 1000' }) 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' }), body: JSON.stringify({ name: 'Priced Lamp', inventionDataFilename: 'a.inv' }),
}) })
const { Invention } = (await save.json()) as InventionSaveResult 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`, { exports.default.fetch(`${ORIGIN}/api/inventions/v1/updateprice`, {
method: 'POST', 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), 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 }) const res = await updateprice({ InventionId: Invention.InventionId, Price: 500 })
expect(res.status).toBe(200) expect(res.status).toBe(200)
const priced = (await res.json()) as InventionSaveResult const priced = (await res.json()) as InventionSaveResult
@@ -5050,11 +5163,28 @@ describe('custom avatar items', () => {
new Date('2026-08-01T00:00:00Z') new Date('2026-08-01T00:00:00Z')
) )
const url = `${ORIGIN}/api/customAvatarItems/v1/${item.CustomAvatarItemId}` 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', method: 'PUT',
headers: { ...(await bearer('205')), 'content-type': 'application/json' }, headers: { ...(await bearer('205')), 'content-type': 'application/json' },
body: JSON.stringify({ Name: null, Description: null, Price: 100, Accessibility: 1 }), 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) expect(res.status).toBe(200)
const body = (await res.json()) as { const body = (await res.json()) as {
Value: Record<string, unknown> 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. // 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 () => { test('the price is capped at 1000 tokens on create and edit', async () => {
expect(MAX_PLAYER_ITEM_PRICE).toBe(1000) expect(MAX_PLAYER_ITEM_PRICE).toBe(1000)
const create = async (Price: unknown) => { const create = async (Price: unknown, plus = true) => {
const form = new FormData() const form = new FormData()
form.set( form.set(
'metadata', 'metadata',
@@ -5113,10 +5243,18 @@ describe('custom avatar items', () => {
form.set('design', new File([PNG_BYTES], 'd.png', { type: 'image/png' })) form.set('design', new File([PNG_BYTES], 'd.png', { type: 'image/png' }))
return exports.default.fetch(`${ORIGIN}/api/customAvatarItems/v1`, { return exports.default.fetch(`${ORIGIN}/api/customAvatarItems/v1`, {
method: 'POST', method: 'POST',
headers: await bearer('205'), headers: await bearer('205', undefined, undefined, plus),
body: form, 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) const over = await create(1001)
expect(over.status).toBe(400) expect(over.status).toBe(400)
expect(await over.json()).toMatchObject({ expect(await over.json()).toMatchObject({
@@ -5126,6 +5264,11 @@ describe('custom avatar items', () => {
}) })
expect((await create(-1)).status).toBe(400) expect((await create(-1)).status).toBe(400)
expect((await create(10.5)).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. // Nothing was written for the refused ones.
expect( expect(
await env.DB.prepare( 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. // 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 url = `${ORIGIN}/api/customAvatarItems/v1/${Value.CustomAvatarItemId}`
const edit = async (Price: number) => const edit = async (Price: number, plus = true) =>
exports.default.fetch(url, { exports.default.fetch(url, {
method: 'PUT', 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 }), body: JSON.stringify({ Name: null, Description: null, Price, Accessibility: null }),
}) })
const tooMuch = await edit(1001) const tooMuch = await edit(1001)
expect(tooMuch.status).toBe(400) expect(tooMuch.status).toBe(400)
expect(await tooMuch.json()).toMatchObject({ Success: false, Error: 'Price must be <= 1000' }) 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(1)).status).toBe(400)
expect((await edit(99)).status).toBe(400)
expect((await getCustomAvatarItem(env.DB, Value.CustomAvatarItemId))!.Price).toBe(1000) expect((await getCustomAvatarItem(env.DB, Value.CustomAvatarItemId))!.Price).toBe(1000)
const lowered = await edit(999) const lowered = await edit(999)
expect(lowered.status).toBe(200) expect(lowered.status).toBe(200)
expect(((await lowered.json()) as { Value: { Price: number } }).Value.Price).toBe(999) 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 // The cap arrived after players had already priced shirts. Migration 0032 lowers every
+83 -18
View File
@@ -4222,6 +4222,8 @@ const app = new Hono<App>({ strict: false })
'the thing actually lives.', 'the thing actually lives.',
'', '',
'Gated to the room’s creator or a co-owner; a valid token from anyone else is a 403.', 'Gated to the room’s creator or a co-owner; a valid token from anyone else is a 403.',
'A listing priced in TOKENS (`CurrencyId` null) at a non-zero price also needs Rec',
'Room Plus (the token’s `rn.plus` claim), on a create or a replace; without it, 403.',
'`PriceAndCurrency` is collapsed into the flat `Price`/`PurchaseCurrencyId` the read', '`PriceAndCurrency` is collapsed into the flat `Price`/`PurchaseCurrencyId` the read',
'serves. Answers the `{ Value, Success, Error, error_id }` envelope the room-currency', 'serves. Answers the `{ Value, Success, Error, error_id }` envelope the room-currency',
'writes use, or a 200 carrying `Success: false` when the body is unusable or the', 'writes use, or a 200 carrying `Success: false` when the body is unusable or the',
@@ -4235,7 +4237,10 @@ const app = new Hono<App>({ strict: false })
'The listing as stored, or a rejection with `Success: false`' 'The listing as stored, or a rejection with `Success: false`'
), ),
401: UNAUTHORIZED_RESPONSE, 401: UNAUTHORIZED_RESPONSE,
403: { description: 'Not the room’s creator or a co-owner (empty body)' }, 403: {
description:
'Not the room’s creator or a co-owner, or a token price without Rec Room Plus (empty body)',
},
}, },
}), }),
async (c) => { async (c) => {
@@ -4282,6 +4287,15 @@ const app = new Hono<App>({ strict: false })
: {} : {}
) as Record<string, unknown> ) as Record<string, unknown>
const price = int(priceAndCurrency.Price, 0)
const purchaseCurrencyId = str(priceAndCurrency.CurrencyId)
// Selling for TOKENS is a Rec Room Plus perk (see `isSubscriber`); a listing in the
// room's own currency, or a free one, is open to every room manager. Checked on a
// replace too, so an edit can't turn a currency listing into a token one.
if (purchaseCurrencyId === null && price !== 0 && !(await isSubscriber(c))) {
return c.body(null, 403)
}
const consumable = await upsertRoomConsumable(c.env.DB, roomConsumableId, { const consumable = await upsertRoomConsumable(c.env.DB, roomConsumableId, {
RoomId: roomId, RoomId: roomId,
// Masked like every other player-typed string: a shop's contents are shown to // Masked like every other player-typed string: a shop's contents are shown to
@@ -4289,8 +4303,8 @@ const app = new Hono<App>({ strict: false })
Name: censorSwears(name), Name: censorSwears(name),
Description: censorSwears(str(body.Description) ?? ''), Description: censorSwears(str(body.Description) ?? ''),
ImageName: str(body.ImageName), ImageName: str(body.ImageName),
Price: int(priceAndCurrency.Price, 0), Price: price,
PurchaseCurrencyId: str(priceAndCurrency.CurrencyId), PurchaseCurrencyId: purchaseCurrencyId,
// The client has not been seen sending this, so it falls back to the CLR default // The client has not been seen sending this, so it falls back to the CLR default
// for an int rather than to a guess at what the shop should allow. // for an int rather than to a guess at what the shop should allow.
MaximumCountPerPurchase: int(body.MaximumCountPerPurchase, 0), MaximumCountPerPurchase: int(body.MaximumCountPerPurchase, 0),
@@ -4691,7 +4705,8 @@ const app = new Hono<App>({ strict: false })
'offers are served sorted by it, so several at 0 keep the order they were added in.', 'offers are served sorted by it, so several at 0 keep the order they were added in.',
'', '',
'Gated to the creator or a co-owner of the room that minted the currency. A valid', 'Gated to the creator or a co-owner of the room that minted the currency. A valid',
'token from anyone else is a 403. Answers the same', 'token from anyone else is a 403, and so is a non-zero `Price` without Rec Room Plus',
'(the token’s `rn.plus` claim) — the offer is paid in tokens. Answers the same',
'`{ Value, Success, Error, error_id }` envelope the currency writes use, with the', '`{ Value, Success, Error, error_id }` envelope the currency writes use, with the',
'offer in `Value`, or a 200 carrying `Success: false` when the currency is unknown', 'offer in `Value`, or a 200 carrying `Success: false` when the currency is unknown',
'or the body unusable.', 'or the body unusable.',
@@ -4704,7 +4719,10 @@ const app = new Hono<App>({ strict: false })
'The offer as created, or a rejection with `Success: false`' 'The offer as created, or a rejection with `Success: false`'
), ),
401: UNAUTHORIZED_RESPONSE, 401: UNAUTHORIZED_RESPONSE,
403: { description: 'Not the creator or a co-owner of the minting room (empty body)' }, 403: {
description:
'Not the creator or a co-owner of the minting room, or a priced offer without Rec Room Plus (empty body)',
},
}, },
}), }),
async (c) => { async (c) => {
@@ -4735,6 +4753,9 @@ const app = new Hono<App>({ strict: false })
const canManage = await canManageRoomById(c.env.DB, currency.RoomId, accountId) const canManage = await canManageRoomById(c.env.DB, currency.RoomId, accountId)
if (canManage === null) return purchaseOfferEnvelope(c, null, CREATE_OFFER_FAILED) if (canManage === null) return purchaseOfferEnvelope(c, null, CREATE_OFFER_FAILED)
if (!canManage) return c.body(null, 403) if (!canManage) return c.body(null, 403)
// An offer sells the currency for TOKENS, which is a Rec Room Plus perk (see
// `isSubscriber`). A free offer moves no tokens, so any room manager may make one.
if (price !== 0 && !(await isSubscriber(c))) return c.body(null, 403)
const offer = await createPurchaseOffer(c.env.DB, currencyId, { const offer = await createPurchaseOffer(c.env.DB, currencyId, {
// The body's `Amount` is the model's `CurrencyAmount`. // The body's `Amount` is the model's `CurrencyAmount`.
@@ -5015,7 +5036,8 @@ const app = new Hono<App>({ strict: false })
'offers are served sorted by it, so several at 0 keep the order they were added in.', 'offers are served sorted by it, so several at 0 keep the order they were added in.',
'', '',
'Gated to the creator or a co-owner of the room that minted the currency. A valid', 'Gated to the creator or a co-owner of the room that minted the currency. A valid',
'token from anyone else is a 403. Answers the same', 'token from anyone else is a 403, and so is a non-zero `Price` without Rec Room Plus',
'(the token’s `rn.plus` claim) — the offer is paid in tokens. Answers the same',
'`{ Value, Success, Error, error_id }` envelope the currency writes use, with the', '`{ Value, Success, Error, error_id }` envelope the currency writes use, with the',
'offer in `Value`, or a 200 carrying `Success: false` when the currency is unknown', 'offer in `Value`, or a 200 carrying `Success: false` when the currency is unknown',
'or the body unusable.', 'or the body unusable.',
@@ -5028,7 +5050,10 @@ const app = new Hono<App>({ strict: false })
'The offer as created, or a rejection with `Success: false`' 'The offer as created, or a rejection with `Success: false`'
), ),
401: UNAUTHORIZED_RESPONSE, 401: UNAUTHORIZED_RESPONSE,
403: { description: 'Not the creator or a co-owner of the minting room (empty body)' }, 403: {
description:
'Not the creator or a co-owner of the minting room, or a priced offer without Rec Room Plus (empty body)',
},
}, },
}), }),
async (c) => { async (c) => {
@@ -5059,6 +5084,9 @@ const app = new Hono<App>({ strict: false })
const canManage = await canManageRoomById(c.env.DB, currency.RoomId, accountId) const canManage = await canManageRoomById(c.env.DB, currency.RoomId, accountId)
if (canManage === null) return purchaseOfferEnvelope(c, null, CREATE_OFFER_FAILED) if (canManage === null) return purchaseOfferEnvelope(c, null, CREATE_OFFER_FAILED)
if (!canManage) return c.body(null, 403) if (!canManage) return c.body(null, 403)
// An offer sells the currency for TOKENS, which is a Rec Room Plus perk (see
// `isSubscriber`). A free offer moves no tokens, so any room manager may make one.
if (price !== 0 && !(await isSubscriber(c))) return c.body(null, 403)
const offer = await createPurchaseOffer(c.env.DB, currencyId, { const offer = await createPurchaseOffer(c.env.DB, currencyId, {
// The body's `Amount` is the model's `CurrencyAmount`. // The body's `Amount` is the model's `CurrencyAmount`.
@@ -6887,20 +6915,25 @@ const app = new Hono<App>({ strict: false })
summary: 'Create a room key', summary: 'Create a room key',
description: [ description: [
'Lists a key for one room — its name, its description and its price. Form-encoded', 'Lists a key for one room — its name, its description and its price. Form-encoded',
'(`Type=Key&RoomId=1162&Name=my%20key&Description=…&Price=50`).', '(`Type=Key&RoomId=1162&Name=my%20key&Description=…&Price=50`), optionally with a',
'`PurchaseCurrencyId` naming one of the room’s own currencies to price it in.',
'', '',
'Gated to the room’s CREATOR or a CO-OWNER, like minting a currency. A valid token', 'Gated to the room’s CREATOR or a CO-OWNER, like minting a currency. A valid token',
'from anyone else is a 403.', 'from anyone else is a 403. A key priced in tokens (no `PurchaseCurrencyId`) at a',
'non-zero `Price` also needs Rec Room Plus (the token’s `rn.plus` claim); without it,',
'403. A room-currency price does not.',
'', '',
'`Name` and `Description` are masked by the same word list as every other string a', '`Name` and `Description` are masked by the same word list as every other string a',
'player types. A room may list at most 10 keys — the `RoomKeyConfig.MaxKeysPerRoom`', 'player types. A room may list at most 10 keys — the `RoomKeyConfig.MaxKeysPerRoom`',
'the `api` config tells the client — and the eleventh is refused. A new key is priced', 'the `api` config tells the client — and the eleventh is refused. `Price` is capped by',
'in tokens (the body names no currency), so a `Price` over 1000 is refused too.', 'what the key is priced in: 1000 in tokens, 1000000000 in a room currency.',
'`PurchaseCurrencyId` absent or blank is tokens; otherwise it must be a currency of',
'the key’s own room, as on `updateAll`.',
'', '',
'Answers `{ Status, RoomKey }` — `Status` 0 and the created key, as the live client', 'Answers `{ Status, RoomKey }` — `Status` 0 and the created key, as the live client',
'reads it — not the `{ Value, Success, Error, error_id }` envelope the room-currency', 'reads it — not the `{ Value, Success, Error, error_id }` envelope the room-currency',
'writes use. A recoverable refusal (an unusable `RoomId`, an empty `Name`, an unknown', 'writes use. A recoverable refusal (an unusable `RoomId`, an empty `Name`, an unknown',
'room, a full room, a `Price` over the limit) is a 200 carrying a non-zero `Status` and a null `RoomKey`; only', 'room, a full room, a `Price` over the limit, an unknown or foreign currency) is a 200 carrying a non-zero `Status` and a null `RoomKey`; only',
'the auth gates answer with an HTTP status of their own.', 'the auth gates answer with an HTTP status of their own.',
'', '',
'Pushes `LocalRoomKeyCreated` (120), carrying the same object, to everyone in the', 'Pushes `LocalRoomKeyCreated` (120), carrying the same object, to everyone in the',
@@ -6911,7 +6944,10 @@ const app = new Hono<App>({ strict: false })
responses: { responses: {
200: json(RoomKeyEnvelope, 'The key as created, or a refusal with a non-zero `Status`'), 200: json(RoomKeyEnvelope, 'The key as created, or a refusal with a non-zero `Status`'),
401: UNAUTHORIZED_RESPONSE, 401: UNAUTHORIZED_RESPONSE,
403: { description: 'Not the room’s creator or a co-owner (empty body)' }, 403: {
description:
'Not the room’s creator or a co-owner, or a token price without Rec Room Plus (empty body)',
},
}, },
}), }),
async (c) => { async (c) => {
@@ -6929,15 +6965,32 @@ const app = new Hono<App>({ strict: false })
if (!Number.isInteger(roomId)) return roomKeyEnvelope(c, null) if (!Number.isInteger(roomId)) return roomKeyEnvelope(c, null)
const name = str(body.Name).trim() const name = str(body.Name).trim()
if (name === '') return roomKeyEnvelope(c, null) if (name === '') return roomKeyEnvelope(c, null)
// A key cannot cost less than nothing — nor, being priced in tokens until an edit // A key cannot cost less than nothing.
// says otherwise, more than the token cap.
const price = Math.max(0, int(body.Price, 0)) const price = Math.max(0, int(body.Price, 0))
if (price > MAX_KEY_PRICE_TOKENS) return roomKeyEnvelope(c, null)
const canManage = await canManageRoomById(c.env.DB, roomId, accountId) const canManage = await canManageRoomById(c.env.DB, roomId, accountId)
if (canManage === null) return roomKeyEnvelope(c, null) if (canManage === null) return roomKeyEnvelope(c, null)
if (!canManage) return c.body(null, 403) if (!canManage) return c.body(null, 403)
// Absent or blank is TOKENS; otherwise the key is sold in its own room's money, not
// another's — the same rule `updateAll` applies.
const postedCurrencyId = str(body.PurchaseCurrencyId).trim()
let purchaseCurrencyId: string | null = null
if (postedCurrencyId !== '') {
const currency = await getRoomCurrency(c.env.DB, postedCurrencyId)
if (!currency || currency.RoomId !== roomId) return roomKeyEnvelope(c, null)
purchaseCurrencyId = currency.CurrencyId
}
const maxPrice =
purchaseCurrencyId === null ? MAX_KEY_PRICE_TOKENS : MAX_KEY_PRICE_ROOM_CURRENCY
if (price > maxPrice) return roomKeyEnvelope(c, null)
// Selling for TOKENS is a Rec Room Plus perk (see `isSubscriber`). A room-currency
// price, or a free key, is open to any room manager.
if (purchaseCurrencyId === null && price !== 0 && !(await isSubscriber(c))) {
return c.body(null, 403)
}
if ((await getRoomKeys(c.env.DB, roomId)).length >= MAX_KEYS_PER_ROOM) { if ((await getRoomKeys(c.env.DB, roomId)).length >= MAX_KEYS_PER_ROOM) {
return roomKeyEnvelope(c, null) return roomKeyEnvelope(c, null)
} }
@@ -6948,6 +7001,7 @@ const app = new Hono<App>({ strict: false })
Name: censorSwears(name), Name: censorSwears(name),
Description: censorSwears(str(body.Description)), Description: censorSwears(str(body.Description)),
Price: price, Price: price,
PurchaseCurrencyId: purchaseCurrencyId,
}) })
await pushRoomKeyCreated(c, key, accountId) await pushRoomKeyCreated(c, key, accountId)
@@ -7202,7 +7256,9 @@ const app = new Hono<App>({ strict: false })
'(`RoomKeyId=43&Name=key1&Description=…&Price=1000&PurchaseCurrencyId=`).', '(`RoomKeyId=43&Name=key1&Description=…&Price=1000&PurchaseCurrencyId=`).',
'', '',
'Gated to the CREATOR or a CO-OWNER of the room that lists the key; a valid token', 'Gated to the CREATOR or a CO-OWNER of the room that lists the key; a valid token',
'from anyone else is a 403.', 'from anyone else is a 403. A key that ends up priced in tokens at a non-zero `Price`',
'also needs Rec Room Plus (the token’s `rn.plus` claim); without it, 403. A room',
'currency price does not.',
'', '',
'`PurchaseCurrencyId` sent EMPTY prices the key in tokens (stored and served as', '`PurchaseCurrencyId` sent EMPTY prices the key in tokens (stored and served as',
'null) — that is how a key on a room currency is taken back off it; otherwise it', 'null) — that is how a key on a room currency is taken back off it; otherwise it',
@@ -7227,7 +7283,10 @@ const app = new Hono<App>({ strict: false })
responses: { responses: {
200: json(RoomKeyEnvelope, 'The key as it now stands, or a refusal'), 200: json(RoomKeyEnvelope, 'The key as it now stands, or a refusal'),
401: UNAUTHORIZED_RESPONSE, 401: UNAUTHORIZED_RESPONSE,
403: { description: 'Not the room’s creator or a co-owner (empty body)' }, 403: {
description:
'Not the room’s creator or a co-owner, or a token price without Rec Room Plus (empty body)',
},
}, },
}), }),
async (c) => { async (c) => {
@@ -7273,6 +7332,12 @@ const app = new Hono<App>({ strict: false })
const maxPrice = const maxPrice =
purchaseCurrencyId === null ? MAX_KEY_PRICE_TOKENS : MAX_KEY_PRICE_ROOM_CURRENCY purchaseCurrencyId === null ? MAX_KEY_PRICE_TOKENS : MAX_KEY_PRICE_ROOM_CURRENCY
if (price > maxPrice) return roomKeyEnvelope(c, null) if (price > maxPrice) return roomKeyEnvelope(c, null)
// Judged on the RESULTING pair, like the cap: a token price is a Rec Room Plus perk
// (see `isSubscriber`), so an edit that leaves or puts a key on tokens at a non-zero
// price needs Plus. A room-currency price, or a free key, does not.
if (purchaseCurrencyId === null && price !== 0 && !(await isSubscriber(c))) {
return c.body(null, 403)
}
// Blank is a value here too: no art. Only absence leaves the stored name alone. // Blank is a value here too: no art. Only absence leaves the stored name alone.
const postedImageName = str(body.ImageName)?.trim() const postedImageName = str(body.ImageName)?.trim()
+7 -1
View File
@@ -822,7 +822,13 @@ export const CreateRoomKeyRequest = z.object({
Price: z Price: z
.string() .string()
.optional() .optional()
.describe('What the key costs, in tokens; defaults to 0. At most 1000'), .describe(
'What the key costs, in tokens or `PurchaseCurrencyId`; defaults to 0. At most 1000 in tokens, 1000000000 in a room currency'
),
PurchaseCurrencyId: z
.string()
.optional()
.describe('A `room_currency` id of the key’s own room; absent or blank prices it in tokens'),
}) })
/** /**
+4 -1
View File
@@ -97,6 +97,8 @@ export interface NewRoomKey {
Name: string Name: string
Description: string Description: string
Price: number Price: number
/** A `room_currency` id of the key's own room, or null for tokens. */
PurchaseCurrencyId: string | null
} }
interface RoomKeyRow { interface RoomKeyRow {
@@ -133,7 +135,7 @@ export async function createRoomKey(db: D1Database, key: NewRoomKey): Promise<Ro
.prepare( .prepare(
`INSERT INTO room_key `INSERT INTO room_key
(replication_id, room_id, key_type, name, description, price, purchase_currency_id, image_name, created_at) (replication_id, room_id, key_type, name, description, price, purchase_currency_id, image_name, created_at)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, NULL, '', ?7) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, '', ?8)
RETURNING ${SELECT_COLUMNS}` RETURNING ${SELECT_COLUMNS}`
) )
.bind( .bind(
@@ -143,6 +145,7 @@ export async function createRoomKey(db: D1Database, key: NewRoomKey): Promise<Ro
key.Name, key.Name,
key.Description, key.Description,
key.Price, key.Price,
key.PurchaseCurrencyId,
new Date().toISOString() new Date().toISOString()
) )
.first<RoomKeyRow>() .first<RoomKeyRow>()
+201 -77
View File
@@ -381,6 +381,13 @@ function b64url(input: ArrayBuffer | string): string {
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '') return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
} }
/**
* A bearer token carrying `rn.plus`. Listing anything for a TOKEN price (a room key, a
* token-priced room consumable, a room currency's purchase offer) is a Rec Room Plus perk,
* so the room-economy tests stock their shops as a subscriber.
*/
const plusBearer = (sub: string) => bearer(sub, undefined, undefined, true)
/** /**
* A bearer token for `sub`. `roles` becomes the `role` claim the auth worker stamps from an * A bearer token for `sub`. `roles` becomes the `role` claim the auth worker stamps from an
* account's flags — pass `['gameClient', 'developer']` for an elevated account; the default * account's flags — pass `['gameClient', 'developer']` for an elevated account; the default
@@ -1363,7 +1370,7 @@ describe('econ endpoints', () => {
'not an object', 'not an object',
] ]
for (const bad of badBodies) { for (const bad of badBodies) {
const res = await save(bad, await bearer('1')) const res = await save(bad, await plusBearer('1'))
expect(res.status).toBe(200) expect(res.status).toBe(200)
expect(await envOf(res)).toEqual({ expect(await envOf(res)).toEqual({
Value: null, Value: null,
@@ -1375,7 +1382,7 @@ describe('econ endpoints', () => {
expect(await shopOf(2511)).toEqual([]) expect(await shopOf(2511)).toEqual([])
// The owner stocks it. `PriceAndCurrency` collapses to the flat pair the read serves. // The owner stocks it. `PriceAndCurrency` collapses to the flat pair the read serves.
const res = await save(body, await bearer('1')) const res = await save(body, await plusBearer('1'))
expect(res.status).toBe(200) expect(res.status).toBe(200)
const env = await envOf(res) const env = await envOf(res)
expect(env).toMatchObject({ Success: true, Error: null, error_id: null }) expect(env).toMatchObject({ Success: true, Error: null, error_id: null })
@@ -1411,7 +1418,7 @@ describe('econ endpoints', () => {
}) })
test('PUT roomConsumable replaces when the body names a listing', async () => { test('PUT roomConsumable replaces when the body names a listing', async () => {
const created = (await envOf(await save({ ...body, Name: 'Before' }, await bearer('1')))) const created = (await envOf(await save({ ...body, Name: 'Before' }, await plusBearer('1'))))
.Value! .Value!
// Naming the listing REPLACES it rather than adding a second, and a co-owner may. // Naming the listing REPLACES it rather than adding a second, and a co-owner may.
@@ -1429,7 +1436,7 @@ describe('econ endpoints', () => {
PriceAndCurrency: { Price: 25, CurrencyId: null }, PriceAndCurrency: { Price: 25, CurrencyId: null },
MaximumCountPerPurchase: 10, MaximumCountPerPurchase: 10,
}, },
await bearer('2') await plusBearer('2')
) )
) )
).Value! ).Value!
@@ -1456,7 +1463,7 @@ describe('econ endpoints', () => {
await envOf( await envOf(
await save( await save(
{ RoomConsumableId: created.RoomConsumableId, Name: 'Bare' }, { RoomConsumableId: created.RoomConsumableId, Name: 'Bare' },
await bearer('1') await plusBearer('1')
) )
) )
).Value! ).Value!
@@ -1471,11 +1478,12 @@ describe('econ endpoints', () => {
}) })
test('PUT roomConsumable masks a name players will see, and a room can stock several', async () => { test('PUT roomConsumable masks a name players will see, and a room can stock several', async () => {
const masked = (await envOf(await save({ ...body, Name: 'shit potion' }, await bearer('1')))) const masked = (
.Value! await envOf(await save({ ...body, Name: 'shit potion' }, await plusBearer('1')))
).Value!
expect(masked.Name).toBe('**** potion') expect(masked.Name).toBe('**** potion')
await save({ ...body, Name: 'Second' }, await bearer('1')) await save({ ...body, Name: 'Second' }, await plusBearer('1'))
const shop = await shopOf(2511) const shop = await shopOf(2511)
expect(shop.length).toBeGreaterThanOrEqual(2) expect(shop.length).toBeGreaterThanOrEqual(2)
// Each listing has its own id — two things in a shop are not one thing. // Each listing has its own id — two things in a shop are not one thing.
@@ -1494,9 +1502,9 @@ describe('econ endpoints', () => {
AwardedAt: string AwardedAt: string
} | null } | null
} }
const potion = (await envOf(await save({ ...body, Name: 'Potion' }, await bearer('1')))) const potion = (await envOf(await save({ ...body, Name: 'Potion' }, await plusBearer('1'))))
.Value! .Value!
const elixir = (await envOf(await save({ ...body, Name: 'Elixir' }, await bearer('1')))) const elixir = (await envOf(await save({ ...body, Name: 'Elixir' }, await plusBearer('1'))))
.Value! .Value!
const awardBulk = async (payload: unknown, headers?: Record<string, string>) => const awardBulk = async (payload: unknown, headers?: Record<string, string>) =>
@@ -1621,12 +1629,12 @@ describe('econ endpoints', () => {
PriceAndCurrency: { Price: 5, CurrencyId: null }, PriceAndCurrency: { Price: 5, CurrencyId: null },
MaximumCountPerPurchase: 3, MaximumCountPerPurchase: 3,
}, },
await bearer('1') await plusBearer('1')
) )
) )
).Value! ).Value!
const coinItem = ( const coinItem = (
await envOf(await save({ ...body, Name: 'Coin potion' }, await bearer('1'))) await envOf(await save({ ...body, Name: 'Coin potion' }, await plusBearer('1')))
).Value! ).Value!
expect(tokenItem).toMatchObject({ Price: 5, PurchaseCurrencyId: null }) expect(tokenItem).toMatchObject({ Price: 5, PurchaseCurrencyId: null })
expect(coinItem).toMatchObject({ Price: 50, PurchaseCurrencyId: coin }) expect(coinItem).toMatchObject({ Price: 50, PurchaseCurrencyId: coin })
@@ -1647,7 +1655,7 @@ describe('econ endpoints', () => {
method: 'PUT', method: 'PUT',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
...(sub === null ? {} : await bearer(sub)), ...(sub === null ? {} : await plusBearer(sub)),
}, },
body: JSON.stringify({ body: JSON.stringify({
ConcurrencyCodes: { ConcurrencyCodes: {
@@ -1867,7 +1875,7 @@ describe('econ endpoints', () => {
const mine = async (sub: string | null, roomId: number | string = 2511) => { const mine = async (sub: string | null, roomId: number | string = 2511) => {
const res = await exports.default.fetch( const res = await exports.default.fetch(
`${ORIGIN}/api/roomconsumables/v1/roomConsumable/room/${roomId}/me`, `${ORIGIN}/api/roomconsumables/v1/roomConsumable/room/${roomId}/me`,
sub === null ? undefined : { headers: await bearer(sub) } sub === null ? undefined : { headers: await plusBearer(sub) }
) )
return { return {
status: res.status, status: res.status,
@@ -1904,7 +1912,7 @@ describe('econ endpoints', () => {
test('PUT roomConsumable/:id/consume uses up one the caller holds', async () => { test('PUT roomConsumable/:id/consume uses up one the caller holds', async () => {
const potion = ( const potion = (
await envOf(await save({ ...body, Name: 'Drinkable potion' }, await bearer('1'))) await envOf(await save({ ...body, Name: 'Drinkable potion' }, await plusBearer('1')))
).Value! ).Value!
await env.DB.prepare( await env.DB.prepare(
'INSERT INTO room_inventory (player_id, consumable_id, quantity) VALUES (7801, ?1, 2)' 'INSERT INTO room_inventory (player_id, consumable_id, quantity) VALUES (7801, ?1, 2)'
@@ -1919,7 +1927,7 @@ describe('econ endpoints', () => {
method: 'PUT', method: 'PUT',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
...(sub === null ? {} : await bearer(sub)), ...(sub === null ? {} : await plusBearer(sub)),
}, },
body: JSON.stringify({ body: JSON.stringify({
CurrentConcurrencyCode: '872adb91-b163-4f2c-aa8f-4f5d828078cb', CurrentConcurrencyCode: '872adb91-b163-4f2c-aa8f-4f5d828078cb',
@@ -2062,7 +2070,7 @@ describe('econ endpoints', () => {
{ ...body, RoomId: 'nope' }, { ...body, RoomId: 'nope' },
{ ...body, Name: ' ' }, { ...body, Name: ' ' },
]) { ]) {
const res = await create(bad, await bearer('1')) const res = await create(bad, await plusBearer('1'))
expect(res.status).toBe(200) expect(res.status).toBe(200)
expect(await envOf(res)).toEqual({ expect(await envOf(res)).toEqual({
Value: null, Value: null,
@@ -2075,7 +2083,7 @@ describe('econ endpoints', () => {
}) })
test('POST createCurrency mints the currency and the list reads it back', async () => { test('POST createCurrency mints the currency and the list reads it back', async () => {
const res = await create(body, await bearer('1')) const res = await create(body, await plusBearer('1'))
expect(res.status).toBe(200) expect(res.status).toBe(200)
// Enveloped: the client unwraps it and hands its callers `Value` alone. // Enveloped: the client unwraps it and hands its callers `Value` alone.
@@ -2128,7 +2136,7 @@ describe('econ endpoints', () => {
}) })
test('POST updateCurrency edits it, gated by the room that minted it', async () => { test('POST updateCurrency edits it, gated by the room that minted it', async () => {
const made = (await envOf(await create({ ...body, Name: 'Before' }, await bearer('1')))) const made = (await envOf(await create({ ...body, Name: 'Before' }, await plusBearer('1'))))
.Value! .Value!
const update = async (fields: Record<string, string>, headers?: Record<string, string>) => const update = async (fields: Record<string, string>, headers?: Record<string, string>) =>
@@ -2153,7 +2161,7 @@ describe('econ endpoints', () => {
{ CurrencyId: made.CurrencyId, Name: ' ' }, { CurrencyId: made.CurrencyId, Name: ' ' },
] ]
for (const bad of badEdits) { for (const bad of badEdits) {
const res = await update(bad, await bearer('1')) const res = await update(bad, await plusBearer('1'))
expect(res.status).toBe(200) expect(res.status).toBe(200)
expect(await envOf(res)).toEqual({ expect(await envOf(res)).toEqual({
Value: null, Value: null,
@@ -2177,7 +2185,7 @@ describe('econ endpoints', () => {
Shape: '9', Shape: '9',
Color: '5', Color: '5',
}, },
await bearer('1') await plusBearer('1')
) )
expect(res.status).toBe(200) expect(res.status).toBe(200)
const updated = (await envOf(res)).Value! const updated = (await envOf(res)).Value!
@@ -2213,13 +2221,13 @@ describe('econ endpoints', () => {
// And a name is masked on the way in, like it is on create. // And a name is masked on the way in, like it is on create.
const masked = await update( const masked = await update(
{ CurrencyId: made.CurrencyId, Name: 'shit bucks' }, { CurrencyId: made.CurrencyId, Name: 'shit bucks' },
await bearer('1') await plusBearer('1')
) )
expect((await envOf(masked)).Value?.Name).toBe('**** bucks') expect((await envOf(masked)).Value?.Name).toBe('**** bucks')
}) })
test('POST updateCurrency pushes RoomCurrencyModified to the room and the editor', async () => { test('POST updateCurrency pushes RoomCurrencyModified to the room and the editor', async () => {
const made = (await envOf(await create({ ...body, Name: 'Watched' }, await bearer('1')))) const made = (await envOf(await create({ ...body, Name: 'Watched' }, await plusBearer('1'))))
.Value! .Value!
const now = Math.floor(Date.now() / 1000) const now = Math.floor(Date.now() / 1000)
@@ -2238,7 +2246,7 @@ describe('econ endpoints', () => {
method: 'POST', method: 'POST',
headers: { headers: {
'Content-Type': 'application/x-www-form-urlencoded', 'Content-Type': 'application/x-www-form-urlencoded',
...(await bearer('1')), ...(await plusBearer('1')),
}, },
body: new URLSearchParams({ CurrencyId: made.CurrencyId, Name: 'Renamed' }), body: new URLSearchParams({ CurrencyId: made.CurrencyId, Name: 'Renamed' }),
}) })
@@ -2263,7 +2271,7 @@ describe('econ endpoints', () => {
test('POST awardCurrency/bulk credits players, reporting each entry on its own', async () => { test('POST awardCurrency/bulk credits players, reporting each entry on its own', async () => {
const coin = ( const coin = (
await envOf(await create({ ...body, Name: 'Payable', Limit: '10' }, await bearer('1'))) await envOf(await create({ ...body, Name: 'Payable', Limit: '10' }, await plusBearer('1')))
).Value! ).Value!
type AwardResult = { type AwardResult = {
@@ -2314,7 +2322,7 @@ describe('econ endpoints', () => {
]) ])
// The owner awards: the result names the entry and carries what the award did. // The owner awards: the result names the entry and carries what the award did.
const res = await award(one, await bearer('1')) const res = await award(one, await plusBearer('1'))
expect(res.status).toBe(200) expect(res.status).toBe(200)
const results = await resultsOf(res) const results = await resultsOf(res)
expect(results).toHaveLength(1) expect(results).toHaveLength(1)
@@ -2362,7 +2370,7 @@ describe('econ endpoints', () => {
{ CurrencyId: coin.CurrencyId, RecipientId: 'nope', Amount: 1 }, { CurrencyId: coin.CurrencyId, RecipientId: 'nope', Amount: 1 },
{ CurrencyId: coin.CurrencyId, RecipientId: 205, Amount: 1 }, { CurrencyId: coin.CurrencyId, RecipientId: 205, Amount: 1 },
], ],
await bearer('1') await plusBearer('1')
) )
const mixedResults = await resultsOf(mixed) const mixedResults = await resultsOf(mixed)
expect(mixedResults.map((r) => r.Success)).toEqual([true, false, false, true]) expect(mixedResults.map((r) => r.Success)).toEqual([true, false, false, true])
@@ -2378,11 +2386,11 @@ describe('econ endpoints', () => {
// A body that is not a list of awards has no entries to report on. // A body that is not a list of awards has no entries to report on.
for (const bad of [{ CurrencyId: coin.CurrencyId }, 'nope', null]) { for (const bad of [{ CurrencyId: coin.CurrencyId }, 'nope', null]) {
const res = await award(bad, await bearer('1')) const res = await award(bad, await plusBearer('1'))
expect(res.status).toBe(200) expect(res.status).toBe(200)
expect(await resultsOf(res)).toEqual([]) expect(await resultsOf(res)).toEqual([])
} }
expect(await resultsOf(await award([], await bearer('1')))).toEqual([]) expect(await resultsOf(await award([], await plusBearer('1')))).toEqual([])
// getBalance reads back what the awards left, for any player the query names. // getBalance reads back what the awards left, for any player the query names.
const balance = async (query: string, headers?: Record<string, string>) => const balance = async (query: string, headers?: Record<string, string>) =>
@@ -2399,14 +2407,16 @@ describe('econ endpoints', () => {
`currencyId=${coin.CurrencyId}&accountId=207`, `currencyId=${coin.CurrencyId}&accountId=207`,
'currencyId=a3f1e8d2-0000-4000-8000-000000000000&accountId=205', 'currencyId=a3f1e8d2-0000-4000-8000-000000000000&accountId=205',
]) { ]) {
expect(await (await balance(query, await bearer('1'))).json()).toMatchObject({ Balance: 0 }) expect(await (await balance(query, await plusBearer('1'))).json()).toMatchObject({
Balance: 0,
})
} }
for (const query of [ for (const query of [
`currencyId=${coin.CurrencyId}`, `currencyId=${coin.CurrencyId}`,
'accountId=205', 'accountId=205',
`currencyId=${coin.CurrencyId}&accountId=x`, `currencyId=${coin.CurrencyId}&accountId=x`,
]) { ]) {
expect((await balance(query, await bearer('1'))).status).toBe(400) expect((await balance(query, await plusBearer('1'))).status).toBe(400)
} }
}) })
@@ -2415,7 +2425,7 @@ describe('econ endpoints', () => {
// holdings — and nothing tracks a day's awards yet, so it must not bound a balance. // holdings — and nothing tracks a day's awards yet, so it must not bound a balance.
// It once did, which left every player of a Limit-3 currency stuck at 3 coins. // It once did, which left every player of a Limit-3 currency stuck at 3 coins.
const capped = ( const capped = (
await envOf(await create({ ...body, Name: 'Limited', Limit: '3' }, await bearer('1'))) await envOf(await create({ ...body, Name: 'Limited', Limit: '3' }, await plusBearer('1')))
).Value! ).Value!
const award = async (currencyId: string, recipientId: number, amount: number) => const award = async (currencyId: string, recipientId: number, amount: number) =>
( (
@@ -2424,7 +2434,7 @@ describe('econ endpoints', () => {
method: 'POST', method: 'POST',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
...(await bearer('1')), ...(await plusBearer('1')),
}, },
body: JSON.stringify([ body: JSON.stringify([
{ CurrencyId: currencyId, RecipientId: recipientId, Amount: amount }, { CurrencyId: currencyId, RecipientId: recipientId, Amount: amount },
@@ -2453,7 +2463,7 @@ describe('econ endpoints', () => {
// A `Limit` of 0 is no different from any other — it bounds nothing either way. // A `Limit` of 0 is no different from any other — it bounds nothing either way.
const unlimited = ( const unlimited = (
await envOf(await create({ RoomId: '2511', Name: 'Unlimited' }, await bearer('1'))) await envOf(await create({ RoomId: '2511', Name: 'Unlimited' }, await plusBearer('1')))
).Value! ).Value!
expect(await landed(unlimited.CurrencyId, 212, 50_000)).toEqual({ expect(await landed(unlimited.CurrencyId, 212, 50_000)).toEqual({
Balance: 50_000, Balance: 50_000,
@@ -2479,13 +2489,14 @@ describe('econ endpoints', () => {
) )
).json()) as Group[] ).json()) as Group[]
const shopped = (await envOf(await create({ ...body, Name: 'Shopped' }, await bearer('1')))) const shopped = (
.Value! await envOf(await create({ ...body, Name: 'Shopped' }, await plusBearer('1')))
).Value!
const alsoShopped = ( const alsoShopped = (
await envOf(await create({ ...body, Name: 'AlsoShopped' }, await bearer('1'))) await envOf(await create({ ...body, Name: 'AlsoShopped' }, await plusBearer('1')))
).Value! ).Value!
const unshopped = ( const unshopped = (
await envOf(await create({ ...body, Name: 'Unshopped' }, await bearer('1'))) await envOf(await create({ ...body, Name: 'Unshopped' }, await plusBearer('1')))
).Value! ).Value!
// Seeded directly, deliberately out of `Order`, to prove the read sorts them. // Seeded directly, deliberately out of `Order`, to prove the read sorts them.
@@ -2622,7 +2633,7 @@ describe('econ endpoints', () => {
method: 'POST', method: 'POST',
headers: { headers: {
'Content-Type': 'application/x-www-form-urlencoded', 'Content-Type': 'application/x-www-form-urlencoded',
...(sub === null ? {} : await bearer(sub)), ...(sub === null ? {} : await plusBearer(sub)),
}, },
body: new URLSearchParams(fields), body: new URLSearchParams(fields),
}) })
@@ -2660,9 +2671,10 @@ describe('econ endpoints', () => {
} }
// Two shops in room 2511, so the delete has to find the right one from the id alone. // Two shops in room 2511, so the delete has to find the right one from the id alone.
const shopped = (await envOf(await create({ ...body, Name: 'Shopped' }, await bearer('1')))) const shopped = (
.Value! await envOf(await create({ ...body, Name: 'Shopped' }, await plusBearer('1')))
const other = (await envOf(await create({ ...body, Name: 'Other' }, await bearer('1')))) ).Value!
const other = (await envOf(await create({ ...body, Name: 'Other' }, await plusBearer('1'))))
.Value! .Value!
const first = await addOffer(shopped.CurrencyId, 'First', 0) const first = await addOffer(shopped.CurrencyId, 'First', 0)
const second = await addOffer(shopped.CurrencyId, 'Second', 1) const second = await addOffer(shopped.CurrencyId, 'Second', 1)
@@ -2739,7 +2751,7 @@ describe('econ endpoints', () => {
// Account 1 owns the room but is NOT standing in it — the settings UI opens from // Account 1 owns the room but is NOT standing in it — the settings UI opens from
// anywhere, so the creator is added to the audience separately. // anywhere, so the creator is added to the audience separately.
const res = await create({ ...body, Name: 'Broadcast bucks' }, await bearer('1')) const res = await create({ ...body, Name: 'Broadcast bucks' }, await plusBearer('1'))
expect(res.status).toBe(200) expect(res.status).toBe(200)
const created = (await envOf(res)).Value! const created = (await envOf(res)).Value!
@@ -2771,7 +2783,7 @@ describe('econ endpoints', () => {
}) })
test('POST createCurrency masks a name players will see, and defaults the rest', async () => { test('POST createCurrency masks a name players will see, and defaults the rest', async () => {
const res = await create({ RoomId: '2511', Name: 'shit bucks' }, await bearer('1')) const res = await create({ RoomId: '2511', Name: 'shit bucks' }, await plusBearer('1'))
expect(res.status).toBe(200) expect(res.status).toBe(200)
const created = (await envOf(res)).Value! const created = (await envOf(res)).Value!
// Masked per character like every other player-typed string (see the gift message // Masked per character like every other player-typed string (see the gift message
@@ -2786,7 +2798,7 @@ describe('econ endpoints', () => {
// long. Neither may be clamped or dropped on the way through. // long. Neither may be clamped or dropped on the way through.
const res = await create( const res = await create(
{ RoomId: '2511', Name: 'Uncoloured', Limit: '1000000', Color: '-1' }, { RoomId: '2511', Name: 'Uncoloured', Limit: '1000000', Color: '-1' },
await bearer('1') await plusBearer('1')
) )
expect(res.status).toBe(200) expect(res.status).toBe(200)
expect((await envOf(res)).Value).toMatchObject({ Limit: 1000000, Color: -1 }) expect((await envOf(res)).Value).toMatchObject({ Limit: 1000000, Color: -1 })
@@ -2798,7 +2810,7 @@ describe('econ endpoints', () => {
method: 'POST', method: 'POST',
headers: { headers: {
'Content-Type': 'application/x-www-form-urlencoded', 'Content-Type': 'application/x-www-form-urlencoded',
...(sub === null ? {} : await bearer(sub)), ...(sub === null ? {} : await plusBearer(sub)),
}, },
body: new URLSearchParams(fields), body: new URLSearchParams(fields),
}) })
@@ -2857,9 +2869,10 @@ describe('econ endpoints', () => {
}) })
// Two currencies in room 2511 (account 1's), so the offer has to pick the right one. // Two currencies in room 2511 (account 1's), so the offer has to pick the right one.
const other = (await envOf(await create({ ...body, Name: 'Other' }, await bearer('1')))) const other = (await envOf(await create({ ...body, Name: 'Other' }, await plusBearer('1'))))
.Value!
const sold = (await envOf(await create({ ...body, Name: 'Sold' }, await plusBearer('1'))))
.Value! .Value!
const sold = (await envOf(await create({ ...body, Name: 'Sold' }, await bearer('1')))).Value!
await addOffer(other.CurrencyId, 444, 1) await addOffer(other.CurrencyId, 444, 1)
const offerId = await addOffer(sold.CurrencyId, 444, 555) const offerId = await addOffer(sold.CurrencyId, 444, 555)
const freeOfferId = await addOffer(sold.CurrencyId, 5, 0) const freeOfferId = await addOffer(sold.CurrencyId, 5, 0)
@@ -3049,7 +3062,7 @@ describe('econ endpoints', () => {
{ ...body, RoomId: 'nope' }, { ...body, RoomId: 'nope' },
{ ...body, Name: ' ' }, { ...body, Name: ' ' },
]) { ]) {
const res = await create(bad, await bearer('1')) const res = await create(bad, await plusBearer('1'))
expect(res.status).toBe(200) expect(res.status).toBe(200)
expect(await envOf(res)).toEqual({ Status: 1, RoomKey: null }) expect(await envOf(res)).toEqual({ Status: 1, RoomKey: null })
} }
@@ -3057,7 +3070,7 @@ describe('econ endpoints', () => {
}) })
test('POST create lists the key and the room read serves it back', async () => { test('POST create lists the key and the room read serves it back', async () => {
const res = await create(body, await bearer('1')) const res = await create(body, await plusBearer('1'))
expect(res.status).toBe(200) expect(res.status).toBe(200)
const envelope = await envOf(res) const envelope = await envOf(res)
expect(Object.keys(envelope)).toEqual(['Status', 'RoomKey']) expect(Object.keys(envelope)).toEqual(['Status', 'RoomKey'])
@@ -3101,7 +3114,7 @@ describe('econ endpoints', () => {
expect(await keysOf(2511)).toEqual([created]) expect(await keysOf(2511)).toEqual([created])
// A co-owner may list one too, and the list is oldest first. // A co-owner may list one too, and the list is oldest first.
const second = await keyOf(await create({ ...body, Name: 'second' }, await bearer('2'))) const second = await keyOf(await create({ ...body, Name: 'second' }, await plusBearer('2')))
expect(second.RoomKeyId).toBeGreaterThan(created.RoomKeyId) expect(second.RoomKeyId).toBeGreaterThan(created.RoomKeyId)
expect((await keysOf(2511)).map((k) => k.Name)).toEqual(['my key', 'second']) expect((await keysOf(2511)).map((k) => k.Name)).toEqual(['my key', 'second'])
}) })
@@ -3122,7 +3135,7 @@ describe('econ endpoints', () => {
await putInRoom(321, 1) await putInRoom(321, 1)
await drainFrames() await drainFrames()
const created = await keyOf(await create({ ...body, Name: 'pushed' }, await bearer('1'))) const created = await keyOf(await create({ ...body, Name: 'pushed' }, await plusBearer('1')))
const frames = await drainFrames() const frames = await drainFrames()
expect(frames.map((f) => f.accountId).sort((a, b) => a - b)).toEqual([1, 320]) expect(frames.map((f) => f.accountId).sort((a, b) => a - b)).toEqual([1, 320])
expect(frames.every((f) => f.notificationType === NotificationType.LocalRoomKeyCreated)).toBe( expect(frames.every((f) => f.notificationType === NotificationType.LocalRoomKeyCreated)).toBe(
@@ -3137,7 +3150,7 @@ describe('econ endpoints', () => {
}) })
test('POST create masks a name players will see, and defaults the rest', async () => { test('POST create masks a name players will see, and defaults the rest', async () => {
const res = await create({ RoomId: '2511', Name: 'shit key' }, await bearer('1')) const res = await create({ RoomId: '2511', Name: 'shit key' }, await plusBearer('1'))
const created = await keyOf(res) const created = await keyOf(res)
expect(created.Name).toBe('**** key') expect(created.Name).toBe('**** key')
expect(created).toMatchObject({ Description: '', Price: 0, Type: 0 }) expect(created).toMatchObject({ Description: '', Price: 0, Type: 0 })
@@ -3146,7 +3159,7 @@ describe('econ endpoints', () => {
.first<{ key_type: string }>() .first<{ key_type: string }>()
expect(row?.key_type).toBe('Key') expect(row?.key_type).toBe('Key')
// A negative price is floored at nothing. // A negative price is floored at nothing.
const free = await keyOf(await create({ ...body, Price: '-5' }, await bearer('1'))) const free = await keyOf(await create({ ...body, Price: '-5' }, await plusBearer('1')))
expect(free.Price).toBe(0) expect(free.Price).toBe(0)
}) })
@@ -3164,7 +3177,7 @@ describe('econ endpoints', () => {
).all<{ room_key_id: number; account_id: number; awarded_at: string }>() ).all<{ room_key_id: number; account_id: number; awarded_at: string }>()
).results ).results
const key = await keyOf(await create({ ...body, Name: 'awarded' }, await bearer('1'))) const key = await keyOf(await create({ ...body, Name: 'awarded' }, await plusBearer('1')))
const id = key.RoomKeyId const id = key.RoomKeyId
// Auth-gated. // Auth-gated.
@@ -3219,7 +3232,7 @@ describe('econ endpoints', () => {
body: JSON.stringify(payload), body: JSON.stringify(payload),
}) })
const key = await keyOf(await create({ ...body, Name: 'held' }, await bearer('1'))) const key = await keyOf(await create({ ...body, Name: 'held' }, await plusBearer('1')))
const id = key.RoomKeyId const id = key.RoomKeyId
await post('awardbulk', [{ RoomKeyId: id, AccountId: 42 }], await bearer('42')) await post('awardbulk', [{ RoomKeyId: id, AccountId: 42 }], await bearer('42'))
@@ -3256,11 +3269,11 @@ describe('econ endpoints', () => {
test('POST create refuses the eleventh key in a room', async () => { test('POST create refuses the eleventh key in a room', async () => {
// MaxKeysPerRoom is 10 (apps/api/static/api-config-v2.json). Fill up to it… // MaxKeysPerRoom is 10 (apps/api/static/api-config-v2.json). Fill up to it…
while ((await keysOf(2511)).length < 10) { while ((await keysOf(2511)).length < 10) {
const res = await create({ ...body, Name: 'filler' }, await bearer('1')) const res = await create({ ...body, Name: 'filler' }, await plusBearer('1'))
expect((await envOf(res)).Status).toBe(0) expect((await envOf(res)).Status).toBe(0)
} }
// …and the next is refused, leaving the room at ten. // …and the next is refused, leaving the room at ten.
const res = await create({ ...body, Name: 'one too many' }, await bearer('1')) const res = await create({ ...body, Name: 'one too many' }, await plusBearer('1'))
expect(res.status).toBe(200) expect(res.status).toBe(200)
expect(await envOf(res)).toEqual({ Status: 1, RoomKey: null }) expect(await envOf(res)).toEqual({ Status: 1, RoomKey: null })
expect((await keysOf(2511)).length).toBe(10) expect((await keysOf(2511)).length).toBe(10)
@@ -3268,9 +3281,9 @@ describe('econ endpoints', () => {
}) })
test('POST create refuses a token price over 1000', async () => { test('POST create refuses a token price over 1000', async () => {
const atCap = await create({ ...body, Price: '1000' }, await bearer('1')) const atCap = await create({ ...body, Price: '1000' }, await plusBearer('1'))
expect((await envOf(atCap)).Status).toBe(0) expect((await envOf(atCap)).Status).toBe(0)
const over = await create({ ...body, Price: '1001' }, await bearer('1')) const over = await create({ ...body, Price: '1001' }, await plusBearer('1'))
expect(await envOf(over)).toEqual({ Status: 1, RoomKey: null }) expect(await envOf(over)).toEqual({ Status: 1, RoomKey: null })
await env.DB.prepare('DELETE FROM room_key').run() await env.DB.prepare('DELETE FROM room_key').run()
}) })
@@ -3296,8 +3309,8 @@ describe('econ endpoints', () => {
).json()) as { Value: { CurrencyId: string } } ).json()) as { Value: { CurrencyId: string } }
).Value.CurrencyId ).Value.CurrencyId
const key = await keyOf(await create(body, await bearer('1'))) const key = await keyOf(await create(body, await plusBearer('1')))
const other = await keyOf(await create({ ...body, Name: 'other' }, await bearer('1'))) const other = await keyOf(await create({ ...body, Name: 'other' }, await plusBearer('1')))
const id = String(key.RoomKeyId) const id = String(key.RoomKeyId)
// The body exactly as the client puts it. // The body exactly as the client puts it.
const edit = { const edit = {
@@ -3313,29 +3326,29 @@ describe('econ endpoints', () => {
expect((await update(edit, await bearer('42'))).status).toBe(403) expect((await update(edit, await bearer('42'))).status).toBe(403)
// In tokens: 1000 passes, 1001 does not. A co-owner may edit. // In tokens: 1000 passes, 1001 does not. A co-owner may edit.
const res = await update(edit, await bearer('2')) const res = await update(edit, await plusBearer('2'))
expect(res.status).toBe(200) expect(res.status).toBe(200)
expect(await envOf(res)).toEqual({ expect(await envOf(res)).toEqual({
Status: 0, Status: 0,
RoomKey: { ...key, Name: 'key1', Description: 'testssdfsdfsdf', Price: 1000 }, RoomKey: { ...key, Name: 'key1', Description: 'testssdfsdfsdf', Price: 1000 },
}) })
expect(await envOf(await update({ ...edit, Price: '1001' }, await bearer('1')))).toEqual( expect(await envOf(await update({ ...edit, Price: '1001' }, await plusBearer('1')))).toEqual(
refused refused
) )
// In the room's own currency the cap is a billion. // In the room's own currency the cap is a billion.
const currencyId = await mint('2511', '1') const currencyId = await mint('2511', '1')
const rich = { ...edit, Price: '1000000000', PurchaseCurrencyId: currencyId } const rich = { ...edit, Price: '1000000000', PurchaseCurrencyId: currencyId }
expect((await envOf(await update(rich, await bearer('1')))).RoomKey).toMatchObject({ expect((await envOf(await update(rich, await plusBearer('1')))).RoomKey).toMatchObject({
Price: 1000000000, Price: 1000000000,
PurchaseCurrencyId: currencyId, PurchaseCurrencyId: currencyId,
}) })
expect( expect(
await envOf(await update({ ...rich, Price: '1000000001' }, await bearer('1'))) await envOf(await update({ ...rich, Price: '1000000001' }, await plusBearer('1')))
).toEqual(refused) ).toEqual(refused)
// Back to tokens with the big price still on it: the token cap applies again. // Back to tokens with the big price still on it: the token cap applies again.
expect( expect(
await envOf(await update({ RoomKeyId: id, PurchaseCurrencyId: '' }, await bearer('1'))) await envOf(await update({ RoomKeyId: id, PurchaseCurrencyId: '' }, await plusBearer('1')))
).toEqual(refused) ).toEqual(refused)
// Refusals: no such key, a blank name, an unknown currency. // Refusals: no such key, a blank name, an unknown currency.
@@ -3344,7 +3357,7 @@ describe('econ endpoints', () => {
{ ...edit, Name: ' ' }, { ...edit, Name: ' ' },
{ ...edit, PurchaseCurrencyId: '46eddc63-e6d4-42cc-bf04-5d879e33ac17' }, { ...edit, PurchaseCurrencyId: '46eddc63-e6d4-42cc-bf04-5d879e33ac17' },
]) { ]) {
expect(await envOf(await update(bad, await bearer('1')))).toEqual(refused) expect(await envOf(await update(bad, await plusBearer('1')))).toEqual(refused)
} }
// Only the named key moved, and the refused edits left it as the last good one. // Only the named key moved, and the refused edits left it as the last good one.
@@ -3372,7 +3385,7 @@ describe('econ endpoints', () => {
method: 'PUT', method: 'PUT',
headers: { headers: {
'Content-Type': 'application/x-www-form-urlencoded', 'Content-Type': 'application/x-www-form-urlencoded',
...(await bearer('1')), ...(await plusBearer('1')),
}, },
body: new URLSearchParams(fields), body: new URLSearchParams(fields),
}) })
@@ -3390,14 +3403,14 @@ describe('econ endpoints', () => {
method: 'POST', method: 'POST',
headers: { headers: {
'Content-Type': 'application/x-www-form-urlencoded', 'Content-Type': 'application/x-www-form-urlencoded',
...(await bearer('1')), ...(await plusBearer('1')),
}, },
body: new URLSearchParams({ RoomId: '2511', Name: 'coin' }), body: new URLSearchParams({ RoomId: '2511', Name: 'coin' }),
}) })
).json()) as { Value: { CurrencyId: string } } ).json()) as { Value: { CurrencyId: string } }
).Value.CurrencyId ).Value.CurrencyId
const key = await keyOf(await create(body, await bearer('1'))) const key = await keyOf(await create(body, await plusBearer('1')))
const id = String(key.RoomKeyId) const id = String(key.RoomKeyId)
expect(key).toMatchObject({ PurchaseCurrencyId: null, ImageName: null }) expect(key).toMatchObject({ PurchaseCurrencyId: null, ImageName: null })
@@ -3446,7 +3459,7 @@ describe('econ endpoints', () => {
exports.default.fetch( exports.default.fetch(
`${ORIGIN}/api/storefronts/v1/buyRoomKey?RoomKeyId=${roomKeyId}` + `${ORIGIN}/api/storefronts/v1/buyRoomKey?RoomKeyId=${roomKeyId}` +
(price === undefined ? '' : `&RequestedPrice=${price}`), (price === undefined ? '' : `&RequestedPrice=${price}`),
sub === null ? undefined : { headers: await bearer(sub) } sub === null ? undefined : { headers: await plusBearer(sub) }
) )
const setTokens = (accountId: number, amount: number) => const setTokens = (accountId: number, amount: number) =>
env.DB.prepare( env.DB.prepare(
@@ -3482,9 +3495,9 @@ describe('econ endpoints', () => {
}) })
// Room 2511 is account 1's. A 300-token key, and a free one. // Room 2511 is account 1's. A 300-token key, and a free one.
const key = await keyOf(await create({ ...body, Price: '300' }, await bearer('1'))) const key = await keyOf(await create({ ...body, Price: '300' }, await plusBearer('1')))
const free = await keyOf( const free = await keyOf(
await create({ ...body, Name: 'free', Price: '0' }, await bearer('1')) await create({ ...body, Name: 'free', Price: '0' }, await plusBearer('1'))
) )
await setTokens(1, 1000) await setTokens(1, 1000)
await setTokens(7301, 500) await setTokens(7301, 500)
@@ -3593,7 +3606,7 @@ describe('econ endpoints', () => {
method, method,
headers: { headers: {
'Content-Type': 'application/x-www-form-urlencoded', 'Content-Type': 'application/x-www-form-urlencoded',
...(sub === null ? {} : await bearer(sub)), ...(sub === null ? {} : await plusBearer(sub)),
}, },
body: new URLSearchParams(fields), body: new URLSearchParams(fields),
}) })
@@ -3662,7 +3675,7 @@ describe('econ endpoints', () => {
// A key priced at 1000 of the room's own currency; a second currency; a token key. // A key priced at 1000 of the room's own currency; a second currency; a token key.
const coin = await mint('coin') const coin = await mint('coin')
const other = await mint('other') const other = await mint('other')
const made = await keyOf(await create(body, await bearer('1'))) const made = await keyOf(await create(body, await plusBearer('1')))
const key = ( const key = (
await envOf( await envOf(
await form( await form(
@@ -3674,7 +3687,9 @@ describe('econ endpoints', () => {
) )
).RoomKey! ).RoomKey!
expect(key).toMatchObject({ Price: 1000, PurchaseCurrencyId: coin }) expect(key).toMatchObject({ Price: 1000, PurchaseCurrencyId: coin })
const tokenKey = await keyOf(await create({ ...body, Name: 'token key' }, await bearer('1'))) const tokenKey = await keyOf(
await create({ ...body, Name: 'token key' }, await plusBearer('1'))
)
await give(coin, 7501, 1500) await give(coin, 7501, 1500)
await give(other, 7501, 5000) await give(other, 7501, 5000)
await give(coin, 7502, 999) await give(coin, 7502, 999)
@@ -3761,6 +3776,114 @@ describe('econ endpoints', () => {
}) })
}) })
// Selling for TOKENS is a Rec Room Plus perk: a room manager without `rn.plus` may not put
// a non-zero token price on a room key, a room consumable or a currency purchase offer.
// A room-currency price, or a free listing, is open to every manager. Room 2511 is
// account 1's.
test('a token price on a room key, consumable or currency offer needs Rec Room Plus', async () => {
const send = async (
method: string,
path: string,
body: BodyInit,
type: string,
plus: boolean
) =>
exports.default.fetch(`${ORIGIN}${path}`, {
method,
headers: {
'Content-Type': type,
...(await (plus ? plusBearer('1') : bearer('1'))),
},
body,
})
const formSend = (method: string, path: string, fields: Record<string, string>, plus = false) =>
send(method, path, new URLSearchParams(fields), 'application/x-www-form-urlencoded', plus)
const jsonSend = (path: string, payload: unknown, plus = false) =>
send('PUT', path, JSON.stringify(payload), 'application/json', plus)
const coin = (
(await (
await formSend('POST', '/api/roomcurrencies/v1/createCurrency', {
RoomId: '2511',
Name: 'plus coin',
})
).json()) as { Value: { CurrencyId: string } }
).Value.CurrencyId
// Room keys: a create is always priced in tokens.
const keyPath = '/api/roomkeys/v1/create'
const key = { RoomId: '2511', Name: 'gate key', Price: '50' }
expect((await formSend('POST', keyPath, key)).status).toBe(403)
expect((await formSend('POST', keyPath, { ...key, Price: '0' })).status).toBe(200)
const free = (
(await (await formSend('POST', keyPath, { ...key, Price: '0' })).json()) as {
RoomKey: { RoomKeyId: number }
}
).RoomKey
const update = (fields: Record<string, string>) =>
formSend('PUT', '/api/roomkeys/v1/updateAll', {
RoomKeyId: String(free.RoomKeyId),
...fields,
})
// An edit to a token price is refused; onto the room's currency it is not.
expect((await update({ Price: '50' })).status).toBe(403)
expect((await update({ Price: '50', PurchaseCurrencyId: coin })).status).toBe(200)
// Taking it back off the currency at that price would make it a token key.
expect((await update({ PurchaseCurrencyId: '' })).status).toBe(403)
expect((await formSend('POST', keyPath, key, true)).status).toBe(200)
// A key CREATED on the room's currency needs no Plus, and is stored on it.
const onCoin = await formSend('POST', keyPath, {
...key,
Type: 'Key',
Description: 'coin key',
Price: '10',
PurchaseCurrencyId: coin,
})
expect(onCoin.status).toBe(200)
expect(
((await onCoin.json()) as { RoomKey: { Price: number; PurchaseCurrencyId: string } }).RoomKey
).toMatchObject({ Price: 10, PurchaseCurrencyId: coin })
// A room currency lifts the token cap, and a currency of another room is refused.
expect(
(
(await (
await formSend('POST', keyPath, { ...key, Price: '5000', PurchaseCurrencyId: coin })
).json()) as { Status: number }
).Status
).toBe(0)
expect(
await (
await formSend('POST', keyPath, { ...key, PurchaseCurrencyId: crypto.randomUUID() })
).json()
).toEqual({ Status: 1, RoomKey: null })
await env.DB.prepare('DELETE FROM room_key WHERE room_id = 2511').run()
// Room consumables: CurrencyId null is a token price.
const consumablePath = '/api/roomconsumables/v1/roomConsumable'
const listing = (price: number, currencyId: string | null) => ({
RoomConsumableId: null,
RoomId: 2511,
Name: 'gate snack',
Description: '',
PriceAndCurrency: { Price: price, CurrencyId: currencyId },
})
expect((await jsonSend(consumablePath, listing(50, null))).status).toBe(403)
expect((await jsonSend(consumablePath, listing(50, coin))).status).toBe(200)
expect((await jsonSend(consumablePath, listing(0, null))).status).toBe(200)
expect((await jsonSend(consumablePath, listing(50, null), true)).status).toBe(200)
// Currency purchase offers are always paid in tokens.
const offerPath = '/api/roomcurrencies/v1/createPurchaseOffer'
const offer = { CurrencyId: coin, Name: 'pack', Amount: '5', Price: '50' }
expect((await formSend('POST', offerPath, offer)).status).toBe(403)
expect((await formSend('POST', offerPath, { ...offer, Price: '0' })).status).toBe(200)
expect((await formSend('POST', offerPath, offer, true)).status).toBe(200)
await env.DB.prepare('DELETE FROM room_key WHERE room_id = 2511').run()
await env.DB.prepare('DELETE FROM room_consumable WHERE room_id = 2511').run()
await env.DB.prepare('DELETE FROM room_currency WHERE currency_id = ?1').bind(coin).run()
})
test('GET /api/roomcurrencies/v1/getAllBalances returns []', async () => { test('GET /api/roomcurrencies/v1/getAllBalances returns []', async () => {
const res = await exports.default.fetch( const res = await exports.default.fetch(
`${ORIGIN}/api/roomcurrencies/v1/getAllBalances?roomId=1` `${ORIGIN}/api/roomcurrencies/v1/getAllBalances?roomId=1`
@@ -3802,6 +3925,7 @@ describe('econ endpoints', () => {
Name: 'VIP Key', Name: 'VIP Key',
Description: '', Description: '',
Price: 250, Price: 250,
PurchaseCurrencyId: null,
}) })
const consumable = await upsertRoomConsumable(env.DB, null, { const consumable = await upsertRoomConsumable(env.DB, null, {
RoomId: 93, RoomId: 93,