From 083d29323f4be1c20556f52bd57439cc34a68ce4 Mon Sep 17 00:00:00 2001 From: Josh Wright Date: Mon, 5 Oct 2026 16:46:44 -0400 Subject: [PATCH] Allow public leaderboard API reads --- docs/authentication.md | 6 ++++ docs/leaderboard.md | 51 ++++++++++++---------------------- src/lib/server/auth/access.ts | 1 + tests/unit/auth-access.test.ts | 15 +++++++++- tests/unit/auth-guard.test.ts | 47 +++++++++++++++++++++++++++++-- 5 files changed, 83 insertions(+), 37 deletions(-) diff --git a/docs/authentication.md b/docs/authentication.md index 76af189..490c195 100644 --- a/docs/authentication.md +++ b/docs/authentication.md @@ -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. 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 1. Generate a setup secret: `openssl rand -hex 32`. diff --git a/docs/leaderboard.md b/docs/leaderboard.md index ad58709..f8c7160 100644 --- a/docs/leaderboard.md +++ b/docs/leaderboard.md @@ -157,35 +157,18 @@ sources are the [HTTP handler](../src/routes/api/leaderboard/+server.ts), ### Authentication and requests -`GET /api/leaderboard` is a session-authenticated JSON endpoint. A **viewer**, -operator or administrator account can read it after any required temporary -password change. There is no API-key or bearer-token authentication for this -endpoint; telemetry and host-agent credentials do not grant access. +`GET /api/leaderboard` is a public JSON endpoint; `HEAD` is public too. A tool +does not need an account, session cookie, API key or authorization header to +read it. Reads also work before administrator setup and with an expired session +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 -`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":"..."}`: +For example: ```sh EC_BASE_URL='https://ec.example.com' -umask 077 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" \ --data-urlencode 'game=t6' \ --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` -and a loopback hostname, and uses the separate `ec_session_dev` cookie. The -read request does not require an `Origin` header. Successful responses use +and a loopback hostname. The read request does not require an `Origin` header. +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`. ### Query parameters @@ -308,14 +292,13 @@ strings such as `2026-10-05T12:00:00.000Z`, not reporter gameplay-clock times. ### Errors, pagination and freshness Invalid query values return HTTP `400` with -`{ "ok": false, "kind": "bad-request", "error": "..." }`. Authentication errors -have a different shape: HTTP `401` returns `{ "error": "...", "setupRequired": -false }` (`true` before initial administrator setup); a required password change -returns HTTP `403` with `{ "error": "...", "mustChangePassword": true }`. -Host/enrollment bearer credentials return `403`. Disallowed HTTP transport +`{ "ok": false, "kind": "bad-request", "error": "..." }`. Public reads do not +return `401` for missing/expired sessions or `403` for a required password change. +Sending host/enrollment bearer credentials still returns `403` because those +credentials are restricted to the host-agent API; omit them. Disallowed HTTP transport 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 -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` 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 `GET /api/matches` and `GET /api/servers/{name}/matches` return JSON match -summaries as `{ "matches": [...], "total": number }`, under the same session -authentication. Both accept only `limit` (default 25, range 1–10,000); there is +summaries as `{ "matches": [...], "total": number }`. These routes still require +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 ID, and include active/interrupted matches that reached round 2. Their [ServerMatch fields](../src/lib/match-history.ts) differ from leaderboard entries diff --git a/src/lib/server/auth/access.ts b/src/lib/server/auth/access.ts index dae65b9..fd6f6bb 100644 --- a/src/lib/server/auth/access.ts +++ b/src/lib/server/auth/access.ts @@ -20,6 +20,7 @@ export function routePermission(routeId: string | null, method: string): RoutePe if (within(routeId, '/api/agent')) 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 === '/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'; diff --git a/tests/unit/auth-access.test.ts b/tests/unit/auth-access.test.ts index c9ef968..49a71ec 100644 --- a/tests/unit/auth-access.test.ts +++ b/tests/unit/auth-access.test.ts @@ -8,7 +8,7 @@ const allows = (role: UserRole, route: string | null, method = 'GET') => roleAll describe('management route permissions', () => { it.each([ '/', '/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]', '/api/provisioning/[id]', '/provisioning/[id]', '/api/provisioning/[id]/events', '/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', () => { + 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', () => { for (const route of ['/login', '/setup']) { expect(routePermission(route, 'GET')).toBe('public'); diff --git a/tests/unit/auth-guard.test.ts b/tests/unit/auth-guard.test.ts index 6559bf2..e8f2c41 100644 --- a/tests/unit/auth-guard.test.ts +++ b/tests/unit/auth-guard.test.ts @@ -24,7 +24,7 @@ function signIn(role: UserRole = 'viewer', mustChangePassword = false): Session } type Resolve = Parameters[0]['resolve']; 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; } = {}) { 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 (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 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 resolve = vi.fn(options.resolve ?? (async () => new Response('resolved', { headers: { 'Content-Type': 'text/plain' } }))); return { event, resolve, cookies, invoke: () => authenticationHandle({ event, resolve } as Parameters[0]) }; @@ -47,6 +47,49 @@ beforeEach(() => { afterEach(() => { vi.useRealTimers(); vi.unstubAllEnvs(); }); 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 () => { const page = requestFor('/metrics', { path: '/metrics?days=7' }); await expect(page.invoke()).rejects.toMatchObject({ status: 303, location: '/login?next=%2Fmetrics%3Fdays%3D7' });