diff --git a/packages/web/src/Routes.jsx b/packages/web/src/Routes.jsx
index 8be85187a..96316f832 100644
--- a/packages/web/src/Routes.jsx
+++ b/packages/web/src/Routes.jsx
@@ -16,6 +16,7 @@ import SettingsPage from '@components/profile-ui/SettingsPage.jsx';
import BillingPage from '@components/billing/BillingPage.jsx';
import NotFoundPage from '@components/NotFoundPage.jsx';
import { AdminDashboard } from '@components/admin-ui/index.js';
+import StorageManagement from '@components/admin-ui/StorageManagement.jsx';
import { BASEPATH } from '@config/api.js';
import ProtectedGuard from '@/components/auth-ui/ProtectedGuard.jsx';
@@ -40,6 +41,7 @@ export default function AppRoutes() {
+
diff --git a/packages/web/src/components/admin-ui/AdminDashboard.jsx b/packages/web/src/components/admin-ui/AdminDashboard.jsx
index 6e8785288..7cbf06f37 100644
--- a/packages/web/src/components/admin-ui/AdminDashboard.jsx
+++ b/packages/web/src/components/admin-ui/AdminDashboard.jsx
@@ -3,7 +3,7 @@
*/
import { createSignal, createResource, Show, onMount } from 'solid-js';
-import { useNavigate } from '@solidjs/router';
+import { useNavigate, A } from '@solidjs/router';
import {
FiUsers,
FiFolder,
@@ -14,6 +14,7 @@ import {
FiChevronRight,
FiShield,
FiAlertCircle,
+ FiDatabase,
} from 'solid-icons/fi';
import {
isAdmin,
@@ -94,6 +95,13 @@ export default function AdminDashboard() {
Manage users and monitor activity
+
+
+ Storage Management
+
{/* Stats Grid */}
diff --git a/packages/web/src/components/admin-ui/StorageManagement.jsx b/packages/web/src/components/admin-ui/StorageManagement.jsx
new file mode 100644
index 000000000..840c3ed5e
--- /dev/null
+++ b/packages/web/src/components/admin-ui/StorageManagement.jsx
@@ -0,0 +1,471 @@
+/**
+ * Storage Management - Admin interface for managing R2 documents
+ */
+
+import { createSignal, createResource, Show, For, onMount } from 'solid-js';
+import { useNavigate } from '@solidjs/router';
+import {
+ FiTrash2,
+ FiSearch,
+ FiChevronLeft,
+ FiChevronRight,
+ FiDatabase,
+ FiAlertCircle,
+ FiCheckSquare,
+ FiSquare,
+} from 'solid-icons/fi';
+import {
+ isAdmin,
+ isAdminChecked,
+ checkAdminStatus,
+ fetchStorageDocuments,
+ deleteStorageDocuments,
+} from '@/stores/adminStore.js';
+import { Dialog, showToast } from '@corates/ui';
+
+function formatFileSize(bytes) {
+ if (!bytes) return '0 B';
+ if (bytes < 1024) return `${bytes} B`;
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
+ if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
+ return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GB`;
+}
+
+function formatDate(timestamp) {
+ if (!timestamp) return '-';
+ return new Date(timestamp).toLocaleDateString('en-US', {
+ month: 'short',
+ day: 'numeric',
+ year: 'numeric',
+ hour: '2-digit',
+ minute: '2-digit',
+ });
+}
+
+export default function StorageManagement() {
+ const navigate = useNavigate();
+ const [search, setSearch] = createSignal('');
+ const [prefix, setPrefix] = createSignal('');
+ const [cursor, setCursor] = createSignal(null);
+ const [cursorHistory, setCursorHistory] = createSignal([]);
+ const [debouncedSearch, setDebouncedSearch] = createSignal('');
+ const [selectedKeys, setSelectedKeys] = createSignal(new Set());
+ const [deleteDialog, setDeleteDialog] = createSignal(null);
+ const [loading, setLoading] = createSignal(false);
+
+ const limit = 50;
+
+ // Check admin status on mount
+ onMount(async () => {
+ await checkAdminStatus();
+ if (!isAdmin()) {
+ navigate('/dashboard');
+ }
+ });
+
+ // Fetch documents with cursor-based pagination
+ const [documentsData, { refetch: refetchDocuments }] = createResource(
+ () => ({ cursor: cursor(), limit, prefix: prefix(), search: debouncedSearch() }),
+ fetchStorageDocuments,
+ );
+
+ // Debounce search
+ let searchTimeout;
+ const handleSearchInput = e => {
+ setSearch(e.target.value);
+ clearTimeout(searchTimeout);
+ searchTimeout = setTimeout(() => {
+ setDebouncedSearch(e.target.value);
+ setCursor(null);
+ setCursorHistory([]);
+ setSelectedKeys(new Set());
+ }, 300);
+ };
+
+ const handlePrefixChange = e => {
+ setPrefix(e.target.value);
+ setCursor(null);
+ setCursorHistory([]);
+ setSelectedKeys(new Set());
+ };
+
+ const handleNextPage = () => {
+ const data = documentsData();
+ if (data?.nextCursor) {
+ setCursorHistory(prev => [...prev, cursor()]);
+ setCursor(data.nextCursor);
+ }
+ };
+
+ const handlePrevPage = () => {
+ const history = cursorHistory();
+ if (history.length > 0) {
+ const prevCursor = history[history.length - 1];
+ setCursorHistory(prev => prev.slice(0, -1));
+ setCursor(prevCursor);
+ } else {
+ setCursor(null);
+ }
+ };
+
+ const toggleSelect = key => {
+ const newSet = new Set(selectedKeys());
+ if (newSet.has(key)) {
+ newSet.delete(key);
+ } else {
+ newSet.add(key);
+ }
+ setSelectedKeys(newSet);
+ };
+
+ const toggleSelectAll = () => {
+ const docs = documentsData()?.documents || [];
+ const currentKeys = new Set(docs.map(d => d.key));
+ const selected = selectedKeys();
+
+ // Check if all current page documents are selected
+ const allCurrentPageSelected = docs.length > 0 && docs.every(doc => selected.has(doc.key));
+
+ if (allCurrentPageSelected) {
+ // Deselect only current page documents, keep other selections
+ const newSet = new Set(selected);
+ currentKeys.forEach(key => newSet.delete(key));
+ setSelectedKeys(newSet);
+ } else {
+ // Select all current page documents, keep other selections
+ const newSet = new Set(selected);
+ currentKeys.forEach(key => newSet.add(key));
+ setSelectedKeys(newSet);
+ }
+ };
+
+ const handleDelete = async () => {
+ const keysToDelete = deleteDialog();
+ if (!keysToDelete || keysToDelete.length === 0) return;
+
+ setLoading(true);
+ try {
+ const result = await deleteStorageDocuments(keysToDelete);
+ setDeleteDialog(null);
+ setSelectedKeys(new Set());
+
+ if (result.failed > 0) {
+ showToast.warning(
+ 'Partial Delete Success',
+ `Deleted ${result.deleted} documents. ${result.failed} failed.`,
+ );
+ } else {
+ showToast.success('Documents Deleted', `Successfully deleted ${result.deleted} documents.`);
+ }
+
+ refetchDocuments();
+ } catch (error) {
+ showToast.error('Delete Failed', error.message || 'Failed to delete documents');
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ const handleBulkDelete = () => {
+ const keys = Array.from(selectedKeys());
+ if (keys.length === 0) return;
+ setDeleteDialog(keys);
+ };
+
+ const handleSingleDelete = key => {
+ setDeleteDialog([key]);
+ };
+
+ return (
+
+
+
+ }
+ >
+
+
+ Access Denied
+ You do not have admin privileges.
+
+ }
+ >
+
+ {/* Header */}
+
+
+
navigate('/admin')}
+ class='text-gray-400 transition-colors hover:text-gray-700'
+ title='Back to Admin Dashboard'
+ >
+
+
+
+
+
+
+
Storage Management
+
Manage documents in R2 storage
+
+
+
+
+ {/* Info Banner */}
+
+
+ Note: This dashboard shows all PDFs in R2 storage. PDFs marked as
+ "Orphaned" have a project ID that no longer exists in the database (e.g., from failed
+ cleanup when projects were deleted). You can safely delete orphaned PDFs to free up
+ storage space.
+
+
+
+ {/* Filters and Search */}
+
+
+ {/* Bulk Actions Bar */}
+
0}>
+
+
+ {selectedKeys().size} document{selectedKeys().size === 1 ? '' : 's'} selected
+
+
+ setSelectedKeys(new Set())}
+ class='rounded-lg bg-white px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50'
+ >
+ Clear Selection
+
+
+ Delete Selected
+
+
+
+
+
+ {/* Documents Table */}
+
+
+
+
+
+
+
+ {
+ const docs = documentsData()?.documents || [];
+ const selected = selectedKeys();
+ return docs.length > 0 && docs.every(doc => selected.has(doc.key));
+ })()}
+ fallback={ }
+ >
+
+
+
+
+
+ File Name
+
+
+ Size
+
+
+ Project ID
+
+
+ Study ID
+
+
+ Uploaded
+
+
+ Actions
+
+
+
+
+
+
+
+
+
+ }
+ >
+
+
+ No documents found
+
+
+ }
+ >
+ {doc => (
+
+
+ toggleSelect(doc.key)}
+ class='text-gray-400 hover:text-gray-600'
+ >
+ }
+ >
+
+
+
+
+
+
+ {doc.fileName}
+
+
+ Orphaned
+
+
+
+
+
+ {formatFileSize(doc.size)}
+
+
+ {doc.projectId}
+
+
+ {doc.studyId}
+
+
+ {formatDate(doc.uploaded)}
+
+
+ handleSingleDelete(doc.key)}
+ class='rounded-lg p-2 text-red-600 hover:bg-red-50'
+ title='Delete'
+ >
+
+
+
+
+ )}
+
+
+
+
+
+
+ {/* Pagination */}
+
+
+
+
+ Showing {documentsData()?.documents?.length || 0} document
+ {documentsData()?.documents?.length === 1 ? '' : 's'}
+
+
+
+ Results truncated after processing 10,000 objects. Use pagination to continue.
+
+
+
+
+
+
+
+
+ {cursorHistory().length + 1}
+ {documentsData()?.nextCursor ? ' →' : ''}
+
+
+
+
+
+
+
+
+
+
+ {/* Delete Confirmation Dialog */}
+ !open && setDeleteDialog(null)}
+ title='Delete Documents'
+ role='alertdialog'
+ >
+
+
+ Are you sure you want to delete {deleteDialog()?.length || 0} document
+ {deleteDialog()?.length === 1 ? '' : 's'}? This action cannot be undone.
+
+
+ setDeleteDialog(null)}
+ disabled={loading()}
+ class='rounded-lg bg-gray-100 px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-200 disabled:opacity-50'
+ >
+ Cancel
+
+
+ {loading() ? 'Deleting...' : 'Delete'}
+
+
+
+
+
+
+ );
+}
diff --git a/packages/web/src/components/project-ui/overview-tab/OverviewTab.jsx b/packages/web/src/components/project-ui/overview-tab/OverviewTab.jsx
index aa2094641..392f554e9 100644
--- a/packages/web/src/components/project-ui/overview-tab/OverviewTab.jsx
+++ b/packages/web/src/components/project-ui/overview-tab/OverviewTab.jsx
@@ -288,7 +288,7 @@ export default function OverviewTab() {
({}));
+ throw new Error(error.error || 'Failed to fetch storage documents');
+ }
+ return response.json();
+}
+
+/**
+ * Delete storage documents (bulk)
+ */
+async function deleteStorageDocuments(keys) {
+ if (!Array.isArray(keys) || keys.length === 0) {
+ throw new Error('Keys array is required');
+ }
+
+ const response = await fetch(`${API_BASE}/api/admin/storage/documents`, {
+ method: 'DELETE',
+ credentials: 'include',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ keys }),
+ });
+ if (!response.ok) {
+ const error = await response.json().catch(() => ({}));
+ throw new Error(error.error || 'Failed to delete storage documents');
+ }
+ return response.json();
+}
+
+/**
+ * Fetch storage statistics
+ */
+async function fetchStorageStats() {
+ const response = await fetch(`${API_BASE}/api/admin/storage/stats`, {
+ credentials: 'include',
+ });
+ if (!response.ok) {
+ const error = await response.json().catch(() => ({}));
+ throw new Error(error.error || 'Failed to fetch storage stats');
+ }
+ return response.json();
+}
+
export {
isAdmin,
isAdminChecked,
@@ -269,4 +325,7 @@ export {
deleteUser,
grantAccess,
revokeAccess,
+ fetchStorageDocuments,
+ deleteStorageDocuments,
+ fetchStorageStats,
};
diff --git a/packages/workers/scripts/generate-openapi.mjs b/packages/workers/scripts/generate-openapi.mjs
index fddc7953a..f18d8753b 100644
--- a/packages/workers/scripts/generate-openapi.mjs
+++ b/packages/workers/scripts/generate-openapi.mjs
@@ -255,13 +255,15 @@ async function generate() {
{ file: 'src/routes/users.js', basePath: '/api/users' },
{ file: 'src/routes/pdfs.js', basePath: '/api/projects/{projectId}/studies/{studyId}/pdfs' },
{ file: 'src/routes/billing/index.js', basePath: '/api/billing' },
- { file: 'src/routes/admin.js', basePath: '/api/admin' },
+ { file: 'src/routes/admin/index.js', basePath: '/api/admin' },
{ file: 'src/routes/contact.js', basePath: '/api/contact' },
{ file: 'src/routes/avatars.js', basePath: '/api/users/avatar' },
{ file: 'src/routes/google-drive.js', basePath: '/api/google-drive' },
{ file: 'src/routes/account-merge.js', basePath: '/api/accounts/merge' },
{ file: 'src/routes/email.js', basePath: '/api/email' },
{ file: 'src/routes/database.js', basePath: '/api/db' },
+ { file: 'src/routes/admin/users.js', basePath: '/api/admin' },
+ { file: 'src/routes/admin/storage.js', basePath: '/api/admin' },
];
// Parse all route files
diff --git a/packages/workers/src/__tests__/admin.test.js b/packages/workers/src/__tests__/admin.test.js
index e5a01fe90..de9904381 100644
--- a/packages/workers/src/__tests__/admin.test.js
+++ b/packages/workers/src/__tests__/admin.test.js
@@ -264,7 +264,7 @@ async function fetchApp(path, init = {}) {
}
beforeAll(async () => {
- const { adminRoutes } = await import('../routes/admin.js');
+ const { adminRoutes } = await import('../routes/admin/index.js');
app = new Hono();
app.route('/api/admin', adminRoutes);
});
diff --git a/packages/workers/src/config/validation.js b/packages/workers/src/config/validation.js
index 0b8756b69..ecc12e812 100644
--- a/packages/workers/src/config/validation.js
+++ b/packages/workers/src/config/validation.js
@@ -90,6 +90,18 @@ export const userSchemas = {
return Math.min(Math.max(1, num), 20);
}),
}),
+ ban: z.object({
+ reason: z.string().optional(),
+ expiresAt: z
+ .union([
+ z
+ .string()
+ .datetime('expiresAt must be a valid ISO datetime string')
+ .transform(val => new Date(val)),
+ z.null(),
+ ])
+ .optional(),
+ }),
};
/**
@@ -133,6 +145,50 @@ export const subscriptionSchemas = {
}),
};
+/**
+ * Storage management schemas
+ */
+export const storageSchemas = {
+ listDocuments: z.object({
+ cursor: z
+ .string()
+ .optional()
+ .transform(val => (val ? val.trim() : undefined)),
+ limit: z
+ .string()
+ .optional()
+ .default('50')
+ .transform(val => parseInt(val, 10))
+ .pipe(
+ z
+ .number()
+ .int('Limit must be an integer')
+ .min(1, 'Limit must be at least 1')
+ .max(1000, 'Limit must be at most 1000'),
+ ),
+ prefix: z
+ .string()
+ .optional()
+ .transform(val => (val ? val.trim() : '')),
+ search: z
+ .string()
+ .optional()
+ .transform(val => (val ? val.trim().toLowerCase() : '')),
+ }),
+ deleteDocuments: z.object({
+ keys: z
+ .array(
+ z
+ .string()
+ .min(1, 'Key cannot be empty')
+ .refine(key => /^projects\/[^/]+\/studies\/[^/]+\/.+$/.test(key), {
+ message: 'Key must match pattern: projects/{projectId}/studies/{studyId}/{fileName}',
+ }),
+ )
+ .min(1, 'At least one key is required'),
+ }),
+};
+
/**
* Map Zod error to validation error code
* @param {object} issue - Zod issue object from error.issues array
diff --git a/packages/workers/src/index.js b/packages/workers/src/index.js
index 098067a99..1a88be753 100644
--- a/packages/workers/src/index.js
+++ b/packages/workers/src/index.js
@@ -24,7 +24,7 @@ import { emailRoutes } from './routes/email.js';
import { billingRoutes } from './routes/billing/index.js';
import { googleDriveRoutes } from './routes/google-drive.js';
import { avatarRoutes } from './routes/avatars.js';
-import { adminRoutes } from './routes/admin.js';
+import { adminRoutes } from './routes/admin/index.js';
import { accountMergeRoutes } from './routes/account-merge.js';
import { contactRoutes } from './routes/contact.js';
diff --git a/packages/workers/src/routes/admin/index.js b/packages/workers/src/routes/admin/index.js
new file mode 100644
index 000000000..0e48b7e4c
--- /dev/null
+++ b/packages/workers/src/routes/admin/index.js
@@ -0,0 +1,23 @@
+/**
+ * Admin routes for Hono
+ * Provides admin dashboard API endpoints
+ */
+
+import { Hono } from 'hono';
+import { requireAdmin } from '../../middleware/requireAdmin.js';
+import { requireTrustedOrigin } from '../../middleware/csrf.js';
+import { userRoutes } from './users.js';
+import { storageRoutes } from './storage.js';
+
+const adminRoutes = new Hono();
+
+// Apply admin middleware to all routes
+adminRoutes.use('*', requireAdmin);
+// CSRF guard for all state-changing admin routes
+adminRoutes.use('*', requireTrustedOrigin);
+
+// Mount route handlers
+adminRoutes.route('/', userRoutes);
+adminRoutes.route('/', storageRoutes);
+
+export { adminRoutes };
diff --git a/packages/workers/src/routes/admin/storage.js b/packages/workers/src/routes/admin/storage.js
new file mode 100644
index 000000000..55ff92bd9
--- /dev/null
+++ b/packages/workers/src/routes/admin/storage.js
@@ -0,0 +1,300 @@
+/**
+ * Admin storage management routes
+ * Handles R2 document listing, deletion, and statistics
+ */
+
+import { Hono } from 'hono';
+import { createDb } from '../../db/client.js';
+import { projects } from '../../db/schema.js';
+import { inArray } from 'drizzle-orm';
+import { createDomainError, SYSTEM_ERRORS } from '@corates/shared';
+import { storageSchemas, validateQueryParams, validateRequest } from '../../config/validation.js';
+
+const storageRoutes = new Hono();
+
+/**
+ * Parse project/study IDs from R2 key pattern: projects/{projectId}/studies/{studyId}/{fileName}
+ */
+function parseKey(key) {
+ const match = key.match(/^projects\/([^/]+)\/studies\/([^/]+)\/(.+)$/);
+ if (!match) {
+ return null;
+ }
+ return {
+ projectId: match[1],
+ studyId: match[2],
+ fileName: match[3],
+ };
+}
+
+/**
+ * GET /api/admin/storage/documents
+ * List documents with cursor-based pagination
+ * Query params:
+ * - cursor: optional cursor token from previous response to continue pagination
+ * - limit: results per page (default 50, max 1000)
+ * - prefix: filter by prefix (e.g., "projects/{projectId}/")
+ * - search: filter by file name (case-insensitive substring match)
+ *
+ * Returns:
+ * - documents: array of matching documents
+ * - nextCursor: cursor token for next page (if more results available)
+ * - truncated: true if processing was stopped due to cap (10k objects processed)
+ */
+storageRoutes.get(
+ '/storage/documents',
+ validateQueryParams(storageSchemas.listDocuments),
+ async c => {
+ try {
+ const { cursor: requestCursor, limit, prefix, search } = c.get('validatedQuery');
+
+ // Decode composite cursor: {r2Cursor: string | undefined, skipCount: number}
+ let r2Cursor = undefined;
+ let skipCount = 0;
+ if (requestCursor) {
+ try {
+ const decoded = JSON.parse(requestCursor);
+ r2Cursor = decoded.r2Cursor;
+ skipCount = Math.max(0, decoded.skipCount || 0);
+ // If r2Cursor is undefined, we're starting from beginning of R2, so reset skipCount
+ if (!r2Cursor) {
+ skipCount = 0;
+ }
+ } catch {
+ // Invalid cursor format, treat as first request
+ r2Cursor = requestCursor;
+ skipCount = 0;
+ }
+ }
+
+ const PROCESSING_CAP = 10000;
+ const matchingObjects = [];
+ let currentCursor = r2Cursor;
+ let objectsProcessed = 0;
+ let truncated = false;
+ let listedTruncated = false;
+
+ // Process batches until we have enough matching results (skipCount + limit) or hit the cap
+ while (matchingObjects.length < skipCount + limit && objectsProcessed < PROCESSING_CAP) {
+ const listOptions = {
+ limit: 1000,
+ prefix: prefix || undefined,
+ };
+ if (currentCursor) {
+ listOptions.cursor = currentCursor;
+ }
+
+ const listed = await c.env.PDF_BUCKET.list(listOptions);
+
+ if (listed.objects.length === 0) {
+ listedTruncated = false;
+ break;
+ }
+
+ objectsProcessed += listed.objects.length;
+
+ // Process the entire batch to avoid skipping objects when we hit the limit
+ for (const obj of listed.objects) {
+ const parsed = parseKey(obj.key);
+ if (!parsed) {
+ continue;
+ }
+
+ // Apply search filter if provided
+ if (search && !parsed.fileName.toLowerCase().includes(search)) {
+ continue;
+ }
+
+ matchingObjects.push({
+ key: obj.key,
+ fileName: parsed.fileName,
+ projectId: parsed.projectId,
+ studyId: parsed.studyId,
+ size: obj.size,
+ uploaded: obj.uploaded,
+ etag: obj.etag,
+ });
+ }
+
+ // Update cursor for next batch
+ if (listed.truncated) {
+ currentCursor = listed.cursor;
+ listedTruncated = true;
+ } else {
+ listedTruncated = false;
+ break;
+ }
+
+ // Stop if we hit the processing cap
+ if (objectsProcessed >= PROCESSING_CAP) {
+ truncated = true;
+ break;
+ }
+ }
+
+ // Guard against skipCount exceeding matchingObjects.length
+ skipCount = Math.min(skipCount, matchingObjects.length);
+
+ // Slice with offset to get the correct page
+ const paginatedObjects = matchingObjects.slice(skipCount, skipCount + limit);
+
+ // Check which projects exist in the database to identify orphaned PDFs
+ const uniqueProjectIds = [...new Set(paginatedObjects.map(doc => doc.projectId))];
+ let existingProjectIds = new Set();
+
+ // Only query database if there are project IDs to check
+ // inArray with empty array generates invalid SQL (WHERE id IN ())
+ if (uniqueProjectIds.length > 0) {
+ const db = createDb(c.env.DB);
+ const existingProjects = await db
+ .select({ id: projects.id })
+ .from(projects)
+ .where(inArray(projects.id, uniqueProjectIds));
+
+ existingProjectIds = new Set(existingProjects.map(p => p.id));
+ }
+
+ // Mark documents as orphaned if their project doesn't exist
+ const documentsWithOrphanStatus = paginatedObjects.map(doc => ({
+ ...doc,
+ orphaned: !existingProjectIds.has(doc.projectId),
+ }));
+
+ const response = {
+ documents: documentsWithOrphanStatus,
+ limit,
+ };
+
+ // Determine if there are more results available
+ // Return nextCursor only if:
+ // 1. There are more R2 objects to process (listedTruncated && currentCursor), OR
+ // 2. We hit the processing cap AND have a cursor to continue from
+ // Note: If R2 is exhausted (listedTruncated = false), we can't persist cached matches
+ // across requests, so we don't return a nextCursor even if we have cached matches
+ const hasMoreR2Objects = listedTruncated && currentCursor;
+ const canContinueAfterCap = truncated && currentCursor;
+
+ // Include nextCursor if there are more results available and we have a cursor to continue from
+ if (hasMoreR2Objects || canContinueAfterCap) {
+ const nextSkipCount = Math.max(0, skipCount + paginatedObjects.length);
+ const nextCursorData = {
+ r2Cursor: currentCursor,
+ skipCount: nextSkipCount,
+ };
+ response.nextCursor = JSON.stringify(nextCursorData);
+ }
+
+ // Include truncated flag if we hit the processing cap
+ if (truncated) {
+ response.truncated = true;
+ }
+
+ return c.json(response);
+ } catch (error) {
+ console.error('Error listing storage documents:', error);
+ const systemError = createDomainError(SYSTEM_ERRORS.INTERNAL_ERROR, {
+ operation: 'list_storage_documents',
+ originalError: error.message,
+ });
+ return c.json(systemError, systemError.statusCode);
+ }
+ },
+);
+
+/**
+ * DELETE /api/admin/storage/documents
+ * Bulk delete documents
+ * Body: { keys: string[] }
+ */
+storageRoutes.delete(
+ '/storage/documents',
+ validateRequest(storageSchemas.deleteDocuments),
+ async c => {
+ try {
+ const { keys } = c.get('validatedBody');
+
+ // Delete all keys in parallel
+ const deleteResults = await Promise.allSettled(keys.map(key => c.env.PDF_BUCKET.delete(key)));
+
+ const deleted = deleteResults.filter(r => r.status === 'fulfilled').length;
+ const failed = deleteResults.filter(r => r.status === 'rejected').length;
+
+ let errors;
+ if (failed > 0) {
+ errors = [];
+ deleteResults.forEach((result, i) => {
+ if (result.status === 'rejected') {
+ errors.push({
+ key: keys[i],
+ error: result.reason?.message || 'Unknown error',
+ });
+ }
+ });
+ }
+
+ return c.json({
+ deleted,
+ failed,
+ errors,
+ });
+ } catch (error) {
+ console.error('Error deleting storage documents:', error);
+ const systemError = createDomainError(SYSTEM_ERRORS.INTERNAL_ERROR, {
+ operation: 'delete_storage_documents',
+ originalError: error.message,
+ });
+ return c.json(systemError, systemError.statusCode);
+ }
+ },
+);
+
+/**
+ * GET /api/admin/storage/stats
+ * Get storage statistics
+ */
+storageRoutes.get('/storage/stats', async c => {
+ try {
+ let cursor = undefined;
+ let totalFiles = 0;
+ let totalSize = 0;
+ const filesByProject = {};
+
+ // Iterate through all objects to calculate stats
+ do {
+ const listed = await c.env.PDF_BUCKET.list({
+ limit: 1000,
+ cursor,
+ });
+
+ for (const obj of listed.objects) {
+ totalFiles++;
+ totalSize += obj.size;
+
+ const parsed = parseKey(obj.key);
+ if (parsed) {
+ filesByProject[parsed.projectId] = (filesByProject[parsed.projectId] || 0) + 1;
+ }
+ }
+
+ cursor = listed.truncated ? listed.cursor : undefined;
+ } while (cursor);
+
+ return c.json({
+ totalFiles,
+ totalSize,
+ filesByProject: Object.entries(filesByProject).map(([projectId, count]) => ({
+ projectId,
+ count,
+ })),
+ });
+ } catch (error) {
+ console.error('Error fetching storage stats:', error);
+ const systemError = createDomainError(SYSTEM_ERRORS.INTERNAL_ERROR, {
+ operation: 'fetch_storage_stats',
+ originalError: error.message,
+ });
+ return c.json(systemError, systemError.statusCode);
+ }
+});
+
+export { storageRoutes };
diff --git a/packages/workers/src/routes/admin.js b/packages/workers/src/routes/admin/users.js
similarity index 92%
rename from packages/workers/src/routes/admin.js
rename to packages/workers/src/routes/admin/users.js
index f37512fa8..7b4974e94 100644
--- a/packages/workers/src/routes/admin.js
+++ b/packages/workers/src/routes/admin/users.js
@@ -1,10 +1,10 @@
/**
- * Admin routes for Hono
- * Provides admin dashboard API endpoints for user management
+ * Admin user management routes
+ * Handles user CRUD operations, bans, impersonation, subscriptions, and stats
*/
import { Hono } from 'hono';
-import { createDb } from '../db/client.js';
+import { createDb } from '../../db/client.js';
import {
user,
session,
@@ -14,11 +14,9 @@ import {
verification,
twoFactor,
subscriptions,
-} from '../db/schema.js';
+} from '../../db/schema.js';
import { eq, desc, sql, like, or, count } from 'drizzle-orm';
-import { requireAdmin } from '../middleware/requireAdmin.js';
-import { requireTrustedOrigin } from '../middleware/csrf.js';
-import { createAuth } from '../auth/config.js';
+import { createAuth } from '../../auth/config.js';
import {
createDomainError,
createValidationError,
@@ -26,21 +24,17 @@ import {
USER_ERRORS,
SYSTEM_ERRORS,
} from '@corates/shared';
-import { upsertSubscription, getSubscriptionByUserId } from '../db/subscriptions.js';
+import { upsertSubscription, getSubscriptionByUserId } from '../../db/subscriptions.js';
import { getPlan } from '@corates/shared/plans';
-import { subscriptionSchemas, validateRequest } from '../config/validation.js';
-const adminRoutes = new Hono();
+import { subscriptionSchemas, userSchemas, validateRequest } from '../../config/validation.js';
-// Apply admin middleware to all routes
-adminRoutes.use('*', requireAdmin);
-// CSRF guard for all state-changing admin routes
-adminRoutes.use('*', requireTrustedOrigin);
+const userRoutes = new Hono();
/**
* GET /api/admin/stats
* Get dashboard statistics
*/
-adminRoutes.get('/stats', async c => {
+userRoutes.get('/stats', async c => {
const db = createDb(c.env.DB);
try {
@@ -84,7 +78,7 @@ adminRoutes.get('/stats', async c => {
* - sort: field to sort by (default createdAt)
* - order: asc or desc (default desc)
*/
-adminRoutes.get('/users', async c => {
+userRoutes.get('/users', async c => {
const db = createDb(c.env.DB);
const page = Math.max(1, parseInt(c.req.query('page') || '1', 10));
@@ -215,7 +209,7 @@ adminRoutes.get('/users', async c => {
* GET /api/admin/users/:userId
* Get detailed user info
*/
-adminRoutes.get('/users/:userId', async c => {
+userRoutes.get('/users/:userId', async c => {
const userId = c.req.param('userId');
const db = createDb(c.env.DB);
@@ -292,9 +286,9 @@ adminRoutes.get('/users/:userId', async c => {
* POST /api/admin/users/:userId/ban
* Ban a user
*/
-adminRoutes.post('/users/:userId/ban', async c => {
+userRoutes.post('/users/:userId/ban', validateRequest(userSchemas.ban), async c => {
const userId = c.req.param('userId');
- const { reason, expiresAt } = await c.req.json();
+ const { reason, expiresAt } = c.get('validatedBody');
const db = createDb(c.env.DB);
try {
@@ -317,7 +311,7 @@ adminRoutes.post('/users/:userId/ban', async c => {
.set({
banned: true,
banReason: reason || 'Banned by administrator',
- banExpires: expiresAt ? new Date(expiresAt) : null,
+ banExpires: expiresAt ?? null,
updatedAt: new Date(),
})
.where(eq(user.id, userId)),
@@ -339,7 +333,7 @@ adminRoutes.post('/users/:userId/ban', async c => {
* POST /api/admin/users/:userId/unban
* Unban a user
*/
-adminRoutes.post('/users/:userId/unban', async c => {
+userRoutes.post('/users/:userId/unban', async c => {
const userId = c.req.param('userId');
const db = createDb(c.env.DB);
@@ -370,7 +364,7 @@ adminRoutes.post('/users/:userId/unban', async c => {
* Start impersonating a user (creates a new session)
* Requires user to have 'admin' role in database
*/
-adminRoutes.post('/users/:userId/impersonate', async c => {
+userRoutes.post('/users/:userId/impersonate', async c => {
const userId = c.req.param('userId');
const adminUser = c.get('user');
@@ -430,15 +424,11 @@ adminRoutes.post('/users/:userId/impersonate', async c => {
}
});
-// Note: stop-impersonation is defined in index.js separately
-// because it needs to bypass the requireAdmin middleware
-// (the impersonated user won't have admin role)
-
/**
* DELETE /api/admin/users/:userId/sessions
* Revoke all sessions for a user
*/
-adminRoutes.delete('/users/:userId/sessions', async c => {
+userRoutes.delete('/users/:userId/sessions', async c => {
const userId = c.req.param('userId');
const db = createDb(c.env.DB);
@@ -460,7 +450,7 @@ adminRoutes.delete('/users/:userId/sessions', async c => {
* DELETE /api/admin/users/:userId
* Delete a user and all their data
*/
-adminRoutes.delete('/users/:userId', async c => {
+userRoutes.delete('/users/:userId', async c => {
const userId = c.req.param('userId');
const adminUser = c.get('user');
const db = createDb(c.env.DB);
@@ -515,7 +505,7 @@ adminRoutes.delete('/users/:userId', async c => {
* GET /api/admin/check
* Check if current user is admin
*/
-adminRoutes.get('/check', async c => {
+userRoutes.get('/check', async c => {
const adminUser = c.get('user');
return c.json({
isAdmin: true,
@@ -533,7 +523,7 @@ adminRoutes.get('/check', async c => {
* Body: { tier: 'free'|'pro'|'unlimited', status: 'active', currentPeriodStart?: timestamp, currentPeriodEnd?: timestamp }
* Admins select the plan (tier), which determines entitlements/quotas via configuration
*/
-adminRoutes.post(
+userRoutes.post(
'/users/:userId/subscription',
validateRequest(subscriptionSchemas.grant),
async c => {
@@ -609,7 +599,7 @@ adminRoutes.post(
* DELETE /api/admin/users/:userId/subscription
* Revoke subscription for a user (sets status to inactive)
*/
-adminRoutes.delete('/users/:userId/subscription', async c => {
+userRoutes.delete('/users/:userId/subscription', async c => {
const userId = c.req.param('userId');
const db = createDb(c.env.DB);
@@ -649,4 +639,4 @@ adminRoutes.delete('/users/:userId/subscription', async c => {
}
});
-export { adminRoutes };
+export { userRoutes };