/** * 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); }); }