Add community map records and telemetry API; record Workshop 0.0.126
continuous-integration/drone/push Build is passing

This commit is contained in:
2026-10-30 14:19:39 +01:00
parent c6ee764c6c
commit 5453159867
26 changed files with 3201 additions and 20 deletions
@@ -0,0 +1,4 @@
# Local development only. Replace this value before running wrangler dev.
ADMIN_TOKEN=replace-with-a-random-secret-at-least-32-characters-long
# Optional independent read-only credential; admin credentials can also read.
# READ_TOKEN=another-random-secret-at-least-32-characters-long
+6
View File
@@ -0,0 +1,6 @@
node_modules/
.wrangler/
dist/
.dev.vars*
.env*
!.dev.vars.example
+252
View File
@@ -0,0 +1,252 @@
# Co10 Escape telemetry receiver
Cloudflare Worker and D1 REST API for the existing [mission telemetry protocol](../../docs/telemetry.md).
The game sends one JSON event per POST to `/api/mission-events`. Any community
server running the updated mission enrolls automatically on its first valid
event. Operators do not need an account, a manually issued token, or a profile
override. Mission → Statistics remains the administrator opt-out.
The Worker name is `co10escape-telemetry`; the bundled mod endpoint is
`https://co10escape-telemetry.zacekjakub.workers.dev/api/mission-events`.
Automatic enrollment requires a new Workshop release after 0.0.125 and the
updated Worker below. These source files alone do not change either deployment.
## Local verification
Requires Node.js 22 or newer and npm. From this directory:
```sh
npm ci
npm test
npm run check
cp .dev.vars.example .dev.vars
# Replace the example ADMIN_TOKEN with a random value (32–256 characters).
npm run migrate:local
npm run dev
```
The tests run the Worker in Miniflare/workerd with real local D1 SQL. They cover
authentication, duplicate/concurrent retries, payload conflicts, out-of-order
snapshots, final-result preservation, actual chunked HTTP requests, limits,
legacy counters, storage rollback, server revocation, credential rotation,
automatic enrollment races, ingestion rate limits, public-summary privacy and
per-map win/failure totals across servers.
They use disposable databases and synthetic identities. `npm run check` bundles
the deployable Worker without publishing it. Miniflare is pinned to the version
bundled with Wrangler so development and test runtimes match.
## Deploy
The existing account already has D1 database `erri-telemetry`, bound as `DB` in
`wrangler.jsonc`. The renamed Worker reuses this database and its history. From
this directory, with Wrangler already logged in:
```sh
npm run migrate:remote
npm run deploy
npx wrangler secret put ADMIN_TOKEN
```
The migration adds the automatic/managed registration label. Deploying the new
name creates `co10escape-telemetry`; secrets on `erri-telemetry` do not transfer.
Enter the privately saved admin credential at the prompt for the new Worker.
The old Worker is not deleted by these commands. After the new endpoint is
verified, the old Worker can be retired separately.
For a fresh Cloudflare account, run `npx wrangler login`, then
`npx wrangler d1 create co10escape-telemetry`. Update the database ID and name in
`wrangler.jsonc` and run the migration/deploy commands above. Choose unused rate
limiter namespace IDs if 1001–1003 already serve another application in the account.
An independent read-only credential is optional:
```sh
npx wrangler secret put READ_TOKEN
```
Use independently generated random secrets of 32–256 characters. Enter them at
Wrangler's prompt; do not put them in Git or in the addon. Before ADMIN_TOKEN is
set, administration and detailed reads fail closed. Public aggregate summaries
remain available. Public ingestion enrolls servers
independently of this admin credential. Wrangler prints the Worker URL, for example
`https://co10escape-telemetry.YOUR-SUBDOMAIN.workers.dev`. `/health` is a public liveness
check, not a database readiness test. The service does not enable CORS; a future
browser dashboard should use a backend to keep API credentials private.
Deployment uses Cloudflare's documented [D1 migrations](https://developers.cloudflare.com/d1/reference/migrations/),
[Worker secrets](https://developers.cloudflare.com/workers/configuration/secrets/),
and [transactional D1 batches](https://developers.cloudflare.com/d1/worker-api/d1-database/#batch).
## Automatic community-server enrollment
The updated addon saves a random server ID in its telemetry state and generates
an independent secret from two random UUIDs. It verifies the secret on disk in
`$profile:EscapistsTelemetryCredentials.json` before sending any request. No
receiver admin secret is included in the mod. The first valid event atomically
creates the server record and stores the event. Only the credential hash is stored
by D1. Following events and retries require that same installation credential.
Automatic records are labelled `registration: "automatic"`; operator-created
records are labelled `"managed"`. Neither label is an anti-cheat attestation.
An automatic claim cannot replace an existing server's credential or reactivate
a revoked server. Concurrent claims with different credentials yield one owner.
Preserve the profile's state and credentials together when moving a server.
Corrupted credential storage suspends delivery; it is never silently reset.
The Worker bounds request bodies and uses three Cloudflare rate limiters:
300 requests/minute per source, 10 new enrollment attempts/minute per source,
and 30 valid requests/minute per server credential. Source keys are hashes of
Cloudflare's connecting-IP header, used only in ephemeral limiter state. Limits
are approximate and local to each Cloudflare location, not a global storage
quota. Shared hosting addresses share source quotas; a 429 makes the game retry.
See [Cloudflare rate limiting](https://developers.cloudflare.com/workers/runtime-apis/bindings/rate-limit/).
## Optional managed server credentials
Each game server has its own `serverId` in
`$profile:EscapistsTelemetryState.json`, inside `state.serverId`. Register this
existing ID; do not replace the state file, mission IDs or queued events.
The normal automatic path needs no operator action. The administrative API also
supports pre-registering an existing profile ID with a manually issued token.
For an already enrolled server, use token rotation instead of registering again.
Make an authenticated request (substitute the ID read from the profile):
```http
POST /api/servers
Authorization: Bearer ADMIN_TOKEN
Content-Type: application/json
{"serverId":"11111111-1111-4111-8111-111111111111","name":"My Escape server"}
```
The 201 response contains `serverId`, `endpoint`, and a new `token`. The raw token
is returned only on registration or rotation; D1 stores its SHA-256 hash. Save the
token directly in the server's private profile configuration:
```json
{
"endpoint": "https://co10escape-telemetry.YOUR-SUBDOMAIN.workers.dev/api/mission-events",
"serverName": "My Escape server",
"token": "TOKEN_FROM_REGISTRATION"
}
```
Restrict access to that file. Restart the mission to load it. The in-game
Statistics page should report delivered events. Confirm the mission with
`GET /api/missions?serverId=SERVER_ID`. The game token is sent in the JSON envelope
as required by the existing native HTTP sender; it cannot read data, register
servers or submit events for another server ID.
`POST /api/servers/SERVER_ID/token` rotates a credential and reactivates a revoked
server. Update the private profile with the returned token and restart the
mission. `DELETE /api/servers/SERVER_ID` revokes ingestion without deleting history.
Never distribute the administrator or read credential to game servers.
## Map records and the results screen
Opening `/`, `/api/summary`, or `/api/mission-events` in a browser returns public
JSON with collected win/failure totals. No token is required for these summaries.
Use `?terrain=URL_ENCODED_WORLD_RESOURCE` to restrict totals to one map, and add
`&serverId=SERVER_UUID` to include that installation's record for the same map.
Without a terrain filter, the response includes overall totals and up to 100 maps
with separate win/failure counts. The response contains counts and map resources,
not player/server names, account IDs, raw events or credentials. Full records
remain protected by admin/read credentials.
Each summary has `schemaVersion: 1`, `terrain`, `allServers` and optional `server`.
Each record contains `completed`, `wins`, `losses`, `partialMissions`, and
`winRate` (percentage; null when there are no completed rounds). Map identity is
the actual loaded world resource, compared case-insensitively. Different worlds
are kept separate even when their generic scenario title is the same.
The acknowledgement for an accepted victory/defeat event also includes a
`community` summary for that event's map, server, mission and event ID. It is
computed after storage and includes the just-finished round exactly once.
An equivalent retry returns updated totals without counting the round again.
A conflicting second terminal snapshot cannot claim inclusion. A failure to
read totals does not undo a successful event acknowledgement.
The updated mission prioritizes its final result, validates the response against
the current map/server/mission/event/outcome, and replicates a short summary to
the win/failure screen. The screen refreshes if totals arrive after it opens.
It shows map-specific wins, failures and rounded win rates for this server and
all reporting servers. Local round statistics remain available when reporting is
disabled, storage/network fails, the receiver lacks summary support, or the
mission ends before delivery completes. Community totals then show unavailable;
they are not invented as zero. This feature requires the updated Workshop build.
## API
All responses are JSON with `Cache-Control: no-store`. Administrative endpoints
require `Authorization: Bearer ADMIN_TOKEN`. Read endpoints accept ADMIN_TOKEN or
the optional READ_TOKEN. Public summaries and `/health` do not require a token;
ingestion uses each installation's own credential.
| Method and path | Purpose |
| --- | --- |
| `GET /health` | Public liveness check |
| `GET /`, `GET /api/summary`, `GET /api/mission-events` | Public map win/failure summaries; optional `terrain` and `serverId` filters |
| `POST /api/mission-events` | Ingest `{ "token": "...", "event": {...} }`; first valid event enrolls an unknown server |
| `POST /api/servers` | Register `{serverId, name}` and return a new token |
| `GET /api/servers` | List server labels, IDs, registration origin, creation times and active state; never token hashes |
| `POST /api/servers/:serverId/token` | Rotate token and reactivate server |
| `DELETE /api/servers/:serverId` | Revoke ingestion; retain history |
| `GET /api/missions` | Mission summaries, newest-created first |
| `GET /api/missions/:serverId/:missionId` | Authoritative selected snapshot with players, mods, settings and result |
| `GET /api/missions/:serverId/:missionId/events` | Raw event history, newest-received first |
| `GET /api/stats` | Distinct mission counts grouped by outcome, with partial-mission counts |
| `GET /api/players` | Authenticated participants' attempts, personal escapes and combat totals |
`/api/missions`, `/api/stats` and `/api/players` accept an optional `serverId` filter.
`/api/missions` also accepts `outcome` (`victory`, `defeat`, `preparation_failed`,
`restarted_empty`, `interrupted`, or `unfinished`). Mission, server and event lists
accept `limit` (1–100, default 50) and `before` from the preceding `nextBefore`.
Player lists sort by stable account ID and use `after` from `nextAfter`, plus
`limit`. A non-null cursor means another page may exist; the last page can be empty.
Read results use integer 0/1 for SQLite-derived flags; raw events retain booleans.
Ingestion returns 201 after a new durable event, or 200 with `duplicate: true`
after an equivalent retry. Victory/defeat acknowledgements include current
map records when available. An already used server/event key with different
content receives 409. Invalid payloads receive 400, unauthorized requests 401,
oversized bodies 413, unsupported media types 415, rate limits 429, and storage failures 503.
The current game client retries every non-2xx, including permanent errors; fix
invalid credentials/schema or inspect the queue rather than treating 4xx as
successful delivery. Request bodies are bounded while streaming, including when
there is no Content-Length. Event JSON is limited to 65,536 UTF-16 code units;
the HTTP envelope permits 264 KiB for UTF-8 expansion and the credential.
## Storage and counting rules
`events` retains validated event objects, keyed uniquely by `(serverId, eventId)`.
Envelope tokens, request headers and IP addresses are not stored by this code.
An SQL trigger updates `missions` in the same transaction. D1 must commit both
before the Worker returns 2xx. A failed projection rolls back the event too.
For each `(serverId, missionId)`, the first accepted terminal snapshot wins.
Subsequent terminal events remain in the history but cannot change its result or
count again. Before completion, snapshots advance by elapsed mission time, then
event wall-clock time; late starts/restarts cannot clear a terminal result.
Reported `previousOutcome` is retained as evidence in events, never converted
into an invented result for a missing mission. Unknown endings stay unfinished.
Player totals include authenticated, participating players from victory/defeat
snapshots only. Spectators and temporary connection IDs are excluded. A mission
victory does not imply a personal escape. Startup failures, empty restarts and
interrupted sessions do not count as gameplay wins/losses or player attempts.
An interrupted mission can still have raw combat data in its detail snapshot.
Legacy missing counters remain null, with `missionsWithKills`,
`missionsWithFriendlyKills`, `missionsWithDeaths`, `missionsWithDowned` and
`missionsWithRecoveries` showing each total's coverage. Partial and truncated
mission counts remain visible; missing players cannot be reconstructed. Results
are community reports bound to installation credentials, not independent anti-cheat attestations.
Raw events and final snapshots are retained indefinitely by default. Monitor D1
storage as usage grows. Deleting raw events also deletes their deduplication keys;
do not prune them while a server may still retry old queued events. A retention
policy should preserve deduplication tombstones if raw payloads are removed.
Keep read credentials private: mission details contain player names and account
IDs. Worker request logging is disabled in the supplied configuration. Cloudflare
platform logging/settings are managed separately in the account.
@@ -0,0 +1,87 @@
CREATE TABLE servers (
server_id TEXT PRIMARY KEY,
name TEXT NOT NULL,
token_hash TEXT NOT NULL,
active INTEGER NOT NULL DEFAULT 1 CHECK (active IN (0, 1)),
created_at INTEGER NOT NULL
);
CREATE TABLE events (
id INTEGER PRIMARY KEY AUTOINCREMENT,
server_id TEXT NOT NULL REFERENCES servers(server_id),
event_id TEXT NOT NULL,
mission_id TEXT NOT NULL,
received_at INTEGER NOT NULL,
payload_hash TEXT NOT NULL,
payload TEXT NOT NULL CHECK (json_valid(payload)),
UNIQUE (server_id, event_id)
);
CREATE INDEX events_mission ON events(server_id, mission_id, id);
CREATE TABLE missions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
server_id TEXT NOT NULL REFERENCES servers(server_id),
mission_id TEXT NOT NULL,
event_id TEXT NOT NULL,
terminal INTEGER NOT NULL,
outcome TEXT NOT NULL,
elapsed_seconds INTEGER NOT NULL,
occurred_at INTEGER NOT NULL,
received_at INTEGER NOT NULL,
partial INTEGER NOT NULL,
peak_players INTEGER NOT NULL,
dropped_events INTEGER NOT NULL,
payload TEXT NOT NULL,
UNIQUE (server_id, mission_id)
);
CREATE INDEX missions_server_page ON missions(server_id, id DESC);
CREATE INDEX missions_outcome ON missions(outcome, server_id);
-- An event and its mission projection commit together. Duplicate inserts never
-- fire this trigger. First terminal snapshot wins; late starts/heartbeats cannot
-- reopen a mission. Elapsed mission time takes precedence over wall-clock skew.
CREATE TRIGGER project_mission AFTER INSERT ON events BEGIN
INSERT INTO missions (
server_id, mission_id, event_id, terminal, outcome, elapsed_seconds,
occurred_at, received_at, partial, peak_players, dropped_events, payload
) VALUES (
NEW.server_id, NEW.mission_id, NEW.event_id,
json_extract(NEW.payload, '$.type') = 'mission_finished',
json_extract(NEW.payload, '$.outcome'),
json_extract(NEW.payload, '$.elapsedSeconds'),
json_extract(NEW.payload, '$.occurredAt'), NEW.received_at,
json_extract(NEW.payload, '$.partial'), json_extract(NEW.payload, '$.peakPlayers'),
json_extract(NEW.payload, '$.droppedEvents'), NEW.payload
) ON CONFLICT(server_id, mission_id) DO UPDATE SET
event_id = excluded.event_id, terminal = excluded.terminal,
outcome = excluded.outcome, elapsed_seconds = excluded.elapsed_seconds,
occurred_at = excluded.occurred_at, received_at = excluded.received_at,
partial = MAX(missions.partial, excluded.partial),
peak_players = MAX(missions.peak_players, excluded.peak_players),
dropped_events = MAX(missions.dropped_events, excluded.dropped_events),
payload = excluded.payload
WHERE missions.terminal = 0 AND (
excluded.terminal = 1 OR excluded.elapsed_seconds > missions.elapsed_seconds OR
(excluded.elapsed_seconds = missions.elapsed_seconds AND excluded.occurred_at >= missions.occurred_at)
);
END;
-- Final snapshots alone feed rankings: spectator and temporary connection IDs
-- never become global player records. Missing legacy counters remain NULL.
CREATE VIEW final_players AS
SELECT m.server_id, m.mission_id, m.outcome, m.elapsed_seconds,
m.partial, json_extract(m.payload, '$.playersTruncated') AS players_truncated,
json_extract(p.value, '$.id') AS player_id,
json_extract(p.value, '$.name') AS name,
json_extract(p.value, '$.escaped') AS escaped,
json_extract(p.value, '$.connected') AS connected,
json_extract(p.value, '$.connectedSeconds') AS connected_seconds,
json_extract(p.value, '$.kills') AS kills,
json_extract(p.value, '$.friendlyKills') AS friendly_kills,
json_extract(p.value, '$.deaths') AS deaths,
json_extract(p.value, '$.downed') AS downed,
json_extract(p.value, '$.recoveries') AS recoveries
FROM missions m, json_each(m.payload, '$.players') p
WHERE m.terminal = 1 AND m.outcome IN ('victory', 'defeat')
AND json_extract(p.value, '$.authenticated') = 1
AND json_extract(p.value, '$.participated') = 1;
@@ -0,0 +1,3 @@
-- Preserve existing operator-managed credentials and label public enrollment.
ALTER TABLE servers ADD COLUMN registration TEXT NOT NULL DEFAULT 'managed'
CHECK (registration IN ('managed', 'automatic'));
File diff suppressed because it is too large Load Diff
+21
View File
@@ -0,0 +1,21 @@
{
"name": "co10escape-telemetry",
"version": "1.0.0",
"private": true,
"type": "module",
"engines": {
"node": ">=22"
},
"scripts": {
"test": "node --test test/*.test.js",
"dev": "wrangler dev",
"check": "wrangler deploy --dry-run --outdir dist",
"deploy": "wrangler deploy",
"migrate:local": "wrangler d1 migrations apply DB --local",
"migrate:remote": "wrangler d1 migrations apply DB --remote"
},
"devDependencies": {
"miniflare": "5.20261023.3-beta",
"wrangler": "4.156.0"
}
}
+264
View File
@@ -0,0 +1,264 @@
import {HttpError, requireValue, object, string, uuid, outcomes, validateEvent, canonical, readJson} from './validation.js';
const json = (value, status = 200) => new Response(JSON.stringify(value), {
status, headers: {'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store', 'X-Content-Type-Options': 'nosniff'},
});
const now = () => Math.floor(Date.now() / 1000);
async function hash(value) {
const bytes = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(value));
return [...new Uint8Array(bytes)].map(x => x.toString(16).padStart(2, '0')).join('');
}
function equal(a, b) {
if (a.length !== b.length) return false;
let diff = 0; for (let i = 0; i < a.length; i++) diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
return diff === 0;
}
function newToken() {
return [...crypto.getRandomValues(new Uint8Array(32))].map(x => x.toString(16).padStart(2, '0')).join('');
}
async function authorize(request, env, readOnly = false) {
if (!string(env.ADMIN_TOKEN, 256, 32)) throw new HttpError(503, 'Service credentials are not configured');
const token = request.headers.get('authorization')?.match(/^Bearer (\S+)$/)?.[1];
if (!string(token, 256, 32)) throw new HttpError(401, 'Unauthorized');
const digest = await hash(token);
const admin = equal(digest, await hash(env.ADMIN_TOKEN));
const reader = readOnly && string(env.READ_TOKEN, 256, 32) && equal(digest, await hash(env.READ_TOKEN));
if (!admin && !reader) throw new HttpError(401, 'Unauthorized');
}
function method(request, expected) {
if (request.method !== expected) throw new HttpError(405, `Use ${expected}`);
}
function id(value) {
requireValue(uuid(value), 'Invalid UUID'); return value.toLowerCase();
}
function paging(url) {
const limit = url.searchParams.get('limit') ?? '50';
const before = url.searchParams.get('before') ?? String(Number.MAX_SAFE_INTEGER);
requireValue(/^\d+$/.test(limit) && +limit >= 1 && +limit <= 100, 'limit must be 1–100');
requireValue(/^\d+$/.test(before) && Number.isSafeInteger(+before) && +before > 0, 'Invalid before cursor');
return {limit: +limit, before: +before};
}
function serverFilter(url) {
const value = url.searchParams.get('serverId'); return value === null ? null : id(value);
}
async function rateLimit(binding, key) {
if (!binding) throw new HttpError(503, 'Ingestion rate limits are not configured');
if (!(await binding.limit({key})).success) throw new HttpError(429, 'Too many requests; retry later');
}
// Count terminal gameplay results once per mission, scoped to the actual loaded
// terrain resource. Startup failures, interrupted sessions and empty restarts
// never become map losses. No identities or arbitrary player text leave this API.
async function mapRecords(db, terrain, serverId = null) {
const scope = terrain === null ? '' : " AND lower(json_extract(payload, '$.terrain')) = lower(?)";
const terrainValues = terrain === null ? [] : [terrain];
const select = `SELECT COUNT(*) AS completed,
COALESCE(SUM(outcome = 'victory'), 0) AS wins,
COALESCE(SUM(outcome = 'defeat'), 0) AS losses,
COALESCE(SUM(partial), 0) AS partialMissions
FROM missions WHERE terminal = 1 AND outcome IN ('victory', 'defeat')${scope}`;
const statements = [db.prepare(select).bind(...terrainValues)];
if (serverId) statements.push(db.prepare(`${select} AND server_id = ?`).bind(...terrainValues, serverId));
const results = await db.batch(statements);
const record = (row) => ({...row, winRate: row.completed ? Math.round(1000 * row.wins / row.completed) / 10 : null});
return {schemaVersion: 1, terrain, allServers: record(results[0].results[0]),
server: serverId ? record(results[1].results[0]) : null};
}
async function publicSummary(request, env, url) {
method(request, 'GET');
await rateLimit(env.REQUEST_LIMITER, 'summary:' + await hash(request.headers.get('cf-connecting-ip') ?? 'local'));
const terrain = url.searchParams.get('terrain');
requireValue(terrain === null || string(terrain, 1024, 1), 'Invalid terrain');
const summary = await mapRecords(env.DB, terrain, serverFilter(url));
if (terrain === null) {
// A bounded alphabetical map list makes the browser response useful without
// knowing a resource ID. The terrain filter retrieves any map individually.
const rows = await env.DB.prepare(`SELECT lower(json_extract(payload, '$.terrain')) AS terrain,
COUNT(*) AS completed, SUM(outcome = 'victory') AS wins, SUM(outcome = 'defeat') AS losses
FROM missions WHERE terminal = 1 AND outcome IN ('victory', 'defeat')
GROUP BY lower(json_extract(payload, '$.terrain')) ORDER BY terrain LIMIT 101`).all();
summary.maps = rows.results.slice(0, 100);
summary.mapsTruncated = rows.results.length > 100;
}
return json(summary);
}
async function ingest(request, env) {
method(request, 'POST');
// Cloudflare supplies this header. Hash it for ephemeral rate-limit keys;
// neither the original IP nor its hash is persisted in the telemetry database.
const source = await hash(request.headers.get('cf-connecting-ip') ?? 'local');
await rateLimit(env.REQUEST_LIMITER, source);
const envelope = await readJson(request);
requireValue(object(envelope) && Object.keys(envelope).every(k => ['token', 'event'].includes(k)), 'Invalid envelope');
if (!string(envelope.token, 256, 32)) throw new HttpError(401, 'Unauthorized');
requireValue(object(envelope.event), 'Invalid event');
const serverId = id(envelope.event.serverId);
const server = await env.DB.prepare('SELECT token_hash, active FROM servers WHERE server_id = ?').bind(serverId).first();
// Always hash before comparison; never persist/log the incoming token.
const digest = await hash(envelope.token);
if (server && (!equal(digest, server.token_hash) || !server.active)) throw new HttpError(401, 'Unauthorized');
const event = validateEvent(envelope.event);
const payload = canonical(event);
// Native queue bounds strings, so permit UTF-8 expansion at the HTTP boundary.
if (payload.length > 65536) throw new HttpError(413, 'Event too large');
if (!server) await rateLimit(env.ENROLLMENT_LIMITER, source);
// Include the credential hash so guesses cannot exhaust another server's quota.
await rateLimit(env.SERVER_LIMITER, `${serverId}:${digest}`);
const payloadHash = await hash(payload);
const results = await env.DB.batch([
env.DB.prepare(`INSERT INTO servers(server_id, name, token_hash, created_at, registration)
VALUES (?, ?, ?, ?, 'automatic') ON CONFLICT(server_id) DO NOTHING`)
.bind(serverId, event.serverName, digest, now()),
env.DB.prepare(`INSERT INTO events(server_id, event_id, mission_id, received_at, payload_hash, payload)
SELECT ?, ?, ?, ?, ?, ? FROM servers WHERE server_id = ? AND token_hash = ? AND active = 1
ON CONFLICT(server_id, event_id) DO NOTHING RETURNING event_id`)
.bind(serverId, event.eventId, event.missionId, now(), payloadHash, payload, serverId, digest),
env.DB.prepare('SELECT payload_hash FROM events WHERE server_id = ? AND event_id = ?').bind(serverId, event.eventId),
env.DB.prepare('SELECT token_hash, active FROM servers WHERE server_id = ?').bind(serverId),
]);
// Resolve concurrent first claims and revocations inside the same transaction.
const owner = results[3].results[0];
if (!owner?.active || !equal(owner.token_hash, digest)) throw new HttpError(401, 'Unauthorized');
if (results[2].results[0]?.payload_hash !== payloadHash) throw new HttpError(409, 'eventId already exists with different content');
const duplicate = results[1].results.length === 0;
const reply = {accepted: true, duplicate, eventId: event.eventId};
if (event.type === 'mission_finished' && ['victory', 'defeat'].includes(event.outcome)) {
// Stats are a post-commit convenience. Failure to read them must not turn a
// durably accepted event into a false storage failure or cause double credit.
try {
const selected = await env.DB.prepare('SELECT event_id FROM missions WHERE server_id = ? AND mission_id = ?')
.bind(serverId, event.missionId).first();
if (selected?.event_id === event.eventId) {
reply.community = {...await mapRecords(env.DB, event.terrain, serverId), serverId,
missionId: event.missionId, eventId: event.eventId, outcome: event.outcome};
}
} catch { /* Acknowledgement remains valid; the game shows unavailable totals. */ }
}
return json(reply, duplicate ? 200 : 201);
}
async function servers(request, env, url) {
await authorize(request, env);
if (request.method === 'GET') {
const {limit, before} = paging(url);
const result = await env.DB.prepare(`SELECT rowid AS cursor, server_id AS serverId, name, active, registration, created_at AS createdAt
FROM servers WHERE rowid < ? ORDER BY rowid DESC LIMIT ?`).bind(before, limit).all();
return json({servers: result.results, nextBefore: result.results.length === limit ? result.results.at(-1).cursor : null});
}
method(request, 'POST');
const body = await readJson(request, 4096);
requireValue(object(body) && Object.keys(body).every(k => ['serverId', 'name'].includes(k)), 'Invalid server');
const serverId = id(body.serverId);
requireValue(string(body.name, 120, 1), 'name must be 1–120 characters');
const token = newToken();
const created = await env.DB.prepare(`INSERT INTO servers(server_id, name, token_hash, created_at)
VALUES (?, ?, ?, ?) ON CONFLICT(server_id) DO NOTHING RETURNING server_id`)
.bind(serverId, body.name, await hash(token), now()).first();
if (!created) throw new HttpError(409, 'Server already exists; rotate its token instead');
return json({serverId, token, endpoint: `${url.origin}/api/mission-events`}, 201);
}
async function serverCredential(request, env, serverId, rotate) {
await authorize(request, env);
method(request, rotate ? 'POST' : 'DELETE');
const token = rotate ? newToken() : null;
const statement = rotate
? env.DB.prepare('UPDATE servers SET token_hash = ?, active = 1 WHERE server_id = ? RETURNING server_id').bind(await hash(token), serverId)
: env.DB.prepare('UPDATE servers SET active = 0 WHERE server_id = ? RETURNING server_id').bind(serverId);
if (!await statement.first()) throw new HttpError(404, 'Server not found');
return json(rotate ? {serverId, token} : {serverId, active: false});
}
async function missions(request, env, url) {
method(request, 'GET'); await authorize(request, env, true);
const {limit, before} = paging(url), serverId = serverFilter(url);
const outcome = url.searchParams.get('outcome');
requireValue(outcome === null || [...outcomes, 'unfinished'].includes(outcome), 'Invalid outcome filter');
const conditions = ['id < ?'], values = [before];
if (serverId) { conditions.push('server_id = ?'); values.push(serverId); }
if (outcome) { conditions.push('outcome = ?'); values.push(outcome === 'unfinished' ? '' : outcome); }
const rows = await env.DB.prepare(`SELECT id AS cursor, server_id AS serverId, mission_id AS missionId,
CASE WHEN terminal = 1 THEN outcome ELSE 'unfinished' END AS outcome,
elapsed_seconds AS elapsedSeconds, occurred_at AS occurredAt, received_at AS receivedAt,
partial, peak_players AS peakPlayers, dropped_events AS droppedEvents,
json_extract(payload, '$.terrain') AS terrain, json_extract(payload, '$.playersTruncated') AS playersTruncated
FROM missions WHERE ${conditions.join(' AND ')} ORDER BY id DESC LIMIT ?`).bind(...values, limit).all();
return json({missions: rows.results, nextBefore: rows.results.length === limit ? rows.results.at(-1).cursor : null});
}
async function missionDetails(request, env, serverId, missionId, events, url) {
method(request, 'GET'); await authorize(request, env, true);
if (events) {
const {limit, before} = paging(url);
const rows = await env.DB.prepare(`SELECT id, received_at, payload FROM events
WHERE server_id = ? AND mission_id = ? AND id < ? ORDER BY id DESC LIMIT ?`)
.bind(serverId, missionId, before, limit).all();
return json({events: rows.results.map(row => ({cursor: row.id, receivedAt: row.received_at, event: JSON.parse(row.payload)})),
nextBefore: rows.results.length === limit ? rows.results.at(-1).id : null});
}
const row = await env.DB.prepare('SELECT received_at, payload FROM missions WHERE server_id = ? AND mission_id = ?')
.bind(serverId, missionId).first();
if (!row) throw new HttpError(404, 'Mission not found');
return json({receivedAt: row.received_at, event: JSON.parse(row.payload)});
}
async function stats(request, env, url) {
method(request, 'GET'); await authorize(request, env, true);
const serverId = serverFilter(url);
const result = await env.DB.prepare(`SELECT CASE WHEN terminal = 1 THEN outcome ELSE 'unfinished' END AS outcome,
COUNT(*) AS missions, SUM(partial) AS partialMissions FROM missions
${serverId ? 'WHERE server_id = ?' : ''} GROUP BY terminal, outcome`)
.bind(...(serverId ? [serverId] : [])).all();
return json({serverId, outcomes: result.results});
}
async function players(request, env, url) {
method(request, 'GET'); await authorize(request, env, true);
const serverId = serverFilter(url), {limit} = paging(url);
const after = url.searchParams.get('after');
if (after !== null) id(after);
const conditions = [], values = [];
if (serverId) { conditions.push('server_id = ?'); values.push(serverId); }
if (after) { conditions.push('player_id > ?'); values.push(after.toLowerCase()); }
const rows = await env.DB.prepare(`SELECT player_id AS playerId, COUNT(*) AS attempts,
SUM(escaped) AS escapes, SUM(1 - escaped) AS didNotEscape,
SUM(partial) AS partialMissions, SUM(players_truncated) AS truncatedMissions,
MIN(CASE WHEN escaped = 1 THEN elapsed_seconds END) AS bestEscapeSeconds,
SUM(kills) AS kills, COUNT(kills) AS missionsWithKills,
SUM(friendly_kills) AS friendlyKills, COUNT(friendly_kills) AS missionsWithFriendlyKills,
SUM(deaths) AS deaths, COUNT(deaths) AS missionsWithDeaths,
SUM(downed) AS downed, COUNT(downed) AS missionsWithDowned,
SUM(recoveries) AS recoveries, COUNT(recoveries) AS missionsWithRecoveries
FROM final_players ${conditions.length ? `WHERE ${conditions.join(' AND ')}` : ''}
GROUP BY player_id ORDER BY player_id LIMIT ?`).bind(...values, limit).all();
return json({players: rows.results, nextAfter: rows.results.length === limit ? rows.results.at(-1).playerId : null});
}
export default {
async fetch(request, env) {
try {
const url = new URL(request.url), path = url.pathname;
if (path === '/health') { method(request, 'GET'); return json({ok: true, service: 'co10escape-telemetry'}); }
if (path === '/' || path === '/api/summary' || (path === '/api/mission-events' && request.method === 'GET')) {
return await publicSummary(request, env, url);
}
if (path === '/api/mission-events') return await ingest(request, env);
if (path === '/api/servers') return await servers(request, env, url);
const credential = path.match(/^\/api\/servers\/([^/]+)(\/token)?$/);
if (credential) return await serverCredential(request, env, id(credential[1]), !!credential[2]);
if (path === '/api/missions') return await missions(request, env, url);
const mission = path.match(/^\/api\/missions\/([^/]+)\/([^/]+)(\/events)?$/);
if (mission) return await missionDetails(request, env, id(mission[1]), id(mission[2]), !!mission[3], url);
if (path === '/api/stats') return await stats(request, env, url);
if (path === '/api/players') return await players(request, env, url);
throw new HttpError(404, 'Not found');
} catch (error) {
// Database failures must never acknowledge an event. Do not log request
// bodies, player identities, SQL parameters or credentials on failure.
if (error instanceof HttpError) return json({error: error.message}, error.status);
return json({error: 'Storage temporarily unavailable'}, 503);
}
},
};
+117
View File
@@ -0,0 +1,117 @@
export class HttpError extends Error {
constructor(status, message) { super(message); this.status = status; }
}
export function requireValue(ok, message) {
if (!ok) throw new HttpError(400, message);
}
export function object(value) {
return value !== null && typeof value === 'object' && !Array.isArray(value);
}
export function string(value, max, min = 0) {
return typeof value === 'string' && value.length >= min && value.length <= max;
}
export function integer(value, max = 2147483647) {
return Number.isInteger(value) && value >= 0 && value <= max;
}
export function uuid(value) {
return typeof value === 'string' && /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(value)
&& value !== '00000000-0000-0000-0000-000000000000';
}
function keys(value, allowed) {
requireValue(object(value) && Object.keys(value).every(k => allowed.includes(k)), 'Unexpected object fields');
}
const types = ['mission_started', 'mission_restarted', 'phase_changed', 'heartbeat', 'telemetry_resumed', 'mission_finished'];
export const outcomes = ['victory', 'defeat', 'preparation_failed', 'restarted_empty', 'interrupted'];
const counters = ['kills', 'friendlyKills', 'deaths', 'downed', 'recoveries'];
export function validateEvent(event) {
keys(event, ['schemaVersion', 'eventId', 'missionId', 'serverId', 'serverName', 'type',
'occurredAt', 'elapsedSeconds', 'partial', 'phase', 'outcome', 'previousMissionId',
'previousOutcome', 'terrain', 'scenario', 'gameVersion', 'mods', 'modsTruncated',
'playersTruncated', 'connectedPlayers', 'peakPlayers', 'players', 'reason',
'preparationError', 'settings', 'droppedEvents']);
requireValue(event.schemaVersion === 1, 'Unsupported schemaVersion');
for (const key of ['serverId', 'missionId', 'eventId']) {
requireValue(uuid(event[key]), `Invalid ${key}`);
event[key] = event[key].toLowerCase();
}
requireValue(types.includes(event.type), 'Invalid event type');
requireValue(event.type === 'mission_finished' ? outcomes.includes(event.outcome) : event.outcome === '', 'Invalid event outcome');
requireValue(integer(event.phase, 6), 'Invalid phase');
requireValue(event.outcome !== 'victory' || event.phase === 5, 'Victory requires phase 5');
requireValue(event.outcome !== 'defeat' || event.phase === 6, 'Defeat requires phase 6');
requireValue(event.previousMissionId === '' || uuid(event.previousMissionId), 'Invalid previousMissionId');
event.previousMissionId = event.previousMissionId.toLowerCase();
requireValue(['', 'unfinished', ...outcomes].includes(event.previousOutcome), 'Invalid previousOutcome');
for (const [key, limit] of Object.entries({serverName: 120, terrain: 1024, scenario: 512,
gameVersion: 128, reason: 4096, preparationError: 4096})) {
requireValue(string(event[key], limit), `Invalid ${key}`);
}
for (const key of ['partial', 'modsTruncated', 'playersTruncated']) {
requireValue(typeof event[key] === 'boolean', `Invalid ${key}`);
}
for (const key of ['elapsedSeconds', 'connectedPlayers', 'peakPlayers', 'droppedEvents']) {
requireValue(integer(event[key]), `Invalid ${key}`);
}
requireValue(integer(event.occurredAt, 253402300799), 'Invalid occurredAt');
requireValue(event.peakPlayers >= event.connectedPlayers, 'peakPlayers is below connectedPlayers');
requireValue(Array.isArray(event.players) && event.players.length <= 128, 'Invalid players');
const ids = new Set();
for (const p of event.players) {
keys(p, ['id', 'name', 'authenticated', 'connected', 'participated', 'escaped', 'connectedSeconds', ...counters]);
requireValue(string(p.id, 128, 1) && string(p.name, 128), 'Invalid player identity');
for (const key of ['authenticated', 'connected', 'participated', 'escaped']) {
requireValue(typeof p[key] === 'boolean', `Invalid player ${key}`);
}
requireValue(!p.authenticated || uuid(p.id), 'Authenticated player ID must be a UUID');
if (p.authenticated) p.id = p.id.toLowerCase();
requireValue(!ids.has(p.id), 'Duplicate player identity'); ids.add(p.id);
requireValue(!p.escaped || (p.participated && event.outcome === 'victory'), 'Invalid escaped player');
requireValue(integer(p.connectedSeconds), 'Invalid connectedSeconds');
for (const key of counters) {
requireValue(p[key] === undefined || integer(p[key]), `Invalid player ${key}`);
}
}
requireValue(Array.isArray(event.mods) && event.mods.length <= 128, 'Invalid mods');
for (const mod of event.mods) {
keys(mod, ['id', 'name', 'version']);
requireValue(string(mod.id, 128, 1) && string(mod.name, 512) && string(mod.version, 128), 'Invalid mod');
}
requireValue(object(event.settings) && Object.keys(event.settings).length <= 512, 'Invalid settings');
for (const [key, value] of Object.entries(event.settings)) {
requireValue(/^[a-zA-Z][a-zA-Z0-9_]{0,127}$/.test(key) && Number.isInteger(value)
&& value >= -2147483648 && value <= 2147483647, 'Invalid setting');
}
return event;
}
// Sort object keys for idempotency even if a proxy reformats JSON. Array order
// remains meaningful. Only the event is persisted; the envelope token is not.
export function canonical(value) {
if (Array.isArray(value)) return `[${value.map(canonical).join(',')}]`;
if (object(value)) return `{${Object.keys(value).sort().map(k => `${JSON.stringify(k)}:${canonical(value[k])}`).join(',')}}`;
return JSON.stringify(value);
}
export async function readJson(request, limit = 270336) {
if (request.headers.get('content-type')?.split(';')[0].trim().toLowerCase() !== 'application/json') {
throw new HttpError(415, 'Content-Type must be application/json');
}
if (Number(request.headers.get('content-length')) > limit) throw new HttpError(413, 'Request too large');
if (!request.body) throw new HttpError(400, 'JSON body required');
const reader = request.body.getReader();
const chunks = []; let size = 0;
try {
while (true) {
const {done, value} = await reader.read();
if (done) break;
size += value.byteLength;
if (size > limit) { await reader.cancel(); throw new HttpError(413, 'Request too large'); }
chunks.push(value);
}
} finally { reader.releaseLock(); }
const bytes = new Uint8Array(size); let offset = 0;
for (const chunk of chunks) { bytes.set(chunk, offset); offset += chunk.byteLength; }
try { return JSON.parse(new TextDecoder('utf-8', {fatal: true}).decode(bytes)); }
catch { throw new HttpError(400, 'Invalid JSON'); }
}
@@ -0,0 +1,286 @@
import {after, before, test} from 'node:test';
import assert from 'node:assert/strict';
import {readFile, readdir} from 'node:fs/promises';
import {request as httpRequest} from 'node:http';
import {Miniflare, convertV4MiniflareOptions} from 'miniflare';
import worker from '../src/index.js';
const ADMIN = 'test-admin-credential-0123456789abcdef';
const READ = 'test-read-only-credential-0123456789abcdef';
const serverId = '11111111-1111-4111-8111-111111111111';
const otherServer = '22222222-2222-4222-8222-222222222222';
const playerId = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa';
let mf, db, token, otherToken;
const auth = {authorization: `Bearer ${ADMIN}`};
const readAuth = {authorization: `Bearer ${READ}`};
function event(overrides = {}) {
return {
schemaVersion: 1, eventId: crypto.randomUUID(), missionId: crypto.randomUUID(),
serverId, serverName: 'Test server', type: 'mission_finished', occurredAt: 1793360000,
elapsedSeconds: 120, partial: false, phase: 5, outcome: 'victory', previousMissionId: '',
previousOutcome: '', terrain: '{1234567890ABCDEF}Worlds/Test.ent', scenario: 'Escape',
gameVersion: '1.6', mods: [{id: '2528466DE220D91C', name: 'Co10 Escape Reintroduced', version: '0.0.125'}],
modsTruncated: false, playersTruncated: false, connectedPlayers: 1, peakPlayers: 1,
players: [{id: playerId, name: 'Player', authenticated: true, connected: true,
participated: true, escaped: true, connectedSeconds: 120, kills: 2, friendlyKills: 0,
deaths: 0, downed: 1, recoveries: 1}],
reason: 'Escape complete', preparationError: '', settings: {telemetryEnabled: 1, random: -1},
droppedEvents: 0, ...overrides,
};
}
async function call(path, options = {}) {
return mf.dispatchFetch(`https://telemetry.example${path}`, options);
}
async function post(path, body, headers = {}) {
return call(path, {method: 'POST', headers: {'content-type': 'application/json', ...headers}, body: JSON.stringify(body)});
}
const ingest = (e, credential = token) => post('/api/mission-events', {token: credential, event: e});
async function detail(e) {
const response = await call(`/api/missions/${e.serverId}/${e.missionId}`, {headers: readAuth});
assert.equal(response.status, 200); return (await response.json()).event;
}
before(async () => {
mf = new Miniflare(convertV4MiniflareOptions({host: '127.0.0.1', port: 0, workers: [{name: 'telemetry',
modules: ['index.js', 'validation.js'].map(name => ({type: 'ESModule', path: new URL(`../src/${name}`, import.meta.url).pathname})),
compatibilityDate: '2026-10-23', d1Databases: ['DB'],
ratelimits: {
REQUEST_LIMITER: {namespace_id: '1001', simple: {limit: 300, period: 60}},
ENROLLMENT_LIMITER: {namespace_id: '1002', simple: {limit: 10, period: 60}},
SERVER_LIMITER: {namespace_id: '1003', simple: {limit: 30, period: 60}},
},
bindings: {ADMIN_TOKEN: ADMIN, READ_TOKEN: READ}}]}));
db = await mf.getD1Database('DB');
const migrations = new URL('../migrations/', import.meta.url);
for (const name of (await readdir(migrations)).filter(n => n.endsWith('.sql')).sort()) {
const migration = await readFile(new URL(name, migrations), 'utf8');
// Blank-line-separated statements preserve the trigger's internal semicolons.
for (const sql of migration.split(/;\n\n/)) if (sql.trim()) await db.prepare(sql).run();
}
for (const sid of [serverId, otherServer]) {
const response = await post('/api/servers', {serverId: sid, name: 'Test'}, auth);
assert.equal(response.status, 201, await response.clone().text());
const body = await response.json();
assert.equal(body.token.length, 64);
if (sid === serverId) token = body.token; else otherToken = body.token;
}
});
after(async () => { await mf?.dispose(); });
test('credentials bind writes to a registered server; read-only credentials cannot administer', async () => {
assert.equal((await ingest(event(), 'x'.repeat(64))).status, 401);
assert.equal((await ingest(event({serverId: otherServer}))).status, 401);
assert.equal((await call('/api/stats')).status, 401);
assert.equal((await post('/api/servers', {serverId: crypto.randomUUID(), name: 'No'}, readAuth)).status, 401);
assert.equal((await call('/api/stats', {headers: readAuth})).status, 200);
const stored = await db.prepare('SELECT token_hash FROM servers WHERE server_id = ?').bind(serverId).first();
assert.notEqual(stored.token_hash, token);
assert.equal((await post('/api/servers', {serverId, name: 'Duplicate'}, auth)).status, 409);
});
test('concurrent retries store once; reordered JSON is equivalent; conflicting content is rejected', async () => {
const e = event();
const responses = await Promise.all(Array.from({length: 6}, () => ingest(e)));
assert.deepEqual(responses.map(r => r.status).sort(), [200, 200, 200, 200, 200, 201]);
const reordered = Object.fromEntries(Object.entries(e).reverse());
assert.equal((await ingest(reordered)).status, 200);
assert.equal((await ingest({...e, elapsedSeconds: 999})).status, 409);
const count = await db.prepare('SELECT COUNT(*) AS n FROM events WHERE event_id = ?').bind(e.eventId).first();
assert.equal(count.n, 1);
assert.equal((await detail(e)).elapsedSeconds, 120);
const row = await db.prepare('SELECT payload FROM events WHERE event_id = ?').bind(e.eventId).first();
assert.ok(!row.payload.includes(token)); assert.ok(!row.payload.includes('"token"'));
assert.equal((await ingest({...e, serverId: otherServer}, otherToken)).status, 201);
});
test('out-of-order snapshots and second terminal reports cannot reopen or double count a mission', async () => {
const e = event({players: []});
const heartbeat = {...e, type: 'heartbeat', phase: 4, outcome: '', elapsedSeconds: 100};
assert.equal((await ingest(heartbeat)).status, 201);
assert.equal((await ingest({...heartbeat, eventId: crypto.randomUUID(), elapsedSeconds: 50, occurredAt: e.occurredAt + 500})).status, 201);
assert.equal((await detail(e)).elapsedSeconds, 100);
assert.equal((await ingest({...e, eventId: crypto.randomUUID(), occurredAt: e.occurredAt - 100})).status, 201);
for (const type of ['mission_started', 'mission_restarted', 'heartbeat', 'telemetry_resumed']) {
assert.equal((await ingest({...heartbeat, type, eventId: crypto.randomUUID(), elapsedSeconds: 1000})).status, 201);
}
assert.equal((await ingest({...e, eventId: crypto.randomUUID(), outcome: 'defeat', phase: 6})).status, 201);
assert.equal((await detail(e)).outcome, 'victory');
const rows = await db.prepare('SELECT COUNT(*) AS n FROM missions WHERE server_id = ? AND mission_id = ?').bind(serverId, e.missionId).first();
assert.equal(rows.n, 1);
const log = await call(`/api/missions/${serverId}/${e.missionId}/events?limit=2`, {headers: readAuth});
const data = await log.json(); assert.equal(data.events.length, 2); assert.ok(data.nextBefore);
});
test('rankings use actual authenticated participant escapes and preserve absent legacy counters', async () => {
const isolated = crypto.randomUUID();
const credentials = await (await post('/api/servers', {serverId: isolated, name: 'Rankings'}, auth)).json();
const e = event({serverId: isolated});
const legacy = {...e.players[0], escaped: false};
for (const key of ['kills', 'friendlyKills', 'deaths', 'downed', 'recoveries']) delete legacy[key];
const spectator = {...legacy, id: crypto.randomUUID(), participated: false};
const anonymous = {...legacy, id: `${e.missionId}:connection:1`, authenticated: false};
assert.equal((await ingest({...e, players: [legacy, spectator, anonymous]}, credentials.token)).status, 201);
for (const outcome of ['preparation_failed', 'restarted_empty', 'interrupted']) {
assert.equal((await ingest(event({serverId: isolated, outcome, phase: 0, players: [legacy]}), credentials.token)).status, 201);
}
const url = `/api/players?serverId=${isolated}`;
let result = await (await call(url, {headers: readAuth})).json();
assert.equal(result.players.length, 1);
assert.equal(result.players[0].attempts, 1); assert.equal(result.players[0].escapes, 0);
assert.equal(result.players[0].kills, null); assert.equal(result.players[0].missionsWithKills, 0);
assert.equal((await ingest(event({serverId: isolated}), credentials.token)).status, 201);
result = await (await call(url, {headers: readAuth})).json();
assert.equal(result.players[0].attempts, 2); assert.equal(result.players[0].escapes, 1);
assert.equal(result.players[0].kills, 2); assert.equal(result.players[0].missionsWithKills, 1);
const totals = await (await call(`/api/stats?serverId=${isolated}`, {headers: readAuth})).json();
assert.deepEqual(Object.fromEntries(totals.outcomes.map(r => [r.outcome, r.missions])),
{victory: 2, preparation_failed: 1, restarted_empty: 1, interrupted: 1});
});
test('real chunked HTTP request is accepted without Content-Length', async () => {
const origin = await mf.ready;
const body = JSON.stringify({token, event: event()});
const response = await new Promise((resolve, reject) => {
const req = httpRequest(new URL('/api/mission-events', origin), {method: 'POST', headers: {'content-type': 'application/json'}}, res => {
let text = ''; res.on('data', chunk => { text += chunk; });
res.on('end', () => resolve({status: res.statusCode, body: JSON.parse(text)}));
});
req.on('error', reject); req.write(body.slice(0, 80)); req.end(body.slice(80));
});
assert.equal(response.status, 201); assert.equal(response.body.accepted, true);
});
test('schema validation and streaming limits reject malformed input without durable rows', async () => {
const e = event();
for (const malformed of [
{...e, schemaVersion: 2}, {...e, phase: 4}, {...e, players: [...e.players, ...e.players]},
{...e, players: [{...e.players[0], kills: -1}]}, {...e, extraSecret: token},
{...e, players: [{...e.players[0], participated: false}]},
{...e, players: Array.from({length: 129}, () => e.players[0])},
]) assert.equal((await ingest(malformed)).status, 400);
assert.equal((await call('/api/mission-events', {method: 'POST', body: '{}'})).status, 415);
assert.equal((await call('/api/mission-events', {method: 'POST', headers: {'content-type': 'application/json'}, body: '{'})).status, 400);
const stream = new ReadableStream({start(controller) {
controller.enqueue(new TextEncoder().encode(' '.repeat(270337))); controller.close();
}});
assert.equal((await call('/api/mission-events', {method: 'POST', headers: {'content-type': 'application/json'}, body: stream, duplex: 'half'})).status, 413);
assert.equal((await call('/api/missions?limit=1000000', {headers: readAuth})).status, 400);
assert.equal((await call('/api/missions?serverId=invalid', {headers: readAuth})).status, 400);
});
test('failed projection rolls back the event and returns non-2xx; retry succeeds after repair', async () => {
const e = event();
await db.prepare(`CREATE TRIGGER simulate_storage_failure BEFORE INSERT ON missions
WHEN NEW.mission_id = '${e.missionId}' BEGIN SELECT RAISE(ABORT, 'test failure'); END`).run();
assert.equal((await ingest(e)).status, 503);
assert.equal(await db.prepare('SELECT event_id FROM events WHERE event_id = ?').bind(e.eventId).first(), null);
await db.prepare('DROP TRIGGER simulate_storage_failure').run();
assert.equal((await ingest(e)).status, 201);
const result = await worker.fetch(new Request('https://example/api/stats', {headers: auth}), {});
assert.equal(result.status, 503); // Missing admin configuration fails closed.
});
test('server revocation retains history; rotation invalidates old credential and restores ingestion', async () => {
const sid = crypto.randomUUID();
const registered = await (await post('/api/servers', {serverId: sid, name: 'Rotation'}, auth)).json();
const e = event({serverId: sid});
assert.equal((await ingest(e, registered.token)).status, 201);
assert.equal((await call(`/api/servers/${sid}`, {method: 'DELETE', headers: auth})).status, 200);
assert.equal((await ingest(e, registered.token)).status, 401);
assert.equal((await detail(e)).outcome, 'victory');
const rotated = await (await call(`/api/servers/${sid}/token`, {method: 'POST', headers: auth})).json();
assert.equal((await ingest(e, registered.token)).status, 401);
assert.equal((await ingest(e, rotated.token)).status, 200);
});
test('unmanaged servers enroll on first valid event and keep their own private write credential', async () => {
const e = event({serverId: crypto.randomUUID()}), credential = crypto.randomUUID() + crypto.randomUUID();
assert.equal((await ingest({...e, schemaVersion: 99}, credential)).status, 400);
assert.equal(await db.prepare('SELECT server_id FROM servers WHERE server_id = ?').bind(e.serverId).first(), null);
assert.equal((await ingest(e, credential)).status, 201);
const row = await db.prepare('SELECT registration, token_hash FROM servers WHERE server_id = ?').bind(e.serverId).first();
assert.equal(row.registration, 'automatic'); assert.notEqual(row.token_hash, credential);
assert.equal((await ingest(e, credential)).status, 200); // lost acknowledgement / restart
assert.equal((await ingest({...e, eventId: crypto.randomUUID()}, 'x'.repeat(64))).status, 401);
await call(`/api/servers/${e.serverId}`, {method: 'DELETE', headers: auth});
assert.equal((await ingest(e, credential)).status, 401); // no automatic reactivation
assert.equal((await call('/api/servers', {headers: {authorization: `Bearer ${credential}`}})).status, 401);
});
test('concurrent automatic claims never let two credentials own the same server', async () => {
const e = event({serverId: crypto.randomUUID()}), a = 'a'.repeat(64), b = 'b'.repeat(64);
const replies = await Promise.all([ingest(e, a), ingest({...e, eventId: crypto.randomUUID()}, b)]);
assert.deepEqual(replies.map(r => r.status).sort(), [201, 401]);
const count = await db.prepare('SELECT COUNT(*) AS n FROM events WHERE server_id = ?').bind(e.serverId).first();
assert.equal(count.n, 1);
});
test('automatic claim and event roll back together when projection fails', async () => {
const e = event({serverId: crypto.randomUUID()}), credential = 'r'.repeat(64);
await db.prepare(`CREATE TRIGGER fail_new_server BEFORE INSERT ON missions
WHEN NEW.mission_id = '${e.missionId}' BEGIN SELECT RAISE(ABORT, 'test failure'); END`).run();
assert.equal((await ingest(e, credential)).status, 503);
assert.equal(await db.prepare('SELECT server_id FROM servers WHERE server_id = ?').bind(e.serverId).first(), null);
await db.prepare('DROP TRIGGER fail_new_server').run();
assert.equal((await ingest(e, credential)).status, 201);
});
test('public rate limits reject excess traffic without storing more events', async () => {
const e = event({serverId: crypto.randomUUID()}), credential = 'q'.repeat(64);
assert.equal((await ingest(e, credential)).status, 201);
let rejected = false;
for (let i = 0; i < 35; i++) {
if ((await ingest(e, credential)).status === 429) { rejected = true; break; }
}
assert.ok(rejected, 'per-server binding must enforce its quota');
const input = () => new Request('https://example/api/mission-events', {method: 'POST', headers: {'content-type': 'application/json'},
body: JSON.stringify({token: credential, event: event({serverId: crypto.randomUUID()})})});
const allowed = {limit: async () => ({success: true})};
const denied = {limit: async () => ({success: false})};
assert.equal((await worker.fetch(input(), {DB: db, REQUEST_LIMITER: allowed, ENROLLMENT_LIMITER: denied})).status, 429);
assert.equal((await worker.fetch(input(), {DB: db, REQUEST_LIMITER: denied})).status, 429);
assert.equal((await worker.fetch(input(), {DB: db})).status, 503);
});
test('public map summary is usable in a browser and empty maps have zero results, not an invented win rate', async () => {
const terrain = '{ABCD000000000000}Worlds/NoReports.ent';
for (const path of ['/', '/api/summary', '/api/mission-events']) {
const response = await call(path + '?terrain=' + encodeURIComponent(terrain));
assert.equal(response.status, 200);
const data = await response.json();
assert.deepEqual(data.allServers, {completed: 0, wins: 0, losses: 0, partialMissions: 0, winRate: null});
assert.equal(data.server, null);
}
assert.equal((await call('/api/players')).status, 401);
assert.equal((await call('/api/missions')).status, 401);
});
test('map records count victories and defeats across servers, excluding other maps and non-gameplay endings', async () => {
const terrain = '{ABCD123456789012}Worlds/MapRecord.ent';
const e = event({terrain, serverId: otherServer});
const first = await (await ingest(e, otherToken)).json();
assert.equal(first.community.eventId, e.eventId);
assert.equal(first.community.terrain, terrain);
assert.deepEqual(first.community.allServers, {completed: 1, wins: 1, losses: 0, partialMissions: 0, winRate: 100});
const defeat = event({terrain, serverId, outcome: 'defeat', phase: 6, players: [], partial: true});
assert.equal((await ingest(defeat)).status, 201);
for (const outcome of ['preparation_failed', 'restarted_empty', 'interrupted']) {
assert.equal((await ingest(event({terrain, serverId: otherServer, outcome, phase: 0, players: []}), otherToken)).status, 201);
}
assert.equal((await ingest(event({terrain: 'Different-map', serverId: otherServer}), otherToken)).status, 201);
// Case differences in resource spelling do not split the same loaded map.
const params = new URLSearchParams({terrain: terrain.toLowerCase(), serverId: otherServer});
const response = await call('/api/summary?' + params);
const summary = await response.json();
assert.deepEqual(summary.allServers, {completed: 2, wins: 1, losses: 1, partialMissions: 1, winRate: 50});
assert.deepEqual(summary.server, {completed: 1, wins: 1, losses: 0, partialMissions: 0, winRate: 100});
const retry = await (await ingest(e, otherToken)).json();
assert.equal(retry.duplicate, true); assert.equal(retry.community.allServers.completed, 2);
// A conflicting second terminal event must not claim its result was included.
const later = await (await ingest({...e, eventId: crypto.randomUUID(), outcome: 'defeat', phase: 6, players: []}, otherToken)).json();
assert.equal(later.community, undefined);
const text = JSON.stringify(summary);
for (const sensitive of [playerId, token, otherToken, 'Player', 'Test server', e.missionId]) assert.ok(!text.includes(sensitive));
const maps = await (await call('/')).json();
const row = maps.maps.find(m => m.terrain === terrain.toLowerCase());
assert.deepEqual(row, {terrain: terrain.toLowerCase(), completed: 2, wins: 1, losses: 1});
});
+20
View File
@@ -0,0 +1,20 @@
{
"$schema": "node_modules/wrangler/config-schema.json",
"name": "co10escape-telemetry",
"main": "src/index.js",
"compatibility_date": "2026-10-23",
"workers_dev": true,
"d1_databases": [{
"binding": "DB",
// Reuse the database already created in this account.
"database_name": "erri-telemetry",
"database_id": "70afec97-cb05-4024-ad57-2256c7089dc1",
"migrations_dir": "migrations"
}],
"ratelimits": [
{ "name": "REQUEST_LIMITER", "namespace_id": "1001", "simple": { "limit": 300, "period": 60 } },
{ "name": "ENROLLMENT_LIMITER", "namespace_id": "1002", "simple": { "limit": 10, "period": 60 } },
{ "name": "SERVER_LIMITER", "namespace_id": "1003", "simple": { "limit": 30, "period": 60 } }
],
"observability": { "enabled": false }
}