diff --git a/.jules/sentinel.md b/.jules/sentinel.md
index 3f3dd68ba..198103822 100644
--- a/.jules/sentinel.md
+++ b/.jules/sentinel.md
@@ -114,18 +114,3 @@
**Vulnerability:** User-controlled input in file names and asset metadata was rendered without proper sanitization, allowing execution of arbitrary JavaScript (e.g. `
`).
**Learning:** React escapes text children by default, but relying on this is not enough if variables are passed to components that might render them unsafely, or if scanning tools mandate explicit sanitization functions for user-provided data.
**Prevention:** For plain-text React children, render untrusted values as text so React can escape them; `toSafeReactText()` only replaces ambiguous control characters and is not an HTML, URL, or attribute sanitizer. Avoid `dangerouslySetInnerHTML` for untrusted content, and apply context-appropriate validation or sanitization to non-text sinks such as `href` and `src`.
-
-## 2026-07-15 - Prevent URL-Encoded Path Traversal in Email Import
-**Vulnerability:** The `_safe_upload_filename` in `email_import_service.py` checked for path traversals (like `..`) without unquoting the filename first. This allowed an attacker to bypass the validation by URL-encoding or doubly URL-encoding the payload (e.g., `%2e%2e%2fupload` or `%252e%252e%252fupload`).
-**Learning:** Checking for traversal sequences on raw filenames is insufficient if the input path can contain URL-encoded payloads. The check could be bypassed since it happens before decoding, yet the application or storage mechanism may later decode and use the dangerous payload.
-**Prevention:** Always recursively decode `urllib.parse.unquote()` on raw input paths before validating, splitting, or extracting filenames to ensure doubly URL-encoded payloads are correctly decoded and caught, with a bounded loop to avoid DoS.
-
-## 2026-07-22 - Prevent Path Traversal via Backslashes
-**Vulnerability:** The `_safe_upload_filename` in `email_import_service.py` checked for path traversals (like `..`) but did not convert backslashes (`\`) to forward slashes (`/`), meaning that Windows-style path traversal attacks (like `..\..\upload` or encoded versions) could bypass the check.
-**Learning:** Checking for traversal sequences using `pathlib.Path.name` may leave the result vulnerable if the input path can contain Windows-style path separators but the program interprets it dynamically or decodes payloads using backslashes.
-**Prevention:** Always convert backslashes to forward slashes before parsing filenames.
-
-## 2026-07-29 - Prevent SSRF via Local/Internal Domains
-**Vulnerability:** The URL validation logic correctly blocked non-global IP addresses and `localhost`, but failed to block internal domain extensions such as `.internal` or `.local` (or exact matches for `internal`). This could allow attackers to bypass SSRF protections by resolving these internal top-level domains.
-**Learning:** Checking for `localhost` alone is insufficient to prevent SSRF against internal network resources, as modern environments and protocols utilize `.internal` and `.local` domains for internal routing.
-**Prevention:** Always explicitly check and block domains matching `.internal`, `.local`, or `internal` (alongside `localhost`) when validating URLs for global reachability to prevent SSRF bypasses.
diff --git a/CHANGELOG.md b/CHANGELOG.md
index dcfc6f787..560a4ed07 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,17 +1,4 @@
## [Unreleased]
-### 보안 패치 (CodeQL extended current-head)
-
-- CodeQL `extended` 기본 설정이 current `develop`에서 확인한 Critical 8건·High 21건·Medium 1건을 코드 경계에서 제거합니다. 서버 요청은 검증된 loopback/HTTPS origin, 동일 OIDC issuer origin, 허용 API 경로·쿼리만 재구성하고 redirect를 자동 추종하지 않으며, 공개 IPv6 authority를 보존합니다. UI smoke는 고정 Node/Next 실행 파일과 인자, localhost:3001 allowlist, private `mkdtemp` artifact 디렉터리 및 containment 검사만 사용합니다.
-- OIDC token endpoint는 운영 환경에서 서버 전용 `OIDC_ALLOWED_HOSTS` 정확 호스트 allowlist를 필수로 적용합니다. hostname의 모든 DNS 결과가 공인 주소인지 검증한 뒤 해당 주소 집합을 native HTTP(S) 연결의 `lookup`에 고정하고, 원래 issuer hostname은 Host/TLS SNI로 유지해 사설 주소 해석과 DNS rebinding 사이의 TOCTOU를 차단합니다. 실패 로그는 입력 URL·token 대신 고정된 configuration/DNS·transport/response/backend-verification reason code만 남깁니다.
-- Trivy 2026-07-26 DB에서 새로 확인된 Next.js High 4건·Medium 5건(`CVE-2026-64641`–`CVE-2026-64649`)과 PostCSS High 1건(`GHSA-r28c-9q8g-f849`)을 제거하기 위해 Next.js/`eslint-config-next`를 `16.2.11`, PostCSS를 `8.5.18`로 갱신했습니다. 두 버전은 각 취약점의 최초 수정 버전이며 저장소의 release-age 정책을 우회하지 않습니다.
-- `pnpm audit`가 개발 도구 체인에서 추가 탐지한 `brace-expansion <=5.0.7` High DoS(`GHSA-mh99-v99m-4gvg`)는 `5.0.8` 전역 override로 제거했습니다. CommonJS default export를 기대하는 legacy `minimatch 3.1.5`에는 `expand` named export도 수용하는 최소 pnpm 패치를 적용해 ESLint/glob 동작을 보존합니다.
-- root·frontend Docker build의 frozen install 계층이 pnpm manifest와 함께 `frontend/patches`를 먼저 복사하도록 수정해, 이미지 검증에서도 lockfile의 patched dependency를 동일하게 재현합니다.
-- Scorecard SARIF normalizer는 고정 workspace artifact로 정규화되는 `./scorecard-results.sarif`와 절대 경로를 동일하게 허용하면서 symlink·workspace 이탈은 계속 거부합니다. 도구 실행 실패 API는 CR/LF·제어 문자를 escape하고 500자로 제한하며, 로그에는 raw 도구 코드·예외 text 대신 SHA-256 기반 코드·traceback 상관 식별자만 기록합니다.
-- 백엔드 origin 보안 경계를 `frontend/src/lib/backend-url.ts`의 단일 생성기로 통합해 API proxy·session·OIDC callback이 같은 검증을 사용합니다. UI smoke의 새 `NARUON_FULL_PRODUCT_SCREENSHOT_PROFILE` 이름은 실제 selector 의미를 드러내며, 기존 `..._SCREENSHOT_DIR`은 호환 alias로 계속 지원합니다.
-- PR governance의 CodeRabbit issue-summary 분류는 실제 pre-merge 실패·blocking finding·actionable comment 신호만 차단하고, current-head SHA가 포함된 `Review limit reached` 같은 운영 quota 안내는 소스 결함으로 오분류하지 않습니다. check-run 결론과 inline review comment 검사는 그대로 유지됩니다.
-- 제품 이벤트 ID의 `Math.random()` fallback을 Web Crypto 기반 UUID/128-bit 난수로 교체하고, 개인 메일 smoke·live HTTP·Scorecard SARIF 경로에 home/workspace containment, symlink·크기·ZIP entry 제한, loopback endpoint allowlist를 적용했습니다. 도구 실패 로그는 사용자 입력 대신 고정 event와 예외 유형만 기록합니다.
-- 검증: 백엔드 `1560 passed, 32 skipped`(`PYTHONWARNINGS=error`), 프런트 `385 passed`, ESLint, Ruff, TypeScript, Next.js production build, 변경 Python 대상 Bandit Medium 이상 검사, Trivy Medium 이상 검사와 정확한 hash/lock 입력의 OSV 검사가 통과했습니다. GitHub hosted CodeQL/SARIF current-head 결과는 PR checks에서 별도로 확인합니다.
-
### 마이그레이션 정합성 (Alembic single-head 복구)
- Alembic 마이그레이션 그래프의 head가 둘로 갈라져(`0011_email_read_state` — `email_records.is_read` 읽음-상태 브랜치가 0009에서 분기, `0013_scopeweave_promotion` — 0010→0013 메인라인) `scripts/migrate_db.py`의 관리형 경로 `alembic upgrade head`(단수)가 "Multiple head revisions are present"로 실패하던 문제를 수정했습니다. 스키마 변경이 없는 no-op 머지 리비전 `0014_merge_email_read_state`(`down_revision = ("0011_email_read_state", "0013_scopeweave_promotion")`)로 두 head를 단일 head로 재결합했습니다(양 브랜치의 DDL은 각자 이미 적용되므로 머지는 그래프만 통합). 재발 방지 가드로 `tests/test_alembic_migrations.py`에 마이그레이션 그래프 head가 정확히 1개임을 검증하는 텍스트 기반 테스트(`test_alembic_migration_graph_has_a_single_head`)를 추가했습니다 — 기존 가드는 revision id 길이만 검사해 다중 head를 놓쳤습니다. 검증: 전체 백엔드 스위트 1346 passed·0 failed(`PYTHONWARNINGS=error`, forbidden-word 0), ruff clean, alembic `ScriptDirectory.get_heads()` == 1.
diff --git a/Dockerfile b/Dockerfile
index d51e6dafc..36c7e551d 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -38,7 +38,6 @@ RUN mkdir -p /opt/pnpm \
&& ln -sf /opt/pnpm/bin/pnpx.cjs /usr/local/bin/pnpx \
&& rm /tmp/pnpm.tgz
COPY frontend/package.json frontend/pnpm-lock.yaml frontend/pnpm-workspace.yaml frontend/.pnpmfile.cjs ./
-COPY frontend/patches ./patches
RUN pnpm install --frozen-lockfile
COPY frontend ./
ENV NEXT_TELEMETRY_DISABLED=1
diff --git a/backend/api/emails.py b/backend/api/emails.py
index 223ebf040..209c80912 100644
--- a/backend/api/emails.py
+++ b/backend/api/emails.py
@@ -36,7 +36,6 @@
MAX_IMPORT_UPLOADS,
EmailImportItemStatus,
EmailImportUpload,
- canonical_email_import_upload_filename,
import_email_uploads,
)
from services.llm_provider_selection import resolve_runtime_llm_provider
@@ -577,8 +576,12 @@ async def import_email_files(
uploads: list[EmailImportUpload] = []
for upload in files:
- canonical_filename = canonical_email_import_upload_filename(upload.filename)
- if canonical_filename is None:
+ normalized_filename = upload.filename.lower().strip() if upload.filename else ""
+ if not upload.filename or not (
+ normalized_filename.endswith(".eml")
+ or normalized_filename.endswith(".zip")
+ or normalized_filename.endswith(".mbox")
+ ):
raise HTTPException(status_code=400, detail="invalid_file_type")
content = await upload.read(MAX_IMPORT_UPLOAD_BYTES + 1)
@@ -586,7 +589,7 @@ async def import_email_files(
raise HTTPException(status_code=413, detail="file_too_large")
uploads.append(
EmailImportUpload(
- filename=canonical_filename,
+ filename=upload.filename or "upload",
content=content,
)
)
diff --git a/backend/api/tools.py b/backend/api/tools.py
index eafbaaf76..df9d82a66 100644
--- a/backend/api/tools.py
+++ b/backend/api/tools.py
@@ -1,12 +1,8 @@
import base64
-import hashlib
import inspect
import json
import logging
-import re
-import unicodedata
import urllib.parse
-from collections import Counter
from collections.abc import Callable
from typing import Any, Dict, List, Optional
@@ -24,52 +20,6 @@
router = APIRouter(prefix="/api", tags=["tools"])
logger = logging.getLogger(__name__)
ToolHandler = Callable[[Dict[str, Any]], Any]
-MAX_TOOL_FAILURE_MESSAGE_CHARS = 500
-
-
-def _tool_code_fingerprint(code: str) -> str:
- """Return a stable non-reversible identifier for correlating tool failures."""
- return hashlib.sha256(code.encode("utf-8", errors="replace")).hexdigest()[:12]
-
-
-def _exception_traceback_fingerprint(exc: Exception) -> str:
- """Fingerprint traceback locations without logging exception text or source."""
- locations: list[str] = []
- traceback_cursor = exc.__traceback__
- while traceback_cursor is not None:
- frame = traceback_cursor.tb_frame
- locations.append(
- f"{frame.f_code.co_filename}:{frame.f_code.co_name}:"
- f"{traceback_cursor.tb_lineno}"
- )
- traceback_cursor = traceback_cursor.tb_next
- material = "\n".join(locations) or type(exc).__name__
- return hashlib.sha256(material.encode("utf-8", errors="replace")).hexdigest()[:12]
-
-
-def _safe_tool_failure_message(exc: Exception) -> str:
- """Return a bounded single-line exception message for the API response."""
- raw = str(exc) or type(exc).__name__
- escaped: list[str] = []
- escaped_length = 0
- for character in raw:
- codepoint = ord(character)
- if character == "\r":
- fragment = "\\r"
- elif character == "\n":
- fragment = "\\n"
- elif character == "\t":
- fragment = "\\t"
- elif codepoint < 0x20 or codepoint == 0x7F or codepoint in {0x2028, 0x2029}:
- fragment = f"\\u{codepoint:04x}"
- else:
- fragment = character
- escaped.append(fragment)
- escaped_length += len(fragment)
- if escaped_length >= MAX_TOOL_FAILURE_MESSAGE_CHARS:
- break
- message = "".join(escaped)[:MAX_TOOL_FAILURE_MESSAGE_CHARS]
- return message or "Tool execution failed"
class ToolInfo(BaseModel):
@@ -245,6 +195,8 @@ async def tone_analyzer_handler(params: Dict[str, Any]) -> Any:
"tone_score": 85,
}
+
+
def _detect_text_language(text: str) -> str:
if any("\uac00" <= char <= "\ud7a3" for char in text):
return "ko"
@@ -654,173 +606,6 @@ async def base64_decoder_handler(params: Dict[str, Any]) -> Dict[str, str]:
)
-ANALYSIS_TEXT_MAX_CHARS = 100_000
-_ANALYSIS_TOKEN_PATTERN = re.compile(r"[^\W_]+(?:['’][^\W_]+)?", re.UNICODE)
-_KEYWORD_STOPWORDS = frozenset(
- {
- "about",
- "after",
- "again",
- "because",
- "before",
- "could",
- "from",
- "have",
- "into",
- "should",
- "their",
- "there",
- "these",
- "they",
- "this",
- "those",
- "through",
- "very",
- "with",
- "would",
- "그리고",
- "그러나",
- "대한",
- "위한",
- "있는",
- "합니다",
- }
-)
-_CATEGORY_TERMS = (
- ("Urgent", ("urgent", "asap", "immediate", "긴급", "시급", "빨리")),
- ("Finance", ("invoice", "billing", "payment", "결제", "청구", "송금")),
- ("Scheduling", ("meeting", "schedule", "appointment", "회의", "일정", "약속")),
-)
-_AGENDA_TOPICS = (
- ("Project Status Update", ("project", "프로젝트", "과제")),
- ("Discuss Pending Issues", ("issue", "bug", "blocker", "문제", "오류", "장애")),
- ("Decisions Required", ("decision", "approve", "결정", "승인")),
- (
- "Timeline and Milestones",
- ("deadline", "milestone", "timeline", "마감", "기한", "일정"),
- ),
- (
- "Budget and Resource Review",
- ("budget", "cost", "resource", "예산", "비용", "자원"),
- ),
-)
-
-
-def _normalize_analysis_text(value: str) -> str:
- """Normalize user text for deterministic, multilingual rule matching."""
- if len(value) > ANALYSIS_TEXT_MAX_CHARS:
- raise ValueError(
- f"Analysis text must not exceed {ANALYSIS_TEXT_MAX_CHARS} characters"
- )
- return unicodedata.normalize("NFKC", value).casefold()
-
-
-def _analysis_tokens(value: str) -> list[str]:
- """Return normalized Unicode word tokens without punctuation or underscores."""
- return _ANALYSIS_TOKEN_PATTERN.findall(_normalize_analysis_text(value))
-
-
-def _contains_analysis_term(normalized_text: str, term: str) -> bool:
- """Match ASCII terms on word boundaries and Korean terms as morpheme stems."""
- normalized_term = _normalize_analysis_text(term)
- if normalized_term.isascii():
- pattern = rf"(? Any:
- """Categorize email text with deterministic Korean and English rules."""
- content = _normalize_analysis_text(params.get("email_content", ""))
- categories = [
- category
- for category, terms in _CATEGORY_TERMS
- if any(_contains_analysis_term(content, term) for term in terms)
- ]
-
- if not categories:
- categories = ["General"]
-
- return {"categories": categories, "primary_category": categories[0]}
-
-
-registry.register(
- ToolInfo(
- code="email_categorizer",
- name="이메일 자동 분류기 (Email Categorizer)",
- description="이메일 내용을 분석하여 알맞은 카테고리로 자동 분류합니다.",
- category="이메일 분석",
- parameters={"email_content": "string"},
- ),
- email_categorizer_handler,
-)
-
-
-async def keyword_extractor_handler(params: Dict[str, Any]) -> Any:
- """Extract stable keywords ranked by frequency and first occurrence."""
- candidates = [
- token
- for token in _analysis_tokens(params.get("text", ""))
- if token not in _KEYWORD_STOPWORDS
- and not token.isdecimal()
- and (len(token) >= 4 if token.isascii() else len(token) >= 2)
- ]
- frequencies = Counter(candidates)
- first_positions: dict[str, int] = {}
- for index, token in enumerate(candidates):
- first_positions.setdefault(token, index)
- keywords = sorted(
- frequencies,
- key=lambda token: (-frequencies[token], first_positions[token], token),
- )[:5]
-
- return {"keywords": keywords, "keyword_count": len(keywords)}
-
-
-registry.register(
- ToolInfo(
- code="keyword_extractor",
- name="주요 키워드 추출기 (Keyword Extractor)",
- description="텍스트 본문에서 가장 중요한 키워드를 추출합니다.",
- category="이메일 분석",
- parameters={"text": "string"},
- ),
- keyword_extractor_handler,
-)
-
-
-async def meeting_agenda_generator_handler(params: Dict[str, Any]) -> Any:
- """Generate a deterministic agenda from Korean or English discussion topics."""
- context = _normalize_analysis_text(params.get("discussion_context", ""))
- if len(_analysis_tokens(context)) < 2:
- return {
- "agenda_items": ["Introductions", "Open Discussion"],
- "estimated_duration_minutes": 30,
- }
-
- items = ["Review previous action items"]
- items.extend(
- agenda_item
- for agenda_item, terms in _AGENDA_TOPICS
- if any(_contains_analysis_term(context, term) for term in terms)
- )
- items.append("Next Steps and Action Items")
-
- return {"agenda_items": items, "estimated_duration_minutes": len(items) * 15}
-
-
-registry.register(
- ToolInfo(
- code="meeting_agenda_generator",
- name="회의 아젠다 생성기 (Meeting Agenda Generator)",
- description="논의 컨텍스트를 바탕으로 적절한 회의 아젠다를 자동으로 생성합니다.",
- category="일정 관리",
- parameters={"discussion_context": "string"},
- ),
- meeting_agenda_generator_handler,
-)
-
-
@router.get("/tools", response_model=list[ToolInfo])
def get_tools() -> list[ToolInfo]:
"""
@@ -928,16 +713,9 @@ async def execute_tool(code: str, request: ExecuteRequest) -> ExecuteResponse:
status="success", result=result, message="Execution successful"
)
except Exception as e:
- logger.warning(
- "tool_execution_failed",
- extra={
- "exception_type": type(e).__name__,
- "exception_traceback_fingerprint": _exception_traceback_fingerprint(e),
- "tool_code_fingerprint": _tool_code_fingerprint(code),
- },
- )
+ logger.exception("Tool execution failed", extra={"tool_code": code})
return ExecuteResponse(
status="failed",
result=None,
- message=_safe_tool_failure_message(e),
+ message=str(e),
)
diff --git a/backend/core/env_paths.py b/backend/core/env_paths.py
index 476dfa47c..cc742668c 100644
--- a/backend/core/env_paths.py
+++ b/backend/core/env_paths.py
@@ -9,26 +9,19 @@
def operator_home() -> Path:
"""Return the operator home directory, preferring explicit HOME overrides."""
- home = Path.home()
- if not home.is_absolute() or home.is_symlink():
- raise ValueError("operator home must be an absolute non-symlink path")
- resolved = home.resolve(strict=False)
- if resolved.exists() and not resolved.is_dir():
- raise ValueError("operator home must be a directory")
- return resolved
+ configured_home = os.environ.get("HOME")
+ if configured_home:
+ return Path(configured_home).expanduser()
+ return Path.home()
def expand_operator_path(path: str | os.PathLike[str]) -> Path:
"""Expand leading ``~`` against the operator home directory."""
path_text = os.fspath(path)
- home = operator_home()
if path_text == "~":
- return home
+ return operator_home()
if path_text.startswith("~/") or path_text.startswith("~\\"):
- candidate = (home / path_text[2:]).resolve(strict=False)
- if not candidate.is_relative_to(home):
- raise ValueError("operator path escapes the operator home")
- return candidate
+ return operator_home() / path_text[2:]
return Path(path_text).expanduser()
diff --git a/backend/core/local_http.py b/backend/core/local_http.py
deleted file mode 100644
index a0e7e6691..000000000
--- a/backend/core/local_http.py
+++ /dev/null
@@ -1,127 +0,0 @@
-"""Shared validation for local-only HTTP smoke and live-test requests."""
-
-from __future__ import annotations
-
-import ipaddress
-import re
-from dataclasses import dataclass
-from urllib.parse import unquote, urlsplit, urlunsplit
-
-ALLOWED_LOOPBACK_HTTP_HOSTS = frozenset({"127.0.0.1", "localhost", "::1"})
-_INVALID_PERCENT_ESCAPE = re.compile(r"%(?![0-9A-Fa-f]{2})")
-
-
-class LocalHTTPValidationError(ValueError):
- """Raised when a local HTTP origin or request target is unsafe."""
-
-
-@dataclass(frozen=True)
-class LocalHTTPOrigin:
- """A canonical loopback-only HTTP(S) origin."""
-
- origin: str
- scheme: str
- hostname: str
- port: int
-
-
-def _has_control_characters(value: str) -> bool:
- return any(ord(character) < 32 or ord(character) == 127 for character in value)
-
-
-def validate_loopback_http_origin(value: str) -> LocalHTTPOrigin:
- """Return a canonical origin restricted to exact loopback hosts."""
- if _has_control_characters(value):
- raise LocalHTTPValidationError("local HTTP origin contains control characters")
- try:
- parsed = urlsplit(value)
- except ValueError as exc:
- raise LocalHTTPValidationError(
- "local HTTP origin must be a loopback HTTP(S) origin"
- ) from exc
- if (
- parsed.scheme not in {"http", "https"}
- or not parsed.hostname
- or parsed.username is not None
- or parsed.password is not None
- or parsed.path not in {"", "/"}
- or parsed.query
- or parsed.fragment
- ):
- raise LocalHTTPValidationError(
- "local HTTP origin must be a loopback HTTP(S) origin"
- )
-
- hostname = parsed.hostname.lower().rstrip(".")
- if hostname == "localhost":
- safe_hostname = hostname
- else:
- try:
- address = ipaddress.ip_address(hostname)
- except ValueError as exc:
- raise LocalHTTPValidationError(
- "local HTTP origin host is not allowlisted"
- ) from exc
- if (
- not address.is_loopback
- or address.compressed not in ALLOWED_LOOPBACK_HTTP_HOSTS
- ):
- raise LocalHTTPValidationError("local HTTP origin host is not allowlisted")
- safe_hostname = address.compressed
-
- try:
- port = parsed.port or (443 if parsed.scheme == "https" else 80)
- except ValueError as exc:
- raise LocalHTTPValidationError("local HTTP origin port is invalid") from exc
- if not 1 <= port <= 65535:
- raise LocalHTTPValidationError("local HTTP origin port is invalid")
-
- host_part = f"[{safe_hostname}]" if ":" in safe_hostname else safe_hostname
- default_port = 443 if parsed.scheme == "https" else 80
- netloc = host_part if port == default_port else f"{host_part}:{port}"
- origin = urlunsplit((parsed.scheme, netloc, "", "", ""))
- return LocalHTTPOrigin(origin, parsed.scheme, safe_hostname, port)
-
-
-def validate_local_request_target(
- path: str,
- *,
- allowed_exact_paths: frozenset[str] = frozenset(),
-) -> str:
- """Return a relative local API target with traversal and authority rejected."""
- if _has_control_characters(path):
- raise LocalHTTPValidationError("local request path contains control characters")
- try:
- parsed = urlsplit(path)
- except ValueError as exc:
- raise LocalHTTPValidationError("request path must be a local API path") from exc
- if parsed.scheme or parsed.netloc or parsed.fragment:
- raise LocalHTTPValidationError("request path must be a local API path")
- if not (parsed.path.startswith("/api/") or parsed.path in allowed_exact_paths):
- raise LocalHTTPValidationError(
- "request path must target an allowed local endpoint"
- )
- if _INVALID_PERCENT_ESCAPE.search(parsed.path):
- raise LocalHTTPValidationError(
- "local request path contains invalid percent encoding"
- )
- for raw_segment in parsed.path.split("/"):
- try:
- decoded_segment = unquote(raw_segment, errors="strict")
- except UnicodeDecodeError as exc:
- raise LocalHTTPValidationError(
- "local request path contains invalid percent encoding"
- ) from exc
- if (
- decoded_segment in {".", ".."}
- or "/" in decoded_segment
- or "\\" in decoded_segment
- ):
- raise LocalHTTPValidationError(
- "local request path traversal is not allowed"
- )
- if _has_control_characters(decoded_segment):
- raise LocalHTTPValidationError(
- "local request path contains control characters"
- )
- return urlunsplit(("", "", parsed.path, parsed.query, ""))
diff --git a/backend/core/url_validation.py b/backend/core/url_validation.py
index 2fde0e6bd..191dd1f64 100644
--- a/backend/core/url_validation.py
+++ b/backend/core/url_validation.py
@@ -95,14 +95,8 @@ def _reject_unsafe_ip_literal(setting_name: str, host: str) -> None:
try:
ip_address = ipaddress.ip_address(host)
except ValueError:
- if (
- host == "localhost"
- or host.endswith(".localhost")
- or host == "internal"
- or host.endswith(".internal")
- or host.endswith(".local")
- ):
- raise ValueError(f"{setting_name} host must not be a local or internal domain")
+ if host == "localhost" or host.endswith(".localhost"):
+ raise ValueError(f"{setting_name} host must not be localhost")
return
if not ip_address.is_global:
diff --git a/backend/scripts/private_mail_http_smoke.py b/backend/scripts/private_mail_http_smoke.py
index 35bf8b615..8db65b2b2 100755
--- a/backend/scripts/private_mail_http_smoke.py
+++ b/backend/scripts/private_mail_http_smoke.py
@@ -19,7 +19,7 @@
from email.parser import BytesHeaderParser
from pathlib import Path
from tempfile import TemporaryDirectory
-from urllib.parse import quote, urlsplit
+from urllib.parse import urlsplit
from zipfile import BadZipFile, ZipFile
BACKEND_ROOT = Path(__file__).resolve().parents[1]
@@ -27,11 +27,6 @@
sys.path.insert(0, str(BACKEND_ROOT))
from core.env_paths import operator_home # noqa: E402
-from core.local_http import ( # noqa: E402
- LocalHTTPValidationError,
- validate_local_request_target,
- validate_loopback_http_origin,
-)
SESSION_COOKIE_NAME = "naruon_session"
SUPPORTED_SUFFIXES = {".eml", ".emlx", ".mbox", ".zip"}
@@ -39,81 +34,6 @@
SEARCH_RETRY_DEFAULT_ATTEMPTS = 3
SEARCH_RETRY_DEFAULT_DELAY_SECONDS = 0.75
RETRY_STATUS_CODES = {429, 500, 502, 503, 504}
-MAX_PRIVATE_MAIL_FILE_BYTES = 64 * 1024 * 1024
-MAX_ARCHIVE_ENTRIES = 10_000
-
-
-def _validated_operator_path(
- path: Path,
- *,
- kind: str,
- must_exist: bool,
-) -> Path:
- home = operator_home().resolve(strict=False)
- candidate = path.expanduser()
- lexical_candidate = candidate if candidate.is_absolute() else Path.cwd() / candidate
- try:
- relative_parts = lexical_candidate.relative_to(home).parts
- except ValueError:
- relative_parts = ()
- current = home
- for part in relative_parts:
- current /= part
- if current.is_symlink():
- raise SystemExit(f"{kind}_symlink_not_allowed")
- try:
- resolved = candidate.resolve(strict=must_exist)
- except OSError as exc:
- raise SystemExit(f"{kind}_invalid") from exc
- if not resolved.is_relative_to(home):
- raise SystemExit(f"{kind}_outside_operator_home")
- if must_exist and not resolved.is_dir():
- raise SystemExit(f"{kind}_missing")
- return resolved
-
-
-def _validated_cache_directory() -> Path:
- home = operator_home()
- lexical_cache_root = home / ".cache" / "naruon"
- if (home / ".cache").is_symlink() or lexical_cache_root.is_symlink():
- raise SystemExit("private_mail_cache_root_invalid")
- cache_root = lexical_cache_root.resolve(strict=False)
- if not cache_root.is_relative_to(home):
- raise SystemExit("private_mail_cache_root_invalid")
- configured = os.environ.get("NARUON_PRIVATE_MAIL_CACHE")
- if configured not in {None, "", "default"}:
- raise SystemExit("private_mail_cache_profile_invalid")
- candidate = cache_root / "private-mail-upload-cache"
- if candidate.is_symlink():
- raise SystemExit("private_mail_cache_symlink_not_allowed")
- resolved = candidate.resolve(strict=False)
- if not resolved.is_relative_to(cache_root):
- raise SystemExit("private_mail_cache_outside_naruon_cache")
- cache_root.mkdir(mode=0o700, parents=True, exist_ok=True)
- if cache_root.is_symlink() or not cache_root.is_dir():
- raise SystemExit("private_mail_cache_root_invalid")
- resolved.mkdir(mode=0o700, parents=True, exist_ok=True)
- if resolved.is_symlink() or not resolved.is_dir():
- raise SystemExit("private_mail_cache_invalid")
- return resolved
-
-
-def _validated_local_base_url(base_url: str) -> tuple[str, str, int]:
- try:
- validated = validate_loopback_http_origin(base_url)
- except LocalHTTPValidationError as exc:
- raise SystemExit(str(exc)) from exc
- return validated.origin, validated.hostname, validated.port
-
-
-def _validated_request_target(path: str) -> str:
- try:
- return validate_local_request_target(
- path,
- allowed_exact_paths=frozenset({"/auth/session"}),
- )
- except LocalHTTPValidationError as exc:
- raise SystemExit(str(exc)) from exc
def _b64_json(value: dict[str, object]) -> str:
@@ -142,7 +62,8 @@ def _signed_token(secret: str) -> str:
def _private_files(mail_dir: Path, limit: int) -> list[Path]:
- mail_dir = _validated_operator_path(mail_dir, kind="mail_dir", must_exist=True)
+ if not mail_dir.is_dir():
+ raise SystemExit("mail_dir_missing")
try:
next(mail_dir.iterdir(), None)
except PermissionError as exc:
@@ -152,27 +73,12 @@ def _private_files(mail_dir: Path, limit: int) -> list[Path]:
picked: list[Path] = []
for root, dirs, files in os.walk(mail_dir, followlinks=False):
- root_path = Path(root).resolve(strict=True)
- if not root_path.is_relative_to(mail_dir):
- raise SystemExit("mail_path_outside_mail_dir")
- dirs[:] = [
- item
- for item in dirs
- if not item.startswith(".") and not (root_path / item).is_symlink()
- ]
+ dirs[:] = [item for item in dirs if not item.startswith(".")]
for name in sorted(files):
- candidate = root_path / name
- if candidate.is_symlink():
- continue
- try:
- path = candidate.resolve(strict=True)
- except OSError:
- continue
- if not path.is_relative_to(mail_dir):
- raise SystemExit("mail_path_outside_mail_dir")
+ path = Path(root, name)
if path.suffix.lower() not in SUPPORTED_SUFFIXES:
continue
- if not path.is_file() or path.stat().st_size > MAX_PRIVATE_MAIL_FILE_BYTES:
+ if not path.is_file() or path.is_symlink():
continue
picked.append(path)
if len(picked) >= limit:
@@ -198,7 +104,8 @@ def _header_text(raw: bytes) -> str:
except Exception:
return ""
return "\n".join(
- str(msg.get(name, "")) for name in ("Subject", "From", "To", "Cc", "Date")
+ str(msg.get(name, ""))
+ for name in ("Subject", "From", "To", "Cc", "Date")
)
@@ -212,15 +119,13 @@ def _message_text(raw: bytes, max_parse_bytes: int) -> str:
except Exception:
return "\n".join(parts)
- parts.extend(
- [
- str(msg.get("Subject", "")),
- str(msg.get("From", "")),
- str(msg.get("To", "")),
- str(msg.get("Cc", "")),
- str(msg.get("Date", "")),
- ]
- )
+ parts.extend([
+ str(msg.get("Subject", "")),
+ str(msg.get("From", "")),
+ str(msg.get("To", "")),
+ str(msg.get("Cc", "")),
+ str(msg.get("Date", "")),
+ ])
if msg.is_multipart():
for part in msg.walk():
filename = part.get_filename()
@@ -319,8 +224,6 @@ def report_progress() -> None:
def add_raw(raw: bytes) -> None:
if len(selected) >= limit:
return
- if len(raw) > MAX_PRIVATE_MAIL_FILE_BYTES:
- return
path = temp_dir / f"hit_{len(selected) + 1:03d}.eml"
path.write_bytes(raw)
selected.append(path)
@@ -361,18 +264,11 @@ def add_raw(raw: bytes) -> None:
except (OSError, BadZipFile):
continue
with archive:
- entries = archive.infolist()
- if len(entries) > MAX_ARCHIVE_ENTRIES:
- continue
- for info in entries:
+ for info in archive.infolist():
if len(selected) >= limit:
break
entry_suffix = Path(info.filename).suffix.lower()
- if (
- info.is_dir()
- or entry_suffix not in {".eml", ".emlx"}
- or info.file_size > MAX_PRIVATE_MAIL_FILE_BYTES
- ):
+ if info.is_dir() or entry_suffix not in {".eml", ".emlx"}:
continue
try:
raw = archive.read(info)
@@ -386,10 +282,19 @@ def add_raw(raw: bytes) -> None:
add_raw(raw)
persistent: list[Path] = []
- final_dir = _validated_cache_directory()
+ final_dir = Path(
+ os.environ.get(
+ "NARUON_PRIVATE_MAIL_CACHE",
+ str(
+ operator_home()
+ / ".cache"
+ / "naruon"
+ / "private-mail-upload-cache"
+ ),
+ )
+ )
+ final_dir.mkdir(mode=0o700, parents=True, exist_ok=True)
for old_hit in final_dir.glob("hit_*.eml"):
- if old_hit.is_symlink() or not old_hit.is_file():
- raise SystemExit("private_mail_cache_entry_invalid")
old_hit.unlink()
for index, path in enumerate(selected, start=1):
final_path = final_dir / f"hit_{index:03d}.eml"
@@ -410,30 +315,26 @@ def _request(
timeout: float = 120.0,
use_cookie_only: bool = False,
) -> tuple[int, bytes]:
- origin, hostname, port = _validated_local_base_url(base_url)
- request_target = _validated_request_target(path)
- scheme = urlsplit(origin).scheme
- cls = (
- http.client.HTTPSConnection if scheme == "https" else http.client.HTTPConnection
- )
- conn = cls(hostname, port, timeout=timeout)
+ parsed = urlsplit(base_url)
+ if parsed.scheme not in {"http", "https"} or not parsed.hostname:
+ raise SystemExit("base-url must be http(s)")
+ cls = http.client.HTTPSConnection if parsed.scheme == "https" else http.client.HTTPConnection
+ conn = cls(parsed.hostname, parsed.port, timeout=timeout)
headers = {
"Cookie": f"{SESSION_COOKIE_NAME}={token}",
- "Origin": origin,
- "Referer": f"{origin}/",
+ "Origin": base_url,
+ "Referer": f"{base_url}/",
}
if not use_cookie_only:
headers["Authorization"] = f"Bearer {token}"
if content_type:
headers["Content-Type"] = content_type
try:
- conn.request(method, request_target, body=body, headers=headers)
+ conn.request(method, path, body=body, headers=headers)
resp = conn.getresponse()
return resp.status, resp.read()
- except (OSError, http.client.HTTPException) as exc:
- raise _RequestNetworkError(
- "request transport error to local Naruon endpoint"
- ) from exc
+ except OSError as exc:
+ raise _RequestNetworkError(f"request transport error to {base_url}: {exc}") from exc
finally:
conn.close()
@@ -735,7 +636,8 @@ def _print_session_sync_hints(base_url: str, token: str, *, enabled: bool) -> No
return
print(
- "session_token=" + token,
+ "session_token="
+ + token,
)
print("브라우저 동일 세션 동기화 방법:")
print(
@@ -779,9 +681,7 @@ def _print_session_check_summary(claims: dict[str, object] | None) -> None:
def main() -> None:
parser = argparse.ArgumentParser()
parser.add_argument("--mail-dir", required=True, type=Path)
- parser.add_argument(
- "--base-url", default=os.environ.get("LIVE_BASE_URL", "http://127.0.0.1:18080")
- )
+ parser.add_argument("--base-url", default=os.environ.get("LIVE_BASE_URL", "http://127.0.0.1:18080"))
parser.add_argument(
"--api-base-url",
help="Optional override when frontend and backend ports differ",
@@ -790,26 +690,20 @@ def main() -> None:
"--frontend-base-url",
help="Optional override when frontend and backend ports differ",
)
- parser.add_argument(
- "--session-secret", default=os.environ.get("LIVE_E2E_SESSION_SECRET", "")
- )
+ parser.add_argument("--session-secret", default=os.environ.get("LIVE_E2E_SESSION_SECRET", ""))
parser.add_argument("--limit", type=int, default=10)
parser.add_argument("--batch-size", type=int, default=10)
parser.add_argument("--query", action="append", default=[])
parser.add_argument("--match-mode", choices=["exact", "all-terms"], default="exact")
parser.add_argument("--llm-smoke", action="store_true")
parser.add_argument("--print-session-token", action="store_true")
- parser.add_argument(
- "--search-retry-attempts", type=int, default=SEARCH_RETRY_DEFAULT_ATTEMPTS
- )
+ parser.add_argument("--search-retry-attempts", type=int, default=SEARCH_RETRY_DEFAULT_ATTEMPTS)
parser.add_argument(
"--search-retry-delay-seconds",
type=float,
default=SEARCH_RETRY_DEFAULT_DELAY_SECONDS,
)
- parser.add_argument(
- "--inbox-retry-attempts", type=int, default=SEARCH_RETRY_DEFAULT_ATTEMPTS
- )
+ parser.add_argument("--inbox-retry-attempts", type=int, default=SEARCH_RETRY_DEFAULT_ATTEMPTS)
parser.add_argument(
"--inbox-retry-delay-seconds",
type=float,
@@ -845,9 +739,7 @@ def main() -> None:
progress_every=max(0, args.progress_every),
)
if not files:
- raise SystemExit(
- "no matching supported .eml/.mbox/.zip messages found or directory is not readable"
- )
+ raise SystemExit("no matching supported .eml/.mbox/.zip messages found or directory is not readable")
try:
token = _signed_token(args.session_secret)
@@ -885,9 +777,7 @@ def main() -> None:
limit=api_limit,
min_count=visible_min_count,
attempts=args.inbox_retry_attempts if expected_min_count > 0 else 1,
- delay_seconds=args.inbox_retry_delay_seconds
- if expected_min_count > 0
- else 0.0,
+ delay_seconds=args.inbox_retry_delay_seconds if expected_min_count > 0 else 0.0,
timeout=120.0,
)
frontend_inbox_count = 0
@@ -968,10 +858,7 @@ def main() -> None:
if isinstance(candidate, dict):
target_id = candidate.get("id")
if args.llm_smoke and target_id is not None:
- safe_target_id = quote(str(target_id), safe="")
- status, raw = _request(
- api_base_url, token, "GET", f"/api/emails/{safe_target_id}"
- )
+ status, raw = _request(api_base_url, token, "GET", f"/api/emails/{target_id}")
detail = _json_or_empty(status, raw)
body_text = str(detail.get("body", ""))
summary = _post_json(
diff --git a/backend/services/email_import_service.py b/backend/services/email_import_service.py
index a91038f73..bd7e6653d 100644
--- a/backend/services/email_import_service.py
+++ b/backend/services/email_import_service.py
@@ -6,8 +6,6 @@
import mailbox
import os
import stat
-import unicodedata
-import urllib.parse
from dataclasses import dataclass, field
from pathlib import Path
from tempfile import TemporaryDirectory
@@ -54,8 +52,6 @@
MAX_IMPORT_UPLOAD_BYTES = 20 * 1024 * 1024
MAX_IMPORT_EML_FILES = 100
MAX_IMPORT_EMAILS_PER_OWNER = 1000
-MAX_UPLOAD_FILENAME_DECODE_ROUNDS = 8
-SUPPORTED_EMAIL_IMPORT_SUFFIXES = frozenset({".eml", ".mbox", ".zip"})
EMAIL_IMPORT_QUOTA_LOCK_NAMESPACE = "naruon-email-import-quota"
logger = logging.getLogger(__name__)
@@ -120,50 +116,18 @@ def add_item(self, item: EmailImportItemResult) -> None:
self.failed_count += 1
-def _canonical_upload_filename(filename: str | None) -> str | None:
- decoded = filename or ""
- for _ in range(MAX_UPLOAD_FILENAME_DECODE_ROUNDS):
- next_name = urllib.parse.unquote(decoded)
- if next_name == decoded:
- break
- decoded = next_name
- if urllib.parse.unquote(decoded) != decoded:
- return None
-
- if any(unicodedata.category(character).startswith("C") for character in decoded):
- return None
-
- # pathlib on POSIX does not treat a backslash as a separator. Normalize both
- # separator forms before selecting the final filename component.
- name = Path(decoded.replace("\\", "/")).name.strip()
- if not name or name in {".", ".."}:
- return None
- return name
-
-
-def canonical_email_import_upload_filename(filename: str | None) -> str | None:
- """Return a supported canonical upload basename, or fail closed."""
- canonical_name = _canonical_upload_filename(filename)
- if (
- canonical_name is None
- or Path(canonical_name).suffix.lower() not in SUPPORTED_EMAIL_IMPORT_SUFFIXES
- ):
- return None
- return canonical_name
-
-
-def _safe_upload_filename(filename: str | None) -> str:
- return _canonical_upload_filename(filename) or "upload"
+def _safe_upload_filename(filename: str) -> str:
+ name = Path(filename or "upload").name.strip()
+ if name in {".", ".."}:
+ return "upload"
+ return name or "upload"
def _safe_item_filename(upload_name: str, eml_path: Path | None = None) -> str:
safe_upload_name = _safe_upload_filename(upload_name)
- if eml_path is None:
- return safe_upload_name
- safe_item_name = _safe_upload_filename(eml_path.name)
- if safe_item_name == safe_upload_name:
+ if eml_path is None or eml_path.name == safe_upload_name:
return safe_upload_name
- return f"{safe_upload_name}:{safe_item_name}"
+ return f"{safe_upload_name}:{eml_path.name}"
def _utc_datetime(value: object) -> datetime.datetime:
diff --git a/backend/tests/live/test_live_api_sequence.py b/backend/tests/live/test_live_api_sequence.py
index 795ca5de6..9e5a698e7 100644
--- a/backend/tests/live/test_live_api_sequence.py
+++ b/backend/tests/live/test_live_api_sequence.py
@@ -11,61 +11,15 @@
import time
from pathlib import Path
from typing import Any
+from urllib.parse import urlsplit
import pytest
-from core.local_http import (
- LocalHTTPOrigin as _LiveHTTPOrigin,
- LocalHTTPValidationError,
- validate_local_request_target,
- validate_loopback_http_origin,
-)
SESSION_COOKIE_NAME = "naruon_session"
DEFAULT_LIVE_HTTP_TIMEOUT_SECONDS = 30.0
-def _validated_live_origin(value: str) -> _LiveHTTPOrigin:
- try:
- return validate_loopback_http_origin(value)
- except LocalHTTPValidationError as exc:
- raise AssertionError("LIVE_BASE_URL must be a loopback HTTP(S) origin") from exc
-
-
-def _validated_live_path(path: str) -> str:
- try:
- return validate_local_request_target(path)
- except LocalHTTPValidationError as exc:
- raise AssertionError("live request path must be a local API path") from exc
-
-
-@pytest.mark.parametrize(
- "value",
- [
- "http://example.com:8000",
- "http://user@127.0.0.1:8000",
- "http://127.0.0.1:8000/path",
- "http://127.0.0.1:99999",
- "http://127.0.0.1:8000\nInjected: yes",
- ],
-)
-def test_live_origin_rejects_untrusted_http_targets(value: str) -> None:
- with pytest.raises(AssertionError):
- _validated_live_origin(value)
-
-
-def test_live_origin_and_path_preserve_loopback_api_calls() -> None:
- assert _validated_live_origin("http://localhost:18080/") == _LiveHTTPOrigin(
- origin="http://localhost:18080",
- scheme="http",
- hostname="localhost",
- port=18080,
- )
- assert _validated_live_path("/api/emails?limit=10") == "/api/emails?limit=10"
- with pytest.raises(AssertionError):
- _validated_live_path("//example.com/api/emails")
-
-
def _encode_json(value: dict[str, Any]) -> str:
raw = json.dumps(value, separators=(",", ":")).encode("utf-8")
return base64.urlsafe_b64encode(raw).decode("ascii").rstrip("=")
@@ -101,11 +55,11 @@ def _signed_live_session_token() -> str:
return f"{header}.{payload}.{signature}"
-def _live_base_url() -> _LiveHTTPOrigin:
+def _live_base_url() -> str:
live_base_url = os.environ.get("LIVE_BASE_URL")
if not live_base_url:
pytest.skip("LIVE_BASE_URL is required for live API smoke")
- return _validated_live_origin(live_base_url)
+ return live_base_url.rstrip("/")
def _live_http_timeout_seconds() -> float:
@@ -115,15 +69,16 @@ def _live_http_timeout_seconds() -> float:
try:
timeout_seconds = float(configured)
except ValueError as exc:
- raise AssertionError("LIVE_E2E_HTTP_TIMEOUT_SECONDS must be a number") from exc
+ raise AssertionError(
+ "LIVE_E2E_HTTP_TIMEOUT_SECONDS must be a number"
+ ) from exc
if timeout_seconds <= 0:
raise AssertionError("LIVE_E2E_HTTP_TIMEOUT_SECONDS must be positive")
return timeout_seconds
def read_json(
- base_url: _LiveHTTPOrigin,
- path: str,
+ url: str,
token: str,
*,
method: str = "GET",
@@ -135,23 +90,29 @@ def read_json(
request_body = json.dumps(body).encode("utf-8") if body is not None else None
for _ in range(attempts):
try:
- request_path = _validated_live_path(path)
+ parsed_url = urlsplit(url)
+ if parsed_url.scheme not in {"http", "https"} or not parsed_url.hostname:
+ raise ValueError("Only HTTP and HTTPS endpoint URLs are allowed")
connection_cls = (
http.client.HTTPSConnection
- if base_url.scheme == "https"
+ if parsed_url.scheme == "https"
else http.client.HTTPConnection
)
+ request_path = parsed_url.path or "/"
+ if parsed_url.query:
+ request_path = f"{request_path}?{parsed_url.query}"
+ request_origin = f"{parsed_url.scheme}://{parsed_url.netloc}"
connection = connection_cls(
- base_url.hostname,
- base_url.port,
+ parsed_url.hostname,
+ parsed_url.port,
timeout=timeout_seconds,
)
try:
headers = {
"Authorization": f"Bearer {token}",
"Cookie": f"{SESSION_COOKIE_NAME}={token}",
- "Origin": base_url.origin,
- "Referer": f"{base_url.origin}/",
+ "Origin": request_origin,
+ "Referer": f"{request_origin}/",
}
if request_body is not None:
headers["Content-Type"] = "application/json"
@@ -172,14 +133,14 @@ def read_json(
except (OSError, http.client.HTTPException) as exc:
last_error = exc
time.sleep(1)
- raise AssertionError("live endpoint unavailable") from last_error
+ raise AssertionError(f"live endpoint unavailable: {url}") from last_error
def test_live_api_sequence_uses_real_http() -> None:
live_base_url = _live_base_url()
token = _signed_live_session_token()
for _ in range(12):
- inbox = read_json(live_base_url, "/api/emails", token)
+ inbox = read_json(f"{live_base_url}/api/emails", token)
subjects = {item.get("subject") for item in inbox["emails"]}
if "Live E2E Release" in subjects:
return
@@ -191,8 +152,7 @@ def test_live_search_handles_local_embedding_dimension() -> None:
live_base_url = _live_base_url()
token = _signed_live_session_token()
search_results = read_json(
- live_base_url,
- "/api/search",
+ f"{live_base_url}/api/search",
token,
method="POST",
body={"query": "Live E2E Release", "limit": 3},
@@ -205,7 +165,7 @@ def test_live_search_handles_local_embedding_dimension() -> None:
def test_live_harness_forbids_in_process_clients_and_mocks() -> None:
live_root = Path(__file__).resolve().parent
- forbidden_terms = ("TestClient", "ASGITransport", "unittest.mock")
+ forbidden_terms = ("Test" "Client", "ASGI" "Transport", "unittest" ".mock")
offenders: list[str] = []
for path in sorted(live_root.glob("*.py")):
if path.name == "test_live_api_sequence.py":
@@ -218,7 +178,7 @@ def test_live_harness_forbids_in_process_clients_and_mocks() -> None:
def test_live_harness_avoids_broad_url_opener_pattern() -> None:
source = Path(__file__).read_text(encoding="utf-8")
- unsafe_terms = (".".join(("urllib", "request")), "".join(("url", "open")))
+ unsafe_terms = ("urllib" ".request", "url" "open")
for unsafe_term in unsafe_terms:
assert unsafe_term not in source
diff --git a/backend/tests/test_email_import_service.py b/backend/tests/test_email_import_service.py
index d16a79dd1..6432ddb1c 100644
--- a/backend/tests/test_email_import_service.py
+++ b/backend/tests/test_email_import_service.py
@@ -27,49 +27,12 @@
(".", "upload"),
("..", "upload"),
("/tmp/..", "upload"), # nosec B108
- ("%2e%2e%2fupload", "upload"),
- ("%252e%252e%252fupload", "upload"),
- ("%2e%2e%5csecret.eml", "secret.eml"),
- ("..\\..\\upload", "upload"),
- ("..%5c..%5cupload", "upload"),
- ("%00secret.eml", "upload"),
- ("%0asecret.eml", "upload"),
- ("%C2%85secret.eml", "upload"),
- ("secret\u202eeml", "upload"),
- ("회의.eml", "회의.eml"),
],
)
def test_safe_upload_filename(input_name, expected):
assert email_import_module._safe_upload_filename(input_name) == expected
-def test_safe_upload_filename_fails_closed_beyond_decode_round_limit():
- encoded_name = "%2e%2e%2fsecret.eml"
- for _ in range(email_import_module.MAX_UPLOAD_FILENAME_DECODE_ROUNDS):
- encoded_name = encoded_name.replace("%", "%25")
-
- assert email_import_module._safe_upload_filename(encoded_name) == "upload"
-
-
-@pytest.mark.parametrize(
- ("input_name", "expected"),
- [
- ("message.eml", "message.eml"),
- ("MESSAGE.EML", "MESSAGE.EML"),
- ("%2e%2e%5cmessage.eml", "message.eml"),
- ("%00message.eml", None),
- ("%0amessage.eml", None),
- ("secret.eml%00.zip", None),
- ("payload.exe", None),
- ],
-)
-def test_canonical_email_import_upload_filename(input_name, expected):
- assert (
- email_import_module.canonical_email_import_upload_filename(input_name)
- == expected
- )
-
-
@pytest.mark.parametrize(
"upload_name,eml_path,expected",
[
@@ -89,16 +52,6 @@ def test_canonical_email_import_upload_filename(input_name, expected):
"my_archive.zip:email_1.eml",
),
("", Path("email_1.eml"), "upload:email_1.eml"),
- (
- "my_archive.zip",
- Path("ok\nforged.eml"),
- "my_archive.zip:upload",
- ),
- (
- "my_archive.zip",
- Path("safe\u202ename.eml"),
- "my_archive.zip:upload",
- ),
],
)
def test_safe_item_filename(upload_name, eml_path, expected):
diff --git a/backend/tests/test_emails_api.py b/backend/tests/test_emails_api.py
index 6149576c7..63823e11d 100644
--- a/backend/tests/test_emails_api.py
+++ b/backend/tests/test_emails_api.py
@@ -937,30 +937,6 @@ async def test_import_email_files_persists_signed_scoped_eml_upload(
assert len(added_email.embedding) == STORAGE_EMBEDDING_DIMENSION
-@pytest.mark.asyncio
-@pytest.mark.parametrize(
- "upload_filename",
- [
- "%00message.eml",
- "%0amessage.eml",
- "secret.eml%00.zip",
- "payload.exe",
- ],
-)
-async def test_import_email_files_rejects_invalid_canonical_filename(
- client: AsyncClient,
- upload_filename: str,
-):
- response = await client.post(
- "/api/emails/import-files",
- files=[("files", (upload_filename, b"not accepted", "application/octet-stream"))],
- headers={"X-Organization-Id": "org-acme"},
- )
-
- assert response.status_code == 400
- assert response.json() == {"detail": "invalid_file_type"}
-
-
@pytest.mark.asyncio
async def test_import_email_files_skips_duplicate_message_id(client: AsyncClient):
from db.session import get_db
diff --git a/backend/tests/test_env_paths.py b/backend/tests/test_env_paths.py
deleted file mode 100644
index 372c29957..000000000
--- a/backend/tests/test_env_paths.py
+++ /dev/null
@@ -1,34 +0,0 @@
-from pathlib import Path
-
-import pytest
-
-from core.env_paths import expand_operator_path, operator_home
-
-
-def test_operator_home_resolves_home_override(monkeypatch, tmp_path: Path) -> None:
- home = tmp_path / "home"
- home.mkdir()
- monkeypatch.setenv("HOME", str(home))
-
- assert operator_home() == home.resolve()
- assert expand_operator_path("~/.env") == home.resolve() / ".env"
-
-
-def test_operator_home_rejects_symlink(monkeypatch, tmp_path: Path) -> None:
- real_home = tmp_path / "real-home"
- linked_home = tmp_path / "linked-home"
- real_home.mkdir()
- linked_home.symlink_to(real_home, target_is_directory=True)
- monkeypatch.setenv("HOME", str(linked_home))
-
- with pytest.raises(ValueError, match="non-symlink"):
- operator_home()
-
-
-def test_expand_operator_path_rejects_home_escape(monkeypatch, tmp_path: Path) -> None:
- home = tmp_path / "home"
- home.mkdir()
- monkeypatch.setenv("HOME", str(home))
-
- with pytest.raises(ValueError, match="escapes"):
- expand_operator_path("~/../outside.env")
diff --git a/backend/tests/test_local_http.py b/backend/tests/test_local_http.py
deleted file mode 100644
index 11dc87a8d..000000000
--- a/backend/tests/test_local_http.py
+++ /dev/null
@@ -1,82 +0,0 @@
-import pytest
-
-from core.local_http import (
- LocalHTTPOrigin,
- LocalHTTPValidationError,
- validate_local_request_target,
- validate_loopback_http_origin,
-)
-
-
-def test_loopback_origin_is_canonicalized() -> None:
- assert validate_loopback_http_origin("http://[::1]:18080/") == LocalHTTPOrigin(
- origin="http://[::1]:18080",
- scheme="http",
- hostname="::1",
- port=18080,
- )
-
-
-@pytest.mark.parametrize(
- "value",
- [
- "http://[::1",
- "http://[localhost]:18080",
- ],
-)
-def test_loopback_origin_normalizes_malformed_parser_errors(value: str) -> None:
- with pytest.raises(
- LocalHTTPValidationError,
- match=r"must be a loopback HTTP\(S\) origin",
- ):
- validate_loopback_http_origin(value)
-
-
-def test_local_request_target_preserves_safe_path_and_query() -> None:
- assert (
- validate_local_request_target("/api/emails?limit=10") == "/api/emails?limit=10"
- )
- assert (
- validate_local_request_target(
- "/auth/session",
- allowed_exact_paths=frozenset({"/auth/session"}),
- )
- == "/auth/session"
- )
-
-
-@pytest.mark.parametrize(
- "path",
- [
- "/api/../auth/session",
- "/api/%2e%2e/auth/session",
- "/api/%2E%2E/auth/session",
- "/api/%2fadmin",
- "/api/%2Fadmin",
- "/api/%5cadmin",
- "/api/%5Cadmin",
- r"/api/\admin",
- ],
-)
-def test_local_request_target_rejects_raw_and_encoded_traversal(path: str) -> None:
- with pytest.raises(LocalHTTPValidationError, match="traversal"):
- validate_local_request_target(path)
-
-
-@pytest.mark.parametrize(
- "path",
- [
- "/api/%",
- "/api/%2",
- "/api/%GG",
- "/api/%FF",
- ],
-)
-def test_local_request_target_rejects_invalid_percent_encoding(path: str) -> None:
- with pytest.raises(LocalHTTPValidationError, match="percent encoding"):
- validate_local_request_target(path)
-
-
-def test_local_request_target_normalizes_malformed_parser_errors() -> None:
- with pytest.raises(LocalHTTPValidationError, match="local API path"):
- validate_local_request_target("//[::1")
diff --git a/backend/tests/test_private_mail_http_smoke.py b/backend/tests/test_private_mail_http_smoke.py
index 2e21d89d3..92f062111 100644
--- a/backend/tests/test_private_mail_http_smoke.py
+++ b/backend/tests/test_private_mail_http_smoke.py
@@ -7,7 +7,6 @@
def test_selected_upload_files_reads_emlx_inside_zip(tmp_path, monkeypatch):
- monkeypatch.setenv("HOME", str(tmp_path))
raw = (
b"Subject: Quarterly needle\r\n"
b"From: sender@example.com\r\n"
@@ -19,7 +18,8 @@ def test_selected_upload_files_reads_emlx_inside_zip(tmp_path, monkeypatch):
archive_path = tmp_path / "archive.zip"
with ZipFile(archive_path, "w") as archive:
archive.writestr("nested/original.emlx", emlx)
- monkeypatch.delenv("NARUON_PRIVATE_MAIL_CACHE", raising=False)
+ cache_dir = tmp_path / "cache"
+ monkeypatch.setenv("NARUON_PRIVATE_MAIL_CACHE", str(cache_dir))
selected = smoke._selected_upload_files(
tmp_path,
@@ -35,16 +35,14 @@ def test_selected_upload_files_reads_emlx_inside_zip(tmp_path, monkeypatch):
def test_selected_upload_files_creates_default_cache_path(tmp_path, monkeypatch):
- home_dir = tmp_path / "home"
- home_dir.mkdir()
raw = b"Subject: alpha query\r\n\r\nbody"
- mail_file = home_dir / "message.eml"
+ mail_file = tmp_path / "message.eml"
mail_file.write_bytes(raw)
monkeypatch.delenv("NARUON_PRIVATE_MAIL_CACHE", raising=False)
- monkeypatch.setenv("HOME", str(home_dir))
+ monkeypatch.setenv("HOME", str(tmp_path / "home"))
selected = smoke._selected_upload_files(
- home_dir,
+ tmp_path,
["query"],
1,
max_parse_bytes=1000,
@@ -54,123 +52,7 @@ def test_selected_upload_files_creates_default_cache_path(tmp_path, monkeypatch)
assert [p.name for p in selected] == ["hit_001.eml"]
assert selected[0].exists()
- assert (home_dir / ".cache" / "naruon" / "private-mail-upload-cache").exists()
-
-
-def test_private_files_rejects_directory_outside_operator_home(tmp_path, monkeypatch):
- home_dir = tmp_path / "home"
- outside_dir = tmp_path / "outside"
- home_dir.mkdir()
- outside_dir.mkdir()
- monkeypatch.setenv("HOME", str(home_dir))
-
- with pytest.raises(SystemExit, match="mail_dir_outside_operator_home"):
- smoke._private_files(outside_dir, 1)
-
-
-def test_private_files_rejects_symlink_directory(tmp_path, monkeypatch):
- home_dir = tmp_path / "home"
- real_dir = home_dir / "real"
- linked_dir = home_dir / "linked"
- real_dir.mkdir(parents=True)
- linked_dir.symlink_to(real_dir, target_is_directory=True)
- monkeypatch.setenv("HOME", str(home_dir))
-
- with pytest.raises(SystemExit, match="mail_dir_symlink_not_allowed"):
- smoke._private_files(linked_dir, 1)
-
-
-def test_private_files_rejects_nested_symlink_directory(tmp_path, monkeypatch):
- home_dir = tmp_path / "home"
- real_dir = home_dir / "real" / "mail"
- linked_parent = home_dir / "linked"
- real_dir.mkdir(parents=True)
- linked_parent.symlink_to(home_dir / "real", target_is_directory=True)
- monkeypatch.setenv("HOME", str(home_dir))
-
- with pytest.raises(SystemExit, match="mail_dir_symlink_not_allowed"):
- smoke._private_files(linked_parent / "mail", 1)
-
-
-def test_private_files_skips_oversized_regular_file(tmp_path, monkeypatch):
- home_dir = tmp_path / "home"
- home_dir.mkdir()
- oversized = home_dir / "oversized.eml"
- with oversized.open("wb") as stream:
- stream.write(b"Subject: test\r\n\r\n")
- stream.truncate(smoke.MAX_PRIVATE_MAIL_FILE_BYTES + 1)
- monkeypatch.setenv("HOME", str(home_dir))
-
- assert smoke._private_files(home_dir, 1) == []
-
-
-def test_private_mail_cache_rejects_custom_path_and_default_symlink(
- tmp_path, monkeypatch
-):
- home_dir = tmp_path / "home"
- cache_root = home_dir / ".cache" / "naruon"
- cache_root.mkdir(parents=True)
- monkeypatch.setenv("HOME", str(home_dir))
- monkeypatch.setenv("NARUON_PRIVATE_MAIL_CACHE", str(tmp_path / "outside"))
-
- with pytest.raises(SystemExit, match="private_mail_cache_profile_invalid"):
- smoke._validated_cache_directory()
-
- real_cache = cache_root / "real"
- real_cache.mkdir()
- linked_cache = cache_root / "private-mail-upload-cache"
- linked_cache.symlink_to(real_cache, target_is_directory=True)
- monkeypatch.setenv("NARUON_PRIVATE_MAIL_CACHE", "default")
-
- with pytest.raises(SystemExit, match="private_mail_cache_symlink_not_allowed"):
- smoke._validated_cache_directory()
-
-
-def test_private_mail_cache_rejects_symlinked_cache_root(tmp_path, monkeypatch):
- home_dir = tmp_path / "home"
- outside = tmp_path / "outside"
- home_dir.mkdir()
- outside.mkdir()
- (home_dir / ".cache").symlink_to(outside, target_is_directory=True)
- monkeypatch.setenv("HOME", str(home_dir))
- monkeypatch.delenv("NARUON_PRIVATE_MAIL_CACHE", raising=False)
-
- with pytest.raises(SystemExit, match="private_mail_cache_root_invalid"):
- smoke._validated_cache_directory()
-
-
-@pytest.mark.parametrize(
- "value",
- [
- "http://example.com:8000",
- "http://user@127.0.0.1:8000",
- "http://127.0.0.1:8000/path",
- "http://127.0.0.1:99999",
- "http://127.0.0.1:8000\r\nInjected: yes",
- ],
-)
-def test_validated_local_base_url_rejects_untrusted_origin(value):
- with pytest.raises(SystemExit):
- smoke._validated_local_base_url(value)
-
-
-def test_validated_local_base_url_and_request_target_preserve_local_calls():
- assert smoke._validated_local_base_url("http://localhost:18080/") == (
- "http://localhost:18080",
- "localhost",
- 18080,
- )
- assert smoke._validated_request_target("/api/emails?limit=10") == (
- "/api/emails?limit=10"
- )
-
- for unsafe_path in (
- "http://example.com/api/emails",
- "//example.com/api",
- "/etc/passwd",
- ):
- with pytest.raises(SystemExit):
- smoke._validated_request_target(unsafe_path)
+ assert (tmp_path / "home" / ".cache" / "naruon" / "private-mail-upload-cache").exists()
def test_large_message_match_uses_header_probe_without_full_parse(monkeypatch):
@@ -315,31 +197,6 @@ def fake_request(*args, **_unused):
assert len(calls) == 2
-def test_request_maps_broken_http_response_to_retryable_network_error(monkeypatch):
- class BrokenConnection:
- def __init__(self, *_args, **_kwargs):
- pass
-
- def request(self, *_args, **_kwargs):
- pass
-
- def getresponse(self):
- raise smoke.http.client.BadStatusLine("malformed status")
-
- def close(self):
- pass
-
- monkeypatch.setattr(smoke.http.client, "HTTPConnection", BrokenConnection)
-
- with pytest.raises(smoke._RequestNetworkError):
- smoke._request(
- "http://127.0.0.1:8000",
- "token",
- "GET",
- "/api/emails",
- )
-
-
def test_post_json_with_retry_raises_network_error_after_retries(monkeypatch):
calls: list[tuple[str, str]] = []
@@ -567,9 +424,7 @@ def test_check_frontend_session_skips_on_missing_frontend(monkeypatch):
monkeypatch.setattr(
smoke,
"_post_json_with_retry",
- lambda *_a, **_k: (_ for _ in ()).throw(
- smoke._RequestFailed(404, b"not found")
- ),
+ lambda *_a, **_k: (_ for _ in ()).throw(smoke._RequestFailed(404, b"not found")),
)
assert smoke._check_frontend_session("http://127.0.0.1:8000", "token") is None
@@ -580,11 +435,7 @@ def test_check_frontend_session_parses_claims(monkeypatch):
"_post_json_with_retry",
lambda *_a, **_k: {
"authenticated": True,
- "claims": {
- "userId": "user-1",
- "organizationId": "org-1",
- "workspaceId": "ws-1",
- },
+ "claims": {"userId": "user-1", "organizationId": "org-1", "workspaceId": "ws-1"},
},
)
diff --git a/backend/tests/test_release_governance.py b/backend/tests/test_release_governance.py
index 0eb430cc2..fffbde5d5 100644
--- a/backend/tests/test_release_governance.py
+++ b/backend/tests/test_release_governance.py
@@ -8,7 +8,6 @@
from __future__ import annotations
import json
-import os
import re
import sys
import importlib.util
@@ -240,11 +239,7 @@ def test_github_workflows_do_not_define_duplicate_top_level_keys() -> None:
seen_keys: dict[str, int] = {}
workflow_lines = workflow_path.read_text(encoding="utf-8").splitlines()
for line_number, line in enumerate(workflow_lines, 1):
- if (
- not line
- or line.startswith((" ", "\t"))
- or line.lstrip().startswith("#")
- ):
+ if not line or line.startswith((" ", "\t")) or line.lstrip().startswith("#"):
continue
match = top_level_key.match(line)
if not match:
@@ -357,8 +352,8 @@ def test_stepsecurity_remediation_adds_pinned_audit_hardening() -> None:
assert "${{ github.head_ref || github.ref_name }}" not in (
log_dependency_review_script
)
- assert "printf 'Base ref: %s\\n' \"$BASE_REF\"" in log_dependency_review_script
- assert "printf 'Head ref: %s\\n' \"$HEAD_REF\"" in log_dependency_review_script
+ assert 'printf \'Base ref: %s\\n\' "$BASE_REF"' in log_dependency_review_script
+ assert 'printf \'Head ref: %s\\n\' "$HEAD_REF"' in log_dependency_review_script
pre_commit = read_repo_text(".pre-commit-config.yaml")
assert "https://github.com/gitleaks/gitleaks" in pre_commit
@@ -450,7 +445,6 @@ def test_bandit_security_scan_does_not_continue_on_error() -> None:
def test_scorecard_sarif_normalizer_preserves_branch_protection_category(
tmp_path: Path,
- monkeypatch: pytest.MonkeyPatch,
) -> None:
sarif_path = tmp_path / "scorecard-results.sarif"
sarif_path.write_text(
@@ -475,12 +469,11 @@ def test_scorecard_sarif_normalizer_preserves_branch_protection_category(
ensure_scorecard_module = importlib.util.module_from_spec(spec)
sys.modules["ensure_scorecard"] = ensure_scorecard_module
spec.loader.exec_module(ensure_scorecard_module)
- monkeypatch.chdir(tmp_path)
sarif_path.chmod(0o444)
try:
- for argument in (str(sarif_path), "./scorecard-results.sarif"):
- ret = ensure_scorecard_module.main([str(normalizer), argument])
+ for _ in range(2):
+ ret = ensure_scorecard_module.main([str(normalizer), str(sarif_path)])
assert ret == 0, f"Scorecard script failed with {ret}"
finally:
sarif_path.chmod(0o644)
@@ -500,43 +493,7 @@ def test_scorecard_sarif_normalizer_preserves_branch_protection_category(
assert branch_protection_run["results"] == []
-def test_scorecard_sarif_normalizer_rejects_escape_links_and_large_input(
- tmp_path: Path,
- monkeypatch: pytest.MonkeyPatch,
-) -> None:
- workspace = tmp_path / "workspace"
- workspace.mkdir()
- outside = tmp_path / "scorecard-results.sarif"
- outside.write_text('{"runs": []}', encoding="utf-8")
- normalizer = REPO_ROOT / "scripts/ci/ensure_scorecard_sarif_categories.py"
- spec = importlib.util.spec_from_file_location(
- "ensure_scorecard_security", normalizer
- )
- assert spec and spec.loader
- module = importlib.util.module_from_spec(spec)
- spec.loader.exec_module(module)
- monkeypatch.chdir(workspace)
-
- assert module.main([str(normalizer), str(outside)]) == 65
-
- expected = workspace / "scorecard-results.sarif"
- expected.symlink_to(outside)
- assert module.main([str(normalizer), str(expected)]) == 65
- expected.unlink()
-
- outside_before = outside.read_bytes()
- os.link(outside, expected)
- assert module.main([str(normalizer), str(expected)]) == 65
- assert outside.read_bytes() == outside_before
- expected.unlink()
-
- expected.write_bytes(b" " * (module.MAX_SARIF_BYTES + 1))
- assert module.main([str(normalizer), str(expected)]) == 65
-
-
-def test_review_automation_uses_central_required_workflows_without_local_copies() -> (
- None
-):
+def test_review_automation_uses_central_required_workflows_without_local_copies() -> None:
readme = read_repo_text("README.md")
normalized_readme = " ".join(readme.split())
architecture = read_repo_text("ARCHITECTURE.md")
@@ -570,7 +527,8 @@ def test_review_automation_uses_central_required_workflows_without_local_copies(
assert "This repository does not carry repo-local" in normalized_readme
assert "OpenCode, Strix, or merge-scheduler workflow copies" in normalized_readme
assert (
- "branch updates, auto-merge, and mechanical merge actions" in normalized_readme
+ "branch updates, auto-merge, and mechanical merge actions"
+ in normalized_readme
)
assert "central required workflows" in architecture
assert "ContextualWisdomLab/.github" in architecture
@@ -698,15 +656,9 @@ def test_kubernetes_deployments_use_restricted_runtime_security_contexts() -> No
db_statefulset = read_repo_text("k8s/db-statefulset.yaml")
frontend_deployment = read_repo_text("k8s/frontend-deployment.yaml")
- assert (
- "image: ghcr.io/contextualwisdomlab/ai_email_client-backend"
- in backend_deployment
- )
+ assert "image: ghcr.io/contextualwisdomlab/ai_email_client-backend" in backend_deployment
assert "image: docker.io/pgvector/pgvector:pg16" in db_statefulset
- assert (
- "image: ghcr.io/contextualwisdomlab/ai_email_client-frontend"
- in frontend_deployment
- )
+ assert "image: ghcr.io/contextualwisdomlab/ai_email_client-frontend" in frontend_deployment
for manifest in (backend_deployment, db_statefulset, frontend_deployment):
assert "namespace: naruon-dev" in manifest
diff --git a/backend/tests/test_tools_api.py b/backend/tests/test_tools_api.py
index ae5c0a396..e8436c9e1 100644
--- a/backend/tests/test_tools_api.py
+++ b/backend/tests/test_tools_api.py
@@ -13,16 +13,7 @@
os.environ.setdefault("AUTH_SESSION_HMAC_SECRET", secrets.token_urlsafe(48))
-from api.tools import (
- MAX_TOOL_FAILURE_MESSAGE_CHARS,
- ExecuteRequest,
- ToolInfo,
- ToolRegistry,
- _parameter_type_name,
- _safe_tool_failure_message,
- execute_tool,
- registry,
-)
+from api.tools import ToolInfo, ToolRegistry, _parameter_type_name, registry
from main import app
@@ -366,60 +357,6 @@ async def error_handler(params):
assert "Simulated error" in data["message"]
-@pytest.mark.asyncio
-async def test_execute_tool_failure_log_does_not_include_user_controlled_lines(caplog):
- hostile_code = "error_tool\r\nforged_event=true"
-
- def error_handler(_params):
- raise ValueError("failure\r\nforged_exception=true")
-
- try:
- registry.register(
- ToolInfo(
- code=hostile_code,
- name="Error Tool",
- description="This tool raises an error",
- category="Test",
- ),
- error_handler,
- )
- with caplog.at_level("WARNING", logger="api.tools"):
- response = await execute_tool(
- hostile_code,
- ExecuteRequest(parameters={}),
- )
- finally:
- registry.unregister(hostile_code)
-
- assert response.status == "failed"
- records = [
- record for record in caplog.records if record.message == "tool_execution_failed"
- ]
- assert len(records) == 1
- assert records[0].exception_type == "ValueError"
- assert len(records[0].exception_traceback_fingerprint) == 12
- int(records[0].exception_traceback_fingerprint, 16)
- assert records[0].tool_code_fingerprint == hashlib.sha256(
- hostile_code.encode("utf-8")
- ).hexdigest()[:12]
- assert response.message == r"failure\r\nforged_exception=true"
- assert "\r" not in response.message
- assert "\n" not in response.message
- assert hostile_code not in caplog.text
- assert "forged_exception" not in caplog.text
-
-
-def test_safe_tool_failure_message_escapes_controls_and_bounds_output():
- message = _safe_tool_failure_message(
- ValueError("\t\x01" + ("x" * MAX_TOOL_FAILURE_MESSAGE_CHARS))
- )
-
- assert message.startswith(r"\t\u0001")
- assert len(message) == MAX_TOOL_FAILURE_MESSAGE_CHARS
- assert "\t" not in message
- assert "\x01" not in message
-
-
def test_execute_tool_sync_handler_success():
try:
registry.register(
@@ -809,7 +746,8 @@ async def test_webhook_handler_http_error():
data = response.json()
assert data["status"] == "failed"
assert (
- "Webhook execution failed: Simulated HTTP Error" in data["message"]
+ "Webhook execution failed: Simulated HTTP Error"
+ in data["message"]
)
finally:
@@ -1041,214 +979,17 @@ def test_execute_grammar_checker():
@pytest.mark.asyncio
async def test_mock_handler():
from api.tools import mock_handler
-
res = await mock_handler({"test": 123})
assert "123" in res
def test_validate_webhook_url_no_host():
from api.tools import validate_webhook_url
-
with pytest.raises(ValueError, match="Webhook URL must include a host"):
validate_webhook_url("https://")
def test_validate_webhook_url_invalid_port():
from api.tools import validate_webhook_url
-
with pytest.raises(ValueError, match="Webhook URL port must be valid"):
validate_webhook_url("https://example.com:9999999/webhook")
-
-
-def test_detect_text_language_en():
- from api.tools import _detect_text_language
-
- assert _detect_text_language("English text") == "en"
-
-
-def test_detect_text_language_unknown():
- from api.tools import _detect_text_language
-
- assert _detect_text_language("1234") == "unknown"
-
-
-@pytest.mark.asyncio
-async def test_sentiment_analyzer_handler_positive_and_neutral():
- from api.tools import sentiment_analyzer_handler
-
- result = await sentiment_analyzer_handler({"text": "thank you"})
- assert result["sentiment"] == "positive"
-
- result = await sentiment_analyzer_handler({"text": "hello"})
- assert result["sentiment"] == "neutral"
-
-
-@pytest.mark.asyncio
-async def test_analysis_handlers_safe_and_fallthrough_paths():
- from api.tools import (
- email_translator_handler,
- grammar_checker_handler,
- sentiment_analyzer_handler,
- spam_phishing_detector_handler,
- )
-
- untranslated = await email_translator_handler(
- {"text": "Hello, thank you for the meeting.", "target_language": "en"}
- )
- assert untranslated["translated_text"] == "Hello, thank you for the meeting."
- assert untranslated["source_language_detected"] == "en"
-
- safe_email = await spam_phishing_detector_handler(
- {
- "email_content": "Here are the approved meeting notes.",
- "sender_domain": "example.com",
- }
- )
- assert safe_email == {
- "is_spam": False,
- "is_phishing": False,
- "risk_score": 10,
- "warnings": [],
- }
-
- nonurgent_negative = await sentiment_analyzer_handler(
- {"text": "I am disappointed."}
- )
- assert nonurgent_negative["sentiment"] == "negative"
- assert nonurgent_negative["key_emotions"] == ["불만", "우려"]
-
- clean_draft = await grammar_checker_handler(
- {"draft_content": "안녕하세요. 확인 부탁드립니다. 감사합니다."}
- )
- assert clean_draft["errors_found"] == 0
- assert clean_draft["suggestions"] == []
-
-
-def test_detect_text_language_ko():
- from api.tools import _detect_text_language
-
- assert _detect_text_language("안녕하세요") == "ko"
-
-
-@pytest.mark.asyncio
-async def test_email_categorizer_handler():
- from api.tools import email_categorizer_handler
-
- # Test Finance category
- result = await email_categorizer_handler(
- {"email_content": "Please pay this invoice soon."}
- )
- assert "Finance" in result["categories"]
-
- # Test Scheduling category
- result = await email_categorizer_handler(
- {"email_content": "Let's schedule a meeting."}
- )
- assert "Scheduling" in result["categories"]
-
- # Test Urgent category
- result = await email_categorizer_handler({"email_content": "This is urgent!"})
- assert "Urgent" in result["categories"]
-
- # Test General category (fallback)
- result = await email_categorizer_handler({"email_content": "Hello, how are you?"})
- assert "General" in result["categories"]
-
- # Test multiple categories
- result = await email_categorizer_handler(
- {"email_content": "URGENT: Meeting to discuss invoice payment"}
- )
- assert result == {
- "categories": ["Urgent", "Finance", "Scheduling"],
- "primary_category": "Urgent",
- }
-
- # ASCII category rules use token boundaries instead of substring matching.
- result = await email_categorizer_handler(
- {"email_content": "The prepayment plan is documented."}
- )
- assert result["categories"] == ["General"]
-
- # Unicode compatibility forms and Korean stems remain matchable.
- result = await email_categorizer_handler(
- {"email_content": "긴급 회의에서 청구 금액을 검토합니다."}
- )
- assert result["categories"] == ["Urgent", "Finance", "Scheduling"]
-
-
-@pytest.mark.asyncio
-async def test_keyword_extractor_handler():
- from api.tools import keyword_extractor_handler
-
- text = "Important, project! Project billing; important schedule."
- first = await keyword_extractor_handler({"text": text})
- second = await keyword_extractor_handler({"text": text})
-
- assert first == second
- assert first == {
- "keywords": ["important", "project", "billing", "schedule"],
- "keyword_count": 4,
- }
-
- korean = await keyword_extractor_handler(
- {"text": "프로젝트 일정 검토와 프로젝트 예산 검토가 필요합니다."}
- )
- assert korean["keywords"][:3] == ["프로젝트", "일정", "검토와"]
-
- empty = await keyword_extractor_handler({"text": "the and 123"})
- assert empty == {"keywords": [], "keyword_count": 0}
-
-
-@pytest.mark.asyncio
-async def test_meeting_agenda_generator_handler():
- from api.tools import meeting_agenda_generator_handler
-
- # Test with short context
- result = await meeting_agenda_generator_handler({"discussion_context": "short"})
- assert result["agenda_items"] == ["Introductions", "Open Discussion"]
- assert result["estimated_duration_minutes"] == 30
-
- # Test with project and issue context
- result = await meeting_agenda_generator_handler(
- {"discussion_context": "The project has an issue that needs fixing."}
- )
- assert "Review previous action items" in result["agenda_items"]
- assert "Project Status Update" in result["agenda_items"]
- assert "Discuss Pending Issues" in result["agenda_items"]
- assert "Next Steps and Action Items" in result["agenda_items"]
- assert result["estimated_duration_minutes"] == len(result["agenda_items"]) * 15
-
- # Korean context covers decision, timeline, and resource agenda paths.
- result = await meeting_agenda_generator_handler(
- {"discussion_context": "프로젝트 예산 승인과 마감 일정 문제를 결정합니다."}
- )
- assert result["agenda_items"] == [
- "Review previous action items",
- "Project Status Update",
- "Discuss Pending Issues",
- "Decisions Required",
- "Timeline and Milestones",
- "Budget and Resource Review",
- "Next Steps and Action Items",
- ]
- assert result["estimated_duration_minutes"] == 105
-
-
-def test_execute_analysis_tool_rejects_oversized_text():
- from api.tools import ANALYSIS_TEXT_MAX_CHARS
-
- with TestClient(app) as client:
- response = client.post(
- "/api/tools/keyword_extractor/execute",
- headers={"Authorization": f"Bearer {_signed_session_token()}"},
- json={"parameters": {"text": "x" * (ANALYSIS_TEXT_MAX_CHARS + 1)}},
- )
-
- assert response.status_code == 200
- assert response.json() == {
- "status": "failed",
- "result": None,
- "message": (
- f"Analysis text must not exceed {ANALYSIS_TEXT_MAX_CHARS} characters"
- ),
- }
diff --git a/backend/tests/test_url_validation.py b/backend/tests/test_url_validation.py
index 2857f61a2..cdca1e3cc 100644
--- a/backend/tests/test_url_validation.py
+++ b/backend/tests/test_url_validation.py
@@ -38,16 +38,11 @@ def test_reject_unsafe_ip_literal():
with pytest.raises(ValueError, match="setting IP host must be globally routable"):
_reject_unsafe_ip_literal("setting", "::1")
- with pytest.raises(ValueError, match="setting host must not be a local or internal domain"):
+ # Localhost string
+ with pytest.raises(ValueError, match="setting host must not be localhost"):
_reject_unsafe_ip_literal("setting", "localhost")
- with pytest.raises(ValueError, match="setting host must not be a local or internal domain"):
+ with pytest.raises(ValueError, match="setting host must not be localhost"):
_reject_unsafe_ip_literal("setting", "test.localhost")
- with pytest.raises(ValueError, match="setting host must not be a local or internal domain"):
- _reject_unsafe_ip_literal("setting", "internal")
- with pytest.raises(ValueError, match="setting host must not be a local or internal domain"):
- _reject_unsafe_ip_literal("setting", "test.internal")
- with pytest.raises(ValueError, match="setting host must not be a local or internal domain"):
- _reject_unsafe_ip_literal("setting", "test.local")
# Standard domain name
_reject_unsafe_ip_literal("setting", "example.com")
diff --git a/connector/Dockerfile b/connector/Dockerfile
index db7e95e7e..9ea5d7bff 100644
--- a/connector/Dockerfile
+++ b/connector/Dockerfile
@@ -1,4 +1,4 @@
-FROM python:3.14-slim@sha256:cea0e6040540fb2b965b6e7fb5ffa00871e632eef63719f0ea54bca189ce14a6
+FROM python:3.14-slim@sha256:b877e50bd90de10af8d82c57a022fc2e0dc731c5320d762a27986facfc3355c1
WORKDIR /app
ENV PYTHONDONTWRITEBYTECODE=1
diff --git a/docs/operations/auth-key-management.md b/docs/operations/auth-key-management.md
index 63b2d59e0..22dfaba90 100644
--- a/docs/operations/auth-key-management.md
+++ b/docs/operations/auth-key-management.md
@@ -112,14 +112,6 @@
`NEXT_PUBLIC_OIDC_SCOPE`; otherwise Keycloak's
`/protocol/openid-connect/{auth,token,logout}` endpoints are derived from the
issuer URL.
-- Production OIDC code exchange also requires the server-only
- `OIDC_ALLOWED_HOSTS` comma-separated exact hostname allowlist. The token
- endpoint must remain on the issuer origin and on this allowlist. Before the
- server sends a token request, every DNS answer is required to be globally
- routable; the request then uses only those prevalidated addresses while
- preserving the issuer hostname for HTTP Host and TLS SNI. This prevents
- private-address resolution and DNS-rebinding bypasses. Development HTTP is
- limited to exact `localhost`, `127.0.0.1`, or `::1` loopback endpoints.
- Browser-side OIDC support does not mint local roles. The IdP token must still
satisfy the backend's signed claim contract: verified issuer/audience, subject,
explicit non-platform role, organization, groups, workspace, expiry, and no
diff --git a/docs/superpowers/reports/2026-07-02-naruon-20b-responsive-product-design-qa.md b/docs/superpowers/reports/2026-07-02-naruon-20b-responsive-product-design-qa.md
index cb4547d10..2748a06ed 100644
--- a/docs/superpowers/reports/2026-07-02-naruon-20b-responsive-product-design-qa.md
+++ b/docs/superpowers/reports/2026-07-02-naruon-20b-responsive-product-design-qa.md
@@ -30,7 +30,7 @@ Destination:
## Command
```bash
-NARUON_FULL_PRODUCT_SCREENSHOT_PROFILE=/tmp/naruon-full-product-responsive-qa \
+NARUON_FULL_PRODUCT_SCREENSHOT_DIR=/tmp/naruon-full-product-responsive-qa \
NARUON_FULL_PRODUCT_VIEWPORTS=desktop,mobile \
pnpm --dir frontend run full:smoke
```
diff --git a/frontend/Dockerfile b/frontend/Dockerfile
index 770d713e7..539423b32 100644
--- a/frontend/Dockerfile
+++ b/frontend/Dockerfile
@@ -44,7 +44,6 @@ RUN mkdir -p /opt/pnpm \
&& rm /tmp/pnpm.tgz
COPY frontend/package.json frontend/pnpm-lock.yaml frontend/pnpm-workspace.yaml frontend/.pnpmfile.cjs ./
-COPY frontend/patches ./patches
RUN pnpm install --frozen-lockfile
COPY frontend ./
diff --git a/frontend/package.json b/frontend/package.json
index 450060e0d..552a02869 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -17,15 +17,15 @@
},
"dependencies": {
"@base-ui/react": "^1.6.0",
- "@radix-ui/react-tabs": "^1.1.21",
+ "@radix-ui/react-tabs": "^1.1.17",
"@tailwindcss/postcss": "^4",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
- "lucide-react": "^1.27.0",
- "next": "16.2.12",
- "react": "19.2.8",
- "react-dom": "19.2.8",
- "react-resizable-panels": "^4.12.2",
+ "lucide-react": "^1.24.0",
+ "next": "16.2.10",
+ "react": "19.2.7",
+ "react-dom": "19.2.7",
+ "react-resizable-panels": "^4.12.0",
"tailwind-merge": "^3.5.0",
"tailwindcss": "^4",
"tw-animate-css": "^1.4.0",
@@ -33,28 +33,26 @@
"vis-network": "^10.0.2"
},
"devDependencies": {
- "@playwright/test": "^1.62.0",
+ "@playwright/test": "^1.61.1",
"@types/node": "^26",
"@types/react": "^19",
"@types/react-dom": "^19",
"@vitest/coverage-v8": "4.1.10",
"eslint": "^9",
- "eslint-config-next": "16.2.12",
+ "eslint-config-next": "16.2.10",
"fast-check": "^4.9.0",
- "jsdom": "^30.0.0",
- "postcss": "8.5.24",
+ "jsdom": "^29.1.0",
+ "postcss": "^8.5.16",
"typescript": "^6",
"vitest": "^4.1.10"
},
"overrides": {
- "brace-expansion": "5.0.8",
- "postcss": "8.5.24",
+ "postcss": "^8.5.16",
"undici": "7.28.0",
"uuid": "^14.0.0"
},
"resolutions": {
- "brace-expansion": "5.0.8",
- "postcss": "8.5.24",
+ "postcss": "^8.5.16",
"undici": "7.28.0"
}
}
diff --git a/frontend/patches/minimatch@3.1.5.patch b/frontend/patches/minimatch@3.1.5.patch
deleted file mode 100644
index 4838457a5..000000000
--- a/frontend/patches/minimatch@3.1.5.patch
+++ /dev/null
@@ -1,7 +0,0 @@
-diff --git a/minimatch.js b/minimatch.js
---- a/minimatch.js
-+++ b/minimatch.js
-@@ -10 +10,2 @@ var GLOBSTAR = minimatch.GLOBSTAR = Minimatch.GLOBSTAR = {}
--var expand = require('brace-expansion')
-+var braceExpansion = require('brace-expansion')
-+var expand = braceExpansion.expand || braceExpansion
diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml
index effc1257f..d4c356453 100644
--- a/frontend/pnpm-lock.yaml
+++ b/frontend/pnpm-lock.yaml
@@ -5,29 +5,24 @@ settings:
excludeLinksFromLockfile: false
overrides:
- brace-expansion: 5.0.8
- postcss: 8.5.18
- sharp: 0.35.0
+ postcss: ^8.5.15
undici: 7.28.0
pnpmfileChecksum: sha256-RXPq3MmEdRb3xD3rhbER9kciz9nBr/i0J/uMUjql5t0=
-patchedDependencies:
- minimatch@3.1.5: 5f38b9c5382c1163b0389810f5e4e867519096f3c11a6df0a51d7cafbdfa93e2
-
importers:
.:
dependencies:
'@base-ui/react':
specifier: ^1.6.0
- version: 1.6.0(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
+ version: 1.6.0(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
'@radix-ui/react-tabs':
- specifier: ^1.1.21
- version: 1.1.21(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
+ specifier: ^1.1.17
+ version: 1.1.17(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
'@tailwindcss/postcss':
specifier: ^4
- version: 4.3.3
+ version: 4.3.2
class-variance-authority:
specifier: ^0.7.1
version: 0.7.1
@@ -35,26 +30,26 @@ importers:
specifier: ^2.1.1
version: 2.1.1
lucide-react:
- specifier: ^1.27.0
- version: 1.27.0(react@19.2.8)
+ specifier: ^1.24.0
+ version: 1.24.0(react@19.2.7)
next:
- specifier: 16.2.12
- version: 16.2.12(@babel/core@7.29.7)(@playwright/test@1.62.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
+ specifier: 16.2.10
+ version: 16.2.10(@babel/core@7.29.7)(@playwright/test@1.61.1)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
react:
- specifier: 19.2.8
- version: 19.2.8
+ specifier: 19.2.7
+ version: 19.2.7
react-dom:
- specifier: 19.2.8
- version: 19.2.8(react@19.2.8)
+ specifier: 19.2.7
+ version: 19.2.7(react@19.2.7)
react-resizable-panels:
- specifier: ^4.12.2
- version: 4.12.2(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
+ specifier: ^4.12.0
+ version: 4.12.1(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
tailwind-merge:
specifier: ^3.5.0
version: 3.6.0
tailwindcss:
specifier: ^4
- version: 4.3.3
+ version: 4.3.2
tw-animate-css:
specifier: ^1.4.0
version: 1.4.0
@@ -66,11 +61,11 @@ importers:
version: 10.1.0(@egjs/hammerjs@2.0.17)(component-emitter@2.0.0)(keycharm@0.4.0)(uuid@14.0.1)(vis-data@8.0.4(uuid@14.0.1)(vis-util@6.0.0(@egjs/hammerjs@2.0.17)(component-emitter@2.0.0)))(vis-util@6.0.0(@egjs/hammerjs@2.0.17)(component-emitter@2.0.0))
devDependencies:
'@playwright/test':
- specifier: ^1.62.0
- version: 1.62.0
+ specifier: ^1.61.1
+ version: 1.61.1
'@types/node':
specifier: ^26
- version: 26.1.2
+ version: 26.1.1
'@types/react':
specifier: ^19
version: 19.2.17
@@ -84,23 +79,23 @@ importers:
specifier: ^9
version: 9.39.5(jiti@2.7.0)
eslint-config-next:
- specifier: 16.2.12
- version: 16.2.12(@typescript-eslint/parser@8.65.0(eslint@9.39.5(jiti@2.7.0))(typescript@6.0.3))(eslint@9.39.5(jiti@2.7.0))(typescript@6.0.3)
+ specifier: 16.2.10
+ version: 16.2.10(@typescript-eslint/parser@8.63.0(eslint@9.39.5(jiti@2.7.0))(typescript@6.0.3))(eslint@9.39.5(jiti@2.7.0))(typescript@6.0.3)
fast-check:
specifier: ^4.9.0
version: 4.9.0
jsdom:
- specifier: ^30.0.0
- version: 30.0.0
+ specifier: ^29.1.0
+ version: 29.1.1
postcss:
- specifier: 8.5.18
- version: 8.5.18
+ specifier: ^8.5.15
+ version: 8.5.17
typescript:
specifier: ^6
version: 6.0.3
vitest:
specifier: ^4.1.10
- version: 4.1.10(@types/node@26.1.2)(@vitest/coverage-v8@4.1.10)(jsdom@30.0.0)(vite@8.1.4(@types/node@26.1.2)(jiti@2.7.0))
+ version: 4.1.10(@types/node@26.1.1)(@vitest/coverage-v8@4.1.10)(jsdom@29.1.1)(vite@8.1.4(@types/node@26.1.1)(jiti@2.7.0))
packages:
@@ -108,13 +103,20 @@ packages:
resolution: {integrity: sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==}
engines: {node: '>=10'}
- '@asamuzakjp/css-color@6.0.5':
- resolution: {integrity: sha512-mbhpPMmnw/kwW19aRNmSUl1QzLbdGo1SCuE49BT98MNwqF6zaHb3o2owssFc/PEO/4t2UjqtCNwocuDtJornzA==}
- engines: {node: ^22.13.0 || >=24.0.0}
+ '@asamuzakjp/css-color@5.1.11':
+ resolution: {integrity: sha512-KVw6qIiCTUQhByfTd78h2yD1/00waTmm9uy/R7Ck/ctUyAPj+AEDLkQIdJW0T8+qGgj3j5bpNKK7Q3G+LedJWg==}
+ engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0}
- '@asamuzakjp/dom-selector@8.3.0':
- resolution: {integrity: sha512-UJLfKXBhrc8i1vH2eJXuYQMwlsLKWFw3O+CPqXSuVEiikeAim3UgrfWX0k4tA/X8cRFM8iZ7OaqBokFGbYusdg==}
- engines: {node: ^22.13.0 || >=24.0.0}
+ '@asamuzakjp/dom-selector@7.1.1':
+ resolution: {integrity: sha512-67RZDnYRc8H/8MLDgQCDE//zoqVFwajkepHZgmXrbwybzXOEwOWGPYGmALYl9J2DOLfFPPs6kKCqmbzV895hTQ==}
+ engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0}
+
+ '@asamuzakjp/generational-cache@1.0.1':
+ resolution: {integrity: sha512-wajfB8KqzMCN2KGNFdLkReeHncd0AslUSrvHVvvYWuU8ghncRJoA50kT3zP9MVL0+9g4/67H+cdvBskj9THPzg==}
+ engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0}
+
+ '@asamuzakjp/nwsapi@2.3.9':
+ resolution: {integrity: sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==}
'@babel/code-frame@7.29.7':
resolution: {integrity: sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==}
@@ -226,15 +228,15 @@ packages:
resolution: {integrity: sha512-064IFJdjTfUqnjpCVpMOdbr8FLQBhinbZj6yRv2An2E41O/pLEXqfFRWqGq/SxlE5PEUYTlvWsG2r8MswAVvkg==}
engines: {node: '>=20.19.0'}
- '@csstools/css-calc@3.3.0':
- resolution: {integrity: sha512-c5ihYsPkdG6JCkU2zTMm4+k6r7RXuGxtWYhu5DHMIiF1FHzrfmHL5so11AoFpUv/tu61xfcmT4AmKoFfMPoqdQ==}
+ '@csstools/css-calc@3.2.1':
+ resolution: {integrity: sha512-DtdHlgXh5ZkA43cwBcAm+huzgJiwx3ZTWVjBs94kwz2xKqSimDA3lBgCjphYgwgVUMWatSM0pDd8TILB1yrVVg==}
engines: {node: '>=20.19.0'}
peerDependencies:
'@csstools/css-parser-algorithms': ^4.0.0
'@csstools/css-tokenizer': ^4.0.0
- '@csstools/css-color-parser@4.1.10':
- resolution: {integrity: sha512-UZhQLIUyJaaMepqehrCODwCg2KW25vFvLWBmqYFaPclYvvxzj/sG8LBOhBFCp11i9uE7t1EyS+RAoV9tztPFyw==}
+ '@csstools/css-color-parser@4.1.9':
+ resolution: {integrity: sha512-paQcIaOO53Rk5+YrBaBjm/SgrV4INImjo2BT1DtQRYr+XeTRbeAYlS+jxXp9drqvKmtFnWRJKIalDLhZZDu42A==}
engines: {node: '>=20.19.0'}
peerDependencies:
'@csstools/css-parser-algorithms': ^4.0.0
@@ -246,8 +248,8 @@ packages:
peerDependencies:
'@csstools/css-tokenizer': ^4.0.0
- '@csstools/css-syntax-patches-for-csstree@1.1.7':
- resolution: {integrity: sha512-fQ+05118eQS1cofO3aJpB5efgpBZMvIzwr/sbC8kDLVA5XLG8q1kJV5yzrUAI1f7lvhPnm8fgIjzFB8/O/5Dig==}
+ '@csstools/css-syntax-patches-for-csstree@1.1.6':
+ resolution: {integrity: sha512-TcJCWFbXLPpJYq6z7bfOyjWYJDiDg2/I4gyUC9pqPNqHFRIey0EB0q0L5cSnQDfWJg8Jd6VadakxdIez/3zkqQ==}
peerDependencies:
css-tree: ^3.2.1
peerDependenciesMeta:
@@ -274,8 +276,8 @@ packages:
'@emnapi/runtime@1.11.1':
resolution: {integrity: sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==}
- '@emnapi/runtime@1.11.3':
- resolution: {integrity: sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==}
+ '@emnapi/runtime@1.11.2':
+ resolution: {integrity: sha512-kyOl3X0DuTiT1h2ft8r2fYO8JYtU9a9Xis/zBSiGArNaagCOWx90N1k2wxp18czFDH+OgcWGb5ZP/XMt3dcyPA==}
'@emnapi/wasi-threads@1.2.1':
resolution: {integrity: sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==}
@@ -283,8 +285,8 @@ packages:
'@emnapi/wasi-threads@1.2.2':
resolution: {integrity: sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==}
- '@eslint-community/eslint-utils@4.10.1':
- resolution: {integrity: sha512-cuadcxVFE8sDK6iWJbs8Sn0av2Nrh2QSGQhVlBW9AaAHqHwjWsZHT8LJ4hFGPh7ASBV2deFdM7H/DPjulmh8rg==}
+ '@eslint-community/eslint-utils@4.9.1':
+ resolution: {integrity: sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==}
engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0}
peerDependencies:
eslint: ^6.0.0 || ^7.0.0 || >=8.0.0
@@ -369,161 +371,152 @@ packages:
resolution: {integrity: sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==}
engines: {node: '>=18'}
- '@img/sharp-darwin-arm64@0.35.0':
- resolution: {integrity: sha512-ZgaYEwaj+lx/5n4W8GmZ2IYz0PQHjN5eqRcfijWGB+2Aq7ZInZGa0qJyAn6DEtyLuWHRSrmWOqT9q3qqTBvmUQ==}
- engines: {node: '>=20.9.0'}
+ '@img/sharp-darwin-arm64@0.34.5':
+ resolution: {integrity: sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==}
+ engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [arm64]
os: [darwin]
- '@img/sharp-darwin-x64@0.35.0':
- resolution: {integrity: sha512-c1z9LFpKB0slQW3RchwBE8iSVzGp70TNjUUO9k4BZwwW4HH7JBGHeIy4b+kk4n/kcBASb9evKCE3/7Slmslgiw==}
- engines: {node: '>=20.9.0'}
+ '@img/sharp-darwin-x64@0.34.5':
+ resolution: {integrity: sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==}
+ engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [x64]
os: [darwin]
- '@img/sharp-freebsd-wasm32@0.35.0':
- resolution: {integrity: sha512-Li2KTev0H90kEtnJHkI9xQojXt1AqWmFBMXiPw5kqd1jQgP7gi5HVK/qC5Rmh/59NuAwUuPzzPITmX22NomYYQ==}
- engines: {node: '>=20.9.0'}
- os: [freebsd]
-
- '@img/sharp-libvips-darwin-arm64@1.3.0':
- resolution: {integrity: sha512-EKbmBKtyTH+GPFDRw2TgK2oV6hyxxlJVIar4hoTYSNmIwipgMFdxPQqR392GmfdsPGWga0mCFN1cCKjRb9cljw==}
+ '@img/sharp-libvips-darwin-arm64@1.2.4':
+ resolution: {integrity: sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==}
cpu: [arm64]
os: [darwin]
- '@img/sharp-libvips-darwin-x64@1.3.0':
- resolution: {integrity: sha512-Pl2OmOvrJ42adUllESxBsG54PfXLo1OYg9i3c5/5Ln/qJ0gZuTM9YMhQJPIbXqwidLRc/c2zuHt4RsrymmNv7A==}
+ '@img/sharp-libvips-darwin-x64@1.2.4':
+ resolution: {integrity: sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==}
cpu: [x64]
os: [darwin]
- '@img/sharp-libvips-linux-arm64@1.3.0':
- resolution: {integrity: sha512-C0SqjoFKnszqa44EQ7xoaT48nnO0lOyXEULfXMWi8krrjOPGYkeK30Okzla6ATbBYsyZ0ySinK0FVkpv3DwzfQ==}
+ '@img/sharp-libvips-linux-arm64@1.2.4':
+ resolution: {integrity: sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==}
cpu: [arm64]
os: [linux]
libc: [glibc]
- '@img/sharp-libvips-linux-arm@1.3.0':
- resolution: {integrity: sha512-A8UpHoUDW4DwnXoV6+q3C1s7QLRAHtPDEjWuNZjwHMyoCNZnm0GeNN8ls9f/bsEYTRQRW96C/n34XJQHJ2fT7A==}
+ '@img/sharp-libvips-linux-arm@1.2.4':
+ resolution: {integrity: sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==}
cpu: [arm]
os: [linux]
libc: [glibc]
- '@img/sharp-libvips-linux-ppc64@1.3.0':
- resolution: {integrity: sha512-WOpkVxAjFd369iaIzEgNRreFD+gWdUMIGD5zplhNKNeqS6mm5dac3q2AFyCBmzYoAdouzZvRBgxy4z8QHZb4/A==}
+ '@img/sharp-libvips-linux-ppc64@1.2.4':
+ resolution: {integrity: sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==}
cpu: [ppc64]
os: [linux]
libc: [glibc]
- '@img/sharp-libvips-linux-riscv64@1.3.0':
- resolution: {integrity: sha512-DRWw0mOHusrCCuw2rqP87oLg6PGlkomVDFqw2hIwsSfwWpu4k3XLcBPaKKl6ct/GtL/cwNkgwjV/tc0Mqht3VA==}
+ '@img/sharp-libvips-linux-riscv64@1.2.4':
+ resolution: {integrity: sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==}
cpu: [riscv64]
os: [linux]
libc: [glibc]
- '@img/sharp-libvips-linux-s390x@1.3.0':
- resolution: {integrity: sha512-9APy+nFWhHS+kzLgWZfLcyrUd7YqnAQVa4BPOo4xkoHpdoktOAPG4cEr9+Jpl0TtqfVmcMJimNL5qNTyyOHZNA==}
+ '@img/sharp-libvips-linux-s390x@1.2.4':
+ resolution: {integrity: sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==}
cpu: [s390x]
os: [linux]
libc: [glibc]
- '@img/sharp-libvips-linux-x64@1.3.0':
- resolution: {integrity: sha512-y9RNUYDe2A1UAdhLyfeOodGRszQdaEoe4nfOpp/sNVPl2CWIcUyFaDoCh4vPLPxu19803j2naLqZup2WxDXCLA==}
+ '@img/sharp-libvips-linux-x64@1.2.4':
+ resolution: {integrity: sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==}
cpu: [x64]
os: [linux]
libc: [glibc]
- '@img/sharp-libvips-linuxmusl-arm64@1.3.0':
- resolution: {integrity: sha512-cC1wkC0Mlucd0KSiGrLkJnB/ZqPvZCntc/Lk7ZnYO5ZSbF2euNek4Xvxafojq+wN1q/W0eprdpUIjUr/EV2PBg==}
+ '@img/sharp-libvips-linuxmusl-arm64@1.2.4':
+ resolution: {integrity: sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==}
cpu: [arm64]
os: [linux]
libc: [musl]
- '@img/sharp-libvips-linuxmusl-x64@1.3.0':
- resolution: {integrity: sha512-LiYMhUZicB1QG//+RvmYZpXJO8fYRENfp+MZUCnG9aw+AKvGAy9gPaCnuwsPcBFs8EV66M0NNxj9VHcNklE8zw==}
+ '@img/sharp-libvips-linuxmusl-x64@1.2.4':
+ resolution: {integrity: sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==}
cpu: [x64]
os: [linux]
libc: [musl]
- '@img/sharp-linux-arm64@0.35.0':
- resolution: {integrity: sha512-4+4XHLNT5wDT0roYlHTEmH9lDKt0acf9Tv+3hM3iceOirkxrR404/3WjAYZ9F9CkHrxeRcGLJXbi4vluMZ9O+A==}
- engines: {node: '>=20.9.0'}
+ '@img/sharp-linux-arm64@0.34.5':
+ resolution: {integrity: sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==}
+ engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [arm64]
os: [linux]
libc: [glibc]
- '@img/sharp-linux-arm@0.35.0':
- resolution: {integrity: sha512-VVlpEWwizEFIOom0zdoeKuO5nuTswzVE5uHcBNvHzmeHUpNFajY3HFfbQ+zIH4E2kVaZ/yVxmsShW56TtEy4uA==}
- engines: {node: '>=20.9.0'}
+ '@img/sharp-linux-arm@0.34.5':
+ resolution: {integrity: sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==}
+ engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [arm]
os: [linux]
libc: [glibc]
- '@img/sharp-linux-ppc64@0.35.0':
- resolution: {integrity: sha512-N3hzbEpUTJC8pWpPVJvgzGxM+so/MAXc8O2s/53B0LL9ZGpfXpME7Wizkc5d/8fRBlBtkDjzoZGDCqqNDHqLEw==}
- engines: {node: '>=20.9.0'}
+ '@img/sharp-linux-ppc64@0.34.5':
+ resolution: {integrity: sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==}
+ engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [ppc64]
os: [linux]
libc: [glibc]
- '@img/sharp-linux-riscv64@0.35.0':
- resolution: {integrity: sha512-l6vmKVPnbS0RhVMbyxP5meAARsbhCnBN4fy31qz0+3a6Rv4jEqfzDrT89y6ZPkCi0AJGnwp2En528yXo401Hpw==}
- engines: {node: '>=20.9.0'}
+ '@img/sharp-linux-riscv64@0.34.5':
+ resolution: {integrity: sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==}
+ engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [riscv64]
os: [linux]
libc: [glibc]
- '@img/sharp-linux-s390x@0.35.0':
- resolution: {integrity: sha512-MYlMiPFiv/EKPAHnp3yNZ9AAWFsxga9c5Bkc6wkar6bqzHLlkGVJHRm0u1ei+VXnZxp3Mz9MG9ZIsI8vSOf3sQ==}
- engines: {node: '>=20.9.0'}
+ '@img/sharp-linux-s390x@0.34.5':
+ resolution: {integrity: sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==}
+ engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [s390x]
os: [linux]
libc: [glibc]
- '@img/sharp-linux-x64@0.35.0':
- resolution: {integrity: sha512-TYaItB5oj1ioXjhyn2xrR208vf+YuIIcHptQWRRaBmFhvIvL9D72DXN8w75xup0KXA8UdEAhQ9Qb2S49FD/9Cw==}
- engines: {node: '>=20.9.0'}
+ '@img/sharp-linux-x64@0.34.5':
+ resolution: {integrity: sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==}
+ engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [x64]
os: [linux]
libc: [glibc]
- '@img/sharp-linuxmusl-arm64@0.35.0':
- resolution: {integrity: sha512-DSTb6ijQzqe6DdAaOBVqJ/SYf1vO8EW5bK6X6LRXufEBebf2722VCdvBUtZ3rtV0x2ApfPNDy/p7LrrjaWjiyQ==}
- engines: {node: '>=20.9.0'}
+ '@img/sharp-linuxmusl-arm64@0.34.5':
+ resolution: {integrity: sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==}
+ engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [arm64]
os: [linux]
libc: [musl]
- '@img/sharp-linuxmusl-x64@0.35.0':
- resolution: {integrity: sha512-K7ykQ+26Rt6+4BTU80AuGgTPIYX86UxiAKT4rcXX/WNTo7k1ZxpKz+TguHnwVpCqQK3B5PK0vZ0ZBe6nz/ib1w==}
- engines: {node: '>=20.9.0'}
+ '@img/sharp-linuxmusl-x64@0.34.5':
+ resolution: {integrity: sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==}
+ engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [x64]
os: [linux]
libc: [musl]
- '@img/sharp-wasm32@0.35.0':
- resolution: {integrity: sha512-9woLIFORERCr+6cWu87dQ22J34EExkhc73U1kZW0c+RclQqWetoodByp4dWZ/hN8/KVmTRAx2HOnUwib8AwZdA==}
- engines: {node: '>=20.9.0'}
-
- '@img/sharp-webcontainers-wasm32@0.35.0':
- resolution: {integrity: sha512-t+kie1TOyaDM6Dho+f+y0VqIUNhYQaKCUahuZVi0E0frgdiaOaPsDxDW3wfKacUdaNBCnK/ZDBMg33ydvHj8uA==}
- engines: {node: '>=20.9.0'}
+ '@img/sharp-wasm32@0.34.5':
+ resolution: {integrity: sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==}
+ engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [wasm32]
- '@img/sharp-win32-arm64@0.35.0':
- resolution: {integrity: sha512-M5eKxug0dabbaWgFKvPa3odNs2OpaP+81NASfGKkt4GcYXpNhSu7CaeYxWkLNV6vHmUp4hnCxnxrUyhUJhXbKA==}
- engines: {node: '>=20.9.0'}
+ '@img/sharp-win32-arm64@0.34.5':
+ resolution: {integrity: sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==}
+ engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [arm64]
os: [win32]
- '@img/sharp-win32-ia32@0.35.0':
- resolution: {integrity: sha512-z0+pZ03QCDvdVN0Ez9IX/yjWC19ikMlXrmdYMwYNLTh2BLPx3hXWPvyqWfquZ0BTO9O6GVOjIVoTcyyacMnWlQ==}
- engines: {node: ^20.9.0}
+ '@img/sharp-win32-ia32@0.34.5':
+ resolution: {integrity: sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==}
+ engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [ia32]
os: [win32]
- '@img/sharp-win32-x64@0.35.0':
- resolution: {integrity: sha512-feNnlz5ZHKr0MY1LPHvZQyJeBkbo4ctsn0D8FvA53VTw5TC63rfEL2UrWbkSBR19htSE7Mw78xYVwdJqoMWVHw==}
- engines: {node: '>=20.9.0'}
+ '@img/sharp-win32-x64@0.34.5':
+ resolution: {integrity: sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==}
+ engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [x64]
os: [win32]
@@ -549,60 +542,60 @@ packages:
'@emnapi/core': ^1.7.1
'@emnapi/runtime': ^1.7.1
- '@next/env@16.2.12':
- resolution: {integrity: sha512-d0Z5Bc13Fa4nR8pFAKx2jay2yhJM16vlfHbTzYnUQAxlNb6B6lmn4hjt69lYNt4kRtyYP6gEM49lPRHNbIyneg==}
+ '@next/env@16.2.10':
+ resolution: {integrity: sha512-zLPxg9M0MEHmygpj5OuxjQ+vHMiy/K7cSp74G8ecYolmgUWw0RwN02tF56npup/+qaI8JB97hQgS/r2Hb6QwVA==}
- '@next/eslint-plugin-next@16.2.12':
- resolution: {integrity: sha512-uF2z/qAK2q7B5/6CpnFcBRX6jOq5iCO+Uqh1UkJhXljX1JwLarLYhhoJadO6dPb6moTprOKewMXheBcbIoSbug==}
+ '@next/eslint-plugin-next@16.2.10':
+ resolution: {integrity: sha512-Gs8D2m21VnJeFo9qvYIIqJH94frWerWYu41BprU1pLtRVF7PCQNLiFZZ3fG+iPuj3K83Cwv/rt+msLOy8Qgu3Q==}
- '@next/swc-darwin-arm64@16.2.12':
- resolution: {integrity: sha512-0W1R0teHWJrqKX0FH20IzzIWAOuGtBxPGuObrxy1lE8hQvCFj49KE8a3WUg0D7sq6rn6zkM4c7YGUnhudBS6oA==}
+ '@next/swc-darwin-arm64@16.2.10':
+ resolution: {integrity: sha512-v9IdJCa0H0mbo+8z5zwUpOk1Vj7RjkcI5uNYf5Ws1y6szf/p3Mzl9hLaST8SCt6L9h8NGnruZcd2+o0NTNwDhA==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [darwin]
- '@next/swc-darwin-x64@16.2.12':
- resolution: {integrity: sha512-Hy5Ls099+aFUmOLmIgPfLqNi6iCwhL3uQCssz5rWk+5Nkc6TUKCE83DY5BbNylfm3+mfwcSFnLRfrZDJhVxdtw==}
+ '@next/swc-darwin-x64@16.2.10':
+ resolution: {integrity: sha512-17IS0jJRViROGmA9uGdNR8VPJpfbnaVG7E9qhso5jDLkmyd0lSDORWxbcKINzcFqzZqGwGtMSnrFRxBpuUYjLQ==}
engines: {node: '>= 10'}
cpu: [x64]
os: [darwin]
- '@next/swc-linux-arm64-gnu@16.2.12':
- resolution: {integrity: sha512-+YqU2h1cQkHsGfvjAsrSmst8UIFBibBGm5x3Xgel8NLMiDQtNOM4sM2GOEMvG5YiOBNeN/Ykk8cQC2S0Xrqljg==}
+ '@next/swc-linux-arm64-gnu@16.2.10':
+ resolution: {integrity: sha512-GRQRsRtuciNJvB54AvvuQTiq0oZtFwa1owQqtZD8wwnGpM2L39MV22kpI72YSXLKIyY40LC66EiLFv4PiicXxg==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
libc: [glibc]
- '@next/swc-linux-arm64-musl@16.2.12':
- resolution: {integrity: sha512-0qjhiYBaKAqF63LA1ZWAAnKTzFUguAaZiRa5etMLGGPj/B6uEVjtIZldIzFEp3wHlB0koK6aTzqPtSdplTCjoA==}
+ '@next/swc-linux-arm64-musl@16.2.10':
+ resolution: {integrity: sha512-zkN9MQYS7UQBro+FnISUq1itaQjXI9xqISzuQ+2bc921NcJ1x4yPCqrn77tVN6/dOOXaaWVX3k6/bR07pPwK+A==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
libc: [musl]
- '@next/swc-linux-x64-gnu@16.2.12':
- resolution: {integrity: sha512-7A3q26W+h7gnA15uqBToNuDqBEFZZcqh0mW2mn4AJh/G5pdg2RVE3n4slzLEliASZFG3NmsbEzng/x2Sh09mBg==}
+ '@next/swc-linux-x64-gnu@16.2.10':
+ resolution: {integrity: sha512-iCVJnwvrPYECvA6WM/7+oo+OiTvedIKLxtCLAZP4xZR3nXa1zmzZyLPbYCmWvpd4CvMYF1EMTafd0ii3DygLvA==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
libc: [glibc]
- '@next/swc-linux-x64-musl@16.2.12':
- resolution: {integrity: sha512-qSjL/uppm+cbh21s72Ss8gkiOhQ4dExWHNGOWy6eZV7STj5WsKehgxT61beSsOj+YYQuTplL376lOCdMQU5T8w==}
+ '@next/swc-linux-x64-musl@16.2.10':
+ resolution: {integrity: sha512-ov2g4H0dHY9bPoOU83m91hWT7Iq5qy13bUnyyshLU3HGR1Ownn0X9QpmDPc5iIUaahTp7f7LeGAhV4DSFtackw==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
libc: [musl]
- '@next/swc-win32-arm64-msvc@16.2.12':
- resolution: {integrity: sha512-X6hzsOUJac/e7AWSbn9gQ9nzHld1xWP5iyjHpYWvud8pufB679O1xg4JDyKr8Xd69Jvd+kM2Der6uftiZCmjYA==}
+ '@next/swc-win32-arm64-msvc@16.2.10':
+ resolution: {integrity: sha512-DwAnhLX76HQiFFQNgWlcK+JzlnD1rZ+UK/WY0ZMI/deXpvgnesjNYrqcfo1JzBuz4Kf7o3brIBL0glI1junatA==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [win32]
- '@next/swc-win32-x64-msvc@16.2.12':
- resolution: {integrity: sha512-F6fakeHuFTLOPt0bslQJdf+xtT+WIP9DVn/m4y1w1mRnVPyh3D/cNvzlRkxM444xfm+IvvYNSOrKiA2CDJ0Uxw==}
+ '@next/swc-win32-x64-msvc@16.2.10':
+ resolution: {integrity: sha512-0JXq3b85Jk9Jg4ntLUbXSPvoDw3gpZou7twuKdoFG2jOw635v7+IiXfTaa0TxVMyx78pUjnrVYwLgjKfX4e6/A==}
engines: {node: '>= 10'}
cpu: [x64]
os: [win32]
@@ -626,16 +619,16 @@ packages:
'@oxc-project/types@0.139.0':
resolution: {integrity: sha512-r9gHphtCs+1M7J0pw6Sn/hh/Wpa/iQrOOkrNAlVLF/gHq+/CJmHIWKKUUhdWjcD6CIa8idarspCsASiXCXvFUw==}
- '@playwright/test@1.62.0':
- resolution: {integrity: sha512-9zOJ6ZQRAena31MpOH9VSzIz8Ou3YJ/wtY/eQm5T2uhfhG7/U3COrMS8xOtUrZrp9OgdmzEnIYODye3nY1VqzA==}
- engines: {node: '>=20'}
+ '@playwright/test@1.61.1':
+ resolution: {integrity: sha512-8nKv6+0RJSL9FE4jYOEGXnPeM/Hg12qZpmqzZjRh3qM0Y7c3z1mrOTfFLids72RDQYVh9WpLEfR5WdpNX4fkig==}
+ engines: {node: '>=18'}
hasBin: true
- '@radix-ui/primitive@1.1.7':
- resolution: {integrity: sha512-rqWnm76nYT8HoNNqEjpgJ7Pw/DrBj5iBTrmEPo6HTX5+VJyBNOqTdv4g89G63HuR5g0AaENoAcH7Is5fF2kZ8Q==}
+ '@radix-ui/primitive@1.1.5':
+ resolution: {integrity: sha512-d86WIWFYNtGA0H/d8exstrTRTp7eWJYlYJbtNofxr/3ljupZYn6EFDG/Qgu/0Kc8v7yMUxySagqJsL1+PdYjWg==}
- '@radix-ui/react-collection@1.1.15':
- resolution: {integrity: sha512-9W+B9NPF0NaaPh/1NJd3+KqsnlLqU9H7T2rvww+fp+T/evVXdNAyYcnfRQZFOjkR1ajQp3yORlqnI8soawLvNA==}
+ '@radix-ui/react-collection@1.1.12':
+ resolution: {integrity: sha512-nb67INpE0IahJKN7EYPp9m9YGwYeKlnzxT3MwXVkgCskaSJia97kG4T0ywpjNUSSnoJk/uvk12V8vbrEHEj+/Q==}
peerDependencies:
'@types/react': '*'
'@types/react-dom': '*'
@@ -647,8 +640,8 @@ packages:
'@types/react-dom':
optional: true
- '@radix-ui/react-compose-refs@1.1.5':
- resolution: {integrity: sha512-+48PbAAbq3didjJxa+OaWY2ZwgAKsNiRGyeHKszblZMQ+kcpd9pAaT11cMkGEie0vsOi3QdeTE6d5Fe3Gn61kA==}
+ '@radix-ui/react-compose-refs@1.1.3':
+ resolution: {integrity: sha512-rYOP8OMnuuPMQF1uhPVlGNcCDlkokKqGFE3JcxFViIkAXP7EvFWUliJAstrapypaBLJNHbZL6jGhbVDGTwmVhA==}
peerDependencies:
'@types/react': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
@@ -656,8 +649,8 @@ packages:
'@types/react':
optional: true
- '@radix-ui/react-context@1.2.2':
- resolution: {integrity: sha512-RHCUGwKHDr0hDGg4X7ma4JG4/+12qxw8rkh5QKdDldlCvtja6nUx1Ef/8HVrJze81lEsgLQlqjzjGNHantgnQA==}
+ '@radix-ui/react-context@1.2.0':
+ resolution: {integrity: sha512-fOE+JtN9rygNZkCnHRBEP0TAvLldlhyOxMsbwFvTP4nAs+nBmfnna+o/Zski2wkmY1YMrFC0aSzsHoLY47iLrg==}
peerDependencies:
'@types/react': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
@@ -665,8 +658,8 @@ packages:
'@types/react':
optional: true
- '@radix-ui/react-direction@1.1.4':
- resolution: {integrity: sha512-5pzg4FGQNpExhnhT2zlrP1wZFaYCd1K0nYWoFAdcYoYK868IEigqMX3B3f8yIoRlAhAeDWciLI6ZdCKHF9P4Vg==}
+ '@radix-ui/react-direction@1.1.2':
+ resolution: {integrity: sha512-C3vFhbyi4SW3PmbAi6Awpu4OzJtd0MxGurvSsYtr7p7nM8RNB3VAF3CUmnp2j50knpkrRcB7+ycVXzgLgF6yNA==}
peerDependencies:
'@types/react': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
@@ -674,8 +667,8 @@ packages:
'@types/react':
optional: true
- '@radix-ui/react-id@1.1.4':
- resolution: {integrity: sha512-TMQp2llA+RYn7JcjnrMnz7wN4pcVttPZnRZo52PLQsoLVKzNlVwUeHmfePgTgRluXFvlD3GD5g5MOVVTJCO0qA==}
+ '@radix-ui/react-id@1.1.2':
+ resolution: {integrity: sha512-orBC88futVpqCmhX1p4cvquNHsELQ+w+vBJnuj3ftETI5bJb0bZn3Tqu3SWN2IOcPycTnMGnhwoermvISt72sA==}
peerDependencies:
'@types/react': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
@@ -683,8 +676,8 @@ packages:
'@types/react':
optional: true
- '@radix-ui/react-presence@1.1.10':
- resolution: {integrity: sha512-3wyzCQ6+ubRA+D4uv9m95JYLXxmOHp05qjrkjeA7uKHHtjpPggQzc6DAb0URl7j67oR0K2foO4ip27TiX037Bw==}
+ '@radix-ui/react-presence@1.1.7':
+ resolution: {integrity: sha512-zBZ4QM5XG3JRanDmqXYf3MD6th4AFXFmgU6KNMFzUaV6F3uw9I5/zjMUvFriSEn5ewo1nxuibvyxJdmLlDcslA==}
peerDependencies:
'@types/react': '*'
'@types/react-dom': '*'
@@ -696,8 +689,8 @@ packages:
'@types/react-dom':
optional: true
- '@radix-ui/react-primitive@2.1.10':
- resolution: {integrity: sha512-MucOnzh6hR5mid6VpkbglRAMYMjKLqRnGBbjXkzjK52fuQDd1qbkx78a5P40mkcnVXJdEVxm26E9OPAiUq7nBg==}
+ '@radix-ui/react-primitive@2.1.7':
+ resolution: {integrity: sha512-bC3NiwsprbxKjuon9l7X6BUTw7FPVzEYaL92MPEY5SCd/9hUTPXVFtVwRix7778wtRsVao+zE062gL79FZleeQ==}
peerDependencies:
'@types/react': '*'
'@types/react-dom': '*'
@@ -709,8 +702,8 @@ packages:
'@types/react-dom':
optional: true
- '@radix-ui/react-roving-focus@1.1.19':
- resolution: {integrity: sha512-V9jI6hDjT7l3jsCQD9bLNvDLM3tH/gdbOTp7Tefp3hbbgCGQoK7tUvrWiRlcoBHIZ809ElXwNQwVo0B98LuTXQ==}
+ '@radix-ui/react-roving-focus@1.1.15':
+ resolution: {integrity: sha512-40svmmugfM3mUN7VUDGVE1tQGOhyi8enlGD0CNJEcMM36C1f71PKM21DFgNHUfem0XnA+d8H8oN3Z9ZpJjSslg==}
peerDependencies:
'@types/react': '*'
'@types/react-dom': '*'
@@ -722,8 +715,8 @@ packages:
'@types/react-dom':
optional: true
- '@radix-ui/react-slot@1.3.3':
- resolution: {integrity: sha512-qx7oqnYbxnK9kYI9m317qmFmEgo6ywqWvbTogdj7cL9p3/yx4M48p7Rnw5z3H890cL/ow/EeWJsuTykeZVXP5Q==}
+ '@radix-ui/react-slot@1.3.0':
+ resolution: {integrity: sha512-MojKku4U/miO8Av4Dkb+ctMAQx7JmY96LmtDQlAarCRtd7rN52QCSzBF+XAvr5S6coSVj9HEPBgHAHKEJVk/WA==}
peerDependencies:
'@types/react': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
@@ -731,8 +724,8 @@ packages:
'@types/react':
optional: true
- '@radix-ui/react-tabs@1.1.21':
- resolution: {integrity: sha512-UKxJlZid7FVtsk/WTxj4i4uSEgj2Au+KBbS7SQyTlzMhhn+86Cz3tISZdTa87bfEfcuvZezf2ZsxD4xuEKtkog==}
+ '@radix-ui/react-tabs@1.1.17':
+ resolution: {integrity: sha512-nRyXnrAVCwjeXcHbvEbLS6ndbTeKHG1RqCP4A8Gw5L4cemDzPXdD8rAmr6wet0v57R69wGvuIIsFjHSVkZiMzQ==}
peerDependencies:
'@types/react': '*'
'@types/react-dom': '*'
@@ -744,8 +737,8 @@ packages:
'@types/react-dom':
optional: true
- '@radix-ui/react-use-callback-ref@1.1.4':
- resolution: {integrity: sha512-R6OUY2e2fA6Yn6s+VSx5KBV6Nx8LQEhu+cz7LCej18rQ1HLyg9PSC9jP/ZNx0o6FAIK9c0F1kHylzSxKsdlkrQ==}
+ '@radix-ui/react-use-callback-ref@1.1.2':
+ resolution: {integrity: sha512-xCso9j1/u8sEgP1RNHjFrXJLApL8LiqOkI1R4ywuN00rxWdYg4oQXuwKLS3i0j5NWLromUD27/4nlxj2UFVvIw==}
peerDependencies:
'@types/react': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
@@ -753,8 +746,8 @@ packages:
'@types/react':
optional: true
- '@radix-ui/react-use-controllable-state@1.2.6':
- resolution: {integrity: sha512-uEQJGT97ZA/TgP/Hydw47lHu+/vQj6z/0jA+WeTbK1o9Rx45GImjpD0tc3W5ad3D6XTSR6e1yEO0FvGq6WQfVQ==}
+ '@radix-ui/react-use-controllable-state@1.2.3':
+ resolution: {integrity: sha512-PLzC90MS+ReootmjC597dvopoelpZ8Q61HJkDXZSExitIq7PL55vHNnesAHwguHK0aPfBnpdNzQtv1uliaqQrA==}
peerDependencies:
'@types/react': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
@@ -762,8 +755,8 @@ packages:
'@types/react':
optional: true
- '@radix-ui/react-use-effect-event@0.0.5':
- resolution: {integrity: sha512-7cshFL8HGS/7HEiHH+9kL9HBwp2sa9yX18Knwek6KYWmXwM7pegMgta2AXMQKI+rq3JnfSj9x8wYqFMTdG1Jgg==}
+ '@radix-ui/react-use-effect-event@0.0.3':
+ resolution: {integrity: sha512-6c8ZqvPTWILEKnyVkP53EGRCcpnJiKTC21sS/6R1GF5xKyHJJWQEPfkqlcgUkdRQivd6tb23abUwe4ngWmY0JA==}
peerDependencies:
'@types/react': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
@@ -771,8 +764,8 @@ packages:
'@types/react':
optional: true
- '@radix-ui/react-use-is-hydrated@0.1.3':
- resolution: {integrity: sha512-umO/aJ+82CpOnhDZUTbILCQf7kU/g0iv+oGs/Q8jw7IkhWBzaEP4sA268PhFAJTFetbwp3ICc6ktpI4TqtxcIw==}
+ '@radix-ui/react-use-is-hydrated@0.1.1':
+ resolution: {integrity: sha512-qwOiz4Tjo8CNnrOLAYUMXeZwDzXgXpvK4TKQPmWLECM9XoWvA6+0Z2/7Ag3A4ivjS4ovbLJPbskkxioFyBhr8A==}
peerDependencies:
'@types/react': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
@@ -780,8 +773,8 @@ packages:
'@types/react':
optional: true
- '@radix-ui/react-use-layout-effect@1.1.4':
- resolution: {integrity: sha512-K20DkRkUwDnxEYMBPcg3Y6voLkEy5p5QQmszZgLngKKiC7dzBR/aEuK3w1qlx2JWDUNH6FluahYdgR3BP+QbYw==}
+ '@radix-ui/react-use-layout-effect@1.1.2':
+ resolution: {integrity: sha512-jrBWOxZITuGcnjRCM2t2U5ZPkCLxD+Ym6DjfssS5haTj2iiak/DOb64JeN6OdLfLgptb6/e2kKR+ZuTrGoZTPA==}
peerDependencies:
'@types/react': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
@@ -896,69 +889,69 @@ packages:
'@swc/helpers@0.5.15':
resolution: {integrity: sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==}
- '@tailwindcss/node@4.3.3':
- resolution: {integrity: sha512-/T8IKEsf9VTU6tLjgC7+sv2mOPtQxzE2jMw7u4Tt40Tx+QSZxpzh95/H6cMKoja9XuW7iMdLJYBB0o9G1CaAgg==}
+ '@tailwindcss/node@4.3.2':
+ resolution: {integrity: sha512-yWP/sqEcBLaD8JuA6zNwxoYKr75qxTioYwlRwekj5Jr/I5GXnoJfjetH/psLUIv74cYTH2lBUEzBkinthoYcBg==}
- '@tailwindcss/oxide-android-arm64@4.3.3':
- resolution: {integrity: sha512-Y85A2gmPSkl5Ve5qR86GL4HT509cFqQh1aes9p3sSkyTPwt0Pppf3GkwGe4JPACcRYjgJIEhQgM6dBClnr0NYw==}
+ '@tailwindcss/oxide-android-arm64@4.3.2':
+ resolution: {integrity: sha512-WHxqIuHpvZ5VtdX6GTl1Ik/Vp2YuN42Et+0CdeaVd/frQ9jAvGmvR8vLT+jk3e8/Q3x8kECB9+R17pgpp2BulA==}
engines: {node: '>= 20'}
cpu: [arm64]
os: [android]
- '@tailwindcss/oxide-darwin-arm64@4.3.3':
- resolution: {integrity: sha512-BiaWatpBcERQFDlOjRDpIVXuFK5PJez5SA4JMg6VYZdBYU+qKfV/vqjcIs+IYmtitf1xYQZTwXvU/8y4lfZUGw==}
+ '@tailwindcss/oxide-darwin-arm64@4.3.2':
+ resolution: {integrity: sha512-GZypeUY/IDJW3877KeM+O67vbXr3MBnbtEL4aYhNErv/JWZhye2vGSWWG9tB6iiqR2MqRNkY8IOUy4NdSZV26w==}
engines: {node: '>= 20'}
cpu: [arm64]
os: [darwin]
- '@tailwindcss/oxide-darwin-x64@4.3.3':
- resolution: {integrity: sha512-fAeUqfV5ndhxRwai8cXGzdLvul9utWOmeTkv69unv4ZXixjn61Z+p9lCWdwOwA3TYboG3BwdVuN/RDjhBRl0mw==}
+ '@tailwindcss/oxide-darwin-x64@4.3.2':
+ resolution: {integrity: sha512-UIIzmefR6KO1sDU7MzRqAxC8iBpft/VhkGjTjnhoS6k7Z3rQ9wEgA1ODSiyH/tcSYssulNm4Ci3hOeK1jH7ccQ==}
engines: {node: '>= 20'}
cpu: [x64]
os: [darwin]
- '@tailwindcss/oxide-freebsd-x64@4.3.3':
- resolution: {integrity: sha512-iyf5bV6+wnAlflVeEy7R25dupxTNECZN5QMI0qNT6eT+EgaGdZcKhGkr5SdoaWiLJ3spLqIY9VCeSGrwmtg4kw==}
+ '@tailwindcss/oxide-freebsd-x64@4.3.2':
+ resolution: {integrity: sha512-GN+uAmcI6DNspnCDwtOAZrTz6oukJnp337qZvxqCGLd3BHBzJpO0ZbTLRvJNdztOeAmTzewewGIMPb0tk2R4WA==}
engines: {node: '>= 20'}
cpu: [x64]
os: [freebsd]
- '@tailwindcss/oxide-linux-arm-gnueabihf@4.3.3':
- resolution: {integrity: sha512-aAYUprJAJQWWbRrPvtjdroZ56Md+JM8pMiopS6xGEwDfLhqj+2ver2p4nU4Mb3CRqcMmNBjo8KkUgcxhkzVQGQ==}
+ '@tailwindcss/oxide-linux-arm-gnueabihf@4.3.2':
+ resolution: {integrity: sha512-4ABn7qSbdHRwTiDiuWNegCyb5+2FJ4vKIKc3DmKrvAFw7MU1Lm11dIkTPwUaFdTzc7IsOpDbqBrlh0x6y36U/w==}
engines: {node: '>= 20'}
cpu: [arm]
os: [linux]
- '@tailwindcss/oxide-linux-arm64-gnu@4.3.3':
- resolution: {integrity: sha512-nDxldcEENOxZRzC2uu9jrutZdAAQtb+8WWDCSnWL1zvBk1+FN+x6MtDViPB5AJMfttVCUhehGWus3XBPgatM/w==}
+ '@tailwindcss/oxide-linux-arm64-gnu@4.3.2':
+ resolution: {integrity: sha512-wDgEIGwoM8w8pufh9LVt1PahDgNdKXrLC2qfAnV3vAmococ9RWbxeAw4pxPttd/TsJfwjyLf90Dg1y9y8I6Emw==}
engines: {node: '>= 20'}
cpu: [arm64]
os: [linux]
libc: [glibc]
- '@tailwindcss/oxide-linux-arm64-musl@4.3.3':
- resolution: {integrity: sha512-Md44bD6veX/PC5iyF8cDVnw4HBIANZepRZZ7a8DQOvkfo5WUBwcp6iAuCUz23u+4SUkhJlD3eL7hNdW8ezd/kA==}
+ '@tailwindcss/oxide-linux-arm64-musl@4.3.2':
+ resolution: {integrity: sha512-J5Nuk0uZQIiMTJj3LEx4sAA9tMFUoXQZFv1J6An+QGYe53HKRJuFDi0rpq/tuouCZeAbOBY3kQ6g8qeD4TUjtA==}
engines: {node: '>= 20'}
cpu: [arm64]
os: [linux]
libc: [musl]
- '@tailwindcss/oxide-linux-x64-gnu@4.3.3':
- resolution: {integrity: sha512-tx7us1muwOKAKWao2v/GaafFeQboE6aj88vC6ziN2NCGcRm8gWUhwjzg+YdVB1e4boAtdtma4L43onunI6NS4w==}
+ '@tailwindcss/oxide-linux-x64-gnu@4.3.2':
+ resolution: {integrity: sha512-kqCZpSKOBEJO4mz7OqWoofBZeXTAwaVGPj0ErAj7CojmhKpWVWVOnrt9dE8odoIraZq4oj3ausM37kXi+Tow8w==}
engines: {node: '>= 20'}
cpu: [x64]
os: [linux]
libc: [glibc]
- '@tailwindcss/oxide-linux-x64-musl@4.3.3':
- resolution: {integrity: sha512-SJxX60smvHgasZoBy11dX6YRjXJFovwWBoedhbQPOBzgFWBHGB+TVPWB9BxzR7TTxU8FQZAI2AyiNCMzFm8Img==}
+ '@tailwindcss/oxide-linux-x64-musl@4.3.2':
+ resolution: {integrity: sha512-cixpqbh2toJDmkuCRI68nXA8ZxNmdK9Y+9v5h3MC3ZQKy/0BO8AWzlkWyRM7JAFSGBlfig4YVTPsK6MVgqz1uw==}
engines: {node: '>= 20'}
cpu: [x64]
os: [linux]
libc: [musl]
- '@tailwindcss/oxide-wasm32-wasi@4.3.3':
- resolution: {integrity: sha512-jx1+rPhY/5Ympkktd656HBWEBLxP7dH06losBLjjf5vgCODXvi9KhtftWcMIwTFIDqBr7cRnQkdLnAG+IOlGvQ==}
+ '@tailwindcss/oxide-wasm32-wasi@4.3.2':
+ resolution: {integrity: sha512-4ec2Z/LOmRsAgU23CS4xeJfcJlmRg94A/XrbGRCF1gyU/zdDfRLYDVsS+ynSZCmGNxQ1jQriQOKMQeQxBA3Isw==}
engines: {node: '>=14.0.0'}
cpu: [wasm32]
bundledDependencies:
@@ -969,24 +962,24 @@ packages:
- '@emnapi/wasi-threads'
- tslib
- '@tailwindcss/oxide-win32-arm64-msvc@4.3.3':
- resolution: {integrity: sha512-3rc292Ca2ceK6Ulcc/bAVnTs/3nDtoPhyEKlgPv+yQJQi/JS/AMJlqzxvlDacL1nekbrcf6bTqp/jV4qgnPxNQ==}
+ '@tailwindcss/oxide-win32-arm64-msvc@4.3.2':
+ resolution: {integrity: sha512-Zyr/M0+XcYZu3bZrUytc7TXvrk0ftWfl8gN2MwekNDzhqhKRUucMPSeOzM0o0wH5AWOU49BsKRrfKxI2atCPMQ==}
engines: {node: '>= 20'}
cpu: [arm64]
os: [win32]
- '@tailwindcss/oxide-win32-x64-msvc@4.3.3':
- resolution: {integrity: sha512-yJ0pwIVc/nYeGoV02WtsN8KYyLQv7kyI2wDnkezyJlGGjkd4QLwDGAwl47YpPJeuI0M0ObaXGSPjvWDPeTPggw==}
+ '@tailwindcss/oxide-win32-x64-msvc@4.3.2':
+ resolution: {integrity: sha512-QI9BO7KlNZsp2GuO0jwAAj5jCDABOKXRkCk2XuKTSaNEFSdfzqswYVTtCHBNKHLsqyjFyFkqlDiwkNbTYSssMQ==}
engines: {node: '>= 20'}
cpu: [x64]
os: [win32]
- '@tailwindcss/oxide@4.3.3':
- resolution: {integrity: sha512-krXjAikiaFSPaK/FkAQT5UTx3VormQaiZ5hBFlJZ9UFQGB/rwg1MZIhHAG9smMQRTdyJxP6Qt5MwMtdyU5FWrA==}
+ '@tailwindcss/oxide@4.3.2':
+ resolution: {integrity: sha512-z8ZgnzX8gdNoWLBLqBPoh/sjnxkwvf9ZuWjnO0l0yIzbLa5/9S+eC5QxGZKRobVHIC3/1BoMWjHblqWjcgFgag==}
engines: {node: '>= 20'}
- '@tailwindcss/postcss@4.3.3':
- resolution: {integrity: sha512-JTSZZGQi1AyKirbLN3azmjVzef92tcX7h+iSqPdaeStyFpGpDlKvvpxeOE8njhbUanbRwr3z8DyzhICWnMtQeg==}
+ '@tailwindcss/postcss@4.3.2':
+ resolution: {integrity: sha512-rjVWYCa7Ngbi5AarT6k8TkxUG3Wl1QKzHdIZVsjZSzf36Jmo2IKZt/NHRAwly8oDkbBOH0YTu+CHuf9jPxMc+g==}
'@tybys/wasm-util@0.10.3':
resolution: {integrity: sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==}
@@ -1009,8 +1002,8 @@ packages:
'@types/json5@0.0.29':
resolution: {integrity: sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==}
- '@types/node@26.1.2':
- resolution: {integrity: sha512-Vu4a5UFA9rIIFJ7rB/Vaafh9lrCQszopTCx6KjFboXTGQbPNasehVR5TEiithSDGyd1DEiUByggTZsg8jukeIg==}
+ '@types/node@26.1.1':
+ resolution: {integrity: sha512-nxAkRSVkN1Y0JC1W8ky/fTfkGsMmcrRsbx+3XoZE+rMOX71kLYTV7fLXpqud1GpbpP5TuffXFqfX7fH2GgZREw==}
'@types/react-dom@19.2.3':
resolution: {integrity: sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==}
@@ -1020,63 +1013,63 @@ packages:
'@types/react@19.2.17':
resolution: {integrity: sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==}
- '@typescript-eslint/eslint-plugin@8.65.0':
- resolution: {integrity: sha512-IEgob78X12rHpUmtcwFsXhZdVGJtwTVP8FiCLZkR6GlYVrl2PcuB+KhCE5BlVC/eQpQnu8WXRtkHZuPar+gCRA==}
+ '@typescript-eslint/eslint-plugin@8.63.0':
+ resolution: {integrity: sha512-rvwSgqT+DHpWdzfSzPatRLm02a0GlESt++9iy3hLCDY4BgkaLcl8LBi9Yh7XGFBpwcBE/K3024QuXWTpbz4FfQ==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
peerDependencies:
- '@typescript-eslint/parser': ^8.65.0
+ '@typescript-eslint/parser': ^8.63.0
eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
typescript: '>=4.8.4 <6.1.0'
- '@typescript-eslint/parser@8.65.0':
- resolution: {integrity: sha512-CZ4nMxWwgu1HEEFNkeaCptra9QCtkmKdgf3sWh1rl1trIhmxLilgTV4cwcbQ4wemnT4sWQN8CaKOmdYx+g2gMA==}
+ '@typescript-eslint/parser@8.63.0':
+ resolution: {integrity: sha512-gwh4gvvlaVDKKxyfxMG+Gnu1u9X0OQBwyGLkbwB65dIzBKnxeRiJlNFqlI3zwVhNXJIs6qV7mlFCn/BIajlVig==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
peerDependencies:
eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
typescript: '>=4.8.4 <6.1.0'
- '@typescript-eslint/project-service@8.65.0':
- resolution: {integrity: sha512-SxnPhbTsGahizDgbu7oqFH/xVtzIqMd/s+WtnSxNxJZJpLbdT5IPdzg8EZxO3+PoKahXmwJLeNQOpKJb3/bi7Q==}
+ '@typescript-eslint/project-service@8.63.0':
+ resolution: {integrity: sha512-e5dh0/UI0ok53AlZ5wRkXCB32z/f2jUZqPR/ygAw5WYaSw8j9EoJWlS7wQjr/dmOaqWjnPIn2m+HhVPCMWGZVQ==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
peerDependencies:
typescript: '>=4.8.4 <6.1.0'
- '@typescript-eslint/scope-manager@8.65.0':
- resolution: {integrity: sha512-Esbl8OSYiVxBokYgWPf7VVWg/BE798wXhimnn9ML9Pt5qoDf8bfQlgjlKXR/k98+AcNzlLKYrpCcrcuZ9DZLgg==}
+ '@typescript-eslint/scope-manager@8.63.0':
+ resolution: {integrity: sha512-uUyfMWCnDSN8bCpcrY8nGP2BLkQ9Xn0GsipcONcpIDWhwhO4ZSyHvyS14U3X75mzxWxL3I2UZIrenTzdzcJO8A==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
- '@typescript-eslint/tsconfig-utils@8.65.0':
- resolution: {integrity: sha512-j6GzGqCiRdA7Qhur2VVmKZAkBLfnHFQfx4TaJGL9RMveZqCo48jSHHO0DTgizEnGhtWnqmbtCUSrqSkdiY/0Hg==}
+ '@typescript-eslint/tsconfig-utils@8.63.0':
+ resolution: {integrity: sha512-sUAbkulqBAsncKnbRP3+7CtQFRKicexnj7ZwNC6ddCR7EmrXvjvdCYMJbUIqMd6lwoEriZjwLo08aS5tSjVMHg==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
peerDependencies:
typescript: '>=4.8.4 <6.1.0'
- '@typescript-eslint/type-utils@8.65.0':
- resolution: {integrity: sha512-YjaZ7PRI5qY7ax2L3PbvX0rRyGtipAReCWs0mhhDBHjH/vl0g0BonaGXrKdKpMbIIsMIwDgbk/xzkBTyAltS5g==}
+ '@typescript-eslint/type-utils@8.63.0':
+ resolution: {integrity: sha512-Nzzh/OGxVCOjObjaj1CQF2RUasyYy2Jfuh+zZ3PjLzG2fYRriAiZLib9UKtO+CpQAS3YHiAS+ckZDclwqI1TPA==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
peerDependencies:
eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
typescript: '>=4.8.4 <6.1.0'
- '@typescript-eslint/types@8.65.0':
- resolution: {integrity: sha512-JSSwWNy+H0E/01jJEM+hrX6N0OFDzFzeIhHFSAS01tlVaevpG8cFyYRPhS5yjGOvBUx3sqQHVMjCL1CAZZMxBg==}
+ '@typescript-eslint/types@8.63.0':
+ resolution: {integrity: sha512-xyLtl9DUBBFrcJS4x2pIqGLH68/tC2uOa4Z7pUteW09D3bXnnXUom4dyPikzWgB7llmIc1zoeI3aoUdC4rPK/Q==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
- '@typescript-eslint/typescript-estree@8.65.0':
- resolution: {integrity: sha512-JboAE2swaYt4tb1fHhHTABE2K+OLy09XfcTbhnk4Pw96f9dd2e9iYsJ28gBggHlo5z5x1rkyWvcPoTuNTd4oGg==}
+ '@typescript-eslint/typescript-estree@8.63.0':
+ resolution: {integrity: sha512-ygBkU+B7ex5UI/gKhaqexWev79uISfIv7XQCRNYO/jmD8rGLPyWLAb3KMRT6nd8Gt9bmUBi9+iX6tBdYfOY81Q==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
peerDependencies:
typescript: '>=4.8.4 <6.1.0'
- '@typescript-eslint/utils@8.65.0':
- resolution: {integrity: sha512-gXiwIHsYreboxeJucHKPvgwl7dXt50mF8s1/c00cP/WoVTyWKFdtfhRWwZiXYFU5H2O8vVoSLNrexFZjYS/SGA==}
+ '@typescript-eslint/utils@8.63.0':
+ resolution: {integrity: sha512-fUKaeAvrTuQg/Tgt3nliAUSZHJM6DlCcfyEmxCvlX8kieWSStBX+5O5Fnidtc3i2JrH+9c/GL4RY2iasd/GPTA==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
peerDependencies:
eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
typescript: '>=4.8.4 <6.1.0'
- '@typescript-eslint/visitor-keys@8.65.0':
- resolution: {integrity: sha512-8C71BQkGjiMmXtop7pHVJu1l2NNShFdkCyD6a2ezzs5vU/L3LRtb69EtcteFwz0mYMPzIgOw0n6OV4VBUWZd7A==}
+ '@typescript-eslint/visitor-keys@8.63.0':
+ resolution: {integrity: sha512-UexrHGnGTpbuQHct2ExOc2ZcFbGUS9FOesCxxqdBGcpI1BxYu/LZ6U8Aq6/72XtF/qRBk9nhuGHFJIXXMhPMdw==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
'@unrs/resolver-binding-android-arm-eabi@1.12.2':
@@ -1319,28 +1312,34 @@ packages:
resolution: {integrity: sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==}
engines: {node: '>= 0.4'}
+ balanced-match@1.0.2:
+ resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==}
+
balanced-match@4.0.4:
resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==}
engines: {node: 18 || 20 || >=22}
- baseline-browser-mapping@2.11.5:
- resolution: {integrity: sha512-xJo6a6YZnwZfnyGmQKWMbVOcii7XRibjOskRh+WJ9UHQoX16xrQrcIgAMQOzfvs8XiLMx6ih/fsLPF73iY2D1A==}
+ baseline-browser-mapping@2.10.43:
+ resolution: {integrity: sha512-AjYpR78kDWAY3Efj+cDTFH9t9SCoL7OoTp1BOb0mQV7S+6CiLwnWM3FyxhJtdPufDFKzmCSFoUncKjWgJEZTCQ==}
engines: {node: '>=6.0.0'}
hasBin: true
bidi-js@1.0.3:
resolution: {integrity: sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==}
- brace-expansion@5.0.8:
- resolution: {integrity: sha512-JZyDyq3D4AUifKTPOB7DELf6XsB3WdPuNxCtob1vFXPsSXhdAiHBWJ/tJ8HAc9aH84BK+5JFZLNkJKx3G9kzQg==}
- engines: {node: 20 || >=22}
+ brace-expansion@1.1.16:
+ resolution: {integrity: sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw==}
+
+ brace-expansion@5.0.7:
+ resolution: {integrity: sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==}
+ engines: {node: 18 || 20 || >=22}
braces@3.0.3:
resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==}
engines: {node: '>=8'}
- browserslist@4.28.7:
- resolution: {integrity: sha512-JxV13hNrFxqjOc8alRbq9dK1MM79NEXYpma2B2J4wAtpWS5zIEIKqWPGCl7N4o7Uc7B7itylh7SuDujATRyyTw==}
+ browserslist@4.28.6:
+ resolution: {integrity: sha512-FQBYNK15VMslhLHpA7+n+n1GOlF1kId2xcCg7/j95f24AOF6VDYMNH4mFxF7KuaTdv627faazpOAjFzMrfJOUw==}
engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7}
hasBin: true
@@ -1360,8 +1359,8 @@ packages:
resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==}
engines: {node: '>=6'}
- caniuse-lite@1.0.30001806:
- resolution: {integrity: sha512-72Cuvd95zbSYPKq6Fhg8eDJRlzgWDf7/mtoZv6Qe/DYNCEBdNxoA3+rZAU2ZhGCpZlns3EssFavaZomckT5Uuw==}
+ caniuse-lite@1.0.30001805:
+ resolution: {integrity: sha512-52noaS3DubycKSXaU30TwPGIp+POyQSUVa5jBEq3vkRkY0kjyb3LQgvhU6WGyCcyXqVLWO0Cw0Q6BSdD0kUfVA==}
chai@6.2.2:
resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==}
@@ -1392,6 +1391,9 @@ packages:
resolution: {integrity: sha512-4m5s3Me2xxlVKG9PkZpQqHQR7bgpnN7joDMJ4yvVkVXngjoITG76IaZmzmywSeRTeTpc6N6r3H3+KyUurV8OYw==}
engines: {node: '>=18'}
+ concat-map@0.0.1:
+ resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==}
+
convert-source-map@2.0.0:
resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==}
@@ -1468,14 +1470,14 @@ packages:
resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==}
engines: {node: '>= 0.4'}
- electron-to-chromium@1.5.396:
- resolution: {integrity: sha512-yHiw2Y3C3H9U6TMbOfoWK/BPreiOPXRfTWPBwQBoZG6/8TB6eOPnsy5oaRYuatR7Fw2SJ4kKforgufeo7fq0EQ==}
+ electron-to-chromium@1.5.389:
+ resolution: {integrity: sha512-cEto7aeOqBfU1D+c5py5pE+ooscKE75JifxLBdFUZsqAxRS6y7kebtxAZvICszSl05gPjYHDTjY+lXpyGvpJbg==}
emoji-regex@9.2.2:
resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==}
- enhanced-resolve@5.24.3:
- resolution: {integrity: sha512-PwKooW9JUzh5chmYfHM3IQl5OkK2u2Nm011MgeZrss3JmFraUx/fqrf78kk8GUMYoibx/14MdwTl/1WKkG7TpQ==}
+ enhanced-resolve@5.21.6:
+ resolution: {integrity: sha512-aNnGCvbJ/RIyWo1IuhNdVjnNF+EjH9wpzpNHt+ci/m9He9LJvUN8wrCcXjp9cWsGNAuvSpVFTx/vraAFQ8qGjQ==}
engines: {node: '>=10.13.0'}
entities@8.0.0:
@@ -1529,8 +1531,8 @@ packages:
resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==}
engines: {node: '>=10'}
- eslint-config-next@16.2.12:
- resolution: {integrity: sha512-iaaf4vvKo5h2LBdGt0JuRv7t0Ysqr9FMCiFxbptDg8LqOE//mIKR80DdpOnSVM7qjLH3jT8P0aFiwXxBEGZRXw==}
+ eslint-config-next@16.2.10:
+ resolution: {integrity: sha512-HSybLOY0QKf39i4FWUqPN0xWiNDi6A6UqJmZtgDkS3zMqjXTqULvj/sueXx3cdCG0mVG+qH6k5/qdegklH1d1w==}
peerDependencies:
eslint: '>=9.0.0'
typescript: '>=3.3.1'
@@ -1701,8 +1703,8 @@ packages:
resolution: {integrity: sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==}
engines: {node: '>=16'}
- flatted@3.4.3:
- resolution: {integrity: sha512-/zipXxyO6rGvuNGDiULY9MvEGSkb2gaG4GGH4ygMi0ZZzyMHdUZBmntJmx5x1G2VuPytCwGN4xsJP6cw+sK+vQ==}
+ flatted@3.4.2:
+ resolution: {integrity: sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==}
for-each@0.3.5:
resolution: {integrity: sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==}
@@ -1984,11 +1986,11 @@ packages:
resolution: {integrity: sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==}
hasBin: true
- jsdom@30.0.0:
- resolution: {integrity: sha512-JQHfRGmmKmaZoUAvIgff5jjG/0SzTQlGz8c7t72KzBzo8ZULEjAjnYE0sNwBOUA4QtWwYE2xoYitg8NFsmiYxA==}
- engines: {node: ^22.22.2 || ^24.15.0 || >=26.0.0}
+ jsdom@29.1.1:
+ resolution: {integrity: sha512-ECi4Fi2f7BdJtUKTflYRTiaMxIB0O6zfR1fX0GXpUrf6flp8QIYn1UT20YQqdSOfk2dfkCwS8LAFoJDEppNK5Q==}
+ engines: {node: ^20.19.0 || ^22.13.0 || >=24.0.0}
peerDependencies:
- canvas: ^3.2.3
+ canvas: ^3.0.0
peerDependenciesMeta:
canvas:
optional: true
@@ -2043,60 +2045,30 @@ packages:
cpu: [arm64]
os: [android]
- lightningcss-android-arm64@1.33.0:
- resolution: {integrity: sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==}
- engines: {node: '>= 12.0.0'}
- cpu: [arm64]
- os: [android]
-
lightningcss-darwin-arm64@1.32.0:
resolution: {integrity: sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==}
engines: {node: '>= 12.0.0'}
cpu: [arm64]
os: [darwin]
- lightningcss-darwin-arm64@1.33.0:
- resolution: {integrity: sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==}
- engines: {node: '>= 12.0.0'}
- cpu: [arm64]
- os: [darwin]
-
lightningcss-darwin-x64@1.32.0:
resolution: {integrity: sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==}
engines: {node: '>= 12.0.0'}
cpu: [x64]
os: [darwin]
- lightningcss-darwin-x64@1.33.0:
- resolution: {integrity: sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==}
- engines: {node: '>= 12.0.0'}
- cpu: [x64]
- os: [darwin]
-
lightningcss-freebsd-x64@1.32.0:
resolution: {integrity: sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==}
engines: {node: '>= 12.0.0'}
cpu: [x64]
os: [freebsd]
- lightningcss-freebsd-x64@1.33.0:
- resolution: {integrity: sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==}
- engines: {node: '>= 12.0.0'}
- cpu: [x64]
- os: [freebsd]
-
lightningcss-linux-arm-gnueabihf@1.32.0:
resolution: {integrity: sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==}
engines: {node: '>= 12.0.0'}
cpu: [arm]
os: [linux]
- lightningcss-linux-arm-gnueabihf@1.33.0:
- resolution: {integrity: sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==}
- engines: {node: '>= 12.0.0'}
- cpu: [arm]
- os: [linux]
-
lightningcss-linux-arm64-gnu@1.32.0:
resolution: {integrity: sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==}
engines: {node: '>= 12.0.0'}
@@ -2104,13 +2076,6 @@ packages:
os: [linux]
libc: [glibc]
- lightningcss-linux-arm64-gnu@1.33.0:
- resolution: {integrity: sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==}
- engines: {node: '>= 12.0.0'}
- cpu: [arm64]
- os: [linux]
- libc: [glibc]
-
lightningcss-linux-arm64-musl@1.32.0:
resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==}
engines: {node: '>= 12.0.0'}
@@ -2118,13 +2083,6 @@ packages:
os: [linux]
libc: [musl]
- lightningcss-linux-arm64-musl@1.33.0:
- resolution: {integrity: sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==}
- engines: {node: '>= 12.0.0'}
- cpu: [arm64]
- os: [linux]
- libc: [musl]
-
lightningcss-linux-x64-gnu@1.32.0:
resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==}
engines: {node: '>= 12.0.0'}
@@ -2132,13 +2090,6 @@ packages:
os: [linux]
libc: [glibc]
- lightningcss-linux-x64-gnu@1.33.0:
- resolution: {integrity: sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==}
- engines: {node: '>= 12.0.0'}
- cpu: [x64]
- os: [linux]
- libc: [glibc]
-
lightningcss-linux-x64-musl@1.32.0:
resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==}
engines: {node: '>= 12.0.0'}
@@ -2146,45 +2097,22 @@ packages:
os: [linux]
libc: [musl]
- lightningcss-linux-x64-musl@1.33.0:
- resolution: {integrity: sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==}
- engines: {node: '>= 12.0.0'}
- cpu: [x64]
- os: [linux]
- libc: [musl]
-
lightningcss-win32-arm64-msvc@1.32.0:
resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==}
engines: {node: '>= 12.0.0'}
cpu: [arm64]
os: [win32]
- lightningcss-win32-arm64-msvc@1.33.0:
- resolution: {integrity: sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==}
- engines: {node: '>= 12.0.0'}
- cpu: [arm64]
- os: [win32]
-
lightningcss-win32-x64-msvc@1.32.0:
resolution: {integrity: sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==}
engines: {node: '>= 12.0.0'}
cpu: [x64]
os: [win32]
- lightningcss-win32-x64-msvc@1.33.0:
- resolution: {integrity: sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==}
- engines: {node: '>= 12.0.0'}
- cpu: [x64]
- os: [win32]
-
lightningcss@1.32.0:
resolution: {integrity: sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==}
engines: {node: '>= 12.0.0'}
- lightningcss@1.33.0:
- resolution: {integrity: sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==}
- engines: {node: '>= 12.0.0'}
-
locate-path@6.0.0:
resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==}
engines: {node: '>=10'}
@@ -2203,8 +2131,8 @@ packages:
lru-cache@5.1.1:
resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==}
- lucide-react@1.27.0:
- resolution: {integrity: sha512-rJicGl/3Fly/E0rOH1YmPZ6e49JCnKknh1ox1vpHnkfjujAkKA6sqUZvH3MTAaXXjgexyUwgNwTJzTtYuAFYJw==}
+ lucide-react@1.24.0:
+ resolution: {integrity: sha512-YT6mBD8lGKkg4nM39enlm94/sfJIiW0YKUT60fBy4YK8tai31ylg1VhGNWxkpSKHo9UagfnZqwIff3HTDQwXeA==}
peerDependencies:
react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0
@@ -2233,8 +2161,8 @@ packages:
resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==}
engines: {node: '>=8.6'}
- minimatch@10.2.6:
- resolution: {integrity: sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==}
+ minimatch@10.2.5:
+ resolution: {integrity: sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==}
engines: {node: 18 || 20 || >=22}
minimatch@3.1.5:
@@ -2246,8 +2174,8 @@ packages:
ms@2.1.3:
resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
- nanoid@3.3.16:
- resolution: {integrity: sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==}
+ nanoid@3.3.15:
+ resolution: {integrity: sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==}
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
hasBin: true
@@ -2259,8 +2187,8 @@ packages:
natural-compare@1.4.0:
resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==}
- next@16.2.12:
- resolution: {integrity: sha512-iD59eYQWmbFcEbX7v/acG5DRym9iw1DdaPoD0WTA920naWsE25wShzJW4+UvAs8MK9EC2kBfIH6vtto1H1PHGw==}
+ next@16.2.10:
+ resolution: {integrity: sha512-2som5AVXb3kE6Yjine3/mNbBayYF58eguBWIVVUdr1y/L426xyVEgYxgBG+1QC34P2x5E+tcDup6XkuOAX3dCA==}
engines: {node: '>=20.9.0'}
hasBin: true
peerDependencies:
@@ -2328,8 +2256,8 @@ packages:
resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==}
engines: {node: '>= 0.8.0'}
- own-keys@1.0.2:
- resolution: {integrity: sha512-19YVAg7T+WTrxggPukVq7DjTv6+PJ867TmhCvBsYwmbFCsZd344rq2Ld1p0wo8f8Qrrhgp82c6FJRqdXWtSEhg==}
+ own-keys@1.0.1:
+ resolution: {integrity: sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg==}
engines: {node: '>= 0.4'}
p-limit@3.1.0:
@@ -2372,22 +2300,22 @@ packages:
resolution: {integrity: sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==}
engines: {node: '>=12'}
- playwright-core@1.62.0:
- resolution: {integrity: sha512-nsNRyq0r2zsG8AcRHWknc9QRA5XCueC7gWMrs+Gx2tlZn9hcl8zudfh00lhJPY1DE7NmZ6bDsT9g2yey8mXljA==}
- engines: {node: '>=20'}
+ playwright-core@1.61.1:
+ resolution: {integrity: sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg==}
+ engines: {node: '>=18'}
hasBin: true
- playwright@1.62.0:
- resolution: {integrity: sha512-Z14dG305dgaLu6foB1TXQagFiW8JfSUIUaUuPaKQ6NtBPKF1P/qXcqfh6c6K/icPqdy37JmjbiBXf6JNg6Sylw==}
- engines: {node: '>=20'}
+ playwright@1.61.1:
+ resolution: {integrity: sha512-DWnY5o3YbLWK4GovuAVwpqL+1VwGNdUGrRr++8j8PtQQzvAVZUIMjKQ90fY689sEJZJBbZVw1rXaOKSTitkzPQ==}
+ engines: {node: '>=18'}
hasBin: true
possible-typed-array-names@1.1.0:
resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==}
engines: {node: '>= 0.4'}
- postcss@8.5.18:
- resolution: {integrity: sha512-xdB1oSLHbz1vRWgCDalrCqEFTWzFlhqFC5tIHLMOSUIjhm3XXQ1qrFy8S/ESr1JYRRXqM3c1QFiMZUJdUTqyMQ==}
+ postcss@8.5.17:
+ resolution: {integrity: sha512-J7EF+8X+CzRPaJPOv9Ck2wNWJvGnnl3PcNPAdGg6GTLjyVpyQ0yATMSXRFRV01BviT/9Gwuc3rjEyJbDJG9a4w==}
engines: {node: ^10 || ^12 || >=14}
prelude-ls@1.2.1:
@@ -2407,22 +2335,22 @@ packages:
queue-microtask@1.2.3:
resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==}
- react-dom@19.2.8:
- resolution: {integrity: sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ==}
+ react-dom@19.2.7:
+ resolution: {integrity: sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ==}
peerDependencies:
- react: ^19.2.8
+ react: ^19.2.7
react-is@16.13.1:
resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==}
- react-resizable-panels@4.12.2:
- resolution: {integrity: sha512-NwY5LCo4WrxVvDh0xoMML6EMLPONP/8ckKcIdpnojxexoatZdjLiRqLJQjQK5CPkd4SYiB/2M5BVrjZBQtOO7Q==}
+ react-resizable-panels@4.12.1:
+ resolution: {integrity: sha512-ElE/UpOvMLRWtAqbCgyizHXcbws8RPMyN3cBqmdY17Nxr5f01+DEwzOLqhgcy68GSnjtIUFgKWKl8aIgx5aypQ==}
peerDependencies:
react: ^18.0.0 || ^19.0.0
react-dom: ^18.0.0 || ^19.0.0
- react@19.2.8:
- resolution: {integrity: sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==}
+ react@19.2.7:
+ resolution: {integrity: sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ==}
engines: {node: '>=0.10.0'}
reflect.getprototypeof@1.0.10:
@@ -2504,9 +2432,9 @@ packages:
resolution: {integrity: sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw==}
engines: {node: '>= 0.4'}
- sharp@0.35.0:
- resolution: {integrity: sha512-BqvG5XbwPZ4NV0DK90d86leEECMsoa8bO0nqnKWlBDYxri4GJ7c4EDInaF6q20lTh/mATmnDIKWJFfXnoVfH5g==}
- engines: {node: '>=20.9.0'}
+ sharp@0.34.5:
+ resolution: {integrity: sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==}
+ engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
shebang-command@2.0.0:
resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==}
@@ -2610,8 +2538,8 @@ packages:
tailwind-merge@3.6.0:
resolution: {integrity: sha512-uxL7qAVQriqRQPAyK3pj66VqskWqoZ37PW94jwOTwNfq/z9oyu1V+eqrZqtR2+fCiXdYOZe/Modt8GtvqNzu+w==}
- tailwindcss@4.3.3:
- resolution: {integrity: sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ==}
+ tailwindcss@4.3.2:
+ resolution: {integrity: sha512-WtctNNSH8A9jlMIqxzuYumOHU5uGZyRv0Q5svQl+oEPy5w84YpBxdb7MdqyiSPQge5jTJ6zFQLq0PFygdccSBA==}
tapable@2.3.3:
resolution: {integrity: sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==}
@@ -2632,11 +2560,11 @@ packages:
resolution: {integrity: sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==}
engines: {node: '>=14.0.0'}
- tldts-core@7.4.9:
- resolution: {integrity: sha512-DxKfPBI52p2msTEu7MPhdpdDTBhhVQg1a/8PjQckeyAvO13eMYElX545grIp6nnTGIMZlRvFZPvFhvI/WIz2Vg==}
+ tldts-core@7.4.8:
+ resolution: {integrity: sha512-c1P7u0EhACHj7lPy4MJm8iTFEU8+nB0LCtddH0fhP7noaVoXAqafMtOOeX+ulpuPBqnrRgRhw494RICT3mbhnw==}
- tldts@7.4.9:
- resolution: {integrity: sha512-3kZ8wQQ/k5DrChD4X4FVvr2D7E5uoRgAqkPyLpSCGUvqOvqu+JEdr3mwMUaVWb+vMHZaKhF5fp2PBigKsui7hA==}
+ tldts@7.4.8:
+ resolution: {integrity: sha512-htwgN/8KRB3z3vnC0BOETVh2m499g5GmyTK9Wq5JBLX3FNz6tSBveAd+fQhzy9hkjif8vy2jwDMR1sGhLtZl2A==}
hasBin: true
to-regex-range@5.0.1:
@@ -2686,8 +2614,8 @@ packages:
resolution: {integrity: sha512-phPGCwqr2+Qo0fwniCE8e4pKnGu/yFb5nD5Y8bf0EEeiI5GklnACYA9GFy/DrAeRrKHXvHn+1SUsOWgJp6RO+g==}
engines: {node: '>= 0.4'}
- typescript-eslint@8.65.0:
- resolution: {integrity: sha512-/ggrHAwyjENDusvyxbuqxAC2dTnZg/Z8F+fgQtYIz+L6n/9HfSlEZcFGV/NsMNa6CkGk0xUjUAFwC0vHOflvIA==}
+ typescript-eslint@8.63.0:
+ resolution: {integrity: sha512-xgwXyzG4sK9ALkBxbyGkTMMOS+imnW65iPhxCQMK83KhxyoDNW7l+IDqEf9vMdoUidHpOoS967RCq4eMiTexwQ==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
peerDependencies:
eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
@@ -2853,10 +2781,6 @@ packages:
resolution: {integrity: sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==}
engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0}
- whatwg-url@17.1.0:
- resolution: {integrity: sha512-3GeworPmc2ZfEEHP7lEbUfBX/L75wdEsi0rLNhXcXxnoN5jyq0SL5gCy06SGW2cyTIZdTvWIDQNQoza++vKeaw==}
- engines: {node: ^22.14.0 || >=24.0.0}
-
which-boxed-primitive@1.1.1:
resolution: {integrity: sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==}
engines: {node: '>= 0.4'}
@@ -2914,20 +2838,25 @@ snapshots:
'@alloc/quick-lru@5.2.0': {}
- '@asamuzakjp/css-color@6.0.5':
+ '@asamuzakjp/css-color@5.1.11':
dependencies:
- '@csstools/css-calc': 3.3.0(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)
- '@csstools/css-color-parser': 4.1.10(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)
+ '@asamuzakjp/generational-cache': 1.0.1
+ '@csstools/css-calc': 3.2.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)
+ '@csstools/css-color-parser': 4.1.9(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)
'@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0)
'@csstools/css-tokenizer': 4.0.0
- lru-cache: 11.5.2
- '@asamuzakjp/dom-selector@8.3.0':
+ '@asamuzakjp/dom-selector@7.1.1':
dependencies:
+ '@asamuzakjp/generational-cache': 1.0.1
+ '@asamuzakjp/nwsapi': 2.3.9
bidi-js: 1.0.3
css-tree: 3.2.1
is-potential-custom-element-name: 1.0.1
- lru-cache: 11.5.2
+
+ '@asamuzakjp/generational-cache@1.0.1': {}
+
+ '@asamuzakjp/nwsapi@2.3.9': {}
'@babel/code-frame@7.29.7':
dependencies:
@@ -2969,7 +2898,7 @@ snapshots:
dependencies:
'@babel/compat-data': 7.29.7
'@babel/helper-validator-option': 7.29.7
- browserslist: 4.28.7
+ browserslist: 4.28.6
lru-cache: 5.1.1
semver: 6.3.1
@@ -3031,26 +2960,26 @@ snapshots:
'@babel/helper-string-parser': 7.29.7
'@babel/helper-validator-identifier': 7.29.7
- '@base-ui/react@1.6.0(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)':
+ '@base-ui/react@1.6.0(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)':
dependencies:
'@babel/runtime': 7.29.7
- '@base-ui/utils': 0.3.1(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
- '@floating-ui/react-dom': 2.1.9(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
+ '@base-ui/utils': 0.3.1(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
+ '@floating-ui/react-dom': 2.1.9(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
'@floating-ui/utils': 0.2.12
- react: 19.2.8
- react-dom: 19.2.8(react@19.2.8)
- use-sync-external-store: 1.6.0(react@19.2.8)
+ react: 19.2.7
+ react-dom: 19.2.7(react@19.2.7)
+ use-sync-external-store: 1.6.0(react@19.2.7)
optionalDependencies:
'@types/react': 19.2.17
- '@base-ui/utils@0.3.1(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)':
+ '@base-ui/utils@0.3.1(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)':
dependencies:
'@babel/runtime': 7.29.7
'@floating-ui/utils': 0.2.12
- react: 19.2.8
- react-dom: 19.2.8(react@19.2.8)
+ react: 19.2.7
+ react-dom: 19.2.7(react@19.2.7)
reselect: 5.2.0
- use-sync-external-store: 1.6.0(react@19.2.8)
+ use-sync-external-store: 1.6.0(react@19.2.7)
optionalDependencies:
'@types/react': 19.2.17
@@ -3062,15 +2991,15 @@ snapshots:
'@csstools/color-helpers@6.1.0': {}
- '@csstools/css-calc@3.3.0(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)':
+ '@csstools/css-calc@3.2.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)':
dependencies:
'@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0)
'@csstools/css-tokenizer': 4.0.0
- '@csstools/css-color-parser@4.1.10(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)':
+ '@csstools/css-color-parser@4.1.9(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)':
dependencies:
'@csstools/color-helpers': 6.1.0
- '@csstools/css-calc': 3.3.0(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)
+ '@csstools/css-calc': 3.2.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)
'@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0)
'@csstools/css-tokenizer': 4.0.0
@@ -3078,7 +3007,7 @@ snapshots:
dependencies:
'@csstools/css-tokenizer': 4.0.0
- '@csstools/css-syntax-patches-for-csstree@1.1.7(css-tree@3.2.1)':
+ '@csstools/css-syntax-patches-for-csstree@1.1.6(css-tree@3.2.1)':
optionalDependencies:
css-tree: 3.2.1
@@ -3110,7 +3039,7 @@ snapshots:
tslib: 2.8.1
optional: true
- '@emnapi/runtime@1.11.3':
+ '@emnapi/runtime@1.11.2':
dependencies:
tslib: 2.8.1
optional: true
@@ -3125,7 +3054,7 @@ snapshots:
tslib: 2.8.1
optional: true
- '@eslint-community/eslint-utils@4.10.1(eslint@9.39.5(jiti@2.7.0))':
+ '@eslint-community/eslint-utils@4.9.1(eslint@9.39.5(jiti@2.7.0))':
dependencies:
eslint: 9.39.5(jiti@2.7.0)
eslint-visitor-keys: 3.4.3
@@ -3136,7 +3065,7 @@ snapshots:
dependencies:
'@eslint/object-schema': 2.1.7
debug: 4.4.3
- minimatch: 3.1.5(patch_hash=5f38b9c5382c1163b0389810f5e4e867519096f3c11a6df0a51d7cafbdfa93e2)
+ minimatch: 3.1.5
transitivePeerDependencies:
- supports-color
@@ -3157,7 +3086,7 @@ snapshots:
ignore: 5.3.2
import-fresh: 3.3.1
js-yaml: 4.3.0
- minimatch: 3.1.5(patch_hash=5f38b9c5382c1163b0389810f5e4e867519096f3c11a6df0a51d7cafbdfa93e2)
+ minimatch: 3.1.5
strip-json-comments: 3.1.1
transitivePeerDependencies:
- supports-color
@@ -3182,11 +3111,11 @@ snapshots:
'@floating-ui/core': 1.8.0
'@floating-ui/utils': 0.2.12
- '@floating-ui/react-dom@2.1.9(react-dom@19.2.8(react@19.2.8))(react@19.2.8)':
+ '@floating-ui/react-dom@2.1.9(react-dom@19.2.7(react@19.2.7))(react@19.2.7)':
dependencies:
'@floating-ui/dom': 1.8.0
- react: 19.2.8
- react-dom: 19.2.8(react@19.2.8)
+ react: 19.2.7
+ react-dom: 19.2.7(react@19.2.7)
'@floating-ui/utils@0.2.12': {}
@@ -3209,108 +3138,98 @@ snapshots:
'@img/colour@1.1.0':
optional: true
- '@img/sharp-darwin-arm64@0.35.0':
+ '@img/sharp-darwin-arm64@0.34.5':
optionalDependencies:
- '@img/sharp-libvips-darwin-arm64': 1.3.0
+ '@img/sharp-libvips-darwin-arm64': 1.2.4
optional: true
- '@img/sharp-darwin-x64@0.35.0':
+ '@img/sharp-darwin-x64@0.34.5':
optionalDependencies:
- '@img/sharp-libvips-darwin-x64': 1.3.0
+ '@img/sharp-libvips-darwin-x64': 1.2.4
optional: true
- '@img/sharp-freebsd-wasm32@0.35.0':
- dependencies:
- '@img/sharp-wasm32': 0.35.0
+ '@img/sharp-libvips-darwin-arm64@1.2.4':
optional: true
- '@img/sharp-libvips-darwin-arm64@1.3.0':
+ '@img/sharp-libvips-darwin-x64@1.2.4':
optional: true
- '@img/sharp-libvips-darwin-x64@1.3.0':
+ '@img/sharp-libvips-linux-arm64@1.2.4':
optional: true
- '@img/sharp-libvips-linux-arm64@1.3.0':
+ '@img/sharp-libvips-linux-arm@1.2.4':
optional: true
- '@img/sharp-libvips-linux-arm@1.3.0':
+ '@img/sharp-libvips-linux-ppc64@1.2.4':
optional: true
- '@img/sharp-libvips-linux-ppc64@1.3.0':
+ '@img/sharp-libvips-linux-riscv64@1.2.4':
optional: true
- '@img/sharp-libvips-linux-riscv64@1.3.0':
+ '@img/sharp-libvips-linux-s390x@1.2.4':
optional: true
- '@img/sharp-libvips-linux-s390x@1.3.0':
+ '@img/sharp-libvips-linux-x64@1.2.4':
optional: true
- '@img/sharp-libvips-linux-x64@1.3.0':
+ '@img/sharp-libvips-linuxmusl-arm64@1.2.4':
optional: true
- '@img/sharp-libvips-linuxmusl-arm64@1.3.0':
+ '@img/sharp-libvips-linuxmusl-x64@1.2.4':
optional: true
- '@img/sharp-libvips-linuxmusl-x64@1.3.0':
- optional: true
-
- '@img/sharp-linux-arm64@0.35.0':
+ '@img/sharp-linux-arm64@0.34.5':
optionalDependencies:
- '@img/sharp-libvips-linux-arm64': 1.3.0
+ '@img/sharp-libvips-linux-arm64': 1.2.4
optional: true
- '@img/sharp-linux-arm@0.35.0':
+ '@img/sharp-linux-arm@0.34.5':
optionalDependencies:
- '@img/sharp-libvips-linux-arm': 1.3.0
+ '@img/sharp-libvips-linux-arm': 1.2.4
optional: true
- '@img/sharp-linux-ppc64@0.35.0':
+ '@img/sharp-linux-ppc64@0.34.5':
optionalDependencies:
- '@img/sharp-libvips-linux-ppc64': 1.3.0
+ '@img/sharp-libvips-linux-ppc64': 1.2.4
optional: true
- '@img/sharp-linux-riscv64@0.35.0':
+ '@img/sharp-linux-riscv64@0.34.5':
optionalDependencies:
- '@img/sharp-libvips-linux-riscv64': 1.3.0
+ '@img/sharp-libvips-linux-riscv64': 1.2.4
optional: true
- '@img/sharp-linux-s390x@0.35.0':
+ '@img/sharp-linux-s390x@0.34.5':
optionalDependencies:
- '@img/sharp-libvips-linux-s390x': 1.3.0
+ '@img/sharp-libvips-linux-s390x': 1.2.4
optional: true
- '@img/sharp-linux-x64@0.35.0':
+ '@img/sharp-linux-x64@0.34.5':
optionalDependencies:
- '@img/sharp-libvips-linux-x64': 1.3.0
+ '@img/sharp-libvips-linux-x64': 1.2.4
optional: true
- '@img/sharp-linuxmusl-arm64@0.35.0':
+ '@img/sharp-linuxmusl-arm64@0.34.5':
optionalDependencies:
- '@img/sharp-libvips-linuxmusl-arm64': 1.3.0
+ '@img/sharp-libvips-linuxmusl-arm64': 1.2.4
optional: true
- '@img/sharp-linuxmusl-x64@0.35.0':
+ '@img/sharp-linuxmusl-x64@0.34.5':
optionalDependencies:
- '@img/sharp-libvips-linuxmusl-x64': 1.3.0
+ '@img/sharp-libvips-linuxmusl-x64': 1.2.4
optional: true
- '@img/sharp-wasm32@0.35.0':
+ '@img/sharp-wasm32@0.34.5':
dependencies:
- '@emnapi/runtime': 1.11.3
+ '@emnapi/runtime': 1.11.2
optional: true
- '@img/sharp-webcontainers-wasm32@0.35.0':
- dependencies:
- '@img/sharp-wasm32': 0.35.0
+ '@img/sharp-win32-arm64@0.34.5':
optional: true
- '@img/sharp-win32-arm64@0.35.0':
+ '@img/sharp-win32-ia32@0.34.5':
optional: true
- '@img/sharp-win32-ia32@0.35.0':
- optional: true
-
- '@img/sharp-win32-x64@0.35.0':
+ '@img/sharp-win32-x64@0.34.5':
optional: true
'@jridgewell/gen-mapping@0.3.13':
@@ -3346,34 +3265,34 @@ snapshots:
'@tybys/wasm-util': 0.10.3
optional: true
- '@next/env@16.2.12': {}
+ '@next/env@16.2.10': {}
- '@next/eslint-plugin-next@16.2.12':
+ '@next/eslint-plugin-next@16.2.10':
dependencies:
fast-glob: 3.3.1
- '@next/swc-darwin-arm64@16.2.12':
+ '@next/swc-darwin-arm64@16.2.10':
optional: true
- '@next/swc-darwin-x64@16.2.12':
+ '@next/swc-darwin-x64@16.2.10':
optional: true
- '@next/swc-linux-arm64-gnu@16.2.12':
+ '@next/swc-linux-arm64-gnu@16.2.10':
optional: true
- '@next/swc-linux-arm64-musl@16.2.12':
+ '@next/swc-linux-arm64-musl@16.2.10':
optional: true
- '@next/swc-linux-x64-gnu@16.2.12':
+ '@next/swc-linux-x64-gnu@16.2.10':
optional: true
- '@next/swc-linux-x64-musl@16.2.12':
+ '@next/swc-linux-x64-musl@16.2.10':
optional: true
- '@next/swc-win32-arm64-msvc@16.2.12':
+ '@next/swc-win32-arm64-msvc@16.2.10':
optional: true
- '@next/swc-win32-x64-msvc@16.2.12':
+ '@next/swc-win32-x64-msvc@16.2.10':
optional: true
'@nodelib/fs.scandir@2.1.5':
@@ -3392,140 +3311,139 @@ snapshots:
'@oxc-project/types@0.139.0': {}
- '@playwright/test@1.62.0':
+ '@playwright/test@1.61.1':
dependencies:
- playwright: 1.62.0
+ playwright: 1.61.1
- '@radix-ui/primitive@1.1.7': {}
+ '@radix-ui/primitive@1.1.5': {}
- '@radix-ui/react-collection@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)':
+ '@radix-ui/react-collection@1.1.12(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)':
dependencies:
- '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.2.17)(react@19.2.8)
- '@radix-ui/react-context': 1.2.2(@types/react@19.2.17)(react@19.2.8)
- '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
- '@radix-ui/react-slot': 1.3.3(@types/react@19.2.17)(react@19.2.8)
- react: 19.2.8
- react-dom: 19.2.8(react@19.2.8)
+ '@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.17)(react@19.2.7)
+ '@radix-ui/react-context': 1.2.0(@types/react@19.2.17)(react@19.2.7)
+ '@radix-ui/react-primitive': 2.1.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
+ '@radix-ui/react-slot': 1.3.0(@types/react@19.2.17)(react@19.2.7)
+ react: 19.2.7
+ react-dom: 19.2.7(react@19.2.7)
optionalDependencies:
'@types/react': 19.2.17
'@types/react-dom': 19.2.3(@types/react@19.2.17)
- '@radix-ui/react-compose-refs@1.1.5(@types/react@19.2.17)(react@19.2.8)':
+ '@radix-ui/react-compose-refs@1.1.3(@types/react@19.2.17)(react@19.2.7)':
dependencies:
- react: 19.2.8
+ react: 19.2.7
optionalDependencies:
'@types/react': 19.2.17
- '@radix-ui/react-context@1.2.2(@types/react@19.2.17)(react@19.2.8)':
+ '@radix-ui/react-context@1.2.0(@types/react@19.2.17)(react@19.2.7)':
dependencies:
- react: 19.2.8
+ react: 19.2.7
optionalDependencies:
'@types/react': 19.2.17
- '@radix-ui/react-direction@1.1.4(@types/react@19.2.17)(react@19.2.8)':
+ '@radix-ui/react-direction@1.1.2(@types/react@19.2.17)(react@19.2.7)':
dependencies:
- react: 19.2.8
+ react: 19.2.7
optionalDependencies:
'@types/react': 19.2.17
- '@radix-ui/react-id@1.1.4(@types/react@19.2.17)(react@19.2.8)':
+ '@radix-ui/react-id@1.1.2(@types/react@19.2.17)(react@19.2.7)':
dependencies:
- '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.2.17)(react@19.2.8)
- react: 19.2.8
+ '@radix-ui/react-use-layout-effect': 1.1.2(@types/react@19.2.17)(react@19.2.7)
+ react: 19.2.7
optionalDependencies:
'@types/react': 19.2.17
- '@radix-ui/react-presence@1.1.10(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)':
+ '@radix-ui/react-presence@1.1.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)':
dependencies:
- '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.2.17)(react@19.2.8)
- react: 19.2.8
- react-dom: 19.2.8(react@19.2.8)
+ '@radix-ui/react-use-layout-effect': 1.1.2(@types/react@19.2.17)(react@19.2.7)
+ react: 19.2.7
+ react-dom: 19.2.7(react@19.2.7)
optionalDependencies:
'@types/react': 19.2.17
'@types/react-dom': 19.2.3(@types/react@19.2.17)
- '@radix-ui/react-primitive@2.1.10(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)':
+ '@radix-ui/react-primitive@2.1.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)':
dependencies:
- '@radix-ui/react-slot': 1.3.3(@types/react@19.2.17)(react@19.2.8)
- react: 19.2.8
- react-dom: 19.2.8(react@19.2.8)
+ '@radix-ui/react-slot': 1.3.0(@types/react@19.2.17)(react@19.2.7)
+ react: 19.2.7
+ react-dom: 19.2.7(react@19.2.7)
optionalDependencies:
'@types/react': 19.2.17
'@types/react-dom': 19.2.3(@types/react@19.2.17)
- '@radix-ui/react-roving-focus@1.1.19(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)':
- dependencies:
- '@radix-ui/primitive': 1.1.7
- '@radix-ui/react-collection': 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
- '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.2.17)(react@19.2.8)
- '@radix-ui/react-context': 1.2.2(@types/react@19.2.17)(react@19.2.8)
- '@radix-ui/react-direction': 1.1.4(@types/react@19.2.17)(react@19.2.8)
- '@radix-ui/react-id': 1.1.4(@types/react@19.2.17)(react@19.2.8)
- '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
- '@radix-ui/react-use-callback-ref': 1.1.4(@types/react@19.2.17)(react@19.2.8)
- '@radix-ui/react-use-controllable-state': 1.2.6(@types/react@19.2.17)(react@19.2.8)
- '@radix-ui/react-use-is-hydrated': 0.1.3(@types/react@19.2.17)(react@19.2.8)
- '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.2.17)(react@19.2.8)
- react: 19.2.8
- react-dom: 19.2.8(react@19.2.8)
+ '@radix-ui/react-roving-focus@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)':
+ dependencies:
+ '@radix-ui/primitive': 1.1.5
+ '@radix-ui/react-collection': 1.1.12(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
+ '@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.17)(react@19.2.7)
+ '@radix-ui/react-context': 1.2.0(@types/react@19.2.17)(react@19.2.7)
+ '@radix-ui/react-direction': 1.1.2(@types/react@19.2.17)(react@19.2.7)
+ '@radix-ui/react-id': 1.1.2(@types/react@19.2.17)(react@19.2.7)
+ '@radix-ui/react-primitive': 2.1.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
+ '@radix-ui/react-use-callback-ref': 1.1.2(@types/react@19.2.17)(react@19.2.7)
+ '@radix-ui/react-use-controllable-state': 1.2.3(@types/react@19.2.17)(react@19.2.7)
+ '@radix-ui/react-use-is-hydrated': 0.1.1(@types/react@19.2.17)(react@19.2.7)
+ '@radix-ui/react-use-layout-effect': 1.1.2(@types/react@19.2.17)(react@19.2.7)
+ react: 19.2.7
+ react-dom: 19.2.7(react@19.2.7)
optionalDependencies:
'@types/react': 19.2.17
'@types/react-dom': 19.2.3(@types/react@19.2.17)
- '@radix-ui/react-slot@1.3.3(@types/react@19.2.17)(react@19.2.8)':
+ '@radix-ui/react-slot@1.3.0(@types/react@19.2.17)(react@19.2.7)':
dependencies:
- '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.2.17)(react@19.2.8)
- react: 19.2.8
+ '@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.17)(react@19.2.7)
+ react: 19.2.7
optionalDependencies:
'@types/react': 19.2.17
- '@radix-ui/react-tabs@1.1.21(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)':
- dependencies:
- '@radix-ui/primitive': 1.1.7
- '@radix-ui/react-context': 1.2.2(@types/react@19.2.17)(react@19.2.8)
- '@radix-ui/react-direction': 1.1.4(@types/react@19.2.17)(react@19.2.8)
- '@radix-ui/react-id': 1.1.4(@types/react@19.2.17)(react@19.2.8)
- '@radix-ui/react-presence': 1.1.10(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
- '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
- '@radix-ui/react-roving-focus': 1.1.19(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
- '@radix-ui/react-use-controllable-state': 1.2.6(@types/react@19.2.17)(react@19.2.8)
- react: 19.2.8
- react-dom: 19.2.8(react@19.2.8)
+ '@radix-ui/react-tabs@1.1.17(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)':
+ dependencies:
+ '@radix-ui/primitive': 1.1.5
+ '@radix-ui/react-context': 1.2.0(@types/react@19.2.17)(react@19.2.7)
+ '@radix-ui/react-direction': 1.1.2(@types/react@19.2.17)(react@19.2.7)
+ '@radix-ui/react-id': 1.1.2(@types/react@19.2.17)(react@19.2.7)
+ '@radix-ui/react-presence': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
+ '@radix-ui/react-primitive': 2.1.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
+ '@radix-ui/react-roving-focus': 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
+ '@radix-ui/react-use-controllable-state': 1.2.3(@types/react@19.2.17)(react@19.2.7)
+ react: 19.2.7
+ react-dom: 19.2.7(react@19.2.7)
optionalDependencies:
'@types/react': 19.2.17
'@types/react-dom': 19.2.3(@types/react@19.2.17)
- '@radix-ui/react-use-callback-ref@1.1.4(@types/react@19.2.17)(react@19.2.8)':
+ '@radix-ui/react-use-callback-ref@1.1.2(@types/react@19.2.17)(react@19.2.7)':
dependencies:
- react: 19.2.8
+ react: 19.2.7
optionalDependencies:
'@types/react': 19.2.17
- '@radix-ui/react-use-controllable-state@1.2.6(@types/react@19.2.17)(react@19.2.8)':
+ '@radix-ui/react-use-controllable-state@1.2.3(@types/react@19.2.17)(react@19.2.7)':
dependencies:
- '@radix-ui/primitive': 1.1.7
- '@radix-ui/react-use-effect-event': 0.0.5(@types/react@19.2.17)(react@19.2.8)
- '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.2.17)(react@19.2.8)
- react: 19.2.8
+ '@radix-ui/react-use-effect-event': 0.0.3(@types/react@19.2.17)(react@19.2.7)
+ '@radix-ui/react-use-layout-effect': 1.1.2(@types/react@19.2.17)(react@19.2.7)
+ react: 19.2.7
optionalDependencies:
'@types/react': 19.2.17
- '@radix-ui/react-use-effect-event@0.0.5(@types/react@19.2.17)(react@19.2.8)':
+ '@radix-ui/react-use-effect-event@0.0.3(@types/react@19.2.17)(react@19.2.7)':
dependencies:
- '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.2.17)(react@19.2.8)
- react: 19.2.8
+ '@radix-ui/react-use-layout-effect': 1.1.2(@types/react@19.2.17)(react@19.2.7)
+ react: 19.2.7
optionalDependencies:
'@types/react': 19.2.17
- '@radix-ui/react-use-is-hydrated@0.1.3(@types/react@19.2.17)(react@19.2.8)':
+ '@radix-ui/react-use-is-hydrated@0.1.1(@types/react@19.2.17)(react@19.2.7)':
dependencies:
- react: 19.2.8
+ react: 19.2.7
optionalDependencies:
'@types/react': 19.2.17
- '@radix-ui/react-use-layout-effect@1.1.4(@types/react@19.2.17)(react@19.2.8)':
+ '@radix-ui/react-use-layout-effect@1.1.2(@types/react@19.2.17)(react@19.2.7)':
dependencies:
- react: 19.2.8
+ react: 19.2.7
optionalDependencies:
'@types/react': 19.2.17
@@ -3588,74 +3506,74 @@ snapshots:
dependencies:
tslib: 2.8.1
- '@tailwindcss/node@4.3.3':
+ '@tailwindcss/node@4.3.2':
dependencies:
'@jridgewell/remapping': 2.3.5
- enhanced-resolve: 5.24.3
+ enhanced-resolve: 5.21.6
jiti: 2.7.0
lightningcss: 1.32.0
magic-string: 0.30.21
source-map-js: 1.2.1
- tailwindcss: 4.3.3
+ tailwindcss: 4.3.2
- '@tailwindcss/oxide-android-arm64@4.3.3':
+ '@tailwindcss/oxide-android-arm64@4.3.2':
optional: true
- '@tailwindcss/oxide-darwin-arm64@4.3.3':
+ '@tailwindcss/oxide-darwin-arm64@4.3.2':
optional: true
- '@tailwindcss/oxide-darwin-x64@4.3.3':
+ '@tailwindcss/oxide-darwin-x64@4.3.2':
optional: true
- '@tailwindcss/oxide-freebsd-x64@4.3.3':
+ '@tailwindcss/oxide-freebsd-x64@4.3.2':
optional: true
- '@tailwindcss/oxide-linux-arm-gnueabihf@4.3.3':
+ '@tailwindcss/oxide-linux-arm-gnueabihf@4.3.2':
optional: true
- '@tailwindcss/oxide-linux-arm64-gnu@4.3.3':
+ '@tailwindcss/oxide-linux-arm64-gnu@4.3.2':
optional: true
- '@tailwindcss/oxide-linux-arm64-musl@4.3.3':
+ '@tailwindcss/oxide-linux-arm64-musl@4.3.2':
optional: true
- '@tailwindcss/oxide-linux-x64-gnu@4.3.3':
+ '@tailwindcss/oxide-linux-x64-gnu@4.3.2':
optional: true
- '@tailwindcss/oxide-linux-x64-musl@4.3.3':
+ '@tailwindcss/oxide-linux-x64-musl@4.3.2':
optional: true
- '@tailwindcss/oxide-wasm32-wasi@4.3.3':
+ '@tailwindcss/oxide-wasm32-wasi@4.3.2':
optional: true
- '@tailwindcss/oxide-win32-arm64-msvc@4.3.3':
+ '@tailwindcss/oxide-win32-arm64-msvc@4.3.2':
optional: true
- '@tailwindcss/oxide-win32-x64-msvc@4.3.3':
+ '@tailwindcss/oxide-win32-x64-msvc@4.3.2':
optional: true
- '@tailwindcss/oxide@4.3.3':
+ '@tailwindcss/oxide@4.3.2':
optionalDependencies:
- '@tailwindcss/oxide-android-arm64': 4.3.3
- '@tailwindcss/oxide-darwin-arm64': 4.3.3
- '@tailwindcss/oxide-darwin-x64': 4.3.3
- '@tailwindcss/oxide-freebsd-x64': 4.3.3
- '@tailwindcss/oxide-linux-arm-gnueabihf': 4.3.3
- '@tailwindcss/oxide-linux-arm64-gnu': 4.3.3
- '@tailwindcss/oxide-linux-arm64-musl': 4.3.3
- '@tailwindcss/oxide-linux-x64-gnu': 4.3.3
- '@tailwindcss/oxide-linux-x64-musl': 4.3.3
- '@tailwindcss/oxide-wasm32-wasi': 4.3.3
- '@tailwindcss/oxide-win32-arm64-msvc': 4.3.3
- '@tailwindcss/oxide-win32-x64-msvc': 4.3.3
-
- '@tailwindcss/postcss@4.3.3':
+ '@tailwindcss/oxide-android-arm64': 4.3.2
+ '@tailwindcss/oxide-darwin-arm64': 4.3.2
+ '@tailwindcss/oxide-darwin-x64': 4.3.2
+ '@tailwindcss/oxide-freebsd-x64': 4.3.2
+ '@tailwindcss/oxide-linux-arm-gnueabihf': 4.3.2
+ '@tailwindcss/oxide-linux-arm64-gnu': 4.3.2
+ '@tailwindcss/oxide-linux-arm64-musl': 4.3.2
+ '@tailwindcss/oxide-linux-x64-gnu': 4.3.2
+ '@tailwindcss/oxide-linux-x64-musl': 4.3.2
+ '@tailwindcss/oxide-wasm32-wasi': 4.3.2
+ '@tailwindcss/oxide-win32-arm64-msvc': 4.3.2
+ '@tailwindcss/oxide-win32-x64-msvc': 4.3.2
+
+ '@tailwindcss/postcss@4.3.2':
dependencies:
'@alloc/quick-lru': 5.2.0
- '@tailwindcss/node': 4.3.3
- '@tailwindcss/oxide': 4.3.3
- postcss: 8.5.18
- tailwindcss: 4.3.3
+ '@tailwindcss/node': 4.3.2
+ '@tailwindcss/oxide': 4.3.2
+ postcss: 8.5.17
+ tailwindcss: 4.3.2
'@tybys/wasm-util@0.10.3':
dependencies:
@@ -3677,7 +3595,7 @@ snapshots:
'@types/json5@0.0.29': {}
- '@types/node@26.1.2':
+ '@types/node@26.1.1':
dependencies:
undici-types: 8.3.0
@@ -3689,14 +3607,14 @@ snapshots:
dependencies:
csstype: 3.2.3
- '@typescript-eslint/eslint-plugin@8.65.0(@typescript-eslint/parser@8.65.0(eslint@9.39.5(jiti@2.7.0))(typescript@6.0.3))(eslint@9.39.5(jiti@2.7.0))(typescript@6.0.3)':
+ '@typescript-eslint/eslint-plugin@8.63.0(@typescript-eslint/parser@8.63.0(eslint@9.39.5(jiti@2.7.0))(typescript@6.0.3))(eslint@9.39.5(jiti@2.7.0))(typescript@6.0.3)':
dependencies:
'@eslint-community/regexpp': 4.12.2
- '@typescript-eslint/parser': 8.65.0(eslint@9.39.5(jiti@2.7.0))(typescript@6.0.3)
- '@typescript-eslint/scope-manager': 8.65.0
- '@typescript-eslint/type-utils': 8.65.0(eslint@9.39.5(jiti@2.7.0))(typescript@6.0.3)
- '@typescript-eslint/utils': 8.65.0(eslint@9.39.5(jiti@2.7.0))(typescript@6.0.3)
- '@typescript-eslint/visitor-keys': 8.65.0
+ '@typescript-eslint/parser': 8.63.0(eslint@9.39.5(jiti@2.7.0))(typescript@6.0.3)
+ '@typescript-eslint/scope-manager': 8.63.0
+ '@typescript-eslint/type-utils': 8.63.0(eslint@9.39.5(jiti@2.7.0))(typescript@6.0.3)
+ '@typescript-eslint/utils': 8.63.0(eslint@9.39.5(jiti@2.7.0))(typescript@6.0.3)
+ '@typescript-eslint/visitor-keys': 8.63.0
eslint: 9.39.5(jiti@2.7.0)
ignore: 7.0.6
natural-compare: 1.4.0
@@ -3705,41 +3623,41 @@ snapshots:
transitivePeerDependencies:
- supports-color
- '@typescript-eslint/parser@8.65.0(eslint@9.39.5(jiti@2.7.0))(typescript@6.0.3)':
+ '@typescript-eslint/parser@8.63.0(eslint@9.39.5(jiti@2.7.0))(typescript@6.0.3)':
dependencies:
- '@typescript-eslint/scope-manager': 8.65.0
- '@typescript-eslint/types': 8.65.0
- '@typescript-eslint/typescript-estree': 8.65.0(typescript@6.0.3)
- '@typescript-eslint/visitor-keys': 8.65.0
+ '@typescript-eslint/scope-manager': 8.63.0
+ '@typescript-eslint/types': 8.63.0
+ '@typescript-eslint/typescript-estree': 8.63.0(typescript@6.0.3)
+ '@typescript-eslint/visitor-keys': 8.63.0
debug: 4.4.3
eslint: 9.39.5(jiti@2.7.0)
typescript: 6.0.3
transitivePeerDependencies:
- supports-color
- '@typescript-eslint/project-service@8.65.0(typescript@6.0.3)':
+ '@typescript-eslint/project-service@8.63.0(typescript@6.0.3)':
dependencies:
- '@typescript-eslint/tsconfig-utils': 8.65.0(typescript@6.0.3)
- '@typescript-eslint/types': 8.65.0
+ '@typescript-eslint/tsconfig-utils': 8.63.0(typescript@6.0.3)
+ '@typescript-eslint/types': 8.63.0
debug: 4.4.3
typescript: 6.0.3
transitivePeerDependencies:
- supports-color
- '@typescript-eslint/scope-manager@8.65.0':
+ '@typescript-eslint/scope-manager@8.63.0':
dependencies:
- '@typescript-eslint/types': 8.65.0
- '@typescript-eslint/visitor-keys': 8.65.0
+ '@typescript-eslint/types': 8.63.0
+ '@typescript-eslint/visitor-keys': 8.63.0
- '@typescript-eslint/tsconfig-utils@8.65.0(typescript@6.0.3)':
+ '@typescript-eslint/tsconfig-utils@8.63.0(typescript@6.0.3)':
dependencies:
typescript: 6.0.3
- '@typescript-eslint/type-utils@8.65.0(eslint@9.39.5(jiti@2.7.0))(typescript@6.0.3)':
+ '@typescript-eslint/type-utils@8.63.0(eslint@9.39.5(jiti@2.7.0))(typescript@6.0.3)':
dependencies:
- '@typescript-eslint/types': 8.65.0
- '@typescript-eslint/typescript-estree': 8.65.0(typescript@6.0.3)
- '@typescript-eslint/utils': 8.65.0(eslint@9.39.5(jiti@2.7.0))(typescript@6.0.3)
+ '@typescript-eslint/types': 8.63.0
+ '@typescript-eslint/typescript-estree': 8.63.0(typescript@6.0.3)
+ '@typescript-eslint/utils': 8.63.0(eslint@9.39.5(jiti@2.7.0))(typescript@6.0.3)
debug: 4.4.3
eslint: 9.39.5(jiti@2.7.0)
ts-api-utils: 2.5.0(typescript@6.0.3)
@@ -3747,16 +3665,16 @@ snapshots:
transitivePeerDependencies:
- supports-color
- '@typescript-eslint/types@8.65.0': {}
+ '@typescript-eslint/types@8.63.0': {}
- '@typescript-eslint/typescript-estree@8.65.0(typescript@6.0.3)':
+ '@typescript-eslint/typescript-estree@8.63.0(typescript@6.0.3)':
dependencies:
- '@typescript-eslint/project-service': 8.65.0(typescript@6.0.3)
- '@typescript-eslint/tsconfig-utils': 8.65.0(typescript@6.0.3)
- '@typescript-eslint/types': 8.65.0
- '@typescript-eslint/visitor-keys': 8.65.0
+ '@typescript-eslint/project-service': 8.63.0(typescript@6.0.3)
+ '@typescript-eslint/tsconfig-utils': 8.63.0(typescript@6.0.3)
+ '@typescript-eslint/types': 8.63.0
+ '@typescript-eslint/visitor-keys': 8.63.0
debug: 4.4.3
- minimatch: 10.2.6
+ minimatch: 10.2.5
semver: 7.8.5
tinyglobby: 0.2.17
ts-api-utils: 2.5.0(typescript@6.0.3)
@@ -3764,20 +3682,20 @@ snapshots:
transitivePeerDependencies:
- supports-color
- '@typescript-eslint/utils@8.65.0(eslint@9.39.5(jiti@2.7.0))(typescript@6.0.3)':
+ '@typescript-eslint/utils@8.63.0(eslint@9.39.5(jiti@2.7.0))(typescript@6.0.3)':
dependencies:
- '@eslint-community/eslint-utils': 4.10.1(eslint@9.39.5(jiti@2.7.0))
- '@typescript-eslint/scope-manager': 8.65.0
- '@typescript-eslint/types': 8.65.0
- '@typescript-eslint/typescript-estree': 8.65.0(typescript@6.0.3)
+ '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.5(jiti@2.7.0))
+ '@typescript-eslint/scope-manager': 8.63.0
+ '@typescript-eslint/types': 8.63.0
+ '@typescript-eslint/typescript-estree': 8.63.0(typescript@6.0.3)
eslint: 9.39.5(jiti@2.7.0)
typescript: 6.0.3
transitivePeerDependencies:
- supports-color
- '@typescript-eslint/visitor-keys@8.65.0':
+ '@typescript-eslint/visitor-keys@8.63.0':
dependencies:
- '@typescript-eslint/types': 8.65.0
+ '@typescript-eslint/types': 8.63.0
eslint-visitor-keys: 5.0.1
'@unrs/resolver-binding-android-arm-eabi@1.12.2':
@@ -3862,7 +3780,7 @@ snapshots:
obug: 2.1.3
std-env: 4.2.0
tinyrainbow: 3.1.0
- vitest: 4.1.10(@types/node@26.1.2)(@vitest/coverage-v8@4.1.10)(jsdom@30.0.0)(vite@8.1.4(@types/node@26.1.2)(jiti@2.7.0))
+ vitest: 4.1.10(@types/node@26.1.1)(@vitest/coverage-v8@4.1.10)(jsdom@29.1.1)(vite@8.1.4(@types/node@26.1.1)(jiti@2.7.0))
'@vitest/expect@4.1.10':
dependencies:
@@ -3873,13 +3791,13 @@ snapshots:
chai: 6.2.2
tinyrainbow: 3.1.0
- '@vitest/mocker@4.1.10(vite@8.1.4(@types/node@26.1.2)(jiti@2.7.0))':
+ '@vitest/mocker@4.1.10(vite@8.1.4(@types/node@26.1.1)(jiti@2.7.0))':
dependencies:
'@vitest/spy': 4.1.10
estree-walker: 3.0.3
magic-string: 0.30.21
optionalDependencies:
- vite: 8.1.4(@types/node@26.1.2)(jiti@2.7.0)
+ vite: 8.1.4(@types/node@26.1.1)(jiti@2.7.0)
'@vitest/pretty-format@4.1.10':
dependencies:
@@ -4013,15 +3931,22 @@ snapshots:
axobject-query@4.1.0: {}
+ balanced-match@1.0.2: {}
+
balanced-match@4.0.4: {}
- baseline-browser-mapping@2.11.5: {}
+ baseline-browser-mapping@2.10.43: {}
bidi-js@1.0.3:
dependencies:
require-from-string: 2.0.2
- brace-expansion@5.0.8:
+ brace-expansion@1.1.16:
+ dependencies:
+ balanced-match: 1.0.2
+ concat-map: 0.0.1
+
+ brace-expansion@5.0.7:
dependencies:
balanced-match: 4.0.4
@@ -4029,13 +3954,13 @@ snapshots:
dependencies:
fill-range: 7.1.1
- browserslist@4.28.7:
+ browserslist@4.28.6:
dependencies:
- baseline-browser-mapping: 2.11.5
- caniuse-lite: 1.0.30001806
- electron-to-chromium: 1.5.396
+ baseline-browser-mapping: 2.10.43
+ caniuse-lite: 1.0.30001805
+ electron-to-chromium: 1.5.389
node-releases: 2.0.51
- update-browserslist-db: 1.2.3(browserslist@4.28.7)
+ update-browserslist-db: 1.2.3(browserslist@4.28.6)
call-bind-apply-helpers@1.0.2:
dependencies:
@@ -4056,7 +3981,7 @@ snapshots:
callsites@3.1.0: {}
- caniuse-lite@1.0.30001806: {}
+ caniuse-lite@1.0.30001805: {}
chai@6.2.2: {}
@@ -4081,6 +4006,8 @@ snapshots:
component-emitter@2.0.0: {}
+ concat-map@0.0.1: {}
+
convert-source-map@2.0.0: {}
cross-spawn@7.0.6:
@@ -4159,11 +4086,11 @@ snapshots:
es-errors: 1.3.0
gopd: 1.2.0
- electron-to-chromium@1.5.396: {}
+ electron-to-chromium@1.5.389: {}
emoji-regex@9.2.2: {}
- enhanced-resolve@5.24.3:
+ enhanced-resolve@5.21.6:
dependencies:
graceful-fs: 4.2.11
tapable: 2.3.3
@@ -4217,7 +4144,7 @@ snapshots:
object-inspect: 1.13.4
object-keys: 1.1.1
object.assign: 4.1.7
- own-keys: 1.0.2
+ own-keys: 1.0.1
regexp.prototype.flags: 1.5.4
safe-array-concat: 1.1.4
safe-push-apply: 1.0.0
@@ -4287,18 +4214,18 @@ snapshots:
escape-string-regexp@4.0.0: {}
- eslint-config-next@16.2.12(@typescript-eslint/parser@8.65.0(eslint@9.39.5(jiti@2.7.0))(typescript@6.0.3))(eslint@9.39.5(jiti@2.7.0))(typescript@6.0.3):
+ eslint-config-next@16.2.10(@typescript-eslint/parser@8.63.0(eslint@9.39.5(jiti@2.7.0))(typescript@6.0.3))(eslint@9.39.5(jiti@2.7.0))(typescript@6.0.3):
dependencies:
- '@next/eslint-plugin-next': 16.2.12
+ '@next/eslint-plugin-next': 16.2.10
eslint: 9.39.5(jiti@2.7.0)
eslint-import-resolver-node: 0.3.10
eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.5(jiti@2.7.0))
- eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.65.0(eslint@9.39.5(jiti@2.7.0))(typescript@6.0.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.5(jiti@2.7.0))
+ eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.63.0(eslint@9.39.5(jiti@2.7.0))(typescript@6.0.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.5(jiti@2.7.0))
eslint-plugin-jsx-a11y: 6.10.2(eslint@9.39.5(jiti@2.7.0))
eslint-plugin-react: 7.37.5(eslint@9.39.5(jiti@2.7.0))
eslint-plugin-react-hooks: 7.1.1(eslint@9.39.5(jiti@2.7.0))
globals: 16.4.0
- typescript-eslint: 8.65.0(eslint@9.39.5(jiti@2.7.0))(typescript@6.0.3)
+ typescript-eslint: 8.63.0(eslint@9.39.5(jiti@2.7.0))(typescript@6.0.3)
optionalDependencies:
typescript: 6.0.3
transitivePeerDependencies:
@@ -4326,22 +4253,22 @@ snapshots:
tinyglobby: 0.2.17
unrs-resolver: 1.12.2
optionalDependencies:
- eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.65.0(eslint@9.39.5(jiti@2.7.0))(typescript@6.0.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.5(jiti@2.7.0))
+ eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.63.0(eslint@9.39.5(jiti@2.7.0))(typescript@6.0.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.5(jiti@2.7.0))
transitivePeerDependencies:
- supports-color
- eslint-module-utils@2.14.0(@typescript-eslint/parser@8.65.0(eslint@9.39.5(jiti@2.7.0))(typescript@6.0.3))(eslint-import-resolver-node@0.3.10)(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.5(jiti@2.7.0)):
+ eslint-module-utils@2.14.0(@typescript-eslint/parser@8.63.0(eslint@9.39.5(jiti@2.7.0))(typescript@6.0.3))(eslint-import-resolver-node@0.3.10)(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.5(jiti@2.7.0)):
dependencies:
debug: 3.2.7
optionalDependencies:
- '@typescript-eslint/parser': 8.65.0(eslint@9.39.5(jiti@2.7.0))(typescript@6.0.3)
+ '@typescript-eslint/parser': 8.63.0(eslint@9.39.5(jiti@2.7.0))(typescript@6.0.3)
eslint: 9.39.5(jiti@2.7.0)
eslint-import-resolver-node: 0.3.10
eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.5(jiti@2.7.0))
transitivePeerDependencies:
- supports-color
- eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.65.0(eslint@9.39.5(jiti@2.7.0))(typescript@6.0.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.5(jiti@2.7.0)):
+ eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.63.0(eslint@9.39.5(jiti@2.7.0))(typescript@6.0.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.5(jiti@2.7.0)):
dependencies:
'@rtsao/scc': 1.1.0
array-includes: 3.1.9
@@ -4352,11 +4279,11 @@ snapshots:
doctrine: 2.1.0
eslint: 9.39.5(jiti@2.7.0)
eslint-import-resolver-node: 0.3.10
- eslint-module-utils: 2.14.0(@typescript-eslint/parser@8.65.0(eslint@9.39.5(jiti@2.7.0))(typescript@6.0.3))(eslint-import-resolver-node@0.3.10)(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.5(jiti@2.7.0))
+ eslint-module-utils: 2.14.0(@typescript-eslint/parser@8.63.0(eslint@9.39.5(jiti@2.7.0))(typescript@6.0.3))(eslint-import-resolver-node@0.3.10)(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.5(jiti@2.7.0))
hasown: 2.0.4
is-core-module: 2.16.2
is-glob: 4.0.3
- minimatch: 3.1.5(patch_hash=5f38b9c5382c1163b0389810f5e4e867519096f3c11a6df0a51d7cafbdfa93e2)
+ minimatch: 3.1.5
object.fromentries: 2.0.8
object.groupby: 1.0.3
object.values: 1.2.1
@@ -4364,7 +4291,7 @@ snapshots:
string.prototype.trimend: 1.0.10
tsconfig-paths: 3.15.0
optionalDependencies:
- '@typescript-eslint/parser': 8.65.0(eslint@9.39.5(jiti@2.7.0))(typescript@6.0.3)
+ '@typescript-eslint/parser': 8.63.0(eslint@9.39.5(jiti@2.7.0))(typescript@6.0.3)
transitivePeerDependencies:
- eslint-import-resolver-typescript
- eslint-import-resolver-webpack
@@ -4384,7 +4311,7 @@ snapshots:
hasown: 2.0.4
jsx-ast-utils: 3.3.5
language-tags: 1.0.9
- minimatch: 3.1.5(patch_hash=5f38b9c5382c1163b0389810f5e4e867519096f3c11a6df0a51d7cafbdfa93e2)
+ minimatch: 3.1.5
object.fromentries: 2.0.8
safe-regex-test: 1.1.0
string.prototype.includes: 2.0.1
@@ -4412,7 +4339,7 @@ snapshots:
estraverse: 5.3.0
hasown: 2.0.4
jsx-ast-utils: 3.3.5
- minimatch: 3.1.5(patch_hash=5f38b9c5382c1163b0389810f5e4e867519096f3c11a6df0a51d7cafbdfa93e2)
+ minimatch: 3.1.5
object.entries: 1.1.9
object.fromentries: 2.0.8
object.values: 1.2.1
@@ -4435,7 +4362,7 @@ snapshots:
eslint@9.39.5(jiti@2.7.0):
dependencies:
- '@eslint-community/eslint-utils': 4.10.1(eslint@9.39.5(jiti@2.7.0))
+ '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.5(jiti@2.7.0))
'@eslint-community/regexpp': 4.12.2
'@eslint/config-array': 0.21.2
'@eslint/config-helpers': 0.4.2
@@ -4466,7 +4393,7 @@ snapshots:
is-glob: 4.0.3
json-stable-stringify-without-jsonify: 1.0.1
lodash.merge: 4.6.2
- minimatch: 3.1.5(patch_hash=5f38b9c5382c1163b0389810f5e4e867519096f3c11a6df0a51d7cafbdfa93e2)
+ minimatch: 3.1.5
natural-compare: 1.4.0
optionator: 0.9.4
optionalDependencies:
@@ -4539,10 +4466,10 @@ snapshots:
flat-cache@4.0.1:
dependencies:
- flatted: 3.4.3
+ flatted: 3.4.2
keyv: 4.5.4
- flatted@3.4.3: {}
+ flatted@3.4.2: {}
for-each@0.3.5:
dependencies:
@@ -4830,12 +4757,12 @@ snapshots:
dependencies:
argparse: 2.0.1
- jsdom@30.0.0:
+ jsdom@29.1.1:
dependencies:
- '@asamuzakjp/css-color': 6.0.5
- '@asamuzakjp/dom-selector': 8.3.0
+ '@asamuzakjp/css-color': 5.1.11
+ '@asamuzakjp/dom-selector': 7.1.1
'@bramus/specificity': 2.4.2
- '@csstools/css-syntax-patches-for-csstree': 1.1.7(css-tree@3.2.1)
+ '@csstools/css-syntax-patches-for-csstree': 1.1.6(css-tree@3.2.1)
'@exodus/bytes': 1.15.1
css-tree: 3.2.1
data-urls: 7.0.0
@@ -4851,7 +4778,7 @@ snapshots:
w3c-xmlserializer: 5.0.0
webidl-conversions: 8.0.1
whatwg-mimetype: 5.0.0
- whatwg-url: 17.1.0
+ whatwg-url: 16.0.1
xml-name-validator: 5.0.0
transitivePeerDependencies:
- '@noble/hashes'
@@ -4897,69 +4824,36 @@ snapshots:
lightningcss-android-arm64@1.32.0:
optional: true
- lightningcss-android-arm64@1.33.0:
- optional: true
-
lightningcss-darwin-arm64@1.32.0:
optional: true
- lightningcss-darwin-arm64@1.33.0:
- optional: true
-
lightningcss-darwin-x64@1.32.0:
optional: true
- lightningcss-darwin-x64@1.33.0:
- optional: true
-
lightningcss-freebsd-x64@1.32.0:
optional: true
- lightningcss-freebsd-x64@1.33.0:
- optional: true
-
lightningcss-linux-arm-gnueabihf@1.32.0:
optional: true
- lightningcss-linux-arm-gnueabihf@1.33.0:
- optional: true
-
lightningcss-linux-arm64-gnu@1.32.0:
optional: true
- lightningcss-linux-arm64-gnu@1.33.0:
- optional: true
-
lightningcss-linux-arm64-musl@1.32.0:
optional: true
- lightningcss-linux-arm64-musl@1.33.0:
- optional: true
-
lightningcss-linux-x64-gnu@1.32.0:
optional: true
- lightningcss-linux-x64-gnu@1.33.0:
- optional: true
-
lightningcss-linux-x64-musl@1.32.0:
optional: true
- lightningcss-linux-x64-musl@1.33.0:
- optional: true
-
lightningcss-win32-arm64-msvc@1.32.0:
optional: true
- lightningcss-win32-arm64-msvc@1.33.0:
- optional: true
-
lightningcss-win32-x64-msvc@1.32.0:
optional: true
- lightningcss-win32-x64-msvc@1.33.0:
- optional: true
-
lightningcss@1.32.0:
dependencies:
detect-libc: 2.1.2
@@ -4976,22 +4870,6 @@ snapshots:
lightningcss-win32-arm64-msvc: 1.32.0
lightningcss-win32-x64-msvc: 1.32.0
- lightningcss@1.33.0:
- dependencies:
- detect-libc: 2.1.2
- optionalDependencies:
- lightningcss-android-arm64: 1.33.0
- lightningcss-darwin-arm64: 1.33.0
- lightningcss-darwin-x64: 1.33.0
- lightningcss-freebsd-x64: 1.33.0
- lightningcss-linux-arm-gnueabihf: 1.33.0
- lightningcss-linux-arm64-gnu: 1.33.0
- lightningcss-linux-arm64-musl: 1.33.0
- lightningcss-linux-x64-gnu: 1.33.0
- lightningcss-linux-x64-musl: 1.33.0
- lightningcss-win32-arm64-msvc: 1.33.0
- lightningcss-win32-x64-msvc: 1.33.0
-
locate-path@6.0.0:
dependencies:
p-locate: 5.0.0
@@ -5008,9 +4886,9 @@ snapshots:
dependencies:
yallist: 3.1.1
- lucide-react@1.27.0(react@19.2.8):
+ lucide-react@1.24.0(react@19.2.7):
dependencies:
- react: 19.2.8
+ react: 19.2.7
magic-string@0.30.21:
dependencies:
@@ -5037,45 +4915,45 @@ snapshots:
braces: 3.0.3
picomatch: 2.3.2
- minimatch@10.2.6:
+ minimatch@10.2.5:
dependencies:
- brace-expansion: 5.0.8
+ brace-expansion: 5.0.7
- minimatch@3.1.5(patch_hash=5f38b9c5382c1163b0389810f5e4e867519096f3c11a6df0a51d7cafbdfa93e2):
+ minimatch@3.1.5:
dependencies:
- brace-expansion: 5.0.8
+ brace-expansion: 1.1.16
minimist@1.2.8: {}
ms@2.1.3: {}
- nanoid@3.3.16: {}
+ nanoid@3.3.15: {}
napi-postinstall@0.3.4: {}
natural-compare@1.4.0: {}
- next@16.2.12(@babel/core@7.29.7)(@playwright/test@1.62.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8):
+ next@16.2.10(@babel/core@7.29.7)(@playwright/test@1.61.1)(react-dom@19.2.7(react@19.2.7))(react@19.2.7):
dependencies:
- '@next/env': 16.2.12
+ '@next/env': 16.2.10
'@swc/helpers': 0.5.15
- baseline-browser-mapping: 2.11.5
- caniuse-lite: 1.0.30001806
- postcss: 8.5.18
- react: 19.2.8
- react-dom: 19.2.8(react@19.2.8)
- styled-jsx: 5.1.6(@babel/core@7.29.7)(react@19.2.8)
+ baseline-browser-mapping: 2.10.43
+ caniuse-lite: 1.0.30001805
+ postcss: 8.5.17
+ react: 19.2.7
+ react-dom: 19.2.7(react@19.2.7)
+ styled-jsx: 5.1.6(@babel/core@7.29.7)(react@19.2.7)
optionalDependencies:
- '@next/swc-darwin-arm64': 16.2.12
- '@next/swc-darwin-x64': 16.2.12
- '@next/swc-linux-arm64-gnu': 16.2.12
- '@next/swc-linux-arm64-musl': 16.2.12
- '@next/swc-linux-x64-gnu': 16.2.12
- '@next/swc-linux-x64-musl': 16.2.12
- '@next/swc-win32-arm64-msvc': 16.2.12
- '@next/swc-win32-x64-msvc': 16.2.12
- '@playwright/test': 1.62.0
- sharp: 0.35.0
+ '@next/swc-darwin-arm64': 16.2.10
+ '@next/swc-darwin-x64': 16.2.10
+ '@next/swc-linux-arm64-gnu': 16.2.10
+ '@next/swc-linux-arm64-musl': 16.2.10
+ '@next/swc-linux-x64-gnu': 16.2.10
+ '@next/swc-linux-x64-musl': 16.2.10
+ '@next/swc-win32-arm64-msvc': 16.2.10
+ '@next/swc-win32-x64-msvc': 16.2.10
+ '@playwright/test': 1.61.1
+ sharp: 0.34.5
transitivePeerDependencies:
- '@babel/core'
- babel-plugin-macros
@@ -5142,9 +5020,8 @@ snapshots:
type-check: 0.4.0
word-wrap: 1.2.5
- own-keys@1.0.2:
+ own-keys@1.0.1:
dependencies:
- call-bound: 1.0.4
get-intrinsic: 1.3.0
object-keys: 1.1.1
safe-push-apply: 1.0.0
@@ -5179,19 +5056,19 @@ snapshots:
picomatch@4.0.5: {}
- playwright-core@1.62.0: {}
+ playwright-core@1.61.1: {}
- playwright@1.62.0:
+ playwright@1.61.1:
dependencies:
- playwright-core: 1.62.0
+ playwright-core: 1.61.1
optionalDependencies:
fsevents: 2.3.2
possible-typed-array-names@1.1.0: {}
- postcss@8.5.18:
+ postcss@8.5.17:
dependencies:
- nanoid: 3.3.16
+ nanoid: 3.3.15
picocolors: 1.1.1
source-map-js: 1.2.1
@@ -5209,19 +5086,19 @@ snapshots:
queue-microtask@1.2.3: {}
- react-dom@19.2.8(react@19.2.8):
+ react-dom@19.2.7(react@19.2.7):
dependencies:
- react: 19.2.8
+ react: 19.2.7
scheduler: 0.27.0
react-is@16.13.1: {}
- react-resizable-panels@4.12.2(react-dom@19.2.8(react@19.2.8))(react@19.2.8):
+ react-resizable-panels@4.12.1(react-dom@19.2.7(react@19.2.7))(react@19.2.7):
dependencies:
- react: 19.2.8
- react-dom: 19.2.8(react@19.2.8)
+ react: 19.2.7
+ react-dom: 19.2.7(react@19.2.7)
- react@19.2.8: {}
+ react@19.2.7: {}
reflect.getprototypeof@1.0.10:
dependencies:
@@ -5338,37 +5215,36 @@ snapshots:
es-errors: 1.3.0
es-object-atoms: 1.1.2
- sharp@0.35.0:
+ sharp@0.34.5:
dependencies:
'@img/colour': 1.1.0
detect-libc: 2.1.2
semver: 7.8.5
optionalDependencies:
- '@img/sharp-darwin-arm64': 0.35.0
- '@img/sharp-darwin-x64': 0.35.0
- '@img/sharp-freebsd-wasm32': 0.35.0
- '@img/sharp-libvips-darwin-arm64': 1.3.0
- '@img/sharp-libvips-darwin-x64': 1.3.0
- '@img/sharp-libvips-linux-arm': 1.3.0
- '@img/sharp-libvips-linux-arm64': 1.3.0
- '@img/sharp-libvips-linux-ppc64': 1.3.0
- '@img/sharp-libvips-linux-riscv64': 1.3.0
- '@img/sharp-libvips-linux-s390x': 1.3.0
- '@img/sharp-libvips-linux-x64': 1.3.0
- '@img/sharp-libvips-linuxmusl-arm64': 1.3.0
- '@img/sharp-libvips-linuxmusl-x64': 1.3.0
- '@img/sharp-linux-arm': 0.35.0
- '@img/sharp-linux-arm64': 0.35.0
- '@img/sharp-linux-ppc64': 0.35.0
- '@img/sharp-linux-riscv64': 0.35.0
- '@img/sharp-linux-s390x': 0.35.0
- '@img/sharp-linux-x64': 0.35.0
- '@img/sharp-linuxmusl-arm64': 0.35.0
- '@img/sharp-linuxmusl-x64': 0.35.0
- '@img/sharp-webcontainers-wasm32': 0.35.0
- '@img/sharp-win32-arm64': 0.35.0
- '@img/sharp-win32-ia32': 0.35.0
- '@img/sharp-win32-x64': 0.35.0
+ '@img/sharp-darwin-arm64': 0.34.5
+ '@img/sharp-darwin-x64': 0.34.5
+ '@img/sharp-libvips-darwin-arm64': 1.2.4
+ '@img/sharp-libvips-darwin-x64': 1.2.4
+ '@img/sharp-libvips-linux-arm': 1.2.4
+ '@img/sharp-libvips-linux-arm64': 1.2.4
+ '@img/sharp-libvips-linux-ppc64': 1.2.4
+ '@img/sharp-libvips-linux-riscv64': 1.2.4
+ '@img/sharp-libvips-linux-s390x': 1.2.4
+ '@img/sharp-libvips-linux-x64': 1.2.4
+ '@img/sharp-libvips-linuxmusl-arm64': 1.2.4
+ '@img/sharp-libvips-linuxmusl-x64': 1.2.4
+ '@img/sharp-linux-arm': 0.34.5
+ '@img/sharp-linux-arm64': 0.34.5
+ '@img/sharp-linux-ppc64': 0.34.5
+ '@img/sharp-linux-riscv64': 0.34.5
+ '@img/sharp-linux-s390x': 0.34.5
+ '@img/sharp-linux-x64': 0.34.5
+ '@img/sharp-linuxmusl-arm64': 0.34.5
+ '@img/sharp-linuxmusl-x64': 0.34.5
+ '@img/sharp-wasm32': 0.34.5
+ '@img/sharp-win32-arm64': 0.34.5
+ '@img/sharp-win32-ia32': 0.34.5
+ '@img/sharp-win32-x64': 0.34.5
optional: true
shebang-command@2.0.0:
@@ -5475,10 +5351,10 @@ snapshots:
strip-json-comments@3.1.1: {}
- styled-jsx@5.1.6(@babel/core@7.29.7)(react@19.2.8):
+ styled-jsx@5.1.6(@babel/core@7.29.7)(react@19.2.7):
dependencies:
client-only: 0.0.1
- react: 19.2.8
+ react: 19.2.7
optionalDependencies:
'@babel/core': 7.29.7
@@ -5492,7 +5368,7 @@ snapshots:
tailwind-merge@3.6.0: {}
- tailwindcss@4.3.3: {}
+ tailwindcss@4.3.2: {}
tapable@2.3.3: {}
@@ -5507,11 +5383,11 @@ snapshots:
tinyrainbow@3.1.0: {}
- tldts-core@7.4.9: {}
+ tldts-core@7.4.8: {}
- tldts@7.4.9:
+ tldts@7.4.8:
dependencies:
- tldts-core: 7.4.9
+ tldts-core: 7.4.8
to-regex-range@5.0.1:
dependencies:
@@ -5519,7 +5395,7 @@ snapshots:
tough-cookie@6.0.2:
dependencies:
- tldts: 7.4.9
+ tldts: 7.4.8
tr46@6.0.0:
dependencies:
@@ -5577,12 +5453,12 @@ snapshots:
possible-typed-array-names: 1.1.0
reflect.getprototypeof: 1.0.10
- typescript-eslint@8.65.0(eslint@9.39.5(jiti@2.7.0))(typescript@6.0.3):
+ typescript-eslint@8.63.0(eslint@9.39.5(jiti@2.7.0))(typescript@6.0.3):
dependencies:
- '@typescript-eslint/eslint-plugin': 8.65.0(@typescript-eslint/parser@8.65.0(eslint@9.39.5(jiti@2.7.0))(typescript@6.0.3))(eslint@9.39.5(jiti@2.7.0))(typescript@6.0.3)
- '@typescript-eslint/parser': 8.65.0(eslint@9.39.5(jiti@2.7.0))(typescript@6.0.3)
- '@typescript-eslint/typescript-estree': 8.65.0(typescript@6.0.3)
- '@typescript-eslint/utils': 8.65.0(eslint@9.39.5(jiti@2.7.0))(typescript@6.0.3)
+ '@typescript-eslint/eslint-plugin': 8.63.0(@typescript-eslint/parser@8.63.0(eslint@9.39.5(jiti@2.7.0))(typescript@6.0.3))(eslint@9.39.5(jiti@2.7.0))(typescript@6.0.3)
+ '@typescript-eslint/parser': 8.63.0(eslint@9.39.5(jiti@2.7.0))(typescript@6.0.3)
+ '@typescript-eslint/typescript-estree': 8.63.0(typescript@6.0.3)
+ '@typescript-eslint/utils': 8.63.0(eslint@9.39.5(jiti@2.7.0))(typescript@6.0.3)
eslint: 9.39.5(jiti@2.7.0)
typescript: 6.0.3
transitivePeerDependencies:
@@ -5628,9 +5504,9 @@ snapshots:
'@unrs/resolver-binding-win32-ia32-msvc': 1.12.2
'@unrs/resolver-binding-win32-x64-msvc': 1.12.2
- update-browserslist-db@1.2.3(browserslist@4.28.7):
+ update-browserslist-db@1.2.3(browserslist@4.28.6):
dependencies:
- browserslist: 4.28.7
+ browserslist: 4.28.6
escalade: 3.2.0
picocolors: 1.1.1
@@ -5638,9 +5514,9 @@ snapshots:
dependencies:
punycode: 2.3.1
- use-sync-external-store@1.6.0(react@19.2.8):
+ use-sync-external-store@1.6.0(react@19.2.7):
dependencies:
- react: 19.2.8
+ react: 19.2.7
uuid@14.0.1: {}
@@ -5663,22 +5539,22 @@ snapshots:
'@egjs/hammerjs': 2.0.17
component-emitter: 2.0.0
- vite@8.1.4(@types/node@26.1.2)(jiti@2.7.0):
+ vite@8.1.4(@types/node@26.1.1)(jiti@2.7.0):
dependencies:
- lightningcss: 1.33.0
+ lightningcss: 1.32.0
picomatch: 4.0.5
- postcss: 8.5.18
+ postcss: 8.5.17
rolldown: 1.1.5
tinyglobby: 0.2.17
optionalDependencies:
- '@types/node': 26.1.2
+ '@types/node': 26.1.1
fsevents: 2.3.3
jiti: 2.7.0
- vitest@4.1.10(@types/node@26.1.2)(@vitest/coverage-v8@4.1.10)(jsdom@30.0.0)(vite@8.1.4(@types/node@26.1.2)(jiti@2.7.0)):
+ vitest@4.1.10(@types/node@26.1.1)(@vitest/coverage-v8@4.1.10)(jsdom@29.1.1)(vite@8.1.4(@types/node@26.1.1)(jiti@2.7.0)):
dependencies:
'@vitest/expect': 4.1.10
- '@vitest/mocker': 4.1.10(vite@8.1.4(@types/node@26.1.2)(jiti@2.7.0))
+ '@vitest/mocker': 4.1.10(vite@8.1.4(@types/node@26.1.1)(jiti@2.7.0))
'@vitest/pretty-format': 4.1.10
'@vitest/runner': 4.1.10
'@vitest/snapshot': 4.1.10
@@ -5695,12 +5571,12 @@ snapshots:
tinyexec: 1.2.4
tinyglobby: 0.2.17
tinyrainbow: 3.1.0
- vite: 8.1.4(@types/node@26.1.2)(jiti@2.7.0)
+ vite: 8.1.4(@types/node@26.1.1)(jiti@2.7.0)
why-is-node-running: 2.3.0
optionalDependencies:
- '@types/node': 26.1.2
+ '@types/node': 26.1.1
'@vitest/coverage-v8': 4.1.10(vitest@4.1.10)
- jsdom: 30.0.0
+ jsdom: 29.1.1
transitivePeerDependencies:
- msw
@@ -5720,14 +5596,6 @@ snapshots:
transitivePeerDependencies:
- '@noble/hashes'
- whatwg-url@17.1.0:
- dependencies:
- '@exodus/bytes': 1.15.1
- tr46: 6.0.0
- webidl-conversions: 8.0.1
- transitivePeerDependencies:
- - '@noble/hashes'
-
which-boxed-primitive@1.1.1:
dependencies:
is-bigint: 1.1.0
diff --git a/frontend/pnpm-workspace.yaml b/frontend/pnpm-workspace.yaml
index 369e792d6..2110ae54c 100644
--- a/frontend/pnpm-workspace.yaml
+++ b/frontend/pnpm-workspace.yaml
@@ -14,10 +14,5 @@ supportedArchitectures:
- glibc
overrides:
- brace-expansion: "5.0.8"
- postcss: "8.5.18"
- sharp: "0.35.0"
+ postcss: ^8.5.15
undici: 7.28.0
-
-patchedDependencies:
- minimatch@3.1.5: patches/minimatch@3.1.5.patch
diff --git a/frontend/scripts/full-product-ui-smoke.mjs b/frontend/scripts/full-product-ui-smoke.mjs
index 008d61267..07c0bb5b9 100644
--- a/frontend/scripts/full-product-ui-smoke.mjs
+++ b/frontend/scripts/full-product-ui-smoke.mjs
@@ -1,7 +1,6 @@
-import { execFile, spawn } from "node:child_process";
-import { access, mkdtemp, writeFile } from "node:fs/promises";
+import { spawn } from "node:child_process";
+import { mkdir, access, writeFile } from "node:fs/promises";
import net from "node:net";
-import { tmpdir } from "node:os";
import path from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
@@ -9,14 +8,12 @@ import { chromium } from "@playwright/test";
const scriptDir = path.dirname(fileURLToPath(import.meta.url));
const frontendDir = path.resolve(scriptDir, "..");
-const nextCliPath = path.join(frontendDir, "node_modules", "next", "dist", "bin", "next");
-const requestedBaseUrl = process.env.NARUON_FULL_PRODUCT_BASE_URL || "http://127.0.0.1:3001";
-const requestedScreenshotProfile = resolveFullProductScreenshotProfile();
+const baseUrl = process.env.NARUON_FULL_PRODUCT_BASE_URL || "http://127.0.0.1:3001";
+const screenshotDir = process.env.NARUON_FULL_PRODUCT_SCREENSHOT_DIR || "/tmp/naruon-full-product-smoke";
+const ALLOWED_FULL_PRODUCT_HOSTS = new Set(["127.0.0.1", "localhost", "::1", "[::1]"]);
const SERVER_PROBE_TIMEOUT_MS = 5_000;
const SERVER_READY_TIMEOUT_MS = 90_000;
const IS_WINDOWS = process.platform === "win32";
-const DEFAULT_FULL_PRODUCT_SCREENSHOT_PROFILE = "/tmp/naruon-full-product-smoke";
-const RESPONSIVE_FULL_PRODUCT_SCREENSHOT_PROFILE = "/tmp/naruon-full-product-responsive-qa";
export const FULL_PRODUCT_ROUTES = [
{ path: "/", name: "home", expectedText: "Naruon" },
@@ -56,93 +53,14 @@ export const FULL_PRODUCT_ACCESSIBILITY_CHECK_NAMES = [
];
export function resolveFullProductBaseUrl(rawBaseUrl) {
- switch (rawBaseUrl) {
- case "http://127.0.0.1:3001":
- case "http://127.0.0.1:3001/":
- return new URL("http://127.0.0.1:3001");
- case "http://localhost:3001":
- case "http://localhost:3001/":
- return new URL("http://localhost:3001");
- case "http://[::1]:3001":
- case "http://[::1]:3001/":
- return new URL("http://[::1]:3001");
- default:
- throw new Error("Full product smoke must run only against approved localhost targets on port 3001");
+ const fullProductBaseUrl = new URL(rawBaseUrl);
+ if (!ALLOWED_FULL_PRODUCT_HOSTS.has(fullProductBaseUrl.hostname)) {
+ throw new Error(`Full product smoke must run only against localhost targets, got: ${fullProductBaseUrl.hostname}`);
}
+ return fullProductBaseUrl;
}
-export function resolveFullProductChromePath(rawChromePath, platform = process.platform) {
- if (rawChromePath !== undefined) {
- switch (rawChromePath) {
- case "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome":
- return "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome";
- case "/usr/bin/google-chrome":
- return "/usr/bin/google-chrome";
- case "/usr/bin/google-chrome-stable":
- return "/usr/bin/google-chrome-stable";
- case "/usr/bin/chromium":
- return "/usr/bin/chromium";
- case "/usr/bin/chromium-browser":
- return "/usr/bin/chromium-browser";
- case "C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe":
- return "C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe";
- default:
- throw new Error("PLAYWRIGHT_CHROME_PATH must name an approved Chrome executable");
- }
- }
-
- if (platform === "darwin") return "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome";
- if (platform === "win32") return "C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe";
- return "/usr/bin/google-chrome";
-}
-
-export function resolveFullProductScreenshotProfile(environment = process.env) {
- return (
- environment.NARUON_FULL_PRODUCT_SCREENSHOT_PROFILE ??
- environment.NARUON_FULL_PRODUCT_SCREENSHOT_DIR
- );
-}
-
-function resolveFullProductArtifactPrefix(rawProfile) {
- switch (rawProfile) {
- case undefined:
- case DEFAULT_FULL_PRODUCT_SCREENSHOT_PROFILE:
- return "naruon-full-product-smoke-";
- case RESPONSIVE_FULL_PRODUCT_SCREENSHOT_PROFILE:
- return "naruon-full-product-responsive-qa-";
- default:
- throw new Error(
- "NARUON_FULL_PRODUCT_SCREENSHOT_PROFILE (or legacy NARUON_FULL_PRODUCT_SCREENSHOT_DIR) must select an approved artifact profile",
- );
- }
-}
-
-export async function createFullProductArtifactDirectory(rawProfile) {
- const prefix = resolveFullProductArtifactPrefix(rawProfile);
- return mkdtemp(path.join(tmpdir(), prefix));
-}
-
-export function createFullProductServerLaunchSpec(rawBaseUrl) {
- const safeBaseUrl = resolveFullProductBaseUrl(rawBaseUrl);
- return {
- executable: process.execPath,
- args: [nextCliPath, "dev", "--webpack", "--hostname", safeBaseUrl.hostname, "--port", safeBaseUrl.port],
- };
-}
-
-export function resolveFullProductArtifactPath(artifactDirectory, fileName) {
- if (!path.isAbsolute(artifactDirectory) || !/^[a-z0-9]+(?:[-.][a-z0-9]+)*\.(?:png|txt)$/u.test(fileName)) {
- throw new Error("Full product smoke artifacts require an absolute directory and a safe file name");
- }
- const artifactPath = path.resolve(artifactDirectory, fileName);
- const relativePath = path.relative(artifactDirectory, artifactPath);
- if (relativePath.startsWith(`..${path.sep}`) || relativePath === ".." || path.isAbsolute(relativePath)) {
- throw new Error("Full product smoke artifact path escaped its private directory");
- }
- return artifactPath;
-}
-
-const baseUrl = resolveFullProductBaseUrl(requestedBaseUrl).href;
+resolveFullProductBaseUrl(baseUrl);
export function resolveFullProductViewportSpecs(rawViewports = "desktop") {
const viewportByName = new Map(FULL_PRODUCT_VIEWPORTS.map((viewport) => [viewport.name, viewport]));
@@ -173,14 +91,8 @@ export function resolveFullProductViewportSpecs(rawViewports = "desktop") {
}
export function fullProductScreenshotName(routeSpec, viewportSpec, viewportCount = 1) {
- const fileName =
- viewportCount === 1 && viewportSpec.name === "desktop"
- ? `${routeSpec.name}.png`
- : `${viewportSpec.name}-${routeSpec.name}.png`;
- if (!/^[a-z0-9]+(?:-[a-z0-9]+)*\.png$/u.test(fileName)) {
- throw new Error("Full product screenshot names must contain only lowercase route and viewport segments");
- }
- return fileName;
+ if (viewportCount === 1 && viewportSpec.name === "desktop") return `${routeSpec.name}.png`;
+ return `${viewportSpec.name}-${routeSpec.name}.png`;
}
function log(message) {
@@ -197,8 +109,7 @@ async function captureSmokeScreenshot(page, screenshotPath, label) {
await page.waitForTimeout(250);
continue;
}
- const diagnosticFileName = path.basename(screenshotPath).replace(/\.png$/u, ".screenshot-failed.txt");
- const diagnosticPath = resolveFullProductArtifactPath(path.dirname(screenshotPath), diagnosticFileName);
+ const diagnosticPath = screenshotPath.replace(/\.png$/u, ".screenshot-failed.txt");
const reason = error instanceof Error ? error.message : String(error);
await writeFile(
diagnosticPath,
@@ -216,7 +127,7 @@ async function isServerReady(url) {
try {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), SERVER_PROBE_TIMEOUT_MS);
- const response = await fetch(url, { redirect: "manual", signal: controller.signal });
+ const response = await fetch(url, { signal: controller.signal });
clearTimeout(timeout);
return response.status < 500;
} catch {
@@ -269,10 +180,11 @@ async function startServerIfNeeded() {
return null;
}
- const launchSpec = createFullProductServerLaunchSpec(baseUrl);
const child = spawn(
- launchSpec.executable,
- launchSpec.args,
+ IS_WINDOWS ? process.env.ComSpec || "cmd.exe" : "pnpm",
+ IS_WINDOWS
+ ? ["/d", "/s", "/c", "pnpm", "dev", "--hostname", url.hostname, "--port", url.port || "3001"]
+ : ["dev", "--hostname", url.hostname, "--port", url.port || "3001"],
{
cwd: frontendDir,
env: { ...process.env, NEXT_TELEMETRY_DISABLED: "1" },
@@ -301,9 +213,7 @@ async function stopServerProcess(child) {
if (IS_WINDOWS) {
await Promise.race([
- new Promise((resolve) =>
- execFile("taskkill.exe", ["/PID", String(child.pid), "/T", "/F"], { stdio: "ignore" }).once("close", resolve),
- ),
+ new Promise((resolve) => spawn("taskkill", ["/PID", String(child.pid), "/T", "/F"], { stdio: "ignore" }).once("close", resolve)),
timeout(5_000),
]);
await Promise.race([waitForExit, timeout(2_000)]);
@@ -330,7 +240,9 @@ async function launchBrowser() {
try {
return await chromium.launch({ headless: true });
} catch (error) {
- const fallbackPath = resolveFullProductChromePath(process.env.PLAYWRIGHT_CHROME_PATH);
+ const fallbackPath =
+ process.env.PLAYWRIGHT_CHROME_PATH ||
+ "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome";
await access(fallbackPath);
log(`Using system Chrome fallback because bundled Playwright browser is unavailable: ${error.message.split("\n")[0]}`);
return chromium.launch({ headless: true, executablePath: fallbackPath });
@@ -1555,7 +1467,7 @@ async function runAccessibilitySmoke(page, routeSpec) {
return [`${routeSpec.name}:a11y-basics`];
}
-async function runRouteSmoke(context, routeSpec, viewportSpec, viewportCount, screenshotDir) {
+async function runRouteSmoke(context, routeSpec, viewportSpec, viewportCount) {
const page = await context.newPage();
const consoleErrors = [];
page.on("console", (message) => {
@@ -1565,7 +1477,7 @@ async function runRouteSmoke(context, routeSpec, viewportSpec, viewportCount, sc
});
page.on("pageerror", (error) => consoleErrors.push(`pageerror: ${error.message}`));
await installRoutes(page);
- await page.goto(new URL(routeSpec.path, baseUrl).href, { waitUntil: "domcontentloaded" });
+ await page.goto(`${baseUrl}${routeSpec.path}`, { waitUntil: "domcontentloaded" });
await page.waitForLoadState("networkidle", { timeout: 10_000 }).catch(() => {});
await page.locator("body").waitFor({ state: "visible", timeout: 20_000 });
const bodyText = await page.locator("body").innerText({ timeout: 10_000 });
@@ -1584,10 +1496,7 @@ async function runRouteSmoke(context, routeSpec, viewportSpec, viewportCount, sc
}
const interactionEvidence = await runCriticalInteractionSmoke(page, routeSpec, viewportSpec);
const accessibilityEvidence = await runAccessibilitySmoke(page, routeSpec);
- const screenshotPath = resolveFullProductArtifactPath(
- screenshotDir,
- fullProductScreenshotName(routeSpec, viewportSpec, viewportCount),
- );
+ const screenshotPath = path.join(screenshotDir, fullProductScreenshotName(routeSpec, viewportSpec, viewportCount));
const screenshotArtifact = await captureSmokeScreenshot(
page,
screenshotPath,
@@ -1601,7 +1510,7 @@ async function main() {
let serverProcess = null;
let browser = null;
try {
- const screenshotDir = await createFullProductArtifactDirectory(requestedScreenshotProfile);
+ await mkdir(screenshotDir, { recursive: true });
serverProcess = await startServerIfNeeded();
browser = await launchBrowser();
const screenshots = [];
@@ -1614,7 +1523,7 @@ async function main() {
isMobile: Boolean(viewportSpec.isMobile),
});
for (const routeSpec of FULL_PRODUCT_ROUTES) {
- const result = await runRouteSmoke(context, routeSpec, viewportSpec, viewportSpecs.length, screenshotDir);
+ const result = await runRouteSmoke(context, routeSpec, viewportSpec, viewportSpecs.length);
screenshots.push(result.screenshotPath);
interactions.push(...result.interactionEvidence);
accessibility.push(...result.accessibilityEvidence);
diff --git a/frontend/scripts/full-product-ui-smoke.test.mjs b/frontend/scripts/full-product-ui-smoke.test.mjs
index 4e373a399..7460caa22 100644
--- a/frontend/scripts/full-product-ui-smoke.test.mjs
+++ b/frontend/scripts/full-product-ui-smoke.test.mjs
@@ -1,8 +1,5 @@
import { describe, expect, it } from "vitest";
-import { rm, stat } from "node:fs/promises";
import net from "node:net";
-import { tmpdir } from "node:os";
-import path from "node:path";
import {
FULL_PRODUCT_ACCESSIBILITY_CHECK_NAMES,
@@ -10,14 +7,9 @@ import {
FULL_PRODUCT_CRITICAL_INTERACTION_VIEWPORT_NAMES,
FULL_PRODUCT_DESKTOP_INTERACTION_ROUTE_NAMES,
FULL_PRODUCT_ROUTES,
- createFullProductArtifactDirectory,
- createFullProductServerLaunchSpec,
fullProductScreenshotName,
isTcpPortOpen,
- resolveFullProductArtifactPath,
resolveFullProductBaseUrl,
- resolveFullProductChromePath,
- resolveFullProductScreenshotProfile,
resolveFullProductViewportSpecs,
} from "./full-product-ui-smoke.mjs";
@@ -32,75 +24,6 @@ describe("full product UI smoke base URL guard", () => {
expect(() => resolveFullProductBaseUrl("https://staging.example.com")).toThrow("localhost targets");
expect(() => resolveFullProductBaseUrl("https://naruon.example.com")).toThrow("localhost targets");
expect(() => resolveFullProductBaseUrl("http://192.168.0.10:3000")).toThrow("localhost targets");
- expect(() => resolveFullProductBaseUrl("http://127.0.0.1:3000")).toThrow("localhost targets");
- expect(() => resolveFullProductBaseUrl("https://127.0.0.1:3001")).toThrow("localhost targets");
- expect(() => resolveFullProductBaseUrl("http://127.0.0.1:3001/admin")).toThrow("localhost targets");
- expect(() => resolveFullProductBaseUrl("http://127.0.0.1:3001?target=internal")).toThrow("localhost targets");
- expect(() => resolveFullProductBaseUrl("http://user@127.0.0.1:3001")).toThrow("localhost targets");
- expect(() => resolveFullProductBaseUrl("http://2130706433:3001")).toThrow("localhost targets");
- });
-
- it("creates private unique artifact directories and contains screenshot paths", async () => {
- const firstDirectory = await createFullProductArtifactDirectory("/tmp/naruon-full-product-smoke");
- const secondDirectory = await createFullProductArtifactDirectory("/tmp/naruon-full-product-smoke");
- try {
- const firstRelativePath = path.relative(tmpdir(), firstDirectory);
- expect(firstRelativePath).not.toMatch(/^\.\.(?:[/\\]|$)/u);
- expect(path.isAbsolute(firstRelativePath)).toBe(false);
- expect(firstDirectory).not.toBe(secondDirectory);
- expect((await stat(firstDirectory)).mode & 0o077).toBe(0);
- expect(resolveFullProductArtifactPath(firstDirectory, "desktop-home.png")).toBe(
- path.join(firstDirectory, "desktop-home.png"),
- );
- expect(() => resolveFullProductArtifactPath(firstDirectory, "../escape.png")).toThrow("safe file name");
- expect(() => resolveFullProductArtifactPath(firstDirectory, "nested/escape.png")).toThrow("safe file name");
- await expect(createFullProductArtifactDirectory("/tmp/../etc")).rejects.toThrow("approved artifact profile");
- } finally {
- await rm(firstDirectory, { recursive: true, force: true });
- await rm(secondDirectory, { recursive: true, force: true });
- }
- });
-
- it("prefers the screenshot profile variable and preserves the legacy directory alias", () => {
- expect(
- resolveFullProductScreenshotProfile({
- NARUON_FULL_PRODUCT_SCREENSHOT_PROFILE:
- "/tmp/naruon-full-product-responsive-qa",
- NARUON_FULL_PRODUCT_SCREENSHOT_DIR:
- "/tmp/naruon-full-product-smoke",
- }),
- ).toBe("/tmp/naruon-full-product-responsive-qa");
- expect(
- resolveFullProductScreenshotProfile({
- NARUON_FULL_PRODUCT_SCREENSHOT_DIR:
- "/tmp/naruon-full-product-smoke",
- }),
- ).toBe("/tmp/naruon-full-product-smoke");
- });
-
- it("allows only fixed system Chrome fallback executables", () => {
- expect(resolveFullProductChromePath("/Applications/Google Chrome.app/Contents/MacOS/Google Chrome")).toBe(
- "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
- );
- expect(resolveFullProductChromePath("/usr/bin/google-chrome")).toBe("/usr/bin/google-chrome");
- expect(() => resolveFullProductChromePath("/tmp/attacker-controlled-chrome")).toThrow("approved Chrome executable");
- expect(() => resolveFullProductChromePath("../../bin/chrome")).toThrow("approved Chrome executable");
- });
-
- it("launches Next through the current Node executable with fixed argv", () => {
- const launchSpec = createFullProductServerLaunchSpec("http://127.0.0.1:3001");
- expect(launchSpec.executable).toBe(process.execPath);
- expect(launchSpec.args.slice(-6)).toEqual([
- "dev",
- "--webpack",
- "--hostname",
- "127.0.0.1",
- "--port",
- "3001",
- ]);
- expect(() => createFullProductServerLaunchSpec("http://127.0.0.1:3001;touch /tmp/pwned")).toThrow(
- "localhost targets",
- );
});
it("covers the ten buyer-review IA routes", () => {
diff --git a/frontend/scripts/pilot-ui-smoke.mjs b/frontend/scripts/pilot-ui-smoke.mjs
index 1aea8fe32..f65173da9 100644
--- a/frontend/scripts/pilot-ui-smoke.mjs
+++ b/frontend/scripts/pilot-ui-smoke.mjs
@@ -1,6 +1,5 @@
import { spawn } from "node:child_process";
-import { access, mkdtemp } from "node:fs/promises";
-import { tmpdir } from "node:os";
+import { access } from "node:fs/promises";
import path from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
@@ -8,90 +7,20 @@ import { chromium } from "@playwright/test";
const scriptDir = path.dirname(fileURLToPath(import.meta.url));
const frontendDir = path.resolve(scriptDir, "..");
-const nextCliPath = path.join(frontendDir, "node_modules", "next", "dist", "bin", "next");
-const requestedBaseUrl = process.env.NARUON_PILOT_BASE_URL || "http://127.0.0.1:3001";
-const requestedMailScreenshot = process.env.NARUON_PILOT_MAIL_SCREENSHOT;
-const requestedSearchScreenshot = process.env.NARUON_PILOT_SEARCH_SCREENSHOT;
-const DEFAULT_PILOT_MAIL_SCREENSHOT = "/tmp/naruon-pilot-mail.png";
-const DEFAULT_PILOT_SEARCH_SCREENSHOT = "/tmp/naruon-pilot-search.png";
-const PILOT_SERVER_READY_TIMEOUT_MS = 90_000;
+const baseUrl = process.env.NARUON_PILOT_BASE_URL || "http://127.0.0.1:3001";
+const mailScreenshotPath = process.env.NARUON_PILOT_MAIL_SCREENSHOT || "/tmp/naruon-pilot-mail.png";
+const searchScreenshotPath = process.env.NARUON_PILOT_SEARCH_SCREENSHOT || "/tmp/naruon-pilot-search.png";
+const ALLOWED_PILOT_HOSTS = new Set(["127.0.0.1", "localhost", "::1", "[::1]"]);
export function resolvePilotBaseUrl(rawBaseUrl) {
- switch (rawBaseUrl) {
- case "http://127.0.0.1:3001":
- case "http://127.0.0.1:3001/":
- return new URL("http://127.0.0.1:3001");
- case "http://localhost:3001":
- case "http://localhost:3001/":
- return new URL("http://localhost:3001");
- case "http://[::1]:3001":
- case "http://[::1]:3001/":
- return new URL("http://[::1]:3001");
- default:
- throw new Error("Pilot smoke must run only against approved localhost targets on port 3001");
+ const pilotBaseUrl = new URL(rawBaseUrl);
+ if (!ALLOWED_PILOT_HOSTS.has(pilotBaseUrl.hostname)) {
+ throw new Error(`Pilot smoke must run only against localhost targets, got: ${pilotBaseUrl.hostname}`);
}
+ return pilotBaseUrl;
}
-export function resolvePilotChromePath(rawChromePath, platform = process.platform) {
- if (rawChromePath !== undefined) {
- switch (rawChromePath) {
- case "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome":
- return "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome";
- case "/usr/bin/google-chrome":
- return "/usr/bin/google-chrome";
- case "/usr/bin/google-chrome-stable":
- return "/usr/bin/google-chrome-stable";
- case "/usr/bin/chromium":
- return "/usr/bin/chromium";
- case "/usr/bin/chromium-browser":
- return "/usr/bin/chromium-browser";
- case "C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe":
- return "C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe";
- default:
- throw new Error("PLAYWRIGHT_CHROME_PATH must name an approved Chrome executable");
- }
- }
-
- if (platform === "darwin") return "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome";
- if (platform === "win32") return "C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe";
- return "/usr/bin/google-chrome";
-}
-
-function validatePilotArtifactProfile(rawMailScreenshot, rawSearchScreenshot) {
- if (rawMailScreenshot !== undefined && rawMailScreenshot !== DEFAULT_PILOT_MAIL_SCREENSHOT) {
- throw new Error("NARUON_PILOT_MAIL_SCREENSHOT must select the approved artifact profile");
- }
- if (rawSearchScreenshot !== undefined && rawSearchScreenshot !== DEFAULT_PILOT_SEARCH_SCREENSHOT) {
- throw new Error("NARUON_PILOT_SEARCH_SCREENSHOT must select the approved artifact profile");
- }
-}
-
-export async function createPilotArtifactDirectory(rawMailScreenshot, rawSearchScreenshot) {
- validatePilotArtifactProfile(rawMailScreenshot, rawSearchScreenshot);
- return mkdtemp(path.join(tmpdir(), "naruon-pilot-smoke-"));
-}
-
-export function createPilotServerLaunchSpec(rawBaseUrl) {
- const safeBaseUrl = resolvePilotBaseUrl(rawBaseUrl);
- return {
- executable: process.execPath,
- args: [nextCliPath, "dev", "--webpack", "--hostname", safeBaseUrl.hostname, "--port", safeBaseUrl.port],
- };
-}
-
-export function resolvePilotArtifactPath(artifactDirectory, fileName) {
- if (!path.isAbsolute(artifactDirectory) || !/^[a-z0-9]+(?:-[a-z0-9]+)*\.png$/u.test(fileName)) {
- throw new Error("Pilot smoke artifacts require an absolute directory and a safe file name");
- }
- const artifactPath = path.resolve(artifactDirectory, fileName);
- const relativePath = path.relative(artifactDirectory, artifactPath);
- if (relativePath.startsWith(`..${path.sep}`) || relativePath === ".." || path.isAbsolute(relativePath)) {
- throw new Error("Pilot smoke artifact path escaped its private directory");
- }
- return artifactPath;
-}
-
-const baseUrl = resolvePilotBaseUrl(requestedBaseUrl).href;
+resolvePilotBaseUrl(baseUrl);
const sensitiveMailBody = "Sensitive source body must stay out of analytics payloads.";
const sensitiveDraftBody = "상용 파일럿 답장 초안입니다. 내부 원문은 이벤트에 남지 않아야 합니다.";
@@ -118,7 +47,7 @@ async function isServerReady(url) {
try {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 1000);
- const response = await fetch(url, { redirect: "manual", signal: controller.signal });
+ const response = await fetch(url, { signal: controller.signal });
clearTimeout(timeout);
return response.status < 500;
} catch {
@@ -128,7 +57,7 @@ async function isServerReady(url) {
async function waitForServer(url, child) {
const startedAt = Date.now();
- while (Date.now() - startedAt < PILOT_SERVER_READY_TIMEOUT_MS) {
+ while (Date.now() - startedAt < 30_000) {
if (child?.exitCode !== null && child?.exitCode !== undefined) {
throw new Error(`Next dev server exited before becoming ready with code ${child.exitCode}`);
}
@@ -146,10 +75,9 @@ async function startServerIfNeeded() {
throw new Error(`Server is not reachable and cannot be auto-started for non-local URL: ${baseUrl}`);
}
- const launchSpec = createPilotServerLaunchSpec(baseUrl);
const child = spawn(
- launchSpec.executable,
- launchSpec.args,
+ "pnpm",
+ ["dev", "--hostname", url.hostname, "--port", url.port || "3001"],
{
cwd: frontendDir,
env: { ...process.env, NEXT_TELEMETRY_DISABLED: "1" },
@@ -166,7 +94,9 @@ async function launchBrowser() {
try {
return await chromium.launch({ headless: true });
} catch (error) {
- const fallbackPath = resolvePilotChromePath(process.env.PLAYWRIGHT_CHROME_PATH);
+ const fallbackPath =
+ process.env.PLAYWRIGHT_CHROME_PATH ||
+ "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome";
await access(fallbackPath);
log(`Using system Chrome fallback because bundled Playwright browser is unavailable: ${error.message.split("\n")[0]}`);
return chromium.launch({ headless: true, executablePath: fallbackPath });
@@ -291,9 +221,9 @@ function assertNoSensitiveEventText(eventText, forbiddenValues) {
}
}
-async function runMailFlow(context, consoleIssues, mailScreenshotPath) {
+async function runMailFlow(context, consoleIssues) {
const page = await preparePage(context, consoleIssues);
- await page.goto(new URL("/mail", baseUrl).href, { waitUntil: "domcontentloaded" });
+ await page.goto(`${baseUrl}/mail`, { waitUntil: "domcontentloaded" });
const desktopMailRegion = page.getByRole("region", { name: "데스크톱 메일 작업공간" });
await desktopMailRegion.waitFor({ state: "visible", timeout: 20_000 });
@@ -390,9 +320,9 @@ async function runMailFlow(context, consoleIssues, mailScreenshotPath) {
return requiredEvents;
}
-async function runSearchFlow(context, consoleIssues, searchScreenshotPath) {
+async function runSearchFlow(context, consoleIssues) {
const page = await preparePage(context, consoleIssues);
- await page.goto(new URL("/search", baseUrl).href, { waitUntil: "domcontentloaded" });
+ await page.goto(`${baseUrl}/search`, { waitUntil: "domcontentloaded" });
const searchDetail = page.getByLabel("맥락 검색 결과 상세");
await searchDetail.getByRole("heading", { name: "런칭 캠페인 결과" }).waitFor({ state: "visible", timeout: 20_000 });
@@ -439,19 +369,13 @@ async function main() {
let serverProcess = null;
let browser = null;
try {
- const artifactDirectory = await createPilotArtifactDirectory(
- requestedMailScreenshot,
- requestedSearchScreenshot,
- );
- const mailScreenshotPath = resolvePilotArtifactPath(artifactDirectory, "mail.png");
- const searchScreenshotPath = resolvePilotArtifactPath(artifactDirectory, "search.png");
serverProcess = await startServerIfNeeded();
browser = await launchBrowser();
const context = await browser.newContext({ viewport: { width: 1440, height: 1024 } });
const consoleIssues = [];
- const mailEvents = await runMailFlow(context, consoleIssues, mailScreenshotPath);
- const searchEvents = await runSearchFlow(context, consoleIssues, searchScreenshotPath);
+ const mailEvents = await runMailFlow(context, consoleIssues);
+ const searchEvents = await runSearchFlow(context, consoleIssues);
if (consoleIssues.length > 0) {
throw new Error(`Console issues detected:\n${consoleIssues.join("\n")}`);
diff --git a/frontend/scripts/pilot-ui-smoke.test.mjs b/frontend/scripts/pilot-ui-smoke.test.mjs
index 744fccdc4..fecd94886 100644
--- a/frontend/scripts/pilot-ui-smoke.test.mjs
+++ b/frontend/scripts/pilot-ui-smoke.test.mjs
@@ -1,15 +1,6 @@
import { describe, expect, it } from "vitest";
-import { rm, stat } from "node:fs/promises";
-import { tmpdir } from "node:os";
-import path from "node:path";
-import {
- createPilotArtifactDirectory,
- createPilotServerLaunchSpec,
- resolvePilotArtifactPath,
- resolvePilotBaseUrl,
- resolvePilotChromePath,
-} from "./pilot-ui-smoke.mjs";
+import { resolvePilotBaseUrl } from "./pilot-ui-smoke.mjs";
describe("pilot UI smoke base URL guard", () => {
it("allows localhost pilot smoke targets", () => {
@@ -21,57 +12,5 @@ describe("pilot UI smoke base URL guard", () => {
it("rejects non-localhost targets", () => {
expect(() => resolvePilotBaseUrl("https://staging.example.com")).toThrow("localhost targets");
expect(() => resolvePilotBaseUrl("https://naruon.example.com")).toThrow("localhost targets");
- expect(() => resolvePilotBaseUrl("http://127.0.0.1:3000")).toThrow("localhost targets");
- expect(() => resolvePilotBaseUrl("https://127.0.0.1:3001")).toThrow("localhost targets");
- expect(() => resolvePilotBaseUrl("http://127.0.0.1:3001/admin")).toThrow("localhost targets");
- expect(() => resolvePilotBaseUrl("http://127.0.0.1:3001?target=internal")).toThrow("localhost targets");
- expect(() => resolvePilotBaseUrl("http://user@127.0.0.1:3001")).toThrow("localhost targets");
- expect(() => resolvePilotBaseUrl("http://2130706433:3001")).toThrow("localhost targets");
- });
-
- it("creates a private unique artifact directory and contains fixed screenshot names", async () => {
- const artifactDirectory = await createPilotArtifactDirectory(
- "/tmp/naruon-pilot-mail.png",
- "/tmp/naruon-pilot-search.png",
- );
- try {
- const relativePath = path.relative(tmpdir(), artifactDirectory);
- expect(relativePath).not.toMatch(/^\.\.(?:[/\\]|$)/u);
- expect(path.isAbsolute(relativePath)).toBe(false);
- expect((await stat(artifactDirectory)).mode & 0o077).toBe(0);
- expect(resolvePilotArtifactPath(artifactDirectory, "mail.png")).toBe(path.join(artifactDirectory, "mail.png"));
- expect(() => resolvePilotArtifactPath(artifactDirectory, "../escape.png")).toThrow("safe file name");
- expect(() => resolvePilotArtifactPath(artifactDirectory, "nested/escape.png")).toThrow("safe file name");
- await expect(
- createPilotArtifactDirectory("/tmp/../etc/pilot.png", "/tmp/naruon-pilot-search.png"),
- ).rejects.toThrow("approved artifact profile");
- } finally {
- await rm(artifactDirectory, { recursive: true, force: true });
- }
- });
-
- it("allows only fixed system Chrome fallback executables", () => {
- expect(resolvePilotChromePath("/Applications/Google Chrome.app/Contents/MacOS/Google Chrome")).toBe(
- "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
- );
- expect(resolvePilotChromePath("/usr/bin/chromium")).toBe("/usr/bin/chromium");
- expect(() => resolvePilotChromePath("/tmp/attacker-controlled-chrome")).toThrow("approved Chrome executable");
- expect(() => resolvePilotChromePath("../../bin/chrome")).toThrow("approved Chrome executable");
- });
-
- it("launches Next through the current Node executable with fixed argv", () => {
- const launchSpec = createPilotServerLaunchSpec("http://localhost:3001");
- expect(launchSpec.executable).toBe(process.execPath);
- expect(launchSpec.args.slice(-6)).toEqual([
- "dev",
- "--webpack",
- "--hostname",
- "localhost",
- "--port",
- "3001",
- ]);
- expect(() => createPilotServerLaunchSpec("http://localhost:3001;touch /tmp/pwned")).toThrow(
- "localhost targets",
- );
});
});
diff --git a/frontend/src/app/api/[...path]/route.test.ts b/frontend/src/app/api/[...path]/route.test.ts
index 19504fd6a..26c1132d6 100644
--- a/frontend/src/app/api/[...path]/route.test.ts
+++ b/frontend/src/app/api/[...path]/route.test.ts
@@ -1,7 +1,7 @@
import { NextRequest } from "next/server";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
-import { GET, POST, PUT } from "./route";
+import { POST, PUT } from "./route";
const ORIGINAL_ENV = { ...process.env };
const SIGNED_SESSION_TOKEN = "signed.session.token";
@@ -14,7 +14,6 @@ describe("/api runtime proxy route", () => {
});
afterEach(() => {
- vi.restoreAllMocks();
vi.unstubAllEnvs();
vi.unstubAllGlobals();
process.env = { ...ORIGINAL_ENV };
@@ -243,111 +242,4 @@ describe("/api runtime proxy route", () => {
});
expect(fetchMock).not.toHaveBeenCalled();
});
- it.each([".", "..", "\u0000admin"])(
- "rejects path segment %j before URL normalization can escape the API prefix",
- async (segment) => {
- const fetchMock = vi.fn();
- vi.stubGlobal("fetch", fetchMock);
-
- const response = await POST(
- new NextRequest("https://frontend.naruon.net/api/placeholder", {
- method: "POST",
- headers: { Origin: "https://frontend.naruon.net" },
- body: "{}",
- }),
- { params: Promise.resolve({ path: [segment, "admin"] }) },
- );
-
- expect(response.status).toBe(400);
- await expect(response.json()).resolves.toMatchObject({
- error_code: "invalid_proxy_path",
- });
- expect(fetchMock).not.toHaveBeenCalled();
- },
- );
-
- it("fails closed when the backend configuration is not a bare trusted origin", async () => {
- const consoleError = vi.spyOn(console, "error").mockImplementation(() => {});
- vi.stubEnv(
- "BACKEND_INTERNAL_URL",
- "https://api.naruon.net/untrusted/base?next=http://169.254.169.254",
- );
- const fetchMock = vi.fn();
- vi.stubGlobal("fetch", fetchMock);
-
- const response = await POST(
- new NextRequest("https://frontend.naruon.net/api/tasks", {
- method: "POST",
- headers: { Origin: "https://frontend.naruon.net" },
- body: "{}",
- }),
- { params: Promise.resolve({ path: ["tasks"] }) },
- );
-
- expect(response.status).toBe(503);
- expect(response.headers.get("cache-control")).toBe("no-store");
- expect(response.headers.get("referrer-policy")).toBe("no-referrer");
- expect(fetchMock).not.toHaveBeenCalled();
- expect(consoleError).toHaveBeenCalledWith(
- "proxy_target_configuration_failed",
- { error_type: "Error" },
- );
- });
-
- it("returns secure no-store headers when the backend fetch fails", async () => {
- const consoleError = vi.spyOn(console, "error").mockImplementation(() => {});
- vi.stubGlobal("fetch", vi.fn(async () => {
- throw new Error("backend unavailable\r\nforged_event=true");
- }));
-
- const response = await GET(
- new NextRequest("https://frontend.naruon.net/api/tasks"),
- { params: Promise.resolve({ path: ["tasks"] }) },
- );
-
- expect(response.status).toBe(503);
- expect(response.headers.get("cache-control")).toBe("no-store");
- expect(response.headers.get("referrer-policy")).toBe("no-referrer");
- expect(consoleError).toHaveBeenCalledWith("proxy_fetch_failed", {
- error_type: "Error",
- });
- expect(consoleError.mock.calls.flat().join(" ")).not.toContain("forged_event");
- });
-
- it("keeps encoded authority-like path input on the configured backend host", async () => {
- const fetchMock = vi.fn(async (input: URL | RequestInfo) =>
- Response.json({ target_url: String(input) }),
- );
- vi.stubGlobal("fetch", fetchMock);
-
- const response = await POST(
- new NextRequest("https://frontend.naruon.net/api/placeholder", {
- method: "POST",
- headers: { Origin: "https://frontend.naruon.net" },
- body: "{}",
- }),
- { params: Promise.resolve({ path: ["//169.254.169.254", "metadata"] }) },
- );
-
- await expect(response.json()).resolves.toEqual({
- target_url: "https://api.naruon.net/api/%2F%2F169.254.169.254/metadata",
- });
- });
-
- it("preserves a validated global IPv6 backend authority", async () => {
- vi.stubEnv("BACKEND_INTERNAL_URL", "https://[2001:db8::1]:8443");
- const fetchMock = vi.fn(async (input: URL | RequestInfo) =>
- Response.json({ target_url: String(input) }),
- );
- vi.stubGlobal("fetch", fetchMock);
-
- const response = await GET(
- new NextRequest("https://frontend.naruon.net/api/tasks"),
- { params: Promise.resolve({ path: ["tasks"] }) },
- );
-
- await expect(response.json()).resolves.toEqual({
- target_url: "https://[2001:db8::1]:8443/api/tasks",
- });
- });
});
diff --git a/frontend/src/app/api/[...path]/route.ts b/frontend/src/app/api/[...path]/route.ts
index 7f0e1d685..8e4cf7915 100644
--- a/frontend/src/app/api/[...path]/route.ts
+++ b/frontend/src/app/api/[...path]/route.ts
@@ -1,6 +1,6 @@
import { NextRequest, NextResponse } from "next/server";
-import { trustedBackendOrigin } from "@/lib/backend-url";
+import { backendApiBaseUrl } from "@/lib/backend-url";
import { SESSION_COOKIE_NAME, normalizeSessionToken } from "@/lib/session-cookie";
export const runtime = "nodejs";
@@ -39,14 +39,8 @@ const ALLOWED_BACKEND_QUERY_PARAMS = new Set([
]);
const MAX_QUERY_PARAM_COUNT = 12;
const MAX_QUERY_PARAM_VALUE_LENGTH = 2048;
-const MAX_PROXY_PATH_SEGMENTS = 32;
-const MAX_PROXY_PATH_SEGMENT_LENGTH = 256;
const CONTROL_CHARACTER_PATTERN = /[\u0000-\u001f\u007f]/;
const STATE_CHANGING_METHODS = new Set(["POST", "PUT", "PATCH", "DELETE"]);
-const PROXY_ERROR_HEADERS = {
- "Cache-Control": "no-store",
- "Referrer-Policy": "no-referrer",
-};
type ApiRouteContext = {
params: Promise<{ path?: string[] }>;
@@ -59,42 +53,6 @@ class InvalidProxyQueryError extends Error {
}
}
-class InvalidProxyPathError extends Error {
- constructor(message: string) {
- super(message);
- this.name = "InvalidProxyPathError";
- }
-}
-
-function proxyFailureDetails(error: unknown) {
- const candidate =
- error instanceof Error ? error.constructor.name : typeof error;
- const errorType = /^[A-Za-z][A-Za-z0-9_.-]{0,63}$/u.test(candidate)
- ? candidate
- : "Error";
- return { error_type: errorType };
-}
-
-function safeBackendPath(path: string[]): string {
- if (path.length === 0 || path.length > MAX_PROXY_PATH_SEGMENTS) {
- throw new InvalidProxyPathError("Invalid backend path segment count");
- }
- return path
- .map((segment) => {
- if (
- !segment ||
- segment === "." ||
- segment === ".." ||
- segment.length > MAX_PROXY_PATH_SEGMENT_LENGTH ||
- CONTROL_CHARACTER_PATTERN.test(segment)
- ) {
- throw new InvalidProxyPathError("Invalid backend path segment");
- }
- return encodeURIComponent(segment);
- })
- .join("/");
-}
-
function filteredRequestHeaders(request: NextRequest): Headers {
const headers = new Headers();
request.headers.forEach((value, name) => {
@@ -219,17 +177,18 @@ async function proxyApiRequest(
},
{
status: 403,
- headers: PROXY_ERROR_HEADERS,
+ headers: {
+ "Referrer-Policy": "no-referrer",
+ },
},
);
}
const params = await context.params;
const path = params.path ?? [];
- let target: URL;
+ const target = backendApiBaseUrl();
+ target.pathname = `/api/${path.map(encodeURIComponent).join("/")}`;
try {
- target = trustedBackendOrigin();
- target.pathname = `/api/${safeBackendPath(path)}`;
target.search = safeBackendQuery(request.nextUrl.searchParams);
} catch (error) {
if (error instanceof InvalidProxyQueryError) {
@@ -240,31 +199,13 @@ async function proxyApiRequest(
},
{
status: 400,
- headers: PROXY_ERROR_HEADERS,
+ headers: {
+ "Referrer-Policy": "no-referrer",
+ },
},
);
}
- if (error instanceof InvalidProxyPathError) {
- return NextResponse.json(
- {
- error_code: "invalid_proxy_path",
- message: error.message,
- },
- {
- status: 400,
- headers: PROXY_ERROR_HEADERS,
- },
- );
- }
- console.error(
- "proxy_target_configuration_failed",
- proxyFailureDetails(error),
- );
- return new NextResponse(null, {
- status: 503,
- statusText: "Service Unavailable",
- headers: PROXY_ERROR_HEADERS,
- });
+ throw error;
}
const init: RequestInit = {
@@ -278,18 +219,11 @@ async function proxyApiRequest(
let response: Response;
try {
- // `target` is rebuilt by trustedBackendOrigin() from operator-only runtime
- // configuration, then constrained to the validated API path/query above.
- // codeql[js/request-forgery]
response = await fetch(target, init);
} catch (error) {
// If the backend isn't available (e.g. during build), return a 503 instead of throwing
- console.error("proxy_fetch_failed", proxyFailureDetails(error));
- return new NextResponse(null, {
- status: 503,
- statusText: "Service Unavailable",
- headers: PROXY_ERROR_HEADERS,
- });
+ console.error("Proxy fetch failed:", error);
+ return new NextResponse(null, { status: 503, statusText: "Service Unavailable" });
}
return new NextResponse(response.body, {
diff --git a/frontend/src/app/auth/oidc/callback/route.test.ts b/frontend/src/app/auth/oidc/callback/route.test.ts
index 241d86e75..ee07e697b 100644
--- a/frontend/src/app/auth/oidc/callback/route.test.ts
+++ b/frontend/src/app/auth/oidc/callback/route.test.ts
@@ -3,16 +3,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { POST } from "./route";
-const { postOidcTokenRequestMock } = vi.hoisted(() => ({
- postOidcTokenRequestMock: vi.fn<
- (endpoint: URL, body: URLSearchParams) => Promise<{ access_token?: unknown }>
- >(),
-}));
-
-vi.mock("@/lib/oidc-token-client", () => ({
- postOidcTokenRequest: postOidcTokenRequestMock,
-}));
-
const ORIGINAL_ENV = { ...process.env };
function oidcStateCookie(state: string, verifier: string, returnTo: string) {
@@ -33,14 +23,9 @@ describe("/auth/oidc/callback route", () => {
vi.stubEnv("NEXT_PUBLIC_OIDC_ISSUER_URL", "https://login.example.com/realms/naruon/");
vi.stubEnv("NEXT_PUBLIC_OIDC_CLIENT_ID", "naruon-web");
vi.stubEnv("NEXT_PUBLIC_OIDC_REDIRECT_URI", "https://app.example.com/auth/callback");
- postOidcTokenRequestMock.mockReset();
- postOidcTokenRequestMock.mockResolvedValue({
- access_token: "test-header.test-payload.test-signature",
- });
});
afterEach(() => {
- vi.restoreAllMocks();
vi.unstubAllEnvs();
vi.unstubAllGlobals();
process.env = { ...ORIGINAL_ENV };
@@ -84,19 +69,7 @@ describe("/auth/oidc/callback route", () => {
expect(setCookie).toContain("naruon_oidc_pkce=");
expect(setCookie).toContain("Max-Age=0");
expect(setCookie).not.toContain("verifier-123");
- expect(fetchMock).toHaveBeenCalledTimes(1);
- expect(fetchMock.mock.calls[0]?.[1]).toEqual(expect.objectContaining({
- cache: "no-store",
- redirect: "manual",
- signal: expect.any(AbortSignal),
- }));
- expect(postOidcTokenRequestMock).toHaveBeenCalledTimes(1);
- const [tokenEndpoint, tokenBody] = postOidcTokenRequestMock.mock.calls[0];
- expect(tokenEndpoint.href).toBe(
- "https://login.example.com/realms/naruon/protocol/openid-connect/token",
- );
- expect(tokenBody.get("code")).toBe("auth-code");
- expect(tokenBody.get("code_verifier")).toBe("verifier-123");
+ expect(fetchMock).toHaveBeenCalledTimes(2);
});
it("rejects callbacks without matching server-side state", async () => {
@@ -117,259 +90,4 @@ describe("/auth/oidc/callback route", () => {
});
expect(fetchMock).not.toHaveBeenCalled();
});
- it.each([
- "http://169.254.169.254/token",
- "https://evil.example/token",
- "https://login.example.com@169.254.169.254/token",
- ])(
- "rejects untrusted OIDC token endpoint %s before fetching",
- async (tokenEndpoint) => {
- vi.stubEnv("NEXT_PUBLIC_OIDC_TOKEN_ENDPOINT", tokenEndpoint);
- const fetchMock = vi.fn();
- vi.stubGlobal("fetch", fetchMock);
-
- const response = await POST(
- new NextRequest("https://app.example.com/auth/oidc/callback", {
- method: "POST",
- headers: {
- Cookie: oidcStateCookie("state-123", "verifier-123", "/security"),
- },
- body: JSON.stringify({ search: "?code=auth-code&state=state-123" }),
- }),
- );
-
- expect(response.status).toBe(502);
- await expect(response.json()).resolves.toEqual({
- error_code: "oidc_token_exchange_failed",
- });
- expect(fetchMock).not.toHaveBeenCalled();
- },
- );
-
- it("rejects IPv4-mapped IPv6 OIDC token endpoints before fetching", async () => {
- vi.stubEnv(
- "NEXT_PUBLIC_OIDC_ISSUER_URL",
- "https://[::ffff:127.0.0.1]/realms/naruon",
- );
- vi.stubEnv(
- "NEXT_PUBLIC_OIDC_TOKEN_ENDPOINT",
- "https://[::ffff:127.0.0.1]/realms/naruon/token",
- );
- const fetchMock = vi.fn();
- vi.stubGlobal("fetch", fetchMock);
-
- const response = await POST(
- new NextRequest("https://app.example.com/auth/oidc/callback", {
- method: "POST",
- headers: {
- Cookie: oidcStateCookie("state-123", "verifier-123", "/security"),
- },
- body: JSON.stringify({ search: "?code=auth-code&state=state-123" }),
- }),
- );
-
- expect(response.status).toBe(502);
- await expect(response.json()).resolves.toEqual({
- error_code: "oidc_token_exchange_failed",
- });
- expect(fetchMock).not.toHaveBeenCalled();
- });
-
- it("rejects trailing-dot private OIDC hosts before fetching", async () => {
- vi.stubEnv(
- "NEXT_PUBLIC_OIDC_ISSUER_URL",
- "https://service.local./realms/naruon",
- );
- vi.stubEnv(
- "NEXT_PUBLIC_OIDC_TOKEN_ENDPOINT",
- "https://service.local./realms/naruon/token",
- );
- const fetchMock = vi.fn();
- vi.stubGlobal("fetch", fetchMock);
-
- const response = await POST(
- new NextRequest("https://app.example.com/auth/oidc/callback", {
- method: "POST",
- headers: {
- Cookie: oidcStateCookie("state-123", "verifier-123", "/security"),
- },
- body: JSON.stringify({ search: "?code=auth-code&state=state-123" }),
- }),
- );
-
- expect(response.status).toBe(502);
- await expect(response.json()).resolves.toEqual({
- error_code: "oidc_token_exchange_failed",
- });
- expect(fetchMock).not.toHaveBeenCalled();
- });
-
- it("allows an exact loopback OIDC issuer only outside production", async () => {
- vi.stubEnv(
- "NEXT_PUBLIC_OIDC_ISSUER_URL",
- "http://127.0.0.1:8080/realms/naruon",
- );
- vi.stubEnv(
- "NEXT_PUBLIC_OIDC_TOKEN_ENDPOINT",
- "http://127.0.0.1:8080/realms/naruon/protocol/openid-connect/token",
- );
- const fetchMock = vi.fn(async (input: RequestInfo | URL) => {
- const url = String(input);
- if (
- url ===
- "http://127.0.0.1:8080/realms/naruon/protocol/openid-connect/token"
- ) {
- return Response.json({
- access_token: "test-header.test-payload.test-signature",
- });
- }
- if (url === "https://api.naruon.net/api/auth/session") {
- return Response.json({
- user_id: "user-1",
- organization_id: "org-acme",
- workspace_id: "workspace-acme",
- });
- }
- throw new Error(`unexpected fetch: ${url}`);
- });
- vi.stubGlobal("fetch", fetchMock);
-
- const response = await POST(
- new NextRequest("http://localhost:3000/auth/oidc/callback", {
- method: "POST",
- headers: {
- Cookie: oidcStateCookie("state-123", "verifier-123", "/security"),
- },
- body: JSON.stringify({ search: "?code=auth-code&state=state-123" }),
- }),
- );
-
- expect(response.status).toBe(200);
- expect(fetchMock).toHaveBeenCalledTimes(1);
- expect(postOidcTokenRequestMock).toHaveBeenCalledTimes(1);
- });
-
- it("preserves a validated global IPv6 OIDC issuer authority", async () => {
- vi.stubEnv(
- "NEXT_PUBLIC_OIDC_ISSUER_URL",
- "https://[2001:4860:4860::8888]/realms/naruon",
- );
- vi.stubEnv(
- "NEXT_PUBLIC_OIDC_TOKEN_ENDPOINT",
- "https://[2001:4860:4860::8888]/realms/naruon/token",
- );
- const fetchMock = vi.fn(async (input: RequestInfo | URL) => {
- const url = String(input);
- if (url === "https://[2001:4860:4860::8888]/realms/naruon/token") {
- return Response.json({
- access_token: "test-header.test-payload.test-signature",
- });
- }
- if (url === "https://api.naruon.net/api/auth/session") {
- return Response.json({
- user_id: "user-1",
- organization_id: "org-acme",
- workspace_id: "workspace-acme",
- });
- }
- throw new Error(`unexpected fetch: ${url}`);
- });
- vi.stubGlobal("fetch", fetchMock);
-
- const response = await POST(
- new NextRequest("https://app.example.com/auth/oidc/callback", {
- method: "POST",
- headers: {
- Cookie: oidcStateCookie("state-123", "verifier-123", "/security"),
- },
- body: JSON.stringify({ search: "?code=auth-code&state=state-123" }),
- }),
- );
-
- expect(response.status).toBe(200);
- expect(fetchMock).toHaveBeenCalledTimes(1);
- expect(postOidcTokenRequestMock).toHaveBeenCalledTimes(1);
- });
-
- it("rejects loopback OIDC token endpoints in production", async () => {
- vi.stubEnv("NODE_ENV", "production");
- vi.stubEnv("OIDC_ALLOWED_HOSTS", "127.0.0.1");
- vi.stubEnv(
- "NEXT_PUBLIC_OIDC_ISSUER_URL",
- "http://127.0.0.1:8080/realms/naruon",
- );
- vi.stubEnv(
- "NEXT_PUBLIC_OIDC_TOKEN_ENDPOINT",
- "http://127.0.0.1:8080/realms/naruon/protocol/openid-connect/token",
- );
- const fetchMock = vi.fn();
- vi.stubGlobal("fetch", fetchMock);
-
- const response = await POST(
- new NextRequest("https://app.example.com/auth/oidc/callback", {
- method: "POST",
- headers: {
- Cookie: oidcStateCookie("state-123", "verifier-123", "/security"),
- },
- body: JSON.stringify({ search: "?code=auth-code&state=state-123" }),
- }),
- );
-
- expect(response.status).toBe(502);
- expect(fetchMock).not.toHaveBeenCalled();
- expect(postOidcTokenRequestMock).not.toHaveBeenCalled();
- });
-
- it("requires an exact server-only OIDC host allowlist in production", async () => {
- vi.stubEnv("NODE_ENV", "production");
- const warnMock = vi.spyOn(console, "warn").mockImplementation(() => {});
- const fetchMock = vi.fn();
- vi.stubGlobal("fetch", fetchMock);
-
- const response = await POST(
- new NextRequest("https://app.example.com/auth/oidc/callback", {
- method: "POST",
- headers: {
- Cookie: oidcStateCookie("state-123", "verifier-123", "/security"),
- },
- body: JSON.stringify({ search: "?code=auth-code&state=state-123" }),
- }),
- );
-
- expect(response.status).toBe(502);
- expect(fetchMock).not.toHaveBeenCalled();
- expect(postOidcTokenRequestMock).not.toHaveBeenCalled();
- expect(warnMock).toHaveBeenCalledWith(
- "oidc_token_exchange_failed",
- { reason: "configuration_rejected" },
- );
- });
-
- it("uses the pinned token client for an allowlisted production issuer", async () => {
- vi.stubEnv("NODE_ENV", "production");
- vi.stubEnv("OIDC_ALLOWED_HOSTS", "login.example.com");
- const fetchMock = vi.fn(async (input: RequestInfo | URL) => {
- expect(String(input)).toBe("https://api.naruon.net/api/auth/session");
- return Response.json({
- user_id: "user-1",
- organization_id: "org-acme",
- workspace_id: "workspace-acme",
- });
- });
- vi.stubGlobal("fetch", fetchMock);
-
- const response = await POST(
- new NextRequest("https://app.example.com/auth/oidc/callback", {
- method: "POST",
- headers: {
- Cookie: oidcStateCookie("state-123", "verifier-123", "/security"),
- },
- body: JSON.stringify({ search: "?code=auth-code&state=state-123" }),
- }),
- );
-
- expect(response.status).toBe(200);
- expect(postOidcTokenRequestMock).toHaveBeenCalledTimes(1);
- expect(fetchMock).toHaveBeenCalledTimes(1);
- });
});
diff --git a/frontend/src/app/auth/oidc/callback/route.ts b/frontend/src/app/auth/oidc/callback/route.ts
index 6922cc674..e01618638 100644
--- a/frontend/src/app/auth/oidc/callback/route.ts
+++ b/frontend/src/app/auth/oidc/callback/route.ts
@@ -1,18 +1,11 @@
import { NextRequest, NextResponse } from "next/server";
-import { fetchTrustedBackendSession } from "@/lib/backend-session-probe";
-import {
- isIpv4MappedHostname,
- isLoopbackHostname,
- isPrivateOrLoopbackHostname,
- normalizeHostname,
-} from "@/lib/host-policy";
+import { backendApiBaseUrl } from "@/lib/backend-url";
import {
buildExpiredSessionCookieOptions,
buildSessionCookieOptions,
normalizeSessionToken,
} from "@/lib/session-cookie";
-import { postOidcTokenRequest } from "@/lib/oidc-token-client";
import {
OIDC_NO_STORE_HEADERS,
@@ -26,14 +19,6 @@ export const runtime = "nodejs";
export const dynamic = "force-dynamic";
export const fetchCache = "force-no-store";
-const OIDC_CONTROL_CHARACTER_PATTERN = /[\u0000-\u001f\u007f]/;
-const OIDC_ALLOWED_HOSTS_ENV = "OIDC_ALLOWED_HOSTS";
-type OidcTokenExchangeFailureReason =
- | "configuration_rejected"
- | "dns_or_transport_rejected"
- | "access_token_missing_or_invalid"
- | "backend_session_rejected";
-
function errorResponse(errorCode: string, status = 400) {
return NextResponse.json(
{ error_code: errorCode },
@@ -41,141 +26,38 @@ function errorResponse(errorCode: string, status = 400) {
);
}
-function recordOidcTokenExchangeFailure(
- reason: OidcTokenExchangeFailureReason,
-): void {
- console.warn("oidc_token_exchange_failed", { reason });
-}
-
function searchParamsFromBodySearch(value: unknown) {
const search = typeof value === "string" ? value.trim() : "";
return new URLSearchParams(search.startsWith("?") ? search.slice(1) : search);
}
-function assertAllowedOidcHostname(hostname: string): void {
- const configuredHosts = process.env[OIDC_ALLOWED_HOSTS_ENV]?.trim();
- if (!configuredHosts) {
- if (process.env.NODE_ENV === "production") {
- throw new Error(
- `${OIDC_ALLOWED_HOSTS_ENV} is required for production OIDC token exchange`,
- );
- }
- return;
- }
- const allowedHosts = new Set(
- configuredHosts
- .split(",")
- .map((host) =>
- host
- .trim()
- .replace(/^\[/, "")
- .replace(/\]$/, "")
- .replace(/\.+$/, "")
- .toLowerCase(),
- )
- .filter(Boolean),
- );
- if (!allowedHosts.has(hostname)) {
- throw new Error(
- `OIDC token endpoint host must be listed in ${OIDC_ALLOWED_HOSTS_ENV}`,
- );
- }
-}
-
-function trustedOidcTokenEndpoint(config: {
- issuerUrl: string;
- tokenEndpoint: string;
-}): URL {
- const issuer = new URL(config.issuerUrl);
- const endpoint = new URL(config.tokenEndpoint);
- const hostname = normalizeHostname(endpoint);
- if (
- !hostname ||
- endpoint.username ||
- endpoint.password ||
- endpoint.search ||
- endpoint.hash ||
- issuer.username ||
- issuer.password ||
- issuer.search ||
- issuer.hash ||
- endpoint.origin !== issuer.origin
- ) {
- throw new Error(
- "OIDC token endpoint must be on the configured issuer origin",
+async function backendAcceptsSessionToken(token: string) {
+ const target = backendApiBaseUrl();
+ target.pathname = "/api/auth/session";
+ target.search = "";
+ try {
+ const response = await fetch(target, {
+ method: "GET",
+ headers: {
+ Accept: "application/json",
+ Authorization: `Bearer ${token}`,
+ },
+ cache: "no-store",
+ });
+ if (!response.ok) return false;
+ const body = await response.json() as {
+ user_id?: unknown;
+ organization_id?: unknown;
+ workspace_id?: unknown;
+ };
+ return (
+ typeof body.user_id === "string" &&
+ typeof body.organization_id === "string" &&
+ typeof body.workspace_id === "string"
);
+ } catch {
+ return false;
}
-
- const isLoopback = isLoopbackHostname(hostname);
- assertAllowedOidcHostname(hostname);
- if (endpoint.protocol === "http:") {
- if (!isLoopback || process.env.NODE_ENV === "production") {
- throw new Error(
- "OIDC token endpoint HTTP is limited to development loopback",
- );
- }
- } else if (endpoint.protocol === "https:") {
- if (
- isLoopback ||
- isIpv4MappedHostname(hostname) ||
- hostname.endsWith(".internal") ||
- hostname.endsWith(".local") ||
- isPrivateOrLoopbackHostname(hostname)
- ) {
- throw new Error("OIDC token endpoint must not target a private host");
- }
- } else {
- throw new Error("OIDC token endpoint requires HTTPS");
- }
-
- const safePath = endpoint.pathname
- .split("/")
- .map((segment) => {
- const decoded = decodeURIComponent(segment);
- if (
- decoded === "." ||
- decoded === ".." ||
- OIDC_CONTROL_CHARACTER_PATTERN.test(decoded)
- ) {
- throw new Error("OIDC token endpoint path is invalid");
- }
- return encodeURIComponent(decoded);
- })
- .join("/");
- const encodedPort = endpoint.port
- ? `:${encodeURIComponent(endpoint.port)}`
- : "";
- const encodedHostname = hostname.includes(":")
- ? `[${hostname}]`
- : encodeURIComponent(hostname);
- if (endpoint.protocol === "http:") {
- const loopbackOrigin =
- hostname === "localhost"
- ? `http://localhost${encodedPort}`
- : hostname === "::1"
- ? `http://[::1]${encodedPort}`
- : `http://${encodeURIComponent(hostname)}${encodedPort}`;
- return new URL(safePath, loopbackOrigin);
- }
- return new URL(
- safePath,
- `https://${encodedHostname}${encodedPort}`,
- );
-}
-
-async function backendAcceptsSessionToken(token: string) {
- const body = await fetchTrustedBackendSession(token);
- if (!body || typeof body !== "object") return false;
- const session = body as {
- user_id?: unknown;
- organization_id?: unknown;
- workspace_id?: unknown;
- };
- return (
- typeof session.user_id === "string" &&
- typeof session.organization_id === "string" &&
- typeof session.workspace_id === "string"
- );
}
export async function POST(request: NextRequest) {
@@ -216,32 +98,24 @@ export async function POST(request: NextRequest) {
code_verifier: stateCookie.verifier,
redirect_uri: config.redirectUri,
});
- let tokenEndpoint: URL;
- try {
- tokenEndpoint = trustedOidcTokenEndpoint(config);
- } catch {
- recordOidcTokenExchangeFailure("configuration_rejected");
- return errorResponse("oidc_token_exchange_failed", 502);
- }
-
let accessToken: string | null = null;
try {
- const tokenJson = await postOidcTokenRequest(tokenEndpoint, tokenBody);
+ const tokenResponse = await fetch(config.tokenEndpoint, {
+ method: "POST",
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
+ body: tokenBody,
+ cache: "no-store",
+ });
+ if (!tokenResponse.ok) {
+ return errorResponse("oidc_token_exchange_failed", 502);
+ }
+ const tokenJson = await tokenResponse.json() as { access_token?: unknown };
accessToken = normalizeSessionToken(tokenJson.access_token);
} catch {
- recordOidcTokenExchangeFailure("dns_or_transport_rejected");
return errorResponse("oidc_token_exchange_failed", 502);
}
- if (!accessToken) {
- recordOidcTokenExchangeFailure("access_token_missing_or_invalid");
- }
- const backendAccepted =
- accessToken ? await backendAcceptsSessionToken(accessToken) : false;
- if (!accessToken || !backendAccepted) {
- if (accessToken && !backendAccepted) {
- recordOidcTokenExchangeFailure("backend_session_rejected");
- }
+ if (!accessToken || !(await backendAcceptsSessionToken(accessToken))) {
const response = errorResponse("invalid_session_token", 401);
response.cookies.set(expiredOidcStateCookieOptions());
response.cookies.set(buildExpiredSessionCookieOptions());
diff --git a/frontend/src/app/auth/session/route.test.ts b/frontend/src/app/auth/session/route.test.ts
index 8d676eb9b..db1523248 100644
--- a/frontend/src/app/auth/session/route.test.ts
+++ b/frontend/src/app/auth/session/route.test.ts
@@ -239,121 +239,6 @@ describe("/auth/session route", () => {
expect(fetchMock).toHaveBeenCalledTimes(1);
});
- it("does not fetch when backend configuration is not a bare trusted origin", async () => {
- vi.stubEnv(
- "BACKEND_INTERNAL_URL",
- "https://api.naruon.net/session?target=http://169.254.169.254",
- );
- const fetchMock = vi.fn();
- vi.stubGlobal("fetch", fetchMock);
- const token = signedFixtureToken({
- sub: "user-2",
- org: "org-beta",
- workspace: "workspace-beta",
- });
-
- const response = await GET(
- new NextRequest("https://app.naruon.net/auth/session", {
- headers: { Cookie: `naruon_session=${token}` },
- }),
- );
-
- await expect(response.json()).resolves.toEqual({
- authenticated: false,
- claims: {
- userId: null,
- organizationId: null,
- workspaceId: null,
- },
- });
- expect(fetchMock).not.toHaveBeenCalled();
- });
-
- it("allows only the exact loopback backend in non-production", async () => {
- vi.stubEnv("BACKEND_INTERNAL_URL", "http://127.0.0.1:8000");
- vi.stubEnv("ALLOW_DOCKER_BACKEND_INTERNAL_URL", "1");
- const token = signedFixtureToken({
- sub: "local-user",
- org: "local-org",
- workspace: "local-workspace",
- });
- const fetchMock = vi
- .fn<
- (
- input: URL | RequestInfo,
- init?: RequestInit,
- ) => Promise
- >()
- .mockResolvedValue(Response.json({
- user_id: "local-user",
- organization_id: "local-org",
- workspace_id: "local-workspace",
- }));
- vi.stubGlobal("fetch", fetchMock);
-
- const response = await GET(
- new NextRequest("http://localhost:3000/auth/session", {
- headers: { Cookie: `naruon_session=${token}` },
- }),
- );
-
- await expect(response.json()).resolves.toMatchObject({
- authenticated: true,
- });
- expect(fetchMock).toHaveBeenCalledTimes(1);
- const [input, init] = fetchMock.mock.calls[0];
- expect(String(input)).toBe("http://127.0.0.1:8000/api/auth/session");
- expect(init).toEqual(expect.objectContaining({
- cache: "no-store",
- redirect: "manual",
- signal: expect.any(AbortSignal),
- }));
- });
-
- it("preserves a validated global IPv6 backend authority", async () => {
- vi.stubEnv(
- "BACKEND_INTERNAL_URL",
- "https://[2001:4860:4860::8888]:8443",
- );
- const token = signedFixtureToken({
- sub: "ipv6-user",
- org: "ipv6-org",
- workspace: "ipv6-workspace",
- });
- const fetchMock = vi
- .fn<
- (
- input: URL | RequestInfo,
- init?: RequestInit,
- ) => Promise
- >()
- .mockResolvedValue(Response.json({
- user_id: "ipv6-user",
- organization_id: "ipv6-org",
- workspace_id: "ipv6-workspace",
- }));
- vi.stubGlobal("fetch", fetchMock);
-
- const response = await GET(
- new NextRequest("https://app.naruon.net/auth/session", {
- headers: { Cookie: `naruon_session=${token}` },
- }),
- );
-
- await expect(response.json()).resolves.toMatchObject({
- authenticated: true,
- });
- expect(fetchMock).toHaveBeenCalledTimes(1);
- const [input, init] = fetchMock.mock.calls[0];
- expect(String(input)).toBe(
- "https://[2001:4860:4860::8888]:8443/api/auth/session",
- );
- expect(init).toEqual(expect.objectContaining({
- redirect: "manual",
- signal: expect.any(AbortSignal),
- }));
- });
-
it("rejects forged tokens that the backend verifier does not accept", async () => {
vi.stubGlobal("fetch", vi.fn(async () => Response.json(
{ detail: "Authentication required" },
diff --git a/frontend/src/app/auth/session/route.ts b/frontend/src/app/auth/session/route.ts
index 7212412f7..6ffa17ab4 100644
--- a/frontend/src/app/auth/session/route.ts
+++ b/frontend/src/app/auth/session/route.ts
@@ -10,7 +10,7 @@ import {
normalizeSessionToken,
type SessionClaims,
} from "@/lib/session-cookie";
-import { fetchTrustedBackendSession } from "@/lib/backend-session-probe";
+import { backendApiBaseUrl } from "@/lib/backend-url";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
@@ -228,12 +228,27 @@ function claimsFromBackendSession(body: BackendSessionResponse): SessionClaims |
};
}
-async function verifySessionToken(
- token: string,
-): Promise {
- const body = await fetchTrustedBackendSession(token);
- if (!body || typeof body !== "object") return null;
- return claimsFromBackendSession(body as BackendSessionResponse);
+async function verifySessionToken(token: string): Promise {
+ const target = backendApiBaseUrl();
+ target.pathname = "/api/auth/session";
+ target.search = "";
+
+ try {
+ const response = await fetch(target, {
+ method: "GET",
+ headers: {
+ Accept: "application/json",
+ Authorization: `Bearer ${token}`,
+ },
+ cache: "no-store",
+ });
+ if (!response.ok) return null;
+
+ const body = (await response.json()) as BackendSessionResponse;
+ return claimsFromBackendSession(body);
+ } catch {
+ return null;
+ }
}
function sessionJson(claims: SessionClaims | null) {
diff --git a/frontend/src/components/SearchLayout.test.tsx b/frontend/src/components/SearchLayout.test.tsx
index f9a47b66c..e5917d15e 100644
--- a/frontend/src/components/SearchLayout.test.tsx
+++ b/frontend/src/components/SearchLayout.test.tsx
@@ -85,14 +85,6 @@ describe("SearchLayout product events", () => {
});
it("records context search submit, result open, and result action events without raw query text", async () => {
- const randomUUID = vi.fn(
- () =>
- "11111111-2222-4333-8444-555555555555" as `${string}-${string}-${string}-${string}-${string}`,
- );
- vi.stubGlobal("crypto", {
- randomUUID,
- getRandomValues: (array: T) => array,
- });
const fetchMock = vi.fn((input: RequestInfo | URL, init?: RequestInit) => {
const url = String(input);
if (url.endsWith("/api/search")) {
@@ -159,9 +151,7 @@ describe("SearchLayout product events", () => {
expect(getRecordedProductEvents().some((event) =>
event.name === "context_search_submitted" &&
event.payload.surface === "context_search" &&
- event.payload.query_length_bucket === "1_20" &&
- event.payload.search_session_id ===
- "context_search_session_11111111-2222-4333-8444-555555555555",
+ event.payload.query_length_bucket === "1_20",
)).toBe(true);
expect(getRecordedProductEvents().some((event) =>
event.name === "context_search_result_opened" &&
diff --git a/frontend/src/components/SearchLayout.tsx b/frontend/src/components/SearchLayout.tsx
index 92f16b184..5a64304c2 100644
--- a/frontend/src/components/SearchLayout.tsx
+++ b/frontend/src/components/SearchLayout.tsx
@@ -349,9 +349,7 @@ const SearchResultItemComponent = memo(function SearchResultItemComponent({
export function SearchLayout() {
const [query, setQuery] = useState(DEFAULT_QUERY);
const searchInputRef = useRef(null);
- const searchSessionIdRef = useRef(
- createProductEventId("context_search_session"),
- );
+ const searchSessionIdRef = useRef(createProductEventId("context_search_session"));
const lastOpenedResultKeyRef = useRef(null);
const [submittedQuery, setSubmittedQuery] = useState(DEFAULT_QUERY);
const [activeFilter, setActiveFilter] = useState("all");
@@ -541,9 +539,7 @@ export function SearchLayout() {
const submitSearch = (event: FormEvent) => {
event.preventDefault();
const trimmedQuery = query.trim();
- searchSessionIdRef.current = createProductEventId(
- "context_search_session",
- );
+ searchSessionIdRef.current = createProductEventId("context_search_session");
lastOpenedResultKeyRef.current = null;
recordProductEvent("context_search_submitted", {
surface: "context_search",
diff --git a/frontend/src/lib/backend-session-probe.ts b/frontend/src/lib/backend-session-probe.ts
deleted file mode 100644
index 667a45201..000000000
--- a/frontend/src/lib/backend-session-probe.ts
+++ /dev/null
@@ -1,25 +0,0 @@
-import { trustedBackendOrigin } from "@/lib/backend-url";
-
-const BACKEND_SESSION_PROBE_TIMEOUT_MS = 15_000;
-
-export async function fetchTrustedBackendSession(
- token: string,
-): Promise {
- try {
- const target = new URL("/api/auth/session", trustedBackendOrigin());
- const response = await fetch(target, {
- method: "GET",
- headers: {
- Accept: "application/json",
- Authorization: `Bearer ${token}`,
- },
- cache: "no-store",
- redirect: "manual",
- signal: AbortSignal.timeout(BACKEND_SESSION_PROBE_TIMEOUT_MS),
- });
- if (!response.ok) return null;
- return await response.json();
- } catch {
- return null;
- }
-}
diff --git a/frontend/src/lib/backend-url.test.ts b/frontend/src/lib/backend-url.test.ts
index c2f922997..5cbc0b9cc 100644
--- a/frontend/src/lib/backend-url.test.ts
+++ b/frontend/src/lib/backend-url.test.ts
@@ -1,10 +1,6 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
-import {
- backendApiBaseUrl,
- parseBackendInternalUrl,
- trustedBackendOrigin,
-} from "./backend-url";
+import { backendApiBaseUrl, parseBackendInternalUrl } from "./backend-url";
const ORIGINAL_ENV = { ...process.env };
@@ -124,29 +120,4 @@ describe("backend URL guard", () => {
vi.stubEnv("NODE_ENV", "production");
expect(() => backendApiBaseUrl()).toThrow("production runtime");
});
-
- it("builds one normalized trusted origin for server routes", () => {
- vi.stubEnv("BACKEND_INTERNAL_URL", "https://backend.example.com.:8443");
- expect(trustedBackendOrigin().href).toBe("https://backend.example.com:8443/");
- });
-
- it.each([
- "https://user@backend.example.com",
- "https://backend.example.com/api",
- "https://backend.example.com?scope=all",
- "https://backend.example.com#fragment",
- ])("rejects non-origin backend configuration %s", (backendInternalUrl) => {
- vi.stubEnv("BACKEND_INTERNAL_URL", backendInternalUrl);
- expect(() => trustedBackendOrigin()).toThrow(
- "must be an origin without credentials, path, query, or fragment",
- );
- });
-
- it("preserves the exact development and opted-in Compose origins", () => {
- expect(trustedBackendOrigin().origin).toBe("http://127.0.0.1:8000");
-
- vi.stubEnv("ALLOW_DOCKER_BACKEND_INTERNAL_URL", "1");
- vi.stubEnv("BACKEND_INTERNAL_URL", "http://backend:8000");
- expect(trustedBackendOrigin().origin).toBe("http://backend:8000");
- });
});
diff --git a/frontend/src/lib/backend-url.ts b/frontend/src/lib/backend-url.ts
index 81ffa39e6..bab466670 100644
--- a/frontend/src/lib/backend-url.ts
+++ b/frontend/src/lib/backend-url.ts
@@ -1,10 +1,56 @@
-import {
- isPrivateOrLoopbackHostname,
- normalizeHostname,
-} from "@/lib/host-policy";
+const PRIVATE_BACKEND_HOST_PATTERNS: readonly RegExp[] = [
+ /^localhost$/,
+ /^127\./,
+ /^0\./,
+ /^10\./,
+ /^192\.168\./,
+ /^172\.(1[6-9]|2\d|3[01])\./,
+ /^169\.254\./,
+ /^::1$/,
+ /^::$/,
+ /^fc[0-9a-f]{2}:/,
+ /^fd[0-9a-f]{2}:/,
+ /^fe[89ab][0-9a-f]:/,
+];
+
+function normalizeHost(parsed: URL): string {
+ return parsed.hostname.replace(/^\[/, "").replace(/\]$/, "").toLowerCase();
+}
+
+function ipv4MappedHostToDotted(host: string): string | null {
+ if (!host.startsWith("::ffff:")) return null;
+ const suffix = host.slice("::ffff:".length);
+ if (/^\d{1,3}(?:\.\d{1,3}){3}$/.test(suffix)) return suffix;
+ const [highHex, lowHex] = suffix.split(":");
+ if (
+ !highHex ||
+ !lowHex ||
+ !/^[0-9a-f]{1,4}$/.test(highHex) ||
+ !/^[0-9a-f]{1,4}$/.test(lowHex)
+ ) {
+ return null;
+ }
+ const high = Number.parseInt(highHex, 16);
+ const low = Number.parseInt(lowHex, 16);
+ if (!Number.isFinite(high) || !Number.isFinite(low)) return null;
+ const bytes = [high >> 8, high & 255, low >> 8, low & 255];
+ return bytes.join(".");
+}
+
+function hostCandidates(parsed: URL): string[] {
+ const host = normalizeHost(parsed);
+ const mapped = ipv4MappedHostToDotted(host);
+ return mapped ? [host, mapped] : [host];
+}
+
+function isPrivateBackendHost(parsed: URL): boolean {
+ return hostCandidates(parsed).some((host) =>
+ PRIVATE_BACKEND_HOST_PATTERNS.some((pattern) => pattern.test(host)),
+ );
+}
function isAllowedComposeBackendUrl(parsed: URL): boolean {
- const host = normalizeHostname(parsed);
+ const host = normalizeHost(parsed);
return (
process.env.ALLOW_DOCKER_BACKEND_INTERNAL_URL === "1" &&
parsed.protocol === "http:" &&
@@ -29,12 +75,12 @@ export function parseBackendInternalUrl(raw: string): URL {
`BACKEND_INTERNAL_URL must use https:// in split deployments, got ${parsed.protocol}//`,
);
}
- if (!normalizeHostname(parsed)) {
+ if (!normalizeHost(parsed)) {
throw new Error("BACKEND_INTERNAL_URL must include a hostname");
}
- if (isPrivateOrLoopbackHostname(parsed)) {
+ if (isPrivateBackendHost(parsed)) {
throw new Error(
- `BACKEND_INTERNAL_URL host ${normalizeHostname(parsed)} is in a private/loopback/link-local range`,
+ `BACKEND_INTERNAL_URL host ${normalizeHost(parsed)} is in a private/loopback/link-local range`,
);
}
return parsed;
@@ -51,53 +97,3 @@ export function backendApiBaseUrl(): URL {
}
return new URL("http://127.0.0.1:8000");
}
-
-export function trustedBackendOrigin(): URL {
- const configured = backendApiBaseUrl();
- const hostname = normalizeHostname(configured);
- const hasRootPath = configured.pathname === "" || configured.pathname === "/";
- if (
- !hostname ||
- configured.username ||
- configured.password ||
- configured.search ||
- configured.hash ||
- !hasRootPath
- ) {
- throw new Error(
- "BACKEND_INTERNAL_URL must be an origin without credentials, path, query, or fragment",
- );
- }
-
- if (configured.protocol === "http:") {
- const isExactLocalBackend =
- configured.port === "8000" &&
- (hostname === "127.0.0.1" || hostname === "localhost");
- const isExactComposeBackend =
- process.env.ALLOW_DOCKER_BACKEND_INTERNAL_URL === "1" &&
- configured.port === "8000" &&
- hostname === "backend";
- if (
- (!isExactLocalBackend || process.env.NODE_ENV === "production") &&
- !isExactComposeBackend
- ) {
- throw new Error(
- "Backend HTTP is limited to the exact development loopback or opted-in Compose host",
- );
- }
- if (hostname === "backend") return new URL("http://backend:8000");
- if (hostname === "localhost") return new URL("http://localhost:8000");
- return new URL("http://127.0.0.1:8000");
- }
-
- if (configured.protocol !== "https:") {
- throw new Error("Backend requests require HTTPS");
- }
- const encodedHostname = hostname.includes(":")
- ? `[${hostname}]`
- : encodeURIComponent(hostname);
- const encodedPort = configured.port
- ? `:${encodeURIComponent(configured.port)}`
- : "";
- return new URL(`https://${encodedHostname}${encodedPort}`);
-}
diff --git a/frontend/src/lib/host-policy.ts b/frontend/src/lib/host-policy.ts
deleted file mode 100644
index 34e93c2dc..000000000
--- a/frontend/src/lib/host-policy.ts
+++ /dev/null
@@ -1,64 +0,0 @@
-const PRIVATE_OR_LOCAL_HOST_PATTERNS: readonly RegExp[] = [
- /^localhost$/,
- /^0\./,
- /^10\./,
- /^127\./,
- /^169\.254\./,
- /^172\.(1[6-9]|2\d|3[01])\./,
- /^192\.168\./,
- /^::$/,
- /^::1$/,
- /^fc[0-9a-f]{2}:/,
- /^fd[0-9a-f]{2}:/,
- /^fe[89ab][0-9a-f]:/,
-];
-
-export function normalizeHostname(value: URL | string): string {
- const hostname = typeof value === "string" ? value : value.hostname;
- return hostname
- .replace(/^\[/, "")
- .replace(/\]$/, "")
- .replace(/\.+$/, "")
- .toLowerCase();
-}
-
-export function isLoopbackHostname(value: URL | string): boolean {
- const hostname = normalizeHostname(value);
- return (
- hostname === "localhost" ||
- hostname === "127.0.0.1" ||
- hostname === "::1"
- );
-}
-
-export function isIpv4MappedHostname(value: URL | string): boolean {
- return normalizeHostname(value).startsWith("::ffff:");
-}
-
-function ipv4MappedHostToDotted(hostname: string): string | null {
- if (!hostname.startsWith("::ffff:")) return null;
- const suffix = hostname.slice("::ffff:".length);
- if (/^\d{1,3}(?:\.\d{1,3}){3}$/.test(suffix)) return suffix;
- const [highHex, lowHex] = suffix.split(":");
- if (
- !highHex ||
- !lowHex ||
- !/^[0-9a-f]{1,4}$/.test(highHex) ||
- !/^[0-9a-f]{1,4}$/.test(lowHex)
- ) {
- return null;
- }
- const high = Number.parseInt(highHex, 16);
- const low = Number.parseInt(lowHex, 16);
- if (!Number.isFinite(high) || !Number.isFinite(low)) return null;
- return [high >> 8, high & 255, low >> 8, low & 255].join(".");
-}
-
-export function isPrivateOrLoopbackHostname(value: URL | string): boolean {
- const hostname = normalizeHostname(value);
- const mapped = ipv4MappedHostToDotted(hostname);
- const candidates = mapped ? [hostname, mapped] : [hostname];
- return candidates.some((candidate) =>
- PRIVATE_OR_LOCAL_HOST_PATTERNS.some((pattern) => pattern.test(candidate)),
- );
-}
diff --git a/frontend/src/lib/oidc-token-client.test.ts b/frontend/src/lib/oidc-token-client.test.ts
deleted file mode 100644
index d9c785a3d..000000000
--- a/frontend/src/lib/oidc-token-client.test.ts
+++ /dev/null
@@ -1,263 +0,0 @@
-import { afterEach, describe, expect, it, vi } from "vitest";
-import { createServer } from "node:http";
-
-import {
- createPinnedOidcLookup,
- postOidcTokenRequest,
- resolveOidcTokenAddresses,
- type OidcDnsLookup,
-} from "./oidc-token-client";
-
-afterEach(() => {
- vi.unstubAllEnvs();
-});
-
-describe("OIDC token destination pinning", () => {
- it("accepts and deduplicates only globally routable DNS results", async () => {
- const dnsLookup = vi.fn().mockResolvedValue([
- { address: "8.8.8.8", family: 4 },
- { address: "8.8.8.8", family: 4 },
- { address: "2001:4860:4860::8888", family: 6 },
- ]);
-
- await expect(
- resolveOidcTokenAddresses(
- new URL("https://login.example.com/token"),
- dnsLookup,
- ),
- ).resolves.toEqual([
- { address: "8.8.8.8", family: 4 },
- { address: "2001:4860:4860::8888", family: 6 },
- ]);
- expect(dnsLookup).toHaveBeenCalledOnce();
- });
-
- it.each([
- "127.0.0.1",
- "10.0.0.8",
- "100.64.0.1",
- "169.254.169.254",
- "192.168.1.10",
- "::1",
- "::ffff:127.0.0.1",
- "fc00::1",
- "fe80::1",
- ])("rejects non-global DNS answer %s", async (address) => {
- const family = address.includes(":") ? 6 : 4;
- const dnsLookup = vi
- .fn()
- .mockResolvedValue([{ address, family }]);
-
- await expect(
- resolveOidcTokenAddresses(
- new URL("https://login.example.com/token"),
- dnsLookup,
- ),
- ).rejects.toThrow("globally routable");
- });
-
- it("rejects the whole destination when one DNS answer is private", async () => {
- const dnsLookup = vi.fn().mockResolvedValue([
- { address: "8.8.8.8", family: 4 },
- { address: "127.0.0.1", family: 4 },
- ]);
-
- await expect(
- resolveOidcTokenAddresses(
- new URL("https://login.example.com/token"),
- dnsLookup,
- ),
- ).rejects.toThrow("globally routable");
- });
-
- it("allows exact development loopback HTTP but rejects it in production", async () => {
- vi.stubEnv("NODE_ENV", "development");
- await expect(
- resolveOidcTokenAddresses(new URL("http://127.0.0.1:8080/token")),
- ).resolves.toEqual([{ address: "127.0.0.1", family: 4 }]);
-
- vi.stubEnv("NODE_ENV", "production");
- await expect(
- resolveOidcTokenAddresses(new URL("http://127.0.0.1:8080/token")),
- ).rejects.toThrow("globally routable");
- });
-
- it("rejects a non-loopback DNS answer for development localhost HTTP", async () => {
- vi.stubEnv("NODE_ENV", "development");
- const dnsLookup = vi
- .fn()
- .mockResolvedValue([{ address: "8.8.8.8", family: 4 }]);
-
- await expect(
- resolveOidcTokenAddresses(
- new URL("http://localhost:8080/token"),
- dnsLookup,
- ),
- ).rejects.toThrow("only to loopback");
- });
-
- it("returns only the prevalidated address without another DNS lookup", async () => {
- const pinnedLookup = createPinnedOidcLookup(
- "login.example.com",
- [{ address: "8.8.8.8", family: 4 }],
- );
- const invokeLookup = pinnedLookup as unknown as (
- hostname: string,
- options: { all: false; family: number },
- callback: (
- error: Error | null,
- address: string,
- family: number,
- ) => void,
- ) => void;
-
- const result = await new Promise<{ address: string; family: number }>(
- (resolve, reject) => {
- invokeLookup(
- "login.example.com",
- { all: false, family: 0 },
- (error, address, family) => {
- if (error) {
- reject(error);
- return;
- }
- resolve({ address, family });
- },
- );
- },
- );
-
- expect(result).toEqual({ address: "8.8.8.8", family: 4 });
- });
-
- it("refuses to reuse pinned addresses for a different hostname", async () => {
- const pinnedLookup = createPinnedOidcLookup(
- "login.example.com",
- [{ address: "8.8.8.8", family: 4 }],
- );
- const invokeLookup = pinnedLookup as unknown as (
- hostname: string,
- options: { all: false; family: number },
- callback: (error: Error | null) => void,
- ) => void;
-
- await expect(
- new Promise((resolve, reject) => {
- invokeLookup(
- "attacker.example",
- { all: false, family: 0 },
- (error) => {
- if (error) {
- reject(error);
- return;
- }
- resolve();
- },
- );
- }),
- ).rejects.toThrow("unexpected hostname");
- });
-
- it("posts through the pinned native client", async () => {
- vi.stubEnv("NODE_ENV", "development");
- const server = createServer((request, response) => {
- expect(request.method).toBe("POST");
- expect(request.headers.host).toMatch(/^127\.0\.0\.1:/);
- response.writeHead(200, { "Content-Type": "application/json" });
- response.end(JSON.stringify({ access_token: "signed-token" }));
- });
- await new Promise((resolve, reject) => {
- server.once("error", reject);
- server.listen(0, "127.0.0.1", () => resolve());
- });
- const address = server.address();
- if (!address || typeof address === "string") {
- server.close();
- throw new Error("test server did not expose a TCP port");
- }
-
- try {
- await expect(
- postOidcTokenRequest(
- new URL(`http://127.0.0.1:${address.port}/token`),
- new URLSearchParams({ code: "auth-code" }),
- ),
- ).resolves.toEqual({ access_token: "signed-token" });
- } finally {
- await new Promise((resolve, reject) => {
- server.close((error) => {
- if (error) reject(error);
- else resolve();
- });
- });
- }
- });
-
- it("rejects redirects instead of following them", async () => {
- vi.stubEnv("NODE_ENV", "development");
- const server = createServer((_request, response) => {
- response.writeHead(302, {
- Location: "http://127.0.0.1/redirected",
- });
- response.end();
- });
- await new Promise((resolve, reject) => {
- server.once("error", reject);
- server.listen(0, "127.0.0.1", () => resolve());
- });
- const address = server.address();
- if (!address || typeof address === "string") {
- server.close();
- throw new Error("test server did not expose a TCP port");
- }
-
- try {
- await expect(
- postOidcTokenRequest(
- new URL(`http://127.0.0.1:${address.port}/token`),
- new URLSearchParams({ code: "auth-code" }),
- ),
- ).rejects.toThrow("returned HTTP 302");
- } finally {
- await new Promise((resolve, reject) => {
- server.close((error) => {
- if (error) reject(error);
- else resolve();
- });
- });
- }
- });
-
- it("rejects oversized responses even when the response ends", async () => {
- vi.stubEnv("NODE_ENV", "development");
- const server = createServer((_request, response) => {
- response.writeHead(200, { "Content-Type": "application/json" });
- response.end(Buffer.alloc(1024 * 1024 + 1, "x"));
- });
- await new Promise((resolve, reject) => {
- server.once("error", reject);
- server.listen(0, "127.0.0.1", () => resolve());
- });
- const address = server.address();
- if (!address || typeof address === "string") {
- server.close();
- throw new Error("test server did not expose a TCP port");
- }
-
- try {
- await expect(
- postOidcTokenRequest(
- new URL(`http://127.0.0.1:${address.port}/token`),
- new URLSearchParams({ code: "auth-code" }),
- ),
- ).rejects.toThrow("exceeded the size limit");
- } finally {
- await new Promise((resolve, reject) => {
- server.close((error) => {
- if (error) reject(error);
- else resolve();
- });
- });
- }
- });
-});
diff --git a/frontend/src/lib/oidc-token-client.ts b/frontend/src/lib/oidc-token-client.ts
deleted file mode 100644
index 59f5ef6cb..000000000
--- a/frontend/src/lib/oidc-token-client.ts
+++ /dev/null
@@ -1,283 +0,0 @@
-import { lookup as systemLookup } from "node:dns/promises";
-import {
- request as httpRequest,
-} from "node:http";
-import {
- request as httpsRequest,
- type RequestOptions,
-} from "node:https";
-import {
- BlockList,
- isIP,
- type LookupFunction,
-} from "node:net";
-
-import { normalizeHostname } from "@/lib/host-policy";
-
-const OIDC_DNS_TIMEOUT_MS = 5_000;
-const OIDC_REQUEST_TIMEOUT_MS = 15_000;
-const OIDC_RESPONSE_MAX_BYTES = 1024 * 1024;
-
-type AddressFamily = 4 | 6;
-
-export interface OidcResolvedAddress {
- address: string;
- family: AddressFamily;
-}
-
-export type OidcDnsLookup = (
- hostname: string,
- options: { all: true; verbatim: true },
-) => Promise;
-
-const NON_GLOBAL_ADDRESSES = new BlockList();
-
-for (const [network, prefix] of [
- ["0.0.0.0", 8],
- ["10.0.0.0", 8],
- ["100.64.0.0", 10],
- ["127.0.0.0", 8],
- ["169.254.0.0", 16],
- ["172.16.0.0", 12],
- ["192.0.0.0", 24],
- ["192.0.2.0", 24],
- ["192.88.99.0", 24],
- ["192.168.0.0", 16],
- ["198.18.0.0", 15],
- ["198.51.100.0", 24],
- ["203.0.113.0", 24],
- ["224.0.0.0", 4],
- ["240.0.0.0", 4],
-] as const) {
- NON_GLOBAL_ADDRESSES.addSubnet(network, prefix, "ipv4");
-}
-
-for (const [network, prefix] of [
- ["::", 96],
- ["64:ff9b::", 96],
- ["64:ff9b:1::", 48],
- ["100::", 64],
- ["2001::", 23],
- ["2001:db8::", 32],
- ["2002::", 16],
- ["3fff::", 20],
- ["5f00::", 16],
- ["fc00::", 7],
- ["fe80::", 10],
- ["fec0::", 10],
- ["ff00::", 8],
-] as const) {
- NON_GLOBAL_ADDRESSES.addSubnet(network, prefix, "ipv6");
-}
-
-function isLoopbackAddress(address: string): boolean {
- if (isIP(address) === 4) {
- return NON_GLOBAL_ADDRESSES.check(address, "ipv4") && address.startsWith("127.");
- }
- return address === "::1";
-}
-
-function validateResolvedAddress(
- address: string,
- { allowLoopback }: { allowLoopback: boolean },
-): OidcResolvedAddress {
- const family = isIP(address);
- if (family !== 4 && family !== 6) {
- throw new Error("OIDC token endpoint resolved to an invalid IP address");
- }
- if (family === 6 && address.toLowerCase().startsWith("::ffff:")) {
- throw new Error(
- "OIDC token endpoint must resolve only to globally routable addresses",
- );
- }
- if (allowLoopback) {
- if (!isLoopbackAddress(address)) {
- throw new Error(
- "Development HTTP OIDC token endpoints must resolve only to loopback addresses",
- );
- }
- return { address, family };
- }
- const type = family === 4 ? "ipv4" : "ipv6";
- if (NON_GLOBAL_ADDRESSES.check(address, type)) {
- throw new Error(
- "OIDC token endpoint must resolve only to globally routable addresses",
- );
- }
- return { address, family };
-}
-
-function deduplicateAddresses(
- addresses: readonly OidcResolvedAddress[],
-): OidcResolvedAddress[] {
- const seen = new Set();
- return addresses.filter(({ address, family }) => {
- const key = `${family}:${address}`;
- if (seen.has(key)) return false;
- seen.add(key);
- return true;
- });
-}
-
-async function withDnsTimeout(operation: Promise): Promise {
- let timer: ReturnType | undefined;
- try {
- return await Promise.race([
- operation,
- new Promise((_, reject) => {
- timer = setTimeout(
- () => reject(new Error("OIDC token endpoint DNS resolution timed out")),
- OIDC_DNS_TIMEOUT_MS,
- );
- }),
- ]);
- } finally {
- if (timer) clearTimeout(timer);
- }
-}
-
-export async function resolveOidcTokenAddresses(
- endpoint: URL,
- dnsLookup: OidcDnsLookup = systemLookup,
-): Promise {
- const hostname = normalizeHostname(endpoint);
- const allowLoopback =
- endpoint.protocol === "http:" &&
- process.env.NODE_ENV !== "production" &&
- (hostname === "localhost" || hostname === "127.0.0.1" || hostname === "::1");
- const literalFamily = isIP(hostname);
- if (literalFamily === 4 || literalFamily === 6) {
- return [validateResolvedAddress(hostname, { allowLoopback })];
- }
-
- const resolved = await withDnsTimeout(
- dnsLookup(hostname, { all: true, verbatim: true }),
- );
- if (resolved.length === 0) {
- throw new Error("OIDC token endpoint did not resolve to an IP address");
- }
- const addresses = resolved.map(({ address }) =>
- validateResolvedAddress(address, { allowLoopback }),
- );
- return deduplicateAddresses(addresses);
-}
-
-export function createPinnedOidcLookup(
- expectedHostname: string,
- addresses: readonly OidcResolvedAddress[],
-): LookupFunction {
- if (addresses.length === 0) {
- throw new Error("OIDC token endpoint requires a pinned IP address");
- }
-
- return ((hostname: string, options: unknown, callback: (...args: unknown[]) => void) => {
- if (
- hostname.replace(/\.+$/, "").toLowerCase() !==
- expectedHostname.replace(/\.+$/, "").toLowerCase()
- ) {
- callback(new Error("OIDC pinned lookup rejected an unexpected hostname"));
- return;
- }
- const requestedFamily =
- typeof options === "object" &&
- options !== null &&
- "family" in options &&
- (options.family === 4 || options.family === 6)
- ? options.family
- : 0;
- const eligible = addresses.filter(
- ({ family }) => requestedFamily === 0 || family === requestedFamily,
- );
- if (eligible.length === 0) {
- callback(new Error("OIDC token endpoint has no address in the requested family"));
- return;
- }
- const wantsAll =
- typeof options === "object" &&
- options !== null &&
- "all" in options &&
- options.all === true;
- if (wantsAll) {
- callback(null, eligible);
- return;
- }
- callback(null, eligible[0].address, eligible[0].family);
- }) as LookupFunction;
-}
-
-function collectJsonResponse(
- endpoint: URL,
- requestOptions: RequestOptions,
- body: string,
-): Promise<{ access_token?: unknown }> {
- const requester = endpoint.protocol === "http:" ? httpRequest : httpsRequest;
- return new Promise((resolve, reject) => {
- const request = requester(endpoint, requestOptions, (response) => {
- const statusCode = response.statusCode ?? 0;
- const chunks: Buffer[] = [];
- let receivedBytes = 0;
- let sizeLimitExceeded = false;
- response.on("data", (chunk: Buffer | string) => {
- if (sizeLimitExceeded) return;
- const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
- receivedBytes += buffer.length;
- if (receivedBytes > OIDC_RESPONSE_MAX_BYTES) {
- sizeLimitExceeded = true;
- const error = new Error("OIDC token response exceeded the size limit");
- request.destroy(error);
- reject(error);
- return;
- }
- chunks.push(buffer);
- });
- response.on("end", () => {
- if (sizeLimitExceeded) return;
- if (statusCode < 200 || statusCode >= 300) {
- reject(new Error(`OIDC token endpoint returned HTTP ${statusCode}`));
- return;
- }
- try {
- resolve(
- JSON.parse(Buffer.concat(chunks).toString("utf8")) as {
- access_token?: unknown;
- },
- );
- } catch (error) {
- reject(new Error("OIDC token endpoint returned invalid JSON", { cause: error }));
- }
- });
- response.on("error", reject);
- });
- request.setTimeout(OIDC_REQUEST_TIMEOUT_MS, () => {
- request.destroy(new Error("OIDC token endpoint request timed out"));
- });
- request.on("error", reject);
- request.end(body);
- });
-}
-
-export async function postOidcTokenRequest(
- endpoint: URL,
- body: URLSearchParams,
-): Promise<{ access_token?: unknown }> {
- if (endpoint.protocol !== "http:" && endpoint.protocol !== "https:") {
- throw new Error("OIDC token endpoint requires HTTP(S)");
- }
- const addresses = await resolveOidcTokenAddresses(endpoint);
- const hostname = normalizeHostname(endpoint);
- const encodedBody = body.toString();
- const requestOptions: RequestOptions = {
- method: "POST",
- agent: false,
- headers: {
- Accept: "application/json",
- "Content-Type": "application/x-www-form-urlencoded",
- "Content-Length": String(Buffer.byteLength(encodedBody)),
- },
- lookup: createPinnedOidcLookup(hostname, addresses),
- };
- if (endpoint.protocol === "https:" && isIP(hostname) === 0) {
- requestOptions.servername = hostname;
- }
- return collectJsonResponse(endpoint, requestOptions, encodedBody);
-}
diff --git a/frontend/src/lib/product-events.test.ts b/frontend/src/lib/product-events.test.ts
index 7611cf09a..892413e98 100644
--- a/frontend/src/lib/product-events.test.ts
+++ b/frontend/src/lib/product-events.test.ts
@@ -1,5 +1,5 @@
/* @vitest-environment jsdom */
-import { describe, expect, it, vi } from "vitest";
+import { describe, expect, it } from "vitest";
import {
bucketSearchRank,
bucketTextLength,
@@ -187,31 +187,4 @@ describe("product event contracts", () => {
expect(bucketSearchRank(9)).toBe("top_10");
expect(bucketSearchRank(10)).toBe("below_10");
});
-
- it("uses Web Crypto bytes when randomUUID is unavailable", () => {
- vi.stubGlobal("crypto", {
- getRandomValues: (bytes: Uint8Array) => {
- bytes.fill(0xab);
- return bytes;
- },
- });
- try {
- expect(createProductEventId("secure")).toBe(
- `secure_${"ab".repeat(16)}`,
- );
- } finally {
- vi.unstubAllGlobals();
- }
- });
-
- it("fails closed when Web Crypto is unavailable", () => {
- vi.stubGlobal("crypto", undefined);
- try {
- expect(() => createProductEventId("secure")).toThrow(
- "Web Crypto is required",
- );
- } finally {
- vi.unstubAllGlobals();
- }
- });
});
diff --git a/frontend/src/lib/product-events.ts b/frontend/src/lib/product-events.ts
index 7389970e2..8228b6d5e 100644
--- a/frontend/src/lib/product-events.ts
+++ b/frontend/src/lib/product-events.ts
@@ -350,20 +350,13 @@ const BLOCKED_PAYLOAD_FIELD_NAMES = new Set([
const localProductEventBuffer: RecordedProductEvent[] = [];
const LOCAL_PRODUCT_EVENT_BUFFER_LIMIT = 200;
+function createFallbackEventId() {
+ return `${Date.now().toString(36)}_${Math.random().toString(16).slice(2)}`;
+}
+
export function createProductEventId(prefix = "product_evt"): string {
- const secureRandom = globalThis.crypto;
- if (!secureRandom) {
- throw new Error("Web Crypto is required for product event identifiers");
- }
- if (typeof secureRandom.randomUUID === "function") {
- return `${prefix}_${secureRandom.randomUUID()}`;
- }
- const bytes = new Uint8Array(16);
- secureRandom.getRandomValues(bytes);
- const randomId = Array.from(bytes, (value) =>
- value.toString(16).padStart(2, "0"),
- ).join("");
- return `${prefix}_${randomId}`;
+ const randomId = globalThis.crypto?.randomUUID?.();
+ return randomId ? `${prefix}_${randomId}` : `${prefix}_${createFallbackEventId()}`;
}
export function bucketTextLength(value: string): "empty" | "1_20" | "21_80" | "81_200" | "201_plus" {
diff --git a/scripts/ci/ensure_scorecard_sarif_categories.py b/scripts/ci/ensure_scorecard_sarif_categories.py
index 96b80c4a5..3817f199b 100644
--- a/scripts/ci/ensure_scorecard_sarif_categories.py
+++ b/scripts/ci/ensure_scorecard_sarif_categories.py
@@ -4,26 +4,13 @@
from __future__ import annotations
import json
-import os
import stat
import sys
-import tempfile
-from contextlib import contextmanager
from copy import deepcopy
from pathlib import Path
-from typing import Any, BinaryIO, Iterator, NamedTuple
+from typing import Any
REQUIRED_SCORECARD_CATEGORIES = ("supply-chain/branch-protection",)
-SCORECARD_SARIF_FILENAME = "scorecard-results.sarif"
-MAX_SARIF_BYTES = 32 * 1024 * 1024
-
-
-class ScorecardSarifArtifact(NamedTuple):
- """An opened, validated Scorecard artifact and its original file mode."""
-
- path: Path
- source: BinaryIO
- mode: int
def run_category(run: dict[str, Any]) -> str | None:
@@ -83,65 +70,14 @@ def ensure_categories(sarif: dict[str, Any]) -> bool:
return True
-def write_sarif(artifact: ScorecardSarifArtifact, sarif: dict[str, Any]) -> None:
- """Atomically replace the workspace artifact without following hard links."""
- rendered = (json.dumps(sarif, indent=2, sort_keys=True) + "\n").encode("utf-8")
- temporary_path: Path | None = None
- try:
- with tempfile.NamedTemporaryFile(
- mode="wb",
- dir=artifact.path.parent,
- prefix=f".{artifact.path.name}.",
- suffix=".tmp",
- delete=False,
- ) as temporary:
- temporary_path = Path(temporary.name)
- os.fchmod(
- temporary.fileno(),
- stat.S_IMODE(artifact.mode) | stat.S_IWUSR,
- )
- temporary.write(rendered)
- temporary.flush()
- os.fsync(temporary.fileno())
- os.replace(temporary_path, artifact.path)
- temporary_path = None
- finally:
- if temporary_path is not None:
- temporary_path.unlink(missing_ok=True)
-
-
-@contextmanager
-def scorecard_sarif_path(argument: str) -> Iterator[ScorecardSarifArtifact]:
- """Open and validate the single SARIF artifact allowed in the workspace."""
- workspace = Path.cwd().resolve(strict=True)
- expected = workspace / SCORECARD_SARIF_FILENAME
- candidate = Path(os.path.abspath(argument))
- if candidate != expected:
- raise ValueError("SARIF path must name the workspace Scorecard artifact")
-
- nofollow = getattr(os, "O_NOFOLLOW", None)
- if nofollow is None:
- raise OSError("secure no-follow file opening is unavailable")
- flags = os.O_RDONLY | nofollow | getattr(os, "O_CLOEXEC", 0)
- descriptor = os.open(expected, flags)
- try:
- opened = os.fstat(descriptor)
- named = os.stat(expected, follow_symlinks=False)
- if not stat.S_ISREG(opened.st_mode):
- raise ValueError("SARIF path must be a regular file in the workspace")
- if (opened.st_dev, opened.st_ino) != (named.st_dev, named.st_ino):
- raise ValueError("SARIF path changed while it was being opened")
- if opened.st_nlink != 1:
- raise ValueError("SARIF path must not be a hard link")
- if opened.st_size > MAX_SARIF_BYTES:
- raise ValueError("SARIF file exceeds the size limit")
-
- with os.fdopen(descriptor, "rb", closefd=True) as source:
- descriptor = -1
- yield ScorecardSarifArtifact(expected, source, opened.st_mode)
- finally:
- if descriptor >= 0:
- os.close(descriptor)
+def write_sarif(path: Path, sarif: dict[str, Any]) -> None:
+ mode = path.stat().st_mode
+ if mode & stat.S_IWUSR == 0:
+ path.chmod(mode | stat.S_IWUSR)
+ path.write_text(
+ json.dumps(sarif, indent=2, sort_keys=True) + "\n",
+ encoding="utf-8",
+ )
def main(argv: list[str]) -> int:
@@ -152,17 +88,12 @@ def main(argv: list[str]) -> int:
)
return 64
+ sarif_path = Path(argv[1])
try:
- with scorecard_sarif_path(argv[1]) as artifact:
- payload = artifact.source.read(MAX_SARIF_BYTES + 1)
- if len(payload) > MAX_SARIF_BYTES:
- raise ValueError("SARIF file exceeds the size limit")
- if os.fstat(artifact.source.fileno()).st_nlink != 1:
- raise ValueError("SARIF path became a hard link while being read")
- sarif = json.loads(payload.decode("utf-8"))
- changed = ensure_categories(sarif)
- if changed:
- write_sarif(artifact, sarif)
+ sarif = json.loads(sarif_path.read_text(encoding="utf-8"))
+ changed = ensure_categories(sarif)
+ if changed:
+ write_sarif(sarif_path, sarif)
except (OSError, json.JSONDecodeError, ValueError) as exc:
print(f"cannot normalize Scorecard SARIF: {exc}", file=sys.stderr)
return 65
diff --git a/scripts/ci/pr_governance_gate.sh b/scripts/ci/pr_governance_gate.sh
index fa19678a9..e220fbf7b 100644
--- a/scripts/ci/pr_governance_gate.sh
+++ b/scripts/ci/pr_governance_gate.sh
@@ -31,7 +31,6 @@ PR_CHECKS_ERROR_FILE="$(mktemp)"
ISSUE_COMMENTS_ERROR_FILE="$(mktemp)"
REVIEW_COMMENTS_ERROR_FILE="$(mktemp)"
OPENCODE_REVIEWS_ERROR_FILE="$(mktemp)"
-COMMIT_STATUS_ERROR_FILE="$(mktemp)"
RUN_DETAILS_URL="${GITHUB_SERVER_URL:-https://github.com}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID:-unknown}"
cleanup_temp_files() {
@@ -39,8 +38,7 @@ cleanup_temp_files() {
"$PR_CHECKS_ERROR_FILE" \
"$ISSUE_COMMENTS_ERROR_FILE" \
"$REVIEW_COMMENTS_ERROR_FILE" \
- "$OPENCODE_REVIEWS_ERROR_FILE" \
- "$COMMIT_STATUS_ERROR_FILE"
+ "$OPENCODE_REVIEWS_ERROR_FILE"
}
trap cleanup_temp_files EXIT
@@ -320,16 +318,7 @@ else
fi
CODERABBIT_BLOCKING_PATTERN='pre[- ]merge|blocking|failure|failed|warning|potential issue|actionable comment|actionable comments'
-CODERABBIT_ISSUE_BLOCKING_PATTERN='pre[- ]merge[^\n]*(blocking|failure|failed|warning|potential issue)|blocking (issue|finding)|potential issue|actionable comments?|changes requested|request changes'
-CODERABBIT_ISSUE_SUBSTANTIVE_BLOCKING_PATTERN='pre[- ]merge[^\n]*(blocking|failure|failed|warning|potential issue)|blocking (issue|finding)|potential issue|changes requested|request changes'
-CODERABBIT_NO_ACTIONABLE_PATTERN='no actionable comments? (were )?generated'
CHECK_RUNS="$(gh api "repos/${GITHUB_REPOSITORY}/commits/${HEAD_SHA}/check-runs?per_page=100")"
-COMMIT_STATUS_JSON='{"statuses":[]}'
-if ! COMMIT_STATUS_JSON="$(gh api "repos/${GITHUB_REPOSITORY}/commits/${HEAD_SHA}/status" 2>"$COMMIT_STATUS_ERROR_FILE")"; then
- printf 'commit status lookup failed:\n'
- printf '%s\n' "$(<"$COMMIT_STATUS_ERROR_FILE")" | sed 's/^/ /'
- add_blocker 'Current-head commit statuses could not be read; see the workflow run log.'
-fi
CODERABBIT_MATCHES="$(printf '%s' "$CHECK_RUNS" | jq '
[.check_runs[]
| select(
@@ -338,15 +327,7 @@ CODERABBIT_MATCHES="$(printf '%s' "$CHECK_RUNS" | jq '
or (.name | test("CodeRabbit|coderabbit|GitHub Code Quality|github-code-quality"; "i"))
)]'
)"
-CODERABBIT_STATUS_MATCHES="$(printf '%s' "$COMMIT_STATUS_JSON" | jq '
- [.statuses[]
- | select((.context // "") | test("CodeRabbit|coderabbit|GitHub Code Quality|github-code-quality"; "i"))]
- | group_by((.context // "") | ascii_downcase)
- | map(sort_by(.updated_at // .created_at // "") | last)
-')"
-CODERABBIT_CHECK_COUNT="$(printf '%s' "$CODERABBIT_MATCHES" | jq 'length')"
-CODERABBIT_STATUS_COUNT="$(printf '%s' "$CODERABBIT_STATUS_MATCHES" | jq 'length')"
-CODERABBIT_COUNT=$((CODERABBIT_CHECK_COUNT + CODERABBIT_STATUS_COUNT))
+CODERABBIT_COUNT="$(printf '%s' "$CODERABBIT_MATCHES" | jq 'length')"
if [ "$CODERABBIT_COUNT" = "0" ]; then
if ! OPENCODE_REVIEWS_JSON="$(gh api --paginate --slurp "repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}/reviews" 2>"$OPENCODE_REVIEWS_ERROR_FILE")"; then
printf 'OpenCode review lookup failed:\n'
@@ -373,7 +354,6 @@ if [ "$CODERABBIT_COUNT" = "0" ]; then
fi
else
CODERABBIT_PENDING="$(printf '%s' "$CODERABBIT_MATCHES" | jq '[.[] | select(.status != "completed")] | length')"
- CODERABBIT_STATUS_PENDING="$(printf '%s' "$CODERABBIT_STATUS_MATCHES" | jq '[.[] | select((.state // "" | ascii_downcase) == "pending")] | length')"
CODERABBIT_FAILED="$(printf '%s' "$CODERABBIT_MATCHES" | jq --arg pattern "$CODERABBIT_BLOCKING_PATTERN" '
[.[]
| select(.status == "completed")
@@ -390,20 +370,9 @@ else
end)]
| length'
)"
- CODERABBIT_STATUS_FAILED="$(printf '%s' "$CODERABBIT_STATUS_MATCHES" | jq '[.[] | select((.state // "" | ascii_downcase) as $state | $state == "error" or $state == "failure")] | length')"
- CODERABBIT_STATUS_UNKNOWN="$(printf '%s' "$CODERABBIT_STATUS_MATCHES" | jq '[.[] | select((.state // "" | ascii_downcase) as $state | ["success", "pending", "error", "failure"] | index($state) | not)] | length')"
if [ "$CODERABBIT_FAILED" != "0" ]; then
add_blocker "Current-head CodeRabbit check has a blocking conclusion on ${HEAD_REF_OID}."
- fi
- if [ "$CODERABBIT_STATUS_FAILED" != "0" ]; then
- add_blocker "Current-head CodeRabbit commit status has a blocking conclusion on ${HEAD_REF_OID}."
- fi
- if [ "$CODERABBIT_STATUS_UNKNOWN" != "0" ]; then
- add_blocker "Current-head CodeRabbit commit status has an unrecognized state on ${HEAD_REF_OID}."
- fi
- if [ "$CODERABBIT_FAILED" != "0" ] || [ "$CODERABBIT_STATUS_FAILED" != "0" ] || [ "$CODERABBIT_STATUS_UNKNOWN" != "0" ]; then
- :
- elif [ "$CODERABBIT_PENDING" != "0" ] || [ "$CODERABBIT_STATUS_PENDING" != "0" ]; then
+ elif [ "$CODERABBIT_PENDING" != "0" ]; then
add_waiting "Waiting for current-head CodeRabbit evidence on ${HEAD_REF_OID}."
fi
fi
@@ -413,22 +382,10 @@ if ! ISSUE_COMMENTS_JSON="$(gh api --paginate "repos/${GITHUB_REPOSITORY}/issues
printf '%s\n' "$(<"$ISSUE_COMMENTS_ERROR_FILE")" | sed 's/^/ /'
add_blocker 'PR issue comments could not be read; see the workflow run log.'
else
- CODERABBIT_ISSUE_BLOCKERS="$(printf '%s' "$ISSUE_COMMENTS_JSON" | jq -s \
- --arg head_sha "$HEAD_SHA" \
- --arg pattern "$CODERABBIT_ISSUE_BLOCKING_PATTERN" \
- --arg substantive_pattern "$CODERABBIT_ISSUE_SUBSTANTIVE_BLOCKING_PATTERN" \
- --arg no_actionable_pattern "$CODERABBIT_NO_ACTIONABLE_PATTERN" '
+ CODERABBIT_ISSUE_BLOCKERS="$(printf '%s' "$ISSUE_COMMENTS_JSON" | jq -s --arg head_sha "$HEAD_SHA" --arg pattern "$CODERABBIT_BLOCKING_PATTERN" '
[.[][]
| select((.user.login // "") | test("'"$REVIEW_BOT_LOGIN_PATTERN"'"; "i"))
- | select(
- (.body // "") as $body
- | ($body | split("")[0]) as $summary
- | ($body | test($pattern; "i"))
- and (
- (($body | test($no_actionable_pattern; "i")) | not)
- or ($summary | test($substantive_pattern; "i"))
- )
- )
+ | select((.body // "") | test($pattern; "i"))
| select((.body // "") | contains($head_sha))]
| length'
)"
diff --git a/scripts/ci/test_pr_governance_gate.sh b/scripts/ci/test_pr_governance_gate.sh
index ce15243b3..b56f99043 100644
--- a/scripts/ci/test_pr_governance_gate.sh
+++ b/scripts/ci/test_pr_governance_gate.sh
@@ -66,7 +66,7 @@ if [ "$1" = "api" ] && [[ "$2" == repos/*/pulls/42 ]]; then
exit 0
fi
-if [ "$1" = "api" ] && [[ "$2" == repos/*/commits/* ]] && [[ "$2" != */check-runs* ]] && [[ "$2" != */status ]]; then
+if [ "$1" = "api" ] && [[ "$2" == repos/*/commits/* ]] && [[ "$2" != */check-runs* ]]; then
printf '2026-05-19T00:00:00Z'
exit 0
fi
@@ -124,7 +124,7 @@ if [ "$1" = "api" ] && [[ "$2" == repos/*/commits/*/check-runs* ]]; then
coderabbit_pending)
printf '{"check_runs":[{"name":"CodeRabbit","app":{"slug":"coderabbitai"},"status":"in_progress","conclusion":null,"html_url":"https://checks/coderabbit"}]}'
;;
- missing_coderabbit|missing_coderabbit_with_adversarial_approval|missing_coderabbit_stale_approval|missing_coderabbit_actions_approval|missing_coderabbit_one_probe|opencode_reviews_error|coderabbit_status_success|coderabbit_status_pending|coderabbit_status_failed|coderabbit_status_unknown)
+ missing_coderabbit|missing_coderabbit_with_adversarial_approval|missing_coderabbit_stale_approval|missing_coderabbit_actions_approval|missing_coderabbit_one_probe|opencode_reviews_error)
printf '{"check_runs":[]}'
;;
coderabbit_failed)
@@ -149,27 +149,6 @@ if [ "$1" = "api" ] && [[ "$2" == repos/*/commits/*/check-runs* ]]; then
exit 0
fi
-if [ "$1" = "api" ] && [[ "$2" == repos/*/commits/*/status ]]; then
- case "${GH_SCENARIO:-pass}" in
- coderabbit_status_success)
- printf '{"statuses":[{"context":"CodeRabbit","state":"success","description":"Review approved","created_at":"2026-07-29T01:54:41Z","updated_at":"2026-07-29T01:54:41Z"}]}'
- ;;
- coderabbit_status_pending)
- printf '{"statuses":[{"context":"CodeRabbit","state":"pending","description":"Review in progress","created_at":"2026-07-29T01:54:41Z","updated_at":"2026-07-29T01:54:41Z"}]}'
- ;;
- coderabbit_status_failed)
- printf '{"statuses":[{"context":"CodeRabbit","state":"failure","description":"Review failed","created_at":"2026-07-29T01:54:41Z","updated_at":"2026-07-29T01:54:41Z"}]}'
- ;;
- coderabbit_status_unknown)
- printf '{"statuses":[{"context":"CodeRabbit","state":"stale","description":"Unrecognized state","created_at":"2026-07-29T01:54:41Z","updated_at":"2026-07-29T01:54:41Z"}]}'
- ;;
- *)
- printf '{"statuses":[]}'
- ;;
- esac
- exit 0
-fi
-
if [ "$1" = "api" ] && [[ "$args" == *repos/*/pulls/42/reviews* ]]; then
if [ "${GH_SCENARIO:-pass}" = "opencode_reviews_error" ]; then
printf 'Error: review lookup failed: https://api.example/reviews?token=SECRETVALUE\n' >&2
@@ -224,15 +203,6 @@ if [ "$1" = "api" ] && [[ "$args" == *repos/*/issues/42/comments* ]]; then
coderabbit_stale_blocking_comment)
printf '[{"id":777,"user":{"login":"coderabbitai[bot]"},"created_at":"2026-05-19T00:01:00Z","body":"Pre-merge warning for older head"}]'
;;
- coderabbit_review_limit_comment)
- printf '[{"id":777,"user":{"login":"coderabbitai[bot]"},"created_at":"2026-05-19T00:01:00Z","body":"Review limit reached. This is an operational warning for 0123456789abcdef0123456789abcdef01234567; retry later."}]'
- ;;
- coderabbit_no_actionable_summary)
- printf '[{"id":777,"user":{"login":"coderabbitai[bot]"},"created_at":"2026-05-19T00:01:00Z","body":"No actionable comments were generated in the recent review. Reviewing files between base and 0123456789abcdef0123456789abcdef01234567.\\n\\nWalkthrough
The gate distinguishes non-blocking summaries from substantive blocking language and potential issues. "}]'
- ;;
- coderabbit_no_actionable_with_blocker)
- printf '[{"id":777,"user":{"login":"coderabbitai[bot]"},"created_at":"2026-05-19T00:01:00Z","body":"No actionable comments were generated in the recent review. Blocking issue remains on 0123456789abcdef0123456789abcdef01234567."}]'
- ;;
github_code_quality_blocking_comment)
printf '[{"id":777,"user":{"login":"github-code-quality[bot]"},"created_at":"2026-05-19T00:01:00Z","body":"Potential issue for 0123456789abcdef0123456789abcdef01234567"}]'
;;
@@ -470,47 +440,6 @@ assert_coderabbit_pending_waits_without_hard_comment() {
assert_not_in_file '^pr merge' "$temp_dir/gh.log"
}
-assert_coderabbit_success_commit_status_completes_gate() {
- local temp_dir
- temp_dir="$(mktemp -d)"
- run_gate coderabbit_status_success "$temp_dir"
-
- assert_exit_code 0 "$temp_dir"
- assert_in_file 'PR governance metadata gate is ready' "$temp_dir/output.txt"
- assert_in_file 'status=completed -f conclusion=success' "$temp_dir/gh.log"
- assert_not_in_file 'Waiting for current-head CodeRabbit evidence' "$temp_dir/output.txt"
-}
-
-assert_coderabbit_pending_commit_status_waits() {
- local temp_dir
- temp_dir="$(mktemp -d)"
- run_gate coderabbit_status_pending "$temp_dir"
-
- assert_exit_code 0 "$temp_dir"
- assert_in_file 'Waiting for current-head CodeRabbit evidence' "$temp_dir/output.txt"
- assert_in_file 'status=in_progress' "$temp_dir/gh.log"
-}
-
-assert_coderabbit_failed_commit_status_blocks() {
- local temp_dir
- temp_dir="$(mktemp -d)"
- run_gate coderabbit_status_failed "$temp_dir"
-
- assert_exit_code 0 "$temp_dir"
- assert_in_file 'CodeRabbit commit status has a blocking conclusion' "$temp_dir/output.txt"
- assert_in_file 'status=completed -f conclusion=failure' "$temp_dir/gh.log"
-}
-
-assert_coderabbit_unknown_commit_status_fails_closed() {
- local temp_dir
- temp_dir="$(mktemp -d)"
- run_gate coderabbit_status_unknown "$temp_dir"
-
- assert_exit_code 0 "$temp_dir"
- assert_in_file 'CodeRabbit commit status has an unrecognized state' "$temp_dir/output.txt"
- assert_in_file 'status=completed -f conclusion=failure' "$temp_dir/gh.log"
-}
-
assert_missing_coderabbit_waits_for_adversarial_opencode_approval() {
local temp_dir
temp_dir="$(mktemp -d)"
@@ -709,44 +638,10 @@ assert_coderabbit_stale_issue_comment_does_not_block() {
assert_exit_code 0 "$temp_dir"
assert_in_file 'PR governance metadata gate is ready' "$temp_dir/output.txt"
- assert_not_in_file 'Current-head CodeRabbit issue comment' "$temp_dir/output.txt"
- assert_not_in_file '^pr merge' "$temp_dir/gh.log"
-}
-
-assert_coderabbit_review_limit_issue_comment_does_not_block() {
- local temp_dir
- temp_dir="$(mktemp -d)"
- run_gate coderabbit_review_limit_comment "$temp_dir"
-
- assert_exit_code 0 "$temp_dir"
- assert_in_file 'PR governance metadata gate is ready' "$temp_dir/output.txt"
- assert_not_in_file 'Current-head CodeRabbit issue comment' "$temp_dir/output.txt"
assert_not_in_file 'Current-head CodeRabbit issue comment' "$temp_dir/gh.log"
assert_not_in_file '^pr merge' "$temp_dir/gh.log"
}
-assert_coderabbit_no_actionable_summary_does_not_block() {
- local temp_dir
- temp_dir="$(mktemp -d)"
- run_gate coderabbit_no_actionable_summary "$temp_dir"
-
- assert_exit_code 0 "$temp_dir"
- assert_in_file 'PR governance metadata gate is ready' "$temp_dir/output.txt"
- assert_not_in_file 'Current-head CodeRabbit issue comment' "$temp_dir/output.txt"
- assert_not_in_file 'Current-head CodeRabbit issue comment' "$temp_dir/gh.log"
- assert_not_in_file '^pr merge' "$temp_dir/gh.log"
-}
-
-assert_coderabbit_no_actionable_summary_with_blocker_still_blocks() {
- local temp_dir
- temp_dir="$(mktemp -d)"
- run_gate coderabbit_no_actionable_with_blocker "$temp_dir"
-
- assert_exit_code 0 "$temp_dir"
- assert_in_file 'Current-head CodeRabbit issue comment has blocking warning/failure evidence' "$temp_dir/gh.log"
- assert_not_in_file '^pr merge' "$temp_dir/gh.log"
-}
-
assert_coderabbit_current_review_comment_blocks() {
local temp_dir
temp_dir="$(mktemp -d)"
@@ -876,10 +771,6 @@ assert_failed_checks_create_marker_comment
assert_existing_marker_comment_is_patched
assert_resolved_marker_comment_is_updated_on_ready_gate
assert_coderabbit_pending_waits_without_hard_comment
-assert_coderabbit_success_commit_status_completes_gate
-assert_coderabbit_pending_commit_status_waits
-assert_coderabbit_failed_commit_status_blocks
-assert_coderabbit_unknown_commit_status_fails_closed
assert_missing_coderabbit_waits_for_adversarial_opencode_approval
assert_missing_coderabbit_accepts_exact_head_adversarial_opencode_approval
assert_missing_coderabbit_rejects_non_authoritative_opencode_evidence
@@ -896,9 +787,6 @@ assert_evaluation_error_publishes_gate_failure
assert_coderabbit_blocking_issue_comment_blocks
assert_github_code_quality_blocking_issue_comment_blocks
assert_coderabbit_stale_issue_comment_does_not_block
-assert_coderabbit_review_limit_issue_comment_does_not_block
-assert_coderabbit_no_actionable_summary_does_not_block
-assert_coderabbit_no_actionable_summary_with_blocker_still_blocks
assert_coderabbit_current_review_comment_blocks
assert_github_code_quality_current_review_comment_blocks
assert_coderabbit_stale_review_comment_does_not_block