feat(admin): let a token read the growth scoreboard without a session

The weekly digest reads /api/admin/growth from a script, which has no browser
and therefore no NextAuth session. The alternative was copying a session cookie
out of a browser by hand: those are JWTs with a 30-day lifetime, so a scheduled
job built on one stops working a month later and reports nothing rather than
reporting a failure.

The token path is off unless OPENFRAME_ADMIN_API_TOKEN is set, so an instance
that never sets it keeps session-only admin access. A value under 32 characters
is treated as no token at all: behind this header sit every paying account's
name, email and usage, and a short token is a guessable path to all of it.
Comparison runs over SHA-256 digests so it stays constant time without leaking
the token's length.
This commit is contained in:
2026-08-18 10:22:42 +03:00
parent 32164db15c
commit bfe3cb28b4
5 changed files with 227 additions and 7 deletions
+76
View File
@@ -13,6 +13,9 @@ import {
getCohortComparison,
getScoreboard,
} from '@/lib/analytics/scoreboard';
import { GET as growthRoute } from '@/app/api/admin/growth/route';
import { apiRequest, callRoute, readData } from '../helpers/request';
import { signedInAs, signedOut } from '../helpers/session';
import { createUser } from '../factories';
function daysAgo(days: number): Date {
@@ -271,3 +274,76 @@ describe('getCohortComparison', () => {
expect(comparison?.rows.every((row) => row.signups === 0)).toBe(true);
});
});
// The token path exists so a scheduled digest can read this endpoint with no
// browser. It is the only way into admin data that carries no session, so the
// cases that matter are the ones where it must not open: unset, wrong, and a
// caller who is signed in but not an admin.
describe('GET /api/admin/growth', () => {
const TOKEN = 'wq7Fr2Tn8Vb4Kd1Mw6Hs9Lp3Cf5Gj0Ye';
function growthRequest(headers?: Record<string, string>) {
return callRoute(growthRoute, apiRequest('/api/admin/growth', { headers }));
}
it('refuses an anonymous caller when no token is configured', async () => {
signedOut();
vi.stubEnv('OPENFRAME_ADMIN_API_TOKEN', '');
// The header a caller would send if they had guessed the scheme but there is
// nothing to guess: an unset token must never match.
const response = await growthRequest({ authorization: `Bearer ${TOKEN}` });
expect(response.status).toBe(401);
});
it('refuses a signed-in caller who is not an admin', async () => {
const user = await createUser();
signedInAs({ id: user.id, email: user.email, isAdmin: false });
const response = await growthRequest();
expect(response.status).toBe(403);
});
it('refuses a bearer token that is not the configured one', async () => {
signedOut();
vi.stubEnv('OPENFRAME_ADMIN_API_TOKEN', TOKEN);
const response = await growthRequest({
authorization: 'Bearer wq7Fr2Tn8Vb4Kd1Mw6Hs9Lp3Cf5Gj0Yf',
});
expect(response.status).toBe(401);
});
it('serves the scoreboard to a caller carrying the configured token', async () => {
signedOut();
vi.stubEnv('OPENFRAME_ADMIN_API_TOKEN', TOKEN);
const paying = await createUser({ subscriptionStatus: 'ACTIVE' });
await seedEvent({ name: 'SIGNUP_COMPLETED', occurredAt: daysAgo(1), userId: paying.id });
const response = await growthRequest({ authorization: `Bearer ${TOKEN}` });
expect(response.status).toBe(200);
const scoreboard = await readData(response);
expect(scoreboard.paidAccounts.map((row: { userId: string }) => row.userId)).toContain(
paying.id
);
// Rates ride along with each week; the digest reads them rather than
// recomputing the denominators.
expect(scoreboard.weeks.at(-1)).toHaveProperty('rates');
});
it('still refuses the token when analytics are off, without saying so', async () => {
signedOut();
vi.stubEnv('OPENFRAME_ENABLE_ANALYTICS', 'false');
vi.stubEnv('OPENFRAME_ADMIN_API_TOKEN', TOKEN);
// Authorized, but the flag is off: a 400, not a scoreboard.
const authorized = await growthRequest({ authorization: `Bearer ${TOKEN}` });
expect(authorized.status).toBe(400);
// Unauthorized callers must not learn the flag's state from the status code.
const anonymous = await growthRequest();
expect(anonymous.status).toBe(401);
});
});
+80
View File
@@ -0,0 +1,80 @@
// The guard that lets a script read admin data without a session.
//
// Every case here is a way the endpoint could end up open: a token short enough
// to guess, a comparison that accepts a prefix, or an absent env var read as an
// absent header and waved through.
import { describe, expect, it, afterEach, vi } from 'vitest';
import {
MIN_ADMIN_API_TOKEN_LENGTH,
getAdminApiToken,
isAdminApiTokenRequest,
} from '@/lib/admin-api-token';
// Written out rather than generated from the constant: a test whose input comes
// from the code under test stops testing the length rule the moment it changes.
const TOKEN = 'zq4Xr7Tn2Vb9Kd5Mw8Hs3Lp6Cf1Gj0Ye';
const SHORT = 'zq4Xr7Tn2Vb9Kd5Mw8Hs3Lp6Cf1Gj0Y';
function requestWith(headers: Record<string, string>): Request {
return new Request('http://localhost:3000/api/admin/growth', { headers });
}
afterEach(() => {
vi.unstubAllEnvs();
});
describe('getAdminApiToken', () => {
it('is null when nothing is configured', () => {
vi.stubEnv('OPENFRAME_ADMIN_API_TOKEN', '');
expect(getAdminApiToken()).toBeNull();
});
it('is null for a token shorter than the minimum', () => {
expect(SHORT).toHaveLength(MIN_ADMIN_API_TOKEN_LENGTH - 1);
vi.stubEnv('OPENFRAME_ADMIN_API_TOKEN', SHORT);
expect(getAdminApiToken()).toBeNull();
});
it('trims surrounding whitespace, which a pasted env value carries', () => {
vi.stubEnv('OPENFRAME_ADMIN_API_TOKEN', ` ${TOKEN}\n`);
expect(getAdminApiToken()).toBe(TOKEN);
});
});
describe('isAdminApiTokenRequest', () => {
it('refuses every request when no token is configured', () => {
vi.stubEnv('OPENFRAME_ADMIN_API_TOKEN', '');
expect(isAdminApiTokenRequest(requestWith({ authorization: `Bearer ${TOKEN}` }))).toBe(false);
expect(isAdminApiTokenRequest(requestWith({}))).toBe(false);
});
it('refuses a matching header when the configured token is too short', () => {
vi.stubEnv('OPENFRAME_ADMIN_API_TOKEN', SHORT);
expect(isAdminApiTokenRequest(requestWith({ authorization: `Bearer ${SHORT}` }))).toBe(false);
});
it('accepts the configured token, whatever case the scheme is written in', () => {
vi.stubEnv('OPENFRAME_ADMIN_API_TOKEN', TOKEN);
expect(isAdminApiTokenRequest(requestWith({ authorization: `Bearer ${TOKEN}` }))).toBe(true);
expect(isAdminApiTokenRequest(requestWith({ authorization: `bearer ${TOKEN}` }))).toBe(true);
});
it('refuses a prefix, a suffix and a different token of the same length', () => {
vi.stubEnv('OPENFRAME_ADMIN_API_TOKEN', TOKEN);
expect(isAdminApiTokenRequest(requestWith({ authorization: `Bearer ${SHORT}` }))).toBe(false);
expect(isAdminApiTokenRequest(requestWith({ authorization: `Bearer ${TOKEN}x` }))).toBe(false);
expect(
isAdminApiTokenRequest(
requestWith({ authorization: 'Bearer aq4Xr7Tn2Vb9Kd5Mw8Hs3Lp6Cf1Gj0Ye' })
)
).toBe(false);
});
it('refuses a missing header, a bare token and another scheme', () => {
vi.stubEnv('OPENFRAME_ADMIN_API_TOKEN', TOKEN);
expect(isAdminApiTokenRequest(requestWith({}))).toBe(false);
expect(isAdminApiTokenRequest(requestWith({ authorization: TOKEN }))).toBe(false);
expect(isAdminApiTokenRequest(requestWith({ authorization: `Basic ${TOKEN}` }))).toBe(false);
});
});