diff --git a/.cursor/rules/backend-rules.mdc b/.cursor/rules/backend-rules.mdc index ed0388239..3a6349297 100644 --- a/.cursor/rules/backend-rules.mdc +++ b/.cursor/rules/backend-rules.mdc @@ -114,3 +114,12 @@ If necessary (like editing ), refer [sync-architecture.mdc](mdc:.cursor/rules/sy - **Comprehensive Data**: Database + PostHog contain all search metadata, performance metrics, and feature usage data - **Non-blocking**: Both persistence methods fail gracefully without affecting search functionality - **Automatic Tracking**: All search operations (regular, streaming, legacy) tracked uniformly via `SearchService` + +### Security + +- Never use `random.*` for security-sensitive values — ruff + rule `S311` bans it +- Use `secrets.choice()`, `secrets.randbelow()`, + `secrets.token_bytes()`, or `secrets.token_urlsafe()` instead +- Seeded `random.Random(seed)` is acceptable **only** for + deterministic test-data generation (stub sources) diff --git a/.cursor/rules/frontend-rules.mdc b/.cursor/rules/frontend-rules.mdc index 448748735..a6bb6eea2 100644 --- a/.cursor/rules/frontend-rules.mdc +++ b/.cursor/rules/frontend-rules.mdc @@ -341,3 +341,8 @@ try { - Sensitive data stripped from errors - No credentials in localStorage - Secure OAuth state management + +### 4. **Randomness** +- Never use `Math.random()` — it is banned by ESLint +- Use `crypto.getRandomValues()` for random bytes/integers +- Use `crypto.randomUUID()` for UUIDs diff --git a/backend/airweave/core/readable_id.py b/backend/airweave/core/readable_id.py new file mode 100644 index 000000000..a9e623bf2 --- /dev/null +++ b/backend/airweave/core/readable_id.py @@ -0,0 +1,46 @@ +"""Readable ID generation using a CSPRNG. + +Provides a single ``generate_readable_id`` helper that converts a +human-readable name into a URL-safe slug with a random suffix, e.g. +``"finance-data-ab12x9"``. The suffix is drawn from +:func:`secrets.choice` (CSPRNG) rather than :mod:`random` (Mersenne +Twister). +""" + +import re +import secrets +import string + +_ALPHABET = string.ascii_lowercase + string.digits + + +def generate_readable_id(name: str) -> str: + """Generate a readable ID from a name. + + Converts the name to lowercase, replaces spaces with hyphens, + removes special characters, and appends a cryptographically random + 6-character suffix to ensure uniqueness. + + Args: + name: The human-readable name to convert. + + Returns: + A URL-safe readable identifier (e.g. ``"finance-data-ab123x"``). + """ + # Convert to lowercase and replace spaces with hyphens + readable_id = name.lower().strip() + + # Replace any character that's not a letter, number, or space with nothing + readable_id = re.sub(r"[^a-z0-9\s]", "", readable_id) + # Replace spaces with hyphens + readable_id = re.sub(r"\s+", "-", readable_id) + # Ensure no consecutive hyphens + readable_id = re.sub(r"-+", "-", readable_id) + # Trim hyphens from start and end + readable_id = readable_id.strip("-") + + # Add random alphanumeric suffix (CSPRNG) + suffix = "".join(secrets.choice(_ALPHABET) for _ in range(6)) + readable_id = f"{(readable_id + '-') if readable_id else ''}{suffix}" + + return readable_id diff --git a/backend/airweave/domains/oauth/oauth2_service.py b/backend/airweave/domains/oauth/oauth2_service.py index 038e2a27e..043709a34 100644 --- a/backend/airweave/domains/oauth/oauth2_service.py +++ b/backend/airweave/domains/oauth/oauth2_service.py @@ -3,7 +3,6 @@ import asyncio import base64 import hashlib -import random import secrets from typing import Any, Optional, Tuple from urllib.parse import urlencode @@ -573,7 +572,7 @@ async def _make_token_request( logger.info(f"Received response: Status {response.status_code}, ") if self._is_oauth_rate_limit_error(response): - delay = base_delay * (2**attempt) + random.uniform(0, 2) + delay = base_delay * (2**attempt) + secrets.randbelow(2001) / 1000 logger.warning( f"OAuth rate limit hit, waiting {delay:.1f}s before retry " f"(attempt {attempt + 1}/{max_retries})" @@ -586,7 +585,7 @@ async def _make_token_request( except httpx.HTTPStatusError as e: if self._is_oauth_rate_limit_error(e.response): - delay = base_delay * (2**attempt) + random.uniform(0, 2) + delay = base_delay * (2**attempt) + secrets.randbelow(2001) / 1000 logger.warning( f"OAuth rate limit hit (exception), waiting {delay:.1f}s before retry " f"(attempt {attempt + 1}/{max_retries})" diff --git a/backend/airweave/email/services.py b/backend/airweave/email/services.py index 7d80fdd6e..8c4129b64 100644 --- a/backend/airweave/email/services.py +++ b/backend/airweave/email/services.py @@ -1,7 +1,7 @@ """Email service for sending emails via Resend.""" import asyncio -import random +import secrets import time from datetime import datetime, timedelta, timezone from typing import Optional @@ -139,7 +139,7 @@ def _send_welcome_email_sync(to_email: str, user_name: str) -> None: resend.api_key = settings.RESEND_API_KEY # Generate random delay between 10 and 40 minutes - delay_minutes = random.randint(10, 40) + delay_minutes = secrets.randbelow(31) + 10 # Calculate scheduled time using ISO 8601 format scheduled_time = datetime.now(timezone.utc) + timedelta(minutes=delay_minutes) @@ -203,7 +203,7 @@ def _send_welcome_followup_email_sync(to_email: str, user_name: str) -> None: resend.api_key = settings.RESEND_API_KEY # Generate random delay between 30 and 60 minutes - delay_minutes = random.randint(30, 60) + delay_minutes = secrets.randbelow(31) + 30 # Schedule for 5 days from now plus random delay scheduled_time = datetime.now(timezone.utc) + timedelta(days=5, minutes=delay_minutes) diff --git a/backend/airweave/platform/sources/ctti.py b/backend/airweave/platform/sources/ctti.py index 7201e4f99..87df17d14 100644 --- a/backend/airweave/platform/sources/ctti.py +++ b/backend/airweave/platform/sources/ctti.py @@ -5,7 +5,7 @@ """ import asyncio -import random +import secrets from typing import Any, AsyncGenerator, Dict, Optional, Union import asyncpg @@ -141,7 +141,7 @@ async def _retry_with_backoff(self, func, *args, max_retries: int = 3, **kwargs) if attempt < max_retries: # Calculate delay with exponential backoff and jitter base_delay = 2**attempt # 1s, 2s, 4s - jitter = random.uniform(0.1, 0.5) + jitter = 0.1 + secrets.randbelow(401) / 1000 delay = base_delay + jitter self.logger.warning( @@ -189,6 +189,58 @@ async def _close_pool(self) -> None: await self.pool.close() self.pool = None + async def _fetch_records(self, last_nct_id: str, remaining: int, total_synced: int, limit: int): + """Fetch clinical trial records from the AACT database. + + Handles sync-mode logging, query construction, and query execution + with retry logic. + """ + if last_nct_id: + self.logger.debug( + f"📊 Incremental sync from NCT_ID > {last_nct_id} " + f"({total_synced}/{limit} synced, {remaining} remaining)" + ) + else: + self.logger.debug(f"🔄 Full sync (no cursor), limit={limit}") + + pool = await self._ensure_pool() + + if last_nct_id: + query = f""" + SELECT nct_id + FROM "{self.AACT_SCHEMA}"."{self.AACT_TABLE}" + WHERE nct_id IS NOT NULL AND nct_id > $1 + ORDER BY nct_id ASC + LIMIT {remaining} + """ + query_args = [last_nct_id] + else: + query = f""" + SELECT nct_id + FROM "{self.AACT_SCHEMA}"."{self.AACT_TABLE}" + WHERE nct_id IS NOT NULL + ORDER BY nct_id ASC + LIMIT {remaining} + """ + query_args = [] + + async def _execute_query(): + async with pool.acquire() as conn: + if last_nct_id: + self.logger.debug( + f"Fetching up to {remaining} clinical trials from AACT " + f"(NCT_ID > {last_nct_id})" + ) + else: + self.logger.debug( + f"Fetching up to {remaining} clinical trials from AACT (full sync)" + ) + records = await conn.fetch(query, *query_args) + self.logger.debug(f"Fetched {len(records)} clinical trial records") + return records + + return await self._retry_with_backoff(_execute_query) + async def generate_entities(self) -> AsyncGenerator[CTTIWebEntity, None]: """Generate WebEntity instances for each nct_id in the AACT studies table. @@ -218,55 +270,7 @@ async def generate_entities(self) -> AsyncGenerator[CTTIWebEntity, None]: ) return - # Log sync mode - if last_nct_id: - self.logger.debug( - f"📊 Incremental sync from NCT_ID > {last_nct_id} " - f"({total_synced}/{limit} synced, {remaining} remaining)" - ) - else: - self.logger.debug(f"🔄 Full sync (no cursor), limit={limit}") - - pool = await self._ensure_pool() - - # Build query with cursor-based filtering - # Use parameterized query to prevent SQL injection - # Use 'remaining' as the limit to enforce total limit across syncs - if last_nct_id: - query = f""" - SELECT nct_id - FROM "{self.AACT_SCHEMA}"."{self.AACT_TABLE}" - WHERE nct_id IS NOT NULL AND nct_id > $1 - ORDER BY nct_id ASC - LIMIT {remaining} - """ - query_args = [last_nct_id] - else: - query = f""" - SELECT nct_id - FROM "{self.AACT_SCHEMA}"."{self.AACT_TABLE}" - WHERE nct_id IS NOT NULL - ORDER BY nct_id ASC - LIMIT {remaining} - """ - query_args = [] - - async def _execute_query(): - async with pool.acquire() as conn: - if last_nct_id: - self.logger.debug( - f"Fetching up to {remaining} clinical trials from AACT " - f"(NCT_ID > {last_nct_id})" - ) - else: - self.logger.debug( - f"Fetching up to {remaining} clinical trials from AACT (full sync)" - ) - records = await conn.fetch(query, *query_args) - self.logger.debug(f"Fetched {len(records)} clinical trial records") - return records - - records = await self._retry_with_backoff(_execute_query) + records = await self._fetch_records(last_nct_id, remaining, total_synced, limit) self.logger.debug(f"Processing {len(records)} records into entities") entities_created = 0 diff --git a/backend/airweave/platform/sync/web_fetcher.py b/backend/airweave/platform/sync/web_fetcher.py index 00c9e7313..c8b80ac67 100644 --- a/backend/airweave/platform/sync/web_fetcher.py +++ b/backend/airweave/platform/sync/web_fetcher.py @@ -3,7 +3,7 @@ import asyncio import hashlib import os -import random +import secrets from typing import List from uuid import uuid4 @@ -217,7 +217,7 @@ async def _retry_with_backoff( if is_connection_error: # Longer delays for connection issues base_delay = 5 * (attempt + 1) # 5, 10, 15 seconds - jitter = random.uniform(1.0, 2.0) + jitter = 1.0 + secrets.randbelow(1001) / 1000 delay = base_delay + jitter logger.warning( @@ -227,7 +227,7 @@ async def _retry_with_backoff( elif is_rate_limited: # Medium delay for rate limiting base_delay = 3 ** (attempt + 1) # 3, 9, 27 seconds - jitter = random.uniform(0.5, 1.0) + jitter = 0.5 + secrets.randbelow(501) / 1000 delay = base_delay + jitter logger.warning( @@ -237,7 +237,7 @@ async def _retry_with_backoff( else: # Standard exponential backoff base_delay = 2 * (attempt + 1) # 2, 4, 6 seconds - jitter = random.uniform(0.1, 0.5) + jitter = 0.1 + secrets.randbelow(401) / 1000 delay = base_delay + jitter logger.warning( diff --git a/backend/airweave/schemas/collection.py b/backend/airweave/schemas/collection.py index bce69665f..ab22d1146 100644 --- a/backend/airweave/schemas/collection.py +++ b/backend/airweave/schemas/collection.py @@ -3,51 +3,17 @@ A collection is a group of different data sources that you can search using a single endpoint. """ -import random -import re -import string from datetime import datetime from typing import Optional from uuid import UUID from pydantic import BaseModel, ConfigDict, EmailStr, Field, field_validator, model_validator +from airweave.core.readable_id import generate_readable_id from airweave.core.shared_models import CollectionStatus from airweave.platform.sync.config.base import SyncConfig -def generate_readable_id(name: str) -> str: - """Generate a readable ID from a collection name. - - Converts the name to lowercase, replaces spaces with hyphens, - removes special characters, and adds a random 6-character suffix - to ensure uniqueness. - - Args: - name: The collection name to convert - - Returns: - A URL-safe readable identifier (e.g., "finance-data-ab123") - """ - # Convert to lowercase and replace spaces with hyphens - readable_id = name.lower().strip() - - # Replace any character that's not a letter, number, or space with nothing - readable_id = re.sub(r"[^a-z0-9\s]", "", readable_id) - # Replace spaces with hyphens - readable_id = re.sub(r"\s+", "-", readable_id) - # Ensure no consecutive hyphens - readable_id = re.sub(r"-+", "-", readable_id) - # Trim hyphens from start and end - readable_id = readable_id.strip("-") - - # Add random alphanumeric suffix - suffix = "".join(random.choices(string.ascii_lowercase + string.digits, k=6)) - readable_id = f"{readable_id}-{suffix}" - - return readable_id - - class CollectionBase(BaseModel): """Base schema for collections with common fields.""" diff --git a/backend/airweave/schemas/connection.py b/backend/airweave/schemas/connection.py index af6751e67..82455bf78 100644 --- a/backend/airweave/schemas/connection.py +++ b/backend/airweave/schemas/connection.py @@ -6,50 +6,16 @@ """ -import random -import re -import string from datetime import datetime from typing import Optional from uuid import UUID from pydantic import BaseModel, ConfigDict, EmailStr, Field, field_validator, model_validator +from airweave.core.readable_id import generate_readable_id from airweave.core.shared_models import ConnectionStatus, IntegrationType -def generate_readable_id(name: str) -> str: - """Generate a readable ID from a connection name. - - Converts the name to lowercase, replaces spaces with hyphens, - removes special characters, and adds a random 6-character suffix - to ensure uniqueness. - - Args: - name: The connection name to convert - - Returns: - A URL-safe readable identifier (e.g., "stripe-connection-ab123") - """ - # Convert to lowercase and replace spaces with hyphens - readable_id = name.lower().strip() - - # Replace any character that's not a letter, number, or space with nothing - readable_id = re.sub(r"[^a-z0-9\s]", "", readable_id) - # Replace spaces with hyphens - readable_id = re.sub(r"\s+", "-", readable_id) - # Ensure no consecutive hyphens - readable_id = re.sub(r"-+", "-", readable_id) - # Trim hyphens from start and end - readable_id = readable_id.strip("-") - - # Add random alphanumeric suffix - suffix = "".join(random.choices(string.ascii_lowercase + string.digits, k=6)) - readable_id = f"{(readable_id + '-') if readable_id else ''}{suffix}" - - return readable_id - - class ConnectionBase(BaseModel): """Base schema for connections.""" diff --git a/backend/pyproject.toml b/backend/pyproject.toml index 854635ecc..d61f8e0d3 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -157,6 +157,7 @@ select = [ "B", # flake8-bugbear "D", # flake8-docstrings "PLC0415", # import-outside-top-level + "S311", # flake8-bandit: pseudo-random generators ] ignore = [ "B008", # ignore fastapi dependency injection warning @@ -171,6 +172,11 @@ convention = "google" "**/tests/**" = ["D100", "D101", "D102", "D103", "D107"] "**/fakes/**" = ["D102", "D107"] "**/fake.py" = ["D102", "D107"] +"**/sources/stub.py" = ["S311"] +"**/sources/file_stub.py" = ["S311"] +"**/sources/incremental_stub.py" = ["S311"] +"**/sources/timed.py" = ["S311"] +"**/search/providers/mistral.py" = ["S311"] [tool.ruff.format] quote-style = "double" diff --git a/frontend/eslint.config.js b/frontend/eslint.config.js index 8c6ac702a..630a5167a 100644 --- a/frontend/eslint.config.js +++ b/frontend/eslint.config.js @@ -28,6 +28,11 @@ export default tseslint.config( "@typescript-eslint/no-empty-object-type": "off", "react-refresh/only-export-components": "off", "react-hooks/exhaustive-deps": "off", + "no-restricted-properties": ["error", { + "object": "Math", + "property": "random", + "message": "Use crypto.getRandomValues() or crypto.randomUUID() instead of Math.random().", + }], }, } ); diff --git a/frontend/src/components/CollectionCreationModal.tsx b/frontend/src/components/CollectionCreationModal.tsx index ea4ef395b..eb5f945bf 100644 --- a/frontend/src/components/CollectionCreationModal.tsx +++ b/frontend/src/components/CollectionCreationModal.tsx @@ -4,6 +4,7 @@ import * as VisuallyHidden from '@radix-ui/react-visually-hidden'; import { useCollectionCreationStore } from '@/stores/collectionCreationStore'; import { useNavigate, useSearchParams } from 'react-router-dom'; import { cn } from '@/lib/utils'; +import { generateReadableId } from '@/lib/readable-id'; import { useTheme } from '@/lib/theme-provider'; import { X } from 'lucide-react'; import { toast } from 'sonner'; @@ -42,36 +43,7 @@ export const CollectionCreationModal: React.FC = () => { useEffect(() => { // Only generate for new collections, not when adding to existing if (!isAddingToExistingCollection() && collectionName) { - const generateHumanReadableId = (name: string) => { - // Convert to lowercase and trim - let readableId = name.toLowerCase().trim(); - - // Remove any character that's not a letter, number, or space - readableId = readableId.replace(/[^a-z0-9\s]/g, ''); - - // Replace spaces with hyphens - readableId = readableId.replace(/\s+/g, '-'); - - // Ensure no consecutive hyphens - readableId = readableId.replace(/-+/g, '-'); - - // Trim hyphens from start and end - readableId = readableId.replace(/^-+|-+$/g, ''); - - // Generate random 6-character alphanumeric suffix - const chars = 'abcdefghijklmnopqrstuvwxyz0123456789'; - let suffix = ''; - for (let i = 0; i < 6; i++) { - suffix += chars.charAt(Math.floor(Math.random() * chars.length)); - } - - // Combine with suffix - readableId = `${readableId}-${suffix}`; - - return readableId; - }; - - setHumanReadableId(generateHumanReadableId(collectionName)); + setHumanReadableId(generateReadableId(collectionName)); } else if (!isAddingToExistingCollection() && !collectionName) { // If collection name is empty, clear the readable ID setHumanReadableId(''); diff --git a/frontend/src/components/auth-providers/ConfigureAuthProviderView.tsx b/frontend/src/components/auth-providers/ConfigureAuthProviderView.tsx index 62f29b957..be93ebb38 100644 --- a/frontend/src/components/auth-providers/ConfigureAuthProviderView.tsx +++ b/frontend/src/components/auth-providers/ConfigureAuthProviderView.tsx @@ -5,6 +5,7 @@ import * as z from "zod"; import type { DialogViewProps } from "@/components/types/dialog"; import { useTheme } from "@/lib/theme-provider"; import { cn } from "@/lib/utils"; +import { generateRandomSuffix, generateReadableIdBase } from "@/lib/readable-id"; import { apiClient } from "@/lib/api"; import { useNavigate } from "react-router-dom"; import { toast } from "sonner"; @@ -19,49 +20,6 @@ import { TooltipTrigger, } from "@/components/ui/tooltip"; -/** - * Generates a random suffix for the readable ID - * This ensures uniqueness for similar connection names - * - * @returns Random alphanumeric string of length 6 - */ -const generateRandomSuffix = () => { - const chars = 'abcdefghijklmnopqrstuvwxyz0123456789'; - let result = ''; - for (let i = 0; i < 6; i++) { - result += chars.charAt(Math.floor(Math.random() * chars.length)); - } - return result; -}; - -/** - * Helper to generate the base readable ID from a name - * Transforms name to lowercase, replaces spaces with hyphens, and removes special characters - * - * @param name Connection name to transform - * @returns Sanitized base readable ID (without suffix) - */ -const generateReadableIdBase = (name: string): string => { - if (!name || name.trim() === "") return ""; - - // Convert to lowercase and replace spaces with hyphens - let readable_id = name.toLowerCase().trim(); - - // Replace any character that's not a letter, number, or space with nothing - readable_id = readable_id.replace(/[^a-z0-9\s]/g, ""); - - // Replace spaces with hyphens - readable_id = readable_id.replace(/\s+/g, "-"); - - // Ensure no consecutive hyphens - readable_id = readable_id.replace(/-+/g, "-"); - - // Trim hyphens from start and end - readable_id = readable_id.replace(/^-|-$/g, ""); - - return readable_id; -}; - export interface ConfigureAuthProviderViewProps extends DialogViewProps { viewData?: { authProviderName?: string; diff --git a/frontend/src/components/shared/views/CreateCollectionView.tsx b/frontend/src/components/shared/views/CreateCollectionView.tsx index 430916373..42c613d8f 100644 --- a/frontend/src/components/shared/views/CreateCollectionView.tsx +++ b/frontend/src/components/shared/views/CreateCollectionView.tsx @@ -24,9 +24,9 @@ import { useForm } from "react-hook-form"; import * as z from "zod"; import { Pencil, Info, Loader2 } from "lucide-react"; import { Button } from "@/components/ui/button"; -import { Input } from "@/components/ui/input"; import { useTheme } from "@/lib/theme-provider"; import { cn } from "@/lib/utils"; +import { generateRandomSuffix, generateReadableIdBase } from "@/lib/readable-id"; import { DialogTitle, DialogDescription, @@ -37,49 +37,6 @@ import { getAppIconUrl } from "@/lib/utils/icons"; import { useNavigate } from "react-router-dom"; import { redirectWithError } from "@/lib/error-utils"; -/** - * Generates a random suffix for the readable ID - * This ensures uniqueness for similar collection names - * - * @returns Random alphanumeric string of length 6 - */ -const generateRandomSuffix = () => { - const chars = 'abcdefghijklmnopqrstuvwxyz0123456789'; - let result = ''; - for (let i = 0; i < 6; i++) { - result += chars.charAt(Math.floor(Math.random() * chars.length)); - } - return result; -}; - -/** - * Helper to generate the base readable ID from a name - * Transforms name to lowercase, replaces spaces with hyphens, and removes special characters - * - * @param name Collection name to transform - * @returns Sanitized base readable ID (without suffix) - */ -const generateReadableIdBase = (name: string): string => { - if (!name || name.trim() === "") return ""; - - // Convert to lowercase and replace spaces with hyphens - let readable_id = name.toLowerCase().trim(); - - // Replace any character that's not a letter, number, or space with nothing - readable_id = readable_id.replace(/[^a-z0-9\s]/g, ""); - - // Replace spaces with hyphens - readable_id = readable_id.replace(/\s+/g, "-"); - - // Ensure no consecutive hyphens - readable_id = readable_id.replace(/-+/g, "-"); - - // Trim hyphens from start and end - readable_id = readable_id.replace(/^-|-$/g, ""); - - return readable_id; -}; - /** * Form validation schema using Zod * Defines validation rules for collection name and readable ID diff --git a/frontend/src/lib/readable-id.ts b/frontend/src/lib/readable-id.ts new file mode 100644 index 000000000..9930a857e --- /dev/null +++ b/frontend/src/lib/readable-id.ts @@ -0,0 +1,75 @@ +/** + * Shared readable ID generation using CSPRNG (crypto.getRandomValues). + */ + +const CHARS = "abcdefghijklmnopqrstuvwxyz0123456789"; + +/** + * Generate a cryptographically random alphanumeric suffix. + * + * @param length - Length of the suffix (default 6) + * @returns Random lowercase alphanumeric string + */ +export function generateRandomSuffix(length = 6): string { + const limit = CHARS.length; + // Largest multiple of `limit` that fits in a Uint32. Values at or + // above this threshold would produce modulo bias and are discarded. + const maxUnbiased = limit * Math.floor(0x100000000 / limit); + const buf = new Uint32Array(length); + let result = ""; + let filled = 0; + while (filled < length) { + crypto.getRandomValues(buf); + for (let j = 0; j < buf.length && filled < length; j++) { + if (buf[j] < maxUnbiased) { + result += CHARS[buf[j] % limit]; + filled++; + } + } + } + return result; +} + +/** + * Sanitize a name into a readable ID base (without suffix). + * + * Transforms to lowercase, replaces spaces with hyphens, strips + * special characters. + * + * @param name - Human-readable name to transform + * @returns Sanitized slug, or empty string if name is blank + */ +export function generateReadableIdBase(name: string): string { + if (!name || name.trim() === "") return ""; + + let readable_id = name.toLowerCase().trim(); + + // Remove characters that aren't letters, numbers, or spaces + readable_id = readable_id.replace(/[^a-z0-9\s]/g, ""); + + // Replace spaces with hyphens + readable_id = readable_id.replace(/\s+/g, "-"); + + // Collapse consecutive hyphens + readable_id = readable_id.replace(/-+/g, "-"); + + // Trim leading/trailing hyphens + readable_id = readable_id.replace(/^-|-$/g, ""); + + return readable_id; +} + +/** + * Generate a complete readable ID from a name. + * + * Combines the sanitized base with a random suffix, e.g. + * `"finance-data-ab12x9"`. + * + * @param name - Human-readable name + * @returns Full readable ID, or empty string if name is blank + */ +export function generateReadableId(name: string): string { + const base = generateReadableIdBase(name); + if (!base) return ""; + return `${base}-${generateRandomSuffix()}`; +} diff --git a/frontend/src/search/FilterBuilderModal.tsx b/frontend/src/search/FilterBuilderModal.tsx index c0d96a104..2afa449b6 100644 --- a/frontend/src/search/FilterBuilderModal.tsx +++ b/frontend/src/search/FilterBuilderModal.tsx @@ -16,7 +16,7 @@ import { DESIGN_SYSTEM } from "@/lib/design-system"; // ─── Helpers ────────────────────────────────────────────────────────────────── -const uid = () => Math.random().toString(36).slice(2, 9); +const uid = () => crypto.randomUUID(); // ─── Field & Operator definitions (mirror backend enums) ──────────────────────