From 5b9520e4dd0852caf629ebd73be34050c9474c34 Mon Sep 17 00:00:00 2001 From: Devin Zuczek Date: Wed, 28 Oct 2026 10:54:17 -0400 Subject: [PATCH] [plus] enforce aura/paint/emoji/selling for plus, since client removed --- apps/accounts/src/accounts.app.ts | 16 +- .../accounts/src/test/integration/api.test.ts | 67 ++++- apps/api/src/custom-avatar-items-db.ts | 7 + apps/api/src/http.ts | 11 +- apps/api/src/routes/avatar.ts | 148 +++++++++- apps/api/src/test/integration/api.test.ts | 184 +++++++++++- apps/econ/src/econ.app.ts | 101 +++++-- apps/econ/src/openapi.ts | 8 +- apps/econ/src/room-key-db.ts | 5 +- apps/econ/src/test/integration/api.test.ts | 278 +++++++++++++----- 10 files changed, 691 insertions(+), 134 deletions(-) diff --git a/apps/accounts/src/accounts.app.ts b/apps/accounts/src/accounts.app.ts index aa2f21a..f02e0f8 100644 --- a/apps/accounts/src/accounts.app.ts +++ b/apps/accounts/src/accounts.app.ts @@ -27,7 +27,7 @@ import { withNotFound, withOnError, } 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 // value — the enum has no runtime dependencies. @@ -1071,6 +1071,11 @@ const app = new Hono() // // 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. + // + // 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( '/account/me/emoji', describeRoute({ @@ -1079,7 +1084,8 @@ const app = new Hono() description: [ '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', - 'clears the pick.', + 'clears the pick. Setting one requires Rec Room Plus (the token’s `rn.plus` claim);', + 'clearing does not.', ].join(' '), security: AUTHED, requestBody: form(EmojiRequest, 'A whitelisted emoji, or "" to clear'), @@ -1087,6 +1093,7 @@ const app = new Hono() 200: json(SuccessResponse, 'Updated'), 400: { description: 'Not a whitelisted emoji (empty body)' }, 401: UNAUTHORIZED_RESPONSE, + 403: { description: 'Setting an emoji without Rec Room Plus (empty body)' }, }, }), async (c) => { @@ -1096,6 +1103,11 @@ const app = new Hono() // An empty value clears the pick; anything else has to be on the list. const displayEmoji = submitted === '' ? '' : resolveWhitelistedEmoji(submitted) 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 }) await pushAccountUpdate(c, account) return c.json({ success: true }) diff --git a/apps/accounts/src/test/integration/api.test.ts b/apps/accounts/src/test/integration/api.test.ts index ac3c4d4..51a0eaf 100644 --- a/apps/accounts/src/test/integration/api.test.ts +++ b/apps/accounts/src/test/integration/api.test.ts @@ -50,10 +50,13 @@ function b64url(input: ArrayBuffer | string): string { return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '') } -async function bearer(sub = '42'): Promise> { +async function bearer( + sub = '42', + claims: Record = {} +): Promise> { const now = Math.floor(Date.now() / 1000) 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( 'raw', @@ -66,6 +69,9 @@ async function bearer(sub = '42'): Promise> { 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): RequestInit => ({ method: 'PUT', 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`). const res = await exports.default.fetch(`${ORIGIN}/account/me/emoji`, { 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', }) 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 () => { const res = await exports.default.fetch(`${ORIGIN}/account/me/emoji`, { ...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) @@ -524,13 +536,19 @@ describe('auth-gated endpoints', () => { test('PUT /account/me/emoji clears the pick on an empty value', async () => { const set = await exports.default.fetch(`${ORIGIN}/account/me/emoji`, { ...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) const cleared = await exports.default.fetch(`${ORIGIN}/account/me/emoji`, { ...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) @@ -549,7 +567,10 @@ describe('auth-gated endpoints', () => { 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`, { ...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) } @@ -559,6 +580,38 @@ describe('auth-gated endpoints', () => { 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 () => { const anon = await exports.default.fetch(`${ORIGIN}/account/me/bannerimage`, { ...form({ imageName: 'x.jpg' }), diff --git a/apps/api/src/custom-avatar-items-db.ts b/apps/api/src/custom-avatar-items-db.ts index cc84160..724a0f5 100644 --- a/apps/api/src/custom-avatar-items-db.ts +++ b/apps/api/src/custom-avatar-items-db.ts @@ -147,6 +147,13 @@ export const OUTFIT_TYPE_CUSTOM_SHIRT = 105 */ export const MAX_PLAYER_ITEM_PRICE = 1000 +/** + * The fewest tokens a player-made item may sell for: a price is 0 (free, open to anyone) or + * at least this. Served as `minPriceForPublicItem`, which the client's creation UI enforces, + * and checked on the create and the edit as well so the API can't price an item at 1. + */ +export const MIN_PLAYER_ITEM_PRICE = 100 + /** What `POST /api/customAvatarItems/v1` needs to create an item. */ export interface CreateCustomAvatarItemInput { /** The item's id. Chosen by the caller because the upload keys are derived from it. */ diff --git a/apps/api/src/http.ts b/apps/api/src/http.ts index cee663a..b908d1a 100644 --- a/apps/api/src/http.ts +++ b/apps/api/src/http.ts @@ -1,4 +1,4 @@ -import { validateAndGetAccountId, validateAndGetRoles } from '@repo/jwt' +import { validateAndGetAccountId, validateAndGetPlus, validateAndGetRoles } from '@repo/jwt' import type { Context } from 'hono' import type { App } from './context' @@ -22,6 +22,15 @@ export async function authedRoles(c: Context): Promise { 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): Promise { + return validateAndGetPlus(c.req.raw, await c.env.JWT_SECRET.get()) +} + /** Results.Unauthorized() equivalent — 401 with empty body. */ export function unauthorized(c: Context) { return c.body(null, 401) diff --git a/apps/api/src/routes/avatar.ts b/apps/api/src/routes/avatar.ts index fe75309..f303fb2 100644 --- a/apps/api/src/routes/avatar.ts +++ b/apps/api/src/routes/avatar.ts @@ -25,11 +25,12 @@ import { listFeaturedCustomAvatarItems, listHotCustomAvatarItems, MAX_PLAYER_ITEM_PRICE, + MIN_PLAYER_ITEM_PRICE, searchCustomAvatarItems, toQuestCustomAvatarItem, updateCustomAvatarItem, } from '../custom-avatar-items-db' -import { authedId, unauthorized } from '../http' +import { authedId, authedPlus, unauthorized } from '../http' import { createInvention, deleteInvention, @@ -135,6 +136,59 @@ import type { InventionTag, SavedInvention } from '../inventions-db' */ const BULK_CUSTOM_AVATAR_ITEM_CAP = 100 +/** The all-zero Guid — what a C# client sends for an unset Guid field. */ +const EMPTY_GUID = '00000000-0000-0000-0000-000000000000' + +/** + * The refusal for putting a price above 0 on an invention without Rec Room Plus (the token's + * `rn.plus` claim). Selling for tokens is a Plus perk; making an invention free is not, so a + * player whose Plus lapsed can still take theirs off sale. + */ +const INVENTION_PRICE_NEEDS_PLUS = 'Rec Room Plus is required to sell an invention' + +/** The same refusal for a custom avatar item — selling one for tokens is a Plus perk too. */ +const CUSTOM_ITEM_PRICE_NEEDS_PLUS = 'Rec Room Plus is required to sell a custom avatar item' + +/** + * The Rec Room Plus perks an outfit body wears, by name — empty when it wears none. Both + * live in `CustomizationSettings`, the client's JSON document encoded as a string (an + * object is accepted too, for a hand-written request): + * + * - an aura, `AuraData.Id`; + * - body paint, `BodyPaint` (a Guid string). + * + * The client's "none" spelling for either hasn't been observed, so every plausible one + * reads as none — a missing/null `AuraData` or an `Id` of 0, and a missing, null, empty or + * all-zero `BodyPaint`. Erring the other way would refuse every non-Plus save if that is + * how the client says "none". A settings string that isn't JSON names no perks either: it's + * stored verbatim and nothing here can read one out of it. + */ +function plusPerksWorn(body: Record): string[] { + 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 + + const perks: string[] = [] + const auraId = + typeof AuraData === 'object' && AuraData !== null + ? (AuraData as Record).Id + : undefined + if (auraId !== undefined && auraId !== null && auraId !== 0 && auraId !== '' && auraId !== '0') { + perks.push('an aura') + } + if (typeof BodyPaint === 'string' && BodyPaint !== '' && BodyPaint !== EMPTY_GUID) { + perks.push('body paint') + } + return perks +} + /** * The ids `POST /api/customAvatarItems/v1/bulk` was asked to resolve. They ride as repeated * `customAvatarItemIds` form fields, and the same spelling is read off the query string @@ -462,10 +516,11 @@ export const avatarRoutes = new Hono({ strict: false }) tags: ['Avatar'], summary: 'Minimum token price for a public custom item', description: - 'The floor the creation UI enforces when listing a custom item publicly. A fixed `100`.', + 'The floor the creation UI enforces when listing a custom item publicly. A fixed `100`' + + ' (`MIN_PLAYER_ITEM_PRICE`), which the create and edit enforce too: a price is 0 or at least this.', responses: { 200: json(BareInteger, 'A bare `100`') }, }), - (c) => c.json(100) + (c) => c.json(MIN_PLAYER_ITEM_PRICE) ) .post( '/api/customAvatarItems/v1', @@ -486,16 +541,21 @@ export const avatarRoutes = new Hono({ strict: false }) 'from that signature, and anything else is refused with a 400 before either file is ' + 'written.\n\n' + '`Price` is capped at 1000 tokens (`MAX_PLAYER_ITEM_PRICE`); one above it, or below ' + - '0, is refused with a 400 rather than clamped.', + '0, is refused with a 400 rather than clamped, and so is one between 1 and 99 — a ' + + 'price is 0 (free) or at least 100 (`MIN_PLAYER_ITEM_PRICE`, the ' + + '`minPriceForPublicItem` floor). A `Price` above 0 needs Rec Room ' + + 'Plus (the token’s `rn.plus` claim); without it the create is refused with a 403 ' + + 'and nothing is written.', security: AUTHED, requestBody: form(CreateCustomAvatarItemRequest, 'The metadata and the two files'), responses: { 200: json(CustomAvatarItemResponse, 'The created item'), 400: json( CustomAvatarItemResponse, - 'Missing or malformed metadata / files, a file that is not an image, or a `Price` outside 0–1000' + 'Missing or malformed metadata / files, a file that is not an image, or a `Price` that is not 0 or 100–1000' ), 401: UNAUTHORIZED_RESPONSE, + 403: json(CustomAvatarItemResponse, 'A `Price` above 0 without Rec Room Plus'), 413: json(CustomAvatarItemResponse, 'Either file exceeds the configured per-file limit'), }, }), @@ -526,6 +586,16 @@ export const avatarRoutes = new Hono({ strict: false }) if (meta.Price < 0) return fail('Price must be >= 0') if (meta.Price > MAX_PLAYER_ITEM_PRICE) return fail(`Price must be <= ${MAX_PLAYER_ITEM_PRICE}`) + if (meta.Price > 0 && meta.Price < MIN_PLAYER_ITEM_PRICE) + return fail(`Price must be 0 or >= ${MIN_PLAYER_ITEM_PRICE}`) + // Selling for tokens is a Rec Room Plus perk; a free item needs none. Checked + // before the files are read, so a refusal writes nothing to R2. + if (meta.Price > 0 && !(await authedPlus(c))) { + return c.json( + { Value: null, Success: false, Error: CUSTOM_ITEM_PRICE_NEEDS_PLUS, error_id: null }, + 403 + ) + } } if (!(body.thumbnailImage instanceof File)) return fail('thumbnailImage is required') if (!(body.design instanceof File)) return fail('design is required') @@ -607,15 +677,22 @@ export const avatarRoutes = new Hono({ strict: false }) 'sends every field and nulls the ones it is not changing, so null means "leave ' + 'alone". Only the creator may edit. `ModifiedAt` is bumped. Answers the updated ' + 'item in the same `{ Value, Success, Error, error_id }` envelope as the create. ' + - '`Price` is bound the same way as on the create: 0 to 1000 tokens, or a 400.', + '`Price` is bound the same way as on the create: 0, or 100 to 1000 tokens, or a 400; and ' + + 'setting one above 0 needs Rec Room Plus (the token’s `rn.plus` claim), or a 403.', security: AUTHED, parameters: [stringParam('id', 'The `CustomAvatarItemId`')], requestBody: jsonBody(UpdateCustomAvatarItemRequest, 'The fields to change'), responses: { 200: json(CustomAvatarItemResponse, 'The updated item'), - 400: json(CustomAvatarItemResponse, 'Malformed body, or a `Price` outside 0–1000'), + 400: json( + CustomAvatarItemResponse, + 'Malformed body, or a `Price` that is not 0 or 100–1000' + ), 401: UNAUTHORIZED_RESPONSE, - 403: json(CustomAvatarItemResponse, 'Not the creator'), + 403: json( + CustomAvatarItemResponse, + 'Not the creator, or a `Price` above 0 without Rec Room Plus' + ), 404: json(CustomAvatarItemResponse, 'No such item'), }, }), @@ -659,6 +736,12 @@ export const avatarRoutes = new Hono({ strict: false }) if (patch.price !== null && patch.price < 0) return fail(400, 'Price must be >= 0') if (patch.price !== null && patch.price > MAX_PLAYER_ITEM_PRICE) return fail(400, `Price must be <= ${MAX_PLAYER_ITEM_PRICE}`) + if (patch.price !== null && patch.price > 0 && patch.price < MIN_PLAYER_ITEM_PRICE) + return fail(400, `Price must be 0 or >= ${MIN_PLAYER_ITEM_PRICE}`) + // Setting a price above 0 is a Rec Room Plus perk. Null (leave it) and 0 (make it + // free) need none, so a creator whose Plus lapsed can still take an item off sale. + if (patch.price !== null && patch.price > 0 && !(await authedPlus(c))) + return fail(403, CUSTOM_ITEM_PRICE_NEEDS_PLUS) const item = await updateCustomAvatarItem(c.env.DB, itemId, patch) if (!item) return fail(404, 'No such item') @@ -1075,13 +1158,23 @@ export const avatarRoutes = new Hono({ strict: false }) 'client’s own serializer, so nothing here parses or re-encodes them.\n\n' + 'The response is the base envelope with no `Value` key — three keys, and the outfit ' + 'is not echoed back. The mixed casing (`Success`/`Error` but `error_id`) is the ' + - 'reference’s, not a typo.', + 'reference’s, not a typo.\n\n' + + 'An aura (`CustomizationSettings.AuraData.Id`) and body paint ' + + '(`CustomizationSettings.BodyPaint`) are Rec Room Plus perks: an outfit wearing ' + + 'either, without the token’s `rn.plus` claim the save is refused with a 403 and ' + + '`Success: false`, and nothing is stored.', security: AUTHED, requestBody: jsonBody(OutfitsMeRequest, 'The outfit to save'), responses: { 200: json(OutfitSaveResponse, 'Saved — `{ Success: true, Error: null, error_id: null }`'), 400: json(ErrorResponse, 'Unparseable body'), 401: UNAUTHORIZED_RESPONSE, + 403: json( + OutfitSaveResponse, + 'The outfit wears an aura (`CustomizationSettings.AuraData.Id`) or body paint ' + + '(`CustomizationSettings.BodyPaint`) and the caller’s ' + + 'token has no Rec Room Plus (`rn.plus`) claim — `Success: false`, nothing saved' + ), }, }), async (c) => { @@ -1091,6 +1184,22 @@ export const avatarRoutes = new Hono({ strict: false }) const body = (await c.req.json().catch(() => null)) as Record | null if (body === null) return c.json({ error: 'Invalid request body' }, 400) + // Auras and body paint are Plus perks: an outfit wearing either is refused outright + // for a caller without the token's `rn.plus` claim, rather than saved with the perk + // stripped — the client keeps what it sent, so a silent strip would show the player + // something nobody else sees. + const perks = plusPerksWorn(body) + if (perks.length > 0 && !(await authedPlus(c))) { + return c.json( + { + Success: false, + Error: `Rec Room Plus is required to wear ${perks.join(' or ')}`, + error_id: null, + }, + 403 + ) + } + // The client sends `Slot`; a body without one saves the worn outfit. const outfit = { ...body, @@ -1498,7 +1607,8 @@ export const avatarRoutes = new Hono({ strict: false }) 'What puts an invention into search and the feeds. Sets the permission other ' + 'players get (defaulting to UseOnly) and its price. Another GET that writes. A ' + 'price over 1000 tokens (`MAX_INVENTION_PRICE`) refuses the whole publish with a ' + - '400 rather than being clamped or dropped.', + '400 rather than being clamped or dropped. A price above 0 needs Rec Room Plus ' + + '(the token’s `rn.plus` claim); without it the publish is refused with a 403.', security: AUTHED, parameters: [ intQuery('inventionId', 'Invention id; required'), @@ -1509,7 +1619,7 @@ export const avatarRoutes = new Hono({ strict: false }) 200: json(InventionSaveResult, 'The published invention, in the save envelope'), 400: json(ErrorResponse, 'A price over 1000'), 401: UNAUTHORIZED_RESPONSE, - 403: json(ErrorResponse, 'Not the caller’s invention'), + 403: json(ErrorResponse, 'Not the caller’s invention, or a price without Rec Room Plus'), 404: { description: 'No such invention' }, }, }), @@ -1521,6 +1631,8 @@ export const avatarRoutes = new Hono({ strict: false }) const price = Number.parseInt(c.req.query('price') ?? '', 10) if (price > MAX_INVENTION_PRICE) return c.json({ error: `Price must be <= ${MAX_INVENTION_PRICE}` }, 400) + if (price > 0 && !(await authedPlus(c))) + return c.json({ error: INVENTION_PRICE_NEEDS_PLUS }, 403) const published = await publishInvention(c.env.DB, gate.invention.InventionId, { permissionLevel: @@ -1540,14 +1652,15 @@ export const avatarRoutes = new Hono({ strict: false }) summary: 'Set an invention’s price', description: 'Unlike update/publish, this one POSTs a JSON body. Creator only; a negative price ' + - 'is rejected, as is one over 1000 tokens (`MAX_INVENTION_PRICE`).', + 'is rejected, as is one over 1000 tokens (`MAX_INVENTION_PRICE`). A price above 0 ' + + 'needs Rec Room Plus (the token’s `rn.plus` claim); without it, 403. Setting 0 does not.', security: AUTHED, requestBody: jsonBody(UpdatePriceRequest, 'The invention and its new price'), responses: { 200: json(InventionSaveResult, 'The repriced invention, in the save envelope'), 400: json(ErrorResponse, 'Unparseable body, or a price below 0 or over 1000'), 401: UNAUTHORIZED_RESPONSE, - 403: json(ErrorResponse, 'Not the caller’s invention'), + 403: json(ErrorResponse, 'Not the caller’s invention, or a price without Rec Room Plus'), 404: { description: 'No such invention' }, }, }), @@ -1563,6 +1676,8 @@ export const avatarRoutes = new Hono({ strict: false }) if (Number.isNaN(price) || price < 0) return c.json({ error: 'Price must be >= 0' }, 400) if (price > MAX_INVENTION_PRICE) return c.json({ error: `Price must be <= ${MAX_INVENTION_PRICE}` }, 400) + if (price > 0 && !(await authedPlus(c))) + return c.json({ error: INVENTION_PRICE_NEEDS_PLUS }, 403) const updated = await setInventionPrice(c.env.DB, gate.invention.InventionId, price) return updated === null ? c.notFound() : c.json(toSaveResult(updated)) @@ -2348,7 +2463,8 @@ export const avatarRoutes = new Hono({ strict: false }) 'than zeroing it, so re-publishing something that was for sale doesn’t give it ' + 'away; every field but `InventionId` is nullable and an omitted one keeps what ' + 'the invention has. A price over 1000 tokens (`MAX_INVENTION_PRICE`) refuses the ' + - 'publish in-band.\n\n' + + 'publish in-band, and so does a `Price` above 0 without Rec Room Plus (the ' + + 'token’s `rn.plus` claim).\n\n' + 'Publishing is undone by `v2/unpublish`, and re-publishing doesn’t re-date the first ' + 'publish. Refusals answer `Success: false` with a null `Value`, the way ' + '`v9/save` does.', @@ -2388,6 +2504,10 @@ export const avatarRoutes = new Hono({ strict: false }) const price = int('Price') if (price !== undefined && price > MAX_INVENTION_PRICE) return c.json(inventionSaveV9Failure(`Price must be <= ${MAX_INVENTION_PRICE}`)) + // Selling an invention is a Rec Room Plus perk. Refused in-band like the cap, so the + // client reads it out of the envelope. Null (leave it) and 0 (free) need no Plus. + if (price !== undefined && price > 0 && !(await authedPlus(c))) + return c.json(inventionSaveV9Failure(INVENTION_PRICE_NEEDS_PLUS)) const published = await publishInvention(c.env.DB, gate.invention.InventionId, { permissionLevel: int('Permission'), diff --git a/apps/api/src/test/integration/api.test.ts b/apps/api/src/test/integration/api.test.ts index 3858a91..7785f59 100644 --- a/apps/api/src/test/integration/api.test.ts +++ b/apps/api/src/test/integration/api.test.ts @@ -229,11 +229,12 @@ function b64url(input: ArrayBuffer | string): string { // off, the token carries none, which is what a plain player's looks like to the // role-gated routes. `version` mints the `rn.ver` claim auth stamps from the build the // client posted at login; left off, the token carries none, like one issued before the -// claim existed. +// claim existed. `plus` mints the `rn.plus` claim auth stamps from `account.hasPlus`. async function bearer( sub = '42', roles?: string[], - version?: string + version?: string, + plus?: boolean ): Promise> { const now = Math.floor(Date.now() / 1000) const signingInput = `${b64url(JSON.stringify({ alg: 'HS256', typ: 'JWT' }))}.${b64url( @@ -242,6 +243,7 @@ async function bearer( exp: now + 3600, ...(roles && { role: roles }), ...(version && { 'rn.ver': version }), + ...(plus && { 'rn.plus': true }), }) )}` const key = await crypto.subtle.importKey( @@ -1986,6 +1988,60 @@ describe('public endpoints', () => { expect(((await worn.json()) as { Name: string | null }).Name).toBe(null) }) + test('PUT /outfits/me refuses an aura or body paint without Plus', async () => { + const outfit = (auraId: number | null, bodyPaint: string | null, Slot: number) => ({ + DataVersion: 10, + CustomizationSettings: JSON.stringify({ + AvatarVersion: 10, + ...(auraId !== null && { AuraData: { Id: auraId } }), + ...(bodyPaint !== null && { BodyPaint: bodyPaint }), + }), + Selections: [], + Slot, + }) + const put = async (body: unknown, headers: Record) => + exports.default.fetch(`${ORIGIN}/outfits/me`, { + method: 'PUT', + headers: { ...headers, 'content-type': 'application/json' }, + body: JSON.stringify(body), + }) + const paint = '44d7e2a4-ae8d-4b95-8686-63e0f7d1ef8b' + + // No Plus: an aura, body paint, or both are refused and nothing lands in the slot. + const refused = await put(outfit(14, null, 5), await bearer('4444')) + expect(refused.status).toBe(403) + expect(await refused.json()).toEqual({ + Success: false, + Error: 'Rec Room Plus is required to wear an aura', + error_id: null, + }) + const painted = await put(outfit(null, paint, 5), await bearer('4444')) + expect(painted.status).toBe(403) + expect(((await painted.json()) as { Error: string }).Error).toBe( + 'Rec Room Plus is required to wear body paint' + ) + const both = await put(outfit(14, paint, 5), await bearer('4444')) + expect(((await both.json()) as { Error: string }).Error).toBe( + 'Rec Room Plus is required to wear an aura or body paint' + ) + const saved = await exports.default.fetch(`${ORIGIN}/outfits/me/saved`, { + headers: await bearer('4444'), + }) + expect(((await saved.json()) as { Slot: number }[]).some((o) => o.Slot === 5)).toBe(false) + + // No Plus, neither perk (absent, aura Id 0, empty or all-zero paint): saves as before. + expect((await put(outfit(null, null, 0), await bearer('4444'))).status).toBe(200) + expect((await put(outfit(0, '', 0), await bearer('4444'))).status).toBe(200) + expect( + (await put(outfit(0, '00000000-0000-0000-0000-000000000000', 0), await bearer('4444'))).status + ).toBe(200) + + // Plus: both save. + const plus = await put(outfit(14, paint, 5), await bearer('4444', undefined, undefined, true)) + expect(plus.status).toBe(200) + expect(await plus.json()).toEqual({ Success: true, Error: null, error_id: null }) + }) + test('GET /outfits/me/saved lists every saved slot, ordered by slot', async () => { const anon = await exports.default.fetch(`${ORIGIN}/outfits/me/saved`) expect(anon.status).toBe(401) @@ -3047,7 +3103,10 @@ describe('public endpoints', () => { const publish = async (body: Record) => { const res = await exports.default.fetch(`${ORIGIN}/api/inventions/v4/publish`, { method: 'POST', - headers: { ...(await bearer('5172')), 'Content-Type': 'application/json' }, + headers: { + ...(await bearer('5172', undefined, undefined, true)), + 'Content-Type': 'application/json', + }, body: JSON.stringify({ InventionId: inventionId, ...body }), }) const value = ((await res.json()) as InventionSaveV9Result).Value @@ -3055,6 +3114,25 @@ describe('public endpoints', () => { return value.Invention } + // A price is a Rec Room Plus perk: without `rn.plus` the publish is refused in-band and + // nothing is published. + const unpaid = await exports.default.fetch(`${ORIGIN}/api/inventions/v4/publish`, { + method: 'POST', + headers: { ...(await bearer('5172')), 'Content-Type': 'application/json' }, + body: JSON.stringify({ + InventionId: inventionId, + Permission: 80, + Accessibility: 1, + Price: 250, + }), + }) + expect(unpaid.status).toBe(200) + expect(await unpaid.json()).toMatchObject({ + Success: false, + Value: null, + Error: 'Rec Room Plus is required to sell an invention', + }) + const first = await publish({ Permission: 80, Accessibility: 1, Price: 250 }) expect(first.Price).toBe(250) @@ -3071,7 +3149,10 @@ describe('public endpoints', () => { // One over the cap refuses the publish in-band, and the price stands. const over = await exports.default.fetch(`${ORIGIN}/api/inventions/v4/publish`, { method: 'POST', - headers: { ...(await bearer('5172')), 'Content-Type': 'application/json' }, + headers: { + ...(await bearer('5172', undefined, undefined, true)), + 'Content-Type': 'application/json', + }, body: JSON.stringify({ InventionId: inventionId, Price: 1001 }), }) expect(over.status).toBe(200) @@ -3082,6 +3163,14 @@ describe('public endpoints', () => { }) expect((await publish({ Price: 1000 })).Price).toBe(1000) + // A republish without Plus that leaves the price alone (null) is not a sale being set. + const keep = await exports.default.fetch(`${ORIGIN}/api/inventions/v4/publish`, { + method: 'POST', + headers: { ...(await bearer('5172')), 'Content-Type': 'application/json' }, + body: JSON.stringify({ InventionId: inventionId, Permission: 20, Price: null }), + }) + expect(((await keep.json()) as InventionSaveV9Result).Success).toBe(true) + // Out of the published catalogue again — see the note in the publish test above. await env.DB.prepare('DELETE FROM invention WHERE id = ?1').bind(inventionId).run() }) @@ -4436,10 +4525,19 @@ describe('public endpoints', () => { // A saved draft is invisible until it's published. expect(await search()).toEqual([]) - const res = await exports.default.fetch( + // Pricing is a Rec Room Plus perk: without `rn.plus` the publish is refused, unpublished. + const unpaid = await exports.default.fetch( `${ORIGIN}/api/inventions/v3/publish?inventionId=${Invention.InventionId}&permissionLevel=charge&price=250`, { headers: await bearer('2121') } ) + expect(unpaid.status).toBe(403) + expect(await unpaid.json()).toEqual({ error: 'Rec Room Plus is required to sell an invention' }) + expect(await search()).toEqual([]) + + const res = await exports.default.fetch( + `${ORIGIN}/api/inventions/v3/publish?inventionId=${Invention.InventionId}&permissionLevel=charge&price=250`, + { headers: await bearer('2121', undefined, undefined, true) } + ) expect(res.status).toBe(200) const published = (await res.json()) as InventionSaveResult expect(published.Status).toBe(0) @@ -4455,7 +4553,7 @@ describe('public endpoints', () => { // A price over the cap refuses the whole publish, leaving the invention as it was. const over = await exports.default.fetch( `${ORIGIN}/api/inventions/v3/publish?inventionId=${Invention.InventionId}&price=1001`, - { headers: await bearer('2121') } + { headers: await bearer('2121', undefined, undefined, true) } ) expect(over.status).toBe(400) expect(await over.json()).toEqual({ error: 'Price must be <= 1000' }) @@ -4501,13 +4599,28 @@ describe('public endpoints', () => { body: JSON.stringify({ name: 'Priced Lamp', inventionDataFilename: 'a.inv' }), }) const { Invention } = (await save.json()) as InventionSaveResult - const updateprice = async (body: unknown, sub = '1212'): Promise => + const updateprice = async (body: unknown, sub = '1212', plus = true): Promise => exports.default.fetch(`${ORIGIN}/api/inventions/v1/updateprice`, { method: 'POST', - headers: { ...(await bearer(sub)), 'Content-Type': 'application/json' }, + headers: { + ...(await bearer(sub, undefined, undefined, plus)), + 'Content-Type': 'application/json', + }, body: JSON.stringify(body), }) + // A price above 0 is a Rec Room Plus perk; making it free is not. + const unpaid = await updateprice( + { InventionId: Invention.InventionId, Price: 500 }, + '1212', + false + ) + expect(unpaid.status).toBe(403) + expect(await unpaid.json()).toEqual({ error: 'Rec Room Plus is required to sell an invention' }) + expect( + (await updateprice({ InventionId: Invention.InventionId, Price: 0 }, '1212', false)).status + ).toBe(200) + const res = await updateprice({ InventionId: Invention.InventionId, Price: 500 }) expect(res.status).toBe(200) const priced = (await res.json()) as InventionSaveResult @@ -5050,11 +5163,28 @@ describe('custom avatar items', () => { new Date('2026-08-01T00:00:00Z') ) const url = `${ORIGIN}/api/customAvatarItems/v1/${item.CustomAvatarItemId}` - const res = await exports.default.fetch(url, { + // Pricing is a Rec Room Plus perk: without `rn.plus` the edit is refused, unchanged. + const unpaid = await exports.default.fetch(url, { method: 'PUT', headers: { ...(await bearer('205')), 'content-type': 'application/json' }, body: JSON.stringify({ Name: null, Description: null, Price: 100, Accessibility: 1 }), }) + expect(unpaid.status).toBe(403) + expect(await unpaid.json()).toMatchObject({ + Success: false, + Value: null, + Error: 'Rec Room Plus is required to sell a custom avatar item', + }) + expect((await getCustomAvatarItem(env.DB, item.CustomAvatarItemId))!.Price).toBe(0) + + const res = await exports.default.fetch(url, { + method: 'PUT', + headers: { + ...(await bearer('205', undefined, undefined, true)), + 'content-type': 'application/json', + }, + body: JSON.stringify({ Name: null, Description: null, Price: 100, Accessibility: 1 }), + }) expect(res.status).toBe(200) const body = (await res.json()) as { Value: Record @@ -5103,7 +5233,7 @@ describe('custom avatar items', () => { // player has enforce it — refusing, not clamping, so the creator sees what was set. test('the price is capped at 1000 tokens on create and edit', async () => { expect(MAX_PLAYER_ITEM_PRICE).toBe(1000) - const create = async (Price: unknown) => { + const create = async (Price: unknown, plus = true) => { const form = new FormData() form.set( 'metadata', @@ -5113,10 +5243,18 @@ describe('custom avatar items', () => { form.set('design', new File([PNG_BYTES], 'd.png', { type: 'image/png' })) return exports.default.fetch(`${ORIGIN}/api/customAvatarItems/v1`, { method: 'POST', - headers: await bearer('205'), + headers: await bearer('205', undefined, undefined, plus), body: form, }) } + // A price above 0 is a Rec Room Plus perk; without `rn.plus` the create is refused. + const unpaid = await create(100, false) + expect(unpaid.status).toBe(403) + expect(await unpaid.json()).toMatchObject({ + Success: false, + Value: null, + Error: 'Rec Room Plus is required to sell a custom avatar item', + }) const over = await create(1001) expect(over.status).toBe(400) expect(await over.json()).toMatchObject({ @@ -5126,6 +5264,11 @@ describe('custom avatar items', () => { }) expect((await create(-1)).status).toBe(400) expect((await create(10.5)).status).toBe(400) + // Below the `minPriceForPublicItem` floor: a price is 0 or at least 100, Plus or not. + const cheap = await create(1) + expect(cheap.status).toBe(400) + expect(await cheap.json()).toMatchObject({ Error: 'Price must be 0 or >= 100' }) + expect((await create(99)).status).toBe(400) // Nothing was written for the refused ones. expect( await env.DB.prepare( @@ -5148,20 +5291,35 @@ describe('custom avatar items', () => { // The edit is bound the same way, and a refused edit leaves the row as it was. const url = `${ORIGIN}/api/customAvatarItems/v1/${Value.CustomAvatarItemId}` - const edit = async (Price: number) => + const edit = async (Price: number, plus = true) => exports.default.fetch(url, { method: 'PUT', - headers: { ...(await bearer('205')), 'content-type': 'application/json' }, + headers: { + ...(await bearer('205', undefined, undefined, plus)), + 'content-type': 'application/json', + }, body: JSON.stringify({ Name: null, Description: null, Price, Accessibility: null }), }) const tooMuch = await edit(1001) expect(tooMuch.status).toBe(400) expect(await tooMuch.json()).toMatchObject({ Success: false, Error: 'Price must be <= 1000' }) expect((await edit(-1)).status).toBe(400) + expect((await edit(1)).status).toBe(400) + expect((await edit(99)).status).toBe(400) expect((await getCustomAvatarItem(env.DB, Value.CustomAvatarItemId))!.Price).toBe(1000) const lowered = await edit(999) expect(lowered.status).toBe(200) expect(((await lowered.json()) as { Value: { Price: number } }).Value.Price).toBe(999) + + // Without Plus a price can't be set, but the item can still be made free. + expect((await edit(500, false)).status).toBe(403) + const freed = await edit(0, false) + expect(freed.status).toBe(200) + expect(((await freed.json()) as { Value: { Price: number } }).Value.Price).toBe(0) + // And a free item can be created without Plus. + expect((await create(0, false)).status).toBe(200) + // The floor itself is allowed. + expect((await create(100)).status).toBe(200) }) // The cap arrived after players had already priced shirts. Migration 0032 lowers every diff --git a/apps/econ/src/econ.app.ts b/apps/econ/src/econ.app.ts index ed7e3f8..84dfb7a 100644 --- a/apps/econ/src/econ.app.ts +++ b/apps/econ/src/econ.app.ts @@ -4222,6 +4222,8 @@ const app = new Hono({ strict: false }) 'the thing actually lives.', '', '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', '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', @@ -4235,7 +4237,10 @@ const app = new Hono({ strict: false }) 'The listing as stored, or a rejection with `Success: false`' ), 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) => { @@ -4282,6 +4287,15 @@ const app = new Hono({ strict: false }) : {} ) as Record + 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, { RoomId: roomId, // Masked like every other player-typed string: a shop's contents are shown to @@ -4289,8 +4303,8 @@ const app = new Hono({ strict: false }) Name: censorSwears(name), Description: censorSwears(str(body.Description) ?? ''), ImageName: str(body.ImageName), - Price: int(priceAndCurrency.Price, 0), - PurchaseCurrencyId: str(priceAndCurrency.CurrencyId), + Price: price, + PurchaseCurrencyId: purchaseCurrencyId, // 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. MaximumCountPerPurchase: int(body.MaximumCountPerPurchase, 0), @@ -4691,7 +4705,8 @@ const app = new Hono({ strict: false }) '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', - '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', 'offer in `Value`, or a 200 carrying `Success: false` when the currency is unknown', 'or the body unusable.', @@ -4704,7 +4719,10 @@ const app = new Hono({ strict: false }) 'The offer as created, or a rejection with `Success: false`' ), 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) => { @@ -4735,6 +4753,9 @@ const app = new Hono({ strict: false }) const canManage = await canManageRoomById(c.env.DB, currency.RoomId, accountId) if (canManage === null) return purchaseOfferEnvelope(c, null, CREATE_OFFER_FAILED) 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, { // The body's `Amount` is the model's `CurrencyAmount`. @@ -5015,7 +5036,8 @@ const app = new Hono({ strict: false }) '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', - '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', 'offer in `Value`, or a 200 carrying `Success: false` when the currency is unknown', 'or the body unusable.', @@ -5028,7 +5050,10 @@ const app = new Hono({ strict: false }) 'The offer as created, or a rejection with `Success: false`' ), 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) => { @@ -5059,6 +5084,9 @@ const app = new Hono({ strict: false }) const canManage = await canManageRoomById(c.env.DB, currency.RoomId, accountId) if (canManage === null) return purchaseOfferEnvelope(c, null, CREATE_OFFER_FAILED) 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, { // The body's `Amount` is the model's `CurrencyAmount`. @@ -6887,20 +6915,25 @@ const app = new Hono({ strict: false }) summary: 'Create a room key', description: [ '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', - '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', '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', - 'in tokens (the body names no currency), so a `Price` over 1000 is refused too.', + 'the `api` config tells the client — and the eleventh is refused. `Price` is capped by', + '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', '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', - '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.', '', 'Pushes `LocalRoomKeyCreated` (120), carrying the same object, to everyone in the', @@ -6911,7 +6944,10 @@ const app = new Hono({ strict: false }) responses: { 200: json(RoomKeyEnvelope, 'The key as created, or a refusal with a non-zero `Status`'), 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) => { @@ -6929,15 +6965,32 @@ const app = new Hono({ strict: false }) if (!Number.isInteger(roomId)) return roomKeyEnvelope(c, null) const name = str(body.Name).trim() if (name === '') return roomKeyEnvelope(c, null) - // A key cannot cost less than nothing — nor, being priced in tokens until an edit - // says otherwise, more than the token cap. + // A key cannot cost less than nothing. 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) if (canManage === null) return roomKeyEnvelope(c, null) 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) { return roomKeyEnvelope(c, null) } @@ -6948,6 +7001,7 @@ const app = new Hono({ strict: false }) Name: censorSwears(name), Description: censorSwears(str(body.Description)), Price: price, + PurchaseCurrencyId: purchaseCurrencyId, }) await pushRoomKeyCreated(c, key, accountId) @@ -7202,7 +7256,9 @@ const app = new Hono({ strict: false }) '(`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', - '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', '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({ strict: false }) responses: { 200: json(RoomKeyEnvelope, 'The key as it now stands, or a refusal'), 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) => { @@ -7273,6 +7332,12 @@ const app = new Hono({ strict: false }) const maxPrice = purchaseCurrencyId === null ? MAX_KEY_PRICE_TOKENS : MAX_KEY_PRICE_ROOM_CURRENCY 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. const postedImageName = str(body.ImageName)?.trim() diff --git a/apps/econ/src/openapi.ts b/apps/econ/src/openapi.ts index 4964a23..28b1748 100644 --- a/apps/econ/src/openapi.ts +++ b/apps/econ/src/openapi.ts @@ -822,7 +822,13 @@ export const CreateRoomKeyRequest = z.object({ Price: z .string() .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'), }) /** diff --git a/apps/econ/src/room-key-db.ts b/apps/econ/src/room-key-db.ts index 93eaf0a..16dd795 100644 --- a/apps/econ/src/room-key-db.ts +++ b/apps/econ/src/room-key-db.ts @@ -97,6 +97,8 @@ export interface NewRoomKey { Name: string Description: string Price: number + /** A `room_currency` id of the key's own room, or null for tokens. */ + PurchaseCurrencyId: string | null } interface RoomKeyRow { @@ -133,7 +135,7 @@ export async function createRoomKey(db: D1Database, key: NewRoomKey): Promise() diff --git a/apps/econ/src/test/integration/api.test.ts b/apps/econ/src/test/integration/api.test.ts index 07cfb9a..984caa9 100644 --- a/apps/econ/src/test/integration/api.test.ts +++ b/apps/econ/src/test/integration/api.test.ts @@ -381,6 +381,13 @@ function b64url(input: ArrayBuffer | string): string { 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 * account's flags — pass `['gameClient', 'developer']` for an elevated account; the default @@ -1363,7 +1370,7 @@ describe('econ endpoints', () => { 'not an object', ] 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(await envOf(res)).toEqual({ Value: null, @@ -1375,7 +1382,7 @@ describe('econ endpoints', () => { expect(await shopOf(2511)).toEqual([]) // 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) const env = await envOf(res) 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 () => { - 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! // 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 }, MaximumCountPerPurchase: 10, }, - await bearer('2') + await plusBearer('2') ) ) ).Value! @@ -1456,7 +1463,7 @@ describe('econ endpoints', () => { await envOf( await save( { RoomConsumableId: created.RoomConsumableId, Name: 'Bare' }, - await bearer('1') + await plusBearer('1') ) ) ).Value! @@ -1471,11 +1478,12 @@ describe('econ endpoints', () => { }) 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')))) - .Value! + const masked = ( + await envOf(await save({ ...body, Name: 'shit potion' }, await plusBearer('1'))) + ).Value! 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) expect(shop.length).toBeGreaterThanOrEqual(2) // Each listing has its own id — two things in a shop are not one thing. @@ -1494,9 +1502,9 @@ describe('econ endpoints', () => { AwardedAt: string } | 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! - 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! const awardBulk = async (payload: unknown, headers?: Record) => @@ -1621,12 +1629,12 @@ describe('econ endpoints', () => { PriceAndCurrency: { Price: 5, CurrencyId: null }, MaximumCountPerPurchase: 3, }, - await bearer('1') + await plusBearer('1') ) ) ).Value! 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! expect(tokenItem).toMatchObject({ Price: 5, PurchaseCurrencyId: null }) expect(coinItem).toMatchObject({ Price: 50, PurchaseCurrencyId: coin }) @@ -1647,7 +1655,7 @@ describe('econ endpoints', () => { method: 'PUT', headers: { 'Content-Type': 'application/json', - ...(sub === null ? {} : await bearer(sub)), + ...(sub === null ? {} : await plusBearer(sub)), }, body: JSON.stringify({ ConcurrencyCodes: { @@ -1867,7 +1875,7 @@ describe('econ endpoints', () => { const mine = async (sub: string | null, roomId: number | string = 2511) => { const res = await exports.default.fetch( `${ORIGIN}/api/roomconsumables/v1/roomConsumable/room/${roomId}/me`, - sub === null ? undefined : { headers: await bearer(sub) } + sub === null ? undefined : { headers: await plusBearer(sub) } ) return { status: res.status, @@ -1904,7 +1912,7 @@ describe('econ endpoints', () => { test('PUT roomConsumable/:id/consume uses up one the caller holds', async () => { 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! await env.DB.prepare( 'INSERT INTO room_inventory (player_id, consumable_id, quantity) VALUES (7801, ?1, 2)' @@ -1919,7 +1927,7 @@ describe('econ endpoints', () => { method: 'PUT', headers: { 'Content-Type': 'application/json', - ...(sub === null ? {} : await bearer(sub)), + ...(sub === null ? {} : await plusBearer(sub)), }, body: JSON.stringify({ CurrentConcurrencyCode: '872adb91-b163-4f2c-aa8f-4f5d828078cb', @@ -2062,7 +2070,7 @@ describe('econ endpoints', () => { { ...body, RoomId: 'nope' }, { ...body, Name: ' ' }, ]) { - const res = await create(bad, await bearer('1')) + const res = await create(bad, await plusBearer('1')) expect(res.status).toBe(200) expect(await envOf(res)).toEqual({ Value: null, @@ -2075,7 +2083,7 @@ describe('econ endpoints', () => { }) 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) // 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 () => { - 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! const update = async (fields: Record, headers?: Record) => @@ -2153,7 +2161,7 @@ describe('econ endpoints', () => { { CurrencyId: made.CurrencyId, Name: ' ' }, ] 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(await envOf(res)).toEqual({ Value: null, @@ -2177,7 +2185,7 @@ describe('econ endpoints', () => { Shape: '9', Color: '5', }, - await bearer('1') + await plusBearer('1') ) expect(res.status).toBe(200) 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. const masked = await update( { CurrencyId: made.CurrencyId, Name: 'shit bucks' }, - await bearer('1') + await plusBearer('1') ) expect((await envOf(masked)).Value?.Name).toBe('**** bucks') }) 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! const now = Math.floor(Date.now() / 1000) @@ -2238,7 +2246,7 @@ describe('econ endpoints', () => { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded', - ...(await bearer('1')), + ...(await plusBearer('1')), }, 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 () => { 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! type AwardResult = { @@ -2314,7 +2322,7 @@ describe('econ endpoints', () => { ]) // 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) const results = await resultsOf(res) expect(results).toHaveLength(1) @@ -2362,7 +2370,7 @@ describe('econ endpoints', () => { { CurrencyId: coin.CurrencyId, RecipientId: 'nope', Amount: 1 }, { CurrencyId: coin.CurrencyId, RecipientId: 205, Amount: 1 }, ], - await bearer('1') + await plusBearer('1') ) const mixedResults = await resultsOf(mixed) 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. 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(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. const balance = async (query: string, headers?: Record) => @@ -2399,14 +2407,16 @@ describe('econ endpoints', () => { `currencyId=${coin.CurrencyId}&accountId=207`, '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 [ `currencyId=${coin.CurrencyId}`, 'accountId=205', `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. // It once did, which left every player of a Limit-3 currency stuck at 3 coins. 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! const award = async (currencyId: string, recipientId: number, amount: number) => ( @@ -2424,7 +2434,7 @@ describe('econ endpoints', () => { method: 'POST', headers: { 'Content-Type': 'application/json', - ...(await bearer('1')), + ...(await plusBearer('1')), }, body: JSON.stringify([ { 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. 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! expect(await landed(unlimited.CurrencyId, 212, 50_000)).toEqual({ Balance: 50_000, @@ -2479,13 +2489,14 @@ describe('econ endpoints', () => { ) ).json()) as Group[] - const shopped = (await envOf(await create({ ...body, Name: 'Shopped' }, await bearer('1')))) - .Value! + const shopped = ( + await envOf(await create({ ...body, Name: 'Shopped' }, await plusBearer('1'))) + ).Value! const alsoShopped = ( - await envOf(await create({ ...body, Name: 'AlsoShopped' }, await bearer('1'))) + await envOf(await create({ ...body, Name: 'AlsoShopped' }, await plusBearer('1'))) ).Value! const unshopped = ( - await envOf(await create({ ...body, Name: 'Unshopped' }, await bearer('1'))) + await envOf(await create({ ...body, Name: 'Unshopped' }, await plusBearer('1'))) ).Value! // Seeded directly, deliberately out of `Order`, to prove the read sorts them. @@ -2622,7 +2633,7 @@ describe('econ endpoints', () => { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded', - ...(sub === null ? {} : await bearer(sub)), + ...(sub === null ? {} : await plusBearer(sub)), }, 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. - const shopped = (await envOf(await create({ ...body, Name: 'Shopped' }, await bearer('1')))) - .Value! - const other = (await envOf(await create({ ...body, Name: 'Other' }, await bearer('1')))) + const shopped = ( + await envOf(await create({ ...body, Name: 'Shopped' }, await plusBearer('1'))) + ).Value! + const other = (await envOf(await create({ ...body, Name: 'Other' }, await plusBearer('1')))) .Value! const first = await addOffer(shopped.CurrencyId, 'First', 0) 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 // 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) 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 () => { - 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) const created = (await envOf(res)).Value! // 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. const res = await create( { RoomId: '2511', Name: 'Uncoloured', Limit: '1000000', Color: '-1' }, - await bearer('1') + await plusBearer('1') ) expect(res.status).toBe(200) expect((await envOf(res)).Value).toMatchObject({ Limit: 1000000, Color: -1 }) @@ -2798,7 +2810,7 @@ describe('econ endpoints', () => { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded', - ...(sub === null ? {} : await bearer(sub)), + ...(sub === null ? {} : await plusBearer(sub)), }, 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. - 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! - const sold = (await envOf(await create({ ...body, Name: 'Sold' }, await bearer('1')))).Value! await addOffer(other.CurrencyId, 444, 1) const offerId = await addOffer(sold.CurrencyId, 444, 555) const freeOfferId = await addOffer(sold.CurrencyId, 5, 0) @@ -3049,7 +3062,7 @@ describe('econ endpoints', () => { { ...body, RoomId: 'nope' }, { ...body, Name: ' ' }, ]) { - const res = await create(bad, await bearer('1')) + const res = await create(bad, await plusBearer('1')) expect(res.status).toBe(200) 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 () => { - const res = await create(body, await bearer('1')) + const res = await create(body, await plusBearer('1')) expect(res.status).toBe(200) const envelope = await envOf(res) expect(Object.keys(envelope)).toEqual(['Status', 'RoomKey']) @@ -3101,7 +3114,7 @@ describe('econ endpoints', () => { expect(await keysOf(2511)).toEqual([created]) // 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((await keysOf(2511)).map((k) => k.Name)).toEqual(['my key', 'second']) }) @@ -3122,7 +3135,7 @@ describe('econ endpoints', () => { await putInRoom(321, 1) 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() expect(frames.map((f) => f.accountId).sort((a, b) => a - b)).toEqual([1, 320]) 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 () => { - 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) expect(created.Name).toBe('**** key') expect(created).toMatchObject({ Description: '', Price: 0, Type: 0 }) @@ -3146,7 +3159,7 @@ describe('econ endpoints', () => { .first<{ key_type: string }>() expect(row?.key_type).toBe('Key') // 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) }) @@ -3164,7 +3177,7 @@ describe('econ endpoints', () => { ).all<{ room_key_id: number; account_id: number; awarded_at: string }>() ).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 // Auth-gated. @@ -3219,7 +3232,7 @@ describe('econ endpoints', () => { 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 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 () => { // MaxKeysPerRoom is 10 (apps/api/static/api-config-v2.json). Fill up to it… 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) } // …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(await envOf(res)).toEqual({ Status: 1, RoomKey: null }) expect((await keysOf(2511)).length).toBe(10) @@ -3268,9 +3281,9 @@ describe('econ endpoints', () => { }) 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) - 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 }) await env.DB.prepare('DELETE FROM room_key').run() }) @@ -3296,8 +3309,8 @@ describe('econ endpoints', () => { ).json()) as { Value: { CurrencyId: string } } ).Value.CurrencyId - const key = await keyOf(await create(body, await bearer('1'))) - const other = await keyOf(await create({ ...body, Name: 'other' }, await bearer('1'))) + const key = await keyOf(await create(body, await plusBearer('1'))) + const other = await keyOf(await create({ ...body, Name: 'other' }, await plusBearer('1'))) const id = String(key.RoomKeyId) // The body exactly as the client puts it. const edit = { @@ -3313,29 +3326,29 @@ describe('econ endpoints', () => { expect((await update(edit, await bearer('42'))).status).toBe(403) // 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(await envOf(res)).toEqual({ Status: 0, 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 ) // In the room's own currency the cap is a billion. const currencyId = await mint('2511', '1') 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, PurchaseCurrencyId: currencyId, }) expect( - await envOf(await update({ ...rich, Price: '1000000001' }, await bearer('1'))) + await envOf(await update({ ...rich, Price: '1000000001' }, await plusBearer('1'))) ).toEqual(refused) // Back to tokens with the big price still on it: the token cap applies again. expect( - await envOf(await update({ RoomKeyId: id, PurchaseCurrencyId: '' }, await bearer('1'))) + await envOf(await update({ RoomKeyId: id, PurchaseCurrencyId: '' }, await plusBearer('1'))) ).toEqual(refused) // Refusals: no such key, a blank name, an unknown currency. @@ -3344,7 +3357,7 @@ describe('econ endpoints', () => { { ...edit, Name: ' ' }, { ...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. @@ -3372,7 +3385,7 @@ describe('econ endpoints', () => { method: 'PUT', headers: { 'Content-Type': 'application/x-www-form-urlencoded', - ...(await bearer('1')), + ...(await plusBearer('1')), }, body: new URLSearchParams(fields), }) @@ -3390,14 +3403,14 @@ describe('econ endpoints', () => { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded', - ...(await bearer('1')), + ...(await plusBearer('1')), }, body: new URLSearchParams({ RoomId: '2511', Name: 'coin' }), }) ).json()) as { Value: { CurrencyId: string } } ).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) expect(key).toMatchObject({ PurchaseCurrencyId: null, ImageName: null }) @@ -3446,7 +3459,7 @@ describe('econ endpoints', () => { exports.default.fetch( `${ORIGIN}/api/storefronts/v1/buyRoomKey?RoomKeyId=${roomKeyId}` + (price === undefined ? '' : `&RequestedPrice=${price}`), - sub === null ? undefined : { headers: await bearer(sub) } + sub === null ? undefined : { headers: await plusBearer(sub) } ) const setTokens = (accountId: number, amount: number) => env.DB.prepare( @@ -3482,9 +3495,9 @@ describe('econ endpoints', () => { }) // 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( - 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(7301, 500) @@ -3593,7 +3606,7 @@ describe('econ endpoints', () => { method, headers: { 'Content-Type': 'application/x-www-form-urlencoded', - ...(sub === null ? {} : await bearer(sub)), + ...(sub === null ? {} : await plusBearer(sub)), }, 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. const coin = await mint('coin') 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 = ( await envOf( await form( @@ -3674,7 +3687,9 @@ describe('econ endpoints', () => { ) ).RoomKey! 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(other, 7501, 5000) 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, 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) => + 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 () => { const res = await exports.default.fetch( `${ORIGIN}/api/roomcurrencies/v1/getAllBalances?roomId=1` @@ -3802,6 +3925,7 @@ describe('econ endpoints', () => { Name: 'VIP Key', Description: '', Price: 250, + PurchaseCurrencyId: null, }) const consumable = await upsertRoomConsumable(env.DB, null, { RoomId: 93,