Allow public leaderboard API reads

This commit is contained in:
2026-10-05 16:46:44 -04:00
parent bd10c1c5d2
commit 083d29323f
5 changed files with 83 additions and 37 deletions
+6
View File
@@ -4,6 +4,12 @@ Electric Cherry requires a user session for operator pages and management APIs.
HTTPS protects the connection; server-side role checks protect each operation. HTTPS protects the connection; server-side role checks protect each operation.
There is no anonymous management mode or public registration. There is no anonymous management mode or public registration.
Leaderboard API reads (`GET` and `HEAD /api/leaderboard`) are public and need no
session or credentials. This exception covers only that exact API route;
leaderboard/profile pages, match-history routes and management operations retain
their access checks. HTTPS requirements still apply. See the
[leaderboard API reference](leaderboard.md#read-api) for requests and response fields.
## First administrator and upgrades ## First administrator and upgrades
1. Generate a setup secret: `openssl rand -hex 32`. 1. Generate a setup secret: `openssl rand -hex 32`.
+17 -34
View File
@@ -157,35 +157,18 @@ sources are the [HTTP handler](../src/routes/api/leaderboard/+server.ts),
### Authentication and requests ### Authentication and requests
`GET /api/leaderboard` is a session-authenticated JSON endpoint. A **viewer**, `GET /api/leaderboard` is a public JSON endpoint; `HEAD` is public too. A tool
operator or administrator account can read it after any required temporary does not need an account, session cookie, API key or authorization header to
password change. There is no API-key or bearer-token authentication for this read it. Reads also work before administrator setup and with an expired session
endpoint; telemetry and host-agent credentials do not grant access. or a session awaiting a password change. This exception applies only to reads
of this exact API route. Leaderboard/profile HTML pages, match-history APIs and
management routes still require a session; see [Users and access](authentication.md).
A tool can sign in with `POST /api/auth/login`, sending a JSON object with For example:
`username` and `password`, `Content-Type: application/json`, and an `Origin`
header exactly matching the instance's origin (scheme, hostname and port, with
no trailing slash). Preserve the returned `__Host-ec_session` cookie and send it
on subsequent requests. Login returns `{ "user": ... }`; the session token is
in `Set-Cookie`, not the JSON body. Sessions expire after 12 hours or 30 minutes
of inactivity, and can be revoked earlier. Handle `401` by signing in again.
See [Users and access](authentication.md) for setup and session rules.
For example, with an existing account's credentials in a private `login.json`
file containing `{"username":"reader","password":"..."}`:
```sh ```sh
EC_BASE_URL='https://ec.example.com' EC_BASE_URL='https://ec.example.com'
umask 077
curl --fail-with-body --silent --show-error \ curl --fail-with-body --silent --show-error \
--cookie-jar ec-cookies.txt \
--header "Origin: $EC_BASE_URL" \
--header 'Content-Type: application/json' \
--data-binary @login.json \
"$EC_BASE_URL/api/auth/login"
curl --fail-with-body --silent --show-error \
--cookie ec-cookies.txt \
--get "$EC_BASE_URL/api/leaderboard" \ --get "$EC_BASE_URL/api/leaderboard" \
--data-urlencode 'game=t6' \ --data-urlencode 'game=t6' \
--data-urlencode 'metric=kills' \ --data-urlencode 'metric=kills' \
@@ -194,8 +177,9 @@ curl --fail-with-body --silent --show-error \
``` ```
HTTPS is required. Local HTTP development requires `EC_AUTH_ALLOW_HTTP=true` HTTPS is required. Local HTTP development requires `EC_AUTH_ALLOW_HTTP=true`
and a loopback hostname, and uses the separate `ec_session_dev` cookie. The and a loopback hostname. The read request does not require an `Origin` header.
read request does not require an `Origin` header. Successful responses use This endpoint does not add cross-origin browser access (CORS) headers; the
example above works for command-line or server-side clients. Successful responses use
`Content-Type: application/json`, `Cache-Control: no-store` and `Vary: Cookie`. `Content-Type: application/json`, `Cache-Control: no-store` and `Vary: Cookie`.
### Query parameters ### Query parameters
@@ -308,14 +292,13 @@ strings such as `2026-10-05T12:00:00.000Z`, not reporter gameplay-clock times.
### Errors, pagination and freshness ### Errors, pagination and freshness
Invalid query values return HTTP `400` with Invalid query values return HTTP `400` with
`{ "ok": false, "kind": "bad-request", "error": "..." }`. Authentication errors `{ "ok": false, "kind": "bad-request", "error": "..." }`. Public reads do not
have a different shape: HTTP `401` returns `{ "error": "...", "setupRequired": return `401` for missing/expired sessions or `403` for a required password change.
false }` (`true` before initial administrator setup); a required password change Sending host/enrollment bearer credentials still returns `403` because those
returns HTTP `403` with `{ "error": "...", "mustChangePassword": true }`. credentials are restricted to the host-agent API; omit them. Disallowed HTTP transport
Host/enrollment bearer credentials return `403`. Disallowed HTTP transport
returns `426` with an `error` string. Report-service failures can produce `5xx`; returns `426` with an `error` string. Report-service failures can produce `5xx`;
check status and content type before assuming the success schema or a uniform check status and content type before assuming the success schema or a uniform
error body. Failed/rate-limited login requests use their own auth error responses. error body.
For successive pages, retain the same filters and increase `offset` by `limit` For successive pages, retain the same filters and increase `offset` by `limit`
until `offset + entries.length >= total` or the page is empty. There is no cursor, until `offset + entries.length >= total` or the page is empty. There is no cursor,
@@ -335,8 +318,8 @@ an identity rename can change results without changing those timestamps.
### Related routes and current limits ### Related routes and current limits
`GET /api/matches` and `GET /api/servers/{name}/matches` return JSON match `GET /api/matches` and `GET /api/servers/{name}/matches` return JSON match
summaries as `{ "matches": [...], "total": number }`, under the same session summaries as `{ "matches": [...], "total": number }`. These routes still require
authentication. Both accept only `limit` (default 25, range 1–10,000); there is session authentication. Both accept only `limit` (default 25, range 1–10,000); there is
no offset pagination. Results are newest first by first observation, then match no offset pagination. Results are newest first by first observation, then match
ID, and include active/interrupted matches that reached round 2. Their ID, and include active/interrupted matches that reached round 2. Their
[ServerMatch fields](../src/lib/match-history.ts) differ from leaderboard entries [ServerMatch fields](../src/lib/match-history.ts) differ from leaderboard entries
+1
View File
@@ -20,6 +20,7 @@ export function routePermission(routeId: string | null, method: string): RoutePe
if (within(routeId, '/api/agent')) return 'machine'; if (within(routeId, '/api/agent')) return 'machine';
if (routeId === '/api/telemetry' && method === 'POST') return 'machine'; if (routeId === '/api/telemetry' && method === 'POST') return 'machine';
if (routeId === '/api/leaderboard' && reading) return 'public';
if ((routeId === '/login' || routeId === '/setup') && reading) return 'public'; if ((routeId === '/login' || routeId === '/setup') && reading) return 'public';
if ((routeId === '/api/auth/login' || routeId === '/api/auth/setup') && method === 'POST') return 'public'; if ((routeId === '/api/auth/login' || routeId === '/api/auth/setup') && method === 'POST') return 'public';
if (routeId === '/account' || ['/api/auth/me', '/api/auth/logout', '/api/auth/password'].includes(routeId)) return 'authenticated'; if (routeId === '/account' || ['/api/auth/me', '/api/auth/logout', '/api/auth/password'].includes(routeId)) return 'authenticated';
+14 -1
View File
@@ -8,7 +8,7 @@ const allows = (role: UserRole, route: string | null, method = 'GET') => roleAll
describe('management route permissions', () => { describe('management route permissions', () => {
it.each([ it.each([
'/', '/api/fleet', '/capacity', '/api/capacity', '/api/servers', '/servers/[name]', '/api/servers/[name]/scripts', '/', '/api/fleet', '/capacity', '/api/capacity', '/api/servers', '/servers/[name]', '/api/servers/[name]/scripts',
'/api/metrics', '/metrics', '/api/matches', '/matches/[id]', '/api/leaderboard', '/api/metrics', '/metrics', '/api/matches', '/matches/[id]',
'/leaderboard/players/[game]/[guid]', '/api/campaigns/[id]', '/campaigns/[id]', '/leaderboard/players/[game]/[guid]', '/api/campaigns/[id]', '/campaigns/[id]',
'/api/provisioning/[id]', '/provisioning/[id]', '/api/provisioning/[id]/events', '/api/provisioning/[id]', '/provisioning/[id]', '/api/provisioning/[id]/events',
'/api/betas/[id]', '/api/scripts/[id]', '/api/configs', '/api/plugins', '/api/hosts/[id]', '/api/betas/[id]', '/api/scripts/[id]', '/api/configs', '/api/plugins', '/api/hosts/[id]',
@@ -72,6 +72,19 @@ describe('management route permissions', () => {
}); });
describe('authentication and machine boundaries', () => { describe('authentication and machine boundaries', () => {
it('makes only leaderboard API reads public', () => {
for (const method of ['GET', 'HEAD']) {
expect(routePermission('/api/leaderboard', method)).toBe('public');
for (const route of ['/leaderboard', '/leaderboard/players/[guid]',
'/leaderboard/players/[game]/[guid]', '/api/leaderboard/players/[guid]',
'/api/leaderboard-extra', '/api/matches', '/api/servers/[name]/matches', '/matches/[id]']) {
expect(routePermission(route, method)).toBe('viewer');
}
}
for (const method of ['POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS']) {
expect(routePermission('/api/leaderboard', method)).toBe('operator');
}
});
it('makes only the intended login/bootstrap methods public', () => { it('makes only the intended login/bootstrap methods public', () => {
for (const route of ['/login', '/setup']) { for (const route of ['/login', '/setup']) {
expect(routePermission(route, 'GET')).toBe('public'); expect(routePermission(route, 'GET')).toBe('public');
+45 -2
View File
@@ -24,7 +24,7 @@ function signIn(role: UserRole = 'viewer', mustChangePassword = false): Session
} }
type Resolve = Parameters<Handle>[0]['resolve']; type Resolve = Parameters<Handle>[0]['resolve'];
function requestFor(routeId: string | null, options: { function requestFor(routeId: string | null, options: {
method?: string; path?: string; base?: string; origin?: string | null; headers?: HeadersInit; method?: string; path?: string; base?: string; origin?: string | null; headers?: HeadersInit; cookie?: string | null;
body?: unknown; rawBody?: BodyInit; signal?: AbortSignal; resolve?: Resolve; body?: unknown; rawBody?: BodyInit; signal?: AbortSignal; resolve?: Resolve;
} = {}) { } = {}) {
const url = new URL(options.path ?? routeId ?? '/not-found', options.base ?? 'https://ec.test'); const url = new URL(options.path ?? routeId ?? '/not-found', options.base ?? 'https://ec.test');
@@ -32,7 +32,7 @@ function requestFor(routeId: string | null, options: {
if (!['GET', 'HEAD'].includes(method) && options.origin !== null) headers.set('Origin', options.origin ?? url.origin); if (!['GET', 'HEAD'].includes(method) && options.origin !== null) headers.set('Origin', options.origin ?? url.origin);
if (options.body !== undefined && !headers.has('Content-Type')) headers.set('Content-Type', 'application/json'); if (options.body !== undefined && !headers.has('Content-Type')) headers.set('Content-Type', 'application/json');
const request = new Request(url, { method, headers, body: options.rawBody ?? (options.body === undefined ? undefined : JSON.stringify(options.body)), signal: options.signal }); const request = new Request(url, { method, headers, body: options.rawBody ?? (options.body === undefined ? undefined : JSON.stringify(options.body)), signal: options.signal });
const cookies = { get: vi.fn(() => token) }; const cookies = { get: vi.fn(() => options.cookie === null ? undefined : options.cookie ?? token) };
const event = { request, url, route: { id: routeId }, locals: { user: null, sessionHash: null }, cookies } as unknown as RequestEvent; const event = { request, url, route: { id: routeId }, locals: { user: null, sessionHash: null }, cookies } as unknown as RequestEvent;
const resolve = vi.fn(options.resolve ?? (async () => new Response('resolved', { headers: { 'Content-Type': 'text/plain' } }))); const resolve = vi.fn(options.resolve ?? (async () => new Response('resolved', { headers: { 'Content-Type': 'text/plain' } })));
return { event, resolve, cookies, invoke: () => authenticationHandle({ event, resolve } as Parameters<Handle>[0]) }; return { event, resolve, cookies, invoke: () => authenticationHandle({ event, resolve } as Parameters<Handle>[0]) };
@@ -47,6 +47,49 @@ beforeEach(() => {
afterEach(() => { vi.useRealTimers(); vi.unstubAllEnvs(); }); afterEach(() => { vi.useRealTimers(); vi.unstubAllEnvs(); });
describe('browser authentication guard', () => { describe('browser authentication guard', () => {
it.each(['GET', 'HEAD'])('allows anonymous leaderboard %s requests even before account setup', async method => {
for (const setup of [false, true]) {
auth.setup = setup;
const call = requestFor('/api/leaderboard', { method, path: '/api/leaderboard?game=t6&metric=kills', origin: null, cookie: null });
const response = await call.invoke();
expect(response.status).toBe(200);
expect(call.resolve).toHaveBeenCalledOnce();
expect(call.event.locals.user).toBeNull();
expect(response.headers.get('cache-control')).toBe('no-store');
expect(response.headers.get('vary')).toContain('Cookie');
}
});
it('allows leaderboard reads when a supplied session has expired', async () => {
const call = requestFor('/api/leaderboard', { cookie: token });
expect((await call.invoke()).status).toBe(200);
expect(auth.readSession).toHaveBeenCalledWith(token);
expect(call.event.locals.user).toBeNull();
expect(call.resolve).toHaveBeenCalledOnce();
});
it('allows public leaderboard reads with a session that requires a password change', async () => {
signIn('viewer', true);
const call = requestFor('/api/leaderboard');
expect((await call.invoke()).status).toBe(200);
expect(call.resolve).toHaveBeenCalledOnce();
});
it('keeps anonymous leaderboard writes and related API reads protected', async () => {
for (const method of ['POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS']) {
const call = requestFor('/api/leaderboard', { method });
expect((await call.invoke()).status).toBe(401);
expect(call.resolve).not.toHaveBeenCalled();
}
for (const route of ['/api/leaderboard/players/[guid]', '/api/leaderboard-extra',
'/api/matches', '/api/servers/[name]/matches', '/api/fleet']) {
const call = requestFor(route);
expect((await call.invoke()).status).toBe(401);
expect(call.resolve).not.toHaveBeenCalled();
}
for (const route of ['/leaderboard', '/leaderboard/players/[guid]', '/matches/[id]']) {
const call = requestFor(route);
await expect(call.invoke()).rejects.toMatchObject({ status: 303 });
expect(call.resolve).not.toHaveBeenCalled();
}
});
it('redirects anonymous pages to login while APIs return JSON 401 without resolving', async () => { it('redirects anonymous pages to login while APIs return JSON 401 without resolving', async () => {
const page = requestFor('/metrics', { path: '/metrics?days=7' }); const page = requestFor('/metrics', { path: '/metrics?days=7' });
await expect(page.invoke()).rejects.toMatchObject({ status: 303, location: '/login?next=%2Fmetrics%3Fdays%3D7' }); await expect(page.invoke()).rejects.toMatchObject({ status: 303, location: '/login?next=%2Fmetrics%3Fdays%3D7' });