diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md
index 67bcc0a..9896c7a 100644
--- a/.github/CONTRIBUTING.md
+++ b/.github/CONTRIBUTING.md
@@ -84,7 +84,7 @@ By participating in this project, you agree to abide by our Code of Conduct.
- Run mypy to check static typing:
```bash
.venv/bin/python -m mypy \
- crypto_config.py crypto_core.py pqc_agent_tools.py pqc_app.py \
+ api_app.py crypto_config.py crypto_core.py pqc_agent_tools.py ui_helpers.py \
tests/test_agent_tools.py tests/test_crypto_core.py
```
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 5eb54a4..83539df 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -32,13 +32,13 @@ jobs:
run: python -m pip install -r requirements-dev.txt
- name: Check formatting
- run: python -m black --check api_app.py crypto_config.py crypto_core.py pqc_agent_tools.py pqc_app.py ui_helpers.py setup.py tests/test_agent_tools.py tests/test_api_app.py tests/test_crypto_core.py tests/test_ui_helpers.py
+ run: python -m black --check api_app.py crypto_config.py crypto_core.py pqc_agent_tools.py ui_helpers.py setup.py tests/test_agent_tools.py tests/test_api_app.py tests/test_crypto_core.py tests/test_ui_helpers.py
- name: Lint
- run: python -m flake8 api_app.py crypto_config.py crypto_core.py pqc_agent_tools.py pqc_app.py ui_helpers.py setup.py tests/test_agent_tools.py tests/test_api_app.py tests/test_crypto_core.py tests/test_ui_helpers.py
+ run: python -m flake8 api_app.py crypto_config.py crypto_core.py pqc_agent_tools.py ui_helpers.py setup.py tests/test_agent_tools.py tests/test_api_app.py tests/test_crypto_core.py tests/test_ui_helpers.py
- name: Type check
- run: python -m mypy api_app.py crypto_config.py crypto_core.py pqc_agent_tools.py pqc_app.py ui_helpers.py tests/test_agent_tools.py tests/test_api_app.py tests/test_crypto_core.py tests/test_ui_helpers.py
+ run: python -m mypy api_app.py crypto_config.py crypto_core.py pqc_agent_tools.py ui_helpers.py tests/test_agent_tools.py tests/test_api_app.py tests/test_crypto_core.py tests/test_ui_helpers.py
- name: Unit tests without native liboqs
run: >
@@ -93,15 +93,21 @@ jobs:
/tmp/qe-wheel-test/bin/quantum-encryptor-agent health --json
/tmp/qe-wheel-test/bin/python -I - <<'PY'
from pathlib import Path
+ from importlib.metadata import requires
+ from importlib.util import find_spec
import sys
import api_app
import pqc_agent_tools
+ import ui_helpers
prefix = Path(sys.prefix).resolve()
- for module in (api_app, pqc_agent_tools):
+ for module in (api_app, pqc_agent_tools, ui_helpers):
module_path = Path(module.__file__).resolve()
assert module_path.is_relative_to(prefix), (module.__name__, module_path, prefix)
+ assert find_spec("pqc_app") is None
+ package_requirements = requires("quantum-encryptor") or ()
+ assert all(not requirement.lower().startswith("streamlit") for requirement in package_requirements)
PY
- name: Smoke installed web UI
@@ -155,7 +161,7 @@ jobs:
run: npm audit --package-lock-only --audit-level=high
- name: Run Python security lint
- run: python -m bandit -q -r api_app.py crypto_core.py pqc_agent_tools.py pqc_app.py ui_helpers.py
+ run: python -m bandit -q -r api_app.py crypto_core.py pqc_agent_tools.py ui_helpers.py
web:
name: Custom web UI
@@ -262,9 +268,9 @@ jobs:
run: |
git init .ci/liboqs
git -C .ci/liboqs remote add origin https://github.com/open-quantum-safe/liboqs.git
- # liboqs 0.15.0
- git -C .ci/liboqs fetch --depth=1 origin 97f6b86b1b6d109cfd43cf276ae39c2e776aed80
- git -C .ci/liboqs checkout --detach 97f6b86b1b6d109cfd43cf276ae39c2e776aed80
+ # liboqs 0.16.0
+ git -C .ci/liboqs fetch --depth=1 origin 5a1a854b0dc9f2141bdc771c555ee60c37950183
+ git -C .ci/liboqs checkout --detach 5a1a854b0dc9f2141bdc771c555ee60c37950183
cmake -S .ci/liboqs -B .ci/liboqs/build \
-GNinja \
-DBUILD_SHARED_LIBS=ON \
@@ -273,7 +279,22 @@ jobs:
cmake --install .ci/liboqs/build
- name: Install Python dependencies
- run: python -m pip install -r requirements-dev.txt
+ run: python -m pip install --require-hashes -r requirements-dev-lock.txt
+
+ - name: Set up Node
+ uses: actions/setup-node@v7
+ with:
+ node-version: "22"
+ cache: npm
+
+ - name: Install frontend dependencies
+ run: npm ci
+
+ - name: Build frontend
+ run: npm run build
+
+ - name: Install Playwright Chromium
+ run: npx playwright install --with-deps chromium
- name: Verify native backend is active
run: |
@@ -314,3 +335,40 @@ jobs:
--private-key agent-private.pem \
--output agent-output.txt
cmp agent-input.txt agent-output.txt
+
+ - name: Native browser encryption round trip
+ run: |
+ SKIP_WEB_BUILD=1 PYTHON=python ./start.sh &
+ server_pid=$!
+ trap 'kill "$server_pid" 2>/dev/null || true; wait "$server_pid" 2>/dev/null || true' EXIT
+ python - <<'PY'
+ import json
+ import time
+ from urllib.request import urlopen
+
+ required = ("generate", "encrypt", "decrypt")
+ deadline = time.monotonic() + 30
+
+ while (remaining := deadline - time.monotonic()) > 0:
+ try:
+ with urlopen("http://127.0.0.1:4000/api/health", timeout=min(1, remaining)) as response:
+ health = json.load(response)
+ except Exception:
+ health = None
+
+ ready = bool(
+ health
+ and health.get("ok")
+ and health.get("backendReady")
+ and all(health.get("capabilities", {}).get(name, {}).get("available") for name in required)
+ )
+ if ready:
+ raise SystemExit(0)
+
+ remaining = deadline - time.monotonic()
+ if remaining > 0:
+ time.sleep(min(0.25, remaining))
+
+ raise SystemExit("Native local app did not become ready within 30 seconds.")
+ PY
+ npm run ui-native
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 815f3c2..3bc944b 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -44,7 +44,7 @@ jobs:
run: python -m pip_audit -r requirements-dev-lock.txt
- name: Run Python security lint
- run: python -m bandit -q -r crypto_core.py pqc_agent_tools.py pqc_app.py ui_helpers.py api_app.py
+ run: python -m bandit -q -r crypto_core.py pqc_agent_tools.py ui_helpers.py api_app.py
- name: Install frontend dependencies
run: npm ci
@@ -82,15 +82,21 @@ jobs:
/tmp/qe-release-test/bin/quantum-encryptor-agent health --json
/tmp/qe-release-test/bin/python -I - <<'PY'
from pathlib import Path
+ from importlib.metadata import requires
+ from importlib.util import find_spec
import sys
import api_app
import pqc_agent_tools
+ import ui_helpers
prefix = Path(sys.prefix).resolve()
- for module in (api_app, pqc_agent_tools):
+ for module in (api_app, pqc_agent_tools, ui_helpers):
module_path = Path(module.__file__).resolve()
assert module_path.is_relative_to(prefix), (module.__name__, module_path, prefix)
+ assert find_spec("pqc_app") is None
+ package_requirements = requires("quantum-encryptor") or ()
+ assert all(not requirement.lower().startswith("streamlit") for requirement in package_requirements)
PY
- name: Smoke installed web UI
@@ -177,9 +183,9 @@ jobs:
run: |
git init .ci/liboqs
git -C .ci/liboqs remote add origin https://github.com/open-quantum-safe/liboqs.git
- # liboqs 0.15.0
- git -C .ci/liboqs fetch --depth=1 origin 97f6b86b1b6d109cfd43cf276ae39c2e776aed80
- git -C .ci/liboqs checkout --detach 97f6b86b1b6d109cfd43cf276ae39c2e776aed80
+ # liboqs 0.16.0
+ git -C .ci/liboqs fetch --depth=1 origin 5a1a854b0dc9f2141bdc771c555ee60c37950183
+ git -C .ci/liboqs checkout --detach 5a1a854b0dc9f2141bdc771c555ee60c37950183
cmake -S .ci/liboqs -B .ci/liboqs/build \
-GNinja \
-DBUILD_SHARED_LIBS=ON \
@@ -188,7 +194,7 @@ jobs:
cmake --install .ci/liboqs/build
- name: Install Python dependencies
- run: python -m pip install -r requirements-dev.txt
+ run: python -m pip install --require-hashes -r requirements-dev-lock.txt
- name: Verify migration backends are active
run: |
diff --git a/.gitignore b/.gitignore
index 8cb241b..aba43b7 100644
--- a/.gitignore
+++ b/.gitignore
@@ -108,11 +108,6 @@ ENV/
env.bak/
venv.bak/
-# Streamlit local state and secrets. Keep committed UI theme config.
-.streamlit/*
-!.streamlit/
-!.streamlit/config.toml
-
# Spyder project settings
.spyderproject
.spyproject
diff --git a/.streamlit/config.toml b/.streamlit/config.toml
deleted file mode 100644
index d76f1b1..0000000
--- a/.streamlit/config.toml
+++ /dev/null
@@ -1,7 +0,0 @@
-[theme]
-base = "dark"
-primaryColor = "#2DD4BF"
-backgroundColor = "#000000"
-secondaryBackgroundColor = "#111827"
-textColor = "#FFFFFF"
-font = "sans serif"
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 349bea6..efd4998 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -10,6 +10,17 @@ This project follows a practical semantic-versioning style.
- ML-KEM-768 + X25519 composite key generation and format-v4 encrypted containers.
- SHA3-256 hybrid key combiner binding both key shares, X25519 context, suite identifier, and application domain.
+- Polished monochrome local web workflows with progressive technical details,
+ responsive navigation, component tests, accessibility checks, and a native
+ browser encryption round trip.
+
+### Changed
+
+- Retired the Streamlit application, temporary startup fallback, packaging
+ surface, and superseded reference screenshots. `./start.sh` now serves the
+ React/Vite interface through the loopback-only Python API as the sole GUI.
+- Preserved decrypt-only compatibility for authenticated earlier containers and
+ private-key formats, including the bounded ML-KEM/Kyber migration path.
### Security
diff --git a/README.md b/README.md
index 127b1a6..947f795 100644
--- a/README.md
+++ b/README.md
@@ -10,14 +10,14 @@ A post-quantum cryptography tool for file encryption. New files combine ML-KEM-7
- Dark local web interface for ML-KEM-768 + X25519 key generation, file encryption, decryption, and PEM key inspection.
+ Monochrome local web interface for ML-KEM-768 + X25519 key generation, file encryption, decryption, and PEM key inspection.
## Features
@@ -25,12 +25,12 @@ A post-quantum cryptography tool for file encryption. New files combine ML-KEM-7
- **Post-Quantum/Traditional Security**: Combines ML-KEM-768 with X25519 so confidentiality does not depend on one key-establishment algorithm
- **Authenticated File Encryption**: Derives AES-256-GCM keys from both ML-KEM and X25519 shared secrets
- **Password-Protected Keys**: Private keys are always encrypted with scrypt-derived AES-256-GCM keys
-- **User-Friendly Interface**: Custom local web UI with a Python ASGI API
+- **User-Friendly Interface**: Custom local web UI with progressive technical details and a Python ASGI API
- **PEM Key Format**: Keys stored in PEM-like format with quantum algorithm extensions
## Screenshots
-The backend readiness warning shown here is expected when native `liboqs` is not installed in the local environment. Click any image to open the full screenshot page.
+The current browser smoke captures show the responsive Encrypt and Inspect key workflows. Click either image to open the full screenshot page.
@@ -111,21 +111,22 @@ See [docs/SCREENSHOTS.md](docs/SCREENSHOTS.md) for the dedicated screenshot page
PYTHON=.venv/bin/python ./test.sh
```
- To run the legacy Streamlit UI during transition:
- ```bash
- LEGACY_STREAMLIT=1 ./start.sh
- ```
-
Frontend development can run Vite separately on `127.0.0.1:4001`:
```bash
npm run dev
```
-2. Open the web interface in your browser. You can:
- - Generate a new post-quantum key pair
- - Encrypt files using a recipient's public key
- - Decrypt files using your private key
- - Access key utilities
+2. Open the web interface in your browser. Choose the intent that matches your task:
+ - **Encrypt**: protect a file for the holder of a recipient public key.
+ - **Decrypt**: recover a file with the matching encrypted private key and password.
+ - **Generate keys**: create a new public key and password-protected private key.
+ - **Inspect key**: check supported key metadata without exposing key material.
+
+ Each workflow starts with plain-language guidance. Expand **Technical details** only when you need suite, format, or key-policy information.
+
+### Local-only interface privacy
+
+The custom interface processes selected files through the local Python service at `127.0.0.1`. It does not use browser persistence, telemetry, remote fonts, or remote application assets. The UI does not display plaintext previews, passwords, private-key content, or the local API token.
## Verification
@@ -138,8 +139,9 @@ Run the Python test suite:
Run the custom frontend checks:
```bash
-npm run build
+npm run test:unit
npm run check
+npm run build
```
With the app already running on `127.0.0.1:4000`, run the browser smoke test:
@@ -150,16 +152,24 @@ npm run ui-smoke
The UI smoke test writes ignored screenshots under `tmp/ui-smoke/`.
+When a native `liboqs` installation is available to the running app, also run the real browser encryption round trip:
+
+```bash
+npm run ui-native
+```
+
+Do not treat the browser smoke test as proof that the native cryptographic backend is installed; it verifies the built interface against the local API contract. `npm run ui-native` verifies key generation, encryption, and decryption through the available native backend.
+
### Key Generation
-1. Select "Generate Keys" from the sidebar
+1. Select "Generate keys" from the workflow navigation
2. Enter and confirm a strong private-key password
3. Generate the keys and download both public and private key files
4. Share your public key with others who want to send you encrypted files
### File Encryption
-1. Select "Encrypt File" from the sidebar
+1. Select "Encrypt" from the workflow navigation
2. Upload the file you want to encrypt
3. Upload the recipient's public key (.pem file)
4. Specify the output filename
@@ -167,15 +177,15 @@ The UI smoke test writes ignored screenshots under `tmp/ui-smoke/`.
### File Decryption
-1. Select "Decrypt File" from the sidebar
+1. Select "Decrypt" from the workflow navigation
2. Upload the encrypted file (.pqc file)
3. Upload your private key (.pem file)
4. Enter your private-key password
5. Download the decrypted file
-## Agent Usage
+## Automation Usage
-Local automation agents can use the deterministic JSON CLI instead of driving the Streamlit UI. Run commands from the repository workspace and pass only workspace-relative paths. Absolute paths, `..` traversal, symlink escapes, and accidental output overwrites are rejected.
+Automation tools can use the deterministic JSON CLI instead of driving the browser interface. Run commands from the repository workspace and pass only workspace-relative paths. Absolute paths, `..` traversal, symlink escapes, and accidental output overwrites are rejected.
```bash
mkdir -p keys data
@@ -229,7 +239,7 @@ The CLI prints JSON only and never includes plaintext, private keys, passwords,
- State-changing local web API requests require a per-process API token and reject non-local browser origins when an `Origin` header is present
- The local agent CLI accepts only workspace-relative paths, returns machine-readable JSON without secret material, and writes private keys plus decrypted outputs with owner-only permissions on POSIX systems; non-overwrite output creation uses exclusive file creation
- Native `liboqs` is loaded lazily and missing backend support disables key generation/encryption instead of crashing the app
-- CI runs Python formatting, linting, type checks, unit tests, custom web UI build/type checks, API client tests, browser UI smoke, isolated installed-wheel checks, Python/npm dependency audits, locked runtime install, and a native `liboqs` integration test job pinned to the matching 0.15.0 release commit; repository CodeQL default setup provides static analysis
+- CI runs Python formatting, linting, type checks, unit tests, custom web UI build/type checks, API client tests, browser UI smoke, isolated installed-wheel checks, Python/npm dependency audits, locked runtime install, and a native `liboqs` integration test job pinned to the matching 0.16.0 release commit; repository CodeQL default setup provides static analysis
- See [docs/THREAT_MODEL.md](docs/THREAT_MODEL.md) for repository trust boundaries, assets, abuse cases, and invariants
- **Disclaimer**: This software has not undergone an independent security audit and should be reviewed before production use
@@ -238,8 +248,7 @@ The CLI prints JSON only and never includes plaintext, private keys, passwords,
- `crypto_config.py` - Configuration parameters for cryptographic operations
- `crypto_core.py` - Core cryptographic functions (key generation, encryption, decryption)
- `api_app.py` - Local ASGI API and static web UI server
-- `pqc_agent_tools.py` - Local JSON CLI for agentic workflows
-- `pqc_app.py` - Legacy Streamlit web application interface
+- `pqc_agent_tools.py` - Local JSON CLI for automation workflows
- `web/` - React frontend source for the custom UI
- `package.json` / `vite.config.ts` - Frontend build configuration
- `ui_helpers.py` - UI-safe filename helpers
diff --git a/api_app.py b/api_app.py
index 8d9826c..465d1d4 100644
--- a/api_app.py
+++ b/api_app.py
@@ -299,23 +299,53 @@ async def _read_upload_text(upload: UploadFile, max_bytes: int, label: str) -> s
raise ApiError(400, "invalid_text", f"{label} must be a UTF-8 text file.") from exc
+def _capability(available: bool, reason: str = "") -> dict[str, bool | str]:
+ return {"available": available, "reason": "" if available else reason}
+
+
def _health_payload() -> dict[str, Any]:
try:
active_kem_component = core.resolve_kem_algorithm(cfg.KEM_ALG)
- backend_ready = True
- backend_message = "Post-quantum backend ready."
+ current_backend_ready = True
except Exception as exc:
active_kem_component = cfg.KEM_ALG
- backend_ready = False
- backend_message = (
+ current_backend_ready = False
+ logger.warning("Post-quantum backend readiness check failed: %s", exc)
+
+ try:
+ decrypt_ready = bool(core.available_decryption_kem_algorithms())
+ except Exception as exc:
+ decrypt_ready = False
+ logger.warning("Decryption backend readiness check failed: %s", exc)
+
+ capabilities = {
+ "inspect": _capability(True),
+ "generate": _capability(
+ current_backend_ready,
+ "ML-KEM-768 is unavailable for new key generation.",
+ ),
+ "encrypt": _capability(
+ current_backend_ready,
+ "ML-KEM-768 is unavailable for new encryption.",
+ ),
+ "decrypt": _capability(
+ decrypt_ready,
+ "No supported post-quantum decryption backend is available.",
+ ),
+ }
+ backend_message = (
+ "Post-quantum backend ready."
+ if current_backend_ready
+ else (
"The ML-KEM backend is not ready, so new keys and ciphertexts cannot be created. "
- "Compatible legacy archives may still be decryptable."
+ "Compatible encrypted archives may still be decryptable."
)
- logger.warning("Post-quantum backend readiness check failed: %s", exc)
+ )
return {
- "backendReady": backend_ready,
+ "backendReady": current_backend_ready,
"backendMessage": backend_message,
+ "capabilities": capabilities,
"formatVersion": cfg.FORMAT_VERSION,
"kem": cfg.HYBRID_KEM_ALG,
"kemComponent": active_kem_component,
diff --git a/crypto_core.py b/crypto_core.py
index 737dcb9..e516a78 100644
--- a/crypto_core.py
+++ b/crypto_core.py
@@ -220,6 +220,12 @@ def resolve_kem_algorithm(kem_alg: Optional[str] = None) -> str:
)
+def available_decryption_kem_algorithms() -> Tuple[str, ...]:
+ """Return enabled, application-supported KEM identities in preference order."""
+ enabled = set(_enabled_kem_mechanisms())
+ return tuple(candidate for candidate in (cfg.KEM_ALG, *cfg.LEGACY_KEM_ALGS) if candidate in enabled)
+
+
def resolve_decryption_kem_algorithms(suite: str) -> Tuple[str, ...]:
"""Resolve exact KEM identities allowed to decrypt a key/container suite."""
if suite == cfg.HYBRID_KEM_ALG:
@@ -229,8 +235,7 @@ def resolve_decryption_kem_algorithms(suite: str) -> Tuple[str, ...]:
if suite != cfg.LEGACY_HYBRID_KEM_ALG:
raise UnsupportedAlgorithmError(f"Unsupported key or container suite: {suite!r}")
- enabled = set(_enabled_kem_mechanisms())
- candidates = tuple(candidate for candidate in (cfg.KEM_ALG, *cfg.LEGACY_KEM_ALGS) if candidate in enabled)
+ candidates = available_decryption_kem_algorithms()
if candidates:
return candidates
raise CryptoDependencyError(
diff --git a/docs/API.md b/docs/API.md
index b6c27f9..0ea8946 100644
--- a/docs/API.md
+++ b/docs/API.md
@@ -39,6 +39,23 @@ The custom web UI is served by `api_app.py` on `127.0.0.1` by default. `GET /api
When a browser sends an `Origin` header on a state-changing API request, the server only accepts local origins beginning with `http://127.0.0.1:` or `http://localhost:`. Requests without the local API token are rejected before route handlers parse uploaded files or form data.
+### Health capability contract
+
+`GET /api/health` includes additive, operation-specific readiness information. New clients should use these capabilities to decide whether to enable each workflow:
+
+```
+{
+ "capabilities": {
+ "inspect": { "available": true, "reason": "" },
+ "generate": { "available": true, "reason": "" },
+ "encrypt": { "available": true, "reason": "" },
+ "decrypt": { "available": true, "reason": "" }
+ }
+}
+```
+
+`backendReady` and `backendMessage` remain in the response for compatibility, while new clients use operation-specific capabilities. A capability `reason` is a safe user-facing summary of an unavailable operation; it is not a raw backend exception. The health response is marked `Cache-Control: no-store` and `Pragma: no-cache` because it includes the per-process local API token.
+
### Agent JSON Contract
Successful command output:
diff --git a/docs/RELEASE_CHECKLIST.md b/docs/RELEASE_CHECKLIST.md
index 9c96c51..f5283a1 100644
--- a/docs/RELEASE_CHECKLIST.md
+++ b/docs/RELEASE_CHECKLIST.md
@@ -26,8 +26,8 @@ Use this checklist before publishing a new release.
python -m pip install -r requirements-dev.txt
npm ci
python -m black --check .
-python -m flake8 api_app.py crypto_config.py crypto_core.py pqc_agent_tools.py pqc_app.py ui_helpers.py setup.py tests
-python -m mypy api_app.py crypto_config.py crypto_core.py pqc_agent_tools.py pqc_app.py ui_helpers.py tests
+python -m flake8 api_app.py crypto_config.py crypto_core.py pqc_agent_tools.py ui_helpers.py setup.py tests
+python -m mypy api_app.py crypto_config.py crypto_core.py pqc_agent_tools.py ui_helpers.py tests
./test.sh --cov=crypto_core --cov=pqc_agent_tools --cov=ui_helpers --cov=api_app --cov-report=term-missing --cov-fail-under=80
npm run build
npm run check
diff --git a/docs/SCREENSHOTS.md b/docs/SCREENSHOTS.md
index 29f7949..7acda34 100644
--- a/docs/SCREENSHOTS.md
+++ b/docs/SCREENSHOTS.md
@@ -1,31 +1,11 @@
# Application Screenshots
-These screenshots show the custom local web UI. They were captured from the local app with native `liboqs` unavailable, so the backend readiness warning and disabled key generation or file processing controls are expected.
+These screenshots show the current monochrome custom local web UI. The interface uses progressive disclosure: plain-language task guidance appears first, while suite, format, and key-policy information stays inside **Technical details** until it is needed. The desktop Encrypt capture shows format version 4 and the `ML-KEM-768+X25519-v2` hybrid suite; the mobile Inspect key capture shows responsive workflow navigation and public-key metadata only.
## Custom Web Encrypt Workflow
-
+
## Custom Web Mobile Inspect

-
-## Legacy Streamlit Reference
-
-The default `./start.sh` path serves the custom local web UI. These older screenshots remain as a reference for the legacy Streamlit interface available through `LEGACY_STREAMLIT=1 ./start.sh`.
-
-### Generate Keys
-
-
-
-### Encrypt File
-
-
-
-### Decrypt File
-
-
-
-### Key Utilities
-
-
diff --git a/docs/SECURITY.md b/docs/SECURITY.md
index 08941fd..2c5a62b 100644
--- a/docs/SECURITY.md
+++ b/docs/SECURITY.md
@@ -76,7 +76,7 @@ The application is designed to protect against the following threats:
- Legacy hybrid public keys are rejected for encryption and must be regenerated under the current suite.
- The UI and core encryption path enforce a 100 MiB plaintext in-memory file limit; decryption accepts only the bounded encrypted-container size needed for header, KEM ciphertext, nonce, and tag overhead.
- The local web API requires a per-process `X-Quantum-Encryptor-Token` for state-changing `/api/*` requests and rejects non-local browser origins when an `Origin` header is present.
-- Download filenames are reduced to local filenames before being passed to Streamlit.
+- Download filenames are reduced to local filenames before the API returns them to the browser.
- Private-key password protection requires at least 16 characters, at least 5 unique characters, and rejects known weak values in the core, UI, and agent CLI.
- Unencrypted private keys are rejected in the core, UI, and agent CLI.
- The core module defines its own logger but leaves root logging configuration to application entry points.
diff --git a/docs/THREAT_MODEL.md b/docs/THREAT_MODEL.md
index e0825b8..c70c3ab 100644
--- a/docs/THREAT_MODEL.md
+++ b/docs/THREAT_MODEL.md
@@ -15,7 +15,7 @@ Quantum Encryptor protects local files with post-quantum key encapsulation and a
## Trust Boundaries
-- Streamlit uploads are untrusted user-controlled files.
+- Browser uploads to the local web API are untrusted user-controlled files.
- Agent CLI arguments and environment variables are untrusted automation inputs.
- Public/private PEM files and `.pqc` files are attacker-controlled until parsed and authenticated.
- Native `liboqs` and Python `cryptography` are trusted dependencies but may be unavailable or misconfigured.
diff --git a/docs/screenshots/custom-web-encrypt-workflow.png b/docs/screenshots/custom-web-encrypt-workflow.png
index 2b36c3d..9a423ed 100644
Binary files a/docs/screenshots/custom-web-encrypt-workflow.png and b/docs/screenshots/custom-web-encrypt-workflow.png differ
diff --git a/docs/screenshots/custom-web-mobile-inspect.png b/docs/screenshots/custom-web-mobile-inspect.png
index 3405895..a27f59d 100644
Binary files a/docs/screenshots/custom-web-mobile-inspect.png and b/docs/screenshots/custom-web-mobile-inspect.png differ
diff --git a/docs/screenshots/decrypt-file-workflow.jpg b/docs/screenshots/decrypt-file-workflow.jpg
deleted file mode 100644
index 322e96c..0000000
Binary files a/docs/screenshots/decrypt-file-workflow.jpg and /dev/null differ
diff --git a/docs/screenshots/encrypt-file-workflow.jpg b/docs/screenshots/encrypt-file-workflow.jpg
deleted file mode 100644
index b5255b8..0000000
Binary files a/docs/screenshots/encrypt-file-workflow.jpg and /dev/null differ
diff --git a/docs/screenshots/generate-keys-backend-warning.jpg b/docs/screenshots/generate-keys-backend-warning.jpg
deleted file mode 100644
index ee860c8..0000000
Binary files a/docs/screenshots/generate-keys-backend-warning.jpg and /dev/null differ
diff --git a/docs/screenshots/key-utilities-workflow.jpg b/docs/screenshots/key-utilities-workflow.jpg
deleted file mode 100644
index 6d66f84..0000000
Binary files a/docs/screenshots/key-utilities-workflow.jpg and /dev/null differ
diff --git a/package-lock.json b/package-lock.json
index 412e548..f6b9712 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -12,17 +12,208 @@
"react-dom": "^19.2.7"
},
"devDependencies": {
+ "@axe-core/playwright": "^4.12.1",
+ "@testing-library/dom": "^10.4.1",
+ "@testing-library/jest-dom": "6.9.1",
+ "@testing-library/react": "^16.3.2",
+ "@testing-library/user-event": "^14.6.1",
"@types/react": "^19.2.17",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.3",
+ "jsdom": "26.1.0",
"playwright": "^1.61.1",
"typescript": "^7.0.2",
- "vite": "^8.1.5"
+ "vite": "^8.1.5",
+ "vitest": "^4.1.10"
},
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
+ "node_modules/@adobe/css-tools": {
+ "version": "4.5.0",
+ "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.5.0.tgz",
+ "integrity": "sha512-6OzddxPio9UiWTCemp4N8cYLV2ZN1ncRnV1cVGtve7dhPOtRkleRyx32GQCYSwDYgaHU3USMm84tNsvKzRCa1Q==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@asamuzakjp/css-color": {
+ "version": "3.2.0",
+ "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-3.2.0.tgz",
+ "integrity": "sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@csstools/css-calc": "^2.1.3",
+ "@csstools/css-color-parser": "^3.0.9",
+ "@csstools/css-parser-algorithms": "^3.0.4",
+ "@csstools/css-tokenizer": "^3.0.3",
+ "lru-cache": "^10.4.3"
+ }
+ },
+ "node_modules/@axe-core/playwright": {
+ "version": "4.12.1",
+ "resolved": "https://registry.npmjs.org/@axe-core/playwright/-/playwright-4.12.1.tgz",
+ "integrity": "sha512-rMd7xriptqKpP+w5265i4Hdkv2X5kbu6uiBi/B2I7uf3hieRBM3qDCfaKPtxfiYb2mKXfF+yLODJwIx+Jv1GDw==",
+ "dev": true,
+ "license": "MPL-2.0",
+ "dependencies": {
+ "axe-core": "~4.12.1"
+ },
+ "peerDependencies": {
+ "playwright-core": ">= 1.0.0"
+ }
+ },
+ "node_modules/@babel/code-frame": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz",
+ "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-validator-identifier": "^7.29.7",
+ "js-tokens": "^4.0.0",
+ "picocolors": "^1.1.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-validator-identifier": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz",
+ "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/runtime": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz",
+ "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@csstools/color-helpers": {
+ "version": "5.1.0",
+ "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.1.0.tgz",
+ "integrity": "sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/csstools"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/csstools"
+ }
+ ],
+ "license": "MIT-0",
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@csstools/css-calc": {
+ "version": "2.1.4",
+ "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-2.1.4.tgz",
+ "integrity": "sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/csstools"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/csstools"
+ }
+ ],
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ },
+ "peerDependencies": {
+ "@csstools/css-parser-algorithms": "^3.0.5",
+ "@csstools/css-tokenizer": "^3.0.4"
+ }
+ },
+ "node_modules/@csstools/css-color-parser": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-3.1.0.tgz",
+ "integrity": "sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/csstools"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/csstools"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "@csstools/color-helpers": "^5.1.0",
+ "@csstools/css-calc": "^2.1.4"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "peerDependencies": {
+ "@csstools/css-parser-algorithms": "^3.0.5",
+ "@csstools/css-tokenizer": "^3.0.4"
+ }
+ },
+ "node_modules/@csstools/css-parser-algorithms": {
+ "version": "3.0.5",
+ "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-3.0.5.tgz",
+ "integrity": "sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/csstools"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/csstools"
+ }
+ ],
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ },
+ "peerDependencies": {
+ "@csstools/css-tokenizer": "^3.0.4"
+ }
+ },
+ "node_modules/@csstools/css-tokenizer": {
+ "version": "3.0.4",
+ "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-3.0.4.tgz",
+ "integrity": "sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/csstools"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/csstools"
+ }
+ ],
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ }
+ },
"node_modules/@emnapi/core": {
"version": "1.11.1",
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz",
@@ -57,6 +248,13 @@
"tslib": "^2.4.0"
}
},
+ "node_modules/@jridgewell/sourcemap-codec": {
+ "version": "1.5.5",
+ "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
+ "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/@napi-rs/wasm-runtime": {
"version": "1.1.6",
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.6.tgz",
@@ -179,9 +377,6 @@
"arm64"
],
"dev": true,
- "libc": [
- "glibc"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -199,9 +394,6 @@
"arm64"
],
"dev": true,
- "libc": [
- "musl"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -219,9 +411,6 @@
"ppc64"
],
"dev": true,
- "libc": [
- "glibc"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -239,9 +428,6 @@
"s390x"
],
"dev": true,
- "libc": [
- "glibc"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -259,9 +445,6 @@
"x64"
],
"dev": true,
- "libc": [
- "glibc"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -279,9 +462,6 @@
"x64"
],
"dev": true,
- "libc": [
- "musl"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -368,6 +548,102 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/@standard-schema/spec": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
+ "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@testing-library/dom": {
+ "version": "10.4.1",
+ "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz",
+ "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/code-frame": "^7.10.4",
+ "@babel/runtime": "^7.12.5",
+ "@types/aria-query": "^5.0.1",
+ "aria-query": "5.3.0",
+ "dom-accessibility-api": "^0.5.9",
+ "lz-string": "^1.5.0",
+ "picocolors": "1.1.1",
+ "pretty-format": "^27.0.2"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@testing-library/jest-dom": {
+ "version": "6.9.1",
+ "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.9.1.tgz",
+ "integrity": "sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@adobe/css-tools": "^4.4.0",
+ "aria-query": "^5.0.0",
+ "css.escape": "^1.5.1",
+ "dom-accessibility-api": "^0.6.3",
+ "picocolors": "^1.1.1",
+ "redent": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=14",
+ "npm": ">=6",
+ "yarn": ">=1"
+ }
+ },
+ "node_modules/@testing-library/jest-dom/node_modules/dom-accessibility-api": {
+ "version": "0.6.3",
+ "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.6.3.tgz",
+ "integrity": "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@testing-library/react": {
+ "version": "16.3.2",
+ "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.2.tgz",
+ "integrity": "sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/runtime": "^7.12.5"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "peerDependencies": {
+ "@testing-library/dom": "^10.0.0",
+ "@types/react": "^18.0.0 || ^19.0.0",
+ "@types/react-dom": "^18.0.0 || ^19.0.0",
+ "react": "^18.0.0 || ^19.0.0",
+ "react-dom": "^18.0.0 || ^19.0.0"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@testing-library/user-event": {
+ "version": "14.6.1",
+ "resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-14.6.1.tgz",
+ "integrity": "sha512-vq7fv0rnt+QTXgPxr5Hjc210p6YKq2kmdziLgnsZGgLJ9e6VAShx1pACLuRjd/AS/sr7phAR58OIIpf0LlmQNw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12",
+ "npm": ">=6"
+ },
+ "peerDependencies": {
+ "@testing-library/dom": ">=7.21.4"
+ }
+ },
"node_modules/@tybys/wasm-util": {
"version": "0.10.3",
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz",
@@ -379,6 +655,38 @@
"tslib": "^2.4.0"
}
},
+ "node_modules/@types/aria-query": {
+ "version": "5.0.4",
+ "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz",
+ "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@types/chai": {
+ "version": "5.2.3",
+ "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz",
+ "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/deep-eql": "*",
+ "assertion-error": "^2.0.1"
+ }
+ },
+ "node_modules/@types/deep-eql": {
+ "version": "4.0.2",
+ "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz",
+ "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@types/estree": {
+ "version": "1.0.9",
+ "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz",
+ "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/@types/react": {
"version": "19.2.17",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.17.tgz",
@@ -765,96 +1073,524 @@
}
}
},
- "node_modules/csstype": {
- "version": "3.2.3",
- "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
- "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
- "dev": true,
- "license": "MIT"
- },
- "node_modules/detect-libc": {
- "version": "2.1.2",
- "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
- "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
+ "node_modules/@vitest/expect": {
+ "version": "4.1.10",
+ "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.10.tgz",
+ "integrity": "sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA==",
"dev": true,
- "license": "Apache-2.0",
- "engines": {
- "node": ">=8"
+ "license": "MIT",
+ "dependencies": {
+ "@standard-schema/spec": "^1.1.0",
+ "@types/chai": "^5.2.2",
+ "@vitest/spy": "4.1.10",
+ "@vitest/utils": "4.1.10",
+ "chai": "^6.2.2",
+ "tinyrainbow": "^3.1.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
}
},
- "node_modules/fdir": {
- "version": "6.5.0",
- "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
- "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
+ "node_modules/@vitest/mocker": {
+ "version": "4.1.10",
+ "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.10.tgz",
+ "integrity": "sha512-v0xaezt+DKEmKfaxg133ldzADrwLGd7Ze1MfQQTYfvs8OqZIwbxyxaYURivwV7sWy5fqn3rH5uOrSp07bp44Ow==",
"dev": true,
"license": "MIT",
- "engines": {
- "node": ">=12.0.0"
+ "dependencies": {
+ "@vitest/spy": "4.1.10",
+ "estree-walker": "^3.0.3",
+ "magic-string": "^0.30.21"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
},
"peerDependencies": {
- "picomatch": "^3 || ^4"
+ "msw": "^2.4.9",
+ "vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
},
"peerDependenciesMeta": {
- "picomatch": {
+ "msw": {
+ "optional": true
+ },
+ "vite": {
"optional": true
}
}
},
- "node_modules/fsevents": {
- "version": "2.3.3",
- "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
- "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
+ "node_modules/@vitest/pretty-format": {
+ "version": "4.1.10",
+ "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.10.tgz",
+ "integrity": "sha512-W1HsjSH4MXQ9YfmmhLAoIYf1HRfekQCGngeIgcei6MP5QQGWUe0gkopdZQaVCFO+JDJMrAJGwa5pRpNpvy4P8Q==",
"dev": true,
- "hasInstallScript": true,
"license": "MIT",
- "optional": true,
- "os": [
- "darwin"
- ],
- "engines": {
- "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+ "dependencies": {
+ "tinyrainbow": "^3.1.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
}
},
- "node_modules/lightningcss": {
- "version": "1.32.0",
- "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz",
- "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==",
+ "node_modules/@vitest/runner": {
+ "version": "4.1.10",
+ "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.10.tgz",
+ "integrity": "sha512-IKI6kpIH+LmpROplyLwBBaCfMgOZOMsygVa6BARD6ahA04VRuJSa6OaVG7kRvSEMD870Vd91rSSw0eegtWyLGg==",
"dev": true,
- "license": "MPL-2.0",
+ "license": "MIT",
"dependencies": {
- "detect-libc": "^2.0.3"
- },
- "engines": {
- "node": ">= 12.0.0"
+ "@vitest/utils": "4.1.10",
+ "pathe": "^2.0.3"
},
"funding": {
- "type": "opencollective",
- "url": "https://opencollective.com/parcel"
- },
- "optionalDependencies": {
- "lightningcss-android-arm64": "1.32.0",
- "lightningcss-darwin-arm64": "1.32.0",
- "lightningcss-darwin-x64": "1.32.0",
- "lightningcss-freebsd-x64": "1.32.0",
- "lightningcss-linux-arm-gnueabihf": "1.32.0",
- "lightningcss-linux-arm64-gnu": "1.32.0",
- "lightningcss-linux-arm64-musl": "1.32.0",
- "lightningcss-linux-x64-gnu": "1.32.0",
- "lightningcss-linux-x64-musl": "1.32.0",
- "lightningcss-win32-arm64-msvc": "1.32.0",
- "lightningcss-win32-x64-msvc": "1.32.0"
+ "url": "https://opencollective.com/vitest"
}
},
- "node_modules/lightningcss-android-arm64": {
- "version": "1.32.0",
- "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz",
- "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==",
- "cpu": [
- "arm64"
- ],
+ "node_modules/@vitest/snapshot": {
+ "version": "4.1.10",
+ "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.10.tgz",
+ "integrity": "sha512-xRkfOT1qpTAi/Ti4Y1LtfRc3kEuqxGw59eN2jN9pRWMtS/XDevekhcFSqvQqjUNGksfjMJu3Y+oJ+4Ypn2OaJw==",
"dev": true,
- "license": "MPL-2.0",
- "optional": true,
+ "license": "MIT",
+ "dependencies": {
+ "@vitest/pretty-format": "4.1.10",
+ "@vitest/utils": "4.1.10",
+ "magic-string": "^0.30.21",
+ "pathe": "^2.0.3"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/spy": {
+ "version": "4.1.10",
+ "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.10.tgz",
+ "integrity": "sha512-PLf/Ugvoq5wO/b4rwYCR1h2PSIdXz7wnkQFMiUpLdtM7l6pqVFcQIBEHyT1+l+cj7mNwAfZHzqXqDyjvOuwbDw==",
+ "dev": true,
+ "license": "MIT",
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/utils": {
+ "version": "4.1.10",
+ "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.10.tgz",
+ "integrity": "sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@vitest/pretty-format": "4.1.10",
+ "convert-source-map": "^2.0.0",
+ "tinyrainbow": "^3.1.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/agent-base": {
+ "version": "7.1.4",
+ "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz",
+ "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 14"
+ }
+ },
+ "node_modules/ansi-regex": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
+ "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/ansi-styles": {
+ "version": "5.2.0",
+ "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz",
+ "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/ansi-styles?sponsor=1"
+ }
+ },
+ "node_modules/aria-query": {
+ "version": "5.3.0",
+ "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz",
+ "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "dequal": "^2.0.3"
+ }
+ },
+ "node_modules/assertion-error": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz",
+ "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/axe-core": {
+ "version": "4.12.1",
+ "resolved": "https://registry.npmjs.org/axe-core/-/axe-core-4.12.1.tgz",
+ "integrity": "sha512-s7iGf5GaVMxEG0ENN9x+xTr7GFZCb1ZP/1uATUpCEK2X78nDB3RwbtFCo9pGAf9ru+VwoQ464DkaLEeRM08wJA==",
+ "dev": true,
+ "license": "MPL-2.0",
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/chai": {
+ "version": "6.2.2",
+ "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz",
+ "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/convert-source-map": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
+ "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/css.escape": {
+ "version": "1.5.1",
+ "resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz",
+ "integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/cssstyle": {
+ "version": "4.6.0",
+ "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-4.6.0.tgz",
+ "integrity": "sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@asamuzakjp/css-color": "^3.2.0",
+ "rrweb-cssom": "^0.8.0"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/csstype": {
+ "version": "3.2.3",
+ "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
+ "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/data-urls": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-5.0.0.tgz",
+ "integrity": "sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "whatwg-mimetype": "^4.0.0",
+ "whatwg-url": "^14.0.0"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/debug": {
+ "version": "4.4.3",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
+ "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ms": "^2.1.3"
+ },
+ "engines": {
+ "node": ">=6.0"
+ },
+ "peerDependenciesMeta": {
+ "supports-color": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/decimal.js": {
+ "version": "10.6.0",
+ "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz",
+ "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/dequal": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz",
+ "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/detect-libc": {
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
+ "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/dom-accessibility-api": {
+ "version": "0.5.16",
+ "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz",
+ "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/entities": {
+ "version": "6.0.1",
+ "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz",
+ "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "engines": {
+ "node": ">=0.12"
+ },
+ "funding": {
+ "url": "https://github.com/fb55/entities?sponsor=1"
+ }
+ },
+ "node_modules/es-module-lexer": {
+ "version": "2.3.1",
+ "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.1.tgz",
+ "integrity": "sha512-shc1dbU90Yl/xq1QrC7QRtfcwURZuVRfPhZbDoldJ1cn1gzDvBaBWlv0eFolj5+0znnPJz5TXLxsN77X/12KTA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/estree-walker": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz",
+ "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/estree": "^1.0.0"
+ }
+ },
+ "node_modules/expect-type": {
+ "version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz",
+ "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=12.0.0"
+ }
+ },
+ "node_modules/fdir": {
+ "version": "6.5.0",
+ "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
+ "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12.0.0"
+ },
+ "peerDependencies": {
+ "picomatch": "^3 || ^4"
+ },
+ "peerDependenciesMeta": {
+ "picomatch": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/fsevents": {
+ "version": "2.3.3",
+ "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
+ "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+ }
+ },
+ "node_modules/html-encoding-sniffer": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-4.0.0.tgz",
+ "integrity": "sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "whatwg-encoding": "^3.1.1"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/http-proxy-agent": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz",
+ "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "agent-base": "^7.1.0",
+ "debug": "^4.3.4"
+ },
+ "engines": {
+ "node": ">= 14"
+ }
+ },
+ "node_modules/https-proxy-agent": {
+ "version": "7.0.6",
+ "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz",
+ "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "agent-base": "^7.1.2",
+ "debug": "4"
+ },
+ "engines": {
+ "node": ">= 14"
+ }
+ },
+ "node_modules/iconv-lite": {
+ "version": "0.6.3",
+ "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
+ "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "safer-buffer": ">= 2.1.2 < 3.0.0"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/indent-string": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz",
+ "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/is-potential-custom-element-name": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz",
+ "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/js-tokens": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
+ "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/jsdom": {
+ "version": "26.1.0",
+ "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-26.1.0.tgz",
+ "integrity": "sha512-Cvc9WUhxSMEo4McES3P7oK3QaXldCfNWp7pl2NNeiIFlCoLr3kfq9kb1fxftiwk1FLV7CvpvDfonxtzUDeSOPg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "cssstyle": "^4.2.1",
+ "data-urls": "^5.0.0",
+ "decimal.js": "^10.5.0",
+ "html-encoding-sniffer": "^4.0.0",
+ "http-proxy-agent": "^7.0.2",
+ "https-proxy-agent": "^7.0.6",
+ "is-potential-custom-element-name": "^1.0.1",
+ "nwsapi": "^2.2.16",
+ "parse5": "^7.2.1",
+ "rrweb-cssom": "^0.8.0",
+ "saxes": "^6.0.0",
+ "symbol-tree": "^3.2.4",
+ "tough-cookie": "^5.1.1",
+ "w3c-xmlserializer": "^5.0.0",
+ "webidl-conversions": "^7.0.0",
+ "whatwg-encoding": "^3.1.1",
+ "whatwg-mimetype": "^4.0.0",
+ "whatwg-url": "^14.1.1",
+ "ws": "^8.18.0",
+ "xml-name-validator": "^5.0.0"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "peerDependencies": {
+ "canvas": "^3.0.0"
+ },
+ "peerDependenciesMeta": {
+ "canvas": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/lightningcss": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz",
+ "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==",
+ "dev": true,
+ "license": "MPL-2.0",
+ "dependencies": {
+ "detect-libc": "^2.0.3"
+ },
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ },
+ "optionalDependencies": {
+ "lightningcss-android-arm64": "1.32.0",
+ "lightningcss-darwin-arm64": "1.32.0",
+ "lightningcss-darwin-x64": "1.32.0",
+ "lightningcss-freebsd-x64": "1.32.0",
+ "lightningcss-linux-arm-gnueabihf": "1.32.0",
+ "lightningcss-linux-arm64-gnu": "1.32.0",
+ "lightningcss-linux-arm64-musl": "1.32.0",
+ "lightningcss-linux-x64-gnu": "1.32.0",
+ "lightningcss-linux-x64-musl": "1.32.0",
+ "lightningcss-win32-arm64-msvc": "1.32.0",
+ "lightningcss-win32-x64-msvc": "1.32.0"
+ }
+ },
+ "node_modules/lightningcss-android-arm64": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz",
+ "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
"os": [
"android"
],
@@ -958,9 +1694,6 @@
"arm64"
],
"dev": true,
- "libc": [
- "glibc"
- ],
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -982,9 +1715,6 @@
"arm64"
],
"dev": true,
- "libc": [
- "musl"
- ],
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -1006,9 +1736,6 @@
"x64"
],
"dev": true,
- "libc": [
- "glibc"
- ],
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -1030,9 +1757,6 @@
"x64"
],
"dev": true,
- "libc": [
- "musl"
- ],
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -1088,6 +1812,50 @@
"url": "https://opencollective.com/parcel"
}
},
+ "node_modules/lru-cache": {
+ "version": "10.4.3",
+ "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz",
+ "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/lz-string": {
+ "version": "1.5.0",
+ "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz",
+ "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "lz-string": "bin/bin.js"
+ }
+ },
+ "node_modules/magic-string": {
+ "version": "0.30.21",
+ "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
+ "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/sourcemap-codec": "^1.5.5"
+ }
+ },
+ "node_modules/min-indent": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz",
+ "integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/ms": {
+ "version": "2.1.3",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
+ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/nanoid": {
"version": "3.3.16",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz",
@@ -1107,6 +1875,47 @@
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
}
},
+ "node_modules/nwsapi": {
+ "version": "2.2.24",
+ "resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.24.tgz",
+ "integrity": "sha512-7YRhZ3jS45LwmSCT4b2sVFHt/WuovaktDU07QrtOBY2PXskss5a9jfmR9jptyumwXST+rFjrmppMY1KT/yn35A==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/obug": {
+ "version": "2.1.4",
+ "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.4.tgz",
+ "integrity": "sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA==",
+ "dev": true,
+ "funding": [
+ "https://github.com/sponsors/sxzz",
+ "https://opencollective.com/debug"
+ ],
+ "license": "MIT",
+ "engines": {
+ "node": ">=12.20.0"
+ }
+ },
+ "node_modules/parse5": {
+ "version": "7.3.0",
+ "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz",
+ "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "entities": "^6.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/inikulin/parse5?sponsor=1"
+ }
+ },
+ "node_modules/pathe": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz",
+ "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/picocolors": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
@@ -1203,6 +2012,31 @@
"node": "^10 || ^12 || >=14"
}
},
+ "node_modules/pretty-format": {
+ "version": "27.5.1",
+ "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz",
+ "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ansi-regex": "^5.0.1",
+ "ansi-styles": "^5.0.0",
+ "react-is": "^17.0.1"
+ },
+ "engines": {
+ "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0"
+ }
+ },
+ "node_modules/punycode": {
+ "version": "2.3.1",
+ "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
+ "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
"node_modules/react": {
"version": "19.2.7",
"resolved": "https://registry.npmjs.org/react/-/react-19.2.7.tgz",
@@ -1224,6 +2058,27 @@
"react": "^19.2.7"
}
},
+ "node_modules/react-is": {
+ "version": "17.0.2",
+ "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz",
+ "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/redent": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz",
+ "integrity": "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "indent-string": "^4.0.0",
+ "strip-indent": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
"node_modules/rolldown": {
"version": "1.1.5",
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.5.tgz",
@@ -1258,12 +2113,46 @@
"@rolldown/binding-win32-x64-msvc": "1.1.5"
}
},
+ "node_modules/rrweb-cssom": {
+ "version": "0.8.0",
+ "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.8.0.tgz",
+ "integrity": "sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/safer-buffer": {
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
+ "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/saxes": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz",
+ "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "xmlchars": "^2.2.0"
+ },
+ "engines": {
+ "node": ">=v12.22.7"
+ }
+ },
"node_modules/scheduler": {
"version": "0.27.0",
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz",
"integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==",
"license": "MIT"
},
+ "node_modules/siginfo": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz",
+ "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==",
+ "dev": true,
+ "license": "ISC"
+ },
"node_modules/source-map-js": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
@@ -1274,6 +2163,57 @@
"node": ">=0.10.0"
}
},
+ "node_modules/stackback": {
+ "version": "0.0.2",
+ "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz",
+ "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/std-env": {
+ "version": "4.2.0",
+ "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.2.0.tgz",
+ "integrity": "sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/strip-indent": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz",
+ "integrity": "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "min-indent": "^1.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/symbol-tree": {
+ "version": "3.2.4",
+ "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz",
+ "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/tinybench": {
+ "version": "2.9.0",
+ "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
+ "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/tinyexec": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.3.0.tgz",
+ "integrity": "sha512-QKAl9m8gWWGHV8jZcPeym6j+XULi6tOf1mT83WYJ4Lk2ytW/uwAWkrP0uFsdoYMdueVJ0qs26wZ+23xeB4ibNQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ }
+ },
"node_modules/tinyglobby": {
"version": "0.2.17",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
@@ -1291,6 +2231,62 @@
"url": "https://github.com/sponsors/SuperchupuDev"
}
},
+ "node_modules/tinyrainbow": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.1.tgz",
+ "integrity": "sha512-yau8yJdTt989Mm0Bd/236QnzEiPf2xLLTqUZRUJOo/3CB078LSwzei343DgtJVmfJKJE3TMINY1u42SQsP6mXw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=14.0.0"
+ }
+ },
+ "node_modules/tldts": {
+ "version": "6.1.86",
+ "resolved": "https://registry.npmjs.org/tldts/-/tldts-6.1.86.tgz",
+ "integrity": "sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "tldts-core": "^6.1.86"
+ },
+ "bin": {
+ "tldts": "bin/cli.js"
+ }
+ },
+ "node_modules/tldts-core": {
+ "version": "6.1.86",
+ "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-6.1.86.tgz",
+ "integrity": "sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/tough-cookie": {
+ "version": "5.1.2",
+ "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-5.1.2.tgz",
+ "integrity": "sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==",
+ "dev": true,
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "tldts": "^6.1.32"
+ },
+ "engines": {
+ "node": ">=16"
+ }
+ },
+ "node_modules/tr46": {
+ "version": "5.1.1",
+ "resolved": "https://registry.npmjs.org/tr46/-/tr46-5.1.1.tgz",
+ "integrity": "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "punycode": "^2.3.1"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
"node_modules/tslib": {
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
@@ -1411,6 +2407,213 @@
"optional": true
}
}
+ },
+ "node_modules/vitest": {
+ "version": "4.1.10",
+ "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.10.tgz",
+ "integrity": "sha512-R9jUTe5S4Qb0HCd4TNqpC7oGcrMssMRGXLW80ubjWsW9VH5GF8y1Y0SFLY9AbqSk6nt0PnOx4H4WNJYZ13GUPw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@vitest/expect": "4.1.10",
+ "@vitest/mocker": "4.1.10",
+ "@vitest/pretty-format": "4.1.10",
+ "@vitest/runner": "4.1.10",
+ "@vitest/snapshot": "4.1.10",
+ "@vitest/spy": "4.1.10",
+ "@vitest/utils": "4.1.10",
+ "es-module-lexer": "^2.0.0",
+ "expect-type": "^1.3.0",
+ "magic-string": "^0.30.21",
+ "obug": "^2.1.1",
+ "pathe": "^2.0.3",
+ "picomatch": "^4.0.3",
+ "std-env": "^4.0.0-rc.1",
+ "tinybench": "^2.9.0",
+ "tinyexec": "^1.0.2",
+ "tinyglobby": "^0.2.15",
+ "tinyrainbow": "^3.1.0",
+ "vite": "^6.0.0 || ^7.0.0 || ^8.0.0",
+ "why-is-node-running": "^2.3.0"
+ },
+ "bin": {
+ "vitest": "vitest.mjs"
+ },
+ "engines": {
+ "node": "^20.0.0 || ^22.0.0 || >=24.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ },
+ "peerDependencies": {
+ "@edge-runtime/vm": "*",
+ "@opentelemetry/api": "^1.9.0",
+ "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0",
+ "@vitest/browser-playwright": "4.1.10",
+ "@vitest/browser-preview": "4.1.10",
+ "@vitest/browser-webdriverio": "4.1.10",
+ "@vitest/coverage-istanbul": "4.1.10",
+ "@vitest/coverage-v8": "4.1.10",
+ "@vitest/ui": "4.1.10",
+ "happy-dom": "*",
+ "jsdom": "*",
+ "vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
+ },
+ "peerDependenciesMeta": {
+ "@edge-runtime/vm": {
+ "optional": true
+ },
+ "@opentelemetry/api": {
+ "optional": true
+ },
+ "@types/node": {
+ "optional": true
+ },
+ "@vitest/browser-playwright": {
+ "optional": true
+ },
+ "@vitest/browser-preview": {
+ "optional": true
+ },
+ "@vitest/browser-webdriverio": {
+ "optional": true
+ },
+ "@vitest/coverage-istanbul": {
+ "optional": true
+ },
+ "@vitest/coverage-v8": {
+ "optional": true
+ },
+ "@vitest/ui": {
+ "optional": true
+ },
+ "happy-dom": {
+ "optional": true
+ },
+ "jsdom": {
+ "optional": true
+ },
+ "vite": {
+ "optional": false
+ }
+ }
+ },
+ "node_modules/w3c-xmlserializer": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz",
+ "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "xml-name-validator": "^5.0.0"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/webidl-conversions": {
+ "version": "7.0.0",
+ "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz",
+ "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/whatwg-encoding": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz",
+ "integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==",
+ "deprecated": "Use @exodus/bytes instead for a more spec-conformant and faster implementation",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "iconv-lite": "0.6.3"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/whatwg-mimetype": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz",
+ "integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/whatwg-url": {
+ "version": "14.2.0",
+ "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-14.2.0.tgz",
+ "integrity": "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "tr46": "^5.1.0",
+ "webidl-conversions": "^7.0.0"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/why-is-node-running": {
+ "version": "2.3.0",
+ "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz",
+ "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "siginfo": "^2.0.0",
+ "stackback": "0.0.2"
+ },
+ "bin": {
+ "why-is-node-running": "cli.js"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/ws": {
+ "version": "8.21.1",
+ "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.1.tgz",
+ "integrity": "sha512-+0NTnW77fFN/DjQi6k/Sq/Yvk4Sgajw7urW8V+asjXnRgDs9gyGkdb7EzgfhA4goXsRIZKE28fzIXBHEzhuiWw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=10.0.0"
+ },
+ "peerDependencies": {
+ "bufferutil": "^4.0.1",
+ "utf-8-validate": ">=5.0.2"
+ },
+ "peerDependenciesMeta": {
+ "bufferutil": {
+ "optional": true
+ },
+ "utf-8-validate": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/xml-name-validator": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz",
+ "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/xmlchars": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz",
+ "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==",
+ "dev": true,
+ "license": "MIT"
}
}
}
diff --git a/package.json b/package.json
index 53f6745..6af6ac9 100644
--- a/package.json
+++ b/package.json
@@ -9,20 +9,30 @@
"scripts": {
"dev": "vite --host 127.0.0.1 --port 4001",
"build": "tsc --noEmit && vite build",
- "check": "tsc --noEmit && npm run test:api",
+ "check": "tsc --noEmit && npm run test:api && npm run test:unit",
"test:api": "node --test scripts/api_client.test.mjs",
- "ui-smoke": "node scripts/ui_smoke.mjs"
+ "test:unit": "vitest run",
+ "test:unit:watch": "vitest",
+ "ui-smoke": "node scripts/ui_smoke.mjs",
+ "ui-native": "node scripts/ui_native_smoke.mjs"
},
"dependencies": {
"react": "^19.2.7",
"react-dom": "^19.2.7"
},
"devDependencies": {
+ "@axe-core/playwright": "^4.12.1",
+ "@testing-library/dom": "^10.4.1",
+ "@testing-library/jest-dom": "6.9.1",
+ "@testing-library/react": "^16.3.2",
+ "@testing-library/user-event": "^14.6.1",
"@types/react": "^19.2.17",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.3",
+ "jsdom": "26.1.0",
"playwright": "^1.61.1",
"typescript": "^7.0.2",
- "vite": "^8.1.5"
+ "vite": "^8.1.5",
+ "vitest": "^4.1.10"
}
}
diff --git a/pqc_app.py b/pqc_app.py
deleted file mode 100644
index c80728f..0000000
--- a/pqc_app.py
+++ /dev/null
@@ -1,492 +0,0 @@
-# pqc_app.py
-import streamlit as st
-import logging
-from pathlib import Path
-import mimetypes # For guessing download mime type
-import re
-
-# Import core logic and config
-from crypto_config import cfg
-import crypto_core as core
-from ui_helpers import format_key_info_for_display, guess_decrypted_filename
-
-# --- Basic Logging Setup ---
-# Configure logging level and format
-# Streamlit can sometimes interfere with basicConfig, setting level explicitly helps.
-logging.basicConfig(
- level=logging.INFO,
- format="%(asctime)s - %(levelname)s - [%(module)s] - %(message)s",
-)
-logger = logging.getLogger(__name__)
-
-# --- Streamlit Page Configuration ---
-st.set_page_config(
- page_title="PQC Pro Encryptor",
- layout="wide",
- initial_sidebar_state="expanded",
- menu_items={"About": f"""
- **PQC Pro Encryptor v{cfg.FORMAT_VERSION}.0**
-
- Uses **{cfg.KEM_ALG}** (PQC KEM) + **AES-256-GCM** (DEM) with HKDF.
- Private keys are always password protected (scrypt + AES-GCM).
-
- **Disclaimer:** Security relies on correct implementation, secure key management,
- and the underlying cryptographic primitives. Requires auditing for production use.
- **The developer provides no warranty.** Use at your own risk.
- """},
-)
-
-# --- Helper Functions for GUI ---
-
-
-def display_pem_download(pem_string: str, filename: str, key_label: str):
- """Provides a download button for a PEM string."""
- try:
- st.download_button(
- label=f"Download {key_label} ({filename})",
- data=pem_string.encode("ascii"), # PEM is ASCII
- file_name=filename,
- mime="application/x-pem-file",
- )
- except Exception as e:
- logger.exception(f"Error creating download button for {key_label}: {e}")
- st.error(f"Could not prepare {key_label} for download.")
-
-
-def format_size(byte_count: int) -> str:
- """Format a byte count for user-facing validation messages."""
- mib = byte_count / (1024 * 1024)
- return f"{mib:.1f} MiB"
-
-
-def uploaded_file_too_large(uploaded_file, max_bytes: int | None = None) -> bool:
- """Return whether a Streamlit upload exceeds the configured in-memory limit."""
- size = getattr(uploaded_file, "size", None)
- limit = cfg.MAX_FILE_BYTES if max_bytes is None else max_bytes
- return size is not None and size > limit
-
-
-def uploaded_key_file_too_large(uploaded_file, max_bytes: int | None = None) -> bool:
- """Return whether a Streamlit PEM/key upload exceeds the configured limit."""
- limit = cfg.MAX_PEM_BYTES if max_bytes is None else max_bytes
- return uploaded_file_too_large(uploaded_file, limit)
-
-
-def sanitize_download_filename(filename: str, fallback: str) -> str:
- """Constrain user-controlled download names to a simple local filename."""
- candidate = Path(filename or "").name.strip()
- candidate = re.sub(r"[\x00-\x1f\x7f]+", "", candidate)
- return candidate or fallback
-
-
-def validate_private_key_password_for_ui(password: str) -> tuple[bool, str | None]:
- """Validate private-key password text with the core policy."""
- try:
- core.validate_private_key_password(password)
- return True, None
- except (core.PasswordRequiredError, core.WeakPasswordError) as exc:
- return False, str(exc)
-
-
-# --- Main Application UI ---
-
-try:
- active_kem_component = core.resolve_kem_algorithm(cfg.KEM_ALG)
- kem_status_message = None
-except Exception as exc:
- active_kem_component = cfg.KEM_ALG
- kem_status_message = (
- "The ML-KEM backend is not ready, so new keys and ciphertexts cannot be created. "
- "Compatible legacy archives may still be decryptable."
- )
- logger.warning("Unable to resolve configured KEM algorithm: %s", exc)
-
-st.title("🛡️ PQC Pro File Encryption / Decryption")
-st.markdown(f"""
-Utilizes **{cfg.HYBRID_KEM_ALG}** + **AES-256-GCM** hybrid encryption.
-Supports password-protected private keys (PEM format).
-""")
-
-st.sidebar.header("Operations")
-operation = st.sidebar.radio(
- "Choose action:",
- ["Generate Keys", "Encrypt File", "Decrypt File", "Key Utilities"],
- key="main_operation",
-)
-
-st.sidebar.markdown("---")
-st.sidebar.info(
- f"Version: {cfg.FORMAT_VERSION}.0\nSuite: {cfg.HYBRID_KEM_ALG}\n"
- f"ML-KEM backend: {active_kem_component}\nDEM: AES-256-GCM"
-)
-if kem_status_message:
- st.sidebar.warning(kem_status_message)
-
-# State Management (using session state is generally better)
-# Empty password field defaults are not credential values.
-if "password_gen" not in st.session_state:
- st.session_state.password_gen = "" # nosec B105
-if "password_gen_confirm" not in st.session_state:
- st.session_state.password_gen_confirm = "" # nosec B105
-if "password_decrypt" not in st.session_state:
- st.session_state.password_decrypt = "" # nosec B105
-
-# === Key Generation ===
-if operation == "Generate Keys":
- st.header("🔑 Generate PQC Key Pair")
- st.markdown(f"Generates a **{cfg.HYBRID_KEM_ALG}** public/private key pair (PEM format).")
- st.info(f"Using hybrid suite: **{cfg.HYBRID_KEM_ALG}**")
-
- st.subheader("Private Key Password")
- password_valid = False
- st.session_state.password_gen = st.text_input("Enter Password:", type="password", key="gen_pw1")
- st.session_state.password_gen_confirm = st.text_input("Confirm Password:", type="password", key="gen_pw2")
- if st.session_state.password_gen or st.session_state.password_gen_confirm:
- if not st.session_state.password_gen:
- st.warning("Password cannot be empty.")
- elif st.session_state.password_gen != st.session_state.password_gen_confirm:
- st.warning("Passwords do not match.")
- else:
- password_valid, password_error = validate_private_key_password_for_ui(st.session_state.password_gen)
- if password_valid:
- st.success("Passwords match.")
- else:
- st.warning(password_error)
- else:
- st.warning("A password is required. Unencrypted private keys are not supported.")
- disable_gen_button = not password_valid
-
- st.markdown("---")
- if st.button(
- f"Generate {cfg.HYBRID_KEM_ALG} Key Pair",
- key="gen_button",
- disabled=disable_gen_button or bool(kem_status_message),
- ):
- final_password = st.session_state.password_gen if password_valid else None
-
- with st.status(f"Generating {cfg.HYBRID_KEM_ALG} keys...", expanded=True) as status:
- st.write("Generating independent X25519 and ML-KEM key pairs...")
- raw_pub_key, raw_priv_key = core.generate_hybrid_keys(active_kem_component)
-
- if raw_pub_key and raw_priv_key:
- st.write("Raw keys generated.")
- st.write("Formatting Public Key (PEM)...")
- pub_pem = core.save_key_pem(raw_pub_key, cfg.HYBRID_KEM_ALG, "public")
-
- st.write("Formatting Private Key (PEM)...")
- st.write("(Encrypting private key with password...)")
- priv_pem = core.save_key_pem(
- raw_priv_key,
- cfg.HYBRID_KEM_ALG,
- "private",
- password=final_password,
- )
-
- # Cleanup raw keys immediately after PEM generation
- del raw_pub_key
- del raw_priv_key
-
- if pub_pem and priv_pem:
- status.update(label="Key generation complete!", state="complete")
- st.success("Key pair generated successfully!")
- st.markdown("---")
- st.subheader("Download Your Keys (PEM Format)")
- st.warning(
- "🚨 **CRITICAL:** Securely store the **Private Key** file. "
- "Remember the password. Loss = permanent data loss."
- )
-
- pub_filename = "ml-kem-768_x25519_public.pem"
- priv_filename = "ml-kem-768_x25519_private.pem"
-
- col1, col2 = st.columns(2)
- with col1:
- display_pem_download(pub_pem, pub_filename, "Public Key")
- with col2:
- display_pem_download(priv_pem, priv_filename, "Private Key")
-
- st.markdown("---")
- st.info("Share the Public Key (`.pem`) file with others to allow them to encrypt files for you.")
-
- else:
- err_msg = "Failed to format keys into PEM format."
- logger.error(err_msg)
- status.update(label=err_msg, state="error")
- st.error(err_msg)
- else:
- err_msg = f"Failed to generate raw keys for {cfg.HYBRID_KEM_ALG}."
- logger.error(err_msg)
- status.update(label=err_msg, state="error")
- st.error(err_msg + " Check logs for details. Is liboqs working?")
-
-# === Encryption ===
-elif operation == "Encrypt File":
- st.header("⬆️ Encrypt a File")
- st.markdown("Encrypt using the recipient's **Public Key** (PEM format).")
-
- uploaded_file = st.file_uploader("1. Choose File to Encrypt", type=None, key="enc_file_input")
- public_key_pem_file = st.file_uploader(
- "2. Upload Recipient's Public Key (.pem)", type=["pem"], key="enc_pubkey_input"
- )
-
- if uploaded_file and public_key_pem_file:
- st.markdown("---")
- input_too_large = uploaded_file_too_large(uploaded_file)
- if input_too_large:
- st.error(
- f"Selected file is {format_size(uploaded_file.size)}. "
- f"The maximum supported size is {format_size(cfg.MAX_FILE_BYTES)}."
- )
- if uploaded_key_file_too_large(public_key_pem_file):
- st.error(
- f"Public key file is {format_size(public_key_pem_file.size)}. "
- f"The maximum supported PEM size is {format_size(cfg.MAX_PEM_BYTES)}."
- )
- st.stop()
- pub_pem_content: str | None
- try:
- pub_pem_content = public_key_pem_file.getvalue().decode("utf-8")
- except Exception as e:
- logger.exception("Error reading public key file: %s", e)
- st.error("Could not read the public key file.")
- pub_pem_content = None # Halt further processing
-
- if pub_pem_content:
- # Load public key (password ignored by load_key_pem for public keys)
- pub_key_bytes, kem_alg_from_key, key_type = core.load_key_pem(pub_pem_content)
-
- if pub_key_bytes and kem_alg_from_key == cfg.HYBRID_KEM_ALG and key_type == "public":
- st.success(f"Public Key loaded successfully (Algorithm: {kem_alg_from_key}).")
-
- original_filename = Path(uploaded_file.name)
- suggested_output_filename = f"{original_filename.stem}_encrypted.pqc"
- output_filename = st.text_input(
- "3. Encrypted file name:",
- value=suggested_output_filename,
- key="enc_output_name",
- )
- output_filename = sanitize_download_filename(output_filename, suggested_output_filename)
-
- if not output_filename:
- st.warning("Please provide a name for the encrypted file.")
- elif st.button("Encrypt File", key="enc_button", disabled=input_too_large):
- input_data = uploaded_file.getvalue()
- if len(input_data) == 0:
- st.warning(
- "Input file is empty. Encryption will proceed, resulting file will contain "
- "header and metadata only."
- )
- elif len(input_data) > cfg.MAX_FILE_BYTES:
- st.error("Selected file exceeds the maximum supported size.")
- st.stop()
-
- with st.status(f"Encrypting '{uploaded_file.name}'...", expanded=False) as status:
- try:
- status.write("Reading input file...") # Already done by getvalue()
- status.write(f"Performing {cfg.HYBRID_KEM_ALG} + AES-GCM encryption...")
- encrypted_blob = core.encrypt_file_pro(input_data, pub_key_bytes, kem_alg_from_key)
- status.write("Cleaning up...")
- del input_data
- del pub_key_bytes
-
- if encrypted_blob:
- status.update(label="Encryption successful!", state="complete")
- st.success("File encrypted successfully!")
- st.download_button(
- label=f"Download Encrypted File ({output_filename})",
- data=encrypted_blob,
- file_name=output_filename,
- mime="application/octet-stream",
- key="enc_download_button",
- )
- else:
- err_msg = "Encryption process failed."
- logger.error(err_msg + " (Core function returned None)")
- status.update(label=err_msg, state="error")
- st.error(err_msg + " Check logs.")
-
- except Exception as e:
- logger.exception(f"Unhandled exception during encryption action: {e}")
- status.update(label="Encryption Error", state="error")
- st.error("An unexpected error occurred during encryption. Check server logs for details.")
-
- elif key_type == "private":
- st.error("Error: The uploaded key file appears to be a Private Key, not a Public Key.")
- else:
- st.error(
- "Failed to load or validate the Public Key from the provided PEM file. Is it correctly formatted?"
- )
-
-
-# === Decryption ===
-elif operation == "Decrypt File":
- st.header("⬇️ Decrypt a File")
- st.markdown("Decrypt a `.pqc` file using **your** corresponding **Private Key** (PEM format).")
-
- encrypted_file = st.file_uploader("1. Choose Encrypted File (.pqc)", type=["pqc"], key="dec_file_input")
- private_key_pem_file = st.file_uploader("2. Upload Your Private Key (.pem)", type=["pem"], key="dec_privkey_input")
-
- if encrypted_file and private_key_pem_file:
- st.markdown("---")
- encrypted_too_large = uploaded_file_too_large(encrypted_file, cfg.MAX_ENCRYPTED_FILE_BYTES)
- if encrypted_too_large:
- st.error(
- f"Selected encrypted file is {format_size(encrypted_file.size)}. "
- f"The maximum supported encrypted file size is {format_size(cfg.MAX_ENCRYPTED_FILE_BYTES)}."
- )
- if uploaded_key_file_too_large(private_key_pem_file):
- st.error(
- f"Private key file is {format_size(private_key_pem_file.size)}. "
- f"The maximum supported PEM size is {format_size(cfg.MAX_PEM_BYTES)}."
- )
- st.stop()
- # First, inspect the private key security metadata.
- priv_pem_content: str | None
- try:
- priv_pem_content = private_key_pem_file.getvalue().decode("utf-8")
- priv_info = core.inspect_key_pem_strict(priv_pem_content)
- priv_type = str(priv_info.get("key_type", "")).title()
- except Exception as e:
- logger.exception("Error reading private key file: %s", e)
- st.error("Could not read a supported encrypted private key file.")
- priv_pem_content = None # Halt
-
- password_provided = False
- if priv_pem_content:
- if priv_type == "Private":
- st.subheader("Password Required for Private Key")
- st.session_state.password_decrypt = st.text_input(
- "Enter Private Key Password:", type="password", key="dec_pw"
- )
- if st.session_state.password_decrypt:
- password_provided = True
- else:
- st.warning("This private key is encrypted. Please enter the password.")
- elif priv_type == "Public":
- st.error(
- "Error: The uploaded key file appears to be a Public Key. A Private Key is required for decryption."
- )
- priv_pem_content = None # Halt
- elif not priv_type:
- st.error("Could not determine key type or algorithm from the Private Key file. Is it a valid PEM?")
- priv_pem_content = None # Halt
-
- if priv_pem_content and password_provided:
- original_filename = Path(encrypted_file.name)
- suggested_output_filename = guess_decrypted_filename(original_filename)
- output_filename = st.text_input(
- "3. Decrypted file name:",
- value=suggested_output_filename,
- key="dec_output_name",
- )
- output_filename = sanitize_download_filename(output_filename, suggested_output_filename)
-
- disable_dec_button = not output_filename or not password_provided or encrypted_too_large
-
- if st.button("Decrypt File", key="dec_button", disabled=disable_dec_button):
- encrypted_blob = encrypted_file.getvalue()
- if len(encrypted_blob) == 0:
- st.error("Encrypted file appears to be empty. Cannot decrypt.")
- else:
- if len(encrypted_blob) > cfg.MAX_ENCRYPTED_FILE_BYTES:
- st.error("Selected encrypted file exceeds the maximum supported encrypted file size.")
- st.stop()
-
- with st.status(f"Decrypting '{encrypted_file.name}'...", expanded=False) as status:
- try:
- status.write("Loading private key...")
- priv_key_bytes, kem_alg_key, key_type = core.load_key_pem(
- priv_pem_content,
- password=st.session_state.password_decrypt,
- )
-
- if not priv_key_bytes or key_type != "private":
- err_msg = "Failed to load private key."
- err_msg += " Check if password is correct."
- logger.error(err_msg + " (Core function returned None or wrong key type)")
- status.update(label=err_msg, state="error")
- st.error(err_msg)
- else:
- status.write(f"Private key loaded (Algorithm: {kem_alg_key}).")
- status.write("Reading encrypted data...") # Already done by getvalue()
- status.write("Performing decryption and authentication...")
-
- decrypted_data, _detected_alg = core.decrypt_file_pro(
- encrypted_blob,
- priv_key_bytes,
- expected_kem_alg=kem_alg_key,
- )
-
- status.write("Cleaning up...")
- del encrypted_blob
- del priv_key_bytes # Very important!
-
- if decrypted_data is not None:
- status.update(label="Decryption successful!", state="complete")
- st.success("File decrypted successfully!")
-
- # Guess mime type for download
- mime_type, _ = mimetypes.guess_type(output_filename)
- mime_type = mime_type or "application/octet-stream" # Default
-
- st.download_button(
- label=f"Download Decrypted File ({output_filename})",
- data=decrypted_data,
- file_name=output_filename,
- mime=mime_type,
- key="dec_download_button",
- )
- # Clean up decrypted data after download button is prepared
- del decrypted_data
- else:
- err_msg = "Decryption failed."
- logger.error(
- err_msg + " (Core function returned None - check for auth errors in logs)"
- )
- status.update(label=err_msg, state="error")
- st.error(
- err_msg + " Possible reasons: incorrect private key, wrong password, "
- "file corrupted/tampered with."
- )
-
- except Exception as e:
- logger.exception(f"Unhandled exception during decryption action: {e}")
- status.update(label="Decryption Error", state="error")
- st.error("An unexpected error occurred during decryption. Check server logs for details.")
-
-
-# === Key Utilities ===
-elif operation == "Key Utilities":
- st.header("🛠️ Key Utilities")
- st.markdown("Inspect a PEM key file.")
-
- key_util_file = st.file_uploader("Upload Key File (.pem)", type=["pem"], key="util_key_input")
-
- if key_util_file:
- if uploaded_key_file_too_large(key_util_file):
- st.error(
- f"Key file is {format_size(key_util_file.size)}. "
- f"The maximum supported PEM size is {format_size(cfg.MAX_PEM_BYTES)}."
- )
- st.stop()
- try:
- pem_content = key_util_file.getvalue().decode("utf-8")
- key_info = core.inspect_key_pem_strict(pem_content)
- display_info = format_key_info_for_display(key_info)
-
- st.success("Key file parsed successfully.")
- st.markdown("---")
- for label, value in display_info.items():
- st.write(f"**{label}:** `{value}`")
- st.markdown("---")
- st.info(
- "Note: This checks supported key metadata and security policy. "
- "It does not prove the key matches a ciphertext."
- )
-
- except (core.InvalidKeyFormatError, core.UnencryptedPrivateKeyError, core.UnsupportedKDFError) as e:
- logger.warning("Unsupported key file in utility inspector: %s", e)
- st.error("Unsupported or insecure PEM key file.")
- except Exception as e:
- logger.exception(f"Error inspecting key file: {e}")
- st.error("Could not read or parse the key file.")
diff --git a/pyproject.toml b/pyproject.toml
index e0a3aa4..ec2fda5 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -15,7 +15,6 @@ authors = [
dependencies = [
"liboqs-python>=0.14.1,<1.0",
"cryptography>=42.0.0",
- "streamlit>=1.32.0",
"starlette>=1.3.1,<2",
"uvicorn>=0.49.0,<1",
"python-multipart>=0.0.32,<1"
@@ -39,7 +38,7 @@ Issues = "https://github.com/brainx/Quantum-Encryptor/issues"
quantum-encryptor-agent = "pqc_agent_tools:main"
[tool.setuptools]
-py-modules = ["api_app", "crypto_config", "crypto_core", "pqc_agent_tools", "pqc_app", "ui_helpers"]
+py-modules = ["api_app", "crypto_config", "crypto_core", "pqc_agent_tools", "ui_helpers"]
[tool.setuptools.data-files]
"static/app" = ["static/app/index.html"]
diff --git a/requirements-dev-lock.txt b/requirements-dev-lock.txt
index a3c453b..890e129 100644
--- a/requirements-dev-lock.txt
+++ b/requirements-dev-lock.txt
@@ -8,16 +8,10 @@ alabaster==1.0.0 \
--hash=sha256:c00dca57bca26fa62a6d7d0a9fcce65f3e026e9bfe33e9c538fd3fbb2144fd9e \
--hash=sha256:fc6786402dc3fcb2de3cabd5fe455a2db534b371124f1f21de8731783dec828b
# via sphinx
-altair==6.2.2 \
- --hash=sha256:94014f8ad8617c3cb163d1137359cd6db5ba134b9b46d93cfd8b609fd245a583 \
- --hash=sha256:a1ff9d9cfe81c75414641826312b9471780e19d39293ba0b012933f6b6cba0fe
- # via streamlit
anyio==4.14.2 \
--hash=sha256:9f505dda5ac9f0c8309b5e8bd445a8c2bf7246f3ce950121e45ea15bc41d1494 \
--hash=sha256:cfa139f3ed1a23ee8f88a145ddb5ac7605b8bbfd8592baacd7ce3d8bb4313c7f
- # via
- # starlette
- # streamlit
+ # via starlette
ast-serialize==0.6.0 \
--hash=sha256:0067b25fce104eaae5b88383de9ab803faeb671831e14ca698b771b356e2600f \
--hash=sha256:085de7f62dc9cc247eb01e965a362707d1d90b1d89a82c5bf78301a60a3c417b \
@@ -54,12 +48,6 @@ ast-serialize==0.6.0 \
--hash=sha256:e61580a69faf47e3689795367ed211f2a10fd741478cc0f36a0f128793360aad \
--hash=sha256:f2ff3baffc3a29c1f15bc9098aa0c09763410262d5e6cef42116f7356c184554
# via mypy
-attrs==26.1.0 \
- --hash=sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309 \
- --hash=sha256:d03ceb89cb322a8fd706d4fb91940737b6642aa36998fe130a9bc96c985eff32
- # via
- # jsonschema
- # referencing
babel==2.18.0 \
--hash=sha256:b80b99a14bd085fcacfa15c9165f651fbb3406e66cc603abf11c5750937c992d \
--hash=sha256:e2b422b277c2b9a9630c1d7903c2a00d0830c409c59ac8cae9081c92f1aeba35
@@ -97,10 +85,6 @@ black==26.5.1 \
--hash=sha256:ecb3e624844c798144e9bd986954e0adc81d8911a1f30f375e1252fe26e8c294 \
--hash=sha256:ed1a20af114c301a0269bf01163d51dbef72737fd65f850001e7cbe7f3c7abae
# via -r requirements-dev.txt
-blinker==1.9.0 \
- --hash=sha256:b4ce2265a7abece45e7cc896e98dbebe6cead56bcf805a3d23136d145f5445bf \
- --hash=sha256:ba0efaa9080b619ff2f3459d1d500c57bddea4a6b424b60a91141db6fd2f08bc
- # via streamlit
boolean-py==5.0 \
--hash=sha256:60cbc4bad079753721d32649545505362c754e121570ada4658b852a3a318d95 \
--hash=sha256:ef28a70bd43115208441b53a045d1549e2f0ec6e3d08a9d142cbc41c1938e8d9
@@ -117,10 +101,6 @@ cachecontrol[filecache]==0.14.4 \
# via
# cachecontrol
# pip-audit
-cachetools==7.1.4 \
- --hash=sha256:323dc4127934744db5b54eb4924482d7edafbf9554e820d1531c2e08c0e4ef54 \
- --hash=sha256:437f55a4e0c1b01a4f3077cc470e6991d47430970e36fbcb77e2be0df4fc1cd6
- # via streamlit
certifi==2026.6.17 \
--hash=sha256:024c88eeec92ca068db80f02b8b07c9cef7b9fe261d1d535abfd5abd6f6af432 \
--hash=sha256:2227dcbaafe0d2f59279d1762ddddc37783ed4354594f194ffc31d20f41fc3db
@@ -328,7 +308,6 @@ click==8.4.2 \
# via
# black
# pip-tools
- # streamlit
# uvicorn
coverage[toml]==7.15.2 \
--hash=sha256:075560438765b7a2ef43bf7aa7758661b53d889df47f062a31bda6c1ade553a2 \
@@ -470,7 +449,9 @@ cryptography==49.0.0 \
--hash=sha256:f78ff2c9ed8dc2d036b0f4d640e22522213d047c1b14e61205a7e55c80a494d4 \
--hash=sha256:f89660a348f4f78a92366240a61404e337586ef7f5909a2fef59ca88ef505493 \
--hash=sha256:fc1e275c2f1d97b1a6450b8b0ea3ebfa6e087a611c2b26cb2404d48588abab7b
- # via -r requirements.txt
+ # via
+ # -r requirements.txt
+ # secretstorage
cyclonedx-python-lib==11.11.0 \
--hash=sha256:3049fc83e06a059b5c5907a527625a8ed5073caab10607ed4c9e5503b590fd44 \
--hash=sha256:4b3194db72b613717f2912447e67ab618c75ff7dcac6c4af3c0e9e1ac617c102
@@ -495,14 +476,6 @@ flake8==7.3.0 \
--hash=sha256:b9696257b9ce8beb888cdbe31cf885c90d31928fe202be0889a7cdafad32f01e \
--hash=sha256:fe044858146b9fc69b551a4b490d69cf960fcb78ad1edcb84e7fbb1b4a8e3872
# via -r requirements-dev.txt
-gitdb==4.0.12 \
- --hash=sha256:5ef71f855d191a3326fcfbc0d5da835f26b13fbcba60c32c21091c349ffdb571 \
- --hash=sha256:67073e15955400952c6565cc3e707c554a4eea2e428946f7a4c162fab9bd9bcf
- # via gitpython
-gitpython==3.1.52 \
- --hash=sha256:79a36ee1f83523214a3f72d56cf1c4e490d577dc61af77e43dfe5862bd9da01a \
- --hash=sha256:de0a8ad86274c6e75ae8b37dd055ba68f19818c813108642263227b20775b48e
- # via streamlit
h11==0.16.0 \
--hash=sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1 \
--hash=sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86
@@ -558,9 +531,7 @@ httptools==0.8.0 \
--hash=sha256:ef7c3c97f4311c7be57e2986629df89d49cb434dbff78eafcd48c2bff986b15a \
--hash=sha256:f256d6ce930c52ca1cb2a960b7da03548c454e7d28b06059ad41bfe789036ce0 \
--hash=sha256:fe2a4c95aeba2209434e7b31172da572846cae8ca0bf1e7013e61b99fbbf5e72
- # via
- # -r requirements.txt
- # streamlit
+ # via -r requirements.txt
id==1.6.1 \
--hash=sha256:d0732d624fb46fd4e7bc4e5152f00214450953b9e772c182c1c22964def1a069 \
--hash=sha256:f5ec41ed2629a508f5d0988eda142e190c9c6da971100612c4de9ad9f9b237ca
@@ -579,10 +550,6 @@ iniconfig==2.3.0 \
--hash=sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730 \
--hash=sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12
# via pytest
-itsdangerous==2.2.0 \
- --hash=sha256:c6242fc49e35958c8b15141343aa660db5fc54d4f13a1db01a3f5891b98700ef \
- --hash=sha256:e0050c0b7da1eea53ffaf149c0cfbb5c6e2e2b69c4bef22c81fa6eb73e5f6173
- # via streamlit
jaraco-classes==3.4.0 \
--hash=sha256:47a024b51d0239c0dd8c8540c6c7f484be3b8fcf0b2d85c13825780d3b3f3acd \
--hash=sha256:f662826b6bed8cace05e7ff873ce0f9283b5c924470fe664fff1c2f00f581790
@@ -604,24 +571,14 @@ jeepney==0.9.0 ; sys_platform == "linux" \
jinja2==3.1.6 \
--hash=sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d \
--hash=sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67
- # via
- # altair
- # pydeck
- # sphinx
-jsonschema==4.26.0 \
- --hash=sha256:0c26707e2efad8aa1bfc5b7ce170f3fccc2e4918ff85989ba9ffa9facb2be326 \
- --hash=sha256:d489f15263b8d200f8387e64b4c3a75f06629559fb73deb8fdfb525f2dab50ce
- # via altair
-jsonschema-specifications==2025.9.1 \
- --hash=sha256:98802fee3a11ee76ecaca44429fda8a41bff98b00a0f2838151b113f210cc6fe \
- --hash=sha256:b540987f239e745613c7a9176f3edb72b832a4ac465cf02712288397832b5e8d
- # via jsonschema
+ # via sphinx
keyring==25.7.0 \
--hash=sha256:be4a0b195f149690c166e850609a477c532ddbfbaed96a404d4e43f8d5e2689f \
--hash=sha256:fe01bd85eb3f8fb3dd0405defdeac9a5b4f6f0439edbb3149577f244a2e8245b
# via twine
-liboqs-python==0.15.0 \
- --hash=sha256:969b27c65c99f973f8c991472151331814c9f02642115fe4d6391bcdc33433a4
+liboqs-python==0.16.0 \
+ --hash=sha256:50bb11a9767b88f68099cedef8de09f798f31eaba1aa18b483988bfcd50df440 \
+ --hash=sha256:ee01445ccfbeab8dced956a642ce1eb5ba69438caba7ec291eea14083627d5c0
# via -r requirements.txt
librt==0.13.0 \
--hash=sha256:05d96b80b95d3a2721b619f8982b8558848b04875bb4772fd54842b59f61dd97 \
@@ -951,10 +908,6 @@ mypy-extensions==1.1.0 \
# via
# black
# mypy
-narwhals==2.24.0 \
- --hash=sha256:42fdedf44e5b2ca7505630d45b4ac3058f38d8485cba9fe1652ca23152df7489 \
- --hash=sha256:b5c0f684ccd9d7475b564111e319a4964abcf2baf79d3cf6b1003d06ac9b828d
- # via altair
nh3==0.3.6 \
--hash=sha256:082675ff87b9385ec430ffe6d5847ba7456cc39b73720cd4add472f9f4cffd56 \
--hash=sha256:2411e8c3cee81a1ddd62c2a5d50585c28aa5566d373ad1db92536b95ddb24ef2 \
@@ -984,83 +937,6 @@ nh3==0.3.6 \
--hash=sha256:f3736c9dd3d1856f80cd031715b84ca75cda2bbb1ac802c3da26bfce590838d7 \
--hash=sha256:f5ed5fe84aee7f39db95c214a7421bf0499fbf500fec6d86a4e29bfc37971438
# via readme-renderer
-numpy==2.4.6 \
- --hash=sha256:001fbb8e08d942dd57599e781f2472269ee7f2755fae407b4f67b2f0b17da3f1 \
- --hash=sha256:0280e0356c0829a18d9de1cb7eee50ec22ca639878d7240307ca0943d73cd2c4 \
- --hash=sha256:043191bfa8eab18c776647b62723ac9dddece59743b13f49b2016094129c2b3f \
- --hash=sha256:06ca2f61ec4385a07a6977c55ba998a4466c123642b4a32694d3128fce18c079 \
- --hash=sha256:0a041d3d761dc3c35cc56ce0351506a02bcbc25f7b169f652435141a17db9096 \
- --hash=sha256:0ab0a9c4ffb1a6d95ef519fe4247dba8eb6b18ad93999f76b7f657039acabd47 \
- --hash=sha256:0c9136e14ed34a9e343a31c533d78a9813a69a3148332bce5e9821cb2f996e66 \
- --hash=sha256:110f8b71aacb688ec69062bb7f6938a0f8acb01b7c1c4beb453c65b6d234584d \
- --hash=sha256:112b06a867b235ef466ed3508ddf0238050df9c727cafb5301ac385b899189a1 \
- --hash=sha256:17f9ade344e7d9b464a084d69bcf18fc691cb1db67c62ed80820bf4926d78f0e \
- --hash=sha256:1e254a00cdf42b1e4d5b3d68d33af63268d41340d8885df2ab6470f2e1500147 \
- --hash=sha256:1e978ec1e8bd0e0e4de6bb75de9d30cbb74db6b6a2bb727618613703ca0167dd \
- --hash=sha256:25c692919ac5a01f170a3bfcd62d745b24fd095c353d50812637d6fcab442e75 \
- --hash=sha256:260a5d70215b61ab4fadf5c7baacd64821842975eea312125ed3c39a6391b063 \
- --hash=sha256:2803abfebfc990042cd494d8ce2d5f82e9d847af6d35ec486923aa19dbad5e73 \
- --hash=sha256:29a287e0cf63ff528da061de6b9f64a4618da591ca1046aafc54062e40ca7eab \
- --hash=sha256:29cb7f67d10b479ff07c17d33e39f78c07f71c40ef30d63c153d340e96cd3fb4 \
- --hash=sha256:3213d622a0283a39a93d188f3cf72b26862df52fbb4ca3697f51705016523d41 \
- --hash=sha256:33111801a01c12a8a1e3721f0a9232f8cfc8ae2c6b7098167e6f623c6073f402 \
- --hash=sha256:357cc07a6d7b0b182ff02249616a03742827ebb1277546b5c7cd7f7620a45698 \
- --hash=sha256:38efbc8de75c7a0fc1ac190162d892787f3f47b57cc291231aafee36b80982b7 \
- --hash=sha256:4081eb135ac24158bd51cdfbef16f1c64df7063b1143f24731387137c092bec8 \
- --hash=sha256:40fdc1ae7125e518ea98e53e69a4ebc27e1fd50510c47b7ea130cf21e5e1d42b \
- --hash=sha256:4cfe66903cc32a9921a6733d96b19bb6abf310397581bbad89c228f5abaf0ee8 \
- --hash=sha256:511dbaf848decaaaf4b4ca48032619fb3138710c4bf7da7617765edad1ef96b0 \
- --hash=sha256:55cced7c52e981362f708ad635198e97a752dfba412cc03c23bbf3bd8d5cd662 \
- --hash=sha256:56b39e5e0622a09a25bf5baf62f4bcf0cb8a41ae6e2819cf49bbc5a74c083f91 \
- --hash=sha256:5dbbdb29840ca3d91ee0fece42fc29278886d908280bfec0a5846c6f901a3eb0 \
- --hash=sha256:5f9fb9157b4ce2971008323afe46053787b526ef624fea915b261468a8421a0f \
- --hash=sha256:6180d8b35af935aed8ece3a85e0a43f87393ae0ac87c8d2c8bd2c993f7270ef3 \
- --hash=sha256:68a5124b13fa6cc2086764a20005d30bc0548146f7f5322f02fce212ca14317f \
- --hash=sha256:68bb27509ac1b9a3443094260f6326150663b06abe40b73a2f81160623da5b67 \
- --hash=sha256:6f41ae150c4e32db4f3310cdaf64b1593a03dbabe29eec77fc9b50fe64061df6 \
- --hash=sha256:7265a2f3d436e54ef9f2b52b5c937e6be778781bd97a590319d7348f1c1ca997 \
- --hash=sha256:72fbe16c6fac95aedf5937fa873445cec2110be35d8a4e9433d7501fd98dae6b \
- --hash=sha256:7d92c3819208a60205a12a245c91ad70cb0a85336659b19b834205573ac8456e \
- --hash=sha256:8155154c7c691289fe18f510b5d4657c68c67989f293f0535a91360392ff6538 \
- --hash=sha256:81a1cca95ed5bb92aa8b10dd2cdc9a0d3853a50fad926c28b5d7e8ea54389627 \
- --hash=sha256:89cd468399cfd2504718f0ba50e410dca55a170b61a02ad92bb18c8a65186e93 \
- --hash=sha256:8ad03c0965fb3c692200e74d458ca28c1dbb4ce96f9a479a8aa041ad5fabca02 \
- --hash=sha256:90f9849678c75fe7afa2d348ac842c168b0a4d3d61919687216dfc547976d853 \
- --hash=sha256:948424b06129ce883307e8cff868c31396d8dc7630a59c61d70d98dbe70f222c \
- --hash=sha256:9cd5ffd25db4e7ba6a375693b3fc0fc1791ec636c17db3720da19bde7180ec43 \
- --hash=sha256:a0df0043bdb289bde1f62da130d20df23d58b45429f752bc7a8fc5325a225ecd \
- --hash=sha256:a2c306dea656c12c68f51f4cea133cbe78ca7435eb28c735eac1d3ebe73be6e8 \
- --hash=sha256:a7830bab239b79cda9c08c2da014761cafb48da6150e1da17ac06283f43b6089 \
- --hash=sha256:a7c711e21628b52034bb5ab8d1bce291f752fcc5e92accc615778acee1ff4778 \
- --hash=sha256:aaf159caa35993cb1f56fb9b8e4610d35758e7ca005412eb1daa856a78c9c4b1 \
- --hash=sha256:ae506e6902902557576a26ff33eda8695e7ecb3cb36c3b573a0765dee114ebdb \
- --hash=sha256:b507f5c4c1d508876d1819b6bf9a49d365b96320b5d4993426b33a23ca4b8261 \
- --hash=sha256:bf162abab1c1a736333192707cef898e735a5ca00f38f27eeedf44b39d9e85eb \
- --hash=sha256:c1a2af6c6ef86344a6b0db6b97834208bf598db514f2b155042439b62605601a \
- --hash=sha256:c2d37ab77531417474168eb79d6d80b14f821a966818505d03013d0833edb7a8 \
- --hash=sha256:c4fc99836233ea196540b17ab0983aff60ed07941751930f5f4d05bc3b3b7359 \
- --hash=sha256:d581b735e177fdcdce6fed8e7e8880a3fb6ee4e3653a3ac6af01c6f4c03effc5 \
- --hash=sha256:d6da64deb6b8ed903e7560180a92f2d804ee1ba5eeb849ac2748b8c1aba1f6d7 \
- --hash=sha256:d8e8286dd7cea7895157318d1b91cdacac64c479f3cbc8dce548331728484751 \
- --hash=sha256:ddea102b48f9e339f3948bf22040944184627a30fdf7f858667673b9c5f033c8 \
- --hash=sha256:dfa20cc6ca228e6b155b11da03825975ce66aea520985dbbddf0f2a5a495c605 \
- --hash=sha256:e3e5193ef5a3dc73bceee50f7fdc2c90dbb76c42df8d8fae3d1067a583df579e \
- --hash=sha256:e3eeb0aabd6bd5ce64faae67e9935203a6991b4bc2a485a767fbafb2c5125f45 \
- --hash=sha256:e5805d5a22fd19c8ccff10a9561f9df94436b0545619ea579db2d3c35294bce2 \
- --hash=sha256:e85b752a1e912b70eaad4fafbd4d1238007ab221de2009b9a2f5ae7461239895 \
- --hash=sha256:eaf7fa2de5c0be8ae6ff8e9bea2ccd725e980541244521d8d4b5f3354a27babe \
- --hash=sha256:ebfb099f8dcf083deef3ac1ca4c1503f387cf76296fcb3816b66f5ecb5f54fdb \
- --hash=sha256:ece3d2cfe132e7d51f44a832b303895e6f2d499c5e74dfbdb06ee246147a304a \
- --hash=sha256:ed9749eef4cbd126da3dc1d6bcb3a57f5eb7ac6a6484146bdbf743f552dfc577 \
- --hash=sha256:ede83e07a75dd06bc501566c1eca2afc0d61677c1472ac9ad93fdee6e638a48d \
- --hash=sha256:ef4aea96ce4d3b074422cb4f2f64e216bf9e213004bb58ecfdf50ea02ea8eb9a \
- --hash=sha256:f3a3570c4a2a16746ac2c31a7c7c7b0c186b95ce902e33db6f28094ed7387dda \
- --hash=sha256:f407cb6b8e9d6d8c626bc73c945db1706035af8fd632295547bf1c9e46d092d6 \
- --hash=sha256:f74a575920ab21fe304421a3fc28793d82e299cae9eccb37084e9fc7f3617c20
- # via
- # pandas
- # pydeck
- # streamlit
packageurl-python==0.17.6 \
--hash=sha256:1252ce3a102372ca6f86eb968e16f9014c4ba511c5c37d95a7f023e2ca6e5c25 \
--hash=sha256:31a85c2717bc41dd818f3c62908685ff9eebcb68588213745b14a6ee9e7df7c9
@@ -1069,161 +945,20 @@ packaging==26.2 \
--hash=sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e \
--hash=sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661
# via
- # altair
# black
# build
# pip-audit
# pip-requirements-parser
# pytest
# sphinx
- # streamlit
# twine
# wheel
-pandas==3.0.3 \
- --hash=sha256:0383c72c75cdcca61a9e116e611143902dbfd08bff356829c2f6d1cf40a9ca8c \
- --hash=sha256:05f1f1752b8533ea03f7f39a9c15b1a058d067bb48f4748948e7a8691e0510f2 \
- --hash=sha256:08d789b41f87e0905880e293cedf6197ce71fe67cc081358b1e148a491b9bd13 \
- --hash=sha256:0d589105b3c14645af1738ff279b2995102d8f7a03b0a66dc8d95550eb513e04 \
- --hash=sha256:13fc1e853d9e04743d11ba75a985ccbc2a317fe07d8af61e445a6fd24dacd6a6 \
- --hash=sha256:14da8316da4d0c5a77618425996bfb1248ca87fc2c1486e6fde4652bd18b5824 \
- --hash=sha256:1928e07221f82db493cd4af1e23c1bfca524a19a4699887975bff68f49a72bfb \
- --hash=sha256:261e308dfb22448384b7580cf719d2f998fe2966c92893c3e77d14008af1f066 \
- --hash=sha256:275c14e0fce14a2ec20eee474aecd305478ea3c1e6f6a9d8fe219a165542717e \
- --hash=sha256:335f62418ed562cfc3c49e9e196375c28b729dcef8543abf4f9438e381bf3c76 \
- --hash=sha256:3650109c0f22879df8bd6179ab9ee3d7f1d1d4e7e0094a3f0032d9f51e2e64ac \
- --hash=sha256:39436b377d56d2a2e52d0395bdbee171f01068e99af5250509aceeb929f765c7 \
- --hash=sha256:3c20a521bbb85902f79f7270c80a59e1b5452d96d170c034f207181870f97ac5 \
- --hash=sha256:3e91cec1879ada0624fc3dc9953c5cbd60208e59c0db28f540c5d6d47502422f \
- --hash=sha256:455f6f8139d4282188f526868dbc3c828470e88a3d9d59a891bd46a455f21b98 \
- --hash=sha256:46997386d528eb40376ecd6b033cf4a8a1e5282580f68f43de875b78cba2199d \
- --hash=sha256:4db8c527972a821cf5286b40ccc57642a39bc62e62022b42f99f8a67fca8c3a1 \
- --hash=sha256:4e15135e2ee5df1063313e2425ceef8ac0f4ae775893815b0923651b806a5639 \
- --hash=sha256:51b1fe551acb77dac643c6fda86084d8d446c10fe64b06a9cc29c4cc8540e7f2 \
- --hash=sha256:557409bc4178e70ee8d9ddb494798e51ebf6ea59330f6be22c51bab2a7db6c49 \
- --hash=sha256:5cc09a68b3120e0f54870dede8287a7bb1fa463907e4fcec1ea77cab6179bf7a \
- --hash=sha256:60ae316d3fd75d1858d450d0db0103ea2be3e7d4a95ec2f064f7e2ae63f7b028 \
- --hash=sha256:6674ab18ad8c57802867264b00e15e7bb904700cdd9046e3b2fa1fce237439ea \
- --hash=sha256:67b3b64c11910cfa29f4e94a14d3bff9ee693b6fc76055e7cad549cee0aec5fa \
- --hash=sha256:696a4a00a2a2a35d4e5deb3fc946641b96c944f02230e4f76137fe35d806c4fc \
- --hash=sha256:6dc0b3fd2169c9157deed50b4d519553a3655c8c6a96027136d654592be973a9 \
- --hash=sha256:7e65d5407dc0b394f509699650e4a2ec01c0514f21850f453fa60f3be79a5dbf \
- --hash=sha256:819959dab7bbd0049c15623fbac4e29a191b9528160a61fb1032242d8ced2d9c \
- --hash=sha256:8a1e45c80cceb3b4a21bc5939d52e8cbd8d9b7305309219d59e9754d9ce09e27 \
- --hash=sha256:9c39be2d709d01fa972a0cabc522389fceca4f3969332ba25a7d6c5802cf976a \
- --hash=sha256:9d71c63ae4ebdbf70209742096f1fc46a83a0613c99d4b23766cced9ff8cd62a \
- --hash=sha256:a2d2dff8a04f3917b55ab3910c32990f8ddf7eceba114947838cefa976a68977 \
- --hash=sha256:a4eeb6830daf35a71cc09649bd823e2b542dac246cdee9614c6e4bd65028cd6a \
- --hash=sha256:a55066a0505dae0ba2b50a46637db34b46f9094c65c5d4800794ef6335010938 \
- --hash=sha256:a82d532a3351d435432cd913edbccaf8b8e01d4dd0e5ced5a8d2e8ecd94c7e44 \
- --hash=sha256:b168fc218fd80a6cbdbdbc1a97ddc7889ed057d7eb45f50d866ceab5f39904c4 \
- --hash=sha256:b2c95f8bfc1ee412bf482605d7bfd30c12d1d26bd59fdd91efeef1d4718decb1 \
- --hash=sha256:ba7e08b9ac1d54569cd1e256e3668975ed624d6826f7b68df0342b012007bddb \
- --hash=sha256:bab900348131a7db1f69a7309ef141fd5680f1487094193bcbbb61791573bf8f \
- --hash=sha256:bd3a518890b400d32f9023722dc9a9a5c969f00b415419a3c06c043f09bb5d7d \
- --hash=sha256:c7be265b62cef88e253a941e4698604973736dcfe242fdb5198f0f7bc473cdcc \
- --hash=sha256:d26cbe1fcfc12e8fd900e2454163e466b2d3af84f7c75481df7683ffc073d870 \
- --hash=sha256:d4be06d68f9ddcfc645b87534911da79a8fbffc7573c80e0edcf42a5020624d8 \
- --hash=sha256:d72828c20c6d6e83e1e22a6a3b47b326b71664112fa9705dcbccfd7a39b62085 \
- --hash=sha256:dd1a5d1def6a46002e964510bdc67c368aa0951df5d1d9f8365336f5a1f490cd \
- --hash=sha256:e3a2ec42c98ffa2565a67e08e218d06d72576d758d90facb7c00805194d8f360 \
- --hash=sha256:f8894dc474d648fe7b6ff0ca9b0bd73950d19952bc1a6534540762c5d79d305c \
- --hash=sha256:fed2ff7fd9779120e388e285fc029bd5cf9490cdd2e4166a9ee22c0e49a9ab09
- # via streamlit
pathspec==1.1.1 \
--hash=sha256:17db5ecd524104a120e173814c90367a96a98d07c45b2e10c2f3919fff91bf5a \
--hash=sha256:a00ce642f577bf7f473932318056212bc4f8bfdf53128c78bbd5af0b9b20b189
# via
# black
# mypy
-pillow==12.3.0 \
- --hash=sha256:00808c5e14ef63ac5161091d242999076604ff74b883423a11e5d7bbb38bf756 \
- --hash=sha256:04f01d28a6aaff387bf842a13be313df23ba0597a44f1a976c9feb3c6ff4711a \
- --hash=sha256:06ff022112bc9cbf83b60f8e028d94ad87b60621706487e65f673de61610ab59 \
- --hash=sha256:0740a512dc522224c77d9aa5a8d70d8b7d73fb91f2c21125d8d025d3b8990e45 \
- --hash=sha256:0847a763afefb695bc912d7c131e7e0632d4edc1d8698f58ddabec8e46b8b6d3 \
- --hash=sha256:0dd2064cbc55aaec028ef5fbb60fa47bb6c3e7918e07ff17935284b227a9d2df \
- --hash=sha256:0feb2e9d6ad6c9e3c06effe9d00f3f1e618a6643273576b016f591e9315a7139 \
- --hash=sha256:10e41f0fbf1eec8cfd234b8fe17a4caac7c9d0db4c204d3c173a8f9f6ef3232b \
- --hash=sha256:1182d52bc2d5e5d7d0949503aa7e36d12f42205dc287e4883f407b1988820d39 \
- --hash=sha256:164b31cd1a0490ab6efae01aa5df49da7061be0af1b30e035b6e9a1bfe34ee6e \
- --hash=sha256:1657923d2d45afb66526e5b933e5b3052e6bdea196c90d3abb2424e18c77dae8 \
- --hash=sha256:186941b6aef820ad110fb01fb06eb925374dc3a21b17e37ec9a53b250c6fe2d1 \
- --hash=sha256:1cca606cd25738df4ed873d5ad46bbdb3d83b5cbca291f6b4ff13a4df6b0bbe8 \
- --hash=sha256:21900ce7ba264168cd50defae43cd75d25c833ad4ad6e73ffc5596d12e25ac89 \
- --hash=sha256:236ff70b9312fb68943c703aa842ca6a758abfa45ac187a5e7c1452e96ef72b5 \
- --hash=sha256:23aceaa007d6172b02c277f0cd359c79492bbb14f7072b4ede9fbcaf20648130 \
- --hash=sha256:23d27a3e0307ec2244cc51e7287b919aa68d097504ebe19df4e76a98a3eea5bd \
- --hash=sha256:24870b09b224f7ae3c39ed07d10e819d06f8720bc551847b1d623832b5b0e28d \
- --hash=sha256:251bf95b67017e27b13d82f5b326234ca62d70f9cf4c2b9032de2358a3b12c7b \
- --hash=sha256:25b9b82bb22e6e2b3cd07b39c68b7b862001226cb3dff7130d1cb914121b39ed \
- --hash=sha256:28ce87c5ab450a9dd970b52e5aca5fe63ed432d18a2eaddd1979a00a1ba24ace \
- --hash=sha256:300557495eb45ebb8aec96c2da9c4be642fbf7cd937278b4013ba894ea8eb0eb \
- --hash=sha256:30f2aa603c41533cc25c05acd0da21636e84a315768feb631c937177db558931 \
- --hash=sha256:331b624368d4f1d069149002f25f44bc61c8919ce8ddb3c45bdad8f6e2d89510 \
- --hash=sha256:37d6d0a00072fd2948eb22bce7e1475f34569d90c87c59f7a2ec59541b77f7a6 \
- --hash=sha256:37dc8f7bbb66efe481bb60defacef820c950c24713fb44962ed6aa2a50966de1 \
- --hash=sha256:3b8182a766685eaa002637e28b4ec8d6b18819a0c71f579bf0dbaa5830297cce \
- --hash=sha256:3edce1d53195db527e0191f84b71d02022de0540bf43a16ed734ed7537b07385 \
- --hash=sha256:446c34dcc4324b084a53b705127dc15717b22c5e140ae0a3c38349d4efec071e \
- --hash=sha256:4998562bf62a445225f22e07c896bb04b35b1b1f2eb6d760584c9c51d7a5f78c \
- --hash=sha256:4b0a7fe987b14c31ebda6083f74f22b561fd3739bc0ac51e019622e3d72668c7 \
- --hash=sha256:4e8c2a84d977f50b9daed6eeaf3baef67d00d5d74d932288f02cb94518ee3ace \
- --hash=sha256:4f883547d4b7f0495ebe7056b0cc2aea76094e7a4abc8e933540f3271df27d9c \
- --hash=sha256:514435a37670e3e5e08f3945b68718b6ed329bb84367777e16f9f4dfe1e61a0f \
- --hash=sha256:53aa02d20d10c3d814d536aa4e5ac9b84ca0ff5a88377963b085ad6822f93e64 \
- --hash=sha256:5594fc43d548a7ed94949d139aa1341b270f1863f11cfd37f5a6c8b778a6b67f \
- --hash=sha256:571b9fcb07b97ef3a492028fb3d2dc0993ca23a06138b0315286566d29ef718a \
- --hash=sha256:57b3d78c95ba9059768b10e28b813002261d3f3dfc55cc48b0c988f625175827 \
- --hash=sha256:5afb51d599ea772b8365ae807ae557f18bccfe46ab261fd1c2a9ed700fc6eb17 \
- --hash=sha256:6b02afb9b97f65fbca5f31db6a2a3ba21aa93030225f150fa3f249717e938fb4 \
- --hash=sha256:6c0016e7b354317c4e9e525b937ac8596c38d2d232b419529b9cd7a1cd46e39a \
- --hash=sha256:71d6097b330eea8fd15097780c8e89cb1a8ce7838669f48c5bacd6f663dd4701 \
- --hash=sha256:756c768d0c9c2955feb7a56c37ea24aea2e369f8d36a88da270b6a9f19e62b5e \
- --hash=sha256:78cb2c6865a35ab8ff8b75fd122f6033b92a62c82801110e48ddd6c936a45d91 \
- --hash=sha256:7a743ff716f746fc19a9557f60dab1600d4613255f8a7aeb3cdde4db7eb15a66 \
- --hash=sha256:85f998ea1848bc6757289e739cfbdda3a04adfd58b02fc018ce54d754a5ce468 \
- --hash=sha256:8728f216dcdb6e6d555cf971cb34076139ad74b31fc2c14da4fafc741c5f6217 \
- --hash=sha256:877c3f311ff35410f690861c4409e7ccbf0cd2f878e50628a28e5a0bb689e658 \
- --hash=sha256:8cd2f7bdda092d99c9fc2fb7391354f306d01443d22785d0cbfafa2e2c8bb418 \
- --hash=sha256:8e95e1385e4998ae9694eeaa4730ba5457ff61185b3a55e2e7bea0880aef452a \
- --hash=sha256:962864dc93511324d51ddbb5b9f8731bf71675b93ca612a07441896f4688fb8c \
- --hash=sha256:9cf95fe4d0f84c82d282745d9bb08ad9f926efa00be4697e767b814ce40d4330 \
- --hash=sha256:9e881fca225083806662a5c43d627d215f258ff43c890f831966c7d7ba9c7402 \
- --hash=sha256:a2b55dd6b2a4c4b7d87ffa56bdb33fdc5fdb9a462173861a7bc097f17d91cb09 \
- --hash=sha256:a45650e8ce7fafffd731db8550230db6b0d306d181a90b67d3e6bca2f1990930 \
- --hash=sha256:a876864214e136f0eb367788dbd7df045f4806801518e2cfe9e13229cfe06d8f \
- --hash=sha256:ae26d61dfa7a47befdc7572b521024e8745f3d809bd95ca9505a7bba9ef849ec \
- --hash=sha256:af8d94b0db561cf68b88a267c5c44b49e134f525d0dc2cb7ed413a66bc23559a \
- --hash=sha256:b343699e8308bdc51978310e1c959c584e7869cc8c40780058c87da7781a1e94 \
- --hash=sha256:b3c777e849237620b022f7f297dd67705f9f5cf1685f09f02e46f93e92725468 \
- --hash=sha256:b629de27fda84b42cde7edef0d85f13b958b47f6e9bbcbba9b673c562a89bd8b \
- --hash=sha256:ba09209fbe443b4acccebe845d8a138b89a8f4fbaeedd44953490b5315d5e965 \
- --hash=sha256:ba54cfebe86920a559a7c4d6b9050791c20513650a1952ebe3368c7dc70306f8 \
- --hash=sha256:bcb46e2f9feff8d06323983bd83ed00c201fdcab3d74973e7072a889b3979fcd \
- --hash=sha256:bcc33feacfaefce60c12fd500a277533bdc02b10a19f7f6d348763d8140bbba7 \
- --hash=sha256:bf16ba1b4d0b6b7c8e534936632270cf70eb00dbe09005bc345b2677b726855c \
- --hash=sha256:cf1845d02ad822a369a49f2bb9345b1614744267682e7a03527dc3bf6eea1777 \
- --hash=sha256:d69141514cc30b774ceea5e3ed3a6635c8d8a96edf664689b890f4089111fb35 \
- --hash=sha256:d9c7f76c0673154f044e9d78c8655fb4213f6ca31a836df48b40fe5d187717b9 \
- --hash=sha256:dbce0b29841537a2fa4a214c2bbf14de3587c9680caa9b4e217568472490b28f \
- --hash=sha256:dc624f6bc473dacdf7ef7eb8678d0d08edf15cd94fad6ae5c7d6cc67a4e4902f \
- --hash=sha256:e158cb00350dc278f3b91551101aa7d12415a66ebf2c91d8d5ac14e56ddd3ad0 \
- --hash=sha256:e491916b378fba47242221bb9ead245211b70d504f495d105d17b14a24b4907c \
- --hash=sha256:e795b7eb908249c4e43c7c99fac7c2c75dab0c43566e37db472a355f63693d71 \
- --hash=sha256:e7e480451b9fa137494bccd3a7d69adbe8ac65a87d97be61e11f1b1050a5bac3 \
- --hash=sha256:e91206ee562682b51b98ef4b26a6ef48fd84e15fd4c4bc5ec768eb641d206838 \
- --hash=sha256:e9871b1ffbfa9656b60aeee92ed5136a5742696006fa322b29ea3d8da0ecc9cf \
- --hash=sha256:e9aeb04d6aef139de265b29683e119b638208f88cf73cdd1658aa07221165321 \
- --hash=sha256:ebaea975e03d3141d9d3a507df75c9b3ec90fa9d2ffd07567b3a978d9d790b26 \
- --hash=sha256:f0606c8bf2cdefea14a43530f7657cbbb7ecf1c4222512492ef4a4434a9501ec \
- --hash=sha256:f13c32a3abd6079a66d9526e18dad9b6d280384d49d7c54040cd57b6424041d9 \
- --hash=sha256:f7401aebd7f581d7f83a439d87d474999317ee099218e5ad25d125290990ba65 \
- --hash=sha256:fa4ecea169a355be7a3ade2c783e2ed12f0e40d2c5621cda8b3297faf7fbb9f5 \
- --hash=sha256:fbd139c8447d25dd750ab79ee274cc5e1fe80fc56340ab10b18a195e1b6eca3e \
- --hash=sha256:fdafc9cce40277e0f7a0feabce0ee50dd2fa1800f3b38015e51296b5e814048d \
- --hash=sha256:fe3cca2e4e8a592be0f269a1ca4835c25199d9f3ce815c8491048f785b0a0198 \
- --hash=sha256:ffd0c5368496f41b0944be820fcb7a838aa6e623d250b01acf2643939c3f99d7
- # via streamlit
pip-api==0.0.34 \
--hash=sha256:8b2d7d7c37f2447373aa2cf8b1f60a2f2b27a84e1e9e0294a3f6ef10eb3ba6bb \
--hash=sha256:9b75e958f14c5a2614bae415f2adf7eeb54d50a2cfbe7e24fd4826471bac3625
@@ -1252,72 +987,10 @@ pluggy==1.6.0 \
# via
# pytest
# pytest-cov
-protobuf==7.35.1 \
- --hash=sha256:11d6b0ec246892d85215b0a13ca6e0233cf5284b68f0ac02646427f4ff88a799 \
- --hash=sha256:230a75ddfc2de4806e56696ce9640c1cdfdb6543b7cfce98d42a4c0a0e7bdb87 \
- --hash=sha256:24f857477359a85c0c235261b8ba905fd51b2562f4a64ca1df5473f29850cbf6 \
- --hash=sha256:353652e4efd0bca5b5fc2656abf8307ef351f0cf938c9eba09f0e09c20a25c30 \
- --hash=sha256:4bc97768d8fe4ad6743c8a19403e314511ed9f6d13205b687e52421c023ac1b9 \
- --hash=sha256:74758715c53d7158fb76caf4f0cfdacc5329a4b1bb994f865d6cf302d413a1c4 \
- --hash=sha256:b73f9489a4b8b1c9cb1f8ed951c736392592edb24b9d6819f36d2e10b171d5b4 \
- --hash=sha256:ce115a26fe0c39a2c29973d914d327e516a6455464489fe3cd1e51a1b354f81a
- # via streamlit
py-serializable==2.1.0 \
--hash=sha256:9d5db56154a867a9b897c0163b33a793c804c80cee984116d02d49e4578fc103 \
--hash=sha256:b56d5d686b5a03ba4f4db5e769dc32336e142fc3bd4d68a8c25579ebb0a67304
# via cyclonedx-python-lib
-pyarrow==24.0.0 \
- --hash=sha256:02b001b3ed4723caa44f6cd1af2d5c86aa2cf9971dacc2ffa55b21237713dfba \
- --hash=sha256:04920d6a71aabd08a0417709efce97d45ea8e6fb733d9ca9ecffb13c67839f68 \
- --hash=sha256:0b3537c00fb8d384f15ac1e79b6eb6db04a16514c8c1d22e59a9b95c8ba42868 \
- --hash=sha256:1183baeb14c5f587b1ec52831e665718ce632caab84b7cd6b85fd44f96114495 \
- --hash=sha256:14e31a3c9e35f1ab6356c6378f6f72830e6d2d5f1791df3774a7b097d18a6a1e \
- --hash=sha256:1617043b99bd33e5318ae18eb2919af09c71322ef1ca46566cdafc6e6712fb66 \
- --hash=sha256:1a4e45017efbf115032e4475ee876d525e0e36c742214fbe405332480ecd6275 \
- --hash=sha256:1b2fe7f9a5566401a0ef2571f197eb92358925c1f0c8dba305d6e43ea0871bb3 \
- --hash=sha256:1cc9057f0319e26333b357e17f3c2c022f1a83739b48a88b25bfd5fa2dc18838 \
- --hash=sha256:2392d954fcb920f42d230284b677605e4e2fbb11f2821e823e642abd67fbb491 \
- --hash=sha256:25ea65d868eb04015cd18e6df2fbe98f07e5bda2abefabcb88fce39a947716f6 \
- --hash=sha256:295f0a7f2e242dabd513737cf076007dc5b2d59237e3eca37b05c0c6446f3826 \
- --hash=sha256:2f16197705a230a78270cdd4ea8a1d57e86b2fdcbc34a1f6aebc72e65c986f9a \
- --hash=sha256:35405aecb474e683fb36af650618fd5340ee5471fc65a21b36076a18bbc6c981 \
- --hash=sha256:38be1808cdd068605b787e6ca9119b27eb275a0234e50212c3492331680c3b1e \
- --hash=sha256:3a577bd840ca83f646f0a625dbc571dba7044c43c2d1503afc378b570954345c \
- --hash=sha256:3b13dedfe76a0ad2d1d859b0811b53827a4e9d93a0bcb05cf59333ab4980cc7e \
- --hash=sha256:418e48ce50a45a6a6c73c454677203a9c75c966cb1e92ca3370959185f197a05 \
- --hash=sha256:55a3bc1e3df3b5567b7d27ef551b2283f0c68a5e86f1cd56abc569da4f31335b \
- --hash=sha256:6165461f55ef6314f026de6638d661188e3455d3ec49834556a0ebbdbace18bb \
- --hash=sha256:61a3d7eaa97a14768b542f3d284dc6400dd2470d9f080708b13cd46b6ae18136 \
- --hash=sha256:6233c9ed9ab9d1db47de57d9753256d9dcffbf42db341576099f0fd9f6bf4810 \
- --hash=sha256:641f795b361874ac9da5294f8f443dfdbee355cf2bd9e3b8d97aaac2306b9b37 \
- --hash=sha256:644a246325b8c69c595ad1dd4b463eba4b0cdb731370e4a86137d433208d6147 \
- --hash=sha256:6f066b179d68c413374294bc1735f68475457c933258df594443bb9d88ddc2a0 \
- --hash=sha256:7986f1fa71cee060ad00758bcc79d3a93bab8559bf978fab9e53472a2e25a17b \
- --hash=sha256:7c2b98645d576a0b9616892ead22b64a83a5f043c5e2ca15ebcefcb5b70c80cb \
- --hash=sha256:806f24b4085453c197a5078218d1ee08783ebbba271badd153d1ae22a3ee804f \
- --hash=sha256:85fe721a14dd823aca09127acbb06c3ca723efbd436c004f16bca601b04dcc83 \
- --hash=sha256:8adc8e6ce5fccf5dc707046ae4914fd537def529709cc0d285d37a7f9cd442ca \
- --hash=sha256:9700ebd9a51f5895ce75ff4ac4b3c47a7d4b42bc618be8e713e5d56bacf5f931 \
- --hash=sha256:9b18371ad2f44044b81a8d23bc2d8a9b6a6226dca775e8e16cfee640473d6c5d \
- --hash=sha256:a964266397740257f16f7bb2e4f08a0c81454004beab8ff59dd531b73610e9f2 \
- --hash=sha256:adbbedc55506cbdabb830890444fb856bfb0060c46c6f8026c6c2f2cf86ae795 \
- --hash=sha256:ae8a1145af31d903fa9bb166824d7abe9b4681a000b0159c9fb99c11bc11ad26 \
- --hash=sha256:b0e131f880cda8d04e076cee175a46fc0e8bc8b65c99c6c09dff6669335fde74 \
- --hash=sha256:b196eb3f931862af3fa84c2a253514d859c08e0d8fe020e07be12e75a5a9780c \
- --hash=sha256:b7d9a514e73bc42711e6a35aaccf3587c520024fe0a25d830a1a8a27c15f4f57 \
- --hash=sha256:bec9373df11544592b0ba7ec2af0e35059e5f0e7647c6183a854dedd193298f1 \
- --hash=sha256:c42ab9439498270139cc63e18847a02afe5c8b3ed9c931266533cfe378bd3591 \
- --hash=sha256:c91d00057f23b8d353039520dc3a6c09d8608164c692e9f59a175a42b2ae0c19 \
- --hash=sha256:d3e0b61e8efb24ed38898e5cdc5fffa9124be480008d401a1f8071500494ae42 \
- --hash=sha256:d7027eba1df3b2069e2e8d80f644fa0918b68c46432af3d088ddd390d063ecde \
- --hash=sha256:d8ddd2768da81d3ee08cfea9b597f4abb4e8e1dc8ae7e204b608d23a0d3ab699 \
- --hash=sha256:e3268e43984d0b1a185c89b4cfff282a7ead12fc93f56cfd7088bdbcbe727041 \
- --hash=sha256:e4505fc6583f7b05ab854934896bcac8253b04ac1171a77dfb73efef92076d91 \
- --hash=sha256:e56a1ffe9bf7b727432b89104cc0849c21582949dd7bdcb34f17b2001a351a76 \
- --hash=sha256:e6f1278ee4785b6db21229374a1c9e54ec7c549de5d1efc9630b6207de7e170b \
- --hash=sha256:f7616236ec1bc2b15bfdec22a71ab38851c86f8f05ff64f379e1278cf20c634a \
- --hash=sha256:fb24ac194bfc5e86839d7dcd52092ee31e5fe6733fe11f5e3b06ef0812b20072
- # via streamlit
pycodestyle==2.14.0 \
--hash=sha256:c4b5b517d278089ff9d0abdec919cd97262a3367449ea1c8b49b91529167b783 \
--hash=sha256:dd6bf7cb4ee77f8e016f9c8e74a35ddd9f67e1d5fd4184d86c3b98e07099f42d
@@ -1326,10 +999,6 @@ pycparser==3.0 \
--hash=sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29 \
--hash=sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992
# via cffi
-pydeck==0.9.3 \
- --hash=sha256:695775cbfe51f5fdffbd9735ba469987fdc5efc96bc40a0ee4808170509c78b2 \
- --hash=sha256:d8a47c11c81fb12d51b1feb42427ff4f0e13cb599e48931021b2cba98b6849a6
- # via streamlit
pyflakes==3.4.0 \
--hash=sha256:b24f96fafb7d2ab0ec5075b7350b3d2d2218eab42003821c06344973d3ea2f58 \
--hash=sha256:f742a7dbd0d9cb9ea41e9a24a918996e8170c799fa528688d40dd582c8265f4f
@@ -1362,16 +1031,10 @@ pytest-cov==7.1.0 \
--hash=sha256:30674f2b5f6351aa09702a9c8c364f6a01c27aae0c1366ae8016160d1efc56b2 \
--hash=sha256:a0461110b7865f9a271aa1b51e516c9a95de9d696734a2f71e3e78f46e1d4678
# via -r requirements-dev.txt
-python-dateutil==2.9.0.post0 \
- --hash=sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3 \
- --hash=sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427
- # via pandas
python-multipart==0.0.32 \
--hash=sha256:be54b7f3fa167bb83e4fcd936b887b708f4e57fe75911c02aebf53efaf8d938e \
--hash=sha256:ff6d3f776f16878c894e52e107296ffc890e913c611b1a4ec6c44e2821fe2e23
- # via
- # -r requirements.txt
- # streamlit
+ # via -r requirements.txt
pytokens==0.4.1 \
--hash=sha256:0fc71786e629cef478cbf29d7ea1923299181d0699dbe7c3c0f4a583811d9fc1 \
--hash=sha256:11edda0942da80ff58c4408407616a310adecae1ddd22eef8c692fe266fa5009 \
@@ -1495,12 +1158,6 @@ readme-renderer==45.0 \
--hash=sha256:030a8fac74904f8fba11ad1bb6964e3f76e896dc7e5e71f16af190c9056696d1 \
--hash=sha256:3385ed220117104a2bceb4a9dac8c5fdf6d1f96890d7ea2a9c7174fd5c84091f
# via twine
-referencing==0.37.0 \
- --hash=sha256:381329a9f99628c9069361716891d34ad94af76e461dcb0335825aecc7692231 \
- --hash=sha256:44aefc3142c5b842538163acb373e24cce6632bd54bdb01b21ad5863489f50d8
- # via
- # jsonschema
- # jsonschema-specifications
requests==2.34.2 \
--hash=sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0 \
--hash=sha256:f288924cae4e29463698d6d60bc6a4da69c89185ad1e0bcc4104f584e960b9ed
@@ -1509,7 +1166,6 @@ requests==2.34.2 \
# pip-audit
# requests-toolbelt
# sphinx
- # streamlit
# twine
requests-toolbelt==1.0.0 \
--hash=sha256:7681a0a3d047012b5bdc0ee37d7f8f07ebe76ab08caeccfc3921ce23c88d5bc6 \
@@ -1530,153 +1186,11 @@ roman-numerals==4.1.0 \
--hash=sha256:1af8b147eb1405d5839e78aeb93131690495fe9da5c91856cb33ad55a7f1e5b2 \
--hash=sha256:647ba99caddc2cc1e55a51e4360689115551bf4476d90e8162cf8c345fe233c7
# via sphinx
-rpds-py==2026.5.1 \
- --hash=sha256:01d17b29c0c23d82b1f4751147ec49cf451f1fc2554eb9ef5f957e55d2656ead \
- --hash=sha256:036a36a87fb1cd3b214d11c4b3c4f7d2ddad933625dca1c900b56a057c07740a \
- --hash=sha256:0408a24e44feb919423dc6d9da677cb5cddb894d2ca9e763967d156d9c60fab4 \
- --hash=sha256:07b24fea40541e28570e5b795a4a38fbdcd12550c06bd0748005ecc8116ca256 \
- --hash=sha256:0957cf3c2b8632ec7aaebffebea8005b353cc2a237b6e2ae3c2cac0820704cfb \
- --hash=sha256:0a5ae4dbe43c1076983b72616496919872ae7bbe7a1e21cc48336bc3154d130b \
- --hash=sha256:0a7d1eec967df0e9b22614a5e177622e0c89611d03727fa0cb48e45028907870 \
- --hash=sha256:0b35217adefe87f2fe4db7e9766cabe84744bfe9616d9667be18988928c7f2dc \
- --hash=sha256:0fa92420128dadce7f54bd73ba1825a273e9268fe9e35dbf7e6362890efa4e08 \
- --hash=sha256:141c9498daf2ace9eda35d2b0e376f9ea8b058d84f2aef4f96fccfd449a2f251 \
- --hash=sha256:1841d067089e117142d79b98aa0df2f08b52f2ecc1819dd2700636c0db74a473 \
- --hash=sha256:19cb09fab7b7fc96b2a6e28f2e34b72a3705ff27b37edb77455316e5d3f3dc9b \
- --hash=sha256:1c27c5f6102eac8c03e7595a00827a53b271ba40a53b59ff8709170e0855ea4a \
- --hash=sha256:1ebb2f0ab7e16132995a72de805170e0203df0c3dd22e1ef1cd1fdd90bd7a131 \
- --hash=sha256:1f2c391c3059798093b65df23aca2cac150460ae9c630d99dec83d703d9485b9 \
- --hash=sha256:205dde846f24332ab0c1188699a043b8d165b79bb84529ce272c45048ff6be01 \
- --hash=sha256:21846aac0ed2e0589f38c12dc44e77bb64e494b771eadbcf169cba00566ba7ba \
- --hash=sha256:21942f52dbbd5f8758bf021213d28bd45c39e873e65e2407faf5f1846f5761ad \
- --hash=sha256:277f6c82f0580848796c7ecc8a7173aa3bfb928e4ff831261c2f60a81dc270db \
- --hash=sha256:27b74c10ed6a8f190f4287f53bcfea348b92a84a9c9f70d30183d1e6172d580d \
- --hash=sha256:296c799becfa849c779c8725494fe9ed94959ed886787df4364b058465bad7f0 \
- --hash=sha256:2c595a1d9255dce0599e13130d1440ab2506654f2b50294226ee06402f8fef63 \
- --hash=sha256:2c817a189d4ee14290420e5ff051e4dd6baa13f3edf84685071dee07a6d538ee \
- --hash=sha256:2d88621d6a7d4dfa633d21abe90f280bb205274e16b1d1e61c6ad4640b2453b7 \
- --hash=sha256:3350ec808fb538fe71a1f94dfaa0e29c598dfad805ce49f0caec5ae3183c652b \
- --hash=sha256:3397a5ed7174dc2786bb214030232fc36fe8e5584fec43a9952cc542b1a12036 \
- --hash=sha256:3574b55c604b8f75dacb007136508bbc0db406e626301778096a133327e7f2fb \
- --hash=sha256:3609e9939a8a76cd904cf98a3f1f13b5dc7e150adeaee89e0ea09652ea213e16 \
- --hash=sha256:3684a59b158a7683aaeb8e25352e9a9dd2122cec78f2d8530266e4f91b4c7b3f \
- --hash=sha256:3966b82dd563176396df030f3dd52a6e54cb69b718e95e78bd555ed3d1e0185d \
- --hash=sha256:3abe24a66e57adcfa645d718063a5fa5103ecc71ddbf26d78af8f9368018ff1d \
- --hash=sha256:40ff257542e04796880e011e15cd4dc21c2599975df2aaa8f2c8495ca574e1a5 \
- --hash=sha256:413b424f7c4ee65ab5e5be91f5731be0f8b41a1ee2b12dfe810d716312e95a78 \
- --hash=sha256:42d0f20e85e549c870749d0e247f0c10d318a45b7e9676d575d2dcb04a1b2e66 \
- --hash=sha256:43bca78665423cabae77146f2fe7ce55272b6c8d55d82cca83effd42c7e13972 \
- --hash=sha256:453895624ecf7db7063b1004e44037522bbaef9ff6a945e59bc71662d7a03abd \
- --hash=sha256:4860b603ddda0475a8885499b3729e90229d480105b42651962a5397d995fa89 \
- --hash=sha256:4be8b1d2a705cc37d08256004e1d07de143fa0075c8e85a3df020b776f62b732 \
- --hash=sha256:4e237e139f94d3c036fd28eb9f564c99055476ff4ff05cd42be55ce349b5aa02 \
- --hash=sha256:4fb8d2e7cb2f850b169806d61d1b991738acec96500a75c30f49caf064ce7cef \
- --hash=sha256:55d8f9b7b78c9538fc9e04e82ec0e888ff0c3cffcfad152c77e57cd09351a98a \
- --hash=sha256:58b1d94308ddf0b1982f61f2eb54bf92997c9ece8a8093ef014250f4a517906c \
- --hash=sha256:5d333a7127d4b307601ac37792bee01bb95c867cbfacf21b6375b804d6bbd723 \
- --hash=sha256:613fc4ee9eaef26dc5840666214dd6fbcebcf32f46e76f4abc473059f4e13dda \
- --hash=sha256:6142dbd80c4df62a5d899f0d616d417f84e0bc8d32526c8e5589019d75d028a7 \
- --hash=sha256:62ae3853454fe9ef283a03c96c2d835d39e84b14643a9d62c82ef0fb87d702ca \
- --hash=sha256:63c2c4c213f1a4e3f3de28ecab029dbdee976324e729c0d7a55211be72576b02 \
- --hash=sha256:656a042550878f12d45752452d47094b7cfe5ad1e9d7b87b5a22ad3ae5ff8015 \
- --hash=sha256:66c93681c4729e4e3ecba31b8179fae083ff3118841672835140338b4b9867c1 \
- --hash=sha256:6736718bd4fc49cbcb538ba30516fdbef161522acefb739657d48b97bd864fed \
- --hash=sha256:68700371c5d7ae1412862ddfa719090925c93ecf351c566d66f09d04b136ea00 \
- --hash=sha256:6c3d771a46ec18b12af06ce36243a9a80b07a5d0515236332d90863ca8bb326a \
- --hash=sha256:6c7fcf61d44cacecaf3aea542b0e053db77972a4573e7ceda16fb2b399161195 \
- --hash=sha256:6f249f8b860a200ad35193af961183ebe9132710484e6f6ce0cf89fd83c63a9a \
- --hash=sha256:73c4bd4f70294737b5206a3e8e30ccadbf8a60301831c8ea23eec5dbeea1ecfa \
- --hash=sha256:7559f72b94ae52659086c595dfa017cde03155f7832071d30959049052cb3ece \
- --hash=sha256:75808f6c38ce7749bb68cc2770161aae5045e6c6f6781a9782e74b93304399df \
- --hash=sha256:77c004fdc7b891967106f78ddfd7b076bfe6813c6139c6fff6aed3bcaa960b26 \
- --hash=sha256:7818f8d0a415be74d2be3590b0a1c1f463a642f4d0217e7d10602dceef5b79aa \
- --hash=sha256:7944270ae71383f6e2657dd7d5ce4eeb4ac2d0059a6738f0510583d462ab4842 \
- --hash=sha256:7bd530e6a530bb3ea892f194fafa455f3516ac25ecf7143fd33c09be62b0470a \
- --hash=sha256:8213afbe8a3a906fb9acb2014423fe3359ee783d0bf90995f70623a3217bfa6c \
- --hash=sha256:83bcf894486c9d78dd290d3c0124ff6dd8875d3025e2090a8ec49fcc37c55fdd \
- --hash=sha256:85264a90ff4c05c1568dd65f5921c837614b67c60358fb4c17df3b7f2e90690a \
- --hash=sha256:88647f43a73c4e01be19b04ceef0c8d3a1958153604d13c773becd8016f2a0cf \
- --hash=sha256:8895840ac4809e5f60c88fd07617cd71326e73d6e5a8aa783c5c0f7c24985de2 \
- --hash=sha256:8ba264fa49be666cd9cc56bf34ec7002fb3d27a4aee5bcb4d43d0d18feb1bb6f \
- --hash=sha256:8bff7073db3899158fff55ebf57b113a67030af26f80a18978f9f0aa60250ddf \
- --hash=sha256:8c43a8a973270fd173bf48cdf80bbe66312421cba68d40845034f174f2389049 \
- --hash=sha256:90bd6630002a1c7f09e7843dd79f0d24f3d2897cc25a753480917865d14f15b3 \
- --hash=sha256:90f628283be835db980c941767d41c9a27b5239e54ba0a9c1335247e82406964 \
- --hash=sha256:94068eb3ae6d43f5a786b7db96a406a34e6d5c24489feef32fd6e8946ea7b291 \
- --hash=sha256:980450826cf22e133c57e0835070bdd0dd3f73b9b708c3ce223def2cb9469e14 \
- --hash=sha256:99ab6ba7bfa2cb0f96a04e3652355bf04e3f51aceb1e943b8541dab7ba4828cc \
- --hash=sha256:9af8905b8f854990e40d5206aa5ac58d9b0fe0b7f351ff2bb086c20f6c8c6a47 \
- --hash=sha256:9baffb505aff33acc69b422a19f77806680f3c8632227d79f48de8a810d1c2c5 \
- --hash=sha256:9cdddb6c1207d284d94fd1530adf57fbd797fe7c4b8704ba85f49414f2557e7d \
- --hash=sha256:9e25b7088f9ccbfc0dfcaa52bf969300ca229e10ecf758974ebcbb080a4b37bb \
- --hash=sha256:a04df86b3f0fade39ec8fd0e0aab089b1da9fbd2b48df778a57ef96f5e7d38df \
- --hash=sha256:a05fa4f41f37ec97c9c260441a940450a192f78d774d2b097eee1379f1e1246a \
- --hash=sha256:a2999883eedf72fdfb7520b92c7d4ec2572a71ff40239377aa604cc529eecafc \
- --hash=sha256:aad1bff7f666b9598e573815affd666aac6a13a585dde336f843e33350c7fadc \
- --hash=sha256:abe76bcdba31e576cb83eeb8797aa0d882b738fef6dc65d0601fc753806a5b46 \
- --hash=sha256:ad3773236e95f7f33991eb125224b7da66f206504d032a253a02da7e134519fb \
- --hash=sha256:af03e34e860047bc7a352b842856fcf78798fbb81132cc98bd2f907ab4eb9cd2 \
- --hash=sha256:b1b964e3ab599e718dc46c018d104b1ebc007cbc6567d827c94a687fca56d77e \
- --hash=sha256:b1be5c35683684d5331b93600c210e8367c254683d8a6df6bd21bd2da3a334fb \
- --hash=sha256:b317c87a13f769a4e787819bd508aaa5d69aa09b0880de9af6d3a8a54571cdec \
- --hash=sha256:b3cc20c0d800af78fd0fac68086e28c1856cec51ea528bb81ea851aa40d39325 \
- --hash=sha256:b4e4bc98639ec915f512fde3aa7a95e0041d95d9c3cc86eea841fa63cb1e8600 \
- --hash=sha256:b5c30f3f04eef4fbd362226a6f31d7c8895ca4fbb6e0b790f6890a98d8da8559 \
- --hash=sha256:b5f077b44a4f7808520f66dae234988d867deb9aed9be5da057ce9ba831b2a41 \
- --hash=sha256:b6825cc329b290e93c5f6a9be2393118a763f6ccf6abd83704e0c102ca583644 \
- --hash=sha256:b8d2f912928d426e8cfa396f7f3f8d29a59e6689c86dcca3c420730c1096322b \
- --hash=sha256:b95d5e11fc712b752081183a55a244c03cd00570489edd7014d8899f8ceb8162 \
- --hash=sha256:b9a6528956191c48c52294a592dbd4a8386d7048bdb25c0efcb6b966466c6d83 \
- --hash=sha256:ba05adbf15d994c38ec0b7ab32e858e5110c21e9009a00a86545fd220f84e038 \
- --hash=sha256:c0f920015df2a504bebaba6d4c31ccf3fcf942f92655c086da30b671aad19aa6 \
- --hash=sha256:c396c1304de421050b3681ea70f371874b54d41b0151e96109758144c231e30b \
- --hash=sha256:c39f5b67a8a2e67179ada2a954227d670fe65fa9098457f698f56ddf248709b3 \
- --hash=sha256:c3df104083952a0e0c6f10de33e440eabe98fb6317d23e1a58c68f6df08d01b9 \
- --hash=sha256:c74005a7bb87752acf351c93897ec63ad77a07a0da7ecad9c050e32e7286ba34 \
- --hash=sha256:c93c629be4636cf54337bd5f06c104d55e42ced54d681f6fe21ae510a65116f6 \
- --hash=sha256:ca653c6546386227cd9800d1bef6a348099acf8db4250341da6d90f663d6dfcb \
- --hash=sha256:cacedb7a6e167680acba45ad5716e89067d225dc80da0d7040cae8c81d4572fa \
- --hash=sha256:cc68e231a77a5f0d774ae278a1f8e55c0456501820847c1e4efb3829f3441df6 \
- --hash=sha256:ce87129d9f2c14fa6c4a8601fb80eb4488c80d38a20cd13758ef11123e14995d \
- --hash=sha256:cea68bcd53467561ae2f96a6bdad1544299ba97b5b0ddcd5ac3d376e5c781c24 \
- --hash=sha256:cef8ac28d26f4dda3533060c20fbf80a325458fa9fd23ea72a73cdfa8e978838 \
- --hash=sha256:d0efbe45632665e53e3db8fe1e5692db58fc5cb9bab4459d570b83efefe11164 \
- --hash=sha256:d3858b908218ee108d0bbfb2095ccc237648053c9bf98affad7cb079acaf1d97 \
- --hash=sha256:de42116e69cb53b911cc34aee5ab98f36c597b822545045d49e938818b99e5e4 \
- --hash=sha256:df1d2a1996755b24b9ecee92cb4d36c28f86f464a6a173349c26bab41e94b8c2 \
- --hash=sha256:e07be2a9d7122bd6e82dea89814ef8dc893feb1aae97fec1630f3263bbb30e55 \
- --hash=sha256:e0b360f316d966b048b085857630b3cc51f3db2f07b06f440eac8f695374d1e3 \
- --hash=sha256:e10464d17df3b582745c25cec695cb9558bca2cb6ddb631aee1787fc72c767b2 \
- --hash=sha256:e3a8ae58895ac107ed934a6bf51e5846f95c53b9b940c2c6d310838fd5846358 \
- --hash=sha256:e4abbf391a70be864920858bf360f4fb380577c9a0f732438a1996726e2c195b \
- --hash=sha256:eaaea962c68cdc68d4a533ba985ab8e9484277910bbfaa2ab3ef7732667bfed8 \
- --hash=sha256:ed0954b524873214369184a9c82b0eaa45a3fbb9a798cd95b17e0d98499e7ea0 \
- --hash=sha256:edf2765d84e42447f112ad877af8fe1db0089aaec5b28e88d6eab45e7fe99cea \
- --hash=sha256:ef1013a8625c74043210190b246f5b1551e09757c1f356c6e4160ef96c5bc081 \
- --hash=sha256:efef4ac29c6ff495531eb17ee705b62841ecaa291b7c7077e848ea03e237164d \
- --hash=sha256:f3a5b10e8ce894825f380a8f1b6444cf73c294dfea62afbb2d13e3a9e630cec1 \
- --hash=sha256:f3df3d16ded76f1f8c9cdebd0e1ea55fdf4c23b812de189814da7cf229c22a81 \
- --hash=sha256:f414556f6e3958300ff941e40c9f97e3dc9774ddd1b3434c475d73dd354bbed3 \
- --hash=sha256:fc09f82e63d4bcd58149572f857a431bae851dc747e313c3b5bdf7abb907fda8 \
- --hash=sha256:fc0c0f878ea770a0a8a462456c5ad36fc9fe6358e6b76fdadc7f17575e0b8bf1 \
- --hash=sha256:fe71bca7d547acb17027c7fd1624ff8aae623499c498d3e7011182c4de5c25e0 \
- --hash=sha256:fea6e836d10abbe191d557d33bd58bd5987725fe63aa1eefe557d230209855bd
- # via
- # jsonschema
- # referencing
secretstorage==3.5.0 ; sys_platform == "linux" \
--hash=sha256:0ce65888c0725fcb2c5bc0fdb8e5438eece02c523557ea40ce0703c266248137
# via
# -r requirements-dev.txt
# keyring
-six==1.17.0 \
- --hash=sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274 \
- --hash=sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81
- # via python-dateutil
-smmap==5.0.3 \
- --hash=sha256:4d9debb8b99007ae47165abc08670bd74cb74b5227dda7f643eccc4e9eb5642c \
- --hash=sha256:c106e05d5a61449cf6ba9a1e650227ecfb141590d2a98412103ff35d89fc7b2f
- # via gitdb
snowballstemmer==3.1.1 \
--hash=sha256:7e207fa178741da09cdee59d3ecec3827ad5f92b1fc5c9ff3755b639f71f5752 \
--hash=sha256:e07bbc54a0d798fe6010a12398422e62a8bfbba95c394fd0956ef58cb4d3e260
@@ -1732,25 +1246,11 @@ sphinxcontrib-serializinghtml==2.0.0 \
starlette==1.3.1 \
--hash=sha256:05d0213193f2fbaae60e2ecb593b4add4262ad4e46536b54abe36f11a71724e0 \
--hash=sha256:c7372aae11c3c3f26a42df7bd626cec2f47d03483d261d369516a615a53714c6
- # via
- # -r requirements.txt
- # streamlit
+ # via -r requirements.txt
stevedore==5.8.0 \
--hash=sha256:88eede9e66ca80e34085b9174e2327da2c61ac91f24f70e41c3ad76e4bb4872b \
--hash=sha256:b49867b32ca3016e94100e68dbf26e72aa7b8708d0a3f73c08aeb220370ac715
# via bandit
-streamlit==1.59.2 \
- --hash=sha256:5014cb4f0064bd16c58fe334cb0d3c4b7e2be3d1ce4c02202c07114a33576dab \
- --hash=sha256:8fcb35df152868d33eec7ad2d89e276499a5351fb861eb1752b76476be7eb582
- # via -r requirements.txt
-tenacity==9.1.4 \
- --hash=sha256:6095a360c919085f28c6527de529e76a06ad89b23659fa881ae0649b867a9d55 \
- --hash=sha256:adb31d4c263f2bd041081ab33b498309a57c77f9acf2db65aadf0898179cf93a
- # via streamlit
-toml==0.10.2 \
- --hash=sha256:806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b \
- --hash=sha256:b3bda1d108d5dd99f4a20d24d9c348e91c4db7ab1b749200bded2f839ccbe68f
- # via streamlit
tomli==2.4.1 \
--hash=sha256:01f520d4f53ef97964a240a035ec2a869fe1a37dde002b57ebc4417a27ccd853 \
--hash=sha256:0d85819802132122da43cb86656f8d1f8c6587d54ae7dcaf30e90533028b49fe \
@@ -1811,10 +1311,7 @@ twine==6.2.0 \
typing-extensions==4.16.0 \
--hash=sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8 \
--hash=sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5
- # via
- # altair
- # mypy
- # streamlit
+ # via mypy
urllib3==2.7.0 \
--hash=sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c \
--hash=sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897
@@ -1825,152 +1322,7 @@ urllib3==2.7.0 \
uvicorn==0.51.0 \
--hash=sha256:5d38af6cd620f2ae3849fb44fd4879e0890aa1febe8d47eb355fb45d93fe6a5b \
--hash=sha256:f6f4b69b657c312f516dd2d268ab9ae6f254b11e4bac504f37b2ab58b24dd0b0
- # via
- # -r requirements.txt
- # streamlit
-watchdog==6.0.0 \
- --hash=sha256:07df1fdd701c5d4c8e55ef6cf55b8f0120fe1aef7ef39a1c6fc6bc2e606d517a \
- --hash=sha256:20ffe5b202af80ab4266dcd3e91aae72bf2da48c0d33bdb15c66658e685e94e2 \
- --hash=sha256:212ac9b8bf1161dc91bd09c048048a95ca3a4c4f5e5d4a7d1b1a7d5752a7f96f \
- --hash=sha256:2cce7cfc2008eb51feb6aab51251fd79b85d9894e98ba847408f662b3395ca3c \
- --hash=sha256:490ab2ef84f11129844c23fb14ecf30ef3d8a6abafd3754a6f75ca1e6654136c \
- --hash=sha256:6eb11feb5a0d452ee41f824e271ca311a09e250441c262ca2fd7ebcf2461a06c \
- --hash=sha256:6f10cb2d5902447c7d0da897e2c6768bca89174d0c6e1e30abec5421af97a5b0 \
- --hash=sha256:7607498efa04a3542ae3e05e64da8202e58159aa1fa4acddf7678d34a35d4f13 \
- --hash=sha256:76aae96b00ae814b181bb25b1b98076d5fc84e8a53cd8885a318b42b6d3a5134 \
- --hash=sha256:7a0e56874cfbc4b9b05c60c8a1926fedf56324bb08cfbc188969777940aef3aa \
- --hash=sha256:82dc3e3143c7e38ec49d61af98d6558288c415eac98486a5c581726e0737c00e \
- --hash=sha256:9041567ee8953024c83343288ccc458fd0a2d811d6a0fd68c4c22609e3490379 \
- --hash=sha256:90c8e78f3b94014f7aaae121e6b909674df5b46ec24d6bebc45c44c56729af2a \
- --hash=sha256:9513f27a1a582d9808cf21a07dae516f0fab1cf2d7683a742c498b93eedabb11 \
- --hash=sha256:9ddf7c82fda3ae8e24decda1338ede66e1c99883db93711d8fb941eaa2d8c282 \
- --hash=sha256:a175f755fc2279e0b7312c0035d52e27211a5bc39719dd529625b1930917345b \
- --hash=sha256:a1914259fa9e1454315171103c6a30961236f508b9b623eae470268bbcc6a22f \
- --hash=sha256:afd0fe1b2270917c5e23c2a65ce50c2a4abb63daafb0d419fde368e272a76b7c \
- --hash=sha256:bc64ab3bdb6a04d69d4023b29422170b74681784ffb9463ed4870cf2f3e66112 \
- --hash=sha256:bdd4e6f14b8b18c334febb9c4425a878a2ac20efd1e0b231978e7b150f92a948 \
- --hash=sha256:c7ac31a19f4545dd92fc25d200694098f42c9a8e391bc00bdd362c5736dbf881 \
- --hash=sha256:c7c15dda13c4eb00d6fb6fc508b3c0ed88b9d5d374056b239c4ad1611125c860 \
- --hash=sha256:c897ac1b55c5a1461e16dae288d22bb2e412ba9807df8397a635d88f671d36c3 \
- --hash=sha256:cbafb470cf848d93b5d013e2ecb245d4aa1c8fd0504e863ccefa32445359d680 \
- --hash=sha256:d1cdb490583ebd691c012b3d6dae011000fe42edb7a82ece80965b42abd61f26 \
- --hash=sha256:e3df4cbb9a450c6d49318f6d14f4bbc80d763fa587ba46ec86f99f9e6876bb26 \
- --hash=sha256:e6439e374fc012255b4ec786ae3c4bc838cd7309a540e5fe0952d03687d8804e \
- --hash=sha256:e6f0e77c9417e7cd62af82529b10563db3423625c5fce018430b249bf977f9e8 \
- --hash=sha256:e7631a77ffb1f7d2eefa4445ebbee491c720a5661ddf6df3498ebecae5ed375c \
- --hash=sha256:ef810fbf7b781a5a593894e4f439773830bdecb885e6880d957d5b9382a960d2
# via -r requirements.txt
-websockets==16.1.1 \
- --hash=sha256:01fbdcbac298efe19360b94bc0039c8f746f0220ba570f327577bfee81059175 \
- --hash=sha256:024193f8551a2b0eafbdd160911012c4e6c228c28430c84433253299a9e42d6a \
- --hash=sha256:04fd29a0e2fe9414a95b00e92c67ae51bf900c50c0f8a4b2dafdad621f49ea1d \
- --hash=sha256:056ae37939ed7e9974f364f5864e76e49182622d8f9751ac1903c0d09b013985 \
- --hash=sha256:0f62863e8a00a6d33c3d6566ec0b89f23787b747ffe0c3bc71ec0e76b82c94b1 \
- --hash=sha256:0ffd3031ea8bda8d61762e84220186105ba3b748b3c8da2ae4f7816fac03e573 \
- --hash=sha256:1214e673c404684b9bf7154f5cf43b45025b1a6160fac3a9e438e9c1a97e22cb \
- --hash=sha256:125f22dbefaf1554fea66fc83851490edb284ce4f501d37ffed2752f418332d9 \
- --hash=sha256:130937b167a52af203c8d58e78d67705874e82759862e3b9671a452fec4abc87 \
- --hash=sha256:1427fb4cf0d72f66333e2cacc3ff5f575bf2d7008166ce991a4a470b21d51a22 \
- --hash=sha256:195c978b065fa40910582464f99d6b15c8b314c68e0546549a55ed83f4735328 \
- --hash=sha256:1d27fa8462ad6a1cb36206a3d0640b2333340def181fae11ed7f9adeaa5c0747 \
- --hash=sha256:1db4de4a0e95673f7545d393c49eeb0c2f18ac1ef93073218c79d5cdb2ee75ab \
- --hash=sha256:1f79c89b5eb034d1722938a891916582f8f7f503f58ca22518a63c3f2cd18499 \
- --hash=sha256:23253dd5bcae3f9aaee0a1d30967a8dbd52e5d3cff93a2e5b84df57b77d4750d \
- --hash=sha256:249116b4a76063d930a46391ad56e135c286e4562a18309029fc2c73f4ed4c62 \
- --hash=sha256:29dfa8114c4a620c69591c5973860f768eac29d3fd6904f37f34266cb219c512 \
- --hash=sha256:2a606d9c24035242a3e256e9d5b77ed9cd6bccfcb7cf993e5ca3c0f6f68fb6a7 \
- --hash=sha256:2a636ff1e7a5c4edf71ef0e79adae7f25dba93b4fcbe3dc958733477ffeb0eaf \
- --hash=sha256:2bb5d041a8307d2e18782e7ce777f6fdb1e8c2f5d09291484b18c294b789d9aa \
- --hash=sha256:2e28e602bb13da44fbe518c1781a88e3b9d4c3d48d02c9bad83e546164336f57 \
- --hash=sha256:30bbe120437b5648a77d3519b7024ea09530e0b5b18d3698c5a0ae536fe0cc2e \
- --hash=sha256:34420aaa64440ebd51ac72ca8a45ef4626429438c9b02e633ae412ed43f925d3 \
- --hash=sha256:38565aca3e01ea8734e578fb2118dade0ecb0250533f29e22b8d1a7a196cf4d0 \
- --hash=sha256:387e8e4aa5df2f90b198fa3cad3478822a89cf905b6a6d6c97dc3664689640cc \
- --hash=sha256:39f2a024af5c345ffe8fcf1ee18c049c024c94df393bb09b044a6917c77bde43 \
- --hash=sha256:3df13f73af9b3b38ab1195eb299ecb67a4330c911c97ae04043ff74085728abe \
- --hash=sha256:414e596c75f74e0994084694189d7dc9229fb278e33064d6784b73ffbba3ca31 \
- --hash=sha256:41c8e77f17294c0ac18008a7309b99b34ee72247ef10b6dff4c3f8b5ac29896b \
- --hash=sha256:42290eb6db4ccaca7012656738214f8514082fb6fa40cdeb61bb9a471b52e383 \
- --hash=sha256:42f599f4d48c7e1a3338fdaac3acd075be3b3cf02d4b274f3bf2767aedd3d217 \
- --hash=sha256:43e3a9fdd7cbf7ba6040c31fae0faf84ca1474fef777c4e37912f1540f854499 \
- --hash=sha256:443aefe96b7fdb132e2a70806cca1f2af49bb3f28e47abcd7c2e9dcf4d8fa1b8 \
- --hash=sha256:46dcaa042cd1de6c59e7d9269fa63ff7572b6df40510600b678f0826b3c7af51 \
- --hash=sha256:496af849a472b531f758dbd4d61338f5000538cb1a7b3d20d9d32a264517f509 \
- --hash=sha256:49ae99bdfcae803a885c926bf14f886196e84925395bb3f568fef5c0f0979d7d \
- --hash=sha256:4b57693728576d84ede0a77987ab16881b783d2cd9f1dc180a8fbbc3f79c4428 \
- --hash=sha256:4e3b680b1e0a27457e727a0d572fd81dffa87b6dbf8b228ab57da64f7d85aead \
- --hash=sha256:4e8d01cc3bcae7bbf8167f944aeafefed590fae5693552bba9794a9df68371cc \
- --hash=sha256:5283810d2646741a0d8da2aa733d6aefa0545809afccb2a5d105a26bc45125f1 \
- --hash=sha256:53260c8930da5771cec89439bff99c20c8cb03ddb9588b980697355a83cd4bd3 \
- --hash=sha256:536676848fc5961aca9d20389951f59169508f765637a172403dc5434d722fa0 \
- --hash=sha256:54509b8e92fee4453e152b7558ddef37ce9705a044922f2095a6105e3f80c96f \
- --hash=sha256:56cd5fc4f10a9ea8aa0804bddb7b42506cf9e136046f3b4c27de8fec9e2ecba5 \
- --hash=sha256:5bfd1ac19b1b9986a9c95a82d5e23a391ebb09e12c34d7be6094b86efcc35731 \
- --hash=sha256:5c31aa7e39ee3e8a358573257f1c0bb5c52430d1b637030dd9c8cc2c282926be \
- --hash=sha256:5e3b7d601f6f84156b08cc4a5e541c2b50ad7b36cfc302b657a12477c904a5df \
- --hash=sha256:61922544a0587a13fd3f53e4c0e5e606510c7b0d9d22c8444e5fae22a06b38cb \
- --hash=sha256:6456ff333092d509127d75a638cb411afae8ff17f092635015d1902efec8a293 \
- --hash=sha256:69159730a823dde3ea8d08783e8d47ef135a6d7e8d44eb127e32b321c9db8e3e \
- --hash=sha256:69e52d175a0a7d1e13b4b67ad41c560b7d98e8c6f6126eb0bda496c784faf8c7 \
- --hash=sha256:6aaface73b9c71974c6497366d8b9628357f6c9749e09c4ea3610176c63f2ae3 \
- --hash=sha256:6abbd3e82c731c8e531714466acd5d87b5e88ac3243465337ba71d68e23ae7e3 \
- --hash=sha256:6ff9417c0ada4d0f7d212f928303e5579bdf3ace4c802fa4afabb30995da58c3 \
- --hash=sha256:7421fad442de870a8cbf2287d1cad7e706ece0dbfeba5e911df132cbdc1cb56a \
- --hash=sha256:7883388947767080f094950b342b30d35a2a06b849cd967c422fa0db72b40ea9 \
- --hash=sha256:79eace538c6a97e96d0d03d4f9d314f9677f5ed85a8a984992ffd90b13cb8a56 \
- --hash=sha256:7b1b19636af86a3c7995d4d028dbe376f39b4bf31541146f9c123582a6c94562 \
- --hash=sha256:7dfcad78ea1492ee3a9ec765cb7f51bbc17d477107aaf6b22abf7b2558d1c5a0 \
- --hash=sha256:8087e82f842609734c9b5a1330464f8e94e346ba0e18c832c08bafa4b0d63c15 \
- --hash=sha256:820fb8450edddae3812fd58cbc08e2bf22812cb248ecb5f06dbb82119a56e869 \
- --hash=sha256:8483c2096363120eea8b07c06ae7304d520f686665fffd4811fad423930a65d7 \
- --hash=sha256:84a2cef8deffbd9ab8ee0ea546a2a6a7030c28f44e6cdd4547dbfeb489eb8999 \
- --hash=sha256:86d7f0f8bdb25d2c632b72527325e4776430fd5bc61b9118de4e2b8ddb5f5b01 \
- --hash=sha256:8fe0b50da2d84535fb4f7b4bfa951280f97ce3d558a0443b541166d609e67b57 \
- --hash=sha256:90001d893bc368e302ef168d82130b4e4fdd27b85fa094682df9b667c2d48838 \
- --hash=sha256:9246a0d063cfcbcc85f2359dd6876d681213f4790832272aa16641b4ed5d64d4 \
- --hash=sha256:92b820d345f7a3fc7b8163949ee92df910f290c3fc517b3d5301c78065adafe1 \
- --hash=sha256:952303a7318d4cbe1011400839bb2051c9f84fa0a35923267f5daba34b15d458 \
- --hash=sha256:97fd3a0e8b53efa41970ac1dff3d8cf0d2884cadeb4caaf95db7ad1526926ee3 \
- --hash=sha256:9c1c5705e314449e3308872fe084b8571ce078ee4fc55a98a769bdefe5917392 \
- --hash=sha256:9c9f23004a3d40e89c01a7955d186a6cc83418d93b749701944ce2de3e95a1f3 \
- --hash=sha256:9f63bcef7f4b02b06b35fc01c93b96c43b5e88e1e8868676caacf493d5a31f3a \
- --hash=sha256:a0eadbbf2c30f01efa58e1f110eb6fa293261f6b0b1aa38f7f48707107690af9 \
- --hash=sha256:a28fcbc9b6baf54a2e23f8655f308e4ccc6afdd7266f8fe7954f320dcda0f785 \
- --hash=sha256:a6a61aff018180c9c50b7b0da33bfd29d378af3497429c95006c589a23a11648 \
- --hash=sha256:aabe464bfd13bd25f4821faf111da6fefdc389f870265a53105580e45b0a2e49 \
- --hash=sha256:ab59169ace05dcb49a1d4118f0bde139557adf45091bd85747e36bf5de984dd1 \
- --hash=sha256:b436f6ec4fc3a6b4237c84d3f83170ed2b40bb584222f0ac47a0c8a5921980c7 \
- --hash=sha256:b6b9dadbef0cccd9f4c4ee96b08898afa73e26803bbe0f6aeb5bb12b0074206d \
- --hash=sha256:b852788aa51764e2d8e4cf5493d559326bcae5e38d16ba25ffa322b034df272a \
- --hash=sha256:bae954c382e013d5ea5b190d2830526bfa45ad121c326da0049b8c769f185db6 \
- --hash=sha256:bcce07e23e5769375158f5efdcdafa8d5cd014b93c6683865b840ed65b96f231 \
- --hash=sha256:cc97814dfb786a83b6e2dc2e79351e1b83e6d715647d6887fcabd83026417a00 \
- --hash=sha256:cd2ca96a082a36964aca83e992f72abeb61b7306c1a6cba4c7d06a7b93750cac \
- --hash=sha256:cfb70b4eb56cac4da0a83588f3ad50d46beb0690391082f3d4e2d488c70b68ea \
- --hash=sha256:d0fcf657e9f13ff4b177960ab2200237b12994232dfb6df16f1cfe1d4339f93c \
- --hash=sha256:d14bfb217eb4701e850f1525c9d29d79c44794cdf1c299ead25f39f8c78dea81 \
- --hash=sha256:d57685547e0060cc6fd90ee6a28405d6bd395e525545f13c8d7cd99c78afd79f \
- --hash=sha256:d6bec75c290fe484a8ba4cacdf838501e17c06ecfbbf31eede81a9e431bd7751 \
- --hash=sha256:d9531d9cbeac99af6f038fb1bc351403531f7d634a2c2e10e2f7c854c6ed5b68 \
- --hash=sha256:da4ca1a9d72f9030b3146b8d7022719a9f3d478f61efe6f7dd51d243f61c51b2 \
- --hash=sha256:dab9eb87869da2d6ed3af3f3adf28414baae6ec9d4df355ffc18889132f3436c \
- --hash=sha256:db234eda965dcce15df96bb9709f587cd87d4d52aaf0e80e2f34ec04c7670c57 \
- --hash=sha256:dc0fad4933f427acd5b1cec210f3ea6dce7089e1724e4b9ec6ef47c6c04d1b3b \
- --hash=sha256:dc385593a42e31cd6fb60c19f0ecb015b386603818fc2c6c274fb42bd2bb4165 \
- --hash=sha256:dcc04fedf83effaeb9cce98abc9469bb1b42ef85f03e01c8c1f4438ef7555737 \
- --hash=sha256:e047dc87ef7ca50f4d309bf775ad4a71711c58556d75d7bd0604b2317f43e94b \
- --hash=sha256:e09f753a169951eb4f28c2c774f71069304f66e7277e0f5a2892423599cfa854 \
- --hash=sha256:ed5bb271084b46530ee2ddc0410537a9961152c5ccba2fc98c5276d992ccba87 \
- --hash=sha256:f0aa4aad3b1b69ad3fd85a0fd0952ec64331c762bd77ec51cc814170873890b2 \
- --hash=sha256:f17dbe07eb3ea7f99e4df9b7e0efefe80fbf30d37a8cc4d561a0aed310bc8847 \
- --hash=sha256:f2769a0344a09e9ccf5b3cce538bc75a51b53eff3275d3896310c8552049195d \
- --hash=sha256:f55f0b01956a094c8587146d9558c91937e78789c333860ffaf35931a6e5dbc4 \
- --hash=sha256:f5d497865f05bb222cab7016c6034542e84e5f29f49c6fd3f4939cda7197b5b8 \
- --hash=sha256:f70541f3104339f59f830522d94ebadb1bf47426287381623443d8bb1cdbf33d \
- --hash=sha256:fb9a0a6dc3d1b3986cb88091b6899f0396651e0f74e2c9766ab8d6ffc3842e29 \
- --hash=sha256:fce6c48559c86d1ac3632ccb1bebc7d5442fbe79bd9bb0e40379ee54be2a4051 \
- --hash=sha256:fd46fff7eb62c24804d234f0051c7a8ea81285ad63e0337d3dcf33ca82aee58a
- # via streamlit
wheel==0.47.0 \
--hash=sha256:212281cab4dff978f6cedd499cd893e1f620791ca6ff7107cf270781e587eced \
--hash=sha256:cc72bd1009ba0cf63922e28f94d9d83b920aa2bb28f798a31d0691b02fa3c9b3
diff --git a/requirements-lock.txt b/requirements-lock.txt
index 97b38f0..4c3e154 100644
--- a/requirements-lock.txt
+++ b/requirements-lock.txt
@@ -4,34 +4,10 @@
#
# pip-compile --generate-hashes --output-file=requirements-lock.txt requirements.txt
#
-altair==6.2.2 \
- --hash=sha256:94014f8ad8617c3cb163d1137359cd6db5ba134b9b46d93cfd8b609fd245a583 \
- --hash=sha256:a1ff9d9cfe81c75414641826312b9471780e19d39293ba0b012933f6b6cba0fe
- # via streamlit
anyio==4.14.2 \
--hash=sha256:9f505dda5ac9f0c8309b5e8bd445a8c2bf7246f3ce950121e45ea15bc41d1494 \
--hash=sha256:cfa139f3ed1a23ee8f88a145ddb5ac7605b8bbfd8592baacd7ce3d8bb4313c7f
- # via
- # starlette
- # streamlit
-attrs==26.1.0 \
- --hash=sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309 \
- --hash=sha256:d03ceb89cb322a8fd706d4fb91940737b6642aa36998fe130a9bc96c985eff32
- # via
- # jsonschema
- # referencing
-blinker==1.9.0 \
- --hash=sha256:b4ce2265a7abece45e7cc896e98dbebe6cead56bcf805a3d23136d145f5445bf \
- --hash=sha256:ba0efaa9080b619ff2f3459d1d500c57bddea4a6b424b60a91141db6fd2f08bc
- # via streamlit
-cachetools==7.1.4 \
- --hash=sha256:323dc4127934744db5b54eb4924482d7edafbf9554e820d1531c2e08c0e4ef54 \
- --hash=sha256:437f55a4e0c1b01a4f3077cc470e6991d47430970e36fbcb77e2be0df4fc1cd6
- # via streamlit
-certifi==2026.6.17 \
- --hash=sha256:024c88eeec92ca068db80f02b8b07c9cef7b9fe261d1d535abfd5abd6f6af432 \
- --hash=sha256:2227dcbaafe0d2f59279d1762ddddc37783ed4354594f194ffc31d20f41fc3db
- # via requests
+ # via starlette
cffi==2.1.0 \
--hash=sha256:02cb7ff33ded4f1532476731f89ede53e2e488a8e6205515a82144246ffa7dcc \
--hash=sha256:03e9810d18c646077e501f661b682fbf5dee4676048527ca3cffe66faa9960dd \
@@ -134,107 +110,10 @@ cffi==2.1.0 \
--hash=sha256:fb62edb5bb52cca65fab91a63afa7561607120d26090a7e8fda6fb9f064726da \
--hash=sha256:ff067a8d8d880e7809e4ac88eb009bb848870115317b306666502ccad30b147f
# via cryptography
-charset-normalizer==3.4.9 \
- --hash=sha256:0327fcd59a935777d83410750c50600ee9571af2846f71ce40f25b13da1ef380 \
- --hash=sha256:03d07803992c6c7bbc976327f34b18b6160327fc81cb82c9d504720ac0be3b62 \
- --hash=sha256:04ce310cb89c15df659582aee80a0603788732a5e017d5bd5c81158106ce249c \
- --hash=sha256:0d861473f743244d349b50f850d10eb87aeb22bbdcc8e64f79273c94af5a8226 \
- --hash=sha256:0e94703ec9684807f20cfb5eed95c70f67f2a8f21ad620146d7b5a13677b93e5 \
- --hash=sha256:0fa1aec2d32bcc03c8fa0f6f1712caad1adc38509f31142112e5c9daf5b9c833 \
- --hash=sha256:16b65ea0f2465b6fb52aa22de5eca612aa964ddfec00a912e26f4656cbef890b \
- --hash=sha256:16d10d789dd9bcca1173c95af82c58433122564b7bc39385124be735a35cbe99 \
- --hash=sha256:19ac87f93086ce37b86e098888555c4b4bc48102279bae3350098c0ed664b501 \
- --hash=sha256:1d22856ffbe153a602df38e4a5464f0b748a54002e0d69ac6d2ad0a197cc99ec \
- --hash=sha256:21e764fd1e70b6a3e205a0e46f3051701f98a8cb3fad66eeb80e48bb502f8698 \
- --hash=sha256:231ddcbb35e2ff8973e1365db41fe0572662893b99a05deb183b68ad4c0c8bd4 \
- --hash=sha256:253a4a220747e8b5faf57ec320c4f5efb0cef05f647420bf267143ec15dba10a \
- --hash=sha256:280081916dc341820640489a66e4696049401ef1cf6dd672f672e70ad915aca3 \
- --hash=sha256:2a441ea71902098ffe78c5abe6c494f44160b4af614ed16c3d9a3b1d17fd8ee2 \
- --hash=sha256:304b13570067b2547562e308af560b3963857b1fa90bd6afd978130130fe2d6a \
- --hash=sha256:32286a2c8d167e897177b673176c1e3e00d4057caf5d2b64eef9a3666b03018e \
- --hash=sha256:33bdcc2a32c0a0e861f60841a512c8acc658c87c2ac59d89e3a46dacf7d866e4 \
- --hash=sha256:375b83ed0aecfce76c16d198fbc21f3b11b337d68662bea0a995046682a11419 \
- --hash=sha256:3c09a49d6cde137258beb3d551994a2927fd35ad5cf96aed573f61bbd67c5f84 \
- --hash=sha256:3d92613ec25e43b05f042302531ec0f00b8445190e43325880cbd6ab7c2581da \
- --hash=sha256:40a126142a56b2dfc0aacbad1de8310cbf60da7656db0e6b16eebd48e3e93519 \
- --hash=sha256:416c229f77e5ea25b3dfd4b582f8d73d7e43c22320302b9ab128a2d3a0b38efe \
- --hash=sha256:432786d3561e69aeeae6c7e8648964ce0ad05736120135601f87ac26b9c83381 \
- --hash=sha256:43b9e366a31fdd1c87d0eb08f579b4a82b723ea54338f040d6b4e518a026ea29 \
- --hash=sha256:440eede837960000d74978f0eba527be106b5b9aee0daf779d395276ed0b0614 \
- --hash=sha256:45b0cc4e3556cd875e09102988d1ab8356c998b596c9fced84547c8138b487a0 \
- --hash=sha256:476743fe6dfe14a2da12e3ac79125dc84a3b2cf8094369a47a1529b0cd8549fe \
- --hash=sha256:4773092f8019072343a7447203308b176e10199920eb02d6195e81bbb3274c29 \
- --hash=sha256:4b3dac63058cc36820b0dd072f89898604e2d39686fe05321729d00d8ac185a0 \
- --hash=sha256:4d1c96a7a18b9690a4d46df09e3e3382406ae3213727cd1019ebade1c4a81917 \
- --hash=sha256:51307f5c71007673a2bf8232ad973483d281e74cb99c8c5a990af1eefa6277d9 \
- --hash=sha256:51447e9aa2684679af07ca5021c3db526e0284347ebf4ffcec1154c3350cfe32 \
- --hash=sha256:58150c9f9b9a552505912d182ccdf26f6396fb6094816ceebcbb20eecabaed94 \
- --hash=sha256:5b10cd92fc5c498b35a8635df6d5a100207f88b63a4dc1de7ef9a548e1e2cd63 \
- --hash=sha256:5e226f6218febc71f6c1fc2fafb91c226f75bdc1d8fb12d66823716e891608fd \
- --hash=sha256:609b3ba8fcc0fb5ab7af00719d0fb6ad0cb518e48e7712d12fd68f1327951198 \
- --hash=sha256:60f44ade2cf573dad7a277e6f8ca9a51a21dda572b13bd7d8539bb3cd5dbedde \
- --hash=sha256:611057cc5d5c0afc743ba8be6bd828c17e0aaa8643f9d0a9b9bb7dea80eb8012 \
- --hash=sha256:6366a16e1a25018694d6a5d784d09b046edc9eac40ea2b54065c3052672516a1 \
- --hash=sha256:65a7ff3f705e57d392f7261b6d0550fe137c3019477431f1c355e0db0a7d3e15 \
- --hash=sha256:673611bbd43f0810bec0b0f028ddeaaa501190339cac411f347ac76917c3ae7b \
- --hash=sha256:67830fc78e67501f47bb950471b2dcb9b35b140084429318e862895a8e89c993 \
- --hash=sha256:68ce9f4d6b26d5ccbf7fd4459bf75f74a0a146677ebba80597df60cbdb20e6f4 \
- --hash=sha256:68e5f26a1ad57ded6d1cfb85331d1c1a195314756471d97758c48498bb4dcdf5 \
- --hash=sha256:69b157c5d3292bcd443faca052f3096f637f1e074b98212a933c074ae23dc3b8 \
- --hash=sha256:75286256590a6320cf106a0d28970d3560aad9ee09aa7b34fb40524792436d35 \
- --hash=sha256:78841cccf1af7b40f6f716338d50c0902dbe88d9f800b3c973b7a9a0a693a642 \
- --hash=sha256:78fa18e436a1a0e58dbd7e02fc4473f3f32cceb12df9dfca542d075961c307d2 \
- --hash=sha256:79580094b00d1789d1f93ea55bc43cb2f611910c72235b7657f3482ddcc1b22d \
- --hash=sha256:7b86a2b16095d250c6f58b3d9b2eee6f4147754344f3dab0922f7c9bf7d226c9 \
- --hash=sha256:83aed2c10721ddd90f68140685391b50811a880af20654c59af6b6c66c40513c \
- --hash=sha256:84fd18bcc17526fc2b3c1af7d2b9217d32c9c04448c16ec693b9b4f1985c3d33 \
- --hash=sha256:871ff67ea1aad4dfd91736464934d56b32dac49f9fbe16cddba36198a7b3a0db \
- --hash=sha256:898f0e9068ca27d37f8e83a5b962821df851532e6c4a7d615c1c033f9da6eedf \
- --hash=sha256:8a79d9f4d8001473a30c163556b3c3bfebec837495a412dde78b51672f6134f9 \
- --hash=sha256:8c041122946b7ba21bb32c45b1aa57b1be35527690aeb3c5c234521085632eee \
- --hash=sha256:90c44bc373b7687f6948b693cceaea1348ae0975d7474746559494468e3c1d84 \
- --hash=sha256:9104ed0bd76a429d46f9ec0dbc9b08ad1d2dcdf2b00a5a0daa1c145329b35b44 \
- --hash=sha256:920079c3f7456fa213e0829ed2073aaa727fd39d889ead5b4f35d0de5460d04f \
- --hash=sha256:93d59d504b230e83c7a843251681959a0b6a9cd76f6e146ce1b8a80eb8739af9 \
- --hash=sha256:9b2aff1c7b3884512b9512c3eaadd9bab39fb45042ffaaa1dd08ff2b9f8109d9 \
- --hash=sha256:9b8e0f3107e2200b76f6054de99016eac3ee6762713587b36baaa7e4bd2ae177 \
- --hash=sha256:9bb41182d93ea91f60b4bc8fbf4c820c69ef8a12ab2d917f3f1834f1acad07e8 \
- --hash=sha256:9cdef90ae47919cae358d8ab15797a800ed41da7aba5d72419fb510729e2ed4b \
- --hash=sha256:a1786910334ed46ab1dd73222f2cd1e05c2c3bb39f6dddb4f8b36fc382058a39 \
- --hash=sha256:a4cfde78a9f2880208d16a93b795726a3017d5977e08d1e162a7a31322479c41 \
- --hash=sha256:a4fbdde9dd4a9ce5fd52c2b3a347bb50cc89483ef783f1cb00d408c13f7a96c0 \
- --hash=sha256:aa99adc8f081b475a12843953db36831eaf83ec33eb46a90629ca6a5de45a616 \
- --hash=sha256:ac351b3b8014eead140e77e9717e2992c6bbe30b63bc3422422eb84865412e3d \
- --hash=sha256:ad41ba96094304aa090f5a30cb6e4fb3b3f1c264c523394b4c39bbacc4dc92ba \
- --hash=sha256:b5314963fce9b0b12743891de876e724997864ee22aa496f903f426c7e2fa5b2 \
- --hash=sha256:bcf74c1df76758a395bf0af608c04c82257523f55c9868b334f06270d0f2112b \
- --hash=sha256:bd47ba7fc3ca94896759ea0109775132d3e7ab921fbf54038e1bab2e46c313c9 \
- --hash=sha256:c0323c9daef75ef2e5083624b4585018a0c9d5e3b40f607eed81a311270b934b \
- --hash=sha256:c1225416b463483160e4af85d5fc3a9690ccb53fd4b1865a6437825f5ede3209 \
- --hash=sha256:c1c948747b03be832dceed96ca815cef7360de9aa19d37c730f8e3f6101aca48 \
- --hash=sha256:c25fe15c70c59eb7c5ce8c06a1f3fa1da0ecc5ea1e7a5922c40fd2fa9b0d5046 \
- --hash=sha256:cc1b0fff8ead343dae06305f954eb8468ba0ec1a97881f42489d198e4ce3c632 \
- --hash=sha256:cd6280cf040f233bd7d3407b743b4b4c74f70e8e1c4199cb112a62c941c0772a \
- --hash=sha256:cd6c3d4b783c556fa00bf540854e42f135e2f256abd29669fcd0da0f2dec79c2 \
- --hash=sha256:d4d6fcde76f94f5cb9e43e9e9a61f16dacefd228cbbf6f1a09bd9b219a92f1a1 \
- --hash=sha256:ddf4af30b417d9fe16481e9b81c27ab2a7cde1ff7ba3e85653b02db7d145dc7b \
- --hash=sha256:df115d4d83168fdf2cae48ef1ff6d1cb4c466364e30861b37121de0f3bf1b990 \
- --hash=sha256:df7276909358e5635ae203673ab7e509ddd224225a8d6b0790bf13eb2bde1cc5 \
- --hash=sha256:e4fd89cc178bced6ad29cb3e6dd4aa63fa5017c3524dbd0b25998fb64a87cc8b \
- --hash=sha256:e9701d0049d92c16703a42771b98d560b95248949f23f8cf7b4eddd201814fb9 \
- --hash=sha256:ee2f2a527e3c1a6e6411eb4209642e138b544a2d72fe5d0d76daf77b24063534 \
- --hash=sha256:f7fb7d750cfa0a070d2c24e831fd3481019a60dd317ea2b39acbcebc08b6ed81 \
- --hash=sha256:f840ed6d8ecba8255df8c42b87fadeda98ddfc6eeec05e2dc66e26d46dd6f58a \
- --hash=sha256:f86c6358749bd4fda175388691e3ba8c46e24c5347d0afd20f9b7edfc9faf07d \
- --hash=sha256:fa36ec09ef71d158186bc79e359ff5fdd6e7996fe8ab638f00d6b93139ba4fcf \
- --hash=sha256:fe2c7201c642b7c308f1675355ad7ff7b66acfe3541625efe5a3ad38f29d6115
- # via requests
click==8.4.2 \
--hash=sha256:9a6cea6e60b17ebe0a44c5cc636d94f09bd66142c1cd7d8b4cd731c4917a15f6 \
--hash=sha256:e6f9f66136c816745b9d65817da91d61d957fb16e02e4dcd0552553c5a197b76
- # via
- # streamlit
- # uvicorn
+ # via uvicorn
cryptography==49.0.0 \
--hash=sha256:026ac7423e6fa66872d3bf889be5974507da3944f866f704fa200eadacd00001 \
--hash=sha256:07cab27cc7b7e0fd28e5e26bb9eeedde5c135c868b46de4a27845abe94af6122 \
@@ -283,14 +162,6 @@ cryptography==49.0.0 \
--hash=sha256:f89660a348f4f78a92366240a61404e337586ef7f5909a2fef59ca88ef505493 \
--hash=sha256:fc1e275c2f1d97b1a6450b8b0ea3ebfa6e087a611c2b26cb2404d48588abab7b
# via -r requirements.txt
-gitdb==4.0.12 \
- --hash=sha256:5ef71f855d191a3326fcfbc0d5da835f26b13fbcba60c32c21091c349ffdb571 \
- --hash=sha256:67073e15955400952c6565cc3e707c554a4eea2e428946f7a4c162fab9bd9bcf
- # via gitpython
-gitpython==3.1.52 \
- --hash=sha256:79a36ee1f83523214a3f72d56cf1c4e490d577dc61af77e43dfe5862bd9da01a \
- --hash=sha256:de0a8ad86274c6e75ae8b37dd055ba68f19818c813108642263227b20775b48e
- # via streamlit
h11==0.16.0 \
--hash=sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1 \
--hash=sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86
@@ -346,759 +217,28 @@ httptools==0.8.0 \
--hash=sha256:ef7c3c97f4311c7be57e2986629df89d49cb434dbff78eafcd48c2bff986b15a \
--hash=sha256:f256d6ce930c52ca1cb2a960b7da03548c454e7d28b06059ad41bfe789036ce0 \
--hash=sha256:fe2a4c95aeba2209434e7b31172da572846cae8ca0bf1e7013e61b99fbbf5e72
- # via
- # -r requirements.txt
- # streamlit
+ # via -r requirements.txt
idna==3.18 \
--hash=sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2 \
--hash=sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848
- # via
- # anyio
- # requests
-itsdangerous==2.2.0 \
- --hash=sha256:c6242fc49e35958c8b15141343aa660db5fc54d4f13a1db01a3f5891b98700ef \
- --hash=sha256:e0050c0b7da1eea53ffaf149c0cfbb5c6e2e2b69c4bef22c81fa6eb73e5f6173
- # via streamlit
-jinja2==3.1.6 \
- --hash=sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d \
- --hash=sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67
- # via
- # altair
- # pydeck
-jsonschema==4.26.0 \
- --hash=sha256:0c26707e2efad8aa1bfc5b7ce170f3fccc2e4918ff85989ba9ffa9facb2be326 \
- --hash=sha256:d489f15263b8d200f8387e64b4c3a75f06629559fb73deb8fdfb525f2dab50ce
- # via altair
-jsonschema-specifications==2025.9.1 \
- --hash=sha256:98802fee3a11ee76ecaca44429fda8a41bff98b00a0f2838151b113f210cc6fe \
- --hash=sha256:b540987f239e745613c7a9176f3edb72b832a4ac465cf02712288397832b5e8d
- # via jsonschema
-liboqs-python==0.15.0 \
- --hash=sha256:969b27c65c99f973f8c991472151331814c9f02642115fe4d6391bcdc33433a4
+ # via anyio
+liboqs-python==0.16.0 \
+ --hash=sha256:50bb11a9767b88f68099cedef8de09f798f31eaba1aa18b483988bfcd50df440 \
+ --hash=sha256:ee01445ccfbeab8dced956a642ce1eb5ba69438caba7ec291eea14083627d5c0
# via -r requirements.txt
-markupsafe==3.0.3 \
- --hash=sha256:0303439a41979d9e74d18ff5e2dd8c43ed6c6001fd40e5bf2e43f7bd9bbc523f \
- --hash=sha256:068f375c472b3e7acbe2d5318dea141359e6900156b5b2ba06a30b169086b91a \
- --hash=sha256:0bf2a864d67e76e5c9a34dc26ec616a66b9888e25e7b9460e1c76d3293bd9dbf \
- --hash=sha256:0db14f5dafddbb6d9208827849fad01f1a2609380add406671a26386cdf15a19 \
- --hash=sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf \
- --hash=sha256:0f4b68347f8c5eab4a13419215bdfd7f8c9b19f2b25520968adfad23eb0ce60c \
- --hash=sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175 \
- --hash=sha256:116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219 \
- --hash=sha256:12c63dfb4a98206f045aa9563db46507995f7ef6d83b2f68eda65c307c6829eb \
- --hash=sha256:133a43e73a802c5562be9bbcd03d090aa5a1fe899db609c29e8c8d815c5f6de6 \
- --hash=sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab \
- --hash=sha256:15d939a21d546304880945ca1ecb8a039db6b4dc49b2c5a400387cdae6a62e26 \
- --hash=sha256:177b5253b2834fe3678cb4a5f0059808258584c559193998be2601324fdeafb1 \
- --hash=sha256:1872df69a4de6aead3491198eaf13810b565bdbeec3ae2dc8780f14458ec73ce \
- --hash=sha256:1b4b79e8ebf6b55351f0d91fe80f893b4743f104bff22e90697db1590e47a218 \
- --hash=sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634 \
- --hash=sha256:1ba88449deb3de88bd40044603fafffb7bc2b055d626a330323a9ed736661695 \
- --hash=sha256:1cc7ea17a6824959616c525620e387f6dd30fec8cb44f649e31712db02123dad \
- --hash=sha256:218551f6df4868a8d527e3062d0fb968682fe92054e89978594c28e642c43a73 \
- --hash=sha256:26a5784ded40c9e318cfc2bdb30fe164bdb8665ded9cd64d500a34fb42067b1c \
- --hash=sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe \
- --hash=sha256:2a15a08b17dd94c53a1da0438822d70ebcd13f8c3a95abe3a9ef9f11a94830aa \
- --hash=sha256:2f981d352f04553a7171b8e44369f2af4055f888dfb147d55e42d29e29e74559 \
- --hash=sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa \
- --hash=sha256:3524b778fe5cfb3452a09d31e7b5adefeea8c5be1d43c4f810ba09f2ceb29d37 \
- --hash=sha256:3537e01efc9d4dccdf77221fb1cb3b8e1a38d5428920e0657ce299b20324d758 \
- --hash=sha256:35add3b638a5d900e807944a078b51922212fb3dedb01633a8defc4b01a3c85f \
- --hash=sha256:38664109c14ffc9e7437e86b4dceb442b0096dfe3541d7864d9cbe1da4cf36c8 \
- --hash=sha256:3a7e8ae81ae39e62a41ec302f972ba6ae23a5c5396c8e60113e9066ef893da0d \
- --hash=sha256:3b562dd9e9ea93f13d53989d23a7e775fdfd1066c33494ff43f5418bc8c58a5c \
- --hash=sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97 \
- --hash=sha256:4bd4cd07944443f5a265608cc6aab442e4f74dff8088b0dfc8238647b8f6ae9a \
- --hash=sha256:4e885a3d1efa2eadc93c894a21770e4bc67899e3543680313b09f139e149ab19 \
- --hash=sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9 \
- --hash=sha256:509fa21c6deb7a7a273d629cf5ec029bc209d1a51178615ddf718f5918992ab9 \
- --hash=sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc \
- --hash=sha256:591ae9f2a647529ca990bc681daebdd52c8791ff06c2bfa05b65163e28102ef2 \
- --hash=sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4 \
- --hash=sha256:69c0b73548bc525c8cb9a251cddf1931d1db4d2258e9599c28c07ef3580ef354 \
- --hash=sha256:6b5420a1d9450023228968e7e6a9ce57f65d148ab56d2313fcd589eee96a7a50 \
- --hash=sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698 \
- --hash=sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9 \
- --hash=sha256:77f0643abe7495da77fb436f50f8dab76dbc6e5fd25d39589a0f1fe6548bfa2b \
- --hash=sha256:795e7751525cae078558e679d646ae45574b47ed6e7771863fcc079a6171a0fc \
- --hash=sha256:7be7b61bb172e1ed687f1754f8e7484f1c8019780f6f6b0786e76bb01c2ae115 \
- --hash=sha256:7c3fb7d25180895632e5d3148dbdc29ea38ccb7fd210aa27acbd1201a1902c6e \
- --hash=sha256:7e68f88e5b8799aa49c85cd116c932a1ac15caaa3f5db09087854d218359e485 \
- --hash=sha256:83891d0e9fb81a825d9a6d61e3f07550ca70a076484292a70fde82c4b807286f \
- --hash=sha256:8485f406a96febb5140bfeca44a73e3ce5116b2501ac54fe953e488fb1d03b12 \
- --hash=sha256:8709b08f4a89aa7586de0aadc8da56180242ee0ada3999749b183aa23df95025 \
- --hash=sha256:8f71bc33915be5186016f675cd83a1e08523649b0e33efdb898db577ef5bb009 \
- --hash=sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d \
- --hash=sha256:949b8d66bc381ee8b007cd945914c721d9aba8e27f71959d750a46f7c282b20b \
- --hash=sha256:94c6f0bb423f739146aec64595853541634bde58b2135f27f61c1ffd1cd4d16a \
- --hash=sha256:9a1abfdc021a164803f4d485104931fb8f8c1efd55bc6b748d2f5774e78b62c5 \
- --hash=sha256:9b79b7a16f7fedff2495d684f2b59b0457c3b493778c9eed31111be64d58279f \
- --hash=sha256:a320721ab5a1aba0a233739394eb907f8c8da5c98c9181d1161e77a0c8e36f2d \
- --hash=sha256:a4afe79fb3de0b7097d81da19090f4df4f8d3a2b3adaa8764138aac2e44f3af1 \
- --hash=sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287 \
- --hash=sha256:b8512a91625c9b3da6f127803b166b629725e68af71f8184ae7e7d54686a56d6 \
- --hash=sha256:bc51efed119bc9cfdf792cdeaa4d67e8f6fcccab66ed4bfdd6bde3e59bfcbb2f \
- --hash=sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581 \
- --hash=sha256:bdd37121970bfd8be76c5fb069c7751683bdf373db1ed6c010162b2a130248ed \
- --hash=sha256:be8813b57049a7dc738189df53d69395eba14fb99345e0a5994914a3864c8a4b \
- --hash=sha256:c0c0b3ade1c0b13b936d7970b1d37a57acde9199dc2aecc4c336773e1d86049c \
- --hash=sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026 \
- --hash=sha256:c4ffb7ebf07cfe8931028e3e4c85f0357459a3f9f9490886198848f4fa002ec8 \
- --hash=sha256:ccfcd093f13f0f0b7fdd0f198b90053bf7b2f02a3927a30e63f3ccc9df56b676 \
- --hash=sha256:d2ee202e79d8ed691ceebae8e0486bd9a2cd4794cec4824e1c99b6f5009502f6 \
- --hash=sha256:d53197da72cc091b024dd97249dfc7794d6a56530370992a5e1a08983ad9230e \
- --hash=sha256:d6dd0be5b5b189d31db7cda48b91d7e0a9795f31430b7f271219ab30f1d3ac9d \
- --hash=sha256:d88b440e37a16e651bda4c7c2b930eb586fd15ca7406cb39e211fcff3bf3017d \
- --hash=sha256:de8a88e63464af587c950061a5e6a67d3632e36df62b986892331d4620a35c01 \
- --hash=sha256:df2449253ef108a379b8b5d6b43f4b1a8e81a061d6537becd5582fba5f9196d7 \
- --hash=sha256:e1c1493fb6e50ab01d20a22826e57520f1284df32f2d8601fdd90b6304601419 \
- --hash=sha256:e1cf1972137e83c5d4c136c43ced9ac51d0e124706ee1c8aa8532c1287fa8795 \
- --hash=sha256:e2103a929dfa2fcaf9bb4e7c091983a49c9ac3b19c9061b6d5427dd7d14d81a1 \
- --hash=sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5 \
- --hash=sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d \
- --hash=sha256:e8fc20152abba6b83724d7ff268c249fa196d8259ff481f3b1476383f8f24e42 \
- --hash=sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe \
- --hash=sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda \
- --hash=sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e \
- --hash=sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737 \
- --hash=sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523 \
- --hash=sha256:f42d0984e947b8adf7dd6dde396e720934d12c506ce84eea8476409563607591 \
- --hash=sha256:f71a396b3bf33ecaa1626c255855702aca4d3d9fea5e051b41ac59a9c1c41edc \
- --hash=sha256:f9e130248f4462aaa8e2552d547f36ddadbeaa573879158d721bbd33dfe4743a \
- --hash=sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50
- # via jinja2
-narwhals==2.24.0 \
- --hash=sha256:42fdedf44e5b2ca7505630d45b4ac3058f38d8485cba9fe1652ca23152df7489 \
- --hash=sha256:b5c0f684ccd9d7475b564111e319a4964abcf2baf79d3cf6b1003d06ac9b828d
- # via altair
-numpy==2.4.6 \
- --hash=sha256:001fbb8e08d942dd57599e781f2472269ee7f2755fae407b4f67b2f0b17da3f1 \
- --hash=sha256:0280e0356c0829a18d9de1cb7eee50ec22ca639878d7240307ca0943d73cd2c4 \
- --hash=sha256:043191bfa8eab18c776647b62723ac9dddece59743b13f49b2016094129c2b3f \
- --hash=sha256:06ca2f61ec4385a07a6977c55ba998a4466c123642b4a32694d3128fce18c079 \
- --hash=sha256:0a041d3d761dc3c35cc56ce0351506a02bcbc25f7b169f652435141a17db9096 \
- --hash=sha256:0ab0a9c4ffb1a6d95ef519fe4247dba8eb6b18ad93999f76b7f657039acabd47 \
- --hash=sha256:0c9136e14ed34a9e343a31c533d78a9813a69a3148332bce5e9821cb2f996e66 \
- --hash=sha256:110f8b71aacb688ec69062bb7f6938a0f8acb01b7c1c4beb453c65b6d234584d \
- --hash=sha256:112b06a867b235ef466ed3508ddf0238050df9c727cafb5301ac385b899189a1 \
- --hash=sha256:17f9ade344e7d9b464a084d69bcf18fc691cb1db67c62ed80820bf4926d78f0e \
- --hash=sha256:1e254a00cdf42b1e4d5b3d68d33af63268d41340d8885df2ab6470f2e1500147 \
- --hash=sha256:1e978ec1e8bd0e0e4de6bb75de9d30cbb74db6b6a2bb727618613703ca0167dd \
- --hash=sha256:25c692919ac5a01f170a3bfcd62d745b24fd095c353d50812637d6fcab442e75 \
- --hash=sha256:260a5d70215b61ab4fadf5c7baacd64821842975eea312125ed3c39a6391b063 \
- --hash=sha256:2803abfebfc990042cd494d8ce2d5f82e9d847af6d35ec486923aa19dbad5e73 \
- --hash=sha256:29a287e0cf63ff528da061de6b9f64a4618da591ca1046aafc54062e40ca7eab \
- --hash=sha256:29cb7f67d10b479ff07c17d33e39f78c07f71c40ef30d63c153d340e96cd3fb4 \
- --hash=sha256:3213d622a0283a39a93d188f3cf72b26862df52fbb4ca3697f51705016523d41 \
- --hash=sha256:33111801a01c12a8a1e3721f0a9232f8cfc8ae2c6b7098167e6f623c6073f402 \
- --hash=sha256:357cc07a6d7b0b182ff02249616a03742827ebb1277546b5c7cd7f7620a45698 \
- --hash=sha256:38efbc8de75c7a0fc1ac190162d892787f3f47b57cc291231aafee36b80982b7 \
- --hash=sha256:4081eb135ac24158bd51cdfbef16f1c64df7063b1143f24731387137c092bec8 \
- --hash=sha256:40fdc1ae7125e518ea98e53e69a4ebc27e1fd50510c47b7ea130cf21e5e1d42b \
- --hash=sha256:4cfe66903cc32a9921a6733d96b19bb6abf310397581bbad89c228f5abaf0ee8 \
- --hash=sha256:511dbaf848decaaaf4b4ca48032619fb3138710c4bf7da7617765edad1ef96b0 \
- --hash=sha256:55cced7c52e981362f708ad635198e97a752dfba412cc03c23bbf3bd8d5cd662 \
- --hash=sha256:56b39e5e0622a09a25bf5baf62f4bcf0cb8a41ae6e2819cf49bbc5a74c083f91 \
- --hash=sha256:5dbbdb29840ca3d91ee0fece42fc29278886d908280bfec0a5846c6f901a3eb0 \
- --hash=sha256:5f9fb9157b4ce2971008323afe46053787b526ef624fea915b261468a8421a0f \
- --hash=sha256:6180d8b35af935aed8ece3a85e0a43f87393ae0ac87c8d2c8bd2c993f7270ef3 \
- --hash=sha256:68a5124b13fa6cc2086764a20005d30bc0548146f7f5322f02fce212ca14317f \
- --hash=sha256:68bb27509ac1b9a3443094260f6326150663b06abe40b73a2f81160623da5b67 \
- --hash=sha256:6f41ae150c4e32db4f3310cdaf64b1593a03dbabe29eec77fc9b50fe64061df6 \
- --hash=sha256:7265a2f3d436e54ef9f2b52b5c937e6be778781bd97a590319d7348f1c1ca997 \
- --hash=sha256:72fbe16c6fac95aedf5937fa873445cec2110be35d8a4e9433d7501fd98dae6b \
- --hash=sha256:7d92c3819208a60205a12a245c91ad70cb0a85336659b19b834205573ac8456e \
- --hash=sha256:8155154c7c691289fe18f510b5d4657c68c67989f293f0535a91360392ff6538 \
- --hash=sha256:81a1cca95ed5bb92aa8b10dd2cdc9a0d3853a50fad926c28b5d7e8ea54389627 \
- --hash=sha256:89cd468399cfd2504718f0ba50e410dca55a170b61a02ad92bb18c8a65186e93 \
- --hash=sha256:8ad03c0965fb3c692200e74d458ca28c1dbb4ce96f9a479a8aa041ad5fabca02 \
- --hash=sha256:90f9849678c75fe7afa2d348ac842c168b0a4d3d61919687216dfc547976d853 \
- --hash=sha256:948424b06129ce883307e8cff868c31396d8dc7630a59c61d70d98dbe70f222c \
- --hash=sha256:9cd5ffd25db4e7ba6a375693b3fc0fc1791ec636c17db3720da19bde7180ec43 \
- --hash=sha256:a0df0043bdb289bde1f62da130d20df23d58b45429f752bc7a8fc5325a225ecd \
- --hash=sha256:a2c306dea656c12c68f51f4cea133cbe78ca7435eb28c735eac1d3ebe73be6e8 \
- --hash=sha256:a7830bab239b79cda9c08c2da014761cafb48da6150e1da17ac06283f43b6089 \
- --hash=sha256:a7c711e21628b52034bb5ab8d1bce291f752fcc5e92accc615778acee1ff4778 \
- --hash=sha256:aaf159caa35993cb1f56fb9b8e4610d35758e7ca005412eb1daa856a78c9c4b1 \
- --hash=sha256:ae506e6902902557576a26ff33eda8695e7ecb3cb36c3b573a0765dee114ebdb \
- --hash=sha256:b507f5c4c1d508876d1819b6bf9a49d365b96320b5d4993426b33a23ca4b8261 \
- --hash=sha256:bf162abab1c1a736333192707cef898e735a5ca00f38f27eeedf44b39d9e85eb \
- --hash=sha256:c1a2af6c6ef86344a6b0db6b97834208bf598db514f2b155042439b62605601a \
- --hash=sha256:c2d37ab77531417474168eb79d6d80b14f821a966818505d03013d0833edb7a8 \
- --hash=sha256:c4fc99836233ea196540b17ab0983aff60ed07941751930f5f4d05bc3b3b7359 \
- --hash=sha256:d581b735e177fdcdce6fed8e7e8880a3fb6ee4e3653a3ac6af01c6f4c03effc5 \
- --hash=sha256:d6da64deb6b8ed903e7560180a92f2d804ee1ba5eeb849ac2748b8c1aba1f6d7 \
- --hash=sha256:d8e8286dd7cea7895157318d1b91cdacac64c479f3cbc8dce548331728484751 \
- --hash=sha256:ddea102b48f9e339f3948bf22040944184627a30fdf7f858667673b9c5f033c8 \
- --hash=sha256:dfa20cc6ca228e6b155b11da03825975ce66aea520985dbbddf0f2a5a495c605 \
- --hash=sha256:e3e5193ef5a3dc73bceee50f7fdc2c90dbb76c42df8d8fae3d1067a583df579e \
- --hash=sha256:e3eeb0aabd6bd5ce64faae67e9935203a6991b4bc2a485a767fbafb2c5125f45 \
- --hash=sha256:e5805d5a22fd19c8ccff10a9561f9df94436b0545619ea579db2d3c35294bce2 \
- --hash=sha256:e85b752a1e912b70eaad4fafbd4d1238007ab221de2009b9a2f5ae7461239895 \
- --hash=sha256:eaf7fa2de5c0be8ae6ff8e9bea2ccd725e980541244521d8d4b5f3354a27babe \
- --hash=sha256:ebfb099f8dcf083deef3ac1ca4c1503f387cf76296fcb3816b66f5ecb5f54fdb \
- --hash=sha256:ece3d2cfe132e7d51f44a832b303895e6f2d499c5e74dfbdb06ee246147a304a \
- --hash=sha256:ed9749eef4cbd126da3dc1d6bcb3a57f5eb7ac6a6484146bdbf743f552dfc577 \
- --hash=sha256:ede83e07a75dd06bc501566c1eca2afc0d61677c1472ac9ad93fdee6e638a48d \
- --hash=sha256:ef4aea96ce4d3b074422cb4f2f64e216bf9e213004bb58ecfdf50ea02ea8eb9a \
- --hash=sha256:f3a3570c4a2a16746ac2c31a7c7c7b0c186b95ce902e33db6f28094ed7387dda \
- --hash=sha256:f407cb6b8e9d6d8c626bc73c945db1706035af8fd632295547bf1c9e46d092d6 \
- --hash=sha256:f74a575920ab21fe304421a3fc28793d82e299cae9eccb37084e9fc7f3617c20
- # via
- # pandas
- # pydeck
- # streamlit
-packaging==26.2 \
- --hash=sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e \
- --hash=sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661
- # via
- # altair
- # streamlit
-pandas==3.0.3 \
- --hash=sha256:0383c72c75cdcca61a9e116e611143902dbfd08bff356829c2f6d1cf40a9ca8c \
- --hash=sha256:05f1f1752b8533ea03f7f39a9c15b1a058d067bb48f4748948e7a8691e0510f2 \
- --hash=sha256:08d789b41f87e0905880e293cedf6197ce71fe67cc081358b1e148a491b9bd13 \
- --hash=sha256:0d589105b3c14645af1738ff279b2995102d8f7a03b0a66dc8d95550eb513e04 \
- --hash=sha256:13fc1e853d9e04743d11ba75a985ccbc2a317fe07d8af61e445a6fd24dacd6a6 \
- --hash=sha256:14da8316da4d0c5a77618425996bfb1248ca87fc2c1486e6fde4652bd18b5824 \
- --hash=sha256:1928e07221f82db493cd4af1e23c1bfca524a19a4699887975bff68f49a72bfb \
- --hash=sha256:261e308dfb22448384b7580cf719d2f998fe2966c92893c3e77d14008af1f066 \
- --hash=sha256:275c14e0fce14a2ec20eee474aecd305478ea3c1e6f6a9d8fe219a165542717e \
- --hash=sha256:335f62418ed562cfc3c49e9e196375c28b729dcef8543abf4f9438e381bf3c76 \
- --hash=sha256:3650109c0f22879df8bd6179ab9ee3d7f1d1d4e7e0094a3f0032d9f51e2e64ac \
- --hash=sha256:39436b377d56d2a2e52d0395bdbee171f01068e99af5250509aceeb929f765c7 \
- --hash=sha256:3c20a521bbb85902f79f7270c80a59e1b5452d96d170c034f207181870f97ac5 \
- --hash=sha256:3e91cec1879ada0624fc3dc9953c5cbd60208e59c0db28f540c5d6d47502422f \
- --hash=sha256:455f6f8139d4282188f526868dbc3c828470e88a3d9d59a891bd46a455f21b98 \
- --hash=sha256:46997386d528eb40376ecd6b033cf4a8a1e5282580f68f43de875b78cba2199d \
- --hash=sha256:4db8c527972a821cf5286b40ccc57642a39bc62e62022b42f99f8a67fca8c3a1 \
- --hash=sha256:4e15135e2ee5df1063313e2425ceef8ac0f4ae775893815b0923651b806a5639 \
- --hash=sha256:51b1fe551acb77dac643c6fda86084d8d446c10fe64b06a9cc29c4cc8540e7f2 \
- --hash=sha256:557409bc4178e70ee8d9ddb494798e51ebf6ea59330f6be22c51bab2a7db6c49 \
- --hash=sha256:5cc09a68b3120e0f54870dede8287a7bb1fa463907e4fcec1ea77cab6179bf7a \
- --hash=sha256:60ae316d3fd75d1858d450d0db0103ea2be3e7d4a95ec2f064f7e2ae63f7b028 \
- --hash=sha256:6674ab18ad8c57802867264b00e15e7bb904700cdd9046e3b2fa1fce237439ea \
- --hash=sha256:67b3b64c11910cfa29f4e94a14d3bff9ee693b6fc76055e7cad549cee0aec5fa \
- --hash=sha256:696a4a00a2a2a35d4e5deb3fc946641b96c944f02230e4f76137fe35d806c4fc \
- --hash=sha256:6dc0b3fd2169c9157deed50b4d519553a3655c8c6a96027136d654592be973a9 \
- --hash=sha256:7e65d5407dc0b394f509699650e4a2ec01c0514f21850f453fa60f3be79a5dbf \
- --hash=sha256:819959dab7bbd0049c15623fbac4e29a191b9528160a61fb1032242d8ced2d9c \
- --hash=sha256:8a1e45c80cceb3b4a21bc5939d52e8cbd8d9b7305309219d59e9754d9ce09e27 \
- --hash=sha256:9c39be2d709d01fa972a0cabc522389fceca4f3969332ba25a7d6c5802cf976a \
- --hash=sha256:9d71c63ae4ebdbf70209742096f1fc46a83a0613c99d4b23766cced9ff8cd62a \
- --hash=sha256:a2d2dff8a04f3917b55ab3910c32990f8ddf7eceba114947838cefa976a68977 \
- --hash=sha256:a4eeb6830daf35a71cc09649bd823e2b542dac246cdee9614c6e4bd65028cd6a \
- --hash=sha256:a55066a0505dae0ba2b50a46637db34b46f9094c65c5d4800794ef6335010938 \
- --hash=sha256:a82d532a3351d435432cd913edbccaf8b8e01d4dd0e5ced5a8d2e8ecd94c7e44 \
- --hash=sha256:b168fc218fd80a6cbdbdbc1a97ddc7889ed057d7eb45f50d866ceab5f39904c4 \
- --hash=sha256:b2c95f8bfc1ee412bf482605d7bfd30c12d1d26bd59fdd91efeef1d4718decb1 \
- --hash=sha256:ba7e08b9ac1d54569cd1e256e3668975ed624d6826f7b68df0342b012007bddb \
- --hash=sha256:bab900348131a7db1f69a7309ef141fd5680f1487094193bcbbb61791573bf8f \
- --hash=sha256:bd3a518890b400d32f9023722dc9a9a5c969f00b415419a3c06c043f09bb5d7d \
- --hash=sha256:c7be265b62cef88e253a941e4698604973736dcfe242fdb5198f0f7bc473cdcc \
- --hash=sha256:d26cbe1fcfc12e8fd900e2454163e466b2d3af84f7c75481df7683ffc073d870 \
- --hash=sha256:d4be06d68f9ddcfc645b87534911da79a8fbffc7573c80e0edcf42a5020624d8 \
- --hash=sha256:d72828c20c6d6e83e1e22a6a3b47b326b71664112fa9705dcbccfd7a39b62085 \
- --hash=sha256:dd1a5d1def6a46002e964510bdc67c368aa0951df5d1d9f8365336f5a1f490cd \
- --hash=sha256:e3a2ec42c98ffa2565a67e08e218d06d72576d758d90facb7c00805194d8f360 \
- --hash=sha256:f8894dc474d648fe7b6ff0ca9b0bd73950d19952bc1a6534540762c5d79d305c \
- --hash=sha256:fed2ff7fd9779120e388e285fc029bd5cf9490cdd2e4166a9ee22c0e49a9ab09
- # via streamlit
-pillow==12.3.0 \
- --hash=sha256:00808c5e14ef63ac5161091d242999076604ff74b883423a11e5d7bbb38bf756 \
- --hash=sha256:04f01d28a6aaff387bf842a13be313df23ba0597a44f1a976c9feb3c6ff4711a \
- --hash=sha256:06ff022112bc9cbf83b60f8e028d94ad87b60621706487e65f673de61610ab59 \
- --hash=sha256:0740a512dc522224c77d9aa5a8d70d8b7d73fb91f2c21125d8d025d3b8990e45 \
- --hash=sha256:0847a763afefb695bc912d7c131e7e0632d4edc1d8698f58ddabec8e46b8b6d3 \
- --hash=sha256:0dd2064cbc55aaec028ef5fbb60fa47bb6c3e7918e07ff17935284b227a9d2df \
- --hash=sha256:0feb2e9d6ad6c9e3c06effe9d00f3f1e618a6643273576b016f591e9315a7139 \
- --hash=sha256:10e41f0fbf1eec8cfd234b8fe17a4caac7c9d0db4c204d3c173a8f9f6ef3232b \
- --hash=sha256:1182d52bc2d5e5d7d0949503aa7e36d12f42205dc287e4883f407b1988820d39 \
- --hash=sha256:164b31cd1a0490ab6efae01aa5df49da7061be0af1b30e035b6e9a1bfe34ee6e \
- --hash=sha256:1657923d2d45afb66526e5b933e5b3052e6bdea196c90d3abb2424e18c77dae8 \
- --hash=sha256:186941b6aef820ad110fb01fb06eb925374dc3a21b17e37ec9a53b250c6fe2d1 \
- --hash=sha256:1cca606cd25738df4ed873d5ad46bbdb3d83b5cbca291f6b4ff13a4df6b0bbe8 \
- --hash=sha256:21900ce7ba264168cd50defae43cd75d25c833ad4ad6e73ffc5596d12e25ac89 \
- --hash=sha256:236ff70b9312fb68943c703aa842ca6a758abfa45ac187a5e7c1452e96ef72b5 \
- --hash=sha256:23aceaa007d6172b02c277f0cd359c79492bbb14f7072b4ede9fbcaf20648130 \
- --hash=sha256:23d27a3e0307ec2244cc51e7287b919aa68d097504ebe19df4e76a98a3eea5bd \
- --hash=sha256:24870b09b224f7ae3c39ed07d10e819d06f8720bc551847b1d623832b5b0e28d \
- --hash=sha256:251bf95b67017e27b13d82f5b326234ca62d70f9cf4c2b9032de2358a3b12c7b \
- --hash=sha256:25b9b82bb22e6e2b3cd07b39c68b7b862001226cb3dff7130d1cb914121b39ed \
- --hash=sha256:28ce87c5ab450a9dd970b52e5aca5fe63ed432d18a2eaddd1979a00a1ba24ace \
- --hash=sha256:300557495eb45ebb8aec96c2da9c4be642fbf7cd937278b4013ba894ea8eb0eb \
- --hash=sha256:30f2aa603c41533cc25c05acd0da21636e84a315768feb631c937177db558931 \
- --hash=sha256:331b624368d4f1d069149002f25f44bc61c8919ce8ddb3c45bdad8f6e2d89510 \
- --hash=sha256:37d6d0a00072fd2948eb22bce7e1475f34569d90c87c59f7a2ec59541b77f7a6 \
- --hash=sha256:37dc8f7bbb66efe481bb60defacef820c950c24713fb44962ed6aa2a50966de1 \
- --hash=sha256:3b8182a766685eaa002637e28b4ec8d6b18819a0c71f579bf0dbaa5830297cce \
- --hash=sha256:3edce1d53195db527e0191f84b71d02022de0540bf43a16ed734ed7537b07385 \
- --hash=sha256:446c34dcc4324b084a53b705127dc15717b22c5e140ae0a3c38349d4efec071e \
- --hash=sha256:4998562bf62a445225f22e07c896bb04b35b1b1f2eb6d760584c9c51d7a5f78c \
- --hash=sha256:4b0a7fe987b14c31ebda6083f74f22b561fd3739bc0ac51e019622e3d72668c7 \
- --hash=sha256:4e8c2a84d977f50b9daed6eeaf3baef67d00d5d74d932288f02cb94518ee3ace \
- --hash=sha256:4f883547d4b7f0495ebe7056b0cc2aea76094e7a4abc8e933540f3271df27d9c \
- --hash=sha256:514435a37670e3e5e08f3945b68718b6ed329bb84367777e16f9f4dfe1e61a0f \
- --hash=sha256:53aa02d20d10c3d814d536aa4e5ac9b84ca0ff5a88377963b085ad6822f93e64 \
- --hash=sha256:5594fc43d548a7ed94949d139aa1341b270f1863f11cfd37f5a6c8b778a6b67f \
- --hash=sha256:571b9fcb07b97ef3a492028fb3d2dc0993ca23a06138b0315286566d29ef718a \
- --hash=sha256:57b3d78c95ba9059768b10e28b813002261d3f3dfc55cc48b0c988f625175827 \
- --hash=sha256:5afb51d599ea772b8365ae807ae557f18bccfe46ab261fd1c2a9ed700fc6eb17 \
- --hash=sha256:6b02afb9b97f65fbca5f31db6a2a3ba21aa93030225f150fa3f249717e938fb4 \
- --hash=sha256:6c0016e7b354317c4e9e525b937ac8596c38d2d232b419529b9cd7a1cd46e39a \
- --hash=sha256:71d6097b330eea8fd15097780c8e89cb1a8ce7838669f48c5bacd6f663dd4701 \
- --hash=sha256:756c768d0c9c2955feb7a56c37ea24aea2e369f8d36a88da270b6a9f19e62b5e \
- --hash=sha256:78cb2c6865a35ab8ff8b75fd122f6033b92a62c82801110e48ddd6c936a45d91 \
- --hash=sha256:7a743ff716f746fc19a9557f60dab1600d4613255f8a7aeb3cdde4db7eb15a66 \
- --hash=sha256:85f998ea1848bc6757289e739cfbdda3a04adfd58b02fc018ce54d754a5ce468 \
- --hash=sha256:8728f216dcdb6e6d555cf971cb34076139ad74b31fc2c14da4fafc741c5f6217 \
- --hash=sha256:877c3f311ff35410f690861c4409e7ccbf0cd2f878e50628a28e5a0bb689e658 \
- --hash=sha256:8cd2f7bdda092d99c9fc2fb7391354f306d01443d22785d0cbfafa2e2c8bb418 \
- --hash=sha256:8e95e1385e4998ae9694eeaa4730ba5457ff61185b3a55e2e7bea0880aef452a \
- --hash=sha256:962864dc93511324d51ddbb5b9f8731bf71675b93ca612a07441896f4688fb8c \
- --hash=sha256:9cf95fe4d0f84c82d282745d9bb08ad9f926efa00be4697e767b814ce40d4330 \
- --hash=sha256:9e881fca225083806662a5c43d627d215f258ff43c890f831966c7d7ba9c7402 \
- --hash=sha256:a2b55dd6b2a4c4b7d87ffa56bdb33fdc5fdb9a462173861a7bc097f17d91cb09 \
- --hash=sha256:a45650e8ce7fafffd731db8550230db6b0d306d181a90b67d3e6bca2f1990930 \
- --hash=sha256:a876864214e136f0eb367788dbd7df045f4806801518e2cfe9e13229cfe06d8f \
- --hash=sha256:ae26d61dfa7a47befdc7572b521024e8745f3d809bd95ca9505a7bba9ef849ec \
- --hash=sha256:af8d94b0db561cf68b88a267c5c44b49e134f525d0dc2cb7ed413a66bc23559a \
- --hash=sha256:b343699e8308bdc51978310e1c959c584e7869cc8c40780058c87da7781a1e94 \
- --hash=sha256:b3c777e849237620b022f7f297dd67705f9f5cf1685f09f02e46f93e92725468 \
- --hash=sha256:b629de27fda84b42cde7edef0d85f13b958b47f6e9bbcbba9b673c562a89bd8b \
- --hash=sha256:ba09209fbe443b4acccebe845d8a138b89a8f4fbaeedd44953490b5315d5e965 \
- --hash=sha256:ba54cfebe86920a559a7c4d6b9050791c20513650a1952ebe3368c7dc70306f8 \
- --hash=sha256:bcb46e2f9feff8d06323983bd83ed00c201fdcab3d74973e7072a889b3979fcd \
- --hash=sha256:bcc33feacfaefce60c12fd500a277533bdc02b10a19f7f6d348763d8140bbba7 \
- --hash=sha256:bf16ba1b4d0b6b7c8e534936632270cf70eb00dbe09005bc345b2677b726855c \
- --hash=sha256:cf1845d02ad822a369a49f2bb9345b1614744267682e7a03527dc3bf6eea1777 \
- --hash=sha256:d69141514cc30b774ceea5e3ed3a6635c8d8a96edf664689b890f4089111fb35 \
- --hash=sha256:d9c7f76c0673154f044e9d78c8655fb4213f6ca31a836df48b40fe5d187717b9 \
- --hash=sha256:dbce0b29841537a2fa4a214c2bbf14de3587c9680caa9b4e217568472490b28f \
- --hash=sha256:dc624f6bc473dacdf7ef7eb8678d0d08edf15cd94fad6ae5c7d6cc67a4e4902f \
- --hash=sha256:e158cb00350dc278f3b91551101aa7d12415a66ebf2c91d8d5ac14e56ddd3ad0 \
- --hash=sha256:e491916b378fba47242221bb9ead245211b70d504f495d105d17b14a24b4907c \
- --hash=sha256:e795b7eb908249c4e43c7c99fac7c2c75dab0c43566e37db472a355f63693d71 \
- --hash=sha256:e7e480451b9fa137494bccd3a7d69adbe8ac65a87d97be61e11f1b1050a5bac3 \
- --hash=sha256:e91206ee562682b51b98ef4b26a6ef48fd84e15fd4c4bc5ec768eb641d206838 \
- --hash=sha256:e9871b1ffbfa9656b60aeee92ed5136a5742696006fa322b29ea3d8da0ecc9cf \
- --hash=sha256:e9aeb04d6aef139de265b29683e119b638208f88cf73cdd1658aa07221165321 \
- --hash=sha256:ebaea975e03d3141d9d3a507df75c9b3ec90fa9d2ffd07567b3a978d9d790b26 \
- --hash=sha256:f0606c8bf2cdefea14a43530f7657cbbb7ecf1c4222512492ef4a4434a9501ec \
- --hash=sha256:f13c32a3abd6079a66d9526e18dad9b6d280384d49d7c54040cd57b6424041d9 \
- --hash=sha256:f7401aebd7f581d7f83a439d87d474999317ee099218e5ad25d125290990ba65 \
- --hash=sha256:fa4ecea169a355be7a3ade2c783e2ed12f0e40d2c5621cda8b3297faf7fbb9f5 \
- --hash=sha256:fbd139c8447d25dd750ab79ee274cc5e1fe80fc56340ab10b18a195e1b6eca3e \
- --hash=sha256:fdafc9cce40277e0f7a0feabce0ee50dd2fa1800f3b38015e51296b5e814048d \
- --hash=sha256:fe3cca2e4e8a592be0f269a1ca4835c25199d9f3ce815c8491048f785b0a0198 \
- --hash=sha256:ffd0c5368496f41b0944be820fcb7a838aa6e623d250b01acf2643939c3f99d7
- # via streamlit
-protobuf==7.35.1 \
- --hash=sha256:11d6b0ec246892d85215b0a13ca6e0233cf5284b68f0ac02646427f4ff88a799 \
- --hash=sha256:230a75ddfc2de4806e56696ce9640c1cdfdb6543b7cfce98d42a4c0a0e7bdb87 \
- --hash=sha256:24f857477359a85c0c235261b8ba905fd51b2562f4a64ca1df5473f29850cbf6 \
- --hash=sha256:353652e4efd0bca5b5fc2656abf8307ef351f0cf938c9eba09f0e09c20a25c30 \
- --hash=sha256:4bc97768d8fe4ad6743c8a19403e314511ed9f6d13205b687e52421c023ac1b9 \
- --hash=sha256:74758715c53d7158fb76caf4f0cfdacc5329a4b1bb994f865d6cf302d413a1c4 \
- --hash=sha256:b73f9489a4b8b1c9cb1f8ed951c736392592edb24b9d6819f36d2e10b171d5b4 \
- --hash=sha256:ce115a26fe0c39a2c29973d914d327e516a6455464489fe3cd1e51a1b354f81a
- # via streamlit
-pyarrow==24.0.0 \
- --hash=sha256:02b001b3ed4723caa44f6cd1af2d5c86aa2cf9971dacc2ffa55b21237713dfba \
- --hash=sha256:04920d6a71aabd08a0417709efce97d45ea8e6fb733d9ca9ecffb13c67839f68 \
- --hash=sha256:0b3537c00fb8d384f15ac1e79b6eb6db04a16514c8c1d22e59a9b95c8ba42868 \
- --hash=sha256:1183baeb14c5f587b1ec52831e665718ce632caab84b7cd6b85fd44f96114495 \
- --hash=sha256:14e31a3c9e35f1ab6356c6378f6f72830e6d2d5f1791df3774a7b097d18a6a1e \
- --hash=sha256:1617043b99bd33e5318ae18eb2919af09c71322ef1ca46566cdafc6e6712fb66 \
- --hash=sha256:1a4e45017efbf115032e4475ee876d525e0e36c742214fbe405332480ecd6275 \
- --hash=sha256:1b2fe7f9a5566401a0ef2571f197eb92358925c1f0c8dba305d6e43ea0871bb3 \
- --hash=sha256:1cc9057f0319e26333b357e17f3c2c022f1a83739b48a88b25bfd5fa2dc18838 \
- --hash=sha256:2392d954fcb920f42d230284b677605e4e2fbb11f2821e823e642abd67fbb491 \
- --hash=sha256:25ea65d868eb04015cd18e6df2fbe98f07e5bda2abefabcb88fce39a947716f6 \
- --hash=sha256:295f0a7f2e242dabd513737cf076007dc5b2d59237e3eca37b05c0c6446f3826 \
- --hash=sha256:2f16197705a230a78270cdd4ea8a1d57e86b2fdcbc34a1f6aebc72e65c986f9a \
- --hash=sha256:35405aecb474e683fb36af650618fd5340ee5471fc65a21b36076a18bbc6c981 \
- --hash=sha256:38be1808cdd068605b787e6ca9119b27eb275a0234e50212c3492331680c3b1e \
- --hash=sha256:3a577bd840ca83f646f0a625dbc571dba7044c43c2d1503afc378b570954345c \
- --hash=sha256:3b13dedfe76a0ad2d1d859b0811b53827a4e9d93a0bcb05cf59333ab4980cc7e \
- --hash=sha256:418e48ce50a45a6a6c73c454677203a9c75c966cb1e92ca3370959185f197a05 \
- --hash=sha256:55a3bc1e3df3b5567b7d27ef551b2283f0c68a5e86f1cd56abc569da4f31335b \
- --hash=sha256:6165461f55ef6314f026de6638d661188e3455d3ec49834556a0ebbdbace18bb \
- --hash=sha256:61a3d7eaa97a14768b542f3d284dc6400dd2470d9f080708b13cd46b6ae18136 \
- --hash=sha256:6233c9ed9ab9d1db47de57d9753256d9dcffbf42db341576099f0fd9f6bf4810 \
- --hash=sha256:641f795b361874ac9da5294f8f443dfdbee355cf2bd9e3b8d97aaac2306b9b37 \
- --hash=sha256:644a246325b8c69c595ad1dd4b463eba4b0cdb731370e4a86137d433208d6147 \
- --hash=sha256:6f066b179d68c413374294bc1735f68475457c933258df594443bb9d88ddc2a0 \
- --hash=sha256:7986f1fa71cee060ad00758bcc79d3a93bab8559bf978fab9e53472a2e25a17b \
- --hash=sha256:7c2b98645d576a0b9616892ead22b64a83a5f043c5e2ca15ebcefcb5b70c80cb \
- --hash=sha256:806f24b4085453c197a5078218d1ee08783ebbba271badd153d1ae22a3ee804f \
- --hash=sha256:85fe721a14dd823aca09127acbb06c3ca723efbd436c004f16bca601b04dcc83 \
- --hash=sha256:8adc8e6ce5fccf5dc707046ae4914fd537def529709cc0d285d37a7f9cd442ca \
- --hash=sha256:9700ebd9a51f5895ce75ff4ac4b3c47a7d4b42bc618be8e713e5d56bacf5f931 \
- --hash=sha256:9b18371ad2f44044b81a8d23bc2d8a9b6a6226dca775e8e16cfee640473d6c5d \
- --hash=sha256:a964266397740257f16f7bb2e4f08a0c81454004beab8ff59dd531b73610e9f2 \
- --hash=sha256:adbbedc55506cbdabb830890444fb856bfb0060c46c6f8026c6c2f2cf86ae795 \
- --hash=sha256:ae8a1145af31d903fa9bb166824d7abe9b4681a000b0159c9fb99c11bc11ad26 \
- --hash=sha256:b0e131f880cda8d04e076cee175a46fc0e8bc8b65c99c6c09dff6669335fde74 \
- --hash=sha256:b196eb3f931862af3fa84c2a253514d859c08e0d8fe020e07be12e75a5a9780c \
- --hash=sha256:b7d9a514e73bc42711e6a35aaccf3587c520024fe0a25d830a1a8a27c15f4f57 \
- --hash=sha256:bec9373df11544592b0ba7ec2af0e35059e5f0e7647c6183a854dedd193298f1 \
- --hash=sha256:c42ab9439498270139cc63e18847a02afe5c8b3ed9c931266533cfe378bd3591 \
- --hash=sha256:c91d00057f23b8d353039520dc3a6c09d8608164c692e9f59a175a42b2ae0c19 \
- --hash=sha256:d3e0b61e8efb24ed38898e5cdc5fffa9124be480008d401a1f8071500494ae42 \
- --hash=sha256:d7027eba1df3b2069e2e8d80f644fa0918b68c46432af3d088ddd390d063ecde \
- --hash=sha256:d8ddd2768da81d3ee08cfea9b597f4abb4e8e1dc8ae7e204b608d23a0d3ab699 \
- --hash=sha256:e3268e43984d0b1a185c89b4cfff282a7ead12fc93f56cfd7088bdbcbe727041 \
- --hash=sha256:e4505fc6583f7b05ab854934896bcac8253b04ac1171a77dfb73efef92076d91 \
- --hash=sha256:e56a1ffe9bf7b727432b89104cc0849c21582949dd7bdcb34f17b2001a351a76 \
- --hash=sha256:e6f1278ee4785b6db21229374a1c9e54ec7c549de5d1efc9630b6207de7e170b \
- --hash=sha256:f7616236ec1bc2b15bfdec22a71ab38851c86f8f05ff64f379e1278cf20c634a \
- --hash=sha256:fb24ac194bfc5e86839d7dcd52092ee31e5fe6733fe11f5e3b06ef0812b20072
- # via streamlit
pycparser==3.0 \
--hash=sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29 \
--hash=sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992
# via cffi
-pydeck==0.9.3 \
- --hash=sha256:695775cbfe51f5fdffbd9735ba469987fdc5efc96bc40a0ee4808170509c78b2 \
- --hash=sha256:d8a47c11c81fb12d51b1feb42427ff4f0e13cb599e48931021b2cba98b6849a6
- # via streamlit
-python-dateutil==2.9.0.post0 \
- --hash=sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3 \
- --hash=sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427
- # via pandas
python-multipart==0.0.32 \
--hash=sha256:be54b7f3fa167bb83e4fcd936b887b708f4e57fe75911c02aebf53efaf8d938e \
--hash=sha256:ff6d3f776f16878c894e52e107296ffc890e913c611b1a4ec6c44e2821fe2e23
- # via
- # -r requirements.txt
- # streamlit
-referencing==0.37.0 \
- --hash=sha256:381329a9f99628c9069361716891d34ad94af76e461dcb0335825aecc7692231 \
- --hash=sha256:44aefc3142c5b842538163acb373e24cce6632bd54bdb01b21ad5863489f50d8
- # via
- # jsonschema
- # jsonschema-specifications
-requests==2.34.2 \
- --hash=sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0 \
- --hash=sha256:f288924cae4e29463698d6d60bc6a4da69c89185ad1e0bcc4104f584e960b9ed
- # via streamlit
-rpds-py==2026.5.1 \
- --hash=sha256:01d17b29c0c23d82b1f4751147ec49cf451f1fc2554eb9ef5f957e55d2656ead \
- --hash=sha256:036a36a87fb1cd3b214d11c4b3c4f7d2ddad933625dca1c900b56a057c07740a \
- --hash=sha256:0408a24e44feb919423dc6d9da677cb5cddb894d2ca9e763967d156d9c60fab4 \
- --hash=sha256:07b24fea40541e28570e5b795a4a38fbdcd12550c06bd0748005ecc8116ca256 \
- --hash=sha256:0957cf3c2b8632ec7aaebffebea8005b353cc2a237b6e2ae3c2cac0820704cfb \
- --hash=sha256:0a5ae4dbe43c1076983b72616496919872ae7bbe7a1e21cc48336bc3154d130b \
- --hash=sha256:0a7d1eec967df0e9b22614a5e177622e0c89611d03727fa0cb48e45028907870 \
- --hash=sha256:0b35217adefe87f2fe4db7e9766cabe84744bfe9616d9667be18988928c7f2dc \
- --hash=sha256:0fa92420128dadce7f54bd73ba1825a273e9268fe9e35dbf7e6362890efa4e08 \
- --hash=sha256:141c9498daf2ace9eda35d2b0e376f9ea8b058d84f2aef4f96fccfd449a2f251 \
- --hash=sha256:1841d067089e117142d79b98aa0df2f08b52f2ecc1819dd2700636c0db74a473 \
- --hash=sha256:19cb09fab7b7fc96b2a6e28f2e34b72a3705ff27b37edb77455316e5d3f3dc9b \
- --hash=sha256:1c27c5f6102eac8c03e7595a00827a53b271ba40a53b59ff8709170e0855ea4a \
- --hash=sha256:1ebb2f0ab7e16132995a72de805170e0203df0c3dd22e1ef1cd1fdd90bd7a131 \
- --hash=sha256:1f2c391c3059798093b65df23aca2cac150460ae9c630d99dec83d703d9485b9 \
- --hash=sha256:205dde846f24332ab0c1188699a043b8d165b79bb84529ce272c45048ff6be01 \
- --hash=sha256:21846aac0ed2e0589f38c12dc44e77bb64e494b771eadbcf169cba00566ba7ba \
- --hash=sha256:21942f52dbbd5f8758bf021213d28bd45c39e873e65e2407faf5f1846f5761ad \
- --hash=sha256:277f6c82f0580848796c7ecc8a7173aa3bfb928e4ff831261c2f60a81dc270db \
- --hash=sha256:27b74c10ed6a8f190f4287f53bcfea348b92a84a9c9f70d30183d1e6172d580d \
- --hash=sha256:296c799becfa849c779c8725494fe9ed94959ed886787df4364b058465bad7f0 \
- --hash=sha256:2c595a1d9255dce0599e13130d1440ab2506654f2b50294226ee06402f8fef63 \
- --hash=sha256:2c817a189d4ee14290420e5ff051e4dd6baa13f3edf84685071dee07a6d538ee \
- --hash=sha256:2d88621d6a7d4dfa633d21abe90f280bb205274e16b1d1e61c6ad4640b2453b7 \
- --hash=sha256:3350ec808fb538fe71a1f94dfaa0e29c598dfad805ce49f0caec5ae3183c652b \
- --hash=sha256:3397a5ed7174dc2786bb214030232fc36fe8e5584fec43a9952cc542b1a12036 \
- --hash=sha256:3574b55c604b8f75dacb007136508bbc0db406e626301778096a133327e7f2fb \
- --hash=sha256:3609e9939a8a76cd904cf98a3f1f13b5dc7e150adeaee89e0ea09652ea213e16 \
- --hash=sha256:3684a59b158a7683aaeb8e25352e9a9dd2122cec78f2d8530266e4f91b4c7b3f \
- --hash=sha256:3966b82dd563176396df030f3dd52a6e54cb69b718e95e78bd555ed3d1e0185d \
- --hash=sha256:3abe24a66e57adcfa645d718063a5fa5103ecc71ddbf26d78af8f9368018ff1d \
- --hash=sha256:40ff257542e04796880e011e15cd4dc21c2599975df2aaa8f2c8495ca574e1a5 \
- --hash=sha256:413b424f7c4ee65ab5e5be91f5731be0f8b41a1ee2b12dfe810d716312e95a78 \
- --hash=sha256:42d0f20e85e549c870749d0e247f0c10d318a45b7e9676d575d2dcb04a1b2e66 \
- --hash=sha256:43bca78665423cabae77146f2fe7ce55272b6c8d55d82cca83effd42c7e13972 \
- --hash=sha256:453895624ecf7db7063b1004e44037522bbaef9ff6a945e59bc71662d7a03abd \
- --hash=sha256:4860b603ddda0475a8885499b3729e90229d480105b42651962a5397d995fa89 \
- --hash=sha256:4be8b1d2a705cc37d08256004e1d07de143fa0075c8e85a3df020b776f62b732 \
- --hash=sha256:4e237e139f94d3c036fd28eb9f564c99055476ff4ff05cd42be55ce349b5aa02 \
- --hash=sha256:4fb8d2e7cb2f850b169806d61d1b991738acec96500a75c30f49caf064ce7cef \
- --hash=sha256:55d8f9b7b78c9538fc9e04e82ec0e888ff0c3cffcfad152c77e57cd09351a98a \
- --hash=sha256:58b1d94308ddf0b1982f61f2eb54bf92997c9ece8a8093ef014250f4a517906c \
- --hash=sha256:5d333a7127d4b307601ac37792bee01bb95c867cbfacf21b6375b804d6bbd723 \
- --hash=sha256:613fc4ee9eaef26dc5840666214dd6fbcebcf32f46e76f4abc473059f4e13dda \
- --hash=sha256:6142dbd80c4df62a5d899f0d616d417f84e0bc8d32526c8e5589019d75d028a7 \
- --hash=sha256:62ae3853454fe9ef283a03c96c2d835d39e84b14643a9d62c82ef0fb87d702ca \
- --hash=sha256:63c2c4c213f1a4e3f3de28ecab029dbdee976324e729c0d7a55211be72576b02 \
- --hash=sha256:656a042550878f12d45752452d47094b7cfe5ad1e9d7b87b5a22ad3ae5ff8015 \
- --hash=sha256:66c93681c4729e4e3ecba31b8179fae083ff3118841672835140338b4b9867c1 \
- --hash=sha256:6736718bd4fc49cbcb538ba30516fdbef161522acefb739657d48b97bd864fed \
- --hash=sha256:68700371c5d7ae1412862ddfa719090925c93ecf351c566d66f09d04b136ea00 \
- --hash=sha256:6c3d771a46ec18b12af06ce36243a9a80b07a5d0515236332d90863ca8bb326a \
- --hash=sha256:6c7fcf61d44cacecaf3aea542b0e053db77972a4573e7ceda16fb2b399161195 \
- --hash=sha256:6f249f8b860a200ad35193af961183ebe9132710484e6f6ce0cf89fd83c63a9a \
- --hash=sha256:73c4bd4f70294737b5206a3e8e30ccadbf8a60301831c8ea23eec5dbeea1ecfa \
- --hash=sha256:7559f72b94ae52659086c595dfa017cde03155f7832071d30959049052cb3ece \
- --hash=sha256:75808f6c38ce7749bb68cc2770161aae5045e6c6f6781a9782e74b93304399df \
- --hash=sha256:77c004fdc7b891967106f78ddfd7b076bfe6813c6139c6fff6aed3bcaa960b26 \
- --hash=sha256:7818f8d0a415be74d2be3590b0a1c1f463a642f4d0217e7d10602dceef5b79aa \
- --hash=sha256:7944270ae71383f6e2657dd7d5ce4eeb4ac2d0059a6738f0510583d462ab4842 \
- --hash=sha256:7bd530e6a530bb3ea892f194fafa455f3516ac25ecf7143fd33c09be62b0470a \
- --hash=sha256:8213afbe8a3a906fb9acb2014423fe3359ee783d0bf90995f70623a3217bfa6c \
- --hash=sha256:83bcf894486c9d78dd290d3c0124ff6dd8875d3025e2090a8ec49fcc37c55fdd \
- --hash=sha256:85264a90ff4c05c1568dd65f5921c837614b67c60358fb4c17df3b7f2e90690a \
- --hash=sha256:88647f43a73c4e01be19b04ceef0c8d3a1958153604d13c773becd8016f2a0cf \
- --hash=sha256:8895840ac4809e5f60c88fd07617cd71326e73d6e5a8aa783c5c0f7c24985de2 \
- --hash=sha256:8ba264fa49be666cd9cc56bf34ec7002fb3d27a4aee5bcb4d43d0d18feb1bb6f \
- --hash=sha256:8bff7073db3899158fff55ebf57b113a67030af26f80a18978f9f0aa60250ddf \
- --hash=sha256:8c43a8a973270fd173bf48cdf80bbe66312421cba68d40845034f174f2389049 \
- --hash=sha256:90bd6630002a1c7f09e7843dd79f0d24f3d2897cc25a753480917865d14f15b3 \
- --hash=sha256:90f628283be835db980c941767d41c9a27b5239e54ba0a9c1335247e82406964 \
- --hash=sha256:94068eb3ae6d43f5a786b7db96a406a34e6d5c24489feef32fd6e8946ea7b291 \
- --hash=sha256:980450826cf22e133c57e0835070bdd0dd3f73b9b708c3ce223def2cb9469e14 \
- --hash=sha256:99ab6ba7bfa2cb0f96a04e3652355bf04e3f51aceb1e943b8541dab7ba4828cc \
- --hash=sha256:9af8905b8f854990e40d5206aa5ac58d9b0fe0b7f351ff2bb086c20f6c8c6a47 \
- --hash=sha256:9baffb505aff33acc69b422a19f77806680f3c8632227d79f48de8a810d1c2c5 \
- --hash=sha256:9cdddb6c1207d284d94fd1530adf57fbd797fe7c4b8704ba85f49414f2557e7d \
- --hash=sha256:9e25b7088f9ccbfc0dfcaa52bf969300ca229e10ecf758974ebcbb080a4b37bb \
- --hash=sha256:a04df86b3f0fade39ec8fd0e0aab089b1da9fbd2b48df778a57ef96f5e7d38df \
- --hash=sha256:a05fa4f41f37ec97c9c260441a940450a192f78d774d2b097eee1379f1e1246a \
- --hash=sha256:a2999883eedf72fdfb7520b92c7d4ec2572a71ff40239377aa604cc529eecafc \
- --hash=sha256:aad1bff7f666b9598e573815affd666aac6a13a585dde336f843e33350c7fadc \
- --hash=sha256:abe76bcdba31e576cb83eeb8797aa0d882b738fef6dc65d0601fc753806a5b46 \
- --hash=sha256:ad3773236e95f7f33991eb125224b7da66f206504d032a253a02da7e134519fb \
- --hash=sha256:af03e34e860047bc7a352b842856fcf78798fbb81132cc98bd2f907ab4eb9cd2 \
- --hash=sha256:b1b964e3ab599e718dc46c018d104b1ebc007cbc6567d827c94a687fca56d77e \
- --hash=sha256:b1be5c35683684d5331b93600c210e8367c254683d8a6df6bd21bd2da3a334fb \
- --hash=sha256:b317c87a13f769a4e787819bd508aaa5d69aa09b0880de9af6d3a8a54571cdec \
- --hash=sha256:b3cc20c0d800af78fd0fac68086e28c1856cec51ea528bb81ea851aa40d39325 \
- --hash=sha256:b4e4bc98639ec915f512fde3aa7a95e0041d95d9c3cc86eea841fa63cb1e8600 \
- --hash=sha256:b5c30f3f04eef4fbd362226a6f31d7c8895ca4fbb6e0b790f6890a98d8da8559 \
- --hash=sha256:b5f077b44a4f7808520f66dae234988d867deb9aed9be5da057ce9ba831b2a41 \
- --hash=sha256:b6825cc329b290e93c5f6a9be2393118a763f6ccf6abd83704e0c102ca583644 \
- --hash=sha256:b8d2f912928d426e8cfa396f7f3f8d29a59e6689c86dcca3c420730c1096322b \
- --hash=sha256:b95d5e11fc712b752081183a55a244c03cd00570489edd7014d8899f8ceb8162 \
- --hash=sha256:b9a6528956191c48c52294a592dbd4a8386d7048bdb25c0efcb6b966466c6d83 \
- --hash=sha256:ba05adbf15d994c38ec0b7ab32e858e5110c21e9009a00a86545fd220f84e038 \
- --hash=sha256:c0f920015df2a504bebaba6d4c31ccf3fcf942f92655c086da30b671aad19aa6 \
- --hash=sha256:c396c1304de421050b3681ea70f371874b54d41b0151e96109758144c231e30b \
- --hash=sha256:c39f5b67a8a2e67179ada2a954227d670fe65fa9098457f698f56ddf248709b3 \
- --hash=sha256:c3df104083952a0e0c6f10de33e440eabe98fb6317d23e1a58c68f6df08d01b9 \
- --hash=sha256:c74005a7bb87752acf351c93897ec63ad77a07a0da7ecad9c050e32e7286ba34 \
- --hash=sha256:c93c629be4636cf54337bd5f06c104d55e42ced54d681f6fe21ae510a65116f6 \
- --hash=sha256:ca653c6546386227cd9800d1bef6a348099acf8db4250341da6d90f663d6dfcb \
- --hash=sha256:cacedb7a6e167680acba45ad5716e89067d225dc80da0d7040cae8c81d4572fa \
- --hash=sha256:cc68e231a77a5f0d774ae278a1f8e55c0456501820847c1e4efb3829f3441df6 \
- --hash=sha256:ce87129d9f2c14fa6c4a8601fb80eb4488c80d38a20cd13758ef11123e14995d \
- --hash=sha256:cea68bcd53467561ae2f96a6bdad1544299ba97b5b0ddcd5ac3d376e5c781c24 \
- --hash=sha256:cef8ac28d26f4dda3533060c20fbf80a325458fa9fd23ea72a73cdfa8e978838 \
- --hash=sha256:d0efbe45632665e53e3db8fe1e5692db58fc5cb9bab4459d570b83efefe11164 \
- --hash=sha256:d3858b908218ee108d0bbfb2095ccc237648053c9bf98affad7cb079acaf1d97 \
- --hash=sha256:de42116e69cb53b911cc34aee5ab98f36c597b822545045d49e938818b99e5e4 \
- --hash=sha256:df1d2a1996755b24b9ecee92cb4d36c28f86f464a6a173349c26bab41e94b8c2 \
- --hash=sha256:e07be2a9d7122bd6e82dea89814ef8dc893feb1aae97fec1630f3263bbb30e55 \
- --hash=sha256:e0b360f316d966b048b085857630b3cc51f3db2f07b06f440eac8f695374d1e3 \
- --hash=sha256:e10464d17df3b582745c25cec695cb9558bca2cb6ddb631aee1787fc72c767b2 \
- --hash=sha256:e3a8ae58895ac107ed934a6bf51e5846f95c53b9b940c2c6d310838fd5846358 \
- --hash=sha256:e4abbf391a70be864920858bf360f4fb380577c9a0f732438a1996726e2c195b \
- --hash=sha256:eaaea962c68cdc68d4a533ba985ab8e9484277910bbfaa2ab3ef7732667bfed8 \
- --hash=sha256:ed0954b524873214369184a9c82b0eaa45a3fbb9a798cd95b17e0d98499e7ea0 \
- --hash=sha256:edf2765d84e42447f112ad877af8fe1db0089aaec5b28e88d6eab45e7fe99cea \
- --hash=sha256:ef1013a8625c74043210190b246f5b1551e09757c1f356c6e4160ef96c5bc081 \
- --hash=sha256:efef4ac29c6ff495531eb17ee705b62841ecaa291b7c7077e848ea03e237164d \
- --hash=sha256:f3a5b10e8ce894825f380a8f1b6444cf73c294dfea62afbb2d13e3a9e630cec1 \
- --hash=sha256:f3df3d16ded76f1f8c9cdebd0e1ea55fdf4c23b812de189814da7cf229c22a81 \
- --hash=sha256:f414556f6e3958300ff941e40c9f97e3dc9774ddd1b3434c475d73dd354bbed3 \
- --hash=sha256:fc09f82e63d4bcd58149572f857a431bae851dc747e313c3b5bdf7abb907fda8 \
- --hash=sha256:fc0c0f878ea770a0a8a462456c5ad36fc9fe6358e6b76fdadc7f17575e0b8bf1 \
- --hash=sha256:fe71bca7d547acb17027c7fd1624ff8aae623499c498d3e7011182c4de5c25e0 \
- --hash=sha256:fea6e836d10abbe191d557d33bd58bd5987725fe63aa1eefe557d230209855bd
- # via
- # jsonschema
- # referencing
-six==1.17.0 \
- --hash=sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274 \
- --hash=sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81
- # via python-dateutil
-smmap==5.0.3 \
- --hash=sha256:4d9debb8b99007ae47165abc08670bd74cb74b5227dda7f643eccc4e9eb5642c \
- --hash=sha256:c106e05d5a61449cf6ba9a1e650227ecfb141590d2a98412103ff35d89fc7b2f
- # via gitdb
+ # via -r requirements.txt
starlette==1.3.1 \
--hash=sha256:05d0213193f2fbaae60e2ecb593b4add4262ad4e46536b54abe36f11a71724e0 \
--hash=sha256:c7372aae11c3c3f26a42df7bd626cec2f47d03483d261d369516a615a53714c6
- # via
- # -r requirements.txt
- # streamlit
-streamlit==1.59.2 \
- --hash=sha256:5014cb4f0064bd16c58fe334cb0d3c4b7e2be3d1ce4c02202c07114a33576dab \
- --hash=sha256:8fcb35df152868d33eec7ad2d89e276499a5351fb861eb1752b76476be7eb582
# via -r requirements.txt
-tenacity==9.1.4 \
- --hash=sha256:6095a360c919085f28c6527de529e76a06ad89b23659fa881ae0649b867a9d55 \
- --hash=sha256:adb31d4c263f2bd041081ab33b498309a57c77f9acf2db65aadf0898179cf93a
- # via streamlit
-toml==0.10.2 \
- --hash=sha256:806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b \
- --hash=sha256:b3bda1d108d5dd99f4a20d24d9c348e91c4db7ab1b749200bded2f839ccbe68f
- # via streamlit
-typing-extensions==4.16.0 \
- --hash=sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8 \
- --hash=sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5
- # via
- # altair
- # streamlit
-urllib3==2.7.0 \
- --hash=sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c \
- --hash=sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897
- # via requests
uvicorn==0.51.0 \
--hash=sha256:5d38af6cd620f2ae3849fb44fd4879e0890aa1febe8d47eb355fb45d93fe6a5b \
--hash=sha256:f6f4b69b657c312f516dd2d268ab9ae6f254b11e4bac504f37b2ab58b24dd0b0
- # via
- # -r requirements.txt
- # streamlit
-watchdog==6.0.0 \
- --hash=sha256:07df1fdd701c5d4c8e55ef6cf55b8f0120fe1aef7ef39a1c6fc6bc2e606d517a \
- --hash=sha256:20ffe5b202af80ab4266dcd3e91aae72bf2da48c0d33bdb15c66658e685e94e2 \
- --hash=sha256:212ac9b8bf1161dc91bd09c048048a95ca3a4c4f5e5d4a7d1b1a7d5752a7f96f \
- --hash=sha256:2cce7cfc2008eb51feb6aab51251fd79b85d9894e98ba847408f662b3395ca3c \
- --hash=sha256:490ab2ef84f11129844c23fb14ecf30ef3d8a6abafd3754a6f75ca1e6654136c \
- --hash=sha256:6eb11feb5a0d452ee41f824e271ca311a09e250441c262ca2fd7ebcf2461a06c \
- --hash=sha256:6f10cb2d5902447c7d0da897e2c6768bca89174d0c6e1e30abec5421af97a5b0 \
- --hash=sha256:7607498efa04a3542ae3e05e64da8202e58159aa1fa4acddf7678d34a35d4f13 \
- --hash=sha256:76aae96b00ae814b181bb25b1b98076d5fc84e8a53cd8885a318b42b6d3a5134 \
- --hash=sha256:7a0e56874cfbc4b9b05c60c8a1926fedf56324bb08cfbc188969777940aef3aa \
- --hash=sha256:82dc3e3143c7e38ec49d61af98d6558288c415eac98486a5c581726e0737c00e \
- --hash=sha256:9041567ee8953024c83343288ccc458fd0a2d811d6a0fd68c4c22609e3490379 \
- --hash=sha256:90c8e78f3b94014f7aaae121e6b909674df5b46ec24d6bebc45c44c56729af2a \
- --hash=sha256:9513f27a1a582d9808cf21a07dae516f0fab1cf2d7683a742c498b93eedabb11 \
- --hash=sha256:9ddf7c82fda3ae8e24decda1338ede66e1c99883db93711d8fb941eaa2d8c282 \
- --hash=sha256:a175f755fc2279e0b7312c0035d52e27211a5bc39719dd529625b1930917345b \
- --hash=sha256:a1914259fa9e1454315171103c6a30961236f508b9b623eae470268bbcc6a22f \
- --hash=sha256:afd0fe1b2270917c5e23c2a65ce50c2a4abb63daafb0d419fde368e272a76b7c \
- --hash=sha256:bc64ab3bdb6a04d69d4023b29422170b74681784ffb9463ed4870cf2f3e66112 \
- --hash=sha256:bdd4e6f14b8b18c334febb9c4425a878a2ac20efd1e0b231978e7b150f92a948 \
- --hash=sha256:c7ac31a19f4545dd92fc25d200694098f42c9a8e391bc00bdd362c5736dbf881 \
- --hash=sha256:c7c15dda13c4eb00d6fb6fc508b3c0ed88b9d5d374056b239c4ad1611125c860 \
- --hash=sha256:c897ac1b55c5a1461e16dae288d22bb2e412ba9807df8397a635d88f671d36c3 \
- --hash=sha256:cbafb470cf848d93b5d013e2ecb245d4aa1c8fd0504e863ccefa32445359d680 \
- --hash=sha256:d1cdb490583ebd691c012b3d6dae011000fe42edb7a82ece80965b42abd61f26 \
- --hash=sha256:e3df4cbb9a450c6d49318f6d14f4bbc80d763fa587ba46ec86f99f9e6876bb26 \
- --hash=sha256:e6439e374fc012255b4ec786ae3c4bc838cd7309a540e5fe0952d03687d8804e \
- --hash=sha256:e6f0e77c9417e7cd62af82529b10563db3423625c5fce018430b249bf977f9e8 \
- --hash=sha256:e7631a77ffb1f7d2eefa4445ebbee491c720a5661ddf6df3498ebecae5ed375c \
- --hash=sha256:ef810fbf7b781a5a593894e4f439773830bdecb885e6880d957d5b9382a960d2
# via -r requirements.txt
-websockets==16.1.1 \
- --hash=sha256:01fbdcbac298efe19360b94bc0039c8f746f0220ba570f327577bfee81059175 \
- --hash=sha256:024193f8551a2b0eafbdd160911012c4e6c228c28430c84433253299a9e42d6a \
- --hash=sha256:04fd29a0e2fe9414a95b00e92c67ae51bf900c50c0f8a4b2dafdad621f49ea1d \
- --hash=sha256:056ae37939ed7e9974f364f5864e76e49182622d8f9751ac1903c0d09b013985 \
- --hash=sha256:0f62863e8a00a6d33c3d6566ec0b89f23787b747ffe0c3bc71ec0e76b82c94b1 \
- --hash=sha256:0ffd3031ea8bda8d61762e84220186105ba3b748b3c8da2ae4f7816fac03e573 \
- --hash=sha256:1214e673c404684b9bf7154f5cf43b45025b1a6160fac3a9e438e9c1a97e22cb \
- --hash=sha256:125f22dbefaf1554fea66fc83851490edb284ce4f501d37ffed2752f418332d9 \
- --hash=sha256:130937b167a52af203c8d58e78d67705874e82759862e3b9671a452fec4abc87 \
- --hash=sha256:1427fb4cf0d72f66333e2cacc3ff5f575bf2d7008166ce991a4a470b21d51a22 \
- --hash=sha256:195c978b065fa40910582464f99d6b15c8b314c68e0546549a55ed83f4735328 \
- --hash=sha256:1d27fa8462ad6a1cb36206a3d0640b2333340def181fae11ed7f9adeaa5c0747 \
- --hash=sha256:1db4de4a0e95673f7545d393c49eeb0c2f18ac1ef93073218c79d5cdb2ee75ab \
- --hash=sha256:1f79c89b5eb034d1722938a891916582f8f7f503f58ca22518a63c3f2cd18499 \
- --hash=sha256:23253dd5bcae3f9aaee0a1d30967a8dbd52e5d3cff93a2e5b84df57b77d4750d \
- --hash=sha256:249116b4a76063d930a46391ad56e135c286e4562a18309029fc2c73f4ed4c62 \
- --hash=sha256:29dfa8114c4a620c69591c5973860f768eac29d3fd6904f37f34266cb219c512 \
- --hash=sha256:2a606d9c24035242a3e256e9d5b77ed9cd6bccfcb7cf993e5ca3c0f6f68fb6a7 \
- --hash=sha256:2a636ff1e7a5c4edf71ef0e79adae7f25dba93b4fcbe3dc958733477ffeb0eaf \
- --hash=sha256:2bb5d041a8307d2e18782e7ce777f6fdb1e8c2f5d09291484b18c294b789d9aa \
- --hash=sha256:2e28e602bb13da44fbe518c1781a88e3b9d4c3d48d02c9bad83e546164336f57 \
- --hash=sha256:30bbe120437b5648a77d3519b7024ea09530e0b5b18d3698c5a0ae536fe0cc2e \
- --hash=sha256:34420aaa64440ebd51ac72ca8a45ef4626429438c9b02e633ae412ed43f925d3 \
- --hash=sha256:38565aca3e01ea8734e578fb2118dade0ecb0250533f29e22b8d1a7a196cf4d0 \
- --hash=sha256:387e8e4aa5df2f90b198fa3cad3478822a89cf905b6a6d6c97dc3664689640cc \
- --hash=sha256:39f2a024af5c345ffe8fcf1ee18c049c024c94df393bb09b044a6917c77bde43 \
- --hash=sha256:3df13f73af9b3b38ab1195eb299ecb67a4330c911c97ae04043ff74085728abe \
- --hash=sha256:414e596c75f74e0994084694189d7dc9229fb278e33064d6784b73ffbba3ca31 \
- --hash=sha256:41c8e77f17294c0ac18008a7309b99b34ee72247ef10b6dff4c3f8b5ac29896b \
- --hash=sha256:42290eb6db4ccaca7012656738214f8514082fb6fa40cdeb61bb9a471b52e383 \
- --hash=sha256:42f599f4d48c7e1a3338fdaac3acd075be3b3cf02d4b274f3bf2767aedd3d217 \
- --hash=sha256:43e3a9fdd7cbf7ba6040c31fae0faf84ca1474fef777c4e37912f1540f854499 \
- --hash=sha256:443aefe96b7fdb132e2a70806cca1f2af49bb3f28e47abcd7c2e9dcf4d8fa1b8 \
- --hash=sha256:46dcaa042cd1de6c59e7d9269fa63ff7572b6df40510600b678f0826b3c7af51 \
- --hash=sha256:496af849a472b531f758dbd4d61338f5000538cb1a7b3d20d9d32a264517f509 \
- --hash=sha256:49ae99bdfcae803a885c926bf14f886196e84925395bb3f568fef5c0f0979d7d \
- --hash=sha256:4b57693728576d84ede0a77987ab16881b783d2cd9f1dc180a8fbbc3f79c4428 \
- --hash=sha256:4e3b680b1e0a27457e727a0d572fd81dffa87b6dbf8b228ab57da64f7d85aead \
- --hash=sha256:4e8d01cc3bcae7bbf8167f944aeafefed590fae5693552bba9794a9df68371cc \
- --hash=sha256:5283810d2646741a0d8da2aa733d6aefa0545809afccb2a5d105a26bc45125f1 \
- --hash=sha256:53260c8930da5771cec89439bff99c20c8cb03ddb9588b980697355a83cd4bd3 \
- --hash=sha256:536676848fc5961aca9d20389951f59169508f765637a172403dc5434d722fa0 \
- --hash=sha256:54509b8e92fee4453e152b7558ddef37ce9705a044922f2095a6105e3f80c96f \
- --hash=sha256:56cd5fc4f10a9ea8aa0804bddb7b42506cf9e136046f3b4c27de8fec9e2ecba5 \
- --hash=sha256:5bfd1ac19b1b9986a9c95a82d5e23a391ebb09e12c34d7be6094b86efcc35731 \
- --hash=sha256:5c31aa7e39ee3e8a358573257f1c0bb5c52430d1b637030dd9c8cc2c282926be \
- --hash=sha256:5e3b7d601f6f84156b08cc4a5e541c2b50ad7b36cfc302b657a12477c904a5df \
- --hash=sha256:61922544a0587a13fd3f53e4c0e5e606510c7b0d9d22c8444e5fae22a06b38cb \
- --hash=sha256:6456ff333092d509127d75a638cb411afae8ff17f092635015d1902efec8a293 \
- --hash=sha256:69159730a823dde3ea8d08783e8d47ef135a6d7e8d44eb127e32b321c9db8e3e \
- --hash=sha256:69e52d175a0a7d1e13b4b67ad41c560b7d98e8c6f6126eb0bda496c784faf8c7 \
- --hash=sha256:6aaface73b9c71974c6497366d8b9628357f6c9749e09c4ea3610176c63f2ae3 \
- --hash=sha256:6abbd3e82c731c8e531714466acd5d87b5e88ac3243465337ba71d68e23ae7e3 \
- --hash=sha256:6ff9417c0ada4d0f7d212f928303e5579bdf3ace4c802fa4afabb30995da58c3 \
- --hash=sha256:7421fad442de870a8cbf2287d1cad7e706ece0dbfeba5e911df132cbdc1cb56a \
- --hash=sha256:7883388947767080f094950b342b30d35a2a06b849cd967c422fa0db72b40ea9 \
- --hash=sha256:79eace538c6a97e96d0d03d4f9d314f9677f5ed85a8a984992ffd90b13cb8a56 \
- --hash=sha256:7b1b19636af86a3c7995d4d028dbe376f39b4bf31541146f9c123582a6c94562 \
- --hash=sha256:7dfcad78ea1492ee3a9ec765cb7f51bbc17d477107aaf6b22abf7b2558d1c5a0 \
- --hash=sha256:8087e82f842609734c9b5a1330464f8e94e346ba0e18c832c08bafa4b0d63c15 \
- --hash=sha256:820fb8450edddae3812fd58cbc08e2bf22812cb248ecb5f06dbb82119a56e869 \
- --hash=sha256:8483c2096363120eea8b07c06ae7304d520f686665fffd4811fad423930a65d7 \
- --hash=sha256:84a2cef8deffbd9ab8ee0ea546a2a6a7030c28f44e6cdd4547dbfeb489eb8999 \
- --hash=sha256:86d7f0f8bdb25d2c632b72527325e4776430fd5bc61b9118de4e2b8ddb5f5b01 \
- --hash=sha256:8fe0b50da2d84535fb4f7b4bfa951280f97ce3d558a0443b541166d609e67b57 \
- --hash=sha256:90001d893bc368e302ef168d82130b4e4fdd27b85fa094682df9b667c2d48838 \
- --hash=sha256:9246a0d063cfcbcc85f2359dd6876d681213f4790832272aa16641b4ed5d64d4 \
- --hash=sha256:92b820d345f7a3fc7b8163949ee92df910f290c3fc517b3d5301c78065adafe1 \
- --hash=sha256:952303a7318d4cbe1011400839bb2051c9f84fa0a35923267f5daba34b15d458 \
- --hash=sha256:97fd3a0e8b53efa41970ac1dff3d8cf0d2884cadeb4caaf95db7ad1526926ee3 \
- --hash=sha256:9c1c5705e314449e3308872fe084b8571ce078ee4fc55a98a769bdefe5917392 \
- --hash=sha256:9c9f23004a3d40e89c01a7955d186a6cc83418d93b749701944ce2de3e95a1f3 \
- --hash=sha256:9f63bcef7f4b02b06b35fc01c93b96c43b5e88e1e8868676caacf493d5a31f3a \
- --hash=sha256:a0eadbbf2c30f01efa58e1f110eb6fa293261f6b0b1aa38f7f48707107690af9 \
- --hash=sha256:a28fcbc9b6baf54a2e23f8655f308e4ccc6afdd7266f8fe7954f320dcda0f785 \
- --hash=sha256:a6a61aff018180c9c50b7b0da33bfd29d378af3497429c95006c589a23a11648 \
- --hash=sha256:aabe464bfd13bd25f4821faf111da6fefdc389f870265a53105580e45b0a2e49 \
- --hash=sha256:ab59169ace05dcb49a1d4118f0bde139557adf45091bd85747e36bf5de984dd1 \
- --hash=sha256:b436f6ec4fc3a6b4237c84d3f83170ed2b40bb584222f0ac47a0c8a5921980c7 \
- --hash=sha256:b6b9dadbef0cccd9f4c4ee96b08898afa73e26803bbe0f6aeb5bb12b0074206d \
- --hash=sha256:b852788aa51764e2d8e4cf5493d559326bcae5e38d16ba25ffa322b034df272a \
- --hash=sha256:bae954c382e013d5ea5b190d2830526bfa45ad121c326da0049b8c769f185db6 \
- --hash=sha256:bcce07e23e5769375158f5efdcdafa8d5cd014b93c6683865b840ed65b96f231 \
- --hash=sha256:cc97814dfb786a83b6e2dc2e79351e1b83e6d715647d6887fcabd83026417a00 \
- --hash=sha256:cd2ca96a082a36964aca83e992f72abeb61b7306c1a6cba4c7d06a7b93750cac \
- --hash=sha256:cfb70b4eb56cac4da0a83588f3ad50d46beb0690391082f3d4e2d488c70b68ea \
- --hash=sha256:d0fcf657e9f13ff4b177960ab2200237b12994232dfb6df16f1cfe1d4339f93c \
- --hash=sha256:d14bfb217eb4701e850f1525c9d29d79c44794cdf1c299ead25f39f8c78dea81 \
- --hash=sha256:d57685547e0060cc6fd90ee6a28405d6bd395e525545f13c8d7cd99c78afd79f \
- --hash=sha256:d6bec75c290fe484a8ba4cacdf838501e17c06ecfbbf31eede81a9e431bd7751 \
- --hash=sha256:d9531d9cbeac99af6f038fb1bc351403531f7d634a2c2e10e2f7c854c6ed5b68 \
- --hash=sha256:da4ca1a9d72f9030b3146b8d7022719a9f3d478f61efe6f7dd51d243f61c51b2 \
- --hash=sha256:dab9eb87869da2d6ed3af3f3adf28414baae6ec9d4df355ffc18889132f3436c \
- --hash=sha256:db234eda965dcce15df96bb9709f587cd87d4d52aaf0e80e2f34ec04c7670c57 \
- --hash=sha256:dc0fad4933f427acd5b1cec210f3ea6dce7089e1724e4b9ec6ef47c6c04d1b3b \
- --hash=sha256:dc385593a42e31cd6fb60c19f0ecb015b386603818fc2c6c274fb42bd2bb4165 \
- --hash=sha256:dcc04fedf83effaeb9cce98abc9469bb1b42ef85f03e01c8c1f4438ef7555737 \
- --hash=sha256:e047dc87ef7ca50f4d309bf775ad4a71711c58556d75d7bd0604b2317f43e94b \
- --hash=sha256:e09f753a169951eb4f28c2c774f71069304f66e7277e0f5a2892423599cfa854 \
- --hash=sha256:ed5bb271084b46530ee2ddc0410537a9961152c5ccba2fc98c5276d992ccba87 \
- --hash=sha256:f0aa4aad3b1b69ad3fd85a0fd0952ec64331c762bd77ec51cc814170873890b2 \
- --hash=sha256:f17dbe07eb3ea7f99e4df9b7e0efefe80fbf30d37a8cc4d561a0aed310bc8847 \
- --hash=sha256:f2769a0344a09e9ccf5b3cce538bc75a51b53eff3275d3896310c8552049195d \
- --hash=sha256:f55f0b01956a094c8587146d9558c91937e78789c333860ffaf35931a6e5dbc4 \
- --hash=sha256:f5d497865f05bb222cab7016c6034542e84e5f29f49c6fd3f4939cda7197b5b8 \
- --hash=sha256:f70541f3104339f59f830522d94ebadb1bf47426287381623443d8bb1cdbf33d \
- --hash=sha256:fb9a0a6dc3d1b3986cb88091b6899f0396651e0f74e2c9766ab8d6ffc3842e29 \
- --hash=sha256:fce6c48559c86d1ac3632ccb1bebc7d5442fbe79bd9bb0e40379ee54be2a4051 \
- --hash=sha256:fd46fff7eb62c24804d234f0051c7a8ea81285ad63e0337d3dcf33ca82aee58a
- # via streamlit
diff --git a/requirements.txt b/requirements.txt
index b088bce..2a6ac84 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -1,11 +1,8 @@
liboqs-python>=0.14.1,<1.0
cryptography>=42.0.0
-streamlit>=1.32.0
# Starlette <1.3.1 is affected by CVE-2026-54283.
starlette>=1.3.1,<2
uvicorn>=0.49.0,<1
python-multipart>=0.0.32,<1
# Keep the locked runtime install portable on Ubuntu/Python 3.13.
httptools>=0.8.0,<0.9
-# Streamlit pulls this on Linux; pin it explicitly so hashed locks stay portable.
-watchdog>=2.1.5,<7
diff --git a/scripts/api_client.test.mjs b/scripts/api_client.test.mjs
index 06afaa8..30af02e 100644
--- a/scripts/api_client.test.mjs
+++ b/scripts/api_client.test.mjs
@@ -1,22 +1,36 @@
import assert from "node:assert/strict";
-import { readFile } from "node:fs/promises";
+import { mkdir, readFile, writeFile } from "node:fs/promises";
import test from "node:test";
+import { fileURLToPath, pathToFileURL } from "node:url";
import { transformWithOxc } from "vite";
let moduleSequence = 0;
async function loadApiModule() {
- const source = await readFile(new URL("../web/src/api.ts", import.meta.url), "utf8");
- const compiled = (
- await transformWithOxc(source, "api.ts", {
+ const tmpDirectory = new URL("../tmp/api-client-tests/", import.meta.url);
+ await mkdir(tmpDirectory, { recursive: true });
+
+ const contractsSource = await readFile(new URL("../web/src/api/contracts.ts", import.meta.url), "utf8");
+ const contracts = (
+ await transformWithOxc(contractsSource, "contracts.ts", {
+ lang: "ts",
+ sourcemap: false
+ })
+ ).code;
+ await writeFile(new URL("contracts.mjs", tmpDirectory), contracts);
+
+ const clientSource = await readFile(new URL("../web/src/api/client.ts", import.meta.url), "utf8");
+ const client = (
+ await transformWithOxc(clientSource.replace("./contracts", "./contracts.mjs"), "client.ts", {
lang: "ts",
sourcemap: false
})
).code;
- const encoded = Buffer.from(compiled).toString("base64");
moduleSequence += 1;
- return import(`data:text/javascript;base64,${encoded}#${moduleSequence}`);
+ const clientPath = new URL(`client-${moduleSequence}.mjs`, tmpDirectory);
+ await writeFile(clientPath, client);
+ return import(`${pathToFileURL(fileURLToPath(clientPath)).href}#${moduleSequence}`);
}
function healthPayload(apiToken) {
@@ -24,6 +38,12 @@ function healthPayload(apiToken) {
ok: true,
backendReady: true,
backendMessage: "Post-quantum backend ready.",
+ capabilities: {
+ inspect: { available: true, reason: "" },
+ generate: { available: true, reason: "" },
+ encrypt: { available: true, reason: "" },
+ decrypt: { available: true, reason: "" }
+ },
formatVersion: 4,
kem: "ML-KEM-768+X25519-v2",
kemComponent: "ML-KEM-768",
@@ -128,3 +148,43 @@ test("an unrelated authorization rejection is not retried", async (t) => {
{ url: "/api/keys/generate", token: "current-token" }
]);
});
+
+test("sensitive operation signals reach each state-changing fetch", async (t) => {
+ const originalFetch = globalThis.fetch;
+ t.after(() => {
+ globalThis.fetch = originalFetch;
+ });
+
+ const calls = [];
+ globalThis.fetch = async (input, init = {}) => {
+ const url = String(input);
+ calls.push({ url, signal: init.signal });
+ if (url === "/api/health") return jsonResponse(healthPayload("current-token"));
+ if (url === "/api/keys/generate") return jsonResponse(generatedKeysPayload());
+ if (url === "/api/files/encrypt" || url === "/api/files/decrypt") {
+ return new Response(new Blob([url]), {
+ status: 200,
+ headers: { "Content-Disposition": 'attachment; filename="result.bin"' }
+ });
+ }
+ throw new Error(`Unexpected request: ${url}`);
+ };
+
+ const api = await loadApiModule();
+ const controller = new AbortController();
+ const file = new Blob(["file"]);
+ const key = new Blob(["key"]);
+
+ await api.generateKeys("correct horse battery staple", controller.signal);
+ await api.encryptFile(file, key, "encrypted.pqc", controller.signal);
+ await api.decryptFile(file, key, "correct horse battery staple", "plain.txt", controller.signal);
+
+ assert.deepEqual(
+ calls.filter(({ url }) => url !== "/api/health").map(({ url, signal }) => ({ url, signal })),
+ [
+ { url: "/api/keys/generate", signal: controller.signal },
+ { url: "/api/files/encrypt", signal: controller.signal },
+ { url: "/api/files/decrypt", signal: controller.signal }
+ ]
+ );
+});
diff --git a/scripts/ui_native_smoke.mjs b/scripts/ui_native_smoke.mjs
new file mode 100644
index 0000000..246d38f
--- /dev/null
+++ b/scripts/ui_native_smoke.mjs
@@ -0,0 +1,113 @@
+import assert from "node:assert/strict";
+import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
+import os from "node:os";
+import path from "node:path";
+
+import { chromium } from "playwright";
+
+const baseUrl = process.env.UI_NATIVE_URL ?? "http://127.0.0.1:4000/";
+const password = "correct horse battery staple";
+const inputBytes = Buffer.from("native browser round trip");
+
+function assertReadyHealth(health) {
+ const requiredCapabilities = ["generate", "encrypt", "decrypt"];
+ const isReady = Boolean(
+ health?.ok &&
+ health.backendReady &&
+ requiredCapabilities.every((capability) => health.capabilities?.[capability]?.available)
+ );
+
+ assert.equal(isReady, true, "The native local engine is not ready for key generation, encryption, and decryption.");
+}
+
+async function saveDownload(download, destination) {
+ await download.saveAs(destination);
+ return destination;
+}
+
+async function downloadFromButton(page, name, destination) {
+ const downloadPromise = page.waitForEvent("download");
+ await page.getByRole("button", { name }).click();
+ return saveDownload(await downloadPromise, destination);
+}
+
+async function run() {
+ let temporaryDirectory;
+ let browser;
+
+ try {
+ temporaryDirectory = await mkdtemp(path.join(os.tmpdir(), "quantum-encryptor-native-ui-"));
+ const inputPath = path.join(temporaryDirectory, "input.txt");
+ const publicKeyPath = path.join(temporaryDirectory, "public.pem");
+ const privateKeyPath = path.join(temporaryDirectory, "private.pem");
+ const encryptedPath = path.join(temporaryDirectory, "input_encrypted.pqc");
+ const decryptedPath = path.join(temporaryDirectory, "input_decrypted.txt");
+ await writeFile(inputPath, inputBytes);
+
+ browser = await chromium.launch({ headless: true });
+ const context = await browser.newContext({ viewport: { width: 1440, height: 900 } });
+ const page = await context.newPage();
+
+ await page.goto(baseUrl, { waitUntil: "networkidle" });
+ await page.getByRole("heading", { name: "Encrypt a file" }).waitFor();
+
+ const health = await page.evaluate(async () => {
+ const response = await fetch("/api/health", { credentials: "same-origin" });
+ if (!response.ok) return null;
+ return response.json();
+ });
+ assertReadyHealth(health);
+ await page.getByText("Ready", { exact: true }).first().waitFor({ state: "visible" });
+
+ await page.getByRole("button", { name: "Generate keys" }).click();
+ await page.getByRole("heading", { name: "Generate keys" }).waitFor();
+ await page.getByLabel("Private key password", { exact: true }).fill(password);
+ await page.getByLabel("Confirm private key password").fill(password);
+ await page.getByRole("button", { name: "Generate key pair" }).click();
+ await page.getByText("Key pair generated", { exact: true }).waitFor({ state: "visible" });
+ await downloadFromButton(page, "Download public key", publicKeyPath);
+ await downloadFromButton(page, "Download encrypted private key", privateKeyPath);
+ await page.getByRole("button", { name: "Clear generated keys" }).click();
+ assert.equal(await page.getByRole("button", { name: "Download public key" }).count(), 0);
+ assert.equal(await page.getByRole("button", { name: "Download encrypted private key" }).count(), 0);
+
+ await page.getByRole("button", { name: "Encrypt" }).first().click();
+ await page.getByRole("heading", { name: "Encrypt a file" }).waitFor();
+ await page.getByLabel("File to encrypt").setInputFiles(inputPath);
+ await page.getByLabel("Recipient public key").setInputFiles(publicKeyPath);
+ await page.getByText("Compatible public key", { exact: true }).waitFor({ state: "visible" });
+ const encryptedDownload = page.waitForEvent("download");
+ await page.getByRole("button", { name: "Encrypt file" }).click();
+ await saveDownload(await encryptedDownload, encryptedPath);
+ await page.getByText("File encrypted", { exact: true }).waitFor({ state: "visible" });
+
+ await page.getByRole("button", { name: "Decrypt" }).first().click();
+ await page.getByRole("heading", { name: "Decrypt a file" }).waitFor();
+ await page.getByLabel("Encrypted file").setInputFiles(encryptedPath);
+ await page.getByLabel("Private key", { exact: true }).setInputFiles(privateKeyPath);
+ await page
+ .getByText("Supported encrypted private key; match not yet verified", { exact: true })
+ .waitFor({ state: "visible" });
+ await page.getByLabel("Private key password", { exact: true }).fill(password);
+ const decryptedDownload = page.waitForEvent("download");
+ await page.getByRole("button", { name: "Decrypt file" }).click();
+ await saveDownload(await decryptedDownload, decryptedPath);
+ await page.getByText("File decrypted", { exact: true }).waitFor({ state: "visible" });
+
+ assert.deepEqual(await readFile(decryptedPath), inputBytes, "The native browser round trip returned different bytes.");
+ console.log("Native browser encryption round trip passed.");
+ } finally {
+ try {
+ await browser?.close();
+ } finally {
+ if (temporaryDirectory) await rm(temporaryDirectory, { recursive: true, force: true });
+ }
+ }
+}
+
+try {
+ await run();
+} catch {
+ console.error("Native browser encryption round trip failed.");
+ process.exitCode = 1;
+}
diff --git a/scripts/ui_smoke.mjs b/scripts/ui_smoke.mjs
index dad1865..2255ab8 100644
--- a/scripts/ui_smoke.mjs
+++ b/scripts/ui_smoke.mjs
@@ -1,50 +1,381 @@
+import assert from "node:assert/strict";
import { mkdir } from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
+import AxeBuilder from "@axe-core/playwright";
import { chromium } from "playwright";
const rootDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const outDir = path.join(rootDir, "tmp", "ui-smoke");
const baseUrl = process.env.UI_SMOKE_URL ?? "http://127.0.0.1:4000/";
+const localToken = "ui-smoke-local-token";
+
+const publicInspection = {
+ ok: true,
+ keyInfo: { kem: "ML-KEM-768+X25519-v2", key_type: "public" },
+ display: {
+ "Key Type": "Public key",
+ "Hybrid suite": "ML-KEM-768+X25519-v2"
+ }
+};
+
+const outdatedPublicInspection = {
+ ok: true,
+ keyInfo: { kem: "ML-KEM-768+X25519", key_type: "public" },
+ display: {
+ "Key Type": "Public key",
+ "Hybrid suite": "ML-KEM-768+X25519"
+ }
+};
+
+const privateInspection = {
+ ok: true,
+ keyInfo: {
+ kem: "ML-KEM-768+X25519-v2",
+ key_type: "private",
+ private_key_encrypted: true,
+ private_key_format_version: 2,
+ private_key_kdf: "scrypt"
+ },
+ display: {
+ "Key Type": "Encrypted private key",
+ "Key format": "2",
+ "Key KDF": "scrypt",
+ "Hybrid suite": "ML-KEM-768+X25519-v2"
+ }
+};
+
+function health({ limited = false, strictFileLimit = false } = {}) {
+ const available = { available: true, reason: "" };
+ return {
+ ok: true,
+ backendReady: !limited,
+ backendMessage: limited
+ ? "The ML-KEM backend is not ready, so new keys and ciphertexts cannot be created. Compatible encrypted archives may still be decryptable."
+ : "Post-quantum backend ready.",
+ capabilities: limited
+ ? {
+ inspect: available,
+ generate: { available: false, reason: "Key generation is unavailable in this local engine." },
+ encrypt: { available: false, reason: "Encryption is unavailable in this local engine." },
+ decrypt: available
+ }
+ : { inspect: available, generate: available, encrypt: available, decrypt: available },
+ formatVersion: 4,
+ kem: "ML-KEM-768+X25519-v2",
+ kemComponent: "ML-KEM-768",
+ configuredKem: "ML-KEM-768",
+ dem: "AES-256-GCM",
+ maxFileBytes: strictFileLimit ? 3 : 104857600,
+ maxEncryptedFileBytes: 105906314,
+ maxPemBytes: 131072,
+ apiToken: localToken,
+ passwordPolicy: { minChars: 16, minUniqueChars: 5 }
+ };
+}
+
+function json(route, payload, status = 200) {
+ return route.fulfill({
+ status,
+ contentType: "application/json",
+ body: JSON.stringify(payload)
+ });
+}
+
+function errorPayload(errorCode, message) {
+ return { ok: false, error_code: errorCode, message };
+}
+
+function file(name, contents, mimeType = "application/octet-stream") {
+ return { name, mimeType, buffer: Buffer.from(contents) };
+}
+
+function activeElementLabel() {
+ const element = document.activeElement;
+ if (!(element instanceof HTMLElement)) return "";
+ const associatedLabel = "labels" in element && element.labels?.[0]?.textContent?.trim();
+ return element.getAttribute("aria-label") || associatedLabel || element.textContent?.trim() || element.id;
+}
+
+async function collectTabOrder(page, limit = 40) {
+ await page.evaluate(() => (document.activeElement instanceof HTMLElement ? document.activeElement.blur() : undefined));
+ const order = [];
+ for (let index = 0; index < limit; index += 1) {
+ await page.keyboard.press("Tab");
+ order.push(await page.evaluate(activeElementLabel));
+ }
+ return order;
+}
+
+function assertLogicalTabOrder(order, labels) {
+ let lastIndex = -1;
+ for (const label of labels) {
+ const nextIndex = order.findIndex((value, index) => index > lastIndex && value === label);
+ assert.notEqual(nextIndex, -1, `Tab did not reach ${label}: ${JSON.stringify(order)}`);
+ lastIndex = nextIndex;
+ }
+}
+
+async function assertNoHorizontalOverflow(page) {
+ for (const viewport of [
+ { width: 390, height: 844 },
+ { width: 768, height: 900 },
+ { width: 1024, height: 768 },
+ { width: 1440, height: 900 }
+ ]) {
+ await page.setViewportSize(viewport);
+ const overflow = await page.evaluate(() => document.documentElement.scrollWidth > window.innerWidth);
+ assert.equal(overflow, false, `horizontal overflow at ${viewport.width}`);
+ }
+}
+
+async function assertAxe(page, label) {
+ const scan = await new AxeBuilder({ page })
+ .withTags(["wcag2a", "wcag2aa", "wcag21a", "wcag21aa"])
+ .analyze();
+ assert.deepEqual(scan.violations, [], `${label} accessibility violations: ${JSON.stringify(scan.violations, null, 2)}`);
+}
await mkdir(outDir, { recursive: true });
+let limitedHealth = false;
+let strictFileLimit = false;
+const unexpectedApiRequests = [];
+const apiRequests = [];
+let staleInspectionRoute = null;
+let captureStaleInspectionRoute;
+const staleInspectionRouteCaptured = new Promise((resolve) => {
+ captureStaleInspectionRoute = resolve;
+});
const browser = await chromium.launch({ headless: true });
try {
- const page = await browser.newPage({ viewport: { width: 1280, height: 720 } });
+ const context = await browser.newContext({ viewport: { width: 1440, height: 900 } });
+ const page = await context.newPage();
+
+ await page.route("**/api/**", async (route) => {
+ const request = route.request();
+ const pathname = new URL(request.url()).pathname;
+ const method = request.method();
+ const payload = request.postDataBuffer()?.toString("utf8") ?? "";
+ apiRequests.push(`${method} ${pathname}`);
+
+ if (method === "GET" && pathname === "/api/health") {
+ return json(route, health({ limited: limitedHealth, strictFileLimit }));
+ }
+ if (method === "POST" && pathname === "/api/keys/inspect") {
+ if (payload.includes("stale-first.pem")) {
+ staleInspectionRoute = route;
+ captureStaleInspectionRoute();
+ return;
+ }
+ if (payload.includes("outdated-public.pem")) return json(route, outdatedPublicInspection);
+ if (payload.includes("wrong-key-type.pem")) return json(route, privateInspection);
+ return json(route, payload.includes("private.pem") ? privateInspection : publicInspection);
+ }
+ if (method === "POST" && pathname === "/api/keys/generate") {
+ return json(route, {
+ ok: true,
+ kem: "ML-KEM-768+X25519-v2",
+ publicPem: "PUBLIC-PEM",
+ privatePem: "PRIVATE-PEM",
+ publicFilename: "quantum_public.pem",
+ privateFilename: "quantum_private_encrypted.pem"
+ });
+ }
+ if (method === "POST" && pathname === "/api/files/encrypt") {
+ if (payload.includes("unexpected-backend-error.txt")) {
+ return json(route, errorPayload("unexpected_backend_error", "Raw fixture detail: /not-for-the-user"), 500);
+ }
+ return route.fulfill({
+ status: 200,
+ contentType: "application/octet-stream",
+ headers: { "Content-Disposition": 'attachment; filename="report_encrypted.pqc"' },
+ body: "ciphertext"
+ });
+ }
+ if (method === "POST" && pathname === "/api/files/decrypt") {
+ if (payload.includes("tampered_encrypted.pqc")) {
+ return json(route, errorPayload("decryption_failed", "Ciphertext authentication failed."), 422);
+ }
+ return route.fulfill({
+ status: 200,
+ contentType: "application/octet-stream",
+ headers: { "Content-Disposition": 'attachment; filename="report.txt"' },
+ body: "plaintext"
+ });
+ }
+ unexpectedApiRequests.push(`${method} ${pathname}`);
+ return json(route, errorPayload("unexpected_ui_smoke_api_request", "Unexpected local test API request."), 500);
+ });
+
await page.goto(baseUrl, { waitUntil: "networkidle" });
- const states = {
- encryptDisabled: await page.getByRole("button", { name: "Encrypt File" }).last().isDisabled()
- };
+ await page.getByRole("heading", { name: "Encrypt a file" }).waitFor();
+ assert.equal(await page.getByRole("main").count(), 1);
+ assert.equal(await page.getByRole("navigation", { name: "Workflows" }).count(), 1);
+ assert.equal(await page.getByRole("button", { name: "Encrypt" }).first().getAttribute("aria-current"), "page");
+ await expectText(page, "Ready");
- await page.getByRole("button", { name: "Decrypt File" }).click();
- states.decryptVisible = await page.getByRole("heading", { name: "Decrypt File" }).isVisible();
- states.decryptDisabled = await page.getByRole("button", { name: "Decrypt File" }).last().isDisabled();
+ strictFileLimit = true;
+ await page.reload({ waitUntil: "networkidle" });
+ const encryptRequestsBeforeOversizedFile = apiRequests.filter((request) => request === "POST /api/files/encrypt").length;
+ await page.getByLabel("File to encrypt").setInputFiles(file("oversized.txt", "four", "text/plain"));
+ await expectText(page, "This file exceeds the 3 byte limit.");
+ assert.equal(await page.getByRole("button", { name: "Encrypt file" }).isDisabled(), true);
+ assert.equal(
+ apiRequests.filter((request) => request === "POST /api/files/encrypt").length,
+ encryptRequestsBeforeOversizedFile,
+ "oversized files must not trigger encryption requests"
+ );
- await page.getByRole("button", { name: "Generate Keys" }).click();
- states.generateVisible = await page.getByRole("heading", { name: "Generate Keys" }).isVisible();
- states.generateDisabled = await page.getByRole("button", { name: /Generate .* Key Pair/ }).isDisabled();
+ strictFileLimit = false;
+ await page.reload({ waitUntil: "networkidle" });
- await page.getByRole("button", { name: "Inspect Key" }).click();
- states.inspectVisible = await page.getByRole("heading", { name: "Inspect Key" }).isVisible();
+ await page.getByLabel("File to encrypt").setInputFiles(file("report.txt", "local report", "text/plain"));
+ await page.getByLabel("Recipient public key").setInputFiles(file("public.pem", "PUBLIC-PEM", "application/x-pem-file"));
+ await expectText(page, "Compatible public key");
+ await page.getByRole("button", { name: "Encrypt file" }).waitFor({ state: "visible" });
+ assert.equal(await page.getByRole("button", { name: "Encrypt file" }).isEnabled(), true);
+ assert.equal(await page.getByLabel("Output filename").inputValue(), "report_encrypted.pqc");
- await page.setViewportSize({ width: 1280, height: 720 });
- await page.getByRole("button", { name: "Encrypt File" }).click();
+ const desktopTabOrder = await collectTabOrder(page);
+ assertLogicalTabOrder(desktopTabOrder, ["Encrypt", "File to encrypt", "Recipient public key", "Output filename", "Encrypt file", "Technical details"]);
+ assert.equal(await page.getByText("Technical details").first().evaluate((element) => element.parentElement?.open), false);
+ await page.getByText("Technical details").first().click();
+ assert.equal(await page.getByText("Technical details").first().evaluate((element) => element.parentElement?.open), true);
+ await assertNoHorizontalOverflow(page);
+ await page.setViewportSize({ width: 1440, height: 900 });
+ await assertAxe(page, "ready Encrypt screen");
await page.screenshot({ path: path.join(outDir, "quantum-encryptor-web.png"), fullPage: true });
+ await page.getByRole("button", { name: "Encrypt file" }).click();
+ await expectText(page, "File encrypted");
+ await expectText(page, "Saved report_encrypted.pqc.");
+
+ await page.getByLabel("Recipient public key").setInputFiles(file("outdated-public.pem", "OUTDATED", "application/x-pem-file"));
+ await expectText(page, "This public key uses ML-KEM-768+X25519; encryption requires ML-KEM-768+X25519-v2.");
+ assert.equal(await page.getByRole("button", { name: "Encrypt file" }).isDisabled(), true);
+
+ await page.getByLabel("Recipient public key").setInputFiles(file("wrong-key-type.pem", "PRIVATE-PEM", "application/x-pem-file"));
+ await expectText(page, "Private key — not valid for encryption");
+ assert.equal(await page.getByRole("button", { name: "Encrypt file" }).isDisabled(), true);
+
+ await page.getByLabel("Recipient public key").setInputFiles(file("public.pem", "PUBLIC-PEM", "application/x-pem-file"));
+ await expectText(page, "Compatible public key");
+ await page.getByLabel("File to encrypt").setInputFiles(file("unexpected-backend-error.txt", "safe local fixture", "text/plain"));
+ await page.getByRole("button", { name: "Encrypt file" }).click();
+ await expectText(page, "Could not encrypt this file. Confirm the recipient key and try again.");
+ assert.equal(await page.getByText("Raw fixture detail: /not-for-the-user").count(), 0);
+
+ await page.getByRole("button", { name: "Inspect key" }).first().click();
+ await page.getByRole("heading", { name: "Inspect a key" }).waitFor();
+ assert.equal(await page.getByRole("button", { name: "Inspect key" }).first().getAttribute("aria-current"), "page");
+ await page.getByLabel("Key file").setInputFiles(file("public.pem", "PUBLIC-PEM", "application/x-pem-file"));
+ await expectText(page, "Public key");
+ await page.getByText("Technical details").first().click();
+ await expectText(page, "ML-KEM-768+X25519-v2");
+ await page.getByLabel("Key file").setInputFiles(file("private.pem", "PRIVATE-PEM", "application/x-pem-file"));
+ await expectText(page, "Encrypted private key");
+ await page.getByText("Technical details").first().click();
+ await expectText(page, "scrypt");
+ await page.getByLabel("Key file").setInputFiles(file("stale-first.pem", "STALE", "application/x-pem-file"));
+ await staleInspectionRouteCaptured;
+ await page.getByLabel("Key file").setInputFiles(file("public.pem", "PUBLIC-PEM", "application/x-pem-file"));
+ await expectText(page, "Public key");
+ await staleInspectionRoute?.abort("failed");
+ assert.equal(await page.getByText("Public key").count() > 0, true, "replaced inspection must remain current after cancellation");
+
+ await page.getByRole("button", { name: "Generate keys" }).click();
+ await page.getByRole("heading", { name: "Generate keys" }).waitFor();
+ await page.getByLabel("Private key password", { exact: true }).fill("correct-horse-battery-staple");
+ await page.getByLabel("Confirm private key password").fill("correct-horse-battery-staple");
+ await page.getByRole("button", { name: "Generate key pair" }).click();
+ await expectText(page, "Key pair generated");
+ await expectText(page, "quantum_public.pem");
+ await expectText(page, "quantum_private_encrypted.pem");
+ await page.getByRole("button", { name: "Clear generated keys" }).click();
+ assert.equal(await page.getByRole("button", { name: "Download public key" }).count(), 0);
+ assert.equal(await page.getByRole("button", { name: "Download encrypted private key" }).count(), 0);
+
+ await page.getByRole("button", { name: "Decrypt" }).first().click();
+ await page.getByRole("heading", { name: "Decrypt a file" }).waitFor();
+ await page.getByLabel("Encrypted file").setInputFiles(file("report_encrypted.pqc", "ciphertext"));
+ await page.getByLabel("Private key", { exact: true }).setInputFiles(file("private.pem", "PRIVATE-PEM", "application/x-pem-file"));
+ await expectText(page, "Supported encrypted private key; match not yet verified");
+ await page.getByLabel("Private key password", { exact: true }).fill("correct-horse-battery-staple");
+ assert.equal(await page.getByLabel("Output filename").inputValue(), "report");
+ await page.getByRole("button", { name: "Decrypt file" }).click();
+ await expectText(page, "File decrypted");
+ await expectText(page, "Saved report.txt.");
+
+ await page.getByLabel("Encrypted file").setInputFiles(file("tampered_encrypted.pqc", "tampered"));
+ await page.getByLabel("Private key password", { exact: true }).fill("correct-horse-battery-staple");
+ await page.getByRole("button", { name: "Decrypt file" }).click();
+ await expectText(page, "Decryption failed");
+ await expectText(page, "The file could not be authenticated. Check the encrypted file, private key, and password.");
+ assert.equal(await page.getByText("Ciphertext authentication failed.").count(), 0);
+
await page.setViewportSize({ width: 390, height: 844 });
- await page.getByRole("button", { name: "Inspect Key" }).click();
+ await page.getByRole("link", { name: "Inspect key" }).click();
+ await page.getByRole("heading", { name: "Inspect a key" }).waitFor();
+ assert.equal(await page.getByRole("link", { name: "Inspect key" }).getAttribute("aria-current"), "page");
+ await page.getByLabel("Key file").setInputFiles(file("public.pem", "PUBLIC-PEM", "application/x-pem-file"));
+ await expectText(page, "Public key");
+ await assertAxe(page, "mobile Inspect screen");
await page.screenshot({ path: path.join(outDir, "quantum-encryptor-mobile.png"), fullPage: true });
- if (!states.encryptDisabled || !states.decryptVisible || !states.decryptDisabled) {
- throw new Error(`Unexpected file workflow state: ${JSON.stringify(states)}`);
- }
- if (!states.generateVisible || !states.generateDisabled || !states.inspectVisible) {
- throw new Error(`Unexpected key workflow state: ${JSON.stringify(states)}`);
- }
+ await page.emulateMedia({ reducedMotion: "reduce" });
+ const reducedMotionDuration = await page.locator(".app-mobile-navigation a").first().evaluate(
+ (element) => getComputedStyle(element).transitionDuration
+ );
+ assert.ok(
+ reducedMotionDuration.split(", ").every((duration) => Number.parseFloat(duration) <= 0.01),
+ `reduced-motion transition duration is too long: ${reducedMotionDuration}`
+ );
+
+ limitedHealth = true;
+ await page.setViewportSize({ width: 1440, height: 900 });
+ await page.reload({ waitUntil: "networkidle" });
+ await page.getByRole("heading", { name: "Encrypt a file" }).waitFor();
+ await expectText(page, "Limited");
+ await expectText(page, "Encryption is unavailable in this local engine.");
+ assert.equal(await page.getByLabel("File to encrypt").count(), 0);
+ await page.getByText("Capability details").first().click();
+ await expectText(page, "Key generation is unavailable in this local engine.");
+ await expectText(page, "Encryption is unavailable in this local engine.");
+
+ await page.getByRole("button", { name: "Inspect key" }).first().click();
+ await page.getByRole("heading", { name: "Inspect a key" }).waitFor();
+ assert.equal(await page.getByLabel("Key file").count(), 1);
- console.log(JSON.stringify(states, null, 2));
+ await page.getByRole("button", { name: "Generate keys" }).click();
+ await page.getByRole("heading", { name: "Generate keys" }).waitFor();
+ await expectText(page, "Key generation is unavailable in this local engine.");
+ assert.equal(await page.getByLabel("Private key password", { exact: true }).count(), 0);
+ assert.equal(await page.getByRole("button", { name: "Generate key pair" }).count(), 0);
+
+ await page.getByRole("button", { name: "Encrypt" }).first().click();
+ await page.getByRole("heading", { name: "Encrypt a file" }).waitFor();
+ await expectText(page, "Encryption is unavailable in this local engine.");
+ assert.equal(await page.getByLabel("File to encrypt").count(), 0);
+
+ await page.getByRole("button", { name: "Decrypt" }).first().click();
+ await page.getByRole("heading", { name: "Decrypt a file" }).waitFor();
+ assert.equal(await page.getByRole("button", { name: "Decrypt file" }).isDisabled(), true);
+ await expectText(page, "Choose an encrypted file.");
+
+ assert.deepEqual(unexpectedApiRequests, [], `Unexpected API requests: ${unexpectedApiRequests.join(", ")}`);
+
+ console.log(JSON.stringify({
+ health: ["all capabilities", "inspect and decrypt"],
+ workflows: ["inspect", "generate", "encrypt", "decrypt", "decryption_failed"],
+ viewports: [390, 768, 1024, 1440],
+ screenshots: ["quantum-encryptor-web.png", "quantum-encryptor-mobile.png"]
+ }, null, 2));
} finally {
await browser.close();
}
+
+async function expectText(page, text) {
+ await page.getByText(text, { exact: true }).filter({ visible: true }).first().waitFor({ state: "visible" });
+}
diff --git a/start.sh b/start.sh
index eb0be2a..b66ec1f 100755
--- a/start.sh
+++ b/start.sh
@@ -22,12 +22,6 @@ if [ -z "$PYTHON_BIN" ]; then
exit 127
fi
-if [ "${LEGACY_STREAMLIT:-0}" = "1" ]; then
- exec "$PYTHON_BIN" -m streamlit run pqc_app.py \
- --server.address 127.0.0.1 \
- --server.port "$APP_PORT"
-fi
-
if [ "${SKIP_WEB_BUILD:-0}" != "1" ]; then
if [ ! -x "node_modules/.bin/vite" ]; then
echo "Frontend dependencies are missing. Run npm install before ./start.sh." >&2
diff --git a/tests/test_api_app.py b/tests/test_api_app.py
index 726476a..fd84fc2 100644
--- a/tests/test_api_app.py
+++ b/tests/test_api_app.py
@@ -47,6 +47,31 @@ def test_health_payload_reports_ready_backend(monkeypatch):
assert payload["kemComponent"] == "ML-KEM-768"
+def test_health_payload_reports_partial_capabilities(monkeypatch):
+ def missing_current_backend(_kem):
+ raise core.CryptoDependencyError("private backend detail")
+
+ monkeypatch.setattr(core, "resolve_kem_algorithm", missing_current_backend)
+ monkeypatch.setattr(core, "available_decryption_kem_algorithms", lambda: ("Kyber768",))
+
+ payload = api_app._health_payload()
+
+ assert payload["backendReady"] is False
+ assert payload["capabilities"] == {
+ "inspect": {"available": True, "reason": ""},
+ "generate": {
+ "available": False,
+ "reason": "ML-KEM-768 is unavailable for new key generation.",
+ },
+ "encrypt": {
+ "available": False,
+ "reason": "ML-KEM-768 is unavailable for new encryption.",
+ },
+ "decrypt": {"available": True, "reason": ""},
+ }
+ assert "private backend detail" not in str(payload)
+
+
def test_content_disposition_quotes_download_filename():
header = api_app._content_disposition("encrypted file.pqc")
diff --git a/tests/test_crypto_core.py b/tests/test_crypto_core.py
index 1d644f5..cd50f30 100644
--- a/tests/test_crypto_core.py
+++ b/tests/test_crypto_core.py
@@ -163,6 +163,17 @@ class FakeOQS:
return FakeOQS
+def test_available_decryption_kem_algorithms_preserves_exact_identities(monkeypatch):
+ """Archive availability lists exact current and legacy KEM identities."""
+ monkeypatch.setattr(
+ core,
+ "_enabled_kem_mechanisms",
+ lambda: ("Kyber768", "ML-KEM-768", "Other"),
+ )
+
+ assert core.available_decryption_kem_algorithms() == ("ML-KEM-768", "Kyber768")
+
+
def _legacy_v3_blob(plaintext: bytes, mlkem_public: bytes) -> bytes:
"""Build a valid legacy v3 container using the deterministic fake backend."""
ciphertext_kem = b"C" * 32
diff --git a/tests/test_startup.py b/tests/test_startup.py
new file mode 100644
index 0000000..de68435
--- /dev/null
+++ b/tests/test_startup.py
@@ -0,0 +1,43 @@
+import os
+from pathlib import Path
+import subprocess
+
+REPOSITORY_ROOT = Path(__file__).resolve().parents[1]
+
+
+def test_start_script_uses_api_app_when_legacy_flag_is_set(tmp_path):
+ argv_log = tmp_path / "interpreter-argv.txt"
+ interpreter = tmp_path / "python-stub"
+ startup_script = tmp_path / "start.sh"
+ static_app = tmp_path / "static" / "app"
+ static_app.mkdir(parents=True)
+ (static_app / "index.html").write_text("", encoding="utf-8")
+ startup_script.write_text((REPOSITORY_ROOT / "start.sh").read_text(encoding="utf-8"), encoding="utf-8")
+ startup_script.chmod(0o755)
+ interpreter.write_text(
+ "#!/usr/bin/env sh\n" "set -eu\n" 'printf "%s\\n" "$@" > "$STARTUP_ARGV_LOG"\n',
+ encoding="utf-8",
+ )
+ interpreter.chmod(0o755)
+
+ environment = os.environ.copy()
+ environment.update(
+ {
+ "PYTHON": str(interpreter),
+ "LEGACY_STREAMLIT": "1",
+ "SKIP_WEB_BUILD": "1",
+ "STARTUP_ARGV_LOG": str(argv_log),
+ }
+ )
+
+ completed = subprocess.run(
+ [str(startup_script)],
+ cwd=tmp_path,
+ env=environment,
+ check=False,
+ capture_output=True,
+ text=True,
+ )
+
+ assert completed.returncode == 0, completed.stderr
+ assert argv_log.read_text(encoding="utf-8").splitlines() == ["-m", "api_app"]
diff --git a/tsconfig.json b/tsconfig.json
index 1a8a9f1..0dd3134 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -16,5 +16,5 @@
"noEmit": true,
"jsx": "react-jsx"
},
- "include": ["web/src", "vite.config.ts"]
+ "include": ["web/src", "web/src/**/*.test.ts", "web/src/**/*.test.tsx", "vite.config.ts"]
}
diff --git a/ui_helpers.py b/ui_helpers.py
index d78d6e9..56266ce 100644
--- a/ui_helpers.py
+++ b/ui_helpers.py
@@ -19,7 +19,7 @@ def guess_decrypted_filename(encrypted_filename: Path) -> str:
def format_key_info_for_display(key_info: dict[str, object]) -> dict[str, str]:
- """Format strict key-inspection metadata for Streamlit display."""
+ """Format strict key-inspection metadata for local web display."""
display = {
"Key Type": str(key_info["key_type"]).title(),
"Algorithm": str(key_info["kem"]),
diff --git a/vite.config.ts b/vite.config.ts
index 52c0507..2778f58 100644
--- a/vite.config.ts
+++ b/vite.config.ts
@@ -1,3 +1,5 @@
+///
+
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
@@ -15,5 +17,11 @@ export default defineConfig({
build: {
outDir: "../static/app",
emptyOutDir: true
+ },
+ test: {
+ environment: "jsdom",
+ setupFiles: ["./src/test/setup.ts"],
+ clearMocks: true,
+ restoreMocks: true
}
});
diff --git a/web/src/App.tsx b/web/src/App.tsx
deleted file mode 100644
index 923a8d3..0000000
--- a/web/src/App.tsx
+++ /dev/null
@@ -1,632 +0,0 @@
-import { useEffect, useMemo, useState } from "react";
-import {
- ApiError,
- decryptFile,
- DownloadResult,
- encryptFile,
- fetchHealth,
- generateKeys,
- GeneratedKeys,
- Health,
- inspectKey,
- KeyInspectResult
-} from "./api";
-
-type View = "encrypt" | "decrypt" | "generate" | "inspect";
-type NoticeKind = "info" | "success" | "warning" | "error";
-type Notice = { kind: NoticeKind; text: string };
-
-const DEFAULT_HEALTH: Health = {
- ok: true,
- backendReady: false,
- backendMessage: "Checking backend readiness.",
- formatVersion: 4,
- kem: "ML-KEM-768+X25519-v2",
- kemComponent: "ML-KEM-768",
- configuredKem: "ML-KEM-768",
- dem: "AES-256-GCM",
- maxFileBytes: 100 * 1024 * 1024,
- maxEncryptedFileBytes: 101 * 1024 * 1024,
- maxPemBytes: 128 * 1024,
- apiToken: "",
- passwordPolicy: { minChars: 16, minUniqueChars: 5 }
-};
-
-const views: Array<{ id: View; label: string; group: "File Workflows" | "Key Management" }> = [
- { id: "encrypt", label: "Encrypt File", group: "File Workflows" },
- { id: "decrypt", label: "Decrypt File", group: "File Workflows" },
- { id: "generate", label: "Generate Keys", group: "Key Management" },
- { id: "inspect", label: "Inspect Key", group: "Key Management" }
-];
-
-function formatBytes(bytes: number): string {
- if (!Number.isFinite(bytes)) return "0 B";
- if (bytes < 1024) return `${bytes} B`;
- const units = ["KiB", "MiB", "GiB"];
- let value = bytes / 1024;
- let index = 0;
- while (value >= 1024 && index < units.length - 1) {
- value /= 1024;
- index += 1;
- }
- return `${value.toFixed(value >= 10 ? 1 : 2)} ${units[index]}`;
-}
-
-function downloadBlob(result: DownloadResult): void {
- const url = URL.createObjectURL(result.blob);
- const anchor = document.createElement("a");
- anchor.href = url;
- anchor.download = result.filename;
- document.body.append(anchor);
- anchor.click();
- anchor.remove();
- window.setTimeout(() => URL.revokeObjectURL(url), 1000);
-}
-
-function downloadText(filename: string, text: string): void {
- downloadBlob({ filename, blob: new Blob([text], { type: "application/x-pem-file" }) });
-}
-
-function apiMessage(error: unknown): string {
- if (error instanceof ApiError) return error.message;
- if (error instanceof Error) return error.message;
- return "Request failed.";
-}
-
-function fileTooLarge(file: File | null, maxBytes: number): boolean {
- return !!file && file.size > maxBytes;
-}
-
-function sizeNotice(file: File | null, maxBytes: number, label: string): Notice | null {
- if (!fileTooLarge(file, maxBytes)) return null;
- return {
- kind: "error",
- text: `${label} is ${formatBytes(file?.size ?? 0)}. The maximum supported size is ${formatBytes(maxBytes)}.`
- };
-}
-
-function suggestedEncryptedName(file: File | null): string {
- if (!file) return "encrypted-file.pqc";
- const dot = file.name.lastIndexOf(".");
- const stem = dot > 0 ? file.name.slice(0, dot) : file.name || "file";
- return `${stem}_encrypted.pqc`;
-}
-
-function suggestedDecryptedName(file: File | null): string {
- if (!file) return "decrypted.bin";
- const name = file.name;
- if (name === ".pqc") return "decrypted.bin";
- if (name.endsWith("_encrypted.pqc")) return name.slice(0, -"_encrypted.pqc".length) || "decrypted.bin";
- if (name.endsWith(".pqc")) return `${name.slice(0, -".pqc".length)}_decrypted.bin`;
- const dot = name.lastIndexOf(".");
- if (dot > 0) return `${name.slice(0, dot)}_decrypted${name.slice(dot)}`;
- return `${name}_decrypted.bin`;
-}
-
-function useKeyInspection(file: File | null, maxPemBytes: number) {
- const [result, setResult] = useState(null);
- const [notice, setNotice] = useState(null);
- const [loading, setLoading] = useState(false);
-
- useEffect(() => {
- let cancelled = false;
- setResult(null);
- setNotice(null);
- if (!file) return;
- if (fileTooLarge(file, maxPemBytes)) {
- setNotice(sizeNotice(file, maxPemBytes, "Key file"));
- return;
- }
- setLoading(true);
- inspectKey(file)
- .then((payload) => {
- if (!cancelled) setResult(payload);
- })
- .catch((error) => {
- if (!cancelled) setNotice({ kind: "error", text: apiMessage(error) });
- })
- .finally(() => {
- if (!cancelled) setLoading(false);
- });
- return () => {
- cancelled = true;
- };
- }, [file, maxPemBytes]);
-
- return { result, notice, loading };
-}
-
-function App() {
- const [activeView, setActiveView] = useState("encrypt");
- const [health, setHealth] = useState(DEFAULT_HEALTH);
- const [healthNotice, setHealthNotice] = useState(null);
-
- useEffect(() => {
- let cancelled = false;
- fetchHealth()
- .then((payload) => {
- if (!cancelled) setHealth(payload);
- })
- .catch((error) => {
- if (!cancelled) setHealthNotice({ kind: "error", text: apiMessage(error) });
- });
- return () => {
- cancelled = true;
- };
- }, []);
-
- const readinessNotice = healthNotice ?? {
- kind: health.backendReady ? ("success" as const) : ("warning" as const),
- text: health.backendMessage
- };
-
- return (
-
-
-
- {activeView === "encrypt" && }
- {activeView === "decrypt" && }
- {activeView === "generate" && }
- {activeView === "inspect" && }
-
-
- );
-}
-
-function Sidebar({ activeView, health, onSelect }: { activeView: View; health: Health; onSelect: (view: View) => void }) {
- return (
-
-
-
- QE
-
-
-
Quantum Encryptor
-
Local post-quantum file security
-
-
-
- {(["File Workflows", "Key Management"] as const).map((group) => (
-
- {group}
- {views
- .filter((item) => item.group === group)
- .map((item) => (
- onSelect(item.id)}
- type="button"
- >
- {item.label}
-
- ))}
-
- ))}
-
-
-
-
-
{health.backendReady ? "Backend ready" : "Backend not ready"}
-
{health.kem}
-
-
-
-
-
-
-
-
-
-
- );
-}
-
-function MetaBadge({ label, value }: { label: string; value: string }) {
- return (
-
- {label}
- {value}
-
- );
-}
-
-function NoticePanel({ notice }: { notice: Notice | null }) {
- if (!notice) return null;
- return (
-
- {notice.text}
-
- );
-}
-
-function WorkflowHeader({ title, subtitle }: { title: string; subtitle: string }) {
- return (
-
- );
-}
-
-function Stepper({ steps, activeIndex }: { steps: string[]; activeIndex: number }) {
- return (
-
- {steps.map((step, index) => (
-
- {index + 1}
- {step}
-
- ))}
-
- );
-}
-
-function FileDrop({
- id,
- label,
- hint,
- accept,
- file,
- onFile
-}: {
- id: string;
- label: string;
- hint: string;
- accept?: string;
- file: File | null;
- onFile: (file: File | null) => void;
-}) {
- return (
-
- onFile(event.target.files?.[0] ?? null)}
- type="file"
- />
- Choose file
-
- {file ? file.name : label}
- {file ? formatBytes(file.size) : hint}
-
-
- );
-}
-
-function FieldRow({ label, value }: { label: string; value: string }) {
- return (
-
- {label}
- {value}
-
- );
-}
-
-function EncryptView({ health, readinessNotice }: { health: Health; readinessNotice: Notice }) {
- const [file, setFile] = useState(null);
- const [publicKey, setPublicKey] = useState(null);
- const [outputName, setOutputName] = useState("encrypted-file.pqc");
- const [notice, setNotice] = useState(null);
- const [working, setWorking] = useState(false);
- const [downloadReady, setDownloadReady] = useState(false);
- const keyInspection = useKeyInspection(publicKey, health.maxPemBytes);
-
- useEffect(() => {
- setOutputName(suggestedEncryptedName(file));
- setDownloadReady(false);
- }, [file]);
-
- const publicKeyReady =
- keyInspection.result?.keyInfo.key_type === "public" && keyInspection.result.keyInfo.kem === health.kem;
- const fileSizeNotice = sizeNotice(file, health.maxFileBytes, "Input file");
- const keySizeNotice = sizeNotice(publicKey, health.maxPemBytes, "Public key file");
- const activeIndex = downloadReady ? 4 : publicKeyReady && outputName ? 3 : file && publicKey ? 2 : file ? 1 : 0;
- const canEncrypt =
- health.backendReady &&
- !!file &&
- !fileSizeNotice &&
- publicKeyReady &&
- !keySizeNotice &&
- !!outputName &&
- !working;
-
- async function submit() {
- if (!file || !publicKey || !canEncrypt) return;
- setNotice(null);
- setWorking(true);
- try {
- const result = await encryptFile(file, publicKey, outputName);
- downloadBlob(result);
- setDownloadReady(true);
- setNotice({ kind: "success", text: `Encrypted file prepared as ${result.filename}.` });
- } catch (error) {
- setNotice({ kind: "error", text: apiMessage(error) });
- } finally {
- setWorking(false);
- }
- }
-
- return (
-
-
-
-
-
-
-
Inputs
-
-
-
- {keyInspection.loading && }
-
-
-
-
Review
-
-
-
- Output filename
- setOutputName(event.target.value)} />
-
-
- {working ? "Encrypting..." : "Encrypt File"}
-
-
-
-
-
- );
-}
-
-function DecryptView({ health, readinessNotice }: { health: Health; readinessNotice: Notice }) {
- const [file, setFile] = useState(null);
- const [privateKey, setPrivateKey] = useState(null);
- const [password, setPassword] = useState("");
- const [outputName, setOutputName] = useState("decrypted.bin");
- const [notice, setNotice] = useState(null);
- const [working, setWorking] = useState(false);
- const [downloadReady, setDownloadReady] = useState(false);
- const keyInspection = useKeyInspection(privateKey, health.maxPemBytes);
-
- useEffect(() => {
- setOutputName(suggestedDecryptedName(file));
- setDownloadReady(false);
- }, [file]);
-
- const privateKeyReady = keyInspection.result?.keyInfo.key_type === "private";
- const fileSizeNotice = sizeNotice(file, health.maxEncryptedFileBytes, "Encrypted file");
- const keySizeNotice = sizeNotice(privateKey, health.maxPemBytes, "Private key file");
- const activeIndex = downloadReady ? 4 : privateKeyReady && password && outputName ? 3 : file && privateKey ? 2 : file ? 1 : 0;
- const canDecrypt =
- !!file &&
- !fileSizeNotice &&
- privateKeyReady &&
- !keySizeNotice &&
- !!password &&
- !!outputName &&
- !working;
-
- async function submit() {
- if (!file || !privateKey || !canDecrypt) return;
- setNotice(null);
- setWorking(true);
- try {
- const result = await decryptFile(file, privateKey, password, outputName);
- downloadBlob(result);
- setDownloadReady(true);
- setPassword("");
- setNotice({ kind: "success", text: `Decrypted file prepared as ${result.filename}.` });
- } catch (error) {
- setNotice({ kind: "error", text: apiMessage(error) });
- } finally {
- setWorking(false);
- }
- }
-
- return (
-
- );
-}
-
-function GenerateKeysView({ health, readinessNotice }: { health: Health; readinessNotice: Notice }) {
- const [password, setPassword] = useState("");
- const [confirm, setConfirm] = useState("");
- const [notice, setNotice] = useState(null);
- const [working, setWorking] = useState(false);
- const [keys, setKeys] = useState(null);
-
- const checks = useMemo(
- () => [
- { label: `${health.passwordPolicy.minChars}+ characters`, ok: password.length >= health.passwordPolicy.minChars },
- { label: `${health.passwordPolicy.minUniqueChars}+ unique characters`, ok: new Set(password).size >= health.passwordPolicy.minUniqueChars },
- { label: "Confirmation matches", ok: password.length > 0 && password === confirm }
- ],
- [confirm, health.passwordPolicy.minChars, health.passwordPolicy.minUniqueChars, password]
- );
- const canGenerate = health.backendReady && checks.every((check) => check.ok) && !working;
- const activeIndex = keys ? 4 : canGenerate ? 2 : password || confirm ? 1 : 0;
-
- async function submit() {
- if (!canGenerate) return;
- setNotice(null);
- setWorking(true);
- setKeys(null);
- try {
- const result = await generateKeys(password);
- setKeys(result);
- setPassword("");
- setConfirm("");
- setNotice({ kind: "success", text: "Key pair generated. Download both PEM files now." });
- } catch (error) {
- setNotice({ kind: "error", text: apiMessage(error) });
- } finally {
- setWorking(false);
- }
- }
-
- return (
-
-
-
-
-
-
-
Private key password
-
- Password
- setPassword(event.target.value)}
- />
-
-
- Confirm password
- setConfirm(event.target.value)}
- />
-
-
- {checks.map((check) => (
-
-
- {check.label}
-
- ))}
-
-
- {working ? "Generating..." : `Generate ${health.kem} Key Pair`}
-
-
-
-
-
Downloads
- {keys ? (
-
- downloadText(keys.publicFilename, keys.publicPem)} type="button">
- Public Key
- {keys.publicFilename}
-
- downloadText(keys.privateFilename, keys.privatePem)} type="button">
- Encrypted Private Key
- {keys.privateFilename}
-
-
- ) : (
-
Generated PEM files will appear here for immediate download.
- )}
-
-
-
- );
-}
-
-function InspectKeyView({ health }: { health: Health }) {
- const [keyFile, setKeyFile] = useState(null);
- const keyInspection = useKeyInspection(keyFile, health.maxPemBytes);
-
- return (
-
-
-
-
-
-
Key file
-
- {keyInspection.loading && }
-
-
-
-
Metadata
- {keyInspection.result ? (
-
- {Object.entries(keyInspection.result.display).map(([label, value]) => (
-
- ))}
-
- ) : (
-
Supported key metadata will appear here.
- )}
-
-
-
- );
-}
-
-export default App;
diff --git a/web/src/api.ts b/web/src/api/client.ts
similarity index 77%
rename from web/src/api.ts
rename to web/src/api/client.ts
index ba32d30..9627710 100644
--- a/web/src/api.ts
+++ b/web/src/api/client.ts
@@ -1,47 +1,4 @@
-export type Health = {
- ok: boolean;
- backendReady: boolean;
- backendMessage: string;
- formatVersion: number;
- kem: string;
- kemComponent: string;
- configuredKem: string;
- dem: string;
- maxFileBytes: number;
- maxEncryptedFileBytes: number;
- maxPemBytes: number;
- apiToken: string;
- passwordPolicy: {
- minChars: number;
- minUniqueChars: number;
- };
-};
-
-export type KeyInspectResult = {
- ok: boolean;
- keyInfo: {
- kem: string;
- key_type: "public" | "private";
- private_key_encrypted?: boolean;
- private_key_format_version?: number;
- private_key_kdf?: string;
- };
- display: Record;
-};
-
-export type GeneratedKeys = {
- ok: boolean;
- kem: string;
- publicPem: string;
- privatePem: string;
- publicFilename: string;
- privateFilename: string;
-};
-
-export type DownloadResult = {
- blob: Blob;
- filename: string;
-};
+import type { DownloadResult, GeneratedKeys, Health, KeyInspectResult } from "./contracts";
type ApiErrorPayload = {
ok: false;
@@ -138,28 +95,37 @@ export async function fetchHealth(): Promise {
return healthRequest;
}
-export async function inspectKey(file: File): Promise {
+export async function inspectKey(file: File, signal?: AbortSignal): Promise {
const form = new FormData();
form.append("key", file);
- const response = await fetchWithApiToken("/api/keys/inspect", { method: "POST", body: form });
+ const response = await fetchWithApiToken("/api/keys/inspect", {
+ method: "POST",
+ body: form,
+ signal
+ });
if (!response.ok) await parseError(response);
return (await response.json()) as KeyInspectResult;
}
-export async function generateKeys(password: string): Promise {
+export async function generateKeys(password: string, signal?: AbortSignal): Promise {
const form = new FormData();
form.append("password", password);
- const response = await fetchWithApiToken("/api/keys/generate", { method: "POST", body: form });
+ const response = await fetchWithApiToken("/api/keys/generate", { method: "POST", body: form, signal });
if (!response.ok) await parseError(response);
return (await response.json()) as GeneratedKeys;
}
-export async function encryptFile(file: File, publicKey: File, outputFilename: string): Promise {
+export async function encryptFile(
+ file: File,
+ publicKey: File,
+ outputFilename: string,
+ signal?: AbortSignal
+): Promise {
const form = new FormData();
form.append("file", file);
form.append("public_key", publicKey);
form.append("output_filename", outputFilename);
- const response = await fetchWithApiToken("/api/files/encrypt", { method: "POST", body: form });
+ const response = await fetchWithApiToken("/api/files/encrypt", { method: "POST", body: form, signal });
if (!response.ok) await parseError(response);
return {
blob: await response.blob(),
@@ -171,14 +137,15 @@ export async function decryptFile(
file: File,
privateKey: File,
password: string,
- outputFilename: string
+ outputFilename: string,
+ signal?: AbortSignal
): Promise {
const form = new FormData();
form.append("file", file);
form.append("private_key", privateKey);
form.append("password", password);
form.append("output_filename", outputFilename);
- const response = await fetchWithApiToken("/api/files/decrypt", { method: "POST", body: form });
+ const response = await fetchWithApiToken("/api/files/decrypt", { method: "POST", body: form, signal });
if (!response.ok) await parseError(response);
return {
blob: await response.blob(),
diff --git a/web/src/api/contracts.ts b/web/src/api/contracts.ts
new file mode 100644
index 0000000..4aae2a1
--- /dev/null
+++ b/web/src/api/contracts.ts
@@ -0,0 +1,68 @@
+export type CapabilityName = "inspect" | "generate" | "encrypt" | "decrypt";
+
+export type Capability = {
+ available: boolean;
+ reason: string;
+};
+
+export type Health = {
+ ok: boolean;
+ backendReady: boolean;
+ backendMessage: string;
+ capabilities: Record;
+ formatVersion: number;
+ kem: string;
+ kemComponent: string;
+ configuredKem: string;
+ dem: string;
+ maxFileBytes: number;
+ maxEncryptedFileBytes: number;
+ maxPemBytes: number;
+ apiToken: string;
+ passwordPolicy: {
+ minChars: number;
+ minUniqueChars: number;
+ };
+};
+
+export type KeyInspectResult = {
+ ok: boolean;
+ keyInfo: {
+ kem: string;
+ key_type: "public" | "private";
+ private_key_encrypted?: boolean;
+ private_key_format_version?: number;
+ private_key_kdf?: string;
+ };
+ display: Record;
+};
+
+export type GeneratedKeys = {
+ ok: boolean;
+ kem: string;
+ publicPem: string;
+ privatePem: string;
+ publicFilename: string;
+ privateFilename: string;
+};
+
+export type DownloadResult = {
+ blob: Blob;
+ filename: string;
+};
+
+export type InspectKeyOperation = (file: File, signal?: AbortSignal) => Promise;
+export type GenerateKeysOperation = (password: string, signal?: AbortSignal) => Promise;
+export type EncryptFileOperation = (
+ file: File,
+ publicKey: File,
+ outputFilename: string,
+ signal?: AbortSignal
+) => Promise;
+export type DecryptFileOperation = (
+ file: File,
+ privateKey: File,
+ password: string,
+ outputFilename: string,
+ signal?: AbortSignal
+) => Promise;
diff --git a/web/src/api/errors.ts b/web/src/api/errors.ts
new file mode 100644
index 0000000..68c278a
--- /dev/null
+++ b/web/src/api/errors.ts
@@ -0,0 +1,18 @@
+import { ApiError } from "./client";
+
+const LOCAL_SERVICE_UNAVAILABLE = "Could not reach the local service. Check that it is running and try again.";
+
+export function isAbortError(error: unknown): boolean {
+ return error instanceof DOMException
+ ? error.name === "AbortError"
+ : error instanceof Error && error.name === "AbortError";
+}
+
+export function safeOperationError(error: unknown, fallback: string): string {
+ if (error instanceof ApiError) {
+ if (error.status < 500 || error.code === "backend_unavailable") return error.message;
+ return fallback;
+ }
+ if (error instanceof TypeError) return LOCAL_SERVICE_UNAVAILABLE;
+ return fallback;
+}
diff --git a/web/src/api/index.ts b/web/src/api/index.ts
new file mode 100644
index 0000000..589823a
--- /dev/null
+++ b/web/src/api/index.ts
@@ -0,0 +1,13 @@
+export { ApiError, decryptFile, encryptFile, fetchHealth, generateKeys, inspectKey } from "./client";
+export type {
+ Capability,
+ CapabilityName,
+ DecryptFileOperation,
+ DownloadResult,
+ EncryptFileOperation,
+ GeneratedKeys,
+ GenerateKeysOperation,
+ Health,
+ InspectKeyOperation,
+ KeyInspectResult
+} from "./contracts";
diff --git a/web/src/app/App.test.tsx b/web/src/app/App.test.tsx
new file mode 100644
index 0000000..9c4c4c1
--- /dev/null
+++ b/web/src/app/App.test.tsx
@@ -0,0 +1,130 @@
+import { StrictMode } from "react";
+import { render, screen, waitFor } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+import { READY_HEALTH } from "../test/fixtures";
+import App from "./App";
+
+const client = vi.hoisted(() => ({
+ fetchHealth: vi.fn(),
+ generateKeys: vi.fn()
+}));
+
+vi.mock("../api/client", async (importOriginal) => {
+ const actual = await importOriginal();
+ return {
+ ...actual,
+ fetchHealth: client.fetchHealth,
+ generateKeys: client.generateKeys
+ };
+});
+
+async function openGeneratedKeys(user: ReturnType) {
+ await screen.findByRole("heading", { name: "Encrypt a file" });
+ await user.click(screen.getByRole("button", { name: "Generate keys" }));
+ await screen.findByRole("heading", { name: "Generate keys" });
+ await user.type(screen.getByLabelText("Private key password"), "correct horse battery staple");
+ await user.type(screen.getByLabelText("Confirm private key password"), "correct horse battery staple");
+ await user.click(screen.getByRole("button", { name: "Generate key pair" }));
+ await screen.findByRole("button", { name: "Download public key" });
+}
+
+beforeEach(() => {
+ client.fetchHealth.mockReset();
+ client.fetchHealth.mockResolvedValue(READY_HEALTH);
+ client.generateKeys.mockReset();
+ client.generateKeys.mockResolvedValue({
+ ok: true,
+ kem: READY_HEALTH.kem,
+ publicPem: "PUBLIC-PEM",
+ privatePem: "ENCRYPTED-PRIVATE-PEM",
+ publicFilename: "recipient-public.pem",
+ privateFilename: "recipient-private.pem"
+ });
+});
+
+describe("App", () => {
+ it("starts on file encryption and switches to key inspection", async () => {
+ const user = userEvent.setup();
+
+ render( );
+
+ expect(await screen.findByRole("heading", { name: "Encrypt a file" })).toBeVisible();
+ await user.click(screen.getByRole("button", { name: "Inspect key" }));
+
+ expect(await screen.findByRole("heading", { name: "Inspect a key" })).toBeVisible();
+ });
+
+ it("coalesces the initial health request in StrictMode while workflows stay unmounted", async () => {
+ let resolveHealth: (health: typeof READY_HEALTH) => void = () => undefined;
+ const pendingHealth = new Promise((resolve) => {
+ resolveHealth = resolve;
+ });
+ client.fetchHealth.mockReturnValue(pendingHealth);
+
+ render(
+
+
+
+ );
+
+ expect(client.fetchHealth).toHaveBeenCalledTimes(1);
+ expect(screen.getByRole("status")).toHaveTextContent("Loading local engine status.");
+ expect(screen.queryByRole("heading", { name: "Encrypt a file" })).not.toBeInTheDocument();
+
+ resolveHealth(READY_HEALTH);
+
+ expect(await screen.findByRole("heading", { name: "Encrypt a file" })).toBeVisible();
+ });
+
+ it("retries a failed health request with a fresh request and safe recovery", async () => {
+ client.fetchHealth.mockRejectedValueOnce(new Error("raw backend connection details"));
+ client.fetchHealth.mockResolvedValueOnce(READY_HEALTH);
+ const user = userEvent.setup();
+
+ render( );
+
+ expect(
+ await screen.findByText("The local engine status could not be loaded. Restart the app and try again.")
+ ).toBeVisible();
+ expect(screen.getByRole("button", { name: "Retry" })).toBeEnabled();
+ expect(screen.queryByText("raw backend connection details")).not.toBeInTheDocument();
+
+ await user.click(screen.getByRole("button", { name: "Retry" }));
+
+ expect(client.fetchHealth).toHaveBeenCalledTimes(2);
+ expect(await screen.findByRole("heading", { name: "Encrypt a file" })).toBeVisible();
+ });
+
+ it("keeps generated keys active when leaving is cancelled", async () => {
+ const user = userEvent.setup();
+ const confirm = vi.spyOn(window, "confirm").mockReturnValue(false);
+
+ render( );
+ await openGeneratedKeys(user);
+ await user.click(screen.getByRole("button", { name: "Inspect key" }));
+
+ expect(confirm).toHaveBeenCalledWith("Generated keys are still available. Leave this workflow and clear them?");
+ expect(screen.getByRole("heading", { name: "Generate keys" })).toBeVisible();
+ expect(screen.getByRole("button", { name: "Download public key" })).toBeVisible();
+ });
+
+ it("clears generated-key state before leaving after confirmation", async () => {
+ const user = userEvent.setup();
+ const confirm = vi.spyOn(window, "confirm").mockReturnValue(true);
+
+ render( );
+ await openGeneratedKeys(user);
+ await user.click(screen.getByRole("button", { name: "Inspect key" }));
+
+ expect(confirm).toHaveBeenCalledWith("Generated keys are still available. Leave this workflow and clear them?");
+ expect(await screen.findByRole("heading", { name: "Inspect a key" })).toBeVisible();
+
+ await user.click(screen.getByRole("button", { name: "Generate keys" }));
+ await screen.findByRole("heading", { name: "Generate keys" });
+ await user.click(screen.getByRole("button", { name: "Encrypt" }));
+
+ await waitFor(() => expect(screen.getByRole("heading", { name: "Encrypt a file" })).toBeVisible());
+ expect(confirm).toHaveBeenCalledTimes(1);
+ });
+});
diff --git a/web/src/app/App.tsx b/web/src/app/App.tsx
new file mode 100644
index 0000000..1cb7e03
--- /dev/null
+++ b/web/src/app/App.tsx
@@ -0,0 +1,84 @@
+import { useCallback, useEffect, useRef, useState } from "react";
+import { fetchHealth } from "../api/client";
+import type { Health } from "../api/contracts";
+import { DecryptWorkflow } from "../features/decrypt/DecryptWorkflow";
+import { EncryptWorkflow } from "../features/encrypt/EncryptWorkflow";
+import { GenerateKeysWorkflow } from "../features/generate/GenerateKeysWorkflow";
+import { InspectKeyWorkflow } from "../features/inspect/InspectKeyWorkflow";
+import { AppShell } from "./AppShell";
+import type { View } from "./navigation";
+
+const SENSITIVE_RESULT_CONFIRMATION = "Generated keys are still available. Leave this workflow and clear them?";
+
+export default function App() {
+ const [activeView, setActiveView] = useState("encrypt");
+ const [health, setHealth] = useState(null);
+ const [healthError, setHealthError] = useState(null);
+ const [hasGeneratedKeys, setHasGeneratedKeys] = useState(false);
+ const healthRequestId = useRef(0);
+ const initialHealthRequest = useRef | null>(null);
+
+ const loadHealth = useCallback(async (retry = false) => {
+ const requestId = healthRequestId.current + 1;
+ healthRequestId.current = requestId;
+ setHealth(null);
+ setHealthError(null);
+ const request = retry
+ ? fetchHealth()
+ : (initialHealthRequest.current ?? (initialHealthRequest.current = fetchHealth()));
+
+ try {
+ const nextHealth = await request;
+ if (requestId === healthRequestId.current) setHealth(nextHealth);
+ } catch {
+ if (requestId === healthRequestId.current) {
+ setHealthError("The local engine status could not be loaded. Restart the app and try again.");
+ }
+ }
+ }, []);
+
+ useEffect(() => {
+ void loadHealth();
+ return () => {
+ healthRequestId.current += 1;
+ };
+ }, [loadHealth]);
+
+ function navigate(nextView: View) {
+ if (nextView === activeView) return;
+ if (activeView === "generate" && hasGeneratedKeys) {
+ if (!window.confirm(SENSITIVE_RESULT_CONFIRMATION)) return;
+ setHasGeneratedKeys(false);
+ }
+ setActiveView(nextView);
+ }
+
+ if (!health) {
+ return (
+
+ {healthError ? (
+ <>
+ Local engine unavailable
+ {healthError}
+ void loadHealth(true)} type="button">
+ Retry
+
+ >
+ ) : (
+ Loading local engine status.
+ )}
+
+ );
+ }
+
+ return (
+
+ {activeView === "encrypt" && }
+ {activeView === "decrypt" && }
+ {activeView === "generate" && (
+
+ )}
+ {activeView === "inspect" && }
+
+ );
+}
diff --git a/web/src/app/AppShell.test.tsx b/web/src/app/AppShell.test.tsx
new file mode 100644
index 0000000..17ee297
--- /dev/null
+++ b/web/src/app/AppShell.test.tsx
@@ -0,0 +1,59 @@
+import { render, screen } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { expect, it, vi } from "vitest";
+import { READY_HEALTH } from "../test/fixtures";
+import { AppShell } from "./AppShell";
+
+it("exposes semantic navigation and changes workflows", async () => {
+ const user = userEvent.setup();
+ const onNavigate = vi.fn();
+
+ render(
+
+ Encrypt a file
+
+ );
+
+ expect(screen.getByRole("navigation", { name: "Workflows" })).toBeVisible();
+ expect(screen.getByRole("button", { name: "Encrypt" })).toHaveAttribute("aria-current", "page");
+
+ await user.click(screen.getByRole("button", { name: "Decrypt" }));
+
+ expect(onNavigate).toHaveBeenCalledWith("decrypt");
+ expect(screen.getByText("Local engine")).toBeVisible();
+});
+
+it("keeps a supplied availability reason in capability details", () => {
+ const health = {
+ ...READY_HEALTH,
+ capabilities: {
+ ...READY_HEALTH.capabilities,
+ encrypt: {
+ available: true,
+ reason: "Uses the device-bound crypto provider."
+ }
+ }
+ };
+
+ render(
+ undefined}>
+ Encrypt a file
+
+ );
+
+ expect(screen.getAllByText("Uses the device-bound crypto provider.")).toHaveLength(2);
+});
+
+it("places narrow-layout workflow navigation before the main content in keyboard order", () => {
+ render(
+ undefined}>
+ Encrypt a file
+
+ );
+
+ const mobileNavigation = screen.getByRole("navigation", { name: "Mobile workflows" });
+ const main = screen.getByRole("main");
+
+ expect(mobileNavigation.compareDocumentPosition(main) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
+ expect(mobileNavigation).toContainElement(screen.getByRole("link", { name: "Encrypt" }));
+});
diff --git a/web/src/app/AppShell.tsx b/web/src/app/AppShell.tsx
new file mode 100644
index 0000000..68c5567
--- /dev/null
+++ b/web/src/app/AppShell.tsx
@@ -0,0 +1,99 @@
+import type { ReactNode } from "react";
+import type { Health } from "../api";
+import { CapabilityStatus } from "../components/CapabilityStatus";
+import "../styles/components.css";
+import { NAV_ITEMS, type View } from "./navigation";
+
+export type AppShellProps = {
+ activeView: View;
+ health: Health;
+ onNavigate: (view: View) => void;
+ children: ReactNode;
+};
+
+function Brand() {
+ return (
+
+
+ QE
+
+
+ Quantum Encryptor
+ Local file security
+
+
+ );
+}
+
+function RailNavigation({ activeView, onNavigate }: Pick) {
+ return (
+
+ Workflows
+ {NAV_ITEMS.map((item) => (
+ onNavigate(item.id)}
+ type="button"
+ >
+ {item.label}
+
+ ))}
+
+ );
+}
+
+function HeaderNavigation({ activeView, health, onNavigate }: Omit) {
+ return (
+
+
+
+ Workflow
+ onNavigate(event.target.value as View)} value={activeView}>
+ {NAV_ITEMS.map((item) => (
+
+ {item.label}
+
+ ))}
+
+
+
+
+ );
+}
+
+function MobileNavigation({ activeView, onNavigate }: Pick) {
+ return (
+
+ {NAV_ITEMS.map((item) => (
+ {
+ event.preventDefault();
+ onNavigate(item.id);
+ }}
+ >
+ {item.label}
+
+ ))}
+
+ );
+}
+
+export function AppShell({ activeView, health, onNavigate, children }: AppShellProps) {
+ return (
+
+ );
+}
diff --git a/web/src/app/navigation.ts b/web/src/app/navigation.ts
new file mode 100644
index 0000000..d1b922d
--- /dev/null
+++ b/web/src/app/navigation.ts
@@ -0,0 +1,8 @@
+export type View = "encrypt" | "decrypt" | "generate" | "inspect";
+
+export const NAV_ITEMS: ReadonlyArray<{ id: View; label: string }> = [
+ { id: "encrypt", label: "Encrypt" },
+ { id: "decrypt", label: "Decrypt" },
+ { id: "generate", label: "Generate keys" },
+ { id: "inspect", label: "Inspect key" }
+];
diff --git a/web/src/components/ActionButton.tsx b/web/src/components/ActionButton.tsx
new file mode 100644
index 0000000..1649449
--- /dev/null
+++ b/web/src/components/ActionButton.tsx
@@ -0,0 +1,14 @@
+import type { ButtonHTMLAttributes } from "react";
+
+export type ActionButtonProps = ButtonHTMLAttributes & {
+ busy?: boolean;
+ busyLabel: string;
+};
+
+export function ActionButton({ busy = false, busyLabel, children, disabled, ...props }: ActionButtonProps) {
+ return (
+
+ {busy ? busyLabel : children}
+
+ );
+}
diff --git a/web/src/components/CapabilityStatus.tsx b/web/src/components/CapabilityStatus.tsx
new file mode 100644
index 0000000..1225722
--- /dev/null
+++ b/web/src/components/CapabilityStatus.tsx
@@ -0,0 +1,60 @@
+import type { Health } from "../api";
+
+type CapabilityStatusProps = {
+ health: Health;
+ compact?: boolean;
+};
+
+const capabilityLabels = {
+ encrypt: "Encrypt",
+ decrypt: "Decrypt",
+ generate: "Generate keys",
+ inspect: "Inspect key"
+} as const;
+
+function availabilityLabel(available: number): "Ready" | "Limited" | "Unavailable" {
+ if (available === 4) return "Ready";
+ if (available === 0) return "Unavailable";
+ return "Limited";
+}
+
+export function CapabilityStatus({ health, compact = false }: CapabilityStatusProps) {
+ const available = Object.values(health.capabilities).filter((capability) => capability.available).length;
+ const status = availabilityLabel(available);
+ const statusClass = status.toLowerCase();
+
+ return (
+
+
+
+ {compact ? (
+ {status}
+ ) : (
+ <>
+ Local engine
+ {status}
+ >
+ )}
+
+
+ Capability details
+
+ {(Object.keys(capabilityLabels) as Array).map((operation) => {
+ const capability = health.capabilities[operation];
+ const reason = capability.reason || (capability.available ? "Available" : "Unavailable");
+ return (
+
+ {capabilityLabels[operation]}
+ {reason}
+
+ );
+ })}
+
+
+
+ );
+}
diff --git a/web/src/components/FilePicker.tsx b/web/src/components/FilePicker.tsx
new file mode 100644
index 0000000..ef1e8a6
--- /dev/null
+++ b/web/src/components/FilePicker.tsx
@@ -0,0 +1,69 @@
+import type { ChangeEvent, DragEvent } from "react";
+import { formatBytes } from "../lib/format";
+
+export type FilePickerProps = {
+ id: string;
+ label: string;
+ hint: string;
+ accept?: string;
+ file: File | null;
+ error?: string;
+ disabled?: boolean;
+ onFile: (file: File | null) => void;
+};
+
+export function FilePicker({ id, label, hint, accept, file, error, disabled = false, onFile }: FilePickerProps) {
+ const descriptionId = `${id}-description`;
+ const errorId = `${id}-error`;
+ const describedBy = error ? `${descriptionId} ${errorId}` : descriptionId;
+
+ function selectFile(event: ChangeEvent) {
+ if (disabled) return;
+ onFile(event.target.files?.item(0) ?? null);
+ }
+
+ function allowDrop(event: DragEvent) {
+ event.preventDefault();
+ }
+
+ function dropFile(event: DragEvent) {
+ event.preventDefault();
+ if (disabled) return;
+ onFile(event.dataTransfer.files.item(0) ?? null);
+ }
+
+ return (
+
+
+ {label}
+ Choose a file or drop it here
+
+ {file ? `${file.name} · ${formatBytes(file.size)}` : hint}
+
+
+
+
+ {hint}
+
+ {error && (
+
+ {error}
+
+ )}
+
+ );
+}
diff --git a/web/src/components/Notice.tsx b/web/src/components/Notice.tsx
new file mode 100644
index 0000000..61fa12a
--- /dev/null
+++ b/web/src/components/Notice.tsx
@@ -0,0 +1,18 @@
+import type { ReactNode } from "react";
+
+export type NoticeKind = "info" | "success" | "warning" | "error";
+
+export type NoticeProps = {
+ kind: NoticeKind;
+ title?: string;
+ children: ReactNode;
+};
+
+export function Notice({ kind, title, children }: NoticeProps) {
+ return (
+
+ {title &&
{title} }
+
{children}
+
+ );
+}
diff --git a/web/src/components/PasswordField.tsx b/web/src/components/PasswordField.tsx
new file mode 100644
index 0000000..f388fee
--- /dev/null
+++ b/web/src/components/PasswordField.tsx
@@ -0,0 +1,35 @@
+import { useState } from "react";
+
+export type PasswordFieldProps = {
+ id: string;
+ label: string;
+ value: string;
+ autoComplete: "new-password" | "current-password";
+ describedBy?: string;
+ disabled?: boolean;
+ onChange: (value: string) => void;
+};
+
+export function PasswordField({ id, label, value, autoComplete, describedBy, disabled = false, onChange }: PasswordFieldProps) {
+ const [visible, setVisible] = useState(false);
+
+ return (
+
+
{label}
+
+ onChange(event.target.value)}
+ type={visible ? "text" : "password"}
+ value={value}
+ />
+ setVisible(!visible)} type="button">
+ {visible ? "Hide" : "Show"}
+
+
+
+ );
+}
diff --git a/web/src/components/TechnicalDetails.tsx b/web/src/components/TechnicalDetails.tsx
new file mode 100644
index 0000000..d7dff16
--- /dev/null
+++ b/web/src/components/TechnicalDetails.tsx
@@ -0,0 +1,14 @@
+import type { ReactNode } from "react";
+
+export type TechnicalDetailsProps = {
+ children: ReactNode;
+};
+
+export function TechnicalDetails({ children }: TechnicalDetailsProps) {
+ return (
+
+ Technical details
+ {children}
+
+ );
+}
diff --git a/web/src/components/WorkflowLayout.tsx b/web/src/components/WorkflowLayout.tsx
new file mode 100644
index 0000000..d1a5817
--- /dev/null
+++ b/web/src/components/WorkflowLayout.tsx
@@ -0,0 +1,39 @@
+import type { ReactNode } from "react";
+import type { Capability } from "../api";
+import type { WorkflowPhase } from "../lib/workflow";
+import { Notice } from "./Notice";
+
+export type WorkflowLayoutProps = {
+ title: string;
+ description: string;
+ phase: WorkflowPhase;
+ capability: Capability;
+ busy?: boolean;
+ children: ReactNode;
+};
+
+const phaseLabels: Record = {
+ select: "Select",
+ review: "Review",
+ complete: "Complete"
+};
+
+export function WorkflowLayout({ title, description, phase, capability, busy = false, children }: WorkflowLayoutProps) {
+ return (
+
+
+ {title}
+ {description}
+
+
+ {(Object.keys(phaseLabels) as WorkflowPhase[]).map((value) => (
+
+ {phaseLabels[value]}
+
+ ))}
+
+ {!capability.available && {capability.reason} }
+ {children}
+
+ );
+}
diff --git a/web/src/components/components.test.tsx b/web/src/components/components.test.tsx
new file mode 100644
index 0000000..0364ce9
--- /dev/null
+++ b/web/src/components/components.test.tsx
@@ -0,0 +1,83 @@
+import { render, screen } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { describe, expect, it, vi } from "vitest";
+import { FilePicker } from "./FilePicker";
+import { PasswordField } from "./PasswordField";
+import { TechnicalDetails } from "./TechnicalDetails";
+
+describe("shared controls", () => {
+ it("selects a file through the labeled native input", async () => {
+ const user = userEvent.setup();
+ const onFile = vi.fn();
+ render(
+
+ );
+ const file = new File(["hello"], "hello.txt", { type: "text/plain" });
+ await user.upload(screen.getByLabelText("File to protect"), file);
+ expect(onFile).toHaveBeenCalledWith(file);
+ });
+
+ it("displays selected file sizes with the shared binary precision", () => {
+ const file = new File([new Uint8Array(1024)], "key.pem", { type: "application/x-pem-file" });
+ render(
+ undefined}
+ />
+ );
+ expect(screen.getByText("key.pem · 1.00 KiB")).toBeVisible();
+ });
+
+ it("keeps the visually hidden native file input in keyboard focus order", async () => {
+ const user = userEvent.setup();
+ render(
+ undefined}
+ />
+ );
+
+ await user.tab();
+
+ expect(screen.getByLabelText("Keyboard file")).toHaveFocus();
+ });
+
+ it("announces password visibility through the button label", async () => {
+ const user = userEvent.setup();
+ render(
+ undefined}
+ autoComplete="current-password"
+ />
+ );
+ await user.click(screen.getByRole("button", { name: "Show password" }));
+ expect(screen.getByLabelText("Private key password")).toHaveAttribute("type", "text");
+ expect(screen.getByRole("button", { name: "Hide password" })).toBeVisible();
+ });
+
+ it("uses a native disclosure with accurate expanded state", async () => {
+ const user = userEvent.setup();
+ render(
+
+ ML-KEM-768 + X25519
+
+ );
+ await user.click(screen.getByText("Technical details"));
+ expect(screen.getByText("ML-KEM-768 + X25519")).toBeVisible();
+ });
+});
diff --git a/web/src/features/decrypt/DecryptWorkflow.test.tsx b/web/src/features/decrypt/DecryptWorkflow.test.tsx
new file mode 100644
index 0000000..a62bf6e
--- /dev/null
+++ b/web/src/features/decrypt/DecryptWorkflow.test.tsx
@@ -0,0 +1,306 @@
+import { render, screen, waitFor } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { describe, expect, it, vi } from "vitest";
+import { ApiError } from "../../api";
+import { READY_HEALTH } from "../../test/fixtures";
+import { DecryptWorkflow } from "./DecryptWorkflow";
+
+type Deferred = {
+ promise: Promise;
+ resolve: (value: T) => void;
+};
+
+function deferred(): Deferred {
+ let resolve!: (value: T) => void;
+ const promise = new Promise((resolvePromise) => {
+ resolve = resolvePromise;
+ });
+ return { promise, resolve };
+}
+
+function privateKeyInspection() {
+ return {
+ ok: true,
+ keyInfo: {
+ kem: READY_HEALTH.kem,
+ key_type: "private" as const,
+ private_key_encrypted: true,
+ private_key_format_version: 3,
+ private_key_kdf: "scrypt"
+ },
+ display: {
+ "Key Type": "Encrypted private key",
+ "Key Format": "3",
+ "Key KDF": "scrypt",
+ Algorithm: READY_HEALTH.kem
+ }
+ };
+}
+
+describe("DecryptWorkflow", () => {
+ it("decrypts with an inspected private key, saves the suggested filename, and clears the password", async () => {
+ const inspect = vi.fn().mockResolvedValue(privateKeyInspection());
+ const decrypt = vi.fn().mockResolvedValue({
+ filename: "report",
+ blob: new Blob(["plaintext"])
+ });
+ const save = vi.fn();
+ const user = userEvent.setup();
+
+ render( );
+
+ await user.upload(screen.getByLabelText("Encrypted file"), new File(["ciphertext"], "report_encrypted.pqc"));
+ await user.upload(screen.getByLabelText("Private key"), new File(["PEM"], "recipient_private.pem"));
+ expect(await screen.findByText("Supported encrypted private key; match not yet verified")).toBeVisible();
+
+ const password = screen.getByLabelText("Private key password");
+ await user.type(password, "correct horse battery staple");
+ expect(screen.getByLabelText("Output filename")).toHaveValue("report");
+
+ await user.click(screen.getByRole("button", { name: "Decrypt file" }));
+
+ await waitFor(() =>
+ expect(decrypt).toHaveBeenCalledWith(
+ expect.objectContaining({ name: "report_encrypted.pqc" }),
+ expect.objectContaining({ name: "recipient_private.pem" }),
+ "correct horse battery staple",
+ "report",
+ expect.any(AbortSignal)
+ )
+ );
+ expect(save).toHaveBeenCalledWith(expect.objectContaining({ filename: "report" }));
+ expect(password).toHaveValue("");
+ });
+
+ it("reports a browser download failure separately and still clears the password", async () => {
+ const inspect = vi.fn().mockResolvedValue(privateKeyInspection());
+ const decrypt = vi.fn().mockResolvedValue({ filename: "report", blob: new Blob(["plaintext"]) });
+ const save = vi.fn(() => {
+ throw new TypeError("browser download failed");
+ });
+ const user = userEvent.setup();
+
+ render( );
+ await user.upload(screen.getByLabelText("Encrypted file"), new File(["ciphertext"], "report_encrypted.pqc"));
+ await user.upload(screen.getByLabelText("Private key"), new File(["PEM"], "recipient_private.pem"));
+ await screen.findByText("Supported encrypted private key; match not yet verified");
+ const password = screen.getByLabelText("Private key password");
+ await user.type(password, "correct horse battery staple");
+ await user.click(screen.getByRole("button", { name: "Decrypt file" }));
+
+ expect(
+ await screen.findByText("The file was decrypted, but the download could not start. Try decrypting it again.")
+ ).toBeVisible();
+ expect(screen.queryByText(/could not reach the local service/i)).not.toBeInTheDocument();
+ expect(password).toHaveValue("");
+ });
+
+ it("uses a safe authentication failure message without singling out a secret", async () => {
+ const inspect = vi.fn().mockResolvedValue(privateKeyInspection());
+ const decrypt = vi.fn().mockRejectedValue(
+ new ApiError(
+ 400,
+ "decryption_failed",
+ "Decryption failed. Check the private key, password, and encrypted file integrity."
+ )
+ );
+ const user = userEvent.setup();
+
+ render( );
+
+ await user.upload(screen.getByLabelText("Encrypted file"), new File(["ciphertext"], "report_encrypted.pqc"));
+ await user.upload(screen.getByLabelText("Private key"), new File(["PEM"], "recipient_private.pem"));
+ await screen.findByText("Supported encrypted private key; match not yet verified");
+ const password = screen.getByLabelText("Private key password");
+ await user.type(password, "correct horse battery staple");
+ await user.click(screen.getByRole("button", { name: "Decrypt file" }));
+
+ expect(
+ await screen.findByText("The file could not be authenticated. Check the encrypted file, private key, and password.")
+ ).toBeVisible();
+ expect(screen.queryByText(/decryption failed\. check/i)).not.toBeInTheDocument();
+ expect(password).toHaveValue("correct horse battery staple");
+ });
+
+ it("locks every causal input while a decryption request is in flight", async () => {
+ const inspection = vi.fn().mockResolvedValue(privateKeyInspection());
+ const request = deferred<{ filename: string; blob: Blob }>();
+ const decrypt = vi.fn().mockReturnValue(request.promise);
+ const user = userEvent.setup();
+
+ render( );
+
+ await user.upload(screen.getByLabelText("Encrypted file"), new File(["ciphertext"], "report_encrypted.pqc"));
+ await user.upload(screen.getByLabelText("Private key"), new File(["PEM"], "recipient_private.pem"));
+ await screen.findByText("Supported encrypted private key; match not yet verified");
+ const password = screen.getByLabelText("Private key password");
+ await user.type(password, "correct horse battery staple");
+ await user.click(screen.getByRole("button", { name: "Decrypt file" }));
+ await waitFor(() => expect(decrypt).toHaveBeenCalledTimes(1));
+
+ expect(screen.getByLabelText("Encrypted file")).toBeDisabled();
+ expect(screen.getByLabelText("Private key")).toBeDisabled();
+ expect(password).toBeDisabled();
+ expect(screen.getByLabelText("Output filename")).toBeDisabled();
+ await user.type(password, "x");
+ expect(password).toHaveValue("correct horse battery staple");
+
+ request.resolve({ filename: "report", blob: new Blob(["plaintext"]) });
+ });
+
+ it("clears a completed decryption outcome when the password changes while idle", async () => {
+ const inspect = vi.fn().mockResolvedValue(privateKeyInspection());
+ const decrypt = vi.fn().mockResolvedValue({ filename: "report", blob: new Blob(["plaintext"]) });
+ const user = userEvent.setup();
+
+ render( );
+
+ await user.upload(screen.getByLabelText("Encrypted file"), new File(["ciphertext"], "report_encrypted.pqc"));
+ await user.upload(screen.getByLabelText("Private key"), new File(["PEM"], "recipient_private.pem"));
+ await screen.findByText("Supported encrypted private key; match not yet verified");
+ const password = screen.getByLabelText("Private key password");
+ await user.type(password, "correct horse battery staple");
+ await user.click(screen.getByRole("button", { name: "Decrypt file" }));
+ expect(await screen.findByText("Saved report.")).toBeVisible();
+
+ await user.type(password, "new");
+
+ expect(screen.queryByText("Saved report.")).not.toBeInTheDocument();
+ expect(screen.queryByText("File decrypted")).not.toBeInTheDocument();
+ });
+
+ it("hides inspection failure details while keeping decryption unavailable", async () => {
+ const inspect = vi.fn().mockRejectedValue(new Error("private parser detail: /secret/path/key.pem"));
+ const user = userEvent.setup();
+
+ render( );
+
+ await user.upload(screen.getByLabelText("Private key"), new File(["PEM"], "recipient_private.pem"));
+
+ expect(await screen.findByText("The private key could not be inspected.")).toBeVisible();
+ expect(screen.queryByText(/private parser detail/i)).not.toBeInTheDocument();
+ expect(screen.getByRole("button", { name: "Decrypt file" })).toBeDisabled();
+ });
+
+ it("rejects oversized encrypted files locally before decryption", async () => {
+ const health = { ...READY_HEALTH, maxEncryptedFileBytes: 3 };
+ const decrypt = vi.fn();
+ const user = userEvent.setup();
+
+ render( );
+
+ await user.upload(screen.getByLabelText("Encrypted file"), new File(["four"], "report_encrypted.pqc"));
+
+ expect(screen.getByText("This encrypted file exceeds the 3 byte limit.")).toBeVisible();
+ expect(screen.getByRole("button", { name: "Decrypt file" })).toBeDisabled();
+ expect(decrypt).not.toHaveBeenCalled();
+ });
+
+ it("accepts a legacy private key classification without rejecting its suite before authentication", async () => {
+ const inspect = vi.fn().mockResolvedValue({
+ ...privateKeyInspection(),
+ keyInfo: {
+ ...privateKeyInspection().keyInfo,
+ kem: "ML-KEM-768+X25519"
+ }
+ });
+ const decrypt = vi.fn().mockResolvedValue({ filename: "report", blob: new Blob(["plaintext"]) });
+ const user = userEvent.setup();
+
+ render( );
+
+ await user.upload(screen.getByLabelText("Encrypted file"), new File(["ciphertext"], "report_encrypted.pqc"));
+ await user.upload(screen.getByLabelText("Private key"), new File(["PEM"], "recipient_private.pem"));
+ await screen.findByText("Supported encrypted private key; match not yet verified");
+ await user.type(screen.getByLabelText("Private key password"), "correct horse battery staple");
+ await user.click(screen.getByRole("button", { name: "Decrypt file" }));
+
+ await waitFor(() => expect(decrypt).toHaveBeenCalledTimes(1));
+ });
+
+ it("does not claim a selected container version or pre-authentication key match", async () => {
+ const inspect = vi.fn().mockResolvedValue(privateKeyInspection());
+ const user = userEvent.setup();
+
+ render( );
+ await user.upload(screen.getByLabelText("Encrypted file"), new File(["legacy"], "archive.pqc"));
+ await user.upload(screen.getByLabelText("Private key"), new File(["PEM"], "archive-private.pem"));
+
+ expect(await screen.findByText("Supported encrypted private key; match not yet verified")).toBeVisible();
+ expect(screen.queryByText("Compatible private key")).not.toBeInTheDocument();
+ expect(screen.queryByText("File format version")).not.toBeInTheDocument();
+ });
+
+ it("aborts an in-flight decryption request on unmount", async () => {
+ const inspect = vi.fn().mockResolvedValue(privateKeyInspection());
+ const decrypt = vi.fn(
+ (_file: File, _privateKey: File, _password: string, _outputFilename: string, _signal?: AbortSignal) =>
+ new Promise<{ filename: string; blob: Blob }>(() => undefined)
+ );
+ const user = userEvent.setup();
+ const { unmount } = render(
+
+ );
+
+ await user.upload(screen.getByLabelText("Encrypted file"), new File(["ciphertext"], "report_encrypted.pqc"));
+ await user.upload(screen.getByLabelText("Private key"), new File(["PEM"], "recipient_private.pem"));
+ await screen.findByText("Supported encrypted private key; match not yet verified");
+ await user.type(screen.getByLabelText("Private key password"), "correct horse battery staple");
+ await user.click(screen.getByRole("button", { name: "Decrypt file" }));
+ await waitFor(() => expect(decrypt).toHaveBeenCalledTimes(1));
+ const signal = decrypt.mock.calls[0]?.[4] as AbortSignal;
+
+ unmount();
+
+ expect(signal.aborted).toBe(true);
+ });
+
+ it("preserves backend and invalid-key guidance while distinguishing network failure", async () => {
+ const inspect = vi.fn().mockResolvedValue(privateKeyInspection());
+ const user = userEvent.setup();
+ const renderFailure = async (failure: unknown) => {
+ const decrypt = vi.fn().mockRejectedValue(failure);
+ const rendered = render(
+
+ );
+ await user.upload(screen.getByLabelText("Encrypted file"), new File(["ciphertext"], "report_encrypted.pqc"));
+ await user.upload(screen.getByLabelText("Private key"), new File(["PEM"], "recipient_private.pem"));
+ await screen.findByText("Supported encrypted private key; match not yet verified");
+ await user.type(screen.getByLabelText("Private key password"), "correct horse battery staple");
+ await user.click(screen.getByRole("button", { name: "Decrypt file" }));
+ return rendered;
+ };
+
+ let rendered = await renderFailure(new ApiError(503, "backend_unavailable", "Post-quantum backend is not ready."));
+ expect(await screen.findByText("Post-quantum backend is not ready.")).toBeVisible();
+ rendered.unmount();
+
+ rendered = await renderFailure(
+ new ApiError(400, "invalid_private_key", "Upload a supported encrypted PQC private key PEM file.")
+ );
+ expect(await screen.findByText("Upload a supported encrypted PQC private key PEM file.")).toBeVisible();
+ rendered.unmount();
+
+ await renderFailure(new TypeError("fetch failed: private socket detail"));
+ expect(await screen.findByText("Could not reach the local service. Check that it is running and try again.")).toBeVisible();
+ expect(screen.queryByText(/private socket detail/i)).not.toBeInTheDocument();
+ });
+
+ it("does not render an AbortError as a decryption failure", async () => {
+ const inspect = vi.fn().mockResolvedValue(privateKeyInspection());
+ const aborted = new Error("request aborted");
+ aborted.name = "AbortError";
+ const decrypt = vi.fn().mockRejectedValue(aborted);
+ const user = userEvent.setup();
+
+ render( );
+ await user.upload(screen.getByLabelText("Encrypted file"), new File(["ciphertext"], "report_encrypted.pqc"));
+ await user.upload(screen.getByLabelText("Private key"), new File(["PEM"], "recipient_private.pem"));
+ await screen.findByText("Supported encrypted private key; match not yet verified");
+ await user.type(screen.getByLabelText("Private key password"), "correct horse battery staple");
+ await user.click(screen.getByRole("button", { name: "Decrypt file" }));
+
+ await waitFor(() => expect(screen.getByRole("button", { name: "Decrypt file" })).toBeEnabled());
+ expect(screen.queryByText("Decryption failed")).not.toBeInTheDocument();
+ });
+});
diff --git a/web/src/features/decrypt/DecryptWorkflow.tsx b/web/src/features/decrypt/DecryptWorkflow.tsx
new file mode 100644
index 0000000..49a4316
--- /dev/null
+++ b/web/src/features/decrypt/DecryptWorkflow.tsx
@@ -0,0 +1,298 @@
+import { useEffect, useMemo, useRef, useState, type FormEvent } from "react";
+import {
+ ApiError,
+ decryptFile,
+ type DecryptFileOperation,
+ type DownloadResult,
+ type Health,
+ type InspectKeyOperation
+} from "../../api";
+import { isAbortError, safeOperationError } from "../../api/errors";
+import { ActionButton } from "../../components/ActionButton";
+import { FilePicker } from "../../components/FilePicker";
+import { Notice } from "../../components/Notice";
+import { PasswordField } from "../../components/PasswordField";
+import { TechnicalDetails } from "../../components/TechnicalDetails";
+import { WorkflowLayout } from "../../components/WorkflowLayout";
+import { useKeyInspection } from "../../hooks/useKeyInspection";
+import { downloadBlob } from "../../lib/download";
+import { suggestedDecryptedName } from "../../lib/filenames";
+import { formatBytes } from "../../lib/format";
+import { deriveWorkflowPhase } from "../../lib/workflow";
+
+export type DecryptWorkflowProps = {
+ health: Health;
+ inspect?: InspectKeyOperation;
+ decrypt?: DecryptFileOperation;
+ save?: (result: DownloadResult) => void;
+};
+
+function limitMessage(label: string, maxBytes: number): string {
+ return `${label} exceeds the ${maxBytes.toLocaleString()} byte limit.`;
+}
+
+function keyFormat(inspection: ReturnType["result"]): string {
+ const version = inspection?.keyInfo.private_key_format_version;
+ return version === undefined ? "Not available" : String(version);
+}
+
+const AUTHENTICATION_ERROR_CODES = new Set(["decryption_failed", "private_key_failed"]);
+const AUTHENTICATION_FAILURE =
+ "The file could not be authenticated. Check the encrypted file, private key, and password.";
+
+function decryptionError(caught: unknown): string {
+ if (caught instanceof ApiError && AUTHENTICATION_ERROR_CODES.has(caught.code)) return AUTHENTICATION_FAILURE;
+ return safeOperationError(caught, "The local service could not complete decryption. Try again.");
+}
+
+export function DecryptWorkflow({
+ health,
+ inspect,
+ decrypt = decryptFile,
+ save = downloadBlob
+}: DecryptWorkflowProps) {
+ const [file, setFile] = useState(null);
+ const [privateKey, setPrivateKey] = useState(null);
+ const [password, setPassword] = useState("");
+ const [outputFilename, setOutputFilename] = useState("");
+ const [error, setError] = useState(null);
+ const [completedFilename, setCompletedFilename] = useState(null);
+ const [busy, setBusy] = useState(false);
+ const mountedRef = useRef(true);
+ const requestIdRef = useRef(0);
+ const inFlightRef = useRef(false);
+ const abortControllerRef = useRef(null);
+ const capability = health.capabilities.decrypt;
+ const { result: inspection, error: inspectionError, loading: inspecting } = useKeyInspection(
+ capability.available ? privateKey : null,
+ health.maxPemBytes,
+ inspect
+ );
+ const fileError = file && file.size > health.maxEncryptedFileBytes
+ ? limitMessage("This encrypted file", health.maxEncryptedFileBytes)
+ : null;
+ const keyError = privateKey && privateKey.size > health.maxPemBytes
+ ? limitMessage("This key file", health.maxPemBytes)
+ : null;
+ const safeInspectionError = inspectionError ? "The private key could not be inspected." : null;
+
+ const readinessReason = useMemo(() => {
+ if (!capability.available) return capability.reason;
+ if (!file) return "Choose an encrypted file.";
+ if (fileError) return fileError;
+ if (!privateKey) return "Choose a private key.";
+ if (keyError) return keyError;
+ if (inspecting) return "Inspecting private key.";
+ if (safeInspectionError) return safeInspectionError;
+ if (!inspection?.ok) return "The private key could not be inspected.";
+ if (inspection.keyInfo.key_type !== "private") return "A private key is required to decrypt a file.";
+ if (!password) return "Enter the private key password.";
+ if (!outputFilename.trim()) return "Enter an output filename.";
+ return null;
+ }, [
+ capability.available,
+ capability.reason,
+ file,
+ fileError,
+ inspection,
+ inspecting,
+ keyError,
+ outputFilename,
+ password,
+ privateKey,
+ safeInspectionError
+ ]);
+ const canDecrypt = !readinessReason && !busy;
+ const phase = deriveWorkflowPhase({
+ ready: Boolean(canDecrypt),
+ complete: Boolean(completedFilename)
+ });
+
+ useEffect(() => {
+ mountedRef.current = true;
+ return () => {
+ mountedRef.current = false;
+ requestIdRef.current += 1;
+ inFlightRef.current = false;
+ abortControllerRef.current?.abort();
+ abortControllerRef.current = null;
+ };
+ }, []);
+
+ function clearOutcome() {
+ requestIdRef.current += 1;
+ setError(null);
+ setCompletedFilename(null);
+ }
+
+ function selectFile(nextFile: File | null) {
+ if (busy) return;
+ setFile(nextFile);
+ setOutputFilename(suggestedDecryptedName(nextFile));
+ clearOutcome();
+ }
+
+ function selectPrivateKey(nextFile: File | null) {
+ if (busy) return;
+ setPrivateKey(nextFile);
+ clearOutcome();
+ }
+
+ function updateOutputFilename(value: string) {
+ if (busy) return;
+ setOutputFilename(value);
+ clearOutcome();
+ }
+
+ function updatePassword(value: string) {
+ if (busy) return;
+ setPassword(value);
+ clearOutcome();
+ }
+
+ async function submit(event: FormEvent) {
+ event.preventDefault();
+ if (!canDecrypt || !file || !privateKey || inFlightRef.current) return;
+
+ const requestId = requestIdRef.current + 1;
+ const controller = new AbortController();
+ requestIdRef.current = requestId;
+ inFlightRef.current = true;
+ abortControllerRef.current = controller;
+ setBusy(true);
+ setError(null);
+ setCompletedFilename(null);
+
+ try {
+ const result = await decrypt(file, privateKey, password, outputFilename.trim(), controller.signal);
+ if (!mountedRef.current || requestId !== requestIdRef.current) return;
+ setPassword("");
+ try {
+ save(result);
+ } catch {
+ setError("The file was decrypted, but the download could not start. Try decrypting it again.");
+ return;
+ }
+ setCompletedFilename(result.filename);
+ } catch (caught: unknown) {
+ if (!mountedRef.current || requestId !== requestIdRef.current) return;
+ if (controller.signal.aborted || isAbortError(caught)) return;
+ setError(decryptionError(caught));
+ } finally {
+ if (requestId === requestIdRef.current) inFlightRef.current = false;
+ if (abortControllerRef.current === controller) abortControllerRef.current = null;
+ if (mountedRef.current && requestId === requestIdRef.current) setBusy(false);
+ }
+ }
+
+ const keyClassification = !inspection
+ ? null
+ : inspection.keyInfo.key_type === "private"
+ ? "Supported encrypted private key; match not yet verified"
+ : "Public key — not valid for decryption";
+
+ return (
+
+ {capability.available && (
+
+ )}
+
+ {error && {error} }
+ {completedFilename && (
+
+ Saved {completedFilename}.
+
+ )}
+
+
+
+
+
Key format
+ {keyFormat(inspection)}
+
+
+
Key KDF
+ {inspection?.keyInfo.private_key_kdf ?? "Not available"}
+
+
+
Hybrid suite
+ {inspection?.keyInfo.kem ?? health.kem}
+
+
+
+
+ );
+}
diff --git a/web/src/features/encrypt/EncryptWorkflow.test.tsx b/web/src/features/encrypt/EncryptWorkflow.test.tsx
new file mode 100644
index 0000000..e59bc0a
--- /dev/null
+++ b/web/src/features/encrypt/EncryptWorkflow.test.tsx
@@ -0,0 +1,238 @@
+import { render, screen, waitFor } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { describe, expect, it, vi } from "vitest";
+import { ApiError } from "../../api";
+import { READY_HEALTH } from "../../test/fixtures";
+import { EncryptWorkflow } from "./EncryptWorkflow";
+
+function publicKeyInspection() {
+ return {
+ ok: true,
+ keyInfo: { kem: READY_HEALTH.kem, key_type: "public" as const },
+ display: { "Key Type": "Public", Algorithm: READY_HEALTH.kem }
+ };
+}
+
+async function prepareEncryption(user: ReturnType) {
+ await user.upload(screen.getByLabelText("File to encrypt"), new File(["report"], "report.pdf"));
+ await user.upload(screen.getByLabelText("Recipient public key"), new File(["PEM"], "recipient.pem"));
+ await screen.findByText("Compatible public key");
+}
+
+describe("EncryptWorkflow", () => {
+ it("encrypts with a compatible public key and saves the returned file", async () => {
+ const inspect = vi.fn().mockResolvedValue(publicKeyInspection());
+ const encrypt = vi.fn().mockResolvedValue({
+ filename: "report_encrypted.pqc",
+ blob: new Blob(["ciphertext"])
+ });
+ const save = vi.fn();
+ const user = userEvent.setup();
+
+ render( );
+
+ await user.upload(screen.getByLabelText("File to encrypt"), new File(["report"], "report.pdf"));
+ await user.upload(
+ screen.getByLabelText("Recipient public key"),
+ new File(["PEM"], "recipient.pem", { type: "application/x-pem-file" })
+ );
+
+ expect(await screen.findByText("Compatible public key")).toBeVisible();
+ expect(screen.getByLabelText("Output filename")).toHaveValue("report_encrypted.pqc");
+
+ await user.click(screen.getByRole("button", { name: "Encrypt file" }));
+
+ await waitFor(() =>
+ expect(encrypt).toHaveBeenCalledWith(
+ expect.objectContaining({ name: "report.pdf" }),
+ expect.objectContaining({ name: "recipient.pem" }),
+ "report_encrypted.pqc",
+ expect.any(AbortSignal)
+ )
+ );
+ expect(save).toHaveBeenCalledWith(expect.objectContaining({ filename: "report_encrypted.pqc" }));
+ expect(await screen.findByText(/saved report_encrypted\.pqc/i)).toBeVisible();
+ expect(screen.getByText("New file format version").nextElementSibling).toHaveTextContent("4");
+ });
+
+ it("reports a browser download failure separately from a service failure", async () => {
+ const inspect = vi.fn().mockResolvedValue(publicKeyInspection());
+ const encrypt = vi.fn().mockResolvedValue({
+ filename: "report_encrypted.pqc",
+ blob: new Blob(["ciphertext"])
+ });
+ const save = vi.fn(() => {
+ throw new TypeError("browser download failed");
+ });
+ const user = userEvent.setup();
+
+ render( );
+ await prepareEncryption(user);
+ await user.click(screen.getByRole("button", { name: "Encrypt file" }));
+
+ expect(
+ await screen.findByText("The file was encrypted, but the download could not start. Try encrypting it again.")
+ ).toBeVisible();
+ expect(screen.queryByText(/could not reach the local service/i)).not.toBeInTheDocument();
+ expect(screen.queryByText(/saved report_encrypted\.pqc/i)).not.toBeInTheDocument();
+ });
+
+ it("keeps encryption disabled with a textual reason when the plaintext is oversized", async () => {
+ const health = { ...READY_HEALTH, maxFileBytes: 3 };
+ const encrypt = vi.fn();
+ const user = userEvent.setup();
+
+ render( );
+ await user.upload(screen.getByLabelText("File to encrypt"), new File(["four"], "report.pdf"));
+
+ expect(screen.getByRole("button", { name: "Encrypt file" })).toBeDisabled();
+ expect(screen.getByText(/exceeds the 3 byte limit/i)).toBeVisible();
+ expect(encrypt).not.toHaveBeenCalled();
+ });
+
+ it("keeps encryption disabled when inspection finds a private key", async () => {
+ const inspect = vi.fn().mockResolvedValue({
+ ok: true,
+ keyInfo: {
+ kem: READY_HEALTH.kem,
+ key_type: "private"
+ },
+ display: {
+ "Key Type": "Encrypted private key",
+ Algorithm: READY_HEALTH.kem
+ }
+ });
+ const user = userEvent.setup();
+
+ render( );
+ await user.upload(screen.getByLabelText("File to encrypt"), new File(["report"], "report.pdf"));
+ await user.upload(screen.getByLabelText("Recipient public key"), new File(["PEM"], "recipient.pem"));
+
+ expect(await screen.findByText(/a public key is required/i)).toBeVisible();
+ expect(screen.getByRole("button", { name: "Encrypt file" })).toBeDisabled();
+ });
+
+ it("does not submit a second encryption request while one is in progress", async () => {
+ let resolveEncryption: (value: { filename: string; blob: Blob }) => void;
+ const inspect = vi.fn().mockResolvedValue(publicKeyInspection());
+ const encrypt = vi.fn(
+ (_file: File, _publicKey: File, _outputFilename: string, _signal?: AbortSignal) =>
+ new Promise<{ filename: string; blob: Blob }>((resolve) => {
+ resolveEncryption = resolve;
+ })
+ );
+ const user = userEvent.setup();
+
+ render( );
+ await user.upload(screen.getByLabelText("File to encrypt"), new File(["report"], "report.pdf"));
+ await user.upload(screen.getByLabelText("Recipient public key"), new File(["PEM"], "recipient.pem"));
+ await screen.findByText("Compatible public key");
+
+ await user.click(screen.getByRole("button", { name: "Encrypt file" }));
+ await user.click(screen.getByRole("button", { name: "Encrypting locally" }));
+
+ expect(encrypt).toHaveBeenCalledTimes(1);
+ expect(screen.getByLabelText("File to encrypt")).toBeDisabled();
+ expect(screen.getByLabelText("Recipient public key")).toBeDisabled();
+ expect(screen.getByLabelText("Output filename")).toBeDisabled();
+ expect(encrypt.mock.calls[0]?.[3]).toBeInstanceOf(AbortSignal);
+ resolveEncryption!({ filename: "report_encrypted.pqc", blob: new Blob(["ciphertext"]) });
+ expect(await screen.findByText(/saved report_encrypted\.pqc/i)).toBeVisible();
+ });
+
+ it("does not accept a public key from a different hybrid suite", async () => {
+ const inspect = vi.fn().mockResolvedValue({
+ ok: true,
+ keyInfo: { kem: "Kyber768", key_type: "public" },
+ display: { "Key Type": "Public", Algorithm: "Kyber768" }
+ });
+ const user = userEvent.setup();
+
+ render( );
+ await user.upload(screen.getByLabelText("File to encrypt"), new File(["report"], "report.pdf"));
+ await user.upload(screen.getByLabelText("Recipient public key"), new File(["PEM"], "recipient.pem"));
+
+ expect(await screen.findByText(/encryption requires ML-KEM-768\+X25519-v2/i)).toBeVisible();
+ expect(screen.getByRole("button", { name: "Encrypt file" })).toBeDisabled();
+ });
+
+ it("aborts an in-flight encryption request on unmount", async () => {
+ const inspect = vi.fn().mockResolvedValue(publicKeyInspection());
+ const encrypt = vi.fn(
+ (_file: File, _publicKey: File, _outputFilename: string, _signal?: AbortSignal) =>
+ new Promise<{ filename: string; blob: Blob }>(() => undefined)
+ );
+ const user = userEvent.setup();
+ const { unmount } = render(
+
+ );
+
+ await prepareEncryption(user);
+ await user.click(screen.getByRole("button", { name: "Encrypt file" }));
+ await waitFor(() => expect(encrypt).toHaveBeenCalledTimes(1));
+ const signal = encrypt.mock.calls[0]?.[3] as AbortSignal;
+
+ unmount();
+
+ expect(signal.aborted).toBe(true);
+ });
+
+ it("preserves backend guidance and distinguishes network failure", async () => {
+ const inspect = vi.fn().mockResolvedValue(publicKeyInspection());
+ const user = userEvent.setup();
+ const backendFailure = vi.fn().mockRejectedValue(
+ new ApiError(503, "backend_unavailable", "Post-quantum backend is not ready.")
+ );
+ const { unmount } = render(
+
+ );
+
+ await prepareEncryption(user);
+ await user.click(screen.getByRole("button", { name: "Encrypt file" }));
+ expect(await screen.findByText("Post-quantum backend is not ready.")).toBeVisible();
+
+ unmount();
+ const networkFailure = vi.fn().mockRejectedValue(new TypeError("fetch failed: private socket detail"));
+ render( );
+ await prepareEncryption(user);
+ await user.click(screen.getByRole("button", { name: "Encrypt file" }));
+
+ expect(await screen.findByText("Could not reach the local service. Check that it is running and try again.")).toBeVisible();
+ expect(screen.queryByText(/private socket detail/i)).not.toBeInTheDocument();
+ });
+
+ it("preserves safe recipient-key guidance from the local API", async () => {
+ const inspect = vi.fn().mockResolvedValue(publicKeyInspection());
+ const encrypt = vi.fn().mockRejectedValue(
+ new ApiError(
+ 400,
+ "legacy_public_key",
+ "Generate a new ML-KEM-768+X25519-v2 public key for encryption."
+ )
+ );
+ const user = userEvent.setup();
+
+ render( );
+ await prepareEncryption(user);
+ await user.click(screen.getByRole("button", { name: "Encrypt file" }));
+
+ expect(
+ await screen.findByText("Generate a new ML-KEM-768+X25519-v2 public key for encryption.")
+ ).toBeVisible();
+ });
+
+ it("does not render an AbortError as an encryption failure", async () => {
+ const inspect = vi.fn().mockResolvedValue(publicKeyInspection());
+ const aborted = new Error("request aborted");
+ aborted.name = "AbortError";
+ const encrypt = vi.fn().mockRejectedValue(aborted);
+ const user = userEvent.setup();
+
+ render( );
+ await prepareEncryption(user);
+ await user.click(screen.getByRole("button", { name: "Encrypt file" }));
+
+ await waitFor(() => expect(screen.getByRole("button", { name: "Encrypt file" })).toBeEnabled());
+ expect(screen.queryByText("Encryption failed")).not.toBeInTheDocument();
+ });
+});
diff --git a/web/src/features/encrypt/EncryptWorkflow.tsx b/web/src/features/encrypt/EncryptWorkflow.tsx
new file mode 100644
index 0000000..d24e06c
--- /dev/null
+++ b/web/src/features/encrypt/EncryptWorkflow.tsx
@@ -0,0 +1,268 @@
+import { useEffect, useMemo, useRef, useState, type FormEvent } from "react";
+import {
+ encryptFile,
+ type DownloadResult,
+ type EncryptFileOperation,
+ type Health,
+ type InspectKeyOperation
+} from "../../api";
+import { isAbortError, safeOperationError } from "../../api/errors";
+import { ActionButton } from "../../components/ActionButton";
+import { FilePicker } from "../../components/FilePicker";
+import { Notice } from "../../components/Notice";
+import { TechnicalDetails } from "../../components/TechnicalDetails";
+import { WorkflowLayout } from "../../components/WorkflowLayout";
+import { useKeyInspection } from "../../hooks/useKeyInspection";
+import { downloadBlob } from "../../lib/download";
+import { suggestedEncryptedName } from "../../lib/filenames";
+import { formatBytes } from "../../lib/format";
+import { deriveWorkflowPhase } from "../../lib/workflow";
+
+export type EncryptWorkflowProps = {
+ health: Health;
+ inspect?: InspectKeyOperation;
+ encrypt?: EncryptFileOperation;
+ save?: (result: DownloadResult) => void;
+};
+
+function limitMessage(label: string, maxBytes: number): string {
+ return `${label} exceeds the ${maxBytes.toLocaleString()} byte limit.`;
+}
+
+export function EncryptWorkflow({
+ health,
+ inspect,
+ encrypt = encryptFile,
+ save = downloadBlob
+}: EncryptWorkflowProps) {
+ const [file, setFile] = useState(null);
+ const [publicKey, setPublicKey] = useState(null);
+ const [outputFilename, setOutputFilename] = useState("");
+ const [error, setError] = useState(null);
+ const [completedFilename, setCompletedFilename] = useState(null);
+ const [busy, setBusy] = useState(false);
+ const mountedRef = useRef(true);
+ const requestIdRef = useRef(0);
+ const inFlightRef = useRef(false);
+ const abortControllerRef = useRef(null);
+ const capability = health.capabilities.encrypt;
+ const { result: inspection, error: inspectionError, loading: inspecting } = useKeyInspection(
+ capability.available ? publicKey : null,
+ health.maxPemBytes,
+ inspect
+ );
+ const fileError = file && file.size > health.maxFileBytes ? limitMessage("This file", health.maxFileBytes) : null;
+ const keyError = publicKey && publicKey.size > health.maxPemBytes ? limitMessage("This key file", health.maxPemBytes) : null;
+ const safeInspectionError = inspectionError ? "The recipient key could not be inspected." : null;
+ const compatiblePublicKey = Boolean(
+ inspection?.ok && inspection.keyInfo.key_type === "public" && inspection.keyInfo.kem === health.kem
+ );
+
+ const readinessReason = useMemo(() => {
+ if (!capability.available) return capability.reason;
+ if (!file) return "Choose a file to encrypt.";
+ if (fileError) return fileError;
+ if (!publicKey) return "Choose the recipient's public key.";
+ if (keyError) return keyError;
+ if (inspecting) return "Inspecting recipient key.";
+ if (safeInspectionError) return safeInspectionError;
+ if (!inspection?.ok) return "The recipient key could not be inspected.";
+ if (inspection.keyInfo.key_type !== "public") return "A public key is required to encrypt a file.";
+ if (inspection.keyInfo.kem !== health.kem) {
+ return `This public key uses ${inspection.keyInfo.kem}; encryption requires ${health.kem}.`;
+ }
+ if (!outputFilename.trim()) return "Enter an output filename.";
+ return null;
+ }, [
+ capability.available,
+ capability.reason,
+ file,
+ fileError,
+ health.kem,
+ inspection,
+ inspecting,
+ keyError,
+ outputFilename,
+ publicKey,
+ safeInspectionError
+ ]);
+ const canEncrypt = !readinessReason && !busy;
+ const phase = deriveWorkflowPhase({
+ ready: Boolean(canEncrypt),
+ complete: Boolean(completedFilename)
+ });
+
+ useEffect(() => {
+ mountedRef.current = true;
+ return () => {
+ mountedRef.current = false;
+ requestIdRef.current += 1;
+ inFlightRef.current = false;
+ abortControllerRef.current?.abort();
+ abortControllerRef.current = null;
+ };
+ }, []);
+
+ function clearOutcome() {
+ requestIdRef.current += 1;
+ setError(null);
+ setCompletedFilename(null);
+ }
+
+ function selectFile(nextFile: File | null) {
+ if (busy) return;
+ setFile(nextFile);
+ setOutputFilename(suggestedEncryptedName(nextFile));
+ clearOutcome();
+ }
+
+ function selectPublicKey(nextFile: File | null) {
+ if (busy) return;
+ setPublicKey(nextFile);
+ clearOutcome();
+ }
+
+ function updateOutputFilename(value: string) {
+ if (busy) return;
+ setOutputFilename(value);
+ clearOutcome();
+ }
+
+ async function submit(event: FormEvent) {
+ event.preventDefault();
+ if (!canEncrypt || !file || !publicKey || inFlightRef.current) return;
+
+ const requestId = requestIdRef.current + 1;
+ const controller = new AbortController();
+ requestIdRef.current = requestId;
+ inFlightRef.current = true;
+ abortControllerRef.current = controller;
+ setBusy(true);
+ setError(null);
+ setCompletedFilename(null);
+
+ try {
+ const result = await encrypt(file, publicKey, outputFilename.trim(), controller.signal);
+ if (!mountedRef.current || requestId !== requestIdRef.current) return;
+ try {
+ save(result);
+ } catch {
+ setError("The file was encrypted, but the download could not start. Try encrypting it again.");
+ return;
+ }
+ setCompletedFilename(result.filename);
+ } catch (caught: unknown) {
+ if (!mountedRef.current || requestId !== requestIdRef.current) return;
+ if (controller.signal.aborted || isAbortError(caught)) return;
+ setError(safeOperationError(caught, "Could not encrypt this file. Confirm the recipient key and try again."));
+ } finally {
+ if (requestId === requestIdRef.current) inFlightRef.current = false;
+ if (abortControllerRef.current === controller) abortControllerRef.current = null;
+ if (mountedRef.current && requestId === requestIdRef.current) setBusy(false);
+ }
+ }
+
+ const keyClassification = !inspection
+ ? null
+ : compatiblePublicKey
+ ? "Compatible public key"
+ : inspection.keyInfo.key_type === "private"
+ ? "Private key — not valid for encryption"
+ : "Public key — not compatible";
+
+ return (
+
+ {capability.available && (
+
+ )}
+
+ {error && {error} }
+ {completedFilename && (
+
+ Saved {completedFilename}.
+
+ )}
+
+
+
+
+
Hybrid suite
+ {health.kem}
+
+
+
DEM
+ {health.dem}
+
+
+
New file format version
+ {health.formatVersion}
+
+
+
+
+ );
+}
diff --git a/web/src/features/generate/GenerateKeysWorkflow.test.tsx b/web/src/features/generate/GenerateKeysWorkflow.test.tsx
new file mode 100644
index 0000000..d357c4a
--- /dev/null
+++ b/web/src/features/generate/GenerateKeysWorkflow.test.tsx
@@ -0,0 +1,257 @@
+import { render, screen, waitFor } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { describe, expect, it, vi } from "vitest";
+import { ApiError, type GeneratedKeys } from "../../api";
+import { READY_HEALTH } from "../../test/fixtures";
+import { GenerateKeysWorkflow } from "./GenerateKeysWorkflow";
+
+function generatedKeys(overrides: Partial = {}): GeneratedKeys {
+ return {
+ ok: true,
+ kem: "ML-KEM-768+X25519-v2",
+ publicPem: "PUBLIC-PEM",
+ privatePem: "-----BEGIN PQC PRIVATE KEY-----\nPQC-Key-Format: 3\nENCRYPTED-PRIVATE-PEM\n-----END PQC PRIVATE KEY-----",
+ publicFilename: "recipient-public.pem",
+ privateFilename: "recipient-private.pem",
+ ...overrides
+ };
+}
+
+type Deferred = {
+ promise: Promise;
+ resolve: (value: T) => void;
+};
+
+function deferred(): Deferred {
+ let resolve!: (value: T) => void;
+ const promise = new Promise((resolvePromise) => {
+ resolve = resolvePromise;
+ });
+ return { promise, resolve };
+}
+
+async function enterValidPasswords(user: ReturnType) {
+ await user.type(screen.getByLabelText("Private key password"), "correct horse battery staple");
+ await user.type(screen.getByLabelText("Confirm private key password"), "correct horse battery staple");
+}
+
+describe("GenerateKeysWorkflow", () => {
+ it("keeps generation disabled for a server-blocked common password", async () => {
+ const user = userEvent.setup();
+
+ render( );
+ await user.type(screen.getByLabelText("Private key password"), "password12345678");
+ await user.type(screen.getByLabelText("Confirm private key password"), "password12345678");
+
+ expect(screen.getByText("Not a common password").closest("li")).not.toHaveClass("password-policy-met");
+ expect(screen.getByRole("button", { name: "Generate key pair" })).toBeDisabled();
+ });
+
+ it("keeps generated key material in memory until the user explicitly clears it", async () => {
+ const generate = vi.fn().mockResolvedValue(generatedKeys());
+ const onSensitiveResultChange = vi.fn();
+ const user = userEvent.setup();
+
+ render(
+
+ );
+
+ const generateButton = screen.getByRole("button", { name: "Generate key pair" });
+ expect(generateButton).toBeDisabled();
+
+ await enterValidPasswords(user);
+ expect(generateButton).toBeEnabled();
+
+ await user.click(generateButton);
+
+ await waitFor(() =>
+ expect(generate).toHaveBeenCalledWith("correct horse battery staple", expect.any(AbortSignal))
+ );
+ expect(screen.queryByLabelText("Private key password")).not.toBeInTheDocument();
+ expect(screen.queryByLabelText("Confirm private key password")).not.toBeInTheDocument();
+ expect(screen.getByRole("button", { name: "Download public key" })).toBeVisible();
+ expect(screen.getByRole("button", { name: "Download encrypted private key" })).toBeVisible();
+ expect(screen.queryByText("PUBLIC-PEM")).not.toBeInTheDocument();
+ expect(screen.queryByText("ENCRYPTED-PRIVATE-PEM")).not.toBeInTheDocument();
+ expect(onSensitiveResultChange).toHaveBeenCalledWith(true);
+ expect(screen.getByText("Private key format version").nextElementSibling).toHaveTextContent("3");
+
+ await user.click(screen.getByRole("button", { name: "Clear generated keys" }));
+
+ expect(screen.queryByRole("button", { name: "Download public key" })).not.toBeInTheDocument();
+ expect(screen.queryByRole("button", { name: "Download encrypted private key" })).not.toBeInTheDocument();
+ expect(onSensitiveResultChange).toHaveBeenLastCalledWith(false);
+ });
+
+ it("requires clearing generated keys before another key pair can be requested", async () => {
+ const secondRequest = new Promise(() => undefined);
+ const generate = vi.fn()
+ .mockResolvedValueOnce(generatedKeys())
+ .mockReturnValueOnce(secondRequest);
+ const onSensitiveResultChange = vi.fn();
+ const user = userEvent.setup();
+
+ render(
+
+ );
+
+ await enterValidPasswords(user);
+ await user.click(screen.getByRole("button", { name: "Generate key pair" }));
+ await waitFor(() => expect(generate).toHaveBeenCalledTimes(1));
+ expect(screen.getByRole("button", { name: "Download public key" })).toBeVisible();
+ expect(onSensitiveResultChange).toHaveBeenLastCalledWith(true);
+
+ expect(screen.queryByLabelText("Private key password")).not.toBeInTheDocument();
+ expect(screen.queryByRole("button", { name: "Generate key pair" })).not.toBeInTheDocument();
+ expect(generate).toHaveBeenCalledTimes(1);
+ expect(screen.getByRole("button", { name: "Download encrypted private key" })).toBeVisible();
+ expect(onSensitiveResultChange).toHaveBeenLastCalledWith(true);
+
+ await user.click(screen.getByRole("button", { name: "Clear generated keys" }));
+
+ expect(screen.getByLabelText("Private key password")).toBeVisible();
+ expect(screen.getByRole("button", { name: "Generate key pair" })).toBeDisabled();
+ expect(onSensitiveResultChange).toHaveBeenLastCalledWith(false);
+ });
+
+ it("notifies the shell to clear sensitive result state when unmounted", () => {
+ const onSensitiveResultChange = vi.fn();
+ const { unmount } = render(
+
+ );
+
+ unmount();
+
+ expect(onSensitiveResultChange).toHaveBeenCalledWith(false);
+ });
+
+ it("does not reassert sensitive state when an in-flight request resolves after unmount", async () => {
+ let resolveGeneration: (value: ReturnType) => void;
+ const generate = vi.fn(
+ (_password: string, _signal?: AbortSignal) =>
+ new Promise>((resolve) => {
+ resolveGeneration = resolve;
+ })
+ );
+ const onSensitiveResultChange = vi.fn();
+ const user = userEvent.setup();
+ const { unmount } = render(
+
+ );
+
+ await enterValidPasswords(user);
+ await user.click(screen.getByRole("button", { name: "Generate key pair" }));
+ await waitFor(() => expect(generate).toHaveBeenCalledTimes(1));
+ const signal = generate.mock.calls[0]?.[1] as AbortSignal;
+
+ unmount();
+ expect(signal.aborted).toBe(true);
+ resolveGeneration!(generatedKeys());
+ await Promise.resolve();
+
+ expect(onSensitiveResultChange).not.toHaveBeenCalledWith(true);
+ expect(onSensitiveResultChange).toHaveBeenLastCalledWith(false);
+ });
+
+ it("locks password controls while generation is in flight", async () => {
+ const request = deferred();
+ const generate = vi.fn().mockReturnValue(request.promise);
+ const user = userEvent.setup();
+
+ render( );
+ await enterValidPasswords(user);
+ await user.click(screen.getByRole("button", { name: "Generate key pair" }));
+ await waitFor(() => expect(generate).toHaveBeenCalledTimes(1));
+
+ expect(screen.getByLabelText("Private key password")).toBeDisabled();
+ expect(screen.getByLabelText("Confirm private key password")).toBeDisabled();
+ for (const button of screen.getAllByRole("button", { name: "Show password" })) {
+ expect(button).toBeDisabled();
+ }
+ expect(screen.getByRole("button", { name: "Generating key pair" })).toBeDisabled();
+
+ request.resolve(generatedKeys());
+ expect(await screen.findByRole("button", { name: "Download public key" })).toBeVisible();
+ });
+
+ it("preserves safe backend error categories and distinguishes network failure", async () => {
+ const backendFailure = vi.fn().mockRejectedValue(
+ new ApiError(503, "backend_unavailable", "Post-quantum backend is not ready.")
+ );
+ const user = userEvent.setup();
+ const { unmount } = render( );
+
+ await enterValidPasswords(user);
+ await user.click(screen.getByRole("button", { name: "Generate key pair" }));
+ expect(await screen.findByText("Post-quantum backend is not ready.")).toBeVisible();
+
+ unmount();
+ const networkFailure = vi.fn().mockRejectedValue(new TypeError("fetch failed: private socket detail"));
+ render( );
+ await enterValidPasswords(user);
+ await user.click(screen.getByRole("button", { name: "Generate key pair" }));
+
+ expect(await screen.findByText("Could not reach the local service. Check that it is running and try again.")).toBeVisible();
+ expect(screen.queryByText(/private socket detail/i)).not.toBeInTheDocument();
+ });
+
+ it("preserves safe server password-policy guidance", async () => {
+ const generate = vi.fn().mockRejectedValue(
+ new ApiError(400, "weak_password", "Private-key password is too common.")
+ );
+ const user = userEvent.setup();
+
+ render( );
+ await enterValidPasswords(user);
+ await user.click(screen.getByRole("button", { name: "Generate key pair" }));
+
+ expect(await screen.findByText("Private-key password is too common.")).toBeVisible();
+ });
+
+ it("does not render an AbortError as a key-generation failure", async () => {
+ const aborted = new Error("request aborted");
+ aborted.name = "AbortError";
+ const generate = vi.fn().mockRejectedValue(aborted);
+ const user = userEvent.setup();
+
+ render( );
+ await enterValidPasswords(user);
+ await user.click(screen.getByRole("button", { name: "Generate key pair" }));
+
+ await waitFor(() => expect(screen.getByRole("button", { name: "Generate key pair" })).toBeEnabled());
+ expect(screen.queryByText("Key generation failed")).not.toBeInTheDocument();
+ });
+
+ it("does not announce or expose an unsuccessful generation response", async () => {
+ const generate = vi.fn().mockResolvedValue(generatedKeys({ ok: false }));
+ const onSensitiveResultChange = vi.fn();
+ const user = userEvent.setup();
+
+ render(
+
+ );
+
+ await enterValidPasswords(user);
+ await user.click(screen.getByRole("button", { name: "Generate key pair" }));
+
+ expect(await screen.findByText("Could not generate keys. Check the password requirements and try again.")).toBeVisible();
+ expect(screen.queryByRole("button", { name: "Download public key" })).not.toBeInTheDocument();
+ expect(onSensitiveResultChange).not.toHaveBeenCalledWith(true);
+ });
+});
diff --git a/web/src/features/generate/GenerateKeysWorkflow.tsx b/web/src/features/generate/GenerateKeysWorkflow.tsx
new file mode 100644
index 0000000..af68d4e
--- /dev/null
+++ b/web/src/features/generate/GenerateKeysWorkflow.tsx
@@ -0,0 +1,223 @@
+import { useEffect, useMemo, useRef, useState, type FormEvent } from "react";
+import { generateKeys, type GenerateKeysOperation, type GeneratedKeys, type Health } from "../../api";
+import { isAbortError, safeOperationError } from "../../api/errors";
+import { ActionButton } from "../../components/ActionButton";
+import { Notice } from "../../components/Notice";
+import { PasswordField } from "../../components/PasswordField";
+import { TechnicalDetails } from "../../components/TechnicalDetails";
+import { WorkflowLayout } from "../../components/WorkflowLayout";
+import { downloadText } from "../../lib/download";
+import { deriveWorkflowPhase } from "../../lib/workflow";
+import { passwordPolicyChecks } from "./passwordPolicy";
+
+export type GenerateKeysWorkflowProps = {
+ health: Health;
+ generate?: GenerateKeysOperation;
+ onSensitiveResultChange?: (present: boolean) => void;
+};
+
+type GeneratedKeyState = Omit & {
+ publicPem: string | null;
+ privatePem: string | null;
+};
+
+function privateKeyFormatVersion(privatePem: string | null | undefined): string | null {
+ if (!privatePem) return null;
+ return privatePem.match(/^PQC-Key-Format:\s*(\d+)\s*$/m)?.[1] ?? "Not declared";
+}
+
+export function GenerateKeysWorkflow({ health, generate = generateKeys, onSensitiveResultChange }: GenerateKeysWorkflowProps) {
+ const [password, setPassword] = useState("");
+ const [confirmation, setConfirmation] = useState("");
+ const [generatedKeys, setGeneratedKeys] = useState(null);
+ const [error, setError] = useState(null);
+ const [busy, setBusy] = useState(false);
+ const mountedRef = useRef(true);
+ const requestIdRef = useRef(0);
+ const inFlightRef = useRef(false);
+ const abortControllerRef = useRef(null);
+ const sensitiveResultChangeRef = useRef(onSensitiveResultChange);
+ const capability = health.capabilities.generate;
+ const checks = useMemo(
+ () => passwordPolicyChecks(password, confirmation, health.passwordPolicy),
+ [confirmation, health.passwordPolicy, password]
+ );
+ const policySatisfied = checks.every((check) => check.met);
+ const hasGeneratedKeys = Boolean(generatedKeys?.publicPem && generatedKeys.privatePem);
+ const canGenerate = capability.available && policySatisfied && !busy && !hasGeneratedKeys;
+ const phase = deriveWorkflowPhase({
+ ready: capability.available && policySatisfied,
+ complete: hasGeneratedKeys
+ });
+
+ useEffect(() => {
+ sensitiveResultChangeRef.current = onSensitiveResultChange;
+ }, [onSensitiveResultChange]);
+
+ useEffect(() => {
+ mountedRef.current = true;
+ return () => {
+ mountedRef.current = false;
+ requestIdRef.current += 1;
+ inFlightRef.current = false;
+ abortControllerRef.current?.abort();
+ abortControllerRef.current = null;
+ sensitiveResultChangeRef.current?.(false);
+ };
+ }, []);
+
+ async function submit(event: FormEvent) {
+ event.preventDefault();
+ if (!canGenerate || hasGeneratedKeys || inFlightRef.current) return;
+
+ const requestId = requestIdRef.current + 1;
+ const controller = new AbortController();
+ requestIdRef.current = requestId;
+ inFlightRef.current = true;
+ abortControllerRef.current = controller;
+ setBusy(true);
+ setError(null);
+
+ try {
+ const result = await generate(password, controller.signal);
+ if (!mountedRef.current || requestId !== requestIdRef.current) return;
+ if (!result.ok) {
+ setError("Could not generate keys. Check the password requirements and try again.");
+ return;
+ }
+ setGeneratedKeys(result);
+ setPassword("");
+ setConfirmation("");
+ sensitiveResultChangeRef.current?.(true);
+ } catch (caught: unknown) {
+ if (!mountedRef.current || requestId !== requestIdRef.current) return;
+ if (controller.signal.aborted || isAbortError(caught)) return;
+ setError(safeOperationError(caught, "Could not generate keys. Check the password requirements and try again."));
+ } finally {
+ if (requestId === requestIdRef.current) inFlightRef.current = false;
+ if (abortControllerRef.current === controller) abortControllerRef.current = null;
+ if (mountedRef.current && requestId === requestIdRef.current) {
+ setBusy(false);
+ }
+ }
+ }
+
+ function clearGeneratedKeys() {
+ setGeneratedKeys((current) =>
+ current
+ ? {
+ ...current,
+ publicPem: null,
+ privatePem: null
+ }
+ : null
+ );
+ sensitiveResultChangeRef.current?.(false);
+ }
+
+ return (
+
+
+ Your private key password cannot be recovered. Save it in a secure password manager before generating keys.
+
+
+ {capability.available && !hasGeneratedKeys && (
+
+ )}
+
+ {error && {error} }
+
+ {hasGeneratedKeys && generatedKeys && (
+
+
+ Save both files now. The public key is safe to share; keep the encrypted private key and password secure.
+
+
+ downloadText(generatedKeys.publicFilename, generatedKeys.publicPem!)}
+ type="button"
+ >
+ Public key
+ {generatedKeys.publicFilename}
+
+ downloadText(generatedKeys.privateFilename, generatedKeys.privatePem!)}
+ type="button"
+ >
+ Encrypted private key
+ {generatedKeys.privateFilename}
+
+
+
+ Clear generated keys
+
+
+ )}
+
+
+
+
+
Hybrid suite
+ {health.kem}
+
+ {generatedKeys?.privatePem && (
+
+
Private key format version
+ {privateKeyFormatVersion(generatedKeys.privatePem)}
+
+ )}
+
+
Password KDF
+ scrypt
+
+
+
Password requirements
+ {health.passwordPolicy.minChars} or more characters and {health.passwordPolicy.minUniqueChars} or more unique characters
+
+
+
+
+ );
+}
diff --git a/web/src/features/generate/passwordPolicy.test.ts b/web/src/features/generate/passwordPolicy.test.ts
new file mode 100644
index 0000000..5ab7f72
--- /dev/null
+++ b/web/src/features/generate/passwordPolicy.test.ts
@@ -0,0 +1,35 @@
+import { describe, expect, it } from "vitest";
+import { passwordPolicyChecks } from "./passwordPolicy";
+
+describe("passwordPolicyChecks", () => {
+ it("reports the server-owned policy requirements and confirmation state", () => {
+ expect(
+ passwordPolicyChecks(
+ "correct horse battery staple",
+ "correct horse battery staple",
+ { minChars: 16, minUniqueChars: 5 }
+ )
+ ).toEqual([
+ { label: "16 or more characters", met: true },
+ { label: "5 or more unique characters", met: true },
+ { label: "Not a common password", met: true },
+ { label: "Passwords match", met: true }
+ ]);
+ });
+
+ it.each([
+ "passwordpassword",
+ "password12345678",
+ "qwertyuiopasdfgh",
+ " PassWord 12345678 ",
+ "Pass\u0085word12345678",
+ "Pass\u001cword12345678"
+ ])("matches the server normalization and rejects common password %j", (password) => {
+ const checks = passwordPolicyChecks(password, password, { minChars: 16, minUniqueChars: 5 });
+
+ expect(checks.find((check) => check.label === "Not a common password")).toEqual({
+ label: "Not a common password",
+ met: false
+ });
+ });
+});
diff --git a/web/src/features/generate/passwordPolicy.ts b/web/src/features/generate/passwordPolicy.ts
new file mode 100644
index 0000000..c2b8eea
--- /dev/null
+++ b/web/src/features/generate/passwordPolicy.ts
@@ -0,0 +1,44 @@
+import type { Health } from "../../api";
+
+export type PasswordPolicyCheck = {
+ label: string;
+ met: boolean;
+};
+
+// Keep this usability check synchronized with crypto_core.COMMON_WEAK_PASSWORDS; the server remains authoritative.
+const COMMON_WEAK_PASSWORDS = new Set([
+ "passwordpassword",
+ "password12345678",
+ "qwertyuiopasdfgh"
+]);
+const PYTHON_SPLIT_WHITESPACE = /[\u0009-\u000d\u001c-\u0020\u0085\u00a0\u1680\u2000-\u200a\u2028\u2029\u202f\u205f\u3000]/gu;
+
+function isCommonPassword(password: string): boolean {
+ const normalized = password.toLowerCase().replace(PYTHON_SPLIT_WHITESPACE, "");
+ return COMMON_WEAK_PASSWORDS.has(normalized);
+}
+
+export function passwordPolicyChecks(
+ password: string,
+ confirmation: string,
+ policy: Health["passwordPolicy"]
+): PasswordPolicyCheck[] {
+ return [
+ {
+ label: `${policy.minChars} or more characters`,
+ met: password.length >= policy.minChars
+ },
+ {
+ label: `${policy.minUniqueChars} or more unique characters`,
+ met: new Set(password).size >= policy.minUniqueChars
+ },
+ {
+ label: "Not a common password",
+ met: password.length > 0 && !isCommonPassword(password)
+ },
+ {
+ label: "Passwords match",
+ met: password.length > 0 && password === confirmation
+ }
+ ];
+}
diff --git a/web/src/features/inspect/InspectKeyWorkflow.test.tsx b/web/src/features/inspect/InspectKeyWorkflow.test.tsx
new file mode 100644
index 0000000..9281368
--- /dev/null
+++ b/web/src/features/inspect/InspectKeyWorkflow.test.tsx
@@ -0,0 +1,33 @@
+import { render, screen } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { describe, expect, it, vi } from "vitest";
+import { READY_HEALTH } from "../../test/fixtures";
+import { InspectKeyWorkflow } from "./InspectKeyWorkflow";
+
+describe("InspectKeyWorkflow", () => {
+ it("shows a safe key summary without rendering uploaded key material", async () => {
+ const inspect = vi.fn().mockResolvedValue({
+ ok: true,
+ keyInfo: {
+ kem: "ML-KEM-768+X25519-v2",
+ key_type: "public"
+ },
+ display: {
+ "Key Type": "Public",
+ Algorithm: "ML-KEM-768+X25519-v2"
+ }
+ });
+
+ const user = userEvent.setup();
+ render( );
+ await user.upload(
+ screen.getByLabelText("Key file"),
+ new File(["PEM"], "recipient.pem", { type: "application/x-pem-file" })
+ );
+
+ expect(await screen.findByText("Public key")).toBeVisible();
+ await user.click(screen.getByText("Technical details"));
+ expect(screen.getByText("ML-KEM-768+X25519-v2")).toBeVisible();
+ expect(screen.queryByText("PEM")).not.toBeInTheDocument();
+ });
+});
diff --git a/web/src/features/inspect/InspectKeyWorkflow.tsx b/web/src/features/inspect/InspectKeyWorkflow.tsx
new file mode 100644
index 0000000..d6c2b91
--- /dev/null
+++ b/web/src/features/inspect/InspectKeyWorkflow.tsx
@@ -0,0 +1,68 @@
+import { useState } from "react";
+import type { Health, InspectKeyOperation, KeyInspectResult } from "../../api";
+import { FilePicker } from "../../components/FilePicker";
+import { Notice } from "../../components/Notice";
+import { TechnicalDetails } from "../../components/TechnicalDetails";
+import { WorkflowLayout } from "../../components/WorkflowLayout";
+import { useKeyInspection } from "../../hooks/useKeyInspection";
+import { deriveWorkflowPhase } from "../../lib/workflow";
+
+export type InspectKeyWorkflowProps = {
+ health: Health;
+ inspect?: InspectKeyOperation;
+};
+
+function summaryLabel(result: KeyInspectResult): string {
+ return result.keyInfo.key_type === "public" ? "Public key" : "Encrypted private key";
+}
+
+export function InspectKeyWorkflow({ health, inspect }: InspectKeyWorkflowProps) {
+ const [file, setFile] = useState(null);
+ const capability = health.capabilities.inspect;
+ const { result, error, loading } = useKeyInspection(
+ capability.available ? file : null,
+ health.maxPemBytes,
+ inspect
+ );
+ const phase = deriveWorkflowPhase({ ready: capability.available && Boolean(file) && !error, complete: Boolean(result) });
+
+ return (
+
+ {capability.available && (
+
+ )}
+ {loading && Reading supported key metadata locally. }
+ {result && (
+
+
+ Supported key metadata was read locally.
+
+
+
+ {Object.entries(result.display).map(([label, value]) => (
+
+
{label}
+ {value}
+
+ ))}
+
+
+
+ )}
+
+ );
+}
diff --git a/web/src/hooks/useKeyInspection.test.tsx b/web/src/hooks/useKeyInspection.test.tsx
new file mode 100644
index 0000000..44db334
--- /dev/null
+++ b/web/src/hooks/useKeyInspection.test.tsx
@@ -0,0 +1,131 @@
+import { act, render, screen } from "@testing-library/react";
+import { describe, expect, it, vi } from "vitest";
+import type { InspectKeyOperation, KeyInspectResult } from "../api";
+import { useKeyInspection } from "./useKeyInspection";
+
+type Deferred = {
+ promise: Promise;
+ resolve: (value: T) => void;
+ reject: (reason?: unknown) => void;
+};
+
+function deferred(): Deferred {
+ let resolve!: (value: T) => void;
+ let reject!: (reason?: unknown) => void;
+ const promise = new Promise((resolvePromise, rejectPromise) => {
+ resolve = resolvePromise;
+ reject = rejectPromise;
+ });
+ return { promise, resolve, reject };
+}
+
+function result(kem: string): KeyInspectResult {
+ return {
+ ok: true,
+ keyInfo: { kem, key_type: "public" },
+ display: { Algorithm: kem }
+ };
+}
+
+function InspectionProbe({ file, inspect }: { file: File | null; inspect: InspectKeyOperation }) {
+ const state = useKeyInspection(file, 1024, inspect);
+ return (
+
+ {state.result?.keyInfo.kem ?? "no result"}|{state.error ?? "no error"}|{state.loading ? "loading" : "idle"}
+
+ );
+}
+
+describe("useKeyInspection", () => {
+ it("aborts the previous request when the selected file changes", () => {
+ const firstFile = new File(["first"], "first.pem", { type: "application/x-pem-file" });
+ const secondFile = new File(["second"], "second.pem", { type: "application/x-pem-file" });
+ let firstSignal: AbortSignal | undefined;
+ const inspect = vi.fn((file: File, signal?: AbortSignal) => {
+ if (file === firstFile) firstSignal = signal;
+ return new Promise(() => undefined);
+ });
+
+ const { rerender } = render( );
+ expect(screen.getByTestId("inspection-state")).toHaveTextContent("loading");
+
+ rerender( );
+
+ expect(firstSignal?.aborted).toBe(true);
+ expect(inspect).toHaveBeenCalledWith(secondFile, expect.any(AbortSignal));
+ });
+
+ it("does not render a completed result for a newly selected file", async () => {
+ const firstFile = new File(["first"], "first.pem", { type: "application/x-pem-file" });
+ const secondFile = new File(["second"], "second.pem", { type: "application/x-pem-file" });
+ const firstRequest = deferred();
+ const secondRequest = deferred();
+ const inspect = vi.fn((file: File) => (file === firstFile ? firstRequest.promise : secondRequest.promise));
+ const { rerender } = render( );
+
+ await act(async () => {
+ firstRequest.resolve(result("first-result"));
+ await firstRequest.promise;
+ });
+ expect(screen.getByTestId("inspection-state")).toHaveTextContent("first-result");
+
+ rerender( );
+
+ expect(screen.getByTestId("inspection-state")).not.toHaveTextContent("first-result");
+ expect(screen.getByTestId("inspection-state")).toHaveTextContent("loading");
+ });
+
+ it("ignores a previous request resolving after selection changes", async () => {
+ const firstFile = new File(["first"], "first.pem", { type: "application/x-pem-file" });
+ const secondFile = new File(["second"], "second.pem", { type: "application/x-pem-file" });
+ const firstRequest = deferred();
+ const secondRequest = deferred();
+ const inspect = vi.fn((file: File) => (file === firstFile ? firstRequest.promise : secondRequest.promise));
+ const { rerender } = render( );
+
+ rerender( );
+ await act(async () => {
+ firstRequest.resolve(result("first-result"));
+ await firstRequest.promise;
+ });
+
+ expect(screen.getByTestId("inspection-state")).not.toHaveTextContent("first-result");
+ await act(async () => {
+ secondRequest.resolve(result("second-result"));
+ await secondRequest.promise;
+ });
+ expect(screen.getByTestId("inspection-state")).toHaveTextContent("second-result");
+ });
+
+ it("ignores a previous request rejecting after selection changes", async () => {
+ const firstFile = new File(["first"], "first.pem", { type: "application/x-pem-file" });
+ const secondFile = new File(["second"], "second.pem", { type: "application/x-pem-file" });
+ const firstRequest = deferred();
+ const secondRequest = deferred();
+ const inspect = vi.fn((file: File) => (file === firstFile ? firstRequest.promise : secondRequest.promise));
+ const { rerender } = render( );
+
+ rerender( );
+ await act(async () => {
+ firstRequest.reject(new Error("first error"));
+ await Promise.resolve();
+ });
+
+ expect(screen.getByTestId("inspection-state")).not.toHaveTextContent("first error");
+ await act(async () => {
+ secondRequest.resolve(result("second-result"));
+ await secondRequest.promise;
+ });
+ expect(screen.getByTestId("inspection-state")).toHaveTextContent("second-result");
+ });
+
+ it("rejects an oversized key locally without calling the API", async () => {
+ const inspect = vi.fn();
+ const oversizedFile = new File([new Uint8Array(1025)], "oversized.pem", { type: "application/x-pem-file" });
+
+ render( );
+
+ expect(await screen.findByText(/exceeds the 1,024 byte limit/)).toBeVisible();
+ expect(inspect).not.toHaveBeenCalled();
+ });
+});
diff --git a/web/src/hooks/useKeyInspection.ts b/web/src/hooks/useKeyInspection.ts
new file mode 100644
index 0000000..832b4c6
--- /dev/null
+++ b/web/src/hooks/useKeyInspection.ts
@@ -0,0 +1,80 @@
+import { useEffect, useState } from "react";
+import { inspectKey, type InspectKeyOperation, type KeyInspectResult } from "../api";
+
+type KeyInspectionState = {
+ sourceFile: File | null;
+ result: KeyInspectResult | null;
+ error: string | null;
+ loading: boolean;
+};
+
+type KeyInspectionValue = Omit;
+
+const INITIAL_STATE: KeyInspectionState = {
+ sourceFile: null,
+ result: null,
+ error: null,
+ loading: false
+};
+
+function errorMessage(error: unknown): string {
+ return error instanceof Error && error.message ? error.message : "Unable to inspect this key file.";
+}
+
+export function useKeyInspection(
+ file: File | null,
+ maxBytes: number,
+ inspect: InspectKeyOperation = inspectKey
+): KeyInspectionValue {
+ const [state, setState] = useState(INITIAL_STATE);
+
+ useEffect(() => {
+ if (!file) {
+ setState(INITIAL_STATE);
+ return;
+ }
+
+ if (file.size > maxBytes) {
+ setState({
+ sourceFile: file,
+ result: null,
+ error: `This key file exceeds the ${maxBytes.toLocaleString()} byte limit.`,
+ loading: false
+ });
+ return;
+ }
+
+ const controller = new AbortController();
+ let current = true;
+ setState({ sourceFile: file, result: null, error: null, loading: true });
+
+ inspect(file, controller.signal)
+ .then((result) => {
+ if (!current || controller.signal.aborted) return;
+ setState({ sourceFile: file, result, error: null, loading: false });
+ })
+ .catch((error: unknown) => {
+ if (!current || controller.signal.aborted || (error instanceof Error && error.name === "AbortError")) return;
+ setState({ sourceFile: file, result: null, error: errorMessage(error), loading: false });
+ });
+
+ return () => {
+ current = false;
+ controller.abort();
+ };
+ }, [file, inspect, maxBytes]);
+
+ if (state.sourceFile !== file) {
+ return {
+ result: null,
+ error: null,
+ loading: file !== null && file.size <= maxBytes
+ };
+ }
+
+ return {
+ result: state.result,
+ error: state.error,
+ loading: state.loading
+ };
+}
diff --git a/web/src/lib/download.ts b/web/src/lib/download.ts
new file mode 100644
index 0000000..0d624f8
--- /dev/null
+++ b/web/src/lib/download.ts
@@ -0,0 +1,19 @@
+import type { DownloadResult } from "../api/contracts";
+
+export function downloadBlob(result: DownloadResult): void {
+ const url = URL.createObjectURL(result.blob);
+ const anchor = document.createElement("a");
+ anchor.href = url;
+ anchor.download = result.filename;
+ document.body.append(anchor);
+ anchor.click();
+ anchor.remove();
+ window.setTimeout(() => URL.revokeObjectURL(url), 0);
+}
+
+export function downloadText(filename: string, text: string): void {
+ downloadBlob({
+ filename,
+ blob: new Blob([text], { type: "application/x-pem-file" })
+ });
+}
diff --git a/web/src/lib/filenames.test.ts b/web/src/lib/filenames.test.ts
new file mode 100644
index 0000000..ab5306e
--- /dev/null
+++ b/web/src/lib/filenames.test.ts
@@ -0,0 +1,20 @@
+import { describe, expect, it } from "vitest";
+import { suggestedDecryptedName, suggestedEncryptedName } from "./filenames";
+
+describe("filename suggestions", () => {
+ it("creates the current encrypted filename convention", () => {
+ expect(suggestedEncryptedName(new File(["x"], "report.pdf"))).toBe(
+ "report_encrypted.pqc"
+ );
+ });
+
+ it("recovers the original stem from the encrypted convention", () => {
+ expect(suggestedDecryptedName(new File(["x"], "report_encrypted.pqc"))).toBe(
+ "report"
+ );
+ });
+
+ it("uses a safe fallback for a bare extension", () => {
+ expect(suggestedDecryptedName(new File(["x"], ".pqc"))).toBe("decrypted.bin");
+ });
+});
diff --git a/web/src/lib/filenames.ts b/web/src/lib/filenames.ts
new file mode 100644
index 0000000..31b5f7c
--- /dev/null
+++ b/web/src/lib/filenames.ts
@@ -0,0 +1,19 @@
+export function suggestedEncryptedName(file: File | null): string {
+ if (!file) return "encrypted-file.pqc";
+ const dot = file.name.lastIndexOf(".");
+ const stem = dot > 0 ? file.name.slice(0, dot) : file.name || "file";
+ return `${stem}_encrypted.pqc`;
+}
+
+export function suggestedDecryptedName(file: File | null): string {
+ if (!file) return "decrypted.bin";
+ const name = file.name;
+ if (name === ".pqc") return "decrypted.bin";
+ if (name.endsWith("_encrypted.pqc")) {
+ return name.slice(0, -"_encrypted.pqc".length) || "decrypted.bin";
+ }
+ if (name.endsWith(".pqc")) return `${name.slice(0, -".pqc".length)}_decrypted.bin`;
+ const dot = name.lastIndexOf(".");
+ if (dot > 0) return `${name.slice(0, dot)}_decrypted${name.slice(dot)}`;
+ return `${name}_decrypted.bin`;
+}
diff --git a/web/src/lib/format.test.ts b/web/src/lib/format.test.ts
new file mode 100644
index 0000000..1d36a26
--- /dev/null
+++ b/web/src/lib/format.test.ts
@@ -0,0 +1,10 @@
+import { describe, expect, it } from "vitest";
+import { formatBytes } from "./format";
+
+describe("formatBytes", () => {
+ it("uses binary units without overstating precision", () => {
+ expect(formatBytes(0)).toBe("0 B");
+ expect(formatBytes(1024)).toBe("1.00 KiB");
+ expect(formatBytes(100 * 1024 * 1024)).toBe("100 MiB");
+ });
+});
diff --git a/web/src/lib/format.ts b/web/src/lib/format.ts
new file mode 100644
index 0000000..b0b4b79
--- /dev/null
+++ b/web/src/lib/format.ts
@@ -0,0 +1,13 @@
+export function formatBytes(bytes: number): string {
+ if (!Number.isFinite(bytes) || bytes <= 0) return "0 B";
+ if (bytes < 1024) return String(bytes) + " B";
+ const units = ["KiB", "MiB", "GiB"];
+ let value = bytes / 1024;
+ let index = 0;
+ while (value >= 1024 && index < units.length - 1) {
+ value /= 1024;
+ index += 1;
+ }
+ const precision = value >= 100 ? 0 : value >= 10 ? 1 : 2;
+ return value.toFixed(precision) + " " + units[index];
+}
diff --git a/web/src/lib/workflow.test.ts b/web/src/lib/workflow.test.ts
new file mode 100644
index 0000000..aa9b469
--- /dev/null
+++ b/web/src/lib/workflow.test.ts
@@ -0,0 +1,16 @@
+import { describe, expect, it } from "vitest";
+import { deriveWorkflowPhase } from "./workflow";
+
+describe("deriveWorkflowPhase", () => {
+ it("does not report review before all inputs are ready", () => {
+ expect(deriveWorkflowPhase({ ready: false, complete: false })).toBe("select");
+ });
+
+ it("reports review only for ready inputs", () => {
+ expect(deriveWorkflowPhase({ ready: true, complete: false })).toBe("review");
+ });
+
+ it("prioritizes a completed result", () => {
+ expect(deriveWorkflowPhase({ ready: true, complete: true })).toBe("complete");
+ });
+});
diff --git a/web/src/lib/workflow.ts b/web/src/lib/workflow.ts
new file mode 100644
index 0000000..dad440e
--- /dev/null
+++ b/web/src/lib/workflow.ts
@@ -0,0 +1,6 @@
+export type WorkflowPhase = "select" | "review" | "complete";
+
+export function deriveWorkflowPhase(state: { ready: boolean; complete: boolean }): WorkflowPhase {
+ if (state.complete) return "complete";
+ return state.ready ? "review" : "select";
+}
diff --git a/web/src/main.tsx b/web/src/main.tsx
index fa1b97e..a41d533 100644
--- a/web/src/main.tsx
+++ b/web/src/main.tsx
@@ -1,7 +1,9 @@
import React from "react";
import ReactDOM from "react-dom/client";
-import App from "./App";
-import "./styles.css";
+import App from "./app/App";
+import "./styles/tokens.css";
+import "./styles/global.css";
+import "./styles/components.css";
ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(
diff --git a/web/src/styles.css b/web/src/styles.css
deleted file mode 100644
index 46eb3dd..0000000
--- a/web/src/styles.css
+++ /dev/null
@@ -1,564 +0,0 @@
-:root {
- color-scheme: dark;
- font-family:
- Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
- background: #000000;
- color: #f9fafb;
- font-synthesis: none;
- text-rendering: optimizeLegibility;
- --bg: #000000;
- --sidebar: #0d1321;
- --surface: #07111f;
- --surface-2: #0b1628;
- --surface-3: #0e1c31;
- --border: #1d2b44;
- --border-strong: #2f4364;
- --text: #f9fafb;
- --muted: #b6c2d1;
- --subtle: #748399;
- --primary: #3b82f6;
- --primary-strong: #2563eb;
- --accent: #22d3ee;
- --success: #22c55e;
- --warning: #f59e0b;
- --error: #ef4444;
- --radius: 8px;
-}
-
-* {
- box-sizing: border-box;
-}
-
-body {
- margin: 0;
- min-width: 320px;
- min-height: 100vh;
- background:
- linear-gradient(140deg, rgba(7, 17, 31, 0.94), rgba(0, 0, 0, 0.94) 34%),
- var(--bg);
-}
-
-button,
-input {
- font: inherit;
-}
-
-button {
- cursor: pointer;
-}
-
-button:disabled {
- cursor: not-allowed;
-}
-
-.app-shell {
- min-height: 100vh;
- display: grid;
- grid-template-columns: 280px minmax(0, 1fr);
-}
-
-.sidebar {
- background: var(--sidebar);
- border-right: 1px solid var(--border);
- padding: 28px 20px;
- display: flex;
- flex-direction: column;
- gap: 22px;
-}
-
-.brand-block {
- display: grid;
- grid-template-columns: 44px 1fr;
- gap: 12px;
- align-items: center;
-}
-
-.brand-mark {
- display: grid;
- place-items: center;
- height: 44px;
- border: 1px solid rgba(34, 211, 238, 0.44);
- border-radius: var(--radius);
- background: linear-gradient(135deg, rgba(34, 211, 238, 0.16), rgba(59, 130, 246, 0.18));
- color: var(--accent);
- font-size: 13px;
- font-weight: 800;
- letter-spacing: 0;
-}
-
-.brand-block h1,
-.workflow-header h2,
-.panel h3 {
- margin: 0;
- letter-spacing: 0;
-}
-
-.brand-block h1 {
- font-size: 18px;
- line-height: 1.15;
-}
-
-.brand-block p,
-.workflow-header p,
-.empty-copy {
- margin: 5px 0 0;
- color: var(--muted);
-}
-
-.brand-block p {
- font-size: 12px;
-}
-
-.nav-group {
- display: grid;
- gap: 8px;
-}
-
-.nav-heading,
-.eyebrow {
- margin: 0;
- color: var(--subtle);
- font-size: 11px;
- font-weight: 800;
- letter-spacing: 0.08em;
- text-transform: uppercase;
-}
-
-.nav-item {
- width: 100%;
- border: 1px solid transparent;
- border-radius: var(--radius);
- padding: 12px 12px;
- background: transparent;
- color: var(--muted);
- text-align: left;
-}
-
-.nav-item:hover,
-.nav-item:focus-visible {
- border-color: var(--border-strong);
- color: var(--text);
- outline: none;
-}
-
-.nav-item.active {
- border-color: rgba(59, 130, 246, 0.56);
- background: rgba(59, 130, 246, 0.13);
- color: var(--text);
-}
-
-.system-card,
-.notice,
-.panel,
-.meta-badge,
-.file-drop,
-.download-card {
- border: 1px solid var(--border);
- border-radius: var(--radius);
- background: var(--surface);
-}
-
-.system-card {
- display: grid;
- grid-template-columns: 10px 1fr;
- gap: 12px;
- padding: 14px;
-}
-
-.status-dot {
- width: 10px;
- height: 10px;
- border-radius: 999px;
- margin-top: 4px;
-}
-
-.status-dot.ready {
- background: var(--success);
- box-shadow: 0 0 0 4px rgba(34, 197, 94, 0.12);
-}
-
-.status-dot.warning {
- background: var(--warning);
- box-shadow: 0 0 0 4px rgba(245, 158, 11, 0.12);
-}
-
-.system-title {
- margin: 0;
- font-size: 13px;
- font-weight: 800;
-}
-
-.system-text {
- margin: 4px 0 0;
- color: var(--muted);
- font-size: 12px;
-}
-
-.metadata-grid {
- display: grid;
- grid-template-columns: 1fr;
- gap: 8px;
- margin-top: auto;
-}
-
-.meta-badge {
- padding: 10px 12px;
-}
-
-.meta-badge span,
-.field-row span,
-.download-card span {
- display: block;
- color: var(--subtle);
- font-size: 11px;
- font-weight: 800;
- letter-spacing: 0.06em;
- text-transform: uppercase;
-}
-
-.meta-badge strong,
-.field-row strong,
-.download-card strong {
- display: block;
- margin-top: 3px;
- color: var(--text);
- font-size: 13px;
- word-break: break-word;
-}
-
-.main-panel {
- min-width: 0;
- padding: 28px 38px 48px;
-}
-
-.notice {
- padding: 13px 14px;
- color: var(--muted);
- font-size: 13px;
- line-height: 1.5;
-}
-
-.notice.info {
- border-color: rgba(96, 165, 250, 0.38);
- background: rgba(96, 165, 250, 0.1);
- color: #bfdbfe;
-}
-
-.notice.success {
- border-color: rgba(34, 197, 94, 0.38);
- background: rgba(34, 197, 94, 0.1);
- color: #bbf7d0;
-}
-
-.notice.warning {
- border-color: rgba(245, 158, 11, 0.44);
- background: rgba(245, 158, 11, 0.11);
- color: #fde68a;
-}
-
-.notice.error {
- border-color: rgba(239, 68, 68, 0.44);
- background: rgba(239, 68, 68, 0.1);
- color: #fecaca;
-}
-
-.workflow {
- max-width: 1120px;
-}
-
-.workflow > .notice {
- margin-bottom: 18px;
-}
-
-.workflow-header {
- display: flex;
- align-items: end;
- justify-content: space-between;
- gap: 24px;
- margin-bottom: 22px;
-}
-
-.workflow-header h2 {
- font-size: clamp(28px, 3vw, 38px);
- line-height: 1.05;
-}
-
-.workflow-header p {
- max-width: 620px;
- font-size: 15px;
-}
-
-.stepper {
- list-style: none;
- margin: 0 0 22px;
- padding: 0;
- display: grid;
- grid-template-columns: repeat(5, minmax(0, 1fr));
- gap: 10px;
-}
-
-.step {
- min-width: 0;
- border: 1px solid var(--border);
- border-radius: var(--radius);
- padding: 10px;
- color: var(--subtle);
- background: rgba(11, 22, 40, 0.52);
- font-size: 12px;
- font-weight: 800;
- white-space: nowrap;
- overflow: hidden;
- text-overflow: ellipsis;
-}
-
-.step span {
- display: inline-grid;
- place-items: center;
- width: 22px;
- height: 22px;
- margin-right: 8px;
- border-radius: 999px;
- background: #101c2e;
- color: var(--muted);
-}
-
-.step.active {
- border-color: rgba(59, 130, 246, 0.52);
- color: var(--text);
-}
-
-.step.active span {
- background: var(--primary);
- color: white;
-}
-
-.panel-grid {
- display: grid;
- grid-template-columns: minmax(0, 1fr) minmax(340px, 0.85fr);
- gap: 18px;
-}
-
-.panel {
- padding: 20px;
- background: linear-gradient(180deg, var(--surface-2), var(--surface));
-}
-
-.panel h3 {
- margin-bottom: 16px;
- font-size: 18px;
-}
-
-.file-drop {
- display: grid;
- grid-template-columns: 118px 1fr;
- align-items: center;
- gap: 14px;
- padding: 14px;
- margin-bottom: 12px;
- color: var(--muted);
-}
-
-.file-drop:hover,
-.file-drop:focus-within {
- border-color: var(--border-strong);
-}
-
-.file-drop.has-file {
- border-color: rgba(34, 211, 238, 0.44);
-}
-
-.file-drop input {
- position: absolute;
- inline-size: 1px;
- block-size: 1px;
- overflow: hidden;
- opacity: 0;
-}
-
-.file-action {
- display: inline-grid;
- place-items: center;
- min-height: 44px;
- border: 1px solid var(--border-strong);
- border-radius: var(--radius);
- background: rgba(0, 0, 0, 0.28);
- color: var(--text);
- font-size: 13px;
- font-weight: 800;
-}
-
-.file-copy {
- min-width: 0;
-}
-
-.file-copy strong,
-.file-copy small {
- display: block;
-}
-
-.file-copy strong {
- overflow: hidden;
- color: var(--text);
- font-size: 14px;
- text-overflow: ellipsis;
- white-space: nowrap;
-}
-
-.file-copy small {
- margin-top: 4px;
- color: var(--subtle);
- font-size: 12px;
-}
-
-.field-row {
- display: grid;
- grid-template-columns: 132px minmax(0, 1fr);
- gap: 12px;
- align-items: start;
- padding: 12px 0;
- border-bottom: 1px solid rgba(29, 43, 68, 0.7);
-}
-
-.field-row:first-of-type {
- border-top: 1px solid rgba(29, 43, 68, 0.7);
-}
-
-.input-label {
- display: grid;
- gap: 8px;
- margin: 16px 0;
- color: var(--muted);
- font-size: 12px;
- font-weight: 800;
- letter-spacing: 0.05em;
- text-transform: uppercase;
-}
-
-.input-label input {
- width: 100%;
- border: 1px solid var(--border);
- border-radius: var(--radius);
- padding: 13px 14px;
- background: #050b14;
- color: var(--text);
- outline: none;
- text-transform: none;
- letter-spacing: 0;
-}
-
-.input-label input:focus {
- border-color: var(--primary);
- box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.16);
-}
-
-.primary-button {
- width: 100%;
- border: 1px solid rgba(59, 130, 246, 0.9);
- border-radius: var(--radius);
- padding: 13px 16px;
- background: linear-gradient(180deg, var(--primary), var(--primary-strong));
- color: white;
- font-size: 14px;
- font-weight: 900;
- box-shadow: 0 12px 28px rgba(37, 99, 235, 0.22);
-}
-
-.primary-button:hover:not(:disabled),
-.primary-button:focus-visible:not(:disabled) {
- filter: brightness(1.08);
- outline: none;
-}
-
-.primary-button:disabled {
- border-color: var(--border);
- background: #101827;
- color: var(--subtle);
- box-shadow: none;
-}
-
-.checklist {
- list-style: none;
- margin: 0 0 16px;
- padding: 0;
- display: grid;
- gap: 8px;
-}
-
-.checklist li {
- display: flex;
- align-items: center;
- gap: 10px;
- color: var(--muted);
- font-size: 13px;
-}
-
-.checklist span {
- width: 12px;
- height: 12px;
- border: 1px solid var(--border-strong);
- border-radius: 999px;
-}
-
-.checklist li.ok span {
- border-color: var(--success);
- background: var(--success);
-}
-
-.download-grid {
- display: grid;
- gap: 12px;
-}
-
-.download-card {
- padding: 16px;
- text-align: left;
- color: var(--text);
-}
-
-.download-card:hover,
-.download-card:focus-visible {
- border-color: var(--primary);
- outline: none;
-}
-
-.download-card.danger {
- border-color: rgba(245, 158, 11, 0.44);
-}
-
-.metadata-table {
- display: grid;
-}
-
-@media (max-width: 980px) {
- .app-shell {
- grid-template-columns: 1fr;
- }
-
- .sidebar {
- position: static;
- }
-
- .metadata-grid {
- grid-template-columns: repeat(2, minmax(0, 1fr));
- }
-
- .panel-grid {
- grid-template-columns: 1fr;
- }
-
- .stepper {
- grid-template-columns: 1fr;
- }
-}
-
-@media (max-width: 560px) {
- .main-panel,
- .sidebar {
- padding: 20px;
- }
-
- .file-drop,
- .field-row {
- grid-template-columns: 1fr;
- }
-}
diff --git a/web/src/styles/components.css b/web/src/styles/components.css
new file mode 100644
index 0000000..629c77b
--- /dev/null
+++ b/web/src/styles/components.css
@@ -0,0 +1,609 @@
+@import "./tokens.css";
+
+button {
+ min-height: 44px;
+ border: 1px solid var(--color-border-strong);
+ border-radius: var(--radius);
+ padding: var(--space-2) var(--space-4);
+ background: var(--color-action);
+ color: var(--color-action-text);
+ font-weight: 700;
+ transition: background-color var(--transition-fast), border-color var(--transition-fast), color var(--transition-fast);
+}
+
+button:hover:not(:disabled) {
+ background: #ffffff;
+}
+
+button:disabled {
+ border-color: var(--color-border);
+ background: var(--color-surface-raised);
+ color: var(--color-text-subtle);
+}
+
+.file-picker-field,
+.password-field {
+ display: grid;
+ gap: var(--space-2);
+}
+
+.file-picker {
+ position: relative;
+ display: grid;
+ gap: var(--space-1);
+ align-content: center;
+ border: 1px dashed var(--color-border-strong);
+ border-radius: var(--radius);
+ padding: var(--space-4);
+ background: var(--color-surface);
+ cursor: pointer;
+ transition: border-color var(--transition-fast), background-color var(--transition-fast);
+}
+
+.file-picker:hover,
+.file-picker-selected {
+ border-color: var(--color-text-muted);
+ background: var(--color-surface-raised);
+}
+
+.file-picker:focus-within {
+ border-color: var(--focus-ring);
+ outline: 2px solid var(--focus-ring);
+ outline-offset: 3px;
+ background: var(--color-surface-raised);
+}
+
+.file-picker-label,
+.password-field > label {
+ font-weight: 700;
+}
+
+.file-picker-prompt,
+.file-picker-selection,
+.field-hint {
+ color: var(--color-text-muted);
+}
+
+.file-picker-selection {
+ overflow-wrap: anywhere;
+}
+
+.file-picker-input {
+ position: absolute;
+ inline-size: 1px;
+ block-size: 1px;
+ opacity: 0;
+}
+
+.field-hint,
+.field-error {
+ margin: 0;
+ font-size: 0.875rem;
+}
+
+.field-error {
+ color: var(--color-error);
+}
+
+.password-field-control {
+ display: grid;
+ grid-template-columns: minmax(0, 1fr) auto;
+ gap: var(--space-2);
+}
+
+.password-field input {
+ min-width: 0;
+ border: 1px solid var(--color-border);
+ border-radius: var(--radius);
+ padding: var(--space-2) var(--space-3);
+ background: var(--color-surface);
+ color: var(--color-text);
+}
+
+.notice {
+ display: grid;
+ gap: var(--space-1);
+ border: 1px solid var(--color-border);
+ border-left-width: 4px;
+ border-radius: var(--radius);
+ padding: var(--space-3) var(--space-4);
+ background: var(--color-surface);
+ color: var(--color-text-muted);
+}
+
+.notice-title {
+ color: var(--color-text);
+}
+
+.notice-success {
+ border-left-color: var(--color-success);
+}
+
+.notice-warning {
+ border-left-color: var(--color-warning);
+}
+
+.notice-error {
+ border-left-color: var(--color-error);
+}
+
+.technical-details {
+ border: 1px solid var(--color-border);
+ border-radius: var(--radius);
+ background: var(--color-surface);
+}
+
+.technical-details summary {
+ padding: var(--space-3) var(--space-4);
+ cursor: pointer;
+ font-weight: 700;
+}
+
+.technical-details-content {
+ border-top: 1px solid var(--color-border);
+ padding: var(--space-4);
+ color: var(--color-text-muted);
+}
+
+.workflow-layout {
+ display: grid;
+ gap: var(--space-5);
+ max-width: 760px;
+ margin: 0 auto;
+ padding: var(--space-6) var(--space-4);
+}
+
+.workflow-header h1,
+.workflow-header p {
+ margin: 0;
+}
+
+.workflow-header p {
+ margin-top: var(--space-2);
+ color: var(--color-text-muted);
+}
+
+.workflow-phases {
+ display: grid;
+ grid-template-columns: repeat(3, 1fr);
+ gap: var(--space-2);
+ margin: 0;
+ padding: 0;
+ list-style: none;
+}
+
+.workflow-phases li {
+ border-bottom: 2px solid var(--color-border);
+ padding: var(--space-2) 0;
+ color: var(--color-text-subtle);
+ font-size: 0.875rem;
+ font-weight: 700;
+}
+
+.workflow-phases .is-current {
+ border-color: var(--color-text);
+ color: var(--color-text);
+}
+
+.workflow-content {
+ display: grid;
+ gap: var(--space-4);
+}
+
+.key-inspection-result {
+ display: grid;
+ gap: var(--space-3);
+}
+
+.generate-keys-form,
+.generated-key-downloads {
+ display: grid;
+ gap: var(--space-4);
+}
+
+.encryption-form,
+.decryption-form,
+.workflow-review,
+.output-filename-field,
+.review-list {
+ display: grid;
+ gap: var(--space-3);
+}
+
+.workflow-review {
+ border: 1px solid var(--color-border);
+ border-radius: var(--radius);
+ padding: var(--space-4);
+ background: var(--color-surface);
+}
+
+.workflow-review h2 {
+ margin: 0;
+ font-size: 1rem;
+}
+
+.review-list {
+ margin: 0;
+}
+
+.review-list > div {
+ display: grid;
+ grid-template-columns: minmax(8rem, 1fr) minmax(0, 2fr);
+ gap: var(--space-3);
+}
+
+.review-list dt {
+ color: var(--color-text-subtle);
+ font-weight: 700;
+}
+
+.review-list dd {
+ min-width: 0;
+ margin: 0;
+ overflow-wrap: anywhere;
+}
+
+.output-filename-field label {
+ font-weight: 700;
+}
+
+.output-filename-field input {
+ min-height: 44px;
+ min-width: 0;
+ border: 1px solid var(--color-border);
+ border-radius: var(--radius);
+ padding: var(--space-2) var(--space-3);
+ background: var(--color-surface);
+ color: var(--color-text);
+ font: inherit;
+}
+
+.workflow-readiness-reason {
+ margin: 0;
+ color: var(--color-text-muted);
+ font-size: 0.875rem;
+}
+
+.password-policy {
+ display: grid;
+ gap: var(--space-2);
+ margin: 0;
+ padding: 0;
+ list-style: none;
+ color: var(--color-text-muted);
+}
+
+.password-policy li {
+ display: flex;
+ gap: var(--space-2);
+ align-items: center;
+}
+
+.password-policy li span {
+ inline-size: 1.25rem;
+ color: var(--color-text-subtle);
+ font-weight: 700;
+ text-align: center;
+}
+
+.password-policy .password-policy-met {
+ color: var(--color-text);
+}
+
+.password-policy .password-policy-met span {
+ color: var(--color-success);
+}
+
+.generated-key-download-actions {
+ display: grid;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ gap: var(--space-3);
+}
+
+.generated-key-download-actions button {
+ display: grid;
+ gap: var(--space-1);
+ min-width: 0;
+ text-align: left;
+}
+
+.generated-key-download-actions span {
+ color: var(--color-text-muted);
+ font-size: 0.875rem;
+ font-weight: 400;
+}
+
+.generated-key-download-actions strong {
+ overflow-wrap: anywhere;
+}
+
+.metadata-list {
+ display: grid;
+ gap: var(--space-2);
+ margin: 0;
+}
+
+.metadata-list > div {
+ display: grid;
+ grid-template-columns: minmax(9rem, 1fr) minmax(0, 2fr);
+ gap: var(--space-3);
+}
+
+.metadata-list dt {
+ color: var(--color-text-subtle);
+ font-weight: 700;
+}
+
+.metadata-list dd {
+ min-width: 0;
+ margin: 0;
+ overflow-wrap: anywhere;
+}
+
+@media (max-width: 520px) {
+ .generated-key-download-actions {
+ grid-template-columns: 1fr;
+ }
+
+ .review-list > div {
+ grid-template-columns: 1fr;
+ gap: var(--space-1);
+ }
+}
+
+.app-shell {
+ min-height: 100vh;
+ background: var(--color-canvas);
+}
+
+.app-rail,
+.app-header {
+ border-color: var(--color-border);
+ background: var(--color-rail);
+}
+
+.app-rail {
+ display: none;
+}
+
+.app-header {
+ display: grid;
+ grid-template-columns: minmax(0, 1fr) minmax(168px, 240px) auto;
+ gap: var(--space-3);
+ align-items: center;
+ border-bottom: 1px solid var(--color-border);
+ padding: var(--space-3) var(--space-4);
+}
+
+.app-brand {
+ display: flex;
+ min-width: 0;
+ gap: var(--space-2);
+ align-items: center;
+}
+
+.app-brand > span:last-child {
+ display: grid;
+ min-width: 0;
+}
+
+.app-brand strong,
+.app-brand small {
+ overflow-wrap: anywhere;
+}
+
+.app-brand small {
+ color: var(--color-text-muted);
+}
+
+.app-brand-mark {
+ display: grid;
+ flex: 0 0 44px;
+ place-items: center;
+ inline-size: 44px;
+ block-size: 44px;
+ border: 1px solid var(--color-border-strong);
+ border-radius: var(--radius);
+ color: var(--color-text);
+ font-size: 0.75rem;
+ font-weight: 800;
+}
+
+.app-workflow-select {
+ display: grid;
+ gap: var(--space-1);
+ color: var(--color-text-muted);
+ font-size: 0.875rem;
+ font-weight: 700;
+}
+
+.app-workflow-select select {
+ min-height: 44px;
+ max-width: 100%;
+ border: 1px solid var(--color-border-strong);
+ border-radius: var(--radius);
+ padding: 0 var(--space-3);
+ background: var(--color-surface);
+ color: var(--color-text);
+ font: inherit;
+}
+
+.app-main {
+ min-width: 0;
+ width: 100%;
+ max-width: 1120px;
+ margin: 0 auto;
+ padding: var(--space-6);
+}
+
+.app-rail-navigation {
+ display: grid;
+ gap: var(--space-2);
+}
+
+.app-rail-navigation p {
+ margin: 0;
+ color: var(--color-text-subtle);
+ font-size: 0.75rem;
+ font-weight: 700;
+ letter-spacing: 0.08em;
+ text-transform: uppercase;
+}
+
+.app-navigation-item {
+ width: 100%;
+ border-color: transparent;
+ background: transparent;
+ color: var(--color-text-muted);
+ text-align: left;
+}
+
+.app-navigation-item:hover:not(:disabled),
+.app-navigation-item.is-current {
+ border-color: var(--color-border-strong);
+ background: var(--color-surface-raised);
+ color: var(--color-text);
+}
+
+.capability-status {
+ display: grid;
+ gap: var(--space-2);
+ border-top: 1px solid var(--color-border);
+ padding-top: var(--space-4);
+}
+
+.capability-status-summary {
+ display: grid;
+ grid-template-columns: auto 1fr auto;
+ gap: var(--space-2);
+ align-items: center;
+ margin: 0;
+ color: var(--color-text-muted);
+}
+
+.capability-status-summary strong {
+ color: var(--color-text);
+}
+
+.capability-status-dot {
+ inline-size: 10px;
+ block-size: 10px;
+ border-radius: 50%;
+}
+
+.capability-status-dot-ready {
+ background: var(--color-success);
+}
+
+.capability-status-dot-limited {
+ background: var(--color-warning);
+}
+
+.capability-status-dot-unavailable {
+ background: var(--color-error);
+}
+
+.capability-status-details summary {
+ min-height: 44px;
+ cursor: pointer;
+ color: var(--color-text-muted);
+ font-weight: 700;
+}
+
+.capability-status-details ul {
+ display: grid;
+ gap: var(--space-2);
+ margin: var(--space-2) 0 0;
+ padding: 0;
+ list-style: none;
+}
+
+.capability-status-details li {
+ display: grid;
+ gap: var(--space-1);
+ padding: var(--space-2) 0;
+ border-top: 1px solid var(--color-border);
+}
+
+.capability-status-details li span {
+ color: var(--color-text-muted);
+ overflow-wrap: anywhere;
+}
+
+.capability-status-compact {
+ min-width: 180px;
+ border-top: 0;
+ padding-top: 0;
+}
+
+.app-mobile-navigation {
+ display: none;
+}
+
+@media (min-width: 1024px) {
+ .app-shell {
+ display: grid;
+ grid-template-columns: 256px minmax(0, 1fr);
+ }
+
+ .app-rail {
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-5);
+ min-height: 100vh;
+ border-right: 1px solid var(--color-border);
+ padding: var(--space-5) var(--space-4);
+ }
+
+ .app-rail .capability-status {
+ margin-top: auto;
+ }
+
+ .app-header,
+ .app-mobile-navigation {
+ display: none;
+ }
+}
+
+@media (max-width: 767px) {
+ .app-header {
+ grid-template-columns: minmax(0, 1fr) auto;
+ }
+
+ .app-workflow-select {
+ display: none;
+ }
+
+ .capability-status-compact {
+ min-width: 0;
+ }
+
+ .app-main {
+ padding: var(--space-4);
+ }
+
+ .app-mobile-navigation {
+ display: grid;
+ grid-template-columns: repeat(4, minmax(0, 1fr));
+ border-bottom: 1px solid var(--color-border);
+ background: var(--color-rail);
+ }
+
+ .app-mobile-navigation a {
+ display: grid;
+ min-width: 0;
+ min-height: 44px;
+ place-items: center;
+ padding: var(--space-2);
+ color: var(--color-text-muted);
+ font-size: 0.75rem;
+ font-weight: 700;
+ text-align: center;
+ text-decoration: none;
+ }
+
+ .app-mobile-navigation a[aria-current="page"] {
+ color: var(--color-text);
+ box-shadow: inset 0 -2px 0 var(--color-text);
+ }
+}
diff --git a/web/src/styles/global.css b/web/src/styles/global.css
new file mode 100644
index 0000000..b905341
--- /dev/null
+++ b/web/src/styles/global.css
@@ -0,0 +1,29 @@
+@import "./tokens.css";
+
+* {
+ box-sizing: border-box;
+}
+
+body {
+ margin: 0;
+ min-width: 320px;
+ min-height: 100vh;
+ background: var(--color-canvas);
+ color: var(--color-text);
+ font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
+ font-synthesis: none;
+ text-rendering: optimizeLegibility;
+}
+
+button,
+input {
+ font: inherit;
+}
+
+button {
+ cursor: pointer;
+}
+
+button:disabled {
+ cursor: not-allowed;
+}
diff --git a/web/src/styles/tokens.css b/web/src/styles/tokens.css
new file mode 100644
index 0000000..7c3dd8d
--- /dev/null
+++ b/web/src/styles/tokens.css
@@ -0,0 +1,50 @@
+:root {
+ color-scheme: dark;
+ --color-canvas: #090909;
+ --color-rail: #0d0d0d;
+ --color-surface: #141414;
+ --color-surface-raised: #1b1b1b;
+ --color-border: #343434;
+ --color-border-strong: #555555;
+ --color-text: #f2f2ee;
+ --color-text-muted: #aaa9a3;
+ --color-text-subtle: #8a8a85;
+ --color-action: #f2f2ee;
+ --color-action-text: #0b0b0b;
+ --color-success: #78b86b;
+ --color-warning: #d3a24c;
+ --color-error: #d66a63;
+ --focus-ring: #f2f2ee;
+ --radius: 10px;
+ --space-1: 4px;
+ --space-2: 8px;
+ --space-3: 12px;
+ --space-4: 16px;
+ --space-5: 24px;
+ --space-6: 32px;
+ --space-7: 48px;
+ --transition-fast: 140ms ease;
+}
+
+:focus-visible {
+ outline: 2px solid var(--focus-ring);
+ outline-offset: 3px;
+}
+
+button,
+input,
+summary,
+.file-picker {
+ min-height: 44px;
+}
+
+@media (prefers-reduced-motion: reduce) {
+ *,
+ *::before,
+ *::after {
+ scroll-behavior: auto !important;
+ transition-duration: 0.01ms !important;
+ animation-duration: 0.01ms !important;
+ animation-iteration-count: 1 !important;
+ }
+}
diff --git a/web/src/test/fixtures.ts b/web/src/test/fixtures.ts
new file mode 100644
index 0000000..6d57f2d
--- /dev/null
+++ b/web/src/test/fixtures.ts
@@ -0,0 +1,26 @@
+import type { Health } from "../api/contracts";
+
+export const READY_HEALTH: Health = {
+ ok: true,
+ backendReady: true,
+ backendMessage: "Post-quantum backend ready.",
+ capabilities: {
+ inspect: { available: true, reason: "" },
+ generate: { available: true, reason: "" },
+ encrypt: { available: true, reason: "" },
+ decrypt: { available: true, reason: "" }
+ },
+ formatVersion: 4,
+ kem: "ML-KEM-768+X25519-v2",
+ kemComponent: "ML-KEM-768",
+ configuredKem: "ML-KEM-768",
+ dem: "AES-256-GCM",
+ maxFileBytes: 104857600,
+ maxEncryptedFileBytes: 105906314,
+ maxPemBytes: 131072,
+ apiToken: "test-local-token",
+ passwordPolicy: {
+ minChars: 16,
+ minUniqueChars: 5
+ }
+};
diff --git a/web/src/test/setup.ts b/web/src/test/setup.ts
new file mode 100644
index 0000000..e262193
--- /dev/null
+++ b/web/src/test/setup.ts
@@ -0,0 +1,7 @@
+import "@testing-library/jest-dom/vitest";
+import { cleanup } from "@testing-library/react";
+import { afterEach } from "vitest";
+
+afterEach(() => {
+ cleanup();
+});