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:
kikootwo
2026-05-16 14:17:49 -04:00
parent e39e44ee44
commit 6ec53ff7e3
11 changed files with 417 additions and 39 deletions
+3 -1
View File
@@ -131,7 +131,9 @@ export default function ApiDocsPage() {
{/* Footer note */}
<div className="mt-10 text-center">
<p className="text-xs text-gray-400 dark:text-gray-500">
API tokens are restricted to the endpoints listed above.
API tokens are restricted to the endpoints listed above. Endpoints
flagged <span className="font-semibold text-amber-600 dark:text-amber-400">Write</span> mutate
state on behalf of the token owner keep your tokens private.
JWT session authentication has access to all endpoints.
</p>
</div>
+3 -3
View File
@@ -8,7 +8,7 @@ import { getAudibleService } from '@/lib/integrations/audible.service';
import { enrichAudiobooksWithMatches } from '@/lib/utils/audiobook-matcher';
import { deduplicateAndCollectGroups } from '@/lib/utils/deduplicate-audiobooks';
import { persistDedupGroups, collapseByExistingWorks } from '@/lib/services/works.service';
import { getCurrentUser } from '@/lib/middleware/auth';
import { getCurrentUserAsync } from '@/lib/middleware/auth';
import { RMABLogger } from '@/lib/utils/logger';
import { annotateWithIgnoreStatus } from '@/lib/utils/ignored-audiobooks';
@@ -37,8 +37,8 @@ export async function GET(request: NextRequest) {
const audibleService = getAudibleService();
const results = await audibleService.search(query, page);
// Get current user (optional - for request status enrichment)
const currentUser = getCurrentUser(request);
// Get current user (optional — JWT or API token — for request-status enrichment)
const currentUser = await getCurrentUserAsync(request);
const userId = currentUser?.sub || undefined;
// Two-pass dedup: local title/narrator/duration matching first, then collapse
+31 -18
View File
@@ -90,6 +90,11 @@ export function EndpointCard({ endpoint, token, useSession }: EndpointCardProps)
Admin
</span>
)}
{endpoint.isWrite && (
<span className="inline-flex items-center px-2 py-0.5 rounded-md text-[10px] font-semibold uppercase tracking-wider bg-amber-100 dark:bg-amber-900/30 text-amber-700 dark:text-amber-300">
Write
</span>
)}
</div>
<h3 className="text-base font-semibold text-gray-900 dark:text-white mb-1">
{endpoint.title}
@@ -99,25 +104,33 @@ export function EndpointCard({ endpoint, token, useSession }: EndpointCardProps)
</p>
</div>
<button
onClick={handleTryIt}
disabled={loading}
className="flex-shrink-0 inline-flex items-center gap-1.5 px-4 py-2 rounded-xl text-sm font-semibold bg-gray-900 dark:bg-white text-white dark:text-gray-900 hover:bg-gray-800 dark:hover:bg-gray-100 disabled:opacity-50 transition-all active:scale-[0.97]"
>
{loading ? (
<>
<div className="h-3.5 w-3.5 animate-spin rounded-full border-2 border-white/30 dark:border-gray-900/30 border-t-white dark:border-t-gray-900" />
Running
</>
) : (
<>
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2.5} d="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z" />
</svg>
Try it
</>
<div className="flex-shrink-0 flex flex-col items-end gap-1">
<button
onClick={handleTryIt}
disabled={loading || endpoint.isWrite}
title={endpoint.isWrite ? 'Use curl or your API client — write actions disabled here for safety' : undefined}
className="inline-flex items-center gap-1.5 px-4 py-2 rounded-xl text-sm font-semibold bg-gray-900 dark:bg-white text-white dark:text-gray-900 hover:bg-gray-800 dark:hover:bg-gray-100 disabled:opacity-50 disabled:cursor-not-allowed transition-all active:scale-[0.97]"
>
{loading ? (
<>
<div className="h-3.5 w-3.5 animate-spin rounded-full border-2 border-white/30 dark:border-gray-900/30 border-t-white dark:border-t-gray-900" />
Running
</>
) : (
<>
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2.5} d="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z" />
</svg>
Try it
</>
)}
</button>
{endpoint.isWrite && (
<span className="text-[11px] text-gray-500 dark:text-gray-400 max-w-[180px] text-right leading-snug">
Use curl or your API client write actions disabled here for safety.
</span>
)}
</button>
</div>
</div>
{/* Expandable response area */}
@@ -27,6 +27,10 @@ export function ApiTokensSection() {
View API documentation
</Link>
</p>
<p className="mt-2 text-xs text-amber-700 dark:text-amber-400">
API tokens act with your full user-level permissions, including creating audiobook
requests on your behalf. Keep them private.
</p>
</div>
</div>
+65 -5
View File
@@ -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);
});
}
+20 -1
View File
@@ -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
*/