mirror of
https://github.com/kikootwo/ReadMeABook.git
synced 2026-06-03 04:40:09 +00:00
Add API token allowlist, docs, UI and tests
Introduce API token allowlist support and documentation. Adds a new backend docs page for API tokens and updates TABLEOFCONTENTS. Implements API token constants and a compiled matcher (isEndpointAllowed) with support for single-segment :placeholders and an isWrite flag. Split getCurrentUser into a JWT-only helper and added getCurrentUserAsync to recognize rmab_ API tokens; updated the audiobooks search route to use getCurrentUserAsync. Update API docs UI (EndpointCard and api-docs page) to surface Write badges and disable "Try it" for mutating endpoints, and add a profile warning in ApiTokensSection. Add tests for the allowlist matcher and middleware, and adjust existing route tests/mocks accordingly.
This commit is contained in:
@@ -21,7 +21,11 @@ export const MAX_TOKENS_PER_USER = 25;
|
||||
// Endpoint allowlist — restricts which routes API tokens may access
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Shape of an allowed endpoint entry */
|
||||
/**
|
||||
* Shape of an allowed endpoint entry.
|
||||
* `path` may be a literal (e.g. `/api/requests`) or contain `:name` placeholders
|
||||
* that match a single path segment (e.g. `/api/requests/:id`).
|
||||
*/
|
||||
export interface AllowedEndpoint {
|
||||
method: string;
|
||||
path: string;
|
||||
@@ -34,6 +38,8 @@ export interface EndpointDoc {
|
||||
title: string;
|
||||
description: string;
|
||||
requiresAdmin: boolean;
|
||||
/** True for endpoints that mutate state. Surfaced in the /api-docs UI. */
|
||||
isWrite?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -42,7 +48,10 @@ export interface EndpointDoc {
|
||||
*/
|
||||
export const API_TOKEN_ALLOWED_ENDPOINTS: readonly AllowedEndpoint[] = [
|
||||
{ method: 'GET', path: '/api/auth/me' },
|
||||
{ method: 'GET', path: '/api/audiobooks/search' },
|
||||
{ method: 'GET', path: '/api/requests' },
|
||||
{ method: 'POST', path: '/api/requests' },
|
||||
{ method: 'GET', path: '/api/requests/:id' },
|
||||
{ method: 'GET', path: '/api/admin/metrics' },
|
||||
{ method: 'GET', path: '/api/admin/downloads/active' },
|
||||
{ method: 'GET', path: '/api/admin/requests/recent' },
|
||||
@@ -61,6 +70,14 @@ export const API_TOKEN_ENDPOINT_DOCS: readonly EndpointDoc[] = [
|
||||
'Returns the authenticated user\'s profile information including username, role, and account details.',
|
||||
requiresAdmin: false,
|
||||
},
|
||||
{
|
||||
method: 'GET',
|
||||
path: '/api/audiobooks/search',
|
||||
title: 'Search audiobooks',
|
||||
description:
|
||||
'Search Audible for audiobooks by title or author. Query params: `q` (required), `page` (optional). Returns enriched results including per-user request and library availability status.',
|
||||
requiresAdmin: false,
|
||||
},
|
||||
{
|
||||
method: 'GET',
|
||||
path: '/api/requests',
|
||||
@@ -69,6 +86,23 @@ export const API_TOKEN_ENDPOINT_DOCS: readonly EndpointDoc[] = [
|
||||
'Returns all audiobook requests visible to the authenticated user. Admins see all requests, users see their own.',
|
||||
requiresAdmin: false,
|
||||
},
|
||||
{
|
||||
method: 'POST',
|
||||
path: '/api/requests',
|
||||
title: 'Create request',
|
||||
description:
|
||||
'Create a new audiobook request on behalf of the token owner. Body: `{ "audiobook": { "asin", "title", "author", "narrator?", "description?", "coverArtUrl?" } }`. Follows the user\'s normal auto-approve rules; returns named error codes (`already_available`, `being_processed`, `duplicate`, `ignored`, `user_not_found`) on rejection.',
|
||||
requiresAdmin: false,
|
||||
isWrite: true,
|
||||
},
|
||||
{
|
||||
method: 'GET',
|
||||
path: '/api/requests/:id',
|
||||
title: 'Get request by ID',
|
||||
description:
|
||||
'Returns a single audiobook request including audiobook details, download history, and recent job state. Users may only fetch requests they own; admins may fetch any.',
|
||||
requiresAdmin: false,
|
||||
},
|
||||
{
|
||||
method: 'GET',
|
||||
path: '/api/admin/metrics',
|
||||
@@ -95,13 +129,39 @@ export const API_TOKEN_ENDPOINT_DOCS: readonly EndpointDoc[] = [
|
||||
},
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* Compiled allowlist used by `isEndpointAllowed`. Patterns with `:name`
|
||||
* placeholders are compiled to anchored regexes that match a single path
|
||||
* segment (`[^/]+`); literal paths use string equality.
|
||||
*/
|
||||
interface CompiledEndpoint {
|
||||
method: string;
|
||||
literal: string | null;
|
||||
pattern: RegExp | null;
|
||||
}
|
||||
|
||||
function compileEndpoint(ep: AllowedEndpoint): CompiledEndpoint {
|
||||
const method = ep.method.toUpperCase();
|
||||
if (!ep.path.includes(':')) {
|
||||
return { method, literal: ep.path, pattern: null };
|
||||
}
|
||||
const escaped = ep.path.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
const regexSource = escaped.replace(/:[A-Za-z_][A-Za-z0-9_]*/g, '[^/]+');
|
||||
return { method, literal: null, pattern: new RegExp(`^${regexSource}$`) };
|
||||
}
|
||||
|
||||
const COMPILED_ENDPOINTS: readonly CompiledEndpoint[] = API_TOKEN_ALLOWED_ENDPOINTS.map(compileEndpoint);
|
||||
|
||||
/**
|
||||
* Check whether a given method + path is on the API token allowlist.
|
||||
* Method comparison is case-insensitive.
|
||||
* Method comparison is case-insensitive. Supports dynamic single-segment
|
||||
* placeholders (`:id`) compiled at module load.
|
||||
*/
|
||||
export function isEndpointAllowed(method: string, path: string): boolean {
|
||||
const upperMethod = method.toUpperCase();
|
||||
return API_TOKEN_ALLOWED_ENDPOINTS.some(
|
||||
(ep) => ep.method === upperMethod && ep.path === path
|
||||
);
|
||||
return COMPILED_ENDPOINTS.some((ep) => {
|
||||
if (ep.method !== upperMethod) return false;
|
||||
if (ep.literal !== null) return ep.literal === path;
|
||||
return ep.pattern!.test(path);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -242,7 +242,9 @@ export async function requireAdmin(
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper: Get current user from request (for use in API routes)
|
||||
* Helper: Get current user from request (for use in API routes).
|
||||
* JWT-only — does NOT recognize API tokens. Use `getCurrentUserAsync` if
|
||||
* the caller should also accept `rmab_`-prefixed API tokens.
|
||||
*/
|
||||
export function getCurrentUser(request: NextRequest): TokenPayload | null {
|
||||
const token = extractToken(request);
|
||||
@@ -250,6 +252,23 @@ export function getCurrentUser(request: NextRequest): TokenPayload | null {
|
||||
return verifyAccessToken(token);
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper: Get current user from request, recognizing BOTH JWT sessions and
|
||||
* API tokens (`rmab_` prefix). Returns the same `TokenPayload` shape in both
|
||||
* cases so callers don't need to branch on auth type.
|
||||
*
|
||||
* Use this in routes that are open to optional auth but should still enrich
|
||||
* responses with per-user context when called by an API token holder.
|
||||
*/
|
||||
export async function getCurrentUserAsync(request: NextRequest): Promise<TokenPayload | null> {
|
||||
const token = extractToken(request);
|
||||
if (!token) return null;
|
||||
if (token.startsWith(API_TOKEN_PREFIX)) {
|
||||
return authenticateApiToken(token);
|
||||
}
|
||||
return verifyAccessToken(token);
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper: Check if user is admin
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user