Files
OpenFrame/app/api/admin/growth/route.ts
T
yusufipek bfe3cb28b4 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.
2026-08-18 10:22:42 +03:00

45 lines
1.8 KiB
TypeScript

import { NextRequest } from 'next/server';
import { auth } from '@/lib/auth';
import { isAdminApiTokenRequest } from '@/lib/admin-api-token';
import { apiErrors, successResponse, withCacheControl } from '@/lib/api-response';
import { conversionRates, getScoreboard } from '@/lib/analytics/scoreboard';
import { isProductAnalyticsEnabled } from '@/lib/feature-flags';
import { logError } from '@/lib/logger';
// The same numbers /admin/growth renders, as JSON, so the Monday digest can pull
// the scoreboard instead of somebody retyping it into a table.
export async function GET(request: NextRequest) {
try {
// A bearer token stands in for the admin session so the weekly digest can
// read this without a browser. Unset by default, in which case the only way
// in is still an admin session.
if (!isAdminApiTokenRequest(request)) {
const session = await auth();
if (!session?.user?.id) {
return apiErrors.unauthorized();
}
if (!session.user.isAdmin) {
return apiErrors.forbidden('Admin access required');
}
}
if (!isProductAnalyticsEnabled()) {
return apiErrors.badRequest('Analytics are disabled by this host');
}
const weeksParam = Number(request.nextUrl.searchParams.get('weeks'));
const scoreboard = await getScoreboard({
weeks: Number.isSafeInteger(weeksParam) && weeksParam > 0 ? weeksParam : undefined,
});
const response = successResponse({
...scoreboard,
weeks: scoreboard.weeks.map((week) => ({ ...week, rates: conversionRates(week) })),
});
return withCacheControl(response, 'private, no-store');
} catch (error) {
logError('Error building the growth scoreboard:', error);
return apiErrors.internalError('Failed to build the scoreboard');
}
}