diff --git a/frontend/.eslintrc.js b/frontend/.eslintrc.js
new file mode 100644
index 000000000..cd779cd72
--- /dev/null
+++ b/frontend/.eslintrc.js
@@ -0,0 +1,55 @@
+module.exports = {
+ extends: [
+ 'react-app',
+ 'react-app/jest'
+ ],
+ rules: {
+ // Custom rules to prevent dangerous file.name as ID patterns
+ 'no-file-name-as-id': 'error',
+ 'prefer-file-with-id': 'warn'
+ },
+ overrides: [
+ {
+ files: ['**/*.ts', '**/*.tsx'],
+ rules: {
+ // Prevent file.name being used where FileId is expected
+ 'no-restricted-syntax': [
+ 'error',
+ {
+ selector: 'MemberExpression[object.name="file"][property.name="name"]',
+ message: 'Avoid using file.name directly. Use FileWithId.fileId or safeGetFileId() instead to prevent ID collisions.'
+ },
+ {
+ selector: 'CallExpression[callee.name="createOperation"] > ArrayExpression > CallExpression[callee.property.name="map"] > ArrowFunctionExpression > MemberExpression[object.name="f"][property.name="name"]',
+ message: 'Dangerous pattern: Using file.name as ID in createOperation. Use FileWithId.fileId instead.'
+ },
+ {
+ selector: 'ArrayExpression[elements.length>0] CallExpression[callee.property.name="map"] > ArrowFunctionExpression > MemberExpression[property.name="name"]',
+ message: 'Potential file.name as ID usage detected. Ensure proper FileId usage instead of file.name.'
+ }
+ ]
+ }
+ }
+ ],
+ settings: {
+ // Custom settings for our file ID validation
+ 'file-id-validation': {
+ // Functions that should only accept FileId, not strings
+ 'file-id-only-functions': [
+ 'recordOperation',
+ 'markOperationApplied',
+ 'markOperationFailed',
+ 'removeFiles',
+ 'updateFileRecord',
+ 'pinFile',
+ 'unpinFile'
+ ],
+ // Functions that should accept FileWithId instead of File
+ 'file-with-id-functions': [
+ 'createOperation',
+ 'executeOperation',
+ 'isFilePinned'
+ ]
+ }
+ }
+};
\ No newline at end of file
diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
index 8a30d3869..97503ba21 100644
--- a/frontend/src/App.tsx
+++ b/frontend/src/App.tsx
@@ -14,6 +14,9 @@ import "./styles/cookieconsent.css";
import "./index.css";
import { RightRailProvider } from "./contexts/RightRailContext";
+// Import file ID safety validators (development only)
+import "./utils/fileIdSafety";
+
// Loading component for i18next suspense
const LoadingFallback = () => (
void;
- onMergeFiles?: (files: File[]) => void;
+ onOpenPageEditor?: (file: FileWithId) => void;
+ onMergeFiles?: (files: FileWithId[]) => void;
toolMode?: boolean;
showUpload?: boolean;
showBulkActions?: boolean;
@@ -421,7 +421,7 @@ const FileEditor = ({
if (startIndex === -1) return;
const recordsToMerge = activeFileRecords.slice(startIndex);
- const filesToMerge = recordsToMerge.map(r => selectors.getFile(r.id)).filter(Boolean) as File[];
+ const filesToMerge = recordsToMerge.map(r => selectors.getFile(r.id)).filter(Boolean) as FileWithId[];
if (onMergeFiles) {
onMergeFiles(filesToMerge);
}
diff --git a/frontend/src/components/shared/FileCard.tsx b/frontend/src/components/shared/FileCard.tsx
index 73ae01dba..b433be2ca 100644
--- a/frontend/src/components/shared/FileCard.tsx
+++ b/frontend/src/components/shared/FileCard.tsx
@@ -25,7 +25,7 @@ interface FileCardProps {
const FileCard = ({ file, record, onRemove, onDoubleClick, onView, onEdit, isSelected, onSelect, isSupported = true }: FileCardProps) => {
const { t } = useTranslation();
// Use record thumbnail if available, otherwise fall back to IndexedDB lookup
- const fileMetadata = record ? { id: record.id, name: file.name, type: file.type, size: file.size, lastModified: file.lastModified } : null;
+ const fileMetadata = record ? { id: record.id, name: record.name, type: record.type, size: record.size, lastModified: record.lastModified } : null;
const { thumbnail: indexedDBThumb, isGenerating } = useIndexedDBThumbnail(fileMetadata);
const thumb = record?.thumbnailUrl || indexedDBThumb;
const [isHovered, setIsHovered] = useState(false);
diff --git a/frontend/src/components/shared/FileGrid.tsx b/frontend/src/components/shared/FileGrid.tsx
index 1a43196d6..2da1e2b09 100644
--- a/frontend/src/components/shared/FileGrid.tsx
+++ b/frontend/src/components/shared/FileGrid.tsx
@@ -124,8 +124,12 @@ const FileGrid = ({
style={{ overflowY: "auto", width: "100%" }}
>
{displayFiles.map((item, idx) => {
- const fileId = item.record?.id || item.file.name as FileId /* FIX ME: This doesn't seem right */;
- const originalIdx = files.findIndex(f => (f.record?.id || f.file.name) === fileId);
+ if (!item.record?.id) {
+ console.error('FileGrid: File missing FileRecord with proper ID:', item.file.name);
+ return null;
+ }
+ const fileId = item.record.id;
+ const originalIdx = files.findIndex(f => f.record?.id === fileId);
const supported = isFileSupported ? isFileSupported(item.file.name) : true;
return (
void;
getAvailableToExtensions: (fromExtension: string) => Array<{value: string, label: string, group: string}>;
- selectedFiles: File[];
+ selectedFiles: FileWithId[];
disabled?: boolean;
}
@@ -129,7 +129,7 @@ const ConvertSettings = ({
};
const filterFilesByExtension = (extension: string) => {
- const files = activeFiles.map(fileId => selectors.getFile(fileId)).filter(Boolean) as File[];
+ const files = activeFiles.map(fileId => selectors.getFile(fileId)).filter(Boolean) as FileWithId[];
return files.filter(file => {
const fileExtension = detectFileExtension(file.name);
@@ -143,21 +143,8 @@ const ConvertSettings = ({
});
};
- const updateFileSelection = (files: File[]) => {
- // Map File objects to their actual IDs in FileContext
- const fileIds = files.map(file => {
- // Find the file ID by matching file properties
- const fileRecord = state.files.ids
- .map(id => selectors.getFileRecord(id))
- .find(record =>
- record &&
- record.name === file.name &&
- record.size === file.size &&
- record.lastModified === file.lastModified
- );
- return fileRecord?.id;
- }).filter((id): id is FileId => id !== undefined); // Type guard to ensure only strings
-
+ const updateFileSelection = (files: FileWithId[]) => {
+ const fileIds = files.map(file => file.fileId);
setSelectedFiles(fileIds);
};
diff --git a/frontend/src/components/tools/convert/ConvertToPdfaSettings.tsx b/frontend/src/components/tools/convert/ConvertToPdfaSettings.tsx
index e1a662bd2..ccad7ce67 100644
--- a/frontend/src/components/tools/convert/ConvertToPdfaSettings.tsx
+++ b/frontend/src/components/tools/convert/ConvertToPdfaSettings.tsx
@@ -3,11 +3,12 @@ import { Stack, Text, Select, Alert } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import { ConvertParameters } from '../../../hooks/tools/convert/useConvertParameters';
import { usePdfSignatureDetection } from '../../../hooks/usePdfSignatureDetection';
+import { FileWithId } from '../../../types/fileContext';
interface ConvertToPdfaSettingsProps {
parameters: ConvertParameters;
onParameterChange: (key: keyof ConvertParameters, value: any) => void;
- selectedFiles: File[];
+ selectedFiles: FileWithId[];
disabled?: boolean;
}
diff --git a/frontend/src/components/tools/shared/FileStatusIndicator.tsx b/frontend/src/components/tools/shared/FileStatusIndicator.tsx
index 9b375fc2f..214159b05 100644
--- a/frontend/src/components/tools/shared/FileStatusIndicator.tsx
+++ b/frontend/src/components/tools/shared/FileStatusIndicator.tsx
@@ -6,9 +6,10 @@ import UploadIcon from '@mui/icons-material/Upload';
import { useFilesModalContext } from "../../../contexts/FilesModalContext";
import { useAllFiles } from "../../../contexts/FileContext";
import { useFileManager } from "../../../hooks/useFileManager";
+import { FileWithId } from "../../../types/fileContext";
export interface FileStatusIndicatorProps {
- selectedFiles?: File[];
+ selectedFiles?: FileWithId[];
placeholder?: string;
}
diff --git a/frontend/src/components/tools/shared/FilesToolStep.tsx b/frontend/src/components/tools/shared/FilesToolStep.tsx
index b062e9c02..11f7d4e02 100644
--- a/frontend/src/components/tools/shared/FilesToolStep.tsx
+++ b/frontend/src/components/tools/shared/FilesToolStep.tsx
@@ -1,9 +1,10 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import FileStatusIndicator from './FileStatusIndicator';
+import { FileWithId } from '../../../types/fileContext';
export interface FilesToolStepProps {
- selectedFiles: File[];
+ selectedFiles: FileWithId[];
isCollapsed?: boolean;
onCollapsedClick?: () => void;
placeholder?: string;
diff --git a/frontend/src/components/tools/shared/createToolFlow.tsx b/frontend/src/components/tools/shared/createToolFlow.tsx
index b6a7594c6..1bee8b9fc 100644
--- a/frontend/src/components/tools/shared/createToolFlow.tsx
+++ b/frontend/src/components/tools/shared/createToolFlow.tsx
@@ -4,9 +4,10 @@ import { createToolSteps, ToolStepProvider } from './ToolStep';
import OperationButton from './OperationButton';
import { ToolOperationHook } from '../../../hooks/tools/shared/useToolOperation';
import { ToolWorkflowTitle, ToolWorkflowTitleProps } from './ToolWorkflowTitle';
+import { FileWithId } from '../../../types/fileContext';
export interface FilesStepConfig {
- selectedFiles: File[];
+ selectedFiles: FileWithId[];
isCollapsed?: boolean;
placeholder?: string;
onCollapsedClick?: () => void;
diff --git a/frontend/src/components/viewer/Viewer.tsx b/frontend/src/components/viewer/Viewer.tsx
index 0932e995b..53d9e98e2 100644
--- a/frontend/src/components/viewer/Viewer.tsx
+++ b/frontend/src/components/viewer/Viewer.tsx
@@ -15,6 +15,7 @@ import { fileStorage } from "../../services/fileStorage";
import SkeletonLoader from '../shared/SkeletonLoader';
import { useFileState, useFileActions, useCurrentFile } from "../../contexts/FileContext";
import { useFileWithUrl } from "../../hooks/useFileWithUrl";
+import { isFileObject } from "../../types/fileContext";
import { FileId } from "../../types/file";
@@ -201,7 +202,7 @@ const Viewer = ({
const effectiveFile = React.useMemo(() => {
if (previewFile) {
// Validate the preview file
- if (!(previewFile instanceof File)) {
+ if (!isFileObject(previewFile)) {
return null;
}
diff --git a/frontend/src/hooks/tools/shared/useBaseTool.ts b/frontend/src/hooks/tools/shared/useBaseTool.ts
index 64e9af59e..4995803e5 100644
--- a/frontend/src/hooks/tools/shared/useBaseTool.ts
+++ b/frontend/src/hooks/tools/shared/useBaseTool.ts
@@ -4,10 +4,11 @@ import { useEndpointEnabled } from '../../useEndpointConfig';
import { BaseToolProps } from '../../../types/tool';
import { ToolOperationHook } from './useToolOperation';
import { BaseParametersHook } from './useBaseParameters';
+import { FileWithId } from '../../../types/fileContext';
interface BaseToolReturn {
// File management
- selectedFiles: File[];
+ selectedFiles: FileWithId[];
// Tool-specific hooks
params: BaseParametersHook;
diff --git a/frontend/src/hooks/useFileManager.ts b/frontend/src/hooks/useFileManager.ts
index f47430fd2..8df1e2754 100644
--- a/frontend/src/hooks/useFileManager.ts
+++ b/frontend/src/hooks/useFileManager.ts
@@ -2,7 +2,7 @@ import { useState, useCallback } from 'react';
import { useIndexedDB } from '../contexts/IndexedDBContext';
import { FileMetadata } from '../types/file';
import { generateThumbnailForFile } from '../utils/thumbnailUtils';
-import { FileId } from '../types/file';
+import { FileId } from '../types/fileContext';
export const useFileManager = () => {
const [loading, setLoading] = useState(false);
diff --git a/frontend/src/hooks/useFileWithUrl.ts b/frontend/src/hooks/useFileWithUrl.ts
index fd2f2d604..84c06baa2 100644
--- a/frontend/src/hooks/useFileWithUrl.ts
+++ b/frontend/src/hooks/useFileWithUrl.ts
@@ -1,4 +1,5 @@
import { useMemo } from 'react';
+import { isFileObject } from '../types/fileContext';
/**
* Hook to convert a File object to { file: File; url: string } format
@@ -8,8 +9,8 @@ export function useFileWithUrl(file: File | Blob | null): { file: File | Blob; u
return useMemo(() => {
if (!file) return null;
- // Validate that file is a proper File or Blob object
- if (!(file instanceof File) && !(file instanceof Blob)) {
+ // Validate that file is a proper File, FileWithId, or Blob object
+ if (!isFileObject(file) && !(file instanceof Blob)) {
console.warn('useFileWithUrl: Expected File or Blob, got:', file);
return null;
}
diff --git a/frontend/src/hooks/useIndexedDBThumbnail.ts b/frontend/src/hooks/useIndexedDBThumbnail.ts
index 4f0d77c0e..a6251db3c 100644
--- a/frontend/src/hooks/useIndexedDBThumbnail.ts
+++ b/frontend/src/hooks/useIndexedDBThumbnail.ts
@@ -2,6 +2,7 @@ import { useState, useEffect } from "react";
import { FileMetadata } from "../types/file";
import { useIndexedDB } from "../contexts/IndexedDBContext";
import { generateThumbnailForFile } from "../utils/thumbnailUtils";
+import { FileId } from "../types/fileContext";
/**
* Calculate optimal scale for thumbnail generation
@@ -53,7 +54,7 @@ export function useIndexedDBThumbnail(file: FileMetadata | undefined | null): {
// Try to load file from IndexedDB using new context
if (file.id && indexedDB) {
- const loadedFile = await indexedDB.loadFile(file.id);
+ const loadedFile = await indexedDB.loadFile(file.id as FileId);
if (!loadedFile) {
throw new Error('File not found in IndexedDB');
}
@@ -70,7 +71,7 @@ export function useIndexedDBThumbnail(file: FileMetadata | undefined | null): {
// Save thumbnail to IndexedDB for persistence
if (file.id && indexedDB && thumbnail) {
try {
- await indexedDB.updateThumbnail(file.id, thumbnail);
+ await indexedDB.updateThumbnail(file.id as FileId, thumbnail);
} catch (error) {
console.warn('Failed to save thumbnail to IndexedDB:', error);
}
diff --git a/frontend/src/hooks/usePdfSignatureDetection.ts b/frontend/src/hooks/usePdfSignatureDetection.ts
index 17f90f2d9..84075f972 100644
--- a/frontend/src/hooks/usePdfSignatureDetection.ts
+++ b/frontend/src/hooks/usePdfSignatureDetection.ts
@@ -1,13 +1,14 @@
import { useState, useEffect } from 'react';
import * as pdfjsLib from 'pdfjs-dist';
import { pdfWorkerManager } from '../services/pdfWorkerManager';
+import { FileWithId } from '../types/fileContext';
export interface PdfSignatureDetectionResult {
hasDigitalSignatures: boolean;
isChecking: boolean;
}
-export const usePdfSignatureDetection = (files: File[]): PdfSignatureDetectionResult => {
+export const usePdfSignatureDetection = (files: FileWithId[]): PdfSignatureDetectionResult => {
const [hasDigitalSignatures, setHasDigitalSignatures] = useState(false);
const [isChecking, setIsChecking] = useState(false);
diff --git a/frontend/src/tests/convert/ConvertSmartDetectionIntegration.test.tsx b/frontend/src/tests/convert/ConvertSmartDetectionIntegration.test.tsx
index 4e9fb7908..3ba6cc589 100644
--- a/frontend/src/tests/convert/ConvertSmartDetectionIntegration.test.tsx
+++ b/frontend/src/tests/convert/ConvertSmartDetectionIntegration.test.tsx
@@ -14,6 +14,8 @@ import i18n from '../../i18n/config';
import axios from 'axios';
import { detectFileExtension } from '../../utils/fileUtils';
import { FIT_OPTIONS } from '../../constants/convertConstants';
+import { createTestFileWithId, createTestFilesWithId } from '../utils/testFileHelpers';
+import { FileWithId } from '../../types/fileContext';
// Mock axios
vi.mock('axios');
@@ -81,7 +83,7 @@ describe('Convert Tool - Smart Detection Integration Tests', () => {
});
// Create mock DOCX file
- const docxFile = new File(['docx content'], 'document.docx', { type: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document' });
+ const docxFile = createTestFileWithId('document.docx', 'docx content', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document');
// Test auto-detection
act(() => {
@@ -117,7 +119,7 @@ describe('Convert Tool - Smart Detection Integration Tests', () => {
});
// Create mock unknown file
- const unknownFile = new File(['unknown content'], 'document.xyz', { type: 'application/octet-stream' });
+ const unknownFile = createTestFileWithId('document.xyz', 'unknown content', 'application/octet-stream');
// Test auto-detection
act(() => {
@@ -156,11 +158,11 @@ describe('Convert Tool - Smart Detection Integration Tests', () => {
});
// Create mock image files
- const imageFiles = [
- new File(['jpg content'], 'photo1.jpg', { type: 'image/jpeg' }),
- new File(['png content'], 'photo2.png', { type: 'image/png' }),
- new File(['gif content'], 'photo3.gif', { type: 'image/gif' })
- ];
+ const imageFiles = createTestFilesWithId([
+ { name: 'photo1.jpg', content: 'jpg content', type: 'image/jpeg' },
+ { name: 'photo2.png', content: 'png content', type: 'image/png' },
+ { name: 'photo3.gif', content: 'gif content', type: 'image/gif' }
+ ]);
// Test smart detection for all images
act(() => {
@@ -202,11 +204,11 @@ describe('Convert Tool - Smart Detection Integration Tests', () => {
});
// Create mixed file types
- const mixedFiles = [
- new File(['pdf content'], 'document.pdf', { type: 'application/pdf' }),
- new File(['docx content'], 'spreadsheet.xlsx', { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' }),
- new File(['pptx content'], 'presentation.pptx', { type: 'application/vnd.openxmlformats-officedocument.presentationml.presentation' })
- ];
+ const mixedFiles = createTestFilesWithId([
+ { name: 'document.pdf', content: 'pdf content', type: 'application/pdf' },
+ { name: 'spreadsheet.xlsx', content: 'docx content', type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' },
+ { name: 'presentation.pptx', content: 'pptx content', type: 'application/vnd.openxmlformats-officedocument.presentationml.presentation' }
+ ]);
// Test smart detection for mixed types
act(() => {
@@ -243,10 +245,10 @@ describe('Convert Tool - Smart Detection Integration Tests', () => {
});
// Create mock web files
- const webFiles = [
- new File(['content'], 'page1.html', { type: 'text/html' }),
- new File(['zip content'], 'site.zip', { type: 'application/zip' })
- ];
+ const webFiles = createTestFilesWithId([
+ { name: 'page1.html', content: 'content', type: 'text/html' },
+ { name: 'site.zip', content: 'zip content', type: 'application/zip' }
+ ]);
// Test smart detection for web files
act(() => {
@@ -288,7 +290,7 @@ describe('Convert Tool - Smart Detection Integration Tests', () => {
wrapper: TestWrapper
});
- const htmlFile = new File(['content'], 'page.html', { type: 'text/html' });
+ const htmlFile = createTestFileWithId('page.html', 'content', 'text/html');
// Set up HTML conversion parameters
act(() => {
@@ -318,7 +320,7 @@ describe('Convert Tool - Smart Detection Integration Tests', () => {
wrapper: TestWrapper
});
- const emlFile = new File(['email content'], 'email.eml', { type: 'message/rfc822' });
+ const emlFile = createTestFileWithId('email.eml', 'email content', 'message/rfc822');
// Set up email conversion parameters
act(() => {
@@ -355,7 +357,7 @@ describe('Convert Tool - Smart Detection Integration Tests', () => {
wrapper: TestWrapper
});
- const pdfFile = new File(['pdf content'], 'document.pdf', { type: 'application/pdf' });
+ const pdfFile = createTestFileWithId('document.pdf', 'pdf content', 'application/pdf');
// Set up PDF/A conversion parameters
act(() => {
@@ -392,10 +394,10 @@ describe('Convert Tool - Smart Detection Integration Tests', () => {
wrapper: TestWrapper
});
- const imageFiles = [
- new File(['jpg1'], 'photo1.jpg', { type: 'image/jpeg' }),
- new File(['jpg2'], 'photo2.jpg', { type: 'image/jpeg' })
- ];
+ const imageFiles = createTestFilesWithId([
+ { name: 'photo1.jpg', content: 'jpg1', type: 'image/jpeg' },
+ { name: 'photo2.jpg', content: 'jpg2', type: 'image/jpeg' }
+ ]);
// Set up image conversion parameters
act(() => {
@@ -432,10 +434,10 @@ describe('Convert Tool - Smart Detection Integration Tests', () => {
wrapper: TestWrapper
});
- const imageFiles = [
- new File(['jpg1'], 'photo1.jpg', { type: 'image/jpeg' }),
- new File(['jpg2'], 'photo2.jpg', { type: 'image/jpeg' })
- ];
+ const imageFiles = createTestFilesWithId([
+ { name: 'photo1.jpg', content: 'jpg1', type: 'image/jpeg' },
+ { name: 'photo2.jpg', content: 'jpg2', type: 'image/jpeg' }
+ ]);
// Set up for separate processing
act(() => {
@@ -477,10 +479,10 @@ describe('Convert Tool - Smart Detection Integration Tests', () => {
})
.mockRejectedValueOnce(new Error('File 2 failed'));
- const mixedFiles = [
- new File(['file1'], 'doc1.txt', { type: 'text/plain' }),
- new File(['file2'], 'doc2.xyz', { type: 'application/octet-stream' })
- ];
+ const mixedFiles = createTestFilesWithId([
+ { name: 'doc1.txt', content: 'file1', type: 'text/plain' },
+ { name: 'doc2.xyz', content: 'file2', type: 'application/octet-stream' }
+ ]);
// Set up for separate processing (mixed smart detection)
act(() => {
diff --git a/frontend/src/types/fileIdSafety.d.ts b/frontend/src/types/fileIdSafety.d.ts
new file mode 100644
index 000000000..3235d3b3a
--- /dev/null
+++ b/frontend/src/types/fileIdSafety.d.ts
@@ -0,0 +1,50 @@
+/**
+ * Type safety declarations to prevent file.name/UUID confusion
+ */
+
+import { FileId, FileWithId, OperationType, FileOperation } from './fileContext';
+
+declare global {
+ namespace FileIdSafety {
+ // Mark functions that should never accept file.name as parameters
+ type SafeFileIdFunction any> = T extends (...args: infer P) => infer R
+ ? P extends readonly [string, ...any[]]
+ ? never // Reject string parameters in first position for FileId functions
+ : T
+ : T;
+
+ // Mark functions that should only accept FileWithId, not regular File
+ type FileWithIdOnlyFunction any> = T extends (...args: infer P) => infer R
+ ? P extends readonly [File, ...any[]]
+ ? never // Reject File parameters in first position for FileWithId functions
+ : T
+ : T;
+
+ // Utility type to enforce FileWithId usage
+ type RequireFileWithId = T extends File ? FileWithId : T;
+ }
+
+ // Extend Window interface to add runtime validation helpers
+ interface Window {
+ __FILE_ID_DEBUG?: boolean;
+ __validateFileId?: (id: string, context: string) => void;
+ }
+}
+
+// Augment FileContext types to prevent bypassing FileWithId
+declare module '../contexts/FileContext' {
+ export interface StrictFileContextActions {
+ pinFile: (file: FileWithId) => void; // Must be FileWithId
+ unpinFile: (file: FileWithId) => void; // Must be FileWithId
+ addFiles: (files: File[], options?: { insertAfterPageId?: string }) => Promise; // Returns FileWithId
+ consumeFiles: (inputFileIds: FileId[], outputFiles: File[]) => Promise; // Returns FileWithId
+ }
+
+ export interface StrictFileContextSelectors {
+ getFile: (id: FileId) => FileWithId | undefined; // Returns FileWithId
+ getFiles: (ids?: FileId[]) => FileWithId[]; // Returns FileWithId[]
+ isFilePinned: (file: FileWithId) => boolean; // Must be FileWithId
+ }
+}
+
+export {};
\ No newline at end of file
diff --git a/frontend/src/utils/fileIdSafety.ts b/frontend/src/utils/fileIdSafety.ts
new file mode 100644
index 000000000..6837786ed
--- /dev/null
+++ b/frontend/src/utils/fileIdSafety.ts
@@ -0,0 +1,79 @@
+/**
+ * Runtime validation utilities for FileId safety
+ */
+
+import { FileId } from '../types/fileContext';
+
+// Validate that a string is a proper FileId (has UUID format)
+export function isValidFileId(id: string): id is FileId {
+ // Check UUID v4 format: 8-4-4-4-12 hex digits
+ const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
+ return uuidRegex.test(id);
+}
+
+// Detect potentially dangerous file.name usage as ID
+export function isDangerousFileNameAsId(fileName: string, context: string = ''): boolean {
+ // Check if it's definitely a UUID (safe)
+ if (isValidFileId(fileName)) {
+ return false;
+ }
+
+ // Check if it's a quickKey (safe) - format: name|size|lastModified
+ if (/^.+\|\d+\|\d+$/.test(fileName)) {
+ return false; // quickKeys are legitimate, not dangerous
+ }
+
+ // Common patterns that suggest file.name is being used as ID
+ const dangerousPatterns = [
+ /^[^-]+-page-\d+$/, // pattern: filename-page-123
+ /\.(pdf|jpg|png|doc|docx)$/i, // ends with file extension
+ /\s/, // contains whitespace (filenames often have spaces)
+ /[()[\]{}]/, // contains brackets/parentheses common in filenames
+ /['"]/, // contains quotes
+ /[^a-zA-Z0-9\-._]/ // contains special characters not in UUIDs
+ ];
+
+ // Check dangerous patterns
+ const isDangerous = dangerousPatterns.some(pattern => pattern.test(fileName));
+
+ if (isDangerous && context) {
+ console.warn(`⚠️ Potentially dangerous file.name usage detected in ${context}: "${fileName}"`);
+ }
+
+ return isDangerous;
+}
+
+// Runtime validation for FileId usage in development
+export function validateFileId(id: string, context: string): void {
+ if (process.env.NODE_ENV === 'development') {
+ // Check if it looks like a dangerous file.name usage
+ if (isDangerousFileNameAsId(id, context)) {
+ console.error(`💀 DANGEROUS: file.name used as FileId in ${context}! This will cause ID collisions.`);
+ console.trace('Stack trace:');
+ }
+ }
+}
+
+// Runtime validation for File vs FileWithId usage
+export function validateFileWithId(file: File, context: string): void {
+ // Check if file has embedded fileId
+ if (!('fileId' in file)) {
+ console.warn(`⚠️ Regular File object used where FileWithId expected in ${context}: "${file.name}"`);
+ console.warn('Consider using FileWithId for better type safety');
+ }
+}
+
+// Assertion for FileId validation (throws in development)
+export function assertValidFileId(id: string, context: string): void {
+ if (process.env.NODE_ENV === 'development') {
+ if (isDangerousFileNameAsId(id, context)) {
+ throw new Error(`ASSERTION FAILED: Dangerous file.name as FileId detected in ${context}: "${id}"`);
+ }
+ }
+}
+
+// Global debug helpers (can be enabled in dev tools)
+if (typeof window !== 'undefined') {
+ window.__FILE_ID_DEBUG = process.env.NODE_ENV === 'development';
+ window.__validateFileId = validateFileId;
+}
\ No newline at end of file