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,
|
||||
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 })
|
||||
|
||||
@@ -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' }),
|
||||
|
||||
Reference in New Issue
Block a user