diff --git a/apps/www/src/client/App.tsx b/apps/www/src/client/App.tsx
index 9df26bd..521d9f8 100644
--- a/apps/www/src/client/App.tsx
+++ b/apps/www/src/client/App.tsx
@@ -1291,6 +1291,7 @@ function StaffPlayerActions({ account, navigate }: { account: PublicAccount; nav
const itemGift = useAction()
const usernameChange = useAction()
const clearPassword = useAction()
+ const grantPlus = useAction()
const base = `/api/staff/players/${account.accountId}`
return (
@@ -1458,6 +1459,26 @@ function StaffPlayerActions({ account, navigate }: { account: PublicAccount; nav
>
{usernameChange.pending ? 'Adding…' : 'Add username change'}
+ {/* Developer-only, like the gifts; www refuses it to moderators. */}
+ {isDeveloper() && (
+
+ )}
{confirmingClear ? (
<>
}
{clearPassword.error &&
{clearPassword.error}
}
{clearPassword.done &&
{clearPassword.done}
}
+ {grantPlus.error &&
{grantPlus.error}
}
+ {grantPlus.done &&
{grantPlus.done}
}
)
}
diff --git a/apps/www/src/staff.ts b/apps/www/src/staff.ts
index c34cbe6..da388ff 100644
--- a/apps/www/src/staff.ts
+++ b/apps/www/src/staff.ts
@@ -8,6 +8,7 @@ import {
getPlayerIdsInRoom,
getPresences,
movePlayerToDorm,
+ updateAccount,
writeAuditLog,
} from '@repo/domain'
import { PlatformType } from '@repo/domain/src/enums'
@@ -1407,3 +1408,28 @@ export async function clearPasswordHandler(c: Context) {
logger.info('staff cleared a password', { moderatorId: staffId(c), playerId, hadPassword })
return c.json({ playerId, hadPassword })
}
+
+/**
+ * Give a player Rec Room Plus by setting `hasPlus` on their account — the same flag the
+ * Discord benefits claim and `runx admin grant-plus` set, with no Discord link involved.
+ * Developer-only. `hadPlus` says whether they already had it; granting it again succeeds,
+ * changes nothing, and is still logged.
+ *
+ * `auth` stamps the flag into the token's `rn.plus` claim, so the player has Plus from their
+ * NEXT sign-in or token refresh, not the moment this answers.
+ */
+export async function grantPlusHandler(c: Context) {
+ const playerId = playerIdParam(c)
+ if (playerId === null) return c.json({ error: 'A numeric player id is required' }, 400)
+
+ // Read first: `updateAccount` creates the row when there is none.
+ const account = await getAccount(c.env.DB, playerId)
+ if (account === null) return c.json({ error: 'No such player' }, 404)
+
+ const hadPlus = account.hasPlus === true
+ if (!hadPlus) await updateAccount(c.env.DB, playerId, { hasPlus: true })
+
+ await recordPlayerAudit(c, 'grant_plus', { playerId, hadPlus })
+ logger.info('staff granted plus', { moderatorId: staffId(c), playerId, hadPlus })
+ return c.json({ playerId, hasPlus: true, hadPlus })
+}
diff --git a/apps/www/src/test/integration/api.test.ts b/apps/www/src/test/integration/api.test.ts
index 85ec937..2cca8b6 100644
--- a/apps/www/src/test/integration/api.test.ts
+++ b/apps/www/src/test/integration/api.test.ts
@@ -859,6 +859,7 @@ it('refuses every staff endpoint without a token, and without a staff role', asy
'/api/staff/players/1/gift-xp',
'/api/staff/players/1/username-changes',
'/api/staff/players/1/clear-password',
+ '/api/staff/players/1/grant-plus',
]
writes.push(
'/api/staff/rooms/1/gift-tokens',
@@ -874,8 +875,8 @@ it('refuses every staff endpoint without a token, and without a staff role', asy
expect(res.status).toBe(403)
}
- // The gifts are narrower: a moderator is staff, but not a developer.
- for (const path of writes.filter((p) => p.includes('/gift-'))) {
+ // The gifts and the Plus grant are narrower: a moderator is staff, but not a developer.
+ for (const path of writes.filter((p) => p.includes('/gift-') || p.includes('/grant-plus'))) {
expect((await staffPost(path, 8101, { amount: 1 })).status).toBe(403)
}
@@ -1964,6 +1965,38 @@ it('clears a player’s password so they can set a new one in game', async () =>
expect((await staffPost('/api/staff/players/8397/clear-password', 8110, {})).status).toBe(404)
})
+it('grants a player Plus, developers only', async () => {
+ await updateAccount(env.DB, 8330, { username: 'Plusless' })
+ const hasPlus = async () =>
+ await env.DB.prepare(
+ "SELECT json_extract(data, '$.hasPlus') AS plus, json_extract(data, '$.username') AS username FROM account WHERE account_id = 8330"
+ ).first<{ plus: number | null; username: string }>()
+
+ // A moderator is staff but not a developer: refused, and nothing is written.
+ expect((await staffPost('/api/staff/players/8330/grant-plus', 8101, {})).status).toBe(403)
+ expect(await hasPlus()).toEqual({ plus: null, username: 'Plusless' })
+
+ let res = await devPost('/api/staff/players/8330/grant-plus', 8110, {})
+ expect(res.status).toBe(200)
+ expect(await res.json()).toEqual({ playerId: 8330, hasPlus: true, hadPlus: false })
+ // Only the flag is set: the rest of the account is untouched.
+ expect(await hasPlus()).toEqual({ plus: 1, username: 'Plusless' })
+
+ // Granting it again is harmless, and says they already had it.
+ res = await devPost('/api/staff/players/8330/grant-plus', 8110, {})
+ expect(await res.json()).toEqual({ playerId: 8330, hasPlus: true, hadPlus: true })
+
+ expect(await auditRows('grant_plus', 8330)).toEqual([
+ { actor: 8110, data: { playerId: 8330, hadPlus: false } },
+ { actor: 8110, data: { playerId: 8330, hadPlus: true } },
+ ])
+ // An unknown player is a 404, and no account is conjured for them.
+ expect((await devPost('/api/staff/players/8396/grant-plus', 8110, {})).status).toBe(404)
+ expect(
+ await env.DB.prepare('SELECT 1 AS hit FROM account WHERE account_id = 8396').first()
+ ).toBeNull()
+})
+
/** A custom avatar item made by `creatorAccountId`; published unless `accessibility` says. */
const seedCustomItem = (id: string, creatorAccountId: number, accessibility = 1) =>
createCustomAvatarItem(env.DB, {
diff --git a/apps/www/src/www.app.ts b/apps/www/src/www.app.ts
index ceb59b9..508ba24 100644
--- a/apps/www/src/www.app.ts
+++ b/apps/www/src/www.app.ts
@@ -52,6 +52,7 @@ import {
giftRoomTokensHandler,
giftTokensHandler,
giftXpHandler,
+ grantPlusHandler,
linkedAccountsHandler,
playerHistoryHandler,
requireDeveloper,
@@ -560,6 +561,8 @@ const app = new Hono()
// Its sibling: the same gift to every account whose Discord link holds a role, offline
// included — the supporter cron's audience, with an operator's amount and message.
.post('/api/staff/discord-roles/:roleId/gift-tokens', requireDeveloper, giftRoleTokensHandler)
+ // Rec Room Plus is worth tokens and a discount, so granting it is developer-only too.
+ .post('/api/staff/players/:id/grant-plus', requireDeveloper, grantPlusHandler)
.post('/api/staff/players/:id/username-changes', addUsernameChangeHandler)
.post('/api/staff/players/:id/clear-password', clearPasswordHandler)