mirror of
https://github.com/djdevin/recflare.git
synced 2026-10-28 16:38:51 -07:00
[plus] enforce aura/paint/emoji/selling for plus, since client removed
This commit is contained in:
@@ -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 })
|
||||||
|
|||||||
@@ -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' }),
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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'),
|
||||||
|
|||||||
@@ -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
@@ -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()
|
||||||
|
|||||||
@@ -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'),
|
||||||
})
|
})
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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>()
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user