Bound telemetry player identities (Refs #74)
CI / checks (push) Successful in 4m48s

This commit is contained in:
2026-10-09 17:08:55 -04:00
parent bd16737dd8
commit f0afd6de67
3 changed files with 201 additions and 10 deletions
+15 -8
View File
@@ -51,7 +51,8 @@ Send JSON to `POST /api/telemetry` with `Content-Type: application/json` and eit
`X-EC-Telemetry-Token: <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.
+4 -2
View File
@@ -59,8 +59,10 @@ export function parseGameEvent(value: unknown): GameEvent {
throw new Error(`players[${index}] must be an object`);
const player = value as Record<string, unknown>;
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,
+182
View File
@@ -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<string, string> = {}) {
let index = 0;
const pull = vi.fn((controller: ReadableStreamDefaultController<Uint8Array>) => {
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<string, string> = {
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();
}
});