diff --git a/PROJECTS/encrypted-p2p-chat/.env.example b/PROJECTS/encrypted-p2p-chat/.env.example new file mode 100644 index 00000000..ed841e55 --- /dev/null +++ b/PROJECTS/encrypted-p2p-chat/.env.example @@ -0,0 +1,63 @@ +# Application +ENV=development +DEBUG=true +APP_NAME=encrypted-p2p-chat +SECRET_KEY=your-secret-key-here-change-in-production + +# Docker Host Ports (change these if you have conflicts) +POSTGRES_HOST_PORT=5432 +SURREAL_HOST_PORT=8001 +REDIS_HOST_PORT=6379 +BACKEND_HOST_PORT=8000 +NGINX_HTTP_PORT=80 +NGINX_HTTPS_PORT=443 + +# PostgreSQL (for auth data) +POSTGRES_HOST=postgres +POSTGRES_PORT=5432 +POSTGRES_DB=chat_auth +POSTGRES_USER=chat_user +POSTGRES_PASSWORD=change-this-password +DATABASE_URL=postgresql+asyncpg://chat_user:change-this-password@postgres:5432/chat_auth +DB_POOL_SIZE=20 +DB_MAX_OVERFLOW=40 + +# SurrealDB (for real-time chat data) +SURREAL_HOST=surrealdb +SURREAL_PORT=8000 +SURREAL_USER=root +SURREAL_PASSWORD=change-this-password +SURREAL_NAMESPACE=chat +SURREAL_DATABASE=production +SURREAL_URL=ws://surrealdb:8000 + +# Redis (for caching and rate limiting) +REDIS_HOST=redis +REDIS_PORT=6379 +REDIS_PASSWORD= +REDIS_URL=redis://redis:6379 + +# WebAuthn / Passkeys +RP_ID=localhost +RP_NAME=Encrypted P2P Chat +RP_ORIGIN=http://localhost + +# Frontend (Vite requires VITE_ prefix) +VITE_API_URL=http://localhost:8000 +VITE_WS_URL=ws://localhost:8000 +VITE_RP_ID=localhost + +# CORS +CORS_ORIGINS=["http://localhost:3000","http://localhost:5173"] + +# WebSocket +WS_HEARTBEAT_INTERVAL=30 +WS_MAX_CONNECTIONS_PER_USER=5 + +# Encryption +KEY_ROTATION_DAYS=90 +MAX_SKIPPED_MESSAGE_KEYS=1000 + +# Rate Limiting +RATE_LIMIT_MESSAGES_PER_MINUTE=60 +RATE_LIMIT_AUTH_ATTEMPTS=5 diff --git a/PROJECTS/encrypted-p2p-chat/.gitignore b/PROJECTS/encrypted-p2p-chat/.gitignore new file mode 100644 index 00000000..a8849b3e --- /dev/null +++ b/PROJECTS/encrypted-p2p-chat/.gitignore @@ -0,0 +1,71 @@ +# Environment +/.env +.env.local +backend/.env +frontend/.env + +# Python +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +*.egg-info/ +.installed.cfg +*.egg +venv/ +ENV/ +env/ + +# Testing +.pytest_cache/ +.coverage +htmlcov/ +.mypy_cache/ +.ruff_cache/ + +# IDE +.vscode/ +.idea/ +*.swp +*.swo +*~ + +# Logs +*.log +logs/ + +# Docker +docker-compose.override.yml + +# Database +*.db +*.sqlite +*.sqlite3 + +# macOS +.DS_Store + +# Node +node_modules/ +.pnpm-store/ +dist/ +.solid/ + +# Nginx +nginx/ssl/ + +# AI handoff documentation +.truth diff --git a/PROJECTS/encrypted-p2p-chat/.venv/bin/Activate.ps1 b/PROJECTS/encrypted-p2p-chat/.venv/bin/Activate.ps1 new file mode 100644 index 00000000..b49d77ba --- /dev/null +++ b/PROJECTS/encrypted-p2p-chat/.venv/bin/Activate.ps1 @@ -0,0 +1,247 @@ +<# +.Synopsis +Activate a Python virtual environment for the current PowerShell session. + +.Description +Pushes the python executable for a virtual environment to the front of the +$Env:PATH environment variable and sets the prompt to signify that you are +in a Python virtual environment. Makes use of the command line switches as +well as the `pyvenv.cfg` file values present in the virtual environment. + +.Parameter VenvDir +Path to the directory that contains the virtual environment to activate. The +default value for this is the parent of the directory that the Activate.ps1 +script is located within. + +.Parameter Prompt +The prompt prefix to display when this virtual environment is activated. By +default, this prompt is the name of the virtual environment folder (VenvDir) +surrounded by parentheses and followed by a single space (ie. '(.venv) '). + +.Example +Activate.ps1 +Activates the Python virtual environment that contains the Activate.ps1 script. + +.Example +Activate.ps1 -Verbose +Activates the Python virtual environment that contains the Activate.ps1 script, +and shows extra information about the activation as it executes. + +.Example +Activate.ps1 -VenvDir C:\Users\MyUser\Common\.venv +Activates the Python virtual environment located in the specified location. + +.Example +Activate.ps1 -Prompt "MyPython" +Activates the Python virtual environment that contains the Activate.ps1 script, +and prefixes the current prompt with the specified string (surrounded in +parentheses) while the virtual environment is active. + +.Notes +On Windows, it may be required to enable this Activate.ps1 script by setting the +execution policy for the user. You can do this by issuing the following PowerShell +command: + +PS C:\> Set-ExecutionPolicy -ExecutionPolicy RemoteSigned -Scope CurrentUser + +For more information on Execution Policies: +https://go.microsoft.com/fwlink/?LinkID=135170 + +#> +Param( + [Parameter(Mandatory = $false)] + [String] + $VenvDir, + [Parameter(Mandatory = $false)] + [String] + $Prompt +) + +<# Function declarations --------------------------------------------------- #> + +<# +.Synopsis +Remove all shell session elements added by the Activate script, including the +addition of the virtual environment's Python executable from the beginning of +the PATH variable. + +.Parameter NonDestructive +If present, do not remove this function from the global namespace for the +session. + +#> +function global:deactivate ([switch]$NonDestructive) { + # Revert to original values + + # The prior prompt: + if (Test-Path -Path Function:_OLD_VIRTUAL_PROMPT) { + Copy-Item -Path Function:_OLD_VIRTUAL_PROMPT -Destination Function:prompt + Remove-Item -Path Function:_OLD_VIRTUAL_PROMPT + } + + # The prior PYTHONHOME: + if (Test-Path -Path Env:_OLD_VIRTUAL_PYTHONHOME) { + Copy-Item -Path Env:_OLD_VIRTUAL_PYTHONHOME -Destination Env:PYTHONHOME + Remove-Item -Path Env:_OLD_VIRTUAL_PYTHONHOME + } + + # The prior PATH: + if (Test-Path -Path Env:_OLD_VIRTUAL_PATH) { + Copy-Item -Path Env:_OLD_VIRTUAL_PATH -Destination Env:PATH + Remove-Item -Path Env:_OLD_VIRTUAL_PATH + } + + # Just remove the VIRTUAL_ENV altogether: + if (Test-Path -Path Env:VIRTUAL_ENV) { + Remove-Item -Path env:VIRTUAL_ENV + } + + # Just remove VIRTUAL_ENV_PROMPT altogether. + if (Test-Path -Path Env:VIRTUAL_ENV_PROMPT) { + Remove-Item -Path env:VIRTUAL_ENV_PROMPT + } + + # Just remove the _PYTHON_VENV_PROMPT_PREFIX altogether: + if (Get-Variable -Name "_PYTHON_VENV_PROMPT_PREFIX" -ErrorAction SilentlyContinue) { + Remove-Variable -Name _PYTHON_VENV_PROMPT_PREFIX -Scope Global -Force + } + + # Leave deactivate function in the global namespace if requested: + if (-not $NonDestructive) { + Remove-Item -Path function:deactivate + } +} + +<# +.Description +Get-PyVenvConfig parses the values from the pyvenv.cfg file located in the +given folder, and returns them in a map. + +For each line in the pyvenv.cfg file, if that line can be parsed into exactly +two strings separated by `=` (with any amount of whitespace surrounding the =) +then it is considered a `key = value` line. The left hand string is the key, +the right hand is the value. + +If the value starts with a `'` or a `"` then the first and last character is +stripped from the value before being captured. + +.Parameter ConfigDir +Path to the directory that contains the `pyvenv.cfg` file. +#> +function Get-PyVenvConfig( + [String] + $ConfigDir +) { + Write-Verbose "Given ConfigDir=$ConfigDir, obtain values in pyvenv.cfg" + + # Ensure the file exists, and issue a warning if it doesn't (but still allow the function to continue). + $pyvenvConfigPath = Join-Path -Resolve -Path $ConfigDir -ChildPath 'pyvenv.cfg' -ErrorAction Continue + + # An empty map will be returned if no config file is found. + $pyvenvConfig = @{ } + + if ($pyvenvConfigPath) { + + Write-Verbose "File exists, parse `key = value` lines" + $pyvenvConfigContent = Get-Content -Path $pyvenvConfigPath + + $pyvenvConfigContent | ForEach-Object { + $keyval = $PSItem -split "\s*=\s*", 2 + if ($keyval[0] -and $keyval[1]) { + $val = $keyval[1] + + # Remove extraneous quotations around a string value. + if ("'""".Contains($val.Substring(0, 1))) { + $val = $val.Substring(1, $val.Length - 2) + } + + $pyvenvConfig[$keyval[0]] = $val + Write-Verbose "Adding Key: '$($keyval[0])'='$val'" + } + } + } + return $pyvenvConfig +} + + +<# Begin Activate script --------------------------------------------------- #> + +# Determine the containing directory of this script +$VenvExecPath = Split-Path -Parent $MyInvocation.MyCommand.Definition +$VenvExecDir = Get-Item -Path $VenvExecPath + +Write-Verbose "Activation script is located in path: '$VenvExecPath'" +Write-Verbose "VenvExecDir Fullname: '$($VenvExecDir.FullName)" +Write-Verbose "VenvExecDir Name: '$($VenvExecDir.Name)" + +# Set values required in priority: CmdLine, ConfigFile, Default +# First, get the location of the virtual environment, it might not be +# VenvExecDir if specified on the command line. +if ($VenvDir) { + Write-Verbose "VenvDir given as parameter, using '$VenvDir' to determine values" +} +else { + Write-Verbose "VenvDir not given as a parameter, using parent directory name as VenvDir." + $VenvDir = $VenvExecDir.Parent.FullName.TrimEnd("\\/") + Write-Verbose "VenvDir=$VenvDir" +} + +# Next, read the `pyvenv.cfg` file to determine any required value such +# as `prompt`. +$pyvenvCfg = Get-PyVenvConfig -ConfigDir $VenvDir + +# Next, set the prompt from the command line, or the config file, or +# just use the name of the virtual environment folder. +if ($Prompt) { + Write-Verbose "Prompt specified as argument, using '$Prompt'" +} +else { + Write-Verbose "Prompt not specified as argument to script, checking pyvenv.cfg value" + if ($pyvenvCfg -and $pyvenvCfg['prompt']) { + Write-Verbose " Setting based on value in pyvenv.cfg='$($pyvenvCfg['prompt'])'" + $Prompt = $pyvenvCfg['prompt']; + } + else { + Write-Verbose " Setting prompt based on parent's directory's name. (Is the directory name passed to venv module when creating the virtual environment)" + Write-Verbose " Got leaf-name of $VenvDir='$(Split-Path -Path $venvDir -Leaf)'" + $Prompt = Split-Path -Path $venvDir -Leaf + } +} + +Write-Verbose "Prompt = '$Prompt'" +Write-Verbose "VenvDir='$VenvDir'" + +# Deactivate any currently active virtual environment, but leave the +# deactivate function in place. +deactivate -nondestructive + +# Now set the environment variable VIRTUAL_ENV, used by many tools to determine +# that there is an activated venv. +$env:VIRTUAL_ENV = $VenvDir + +if (-not $Env:VIRTUAL_ENV_DISABLE_PROMPT) { + + Write-Verbose "Setting prompt to '$Prompt'" + + # Set the prompt to include the env name + # Make sure _OLD_VIRTUAL_PROMPT is global + function global:_OLD_VIRTUAL_PROMPT { "" } + Copy-Item -Path function:prompt -Destination function:_OLD_VIRTUAL_PROMPT + New-Variable -Name _PYTHON_VENV_PROMPT_PREFIX -Description "Python virtual environment prompt prefix" -Scope Global -Option ReadOnly -Visibility Public -Value $Prompt + + function global:prompt { + Write-Host -NoNewline -ForegroundColor Green "($_PYTHON_VENV_PROMPT_PREFIX) " + _OLD_VIRTUAL_PROMPT + } + $env:VIRTUAL_ENV_PROMPT = $Prompt +} + +# Clear PYTHONHOME +if (Test-Path -Path Env:PYTHONHOME) { + Copy-Item -Path Env:PYTHONHOME -Destination Env:_OLD_VIRTUAL_PYTHONHOME + Remove-Item -Path Env:PYTHONHOME +} + +# Add the venv to the PATH +Copy-Item -Path Env:PATH -Destination Env:_OLD_VIRTUAL_PATH +$Env:PATH = "$VenvExecDir$([System.IO.Path]::PathSeparator)$Env:PATH" diff --git a/PROJECTS/encrypted-p2p-chat/.venv/bin/activate b/PROJECTS/encrypted-p2p-chat/.venv/bin/activate new file mode 100644 index 00000000..c20de642 --- /dev/null +++ b/PROJECTS/encrypted-p2p-chat/.venv/bin/activate @@ -0,0 +1,70 @@ +# This file must be used with "source bin/activate" *from bash* +# You cannot run it directly + +deactivate () { + # reset old environment variables + if [ -n "${_OLD_VIRTUAL_PATH:-}" ] ; then + PATH="${_OLD_VIRTUAL_PATH:-}" + export PATH + unset _OLD_VIRTUAL_PATH + fi + if [ -n "${_OLD_VIRTUAL_PYTHONHOME:-}" ] ; then + PYTHONHOME="${_OLD_VIRTUAL_PYTHONHOME:-}" + export PYTHONHOME + unset _OLD_VIRTUAL_PYTHONHOME + fi + + # Call hash to forget past commands. Without forgetting + # past commands the $PATH changes we made may not be respected + hash -r 2> /dev/null + + if [ -n "${_OLD_VIRTUAL_PS1:-}" ] ; then + PS1="${_OLD_VIRTUAL_PS1:-}" + export PS1 + unset _OLD_VIRTUAL_PS1 + fi + + unset VIRTUAL_ENV + unset VIRTUAL_ENV_PROMPT + if [ ! "${1:-}" = "nondestructive" ] ; then + # Self destruct! + unset -f deactivate + fi +} + +# unset irrelevant variables +deactivate nondestructive + +# on Windows, a path can contain colons and backslashes and has to be converted: +if [ "${OSTYPE:-}" = "cygwin" ] || [ "${OSTYPE:-}" = "msys" ] ; then + # transform D:\path\to\venv to /d/path/to/venv on MSYS + # and to /cygdrive/d/path/to/venv on Cygwin + export VIRTUAL_ENV=$(cygpath "/home/yoshi/dev/Cybersecurity-Projects/PROJECTS/encrypted-p2p-chat/.venv") +else + # use the path as-is + export VIRTUAL_ENV="/home/yoshi/dev/Cybersecurity-Projects/PROJECTS/encrypted-p2p-chat/.venv" +fi + +_OLD_VIRTUAL_PATH="$PATH" +PATH="$VIRTUAL_ENV/bin:$PATH" +export PATH + +# unset PYTHONHOME if set +# this will fail if PYTHONHOME is set to the empty string (which is bad anyway) +# could use `if (set -u; : $PYTHONHOME) ;` in bash +if [ -n "${PYTHONHOME:-}" ] ; then + _OLD_VIRTUAL_PYTHONHOME="${PYTHONHOME:-}" + unset PYTHONHOME +fi + +if [ -z "${VIRTUAL_ENV_DISABLE_PROMPT:-}" ] ; then + _OLD_VIRTUAL_PS1="${PS1:-}" + PS1="(.venv) ${PS1:-}" + export PS1 + VIRTUAL_ENV_PROMPT="(.venv) " + export VIRTUAL_ENV_PROMPT +fi + +# Call hash to forget past commands. Without forgetting +# past commands the $PATH changes we made may not be respected +hash -r 2> /dev/null diff --git a/PROJECTS/encrypted-p2p-chat/.venv/bin/activate.csh b/PROJECTS/encrypted-p2p-chat/.venv/bin/activate.csh new file mode 100644 index 00000000..c9fe5be8 --- /dev/null +++ b/PROJECTS/encrypted-p2p-chat/.venv/bin/activate.csh @@ -0,0 +1,27 @@ +# This file must be used with "source bin/activate.csh" *from csh*. +# You cannot run it directly. + +# Created by Davide Di Blasi . +# Ported to Python 3.3 venv by Andrew Svetlov + +alias deactivate 'test $?_OLD_VIRTUAL_PATH != 0 && setenv PATH "$_OLD_VIRTUAL_PATH" && unset _OLD_VIRTUAL_PATH; rehash; test $?_OLD_VIRTUAL_PROMPT != 0 && set prompt="$_OLD_VIRTUAL_PROMPT" && unset _OLD_VIRTUAL_PROMPT; unsetenv VIRTUAL_ENV; unsetenv VIRTUAL_ENV_PROMPT; test "\!:*" != "nondestructive" && unalias deactivate' + +# Unset irrelevant variables. +deactivate nondestructive + +setenv VIRTUAL_ENV "/home/yoshi/dev/Cybersecurity-Projects/PROJECTS/encrypted-p2p-chat/.venv" + +set _OLD_VIRTUAL_PATH="$PATH" +setenv PATH "$VIRTUAL_ENV/bin:$PATH" + + +set _OLD_VIRTUAL_PROMPT="$prompt" + +if (! "$?VIRTUAL_ENV_DISABLE_PROMPT") then + set prompt = "(.venv) $prompt" + setenv VIRTUAL_ENV_PROMPT "(.venv) " +endif + +alias pydoc python -m pydoc + +rehash diff --git a/PROJECTS/encrypted-p2p-chat/.venv/bin/activate.fish b/PROJECTS/encrypted-p2p-chat/.venv/bin/activate.fish new file mode 100644 index 00000000..ba7f7dc0 --- /dev/null +++ b/PROJECTS/encrypted-p2p-chat/.venv/bin/activate.fish @@ -0,0 +1,69 @@ +# This file must be used with "source /bin/activate.fish" *from fish* +# (https://fishshell.com/). You cannot run it directly. + +function deactivate -d "Exit virtual environment and return to normal shell environment" + # reset old environment variables + if test -n "$_OLD_VIRTUAL_PATH" + set -gx PATH $_OLD_VIRTUAL_PATH + set -e _OLD_VIRTUAL_PATH + end + if test -n "$_OLD_VIRTUAL_PYTHONHOME" + set -gx PYTHONHOME $_OLD_VIRTUAL_PYTHONHOME + set -e _OLD_VIRTUAL_PYTHONHOME + end + + if test -n "$_OLD_FISH_PROMPT_OVERRIDE" + set -e _OLD_FISH_PROMPT_OVERRIDE + # prevents error when using nested fish instances (Issue #93858) + if functions -q _old_fish_prompt + functions -e fish_prompt + functions -c _old_fish_prompt fish_prompt + functions -e _old_fish_prompt + end + end + + set -e VIRTUAL_ENV + set -e VIRTUAL_ENV_PROMPT + if test "$argv[1]" != "nondestructive" + # Self-destruct! + functions -e deactivate + end +end + +# Unset irrelevant variables. +deactivate nondestructive + +set -gx VIRTUAL_ENV "/home/yoshi/dev/Cybersecurity-Projects/PROJECTS/encrypted-p2p-chat/.venv" + +set -gx _OLD_VIRTUAL_PATH $PATH +set -gx PATH "$VIRTUAL_ENV/bin" $PATH + +# Unset PYTHONHOME if set. +if set -q PYTHONHOME + set -gx _OLD_VIRTUAL_PYTHONHOME $PYTHONHOME + set -e PYTHONHOME +end + +if test -z "$VIRTUAL_ENV_DISABLE_PROMPT" + # fish uses a function instead of an env var to generate the prompt. + + # Save the current fish_prompt function as the function _old_fish_prompt. + functions -c fish_prompt _old_fish_prompt + + # With the original prompt function renamed, we can override with our own. + function fish_prompt + # Save the return status of the last command. + set -l old_status $status + + # Output the venv prompt; color taken from the blue of the Python logo. + printf "%s%s%s" (set_color 4B8BBE) "(.venv) " (set_color normal) + + # Restore the return status of the previous command. + echo "exit $old_status" | . + # Output the original/"old" prompt. + _old_fish_prompt + end + + set -gx _OLD_FISH_PROMPT_OVERRIDE "$VIRTUAL_ENV" + set -gx VIRTUAL_ENV_PROMPT "(.venv) " +end diff --git a/PROJECTS/encrypted-p2p-chat/.venv/bin/alembic b/PROJECTS/encrypted-p2p-chat/.venv/bin/alembic new file mode 100755 index 00000000..5676ea59 --- /dev/null +++ b/PROJECTS/encrypted-p2p-chat/.venv/bin/alembic @@ -0,0 +1,8 @@ +#!/home/yoshi/dev/Cybersecurity-Projects/PROJECTS/encrypted-p2p-chat/.venv/bin/python +# -*- coding: utf-8 -*- +import re +import sys +from alembic.config import main +if __name__ == '__main__': + sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0]) + sys.exit(main()) diff --git a/PROJECTS/encrypted-p2p-chat/.venv/bin/cbor2 b/PROJECTS/encrypted-p2p-chat/.venv/bin/cbor2 new file mode 100755 index 00000000..c6dd1250 --- /dev/null +++ b/PROJECTS/encrypted-p2p-chat/.venv/bin/cbor2 @@ -0,0 +1,8 @@ +#!/home/yoshi/dev/Cybersecurity-Projects/PROJECTS/encrypted-p2p-chat/.venv/bin/python +# -*- coding: utf-8 -*- +import re +import sys +from cbor2.tool import main +if __name__ == '__main__': + sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0]) + sys.exit(main()) diff --git a/PROJECTS/encrypted-p2p-chat/.venv/bin/dmypy b/PROJECTS/encrypted-p2p-chat/.venv/bin/dmypy new file mode 100755 index 00000000..4329c295 --- /dev/null +++ b/PROJECTS/encrypted-p2p-chat/.venv/bin/dmypy @@ -0,0 +1,8 @@ +#!/home/yoshi/dev/Cybersecurity-Projects/PROJECTS/encrypted-p2p-chat/.venv/bin/python +# -*- coding: utf-8 -*- +import re +import sys +from mypy.dmypy.client import console_entry +if __name__ == '__main__': + sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0]) + sys.exit(console_entry()) diff --git a/PROJECTS/encrypted-p2p-chat/.venv/bin/dotenv b/PROJECTS/encrypted-p2p-chat/.venv/bin/dotenv new file mode 100755 index 00000000..d89553ab --- /dev/null +++ b/PROJECTS/encrypted-p2p-chat/.venv/bin/dotenv @@ -0,0 +1,8 @@ +#!/home/yoshi/dev/Cybersecurity-Projects/PROJECTS/encrypted-p2p-chat/.venv/bin/python +# -*- coding: utf-8 -*- +import re +import sys +from dotenv.__main__ import cli +if __name__ == '__main__': + sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0]) + sys.exit(cli()) diff --git a/PROJECTS/encrypted-p2p-chat/.venv/bin/fastapi b/PROJECTS/encrypted-p2p-chat/.venv/bin/fastapi new file mode 100755 index 00000000..32a6ce9c --- /dev/null +++ b/PROJECTS/encrypted-p2p-chat/.venv/bin/fastapi @@ -0,0 +1,8 @@ +#!/home/yoshi/dev/Cybersecurity-Projects/PROJECTS/encrypted-p2p-chat/.venv/bin/python +# -*- coding: utf-8 -*- +import re +import sys +from fastapi.cli import main +if __name__ == '__main__': + sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0]) + sys.exit(main()) diff --git a/PROJECTS/encrypted-p2p-chat/.venv/bin/httpx b/PROJECTS/encrypted-p2p-chat/.venv/bin/httpx new file mode 100755 index 00000000..ae963741 --- /dev/null +++ b/PROJECTS/encrypted-p2p-chat/.venv/bin/httpx @@ -0,0 +1,8 @@ +#!/home/yoshi/dev/Cybersecurity-Projects/PROJECTS/encrypted-p2p-chat/.venv/bin/python +# -*- coding: utf-8 -*- +import re +import sys +from httpx import main +if __name__ == '__main__': + sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0]) + sys.exit(main()) diff --git a/PROJECTS/encrypted-p2p-chat/.venv/bin/identify-cli b/PROJECTS/encrypted-p2p-chat/.venv/bin/identify-cli new file mode 100755 index 00000000..63ad7578 --- /dev/null +++ b/PROJECTS/encrypted-p2p-chat/.venv/bin/identify-cli @@ -0,0 +1,8 @@ +#!/home/yoshi/dev/Cybersecurity-Projects/PROJECTS/encrypted-p2p-chat/.venv/bin/python +# -*- coding: utf-8 -*- +import re +import sys +from identify.cli import main +if __name__ == '__main__': + sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0]) + sys.exit(main()) diff --git a/PROJECTS/encrypted-p2p-chat/.venv/bin/mako-render b/PROJECTS/encrypted-p2p-chat/.venv/bin/mako-render new file mode 100755 index 00000000..ac7c5cd3 --- /dev/null +++ b/PROJECTS/encrypted-p2p-chat/.venv/bin/mako-render @@ -0,0 +1,8 @@ +#!/home/yoshi/dev/Cybersecurity-Projects/PROJECTS/encrypted-p2p-chat/.venv/bin/python +# -*- coding: utf-8 -*- +import re +import sys +from mako.cmd import cmdline +if __name__ == '__main__': + sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0]) + sys.exit(cmdline()) diff --git a/PROJECTS/encrypted-p2p-chat/.venv/bin/mypy b/PROJECTS/encrypted-p2p-chat/.venv/bin/mypy new file mode 100755 index 00000000..82faa570 --- /dev/null +++ b/PROJECTS/encrypted-p2p-chat/.venv/bin/mypy @@ -0,0 +1,8 @@ +#!/home/yoshi/dev/Cybersecurity-Projects/PROJECTS/encrypted-p2p-chat/.venv/bin/python +# -*- coding: utf-8 -*- +import re +import sys +from mypy.__main__ import console_entry +if __name__ == '__main__': + sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0]) + sys.exit(console_entry()) diff --git a/PROJECTS/encrypted-p2p-chat/.venv/bin/mypyc b/PROJECTS/encrypted-p2p-chat/.venv/bin/mypyc new file mode 100755 index 00000000..da6e582b --- /dev/null +++ b/PROJECTS/encrypted-p2p-chat/.venv/bin/mypyc @@ -0,0 +1,8 @@ +#!/home/yoshi/dev/Cybersecurity-Projects/PROJECTS/encrypted-p2p-chat/.venv/bin/python +# -*- coding: utf-8 -*- +import re +import sys +from mypyc.__main__ import main +if __name__ == '__main__': + sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0]) + sys.exit(main()) diff --git a/PROJECTS/encrypted-p2p-chat/.venv/bin/nodeenv b/PROJECTS/encrypted-p2p-chat/.venv/bin/nodeenv new file mode 100755 index 00000000..fb203022 --- /dev/null +++ b/PROJECTS/encrypted-p2p-chat/.venv/bin/nodeenv @@ -0,0 +1,8 @@ +#!/home/yoshi/dev/Cybersecurity-Projects/PROJECTS/encrypted-p2p-chat/.venv/bin/python +# -*- coding: utf-8 -*- +import re +import sys +from nodeenv import main +if __name__ == '__main__': + sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0]) + sys.exit(main()) diff --git a/PROJECTS/encrypted-p2p-chat/.venv/bin/normalizer b/PROJECTS/encrypted-p2p-chat/.venv/bin/normalizer new file mode 100755 index 00000000..6eea6de6 --- /dev/null +++ b/PROJECTS/encrypted-p2p-chat/.venv/bin/normalizer @@ -0,0 +1,8 @@ +#!/home/yoshi/dev/Cybersecurity-Projects/PROJECTS/encrypted-p2p-chat/.venv/bin/python +# -*- coding: utf-8 -*- +import re +import sys +from charset_normalizer.cli import cli_detect +if __name__ == '__main__': + sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0]) + sys.exit(cli_detect()) diff --git a/PROJECTS/encrypted-p2p-chat/.venv/bin/pip b/PROJECTS/encrypted-p2p-chat/.venv/bin/pip new file mode 100755 index 00000000..f6ea54bf --- /dev/null +++ b/PROJECTS/encrypted-p2p-chat/.venv/bin/pip @@ -0,0 +1,8 @@ +#!/home/yoshi/dev/Cybersecurity-Projects/PROJECTS/encrypted-p2p-chat/.venv/bin/python +# -*- coding: utf-8 -*- +import re +import sys +from pip._internal.cli.main import main +if __name__ == '__main__': + sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0]) + sys.exit(main()) diff --git a/PROJECTS/encrypted-p2p-chat/.venv/bin/pip3 b/PROJECTS/encrypted-p2p-chat/.venv/bin/pip3 new file mode 100755 index 00000000..f6ea54bf --- /dev/null +++ b/PROJECTS/encrypted-p2p-chat/.venv/bin/pip3 @@ -0,0 +1,8 @@ +#!/home/yoshi/dev/Cybersecurity-Projects/PROJECTS/encrypted-p2p-chat/.venv/bin/python +# -*- coding: utf-8 -*- +import re +import sys +from pip._internal.cli.main import main +if __name__ == '__main__': + sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0]) + sys.exit(main()) diff --git a/PROJECTS/encrypted-p2p-chat/.venv/bin/pip3.12 b/PROJECTS/encrypted-p2p-chat/.venv/bin/pip3.12 new file mode 100755 index 00000000..f6ea54bf --- /dev/null +++ b/PROJECTS/encrypted-p2p-chat/.venv/bin/pip3.12 @@ -0,0 +1,8 @@ +#!/home/yoshi/dev/Cybersecurity-Projects/PROJECTS/encrypted-p2p-chat/.venv/bin/python +# -*- coding: utf-8 -*- +import re +import sys +from pip._internal.cli.main import main +if __name__ == '__main__': + sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0]) + sys.exit(main()) diff --git a/PROJECTS/encrypted-p2p-chat/.venv/bin/pre-commit b/PROJECTS/encrypted-p2p-chat/.venv/bin/pre-commit new file mode 100755 index 00000000..1eac3c2b --- /dev/null +++ b/PROJECTS/encrypted-p2p-chat/.venv/bin/pre-commit @@ -0,0 +1,8 @@ +#!/home/yoshi/dev/Cybersecurity-Projects/PROJECTS/encrypted-p2p-chat/.venv/bin/python +# -*- coding: utf-8 -*- +import re +import sys +from pre_commit.main import main +if __name__ == '__main__': + sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0]) + sys.exit(main()) diff --git a/PROJECTS/encrypted-p2p-chat/.venv/bin/py.test b/PROJECTS/encrypted-p2p-chat/.venv/bin/py.test new file mode 100755 index 00000000..b69e9594 --- /dev/null +++ b/PROJECTS/encrypted-p2p-chat/.venv/bin/py.test @@ -0,0 +1,8 @@ +#!/home/yoshi/dev/Cybersecurity-Projects/PROJECTS/encrypted-p2p-chat/.venv/bin/python +# -*- coding: utf-8 -*- +import re +import sys +from pytest import console_main +if __name__ == '__main__': + sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0]) + sys.exit(console_main()) diff --git a/PROJECTS/encrypted-p2p-chat/.venv/bin/pygmentize b/PROJECTS/encrypted-p2p-chat/.venv/bin/pygmentize new file mode 100755 index 00000000..d7497938 --- /dev/null +++ b/PROJECTS/encrypted-p2p-chat/.venv/bin/pygmentize @@ -0,0 +1,8 @@ +#!/home/yoshi/dev/Cybersecurity-Projects/PROJECTS/encrypted-p2p-chat/.venv/bin/python +# -*- coding: utf-8 -*- +import re +import sys +from pygments.cmdline import main +if __name__ == '__main__': + sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0]) + sys.exit(main()) diff --git a/PROJECTS/encrypted-p2p-chat/.venv/bin/pytest b/PROJECTS/encrypted-p2p-chat/.venv/bin/pytest new file mode 100755 index 00000000..b69e9594 --- /dev/null +++ b/PROJECTS/encrypted-p2p-chat/.venv/bin/pytest @@ -0,0 +1,8 @@ +#!/home/yoshi/dev/Cybersecurity-Projects/PROJECTS/encrypted-p2p-chat/.venv/bin/python +# -*- coding: utf-8 -*- +import re +import sys +from pytest import console_main +if __name__ == '__main__': + sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0]) + sys.exit(console_main()) diff --git a/PROJECTS/encrypted-p2p-chat/.venv/bin/python b/PROJECTS/encrypted-p2p-chat/.venv/bin/python new file mode 120000 index 00000000..9dc388f7 --- /dev/null +++ b/PROJECTS/encrypted-p2p-chat/.venv/bin/python @@ -0,0 +1 @@ +/home/yoshi/.pyenv/versions/3.12.7/bin/python \ No newline at end of file diff --git a/PROJECTS/encrypted-p2p-chat/.venv/bin/python3 b/PROJECTS/encrypted-p2p-chat/.venv/bin/python3 new file mode 120000 index 00000000..d8654aa0 --- /dev/null +++ b/PROJECTS/encrypted-p2p-chat/.venv/bin/python3 @@ -0,0 +1 @@ +python \ No newline at end of file diff --git a/PROJECTS/encrypted-p2p-chat/.venv/bin/python3.12 b/PROJECTS/encrypted-p2p-chat/.venv/bin/python3.12 new file mode 120000 index 00000000..d8654aa0 --- /dev/null +++ b/PROJECTS/encrypted-p2p-chat/.venv/bin/python3.12 @@ -0,0 +1 @@ +python \ No newline at end of file diff --git a/PROJECTS/encrypted-p2p-chat/.venv/bin/ruff b/PROJECTS/encrypted-p2p-chat/.venv/bin/ruff new file mode 100755 index 00000000..098fc345 Binary files /dev/null and b/PROJECTS/encrypted-p2p-chat/.venv/bin/ruff differ diff --git a/PROJECTS/encrypted-p2p-chat/.venv/bin/stubgen b/PROJECTS/encrypted-p2p-chat/.venv/bin/stubgen new file mode 100755 index 00000000..f1f860a2 --- /dev/null +++ b/PROJECTS/encrypted-p2p-chat/.venv/bin/stubgen @@ -0,0 +1,8 @@ +#!/home/yoshi/dev/Cybersecurity-Projects/PROJECTS/encrypted-p2p-chat/.venv/bin/python +# -*- coding: utf-8 -*- +import re +import sys +from mypy.stubgen import main +if __name__ == '__main__': + sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0]) + sys.exit(main()) diff --git a/PROJECTS/encrypted-p2p-chat/.venv/bin/stubtest b/PROJECTS/encrypted-p2p-chat/.venv/bin/stubtest new file mode 100755 index 00000000..a9819afb --- /dev/null +++ b/PROJECTS/encrypted-p2p-chat/.venv/bin/stubtest @@ -0,0 +1,8 @@ +#!/home/yoshi/dev/Cybersecurity-Projects/PROJECTS/encrypted-p2p-chat/.venv/bin/python +# -*- coding: utf-8 -*- +import re +import sys +from mypy.stubtest import main +if __name__ == '__main__': + sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0]) + sys.exit(main()) diff --git a/PROJECTS/encrypted-p2p-chat/.venv/bin/uvicorn b/PROJECTS/encrypted-p2p-chat/.venv/bin/uvicorn new file mode 100755 index 00000000..db23e2d9 --- /dev/null +++ b/PROJECTS/encrypted-p2p-chat/.venv/bin/uvicorn @@ -0,0 +1,8 @@ +#!/home/yoshi/dev/Cybersecurity-Projects/PROJECTS/encrypted-p2p-chat/.venv/bin/python +# -*- coding: utf-8 -*- +import re +import sys +from uvicorn.main import main +if __name__ == '__main__': + sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0]) + sys.exit(main()) diff --git a/PROJECTS/encrypted-p2p-chat/.venv/bin/virtualenv b/PROJECTS/encrypted-p2p-chat/.venv/bin/virtualenv new file mode 100755 index 00000000..5c30fc5f --- /dev/null +++ b/PROJECTS/encrypted-p2p-chat/.venv/bin/virtualenv @@ -0,0 +1,8 @@ +#!/home/yoshi/dev/Cybersecurity-Projects/PROJECTS/encrypted-p2p-chat/.venv/bin/python +# -*- coding: utf-8 -*- +import re +import sys +from virtualenv.__main__ import run_with_catch +if __name__ == '__main__': + sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0]) + sys.exit(run_with_catch()) diff --git a/PROJECTS/encrypted-p2p-chat/.venv/bin/watchfiles b/PROJECTS/encrypted-p2p-chat/.venv/bin/watchfiles new file mode 100755 index 00000000..d417051c --- /dev/null +++ b/PROJECTS/encrypted-p2p-chat/.venv/bin/watchfiles @@ -0,0 +1,8 @@ +#!/home/yoshi/dev/Cybersecurity-Projects/PROJECTS/encrypted-p2p-chat/.venv/bin/python +# -*- coding: utf-8 -*- +import re +import sys +from watchfiles.cli import cli +if __name__ == '__main__': + sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0]) + sys.exit(cli()) diff --git a/PROJECTS/encrypted-p2p-chat/.venv/bin/websockets b/PROJECTS/encrypted-p2p-chat/.venv/bin/websockets new file mode 100755 index 00000000..88ae427f --- /dev/null +++ b/PROJECTS/encrypted-p2p-chat/.venv/bin/websockets @@ -0,0 +1,8 @@ +#!/home/yoshi/dev/Cybersecurity-Projects/PROJECTS/encrypted-p2p-chat/.venv/bin/python +# -*- coding: utf-8 -*- +import re +import sys +from websockets.cli import main +if __name__ == '__main__': + sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0]) + sys.exit(main()) diff --git a/PROJECTS/encrypted-p2p-chat/.venv/bin/yapf b/PROJECTS/encrypted-p2p-chat/.venv/bin/yapf new file mode 100755 index 00000000..8229a3d1 --- /dev/null +++ b/PROJECTS/encrypted-p2p-chat/.venv/bin/yapf @@ -0,0 +1,8 @@ +#!/home/yoshi/dev/Cybersecurity-Projects/PROJECTS/encrypted-p2p-chat/.venv/bin/python +# -*- coding: utf-8 -*- +import re +import sys +from yapf import run_main +if __name__ == '__main__': + sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0]) + sys.exit(run_main()) diff --git a/PROJECTS/encrypted-p2p-chat/.venv/bin/yapf-diff b/PROJECTS/encrypted-p2p-chat/.venv/bin/yapf-diff new file mode 100755 index 00000000..3dc241bd --- /dev/null +++ b/PROJECTS/encrypted-p2p-chat/.venv/bin/yapf-diff @@ -0,0 +1,8 @@ +#!/home/yoshi/dev/Cybersecurity-Projects/PROJECTS/encrypted-p2p-chat/.venv/bin/python +# -*- coding: utf-8 -*- +import re +import sys +from yapf_third_party.yapf_diff.yapf_diff import main +if __name__ == '__main__': + sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0]) + sys.exit(main()) diff --git a/PROJECTS/encrypted-p2p-chat/.venv/include/site/python3.12/greenlet/greenlet.h b/PROJECTS/encrypted-p2p-chat/.venv/include/site/python3.12/greenlet/greenlet.h new file mode 100644 index 00000000..d02a16e4 --- /dev/null +++ b/PROJECTS/encrypted-p2p-chat/.venv/include/site/python3.12/greenlet/greenlet.h @@ -0,0 +1,164 @@ +/* -*- indent-tabs-mode: nil; tab-width: 4; -*- */ + +/* Greenlet object interface */ + +#ifndef Py_GREENLETOBJECT_H +#define Py_GREENLETOBJECT_H + + +#include + +#ifdef __cplusplus +extern "C" { +#endif + +/* This is deprecated and undocumented. It does not change. */ +#define GREENLET_VERSION "1.0.0" + +#ifndef GREENLET_MODULE +#define implementation_ptr_t void* +#endif + +typedef struct _greenlet { + PyObject_HEAD + PyObject* weakreflist; + PyObject* dict; + implementation_ptr_t pimpl; +} PyGreenlet; + +#define PyGreenlet_Check(op) (op && PyObject_TypeCheck(op, &PyGreenlet_Type)) + + +/* C API functions */ + +/* Total number of symbols that are exported */ +#define PyGreenlet_API_pointers 12 + +#define PyGreenlet_Type_NUM 0 +#define PyExc_GreenletError_NUM 1 +#define PyExc_GreenletExit_NUM 2 + +#define PyGreenlet_New_NUM 3 +#define PyGreenlet_GetCurrent_NUM 4 +#define PyGreenlet_Throw_NUM 5 +#define PyGreenlet_Switch_NUM 6 +#define PyGreenlet_SetParent_NUM 7 + +#define PyGreenlet_MAIN_NUM 8 +#define PyGreenlet_STARTED_NUM 9 +#define PyGreenlet_ACTIVE_NUM 10 +#define PyGreenlet_GET_PARENT_NUM 11 + +#ifndef GREENLET_MODULE +/* This section is used by modules that uses the greenlet C API */ +static void** _PyGreenlet_API = NULL; + +# define PyGreenlet_Type \ + (*(PyTypeObject*)_PyGreenlet_API[PyGreenlet_Type_NUM]) + +# define PyExc_GreenletError \ + ((PyObject*)_PyGreenlet_API[PyExc_GreenletError_NUM]) + +# define PyExc_GreenletExit \ + ((PyObject*)_PyGreenlet_API[PyExc_GreenletExit_NUM]) + +/* + * PyGreenlet_New(PyObject *args) + * + * greenlet.greenlet(run, parent=None) + */ +# define PyGreenlet_New \ + (*(PyGreenlet * (*)(PyObject * run, PyGreenlet * parent)) \ + _PyGreenlet_API[PyGreenlet_New_NUM]) + +/* + * PyGreenlet_GetCurrent(void) + * + * greenlet.getcurrent() + */ +# define PyGreenlet_GetCurrent \ + (*(PyGreenlet * (*)(void)) _PyGreenlet_API[PyGreenlet_GetCurrent_NUM]) + +/* + * PyGreenlet_Throw( + * PyGreenlet *greenlet, + * PyObject *typ, + * PyObject *val, + * PyObject *tb) + * + * g.throw(...) + */ +# define PyGreenlet_Throw \ + (*(PyObject * (*)(PyGreenlet * self, \ + PyObject * typ, \ + PyObject * val, \ + PyObject * tb)) \ + _PyGreenlet_API[PyGreenlet_Throw_NUM]) + +/* + * PyGreenlet_Switch(PyGreenlet *greenlet, PyObject *args) + * + * g.switch(*args, **kwargs) + */ +# define PyGreenlet_Switch \ + (*(PyObject * \ + (*)(PyGreenlet * greenlet, PyObject * args, PyObject * kwargs)) \ + _PyGreenlet_API[PyGreenlet_Switch_NUM]) + +/* + * PyGreenlet_SetParent(PyObject *greenlet, PyObject *new_parent) + * + * g.parent = new_parent + */ +# define PyGreenlet_SetParent \ + (*(int (*)(PyGreenlet * greenlet, PyGreenlet * nparent)) \ + _PyGreenlet_API[PyGreenlet_SetParent_NUM]) + +/* + * PyGreenlet_GetParent(PyObject* greenlet) + * + * return greenlet.parent; + * + * This could return NULL even if there is no exception active. + * If it does not return NULL, you are responsible for decrementing the + * reference count. + */ +# define PyGreenlet_GetParent \ + (*(PyGreenlet* (*)(PyGreenlet*)) \ + _PyGreenlet_API[PyGreenlet_GET_PARENT_NUM]) + +/* + * deprecated, undocumented alias. + */ +# define PyGreenlet_GET_PARENT PyGreenlet_GetParent + +# define PyGreenlet_MAIN \ + (*(int (*)(PyGreenlet*)) \ + _PyGreenlet_API[PyGreenlet_MAIN_NUM]) + +# define PyGreenlet_STARTED \ + (*(int (*)(PyGreenlet*)) \ + _PyGreenlet_API[PyGreenlet_STARTED_NUM]) + +# define PyGreenlet_ACTIVE \ + (*(int (*)(PyGreenlet*)) \ + _PyGreenlet_API[PyGreenlet_ACTIVE_NUM]) + + + + +/* Macro that imports greenlet and initializes C API */ +/* NOTE: This has actually moved to ``greenlet._greenlet._C_API``, but we + keep the older definition to be sure older code that might have a copy of + the header still works. */ +# define PyGreenlet_Import() \ + { \ + _PyGreenlet_API = (void**)PyCapsule_Import("greenlet._C_API", 0); \ + } + +#endif /* GREENLET_MODULE */ + +#ifdef __cplusplus +} +#endif +#endif /* !Py_GREENLETOBJECT_H */ diff --git a/PROJECTS/encrypted-p2p-chat/.venv/lib64 b/PROJECTS/encrypted-p2p-chat/.venv/lib64 new file mode 120000 index 00000000..7951405f --- /dev/null +++ b/PROJECTS/encrypted-p2p-chat/.venv/lib64 @@ -0,0 +1 @@ +lib \ No newline at end of file diff --git a/PROJECTS/encrypted-p2p-chat/.venv/pyvenv.cfg b/PROJECTS/encrypted-p2p-chat/.venv/pyvenv.cfg new file mode 100644 index 00000000..e0a444cc --- /dev/null +++ b/PROJECTS/encrypted-p2p-chat/.venv/pyvenv.cfg @@ -0,0 +1,5 @@ +home = /home/yoshi/.pyenv/versions/3.12.7/bin +include-system-site-packages = false +version = 3.12.7 +executable = /home/yoshi/.pyenv/versions/3.12.7/bin/python3.12 +command = /home/yoshi/.pyenv/versions/3.12.7/bin/python -m venv /home/yoshi/dev/Cybersecurity-Projects/PROJECTS/encrypted-p2p-chat/.venv diff --git a/PROJECTS/encrypted-p2p-chat/Makefile b/PROJECTS/encrypted-p2p-chat/Makefile new file mode 100644 index 00000000..a6612be6 --- /dev/null +++ b/PROJECTS/encrypted-p2p-chat/Makefile @@ -0,0 +1,110 @@ +# ⒸAngelaMos | 2025 +# Makefile + +.PHONY: help setup setup-backend setup-frontend env dev prod build-dev build-prod up-dev up-prod down-dev down-prod logs-dev logs-prod test-backend clean + +help: + @echo "Encrypted P2P Chat - Makefile Commands" + @echo "" + @echo "Setup:" + @echo " make setup - Complete project setup (backend + frontend)" + @echo " make setup-backend - Setup backend (venv, install deps)" + @echo " make setup-frontend - Setup frontend (install npm deps)" + @echo " make env - Copy .env.example to .env files" + @echo "" + @echo "Development:" + @echo " make dev - Start development environment" + @echo " make build-dev - Build development Docker images" + @echo " make up-dev - Start development containers" + @echo " make down-dev - Stop development containers" + @echo " make logs-dev - Follow development logs" + @echo "" + @echo "Production:" + @echo " make prod - Start production environment" + @echo " make build-prod - Build production Docker images" + @echo " make up-prod - Start production containers" + @echo " make down-prod - Stop production containers" + @echo " make logs-prod - Follow production logs" + @echo "" + @echo "Testing:" + @echo " make test-backend - Run backend tests" + @echo "" + @echo "Cleanup:" + @echo " make clean - Remove all containers, volumes, and build artifacts" + +setup: setup-backend setup-frontend env + @echo "Setup complete!" + +setup-backend: + @echo "Setting up backend..." + cd backend && python3 -m venv ../.venv + . .venv/bin/activate && cd backend && pip install -e .[dev] + @echo "Backend setup complete!" + +setup-frontend: + @echo "Setting up frontend..." + cd frontend && npm install + @echo "Frontend setup complete!" + +env: + @echo "Creating .env files..." + @if [ ! -f .env ]; then cp .env.example .env; echo "Created root .env"; fi + @if [ ! -f frontend/.env ]; then cp frontend/.env.example frontend/.env; echo "Created frontend/.env"; fi + @echo ".env files created! Please update with your values." + +dev: build-dev up-dev + +build-dev: + @echo "Building development Docker images..." + docker compose -f docker-compose.dev.yml build + +up-dev: + @echo "Starting development environment..." + docker compose -f docker-compose.dev.yml up -d + @echo "Development environment started!" + @echo "Frontend: http://localhost:5173" + @echo "Backend: http://localhost:8000" + @echo "Nginx: http://localhost" + +down-dev: + @echo "Stopping development environment..." + docker compose -f docker-compose.dev.yml down + +logs-dev: + docker compose -f docker-compose.dev.yml logs -f + +prod: build-prod up-prod + +build-prod: + @echo "Building production Docker images..." + docker compose -f docker-compose.prod.yml build + +up-prod: + @echo "Starting production environment..." + docker compose -f docker-compose.prod.yml up -d + @echo "Production environment started!" + @echo "Application: http://localhost" + +down-prod: + @echo "Stopping production environment..." + docker compose -f docker-compose.prod.yml down + +logs-prod: + docker compose -f docker-compose.prod.yml logs -f + +test-backend: + @echo "Running backend tests..." + . .venv/bin/activate && cd backend && python -m pytest tests/ -v + +clean: + @echo "Cleaning up..." + docker compose -f docker-compose.dev.yml down -v + docker compose -f docker-compose.prod.yml down -v + rm -rf frontend/node_modules + rm -rf frontend/dist + rm -rf backend/.venv + rm -rf backend/__pycache__ + rm -rf backend/.pytest_cache + rm -rf backend/.mypy_cache + rm -rf backend/.ruff_cache + @echo "Cleanup complete!" diff --git a/PROJECTS/encrypted-p2p-chat/README.md b/PROJECTS/encrypted-p2p-chat/README.md new file mode 100644 index 00000000..85dff8c2 --- /dev/null +++ b/PROJECTS/encrypted-p2p-chat/README.md @@ -0,0 +1,240 @@ +# Encrypted P2P Chat + +End-to-end encrypted P2P chat application with Signal Protocol (Double Ratchet + X3DH) and WebAuthn/Passkeys authentication. + +## Tech Stack + +### Backend +- **FastAPI** - Modern Python web framework +- **PostgreSQL + SQLModel** - User and credential storage +- **SurrealDB** - Real-time messaging with live queries +- **Redis** - Challenge storage and caching +- **Double Ratchet + X3DH** - Signal Protocol encryption +- **WebAuthn** - Passwordless authentication + +### Frontend +- **SolidJS 1.9** - Fine-grained reactive UI +- **TypeScript** - Type safety +- **Vite 6** - Modern build tool +- **Tailwind CSS v4** - Utility-first CSS +- **@tanstack/solid-query** - Data fetching + +### Infrastructure +- **Docker Compose** - Service orchestration +- **Nginx** - Reverse proxy +- **Makefile** - Development automation + +## Quick Start + +### Prerequisites + +- Docker and Docker Compose +- **Node.js 20.19+ or 22.12+** (required for Vite 7) +- **Python 3.13+** (latest stable) +- Make + +### Setup + +1. Clone the repository + +2. Create environment files: +```bash +make env +``` + +This creates: +- `.env` (root) - Used by backend and docker-compose +- `frontend/.env` - Used by Vite frontend + +3. Update `.env` files with your configuration + +4. Run development environment: +```bash +make dev +``` + +The application will be available at: +- **Frontend**: http://localhost:3000 (Vite dev server) +- **Backend**: http://localhost:8000 (FastAPI) +- **Nginx**: http://localhost (proxies to frontend/backend) + +### Development Commands + +```bash +make help # Show all commands +make setup # Complete project setup +make dev # Start development environment +make logs-dev # Follow development logs +make down-dev # Stop development environment +make test-backend # Run backend tests +make clean # Clean all artifacts +``` + +### Production Commands + +```bash +make build-prod # Build production images +make prod # Start production environment +make logs-prod # Follow production logs +make down-prod # Stop production environment +``` + +## Project Structure + +``` +encrypted-p2p-chat/ +├── backend/ +│ ├── app/ +│ │ ├── api/ # API endpoints +│ │ │ ├── auth.py # WebAuthn authentication +│ │ │ ├── encryption.py # Prekey bundle endpoints +│ │ │ └── websocket.py # WebSocket endpoint +│ │ ├── core/ +│ │ │ ├── encryption/ +│ │ │ │ ├── x3dh_manager.py # X3DH key exchange +│ │ │ │ └── double_ratchet.py # Double Ratchet engine +│ │ │ ├── passkey/ +│ │ │ │ └── passkey_manager.py # WebAuthn manager +│ │ │ ├── exceptions.py # Custom exceptions +│ │ │ ├── redis_manager.py # Redis client +│ │ │ ├── surreal_manager.py # SurrealDB client +│ │ │ └── websocket_manager.py # WebSocket connections +│ │ ├── models/ # SQLModel database models +│ │ ├── schemas/ # Pydantic schemas +│ │ ├── services/ # Business logic layer +│ │ ├── config.py # Configuration and constants +│ │ ├── factory.py # FastAPI app factory +│ │ └── main.py # Entry point +│ ├── tests/ # Pytest tests +│ ├── Dockerfile # Production +│ ├── Dockerfile.dev # Development +│ └── pyproject.toml +├── frontend/ +│ ├── src/ +│ │ ├── pages/ # SolidJS pages +│ │ ├── App.tsx # Root component with routes +│ │ ├── index.tsx # Entry point +│ │ ├── index.css # Tailwind imports +│ │ └── config.ts # Constants +│ ├── public/ +│ │ └── index.html +│ ├── Dockerfile # Production +│ ├── Dockerfile.dev # Development +│ ├── vite.config.ts +│ ├── tsconfig.json +│ └── package.json +├── nginx/ +│ ├── nginx.dev.conf # Development config +│ ├── nginx.prod.conf # Production config +│ └── Dockerfile +├── docker-compose.yml # Production +├── docker-compose.dev.yml # Development +├── Makefile +└── .env.example + +## Features + +### Authentication +- Passwordless login with WebAuthn/Passkeys +- Discoverable credentials (device-based auth) +- Multi-device support +- Signature counter verification + +### Encryption +- Double Ratchet protocol (Signal) +- X3DH key exchange for async messaging +- Forward secrecy +- Break-in recovery +- Out-of-order message handling + +### Real-time Messaging +- WebSocket connections +- SurrealDB live queries +- Online/offline presence +- Typing indicators +- Read receipts +- Heartbeat keep-alive + +## Development + +### Backend Development + +```bash +cd backend +python -m venv ../.venv +source ../.venv/bin/activate +pip install -e .[dev] +python -m pytest tests/ -v +``` + +### Frontend Development + +```bash +cd frontend +npm install +npm run dev +npm run typecheck +npm run lint +``` + +## Testing + +### Backend Tests + +```bash +make test-backend +``` + +Or manually: + +```bash +cd backend +python -m pytest tests/ -v +``` + +## Environment Variables + +See `.env.example` files for all configuration options. + +Required variables: +- `SECRET_KEY` - Application secret key +- `POSTGRES_PASSWORD` - PostgreSQL password +- `SURREAL_PASSWORD` - SurrealDB password + +## Architecture + +### Backend Architecture + +``` +API Endpoints (thin routes) + ↓ +Services (business logic) + ↓ +Models (database) + ↓ +PostgreSQL / SurrealDB / Redis +``` + +### Encryption Flow + +``` +X3DH Key Exchange + ↓ +Shared Secret + ↓ +Double Ratchet Initialization + ↓ +Per-Message Encryption (AES-256-GCM) +``` + +### WebSocket Flow + +``` +Client → WebSocket → Connection Manager → Service Layer → SurrealDB + ↓ + Live Queries → Broadcast +``` + +## License + +MIT diff --git a/PROJECTS/encrypted-p2p-chat/backend/.dockerignore b/PROJECTS/encrypted-p2p-chat/backend/.dockerignore new file mode 100644 index 00000000..4310a41a --- /dev/null +++ b/PROJECTS/encrypted-p2p-chat/backend/.dockerignore @@ -0,0 +1,30 @@ +__pycache__ +*.pyc +*.pyo +*.pyd +.Python +*.so +*.egg +*.egg-info +dist +build +.env +.env.* +!.env.example +.venv +venv +ENV +env +.pytest_cache +.mypy_cache +.ruff_cache +.coverage +htmlcov +*.log +.git +.gitignore +README.md +*.md +tests +.vscode +.idea diff --git a/PROJECTS/encrypted-p2p-chat/backend/.style.yapf b/PROJECTS/encrypted-p2p-chat/backend/.style.yapf new file mode 100755 index 00000000..e8e673b4 --- /dev/null +++ b/PROJECTS/encrypted-p2p-chat/backend/.style.yapf @@ -0,0 +1,46 @@ +[style] +based_on_style = pep8 +column_limit = 82 +indent_width = 4 +continuation_indent_width = 4 +indent_closing_brackets = false +dedent_closing_brackets = true +indent_blank_lines = false +spaces_before_comment = 2 +spaces_around_power_operator = false +spaces_around_default_or_named_assign = true +space_between_ending_comma_and_closing_bracket = false +space_inside_brackets = false +spaces_around_subscript_colon = true +blank_line_before_nested_class_or_def = false +blank_line_before_class_docstring = false +blank_lines_around_top_level_definition = 2 +blank_lines_between_top_level_imports_and_variables = 2 +blank_line_before_module_docstring = false +split_before_logical_operator = true +split_before_first_argument = true +split_before_named_assigns = true +split_complex_comprehension = true +split_before_expression_after_opening_paren = false +split_before_closing_bracket = true +split_all_comma_separated_values = true +split_all_top_level_comma_separated_values = false +coalesce_brackets = false +each_dict_entry_on_separate_line = true +allow_multiline_lambdas = false +allow_multiline_dictionary_keys = false +split_penalty_import_names = 0 +join_multiple_lines = false +align_closing_bracket_with_visual_indent = true +arithmetic_precedence_indication = false +split_penalty_for_added_line_split = 275 +use_tabs = false +split_before_dot = false +split_arguments_when_comma_terminated = true +i18n_function_call = ['_', 'N_', 'gettext', 'ngettext'] +i18n_comment = ['# Translators:', '# i18n:'] +split_penalty_comprehension = 80 +split_penalty_after_opening_bracket = 280 +split_penalty_before_if_expr = 0 +split_penalty_bitwise_operator = 290 +split_penalty_logical_operator = 0 diff --git a/PROJECTS/encrypted-p2p-chat/backend/alembic.ini b/PROJECTS/encrypted-p2p-chat/backend/alembic.ini new file mode 100644 index 00000000..7f7f01de --- /dev/null +++ b/PROJECTS/encrypted-p2p-chat/backend/alembic.ini @@ -0,0 +1,147 @@ +# A generic, single database configuration. + +[alembic] +# path to migration scripts. +# this is typically a path given in POSIX (e.g. forward slashes) +# format, relative to the token %(here)s which refers to the location of this +# ini file +script_location = %(here)s/alembic + +# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s +# Uncomment the line below if you want the files to be prepended with date and time +# see https://alembic.sqlalchemy.org/en/latest/tutorial.html#editing-the-ini-file +# for all available tokens +# file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s + +# sys.path path, will be prepended to sys.path if present. +# defaults to the current working directory. for multiple paths, the path separator +# is defined by "path_separator" below. +prepend_sys_path = . + + +# timezone to use when rendering the date within the migration file +# as well as the filename. +# If specified, requires the tzdata library which can be installed by adding +# `alembic[tz]` to the pip requirements. +# string value is passed to ZoneInfo() +# leave blank for localtime +# timezone = + +# max length of characters to apply to the "slug" field +# truncate_slug_length = 40 + +# set to 'true' to run the environment during +# the 'revision' command, regardless of autogenerate +# revision_environment = false + +# set to 'true' to allow .pyc and .pyo files without +# a source .py file to be detected as revisions in the +# versions/ directory +# sourceless = false + +# version location specification; This defaults +# to /versions. When using multiple version +# directories, initial revisions must be specified with --version-path. +# The path separator used here should be the separator specified by "path_separator" +# below. +# version_locations = %(here)s/bar:%(here)s/bat:%(here)s/alembic/versions + +# path_separator; This indicates what character is used to split lists of file +# paths, including version_locations and prepend_sys_path within configparser +# files such as alembic.ini. +# The default rendered in new alembic.ini files is "os", which uses os.pathsep +# to provide os-dependent path splitting. +# +# Note that in order to support legacy alembic.ini files, this default does NOT +# take place if path_separator is not present in alembic.ini. If this +# option is omitted entirely, fallback logic is as follows: +# +# 1. Parsing of the version_locations option falls back to using the legacy +# "version_path_separator" key, which if absent then falls back to the legacy +# behavior of splitting on spaces and/or commas. +# 2. Parsing of the prepend_sys_path option falls back to the legacy +# behavior of splitting on spaces, commas, or colons. +# +# Valid values for path_separator are: +# +# path_separator = : +# path_separator = ; +# path_separator = space +# path_separator = newline +# +# Use os.pathsep. Default configuration used for new projects. +path_separator = os + +# set to 'true' to search source files recursively +# in each "version_locations" directory +# new in Alembic version 1.10 +# recursive_version_locations = false + +# the output encoding used when revision files +# are written from script.py.mako +# output_encoding = utf-8 + +# database URL. This is consumed by the user-maintained env.py script only. +# other means of configuring database URLs may be customized within the env.py +# file. +# sqlalchemy.url = driver://user:pass@localhost/dbname + + +[post_write_hooks] +# post_write_hooks defines scripts or Python functions that are run +# on newly generated revision scripts. See the documentation for further +# detail and examples + +# format using "black" - use the console_scripts runner, against the "black" entrypoint +# hooks = black +# black.type = console_scripts +# black.entrypoint = black +# black.options = -l 79 REVISION_SCRIPT_FILENAME + +# lint with attempts to fix using "ruff" - use the module runner, against the "ruff" module +# hooks = ruff +# ruff.type = module +# ruff.module = ruff +# ruff.options = check --fix REVISION_SCRIPT_FILENAME + +# Alternatively, use the exec runner to execute a binary found on your PATH +# hooks = ruff +# ruff.type = exec +# ruff.executable = ruff +# ruff.options = check --fix REVISION_SCRIPT_FILENAME + +# Logging configuration. This is also consumed by the user-maintained +# env.py script only. +[loggers] +keys = root,sqlalchemy,alembic + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = WARNING +handlers = console +qualname = + +[logger_sqlalchemy] +level = WARNING +handlers = +qualname = sqlalchemy.engine + +[logger_alembic] +level = INFO +handlers = +qualname = alembic + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = NOTSET +formatter = generic + +[formatter_generic] +format = %(levelname)-5.5s [%(name)s] %(message)s +datefmt = %H:%M:%S diff --git a/PROJECTS/encrypted-p2p-chat/backend/alembic/README b/PROJECTS/encrypted-p2p-chat/backend/alembic/README new file mode 100644 index 00000000..8e7fd3cd --- /dev/null +++ b/PROJECTS/encrypted-p2p-chat/backend/alembic/README @@ -0,0 +1 @@ +Generic single database configuration diff --git a/PROJECTS/encrypted-p2p-chat/backend/alembic/env.py b/PROJECTS/encrypted-p2p-chat/backend/alembic/env.py new file mode 100644 index 00000000..7a95d325 --- /dev/null +++ b/PROJECTS/encrypted-p2p-chat/backend/alembic/env.py @@ -0,0 +1,73 @@ +""" +ⒸAngelaMos | 2025 +Alembic environment configuration for SQLModel migrations +""" + +from logging.config import fileConfig + +from sqlalchemy import engine_from_config, pool +from sqlmodel import SQLModel + +from alembic import context +from app.config import settings + +# Import all models so they're registered with SQLModel metadata +from app.models.User import User # noqa: F401 +from app.models.Credential import Credential # noqa: F401 +from app.models.IdentityKey import IdentityKey # noqa: F401 +from app.models.SignedPrekey import SignedPrekey # noqa: F401 +from app.models.OneTimePrekey import OneTimePrekey # noqa: F401 +from app.models.RatchetState import RatchetState # noqa: F401 +from app.models.SkippedMessageKey import SkippedMessageKey # noqa: F401 + + +config = context.config + +if config.config_file_name is not None: + fileConfig(config.config_file_name) + +target_metadata = SQLModel.metadata + +config.set_main_option("sqlalchemy.url", str(settings.DATABASE_URL)) + + +def run_migrations_offline() -> None: + """ + Run migrations in offline mode + """ + url = config.get_main_option("sqlalchemy.url") + context.configure( + url = url, + target_metadata = target_metadata, + literal_binds = True, + dialect_opts = {"paramstyle": "named"}, + ) + + with context.begin_transaction(): + context.run_migrations() + + +def run_migrations_online() -> None: + """ + Run migrations in online mode + """ + connectable = engine_from_config( + config.get_section(config.config_ini_section, {}), + prefix = "sqlalchemy.", + poolclass = pool.NullPool, + ) + + with connectable.connect() as connection: + context.configure( + connection = connection, + target_metadata = target_metadata + ) + + with context.begin_transaction(): + context.run_migrations() + + +if context.is_offline_mode(): + run_migrations_offline() +else: + run_migrations_online() diff --git a/PROJECTS/encrypted-p2p-chat/backend/alembic/script.py.mako b/PROJECTS/encrypted-p2p-chat/backend/alembic/script.py.mako new file mode 100644 index 00000000..11016301 --- /dev/null +++ b/PROJECTS/encrypted-p2p-chat/backend/alembic/script.py.mako @@ -0,0 +1,28 @@ +"""${message} + +Revision ID: ${up_revision} +Revises: ${down_revision | comma,n} +Create Date: ${create_date} + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +${imports if imports else ""} + +# revision identifiers, used by Alembic. +revision: str = ${repr(up_revision)} +down_revision: Union[str, Sequence[str], None] = ${repr(down_revision)} +branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)} +depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)} + + +def upgrade() -> None: + """Upgrade schema.""" + ${upgrades if upgrades else "pass"} + + +def downgrade() -> None: + """Downgrade schema.""" + ${downgrades if downgrades else "pass"} diff --git a/PROJECTS/encrypted-p2p-chat/backend/app/__init__.py b/PROJECTS/encrypted-p2p-chat/backend/app/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/PROJECTS/encrypted-p2p-chat/backend/app/api/__init__.py b/PROJECTS/encrypted-p2p-chat/backend/app/api/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/PROJECTS/encrypted-p2p-chat/backend/app/api/auth.py b/PROJECTS/encrypted-p2p-chat/backend/app/api/auth.py new file mode 100644 index 00000000..341890ce --- /dev/null +++ b/PROJECTS/encrypted-p2p-chat/backend/app/api/auth.py @@ -0,0 +1,103 @@ +""" +ⒸAngelaMos | 2025 +WebAuthn passkey registration and login API +""" + +import logging +from typing import Any + +from fastapi import APIRouter, Depends, status +from sqlmodel.ext.asyncio.session import AsyncSession + +from app.models.Base import get_session +from app.schemas.auth import ( + AuthenticationBeginRequest, + AuthenticationCompleteRequest, + RegistrationBeginRequest, + RegistrationCompleteRequest, + UserResponse, + UserSearchRequest, + UserSearchResponse, +) +from app.services.auth_service import auth_service + + +logger = logging.getLogger(__name__) + + +router = APIRouter(prefix = "/auth", tags = ["authentication"]) + + +@router.post("/register/begin", status_code = status.HTTP_200_OK) +async def register_begin( + request: RegistrationBeginRequest, + session: AsyncSession = Depends(get_session), +) -> dict[str, + Any]: + """ + Begin WebAuthn passkey registration flow + """ + return await auth_service.begin_registration(session, request) + + +@router.post("/register/complete", status_code = status.HTTP_201_CREATED) +async def register_complete( + request: RegistrationCompleteRequest, + session: AsyncSession = Depends(get_session), +) -> UserResponse: + """ + Complete WebAuthn passkey registration + """ + return await auth_service.complete_registration(session, request, request.username) + + +@router.post("/authenticate/begin", status_code = status.HTTP_200_OK) +async def authenticate_begin( + request: AuthenticationBeginRequest, + session: AsyncSession = Depends(get_session), +) -> dict[str, + Any]: + """ + Begin WebAuthn passkey authentication flow + """ + return await auth_service.begin_authentication(session, request) + + +@router.post("/authenticate/complete", status_code = status.HTTP_200_OK) +async def authenticate_complete( + request: AuthenticationCompleteRequest, + session: AsyncSession = Depends(get_session), +) -> UserResponse: + """ + Complete WebAuthn passkey authentication + """ + return await auth_service.complete_authentication(session, request) + + +@router.post("/users/search", status_code = status.HTTP_200_OK) +async def search_users( + request: UserSearchRequest, + session: AsyncSession = Depends(get_session), +) -> UserSearchResponse: + """ + Search for users by username or display name + """ + users = await auth_service.search_users( + session, + request.query, + request.limit, + ) + + return UserSearchResponse( + users = [ + UserResponse( + id = str(user.id), + username = user.username, + display_name = user.display_name, + is_active = user.is_active, + is_verified = user.is_verified, + created_at = user.created_at.isoformat(), + ) + for user in users + ] + ) diff --git a/PROJECTS/encrypted-p2p-chat/backend/app/api/encryption.py b/PROJECTS/encrypted-p2p-chat/backend/app/api/encryption.py new file mode 100644 index 00000000..ad28e0de --- /dev/null +++ b/PROJECTS/encrypted-p2p-chat/backend/app/api/encryption.py @@ -0,0 +1,89 @@ +""" +ⒸAngelaMos | 2025 +Encryption endpoints for X3DH prekey bundles +""" + +import logging +from uuid import UUID + +from fastapi import APIRouter, Depends, status +from sqlmodel.ext.asyncio.session import AsyncSession + +from app.models.Base import get_session +from app.services.prekey_service import prekey_service +from app.core.encryption.x3dh_manager import PreKeyBundle + + +logger = logging.getLogger(__name__) + +router = APIRouter(prefix = "/encryption", tags = ["encryption"]) + + +@router.get( + "/prekey-bundle/{user_id}", + status_code = status.HTTP_200_OK, + response_model = PreKeyBundle +) +async def get_prekey_bundle( + user_id: UUID, + session: AsyncSession = Depends(get_session), +) -> PreKeyBundle: + """ + Retrieves prekey bundle for initiating X3DH key exchange with a user + """ + bundle = await prekey_service.get_prekey_bundle(session, user_id) + + unused_count = await prekey_service.get_unused_opk_count(session, user_id) + if unused_count < 20: + logger.info("User %s has %s OPKs, replenishing", user_id, unused_count) + await prekey_service.replenish_one_time_prekeys(session, user_id, 100) + + return bundle + + +@router.post("/initialize-keys/{user_id}", status_code = status.HTTP_201_CREATED) +async def initialize_keys( + user_id: UUID, + session: AsyncSession = Depends(get_session), +) -> dict[str, + str]: + """ + Initializes encryption keys for a user + """ + await prekey_service.initialize_user_keys(session, user_id) + + return { + "status": "success", + "message": f"Initialized encryption keys for user {user_id}" + } + + +@router.post("/rotate-signed-prekey/{user_id}", status_code = status.HTTP_200_OK) +async def rotate_signed_prekey( + user_id: UUID, + session: AsyncSession = Depends(get_session), +) -> dict[str, + str]: + """ + Manually rotates signed prekey for a user + """ + await prekey_service.rotate_signed_prekey(session, user_id) + + return { + "status": "success", + "message": f"Rotated signed prekey for user {user_id}" + } + + +@router.get("/opk-count/{user_id}", status_code = status.HTTP_200_OK) +async def get_opk_count( + user_id: UUID, + session: AsyncSession = Depends(get_session), +) -> dict[str, + int]: + """ + Returns count of unused one time prekeys for a user + """ + count = await prekey_service.get_unused_opk_count(session, user_id) + + return {"unused_opks": count} diff --git a/PROJECTS/encrypted-p2p-chat/backend/app/api/rooms.py b/PROJECTS/encrypted-p2p-chat/backend/app/api/rooms.py new file mode 100644 index 00000000..67e04eca --- /dev/null +++ b/PROJECTS/encrypted-p2p-chat/backend/app/api/rooms.py @@ -0,0 +1,121 @@ +""" +ⒸAngelaMos | 2025 +Rooms API for creating and managing chat rooms +""" + +import logging +from uuid import UUID +from datetime import UTC, datetime + +from fastapi import ( + APIRouter, + Depends, + HTTPException, + status, +) +from sqlmodel.ext.asyncio.session import AsyncSession + +from app.schemas.rooms import ( + CreateRoomRequest, + ParticipantResponse, + RoomAPIResponse, + RoomListResponse, +) +from app.core.enums import RoomType +from app.models.Base import get_session +from app.core.surreal_manager import surreal_db +from app.services.auth_service import auth_service + + +logger = logging.getLogger(__name__) + + +router = APIRouter(prefix = "/rooms", tags = ["rooms"]) + + +@router.post("", status_code = status.HTTP_201_CREATED) +async def create_room( + request: CreateRoomRequest, + session: AsyncSession = Depends(get_session), +) -> RoomAPIResponse: + """ + Create a new chat room + """ + participant = await auth_service.get_user_by_id( + session, + UUID(request.participant_id), + ) + + if not participant: + raise HTTPException( + status_code = status.HTTP_404_NOT_FOUND, + detail = "Participant not found", + ) + + now = datetime.now(UTC) + + room_data = { + "name": participant.display_name, + "room_type": request.room_type.value, + "created_by": request.participant_id, + "created_at": now.isoformat(), + "updated_at": now.isoformat(), + "is_ephemeral": request.room_type == RoomType.EPHEMERAL, + } + + room = await surreal_db.create_room(room_data) + + logger.info( + "Created room %s with participant %s", + room.id, + request.participant_id, + ) + + return RoomAPIResponse( + id = room.id, + type = RoomType(room.room_type), + name = participant.display_name, + participants = [ + ParticipantResponse( + user_id = str(participant.id), + username = participant.username, + display_name = participant.display_name, + role = "member", + joined_at = now.isoformat(), + ) + ], + unread_count = 0, + is_encrypted = True, + created_at = room.created_at.isoformat(), + updated_at = room.updated_at.isoformat(), + ) + + +@router.get("", status_code = status.HTTP_200_OK) +async def list_rooms() -> RoomListResponse: + """ + List all rooms for the current user + """ + return RoomListResponse(rooms = []) + + +@router.get("/{room_id}", status_code = status.HTTP_200_OK) +async def get_room(room_id: str) -> RoomAPIResponse: + """ + Get a specific room + """ + raise HTTPException( + status_code = status.HTTP_404_NOT_FOUND, + detail = "Room not found", + ) + + +@router.delete("/{room_id}", status_code = status.HTTP_204_NO_CONTENT) +async def delete_room(room_id: str) -> None: + """ + Delete a room + """ + raise HTTPException( + status_code = status.HTTP_404_NOT_FOUND, + detail = "Room not found", + ) diff --git a/PROJECTS/encrypted-p2p-chat/backend/app/api/websocket.py b/PROJECTS/encrypted-p2p-chat/backend/app/api/websocket.py new file mode 100644 index 00000000..c68e17e1 --- /dev/null +++ b/PROJECTS/encrypted-p2p-chat/backend/app/api/websocket.py @@ -0,0 +1,84 @@ +""" +ⒸAngelaMos | 2025 +WebSocket endpoints for real time chat communication +""" + +import json +import logging +from uuid import UUID + +from fastapi import ( + APIRouter, + WebSocket, + WebSocketDisconnect, + Query, +) +from app.core.websocket_manager import connection_manager +from app.services.websocket_service import websocket_service + + +logger = logging.getLogger(__name__) + +router = APIRouter(prefix = "/ws", tags = ["websocket"]) + + +@router.websocket("") +async def websocket_endpoint( + websocket: WebSocket, + user_id: str = Query(...), +) -> None: + """ + Main WebSocket endpoint for real time messaging + """ + try: + user_uuid = UUID(user_id) + except ValueError: + logger.error("Invalid user_id format: %s", user_id) + await websocket.close(code = 1008, reason = "Invalid user ID") + return + + connected = await connection_manager.connect(websocket, user_uuid) + + if not connected: + return + + try: + while True: + data = await websocket.receive_text() + + try: + message = json.loads(data) + await websocket_service.route_message( + websocket, + user_uuid, + message + ) + except json.JSONDecodeError: + logger.error( + "Invalid JSON from user %s: %s", + user_uuid, + data[: 100] + ) + await websocket.send_json( + { + "type": "error", + "error_code": "invalid_json", + "error_message": "Invalid JSON format" + } + ) + except Exception as e: + logger.error("Error handling message from %s: %s", user_uuid, e) + await websocket.send_json( + { + "type": "error", + "error_code": "processing_error", + "error_message": str(e) + } + ) + + except WebSocketDisconnect: + logger.info("WebSocket disconnected for user %s", user_uuid) + except Exception as e: + logger.error("WebSocket error for user %s: %s", user_uuid, e) + finally: + await connection_manager.disconnect(websocket, user_uuid) diff --git a/PROJECTS/encrypted-p2p-chat/backend/app/config.py b/PROJECTS/encrypted-p2p-chat/backend/app/config.py new file mode 100644 index 00000000..72bc5e5c --- /dev/null +++ b/PROJECTS/encrypted-p2p-chat/backend/app/config.py @@ -0,0 +1,222 @@ +""" +ⒸAngelaMos | 2025 +All environment variables and constants are centralized here +""" + +from typing import Literal +from functools import lru_cache + +from pydantic import ( + PostgresDsn, + RedisDsn, + field_validator, + ValidationInfo, +) +from pydantic_settings import ( + BaseSettings, + SettingsConfigDict, +) + + +# User field lengths +USERNAME_MIN_LENGTH = 3 +USERNAME_MAX_LENGTH = 50 +DISPLAY_NAME_MIN_LENGTH = 1 +DISPLAY_NAME_MAX_LENGTH = 100 +DEVICE_NAME_MAX_LENGTH = 100 +PREKEY_MAX_LENGTH = 500 + +# User search +USER_SEARCH_MIN_LENGTH = 2 +USER_SEARCH_DEFAULT_LIMIT = 10 +USER_SEARCH_MAX_LIMIT = 50 + +# Credential field lengths +CREDENTIAL_ID_MAX_LENGTH = 512 +PUBLIC_KEY_MAX_LENGTH = 1024 +AAGUID_MAX_LENGTH = 64 +ATTESTATION_TYPE_MAX_LENGTH = 50 +TRANSPORT_MAX_LENGTH = 200 + +# Message field lengths +MESSAGE_ID_MAX_LENGTH = 64 +ROOM_ID_MAX_LENGTH = 64 +ENCRYPTED_CONTENT_MAX_LENGTH = 50000 + +# Pagination defaults +DEFAULT_MESSAGE_LIMIT = 50 +MAX_MESSAGE_LIMIT = 200 + +# WebSocket message types +WS_MESSAGE_TYPE_ENCRYPTED = "encrypted_message" +WS_MESSAGE_TYPE_TYPING = "typing" +WS_MESSAGE_TYPE_PRESENCE = "presence" +WS_MESSAGE_TYPE_RECEIPT = "receipt" +WS_MESSAGE_TYPE_ERROR = "error" + +# Encryption key field lengths +IDENTITY_KEY_LENGTH = 64 +SIGNED_PREKEY_LENGTH = 64 +ONE_TIME_PREKEY_LENGTH = 64 +SIGNATURE_LENGTH = 128 +RATCHET_STATE_MAX_LENGTH = 100000 + +# Encryption constants +X25519_KEY_SIZE = 32 +ED25519_KEY_SIZE = 32 +ED25519_SIGNATURE_SIZE = 64 +AES_GCM_KEY_SIZE = 32 +AES_GCM_NONCE_SIZE = 12 +HKDF_OUTPUT_SIZE = 32 + +# Double Ratchet limits +MAX_SKIP_MESSAGE_KEYS = 1000 +MAX_CACHED_MESSAGE_KEYS = 2000 +DEFAULT_ONE_TIME_PREKEY_COUNT = 100 +SIGNED_PREKEY_ROTATION_HOURS = 48 +SIGNED_PREKEY_RETENTION_DAYS = 7 + +# Server defaults +DEFAULT_HOST = "0.0.0.0" +DEFAULT_PORT = 8000 + +# WebAuthn challenge settings +WEBAUTHN_CHALLENGE_TTL_SECONDS = 600 +WEBAUTHN_CHALLENGE_BYTES = 32 + +# Application metadata +APP_VERSION = "1.0.0" +APP_STATUS = "running" +APP_DESCRIPTION = "End to end encrypted P2P chat with Double Ratchet and WebAuthn" + +# Middleware settings +GZIP_MINIMUM_SIZE = 1000 + + +class Settings(BaseSettings): + """ + Application settings with environment variable support + """ + model_config = SettingsConfigDict( + env_file = "../../.env", + env_file_encoding = "utf-8", + case_sensitive = False, + extra = "ignore", + ) + + ENV: Literal["development", "production", "testing"] = "development" + DEBUG: bool = True + APP_NAME: str = "encrypted-p2p-chat" + SECRET_KEY: str + + POSTGRES_HOST: str = "localhost" + POSTGRES_PORT: int = 5432 + POSTGRES_DB: str = "chat_auth" + POSTGRES_USER: str = "chat_user" + POSTGRES_PASSWORD: str = "" + DATABASE_URL: PostgresDsn | None = None + DB_POOL_SIZE: int = 20 + DB_MAX_OVERFLOW: int = 40 + + SURREAL_HOST: str = "localhost" + SURREAL_PORT: int = 8000 + SURREAL_USER: str = "root" + SURREAL_PASSWORD: str + SURREAL_NAMESPACE: str = "chat" + SURREAL_DATABASE: str = "production" + SURREAL_URL: str | None = None + + REDIS_HOST: str = "localhost" + REDIS_PORT: int = 6379 + REDIS_PASSWORD: str = "" + REDIS_URL: RedisDsn | None = None + + RP_ID: str = "localhost" + RP_NAME: str = "Encrypted P2P Chat" + RP_ORIGIN: str = "http://localhost:3000" + + CORS_ORIGINS: list[str] = ["http://localhost:3000", "http://localhost:5173"] + + WS_HEARTBEAT_INTERVAL: int = 30 + WS_MAX_CONNECTIONS_PER_USER: int = 5 + + KEY_ROTATION_DAYS: int = 90 + MAX_SKIPPED_MESSAGE_KEYS: int = 1000 + + RATE_LIMIT_MESSAGES_PER_MINUTE: int = 60 + RATE_LIMIT_AUTH_ATTEMPTS: int = 5 + + @field_validator("DATABASE_URL", mode = "before") + @classmethod + def assemble_db_connection(cls, v: str | None, info: ValidationInfo) -> str: + """ + Build PostgreSQL connection URL if not provided + """ + if v: + return v + data = info.data + return ( + f"postgresql+asyncpg://{data['POSTGRES_USER']}:{data['POSTGRES_PASSWORD']}" + f"@{data['POSTGRES_HOST']}:{data['POSTGRES_PORT']}/{data['POSTGRES_DB']}" + ) + + @field_validator("SURREAL_URL", mode = "before") + @classmethod + def assemble_surreal_connection( + cls, + v: str | None, + info: ValidationInfo + ) -> str: + """ + Build SurrealDB WebSocket URL if not provided + """ + if v: + return v + data = info.data + return f"ws://{data['SURREAL_HOST']}:{data['SURREAL_PORT']}" + + @field_validator("REDIS_URL", mode = "before") + @classmethod + def assemble_redis_connection( + cls, + v: str | None, + info: ValidationInfo + ) -> str: + """ + Build Redis connection URL if not provided + """ + if v: + return v + data = info.data + password_part = f":{data['REDIS_PASSWORD']}@" if data["REDIS_PASSWORD" + ] else "" + return f"redis://{password_part}{data['REDIS_HOST']}:{data['REDIS_PORT']}" + + @property + def is_production(self) -> bool: + """ + Check if running in production environment + """ + return self.ENV == "production" + + @property + def is_development(self) -> bool: + """ + Check if running in development environment + """ + return self.ENV == "development" + + +@lru_cache +def get_settings() -> Settings: + """ + Get cached settings instance using lru_cache + """ + return Settings() # type: ignore[call-arg] + + +settings = get_settings() + +# Export settings fields as module-level constants for imports +WS_HEARTBEAT_INTERVAL = settings.WS_HEARTBEAT_INTERVAL +WS_MAX_CONNECTIONS_PER_USER = settings.WS_MAX_CONNECTIONS_PER_USER diff --git a/PROJECTS/encrypted-p2p-chat/backend/app/core/__init__.py b/PROJECTS/encrypted-p2p-chat/backend/app/core/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/PROJECTS/encrypted-p2p-chat/backend/app/core/encryption/__init__.py b/PROJECTS/encrypted-p2p-chat/backend/app/core/encryption/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/PROJECTS/encrypted-p2p-chat/backend/app/core/encryption/double_ratchet.py b/PROJECTS/encrypted-p2p-chat/backend/app/core/encryption/double_ratchet.py new file mode 100644 index 00000000..01bdb656 --- /dev/null +++ b/PROJECTS/encrypted-p2p-chat/backend/app/core/encryption/double_ratchet.py @@ -0,0 +1,419 @@ +""" +ⒸAngelaMos | 2025 +Double Ratchet algorithm implementation for end to end encryption +""" + +import os +import logging +from dataclasses import ( + field, + dataclass, +) +from cryptography.hazmat.primitives.asymmetric.x25519 import ( + X25519PrivateKey, + X25519PublicKey, +) +from cryptography.exceptions import InvalidTag +from cryptography.hazmat.primitives import hmac +from cryptography.hazmat.primitives.kdf.hkdf import HKDF +from cryptography.hazmat.primitives.ciphers.aead import AESGCM +from cryptography.hazmat.primitives import hashes, serialization + +from app.config import ( + AES_GCM_NONCE_SIZE, + HKDF_OUTPUT_SIZE, + MAX_CACHED_MESSAGE_KEYS, + MAX_SKIP_MESSAGE_KEYS, + X25519_KEY_SIZE, +) + + +logger = logging.getLogger(__name__) + + +@dataclass +class DoubleRatchetState: + """ + Complete state for Double Ratchet algorithm per conversation + """ + root_key: bytes + sending_chain_key: bytes + receiving_chain_key: bytes + dh_private_key: X25519PrivateKey | None + dh_peer_public_key: bytes | None + sending_message_number: int = 0 + receiving_message_number: int = 0 + previous_sending_chain_length: int = 0 + skipped_message_keys: dict[tuple[bytes, + int], + bytes] = field(default_factory = dict) + + +@dataclass +class EncryptedMessage: + """ + Encrypted message with header and metadata + """ + ciphertext: bytes + nonce: bytes + dh_public_key: bytes + message_number: int + previous_chain_length: int + + +class DoubleRatchet: + """ + Implementation of Signal Protocol Double Ratchet algorithm + """ + def __init__( + self, + max_skip: int = MAX_SKIP_MESSAGE_KEYS, + max_cache: int = MAX_CACHED_MESSAGE_KEYS + ): + """ + Initialize Double Ratchet with security limits + """ + self.max_skip = max_skip + self.max_cache = max_cache + + def _kdf_rk(self, root_key: bytes, dh_output: bytes) -> tuple[bytes, bytes]: + """ + Derives new root key and chain key from DH output + """ + hkdf = HKDF( + algorithm = hashes.SHA256(), + length = HKDF_OUTPUT_SIZE * 2, + salt = root_key, + info = b'', + ) + output = hkdf.derive(dh_output) + new_root_key = output[: HKDF_OUTPUT_SIZE] + new_chain_key = output[HKDF_OUTPUT_SIZE :] + + logger.debug("Derived new root key and chain key") + return new_root_key, new_chain_key + + def _kdf_ck(self, chain_key: bytes) -> tuple[bytes, bytes]: + """ + Derives next chain key and message key from current chain key + """ + h_chain = hmac.HMAC(chain_key, hashes.SHA256()) + h_chain.update(b'\x01') + next_chain_key = h_chain.finalize() + + h_message = hmac.HMAC(chain_key, hashes.SHA256()) + h_message.update(b'\x02') + message_key = h_message.finalize() + + logger.debug("Derived next chain key and message key") + return next_chain_key, message_key + + def _encrypt_with_message_key( + self, + message_key: bytes, + plaintext: bytes, + associated_data: bytes + ) -> tuple[bytes, + bytes]: + """ + Encrypts plaintext using AES-256-GCM with message key + """ + aesgcm = AESGCM(message_key) + nonce = os.urandom(AES_GCM_NONCE_SIZE) + ciphertext = aesgcm.encrypt(nonce, plaintext, associated_data) + + logger.debug( + "Encrypted %s bytes to %s bytes", + len(plaintext), + len(ciphertext) + ) + return nonce, ciphertext + + def _decrypt_with_message_key( + self, + message_key: bytes, + nonce: bytes, + ciphertext: bytes, + associated_data: bytes + ) -> bytes: + """ + Decrypts ciphertext using AES-256-GCM with message key + """ + aesgcm = AESGCM(message_key) + try: + plaintext = aesgcm.decrypt(nonce, ciphertext, associated_data) + logger.debug( + "Decrypted %s bytes to %s bytes", + len(ciphertext), + len(plaintext) + ) + return plaintext + except InvalidTag as e: + logger.error("Message authentication failed") + raise ValueError("Message tampered or corrupted") from e + + def _dh_ratchet_send(self, state: DoubleRatchetState) -> None: + """ + Performs DH ratchet step when sending + """ + state.dh_private_key = X25519PrivateKey.generate() + + state.previous_sending_chain_length = state.sending_message_number + state.sending_message_number = 0 + state.receiving_message_number = 0 + + if state.dh_peer_public_key: + peer_public = X25519PublicKey.from_public_bytes( + state.dh_peer_public_key + ) + dh_output = state.dh_private_key.exchange(peer_public) + + state.root_key, state.sending_chain_key = self._kdf_rk( + state.root_key, + dh_output + ) + + logger.debug("DH ratchet step completed (send)") + + def _dh_ratchet_receive( + self, + state: DoubleRatchetState, + peer_public_key: bytes + ) -> None: + """ + Performs DH ratchet step when receiving + """ + state.previous_sending_chain_length = state.sending_message_number + state.sending_message_number = 0 + state.receiving_message_number = 0 + state.dh_peer_public_key = peer_public_key + + if state.dh_private_key: + peer_public = X25519PublicKey.from_public_bytes(peer_public_key) + dh_output = state.dh_private_key.exchange(peer_public) + + state.root_key, state.receiving_chain_key = self._kdf_rk( + state.root_key, + dh_output + ) + + state.dh_private_key = X25519PrivateKey.generate() + + if state.dh_peer_public_key: + peer_public = X25519PublicKey.from_public_bytes( + state.dh_peer_public_key + ) + dh_output = state.dh_private_key.exchange(peer_public) + + state.root_key, state.sending_chain_key = self._kdf_rk( + state.root_key, + dh_output + ) + + logger.debug("DH ratchet step completed (receive)") + + def _store_skipped_message_keys( + self, + state: DoubleRatchetState, + until_message_number: int, + dh_public_key: bytes + ) -> None: + """ + Stores skipped message keys for out of order delivery + """ + num_to_skip = until_message_number - state.receiving_message_number + + if num_to_skip > self.max_skip: + raise ValueError( + f"Cannot skip {num_to_skip} messages " + f"(MAX_SKIP={self.max_skip})" + ) + + if len(state.skipped_message_keys) + num_to_skip > self.max_cache: + logger.warning("Skipped message key cache full, evicting oldest keys") + self._evict_oldest_skipped_keys(state, num_to_skip) + + chain_key = state.receiving_chain_key + for msg_num in range(state.receiving_message_number, + until_message_number): + chain_key, message_key = self._kdf_ck(chain_key) + state.skipped_message_keys[(dh_public_key, msg_num)] = message_key + + state.receiving_chain_key = chain_key + + logger.debug("Stored %s skipped message keys", num_to_skip) + + def _evict_oldest_skipped_keys( + self, + state: DoubleRatchetState, + count: int + ) -> None: + """ + Evicts oldest skipped message keys to make room + """ + keys_to_remove = list(state.skipped_message_keys.keys())[: count] + for key in keys_to_remove: + del state.skipped_message_keys[key] + + logger.debug("Evicted %s skipped keys", len(keys_to_remove)) + + def _try_skipped_message_key( + self, + state: DoubleRatchetState, + dh_public_key: bytes, + message_number: int + ) -> bytes | None: + """ + Attempts to retrieve skipped message key + """ + key = (dh_public_key, message_number) + message_key = state.skipped_message_keys.pop(key, None) + + if message_key: + logger.debug( + "Retrieved skipped message key for msg %s", + message_number + ) + return message_key + + def initialize_sender( + self, + shared_key: bytes, + peer_public_key: bytes + ) -> DoubleRatchetState: + """ + Initializes Double Ratchet as sender after X3DH + """ + dh_private = X25519PrivateKey.generate() + peer_public = X25519PublicKey.from_public_bytes(peer_public_key) + dh_output = dh_private.exchange(peer_public) + + root_key, sending_chain_key = self._kdf_rk(shared_key, dh_output) + + state = DoubleRatchetState( + root_key = root_key, + sending_chain_key = sending_chain_key, + receiving_chain_key = b'\x00' * HKDF_OUTPUT_SIZE, + dh_private_key = dh_private, + dh_peer_public_key = peer_public_key + ) + + logger.info("Double Ratchet initialized as sender") + return state + + def initialize_receiver( + self, + shared_key: bytes, + own_private_key: X25519PrivateKey + ) -> DoubleRatchetState: + """ + Initializes Double Ratchet as receiver after X3DH + """ + state = DoubleRatchetState( + root_key = shared_key, + sending_chain_key = b'\x00' * HKDF_OUTPUT_SIZE, + receiving_chain_key = b'\x00' * HKDF_OUTPUT_SIZE, + dh_private_key = own_private_key, + dh_peer_public_key = None + ) + + logger.info("Double Ratchet initialized as receiver") + return state + + def encrypt_message( + self, + state: DoubleRatchetState, + plaintext: bytes, + associated_data: bytes + ) -> EncryptedMessage: + """ + Encrypts message and advances sending ratchet + """ + state.sending_chain_key, message_key = self._kdf_ck( + state.sending_chain_key + ) + + nonce, ciphertext = self._encrypt_with_message_key( + message_key, + plaintext, + associated_data + ) + + if state.dh_private_key: + dh_public = state.dh_private_key.public_key() + dh_public_bytes = dh_public.public_bytes( + encoding = serialization.Encoding.Raw, + format = serialization.PublicFormat.Raw + ) + else: + dh_public_bytes = b'\x00' * X25519_KEY_SIZE + + encrypted_msg = EncryptedMessage( + ciphertext = ciphertext, + nonce = nonce, + dh_public_key = dh_public_bytes, + message_number = state.sending_message_number, + previous_chain_length = state.previous_sending_chain_length + ) + + state.sending_message_number += 1 + + logger.info("Encrypted message #%s", encrypted_msg.message_number) + return encrypted_msg + + def decrypt_message( + self, + state: DoubleRatchetState, + encrypted_msg: EncryptedMessage, + associated_data: bytes + ) -> bytes: + """ + Decrypts message and advances receiving ratchet + """ + skipped_key = self._try_skipped_message_key( + state, + encrypted_msg.dh_public_key, + encrypted_msg.message_number + ) + if skipped_key: + return self._decrypt_with_message_key( + skipped_key, + encrypted_msg.nonce, + encrypted_msg.ciphertext, + associated_data + ) + + if encrypted_msg.dh_public_key != state.dh_peer_public_key: + if state.dh_peer_public_key: + self._store_skipped_message_keys( + state, + encrypted_msg.previous_chain_length, + state.dh_peer_public_key + ) + + self._dh_ratchet_receive(state, encrypted_msg.dh_public_key) + + if encrypted_msg.message_number > state.receiving_message_number: + self._store_skipped_message_keys( + state, + encrypted_msg.message_number, + encrypted_msg.dh_public_key + ) + + state.receiving_chain_key, message_key = self._kdf_ck( + state.receiving_chain_key + ) + state.receiving_message_number += 1 + + plaintext = self._decrypt_with_message_key( + message_key, + encrypted_msg.nonce, + encrypted_msg.ciphertext, + associated_data + ) + + logger.info("Decrypted message #%s", encrypted_msg.message_number) + return plaintext + + +double_ratchet = DoubleRatchet() diff --git a/PROJECTS/encrypted-p2p-chat/backend/app/core/encryption/x3dh_manager.py b/PROJECTS/encrypted-p2p-chat/backend/app/core/encryption/x3dh_manager.py new file mode 100644 index 00000000..0c873b1a --- /dev/null +++ b/PROJECTS/encrypted-p2p-chat/backend/app/core/encryption/x3dh_manager.py @@ -0,0 +1,352 @@ +""" +ⒸAngelaMos | 2025 +X3DH key exchange manager for async initial key agreement +""" + +import logging +from dataclasses import dataclass + +from cryptography.hazmat.primitives.asymmetric.ed25519 import ( + Ed25519PrivateKey, + Ed25519PublicKey, +) +from cryptography.hazmat.primitives import hashes, serialization +from cryptography.hazmat.primitives.asymmetric.x25519 import ( + X25519PrivateKey, + X25519PublicKey, +) +from cryptography.exceptions import InvalidSignature +from webauthn.helpers import ( + bytes_to_base64url, + base64url_to_bytes, +) +from cryptography.hazmat.primitives.kdf.hkdf import HKDF + +from app.config import ( + X25519_KEY_SIZE, +) + + +logger = logging.getLogger(__name__) + + +@dataclass +class PreKeyBundle: + """ + Recipient prekey bundle for X3DH protocol + """ + identity_key: str + signed_prekey: str + signed_prekey_signature: str + one_time_prekey: str | None = None + + +@dataclass +class X3DHResult: + """ + Result of X3DH key exchange containing shared key and metadata + """ + shared_key: bytes + associated_data: bytes + ephemeral_public_key: str + used_one_time_prekey: bool + + +class X3DHManager: + """ + Manages X3DH key exchange protocol for async initial key agreement + """ + def generate_identity_keypair_x25519(self) -> tuple[str, str]: + """ + Generates X25519 identity keypair for DH operations + """ + private_key = X25519PrivateKey.generate() + public_key = private_key.public_key() + + private_bytes = private_key.private_bytes( + encoding = serialization.Encoding.Raw, + format = serialization.PrivateFormat.Raw, + encryption_algorithm = serialization.NoEncryption() + ) + public_bytes = public_key.public_bytes( + encoding = serialization.Encoding.Raw, + format = serialization.PublicFormat.Raw + ) + + logger.debug( + "Generated X25519 identity keypair: %s private, %s public bytes", + len(private_bytes), + len(public_bytes) + ) + + return ( + bytes_to_base64url(private_bytes), + bytes_to_base64url(public_bytes) + ) + + def generate_identity_keypair_ed25519(self) -> tuple[str, str]: + """ + Generates Ed25519 identity keypair for signing prekeys + """ + private_key = Ed25519PrivateKey.generate() + public_key = private_key.public_key() + + private_bytes = private_key.private_bytes( + encoding = serialization.Encoding.Raw, + format = serialization.PrivateFormat.Raw, + encryption_algorithm = serialization.NoEncryption() + ) + public_bytes = public_key.public_bytes( + encoding = serialization.Encoding.Raw, + format = serialization.PublicFormat.Raw + ) + + logger.debug( + "Generated Ed25519 signing keypair: %s private, %s public bytes", + len(private_bytes), + len(public_bytes) + ) + + return ( + bytes_to_base64url(private_bytes), + bytes_to_base64url(public_bytes) + ) + + def generate_signed_prekey(self, + identity_private_key_ed25519: str) -> tuple[str, + str, + str]: + """ + Generates signed prekey with signature from Ed25519 identity key + """ + spk_private = X25519PrivateKey.generate() + spk_public = spk_private.public_key() + + spk_private_bytes = spk_private.private_bytes( + encoding = serialization.Encoding.Raw, + format = serialization.PrivateFormat.Raw, + encryption_algorithm = serialization.NoEncryption() + ) + spk_public_bytes = spk_public.public_bytes( + encoding = serialization.Encoding.Raw, + format = serialization.PublicFormat.Raw + ) + + identity_private_bytes = base64url_to_bytes(identity_private_key_ed25519) + identity_private = Ed25519PrivateKey.from_private_bytes( + identity_private_bytes + ) + + signature = identity_private.sign(spk_public_bytes) + + logger.debug( + "Generated signed prekey with %s byte signature", + len(signature) + ) + + return ( + bytes_to_base64url(spk_private_bytes), + bytes_to_base64url(spk_public_bytes), + bytes_to_base64url(signature) + ) + + def generate_one_time_prekey(self) -> tuple[str, str]: + """ + Generates single-use one-time prekey + """ + opk_private = X25519PrivateKey.generate() + opk_public = opk_private.public_key() + + opk_private_bytes = opk_private.private_bytes( + encoding = serialization.Encoding.Raw, + format = serialization.PrivateFormat.Raw, + encryption_algorithm = serialization.NoEncryption() + ) + opk_public_bytes = opk_public.public_bytes( + encoding = serialization.Encoding.Raw, + format = serialization.PublicFormat.Raw + ) + + return ( + bytes_to_base64url(opk_private_bytes), + bytes_to_base64url(opk_public_bytes) + ) + + def verify_signed_prekey( + self, + signed_prekey_public: str, + signature: str, + identity_public_key_ed25519: str + ) -> bool: + """ + Verifies signed prekey signature using Ed25519 identity key + """ + try: + spk_public_bytes = base64url_to_bytes(signed_prekey_public) + signature_bytes = base64url_to_bytes(signature) + identity_public_bytes = base64url_to_bytes( + identity_public_key_ed25519 + ) + + identity_public = Ed25519PublicKey.from_public_bytes( + identity_public_bytes + ) + + identity_public.verify(signature_bytes, spk_public_bytes) + + logger.debug("Signed prekey signature verified successfully") + return True + + except InvalidSignature: + logger.warning("Signed prekey signature verification failed") + return False + except Exception as e: + logger.error("Error verifying signed prekey: %s", e) + return False + + def perform_x3dh_sender( + self, + alice_identity_private_x25519: str, + bob_bundle: PreKeyBundle, + bob_identity_public_ed25519: str + ) -> X3DHResult: + """ + Performs X3DH key exchange from sender side + """ + if not self.verify_signed_prekey(bob_bundle.signed_prekey, + bob_bundle.signed_prekey_signature, + bob_identity_public_ed25519): + raise ValueError("Invalid signed prekey signature") + + alice_ik_private_bytes = base64url_to_bytes(alice_identity_private_x25519) + alice_ik_private = X25519PrivateKey.from_private_bytes( + alice_ik_private_bytes + ) + + alice_ek_private = X25519PrivateKey.generate() + alice_ek_public = alice_ek_private.public_key() + + alice_ek_public_bytes = alice_ek_public.public_bytes( + encoding = serialization.Encoding.Raw, + format = serialization.PublicFormat.Raw + ) + + bob_ik_public_bytes = base64url_to_bytes(bob_bundle.identity_key) + bob_ik_public = X25519PublicKey.from_public_bytes(bob_ik_public_bytes) + + bob_spk_public_bytes = base64url_to_bytes(bob_bundle.signed_prekey) + bob_spk_public = X25519PublicKey.from_public_bytes(bob_spk_public_bytes) + + dh1 = alice_ik_private.exchange(bob_spk_public) + dh2 = alice_ek_private.exchange(bob_ik_public) + dh3 = alice_ek_private.exchange(bob_spk_public) + + used_one_time_prekey = False + if bob_bundle.one_time_prekey: + bob_opk_public_bytes = base64url_to_bytes(bob_bundle.one_time_prekey) + bob_opk_public = X25519PublicKey.from_public_bytes( + bob_opk_public_bytes + ) + dh4 = alice_ek_private.exchange(bob_opk_public) + key_material = dh1 + dh2 + dh3 + dh4 + used_one_time_prekey = True + else: + key_material = dh1 + dh2 + dh3 + + f = b'\xff' * X25519_KEY_SIZE + hkdf = HKDF( + algorithm = hashes.SHA256(), + length = X25519_KEY_SIZE, + salt = b'\x00' * X25519_KEY_SIZE, + info = b'X3DH', + ) + shared_key = hkdf.derive(f + key_material) + + alice_ik_public = alice_ik_private.public_key() + alice_ik_public_bytes = alice_ik_public.public_bytes( + encoding = serialization.Encoding.Raw, + format = serialization.PublicFormat.Raw + ) + + associated_data = alice_ik_public_bytes + bob_ik_public_bytes + + logger.info("X3DH sender completed: OPK used=%s", used_one_time_prekey) + + return X3DHResult( + shared_key = shared_key, + associated_data = associated_data, + ephemeral_public_key = bytes_to_base64url(alice_ek_public_bytes), + used_one_time_prekey = used_one_time_prekey + ) + + def perform_x3dh_receiver( + self, + bob_identity_private_x25519: str, + bob_signed_prekey_private: str, + bob_one_time_prekey_private: str | None, + alice_identity_public_x25519: str, + alice_ephemeral_public: str + ) -> X3DHResult: + """ + Performs X3DH key exchange from receiver side + """ + bob_ik_private_bytes = base64url_to_bytes(bob_identity_private_x25519) + bob_ik_private = X25519PrivateKey.from_private_bytes(bob_ik_private_bytes) + + bob_spk_private_bytes = base64url_to_bytes(bob_signed_prekey_private) + bob_spk_private = X25519PrivateKey.from_private_bytes( + bob_spk_private_bytes + ) + + alice_ik_public_bytes = base64url_to_bytes(alice_identity_public_x25519) + alice_ik_public = X25519PublicKey.from_public_bytes(alice_ik_public_bytes) + + alice_ek_public_bytes = base64url_to_bytes(alice_ephemeral_public) + alice_ek_public = X25519PublicKey.from_public_bytes(alice_ek_public_bytes) + + dh1 = bob_spk_private.exchange(alice_ik_public) + dh2 = bob_ik_private.exchange(alice_ek_public) + dh3 = bob_spk_private.exchange(alice_ek_public) + + used_one_time_prekey = False + if bob_one_time_prekey_private: + bob_opk_private_bytes = base64url_to_bytes( + bob_one_time_prekey_private + ) + bob_opk_private = X25519PrivateKey.from_private_bytes( + bob_opk_private_bytes + ) + dh4 = bob_opk_private.exchange(alice_ek_public) + key_material = dh1 + dh2 + dh3 + dh4 + used_one_time_prekey = True + else: + key_material = dh1 + dh2 + dh3 + + f = b'\xff' * X25519_KEY_SIZE + hkdf = HKDF( + algorithm = hashes.SHA256(), + length = X25519_KEY_SIZE, + salt = b'\x00' * X25519_KEY_SIZE, + info = b'X3DH', + ) + shared_key = hkdf.derive(f + key_material) + + bob_ik_public = bob_ik_private.public_key() + bob_ik_public_bytes = bob_ik_public.public_bytes( + encoding = serialization.Encoding.Raw, + format = serialization.PublicFormat.Raw + ) + + associated_data = alice_ik_public_bytes + bob_ik_public_bytes + + logger.info("X3DH receiver completed: OPK used=%s", used_one_time_prekey) + + return X3DHResult( + shared_key = shared_key, + associated_data = associated_data, + ephemeral_public_key = alice_ephemeral_public, + used_one_time_prekey = used_one_time_prekey + ) + + +x3dh_manager = X3DHManager() diff --git a/PROJECTS/encrypted-p2p-chat/backend/app/core/enums.py b/PROJECTS/encrypted-p2p-chat/backend/app/core/enums.py new file mode 100644 index 00000000..b4270c8c --- /dev/null +++ b/PROJECTS/encrypted-p2p-chat/backend/app/core/enums.py @@ -0,0 +1,35 @@ +""" +ⒸAngelaMos | 2025 +Application enums for type safety +""" + +from enum import Enum + + +class MessageStatus(str, Enum): + """ + Message delivery status + """ + SENDING = "sending" + SENT = "sent" + DELIVERED = "delivered" + READ = "read" + FAILED = "failed" + + +class PresenceStatus(str, Enum): + """ + User presence status + """ + ONLINE = "online" + AWAY = "away" + OFFLINE = "offline" + + +class RoomType(str, Enum): + """ + Chat room types + """ + DIRECT = "direct" + GROUP = "group" + EPHEMERAL = "ephemeral" diff --git a/PROJECTS/encrypted-p2p-chat/backend/app/core/exception_handlers.py b/PROJECTS/encrypted-p2p-chat/backend/app/core/exception_handlers.py new file mode 100644 index 00000000..d963ef97 --- /dev/null +++ b/PROJECTS/encrypted-p2p-chat/backend/app/core/exception_handlers.py @@ -0,0 +1,246 @@ +""" +ⒸAngelaMos | 2025 +Global exception handlers for FastAPI application +""" + +import logging + +from fastapi import FastAPI, Request, status +from fastapi.responses import JSONResponse + +from app.core.exceptions import ( + AuthenticationError, + ChallengeExpiredError, + CredentialNotFoundError, + CredentialVerificationError, + DatabaseError, + DecryptionError, + EncryptionError, + InvalidDataError, + KeyExchangeError, + RatchetStateNotFoundError, + UserExistsError, + UserInactiveError, + UserNotFoundError, +) + + +logger = logging.getLogger(__name__) + + +async def user_exists_handler( + request: Request, + exc: UserExistsError +) -> JSONResponse: + """ + Handle UserExistsError exceptions + """ + logger.warning("User exists error on %s: %s", request.url, exc.message) + return JSONResponse( + status_code = status.HTTP_409_CONFLICT, + content = {"detail": exc.message}, + ) + + +async def user_not_found_handler( + request: Request, + exc: UserNotFoundError +) -> JSONResponse: + """ + Handle UserNotFoundError exceptions + """ + logger.warning("User not found on %s: %s", request.url, exc.message) + return JSONResponse( + status_code = status.HTTP_404_NOT_FOUND, + content = {"detail": exc.message}, + ) + + +async def user_inactive_handler( + request: Request, + exc: UserInactiveError +) -> JSONResponse: + """ + Handle UserInactiveError exceptions + """ + logger.warning( + "Inactive user access attempt on %s: %s", + request.url, + exc.message + ) + return JSONResponse( + status_code = status.HTTP_403_FORBIDDEN, + content = {"detail": exc.message}, + ) + + +async def credential_not_found_handler( + request: Request, + exc: CredentialNotFoundError +) -> JSONResponse: + """ + Handle CredentialNotFoundError exceptions + """ + logger.warning("Credential not found on %s: %s", request.url, exc.message) + return JSONResponse( + status_code = status.HTTP_404_NOT_FOUND, + content = {"detail": exc.message}, + ) + + +async def credential_verification_handler( + request: Request, + exc: CredentialVerificationError +) -> JSONResponse: + """ + Handle CredentialVerificationError exceptions + """ + logger.error( + "Credential verification failed on %s: %s", + request.url, + exc.message + ) + return JSONResponse( + status_code = status.HTTP_401_UNAUTHORIZED, + content = {"detail": exc.message}, + ) + + +async def challenge_expired_handler( + request: Request, + exc: ChallengeExpiredError +) -> JSONResponse: + """ + Handle ChallengeExpiredError exceptions + """ + logger.warning("Challenge expired on %s: %s", request.url, exc.message) + return JSONResponse( + status_code = status.HTTP_400_BAD_REQUEST, + content = {"detail": exc.message}, + ) + + +async def database_error_handler( + request: Request, + exc: DatabaseError +) -> JSONResponse: + """ + Handle DatabaseError exceptions + """ + logger.error("Database error on %s: %s", request.url, exc.message) + return JSONResponse( + status_code = status.HTTP_500_INTERNAL_SERVER_ERROR, + content = {"detail": "Internal server error"}, + ) + + +async def authentication_error_handler( + request: Request, + exc: AuthenticationError +) -> JSONResponse: + """ + Handle AuthenticationError exceptions + """ + logger.warning("Authentication error on %s: %s", request.url, exc.message) + return JSONResponse( + status_code = status.HTTP_401_UNAUTHORIZED, + content = {"detail": exc.message}, + ) + + +async def invalid_data_handler( + request: Request, + exc: InvalidDataError +) -> JSONResponse: + """ + Handle InvalidDataError exceptions + """ + logger.warning("Invalid data on %s: %s", request.url, exc.message) + return JSONResponse( + status_code = status.HTTP_400_BAD_REQUEST, + content = {"detail": exc.message}, + ) + + +async def encryption_error_handler( + request: Request, + exc: EncryptionError +) -> JSONResponse: + """ + Handle EncryptionError exceptions + """ + logger.error("Encryption error on %s: %s", request.url, exc.message) + return JSONResponse( + status_code = status.HTTP_500_INTERNAL_SERVER_ERROR, + content = {"detail": "Encryption failed"}, + ) + + +async def decryption_error_handler( + request: Request, + exc: DecryptionError +) -> JSONResponse: + """ + Handle DecryptionError exceptions + """ + logger.error("Decryption error on %s: %s", request.url, exc.message) + return JSONResponse( + status_code = status.HTTP_500_INTERNAL_SERVER_ERROR, + content = {"detail": "Decryption failed"}, + ) + + +async def ratchet_state_not_found_handler( + request: Request, + exc: RatchetStateNotFoundError +) -> JSONResponse: + """ + Handle RatchetStateNotFoundError exceptions + """ + logger.warning("Ratchet state not found on %s: %s", request.url, exc.message) + return JSONResponse( + status_code = status.HTTP_404_NOT_FOUND, + content = {"detail": exc.message}, + ) + + +async def key_exchange_error_handler( + request: Request, + exc: KeyExchangeError +) -> JSONResponse: + """ + Handle KeyExchangeError exceptions + """ + logger.error("Key exchange error on %s: %s", request.url, exc.message) + return JSONResponse( + status_code = status.HTTP_500_INTERNAL_SERVER_ERROR, + content = {"detail": "Key exchange failed"}, + ) + + +def register_exception_handlers(app: FastAPI) -> None: + """ + Register all custom exception handlers with FastAPI app + """ + app.add_exception_handler(UserExistsError, user_exists_handler) + app.add_exception_handler(UserNotFoundError, user_not_found_handler) + app.add_exception_handler(UserInactiveError, user_inactive_handler) + app.add_exception_handler( + CredentialNotFoundError, + credential_not_found_handler + ) + app.add_exception_handler( + CredentialVerificationError, + credential_verification_handler + ) + app.add_exception_handler(ChallengeExpiredError, challenge_expired_handler) + app.add_exception_handler(DatabaseError, database_error_handler) + app.add_exception_handler(AuthenticationError, authentication_error_handler) + app.add_exception_handler(InvalidDataError, invalid_data_handler) + app.add_exception_handler(EncryptionError, encryption_error_handler) + app.add_exception_handler(DecryptionError, decryption_error_handler) + app.add_exception_handler( + RatchetStateNotFoundError, + ratchet_state_not_found_handler + ) + app.add_exception_handler(KeyExchangeError, key_exchange_error_handler) diff --git a/PROJECTS/encrypted-p2p-chat/backend/app/core/exceptions.py b/PROJECTS/encrypted-p2p-chat/backend/app/core/exceptions.py new file mode 100644 index 00000000..20b7b0e7 --- /dev/null +++ b/PROJECTS/encrypted-p2p-chat/backend/app/core/exceptions.py @@ -0,0 +1,94 @@ +""" +ⒸAngelaMos | 2025 +Custom application exceptions for clean error handling +""" + + +class AppException(Exception): + """ + Base application exception + """ + def __init__(self, message: str) -> None: + """ + Initialize exception with message + """ + self.message = message + super().__init__(self.message) + + +class UserExistsError(AppException): + """ + Raised when attempting to create a user that already exists + """ + + +class UserNotFoundError(AppException): + """ + Raised when user cannot be found + """ + + +class UserInactiveError(AppException): + """ + Raised when user account is inactive + """ + + +class CredentialNotFoundError(AppException): + """ + Raised when credential cannot be found + """ + + +class CredentialVerificationError(AppException): + """ + Raised when credential verification fails + """ + + +class ChallengeExpiredError(AppException): + """ + Raised when WebAuthn challenge has expired or not found + """ + + +class DatabaseError(AppException): + """ + Raised when database operation fails + """ + + +class AuthenticationError(AppException): + """ + Raised when authentication fails + """ + + +class InvalidDataError(AppException): + """ + Raised when input data is invalid + """ + + +class EncryptionError(AppException): + """ + Raised when message encryption fails + """ + + +class DecryptionError(AppException): + """ + Raised when message decryption fails + """ + + +class RatchetStateNotFoundError(AppException): + """ + Raised when ratchet state not found for conversation + """ + + +class KeyExchangeError(AppException): + """ + Raised when X3DH key exchange fails + """ diff --git a/PROJECTS/encrypted-p2p-chat/backend/app/core/passkey/__init__.py b/PROJECTS/encrypted-p2p-chat/backend/app/core/passkey/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/PROJECTS/encrypted-p2p-chat/backend/app/core/passkey/passkey_manager.py b/PROJECTS/encrypted-p2p-chat/backend/app/core/passkey/passkey_manager.py new file mode 100644 index 00000000..ef2807a6 --- /dev/null +++ b/PROJECTS/encrypted-p2p-chat/backend/app/core/passkey/passkey_manager.py @@ -0,0 +1,210 @@ +""" +ⒸAngelaMos | 2025 +WebAuthn passkey manager using py_webauthn library +""" + +import logging +import secrets +from typing import Any + +from webauthn.helpers import ( + bytes_to_base64url, + options_to_json_dict, +) +from webauthn import ( + generate_authentication_options, + generate_registration_options, + verify_authentication_response, + verify_registration_response, +) +from webauthn.helpers.structs import ( + AttestationConveyancePreference, + AuthenticatorSelectionCriteria, + PublicKeyCredentialDescriptor, + ResidentKeyRequirement, + UserVerificationRequirement, +) + +from app.config import ( + settings, + WEBAUTHN_CHALLENGE_BYTES, +) +from app.schemas.auth import ( + AuthenticationOptionsResponse, + RegistrationOptionsResponse, + VerifiedAuthentication, + VerifiedRegistration, +) + + +logger = logging.getLogger(__name__) + + +class PasskeyManager: + """ + WebAuthn passkey manager for registration and authentication + """ + def __init__(self) -> None: + """ + Initialize passkey manager with RP configuration + """ + self.rp_id = settings.RP_ID + self.rp_name = settings.RP_NAME + self.rp_origin = settings.RP_ORIGIN + + def generate_registration_options( + self, + user_id: bytes, + username: str, + display_name: str, + exclude_credentials: list[bytes] | None = None, + ) -> RegistrationOptionsResponse: + """ + Generate WebAuthn registration options for passkey creation + """ + challenge = secrets.token_bytes(WEBAUTHN_CHALLENGE_BYTES) + + exclude_creds = [] + if exclude_credentials: + exclude_creds = [ + PublicKeyCredentialDescriptor(id = cred_id) + for cred_id in exclude_credentials + ] + + options = generate_registration_options( + rp_id = self.rp_id, + rp_name = self.rp_name, + user_id = user_id, + user_name = username, + user_display_name = display_name, + challenge = challenge, + attestation = AttestationConveyancePreference.NONE, + authenticator_selection = AuthenticatorSelectionCriteria( + resident_key = ResidentKeyRequirement.REQUIRED, + user_verification = UserVerificationRequirement.PREFERRED, + ), + exclude_credentials = exclude_creds, + ) + + logger.debug("Generated registration options for user %s", username) + + return RegistrationOptionsResponse( + options = options_to_json_dict(options), + challenge = challenge, + ) + + def verify_registration( + self, + credential: dict[str, + Any], + expected_challenge: bytes, + ) -> VerifiedRegistration: + """ + Verify WebAuthn registration response + """ + verified_registration = verify_registration_response( + credential = credential, + expected_challenge = expected_challenge, + expected_rp_id = self.rp_id, + expected_origin = self.rp_origin, + ) + + logger.info( + "Verified registration for credential %s...", + bytes_to_base64url(verified_registration.credential_id)[: 16] + ) + + return VerifiedRegistration( + credential_id = verified_registration.credential_id, + credential_public_key = verified_registration.credential_public_key, + sign_count = verified_registration.sign_count, + aaguid = verified_registration.aaguid, + attestation_object = verified_registration.attestation_object, + credential_type = verified_registration.credential_type, + user_verified = verified_registration.user_verified, + attestation_format = verified_registration.fmt, + credential_device_type = verified_registration.credential_device_type, + credential_backed_up = verified_registration.credential_backed_up, + backup_eligible = verified_registration.credential_backed_up, + backup_state = verified_registration.credential_backed_up, + ) + + def generate_authentication_options( + self, + allow_credentials: list[bytes] | None = None, + ) -> AuthenticationOptionsResponse: + """ + Generate WebAuthn authentication options for passkey verification + """ + challenge = secrets.token_bytes(WEBAUTHN_CHALLENGE_BYTES) + + allow_creds = None + if allow_credentials: + allow_creds = [ + PublicKeyCredentialDescriptor(id = cred_id) + for cred_id in allow_credentials + ] + + options = generate_authentication_options( + rp_id = self.rp_id, + challenge = challenge, + allow_credentials = allow_creds, + user_verification = UserVerificationRequirement.PREFERRED, + ) + + logger.debug("Generated authentication options") + + return AuthenticationOptionsResponse( + options = options_to_json_dict(options), + challenge = challenge, + ) + + def verify_authentication( + self, + credential: dict[str, + Any], + expected_challenge: bytes, + credential_public_key: bytes, + credential_current_sign_count: int, + ) -> VerifiedAuthentication: + """ + Verify WebAuthn authentication response and check signature counter + """ + verified_authentication = verify_authentication_response( + credential = credential, + expected_challenge = expected_challenge, + expected_rp_id = self.rp_id, + expected_origin = self.rp_origin, + credential_public_key = credential_public_key, + credential_current_sign_count = credential_current_sign_count, + ) + + new_sign_count = verified_authentication.new_sign_count + + if (credential_current_sign_count != 0 and new_sign_count != 0 + and new_sign_count <= credential_current_sign_count): + logger.error( + "Signature counter did not increase: current=%s, new=%s. Possible cloned authenticator detected!", + credential_current_sign_count, + new_sign_count + ) + raise ValueError( + "Signature counter anomaly detected - potential cloned authenticator" + ) + + logger.info( + "Verified authentication with counter %s -> %s", + credential_current_sign_count, + new_sign_count + ) + + return VerifiedAuthentication( + new_sign_count = new_sign_count, + credential_id = verified_authentication.credential_id, + user_verified = verified_authentication.user_verified, + backup_state = verified_authentication.credential_backed_up, + backup_eligible = verified_authentication.credential_backup_eligible, + ) + + +passkey_manager = PasskeyManager() diff --git a/PROJECTS/encrypted-p2p-chat/backend/app/core/redis_manager.py b/PROJECTS/encrypted-p2p-chat/backend/app/core/redis_manager.py new file mode 100644 index 00000000..2a012b60 --- /dev/null +++ b/PROJECTS/encrypted-p2p-chat/backend/app/core/redis_manager.py @@ -0,0 +1,174 @@ +""" +ⒸAngelaMos | 2025 +Redis manager for WebAuthn challenge storage with TTL +""" + +import logging + +import redis.asyncio as redis + +from app.config import ( + settings, + WEBAUTHN_CHALLENGE_TTL_SECONDS, +) + + +logger = logging.getLogger(__name__) + + +class RedisManager: + """ + Redis manager for challenge storage with automatic expiration + """ + def __init__(self) -> None: + """ + Initialize Redis manager with connection pool + """ + self.pool: redis.ConnectionPool | None = None + self.client: redis.Redis | None = None + + async def connect(self) -> None: + """ + Establish Redis connection with connection pooling + """ + if self.pool is not None: + return + + self.pool = redis.ConnectionPool.from_url( + str(settings.REDIS_URL), + max_connections = 50, + decode_responses = False, + ) + self.client = redis.Redis(connection_pool = self.pool) + + await self.client.ping() + logger.info("Connected to Redis at %s", settings.REDIS_URL) + + async def disconnect(self) -> None: + """ + Close Redis connection + """ + if self.client: + await self.client.aclose() + if self.pool: + await self.pool.aclose() + logger.info("Disconnected from Redis") + + async def set_registration_challenge( + self, + user_id: str, + challenge: bytes, + ttl: int = WEBAUTHN_CHALLENGE_TTL_SECONDS, + ) -> None: + """ + Store registration challenge with TTL + """ + if not self.client: + raise RuntimeError("Redis client not connected") + + key = f"webauthn:reg_challenge:{user_id}" + await self.client.setex(key, ttl, challenge.hex()) + logger.debug( + "Stored registration challenge for user %s with %ss TTL", + user_id, + ttl + ) + + async def get_registration_challenge(self, user_id: str) -> bytes | None: + """ + Retrieve and delete registration challenge (one-time use) + """ + if not self.client: + raise RuntimeError("Redis client not connected") + + key = f"webauthn:reg_challenge:{user_id}" + + async with self.client.pipeline() as pipe: + await pipe.get(key) + await pipe.delete(key) + results = await pipe.execute() + + challenge_hex = results[0] + if challenge_hex is None: + return None + + return bytes.fromhex(challenge_hex.decode()) + + async def set_authentication_challenge( + self, + user_id: str, + challenge: bytes, + ttl: int = WEBAUTHN_CHALLENGE_TTL_SECONDS, + ) -> None: + """ + Store authentication challenge with TTL + """ + if not self.client: + raise RuntimeError("Redis client not connected") + + key = f"webauthn:auth_challenge:{user_id}" + await self.client.setex(key, ttl, challenge.hex()) + logger.debug( + "Stored authentication challenge for user %s with %ss TTL", + user_id, + ttl + ) + + async def get_authentication_challenge(self, user_id: str) -> bytes | None: + """ + Retrieve and delete authentication challenge (one-time use) + """ + if not self.client: + raise RuntimeError("Redis client not connected") + + key = f"webauthn:auth_challenge:{user_id}" + + async with self.client.pipeline() as pipe: + await pipe.get(key) + await pipe.delete(key) + results = await pipe.execute() + + challenge_hex = results[0] + if challenge_hex is None: + return None + + return bytes.fromhex(challenge_hex.decode()) + + async def set_value( + self, + key: str, + value: str, + ttl: int | None = None + ) -> None: + """ + Generic set with optional TTL + """ + if not self.client: + raise RuntimeError("Redis client not connected") + + if ttl: + await self.client.setex(key, ttl, value) + else: + await self.client.set(key, value) + + async def get_value(self, key: str) -> str | None: + """ + Generic get + """ + if not self.client: + raise RuntimeError("Redis client not connected") + + value = await self.client.get(key) + return value.decode() if value else None + + async def delete_value(self, key: str) -> None: + """ + Generic delete + """ + if not self.client: + raise RuntimeError("Redis client not connected") + + await self.client.delete(key) + + +redis_manager = RedisManager() diff --git a/PROJECTS/encrypted-p2p-chat/backend/app/core/surreal_manager.py b/PROJECTS/encrypted-p2p-chat/backend/app/core/surreal_manager.py new file mode 100644 index 00000000..109dd80f --- /dev/null +++ b/PROJECTS/encrypted-p2p-chat/backend/app/core/surreal_manager.py @@ -0,0 +1,264 @@ +""" +ⒸAngelaMos | 2025 +SurrealDB manager with live queries for real time chat features +""" + +import asyncio +import logging +from typing import Any +from collections.abc import Callable + +from surrealdb import AsyncSurreal + +from app.schemas.surreal import ( + LiveMessageUpdate, + LivePresenceUpdate, + MessageResponse, + PresenceResponse, + RoomResponse, +) +from app.config import DEFAULT_MESSAGE_LIMIT, settings +from app.core.enums import PresenceStatus + + +logger = logging.getLogger(__name__) + + +class SurrealDBManager: + """ + SurrealDB connection manager with live query subscriptions + """ + def __init__(self) -> None: + """ + Initialize SurrealDB manager + """ + self.db: AsyncSurreal | None = None + self.live_queries: dict[str, str] = {} + self._connected = False + + async def connect(self) -> None: + """ + Establish connection to SurrealDB + """ + if self._connected: + return + + self.db = AsyncSurreal(settings.SURREAL_URL) + await self.db.connect() + + await self.db.signin( + { + "username": settings.SURREAL_USER, + "password": settings.SURREAL_PASSWORD, + } + ) + + await self.db.use( + settings.SURREAL_NAMESPACE, + settings.SURREAL_DATABASE, + ) + + self._connected = True + logger.info("Connected to SurrealDB at %s", settings.SURREAL_URL) + + async def disconnect(self) -> None: + """ + Close SurrealDB connection + """ + if self.db and self._connected: + await self.db.close() + self._connected = False + logger.info("Disconnected from SurrealDB") + + async def ensure_connected(self) -> None: + """ + Ensure connection is established + """ + if not self._connected: + await self.connect() + + async def create_message( + self, + message_data: dict[str, + Any] + ) -> MessageResponse: + """ + Create a new message in SurrealDB + """ + await self.ensure_connected() + result = await self.db.create("messages", message_data) + result["id"] = str(result["id"]) + return MessageResponse(**result) + + async def get_room_messages( + self, + room_id: str, + limit: int = DEFAULT_MESSAGE_LIMIT, + offset: int = 0, + ) -> list[MessageResponse]: + """ + Get messages for a specific room with pagination + """ + await self.ensure_connected() + query = """ + SELECT * FROM messages + WHERE room_id = $room_id + ORDER BY created_at DESC + LIMIT $limit + START $offset + """ + result = await self.db.query( + query, + { + "room_id": room_id, + "limit": limit, + "offset": offset, + } + ) + messages = result[0]["result"] if result else [] + return [MessageResponse(**msg) for msg in messages] + + async def create_room(self, room_data: dict[str, Any]) -> RoomResponse: + """ + Create a new chat room + """ + await self.ensure_connected() + result = await self.db.create("rooms", room_data) + result["id"] = str(result["id"]) + return RoomResponse(**result) + + async def get_user_rooms(self, user_id: str) -> list[RoomResponse]: + """ + Get all rooms a user is part of using graph traversal + """ + await self.ensure_connected() + query = """ + SELECT ->member_of->rooms.* AS rooms + FROM $user_id + """ + result = await self.db.query(query, {"user_id": f"users:{user_id}"}) + rooms = result[0]["result"][0]["rooms"] if result else [] + return [RoomResponse(**room) for room in rooms] + + async def update_presence( + self, + user_id: str, + status: str, + last_seen: str, + ) -> None: + """ + Update user presence status + """ + await self.ensure_connected() + await self.db.merge( + f"presence:{user_id}", + { + "user_id": user_id, + "status": status, + "last_seen": last_seen, + "updated_at": "time::now()", + } + ) + + async def get_room_presence(self, room_id: str) -> list[PresenceResponse]: + """ + Get presence for all users in a room + """ + await self.ensure_connected() + query = f""" + SELECT ->member_of->rooms->has_members<-presence.* AS users + FROM $room_id + WHERE status = '{PresenceStatus.ONLINE.value}' + """ + result = await self.db.query(query, {"room_id": f"rooms:{room_id}"}) + presence_list = result[0]["result"] if result else [] + return [PresenceResponse(**p) for p in presence_list] + + async def live_messages( + self, + room_id: str, + callback: Callable[[LiveMessageUpdate], + None], + ) -> str: + """ + Subscribe to live message updates for a room + """ + await self.ensure_connected() + query = f"LIVE SELECT * FROM messages WHERE room_id = '{room_id}'" + + def wrapper(data: dict[str, Any]) -> None: + update = LiveMessageUpdate(**data) + callback(update) + + live_id = await self.db.live(query, wrapper) + self.live_queries[room_id] = live_id + return live_id + + async def live_presence( + self, + room_id: str, + callback: Callable[[LivePresenceUpdate], + None], + ) -> str: + """ + Subscribe to live presence updates for a room + """ + await self.ensure_connected() + query = f"LIVE SELECT * FROM presence WHERE room_id = '{room_id}'" + + def wrapper(data: dict[str, Any]) -> None: + update = LivePresenceUpdate(**data) + callback(update) + + live_id = await self.db.live(query, wrapper) + self.live_queries[f"presence_{room_id}"] = live_id + return live_id + + async def kill_live_query(self, live_id: str) -> None: + """ + Stop a live query subscription + """ + await self.ensure_connected() + await self.db.kill(live_id) + + for key, query_id in list(self.live_queries.items()): + if query_id == live_id: + del self.live_queries[key] + break + + async def create_ephemeral_room( + self, + room_data: dict[str, + Any], + ttl_seconds: int, + ) -> RoomResponse: + """ + Create an ephemeral room that auto-deletes after TTL + """ + await self.ensure_connected() + room = await self.db.create("rooms", room_data) + room_id = str(room["id"]) + room["id"] = room_id + + asyncio.create_task(self._schedule_room_deletion(room_id, ttl_seconds)) + return RoomResponse(**room) + + async def _schedule_room_deletion( + self, + room_id: str, + ttl_seconds: int + ) -> None: + """ + Schedule automatic deletion of a room after TTL + """ + await asyncio.sleep(ttl_seconds) + await self.ensure_connected() + await self.db.delete(room_id) + logger.info( + "Deleted ephemeral room %s after %ss TTL", + room_id, + ttl_seconds + ) + + +surreal_db = SurrealDBManager() diff --git a/PROJECTS/encrypted-p2p-chat/backend/app/core/websocket_manager.py b/PROJECTS/encrypted-p2p-chat/backend/app/core/websocket_manager.py new file mode 100644 index 00000000..6d14a1e5 --- /dev/null +++ b/PROJECTS/encrypted-p2p-chat/backend/app/core/websocket_manager.py @@ -0,0 +1,281 @@ +""" +ⒸAngelaMos | 2025 +WebSocket connection manager for real time messaging +""" + +import asyncio +import logging +from datetime import UTC, datetime +from typing import Any +from uuid import UUID + +from fastapi import WebSocket + +from app.config import ( + WS_HEARTBEAT_INTERVAL, + WS_MAX_CONNECTIONS_PER_USER, +) +from app.core.surreal_manager import surreal_db +from app.schemas.surreal import LiveMessageUpdate +from app.schemas.websocket import ( + EncryptedMessageWS, + ErrorMessageWS, + WSHeartbeat, +) +from app.services.presence_service import presence_service + + +logger = logging.getLogger(__name__) + + +class ConnectionManager: + """ + Manages WebSocket connections and message broadcasting + """ + def __init__(self) -> None: + """ + Initialize connection manager with empty connection pool + """ + self.active_connections: dict[UUID, list[WebSocket]] = {} + self.live_query_ids: dict[UUID, str] = {} + self.heartbeat_tasks: dict[UUID, asyncio.Task] = {} + + async def connect(self, websocket: WebSocket, user_id: UUID) -> bool: + """ + Accept WebSocket connection and register user + """ + await websocket.accept() + + if user_id not in self.active_connections: + self.active_connections[user_id] = [] + + if len(self.active_connections[user_id]) >= WS_MAX_CONNECTIONS_PER_USER: + logger.warning( + "User %s exceeded max connections (%s)", + user_id, + WS_MAX_CONNECTIONS_PER_USER + ) + await self._send_error( + websocket, + "max_connections", + f"Maximum {WS_MAX_CONNECTIONS_PER_USER} connections per user" + ) + await websocket.close() + return False + + self.active_connections[user_id].append(websocket) + logger.info( + "User %s connected via WebSocket (total: %s)", + user_id, + len(self.active_connections[user_id]) + ) + + await presence_service.set_user_online(user_id) + + self.heartbeat_tasks[user_id] = asyncio.create_task( + self._heartbeat_loop(websocket, + user_id) + ) + + await self._subscribe_to_messages(user_id) + + return True + + async def disconnect(self, websocket: WebSocket, user_id: UUID) -> None: + """ + Remove WebSocket connection and cleanup resources + """ + if user_id in self.active_connections: + if websocket in self.active_connections[user_id]: + self.active_connections[user_id].remove(websocket) + + if not self.active_connections[user_id]: + del self.active_connections[user_id] + + await presence_service.set_user_offline(user_id) + + if user_id in self.live_query_ids: + try: + await surreal_db.kill_live_query( + self.live_query_ids[user_id] + ) + except Exception as e: + logger.error( + "Failed to kill live query for %s: %s", + user_id, + e + ) + del self.live_query_ids[user_id] + + if user_id in self.heartbeat_tasks: + self.heartbeat_tasks[user_id].cancel() + del self.heartbeat_tasks[user_id] + + logger.info("User %s fully disconnected", user_id) + else: + logger.info( + "User %s connection closed (remaining: %s)", + user_id, + len(self.active_connections[user_id]) + ) + + async def send_message(self, user_id: UUID, message: dict[str, Any]) -> None: + """ + Send message to all connections for a specific user + """ + if user_id not in self.active_connections: + logger.debug("User %s not connected, cannot send message", user_id) + return + + dead_connections = [] + + for websocket in self.active_connections[user_id]: + try: + await websocket.send_json(message) + except Exception as e: + logger.error("Failed to send message to %s: %s", user_id, e) + dead_connections.append(websocket) + + for dead_ws in dead_connections: + await self.disconnect(dead_ws, user_id) + + async def broadcast_to_room( + self, + room_id: str, + message: dict[str, + Any] + ) -> None: + """ + Broadcast message to all users in a room + """ + online_users = await presence_service.get_room_online_users(room_id) + + for user_presence in online_users: + try: + user_id = UUID(user_presence["user_id"]) + await self.send_message(user_id, message) + except Exception as e: + logger.error( + "Failed to broadcast to user %s: %s", + user_presence['user_id'], + e + ) + + async def _heartbeat_loop(self, websocket: WebSocket, user_id: UUID) -> None: + """ + Send periodic heartbeat pings to keep connection alive + """ + try: + while True: + await asyncio.sleep(WS_HEARTBEAT_INTERVAL) + + if user_id not in self.active_connections: + break + + if websocket not in self.active_connections[user_id]: + break + + heartbeat = WSHeartbeat(timestamp = datetime.now(UTC)) + + try: + await websocket.send_json(heartbeat.model_dump(mode = "json")) + await presence_service.update_last_seen(user_id) + except Exception as e: + logger.error("Heartbeat failed for user %s: %s", user_id, e) + await self.disconnect(websocket, user_id) + break + except asyncio.CancelledError: + logger.debug("Heartbeat task cancelled for user %s", user_id) + + async def _subscribe_to_messages(self, user_id: UUID) -> None: + """ + Subscribe to live message updates for the user + """ + try: + + def message_callback(update: LiveMessageUpdate) -> None: + """ + Handle incoming message from SurrealDB live query + """ + asyncio.create_task(self._handle_live_message(user_id, update)) + + live_id = await surreal_db.live_messages( + room_id = str(user_id), + callback = message_callback + ) + + self.live_query_ids[user_id] = live_id + logger.debug("Subscribed to live messages for user %s", user_id) + except Exception as e: + logger.error("Failed to subscribe to messages for %s: %s", user_id, e) + + async def _handle_live_message( + self, + user_id: UUID, + update: LiveMessageUpdate + ) -> None: + """ + Process live message update and forward to WebSocket + """ + if update.action != "CREATE": + return + + message_data = update.result + + ws_message = EncryptedMessageWS( + message_id = message_data.id, + sender_id = message_data.sender_id, + recipient_id = str(user_id), + ciphertext = message_data.encrypted_content, + nonce = "", + header = message_data.encrypted_header, + sender_username = "", + timestamp = message_data.created_at + ) + + await self.send_message(user_id, ws_message.model_dump(mode = "json")) + + async def _send_error( + self, + websocket: WebSocket, + error_code: str, + error_message: str + ) -> None: + """ + Send error message to WebSocket connection + """ + error = ErrorMessageWS( + error_code = error_code, + error_message = error_message, + timestamp = datetime.now(UTC) + ) + + try: + await websocket.send_json(error.model_dump(mode = "json")) + except Exception as e: + logger.error("Failed to send error message: %s", e) + + def get_active_users(self) -> list[UUID]: + """ + Get list of all currently connected user IDs + """ + return list(self.active_connections.keys()) + + def get_connection_count(self, user_id: UUID) -> int: + """ + Get number of active connections for a user + """ + if user_id not in self.active_connections: + return 0 + return len(self.active_connections[user_id]) + + def is_user_connected(self, user_id: UUID) -> bool: + """ + Check if user has any active connections + """ + return user_id in self.active_connections and len( + self.active_connections[user_id] + ) > 0 + + +connection_manager = ConnectionManager() diff --git a/PROJECTS/encrypted-p2p-chat/backend/app/factory.py b/PROJECTS/encrypted-p2p-chat/backend/app/factory.py new file mode 100644 index 00000000..4e016f37 --- /dev/null +++ b/PROJECTS/encrypted-p2p-chat/backend/app/factory.py @@ -0,0 +1,114 @@ +""" +ⒸAngelaMos | 2025 +FastAPI application factory +""" + +import logging +from collections.abc import AsyncGenerator +from contextlib import asynccontextmanager + +from fastapi import FastAPI +from fastapi.responses import ORJSONResponse +from fastapi.middleware.cors import CORSMiddleware +from fastapi.middleware.gzip import GZipMiddleware + +from app.schemas.common import ( + HealthResponse, + RootResponse, +) +from app.config import ( + settings, + APP_VERSION, + APP_STATUS, + APP_DESCRIPTION, + GZIP_MINIMUM_SIZE, +) +from app.models.Base import init_db +from app.api.auth import router as auth_router +from app.api.rooms import router as rooms_router +from app.core.surreal_manager import surreal_db +from app.core.redis_manager import redis_manager +from app.api.encryption import router as encryption_router +from app.api.websocket import router as websocket_router +from app.core.exception_handlers import register_exception_handlers + + +logger = logging.getLogger(__name__) + + +@asynccontextmanager +async def lifespan(app: FastAPI) -> AsyncGenerator[None]: + """ + Application lifespan manager for startup and shutdown events + """ + logger.info("Starting %s in %s mode", settings.APP_NAME, settings.ENV) + + await init_db() + logger.info("PostgreSQL database initialized") + + await redis_manager.connect() + logger.info("Redis connected") + + await surreal_db.connect() + logger.info("SurrealDB connected") + + yield + + logger.info("Shutting down application") + + await redis_manager.disconnect() + await surreal_db.disconnect() + + +def create_app() -> FastAPI: + """ + Create and configure the FastAPI application instance + """ + app = FastAPI( + title = settings.APP_NAME, + description = APP_DESCRIPTION, + version = APP_VERSION, + docs_url = "/docs" if settings.is_development else None, + redoc_url = "/redoc" if settings.is_development else None, + default_response_class = ORJSONResponse, + lifespan = lifespan, + ) + + app.add_middleware( + CORSMiddleware, + allow_origins = settings.CORS_ORIGINS, + allow_credentials = True, + allow_methods = ["*"], + allow_headers = ["*"], + expose_headers = ["*"], + ) + + app.add_middleware(GZipMiddleware, minimum_size = GZIP_MINIMUM_SIZE) + + register_exception_handlers(app) + + @app.get("/", tags = ["root"]) + async def root() -> RootResponse: + """ + Root endpoint returning API status + """ + return RootResponse( + app = settings.APP_NAME, + version = APP_VERSION, + status = APP_STATUS, + environment = settings.ENV, + ) + + @app.get("/health", tags = ["health"]) + async def health() -> HealthResponse: + """ + Health check endpoint for monitoring + """ + return HealthResponse(status = "healthy") + + app.include_router(auth_router) + app.include_router(rooms_router) + app.include_router(encryption_router) + app.include_router(websocket_router) + + return app diff --git a/PROJECTS/encrypted-p2p-chat/backend/app/main.py b/PROJECTS/encrypted-p2p-chat/backend/app/main.py new file mode 100644 index 00000000..837cf498 --- /dev/null +++ b/PROJECTS/encrypted-p2p-chat/backend/app/main.py @@ -0,0 +1,31 @@ +""" +ⒸAngelaMos | 2025 +Application entry point with uvicorn server command +""" + +import uvicorn + +from app.config import ( + DEFAULT_HOST, + DEFAULT_PORT, + settings, +) + + +def main() -> None: + """ + Run the FastAPI application with uvicorn + """ + uvicorn.run( + "app.factory:create_app", + factory = True, + host = DEFAULT_HOST, + port = DEFAULT_PORT, + reload = settings.is_development, + log_level = "debug" if settings.DEBUG else "info", + access_log = True, + ) + + +if __name__ == "__main__": + main() diff --git a/PROJECTS/encrypted-p2p-chat/backend/app/models/Base.py b/PROJECTS/encrypted-p2p-chat/backend/app/models/Base.py new file mode 100644 index 00000000..61dce4a6 --- /dev/null +++ b/PROJECTS/encrypted-p2p-chat/backend/app/models/Base.py @@ -0,0 +1,67 @@ +""" +ⒸAngelaMos | 2025 +Base SQLModel class with async PostgreSQL engine setup +""" + +from datetime import UTC, datetime +from collections.abc import AsyncGenerator + +from sqlalchemy import DateTime +from sqlalchemy.ext.asyncio import ( + AsyncSession, + create_async_engine, +) +from sqlmodel import Field, SQLModel +from sqlalchemy.orm import sessionmaker + +from app.config import settings + + +class BaseDBModel(SQLModel): + """ + Base model with common timestamp fields + """ + created_at: datetime = Field( + default_factory = lambda: datetime.now(UTC), + nullable = False, + sa_type = DateTime(timezone = True), + ) + updated_at: datetime = Field( + default_factory = lambda: datetime.now(UTC), + nullable = False, + sa_type = DateTime(timezone = True), + sa_column_kwargs = {"onupdate": lambda: datetime.now(UTC)}, + ) + + +# Create async engine for PostgreSQL +engine = create_async_engine( + str(settings.DATABASE_URL), + echo = settings.DEBUG, + pool_size = settings.DB_POOL_SIZE, + max_overflow = settings.DB_MAX_OVERFLOW, + pool_pre_ping = True, +) + +# Create async session factory +async_session_maker = sessionmaker( # type: ignore[call-overload] + bind = engine, + class_ = AsyncSession, + expire_on_commit = False, +) + + +async def get_session() -> AsyncGenerator[AsyncSession]: + """ + Dependency for getting database sessions + """ + async with async_session_maker() as session: + yield session + + +async def init_db() -> None: + """ + Initialize database tables + """ + async with engine.begin() as conn: + await conn.run_sync(SQLModel.metadata.create_all) diff --git a/PROJECTS/encrypted-p2p-chat/backend/app/models/Credential.py b/PROJECTS/encrypted-p2p-chat/backend/app/models/Credential.py new file mode 100644 index 00000000..43d13506 --- /dev/null +++ b/PROJECTS/encrypted-p2p-chat/backend/app/models/Credential.py @@ -0,0 +1,78 @@ +""" +ⒸAngelaMos | 2025 +WebAuthn credential model for passkey storage +""" + +from datetime import datetime +from typing import TYPE_CHECKING +from uuid import UUID + +from sqlalchemy import DateTime +from sqlmodel import Field, Relationship + +from app.config import ( + AAGUID_MAX_LENGTH, + ATTESTATION_TYPE_MAX_LENGTH, + CREDENTIAL_ID_MAX_LENGTH, + DISPLAY_NAME_MAX_LENGTH, + PUBLIC_KEY_MAX_LENGTH, + TRANSPORT_MAX_LENGTH, +) +from app.models.Base import BaseDBModel + +if TYPE_CHECKING: + from app.models.User import User + + +class Credential(BaseDBModel, table = True): + """ + WebAuthn/FIDO2 passkey credential + """ + __tablename__ = "credentials" + + id: int = Field(default = None, primary_key = True) + credential_id: str = Field( + unique = True, + index = True, + nullable = False, + max_length = CREDENTIAL_ID_MAX_LENGTH + ) + public_key: str = Field(nullable = False, max_length = PUBLIC_KEY_MAX_LENGTH) + sign_count: int = Field(default = 0, nullable = False) + aaguid: str | None = Field(default = None, max_length = AAGUID_MAX_LENGTH) + + # WebAuthn Level 3 fields + backup_eligible: bool = Field(default = False, nullable = False) + backup_state: bool = Field(default = False, nullable = False) + attestation_type: str | None = Field( + default = None, + max_length = ATTESTATION_TYPE_MAX_LENGTH + ) + transports: str | None = Field( + default = None, + max_length = TRANSPORT_MAX_LENGTH + ) + + # User relationship + user_id: UUID = Field( + foreign_key = "users.id", + nullable = False, + index = True + ) + user: "User" = Relationship(back_populates = "credentials") + + # Device metadata + device_name: str | None = Field( + default = None, + max_length = DISPLAY_NAME_MAX_LENGTH + ) + last_used_at: datetime | None = Field( + default = None, + sa_type = DateTime(timezone = True), + ) + + def __repr__(self) -> str: + """ + String representation of Credential + """ + return f"" diff --git a/PROJECTS/encrypted-p2p-chat/backend/app/models/IdentityKey.py b/PROJECTS/encrypted-p2p-chat/backend/app/models/IdentityKey.py new file mode 100644 index 00000000..2be452b6 --- /dev/null +++ b/PROJECTS/encrypted-p2p-chat/backend/app/models/IdentityKey.py @@ -0,0 +1,48 @@ +""" +ⒸAngelaMos | 2025 +X3DH identity key model for long term user identification +""" + +from typing import TYPE_CHECKING +from uuid import UUID + +from sqlmodel import Field + +from app.config import IDENTITY_KEY_LENGTH +from app.models.Base import BaseDBModel + +if TYPE_CHECKING: + pass + + +class IdentityKey(BaseDBModel, table = True): + """ + Long term X25519 identity key for X3DH protocol + """ + __tablename__ = "identity_keys" + + id: int = Field(default = None, primary_key = True) + user_id: UUID = Field( + foreign_key = "users.id", + nullable = False, + unique = True, + index = True + ) + + public_key: str = Field(nullable = False, max_length = IDENTITY_KEY_LENGTH) + private_key: str = Field(nullable = False, max_length = IDENTITY_KEY_LENGTH) + + public_key_ed25519: str = Field( + nullable = False, + max_length = IDENTITY_KEY_LENGTH + ) + private_key_ed25519: str = Field( + nullable = False, + max_length = IDENTITY_KEY_LENGTH + ) + + def __repr__(self) -> str: + """ + String representation of IdentityKey + """ + return f"" diff --git a/PROJECTS/encrypted-p2p-chat/backend/app/models/OneTimePrekey.py b/PROJECTS/encrypted-p2p-chat/backend/app/models/OneTimePrekey.py new file mode 100644 index 00000000..00c421cb --- /dev/null +++ b/PROJECTS/encrypted-p2p-chat/backend/app/models/OneTimePrekey.py @@ -0,0 +1,45 @@ +""" +ⒸAngelaMos | 2025 +X3DH one time prekey model for single use key exchange +""" + +from typing import TYPE_CHECKING +from uuid import UUID + +from sqlmodel import Field + +from app.config import ONE_TIME_PREKEY_LENGTH +from app.models.Base import BaseDBModel + +if TYPE_CHECKING: + pass + + +class OneTimePrekey(BaseDBModel, table = True): + """ + X25519 one time prekey consumed after single use for X3DH protocol + """ + __tablename__ = "one_time_prekeys" + + id: int = Field(default = None, primary_key = True) + user_id: UUID = Field( + foreign_key = "users.id", + nullable = False, + index = True + ) + + key_id: int = Field(nullable = False, index = True) + + public_key: str = Field(nullable = False, max_length = ONE_TIME_PREKEY_LENGTH) + private_key: str = Field( + nullable = False, + max_length = ONE_TIME_PREKEY_LENGTH + ) + + is_used: bool = Field(default = False, nullable = False, index = True) + + def __repr__(self) -> str: + """ + String representation of OneTimePrekey + """ + return f"" diff --git a/PROJECTS/encrypted-p2p-chat/backend/app/models/RatchetState.py b/PROJECTS/encrypted-p2p-chat/backend/app/models/RatchetState.py new file mode 100644 index 00000000..0cf5e53b --- /dev/null +++ b/PROJECTS/encrypted-p2p-chat/backend/app/models/RatchetState.py @@ -0,0 +1,68 @@ +""" +ⒸAngelaMos | 2025 +Double Ratchet state model for per conversation encryption state +""" + +from typing import TYPE_CHECKING +from uuid import UUID + +from sqlmodel import Field + +from app.config import RATCHET_STATE_MAX_LENGTH +from app.models.Base import BaseDBModel + +if TYPE_CHECKING: + pass + + +class RatchetState(BaseDBModel, table = True): + """ + Double Ratchet algorithm state for a conversation between two users + """ + __tablename__ = "ratchet_states" + + id: int = Field(default = None, primary_key = True) + + user_id: UUID = Field( + foreign_key = "users.id", + nullable = False, + index = True + ) + peer_user_id: UUID = Field( + foreign_key = "users.id", + nullable = False, + index = True + ) + + dh_private_key: str | None = Field( + default = None, + max_length = RATCHET_STATE_MAX_LENGTH + ) + dh_public_key: str | None = Field( + default = None, + max_length = RATCHET_STATE_MAX_LENGTH + ) + dh_peer_public_key: str | None = Field( + default = None, + max_length = RATCHET_STATE_MAX_LENGTH + ) + + root_key: str = Field(nullable = False, max_length = RATCHET_STATE_MAX_LENGTH) + sending_chain_key: str = Field( + nullable = False, + max_length = RATCHET_STATE_MAX_LENGTH + ) + receiving_chain_key: str = Field( + nullable = False, + max_length = RATCHET_STATE_MAX_LENGTH + ) + + sending_message_number: int = Field(default = 0, nullable = False) + receiving_message_number: int = Field(default = 0, nullable = False) + previous_sending_chain_length: int = Field(default = 0, nullable = False) + + def __repr__(self) -> str: + """ + String representation of RatchetState + """ + return f"" diff --git a/PROJECTS/encrypted-p2p-chat/backend/app/models/SignedPrekey.py b/PROJECTS/encrypted-p2p-chat/backend/app/models/SignedPrekey.py new file mode 100644 index 00000000..d93fda81 --- /dev/null +++ b/PROJECTS/encrypted-p2p-chat/backend/app/models/SignedPrekey.py @@ -0,0 +1,50 @@ +""" +ⒸAngelaMos | 2025 +X3DH signed prekey model for medium term key rotation +""" + +from datetime import datetime +from typing import TYPE_CHECKING +from uuid import UUID + +from sqlalchemy import DateTime +from sqlmodel import Field + +from app.config import SIGNATURE_LENGTH, SIGNED_PREKEY_LENGTH +from app.models.Base import BaseDBModel + +if TYPE_CHECKING: + pass + + +class SignedPrekey(BaseDBModel, table = True): + """ + X25519 signed prekey rotated every 48 hours for X3DH protocol + """ + __tablename__ = "signed_prekeys" + + id: int = Field(default = None, primary_key = True) + user_id: UUID = Field( + foreign_key = "users.id", + nullable = False, + index = True + ) + + key_id: int = Field(nullable = False, index = True) + + public_key: str = Field(nullable = False, max_length = SIGNED_PREKEY_LENGTH) + private_key: str = Field(nullable = False, max_length = SIGNED_PREKEY_LENGTH) + + signature: str = Field(nullable = False, max_length = SIGNATURE_LENGTH) + + is_active: bool = Field(default = True, nullable = False) + expires_at: datetime | None = Field( + default = None, + sa_type = DateTime(timezone = True), + ) + + def __repr__(self) -> str: + """ + String representation of SignedPrekey + """ + return f"" diff --git a/PROJECTS/encrypted-p2p-chat/backend/app/models/SkippedMessageKey.py b/PROJECTS/encrypted-p2p-chat/backend/app/models/SkippedMessageKey.py new file mode 100644 index 00000000..d484bf75 --- /dev/null +++ b/PROJECTS/encrypted-p2p-chat/backend/app/models/SkippedMessageKey.py @@ -0,0 +1,51 @@ +""" +ⒸAngelaMos | 2025 +Skipped message key storage for out of order Double Ratchet messages +""" + +from typing import TYPE_CHECKING + +from sqlmodel import Field + +from app.models.Base import BaseDBModel +from app.config import RATCHET_STATE_MAX_LENGTH + +if TYPE_CHECKING: + pass + + +class SkippedMessageKey(BaseDBModel, table = True): + """ + Stores message keys for out of order messages in Double Ratchet + """ + __tablename__ = "skipped_message_keys" + + id: int = Field(default = None, primary_key = True) + + ratchet_state_id: int = Field( + foreign_key = "ratchet_states.id", + nullable = False, + index = True + ) + + dh_public_key: str = Field( + nullable = False, + max_length = RATCHET_STATE_MAX_LENGTH, + index = True + ) + message_number: int = Field(nullable = False, index = True) + + message_key: str = Field( + nullable = False, + max_length = RATCHET_STATE_MAX_LENGTH + ) + + def __repr__(self) -> str: + """ + String representation of SkippedMessageKey + """ + return ( + f"" + ) diff --git a/PROJECTS/encrypted-p2p-chat/backend/app/models/User.py b/PROJECTS/encrypted-p2p-chat/backend/app/models/User.py new file mode 100644 index 00000000..708cbe15 --- /dev/null +++ b/PROJECTS/encrypted-p2p-chat/backend/app/models/User.py @@ -0,0 +1,68 @@ +""" +ⒸAngelaMos | 2025 +User model for authentication stored in PostgreSQL +""" + +from typing import TYPE_CHECKING +from uuid import UUID, uuid4 + +from sqlmodel import ( + Field, + Relationship, +) +from app.config import ( + DISPLAY_NAME_MAX_LENGTH, + PREKEY_MAX_LENGTH, + USERNAME_MAX_LENGTH, +) +from app.models.Base import BaseDBModel + +if TYPE_CHECKING: + from app.models.Credential import Credential + + +class User(BaseDBModel, table = True): + """ + User account with WebAuthn passkey authentication + """ + __tablename__ = "users" + + id: UUID = Field( + default_factory = uuid4, + primary_key = True, + nullable = False + ) + username: str = Field( + unique = True, + index = True, + nullable = False, + max_length = USERNAME_MAX_LENGTH + ) + display_name: str = Field( + nullable = False, + max_length = DISPLAY_NAME_MAX_LENGTH + ) + is_active: bool = Field(default = True, nullable = False) + is_verified: bool = Field(default = False, nullable = False) + + credentials: list["Credential"] = Relationship(back_populates = "user") + + identity_key: str | None = Field( + default = None, + max_length = PREKEY_MAX_LENGTH + ) + signed_prekey: str | None = Field( + default = None, + max_length = PREKEY_MAX_LENGTH + ) + signed_prekey_signature: str | None = Field( + default = None, + max_length = PREKEY_MAX_LENGTH + ) + one_time_prekeys: str | None = Field(default = None) + + def __repr__(self) -> str: + """ + String representation of User + """ + return f"" diff --git a/PROJECTS/encrypted-p2p-chat/backend/app/models/__init__.py b/PROJECTS/encrypted-p2p-chat/backend/app/models/__init__.py new file mode 100644 index 00000000..7408f730 --- /dev/null +++ b/PROJECTS/encrypted-p2p-chat/backend/app/models/__init__.py @@ -0,0 +1,33 @@ +""" +ⒸAngelaMos | 2025 +Database models exports +""" + +from app.models.Base import ( + BaseDBModel, + engine, + get_session, + init_db, +) +from app.models.Credential import Credential +from app.models.IdentityKey import IdentityKey +from app.models.OneTimePrekey import OneTimePrekey +from app.models.RatchetState import RatchetState +from app.models.SignedPrekey import SignedPrekey +from app.models.SkippedMessageKey import SkippedMessageKey +from app.models.User import User + + +__all__ = [ + "BaseDBModel", + "Credential", + "IdentityKey", + "OneTimePrekey", + "RatchetState", + "SignedPrekey", + "SkippedMessageKey", + "User", + "engine", + "get_session", + "init_db", +] diff --git a/PROJECTS/encrypted-p2p-chat/backend/app/schemas/__init__.py b/PROJECTS/encrypted-p2p-chat/backend/app/schemas/__init__.py new file mode 100644 index 00000000..8324ba12 --- /dev/null +++ b/PROJECTS/encrypted-p2p-chat/backend/app/schemas/__init__.py @@ -0,0 +1,74 @@ +""" +ⒸAngelaMos | 2025 +Pydantic schemas exports +""" + +from app.schemas.auth import ( + AuthenticationBeginRequest, + AuthenticationCompleteRequest, + AuthenticationOptionsResponse, + RegistrationBeginRequest, + RegistrationCompleteRequest, + RegistrationOptionsResponse, + UserResponse, + VerifiedAuthentication, + VerifiedRegistration, +) +from app.schemas.surreal import ( + LiveMessageUpdate, + LivePresenceUpdate, + LiveQueryUpdate, + MessageResponse, + PresenceResponse, + RoomResponse, +) +from app.schemas.websocket import ( + BaseWSMessage, + EncryptedMessageWS, + ErrorMessageWS, + PresenceUpdateWS, + ReadReceiptWS, + TypingIndicatorWS, + WSConnectionRequest, + WSHeartbeat, +) +from app.schemas.common import HealthResponse, RootResponse +from app.schemas.rooms import ( + CreateRoomRequest, + ParticipantResponse, + RoomAPIResponse, + RoomListResponse, +) + + +__all__ = [ + "MessageResponse", + "RoomResponse", + "PresenceResponse", + "LiveQueryUpdate", + "LiveMessageUpdate", + "LivePresenceUpdate", + "RegistrationOptionsResponse", + "VerifiedRegistration", + "AuthenticationOptionsResponse", + "VerifiedAuthentication", + "RegistrationBeginRequest", + "RegistrationCompleteRequest", + "AuthenticationBeginRequest", + "AuthenticationCompleteRequest", + "UserResponse", + "BaseWSMessage", + "EncryptedMessageWS", + "TypingIndicatorWS", + "PresenceUpdateWS", + "ReadReceiptWS", + "ErrorMessageWS", + "WSConnectionRequest", + "WSHeartbeat", + "RootResponse", + "HealthResponse", + "CreateRoomRequest", + "ParticipantResponse", + "RoomAPIResponse", + "RoomListResponse", +] diff --git a/PROJECTS/encrypted-p2p-chat/backend/app/schemas/auth.py b/PROJECTS/encrypted-p2p-chat/backend/app/schemas/auth.py new file mode 100644 index 00000000..0811a245 --- /dev/null +++ b/PROJECTS/encrypted-p2p-chat/backend/app/schemas/auth.py @@ -0,0 +1,142 @@ +""" +ⒸAngelaMos | 2025 +Pydantic schemas for WebAuthn authentication +""" + +from typing import Any + +from pydantic import BaseModel, Field + +from app.config import ( + DEVICE_NAME_MAX_LENGTH, + DISPLAY_NAME_MAX_LENGTH, + DISPLAY_NAME_MIN_LENGTH, + USERNAME_MAX_LENGTH, + USERNAME_MIN_LENGTH, + USER_SEARCH_DEFAULT_LIMIT, + USER_SEARCH_MAX_LIMIT, + USER_SEARCH_MIN_LENGTH, +) + + +class RegistrationOptionsResponse(BaseModel): + """ + WebAuthn registration options returned to client + """ + options: dict[str, Any] + challenge: bytes + + +class VerifiedRegistration(BaseModel): + """ + Verified WebAuthn registration data + """ + credential_id: bytes + credential_public_key: bytes + sign_count: int + aaguid: bytes + attestation_object: bytes + credential_type: str + user_verified: bool + attestation_format: str + credential_device_type: str + credential_backed_up: bool + backup_eligible: bool + backup_state: bool + + +class AuthenticationOptionsResponse(BaseModel): + """ + WebAuthn authentication options returned to client + """ + options: dict[str, Any] + challenge: bytes + + +class VerifiedAuthentication(BaseModel): + """ + Verified WebAuthn authentication data + """ + new_sign_count: int + credential_id: bytes + user_verified: bool + backup_state: bool + backup_eligible: bool + + +class RegistrationBeginRequest(BaseModel): + """ + Request to begin passkey registration + """ + username: str = Field( + min_length = USERNAME_MIN_LENGTH, + max_length = USERNAME_MAX_LENGTH, + ) + display_name: str = Field( + min_length = DISPLAY_NAME_MIN_LENGTH, + max_length = DISPLAY_NAME_MAX_LENGTH, + ) + + +class RegistrationCompleteRequest(BaseModel): + """ + Request to complete passkey registration + """ + username: str = Field(min_length = USERNAME_MIN_LENGTH, max_length = USERNAME_MAX_LENGTH) + credential: dict[str, Any] + device_name: str | None = Field( + default = None, + max_length = DEVICE_NAME_MAX_LENGTH, + ) + + +class AuthenticationBeginRequest(BaseModel): + """ + Request to begin passkey authentication + """ + username: str | None = Field( + default = None, + min_length = USERNAME_MIN_LENGTH, + max_length = USERNAME_MAX_LENGTH, + ) + + +class AuthenticationCompleteRequest(BaseModel): + """ + Request to complete passkey authentication + """ + credential: dict[str, Any] + + +class UserResponse(BaseModel): + """ + User data response + """ + id: str + username: str + display_name: str + is_active: bool + is_verified: bool + created_at: str + + +class UserSearchRequest(BaseModel): + """ + Request to search for users + """ + query: str = Field( + min_length = USER_SEARCH_MIN_LENGTH, + max_length = USERNAME_MAX_LENGTH, + ) + limit: int = Field( + default = USER_SEARCH_DEFAULT_LIMIT, + ge = 1, + le = USER_SEARCH_MAX_LIMIT, + ) + + +class UserSearchResponse(BaseModel): + """ + Response containing search results + """ + users: list[UserResponse] diff --git a/PROJECTS/encrypted-p2p-chat/backend/app/schemas/common.py b/PROJECTS/encrypted-p2p-chat/backend/app/schemas/common.py new file mode 100644 index 00000000..f61ea1b1 --- /dev/null +++ b/PROJECTS/encrypted-p2p-chat/backend/app/schemas/common.py @@ -0,0 +1,23 @@ +""" +ⒸAngelaMos | 2025 +Common Pydantic schemas for API responses +""" + +from pydantic import BaseModel + + +class RootResponse(BaseModel): + """ + Root endpoint response schema + """ + app: str + version: str + status: str + environment: str + + +class HealthResponse(BaseModel): + """ + Health check endpoint response schema + """ + status: str diff --git a/PROJECTS/encrypted-p2p-chat/backend/app/schemas/rooms.py b/PROJECTS/encrypted-p2p-chat/backend/app/schemas/rooms.py new file mode 100644 index 00000000..abe5c3b5 --- /dev/null +++ b/PROJECTS/encrypted-p2p-chat/backend/app/schemas/rooms.py @@ -0,0 +1,48 @@ +""" +ⒸAngelaMos | 2025 +Pydantic schemas for rooms API +""" + +from pydantic import BaseModel + +from app.core.enums import RoomType + + +class CreateRoomRequest(BaseModel): + """ + Request to create a new room + """ + participant_id: str + room_type: RoomType = RoomType.DIRECT + + +class ParticipantResponse(BaseModel): + """ + Participant in a room + """ + user_id: str + username: str + display_name: str + role: str = "member" + joined_at: str + + +class RoomAPIResponse(BaseModel): + """ + Room response for API + """ + id: str + type: RoomType + name: str | None = None + participants: list[ParticipantResponse] + unread_count: int = 0 + is_encrypted: bool = True + created_at: str + updated_at: str + + +class RoomListResponse(BaseModel): + """ + List of rooms response + """ + rooms: list[RoomAPIResponse] diff --git a/PROJECTS/encrypted-p2p-chat/backend/app/schemas/surreal.py b/PROJECTS/encrypted-p2p-chat/backend/app/schemas/surreal.py new file mode 100644 index 00000000..9e63b43a --- /dev/null +++ b/PROJECTS/encrypted-p2p-chat/backend/app/schemas/surreal.py @@ -0,0 +1,73 @@ +""" +ⒸAngelaMos | 2025 +Pydantic schemas for SurrealDB responses +""" + +from datetime import datetime + +from pydantic import BaseModel + +from app.core.enums import PresenceStatus, RoomType + + +class MessageResponse(BaseModel): + """ + Message response from SurrealDB + """ + id: str + room_id: str + sender_id: str + encrypted_content: str + encrypted_header: str + created_at: datetime + updated_at: datetime + + +class RoomResponse(BaseModel): + """ + Room response from SurrealDB + """ + id: str + name: str + room_type: RoomType + created_by: str + created_at: datetime + updated_at: datetime + is_ephemeral: bool = False + ttl_seconds: int | None = None + + +class PresenceResponse(BaseModel): + """ + Presence response from SurrealDB + """ + id: str + user_id: str + room_id: str | None = None + status: PresenceStatus + last_seen: datetime + updated_at: datetime + + +class LiveQueryUpdate(BaseModel): + """ + Live query update notification from SurrealDB + """ + action: str + result: MessageResponse | PresenceResponse | RoomResponse + + +class LiveMessageUpdate(BaseModel): + """ + Live message update notification + """ + action: str + result: MessageResponse + + +class LivePresenceUpdate(BaseModel): + """ + Live presence update notification + """ + action: str + result: PresenceResponse diff --git a/PROJECTS/encrypted-p2p-chat/backend/app/schemas/websocket.py b/PROJECTS/encrypted-p2p-chat/backend/app/schemas/websocket.py new file mode 100644 index 00000000..c5d37301 --- /dev/null +++ b/PROJECTS/encrypted-p2p-chat/backend/app/schemas/websocket.py @@ -0,0 +1,93 @@ +""" +ⒸAngelaMos | 2025 +Pydantic schemas for WebSocket message types +""" + +from typing import Any +from datetime import datetime +from uuid import UUID + +from pydantic import BaseModel, Field + +from app.config import ( + ENCRYPTED_CONTENT_MAX_LENGTH, + MESSAGE_ID_MAX_LENGTH, +) + + +class BaseWSMessage(BaseModel): + """ + Base WebSocket message with common fields + """ + type: str + timestamp: datetime | None = None + + +class EncryptedMessageWS(BaseWSMessage): + """ + Encrypted message sent over WebSocket + """ + type: str = "encrypted_message" + message_id: str = Field(max_length = MESSAGE_ID_MAX_LENGTH) + sender_id: str + recipient_id: str + ciphertext: str = Field(max_length = ENCRYPTED_CONTENT_MAX_LENGTH) + nonce: str + header: str + sender_username: str + + +class TypingIndicatorWS(BaseWSMessage): + """ + Typing indicator message + """ + type: str = "typing" + user_id: str + room_id: str + is_typing: bool + + +class PresenceUpdateWS(BaseWSMessage): + """ + User presence update message + """ + type: str = "presence" + user_id: str + status: str + last_seen: datetime + + +class ReadReceiptWS(BaseWSMessage): + """ + Message read receipt + """ + type: str = "receipt" + message_id: str = Field(max_length = MESSAGE_ID_MAX_LENGTH) + user_id: str + read_at: datetime + + +class ErrorMessageWS(BaseWSMessage): + """ + Error message sent over WebSocket + """ + type: str = "error" + error_code: str + error_message: str + details: dict[str, Any] | None = None + + +class WSConnectionRequest(BaseModel): + """ + WebSocket connection request with auth token + """ + user_id: UUID + token: str | None = None + + +class WSHeartbeat(BaseModel): + """ + WebSocket heartbeat ping/pong message + """ + type: str = "heartbeat" + timestamp: datetime diff --git a/PROJECTS/encrypted-p2p-chat/backend/app/services/__init__.py b/PROJECTS/encrypted-p2p-chat/backend/app/services/__init__.py new file mode 100644 index 00000000..c932f801 --- /dev/null +++ b/PROJECTS/encrypted-p2p-chat/backend/app/services/__init__.py @@ -0,0 +1,24 @@ +""" +ⒸAngelaMos | 2025 +Service layer exports +""" + +from app.services.auth_service import AuthService, auth_service +from app.services.message_service import MessageService, message_service +from app.services.prekey_service import PrekeyService, prekey_service +from app.services.presence_service import PresenceService, presence_service +from app.services.websocket_service import WebSocketService, websocket_service + + +__all__ = [ + "AuthService", + "auth_service", + "MessageService", + "message_service", + "PrekeyService", + "prekey_service", + "PresenceService", + "presence_service", + "WebSocketService", + "websocket_service", +] diff --git a/PROJECTS/encrypted-p2p-chat/backend/app/services/auth_service.py b/PROJECTS/encrypted-p2p-chat/backend/app/services/auth_service.py new file mode 100644 index 00000000..08290539 --- /dev/null +++ b/PROJECTS/encrypted-p2p-chat/backend/app/services/auth_service.py @@ -0,0 +1,580 @@ +""" +ⒸAngelaMos | 2025 +Authentication service for user and credential management +""" + +import logging +from typing import Any +from uuid import UUID +from datetime import UTC, datetime + +from sqlmodel import select +from sqlalchemy.exc import IntegrityError +from sqlalchemy.orm import selectinload +from sqlmodel.ext.asyncio.session import AsyncSession +from webauthn.helpers import base64url_to_bytes, bytes_to_base64url + +from app.config import USER_SEARCH_DEFAULT_LIMIT +from app.core.exceptions import ( + ChallengeExpiredError, + CredentialNotFoundError, + CredentialVerificationError, + DatabaseError, + InvalidDataError, + UserExistsError, + UserInactiveError, + UserNotFoundError, +) +from app.core.passkey.passkey_manager import passkey_manager +from app.core.redis_manager import redis_manager +from app.models.Credential import Credential +from app.models.User import User +from app.schemas.auth import ( + AuthenticationBeginRequest, + AuthenticationCompleteRequest, + RegistrationBeginRequest, + RegistrationCompleteRequest, + UserResponse, + VerifiedRegistration, +) + + +logger = logging.getLogger(__name__) + + +class AuthService: + """ + Service for managing user authentication and credentials + """ + async def create_user( + self, + session: AsyncSession, + username: str, + display_name: str, + ) -> User: + """ + Create a new user with username uniqueness check + """ + statement = select(User).where(User.username == username) + result = await session.execute(statement) + existing_user = result.scalar_one_or_none() + + if existing_user: + logger.warning("Attempted to create duplicate user: %s", username) + raise UserExistsError(f"Username {username} already exists") + + user = User( + username = username, + display_name = display_name, + ) + + session.add(user) + + try: + await session.commit() + await session.refresh(user) + logger.info("Created new user: %s (ID: %s)", username, user.id) + return user + except IntegrityError as e: + await session.rollback() + logger.error( + "Database integrity error creating user %s: %s", + username, + e + ) + raise DatabaseError( + "Failed to create user: database constraint violation" + ) from e + + async def store_credential( + self, + session: AsyncSession, + user_id: UUID, + verified: VerifiedRegistration, + device_name: str | None = None, + ) -> Credential: + """ + Store WebAuthn credential after successful registration + """ + credential = Credential( + user_id = user_id, + credential_id = bytes_to_base64url(verified.credential_id), + public_key = bytes_to_base64url(verified.credential_public_key), + sign_count = verified.sign_count, + aaguid = bytes_to_base64url(verified.aaguid), + backup_eligible = verified.backup_eligible, + backup_state = verified.backup_state, + attestation_type = verified.attestation_format, + device_name = device_name, + last_used_at = datetime.now(UTC), + ) + + session.add(credential) + + try: + await session.commit() + await session.refresh(credential) + logger.info( + "Stored credential %s... for user %s", + credential.credential_id[: 16], + user_id + ) + return credential + except IntegrityError as e: + await session.rollback() + logger.error("Database integrity error storing credential: %s", e) + raise DatabaseError( + "Failed to store credential: database constraint violation" + ) from e + + async def get_user_by_username( + self, + session: AsyncSession, + username: str, + ) -> User | None: + """ + Retrieve user by username with credentials relationship eager loaded + """ + statement = ( + select(User).where(User.username == username).options( + selectinload(User.credentials) + ) + ) + result = await session.execute(statement) + user = result.scalar_one_or_none() + + if user: + logger.debug( + "Retrieved user %s with %s credentials", + username, + len(user.credentials) + ) + else: + logger.debug("User not found: %s", username) + + return user + + async def get_user_by_id( + self, + session: AsyncSession, + user_id: UUID, + ) -> User | None: + """ + Retrieve user by ID with credentials relationship eager loaded + """ + statement = ( + select(User).where(User.id == user_id).options( + selectinload(User.credentials) + ) + ) + result = await session.execute(statement) + user = result.scalar_one_or_none() + + if user: + logger.debug( + "Retrieved user %s with %s credentials", + user_id, + len(user.credentials) + ) + else: + logger.debug("User not found: %s", user_id) + + return user + + async def search_users( + self, + session: AsyncSession, + query: str, + limit: int = USER_SEARCH_DEFAULT_LIMIT, + exclude_user_id: UUID | None = None, + ) -> list[User]: + """ + Search for active users by username or display name + """ + search_pattern = f"%{query.lower()}%" + + statement = ( + select(User) + .where( + User.is_active == True, + ( + User.username.ilike(search_pattern) | + User.display_name.ilike(search_pattern) + ) + ) + .limit(limit) + ) + + if exclude_user_id is not None: + statement = statement.where(User.id != exclude_user_id) + + result = await session.execute(statement) + users = result.scalars().all() + + logger.debug( + "Search for '%s' returned %d users", + query, + len(users) + ) + + return list(users) + + async def get_credential_by_id( + self, + session: AsyncSession, + credential_id: str, + ) -> Credential | None: + """ + Retrieve credential by credential_id + """ + statement = select(Credential).where( + Credential.credential_id == credential_id + ) + result = await session.execute(statement) + credential = result.scalar_one_or_none() + + if credential: + logger.debug("Retrieved credential %s...", credential_id[: 16]) + else: + logger.debug("Credential not found: %s...", credential_id[: 16]) + + return credential + + async def update_credential_counter( + self, + session: AsyncSession, + credential_id: str, + new_count: int, + ) -> None: + """ + Update credential signature counter after successful authentication + """ + statement = select(Credential).where( + Credential.credential_id == credential_id + ) + result = await session.execute(statement) + credential = result.scalar_one_or_none() + + if not credential: + logger.error( + "Credential not found for counter update: %s...", + credential_id[: 16] + ) + raise CredentialNotFoundError("Credential not found") + + old_count = credential.sign_count + credential.sign_count = new_count + credential.last_used_at = datetime.now(UTC) + + try: + await session.commit() + logger.info( + "Updated credential %s... counter: %s -> %s", + credential_id[: 16], + old_count, + new_count + ) + except IntegrityError as e: + await session.rollback() + logger.error("Database error updating credential counter: %s", e) + raise DatabaseError("Failed to update credential counter") from e + + async def update_backup_state( + self, + session: AsyncSession, + credential_id: str, + backup_state: bool, + backup_eligible: bool, + ) -> None: + """ + Update credential backup flags (WebAuthn Level 3) + """ + statement = select(Credential).where( + Credential.credential_id == credential_id + ) + result = await session.execute(statement) + credential = result.scalar_one_or_none() + + if not credential: + logger.error( + "Credential not found for backup state update: %s...", + credential_id[: 16] + ) + raise CredentialNotFoundError("Credential not found") + + if credential.backup_state != backup_state: + logger.warning( + "Credential %s... backup state changed: %s -> %s", + credential_id[: 16], + credential.backup_state, + backup_state + ) + + credential.backup_state = backup_state + credential.backup_eligible = backup_eligible + + try: + await session.commit() + logger.debug( + "Updated credential %s... backup_state=%s, backup_eligible=%s", + credential_id[: 16], + backup_state, + backup_eligible + ) + except IntegrityError as e: + await session.rollback() + logger.error("Database error updating backup state: %s", e) + raise DatabaseError("Failed to update backup state") from e + + async def begin_registration( + self, + session: AsyncSession, + request: RegistrationBeginRequest, + ) -> dict[str, + Any]: + """ + Begin WebAuthn passkey registration flow + """ + existing_user = await self.get_user_by_username( + session = session, + username = request.username, + ) + + if existing_user: + logger.warning( + "Registration attempt for existing user: %s", + request.username + ) + raise UserExistsError(f"Username {request.username} already exists") + + user_id_bytes = request.username.encode() + + exclude_credentials = [] + if existing_user: + exclude_credentials = [ + base64url_to_bytes(cred.credential_id) + for cred in existing_user.credentials + ] + + options_response = passkey_manager.generate_registration_options( + user_id = user_id_bytes, + username = request.username, + display_name = request.display_name, + exclude_credentials = exclude_credentials, + ) + + await redis_manager.set_registration_challenge( + user_id = request.username, + challenge = options_response.challenge, + ) + + logger.info("Started registration for user: %s", request.username) + return options_response.options + + async def complete_registration( + self, + session: AsyncSession, + request: RegistrationCompleteRequest, + username: str, + ) -> UserResponse: + """ + Complete WebAuthn passkey registration + """ + expected_challenge = await redis_manager.get_registration_challenge( + user_id = username + ) + + if not expected_challenge: + logger.warning( + "Registration challenge not found or expired for user: %s", + username + ) + raise ChallengeExpiredError( + "Challenge expired or not found - please restart registration" + ) + + try: + verified = passkey_manager.verify_registration( + credential = request.credential, + expected_challenge = expected_challenge, + ) + except Exception as e: + logger.error("Registration verification failed: %s", e) + raise CredentialVerificationError( + f"Registration verification failed: {str(e)}" + ) from e + + user = await self.create_user( + session = session, + username = username, + display_name = request.credential.get("displayName", + username), + ) + + await self.store_credential( + session = session, + user_id = user.id, + verified = verified, + device_name = request.device_name, + ) + + logger.info("Registration completed for user: %s", username) + + return UserResponse( + id = str(user.id), + username = user.username, + display_name = user.display_name, + is_active = user.is_active, + is_verified = user.is_verified, + created_at = user.created_at.isoformat(), + ) + + async def begin_authentication( + self, + session: AsyncSession, + request: AuthenticationBeginRequest, + ) -> dict[str, + Any]: + """ + Begin WebAuthn passkey authentication flow + """ + allow_credentials = None + + if request.username: + user = await self.get_user_by_username( + session = session, + username = request.username, + ) + + if not user: + logger.warning( + "Authentication attempt for non-existent user: %s", + request.username + ) + raise UserNotFoundError("User not found") + + if not user.is_active: + logger.warning( + "Authentication attempt for inactive user: %s", + request.username + ) + raise UserInactiveError("User account is inactive") + + allow_credentials = [ + base64url_to_bytes(cred.credential_id) + for cred in user.credentials + ] + + options_response = passkey_manager.generate_authentication_options( + allow_credentials = allow_credentials, + ) + + user_id = request.username if request.username else "discoverable" + await redis_manager.set_authentication_challenge( + user_id = user_id, + challenge = options_response.challenge, + ) + + logger.info("Started authentication for user: %s", user_id) + return options_response.options + + async def complete_authentication( + self, + session: AsyncSession, + request: AuthenticationCompleteRequest, + ) -> UserResponse: + """ + Complete WebAuthn passkey authentication + """ + credential_id = request.credential.get("id") + if not credential_id: + raise InvalidDataError("Missing credential ID") + + credential = await self.get_credential_by_id( + session = session, + credential_id = credential_id, + ) + + if not credential: + logger.warning( + "Authentication with unknown credential: %s...", + credential_id[: 16] + ) + raise CredentialNotFoundError("Credential not found") + + user = await self.get_user_by_id( + session = session, + user_id = credential.user_id, + ) + + if not user: + logger.error( + "User not found for credential: %s...", + credential_id[: 16] + ) + raise UserNotFoundError("User not found") + + if not user.is_active: + logger.warning( + "Authentication attempt for inactive user: %s", + user.username + ) + raise UserInactiveError("User account is inactive") + + expected_challenge = await redis_manager.get_authentication_challenge( + user_id = user.username + ) + + if not expected_challenge: + logger.warning( + "Authentication challenge not found for user: %s", + user.username + ) + raise ChallengeExpiredError( + "Challenge expired or not found - please restart authentication" + ) + + try: + verified = passkey_manager.verify_authentication( + credential = request.credential, + expected_challenge = expected_challenge, + credential_public_key = base64url_to_bytes(credential.public_key), + credential_current_sign_count = credential.sign_count, + ) + except ValueError as e: + logger.error("Authentication verification failed: %s", e) + raise CredentialVerificationError(str(e)) from e + except Exception as e: + logger.error("Unexpected error during authentication: %s", e) + raise CredentialVerificationError( + "Authentication verification failed" + ) from e + + await self.update_credential_counter( + session = session, + credential_id = credential.credential_id, + new_count = verified.new_sign_count, + ) + + if (credential.backup_state != verified.backup_state + or credential.backup_eligible != verified.backup_eligible): + await self.update_backup_state( + session = session, + credential_id = credential.credential_id, + backup_state = verified.backup_state, + backup_eligible = verified.backup_eligible, + ) + + logger.info("Authentication successful for user: %s", user.username) + + return UserResponse( + id = str(user.id), + username = user.username, + display_name = user.display_name, + is_active = user.is_active, + is_verified = user.is_verified, + created_at = user.created_at.isoformat(), + ) + + +auth_service = AuthService() diff --git a/PROJECTS/encrypted-p2p-chat/backend/app/services/message_service.py b/PROJECTS/encrypted-p2p-chat/backend/app/services/message_service.py new file mode 100644 index 00000000..b49b9062 --- /dev/null +++ b/PROJECTS/encrypted-p2p-chat/backend/app/services/message_service.py @@ -0,0 +1,414 @@ +""" +ⒸAngelaMos | 2025 +Message service with end-to-end encryption using Double Ratchet +""" + +import json +import logging +from typing import Any +from uuid import UUID + +from sqlmodel import select +from sqlalchemy.exc import IntegrityError +from sqlmodel.ext.asyncio.session import AsyncSession +from cryptography.hazmat.primitives import serialization +from webauthn.helpers import base64url_to_bytes, bytes_to_base64url +from cryptography.hazmat.primitives.asymmetric.x25519 import ( + X25519PrivateKey, +) +from app.core.encryption.double_ratchet import ( + DoubleRatchetState, + EncryptedMessage, + double_ratchet, +) +from app.core.encryption.x3dh_manager import x3dh_manager +from app.core.exceptions import ( + DatabaseError, + DecryptionError, + EncryptionError, + InvalidDataError, + KeyExchangeError, + RatchetStateNotFoundError, + UserNotFoundError, +) +from app.core.surreal_manager import surreal_db +from app.models.IdentityKey import IdentityKey +from app.models.RatchetState import RatchetState +from app.models.User import User +from app.services.prekey_service import prekey_service + + +logger = logging.getLogger(__name__) + + +class MessageService: + """ + Service for encrypted messaging using Double Ratchet protocol + """ + async def initialize_conversation( + self, + session: AsyncSession, + sender_id: UUID, + recipient_id: UUID + ) -> RatchetState: + """ + Performs X3DH key exchange and initializes Double Ratchet for new conversation + """ + if sender_id == recipient_id: + raise InvalidDataError("Cannot start conversation with yourself") + + existing_state_statement = select(RatchetState).where( + RatchetState.user_id == sender_id, + RatchetState.peer_user_id == recipient_id + ) + existing_state_result = await session.execute(existing_state_statement) + existing_state = existing_state_result.scalar_one_or_none() + + if existing_state: + logger.warning( + "Ratchet state already exists for %s -> %s", + sender_id, + recipient_id + ) + return existing_state + + sender_ik_statement = select(IdentityKey).where( + IdentityKey.user_id == sender_id + ) + sender_ik_result = await session.execute(sender_ik_statement) + sender_ik = sender_ik_result.scalar_one_or_none() + + if not sender_ik: + logger.error("Sender identity key not found: %s", sender_id) + raise InvalidDataError( + "Sender has no identity key - initialize encryption first" + ) + + recipient_bundle = await prekey_service.get_prekey_bundle( + session, + recipient_id + ) + + recipient_ik_statement = select(IdentityKey).where( + IdentityKey.user_id == recipient_id + ) + recipient_ik_result = await session.execute(recipient_ik_statement) + recipient_ik = recipient_ik_result.scalar_one_or_none() + + if not recipient_ik: + logger.error("Recipient identity key not found: %s", recipient_id) + raise InvalidDataError("Recipient has no identity key") + + try: + x3dh_result = x3dh_manager.perform_x3dh_sender( + alice_identity_private_x25519 = sender_ik.private_key, + bob_bundle = recipient_bundle, + bob_identity_public_ed25519 = recipient_ik.public_key_ed25519 + ) + except Exception as e: + logger.error("X3DH key exchange failed: %s", e) + raise KeyExchangeError(f"Key exchange failed: {str(e)}") from e + + recipient_spk_public_bytes = base64url_to_bytes( + recipient_bundle.signed_prekey + ) + + dr_state = double_ratchet.initialize_sender( + shared_key = x3dh_result.shared_key, + peer_public_key = recipient_spk_public_bytes + ) + + dh_private_bytes = dr_state.dh_private_key.private_bytes( + encoding = serialization.Encoding.Raw, + format = serialization.PrivateFormat.Raw, + encryption_algorithm = serialization.NoEncryption() + ) if dr_state.dh_private_key else b'' + + dh_public_bytes = dr_state.dh_private_key.public_key().public_bytes( + encoding = serialization.Encoding.Raw, + format = serialization.PublicFormat.Raw + ) if dr_state.dh_private_key else b'' + + ratchet_state = RatchetState( + user_id = sender_id, + peer_user_id = recipient_id, + dh_private_key = bytes_to_base64url(dh_private_bytes), + dh_public_key = bytes_to_base64url(dh_public_bytes), + dh_peer_public_key = bytes_to_base64url(dr_state.dh_peer_public_key) + if dr_state.dh_peer_public_key else None, + root_key = bytes_to_base64url(dr_state.root_key), + sending_chain_key = bytes_to_base64url(dr_state.sending_chain_key), + receiving_chain_key = bytes_to_base64url( + dr_state.receiving_chain_key + ), + sending_message_number = dr_state.sending_message_number, + receiving_message_number = dr_state.receiving_message_number, + previous_sending_chain_length = ( + dr_state.previous_sending_chain_length + ) + ) + + session.add(ratchet_state) + + try: + await session.commit() + await session.refresh(ratchet_state) + logger.info( + "Initialized conversation: %s -> %s (X3DH complete)", + sender_id, + recipient_id + ) + except IntegrityError as e: + await session.rollback() + logger.error("Database error saving ratchet state: %s", e) + raise DatabaseError("Failed to initialize conversation") from e + + return ratchet_state + + async def _load_ratchet_state_from_db( + self, + ratchet_state_db: RatchetState + ) -> DoubleRatchetState: + """ + Converts database RatchetState to DoubleRatchetState object + """ + dh_private_key = None + if ratchet_state_db.dh_private_key: + dh_private_bytes = base64url_to_bytes(ratchet_state_db.dh_private_key) + dh_private_key = X25519PrivateKey.from_private_bytes(dh_private_bytes) + + dh_peer_public_key = None + if ratchet_state_db.dh_peer_public_key: + dh_peer_public_key = base64url_to_bytes( + ratchet_state_db.dh_peer_public_key + ) + + root_key = base64url_to_bytes(ratchet_state_db.root_key) + sending_chain_key = base64url_to_bytes(ratchet_state_db.sending_chain_key) + receiving_chain_key = base64url_to_bytes( + ratchet_state_db.receiving_chain_key + ) + + return DoubleRatchetState( + root_key = root_key, + sending_chain_key = sending_chain_key, + receiving_chain_key = receiving_chain_key, + dh_private_key = dh_private_key, + dh_peer_public_key = dh_peer_public_key, + sending_message_number = ratchet_state_db.sending_message_number, + receiving_message_number = ( + ratchet_state_db.receiving_message_number + ), + previous_sending_chain_length = ( + ratchet_state_db.previous_sending_chain_length + ), + skipped_message_keys = {} + ) + + async def _save_ratchet_state_to_db( + self, + session: AsyncSession, + ratchet_state_db: RatchetState, + dr_state: DoubleRatchetState + ) -> None: + """ + Updates database RatchetState from DoubleRatchetState object + """ + if dr_state.dh_private_key: + dh_private_bytes = dr_state.dh_private_key.private_bytes( + encoding = serialization.Encoding.Raw, + format = serialization.PrivateFormat.Raw, + encryption_algorithm = serialization.NoEncryption() + ) + dh_public_bytes = dr_state.dh_private_key.public_key().public_bytes( + encoding = serialization.Encoding.Raw, + format = serialization.PublicFormat.Raw + ) + ratchet_state_db.dh_private_key = bytes_to_base64url(dh_private_bytes) + ratchet_state_db.dh_public_key = bytes_to_base64url(dh_public_bytes) + else: + ratchet_state_db.dh_private_key = None + ratchet_state_db.dh_public_key = None + + if dr_state.dh_peer_public_key: + ratchet_state_db.dh_peer_public_key = bytes_to_base64url( + dr_state.dh_peer_public_key + ) + else: + ratchet_state_db.dh_peer_public_key = None + + ratchet_state_db.root_key = bytes_to_base64url(dr_state.root_key) + ratchet_state_db.sending_chain_key = bytes_to_base64url( + dr_state.sending_chain_key + ) + ratchet_state_db.receiving_chain_key = bytes_to_base64url( + dr_state.receiving_chain_key + ) + ratchet_state_db.sending_message_number = ( + dr_state.sending_message_number + ) + ratchet_state_db.receiving_message_number = ( + dr_state.receiving_message_number + ) + ratchet_state_db.previous_sending_chain_length = ( + dr_state.previous_sending_chain_length + ) + + try: + await session.commit() + logger.debug( + "Saved ratchet state: send=%s, recv=%s", + dr_state.sending_message_number, + dr_state.receiving_message_number + ) + except IntegrityError as e: + await session.rollback() + logger.error("Database error saving ratchet state: %s", e) + raise DatabaseError("Failed to save ratchet state") from e + + async def send_encrypted_message( + self, + session: AsyncSession, + sender_id: UUID, + recipient_id: UUID, + plaintext: str + ) -> Any: + """ + Encrypts message with Double Ratchet and stores in SurrealDB + """ + ratchet_state_statement = select(RatchetState).where( + RatchetState.user_id == sender_id, + RatchetState.peer_user_id == recipient_id + ) + ratchet_state_result = await session.execute(ratchet_state_statement) + ratchet_state_db = ratchet_state_result.scalar_one_or_none() + + if not ratchet_state_db: + logger.warning( + "No ratchet state for %s -> %s, initializing", + sender_id, + recipient_id + ) + ratchet_state_db = await self.initialize_conversation( + session, + sender_id, + recipient_id + ) + + dr_state = await self._load_ratchet_state_from_db(ratchet_state_db) + + sender_user_statement = select(User).where(User.id == sender_id) + sender_user_result = await session.execute(sender_user_statement) + sender_user = sender_user_result.scalar_one_or_none() + + if not sender_user: + raise UserNotFoundError("Sender not found") + + associated_data = f"{sender_id}:{recipient_id}".encode() + + try: + encrypted_msg = double_ratchet.encrypt_message( + dr_state, + plaintext.encode(), + associated_data + ) + except Exception as e: + logger.error("Encryption failed: %s", e) + raise EncryptionError(f"Failed to encrypt message: {str(e)}") from e + + await self._save_ratchet_state_to_db(session, ratchet_state_db, dr_state) + + message_header = { + "dh_public_key": bytes_to_base64url(encrypted_msg.dh_public_key), + "message_number": encrypted_msg.message_number, + "previous_chain_length": encrypted_msg.previous_chain_length + } + + surreal_message = { + "sender_id": str(sender_id), + "recipient_id": str(recipient_id), + "ciphertext": bytes_to_base64url(encrypted_msg.ciphertext), + "nonce": bytes_to_base64url(encrypted_msg.nonce), + "header": json.dumps(message_header), + "sender_username": sender_user.username + } + + try: + result = await surreal_db.create_message(surreal_message) + logger.info( + "Sent encrypted message: %s -> %s (msg #%s)", + sender_id, + recipient_id, + encrypted_msg.message_number + ) + return result + except Exception as e: + logger.error("Failed to store encrypted message: %s", e) + raise DatabaseError(f"Failed to store message: {str(e)}") from e + + async def decrypt_received_message( + self, + session: AsyncSession, + recipient_id: UUID, + message_data: dict[str, + Any] + ) -> str: + """ + Decrypts received message using Double Ratchet + """ + sender_id = UUID(message_data["sender_id"]) + + ratchet_state_statement = select(RatchetState).where( + RatchetState.user_id == recipient_id, + RatchetState.peer_user_id == sender_id + ) + ratchet_state_result = await session.execute(ratchet_state_statement) + ratchet_state_db = ratchet_state_result.scalar_one_or_none() + + if not ratchet_state_db: + logger.error( + "No ratchet state for receiving: %s <- %s", + recipient_id, + sender_id + ) + raise RatchetStateNotFoundError("No encryption session with sender") + + dr_state = await self._load_ratchet_state_from_db(ratchet_state_db) + + header = json.loads(message_data["header"]) + + encrypted_msg = EncryptedMessage( + ciphertext = base64url_to_bytes(message_data["ciphertext"]), + nonce = base64url_to_bytes(message_data["nonce"]), + dh_public_key = base64url_to_bytes(header["dh_public_key"]), + message_number = header["message_number"], + previous_chain_length = header["previous_chain_length"] + ) + + associated_data = f"{sender_id}:{recipient_id}".encode() + + try: + plaintext_bytes = double_ratchet.decrypt_message( + dr_state, + encrypted_msg, + associated_data + ) + except Exception as e: + logger.error("Decryption failed: %s", e) + raise DecryptionError(f"Failed to decrypt message: {str(e)}") from e + + await self._save_ratchet_state_to_db(session, ratchet_state_db, dr_state) + + plaintext = plaintext_bytes.decode() + + logger.info( + "Decrypted message: %s <- %s (msg #%s)", + recipient_id, + sender_id, + encrypted_msg.message_number + ) + + return plaintext + + +message_service = MessageService() diff --git a/PROJECTS/encrypted-p2p-chat/backend/app/services/prekey_service.py b/PROJECTS/encrypted-p2p-chat/backend/app/services/prekey_service.py new file mode 100644 index 00000000..9c7f446d --- /dev/null +++ b/PROJECTS/encrypted-p2p-chat/backend/app/services/prekey_service.py @@ -0,0 +1,360 @@ +""" +ⒸAngelaMos | 2025 +Prekey management service for X3DH key bundles +""" + +import logging +from datetime import ( + UTC, + datetime, + timedelta, +) +from uuid import UUID + +from sqlmodel import select +from sqlalchemy.exc import IntegrityError +from sqlmodel.ext.asyncio.session import AsyncSession + +from app.config import ( + DEFAULT_ONE_TIME_PREKEY_COUNT, + SIGNED_PREKEY_RETENTION_DAYS, + SIGNED_PREKEY_ROTATION_HOURS, +) +from app.core.encryption.x3dh_manager import ( + PreKeyBundle, + x3dh_manager, +) +from app.core.exceptions import ( + DatabaseError, + InvalidDataError, + UserNotFoundError, +) +from app.models.User import User +from app.models.IdentityKey import IdentityKey +from app.models.SignedPrekey import SignedPrekey +from app.models.OneTimePrekey import OneTimePrekey + + +logger = logging.getLogger(__name__) + + +class PrekeyService: + """ + Service for managing X3DH prekey bundles and key rotation + """ + async def initialize_user_keys( + self, + session: AsyncSession, + user_id: UUID + ) -> IdentityKey: + """ + Generates and stores initial identity key, + signed prekey, and one time prekeys for a user + """ + statement = select(User).where(User.id == user_id) + result = await session.execute(statement) + user = result.scalar_one_or_none() + + if not user: + logger.error("User not found: %s", user_id) + raise UserNotFoundError("User not found") + + existing_ik_statement = select(IdentityKey).where( + IdentityKey.user_id == user_id + ) + existing_ik_result = await session.execute(existing_ik_statement) + existing_ik = existing_ik_result.scalar_one_or_none() + + if existing_ik: + logger.warning("Identity key already exists for user %s", user_id) + return existing_ik + + ik_private_x25519, ik_public_x25519 = ( + x3dh_manager.generate_identity_keypair_x25519() + ) + ik_private_ed25519, ik_public_ed25519 = ( + x3dh_manager.generate_identity_keypair_ed25519() + ) + + identity_key = IdentityKey( + user_id = user_id, + public_key = ik_public_x25519, + private_key = ik_private_x25519, + public_key_ed25519 = ik_public_ed25519, + private_key_ed25519 = ik_private_ed25519 + ) + + session.add(identity_key) + + try: + await session.commit() + await session.refresh(identity_key) + logger.info("Created identity key for user %s", user_id) + except IntegrityError as e: + await session.rollback() + logger.error("Database error creating identity key: %s", e) + raise DatabaseError("Failed to create identity key") from e + + await self.rotate_signed_prekey(session, user_id) + + await self.replenish_one_time_prekeys( + session, + user_id, + DEFAULT_ONE_TIME_PREKEY_COUNT + ) + + logger.info( + "Initialized all keys for user %s: IK + SPK + %s OPKs", + user_id, + DEFAULT_ONE_TIME_PREKEY_COUNT + ) + + return identity_key + + async def rotate_signed_prekey( + self, + session: AsyncSession, + user_id: UUID + ) -> SignedPrekey: + """ + Generates new signed prekey and marks old ones inactive + """ + ik_statement = select(IdentityKey).where(IdentityKey.user_id == user_id) + ik_result = await session.execute(ik_statement) + identity_key = ik_result.scalar_one_or_none() + + if not identity_key: + logger.error("Identity key not found for user %s", user_id) + raise InvalidDataError("User has no identity key") + + old_spks_statement = select(SignedPrekey).where( + SignedPrekey.user_id == user_id, + SignedPrekey.is_active + ) + old_spks_result = await session.execute(old_spks_statement) + old_spks = old_spks_result.scalars().all() + + for old_spk in old_spks: + old_spk.is_active = False + logger.debug("Marked SPK %s as inactive", old_spk.key_id) + + max_key_id_statement = select(SignedPrekey.key_id).where( + SignedPrekey.user_id == user_id + ).order_by(SignedPrekey.key_id.desc()).limit(1) + max_key_id_result = await session.execute(max_key_id_statement) + max_key_id = max_key_id_result.scalar_one_or_none() + new_key_id = (max_key_id + 1) if max_key_id is not None else 1 + + spk_private, spk_public, spk_signature = ( + x3dh_manager.generate_signed_prekey( + identity_key.private_key_ed25519 + ) + ) + + expires_at = datetime.now(UTC) + timedelta( + hours = SIGNED_PREKEY_ROTATION_HOURS + ) + + signed_prekey = SignedPrekey( + user_id = user_id, + key_id = new_key_id, + public_key = spk_public, + private_key = spk_private, + signature = spk_signature, + is_active = True, + expires_at = expires_at + ) + + session.add(signed_prekey) + + try: + await session.commit() + await session.refresh(signed_prekey) + logger.info( + "Rotated signed prekey for user %s: key_id=%s, expires=%s", + user_id, + new_key_id, + expires_at + ) + except IntegrityError as e: + await session.rollback() + logger.error("Database error rotating signed prekey: %s", e) + raise DatabaseError("Failed to rotate signed prekey") from e + + return signed_prekey + + async def get_prekey_bundle( + self, + session: AsyncSession, + user_id: UUID + ) -> PreKeyBundle: + """ + Retrieves prekey bundle for initiating X3DH with a user + """ + ik_statement = select(IdentityKey).where(IdentityKey.user_id == user_id) + ik_result = await session.execute(ik_statement) + identity_key = ik_result.scalar_one_or_none() + + if not identity_key: + logger.error("Identity key not found for user %s", user_id) + raise InvalidDataError("User has no identity key") + + spk_statement = select(SignedPrekey).where( + SignedPrekey.user_id == user_id, + SignedPrekey.is_active + ).order_by(SignedPrekey.created_at.desc()) + spk_result = await session.execute(spk_statement) + signed_prekey = spk_result.scalar_one_or_none() + + if not signed_prekey: + logger.warning( + "No active signed prekey for user %s, rotating", + user_id + ) + signed_prekey = await self.rotate_signed_prekey(session, user_id) + + opk_statement = select(OneTimePrekey).where( + OneTimePrekey.user_id == user_id, + not OneTimePrekey.is_used + ).limit(1) + opk_result = await session.execute(opk_statement) + one_time_prekey = opk_result.scalar_one_or_none() + + one_time_prekey_public = None + if one_time_prekey: + one_time_prekey.is_used = True + one_time_prekey_public = one_time_prekey.public_key + logger.debug( + "Consumed one time prekey %s for user %s", + one_time_prekey.key_id, + user_id + ) + + try: + await session.commit() + except IntegrityError as e: + await session.rollback() + logger.error("Database error consuming OPK: %s", e) + raise DatabaseError("Failed to consume one-time prekey") from e + + bundle = PreKeyBundle( + identity_key = identity_key.public_key, + signed_prekey = signed_prekey.public_key, + signed_prekey_signature = signed_prekey.signature, + one_time_prekey = one_time_prekey_public + ) + + logger.info( + "Retrieved prekey bundle for user %s: IK + SPK + %s", + user_id, + 'OPK' if one_time_prekey_public else 'no OPK' + ) + + return bundle + + async def replenish_one_time_prekeys( + self, + session: AsyncSession, + user_id: UUID, + count: int = DEFAULT_ONE_TIME_PREKEY_COUNT + ) -> int: + """ + Generates new batch of one time prekeys + """ + max_key_id_statement = select(OneTimePrekey.key_id).where( + OneTimePrekey.user_id == user_id + ).order_by(OneTimePrekey.key_id.desc()).limit(1) + max_key_id_result = await session.execute(max_key_id_statement) + max_key_id = max_key_id_result.scalar_one_or_none() + next_key_id = (max_key_id + 1) if max_key_id is not None else 1 + + one_time_prekeys = [] + for i in range(count): + opk_private, opk_public = x3dh_manager.generate_one_time_prekey() + + one_time_prekey = OneTimePrekey( + user_id = user_id, + key_id = next_key_id + i, + public_key = opk_public, + private_key = opk_private, + is_used = False + ) + one_time_prekeys.append(one_time_prekey) + + for opk in one_time_prekeys: + session.add(opk) + + try: + await session.commit() + logger.info( + "Generated %s one-time prekeys for user %s", + count, + user_id + ) + except IntegrityError as e: + await session.rollback() + logger.error("Database error generating OPKs: %s", e) + raise DatabaseError("Failed to generate one-time prekeys") from e + + return count + + async def get_unused_opk_count( + self, + session: AsyncSession, + user_id: UUID + ) -> int: + """ + Returns count of unused one time prekeys for a user + """ + count_statement = select(OneTimePrekey).where( + OneTimePrekey.user_id == user_id, + not OneTimePrekey.is_used + ) + result = await session.execute(count_statement) + unused_opks = result.scalars().all() + + count = len(unused_opks) + logger.debug("User %s has %s unused OPKs", user_id, count) + return count + + async def cleanup_old_signed_prekeys( + self, + session: AsyncSession, + user_id: UUID + ) -> int: + """ + Deletes inactive signed prekeys older than retention period + """ + cutoff_date = datetime.now(UTC) - timedelta( + days = SIGNED_PREKEY_RETENTION_DAYS + ) + + old_spks_statement = select(SignedPrekey).where( + SignedPrekey.user_id == user_id, + not SignedPrekey.is_active, + SignedPrekey.created_at < cutoff_date + ) + old_spks_result = await session.execute(old_spks_statement) + old_spks = old_spks_result.scalars().all() + + deleted_count = len(old_spks) + + for spk in old_spks: + await session.delete(spk) + + try: + await session.commit() + logger.info( + "Deleted %s old signed prekeys for user %s", + deleted_count, + user_id + ) + except IntegrityError as e: + await session.rollback() + logger.error("Database error deleting old SPKs: %s", e) + raise DatabaseError("Failed to delete old signed prekeys") from e + + return deleted_count + + +prekey_service = PrekeyService() diff --git a/PROJECTS/encrypted-p2p-chat/backend/app/services/presence_service.py b/PROJECTS/encrypted-p2p-chat/backend/app/services/presence_service.py new file mode 100644 index 00000000..ccd47ea0 --- /dev/null +++ b/PROJECTS/encrypted-p2p-chat/backend/app/services/presence_service.py @@ -0,0 +1,164 @@ +""" +ⒸAngelaMos | 2025 +Presence service for managing user online/offline status +""" + +import logging +from datetime import UTC, datetime +from uuid import UUID + +from app.core.enums import PresenceStatus +from app.core.exceptions import DatabaseError +from app.core.surreal_manager import surreal_db + + +logger = logging.getLogger(__name__) + + +class PresenceService: + """ + Service for managing user presence status in real time + """ + async def set_user_online(self, user_id: UUID) -> None: + """ + Mark user as online and update last seen timestamp + """ + try: + await surreal_db.update_presence( + user_id = str(user_id), + status = PresenceStatus.ONLINE.value, + last_seen = datetime.now(UTC).isoformat() + ) + logger.info("User %s is now online", user_id) + except Exception as e: + logger.error("Failed to set user %s online: %s", user_id, e) + raise DatabaseError(f"Failed to update presence: {str(e)}") from e + + async def set_user_offline(self, user_id: UUID) -> None: + """ + Mark user as offline and update last seen timestamp + """ + try: + await surreal_db.update_presence( + user_id = str(user_id), + status = PresenceStatus.OFFLINE.value, + last_seen = datetime.now(UTC).isoformat() + ) + logger.info("User %s is now offline", user_id) + except Exception as e: + logger.error("Failed to set user %s offline: %s", user_id, e) + raise DatabaseError(f"Failed to update presence: {str(e)}") from e + + async def set_user_away(self, user_id: UUID) -> None: + """ + Mark user as away due to inactivity + """ + try: + await surreal_db.update_presence( + user_id = str(user_id), + status = PresenceStatus.AWAY.value, + last_seen = datetime.now(UTC).isoformat() + ) + logger.debug("User %s is now away", user_id) + except Exception as e: + logger.error("Failed to set user %s away: %s", user_id, e) + raise DatabaseError(f"Failed to update presence: {str(e)}") from e + + async def update_last_seen(self, user_id: UUID) -> None: + """ + Update user last seen timestamp without changing status + """ + try: + last_seen = datetime.now(UTC).isoformat() + await surreal_db.db.merge( + f"presence:{user_id}", + { + "last_seen": last_seen, + "updated_at": "time::now()" + } + ) + logger.debug("Updated last seen for user %s", user_id) + except Exception as e: + logger.error("Failed to update last seen for %s: %s", user_id, e) + + async def get_user_presence(self, user_id: UUID) -> dict: + """ + Get current presence status for a user + """ + try: + await surreal_db.ensure_connected() + result = await surreal_db.db.select(f"presence:{user_id}") + + if not result: + return { + "user_id": str(user_id), + "status": PresenceStatus.OFFLINE.value, + "last_seen": datetime.now(UTC).isoformat() + } + + return { + "user_id": result.get("user_id", + str(user_id)), + "status": result.get("status", + PresenceStatus.OFFLINE.value), + "last_seen": + result.get("last_seen", + datetime.now(UTC).isoformat()) + } + except Exception as e: + logger.error("Failed to get presence for user %s: %s", user_id, e) + return { + "user_id": str(user_id), + "status": PresenceStatus.OFFLINE.value, + "last_seen": datetime.now(UTC).isoformat() + } + + async def get_room_online_users(self, room_id: str) -> list[dict]: + """ + Get all online users in a specific room + """ + try: + presence_list = await surreal_db.get_room_presence(room_id) + + return [ + { + "user_id": p.user_id, + "status": p.status, + "last_seen": p.last_seen.isoformat() + } for p in presence_list + ] + except Exception as e: + logger.error("Failed to get online users for room %s: %s", room_id, e) + return [] + + async def bulk_update_presence( + self, + user_ids: list[UUID], + status: PresenceStatus + ) -> None: + """ + Update presence status for multiple users at once + """ + for user_id in user_ids: + try: + await surreal_db.update_presence( + user_id = str(user_id), + status = status.value, + last_seen = datetime.now(UTC).isoformat() + ) + except Exception as e: + logger.error( + "Failed to bulk update presence for %s: %s", + user_id, + e + ) + continue + + logger.info( + "Bulk updated presence for %s users to %s", + len(user_ids), + status.value + ) + + +presence_service = PresenceService() diff --git a/PROJECTS/encrypted-p2p-chat/backend/app/services/websocket_service.py b/PROJECTS/encrypted-p2p-chat/backend/app/services/websocket_service.py new file mode 100644 index 00000000..6af1dc54 --- /dev/null +++ b/PROJECTS/encrypted-p2p-chat/backend/app/services/websocket_service.py @@ -0,0 +1,293 @@ +""" +ⒸAngelaMos | 2025 +WebSocket service for handling real time message routing and processing +""" + +import logging +from typing import Any +from uuid import UUID +from datetime import UTC, datetime + +from fastapi import WebSocket + +from app.config import ( + WS_MESSAGE_TYPE_ENCRYPTED, + WS_MESSAGE_TYPE_PRESENCE, + WS_MESSAGE_TYPE_RECEIPT, + WS_MESSAGE_TYPE_TYPING, +) +from app.core.enums import PresenceStatus +from app.core.websocket_manager import connection_manager +from app.schemas.websocket import ( + EncryptedMessageWS, + ReadReceiptWS, + TypingIndicatorWS, +) +from app.models.Base import async_session_maker +from app.services.message_service import message_service +from app.services.presence_service import presence_service + + +logger = logging.getLogger(__name__) + + +class WebSocketService: + """ + Service for processing WebSocket + messages and routing to appropriate handlers + """ + async def route_message( + self, + websocket: WebSocket, + user_id: UUID, + message: dict[str, + Any] + ) -> None: + """ + Route incoming WebSocket message + to appropriate handler based on type + """ + message_type = message.get("type") + + if not message_type: + await websocket.send_json( + { + "type": "error", + "error_code": "missing_type", + "error_message": "Message type is required" + } + ) + return + + if message_type == WS_MESSAGE_TYPE_ENCRYPTED: + await self.handle_encrypted_message(user_id, message) + elif message_type == WS_MESSAGE_TYPE_TYPING: + await self.handle_typing_indicator(user_id, message) + elif message_type == WS_MESSAGE_TYPE_PRESENCE: + await self.handle_presence_update(user_id, message) + elif message_type == WS_MESSAGE_TYPE_RECEIPT: + await self.handle_read_receipt(user_id, message) + elif message_type == "heartbeat": + await self.handle_heartbeat(user_id) + else: + logger.warning( + "Unknown message type from %s: %s", + user_id, + message_type + ) + await websocket.send_json( + { + "type": "error", + "error_code": "unknown_type", + "error_message": f"Unknown message type: {message_type}" + } + ) + + async def handle_encrypted_message( + self, + user_id: UUID, + message: dict[str, + Any] + ) -> None: + """ + Process encrypted message from client and forward to recipient + """ + try: + recipient_id = UUID(message.get("recipient_id")) + plaintext = message.get("plaintext") + + if not plaintext: + logger.error("Missing plaintext in message from %s", user_id) + return + + async with async_session_maker() as session: + result = await message_service.send_encrypted_message( + session, + user_id, + recipient_id, + plaintext + ) + + ws_message = EncryptedMessageWS( + message_id = result.id if hasattr(result, + 'id') else "unknown", + sender_id = str(user_id), + recipient_id = str(recipient_id), + ciphertext = message.get("ciphertext", + ""), + nonce = message.get("nonce", + ""), + header = message.get("header", + ""), + sender_username = message.get("sender_username", + "") + ) + + await connection_manager.send_message( + recipient_id, + ws_message.model_dump(mode = "json") + ) + + logger.info( + "Encrypted message forwarded: %s -> %s", + user_id, + recipient_id + ) + + except ValueError as e: + logger.error( + "Invalid UUID in encrypted message from %s: %s", + user_id, + e + ) + except Exception as e: + logger.error( + "Failed to handle encrypted message from %s: %s", + user_id, + e + ) + + async def handle_typing_indicator( + self, + user_id: UUID, + message: dict[str, + Any] + ) -> None: + """ + Process typing indicator and broadcast to room + """ + try: + room_id = message.get("room_id") + is_typing = message.get("is_typing", False) + + if not room_id: + logger.error( + "Missing room_id in typing indicator from %s", + user_id + ) + return + + typing_msg = TypingIndicatorWS( + user_id = str(user_id), + room_id = room_id, + is_typing = is_typing + ) + + await connection_manager.broadcast_to_room( + room_id, + typing_msg.model_dump(mode = "json") + ) + + logger.debug( + "Typing indicator broadcast: %s in %s = %s", + user_id, + room_id, + is_typing + ) + + except Exception as e: + logger.error( + "Failed to handle typing indicator from %s: %s", + user_id, + e + ) + + async def handle_presence_update( + self, + user_id: UUID, + message: dict[str, + Any] + ) -> None: + """ + Process presence status update from client + """ + try: + status = message.get("status") + + if not status: + logger.error("Missing status in presence update from %s", user_id) + return + + try: + presence_status = PresenceStatus(status) + except ValueError: + logger.warning( + "Invalid presence status from %s: %s", + user_id, + status + ) + return + + if presence_status == PresenceStatus.ONLINE: + await presence_service.set_user_online(user_id) + elif presence_status == PresenceStatus.AWAY: + await presence_service.set_user_away(user_id) + elif presence_status == PresenceStatus.OFFLINE: + await presence_service.set_user_offline(user_id) + + logger.debug( + "Presence updated: %s -> %s", + user_id, + presence_status.value + ) + + except Exception as e: + logger.error( + "Failed to handle presence update from %s: %s", + user_id, + e + ) + + async def handle_read_receipt( + self, + user_id: UUID, + message: dict[str, + Any] + ) -> None: + """ + Process read receipt and notify message sender + """ + try: + message_id = message.get("message_id") + sender_id_str = message.get("sender_id") + + if not message_id or not sender_id_str: + logger.error( + "Missing message_id or sender_id in receipt from %s", + user_id + ) + return + + sender_id = UUID(sender_id_str) + + receipt_msg = ReadReceiptWS( + message_id = message_id, + user_id = str(user_id), + read_at = datetime.now(UTC) + ) + + await connection_manager.send_message( + sender_id, + receipt_msg.model_dump(mode = "json") + ) + + logger.debug( + "Read receipt sent: message %s read by %s", + message_id, + user_id + ) + + except ValueError as e: + logger.error("Invalid UUID in read receipt from %s: %s", user_id, e) + except Exception as e: + logger.error("Failed to handle read receipt from %s: %s", user_id, e) + + async def handle_heartbeat(self, user_id: UUID) -> None: + """ + Process heartbeat message and update user last seen + """ + logger.debug("Heartbeat received from user %s", user_id) + await presence_service.update_last_seen(user_id) + + +websocket_service = WebSocketService() diff --git a/PROJECTS/encrypted-p2p-chat/backend/pyproject.toml b/PROJECTS/encrypted-p2p-chat/backend/pyproject.toml new file mode 100644 index 00000000..047868fe --- /dev/null +++ b/PROJECTS/encrypted-p2p-chat/backend/pyproject.toml @@ -0,0 +1,253 @@ +[project] +name = "encrypted-p2p-chat" +version = "1.0.0" +description = "End-to-end encrypted P2P chat with Triple Ratchet and WebAuthn" +requires-python = ">=3.13" +authors = [ + {name = "Carter", email = "carter@certgames.com"} +] + +dependencies = [ + "fastapi>=0.121.0", + "uvicorn[standard]>=0.38.0", + "websockets>=15.0.1", + "redis[hiredis]>=7.1.0", + "sqlalchemy>=2.0.44", + "sqlmodel>=0.0.27", + "alembic>=1.17.2", + "asyncpg>=0.30.0", + "pydantic>=2.12.4", + "pydantic-settings>=2.12.0", + "webauthn>=2.7.0", + "fido2>=2.0.0", + "cryptography>=46.0.3", + "pynacl>=1.6.1", + "passlib>=1.7.4", + "python-multipart>=0.0.20", + "httpx>=0.28.1", + "orjson>=3.11.4", + "surrealdb>=1.0.6", + "liboqs-python>=0.14.1" +] +[project.optional-dependencies] +dev = [ + "pytest>=8.0.0", + "pytest-asyncio>=0.23.0", + "aiosqlite>=0.20.0", + "ruff>=0.8.0", + "mypy>=1.9.0", + "pre-commit>=3.0.0", + "types-redis>=4.6.0", + "types-passlib>=1.7.0", +] + +[build-system] +requires = ["setuptools>=80.9.0", "wheel>=0.45.1"] +build-backend = "setuptools.build_meta" + +[tool.setuptools.packages.find] +include = ["app"] + +[tool.ruff] +target-version = "py313" +line-length = 95 +indent-width = 4 +exclude = [ + ".bzr", + ".direnv", + ".eggs", + ".git", + ".git-rewrite", + ".hg", + ".ipynb_checkpoints", + ".mypy_cache", + ".nox", + ".pyenv", + ".pytest_cache", + ".pytype", + ".ruff_cache", + ".svn", + ".tox", + ".venv", + "__pycache__", + "_build", + "build", + "dist", + "site-packages", + "venv", +] + +[tool.ruff.format] +line-ending = "auto" +skip-magic-trailing-comma = false + +[tool.ruff.lint] +select = [ + "E1", # Indentation + "E4", # Imports + "E7", # Statement + "F", # Pyflakes (all F rules) + "W292", # No newline at end of file + "W605", # Invalid escape sequence + "B", # Bugbear + "C4", # Comprehensions + "UP", # Pyupgrade + "ARG", # Unused arguments + "SIM", # Simplify + "I", # isort rules + "F401", # Unused imports + "F811", # Redefined imports + "F821", # Undefined name +] + +ignore = [ + "E501", # Line length (handled by formatter) + "W291", # Trailing whitespace + "W293", # Blank line contains whitespace + "I001", # Import sorting + "RUF001", # Ambiguous unicode + "RUF002", # Docstring with ambiguous unicode + "B008", # FastAPI Depends() in defaults is standard pattern + "ARG001", # Unused function args (lifespan protocol) + "E712", # SQLAlchemy requires == True/False for query filters +] + + +[tool.ruff.lint.per-file-ignores] +"__init__.py" = ["F401"] +"tests/*" = ["ARG002"] + +[tool.mypy] +python_version = "3.13" +strict = true +warn_return_any = true +warn_unused_configs = true +disallow_untyped_defs = true +disallow_any_generics = true +check_untyped_defs = true +no_implicit_optional = true +warn_redundant_casts = true +warn_unused_ignores = true +warn_no_return = true +follow_imports = "normal" +ignore_missing_imports = true + +[[tool.mypy.overrides]] +module = "app.models.*" +disable_error_code = ["call-arg", "misc"] + +[[tool.mypy.overrides]] +module = "app.core.surreal_manager" +disable_error_code = ["union-attr", "no-any-return"] + +[[tool.mypy.overrides]] +module = "app.core.redis_manager" +disable_error_code = ["type-arg", "attr-defined"] + +[[tool.mypy.overrides]] +module = "app.core.exception_handlers" +disable_error_code = ["arg-type"] + +[[tool.mypy.overrides]] +module = "app.services.presence_service" +disable_error_code = ["union-attr", "type-arg"] + +[[tool.mypy.overrides]] +module = "app.services.auth_service" +disable_error_code = ["arg-type", "no-any-return", "attr-defined"] + +[[tool.mypy.overrides]] +module = "app.services.prekey_service" +disable_error_code = ["attr-defined", "no-any-return"] + +[[tool.mypy.overrides]] +module = "app.services.message_service" +disable_error_code = ["no-any-return"] + +[[tool.mypy.overrides]] +module = "app.core.websocket_manager" +disable_error_code = ["attr-defined", "type-arg"] + + +[tool.pylint.main] +py-version = "3.13" +jobs = 4 +load-plugins = [ + "pylint_mongoengine", + "pylint_pydantic", + "pylint_per_file_ignores", + "pylint_flask", + "pylint_celery" +] +persistent = true +suggestion-mode = true +ignore = [ + "venv", + ".venv", + "__pycache__", + "build", + "dist", + ".git", + ".pytest_cache", + ".mypy_cache", + ".ruff_cache", +] +ignore-paths = [ + "^venv/.*", + "^.venv/.*", + "^build/.*", + "^dist/.*", +] +[tool.pylint.type-check] +generated-members = [ + "objects", + "id", + "get_or_create", + "DoesNotExist", + "MultipleObjectsReturned", + "objects.get_or_create" +] + +[tool.pylint.messages_control] +disable = [ + "C0111", # missing-docstring + "C0103", # invalid-name + "R0903", # too-few-public-methods + "W0511", # fixme + "W0622", # redefined-builtin + "W0612", # unused-variable (handled by ruff) + "W0613", # unused-argument (handled by ruff) + "C0301", # Line too long + "C0302", # Too many lines + "C0411", # Wrong import order + "C0305", # Trailing newlines + "C0303", # Trailing whitespace + "C0304", # Final newline missing + "R0801", # Similar lines (want exact duplicates only) + "E0401", # Unable to import - packages not in pylint env + "C0412", # Import grouping - don't care about grouping imports + "W0718", # Broad exception catching - intentional for service layer + "E0611", # No name in module - false positive for dynamic imports + "E1101", # No member - false positive for alembic context +] + +[tool.pylint.design] +max-args = 7 +max-attributes = 10 +max-locals = 30 +max-positional-arguments = 7 + +[tool.pylint."messages control"] +per-file-ignores = [ + "alembic/env.py:W0611", # Unused imports needed for SQLModel metadata + "app/services/*:C0121", # SQLAlchemy requires == True/False for query filters + "tests/*:W0212,W0621", # Tests access protected members; pytest fixtures reuse names +] + + +[tool.pytest.ini_options] +asyncio_mode = "auto" +testpaths = ["tests"] +python_files = ["test_*.py"] +python_classes = ["Test*"] +python_functions = ["test_*"] diff --git a/PROJECTS/encrypted-p2p-chat/backend/tests/__init__.py b/PROJECTS/encrypted-p2p-chat/backend/tests/__init__.py new file mode 100644 index 00000000..159e8918 --- /dev/null +++ b/PROJECTS/encrypted-p2p-chat/backend/tests/__init__.py @@ -0,0 +1,4 @@ +""" +ⒸAngelaMos | 2025 +encrypted-p2p-chat pyest suite +""" diff --git a/PROJECTS/encrypted-p2p-chat/backend/tests/conftest.py b/PROJECTS/encrypted-p2p-chat/backend/tests/conftest.py new file mode 100644 index 00000000..bd8944f2 --- /dev/null +++ b/PROJECTS/encrypted-p2p-chat/backend/tests/conftest.py @@ -0,0 +1,209 @@ +""" +ⒸAngelaMos | 2025 +Pytest configuration and fixtures for all tests +""" + +import asyncio +from uuid import uuid4 +from typing import Any +from collections.abc import AsyncGenerator + +import pytest +import pytest_asyncio +from sqlmodel import SQLModel +from sqlalchemy.ext.asyncio import ( + AsyncSession, + create_async_engine, +) +from sqlalchemy.orm import sessionmaker +from webauthn.helpers import bytes_to_base64url + +from app.models.User import User +from app.models.IdentityKey import IdentityKey +from app.models.SignedPrekey import SignedPrekey +from app.models.OneTimePrekey import OneTimePrekey +from app.core.encryption.x3dh_manager import x3dh_manager + + +@pytest.fixture(scope = "session") +def event_loop(): + """ + Create event loop for async tests + """ + loop = asyncio.get_event_loop_policy().new_event_loop() + yield loop + loop.close() + + +@pytest_asyncio.fixture(scope = "function") +async def db_session() -> AsyncGenerator[AsyncSession]: + """ + Create in-memory SQLite database for testing + """ + engine = create_async_engine( + "sqlite+aiosqlite:///:memory:", + echo = False, + future = True, + ) + + async with engine.begin() as conn: + await conn.run_sync(SQLModel.metadata.create_all) + + async_session = sessionmaker( + bind = engine, + class_ = AsyncSession, + expire_on_commit = False, + ) + + async with async_session() as session: + yield session + await session.rollback() + + await engine.dispose() + + +@pytest_asyncio.fixture +async def test_user(db_session: AsyncSession) -> User: + """ + Create test user + """ + user = User( + username = "testuser", + display_name = "Test User", + is_active = True, + is_verified = True, + ) + db_session.add(user) + await db_session.commit() + await db_session.refresh(user) + return user + + +@pytest_asyncio.fixture +async def test_user_2(db_session: AsyncSession) -> User: + """ + Create second test user for conversations + """ + user = User( + username = "testuser2", + display_name = "Test User 2", + is_active = True, + is_verified = True, + ) + db_session.add(user) + await db_session.commit() + await db_session.refresh(user) + return user + + +@pytest_asyncio.fixture +async def test_identity_key( + db_session: AsyncSession, + test_user: User +) -> IdentityKey: + """ + Create identity key for test user + """ + ik_private_x25519, ik_public_x25519 = ( + x3dh_manager.generate_identity_keypair_x25519() + ) + ik_private_ed25519, ik_public_ed25519 = ( + x3dh_manager.generate_identity_keypair_ed25519() + ) + + identity_key = IdentityKey( + user_id = test_user.id, + public_key = ik_public_x25519, + private_key = ik_private_x25519, + public_key_ed25519 = ik_public_ed25519, + private_key_ed25519 = ik_private_ed25519, + ) + + db_session.add(identity_key) + await db_session.commit() + await db_session.refresh(identity_key) + return identity_key + + +@pytest_asyncio.fixture +async def test_signed_prekey( + db_session: AsyncSession, + test_user: User, + test_identity_key: IdentityKey +) -> SignedPrekey: + """ + Create signed prekey for test user + """ + spk_private, spk_public, spk_signature = x3dh_manager.generate_signed_prekey( + test_identity_key.private_key_ed25519 + ) + + signed_prekey = SignedPrekey( + user_id = test_user.id, + key_id = 1, + public_key = spk_public, + private_key = spk_private, + signature = spk_signature, + is_active = True, + ) + + db_session.add(signed_prekey) + await db_session.commit() + await db_session.refresh(signed_prekey) + return signed_prekey + + +@pytest_asyncio.fixture +async def test_one_time_prekey( + db_session: AsyncSession, + test_user: User +) -> OneTimePrekey: + """ + Create one-time prekey for test user + """ + opk_private, opk_public = x3dh_manager.generate_one_time_prekey() + + one_time_prekey = OneTimePrekey( + user_id = test_user.id, + key_id = 1, + public_key = opk_public, + private_key = opk_private, + is_used = False, + ) + + db_session.add(one_time_prekey) + await db_session.commit() + await db_session.refresh(one_time_prekey) + return one_time_prekey + + +@pytest.fixture +def mock_webauthn_credential() -> dict[str, Any]: + """ + Mock WebAuthn credential response + """ + return { + "id": bytes_to_base64url(uuid4().bytes), + "rawId": bytes_to_base64url(uuid4().bytes), + "type": "public-key", + "response": { + "clientDataJSON": bytes_to_base64url(b'{"type":"webauthn.create"}'), + "attestationObject": bytes_to_base64url(b"mock_attestation"), + }, + } + + +@pytest.fixture +def sample_plaintext() -> str: + """ + Sample message for encryption tests + """ + return "Hello, this is a test message!" + + +@pytest.fixture +def sample_associated_data() -> bytes: + """ + Sample associated data for AEAD + """ + return b"test_sender:test_recipient" diff --git a/PROJECTS/encrypted-p2p-chat/backend/tests/test_auth_service.py b/PROJECTS/encrypted-p2p-chat/backend/tests/test_auth_service.py new file mode 100644 index 00000000..41d379ae --- /dev/null +++ b/PROJECTS/encrypted-p2p-chat/backend/tests/test_auth_service.py @@ -0,0 +1,97 @@ +""" +ⒸAngelaMos | 2025 +Tests for authentication service +""" + +import pytest +from sqlalchemy.ext.asyncio import AsyncSession + +from app.models.User import User +from app.services.auth_service import auth_service +from app.core.exceptions import UserExistsError + + +class TestAuthService: + """ + Test authentication service basics + """ + @pytest.mark.asyncio + async def test_create_user(self, db_session: AsyncSession): + """ + Test creating a new user + """ + user = await auth_service.create_user( + session = db_session, + username = "newuser", + display_name = "New User" + ) + + assert user.id is not None + assert user.username == "newuser" + assert user.display_name == "New User" + assert user.is_active is True + assert user.is_verified is False + + @pytest.mark.asyncio + async def test_create_duplicate_user_fails( + self, + db_session: AsyncSession, + test_user: User + ): + """ + Test cannot create user with duplicate username + """ + with pytest.raises(UserExistsError, match = "already exists"): + await auth_service.create_user( + session = db_session, + username = test_user.username, + display_name = "Duplicate" + ) + + @pytest.mark.asyncio + async def test_get_user_by_username( + self, + db_session: AsyncSession, + test_user: User + ): + """ + Test retrieving user by username + """ + user = await auth_service.get_user_by_username( + session = db_session, + username = test_user.username + ) + + assert user is not None + assert user.id == test_user.id + assert user.username == test_user.username + + @pytest.mark.asyncio + async def test_get_nonexistent_user(self, db_session: AsyncSession): + """ + Test getting user that doesn't exist returns None + """ + user = await auth_service.get_user_by_username( + session = db_session, + username = "nonexistent" + ) + + assert user is None + + @pytest.mark.asyncio + async def test_get_user_by_id( + self, + db_session: AsyncSession, + test_user: User + ): + """ + Test retrieving user by ID + """ + user = await auth_service.get_user_by_id( + session = db_session, + user_id = test_user.id + ) + + assert user is not None + assert user.id == test_user.id + assert user.username == test_user.username diff --git a/PROJECTS/encrypted-p2p-chat/backend/tests/test_encryption.py b/PROJECTS/encrypted-p2p-chat/backend/tests/test_encryption.py new file mode 100644 index 00000000..b6089d49 --- /dev/null +++ b/PROJECTS/encrypted-p2p-chat/backend/tests/test_encryption.py @@ -0,0 +1,165 @@ +""" +ⒸAngelaMos | 2025 +Tests for Double Ratchet encryption core +""" + +import pytest +from cryptography.hazmat.primitives import serialization +from cryptography.hazmat.primitives.asymmetric.x25519 import X25519PrivateKey + +from app.core.encryption.double_ratchet import double_ratchet + + +class TestDoubleRatchet: + """ + Test Double Ratchet encryption/decryption + """ + def test_encrypt_decrypt_basic( + self, + sample_plaintext: str, + sample_associated_data: bytes + ): + """ + Test basic encrypt/decrypt cycle works + """ + shared_key = b"0" * 32 + + bob_dh_private = X25519PrivateKey.generate() + bob_dh_public_bytes = bob_dh_private.public_key().public_bytes( + encoding = serialization.Encoding.Raw, + format = serialization.PublicFormat.Raw + ) + + sender_state = double_ratchet.initialize_sender( + shared_key = shared_key, + peer_public_key = bob_dh_public_bytes + ) + + receiver_state = double_ratchet.initialize_receiver( + shared_key = shared_key, + own_private_key = bob_dh_private + ) + + encrypted = double_ratchet.encrypt_message( + sender_state, + sample_plaintext.encode(), + sample_associated_data + ) + + decrypted = double_ratchet.decrypt_message( + receiver_state, + encrypted, + sample_associated_data + ) + + assert decrypted.decode() == sample_plaintext + + def test_multiple_messages(self, sample_associated_data: bytes): + """ + Test multiple messages maintain state correctly + """ + shared_key = b"0" * 32 + + bob_dh_private = X25519PrivateKey.generate() + bob_dh_public_bytes = bob_dh_private.public_key().public_bytes( + encoding = serialization.Encoding.Raw, + format = serialization.PublicFormat.Raw + ) + + sender_state = double_ratchet.initialize_sender( + shared_key = shared_key, + peer_public_key = bob_dh_public_bytes + ) + + receiver_state = double_ratchet.initialize_receiver( + shared_key = shared_key, + own_private_key = bob_dh_private + ) + + messages = [b"Message 1", b"Message 2", b"Message 3"] + encrypted_messages = [] + + for msg in messages: + encrypted = double_ratchet.encrypt_message( + sender_state, + msg, + sample_associated_data + ) + encrypted_messages.append(encrypted) + + for i, encrypted in enumerate(encrypted_messages): + decrypted = double_ratchet.decrypt_message( + receiver_state, + encrypted, + sample_associated_data + ) + assert decrypted == messages[i] + + def test_message_numbers_increment(self, sample_associated_data: bytes): + """ + Test message numbers increment correctly + """ + shared_key = b"0" * 32 + peer_public_key = b"1" * 32 + + sender_state = double_ratchet.initialize_sender( + shared_key = shared_key, + peer_public_key = peer_public_key + ) + + assert sender_state.sending_message_number == 0 + + double_ratchet.encrypt_message( + sender_state, + b"Message 1", + sample_associated_data + ) + + assert sender_state.sending_message_number == 1 + + double_ratchet.encrypt_message( + sender_state, + b"Message 2", + sample_associated_data + ) + + assert sender_state.sending_message_number == 2 + + def test_tampered_message_fails(self, sample_associated_data: bytes): + """ + Test tampered messages fail to decrypt + """ + shared_key = b"0" * 32 + + bob_dh_private = X25519PrivateKey.generate() + bob_dh_public_bytes = bob_dh_private.public_key().public_bytes( + encoding = serialization.Encoding.Raw, + format = serialization.PublicFormat.Raw + ) + + sender_state = double_ratchet.initialize_sender( + shared_key = shared_key, + peer_public_key = bob_dh_public_bytes + ) + + receiver_state = double_ratchet.initialize_receiver( + shared_key = shared_key, + own_private_key = bob_dh_private + ) + + encrypted = double_ratchet.encrypt_message( + sender_state, + b"Original message", + sample_associated_data + ) + + tampered_ciphertext = bytearray(encrypted.ciphertext) + tampered_ciphertext[0] ^= 0xFF + encrypted.ciphertext = bytes(tampered_ciphertext) + + with pytest.raises(ValueError, match = "tampered or corrupted"): + double_ratchet.decrypt_message( + receiver_state, + encrypted, + sample_associated_data + ) diff --git a/PROJECTS/encrypted-p2p-chat/backend/tests/test_message_service.py b/PROJECTS/encrypted-p2p-chat/backend/tests/test_message_service.py new file mode 100644 index 00000000..adaa1b0a --- /dev/null +++ b/PROJECTS/encrypted-p2p-chat/backend/tests/test_message_service.py @@ -0,0 +1,135 @@ +""" +ⒸAngelaMos | 2025 +Tests for message service (end to end encryption flow) +""" + +import pytest +from sqlalchemy.ext.asyncio import AsyncSession + +from app.models.User import User +from app.models.IdentityKey import IdentityKey +from app.models.SignedPrekey import SignedPrekey +from app.core.exceptions import InvalidDataError +from app.models.OneTimePrekey import OneTimePrekey +from app.services.message_service import message_service +from app.core.encryption.x3dh_manager import x3dh_manager + + +class TestMessageService: + """ + Test message encryption/decryption service + """ + @pytest.mark.asyncio + async def test_initialize_conversation( + self, + db_session: AsyncSession, + test_user: User, + test_user_2: User, + test_identity_key: IdentityKey, + test_signed_prekey: SignedPrekey, + test_one_time_prekey: OneTimePrekey + ): + """ + Test initializing encrypted conversation between two users + """ + sender_ik_private, sender_ik_public = ( + x3dh_manager.generate_identity_keypair_x25519() + ) + sender_ik_private_ed, sender_ik_public_ed = ( + x3dh_manager.generate_identity_keypair_ed25519() + ) + + sender_identity_key = IdentityKey( + user_id = test_user_2.id, + public_key = sender_ik_public, + private_key = sender_ik_private, + public_key_ed25519 = sender_ik_public_ed, + private_key_ed25519 = sender_ik_private_ed, + ) + db_session.add(sender_identity_key) + await db_session.commit() + + ratchet_state = await message_service.initialize_conversation( + session = db_session, + sender_id = test_user_2.id, + recipient_id = test_user.id + ) + + assert ratchet_state.user_id == test_user_2.id + assert ratchet_state.peer_user_id == test_user.id + assert ratchet_state.sending_message_number == 0 + assert ratchet_state.receiving_message_number == 0 + assert ratchet_state.dh_public_key is not None + + @pytest.mark.asyncio + async def test_cannot_initialize_with_self( + self, + db_session: AsyncSession, + test_user: User + ): + """ + Test cannot start conversation with yourself + """ + with pytest.raises(InvalidDataError, + match = "Cannot start conversation with yourself"): + await message_service.initialize_conversation( + session = db_session, + sender_id = test_user.id, + recipient_id = test_user.id + ) + + @pytest.mark.asyncio + async def test_ratchet_state_persistence( + self, + db_session: AsyncSession, + test_user: User, + test_user_2: User, + test_identity_key: IdentityKey, + test_signed_prekey: SignedPrekey, + test_one_time_prekey: OneTimePrekey + ): + """ + Test ratchet state loads and saves correctly + """ + sender_ik_private, sender_ik_public = ( + x3dh_manager.generate_identity_keypair_x25519() + ) + sender_ik_private_ed, sender_ik_public_ed = ( + x3dh_manager.generate_identity_keypair_ed25519() + ) + + sender_identity_key = IdentityKey( + user_id = test_user_2.id, + public_key = sender_ik_public, + private_key = sender_ik_private, + public_key_ed25519 = sender_ik_public_ed, + private_key_ed25519 = sender_ik_private_ed, + ) + db_session.add(sender_identity_key) + await db_session.commit() + + ratchet_state = await message_service.initialize_conversation( + session = db_session, + sender_id = test_user_2.id, + recipient_id = test_user.id + ) + + initial_msg_num = ratchet_state.sending_message_number + + dr_state = await message_service._load_ratchet_state_from_db( + ratchet_state + ) + + assert dr_state.sending_message_number == initial_msg_num + assert dr_state.dh_private_key is not None + + dr_state.sending_message_number += 1 + + await message_service._save_ratchet_state_to_db( + db_session, + ratchet_state, + dr_state + ) + + await db_session.refresh(ratchet_state) + assert ratchet_state.sending_message_number == initial_msg_num + 1 diff --git a/PROJECTS/encrypted-p2p-chat/backend/tests/test_x3dh.py b/PROJECTS/encrypted-p2p-chat/backend/tests/test_x3dh.py new file mode 100644 index 00000000..90ac8e8e --- /dev/null +++ b/PROJECTS/encrypted-p2p-chat/backend/tests/test_x3dh.py @@ -0,0 +1,130 @@ +""" +ⒸAngelaMos | 2025 +Tests for X3DH key exchange protocol +""" + +from webauthn.helpers import base64url_to_bytes + +from app.core.encryption.x3dh_manager import x3dh_manager, PreKeyBundle + + +class TestX3DH: + """ + Test X3DH key exchange + """ + def test_key_generation(self): + """ + Test all key generation functions work + """ + ik_private, ik_public = x3dh_manager.generate_identity_keypair_x25519() + assert len(base64url_to_bytes(ik_private)) == 32 + assert len(base64url_to_bytes(ik_public)) == 32 + + ik_private_ed, ik_public_ed = x3dh_manager.generate_identity_keypair_ed25519() + assert len(base64url_to_bytes(ik_private_ed)) == 32 + assert len(base64url_to_bytes(ik_public_ed)) == 32 + + spk_private, spk_public, signature = x3dh_manager.generate_signed_prekey( + ik_private_ed + ) + assert len(base64url_to_bytes(spk_private)) == 32 + assert len(base64url_to_bytes(spk_public)) == 32 + assert len(base64url_to_bytes(signature)) == 64 + + opk_private, opk_public = x3dh_manager.generate_one_time_prekey() + assert len(base64url_to_bytes(opk_private)) == 32 + assert len(base64url_to_bytes(opk_public)) == 32 + + def test_x3dh_handshake_with_opk(self): + """ + Test full X3DH handshake with one-time prekey + """ + alice_ik_private, alice_ik_public = ( + x3dh_manager.generate_identity_keypair_x25519() + ) + alice_ik_private_ed, alice_ik_public_ed = ( + x3dh_manager.generate_identity_keypair_ed25519() + ) + + bob_ik_private, bob_ik_public = ( + x3dh_manager.generate_identity_keypair_x25519() + ) + bob_ik_private_ed, bob_ik_public_ed = ( + x3dh_manager.generate_identity_keypair_ed25519() + ) + + bob_spk_private, bob_spk_public, bob_spk_sig = ( + x3dh_manager.generate_signed_prekey(bob_ik_private_ed) + ) + + bob_opk_private, bob_opk_public = x3dh_manager.generate_one_time_prekey() + + bob_bundle = PreKeyBundle( + identity_key = bob_ik_public, + signed_prekey = bob_spk_public, + signed_prekey_signature = bob_spk_sig, + one_time_prekey = bob_opk_public + ) + + alice_result = x3dh_manager.perform_x3dh_sender( + alice_identity_private_x25519 = alice_ik_private, + bob_bundle = bob_bundle, + bob_identity_public_ed25519 = bob_ik_public_ed + ) + + bob_result = x3dh_manager.perform_x3dh_receiver( + bob_identity_private_x25519 = bob_ik_private, + bob_signed_prekey_private = bob_spk_private, + bob_one_time_prekey_private = bob_opk_private, + alice_ephemeral_public = alice_result.ephemeral_public_key, + alice_identity_public_x25519 = alice_ik_public + ) + + assert alice_result.shared_key == bob_result.shared_key + assert len(alice_result.shared_key) == 32 + + def test_x3dh_handshake_without_opk(self): + """ + Test X3DH handshake without one-time prekey + """ + alice_ik_private, alice_ik_public = ( + x3dh_manager.generate_identity_keypair_x25519() + ) + alice_ik_private_ed, alice_ik_public_ed = ( + x3dh_manager.generate_identity_keypair_ed25519() + ) + + bob_ik_private, bob_ik_public = ( + x3dh_manager.generate_identity_keypair_x25519() + ) + bob_ik_private_ed, bob_ik_public_ed = ( + x3dh_manager.generate_identity_keypair_ed25519() + ) + + bob_spk_private, bob_spk_public, bob_spk_sig = ( + x3dh_manager.generate_signed_prekey(bob_ik_private_ed) + ) + + bob_bundle = PreKeyBundle( + identity_key = bob_ik_public, + signed_prekey = bob_spk_public, + signed_prekey_signature = bob_spk_sig, + one_time_prekey = None + ) + + alice_result = x3dh_manager.perform_x3dh_sender( + alice_identity_private_x25519 = alice_ik_private, + bob_bundle = bob_bundle, + bob_identity_public_ed25519 = bob_ik_public_ed + ) + + bob_result = x3dh_manager.perform_x3dh_receiver( + bob_identity_private_x25519 = bob_ik_private, + bob_signed_prekey_private = bob_spk_private, + bob_one_time_prekey_private = None, + alice_ephemeral_public = alice_result.ephemeral_public_key, + alice_identity_public_x25519 = alice_ik_public + ) + + assert alice_result.shared_key == bob_result.shared_key + assert len(alice_result.shared_key) == 32 diff --git a/PROJECTS/encrypted-p2p-chat/conf/docker/dev/fastapi.docker b/PROJECTS/encrypted-p2p-chat/conf/docker/dev/fastapi.docker new file mode 100644 index 00000000..7f4e5a99 --- /dev/null +++ b/PROJECTS/encrypted-p2p-chat/conf/docker/dev/fastapi.docker @@ -0,0 +1,28 @@ +# ©AngelaMos | 2025 +# Development FastAPI Dockerfile +# Hot reload with uvicorn, volume mounts for code + +FROM python:3.13-slim + +ENV PYTHONUNBUFFERED=1 \ + PYTHONDONTWRITEBYTECODE=1 \ + PIP_NO_CACHE_DIR=1 \ + PIP_DISABLE_PIP_VERSION_CHECK=1 + +WORKDIR /app + +RUN apt-get update && \ + apt-get install -y --no-install-recommends \ + build-essential \ + libpq-dev \ + curl && \ + rm -rf /var/lib/apt/lists/* + +COPY backend/pyproject.toml ./ +RUN pip install -e .[dev] + +COPY backend/ . + +EXPOSE 8000 + +CMD ["uvicorn", "app.factory:create_app", "--factory", "--host", "0.0.0.0", "--port", "8000", "--reload"] diff --git a/PROJECTS/encrypted-p2p-chat/conf/docker/dev/vite.docker b/PROJECTS/encrypted-p2p-chat/conf/docker/dev/vite.docker new file mode 100644 index 00000000..b238d871 --- /dev/null +++ b/PROJECTS/encrypted-p2p-chat/conf/docker/dev/vite.docker @@ -0,0 +1,17 @@ +# ©AngelaMos | 2025 +# Development Vite Dockerfile +# HMR dev server, volume mounts for code + +FROM node:22-alpine + +WORKDIR /app + +COPY frontend/package*.json ./ + +RUN npm ci + +COPY frontend/ . + +EXPOSE 5173 + +CMD ["npm", "run", "dev", "--", "--host", "0.0.0.0"] diff --git a/PROJECTS/encrypted-p2p-chat/conf/docker/prod/fastapi.docker b/PROJECTS/encrypted-p2p-chat/conf/docker/prod/fastapi.docker new file mode 100644 index 00000000..d4a8af99 --- /dev/null +++ b/PROJECTS/encrypted-p2p-chat/conf/docker/prod/fastapi.docker @@ -0,0 +1,53 @@ +# ©AngelaMos | 2025 +# Production FastAPI Dockerfile +# Multi stage build, gunicorn with uvicorn workers + +FROM python:3.13-slim AS builder + +ENV PYTHONUNBUFFERED=1 \ + PYTHONDONTWRITEBYTECODE=1 \ + PIP_NO_CACHE_DIR=1 \ + PIP_DISABLE_PIP_VERSION_CHECK=1 + +WORKDIR /app + +RUN apt-get update && \ + apt-get install -y --no-install-recommends \ + build-essential \ + libpq-dev && \ + rm -rf /var/lib/apt/lists/* + +COPY backend/pyproject.toml ./ +RUN pip install --user --no-warn-script-location . +RUN pip install --user --no-warn-script-location gunicorn + +FROM python:3.13-slim + +WORKDIR /app + +RUN apt-get update && \ + apt-get install -y --no-install-recommends \ + libpq5 \ + curl && \ + rm -rf /var/lib/apt/lists/* && \ + useradd -m -u 1000 appuser + +COPY --from=builder /root/.local /home/appuser/.local +ENV PATH=/home/appuser/.local/bin:$PATH + +COPY backend/ . + +RUN chown -R appuser:appuser /app +USER appuser + +EXPOSE 8000 + +HEALTHCHECK --interval=30s --timeout=10s --start-period=40s --retries=3 \ + CMD curl -f http://localhost:8000/health || exit 1 + +CMD ["gunicorn", "app.main:app", \ + "--workers", "4", \ + "--worker-class", "uvicorn.workers.UvicornWorker", \ + "--bind", "0.0.0.0:8000", \ + "--access-logfile", "-", \ + "--error-logfile", "-"] diff --git a/PROJECTS/encrypted-p2p-chat/conf/docker/prod/vite.docker b/PROJECTS/encrypted-p2p-chat/conf/docker/prod/vite.docker new file mode 100644 index 00000000..fca89cbc --- /dev/null +++ b/PROJECTS/encrypted-p2p-chat/conf/docker/prod/vite.docker @@ -0,0 +1,28 @@ +# ©AngelaMos | 2025 +# Production Vite Dockerfile +# Multi-stage: build with node, serve with nginx + +FROM node:22-alpine AS builder + +WORKDIR /app + +COPY frontend/package*.json ./ + +RUN npm ci + +COPY frontend/ . + +RUN npm run build + +FROM nginx:alpine + +COPY --from=builder /app/dist /usr/share/nginx/html + +COPY conf/nginx/prod.nginx /etc/nginx/nginx.conf + +EXPOSE 80 + +HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \ + CMD wget --quiet --tries=1 --spider http://localhost/health || exit 1 + +CMD ["nginx", "-g", "daemon off;"] diff --git a/PROJECTS/encrypted-p2p-chat/conf/nginx/dev.nginx b/PROJECTS/encrypted-p2p-chat/conf/nginx/dev.nginx new file mode 100644 index 00000000..b80a0939 --- /dev/null +++ b/PROJECTS/encrypted-p2p-chat/conf/nginx/dev.nginx @@ -0,0 +1,81 @@ +# ©AngelaMos | 2025 +# Development Nginx Configuration +# - Proxies to Vite dev server (HMR/WebSocket support) +# - Proxies /api and /ws to FastAPI backend +# - No caching, verbose logging + +user nginx; +worker_processes 1; +error_log /var/log/nginx/error.log debug; +pid /var/run/nginx.pid; + +events { + worker_connections 1024; + use epoll; +} + +http { + include /etc/nginx/mime.types; + include /etc/nginx/http.conf; + default_type application/octet-stream; + + access_log /var/log/nginx/access.log main_timed; + + sendfile off; + tcp_nodelay on; + keepalive_timeout 65; + + server { + listen 80; + listen [::]:80; + server_name _; + + client_max_body_size 10M; + + # WebSocket to backend + location /ws { + proxy_pass http://backend; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection $connection_upgrade; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_read_timeout 3600s; + proxy_send_timeout 3600s; + proxy_buffering off; + } + + # API routes + location /api { + proxy_pass http://backend; + proxy_http_version 1.1; + proxy_set_header Connection ""; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_buffering off; + proxy_request_buffering off; + } + + # Vite dev server with HMR + location / { + proxy_pass http://frontend; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection $connection_upgrade; + proxy_set_header Host $host; + proxy_cache_bypass $http_upgrade; + proxy_read_timeout 60s; + proxy_buffering off; + } + + location /health { + access_log off; + return 200 "healthy\n"; + add_header Content-Type text/plain; + } + } +} diff --git a/PROJECTS/encrypted-p2p-chat/conf/nginx/http.conf b/PROJECTS/encrypted-p2p-chat/conf/nginx/http.conf new file mode 100644 index 00000000..a0d9348e --- /dev/null +++ b/PROJECTS/encrypted-p2p-chat/conf/nginx/http.conf @@ -0,0 +1,27 @@ +# ©AngelaMos | 2025 +# Shared HTTP configuration for dev and prod + +# WebSocket upgrade handling - sets Connection to "upgrade" when Upgrade header present, +# otherwise "close" for regular HTTP requests +map $http_upgrade $connection_upgrade { + default upgrade; + '' close; +} + +upstream backend { + server backend:8000 max_fails=3 fail_timeout=30s; + keepalive 32; +} + +upstream frontend { + server frontend:5173; +} + +log_format main_timed '$remote_addr - $remote_user [$time_local] ' + '"$request" $status $body_bytes_sent ' + '"$http_referer" "$http_user_agent" ' + 'rt=$request_time uct="$upstream_connect_time" ' + 'uht="$upstream_header_time" urt="$upstream_response_time"'; + +limit_req_zone $binary_remote_addr zone=api_limit:10m rate=10r/s; +limit_conn_zone $binary_remote_addr zone=conn_limit:10m; diff --git a/PROJECTS/encrypted-p2p-chat/conf/nginx/prod.nginx b/PROJECTS/encrypted-p2p-chat/conf/nginx/prod.nginx new file mode 100644 index 00000000..7c3b8a91 --- /dev/null +++ b/PROJECTS/encrypted-p2p-chat/conf/nginx/prod.nginx @@ -0,0 +1,187 @@ +# ©AngelaMos | 2025 +# Production Nginx Configuration +# - Serves static frontend from /usr/share/nginx/html +# - Proxies /api and /ws to FastAPI backend +# - Caching, compression, security headers, rate limiting + +user nginx; +worker_processes auto; +worker_rlimit_nofile 100000; +error_log /var/log/nginx/error.log warn; +pid /var/run/nginx.pid; + +events { + worker_connections 4096; + use epoll; + multi_accept on; +} + +http { + include /etc/nginx/mime.types; + default_type application/octet-stream; + + # WebSocket upgrade handling + map $http_upgrade $connection_upgrade { + default upgrade; + '' close; + } + + upstream backend { + server backend:8000 max_fails=3 fail_timeout=30s; + keepalive 32; + } + + # Rate limit zones + limit_req_zone $binary_remote_addr zone=api_limit:10m rate=10r/s; + limit_conn_zone $binary_remote_addr zone=conn_limit:10m; + + # Logging with timing and buffering + log_format main_timed '$remote_addr - $remote_user [$time_local] ' + '"$request" $status $body_bytes_sent ' + '"$http_referer" "$http_user_agent" ' + 'rt=$request_time urt="$upstream_response_time"'; + + access_log /var/log/nginx/access.log main_timed buffer=32k flush=5s; + + # Performance + sendfile on; + tcp_nopush on; + tcp_nodelay on; + keepalive_timeout 65; + keepalive_requests 100; + types_hash_max_size 2048; + server_tokens off; + + # File cache + open_file_cache max=10000 inactive=20s; + open_file_cache_valid 30s; + open_file_cache_min_uses 2; + open_file_cache_errors on; + + # Buffer sizes + client_body_buffer_size 128k; + client_header_buffer_size 16k; + client_max_body_size 10m; + large_client_header_buffers 4 16k; + + # Timeouts + client_body_timeout 12s; + client_header_timeout 12s; + send_timeout 10s; + + # Gzip compression + gzip on; + gzip_vary on; + gzip_proxied any; + gzip_comp_level 6; + gzip_min_length 256; + gzip_types + text/plain + text/css + text/xml + text/javascript + application/json + application/javascript + application/xml+rss + application/atom+xml + font/truetype + font/opentype + application/vnd.ms-fontobject + image/svg+xml; + + server { + listen 80; + listen [::]:80; + server_name _; + + root /usr/share/nginx/html; + index index.html; + + # Security headers + add_header X-Frame-Options "SAMEORIGIN" always; + add_header X-Content-Type-Options "nosniff" always; + add_header X-XSS-Protection "1; mode=block" always; + add_header Referrer-Policy "strict-origin-when-cross-origin" always; + add_header Permissions-Policy "geolocation=(), microphone=(), camera=()" always; + + # WebSocket to backend + location /ws { + proxy_pass http://backend; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection $connection_upgrade; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_read_timeout 3600s; + proxy_send_timeout 3600s; + proxy_connect_timeout 75s; + proxy_buffering off; + } + + # API routes with rate limiting + location /api { + limit_req zone=api_limit burst=20 nodelay; + limit_conn conn_limit 20; + + proxy_pass http://backend; + proxy_http_version 1.1; + proxy_set_header Connection ""; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + + proxy_buffering on; + proxy_buffers 8 24k; + proxy_buffer_size 2k; + + proxy_connect_timeout 75s; + proxy_send_timeout 30s; + proxy_read_timeout 30s; + } + + # Static assets - aggressive caching + location ~* \.(jpg|jpeg|png|gif|ico|svg|webp|avif)$ { + expires 1y; + add_header Cache-Control "public, immutable"; + access_log off; + } + + location ~* \.(css|js)$ { + expires 1y; + add_header Cache-Control "public, immutable"; + access_log off; + } + + location ~* \.(woff|woff2|ttf|eot|otf)$ { + expires 1y; + add_header Cache-Control "public, immutable"; + access_log off; + } + + # index.html - never cache + location = /index.html { + add_header Cache-Control "no-cache, no-store, must-revalidate"; + } + + # SPA fallback + location / { + try_files $uri $uri/ /index.html; + } + + location /health { + access_log off; + return 200 "healthy\n"; + add_header Content-Type text/plain; + } + + # Deny hidden files + location ~ /\. { + deny all; + access_log off; + log_not_found off; + } + } +} diff --git a/PROJECTS/encrypted-p2p-chat/docker-compose.dev.yml b/PROJECTS/encrypted-p2p-chat/docker-compose.dev.yml new file mode 100644 index 00000000..a4cfc7db --- /dev/null +++ b/PROJECTS/encrypted-p2p-chat/docker-compose.dev.yml @@ -0,0 +1,147 @@ +# ©AngelaMos | 2025 +# Development Docker Compose + +services: + postgres: + image: postgres:16-alpine + container_name: chat-postgres-dev + environment: + POSTGRES_DB: ${POSTGRES_DB:-chat_auth} + POSTGRES_USER: ${POSTGRES_USER:-chat_user} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?POSTGRES_PASSWORD required} + POSTGRES_INITDB_ARGS: "-E UTF8 --locale=C" + volumes: + - postgres_dev_data:/var/lib/postgresql/data + ports: + - "${POSTGRES_HOST_PORT:-5432}:5432" + networks: + - chat_network_dev + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-chat_user} -d ${POSTGRES_DB:-chat_auth}"] + interval: 10s + timeout: 5s + retries: 5 + + surrealdb: + image: surrealdb/surrealdb:latest + container_name: chat-surrealdb-dev + user: "0:0" + command: start --log trace --user root --pass ${SURREAL_PASSWORD:?SURREAL_PASSWORD required} file:/data/database.db + environment: + - SURREAL_USER=root + - SURREAL_PASS=${SURREAL_PASSWORD} + volumes: + - surreal_dev_data:/data + ports: + - "${SURREAL_HOST_PORT:-8001}:8000" + networks: + - chat_network_dev + healthcheck: + test: ["CMD", "/surreal", "isready", "--endpoint", "http://localhost:8000"] + interval: 10s + timeout: 5s + retries: 5 + + redis: + image: redis:8-alpine + container_name: chat-redis-dev + command: > + redis-server + --appendonly yes + --maxmemory 2gb + --maxmemory-policy allkeys-lru + --save 60 1000 + --loglevel warning + volumes: + - redis_dev_data:/data + ports: + - "${REDIS_HOST_PORT:-6379}:6379" + networks: + - chat_network_dev + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 10s + timeout: 5s + retries: 5 + + backend: + build: + context: . + dockerfile: conf/docker/dev/fastapi.docker + container_name: chat-backend-dev + environment: + ENV: ${ENV:-development} + DEBUG: ${DEBUG:-true} + SECRET_KEY: ${SECRET_KEY:?SECRET_KEY required} + DATABASE_URL: postgresql+asyncpg://${POSTGRES_USER:-chat_user}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB:-chat_auth} + SURREAL_URL: ${SURREAL_URL:-ws://surrealdb:8000} + SURREAL_PASSWORD: ${SURREAL_PASSWORD} + REDIS_URL: ${REDIS_URL:-redis://redis:6379} + RP_ID: ${RP_ID:-localhost} + RP_NAME: ${RP_NAME:-Encrypted P2P Chat} + RP_ORIGIN: ${RP_ORIGIN:-http://localhost:82} + CORS_ORIGINS: ${CORS_ORIGINS} + volumes: + - ./backend:/app + - /app/.venv + - /app/__pycache__ + depends_on: + postgres: + condition: service_healthy + surrealdb: + condition: service_healthy + redis: + condition: service_healthy + networks: + - chat_network_dev + ports: + - "${BACKEND_HOST_PORT:-8000}:8000" + restart: unless-stopped + + frontend: + build: + context: . + dockerfile: conf/docker/dev/vite.docker + container_name: chat-frontend-dev + environment: + - VITE_API_URL=${VITE_API_URL:-http://localhost:8000} + - VITE_WS_URL=${VITE_WS_URL:-ws://localhost:8000} + - VITE_RP_ID=${VITE_RP_ID:-localhost} + volumes: + - ./frontend:/app + - /app/node_modules + depends_on: + - backend + networks: + - chat_network_dev + ports: + - "${FRONTEND_DEV_PORT:-5173}:5173" + restart: unless-stopped + + nginx: + image: nginx:alpine + container_name: chat-nginx-dev + ports: + - "${NGINX_HTTP_PORT:-80}:80" + volumes: + - ./conf/nginx/dev.nginx:/etc/nginx/nginx.conf:ro + - ./conf/nginx/http.conf:/etc/nginx/http.conf:ro + depends_on: + - backend + - frontend + networks: + - chat_network_dev + restart: unless-stopped + +networks: + chat_network_dev: + driver: bridge + name: chat_network_dev + +volumes: + postgres_dev_data: + name: chat_postgres_dev_data + surreal_dev_data: + name: chat_surreal_dev_data + redis_dev_data: + name: chat_redis_dev_data diff --git a/PROJECTS/encrypted-p2p-chat/docker-compose.prod.yml b/PROJECTS/encrypted-p2p-chat/docker-compose.prod.yml new file mode 100644 index 00000000..e6440e42 --- /dev/null +++ b/PROJECTS/encrypted-p2p-chat/docker-compose.prod.yml @@ -0,0 +1,117 @@ +# ©AngelaMos | 2025 +# Production Docker Compose + +services: + postgres: + image: postgres:16-alpine + container_name: chat-postgres + environment: + POSTGRES_DB: ${POSTGRES_DB:-chat_auth} + POSTGRES_USER: ${POSTGRES_USER:-chat_user} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?POSTGRES_PASSWORD required} + POSTGRES_INITDB_ARGS: "-E UTF8 --locale=C" + volumes: + - postgres_data:/var/lib/postgresql/data + networks: + - chat_network + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-chat_user}"] + interval: 10s + timeout: 5s + retries: 5 + restart: always + + surrealdb: + image: surrealdb/surrealdb:latest + container_name: chat-surrealdb + command: start --log trace --user root --pass ${SURREAL_PASSWORD:?SURREAL_PASSWORD required} file:/data/database.db + environment: + - SURREAL_USER=root + - SURREAL_PASS=${SURREAL_PASSWORD} + volumes: + - surreal_data:/data + networks: + - chat_network + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:8000/health"] + interval: 10s + timeout: 5s + retries: 5 + restart: always + + redis: + image: redis:8-alpine + container_name: chat-redis + command: > + redis-server + --appendonly yes + --maxmemory 2gb + --maxmemory-policy allkeys-lru + --save 60 1000 + --loglevel warning + volumes: + - redis_data:/data + networks: + - chat_network + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 10s + timeout: 5s + retries: 5 + restart: always + + backend: + build: + context: . + dockerfile: conf/docker/prod/fastapi.docker + container_name: chat-backend + environment: + ENV: ${ENV:-production} + DEBUG: ${DEBUG:-false} + SECRET_KEY: ${SECRET_KEY:?SECRET_KEY required} + DATABASE_URL: postgresql+asyncpg://${POSTGRES_USER:-chat_user}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB:-chat_auth} + SURREAL_URL: ws://surrealdb:8000 + SURREAL_PASSWORD: ${SURREAL_PASSWORD} + REDIS_URL: redis://redis:6379 + RP_ID: ${RP_ID:-localhost} + RP_NAME: ${RP_NAME:-Encrypted P2P Chat} + RP_ORIGIN: ${RP_ORIGIN:-https://localhost} + expose: + - "8000" + depends_on: + postgres: + condition: service_healthy + surrealdb: + condition: service_healthy + redis: + condition: service_healthy + networks: + - chat_network + restart: always + + frontend: + build: + context: . + dockerfile: conf/docker/prod/vite.docker + container_name: chat-nginx + ports: + - "${NGINX_HTTP_PORT:-80}:80" + - "${NGINX_HTTPS_PORT:-443}:443" + depends_on: + - backend + networks: + - chat_network + restart: always + +networks: + chat_network: + driver: bridge + name: chat_network + +volumes: + postgres_data: + name: chat_postgres_data + surreal_data: + name: chat_surreal_data + redis_data: + name: chat_redis_data diff --git a/PROJECTS/encrypted-p2p-chat/frontend/.gitignore b/PROJECTS/encrypted-p2p-chat/frontend/.gitignore new file mode 100644 index 00000000..c78808df --- /dev/null +++ b/PROJECTS/encrypted-p2p-chat/frontend/.gitignore @@ -0,0 +1,8 @@ +node_modules +dist +dist-ssr +*.local +.env +.DS_Store +.context + diff --git a/PROJECTS/encrypted-p2p-chat/frontend/.prettierignore b/PROJECTS/encrypted-p2p-chat/frontend/.prettierignore new file mode 100644 index 00000000..fedf8027 --- /dev/null +++ b/PROJECTS/encrypted-p2p-chat/frontend/.prettierignore @@ -0,0 +1,24 @@ +# Dependencies +node_modules/ + +# Build outputs +dist/ +build/ + +# Logs +*.log +npm-debug.log* + +# Runtime data +pids +*.pid +*.seed + +# Coverage directory used by tools like istanbul +coverage/ + +# Other +.DS_Store +.env* +.vscode/ +.idea/ diff --git a/PROJECTS/encrypted-p2p-chat/frontend/.prettierrc b/PROJECTS/encrypted-p2p-chat/frontend/.prettierrc new file mode 100644 index 00000000..88997a1b --- /dev/null +++ b/PROJECTS/encrypted-p2p-chat/frontend/.prettierrc @@ -0,0 +1,9 @@ +{ + "semi": true, + "trailingComma": "es5", + "singleQuote": false, + "printWidth": 95, + "tabWidth": 2, + "useTabs": false, + "plugins": ["prettier-plugin-tailwindcss"] +} diff --git a/PROJECTS/encrypted-p2p-chat/frontend/README.md b/PROJECTS/encrypted-p2p-chat/frontend/README.md new file mode 100644 index 00000000..7664abdd --- /dev/null +++ b/PROJECTS/encrypted-p2p-chat/frontend/README.md @@ -0,0 +1,50 @@ +# Encrypted P2P Chat - Frontend + +SolidJS frontend with Vite, TypeScript, and Tailwind CSS v4. + +## Requirements + +- **Node.js 20.19+ or 22.12+** (required for Vite 7) + +## Tech Stack + +- **SolidJS 1.9.10** - Fine-grained reactivity +- **TypeScript 5.9** - Type safety +- **Vite 7** - Build tool (ESM only, requires Node 20.19+) +- **Tailwind CSS v4.1** - Utility-first CSS (stable release) +- **@solidjs/router** - Client-side routing +- **@tanstack/solid-query 5.90** - Data fetching and caching +- **nanostores 1.1** - State management + +## Development + +```bash +npm install +npm run dev +``` + +Frontend runs on http://localhost:3000 + +## Build + +```bash +npm run build +``` + +Output in `dist/` directory. + +## SolidJS Routing + +Unlike React Router, SolidJS uses: +- `` component for navigation (not ``) +- `useNavigate()` for programmatic navigation +- `useParams()` for route parameters +- `useSearchParams()` for query parameters + +## Environment Variables + +Copy `.env.example` to `.env` and configure: + +- `VITE_API_URL` - Backend API URL +- `VITE_WS_URL` - WebSocket URL +- `VITE_RP_ID` - WebAuthn Relying Party ID diff --git a/PROJECTS/encrypted-p2p-chat/frontend/eslint.config.js b/PROJECTS/encrypted-p2p-chat/frontend/eslint.config.js new file mode 100644 index 00000000..19813f35 --- /dev/null +++ b/PROJECTS/encrypted-p2p-chat/frontend/eslint.config.js @@ -0,0 +1,156 @@ +// =================== +// © AngelaMos | 2025 +// eslint.config.js +// =================== +import js from "@eslint/js"; +import tseslint from "typescript-eslint"; +import solid from "eslint-plugin-solid/configs/typescript"; +import jsxA11y from "eslint-plugin-jsx-a11y"; +import prettierConfig from "eslint-config-prettier"; +import globals from "globals"; + +export default tseslint.config( + { + ignores: [ + "dist", + "node_modules", + "*.config.js", + "*.config.ts", + "*.min.js", + ], + }, + + js.configs.recommended, + + ...tseslint.configs.strictTypeChecked, + ...tseslint.configs.stylisticTypeChecked, + + { + files: ["**/*.{ts,tsx}"], + ...solid, + plugins: { + ...solid.plugins, + "jsx-a11y": jsxA11y, + }, + languageOptions: { + parser: tseslint.parser, + parserOptions: { + project: ["./tsconfig.json"], + tsconfigRootDir: import.meta.dirname, + ecmaFeatures: { jsx: true }, + }, + globals: { + ...globals.browser, + ...globals.node, + }, + }, + rules: { + ...solid.rules, + + "@typescript-eslint/no-unused-vars": [ + "error", + { + argsIgnorePattern: "^_", + varsIgnorePattern: "^_", + caughtErrorsIgnorePattern: "^_", + }, + ], + "@typescript-eslint/consistent-type-imports": [ + "error", + { + prefer: "type-imports", + fixStyle: "inline-type-imports", + }, + ], + "@typescript-eslint/explicit-function-return-type": [ + "error", + { + allowExpressions: true, + allowTypedFunctionExpressions: true, + allowHigherOrderFunctions: true, + allowDirectConstAssertionInArrowFunctions: true, + }, + ], + "@typescript-eslint/naming-convention": "off", + "@typescript-eslint/no-non-null-assertion": "error", + "@typescript-eslint/array-type": ["error", { default: "array" }], + "@typescript-eslint/no-explicit-any": "error", + "@typescript-eslint/no-confusing-void-expression": "off", + "@typescript-eslint/no-unnecessary-condition": "off", + "@typescript-eslint/no-floating-promises": "error", + "@typescript-eslint/strict-boolean-expressions": [ + "error", + { + allowString: false, + allowNumber: false, + allowNullableObject: false, + allowNullableString: true, + allowAny: true, + }, + ], + "@typescript-eslint/prefer-as-const": "error", + "@typescript-eslint/consistent-type-definitions": ["error", "interface"], + "@typescript-eslint/restrict-template-expressions": "off", + "@typescript-eslint/no-misused-promises": [ + "error", + { + checksVoidReturn: { + attributes: false, + }, + }, + ], + + "solid/reactivity": "warn", + "solid/no-destructure": "warn", + "solid/jsx-no-undef": "error", + "solid/no-react-specific-props": "error", + "solid/prefer-for": "warn", + "solid/self-closing-comp": "error", + "solid/style-prop": "error", + "solid/no-innerhtml": "error", + "solid/no-unknown-namespaces": "error", + "solid/event-handlers": [ + "error", + { + ignoreCase: false, + warnOnSpread: true, + }, + ], + "solid/imports": "error", + "solid/no-proxy-apis": "off", + + "jsx-a11y/alt-text": "error", + "jsx-a11y/anchor-has-content": "error", + "jsx-a11y/click-events-have-key-events": "error", + "jsx-a11y/no-static-element-interactions": "error", + "jsx-a11y/no-noninteractive-element-interactions": "warn", + "jsx-a11y/aria-props": "error", + "jsx-a11y/aria-role": "error", + "jsx-a11y/role-has-required-aria-props": "error", + + "no-console": ["warn", { allow: ["warn", "error"] }], + "no-debugger": "error", + "no-alert": "error", + "no-var": "error", + "prefer-const": "error", + "prefer-template": "error", + "object-shorthand": "error", + "no-nested-ternary": "error", + "max-depth": ["error", 6], + "max-lines": [ + "error", + { max: 2000, skipBlankLines: true, skipComments: true }, + ], + complexity: ["error", 55], + }, + }, + + { + files: ["src/index.tsx"], + rules: { + "@typescript-eslint/no-non-null-assertion": "off", + }, + }, + + prettierConfig +); diff --git a/PROJECTS/encrypted-p2p-chat/frontend/index.html b/PROJECTS/encrypted-p2p-chat/frontend/index.html new file mode 100644 index 00000000..11db284f --- /dev/null +++ b/PROJECTS/encrypted-p2p-chat/frontend/index.html @@ -0,0 +1,19 @@ + + + + + + + Encrypted P2P Chat + + + + + +
+ + + diff --git a/PROJECTS/encrypted-p2p-chat/frontend/package-lock.json b/PROJECTS/encrypted-p2p-chat/frontend/package-lock.json new file mode 100644 index 00000000..1341cffb --- /dev/null +++ b/PROJECTS/encrypted-p2p-chat/frontend/package-lock.json @@ -0,0 +1,5533 @@ +{ + "name": "encrypted-p2p-chat-frontend", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "encrypted-p2p-chat-frontend", + "version": "1.0.0", + "dependencies": { + "@nanostores/persistent": "^1.2.0", + "@nanostores/solid": "^1.1.1", + "@solidjs/router": "^0.15.3", + "@tanstack/solid-query": "^5.90.13", + "nanostores": "^1.1.0", + "solid-js": "^1.9.10" + }, + "devDependencies": { + "@eslint/js": "^9.17.0", + "@tailwindcss/vite": "^4.1.17", + "@types/node": "^22.10.2", + "eslint": "^9.17.0", + "eslint-config-prettier": "^10.1.5", + "eslint-plugin-jsx-a11y": "^6.10.2", + "eslint-plugin-solid": "^0.14.5", + "globals": "^16.2.0", + "prettier": "^3.4.2", + "prettier-plugin-tailwindcss": "^0.6.9", + "tailwindcss": "^4.1.17", + "typescript": "^5.9.3", + "typescript-eslint": "^8.20.0", + "vite": "^7.2.4", + "vite-plugin-solid": "^2.11.10" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz", + "integrity": "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==", + "dev": true, + "dependencies": { + "@babel/helper-validator-identifier": "^7.27.1", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.28.5.tgz", + "integrity": "sha512-6uFXyCayocRbqhZOB+6XcuZbkMNimwfVGFji8CTZnCzOHVGvDqzvitu1re2AU5LROliz7eQPhB8CpAMvnx9EjA==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.5.tgz", + "integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==", + "dev": true, + "dependencies": { + "@babel/code-frame": "^7.27.1", + "@babel/generator": "^7.28.5", + "@babel/helper-compilation-targets": "^7.27.2", + "@babel/helper-module-transforms": "^7.28.3", + "@babel/helpers": "^7.28.4", + "@babel/parser": "^7.28.5", + "@babel/template": "^7.27.2", + "@babel/traverse": "^7.28.5", + "@babel/types": "^7.28.5", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/core/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/@babel/generator": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.28.5.tgz", + "integrity": "sha512-3EwLFhZ38J4VyIP6WNtt2kUdW9dokXA9Cr4IVIFHuCpZ3H8/YFOl5JjZHisrn1fATPBmKKqXzDFvh9fUwHz6CQ==", + "dev": true, + "dependencies": { + "@babel/parser": "^7.28.5", + "@babel/types": "^7.28.5", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.27.2", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.27.2.tgz", + "integrity": "sha512-2+1thGUUWWjLTYTHZWK1n8Yga0ijBz1XAhUXcKy81rd5g6yh7hGqMp45v7cadSbEHc9G3OTv45SyneRN3ps4DQ==", + "dev": true, + "dependencies": { + "@babel/compat-data": "^7.27.2", + "@babel/helper-validator-option": "^7.27.1", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", + "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.27.1.tgz", + "integrity": "sha512-0gSFWUPNXNopqtIPQvlD5WgXYI5GY2kP2cCvoT8kczjbfcfuIljTbcWrulD1CIPIX2gt1wghbDy08yE1p+/r3w==", + "dev": true, + "dependencies": { + "@babel/traverse": "^7.27.1", + "@babel/types": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.28.3", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.3.tgz", + "integrity": "sha512-gytXUbs8k2sXS9PnQptz5o0QnpLL51SwASIORY6XaBKF88nsOT0Zw9szLqlSGQDP/4TljBAD5y98p2U1fqkdsw==", + "dev": true, + "dependencies": { + "@babel/helper-module-imports": "^7.27.1", + "@babel/helper-validator-identifier": "^7.27.1", + "@babel/traverse": "^7.28.3" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.27.1.tgz", + "integrity": "sha512-1gn1Up5YXka3YYAHGKpbideQ5Yjf1tDa9qYcgysz+cNCXukyLl6DjPXhD3VRwSb8c0J9tA4b2+rHEZtc6R0tlw==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", + "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.28.4", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.4.tgz", + "integrity": "sha512-HFN59MmQXGHVyYadKLVumYsA9dBFun/ldYxipEjzA4196jpLZd8UjEEBLkbEkvfYreDqJhZxYAWFPtrfhNpj4w==", + "dev": true, + "dependencies": { + "@babel/template": "^7.27.2", + "@babel/types": "^7.28.4" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.5.tgz", + "integrity": "sha512-KKBU1VGYR7ORr3At5HAtUQ+TV3SzRCXmA/8OdDZiLDBIZxVyzXuztPjfLd3BV1PRAQGCMWWSHYhL0F8d5uHBDQ==", + "dev": true, + "dependencies": { + "@babel/types": "^7.28.5" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-syntax-jsx": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.27.1.tgz", + "integrity": "sha512-y8YTNIeKoyhGd9O0Jiyzyyqk8gdjnumGTQPsz0xOZOQ2RmkVJeZ1vmmfIvFEKqucBG6axJGBZDE/7iI5suUI/w==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/template": { + "version": "7.27.2", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.27.2.tgz", + "integrity": "sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw==", + "dev": true, + "dependencies": { + "@babel/code-frame": "^7.27.1", + "@babel/parser": "^7.27.2", + "@babel/types": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.28.5.tgz", + "integrity": "sha512-TCCj4t55U90khlYkVV/0TfkJkAkUg3jZFA3Neb7unZT8CPok7iiRfaX0F+WnqWqt7OxhOn0uBKXCw4lbL8W0aQ==", + "dev": true, + "dependencies": { + "@babel/code-frame": "^7.27.1", + "@babel/generator": "^7.28.5", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.28.5", + "@babel/template": "^7.27.2", + "@babel/types": "^7.28.5", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.5.tgz", + "integrity": "sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA==", + "dev": true, + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "cpu": [ + "loong64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "cpu": [ + "s390x" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.9.0", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.0.tgz", + "integrity": "sha512-ayVFHdtZ+hsq1t2Dy24wCmGXGe4q9Gu3smhLYALJrr473ZH27MsnSL+LKUlimp4BWJqMDMLmPpx/Q9R3OAlL4g==", + "dev": true, + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.21.1", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.1.tgz", + "integrity": "sha512-aw1gNayWpdI/jSYVgzN5pL0cfzU02GT3NBpeT/DXbx1/1x7ZKxFPd9bwrzygx/qiwIQiJ1sw/zD8qY/kRvlGHA==", + "dev": true, + "dependencies": { + "@eslint/object-schema": "^2.1.7", + "debug": "^4.3.1", + "minimatch": "^3.1.2" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.2.tgz", + "integrity": "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==", + "dev": true, + "dependencies": { + "@eslint/core": "^0.17.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/core": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.17.0.tgz", + "integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==", + "dev": true, + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/eslintrc": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.1.tgz", + "integrity": "sha512-gtF186CXhIl1p4pJNGZw8Yc6RlshoePRvE0X91oPGb3vZ8pM3qOS9W9NGPat9LziaBV7XrJWGylNQXkGcnM3IQ==", + "dev": true, + "dependencies": { + "ajv": "^6.12.4", + "debug": "^4.3.2", + "espree": "^10.0.1", + "globals": "^14.0.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.1.0", + "minimatch": "^3.1.2", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint/eslintrc/node_modules/globals": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", + "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", + "dev": true, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@eslint/js": { + "version": "9.39.1", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.1.tgz", + "integrity": "sha512-S26Stp4zCy88tH94QbBv3XCuzRQiZ9yXofEILmglYTh/Ug/a9/umqvgFtYBAo3Lp0nsI/5/qH1CCrbdK3AP1Tw==", + "dev": true, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + } + }, + "node_modules/@eslint/object-schema": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.7.tgz", + "integrity": "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==", + "dev": true, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.1.tgz", + "integrity": "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==", + "dev": true, + "dependencies": { + "@eslint/core": "^0.17.0", + "levn": "^0.4.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@humanfs/core": { + "version": "0.19.1", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", + "integrity": "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==", + "dev": true, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.7", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.7.tgz", + "integrity": "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==", + "dev": true, + "dependencies": { + "@humanfs/core": "^0.19.1", + "@humanwhocodes/retry": "^0.4.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true, + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "engines": { + "node": ">=6.0.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 + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@nanostores/persistent": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@nanostores/persistent/-/persistent-1.2.0.tgz", + "integrity": "sha512-kf5WOLpVI9Pk+AwXHIax4in3pesNe8299BEGQ2H8kgI05SZw7KKWCDv7bt2FOlND8E5y7rO5PRW34q0UCAl/DA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "engines": { + "node": "^20.0.0 || >=22.0.0" + }, + "peerDependencies": { + "nanostores": "^0.9.0 || ^0.10.0 || ^0.11.0 || ^1.0.0" + } + }, + "node_modules/@nanostores/solid": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@nanostores/solid/-/solid-1.1.1.tgz", + "integrity": "sha512-gF0Eat1/c3hOaklBMSVoEjcNzJc5zGk4VSg94H9LPmXYww1pxgr7zylpi3jjyxRu24c8+aYNkmRWzolAwzSA1A==", + "peerDependencies": { + "nanostores": ">=0.9.0 <2.0.0", + "solid-js": "^1.6.0" + } + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.53.3.tgz", + "integrity": "sha512-mRSi+4cBjrRLoaal2PnqH82Wqyb+d3HsPUN/W+WslCXsZsyHa9ZeQQX/pQsZaVIWDkPcpV6jJ+3KLbTbgnwv8w==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.53.3.tgz", + "integrity": "sha512-CbDGaMpdE9sh7sCmTrTUyllhrg65t6SwhjlMJsLr+J8YjFuPmCEjbBSx4Z/e4SmDyH3aB5hGaJUP2ltV/vcs4w==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.53.3.tgz", + "integrity": "sha512-Nr7SlQeqIBpOV6BHHGZgYBuSdanCXuw09hon14MGOLGmXAFYjx1wNvquVPmpZnl0tLjg25dEdr4IQ6GgyToCUA==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.53.3.tgz", + "integrity": "sha512-DZ8N4CSNfl965CmPktJ8oBnfYr3F8dTTNBQkRlffnUarJ2ohudQD17sZBa097J8xhQ26AwhHJ5mvUyQW8ddTsQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.53.3.tgz", + "integrity": "sha512-yMTrCrK92aGyi7GuDNtGn2sNW+Gdb4vErx4t3Gv/Tr+1zRb8ax4z8GWVRfr3Jw8zJWvpGHNpss3vVlbF58DZ4w==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.53.3.tgz", + "integrity": "sha512-lMfF8X7QhdQzseM6XaX0vbno2m3hlyZFhwcndRMw8fbAGUGL3WFMBdK0hbUBIUYcEcMhVLr1SIamDeuLBnXS+Q==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.53.3.tgz", + "integrity": "sha512-k9oD15soC/Ln6d2Wv/JOFPzZXIAIFLp6B+i14KhxAfnq76ajt0EhYc5YPeX6W1xJkAdItcVT+JhKl1QZh44/qw==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.53.3.tgz", + "integrity": "sha512-vTNlKq+N6CK/8UktsrFuc+/7NlEYVxgaEgRXVUVK258Z5ymho29skzW1sutgYjqNnquGwVUObAaxae8rZ6YMhg==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.53.3.tgz", + "integrity": "sha512-RGrFLWgMhSxRs/EWJMIFM1O5Mzuz3Xy3/mnxJp/5cVhZ2XoCAxJnmNsEyeMJtpK+wu0FJFWz+QF4mjCA7AUQ3w==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.53.3.tgz", + "integrity": "sha512-kASyvfBEWYPEwe0Qv4nfu6pNkITLTb32p4yTgzFCocHnJLAHs+9LjUu9ONIhvfT/5lv4YS5muBHyuV84epBo/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.53.3.tgz", + "integrity": "sha512-JiuKcp2teLJwQ7vkJ95EwESWkNRFJD7TQgYmCnrPtlu50b4XvT5MOmurWNrCj3IFdyjBQ5p9vnrX4JM6I8OE7g==", + "cpu": [ + "loong64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.53.3.tgz", + "integrity": "sha512-EoGSa8nd6d3T7zLuqdojxC20oBfNT8nexBbB/rkxgKj5T5vhpAQKKnD+h3UkoMuTyXkP5jTjK/ccNRmQrPNDuw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.53.3.tgz", + "integrity": "sha512-4s+Wped2IHXHPnAEbIB0YWBv7SDohqxobiiPA1FIWZpX+w9o2i4LezzH/NkFUl8LRci/8udci6cLq+jJQlh+0g==", + "cpu": [ + "riscv64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.53.3.tgz", + "integrity": "sha512-68k2g7+0vs2u9CxDt5ktXTngsxOQkSEV/xBbwlqYcUrAVh6P9EgMZvFsnHy4SEiUl46Xf0IObWVbMvPrr2gw8A==", + "cpu": [ + "riscv64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.53.3.tgz", + "integrity": "sha512-VYsFMpULAz87ZW6BVYw3I6sWesGpsP9OPcyKe8ofdg9LHxSbRMd7zrVrr5xi/3kMZtpWL/wC+UIJWJYVX5uTKg==", + "cpu": [ + "s390x" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.53.3.tgz", + "integrity": "sha512-3EhFi1FU6YL8HTUJZ51imGJWEX//ajQPfqWLI3BQq4TlvHy4X0MOr5q3D2Zof/ka0d5FNdPwZXm3Yyib/UEd+w==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.53.3.tgz", + "integrity": "sha512-eoROhjcc6HbZCJr+tvVT8X4fW3/5g/WkGvvmwz/88sDtSJzO7r/blvoBDgISDiCjDRZmHpwud7h+6Q9JxFwq1Q==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.53.3.tgz", + "integrity": "sha512-OueLAWgrNSPGAdUdIjSWXw+u/02BRTcnfw9PN41D2vq/JSEPnJnVuBgw18VkN8wcd4fjUs+jFHVM4t9+kBSNLw==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.53.3.tgz", + "integrity": "sha512-GOFuKpsxR/whszbF/bzydebLiXIHSgsEUp6M0JI8dWvi+fFa1TD6YQa4aSZHtpmh2/uAlj/Dy+nmby3TJ3pkTw==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.53.3.tgz", + "integrity": "sha512-iah+THLcBJdpfZ1TstDFbKNznlzoxa8fmnFYK4V67HvmuNYkVdAywJSoteUszvBQ9/HqN2+9AZghbajMsFT+oA==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.53.3.tgz", + "integrity": "sha512-J9QDiOIZlZLdcot5NXEepDkstocktoVjkaKUtqzgzpt2yWjGlbYiKyp05rWwk4nypbYUNoFAztEgixoLaSETkg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.53.3.tgz", + "integrity": "sha512-UhTd8u31dXadv0MopwGgNOBpUVROFKWVQgAg5N1ESyCz8AuBcMqm4AuTjrwgQKGDfoFuz02EuMRHQIw/frmYKQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@solidjs/router": { + "version": "0.15.4", + "resolved": "https://registry.npmjs.org/@solidjs/router/-/router-0.15.4.tgz", + "integrity": "sha512-WOpgg9a9T638cR+5FGbFi/IV4l2FpmBs1GpIMSPa0Ce9vyJN7Wts+X2PqMf9IYn0zUj2MlSJtm1gp7/HI/n5TQ==", + "peerDependencies": { + "solid-js": "^1.8.6" + } + }, + "node_modules/@tailwindcss/node": { + "version": "4.1.17", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.1.17.tgz", + "integrity": "sha512-csIkHIgLb3JisEFQ0vxr2Y57GUNYh447C8xzwj89U/8fdW8LhProdxvnVH6U8M2Y73QKiTIH+LWbK3V2BBZsAg==", + "dev": true, + "dependencies": { + "@jridgewell/remapping": "^2.3.4", + "enhanced-resolve": "^5.18.3", + "jiti": "^2.6.1", + "lightningcss": "1.30.2", + "magic-string": "^0.30.21", + "source-map-js": "^1.2.1", + "tailwindcss": "4.1.17" + } + }, + "node_modules/@tailwindcss/oxide": { + "version": "4.1.17", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.1.17.tgz", + "integrity": "sha512-F0F7d01fmkQhsTjXezGBLdrl1KresJTcI3DB8EkScCldyKp3Msz4hub4uyYaVnk88BAS1g5DQjjF6F5qczheLA==", + "dev": true, + "engines": { + "node": ">= 10" + }, + "optionalDependencies": { + "@tailwindcss/oxide-android-arm64": "4.1.17", + "@tailwindcss/oxide-darwin-arm64": "4.1.17", + "@tailwindcss/oxide-darwin-x64": "4.1.17", + "@tailwindcss/oxide-freebsd-x64": "4.1.17", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.1.17", + "@tailwindcss/oxide-linux-arm64-gnu": "4.1.17", + "@tailwindcss/oxide-linux-arm64-musl": "4.1.17", + "@tailwindcss/oxide-linux-x64-gnu": "4.1.17", + "@tailwindcss/oxide-linux-x64-musl": "4.1.17", + "@tailwindcss/oxide-wasm32-wasi": "4.1.17", + "@tailwindcss/oxide-win32-arm64-msvc": "4.1.17", + "@tailwindcss/oxide-win32-x64-msvc": "4.1.17" + } + }, + "node_modules/@tailwindcss/oxide-android-arm64": { + "version": "4.1.17", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.1.17.tgz", + "integrity": "sha512-BMqpkJHgOZ5z78qqiGE6ZIRExyaHyuxjgrJ6eBO5+hfrfGkuya0lYfw8fRHG77gdTjWkNWEEm+qeG2cDMxArLQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-darwin-arm64": { + "version": "4.1.17", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.1.17.tgz", + "integrity": "sha512-EquyumkQweUBNk1zGEU/wfZo2qkp/nQKRZM8bUYO0J+Lums5+wl2CcG1f9BgAjn/u9pJzdYddHWBiFXJTcxmOg==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-darwin-x64": { + "version": "4.1.17", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.1.17.tgz", + "integrity": "sha512-gdhEPLzke2Pog8s12oADwYu0IAw04Y2tlmgVzIN0+046ytcgx8uZmCzEg4VcQh+AHKiS7xaL8kGo/QTiNEGRog==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-freebsd-x64": { + "version": "4.1.17", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.1.17.tgz", + "integrity": "sha512-hxGS81KskMxML9DXsaXT1H0DyA+ZBIbyG/sSAjWNe2EDl7TkPOBI42GBV3u38itzGUOmFfCzk1iAjDXds8Oh0g==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { + "version": "4.1.17", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.1.17.tgz", + "integrity": "sha512-k7jWk5E3ldAdw0cNglhjSgv501u7yrMf8oeZ0cElhxU6Y2o7f8yqelOp3fhf7evjIS6ujTI3U8pKUXV2I4iXHQ==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { + "version": "4.1.17", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.1.17.tgz", + "integrity": "sha512-HVDOm/mxK6+TbARwdW17WrgDYEGzmoYayrCgmLEw7FxTPLcp/glBisuyWkFz/jb7ZfiAXAXUACfyItn+nTgsdQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-musl": { + "version": "4.1.17", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.1.17.tgz", + "integrity": "sha512-HvZLfGr42i5anKtIeQzxdkw/wPqIbpeZqe7vd3V9vI3RQxe3xU1fLjss0TjyhxWcBaipk7NYwSrwTwK1hJARMg==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-gnu": { + "version": "4.1.17", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.1.17.tgz", + "integrity": "sha512-M3XZuORCGB7VPOEDH+nzpJ21XPvK5PyjlkSFkFziNHGLc5d6g3di2McAAblmaSUNl8IOmzYwLx9NsE7bplNkwQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-musl": { + "version": "4.1.17", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.1.17.tgz", + "integrity": "sha512-k7f+pf9eXLEey4pBlw+8dgfJHY4PZ5qOUFDyNf7SI6lHjQ9Zt7+NcscjpwdCEbYi6FI5c2KDTDWyf2iHcCSyyQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi": { + "version": "4.1.17", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.1.17.tgz", + "integrity": "sha512-cEytGqSSoy7zK4JRWiTCx43FsKP/zGr0CsuMawhH67ONlH+T79VteQeJQRO/X7L0juEUA8ZyuYikcRBf0vsxhg==", + "bundleDependencies": [ + "@napi-rs/wasm-runtime", + "@emnapi/core", + "@emnapi/runtime", + "@tybys/wasm-util", + "@emnapi/wasi-threads", + "tslib" + ], + "cpu": [ + "wasm32" + ], + "dev": true, + "optional": true, + "dependencies": { + "@emnapi/core": "^1.6.0", + "@emnapi/runtime": "^1.6.0", + "@emnapi/wasi-threads": "^1.1.0", + "@napi-rs/wasm-runtime": "^1.0.7", + "@tybys/wasm-util": "^0.10.1", + "tslib": "^2.4.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { + "version": "4.1.17", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.1.17.tgz", + "integrity": "sha512-JU5AHr7gKbZlOGvMdb4722/0aYbU+tN6lv1kONx0JK2cGsh7g148zVWLM0IKR3NeKLv+L90chBVYcJ8uJWbC9A==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-win32-x64-msvc": { + "version": "4.1.17", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.1.17.tgz", + "integrity": "sha512-SKWM4waLuqx0IH+FMDUw6R66Hu4OuTALFgnleKbqhgGU30DY20NORZMZUKgLRjQXNN2TLzKvh48QXTig4h4bGw==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/vite": { + "version": "4.1.17", + "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.1.17.tgz", + "integrity": "sha512-4+9w8ZHOiGnpcGI6z1TVVfWaX/koK7fKeSYF3qlYg2xpBtbteP2ddBxiarL+HVgfSJGeK5RIxRQmKm4rTJJAwA==", + "dev": true, + "dependencies": { + "@tailwindcss/node": "4.1.17", + "@tailwindcss/oxide": "4.1.17", + "tailwindcss": "4.1.17" + }, + "peerDependencies": { + "vite": "^5.2.0 || ^6 || ^7" + } + }, + "node_modules/@tanstack/query-core": { + "version": "5.90.11", + "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.90.11.tgz", + "integrity": "sha512-f9z/nXhCgWDF4lHqgIE30jxLe4sYv15QodfdPDKYAk7nAEjNcndy4dHz3ezhdUaR23BpWa4I2EH4/DZ0//Uf8A==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/solid-query": { + "version": "5.90.14", + "resolved": "https://registry.npmjs.org/@tanstack/solid-query/-/solid-query-5.90.14.tgz", + "integrity": "sha512-e/TP+92mOFoQtInffcYKvAExgbQoBSDrarKnGwnindQBItp0ne1VdIg88K2U8rNwwM/wdj15V5azpXIkZkNQDw==", + "dependencies": { + "@tanstack/query-core": "5.90.11" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "solid-js": "^1.6.0" + } + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true + }, + "node_modules/@types/node": { + "version": "22.19.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.1.tgz", + "integrity": "sha512-LCCV0HdSZZZb34qifBsyWlUmok6W7ouER+oQIGBScS8EsZsQbrtFTUrDX4hOl+CS6p7cnNC4td+qrSVGSCTUfQ==", + "dev": true, + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.48.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.48.0.tgz", + "integrity": "sha512-XxXP5tL1txl13YFtrECECQYeZjBZad4fyd3cFV4a19LkAY/bIp9fev3US4S5fDVV2JaYFiKAZ/GRTOLer+mbyQ==", + "dev": true, + "dependencies": { + "@eslint-community/regexpp": "^4.10.0", + "@typescript-eslint/scope-manager": "8.48.0", + "@typescript-eslint/type-utils": "8.48.0", + "@typescript-eslint/utils": "8.48.0", + "@typescript-eslint/visitor-keys": "8.48.0", + "graphemer": "^1.4.0", + "ignore": "^7.0.0", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.1.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.48.0", + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", + "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", + "dev": true, + "engines": { + "node": ">= 4" + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "8.48.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.48.0.tgz", + "integrity": "sha512-jCzKdm/QK0Kg4V4IK/oMlRZlY+QOcdjv89U2NgKHZk1CYTj82/RVSx1mV/0gqCVMJ/DA+Zf/S4NBWNF8GQ+eqQ==", + "dev": true, + "dependencies": { + "@typescript-eslint/scope-manager": "8.48.0", + "@typescript-eslint/types": "8.48.0", + "@typescript-eslint/typescript-estree": "8.48.0", + "@typescript-eslint/visitor-keys": "8.48.0", + "debug": "^4.3.4" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/project-service": { + "version": "8.48.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.48.0.tgz", + "integrity": "sha512-Ne4CTZyRh1BecBf84siv42wv5vQvVmgtk8AuiEffKTUo3DrBaGYZueJSxxBZ8fjk/N3DrgChH4TOdIOwOwiqqw==", + "dev": true, + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.48.0", + "@typescript-eslint/types": "^8.48.0", + "debug": "^4.3.4" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.48.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.48.0.tgz", + "integrity": "sha512-uGSSsbrtJrLduti0Q1Q9+BF1/iFKaxGoQwjWOIVNJv0o6omrdyR8ct37m4xIl5Zzpkp69Kkmvom7QFTtue89YQ==", + "dev": true, + "dependencies": { + "@typescript-eslint/types": "8.48.0", + "@typescript-eslint/visitor-keys": "8.48.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.48.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.48.0.tgz", + "integrity": "sha512-WNebjBdFdyu10sR1M4OXTt2OkMd5KWIL+LLfeH9KhgP+jzfDV/LI3eXzwJ1s9+Yc0Kzo2fQCdY/OpdusCMmh6w==", + "dev": true, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "8.48.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.48.0.tgz", + "integrity": "sha512-zbeVaVqeXhhab6QNEKfK96Xyc7UQuoFWERhEnj3mLVnUWrQnv15cJNseUni7f3g557gm0e46LZ6IJ4NJVOgOpw==", + "dev": true, + "dependencies": { + "@typescript-eslint/types": "8.48.0", + "@typescript-eslint/typescript-estree": "8.48.0", + "@typescript-eslint/utils": "8.48.0", + "debug": "^4.3.4", + "ts-api-utils": "^2.1.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/types": { + "version": "8.48.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.48.0.tgz", + "integrity": "sha512-cQMcGQQH7kwKoVswD1xdOytxQR60MWKM1di26xSUtxehaDs/32Zpqsu5WJlXTtTTqyAVK8R7hvsUnIXRS+bjvA==", + "dev": true, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "8.48.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.48.0.tgz", + "integrity": "sha512-ljHab1CSO4rGrQIAyizUS6UGHHCiAYhbfcIZ1zVJr5nMryxlXMVWS3duFPSKvSUbFPwkXMFk1k0EMIjub4sRRQ==", + "dev": true, + "dependencies": { + "@typescript-eslint/project-service": "8.48.0", + "@typescript-eslint/tsconfig-utils": "8.48.0", + "@typescript-eslint/types": "8.48.0", + "@typescript-eslint/visitor-keys": "8.48.0", + "debug": "^4.3.4", + "minimatch": "^9.0.4", + "semver": "^7.6.0", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.1.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "dev": true, + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", + "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "dev": true, + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "8.48.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.48.0.tgz", + "integrity": "sha512-yTJO1XuGxCsSfIVt1+1UrLHtue8xz16V8apzPYI06W0HbEbEWHxHXgZaAgavIkoh+GeV6hKKd5jm0sS6OYxWXQ==", + "dev": true, + "dependencies": { + "@eslint-community/eslint-utils": "^4.7.0", + "@typescript-eslint/scope-manager": "8.48.0", + "@typescript-eslint/types": "8.48.0", + "@typescript-eslint/typescript-estree": "8.48.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.48.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.48.0.tgz", + "integrity": "sha512-T0XJMaRPOH3+LBbAfzR2jalckP1MSG/L9eUtY0DEzUyVaXJ/t6zN0nR7co5kz0Jko/nkSYCBRkz1djvjajVTTg==", + "dev": true, + "dependencies": { + "@typescript-eslint/types": "8.48.0", + "eslint-visitor-keys": "^4.2.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/acorn": { + "version": "8.15.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", + "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", + "dev": true, + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/ajv": { + "version": "6.12.6", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", + "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "dev": true, + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true + }, + "node_modules/aria-query": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.2.tgz", + "integrity": "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==", + "dev": true, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/array-buffer-byte-length": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/array-buffer-byte-length/-/array-buffer-byte-length-1.0.2.tgz", + "integrity": "sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw==", + "dev": true, + "dependencies": { + "call-bound": "^1.0.3", + "is-array-buffer": "^3.0.5" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array-includes": { + "version": "3.1.9", + "resolved": "https://registry.npmjs.org/array-includes/-/array-includes-3.1.9.tgz", + "integrity": "sha512-FmeCCAenzH0KH381SPT5FZmiA/TmpndpcaShhfgEN9eCVjnFBqq3l1xrI42y8+PPLI6hypzou4GXw00WHmPBLQ==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "define-properties": "^1.2.1", + "es-abstract": "^1.24.0", + "es-object-atoms": "^1.1.1", + "get-intrinsic": "^1.3.0", + "is-string": "^1.1.1", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.flat": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/array.prototype.flat/-/array.prototype.flat-1.3.3.tgz", + "integrity": "sha512-rwG/ja1neyLqCuGZ5YYrznA62D4mZXg0i1cIskIUKSiqF3Cje9/wXAls9B9s1Wa2fomMsIv8czB8jZcPmxCXFg==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-shim-unscopables": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.flatmap": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/array.prototype.flatmap/-/array.prototype.flatmap-1.3.3.tgz", + "integrity": "sha512-Y7Wt51eKJSyi80hFrJCePGGNo5ktJCslFuboqJsbf57CCPcm5zztluPlc4/aD8sWsKvlwatezpV4U1efk8kpjg==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-shim-unscopables": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/arraybuffer.prototype.slice": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/arraybuffer.prototype.slice/-/arraybuffer.prototype.slice-1.0.4.tgz", + "integrity": "sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ==", + "dev": true, + "dependencies": { + "array-buffer-byte-length": "^1.0.1", + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "is-array-buffer": "^3.0.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/ast-types-flow": { + "version": "0.0.8", + "resolved": "https://registry.npmjs.org/ast-types-flow/-/ast-types-flow-0.0.8.tgz", + "integrity": "sha512-OH/2E5Fg20h2aPrbe+QL8JZQFko0YZaF+j4mnQ7BGhfavO7OpSLa8a0y9sBwomHdSbkhTS8TQNayBfnW5DwbvQ==", + "dev": true + }, + "node_modules/async-function": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/async-function/-/async-function-1.0.0.tgz", + "integrity": "sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==", + "dev": true, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/available-typed-arrays": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", + "integrity": "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==", + "dev": true, + "dependencies": { + "possible-typed-array-names": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/axe-core": { + "version": "4.11.0", + "resolved": "https://registry.npmjs.org/axe-core/-/axe-core-4.11.0.tgz", + "integrity": "sha512-ilYanEU8vxxBexpJd8cWM4ElSQq4QctCLKih0TSfjIfCQTeyH/6zVrmIJfLPrKTKJRbiG+cfnZbQIjAlJmF1jQ==", + "dev": true, + "engines": { + "node": ">=4" + } + }, + "node_modules/axobject-query": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz", + "integrity": "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==", + "dev": true, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/babel-plugin-jsx-dom-expressions": { + "version": "0.40.3", + "resolved": "https://registry.npmjs.org/babel-plugin-jsx-dom-expressions/-/babel-plugin-jsx-dom-expressions-0.40.3.tgz", + "integrity": "sha512-5HOwwt0BYiv/zxl7j8Pf2bGL6rDXfV6nUhLs8ygBX+EFJXzBPHM/euj9j/6deMZ6wa52Wb2PBaAV5U/jKwIY1w==", + "dev": true, + "dependencies": { + "@babel/helper-module-imports": "7.18.6", + "@babel/plugin-syntax-jsx": "^7.18.6", + "@babel/types": "^7.20.7", + "html-entities": "2.3.3", + "parse5": "^7.1.2" + }, + "peerDependencies": { + "@babel/core": "^7.20.12" + } + }, + "node_modules/babel-plugin-jsx-dom-expressions/node_modules/@babel/helper-module-imports": { + "version": "7.18.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.18.6.tgz", + "integrity": "sha512-0NFvs3VkuSYbFi1x2Vd6tKrywq+z/cLeYC/RJNFrIX/30Bf5aiGYbtvGXolEktzJH8o5E5KJ3tT+nkxuuZFVlA==", + "dev": true, + "dependencies": { + "@babel/types": "^7.18.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/babel-preset-solid": { + "version": "1.9.10", + "resolved": "https://registry.npmjs.org/babel-preset-solid/-/babel-preset-solid-1.9.10.tgz", + "integrity": "sha512-HCelrgua/Y+kqO8RyL04JBWS/cVdrtUv/h45GntgQY+cJl4eBcKkCDV3TdMjtKx1nXwRaR9QXslM/Npm1dxdZQ==", + "dev": true, + "dependencies": { + "babel-plugin-jsx-dom-expressions": "^0.40.3" + }, + "peerDependencies": { + "@babel/core": "^7.0.0", + "solid-js": "^1.9.10" + }, + "peerDependenciesMeta": { + "solid-js": { + "optional": true + } + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true + }, + "node_modules/baseline-browser-mapping": { + "version": "2.8.31", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.8.31.tgz", + "integrity": "sha512-a28v2eWrrRWPpJSzxc+mKwm0ZtVx/G8SepdQZDArnXYU/XS+IF6mp8aB/4E+hH1tyGCoDo3KlUCdlSxGDsRkAw==", + "dev": true, + "bin": { + "baseline-browser-mapping": "dist/cli.js" + } + }, + "node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "dev": true, + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/browserslist": { + "version": "4.28.0", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.0.tgz", + "integrity": "sha512-tbydkR/CxfMwelN0vwdP/pLkDwyAASZ+VfWm4EOwlB6SWhx1sYnWLqo8N5j0rAzPfzfRaxt0mM/4wPU/Su84RQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "dependencies": { + "baseline-browser-mapping": "^2.8.25", + "caniuse-lite": "^1.0.30001754", + "electron-to-chromium": "^1.5.249", + "node-releases": "^2.0.27", + "update-browserslist-db": "^1.1.4" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/call-bind": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.8.tgz", + "integrity": "sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww==", + "dev": true, + "dependencies": { + "call-bind-apply-helpers": "^1.0.0", + "es-define-property": "^1.0.0", + "get-intrinsic": "^1.2.4", + "set-function-length": "^1.2.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "dev": true, + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "dev": true, + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001757", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001757.tgz", + "integrity": "sha512-r0nnL/I28Zi/yjk1el6ilj27tKcdjLsNqAOZr0yVjWPrSQyHgKI2INaEWw21bAQSv2LXRt1XuCS/GomNpWOxsQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ] + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true + }, + "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 + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "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==" + }, + "node_modules/damerau-levenshtein": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz", + "integrity": "sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA==", + "dev": true + }, + "node_modules/data-view-buffer": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.2.tgz", + "integrity": "sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ==", + "dev": true, + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/data-view-byte-length": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/data-view-byte-length/-/data-view-byte-length-1.0.2.tgz", + "integrity": "sha512-tuhGbE6CfTM9+5ANGf+oQb72Ky/0+s3xKUpHvShfiz2RxMFgFPjsXuRLBVMtvMs15awe45SRb83D6wH4ew6wlQ==", + "dev": true, + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/inspect-js" + } + }, + "node_modules/data-view-byte-offset": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/data-view-byte-offset/-/data-view-byte-offset-1.0.1.tgz", + "integrity": "sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ==", + "dev": true, + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true + }, + "node_modules/define-data-property": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", + "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", + "dev": true, + "dependencies": { + "es-define-property": "^1.0.0", + "es-errors": "^1.3.0", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/define-properties": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz", + "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==", + "dev": true, + "dependencies": { + "define-data-property": "^1.0.1", + "has-property-descriptors": "^1.0.0", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "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, + "engines": { + "node": ">=8" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "dev": true, + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.260", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.260.tgz", + "integrity": "sha512-ov8rBoOBhVawpzdre+Cmz4FB+y66Eqrk6Gwqd8NGxuhv99GQ8XqMAr351KEkOt7gukXWDg6gJWEMKgL2RLMPtA==", + "dev": true + }, + "node_modules/emoji-regex": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", + "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", + "dev": true + }, + "node_modules/enhanced-resolve": { + "version": "5.18.3", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.18.3.tgz", + "integrity": "sha512-d4lC8xfavMeBjzGr2vECC3fsGXziXZQyJxD868h2M/mBI3PwAuODxAkLkq5HYuvrPYcUtiLzsTo8U3PgX3Ocww==", + "dev": true, + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.2.0" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "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, + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/es-abstract": { + "version": "1.24.0", + "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.24.0.tgz", + "integrity": "sha512-WSzPgsdLtTcQwm4CROfS5ju2Wa1QQcVeT37jFjYzdFz1r9ahadC8B8/a4qxJxM+09F18iumCdRmlr96ZYkQvEg==", + "dev": true, + "dependencies": { + "array-buffer-byte-length": "^1.0.2", + "arraybuffer.prototype.slice": "^1.0.4", + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "data-view-buffer": "^1.0.2", + "data-view-byte-length": "^1.0.2", + "data-view-byte-offset": "^1.0.1", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "es-set-tostringtag": "^2.1.0", + "es-to-primitive": "^1.3.0", + "function.prototype.name": "^1.1.8", + "get-intrinsic": "^1.3.0", + "get-proto": "^1.0.1", + "get-symbol-description": "^1.1.0", + "globalthis": "^1.0.4", + "gopd": "^1.2.0", + "has-property-descriptors": "^1.0.2", + "has-proto": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "internal-slot": "^1.1.0", + "is-array-buffer": "^3.0.5", + "is-callable": "^1.2.7", + "is-data-view": "^1.0.2", + "is-negative-zero": "^2.0.3", + "is-regex": "^1.2.1", + "is-set": "^2.0.3", + "is-shared-array-buffer": "^1.0.4", + "is-string": "^1.1.1", + "is-typed-array": "^1.1.15", + "is-weakref": "^1.1.1", + "math-intrinsics": "^1.1.0", + "object-inspect": "^1.13.4", + "object-keys": "^1.1.1", + "object.assign": "^4.1.7", + "own-keys": "^1.0.1", + "regexp.prototype.flags": "^1.5.4", + "safe-array-concat": "^1.1.3", + "safe-push-apply": "^1.0.0", + "safe-regex-test": "^1.1.0", + "set-proto": "^1.0.0", + "stop-iteration-iterator": "^1.1.0", + "string.prototype.trim": "^1.2.10", + "string.prototype.trimend": "^1.0.9", + "string.prototype.trimstart": "^1.0.8", + "typed-array-buffer": "^1.0.3", + "typed-array-byte-length": "^1.0.3", + "typed-array-byte-offset": "^1.0.4", + "typed-array-length": "^1.0.7", + "unbox-primitive": "^1.1.0", + "which-typed-array": "^1.1.19" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "dev": true, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "dev": true, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "dev": true, + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "dev": true, + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-shim-unscopables": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/es-shim-unscopables/-/es-shim-unscopables-1.1.0.tgz", + "integrity": "sha512-d9T8ucsEhh8Bi1woXCf+TIKDIROLG5WCkxg8geBCbvk22kzwC5G2OnXVMO6FUsvQlgUUXQ2itephWDLqDzbeCw==", + "dev": true, + "dependencies": { + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-to-primitive": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-to-primitive/-/es-to-primitive-1.3.0.tgz", + "integrity": "sha512-w+5mJ3GuFL+NjVtJlvydShqE1eN3h3PbI7/5LAsYJP/2qtuMXjfL2LpHSRqo4b4eSF5K/DH1JXKUAHSB2UW50g==", + "dev": true, + "dependencies": { + "is-callable": "^1.2.7", + "is-date-object": "^1.0.5", + "is-symbol": "^1.0.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/esbuild": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "dev": true, + "hasInstallScript": true, + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "9.39.1", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.1.tgz", + "integrity": "sha512-BhHmn2yNOFA9H9JmmIVKJmd288g9hrVRDkdoIgRCRuSySRUHH7r/DI6aAXW9T1WwUuY3DFgrcaqB+deURBLR5g==", + "dev": true, + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.1", + "@eslint/config-array": "^0.21.1", + "@eslint/config-helpers": "^0.4.2", + "@eslint/core": "^0.17.0", + "@eslint/eslintrc": "^3.3.1", + "@eslint/js": "9.39.1", + "@eslint/plugin-kit": "^0.4.1", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "ajv": "^6.12.4", + "chalk": "^4.0.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^8.4.0", + "eslint-visitor-keys": "^4.2.1", + "espree": "^10.4.0", + "esquery": "^1.5.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^8.0.0", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "lodash.merge": "^4.6.2", + "minimatch": "^3.1.2", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-config-prettier": { + "version": "10.1.8", + "resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-10.1.8.tgz", + "integrity": "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==", + "dev": true, + "bin": { + "eslint-config-prettier": "bin/cli.js" + }, + "funding": { + "url": "https://opencollective.com/eslint-config-prettier" + }, + "peerDependencies": { + "eslint": ">=7.0.0" + } + }, + "node_modules/eslint-plugin-jsx-a11y": { + "version": "6.10.2", + "resolved": "https://registry.npmjs.org/eslint-plugin-jsx-a11y/-/eslint-plugin-jsx-a11y-6.10.2.tgz", + "integrity": "sha512-scB3nz4WmG75pV8+3eRUQOHZlNSUhFNq37xnpgRkCCELU3XMvXAxLk1eqWWyE22Ki4Q01Fnsw9BA3cJHDPgn2Q==", + "dev": true, + "dependencies": { + "aria-query": "^5.3.2", + "array-includes": "^3.1.8", + "array.prototype.flatmap": "^1.3.2", + "ast-types-flow": "^0.0.8", + "axe-core": "^4.10.0", + "axobject-query": "^4.1.0", + "damerau-levenshtein": "^1.0.8", + "emoji-regex": "^9.2.2", + "hasown": "^2.0.2", + "jsx-ast-utils": "^3.3.5", + "language-tags": "^1.0.9", + "minimatch": "^3.1.2", + "object.fromentries": "^2.0.8", + "safe-regex-test": "^1.0.3", + "string.prototype.includes": "^2.0.1" + }, + "engines": { + "node": ">=4.0" + }, + "peerDependencies": { + "eslint": "^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9" + } + }, + "node_modules/eslint-plugin-solid": { + "version": "0.14.5", + "resolved": "https://registry.npmjs.org/eslint-plugin-solid/-/eslint-plugin-solid-0.14.5.tgz", + "integrity": "sha512-nfuYK09ah5aJG/oEN6P1qziy1zLgW4PDWe75VNPi4CEFYk1x2AEqwFeQfEPR7gNn0F2jOeqKhx2E+5oNCOBYWQ==", + "dev": true, + "dependencies": { + "@typescript-eslint/utils": "^7.13.1 || ^8.0.0", + "estraverse": "^5.3.0", + "is-html": "^2.0.0", + "kebab-case": "^1.0.2", + "known-css-properties": "^0.30.0", + "style-to-object": "^1.0.6" + }, + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || ^8.0.0 || ^9.0.0", + "typescript": ">=4.8.4" + } + }, + "node_modules/eslint-scope": { + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", + "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", + "dev": true, + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "dev": true, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/espree": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", + "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", + "dev": true, + "dependencies": { + "acorn": "^8.15.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^4.2.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.6.0.tgz", + "integrity": "sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg==", + "dev": true, + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true + }, + "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, + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/file-entry-cache": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "dev": true, + "dependencies": { + "flat-cache": "^4.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "dev": true, + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/flatted": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz", + "integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==", + "dev": true + }, + "node_modules/for-each": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz", + "integrity": "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==", + "dev": true, + "dependencies": { + "is-callable": "^1.2.7" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "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, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/function.prototype.name": { + "version": "1.1.8", + "resolved": "https://registry.npmjs.org/function.prototype.name/-/function.prototype.name-1.1.8.tgz", + "integrity": "sha512-e5iwyodOHhbMr/yNrc7fDYG4qlbIvI5gajyzPnb5TCwyhjApznQh1BMFou9b30SevY43gCJKXycoCBjMbsuW0Q==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "functions-have-names": "^1.2.3", + "hasown": "^2.0.2", + "is-callable": "^1.2.7" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/functions-have-names": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/functions-have-names/-/functions-have-names-1.2.3.tgz", + "integrity": "sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==", + "dev": true, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/generator-function": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/generator-function/-/generator-function-2.0.1.tgz", + "integrity": "sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g==", + "dev": true, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "dev": true, + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "dev": true, + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/get-symbol-description": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/get-symbol-description/-/get-symbol-description-1.1.0.tgz", + "integrity": "sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg==", + "dev": true, + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/globals": { + "version": "16.5.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-16.5.0.tgz", + "integrity": "sha512-c/c15i26VrJ4IRt5Z89DnIzCGDn9EcebibhAOjw5ibqEHsE1wLUgkPn9RDmNcUKyU87GeaL633nyJ+pplFR2ZQ==", + "dev": true, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/globalthis": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.4.tgz", + "integrity": "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==", + "dev": true, + "dependencies": { + "define-properties": "^1.2.1", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "dev": true, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true + }, + "node_modules/graphemer": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz", + "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==", + "dev": true + }, + "node_modules/has-bigints": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.1.0.tgz", + "integrity": "sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==", + "dev": true, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/has-property-descriptors": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", + "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", + "dev": true, + "dependencies": { + "es-define-property": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-proto": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/has-proto/-/has-proto-1.2.0.tgz", + "integrity": "sha512-KIL7eQPfHQRC8+XluaIw7BHUwwqL19bQn4hzNgdr+1wXoU0KKj6rufu47lhY7KbJR2C6T6+PfyN0Ea7wkSS+qQ==", + "dev": true, + "dependencies": { + "dunder-proto": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "dev": true, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "dev": true, + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "dev": true, + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/html-entities": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/html-entities/-/html-entities-2.3.3.tgz", + "integrity": "sha512-DV5Ln36z34NNTDgnz0EWGBLZENelNAtkiFA4kyNOG2tDI6Mz1uSWiq1wAKdyjnJwyDiDO7Fa2SO1CTxPXL8VxA==", + "dev": true + }, + "node_modules/html-tags": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/html-tags/-/html-tags-3.3.1.tgz", + "integrity": "sha512-ztqyC3kLto0e9WbNp0aeP+M3kTt+nbaIveGmUxAtZa+8iFgKLUOD4YKM5j+f3QD89bra7UeumolZHKuOXnTmeQ==", + "dev": true, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "engines": { + "node": ">= 4" + } + }, + "node_modules/import-fresh": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "dev": true, + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/inline-style-parser": { + "version": "0.2.7", + "resolved": "https://registry.npmjs.org/inline-style-parser/-/inline-style-parser-0.2.7.tgz", + "integrity": "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==", + "dev": true + }, + "node_modules/internal-slot": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz", + "integrity": "sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==", + "dev": true, + "dependencies": { + "es-errors": "^1.3.0", + "hasown": "^2.0.2", + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/is-array-buffer": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz", + "integrity": "sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-async-function": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-async-function/-/is-async-function-2.1.1.tgz", + "integrity": "sha512-9dgM/cZBnNvjzaMYHVoxxfPj2QXt22Ev7SuuPrs+xav0ukGB0S6d4ydZdEiM48kLx5kDV+QBPrpVnFyefL8kkQ==", + "dev": true, + "dependencies": { + "async-function": "^1.0.0", + "call-bound": "^1.0.3", + "get-proto": "^1.0.1", + "has-tostringtag": "^1.0.2", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-bigint": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-bigint/-/is-bigint-1.1.0.tgz", + "integrity": "sha512-n4ZT37wG78iz03xPRKJrHTdZbe3IicyucEtdRsV5yglwc3GyUfbAfpSeD0FJ41NbUNSt5wbhqfp1fS+BgnvDFQ==", + "dev": true, + "dependencies": { + "has-bigints": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-boolean-object": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/is-boolean-object/-/is-boolean-object-1.2.2.tgz", + "integrity": "sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A==", + "dev": true, + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-callable": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz", + "integrity": "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==", + "dev": true, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-data-view": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-data-view/-/is-data-view-1.0.2.tgz", + "integrity": "sha512-RKtWF8pGmS87i2D6gqQu/l7EYRlVdfzemCJN/P3UOs//x1QE7mfhvzHIApBTRf7axvT6DMGwSwBXYCT0nfB9xw==", + "dev": true, + "dependencies": { + "call-bound": "^1.0.2", + "get-intrinsic": "^1.2.6", + "is-typed-array": "^1.1.13" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-date-object": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-date-object/-/is-date-object-1.1.0.tgz", + "integrity": "sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg==", + "dev": true, + "dependencies": { + "call-bound": "^1.0.2", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-finalizationregistry": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-finalizationregistry/-/is-finalizationregistry-1.1.1.tgz", + "integrity": "sha512-1pC6N8qWJbWoPtEjgcL2xyhQOP491EQjeUo3qTKcmV8YSDDJrOepfG8pcC7h/QgnQHYSv0mJ3Z/ZWxmatVrysg==", + "dev": true, + "dependencies": { + "call-bound": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-generator-function": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.1.2.tgz", + "integrity": "sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA==", + "dev": true, + "dependencies": { + "call-bound": "^1.0.4", + "generator-function": "^2.0.0", + "get-proto": "^1.0.1", + "has-tostringtag": "^1.0.2", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-html": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/is-html/-/is-html-2.0.0.tgz", + "integrity": "sha512-S+OpgB5i7wzIue/YSE5hg0e5ZYfG3hhpNh9KGl6ayJ38p7ED6wxQLd1TV91xHpcTvw90KMJ9EwN3F/iNflHBVg==", + "dev": true, + "dependencies": { + "html-tags": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-map": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-map/-/is-map-2.0.3.tgz", + "integrity": "sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw==", + "dev": true, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-negative-zero": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-negative-zero/-/is-negative-zero-2.0.3.tgz", + "integrity": "sha512-5KoIu2Ngpyek75jXodFvnafB6DJgr3u8uuK0LEZJjrU19DrMD3EVERaR8sjz8CCGgpZvxPl9SuE1GMVPFHx1mw==", + "dev": true, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-number-object": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-number-object/-/is-number-object-1.1.1.tgz", + "integrity": "sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw==", + "dev": true, + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-regex": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz", + "integrity": "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==", + "dev": true, + "dependencies": { + "call-bound": "^1.0.2", + "gopd": "^1.2.0", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-set": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-set/-/is-set-2.0.3.tgz", + "integrity": "sha512-iPAjerrse27/ygGLxw+EBR9agv9Y6uLeYVJMu+QNCoouJ1/1ri0mGrcWpfCqFZuzzx3WjtwxG098X+n4OuRkPg==", + "dev": true, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-shared-array-buffer": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/is-shared-array-buffer/-/is-shared-array-buffer-1.0.4.tgz", + "integrity": "sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A==", + "dev": true, + "dependencies": { + "call-bound": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-string": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-string/-/is-string-1.1.1.tgz", + "integrity": "sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA==", + "dev": true, + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-symbol": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-symbol/-/is-symbol-1.1.1.tgz", + "integrity": "sha512-9gGx6GTtCQM73BgmHQXfDmLtfjjTUDSyoxTCbp5WtoixAhfgsDirWIcVQ/IHpvI5Vgd5i/J5F7B9cN/WlVbC/w==", + "dev": true, + "dependencies": { + "call-bound": "^1.0.2", + "has-symbols": "^1.1.0", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-typed-array": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.15.tgz", + "integrity": "sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==", + "dev": true, + "dependencies": { + "which-typed-array": "^1.1.16" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-weakmap": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/is-weakmap/-/is-weakmap-2.0.2.tgz", + "integrity": "sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w==", + "dev": true, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-weakref": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-weakref/-/is-weakref-1.1.1.tgz", + "integrity": "sha512-6i9mGWSlqzNMEqpCp93KwRS1uUOodk2OJ6b+sq7ZPDSy2WuI5NFIxp/254TytR8ftefexkWn5xNiHUNpPOfSew==", + "dev": true, + "dependencies": { + "call-bound": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-weakset": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/is-weakset/-/is-weakset-2.0.4.tgz", + "integrity": "sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ==", + "dev": true, + "dependencies": { + "call-bound": "^1.0.3", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-what": { + "version": "4.1.16", + "resolved": "https://registry.npmjs.org/is-what/-/is-what-4.1.16.tgz", + "integrity": "sha512-ZhMwEosbFJkA0YhFnNDgTM4ZxDRsS6HqTo7qsZM08fehyRYIYa0yHu5R6mgo1n/8MgaPBXiPimPD77baVFYg+A==", + "dev": true, + "engines": { + "node": ">=12.13" + }, + "funding": { + "url": "https://github.com/sponsors/mesqueeb" + } + }, + "node_modules/isarray": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", + "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", + "dev": true + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true + }, + "node_modules/jiti": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz", + "integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==", + "dev": true, + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "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 + }, + "node_modules/js-yaml": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", + "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "dev": true, + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/jsx-ast-utils": { + "version": "3.3.5", + "resolved": "https://registry.npmjs.org/jsx-ast-utils/-/jsx-ast-utils-3.3.5.tgz", + "integrity": "sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==", + "dev": true, + "dependencies": { + "array-includes": "^3.1.6", + "array.prototype.flat": "^1.3.1", + "object.assign": "^4.1.4", + "object.values": "^1.1.6" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/kebab-case": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/kebab-case/-/kebab-case-1.0.2.tgz", + "integrity": "sha512-7n6wXq4gNgBELfDCpzKc+mRrZFs7D+wgfF5WRFLNAr4DA/qtr9Js8uOAVAfHhuLMfAcQ0pRKqbpjx+TcJVdE1Q==", + "dev": true + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/known-css-properties": { + "version": "0.30.0", + "resolved": "https://registry.npmjs.org/known-css-properties/-/known-css-properties-0.30.0.tgz", + "integrity": "sha512-VSWXYUnsPu9+WYKkfmJyLKtIvaRJi1kXUqVmBACORXZQxT5oZDsoZ2vQP+bQFDnWtpI/4eq3MLoRMjI2fnLzTQ==", + "dev": true + }, + "node_modules/language-subtag-registry": { + "version": "0.3.23", + "resolved": "https://registry.npmjs.org/language-subtag-registry/-/language-subtag-registry-0.3.23.tgz", + "integrity": "sha512-0K65Lea881pHotoGEa5gDlMxt3pctLi2RplBb7Ezh4rRdLEOtgi7n4EwK9lamnUCkKBqaeKRVebTq6BAxSkpXQ==", + "dev": true + }, + "node_modules/language-tags": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/language-tags/-/language-tags-1.0.9.tgz", + "integrity": "sha512-MbjN408fEndfiQXbFQ1vnd+1NoLDsnQW41410oQBXiyXDMYH5z505juWa4KUE1LqxRC7DgOgZDbKLxHIwm27hA==", + "dev": true, + "dependencies": { + "language-subtag-registry": "^0.3.20" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/lightningcss": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.30.2.tgz", + "integrity": "sha512-utfs7Pr5uJyyvDETitgsaqSyjCb2qNRAtuqUeWIAKztsOYdcACf2KtARYXg2pSvhkt+9NfoaNY7fxjl6nuMjIQ==", + "dev": true, + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.30.2", + "lightningcss-darwin-arm64": "1.30.2", + "lightningcss-darwin-x64": "1.30.2", + "lightningcss-freebsd-x64": "1.30.2", + "lightningcss-linux-arm-gnueabihf": "1.30.2", + "lightningcss-linux-arm64-gnu": "1.30.2", + "lightningcss-linux-arm64-musl": "1.30.2", + "lightningcss-linux-x64-gnu": "1.30.2", + "lightningcss-linux-x64-musl": "1.30.2", + "lightningcss-win32-arm64-msvc": "1.30.2", + "lightningcss-win32-x64-msvc": "1.30.2" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.30.2.tgz", + "integrity": "sha512-BH9sEdOCahSgmkVhBLeU7Hc9DWeZ1Eb6wNS6Da8igvUwAe0sqROHddIlvU06q3WyXVEOYDZ6ykBZQnjTbmo4+A==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.30.2.tgz", + "integrity": "sha512-ylTcDJBN3Hp21TdhRT5zBOIi73P6/W0qwvlFEk22fkdXchtNTOU4Qc37SkzV+EKYxLouZ6M4LG9NfZ1qkhhBWA==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.30.2.tgz", + "integrity": "sha512-oBZgKchomuDYxr7ilwLcyms6BCyLn0z8J0+ZZmfpjwg9fRVZIR5/GMXd7r9RH94iDhld3UmSjBM6nXWM2TfZTQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.30.2.tgz", + "integrity": "sha512-c2bH6xTrf4BDpK8MoGG4Bd6zAMZDAXS569UxCAGcA7IKbHNMlhGQ89eRmvpIUGfKWNVdbhSbkQaWhEoMGmGslA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.30.2.tgz", + "integrity": "sha512-eVdpxh4wYcm0PofJIZVuYuLiqBIakQ9uFZmipf6LF/HRj5Bgm0eb3qL/mr1smyXIS1twwOxNWndd8z0E374hiA==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.30.2.tgz", + "integrity": "sha512-UK65WJAbwIJbiBFXpxrbTNArtfuznvxAJw4Q2ZGlU8kPeDIWEX1dg3rn2veBVUylA2Ezg89ktszWbaQnxD/e3A==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.30.2.tgz", + "integrity": "sha512-5Vh9dGeblpTxWHpOx8iauV02popZDsCYMPIgiuw97OJ5uaDsL86cnqSFs5LZkG3ghHoX5isLgWzMs+eD1YzrnA==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.30.2.tgz", + "integrity": "sha512-Cfd46gdmj1vQ+lR6VRTTadNHu6ALuw2pKR9lYq4FnhvgBc4zWY1EtZcAc6EffShbb1MFrIPfLDXD6Xprbnni4w==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.30.2.tgz", + "integrity": "sha512-XJaLUUFXb6/QG2lGIW6aIk6jKdtjtcffUT0NKvIqhSBY3hh9Ch+1LCeH80dR9q9LBjG3ewbDjnumefsLsP6aiA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.30.2.tgz", + "integrity": "sha512-FZn+vaj7zLv//D/192WFFVA0RgHawIcHqLX9xuWiQt7P0PtdFEVaxgF9rjM/IRYHQXNnk61/H/gb2Ei+kUQ4xQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.30.2.tgz", + "integrity": "sha512-5g1yc73p+iAkid5phb4oVFMB45417DkRevRbt/El/gKXJk4jid+vPFF/AXbxn05Aky8PapwzZrdJShv5C0avjw==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "dev": true + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "dependencies": { + "yallist": "^3.0.2" + } + }, + "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, + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "dev": true, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/merge-anything": { + "version": "5.1.7", + "resolved": "https://registry.npmjs.org/merge-anything/-/merge-anything-5.1.7.tgz", + "integrity": "sha512-eRtbOb1N5iyH0tkQDAoQ4Ipsp/5qSR79Dzrz8hEPxRX10RWWR/iQXdoKmBSRCThY1Fh5EhISDtpSc93fpxUniQ==", + "dev": true, + "dependencies": { + "is-what": "^4.1.8" + }, + "engines": { + "node": ">=12.13" + }, + "funding": { + "url": "https://github.com/sponsors/mesqueeb" + } + }, + "node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "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 + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/nanostores": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/nanostores/-/nanostores-1.1.0.tgz", + "integrity": "sha512-yJBmDJr18xy47dbNVlHcgdPrulSn1nhSE6Ns9vTG+Nx9VPT6iV1MD6aQFp/t52zpf82FhLLTXAXr30NuCnxvwA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "engines": { + "node": "^20.0.0 || >=22.0.0" + } + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true + }, + "node_modules/node-releases": { + "version": "2.0.27", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz", + "integrity": "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==", + "dev": true + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "dev": true, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object-keys": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", + "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", + "dev": true, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/object.assign": { + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/object.assign/-/object.assign-4.1.7.tgz", + "integrity": "sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0", + "has-symbols": "^1.1.0", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object.fromentries": { + "version": "2.0.8", + "resolved": "https://registry.npmjs.org/object.fromentries/-/object.fromentries-2.0.8.tgz", + "integrity": "sha512-k6E21FzySsSK5a21KRADBd/NGneRegFO5pLHfdQLpRDETUNJueLXs3WCzyQ3tFRDYgbq3KHGXfTbi2bs8WQ6rQ==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.2", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object.values": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/object.values/-/object.values-1.2.1.tgz", + "integrity": "sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/own-keys": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/own-keys/-/own-keys-1.0.1.tgz", + "integrity": "sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg==", + "dev": true, + "dependencies": { + "get-intrinsic": "^1.2.6", + "object-keys": "^1.1.1", + "safe-push-apply": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "dev": true, + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "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, + "dependencies": { + "entities": "^6.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true + }, + "node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/possible-typed-array-names": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", + "integrity": "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==", + "dev": true, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/postcss": { + "version": "8.5.6", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", + "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/prettier": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.6.2.tgz", + "integrity": "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==", + "dev": true, + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, + "node_modules/prettier-plugin-tailwindcss": { + "version": "0.6.14", + "resolved": "https://registry.npmjs.org/prettier-plugin-tailwindcss/-/prettier-plugin-tailwindcss-0.6.14.tgz", + "integrity": "sha512-pi2e/+ZygeIqntN+vC573BcW5Cve8zUB0SSAGxqpB4f96boZF4M3phPVoOFCeypwkpRYdi7+jQ5YJJUwrkGUAg==", + "dev": true, + "engines": { + "node": ">=14.21.3" + }, + "peerDependencies": { + "@ianvs/prettier-plugin-sort-imports": "*", + "@prettier/plugin-hermes": "*", + "@prettier/plugin-oxc": "*", + "@prettier/plugin-pug": "*", + "@shopify/prettier-plugin-liquid": "*", + "@trivago/prettier-plugin-sort-imports": "*", + "@zackad/prettier-plugin-twig": "*", + "prettier": "^3.0", + "prettier-plugin-astro": "*", + "prettier-plugin-css-order": "*", + "prettier-plugin-import-sort": "*", + "prettier-plugin-jsdoc": "*", + "prettier-plugin-marko": "*", + "prettier-plugin-multiline-arrays": "*", + "prettier-plugin-organize-attributes": "*", + "prettier-plugin-organize-imports": "*", + "prettier-plugin-sort-imports": "*", + "prettier-plugin-style-order": "*", + "prettier-plugin-svelte": "*" + }, + "peerDependenciesMeta": { + "@ianvs/prettier-plugin-sort-imports": { + "optional": true + }, + "@prettier/plugin-hermes": { + "optional": true + }, + "@prettier/plugin-oxc": { + "optional": true + }, + "@prettier/plugin-pug": { + "optional": true + }, + "@shopify/prettier-plugin-liquid": { + "optional": true + }, + "@trivago/prettier-plugin-sort-imports": { + "optional": true + }, + "@zackad/prettier-plugin-twig": { + "optional": true + }, + "prettier-plugin-astro": { + "optional": true + }, + "prettier-plugin-css-order": { + "optional": true + }, + "prettier-plugin-import-sort": { + "optional": true + }, + "prettier-plugin-jsdoc": { + "optional": true + }, + "prettier-plugin-marko": { + "optional": true + }, + "prettier-plugin-multiline-arrays": { + "optional": true + }, + "prettier-plugin-organize-attributes": { + "optional": true + }, + "prettier-plugin-organize-imports": { + "optional": true + }, + "prettier-plugin-sort-imports": { + "optional": true + }, + "prettier-plugin-style-order": { + "optional": true + }, + "prettier-plugin-svelte": { + "optional": true + } + } + }, + "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, + "engines": { + "node": ">=6" + } + }, + "node_modules/reflect.getprototypeof": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz", + "integrity": "sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.9", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0", + "get-intrinsic": "^1.2.7", + "get-proto": "^1.0.1", + "which-builtin-type": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/regexp.prototype.flags": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz", + "integrity": "sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-errors": "^1.3.0", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "set-function-name": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "dev": true, + "engines": { + "node": ">=4" + } + }, + "node_modules/rollup": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.53.3.tgz", + "integrity": "sha512-w8GmOxZfBmKknvdXU1sdM9NHcoQejwF/4mNgj2JuEEdRaHwwF12K7e9eXn1nLZ07ad+du76mkVsyeb2rKGllsA==", + "dev": true, + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.53.3", + "@rollup/rollup-android-arm64": "4.53.3", + "@rollup/rollup-darwin-arm64": "4.53.3", + "@rollup/rollup-darwin-x64": "4.53.3", + "@rollup/rollup-freebsd-arm64": "4.53.3", + "@rollup/rollup-freebsd-x64": "4.53.3", + "@rollup/rollup-linux-arm-gnueabihf": "4.53.3", + "@rollup/rollup-linux-arm-musleabihf": "4.53.3", + "@rollup/rollup-linux-arm64-gnu": "4.53.3", + "@rollup/rollup-linux-arm64-musl": "4.53.3", + "@rollup/rollup-linux-loong64-gnu": "4.53.3", + "@rollup/rollup-linux-ppc64-gnu": "4.53.3", + "@rollup/rollup-linux-riscv64-gnu": "4.53.3", + "@rollup/rollup-linux-riscv64-musl": "4.53.3", + "@rollup/rollup-linux-s390x-gnu": "4.53.3", + "@rollup/rollup-linux-x64-gnu": "4.53.3", + "@rollup/rollup-linux-x64-musl": "4.53.3", + "@rollup/rollup-openharmony-arm64": "4.53.3", + "@rollup/rollup-win32-arm64-msvc": "4.53.3", + "@rollup/rollup-win32-ia32-msvc": "4.53.3", + "@rollup/rollup-win32-x64-gnu": "4.53.3", + "@rollup/rollup-win32-x64-msvc": "4.53.3", + "fsevents": "~2.3.2" + } + }, + "node_modules/safe-array-concat": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/safe-array-concat/-/safe-array-concat-1.1.3.tgz", + "integrity": "sha512-AURm5f0jYEOydBj7VQlVvDrjeFgthDdEF5H1dP+6mNpoXOMo1quQqJ4wvJDyRZ9+pO3kGWoOdmV08cSv2aJV6Q==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.2", + "get-intrinsic": "^1.2.6", + "has-symbols": "^1.1.0", + "isarray": "^2.0.5" + }, + "engines": { + "node": ">=0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/safe-push-apply": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/safe-push-apply/-/safe-push-apply-1.0.0.tgz", + "integrity": "sha512-iKE9w/Z7xCzUMIZqdBsp6pEQvwuEebH4vdpjcDWnyzaI6yl6O9FHvVpmGelvEHNsoY6wGblkxR6Zty/h00WiSA==", + "dev": true, + "dependencies": { + "es-errors": "^1.3.0", + "isarray": "^2.0.5" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/safe-regex-test": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/safe-regex-test/-/safe-regex-test-1.1.0.tgz", + "integrity": "sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==", + "dev": true, + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "is-regex": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/semver": { + "version": "7.7.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", + "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", + "dev": true, + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/seroval": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/seroval/-/seroval-1.3.2.tgz", + "integrity": "sha512-RbcPH1n5cfwKrru7v7+zrZvjLurgHhGyso3HTyGtRivGWgYjbOmGuivCQaORNELjNONoK35nj28EoWul9sb1zQ==", + "engines": { + "node": ">=10" + } + }, + "node_modules/seroval-plugins": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/seroval-plugins/-/seroval-plugins-1.3.3.tgz", + "integrity": "sha512-16OL3NnUBw8JG1jBLUoZJsLnQq0n5Ua6aHalhJK4fMQkz1lqR7Osz1sA30trBtd9VUDc2NgkuRCn8+/pBwqZ+w==", + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "seroval": "^1.0" + } + }, + "node_modules/set-function-length": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", + "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==", + "dev": true, + "dependencies": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.2.4", + "gopd": "^1.0.1", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/set-function-name": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/set-function-name/-/set-function-name-2.0.2.tgz", + "integrity": "sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ==", + "dev": true, + "dependencies": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "functions-have-names": "^1.2.3", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/set-proto": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/set-proto/-/set-proto-1.0.0.tgz", + "integrity": "sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw==", + "dev": true, + "dependencies": { + "dunder-proto": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/side-channel": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "dev": true, + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", + "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", + "dev": true, + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "dev": true, + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "dev": true, + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/solid-js": { + "version": "1.9.10", + "resolved": "https://registry.npmjs.org/solid-js/-/solid-js-1.9.10.tgz", + "integrity": "sha512-Coz956cos/EPDlhs6+jsdTxKuJDPT7B5SVIWgABwROyxjY7Xbr8wkzD68Et+NxnV7DLJ3nJdAC2r9InuV/4Jew==", + "dependencies": { + "csstype": "^3.1.0", + "seroval": "~1.3.0", + "seroval-plugins": "~1.3.0" + } + }, + "node_modules/solid-refresh": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/solid-refresh/-/solid-refresh-0.6.3.tgz", + "integrity": "sha512-F3aPsX6hVw9ttm5LYlth8Q15x6MlI/J3Dn+o3EQyRTtTxidepSTwAYdozt01/YA+7ObcciagGEyXIopGZzQtbA==", + "dev": true, + "dependencies": { + "@babel/generator": "^7.23.6", + "@babel/helper-module-imports": "^7.22.15", + "@babel/types": "^7.23.6" + }, + "peerDependencies": { + "solid-js": "^1.3" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stop-iteration-iterator": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz", + "integrity": "sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==", + "dev": true, + "dependencies": { + "es-errors": "^1.3.0", + "internal-slot": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/string.prototype.includes": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/string.prototype.includes/-/string.prototype.includes-2.0.1.tgz", + "integrity": "sha512-o7+c9bW6zpAdJHTtujeePODAhkuicdAryFsfVKwA+wGw89wJ4GTY484WTucM9hLtDEOpOvI+aHnzqnC5lHp4Rg==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.3" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/string.prototype.trim": { + "version": "1.2.10", + "resolved": "https://registry.npmjs.org/string.prototype.trim/-/string.prototype.trim-1.2.10.tgz", + "integrity": "sha512-Rs66F0P/1kedk5lyYyH9uBzuiI/kNRmwJAR9quK6VOtIpZ2G+hMZd+HQbbv25MgCA6gEffoMZYxlTod4WcdrKA==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.2", + "define-data-property": "^1.1.4", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-object-atoms": "^1.0.0", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/string.prototype.trimend": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/string.prototype.trimend/-/string.prototype.trimend-1.0.9.tgz", + "integrity": "sha512-G7Ok5C6E/j4SGfyLCloXTrngQIQU3PWtXGst3yM7Bea9FRURf1S42ZHlZZtsNque2FN2PoUhfZXYLNWwEr4dLQ==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.2", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/string.prototype.trimstart": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/string.prototype.trimstart/-/string.prototype.trimstart-1.0.8.tgz", + "integrity": "sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/style-to-object": { + "version": "1.0.14", + "resolved": "https://registry.npmjs.org/style-to-object/-/style-to-object-1.0.14.tgz", + "integrity": "sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw==", + "dev": true, + "dependencies": { + "inline-style-parser": "0.2.7" + } + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/tailwindcss": { + "version": "4.1.17", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.17.tgz", + "integrity": "sha512-j9Ee2YjuQqYT9bbRTfTZht9W/ytp5H+jJpZKiYdP/bpnXARAuELt9ofP0lPnmHjbga7SNQIxdTAXCmtKVYjN+Q==", + "dev": true + }, + "node_modules/tapable": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.0.tgz", + "integrity": "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==", + "dev": true, + "engines": { + "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "dev": true, + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/ts-api-utils": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.1.0.tgz", + "integrity": "sha512-CUgTZL1irw8u29bzrOD/nH85jqyc74D6SshFgujOIA7osm2Rz7dYH77agkx7H4FBNxDq7Cjf+IjaX/8zwFW+ZQ==", + "dev": true, + "engines": { + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" + } + }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/typed-array-buffer": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.3.tgz", + "integrity": "sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==", + "dev": true, + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-typed-array": "^1.1.14" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/typed-array-byte-length": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/typed-array-byte-length/-/typed-array-byte-length-1.0.3.tgz", + "integrity": "sha512-BaXgOuIxz8n8pIq3e7Atg/7s+DpiYrxn4vdot3w9KbnBhcRQq6o3xemQdIfynqSeXeDrF32x+WvfzmOjPiY9lg==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.8", + "for-each": "^0.3.3", + "gopd": "^1.2.0", + "has-proto": "^1.2.0", + "is-typed-array": "^1.1.14" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/typed-array-byte-offset": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/typed-array-byte-offset/-/typed-array-byte-offset-1.0.4.tgz", + "integrity": "sha512-bTlAFB/FBYMcuX81gbL4OcpH5PmlFHqlCCpAl8AlEzMz5k53oNDvN8p1PNOWLEmI2x4orp3raOFB51tv9X+MFQ==", + "dev": true, + "dependencies": { + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.8", + "for-each": "^0.3.3", + "gopd": "^1.2.0", + "has-proto": "^1.2.0", + "is-typed-array": "^1.1.15", + "reflect.getprototypeof": "^1.0.9" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/typed-array-length": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/typed-array-length/-/typed-array-length-1.0.7.tgz", + "integrity": "sha512-3KS2b+kL7fsuk/eJZ7EQdnEmQoaho/r6KUef7hxvltNA5DR8NAUM+8wJMbJyZ4G9/7i3v5zPBIMN5aybAh2/Jg==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.7", + "for-each": "^0.3.3", + "gopd": "^1.0.1", + "is-typed-array": "^1.1.13", + "possible-typed-array-names": "^1.0.0", + "reflect.getprototypeof": "^1.0.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/typescript-eslint": { + "version": "8.48.0", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.48.0.tgz", + "integrity": "sha512-fcKOvQD9GUn3Xw63EgiDqhvWJ5jsyZUaekl3KVpGsDJnN46WJTe3jWxtQP9lMZm1LJNkFLlTaWAxK2vUQR+cqw==", + "dev": true, + "dependencies": { + "@typescript-eslint/eslint-plugin": "8.48.0", + "@typescript-eslint/parser": "8.48.0", + "@typescript-eslint/typescript-estree": "8.48.0", + "@typescript-eslint/utils": "8.48.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/unbox-primitive": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.1.0.tgz", + "integrity": "sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==", + "dev": true, + "dependencies": { + "call-bound": "^1.0.3", + "has-bigints": "^1.0.2", + "has-symbols": "^1.1.0", + "which-boxed-primitive": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true + }, + "node_modules/update-browserslist-db": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.4.tgz", + "integrity": "sha512-q0SPT4xyU84saUX+tomz1WLkxUbuaJnR1xWt17M7fJtEJigJeWUNGUqrauFXsHnqev9y9JTRGwk13tFBuKby4A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/vite": { + "version": "7.2.4", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.2.4.tgz", + "integrity": "sha512-NL8jTlbo0Tn4dUEXEsUg8KeyG/Lkmc4Fnzb8JXN/Ykm9G4HNImjtABMJgkQoVjOBN/j2WAwDTRytdqJbZsah7w==", + "dev": true, + "dependencies": { + "esbuild": "^0.25.0", + "fdir": "^6.5.0", + "picomatch": "^4.0.3", + "postcss": "^8.5.6", + "rollup": "^4.43.0", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "lightningcss": "^1.21.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vite-plugin-solid": { + "version": "2.11.10", + "resolved": "https://registry.npmjs.org/vite-plugin-solid/-/vite-plugin-solid-2.11.10.tgz", + "integrity": "sha512-Yr1dQybmtDtDAHkii6hXuc1oVH9CPcS/Zb2jN/P36qqcrkNnVPsMTzQ06jyzFPFjj3U1IYKMVt/9ZqcwGCEbjw==", + "dev": true, + "dependencies": { + "@babel/core": "^7.23.3", + "@types/babel__core": "^7.20.4", + "babel-preset-solid": "^1.8.4", + "merge-anything": "^5.1.7", + "solid-refresh": "^0.6.3", + "vitefu": "^1.0.4" + }, + "peerDependencies": { + "@testing-library/jest-dom": "^5.16.6 || ^5.17.0 || ^6.*", + "solid-js": "^1.7.2", + "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" + }, + "peerDependenciesMeta": { + "@testing-library/jest-dom": { + "optional": true + } + } + }, + "node_modules/vitefu": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/vitefu/-/vitefu-1.1.1.tgz", + "integrity": "sha512-B/Fegf3i8zh0yFbpzZ21amWzHmuNlLlmJT6n7bu5e+pCHUKQIfXSYokrqOBGEMMe9UG2sostKQF9mml/vYaWJQ==", + "dev": true, + "peerDependencies": { + "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0-beta.0" + }, + "peerDependenciesMeta": { + "vite": { + "optional": true + } + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/which-boxed-primitive": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/which-boxed-primitive/-/which-boxed-primitive-1.1.1.tgz", + "integrity": "sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==", + "dev": true, + "dependencies": { + "is-bigint": "^1.1.0", + "is-boolean-object": "^1.2.1", + "is-number-object": "^1.1.1", + "is-string": "^1.1.1", + "is-symbol": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-builtin-type": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/which-builtin-type/-/which-builtin-type-1.2.1.tgz", + "integrity": "sha512-6iBczoX+kDQ7a3+YJBnh3T+KZRxM/iYNPXicqk66/Qfm1b93iu+yOImkg0zHbj5LNOcNv1TEADiZ0xa34B4q6Q==", + "dev": true, + "dependencies": { + "call-bound": "^1.0.2", + "function.prototype.name": "^1.1.6", + "has-tostringtag": "^1.0.2", + "is-async-function": "^2.0.0", + "is-date-object": "^1.1.0", + "is-finalizationregistry": "^1.1.0", + "is-generator-function": "^1.0.10", + "is-regex": "^1.2.1", + "is-weakref": "^1.0.2", + "isarray": "^2.0.5", + "which-boxed-primitive": "^1.1.0", + "which-collection": "^1.0.2", + "which-typed-array": "^1.1.16" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-collection": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/which-collection/-/which-collection-1.0.2.tgz", + "integrity": "sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw==", + "dev": true, + "dependencies": { + "is-map": "^2.0.3", + "is-set": "^2.0.3", + "is-weakmap": "^2.0.2", + "is-weakset": "^2.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-typed-array": { + "version": "1.1.19", + "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.19.tgz", + "integrity": "sha512-rEvr90Bck4WZt9HHFC4DJMsjvu7x+r6bImz0/BrbWb7A2djJ8hnZMrWnHo9F8ssv0OMErasDhftrfROTyqSDrw==", + "dev": true, + "dependencies": { + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "for-each": "^0.3.5", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + } + } +} diff --git a/PROJECTS/encrypted-p2p-chat/frontend/package.json b/PROJECTS/encrypted-p2p-chat/frontend/package.json new file mode 100644 index 00000000..552e7918 --- /dev/null +++ b/PROJECTS/encrypted-p2p-chat/frontend/package.json @@ -0,0 +1,43 @@ +{ + "name": "encrypted-p2p-chat-frontend", + "version": "1.0.0", + "description": "End-to-end encrypted P2P chat frontend with SolidJS", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview", + "typecheck": "tsc --noEmit", + "lint:eslint": "eslint .", + "lint:eslint:fix": "eslint . --fix", + "lint:types": "tsc --noEmit", + "lint": "npm run lint:eslint && npm run lint:types", + "format": "prettier --write \"src/**/*.{ts,tsx,css}\"", + "format:check": "prettier --check \"src/**/*.{ts,tsx,css}\"" + }, + "dependencies": { + "@nanostores/persistent": "^1.2.0", + "@nanostores/solid": "^1.1.1", + "@solidjs/router": "^0.15.3", + "@tanstack/solid-query": "^5.90.13", + "nanostores": "^1.1.0", + "solid-js": "^1.9.10" + }, + "devDependencies": { + "@eslint/js": "^9.17.0", + "@tailwindcss/vite": "^4.1.17", + "@types/node": "^22.10.2", + "eslint": "^9.17.0", + "eslint-config-prettier": "^10.1.5", + "eslint-plugin-jsx-a11y": "^6.10.2", + "eslint-plugin-solid": "^0.14.5", + "globals": "^16.2.0", + "prettier": "^3.4.2", + "prettier-plugin-tailwindcss": "^0.6.9", + "tailwindcss": "^4.1.17", + "typescript": "^5.9.3", + "typescript-eslint": "^8.20.0", + "vite": "^7.2.4", + "vite-plugin-solid": "^2.11.10" + } +} diff --git a/PROJECTS/encrypted-p2p-chat/frontend/src/App.tsx b/PROJECTS/encrypted-p2p-chat/frontend/src/App.tsx new file mode 100644 index 00000000..8119154f --- /dev/null +++ b/PROJECTS/encrypted-p2p-chat/frontend/src/App.tsx @@ -0,0 +1,26 @@ +// =================== +// © AngelaMos | 2025 +// App.tsx +// =================== +import { Route } from "@solidjs/router"; +import { type Component, lazy } from "solid-js"; + +const Home = lazy(() => import("./pages/Home")); +const Register = lazy(() => import("./pages/Register")); +const Login = lazy(() => import("./pages/Login")); +const Chat = lazy(() => import("./pages/Chat")); +const NotFound = lazy(() => import("./pages/NotFound")); + +const App: Component = () => { + return ( + <> + + + + + + + ); +}; + +export default App; diff --git a/PROJECTS/encrypted-p2p-chat/frontend/src/components/Auth/AuthCard.tsx b/PROJECTS/encrypted-p2p-chat/frontend/src/components/Auth/AuthCard.tsx new file mode 100644 index 00000000..c97badda --- /dev/null +++ b/PROJECTS/encrypted-p2p-chat/frontend/src/components/Auth/AuthCard.tsx @@ -0,0 +1,61 @@ +/** + * 8-bit styled auth card container + */ + +import type { ParentProps, JSX } from "solid-js" +import { Show } from "solid-js" + +interface AuthCardProps extends ParentProps { + title: string + subtitle?: string +} + +export function AuthCard(props: AuthCardProps): JSX.Element { + return ( +
+
+
+
+ +

+ {props.title} +

+
+ + +

+ {props.subtitle} +

+
+
+ + {props.children} +
+ +
+ + END-TO-END ENCRYPTED + +
+
+ ) +} + +function LockIcon(): JSX.Element { + return ( + + + + + + + + + ) +} diff --git a/PROJECTS/encrypted-p2p-chat/frontend/src/components/Auth/AuthForm.tsx b/PROJECTS/encrypted-p2p-chat/frontend/src/components/Auth/AuthForm.tsx new file mode 100644 index 00000000..f0971917 --- /dev/null +++ b/PROJECTS/encrypted-p2p-chat/frontend/src/components/Auth/AuthForm.tsx @@ -0,0 +1,177 @@ +// =================== +// © AngelaMos | 2025 +// AuthForm.tsx +// =================== + +import { createSignal, Show } from "solid-js" +import type { JSX } from "solid-js" +import { A } from "@solidjs/router" +import { Input } from "../UI/Input" +import { PasskeyButton } from "./PasskeyButton" +import { AuthCard } from "./AuthCard" +import { + validateUsername, + validateDisplayName, +} from "../../lib/validators" +import { authService } from "../../services" +import { showToast } from "../../stores" +import { ApiError } from "../../types" + +type AuthMode = "login" | "register" + +interface AuthFormProps { + mode: AuthMode +} + +export function AuthForm(props: AuthFormProps): JSX.Element { + const [username, setUsername] = createSignal("") + const [displayName, setDisplayName] = createSignal("") + const [loading, setLoading] = createSignal(false) + const [usernameError, setUsernameError] = createSignal() + const [displayNameError, setDisplayNameError] = createSignal() + + const isRegister = (): boolean => props.mode === "register" + + const title = (): string => isRegister() ? "CREATE ACCOUNT" : "WELCOME BACK" + + const subtitle = (): string => + isRegister() + ? "REGISTER WITH A PASSKEY FOR SECURE, PASSWORDLESS AUTHENTICATION" + : "SIGN IN WITH YOUR PASSKEY TO CONTINUE" + + const validateForm = (): boolean => { + let valid = true + + if (isRegister()) { + const usernameResult = validateUsername(username()) + if (!usernameResult.valid) { + setUsernameError(usernameResult.error) + valid = false + } else { + setUsernameError(undefined) + } + + const displayNameResult = validateDisplayName(displayName()) + if (!displayNameResult.valid) { + setDisplayNameError(displayNameResult.error) + valid = false + } else { + setDisplayNameError(undefined) + } + } + + return valid + } + + const handleSubmit = async (): Promise => { + if (!validateForm()) { + return + } + + setLoading(true) + + try { + if (isRegister()) { + await authService.register(username(), displayName()) + showToast("success", "REGISTRATION COMPLETE", "YOUR PASSKEY HAS BEEN CREATED") + } else { + const trimmedUsername = username().trim() + const usernameValue = trimmedUsername === "" ? undefined : trimmedUsername + await authService.login(usernameValue) + showToast("success", "LOGIN SUCCESSFUL", "WELCOME BACK") + } + } catch (error) { + let message = "AN UNEXPECTED ERROR OCCURRED" + + if (error instanceof ApiError) { + message = error.message.toUpperCase() + } else if (error instanceof Error) { + if (error.name === "NotAllowedError") { + message = "PASSKEY OPERATION CANCELLED" + } else if (error.name === "InvalidStateError") { + message = "PASSKEY ALREADY REGISTERED" + } else { + message = error.message.toUpperCase() + } + } + + showToast("error", isRegister() ? "REGISTRATION FAILED" : "LOGIN FAILED", message) + } finally { + setLoading(false) + } + } + + return ( + +
+ + + + + + + + + + +
+ +
+
+ +
+

+ + DON'T HAVE AN ACCOUNT?{" "} + + REGISTER + + + } + > + ALREADY HAVE AN ACCOUNT?{" "} + + SIGN IN + + +

+
+ + ) +} diff --git a/PROJECTS/encrypted-p2p-chat/frontend/src/components/Auth/PasskeyButton.tsx b/PROJECTS/encrypted-p2p-chat/frontend/src/components/Auth/PasskeyButton.tsx new file mode 100644 index 00000000..35f7d0db --- /dev/null +++ b/PROJECTS/encrypted-p2p-chat/frontend/src/components/Auth/PasskeyButton.tsx @@ -0,0 +1,95 @@ +// =================== +// © AngelaMos | 2025 +// PasskeyButton.tsx +// =================== + +import { + Show, + createSignal, + onMount, +} from "solid-js" +import type { JSX } from "solid-js" +import { Button } from "../UI/Button" +import { + isWebAuthnSupported, + isPlatformAuthenticatorAvailable, +} from "../../services" + +interface PasskeyButtonProps { + mode: "register" | "login" + onClick: () => void | Promise + loading?: boolean + disabled?: boolean + class?: string +} + +export function PasskeyButton(props: PasskeyButtonProps): JSX.Element { + const [webAuthnSupported, setWebAuthnSupported] = createSignal(true) + const [platformAvailable, setPlatformAvailable] = createSignal(false) + + onMount(() => { + const supported = isWebAuthnSupported() + setWebAuthnSupported(supported) + + if (supported) { + void isPlatformAuthenticatorAvailable().then((available) => { + setPlatformAvailable(available) + }) + } + }) + + const buttonText = (): string => { + if (!webAuthnSupported()) { + return "WEBAUTHN NOT SUPPORTED" + } + return props.mode === "register" ? "REGISTER WITH PASSKEY" : "LOGIN WITH PASSKEY" + } + + const isDisabled = (): boolean => { + return !webAuthnSupported() || (props.disabled ?? false) + } + + return ( +
+ + + +

+ YOUR BROWSER DOES NOT SUPPORT PASSKEYS +

+
+ + +

+ BIOMETRIC AUTH AVAILABLE +

+
+
+ ) +} + +function PasskeyIcon(): JSX.Element { + return ( + + + + + + + + + + + + ) +} diff --git a/PROJECTS/encrypted-p2p-chat/frontend/src/components/Auth/index.ts b/PROJECTS/encrypted-p2p-chat/frontend/src/components/Auth/index.ts new file mode 100644 index 00000000..3ac0dae5 --- /dev/null +++ b/PROJECTS/encrypted-p2p-chat/frontend/src/components/Auth/index.ts @@ -0,0 +1,7 @@ +// =================== +// © AngelaMos | 2025 +// index.ts +// =================== +export { PasskeyButton } from "./PasskeyButton" +export { AuthCard } from "./AuthCard" +export { AuthForm } from "./AuthForm" diff --git a/PROJECTS/encrypted-p2p-chat/frontend/src/components/Chat/ChatHeader.tsx b/PROJECTS/encrypted-p2p-chat/frontend/src/components/Chat/ChatHeader.tsx new file mode 100644 index 00000000..d3326033 --- /dev/null +++ b/PROJECTS/encrypted-p2p-chat/frontend/src/components/Chat/ChatHeader.tsx @@ -0,0 +1,127 @@ +// =================== +// © AngelaMos | 2025 +// ChatHeader.tsx +// =================== +import { Show } from "solid-js" +import type { JSX } from "solid-js" +import type { Room, Participant } from "../../types" +import { OnlineStatus } from "./OnlineStatus" +import { EncryptionBadge } from "./EncryptionBadge" +import { getUserStatus } from "../../stores" +import { IconButton } from "../UI/IconButton" + +interface ChatHeaderProps { + room: Room | null + onSettingsClick?: () => void + onInfoClick?: () => void + class?: string +} + +export function ChatHeader(props: ChatHeaderProps): JSX.Element { + const otherParticipant = (): Participant | null => { + if (props.room?.type !== "direct") return null + return props.room.participants[0] ?? null + } + + const displayName = (): string => { + const room = props.room + if (room === null) return "CHAT" + if (room.name !== undefined && room.name !== null) return room.name + const other = otherParticipant() + return other?.display_name ?? other?.username ?? "CHAT" + } + + const initials = (): string => { + const name = displayName() + return name.slice(0, 2).toUpperCase() + } + + return ( +
+
+
+
+
+ + {initials()} + +
+ + {(participant) => ( +
+ +
+ )} +
+
+ +
+

+ {displayName()} +

+
+ + + + + + {props.room?.participants.length ?? 0} MEMBERS + + + + {(participant) => ( + + {getUserStatus(participant.user_id)} + + )} + +
+
+
+ +
+ + } + onClick={props.onInfoClick} + ariaLabel="Room info" + size="sm" + /> + + + } + onClick={props.onSettingsClick} + ariaLabel="Room settings" + size="sm" + /> + +
+
+
+ ) +} + +function InfoIcon(): JSX.Element { + return ( + + + + + ) +} + +function SettingsIcon(): JSX.Element { + return ( + + + + + + + + ) +} diff --git a/PROJECTS/encrypted-p2p-chat/frontend/src/components/Chat/ChatInput.tsx b/PROJECTS/encrypted-p2p-chat/frontend/src/components/Chat/ChatInput.tsx new file mode 100644 index 00000000..c293dc08 --- /dev/null +++ b/PROJECTS/encrypted-p2p-chat/frontend/src/components/Chat/ChatInput.tsx @@ -0,0 +1,132 @@ +// =================== +// © AngelaMos | 2025 +// ChatInput.tsx +// =================== +import { createSignal, Show, onCleanup } from "solid-js" +import type { JSX } from "solid-js" +import { Button } from "../UI/Button" +import { wsManager } from "../../websocket" +import { MESSAGE_MAX_LENGTH } from "../../types" + +interface ChatInputProps { + roomId: string + recipientId: string + disabled?: boolean + onSend?: (content: string) => void + class?: string +} + +export function ChatInput(props: ChatInputProps): JSX.Element { + const [message, setMessage] = createSignal("") + const [isTyping, setIsTyping] = createSignal(false) + let typingTimeout: ReturnType | undefined + let inputRef: HTMLInputElement | undefined + + const charCount = (): number => message().length + const isOverLimit = (): boolean => charCount() > MESSAGE_MAX_LENGTH + const canSend = (): boolean => message().trim().length > 0 && !isOverLimit() && (props.disabled !== true) + + const handleInput = (e: Event): void => { + const target = e.target as HTMLInputElement + setMessage(target.value) + + if (!isTyping()) { + setIsTyping(true) + wsManager.sendTypingIndicator(props.roomId, true) + } + + if (typingTimeout !== undefined) { + clearTimeout(typingTimeout) + } + + typingTimeout = setTimeout(() => { + setIsTyping(false) + wsManager.sendTypingIndicator(props.roomId, false) + }, 2000) + } + + const handleSend = (): void => { + if (!canSend()) return + + const content = message().trim() + props.onSend?.(content) + setMessage("") + + if (isTyping()) { + setIsTyping(false) + wsManager.sendTypingIndicator(props.roomId, false) + } + + inputRef?.focus() + } + + const handleKeyDown = (e: KeyboardEvent): void => { + if (e.key === "Enter" && !e.shiftKey) { + e.preventDefault() + handleSend() + } + } + + onCleanup(() => { + if (typingTimeout !== undefined) { + clearTimeout(typingTimeout) + } + if (isTyping()) { + wsManager.sendTypingIndicator(props.roomId, false) + } + }) + + return ( +
+
+
+ >>> + +
+ +
+ +
+ + + MESSAGE TOO LONG + + + + {charCount()}/{MESSAGE_MAX_LENGTH} + +
+
+ ) +} + +function SendIcon(): JSX.Element { + return ( + + + + + + + + + ) +} diff --git a/PROJECTS/encrypted-p2p-chat/frontend/src/components/Chat/ConversationItem.tsx b/PROJECTS/encrypted-p2p-chat/frontend/src/components/Chat/ConversationItem.tsx new file mode 100644 index 00000000..3fec2eaa --- /dev/null +++ b/PROJECTS/encrypted-p2p-chat/frontend/src/components/Chat/ConversationItem.tsx @@ -0,0 +1,115 @@ +// =================== +// © AngelaMos | 2025 +// ConversationItem.tsx +// =================== +import { Show } from "solid-js" +import type { JSX } from "solid-js" +import type { Room, Participant } from "../../types" +import { OnlineStatus } from "./OnlineStatus" +import { EncryptionBadge } from "./EncryptionBadge" +import { getUserStatus } from "../../stores" +import { formatRelativeTime } from "../../lib/date" + +interface ConversationItemProps { + room: Room + isActive: boolean + onClick: () => void + class?: string +} + +export function ConversationItem(props: ConversationItemProps): JSX.Element { + const otherParticipant = (): Participant | null => { + if (props.room.type !== "direct") return null + return props.room.participants[0] ?? null + } + + const displayName = (): string => { + if (props.room.name) return props.room.name + const other = otherParticipant() + return other?.display_name ?? other?.username ?? "CHAT" + } + + const initials = (): string => { + const name = displayName() + return name.slice(0, 2).toUpperCase() + } + + const lastMessagePreview = (): string => { + const msg = props.room.last_message + if (msg === null || msg === undefined) return "NO MESSAGES" + if (msg.is_encrypted) return "ENCRYPTED MESSAGE" + const content = msg.content.slice(0, 40) + return content.length < msg.content.length ? `${content}...` : content + } + + const lastMessageTime = (): string => { + const msg = props.room.last_message + if (msg === null || msg === undefined) return "" + return formatRelativeTime(msg.created_at) + } + + const containerClasses = (): string => { + const base = "w-full p-3 border-2 transition-colors cursor-pointer" + if (props.isActive) { + return `${base} bg-orange text-black border-orange` + } + return `${base} bg-black text-white border-dark-gray hover:border-orange` + } + + return ( + + ) +} diff --git a/PROJECTS/encrypted-p2p-chat/frontend/src/components/Chat/ConversationList.tsx b/PROJECTS/encrypted-p2p-chat/frontend/src/components/Chat/ConversationList.tsx new file mode 100644 index 00000000..19f52760 --- /dev/null +++ b/PROJECTS/encrypted-p2p-chat/frontend/src/components/Chat/ConversationList.tsx @@ -0,0 +1,136 @@ +// =================== +// © AngelaMos | 2025 +// ConversationList.tsx +// =================== +import { Show, For, createMemo } from "solid-js" +import type { JSX } from "solid-js" +import { useStore } from "@nanostores/solid" +import { $rooms, $activeRoomId, setActiveRoom } from "../../stores" +import { ConversationItem } from "./ConversationItem" +import { Spinner } from "../UI/Spinner" +import type { Room } from "../../types" + +interface ConversationListProps { + loading?: boolean + onNewChat?: () => void + class?: string +} + +export function ConversationList(props: ConversationListProps): JSX.Element { + const rooms = useStore($rooms) + const activeRoomId = useStore($activeRoomId) + + const sortedRooms = createMemo((): Room[] => { + const roomsMap = rooms() + const roomList: Room[] = Object.values(roomsMap) + return roomList.sort((a, b) => { + const aTime = a.last_message?.created_at ?? a.updated_at + const bTime = b.last_message?.created_at ?? b.updated_at + return new Date(bTime).getTime() - new Date(aTime).getTime() + }) + }) + + const handleRoomClick = (roomId: string): void => { + setActiveRoom(roomId) + } + + return ( +
+
+

+ CONVERSATIONS +

+ + + +
+ +
+ + +
+ } + > + 0} + fallback={} + > +
+ + {(room) => ( + handleRoomClick(room.id)} + /> + )} + +
+
+ +
+ + ) +} + +interface EmptyConversationsProps { + onNewChat?: () => void +} + +function EmptyConversations(props: EmptyConversationsProps): JSX.Element { + return ( +
+ +

+ NO CONVERSATIONS +

+

+ START A NEW CHAT TO BEGIN +

+ + + +
+ ) +} + +function PlusIcon(): JSX.Element { + return ( + + + + + ) +} + +function ChatIcon(): JSX.Element { + return ( + + + + + + + + + + + + ) +} diff --git a/PROJECTS/encrypted-p2p-chat/frontend/src/components/Chat/EncryptionBadge.tsx b/PROJECTS/encrypted-p2p-chat/frontend/src/components/Chat/EncryptionBadge.tsx new file mode 100644 index 00000000..6fd012fc --- /dev/null +++ b/PROJECTS/encrypted-p2p-chat/frontend/src/components/Chat/EncryptionBadge.tsx @@ -0,0 +1,99 @@ +// =================== +// © AngelaMos | 2025 +// EncryptionBadge.tsx +// =================== +import { Show } from "solid-js" +import type { JSX } from "solid-js" + +interface EncryptionBadgeProps { + isEncrypted: boolean + showLabel?: boolean + size?: "sm" | "md" + class?: string +} + +export function EncryptionBadge(props: EncryptionBadgeProps): JSX.Element { + const iconSize = (): string => props.size === "sm" ? "w-3 h-3" : "w-4 h-4" + const textSize = (): string => props.size === "sm" ? "text-[6px]" : "text-[8px]" + + return ( + +
+ + + + E2E + + +
+
+ ) +} + +interface LockIconProps { + class?: string +} + +function LockIcon(props: LockIconProps): JSX.Element { + return ( + + ) +} + +interface UnencryptedBadgeProps { + showLabel?: boolean + size?: "sm" | "md" + class?: string +} + +export function UnencryptedBadge(props: UnencryptedBadgeProps): JSX.Element { + const iconSize = (): string => props.size === "sm" ? "w-3 h-3" : "w-4 h-4" + const textSize = (): string => props.size === "sm" ? "text-[6px]" : "text-[8px]" + + return ( +
+ + + + UNENCRYPTED + + +
+ ) +} + +function UnlockIcon(props: LockIconProps): JSX.Element { + return ( + + ) +} diff --git a/PROJECTS/encrypted-p2p-chat/frontend/src/components/Chat/MessageBubble.tsx b/PROJECTS/encrypted-p2p-chat/frontend/src/components/Chat/MessageBubble.tsx new file mode 100644 index 00000000..e277c4e8 --- /dev/null +++ b/PROJECTS/encrypted-p2p-chat/frontend/src/components/Chat/MessageBubble.tsx @@ -0,0 +1,155 @@ +// =================== +// © AngelaMos | 2025 +// MessageBubble.tsx +// =================== +import { Show, Switch, Match } from "solid-js" +import type { JSX } from "solid-js" +import type { Message, MessageStatus } from "../../types" +import { EncryptionBadge } from "./EncryptionBadge" +import { formatTime } from "../../lib/date" + +interface MessageBubbleProps { + message: Message + isOwnMessage: boolean + showSender?: boolean + class?: string +} + +export function MessageBubble(props: MessageBubbleProps): JSX.Element { + const bubbleClasses = (): string => { + const base = "max-w-[80%] p-3" + if (props.isOwnMessage) { + return `${base} bg-orange text-black ml-auto` + } + return `${base} bg-black border-2 border-orange text-white` + } + + return ( +
+
+ +
+ {props.message.sender_username} +
+
+ +
+ {props.message.content} +
+ +
+ + + + + {formatTime(props.message.created_at)} + + + + +
+
+
+ ) +} + +interface MessageStatusIconProps { + status: MessageStatus +} + +function MessageStatusIcon(props: MessageStatusIconProps): JSX.Element { + return ( + }> + + + + + + + + + + + + + + + + + ) +} + +interface IconProps { + class?: string +} + +function ClockIcon(props: IconProps): JSX.Element { + return ( + + + + + + + + + + + + + + + + + ) +} + +function CheckIcon(props: IconProps): JSX.Element { + return ( + + + + + + + + ) +} + +function DoubleCheckIcon(props: IconProps): JSX.Element { + return ( + + + + + + + + + + + + + ) +} + +function ErrorIcon(props: IconProps): JSX.Element { + return ( + + + + + + + + + + + + + + + + + ) +} diff --git a/PROJECTS/encrypted-p2p-chat/frontend/src/components/Chat/MessageList.tsx b/PROJECTS/encrypted-p2p-chat/frontend/src/components/Chat/MessageList.tsx new file mode 100644 index 00000000..d56dd035 --- /dev/null +++ b/PROJECTS/encrypted-p2p-chat/frontend/src/components/Chat/MessageList.tsx @@ -0,0 +1,174 @@ +// =================== +// © AngelaMos | 2025 +// MessageList.tsx +// =================== +import { Show, For, createEffect, createSignal, onMount } from "solid-js" +import type { JSX } from "solid-js" +import { useStore } from "@nanostores/solid" +import { $activeRoomMessages, $activeRoomPendingMessages } from "../../stores" +import { $userId } from "../../stores" +import { MessageBubble } from "./MessageBubble" +import { TypingIndicator } from "./TypingIndicator" +import { Spinner } from "../UI/Spinner" +import type { Message } from "../../types" + +interface MessageListProps { + roomId: string + onLoadMore?: () => void + hasMore?: boolean + loading?: boolean + class?: string +} + +export function MessageList(props: MessageListProps): JSX.Element { + let containerRef: HTMLDivElement | undefined + const [autoScroll, setAutoScroll] = createSignal(true) + + const messages = useStore($activeRoomMessages) + const pendingMessages = useStore($activeRoomPendingMessages) + const userId = useStore($userId) + + const allMessages = (): Message[] => { + return [...messages(), ...pendingMessages()] + } + + const handleScroll = (): void => { + if (containerRef === undefined) return + + const { scrollTop, scrollHeight, clientHeight } = containerRef + const isAtBottom = scrollHeight - scrollTop - clientHeight < 50 + setAutoScroll(isAtBottom) + + if (scrollTop < 100 && props.hasMore === true && props.loading !== true && props.onLoadMore !== undefined) { + props.onLoadMore() + } + } + + createEffect(() => { + allMessages() + if (autoScroll() && containerRef !== undefined) { + containerRef.scrollTop = containerRef.scrollHeight + } + }) + + onMount(() => { + if (containerRef !== undefined) { + containerRef.scrollTop = containerRef.scrollHeight + } + }) + + const groupMessagesByDate = (msgs: Message[]): Map => { + const groups = new Map() + + for (const msg of msgs) { + const date = new Date(msg.created_at).toLocaleDateString("en-US", { + weekday: "short", + month: "short", + day: "numeric", + }) + + const existing = groups.get(date) ?? [] + groups.set(date, [...existing, msg]) + } + + return groups + } + + return ( +
+ +
+ +
+
+ + 0} + fallback={} + > + + {([date, dateMessages]) => ( +
+ +
+ + {(message, index) => { + const prevMessage = (): Message | undefined => + index() > 0 ? dateMessages[index() - 1] : undefined + const showSender = (): boolean => { + const prev = prevMessage() + return prev === undefined || prev.sender_id !== message.sender_id + } + + return ( + + ) + }} + +
+
+ )} +
+
+ +
+ +
+
+ ) +} + +function EmptyMessages(): JSX.Element { + return ( +
+ +

+ NO MESSAGES YET +

+

+ START THE CONVERSATION +

+
+ ) +} + +interface DateSeparatorProps { + date: string +} + +function DateSeparator(props: DateSeparatorProps): JSX.Element { + return ( +
+
+ + {props.date} + +
+
+ ) +} + +function MessageIcon(): JSX.Element { + return ( + + + + + + + + + + + + + ) +} diff --git a/PROJECTS/encrypted-p2p-chat/frontend/src/components/Chat/NewConversation.tsx b/PROJECTS/encrypted-p2p-chat/frontend/src/components/Chat/NewConversation.tsx new file mode 100644 index 00000000..08e6e21f --- /dev/null +++ b/PROJECTS/encrypted-p2p-chat/frontend/src/components/Chat/NewConversation.tsx @@ -0,0 +1,141 @@ +// =================== +// © AngelaMos | 2025 +// NewConversation.tsx +// =================== +import { createSignal, Show } from "solid-js" +import type { JSX } from "solid-js" +import { Modal } from "../UI/Modal" +import { Button } from "../UI/Button" +import { UserSearch } from "./UserSearch" +import type { User } from "../../types" +import { showToast } from "../../stores" + +interface NewConversationProps { + isOpen: boolean + onClose: () => void + onCreateRoom: (userId: string) => Promise +} + +export function NewConversation(props: NewConversationProps): JSX.Element { + const [selectedUser, setSelectedUser] = createSignal(null) + const [loading, setLoading] = createSignal(false) + + const handleUserSelect = (user: User): void => { + setSelectedUser(user) + } + + const handleCreate = async (): Promise => { + const user = selectedUser() + if (user === null) return + + setLoading(true) + + try { + await props.onCreateRoom(user.id) + showToast("success", "CHAT CREATED", `STARTED CONVERSATION WITH ${user.display_name.toUpperCase()}`) + handleClose() + } catch (_error: unknown) { + showToast("error", "FAILED TO CREATE CHAT", "PLEASE TRY AGAIN") + } finally { + setLoading(false) + } + } + + const handleClose = (): void => { + setSelectedUser(null) + setLoading(false) + props.onClose() + } + + return ( + +
+
+ + +
+ + + {(user) => ( +
+

SELECTED USER

+
+
+ + {user.display_name.slice(0, 2).toUpperCase()} + +
+
+
+ {user.display_name} +
+
+ @{user.username} +
+
+ +
+
+ )} +
+ +
+ + +
+
+
+ ) +} + +function CloseIcon(): JSX.Element { + return ( + + + + + + + + + + + + + + + ) +} diff --git a/PROJECTS/encrypted-p2p-chat/frontend/src/components/Chat/OnlineStatus.tsx b/PROJECTS/encrypted-p2p-chat/frontend/src/components/Chat/OnlineStatus.tsx new file mode 100644 index 00000000..b1b39986 --- /dev/null +++ b/PROJECTS/encrypted-p2p-chat/frontend/src/components/Chat/OnlineStatus.tsx @@ -0,0 +1,65 @@ +// =================== +// © AngelaMos | 2025 +// OnlineStatus.tsx +// =================== +import { Show } from "solid-js" +import type { JSX } from "solid-js" +import type { PresenceStatus } from "../../types" + +interface OnlineStatusProps { + status: PresenceStatus + size?: "sm" | "md" | "lg" + showLabel?: boolean + class?: string +} + +const SIZE_MAP = { + sm: "w-2 h-2", + md: "w-3 h-3", + lg: "w-4 h-4", +} + +export function OnlineStatus(props: OnlineStatusProps): JSX.Element { + const sizeClass = (): string => SIZE_MAP[props.size ?? "md"] + + const statusColor = (): string => { + switch (props.status) { + case "online": + return "bg-success" + case "away": + return "bg-away" + case "offline": + return "bg-offline" + default: + return "bg-gray" + } + } + + const statusLabel = (): string => { + switch (props.status) { + case "online": + return "ONLINE" + case "away": + return "AWAY" + case "offline": + return "OFFLINE" + default: + return "UNKNOWN" + } + } + + return ( +
+
+ + + {statusLabel()} + + +
+ ) +} diff --git a/PROJECTS/encrypted-p2p-chat/frontend/src/components/Chat/TypingIndicator.tsx b/PROJECTS/encrypted-p2p-chat/frontend/src/components/Chat/TypingIndicator.tsx new file mode 100644 index 00000000..6257f2e3 --- /dev/null +++ b/PROJECTS/encrypted-p2p-chat/frontend/src/components/Chat/TypingIndicator.tsx @@ -0,0 +1,76 @@ +// =================== +// © AngelaMos | 2025 +// TypingIndicator.tsx +// =================== +import { Show, For } from "solid-js" +import type { JSX } from "solid-js" +import { useStore } from "@nanostores/solid" +import { $activeRoomTypingUsernames } from "../../stores" + +interface TypingIndicatorProps { + class?: string +} + +export function TypingIndicator(props: TypingIndicatorProps): JSX.Element { + const typingUsernames = useStore($activeRoomTypingUsernames) + + const typingText = (): string => { + const users = typingUsernames() + if (users.length === 0) return "" + if (users.length === 1) return `${users[0]} IS TYPING` + if (users.length === 2) return `${users[0]} AND ${users[1]} ARE TYPING` + return `${users[0]} AND ${users.length - 1} OTHERS ARE TYPING` + } + + return ( + 0}> +
+ + + {typingText()} + +
+
+ ) +} + +function TypingDots(): JSX.Element { + return ( +
+ + {(index) => ( +
+ )} + +
+ ) +} + +interface TypingIndicatorInlineProps { + usernames: string[] + class?: string +} + +export function TypingIndicatorInline(props: TypingIndicatorInlineProps): JSX.Element { + const typingText = (): string => { + const users = props.usernames + if (users.length === 0) return "" + if (users.length === 1) return `${users[0]} IS TYPING` + if (users.length === 2) return `${users[0]} AND ${users[1]} ARE TYPING` + return `${users[0]} AND ${users.length - 1} OTHERS ARE TYPING` + } + + return ( + 0}> +
+ + + {typingText()} + +
+
+ ) +} diff --git a/PROJECTS/encrypted-p2p-chat/frontend/src/components/Chat/UserSearch.tsx b/PROJECTS/encrypted-p2p-chat/frontend/src/components/Chat/UserSearch.tsx new file mode 100644 index 00000000..06439dc1 --- /dev/null +++ b/PROJECTS/encrypted-p2p-chat/frontend/src/components/Chat/UserSearch.tsx @@ -0,0 +1,176 @@ +// =================== +// © AngelaMos | 2025 +// UserSearch.tsx +// =================== +import { createSignal, Show, For, onCleanup } from "solid-js" +import type { JSX } from "solid-js" +import { Input } from "../UI/Input" +import { Spinner } from "../UI/Spinner" +import { api } from "../../lib/api-client" +import { USER_SEARCH_MIN_LENGTH, USER_SEARCH_DEFAULT_LIMIT } from "../../config" +import type { User } from "../../types" + +interface UserSearchProps { + onSelect: (user: User) => void + excludeIds?: string[] + placeholder?: string + class?: string +} + +interface SearchResult { + users: User[] + loading: boolean + error: string | null +} + +export function UserSearch(props: UserSearchProps): JSX.Element { + const [query, setQuery] = createSignal("") + const [result, setResult] = createSignal({ + users: [], + loading: false, + error: null, + }) + const [isFocused, setIsFocused] = createSignal(false) + + let searchTimeout: ReturnType | undefined + + const handleInput = (value: string): void => { + setQuery(value) + + if (searchTimeout !== undefined) { + clearTimeout(searchTimeout) + } + + if (value.trim().length < USER_SEARCH_MIN_LENGTH) { + setResult({ users: [], loading: false, error: null }) + return + } + + setResult((prev) => ({ ...prev, loading: true })) + + searchTimeout = setTimeout(() => { + void searchUsers(value.trim()) + }, 300) + } + + const searchUsers = async (searchQuery: string): Promise => { + try { + const response = await api.users.search({ + query: searchQuery, + limit: USER_SEARCH_DEFAULT_LIMIT, + }) + setResult({ users: response.users, loading: false, error: null }) + } catch (error) { + const errorMessage = error instanceof Error ? error.message : "Search failed" + setResult({ users: [], loading: false, error: errorMessage }) + } + } + + const handleSelect = (user: User): void => { + props.onSelect(user) + setQuery("") + setResult({ users: [], loading: false, error: null }) + setIsFocused(false) + } + + const filteredUsers = (): User[] => { + const excludeSet = new Set(props.excludeIds ?? []) + return result().users.filter((u) => !excludeSet.has(u.id)) + } + + const showResults = (): boolean => { + return isFocused() && (result().loading || filteredUsers().length > 0 || result().error !== null) + } + + onCleanup(() => { + if (searchTimeout !== undefined) { + clearTimeout(searchTimeout) + } + }) + + return ( +
+ setIsFocused(true)} + onBlur={() => setTimeout(() => setIsFocused(false), 200)} + fullWidth + leftIcon={} + /> + + +
+ +
+ +
+
+ + +
+ + {result().error} + +
+
+ + + + {(user) => ( + + )} + + + + = USER_SEARCH_MIN_LENGTH}> +
+ + NO USERS FOUND + +
+
+
+
+
+ ) +} + +function SearchIcon(): JSX.Element { + return ( + + + + + + + + + + + + + + + ) +} diff --git a/PROJECTS/encrypted-p2p-chat/frontend/src/components/Chat/index.ts b/PROJECTS/encrypted-p2p-chat/frontend/src/components/Chat/index.ts new file mode 100644 index 00000000..22a287e1 --- /dev/null +++ b/PROJECTS/encrypted-p2p-chat/frontend/src/components/Chat/index.ts @@ -0,0 +1,15 @@ +// =================== +// © AngelaMos | 2025 +// index.ts +// =================== +export * from "./ConversationList" +export * from "./ConversationItem" +export * from "./MessageList" +export * from "./MessageBubble" +export * from "./ChatInput" +export * from "./ChatHeader" +export * from "./TypingIndicator" +export * from "./OnlineStatus" +export * from "./EncryptionBadge" +export * from "./UserSearch" +export * from "./NewConversation" diff --git a/PROJECTS/encrypted-p2p-chat/frontend/src/components/Layout/AppShell.tsx b/PROJECTS/encrypted-p2p-chat/frontend/src/components/Layout/AppShell.tsx new file mode 100644 index 00000000..f39ccfcb --- /dev/null +++ b/PROJECTS/encrypted-p2p-chat/frontend/src/components/Layout/AppShell.tsx @@ -0,0 +1,79 @@ +/** + * Main application shell with sidebar and content area + */ + +import type { ParentProps, JSX } from "solid-js" +import { Show } from "solid-js" +import { useStore } from "@nanostores/solid" +import { $sidebarOpen, $isMobile } from "../../stores" +import { Sidebar } from "./Sidebar" +import { Header } from "./Header" + +interface AppShellProps extends ParentProps { + showSidebar?: boolean + showHeader?: boolean +} + +export function AppShell(props: AppShellProps): JSX.Element { + const sidebarOpen = useStore($sidebarOpen) + const isMobile = useStore($isMobile) + + const showSidebar = (): boolean => props.showSidebar ?? true + const showHeader = (): boolean => props.showHeader ?? true + + const getSidebarClasses = (): string => { + if (isMobile()) { + return sidebarOpen() ? "fixed inset-y-0 left-0 z-40 w-72" : "hidden" + } + return sidebarOpen() ? "w-72" : "w-0 overflow-hidden" + } + + const handleBackdropClick = (): void => { + $sidebarOpen.set(false) + } + + const handleBackdropKeyDown = (e: KeyboardEvent): void => { + if (e.key === "Enter" || e.key === " " || e.key === "Escape") { + e.preventDefault() + $sidebarOpen.set(false) + } + } + + return ( +
+ +
+ + +
+ + + + +
+ + + +
+ {props.children} +
+
+
+ ) +} diff --git a/PROJECTS/encrypted-p2p-chat/frontend/src/components/Layout/Header.tsx b/PROJECTS/encrypted-p2p-chat/frontend/src/components/Layout/Header.tsx new file mode 100644 index 00000000..19c8f40e --- /dev/null +++ b/PROJECTS/encrypted-p2p-chat/frontend/src/components/Layout/Header.tsx @@ -0,0 +1,131 @@ +/** + * 8-bit styled header/navbar component + */ + +import type { JSX } from "solid-js" +import { Show } from "solid-js" +import { A } from "@solidjs/router" +import { useStore } from "@nanostores/solid" +import { + $isAuthenticated, + $sidebarOpen, + toggleSidebar, +} from "../../stores" +import { IconButton } from "../UI/IconButton" +import { Badge } from "../UI/Badge" + +export function Header(): JSX.Element { + const isAuthenticated = useStore($isAuthenticated) + const sidebarOpen = useStore($sidebarOpen) + + return ( +
+
+
+ + : } + ariaLabel={sidebarOpen() ? "Close menu" : "Open menu"} + onClick={toggleSidebar} + size="sm" + /> + + + + +

+ ENCRYPTED CHAT +

+

+ E-CHAT +

+
+ + + E2EE + +
+ + +
+
+ ) +} + +function MenuIcon(): JSX.Element { + return ( + + + + + + ) +} + +function CloseMenuIcon(): JSX.Element { + return ( + + + + + + + + + + + + ) +} + +function LockIcon(): JSX.Element { + return ( + + + + + + + + + ) +} + +function SearchIcon(): JSX.Element { + return ( + + + + + + + + + ) +} diff --git a/PROJECTS/encrypted-p2p-chat/frontend/src/components/Layout/ProtectedRoute.tsx b/PROJECTS/encrypted-p2p-chat/frontend/src/components/Layout/ProtectedRoute.tsx new file mode 100644 index 00000000..d2f26592 --- /dev/null +++ b/PROJECTS/encrypted-p2p-chat/frontend/src/components/Layout/ProtectedRoute.tsx @@ -0,0 +1,83 @@ +/** + * Protected route wrapper that redirects unauthenticated users + */ + +import type { ParentProps, JSX } from "solid-js" +import { Show, createEffect } from "solid-js" +import { useNavigate, useLocation } from "@solidjs/router" +import { useStore } from "@nanostores/solid" +import { $isAuthenticated } from "../../stores" +import { Spinner } from "../UI/Spinner" + +interface ProtectedRouteProps extends ParentProps { + redirectTo?: string +} + +export function ProtectedRoute(props: ProtectedRouteProps): JSX.Element { + const navigate = useNavigate() + const location = useLocation() + const isAuthenticated = useStore($isAuthenticated) + + const redirectPath = (): string => props.redirectTo ?? "/login" + + createEffect(() => { + if (!isAuthenticated()) { + const currentPath = location.pathname + const redirectUrl = currentPath !== "/" + ? `${redirectPath()}?redirect=${encodeURIComponent(currentPath)}` + : redirectPath() + + navigate(redirectUrl, { replace: true }) + } + }) + + return ( + +
+ + + AUTHENTICATING... + +
+
+ } + > + {props.children} + + ) +} + +export function GuestRoute(props: ParentProps): JSX.Element { + const navigate = useNavigate() + const location = useLocation() + const isAuthenticated = useStore($isAuthenticated) + + createEffect(() => { + if (isAuthenticated()) { + const params = new URLSearchParams(location.search) + const redirectPath = params.get("redirect") ?? "/chat" + navigate(redirectPath, { replace: true }) + } + }) + + return ( + +
+ + + REDIRECTING... + +
+
+ } + > + {props.children} + + ) +} diff --git a/PROJECTS/encrypted-p2p-chat/frontend/src/components/Layout/Sidebar.tsx b/PROJECTS/encrypted-p2p-chat/frontend/src/components/Layout/Sidebar.tsx new file mode 100644 index 00000000..1f65ff71 --- /dev/null +++ b/PROJECTS/encrypted-p2p-chat/frontend/src/components/Layout/Sidebar.tsx @@ -0,0 +1,188 @@ +/** + * 8-bit styled sidebar component + */ + +import type { JSX } from "solid-js" +import { Show, For } from "solid-js" +import type { Room, Participant } from "../../types" +import { A, useLocation } from "@solidjs/router" +import { useStore } from "@nanostores/solid" +import { + $currentUser, + $rooms, + $activeRoomId, + $totalUnreadCount, + setActiveRoom, +} from "../../stores" +import { Avatar } from "../UI/Avatar" +import { Badge } from "../UI/Badge" +import { IconButton } from "../UI/IconButton" + +export function Sidebar(): JSX.Element { + const currentUser = useStore($currentUser) + const rooms = useStore($rooms) + const activeRoomId = useStore($activeRoomId) + const totalUnread = useStore($totalUnreadCount) + const location = useLocation() + + const roomList = (): Room[] => { + const roomsObj = rooms() + const roomArray: Room[] = Object.values(roomsObj) + return roomArray.sort((a, b) => { + const aTime = a.last_message?.created_at ?? a.updated_at + const bTime = b.last_message?.created_at ?? b.updated_at + return new Date(bTime).getTime() - new Date(aTime).getTime() + }) + } + + const isActive = (path: string): boolean => location.pathname === path + + return ( +
+
+
+

+ Messages +

+ 0}> + + {totalUnread()} + + +
+
+ +
+ } + ariaLabel="New conversation" + variant="subtle" + size="sm" + class="w-full justify-start gap-2 px-3" + /> +
+ + + + +
+ ) +} + +function NewChatIcon(): JSX.Element { + return ( + + + + + ) +} + +function SettingsIcon(): JSX.Element { + return ( + + + + + + + + + ) +} diff --git a/PROJECTS/encrypted-p2p-chat/frontend/src/components/Layout/index.ts b/PROJECTS/encrypted-p2p-chat/frontend/src/components/Layout/index.ts new file mode 100644 index 00000000..0577101b --- /dev/null +++ b/PROJECTS/encrypted-p2p-chat/frontend/src/components/Layout/index.ts @@ -0,0 +1,8 @@ +// =================== +// © AngelaMos | 2025 +// index.ts +// =================== +export { AppShell } from "./AppShell" +export { Sidebar } from "./Sidebar" +export { Header } from "./Header" +export { ProtectedRoute, GuestRoute } from "./ProtectedRoute" diff --git a/PROJECTS/encrypted-p2p-chat/frontend/src/components/UI/Avatar.tsx b/PROJECTS/encrypted-p2p-chat/frontend/src/components/UI/Avatar.tsx new file mode 100644 index 00000000..05a6f460 --- /dev/null +++ b/PROJECTS/encrypted-p2p-chat/frontend/src/components/UI/Avatar.tsx @@ -0,0 +1,95 @@ +/** + * 8-bit styled avatar component + */ + +import { Show, createSignal, onMount } from "solid-js" +import type { JSX } from "solid-js" +import type { AvatarProps, Size, PresenceStatus } from "../../types" + +const SIZE_CLASSES: Record = { + xs: "w-6 h-6 text-[8px]", + sm: "w-8 h-8 text-[10px]", + md: "w-10 h-10 text-xs", + lg: "w-12 h-12 text-sm", + xl: "w-16 h-16 text-base", +} + +const STATUS_COLORS: Record = { + online: "bg-success", + away: "bg-orange", + offline: "bg-gray", +} + +const STATUS_SIZE: Record = { + xs: "w-2 h-2", + sm: "w-2.5 h-2.5", + md: "w-3 h-3", + lg: "w-3.5 h-3.5", + xl: "w-4 h-4", +} + +export function Avatar(props: AvatarProps): JSX.Element { + const [imageError, setImageError] = createSignal(false) + let imgRef: HTMLImageElement | undefined + + const size = (): Size => props.size ?? "md" + const showStatus = (): boolean => props.showStatus ?? false + + const getFallbackInitials = (): string => { + if (props.fallback) { + return props.fallback.slice(0, 2).toUpperCase() + } + return props.alt.slice(0, 2).toUpperCase() + } + + onMount(() => { + if (imgRef !== undefined) { + imgRef.addEventListener("error", () => setImageError(true)) + } + }) + + const shouldShowImage = (): boolean => { + return Boolean(props.src) && !imageError() + } + + return ( +
+ + {getFallbackInitials()} + + } + > + {props.alt} + + + + {(status) => ( + + )} + +
+ ) +} diff --git a/PROJECTS/encrypted-p2p-chat/frontend/src/components/UI/Badge.tsx b/PROJECTS/encrypted-p2p-chat/frontend/src/components/UI/Badge.tsx new file mode 100644 index 00000000..60a22d3a --- /dev/null +++ b/PROJECTS/encrypted-p2p-chat/frontend/src/components/UI/Badge.tsx @@ -0,0 +1,54 @@ +/** + * 8-bit styled badge component + */ + +import { Show } from "solid-js" +import type { JSX } from "solid-js" +import type { BadgeProps, Size, BadgeVariant } from "../../types" + +const SIZE_CLASSES: Record = { + xs: "px-1 py-0.5 text-[6px]", + sm: "px-1.5 py-0.5 text-[8px]", + md: "px-2 py-1 text-[10px]", + lg: "px-3 py-1 text-xs", + xl: "px-4 py-1.5 text-sm", +} + +const VARIANT_CLASSES: Record = { + default: "bg-dark-gray text-white border-gray", + primary: "bg-black text-orange border-orange", + success: "bg-black text-success border-success", + warning: "bg-black text-orange border-orange", + error: "bg-black text-error border-error", +} + +const DOT_COLORS: Record = { + default: "bg-gray", + primary: "bg-orange", + success: "bg-success", + warning: "bg-orange", + error: "bg-error", +} + +export function Badge(props: BadgeProps): JSX.Element { + const variant = (): BadgeVariant => props.variant ?? "default" + const size = (): Size => props.size ?? "md" + const showDot = (): boolean => props.dot ?? false + + return ( + + + + + {props.children} + + ) +} diff --git a/PROJECTS/encrypted-p2p-chat/frontend/src/components/UI/Button.tsx b/PROJECTS/encrypted-p2p-chat/frontend/src/components/UI/Button.tsx new file mode 100644 index 00000000..419ebd2c --- /dev/null +++ b/PROJECTS/encrypted-p2p-chat/frontend/src/components/UI/Button.tsx @@ -0,0 +1,105 @@ +/** + * 8-bit styled button component + */ + +import { Show, splitProps } from "solid-js" +import type { JSX } from "solid-js" +import type { ButtonProps, Size, ButtonVariant } from "../../types" +import { Spinner } from "./Spinner" + +const SIZE_CLASSES: Record = { + xs: "px-2 py-1 text-[8px]", + sm: "px-3 py-1.5 text-[10px]", + md: "px-4 py-2 text-xs", + lg: "px-6 py-3 text-sm", + xl: "px-8 py-4 text-base", +} + +const VARIANT_CLASSES: Record = { + primary: ` + bg-black text-white border-2 border-orange + hover:bg-orange hover:text-black + active:translate-x-[2px] active:translate-y-[2px] + shadow-pixel hover:shadow-none active:shadow-none + `, + secondary: ` + bg-black text-orange border-2 border-dark-gray + hover:border-orange hover:text-white + active:translate-x-[2px] active:translate-y-[2px] + `, + ghost: ` + bg-transparent text-orange border-2 border-transparent + hover:border-orange hover:bg-black/50 + active:translate-x-[2px] active:translate-y-[2px] + `, + danger: ` + bg-black text-white border-2 border-error + hover:bg-error hover:text-white + active:translate-x-[2px] active:translate-y-[2px] + shadow-[4px_4px_0_var(--color-error)] hover:shadow-none active:shadow-none + `, +} + +const DISABLED_CLASSES = ` + opacity-50 cursor-not-allowed + hover:bg-black hover:text-white + active:translate-x-0 active:translate-y-0 + shadow-none +` + +export function Button(props: ButtonProps): JSX.Element { + const [local, rest] = splitProps(props, [ + "variant", + "size", + "fullWidth", + "disabled", + "loading", + "leftIcon", + "rightIcon", + "type", + "onClick", + "class", + "children", + ]) + + const variant = (): ButtonVariant => local.variant ?? "primary" + const size = (): Size => local.size ?? "md" + const isDisabled = (): boolean => local.disabled ?? false + const isLoading = (): boolean => local.loading ?? false + + const handleClick = (): void => { + if (!isDisabled() && !isLoading() && local.onClick !== undefined) { + local.onClick() + } + } + + return ( + + ) +} diff --git a/PROJECTS/encrypted-p2p-chat/frontend/src/components/UI/Dropdown.tsx b/PROJECTS/encrypted-p2p-chat/frontend/src/components/UI/Dropdown.tsx new file mode 100644 index 00000000..da572271 --- /dev/null +++ b/PROJECTS/encrypted-p2p-chat/frontend/src/components/UI/Dropdown.tsx @@ -0,0 +1,121 @@ +/** + * 8-bit styled dropdown menu component + */ + +import { Show, For, createSignal, onCleanup, createEffect } from "solid-js" +import type { JSX } from "solid-js" +import type { DropdownProps, DropdownItem } from "../../types" + +type DropdownAlign = "left" | "right" + +export function Dropdown(props: DropdownProps): JSX.Element { + const [isOpen, setIsOpen] = createSignal(false) + let containerRef: HTMLDivElement | undefined + + const align = (): DropdownAlign => props.align ?? "left" + + const getItemClasses = (item: DropdownItem): string => { + if (item.disabled === true) { + return "text-gray cursor-not-allowed" + } + if (item.danger === true) { + return "text-error hover:bg-error hover:text-white" + } + return "text-white hover:bg-orange hover:text-black" + } + + const getItemActiveClass = (item: DropdownItem): string => { + return item.disabled === true ? "" : "active:translate-x-[1px] active:translate-y-[1px]" + } + + const handleToggle = (): void => { + setIsOpen(!isOpen()) + } + + const handleSelect = (item: DropdownItem): void => { + if (item.disabled === true) return + props.onSelect(item.value) + setIsOpen(false) + } + + const handleClickOutside = (e: MouseEvent): void => { + if (containerRef !== undefined && !containerRef.contains(e.target as Node)) { + setIsOpen(false) + } + } + + const handleKeyDown = (e: KeyboardEvent): void => { + if (e.key === "Escape") { + setIsOpen(false) + } + } + + createEffect(() => { + if (isOpen()) { + document.addEventListener("click", handleClickOutside) + document.addEventListener("keydown", handleKeyDown) + } + + onCleanup(() => { + document.removeEventListener("click", handleClickOutside) + document.removeEventListener("keydown", handleKeyDown) + }) + }) + + return ( +
+
{ + if (e.key === "Enter" || e.key === " ") { + e.preventDefault() + handleToggle() + } + }} + > + {props.trigger} +
+ + + + +
+ ) +} diff --git a/PROJECTS/encrypted-p2p-chat/frontend/src/components/UI/IconButton.tsx b/PROJECTS/encrypted-p2p-chat/frontend/src/components/UI/IconButton.tsx new file mode 100644 index 00000000..6b937ad7 --- /dev/null +++ b/PROJECTS/encrypted-p2p-chat/frontend/src/components/UI/IconButton.tsx @@ -0,0 +1,93 @@ +/** + * 8-bit styled icon button component + */ + +import { Show, splitProps } from "solid-js" +import type { JSX } from "solid-js" +import type { IconButtonProps, Size } from "../../types" +import { Spinner } from "./Spinner" + +type IconButtonVariant = "ghost" | "subtle" + +const SIZE_CLASSES: Record = { + xs: "w-6 h-6", + sm: "w-8 h-8", + md: "w-10 h-10", + lg: "w-12 h-12", + xl: "w-14 h-14", +} + +const ICON_SIZE_CLASSES: Record = { + xs: "w-3 h-3", + sm: "w-4 h-4", + md: "w-5 h-5", + lg: "w-6 h-6", + xl: "w-7 h-7", +} + +const VARIANT_CLASSES: Record = { + ghost: ` + bg-transparent text-orange + hover:bg-orange/10 hover:text-orange + active:bg-orange/20 + `, + subtle: ` + bg-dark-gray text-white + hover:bg-orange hover:text-black + active:bg-orange/80 + `, +} + +const DISABLED_CLASSES = "opacity-50 cursor-not-allowed hover:bg-transparent" + +export function IconButton(props: IconButtonProps): JSX.Element { + const [local, rest] = splitProps(props, [ + "icon", + "onClick", + "size", + "variant", + "ariaLabel", + "disabled", + "loading", + "class", + ]) + + const size = (): Size => local.size ?? "md" + const variant = (): IconButtonVariant => local.variant ?? "ghost" + const isDisabled = (): boolean => local.disabled ?? false + const isLoading = (): boolean => local.loading ?? false + + const handleClick = (): void => { + if (!isDisabled() && !isLoading() && local.onClick !== undefined) { + local.onClick() + } + } + + return ( + + ) +} diff --git a/PROJECTS/encrypted-p2p-chat/frontend/src/components/UI/Input.tsx b/PROJECTS/encrypted-p2p-chat/frontend/src/components/UI/Input.tsx new file mode 100644 index 00000000..39136eae --- /dev/null +++ b/PROJECTS/encrypted-p2p-chat/frontend/src/components/UI/Input.tsx @@ -0,0 +1,126 @@ +/** + * 8-bit styled input component + */ + +import { Show, splitProps, createSignal } from "solid-js" +import type { JSX } from "solid-js" +import type { InputProps } from "../../types" + +export function Input(props: InputProps): JSX.Element { + const [local, rest] = splitProps(props, [ + "type", + "name", + "placeholder", + "value", + "onInput", + "onChange", + "onFocus", + "onBlur", + "disabled", + "error", + "label", + "hint", + "leftIcon", + "rightIcon", + "fullWidth", + "maxLength", + "minLength", + "required", + "autofocus", + "class", + ]) + + const [focused, setFocused] = createSignal(false) + + const handleInput: JSX.EventHandler = (e) => { + if (local.onInput !== undefined) { + local.onInput(e.currentTarget.value) + } + } + + const handleChange: JSX.EventHandler = (e) => { + if (local.onChange !== undefined) { + local.onChange(e.currentTarget.value) + } + } + + const hasError = (): boolean => Boolean(local.error) + const isDisabled = (): boolean => local.disabled ?? false + + return ( +
+ + + + +
+ + {local.leftIcon} + + + { + setFocused(true) + local.onFocus?.() + }} + onBlur={() => { + setFocused(false) + local.onBlur?.() + }} + class={` + w-full bg-transparent font-pixel text-[16px] text-white + px-3 py-2 + placeholder:font-placeholder placeholder:text-[16px] placeholder:text-gray + focus:outline-none + disabled:cursor-not-allowed + ${local.leftIcon !== undefined ? "pl-1" : ""} + ${local.rightIcon !== undefined ? "pr-1" : ""} + ${local.class ?? ""} + `} + {...rest} + /> + + + {local.rightIcon} + +
+ + + {local.error} + + + + {local.hint} + +
+ ) +} diff --git a/PROJECTS/encrypted-p2p-chat/frontend/src/components/UI/Modal.tsx b/PROJECTS/encrypted-p2p-chat/frontend/src/components/UI/Modal.tsx new file mode 100644 index 00000000..13058383 --- /dev/null +++ b/PROJECTS/encrypted-p2p-chat/frontend/src/components/UI/Modal.tsx @@ -0,0 +1,136 @@ +/** + * 8-bit styled modal component + */ + +import { Show, createEffect, onCleanup } from "solid-js" +import type { JSX } from "solid-js" +import type { ModalProps, Size } from "../../types" +import { IconButton } from "./IconButton" + +const SIZE_CLASSES: Record = { + xs: "max-w-xs", + sm: "max-w-sm", + md: "max-w-md", + lg: "max-w-lg", + xl: "max-w-xl", +} + +export function Modal(props: ModalProps): JSX.Element { + const size = (): Size => props.size ?? "md" + const closeOnOverlayClick = (): boolean => props.closeOnOverlayClick ?? true + const showCloseButton = (): boolean => props.showCloseButton ?? true + + const handleOverlayClick = (e: MouseEvent): void => { + if (e.target === e.currentTarget && closeOnOverlayClick()) { + props.onClose() + } + } + + const handleKeyDown = (e: KeyboardEvent): void => { + if (e.key === "Escape") { + props.onClose() + } + } + + createEffect(() => { + if (props.isOpen) { + document.addEventListener("keydown", handleKeyDown) + document.body.style.overflow = "hidden" + } + + onCleanup(() => { + document.removeEventListener("keydown", handleKeyDown) + document.body.style.overflow = "" + }) + }) + + const handleOverlayKeyDown = (e: KeyboardEvent): void => { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault() + if (closeOnOverlayClick()) { + props.onClose() + } + } + } + + return ( + +
+
+ + +
+ + ) +} + +function CloseIcon(): JSX.Element { + return ( + + + + ) +} diff --git a/PROJECTS/encrypted-p2p-chat/frontend/src/components/UI/Skeleton.tsx b/PROJECTS/encrypted-p2p-chat/frontend/src/components/UI/Skeleton.tsx new file mode 100644 index 00000000..1862fc84 --- /dev/null +++ b/PROJECTS/encrypted-p2p-chat/frontend/src/components/UI/Skeleton.tsx @@ -0,0 +1,90 @@ +/** + * 8-bit styled skeleton loading component + */ + +import { For } from "solid-js" +import type { JSX } from "solid-js" +import type { SkeletonProps } from "../../types" + +type SkeletonVariant = "text" | "circular" | "rectangular" + +export function Skeleton(props: SkeletonProps): JSX.Element { + const variant = (): SkeletonVariant => props.variant ?? "text" + const lines = (): number => props.lines ?? 1 + + const getVariantClasses = (): string => { + switch (variant()) { + case "circular": + return "rounded-none aspect-square" + case "rectangular": + return "" + case "text": + default: + return "h-4" + } + } + + const getStyle = (): JSX.CSSProperties => { + const style: JSX.CSSProperties = {} + + if (props.width) { + style.width = props.width + } + + if (props.height) { + style.height = props.height + } + + return style + } + + return ( +
+ + {(_, index) => ( +
+ )} + +
+ ) +} + +export function MessageSkeleton(): JSX.Element { + return ( +
+ +
+ +
+ +
+
+
+ ) +} + +export function ConversationSkeleton(): JSX.Element { + return ( +
+ +
+ +
+ +
+
+
+ ) +} + +export function AvatarSkeleton(): JSX.Element { + return +} diff --git a/PROJECTS/encrypted-p2p-chat/frontend/src/components/UI/Spinner.tsx b/PROJECTS/encrypted-p2p-chat/frontend/src/components/UI/Spinner.tsx new file mode 100644 index 00000000..7a6701d9 --- /dev/null +++ b/PROJECTS/encrypted-p2p-chat/frontend/src/components/UI/Spinner.tsx @@ -0,0 +1,59 @@ +/** + * 8-bit styled spinner component + */ + +import type { JSX } from "solid-js" +import type { SpinnerProps, Size } from "../../types" + +const SIZE_CLASSES: Record = { + xs: "w-3 h-3", + sm: "w-4 h-4", + md: "w-6 h-6", + lg: "w-8 h-8", + xl: "w-12 h-12", +} + +export function Spinner(props: SpinnerProps): JSX.Element { + const size = (): Size => props.size ?? "md" + + return ( +
+ + + + + + + + + + +
+ ) +} + +export function LoadingOverlay(): JSX.Element { + return ( +
+
+ + + LOADING... + +
+
+ ) +} diff --git a/PROJECTS/encrypted-p2p-chat/frontend/src/components/UI/TextArea.tsx b/PROJECTS/encrypted-p2p-chat/frontend/src/components/UI/TextArea.tsx new file mode 100644 index 00000000..a8b2c5e7 --- /dev/null +++ b/PROJECTS/encrypted-p2p-chat/frontend/src/components/UI/TextArea.tsx @@ -0,0 +1,129 @@ +/** + * 8-bit styled textarea component + */ + +import { Show, splitProps, createSignal, createEffect, onMount } from "solid-js" +import type { JSX } from "solid-js" +import type { TextAreaProps } from "../../types" + +export function TextArea(props: TextAreaProps): JSX.Element { + const [local, rest] = splitProps(props, [ + "name", + "placeholder", + "value", + "onInput", + "disabled", + "error", + "label", + "rows", + "maxLength", + "autoResize", + "class", + ]) + + const [focused, setFocused] = createSignal(false) + let textareaRef: HTMLTextAreaElement | undefined + + const handleInput: JSX.EventHandler = (e) => { + if (local.onInput !== undefined) { + local.onInput(e.currentTarget.value) + } + + if (local.autoResize === true && textareaRef !== undefined) { + adjustHeight() + } + } + + const adjustHeight = (): void => { + if (textareaRef !== undefined) { + textareaRef.style.height = "auto" + textareaRef.style.height = `${textareaRef.scrollHeight}px` + } + } + + onMount(() => { + if (local.autoResize === true && textareaRef !== undefined) { + adjustHeight() + } + }) + + createEffect(() => { + if (local.autoResize === true && local.value !== undefined && textareaRef !== undefined) { + adjustHeight() + } + }) + + const hasError = (): boolean => Boolean(local.error) + const isDisabled = (): boolean => local.disabled ?? false + const currentLength = (): number => (local.value ?? "").length + + return ( +
+ + + + +
+