diff --git a/docs/guides/beta-test-plans.md b/docs/guides/beta-test-plans.md index 95c94f6..49286e8 100644 --- a/docs/guides/beta-test-plans.md +++ b/docs/guides/beta-test-plans.md @@ -51,7 +51,8 @@ Send JSON to `POST /api/telemetry` with `Content-Type: application/json` and eit `X-EC-Telemetry-Token: `. If both headers are present, they must contain the same secret. Keep the secret out of URLs, payloads and browser code, and use a different secret from host-agent credentials or account passwords. -The JSON event format is unchanged. +Request bodies are limited to 1 MiB (1,048,576 bytes), including requests without +`Content-Length`. The limit applies to the UTF-8 JSON body before parsing. There are four events. Send them whether or not a beta is active. ### Event fields @@ -65,8 +66,11 @@ There are four events. Send them whether or not a beta is active. `server` is the exact registered Electric Cherry server name. `map` is the current map. `players` is the full player array, including an empty array when -there are no players. Names and GUIDs are strings; keep GUIDs as strings to -preserve their precision. Round and stat values are nonnegative JSON integers. +there are no players. `server`, `map`, and each player's `name` and `guid` must be +nonempty strings of at most 255 UTF-16 code units (JavaScript string length). +Values over the limit reject the entire report; accepted values are preserved +without trimming or truncation. Keep GUIDs as strings to preserve their precision +and leading zeros. Round and stat values are nonnegative JSON integers. Each GUID must appear only once within a report's player array. Every event also accepts an optional per-player `ping`: the game server's current @@ -171,8 +175,9 @@ Success returns HTTP 200 with `{ "ok": true, "telemetry": { ... } }`. The returned telemetry contains the event and an Electric Cherry `receivedAt` timestamp. A missing/invalid machine token returns 401; an unset or invalid controller `EC_TELEMETRY_TOKEN` returns 503. Authentication happens before payload -validation. Authenticated invalid JSON or fields return 400, an unknown/destroyed -server 404, and a non-JSON content type 415. +validation. Authenticated bodies over 1 MiB return 413 before JSON parsing. +Invalid JSON or fields return 400, an unknown/destroyed server 404, and a non-JSON +content type 415. The script sends each event once, in gameplay order, directly to Electric Cherry. There is no discovery request, beta token, boot/session ID, sequence number, @@ -207,7 +212,8 @@ name. Each entry includes the Electric Cherry receipt time, HTTP result, error reason, event, map, round, player count, content type, and submitted JSON. Rejected reports are included, even when the content type is wrong but the body is readable JSON after authentication. Unauthorized requests are rejected before their bodies -are read and are not retained in per-server diagnostics. Authentication headers +are read and are not retained in per-server diagnostics. Oversized bodies are +rejected before parsing and are not retained either. Authentication headers and their secrets are never stored. Processing failures return 500 and record the internal error when diagnostic storage is available; failed database transactions do not update the scoreboard. @@ -224,8 +230,9 @@ newest first, with caching disabled. Check the reporter's destination and server field. Calls that never reach Electric Cherry, malformed JSON, and bodies without a usable server name cannot appear in this server's log. Earlier requests are not backfilled. - Missing/invalid telemetry credentials also produce no retained request; inspect - the reporter's HTTP result for 401 or an unconfigured-controller 503. + Missing/invalid telemetry credentials and oversized bodies also produce no + retained request; inspect the reporter's HTTP result for 401, 413, or an + unconfigured-controller 503. - **400 / 415:** inspect the error and payload. One invalid player's stats or a duplicate GUID rejects the entire report. These requests do not update the scoreboard. diff --git a/src/lib/telemetry.ts b/src/lib/telemetry.ts index ed96e03..1b98534 100644 --- a/src/lib/telemetry.ts +++ b/src/lib/telemetry.ts @@ -59,8 +59,10 @@ export function parseGameEvent(value: unknown): GameEvent { throw new Error(`players[${index}] must be an object`); const player = value as Record; for (const key of ['name', 'guid'] as const) { - if (typeof player[key] !== 'string' || !player[key].trim()) - throw new Error(`players[${index}].${key} must be a nonempty string`); + if (typeof player[key] !== 'string' || player[key].length > 255 || !player[key].trim()) + throw new Error( + `players[${index}].${key} must be a nonempty string (up to 255 characters)` + ); } const identity: TelemetryPlayer = { name: player.name as string, diff --git a/tests/unit/telemetry-limits.test.ts b/tests/unit/telemetry-limits.test.ts new file mode 100644 index 0000000..e0a6b02 --- /dev/null +++ b/tests/unit/telemetry-limits.test.ts @@ -0,0 +1,182 @@ +import { afterEach, beforeEach, expect, it, vi } from 'vitest'; +import { JSON_BODY_LIMIT } from '../../src/lib/server/request-body.js'; +import { newFixture, type Fixture } from '../helpers/fixture.js'; +import { gameEvent, postTelemetry as post, TEST_TELEMETRY_TOKEN } from '../helpers/telemetry.js'; + +const events = ['game_start', 'round_change', 'heartbeat', 'game_end'] as const; +const identityLimit = 255; +let fixture: Fixture; + +beforeEach(async () => { + vi.resetModules(); + vi.stubEnv('EC_TELEMETRY_TOKEN', TEST_TELEMETRY_TOKEN); + fixture = await newFixture(); + const { seedServerRegistry } = await import('../../src/lib/server/fleet/servers.js'); + await seedServerRegistry(); +}); + +afterEach(async () => { + vi.restoreAllMocks(); + vi.unstubAllEnvs(); + await fixture.cleanup(); +}); + +async function storedEvidence() { + const { db } = await import('../../src/lib/server/db/index.js'); + return { + telemetry: db().prepare('SELECT * FROM server_telemetry').all(), + players: db().prepare('SELECT * FROM leaderboard_players').all(), + matches: db().prepare('SELECT * FROM leaderboard_matches').all(), + participation: db().prepare('SELECT * FROM leaderboard_match_players').all(), + beta: db().prepare('SELECT * FROM beta_test_evidence').all(), + lifecycle: db() + .prepare( + 'SELECT active_since, inactive_since, game_started_at, game_ended_at FROM servers' + ) + .all() + }; +} + +function streamedRequest(chunks: Uint8Array[], headers: Record = {}) { + let index = 0; + const pull = vi.fn((controller: ReadableStreamDefaultController) => { + if (index === chunks.length) controller.close(); + else controller.enqueue(chunks[index++]); + }); + const cancel = vi.fn(); + const request = new Request('http://localhost/api/telemetry', { + method: 'POST', + headers: { 'Content-Type': 'application/json', ...headers }, + body: new ReadableStream({ pull, cancel }, { highWaterMark: 0 }), + duplex: 'half' + } as RequestInit); + return { request, pull, cancel }; +} + +it.each(events)('preserves exact-limit player identities for %s', async (event) => { + const payload = gameEvent(event, 17, 1); + // Lengths use UTF-16 code units, while the request cap counts UTF-8 bytes. + const name = ` ${'🍒'.repeat(126)}é `; + const guid = `000${'9'.repeat(identityLimit - 3)}`; + expect(name.length).toBe(identityLimit); + payload.players[0] = { ...payload.players[0], name, guid }; + + const response = await post(payload); + expect(response.status).toBe(200); + expect((await response.json()).telemetry.players).toEqual(payload.players); + const { listTelemetry } = await import('../../src/lib/server/fleet/telemetry.js'); + expect(listTelemetry()[0].players).toEqual(payload.players); + const { db } = await import('../../src/lib/server/db/index.js'); + expect(db().prepare('SELECT name, guid FROM leaderboard_players').all()).toEqual([ + { name, guid } + ]); +}); + +it('preserves an opaque Unicode GUID at the length limit', async () => { + const payload = gameEvent('game_start', 0, 1); + payload.players[0].guid = ` ${'識'.repeat(identityLimit - 2)} `; + const response = await post(payload); + expect(response.status).toBe(200); + expect((await response.json()).telemetry.players).toEqual(payload.players); +}); + +it.each(events)('rejects oversized %s identities without changing game evidence', async (event) => { + expect((await post(gameEvent('heartbeat', 5))).status).toBe(200); + const before = await storedEvidence(); + const { subscribe } = await import('../../src/lib/server/pipeline/bus.js'); + const listener = vi.fn(); + const unsubscribe = subscribe(listener); + try { + for (const field of ['name', 'guid'] as const) { + for (const length of [identityLimit + 1, 20_000]) { + const payload = gameEvent(event, 17, 1); + payload.players[0] = { ...payload.players[0], [field]: 'x'.repeat(length) }; + const response = await post(payload); + expect(response.status).toBe(400); + const result = await response.json(); + expect(result).toMatchObject({ ok: false }); + expect(result.error).toContain(`players[0].${field}`); + expect(result.error).toContain(String(identityLimit)); + } + } + expect(await storedEvidence()).toEqual(before); + expect(listener).not.toHaveBeenCalled(); + } finally { + unsubscribe(); + } +}); + +it('accepts a telemetry body at the exact 1 MiB UTF-8 byte limit', async () => { + const payload = gameEvent('heartbeat', 17, 1); + const json = JSON.stringify({ ...payload, diagnostic: 'é🍒' }); + const bytes = Buffer.from(json + ' '.repeat(JSON_BODY_LIMIT - Buffer.byteLength(json))); + expect(bytes.byteLength).toBe(1024 * 1024); + const { request } = streamedRequest([bytes.subarray(0, 100), bytes.subarray(100)], { + Authorization: `Bearer ${TEST_TELEMETRY_TOKEN}`, + 'Content-Length': String(bytes.byteLength) + }); + const { POST } = await import('../../src/routes/api/telemetry/+server.js'); + const response = await POST({ request }); + expect(response.status).toBe(200); + expect((await response.json()).telemetry).toMatchObject(payload); +}); + +it.each(['declared', 'absent', 'understated'] as const)( + 'rejects body overflow with %s Content-Length before storing or broadcasting it', + async (length) => { + expect((await post(gameEvent('heartbeat', 5))).status).toBe(200); + const before = await storedEvidence(); + const { db } = await import('../../src/lib/server/db/index.js'); + const diagnostics = db().prepare('SELECT * FROM telemetry_requests').all(); + const payload = JSON.stringify(gameEvent('game_end', 99)); + const bytes = Buffer.from( + payload + ' '.repeat(JSON_BODY_LIMIT - Buffer.byteLength(payload)) + ); + const headers: Record = { + Authorization: `Bearer ${TEST_TELEMETRY_TOKEN}` + }; + if (length !== 'absent') + headers['Content-Length'] = length === 'declared' ? String(JSON_BODY_LIMIT + 1) : '1'; + const { request, pull, cancel } = streamedRequest( + [bytes, Uint8Array.of(32), Buffer.from('unread')], + headers + ); + const { POST } = await import('../../src/routes/api/telemetry/+server.js'); + const { subscribe } = await import('../../src/lib/server/pipeline/bus.js'); + const listener = vi.fn(); + const unsubscribe = subscribe(listener); + try { + await expect(POST({ request })).rejects.toMatchObject({ status: 413 }); + expect(pull).toHaveBeenCalledTimes(length === 'declared' ? 0 : 2); + expect(cancel).toHaveBeenCalledOnce(); + expect(await storedEvidence()).toEqual(before); + expect(db().prepare('SELECT * FROM telemetry_requests').all()).toEqual(diagnostics); + expect(listener).not.toHaveBeenCalled(); + } finally { + unsubscribe(); + } + } +); + +it.each([401, 503])('preserves authentication rejection %s before body limits', async (status) => { + if (status === 503) vi.stubEnv('EC_TELEMETRY_TOKEN', undefined); + const before = await storedEvidence(); + const { request, pull } = streamedRequest([Buffer.alloc(JSON_BODY_LIMIT + 1)], { + 'Content-Length': String(JSON_BODY_LIMIT + 1) + }); + const { POST } = await import('../../src/routes/api/telemetry/+server.js'); + const { subscribe } = await import('../../src/lib/server/pipeline/bus.js'); + const listener = vi.fn(); + const unsubscribe = subscribe(listener); + try { + expect((await POST({ request })).status).toBe(status); + expect(pull).not.toHaveBeenCalled(); + expect(request.bodyUsed).toBe(false); + expect(await storedEvidence()).toEqual(before); + const { db } = await import('../../src/lib/server/db/index.js'); + expect(db().prepare('SELECT * FROM telemetry_requests').all()).toEqual([]); + expect(listener).not.toHaveBeenCalled(); + } finally { + unsubscribe(); + } +});