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

This commit is contained in:
Devin Zuczek
2026-10-28 10:54:17 -04:00
parent 1b10c14801
commit 5b9520e4dd
10 changed files with 691 additions and 134 deletions
+14 -2
View File
@@ -27,7 +27,7 @@ import {
withNotFound,
withOnError,
} from '@repo/hono-helpers'
import { validateAndGetAccountId } from '@repo/jwt'
import { validateAndGetAccountId, validateAndGetPlus } from '@repo/jwt'
// The notification-type ids the hub carries (owned by the `notify` worker). Imported as a
// value — the enum has no runtime dependencies.
@@ -1071,6 +1071,11 @@ const app = new Hono<App>()
//
// Broadcast like every other public-DTO mutation here — the emoji rides along in the
// AccountUpdate payload, so it redraws beside the name without a refetch.
//
// SETTING an emoji is a Rec Room Plus perk: the token's `rn.plus` claim, the same one
// econ's `isSubscriber` reads (Plus has one source — see `validateAndGetPlus`). Anyone
// may CLEAR theirs, so a player who loses Plus can still take an old pick down. An emoji
// already stored is left alone when Plus lapses; this only gates the write.
.put(
'/account/me/emoji',
describeRoute({
@@ -1079,7 +1084,8 @@ const app = new Hono<App>()
description: [
'Persists the emoji shown beside the display name and broadcasts it in the',
'AccountUpdate payload. The value must be one the whitelist serves; an empty value',
'clears the pick.',
'clears the pick. Setting one requires Rec Room Plus (the token’s `rn.plus` claim);',
'clearing does not.',
].join(' '),
security: AUTHED,
requestBody: form(EmojiRequest, 'A whitelisted emoji, or "" to clear'),
@@ -1087,6 +1093,7 @@ const app = new Hono<App>()
200: json(SuccessResponse, 'Updated'),
400: { description: 'Not a whitelisted emoji (empty body)' },
401: UNAUTHORIZED_RESPONSE,
403: { description: 'Setting an emoji without Rec Room Plus (empty body)' },
},
}),
async (c) => {
@@ -1096,6 +1103,11 @@ const app = new Hono<App>()
// An empty value clears the pick; anything else has to be on the list.
const displayEmoji = submitted === '' ? '' : resolveWhitelistedEmoji(submitted)
if (displayEmoji === null) return c.body(null, 400)
if (
displayEmoji !== '' &&
!(await validateAndGetPlus(c.req.raw, await c.env.JWT_SECRET.get()))
)
return c.body(null, 403)
const account = await updateAccount(c.env.DB, id, { displayEmoji })
await pushAccountUpdate(c, account)
return c.json({ success: true })
+60 -7
View File
@@ -50,10 +50,13 @@ function b64url(input: ArrayBuffer | string): string {
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 signingInput = `${b64url(JSON.stringify({ alg: 'HS256', typ: 'JWT' }))}.${b64url(
JSON.stringify({ sub, exp: now + 3600 })
JSON.stringify({ sub, exp: now + 3600, ...claims })
)}`
const key = await crypto.subtle.importKey(
'raw',
@@ -66,6 +69,9 @@ async function bearer(sub = '42'): Promise<Record<string, string>> {
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 => ({
method: 'PUT',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
@@ -482,7 +488,10 @@ describe('auth-gated endpoints', () => {
// Exactly the body the client sends: one urlencoded field (`%F0%9F%A4%AA`).
const res = await exports.default.fetch(`${ORIGIN}/account/me/emoji`, {
method: 'PUT',
headers: { ...(await bearer('779')), 'Content-Type': 'application/x-www-form-urlencoded' },
headers: {
...(await bearer('779', PLUS)),
'Content-Type': 'application/x-www-form-urlencoded',
},
body: 'displayEmoji=%F0%9F%A4%AA',
})
expect(res.status).toBe(200)
@@ -509,7 +518,10 @@ describe('auth-gated endpoints', () => {
test('PUT /account/me/emoji canonicalizes a pick sent without its variation selector', async () => {
const res = await exports.default.fetch(`${ORIGIN}/account/me/emoji`, {
...form({ displayEmoji: '\u{2764}' }),
headers: { ...(await bearer('780')), 'Content-Type': 'application/x-www-form-urlencoded' },
headers: {
...(await bearer('780', PLUS)),
'Content-Type': 'application/x-www-form-urlencoded',
},
})
expect(res.status).toBe(200)
@@ -524,13 +536,19 @@ describe('auth-gated endpoints', () => {
test('PUT /account/me/emoji clears the pick on an empty value', async () => {
const set = await exports.default.fetch(`${ORIGIN}/account/me/emoji`, {
...form({ displayEmoji: '\u{1F389}' }),
headers: { ...(await bearer('781')), 'Content-Type': 'application/x-www-form-urlencoded' },
headers: {
...(await bearer('781', PLUS)),
'Content-Type': 'application/x-www-form-urlencoded',
},
})
expect(set.status).toBe(200)
const cleared = await exports.default.fetch(`${ORIGIN}/account/me/emoji`, {
...form({ displayEmoji: '' }),
headers: { ...(await bearer('781')), 'Content-Type': 'application/x-www-form-urlencoded' },
headers: {
...(await bearer('781', PLUS)),
'Content-Type': 'application/x-www-form-urlencoded',
},
})
expect(cleared.status).toBe(200)
@@ -549,7 +567,10 @@ describe('auth-gated endpoints', () => {
for (const displayEmoji of ['not an emoji', '\u{1F92A}\u{1F92A}', '\u{1F595}\u{1F3FB}']) {
const res = await exports.default.fetch(`${ORIGIN}/account/me/emoji`, {
...form({ displayEmoji }),
headers: { ...(await bearer('782')), 'Content-Type': 'application/x-www-form-urlencoded' },
headers: {
...(await bearer('782', PLUS)),
'Content-Type': 'application/x-www-form-urlencoded',
},
})
expect(res.status).toBe(400)
}
@@ -559,6 +580,38 @@ describe('auth-gated endpoints', () => {
expect(((await me.json()) as { displayEmoji: string }).displayEmoji).toBe('')
})
// Setting an emoji is a Rec Room Plus perk; clearing one is not, so a player whose Plus
// lapsed can still take an old pick down.
test('PUT /account/me/emoji 403s a set without Rec Room Plus but allows a clear', async () => {
const headers = {
...(await bearer('783')),
'Content-Type': 'application/x-www-form-urlencoded',
}
const set = await exports.default.fetch(`${ORIGIN}/account/me/emoji`, {
...form({ displayEmoji: '\u{1F92A}' }),
headers,
})
expect(set.status).toBe(403)
const notPlus = await exports.default.fetch(`${ORIGIN}/account/me/emoji`, {
...form({ displayEmoji: '\u{1F92A}' }),
headers: {
...(await bearer('783', { 'rn.plus': 'true' })),
'Content-Type': 'application/x-www-form-urlencoded',
},
})
expect(notPlus.status).toBe(403)
const me = await exports.default.fetch(`${ORIGIN}/account/me`, { headers: await bearer('783') })
expect(((await me.json()) as { displayEmoji: string }).displayEmoji).toBe('')
const clear = await exports.default.fetch(`${ORIGIN}/account/me/emoji`, {
...form({ displayEmoji: '' }),
headers,
})
expect(clear.status).toBe(200)
})
test('PUT /account/me/bannerimage 401s without a token, 400s without an imageName', async () => {
const anon = await exports.default.fetch(`${ORIGIN}/account/me/bannerimage`, {
...form({ imageName: 'x.jpg' }),