mirror of
https://github.com/yusufipk/OpenFrame.git
synced 2026-09-11 09:36:08 +00:00
Merge pull request #53 from yusufipk/feat/admin-api-token
feat(admin): let a token read the growth scoreboard without a session
This commit is contained in:
@@ -180,7 +180,7 @@ Behavior when disabled:
|
|||||||
- `OPENFRAME_ENABLE_BUNNY_UPLOADS=false` hides Bunny direct-upload entry points. URL-based providers such as YouTube remain available.
|
- `OPENFRAME_ENABLE_BUNNY_UPLOADS=false` hides Bunny direct-upload entry points. URL-based providers such as YouTube remain available.
|
||||||
- `OPENFRAME_ENABLE_S3_VIDEO_UPLOADS=true` (with `R2_*` configured) enables presigned uploads to your own S3-compatible storage. Set `OPENFRAME_ENABLE_BUNNY_UPLOADS=false` — only one direct-upload backend can be active. The bucket must allow CORS `PUT` from your app origin (for example `http://localhost:3000` in dev and your production URL). For Docker + MinIO, keep `R2_ENDPOINT=http://minio:9000` (app-internal) and set `R2_PRESIGN_ENDPOINT` to the browser-reachable MinIO origin (for example `http://localhost:9000` locally, or `https://minio.example.com` when MinIO is behind a reverse proxy). Use the origin only — no path suffix. The app's Content-Security-Policy is generated from runtime env at request time, so published Docker images pick up custom `R2_PRESIGN_ENDPOINT` values without rebuilding or editing `next.config.ts`.
|
- `OPENFRAME_ENABLE_S3_VIDEO_UPLOADS=true` (with `R2_*` configured) enables presigned uploads to your own S3-compatible storage. Set `OPENFRAME_ENABLE_BUNNY_UPLOADS=false` — only one direct-upload backend can be active. The bucket must allow CORS `PUT` from your app origin (for example `http://localhost:3000` in dev and your production URL). For Docker + MinIO, keep `R2_ENDPOINT=http://minio:9000` (app-internal) and set `R2_PRESIGN_ENDPOINT` to the browser-reachable MinIO origin (for example `http://localhost:9000` locally, or `https://minio.example.com` when MinIO is behind a reverse proxy). Use the origin only — no path suffix. The app's Content-Security-Policy is generated from runtime env at request time, so published Docker images pick up custom `R2_PRESIGN_ENDPOINT` values without rebuilding or editing `next.config.ts`.
|
||||||
- `OPENFRAME_REQUIRE_INVITE_CODE=false` allows open registration while keeping invitation-link registration intact.
|
- `OPENFRAME_REQUIRE_INVITE_CODE=false` allows open registration while keeping invitation-link registration intact.
|
||||||
- `OPENFRAME_ENABLE_ANALYTICS=true` records first-touch attribution and funnel events into your own database, readable on `/admin/growth`. Off by default, and nothing leaves the instance either way.
|
- `OPENFRAME_ENABLE_ANALYTICS=true` records first-touch attribution and funnel events into your own database, readable on `/admin/growth`, or as JSON on `/api/admin/growth` by a script sending `Authorization: Bearer $OPENFRAME_ADMIN_API_TOKEN` (at least 32 characters, unset by default, in which case an admin session is the only way in). Off by default, and nothing leaves the instance either way.
|
||||||
|
|
||||||
For self-hosted MinIO behind a reverse proxy, choose one of these browser-facing layouts:
|
For self-hosted MinIO behind a reverse proxy, choose one of these browser-facing layouts:
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { NextRequest } from 'next/server';
|
import { NextRequest } from 'next/server';
|
||||||
import { auth } from '@/lib/auth';
|
import { auth } from '@/lib/auth';
|
||||||
|
import { isAdminApiTokenRequest } from '@/lib/admin-api-token';
|
||||||
import { apiErrors, successResponse, withCacheControl } from '@/lib/api-response';
|
import { apiErrors, successResponse, withCacheControl } from '@/lib/api-response';
|
||||||
import { conversionRates, getScoreboard } from '@/lib/analytics/scoreboard';
|
import { conversionRates, getScoreboard } from '@/lib/analytics/scoreboard';
|
||||||
import { isProductAnalyticsEnabled } from '@/lib/feature-flags';
|
import { isProductAnalyticsEnabled } from '@/lib/feature-flags';
|
||||||
@@ -9,12 +10,17 @@ import { logError } from '@/lib/logger';
|
|||||||
// the scoreboard instead of somebody retyping it into a table.
|
// the scoreboard instead of somebody retyping it into a table.
|
||||||
export async function GET(request: NextRequest) {
|
export async function GET(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
const session = await auth();
|
// A bearer token stands in for the admin session so the weekly digest can
|
||||||
if (!session?.user?.id) {
|
// read this without a browser. Unset by default, in which case the only way
|
||||||
return apiErrors.unauthorized();
|
// in is still an admin session.
|
||||||
}
|
if (!isAdminApiTokenRequest(request)) {
|
||||||
if (!session.user.isAdmin) {
|
const session = await auth();
|
||||||
return apiErrors.forbidden('Admin access required');
|
if (!session?.user?.id) {
|
||||||
|
return apiErrors.unauthorized();
|
||||||
|
}
|
||||||
|
if (!session.user.isAdmin) {
|
||||||
|
return apiErrors.forbidden('Admin access required');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!isProductAnalyticsEnabled()) {
|
if (!isProductAnalyticsEnabled()) {
|
||||||
|
|||||||
@@ -0,0 +1,58 @@
|
|||||||
|
/**
|
||||||
|
* A machine caller for the read-only admin endpoints.
|
||||||
|
*
|
||||||
|
* The growth scoreboard is read once a week by a digest script that 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.
|
||||||
|
*
|
||||||
|
* Off unless `OPENFRAME_ADMIN_API_TOKEN` is set, so a self-hosted instance that
|
||||||
|
* never sets it keeps session-only admin access.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { createHash, timingSafeEqual } from 'node:crypto';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The shortest token this accepts.
|
||||||
|
*
|
||||||
|
* Behind this header sit every paying account's name, email and usage, so a
|
||||||
|
* short token is a guessable path to all of it. A token under this length is
|
||||||
|
* treated as no token at all rather than as a weaker one: failing closed makes
|
||||||
|
* a bad value visible on the first call, where silently accepting it would
|
||||||
|
* leave the endpoint open and look fine.
|
||||||
|
*/
|
||||||
|
export const MIN_ADMIN_API_TOKEN_LENGTH = 32;
|
||||||
|
|
||||||
|
/** The configured token, or null when it is absent or too short to be safe. */
|
||||||
|
export function getAdminApiToken(): string | null {
|
||||||
|
const raw = process.env.OPENFRAME_ADMIN_API_TOKEN?.trim();
|
||||||
|
if (!raw || raw.length < MIN_ADMIN_API_TOKEN_LENGTH) return null;
|
||||||
|
return raw;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Constant-time comparison over SHA-256 digests.
|
||||||
|
*
|
||||||
|
* Hashing first is not about secrecy, it is about length: `timingSafeEqual`
|
||||||
|
* throws on unequal-length buffers, and the obvious length check before it
|
||||||
|
* would leak the token's length through timing. Digests are always 32 bytes.
|
||||||
|
*/
|
||||||
|
function tokensMatch(candidate: string, expected: string): boolean {
|
||||||
|
const digest = (value: string) => createHash('sha256').update(value, 'utf8').digest();
|
||||||
|
return timingSafeEqual(digest(candidate), digest(expected));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** True when the request carries `Authorization: Bearer <the configured token>`. */
|
||||||
|
export function isAdminApiTokenRequest(request: Request): boolean {
|
||||||
|
const expected = getAdminApiToken();
|
||||||
|
if (!expected) return false;
|
||||||
|
|
||||||
|
const header = request.headers.get('authorization');
|
||||||
|
if (!header) return false;
|
||||||
|
|
||||||
|
const [scheme, ...rest] = header.trim().split(/\s+/);
|
||||||
|
if (scheme.toLowerCase() !== 'bearer' || rest.length !== 1) return false;
|
||||||
|
|
||||||
|
return tokensMatch(rest[0], expected);
|
||||||
|
}
|
||||||
@@ -13,6 +13,9 @@ import {
|
|||||||
getCohortComparison,
|
getCohortComparison,
|
||||||
getScoreboard,
|
getScoreboard,
|
||||||
} from '@/lib/analytics/scoreboard';
|
} 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';
|
import { createUser } from '../factories';
|
||||||
|
|
||||||
function daysAgo(days: number): Date {
|
function daysAgo(days: number): Date {
|
||||||
@@ -271,3 +274,76 @@ describe('getCohortComparison', () => {
|
|||||||
expect(comparison?.rows.every((row) => row.signups === 0)).toBe(true);
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user