mirror of
https://github.com/djdevin/recflare.git
synced 2026-09-09 07:01:27 -07:00
[api] show ban details
This commit is contained in:
+25
-12
@@ -1242,23 +1242,36 @@ export const VoteToKickReason = z.object({
|
|||||||
})
|
})
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* `GET|POST /api/PlayerReporting/v1/moderationBlockDetails` — always the "not blocked"
|
* `GET|POST /api/PlayerReporting/v1/moderationBlockDetails` — the caller's block. With an
|
||||||
* answer (no ban storage yet), mirroring the reference server's stub
|
* account-wide ban in force (a `report` row with `banned` set) it describes that ban:
|
||||||
* `ReturnModerationBlockDetails()`. `ReportCategory` is `Unknown` (-1) rather than 0,
|
* `IsBan` true, the report's `ReportCategory`, `Duration` in seconds left (0 for a
|
||||||
* which is a real category, and `Message` is null — the client distinguishes "no
|
* permanent ban, which has no end) and a fixed `Message` of "Rule violation". Otherwise it is the "not
|
||||||
* message" from a blank one, so we send null where the reference sends an empty string.
|
* blocked" answer, mirroring the reference server's stub `ReturnModerationBlockDetails()`:
|
||||||
* `IsVoiceModAutoban`/`TimeoutStartedAt` are on the DTO but unset by that stub, so
|
* `ReportCategory` is `Unknown` (-1) rather than 0, which is a real category, and
|
||||||
* they carry their C# defaults (false / null).
|
* `Message` is null — the client distinguishes "no message" from a blank one, so we send
|
||||||
|
* null where the reference sends an empty string. `IsVoiceModAutoban`/`TimeoutStartedAt`
|
||||||
|
* are on the DTO but unset by that stub, so they carry their C# defaults (false / null).
|
||||||
*/
|
*/
|
||||||
export const ModerationBlockDetails = z.object({
|
export const ModerationBlockDetails = z.object({
|
||||||
ReportCategory: z.int().describe('-1 = ReportCategory.Unknown (0 is a real category)'),
|
ReportCategory: z
|
||||||
Duration: z.int(),
|
.int()
|
||||||
|
.describe(
|
||||||
|
'The category the ban’s report was filed under; -1 = ReportCategory.Unknown when not blocked (0 is a real category)'
|
||||||
|
),
|
||||||
|
Duration: z
|
||||||
|
.int()
|
||||||
|
.describe(
|
||||||
|
'Seconds left on the block; 0 for a permanent ban (no end) and when not blocked — `IsBan` marks the block'
|
||||||
|
),
|
||||||
GameSessionId: z.int(),
|
GameSessionId: z.int(),
|
||||||
IsBan: z.boolean(),
|
IsBan: z.boolean().describe('True when an account-wide ban is in force'),
|
||||||
IsHostKick: z.boolean(),
|
IsHostKick: z.boolean(),
|
||||||
IsVoiceModAutoban: z.boolean(),
|
IsVoiceModAutoban: z.boolean(),
|
||||||
Message: z.string().nullable(),
|
Message: z.string().nullable().describe('“Rule violation” on a ban; null when not blocked'),
|
||||||
PlayerIdReporter: z.int().nullable(),
|
PlayerIdReporter: z
|
||||||
|
.int()
|
||||||
|
.nullable()
|
||||||
|
.describe('Always null — the reporter is not shown to the reported'),
|
||||||
TimeoutStartedAt: z.string().nullable(),
|
TimeoutStartedAt: z.string().nullable(),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
+11
-11
@@ -20,14 +20,14 @@
|
|||||||
* one polymorphic id because the keys differ in TYPE: two numbers and a guid.
|
* one polymorphic id because the keys differ in TYPE: two numbers and a guid.
|
||||||
*
|
*
|
||||||
* A report is also where an ACCOUNT-WIDE ban lives: acting on a report sets `banned`
|
* A report is also where an ACCOUNT-WIDE ban lives: acting on a report sets `banned`
|
||||||
* on that same row (see `banFromReport`), so the ban carries the evidence for it. Two
|
* on that same row (see `banFromReport`), so the ban carries the evidence for it. It is
|
||||||
* workers read it — `match` refuses every matchmake for a banned player, and `auth`
|
* ENFORCED by `match`, which refuses every matchmake for a banned player, and DESCRIBED
|
||||||
* refuses to issue them a token at all — both via `isPlayerBanned`. This is distinct
|
* by `/api/PlayerReporting/v1/moderationBlockDetails`, which tells the banned player why
|
||||||
* from the per-room `room_ban` table the rooms worker owns: that one keeps a player
|
* (via `getActiveBan`). `auth` still issues a banned account a token — that is what lets
|
||||||
* out of ONE room, this one out of the game.
|
* the client reach the block screen — and reads this table only for ban EVASION (an
|
||||||
*
|
* account sharing a device or network with a banned one; see bans-db). This is distinct
|
||||||
* `/api/PlayerReporting/v1/moderationBlockDetails` is NOT wired to it yet and still
|
* from the per-room `room_ban` table the rooms worker owns: that one keeps a player out
|
||||||
* answers "not blocked" unconditionally.
|
* of ONE room, this one out of the game.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -199,9 +199,9 @@ export async function getActiveBan(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Whether a player is banned right now. The hot-path form of `getActiveBan` — `match`
|
* Whether a player is banned right now. The hot-path form of `getActiveBan`, for a caller
|
||||||
* calls it on every matchmake and `auth` on every token grant, and neither has anything
|
* that has nothing to say about WHICH report did it. `moderationBlockDetails` is the
|
||||||
* to say about WHICH report did it.
|
* caller that does, and reads `getActiveBan` itself.
|
||||||
*/
|
*/
|
||||||
export async function isPlayerBanned(
|
export async function isPlayerBanned(
|
||||||
db: D1Database,
|
db: D1Database,
|
||||||
|
|||||||
@@ -35,12 +35,13 @@ import {
|
|||||||
VoteToKickReason,
|
VoteToKickReason,
|
||||||
VoteToKickRequest,
|
VoteToKickRequest,
|
||||||
} from '../openapi'
|
} from '../openapi'
|
||||||
import { createReport } from '../reports-db'
|
import { createReport, getActiveBan } from '../reports-db'
|
||||||
import { createWarning } from '../warnings-db'
|
import { createWarning } from '../warnings-db'
|
||||||
|
|
||||||
import type { Context } from 'hono'
|
import type { Context } from 'hono'
|
||||||
import type { ModerationKickPayload } from '../../../notify/src/notification-payloads'
|
import type { ModerationKickPayload } from '../../../notify/src/notification-payloads'
|
||||||
import type { App } from '../context'
|
import type { App } from '../context'
|
||||||
|
import type { ReportRow } from '../reports-db'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Roles allowed to hand down a warning — the operator-granted elevated roles the auth
|
* Roles allowed to hand down a warning — the operator-granted elevated roles the auth
|
||||||
@@ -223,38 +224,15 @@ async function pushVoteToKick(c: Context<App>, message: VoteToKickMessage): Prom
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- Player reporting ------------------------------------------------------
|
/**
|
||||||
export const moderationRoutes = new Hono<App>({ strict: false })
|
* `Duration` on a permanent ban: 0, "no end". `IsBan` is what says the player is blocked;
|
||||||
// Whether the caller is currently blocked (banned / timed out / host-kicked). Bans
|
* `Duration` only says for how long, and a ban with no expiry has no length to give. Not
|
||||||
// are stored (a report row with `banned` set) and enforced at matchmake and at login,
|
* the int32-max sentinel E12354 uses — that reads as a 68-year countdown.
|
||||||
// but this endpoint is not wired to them, so it's always the "not blocked" answer —
|
*/
|
||||||
// the reference server's stub `ReturnModerationBlockDetails()`.
|
const PERMANENT_BAN_DURATION = 0
|
||||||
// `ReportCategory` is `Unknown` (-1) rather than 0, which is a real category;
|
|
||||||
// `Message` is null, not the empty string that stub sends — the client distinguishes
|
/** The "not blocked" answer — the reference server's stub `ReturnModerationBlockDetails()`. */
|
||||||
// "no message" from a blank one. `IsVoiceModAutoban`/`TimeoutStartedAt` are on the
|
const NOT_BLOCKED = {
|
||||||
// DTO but left unset there, so they go out with their C# defaults.
|
|
||||||
// The newer client POSTs this with no body despite it being a pure read; it answers
|
|
||||||
// GET too, so the path is reachable from either build.
|
|
||||||
.on(
|
|
||||||
['GET', 'POST'],
|
|
||||||
'/api/PlayerReporting/v1/moderationBlockDetails',
|
|
||||||
describeRoute({
|
|
||||||
tags: ['Moderation'],
|
|
||||||
summary: 'Whether the caller is blocked',
|
|
||||||
description:
|
|
||||||
'Ban / timeout / host-kick state for the caller. Bans are stored (a `report` row ' +
|
|
||||||
'with `banned` set) and enforced at matchmake and at login, but this endpoint is ' +
|
|
||||||
'not wired to them, so it is always the “not blocked” answer, following the ' +
|
|
||||||
'reference server’s stub: `ReportCategory` is `Unknown` (-1) rather than 0, which ' +
|
|
||||||
'is a real category, and `Message` is null rather than the empty string that stub ' +
|
|
||||||
'sends — the client distinguishes “no message” from a blank one. ' +
|
|
||||||
'`IsVoiceModAutoban` and `TimeoutStartedAt` are on the DTO but unset by that ' +
|
|
||||||
'stub, so they carry their defaults. Answers GET or POST: the newer client POSTs ' +
|
|
||||||
'it with no body.',
|
|
||||||
responses: { 200: json(ModerationBlockDetails, 'Always “not blocked”') },
|
|
||||||
}),
|
|
||||||
(c) =>
|
|
||||||
c.json({
|
|
||||||
ReportCategory: -1,
|
ReportCategory: -1,
|
||||||
Duration: 0,
|
Duration: 0,
|
||||||
GameSessionId: 0,
|
GameSessionId: 0,
|
||||||
@@ -264,7 +242,95 @@ export const moderationRoutes = new Hono<App>({ strict: false })
|
|||||||
Message: null,
|
Message: null,
|
||||||
PlayerIdReporter: null,
|
PlayerIdReporter: null,
|
||||||
TimeoutStartedAt: null,
|
TimeoutStartedAt: null,
|
||||||
})
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The block details for a ban in force — the `report` row a moderator set `banned` on.
|
||||||
|
*
|
||||||
|
* `Duration` is the seconds left on the ban (rounded up, so a ban with a second to run
|
||||||
|
* doesn't read as over), or `PERMANENT_BAN_DURATION` (0, no end) when `ban_expires` is
|
||||||
|
* NULL — `IsBan` alone marks the block, so the "not blocked" answer and a permanent ban
|
||||||
|
* share a `Duration` of 0 without being confused. The
|
||||||
|
* category is the one the report was filed under, so the client's ban screen names the
|
||||||
|
* reason. `Message` is a fixed "Rule violation" rather than the report's `details` —
|
||||||
|
* those are the REPORTER's words, and the banned player isn't shown them, for the same
|
||||||
|
* reason `PlayerIdReporter` stays null: the reporter is not a host who kicked them, and
|
||||||
|
* naming them would tell the banned player who reported them. `IsHostKick`,
|
||||||
|
* `IsVoiceModAutoban` and `TimeoutStartedAt` describe the OTHER kinds of block, none of
|
||||||
|
* which this server hands out.
|
||||||
|
*/
|
||||||
|
function banBlockDetails(ban: ReportRow, now: Date) {
|
||||||
|
const duration =
|
||||||
|
ban.ban_expires === null
|
||||||
|
? PERMANENT_BAN_DURATION
|
||||||
|
: Math.max(1, Math.ceil((Date.parse(ban.ban_expires) - now.getTime()) / 1000))
|
||||||
|
return {
|
||||||
|
ReportCategory: ban.report_category,
|
||||||
|
Duration: duration,
|
||||||
|
GameSessionId: 0,
|
||||||
|
IsBan: true,
|
||||||
|
IsHostKick: false,
|
||||||
|
IsVoiceModAutoban: false,
|
||||||
|
Message: 'Rule violation',
|
||||||
|
PlayerIdReporter: null,
|
||||||
|
TimeoutStartedAt: null,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Player reporting ------------------------------------------------------
|
||||||
|
export const moderationRoutes = new Hono<App>({ strict: false })
|
||||||
|
// Whether the caller is currently blocked (banned / timed out / host-kicked). The one
|
||||||
|
// kind of block this server has is the account-wide ban — a `report` row with `banned`
|
||||||
|
// set (see `getActiveBan`), the same row matchmake refuses on — so a caller with one in
|
||||||
|
// force gets it described here, and everyone else gets the "not blocked" answer of the
|
||||||
|
// reference server's stub `ReturnModerationBlockDetails()`. This is the screen a banned
|
||||||
|
// player is shown, which is why `auth` still issues them a token: without one the client
|
||||||
|
// never gets here, and the ban reads as a failed sign-in.
|
||||||
|
// Only the caller's OWN account is consulted, not the evasion arms `resolveBan` adds
|
||||||
|
// at matchmake and login: this screen explains a ban handed to this account, and a
|
||||||
|
// player blocked for sharing a network with a banned one has no report row to show.
|
||||||
|
// In the "not blocked" answer `ReportCategory` is `Unknown` (-1) rather than 0, which
|
||||||
|
// is a real category, and `Message` is null, not the empty string that stub sends —
|
||||||
|
// the client distinguishes "no message" from a blank one. `IsVoiceModAutoban` /
|
||||||
|
// `TimeoutStartedAt` are on the DTO but unset there, so they go out with their C#
|
||||||
|
// defaults.
|
||||||
|
// The newer client POSTs this with no body despite it being a pure read; it answers
|
||||||
|
// GET too, so the path is reachable from either build.
|
||||||
|
.on(
|
||||||
|
['GET', 'POST'],
|
||||||
|
'/api/PlayerReporting/v1/moderationBlockDetails',
|
||||||
|
describeRoute({
|
||||||
|
tags: ['Moderation'],
|
||||||
|
summary: 'Whether the caller is blocked',
|
||||||
|
description:
|
||||||
|
'Ban / timeout / host-kick state for the caller. The one block this server hands ' +
|
||||||
|
'out is the account-wide ban — a `report` row with `banned` set, the same row ' +
|
||||||
|
'matchmake refuses on (login still issues a token, so the client can reach this ' +
|
||||||
|
'screen) — so a caller with one in force gets ' +
|
||||||
|
'`IsBan: true`, the `ReportCategory` the report was filed under, `Duration` as the ' +
|
||||||
|
'seconds left (0 for a permanent ban, which has no end) and the fixed ' +
|
||||||
|
'`Message` “Rule violation”. `PlayerIdReporter` stays null: it names a kicking ' +
|
||||||
|
'host, and the reporter is not shown to the player they reported. Only the ' +
|
||||||
|
'caller’s own account is consulted, not the ban-evasion arms.\n\n' +
|
||||||
|
'Everyone else gets the reference server’s stub “not blocked” answer: ' +
|
||||||
|
'`ReportCategory` is `Unknown` (-1) rather than 0, which is a real category, and ' +
|
||||||
|
'`Message` is null rather than the empty string that stub sends — the client ' +
|
||||||
|
'distinguishes “no message” from a blank one. `IsVoiceModAutoban` and ' +
|
||||||
|
'`TimeoutStartedAt` are on the DTO but unset by that stub, so they carry their ' +
|
||||||
|
'defaults. Answers GET or POST: the newer client POSTs it with no body.',
|
||||||
|
security: AUTHED,
|
||||||
|
responses: {
|
||||||
|
200: json(ModerationBlockDetails, 'The caller’s block, or “not blocked”'),
|
||||||
|
401: UNAUTHORIZED_RESPONSE,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
async (c) => {
|
||||||
|
const id = await authedId(c)
|
||||||
|
if (id === null) return unauthorized(c)
|
||||||
|
const now = new Date()
|
||||||
|
const ban = await getActiveBan(c.env.DB, id, now)
|
||||||
|
return c.json(ban ? banBlockDetails(ban, now) : NOT_BLOCKED)
|
||||||
|
}
|
||||||
)
|
)
|
||||||
// The reasons the client offers when a player starts a vote-to-kick. Order matters —
|
// The reasons the client offers when a player starts a vote-to-kick. Order matters —
|
||||||
// the client renders them in the order they arrive — and the list is grouped by the
|
// the client renders them in the order they arrive — and the list is grouped by the
|
||||||
@@ -350,10 +416,10 @@ export const moderationRoutes = new Hono<App>({ strict: false })
|
|||||||
tags: ['Moderation'],
|
tags: ['Moderation'],
|
||||||
summary: 'Submit a player report',
|
summary: 'Submit a player report',
|
||||||
description:
|
description:
|
||||||
'Records a player report in the `report` table; nothing dedupes the rows, and ' +
|
'Records a player report in the `report` table; nothing dedupes the rows. A report ' +
|
||||||
'`moderationBlockDetails` still answers “not blocked” unconditionally. A report ' +
|
|
||||||
'is filed unbanned — a moderator converts one into an account-wide ban by setting ' +
|
'is filed unbanned — a moderator converts one into an account-wide ban by setting ' +
|
||||||
'`banned` on the row, which is what matchmaking and `/connect/token` refuse on.\n\n' +
|
'`banned` on the row, which is what matchmaking refuses on and what ' +
|
||||||
|
'`moderationBlockDetails` describes to the banned player.\n\n' +
|
||||||
'The reporter is the caller (from the bearer token), NOT a body field. Only ' +
|
'The reporter is the caller (from the bearer token), NOT a body field. Only ' +
|
||||||
'`PlayerIdReported` is required; the client omits whatever it has no value for ' +
|
'`PlayerIdReported` is required; the client omits whatever it has no value for ' +
|
||||||
'(a report raised outside a room carries no `RoomId`), and those are stored as ' +
|
'(a report raised outside a room carries no `RoomId`), and those are stored as ' +
|
||||||
|
|||||||
@@ -750,32 +750,6 @@ describe('public endpoints', () => {
|
|||||||
expect(charadesWordsFor(new Date('2026-04-01T23:59:59Z'))).toBe(april)
|
expect(charadesWordsFor(new Date('2026-04-01T23:59:59Z'))).toBe(april)
|
||||||
})
|
})
|
||||||
|
|
||||||
// The client POSTs this with no body, despite it being a pure read; the route answers
|
|
||||||
// GET as well, and both methods serve the same body.
|
|
||||||
test.each(['GET', 'POST'])(
|
|
||||||
'%s /api/PlayerReporting/v1/moderationBlockDetails reports "not blocked"',
|
|
||||||
async (method) => {
|
|
||||||
const res = await exports.default.fetch(
|
|
||||||
`${ORIGIN}/api/PlayerReporting/v1/moderationBlockDetails`,
|
|
||||||
{ method }
|
|
||||||
)
|
|
||||||
expect(res.status).toBe(200)
|
|
||||||
// ReportCategory -1 = Unknown (0 is a real category). Message is null, not the
|
|
||||||
// reference stub's empty string — the client tells "no message" from a blank one.
|
|
||||||
expect(await res.json()).toEqual({
|
|
||||||
ReportCategory: -1,
|
|
||||||
Duration: 0,
|
|
||||||
GameSessionId: 0,
|
|
||||||
IsBan: false,
|
|
||||||
IsHostKick: false,
|
|
||||||
IsVoiceModAutoban: false,
|
|
||||||
Message: null,
|
|
||||||
PlayerIdReporter: null,
|
|
||||||
TimeoutStartedAt: null,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
// A fixed list, in render order — the client shows the buttons in the order they
|
// A fixed list, in render order — the client shows the buttons in the order they
|
||||||
// arrive, so the order is part of the contract, not just the contents.
|
// arrive, so the order is part of the contract, not just the contents.
|
||||||
test('GET /api/PlayerReporting/v1/voteToKickReasons serves the reasons in order', async () => {
|
test('GET /api/PlayerReporting/v1/voteToKickReasons serves the reasons in order', async () => {
|
||||||
@@ -4531,6 +4505,98 @@ describe('player reports', () => {
|
|||||||
test('banFromReport returns null for an unknown report', async () => {
|
test('banFromReport returns null for an unknown report', async () => {
|
||||||
expect(await banFromReport(env.DB, 999_999)).toBeNull()
|
expect(await banFromReport(env.DB, 999_999)).toBeNull()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// What the banned player is TOLD. The block screen reads this; it's the same row
|
||||||
|
// matchmake and login refuse on, described rather than merely enforced.
|
||||||
|
describe('moderationBlockDetails', () => {
|
||||||
|
const NOT_BLOCKED = {
|
||||||
|
ReportCategory: -1,
|
||||||
|
Duration: 0,
|
||||||
|
GameSessionId: 0,
|
||||||
|
IsBan: false,
|
||||||
|
IsHostKick: false,
|
||||||
|
IsVoiceModAutoban: false,
|
||||||
|
Message: null,
|
||||||
|
PlayerIdReporter: null,
|
||||||
|
TimeoutStartedAt: null,
|
||||||
|
}
|
||||||
|
const details = async (method: string, sub: string) => {
|
||||||
|
const res = await exports.default.fetch(
|
||||||
|
`${ORIGIN}/api/PlayerReporting/v1/moderationBlockDetails`,
|
||||||
|
{ method, headers: await bearer(sub) }
|
||||||
|
)
|
||||||
|
expect(res.status).toBe(200)
|
||||||
|
return res.json()
|
||||||
|
}
|
||||||
|
|
||||||
|
// The client POSTs this with no body, despite it being a pure read; the route
|
||||||
|
// answers GET as well, and both methods serve the same body.
|
||||||
|
test.each(['GET', 'POST'])(
|
||||||
|
'%s reports "not blocked" for an unbanned player',
|
||||||
|
async (method) => {
|
||||||
|
// A report against them that nobody acted on is not a block.
|
||||||
|
await submit({ PlayerIdReported: '220' }, await bearer())
|
||||||
|
// ReportCategory -1 = Unknown (0 is a real category). Message is null, not the
|
||||||
|
// reference stub's empty string — the client tells "no message" from a blank one.
|
||||||
|
expect(await details(method, '220')).toEqual(NOT_BLOCKED)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
test('401s without a bearer token', async () => {
|
||||||
|
const res = await exports.default.fetch(
|
||||||
|
`${ORIGIN}/api/PlayerReporting/v1/moderationBlockDetails`
|
||||||
|
)
|
||||||
|
expect(res.status).toBe(401)
|
||||||
|
})
|
||||||
|
|
||||||
|
// A permanent ban has no end, so Duration is 0 — IsBan is what marks the block.
|
||||||
|
test('describes a permanent ban', async () => {
|
||||||
|
await submit(
|
||||||
|
{ PlayerIdReported: '221', ReportCategory: '102', Details: 'slurs' },
|
||||||
|
await bearer()
|
||||||
|
)
|
||||||
|
const [row] = await getReportsAgainst(env.DB, 221)
|
||||||
|
await banFromReport(env.DB, row!.id)
|
||||||
|
|
||||||
|
expect(await details('POST', '221')).toEqual({
|
||||||
|
ReportCategory: 102,
|
||||||
|
Duration: 0,
|
||||||
|
GameSessionId: 0,
|
||||||
|
IsBan: true,
|
||||||
|
IsHostKick: false,
|
||||||
|
IsVoiceModAutoban: false,
|
||||||
|
// A fixed message — the report's `details` are the reporter's words, and
|
||||||
|
// the reporter is not shown to the player they reported, hence null.
|
||||||
|
Message: 'Rule violation',
|
||||||
|
PlayerIdReporter: null,
|
||||||
|
TimeoutStartedAt: null,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// A timed ban reports the seconds LEFT, not its original length.
|
||||||
|
test('describes a timed ban with the seconds remaining', async () => {
|
||||||
|
await submit({ PlayerIdReported: '222', ReportCategory: '103' }, await bearer())
|
||||||
|
const [row] = await getReportsAgainst(env.DB, 222)
|
||||||
|
await banFromReport(env.DB, row!.id, {
|
||||||
|
banExpires: new Date(Date.now() + 3600 * 1000).toISOString(),
|
||||||
|
})
|
||||||
|
|
||||||
|
const body = await details('GET', '222')
|
||||||
|
expect(body).toMatchObject({ ReportCategory: 103, IsBan: true, Message: 'Rule violation' })
|
||||||
|
expect(body.Duration).toBeGreaterThan(3500)
|
||||||
|
expect(body.Duration).toBeLessThanOrEqual(3600)
|
||||||
|
})
|
||||||
|
|
||||||
|
// A ban that has served its time is not a block, even though the row still says
|
||||||
|
// `banned = 1` — the same rule `getActiveBan` applies for matchmake and login.
|
||||||
|
test('reports "not blocked" once a ban has expired', async () => {
|
||||||
|
await submit({ PlayerIdReported: '223' }, await bearer())
|
||||||
|
const [row] = await getReportsAgainst(env.DB, 223)
|
||||||
|
await banFromReport(env.DB, row!.id, { banExpires: '2020-01-01T00:00:00.000Z' })
|
||||||
|
|
||||||
|
expect(await details('GET', '223')).toEqual(NOT_BLOCKED)
|
||||||
|
})
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('player warnings', () => {
|
describe('player warnings', () => {
|
||||||
|
|||||||
+52
-45
@@ -71,19 +71,16 @@ const TOKEN_SCOPE =
|
|||||||
'offline_access profile rn rn.accounts rn.accounts.gc rn.api rn.chat rn.clubs rn.commerce rn.match.read rn.match.write rn.notify rn.rooms rn.storage'
|
'offline_access profile rn rn.accounts rn.accounts.gc rn.api rn.chat rn.clubs rn.commerce rn.match.read rn.match.write rn.notify rn.rooms rn.storage'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The `error_description` a banned account's grant is refused with. A fixed sentence,
|
* The `error_description` a grant is refused with when the caller's account is not itself
|
||||||
* never interpolated with the expiry, because `www`'s shared auth-messages table keys on
|
* banned but shares an identity with one that is (see bans-db's linked arms). A fixed
|
||||||
* this exact string to put a real sentence in front of a player — anything varying would
|
* sentence, because `www`'s shared auth-messages table keys on this exact string to put a
|
||||||
* fall through to the generic "you could not be signed in". Keep the two in sync.
|
* real sentence in front of a player — anything varying would fall through to the generic
|
||||||
*/
|
* "you could not be signed in". Keep the two in sync. Deliberately vague: the account
|
||||||
const BANNED_DESCRIPTION = 'this account is banned'
|
* being refused may be an innocent housemate of a banned player, so telling them "this
|
||||||
|
* account is banned" would be a lie, and naming the account we matched them to would hand
|
||||||
/**
|
* out somebody else's moderation record.
|
||||||
* The refusal when it is not THIS account that is banned but one it shares an identity
|
*
|
||||||
* with (see bans-db's linked arms). Deliberately a different, vaguer sentence: the
|
* A DIRECTLY banned account is not refused here at all — see the token grant.
|
||||||
* account being refused may be an innocent housemate of a banned player, so telling them
|
|
||||||
* "this account is banned" would be a lie, and naming the account we matched them to
|
|
||||||
* would hand out somebody else's moderation record.
|
|
||||||
*/
|
*/
|
||||||
const BLOCKED_DESCRIPTION = 'this device or network is blocked'
|
const BLOCKED_DESCRIPTION = 'this device or network is blocked'
|
||||||
|
|
||||||
@@ -608,19 +605,22 @@ const app = new Hono<App>()
|
|||||||
'the `rn.privilege` CLAIM (`BanVChat`, `BanRmChat`) — scope-shaped name, but the',
|
'the `rn.privilege` CLAIM (`BanVChat`, `BanRmChat`) — scope-shaped name, but the',
|
||||||
'client reads it as a claim beside `role`, and it is absent for everyone else.',
|
'client reads it as a claim beside `role`, and it is absent for everyone else.',
|
||||||
'',
|
'',
|
||||||
'**Bans.** Once the grant has resolved an account, a BANNED account is refused a',
|
'**Bans.** A BANNED account still gets a token — every grant, including a refresh.',
|
||||||
'token at all (`invalid_grant`) — every grant, including a refresh. A ban is a',
|
'A ban is a `report` row with `banned` set (the `api` worker owns that table); it',
|
||||||
'`report` row with `banned` set (the `api` worker owns that table); it lifts on its',
|
'lifts on its own when `ban_expires` passes, and never if that is null. The token',
|
||||||
'own when `ban_expires` passes, and never if that is null.',
|
'is what lets the client reach `api`’s `/api/PlayerReporting/v1/moderationBlockDetails`',
|
||||||
|
'and show the player the block screen that explains the ban; the ban itself is',
|
||||||
|
'enforced by `match`, which refuses every matchmake for a banned player, so a token',
|
||||||
|
'gets them as far as that screen and no further.',
|
||||||
'',
|
'',
|
||||||
'The refusal follows the player, not just the account: it also catches an account',
|
'What IS refused here (`invalid_grant`) is ban EVASION: an account that shares a',
|
||||||
'that shares a PROVEN platform identity (a `platform_account` link) or an IP',
|
'PROVEN platform identity (a `platform_account` link) or an IP (`signupIp`/',
|
||||||
'(`signupIp`/`lastLoginIp`, or the address this request came from) with a banned',
|
'`lastLoginIp`, or the address this request came from) with a banned one, and a',
|
||||||
'one, and a `create_account` carrying either is refused BEFORE it mints anything.',
|
'`create_account` carrying either, which is refused BEFORE it mints anything. Such',
|
||||||
'Those two arms are the operator’s `BAN_EVASION_MATCH` knob (`ip`, `platform`, or',
|
'an account has no ban of its own for the block screen to describe, so there is',
|
||||||
'`off`); the ban on the account itself is always enforced. A linked match answers a',
|
'nothing to let it in for. Those two arms are the operator’s `BAN_EVASION_MATCH`',
|
||||||
'deliberately vaguer description than a direct one — the account refused may belong',
|
'knob (`ip`, `platform`, or `off`). The description is deliberately vague — the',
|
||||||
'to a housemate of the banned player rather than to them.',
|
'account refused may belong to a housemate of the banned player rather than to them.',
|
||||||
].join('\n'),
|
].join('\n'),
|
||||||
requestBody: form(
|
requestBody: form(
|
||||||
TokenRequest,
|
TokenRequest,
|
||||||
@@ -633,7 +633,7 @@ const app = new Hono<App>()
|
|||||||
[
|
[
|
||||||
'Unusable grant: bad credentials, an unverifiable platform or platform_auth, an',
|
'Unusable grant: bad credentials, an unverifiable platform or platform_auth, an',
|
||||||
'invalid/expired refresh token, a missing account identifier, a signup cap reached,',
|
'invalid/expired refresh token, a missing account identifier, a signup cap reached,',
|
||||||
'or a banned account',
|
'or an account sharing a banned one’s device or network',
|
||||||
].join(' ')
|
].join(' ')
|
||||||
),
|
),
|
||||||
500: json(
|
500: json(
|
||||||
@@ -789,9 +789,9 @@ const app = new Hono<App>()
|
|||||||
// via create_account or /account/me/changepassword.
|
// via create_account or /account/me/changepassword.
|
||||||
let accountId: string
|
let accountId: string
|
||||||
if (grantType === 'create_account') {
|
if (grantType === 'create_account') {
|
||||||
// A banned player's next move is a new account, so the ban is checked BEFORE
|
// A banned player's next move is a new account, so the evasion arms are checked
|
||||||
// one is minted — against the only identity a signup has, the IP it came from
|
// BEFORE one is minted — against the only identity a signup has, the IP it came
|
||||||
// and the platform identity it just proved. Refusing after the fact (as the
|
// from and the platform identity it just proved. Refusing after the fact (as the
|
||||||
// shared check below would) still refuses the token, but leaves the account
|
// shared check below would) still refuses the token, but leaves the account
|
||||||
// row behind and burns a slot off both signup caps, so the evader gets to keep
|
// row behind and burns a slot off both signup caps, so the evader gets to keep
|
||||||
// making them.
|
// making them.
|
||||||
@@ -987,14 +987,19 @@ const app = new Hono<App>()
|
|||||||
await setLoginContext(c.env.DB, resolvedId, { deviceId, deviceClass, ip: clientIp })
|
await setLoginContext(c.env.DB, resolvedId, { deviceId, deviceClass, ip: clientIp })
|
||||||
}
|
}
|
||||||
|
|
||||||
// A banned player gets no token — and with no token every other worker is shut to
|
// A DIRECTLY banned account still gets its token. The client needs one to reach
|
||||||
// them, so this is the outer wall of a ban; matchmaking's refusal is the inner
|
// `api`'s moderationBlockDetails, which is where the player is TOLD they are banned
|
||||||
// one, which still has to exist because a token issued before the ban stays valid
|
// (category, time left, "Rule violation") — refused here, they would only ever see
|
||||||
// until it expires.
|
// a failed sign-in. The ban is enforced by matchmaking instead, which refuses every
|
||||||
|
// matchmake for a banned player, so the token gets them as far as the block screen
|
||||||
|
// and no further. Logged, so the operator can see a banned player signing in.
|
||||||
//
|
//
|
||||||
// Checked once here, after the grant has resolved an account, so it covers every
|
// Ban EVASION is still refused here: an account that merely shares a device or
|
||||||
// grant: password, cached_login and a refresh_token redeemed by a client that has
|
// network with a banned one has no ban of its own for that screen to describe, so
|
||||||
// been running since before the ban. Deliberately AFTER the credential checks —
|
// there is nothing to let it in for — and letting it in is exactly what the evader
|
||||||
|
// wants. Checked once here, after the grant has resolved an account, so it covers
|
||||||
|
// every grant: password, cached_login and a refresh_token redeemed by a client that
|
||||||
|
// has been running since before the ban. Deliberately AFTER the credential checks —
|
||||||
// a wrong password is still "invalid account_id or password", so this can't be
|
// a wrong password is still "invalid account_id or password", so this can't be
|
||||||
// used to probe whether an account exists or is banned without knowing it.
|
// used to probe whether an account exists or is banned without knowing it.
|
||||||
//
|
//
|
||||||
@@ -1007,8 +1012,8 @@ const app = new Hono<App>()
|
|||||||
identity: { ip: clientIp, platform: verifiedPlatform, platformId: verifiedPlatformId },
|
identity: { ip: clientIp, platform: verifiedPlatform, platformId: verifiedPlatformId },
|
||||||
arms: banEvasionMatch(c.env.BAN_EVASION_MATCH),
|
arms: banEvasionMatch(c.env.BAN_EVASION_MATCH),
|
||||||
})
|
})
|
||||||
if (ban) {
|
if (ban && ban.via !== 'account') {
|
||||||
logger.info('token refused: player banned', {
|
logger.info('token refused: ban evasion', {
|
||||||
accountId,
|
accountId,
|
||||||
grantType,
|
grantType,
|
||||||
via: ban.via,
|
via: ban.via,
|
||||||
@@ -1016,13 +1021,15 @@ const app = new Hono<App>()
|
|||||||
reportId: ban.ban.id,
|
reportId: ban.ban.id,
|
||||||
banExpires: ban.ban.ban_expires,
|
banExpires: ban.ban.ban_expires,
|
||||||
})
|
})
|
||||||
return c.json(
|
return c.json({ error: 'invalid_grant', error_description: BLOCKED_DESCRIPTION }, 400)
|
||||||
{
|
}
|
||||||
error: 'invalid_grant',
|
if (ban) {
|
||||||
error_description: ban.via === 'account' ? BANNED_DESCRIPTION : BLOCKED_DESCRIPTION,
|
logger.info('token issued to banned account', {
|
||||||
},
|
accountId,
|
||||||
400
|
grantType,
|
||||||
)
|
reportId: ban.ban.id,
|
||||||
|
banExpires: ban.ban.ban_expires,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Never sign with an empty key. An empty JWT_SECRET (misconfigured/missing
|
// Never sign with an empty key. An empty JWT_SECRET (misconfigured/missing
|
||||||
|
|||||||
@@ -89,8 +89,8 @@ beforeAll(async () => {
|
|||||||
IsDorm: false,
|
IsDorm: false,
|
||||||
SubRooms: [{ SubRoomId: 23, UnitySceneId: ORIENTATION_SCENE, MaxPlayers: 1 }],
|
SubRooms: [{ SubRoomId: 23, UnitySceneId: ORIENTATION_SCENE, MaxPlayers: 1 }],
|
||||||
})
|
})
|
||||||
// Report table (owned by the api worker) — a banned account is refused a token, and
|
// Report table (owned by the api worker) — a ban is a report row with `banned` set;
|
||||||
// a ban is a report row with `banned` set.
|
// the token grant reads it for the evasion arms.
|
||||||
for (const stmt of REPORTS_SCHEMA_DDL) await env.DB.prepare(stmt).run()
|
for (const stmt of REPORTS_SCHEMA_DDL) await env.DB.prepare(stmt).run()
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -1436,38 +1436,35 @@ describe('CORS', () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
// A banned account is refused a token at all — the outer wall of a ban, since with no
|
// A banned account is still issued a token: the game client needs one to reach the api
|
||||||
// token every other worker is shut to it. The ban is a `report` row with `banned` set
|
// worker's moderationBlockDetails, which is where the player is shown WHY they are
|
||||||
// (the api worker owns that table); matchmaking enforces the same ban on tokens issued
|
// blocked. The ban is a `report` row with `banned` set (the api worker owns that table)
|
||||||
// before it was handed down.
|
// and is enforced by matchmaking, which refuses every matchmake for a banned player — so
|
||||||
|
// the token gets them to the block screen and no further.
|
||||||
describe('banned accounts', () => {
|
describe('banned accounts', () => {
|
||||||
test('POST /connect/token refuses a password grant from a banned account', async () => {
|
test('POST /connect/token issues a token to a banned account', async () => {
|
||||||
await seedAccount(6101, 'BannedPlayer')
|
await seedAccount(6101, 'BannedPlayer')
|
||||||
await banAccount(6101)
|
await banAccount(6101)
|
||||||
|
|
||||||
const res = await postToken(`account_id=6101&password=${LOGIN_PASSWORD}`)
|
const res = await postToken(`account_id=6101&password=${LOGIN_PASSWORD}`)
|
||||||
expect(res.status).toBe(400)
|
expect(res.status).toBe(200)
|
||||||
expect(res.json.error).toBe('invalid_grant')
|
expect(decodePayload(res.json.access_token as string).sub).toBe('6101')
|
||||||
// The exact sentence www's shared auth-messages table keys on to put a real
|
|
||||||
// message in front of the player — changing it silently downgrades that to the
|
|
||||||
// generic "you could not be signed in".
|
|
||||||
expect(res.json.error_description).toBe('this account is banned')
|
|
||||||
})
|
})
|
||||||
|
|
||||||
test('POST /connect/token refuses a username login from a banned account', async () => {
|
test('POST /connect/token issues a token to a banned account logging in by username', async () => {
|
||||||
await seedAccount(6102, 'BannedByName')
|
await seedAccount(6102, 'BannedByName')
|
||||||
await banAccount(6102)
|
await banAccount(6102)
|
||||||
|
|
||||||
const res = await postToken(
|
const res = await postToken(
|
||||||
`grant_type=password&username=BannedByName&password=${LOGIN_PASSWORD}`
|
`grant_type=password&username=BannedByName&password=${LOGIN_PASSWORD}`
|
||||||
)
|
)
|
||||||
expect(res.status).toBe(400)
|
expect(res.status).toBe(200)
|
||||||
expect(res.json.error_description).toBe('this account is banned')
|
expect(decodePayload(res.json.access_token as string).sub).toBe('6102')
|
||||||
})
|
})
|
||||||
|
|
||||||
// A client that was already signed in when the ban landed still holds a valid refresh
|
// A client that was already signed in when the ban landed refreshes as normal — its
|
||||||
// token; redeeming it must not renew the session.
|
// next matchmake is what refuses it, and moderationBlockDetails says why.
|
||||||
test('POST /connect/token refuses to refresh a banned account’s session', async () => {
|
test('POST /connect/token refreshes a banned account’s session', async () => {
|
||||||
await seedAccount(6103, 'BannedLater')
|
await seedAccount(6103, 'BannedLater')
|
||||||
const login = await postToken(`account_id=6103&password=${LOGIN_PASSWORD}`)
|
const login = await postToken(`account_id=6103&password=${LOGIN_PASSWORD}`)
|
||||||
expect(login.status).toBe(200)
|
expect(login.status).toBe(200)
|
||||||
@@ -1477,13 +1474,12 @@ describe('banned accounts', () => {
|
|||||||
const refreshed = await postToken(
|
const refreshed = await postToken(
|
||||||
`grant_type=refresh_token&refresh_token=${encodeURIComponent(refreshToken)}`
|
`grant_type=refresh_token&refresh_token=${encodeURIComponent(refreshToken)}`
|
||||||
)
|
)
|
||||||
expect(refreshed.status).toBe(400)
|
expect(refreshed.status).toBe(200)
|
||||||
expect(refreshed.json.error_description).toBe('this account is banned')
|
expect(decodePayload(refreshed.json.access_token as string).sub).toBe('6103')
|
||||||
})
|
})
|
||||||
|
|
||||||
// The ban check runs AFTER the credential check, so a wrong password on a banned
|
// A ban does not loosen the credential check: a wrong password on a banned account is
|
||||||
// account still answers the ordinary bad-credential refusal — it can't be used to
|
// the ordinary bad-credential refusal.
|
||||||
// find out whether an account exists or is banned without knowing its password.
|
|
||||||
test('a wrong password on a banned account is still a credential refusal', async () => {
|
test('a wrong password on a banned account is still a credential refusal', async () => {
|
||||||
await seedAccount(6104, 'BannedWrongPw')
|
await seedAccount(6104, 'BannedWrongPw')
|
||||||
await banAccount(6104)
|
await banAccount(6104)
|
||||||
@@ -1493,23 +1489,13 @@ describe('banned accounts', () => {
|
|||||||
expect(res.json.error_description).toBe('invalid account_id or password')
|
expect(res.json.error_description).toBe('invalid account_id or password')
|
||||||
})
|
})
|
||||||
|
|
||||||
// A timed ban lifts itself when its expiry passes; nothing clears the flag.
|
test('a ban that has not expired yet still issues a token', async () => {
|
||||||
test('an expired ban lets the account sign in again', async () => {
|
|
||||||
await seedAccount(6105, 'ServedTime')
|
|
||||||
await banAccount(6105, '2020-01-01T00:00:00.000Z')
|
|
||||||
|
|
||||||
const res = await postToken(`account_id=6105&password=${LOGIN_PASSWORD}`)
|
|
||||||
expect(res.status).toBe(200)
|
|
||||||
expect(decodePayload(res.json.access_token as string).sub).toBe('6105')
|
|
||||||
})
|
|
||||||
|
|
||||||
test('a ban that has not expired yet still refuses the login', async () => {
|
|
||||||
await seedAccount(6106, 'StillServing')
|
await seedAccount(6106, 'StillServing')
|
||||||
await banAccount(6106, new Date(Date.now() + 3_600_000).toISOString())
|
await banAccount(6106, new Date(Date.now() + 3_600_000).toISOString())
|
||||||
|
|
||||||
const res = await postToken(`account_id=6106&password=${LOGIN_PASSWORD}`)
|
const res = await postToken(`account_id=6106&password=${LOGIN_PASSWORD}`)
|
||||||
expect(res.status).toBe(400)
|
expect(res.status).toBe(200)
|
||||||
expect(res.json.error_description).toBe('this account is banned')
|
expect(decodePayload(res.json.access_token as string).sub).toBe('6106')
|
||||||
})
|
})
|
||||||
|
|
||||||
// A report is not a ban until a moderator converts it.
|
// A report is not a ban until a moderator converts it.
|
||||||
@@ -1534,8 +1520,10 @@ describe('banned accounts', () => {
|
|||||||
|
|
||||||
// The ban follows the player past the account it was written on: a login from an account
|
// The ban follows the player past the account it was written on: a login from an account
|
||||||
// that shares a proven platform identity or an IP with a banned one is refused, and a
|
// that shares a proven platform identity or an IP with a banned one is refused, and a
|
||||||
// signup carrying either is refused before it mints anything. See the api worker's
|
// signup carrying either is refused before it mints anything. Unlike the banned account
|
||||||
// bans-db.ts for the arms and the BAN_EVASION_MATCH knob.
|
// itself, such an account has no ban of its own for the block screen to describe, so
|
||||||
|
// there is nothing to let it in for. See the api worker's bans-db.ts for the arms and
|
||||||
|
// the BAN_EVASION_MATCH knob.
|
||||||
describe('ban evasion at the token endpoint', () => {
|
describe('ban evasion at the token endpoint', () => {
|
||||||
/** Seed a loginable account carrying the IPs it signed up / last logged in from. */
|
/** Seed a loginable account carrying the IPs it signed up / last logged in from. */
|
||||||
const account = async (id: number, name: string, ips: Record<string, string> = {}) => {
|
const account = async (id: number, name: string, ips: Record<string, string> = {}) => {
|
||||||
@@ -1615,7 +1603,7 @@ describe('ban evasion at the token endpoint', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
// The knob an operator reaches for when the IP arm locks out real players.
|
// The knob an operator reaches for when the IP arm locks out real players.
|
||||||
test('BAN_EVASION_MATCH=platform drops the IP arm but keeps the direct ban', async () => {
|
test('BAN_EVASION_MATCH=platform drops the IP arm but keeps the platform one', async () => {
|
||||||
const original = env.BAN_EVASION_MATCH
|
const original = env.BAN_EVASION_MATCH
|
||||||
await account(6320, 'KnobBanned', { signupIp: '203.0.113.50' })
|
await account(6320, 'KnobBanned', { signupIp: '203.0.113.50' })
|
||||||
await linkPlatformIdentity(env.DB, 6320, 0, 'steam-knobevader')
|
await linkPlatformIdentity(env.DB, 6320, 0, 'steam-knobevader')
|
||||||
@@ -1633,10 +1621,9 @@ describe('ban evasion at the token endpoint', () => {
|
|||||||
|
|
||||||
env.BAN_EVASION_MATCH = 'off'
|
env.BAN_EVASION_MATCH = 'off'
|
||||||
expect((await login(6322)).status).toBe(200)
|
expect((await login(6322)).status).toBe(200)
|
||||||
// The banned account itself is refused whatever the knob says.
|
// The banned account itself signs in whatever the knob says — its ban is
|
||||||
const banned = await login(6320)
|
// enforced at matchmake, and the knob only governs the linked arms.
|
||||||
expect(banned.status).toBe(400)
|
expect((await login(6320)).status).toBe(200)
|
||||||
expect(banned.json.error_description).toBe('this account is banned')
|
|
||||||
} finally {
|
} finally {
|
||||||
env.BAN_EVASION_MATCH = original
|
env.BAN_EVASION_MATCH = original
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,13 +32,11 @@ const AUTH_MESSAGES: Record<string, string> = {
|
|||||||
'no linked account for this platform identity':
|
'no linked account for this platform identity':
|
||||||
'No account is linked to this platform sign-in yet. Sign in with your password once to link it.',
|
'No account is linked to this platform sign-in yet. Sign in with your password once to link it.',
|
||||||
'refresh_token is invalid or expired': 'Your session has expired. Please sign in again.',
|
'refresh_token is invalid or expired': 'Your session has expired. Please sign in again.',
|
||||||
// Deliberately says nothing about when it lifts: auth sends one fixed description for
|
// An account that shares a device or network with a BANNED one. Phrased for BOTH the
|
||||||
// every ban (see its BANNED_DESCRIPTION), permanent or timed, so there is no expiry
|
|
||||||
// here to quote.
|
|
||||||
'this account is banned': 'This account is banned and cannot be signed in to.',
|
|
||||||
// Not this account, but one it shares a device or network with. Phrased for BOTH the
|
|
||||||
// person evading a ban and the housemate of one — the IP arm cannot tell them apart —
|
// person evading a ban and the housemate of one — the IP arm cannot tell them apart —
|
||||||
// and for both forms, since signup and sign-in send the same description.
|
// and for both forms, since signup and sign-in send the same description. A directly
|
||||||
|
// banned account is not refused a sign-in at all (auth issues it a token so the game
|
||||||
|
// client can show the block screen), which is why there is no "banned" entry here.
|
||||||
'this device or network is blocked':
|
'this device or network is blocked':
|
||||||
'This device or network is blocked. If you think that is a mistake, contact the server operator.',
|
'This device or network is blocked. If you think that is a mistake, contact the server operator.',
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user