mirror of
https://github.com/djdevin/recflare.git
synced 2026-09-08 14:41:28 -07:00
[plus] discord role verifier to grant RR plus
This commit is contained in:
+48
-42
@@ -17,7 +17,7 @@ import {
|
||||
setOutfit,
|
||||
} from '@repo/domain'
|
||||
import { intVar, logger, withCleanSpec, withNotFound, withOnError } from '@repo/hono-helpers'
|
||||
import { validateAndGetAccountId, validateAndGetRoles, validateAndGetVersion } from '@repo/jwt'
|
||||
import { validateAndGetAccountId, validateAndGetPlus, validateAndGetVersion } from '@repo/jwt'
|
||||
|
||||
import {
|
||||
getCustomAvatarItems,
|
||||
@@ -167,16 +167,6 @@ async function authedId(c: Context<App>): Promise<number | null> {
|
||||
return validateAndGetAccountId(c.req.raw, await c.env.JWT_SECRET.get())
|
||||
}
|
||||
|
||||
/**
|
||||
* The `role` claim from a Bearer token — the operator-granted roles the auth worker stamps
|
||||
* from the account's flags, so a plain player's token is just `['gameClient']`. `null` when
|
||||
* the request carries no valid token; an empty array means a valid token with no roles.
|
||||
* Shaped to mirror {@link authedId}.
|
||||
*/
|
||||
async function authedRoles(c: Context<App>): Promise<string[] | null> {
|
||||
return validateAndGetRoles(c.req.raw, await c.env.JWT_SECRET.get())
|
||||
}
|
||||
|
||||
/**
|
||||
* The client build this request's token was minted for (`rn.ver`), as a comparable NUMBER —
|
||||
* the leading `YYYYMMDD` of e.g. `20250718.01`, whose `.01` is a same-day rebuild and not a
|
||||
@@ -415,20 +405,32 @@ async function pushBalancePurchase(
|
||||
*/
|
||||
const NOT_AN_INFLUENCER = 0
|
||||
|
||||
/** The operator-granted role that comes with a complimentary subscription. */
|
||||
const DEVELOPER_ROLE = 'developer'
|
||||
|
||||
/**
|
||||
* Whether the caller currently holds a Rec Room Plus subscription — the ONE definition,
|
||||
* shared by `UpdateAndGetSubscription` (which reports it) and the storefront buys (which
|
||||
* price off it via `SubscriberPrices`). Nothing sells subscriptions here, so holding the
|
||||
* `developer` role IS the subscription; if a real subscription store ever lands, this is
|
||||
* the only place that has to learn about it. Read from the token's `role` claim, never the
|
||||
* body; no or an invalid token is "not subscribed".
|
||||
* Whether the caller holds a Rec Room Plus subscription — the ONE definition, shared by
|
||||
* `UpdateAndGetSubscription` (which reports it) and the storefront buys (which price off
|
||||
* it via `SubscriberPrices`). Those two must never disagree: a subscriber whose client
|
||||
* applied the discount itself and then had the buy refused as a price mismatch is exactly
|
||||
* what one definition prevents.
|
||||
*
|
||||
* Nothing SELLS subscriptions here. Plus is `account.hasPlus`, claimed on the website by
|
||||
* proving a qualifying role in the community Discord (`www` `POST /api/benefits/claim`),
|
||||
* and it reaches this worker as the token's `rn.plus` claim — stamped by `auth` at login
|
||||
* from that flag. So this is a pure token read: no database, no binding, nothing to load.
|
||||
*
|
||||
* The cost is FRESHNESS, deliberately accepted. The claim is only as current as the token,
|
||||
* which lasts a day and is never refreshed (see TOKEN_TTL_SECONDS), so a player who claims
|
||||
* on the website has to sign in again — and restart the game — before Plus applies. The
|
||||
* website's claim page says so.
|
||||
*
|
||||
* The `developer` role does NOT grant Plus. It used to, as a stand-in while nothing else
|
||||
* could confer it; now that the Discord claim exists, Plus is one thing with one source.
|
||||
* An operator who wants a developer to have it sets `hasPlus` on their account like
|
||||
* anyone else's.
|
||||
*
|
||||
* Never read from the body. No token, or an invalid one, is "not subscribed".
|
||||
*/
|
||||
async function isSubscriber(c: Context<App>): Promise<boolean> {
|
||||
const roles = await authedRoles(c)
|
||||
return roles?.includes(DEVELOPER_ROLE) ?? false
|
||||
return validateAndGetPlus(c.req.raw, await c.env.JWT_SECRET.get())
|
||||
}
|
||||
|
||||
/** `SubscriptionLevel.Gold`. 1 is Platinum. */
|
||||
@@ -448,20 +450,21 @@ const SUBSCRIPTION_PLATFORM_ALL = -1
|
||||
const STUB_SUBSCRIPTION_ID = 1
|
||||
|
||||
/**
|
||||
* The complimentary subscription a `developer` account reports — Rec Room Plus, which the
|
||||
* client's API calls a `CampusCard`.
|
||||
* The complimentary subscription a subscriber reports — Rec Room Plus, which the client's
|
||||
* API calls a `CampusCard`. See `isSubscriber` for who counts as one: a `developer`, or a
|
||||
* player who claimed `hasPlus` with a Discord role on the website.
|
||||
*
|
||||
* Nothing here sells subscriptions, so holding the role IS the subscription: it's how the
|
||||
* paid-tier surfaces get exercised without a store. Every field is computed per call and
|
||||
* none of it is persisted, so this is not a record of anything — revoking the role revokes
|
||||
* the subscription, and no expiry sweep or renewal exists.
|
||||
* Nothing here sells subscriptions, so holding one of those IS the subscription. Every
|
||||
* field is computed per call and none of it is persisted, so this is not a record of
|
||||
* anything — dropping the role or the flag drops the subscription, and no expiry sweep or
|
||||
* renewal exists.
|
||||
*
|
||||
* `ExpirationDate` is a year out from THIS call rather than a fixed date: a hard-coded one
|
||||
* lapses on a day nobody is expecting, and the client would start showing an expired
|
||||
* subscription with no way to renew it. `IsAutoRenewing` tells the client the same thing.
|
||||
* The dates are milliseconds-precision ISO like the rest of this worker's timestamps.
|
||||
*/
|
||||
function developerSubscription(accountId: number) {
|
||||
function plusSubscription(accountId: number) {
|
||||
const now = new Date()
|
||||
// Calendar arithmetic, not now + 365 days: setUTCFullYear lands on the same date next
|
||||
// year whether or not a leap day falls in between.
|
||||
@@ -3097,8 +3100,8 @@ const app = new Hono<App>({ strict: false })
|
||||
summary: 'Buy a storefront item',
|
||||
description: [
|
||||
'Looks the item up in its storefront catalog, confirms the client’s `RequestedPrice`',
|
||||
'still matches the `Prices` entry — a Rec Room Plus subscriber (the same check as',
|
||||
'`UpdateAndGetSubscription`) may pay anywhere from that down to 10% off, since their',
|
||||
'still matches the `Prices` entry — a Rec Room Plus subscriber (the same `rn.plus`',
|
||||
'check as `UpdateAndGetSubscription`) may pay anywhere from that down to 10% off, since their',
|
||||
'client applies the discount itself and not to every item — debits the buyer atomically,',
|
||||
'grants the item (into the inventory or',
|
||||
'consumable table), and returns a gift box. A `Gift` block routes the item — and its',
|
||||
@@ -3947,26 +3950,29 @@ const app = new Hono<App>({ strict: false })
|
||||
)
|
||||
|
||||
// Subscription lookup (Rec Room Plus, the client's `CampusCard`). There is no store to
|
||||
// buy one from, so the `developer` role stands in for a paid subscription: a developer
|
||||
// reports an active Gold year, everyone else reports none. Nothing is stored — see
|
||||
// `developerSubscription`.
|
||||
// buy one from: Plus is claimed on the website by proving a Discord role, and reaches
|
||||
// this worker as the token's `rn.plus` claim. A caller carrying it reports an active
|
||||
// Gold year; everyone else reports none. Nothing about the subscription itself is
|
||||
// stored, and nothing here reads the database — see `isSubscriber` and `plusSubscription`.
|
||||
//
|
||||
// Auth is OPTIONAL, and a missing or invalid token answers "no subscription" rather than
|
||||
// 401: the client posts this while loading, so an error here can stall its load
|
||||
// orchestration, and "you aren't subscribed" is the truthful answer for an anonymous
|
||||
// caller anyway. The role is read from the token's `role` claim, never from the body.
|
||||
// caller anyway. Never read from the body.
|
||||
.post(
|
||||
'/api/CampusCard/v1/UpdateAndGetSubscription',
|
||||
describeRoute({
|
||||
tags: ['Econ'],
|
||||
summary: 'Subscription lookup',
|
||||
description: [
|
||||
'The caller’s Rec Room Plus subscription. Nothing sells subscriptions here, so the',
|
||||
'operator-granted `developer` role stands in for one: a developer’s token reports an',
|
||||
'active Gold (`Level` 0) yearly (`Period` 1) subscription on `PlatformType` -1 (All),',
|
||||
'expiring a year from the call, and every other caller gets `{}`. Auth is optional —',
|
||||
'a missing or invalid token reads as “not subscribed”, not 401. Nothing is persisted:',
|
||||
'the role IS the subscription, so revoking it revokes this.',
|
||||
'The caller’s Rec Room Plus subscription. Nothing sells subscriptions here: Plus is',
|
||||
'claimed on the website by proving a qualifying role in the community Discord, and',
|
||||
'arrives as the token’s `rn.plus` claim. A token carrying it reports an active Gold',
|
||||
'(`Level` 0) yearly (`Period` 1) subscription on `PlatformType` -1 (All), expiring a',
|
||||
'year from the call; every other caller gets `{}`. The `developer` role does NOT',
|
||||
'confer it. Auth is optional — a missing or invalid token reads as “not subscribed”,',
|
||||
'not 401. The subscription itself is not persisted, and because the claim is stamped',
|
||||
'at login, a player who has just claimed must sign in again before it appears.',
|
||||
].join(' '),
|
||||
responses: {
|
||||
200: json(SubscriptionResponse, 'The subscription, or `{}` for no subscription'),
|
||||
@@ -3977,7 +3983,7 @@ const app = new Hono<App>({ strict: false })
|
||||
const id = await authedId(c)
|
||||
if (id === null) return c.json({})
|
||||
return c.json({
|
||||
Subscription: developerSubscription(id),
|
||||
Subscription: plusSubscription(id),
|
||||
PlatformAccountSubscribedPlayerId: null,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -116,8 +116,9 @@ export const CustomAvatarItemsResponse = z.object({
|
||||
|
||||
/**
|
||||
* A Rec Room Plus subscription (the client calls it a `CampusCard`). Nothing here sells one,
|
||||
* so this is the complimentary subscription a `developer` account reports — see
|
||||
* `developerSubscription` in econ.app.ts for why each field reads the way it does.
|
||||
* so this is the complimentary subscription reported by a caller whose token carries
|
||||
* `rn.plus` — stamped from `account.hasPlus`, which the website's Discord benefits claim
|
||||
* sets. See `plusSubscription` in econ.app.ts for why each field reads the way it does.
|
||||
*/
|
||||
export const SubscriptionDto = z.object({
|
||||
SubscriptionId: z.int().describe('Placeholder — no subscription is stored'),
|
||||
|
||||
@@ -285,12 +285,21 @@ async function bearer(
|
||||
sub = '42',
|
||||
roles?: string[],
|
||||
/** The client build to stamp as `rn.ver` — omitted, like a token minted before the claim. */
|
||||
version?: string
|
||||
version?: string,
|
||||
/**
|
||||
* Stamp `rn.plus`, as auth does for an account with `hasPlus`. This is the ONLY thing
|
||||
* that makes a caller a Rec Room Plus subscriber — the `developer` role does not — so
|
||||
* every subscriber-priced test passes it.
|
||||
*/
|
||||
plus = false
|
||||
): Promise<Record<string, string>> {
|
||||
const now = Math.floor(Date.now() / 1000)
|
||||
const claims: Record<string, unknown> = { sub, exp: now + 3600 }
|
||||
if (roles !== undefined) claims.role = roles
|
||||
if (version !== undefined) claims['rn.ver'] = version
|
||||
// Omitted when false, exactly as generateToken omits it — so these tokens match the
|
||||
// shape of a real non-subscriber's.
|
||||
if (plus) claims['rn.plus'] = true
|
||||
const signingInput = `${b64url(JSON.stringify({ alg: 'HS256', typ: 'JWT' }))}.${b64url(
|
||||
JSON.stringify(claims)
|
||||
)}`
|
||||
@@ -1770,7 +1779,7 @@ describe('econ endpoints', () => {
|
||||
|
||||
test('POST /api/storefronts/v2/buyItem charges a subscriber the SubscriberPrices entry', async () => {
|
||||
await drainFrames()
|
||||
const res = await buy2263(await bearer('322', ['gameClient', 'developer']), 85)
|
||||
const res = await buy2263(await bearer('322', ['gameClient'], undefined, true), 85)
|
||||
expect(res.status).toBe(200)
|
||||
expect(((await res.json()) as { Balance: number }).Balance).toBe(-85)
|
||||
const bal = await exports.default.fetch(`${ORIGIN}/api/storefronts/v4/balance/2`, {
|
||||
@@ -1785,7 +1794,7 @@ describe('econ endpoints', () => {
|
||||
const res = await exports.default.fetch(`${ORIGIN}/api/storefronts/v2/buyItem`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
...(await bearer('325', ['gameClient', 'developer'])),
|
||||
...(await bearer('325', ['gameClient'], undefined, true)),
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
@@ -1807,7 +1816,7 @@ describe('econ endpoints', () => {
|
||||
const res = await exports.default.fetch(`${ORIGIN}/api/storefronts/v2/buyItem`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
...(await bearer('326', ['gameClient', 'developer'])),
|
||||
...(await bearer('326', ['gameClient'], undefined, true)),
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
@@ -1828,10 +1837,10 @@ describe('econ endpoints', () => {
|
||||
})
|
||||
|
||||
test('POST /api/storefronts/v2/buyItem 409s a subscriber below the discount band', async () => {
|
||||
const res = await buy2263(await bearer('323', ['gameClient', 'developer']), 84)
|
||||
const res = await buy2263(await bearer('323', ['gameClient'], undefined, true), 84)
|
||||
expect(res.status).toBe(409)
|
||||
// …and above it: a made-up price is a mismatch in either direction.
|
||||
const over = await buy2263(await bearer('323', ['gameClient', 'developer']), 96)
|
||||
const over = await buy2263(await bearer('323', ['gameClient'], undefined, true), 96)
|
||||
expect(over.status).toBe(409)
|
||||
})
|
||||
|
||||
@@ -3593,7 +3602,9 @@ describe('econ endpoints', () => {
|
||||
"DELETE FROM reward_status WHERE account_id = 83 AND gift_context = 'Dodgeball'"
|
||||
).run()
|
||||
expect((await request('rewardType=PostGameActivity&giftContext=Dodgeball')).status).toBe(200)
|
||||
const latest = (await giftBoxes('83')).findLast((b) => b.GiftContext === 8000 || b.GiftContext === 50)
|
||||
const latest = (await giftBoxes('83')).findLast(
|
||||
(b) => b.GiftContext === 8000 || b.GiftContext === 50
|
||||
)
|
||||
if (i < 3) handed.push(latest?.AvatarItemDesc as string)
|
||||
else expect(latest).toMatchObject({ AvatarItemDesc: '', GiftContext: 50 })
|
||||
}
|
||||
@@ -3745,7 +3756,7 @@ describe('econ endpoints', () => {
|
||||
})
|
||||
|
||||
test('POST /api/CampusCard/v1/UpdateAndGetSubscription gives a developer a Gold year', async () => {
|
||||
const res = await getSubscription(await bearer('205', ['gameClient', 'developer']))
|
||||
const res = await getSubscription(await bearer('205', ['gameClient'], undefined, true))
|
||||
expect(res.status).toBe(200)
|
||||
const body = (await res.json()) as {
|
||||
Subscription: Record<string, unknown>
|
||||
@@ -3777,7 +3788,7 @@ describe('econ endpoints', () => {
|
||||
})
|
||||
|
||||
test('POST /api/CampusCard/v1/UpdateAndGetSubscription is {} without the developer role', async () => {
|
||||
// A plain player's token: valid, but no elevated role.
|
||||
// A plain player's token: valid, no elevated role, and no `hasPlus` on the account.
|
||||
expect(await (await getSubscription(await bearer('206', ['gameClient']))).json()).toEqual({})
|
||||
// A token with no `role` claim at all.
|
||||
expect(await (await getSubscription(await bearer('206'))).json()).toEqual({})
|
||||
@@ -3787,6 +3798,56 @@ describe('econ endpoints', () => {
|
||||
expect(await anon.json()).toEqual({})
|
||||
})
|
||||
|
||||
// Plus reaches this worker as the token's `rn.plus` claim, which `auth` stamps from
|
||||
// `account.hasPlus` at login. Nothing here reads the account, so this is the whole
|
||||
// mechanism — and the reason a player who claims on the website has to sign in again.
|
||||
//
|
||||
// The token carries only `gameClient`, exactly as a game client's does.
|
||||
test('POST /api/CampusCard/v1/UpdateAndGetSubscription honours the rn.plus claim', async () => {
|
||||
const res = await getSubscription(await bearer('9208', ['gameClient'], undefined, true))
|
||||
expect(res.status).toBe(200)
|
||||
const body = (await res.json()) as { Subscription: Record<string, unknown> }
|
||||
expect(body.Subscription).toMatchObject({
|
||||
SubscriptionId: 1,
|
||||
RecNetPlayerId: 9208,
|
||||
PlatformType: -1,
|
||||
Level: 0,
|
||||
Period: 1,
|
||||
IsAutoRenewing: true,
|
||||
})
|
||||
})
|
||||
|
||||
// The `developer` role used to BE the subscription, as a stand-in while nothing else
|
||||
// could confer one. Now that Plus has a real source it is one thing with one source, and
|
||||
// an elevated account is not a subscriber unless it also holds `rn.plus`. Pinned because
|
||||
// nothing else would fail if the old shortcut came back: it would silently hand Plus (and
|
||||
// the 10% discount) to every operator account.
|
||||
test('the developer role alone is not a Rec Room Plus subscription', async () => {
|
||||
const dev = await getSubscription(await bearer('9210', ['gameClient', 'developer']))
|
||||
expect(dev.status).toBe(200)
|
||||
expect(await dev.json()).toEqual({})
|
||||
|
||||
// …and it buys nothing at the subscriber price either, so the report and the buy path
|
||||
// agree. 85 is the SubscriberPrices entry for sf300's 2263; 95 is the list price.
|
||||
const discounted = await buy2263(await bearer('9211', ['gameClient', 'developer']), 85)
|
||||
expect(discounted.status).toBe(409)
|
||||
})
|
||||
|
||||
// Plus is priced, not just displayed: the same claim gates the subscriber discount band
|
||||
// on a buy. A subscriber whose client applied the discount itself and then had the
|
||||
// purchase refused as a price mismatch is exactly what one definition prevents, so the
|
||||
// CampusCard report and the buy must never disagree.
|
||||
test('an rn.plus token is charged the subscriber price', async () => {
|
||||
const res = await buy2263(await bearer('9326', ['gameClient'], undefined, true), 85)
|
||||
expect(res.status).toBe(200)
|
||||
expect(((await res.json()) as { Balance: number }).Balance).toBe(-85)
|
||||
|
||||
// The same request without the claim is refused, so the discount really comes from
|
||||
// `rn.plus` and not from the band being open to everyone.
|
||||
const plain = await buy2263(await bearer('9327', ['gameClient']), 85)
|
||||
expect(plain.status).toBe(409)
|
||||
})
|
||||
|
||||
test('unknown path returns 404', async () => {
|
||||
const res = await exports.default.fetch(`${ORIGIN}/nope`)
|
||||
expect(res.status).toBe(404)
|
||||
|
||||
Reference in New Issue
Block a user