mirror of
https://github.com/djdevin/recflare.git
synced 2026-09-08 22:51:30 -07:00
ban hammer
This commit is contained in:
@@ -38,6 +38,10 @@ import {
|
||||
import { logger, withCleanSpec, withNotFound, withOnError } from '@repo/hono-helpers'
|
||||
import { validateAndGetAccountId } from '@repo/jwt'
|
||||
|
||||
// The account-wide ban lives on a `report` row, whose table the api worker owns; its
|
||||
// db module is plain D1 queries with no runtime deps, so it imports cleanly here (the
|
||||
// same way econ reads api's inventions-db).
|
||||
import { isPlayerBanned } from '../../api/src/reports-db'
|
||||
// Value import of the notify worker's NotificationType enum (its bundle has no runtime
|
||||
// deps), so /invite sends a typed MessageReceived frame instead of a magic number.
|
||||
import { NotificationType } from '../../notify/src/notification-types'
|
||||
@@ -696,6 +700,28 @@ const app = new Hono<App>()
|
||||
})(c, next)
|
||||
)
|
||||
|
||||
// A banned account goes nowhere. Room bans are per-room and checked per route (they
|
||||
// depend on which room you're entering); an ACCOUNT ban isn't about a room at all, so
|
||||
// it's enforced once here, across every matchmake — by room, by subroom, by instance,
|
||||
// into a club's clubhouse, following a friend, and into their own dorm. A gate rather
|
||||
// than six copies of the same check: a route added later inherits it, and there is no
|
||||
// matchmake left that hands a banned player Photon coordinates.
|
||||
//
|
||||
// It answers the same BannedFromRoom the room bans do. The code is per-room in name
|
||||
// only — it's the one refusal the client renders as "you are banned" instead of a room
|
||||
// that mysteriously fails to load, and it's what the enum offers.
|
||||
//
|
||||
// Unauthenticated requests fall through untouched: the route's own `authedId` answers
|
||||
// 401, which mustn't turn into "banned" just because the token was missing.
|
||||
.use('/matchmake/*', async (c, next) => {
|
||||
const id = await authedId(c)
|
||||
if (id !== null && (await isPlayerBanned(c.env.DB, id))) {
|
||||
logger.info('matchmake refused: account banned', { accountId: id, path: c.req.path })
|
||||
return c.json({ errorCode: BANNED_FROM_ROOM, roomInstance: null })
|
||||
}
|
||||
await next()
|
||||
})
|
||||
|
||||
.onError(withOnError())
|
||||
.notFound(withNotFound())
|
||||
|
||||
@@ -1288,11 +1314,12 @@ const app = new Hono<App>()
|
||||
description: [
|
||||
'Single-segment matchmake into the caller’s personal dorm, stored as presence. The',
|
||||
'client only ever calls this with the `dorm` keyword — real rooms go through',
|
||||
'`/matchmake/room/:roomId`.',
|
||||
'`/matchmake/room/:roomId`. Returns errorCode 55 with a null instance when the',
|
||||
'account is banned: a ban keeps a player out of their own dorm too.',
|
||||
].join(' '),
|
||||
security: AUTHED,
|
||||
responses: {
|
||||
200: json(MatchmakeResponse, 'The player’s personal dorm instance'),
|
||||
200: json(MatchmakeResponse, 'The dorm instance (or a null instance with errorCode 55)'),
|
||||
401: UNAUTHORIZED_RESPONSE,
|
||||
},
|
||||
}),
|
||||
|
||||
@@ -21,6 +21,11 @@ import {
|
||||
SUBROOM_SCHEMA_DDL,
|
||||
} from '@repo/domain'
|
||||
|
||||
import {
|
||||
banFromReport,
|
||||
createReport,
|
||||
SCHEMA_DDL as REPORTS_SCHEMA_DDL,
|
||||
} from '../../../../api/src/reports-db'
|
||||
import { scheduled } from '../../match.app'
|
||||
|
||||
import type { Env } from '../../context'
|
||||
@@ -162,8 +167,21 @@ beforeAll(async () => {
|
||||
insertRel.bind(9702, 9700, 3), // friends (9702 requested) — friend is the requester
|
||||
insertRel.bind(9700, 9703, 1), // pending request out — 9703 is NOT a friend
|
||||
])
|
||||
|
||||
// Report table (owned by the api worker) — an account-wide ban is a report row with
|
||||
// `banned` set, and every matchmake is refused for a player who has one.
|
||||
for (const stmt of REPORTS_SCHEMA_DDL) await env.DB.prepare(stmt).run()
|
||||
})
|
||||
|
||||
/**
|
||||
* Ban a player account-wide the way a moderator would: file a report against them and
|
||||
* convert it. `banExpires` null is a permanent ban.
|
||||
*/
|
||||
async function banAccount(playerId: number, banExpires: string | null = null): Promise<void> {
|
||||
const row = await createReport(env.DB, { reporterPlayerId: 1, reportedPlayerId: playerId })
|
||||
await banFromReport(env.DB, row.id, { banExpires })
|
||||
}
|
||||
|
||||
// Mint a token the way the `auth` worker does, signing with the shared test key seeded into the JWT_SECRET store, so the
|
||||
// match worker's validation accepts it. Kept inline to avoid a cross-package
|
||||
// import.
|
||||
@@ -1218,8 +1236,11 @@ describe('auth-gated endpoints', () => {
|
||||
|
||||
// No token → 401.
|
||||
expect(
|
||||
(await exports.default.fetch(`${ORIGIN}/matchmake/instance/${instanceId}`, { method: 'POST' }))
|
||||
.status
|
||||
(
|
||||
await exports.default.fetch(`${ORIGIN}/matchmake/instance/${instanceId}`, {
|
||||
method: 'POST',
|
||||
})
|
||||
).status
|
||||
).toBe(401)
|
||||
|
||||
// Authed but not the room's owner or co-owner → the opaque NoSuchRoom refusal,
|
||||
@@ -1598,8 +1619,9 @@ describe('auth-gated endpoints', () => {
|
||||
roomInstance: null,
|
||||
})
|
||||
} finally {
|
||||
await env.DB.prepare('DELETE FROM room_ban WHERE room_id = 2 AND banned_player_id = 9800')
|
||||
.run()
|
||||
await env.DB.prepare(
|
||||
'DELETE FROM room_ban WHERE room_id = 2 AND banned_player_id = 9800'
|
||||
).run()
|
||||
}
|
||||
})
|
||||
|
||||
@@ -1765,3 +1787,95 @@ describe('auth-gated endpoints', () => {
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
// An ACCOUNT ban (a `report` row with `banned` set, owned by the api worker) is not
|
||||
// about any one room, so it is enforced across every matchmake rather than per route —
|
||||
// see the /matchmake/* gate in match.app.ts. It answers the same BannedFromRoom (55) the
|
||||
// per-room bans do, which is the code the client renders as "you are banned".
|
||||
describe('account bans', () => {
|
||||
const matchmake = async (path: string, player: string) =>
|
||||
exports.default.fetch(`${ORIGIN}${path}`, {
|
||||
method: 'POST',
|
||||
headers: await bearer(player),
|
||||
})
|
||||
|
||||
test('every matchmake route is refused for a banned account', async () => {
|
||||
await banAccount(6001)
|
||||
// One live instance of room 2 and one club membership, so each route would
|
||||
// otherwise have somewhere to put them.
|
||||
for (const path of [
|
||||
'/matchmake/room/2',
|
||||
'/matchmake/room/77/34',
|
||||
'/matchmake/dorm',
|
||||
'/matchmake/club/4',
|
||||
'/matchmake/player/9701',
|
||||
'/matchmake/instance/1',
|
||||
]) {
|
||||
const res = await matchmake(path, '6001')
|
||||
expect(res.status, path).toBe(200)
|
||||
expect(await res.json(), path).toEqual({ errorCode: 55, roomInstance: null })
|
||||
}
|
||||
})
|
||||
|
||||
// The refusal is the ban's, not the room's: nothing is entered, so no presence is
|
||||
// written and the player stays where they were (nowhere).
|
||||
test('a refused matchmake leaves no presence behind', async () => {
|
||||
await banAccount(6002)
|
||||
expect((await matchmake('/matchmake/room/2', '6002')).status).toBe(200)
|
||||
|
||||
const player = (await (
|
||||
await exports.default.fetch(`${ORIGIN}/player?id=6002`, { headers: await bearer('6002') })
|
||||
).json()) as Array<{ isOnline: boolean; roomInstance: unknown }>
|
||||
expect(player[0]?.roomInstance ?? null).toBeNull()
|
||||
})
|
||||
|
||||
// A timed ban lifts itself once its expiry passes — nothing clears the flag.
|
||||
test('an expired ban no longer blocks a matchmake', async () => {
|
||||
await banAccount(6003, '2020-01-01T00:00:00.000Z')
|
||||
const res = await matchmake('/matchmake/room/2', '6003')
|
||||
const body = (await res.json()) as { errorCode: number; roomInstance: unknown }
|
||||
expect(body.errorCode).toBe(0)
|
||||
expect(body.roomInstance).not.toBeNull()
|
||||
})
|
||||
|
||||
test('a ban that has not expired yet blocks a matchmake', async () => {
|
||||
await banAccount(6004, new Date(Date.now() + 3_600_000).toISOString())
|
||||
expect(await (await matchmake('/matchmake/room/2', '6004')).json()).toEqual({
|
||||
errorCode: 55,
|
||||
roomInstance: null,
|
||||
})
|
||||
})
|
||||
|
||||
// A report on its own is not a ban — only a moderator converting it is.
|
||||
test('an unbanned report does not block a matchmake', async () => {
|
||||
await createReport(env.DB, { reporterPlayerId: 1, reportedPlayerId: 6005 })
|
||||
const body = (await (await matchmake('/matchmake/room/2', '6005')).json()) as {
|
||||
errorCode: number
|
||||
}
|
||||
expect(body.errorCode).toBe(0)
|
||||
})
|
||||
|
||||
// Filing the report doesn't touch the reporter, so they still play.
|
||||
test('the reporter is not banned by the report they filed', async () => {
|
||||
await banAccount(6006)
|
||||
const body = (await (await matchmake('/matchmake/room/2', '1')).json()) as { errorCode: number }
|
||||
expect(body.errorCode).toBe(0)
|
||||
})
|
||||
|
||||
// The gate must not turn a missing token into "banned" — that's still a 401.
|
||||
test('an unauthenticated matchmake is still a 401', async () => {
|
||||
const res = await exports.default.fetch(`${ORIGIN}/matchmake/room/2`, { method: 'POST' })
|
||||
expect(res.status).toBe(401)
|
||||
})
|
||||
|
||||
// Only the matchmakes are gated: presence and the rest of the surface keep working,
|
||||
// so a banned player's client isn't left hammering a dead heartbeat.
|
||||
test('the gate does not touch non-matchmake routes', async () => {
|
||||
await banAccount(6007)
|
||||
const res = await exports.default.fetch(`${ORIGIN}/player/heartbeat`, {
|
||||
method: 'POST',
|
||||
headers: await bearer('6007'),
|
||||
})
|
||||
expect(res.status).toBe(200)
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user