Files
ReadMeABook/src/lib/constants/api-tokens.ts
T
kikootwo 6ec53ff7e3 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.
2026-05-16 14:17:49 -04:00

168 lines
5.8 KiB
TypeScript

/**
* Component: API Token Constants
* Documentation: documentation/backend/services/api-tokens.md
*
* Centralized API token constants used across authentication middleware and token routes.
*/
/** Prefix prepended to all generated API tokens for identification */
export const API_TOKEN_PREFIX = 'rmab_';
/** Number of random bytes used to generate the token's random portion */
export const TOKEN_RANDOM_BYTES = 32;
/** Length of the token prefix stored in the database for display (first 12 chars: "rmab_" + 7 hex chars) */
export const TOKEN_PREFIX_LENGTH = 12;
/** Maximum number of active (non-expired) API tokens a single user may hold */
export const MAX_TOKENS_PER_USER = 25;
// ---------------------------------------------------------------------------
// Endpoint allowlist — restricts which routes API tokens may access
// ---------------------------------------------------------------------------
/**
* 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;
}
/** Extended metadata used by the interactive API docs page */
export interface EndpointDoc {
method: string;
path: string;
title: string;
description: string;
requiresAdmin: boolean;
/** True for endpoints that mutate state. Surfaced in the /api-docs UI. */
isWrite?: boolean;
}
/**
* Endpoints that API tokens are permitted to call.
* JWT-authenticated sessions are NOT restricted by this list.
*/
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' },
] as const;
/**
* Full documentation metadata for each allowed endpoint.
* Consumed by the /api-docs interactive page.
*/
export const API_TOKEN_ENDPOINT_DOCS: readonly EndpointDoc[] = [
{
method: 'GET',
path: '/api/auth/me',
title: 'Get current user',
description:
'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',
title: 'List requests',
description:
'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',
title: 'System metrics',
description:
'Returns system health metrics including request counts, download statistics, and library size.',
requiresAdmin: true,
},
{
method: 'GET',
path: '/api/admin/downloads/active',
title: 'Active downloads',
description:
'Returns currently active downloads including progress, speed, and ETA.',
requiresAdmin: true,
},
{
method: 'GET',
path: '/api/admin/requests/recent',
title: 'Recent requests',
description:
'Returns the most recent audiobook requests across all users.',
requiresAdmin: true,
},
] 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. Supports dynamic single-segment
* placeholders (`:id`) compiled at module load.
*/
export function isEndpointAllowed(method: string, path: string): boolean {
const upperMethod = method.toUpperCase();
return COMPILED_ENDPOINTS.some((ep) => {
if (ep.method !== upperMethod) return false;
if (ep.literal !== null) return ep.literal === path;
return ep.pattern!.test(path);
});
}