mirror of
https://github.com/djdevin/recflare.git
synced 2026-10-27 16:08:58 -07:00
[www] tweak permissions
This commit is contained in:
+128
-122
@@ -20,6 +20,7 @@ import {
|
|||||||
call,
|
call,
|
||||||
hasToken,
|
hasToken,
|
||||||
isAdmin,
|
isAdmin,
|
||||||
|
isDeveloper,
|
||||||
setHosts,
|
setHosts,
|
||||||
setToken,
|
setToken,
|
||||||
useAction,
|
useAction,
|
||||||
@@ -1184,129 +1185,134 @@ function StaffPlayerActions({ account, navigate }: { account: PublicAccount; nav
|
|||||||
return (
|
return (
|
||||||
<section className="card">
|
<section className="card">
|
||||||
<h2>Staff</h2>
|
<h2>Staff</h2>
|
||||||
<form
|
{/* The gifts are developer-only; www refuses them to moderators. */}
|
||||||
onSubmit={(e) => {
|
{isDeveloper() && (
|
||||||
e.preventDefault()
|
<>
|
||||||
void gift.run(async () => {
|
<form
|
||||||
const amount = Number(tokens)
|
onSubmit={(e) => {
|
||||||
const res = await call<{ balance: number }>(`${base}/gift-tokens`, {
|
e.preventDefault()
|
||||||
authed: true,
|
void gift.run(async () => {
|
||||||
json: { amount },
|
const amount = Number(tokens)
|
||||||
})
|
const res = await call<{ balance: number }>(`${base}/gift-tokens`, {
|
||||||
setTokens('')
|
authed: true,
|
||||||
const what =
|
json: { amount },
|
||||||
amount < 0
|
})
|
||||||
? `Took ${Math.abs(amount).toLocaleString()} tokens back`
|
setTokens('')
|
||||||
: amount === 0
|
const what =
|
||||||
? 'Sent an empty box'
|
amount < 0
|
||||||
: `Sent ${amount.toLocaleString()} tokens`
|
? `Took ${Math.abs(amount).toLocaleString()} tokens back`
|
||||||
return `${what}. @${account.username} now has ${res.balance.toLocaleString()}.`
|
: amount === 0
|
||||||
})
|
? 'Sent an empty box'
|
||||||
}}
|
: `Sent ${amount.toLocaleString()} tokens`
|
||||||
>
|
return `${what}. @${account.username} now has ${res.balance.toLocaleString()}.`
|
||||||
<label>
|
})
|
||||||
Gift tokens
|
}}
|
||||||
<span className="staff-gift">
|
>
|
||||||
<input
|
<label>
|
||||||
type="number"
|
Gift tokens
|
||||||
step={1}
|
<span className="staff-gift">
|
||||||
value={tokens}
|
<input
|
||||||
required
|
type="number"
|
||||||
onChange={(e) => setTokens(e.target.value)}
|
step={1}
|
||||||
/>
|
value={tokens}
|
||||||
<button type="submit" disabled={gift.pending}>
|
required
|
||||||
{gift.pending ? 'Sending…' : 'Send'}
|
onChange={(e) => setTokens(e.target.value)}
|
||||||
</button>
|
/>
|
||||||
</span>
|
<button type="submit" disabled={gift.pending}>
|
||||||
<span className="hint">
|
{gift.pending ? 'Sending…' : 'Send'}
|
||||||
Arrives as a gift box, and is added to their balance right away. A negative amount takes
|
</button>
|
||||||
tokens back instead — it still sends a box, and it can't take more than they have.
|
</span>
|
||||||
Zero sends an empty box and moves nothing.
|
<span className="hint">
|
||||||
</span>
|
Arrives as a gift box, and is added to their balance right away. A negative amount
|
||||||
</label>
|
takes tokens back instead — it still sends a box, and it can't take more than
|
||||||
{gift.error && <p className="error">{gift.error}</p>}
|
they have. Zero sends an empty box and moves nothing.
|
||||||
{gift.done && <p className="ok">{gift.done}</p>}
|
</span>
|
||||||
</form>
|
</label>
|
||||||
|
{gift.error && <p className="error">{gift.error}</p>}
|
||||||
|
{gift.done && <p className="ok">{gift.done}</p>}
|
||||||
|
</form>
|
||||||
|
|
||||||
<form
|
<form
|
||||||
onSubmit={(e) => {
|
onSubmit={(e) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
void xpGift.run(async () => {
|
void xpGift.run(async () => {
|
||||||
const amount = Number(xp)
|
const amount = Number(xp)
|
||||||
const res = await call<{ level: number; levelsGained: number }>(`${base}/gift-xp`, {
|
const res = await call<{ level: number; levelsGained: number }>(`${base}/gift-xp`, {
|
||||||
authed: true,
|
authed: true,
|
||||||
json: { amount },
|
json: { amount },
|
||||||
})
|
})
|
||||||
setXp('')
|
setXp('')
|
||||||
const levels =
|
const levels =
|
||||||
res.levelsGained > 0
|
res.levelsGained > 0
|
||||||
? ` They went up ${res.levelsGained} level${res.levelsGained === 1 ? '' : 's'}, to ${res.level}.`
|
? ` They went up ${res.levelsGained} level${res.levelsGained === 1 ? '' : 's'}, to ${res.level}.`
|
||||||
: ` They're level ${res.level}.`
|
: ` They're level ${res.level}.`
|
||||||
return `Sent ${amount.toLocaleString()} XP.${levels}`
|
return `Sent ${amount.toLocaleString()} XP.${levels}`
|
||||||
})
|
})
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<label>
|
<label>
|
||||||
Gift XP
|
Gift XP
|
||||||
<span className="staff-gift">
|
<span className="staff-gift">
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
min={1}
|
min={1}
|
||||||
step={1}
|
step={1}
|
||||||
inputMode="numeric"
|
inputMode="numeric"
|
||||||
value={xp}
|
value={xp}
|
||||||
required
|
required
|
||||||
onChange={(e) => setXp(e.target.value)}
|
onChange={(e) => setXp(e.target.value)}
|
||||||
/>
|
/>
|
||||||
<button type="submit" disabled={xpGift.pending}>
|
<button type="submit" disabled={xpGift.pending}>
|
||||||
{xpGift.pending ? 'Sending…' : 'Send'}
|
{xpGift.pending ? 'Sending…' : 'Send'}
|
||||||
</button>
|
</button>
|
||||||
</span>
|
</span>
|
||||||
<span className="hint">
|
<span className="hint">
|
||||||
Arrives as a gift box. Levels it crosses count, but don't pay their level-up
|
Arrives as a gift box. Levels it crosses count, but don't pay their level-up
|
||||||
rewards.
|
rewards.
|
||||||
</span>
|
</span>
|
||||||
</label>
|
</label>
|
||||||
{xpGift.error && <p className="error">{xpGift.error}</p>}
|
{xpGift.error && <p className="error">{xpGift.error}</p>}
|
||||||
{xpGift.done && <p className="ok">{xpGift.done}</p>}
|
{xpGift.done && <p className="ok">{xpGift.done}</p>}
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<form
|
<form
|
||||||
onSubmit={(e) => {
|
onSubmit={(e) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
void customGift.run(async () => {
|
void customGift.run(async () => {
|
||||||
const res = await call<{ name: string }>(`${base}/gift-custom-item`, {
|
const res = await call<{ name: string }>(`${base}/gift-custom-item`, {
|
||||||
authed: true,
|
authed: true,
|
||||||
json: { customAvatarItemId: customItemId.trim() },
|
json: { customAvatarItemId: customItemId.trim() },
|
||||||
})
|
})
|
||||||
setCustomItemId('')
|
setCustomItemId('')
|
||||||
return `Sent “${res.name}” to @${account.username}.`
|
return `Sent “${res.name}” to @${account.username}.`
|
||||||
})
|
})
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<label>
|
<label>
|
||||||
Gift custom item
|
Gift custom item
|
||||||
<span className="staff-gift">
|
<span className="staff-gift">
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={customItemId}
|
value={customItemId}
|
||||||
placeholder="Custom avatar item id"
|
placeholder="Custom avatar item id"
|
||||||
spellCheck={false}
|
spellCheck={false}
|
||||||
required
|
required
|
||||||
onChange={(e) => setCustomItemId(e.target.value)}
|
onChange={(e) => setCustomItemId(e.target.value)}
|
||||||
/>
|
/>
|
||||||
<button type="submit" disabled={customGift.pending}>
|
<button type="submit" disabled={customGift.pending}>
|
||||||
{customGift.pending ? 'Sending…' : 'Send'}
|
{customGift.pending ? 'Sending…' : 'Send'}
|
||||||
</button>
|
</button>
|
||||||
</span>
|
</span>
|
||||||
<span className="hint">
|
<span className="hint">
|
||||||
Arrives as a gift box, as if bought from the store. Free to them, and the creator
|
Arrives as a gift box, as if bought from the store. Free to them, and the creator
|
||||||
isn't paid.
|
isn't paid.
|
||||||
</span>
|
</span>
|
||||||
</label>
|
</label>
|
||||||
{customGift.error && <p className="error">{customGift.error}</p>}
|
{customGift.error && <p className="error">{customGift.error}</p>}
|
||||||
{customGift.done && <p className="ok">{customGift.done}</p>}
|
{customGift.done && <p className="ok">{customGift.done}</p>}
|
||||||
</form>
|
</form>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="mod-filter-actions staff-actions">
|
<div className="mod-filter-actions staff-actions">
|
||||||
<button
|
<button
|
||||||
@@ -2161,7 +2167,7 @@ function PublicRoomView({
|
|||||||
role itself on the DELETE. */}
|
role itself on the DELETE. */}
|
||||||
{isAdmin() && (
|
{isAdmin() && (
|
||||||
<>
|
<>
|
||||||
<StaffRoomTokens room={room} />
|
{isDeveloper() && <StaffRoomTokens room={room} />}
|
||||||
<StaffTakedown room={room} onTakenDown={onTakenDown} />
|
<StaffTakedown room={room} onTakenDown={onTakenDown} />
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
+23
-10
@@ -78,22 +78,35 @@ export function where(): Hosts {
|
|||||||
*/
|
*/
|
||||||
const ADMIN_ROLES = new Set(['developer', 'moderator'])
|
const ADMIN_ROLES = new Set(['developer', 'moderator'])
|
||||||
|
|
||||||
|
/** The session token's `role` claim, decoded WITHOUT verifying it. Malformed reads as none. */
|
||||||
|
function tokenRoles(): string[] {
|
||||||
|
const payload = token?.split('.')[1]
|
||||||
|
if (!payload) return []
|
||||||
|
try {
|
||||||
|
const b64 = payload.replace(/-/g, '+').replace(/_/g, '/')
|
||||||
|
const padded = b64.padEnd(b64.length + ((4 - (b64.length % 4)) % 4), '=')
|
||||||
|
const claims = JSON.parse(atob(padded)) as { role?: unknown }
|
||||||
|
return Array.isArray(claims.role) ? (claims.role as string[]) : []
|
||||||
|
} catch {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Whether the session token carries an admin role. Decodes the `role` claim WITHOUT
|
* Whether the session token carries an admin role. Decodes the `role` claim WITHOUT
|
||||||
* verifying it — a page holds no signing key, and faking one here only reveals buttons
|
* verifying it — a page holds no signing key, and faking one here only reveals buttons
|
||||||
* whose endpoints reject the same token. A malformed token reads as "not admin".
|
* whose endpoints reject the same token. A malformed token reads as "not admin".
|
||||||
*/
|
*/
|
||||||
export function isAdmin(): boolean {
|
export function isAdmin(): boolean {
|
||||||
const payload = token?.split('.')[1]
|
return tokenRoles().some((r) => ADMIN_ROLES.has(r))
|
||||||
if (!payload) return false
|
}
|
||||||
try {
|
|
||||||
const b64 = payload.replace(/-/g, '+').replace(/_/g, '/')
|
/**
|
||||||
const padded = b64.padEnd(b64.length + ((4 - (b64.length % 4)) % 4), '=')
|
* Whether the session token carries the `developer` role — the staff gifts are narrowed to
|
||||||
const claims = JSON.parse(atob(padded)) as { role?: unknown }
|
* it (www's `requireDeveloper`). Cosmetic in the same way as {@link isAdmin}.
|
||||||
return Array.isArray(claims.role) && claims.role.some((r) => ADMIN_ROLES.has(r as string))
|
*/
|
||||||
} catch {
|
export function isDeveloper(): boolean {
|
||||||
return false
|
return tokenRoles().includes('developer')
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -135,6 +135,11 @@ export type Variables = SharedHonoVariables & {
|
|||||||
* same token a second time — and so a handler can only get at it behind that gate.
|
* same token a second time — and so a handler can only get at it behind that gate.
|
||||||
*/
|
*/
|
||||||
staffId: number
|
staffId: number
|
||||||
|
/**
|
||||||
|
* The roles on that same token, set beside `staffId` so `requireDeveloper` can narrow a
|
||||||
|
* staff route to developers without verifying the token again.
|
||||||
|
*/
|
||||||
|
staffRoles: string[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface App extends HonoApp {
|
export interface App extends HonoApp {
|
||||||
|
|||||||
+14
-1
@@ -75,7 +75,8 @@ import type { App, Env } from './context'
|
|||||||
* `developer`, the operator-granted roles `auth` stamps from an account's
|
* `developer`, the operator-granted roles `auth` stamps from an account's
|
||||||
* isModerator/isDeveloper flags (see the admin CLI's `grant-moderator` /
|
* 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
|
* `grant-developer`). The SPA's `isAdmin()` gate only decides what to SHOW; this is the
|
||||||
* one that decides anything.
|
* one that decides anything. The gifts are narrower still — developers only, via
|
||||||
|
* {@link requireDeveloper}.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -111,6 +112,18 @@ export const requireStaff: MiddlewareHandler<App> = async (c, next) => {
|
|||||||
// moderator, and every action here is recorded against one.
|
// moderator, and every action here is recorded against one.
|
||||||
if (accountId === null) return c.json({ error: 'Unauthorized' }, 401)
|
if (accountId === null) return c.json({ error: 'Unauthorized' }, 401)
|
||||||
c.set('staffId', accountId)
|
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<App> = async (c, next) => {
|
||||||
|
if (!c.get('staffRoles').includes('developer')) return c.json({ error: 'Forbidden' }, 403)
|
||||||
await next()
|
await next()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -74,13 +74,6 @@ const TEST_JWT_SECRET = 'test-jwt-secret'
|
|||||||
const tokenFor = (accountId: number, roles: string[] = []): Promise<string> =>
|
const tokenFor = (accountId: number, roles: string[] = []): Promise<string> =>
|
||||||
generateToken(String(accountId), '', 4, TEST_JWT_SECRET, roles)
|
generateToken(String(accountId), '', 4, TEST_JWT_SECRET, roles)
|
||||||
|
|
||||||
/**
|
|
||||||
* A STAFF token — one carrying the `moderator` role, as `auth` stamps it from an
|
|
||||||
* account's isModerator flag. The whole moderation surface is gated on this and nothing
|
|
||||||
* else, so a test that forgets it is testing the 403.
|
|
||||||
*/
|
|
||||||
const staffToken = (accountId: number): Promise<string> => tokenFor(accountId, ['moderator'])
|
|
||||||
|
|
||||||
// A Discord app that is HALF configured: credentials seeded below, but wrangler.jsonc
|
// A Discord app that is HALF configured: credentials seeded below, but wrangler.jsonc
|
||||||
// leaves DISCORD_GUILD_ID / DISCORD_BENEFITS_ROLE_IDS empty. This is deliberately the most
|
// leaves DISCORD_GUILD_ID / DISCORD_BENEFITS_ROLE_IDS empty. This is deliberately the most
|
||||||
// dangerous half — an operator who registers an app and stops has something that can sign
|
// dangerous half — an operator who registers an app and stops has something that can sign
|
||||||
@@ -686,18 +679,27 @@ async function staffGet(path: string, accountId: number, roles: string[] = ['mod
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/** POST a JSON body to a staff endpoint as a moderator. */
|
/** POST a JSON body to a staff endpoint as a moderator (or whatever `roles` names). */
|
||||||
async function staffPost(path: string, accountId: number, body: unknown) {
|
async function staffPost(
|
||||||
|
path: string,
|
||||||
|
accountId: number,
|
||||||
|
body: unknown,
|
||||||
|
roles: string[] = ['moderator']
|
||||||
|
) {
|
||||||
return SELF.fetch(`https://example.com${path}`, {
|
return SELF.fetch(`https://example.com${path}`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
authorization: `Bearer ${await staffToken(accountId)}`,
|
authorization: `Bearer ${await tokenFor(accountId, roles)}`,
|
||||||
'content-type': 'application/json',
|
'content-type': 'application/json',
|
||||||
},
|
},
|
||||||
body: JSON.stringify(body),
|
body: JSON.stringify(body),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** POST a JSON body to a developer-only staff endpoint (the gifts) as a developer. */
|
||||||
|
const devPost = (path: string, accountId: number, body: unknown) =>
|
||||||
|
staffPost(path, accountId, body, ['developer'])
|
||||||
|
|
||||||
// Two refusals, not one. A 401 is an expired session — the SPA drops the token and sends
|
// Two refusals, not one. A 401 is an expired session — the SPA drops the token and sends
|
||||||
// the player to sign in — while a 403 is a signed-in player who simply isn't staff, and
|
// the player to sign in — while a 403 is a signed-in player who simply isn't staff, and
|
||||||
// looping THEM through a sign-in would change nothing. Every route is checked, because
|
// looping THEM through a sign-in would change nothing. Every route is checked, because
|
||||||
@@ -728,6 +730,11 @@ it('refuses every staff endpoint without a token, and without a staff role', asy
|
|||||||
expect(res.status).toBe(403)
|
expect(res.status).toBe(403)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The gifts are narrower: a moderator is staff, but not a developer.
|
||||||
|
for (const path of writes.filter((p) => p.includes('/gift-'))) {
|
||||||
|
expect((await staffPost(path, 8101, { amount: 1 })).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)
|
||||||
// A valid token whose `role` claim is a plain player's.
|
// A valid token whose `role` claim is a plain player's.
|
||||||
@@ -1294,7 +1301,7 @@ it('gifts a player tokens in a gift box', async () => {
|
|||||||
await hub().fetch('http://do/all', { method: 'DELETE' })
|
await hub().fetch('http://do/all', { method: 'DELETE' })
|
||||||
await updateAccount(env.DB, 8300, { username: 'Giftee' })
|
await updateAccount(env.DB, 8300, { username: 'Giftee' })
|
||||||
|
|
||||||
const res = await staffPost('/api/staff/players/8300/gift-tokens', 8110, { amount: 500 })
|
const res = await devPost('/api/staff/players/8300/gift-tokens', 8110, { amount: 500 })
|
||||||
expect(res.status).toBe(200)
|
expect(res.status).toBe(200)
|
||||||
const body = (await res.json()) as { balance: number; giftId: number }
|
const body = (await res.json()) as { balance: number; giftId: number }
|
||||||
expect(body.balance).toBe(DEFAULT_STARTING_TOKENS + 500)
|
expect(body.balance).toBe(DEFAULT_STARTING_TOKENS + 500)
|
||||||
@@ -1345,10 +1352,10 @@ it('refuses a token gift that is not a whole number, too large, or to nobody', a
|
|||||||
// A fraction and a word are not amounts, and the cap binds in both directions, since a
|
// A fraction and a word are not amounts, and the cap binds in both directions, since a
|
||||||
// negative gift takes tokens away. Zero is allowed — see the empty box below.
|
// negative gift takes tokens away. Zero is allowed — see the empty box below.
|
||||||
for (const amount of [1.5, 'lots', 10_001, -10_001]) {
|
for (const amount of [1.5, 'lots', 10_001, -10_001]) {
|
||||||
const res = await staffPost('/api/staff/players/8301/gift-tokens', 8110, { amount })
|
const res = await devPost('/api/staff/players/8301/gift-tokens', 8110, { amount })
|
||||||
expect(res.status).toBe(400)
|
expect(res.status).toBe(400)
|
||||||
}
|
}
|
||||||
expect((await staffPost('/api/staff/players/8399/gift-tokens', 8110, { amount: 5 })).status).toBe(
|
expect((await devPost('/api/staff/players/8399/gift-tokens', 8110, { amount: 5 })).status).toBe(
|
||||||
404
|
404
|
||||||
)
|
)
|
||||||
expect(await getPendingGifts(env.DB, 8301)).toEqual([])
|
expect(await getPendingGifts(env.DB, 8301)).toEqual([])
|
||||||
@@ -1363,7 +1370,7 @@ it('takes tokens back on a negative gift, and cannot overdraw', async () => {
|
|||||||
await hub().fetch('http://do/all', { method: 'DELETE' })
|
await hub().fetch('http://do/all', { method: 'DELETE' })
|
||||||
await updateAccount(env.DB, 8360, { username: 'Indebted' })
|
await updateAccount(env.DB, 8360, { username: 'Indebted' })
|
||||||
|
|
||||||
const res = await staffPost('/api/staff/players/8360/gift-tokens', 8110, { amount: -400 })
|
const res = await devPost('/api/staff/players/8360/gift-tokens', 8110, { amount: -400 })
|
||||||
expect(res.status).toBe(200)
|
expect(res.status).toBe(200)
|
||||||
const body = (await res.json()) as { balance: number; giftId: number }
|
const body = (await res.json()) as { balance: number; giftId: number }
|
||||||
expect(body.balance).toBe(DEFAULT_STARTING_TOKENS - 400)
|
expect(body.balance).toBe(DEFAULT_STARTING_TOKENS - 400)
|
||||||
@@ -1397,7 +1404,7 @@ it('takes tokens back on a negative gift, and cannot overdraw', async () => {
|
|||||||
// More than they hold changes nothing: no debit, no box, no audit row beyond the first.
|
// More than they hold changes nothing: no debit, no box, no audit row beyond the first.
|
||||||
// Under the cap, so this is the BALANCE refusing it and not the typo guard (they hold
|
// Under the cap, so this is the BALANCE refusing it and not the typo guard (they hold
|
||||||
// DEFAULT_STARTING_TOKENS - 400 by now).
|
// DEFAULT_STARTING_TOKENS - 400 by now).
|
||||||
const tooMuch = await staffPost('/api/staff/players/8360/gift-tokens', 8110, {
|
const tooMuch = await devPost('/api/staff/players/8360/gift-tokens', 8110, {
|
||||||
amount: -(DEFAULT_STARTING_TOKENS - 100),
|
amount: -(DEFAULT_STARTING_TOKENS - 100),
|
||||||
})
|
})
|
||||||
expect(tooMuch.status).toBe(400)
|
expect(tooMuch.status).toBe(400)
|
||||||
@@ -1427,7 +1434,7 @@ it('sends tokens to everyone in a room, across its instances', async () => {
|
|||||||
await inRoom(8372, 7700, 77102)
|
await inRoom(8372, 7700, 77102)
|
||||||
await inRoom(8373, 7701, 77103)
|
await inRoom(8373, 7701, 77103)
|
||||||
|
|
||||||
const res = await staffPost('/api/staff/rooms/7700/gift-tokens', 8110, { amount: 250 })
|
const res = await devPost('/api/staff/rooms/7700/gift-tokens', 8110, { amount: 250 })
|
||||||
expect(res.status).toBe(200)
|
expect(res.status).toBe(200)
|
||||||
expect(await res.json()).toEqual({
|
expect(await res.json()).toEqual({
|
||||||
roomId: 7700,
|
roomId: 7700,
|
||||||
@@ -1462,18 +1469,18 @@ it('sends tokens to everyone in a room, across its instances', async () => {
|
|||||||
// An empty room is a 404 rather than a silent success: a staffer who pressed the button
|
// An empty room is a 404 rather than a silent success: a staffer who pressed the button
|
||||||
// expects somebody to have been paid.
|
// expects somebody to have been paid.
|
||||||
it('refuses a room gift with nobody in the room, and a bad amount', async () => {
|
it('refuses a room gift with nobody in the room, and a bad amount', async () => {
|
||||||
expect((await staffPost('/api/staff/rooms/7799/gift-tokens', 8110, { amount: 10 })).status).toBe(
|
expect((await devPost('/api/staff/rooms/7799/gift-tokens', 8110, { amount: 10 })).status).toBe(
|
||||||
404
|
404
|
||||||
)
|
)
|
||||||
expect(
|
expect(
|
||||||
(await staffPost('/api/staff/rooms/7700/gift-tokens', 8110, { amount: 10_001 })).status
|
(await devPost('/api/staff/rooms/7700/gift-tokens', 8110, { amount: 10_001 })).status
|
||||||
).toBe(400)
|
).toBe(400)
|
||||||
})
|
})
|
||||||
|
|
||||||
// Zero is a real gift here: nothing moves, and the player gets an empty box.
|
// Zero is a real gift here: nothing moves, and the player gets an empty box.
|
||||||
it('sends an empty box on a zero token gift', async () => {
|
it('sends an empty box on a zero token gift', async () => {
|
||||||
await updateAccount(env.DB, 8361, { username: 'Emptyhanded' })
|
await updateAccount(env.DB, 8361, { username: 'Emptyhanded' })
|
||||||
const res = await staffPost('/api/staff/players/8361/gift-tokens', 8110, { amount: 0 })
|
const res = await devPost('/api/staff/players/8361/gift-tokens', 8110, { amount: 0 })
|
||||||
expect(res.status).toBe(200)
|
expect(res.status).toBe(200)
|
||||||
const body = (await res.json()) as { balance: number; giftId: number }
|
const body = (await res.json()) as { balance: number; giftId: number }
|
||||||
expect(body.balance).toBe(DEFAULT_STARTING_TOKENS)
|
expect(body.balance).toBe(DEFAULT_STARTING_TOKENS)
|
||||||
@@ -1551,7 +1558,7 @@ it('gifts a player a custom item in a gift box', async () => {
|
|||||||
const item = await seedCustomItem('0a1b2c3d-0000-4000-8000-000000000001', 8331)
|
const item = await seedCustomItem('0a1b2c3d-0000-4000-8000-000000000001', 8331)
|
||||||
|
|
||||||
// Upper-cased on purpose: a GUID's case is not part of its identity.
|
// Upper-cased on purpose: a GUID's case is not part of its identity.
|
||||||
const res = await staffPost('/api/staff/players/8330/gift-custom-item', 8110, {
|
const res = await devPost('/api/staff/players/8330/gift-custom-item', 8110, {
|
||||||
customAvatarItemId: item.CustomAvatarItemId.toUpperCase(),
|
customAvatarItemId: item.CustomAvatarItemId.toUpperCase(),
|
||||||
})
|
})
|
||||||
expect(res.status).toBe(200)
|
expect(res.status).toBe(200)
|
||||||
@@ -1608,7 +1615,7 @@ it('refuses a custom item gift the store would refuse', async () => {
|
|||||||
await grantCustomAvatarItem(env.DB, 8340, published.CustomAvatarItemId)
|
await grantCustomAvatarItem(env.DB, 8340, published.CustomAvatarItemId)
|
||||||
|
|
||||||
const gift = (customAvatarItemId: unknown, playerId = 8340) =>
|
const gift = (customAvatarItemId: unknown, playerId = 8340) =>
|
||||||
staffPost(`/api/staff/players/${playerId}/gift-custom-item`, 8110, { customAvatarItemId })
|
devPost(`/api/staff/players/${playerId}/gift-custom-item`, 8110, { customAvatarItemId })
|
||||||
|
|
||||||
expect((await gift('')).status).toBe(400)
|
expect((await gift('')).status).toBe(400)
|
||||||
expect((await gift('no-such-item')).status).toBe(404)
|
expect((await gift('no-such-item')).status).toBe(404)
|
||||||
@@ -1629,7 +1636,7 @@ it('gifts a player XP in a gift box', async () => {
|
|||||||
await hub().fetch('http://do/all', { method: 'DELETE' })
|
await hub().fetch('http://do/all', { method: 'DELETE' })
|
||||||
await updateAccount(env.DB, 8350, { username: 'Climber' })
|
await updateAccount(env.DB, 8350, { username: 'Climber' })
|
||||||
|
|
||||||
const res = await staffPost('/api/staff/players/8350/gift-xp', 8110, { amount: 25 })
|
const res = await devPost('/api/staff/players/8350/gift-xp', 8110, { amount: 25 })
|
||||||
expect(res.status).toBe(200)
|
expect(res.status).toBe(200)
|
||||||
const body = (await res.json()) as { giftId: number }
|
const body = (await res.json()) as { giftId: number }
|
||||||
expect(body).toMatchObject({ level: 3, xp: 5, levelsGained: 2 })
|
expect(body).toMatchObject({ level: 3, xp: 5, levelsGained: 2 })
|
||||||
@@ -1668,9 +1675,9 @@ it('gifts a player XP in a gift box', async () => {
|
|||||||
it('refuses an XP gift that is not a positive whole number, too large, or to nobody', async () => {
|
it('refuses an XP gift that is not a positive whole number, too large, or to nobody', async () => {
|
||||||
await updateAccount(env.DB, 8351, { username: 'Stuck' })
|
await updateAccount(env.DB, 8351, { username: 'Stuck' })
|
||||||
for (const amount of [0, -5, 1.5, 'lots', 101]) {
|
for (const amount of [0, -5, 1.5, 'lots', 101]) {
|
||||||
expect((await staffPost('/api/staff/players/8351/gift-xp', 8110, { amount })).status).toBe(400)
|
expect((await devPost('/api/staff/players/8351/gift-xp', 8110, { amount })).status).toBe(400)
|
||||||
}
|
}
|
||||||
expect((await staffPost('/api/staff/players/8395/gift-xp', 8110, { amount: 5 })).status).toBe(404)
|
expect((await devPost('/api/staff/players/8395/gift-xp', 8110, { amount: 5 })).status).toBe(404)
|
||||||
await expect(getProgression(env.DB, 8351)).resolves.toEqual({ PlayerId: 8351, Level: 1, XP: 0 })
|
await expect(getProgression(env.DB, 8351)).resolves.toEqual({ PlayerId: 8351, Level: 1, XP: 0 })
|
||||||
expect(await auditRows('gift_xp', 8351)).toEqual([])
|
expect(await auditRows('gift_xp', 8351)).toEqual([])
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ import {
|
|||||||
giftXpHandler,
|
giftXpHandler,
|
||||||
linkedAccountsHandler,
|
linkedAccountsHandler,
|
||||||
playerHistoryHandler,
|
playerHistoryHandler,
|
||||||
|
requireDeveloper,
|
||||||
requireStaff,
|
requireStaff,
|
||||||
searchReportsHandler,
|
searchReportsHandler,
|
||||||
topReportedHandler,
|
topReportedHandler,
|
||||||
@@ -482,11 +483,12 @@ const app = new Hono<App>()
|
|||||||
.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.
|
// 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)
|
// The gifts create value from nothing, so they are developer-only, not all staff.
|
||||||
.post('/api/staff/players/:id/gift-custom-item', giftCustomItemHandler)
|
.post('/api/staff/players/:id/gift-tokens', requireDeveloper, giftTokensHandler)
|
||||||
.post('/api/staff/players/:id/gift-xp', giftXpHandler)
|
.post('/api/staff/players/:id/gift-custom-item', requireDeveloper, giftCustomItemHandler)
|
||||||
|
.post('/api/staff/players/:id/gift-xp', requireDeveloper, giftXpHandler)
|
||||||
// Everyone standing in a room, across all of its instances.
|
// Everyone standing in a room, across all of its instances.
|
||||||
.post('/api/staff/rooms/:roomId/gift-tokens', giftRoomTokensHandler)
|
.post('/api/staff/rooms/:roomId/gift-tokens', requireDeveloper, giftRoomTokensHandler)
|
||||||
.post('/api/staff/players/:id/username-changes', addUsernameChangeHandler)
|
.post('/api/staff/players/:id/username-changes', addUsernameChangeHandler)
|
||||||
.post('/api/staff/players/:id/clear-password', clearPasswordHandler)
|
.post('/api/staff/players/:id/clear-password', clearPasswordHandler)
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user