mirror of
https://github.com/kikootwo/ReadMeABook.git
synced 2026-06-02 20:30:10 +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:
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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