diff --git a/.env.example b/.env.example index 4c22fc6..88d8aec 100644 --- a/.env.example +++ b/.env.example @@ -154,6 +154,11 @@ RECFLARE_DOMAIN=rec.example.com # raising it later does NOT top up existing players. # RECFLARE_STARTING_TOKENS=10000 +# The most RecCenterTokens one gift from the staff card on a player's profile can carry +# (`www`). A guard against typos — a gift can't be taken back — not a limit on how much +# staff can give in total. +# RECFLARE_MAX_TOKEN_GIFT=10000 + # Signup on the website is configured OUTSIDE this file: it's guarded by a Cloudflare # Turnstile widget, and both of that widget's keys live in the shared Secrets Store # (RECFLARE_SECRETS_STORE above), alongside JWT_SECRET — not as vars, not as worker secrets. diff --git a/apps/accounts/src/accounts.app.ts b/apps/accounts/src/accounts.app.ts index c3889fd..d351336 100644 --- a/apps/accounts/src/accounts.app.ts +++ b/apps/accounts/src/accounts.app.ts @@ -4,6 +4,7 @@ import { useWorkersLogger } from 'workers-tagged-logger' import { createAccount, + DEFAULT_USERNAME_CHANGES, defaultAccount, getAccount, getAccountByUsername, @@ -80,9 +81,6 @@ function unauthorized(c: Context) { return c.body(null, 401) } -/** Username changes a fresh account starts with (until one has been consumed). */ -const DEFAULT_USERNAME_CHANGES = 3 - /** * Username-change result envelope: `{ success, error, value }`, always HTTP 200. * On success `value` is the updated account; on error `error` carries the message diff --git a/apps/www/src/client/App.tsx b/apps/www/src/client/App.tsx index af8273b..47298ad 100644 --- a/apps/www/src/client/App.tsx +++ b/apps/www/src/client/App.tsx @@ -1155,10 +1155,141 @@ function PlayerPage({ )} + + {/* Staff only, and cosmetic: hidden for everyone else, but every endpoint behind it + is gated by `requireStaff`. */} + {isAdmin() && } ) } +/** + * The staff card at the foot of a player's profile: account fixes the game gives staff no + * way to make. Each is a `www` endpoint (see src/staff.ts) that records itself on + * `audit_log`. Clearing a password asks twice, since it logs the player out of password + * sign-in until they set a new one; the rest are one click. + */ +function StaffPlayerActions({ account, navigate }: { account: PublicAccount; navigate: Navigate }) { + const [tokens, setTokens] = useState('') + const [confirmingClear, setConfirmingClear] = useState(false) + const gift = useAction() + const usernameChange = useAction() + const clearPassword = useAction() + const base = `/api/staff/players/${account.accountId}` + + return ( +
+

Staff

+
{ + e.preventDefault() + void gift.run(async () => { + const amount = Number(tokens) + const res = await call<{ balance: number }>(`${base}/gift-tokens`, { + authed: true, + json: { amount }, + }) + setTokens('') + return `Sent ${amount.toLocaleString()} tokens. @${account.username} now has ${res.balance.toLocaleString()}.` + }) + }} + > + + {gift.error &&

{gift.error}

} + {gift.done &&

{gift.done}

} +
+ +
+ + {confirmingClear ? ( + <> + + + + ) : ( + + )} + +
+

+ Clear password: Only use if the player has requested a password clear and + they can still login via Steam or Meta. +

+ {usernameChange.error &&

{usernameChange.error}

} + {usernameChange.done &&

{usernameChange.done}

} + {clearPassword.error &&

{clearPassword.error}

} + {clearPassword.done &&

{clearPassword.done}

} +
+ ) +} + export function App() { // undefined = still checking the session; null = signed out. const [account, setAccount] = useState(undefined) diff --git a/apps/www/src/client/Moderation.tsx b/apps/www/src/client/Moderation.tsx index 2ae47e1..6812aca 100644 --- a/apps/www/src/client/Moderation.tsx +++ b/apps/www/src/client/Moderation.tsx @@ -400,7 +400,12 @@ export function ModerationPage({ // is almost always the first half of banning somebody, and the new report's // id is then in the URL, so the ban is a real page rather than a modal that // a reload would lose. - + // `?player=` pre-fills who it's against — the Report link on a player's + // profile page lands here with it. + )} @@ -1218,8 +1223,14 @@ function LinkedAccountsPanel({ playerId }: { playerId: number }) { * Hands the new report straight to the ban dialog, since filing one is almost always the * first half of banning somebody. */ -function FileReport({ onFiled }: { onFiled: (report: ReportRow) => void }) { - const [player, setPlayer] = useState('') +function FileReport({ + initialPlayer, + onFiled, +}: { + initialPlayer: string + onFiled: (report: ReportRow) => void +}) { + const [player, setPlayer] = useState(initialPlayer) const [category, setCategory] = useState(String(KickReportCategory.Misc)) const [details, setDetails] = useState('') const { pending, error, run } = useAction() diff --git a/apps/www/src/client/styles.css b/apps/www/src/client/styles.css index 0702243..d2540f5 100644 --- a/apps/www/src/client/styles.css +++ b/apps/www/src/client/styles.css @@ -1556,6 +1556,23 @@ button[type='submit']:disabled { width: auto; } +/* The staff card on a player's profile: the token amount and its Send button on one line. */ +.staff-gift { + display: flex; + gap: 10px; + margin-top: 6px; +} + +.staff-gift input { + margin-top: 0; + max-width: 180px; +} + +.staff-actions { + flex-wrap: wrap; + margin-top: 8px; +} + /* Tables are the one thing allowed to be wider than the page — a report row has more in it than a phone is wide, and wrapping the cells would make it unscannable. */ .mod-scroll { diff --git a/apps/www/src/context.ts b/apps/www/src/context.ts index 0d515f7..ead6042 100644 --- a/apps/www/src/context.ts +++ b/apps/www/src/context.ts @@ -18,10 +18,11 @@ export type Env = SharedHonoEnv & { */ DB: D1Database /** - * SignalR notifications hub (DO owned by the `notify` worker). Bound for ONE thing: a + * SignalR notifications hub (DO owned by the `notify` worker). Bound mainly for one thing: a * ban handed down from the staff panel ejects the player from the instance they are * standing in, which needs a `ModerationKick` frame pushed to them (see src/staff.ts). - * Without it a ban would only bite on their next matchmake. + * Without it a ban would only bite on their next matchmake. A staff token gift uses it + * too, to set the player's balance and show them the box. */ RECFLARE_NOTIFICATIONS_HUB: DurableObjectNamespace /** @@ -36,6 +37,20 @@ export type Env = SharedHonoEnv & { * Undeclared in wrangler.jsonc, as it is there: an operator who wants it sets it. */ BAN_EVASION_MATCH?: string + /** + * The signup token grant — the same knob `econ` reads, injected into every worker from + * RECFLARE_STARTING_TOKENS at deploy. www needs it for a staff token gift, which seeds a + * never-touched balance with the signup grant before crediting, exactly as econ does; a + * different value here would start that player on the wrong amount. Unset means + * DEFAULT_STARTING_TOKENS, as it does in econ. + */ + STARTING_TOKENS?: string | number + /** + * The most RecCenterTokens one staff token gift can carry (see src/staff.ts). A typo guard + * rather than a policy, since a credit can't be taken back. Unset means + * DEFAULT_MAX_TOKEN_GIFT (10,000). + */ + MAX_TOKEN_GIFT?: string | number /** * Service binding to the `auth` worker — how the BFF reaches it, so the browser's real * IP survives the hop (see wrangler.jsonc and src/upstream.ts `postAuthForm`). diff --git a/apps/www/src/staff.ts b/apps/www/src/staff.ts index 4a2fd06..572c0f7 100644 --- a/apps/www/src/staff.ts +++ b/apps/www/src/staff.ts @@ -1,5 +1,13 @@ -import { getPresences, movePlayerToDorm, writeAuditLog } from '@repo/domain' -import { logger } from '@repo/hono-helpers' +import { + addUsernameChange, + clearPasswordHash, + createGift, + getAccount, + getPresences, + movePlayerToDorm, + writeAuditLog, +} from '@repo/domain' +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. @@ -19,13 +27,26 @@ import { searchReports, } from '../../api/src/reports-db' import { getWarningsAgainst } from '../../api/src/warnings-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, +} from '../../econ/src/balance-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' import type { Context, MiddlewareHandler } from 'hono' +import type { GiftContent } from '@repo/domain' import type { ReportRow, ReportSearch } from '../../api/src/reports-db' -import type { ModerationKickPayload } from '../../notify/src/notification-payloads' +import type { + BalanceResponsePayload, + GiftPackagePayload, + ModerationKickPayload, +} from '../../notify/src/notification-payloads' import type { App, Env } from './context' /** @@ -478,3 +499,187 @@ export async function linkedAccountsHandler(c: Context) { 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 message on a staff token gift's box. */ +const TOKEN_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), + }) + } +} + +/** + * Send a player RecCenterTokens in a gift box. + * + * 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. + * + * 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. + */ +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 } + 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) + } + const maxGift = intVar(c.env.MAX_TOKEN_GIFT, DEFAULT_MAX_TOKEN_GIFT) + if (amount > maxGift) { + return c.json({ error: `A gift can carry at most ${maxGift.toLocaleString()} tokens` }, 400) + } + if ((await getAccount(c.env.DB, playerId)) === null) { + return c.json({ error: 'No such player' }, 404) + } + + // Seed the signup grant first, as econ does before every credit: `creditCurrency` upserts, + // and a never-touched balance would otherwise start from this gift instead of the grant. + const startingTokens = intVar(c.env.STARTING_TOKENS, DEFAULT_STARTING_TOKENS) + await ensureStartingBalances(c.env.DB, playerId, startingTokens) + const balance = await creditCurrency( + c.env.DB, + playerId, + CurrencyType.RecCenterTokens, + amount, + startingTokens + ) + + const content: GiftContent = { + FromPlayerId: COACH_ACCOUNT_ID, + GiftContext: 0, + ConsumableItemDesc: '', + ConsumableCount: 0, + AvatarItemDesc: '', + AvatarItemType: 0, + CurrencyType: CurrencyType.RecCenterTokens, + Currency: amount, + Xp: 0, + PackageType: 0, + Message: TOKEN_GIFT_MESSAGE, + EquipmentPrefabName: '', + EquipmentModificationGuid: '', + GiftRarity: 0, + Platform: -1, + PlatformsToSpawnOn: -1, + BalanceType: null, + } + const gift = await createGift(c.env.DB, playerId, content) + + await recordPlayerAudit(c, 'gift_tokens', { playerId, amount, balance, giftId: gift.id }) + logger.info('staff gifted tokens', { moderatorId: staffId(c), playerId, amount }) + + const hub = c.env.RECFLARE_NOTIFICATIONS_HUB.getByName(HUB_INSTANCE) + try { + await hub.notifyPlayer(playerId, NotificationType.StorefrontBalanceUpdate, { + Balance: balance, + CurrencyType: CurrencyType.RecCenterTokens, + Platform: ALL_PLATFORMS, + } satisfies BalanceResponsePayload) + await hub.notifyPlayer(playerId, NotificationType.GiftPackageReceivedImmediate, { + Id: gift.id, + 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, + } satisfies GiftPackagePayload) + } catch (err) { + logger.error('failed to announce a staff token gift', { + playerId, + giftId: gift.id, + error: err instanceof Error ? err.message : String(err), + }) + } + + return c.json({ playerId, amount, balance, 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 }) +} diff --git a/apps/www/src/test/integration/api.test.ts b/apps/www/src/test/integration/api.test.ts index 0aa987e..5efcc53 100644 --- a/apps/www/src/test/integration/api.test.ts +++ b/apps/www/src/test/integration/api.test.ts @@ -4,6 +4,7 @@ import { beforeAll, expect, it } from 'vitest' import { SCHEMA_DDL as ACCOUNT_SCHEMA_DDL, updateAccount } from '@repo/domain/src/accounts-db' import { AUDIT_LOG_SCHEMA_DDL } from '@repo/domain/src/audit-db' import { PlatformType } from '@repo/domain/src/enums' +import { getPendingGifts, RECEIVED_GIFT_SCHEMA_DDL } from '@repo/domain/src/gifts-db' import { PRESENCE_SCHEMA_DDL, PRESENCE_TTL_SECONDS, @@ -29,6 +30,12 @@ import { linkPlatformIdentity, PLATFORM_SCHEMA_DDL, } from '../../../../auth/src/platform-db' +import { + BALANCE_SCHEMA_DDL, + CurrencyType, + DEFAULT_STARTING_TOKENS, + getBalance, +} from '../../../../econ/src/balance-db' import { discordConfig, parseRoleIds, qualifies } from '../../discord' import { DOCUMENTED_SERVICES } from '../../docs' import { DISCORD_INVITE, ISSUES_URL, PRIVACY_EMAIL } from '../../links' @@ -98,6 +105,9 @@ beforeAll(async () => { for (const stmt of STAT_SCHEMA_DDL) await env.DB.prepare(stmt).run() // `audit_log` is owned by `api`; www files a row there for every ban and lift. for (const stmt of AUDIT_LOG_SCHEMA_DDL) await env.DB.prepare(stmt).run() + // `balance` and `received_gift` are owned by `econ`; a staff token gift writes both. + for (const stmt of BALANCE_SCHEMA_DDL) await env.DB.prepare(stmt).run() + for (const stmt of RECEIVED_GIFT_SCHEMA_DDL) await env.DB.prepare(stmt).run() }) // Web signup is open, but only behind the Turnstile check. These pin the closed door: @@ -685,6 +695,19 @@ it('refuses every staff endpoint without a token, and without a staff role', asy '/api/staff/players/1', '/api/staff/players/1/linked', ] + const writes = [ + '/api/staff/players/1/gift-tokens', + '/api/staff/players/1/username-changes', + '/api/staff/players/1/clear-password', + ] + for (const path of writes) { + expect((await SELF.fetch(`https://example.com${path}`, { method: 'POST' })).status).toBe(401) + const res = await SELF.fetch(`https://example.com${path}`, { + method: 'POST', + headers: { authorization: `Bearer ${await tokenFor(8101, ['gameClient'])}` }, + }) + expect(res.status).toBe(403) + } for (const path of paths) { expect((await SELF.fetch(`https://example.com${path}`)).status).toBe(401) @@ -1227,3 +1250,129 @@ it('serves standing bans most recently handed down first', async () => { const ordered = bans.map((b) => b.id).filter((id) => [older.id, newer.id].includes(id)) expect(ordered).toEqual([newer.id, older.id]) }) + +// ---- Staff player actions --------------------------------------------------- +// +// The staff card on a player's profile page. Each action is recorded on `audit_log` against +// the staffer who took it, with the target in `data`. + +/** The audit rows for one action against one target player, oldest first. */ +async function auditRows(action: string, playerId: number) { + const { results } = await env.DB.prepare( + `SELECT player_id, data FROM audit_log + WHERE action = ?1 AND json_extract(data, '$.playerId') = ?2 ORDER BY audit_log_id` + ) + .bind(action, playerId) + .all<{ player_id: number; data: string }>() + return results.map((r) => ({ actor: r.player_id, data: JSON.parse(r.data) })) +} + +// Credited on top of the signup grant (a never-touched balance is seeded first, as econ +// does), boxed, and announced: the balance frame carries the resulting TOTAL in the one +// ALL_PLATFORMS bucket, and the box frame is what shows the player it arrived. +it('gifts a player tokens in a gift box', async () => { + const hub = () => env.RECFLARE_NOTIFICATIONS_HUB.getByName('global') + await hub().fetch('http://do/all', { method: 'DELETE' }) + await updateAccount(env.DB, 8300, { username: 'Giftee' }) + + const res = await staffPost('/api/staff/players/8300/gift-tokens', 8110, { amount: 500 }) + expect(res.status).toBe(200) + const body = (await res.json()) as { balance: number; giftId: number } + expect(body.balance).toBe(DEFAULT_STARTING_TOKENS + 500) + await expect( + getBalance(env.DB, 8300, CurrencyType.RecCenterTokens, DEFAULT_STARTING_TOKENS) + ).resolves.toBe(DEFAULT_STARTING_TOKENS + 500) + + const gifts = await getPendingGifts(env.DB, 8300) + expect(gifts).toHaveLength(1) + expect(gifts[0]).toMatchObject({ + Id: body.giftId, + FromPlayerId: 1, + CurrencyType: CurrencyType.RecCenterTokens, + Currency: 500, + }) + + const frames = (await (await hub().fetch('http://do/all')).json()) as Array<{ + playerId: number + notificationType: number + data: Record + }> + expect(frames.map((f) => [f.playerId, f.notificationType])).toEqual([ + [8300, 61], + [8300, 31], + ]) + expect(frames[0].data).toEqual({ + Balance: DEFAULT_STARTING_TOKENS + 500, + CurrencyType: CurrencyType.RecCenterTokens, + Platform: -2, + }) + expect(frames[1].data).toMatchObject({ Id: body.giftId, Currency: 500, BalanceType: -2 }) + + expect(await auditRows('gift_tokens', 8300)).toEqual([ + { + actor: 8110, + data: { + playerId: 8300, + amount: 500, + balance: DEFAULT_STARTING_TOKENS + 500, + giftId: body.giftId, + }, + }, + ]) +}) + +it('refuses a token gift that is not a positive whole number, too large, or to nobody', async () => { + await updateAccount(env.DB, 8301, { username: 'NotGifted' }) + for (const amount of [0, -5, 1.5, 'lots', 10_001]) { + const res = await staffPost('/api/staff/players/8301/gift-tokens', 8110, { amount }) + expect(res.status).toBe(400) + } + expect((await staffPost('/api/staff/players/8399/gift-tokens', 8110, { amount: 5 })).status).toBe( + 404 + ) + expect(await getPendingGifts(env.DB, 8301)).toEqual([]) + expect(await auditRows('gift_tokens', 8301)).toEqual([]) +}) + +// An account that has never renamed itself stores no count and is read as the default 3, so +// the first grant makes it 4 rather than 1. +it('adds a username change on top of the default', async () => { + await updateAccount(env.DB, 8310, { username: 'Renamer' }) + + let res = await staffPost('/api/staff/players/8310/username-changes', 8110, {}) + expect(res.status).toBe(200) + expect(await res.json()).toEqual({ playerId: 8310, availableUsernameChanges: 4 }) + + await updateAccount(env.DB, 8310, { availableUsernameChanges: 0 }) + res = await staffPost('/api/staff/players/8310/username-changes', 8110, {}) + expect(await res.json()).toEqual({ playerId: 8310, availableUsernameChanges: 1 }) + + expect((await auditRows('add_username_change', 8310)).map((r) => r.data)).toEqual([ + { playerId: 8310, availableUsernameChanges: 4 }, + { playerId: 8310, availableUsernameChanges: 1 }, + ]) + expect((await staffPost('/api/staff/players/8398/username-changes', 8110, {})).status).toBe(404) +}) + +it('clears a player’s password so they can set a new one in game', async () => { + await updateAccount(env.DB, 8320, { username: 'Forgetful', passwordHash: 'salt:hash' }) + + let res = await staffPost('/api/staff/players/8320/clear-password', 8110, {}) + expect(res.status).toBe(200) + expect(await res.json()).toEqual({ playerId: 8320, hadPassword: true }) + const row = await env.DB.prepare( + "SELECT json_extract(data, '$.passwordHash') AS hash, json_extract(data, '$.username') AS username FROM account WHERE account_id = 8320" + ).first<{ hash: string | null; username: string }>() + // Only the hash goes: the rest of the account is untouched. + expect(row).toEqual({ hash: null, username: 'Forgetful' }) + + // Clearing it again is harmless, and says there was nothing to clear. + res = await staffPost('/api/staff/players/8320/clear-password', 8110, {}) + expect(await res.json()).toEqual({ playerId: 8320, hadPassword: false }) + + expect(await auditRows('clear_password', 8320)).toEqual([ + { actor: 8110, data: { playerId: 8320, hadPassword: true } }, + { actor: 8110, data: { playerId: 8320, hadPassword: false } }, + ]) + expect((await staffPost('/api/staff/players/8397/clear-password', 8110, {})).status).toBe(404) +}) diff --git a/apps/www/src/www.app.ts b/apps/www/src/www.app.ts index a6f0cd9..72d61ab 100644 --- a/apps/www/src/www.app.ts +++ b/apps/www/src/www.app.ts @@ -34,10 +34,13 @@ import { import { docsPage, fetchSpec } from './docs' import { privacyPage } from './privacy' import { + addUsernameChangeHandler, banReportHandler, bansInForceHandler, + clearPasswordHandler, createReportHandler, getReportHandler, + giftTokensHandler, linkedAccountsHandler, playerHistoryHandler, requireStaff, @@ -475,6 +478,10 @@ const app = new Hono() .get('/api/staff/bans', bansInForceHandler) .get('/api/staff/players/:id', playerHistoryHandler) .get('/api/staff/players/:id/linked', linkedAccountsHandler) + // The staff card on a player's profile page — account fixes the game has no endpoint for. + .post('/api/staff/players/:id/gift-tokens', giftTokensHandler) + .post('/api/staff/players/:id/username-changes', addUsernameChangeHandler) + .post('/api/staff/players/:id/clear-password', clearPasswordHandler) // ---- Privacy policy ----------------------------------------------------- // Server-rendered rather than a SPA route so the page has real text without diff --git a/packages/domain/src/accounts-db.ts b/packages/domain/src/accounts-db.ts index 92f2cfc..875f4e8 100644 --- a/packages/domain/src/accounts-db.ts +++ b/packages/domain/src/accounts-db.ts @@ -445,6 +445,52 @@ export async function getPasswordHash(db: D1Database, id: number): Promise { + const row = await db + .prepare( + "SELECT json_extract(data, '$.passwordHash') IS NOT NULL AS had FROM account WHERE account_id = ?1" + ) + .bind(id) + .first<{ had: number }>() + if (row === null) return null + if (row.had) { + await db + .prepare( + "UPDATE account SET data = json_remove(data, '$.passwordHash') WHERE account_id = ?1" + ) + .bind(id) + .run() + } + return row.had === 1 +} + +/** Username changes a fresh account starts with (until one has been consumed). */ +export const DEFAULT_USERNAME_CHANGES = 3 + +/** + * Grant the account one more username change, returning how many it now has — or null when + * no such account exists. An account that has never changed its name has no stored count and + * is read as {@link DEFAULT_USERNAME_CHANGES}, so the grant lands on top of that. One UPDATE, + * so two grants racing each other both count. + */ +export async function addUsernameChange(db: D1Database, id: number): Promise { + const row = await db + .prepare( + `UPDATE account SET data = json_set(data, '$.availableUsernameChanges', + COALESCE(json_extract(data, '$.availableUsernameChanges'), ?2) + 1) + WHERE account_id = ?1 + RETURNING json_extract(data, '$.availableUsernameChanges') AS remaining` + ) + .bind(id, DEFAULT_USERNAME_CHANGES) + .first<{ remaining: number }>() + return row?.remaining ?? null +} + /** Persist the account's password hash. Returns false when no such account exists. */ export async function setPasswordHash(db: D1Database, id: number, hash: string): Promise { const { meta } = await db