mirror of
https://github.com/djdevin/recflare.git
synced 2026-09-08 14:41:28 -07:00
ban hammer
This commit is contained in:
@@ -0,0 +1,19 @@
|
|||||||
|
-- Turn a report into a ban. A report row already names the player it is against
|
||||||
|
-- (`reported_player_id`), so a moderator acting on one flips `banned` on that same row
|
||||||
|
-- rather than duplicating it into a second table — the ban then carries the report that
|
||||||
|
-- justified it (category, details, room, who filed it) with no join.
|
||||||
|
-- Generated from src/reports-db.ts (SCHEMA_DDL) — keep in sync.
|
||||||
|
--
|
||||||
|
-- `ban_expires` is an ISO-8601 UTC timestamp like `created_at`, and NULL means the ban
|
||||||
|
-- never expires. Kept as its own column rather than "banned until" alone so a lifted ban
|
||||||
|
-- (banned = 0) is distinguishable from an expired one, and so the row remains a report
|
||||||
|
-- once the ban is over. Rows stay append-only in every other respect.
|
||||||
|
--
|
||||||
|
-- Partial index: bans are rare next to reports, so indexing only the banned rows keeps
|
||||||
|
-- the lookup (done on every matchmake and every token grant) reading a handful of pages
|
||||||
|
-- instead of every report ever filed against that player. idx_report_reported stays —
|
||||||
|
-- it serves the "all reports against this player" moderation read, which is unfiltered.
|
||||||
|
|
||||||
|
ALTER TABLE report ADD COLUMN banned INTEGER NOT NULL DEFAULT 0;
|
||||||
|
ALTER TABLE report ADD COLUMN ban_expires TEXT;
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_report_banned ON report (reported_player_id) WHERE banned = 1;
|
||||||
+88
-10
@@ -3,19 +3,25 @@
|
|||||||
*
|
*
|
||||||
* Like the relationship table (and unlike the JSON-blob tables here — rooms /
|
* Like the relationship table (and unlike the JSON-blob tables here — rooms /
|
||||||
* accounts / image / invention), a report is genuinely columnar, so it gets a
|
* accounts / image / invention), a report is genuinely columnar, so it gets a
|
||||||
* normal relational table. Rows are append-only: nothing updates or dedupes a
|
* normal relational table. Rows are append-only in the sense that nothing rewrites
|
||||||
* report, so the table is a log of exactly what players submitted.
|
* what a player submitted: the table is a log of exactly what was reported.
|
||||||
*
|
*
|
||||||
* The `api` worker owns this schema/migration (migrations/0004_report.sql,
|
* The `api` worker owns this schema/migration (migrations/0004_report.sql and
|
||||||
* applied under its own `migrations_table` so it doesn't clash with the other
|
* 0009_report_ban.sql, applied under its own `migrations_table` so it doesn't clash
|
||||||
* workers' migrations that share the database).
|
* with the other workers' migrations that share the database).
|
||||||
*
|
*
|
||||||
* Nothing acts on the rows yet — `/api/PlayerReporting/v1/moderationBlockDetails`
|
* A report is also where an ACCOUNT-WIDE ban lives: acting on a report sets `banned`
|
||||||
* still answers "not blocked" unconditionally; this is the record that a future
|
* on that same row (see `banFromReport`), so the ban carries the evidence for it. Two
|
||||||
* moderation flow would read.
|
* workers read it — `match` refuses every matchmake for a banned player, and `auth`
|
||||||
|
* refuses to issue them a token at all — both via `isPlayerBanned`. This is distinct
|
||||||
|
* from the per-room `room_ban` table the rooms worker owns: that one keeps a player
|
||||||
|
* out of ONE room, this one out of the game.
|
||||||
|
*
|
||||||
|
* `/api/PlayerReporting/v1/moderationBlockDetails` is NOT wired to it yet and still
|
||||||
|
* answers "not blocked" unconditionally.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/** Schema DDL (mirror of migrations/0004_report.sql, sans seed rows). */
|
/** Schema DDL (mirror of migrations/0004_report.sql + 0009_report_ban.sql). */
|
||||||
export const SCHEMA_DDL: string[] = [
|
export const SCHEMA_DDL: string[] = [
|
||||||
`CREATE TABLE IF NOT EXISTS report (
|
`CREATE TABLE IF NOT EXISTS report (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
@@ -27,10 +33,13 @@ export const SCHEMA_DDL: string[] = [
|
|||||||
height_reported REAL,
|
height_reported REAL,
|
||||||
room_id INTEGER,
|
room_id INTEGER,
|
||||||
room_instance_type TEXT,
|
room_instance_type TEXT,
|
||||||
created_at TEXT NOT NULL
|
created_at TEXT NOT NULL,
|
||||||
|
banned INTEGER NOT NULL DEFAULT 0,
|
||||||
|
ban_expires TEXT
|
||||||
)`,
|
)`,
|
||||||
`CREATE INDEX IF NOT EXISTS idx_report_reported ON report (reported_player_id)`,
|
`CREATE INDEX IF NOT EXISTS idx_report_reported ON report (reported_player_id)`,
|
||||||
`CREATE INDEX IF NOT EXISTS idx_report_reporter ON report (reporter_player_id)`,
|
`CREATE INDEX IF NOT EXISTS idx_report_reporter ON report (reporter_player_id)`,
|
||||||
|
`CREATE INDEX IF NOT EXISTS idx_report_banned ON report (reported_player_id) WHERE banned = 1`,
|
||||||
]
|
]
|
||||||
|
|
||||||
/** A stored report row (snake_case columns, one row per submission). */
|
/** A stored report row (snake_case columns, one row per submission). */
|
||||||
@@ -47,6 +56,10 @@ export interface ReportRow {
|
|||||||
/** The instance's `RoomInstanceType` name, e.g. `Public`. Stored verbatim. */
|
/** The instance's `RoomInstanceType` name, e.g. `Public`. Stored verbatim. */
|
||||||
room_instance_type: string | null
|
room_instance_type: string | null
|
||||||
created_at: string
|
created_at: string
|
||||||
|
/** 1 when a moderator turned this report into a ban of `reported_player_id`. */
|
||||||
|
banned: number
|
||||||
|
/** ISO-8601 UTC instant the ban lifts; NULL means it never does. */
|
||||||
|
ban_expires: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -101,3 +114,68 @@ export async function getReportsAgainst(db: D1Database, playerId: number): Promi
|
|||||||
.all<ReportRow>()
|
.all<ReportRow>()
|
||||||
return results
|
return results
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The ban currently in force against a player, or null when they aren't banned.
|
||||||
|
*
|
||||||
|
* "In force" is narrower than `banned = 1`: a row whose `ban_expires` has passed is a
|
||||||
|
* ban that has SERVED ITS TIME, and the player is let back in without anyone having to
|
||||||
|
* go and clear the flag — the row stays as the record that it happened. A permanent ban
|
||||||
|
* carries no expiry at all (NULL), which is why that arm is checked separately rather
|
||||||
|
* than by comparing against some far-future date.
|
||||||
|
*
|
||||||
|
* When several bans are in force, the longest-lasting one wins: permanent first (NULL
|
||||||
|
* sorts ahead because `ban_expires IS NOT NULL` is 0 for it), then the latest expiry. So
|
||||||
|
* a fresh short ban can never shorten a standing one.
|
||||||
|
*/
|
||||||
|
export async function getActiveBan(
|
||||||
|
db: D1Database,
|
||||||
|
playerId: number,
|
||||||
|
now: Date = new Date()
|
||||||
|
): Promise<ReportRow | null> {
|
||||||
|
return db
|
||||||
|
.prepare(
|
||||||
|
`SELECT * FROM report
|
||||||
|
WHERE reported_player_id = ?1 AND banned = 1
|
||||||
|
AND (ban_expires IS NULL OR ban_expires > ?2)
|
||||||
|
ORDER BY ban_expires IS NOT NULL, ban_expires DESC
|
||||||
|
LIMIT 1`
|
||||||
|
)
|
||||||
|
.bind(playerId, now.toISOString())
|
||||||
|
.first<ReportRow>()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether a player is banned right now. The hot-path form of `getActiveBan` — `match`
|
||||||
|
* calls it on every matchmake and `auth` on every token grant, and neither has anything
|
||||||
|
* to say about WHICH report did it.
|
||||||
|
*/
|
||||||
|
export async function isPlayerBanned(
|
||||||
|
db: D1Database,
|
||||||
|
playerId: number,
|
||||||
|
now: Date = new Date()
|
||||||
|
): Promise<boolean> {
|
||||||
|
return (await getActiveBan(db, playerId, now)) !== null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Turn a report into a ban of the player it was filed against — the moderator action the
|
||||||
|
* `banned` column exists for. `banExpires` is an ISO-8601 UTC instant, or null for a
|
||||||
|
* permanent ban. Passing `banned: false` lifts the ban and clears the expiry, leaving the
|
||||||
|
* report itself intact.
|
||||||
|
*
|
||||||
|
* Returns the updated row, or null when there is no report with that id — so the caller
|
||||||
|
* can tell "banned" from "banned nobody" (wrangler's `d1 execute --json` reports no
|
||||||
|
* changes count, hence RETURNING).
|
||||||
|
*/
|
||||||
|
export async function banFromReport(
|
||||||
|
db: D1Database,
|
||||||
|
reportId: number,
|
||||||
|
options: { banned?: boolean; banExpires?: string | null } = {}
|
||||||
|
): Promise<ReportRow | null> {
|
||||||
|
const banned = options.banned ?? true
|
||||||
|
return db
|
||||||
|
.prepare('UPDATE report SET banned = ?2, ban_expires = ?3 WHERE id = ?1 RETURNING *')
|
||||||
|
.bind(reportId, banned ? 1 : 0, banned ? (options.banExpires ?? null) : null)
|
||||||
|
.first<ReportRow>()
|
||||||
|
}
|
||||||
|
|||||||
@@ -59,18 +59,22 @@ const asFloat = (v: string | undefined): number | null => {
|
|||||||
|
|
||||||
// ---- Player reporting ------------------------------------------------------
|
// ---- Player reporting ------------------------------------------------------
|
||||||
export const moderationRoutes = new Hono<App>({ strict: false })
|
export const moderationRoutes = new Hono<App>({ strict: false })
|
||||||
// Whether the caller is currently blocked (banned / timed out / host-kicked). No
|
// Whether the caller is currently blocked (banned / timed out / host-kicked). Bans
|
||||||
// ban storage yet, so this is always the "not blocked" answer. `ReportCategory` is
|
// are stored (a report row with `banned` set) and enforced at matchmake and at login,
|
||||||
// -1 (no category) rather than 0, which is a real category; `Message` is null, not
|
// but this endpoint is not wired to them, so it's always the "not blocked" answer.
|
||||||
// an empty string — the client distinguishes "no message" from a blank one.
|
// `ReportCategory` is -1 (no category) rather than 0, which is a real category;
|
||||||
|
// `Message` is null, not an empty string — the client distinguishes "no message"
|
||||||
|
// from a blank one.
|
||||||
.get(
|
.get(
|
||||||
'/api/PlayerReporting/v1/moderationBlockDetails',
|
'/api/PlayerReporting/v1/moderationBlockDetails',
|
||||||
describeRoute({
|
describeRoute({
|
||||||
tags: ['Moderation'],
|
tags: ['Moderation'],
|
||||||
summary: 'Whether the caller is blocked',
|
summary: 'Whether the caller is blocked',
|
||||||
description:
|
description:
|
||||||
'Ban / timeout / host-kick state for the caller. There is no ban storage yet, so ' +
|
'Ban / timeout / host-kick state for the caller. Bans are stored (a `report` row ' +
|
||||||
'this is always the “not blocked” answer. Two details matter to the client: ' +
|
'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. Two details matter ' +
|
||||||
|
'to the client: ' +
|
||||||
'`ReportCategory` is -1 (no category) rather than 0, which is a real category, and ' +
|
'`ReportCategory` is -1 (no category) rather than 0, which is a real category, and ' +
|
||||||
'`Message` is null rather than an empty string — the client distinguishes “no ' +
|
'`Message` is null rather than an empty string — the client distinguishes “no ' +
|
||||||
'message” from a blank one.',
|
'message” from a blank one.',
|
||||||
@@ -122,9 +126,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 — an append-only log; nothing ' +
|
'Records a player report in the `report` table; nothing dedupes the rows, and ' +
|
||||||
'dedupes or acts on the rows yet, and `moderationBlockDetails` still answers ' +
|
'`moderationBlockDetails` still answers “not blocked” unconditionally. A report ' +
|
||||||
'“not blocked” unconditionally.\n\n' +
|
'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' +
|
||||||
'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 ' +
|
||||||
|
|||||||
@@ -22,7 +22,13 @@ import {
|
|||||||
import { createImage, getImageByName, SCHEMA_DDL as IMAGES_SCHEMA_DDL } from '../../images-db'
|
import { createImage, getImageByName, SCHEMA_DDL as IMAGES_SCHEMA_DDL } from '../../images-db'
|
||||||
import { SCHEMA_DDL as INVENTIONS_SCHEMA_DDL } from '../../inventions-db'
|
import { SCHEMA_DDL as INVENTIONS_SCHEMA_DDL } from '../../inventions-db'
|
||||||
import { SCHEMA_DDL as RELATIONSHIPS_SCHEMA_DDL } from '../../relationships-db'
|
import { SCHEMA_DDL as RELATIONSHIPS_SCHEMA_DDL } from '../../relationships-db'
|
||||||
import { getReportsAgainst, SCHEMA_DDL as REPORTS_SCHEMA_DDL } from '../../reports-db'
|
import {
|
||||||
|
banFromReport,
|
||||||
|
getActiveBan,
|
||||||
|
getReportsAgainst,
|
||||||
|
isPlayerBanned,
|
||||||
|
SCHEMA_DDL as REPORTS_SCHEMA_DDL,
|
||||||
|
} from '../../reports-db'
|
||||||
import { getWarningsAgainst, SCHEMA_DDL as WARNINGS_SCHEMA_DDL } from '../../warnings-db'
|
import { getWarningsAgainst, SCHEMA_DDL as WARNINGS_SCHEMA_DDL } from '../../warnings-db'
|
||||||
|
|
||||||
import type { Env } from '../../context'
|
import type { Env } from '../../context'
|
||||||
@@ -1331,6 +1337,84 @@ describe('player reports', () => {
|
|||||||
// Same envelope as the success branch — the client parses only one shape.
|
// Same envelope as the success branch — the client parses only one shape.
|
||||||
expect(await res.json()).toEqual({ success: false, error: 'PlayerIdReported is required' })
|
expect(await res.json()).toEqual({ success: false, error: 'PlayerIdReported is required' })
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// A report is filed unbanned; a moderator converting it into a ban is what the
|
||||||
|
// `banned` / `ban_expires` columns are for. `match` and `auth` read exactly this.
|
||||||
|
test('a report is filed unbanned', async () => {
|
||||||
|
await submit({ PlayerIdReported: '210' }, await bearer())
|
||||||
|
const [row] = await getReportsAgainst(env.DB, 210)
|
||||||
|
expect(row).toMatchObject({ banned: 0, ban_expires: null })
|
||||||
|
expect(await isPlayerBanned(env.DB, 210)).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('banFromReport bans the reported player, permanently by default', async () => {
|
||||||
|
await submit({ PlayerIdReported: '211', Details: 'the evidence' }, await bearer())
|
||||||
|
const [row] = await getReportsAgainst(env.DB, 211)
|
||||||
|
|
||||||
|
const banned = await banFromReport(env.DB, row!.id)
|
||||||
|
expect(banned).toMatchObject({ banned: 1, ban_expires: null })
|
||||||
|
// The report the ban was made from is still attached to it — the point of
|
||||||
|
// banning on the row rather than in a table of its own.
|
||||||
|
expect(banned?.details).toBe('the evidence')
|
||||||
|
expect(await isPlayerBanned(env.DB, 211)).toBe(true)
|
||||||
|
// It bans the REPORTED player, not the reporter who filed it.
|
||||||
|
expect(await isPlayerBanned(env.DB, 42)).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
// A timed ban lifts itself: nothing clears the flag, the expiry just passes.
|
||||||
|
test('a ban with a past expiry is no longer in force', async () => {
|
||||||
|
await submit({ PlayerIdReported: '212' }, await bearer())
|
||||||
|
const [row] = await getReportsAgainst(env.DB, 212)
|
||||||
|
await banFromReport(env.DB, row!.id, { banExpires: '2020-01-01T00:00:00.000Z' })
|
||||||
|
|
||||||
|
expect(await isPlayerBanned(env.DB, 212)).toBe(false)
|
||||||
|
// Still on the row, as the record that it happened.
|
||||||
|
expect((await getReportsAgainst(env.DB, 212))[0]).toMatchObject({ banned: 1 })
|
||||||
|
// And in force while it lasted.
|
||||||
|
expect(await isPlayerBanned(env.DB, 212, new Date('2019-06-01T00:00:00.000Z'))).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('a ban with a future expiry is in force', async () => {
|
||||||
|
await submit({ PlayerIdReported: '213' }, await bearer())
|
||||||
|
const [row] = await getReportsAgainst(env.DB, 213)
|
||||||
|
const expires = new Date(Date.now() + 86_400_000).toISOString()
|
||||||
|
await banFromReport(env.DB, row!.id, { banExpires: expires })
|
||||||
|
|
||||||
|
expect(await isPlayerBanned(env.DB, 213)).toBe(true)
|
||||||
|
expect((await getActiveBan(env.DB, 213))?.ban_expires).toBe(expires)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Two bans in force: the longest-lasting one is the one reported, so a fresh short
|
||||||
|
// ban can't shorten a standing permanent one.
|
||||||
|
test('getActiveBan prefers the permanent ban', async () => {
|
||||||
|
await submit({ PlayerIdReported: '214', Details: 'timed' }, await bearer())
|
||||||
|
await submit({ PlayerIdReported: '214', Details: 'permanent' }, await bearer())
|
||||||
|
const rows = await getReportsAgainst(env.DB, 214)
|
||||||
|
const timed = rows.find((r) => r.details === 'timed')!
|
||||||
|
const permanent = rows.find((r) => r.details === 'permanent')!
|
||||||
|
await banFromReport(env.DB, timed.id, {
|
||||||
|
banExpires: new Date(Date.now() + 3_600_000).toISOString(),
|
||||||
|
})
|
||||||
|
await banFromReport(env.DB, permanent.id)
|
||||||
|
|
||||||
|
expect(await getActiveBan(env.DB, 214)).toMatchObject({ details: 'permanent' })
|
||||||
|
})
|
||||||
|
|
||||||
|
test('banFromReport with banned:false lifts the ban and clears the expiry', async () => {
|
||||||
|
await submit({ PlayerIdReported: '215' }, await bearer())
|
||||||
|
const [row] = await getReportsAgainst(env.DB, 215)
|
||||||
|
await banFromReport(env.DB, row!.id, { banExpires: '2999-01-01T00:00:00.000Z' })
|
||||||
|
expect(await isPlayerBanned(env.DB, 215)).toBe(true)
|
||||||
|
|
||||||
|
const lifted = await banFromReport(env.DB, row!.id, { banned: false })
|
||||||
|
expect(lifted).toMatchObject({ banned: 0, ban_expires: null })
|
||||||
|
expect(await isPlayerBanned(env.DB, 215)).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
// No such report — the caller can tell that from having banned nobody.
|
||||||
|
test('banFromReport returns null for an unknown report', async () => {
|
||||||
|
expect(await banFromReport(env.DB, 999_999)).toBeNull()
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('player warnings', () => {
|
describe('player warnings', () => {
|
||||||
|
|||||||
@@ -24,6 +24,9 @@ import {
|
|||||||
import { intVar, logger, withCleanSpec, withDefaultCors, withNotFound, withOnError } from '@repo/hono-helpers'
|
import { intVar, logger, withCleanSpec, withDefaultCors, withNotFound, withOnError } from '@repo/hono-helpers'
|
||||||
import { generateToken, TOKEN_TTL_SECONDS, validateAndGetAccountId } from '@repo/jwt'
|
import { generateToken, TOKEN_TTL_SECONDS, 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.
|
||||||
|
import { getActiveBan } from '../../api/src/reports-db'
|
||||||
import { verifyMetaNonce } from './meta-nonce'
|
import { verifyMetaNonce } from './meta-nonce'
|
||||||
import {
|
import {
|
||||||
CachedLogin,
|
CachedLogin,
|
||||||
@@ -58,6 +61,14 @@ import type { PlatformLink } from './platform-db'
|
|||||||
const TOKEN_SCOPE =
|
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,
|
||||||
|
* never interpolated with the expiry, because `www`'s shared auth-messages table keys on
|
||||||
|
* this exact string to put a 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.
|
||||||
|
*/
|
||||||
|
const BANNED_DESCRIPTION = 'this account is banned'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The platform id a SIDELOADED Oculus APK reports. It is not an identity: a sideloaded
|
* The platform id a SIDELOADED Oculus APK reports. It is not an identity: a sideloaded
|
||||||
* build has no Meta SDK to ask, so it has nothing real to report — and every sideloaded
|
* build has no Meta SDK to ask, so it has nothing real to report — and every sideloaded
|
||||||
@@ -546,6 +557,12 @@ const app = new Hono<App>()
|
|||||||
'',
|
'',
|
||||||
'**Roles.** The token embeds a `role` claim from the account, so developer/moderator',
|
'**Roles.** The token embeds a `role` claim from the account, so developer/moderator',
|
||||||
'powers refresh on every login and every refresh grant.',
|
'powers refresh on every login and every refresh grant.',
|
||||||
|
'',
|
||||||
|
'**Bans.** Once the grant has resolved an account, a BANNED account is refused a',
|
||||||
|
'token at all (`invalid_grant`) — every grant, including a refresh. A ban is a',
|
||||||
|
'`report` row with `banned` set (the `api` worker owns that table); it lifts on its',
|
||||||
|
'own when `ban_expires` passes, and never if that is null. The ban belongs to the',
|
||||||
|
'account, so it does not stop the player creating a new one — only the signup caps do.',
|
||||||
].join('\n'),
|
].join('\n'),
|
||||||
requestBody: form(
|
requestBody: form(
|
||||||
TokenRequest,
|
TokenRequest,
|
||||||
@@ -557,7 +574,8 @@ const app = new Hono<App>()
|
|||||||
OAuthError,
|
OAuthError,
|
||||||
[
|
[
|
||||||
'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, or a signup cap reached',
|
'invalid/expired refresh token, a missing account identifier, a signup cap reached,',
|
||||||
|
'or a banned account',
|
||||||
].join(' ')
|
].join(' ')
|
||||||
),
|
),
|
||||||
500: json(
|
500: json(
|
||||||
@@ -869,6 +887,31 @@ 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 account gets no token — and with no token every other worker is shut
|
||||||
|
// to it, so this is the outer wall of a ban; matchmaking's refusal is the inner
|
||||||
|
// one, which still has to exist because a token issued before the ban stays valid
|
||||||
|
// until it expires.
|
||||||
|
//
|
||||||
|
// 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
|
||||||
|
// used to probe whether an account exists or is banned without knowing it.
|
||||||
|
//
|
||||||
|
// It is per-account, and the ban is the account's, not the person's: nothing here
|
||||||
|
// stops a banned player creating a new account and playing on. Refusing that is a
|
||||||
|
// signup-cap/platform-identity problem, not this check's.
|
||||||
|
const ban = await getActiveBan(c.env.DB, Number(accountId))
|
||||||
|
if (ban) {
|
||||||
|
logger.info('token refused: account banned', {
|
||||||
|
accountId,
|
||||||
|
grantType,
|
||||||
|
reportId: ban.id,
|
||||||
|
banExpires: ban.ban_expires,
|
||||||
|
})
|
||||||
|
return c.json({ error: 'invalid_grant', error_description: BANNED_DESCRIPTION }, 400)
|
||||||
|
}
|
||||||
|
|
||||||
// Never sign with an empty key. An empty JWT_SECRET (misconfigured/missing
|
// Never sign with an empty key. An empty JWT_SECRET (misconfigured/missing
|
||||||
// binding) would still yield a well-formed token — but one signed with an empty
|
// binding) would still yield a well-formed token — but one signed with an empty
|
||||||
// key, which every worker validates against, so anyone could forge it. Refuse to
|
// key, which every worker validates against, so anyone could forge it. Refuse to
|
||||||
|
|||||||
@@ -15,6 +15,11 @@ import {
|
|||||||
} from '@repo/domain'
|
} from '@repo/domain'
|
||||||
import { TOKEN_TTL_SECONDS } from '@repo/jwt'
|
import { TOKEN_TTL_SECONDS } from '@repo/jwt'
|
||||||
|
|
||||||
|
import {
|
||||||
|
banFromReport,
|
||||||
|
createReport,
|
||||||
|
SCHEMA_DDL as REPORTS_SCHEMA_DDL,
|
||||||
|
} from '../../../../api/src/reports-db'
|
||||||
import {
|
import {
|
||||||
getLinksForAccount,
|
getLinksForAccount,
|
||||||
linkPlatformIdentity,
|
linkPlatformIdentity,
|
||||||
@@ -81,8 +86,27 @@ 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
|
||||||
|
// a ban is a report row with `banned` set.
|
||||||
|
for (const stmt of REPORTS_SCHEMA_DDL) await env.DB.prepare(stmt).run()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ban an account the way a moderator would: file a report against it and convert that
|
||||||
|
* report into a ban. `banExpires` null is a permanent ban.
|
||||||
|
*/
|
||||||
|
async function banAccount(accountId: number, banExpires: string | null = null): Promise<void> {
|
||||||
|
const row = await createReport(env.DB, { reporterPlayerId: 1, reportedPlayerId: accountId })
|
||||||
|
await banFromReport(env.DB, row.id, { banExpires })
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Seed an account with LOGIN_PASSWORD set, so it can be logged into. */
|
||||||
|
async function seedAccount(accountId: number, username: string): Promise<void> {
|
||||||
|
await env.DB.prepare('INSERT OR IGNORE INTO account (data) VALUES (?1)')
|
||||||
|
.bind(JSON.stringify({ accountId, username, passwordHash: await hashPassword(LOGIN_PASSWORD) }))
|
||||||
|
.run()
|
||||||
|
}
|
||||||
|
|
||||||
/** Decode a JWT payload (no verification) for asserting claims. */
|
/** Decode a JWT payload (no verification) for asserting claims. */
|
||||||
function decodePayload(token: string): Record<string, unknown> {
|
function decodePayload(token: string): Record<string, unknown> {
|
||||||
const part = token.split('.')[1].replace(/-/g, '+').replace(/_/g, '/')
|
const part = token.split('.')[1].replace(/-/g, '+').replace(/_/g, '/')
|
||||||
@@ -1147,3 +1171,99 @@ describe('CORS', () => {
|
|||||||
)
|
)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// A banned account is refused a token at all — the outer wall of a ban, since with no
|
||||||
|
// token every other worker is shut to it. The ban is a `report` row with `banned` set
|
||||||
|
// (the api worker owns that table); matchmaking enforces the same ban on tokens issued
|
||||||
|
// before it was handed down.
|
||||||
|
describe('banned accounts', () => {
|
||||||
|
test('POST /connect/token refuses a password grant from a banned account', async () => {
|
||||||
|
await seedAccount(6101, 'BannedPlayer')
|
||||||
|
await banAccount(6101)
|
||||||
|
|
||||||
|
const res = await postToken(`account_id=6101&password=${LOGIN_PASSWORD}`)
|
||||||
|
expect(res.status).toBe(400)
|
||||||
|
expect(res.json.error).toBe('invalid_grant')
|
||||||
|
// 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 () => {
|
||||||
|
await seedAccount(6102, 'BannedByName')
|
||||||
|
await banAccount(6102)
|
||||||
|
|
||||||
|
const res = await postToken(
|
||||||
|
`grant_type=password&username=BannedByName&password=${LOGIN_PASSWORD}`
|
||||||
|
)
|
||||||
|
expect(res.status).toBe(400)
|
||||||
|
expect(res.json.error_description).toBe('this account is banned')
|
||||||
|
})
|
||||||
|
|
||||||
|
// A client that was already signed in when the ban landed still holds a valid refresh
|
||||||
|
// token; redeeming it must not renew the session.
|
||||||
|
test('POST /connect/token refuses to refresh a banned account’s session', async () => {
|
||||||
|
await seedAccount(6103, 'BannedLater')
|
||||||
|
const login = await postToken(`account_id=6103&password=${LOGIN_PASSWORD}`)
|
||||||
|
expect(login.status).toBe(200)
|
||||||
|
const refreshToken = login.json.refresh_token as string
|
||||||
|
|
||||||
|
await banAccount(6103)
|
||||||
|
const refreshed = await postToken(
|
||||||
|
`grant_type=refresh_token&refresh_token=${encodeURIComponent(refreshToken)}`
|
||||||
|
)
|
||||||
|
expect(refreshed.status).toBe(400)
|
||||||
|
expect(refreshed.json.error_description).toBe('this account is banned')
|
||||||
|
})
|
||||||
|
|
||||||
|
// The ban check runs AFTER the credential check, so a wrong password on a banned
|
||||||
|
// account still answers the ordinary bad-credential refusal — it can't be used to
|
||||||
|
// 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 () => {
|
||||||
|
await seedAccount(6104, 'BannedWrongPw')
|
||||||
|
await banAccount(6104)
|
||||||
|
|
||||||
|
const res = await postToken('account_id=6104&password=not-the-password')
|
||||||
|
expect(res.status).toBe(400)
|
||||||
|
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('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 banAccount(6106, new Date(Date.now() + 3_600_000).toISOString())
|
||||||
|
|
||||||
|
const res = await postToken(`account_id=6106&password=${LOGIN_PASSWORD}`)
|
||||||
|
expect(res.status).toBe(400)
|
||||||
|
expect(res.json.error_description).toBe('this account is banned')
|
||||||
|
})
|
||||||
|
|
||||||
|
// A report is not a ban until a moderator converts it.
|
||||||
|
test('an unbanned report does not refuse the login', async () => {
|
||||||
|
await seedAccount(6107, 'MerelyReported')
|
||||||
|
await createReport(env.DB, { reporterPlayerId: 1, reportedPlayerId: 6107 })
|
||||||
|
|
||||||
|
const res = await postToken(`account_id=6107&password=${LOGIN_PASSWORD}`)
|
||||||
|
expect(res.status).toBe(200)
|
||||||
|
})
|
||||||
|
|
||||||
|
// The ban is the ACCOUNT's: nothing here stops the player signing up again, which is
|
||||||
|
// the signup caps' job, not this check's.
|
||||||
|
test('a banned player can still create a new account', async () => {
|
||||||
|
await seedAccount(6108, 'BannedButNew')
|
||||||
|
await banAccount(6108)
|
||||||
|
|
||||||
|
const created = await postToken('grant_type=create_account&platform_id=steam-after-ban')
|
||||||
|
expect(created.status).toBe(200)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
@@ -38,6 +38,10 @@ import {
|
|||||||
import { logger, withCleanSpec, withNotFound, withOnError } from '@repo/hono-helpers'
|
import { logger, withCleanSpec, withNotFound, withOnError } from '@repo/hono-helpers'
|
||||||
import { validateAndGetAccountId } from '@repo/jwt'
|
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
|
// 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.
|
// deps), so /invite sends a typed MessageReceived frame instead of a magic number.
|
||||||
import { NotificationType } from '../../notify/src/notification-types'
|
import { NotificationType } from '../../notify/src/notification-types'
|
||||||
@@ -696,6 +700,28 @@ const app = new Hono<App>()
|
|||||||
})(c, next)
|
})(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())
|
.onError(withOnError())
|
||||||
.notFound(withNotFound())
|
.notFound(withNotFound())
|
||||||
|
|
||||||
@@ -1288,11 +1314,12 @@ const app = new Hono<App>()
|
|||||||
description: [
|
description: [
|
||||||
'Single-segment matchmake into the caller’s personal dorm, stored as presence. The',
|
'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',
|
'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(' '),
|
].join(' '),
|
||||||
security: AUTHED,
|
security: AUTHED,
|
||||||
responses: {
|
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,
|
401: UNAUTHORIZED_RESPONSE,
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -21,6 +21,11 @@ import {
|
|||||||
SUBROOM_SCHEMA_DDL,
|
SUBROOM_SCHEMA_DDL,
|
||||||
} from '@repo/domain'
|
} from '@repo/domain'
|
||||||
|
|
||||||
|
import {
|
||||||
|
banFromReport,
|
||||||
|
createReport,
|
||||||
|
SCHEMA_DDL as REPORTS_SCHEMA_DDL,
|
||||||
|
} from '../../../../api/src/reports-db'
|
||||||
import { scheduled } from '../../match.app'
|
import { scheduled } from '../../match.app'
|
||||||
|
|
||||||
import type { Env } from '../../context'
|
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(9702, 9700, 3), // friends (9702 requested) — friend is the requester
|
||||||
insertRel.bind(9700, 9703, 1), // pending request out — 9703 is NOT a friend
|
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
|
// 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
|
// match worker's validation accepts it. Kept inline to avoid a cross-package
|
||||||
// import.
|
// import.
|
||||||
@@ -1218,8 +1236,11 @@ describe('auth-gated endpoints', () => {
|
|||||||
|
|
||||||
// No token → 401.
|
// No token → 401.
|
||||||
expect(
|
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)
|
).toBe(401)
|
||||||
|
|
||||||
// Authed but not the room's owner or co-owner → the opaque NoSuchRoom refusal,
|
// Authed but not the room's owner or co-owner → the opaque NoSuchRoom refusal,
|
||||||
@@ -1598,8 +1619,9 @@ describe('auth-gated endpoints', () => {
|
|||||||
roomInstance: null,
|
roomInstance: null,
|
||||||
})
|
})
|
||||||
} finally {
|
} finally {
|
||||||
await env.DB.prepare('DELETE FROM room_ban WHERE room_id = 2 AND banned_player_id = 9800')
|
await env.DB.prepare(
|
||||||
.run()
|
'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