diff --git a/prisma/schema.prisma b/prisma/schema.prisma
index 2decb53..174d882 100644
--- a/prisma/schema.prisma
+++ b/prisma/schema.prisma
@@ -68,6 +68,8 @@ model User {
goodreadsShelves GoodreadsShelf[]
reportedIssues ReportedIssue[] @relation("Reporter")
resolvedIssues ReportedIssue[] @relation("Resolver")
+ createdApiTokens ApiToken[] @relation("CreatedApiTokens")
+ apiTokens ApiToken[] @relation("UserApiTokens")
watchedSeries WatchedSeries[]
watchedAuthors WatchedAuthor[]
@@ -498,6 +500,34 @@ model ReportedIssue {
// Per-user Goodreads shelf subscriptions + global book-to-ASIN mapping cache
// ============================================================================
+// ============================================================================
+// API TOKEN TABLE
+// Static API tokens for programmatic access (alternative to JWT)
+// Documentation: documentation/backend/services/api-tokens.md
+// ============================================================================
+
+model ApiToken {
+ id String @id @default(uuid())
+ name String // User-friendly label (e.g., "Home Assistant", "Webhook")
+ tokenHash String @unique @map("token_hash") // SHA-256 hash of the token (never store plaintext)
+ tokenPrefix String @map("token_prefix") // First 8 chars for display (e.g., "rmab_a1b2")
+ role String @default("user") // Token role: 'admin' or 'user'
+ createdById String @map("created_by_id") // Who created the token (may differ from userId for admin-created tokens)
+ userId String @map("user_id") // The user identity this token acts as
+ lastUsedAt DateTime? @map("last_used_at")
+ expiresAt DateTime? @map("expires_at") // null = never expires
+ createdAt DateTime @default(now()) @map("created_at")
+
+ // Relations
+ createdBy User @relation("CreatedApiTokens", fields: [createdById], references: [id], onDelete: Cascade)
+ tokenUser User @relation("UserApiTokens", fields: [userId], references: [id], onDelete: Cascade)
+
+ @@index([tokenHash])
+ @@index([createdById])
+ @@index([userId])
+ @@map("api_tokens")
+}
+
model GoodreadsShelf {
id String @id @default(uuid())
userId String @map("user_id")
diff --git a/src/app/admin/settings/lib/helpers.ts b/src/app/admin/settings/lib/helpers.ts
index ff8f648..38271c5 100644
--- a/src/app/admin/settings/lib/helpers.ts
+++ b/src/app/admin/settings/lib/helpers.ts
@@ -210,6 +210,7 @@ export const getTabValidation = (
return validated.paths;
case 'ebook':
case 'bookdate':
+ case 'api':
return true; // These tabs handle their own saving
default:
return false;
@@ -228,4 +229,5 @@ export const getTabs = (backendMode: 'plex' | 'audiobookshelf') => [
{ id: 'ebook' as const, label: 'E-book Sidecar', icon: '📖' },
{ id: 'bookdate' as const, label: 'BookDate', icon: '📚' },
{ id: 'notifications' as const, label: 'Notifications', icon: '🔔' },
+ { id: 'api' as const, label: 'API', icon: '🔑' },
];
diff --git a/src/app/admin/settings/lib/types.ts b/src/app/admin/settings/lib/types.ts
index 6a104cf..bbb4070 100644
--- a/src/app/admin/settings/lib/types.ts
+++ b/src/app/admin/settings/lib/types.ts
@@ -243,4 +243,4 @@ export interface BookDateModel {
/**
* Tab identifier type
*/
-export type SettingsTab = 'library' | 'auth' | 'prowlarr' | 'download' | 'paths' | 'ebook' | 'bookdate' | 'notifications';
+export type SettingsTab = 'library' | 'auth' | 'prowlarr' | 'download' | 'paths' | 'ebook' | 'bookdate' | 'notifications' | 'api';
diff --git a/src/app/admin/settings/page.tsx b/src/app/admin/settings/page.tsx
index 01b5416..13af2e1 100644
--- a/src/app/admin/settings/page.tsx
+++ b/src/app/admin/settings/page.tsx
@@ -23,6 +23,7 @@ import { PathsTab } from './tabs/PathsTab/PathsTab';
import { EbookTab } from './tabs/EbookTab/EbookTab';
import { BookDateTab } from './tabs/BookDateTab/BookDateTab';
import { NotificationsTab } from './tabs/NotificationsTab';
+import { ApiTab } from './tabs/ApiTab/ApiTab';
// Types and Helpers
import type { Settings, SettingsTab, IndexerConfig, SavedIndexerConfig, Message } from './lib/types';
@@ -346,8 +347,11 @@ export default function AdminSettings() {
{/* Notifications Tab */}
{activeTab === 'notifications' && }
+ {/* API Tab */}
+ {activeTab === 'api' && }
+
{/* Save Button (only for tabs that save through main page) */}
- {activeTab !== 'ebook' && activeTab !== 'bookdate' && activeTab !== 'notifications' && (
+ {activeTab !== 'ebook' && activeTab !== 'bookdate' && activeTab !== 'notifications' && activeTab !== 'api' && (
)}
+
+ {/* API Tokens */}
+
);
diff --git a/src/components/profile/ApiTokensSection.tsx b/src/components/profile/ApiTokensSection.tsx
new file mode 100644
index 0000000..846da4c
--- /dev/null
+++ b/src/components/profile/ApiTokensSection.tsx
@@ -0,0 +1,328 @@
+/**
+ * Component: API Tokens Section (Profile Page)
+ * Documentation: documentation/backend/services/api-tokens.md
+ */
+
+'use client';
+
+import { useState, useEffect, useCallback } from 'react';
+import { fetchWithAuth } from '@/lib/utils/api';
+
+interface ApiToken {
+ id: string;
+ name: string;
+ tokenPrefix: string;
+ role: string;
+ lastUsedAt: string | null;
+ expiresAt: string | null;
+ createdAt: string;
+}
+
+export function ApiTokensSection() {
+ const [tokens, setTokens] = useState([]);
+ const [loading, setLoading] = useState(true);
+ const [creating, setCreating] = useState(false);
+ const [newTokenName, setNewTokenName] = useState('');
+ const [newTokenExpiry, setNewTokenExpiry] = useState('never');
+ const [showCreateForm, setShowCreateForm] = useState(false);
+ const [createdToken, setCreatedToken] = useState(null);
+ const [copied, setCopied] = useState(false);
+ const [error, setError] = useState(null);
+ const [deletingId, setDeletingId] = useState(null);
+
+ const fetchTokens = useCallback(async () => {
+ try {
+ const response = await fetchWithAuth('/api/user/api-tokens');
+ if (response.ok) {
+ const data = await response.json();
+ setTokens(data.tokens);
+ }
+ } catch {
+ setError('Failed to load API tokens');
+ } finally {
+ setLoading(false);
+ }
+ }, []);
+
+ useEffect(() => {
+ fetchTokens();
+ }, [fetchTokens]);
+
+ const handleCreate = async () => {
+ if (!newTokenName.trim()) {
+ setError('Token name is required');
+ return;
+ }
+
+ setCreating(true);
+ setError(null);
+
+ try {
+ let expiresAt: string | null = null;
+ if (newTokenExpiry !== 'never') {
+ const date = new Date();
+ switch (newTokenExpiry) {
+ case '30d': date.setDate(date.getDate() + 30); break;
+ case '90d': date.setDate(date.getDate() + 90); break;
+ case '1y': date.setFullYear(date.getFullYear() + 1); break;
+ }
+ expiresAt = date.toISOString();
+ }
+
+ const response = await fetchWithAuth('/api/user/api-tokens', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ name: newTokenName.trim(), expiresAt }),
+ });
+
+ if (response.ok) {
+ const data = await response.json();
+ setCreatedToken(data.fullToken);
+ setNewTokenName('');
+ setNewTokenExpiry('never');
+ setShowCreateForm(false);
+ await fetchTokens();
+ } else {
+ const data = await response.json();
+ setError(data.error || 'Failed to create token');
+ }
+ } catch {
+ setError('Failed to create token');
+ } finally {
+ setCreating(false);
+ }
+ };
+
+ const handleDelete = async (id: string) => {
+ setDeletingId(id);
+ setError(null);
+
+ try {
+ const response = await fetchWithAuth(`/api/user/api-tokens/${id}`, {
+ method: 'DELETE',
+ });
+
+ if (response.ok) {
+ setTokens(tokens.filter((t) => t.id !== id));
+ } else {
+ setError('Failed to revoke token');
+ }
+ } catch {
+ setError('Failed to revoke token');
+ } finally {
+ setDeletingId(null);
+ }
+ };
+
+ const handleCopy = async () => {
+ if (createdToken) {
+ await navigator.clipboard.writeText(createdToken);
+ setCopied(true);
+ setTimeout(() => setCopied(false), 2000);
+ }
+ };
+
+ const formatDate = (dateStr: string | null) => {
+ if (!dateStr) return 'Never';
+ return new Date(dateStr).toLocaleDateString(undefined, {
+ year: 'numeric',
+ month: 'short',
+ day: 'numeric',
+ hour: '2-digit',
+ minute: '2-digit',
+ });
+ };
+
+ return (
+
+
+
+
+ API Tokens
+
+
+ Create personal API tokens for programmatic access to the API.
+
+
+
+
+
+
+ {/* Error display */}
+ {error && (
+
+ {error}
+
+ )}
+
+ {/* Newly created token banner */}
+ {createdToken && (
+
+
+
+
+
+ Token created successfully! Copy it now — it won't be shown again.
+
+
+
+ {createdToken}
+
+
+ {copied ? 'Copied!' : 'Copy'}
+
+
+
+
setCreatedToken(null)}
+ className="flex-shrink-0 text-green-600 dark:text-green-400 hover:text-green-800 dark:hover:text-green-200"
+ >
+
+
+
+
+ )}
+
+ {/* Create token form */}
+ {showCreateForm ? (
+
+
Create New Token
+
+
+
+ setNewTokenName(e.target.value)}
+ placeholder="e.g., Home Assistant, Webhook"
+ className="w-full rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 px-3 py-2 text-sm text-gray-900 dark:text-gray-100 focus:border-blue-500 focus:ring-1 focus:ring-blue-500"
+ onKeyDown={(e) => e.key === 'Enter' && handleCreate()}
+ />
+
+
+
+
+
+
+
+
+ {creating ? 'Creating...' : 'Create Token'}
+
+ { setShowCreateForm(false); setNewTokenName(''); setNewTokenExpiry('never'); }}
+ className="px-4 py-2 text-sm font-medium rounded-lg bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 text-gray-900 dark:text-gray-100 transition-colors"
+ >
+ Cancel
+
+
+
+ ) : (
+
setShowCreateForm(true)}
+ className="px-4 py-2 text-sm font-medium rounded-lg bg-blue-600 hover:bg-blue-700 text-white transition-colors"
+ >
+ Create New Token
+
+ )}
+
+ {/* Token list */}
+ {loading ? (
+
+ ) : tokens.length === 0 ? (
+
+
+
No API tokens yet
+
Create a token to enable programmatic API access
+
+ ) : (
+
+
+
+
+ | Name |
+ Token |
+ Last Used |
+ Expires |
+ Actions |
+
+
+
+ {tokens.map((token) => (
+
+ | {token.name} |
+
+
+ {token.tokenPrefix}...
+
+ |
+ {formatDate(token.lastUsedAt)} |
+
+ {token.expiresAt ? (
+
+ {formatDate(token.expiresAt)}
+ {new Date(token.expiresAt) < new Date() && ' (expired)'}
+
+ ) : (
+ 'Never'
+ )}
+ |
+
+ handleDelete(token.id)}
+ disabled={deletingId === token.id}
+ className="px-3 py-1 text-xs font-medium rounded-lg bg-red-100 dark:bg-red-900/30 hover:bg-red-200 dark:hover:bg-red-900/50 text-red-700 dark:text-red-300 transition-colors disabled:opacity-50"
+ >
+ {deletingId === token.id ? 'Revoking...' : 'Revoke'}
+
+ |
+
+ ))}
+
+
+
+ )}
+
+ {/* Usage instructions */}
+
+
Usage
+
+ Include the token in the Authorization header:
+
+
+{`curl -H "Authorization: Bearer rmab_your_token_here" \\
+ ${typeof window !== 'undefined' ? window.location.origin : 'https://your-instance'}/api/requests`}
+
+
+
+
+
+ );
+}
diff --git a/src/lib/middleware/auth.ts b/src/lib/middleware/auth.ts
index ce44d33..c1e7f98 100644
--- a/src/lib/middleware/auth.ts
+++ b/src/lib/middleware/auth.ts
@@ -4,12 +4,15 @@
*/
import { NextRequest, NextResponse } from 'next/server';
+import crypto from 'crypto';
import { verifyAccessToken, TokenPayload } from '../utils/jwt';
import { prisma } from '../db';
import { RMABLogger } from '../utils/logger';
const logger = RMABLogger.create('Auth');
+const API_TOKEN_PREFIX = 'rmab_';
+
export interface AuthenticatedRequest extends NextRequest {
user?: TokenPayload & { id: string };
}
@@ -32,9 +35,66 @@ function extractToken(request: NextRequest): string | null {
return parts[1];
}
+/**
+ * Authenticate via static API token (rmab_ prefix).
+ * Returns a synthetic TokenPayload if valid, null otherwise.
+ * Updates lastUsedAt asynchronously.
+ */
+async function authenticateApiToken(token: string): Promise<(TokenPayload & { id: string }) | null> {
+ const tokenHash = crypto.createHash('sha256').update(token).digest('hex');
+
+ const apiToken = await prisma.apiToken.findUnique({
+ where: { tokenHash },
+ include: {
+ tokenUser: {
+ select: {
+ id: true,
+ plexId: true,
+ plexUsername: true,
+ role: true,
+ deletedAt: true,
+ },
+ },
+ },
+ });
+
+ if (!apiToken) return null;
+
+ // Check expiration
+ if (apiToken.expiresAt && apiToken.expiresAt < new Date()) {
+ logger.warn('API token expired', { tokenPrefix: apiToken.tokenPrefix });
+ return null;
+ }
+
+ // Reject tokens for soft-deleted users
+ const user = apiToken.tokenUser;
+ if (!user || user.deletedAt) {
+ logger.warn('API token used by deleted or missing user', {
+ tokenPrefix: apiToken.tokenPrefix,
+ userId: user?.id,
+ });
+ return null;
+ }
+
+ // Update lastUsedAt (fire-and-forget)
+ prisma.apiToken.update({
+ where: { id: apiToken.id },
+ data: { lastUsedAt: new Date() },
+ }).catch(() => {});
+
+ // Use the token's target user (userId), not the creator (createdById)
+ return {
+ sub: user.id,
+ id: user.id,
+ plexId: user.plexId,
+ username: user.plexUsername,
+ role: apiToken.role,
+ };
+}
+
/**
* Middleware: Require authentication
- * Verifies JWT token and adds user to request
+ * Verifies JWT token or static API token and adds user to request
*/
export async function requireAuth(
request: NextRequest,
@@ -53,6 +113,26 @@ export async function requireAuth(
);
}
+ // Check if this is a static API token
+ if (token.startsWith(API_TOKEN_PREFIX)) {
+ const apiUser = await authenticateApiToken(token);
+ if (!apiUser) {
+ logger.error('API token authentication failed');
+ return NextResponse.json(
+ {
+ error: 'Unauthorized',
+ message: 'Invalid or expired API token',
+ },
+ { status: 401 }
+ );
+ }
+
+ const authenticatedRequest = request as AuthenticatedRequest;
+ authenticatedRequest.user = apiUser;
+ return handler(authenticatedRequest);
+ }
+
+ // Fall back to JWT verification
const payload = verifyAccessToken(token);
if (!payload) {
@@ -69,9 +149,13 @@ export async function requireAuth(
// Verify user still exists in database
const user = await prisma.user.findUnique({
where: { id: payload.sub },
+ select: {
+ id: true,
+ deletedAt: true,
+ },
});
- if (!user) {
+ if (!user || user.deletedAt) {
logger.error('User not found in database', { userId: payload.sub });
return NextResponse.json(
{
diff --git a/src/lib/utils/apiTokenRateLimit.ts b/src/lib/utils/apiTokenRateLimit.ts
new file mode 100644
index 0000000..9d01e2c
--- /dev/null
+++ b/src/lib/utils/apiTokenRateLimit.ts
@@ -0,0 +1,42 @@
+type Bucket = {
+ count: number;
+ resetAt: number;
+};
+
+type RateLimitResult = {
+ allowed: boolean;
+ retryAfterSeconds: number;
+};
+
+const buckets = new Map();
+
+function checkRateLimit(key: string, maxRequests: number, windowMs: number): RateLimitResult {
+ const now = Date.now();
+ const current = buckets.get(key);
+
+ if (!current || now >= current.resetAt) {
+ buckets.set(key, { count: 1, resetAt: now + windowMs });
+ return { allowed: true, retryAfterSeconds: Math.ceil(windowMs / 1000) };
+ }
+
+ if (current.count >= maxRequests) {
+ return {
+ allowed: false,
+ retryAfterSeconds: Math.max(1, Math.ceil((current.resetAt - now) / 1000)),
+ };
+ }
+
+ current.count += 1;
+ return {
+ allowed: true,
+ retryAfterSeconds: Math.max(1, Math.ceil((current.resetAt - now) / 1000)),
+ };
+}
+
+export function checkApiTokenCreateRateLimit(actorId: string): RateLimitResult {
+ return checkRateLimit(`api-token-create:${actorId}`, 10, 60 * 1000);
+}
+
+export function checkApiTokenRevokeRateLimit(actorId: string): RateLimitResult {
+ return checkRateLimit(`api-token-revoke:${actorId}`, 20, 60 * 1000);
+}
diff --git a/tests/helpers/prisma.ts b/tests/helpers/prisma.ts
index e52b73b..8b6debb 100644
--- a/tests/helpers/prisma.ts
+++ b/tests/helpers/prisma.ts
@@ -47,6 +47,7 @@ export const createPrismaMock = () => ({
bookDateSwipe: createModelMock(),
goodreadsShelf: createModelMock(),
goodreadsBookMapping: createModelMock(),
+ apiToken: createModelMock(),
work: createModelMock(),
workAsin: createModelMock(),
watchedSeries: createModelMock(),
diff --git a/tests/middleware/auth.middleware.test.ts b/tests/middleware/auth.middleware.test.ts
index e6f9436..4f3eee2 100644
--- a/tests/middleware/auth.middleware.test.ts
+++ b/tests/middleware/auth.middleware.test.ts
@@ -3,9 +3,11 @@
* Documentation: documentation/backend/services/auth.md
*/
+/* eslint-disable @typescript-eslint/no-explicit-any */
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { NextResponse } from 'next/server';
import { createPrismaMock } from '../helpers/prisma';
+import crypto from 'crypto';
const prismaMock = createPrismaMock();
const verifyAccessTokenMock = vi.fn();
@@ -29,6 +31,11 @@ const makeRequest = (authHeader?: string) => ({
},
});
+// Helper to create a valid API token hash for testing
+const createTestApiToken = (token: string) => {
+ return crypto.createHash('sha256').update(token).digest('hex');
+};
+
describe('auth middleware', () => {
beforeEach(() => {
vi.clearAllMocks();
@@ -159,6 +166,98 @@ describe('auth middleware', () => {
expect(result).toBe(true);
});
+ it('rejects JWT tokens for soft-deleted users', async () => {
+ verifyAccessTokenMock.mockReturnValue({
+ sub: 'user-1',
+ plexId: 'plex-1',
+ username: 'user',
+ role: 'user',
+ iat: 1,
+ exp: 2,
+ });
+ prismaMock.user.findUnique.mockResolvedValue({
+ id: 'user-1',
+ deletedAt: new Date(),
+ });
+ const { requireAuth } = await import('@/lib/middleware/auth');
+
+ const response = await requireAuth(makeRequest('Bearer token') as any, vi.fn());
+ const payload = await response.json();
+
+ expect(response.status).toBe(401);
+ expect(payload.message).toMatch(/user not found/i);
+ });
+
+ describe('API token authentication', () => {
+ const testToken = 'rmab_test1234567890abcdef';
+ const testTokenHash = createTestApiToken(testToken);
+
+ it('rejects API tokens for soft-deleted users', async () => {
+ prismaMock.apiToken.findUnique.mockResolvedValue({
+ id: 'token-1',
+ tokenHash: testTokenHash,
+ role: 'user',
+ expiresAt: null,
+ tokenUser: {
+ id: 'user-1',
+ plexUsername: 'deleteduser',
+ role: 'user',
+ deletedAt: new Date(),
+ },
+ });
+ const { requireAuth } = await import('@/lib/middleware/auth');
+
+ const response = await requireAuth(makeRequest(`Bearer ${testToken}`) as any, vi.fn());
+ const payload = await response.json();
+
+ expect(response.status).toBe(401);
+ expect(payload.message).toMatch(/invalid.*expired/i);
+ });
+
+ it('rejects API tokens for missing users', async () => {
+ prismaMock.apiToken.findUnique.mockResolvedValue({
+ id: 'token-1',
+ tokenHash: testTokenHash,
+ role: 'user',
+ expiresAt: null,
+ tokenUser: null,
+ });
+ const { requireAuth } = await import('@/lib/middleware/auth');
+
+ const response = await requireAuth(makeRequest(`Bearer ${testToken}`) as any, vi.fn());
+ const payload = await response.json();
+
+ expect(response.status).toBe(401);
+ expect(payload.message).toMatch(/invalid.*expired/i);
+ });
+
+ it('accepts valid API tokens for active users', async () => {
+ prismaMock.apiToken.findUnique.mockResolvedValue({
+ id: 'token-1',
+ tokenHash: testTokenHash,
+ role: 'user',
+ expiresAt: null,
+ tokenUser: {
+ id: 'user-1',
+ plexUsername: 'activeuser',
+ role: 'user',
+ deletedAt: null,
+ },
+ });
+ prismaMock.apiToken.update.mockResolvedValue({});
+ const { requireAuth } = await import('@/lib/middleware/auth');
+
+ const handler = vi.fn(async (req: any) =>
+ NextResponse.json({ ok: true, userId: req.user?.id })
+ );
+ const response = await requireAuth(makeRequest(`Bearer ${testToken}`) as any, handler);
+ const payload = await response.json();
+
+ expect(handler).toHaveBeenCalled();
+ expect(payload.userId).toBe('user-1');
+ });
+ });
+
it('returns current user from token', async () => {
verifyAccessTokenMock.mockReturnValue({
sub: 'user-1',
diff --git a/tests/utils/apiTokenRateLimit.test.ts b/tests/utils/apiTokenRateLimit.test.ts
new file mode 100644
index 0000000..a80c9d8
--- /dev/null
+++ b/tests/utils/apiTokenRateLimit.test.ts
@@ -0,0 +1,122 @@
+/**
+ * Component: API Token Rate Limit Tests
+ * Documentation: documentation/backend/services/auth.md
+ */
+
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+import {
+ checkApiTokenCreateRateLimit,
+ checkApiTokenRevokeRateLimit,
+} from '@/lib/utils/apiTokenRateLimit';
+
+describe('API Token Rate Limiting', () => {
+ beforeEach(() => {
+ vi.useFakeTimers();
+ });
+
+ afterEach(() => {
+ vi.useRealTimers();
+ });
+
+ describe('checkApiTokenCreateRateLimit', () => {
+ it('allows requests under the limit', () => {
+ const actorId = 'user-create-1';
+
+ for (let i = 0; i < 10; i++) {
+ const result = checkApiTokenCreateRateLimit(actorId);
+ expect(result.allowed).toBe(true);
+ }
+ });
+
+ it('blocks requests over the limit (10/min)', () => {
+ const actorId = 'user-create-2';
+
+ // Use up the limit
+ for (let i = 0; i < 10; i++) {
+ checkApiTokenCreateRateLimit(actorId);
+ }
+
+ // 11th request should be blocked
+ const result = checkApiTokenCreateRateLimit(actorId);
+ expect(result.allowed).toBe(false);
+ expect(result.retryAfterSeconds).toBeGreaterThan(0);
+ });
+
+ it('resets after the window expires', () => {
+ const actorId = 'user-create-3';
+
+ // Use up the limit
+ for (let i = 0; i < 10; i++) {
+ checkApiTokenCreateRateLimit(actorId);
+ }
+
+ // Should be blocked
+ expect(checkApiTokenCreateRateLimit(actorId).allowed).toBe(false);
+
+ // Advance time past the window (60 seconds)
+ vi.advanceTimersByTime(61 * 1000);
+
+ // Should be allowed again
+ expect(checkApiTokenCreateRateLimit(actorId).allowed).toBe(true);
+ });
+
+ it('tracks different actors separately', () => {
+ const actor1 = 'user-create-4';
+ const actor2 = 'user-create-5';
+
+ // Use up actor1's limit
+ for (let i = 0; i < 10; i++) {
+ checkApiTokenCreateRateLimit(actor1);
+ }
+
+ // actor1 should be blocked
+ expect(checkApiTokenCreateRateLimit(actor1).allowed).toBe(false);
+
+ // actor2 should still be allowed
+ expect(checkApiTokenCreateRateLimit(actor2).allowed).toBe(true);
+ });
+ });
+
+ describe('checkApiTokenRevokeRateLimit', () => {
+ it('allows requests under the limit', () => {
+ const actorId = 'user-revoke-1';
+
+ for (let i = 0; i < 20; i++) {
+ const result = checkApiTokenRevokeRateLimit(actorId);
+ expect(result.allowed).toBe(true);
+ }
+ });
+
+ it('blocks requests over the limit (20/min)', () => {
+ const actorId = 'user-revoke-2';
+
+ // Use up the limit
+ for (let i = 0; i < 20; i++) {
+ checkApiTokenRevokeRateLimit(actorId);
+ }
+
+ // 21st request should be blocked
+ const result = checkApiTokenRevokeRateLimit(actorId);
+ expect(result.allowed).toBe(false);
+ expect(result.retryAfterSeconds).toBeGreaterThan(0);
+ });
+
+ it('returns correct retryAfterSeconds', () => {
+ const actorId = 'user-revoke-3';
+
+ // Use up the limit
+ for (let i = 0; i < 20; i++) {
+ checkApiTokenRevokeRateLimit(actorId);
+ }
+
+ // Advance 30 seconds into the window
+ vi.advanceTimersByTime(30 * 1000);
+
+ const result = checkApiTokenRevokeRateLimit(actorId);
+ expect(result.allowed).toBe(false);
+ // Should have ~30 seconds left
+ expect(result.retryAfterSeconds).toBeLessThanOrEqual(30);
+ expect(result.retryAfterSeconds).toBeGreaterThan(0);
+ });
+ });
+});