mirror of
https://github.com/djdevin/recflare.git
synced 2026-10-25 23:18:51 -07:00
[www] token giver 3000
This commit is contained in:
@@ -154,6 +154,11 @@ RECFLARE_DOMAIN=rec.example.com
|
|||||||
# raising it later does NOT top up existing players.
|
# raising it later does NOT top up existing players.
|
||||||
# RECFLARE_STARTING_TOKENS=10000
|
# 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
|
# 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
|
# 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.
|
# (RECFLARE_SECRETS_STORE above), alongside JWT_SECRET — not as vars, not as worker secrets.
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { useWorkersLogger } from 'workers-tagged-logger'
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
createAccount,
|
createAccount,
|
||||||
|
DEFAULT_USERNAME_CHANGES,
|
||||||
defaultAccount,
|
defaultAccount,
|
||||||
getAccount,
|
getAccount,
|
||||||
getAccountByUsername,
|
getAccountByUsername,
|
||||||
@@ -80,9 +81,6 @@ function unauthorized(c: Context<App>) {
|
|||||||
return c.body(null, 401)
|
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.
|
* Username-change result envelope: `{ success, error, value }`, always HTTP 200.
|
||||||
* On success `value` is the updated account; on error `error` carries the message
|
* On success `value` is the updated account; on error `error` carries the message
|
||||||
|
|||||||
@@ -1155,10 +1155,141 @@ function PlayerPage({
|
|||||||
<PhotoGrid photos={photos} />
|
<PhotoGrid photos={photos} />
|
||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
{/* Staff only, and cosmetic: hidden for everyone else, but every endpoint behind it
|
||||||
|
is gated by `requireStaff`. */}
|
||||||
|
{isAdmin() && <StaffPlayerActions account={account} navigate={navigate} />}
|
||||||
</main>
|
</main>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 (
|
||||||
|
<section className="card">
|
||||||
|
<h2>Staff</h2>
|
||||||
|
<form
|
||||||
|
onSubmit={(e) => {
|
||||||
|
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()}.`
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<label>
|
||||||
|
Gift tokens
|
||||||
|
<span className="staff-gift">
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
step={1}
|
||||||
|
inputMode="numeric"
|
||||||
|
value={tokens}
|
||||||
|
required
|
||||||
|
onChange={(e) => setTokens(e.target.value)}
|
||||||
|
/>
|
||||||
|
<button type="submit" disabled={gift.pending}>
|
||||||
|
{gift.pending ? 'Sending…' : 'Send'}
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
<span className="hint">
|
||||||
|
Arrives as a gift box, and is added to their balance right away.
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
{gift.error && <p className="error">{gift.error}</p>}
|
||||||
|
{gift.done && <p className="ok">{gift.done}</p>}
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div className="mod-filter-actions staff-actions">
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={usernameChange.pending}
|
||||||
|
onClick={() =>
|
||||||
|
void usernameChange.run(async () => {
|
||||||
|
const res = await call<{ availableUsernameChanges: number }>(
|
||||||
|
`${base}/username-changes`,
|
||||||
|
{ authed: true, method: 'POST' }
|
||||||
|
)
|
||||||
|
const n = res.availableUsernameChanges
|
||||||
|
return `@${account.username} now has ${n} username change${n === 1 ? '' : 's'}.`
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{usernameChange.pending ? 'Adding…' : 'Add username change'}
|
||||||
|
</button>
|
||||||
|
{confirmingClear ? (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={clearPassword.pending}
|
||||||
|
onClick={() =>
|
||||||
|
void clearPassword.run(async () => {
|
||||||
|
const res = await call<{ hadPassword: boolean }>(`${base}/clear-password`, {
|
||||||
|
authed: true,
|
||||||
|
method: 'POST',
|
||||||
|
})
|
||||||
|
setConfirmingClear(false)
|
||||||
|
return res.hadPassword
|
||||||
|
? `Password cleared. @${account.username} can set a new one in game.`
|
||||||
|
: `@${account.username} had no password set.`
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{clearPassword.pending ? 'Clearing…' : 'Confirm clear password'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="linkish"
|
||||||
|
disabled={clearPassword.pending}
|
||||||
|
onClick={() => setConfirmingClear(false)}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<button type="submit" onClick={() => setConfirmingClear(true)}>
|
||||||
|
Clear password
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
onClick={() =>
|
||||||
|
navigate(`/moderation/new?player=${encodeURIComponent(`@${account.username}`)}`)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Report
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p className="hint">
|
||||||
|
<strong>Clear password:</strong> Only use if the player has requested a password clear and
|
||||||
|
they can still login via Steam or Meta.
|
||||||
|
</p>
|
||||||
|
{usernameChange.error && <p className="error">{usernameChange.error}</p>}
|
||||||
|
{usernameChange.done && <p className="ok">{usernameChange.done}</p>}
|
||||||
|
{clearPassword.error && <p className="error">{clearPassword.error}</p>}
|
||||||
|
{clearPassword.done && <p className="ok">{clearPassword.done}</p>}
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export function App() {
|
export function App() {
|
||||||
// undefined = still checking the session; null = signed out.
|
// undefined = still checking the session; null = signed out.
|
||||||
const [account, setAccount] = useState<SelfAccount | null | undefined>(undefined)
|
const [account, setAccount] = useState<SelfAccount | null | undefined>(undefined)
|
||||||
|
|||||||
@@ -400,7 +400,12 @@ export function ModerationPage({
|
|||||||
// is almost always the first half of banning somebody, and the new report's
|
// 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
|
// id is then in the URL, so the ban is a real page rather than a modal that
|
||||||
// a reload would lose.
|
// a reload would lose.
|
||||||
<FileReport onFiled={openBan} />
|
// `?player=` pre-fills who it's against — the Report link on a player's
|
||||||
|
// profile page lands here with it.
|
||||||
|
<FileReport
|
||||||
|
initialPlayer={new URLSearchParams(search).get('player') ?? ''}
|
||||||
|
onFiled={openBan}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -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
|
* Hands the new report straight to the ban dialog, since filing one is almost always the
|
||||||
* first half of banning somebody.
|
* first half of banning somebody.
|
||||||
*/
|
*/
|
||||||
function FileReport({ onFiled }: { onFiled: (report: ReportRow) => void }) {
|
function FileReport({
|
||||||
const [player, setPlayer] = useState('')
|
initialPlayer,
|
||||||
|
onFiled,
|
||||||
|
}: {
|
||||||
|
initialPlayer: string
|
||||||
|
onFiled: (report: ReportRow) => void
|
||||||
|
}) {
|
||||||
|
const [player, setPlayer] = useState(initialPlayer)
|
||||||
const [category, setCategory] = useState(String(KickReportCategory.Misc))
|
const [category, setCategory] = useState(String(KickReportCategory.Misc))
|
||||||
const [details, setDetails] = useState('')
|
const [details, setDetails] = useState('')
|
||||||
const { pending, error, run } = useAction()
|
const { pending, error, run } = useAction()
|
||||||
|
|||||||
@@ -1556,6 +1556,23 @@ button[type='submit']:disabled {
|
|||||||
width: auto;
|
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
|
/* 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. */
|
it than a phone is wide, and wrapping the cells would make it unscannable. */
|
||||||
.mod-scroll {
|
.mod-scroll {
|
||||||
|
|||||||
+17
-2
@@ -18,10 +18,11 @@ export type Env = SharedHonoEnv & {
|
|||||||
*/
|
*/
|
||||||
DB: D1Database
|
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
|
* 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).
|
* 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<NotificationsHub>
|
RECFLARE_NOTIFICATIONS_HUB: DurableObjectNamespace<NotificationsHub>
|
||||||
/**
|
/**
|
||||||
@@ -36,6 +37,20 @@ export type Env = SharedHonoEnv & {
|
|||||||
* Undeclared in wrangler.jsonc, as it is there: an operator who wants it sets it.
|
* Undeclared in wrangler.jsonc, as it is there: an operator who wants it sets it.
|
||||||
*/
|
*/
|
||||||
BAN_EVASION_MATCH?: string
|
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
|
* 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`).
|
* IP survives the hop (see wrangler.jsonc and src/upstream.ts `postAuthForm`).
|
||||||
|
|||||||
+208
-3
@@ -1,5 +1,13 @@
|
|||||||
import { getPresences, movePlayerToDorm, writeAuditLog } from '@repo/domain'
|
import {
|
||||||
import { logger } from '@repo/hono-helpers'
|
addUsernameChange,
|
||||||
|
clearPasswordHash,
|
||||||
|
createGift,
|
||||||
|
getAccount,
|
||||||
|
getPresences,
|
||||||
|
movePlayerToDorm,
|
||||||
|
writeAuditLog,
|
||||||
|
} from '@repo/domain'
|
||||||
|
import { intVar, logger } from '@repo/hono-helpers'
|
||||||
import { validateAndGetAccountId, validateAndGetRoles } from '@repo/jwt'
|
import { validateAndGetAccountId, validateAndGetRoles } from '@repo/jwt'
|
||||||
|
|
||||||
// The report table and the ban policy over it, owned (and migrated) by the `api` worker.
|
// The report table and the ban policy over it, owned (and migrated) by the `api` worker.
|
||||||
@@ -19,13 +27,26 @@ import {
|
|||||||
searchReports,
|
searchReports,
|
||||||
} from '../../api/src/reports-db'
|
} from '../../api/src/reports-db'
|
||||||
import { getWarningsAgainst } from '../../api/src/warnings-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
|
// The notification ids and the kick frame's recovered shape, owned by `notify`. Both are
|
||||||
// imported as values/types with no runtime dependencies.
|
// imported as values/types with no runtime dependencies.
|
||||||
import { NotificationType } from '../../notify/src/notification-types'
|
import { NotificationType } from '../../notify/src/notification-types'
|
||||||
|
|
||||||
import type { Context, MiddlewareHandler } from 'hono'
|
import type { Context, MiddlewareHandler } from 'hono'
|
||||||
|
import type { GiftContent } from '@repo/domain'
|
||||||
import type { ReportRow, ReportSearch } from '../../api/src/reports-db'
|
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'
|
import type { App, Env } from './context'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -478,3 +499,187 @@ export async function linkedAccountsHandler(c: Context<App>) {
|
|||||||
const arms = armsFor(c.env)
|
const arms = armsFor(c.env)
|
||||||
return c.json({ playerId, arms, linked: await linkedAccounts(c.env.DB, playerId, arms) })
|
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<App>): 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<App>,
|
||||||
|
action: string,
|
||||||
|
data: Record<string, unknown>
|
||||||
|
): Promise<void> {
|
||||||
|
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<App>) {
|
||||||
|
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<App>) {
|
||||||
|
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<App>) {
|
||||||
|
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 })
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 { 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 { AUDIT_LOG_SCHEMA_DDL } from '@repo/domain/src/audit-db'
|
||||||
import { PlatformType } from '@repo/domain/src/enums'
|
import { PlatformType } from '@repo/domain/src/enums'
|
||||||
|
import { getPendingGifts, RECEIVED_GIFT_SCHEMA_DDL } from '@repo/domain/src/gifts-db'
|
||||||
import {
|
import {
|
||||||
PRESENCE_SCHEMA_DDL,
|
PRESENCE_SCHEMA_DDL,
|
||||||
PRESENCE_TTL_SECONDS,
|
PRESENCE_TTL_SECONDS,
|
||||||
@@ -29,6 +30,12 @@ import {
|
|||||||
linkPlatformIdentity,
|
linkPlatformIdentity,
|
||||||
PLATFORM_SCHEMA_DDL,
|
PLATFORM_SCHEMA_DDL,
|
||||||
} from '../../../../auth/src/platform-db'
|
} 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 { discordConfig, parseRoleIds, qualifies } from '../../discord'
|
||||||
import { DOCUMENTED_SERVICES } from '../../docs'
|
import { DOCUMENTED_SERVICES } from '../../docs'
|
||||||
import { DISCORD_INVITE, ISSUES_URL, PRIVACY_EMAIL } from '../../links'
|
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()
|
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.
|
// `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()
|
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:
|
// 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',
|
||||||
'/api/staff/players/1/linked',
|
'/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) {
|
for (const path of paths) {
|
||||||
expect((await SELF.fetch(`https://example.com${path}`)).status).toBe(401)
|
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))
|
const ordered = bans.map((b) => b.id).filter((id) => [older.id, newer.id].includes(id))
|
||||||
expect(ordered).toEqual([newer.id, older.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<string, unknown>
|
||||||
|
}>
|
||||||
|
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)
|
||||||
|
})
|
||||||
|
|||||||
@@ -34,10 +34,13 @@ import {
|
|||||||
import { docsPage, fetchSpec } from './docs'
|
import { docsPage, fetchSpec } from './docs'
|
||||||
import { privacyPage } from './privacy'
|
import { privacyPage } from './privacy'
|
||||||
import {
|
import {
|
||||||
|
addUsernameChangeHandler,
|
||||||
banReportHandler,
|
banReportHandler,
|
||||||
bansInForceHandler,
|
bansInForceHandler,
|
||||||
|
clearPasswordHandler,
|
||||||
createReportHandler,
|
createReportHandler,
|
||||||
getReportHandler,
|
getReportHandler,
|
||||||
|
giftTokensHandler,
|
||||||
linkedAccountsHandler,
|
linkedAccountsHandler,
|
||||||
playerHistoryHandler,
|
playerHistoryHandler,
|
||||||
requireStaff,
|
requireStaff,
|
||||||
@@ -475,6 +478,10 @@ const app = new Hono<App>()
|
|||||||
.get('/api/staff/bans', bansInForceHandler)
|
.get('/api/staff/bans', bansInForceHandler)
|
||||||
.get('/api/staff/players/:id', playerHistoryHandler)
|
.get('/api/staff/players/:id', playerHistoryHandler)
|
||||||
.get('/api/staff/players/:id/linked', linkedAccountsHandler)
|
.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 -----------------------------------------------------
|
// ---- Privacy policy -----------------------------------------------------
|
||||||
// Server-rendered rather than a SPA route so the page has real text without
|
// Server-rendered rather than a SPA route so the page has real text without
|
||||||
|
|||||||
@@ -445,6 +445,52 @@ export async function getPasswordHash(db: D1Database, id: number): Promise<strin
|
|||||||
return row?.hash ?? null
|
return row?.hash ?? null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove the account's password hash, leaving it with no credential login — which the game
|
||||||
|
* reads as "set one", so a player locked out of their password can choose a new one in game.
|
||||||
|
* Returns null when no such account exists, else whether it HAD a password to remove.
|
||||||
|
*/
|
||||||
|
export async function clearPasswordHash(db: D1Database, id: number): Promise<boolean | null> {
|
||||||
|
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<number | null> {
|
||||||
|
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. */
|
/** Persist the account's password hash. Returns false when no such account exists. */
|
||||||
export async function setPasswordHash(db: D1Database, id: number, hash: string): Promise<boolean> {
|
export async function setPasswordHash(db: D1Database, id: number, hash: string): Promise<boolean> {
|
||||||
const { meta } = await db
|
const { meta } = await db
|
||||||
|
|||||||
Reference in New Issue
Block a user