import { addUsernameChange, addXp, clearPasswordHash, createGift, getAccount, getOnlinePlayerIds, getPlayerIdsInRoom, getPresences, movePlayerToDorm, updateAccount, writeAuditLog, } from '@repo/domain' import { PlatformType } from '@repo/domain/src/enums' import { intVar, logger } from '@repo/hono-helpers' import { validateAndGetAccountId, validateAndGetRoles } from '@repo/jwt' // The report table and the ban policy over it, owned (and migrated) by the `api` worker. // Imported rather than reimplemented: the SQL belongs with the table, and `banFromReport` // is the same write `api` would do. www owns the ENDPOINTS, not the storage — see the // module comment below. import { banEvasionMatch, linkedAccounts } from '../../api/src/bans-db' import { getCustomAvatarItem } from '../../api/src/custom-avatar-items-db' import { banBlockDetails, banFromReport, createReport, getActiveBan, getBansInForce, getReportById, getReportsAgainst, getTopReported, searchReports, } from '../../api/src/reports-db' import { getWarningsAgainst } from '../../api/src/warnings-db' // The platform links, owned by `auth`: a Discord link carries the roles its member held at // their claim or the last daily sweep (discord-roles.ts), which is what the role drop reads. import { getLinksForPlatform } from '../../auth/src/platform-db' // Balances, owned by `econ`. A staff token gift is the same credit econ's own faucets make. import { ALL_PLATFORMS, creditCurrency, CurrencyType, DEFAULT_STARTING_TOKENS, ensureStartingBalances, getBalance, payTokenGifts, spendCurrency, } from '../../econ/src/balance-db' // The item catalog and the two inventories a skin or a consumable gift writes, all `econ`'s. import { CatalogKind, getCatalogItem, getCatalogItemById, toCatalogSkin, } from '../../econ/src/catalog-db' import { countConsumable, grantConsumable } from '../../econ/src/consumables-db' import { grantDiscordRoleGifts } from '../../econ/src/discord-role-gift' import { getEquipment, grantEquipment } from '../../econ/src/equipment-db' import { grantCustomAvatarItem, ownedCustomAvatarItemIds } from '../../econ/src/inventory-custom-db' // The notification ids and the kick frame's recovered shape, owned by `notify`. Both are // imported as values/types with no runtime dependencies. import { NotificationType } from '../../notify/src/notification-types' // The sweep itself, so a developer can run it on demand instead of waiting for the cron. import { refreshDiscordRoles } from './discord-roles' import type { Context, MiddlewareHandler } from 'hono' import type { GiftContent } from '@repo/domain' import type { CustomAvatarItem } from '../../api/src/custom-avatar-items-db' import type { ReportRow, ReportSearch } from '../../api/src/reports-db' import type { CatalogRow } from '../../econ/src/catalog-db' import type { BalanceResponsePayload, GiftPackagePayload, ModerationKickPayload, PlayerProgressionLevelPayload, } from '../../notify/src/notification-payloads' import type { PlayerNotification } from '../../notify/src/notifications-hub' import type { App, Env } from './context' /** * The staff moderation surface — the endpoints behind the `/moderation` panel in the SPA. * * These live on `www` rather than on `api` (which owns the `report` table) on purpose: * every other worker reimplements an endpoint the Rec Room client actually calls, and a * staff panel has no counterpart in the real service. Keeping recflare's own additions * here leaves the game-facing workers a faithful surface, with nothing in them the client * never asked for. So the SQL stays in `api`'s reports-db/bans-db beside the table, and * only the HTTP lives here. * * Where a real game service already answers the question, the SPA asks IT rather than * having www proxy: usernames for the ids in a report come from `accounts`' * `POST /account/bulk`, the same lookup the game does. This module serves only what no * existing endpoint does. * * Every route is gated by {@link requireStaff} — a valid token carrying `moderator` or * `developer`, the operator-granted roles `auth` stamps from an account's * isModerator/isDeveloper flags (see the admin CLI's `grant-moderator` / * `grant-developer`). The SPA's `isAdmin()` gate only decides what to SHOW; this is the * one that decides anything. The gifts are narrower still — developers only, via * {@link requireDeveloper}. */ /** * Roles allowed through. The same set `api`'s warning write and `notify`'s internal * endpoints gate on — staff hold both roles, and moderation is not developer-only. */ const STAFF_ROLES = new Set(['moderator', 'developer']) /** The notifications hub is a single global DO instance (see the `notify` worker). */ const HUB_INSTANCE = 'global' /** Hard cap on a page of search results, whatever the query string asks for. */ const MAX_TAKE = 200 /** * Gates a route on a staff token: 401 for a missing or invalid one, 403 for a valid token * without a staff role. The acting moderator is stashed on the context so a handler reads * it without validating the token a second time. * * Two answers, not one, because they mean different things to the page: a 401 is a session * that has expired (the SPA's `call` drops the token and sends them to sign in), while a * 403 is a signed-in player who is not staff — and the panel says so rather than looping * them through a sign-in that would change nothing. */ export const requireStaff: MiddlewareHandler = async (c, next) => { const secret = await c.env.JWT_SECRET.get() const roles = await validateAndGetRoles(c.req.raw, secret) if (roles === null) return c.json({ error: 'Unauthorized' }, 401) if (!roles.some((role) => STAFF_ROLES.has(role))) return c.json({ error: 'Forbidden' }, 403) const accountId = await validateAndGetAccountId(c.req.raw, secret) // A token that carries a staff role but no integer `sub` can't be attributed to a // moderator, and every action here is recorded against one. if (accountId === null) return c.json({ error: 'Unauthorized' }, 401) c.set('staffId', accountId) c.set('staffRoles', roles) await next() } /** * Narrows a staff route to developers — the ones that mint currency, items or XP out of * nothing, which moderation has no need for. Runs AFTER {@link requireStaff} (it reads the * roles that gate stashed), so a missing or invalid token is still that gate's 401 and a * moderator without `developer` gets a 403. */ export const requireDeveloper: MiddlewareHandler = async (c, next) => { if (!c.get('staffRoles').includes('developer')) return c.json({ error: 'Forbidden' }, 403) await next() } /** The acting moderator, set by {@link requireStaff}. Only valid behind that middleware. */ const staffId = (c: Context): number => c.get('staffId') /** Read an integer query param, or null when absent or unparseable. */ function intQuery(c: Context, name: string): number | null { const raw = c.req.query(name) if (raw === undefined || raw === '') return null const n = Number.parseInt(raw, 10) return Number.isNaN(n) ? null : n } /** * Read a tri-state boolean query param: `true`/`false` narrow, absent means "don't * filter". Distinct from a plain `=== 'true'`, which would read an absent param as an * explicit `false` and hide every banned row from an unfiltered search. */ function boolQuery(c: Context, name: string): boolean | null { const raw = c.req.query(name) if (raw === undefined || raw === '') return null return raw === 'true' || raw === '1' } /** Read an ISO-8601 query param, or null when absent or not a date we can parse. */ function dateQuery(c: Context, name: string): string | null { const raw = c.req.query(name) if (raw === undefined || raw === '') return null const parsed = Date.parse(raw) return Number.isNaN(parsed) ? null : new Date(parsed).toISOString() } /** * When a ban handed down now should lift, from what the panel posted. * * Accepts a DURATION (`days` and/or `hours`) or an explicit ISO `expires`, because * moderators think in "7 days" and audit trails are in timestamps. `permanent` (or a * body with neither) means null — a ban that never lifts, which is what `ban_expires` * NULL records. * * Returns `undefined` for an expiry that was asked for but can't be honoured (an * unparseable date, or a duration that lands in the past), so the caller refuses the * request rather than quietly making the ban permanent — the one mistake here that * cannot be walked back by waiting. */ function banExpiry( body: { permanent?: unknown; expires?: unknown; days?: unknown; hours?: unknown }, now: Date ): string | null | undefined { if (body.permanent === true) return null if (typeof body.expires === 'string' && body.expires !== '') { const parsed = Date.parse(body.expires) if (Number.isNaN(parsed) || parsed <= now.getTime()) return undefined return new Date(parsed).toISOString() } const days = Number(body.days ?? 0) const hours = Number(body.hours ?? 0) if (!Number.isFinite(days) || !Number.isFinite(hours)) return undefined const ms = days * 86_400_000 + hours * 3_600_000 // No duration at all is a permanent ban; a negative one is a mistake, not a lift. if (ms === 0) return null if (ms < 0) return undefined return new Date(now.getTime() + ms).toISOString() } /** * Tell a player they have been banned from the GAME, and throw them out of wherever they are. * * Without this a ban only takes effect on the player's NEXT sign-in or matchmake: `match` * refuses a banned player and `moderationBlockDetails` blocks them at login, but nothing * revisits a session already in progress, so someone banned mid-session keeps playing. * * The frame is a `ModerationKick` (id 22) with `IsBan: true` — the frame the client's * moderation screen shows as a ban from the game. This is the ONE place that frame belongs: a * ROOM ban must never send it (`rooms` sends `IsBan: false` and is enforced by refusing the * room), because the client cannot tell the two apart and shows a game-wide ban screen. * * Its contents are {@link banBlockDetails} — the same `ReportCategory`, `Message` and * `Duration` `moderationBlockDetails` answers for this ban — so the screen a player sees * mid-session is the screen they see when they next sign in, not a generic "banned" with no * length. `Duration` is the seconds LEFT, and this frame goes out the instant the ban is * handed down, so here that is the ban's whole span; every later sign-in gets less. * * Sent to anyone ONLINE, not only to a player standing in an instance: someone in a menu is * as banned as someone in a room. `GameSessionId` is their instance, or 0 when they are in * none. EPHEMERAL, because an offline player needs no frame — they meet the same screen at * `moderationBlockDetails` when they next sign in — and a queued one would fire again then. * * They are moved into their own DORM rather than having their presence deleted — the same move * every kick makes, and the one place a banned player is still allowed to be: `match` lets them * matchmake there and nowhere else, which is how they reach this screen at all. Deleting the row * instead left the client arriving at the dorm not knowing where it was, loading it a second * time. The instance they were in, if any, frees a slot. * * Entirely best-effort. The ban row is already committed by the time this runs; a hub hiccup * or a missing presence must not fail a ban that has been handed down. */ async function kickBannedPlayer(c: Context, report: ReportRow, moderatorId: number) { const playerId = report.reported_player_id try { const presence = (await getPresences<{ roomInstanceId?: number }>(c.env.DB, [playerId])).get( playerId ) // Offline: nothing to eject and nobody to tell. The sign-in check has them. if (!presence) return // Read before the move, so the frame names the session they were thrown out of rather // than the dorm they landed in. `movePlayerToDorm` recomputes that instance's fullness. const gameSessionId = presence.roomInstance?.roomInstanceId await movePlayerToDorm(c.env.DB, playerId) const block = banBlockDetails(report) const frame: ModerationKickPayload = { ReportCategory: block.ReportCategory, Duration: block.Duration, GameSessionId: gameSessionId ?? 0, IsHostKick: false, Message: block.Message, PlayerIdReporter: null, IsBan: true, IsVoiceModAutoban: false, IsWarning: false, VoteKickReason: '', TimeoutStartedAt: block.TimeoutStartedAt, } await c.env.RECFLARE_NOTIFICATIONS_HUB.getByName(HUB_INSTANCE).notifyPlayersEphemeral( [playerId], NotificationType.ModerationKick, { ...frame } ) } catch (err) { logger.error('failed to tell a banned player they are banned', { playerId, moderatorId, error: err instanceof Error ? err.message : String(err), }) } } /** * Record a ban or a lift on the `audit_log` table, against the moderator who made it. * * The report row only ever holds the ban's CURRENT state: a lift clears `banned_by` and * `banned_at`, and a re-ban overwrites them, so without this row nothing says who lifted a * ban, or that one was ever handed down on a report that is clean today. `ban` and `unban` * are separate actions because they are the two questions asked of this log ("who banned * X", "who let X back in"); the target and the report go in `data`, per the table's rule * that `player_id` is the actor. `previous` is the row's ban state before this call, which * is the only place a lifted ban's original moderator and expiry survive. * * Written after the row is committed and never throws — the ban has already happened, and a * failed audit insert must not turn it into a 500 the panel would read as refused. */ async function recordBanAudit( c: Context, moderatorId: number, before: ReportRow, after: ReportRow ) { const action = after.banned ? 'ban' : 'unban' try { await writeAuditLog(c.env.DB, { playerId: moderatorId, action, data: { reportId: after.id, playerId: after.reported_player_id, reportCategory: after.report_category, banExpires: after.ban_expires, previous: { banned: before.banned === 1, banExpires: before.ban_expires, bannedBy: before.banned_by_player_id, bannedAt: before.banned_at, }, }, }) } catch (err) { logger.error('could not write an audit log row', { action, moderatorId, reportId: after.id, error: err instanceof Error ? err.message : String(err), }) } } /** The evasion arms in force, read from the same knob `api` and `match` read. */ const armsFor = (env: Env) => banEvasionMatch(env.BAN_EVASION_MATCH) // ---- Handlers --------------------------------------------------------------- /** * A page of reports matching the panel's filters, newest first. * * Returns ids only — no usernames. The page resolves those itself through `accounts`' * bulk lookup, the same endpoint the game uses, so this stays one query against one table. */ export async function searchReportsHandler(c: Context) { const filters: ReportSearch = { reportedPlayerId: intQuery(c, 'reportedPlayerId'), reporterPlayerId: intQuery(c, 'reporterPlayerId'), reportCategory: intQuery(c, 'reportCategory'), banned: boolQuery(c, 'banned'), from: dateQuery(c, 'from'), to: dateQuery(c, 'to'), } const page = await searchReports(c.env.DB, filters, { skip: intQuery(c, 'skip') ?? 0, take: Math.min(intQuery(c, 'take') ?? 50, MAX_TAKE), }) return c.json(page) } /** One report by id — the detail behind a search row. */ export async function getReportHandler(c: Context) { const id = Number.parseInt(c.req.param('id') ?? '', 10) if (Number.isNaN(id)) return c.json({ error: 'A numeric report id is required' }, 400) const report = await getReportById(c.env.DB, id) if (report === null) return c.json({ error: 'No such report' }, 404) return c.json(report) } /** * File a minimal report as the acting moderator — the row a ban needs something to hang * on when there is no player report to act on. * * A ban lives ON a report (see reports-db), so moderating something nobody happened to * report — found in a log, seen first-hand, escalated from elsewhere — needs a row first. * The reporter is the moderator, from the token, never the body: that IS the record of who * raised it, and it is why nothing marks these rows as staff-created. * * WHO, WHAT KIND and WHY, and nothing else. Everything else on a report describes a * client-side moment that did not happen here: the heights the game measured, the instance * type, the event/invention/item ids — and `room_id`, which a player's report gets from * the client that filed it. A moderator does not know a room's numeric id and would have * to go and look it up, so the room goes in `details` along with the rest of the account * of what happened. A `roomId` in the body is therefore not read; player reports still * carry the column, and it is theirs alone. */ export async function createReportHandler(c: Context) { const moderatorId = staffId(c) const body = (await c.req.json().catch(() => null)) as { reportedPlayerId?: unknown reportCategory?: unknown details?: unknown } | null if (body === null) return c.json({ error: 'Invalid request body' }, 400) const reportedPlayerId = Number(body.reportedPlayerId) if (!Number.isInteger(reportedPlayerId) || reportedPlayerId <= 0) { return c.json({ error: 'reportedPlayerId is required' }, 400) } // A moderator filing a report against themselves is a mistake every time, and the ban // it would justify locks the panel's own operator out of the game. if (reportedPlayerId === moderatorId) { return c.json({ error: 'You cannot file a report against yourself' }, 400) } const report = await createReport(c.env.DB, { reporterPlayerId: moderatorId, reportedPlayerId, reportCategory: Number.isInteger(Number(body.reportCategory)) ? Number(body.reportCategory) : 0, details: typeof body.details === 'string' && body.details !== '' ? body.details : null, }) logger.info('a moderator filed a report by hand', { reportId: report.id, moderatorId, reportedPlayerId, }) return c.json(report) } /** * Apply or lift a ban on the report with this id. * * `banned: false` lifts it, clearing the expiry and the audit columns and leaving the * report itself intact — the panel needs to be able to undo a ban, and a lifted ban has to * be distinguishable from an expired one (`banned = 0` versus a past `ban_expires`). The * lift is signed onto the row (`unbanned_by_player_id`/`unbanned_at`, by `banFromReport`) * so the tables can say a colleague let the player back in, rather than offering "Ban…" * on a row that looks untouched — which is how a lifted ban got re-applied by the next * moderator to see it. * * Applying one also EJECTS the player from any instance they are in (see * {@link kickBannedPlayer}), best-effort and after the row is committed. The response * reports whether the frame went out, so the panel can say "banned, but they were offline" * rather than implying a kick that never happened. */ export async function banReportHandler(c: Context) { const moderatorId = staffId(c) const id = Number.parseInt(c.req.param('id') ?? '', 10) if (Number.isNaN(id)) return c.json({ error: 'A numeric report id is required' }, 400) const body = (await c.req.json().catch(() => ({}))) as { banned?: unknown permanent?: unknown expires?: unknown days?: unknown hours?: unknown } // Absent means ban: the endpoint's purpose is to hand one down, and a lift is the // explicit case. const banned = body.banned !== false const now = new Date() const expires = banned ? banExpiry(body, now) : null if (expires === undefined) { return c.json({ error: 'That ban duration is not a time in the future' }, 400) } // Read first so a ban on a report that doesn't exist is a 404 rather than a silent // no-op, and so the response can name who it reached. const existing = await getReportById(c.env.DB, id) if (existing === null) return c.json({ error: 'No such report' }, 404) const report = await banFromReport(c.env.DB, id, { banned, banExpires: expires, bannedBy: moderatorId, }) if (report === null) return c.json({ error: 'No such report' }, 404) logger.info(banned ? 'a moderator banned a player' : 'a moderator lifted a ban', { reportId: id, moderatorId, bannedPlayerId: report.reported_player_id, banExpires: report.ban_expires, }) await recordBanAudit(c, moderatorId, existing, report) if (banned) await kickBannedPlayer(c, report, moderatorId) return c.json(report) } /** * The players with the most reports against them — the panel's triage list. * * Windowed to the last 30 days by default, so the list is who is a problem NOW rather * than whoever has ever accumulated the most reports. `sinceDays=0` reads as all time. */ export async function topReportedHandler(c: Context) { const sinceDays = intQuery(c, 'sinceDays') const players = await getTopReported(c.env.DB, { // 0 is the panel's "all time", which the query expresses as null. sinceDays: sinceDays === 0 ? null : (sinceDays ?? 30), minReports: intQuery(c, 'minReports') ?? 3, take: Math.min(intQuery(c, 'take') ?? 50, MAX_TAKE), }) return c.json(players) } /** Every ban in force right now — the standing-bans list. */ export async function bansInForceHandler(c: Context) { return c.json(await getBansInForce(c.env.DB)) } /** * Everything on file about one player: every report against them, every warning handed * down, and the ban in force if there is one. * * One call because it is one screen — a moderator deciding what to do about an account * reads all three together, and three round trips would let the page render a decision * out of a partially-loaded history. */ export async function playerHistoryHandler(c: Context) { const playerId = Number.parseInt(c.req.param('id') ?? '', 10) if (Number.isNaN(playerId)) return c.json({ error: 'A numeric player id is required' }, 400) const [reports, warnings, activeBan] = await Promise.all([ getReportsAgainst(c.env.DB, playerId), getWarningsAgainst(c.env.DB, playerId), getActiveBan(c.env.DB, playerId), ]) return c.json({ playerId, reports, warnings, activeBan }) } /** * Which other accounts a ban on this player would ALSO block — shown before a moderator * confirms one. * * The IP arm is coarse by design (see bans-db): households, NAT and campus networks put * unrelated players behind one address, so a ban can reach people who did nothing. That is * the operator's trade to make, but it should be made with the list in front of them rather * than discovered from a support ticket, which is the whole reason this endpoint exists. * * `arms` echoes the operator's `BAN_EVASION_MATCH` so the page can say which arms are live * — an empty list under `off` means the ban reaches exactly the one account. */ export async function linkedAccountsHandler(c: Context) { const playerId = Number.parseInt(c.req.param('id') ?? '', 10) if (Number.isNaN(playerId)) return c.json({ error: 'A numeric player id is required' }, 400) const arms = armsFor(c.env) return c.json({ playerId, arms, linked: await linkedAccounts(c.env.DB, playerId, arms) }) } // ---- Player actions --------------------------------------------------------- // // The staff card on a player's profile page. None of these has a game endpoint to call — // the client can't grant tokens, hand back a username change or wipe a password — so, like // the moderation panel, they live here. Each one is recorded on `audit_log` against the // acting staffer, with the target in `data` (the table's `player_id` is the actor). /** The system "Coach" account — who a box the server hands over is from. */ const COACH_ACCOUNT_ID = 1 /** * The most one gift can carry when the operator's `MAX_TOKEN_GIFT` is unset. Not a policy — * staff can send as many gifts as they like — but a guard against a stray keypress turning * 1,000 into 10,000,000, which nothing can take back: there is no debit endpoint to undo a * credit with. */ export const DEFAULT_MAX_TOKEN_GIFT = 10_000 /** * The most XP one gift can carry when the operator's `MAX_XP_GIFT` is unset — a typo guard, * as {@link DEFAULT_MAX_TOKEN_GIFT} is. Kept small for now: enough to cross the first few * levels, where every level to the top costs 15,090 in all (level 1 to 50). */ export const DEFAULT_MAX_XP_GIFT = 100 /** * The most a token DROP — the gift to everyone online at once — can carry per player. Fixed * rather than an operator knob like `MAX_TOKEN_GIFT`, and far under it: this one is multiplied * by however many players are on, and the drop is meant for a small thank-you to the whole * server, not a payout. */ export const MAX_TOKEN_DROP = 1_000 /** * The most the Discord ROLE drop can carry per player. Higher than {@link MAX_TOKEN_DROP}: * the audience is a named role, not whoever happens to be on, so the operator chooses who is * paid and the drop is a reward rather than a server-wide thank-you. */ export const MAX_ROLE_TOKEN_DROP = 10_000 /** The longest message a staff box may carry — the same cap a client message has. */ const MAX_GIFT_MESSAGE = 256 /** * `GiftContext.Purchased_Gift_A` — what a staff box says it came from. * * The client classifies a box by its context and only tests a small set (500–503 * `Purchased_Gift_A`–`D`, 1300 `Friendotron_Gift`); `Default` (0) falls outside it, and a box * whose contents it won't render shows "cannot display". The TOKEN box is left on 0 because * it renders as it is — this is the thing being changed, so it is not changed everywhere at * once. */ const GIFT_CONTEXT_PURCHASED_GIFT_A = 500 /** The message on the box a staff gift comes in. */ const STAFF_GIFT_MESSAGE = 'A gift from the staff!' /** A path's `:id` as a player id, or null when it isn't a positive integer. */ function playerIdParam(c: Context): number | null { const id = Number(c.req.param('id')) return Number.isInteger(id) && id > 0 ? id : null } /** * Record a player action on `audit_log`. Written after the change has committed and never * throws, for the reason {@link recordBanAudit} gives: the change has already happened. */ async function recordPlayerAudit( c: Context, action: string, data: Record ): Promise { try { await writeAuditLog(c.env.DB, { playerId: staffId(c), action, data }) } catch (err) { logger.error('could not write an audit log row', { action, moderatorId: staffId(c), error: err instanceof Error ? err.message : String(err), }) } } /** * The stored content of a box staff hand over: from the Coach, as every box the server hands * over on nobody's behalf is (who really sent it is on the audit row), carrying nothing but * what `fields` puts in it. * * `AvatarItemType` is NULL: no staff box holds an avatar item, and the client routes a box on * that field before reading the rest — a 0 sends it after "avatar item type 0", which fails * with "can't find avatar item". Same rule as econ's `boxAvatarItemType`. */ function staffGiftContent(fields: Partial): GiftContent { return { FromPlayerId: COACH_ACCOUNT_ID, GiftContext: 0, ConsumableItemDesc: '', ConsumableCount: 0, AvatarItemDesc: '', AvatarItemType: null, CurrencyType: 0, Currency: 0, Xp: 0, PackageType: 0, Message: STAFF_GIFT_MESSAGE, EquipmentPrefabName: '', EquipmentModificationGuid: '', GiftRarity: 0, Platform: -1, PlatformsToSpawnOn: -1, BalanceType: null, ...fields, } } /** * Show a player the box they were just handed: `GiftPackageReceivedImmediate`, the frame * econ sends for a box the player never clicked for, read off the stored content so the * frame and `GET /api/avatar/v2/gifts` describe one box. A custom item's box also carries * `CustomAvatarItemId`, as econ's does — the recovered decoder names no such member and * drops unknown ones, so it costs nothing if unread. Best-effort: the box is already stored, * and an offline player meets it on their next read of their gifts. */ /** The `GiftPackageReceivedImmediate` frame announcing a stored box — see {@link announceGift}. */ function giftFrame( giftId: number, content: GiftContent ): GiftPackagePayload & { CustomAvatarItemId?: string } { const frame: GiftPackagePayload = { Id: giftId, FromPlayerId: content.FromPlayerId ?? COACH_ACCOUNT_ID, ConsumableItemDesc: content.ConsumableItemDesc, AvatarItemType: content.AvatarItemType, AvatarItemDesc: content.AvatarItemDesc, EquipmentPrefabName: content.EquipmentPrefabName, EquipmentModificationGuid: content.EquipmentModificationGuid, CurrencyType: content.CurrencyType, Currency: content.Currency, Xp: content.Xp, GiftContext: content.GiftContext ?? 0, GiftRarity: content.GiftRarity, Message: content.Message, Platform: -1, PlatformsToSpawnOn: -1, BalanceType: ALL_PLATFORMS, } return content.CustomAvatarItemId ? { ...frame, CustomAvatarItemId: content.CustomAvatarItemId } : frame } async function announceGift( c: Context, playerId: number, giftId: number, content: GiftContent ): Promise { try { await c.env.RECFLARE_NOTIFICATIONS_HUB.getByName(HUB_INSTANCE).notifyPlayer( playerId, NotificationType.GiftPackageReceivedImmediate, { ...giftFrame(giftId, content) } ) } catch (err) { logger.error('failed to announce a staff gift', { playerId, giftId, error: err instanceof Error ? err.message : String(err), }) } } /** * Send a player RecCenterTokens in a gift box — or, with a NEGATIVE amount, take some back. * * * The same shape as econ's own server-handed currency (a game reward's Laser Tag tickets): * the balance is CREDITED here — opening a box only deletes it, it grants nothing — then a * `StorefrontBalanceUpdate` carrying the resulting total sets the client's bucket, and the box * is stored and announced with `GiftPackageReceivedImmediate` so the player sees what arrived. * The box is from the Coach, as every box the server hands over on nobody's behalf is; who * really sent it is on the audit row. It says what the body's optional `message` says, or * the staff default when there is none. * * Both frames are best-effort: the tokens are banked by the time they go out, and an offline * player meets the box in `GET /api/avatar/v2/gifts` and the balance on their next read. * * A negative amount debits through the same guarded spend a purchase uses, so it cannot * overdraw: a player who can't afford it is refused and keeps what they have. Zero moves * nothing at all. Both still mint a box, carrying that `Currency` — nobody has seen what the * client makes of an empty or a negative one, and finding out is the point. */ export async function giftTokensHandler(c: Context) { const playerId = playerIdParam(c) if (playerId === null) return c.json({ error: 'A numeric player id is required' }, 400) const body = (await c.req.json().catch(() => ({}))) as { amount?: unknown; message?: unknown } const amount = Number(body.amount) const refusal = tokenAmountRefusal(c, amount) if (refusal !== null) return c.json({ error: refusal }, 400) const message = giftMessage(body) if (message === undefined) return c.json({ error: GIFT_MESSAGE_TOO_LONG }, 400) if ((await getAccount(c.env.DB, playerId)) === null) { return c.json({ error: 'No such player' }, 404) } const startingTokens = intVar(c.env.STARTING_TOKENS, DEFAULT_STARTING_TOKENS) const sent = await sendTokens(c, playerId, amount, startingTokens, message) if (sent === null) { const held = await getBalance(c.env.DB, playerId, CurrencyType.RecCenterTokens, startingTokens) return c.json({ error: `They only have ${held.toLocaleString()} tokens to take` }, 400) } await recordPlayerAudit(c, 'gift_tokens', { playerId, amount, message, ...sent }) logger.info(amount < 0 ? 'staff took tokens back' : 'staff gifted tokens', { moderatorId: staffId(c), playerId, amount, }) return c.json({ playerId, amount, ...sent }) } /** * Why this token amount can't be sent, or null when it can. Shared by the one-player gift * and the room-wide one so they agree on what an amount is: any whole number within the * operator's cap, in either direction (see {@link sendTokens} for what each sign does). */ function tokenAmountRefusal(c: Context, amount: number): string | null { if (!Number.isInteger(amount)) return 'Enter a whole number of tokens, positive or negative' const maxGift = intVar(c.env.MAX_TOKEN_GIFT, DEFAULT_MAX_TOKEN_GIFT) if (Math.abs(amount) > maxGift) { return `A gift can carry at most ${maxGift.toLocaleString()} tokens` } return null } /** * The message a staff box should carry, from the body's optional `message`: the staffer's * own words, trimmed, or {@link STAFF_GIFT_MESSAGE} when they wrote none. `undefined` means * a message was written but is too long to send — the caller refuses rather than truncating * what the player is going to read. */ function giftMessage(body: { message?: unknown }): string | undefined { const message = typeof body.message === 'string' ? body.message.trim() : '' if (message === '') return STAFF_GIFT_MESSAGE if (message.length > MAX_GIFT_MESSAGE) return undefined return message } /** The refusal {@link giftMessage} stands for when it answers `undefined`. */ const GIFT_MESSAGE_TOO_LONG = `Keep the message under ${MAX_GIFT_MESSAGE} characters` /** * Move one player's token balance by `amount`, box it and tell them — the whole of a token * gift, for one player. Returns their resulting balance and the box's id, or null when a * negative amount is more than they hold (nothing is changed, and no box is minted). * * A POSITIVE amount credits, as econ's own faucets do. A NEGATIVE one debits through the * same guarded spend a purchase uses, so it cannot overdraw. ZERO moves nothing. All three * mint a box — an empty or negative one has never been seen in the client, and finding out * is the point. * * The signup grant is seeded first, as econ does before every credit: `creditCurrency` * upserts the row, so a never-touched balance would otherwise start from this gift. * * `message` is what the box says; the staff default unless the caller wrote one (the drop * to everyone online does). */ async function sendTokens( c: Context, playerId: number, amount: number, startingTokens: number, message: string = STAFF_GIFT_MESSAGE ): Promise<{ balance: number; giftId: number } | null> { await ensureStartingBalances(c.env.DB, playerId, startingTokens) if (amount < 0) { const spent = await spendCurrency( c.env.DB, playerId, CurrencyType.RecCenterTokens, -amount, startingTokens ) if (!spent) return null } const balance = amount > 0 ? await creditCurrency( c.env.DB, playerId, CurrencyType.RecCenterTokens, amount, startingTokens ) : await getBalance(c.env.DB, playerId, CurrencyType.RecCenterTokens, startingTokens) const content = staffGiftContent({ CurrencyType: CurrencyType.RecCenterTokens, Currency: amount, Message: message, }) const gift = await createGift(c.env.DB, playerId, content) // The balance first, so the box's announcement lands on a total that already includes it. try { await c.env.RECFLARE_NOTIFICATIONS_HUB.getByName(HUB_INSTANCE).notifyPlayer( playerId, NotificationType.StorefrontBalanceUpdate, { Balance: balance, CurrencyType: CurrencyType.RecCenterTokens, Platform: ALL_PLATFORMS, } satisfies BalanceResponsePayload ) } catch (err) { logger.error('failed to push a staff token gift balance', { playerId, error: err instanceof Error ? err.message : String(err), }) } await announceGift(c, playerId, gift.id, content) return { balance, giftId: gift.id } } /** * {@link sendTokens} for a crowd — the room gift, the online drop and the role drop, which * pay the same amount in the same box to everyone on a list. The one-player path is five * D1 round trips and two hub calls; done in series for a full server that was a request of * ~1,750 subrequests, a minute or more long, and past D1's 1,000-queries-an-invocation cap * somewhere around 200 players — the drop then died partway, with the first players paid * and the rest not, and no audit row. This banks the boxes through econ's batched * `payTokenGifts` and announces the lot in ONE hub RPC. * * Per player it is exactly the one-player gift: the signup grant seeded first, the balance * moved, a box stored, then a balance frame (the RESULTING total, into the -2 bucket) and * the box announced. A negative amount is a debit guarded per row — `amount >= ?` in the * UPDATE, so nobody is overdrawn — and a player who can't afford it is SKIPPED (no box, no * frame) rather than failing the rest, as the one-player gift returns null. `paid` and * `skipped` keep the order of `playerIds`. * * The frames are best-effort, as everywhere: the tokens and boxes are stored before the hub * is called, and a player who was offline meets both on their next sign-in. */ async function sendTokensToMany( c: Context, playerIds: readonly number[], amount: number, startingTokens: number, message: string = STAFF_GIFT_MESSAGE ): Promise<{ paid: number[]; skipped: number[] }> { const content = staffGiftContent({ CurrencyType: CurrencyType.RecCenterTokens, Currency: amount, Message: message, }) const { paid, skipped } = await payTokenGifts( c.env.DB, playerIds.map((accountId) => ({ accountId, amount, content })), startingTokens ) // The balance first, so the box's announcement lands on a total that already includes // it — the same two frames, in the same order, as sendTokens. const frames: PlayerNotification[] = paid.flatMap((p) => [ { playerId: p.accountId, notificationType: NotificationType.StorefrontBalanceUpdate, data: { Balance: p.balance, CurrencyType: CurrencyType.RecCenterTokens, Platform: ALL_PLATFORMS, } satisfies BalanceResponsePayload, }, { playerId: p.accountId, notificationType: NotificationType.GiftPackageReceivedImmediate, data: { ...giftFrame(p.giftId, p.content) }, }, ]) if (frames.length > 0) { try { await c.env.RECFLARE_NOTIFICATIONS_HUB.getByName(HUB_INSTANCE).notifyPlayers(frames) } catch (err) { logger.error('failed to announce a bulk staff token gift', { paidCount: paid.length, error: err instanceof Error ? err.message : String(err), }) } } return { paid: paid.map((p) => p.accountId), skipped } } /** * Send tokens to EVERYONE standing in a room right now — every instance of it at once. * * The room is the audience, not one session: `getPlayerIdsInRoom` is the live, unexpired * presence for that room across its instances, deduplicated, and it is read at the moment the * button is pressed. Someone who arrives a second later gets nothing; someone whose presence * has lapsed is already gone from it. * * Each player is paid exactly as the one-player gift pays them — the same box, with the same * optional `message` on it — in batches, through {@link sendTokensToMany}. A player a * negative amount can't be taken from is SKIPPED rather than failing the room — the ones who * could afford it are debited regardless — and the response says who was missed. */ export async function giftRoomTokensHandler(c: Context) { const roomId = Number(c.req.param('roomId')) if (!Number.isInteger(roomId) || roomId <= 0) { return c.json({ error: 'A numeric room id is required' }, 400) } const body = (await c.req.json().catch(() => ({}))) as { amount?: unknown; message?: unknown } const amount = Number(body.amount) const refusal = tokenAmountRefusal(c, amount) if (refusal !== null) return c.json({ error: refusal }, 400) const message = giftMessage(body) if (message === undefined) return c.json({ error: GIFT_MESSAGE_TOO_LONG }, 400) const playerIds = await getPlayerIdsInRoom(c.env.DB, roomId) if (playerIds.length === 0) { return c.json({ error: 'Nobody is in that room right now' }, 404) } const startingTokens = intVar(c.env.STARTING_TOKENS, DEFAULT_STARTING_TOKENS) const { paid, skipped } = await sendTokensToMany(c, playerIds, amount, startingTokens, message) await recordPlayerAudit(c, 'gift_tokens_room', { roomId, amount, message, paid, skipped }) logger.info('staff gifted tokens to a room', { moderatorId: staffId(c), roomId, amount, paidCount: paid.length, skippedCount: skipped.length, }) return c.json({ roomId, amount, paid, skipped }) } /** * Send tokens to EVERYONE ONLINE right now — the server-wide drop behind the account page's * "Token drop" tab, beside the coach broadcast and the maintenance notice. * * The audience is every unexpired `presence` row, lobby included (see `getOnlinePlayerIds`), * read when the button is pressed: the same population the coach broadcast reaches and the * status page counts. Each player is paid exactly as the one-player and room gifts pay them * (banked, then a balance frame and an announced box), in batches through * {@link sendTokensToMany}: a full server is a lot of writes, and paid one player at a time * the drop outran D1's per-invocation query cap and died partway. * * Narrower than the other gifts on purpose: the amount is POSITIVE and capped at * {@link MAX_TOKEN_DROP} per player, whatever `MAX_TOKEN_GIFT` says — a stray zero here is * multiplied by everyone online, and there is no way to take a drop back. The message on the * box is the operator's, required, since the box is what the players actually see of it. */ export async function giftOnlineTokensHandler(c: Context) { const body = (await c.req.json().catch(() => ({}))) as { amount?: unknown; message?: unknown } const amount = Number(body.amount) if (!Number.isInteger(amount) || amount <= 0) { return c.json({ error: 'Enter a whole number of tokens greater than 0' }, 400) } if (amount > MAX_TOKEN_DROP) { return c.json( { error: `A token drop can carry at most ${MAX_TOKEN_DROP.toLocaleString()} tokens each` }, 400 ) } // Required here, where the other gifts fall back to the staff default: a server-wide drop // with nothing to say about itself is a mistake more often than not. if (typeof body.message !== 'string' || body.message.trim() === '') { return c.json({ error: 'Enter the message for the gift box' }, 400) } const message = giftMessage(body) if (message === undefined) return c.json({ error: GIFT_MESSAGE_TOO_LONG }, 400) const playerIds = await getOnlinePlayerIds(c.env.DB) if (playerIds.length === 0) return c.json({ error: 'Nobody is online right now' }, 404) const startingTokens = intVar(c.env.STARTING_TOKENS, DEFAULT_STARTING_TOKENS) // A positive amount skips nobody. const { paid } = await sendTokensToMany(c, playerIds, amount, startingTokens, message) await recordPlayerAudit(c, 'gift_tokens_online', { amount, message, paid }) logger.info('staff dropped tokens on everyone online', { moderatorId: staffId(c), amount, paidCount: paid.length, }) return c.json({ amount, message, paid }) } /** * Send tokens to EVERYONE HOLDING A DISCORD ROLE — online or not. The account page's second * drop, beside the one to everyone online: the same box, the same cap, a different audience. * * The audience is every account whose Discord link records the role: the snapshot the * benefits claim wrote and the daily sweep (discord-roles.ts) refreshes, read when the button * is pressed. Nothing asks Discord here — a member who gained the role since the last sweep * is missed until the next one, and one who lost it is still paid until then; the form says * as much. A player who holds the role in the guild but never claimed on the website has no * link, and so is not in the audience however many roles they hold there. The link is the * unit, so a Discord identity linked to two accounts pays both; an account is paid once * whatever its links say. * * Offline is the point: the econ cron's supporter gift (discord-role-gift.ts) is this on a * schedule, and this is the hand-run version with an operator's amount and message on it. A * player who isn't signed in meets the box in `GET /api/avatar/v2/gifts` and the balance on * their next read, as they do for every box the server hands over. * * The role is a Discord snowflake — all digits, compared exactly, as the sweep stores them. * The amount is bounded as the online drop's is, positive only, but with its own cap * ({@link MAX_ROLE_TOKEN_DROP} each): this one multiplies by a role's whole membership, which * can be larger than the server has ever had online at once. The message is required for the * same reason. */ export async function giftRoleTokensHandler(c: Context) { const roleId = c.req.param('roleId') ?? '' if (!/^\d+$/.test(roleId)) { return c.json({ error: 'A Discord role id is all digits' }, 400) } const body = (await c.req.json().catch(() => ({}))) as { amount?: unknown; message?: unknown } const amount = Number(body.amount) if (!Number.isInteger(amount) || amount <= 0) { return c.json({ error: 'Enter a whole number of tokens greater than 0' }, 400) } if (amount > MAX_ROLE_TOKEN_DROP) { return c.json( { error: `A token drop can carry at most ${MAX_ROLE_TOKEN_DROP.toLocaleString()} tokens each`, }, 400 ) } if (typeof body.message !== 'string' || body.message.trim() === '') { return c.json({ error: 'Enter the message for the gift box' }, 400) } const message = giftMessage(body) if (message === undefined) return c.json({ error: GIFT_MESSAGE_TOO_LONG }, 400) const links = await getLinksForPlatform(c.env.DB, PlatformType.Discord) const playerIds = [ ...new Set(links.filter((l) => l.roles.includes(roleId)).map((l) => l.accountId)), ] if (playerIds.length === 0) { return c.json({ error: 'No linked account holds that Discord role' }, 404) } const startingTokens = intVar(c.env.STARTING_TOKENS, DEFAULT_STARTING_TOKENS) // A positive amount skips nobody. const { paid } = await sendTokensToMany(c, playerIds, amount, startingTokens, message) await recordPlayerAudit(c, 'gift_tokens_role', { roleId, amount, message, paid }) logger.info('staff dropped tokens on a discord role', { moderatorId: staffId(c), roleId, amount, paidCount: paid.length, }) return c.json({ roleId, amount, message, paid }) } /** * Run the Discord role sweep NOW — the daily cron (discord-roles.ts) on a button, for a * developer who has just changed the bot, invited it to the guild, or needs the role drop * above to see this morning's roles. Same code, same credentials, same writes: every Discord * link is re-read through the bot token and `platform_account.role` rewritten. * * The point of having it as a request is the ANSWER. The cron's summary goes to a log line * on a worker with observability off, so a sweep that halts on its first call (a token * Discord refuses, a bot not in the guild) halts silently every night, and the table looks * exactly as it did — which is how it was found never to have completed a run. Here the * same summary comes back as the response, `halted` reason and all. * * Always a 200 with the summary, whatever it says: a `skipped` or `halted` run is not a * malformed request, it is the sweep reporting on its configuration, and the operator * reads the reason off the body. Two developers pressing it at once run two sweeps that * interleave harmlessly — each write is the absolute role list Discord served — at twice the * Discord calls; nothing locks it. It is on the audit log like any other developer action. */ export async function syncDiscordRolesHandler(c: Context) { const summary = await refreshDiscordRoles(c.env) await recordPlayerAudit(c, 'sync_discord_roles', { ...summary }) logger.info('staff ran the discord role sweep', { moderatorId: staffId(c), ...summary }) return c.json(summary) } /** * Run econ's supporter gift NOW — the weekly cron (econ's discord-role-gift.ts) on a button, * a bonus round for a developer who wants every supporter paid again today. Same function, * same map (`DISCORD_ROLE_TOKENS`), same box: every account whose Discord link holds a mapped * role is boxed its best role's tokens, online or not. The role drop above is the same * audience with an operator's amount and message; this one pays what the map says and * says so on the box. * * It pays everyone it finds, every time — the gift keeps no ledger, and that module is * explicit that a hand run is an extra gift on purpose. So the panel asks before sending, * and the run is on the audit log with how many boxes and tokens went out. * * Always a 200 with the run's summary, as the sweep's sync is: a `skipped` run (no role * mapped) is the gift reporting on its configuration, and the panel shows the reason. */ export async function runDiscordRoleGiftHandler(c: Context) { const startingTokens = intVar(c.env.STARTING_TOKENS, DEFAULT_STARTING_TOKENS) const summary = await grantDiscordRoleGifts(c.env, startingTokens) await recordPlayerAudit(c, 'run_discord_role_gift', { ...summary }) logger.info('staff ran the discord supporter gift', { moderatorId: staffId(c), ...summary }) return c.json(summary) } /** * Give a player XP, in a gift box — the way a game reward pays its XP. The XP is banked * here (the box, like every box, grants nothing when opened) and spent on any levels it now * pays for; a `PlayerProgressionLevelUpdate` moves the level bar, and the box is announced * so the player sees what arrived. * * Levels crossed do NOT pay their level-up prize boxes: those are rolled from econ's catalog * (`grantLevelUpGifts`), which lives inside the econ app. The levels themselves are stored. */ export async function giftXpHandler(c: Context) { const playerId = playerIdParam(c) if (playerId === null) return c.json({ error: 'A numeric player id is required' }, 400) const body = (await c.req.json().catch(() => ({}))) as { amount?: unknown; message?: unknown } const amount = Number(body.amount) if (!Number.isInteger(amount) || amount <= 0) { return c.json({ error: 'Enter a whole number of XP greater than 0' }, 400) } const maxGift = intVar(c.env.MAX_XP_GIFT, DEFAULT_MAX_XP_GIFT) if (amount > maxGift) { return c.json({ error: `A gift can carry at most ${maxGift.toLocaleString()} XP` }, 400) } const message = giftMessage(body) if (message === undefined) return c.json({ error: GIFT_MESSAGE_TOO_LONG }, 400) if ((await getAccount(c.env.DB, playerId)) === null) { return c.json({ error: 'No such player' }, 404) } const { progression, levelsGained } = await addXp(c.env.DB, playerId, amount) const content = staffGiftContent({ GiftContext: GIFT_CONTEXT_PURCHASED_GIFT_A, Xp: amount, Message: message, }) const gift = await createGift(c.env.DB, playerId, content) await recordPlayerAudit(c, 'gift_xp', { playerId, amount, message, level: progression.Level, levelsGained, giftId: gift.id, }) logger.info('staff gifted xp', { moderatorId: staffId(c), playerId, amount, levelsGained }) try { await c.env.RECFLARE_NOTIFICATIONS_HUB.getByName(HUB_INSTANCE).notifyPlayer( playerId, NotificationType.PlayerProgressionLevelUpdate, { PlayerId: progression.PlayerId, Level: progression.Level, XP: progression.XP, } satisfies PlayerProgressionLevelPayload ) } catch (err) { logger.error('failed to push a staff xp gift progression', { playerId, error: err instanceof Error ? err.message : String(err), }) } await announceGift(c, playerId, gift.id, content) return c.json({ playerId, amount, level: progression.Level, xp: progression.XP, levelsGained, giftId: gift.id, }) } /** * How many of a consumable one staff gift carries — one, as a store purchase grants one * (econ's `CONSUMABLE_GRANT_COUNT`). Consumables stack, so more is more gifts. */ const CONSUMABLE_GIFT_COUNT = 1 /** * What an item gift's id resolved to. `avatar_item` is a catalog row nothing here can grant; * `ambiguous` is an id found in BOTH tables, which nothing here can pick between. */ type GiftableItem = | { kind: 'custom_item'; item: CustomAvatarItem } | { kind: 'skin'; row: CatalogRow } | { kind: 'consumable'; row: CatalogRow } | { kind: 'avatar_item'; row: CatalogRow } | { kind: 'ambiguous' } /** * Resolve the one id the staff card takes to whatever it names, looking in BOTH tables an * item can live in: `custom_avatar_item` by guid, in either case (a GUID's case is not its * identity; ids are stored as the export spelled them), and `catalog` by KEY — a skin's * `ModificationGuid` or a consumable's `ConsumableItemDesc`, spelled as the export spells it * (the short pre-GUID ids are case-sensitive, so nothing is folded there) — or, for a bare * number, by `catalog_id`, the store's `PurchasableItemId`. * * A UUID could be anything, so neither lookup is skipped on the strength of the other: an id * that answers in both tables is `ambiguous` rather than whichever was asked first. In * practice they don't collide — no catalog key is a bare number, and a skin and a consumable * never share a key — but the check is what makes that a fact rather than an assumption. * * A DRAFT custom item (`Accessibility` 0, visible to its creator alone — handing it out would * publish it for them) is treated as unknown, exactly as the store would refuse to sell it. */ async function findGiftableItem(db: D1Database, requested: string): Promise { const [custom, byKey] = await Promise.all([ getCustomAvatarItem(db, requested).then( (item) => item ?? getCustomAvatarItem(db, requested.toLowerCase()) ), getCatalogItem(db, requested), ]) const item = custom !== null && custom.Accessibility !== 0 ? custom : null const row = byKey ?? (/^\d{1,9}$/.test(requested) ? await getCatalogItemById(db, Number(requested)) : null) if (item !== null && row !== null) return { kind: 'ambiguous' } if (item !== null) return { kind: 'custom_item', item } if (row === null) return null switch (row.kind) { case CatalogKind.Skin: return { kind: 'skin', row } case CatalogKind.Consumable: return { kind: 'consumable', row } case CatalogKind.AvatarItem: return { kind: 'avatar_item', row } default: return null } } /** * Give a player an item — a custom avatar item, an equipment skin or a consumable — by any * one of their ids, delivered the way a store purchase delivers it: ownership in the inventory * `econ` reads that kind from, and a gift box that names it, without which the player is * shown nothing. Nobody pays, and a custom item's creator is not paid: a grant, not a sale. * Which kind the id turned out to be is on the reply as `kind`, and the audit row is filed * per kind (`gift_custom_item`, `gift_skin`, `gift_consumable`). * * A baked avatar item's id is refused outright: nothing here grants one yet, and a 404 would * send the staffer checking an id that is right. */ export async function giftItemHandler(c: Context) { const playerId = playerIdParam(c) if (playerId === null) return c.json({ error: 'A numeric player id is required' }, 400) const body = (await c.req.json().catch(() => ({}))) as { itemId?: unknown; message?: unknown } const requested = typeof body.itemId === 'string' ? body.itemId.trim() : '' if (requested === '') return c.json({ error: 'Enter an item id' }, 400) const message = giftMessage(body) if (message === undefined) return c.json({ error: GIFT_MESSAGE_TOO_LONG }, 400) if ((await getAccount(c.env.DB, playerId)) === null) { return c.json({ error: 'No such player' }, 404) } const found = await findGiftableItem(c.env.DB, requested) if (found === null) return c.json({ error: 'No such item' }, 404) switch (found.kind) { case 'custom_item': return await giftCustomItem(c, playerId, found.item, message) case 'skin': return await giftSkin(c, playerId, found.row, message) case 'consumable': return await giftConsumable(c, playerId, found.row, message) case 'avatar_item': return c.json( { error: 'That is a baked avatar item; only custom items, skins and consumables can be gifted', }, 400 ) case 'ambiguous': return c.json({ error: 'That id names both a custom item and a catalog item' }, 409) } } /** * A custom avatar item: ownership is a row in `inventory_custom` (what `econ`'s owned read * serves the item from), and the box names it by `CustomAvatarItemId`. * * Refused as the store refuses a sale to this player: the item's own creator (who owns it * already), or a player who already has it. */ async function giftCustomItem( c: Context, playerId: number, item: CustomAvatarItem, message: string ) { if (item.CreatorAccountId === playerId) { return c.json({ error: 'This player created that item, so they already have it' }, 409) } const owned = await ownedCustomAvatarItemIds(c.env.DB, playerId, [item.CustomAvatarItemId]) if (owned.has(item.CustomAvatarItemId.toLowerCase())) { return c.json({ error: 'This player already owns that item' }, 409) } await grantCustomAvatarItem(c.env.DB, playerId, item.CustomAvatarItemId) const content = staffGiftContent({ GiftContext: GIFT_CONTEXT_PURCHASED_GIFT_A, CustomAvatarItemId: item.CustomAvatarItemId, // 0, not the null the other staff boxes carry: a custom item IS an avatar item, and this // matches the box econ mints for a bought one, which is the shape seen to open. AvatarItemType: 0, Message: message, }) const gift = await createGift(c.env.DB, playerId, content) await recordPlayerAudit(c, 'gift_custom_item', { playerId, customAvatarItemId: item.CustomAvatarItemId, name: item.Name, message, giftId: gift.id, }) logger.info('staff gifted a custom item', { moderatorId: staffId(c), playerId, customAvatarItemId: item.CustomAvatarItemId, }) await announceGift(c, playerId, gift.id, content) return c.json({ kind: 'custom_item', playerId, customAvatarItemId: item.CustomAvatarItemId, name: item.Name, giftId: gift.id, }) } /** * An equipment skin: ownership is a row in `equipment` (what `GET /api/equipment/v2/getUnlocked` * serves), and the box names it by `EquipmentPrefabName`/`EquipmentModificationGuid` with * `AvatarItemType` NULL — a 0 there sends the client looking for an avatar item that does not * exist, and the box fails to open. * * Refused for a player who already owns it: owning a skin is boolean, so a second grant would * hand them a box with nothing new in it. */ async function giftSkin(c: Context, playerId: number, row: CatalogRow, message: string) { const skin = toCatalogSkin(row) const owned = await getEquipment(c.env.DB, playerId) if (owned.some((eq) => eq.ModificationGuid === skin.ModificationGuid)) { return c.json({ error: 'This player already owns that skin' }, 409) } // The same DTO a purchase grants (econ's `toEquipment`), built from the catalog's view. await grantEquipment(c.env.DB, playerId, { ModificationGuid: skin.ModificationGuid, PrefabName: skin.PrefabName, FriendlyName: skin.FriendlyName, Tooltip: skin.Tooltip ?? '', Rarity: skin.Rarity, PlatformMask: -1, Favorited: false, }) const content = staffGiftContent({ GiftContext: GIFT_CONTEXT_PURCHASED_GIFT_A, EquipmentPrefabName: skin.PrefabName, EquipmentModificationGuid: skin.ModificationGuid, GiftRarity: skin.Rarity, Message: message, }) const gift = await createGift(c.env.DB, playerId, content) await recordPlayerAudit(c, 'gift_skin', { playerId, modificationGuid: skin.ModificationGuid, prefabName: skin.PrefabName, name: skin.FriendlyName, message, giftId: gift.id, }) logger.info('staff gifted a skin', { moderatorId: staffId(c), playerId, modificationGuid: skin.ModificationGuid, }) await announceGift(c, playerId, gift.id, content) return c.json({ kind: 'skin', playerId, modificationGuid: skin.ModificationGuid, prefabName: skin.PrefabName, name: skin.FriendlyName, giftId: gift.id, }) } /** * One of a consumable: a fresh `consumable` row (they stack, so there is no "already owns" * refusal — a second gift is a second one), and a box naming it by `ConsumableItemDesc` with * `AvatarItemType` NULL, as a skin's is. The box also carries the granted row's id and the * player's count BEFORE this one, which is what lets opening it fire an accurate * `ConsumableMappingAdded`. */ async function giftConsumable(c: Context, playerId: number, row: CatalogRow, message: string) { const preExisting = await countConsumable(c.env.DB, playerId, row.item_key) const mappingId = await grantConsumable(c.env.DB, playerId, row.item_key, CONSUMABLE_GIFT_COUNT) const content = staffGiftContent({ GiftContext: GIFT_CONTEXT_PURCHASED_GIFT_A, ConsumableItemDesc: row.item_key, ConsumableCount: CONSUMABLE_GIFT_COUNT, ConsumableMappingId: mappingId, ConsumablePreExistingCount: preExisting, GiftRarity: row.rarity, Message: message, }) const gift = await createGift(c.env.DB, playerId, content) await recordPlayerAudit(c, 'gift_consumable', { playerId, consumableItemDesc: row.item_key, name: row.friendly_name, count: CONSUMABLE_GIFT_COUNT, message, giftId: gift.id, }) logger.info('staff gifted a consumable', { moderatorId: staffId(c), playerId, consumableItemDesc: row.item_key, }) await announceGift(c, playerId, gift.id, content) return c.json({ kind: 'consumable', playerId, consumableItemDesc: row.item_key, name: row.friendly_name, count: CONSUMABLE_GIFT_COUNT, owned: preExisting + CONSUMABLE_GIFT_COUNT, giftId: gift.id, }) } /** * Give a player one more username change. The client reads the count off `/account/me`, * so it shows the next time the player opens the name screen. */ export async function addUsernameChangeHandler(c: Context) { const playerId = playerIdParam(c) if (playerId === null) return c.json({ error: 'A numeric player id is required' }, 400) const remaining = await addUsernameChange(c.env.DB, playerId) if (remaining === null) return c.json({ error: 'No such player' }, 404) await recordPlayerAudit(c, 'add_username_change', { playerId, availableUsernameChanges: remaining, }) return c.json({ playerId, availableUsernameChanges: remaining }) } /** * Remove a player's password so they can set a new one in game — the recovery for someone * who has forgotten it, since nothing here can send a reset email. `hadPassword` says whether * there was one to remove; clearing an already-clear password succeeds, and is still logged. */ export async function clearPasswordHandler(c: Context) { const playerId = playerIdParam(c) if (playerId === null) return c.json({ error: 'A numeric player id is required' }, 400) const hadPassword = await clearPasswordHash(c.env.DB, playerId) if (hadPassword === null) return c.json({ error: 'No such player' }, 404) await recordPlayerAudit(c, 'clear_password', { playerId, hadPassword }) 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 }) } /** * Whether the acting staff member's OWN account is a sandbox account — the `isSandbox` * flag `match` reads on every `GET /player/connection-info` to pick the sandbox Tachyon * pool over the live one. Self-only: the toggle on the account page is how a moderator or * developer moves THEMSELVES onto the test servers, not how anyone is moved by someone else * (an operator does that with `runx admin`, if at all). */ export async function sandboxStatusHandler(c: Context) { const account = await getAccount(c.env.DB, staffId(c)) return c.json({ sandbox: account?.isSandbox === true }) } /** * Opt the acting staff member in to or out of the sandbox pool: `{ sandbox: boolean }` sets * `isSandbox` on their own account. Idempotent — setting what's already set succeeds, * changes nothing and still answers the current state. Logged on `audit_log` like every * other account change staff make from the website, with `changed` saying whether anything * moved. * * `match` reads the account per connection-info request, so the switch is honoured the next * time the player joins a room instance; the instance they're standing in keeps its server. */ export async function setSandboxHandler(c: Context) { const body = (await c.req.json().catch(() => null)) as { sandbox?: unknown } | null if (body === null || typeof body !== 'object' || typeof body.sandbox !== 'boolean') { return c.json({ error: 'Expected a JSON body with a boolean `sandbox`' }, 400) } const playerId = staffId(c) const account = await getAccount(c.env.DB, playerId) const was = account?.isSandbox === true const changed = was !== body.sandbox if (changed) await updateAccount(c.env.DB, playerId, { isSandbox: body.sandbox }) await recordPlayerAudit(c, 'set_sandbox', { playerId, sandbox: body.sandbox, changed }) logger.info('staff set their sandbox flag', { moderatorId: playerId, sandbox: body.sandbox, changed, }) return c.json({ sandbox: body.sandbox, changed }) }