mirror of
https://github.com/kikootwo/ReadMeABook.git
synced 2026-07-13 08:11:08 +00:00
6ec53ff7e3
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.
168 lines
5.8 KiB
TypeScript
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);
|
|
});
|
|
}
|