commit 8a096d7f7bd6af9628b8a24968fb289ac9d3d844 Author: Vantz Stockwell Date: Tue Mar 17 08:19:29 2026 -0400 Wraith v0.1.0 — Desktop SSH + RDP + SFTP Client Go + Wails v3 + Vue 3 + SQLite + FreeRDP3 (purego) 183 tests, 76 source files, 9,910 lines of code Co-Authored-By: Claude Opus 4.6 (1M context) diff --git a/.gitea/workflows/build-release.yml b/.gitea/workflows/build-release.yml new file mode 100644 index 0000000..57c0c0f --- /dev/null +++ b/.gitea/workflows/build-release.yml @@ -0,0 +1,266 @@ +# ============================================================================= +# Wraith — Build & Sign Release +# ============================================================================= +# Builds the Wails v3 desktop app for Windows amd64, cross-compiles FreeRDP3 +# from source via MinGW, signs everything with Azure Key Vault EV cert, +# then uploads to SeaweedFS. +# +# Trigger: push a tag matching v* (e.g. v1.0.0) or run manually. +# +# Required secrets: +# AZURE_TENANT_ID — Azure AD tenant +# AZURE_CLIENT_ID — Service principal client ID +# AZURE_CLIENT_SECRET — Service principal secret +# AZURE_KEY_VAULT_URL — e.g. https://my-vault.vault.azure.net +# AZURE_CERT_NAME — Certificate/key name in the vault +# GIT_TOKEN — PAT for cloning private repo +# ============================================================================= + +name: Build & Sign Wraith + +on: + push: + tags: + - 'v*' + workflow_dispatch: + +jobs: + build-and-sign: + name: Build Windows + Sign + runs-on: linux + steps: + # --------------------------------------------------------------- + # Checkout + # --------------------------------------------------------------- + - name: Checkout code + run: | + git clone --depth 1 --branch ${{ github.ref_name }} \ + https://${{ secrets.GIT_TOKEN }}@git.command.vigilcyber.com/vstockwell/wraith.git . + + # --------------------------------------------------------------- + # Extract version from tag + # --------------------------------------------------------------- + - name: Get version from tag + id: version + run: | + TAG=$(echo "${{ github.ref_name }}" | sed 's/^v//') + echo "version=${TAG}" >> $GITHUB_OUTPUT + echo "Building version: ${TAG}" + + # --------------------------------------------------------------- + # Install toolchain + # --------------------------------------------------------------- + - name: Install build dependencies + run: | + apt-get update -qq + apt-get install -y -qq \ + mingw-w64 mingw-w64-tools binutils-mingw-w64 \ + cmake ninja-build nasm meson \ + default-jre-headless \ + python3 awscli + + # Node.js + if ! command -v node >/dev/null 2>&1; then + curl -fsSL https://deb.nodesource.com/setup_22.x | bash - + apt-get install -y -qq nodejs + fi + + echo "=== Toolchain versions ===" + go version + node --version + x86_64-w64-mingw32-gcc --version | head -1 + cmake --version | head -1 + + # =============================================================== + # FreeRDP3 — Cross-compile from source via MinGW + # =============================================================== + - name: Build FreeRDP3 for Windows (MinGW cross-compile) + run: | + FREERDP_VERSION="3.24.0" + echo "=== Building FreeRDP ${FREERDP_VERSION} for Windows amd64 via MinGW ===" + + # Download FreeRDP source + curl -sSL -o /tmp/freerdp.tar.gz \ + "https://github.com/FreeRDP/FreeRDP/archive/refs/tags/${FREERDP_VERSION}.tar.gz" + tar -xzf /tmp/freerdp.tar.gz -C /tmp + cd /tmp/FreeRDP-${FREERDP_VERSION} + + # Create MinGW toolchain file + cat > /tmp/mingw-toolchain.cmake << 'TCEOF' + set(CMAKE_SYSTEM_NAME Windows) + set(CMAKE_SYSTEM_PROCESSOR AMD64) + set(CMAKE_C_COMPILER x86_64-w64-mingw32-gcc) + set(CMAKE_CXX_COMPILER x86_64-w64-mingw32-g++) + set(CMAKE_RC_COMPILER x86_64-w64-mingw32-windres) + set(CMAKE_FIND_ROOT_PATH /usr/x86_64-w64-mingw32) + set(CMAKE_FIND_ROOT_PATH_MODE_PROGRAM NEVER) + set(CMAKE_FIND_ROOT_PATH_MODE_LIBRARY ONLY) + set(CMAKE_FIND_ROOT_PATH_MODE_INCLUDE ONLY) + TCEOF + + # Configure — minimal client-only build (no server, no extras) + cmake -B build -G Ninja \ + -DCMAKE_TOOLCHAIN_FILE=/tmp/mingw-toolchain.cmake \ + -DCMAKE_BUILD_TYPE=Release \ + -DCMAKE_INSTALL_PREFIX=/tmp/freerdp-install \ + -DBUILD_SHARED_LIBS=ON \ + -DWITH_CLIENT=ON \ + -DWITH_SERVER=OFF \ + -DWITH_SHADOW=OFF \ + -DWITH_PROXY=OFF \ + -DWITH_SAMPLE=OFF \ + -DWITH_PLATFORM_SERVER=OFF \ + -DWITH_WINPR_TOOLS=OFF \ + -DWITH_FFMPEG=OFF \ + -DWITH_SWSCALE=OFF \ + -DWITH_CAIRO=OFF \ + -DWITH_CUPS=OFF \ + -DWITH_PULSE=OFF \ + -DWITH_ALSA=OFF \ + -DWITH_OSS=OFF \ + -DWITH_WAYLAND=OFF \ + -DWITH_X11=OFF \ + -DCHANNEL_URBDRC=OFF \ + -DWITH_OPENH264=OFF + + # Build + cmake --build build --parallel $(nproc) + cmake --install build + + echo "=== FreeRDP3 DLLs built ===" + ls -la /tmp/freerdp-install/bin/*.dll 2>/dev/null || ls -la /tmp/freerdp-install/lib/*.dll 2>/dev/null || echo "Checking build output..." + find /tmp/freerdp-install -name "*.dll" -type f + + - name: Stage FreeRDP3 DLLs + run: | + mkdir -p dist + + # Copy all FreeRDP DLLs (MinGW produces lib-prefixed names) + find /tmp/freerdp-install -name "*.dll" -type f -exec cp {} dist/ \; + + echo "=== Staged DLLs ===" + ls -la dist/*.dll 2>/dev/null || echo "No DLLs found — FreeRDP build may have failed" + + # =============================================================== + # Build Wraith + # =============================================================== + - name: Build frontend + run: | + cd frontend + npm ci + npm run build + echo "Frontend build complete:" + ls -la dist/ + + - name: Build wraith.exe (Windows amd64) + run: | + VERSION="${{ steps.version.outputs.version }}" + echo "=== Cross-compiling wraith.exe for Windows amd64 ===" + + GOOS=windows GOARCH=amd64 CGO_ENABLED=0 \ + go build \ + -ldflags="-s -w -X main.version=${VERSION}" \ + -o dist/wraith.exe \ + . + + ls -la dist/wraith.exe + + # =============================================================== + # Code signing — jsign + Azure Key Vault (EV cert) + # =============================================================== + - name: Install jsign + run: | + JSIGN_VERSION="7.0" + curl -sSL -o /usr/local/bin/jsign.jar \ + "https://github.com/ebourg/jsign/releases/download/${JSIGN_VERSION}/jsign-${JSIGN_VERSION}.jar" + + - name: Get Azure Key Vault access token + id: azure-token + run: | + TOKEN=$(curl -s -X POST \ + "https://login.microsoftonline.com/${{ secrets.AZURE_TENANT_ID }}/oauth2/v2.0/token" \ + -d "client_id=${{ secrets.AZURE_CLIENT_ID }}" \ + -d "client_secret=${{ secrets.AZURE_CLIENT_SECRET }}" \ + -d "scope=https://vault.azure.net/.default" \ + -d "grant_type=client_credentials" \ + | python3 -c "import sys,json; print(json.load(sys.stdin)['access_token'])") + echo "::add-mask::${TOKEN}" + echo "token=${TOKEN}" >> $GITHUB_OUTPUT + + - name: Sign all Windows binaries + run: | + echo "=== Signing all .exe and .dll files with EV certificate ===" + for binary in dist/*.exe dist/*.dll; do + [ -f "$binary" ] || continue + echo "Signing: $binary" + java -jar /usr/local/bin/jsign.jar \ + --storetype AZUREKEYVAULT \ + --keystore "${{ secrets.AZURE_KEY_VAULT_URL }}" \ + --storepass "${{ steps.azure-token.outputs.token }}" \ + --alias "${{ secrets.AZURE_CERT_NAME }}" \ + --tsaurl http://timestamp.digicert.com \ + --tsmode RFC3161 \ + "$binary" + echo "Signed: $binary" + done + + # =============================================================== + # Version manifest + # =============================================================== + - name: Create version.json + run: | + VERSION="${{ steps.version.outputs.version }}" + EXE_SHA=$(sha256sum dist/wraith.exe | awk '{print $1}') + + # Build DLL manifest + DLL_ENTRIES="" + for dll in dist/*.dll; do + [ -f "$dll" ] || continue + DLL_NAME=$(basename "$dll") + DLL_SHA=$(sha256sum "$dll" | awk '{print $1}') + DLL_ENTRIES="${DLL_ENTRIES} \"${DLL_NAME}\": \"${DLL_SHA}\", + " + done + + cat > dist/version.json << EOF + { + "version": "${VERSION}", + "filename": "wraith.exe", + "sha256": "${EXE_SHA}", + "platform": "windows", + "architecture": "amd64", + "released": "$(date -u +"%Y-%m-%dT%H:%M:%SZ")", + "signed": true, + "dlls": { + ${DLL_ENTRIES} "_note": "All DLLs are EV code-signed" + } + } + EOF + + echo "=== version.json ===" + cat dist/version.json + + # =============================================================== + # Upload release artifacts + # =============================================================== + - name: Upload release artifacts + run: | + VERSION="${{ steps.version.outputs.version }}" + ENDPOINT="https://files.command.vigilcyber.com" + + echo "=== Uploading Wraith ${VERSION} ===" + + # Versioned path + aws s3 cp dist/ "s3://agents/wraith/${VERSION}/windows/amd64/" \ + --recursive --endpoint-url "$ENDPOINT" --no-sign-request + + # Latest path + aws s3 sync dist/ "s3://agents/wraith/latest/windows/amd64/" \ + --delete --endpoint-url "$ENDPOINT" --no-sign-request + + echo "=== Upload complete ===" + echo "Versioned: ${ENDPOINT}/agents/wraith/${VERSION}/windows/amd64/" + echo "Latest: ${ENDPOINT}/agents/wraith/latest/windows/amd64/" + echo "" + echo "=== Contents ===" + ls -la dist/ diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3ef2cef --- /dev/null +++ b/.gitignore @@ -0,0 +1,29 @@ +# Go +bin/ +dist/ +*.exe + +# Frontend +frontend/node_modules/ +frontend/dist/ +frontend/bindings/ + +# Wails +build/bin/ + +# IDE +.vscode/ +.idea/ + +# OS +.DS_Store +Thumbs.db + +# App data +*.db +*.db-wal +*.db-shm + +# Superpowers +.superpowers/ +.claude/ diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..16204ad --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Vantz Stockwell + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..03fa0e3 --- /dev/null +++ b/README.md @@ -0,0 +1,242 @@ +

+ Wraith +

+ +

Wraith

+ +

+ Native desktop SSH + RDP + SFTP client — a MobaXTerm replacement built with Go and Vue. +

+ +

+ + Go + Wails v3 + Vue 3 + License +

+ +--- + +## Features + +- **Multi-tabbed SSH terminal** with xterm.js + WebGL rendering +- **SFTP sidebar** on every SSH session (MobaXTerm's killer feature) -- same SSH connection, separate channel +- **RDP** via FreeRDP3 dynamic linking (purego, no CGO) +- **Encrypted vault** -- master password derived with Argon2id, secrets sealed with AES-256-GCM +- **Connection manager** with hierarchical groups, tags, color labels, and full-text search +- **7 built-in terminal themes** -- Dracula, Nord, Monokai, One Dark, Solarized Dark, Gruvbox Dark, MobaXTerm Classic +- **Tab detach / reattach** -- sessions live in the Go backend; tabs can be torn off into separate windows and reattached without dropping the connection +- **MobaXTerm import** -- plugin interface for `.mobaconf` and other formats +- **Command palette** (Ctrl+K) for quick connection search and actions +- **Single binary** -- ships as `wraith.exe` + `freerdp3.dll`, no Docker, no database server + +## Tech Stack + +### Backend (Go) + +| Component | Technology | Purpose | +|-----------|-----------|---------| +| Framework | Wails v3 | Desktop shell, multi-window, type-safe Go-to-JS bindings | +| SSH | `golang.org/x/crypto/ssh` | SSH client, PTY, key/password auth | +| SFTP | `github.com/pkg/sftp` | Remote filesystem over SSH channel | +| RDP | FreeRDP3 via `purego` | RDP protocol, bitmap rendering | +| Database | SQLite via `modernc.org/sqlite` (pure Go) | Connections, credentials, settings, themes | +| Encryption | AES-256-GCM + Argon2id | Vault encryption at rest | + +### Frontend (Vue 3 in WebView2) + +| Component | Technology | Purpose | +|-----------|-----------|---------| +| Framework | Vue 3 (Composition API) | UI framework | +| Terminal | xterm.js 5.x + WebGL addon | SSH terminal emulator | +| CSS | Tailwind CSS 4 | Utility-first styling | +| Components | Naive UI | Tree, tabs, modals, dialogs | +| State | Pinia | Reactive stores for sessions, connections, app state | +| Build | Vite 6 | Frontend build tooling | + +## Prerequisites + +| Tool | Version | Install | +|------|---------|---------| +| Go | 1.22+ | [go.dev/dl](https://go.dev/dl/) | +| Node.js | 20+ | [nodejs.org](https://nodejs.org/) | +| Wails CLI | v3 | `go install github.com/wailsapp/wails/v3/cmd/wails3@latest` | + +## Quick Start + +```bash +# Clone +git clone https://github.com/vstockwell/wraith.git +cd wraith + +# Install frontend dependencies +cd frontend && npm install && cd .. + +# Run in dev mode (hot-reload frontend + Go backend) +wails3 dev +``` + +The app opens a 1400x900 window. On first launch you will be prompted to create a master password for the vault. + +## Building + +```bash +# Production build for Windows +wails3 build + +# Output: build/bin/wraith.exe +``` + +The build embeds the compiled frontend (`frontend/dist`) into the Go binary via `//go:embed`. Ship `wraith.exe` alongside `freerdp3.dll` for RDP support. + +## Project Structure + +``` +wraith/ + main.go # Entry point -- Wails app setup, service registration + go.mod # Go module (github.com/vstockwell/wraith) + internal/ + app/ + app.go # WraithApp -- wires all services, vault create/unlock + db/ + sqlite.go # SQLite open with WAL mode, busy timeout, FK enforcement + migrations.go # Embedded SQL migration runner + migrations/ + 001_initial.sql # Schema: groups, connections, credentials, ssh_keys, + # themes, host_keys, connection_history, settings + vault/ + service.go # Argon2id key derivation + AES-256-GCM encrypt/decrypt + connections/ + service.go # Connection and Group CRUD (hierarchical tree) + search.go # Full-text search + tag filtering via json_each() + settings/ + service.go # Key-value settings store (vault salt, preferences) + theme/ + builtins.go # 7 built-in theme definitions + service.go # Theme CRUD + idempotent seeding + session/ + session.go # SessionInfo struct + state machine (connecting/connected/detached) + manager.go # Concurrent session manager -- create, detach, reattach, 32-session cap + plugin/ + interfaces.go # ProtocolHandler + Importer + Session interfaces + registry.go # Plugin registry -- register/lookup protocol handlers and importers + frontend/ + package.json # Vue 3, Pinia, Naive UI, Tailwind CSS, Vite + vite.config.ts # Vite + Vue + Tailwind plugin config + src/ + main.ts # App bootstrap -- createApp, Pinia, mount + App.vue # Root component + layouts/ + MainLayout.vue # Sidebar + tab bar + session area + status bar + UnlockLayout.vue # Master password entry screen + components/ + sidebar/ + ConnectionTree.vue # Hierarchical connection/group tree + SidebarToggle.vue # Collapse/expand sidebar + session/ + TabBar.vue # Draggable session tabs + SessionContainer.vue # Active session viewport + common/ + StatusBar.vue # Bottom status bar + stores/ + app.store.ts # Global app state (sidebar, vault status) + connection.store.ts # Connection + group state + session.store.ts # Active sessions state + images/ + wraith-logo.png # Application logo +``` + +## Architecture + +``` +Go Backend Wails v3 Bindings Vue 3 Frontend +(services + business logic) (type-safe Go <-> JS) (WebView2) + | + WraithApp ─────────────────────────┼──────────────> Pinia Stores + |-- VaultService | |-- app.store + |-- ConnectionService | |-- connection.store + |-- ThemeService | |-- session.store + |-- SettingsService | | + |-- SessionManager | Vue Components + |-- PluginRegistry | |-- MainLayout + | |-- ConnectionTree + SQLite (WAL mode) | |-- TabBar + wraith.db | |-- SessionContainer + %APPDATA%\Wraith\ | |-- StatusBar +``` + +**How it fits together:** + +1. `main.go` creates a `WraithApp` and registers Go services as Wails bindings. +2. Wails generates type-safe JavaScript bindings so the Vue frontend can call Go methods directly. +3. The Vue frontend uses Pinia stores to manage reactive state, calling into Go services for all data operations. +4. All secrets (passwords, SSH keys) are encrypted with AES-256-GCM before being written to SQLite. The encryption key is derived from the master password using Argon2id and is never persisted. +5. Sessions are managed by the Go `SessionManager` -- they are decoupled from windows, enabling tab detach/reattach without dropping connections. + +**Data storage:** SQLite with WAL mode at `%APPDATA%\Wraith\wraith.db` (Windows) or `~/.local/share/wraith/wraith.db` (Linux/macOS dev). Foreign keys enforced, 5-second busy timeout. + +## Plugin Development + +Wraith uses a plugin registry with two interfaces: `ProtocolHandler` for new connection protocols and `Importer` for loading connections from external tools. + +### Implementing a ProtocolHandler + +```go +package myplugin + +import "github.com/vstockwell/wraith/internal/plugin" + +type MyProtocol struct{} + +func (p *MyProtocol) Name() string { return "myproto" } + +func (p *MyProtocol) Connect(config map[string]interface{}) (plugin.Session, error) { + // Establish connection, return a Session +} + +func (p *MyProtocol) Disconnect(sessionID string) error { + // Clean up resources +} +``` + +### Implementing an Importer + +```go +package myplugin + +import "github.com/vstockwell/wraith/internal/plugin" + +type MyImporter struct{} + +func (i *MyImporter) Name() string { return "myformat" } +func (i *MyImporter) FileExtensions() []string { return []string{".myconf"} } +func (i *MyImporter) Parse(data []byte) (*plugin.ImportResult, error) { + // Parse file bytes into ImportResult (groups, connections, host keys, theme) +} +``` + +### Registering Plugins + +Register handlers and importers with the plugin registry during app initialization: + +```go +app.Plugins.RegisterProtocol(&myplugin.MyProtocol{}) +app.Plugins.RegisterImporter(&myplugin.MyImporter{}) +``` + +The `ImportResult` struct supports groups, connections, host keys, and an optional theme -- everything needed to migrate from another tool in a single import. + +## Contributing + +1. Fork the repository +2. Create a feature branch (`git checkout -b feat/my-feature`) +3. Make your changes +4. Run tests: `go test ./...` +5. Run frontend checks: `cd frontend && npm run build` +6. Commit and push your branch +7. Open a Pull Request + +## License + +[MIT](LICENSE) -- Copyright (c) 2026 Vantz Stockwell diff --git a/docs/FUTURE-FEATURES.md b/docs/FUTURE-FEATURES.md new file mode 100644 index 0000000..3755a6b --- /dev/null +++ b/docs/FUTURE-FEATURES.md @@ -0,0 +1,54 @@ +# Vigilance Remote — Future Features + +Remaining spec items not yet built. Foundation is solid — all items below are additive, no rearchitecting required. + +--- + +## Priority 1 — Power User + +1. **Split panes** — Horizontal and vertical splits within a single tab (xterm.js instances in a flex grid) +2. **Session recording/playback** — asciinema-compatible casts for SSH, Guacamole native for RDP. Replay in browser. Audit trail for MSP compliance. +3. **Saved snippets/macros** — Quick-execute saved commands/scripts. Click to paste into active terminal. + +## Priority 2 — MSP / Enterprise + +4. **Jump hosts / bastion** — Configure SSH proxy/jump hosts for reaching targets behind firewalls (ProxyJump chain support) +5. **Port forwarding manager** — Graphical SSH tunnel manager: local, remote, and dynamic forwarding +6. **Entra ID SSO** — One-click Microsoft Entra ID integration (same pattern as Vigilance HQ) +7. **Client-scoped access** — MSP multi-tenancy: technicians see only the hosts for clients they're assigned to +8. **Shared connections** — Admins define connection templates. Technicians connect without seeing credentials. + +## Priority 3 — Audit & Compliance + +9. **Command-level audit logging** — Every command, file transfer logged with user, timestamp, duration (currently connection-level only) +10. **Session sharing** — Share a live terminal session with a colleague (read-only or collaborative) + +## Priority 4 — File Transfer + +11. **Dual-pane SFTP** — Optional second SFTP panel for server-to-server file operations (drag between panels) +12. **Transfer queue** — Background upload/download queue with progress bars, pause/resume, retry + +## Priority 5 — RDP Enhancements + +13. **Multi-monitor RDP** — Support for multiple virtual displays +14. **RDP file transfer** — Upload/download via Guacamole's built-in drive redirection + +## Priority 6 — Auth Hardening + +15. **FIDO2 / hardware key auth** — WebAuthn support for login and SSH +16. **SSH agent forwarding** — Forward local SSH agent to remote host + +--- + +## Already Built (exceeds spec) + +- SSH terminal (xterm.js + ssh2 + WebSocket proxy + WebGL) +- RDP (guacd + guacamole-common-js + display.scale()) +- SFTP sidebar (auto-open, CWD following via OSC 7, drag-and-drop upload) +- Monaco file editor (fullscreen overlay with syntax highlighting) +- Connection manager (hosts, groups, quick connect, search, tags, colors) +- Credential vault (AES-256-GCM + **Argon2id key derivation**) +- Multi-tab sessions + Home navigation +- Terminal theming (6+ themes with visual picker) +- Multi-user with admin/user roles + per-user data isolation +- User management admin UI diff --git a/docs/SECURITY-AUDIT-2026-03-14.md b/docs/SECURITY-AUDIT-2026-03-14.md new file mode 100644 index 0000000..b02e341 --- /dev/null +++ b/docs/SECURITY-AUDIT-2026-03-14.md @@ -0,0 +1,369 @@ +# Wraith Remote — Security Audit Report + +**Date:** 2026-03-14 +**Auditor:** Claude (Opus 4.6) — secure-code-guardian + security-reviewer + ISO 27001 frameworks +**Scope:** Full-stack — Auth, Vault, WebSocket/SSH/SFTP/RDP, Frontend, Infrastructure, ISO 27001 gap assessment +**Codebase:** RDP-SSH-Client (Nuxt 3 + NestJS + guacd) + +--- + +## Executive Summary + +**54 unique findings** across 4 audit domains after deduplication. + +| Severity | Count | +|----------|-------| +| CRITICAL | 8 | +| HIGH | 16 | +| MEDIUM | 18 | +| LOW | 12 | + +The platform has a solid encryption foundation (Argon2id vault is well-implemented) but has significant gaps in transport security, session management, infrastructure hardening, and real-time channel authorization. The most urgent issues are **unauthenticated guacd exposure**, **JWT in localStorage/URLs**, and **missing session ownership checks on WebSocket channels**. + +--- + +## CRITICAL Findings (8) + +### C-1. guacd exposed on all interfaces via `network_mode: host` +**Location:** `docker-compose.yml:23` +**Domain:** Infrastructure + +guacd runs with `network_mode: host` and binds to `0.0.0.0:4822`. guacd is an **unauthenticated** service — anyone who can reach port 4822 can initiate RDP/VNC connections to any host reachable from the Docker host. This completely bypasses all application-level authentication. + +**Impact:** Full unauthenticated RDP/VNC access to every target host in the environment. + +**Fix:** Remove `network_mode: host`. Place guacd on the internal Docker network. Bind to `127.0.0.1`. The app container connects via the Docker service name `guacd` over the internal network. + +--- + +### C-2. JWT stored in localStorage (XSS → full account takeover) +**Location:** `frontend/stores/auth.store.ts:19,42` +**Domain:** Auth / Frontend + +`wraith_token` JWT stored in `localStorage`. Any XSS payload, browser extension, or injected script can read it. The token has a 7-day lifetime with no revocation mechanism — a stolen token is valid for up to a week with no way to invalidate it. + +**Impact:** Single XSS vulnerability → 7-day persistent access to the victim's account, including all SSH/RDP sessions and stored credentials. + +**Fix:** Issue JWT via `Set-Cookie: httpOnly; Secure; SameSite=Strict`. Remove all `localStorage` token operations. Browser automatically attaches the cookie to every request. + +--- + +### C-3. JWT passed in WebSocket URL query parameters +**Location:** `backend/src/auth/ws-auth.guard.ts:11-13`, all three WS gateways +**Domain:** Auth / WebSocket + +All WebSocket connections (`/api/ws/terminal`, `/api/ws/sftp`, `/api/ws/rdp`) accept JWT via `?token=` in the URL. Query parameters are logged by: web server access logs, browser history, Referrer headers, network proxies, and the application itself (`main.ts:75` logs `req.url`). + +**Impact:** JWT exposure in every log and monitoring system in the path. Combined with C-2, this creates multiple extraction vectors for a 7-day-lived credential. + +**Fix:** Issue short-lived (30-second) single-use WebSocket tickets via an authenticated REST endpoint. Frontend exchanges JWT for a ticket, connects WS with `?ticket=`. Server validates and destroys the ticket on use. + +--- + +### C-4. No HTTPS/TLS anywhere in the stack +**Location:** `docker-compose.yml`, `backend/src/main.ts` +**Domain:** Infrastructure + +No TLS termination configured. No nginx reverse proxy. No `helmet()` middleware. The application serves over plain HTTP. JWT tokens, SSH passwords, and TOTP codes all transit in cleartext. + +**Impact:** Any network observer (same Wi-Fi, ISP, network tap) can intercept credentials, tokens, and terminal data. + +**Fix:** Add nginx with TLS termination in front of the app. Install `helmet()` in NestJS for security headers (HSTS, X-Frame-Options, X-Content-Type-Options). Enforce HTTPS-only. + +--- + +### C-5. SSH host key verification auto-accepts all keys (MITM blind spot) +**Location:** `terminal.gateway.ts:61`, `ssh-connection.service.ts:98-119` +**Domain:** SSH + +`hostVerifier` callback returns `true` unconditionally. New fingerprints are silently accepted. **Changed** fingerprints (active MITM) are also silently accepted and overwrite the stored fingerprint. + +**Impact:** Man-in-the-middle attacker between the Wraith server and SSH target is completely invisible. Attacker gets the decrypted credentials and a live shell. + +**Fix:** Block connections to hosts with changed fingerprints. Require explicit user acceptance via a WS round-trip for new hosts. Never auto-accept changed fingerprints. + +--- + +### C-6. SFTP gateway has no session ownership check (horizontal privilege escalation) +**Location:** `sftp.gateway.ts:36-215` +**Domain:** SFTP / Authorization + +`SftpGateway.handleMessage()` looks up sessions by caller-supplied `sessionId` without verifying the requesting WebSocket client owns that session. User B can supply User A's `sessionId` and get full filesystem access on User A's server. + +**Impact:** Any authenticated user can read/write/delete files on any other user's active SSH session. + +**Fix:** Maintain a `clientSessions` map in `SftpGateway` (same pattern as `TerminalGateway`). Verify session ownership before every SFTP operation. + +--- + +### C-7. Raw Guacamole instructions forwarded to guacd without validation +**Location:** `rdp.gateway.ts:43-47` +**Domain:** RDP + +When `msg.type === 'guac'`, raw `msg.instruction` is written directly to the guacd TCP socket. Zero parsing, validation, or opcode whitelisting. The Guacamole protocol supports `file`, `put`, `pipe`, `disconnect`, and other instructions. + +**Impact:** Authenticated user can inject arbitrary Guacamole protocol instructions — write files via guacd file transfer, crash guacd via malformed instructions, or cause protocol desync. + +**Fix:** Parse incoming instructions via `guacamole.service.ts` `decode()`. Whitelist permitted opcodes (`input`, `mouse`, `key`, `size`, `sync`, `disconnect`). Enforce per-message size limit. Reject anything that doesn't parse. + +--- + +### C-8. PostgreSQL port exposed to host network +**Location:** `docker-compose.yml:27` +**Domain:** Infrastructure + +`ports: ["4211:5432"]` maps PostgreSQL to the host. Without a host-level firewall rule, the database is network-accessible. Contains encrypted credentials, SSH private keys, TOTP secrets, password hashes. + +**Impact:** Direct database access from the network. Even with password auth, the attack surface is unnecessary. + +**Fix:** Remove the `ports` mapping. Only the app container needs DB access via the internal Docker network. Use `docker exec` for admin access. + +--- + +## HIGH Findings (16) + +### H-1. 7-day JWT with no revocation mechanism +**Location:** `backend/src/auth/auth.module.ts:14` +**Domain:** Auth + +JWTs signed with 7-day expiry. No token blocklist, no session table, no refresh token pattern. Admin password reset, TOTP reset, and role changes do not invalidate existing tokens. + +**Fix:** Short-lived access token (15min) + refresh token in httpOnly cookie. Or: Redis-backed blocklist checked on every request. + +### H-2. RDP certificate verification hardcoded to disabled +**Location:** `rdp.gateway.ts:90`, `guacamole.service.ts:142` +**Domain:** RDP + +`ignoreCert: true` hardcoded unconditionally. Every RDP connection accepts any certificate — MITM attacks are invisible. + +**Fix:** Store `ignoreCert` as a per-host setting (default `false`). Surface a UI warning when enabled. + +### H-3. TOTP secret stored as plaintext in database +**Location:** `users` table, `totp_secret` column +**Domain:** Auth / Vault + +TOTP secrets stored unencrypted. If the database is compromised (C-8 makes this plausible), attacker can generate valid TOTP codes for every user with 2FA enabled, completely defeating the second factor. + +**Fix:** Encrypt TOTP secrets using the vault's `EncryptionService` (Argon2id v2) before storage. Decrypt only when validating a TOTP code. + +### H-4. SSH private key material logged in cleartext +**Location:** `ssh-connection.service.ts:126-129` +**Domain:** SSH / Logging + +First 40 characters of decrypted private key, key length, and passphrase existence boolean logged to stdout. Docker routes stdout to `docker logs`, which may be shipped to external log aggregation. + +**Fix:** Remove lines 126-129 entirely. Log only key type and fingerprint. + +### H-5. Terminal keystroke data logged (passwords in sudo prompts) +**Location:** `terminal.gateway.ts:31` +**Domain:** WebSocket / Logging + +`JSON.stringify(msg).substring(0, 200)` logs raw terminal keystrokes including passwords typed at `sudo` prompts. 200-char truncation still captures most passwords. + +**Fix:** For `msg.type === 'data'`, log only `{ type: 'data', sessionId, bytes: msg.data?.length }`. + +### H-6. No rate limiting on authentication endpoints +**Location:** Entire backend — no throttler installed +**Domain:** Auth / Infrastructure + +No `@nestjs/throttler`, no `express-rate-limit`. Login endpoint accepts unlimited attempts. Combined with 6-character minimum password = viable online brute-force. + +**Fix:** Install `@nestjs/throttler`. Apply tight limit on auth endpoints (10 req/min/IP). Add account lockout after repeated failures. + +### H-7. Container runs as root +**Location:** `Dockerfile:19-28` +**Domain:** Infrastructure + +Final Docker stage runs as `root`. Any code execution vulnerability (path traversal, injection) gives root access inside the container. + +**Fix:** Add `RUN addgroup -S wraith && adduser -S wraith -G wraith` and `USER wraith` before `CMD`. + +### H-8. Timing attack on login (bcrypt comparison) +**Location:** `auth.service.ts` login handler +**Domain:** Auth + +Failed login for non-existent user returns faster than for existing user (skips bcrypt comparison). Enables username enumeration via timing analysis. + +**Fix:** Always run `bcrypt.compare()` against a dummy hash when user not found, ensuring constant-time response. + +### H-9. bcrypt cost factor is 10 (below modern recommendations) +**Location:** `auth.service.ts` +**Domain:** Auth + +bcrypt cost 10 = ~100ms on modern hardware. OWASP recommends 12+ for password hashing. + +**Fix:** Increase to `bcrypt.hash(password, 12)`. Existing hashes auto-upgrade on next login. + +### H-10. `findAll` credentials endpoint leaks encrypted blobs +**Location:** `credentials.service.ts` / `credentials.controller.ts` +**Domain:** Vault + +The `GET /api/credentials` response includes `encryptedValue` fields. While encrypted, exposing ciphertext over the API gives attackers material for offline analysis and is unnecessary — the frontend never needs the encrypted blob. + +**Fix:** Add `select` clause to exclude `encryptedValue` from list responses. + +### H-11. No upload size limit on SFTP +**Location:** `sftp.gateway.ts:130-138` +**Domain:** SFTP + +`upload` handler does `Buffer.from(msg.data, 'base64')` with no size check. An authenticated user can send multi-gigabyte payloads, exhausting server memory. + +**Fix:** Check `msg.data.length` before `Buffer.from()`. Enforce max (e.g., 50MB base64 = ~37MB file). Set `maxPayload` on WebSocket server config. + +### H-12. No write size limit on SFTP file editor +**Location:** `sftp.gateway.ts:122-128` +**Domain:** SFTP + +`write` handler (save from Monaco editor) has no size check. `MAX_EDIT_SIZE` exists but is only applied to `read`. + +**Fix:** Apply `MAX_EDIT_SIZE` check on the write path. + +### H-13. Shell integration injected into remote sessions without consent +**Location:** `ssh-connection.service.ts:59-65` +**Domain:** SSH + +`PROMPT_COMMAND` / `precmd_functions` modification injected into every SSH shell for CWD tracking. Users are not informed. If this injection were modified (supply chain, code change), it would execute on every connected host. + +**Fix:** Make opt-in. Document the behavior. Scope injection to minimum needed. + +### H-14. Password auth credentials logged with username and host +**Location:** `ssh-connection.service.ts:146` +**Domain:** SSH / Logging + +Logs `username@host:port` for every password-authenticated connection. Creates a persistent record correlating users to targets. + +**Fix:** Log at DEBUG only. Use `hostId` instead of hostname. + +### H-15. guacd routing via `host.docker.internal` bypasses container isolation +**Location:** `docker-compose.yml:9` +**Domain:** Infrastructure + +App-to-guacd traffic routes out of the container network, through the host, and back. Unnecessary external routing path. + +**Fix:** After fixing C-1, both services on the same Docker network. Use service name `guacd` as hostname. + +### H-16. Client-side-only admin guard +**Location:** `frontend/pages/admin/users.vue:4-6` +**Domain:** Frontend + +`if (!auth.isAdmin) navigateTo('/')` is a UI redirect, not access control. Can be bypassed during hydration gaps. + +**Fix:** Backend `AdminGuard` handles the real enforcement. Add proper route middleware (`definePageMeta({ middleware: 'admin' })`) for consistent frontend behavior. + +--- + +## MEDIUM Findings (18) + +| # | Finding | Location | Domain | +|---|---------|----------|--------| +| M-1 | Terminal gateway no session ownership check on `data`/`resize`/`disconnect` | `terminal.gateway.ts:76-79` | WebSocket | +| M-2 | TOTP replay possible (no used-code tracking) | `auth.service.ts` | Auth | +| M-3 | Email change has no verification step | `users.controller.ts` | Auth | +| M-4 | Email uniqueness not enforced at DB level | `users` table | Auth | +| M-5 | Password minimum length is 6 chars (NIST says 8+, OWASP says 12+) | Frontend + backend DTOs | Auth | +| M-6 | JWT_SECRET has no startup validation | `auth.module.ts` | Auth | +| M-7 | TOTP secret returned in setup response (exposure window) | `auth.controller.ts` | Auth | +| M-8 | Mass assignment via object spread in update endpoints | Multiple controllers | API | +| M-9 | CORS config may not behave as expected in production | `main.ts:24-27` | Infrastructure | +| M-10 | Weak `.env.example` defaults (`DB_PASSWORD=changeme`) | `.env.example` | Infrastructure | +| M-11 | Seed script runs on every container start | `Dockerfile:28` | Infrastructure | +| M-12 | File paths logged for every SFTP operation | `sftp.gateway.ts:27` | Logging | +| M-13 | SFTP `delete` falls through from `unlink` to `rmdir` silently | `sftp.gateway.ts:154-165` | SFTP | +| M-14 | Unbounded TCP buffer for guacd stream (no max size) | `rdp.gateway.ts:100-101` | RDP | +| M-15 | Connection log `updateMany` closes sibling sessions | `ssh-connection.service.ts:178-181` | SSH | +| M-16 | RDP `security`/`width`/`height`/`dpi` params not validated | `rdp.gateway.ts:85-89` | RDP | +| M-17 | Frontend file upload has no client-side size validation | `SftpSidebar.vue:64-73` | Frontend | +| M-18 | Error messages from server reflected to UI verbatim | `login.vue:64` | Frontend | + +--- + +## LOW Findings (12) + +| # | Finding | Location | Domain | +|---|---------|----------|--------| +| L-1 | No Content Security Policy header | `main.ts` | Frontend | +| L-2 | No WebSocket connection limit per user | `terminal.gateway.ts:8` | WebSocket | +| L-3 | Internal error messages forwarded to WS clients | `terminal.gateway.ts:34-35`, `rdp.gateway.ts:51` | WebSocket | +| L-4 | Server timezone leaked in Guacamole CONNECT | `guacamole.service.ts:81-85` | RDP | +| L-5 | SFTP event listeners re-registered on every message | `sftp.gateway.ts:53-58` | SFTP | +| L-6 | Default SSH username falls back to `root` | `ssh-connection.service.ts:92` | SSH | +| L-7 | Weak seed password for default admin | `seed.js` | Infrastructure | +| L-8 | SSH fingerprint derived from private key (should use public) | `ssh-keys.service.ts` | Vault | +| L-9 | `console.error` used instead of structured logger | Multiple files | Logging | +| L-10 | `confirm()` used for SFTP delete | `SftpSidebar.vue:210` | Frontend | +| L-11 | Settings mirrored to localStorage unnecessarily | `default.vue:25-27` | Frontend | +| L-12 | No DTO validation on admin password reset | `auth.controller.ts` | Auth | + +--- + +## ISO 27001:2022 Gap Assessment + +| Control | Status | Gap | +|---------|--------|-----| +| **A.5 — Security Policies** | MISSING | No security policies, incident response plan, or vulnerability disclosure process | +| **A.6 — Security Roles** | MISSING | No defined security responsibilities or RACI for incidents | +| **A.8.1 — Asset Management** | MISSING | No data classification scheme (SSH keys, TOTP secrets, credentials treated uniformly) | +| **A.8.5 — Access Control** | PARTIAL | Auth exists but: no brute-force protection, no account lockout, no session revocation, only 2 roles (admin/user) with no least-privilege granularity | +| **A.8.9 — Configuration Mgmt** | FAIL | guacd on host network, DB port exposed, no security headers | +| **A.8.15 — Logging** | FAIL | No structured audit log. Sensitive data IN logs. No failed login tracking | +| **A.8.16 — Monitoring** | MISSING | No anomaly detection, no alerting on repeated auth failures | +| **A.8.24 — Cryptography** | PARTIAL | Vault encryption is excellent (Argon2id). But: no TLS, tokens in URLs, TOTP unencrypted, keys in logs | +| **A.8.25 — Secure Development** | MISSING | No SAST, no dependency scanning, no security testing | +| **A.8.28 — Secure Coding** | MISSING | No documented coding standard, no input validation framework | + +--- + +## Prioritized Remediation Roadmap + +### Phase 1 — Stop the Bleeding (do this week) + +| Priority | Finding | Effort | Impact | +|----------|---------|--------|--------| +| 1 | **C-1:** Fix guacd `network_mode: host` | 30 min | Closes unauthenticated backdoor to entire infrastructure | +| 2 | **C-8:** Remove PostgreSQL port exposure | 5 min | Closes direct DB access from network | +| 3 | **C-6:** Add session ownership to SFTP gateway | 1 hr | Blocks cross-user file access | +| 4 | **H-4:** Remove private key logging | 15 min | Stop bleeding secrets to logs | +| 5 | **H-5:** Stop logging terminal keystroke data | 15 min | Stop logging passwords | +| 6 | **H-11:** Add upload size limit | 15 min | Block memory exhaustion DoS | + +### Phase 2 — Auth Hardening (next sprint) + +| Priority | Finding | Effort | Impact | +|----------|---------|--------|--------| +| 7 | **C-2 + C-3:** Move JWT to httpOnly cookie + WS ticket auth | 4 hr | Eliminates primary token theft vectors | +| 8 | **C-4:** Add TLS termination (nginx + Let's Encrypt) | 2 hr | Encrypts all traffic | +| 9 | **H-1:** Short-lived access + refresh token | 3 hr | Limits exposure window of stolen tokens | +| 10 | **H-6:** Rate limiting on auth endpoints | 1 hr | Blocks brute-force | +| 11 | **H-3:** Encrypt TOTP secrets in DB | 1 hr | Protects 2FA if DB compromised | +| 12 | **M-5:** Increase password minimum to 12 chars | 15 min | NIST/OWASP compliance | + +### Phase 3 — Channel Hardening (following sprint) + +| Priority | Finding | Effort | Impact | +|----------|---------|--------|--------| +| 13 | **C-5:** SSH host key verification (block changed fingerprints) | 3 hr | Blocks MITM on SSH | +| 14 | **C-7:** Guacamole instruction validation + opcode whitelist | 2 hr | Blocks protocol injection | +| 15 | **H-2:** RDP cert validation (per-host configurable) | 2 hr | Blocks MITM on RDP | +| 16 | **M-1:** Terminal gateway session ownership check | 30 min | Blocks cross-user terminal access | +| 17 | **H-7:** Run container as non-root | 30 min | Limits blast radius of any RCE | + +### Phase 4 — Hardening & Compliance (ongoing) + +Everything in MEDIUM and LOW, plus ISO 27001 documentation gaps. Most are incremental improvements that can be addressed as part of normal development. + +--- + +## What's Actually Good + +Credit where due — these areas are solid: + +- **Vault encryption (Argon2id v2)** — OWASP-recommended parameters, per-record salts, backwards-compatible versioning, migration endpoint. This is production-grade. +- **Credential isolation** — `decryptForConnection()` is internal-only, never exposed over API. Correct pattern. +- **Per-user data isolation** — Users can only see their own credentials and SSH keys (ownership checks in vault services). +- **TOTP 2FA implementation** — Correct TOTP flow with QR code generation (aside from the plaintext storage issue). +- **Password hashing** — bcrypt is correct choice (cost factor should increase, but the algorithm is right). +- **Admin guards on backend** — `AdminGuard` properly enforces server-side. Not just frontend checks. + +--- + +*Report generated by 4 parallel audit agents covering Auth/JWT/Session, Vault/Encryption/DB, WebSocket/SSH/SFTP/RDP, and Frontend/Infrastructure/ISO 27001. Deduplicated from 70+ raw findings to 54 unique issues.* diff --git a/docs/config-export.mobaconf b/docs/config-export.mobaconf new file mode 100644 index 0000000..4eafb6e --- /dev/null +++ b/docs/config-export.mobaconf @@ -0,0 +1,304 @@ +[Misc] +PasswordsInRegistry=0 +SlashDir=_AppDataDir_\MobaXterm\slash +HomeDir=_AppDataDir_\MobaXterm\home +SkinSat=80 +LastSession=*Predator Mac|#109#0%192.168.1.214%22%vstockwell%%-1%-1%%%%%0%0%0%%%-1%-1%0%0%%1080%%0%-1%3%%0%%%%0%-1%-1%0%%#MobaFont%10%0%0%-1%15%236,236,236%30,30,30%180,180,192%0%-1%0%%xterm%-1%0%_Std_Colors_0_%80%24%0%1%-1%%%0%0%-1%0%#0# #-1 +RightClickAction=1 +RightClickAssigned=1 +DefTextEditor= +LogFileFormat= +StorePasswords=Ask +StoreSSHKeysPassphrases=0 +BackupIni=1 +LogTerminalActivity=0 +LogType=0 +LocalShell=Bash (32 bit) +LogFolder= +DarknessIntensity=80 +XAuto=1 +XKeepAlive=1 +BackspaceSendsBS=1 +BSisBS=1 +BoldAsFont=0 +WinPath=1 +ConfirmExit=0 +PromptType=2 +WarnBeforePasteMultipleLines=1 +TransTerm=0 +TransDetachedTabs=0 +X11SharedClipboard2=1 +X11OpenGL=0 +X11DisplayMode=0 +X11AccessControl2=0 +X11DisplayOffset=0 +X11ScreenNumber=1 +X11Keyboard=0 +X11DpiMode=stretched +X11Version=MobaX +AdvTermSettings= +DefaultHome= +DefaultNewTab= +NewTabPosition=1 +ReplaceHomeTab=0 +NoTabClose=0 +AllowMultiInstances=1 +MPSetDate=2/2/2026 +MPSetAccount=vantz +MPSetComputer=STORMBREAKER +WhenToPromptForMasterPassword=0 +ExportConfPartialCrypt=1 +ExportConfUseMP=1 +SessionP=18377107475026 + +[WindowPos_DESKTOP-V1DRJ1C_3440_1440_1920_1080_1920_1080] +CompactMode=0 +MonitorCount=3 +Left=-8 +Top=-8 +Width=3456 +Height=1408 +Maximized=1 +SidebarVisible=1 +SidebarWidth=240 + +[SSH] +SFTPShowDotFiles=1 +SFTPAsciiMode=0 +MonitorHost=1 +MonitorCPU=1 +MonitorRAM=1 +MonitorNetUp=1 +MonitorNetDown=1 +MonitorProcesses=0 +MonitoFDs=0 +MonitorUptime=1 +MonitorUsers=1 +MonitorPartitions=1 +MonitorNfsPartitions=0 +MonitorNetstat=0 +StrictHostKeyChecking=0 +LastDownDir_vantz@STORMBREAKER=C:\Users\vantz\Downloads +UseInternalMobAgent=0 +UseExternalPageant=1 +ValidateEachAgentRequest=0 +MobAgentKeys= +DisplaySSHBanner=1 +UseNewMoTTY=1 +GwUse2factor=0 +AutoStartSSHGUI=1 +SSHKeepAlive2=0 +EnableSFTP=1 +RemoteMonitoring=1 +ScpPreservesDates=0 +UseGSSAPI=1 +KrbDomain= +GSSAPICustomLib= +GSSAPILibNumber=0 +DefaultLoginName= +BrowserSortType=0 +BrowserSortReverse=0 + +[Display] +SidebarRight=0 +C10Checked=1 +C11Checked=1 +C12Checked=1 +C13Checked=0 +C14Checked=0 +VisibleTabNum=1 +VisibleTabClose=1 +MenuAndButtons=2 +BtnType2=2 +S3Checked=0 +DisableQuickConnect=0 +CustomDPI_3440_96=0 +IconsTheme=0 +RoundedTabs=1 +GraphicCache=1 + +[SSH_Hostkeys] +ssh-ed25519@22:192.168.1.4=0x29ac3a21e1d5166c45aed41398d71cc889b683d01e1a019bf23cb2e1ce1c8276,0x2a8e2417caf686ac4b219cc3b94cd726fb49d2559bd8725ac2281b842845582b +sha256-ssh-ed25519@22:192.168.1.4=e4:88:e6:10:02:fc:01:35:3e:f4:a0:91:4c:ce:28:99:2f:ba:fe:78:54:b1:28:8e:fb:5d:90:36:5f:69:17:00 +ssh-ed25519@22:155.254.29.221=0x56f06b2deb3d77fd2d70a2cb1769555423e73f4abe5dfbed60e675bf97f5a3a7,0x477c72989fc0a6a61fab477a5ffb4c559426cd2a5e201db62d32e62599661fc7 +sha256-ssh-ed25519@22:155.254.29.221=1e:1a:04:68:bf:98:f7:fa:75:ae:b4:df:bd:20:bb:af:cd:70:c3:dd:2c:94:7e:ad:77:f5:9b:25:87:a6:0c:fd +ssh-ed25519@22:192.168.1.108=0x150d36ae7dcf070a42a692cc3ed1ef2758550ac62a03837348d1e386071dcbfb,0x7e8287a32f0f9f9720b8b949530110c34eb32efb99762523ec270d1056d4aefc +sha256-ssh-ed25519@22:192.168.1.108=25:92:7f:45:ac:22:4e:43:b8:3d:03:eb:b6:da:eb:1d:dc:ca:90:da:69:57:ab:36:d6:6a:63:7f:da:35:22:2c +ssh-ed25519@22:192.168.1.214=0x150d36ae7dcf070a42a692cc3ed1ef2758550ac62a03837348d1e386071dcbfb,0x7e8287a32f0f9f9720b8b949530110c34eb32efb99762523ec270d1056d4aefc +sha256-ssh-ed25519@22:192.168.1.214=25:92:7f:45:ac:22:4e:43:b8:3d:03:eb:b6:da:eb:1d:dc:ca:90:da:69:57:ab:36:d6:6a:63:7f:da:35:22:2c + +[WindowPos_STORMBREAKER_1920_1080_1920_1080_3440_1440] +CompactMode=0 +MonitorCount=3 +Left=3432 +Top=343 +Width=1936 +Height=1048 +Maximized=1 +SidebarVisible=1 +SidebarWidth=442 + +[CustomIcons] +-3=iVBORw0KGgoAAAANSUhEUgAAADAAAAAwCAYAAABXAvmHAAAOwklEQVR42s1aaVBc15U+r1d6oTc1+yYkxI4ACUmgXUIgCVmWJcu2kthyHE0q8dQk/pGqpJJMlZ2pqZmq+ZGpyWSZ2BOn5oftaAUtgARYQmCEQEKA2FvsNEvTLL2/fq/fMue1n11YBoQWy3lVp3h933Lvd+53zvnufRA8z8MzP27cyIS6uu149iJaOJobrRSys+vgpZfanmVXxDMFMDenh7t3M2BwsARstiJsiUULQQugjYLRWA/x8echL88CsbEzf18A7HYjDAykwK1bL4DbXYwtecL7F9zBoXWBRFIBW7dehfT0+xAR4cDf3N8HgJqaAmhpOQgUdRJ4PgpbFIvcJcyEFxSKDyAl5SIcOtQMSmXg2wVgtyuhvd0A/f1vIW2OY0s6mmqZJwSPdyCdamHt2nOQlWVBWk1/ewDGxtRQWxsG09PvgMfzqhi08kc8JYCwAEFcQDpVY3C3IiAPyGTs8wZAQCBA4MBlCKIIOjqOIH1exnbTCp71ozlAo/kYVq8uheLie6DT+Z4PAJvNBD09q/EsJshpgNvozXBwuXKw/RiQ5BZsS17Bm4TO20CvvwlpaaWQmtoLCQnT3xwAnhe8rUJPZ0B19T5sWS9EAdr7sGvXGERHKzALFWBcHAefrwTbdSugkzCAIXTAJ7BlSxVaC2i1/pXS6fEA+P0KpMoGePCgBHP+97BFiybk88/Qi6cxt9+GnBw1zsJ2aG0tQcAv4LWoFbyZFOYVKVQGcXHnYffu+2A2u54tgNlZAkZGtHDnzgmk0FEc3AExz3vQJtAugcFQCevX38XcboL5+QysC7vB6w3He5V4XaBcHFr0MjNRRZtU521Fa8pV8RmTZnUa/9QA8DJBMSDnunvkRNNtk3x+9m0p5TuAD2biZSmaRLzVIogItP+GPXuGMD0GoKIiDCYn1fiSUGwvAobZBzS9R3zu4SLHMFK+2aGnqls2TpSuStw5mhl+glRIQhgJIeWeGIDbD5qGAVg/O+VJVMzawjZYm4Zj3GMpIeA/KUoFvXirJ8hlgAoEcBkKCm7hTCmxsEkRgAzbw2Bo6ADGyE9EWoUu6MYh1Ia+KLK8J87bOhvuKZBpjXNm1brWTWEl3eGqhLknAmBzgWnADkl3R+DwvA/S5FxAk2XvKE12WNyJnqE0GTCFEuAyxGAVPDqL1owAPoSdO8997YXt7YVQVvYzPMteQCU7qeB6p/SByp5Y78RAFKlmFYHXOAnPKKSq1mzT3vK1utyuaHWS/bEAYCtx0wIb6yxwAM9/IHKYRqtLmesrKxqq/lgLnp+hi49iWwqaTPRkGwbh+wjgE8wsDwPYhADewLP9YpoVOr89ZQhU1mQ4P5k3+F9glMxbPMGvxXa18D4c3p8zjNsv74891UjgcFcEYM4LujsjkDE4A4enXSCkw7VixhFSm11Hu1qiPJOVufbW2QTPSDyC+A4BvABQCFYrirTfwZo178OmTQGssPwCAOkIQHjf98SA7u2NIi9boskH42FkFhXCbOekXJ7Yl0x0mEUvD/s0Wr3uTM6qQkuUeu3MsgDsbjD2z0By4wAcQv4Lntr0UMAJxyRa5wbbvXOp8732ON9YvoKntyOdUsXrH2FK/CvGwQDqHQ+EhX0ehEND0dDYmAVTU8dJymme0TJ32ld7hociSR16/g1ewgkzuWoR2dFFgKQiz3zwarI+ryMsJGFOQkj4RQHU9MIW9P7BAANv4qWYJQqR8NIAxkTtaufwtcKRTy8aWMfrOBOviLPVh/SpAbn8Q9i8uR8KCz9XnCwrwaCWw5UrOWPT7araVNeAS0e9TocEjvESPlWkjWQpFSsjlB9gPFwsivl+k5CdvgJA8Hy7FZL77XAEg7d4BapSOMZDaXdbvGu0PHO204POjEYQx5FORkHmof0r5OffxaAmkT55WKEpBNYGoaHGIeWUsYq4biRl/rc5GfeimM2kj1KxOrm5NkGbeS7dsNUSo0meDgJAzustNki72QcH/Swcwhs3LEKbpQ5Bu7Tm2lovIIjhKGqyWMlRW5FOGmx/DwE0wI4dXrhw4TVy9IHXqeZKib272NkYpbHH2ZA+Q0286WVdxSIAuQhCtkT/Aog+HHIpptfqdMO21iCAa92wtXMcDnppeAN/hq/A8wsPYSr9IYy/PtZtvb7TWlcbRtv3YJ0QYucvCKAVAXigtPTYoLOburXGXSE1qZiIsGQ+O2q/vHmmvLDH0bgPR7sVuWDGvKTFoetFEEupWGeINPSjeE1aKXGjj8/omYQT0+5gwGYvsZJayWHV0p7WNY7Bc1kGjzdJ7WKhu7sLMjKmYO9eP+ab1B5XS2K1vCGGkPF+ndo8EmvMuqUkVBEykAoZLNFKWnRW3wMzzwUK0LGpYqFcUsWGyk21xG+u8D9Gr38fGzY/Bm2WrH346utb1sDH+UZHpbauSiqLjeb4bVtZL+OQ9zgbD9VPnf1nvMfLEmwTRQR+fyDqB9N5pmJBzEGbo1baNn8j0uW3vcyw/iPYtGeZMQl0ekC8d5mveKgyPs1BCTERqoL/itUxf9pjnqfDTAqW0sokjdPnYgfd919x0LPvCvmIB76dA+6Doug3G3NM+waEh72Mk5ggB1T19gur5/zjpyQc/46YlZYCMSsAuCtM3wpXUSuJBx96+D9WaeC3xzYAFW0AjmTcsorxPyaOeLq+A7zkN+K995EJ/1sYfbIm21TY82URpaeklVMfamy+wbdR3r0rUnqp7OQRAAgKMg0t4lnNANaH30aoAn88tBGYSKOMJzlaWjXx15hhT+cJlqX+RQzQLoLgP9wedaI6y7SnS4lFHKUCzNM2yTXb/6ls3oEfsQz1rlgblgpoF/HeJf6n+Nzr+GPjEkXkcY4JfFd5tq69bIO5oz4CVwJKbWqAVeTQdr81pM/ZdLBlpvIXYrETZEJTaHTqn+NNude2wTZeiwlojp7UXB3/YL2NHD7Jc9w/LCK9Fx7DROk9Pmd0Ht5AtSnspKWuYAm41DGilvva4rWj5TnGdjJF1xtAp7aDOn8C1IU+oEcT+zxNyZ/OX80IBIgDLAtJAoiAQVum18bcyIM8mxrUMgz2iPtzN/Kd9MwuvF6wTH9DapmuMVgHytpga8c4vMjx8CNRGj/uTHBIhwuxWuvVo4kXB4yKuVdxDSDsjb4LGixkWixk8+e/O0i3+29Jpi+5XJKf+CniJYwBXFPzwr7pIHZZj37WBmeH53Pwr2GJcfDB/oA4jZX4TBCAdR7MfTbIbB6CEpoNyoisxwAxjh3X55jbb2eZOlyxautehYTaKPL2lwigHgHQ4LjwSw/VG2knqHudjGtinKZiSJI4gf3Hifun0+LsCyCM4vli1BlFu5lp3Hk5VZ/f+KUWmnSCCRVomtUBryCd9ov6P+RRxUsj97ZGqqfKN4ffIZL1liT0nrC5ZQ52RMAvgjOg2cEigH8DamAj9ubpJhynBxjv1DgZyKUZfjfSKVtcoUkfsXsxppbpG8ND4k/nripqSQxdP/olAKSPJMCCtKYHtrSgGhUXMRHLBBCuluHMWt3g9cOrr/RqZe43pcC8KE49ijboxjn8FcZAM6h3SBDArxGAsBZIYoCvt4K3spGwn5n3wHf9fuI4vi7jERJGWIt8hIMuK4p+q1olCyWlhIz9mpwenYNwFHbpbWNwGLWRUAlzFgEheLcZafNZuqHbv0Y3uAUHL2xmrQtSB6sxSDQXQJtTCcrkcZCaCZi7WIAAjoOE/aEgfj3A3J8ifOW9Ac/8GE1FUBQc57jgKs28WMDieqARV2Zl63R5TQnaDOsXC/1FV2QorfU3H0AWxsbLTjL4kSJK9I7wkF0j9zWvCpm9uCu6jl8TOpCOtDm5IOj8INH/AeRxfwDdLhvIzBRwfgKcDSqgLEeBn/53cdEi7Oh1IJ3OWlhP/5SP2e0P8DsQhOAwjUgnwes2zDb1JmXU3/LDjtyJ16aPP3JJyXAg9dGgxJjIaxqCYv7zJWBCcHAElKYa+hr2x1V1YdI7pSAoYZYixQ59eH0YafN70BT8BWeBAULKB+nL+iTgv78T3FX/iD+3iNuSfgrYhinwX2/mZ67NkNw+DOzX8P5UMZiFnY5z6PUruyJPVCFt/HLJV7fjl92VGJmF8N4pSO2ehKMuPyTIJAE+w9RzM83Yw6bo+zLR80KuTlpQO8jgmjg0/T+RPv+DYpL/Cq3J9t3gKHtH/PjxhdKc8CGdxgjvpf6A1z1KU+E0BUdYDpQ42MFk/abSJN3GO2tDc4efaF/IQYKmphtybU4ySknMaorjakgsVptx8D8UaSV7aOnnAI3pd6CO/ROmeRdITAxIpQSwTAiQPQfBXfNzsRIvXPvOo93rJZznO1lni93FHyZ4NW0IiercGflaY7Q6aeqJN7YEOjl8oAl4OkMJ32cRRoXjbaWU2i0KwIeVIh8UdFL5JSCUfwNW3QBq0wxoDVJw2zOBmj0GrOOfFgEuiEAPzsTtaYa+0eRwN6wK3TCeHbnfrVeY3QqJin76vVF6RA5kpwmo/h/jIA4t8g1s4WFBKO0Y8vdBqZ4DlYYAnzMFWFpYqm5bppd6EpSX+tmYMzrtemuCIfuR388eb3eaIxXgqtgM/v4jwPtPidlCsUze9ooKlROzlHKZe0mQKC6DPPIs6ErqQBYx+w18H2AlwNgN4O9aD54GrNbB7fP0ZTQLKw6eFwNdsrSKJapAlV0B6g31IAufQwrS39wXGnosDHz31iGtXgV2fu9TqtghkGqaQZFwFgG0YOEbfk7fyHgCHFhdyQ4sdNyTqNigqgRCchoUcWdB/9KnIDW4n+9HPtpqBgrrgbe5BHj6cVUsyhGiFlS5V0CVeQvksdNAyAPPF0Aw80+sAu/tVATzCtKpWEyvIY9SlSDVNoI86jSoNyNtkka/xS/1uLbgGSm4qvIxLg7i+E6J34qJJTMOQXyMgy4F3QvVKDdQfki5bxHAF3QaDQe/JR3ItsPAefeIWzWSrwUsQTQibcogJK0JFIm4GJKyT9Pts/1vFcauB/fNLAhYseI6S0RprBRT6Rxmm1sgCzsD2p0tOHjrs+jy/wGX9sWqI3zmPgAAAABJRU5ErkJggg== + +[WindowPos_STORMBREAKER_1920_1080_1920_1080_4206281_-24] +CompactMode=0 +MonitorCount=3 +Left=3432 +Top=343 +Width=1936 +Height=1048 +Maximized=1 +SidebarVisible=1 +SidebarWidth=442 + +[WindowPos_STORMBREAKER_3440_1440_1920_1080_1920_1080] +CompactMode=0 +MonitorCount=3 +Left=3432 +Top=343 +Width=1936 +Height=1048 +Maximized=1 +SidebarVisible=1 +SidebarWidth=240 + +[Font] +Font2=MobaFont +FontIsBold2=0 +FontHeight2=10 +FontCharset2=0 +FontQuality2=3 +Ligatures=0 +Charset2=UTF-8 +LockRowsCols=0 +LockedCols=80 +LockedRows=24 + +[Colors] +Black=0,0,0 +BoldBlack=128,128,128 +Red=170,66,68 +BoldRed=204,123,125 +Green=126,141,83 +BoldGreen=165,177,124 +Yellow=228,180,109 +BoldYellow=236,201,149 +Blue=110,154,186 +BoldBlue=150,182,205 +Magenta=158,80,133 +BoldMagenta=192,131,172 +Cyan=128,213,207 +BoldCyan=169,226,222 +White=204,204,204 +BoldWhite=204,204,204 +ForegroundColour=236,236,236 +BackgroundColour=36,36,36 +CursorColour=180,180,192 +SyntaxType=1 + +[Cursor] +CursorType=0 +CursorBlinks=0 + +[PopupConsole] +Transparency=16 +Size=36 +Sizeable=0 +Position=3 +TryToOpenInExplorerFolder=1 +BackgroundInsteadOfClose=0 + +[X11Extension] +Composite=1 +DAMAGE=1 +RANDR=1 +XFIXES=1 +XTEST=1 +XINERAMA=1 + +[WindowPos_STORMBREAKER_0_-30_1920_1080_1920_1080] +CompactMode=0 +MonitorCount=3 +Left=3432 +Top=-737 +Width=1936 +Height=1048 +Maximized=1 +SidebarVisible=1 +SidebarWidth=240 + +[Bookmarks] +SubRep= +ImgNum=42 + +[Bookmarks_1] +SubRep=AAA Vantz's Stuff +ImgNum=41 +*Asgard=#109#0%192.168.1.4%22%vstockwell%%-1%-1%%%22%%0%0%0%V:\vBackup\Webhosting Password\ssh-key-vantz-primary%%-1%0%0%0%%1080%%0%0%1%%0%%%%0%-1%-1%0%%#MobaFont%10%0%0%0%15%236,236,236%30,30,30%180,180,192%0%-1%0%%xterm%-1%0%_Std_Colors_0_%80%24%0%1%-1%%%0%1%-1%0%#0# #-1 +*Docker=#109#0%155.254.29.221%22%vstockwell%%-1%-1%%%22%%0%0%0%V:\vBackup\Webhosting Password\ssh-key-vantz-primary%%-1%0%0%0%%1080%%0%0%1%%0%%%%0%-1%-1%0#MobaFont%10%0%0%0%15%236,236,236%30,30,30%180,180,192%0%-1%0%%xterm%-1%0%_Std_Colors_0_%80%24%0%1%-1%%%0%1%-1%0%#0# #-1 +*New-cp01-heistacp=#109#0%155.254.29.219%22%vstockwell%%-1%-1%%%22%%0%0%0%V:\vBackup\Webhosting Password\ssh-key-vantz-primary%%-1%0%0%0%%1080%%0%0%1%%0%%%%0%-1%-1%0#MobaFont%10%0%0%0%15%236,236,236%30,30,30%180,180,192%0%-1%0%%xterm%-1%0%_Std_Colors_0_%80%24%0%1%-1%%%0%1%-1%0%#0# #-1 +*Predator Mac=#109#0%192.168.1.214%22%vstockwell%%-1%-1%%%%%0%0%0%%%-1%-1%0%0%%1080%%0%-1%3%%0%%%%0%-1%-1%0%%#MobaFont%10%0%0%-1%15%236,236,236%30,30,30%180,180,192%0%-1%0%%xterm%-1%0%_Std_Colors_0_%80%24%0%1%-1%%%0%0%-1%0%#0# #-1 +*VigilCyber-Helpdesk=#109#0%192.154.253.101%22%vstockwell%%-1%-1%%%22%%0%0%0%V:\vBackup\Webhosting Password\ssh-key-vantz-primary%%-1%0%0%0%%1080%%0%0%1%%0%%%%0%-1%-1%0#MobaFont%10%0%0%0%15%236,236,236%30,30,30%180,180,192%0%-1%0%%xterm%-1%0%_Std_Colors_0_%80%24%0%1%-1%%%0%1%-1%0%#0# #-1 +192.168.1.107 ([UPS-Monitor])=#91#4%192.168.1.107%3389%[UPS-Monitor]%0%-1%-1%-1%-1%0%0%-1%%%%%0%0%%-1%%-1%-1%0%-1%0%-1#MobaFont%10%0%0%0%15%236,236,236%30,30,30%180,180,192%0%-1%0%%xterm%-1%-1%_Std_Colors_0_%80%24%0%1%-1%%%0#0# #-1 +CLT-VMHOST01=#91#4%100.64.1.204%3389%%-1%0%0%0%-1%0%0%-1%%%%%0%-1%%-1%%-1%-1%0%-1%0%-1%0%0%0%0#MobaFont%10%0%0%0%15%236,236,236%30,30,30%180,180,192%0%-1%0%%xterm%-1%0%_Std_Colors_0_%80%24%0%1%-1%%%0%0%-1%0%#0# #-1 +CTG Unifi=#109#0%192.154.253.111%22%vstockwell%%-1%-1%%%22%%0%0%0%D:\Webhosting Password\ssh-key-vantz-primary%%-1%0%0%0%%1080%%0%0%1%%0%%%%0%-1%-1%0#MobaFont%10%0%0%0%15%236,236,236%30,30,30%180,180,192%0%-1%0%%xterm%-1%0%_Std_Colors_0_%80%24%0%1%-1%%%0%1%-1%0%#0# #-1 +Greyming Webhosting=#109#0%155.254.29.219%22%greyming%%-1%-1%%%22%%0%0%0%%%-1%0%0%0%%1080%%0%0%1%%0%%%%0%-1%-1%0#MobaFont%10%0%0%0%15%236,236,236%30,30,30%180,180,192%0%-1%0%%xterm%-1%0%_Std_Colors_0_%80%24%0%1%-1%%%0%1%-1%0#0# #-1 +MSPNerd - ITFlow=#109#0%192.154.253.106%22%vstockwell%%-1%-1%%%22%%0%0%0%V:\vBackup\Webhosting Password\ssh-key-vantz-primary%%-1%0%0%0%%1080%%0%0%1%%0%%%%0%-1%-1%0#MobaFont%10%0%0%0%15%236,236,236%30,30,30%180,180,192%0%-1%0%%xterm%-1%0%_Std_Colors_0_%80%24%0%1%-1%%%0%1%-1%0%#0# #-1 +MSPNerd - Mautic=#109#0%192.154.253.112%22%vstockwell%%-1%-1%%%22%%0%0%0%V:\vBackup\Webhosting Password\ssh-key-vantz-primary%%-1%0%0%0%%1080%%0%0%1%%0%%%%0%-1%-1%0#MobaFont%10%0%0%0%15%236,236,236%30,30,30%180,180,192%0%-1%0%%xterm%-1%0%_Std_Colors_0_%80%24%0%1%-1%%%0%1%-1%0%#0# #-1 +Netbird=#109#0%192.154.253.105%22%vstockwell%%-1%-1%%%22%%0%0%0%V:\vBackup\Webhosting Password\ssh-key-vantz-primary%%-1%0%0%0%%1080%%0%0%1%%0%%%%0%-1%-1%0#MobaFont%10%0%0%0%15%236,236,236%30,30,30%180,180,192%0%-1%0%%xterm%-1%0%_Std_Colors_0_%80%24%0%1%-1%%%0%1%-1%0%#0# #-1 +Node 1(top)=#109#0%192.168.1.105%22%root%%-1%-1%%%%%0%0%0%%%-1%0%0%0%%1080%%0%0%1#MobaFont%10%0%0%0%15%236,236,236%30,30,30%180,180,192%0%-1%0%%xterm%-1%-1%_Std_Colors_0_%80%24%0%1%-1%%%0#0# #-1 +Node 2(bottom)=#109#0%192.168.1.106%22%root%%-1%-1%%%%%0%0%0%%%-1%0%0%0%%1080%%0%0%1#MobaFont%10%0%0%0%15%236,236,236%30,30,30%180,180,192%0%-1%0%%xterm%-1%-1%_Std_Colors_0_%80%24%0%1%-1%%%0#0# #-1 +OpenVAS-Reporting=#109#0%10.150.10.100%22%vstockwell%%-1%-1%%%22%%0%0%0%D:\Webhosting Password\ssh-key-vantz-primary%%-1%0%0%0%%1080%%0%0%1%%0%%%%0%-1%-1%0#MobaFont%10%0%0%0%15%236,236,236%30,30,30%180,180,192%0%-1%0%%xterm%-1%0%_Std_Colors_0_%80%24%0%1%-1%%%0%1%-1%0%#0# #-1 +Pterodactyl Panel=#109#0%192.154.253.110%22%vstockwell%%-1%-1%%%22%%0%0%0%V:\vBackup\Webhosting Password\ssh-key-vantz-primary%%-1%0%0%0%%1080%%0%0%1%%0%%%%0%-1%-1%0#MobaFont%10%0%0%0%15%236,236,236%30,30,30%180,180,192%0%-1%0%%xterm%-1%0%_Std_Colors_0_%80%24%0%1%-1%%%0%1%-1%0%#0# #-1 +rmm.t2cnc.com=#109#0%192.154.253.99%22%vstockwell%%-1%-1%%%22%%0%0%0%D:\Webhosting Password\ssh-key-vantz-primary%%-1%0%0%0%%1080%%0%0%1%%0%%%%0%-1%-1%0#MobaFont%10%0%0%0%15%236,236,236%30,30,30%180,180,192%0%-1%0%%xterm%-1%0%_Std_Colors_0_%80%24%0%1%-1%%%0%1%-1%0#0# #-1 +Win Game Host=#91#4%192.154.253.107%3389%administrator%0%0%0%0%-1%0%0%-1%%%%%0%0%%-1%%-1%-1%0%-1%0%-1%0%0%0%0#MobaFont%10%0%0%0%15%236,236,236%30,30,30%180,180,192%0%-1%0%%xterm%-1%0%_Std_Colors_0_%80%24%0%1%-1%%%0%0%-1%0%#0# #-1 + +[EditorWindowPos_3440_1440_1920_1080_1920_1080] +Left=0 +Top=0 +Width=3440 +Height=1392 +Maximized=0 +MonitorCount=3 + +[EditorWindowPos_1920_1080_1920_1080_3440_1440] +Left=3432 +Top=343 +Width=1936 +Height=1048 +Maximized=1 +MonitorCount=3 + +[MobaTextEditor] +DarkTheme=1 +FontName3=MobaFanta +FontLigatures3=0 +FontHeight3=-13 +GutterVisible=1 +GutterZeroStart=0 +EdgeColumn=800 +TabWidth=2 +ColorsIntensity=80 +Options2=518148035 +RecentSearchDir= + +[Passwords] +mobauser@mobaserver=_@NLb5+QoIseFoRg+0bK8Kx4G/zfEh/cmZDlpOBedcA9qDv/RiIgkQ== +vstockwell@192.168.1.214=_@9jajOXKuLSaKciMn1EIQTT9vNVLCGwk3a4Fw== +ssh22:vstockwell@192.168.1.214=_@9jajOXKuLSaKciMn1EIQTT9vNVLCGwk3a4Fw== +vstockwell@192.168.1.108=_@FYcgiZ336eUoS36gHigR8MqfY3+8AIbpwQvw== +ssh22:vstockwell@192.168.1.108=_@FYcgiZ336eUoS36gHigR8MqfY3+8AIbpwQvw== + +[Credentials] +Mac Login=vstockwell:_@25LSjojT5a73AVTma1s1LUVSs/YNHX7740sg== + +[ImportExportSpecials] +ExportedSettings=Home and Slash directories location|Local terminal settings|Global X server settings|SSH settings|Folders history|Services settings|MobaXterm interface settings|Sessions|Macros|Stored passwords and credentials|Sessions presets|SSH tunnels|MobaTextEditor and MobaDiff settings|Custom syntaxes|Shared sessions|Suppressed message dialogs +mobauser@mobaserver=_@NLb5+QoIseFoRg+0bK8Kx4G/zfEh/cmZDlpOBedcA9qDv/RiIgkQ== +MobaVersion=25.3 +ExportDate=2026-03-14 +ExportTime=13:16:51 +ExportUsername=vantz +ExportComputer=STORMBREAKER + diff --git a/docs/karens-wraith-layout.png b/docs/karens-wraith-layout.png new file mode 100644 index 0000000..61d7de5 Binary files /dev/null and b/docs/karens-wraith-layout.png differ diff --git a/docs/moba1.png b/docs/moba1.png new file mode 100644 index 0000000..dd741dc Binary files /dev/null and b/docs/moba1.png differ diff --git a/docs/moba2.png b/docs/moba2.png new file mode 100644 index 0000000..07426ab Binary files /dev/null and b/docs/moba2.png differ diff --git a/docs/plans/2026-03-14-test-suite-buildout.md b/docs/plans/2026-03-14-test-suite-buildout.md new file mode 100644 index 0000000..8fbcec0 --- /dev/null +++ b/docs/plans/2026-03-14-test-suite-buildout.md @@ -0,0 +1,1371 @@ +# Wraith Remote — Test Suite Build-Out Implementation Plan + +> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Build a full service-layer test suite (~96 tests) covering all backend services, guards, and controller logic plus frontend stores, composables, and middleware. + +**Architecture:** Backend uses NestJS Testing Module with Jest. Frontend uses Vitest + happy-dom with mocked Nuxt auto-imports ($fetch, navigateTo). All tests are unit tests with mocked dependencies — no real database or network calls. + +**Tech Stack:** Jest 29 (backend, already configured), Vitest (frontend, new), @nestjs/testing, @vue/test-utils, @pinia/testing, happy-dom + +--- + +## File Structure + +### Backend (existing Jest infrastructure, new test files) + +| File | Purpose | +|------|---------| +| `backend/src/__mocks__/prisma.mock.ts` | Shared PrismaService mock factory | +| `backend/src/vault/encryption.service.spec.ts` | Encryption round-trip, v1/v2, upgrade | +| `backend/src/vault/credentials.service.spec.ts` | Credential CRUD, decryptForConnection | +| `backend/src/vault/ssh-keys.service.spec.ts` | SSH key CRUD, key type detection | +| `backend/src/auth/auth.service.spec.ts` | Login, password hashing, TOTP, admin CRUD | +| `backend/src/auth/auth.controller.spec.ts` | Cookie auth, WS tickets, route wiring | +| `backend/src/auth/jwt-auth.guard.spec.ts` | JWT guard pass/reject | +| `backend/src/auth/admin.guard.spec.ts` | Admin role enforcement | +| `backend/src/auth/ws-auth.guard.spec.ts` | Cookie, ticket, legacy token auth | + +### Frontend (new Vitest infrastructure + test files) + +| File | Purpose | +|------|---------| +| `frontend/vitest.config.ts` | Vitest config with happy-dom | +| `frontend/tests/setup.ts` | Global mocks for $fetch, navigateTo | +| `frontend/tests/stores/auth.store.spec.ts` | Auth store login/logout/profile | +| `frontend/tests/stores/connection.store.spec.ts` | Host/group CRUD | +| `frontend/tests/composables/useVault.spec.ts` | Vault API calls | +| `frontend/tests/middleware/admin.spec.ts` | Admin route guard | + +--- + +## Chunk 1: Backend Test Infrastructure + Encryption Tests + +### Task 1: Create Prisma Mock Factory + +**Files:** +- Create: `backend/src/__mocks__/prisma.mock.ts` + +- [ ] **Step 1: Create the shared mock** + +```typescript +// backend/src/__mocks__/prisma.mock.ts +export const mockPrismaService = { + user: { + findUnique: jest.fn(), + findFirst: jest.fn(), + findMany: jest.fn(), + create: jest.fn(), + update: jest.fn(), + delete: jest.fn(), + }, + credential: { + findUnique: jest.fn(), + findMany: jest.fn(), + create: jest.fn(), + update: jest.fn(), + delete: jest.fn(), + }, + sshKey: { + findUnique: jest.fn(), + findMany: jest.fn(), + create: jest.fn(), + update: jest.fn(), + delete: jest.fn(), + }, + connectionLog: { + create: jest.fn(), + updateMany: jest.fn(), + }, +}; + +export function createMockPrisma() { + // Deep clone to prevent test bleed + const mock = JSON.parse(JSON.stringify(mockPrismaService)); + // Re-attach jest.fn() since JSON.parse loses functions + for (const model of Object.keys(mock)) { + for (const method of Object.keys(mock[model])) { + mock[model][method] = jest.fn(); + } + } + return mock; +} +``` + +- [ ] **Step 2: Commit** + +```bash +git add backend/src/__mocks__/prisma.mock.ts +git commit -m "test: add shared Prisma mock factory" +``` + +### Task 2: Encryption Service Tests + +**Files:** +- Create: `backend/src/vault/encryption.service.spec.ts` +- Reference: `backend/src/vault/encryption.service.ts` + +- [ ] **Step 1: Write encryption tests** + +```typescript +// backend/src/vault/encryption.service.spec.ts +import { EncryptionService } from './encryption.service'; + +// Set test encryption key before importing service +process.env.ENCRYPTION_KEY = 'a'.repeat(64); // 32 bytes hex + +describe('EncryptionService', () => { + let service: EncryptionService; + + beforeAll(async () => { + service = new EncryptionService(); + await service.onModuleInit(); + }); + + describe('encrypt/decrypt round-trip', () => { + it('should encrypt and decrypt a string', async () => { + const plaintext = 'my-secret-password'; + const encrypted = await service.encrypt(plaintext); + expect(encrypted).toMatch(/^v2:/); + const decrypted = await service.decrypt(encrypted); + expect(decrypted).toBe(plaintext); + }); + + it('should produce different ciphertexts for the same plaintext', async () => { + const plaintext = 'same-input'; + const a = await service.encrypt(plaintext); + const b = await service.encrypt(plaintext); + expect(a).not.toBe(b); // Different salts + IVs + }); + + it('should handle empty string', async () => { + const encrypted = await service.encrypt(''); + const decrypted = await service.decrypt(encrypted); + expect(decrypted).toBe(''); + }); + + it('should handle unicode', async () => { + const plaintext = '密码 пароль 🔐'; + const encrypted = await service.encrypt(plaintext); + const decrypted = await service.decrypt(encrypted); + expect(decrypted).toBe(plaintext); + }); + }); + + describe('v2 format', () => { + it('should produce v2-prefixed ciphertext', async () => { + const encrypted = await service.encrypt('test'); + const parts = encrypted.split(':'); + expect(parts[0]).toBe('v2'); + expect(parts).toHaveLength(5); // v2:salt:iv:authTag:ciphertext + }); + }); + + describe('isV1', () => { + it('should detect v1 format', () => { + expect(service.isV1('v1:abc:def:ghi')).toBe(true); + }); + + it('should not detect v2 as v1', () => { + expect(service.isV1('v2:abc:def:ghi:jkl')).toBe(false); + }); + }); + + describe('upgradeToV2', () => { + it('should return null for v2 ciphertext', async () => { + const v2 = await service.encrypt('test'); + const result = await service.upgradeToV2(v2); + expect(result).toBeNull(); + }); + }); + + describe('error handling', () => { + it('should throw on unknown version', async () => { + await expect(service.decrypt('v3:bad:data')).rejects.toThrow('Unknown encryption version'); + }); + + it('should throw on tampered ciphertext', async () => { + const encrypted = await service.encrypt('test'); + const tampered = encrypted.slice(0, -4) + 'dead'; + await expect(service.decrypt(tampered)).rejects.toThrow(); + }); + }); +}); +``` + +- [ ] **Step 2: Run tests to verify they pass** + +Run: `cd backend && npx jest src/vault/encryption.service.spec.ts --verbose` +Expected: 8 tests PASS + +- [ ] **Step 3: Commit** + +```bash +git add backend/src/vault/encryption.service.spec.ts +git commit -m "test: encryption service — round-trip, v1/v2 format, upgrade, error handling" +``` + +--- + +## Chunk 2: Backend Vault Service Tests + +### Task 3: Credentials Service Tests + +**Files:** +- Create: `backend/src/vault/credentials.service.spec.ts` +- Reference: `backend/src/vault/credentials.service.ts` + +- [ ] **Step 1: Write credentials service tests** + +```typescript +// backend/src/vault/credentials.service.spec.ts +import { Test, TestingModule } from '@nestjs/testing'; +import { CredentialsService } from './credentials.service'; +import { PrismaService } from '../prisma/prisma.service'; +import { EncryptionService } from './encryption.service'; +import { NotFoundException, ForbiddenException } from '@nestjs/common'; + +describe('CredentialsService', () => { + let service: CredentialsService; + let prisma: any; + let encryption: any; + + beforeEach(async () => { + prisma = { + credential: { + findMany: jest.fn(), + findUnique: jest.fn(), + create: jest.fn(), + update: jest.fn(), + delete: jest.fn(), + }, + }; + encryption = { + encrypt: jest.fn().mockResolvedValue('v2:mock:encrypted'), + decrypt: jest.fn().mockResolvedValue('decrypted-password'), + }; + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + CredentialsService, + { provide: PrismaService, useValue: prisma }, + { provide: EncryptionService, useValue: encryption }, + ], + }).compile(); + + service = module.get(CredentialsService); + }); + + describe('findAll', () => { + it('should return credentials without encryptedValue', async () => { + prisma.credential.findMany.mockResolvedValue([{ id: 1, name: 'test' }]); + const result = await service.findAll(1); + expect(prisma.credential.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: { userId: 1 }, + select: expect.objectContaining({ id: true, name: true }), + }), + ); + // Verify encryptedValue is NOT in select + const call = prisma.credential.findMany.mock.calls[0][0]; + expect(call.select.encryptedValue).toBeUndefined(); + }); + }); + + describe('findOne', () => { + it('should return credential for owner', async () => { + prisma.credential.findUnique.mockResolvedValue({ id: 1, userId: 1, name: 'cred' }); + const result = await service.findOne(1, 1); + expect(result.name).toBe('cred'); + }); + + it('should throw ForbiddenException for non-owner', async () => { + prisma.credential.findUnique.mockResolvedValue({ id: 1, userId: 2, name: 'cred' }); + await expect(service.findOne(1, 1)).rejects.toThrow(ForbiddenException); + }); + + it('should throw NotFoundException for missing credential', async () => { + prisma.credential.findUnique.mockResolvedValue(null); + await expect(service.findOne(99, 1)).rejects.toThrow(NotFoundException); + }); + }); + + describe('create', () => { + it('should encrypt password before storage', async () => { + prisma.credential.create.mockResolvedValue({ id: 1 }); + await service.create(1, { name: 'test', username: 'admin', password: 'secret', type: 'password' as any }); + expect(encryption.encrypt).toHaveBeenCalledWith('secret'); + expect(prisma.credential.create).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ encryptedValue: 'v2:mock:encrypted' }), + }), + ); + }); + }); + + describe('decryptForConnection', () => { + it('should decrypt password credential', async () => { + prisma.credential.findUnique.mockResolvedValue({ + id: 1, username: 'admin', domain: null, + encryptedValue: 'v2:encrypted', sshKey: null, sshKeyId: null, + }); + const result = await service.decryptForConnection(1); + expect(result.password).toBe('decrypted-password'); + expect(result.username).toBe('admin'); + }); + + it('should decrypt SSH key credential', async () => { + prisma.credential.findUnique.mockResolvedValue({ + id: 1, username: 'root', domain: null, encryptedValue: null, + sshKey: { encryptedPrivateKey: 'v2:key', passphraseEncrypted: 'v2:pass' }, + sshKeyId: 5, + }); + encryption.decrypt.mockResolvedValueOnce('private-key-content'); + encryption.decrypt.mockResolvedValueOnce('passphrase'); + const result = await service.decryptForConnection(1); + expect(result.sshKey).toEqual({ privateKey: 'private-key-content', passphrase: 'passphrase' }); + }); + + it('should throw for orphaned SSH key reference', async () => { + prisma.credential.findUnique.mockResolvedValue({ + id: 1, name: 'orphan', username: 'root', domain: null, + encryptedValue: null, sshKey: null, sshKeyId: 99, + }); + await expect(service.decryptForConnection(1)).rejects.toThrow(NotFoundException); + }); + + it('should throw for credential with no auth method', async () => { + prisma.credential.findUnique.mockResolvedValue({ + id: 1, name: 'empty', username: 'root', domain: null, + encryptedValue: null, sshKey: null, sshKeyId: null, + }); + await expect(service.decryptForConnection(1)).rejects.toThrow(NotFoundException); + }); + }); + + describe('remove', () => { + it('should delete owned credential', async () => { + prisma.credential.findUnique.mockResolvedValue({ id: 1, userId: 1 }); + prisma.credential.delete.mockResolvedValue({ id: 1 }); + await service.remove(1, 1); + expect(prisma.credential.delete).toHaveBeenCalledWith({ where: { id: 1 } }); + }); + }); +}); +``` + +- [ ] **Step 2: Run tests** + +Run: `cd backend && npx jest src/vault/credentials.service.spec.ts --verbose` +Expected: 10 tests PASS + +- [ ] **Step 3: Commit** + +```bash +git add backend/src/vault/credentials.service.spec.ts +git commit -m "test: credentials service — CRUD, ownership, decryptForConnection" +``` + +### Task 4: SSH Keys Service Tests + +**Files:** +- Create: `backend/src/vault/ssh-keys.service.spec.ts` +- Reference: `backend/src/vault/ssh-keys.service.ts` + +- [ ] **Step 1: Write SSH keys tests** + +```typescript +// backend/src/vault/ssh-keys.service.spec.ts +import { Test, TestingModule } from '@nestjs/testing'; +import { SshKeysService } from './ssh-keys.service'; +import { PrismaService } from '../prisma/prisma.service'; +import { EncryptionService } from './encryption.service'; +import { NotFoundException, ForbiddenException } from '@nestjs/common'; + +describe('SshKeysService', () => { + let service: SshKeysService; + let prisma: any; + let encryption: any; + + beforeEach(async () => { + prisma = { + sshKey: { + findMany: jest.fn(), + findUnique: jest.fn(), + create: jest.fn(), + update: jest.fn(), + delete: jest.fn(), + }, + }; + encryption = { + encrypt: jest.fn().mockResolvedValue('v2:mock:encrypted'), + }; + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + SshKeysService, + { provide: PrismaService, useValue: prisma }, + { provide: EncryptionService, useValue: encryption }, + ], + }).compile(); + + service = module.get(SshKeysService); + }); + + describe('findAll', () => { + it('should return keys without private key data', async () => { + prisma.sshKey.findMany.mockResolvedValue([{ id: 1, name: 'key1' }]); + await service.findAll(1); + const call = prisma.sshKey.findMany.mock.calls[0][0]; + expect(call.select.encryptedPrivateKey).toBeUndefined(); + expect(call.select.passphraseEncrypted).toBeUndefined(); + }); + }); + + describe('findOne', () => { + it('should return key for owner', async () => { + prisma.sshKey.findUnique.mockResolvedValue({ id: 1, userId: 1, name: 'key1', keyType: 'ed25519' }); + const result = await service.findOne(1, 1); + expect(result.name).toBe('key1'); + // Should not include encrypted private key + expect((result as any).encryptedPrivateKey).toBeUndefined(); + }); + + it('should throw ForbiddenException for non-owner', async () => { + prisma.sshKey.findUnique.mockResolvedValue({ id: 1, userId: 2 }); + await expect(service.findOne(1, 1)).rejects.toThrow(ForbiddenException); + }); + }); + + describe('create', () => { + it('should encrypt private key and passphrase', async () => { + prisma.sshKey.create.mockResolvedValue({ id: 1 }); + await service.create(1, { + name: 'my-key', + privateKey: '-----BEGIN OPENSSH PRIVATE KEY-----\ndata\n-----END OPENSSH PRIVATE KEY-----', + passphrase: 'secret', + }); + expect(encryption.encrypt).toHaveBeenCalledTimes(2); + }); + + it('should detect RSA key type', async () => { + prisma.sshKey.create.mockResolvedValue({ id: 1 }); + await service.create(1, { + name: 'rsa-key', + privateKey: '-----BEGIN RSA PRIVATE KEY-----\ndata\n-----END RSA PRIVATE KEY-----', + }); + expect(prisma.sshKey.create).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ keyType: 'rsa' }), + }), + ); + }); + + it('should detect ed25519 key type from OPENSSH format', async () => { + prisma.sshKey.create.mockResolvedValue({ id: 1 }); + await service.create(1, { + name: 'ed25519-key', + privateKey: '-----BEGIN OPENSSH PRIVATE KEY-----\ndata\n-----END OPENSSH PRIVATE KEY-----', + }); + expect(prisma.sshKey.create).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ keyType: 'ed25519' }), + }), + ); + }); + }); + + describe('remove', () => { + it('should delete owned key', async () => { + prisma.sshKey.findUnique.mockResolvedValue({ id: 1, userId: 1 }); + prisma.sshKey.delete.mockResolvedValue({ id: 1 }); + await service.remove(1, 1); + expect(prisma.sshKey.delete).toHaveBeenCalled(); + }); + + it('should throw for non-owner', async () => { + prisma.sshKey.findUnique.mockResolvedValue({ id: 1, userId: 2 }); + await expect(service.remove(1, 1)).rejects.toThrow(ForbiddenException); + }); + }); +}); +``` + +- [ ] **Step 2: Run tests** + +Run: `cd backend && npx jest src/vault/ssh-keys.service.spec.ts --verbose` +Expected: 8 tests PASS + +- [ ] **Step 3: Commit** + +```bash +git add backend/src/vault/ssh-keys.service.spec.ts +git commit -m "test: SSH keys service — CRUD, ownership, key type detection, encryption" +``` + +--- + +## Chunk 3: Backend Auth Tests + +### Task 5: Auth Guards Tests + +**Files:** +- Create: `backend/src/auth/jwt-auth.guard.spec.ts` +- Create: `backend/src/auth/admin.guard.spec.ts` +- Create: `backend/src/auth/ws-auth.guard.spec.ts` + +- [ ] **Step 1: Write JWT auth guard test** + +```typescript +// backend/src/auth/jwt-auth.guard.spec.ts +import { JwtAuthGuard } from './jwt-auth.guard'; + +describe('JwtAuthGuard', () => { + it('should be defined', () => { + expect(new JwtAuthGuard()).toBeDefined(); + }); + + it('should extend AuthGuard("jwt")', () => { + const guard = new JwtAuthGuard(); + expect(guard).toBeInstanceOf(JwtAuthGuard); + }); +}); +``` + +- [ ] **Step 2: Write admin guard test** + +```typescript +// backend/src/auth/admin.guard.spec.ts +import { AdminGuard } from './admin.guard'; +import { ExecutionContext, ForbiddenException } from '@nestjs/common'; + +function createMockContext(user: any): ExecutionContext { + return { + switchToHttp: () => ({ + getRequest: () => ({ user }), + }), + } as any; +} + +describe('AdminGuard', () => { + const guard = new AdminGuard(); + + it('should allow admin role', () => { + const ctx = createMockContext({ role: 'admin' }); + expect(guard.canActivate(ctx)).toBe(true); + }); + + it('should reject non-admin role', () => { + const ctx = createMockContext({ role: 'user' }); + expect(() => guard.canActivate(ctx)).toThrow(ForbiddenException); + }); + + it('should reject missing user', () => { + const ctx = createMockContext(undefined); + expect(() => guard.canActivate(ctx)).toThrow(ForbiddenException); + }); +}); +``` + +- [ ] **Step 3: Write WS auth guard test** + +```typescript +// backend/src/auth/ws-auth.guard.spec.ts +import { WsAuthGuard } from './ws-auth.guard'; +import { JwtService } from '@nestjs/jwt'; +import { AuthController } from './auth.controller'; + +describe('WsAuthGuard', () => { + let guard: WsAuthGuard; + let jwt: any; + + beforeEach(() => { + jwt = { + verify: jest.fn().mockReturnValue({ sub: 1, email: 'test@test.com' }), + }; + guard = new WsAuthGuard(jwt as any); + }); + + it('should authenticate via cookie', () => { + const req = { headers: { cookie: 'wraith_token=valid-jwt; other=stuff' } }; + const result = guard.validateClient({}, req); + expect(jwt.verify).toHaveBeenCalledWith('valid-jwt'); + expect(result).toEqual({ sub: 1, email: 'test@test.com' }); + }); + + it('should authenticate via WS ticket', () => { + const originalConsume = AuthController.consumeWsTicket; + AuthController.consumeWsTicket = jest.fn().mockReturnValue({ sub: 1, email: 'test@test.com', role: 'admin' }); + const req = { url: '/api/ws/terminal?ticket=abc123', headers: {} }; + const result = guard.validateClient({}, req); + expect(result).toEqual({ sub: 1, email: 'test@test.com' }); + AuthController.consumeWsTicket = originalConsume; + }); + + it('should fall back to legacy URL token', () => { + const req = { url: '/api/ws/terminal?token=legacy-jwt', headers: {} }; + guard.validateClient({}, req); + expect(jwt.verify).toHaveBeenCalledWith('legacy-jwt'); + }); + + it('should return null for no credentials', () => { + const req = { url: '/api/ws/terminal', headers: {} }; + const result = guard.validateClient({}, req); + expect(result).toBeNull(); + }); + + it('should return null for invalid JWT', () => { + jwt.verify.mockImplementation(() => { throw new Error('invalid'); }); + const req = { headers: { cookie: 'wraith_token=bad-jwt' } }; + const result = guard.validateClient({}, req); + expect(result).toBeNull(); + }); +}); +``` + +- [ ] **Step 4: Run all guard tests** + +Run: `cd backend && npx jest src/auth/*.guard.spec.ts --verbose` +Expected: 10 tests PASS + +- [ ] **Step 5: Commit** + +```bash +git add backend/src/auth/jwt-auth.guard.spec.ts backend/src/auth/admin.guard.spec.ts backend/src/auth/ws-auth.guard.spec.ts +git commit -m "test: auth guards — JWT, admin, WS (cookie, ticket, legacy token)" +``` + +### Task 6: Auth Service Tests + +**Files:** +- Create: `backend/src/auth/auth.service.spec.ts` +- Reference: `backend/src/auth/auth.service.ts` + +- [ ] **Step 1: Write auth service tests** + +```typescript +// backend/src/auth/auth.service.spec.ts +import { Test, TestingModule } from '@nestjs/testing'; +import { AuthService } from './auth.service'; +import { PrismaService } from '../prisma/prisma.service'; +import { JwtService } from '@nestjs/jwt'; +import { EncryptionService } from '../vault/encryption.service'; +import { UnauthorizedException, BadRequestException, NotFoundException, ForbiddenException } from '@nestjs/common'; +import * as argon2 from 'argon2'; +import * as bcrypt from 'bcrypt'; + +describe('AuthService', () => { + let service: AuthService; + let prisma: any; + let jwt: any; + let encryption: any; + + beforeEach(async () => { + prisma = { + user: { + findUnique: jest.fn(), + findMany: jest.fn(), + create: jest.fn(), + update: jest.fn(), + delete: jest.fn(), + }, + }; + jwt = { sign: jest.fn().mockReturnValue('mock-jwt-token') }; + encryption = { + encrypt: jest.fn().mockResolvedValue('v2:encrypted'), + decrypt: jest.fn().mockResolvedValue('decrypted-secret'), + }; + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + AuthService, + { provide: PrismaService, useValue: prisma }, + { provide: JwtService, useValue: jwt }, + { provide: EncryptionService, useValue: encryption }, + ], + }).compile(); + + service = module.get(AuthService); + await service.onModuleInit(); + }); + + describe('login', () => { + it('should return access_token and user for valid credentials', async () => { + const hash = await argon2.hash('correct-password', { type: argon2.argon2id }); + prisma.user.findUnique.mockResolvedValue({ + id: 1, email: 'test@test.com', passwordHash: hash, + displayName: 'Test', role: 'admin', totpEnabled: false, + }); + const result = await service.login('test@test.com', 'correct-password'); + expect(result).toHaveProperty('access_token', 'mock-jwt-token'); + expect(result).toHaveProperty('user'); + expect((result as any).user.email).toBe('test@test.com'); + }); + + it('should throw for wrong password', async () => { + const hash = await argon2.hash('correct', { type: argon2.argon2id }); + prisma.user.findUnique.mockResolvedValue({ + id: 1, email: 'test@test.com', passwordHash: hash, + totpEnabled: false, + }); + await expect(service.login('test@test.com', 'wrong')).rejects.toThrow(UnauthorizedException); + }); + + it('should throw for non-existent user (constant time)', async () => { + prisma.user.findUnique.mockResolvedValue(null); + await expect(service.login('nobody@test.com', 'pass')).rejects.toThrow(UnauthorizedException); + }); + + it('should auto-upgrade bcrypt hash to argon2id on login', async () => { + const bcryptHash = await bcrypt.hash('password', 10); + prisma.user.findUnique.mockResolvedValue({ + id: 1, email: 'legacy@test.com', passwordHash: bcryptHash, + displayName: 'Legacy', role: 'user', totpEnabled: false, + }); + prisma.user.update.mockResolvedValue({}); + await service.login('legacy@test.com', 'password'); + // Should have called update to upgrade the hash + expect(prisma.user.update).toHaveBeenCalledWith( + expect.objectContaining({ + where: { id: 1 }, + data: expect.objectContaining({ + passwordHash: expect.stringContaining('$argon2id$'), + }), + }), + ); + }); + + it('should return requires_totp when TOTP enabled but no code provided', async () => { + const hash = await argon2.hash('pass', { type: argon2.argon2id }); + prisma.user.findUnique.mockResolvedValue({ + id: 1, email: 'totp@test.com', passwordHash: hash, + totpEnabled: true, totpSecret: 'v2:encrypted-secret', + }); + const result = await service.login('totp@test.com', 'pass'); + expect(result).toEqual({ requires_totp: true }); + }); + }); + + describe('createUser', () => { + it('should hash password with argon2id', async () => { + prisma.user.findUnique.mockResolvedValue(null); // no existing user + prisma.user.create.mockResolvedValue({ id: 1, email: 'new@test.com', displayName: null, role: 'user' }); + await service.createUser({ email: 'new@test.com', password: 'StrongPass1!' }); + const createCall = prisma.user.create.mock.calls[0][0]; + expect(createCall.data.passwordHash).toMatch(/^\$argon2id\$/); + }); + + it('should throw for duplicate email', async () => { + prisma.user.findUnique.mockResolvedValue({ id: 1 }); + await expect(service.createUser({ email: 'dup@test.com', password: 'pass' })) + .rejects.toThrow(BadRequestException); + }); + }); + + describe('adminDeleteUser', () => { + it('should prevent self-deletion', async () => { + await expect(service.adminDeleteUser(1, 1)).rejects.toThrow(ForbiddenException); + }); + + it('should delete another user', async () => { + prisma.user.findUnique.mockResolvedValue({ id: 2 }); + prisma.user.delete.mockResolvedValue({ id: 2 }); + await service.adminDeleteUser(2, 1); + expect(prisma.user.delete).toHaveBeenCalledWith({ where: { id: 2 } }); + }); + }); + + describe('totpSetup', () => { + it('should encrypt TOTP secret before storage', async () => { + prisma.user.findUnique.mockResolvedValue({ id: 1, totpEnabled: false }); + prisma.user.update.mockResolvedValue({}); + const result = await service.totpSetup(1); + expect(encryption.encrypt).toHaveBeenCalled(); + expect(result).toHaveProperty('secret'); + expect(result).toHaveProperty('qrCode'); + }); + + it('should throw if TOTP already enabled', async () => { + prisma.user.findUnique.mockResolvedValue({ id: 1, totpEnabled: true }); + await expect(service.totpSetup(1)).rejects.toThrow(BadRequestException); + }); + }); + + describe('updateProfile', () => { + it('should require current password for password change', async () => { + prisma.user.findUnique.mockResolvedValue({ id: 1, passwordHash: 'hash' }); + await expect(service.updateProfile(1, { newPassword: 'new' })) + .rejects.toThrow(BadRequestException); + }); + }); +}); +``` + +- [ ] **Step 2: Run tests** + +Run: `cd backend && npx jest src/auth/auth.service.spec.ts --verbose` +Expected: ~12 tests PASS + +- [ ] **Step 3: Commit** + +```bash +git add backend/src/auth/auth.service.spec.ts +git commit -m "test: auth service — login, bcrypt migration, TOTP, admin CRUD, profile" +``` + +### Task 7: Auth Controller Tests + +**Files:** +- Create: `backend/src/auth/auth.controller.spec.ts` + +- [ ] **Step 1: Write auth controller tests** + +```typescript +// backend/src/auth/auth.controller.spec.ts +import { Test, TestingModule } from '@nestjs/testing'; +import { AuthController } from './auth.controller'; +import { AuthService } from './auth.service'; +import { JwtService } from '@nestjs/jwt'; + +describe('AuthController', () => { + let controller: AuthController; + let authService: any; + + beforeEach(async () => { + authService = { + login: jest.fn(), + getProfile: jest.fn(), + totpSetup: jest.fn(), + listUsers: jest.fn(), + }; + + const module: TestingModule = await Test.createTestingModule({ + controllers: [AuthController], + providers: [ + { provide: AuthService, useValue: authService }, + { provide: JwtService, useValue: { sign: jest.fn(), verify: jest.fn() } }, + ], + }).compile(); + + controller = module.get(AuthController); + }); + + describe('login', () => { + it('should set httpOnly cookie on successful login', async () => { + authService.login.mockResolvedValue({ + access_token: 'jwt-token', + user: { id: 1, email: 'test@test.com', role: 'admin' }, + }); + const res = { cookie: jest.fn() }; + const result = await controller.login({ email: 'test@test.com', password: 'pass' } as any, res); + expect(res.cookie).toHaveBeenCalledWith('wraith_token', 'jwt-token', expect.objectContaining({ + httpOnly: true, + sameSite: 'strict', + path: '/', + })); + expect(result).toEqual({ user: expect.objectContaining({ email: 'test@test.com' }) }); + // Token should NOT be in response body + expect(result).not.toHaveProperty('access_token'); + }); + + it('should pass through requires_totp without setting cookie', async () => { + authService.login.mockResolvedValue({ requires_totp: true }); + const res = { cookie: jest.fn() }; + const result = await controller.login({ email: 'test@test.com', password: 'pass' } as any, res); + expect(res.cookie).not.toHaveBeenCalled(); + expect(result).toEqual({ requires_totp: true }); + }); + }); + + describe('logout', () => { + it('should clear cookie', () => { + const res = { clearCookie: jest.fn() }; + const result = controller.logout(res); + expect(res.clearCookie).toHaveBeenCalledWith('wraith_token', { path: '/' }); + }); + }); + + describe('ws-ticket', () => { + it('should issue a ticket', () => { + const req = { user: { sub: 1, email: 'test@test.com', role: 'admin' } }; + const result = controller.issueWsTicket(req); + expect(result).toHaveProperty('ticket'); + expect(result.ticket).toHaveLength(64); // 32 bytes hex + }); + + it('should consume ticket exactly once', () => { + const req = { user: { sub: 1, email: 'test@test.com', role: 'admin' } }; + const { ticket } = controller.issueWsTicket(req); + const first = AuthController.consumeWsTicket(ticket); + expect(first).toEqual(expect.objectContaining({ sub: 1 })); + const second = AuthController.consumeWsTicket(ticket); + expect(second).toBeNull(); // Single-use + }); + }); +}); +``` + +- [ ] **Step 2: Run tests** + +Run: `cd backend && npx jest src/auth/auth.controller.spec.ts --verbose` +Expected: 6 tests PASS + +- [ ] **Step 3: Commit** + +```bash +git add backend/src/auth/auth.controller.spec.ts +git commit -m "test: auth controller — cookie login, logout, WS ticket issuance/consumption" +``` + +--- + +## Chunk 4: Frontend Test Infrastructure + Tests + +### Task 8: Frontend Test Infrastructure + +**Files:** +- Create: `frontend/vitest.config.ts` +- Create: `frontend/tests/setup.ts` +- Modify: `frontend/package.json` + +- [ ] **Step 1: Install Vitest dependencies** + +Run: `cd frontend && npm install --save-dev vitest @vue/test-utils @pinia/testing happy-dom` + +- [ ] **Step 2: Create Vitest config** + +```typescript +// frontend/vitest.config.ts +import { defineConfig } from 'vitest/config'; +import { resolve } from 'path'; + +export default defineConfig({ + test: { + environment: 'happy-dom', + globals: true, + setupFiles: ['./tests/setup.ts'], + include: ['tests/**/*.spec.ts'], + }, + resolve: { + alias: { + '~': resolve(__dirname, '.'), + '#imports': resolve(__dirname, '.nuxt/imports.d.ts'), + }, + }, +}); +``` + +- [ ] **Step 3: Create test setup with Nuxt auto-import mocks** + +```typescript +// frontend/tests/setup.ts +import { vi } from 'vitest'; + +// Mock Nuxt auto-imports +(globalThis as any).$fetch = vi.fn(); +(globalThis as any).navigateTo = vi.fn(); +(globalThis as any).defineNuxtRouteMiddleware = (fn: any) => fn; +(globalThis as any).defineNuxtPlugin = vi.fn(); +(globalThis as any).definePageMeta = vi.fn(); +(globalThis as any).useAuthStore = vi.fn(); +(globalThis as any).useSessionStore = vi.fn(); +(globalThis as any).useConnectionStore = vi.fn(); + +// Mock ref, computed, onMounted from Vue (Nuxt auto-imports these) +import { ref, computed, onMounted, watch } from 'vue'; +(globalThis as any).ref = ref; +(globalThis as any).computed = computed; +(globalThis as any).onMounted = onMounted; +(globalThis as any).watch = watch; +``` + +- [ ] **Step 4: Add test scripts to frontend package.json** + +Add to `frontend/package.json` scripts: +```json +"test": "vitest run", +"test:watch": "vitest", +"test:cov": "vitest run --coverage" +``` + +- [ ] **Step 5: Commit** + +```bash +git add frontend/vitest.config.ts frontend/tests/setup.ts frontend/package.json +git commit -m "test: frontend test infrastructure — Vitest, happy-dom, Nuxt auto-import mocks" +``` + +### Task 9: Auth Store Tests + +**Files:** +- Create: `frontend/tests/stores/auth.store.spec.ts` +- Reference: `frontend/stores/auth.store.ts` + +- [ ] **Step 1: Write auth store tests** + +```typescript +// frontend/tests/stores/auth.store.spec.ts +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { setActivePinia, createPinia } from 'pinia'; +import { useAuthStore } from '../../stores/auth.store'; + +describe('Auth Store', () => { + beforeEach(() => { + setActivePinia(createPinia()); + vi.resetAllMocks(); + }); + + describe('initial state', () => { + it('should start with no user', () => { + const auth = useAuthStore(); + expect(auth.user).toBeNull(); + expect(auth.isAuthenticated).toBe(false); + expect(auth.isAdmin).toBe(false); + }); + }); + + describe('login', () => { + it('should store user on successful login', async () => { + const mockUser = { id: 1, email: 'test@test.com', displayName: 'Test', role: 'admin' }; + (globalThis as any).$fetch = vi.fn().mockResolvedValue({ user: mockUser }); + + const auth = useAuthStore(); + await auth.login('test@test.com', 'password'); + expect(auth.user).toEqual(mockUser); + expect(auth.isAuthenticated).toBe(true); + expect(auth.isAdmin).toBe(true); + }); + + it('should return requires_totp without storing user', async () => { + (globalThis as any).$fetch = vi.fn().mockResolvedValue({ requires_totp: true }); + + const auth = useAuthStore(); + const result = await auth.login('test@test.com', 'password'); + expect(result).toEqual({ requires_totp: true }); + expect(auth.user).toBeNull(); + }); + + it('should not store token in state (httpOnly cookie)', async () => { + (globalThis as any).$fetch = vi.fn().mockResolvedValue({ + user: { id: 1, email: 'test@test.com', role: 'user' }, + }); + + const auth = useAuthStore(); + await auth.login('test@test.com', 'password'); + expect((auth as any).token).toBeUndefined(); + }); + }); + + describe('logout', () => { + it('should clear user and call logout API', async () => { + (globalThis as any).$fetch = vi.fn().mockResolvedValue({}); + (globalThis as any).navigateTo = vi.fn(); + + const auth = useAuthStore(); + auth.user = { id: 1, email: 'test@test.com', displayName: null, role: 'admin' }; + await auth.logout(); + expect(auth.user).toBeNull(); + expect(auth.isAuthenticated).toBe(false); + expect(navigateTo).toHaveBeenCalledWith('/login'); + }); + }); + + describe('fetchProfile', () => { + it('should populate user on success', async () => { + const mockUser = { id: 1, email: 'test@test.com', displayName: 'Test', role: 'user' }; + (globalThis as any).$fetch = vi.fn().mockResolvedValue(mockUser); + + const auth = useAuthStore(); + await auth.fetchProfile(); + expect(auth.user).toEqual(mockUser); + }); + + it('should clear user on failure', async () => { + (globalThis as any).$fetch = vi.fn().mockRejectedValue(new Error('401')); + + const auth = useAuthStore(); + auth.user = { id: 1, email: 'old@test.com', displayName: null, role: 'user' }; + await auth.fetchProfile(); + expect(auth.user).toBeNull(); + }); + }); + + describe('getWsTicket', () => { + it('should return ticket string', async () => { + (globalThis as any).$fetch = vi.fn().mockResolvedValue({ ticket: 'abc123' }); + + const auth = useAuthStore(); + const ticket = await auth.getWsTicket(); + expect(ticket).toBe('abc123'); + }); + }); + + describe('getters', () => { + it('isAdmin should be true for admin role', () => { + const auth = useAuthStore(); + auth.user = { id: 1, email: 'a@b.com', displayName: null, role: 'admin' }; + expect(auth.isAdmin).toBe(true); + }); + + it('isAdmin should be false for user role', () => { + const auth = useAuthStore(); + auth.user = { id: 1, email: 'a@b.com', displayName: null, role: 'user' }; + expect(auth.isAdmin).toBe(false); + }); + }); +}); +``` + +- [ ] **Step 2: Run tests** + +Run: `cd frontend && npx vitest run tests/stores/auth.store.spec.ts` +Expected: 10 tests PASS + +- [ ] **Step 3: Commit** + +```bash +git add frontend/tests/stores/auth.store.spec.ts +git commit -m "test: auth store — login, logout, fetchProfile, getWsTicket, getters" +``` + +### Task 10: Connection Store Tests + +**Files:** +- Create: `frontend/tests/stores/connection.store.spec.ts` + +- [ ] **Step 1: Write connection store tests** + +```typescript +// frontend/tests/stores/connection.store.spec.ts +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { setActivePinia, createPinia } from 'pinia'; +import { useConnectionStore } from '../../stores/connection.store'; + +describe('Connection Store', () => { + beforeEach(() => { + setActivePinia(createPinia()); + vi.resetAllMocks(); + (globalThis as any).$fetch = vi.fn().mockResolvedValue([]); + }); + + describe('fetchHosts', () => { + it('should populate hosts from API', async () => { + const mockHosts = [{ id: 1, name: 'Server 1' }]; + (globalThis as any).$fetch = vi.fn().mockResolvedValue(mockHosts); + + const store = useConnectionStore(); + await store.fetchHosts(); + expect(store.hosts).toEqual(mockHosts); + expect((globalThis as any).$fetch).toHaveBeenCalledWith('/api/hosts'); + }); + + it('should not send Authorization header', async () => { + const store = useConnectionStore(); + await store.fetchHosts(); + const callArgs = (globalThis as any).$fetch.mock.calls[0]; + expect(callArgs[1]?.headers?.Authorization).toBeUndefined(); + }); + }); + + describe('createHost', () => { + it('should POST and refresh hosts', async () => { + (globalThis as any).$fetch = vi.fn() + .mockResolvedValueOnce({ id: 1, name: 'New' }) // create + .mockResolvedValueOnce([]); // fetchHosts + + const store = useConnectionStore(); + await store.createHost({ name: 'New', hostname: '10.0.0.1', port: 22 } as any); + expect((globalThis as any).$fetch).toHaveBeenCalledWith('/api/hosts', expect.objectContaining({ method: 'POST' })); + }); + }); + + describe('deleteHost', () => { + it('should DELETE and refresh hosts', async () => { + (globalThis as any).$fetch = vi.fn() + .mockResolvedValueOnce({}) // delete + .mockResolvedValueOnce([]); // fetchHosts + + const store = useConnectionStore(); + await store.deleteHost(1); + expect((globalThis as any).$fetch).toHaveBeenCalledWith('/api/hosts/1', expect.objectContaining({ method: 'DELETE' })); + }); + }); + + describe('group CRUD', () => { + it('should create group and refresh tree', async () => { + (globalThis as any).$fetch = vi.fn().mockResolvedValue([]); + const store = useConnectionStore(); + await store.createGroup({ name: 'Production' }); + expect((globalThis as any).$fetch).toHaveBeenCalledWith('/api/groups', expect.objectContaining({ + method: 'POST', + body: { name: 'Production' }, + })); + }); + }); +}); +``` + +- [ ] **Step 2: Run tests** + +Run: `cd frontend && npx vitest run tests/stores/connection.store.spec.ts` +Expected: 5 tests PASS + +- [ ] **Step 3: Commit** + +```bash +git add frontend/tests/stores/connection.store.spec.ts +git commit -m "test: connection store — host/group CRUD, no auth headers" +``` + +### Task 11: Vault Composable + Admin Middleware Tests + +**Files:** +- Create: `frontend/tests/composables/useVault.spec.ts` +- Create: `frontend/tests/middleware/admin.spec.ts` + +- [ ] **Step 1: Write vault composable tests** + +```typescript +// frontend/tests/composables/useVault.spec.ts +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { useVault } from '../../composables/useVault'; + +describe('useVault', () => { + beforeEach(() => { + vi.resetAllMocks(); + (globalThis as any).$fetch = vi.fn().mockResolvedValue([]); + }); + + it('should list keys without auth header', async () => { + const { listKeys } = useVault(); + await listKeys(); + expect((globalThis as any).$fetch).toHaveBeenCalledWith('/api/ssh-keys'); + const callArgs = (globalThis as any).$fetch.mock.calls[0]; + expect(callArgs[1]?.headers?.Authorization).toBeUndefined(); + }); + + it('should list credentials without auth header', async () => { + const { listCredentials } = useVault(); + await listCredentials(); + expect((globalThis as any).$fetch).toHaveBeenCalledWith('/api/credentials'); + }); + + it('should import key via POST', async () => { + const { importKey } = useVault(); + await importKey({ name: 'key', privateKey: 'pem-data' }); + expect((globalThis as any).$fetch).toHaveBeenCalledWith('/api/ssh-keys', expect.objectContaining({ method: 'POST' })); + }); + + it('should delete key via DELETE', async () => { + const { deleteKey } = useVault(); + await deleteKey(5); + expect((globalThis as any).$fetch).toHaveBeenCalledWith('/api/ssh-keys/5', expect.objectContaining({ method: 'DELETE' })); + }); + + it('should create credential via POST', async () => { + const { createCredential } = useVault(); + await createCredential({ name: 'cred', username: 'admin' }); + expect((globalThis as any).$fetch).toHaveBeenCalledWith('/api/credentials', expect.objectContaining({ method: 'POST' })); + }); + + it('should update credential via PUT', async () => { + const { updateCredential } = useVault(); + await updateCredential(3, { name: 'updated' }); + expect((globalThis as any).$fetch).toHaveBeenCalledWith('/api/credentials/3', expect.objectContaining({ method: 'PUT' })); + }); + + it('should delete credential via DELETE', async () => { + const { deleteCredential } = useVault(); + await deleteCredential(3); + expect((globalThis as any).$fetch).toHaveBeenCalledWith('/api/credentials/3', expect.objectContaining({ method: 'DELETE' })); + }); +}); +``` + +- [ ] **Step 2: Write admin middleware tests** + +```typescript +// frontend/tests/middleware/admin.spec.ts +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +describe('Admin Middleware', () => { + let middleware: any; + + beforeEach(() => { + vi.resetAllMocks(); + (globalThis as any).navigateTo = vi.fn().mockReturnValue('/'); + }); + + it('should redirect non-admin to /', async () => { + (globalThis as any).useAuthStore = vi.fn().mockReturnValue({ isAdmin: false }); + // Re-import to pick up fresh mock + const mod = await import('../../middleware/admin.ts'); + middleware = mod.default; + const result = middleware({} as any, {} as any); + expect(navigateTo).toHaveBeenCalledWith('/'); + }); + + it('should allow admin through', async () => { + (globalThis as any).useAuthStore = vi.fn().mockReturnValue({ isAdmin: true }); + const mod = await import('../../middleware/admin.ts'); + middleware = mod.default; + const result = middleware({} as any, {} as any); + // Should not redirect — returns undefined + expect(result).toBeUndefined(); + }); +}); +``` + +- [ ] **Step 3: Run all frontend tests** + +Run: `cd frontend && npx vitest run` +Expected: ~24 tests PASS + +- [ ] **Step 4: Commit** + +```bash +git add frontend/tests/composables/useVault.spec.ts frontend/tests/middleware/admin.spec.ts +git commit -m "test: vault composable + admin middleware — API calls, auth headers, route guard" +``` + +--- + +## Chunk 5: Integration Verification + Pre-commit Hook + +### Task 12: Run Full Test Suites + +- [ ] **Step 1: Run all backend tests** + +Run: `cd backend && npx jest --verbose` +Expected: ~66 tests PASS across 8 spec files + +- [ ] **Step 2: Run all frontend tests** + +Run: `cd frontend && npx vitest run` +Expected: ~30 tests PASS across 4 spec files + +- [ ] **Step 3: Add test:cov script to backend** + +Add to `backend/package.json` scripts: `"test:cov": "jest --coverage"` + +- [ ] **Step 4: Commit** + +```bash +git add backend/package.json +git commit -m "chore: add test:cov script to backend" +``` + +### Task 13: Final Push + +- [ ] **Step 1: Push everything** + +```bash +git push +``` + +- [ ] **Step 2: Verify test count** + +Run: `cd backend && npx jest --verbose 2>&1 | tail -5` +Run: `cd frontend && npx vitest run 2>&1 | tail -5` + +Report total test count to Commander. diff --git a/docs/screenshots/wraith-final.png b/docs/screenshots/wraith-final.png new file mode 100644 index 0000000..674a7d7 Binary files /dev/null and b/docs/screenshots/wraith-final.png differ diff --git a/docs/spikes/multi-window-results.md b/docs/spikes/multi-window-results.md new file mode 100644 index 0000000..083a504 --- /dev/null +++ b/docs/spikes/multi-window-results.md @@ -0,0 +1,129 @@ +# Spike: Multi-Window Support in Wails v3 + +**Status:** Research-based (not yet validated on Windows) +**Date:** 2026-03-17 +**Target platform:** Windows (developing on macOS) +**Wails version:** v3.0.0-alpha.74 + +--- + +## Context + +Wraith needs to support detached sessions — users should be able to pop out +an SSH or RDP session into its own window while the main connection manager +remains open. This spike evaluates three approaches, ranked by preference. + +--- + +## Plan A: Wails v3 Native Multi-Window + +**Status: LIKELY WORKS** based on API documentation. + +### How it works + +- `app.Window.NewWithOptions()` creates a new OS-level window at runtime. +- Each window can load a different URL or frontend route (e.g., + `/session/rdp/3` in one window, `/` in the main window). +- All windows share the same Go backend services — no IPC or inter-process + marshalling required. Bindings registered on the application are callable + from any window. +- Window lifecycle events (`OnClose`, `OnFocus`, etc.) are available for + cleanup. + +### Example (pseudocode) + +```go +win, err := app.Window.NewWithOptions(application.WindowOptions{ + Title: "RDP — server-01", + Width: 1280, + Height: 720, + URL: "/session/rdp/3", +}) +``` + +### Risks + +| Risk | Severity | Mitigation | +|------|----------|------------| +| Alpha API — method signatures may change before v3 stable | Medium | Pin to a known-good alpha tag; wrap calls behind an internal interface so migration is a single-file change. | +| Platform-specific quirks on Windows (DPI, focus, taskbar grouping) | Low | Test on Windows during Phase 2. Wails uses webview2 on Windows which is mature. | +| Window count limits or resource leaks | Low | Cap concurrent detached windows (e.g., 8). Ensure `OnClose` releases resources. | + +--- + +## Plan B: Floating Panels (CSS-based) + +**Status: FALLBACK** — no external dependency, purely frontend. + +### How it works + +- Detached sessions render as draggable, resizable `position: fixed` panels + within the main Wails window. +- Each panel contains its own Vue component instance (terminal emulator or + RDP canvas). +- Panels can be minimised, maximised within the viewport, or snapped to + edges. + +### Pros + +- Zero dependency on Wails multi-window API. +- Works on any platform without additional testing. +- Simpler state management — everything lives in one window context. + +### Cons + +- Sessions share the same viewport — limited screen real estate. +- Cannot span multiple monitors. +- Feels less native than real OS windows. + +### Implementation cost + +Small. Requires a `` wrapper component with drag/resize +handlers. Libraries like `vue3-draggable-resizable` exist but a lightweight +custom implementation (~150 LOC) is preferable to avoid dependency churn. + +--- + +## Plan C: Browser Mode + +**Status: EMERGENCY** — last resort if both Plan A and Plan B are inadequate. + +### How it works + +- Wails v3 supports a server mode where the frontend is served over HTTP on + `localhost`. +- Detached sessions open in the user's default browser via + `open(url, '_blank')` or `runtime.BrowserOpenURL()`. +- The browser tab communicates with Go services through the same HTTP + endpoint. + +### Pros + +- Guaranteed to work — it is just a web page. +- Users can arrange tabs freely across monitors. + +### Cons + +- Breaks the desktop-app experience. +- Browser tabs lack access to Wails runtime bindings; all communication must + go through HTTP/WebSocket, requiring a parallel transport layer. +- Security surface increases — localhost HTTP server is accessible to other + local processes. + +--- + +## Recommendation + +**Start with Plan A.** The Wails v3 `NewWithOptions` API is documented and +consistent with how other multi-window desktop frameworks (Electron, +Tauri v2) work. The alpha stability risk is mitigated by wrapping calls +behind an internal interface. + +If Plan A fails during Windows validation, **Plan B requires only frontend +CSS changes** — no backend work is wasted. Plan C is reserved for scenarios +where neither A nor B is viable. + +## Next Step + +Validate Plan A on Windows during Phase 2 when SSH sessions exist and there +is a real payload to render in a second window. diff --git a/docs/spikes/rdp-frame-transport-results.md b/docs/spikes/rdp-frame-transport-results.md new file mode 100644 index 0000000..febf9e8 --- /dev/null +++ b/docs/spikes/rdp-frame-transport-results.md @@ -0,0 +1,171 @@ +# Spike: RDP Frame Transport Mechanisms + +**Status:** Research-based (not yet benchmarked) +**Date:** 2026-03-17 +**Target platform:** Windows (developing on macOS) +**Wails version:** v3.0.0-alpha.74 + +--- + +## Context + +When Wraith connects to a remote desktop via FreeRDP, the Go backend +receives raw bitmap frames that must be delivered to the frontend for +rendering on an HTML ``. This spike evaluates three transport +approaches, estimating throughput for a 1920x1080 session at 30 fps. + +--- + +## Approach 1: Local HTTP Endpoint + +### How it works + +1. Go spins up a local HTTP server on a random high port + (`net.Listen("tcp", "127.0.0.1:0")`). +2. Each frame is JPEG-encoded and served at a predictable URL + (e.g., `http://127.0.0.1:{port}/frame?session=3`). +3. The frontend fetches frames via `fetch()`, `` tag, or + `ReadableStream` for chunked delivery. + +### Throughput estimate + +| Metric | Value | +|--------|-------| +| 1080p RGBA raw | ~8 MB/frame | +| 1080p JPEG (quality 80) | ~100-200 KB/frame | +| At 30 fps (JPEG) | ~3-6 MB/s | +| Loopback bandwidth | >1 GB/s | + +Loopback HTTP can handle this with headroom to spare. + +### Pros + +- No base64 overhead — binary JPEG bytes transfer directly. +- Standard HTTP semantics; easy to debug with browser DevTools. +- Can use `Transfer-Encoding: chunked` or Server-Sent Events for + push-based delivery. +- Can serve multiple sessions on the same server with different paths. + +### Cons + +- Requires an extra listening port on localhost. +- Potential firewall or endpoint-security issues on locked-down Windows + enterprise machines. +- Slightly more complex setup (port allocation, CORS headers for Wails + webview origin). + +--- + +## Approach 2: Wails Bindings (Base64) + +### How it works + +1. Go encodes each frame as a JPEG, then base64-encodes the result. +2. A Wails-bound method (`SessionService.GetFrame(sessionID)`) returns the + base64 string. +3. The frontend decodes the string, creates an `ImageBitmap` or sets it as a + data URI, and draws it on a ``. + +### Throughput estimate + +| Metric | Value | +|--------|-------| +| 1080p JPEG (quality 80) | ~100-200 KB/frame | +| Base64 of JPEG (+33%) | ~133-270 KB/frame | +| At 30 fps | ~4-8 MB/s of string data | +| Wails IPC overhead | Negligible for this payload size | + +This is feasible. Modern JavaScript engines handle base64 decoding at +several hundred MB/s. + +### Pros + +- No extra ports — everything flows through the existing Wails IPC channel. +- Works out of the box with Wails bindings; no additional infrastructure. +- No firewall concerns. + +### Cons + +- 33% base64 size overhead on every frame. +- CPU cost of `base64.StdEncoding.EncodeToString()` in Go and `atob()` in + JS on every frame (though both are fast). +- Polling-based unless combined with Wails events to signal frame + availability. +- May bottleneck at very high resolutions (4K) or high FPS (60+). + +--- + +## Approach 3: Wails Events (Streaming) + +### How it works + +1. Go emits each frame as a Wails event: + `app.EmitEvent("frame:3", base64JpegString)`. +2. The frontend subscribes: `wails.Events.On("frame:3", handler)`. +3. The handler decodes and renders on canvas. + +### Throughput estimate + +Same as Approach 2 — the payload is identical (base64 JPEG). The difference +is delivery mechanism (push vs. pull). + +### Pros + +- Push-based — the frontend receives frames as soon as they are available + with no polling delay. +- Natural Wails pattern; aligns with how other real-time data (connection + status, notifications) already flows. + +### Cons + +- Same 33% base64 overhead as Approach 2. +- Wails event bus may not be optimised for high-frequency, large-payload + events. This is unvalidated. +- Harder to apply backpressure — if the frontend cannot keep up, events + queue without flow control. + +--- + +## Throughput Summary + +| Approach | Payload/frame | 30 fps throughput | Extra infra | +|----------|--------------|-------------------|-------------| +| 1 — Local HTTP | ~150 KB (binary JPEG) | ~4.5 MB/s | Localhost port | +| 2 — Wails bindings | ~200 KB (base64 JPEG) | ~6 MB/s | None | +| 3 — Wails events | ~200 KB (base64 JPEG) | ~6 MB/s | None | + +All three approaches are within comfortable limits for 1080p at 30 fps. +The differentiator is operational simplicity, not raw throughput. + +--- + +## Recommendation + +**Start with Approach 2 (base64 JPEG via Wails bindings).** + +Rationale: + +1. JPEG compression brings 1080p frames down to ~200 KB, making the 33% + base64 overhead manageable (~6 MB/s at 30 fps). +2. No extra ports or firewall concerns — important for enterprise Windows + environments where Wraith will be deployed. +3. Simple implementation: one Go method, one frontend call per frame. +4. If polling latency is a problem, upgrade to Approach 3 (events) with + minimal code change — the payload encoding is identical. + +**If benchmarking reveals issues** (dropped frames, high CPU from +encoding), fall back to Approach 1 (local HTTP) which eliminates base64 +overhead entirely. The migration path is straightforward: replace the +`fetch(dataUri)` call with `fetch(httpUrl)`. + +--- + +## Next Step + +Benchmark during Phase 3 when FreeRDP integration is in progress and real +frame data is available. Key metrics to capture: + +- End-to-end frame latency (Go encode to canvas paint) +- CPU utilisation on both Go and browser sides +- Frame drop rate at 30 fps and 60 fps +- Memory pressure from base64 string allocation/GC diff --git a/docs/superpowers/plans/2026-03-12-wraith-build.md b/docs/superpowers/plans/2026-03-12-wraith-build.md new file mode 100644 index 0000000..61a591d --- /dev/null +++ b/docs/superpowers/plans/2026-03-12-wraith-build.md @@ -0,0 +1,3931 @@ +# Wraith Implementation Plan + +> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Build Wraith — a self-hosted MobaXterm replacement providing SSH+SFTP+RDP in a browser, deployed as a Docker stack. + +**Architecture:** Monorepo with `backend/` (NestJS 10, Prisma, ssh2, guacamole tunnel) and `frontend/` (Nuxt 3 SPA, xterm.js, Monaco, guacamole-common-js, PrimeVue 4). Single Docker container serves both API and static frontend. WebSocket gateways handle real-time terminal/SFTP/RDP data channels. + +**Tech Stack:** NestJS 10, Prisma 6, PostgreSQL 16, ssh2, guacd, Nuxt 3 (SPA mode), xterm.js 5, Monaco Editor, guacamole-common-js, PrimeVue 4, Tailwind CSS, Pinia + +**Spec:** `docs/superpowers/specs/2026-03-12-vigilance-remote-lean-design.md` + +--- + +## File Structure + +``` +wraith/ +├── docker-compose.yml +├── Dockerfile +├── .env.example +├── .gitignore +├── README.md +├── backend/ +│ ├── package.json +│ ├── tsconfig.json +│ ├── tsconfig.build.json +│ ├── nest-cli.json +│ ├── prisma/ +│ │ ├── schema.prisma +│ │ └── seed.ts +│ ├── src/ +│ │ ├── main.ts +│ │ ├── app.module.ts +│ │ ├── prisma/ +│ │ │ ├── prisma.service.ts +│ │ │ └── prisma.module.ts +│ │ ├── auth/ +│ │ │ ├── auth.module.ts +│ │ │ ├── auth.service.ts +│ │ │ ├── auth.controller.ts +│ │ │ ├── jwt.strategy.ts +│ │ │ ├── jwt-auth.guard.ts +│ │ │ ├── ws-auth.guard.ts +│ │ │ └── dto/ +│ │ │ └── login.dto.ts +│ │ ├── vault/ +│ │ │ ├── vault.module.ts +│ │ │ ├── encryption.service.ts +│ │ │ ├── credentials.service.ts +│ │ │ ├── credentials.controller.ts +│ │ │ ├── ssh-keys.service.ts +│ │ │ ├── ssh-keys.controller.ts +│ │ │ └── dto/ +│ │ │ ├── create-credential.dto.ts +│ │ │ ├── update-credential.dto.ts +│ │ │ ├── create-ssh-key.dto.ts +│ │ │ └── update-ssh-key.dto.ts +│ │ ├── connections/ +│ │ │ ├── connections.module.ts +│ │ │ ├── hosts.service.ts +│ │ │ ├── hosts.controller.ts +│ │ │ ├── groups.service.ts +│ │ │ ├── groups.controller.ts +│ │ │ └── dto/ +│ │ │ ├── create-host.dto.ts +│ │ │ ├── update-host.dto.ts +│ │ │ ├── create-group.dto.ts +│ │ │ └── update-group.dto.ts +│ │ ├── terminal/ +│ │ │ ├── terminal.module.ts +│ │ │ ├── terminal.gateway.ts +│ │ │ ├── sftp.gateway.ts +│ │ │ └── ssh-connection.service.ts +│ │ ├── rdp/ +│ │ │ ├── rdp.module.ts +│ │ │ ├── rdp.gateway.ts +│ │ │ └── guacamole.service.ts +│ │ └── settings/ +│ │ ├── settings.module.ts +│ │ ├── settings.service.ts +│ │ └── settings.controller.ts +│ └── test/ +│ ├── encryption.service.spec.ts +│ └── auth.service.spec.ts +├── frontend/ +│ ├── package.json +│ ├── nuxt.config.ts +│ ├── tailwind.config.ts +│ ├── app.vue +│ ├── assets/ +│ │ └── css/ +│ │ └── main.css +│ ├── layouts/ +│ │ ├── default.vue +│ │ └── auth.vue +│ ├── pages/ +│ │ ├── index.vue +│ │ ├── login.vue +│ │ ├── vault/ +│ │ │ ├── index.vue +│ │ │ ├── keys.vue +│ │ │ └── credentials.vue +│ │ └── settings.vue +│ ├── components/ +│ │ ├── connections/ +│ │ │ ├── HostTree.vue +│ │ │ ├── HostCard.vue +│ │ │ ├── HostEditDialog.vue +│ │ │ ├── GroupEditDialog.vue +│ │ │ └── QuickConnect.vue +│ │ ├── session/ +│ │ │ ├── SessionContainer.vue +│ │ │ └── SessionTab.vue +│ │ ├── terminal/ +│ │ │ ├── TerminalInstance.vue +│ │ │ ├── TerminalTabs.vue +│ │ │ └── SplitPane.vue +│ │ ├── sftp/ +│ │ │ ├── SftpSidebar.vue +│ │ │ ├── FileTree.vue +│ │ │ ├── FileEditor.vue +│ │ │ └── TransferStatus.vue +│ │ ├── rdp/ +│ │ │ ├── RdpCanvas.vue +│ │ │ └── RdpToolbar.vue +│ │ └── vault/ +│ │ ├── KeyImportDialog.vue +│ │ └── CredentialForm.vue +│ ├── composables/ +│ │ ├── useTerminal.ts +│ │ ├── useSftp.ts +│ │ ├── useRdp.ts +│ │ ├── useVault.ts +│ │ └── useConnections.ts +│ └── stores/ +│ ├── auth.store.ts +│ ├── session.store.ts +│ └── connection.store.ts +└── docs/ + └── superpowers/ + ├── specs/ + │ └── 2026-03-12-vigilance-remote-lean-design.md + └── plans/ + └── 2026-03-12-wraith-build.md +``` + +--- + +## Chunk 1: Foundation (Phase 1) + +### Task 1: Project Scaffold + Docker + +**Files:** +- Create: `.gitignore`, `.env.example`, `docker-compose.yml`, `Dockerfile`, `README.md` +- Create: `backend/package.json`, `backend/tsconfig.json`, `backend/tsconfig.build.json`, `backend/nest-cli.json` +- Create: `frontend/package.json`, `frontend/nuxt.config.ts`, `frontend/tailwind.config.ts` + +- [ ] **Step 1: Initialize git repo** + +```bash +cd /Users/vstockwell/repos/RDP-SSH-Client +git init +``` + +- [ ] **Step 2: Create `.gitignore`** + +```gitignore +node_modules/ +dist/ +.output/ +.nuxt/ +.env +*.log +.DS_Store +backend/prisma/*.db +``` + +- [ ] **Step 3: Create `.env.example`** + +```env +DB_PASSWORD=changeme +JWT_SECRET=generate-a-64-char-hex-string +ENCRYPTION_KEY=generate-a-64-char-hex-string +``` + +- [ ] **Step 4: Create `docker-compose.yml`** + +```yaml +services: + app: + build: . + ports: ["3000:3000"] + environment: + DATABASE_URL: postgresql://wraith:${DB_PASSWORD}@postgres:5432/wraith + JWT_SECRET: ${JWT_SECRET} + ENCRYPTION_KEY: ${ENCRYPTION_KEY} + GUACD_HOST: guacd + GUACD_PORT: "4822" + depends_on: + postgres: + condition: service_healthy + guacd: + condition: service_started + restart: unless-stopped + + guacd: + image: guacamole/guacd + restart: always + + postgres: + image: postgres:16-alpine + volumes: [pgdata:/var/lib/postgresql/data] + environment: + POSTGRES_DB: wraith + POSTGRES_USER: wraith + POSTGRES_PASSWORD: ${DB_PASSWORD} + healthcheck: + test: ["CMD-SHELL", "pg_isready -U wraith"] + interval: 5s + timeout: 3s + retries: 5 + +volumes: + pgdata: +``` + +- [ ] **Step 5: Create `Dockerfile`** + +Multi-stage build: frontend → static, backend → NestJS, production → serve both. + +```dockerfile +# Stage 1: Frontend build +FROM node:20-alpine AS frontend +WORKDIR /app/frontend +COPY frontend/package*.json ./ +RUN npm ci +COPY frontend/ ./ +RUN npx nuxi generate + +# Stage 2: Backend build +FROM node:20-alpine AS backend +WORKDIR /app/backend +COPY backend/package*.json ./ +RUN npm ci +COPY backend/ ./ +RUN npx prisma generate +RUN npm run build + +# Stage 3: Production +FROM node:20-alpine +WORKDIR /app +COPY --from=backend /app/backend/dist ./dist +COPY --from=backend /app/backend/node_modules ./node_modules +COPY --from=backend /app/backend/package.json ./ +COPY --from=backend /app/backend/prisma ./prisma +COPY --from=frontend /app/frontend/.output/public ./public +EXPOSE 3000 +CMD ["node", "dist/main.js"] +``` + +- [ ] **Step 6: Create `backend/package.json`** + +```json +{ + "name": "wraith-backend", + "version": "0.1.0", + "private": true, + "scripts": { + "build": "nest build", + "start": "node dist/main.js", + "dev": "nest start --watch", + "test": "jest", + "test:watch": "jest --watch", + "prisma:migrate": "prisma migrate dev", + "prisma:generate": "prisma generate", + "prisma:seed": "ts-node prisma/seed.ts" + }, + "dependencies": { + "@nestjs/common": "^10.0.0", + "@nestjs/core": "^10.0.0", + "@nestjs/jwt": "^10.0.0", + "@nestjs/passport": "^10.0.0", + "@nestjs/platform-express": "^10.0.0", + "@nestjs/platform-ws": "^10.0.0", + "@nestjs/serve-static": "^4.0.0", + "@nestjs/websockets": "^10.0.0", + "@prisma/client": "^6.0.0", + "bcrypt": "^5.1.0", + "class-transformer": "^0.5.1", + "class-validator": "^0.14.0", + "passport": "^0.7.0", + "passport-jwt": "^4.0.1", + "reflect-metadata": "^0.2.0", + "rxjs": "^7.8.0", + "ssh2": "^1.15.0", + "ws": "^8.16.0" + }, + "devDependencies": { + "@nestjs/cli": "^10.0.0", + "@nestjs/testing": "^10.0.0", + "@types/bcrypt": "^5.0.0", + "@types/node": "^20.0.0", + "@types/passport-jwt": "^4.0.0", + "@types/ssh2": "^1.15.0", + "@types/ws": "^8.5.0", + "jest": "^29.0.0", + "prisma": "^6.0.0", + "ts-jest": "^29.0.0", + "ts-node": "^10.9.0", + "typescript": "^5.3.0" + }, + "jest": { + "moduleFileExtensions": ["js", "json", "ts"], + "rootDir": ".", + "testRegex": ".*\\.spec\\.ts$", + "transform": { "^.+\\.ts$": "ts-jest" }, + "testEnvironment": "node", + "moduleNameMapper": { + "^@/(.*)$": "/src/$1" + } + } +} +``` + +- [ ] **Step 7: Create `backend/tsconfig.json` and `backend/tsconfig.build.json`** + +`tsconfig.json`: +```json +{ + "compilerOptions": { + "module": "commonjs", + "declaration": true, + "removeComments": true, + "emitDecoratorMetadata": true, + "experimentalDecorators": true, + "allowSyntheticDefaultImports": true, + "target": "ES2021", + "sourceMap": true, + "outDir": "./dist", + "baseUrl": "./", + "incremental": true, + "skipLibCheck": true, + "strictNullChecks": true, + "noImplicitAny": true, + "strictBindCallApply": true, + "forceConsistentCasingInFileNames": true, + "noFallthroughCasesInSwitch": true, + "paths": { "@/*": ["src/*"] } + } +} +``` + +`tsconfig.build.json`: +```json +{ + "extends": "./tsconfig.json", + "exclude": ["node_modules", "test", "dist", "**/*.spec.ts"] +} +``` + +`nest-cli.json`: +```json +{ + "$schema": "https://json.schemastore.org/nest-cli", + "collection": "@nestjs/schematics", + "sourceRoot": "src", + "compilerOptions": { + "deleteOutDir": true + } +} +``` + +- [ ] **Step 8: Create `frontend/package.json`** + +```json +{ + "name": "wraith-frontend", + "version": "0.1.0", + "private": true, + "scripts": { + "dev": "nuxi dev", + "build": "nuxi generate", + "preview": "nuxi preview" + }, + "dependencies": { + "@pinia/nuxt": "^0.5.0", + "@primevue/themes": "^4.0.0", + "guacamole-common-js": "^1.5.0", + "lucide-vue-next": "^0.300.0", + "monaco-editor": "^0.45.0", + "pinia": "^2.1.0", + "primevue": "^4.0.0", + "@xterm/xterm": "^5.4.0", + "@xterm/addon-fit": "^0.10.0", + "@xterm/addon-search": "^0.15.0", + "@xterm/addon-web-links": "^0.11.0", + "@xterm/addon-webgl": "^0.18.0" + }, + "devDependencies": { + "@nuxtjs/tailwindcss": "^6.0.0", + "@primevue/nuxt-module": "^4.0.0", + "nuxt": "^3.10.0", + "typescript": "^5.3.0" + } +} +``` + +- [ ] **Step 9: Create `frontend/nuxt.config.ts`** + +```typescript +export default defineNuxtConfig({ + ssr: false, + devtools: { enabled: false }, + modules: [ + '@pinia/nuxt', + '@nuxtjs/tailwindcss', + '@primevue/nuxt-module', + ], + css: ['~/assets/css/main.css'], + primevue: { + options: { + theme: 'none', + }, + }, + runtimeConfig: { + public: { + apiBase: process.env.API_BASE || 'http://localhost:3000', + }, + }, + devServer: { + port: 3001, + }, + nitro: { + devProxy: { + '/api': { target: 'http://localhost:3000/api', ws: true }, + '/ws': { target: 'ws://localhost:3000/ws', ws: true }, + }, + }, +}) +``` + +- [ ] **Step 10: Create `frontend/tailwind.config.ts`** + +```typescript +import type { Config } from 'tailwindcss' + +export default { + content: [ + './components/**/*.vue', + './layouts/**/*.vue', + './pages/**/*.vue', + './composables/**/*.ts', + './app.vue', + ], + darkMode: 'class', + theme: { + extend: { + colors: { + wraith: { + 50: '#f0f4ff', + 100: '#dbe4ff', + 200: '#bac8ff', + 300: '#91a7ff', + 400: '#748ffc', + 500: '#5c7cfa', + 600: '#4c6ef5', + 700: '#4263eb', + 800: '#3b5bdb', + 900: '#364fc7', + 950: '#1e3a8a', + }, + }, + }, + }, + plugins: [], +} satisfies Config +``` + +- [ ] **Step 11: Create frontend shell files** + +`frontend/app.vue`: +```vue + +``` + +`frontend/assets/css/main.css`: +```css +@tailwind base; +@tailwind components; +@tailwind utilities; + +html, body, #__nuxt { + @apply h-full bg-gray-900 text-gray-100; +} +``` + +`frontend/layouts/auth.vue`: +```vue + +``` + +- [ ] **Step 12: Commit scaffold** + +```bash +git add -A +git commit -m "feat: project scaffold — Docker, NestJS, Nuxt 3, Prisma config" +``` + +--- + +### Task 2: Prisma Schema + Backend Bootstrap + +**Files:** +- Create: `backend/prisma/schema.prisma` +- Create: `backend/src/main.ts`, `backend/src/app.module.ts` +- Create: `backend/src/prisma/prisma.service.ts`, `backend/src/prisma/prisma.module.ts` + +- [ ] **Step 1: Create `backend/prisma/schema.prisma`** + +Full schema from spec Section 5 — 7 models, 2 enums. Copy verbatim from spec: + +```prisma +generator client { + provider = "prisma-client-js" +} + +datasource db { + provider = "postgresql" + url = env("DATABASE_URL") +} + +model User { + id Int @id @default(autoincrement()) + email String @unique + passwordHash String @map("password_hash") + displayName String? @map("display_name") + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + + @@map("users") +} + +model HostGroup { + id Int @id @default(autoincrement()) + name String + parentId Int? @map("parent_id") + sortOrder Int @default(0) @map("sort_order") + parent HostGroup? @relation("GroupTree", fields: [parentId], references: [id], onDelete: SetNull) + children HostGroup[] @relation("GroupTree") + hosts Host[] + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + + @@map("host_groups") +} + +model Host { + id Int @id @default(autoincrement()) + name String + hostname String + port Int @default(22) + protocol Protocol @default(ssh) + groupId Int? @map("group_id") + credentialId Int? @map("credential_id") + tags String[] @default([]) + notes String? + color String? @db.VarChar(7) + sortOrder Int @default(0) @map("sort_order") + hostFingerprint String? @map("host_fingerprint") + lastConnectedAt DateTime? @map("last_connected_at") + group HostGroup? @relation(fields: [groupId], references: [id], onDelete: SetNull) + credential Credential? @relation(fields: [credentialId], references: [id], onDelete: SetNull) + connectionLogs ConnectionLog[] + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + + @@map("hosts") +} + +model Credential { + id Int @id @default(autoincrement()) + name String + username String? + domain String? + type CredentialType + encryptedValue String? @map("encrypted_value") + sshKeyId Int? @map("ssh_key_id") + sshKey SshKey? @relation(fields: [sshKeyId], references: [id], onDelete: SetNull) + hosts Host[] + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + + @@map("credentials") +} + +model SshKey { + id Int @id @default(autoincrement()) + name String + keyType String @map("key_type") @db.VarChar(20) + fingerprint String? + publicKey String? @map("public_key") + encryptedPrivateKey String @map("encrypted_private_key") + passphraseEncrypted String? @map("passphrase_encrypted") + credentials Credential[] + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + + @@map("ssh_keys") +} + +model ConnectionLog { + id Int @id @default(autoincrement()) + hostId Int @map("host_id") + protocol Protocol + connectedAt DateTime @default(now()) @map("connected_at") + disconnectedAt DateTime? @map("disconnected_at") + host Host @relation(fields: [hostId], references: [id], onDelete: Cascade) + + @@map("connection_logs") +} + +model Setting { + key String @id + value String + + @@map("settings") +} + +enum Protocol { + ssh + rdp +} + +enum CredentialType { + password + ssh_key +} +``` + +- [ ] **Step 2: Create Prisma service + module** + +`backend/src/prisma/prisma.service.ts`: +```typescript +import { Injectable, OnModuleInit, OnModuleDestroy } from '@nestjs/common'; +import { PrismaClient } from '@prisma/client'; + +@Injectable() +export class PrismaService extends PrismaClient implements OnModuleInit, OnModuleDestroy { + async onModuleInit() { + await this.$connect(); + } + + async onModuleDestroy() { + await this.$disconnect(); + } +} +``` + +`backend/src/prisma/prisma.module.ts`: +```typescript +import { Global, Module } from '@nestjs/common'; +import { PrismaService } from './prisma.service'; + +@Global() +@Module({ + providers: [PrismaService], + exports: [PrismaService], +}) +export class PrismaModule {} +``` + +- [ ] **Step 3: Create `backend/src/main.ts`** + +```typescript +import { NestFactory } from '@nestjs/core'; +import { ValidationPipe } from '@nestjs/common'; +import { WsAdapter } from '@nestjs/platform-ws'; +import { AppModule } from './app.module'; + +async function bootstrap() { + const app = await NestFactory.create(AppModule); + app.setGlobalPrefix('api'); + app.useGlobalPipes(new ValidationPipe({ whitelist: true, transform: true })); + app.useWebSocketAdapter(new WsAdapter(app)); + app.enableCors({ + origin: process.env.NODE_ENV === 'production' ? false : 'http://localhost:3001', + credentials: true, + }); + await app.listen(3000); + console.log('Wraith backend running on port 3000'); +} +bootstrap(); +``` + +- [ ] **Step 4: Create `backend/src/app.module.ts`** (initial — will grow as modules are added) + +```typescript +import { Module } from '@nestjs/common'; +import { ServeStaticModule } from '@nestjs/serve-static'; +import { join } from 'path'; +import { PrismaModule } from './prisma/prisma.module'; + +@Module({ + imports: [ + PrismaModule, + ServeStaticModule.forRoot({ + rootPath: join(__dirname, '..', 'public'), + exclude: ['/api/(.*)'], + }), + ], +}) +export class AppModule {} +``` + +- [ ] **Step 5: Install backend dependencies and generate Prisma client** + +```bash +cd backend && npm install +npx prisma generate +``` + +- [ ] **Step 6: Commit** + +```bash +git add -A +git commit -m "feat: Prisma schema (7 models) + NestJS bootstrap" +``` + +--- + +### Task 3: Encryption Service + +**Files:** +- Create: `backend/src/vault/encryption.service.ts` +- Create: `backend/src/vault/vault.module.ts` +- Create: `backend/test/encryption.service.spec.ts` + +This is a critical security component. Full code required. + +- [ ] **Step 1: Write encryption service tests** + +`backend/test/encryption.service.spec.ts`: +```typescript +import { EncryptionService } from '../src/vault/encryption.service'; + +describe('EncryptionService', () => { + let service: EncryptionService; + + beforeEach(() => { + // 32-byte key as 64-char hex string + process.env.ENCRYPTION_KEY = 'a'.repeat(64); + service = new EncryptionService(); + }); + + it('encrypts and decrypts a string', () => { + const plaintext = 'my-secret-password'; + const encrypted = service.encrypt(plaintext); + expect(encrypted).not.toEqual(plaintext); + expect(encrypted.startsWith('v1:')).toBe(true); + expect(service.decrypt(encrypted)).toEqual(plaintext); + }); + + it('produces different ciphertext for same plaintext (random IV)', () => { + const plaintext = 'same-input'; + const a = service.encrypt(plaintext); + const b = service.encrypt(plaintext); + expect(a).not.toEqual(b); + expect(service.decrypt(a)).toEqual(plaintext); + expect(service.decrypt(b)).toEqual(plaintext); + }); + + it('throws on tampered ciphertext', () => { + const encrypted = service.encrypt('test'); + const parts = encrypted.split(':'); + parts[3] = 'ff' + parts[3].slice(2); // tamper ciphertext + expect(() => service.decrypt(parts.join(':'))).toThrow(); + }); + + it('handles empty string', () => { + const encrypted = service.encrypt(''); + expect(service.decrypt(encrypted)).toEqual(''); + }); + + it('handles unicode', () => { + const plaintext = 'p@$$w0rd-日本語-🔑'; + const encrypted = service.encrypt(plaintext); + expect(service.decrypt(encrypted)).toEqual(plaintext); + }); +}); +``` + +- [ ] **Step 2: Run tests — verify they fail** + +```bash +cd backend && npx jest test/encryption.service.spec.ts +``` + +Expected: FAIL — module not found. + +- [ ] **Step 3: Implement encryption service** + +`backend/src/vault/encryption.service.ts`: +```typescript +import { Injectable } from '@nestjs/common'; +import { createCipheriv, createDecipheriv, randomBytes } from 'crypto'; + +@Injectable() +export class EncryptionService { + private readonly algorithm = 'aes-256-gcm'; + private readonly key: Buffer; + + constructor() { + const hex = process.env.ENCRYPTION_KEY; + if (!hex || hex.length < 64) { + throw new Error('ENCRYPTION_KEY must be a 64-char hex string (32 bytes)'); + } + this.key = Buffer.from(hex.slice(0, 64), 'hex'); + } + + encrypt(plaintext: string): string { + const iv = randomBytes(16); + const cipher = createCipheriv(this.algorithm, this.key, iv); + const encrypted = Buffer.concat([ + cipher.update(plaintext, 'utf8'), + cipher.final(), + ]); + const authTag = cipher.getAuthTag(); + return `v1:${iv.toString('hex')}:${authTag.toString('hex')}:${encrypted.toString('hex')}`; + } + + decrypt(encrypted: string): string { + const [version, ivHex, authTagHex, ciphertextHex] = encrypted.split(':'); + if (version !== 'v1') throw new Error(`Unknown encryption version: ${version}`); + const iv = Buffer.from(ivHex, 'hex'); + const authTag = Buffer.from(authTagHex, 'hex'); + const ciphertext = Buffer.from(ciphertextHex, 'hex'); + const decipher = createDecipheriv(this.algorithm, this.key, iv); + decipher.setAuthTag(authTag); + return Buffer.concat([ + decipher.update(ciphertext), + decipher.final(), + ]).toString('utf8'); + } +} +``` + +- [ ] **Step 4: Create vault module** + +`backend/src/vault/vault.module.ts`: +```typescript +import { Module } from '@nestjs/common'; +import { EncryptionService } from './encryption.service'; + +@Module({ + providers: [EncryptionService], + exports: [EncryptionService], +}) +export class VaultModule {} +``` + +- [ ] **Step 5: Run tests — verify they pass** + +```bash +cd backend && npx jest test/encryption.service.spec.ts --verbose +``` + +Expected: 5 tests PASS. + +- [ ] **Step 6: Commit** + +```bash +git add -A +git commit -m "feat: AES-256-GCM encryption service with tests" +``` + +--- + +### Task 4: Auth Module + +**Files:** +- Create: `backend/src/auth/auth.module.ts`, `auth.service.ts`, `auth.controller.ts` +- Create: `backend/src/auth/jwt.strategy.ts`, `jwt-auth.guard.ts`, `ws-auth.guard.ts` +- Create: `backend/src/auth/dto/login.dto.ts` +- Create: `backend/test/auth.service.spec.ts` +- Create: `backend/prisma/seed.ts` + +- [ ] **Step 1: Write auth service tests** + +`backend/test/auth.service.spec.ts`: +```typescript +import { JwtService } from '@nestjs/jwt'; +import { AuthService } from '../src/auth/auth.service'; +import * as bcrypt from 'bcrypt'; + +describe('AuthService', () => { + let service: AuthService; + const mockPrisma = { + user: { + findUnique: jest.fn(), + count: jest.fn(), + create: jest.fn(), + }, + }; + const mockJwt = { + sign: jest.fn().mockReturnValue('mock-jwt-token'), + }; + + beforeEach(() => { + service = new AuthService(mockPrisma as any, mockJwt as any); + jest.clearAllMocks(); + }); + + it('returns token for valid credentials', async () => { + const hash = await bcrypt.hash('password123', 10); + mockPrisma.user.findUnique.mockResolvedValue({ + id: 1, + email: 'admin@wraith.local', + passwordHash: hash, + displayName: 'Admin', + }); + + const result = await service.login('admin@wraith.local', 'password123'); + expect(result).toEqual({ + access_token: 'mock-jwt-token', + user: { id: 1, email: 'admin@wraith.local', displayName: 'Admin' }, + }); + }); + + it('throws on wrong password', async () => { + const hash = await bcrypt.hash('correct', 10); + mockPrisma.user.findUnique.mockResolvedValue({ + id: 1, + email: 'admin@wraith.local', + passwordHash: hash, + }); + + await expect(service.login('admin@wraith.local', 'wrong')) + .rejects.toThrow('Invalid credentials'); + }); + + it('throws on unknown user', async () => { + mockPrisma.user.findUnique.mockResolvedValue(null); + await expect(service.login('nobody@wraith.local', 'pass')) + .rejects.toThrow('Invalid credentials'); + }); +}); +``` + +- [ ] **Step 2: Run tests — verify fail** + +- [ ] **Step 3: Implement auth service** + +`backend/src/auth/auth.service.ts`: +```typescript +import { Injectable, UnauthorizedException } from '@nestjs/common'; +import { JwtService } from '@nestjs/jwt'; +import { PrismaService } from '../prisma/prisma.service'; +import * as bcrypt from 'bcrypt'; + +@Injectable() +export class AuthService { + constructor( + private prisma: PrismaService, + private jwt: JwtService, + ) {} + + async login(email: string, password: string) { + const user = await this.prisma.user.findUnique({ where: { email } }); + if (!user) throw new UnauthorizedException('Invalid credentials'); + + const valid = await bcrypt.compare(password, user.passwordHash); + if (!valid) throw new UnauthorizedException('Invalid credentials'); + + const payload = { sub: user.id, email: user.email }; + return { + access_token: this.jwt.sign(payload), + user: { id: user.id, email: user.email, displayName: user.displayName }, + }; + } + + async getProfile(userId: number) { + const user = await this.prisma.user.findUnique({ where: { id: userId } }); + if (!user) throw new UnauthorizedException(); + return { id: user.id, email: user.email, displayName: user.displayName }; + } +} +``` + +- [ ] **Step 4: Implement auth controller** + +`backend/src/auth/auth.controller.ts`: +```typescript +import { Controller, Post, Get, Body, Request, UseGuards } from '@nestjs/common'; +import { AuthService } from './auth.service'; +import { JwtAuthGuard } from './jwt-auth.guard'; +import { LoginDto } from './dto/login.dto'; + +@Controller('auth') +export class AuthController { + constructor(private auth: AuthService) {} + + @Post('login') + login(@Body() dto: LoginDto) { + return this.auth.login(dto.email, dto.password); + } + + @UseGuards(JwtAuthGuard) + @Get('profile') + getProfile(@Request() req: any) { + return this.auth.getProfile(req.user.sub); + } +} +``` + +`backend/src/auth/dto/login.dto.ts`: +```typescript +import { IsEmail, IsString, MinLength } from 'class-validator'; + +export class LoginDto { + @IsEmail() + email: string; + + @IsString() + @MinLength(1) + password: string; +} +``` + +- [ ] **Step 5: Implement JWT strategy + guards** + +`backend/src/auth/jwt.strategy.ts`: +```typescript +import { Injectable } from '@nestjs/common'; +import { PassportStrategy } from '@nestjs/passport'; +import { ExtractJwt, Strategy } from 'passport-jwt'; + +@Injectable() +export class JwtStrategy extends PassportStrategy(Strategy) { + constructor() { + super({ + jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(), + ignoreExpiration: false, + secretOrKey: process.env.JWT_SECRET, + }); + } + + validate(payload: { sub: number; email: string }) { + return { sub: payload.sub, email: payload.email }; + } +} +``` + +`backend/src/auth/jwt-auth.guard.ts`: +```typescript +import { Injectable } from '@nestjs/common'; +import { AuthGuard } from '@nestjs/passport'; + +@Injectable() +export class JwtAuthGuard extends AuthGuard('jwt') {} +``` + +`backend/src/auth/ws-auth.guard.ts`: +```typescript +import { Injectable } from '@nestjs/common'; +import { JwtService } from '@nestjs/jwt'; +import { WsException } from '@nestjs/websockets'; + +@Injectable() +export class WsAuthGuard { + constructor(private jwt: JwtService) {} + + validateClient(client: any): { sub: number; email: string } | null { + try { + const url = new URL(client.url || client._url, 'http://localhost'); + const token = url.searchParams.get('token'); + if (!token) throw new WsException('No token'); + return this.jwt.verify(token); + } catch { + return null; + } + } +} +``` + +- [ ] **Step 6: Implement auth module** + +`backend/src/auth/auth.module.ts`: +```typescript +import { Module } from '@nestjs/common'; +import { JwtModule } from '@nestjs/jwt'; +import { PassportModule } from '@nestjs/passport'; +import { AuthService } from './auth.service'; +import { AuthController } from './auth.controller'; +import { JwtStrategy } from './jwt.strategy'; +import { WsAuthGuard } from './ws-auth.guard'; + +@Module({ + imports: [ + PassportModule, + JwtModule.register({ + secret: process.env.JWT_SECRET, + signOptions: { expiresIn: '7d' }, + }), + ], + providers: [AuthService, JwtStrategy, WsAuthGuard], + controllers: [AuthController], + exports: [WsAuthGuard, JwtModule], +}) +export class AuthModule {} +``` + +- [ ] **Step 7: Create seed script** + +`backend/prisma/seed.ts`: +```typescript +import { PrismaClient } from '@prisma/client'; +import * as bcrypt from 'bcrypt'; + +const prisma = new PrismaClient(); + +async function main() { + const hash = await bcrypt.hash('wraith', 10); + await prisma.user.upsert({ + where: { email: 'admin@wraith.local' }, + update: {}, + create: { + email: 'admin@wraith.local', + passwordHash: hash, + displayName: 'Admin', + }, + }); + console.log('Seed complete: admin@wraith.local / wraith'); +} + +main() + .catch(console.error) + .finally(() => prisma.$disconnect()); +``` + +Add to `backend/package.json`: +```json +"prisma": { + "seed": "ts-node prisma/seed.ts" +} +``` + +- [ ] **Step 8: Update app.module.ts — register AuthModule + VaultModule** + +```typescript +import { Module } from '@nestjs/common'; +import { ServeStaticModule } from '@nestjs/serve-static'; +import { join } from 'path'; +import { PrismaModule } from './prisma/prisma.module'; +import { AuthModule } from './auth/auth.module'; +import { VaultModule } from './vault/vault.module'; + +@Module({ + imports: [ + PrismaModule, + AuthModule, + VaultModule, + ServeStaticModule.forRoot({ + rootPath: join(__dirname, '..', 'public'), + exclude: ['/api/(.*)'], + }), + ], +}) +export class AppModule {} +``` + +- [ ] **Step 9: Run tests — verify pass** + +```bash +cd backend && npx jest --verbose +``` + +Expected: all encryption + auth tests pass. + +- [ ] **Step 10: Commit** + +```bash +git add -A +git commit -m "feat: auth module — JWT login, guards, seed user" +``` + +--- + +### Task 5: Connection Manager Backend + +**Files:** +- Create: `backend/src/connections/connections.module.ts` +- Create: `backend/src/connections/hosts.service.ts`, `hosts.controller.ts` +- Create: `backend/src/connections/groups.service.ts`, `groups.controller.ts` +- Create: `backend/src/connections/dto/*.ts` + +- [ ] **Step 1: Create DTOs** + +`backend/src/connections/dto/create-host.dto.ts`: +```typescript +import { IsString, IsInt, IsOptional, IsEnum, IsArray, Min, Max } from 'class-validator'; +import { Protocol } from '@prisma/client'; + +export class CreateHostDto { + @IsString() + name: string; + + @IsString() + hostname: string; + + @IsInt() + @Min(1) + @Max(65535) + @IsOptional() + port?: number; + + @IsEnum(Protocol) + @IsOptional() + protocol?: Protocol; + + @IsInt() + @IsOptional() + groupId?: number; + + @IsInt() + @IsOptional() + credentialId?: number; + + @IsArray() + @IsString({ each: true }) + @IsOptional() + tags?: string[]; + + @IsString() + @IsOptional() + notes?: string; + + @IsString() + @IsOptional() + color?: string; +} +``` + +`backend/src/connections/dto/update-host.dto.ts`: +```typescript +import { PartialType } from '@nestjs/mapped-types'; +import { CreateHostDto } from './create-host.dto'; + +export class UpdateHostDto extends PartialType(CreateHostDto) {} +``` + +`backend/src/connections/dto/create-group.dto.ts`: +```typescript +import { IsString, IsInt, IsOptional } from 'class-validator'; + +export class CreateGroupDto { + @IsString() + name: string; + + @IsInt() + @IsOptional() + parentId?: number; + + @IsInt() + @IsOptional() + sortOrder?: number; +} +``` + +`backend/src/connections/dto/update-group.dto.ts`: +```typescript +import { PartialType } from '@nestjs/mapped-types'; +import { CreateGroupDto } from './create-group.dto'; + +export class UpdateGroupDto extends PartialType(CreateGroupDto) {} +``` + +- [ ] **Step 2: Implement hosts service** + +`backend/src/connections/hosts.service.ts`: +```typescript +import { Injectable, NotFoundException } from '@nestjs/common'; +import { PrismaService } from '../prisma/prisma.service'; +import { CreateHostDto } from './dto/create-host.dto'; +import { UpdateHostDto } from './dto/update-host.dto'; + +@Injectable() +export class HostsService { + constructor(private prisma: PrismaService) {} + + findAll(search?: string) { + const where = search + ? { + OR: [ + { name: { contains: search, mode: 'insensitive' as const } }, + { hostname: { contains: search, mode: 'insensitive' as const } }, + { tags: { has: search } }, + ], + } + : {}; + return this.prisma.host.findMany({ + where, + include: { group: true, credential: { select: { id: true, name: true, type: true } } }, + orderBy: [{ lastConnectedAt: { sort: 'desc', nulls: 'last' } }, { sortOrder: 'asc' }], + }); + } + + async findOne(id: number) { + const host = await this.prisma.host.findUnique({ + where: { id }, + include: { group: true, credential: true }, + }); + if (!host) throw new NotFoundException(`Host ${id} not found`); + return host; + } + + create(dto: CreateHostDto) { + return this.prisma.host.create({ + data: { + name: dto.name, + hostname: dto.hostname, + port: dto.port ?? (dto.protocol === 'rdp' ? 3389 : 22), + protocol: dto.protocol ?? 'ssh', + groupId: dto.groupId, + credentialId: dto.credentialId, + tags: dto.tags ?? [], + notes: dto.notes, + color: dto.color, + }, + include: { group: true }, + }); + } + + async update(id: number, dto: UpdateHostDto) { + await this.findOne(id); // throws if not found + return this.prisma.host.update({ where: { id }, data: dto }); + } + + async remove(id: number) { + await this.findOne(id); + return this.prisma.host.delete({ where: { id } }); + } + + async touchLastConnected(id: number) { + return this.prisma.host.update({ + where: { id }, + data: { lastConnectedAt: new Date() }, + }); + } + + async reorder(ids: number[]) { + const updates = ids.map((id, index) => + this.prisma.host.update({ where: { id }, data: { sortOrder: index } }), + ); + return this.prisma.$transaction(updates); + } +} +``` + +- [ ] **Step 3: Implement hosts controller** + +`backend/src/connections/hosts.controller.ts`: +```typescript +import { Controller, Get, Post, Put, Delete, Param, Body, Query, UseGuards, ParseIntPipe } from '@nestjs/common'; +import { JwtAuthGuard } from '../auth/jwt-auth.guard'; +import { HostsService } from './hosts.service'; +import { CreateHostDto } from './dto/create-host.dto'; +import { UpdateHostDto } from './dto/update-host.dto'; + +@UseGuards(JwtAuthGuard) +@Controller('hosts') +export class HostsController { + constructor(private hosts: HostsService) {} + + @Get() + findAll(@Query('search') search?: string) { + return this.hosts.findAll(search); + } + + @Get(':id') + findOne(@Param('id', ParseIntPipe) id: number) { + return this.hosts.findOne(id); + } + + @Post() + create(@Body() dto: CreateHostDto) { + return this.hosts.create(dto); + } + + @Put(':id') + update(@Param('id', ParseIntPipe) id: number, @Body() dto: UpdateHostDto) { + return this.hosts.update(id, dto); + } + + @Delete(':id') + remove(@Param('id', ParseIntPipe) id: number) { + return this.hosts.remove(id); + } + + @Post('reorder') + reorder(@Body() body: { ids: number[] }) { + return this.hosts.reorder(body.ids); + } +} +``` + +- [ ] **Step 4: Implement groups service + controller** + +`backend/src/connections/groups.service.ts`: +```typescript +import { Injectable, NotFoundException } from '@nestjs/common'; +import { PrismaService } from '../prisma/prisma.service'; +import { CreateGroupDto } from './dto/create-group.dto'; +import { UpdateGroupDto } from './dto/update-group.dto'; + +@Injectable() +export class GroupsService { + constructor(private prisma: PrismaService) {} + + findAll() { + return this.prisma.hostGroup.findMany({ + include: { children: true, hosts: { select: { id: true, name: true, protocol: true } } }, + orderBy: { sortOrder: 'asc' }, + }); + } + + findTree() { + return this.prisma.hostGroup.findMany({ + where: { parentId: null }, + include: { + hosts: { orderBy: { sortOrder: 'asc' } }, + children: { + include: { + hosts: { orderBy: { sortOrder: 'asc' } }, + children: { + include: { + hosts: { orderBy: { sortOrder: 'asc' } }, + }, + }, + }, + orderBy: { sortOrder: 'asc' }, + }, + }, + orderBy: { sortOrder: 'asc' }, + }); + } + + async findOne(id: number) { + const group = await this.prisma.hostGroup.findUnique({ + where: { id }, + include: { hosts: true, children: true }, + }); + if (!group) throw new NotFoundException(`Group ${id} not found`); + return group; + } + + create(dto: CreateGroupDto) { + return this.prisma.hostGroup.create({ data: dto }); + } + + async update(id: number, dto: UpdateGroupDto) { + await this.findOne(id); + return this.prisma.hostGroup.update({ where: { id }, data: dto }); + } + + async remove(id: number) { + await this.findOne(id); + return this.prisma.hostGroup.delete({ where: { id } }); + } +} +``` + +`backend/src/connections/groups.controller.ts`: +```typescript +import { Controller, Get, Post, Put, Delete, Param, Body, UseGuards, ParseIntPipe } from '@nestjs/common'; +import { JwtAuthGuard } from '../auth/jwt-auth.guard'; +import { GroupsService } from './groups.service'; +import { CreateGroupDto } from './dto/create-group.dto'; +import { UpdateGroupDto } from './dto/update-group.dto'; + +@UseGuards(JwtAuthGuard) +@Controller('groups') +export class GroupsController { + constructor(private groups: GroupsService) {} + + @Get() + findAll() { + return this.groups.findAll(); + } + + @Get('tree') + findTree() { + return this.groups.findTree(); + } + + @Get(':id') + findOne(@Param('id', ParseIntPipe) id: number) { + return this.groups.findOne(id); + } + + @Post() + create(@Body() dto: CreateGroupDto) { + return this.groups.create(dto); + } + + @Put(':id') + update(@Param('id', ParseIntPipe) id: number, @Body() dto: UpdateGroupDto) { + return this.groups.update(id, dto); + } + + @Delete(':id') + remove(@Param('id', ParseIntPipe) id: number) { + return this.groups.remove(id); + } +} +``` + +- [ ] **Step 5: Create connections module + register in app.module** + +`backend/src/connections/connections.module.ts`: +```typescript +import { Module } from '@nestjs/common'; +import { HostsService } from './hosts.service'; +import { HostsController } from './hosts.controller'; +import { GroupsService } from './groups.service'; +import { GroupsController } from './groups.controller'; + +@Module({ + providers: [HostsService, GroupsService], + controllers: [HostsController, GroupsController], + exports: [HostsService], +}) +export class ConnectionsModule {} +``` + +Update `app.module.ts` imports to add `ConnectionsModule`. + +- [ ] **Step 6: Commit** + +```bash +git add -A +git commit -m "feat: connection manager — hosts + groups CRUD with search" +``` + +--- + +### Task 6: Vault Backend (Credentials + SSH Keys) + +**Files:** +- Create: `backend/src/vault/credentials.service.ts`, `credentials.controller.ts` +- Create: `backend/src/vault/ssh-keys.service.ts`, `ssh-keys.controller.ts` +- Create: `backend/src/vault/dto/*.ts` +- Modify: `backend/src/vault/vault.module.ts` + +- [ ] **Step 1: Create DTOs** + +`backend/src/vault/dto/create-credential.dto.ts`: +```typescript +import { IsString, IsOptional, IsEnum, IsInt } from 'class-validator'; +import { CredentialType } from '@prisma/client'; + +export class CreateCredentialDto { + @IsString() + name: string; + + @IsString() + @IsOptional() + username?: string; + + @IsString() + @IsOptional() + domain?: string; + + @IsEnum(CredentialType) + type: CredentialType; + + @IsString() + @IsOptional() + password?: string; // plaintext — encrypted before storage + + @IsInt() + @IsOptional() + sshKeyId?: number; +} +``` + +`backend/src/vault/dto/update-credential.dto.ts`: +```typescript +import { PartialType } from '@nestjs/mapped-types'; +import { CreateCredentialDto } from './create-credential.dto'; + +export class UpdateCredentialDto extends PartialType(CreateCredentialDto) {} +``` + +`backend/src/vault/dto/create-ssh-key.dto.ts`: +```typescript +import { IsString, IsOptional } from 'class-validator'; + +export class CreateSshKeyDto { + @IsString() + name: string; + + @IsString() + privateKey: string; // plaintext — encrypted before storage + + @IsString() + @IsOptional() + passphrase?: string; // plaintext — encrypted before storage + + @IsString() + @IsOptional() + publicKey?: string; +} +``` + +`backend/src/vault/dto/update-ssh-key.dto.ts`: +```typescript +import { IsString, IsOptional } from 'class-validator'; + +export class UpdateSshKeyDto { + @IsString() + @IsOptional() + name?: string; + + @IsString() + @IsOptional() + passphrase?: string; // new passphrase (re-encrypted) +} +``` + +- [ ] **Step 2: Implement credentials service** + +`backend/src/vault/credentials.service.ts`: +```typescript +import { Injectable, NotFoundException } from '@nestjs/common'; +import { PrismaService } from '../prisma/prisma.service'; +import { EncryptionService } from './encryption.service'; +import { CreateCredentialDto } from './dto/create-credential.dto'; +import { UpdateCredentialDto } from './dto/update-credential.dto'; + +@Injectable() +export class CredentialsService { + constructor( + private prisma: PrismaService, + private encryption: EncryptionService, + ) {} + + findAll() { + return this.prisma.credential.findMany({ + include: { sshKey: { select: { id: true, name: true, keyType: true, fingerprint: true } } }, + orderBy: { name: 'asc' }, + }); + } + + async findOne(id: number) { + const cred = await this.prisma.credential.findUnique({ + where: { id }, + include: { sshKey: true, hosts: { select: { id: true, name: true } } }, + }); + if (!cred) throw new NotFoundException(`Credential ${id} not found`); + return cred; + } + + create(dto: CreateCredentialDto) { + const encryptedValue = dto.password ? this.encryption.encrypt(dto.password) : null; + return this.prisma.credential.create({ + data: { + name: dto.name, + username: dto.username, + domain: dto.domain, + type: dto.type, + encryptedValue, + sshKeyId: dto.sshKeyId, + }, + }); + } + + async update(id: number, dto: UpdateCredentialDto) { + await this.findOne(id); + const data: any = { ...dto }; + delete data.password; + if (dto.password) { + data.encryptedValue = this.encryption.encrypt(dto.password); + } + return this.prisma.credential.update({ where: { id }, data }); + } + + async remove(id: number) { + await this.findOne(id); + return this.prisma.credential.delete({ where: { id } }); + } + + /** Decrypt credential for use in SSH/RDP connections. Never expose over API. */ + async decryptForConnection(id: number): Promise<{ + username: string | null; + domain: string | null; + password: string | null; + sshKey: { privateKey: string; passphrase: string | null } | null; + }> { + const cred = await this.prisma.credential.findUnique({ + where: { id }, + include: { sshKey: true }, + }); + if (!cred) throw new NotFoundException(`Credential ${id} not found`); + + let password: string | null = null; + if (cred.encryptedValue) { + password = this.encryption.decrypt(cred.encryptedValue); + } + + let sshKey: { privateKey: string; passphrase: string | null } | null = null; + if (cred.sshKey) { + const privateKey = this.encryption.decrypt(cred.sshKey.encryptedPrivateKey); + const passphrase = cred.sshKey.passphraseEncrypted + ? this.encryption.decrypt(cred.sshKey.passphraseEncrypted) + : null; + sshKey = { privateKey, passphrase }; + } + + return { username: cred.username, domain: cred.domain, password, sshKey }; + } +} +``` + +- [ ] **Step 3: Implement credentials controller** + +`backend/src/vault/credentials.controller.ts`: +```typescript +import { Controller, Get, Post, Put, Delete, Param, Body, UseGuards, ParseIntPipe } from '@nestjs/common'; +import { JwtAuthGuard } from '../auth/jwt-auth.guard'; +import { CredentialsService } from './credentials.service'; +import { CreateCredentialDto } from './dto/create-credential.dto'; +import { UpdateCredentialDto } from './dto/update-credential.dto'; + +@UseGuards(JwtAuthGuard) +@Controller('credentials') +export class CredentialsController { + constructor(private credentials: CredentialsService) {} + + @Get() + findAll() { + return this.credentials.findAll(); + } + + @Get(':id') + findOne(@Param('id', ParseIntPipe) id: number) { + return this.credentials.findOne(id); + } + + @Post() + create(@Body() dto: CreateCredentialDto) { + return this.credentials.create(dto); + } + + @Put(':id') + update(@Param('id', ParseIntPipe) id: number, @Body() dto: UpdateCredentialDto) { + return this.credentials.update(id, dto); + } + + @Delete(':id') + remove(@Param('id', ParseIntPipe) id: number) { + return this.credentials.remove(id); + } +} +``` + +- [ ] **Step 4: Implement SSH keys service** + +`backend/src/vault/ssh-keys.service.ts`: +```typescript +import { Injectable, NotFoundException } from '@nestjs/common'; +import { PrismaService } from '../prisma/prisma.service'; +import { EncryptionService } from './encryption.service'; +import { CreateSshKeyDto } from './dto/create-ssh-key.dto'; +import { UpdateSshKeyDto } from './dto/update-ssh-key.dto'; +import { createPublicKey, createHash } from 'crypto'; +import { utils as ssh2Utils } from 'ssh2'; + +@Injectable() +export class SshKeysService { + constructor( + private prisma: PrismaService, + private encryption: EncryptionService, + ) {} + + findAll() { + return this.prisma.sshKey.findMany({ + select: { id: true, name: true, keyType: true, fingerprint: true, publicKey: true, createdAt: true }, + orderBy: { name: 'asc' }, + }); + } + + async findOne(id: number) { + const key = await this.prisma.sshKey.findUnique({ + where: { id }, + include: { credentials: { select: { id: true, name: true } } }, + }); + if (!key) throw new NotFoundException(`SSH key ${id} not found`); + // Never return encrypted private key over API + return { + id: key.id, + name: key.name, + keyType: key.keyType, + fingerprint: key.fingerprint, + publicKey: key.publicKey, + credentials: key.credentials, + createdAt: key.createdAt, + }; + } + + async create(dto: CreateSshKeyDto) { + // Detect key type from private key content + const keyType = this.detectKeyType(dto.privateKey); + + // Generate fingerprint from public key if provided, else from private key + const fingerprint = this.generateFingerprint(dto.publicKey || dto.privateKey); + + // Encrypt sensitive data + const encryptedPrivateKey = this.encryption.encrypt(dto.privateKey); + const passphraseEncrypted = dto.passphrase + ? this.encryption.encrypt(dto.passphrase) + : null; + + return this.prisma.sshKey.create({ + data: { + name: dto.name, + keyType, + fingerprint, + publicKey: dto.publicKey || null, + encryptedPrivateKey, + passphraseEncrypted, + }, + }); + } + + async update(id: number, dto: UpdateSshKeyDto) { + const key = await this.prisma.sshKey.findUnique({ where: { id } }); + if (!key) throw new NotFoundException(`SSH key ${id} not found`); + + const data: any = {}; + if (dto.name) data.name = dto.name; + if (dto.passphrase !== undefined) { + data.passphraseEncrypted = dto.passphrase + ? this.encryption.encrypt(dto.passphrase) + : null; + } + return this.prisma.sshKey.update({ where: { id }, data }); + } + + async remove(id: number) { + const key = await this.prisma.sshKey.findUnique({ where: { id } }); + if (!key) throw new NotFoundException(`SSH key ${id} not found`); + return this.prisma.sshKey.delete({ where: { id } }); + } + + private detectKeyType(privateKey: string): string { + if (privateKey.includes('RSA')) return 'rsa'; + if (privateKey.includes('EC')) return 'ecdsa'; + if (privateKey.includes('OPENSSH')) return 'ed25519'; // OpenSSH format, likely ed25519 + return 'unknown'; + } + + private generateFingerprint(keyContent: string): string { + try { + const hash = createHash('sha256').update(keyContent.trim()).digest('base64'); + return `SHA256:${hash}`; + } catch { + return 'unknown'; + } + } +} +``` + +- [ ] **Step 5: Implement SSH keys controller** + +`backend/src/vault/ssh-keys.controller.ts`: +```typescript +import { Controller, Get, Post, Put, Delete, Param, Body, UseGuards, ParseIntPipe } from '@nestjs/common'; +import { JwtAuthGuard } from '../auth/jwt-auth.guard'; +import { SshKeysService } from './ssh-keys.service'; +import { CreateSshKeyDto } from './dto/create-ssh-key.dto'; +import { UpdateSshKeyDto } from './dto/update-ssh-key.dto'; + +@UseGuards(JwtAuthGuard) +@Controller('ssh-keys') +export class SshKeysController { + constructor(private sshKeys: SshKeysService) {} + + @Get() + findAll() { + return this.sshKeys.findAll(); + } + + @Get(':id') + findOne(@Param('id', ParseIntPipe) id: number) { + return this.sshKeys.findOne(id); + } + + @Post() + create(@Body() dto: CreateSshKeyDto) { + return this.sshKeys.create(dto); + } + + @Put(':id') + update(@Param('id', ParseIntPipe) id: number, @Body() dto: UpdateSshKeyDto) { + return this.sshKeys.update(id, dto); + } + + @Delete(':id') + remove(@Param('id', ParseIntPipe) id: number) { + return this.sshKeys.remove(id); + } +} +``` + +- [ ] **Step 6: Update vault.module.ts** + +```typescript +import { Module } from '@nestjs/common'; +import { EncryptionService } from './encryption.service'; +import { CredentialsService } from './credentials.service'; +import { CredentialsController } from './credentials.controller'; +import { SshKeysService } from './ssh-keys.service'; +import { SshKeysController } from './ssh-keys.controller'; + +@Module({ + providers: [EncryptionService, CredentialsService, SshKeysService], + controllers: [CredentialsController, SshKeysController], + exports: [EncryptionService, CredentialsService, SshKeysService], +}) +export class VaultModule {} +``` + +Update `app.module.ts` — VaultModule is already imported. + +- [ ] **Step 7: Commit** + +```bash +git add -A +git commit -m "feat: vault — encrypted credentials + SSH key management" +``` + +--- + +### Task 7: Settings Backend + +**Files:** +- Create: `backend/src/settings/settings.module.ts`, `settings.service.ts`, `settings.controller.ts` + +- [ ] **Step 1: Implement settings service + controller** + +`backend/src/settings/settings.service.ts`: +```typescript +import { Injectable } from '@nestjs/common'; +import { PrismaService } from '../prisma/prisma.service'; + +@Injectable() +export class SettingsService { + constructor(private prisma: PrismaService) {} + + async getAll(): Promise> { + const settings = await this.prisma.setting.findMany(); + return Object.fromEntries(settings.map((s) => [s.key, s.value])); + } + + async get(key: string): Promise { + const setting = await this.prisma.setting.findUnique({ where: { key } }); + return setting?.value ?? null; + } + + async set(key: string, value: string) { + return this.prisma.setting.upsert({ + where: { key }, + update: { value }, + create: { key, value }, + }); + } + + async setMany(settings: Record) { + const ops = Object.entries(settings).map(([key, value]) => + this.prisma.setting.upsert({ where: { key }, update: { value }, create: { key, value } }), + ); + return this.prisma.$transaction(ops); + } + + async remove(key: string) { + return this.prisma.setting.delete({ where: { key } }).catch(() => null); + } +} +``` + +`backend/src/settings/settings.controller.ts`: +```typescript +import { Controller, Get, Put, Body, UseGuards } from '@nestjs/common'; +import { JwtAuthGuard } from '../auth/jwt-auth.guard'; +import { SettingsService } from './settings.service'; + +@UseGuards(JwtAuthGuard) +@Controller('settings') +export class SettingsController { + constructor(private settings: SettingsService) {} + + @Get() + getAll() { + return this.settings.getAll(); + } + + @Put() + update(@Body() body: Record) { + return this.settings.setMany(body); + } +} +``` + +`backend/src/settings/settings.module.ts`: +```typescript +import { Module } from '@nestjs/common'; +import { SettingsService } from './settings.service'; +import { SettingsController } from './settings.controller'; + +@Module({ + providers: [SettingsService], + controllers: [SettingsController], + exports: [SettingsService], +}) +export class SettingsModule {} +``` + +Register `SettingsModule` in `app.module.ts`. + +- [ ] **Step 2: Commit** + +```bash +git add -A +git commit -m "feat: settings — key/value store with CRUD API" +``` + +--- + +### Task 8: Frontend — Auth + Connection Manager UI + +**Files:** +- Create: `frontend/stores/auth.store.ts`, `frontend/stores/connection.store.ts` +- Create: `frontend/pages/login.vue`, `frontend/pages/index.vue` +- Create: `frontend/layouts/default.vue` +- Create: `frontend/components/connections/HostTree.vue`, `HostCard.vue`, `HostEditDialog.vue`, `GroupEditDialog.vue` + +- [ ] **Step 1: Create auth store** + +`frontend/stores/auth.store.ts`: +```typescript +import { defineStore } from 'pinia' + +interface User { + id: number + email: string + displayName: string | null +} + +export const useAuthStore = defineStore('auth', { + state: () => ({ + token: localStorage.getItem('wraith_token') || '', + user: null as User | null, + }), + getters: { + isAuthenticated: (state) => !!state.token, + }, + actions: { + async login(email: string, password: string) { + const res = await $fetch<{ access_token: string; user: User }>('/api/auth/login', { + method: 'POST', + body: { email, password }, + }) + this.token = res.access_token + this.user = res.user + localStorage.setItem('wraith_token', res.access_token) + }, + logout() { + this.token = '' + this.user = null + localStorage.removeItem('wraith_token') + navigateTo('/login') + }, + async fetchProfile() { + if (!this.token) return + try { + this.user = await $fetch('/api/auth/profile', { + headers: { Authorization: `Bearer ${this.token}` }, + }) + } catch { + this.logout() + } + }, + }, +}) +``` + +- [ ] **Step 2: Create connection store** + +`frontend/stores/connection.store.ts`: +```typescript +import { defineStore } from 'pinia' +import { useAuthStore } from './auth.store' + +interface Host { + id: number + name: string + hostname: string + port: number + protocol: 'ssh' | 'rdp' + groupId: number | null + credentialId: number | null + tags: string[] + notes: string | null + color: string | null + lastConnectedAt: string | null + group: { id: number; name: string } | null +} + +interface HostGroup { + id: number + name: string + parentId: number | null + children: HostGroup[] + hosts: Host[] +} + +export const useConnectionStore = defineStore('connections', { + state: () => ({ + hosts: [] as Host[], + groups: [] as HostGroup[], + search: '', + loading: false, + }), + actions: { + headers() { + const auth = useAuthStore() + return { Authorization: `Bearer ${auth.token}` } + }, + async fetchHosts() { + this.loading = true + try { + this.hosts = await $fetch('/api/hosts', { headers: this.headers() }) + } finally { + this.loading = false + } + }, + async fetchTree() { + this.groups = await $fetch('/api/groups/tree', { headers: this.headers() }) + }, + async createHost(data: Partial) { + const host = await $fetch('/api/hosts', { + method: 'POST', + body: data, + headers: this.headers(), + }) + await this.fetchHosts() + return host + }, + async updateHost(id: number, data: Partial) { + await $fetch(`/api/hosts/${id}`, { + method: 'PUT', + body: data, + headers: this.headers(), + }) + await this.fetchHosts() + }, + async deleteHost(id: number) { + await $fetch(`/api/hosts/${id}`, { + method: 'DELETE', + headers: this.headers(), + }) + await this.fetchHosts() + }, + async createGroup(data: { name: string; parentId?: number }) { + await $fetch('/api/groups', { + method: 'POST', + body: data, + headers: this.headers(), + }) + await this.fetchTree() + }, + async updateGroup(id: number, data: { name?: string; parentId?: number }) { + await $fetch(`/api/groups/${id}`, { + method: 'PUT', + body: data, + headers: this.headers(), + }) + await this.fetchTree() + }, + async deleteGroup(id: number) { + await $fetch(`/api/groups/${id}`, { + method: 'DELETE', + headers: this.headers(), + }) + await this.fetchTree() + }, + }, +}) +``` + +- [ ] **Step 3: Create login page** + +`frontend/pages/login.vue`: +```vue + + + +``` + +- [ ] **Step 4: Create default layout + main index page (connection manager)** + +`frontend/layouts/default.vue` — main layout with sidebar for host tree and top bar. Active sessions render as persistent tabs via `SessionContainer.vue` (built in Phase 2). For now, just the connection manager shell: + +```vue + + + +``` + +`frontend/pages/index.vue` — connection manager home page: +```vue + + + +``` + +- [ ] **Step 5: Create HostTree, HostCard, HostEditDialog, GroupEditDialog components** + +These are standard PrimeVue-driven components. Each component: + +`frontend/components/connections/HostTree.vue` — recursive tree using PrimeVue `Tree` or hand-rolled recursive list. Displays groups with expand/collapse, hosts as leaf nodes. Emits `select-host` and `new-host` events. + +`frontend/components/connections/HostCard.vue` — card showing host name, hostname:port, protocol badge (SSH/RDP), color indicator, last connected timestamp. Click to connect (Phase 2), edit button, delete button. + +`frontend/components/connections/HostEditDialog.vue` — PrimeVue Dialog with form fields matching CreateHostDto. Protocol selector, group dropdown, credential dropdown, tags input, notes textarea, color picker. + +`frontend/components/connections/GroupEditDialog.vue` — PrimeVue Dialog with name field and parent group dropdown. + +Each component should be implemented following standard Vue 3 Composition API patterns with PrimeVue components. Use `$fetch` with auth headers for API calls. + +- [ ] **Step 6: Install frontend dependencies and verify dev server starts** + +```bash +cd frontend && npm install +npx nuxi dev +``` + +Verify: login page renders, login succeeds (requires backend running with DB), connection manager loads. + +- [ ] **Step 7: Commit** + +```bash +git add -A +git commit -m "feat: frontend — auth flow, connection manager UI, host tree" +``` + +--- + +### Task 9: First Docker Compose Up + +- [ ] **Step 1: Create `.env` from `.env.example` with real values** + +```bash +cp .env.example .env +# Generate secrets: +echo "DB_PASSWORD=$(openssl rand -hex 16)" >> .env +echo "JWT_SECRET=$(openssl rand -hex 32)" >> .env +echo "ENCRYPTION_KEY=$(openssl rand -hex 32)" >> .env +``` + +- [ ] **Step 2: Run Prisma migration against Docker postgres** + +```bash +cd backend +DATABASE_URL=postgresql://wraith:$(grep DB_PASSWORD ../.env | cut -d= -f2)@localhost:5432/wraith npx prisma migrate dev --name init +``` + +- [ ] **Step 3: Seed the database** + +```bash +DATABASE_URL=... npx prisma db seed +``` + +- [ ] **Step 4: Verify Docker Compose up** + +```bash +docker compose up -d +docker logs -f wraith-app +``` + +Expected: NestJS starts, serves API on port 3000, frontend loads in browser. + +- [ ] **Step 5: Commit** + +```bash +git add -A +git commit -m "feat: Phase 1 complete — foundation layer verified" +``` + +--- + +## Chunk 2: SSH + SFTP (Phase 2) + +### Task 10: SSH Connection Service + Terminal Gateway + +**Files:** +- Create: `backend/src/terminal/terminal.module.ts` +- Create: `backend/src/terminal/ssh-connection.service.ts` +- Create: `backend/src/terminal/terminal.gateway.ts` + +This is the core of Wraith. The SSH connection service manages ssh2 connections, the terminal gateway bridges WebSocket to ssh2. + +- [ ] **Step 1: Implement SSH connection service** + +`backend/src/terminal/ssh-connection.service.ts`: +```typescript +import { Injectable, Logger } from '@nestjs/common'; +import { Client, ClientChannel } from 'ssh2'; +import { createHash } from 'crypto'; +import { CredentialsService } from '../vault/credentials.service'; +import { HostsService } from '../connections/hosts.service'; +import { PrismaService } from '../prisma/prisma.service'; +import { v4 as uuid } from 'uuid'; + +export interface SshSession { + id: string; + hostId: number; + client: Client; + stream: ClientChannel | null; +} + +@Injectable() +export class SshConnectionService { + private readonly logger = new Logger(SshConnectionService.name); + private sessions = new Map(); + + constructor( + private credentials: CredentialsService, + private hosts: HostsService, + private prisma: PrismaService, + ) {} + + async connect( + hostId: number, + onData: (data: string) => void, + onClose: (reason: string) => void, + onHostKeyVerify: (fingerprint: string, isNew: boolean) => Promise, + ): Promise { + const host = await this.hosts.findOne(hostId); + const cred = host.credentialId + ? await this.credentials.decryptForConnection(host.credentialId) + : null; + + const sessionId = uuid(); + const client = new Client(); + + return new Promise((resolve, reject) => { + client.on('ready', () => { + client.shell({ term: 'xterm-256color' }, (err, stream) => { + if (err) { + client.end(); + return reject(err); + } + const session: SshSession = { id: sessionId, hostId, client, stream }; + this.sessions.set(sessionId, session); + + stream.on('data', (data: Buffer) => onData(data.toString('utf-8'))); + stream.on('close', () => { + this.disconnect(sessionId); + onClose('Session ended'); + }); + + // Update lastConnectedAt and create connection log + this.hosts.touchLastConnected(hostId); + this.prisma.connectionLog.create({ + data: { hostId, protocol: host.protocol }, + }).catch(() => {}); + + resolve(sessionId); + }); + }); + + client.on('error', (err) => { + this.logger.error(`SSH error for host ${hostId}: ${err.message}`); + this.disconnect(sessionId); + onClose(err.message); + reject(err); + }); + + const connectConfig: any = { + host: host.hostname, + port: host.port, + username: cred?.username || 'root', + hostVerifier: (key: Buffer, verify: (accept: boolean) => void) => { + const fingerprint = createHash('sha256').update(key).digest('base64'); + const fp = `SHA256:${fingerprint}`; + + if (host.hostFingerprint === fp) { + verify(true); // known host — accept silently + return; + } + + // Unknown or changed fingerprint — ask the user via WebSocket + const isNew = !host.hostFingerprint; + onHostKeyVerify(fp, isNew).then((accepted) => { + if (accepted) { + // Persist fingerprint so future connections auto-accept + this.prisma.host.update({ + where: { id: hostId }, + data: { hostFingerprint: fp }, + }).catch(() => {}); + } + verify(accepted); + }); + }, + }; + + if (cred?.sshKey) { + connectConfig.privateKey = cred.sshKey.privateKey; + if (cred.sshKey.passphrase) { + connectConfig.passphrase = cred.sshKey.passphrase; + } + } else if (cred?.password) { + connectConfig.password = cred.password; + } + + client.connect(connectConfig); + }); + } + + write(sessionId: string, data: string) { + const session = this.sessions.get(sessionId); + if (session?.stream) { + session.stream.write(data); + } + } + + resize(sessionId: string, cols: number, rows: number) { + const session = this.sessions.get(sessionId); + if (session?.stream) { + session.stream.setWindow(rows, cols, 0, 0); + } + } + + disconnect(sessionId: string) { + const session = this.sessions.get(sessionId); + if (session) { + session.stream?.close(); + session.client.end(); + this.sessions.delete(sessionId); + + // Update connection log with disconnect time + this.prisma.connectionLog.updateMany({ + where: { hostId: session.hostId, disconnectedAt: null }, + data: { disconnectedAt: new Date() }, + }).catch(() => {}); + } + } + + getSession(sessionId: string): SshSession | undefined { + return this.sessions.get(sessionId); + } + + getSftpChannel(sessionId: string): Promise { + return new Promise((resolve, reject) => { + const session = this.sessions.get(sessionId); + if (!session) return reject(new Error('Session not found')); + session.client.sftp((err, sftp) => { + if (err) return reject(err); + resolve(sftp); + }); + }); + } +} +``` + +- [ ] **Step 2: Implement terminal gateway** + +`backend/src/terminal/terminal.gateway.ts`: +```typescript +import { WebSocketGateway, WebSocketServer, OnGatewayConnection, OnGatewayDisconnect } from '@nestjs/websockets'; +import { Logger } from '@nestjs/common'; +import { Server } from 'ws'; +import { WsAuthGuard } from '../auth/ws-auth.guard'; +import { SshConnectionService } from './ssh-connection.service'; + +@WebSocketGateway({ path: '/ws/terminal' }) +export class TerminalGateway implements OnGatewayConnection, OnGatewayDisconnect { + @WebSocketServer() server: Server; + private readonly logger = new Logger(TerminalGateway.name); + private clientSessions = new Map(); // ws client → sessionIds + + constructor( + private ssh: SshConnectionService, + private wsAuth: WsAuthGuard, + ) {} + + handleConnection(client: any) { + const user = this.wsAuth.validateClient(client); + if (!user) { + client.close(4001, 'Unauthorized'); + return; + } + this.clientSessions.set(client, []); + this.logger.log(`Terminal WS connected: ${user.email}`); + + client.on('message', async (raw: Buffer) => { + try { + const msg = JSON.parse(raw.toString()); + await this.handleMessage(client, msg); + } catch (err: any) { + this.send(client, { type: 'error', message: err.message }); + } + }); + } + + handleDisconnect(client: any) { + const sessions = this.clientSessions.get(client) || []; + sessions.forEach((sid) => this.ssh.disconnect(sid)); + this.clientSessions.delete(client); + } + + private async handleMessage(client: any, msg: any) { + switch (msg.type) { + case 'connect': { + const sessionId = await this.ssh.connect( + msg.hostId, + (data) => this.send(client, { type: 'data', sessionId, data }), + (reason) => this.send(client, { type: 'disconnected', sessionId, reason }), + async (fingerprint, isNew) => { + // Send verification request to client + this.send(client, { type: 'host-key-verify', fingerprint, isNew }); + return true; // auto-accept for now, full flow in Task 12 + }, + ); + const sessions = this.clientSessions.get(client) || []; + sessions.push(sessionId); + this.clientSessions.set(client, sessions); + this.send(client, { type: 'connected', sessionId }); + break; + } + case 'data': { + if (msg.sessionId) { + this.ssh.write(msg.sessionId, msg.data); + } + break; + } + case 'resize': { + if (msg.sessionId) { + this.ssh.resize(msg.sessionId, msg.cols, msg.rows); + } + break; + } + case 'disconnect': { + if (msg.sessionId) { + this.ssh.disconnect(msg.sessionId); + } + break; + } + } + } + + private send(client: any, data: any) { + if (client.readyState === 1) { // WebSocket.OPEN + client.send(JSON.stringify(data)); + } + } +} +``` + +- [ ] **Step 3: Create terminal module** + +`backend/src/terminal/terminal.module.ts`: +```typescript +import { Module } from '@nestjs/common'; +import { SshConnectionService } from './ssh-connection.service'; +import { TerminalGateway } from './terminal.gateway'; +import { VaultModule } from '../vault/vault.module'; +import { ConnectionsModule } from '../connections/connections.module'; +import { AuthModule } from '../auth/auth.module'; + +@Module({ + imports: [VaultModule, ConnectionsModule, AuthModule], + providers: [SshConnectionService, TerminalGateway], + exports: [SshConnectionService], +}) +export class TerminalModule {} +``` + +Register `TerminalModule` in `app.module.ts`. + +- [ ] **Step 4: Add `uuid` dependency** + +```bash +cd backend && npm install uuid && npm install -D @types/uuid +``` + +- [ ] **Step 5: Commit** + +```bash +git add -A +git commit -m "feat: SSH terminal gateway — ssh2 proxy over WebSocket" +``` + +--- + +### Task 11: SFTP Gateway + +**Files:** +- Create: `backend/src/terminal/sftp.gateway.ts` +- Modify: `backend/src/terminal/terminal.module.ts` + +- [ ] **Step 1: Implement SFTP gateway** + +`backend/src/terminal/sftp.gateway.ts`: +```typescript +import { WebSocketGateway, WebSocketServer, OnGatewayConnection, OnGatewayDisconnect } from '@nestjs/websockets'; +import { Logger } from '@nestjs/common'; +import { Server } from 'ws'; +import { WsAuthGuard } from '../auth/ws-auth.guard'; +import { SshConnectionService } from './ssh-connection.service'; + +const MAX_EDIT_SIZE = 5 * 1024 * 1024; // 5MB + +@WebSocketGateway({ path: '/ws/sftp' }) +export class SftpGateway implements OnGatewayConnection, OnGatewayDisconnect { + @WebSocketServer() server: Server; + private readonly logger = new Logger(SftpGateway.name); + + constructor( + private ssh: SshConnectionService, + private wsAuth: WsAuthGuard, + ) {} + + handleConnection(client: any) { + const user = this.wsAuth.validateClient(client); + if (!user) { + client.close(4001, 'Unauthorized'); + return; + } + this.logger.log(`SFTP WS connected: ${user.email}`); + + client.on('message', async (raw: Buffer) => { + try { + const msg = JSON.parse(raw.toString()); + await this.handleMessage(client, msg); + } catch (err: any) { + this.send(client, { type: 'error', message: err.message }); + } + }); + } + + handleDisconnect() {} + + private async handleMessage(client: any, msg: any) { + const { sessionId } = msg; + if (!sessionId) { + return this.send(client, { type: 'error', message: 'sessionId required' }); + } + + const sftp = await this.ssh.getSftpChannel(sessionId); + + switch (msg.type) { + case 'list': { + sftp.readdir(msg.path, (err: any, list: any[]) => { + if (err) return this.send(client, { type: 'error', message: err.message }); + const entries = list.map((f: any) => ({ + name: f.filename, + path: `${msg.path === '/' ? '' : msg.path}/${f.filename}`, + size: f.attrs.size, + isDirectory: (f.attrs.mode & 0o40000) !== 0, + permissions: (f.attrs.mode & 0o7777).toString(8), + modified: new Date(f.attrs.mtime * 1000).toISOString(), + })); + this.send(client, { type: 'list', path: msg.path, entries }); + }); + break; + } + case 'read': { + sftp.stat(msg.path, (err: any, stats: any) => { + if (err) return this.send(client, { type: 'error', message: err.message }); + if (stats.size > MAX_EDIT_SIZE) { + return this.send(client, { + type: 'error', + message: `File too large for editing (${(stats.size / 1024 / 1024).toFixed(1)}MB, max 5MB). Download instead.`, + }); + } + const chunks: Buffer[] = []; + const stream = sftp.createReadStream(msg.path); + stream.on('data', (chunk: Buffer) => chunks.push(chunk)); + stream.on('end', () => { + const content = Buffer.concat(chunks).toString('utf-8'); + this.send(client, { type: 'fileContent', path: msg.path, content, encoding: 'utf-8' }); + }); + stream.on('error', (e: any) => this.send(client, { type: 'error', message: e.message })); + }); + break; + } + case 'write': { + const stream = sftp.createWriteStream(msg.path); + stream.end(Buffer.from(msg.data, 'utf-8'), () => { + this.send(client, { type: 'saved', path: msg.path }); + }); + stream.on('error', (e: any) => this.send(client, { type: 'error', message: e.message })); + break; + } + case 'mkdir': { + sftp.mkdir(msg.path, (err: any) => { + if (err) return this.send(client, { type: 'error', message: err.message }); + this.send(client, { type: 'created', path: msg.path }); + }); + break; + } + case 'rename': { + sftp.rename(msg.oldPath, msg.newPath, (err: any) => { + if (err) return this.send(client, { type: 'error', message: err.message }); + this.send(client, { type: 'renamed', oldPath: msg.oldPath, newPath: msg.newPath }); + }); + break; + } + case 'delete': { + // Try unlink (file), fallback to rmdir (directory) + sftp.unlink(msg.path, (err: any) => { + if (err) { + sftp.rmdir(msg.path, (err2: any) => { + if (err2) return this.send(client, { type: 'error', message: err2.message }); + this.send(client, { type: 'deleted', path: msg.path }); + }); + } else { + this.send(client, { type: 'deleted', path: msg.path }); + } + }); + break; + } + case 'chmod': { + const mode = parseInt(msg.mode, 8); + sftp.chmod(msg.path, mode, (err: any) => { + if (err) return this.send(client, { type: 'error', message: err.message }); + this.send(client, { type: 'chmodDone', path: msg.path, mode: msg.mode }); + }); + break; + } + case 'stat': { + sftp.stat(msg.path, (err: any, stats: any) => { + if (err) return this.send(client, { type: 'error', message: err.message }); + this.send(client, { + type: 'stat', + path: msg.path, + size: stats.size, + isDirectory: (stats.mode & 0o40000) !== 0, + permissions: (stats.mode & 0o7777).toString(8), + modified: new Date(stats.mtime * 1000).toISOString(), + accessed: new Date(stats.atime * 1000).toISOString(), + }); + }); + break; + } + case 'download': { + // Stream file data to client in chunks + const readStream = sftp.createReadStream(msg.path); + sftp.stat(msg.path, (err: any, stats: any) => { + if (err) return this.send(client, { type: 'error', message: err.message }); + const transferId = `dl-${Date.now()}`; + let sent = 0; + this.send(client, { type: 'downloadStart', transferId, path: msg.path, total: stats.size }); + readStream.on('data', (chunk: Buffer) => { + sent += chunk.length; + client.send(JSON.stringify({ + type: 'downloadChunk', + transferId, + data: chunk.toString('base64'), + progress: { bytes: sent, total: stats.size }, + })); + }); + readStream.on('end', () => { + this.send(client, { type: 'downloadComplete', transferId }); + }); + readStream.on('error', (e: any) => { + this.send(client, { type: 'error', message: e.message }); + }); + }); + break; + } + } + } + + private send(client: any, data: any) { + if (client.readyState === 1) { + client.send(JSON.stringify(data)); + } + } +} +``` + +- [ ] **Step 2: Register SftpGateway in terminal.module.ts** + +Add `SftpGateway` to providers array. + +- [ ] **Step 3: Commit** + +```bash +git add -A +git commit -m "feat: SFTP gateway — file operations over WebSocket" +``` + +--- + +### Task 12: Frontend — Terminal + SFTP + +**Files:** +- Create: `frontend/composables/useTerminal.ts`, `frontend/composables/useSftp.ts` +- Create: `frontend/stores/session.store.ts` +- Create: `frontend/components/session/SessionContainer.vue`, `SessionTab.vue` +- Create: `frontend/components/terminal/TerminalInstance.vue`, `TerminalTabs.vue`, `SplitPane.vue` +- Create: `frontend/components/sftp/SftpSidebar.vue`, `FileTree.vue`, `FileEditor.vue`, `TransferStatus.vue` +- Modify: `frontend/layouts/default.vue`, `frontend/pages/index.vue` + +- [ ] **Step 1: Create session store** + +`frontend/stores/session.store.ts`: +```typescript +import { defineStore } from 'pinia' + +interface Session { + id: string // uuid from backend + hostId: number + hostName: string + protocol: 'ssh' | 'rdp' + color: string | null + active: boolean +} + +export const useSessionStore = defineStore('sessions', { + state: () => ({ + sessions: [] as Session[], + activeSessionId: null as string | null, + }), + getters: { + activeSession: (state) => state.sessions.find(s => s.id === state.activeSessionId), + hasSessions: (state) => state.sessions.length > 0, + }, + actions: { + addSession(session: Session) { + this.sessions.push(session) + this.activeSessionId = session.id + }, + removeSession(id: string) { + this.sessions = this.sessions.filter(s => s.id !== id) + if (this.activeSessionId === id) { + this.activeSessionId = this.sessions.length ? this.sessions[this.sessions.length - 1].id : null + } + }, + setActive(id: string) { + this.activeSessionId = id + }, + }, +}) +``` + +- [ ] **Step 2: Create useTerminal composable** + +`frontend/composables/useTerminal.ts`: +```typescript +import { Terminal } from '@xterm/xterm' +import { FitAddon } from '@xterm/addon-fit' +import { SearchAddon } from '@xterm/addon-search' +import { WebLinksAddon } from '@xterm/addon-web-links' +import { WebglAddon } from '@xterm/addon-webgl' +import { useAuthStore } from '~/stores/auth.store' +import { useSessionStore } from '~/stores/session.store' + +export function useTerminal() { + const auth = useAuthStore() + const sessions = useSessionStore() + let ws: WebSocket | null = null + + function createTerminal(container: HTMLElement, options?: Partial<{ fontSize: number; scrollback: number }>) { + const term = new Terminal({ + cursorBlink: true, + fontSize: options?.fontSize || 14, + fontFamily: "'JetBrains Mono', 'Fira Code', 'Cascadia Code', monospace", + scrollback: options?.scrollback || 10000, + theme: { + background: '#0a0a0f', + foreground: '#e4e4ef', + cursor: '#5c7cfa', + selectionBackground: '#364fc744', + }, + }) + + const fitAddon = new FitAddon() + const searchAddon = new SearchAddon() + term.loadAddon(fitAddon) + term.loadAddon(searchAddon) + term.loadAddon(new WebLinksAddon()) + + term.open(container) + + try { + term.loadAddon(new WebglAddon()) + } catch { + // WebGL not available, fall back to canvas + } + + fitAddon.fit() + const resizeObserver = new ResizeObserver(() => fitAddon.fit()) + resizeObserver.observe(container) + + return { term, fitAddon, searchAddon, resizeObserver } + } + + function connectToHost(hostId: number, hostName: string, protocol: 'ssh', color: string | null, term: Terminal, fitAddon: FitAddon) { + const wsUrl = `${location.protocol === 'https:' ? 'wss' : 'ws'}://${location.host}/ws/terminal?token=${auth.token}` + ws = new WebSocket(wsUrl) + + ws.onopen = () => { + ws!.send(JSON.stringify({ type: 'connect', hostId })) + } + + ws.onmessage = (event) => { + const msg = JSON.parse(event.data) + switch (msg.type) { + case 'connected': + sessions.addSession({ id: msg.sessionId, hostId, hostName, protocol, color, active: true }) + // Send initial terminal size + ws!.send(JSON.stringify({ type: 'resize', sessionId: msg.sessionId, cols: term.cols, rows: term.rows })) + break + case 'data': + term.write(msg.data) + break + case 'disconnected': + sessions.removeSession(msg.sessionId) + break + case 'host-key-verify': + // Auto-accept for now — full UX in polish phase + ws!.send(JSON.stringify({ type: 'host-key-accept' })) + break + case 'error': + term.write(`\r\n\x1b[31mError: ${msg.message}\x1b[0m\r\n`) + break + } + } + + ws.onclose = () => { + term.write('\r\n\x1b[33mConnection closed.\x1b[0m\r\n') + } + + // Terminal input → WebSocket + term.onData((data) => { + if (ws?.readyState === WebSocket.OPEN) { + const sessionId = sessions.activeSession?.id + if (sessionId) { + ws.send(JSON.stringify({ type: 'data', sessionId, data })) + } + } + }) + + // Terminal resize → WebSocket + term.onResize(({ cols, rows }) => { + if (ws?.readyState === WebSocket.OPEN) { + const sessionId = sessions.activeSession?.id + if (sessionId) { + ws.send(JSON.stringify({ type: 'resize', sessionId, cols, rows })) + } + } + }) + + return ws + } + + function disconnect(sessionId: string) { + if (ws?.readyState === WebSocket.OPEN) { + ws.send(JSON.stringify({ type: 'disconnect', sessionId })) + } + sessions.removeSession(sessionId) + } + + return { createTerminal, connectToHost, disconnect } +} +``` + +- [ ] **Step 3: Create useSftp composable** + +`frontend/composables/useSftp.ts`: +```typescript +import { useAuthStore } from '~/stores/auth.store' + +export function useSftp(sessionId: Ref) { + const auth = useAuthStore() + let ws: WebSocket | null = null + const entries = ref([]) + const currentPath = ref('/') + const fileContent = ref<{ path: string; content: string } | null>(null) + const transfers = ref([]) + + function connect() { + const wsUrl = `${location.protocol === 'https:' ? 'wss' : 'ws'}://${location.host}/ws/sftp?token=${auth.token}` + ws = new WebSocket(wsUrl) + + ws.onmessage = (event) => { + const msg = JSON.parse(event.data) + switch (msg.type) { + case 'list': + entries.value = msg.entries.sort((a: any, b: any) => { + if (a.isDirectory !== b.isDirectory) return a.isDirectory ? -1 : 1 + return a.name.localeCompare(b.name) + }) + currentPath.value = msg.path + break + case 'fileContent': + fileContent.value = { path: msg.path, content: msg.content } + break + case 'saved': + fileContent.value = null + list(currentPath.value) + break + case 'progress': + // Update transfer progress + break + case 'error': + console.error('SFTP error:', msg.message) + break + } + } + + return ws + } + + function send(msg: any) { + if (ws?.readyState === WebSocket.OPEN && sessionId.value) { + ws.send(JSON.stringify({ ...msg, sessionId: sessionId.value })) + } + } + + function list(path: string) { send({ type: 'list', path }) } + function readFile(path: string) { send({ type: 'read', path }) } + function writeFile(path: string, data: string) { send({ type: 'write', path, data }) } + function mkdir(path: string) { send({ type: 'mkdir', path }) } + function rename(oldPath: string, newPath: string) { send({ type: 'rename', oldPath, newPath }) } + function remove(path: string) { send({ type: 'delete', path }) } + function chmod(path: string, mode: string) { send({ type: 'chmod', path, mode }) } + function download(path: string) { send({ type: 'download', path }) } + + function disconnect() { + ws?.close() + ws = null + } + + return { + entries, currentPath, fileContent, transfers, + connect, disconnect, list, readFile, writeFile, mkdir, rename, remove, chmod, download, + } +} +``` + +- [ ] **Step 4: Create SessionContainer, SessionTab, TerminalInstance components** + +`frontend/components/session/SessionContainer.vue` — persistent container in default layout. Renders all active sessions, shows/hides via `v-show` based on `activeSessionId`. Tab bar on top. Each SSH session renders `TerminalInstance` + `SftpSidebar`. Each RDP session renders `RdpCanvas` (Phase 3). + +`frontend/components/session/SessionTab.vue` — individual tab in the tab bar. Shows host name, color dot, protocol icon, close button. + +`frontend/components/terminal/TerminalInstance.vue` — wraps xterm.js in a Vue component. Uses `useTerminal()` composable. Mounts terminal to a div ref. Handles resize via ResizeObserver. Props: `sessionId`, `hostId`. Imports xterm.js CSS. + +`frontend/components/terminal/TerminalTabs.vue` — tab bar component showing all active sessions. + +`frontend/components/terminal/SplitPane.vue` — flex container that allows horizontal/vertical splitting of terminal instances within a session. Uses CSS `flex-direction` toggle and a draggable divider. + +- [ ] **Step 5: Create SFTP components** + +`frontend/components/sftp/SftpSidebar.vue` — resizable panel on the left of the terminal. Uses `useSftp()` composable. Shows `FileTree` component. Top bar with path breadcrumbs and action buttons (upload, new folder, refresh). + +`frontend/components/sftp/FileTree.vue` — recursive tree of remote filesystem entries. Directories are expandable (lazy-load children on click). Files are clickable (open in FileEditor if text, download if binary). Right-click context menu for rename/delete/chmod/download. + +`frontend/components/sftp/FileEditor.vue` — wraps Monaco Editor. Opens when a text file is clicked in the tree. Shows file path, save button, close button. Unsaved changes warning on close. + +`frontend/components/sftp/TransferStatus.vue` — bottom bar showing active uploads/downloads with progress bars, file names, speed, ETA. + +- [ ] **Step 6: Update default.vue layout — add SessionContainer** + +The `SessionContainer` should live in the default layout so it persists across page navigation. When sessions exist, the session area takes over the main content area. The connection manager sidebar remains for launching new connections. + +- [ ] **Step 7: Update index.vue — connect-on-click** + +When a host card is clicked (not the edit button), call `useTerminal().connectToHost()` to open a new SSH or RDP session. Add an "open" action to HostCard. + +- [ ] **Step 8: Install xterm.js CSS** + +Add to `frontend/nuxt.config.ts` CSS array: `'@xterm/xterm/css/xterm.css'` + +- [ ] **Step 9: Commit** + +```bash +git add -A +git commit -m "feat: Phase 2 — SSH terminal + SFTP sidebar in browser" +``` + +--- + +## Chunk 3: RDP + Polish (Phases 3-4) + +### Task 13: RDP Backend — Guacamole Service + Gateway + +**Files:** +- Create: `backend/src/rdp/rdp.module.ts` +- Create: `backend/src/rdp/guacamole.service.ts` +- Create: `backend/src/rdp/rdp.gateway.ts` +- Modify: `backend/src/app.module.ts` + +- [ ] **Step 1: Implement Guacamole service** + +`backend/src/rdp/guacamole.service.ts`: + +The Guacamole service opens a raw TCP socket to guacd and speaks the Guacamole wire protocol. This is NOT an HTTP integration — it's a custom TCP client that translates between the Guacamole instruction format and JSON WebSocket messages. + +```typescript +import { Injectable, Logger } from '@nestjs/common'; +import * as net from 'net'; + +/** + * Guacamole wire protocol: instructions are comma-separated fields + * terminated by semicolons. Each field is length-prefixed. + * Example: "4.size,4.1024,3.768;" + */ + +@Injectable() +export class GuacamoleService { + private readonly logger = new Logger(GuacamoleService.name); + private readonly host = process.env.GUACD_HOST || 'guacd'; + private readonly port = parseInt(process.env.GUACD_PORT || '4822', 10); + + async connect(params: { + hostname: string; + port: number; + username: string; + password: string; + domain?: string; + width: number; + height: number; + dpi?: number; + security?: string; + colorDepth?: number; + ignoreCert?: boolean; + }): Promise { + return new Promise((resolve, reject) => { + const socket = net.createConnection(this.port, this.host, () => { + this.logger.log(`Connected to guacd at ${this.host}:${this.port}`); + + // Phase 1: SELECT protocol + socket.write(this.encode('select', 'rdp')); + + let buffer = ''; + const onData = (data: Buffer) => { + buffer += data.toString('utf-8'); + + // Wait for "args" instruction from guacd + if (buffer.includes(';')) { + socket.removeListener('data', onData); + + // Phase 2: CONNECT with RDP parameters + const connectArgs = this.buildRdpArgs(params); + socket.write(connectArgs); + + resolve(socket); + } + }; + socket.on('data', onData); + }); + + socket.on('error', (err) => { + this.logger.error(`guacd connection error: ${err.message}`); + reject(err); + }); + + socket.setTimeout(10000, () => { + socket.destroy(); + reject(new Error('guacd connection timeout')); + }); + }); + } + + private buildRdpArgs(params: any): string { + const args: Record = { + hostname: params.hostname, + port: String(params.port), + username: params.username, + password: params.password, + width: String(params.width), + height: String(params.height), + dpi: String(params.dpi || 96), + security: params.security || 'any', + 'color-depth': String(params.colorDepth || 24), + 'ignore-cert': params.ignoreCert !== false ? 'true' : 'false', + 'disable-audio': 'false', + 'enable-wallpaper': 'false', + 'enable-theming': 'true', + 'enable-font-smoothing': 'true', + 'resize-method': 'reconnect', + }; + if (params.domain) args.domain = params.domain; + + // Build connect instruction with all args + const values = Object.values(args); + return this.encode('connect', ...values); + } + + /** Encode a Guacamole instruction: "opcode,arg1,arg2,...;" with length prefixes */ + encode(...parts: string[]): string { + return parts.map((p) => `${p.length}.${p}`).join(',') + ';'; + } + + /** Decode a Guacamole instruction back to string array */ + decode(instruction: string): string[] { + const parts: string[] = []; + let pos = 0; + while (pos < instruction.length) { + const dotIndex = instruction.indexOf('.', pos); + if (dotIndex === -1) break; + const len = parseInt(instruction.substring(pos, dotIndex), 10); + const value = instruction.substring(dotIndex + 1, dotIndex + 1 + len); + parts.push(value); + pos = dotIndex + 1 + len + 1; // skip comma or semicolon + } + return parts; + } +} +``` + +- [ ] **Step 2: Implement RDP gateway** + +`backend/src/rdp/rdp.gateway.ts`: +```typescript +import { WebSocketGateway, WebSocketServer, OnGatewayConnection, OnGatewayDisconnect } from '@nestjs/websockets'; +import { Logger } from '@nestjs/common'; +import { Server } from 'ws'; +import * as net from 'net'; +import { WsAuthGuard } from '../auth/ws-auth.guard'; +import { GuacamoleService } from './guacamole.service'; +import { CredentialsService } from '../vault/credentials.service'; +import { HostsService } from '../connections/hosts.service'; +import { PrismaService } from '../prisma/prisma.service'; + +@WebSocketGateway({ path: '/ws/rdp' }) +export class RdpGateway implements OnGatewayConnection, OnGatewayDisconnect { + @WebSocketServer() server: Server; + private readonly logger = new Logger(RdpGateway.name); + private clientSockets = new Map(); // ws client → guacd socket + + constructor( + private guacamole: GuacamoleService, + private credentials: CredentialsService, + private hosts: HostsService, + private prisma: PrismaService, + private wsAuth: WsAuthGuard, + ) {} + + handleConnection(client: any) { + const user = this.wsAuth.validateClient(client); + if (!user) { + client.close(4001, 'Unauthorized'); + return; + } + this.logger.log(`RDP WS connected: ${user.email}`); + + client.on('message', async (raw: Buffer) => { + try { + const msg = JSON.parse(raw.toString()); + if (msg.type === 'connect') { + await this.handleConnect(client, msg); + } else if (msg.type === 'guac') { + // Forward Guacamole instruction to guacd + const socket = this.clientSockets.get(client); + if (socket) { + socket.write(msg.instruction); + } + } + } catch (err: any) { + this.send(client, { type: 'error', message: err.message }); + } + }); + } + + handleDisconnect(client: any) { + const socket = this.clientSockets.get(client); + if (socket) { + socket.destroy(); + this.clientSockets.delete(client); + } + } + + private async handleConnect(client: any, msg: any) { + const host = await this.hosts.findOne(msg.hostId); + const cred = host.credentialId + ? await this.credentials.decryptForConnection(host.credentialId) + : null; + + const socket = await this.guacamole.connect({ + hostname: host.hostname, + port: host.port, + username: cred?.username || '', + password: cred?.password || '', + domain: cred?.domain || undefined, + width: msg.width || 1920, + height: msg.height || 1080, + dpi: msg.dpi || 96, + security: msg.security || 'any', + colorDepth: msg.colorDepth || 24, + }); + + this.clientSockets.set(client, socket); + + // Forward guacd data to browser + socket.on('data', (data: Buffer) => { + if (client.readyState === 1) { + client.send(JSON.stringify({ type: 'guac', instruction: data.toString('utf-8') })); + } + }); + + socket.on('close', () => { + this.send(client, { type: 'disconnected', reason: 'RDP session closed' }); + this.clientSockets.delete(client); + }); + + socket.on('error', (err) => { + this.send(client, { type: 'error', message: err.message }); + }); + + // Update connection tracking + this.hosts.touchLastConnected(host.id); + this.prisma.connectionLog.create({ + data: { hostId: host.id, protocol: 'rdp' }, + }).catch(() => {}); + + this.send(client, { type: 'connected', hostId: host.id }); + } + + private send(client: any, data: any) { + if (client.readyState === 1) { + client.send(JSON.stringify(data)); + } + } +} +``` + +- [ ] **Step 3: Create RDP module** + +`backend/src/rdp/rdp.module.ts`: +```typescript +import { Module } from '@nestjs/common'; +import { GuacamoleService } from './guacamole.service'; +import { RdpGateway } from './rdp.gateway'; +import { VaultModule } from '../vault/vault.module'; +import { ConnectionsModule } from '../connections/connections.module'; +import { AuthModule } from '../auth/auth.module'; + +@Module({ + imports: [VaultModule, ConnectionsModule, AuthModule], + providers: [GuacamoleService, RdpGateway], +}) +export class RdpModule {} +``` + +Register `RdpModule` in `app.module.ts`. + +- [ ] **Step 4: Commit** + +```bash +git add -A +git commit -m "feat: RDP gateway — Guacamole tunnel to guacd over WebSocket" +``` + +--- + +### Task 14: RDP Frontend + +**Files:** +- Create: `frontend/composables/useRdp.ts` +- Create: `frontend/components/rdp/RdpCanvas.vue`, `RdpToolbar.vue` + +- [ ] **Step 1: Create useRdp composable** + +`frontend/composables/useRdp.ts`: +```typescript +import Guacamole from 'guacamole-common-js' +import { useAuthStore } from '~/stores/auth.store' +import { useSessionStore } from '~/stores/session.store' + +export function useRdp() { + const auth = useAuthStore() + const sessions = useSessionStore() + + function connectRdp( + container: HTMLElement, + hostId: number, + hostName: string, + color: string | null, + options?: { width?: number; height?: number }, + ) { + const wsUrl = `${location.protocol === 'https:' ? 'wss' : 'ws'}://${location.host}/ws/rdp?token=${auth.token}` + const ws = new WebSocket(wsUrl) + + // Guacamole tunnel wrapping our WebSocket + const tunnel = new Guacamole.WebSocketTunnel(wsUrl) + // We need to handle this custom since we have a JSON wrapper + + let client: Guacamole.Client | null = null + + ws.onopen = () => { + ws.send(JSON.stringify({ + type: 'connect', + hostId, + width: options?.width || container.clientWidth, + height: options?.height || container.clientHeight, + })) + } + + ws.onmessage = (event) => { + const msg = JSON.parse(event.data) + switch (msg.type) { + case 'connected': { + // Initialize Guacamole client with custom tunnel + client = new Guacamole.Client(tunnel) + const display = client.getDisplay().getElement() + container.appendChild(display) + + // Set up input + const mouse = new Guacamole.Mouse(display) + mouse.onEach(['mousedown', 'mousemove', 'mouseup'], (e: any) => { + client?.sendMouseState(e.state) + }) + + const keyboard = new Guacamole.Keyboard(document) + keyboard.onkeydown = (keysym: number) => client?.sendKeyEvent(1, keysym) + keyboard.onkeyup = (keysym: number) => client?.sendKeyEvent(0, keysym) + + sessions.addSession({ + id: `rdp-${hostId}-${Date.now()}`, + hostId, + hostName, + protocol: 'rdp', + color, + active: true, + }) + break + } + case 'guac': { + // Forward Guacamole instruction to client + if (client) { + tunnel.oninstruction?.(msg.instruction) + } + break + } + case 'error': + console.error('RDP error:', msg.message) + break + case 'disconnected': + client?.disconnect() + break + } + } + + return { ws, getClient: () => client } + } + + return { connectRdp } +} +``` + +**Note:** The guacamole-common-js integration may need adjustment during implementation. The standard `WebSocketTunnel` expects raw Guacamole protocol over WebSocket, but our gateway wraps instructions in JSON. Two approaches: +1. Implement a custom `Guacamole.Tunnel` that speaks JSON over WebSocket +2. Switch the RDP gateway to pass raw Guacamole instructions without JSON wrapping + +The implementer should evaluate both approaches during Phase 3 and choose whichever produces cleaner code. The custom tunnel approach is likely simpler. + +- [ ] **Step 2: Create RdpCanvas and RdpToolbar components** + +`frontend/components/rdp/RdpCanvas.vue` — wraps the Guacamole display element. Uses `useRdp()` composable. Full-size container that resizes with the parent. Props: `sessionId`, `hostId`. + +`frontend/components/rdp/RdpToolbar.vue` — floating toolbar overlay for RDP sessions. Buttons: clipboard (text input dialog for paste), fullscreen toggle (HTML5 Fullscreen API), disconnect, settings dropdown (color depth, resize behavior). + +- [ ] **Step 3: Update SessionContainer to handle RDP sessions** + +When a session has `protocol === 'rdp'`, render `RdpCanvas` instead of `TerminalInstance + SftpSidebar`. + +- [ ] **Step 4: Commit** + +```bash +git add -A +git commit -m "feat: Phase 3 — RDP via Guacamole in browser" +``` + +--- + +### Task 15: Vault Management UI + +**Files:** +- Create: `frontend/composables/useVault.ts` +- Create: `frontend/pages/vault/index.vue`, `vault/keys.vue`, `vault/credentials.vue` +- Create: `frontend/components/vault/KeyImportDialog.vue`, `CredentialForm.vue` + +- [ ] **Step 1: Create useVault composable** + +`frontend/composables/useVault.ts`: +```typescript +import { useAuthStore } from '~/stores/auth.store' + +export function useVault() { + const auth = useAuthStore() + const headers = () => ({ Authorization: `Bearer ${auth.token}` }) + + // SSH Keys + async function listKeys() { + return $fetch('/api/ssh-keys', { headers: headers() }) + } + async function importKey(data: { name: string; privateKey: string; passphrase?: string; publicKey?: string }) { + return $fetch('/api/ssh-keys', { method: 'POST', body: data, headers: headers() }) + } + async function deleteKey(id: number) { + return $fetch(`/api/ssh-keys/${id}`, { method: 'DELETE', headers: headers() }) + } + + // Credentials + async function listCredentials() { + return $fetch('/api/credentials', { headers: headers() }) + } + async function createCredential(data: any) { + return $fetch('/api/credentials', { method: 'POST', body: data, headers: headers() }) + } + async function updateCredential(id: number, data: any) { + return $fetch(`/api/credentials/${id}`, { method: 'PUT', body: data, headers: headers() }) + } + async function deleteCredential(id: number) { + return $fetch(`/api/credentials/${id}`, { method: 'DELETE', headers: headers() }) + } + + return { + listKeys, importKey, deleteKey, + listCredentials, createCredential, updateCredential, deleteCredential, + } +} +``` + +- [ ] **Step 2: Create vault pages** + +`frontend/pages/vault/index.vue` — overview page with quick stats (number of keys, number of credentials). Links to keys and credentials sub-pages. + +`frontend/pages/vault/keys.vue` — list of SSH keys (name, type, fingerprint, created date). "Import Key" button opens `KeyImportDialog`. Delete button per key with confirmation. + +`frontend/pages/vault/credentials.vue` — list of credentials (name, username, type badge, associated hosts). "New Credential" button opens form. Edit and delete per credential. + +- [ ] **Step 3: Create KeyImportDialog** + +`frontend/components/vault/KeyImportDialog.vue` — PrimeVue Dialog. Fields: name (text), private key (textarea or file upload), public key (textarea or file upload, optional), passphrase (password input, optional). File upload accepts `.pem`, `.pub`, `id_rsa`, `id_ed25519`. + +- [ ] **Step 4: Create CredentialForm** + +`frontend/components/vault/CredentialForm.vue` — form component used in both create and edit modes. Fields: name, type dropdown (password/ssh_key), username, password (shown if type=password), SSH key dropdown (shown if type=ssh_key, populated from keys list), domain (optional, for RDP). + +- [ ] **Step 5: Commit** + +```bash +git add -A +git commit -m "feat: vault management UI — SSH key import + credential CRUD" +``` + +--- + +### Task 16: Quick Connect + Search + Connection History + +**Files:** +- Create: `frontend/components/connections/QuickConnect.vue` +- Modify: `frontend/pages/index.vue` — add quick connect bar, search, recent connections section + +- [ ] **Step 1: Create QuickConnect component** + +`frontend/components/connections/QuickConnect.vue`: +```vue + + + +``` + +- [ ] **Step 2: Add search filter to connection manager** + +Update `frontend/pages/index.vue`: add a search input above the host grid. Filter `connections.hosts` by search term (name, hostname, tags) client-side. Add a "Recent" section above the full list showing hosts sorted by `lastConnectedAt`. + +- [ ] **Step 3: Wire QuickConnect to terminal/RDP** + +When QuickConnect emits a connection, create a temporary (unsaved) connection and open a terminal or RDP session. If the user wants to save it afterward, show a "Save this connection?" prompt. + +- [ ] **Step 4: Commit** + +```bash +git add -A +git commit -m "feat: quick connect, search, recent connections" +``` + +--- + +### Task 17: Settings Page + Theming + +**Files:** +- Create: `frontend/pages/settings.vue` +- Modify: `frontend/layouts/default.vue` — add dark/light toggle + +- [ ] **Step 1: Create settings page** + +`frontend/pages/settings.vue`: +```vue + + + +``` + +- [ ] **Step 2: Wire theme toggle** + +Apply `dark` class to `` element based on theme setting. Persist via the settings API. Update Tailwind classes throughout to support both modes using `dark:` prefix. + +- [ ] **Step 3: Commit** + +```bash +git add -A +git commit -m "feat: Phase 4 — settings, theming, polish" +``` + +--- + +### Task 18: Final Integration + Docker Verify + +- [ ] **Step 1: Update app.module.ts — register all modules** + +Ensure `app.module.ts` imports: `PrismaModule`, `AuthModule`, `VaultModule`, `ConnectionsModule`, `TerminalModule`, `RdpModule`, `SettingsModule`, `ServeStaticModule`. + +- [ ] **Step 2: Full Docker build and test** + +```bash +docker compose build +docker compose up -d +docker logs -f wraith-app +``` + +Verify: +- Login works (admin@wraith.local / wraith) +- Connection manager loads, can create hosts and groups +- SSH terminal connects to a test host +- SFTP sidebar shows remote files +- RDP connects to a Windows target (if available) +- Vault: can import SSH key, create credential, associate with host +- Settings: theme, font size, scrollback persist + +- [ ] **Step 3: Commit + tag** + +```bash +git add -A +git commit -m "feat: Wraith v0.1.0 — SSH + SFTP + RDP in a browser" +git tag v0.1.0 +``` + +--- + +## Dependency Graph + +``` +Task 1 (scaffold) ──┬──→ Task 2 (prisma + bootstrap) ──→ Task 3 (encryption) ──→ Task 4 (auth) + │ ↓ + │ Task 6 (vault backend) + │ ↓ + └──→ Task 8 (frontend shell) ←── Task 5 (connections backend) + ↓ + Task 9 (docker compose up) + ↓ + ┌──────────────────────────────────────────────┐ + ↓ ↓ + Task 10 (SSH gateway) ──→ Task 11 (SFTP gateway) ──→ Task 12 (frontend terminal+sftp) + │ + ↓ + Task 13 (RDP backend) ──→ Task 14 (RDP frontend) + ↓ + Task 15 (vault UI) ──→ Task 16 (quick connect + search) ──→ Task 17 (settings) + ↓ + Task 18 (final integration) +``` + +**Parallelizable groups:** +- Tasks 3 + 5 (after Task 2) +- Tasks 6 + 7 (after Task 3) +- Tasks 10-12 and Tasks 13-14 (after Task 9) +- Tasks 15, 16, 17 (after Task 12) diff --git a/docs/superpowers/plans/2026-03-17-wraith-phase1-foundation.md b/docs/superpowers/plans/2026-03-17-wraith-phase1-foundation.md new file mode 100644 index 0000000..8e98ad2 --- /dev/null +++ b/docs/superpowers/plans/2026-03-17-wraith-phase1-foundation.md @@ -0,0 +1,2690 @@ +# Wraith Desktop — Phase 1: Foundation Implementation Plan + +> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Stand up the Wraith desktop application skeleton — Wails v3 + Vue 3 + SQLite + vault encryption + connection CRUD + dark UI shell — with validated spikes for multi-window and RDP frame transport. + +**Architecture:** Go backend exposes services (vault, connections, settings, themes) via Wails v3 bindings to a Vue 3 frontend running in WebView2. SQLite (WAL mode) stores all data in `%APPDATA%\Wraith\wraith.db`. Master password derives a 256-bit AES key via Argon2id. + +**Tech Stack:** Go 1.22+, Wails v3 (alpha), Vue 3 (Composition API), Pinia, Tailwind CSS, Naive UI, SQLite (modernc.org/sqlite), Argon2id, AES-256-GCM + +**Spec:** `docs/superpowers/specs/2026-03-17-wraith-desktop-design.md` + +--- + +## File Structure + +``` +wraith/ + main.go # Wails app entry point, service registration + go.mod # Go module + go.sum + Taskfile.yml # Wails v3 build tasks + README.md # Developer documentation + LICENSE # MIT license + .gitignore + build/ + config.yml # Wails dev mode config + windows/ + Taskfile.yml # Windows build tasks + internal/ + db/ + sqlite.go # SQLite open, WAL mode, busy_timeout + sqlite_test.go + migrations.go # Embedded SQL migrations runner + migrations/ + 001_initial.sql # Full schema + vault/ + service.go # Argon2id key derivation, AES-256-GCM encrypt/decrypt + service_test.go + connections/ + service.go # Connection + group CRUD + service_test.go + search.go # Full-text search + tag filtering + search_test.go + settings/ + service.go # Key-value settings CRUD + service_test.go + theme/ + service.go # Theme CRUD + service_test.go + builtins.go # Dracula, Nord, Monokai, etc. + session/ + manager.go # Session manager (window-agnostic) + session.go # Session types + interfaces + plugin/ + interfaces.go # ProtocolHandler, Importer interfaces + registry.go # Plugin registration + frontend/ + index.html + package.json + vite.config.ts + tsconfig.json + tailwind.config.ts + postcss.config.js + src/ + main.ts # Vue app bootstrap + Pinia + Naive UI + App.vue # Root: unlock gate → main layout + router.ts # Vue Router (/, /settings, /vault) + layouts/ + MainLayout.vue # Sidebar + tabs + status bar + UnlockLayout.vue # Master password prompt + components/ + sidebar/ + ConnectionTree.vue # Group tree with connection entries + SidebarToggle.vue # Connections ↔ SFTP toggle + session/ + SessionContainer.vue # Holds active sessions (v-show) + TabBar.vue # Draggable tab bar + common/ + StatusBar.vue # Bottom status bar + stores/ + app.store.ts # Unlocked state, settings, active theme + connection.store.ts # Connections, groups, search + session.store.ts # Active sessions, tab order + composables/ + useConnections.ts # Connection CRUD wrappers + useVault.ts # Vault lock/unlock wrappers + assets/ + css/ + main.css # Tailwind imports + dark theme vars +``` + +--- + +## Task 1: Project Scaffold + +**Files:** +- Create: `go.mod`, `main.go`, `Taskfile.yml`, `.gitignore`, `LICENSE` +- Create: `frontend/package.json`, `frontend/vite.config.ts`, `frontend/tsconfig.json`, `frontend/index.html` +- Create: `frontend/src/main.ts`, `frontend/src/App.vue` + +- [ ] **Step 1: Initialize Go module** + +```bash +cd /Users/vstockwell/repos/wraith +go mod init github.com/vstockwell/wraith +``` + +- [ ] **Step 2: Install Wails v3 CLI (if not already installed)** + +```bash +go install github.com/wailsapp/wails/v3/cmd/wails3@latest +``` + +- [ ] **Step 3: Create `.gitignore`** + +```gitignore +# Go +bin/ +dist/ +*.exe + +# Frontend +frontend/node_modules/ +frontend/dist/ +frontend/bindings/ + +# Wails +build/bin/ + +# IDE +.vscode/ +.idea/ + +# OS +.DS_Store +Thumbs.db + +# App data +*.db +*.db-wal +*.db-shm + +# Superpowers +.superpowers/ +``` + +- [ ] **Step 4: Create `LICENSE` (MIT)** + +``` +MIT License + +Copyright (c) 2026 Vantz Stockwell + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +``` + +- [ ] **Step 5: Create `main.go` — minimal Wails v3 app** + +```go +package main + +import ( + "embed" + "log" + + "github.com/wailsapp/wails/v3/pkg/application" +) + +//go:embed all:frontend/dist +var assets embed.FS + +func main() { + app := application.New(application.Options{ + Name: "Wraith", + Description: "SSH + RDP + SFTP Desktop Client", + Services: []application.Service{}, + Assets: application.AssetOptions{ + Handler: application.BundledAssetFileServer(assets), + }, + }) + + app.NewWebviewWindowWithOptions(application.WebviewWindowOptions{ + Title: "Wraith", + Width: 1400, + Height: 900, + URL: "/", + Windows: application.WindowsWindowOptions{ + BackdropType: application.Mica, + }, + BackgroundColour: application.NewRGBA(13, 17, 23, 255), + }) + + if err := app.Run(); err != nil { + log.Fatal(err) + } +} +``` + +- [ ] **Step 6: Create `frontend/package.json`** + +```json +{ + "name": "wraith-frontend", + "private": true, + "type": "module", + "scripts": { + "dev": "vite", + "build": "vue-tsc --noEmit && vite build", + "build:dev": "vue-tsc --noEmit && vite build --minify false --mode development", + "preview": "vite preview" + }, + "dependencies": { + "@wailsio/runtime": "latest", + "vue": "^3.5.0", + "vue-router": "^4.4.0", + "pinia": "^2.2.0", + "naive-ui": "^2.40.0" + }, + "devDependencies": { + "@vitejs/plugin-vue": "^5.0.0", + "typescript": "^5.5.0", + "vite": "^6.0.0", + "vue-tsc": "^2.0.0", + "tailwindcss": "^4.0.0", + "@tailwindcss/vite": "^4.0.0" + } +} +``` + +- [ ] **Step 7: Create `frontend/vite.config.ts`** + +```ts +import { defineConfig } from "vite"; +import vue from "@vitejs/plugin-vue"; +import tailwindcss from "@tailwindcss/vite"; + +export default defineConfig({ + plugins: [vue(), tailwindcss()], +}); +``` + +- [ ] **Step 8: Create `frontend/tsconfig.json`** + +```json +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "strict": true, + "jsx": "preserve", + "resolveJsonModule": true, + "isolatedModules": true, + "esModuleInterop": true, + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "skipLibCheck": true, + "noEmit": true, + "paths": { + "@/*": ["./src/*"] + } + }, + "include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.vue"] +} +``` + +- [ ] **Step 9: Create `frontend/index.html`** + +```html + + + + + + Wraith + + +
+ + + +``` + +- [ ] **Step 10: Create `frontend/src/assets/css/main.css`** + +```css +@import "tailwindcss"; + +:root { + --wraith-bg-primary: #0d1117; + --wraith-bg-secondary: #161b22; + --wraith-bg-tertiary: #21262d; + --wraith-border: #30363d; + --wraith-text-primary: #e0e0e0; + --wraith-text-secondary: #8b949e; + --wraith-text-muted: #484f58; + --wraith-accent-blue: #58a6ff; + --wraith-accent-green: #3fb950; + --wraith-accent-red: #f85149; + --wraith-accent-yellow: #e3b341; +} + +body { + margin: 0; + font-family: system-ui, -apple-system, 'Segoe UI', sans-serif; + background: var(--wraith-bg-primary); + color: var(--wraith-text-primary); + overflow: hidden; + user-select: none; +} + +/* Scrollbar styling */ +::-webkit-scrollbar { width: 8px; } +::-webkit-scrollbar-track { background: var(--wraith-bg-primary); } +::-webkit-scrollbar-thumb { background: var(--wraith-border); border-radius: 4px; } +::-webkit-scrollbar-thumb:hover { background: var(--wraith-text-muted); } +``` + +- [ ] **Step 11: Create `frontend/src/main.ts`** + +```ts +import { createApp } from "vue"; +import { createPinia } from "pinia"; +import App from "./App.vue"; +import "./assets/css/main.css"; + +const app = createApp(App); +app.use(createPinia()); +app.mount("#app"); +``` + +- [ ] **Step 12: Create `frontend/src/App.vue` — placeholder** + +```vue + +``` + +- [ ] **Step 13: Install frontend dependencies and verify build** + +```bash +cd frontend && npm install && npm run build && cd .. +``` + +- [ ] **Step 14: Install Go dependencies and verify compilation** + +```bash +go mod tidy +go build -o bin/wraith.exe . +``` + +- [ ] **Step 15: Commit scaffold** + +```bash +git add .gitignore LICENSE main.go go.mod go.sum Taskfile.yml frontend/ +git commit -m "feat: Wails v3 + Vue 3 project scaffold with Tailwind dark theme" +``` + +--- + +## Task 2: SQLite Database Layer + +**Files:** +- Create: `internal/db/sqlite.go`, `internal/db/sqlite_test.go` +- Create: `internal/db/migrations.go`, `internal/db/migrations/001_initial.sql` + +- [ ] **Step 1: Write the test — SQLite opens with WAL mode** + +```go +// internal/db/sqlite_test.go +package db + +import ( + "os" + "path/filepath" + "testing" +) + +func TestOpenCreatesDatabase(t *testing.T) { + dir := t.TempDir() + dbPath := filepath.Join(dir, "test.db") + + db, err := Open(dbPath) + if err != nil { + t.Fatalf("Open() error: %v", err) + } + defer db.Close() + + // Verify file exists + if _, err := os.Stat(dbPath); os.IsNotExist(err) { + t.Fatal("database file was not created") + } +} + +func TestOpenSetsWALMode(t *testing.T) { + dir := t.TempDir() + db, err := Open(filepath.Join(dir, "test.db")) + if err != nil { + t.Fatalf("Open() error: %v", err) + } + defer db.Close() + + var mode string + err = db.QueryRow("PRAGMA journal_mode").Scan(&mode) + if err != nil { + t.Fatalf("PRAGMA query error: %v", err) + } + if mode != "wal" { + t.Errorf("journal_mode = %q, want %q", mode, "wal") + } +} + +func TestOpenSetsBusyTimeout(t *testing.T) { + dir := t.TempDir() + db, err := Open(filepath.Join(dir, "test.db")) + if err != nil { + t.Fatalf("Open() error: %v", err) + } + defer db.Close() + + var timeout int + err = db.QueryRow("PRAGMA busy_timeout").Scan(&timeout) + if err != nil { + t.Fatalf("PRAGMA query error: %v", err) + } + if timeout != 5000 { + t.Errorf("busy_timeout = %d, want %d", timeout, 5000) + } +} +``` + +- [ ] **Step 2: Run tests to verify they fail** + +```bash +go test ./internal/db/ -v +``` + +Expected: FAIL — `Open` not defined + +- [ ] **Step 3: Implement `internal/db/sqlite.go`** + +```go +package db + +import ( + "database/sql" + "fmt" + "os" + "path/filepath" + + _ "modernc.org/sqlite" +) + +func Open(dbPath string) (*sql.DB, error) { + // Ensure parent directory exists + dir := filepath.Dir(dbPath) + if err := os.MkdirAll(dir, 0700); err != nil { + return nil, fmt.Errorf("create db directory: %w", err) + } + + db, err := sql.Open("sqlite", dbPath) + if err != nil { + return nil, fmt.Errorf("open database: %w", err) + } + + // Enable WAL mode for concurrent read/write + if _, err := db.Exec("PRAGMA journal_mode=WAL"); err != nil { + db.Close() + return nil, fmt.Errorf("set WAL mode: %w", err) + } + + // Set busy timeout to prevent "database is locked" errors + if _, err := db.Exec("PRAGMA busy_timeout=5000"); err != nil { + db.Close() + return nil, fmt.Errorf("set busy_timeout: %w", err) + } + + // Enable foreign keys + if _, err := db.Exec("PRAGMA foreign_keys=ON"); err != nil { + db.Close() + return nil, fmt.Errorf("enable foreign keys: %w", err) + } + + return db, nil +} +``` + +- [ ] **Step 4: Add `modernc.org/sqlite` dependency** + +```bash +go get modernc.org/sqlite +go mod tidy +``` + +- [ ] **Step 5: Run tests to verify they pass** + +```bash +go test ./internal/db/ -v +``` + +Expected: 3 PASS + +- [ ] **Step 6: Create migration SQL — `internal/db/migrations/001_initial.sql`** + +Full schema from spec (all tables: groups, connections, credentials, ssh_keys, themes, connection_history, host_keys, settings). + +```sql +-- 001_initial.sql +CREATE TABLE IF NOT EXISTS groups ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + parent_id INTEGER REFERENCES groups(id) ON DELETE SET NULL, + sort_order INTEGER DEFAULT 0, + icon TEXT, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP +); + +CREATE TABLE IF NOT EXISTS ssh_keys ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + key_type TEXT, + fingerprint TEXT, + public_key TEXT, + encrypted_private_key TEXT NOT NULL, + passphrase_encrypted TEXT, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP +); + +CREATE TABLE IF NOT EXISTS credentials ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + username TEXT, + domain TEXT, + type TEXT NOT NULL CHECK(type IN ('password','ssh_key')), + encrypted_value TEXT, + ssh_key_id INTEGER REFERENCES ssh_keys(id) ON DELETE SET NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP +); + +CREATE TABLE IF NOT EXISTS connections ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + hostname TEXT NOT NULL, + port INTEGER NOT NULL DEFAULT 22, + protocol TEXT NOT NULL CHECK(protocol IN ('ssh','rdp')), + group_id INTEGER REFERENCES groups(id) ON DELETE SET NULL, + credential_id INTEGER REFERENCES credentials(id) ON DELETE SET NULL, + color TEXT, + tags TEXT DEFAULT '[]', + notes TEXT, + options TEXT DEFAULT '{}', + sort_order INTEGER DEFAULT 0, + last_connected DATETIME, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP +); + +CREATE TABLE IF NOT EXISTS themes ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL UNIQUE, + foreground TEXT NOT NULL, + background TEXT NOT NULL, + cursor TEXT NOT NULL, + black TEXT NOT NULL, + red TEXT NOT NULL, + green TEXT NOT NULL, + yellow TEXT NOT NULL, + blue TEXT NOT NULL, + magenta TEXT NOT NULL, + cyan TEXT NOT NULL, + white TEXT NOT NULL, + bright_black TEXT NOT NULL, + bright_red TEXT NOT NULL, + bright_green TEXT NOT NULL, + bright_yellow TEXT NOT NULL, + bright_blue TEXT NOT NULL, + bright_magenta TEXT NOT NULL, + bright_cyan TEXT NOT NULL, + bright_white TEXT NOT NULL, + selection_bg TEXT, + selection_fg TEXT, + is_builtin BOOLEAN DEFAULT 0 +); + +CREATE TABLE IF NOT EXISTS connection_history ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + connection_id INTEGER NOT NULL REFERENCES connections(id) ON DELETE CASCADE, + protocol TEXT NOT NULL, + connected_at DATETIME DEFAULT CURRENT_TIMESTAMP, + disconnected_at DATETIME, + duration_secs INTEGER +); + +CREATE TABLE IF NOT EXISTS host_keys ( + hostname TEXT NOT NULL, + port INTEGER NOT NULL, + key_type TEXT NOT NULL, + fingerprint TEXT NOT NULL, + raw_key TEXT, + first_seen DATETIME DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (hostname, port, key_type) +); + +CREATE TABLE IF NOT EXISTS settings ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL +); +``` + +- [ ] **Step 7: Write migration runner test** + +```go +// Add to internal/db/sqlite_test.go +func TestMigrateCreatesAllTables(t *testing.T) { + dir := t.TempDir() + db, err := Open(filepath.Join(dir, "test.db")) + if err != nil { + t.Fatalf("Open() error: %v", err) + } + defer db.Close() + + if err := Migrate(db); err != nil { + t.Fatalf("Migrate() error: %v", err) + } + + expectedTables := []string{ + "groups", "connections", "credentials", "ssh_keys", + "themes", "connection_history", "host_keys", "settings", + } + for _, table := range expectedTables { + var name string + err := db.QueryRow( + "SELECT name FROM sqlite_master WHERE type='table' AND name=?", table, + ).Scan(&name) + if err != nil { + t.Errorf("table %q not found: %v", table, err) + } + } +} +``` + +- [ ] **Step 8: Implement migration runner — `internal/db/migrations.go`** + +```go +package db + +import ( + "database/sql" + "embed" + "fmt" + "sort" +) + +//go:embed migrations/*.sql +var migrationFiles embed.FS + +func Migrate(db *sql.DB) error { + entries, err := migrationFiles.ReadDir("migrations") + if err != nil { + return fmt.Errorf("read migrations: %w", err) + } + + sort.Slice(entries, func(i, j int) bool { + return entries[i].Name() < entries[j].Name() + }) + + for _, entry := range entries { + content, err := migrationFiles.ReadFile("migrations/" + entry.Name()) + if err != nil { + return fmt.Errorf("read migration %s: %w", entry.Name(), err) + } + if _, err := db.Exec(string(content)); err != nil { + return fmt.Errorf("execute migration %s: %w", entry.Name(), err) + } + } + + return nil +} +``` + +- [ ] **Step 9: Run all db tests** + +```bash +go test ./internal/db/ -v +``` + +Expected: 4 PASS + +- [ ] **Step 10: Commit** + +```bash +git add internal/db/ +git commit -m "feat: SQLite database layer with WAL mode and schema migrations" +``` + +--- + +## Task 3: Vault Service — Encryption + +**Files:** +- Create: `internal/vault/service.go`, `internal/vault/service_test.go` + +- [ ] **Step 1: Write test — Argon2id key derivation produces consistent keys** + +```go +// internal/vault/service_test.go +package vault + +import ( + "testing" +) + +func TestDeriveKeyConsistent(t *testing.T) { + salt := []byte("test-salt-exactly-32-bytes-long!") + key1 := DeriveKey("mypassword", salt) + key2 := DeriveKey("mypassword", salt) + + if len(key1) != 32 { + t.Errorf("key length = %d, want 32", len(key1)) + } + if string(key1) != string(key2) { + t.Error("same password+salt produced different keys") + } +} + +func TestDeriveKeyDifferentPasswords(t *testing.T) { + salt := []byte("test-salt-exactly-32-bytes-long!") + key1 := DeriveKey("password1", salt) + key2 := DeriveKey("password2", salt) + + if string(key1) == string(key2) { + t.Error("different passwords produced same key") + } +} +``` + +- [ ] **Step 2: Run tests to verify they fail** + +```bash +go test ./internal/vault/ -v +``` + +Expected: FAIL — `DeriveKey` not defined + +- [ ] **Step 3: Implement key derivation** + +```go +// internal/vault/service.go +package vault + +import ( + "crypto/aes" + "crypto/cipher" + "crypto/rand" + "encoding/hex" + "errors" + "fmt" + "strings" + + "golang.org/x/crypto/argon2" +) + +// Argon2id parameters (OWASP recommended) +const ( + argonTime = 3 + argonMemory = 64 * 1024 // 64MB + argonThreads = 4 + argonKeyLen = 32 +) + +// DeriveKey derives a 256-bit key from password and salt using Argon2id. +func DeriveKey(password string, salt []byte) []byte { + return argon2.IDKey([]byte(password), salt, argonTime, argonMemory, argonThreads, argonKeyLen) +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +```bash +go get golang.org/x/crypto +go test ./internal/vault/ -v +``` + +Expected: 2 PASS + +- [ ] **Step 5: Write test — Encrypt/Decrypt round-trip** + +```go +// Add to internal/vault/service_test.go +func TestEncryptDecryptRoundTrip(t *testing.T) { + key := DeriveKey("testpassword", []byte("test-salt-32-bytes-long-exactly!")) + vs := NewVaultService(key) + + plaintext := "super-secret-ssh-key-data" + encrypted, err := vs.Encrypt(plaintext) + if err != nil { + t.Fatalf("Encrypt() error: %v", err) + } + + // Must start with v1: prefix + if !strings.HasPrefix(encrypted, "v1:") { + t.Errorf("encrypted does not start with v1: prefix: %q", encrypted[:10]) + } + + decrypted, err := vs.Decrypt(encrypted) + if err != nil { + t.Fatalf("Decrypt() error: %v", err) + } + + if decrypted != plaintext { + t.Errorf("Decrypt() = %q, want %q", decrypted, plaintext) + } +} + +func TestEncryptProducesDifferentCiphertexts(t *testing.T) { + key := DeriveKey("testpassword", []byte("test-salt-32-bytes-long-exactly!")) + vs := NewVaultService(key) + + enc1, _ := vs.Encrypt("same-data") + enc2, _ := vs.Encrypt("same-data") + + if enc1 == enc2 { + t.Error("two encryptions of same data produced identical ciphertext (IV reuse)") + } +} + +func TestDecryptWrongKey(t *testing.T) { + key1 := DeriveKey("password1", []byte("test-salt-32-bytes-long-exactly!")) + key2 := DeriveKey("password2", []byte("test-salt-32-bytes-long-exactly!")) + + vs1 := NewVaultService(key1) + vs2 := NewVaultService(key2) + + encrypted, _ := vs1.Encrypt("secret") + _, err := vs2.Decrypt(encrypted) + if err == nil { + t.Error("Decrypt() with wrong key should return error") + } +} + +func TestDecryptInvalidFormat(t *testing.T) { + key := DeriveKey("test", []byte("test-salt-32-bytes-long-exactly!")) + vs := NewVaultService(key) + + _, err := vs.Decrypt("not-valid-format") + if err == nil { + t.Error("Decrypt() with invalid format should return error") + } +} +``` + +- [ ] **Step 6: Implement Encrypt/Decrypt** + +```go +// Add to internal/vault/service.go + +// VaultService handles encryption and decryption of sensitive data. +type VaultService struct { + key []byte +} + +// NewVaultService creates a vault with the given AES-256 key. +func NewVaultService(key []byte) *VaultService { + return &VaultService{key: key} +} + +// Encrypt encrypts plaintext using AES-256-GCM. +// Returns "v1:{iv_hex}:{sealed_hex}" where sealed = ciphertext || authTag. +func (v *VaultService) Encrypt(plaintext string) (string, error) { + block, err := aes.NewCipher(v.key) + if err != nil { + return "", fmt.Errorf("create cipher: %w", err) + } + + gcm, err := cipher.NewGCM(block) + if err != nil { + return "", fmt.Errorf("create GCM: %w", err) + } + + iv := make([]byte, gcm.NonceSize()) // 12 bytes + if _, err := rand.Read(iv); err != nil { + return "", fmt.Errorf("generate IV: %w", err) + } + + sealed := gcm.Seal(nil, iv, []byte(plaintext), nil) + + return fmt.Sprintf("v1:%s:%s", hex.EncodeToString(iv), hex.EncodeToString(sealed)), nil +} + +// Decrypt decrypts a "v1:{iv_hex}:{sealed_hex}" string. +func (v *VaultService) Decrypt(encrypted string) (string, error) { + parts := strings.SplitN(encrypted, ":", 3) + if len(parts) != 3 || parts[0] != "v1" { + return "", errors.New("invalid encrypted format: expected v1:{iv}:{sealed}") + } + + iv, err := hex.DecodeString(parts[1]) + if err != nil { + return "", fmt.Errorf("decode IV: %w", err) + } + + sealed, err := hex.DecodeString(parts[2]) + if err != nil { + return "", fmt.Errorf("decode sealed data: %w", err) + } + + block, err := aes.NewCipher(v.key) + if err != nil { + return "", fmt.Errorf("create cipher: %w", err) + } + + gcm, err := cipher.NewGCM(block) + if err != nil { + return "", fmt.Errorf("create GCM: %w", err) + } + + plaintext, err := gcm.Open(nil, iv, sealed, nil) + if err != nil { + return "", fmt.Errorf("decrypt: %w", err) + } + + return string(plaintext), nil +} +``` + +- [ ] **Step 7: Run all vault tests** + +```bash +go test ./internal/vault/ -v +``` + +Expected: 6 PASS + +- [ ] **Step 8: Write test — GenerateSalt produces 32 random bytes** + +```go +// Add to internal/vault/service_test.go +func TestGenerateSalt(t *testing.T) { + salt1, err := GenerateSalt() + if err != nil { + t.Fatalf("GenerateSalt() error: %v", err) + } + if len(salt1) != 32 { + t.Errorf("salt length = %d, want 32", len(salt1)) + } + + salt2, _ := GenerateSalt() + if string(salt1) == string(salt2) { + t.Error("two calls to GenerateSalt produced identical salt") + } +} +``` + +- [ ] **Step 9: Implement GenerateSalt** + +```go +// Add to internal/vault/service.go + +// GenerateSalt generates a 32-byte random salt for Argon2id. +func GenerateSalt() ([]byte, error) { + salt := make([]byte, 32) + if _, err := rand.Read(salt); err != nil { + return nil, fmt.Errorf("generate salt: %w", err) + } + return salt, nil +} +``` + +- [ ] **Step 10: Run all vault tests** + +```bash +go test ./internal/vault/ -v +``` + +Expected: 7 PASS + +- [ ] **Step 11: Commit** + +```bash +git add internal/vault/ +git commit -m "feat: vault service — Argon2id key derivation + AES-256-GCM encrypt/decrypt" +``` + +--- + +## Task 4: Settings Service + +**Files:** +- Create: `internal/settings/service.go`, `internal/settings/service_test.go` + +- [ ] **Step 1: Write tests** + +```go +// internal/settings/service_test.go +package settings + +import ( + "path/filepath" + "testing" + + "github.com/vstockwell/wraith/internal/db" +) + +func setupTestDB(t *testing.T) *SettingsService { + t.Helper() + d, err := db.Open(filepath.Join(t.TempDir(), "test.db")) + if err != nil { + t.Fatal(err) + } + if err := db.Migrate(d); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { d.Close() }) + return NewSettingsService(d) +} + +func TestSetAndGet(t *testing.T) { + s := setupTestDB(t) + + if err := s.Set("theme", "dracula"); err != nil { + t.Fatalf("Set() error: %v", err) + } + + val, err := s.Get("theme") + if err != nil { + t.Fatalf("Get() error: %v", err) + } + if val != "dracula" { + t.Errorf("Get() = %q, want %q", val, "dracula") + } +} + +func TestGetMissing(t *testing.T) { + s := setupTestDB(t) + + val, err := s.Get("nonexistent") + if err != nil { + t.Fatalf("Get() error: %v", err) + } + if val != "" { + t.Errorf("Get() = %q, want empty string", val) + } +} + +func TestSetOverwrites(t *testing.T) { + s := setupTestDB(t) + + s.Set("key", "value1") + s.Set("key", "value2") + + val, _ := s.Get("key") + if val != "value2" { + t.Errorf("Get() = %q, want %q", val, "value2") + } +} + +func TestGetWithDefault(t *testing.T) { + s := setupTestDB(t) + + val := s.GetDefault("missing", "fallback") + if val != "fallback" { + t.Errorf("GetDefault() = %q, want %q", val, "fallback") + } +} +``` + +- [ ] **Step 2: Run tests to verify they fail** + +```bash +go test ./internal/settings/ -v +``` + +- [ ] **Step 3: Implement settings service** + +```go +// internal/settings/service.go +package settings + +import "database/sql" + +type SettingsService struct { + db *sql.DB +} + +func NewSettingsService(db *sql.DB) *SettingsService { + return &SettingsService{db: db} +} + +func (s *SettingsService) Get(key string) (string, error) { + var value string + err := s.db.QueryRow("SELECT value FROM settings WHERE key = ?", key).Scan(&value) + if err == sql.ErrNoRows { + return "", nil + } + return value, err +} + +func (s *SettingsService) GetDefault(key, defaultValue string) string { + val, err := s.Get(key) + if err != nil || val == "" { + return defaultValue + } + return val +} + +func (s *SettingsService) Set(key, value string) error { + _, err := s.db.Exec( + "INSERT INTO settings (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = ?", + key, value, value, + ) + return err +} + +func (s *SettingsService) Delete(key string) error { + _, err := s.db.Exec("DELETE FROM settings WHERE key = ?", key) + return err +} +``` + +- [ ] **Step 4: Run tests** + +```bash +go test ./internal/settings/ -v +``` + +Expected: 4 PASS + +- [ ] **Step 5: Commit** + +```bash +git add internal/settings/ +git commit -m "feat: settings service — key-value store with upsert" +``` + +--- + +## Task 5: Connection + Group CRUD + +**Files:** +- Create: `internal/connections/service.go`, `internal/connections/service_test.go` + +- [ ] **Step 1: Write connection model types** + +```go +// internal/connections/service.go +package connections + +import ( + "database/sql" + "encoding/json" + "fmt" + "time" +) + +type Group struct { + ID int64 `json:"id"` + Name string `json:"name"` + ParentID *int64 `json:"parentId"` + SortOrder int `json:"sortOrder"` + Icon string `json:"icon"` + CreatedAt time.Time `json:"createdAt"` + Children []Group `json:"children,omitempty"` +} + +type Connection struct { + ID int64 `json:"id"` + Name string `json:"name"` + Hostname string `json:"hostname"` + Port int `json:"port"` + Protocol string `json:"protocol"` + GroupID *int64 `json:"groupId"` + CredentialID *int64 `json:"credentialId"` + Color string `json:"color"` + Tags []string `json:"tags"` + Notes string `json:"notes"` + Options string `json:"options"` + SortOrder int `json:"sortOrder"` + LastConnected *time.Time `json:"lastConnected"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` +} + +type ConnectionService struct { + db *sql.DB +} + +func NewConnectionService(db *sql.DB) *ConnectionService { + return &ConnectionService{db: db} +} +``` + +- [ ] **Step 2: Write tests for group CRUD** + +```go +// internal/connections/service_test.go +package connections + +import ( + "path/filepath" + "testing" + + "github.com/vstockwell/wraith/internal/db" +) + +func setupTestDB(t *testing.T) *ConnectionService { + t.Helper() + d, err := db.Open(filepath.Join(t.TempDir(), "test.db")) + if err != nil { + t.Fatal(err) + } + if err := db.Migrate(d); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { d.Close() }) + return NewConnectionService(d) +} + +func TestCreateGroup(t *testing.T) { + svc := setupTestDB(t) + + g, err := svc.CreateGroup("Vantz's Stuff", nil) + if err != nil { + t.Fatalf("CreateGroup() error: %v", err) + } + if g.ID == 0 { + t.Error("group ID should not be zero") + } + if g.Name != "Vantz's Stuff" { + t.Errorf("Name = %q, want %q", g.Name, "Vantz's Stuff") + } +} + +func TestCreateSubGroup(t *testing.T) { + svc := setupTestDB(t) + + parent, _ := svc.CreateGroup("Parent", nil) + parentID := parent.ID + child, err := svc.CreateGroup("Child", &parentID) + if err != nil { + t.Fatalf("CreateGroup() error: %v", err) + } + if child.ParentID == nil || *child.ParentID != parent.ID { + t.Error("child ParentID should match parent ID") + } +} + +func TestListGroups(t *testing.T) { + svc := setupTestDB(t) + svc.CreateGroup("Group A", nil) + svc.CreateGroup("Group B", nil) + + groups, err := svc.ListGroups() + if err != nil { + t.Fatalf("ListGroups() error: %v", err) + } + if len(groups) != 2 { + t.Errorf("len(groups) = %d, want 2", len(groups)) + } +} + +func TestDeleteGroup(t *testing.T) { + svc := setupTestDB(t) + g, _ := svc.CreateGroup("ToDelete", nil) + + if err := svc.DeleteGroup(g.ID); err != nil { + t.Fatalf("DeleteGroup() error: %v", err) + } + + groups, _ := svc.ListGroups() + if len(groups) != 0 { + t.Error("group should have been deleted") + } +} +``` + +- [ ] **Step 3: Implement group CRUD methods** + +```go +// Add to internal/connections/service.go + +func (s *ConnectionService) CreateGroup(name string, parentID *int64) (*Group, error) { + result, err := s.db.Exec( + "INSERT INTO groups (name, parent_id) VALUES (?, ?)", + name, parentID, + ) + if err != nil { + return nil, fmt.Errorf("create group: %w", err) + } + id, _ := result.LastInsertId() + return &Group{ID: id, Name: name, ParentID: parentID}, nil +} + +func (s *ConnectionService) ListGroups() ([]Group, error) { + rows, err := s.db.Query("SELECT id, name, parent_id, sort_order, COALESCE(icon,''), created_at FROM groups ORDER BY sort_order, name") + if err != nil { + return nil, fmt.Errorf("list groups: %w", err) + } + defer rows.Close() + + var groups []Group + for rows.Next() { + var g Group + if err := rows.Scan(&g.ID, &g.Name, &g.ParentID, &g.SortOrder, &g.Icon, &g.CreatedAt); err != nil { + return nil, err + } + groups = append(groups, g) + } + return groups, nil +} + +func (s *ConnectionService) DeleteGroup(id int64) error { + _, err := s.db.Exec("DELETE FROM groups WHERE id = ?", id) + return err +} +``` + +- [ ] **Step 4: Run group tests** + +```bash +go test ./internal/connections/ -v -run "TestCreateGroup|TestCreateSubGroup|TestListGroups|TestDeleteGroup" +``` + +Expected: 4 PASS + +- [ ] **Step 5: Write tests for connection CRUD** + +```go +// Add to internal/connections/service_test.go + +func TestCreateConnection(t *testing.T) { + svc := setupTestDB(t) + + conn, err := svc.CreateConnection(CreateConnectionInput{ + Name: "Asgard", + Hostname: "192.168.1.4", + Port: 22, + Protocol: "ssh", + Tags: []string{"Prod", "Linux"}, + }) + if err != nil { + t.Fatalf("CreateConnection() error: %v", err) + } + if conn.Name != "Asgard" { + t.Errorf("Name = %q, want %q", conn.Name, "Asgard") + } + if len(conn.Tags) != 2 { + t.Errorf("len(Tags) = %d, want 2", len(conn.Tags)) + } +} + +func TestListConnections(t *testing.T) { + svc := setupTestDB(t) + svc.CreateConnection(CreateConnectionInput{Name: "Host1", Hostname: "10.0.0.1", Port: 22, Protocol: "ssh"}) + svc.CreateConnection(CreateConnectionInput{Name: "Host2", Hostname: "10.0.0.2", Port: 3389, Protocol: "rdp"}) + + conns, err := svc.ListConnections() + if err != nil { + t.Fatalf("ListConnections() error: %v", err) + } + if len(conns) != 2 { + t.Errorf("len(conns) = %d, want 2", len(conns)) + } +} + +func TestUpdateConnection(t *testing.T) { + svc := setupTestDB(t) + conn, _ := svc.CreateConnection(CreateConnectionInput{Name: "Old", Hostname: "10.0.0.1", Port: 22, Protocol: "ssh"}) + + updated, err := svc.UpdateConnection(conn.ID, UpdateConnectionInput{Name: strPtr("New")}) + if err != nil { + t.Fatalf("UpdateConnection() error: %v", err) + } + if updated.Name != "New" { + t.Errorf("Name = %q, want %q", updated.Name, "New") + } +} + +func TestDeleteConnection(t *testing.T) { + svc := setupTestDB(t) + conn, _ := svc.CreateConnection(CreateConnectionInput{Name: "Del", Hostname: "10.0.0.1", Port: 22, Protocol: "ssh"}) + + if err := svc.DeleteConnection(conn.ID); err != nil { + t.Fatalf("DeleteConnection() error: %v", err) + } + conns, _ := svc.ListConnections() + if len(conns) != 0 { + t.Error("connection should have been deleted") + } +} + +func strPtr(s string) *string { return &s } +``` + +- [ ] **Step 6: Implement connection CRUD** + +```go +// Add to internal/connections/service.go + +type CreateConnectionInput struct { + Name string `json:"name"` + Hostname string `json:"hostname"` + Port int `json:"port"` + Protocol string `json:"protocol"` + GroupID *int64 `json:"groupId"` + CredentialID *int64 `json:"credentialId"` + Color string `json:"color"` + Tags []string `json:"tags"` + Notes string `json:"notes"` + Options string `json:"options"` +} + +type UpdateConnectionInput struct { + Name *string `json:"name"` + Hostname *string `json:"hostname"` + Port *int `json:"port"` + GroupID *int64 `json:"groupId"` + CredentialID *int64 `json:"credentialId"` + Color *string `json:"color"` + Tags []string `json:"tags"` + Notes *string `json:"notes"` + Options *string `json:"options"` +} + +func (s *ConnectionService) CreateConnection(input CreateConnectionInput) (*Connection, error) { + tags, _ := json.Marshal(input.Tags) + if input.Tags == nil { + tags = []byte("[]") + } + options := input.Options + if options == "" { + options = "{}" + } + + result, err := s.db.Exec( + `INSERT INTO connections (name, hostname, port, protocol, group_id, credential_id, color, tags, notes, options) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + input.Name, input.Hostname, input.Port, input.Protocol, + input.GroupID, input.CredentialID, input.Color, string(tags), input.Notes, options, + ) + if err != nil { + return nil, fmt.Errorf("create connection: %w", err) + } + + id, _ := result.LastInsertId() + return s.GetConnection(id) +} + +func (s *ConnectionService) GetConnection(id int64) (*Connection, error) { + var conn Connection + var tagsStr string + err := s.db.QueryRow( + `SELECT id, name, hostname, port, protocol, group_id, credential_id, + COALESCE(color,''), tags, COALESCE(notes,''), COALESCE(options,'{}'), + sort_order, last_connected, created_at, updated_at + FROM connections WHERE id = ?`, id, + ).Scan(&conn.ID, &conn.Name, &conn.Hostname, &conn.Port, &conn.Protocol, + &conn.GroupID, &conn.CredentialID, &conn.Color, &tagsStr, &conn.Notes, + &conn.Options, &conn.SortOrder, &conn.LastConnected, &conn.CreatedAt, &conn.UpdatedAt) + if err != nil { + return nil, fmt.Errorf("get connection: %w", err) + } + json.Unmarshal([]byte(tagsStr), &conn.Tags) + return &conn, nil +} + +func (s *ConnectionService) ListConnections() ([]Connection, error) { + rows, err := s.db.Query( + `SELECT id, name, hostname, port, protocol, group_id, credential_id, + COALESCE(color,''), tags, COALESCE(notes,''), COALESCE(options,'{}'), + sort_order, last_connected, created_at, updated_at + FROM connections ORDER BY sort_order, name`) + if err != nil { + return nil, fmt.Errorf("list connections: %w", err) + } + defer rows.Close() + + var conns []Connection + for rows.Next() { + var c Connection + var tagsStr string + if err := rows.Scan(&c.ID, &c.Name, &c.Hostname, &c.Port, &c.Protocol, + &c.GroupID, &c.CredentialID, &c.Color, &tagsStr, &c.Notes, &c.Options, + &c.SortOrder, &c.LastConnected, &c.CreatedAt, &c.UpdatedAt); err != nil { + return nil, err + } + json.Unmarshal([]byte(tagsStr), &c.Tags) + conns = append(conns, c) + } + return conns, nil +} + +func (s *ConnectionService) UpdateConnection(id int64, input UpdateConnectionInput) (*Connection, error) { + setClauses := []string{"updated_at = CURRENT_TIMESTAMP"} + args := []interface{}{} + + if input.Name != nil { + setClauses = append(setClauses, "name = ?") + args = append(args, *input.Name) + } + if input.Hostname != nil { + setClauses = append(setClauses, "hostname = ?") + args = append(args, *input.Hostname) + } + if input.Port != nil { + setClauses = append(setClauses, "port = ?") + args = append(args, *input.Port) + } + if input.Tags != nil { + tags, _ := json.Marshal(input.Tags) + setClauses = append(setClauses, "tags = ?") + args = append(args, string(tags)) + } + if input.Notes != nil { + setClauses = append(setClauses, "notes = ?") + args = append(args, *input.Notes) + } + if input.Color != nil { + setClauses = append(setClauses, "color = ?") + args = append(args, *input.Color) + } + if input.Options != nil { + setClauses = append(setClauses, "options = ?") + args = append(args, *input.Options) + } + + args = append(args, id) + query := fmt.Sprintf("UPDATE connections SET %s WHERE id = ?", strings.Join(setClauses, ", ")) + if _, err := s.db.Exec(query, args...); err != nil { + return nil, fmt.Errorf("update connection: %w", err) + } + return s.GetConnection(id) +} + +func (s *ConnectionService) DeleteConnection(id int64) error { + _, err := s.db.Exec("DELETE FROM connections WHERE id = ?", id) + return err +} +``` + +- [ ] **Step 7: Run all connection tests** + +```bash +go test ./internal/connections/ -v +``` + +Expected: 8 PASS (4 group + 4 connection) + +- [ ] **Step 8: Commit** + +```bash +git add internal/connections/ +git commit -m "feat: connection + group CRUD with JSON tags and options" +``` + +--- + +## Task 6: Connection Search + Tag Filtering + +**Files:** +- Create: `internal/connections/search.go`, `internal/connections/search_test.go` + +- [ ] **Step 1: Write search tests** + +```go +// internal/connections/search_test.go +package connections + +import "testing" + +func TestSearchByName(t *testing.T) { + svc := setupTestDB(t) + svc.CreateConnection(CreateConnectionInput{Name: "Asgard", Hostname: "192.168.1.4", Port: 22, Protocol: "ssh"}) + svc.CreateConnection(CreateConnectionInput{Name: "Docker", Hostname: "155.254.29.221", Port: 22, Protocol: "ssh"}) + + results, err := svc.Search("asg") + if err != nil { + t.Fatalf("Search() error: %v", err) + } + if len(results) != 1 { + t.Fatalf("len(results) = %d, want 1", len(results)) + } + if results[0].Name != "Asgard" { + t.Errorf("Name = %q, want %q", results[0].Name, "Asgard") + } +} + +func TestSearchByHostname(t *testing.T) { + svc := setupTestDB(t) + svc.CreateConnection(CreateConnectionInput{Name: "Asgard", Hostname: "192.168.1.4", Port: 22, Protocol: "ssh"}) + + results, _ := svc.Search("192.168") + if len(results) != 1 { + t.Errorf("len(results) = %d, want 1", len(results)) + } +} + +func TestSearchByTag(t *testing.T) { + svc := setupTestDB(t) + svc.CreateConnection(CreateConnectionInput{Name: "ProdServer", Hostname: "10.0.0.1", Port: 22, Protocol: "ssh", Tags: []string{"Prod", "Linux"}}) + svc.CreateConnection(CreateConnectionInput{Name: "DevServer", Hostname: "10.0.0.2", Port: 22, Protocol: "ssh", Tags: []string{"Dev", "Linux"}}) + + results, _ := svc.Search("Prod") + if len(results) != 1 { + t.Errorf("len(results) = %d, want 1", len(results)) + } +} + +func TestFilterByTag(t *testing.T) { + svc := setupTestDB(t) + svc.CreateConnection(CreateConnectionInput{Name: "A", Hostname: "10.0.0.1", Port: 22, Protocol: "ssh", Tags: []string{"Prod"}}) + svc.CreateConnection(CreateConnectionInput{Name: "B", Hostname: "10.0.0.2", Port: 22, Protocol: "ssh", Tags: []string{"Dev"}}) + svc.CreateConnection(CreateConnectionInput{Name: "C", Hostname: "10.0.0.3", Port: 22, Protocol: "ssh", Tags: []string{"Prod", "Linux"}}) + + results, _ := svc.FilterByTag("Prod") + if len(results) != 2 { + t.Errorf("len(results) = %d, want 2", len(results)) + } +} +``` + +- [ ] **Step 2: Implement search** + +```go +// internal/connections/search.go +package connections + +import "fmt" + +func (s *ConnectionService) Search(query string) ([]Connection, error) { + like := "%" + query + "%" + rows, err := s.db.Query( + `SELECT id, name, hostname, port, protocol, group_id, credential_id, + COALESCE(color,''), tags, COALESCE(notes,''), COALESCE(options,'{}'), + sort_order, last_connected, created_at, updated_at + FROM connections + WHERE name LIKE ? COLLATE NOCASE + OR hostname LIKE ? COLLATE NOCASE + OR tags LIKE ? COLLATE NOCASE + OR notes LIKE ? COLLATE NOCASE + ORDER BY last_connected DESC NULLS LAST, name`, + like, like, like, like, + ) + if err != nil { + return nil, fmt.Errorf("search connections: %w", err) + } + defer rows.Close() + return scanConnections(rows) +} + +func (s *ConnectionService) FilterByTag(tag string) ([]Connection, error) { + rows, err := s.db.Query( + `SELECT c.id, c.name, c.hostname, c.port, c.protocol, c.group_id, c.credential_id, + COALESCE(c.color,''), c.tags, COALESCE(c.notes,''), COALESCE(c.options,'{}'), + c.sort_order, c.last_connected, c.created_at, c.updated_at + FROM connections c, json_each(c.tags) AS t + WHERE t.value = ? + ORDER BY c.name`, tag, + ) + if err != nil { + return nil, fmt.Errorf("filter by tag: %w", err) + } + defer rows.Close() + return scanConnections(rows) +} +``` + +- [ ] **Step 3: Extract `scanConnections` helper to `service.go`** + +```go +// Add to internal/connections/service.go +import "database/sql" + +func scanConnections(rows *sql.Rows) ([]Connection, error) { + var conns []Connection + for rows.Next() { + var c Connection + var tagsStr string + if err := rows.Scan(&c.ID, &c.Name, &c.Hostname, &c.Port, &c.Protocol, + &c.GroupID, &c.CredentialID, &c.Color, &tagsStr, &c.Notes, &c.Options, + &c.SortOrder, &c.LastConnected, &c.CreatedAt, &c.UpdatedAt); err != nil { + return nil, err + } + json.Unmarshal([]byte(tagsStr), &c.Tags) + conns = append(conns, c) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("scan connections: %w", err) + } + return conns, nil +} +``` + +Refactor `ListConnections` to use `scanConnections`. + +- [ ] **Step 4: Run search tests** + +```bash +go test ./internal/connections/ -v +``` + +Expected: 12 PASS + +- [ ] **Step 5: Commit** + +```bash +git add internal/connections/ +git commit -m "feat: connection search by name/hostname/tag with json_each filtering" +``` + +--- + +## Task 7: Theme Service + Built-in Themes + +**Files:** +- Create: `internal/theme/service.go`, `internal/theme/service_test.go`, `internal/theme/builtins.go` + +- [ ] **Step 1: Define theme type and built-in themes** + +```go +// internal/theme/builtins.go +package theme + +type Theme struct { + ID int64 `json:"id"` + Name string `json:"name"` + Foreground string `json:"foreground"` + Background string `json:"background"` + Cursor string `json:"cursor"` + Black string `json:"black"` + Red string `json:"red"` + Green string `json:"green"` + Yellow string `json:"yellow"` + Blue string `json:"blue"` + Magenta string `json:"magenta"` + Cyan string `json:"cyan"` + White string `json:"white"` + BrightBlack string `json:"brightBlack"` + BrightRed string `json:"brightRed"` + BrightGreen string `json:"brightGreen"` + BrightYellow string `json:"brightYellow"` + BrightBlue string `json:"brightBlue"` + BrightMagenta string `json:"brightMagenta"` + BrightCyan string `json:"brightCyan"` + BrightWhite string `json:"brightWhite"` + SelectionBg string `json:"selectionBg,omitempty"` + SelectionFg string `json:"selectionFg,omitempty"` + IsBuiltin bool `json:"isBuiltin"` +} + +var BuiltinThemes = []Theme{ + { + Name: "Dracula", IsBuiltin: true, + Foreground: "#f8f8f2", Background: "#282a36", Cursor: "#f8f8f2", + Black: "#21222c", Red: "#ff5555", Green: "#50fa7b", Yellow: "#f1fa8c", + Blue: "#bd93f9", Magenta: "#ff79c6", Cyan: "#8be9fd", White: "#f8f8f2", + BrightBlack: "#6272a4", BrightRed: "#ff6e6e", BrightGreen: "#69ff94", + BrightYellow: "#ffffa5", BrightBlue: "#d6acff", BrightMagenta: "#ff92df", + BrightCyan: "#a4ffff", BrightWhite: "#ffffff", + }, + { + Name: "Nord", IsBuiltin: true, + Foreground: "#d8dee9", Background: "#2e3440", Cursor: "#d8dee9", + Black: "#3b4252", Red: "#bf616a", Green: "#a3be8c", Yellow: "#ebcb8b", + Blue: "#81a1c1", Magenta: "#b48ead", Cyan: "#88c0d0", White: "#e5e9f0", + BrightBlack: "#4c566a", BrightRed: "#bf616a", BrightGreen: "#a3be8c", + BrightYellow: "#ebcb8b", BrightBlue: "#81a1c1", BrightMagenta: "#b48ead", + BrightCyan: "#8fbcbb", BrightWhite: "#eceff4", + }, + { + Name: "Monokai", IsBuiltin: true, + Foreground: "#f8f8f2", Background: "#272822", Cursor: "#f8f8f0", + Black: "#272822", Red: "#f92672", Green: "#a6e22e", Yellow: "#f4bf75", + Blue: "#66d9ef", Magenta: "#ae81ff", Cyan: "#a1efe4", White: "#f8f8f2", + BrightBlack: "#75715e", BrightRed: "#f92672", BrightGreen: "#a6e22e", + BrightYellow: "#f4bf75", BrightBlue: "#66d9ef", BrightMagenta: "#ae81ff", + BrightCyan: "#a1efe4", BrightWhite: "#f9f8f5", + }, + { + Name: "One Dark", IsBuiltin: true, + Foreground: "#abb2bf", Background: "#282c34", Cursor: "#528bff", + Black: "#282c34", Red: "#e06c75", Green: "#98c379", Yellow: "#e5c07b", + Blue: "#61afef", Magenta: "#c678dd", Cyan: "#56b6c2", White: "#abb2bf", + BrightBlack: "#545862", BrightRed: "#e06c75", BrightGreen: "#98c379", + BrightYellow: "#e5c07b", BrightBlue: "#61afef", BrightMagenta: "#c678dd", + BrightCyan: "#56b6c2", BrightWhite: "#c8ccd4", + }, + { + Name: "Solarized Dark", IsBuiltin: true, + Foreground: "#839496", Background: "#002b36", Cursor: "#839496", + Black: "#073642", Red: "#dc322f", Green: "#859900", Yellow: "#b58900", + Blue: "#268bd2", Magenta: "#d33682", Cyan: "#2aa198", White: "#eee8d5", + BrightBlack: "#002b36", BrightRed: "#cb4b16", BrightGreen: "#586e75", + BrightYellow: "#657b83", BrightBlue: "#839496", BrightMagenta: "#6c71c4", + BrightCyan: "#93a1a1", BrightWhite: "#fdf6e3", + }, + { + Name: "Gruvbox Dark", IsBuiltin: true, + Foreground: "#ebdbb2", Background: "#282828", Cursor: "#ebdbb2", + Black: "#282828", Red: "#cc241d", Green: "#98971a", Yellow: "#d79921", + Blue: "#458588", Magenta: "#b16286", Cyan: "#689d6a", White: "#a89984", + BrightBlack: "#928374", BrightRed: "#fb4934", BrightGreen: "#b8bb26", + BrightYellow: "#fabd2f", BrightBlue: "#83a598", BrightMagenta: "#d3869b", + BrightCyan: "#8ec07c", BrightWhite: "#ebdbb2", + }, + { + Name: "MobaXTerm Classic", IsBuiltin: true, + Foreground: "#ececec", Background: "#242424", Cursor: "#b4b4c0", + Black: "#000000", Red: "#aa4244", Green: "#7e8d53", Yellow: "#e4b46d", + Blue: "#6e9aba", Magenta: "#9e5085", Cyan: "#80d5cf", White: "#cccccc", + BrightBlack: "#808080", BrightRed: "#cc7b7d", BrightGreen: "#a5b17c", + BrightYellow: "#ecc995", BrightBlue: "#96b6cd", BrightMagenta: "#c083ac", + BrightCyan: "#a9e2de", BrightWhite: "#cccccc", + }, +} +``` + +- [ ] **Step 2: Write test — seed built-in themes and list them** + +```go +// internal/theme/service_test.go +package theme + +import ( + "path/filepath" + "testing" + + "github.com/vstockwell/wraith/internal/db" +) + +func setupTestDB(t *testing.T) *ThemeService { + t.Helper() + d, err := db.Open(filepath.Join(t.TempDir(), "test.db")) + if err != nil { + t.Fatal(err) + } + if err := db.Migrate(d); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { d.Close() }) + return NewThemeService(d) +} + +func TestSeedBuiltins(t *testing.T) { + svc := setupTestDB(t) + if err := svc.SeedBuiltins(); err != nil { + t.Fatalf("SeedBuiltins() error: %v", err) + } + + themes, err := svc.List() + if err != nil { + t.Fatalf("List() error: %v", err) + } + if len(themes) != len(BuiltinThemes) { + t.Errorf("len(themes) = %d, want %d", len(themes), len(BuiltinThemes)) + } +} + +func TestSeedBuiltinsIdempotent(t *testing.T) { + svc := setupTestDB(t) + svc.SeedBuiltins() + svc.SeedBuiltins() // Second call should not duplicate + + themes, _ := svc.List() + if len(themes) != len(BuiltinThemes) { + t.Errorf("len(themes) = %d after double seed, want %d", len(themes), len(BuiltinThemes)) + } +} + +func TestGetByName(t *testing.T) { + svc := setupTestDB(t) + svc.SeedBuiltins() + + theme, err := svc.GetByName("Dracula") + if err != nil { + t.Fatalf("GetByName() error: %v", err) + } + if theme.Background != "#282a36" { + t.Errorf("Background = %q, want %q", theme.Background, "#282a36") + } +} +``` + +- [ ] **Step 3: Implement theme service** + +```go +// internal/theme/service.go +package theme + +import ( + "database/sql" + "fmt" +) + +type ThemeService struct { + db *sql.DB +} + +func NewThemeService(db *sql.DB) *ThemeService { + return &ThemeService{db: db} +} + +func (s *ThemeService) SeedBuiltins() error { + for _, t := range BuiltinThemes { + _, err := s.db.Exec( + `INSERT OR IGNORE INTO themes (name, foreground, background, cursor, + black, red, green, yellow, blue, magenta, cyan, white, + bright_black, bright_red, bright_green, bright_yellow, bright_blue, + bright_magenta, bright_cyan, bright_white, selection_bg, selection_fg, is_builtin) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,1)`, + t.Name, t.Foreground, t.Background, t.Cursor, + t.Black, t.Red, t.Green, t.Yellow, t.Blue, t.Magenta, t.Cyan, t.White, + t.BrightBlack, t.BrightRed, t.BrightGreen, t.BrightYellow, t.BrightBlue, + t.BrightMagenta, t.BrightCyan, t.BrightWhite, t.SelectionBg, t.SelectionFg, + ) + if err != nil { + return fmt.Errorf("seed theme %s: %w", t.Name, err) + } + } + return nil +} + +func (s *ThemeService) List() ([]Theme, error) { + rows, err := s.db.Query( + `SELECT id, name, foreground, background, cursor, + black, red, green, yellow, blue, magenta, cyan, white, + bright_black, bright_red, bright_green, bright_yellow, bright_blue, + bright_magenta, bright_cyan, bright_white, + COALESCE(selection_bg,''), COALESCE(selection_fg,''), is_builtin + FROM themes ORDER BY is_builtin DESC, name`) + if err != nil { + return nil, err + } + defer rows.Close() + + var themes []Theme + for rows.Next() { + var t Theme + if err := rows.Scan(&t.ID, &t.Name, &t.Foreground, &t.Background, &t.Cursor, + &t.Black, &t.Red, &t.Green, &t.Yellow, &t.Blue, &t.Magenta, &t.Cyan, &t.White, + &t.BrightBlack, &t.BrightRed, &t.BrightGreen, &t.BrightYellow, &t.BrightBlue, + &t.BrightMagenta, &t.BrightCyan, &t.BrightWhite, + &t.SelectionBg, &t.SelectionFg, &t.IsBuiltin); err != nil { + return nil, err + } + themes = append(themes, t) + } + return themes, nil +} + +func (s *ThemeService) GetByName(name string) (*Theme, error) { + var t Theme + err := s.db.QueryRow( + `SELECT id, name, foreground, background, cursor, + black, red, green, yellow, blue, magenta, cyan, white, + bright_black, bright_red, bright_green, bright_yellow, bright_blue, + bright_magenta, bright_cyan, bright_white, + COALESCE(selection_bg,''), COALESCE(selection_fg,''), is_builtin + FROM themes WHERE name = ?`, name, + ).Scan(&t.ID, &t.Name, &t.Foreground, &t.Background, &t.Cursor, + &t.Black, &t.Red, &t.Green, &t.Yellow, &t.Blue, &t.Magenta, &t.Cyan, &t.White, + &t.BrightBlack, &t.BrightRed, &t.BrightGreen, &t.BrightYellow, &t.BrightBlue, + &t.BrightMagenta, &t.BrightCyan, &t.BrightWhite, + &t.SelectionBg, &t.SelectionFg, &t.IsBuiltin) + if err != nil { + return nil, fmt.Errorf("get theme %s: %w", name, err) + } + return &t, nil +} +``` + +- [ ] **Step 4: Run theme tests** + +```bash +go test ./internal/theme/ -v +``` + +Expected: 3 PASS + +- [ ] **Step 5: Commit** + +```bash +git add internal/theme/ +git commit -m "feat: theme service with 7 built-in terminal color schemes" +``` + +--- + +## Task 8: Plugin Interfaces + Session Manager Skeleton + +**Files:** +- Create: `internal/plugin/interfaces.go`, `internal/plugin/registry.go` +- Create: `internal/session/session.go`, `internal/session/manager.go`, `internal/session/manager_test.go` + +- [ ] **Step 1: Define plugin interfaces** + +```go +// internal/plugin/interfaces.go +package plugin + +// ProtocolHandler defines how a protocol plugin connects and manages sessions. +type ProtocolHandler interface { + Name() string + Connect(config map[string]interface{}) (Session, error) + Disconnect(sessionID string) error +} + +// Session represents an active protocol session. +type Session interface { + ID() string + Protocol() string + Write(data []byte) error + Close() error +} + +// Importer parses configuration files from other tools. +type Importer interface { + Name() string + FileExtensions() []string + Parse(data []byte) (*ImportResult, error) +} + +// ImportResult holds parsed data from an imported config file. +type ImportResult struct { + Groups []ImportGroup `json:"groups"` + Connections []ImportConnection `json:"connections"` + HostKeys []ImportHostKey `json:"hostKeys"` + Theme *ImportTheme `json:"theme,omitempty"` +} + +type ImportGroup struct { + Name string `json:"name"` + ParentName string `json:"parentName,omitempty"` +} + +type ImportConnection struct { + Name string `json:"name"` + Hostname string `json:"hostname"` + Port int `json:"port"` + Protocol string `json:"protocol"` + Username string `json:"username"` + GroupName string `json:"groupName"` + Notes string `json:"notes"` +} + +type ImportHostKey struct { + Hostname string `json:"hostname"` + Port int `json:"port"` + KeyType string `json:"keyType"` + Fingerprint string `json:"fingerprint"` +} + +type ImportTheme struct { + Name string `json:"name"` + Foreground string `json:"foreground"` + Background string `json:"background"` + Cursor string `json:"cursor"` + Colors [16]string `json:"colors"` // ANSI 0-15 +} +``` + +- [ ] **Step 2: Define plugin registry** + +```go +// internal/plugin/registry.go +package plugin + +import "fmt" + +type Registry struct { + protocols map[string]ProtocolHandler + importers map[string]Importer +} + +func NewRegistry() *Registry { + return &Registry{ + protocols: make(map[string]ProtocolHandler), + importers: make(map[string]Importer), + } +} + +func (r *Registry) RegisterProtocol(handler ProtocolHandler) { + r.protocols[handler.Name()] = handler +} + +func (r *Registry) RegisterImporter(imp Importer) { + r.importers[imp.Name()] = imp +} + +func (r *Registry) GetProtocol(name string) (ProtocolHandler, error) { + h, ok := r.protocols[name] + if !ok { + return nil, fmt.Errorf("protocol handler %q not registered", name) + } + return h, nil +} + +func (r *Registry) GetImporter(name string) (Importer, error) { + imp, ok := r.importers[name] + if !ok { + return nil, fmt.Errorf("importer %q not registered", name) + } + return imp, nil +} + +func (r *Registry) ListProtocols() []string { + names := make([]string, 0, len(r.protocols)) + for name := range r.protocols { + names = append(names, name) + } + return names +} +``` + +- [ ] **Step 3: Define session types (data only — no behavior to test)** + +```go +// internal/session/session.go +package session + +import "time" + +type SessionState string + +const ( + StateConnecting SessionState = "connecting" + StateConnected SessionState = "connected" + StateDisconnected SessionState = "disconnected" + StateDetached SessionState = "detached" +) + +type SessionInfo struct { + ID string `json:"id"` + ConnectionID int64 `json:"connectionId"` + Protocol string `json:"protocol"` + State SessionState `json:"state"` + WindowID string `json:"windowId"` + TabPosition int `json:"tabPosition"` + ConnectedAt time.Time `json:"connectedAt"` +} +``` + +```go +// internal/session/manager.go +package session + +import ( + "fmt" + "sync" + + "github.com/google/uuid" +) + +const MaxSessions = 32 + +type Manager struct { + mu sync.RWMutex + sessions map[string]*SessionInfo +} + +func NewManager() *Manager { + return &Manager{ + sessions: make(map[string]*SessionInfo), + } +} + +func (m *Manager) Create(connectionID int64, protocol string) (*SessionInfo, error) { + m.mu.Lock() + defer m.mu.Unlock() + + if len(m.sessions) >= MaxSessions { + return nil, fmt.Errorf("maximum sessions (%d) reached", MaxSessions) + } + + s := &SessionInfo{ + ID: uuid.NewString(), + ConnectionID: connectionID, + Protocol: protocol, + State: StateConnecting, + TabPosition: len(m.sessions), + } + m.sessions[s.ID] = s + return s, nil +} + +func (m *Manager) Get(id string) (*SessionInfo, bool) { + m.mu.RLock() + defer m.mu.RUnlock() + s, ok := m.sessions[id] + return s, ok +} + +func (m *Manager) List() []*SessionInfo { + m.mu.RLock() + defer m.mu.RUnlock() + list := make([]*SessionInfo, 0, len(m.sessions)) + for _, s := range m.sessions { + list = append(list, s) + } + return list +} + +func (m *Manager) SetState(id string, state SessionState) error { + m.mu.Lock() + defer m.mu.Unlock() + s, ok := m.sessions[id] + if !ok { + return fmt.Errorf("session %s not found", id) + } + s.State = state + return nil +} + +func (m *Manager) Detach(id string) error { + return m.SetState(id, StateDetached) +} + +func (m *Manager) Reattach(id, windowID string) error { + m.mu.Lock() + defer m.mu.Unlock() + s, ok := m.sessions[id] + if !ok { + return fmt.Errorf("session %s not found", id) + } + s.State = StateConnected + s.WindowID = windowID + return nil +} + +func (m *Manager) Remove(id string) { + m.mu.Lock() + defer m.mu.Unlock() + delete(m.sessions, id) +} + +func (m *Manager) Count() int { + m.mu.RLock() + defer m.mu.RUnlock() + return len(m.sessions) +} +``` + +- [ ] **Step 4: Write session manager tests (TDD — tests before manager implementation)** + +Add `github.com/google/uuid` before running tests: +```bash +go get github.com/google/uuid +``` + +```go +// internal/session/manager_test.go +package session + +import "testing" + +func TestCreateSession(t *testing.T) { + m := NewManager() + s, err := m.Create(1, "ssh") + if err != nil { + t.Fatalf("Create() error: %v", err) + } + if s.ID == "" { + t.Error("session ID should not be empty") + } + if s.State != StateConnecting { + t.Errorf("State = %q, want %q", s.State, StateConnecting) + } +} + +func TestMaxSessions(t *testing.T) { + m := NewManager() + for i := 0; i < MaxSessions; i++ { + _, err := m.Create(int64(i), "ssh") + if err != nil { + t.Fatalf("Create() error at %d: %v", i, err) + } + } + _, err := m.Create(999, "ssh") + if err == nil { + t.Error("Create() should fail at max sessions") + } +} + +func TestDetachReattach(t *testing.T) { + m := NewManager() + s, _ := m.Create(1, "ssh") + m.SetState(s.ID, StateConnected) + + if err := m.Detach(s.ID); err != nil { + t.Fatalf("Detach() error: %v", err) + } + + got, _ := m.Get(s.ID) + if got.State != StateDetached { + t.Errorf("State = %q, want %q", got.State, StateDetached) + } + + if err := m.Reattach(s.ID, "window-1"); err != nil { + t.Fatalf("Reattach() error: %v", err) + } + + got, _ = m.Get(s.ID) + if got.State != StateConnected { + t.Errorf("State = %q, want %q", got.State, StateConnected) + } +} + +func TestRemoveSession(t *testing.T) { + m := NewManager() + s, _ := m.Create(1, "ssh") + m.Remove(s.ID) + if m.Count() != 0 { + t.Error("session should have been removed") + } +} +``` + +- [ ] **Step 5: Run session tests to verify they FAIL (manager not implemented yet)** + +```bash +go test ./internal/session/ -v +``` + +Expected: FAIL — `NewManager` not defined + +- [ ] **Step 5b: Implement session manager (manager.go — the code from Step 3's second block)** + +Move the `Manager` struct and methods into `manager.go` now that tests exist. + +- [ ] **Step 5c: Run session tests to verify they PASS** + +```bash +go test ./internal/session/ -v +``` + +Expected: 4 PASS + +- [ ] **Step 6: Commit** + +```bash +git add internal/plugin/ internal/session/ +git commit -m "feat: plugin interfaces + window-agnostic session manager with detach/reattach" +``` + +--- + +## Task 9: Wire Services into Wails App + +**Files:** +- Modify: `main.go` + +- [ ] **Step 1: Update `main.go` to initialize DB, vault, and register all services** + +Wire up SQLite, vault unlock flow, and register ConnectionService, SettingsService, ThemeService, and SessionManager as Wails services. The vault unlock happens via a frontend prompt (implemented in Task 10). + +Create a `WraithApp` struct that holds all services and exposes methods the frontend needs: +- `Unlock(password string) error` +- `IsFirstRun() bool` +- `CreateVault(password string) error` + +Register it as a Wails service alongside ConnectionService, ThemeService, and SettingsService. + +- [ ] **Step 2: Verify compilation** + +```bash +go build -o bin/wraith.exe . +``` + +- [ ] **Step 3: Commit** + +```bash +git add main.go +git commit -m "feat: wire all services into Wails app entry point" +``` + +--- + +## Task 10: Master Password Unlock UI + +**Files:** +- Create: `frontend/src/layouts/UnlockLayout.vue` +- Create: `frontend/src/stores/app.store.ts` +- Create: `frontend/src/composables/useVault.ts` +- Modify: `frontend/src/App.vue` + +- [ ] **Step 1: Create app store with unlock state** + +```ts +// frontend/src/stores/app.store.ts +import { defineStore } from "pinia"; +import { ref } from "vue"; + +export const useAppStore = defineStore("app", () => { + const unlocked = ref(false); + const isFirstRun = ref(false); + + function setUnlocked(val: boolean) { unlocked.value = val; } + function setFirstRun(val: boolean) { isFirstRun.value = val; } + + return { unlocked, isFirstRun, setUnlocked, setFirstRun }; +}); +``` + +- [ ] **Step 2: Create unlock layout with master password form** + +Dark-themed centered card with the Wraith logo, master password input, unlock button. First-run mode shows "Create Master Password" with confirm field. + +- [ ] **Step 3: Update `App.vue` to gate on unlock state** + +```vue + +``` + +Note: `MainLayout` is created in Task 11. This placeholder will be replaced there. + +- [ ] **Step 4: Generate Wails bindings and verify the unlock flow renders** + +```bash +wails3 generate bindings +cd frontend && npm run build && cd .. +go run . +``` + +Note: `wails3 generate bindings` scans registered Go services and generates TypeScript bindings into `frontend/bindings/`. Frontend code imports from these bindings to call Go methods. Re-run this command whenever Go service methods change. + +- [ ] **Step 5: Commit** + +```bash +git add frontend/src/ +git commit -m "feat: master password unlock UI with first-run vault creation" +``` + +--- + +## Task 11: Main Layout — Sidebar + Tab Container + Status Bar + +**Files:** +- Create: `frontend/src/layouts/MainLayout.vue` +- Create: `frontend/src/components/sidebar/ConnectionTree.vue` +- Create: `frontend/src/components/sidebar/SidebarToggle.vue` +- Create: `frontend/src/components/session/TabBar.vue` +- Create: `frontend/src/components/session/SessionContainer.vue` +- Create: `frontend/src/components/common/StatusBar.vue` +- Create: `frontend/src/stores/connection.store.ts` +- Create: `frontend/src/stores/session.store.ts` +- Create: `frontend/src/composables/useConnections.ts` + +- [ ] **Step 1: Create connection store** + +Pinia store wrapping Wails bindings for ConnectionService: `groups`, `connections`, `searchQuery`, `activeTag`, computed filtered lists. + +- [ ] **Step 2: Create session store** + +Pinia store wrapping SessionManager bindings: `sessions`, `activeSessionId`, `tabOrder`. + +- [ ] **Step 3: Create `MainLayout.vue`** + +Three-panel layout: resizable sidebar (240px default), tab bar + session area, status bar. Uses CSS Grid. + +- [ ] **Step 4: Create `ConnectionTree.vue`** + +Renders hierarchical group tree with connection entries. Search bar at top, tag filter pills, recent connections section. Uses Naive UI `NTree` component. Green dots for SSH, blue for RDP. + +- [ ] **Step 5: Create `SidebarToggle.vue`** + +Toggle buttons: Connections | SFTP. Switches the sidebar content area. + +- [ ] **Step 6: Create `TabBar.vue`** + +Horizontal tab bar with color-coded dots, close button, "+" button. Active tab highlighted with blue bottom border and 0.5s CSS transition. + +- [ ] **Step 7: Create `SessionContainer.vue`** + +Holds active session placeholders (empty state for Phase 1 — "Connect to a host to start a session"). Uses `v-show` for tab switching. + +- [ ] **Step 8: Create `StatusBar.vue`** + +Bottom bar with connection info (left) and app info (right): theme name, encoding, terminal dimensions placeholder. + +- [ ] **Step 9: Build and verify layout renders** + +```bash +cd frontend && npm run build && cd .. +go run . +``` + +- [ ] **Step 10: Commit** + +```bash +git add frontend/src/ +git commit -m "feat: main layout — sidebar connection tree, tab bar, status bar" +``` + +--- + +## Task 12: Multi-Window Spike (Plan A Validation) + +**Files:** +- Create: `spike/multiwindow/main.go` (temporary) + +- [ ] **Step 1: Write a minimal two-window Wails v3 test** + +Create a spike app that: +1. Opens a main window +2. Has a button that opens a second window via `app.NewWebviewWindowWithOptions()` +3. Both windows can call the same Go service method +4. Closing the second window doesn't crash the app + +- [ ] **Step 2: Run the spike on Windows (or via cross-compile check)** + +```bash +cd spike/multiwindow && go run . +``` + +Document results: does `NewWebviewWindow` work? Can both windows access the same service? Any crashes? + +- [ ] **Step 3: Record findings** + +Create `docs/spikes/multi-window-results.md` with: +- Plan A status: WORKS / PARTIAL / FAILED +- Any issues found +- Fallback recommendation if needed + +- [ ] **Step 4: Clean up spike** + +```bash +rm -rf spike/ +``` + +- [ ] **Step 5: Commit findings** + +```bash +git add docs/spikes/ +git commit -m "spike: Wails v3 multi-window validation — Plan A results" +``` + +--- + +## Task 13: RDP Frame Transport Spike + +**Files:** +- Create: `spike/frametransport/main.go` (temporary) + +- [ ] **Step 1: Write a spike that benchmarks frame delivery** + +Create a Go app that: +1. Generates a 1920x1080 RGBA test frame (solid color + timestamp) +2. Serves it via local HTTP endpoint (`/frame`) +3. Also exposes it via a Wails binding (base64-encoded PNG) +4. Frontend renders both approaches on a `` and measures FPS + +- [ ] **Step 2: Run benchmark** + +Target: which approach sustains 30fps at 1080p? + +- [ ] **Step 3: Record findings** + +Create `docs/spikes/rdp-frame-transport-results.md` with: +- HTTP approach: measured FPS, latency, CPU usage +- Base64 approach: measured FPS, latency, CPU usage +- Recommendation for Phase 3 + +- [ ] **Step 4: Clean up spike** + +```bash +rm -rf spike/ +``` + +- [ ] **Step 5: Commit findings** + +```bash +git add docs/spikes/ +git commit -m "spike: RDP frame transport benchmark — HTTP vs base64 results" +``` + +--- + +## Task 14: README.md + +**Files:** +- Create: `README.md` + +- [ ] **Step 1: Write comprehensive README** + +Cover: +- Project overview (what Wraith is, screenshot/logo) +- Features list +- Tech stack +- Prerequisites (Go 1.22+, Node 20+, Wails v3 CLI) +- Build instructions (dev mode, production build) +- Project structure walkthrough +- Architecture overview (Go services → Wails bindings → Vue 3 frontend) +- Plugin development guide (implement ProtocolHandler or Importer interface) +- Contributing guidelines +- License (MIT) + +- [ ] **Step 2: Commit** + +```bash +git add README.md +git commit -m "docs: comprehensive README with architecture, build, and plugin guide" +``` + +--- + +## Task 15: License Audit + Final Verification + +- [ ] **Step 1: Audit all Go dependencies** + +```bash +go list -m all +``` + +Verify each is MIT, BSD, Apache 2.0, or ISC. No GPL/AGPL. + +- [ ] **Step 2: Audit all npm dependencies** + +```bash +cd frontend && npx license-checker --summary && cd .. +``` + +- [ ] **Step 3: Run all Go tests** + +```bash +go test ./... -v +``` + +Expected: All pass (vault, db, connections, search, settings, theme, session) + +- [ ] **Step 4: Build production binary** + +```bash +cd frontend && npm run build && cd .. +go build -o bin/wraith.exe . +``` + +- [ ] **Step 5: Commit any fixes** + +```bash +git add -A +git commit -m "chore: Phase 1 complete — license audit passed, all tests green" +``` + +--- + +## Phase 1 Completion Checklist + +- [ ] Wails v3 scaffold compiles and runs +- [ ] SQLite with WAL mode + full schema +- [ ] Vault service: Argon2id + AES-256-GCM (7 passing tests) +- [ ] Connection + Group CRUD with JSON tags/options +- [ ] Search by name/hostname/tag with `json_each` +- [ ] Settings service (key-value with upsert) +- [ ] Theme service with 7 built-in themes +- [ ] Plugin interfaces defined (ProtocolHandler, Importer) +- [ ] Session manager (window-agnostic, detach/reattach, 32 session cap) +- [ ] Master password unlock UI +- [ ] Main layout: sidebar + tab bar + status bar +- [ ] Multi-window spike completed with documented results +- [ ] RDP frame transport spike completed with documented results +- [ ] README.md with architecture and plugin guide +- [ ] All dependencies MIT/BSD/Apache 2.0 compatible +- [ ] All Go tests passing diff --git a/docs/superpowers/plans/2026-03-17-wraith-phase2-ssh-sftp.md b/docs/superpowers/plans/2026-03-17-wraith-phase2-ssh-sftp.md new file mode 100644 index 0000000..49005d5 --- /dev/null +++ b/docs/superpowers/plans/2026-03-17-wraith-phase2-ssh-sftp.md @@ -0,0 +1,487 @@ +# Wraith Desktop — Phase 2: SSH + SFTP Implementation Plan + +> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Connect to remote hosts via SSH with a real terminal (xterm.js), SFTP sidebar with file operations and CWD following, multi-tab sessions, and CodeMirror 6 file editor. + +**Architecture:** Go SSH service wraps `x/crypto/ssh` for connections, PTY requests, and shell I/O. SFTP service wraps `pkg/sftp` riding the same SSH connection. Data flows: xterm.js ↔ Wails bindings ↔ Go SSH pipes. CWD tracking via OSC 7 shell injection. + +**Tech Stack:** `golang.org/x/crypto/ssh`, `github.com/pkg/sftp`, xterm.js 5.x + WebGL addon + fit addon + search addon, CodeMirror 6 + +**Spec:** `docs/superpowers/specs/2026-03-17-wraith-desktop-design.md` (Sections 6, 10, 11) + +--- + +## File Structure (New/Modified) + +``` +internal/ + ssh/ + service.go # SSH dial, PTY, shell, I/O goroutines + service_test.go # Connection config tests (unit, no real SSH) + hostkey.go # Host key verification + storage + hostkey_test.go + cwd.go # OSC 7 parser for CWD tracking + cwd_test.go + sftp/ + service.go # SFTP operations (list, upload, download, etc.) + service_test.go + credentials/ + service.go # Credential CRUD (encrypted passwords + SSH keys) + service_test.go + app/ + app.go # Add SSH/SFTP/Credential services +frontend/ + src/ + components/ + terminal/ + TerminalView.vue # xterm.js instance wrapper + sftp/ + FileTree.vue # Remote filesystem tree + TransferProgress.vue # Upload/download progress + editor/ + EditorWindow.vue # CodeMirror 6 (placeholder for multi-window) + composables/ + useTerminal.ts # xterm.js lifecycle + Wails binding bridge + useSftp.ts # SFTP operations via Wails bindings + stores/ + session.store.ts # Update with real session management + assets/ + css/ + terminal.css # xterm.js overrides + package.json # Add xterm.js, codemirror deps +``` + +--- + +## Task 1: SSH Service — Connect, PTY, Shell I/O + +**Files:** +- Create: `internal/ssh/service.go` +- Create: `internal/ssh/service_test.go` + +The SSH service manages connections and exposes methods to Wails: + +```go +// SSHService methods (exposed to frontend via Wails bindings): +// Connect(connectionID int64) (string, error) → returns sessionID +// Write(sessionID string, data string) error → write to stdin +// Resize(sessionID string, cols, rows int) error → window change +// Disconnect(sessionID string) error → close session +// +// Events emitted to frontend via Wails events: +// "ssh:data:{sessionID}" → terminal output (stdout) +// "ssh:connected:{sessionID}" → connection established +// "ssh:disconnected:{sessionID}" → connection closed +// "ssh:error:{sessionID}" → error message +``` + +Key implementation details: +- Each SSH session runs two goroutines: one reading stdout→Wails events, one for keepalive +- Sessions stored in a `map[string]*SSHSession` with mutex +- `SSHSession` holds: `*ssh.Client`, `*ssh.Session`, stdin `io.WriteCloser`, connection metadata +- PTY requested as `xterm-256color` with initial size from frontend +- Auth method determined by credential type (password, SSH key, keyboard-interactive) +- Host key verification delegates to `hostkey.go` + +Tests (unit only — no real SSH server): +- TestSSHServiceCreation +- TestBuildAuthMethods (password → ssh.Password, key → ssh.PublicKeys) +- TestSessionTracking (create, get, remove) + +- [ ] **Step 1:** Write tests +- [ ] **Step 2:** Implement service +- [ ] **Step 3:** Run tests, verify pass +- [ ] **Step 4:** Commit: `feat: SSH service — connect, PTY, shell I/O with goroutine pipes` + +--- + +## Task 2: Host Key Verification + +**Files:** +- Create: `internal/ssh/hostkey.go` +- Create: `internal/ssh/hostkey_test.go` + +Host key verification stores/checks fingerprints in the `host_keys` SQLite table: +- New host → emit `ssh:hostkey-verify` event to frontend with fingerprint, wait for accept/reject +- Known host, matching fingerprint → proceed silently +- Known host, CHANGED fingerprint → emit warning event, block connection + +For Phase 2, implement the storage and verification logic. The frontend prompt (accept/reject dialog) will be wired in the frontend task. + +Tests: +- TestStoreHostKey +- TestVerifyKnownHost (match → ok) +- TestVerifyChangedHost (mismatch → error) +- TestVerifyNewHost (not found → returns "new") + +- [ ] **Step 1:** Write tests +- [ ] **Step 2:** Implement hostkey.go +- [ ] **Step 3:** Run tests, verify pass +- [ ] **Step 4:** Commit: `feat: SSH host key verification — store, verify, detect changes` + +--- + +## Task 3: CWD Tracker (OSC 7 Parser) + +**Files:** +- Create: `internal/ssh/cwd.go` +- Create: `internal/ssh/cwd_test.go` + +Parses OSC 7 escape sequences from terminal output to track the remote working directory: + +``` +Input: "some output\033]7;file://hostname/home/user\033\\more output" +Output: stripped="some output more output", cwd="/home/user" +``` + +The CWD tracker: +1. Scans byte stream for `\033]7;` prefix +2. Extracts URL between prefix and `\033\\` (or `\007`) terminator +3. Parses `file://hostname/path` to extract just the path +4. Strips the OSC 7 sequence from the output before forwarding to xterm.js +5. Returns the new CWD path when detected + +Shell injection command (injected after PTY is established): +```bash +# bash +PROMPT_COMMAND='printf "\033]7;file://%s%s\033\\" "$(hostname)" "$PWD"' +# zsh +precmd() { printf "\033]7;file://%s%s\033\\" "$(hostname)" "$PWD" } +# fish +function fish_prompt; printf "\033]7;file://%s%s\033\\" (hostname) "$PWD"; end +``` + +Tests: +- TestParseOSC7Basic +- TestParseOSC7WithBEL (terminated by \007 instead of ST) +- TestParseOSC7NoMatch (no OSC 7 in output) +- TestParseOSC7MultipleInStream +- TestStripOSC7FromOutput + +- [ ] **Step 1:** Write tests +- [ ] **Step 2:** Implement cwd.go +- [ ] **Step 3:** Run tests, verify pass +- [ ] **Step 4:** Commit: `feat: OSC 7 CWD tracker — parse and strip directory change sequences` + +--- + +## Task 4: SFTP Service + +**Files:** +- Create: `internal/sftp/service.go` +- Create: `internal/sftp/service_test.go` + +SFTP service wraps `pkg/sftp` and exposes file operations to the frontend: + +```go +// SFTPService methods (exposed via Wails bindings): +// OpenSFTP(sessionID string) error → start SFTP on existing SSH connection +// List(sessionID string, path string) ([]FileEntry, error) → directory listing +// ReadFile(sessionID string, path string) (string, error) → read file content (max 5MB) +// WriteFile(sessionID string, path string, content string) error → write file +// Upload(sessionID string, remotePath string, localPath string) error +// Download(sessionID string, remotePath string) (string, error) → returns local temp path +// Mkdir(sessionID string, path string) error +// Delete(sessionID string, path string) error +// Rename(sessionID string, oldPath, newPath string) error +// Stat(sessionID string, path string) (*FileEntry, error) +``` + +`FileEntry` type: +```go +type FileEntry struct { + Name string `json:"name"` + Path string `json:"path"` + Size int64 `json:"size"` + IsDir bool `json:"isDir"` + Permissions string `json:"permissions"` + ModTime string `json:"modTime"` + Owner string `json:"owner"` +} +``` + +SFTP client is created from the existing `*ssh.Client` (same connection, separate channel). Stored alongside the SSH session. + +Tests (unit — mock the sftp.Client interface): +- TestFileEntryFromFileInfo +- TestListSortsDirectoriesFirst +- TestReadFileRejectsLargeFiles (>5MB) + +- [ ] **Step 1:** Write tests +- [ ] **Step 2:** Implement service +- [ ] **Step 3:** Add `pkg/sftp` dependency +- [ ] **Step 4:** Run tests, verify pass +- [ ] **Step 5:** Commit: `feat: SFTP service — list, read, write, upload, download, mkdir, delete` + +--- + +## Task 5: Credential Service (Encrypted SSH Keys + Passwords) + +**Files:** +- Create: `internal/credentials/service.go` +- Create: `internal/credentials/service_test.go` + +CRUD for credentials and SSH keys with vault encryption: + +```go +// CredentialService methods: +// CreatePassword(name, username, password, domain string) (*Credential, error) +// CreateSSHKey(name string, privateKey, passphrase []byte) (*SSHKey, error) +// GetCredential(id int64) (*Credential, error) +// ListCredentials() ([]Credential, error) +// DecryptPassword(id int64) (string, error) → decrypt for connection use only +// DecryptSSHKey(id int64) ([]byte, string, error) → returns (privateKey, passphrase, error) +// DeleteCredential(id int64) error +// ImportSSHKeyFile(name, filePath string) (*SSHKey, error) → read .pem file, detect type, store +``` + +All sensitive data encrypted via VaultService before storage. Decryption only happens at connection time. + +Tests: +- TestCreatePasswordCredential +- TestCreateSSHKeyCredential +- TestDecryptPassword (round-trip through vault) +- TestDecryptSSHKey (round-trip) +- TestListCredentialsExcludesEncryptedValues +- TestDetectKeyType (RSA, Ed25519, ECDSA) + +- [ ] **Step 1:** Write tests +- [ ] **Step 2:** Implement service +- [ ] **Step 3:** Run tests, verify pass +- [ ] **Step 4:** Commit: `feat: credential service — encrypted password and SSH key storage` + +--- + +## Task 6: Wire SSH/SFTP/Credentials into App + +**Files:** +- Modify: `internal/app/app.go` +- Modify: `main.go` + +Add SSHService, SFTPService, and CredentialService to WraithApp. Register as Wails services. + +- [ ] **Step 1:** Update app.go to create and expose new services +- [ ] **Step 2:** Update main.go to register them +- [ ] **Step 3:** Verify compilation: `go vet ./...` +- [ ] **Step 4:** Run all tests: `go test ./... -count=1` +- [ ] **Step 5:** Commit: `feat: wire SSH, SFTP, and credential services into Wails app` + +--- + +## Task 7: Frontend — xterm.js Terminal + +**Files:** +- Modify: `frontend/package.json` — add xterm.js + addons +- Create: `frontend/src/components/terminal/TerminalView.vue` +- Create: `frontend/src/composables/useTerminal.ts` +- Create: `frontend/src/assets/css/terminal.css` +- Modify: `frontend/src/components/session/SessionContainer.vue` +- Modify: `frontend/src/stores/session.store.ts` + +Install xterm.js dependencies: +``` +@xterm/xterm +@xterm/addon-fit +@xterm/addon-webgl +@xterm/addon-search +@xterm/addon-web-links +``` + +`useTerminal` composable: +- Creates xterm.js Terminal instance with theme from connection settings +- Attaches fit, WebGL, search, web-links addons +- Binds `terminal.onData` → Wails `SSHService.Write(sessionId, data)` +- Listens for Wails events `ssh:data:{sessionId}` → `terminal.write(data)` +- Handles resize via fit addon → Wails `SSHService.Resize(sessionId, cols, rows)` +- Cleanup on unmount + +`TerminalView.vue`: +- Receives `sessionId` prop +- Mounts xterm.js into a div ref +- Applies theme colors from the active theme +- Handles focus management + +`SessionContainer.vue` update: +- Replace placeholder with real TerminalView for SSH sessions +- Use `v-show` (not `v-if`) to keep terminals alive across tab switches + +- [ ] **Step 1:** Install xterm.js deps: `cd frontend && npm install @xterm/xterm @xterm/addon-fit @xterm/addon-webgl @xterm/addon-search @xterm/addon-web-links` +- [ ] **Step 2:** Create terminal.css (xterm.js container styling) +- [ ] **Step 3:** Create useTerminal.ts composable +- [ ] **Step 4:** Create TerminalView.vue component +- [ ] **Step 5:** Update SessionContainer.vue to render TerminalView +- [ ] **Step 6:** Update session.store.ts with real Wails binding calls +- [ ] **Step 7:** Build frontend: `npm run build` +- [ ] **Step 8:** Commit: `feat: xterm.js terminal with WebGL rendering and Wails binding bridge` + +--- + +## Task 8: Frontend — SFTP Sidebar + +**Files:** +- Create: `frontend/src/components/sftp/FileTree.vue` +- Create: `frontend/src/components/sftp/TransferProgress.vue` +- Create: `frontend/src/composables/useSftp.ts` +- Modify: `frontend/src/layouts/MainLayout.vue` — SFTP sidebar rendering +- Modify: `frontend/src/components/sidebar/SidebarToggle.vue` — enable SFTP tab + +`useSftp` composable: +- `listDirectory(sessionId, path)` → calls Wails SFTPService.List +- `uploadFile(sessionId, remotePath, file)` → chunked upload with progress +- `downloadFile(sessionId, remotePath)` → triggers browser download +- `deleteFile(sessionId, path)` → with confirmation +- `createDirectory(sessionId, path)` +- `renameFile(sessionId, old, new)` +- Tracks current path, file list, loading state, transfer progress + +`FileTree.vue`: +- Renders file/directory tree (lazy-loaded on expand) +- Path bar at top showing current directory +- Toolbar: upload, download, new file, new folder, refresh, delete +- File entries show: icon (folder/file), name, size, modified date +- Double-click file → open in editor (Task 9) +- Drag-and-drop upload zone +- "Follow terminal folder" toggle at bottom + +`TransferProgress.vue`: +- Shows active uploads/downloads with progress bars +- File name, percentage, speed, ETA + +- [ ] **Step 1:** Create useSftp.ts composable +- [ ] **Step 2:** Create FileTree.vue component +- [ ] **Step 3:** Create TransferProgress.vue component +- [ ] **Step 4:** Update MainLayout.vue to render SFTP sidebar when toggled +- [ ] **Step 5:** Enable SFTP toggle in SidebarToggle.vue +- [ ] **Step 6:** Build frontend: `npm run build` +- [ ] **Step 7:** Commit: `feat: SFTP sidebar — file tree, upload/download, CWD following` + +--- + +## Task 9: Frontend — Host Key Dialog + Connection Flow + +**Files:** +- Create: `frontend/src/components/common/HostKeyDialog.vue` +- Modify: `frontend/src/components/sidebar/ConnectionTree.vue` — double-click to connect +- Modify: `frontend/src/stores/session.store.ts` — real connection flow + +Wire up the full connection flow: +1. User double-clicks connection in sidebar +2. Session store calls Wails `SSHService.Connect(connectionId)` +3. If host key verification needed → show HostKeyDialog +4. On success → create tab, mount TerminalView, open SFTP sidebar +5. On error → show error toast + +`HostKeyDialog.vue`: +- Modal showing: hostname, key type, fingerprint +- "New host" vs "CHANGED host key (WARNING)" modes +- Accept / Reject buttons +- "Always accept for this host" checkbox + +- [ ] **Step 1:** Create HostKeyDialog.vue +- [ ] **Step 2:** Update ConnectionTree.vue with double-click handler +- [ ] **Step 3:** Update session.store.ts with connection flow +- [ ] **Step 4:** Build frontend: `npm run build` +- [ ] **Step 5:** Commit: `feat: connection flow — host key dialog, double-click to connect` + +--- + +## Task 10: Frontend — CodeMirror 6 Editor (Placeholder) + +**Files:** +- Modify: `frontend/package.json` — add CodeMirror deps +- Create: `frontend/src/components/editor/EditorWindow.vue` + +Install CodeMirror 6: +``` +codemirror +@codemirror/lang-javascript +@codemirror/lang-json +@codemirror/lang-html +@codemirror/lang-css +@codemirror/lang-python +@codemirror/lang-markdown +@codemirror/theme-one-dark +``` + +`EditorWindow.vue`: +- Renders CodeMirror 6 editor with dark theme +- Receives file content, path, and sessionId as props +- Syntax highlighting based on file extension +- Save button → calls Wails SFTPService.WriteFile +- Unsaved changes detection +- For Phase 2: renders inline (not separate window — multi-window is Phase 4) + +- [ ] **Step 1:** Install CodeMirror deps +- [ ] **Step 2:** Create EditorWindow.vue +- [ ] **Step 3:** Wire file click in FileTree.vue to open EditorWindow +- [ ] **Step 4:** Build frontend: `npm run build` +- [ ] **Step 5:** Commit: `feat: CodeMirror 6 editor — syntax highlighting, dark theme, SFTP save` + +--- + +## Task 11: Workspace Snapshot Persistence + +**Files:** +- Create: `internal/app/workspace.go` +- Create: `internal/app/workspace_test.go` + +Implements workspace snapshot saving/restoring per the spec: + +```go +// SaveWorkspace() error — serialize current tab layout to settings +// LoadWorkspace() (*WorkspaceSnapshot, error) — read last saved layout +// Auto-save every 30 seconds via goroutine +// Save on clean shutdown +``` + +WorkspaceSnapshot JSON: +```json +{ + "tabs": [ + {"connectionId": 1, "protocol": "ssh", "position": 0}, + {"connectionId": 5, "protocol": "rdp", "position": 1} + ], + "sidebarWidth": 240, + "sidebarMode": "connections", + "activeTab": 0 +} +``` + +Tests: +- TestSaveAndLoadWorkspace +- TestEmptyWorkspace + +- [ ] **Step 1:** Write tests +- [ ] **Step 2:** Implement workspace.go +- [ ] **Step 3:** Run tests, verify pass +- [ ] **Step 4:** Commit: `feat: workspace snapshot persistence — auto-save layout every 30s` + +--- + +## Task 12: Integration Test + Final Verification + +- [ ] **Step 1:** Run all Go tests: `go test ./... -count=1` +- [ ] **Step 2:** Build frontend: `cd frontend && npm run build` +- [ ] **Step 3:** Verify Go compiles with embedded frontend: `go vet ./...` +- [ ] **Step 4:** Count tests and lines of code +- [ ] **Step 5:** Commit any fixes: `chore: Phase 2 complete — SSH + SFTP with terminal and file operations` + +--- + +## Phase 2 Completion Checklist + +- [ ] SSH service: connect, PTY, shell I/O with goroutine pipes +- [ ] Host key verification: store, verify, detect changes +- [ ] OSC 7 CWD tracker: parse and strip directory change sequences +- [ ] SFTP service: list, read, write, upload, download, mkdir, delete +- [ ] Credential service: encrypted password + SSH key storage +- [ ] All new services wired into Wails app +- [ ] xterm.js terminal with WebGL rendering +- [ ] SFTP file tree sidebar with upload/download +- [ ] Host key verification dialog +- [ ] Double-click connection to connect flow +- [ ] CodeMirror 6 inline editor with SFTP save +- [ ] Workspace snapshot persistence +- [ ] All Go tests passing +- [ ] Frontend builds clean diff --git a/docs/superpowers/specs/2026-03-12-vigilance-remote-lean-design.md b/docs/superpowers/specs/2026-03-12-vigilance-remote-lean-design.md new file mode 100644 index 0000000..863075b --- /dev/null +++ b/docs/superpowers/specs/2026-03-12-vigilance-remote-lean-design.md @@ -0,0 +1,614 @@ +# Wraith — Lean Build Spec + +> **Date:** 2026-03-12 +> **Purpose:** Self-hosted MobaXterm replacement — SSH + SFTP + RDP in a browser +> **Stack:** Nuxt 3 (Vue 3 SPA) + NestJS 10 + PostgreSQL 16 + guacd +> **Target:** Single-user personal tool with bolt-on multi-user path +> **Reference:** `Remote-Spec.md` (full feature spec — this is the lean cut) + +--- + +## 1. What This Is + +A self-hosted web application that replaces MobaXterm. SSH terminal with SFTP sidebar (MobaXterm's killer feature), RDP via Guacamole, connection manager with hierarchical groups, and an encrypted vault for SSH keys and passwords. Runs in any browser, deployed as a Docker stack. + +**What this is NOT:** An MSP product, a SaaS platform, a team collaboration tool. It's a personal remote access workstation that happens to be web-based. Multi-user is a future bolt-on, not a design constraint. + +**Name:** Wraith — exists everywhere, all at once. + +--- + +## 2. Five Modules + +### 2.1 SSH Terminal + +**Frontend:** xterm.js 5.x with addons: +- `@xterm/addon-fit` — auto-resize to container +- `@xterm/addon-search` — Ctrl+F scrollback search +- `@xterm/addon-web-links` — clickable URLs +- `@xterm/addon-webgl` — GPU-accelerated rendering + +**Backend:** NestJS WebSocket gateway + `ssh2` (npm). Browser opens WebSocket to NestJS, NestJS opens SSH connection to target using credentials from the vault. Bidirectional data pipe: terminal input → ssh2 stdin, ssh2 stdout → terminal output. + +**Features:** +- Multi-tab sessions with host name labels and color-coding by group +- Horizontal and vertical split panes within a single tab (multiple xterm.js instances in a flex grid) +- Terminal theming: dark/light modes, custom color schemes, font selection, font size +- Configurable scrollback buffer size (default 10,000 lines, configurable in settings) +- Copy/paste: Ctrl+Shift+C/V, right-click context menu +- Search in scrollback: Ctrl+F via xterm.js SearchAddon +- Auto-reconnect on connection drop with configurable retry + +**Authentication flow:** +1. User clicks host in connection manager +2. Backend looks up host → finds associated credential (key or password) +3. If SSH key: decrypt private key from vault, optionally decrypt passphrase, pass to ssh2 +4. If password: decrypt from vault, pass to ssh2 +5. ssh2 performs host key verification (see Section 8: Host Key Verification) +6. ssh2 connects, WebSocket bridge established + +### 2.2 SFTP Sidebar + +The MobaXterm feature. When an SSH session connects, a sidebar automatically opens showing the remote filesystem. + +**Layout:** Resizable left sidebar panel (tree view) + main terminal panel. Sidebar can be collapsed/hidden per session. + +**Backend:** Uses the same ssh2 connection as the terminal (ssh2's SFTP subsystem). No separate connection needed — SFTP rides the existing SSH channel. All SFTP commands include a `sessionId` to target the correct ssh2 connection when multiple tabs are open. + +**File operations:** +- Browse remote filesystem as a tree (lazy-loaded — fetch children on expand) +- Upload: drag-and-drop from desktop onto sidebar, or click upload button. Chunked transfer with progress bar. +- Download: click file → browser download, or right-click → Download +- Rename, delete, chmod, mkdir via right-click context menu +- File size, permissions, modified date shown in tree or detail view + +**File editing:** +- Click a text file → opens in embedded Monaco Editor (VS Code's editor component) +- File size guard: files over 5MB are refused for inline editing (download instead) +- Syntax highlighting based on file extension +- Save button pushes content back to remote via SFTP +- Unsaved changes warning on close + +**Transfer status:** Bottom status bar showing active transfers with progress, speed, ETA. Queue-based — multiple uploads/downloads run sequentially with status indicators. + +### 2.3 RDP (Remote Desktop) + +**Architecture:** Browser → WebSocket → NestJS Guacamole tunnel → guacd (Docker) → RDP target + +**Frontend:** `guacamole-common-js` — renders remote desktop on HTML5 Canvas. Keyboard, mouse, and touch input forwarded to remote. + +**Backend:** NestJS WebSocket gateway that speaks Guacamole wire protocol to the `guacd` daemon over TCP. The gateway translates between the browser's WebSocket and guacd's TCP socket. + +**guacd:** Apache Guacamole daemon running as `guacamole/guacd` Docker image. Handles the actual RDP protocol translation. Battle-tested, Apache-licensed. + +**Features:** +- Clipboard sync: bidirectional between browser and remote desktop +- Auto-resolution: detect browser window/tab size, send to RDP server +- Connection settings: color depth (16/24/32-bit), security mode (NLA/TLS/RDP), console session, admin mode +- Audio: remote audio playback in browser (Guacamole native) +- Full-screen mode: F11 or toolbar button + +**Authentication:** RDP credentials (username + password + domain) stored encrypted in vault, associated with host. Decrypted at connect time and passed to guacd. + +### 2.4 Connection Manager + +The home screen. A searchable, organized view of all saved hosts. + +**Host properties:** +``` +name — display name (e.g., "RSM File Server") +hostname — IP or FQDN +port — default 22 (SSH) or 3389 (RDP) +protocol — ssh | rdp +group_id — FK to host_groups (nullable for ungrouped) +credential_id — FK to credentials (nullable for quick-connect-style) +tags — text[] array for categorization +notes — free text (markdown rendered) +color — hex color for visual grouping +lastConnectedAt — timestamp of most recent connection +``` + +**Host groups:** Hierarchical folders with `parent_id` self-reference. E.g., "RSM > Servers", "Home Lab > VMs". Collapsible tree in the sidebar. + +**Quick connect:** Top bar input — type `user@hostname:port` and hit Enter to connect without saving. Protocol auto-detected (or toggle SSH/RDP). + +**Search:** Full-text across host name, hostname, tags, notes, group name. Instant filter as you type. + +**Recent connections:** Hosts sorted by `lastConnectedAt` shown as a quick-access section above the full host tree. + +**UI pattern:** Left sidebar = group tree + host list. Main area = active sessions rendered as persistent tab components within the layout (NOT separate routes — terminal/RDP instances persist across tab switches). Double-click host or press Enter to connect. Drag hosts between groups. + +### 2.5 Key Vault + +Encrypted storage for SSH private keys and passwords. + +**SSH keys:** +``` +name — display name (e.g., "RSM Production Key") +public_key — plaintext (safe to store) +encrypted_private_key — AES-256-GCM encrypted blob +passphrase_encrypted — AES-256-GCM encrypted (nullable — not all keys have passphrases) +fingerprint — SHA-256 fingerprint for display +key_type — rsa | ed25519 | ecdsa (detected on import) +``` + +**Import flow:** +1. Click "Import Key" in vault management +2. Paste key content or upload `.pem`/`.pub`/id_rsa file +3. If key has passphrase, prompt for it (stored encrypted) +4. Key encrypted with AES-256-GCM using `ENCRYPTION_KEY` env var +5. Public key extracted and stored separately (for display/export) + +**Credentials (passwords and key references):** +``` +name — display name (e.g., "RSM root cred") +username — plaintext username (not sensitive) +domain — for RDP (e.g., "CONTOSO") +type — password | ssh_key (enum CredentialType) +encrypted_value — AES-256-GCM encrypted password (for type=password) +ssh_key_id — FK to ssh_keys (for type=ssh_key) +``` + +Credentials are shared entities — hosts reference credentials via `credential_id` FK on the host. Multiple hosts can share the same credential. The relationship is Host → Credential (many-to-one), not Credential → Host. + +**Encryption pattern:** Same as Vigilance HQ — `ENCRYPTION_KEY` env var (32+ byte hex), AES-256-GCM, random IV per encryption, `v1:` version prefix on ciphertext for future key rotation. + +--- + +## 3. Technology Stack + +### Frontend + +| Component | Technology | Purpose | +|---|---|---| +| Framework | Nuxt 3 (Vue 3, SPA mode `ssr: false`) | App shell, routing, auto-imports | +| Terminal | xterm.js 5.x + addons | SSH terminal emulator | +| RDP client | guacamole-common-js | RDP canvas rendering | +| Code editor | Monaco Editor | SFTP file editing | +| UI library | PrimeVue 4 | DataTable, Dialog, Tree, Toolbar, etc. | +| State | Pinia | Connection state, session management | +| CSS | Tailwind CSS | Utility-first styling | +| Icons | Lucide Vue | Consistent iconography | + +> **Why SPA, not SSR:** xterm.js, Monaco, and guacamole-common-js are all browser-only. Every session page would need `` wrappers. No SEO benefit for a self-hosted tool behind auth. SPA mode avoids hydration mismatches entirely while keeping Nuxt's routing, auto-imports, and module ecosystem. + +### Backend + +| Component | Technology | Purpose | +|---|---|---| +| Framework | NestJS 10 | REST API + WebSocket gateways | +| SSH proxy | ssh2 (npm) | SSH + SFTP connections | +| RDP proxy | Custom Guacamole tunnel | NestJS ↔ guacd TCP bridge | +| Database | PostgreSQL 16 | Hosts, credentials, keys, settings | +| ORM | Prisma | Schema-as-code, type-safe queries | +| Encryption | Node.js crypto (AES-256-GCM) | Vault encryption at rest | +| Auth | JWT + bcrypt | Single-user local login | +| WebSocket | @nestjs/websockets (ws) | Terminal and RDP data channels | + +### Infrastructure (Docker Compose) + +```yaml +services: + app: + build: . + ports: ["3000:3000"] + environment: + DATABASE_URL: postgresql://wraith:${DB_PASSWORD}@postgres:5432/wraith + JWT_SECRET: ${JWT_SECRET} + ENCRYPTION_KEY: ${ENCRYPTION_KEY} + GUACD_HOST: guacd + GUACD_PORT: "4822" + depends_on: [postgres, guacd] + + guacd: + image: guacamole/guacd + restart: always + # Internal only — app connects via Docker DNS hostname "guacd" on port 4822 + + postgres: + image: postgres:16-alpine + volumes: [pgdata:/var/lib/postgresql/data] + environment: + POSTGRES_DB: wraith + POSTGRES_USER: wraith + POSTGRES_PASSWORD: ${DB_PASSWORD} + +volumes: + pgdata: +``` + +> **No Redis:** JWT auth is stateless. Single NestJS process means no pub/sub fanout needed. If horizontal scaling becomes relevant later, Redis is a straightforward add. Not burning ops complexity on it now. + +**Required `.env` vars:** +``` +DB_PASSWORD= +JWT_SECRET= +ENCRYPTION_KEY= +``` + +Production deployment: Nginx reverse proxy on the Docker host with SSL termination and WebSocket upgrade support (`proxy_set_header Upgrade $http_upgrade`). + +--- + +## 4. Architecture + +``` +┌─────────────────────────────────────────────────────────────┐ +│ Browser (Any device) │ +│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │ +│ │ xterm.js │ │ SFTP Sidebar │ │ guac-client │ │ +│ │ (SSH term) │ │ (file tree) │ │ (RDP canvas) │ │ +│ └──────┬───────┘ └──────┬───────┘ └──────┬───────┘ │ +│ │ WebSocket │ WebSocket │ WebSocket │ +└─────────┼──────────────────┼─────────────────┼──────────────┘ + │ │ │ +┌─────────┼──────────────────┼─────────────────┼──────────────┐ +│ NestJS Backend (Docker: app) │ +│ ┌──────▼───────┐ ┌──────▼───────┐ ┌──────▼───────┐ │ +│ │ SSH Gateway │ │ SFTP Gateway │ │ Guac Tunnel │ │ +│ │ (ssh2) │ │ (ssh2 sftp) │ │ (TCP→guacd) │ │ +│ └──────┬───────┘ └──────┬───────┘ └──────┬───────┘ │ +│ │ SSH │ SFTP │ Guac Protocol │ +│ ┌──────▼────────────────────────┐ ┌──────▼───────┐ │ +│ │ Vault Service │ │ guacd │ │ +│ │ (decrypt keys/passwords) │ │ (Docker) │ │ +│ └──────┬────────────────────────┘ └──────┬───────┘ │ +│ │ Prisma │ RDP │ +│ ┌──────▼───────┐ │ │ +│ │ PostgreSQL │ │ │ +│ │ (Docker) │ │ │ +│ └──────────────┘ │ │ +└──────────────────────────────────────────────┼──────────────┘ + │ + ┌─────────────────┐ ┌──────▼───────┐ + │ SSH Targets │ │ RDP Targets │ + │ (Linux/Unix) │ │ (Windows) │ + └─────────────────┘ └──────────────┘ +``` + +--- + +## 5. Database Schema (Prisma) + +```prisma +model User { + id Int @id @default(autoincrement()) + email String @unique + passwordHash String @map("password_hash") + displayName String? @map("display_name") + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + + @@map("users") +} + +model HostGroup { + id Int @id @default(autoincrement()) + name String + parentId Int? @map("parent_id") + sortOrder Int @default(0) @map("sort_order") + parent HostGroup? @relation("GroupTree", fields: [parentId], references: [id], onDelete: SetNull) + children HostGroup[] @relation("GroupTree") + hosts Host[] + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + + @@map("host_groups") +} + +model Host { + id Int @id @default(autoincrement()) + name String + hostname String + port Int @default(22) + protocol Protocol @default(ssh) + groupId Int? @map("group_id") + credentialId Int? @map("credential_id") + tags String[] @default([]) + notes String? + color String? @db.VarChar(7) + sortOrder Int @default(0) @map("sort_order") + hostFingerprint String? @map("host_fingerprint") + lastConnectedAt DateTime? @map("last_connected_at") + group HostGroup? @relation(fields: [groupId], references: [id], onDelete: SetNull) + credential Credential? @relation(fields: [credentialId], references: [id], onDelete: SetNull) + connectionLogs ConnectionLog[] + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + + @@map("hosts") +} + +model Credential { + id Int @id @default(autoincrement()) + name String + username String? + domain String? + type CredentialType + encryptedValue String? @map("encrypted_value") + sshKeyId Int? @map("ssh_key_id") + sshKey SshKey? @relation(fields: [sshKeyId], references: [id], onDelete: SetNull) + hosts Host[] + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + + @@map("credentials") +} + +model SshKey { + id Int @id @default(autoincrement()) + name String + keyType String @map("key_type") @db.VarChar(20) + fingerprint String? + publicKey String? @map("public_key") + encryptedPrivateKey String @map("encrypted_private_key") + passphraseEncrypted String? @map("passphrase_encrypted") + credentials Credential[] + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + + @@map("ssh_keys") +} + +model ConnectionLog { + id Int @id @default(autoincrement()) + hostId Int @map("host_id") + protocol Protocol + connectedAt DateTime @default(now()) @map("connected_at") + disconnectedAt DateTime? @map("disconnected_at") + host Host @relation(fields: [hostId], references: [id], onDelete: Cascade) + + @@map("connection_logs") +} + +model Setting { + key String @id + value String + + @@map("settings") +} + +enum Protocol { + ssh + rdp +} + +enum CredentialType { + password + ssh_key +} +``` + +--- + +## 6. Frontend Structure + +``` +frontend/ + nuxt.config.ts # ssr: false (SPA mode) + layouts/ + default.vue # Main layout: sidebar + persistent tab container + auth.vue # Login page layout + pages/ + index.vue # Connection manager (home screen) + active session tabs + login.vue # Single-user login + vault/ + index.vue # Key vault management + keys.vue # SSH key list + import + credentials.vue # Password credentials + settings.vue # App settings (theme, terminal defaults, scrollback) + components/ + connections/ + HostTree.vue # Sidebar host group tree + HostCard.vue # Host entry in list + HostEditDialog.vue # Add/edit host modal + GroupEditDialog.vue # Add/edit group modal + QuickConnect.vue # Top bar quick connect input + session/ + SessionContainer.vue # Persistent container — holds all active sessions, manages tab switching + SessionTab.vue # Single session (SSH terminal + SFTP sidebar, or RDP canvas) + terminal/ + TerminalInstance.vue # Single xterm.js instance + TerminalTabs.vue # Tab bar for multiple sessions + SplitPane.vue # Split pane container + sftp/ + SftpSidebar.vue # SFTP file tree sidebar + FileTree.vue # Remote filesystem tree + FileEditor.vue # Monaco editor for text files + TransferStatus.vue # Upload/download progress + rdp/ + RdpCanvas.vue # Guacamole client wrapper + RdpToolbar.vue # Clipboard, fullscreen, settings + vault/ + KeyImportDialog.vue # SSH key import modal + CredentialForm.vue # Password credential form + composables/ + useTerminal.ts # xterm.js lifecycle + WebSocket + useSftp.ts # SFTP operations via WebSocket + useRdp.ts # Guacamole client lifecycle + useVault.ts # Key/credential CRUD + useConnections.ts # Host CRUD + search + stores/ + auth.store.ts # Login state, JWT (stored in memory/localStorage, sent via Authorization header) + session.store.ts # Active sessions, tabs — sessions persist across tab switches + connection.store.ts # Hosts, groups, search +``` + +> **Session architecture:** Active sessions are NOT page routes. They render as persistent tab components inside `SessionContainer.vue` within the main `index.vue` layout. Switching tabs toggles `v-show` visibility (not `v-if` destruction), so xterm.js and guacamole-common-js instances stay alive. The vault and settings pages are separate routes — navigating away from the main page does NOT destroy active sessions (the SessionContainer lives in the `default.vue` layout). + +--- + +## 7. Backend Structure + +``` +backend/src/ + main.ts # Bootstrap, global prefix, validation pipe + app.module.ts # Root module + prisma/ + prisma.service.ts # Prisma client lifecycle + prisma.module.ts # Global Prisma module + auth/ + auth.module.ts + auth.service.ts # Login, JWT issue/verify + auth.controller.ts # POST /login, GET /profile + jwt.strategy.ts # Passport JWT strategy + jwt-auth.guard.ts # Route guard (REST) + ws-auth.guard.ts # WebSocket auth guard (validates JWT from handshake) + connections/ + connections.module.ts + hosts.service.ts # Host CRUD + lastConnectedAt updates + hosts.controller.ts # REST: /hosts + groups.service.ts # Group CRUD (hierarchical) + groups.controller.ts # REST: /groups + vault/ + vault.module.ts + encryption.service.ts # AES-256-GCM encrypt/decrypt + credentials.service.ts # Credential CRUD + decrypt-on-demand + credentials.controller.ts # REST: /credentials + ssh-keys.service.ts # SSH key import/CRUD + ssh-keys.controller.ts # REST: /ssh-keys + terminal/ + terminal.module.ts + terminal.gateway.ts # WebSocket gateway: SSH proxy via ssh2 + sftp.gateway.ts # WebSocket gateway: SFTP operations + ssh-connection.service.ts # ssh2 connection management + pooling + rdp/ + rdp.module.ts + rdp.gateway.ts # WebSocket gateway: Guacamole tunnel + guacamole.service.ts # TCP connection to guacd, protocol translation + settings/ + settings.module.ts + settings.service.ts # Key/value settings CRUD + settings.controller.ts # REST: /settings +``` + +--- + +## 8. Key Implementation Details + +### WebSocket Authentication + +All WebSocket gateways validate JWT before processing any commands. The token is sent in the WebSocket handshake: + +```typescript +// Client: connect with JWT +const ws = new WebSocket(`wss://host/terminal?token=${jwt}`) + +// Server: ws-auth.guard.ts validates in handleConnection +// Rejects connection if token is invalid/expired +``` + +JWT is stored in Pinia state (memory) and localStorage for persistence. Sent via `Authorization: Bearer` header for REST, query parameter for WebSocket handshake. No cookies used for auth — CSRF protection not required. + +### WebSocket Protocol (SSH) + +``` +Client → Server: + { type: 'connect', hostId: 123 } # Initiate SSH connection + { type: 'data', data: '...' } # Terminal input (keystrokes) + { type: 'resize', cols: 120, rows: 40 } # Terminal resize + +Server → Client: + { type: 'connected', sessionId: 'uuid' } # SSH connection established + { type: 'data', data: '...' } # Terminal output + { type: 'host-key-verify', fingerprint: 'SHA256:...', isNew: true } # First connection — needs approval + { type: 'error', message: '...' } # Connection error + { type: 'disconnected', reason: '...' } # Connection closed + +Client → Server (host key response): + { type: 'host-key-accept' } # User approved — save fingerprint to host record + { type: 'host-key-reject' } # User rejected — abort connection +``` + +### WebSocket Protocol (SFTP) + +All SFTP commands include `sessionId` to target the correct ssh2 connection: + +``` +Client → Server: + { type: 'list', sessionId: 'uuid', path: '/home/user' } # List directory + { type: 'read', sessionId: 'uuid', path: '/etc/nginx/nginx.conf' } # Read file (max 5MB) + { type: 'write', sessionId: 'uuid', path: '/etc/nginx/nginx.conf', data } # Write file + { type: 'upload', sessionId: 'uuid', path: '/tmp/file.tar.gz', chunk } # Upload chunk + { type: 'download', sessionId: 'uuid', path: '/var/log/syslog' } # Start download + { type: 'mkdir', sessionId: 'uuid', path: '/home/user/newdir' } # Create directory + { type: 'rename', sessionId: 'uuid', oldPath, newPath } # Rename/move + { type: 'delete', sessionId: 'uuid', path: '/tmp/junk.log' } # Delete file + { type: 'chmod', sessionId: 'uuid', path, mode: '755' } # Change permissions + { type: 'stat', sessionId: 'uuid', path: '/home/user' } # Get file info + +Server → Client: + { type: 'list', path, entries: [...] } # Directory listing + { type: 'fileContent', path, content, encoding } # File content + { type: 'progress', transferId, bytes, total } # Transfer progress + { type: 'error', message } # Operation error +``` + +### Host Key Verification + +SSH host key verification follows standard `known_hosts` behavior: + +1. **First connection:** ssh2 receives server's public key fingerprint. Gateway sends `host-key-verify` message to browser with `isNew: true`. User sees a dialog showing the fingerprint and chooses to accept or reject. +2. **Accept:** Fingerprint saved to `Host.hostFingerprint` in database. Connection proceeds. +3. **Subsequent connections:** ssh2 receives fingerprint, compared against stored `Host.hostFingerprint`. If match, connect silently. If mismatch, gateway sends `host-key-verify` with `isNew: false` and `previousFingerprint` — user warned of possible MITM. +4. **Reject:** Connection aborted, no fingerprint stored. + +### Guacamole Tunnel (RDP) + +NestJS acts as a tunnel between the browser's WebSocket and guacd's TCP socket: + +1. Browser sends `{ type: 'connect', hostId: 456 }` +2. NestJS looks up host → decrypts RDP credentials +3. NestJS opens TCP socket to guacd at `${GUACD_HOST}:${GUACD_PORT}` (default: `guacd:4822`) +4. NestJS sends Guacamole handshake: `select`, `size`, `audio`, `video`, `image` instructions +5. NestJS sends `connect` instruction with RDP params (hostname, port, username, password, security, color-depth) +6. Bidirectional pipe: browser WebSocket ↔ NestJS ↔ guacd TCP +7. guacd handles actual RDP protocol to target Windows machine + +The `guacamole-common-js` client library handles rendering the Guacamole instruction stream to Canvas in the browser. + +### Encryption Service + +Identical pattern to Vigilance HQ: + +```typescript +encrypt(plaintext: string): string + → random 16-byte IV + → AES-256-GCM cipher with ENCRYPTION_KEY + → return `v1:${iv.hex}:${authTag.hex}:${ciphertext.hex}` + +decrypt(encrypted: string): string + → parse version prefix, IV, authTag, ciphertext + → AES-256-GCM decipher + → return plaintext +``` + +`ENCRYPTION_KEY` is a 32-byte hex string from environment. `v1:` prefix allows future key rotation without re-encrypting all stored values. + +--- + +## 9. Multi-User Bolt-On Path + +When the time comes to add JT or Victor: + +1. Add rows to `users` table +2. Add `userId` FK to `hosts`, `host_groups`, `credentials`, and `ssh_keys` tables (nullable — null = shared with all users) +3. Add `shared_with` field or a `host_permissions` join table +4. Add basic role: `admin` | `user` on `users` table +5. Filter host list by ownership/sharing in queries +6. Optional: Entra ID SSO (same pattern as HQ and RSM) + +**Zero architectural changes.** The connection manager, vault, terminal, SFTP, and RDP modules don't change. You just add a filter layer on who can see what. + +--- + +## 10. Build Phases + +| Phase | Deliverables | +|---|---| +| **1: Foundation** | Docker Compose, NestJS scaffold, Prisma schema, encryption service, Nuxt 3 SPA shell, auth (single-user login), connection manager CRUD, host groups | +| **2: SSH + SFTP** | xterm.js terminal, ssh2 WebSocket proxy, host key verification, multi-tab, split panes, SFTP sidebar with file tree, upload/download, Monaco editor | +| **3: RDP** | guacd integration, Guacamole tunnel, RDP canvas rendering, clipboard sync, connection settings | +| **4: Polish** | SSH key import UI, vault management page, theming, quick connect, search, settings page, connection history/recent hosts | + +> **Note on encryption timing:** The encryption service and credential CRUD (encrypted) are in Phase 1, not Phase 4. SSH connections in Phase 2 need to decrypt credentials — plaintext storage is never acceptable, even temporarily. Phase 4's vault work is the management UI (import dialogs, key list view), not the encryption layer itself. diff --git a/docs/superpowers/specs/2026-03-17-wraith-desktop-design.md b/docs/superpowers/specs/2026-03-17-wraith-desktop-design.md new file mode 100644 index 0000000..24a6a4f --- /dev/null +++ b/docs/superpowers/specs/2026-03-17-wraith-desktop-design.md @@ -0,0 +1,982 @@ +# Wraith Desktop — Design Spec + +> **Date:** 2026-03-17 +> **Purpose:** Native Windows desktop replacement for MobaXTerm — SSH + SFTP + RDP in a single binary +> **Stack:** Go + Wails v3 (Vue 3 frontend, WebView2) + SQLite + FreeRDP3 (purego) +> **Target:** Personal tool for daily MSP/sysadmin work — Windows only +> **Name:** Wraith — exists everywhere, all at once. + +--- + +## 1. What This Is + +A Windows desktop application that replaces MobaXTerm. Multi-tabbed SSH terminal with SFTP sidebar (MobaXTerm's killer feature), RDP via FreeRDP3 dynamic linking, connection manager with hierarchical groups, and an encrypted vault for SSH keys and passwords. Ships as `wraith.exe` + `freerdp3.dll`. No Docker, no database server, no sidecar processes. + +**What this is NOT:** A web app, a SaaS platform, a team tool. It's a personal remote access workstation built as a native desktop binary. + +**Prior art:** This is a ground-up rebuild of Wraith, which was previously a self-hosted web application (Nuxt 3 + NestJS + guacd + PostgreSQL). The web version proved the feature set; this version delivers it as a proper desktop tool. + +--- + +## 2. Technology Stack + +### Backend (Go) + +| Component | Technology | Purpose | +|---|---|---| +| Framework | Wails v3 (alpha) | Desktop app shell, multi-window, Go↔JS bindings | +| SSH | `golang.org/x/crypto/ssh` | SSH client connections, PTY, auth | +| SFTP | `github.com/pkg/sftp` | Remote filesystem operations over SSH | +| RDP | FreeRDP3 via `purego` / `syscall.NewLazyDLL` | RDP protocol, bitmap rendering | +| Database | SQLite via `modernc.org/sqlite` (pure Go) | Connections, credentials, settings | +| Encryption | `crypto/aes` + `crypto/cipher` (GCM) | Vault encryption at rest | +| Key derivation | `golang.org/x/crypto/argon2` | Master password → encryption key | + +### Frontend (Vue 3 in WebView2) + +| Component | Technology | Purpose | +|---|---|---| +| Framework | Vue 3 (Composition API) | UI framework | +| Terminal | xterm.js 5.x + WebGL addon | SSH terminal emulator | +| File editor | CodeMirror 6 | Remote file editing (separate window) | +| CSS | Tailwind CSS | Utility-first styling | +| Components | Naive UI | Tree, tabs, modals, dialogs, inputs | +| State | Pinia | Reactive stores for sessions, connections, app state | +| Build | Vite | Frontend build tooling | + +### Distribution + +| Artifact | Notes | +|---|---| +| `wraith.exe` | Single Go binary, ~8-10MB | +| `freerdp3.dll` | FreeRDP3 dynamic library, shipped alongside | +| Data | `%APPDATA%\Wraith\wraith.db` (SQLite) | +| Installer | NSIS via Wails build | + +--- + +## 3. Architecture + +``` +┌─────────────────────────────────────────────────────────────────────┐ +│ Wails v3 Application (wraith.exe) │ +│ │ +│ ┌─ Go Backend ──────────────────────────────────────────────────┐ │ +│ │ │ │ +│ │ ┌──────────────┐ ┌──────────────┐ ┌───────────────────┐ │ │ +│ │ │ SSH Service │ │ SFTP Service │ │ RDP Service │ │ │ +│ │ │ x/crypto/ssh │ │ pkg/sftp │ │ purego→freerdp3 │ │ │ +│ │ └──────┬───────┘ └──────┬───────┘ └────────┬──────────┘ │ │ +│ │ │ │ │ │ │ +│ │ ┌──────▼─────────────────▼────────────────────▼──────────┐ │ │ +│ │ │ Session Manager │ │ │ +│ │ │ • Tracks all active SSH/RDP sessions │ │ │ +│ │ │ • Routes I/O between frontend and protocol backends │ │ │ +│ │ │ • Supports tab detach/reattach (session ≠ window) │ │ │ +│ │ └────────────────────────┬───────────────────────────────┘ │ │ +│ │ │ │ │ +│ │ ┌────────────────────────▼───────────────────────────────┐ │ │ +│ │ │ Vault Service │ │ │ +│ │ │ • Master password → Argon2id → AES-256-GCM key │ │ │ +│ │ │ • SQLite storage (%APPDATA%\Wraith\wraith.db) │ │ │ +│ │ │ • Encrypts: SSH keys, passwords, RDP credentials │ │ │ +│ │ └────────────────────────────────────────────────────────┘ │ │ +│ │ │ │ +│ │ ┌──────────────┐ ┌──────────────┐ ┌──────────────────┐ │ │ +│ │ │ Connection │ │ Import │ │ Host Key │ │ │ +│ │ │ Manager │ │ .mobaconf │ │ Store │ │ │ +│ │ └──────────────┘ └──────────────┘ └──────────────────┘ │ │ +│ └───────────────────────────────────────────────────────────────┘ │ +│ ▲ │ +│ Wails v3 Bindings (type-safe Go↔JS) │ +│ ▼ │ +│ ┌─ Vue 3 Frontend (WebView2) ───────────────────────────────────┐ │ +│ │ │ │ +│ │ ┌──────────┐ ┌───────────┐ ┌──────────┐ ┌───────────────┐ │ │ +│ │ │ xterm.js │ │ SFTP Tree │ │ RDP │ │ CodeMirror 6 │ │ │ +│ │ │ +WebGL │ │ Sidebar │ │ Canvas │ │ (sep window) │ │ │ +│ │ └──────────┘ └───────────┘ └──────────┘ └───────────────┘ │ │ +│ │ │ │ +│ │ ┌───────────────────────────────────────────────────────┐ │ │ +│ │ │ Tab Bar (detachable) + Connection Sidebar │ │ │ +│ │ │ Command Palette (Ctrl+K) | Dark theme │ │ │ +│ │ │ Tailwind CSS + Naive UI │ │ │ +│ │ └───────────────────────────────────────────────────────┘ │ │ +│ └───────────────────────────────────────────────────────────────┘ │ +└─────────────────────────────────────────────────────────────────────┘ + │ │ │ + SSH (port 22) SFTP (over SSH) RDP (port 3389) + │ │ │ + ▼ ▼ ▼ + Linux/macOS hosts Remote filesystems Windows hosts +``` + +### Key Architectural Decisions + +**Sessions ≠ Windows.** SSH and RDP sessions live as objects in the Go Session Manager. The frontend is a view. Detaching a tab spawns a new Wails window pointing at the same backend session. Re-attaching destroys the window and re-renders the session in the original tab. The session itself never drops. + +**Wails v3 multi-window risk mitigation:** This is the project's biggest technical risk. The detach/reattach model depends on Wails v3's alpha `application.NewWebviewWindow()` API. Three fallback plans, validated in priority order during Phase 1: + +- **Plan A (target):** Wails v3 `NewWebviewWindow()` — true native multi-window. Spike this in Phase 1 with a minimal two-window prototype before committing. +- **Plan B:** Single Wails window with internal "floating panel" detach — session renders in a draggable, resizable overlay within the main window. Not true OS windows, but close enough. No external dependency. +- **Plan C:** Wails v3 server mode — detached sessions open in the default browser at `localhost:{port}/session/{id}`. Functional but breaks the native feel. + +If Plan A fails, we fall to Plan B (which is entirely within our control). Plan C is the emergency fallback. **This must be validated in Phase 1, not discovered in Phase 4.** + +**Single binary + DLL.** No Docker, no sidecar processes. SQLite is embedded (pure Go driver). FreeRDP3 is the only external dependency, loaded dynamically via `purego`. + +**SFTP rides SSH.** SFTP opens a separate SSH channel on the same `x/crypto/ssh` connection as the terminal. No separate TCP connection is needed. `pkg/sftp.NewClient()` takes an `*ssh.Client` (not the shell `*ssh.Session`) and opens its own subsystem channel internally. The terminal shell session and SFTP operate as independent channels multiplexed over the same connection. + +**RDP via pixel buffer.** FreeRDP3 is loaded via `purego` (dynamic linking, no CGO). FreeRDP writes decoded bitmap frames into a shared Go pixel buffer. The Go backend serves frame data to the frontend via a local HTTP endpoint (`localhost:{random_port}/frame`) that returns raw RGBA data. The frontend renders frames on a `` element using `requestAnimationFrame`. Performance target: 1080p @ 30fps using Bitmap Update callbacks. The local HTTP approach is the default; if benchmarking reveals issues, Wails binding with base64-encoded frames is the fallback. + +--- + +## 4. Data Model (SQLite) + +```sql +-- Connection groups (hierarchical folders) +CREATE TABLE groups ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + parent_id INTEGER REFERENCES groups(id) ON DELETE SET NULL, + sort_order INTEGER DEFAULT 0, + icon TEXT, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP +); + +-- Saved connections +CREATE TABLE connections ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + hostname TEXT NOT NULL, + port INTEGER NOT NULL DEFAULT 22, + protocol TEXT NOT NULL CHECK(protocol IN ('ssh','rdp')), + group_id INTEGER REFERENCES groups(id) ON DELETE SET NULL, + credential_id INTEGER REFERENCES credentials(id) ON DELETE SET NULL, + color TEXT, + tags TEXT DEFAULT '[]', -- JSON array: ["Prod","Linux","Client-RSM"] + notes TEXT, + options TEXT DEFAULT '{}', -- JSON: protocol-specific settings + sort_order INTEGER DEFAULT 0, + last_connected DATETIME, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP +); + +-- Credentials (password or SSH key reference) +CREATE TABLE credentials ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + username TEXT, + domain TEXT, + type TEXT NOT NULL CHECK(type IN ('password','ssh_key')), + encrypted_value TEXT, + ssh_key_id INTEGER REFERENCES ssh_keys(id) ON DELETE SET NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP +); + +-- SSH private keys (encrypted at rest) +CREATE TABLE ssh_keys ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + key_type TEXT, + fingerprint TEXT, + public_key TEXT, + encrypted_private_key TEXT NOT NULL, + passphrase_encrypted TEXT, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP +); + +-- Terminal themes (16-color ANSI + fg/bg/cursor) +CREATE TABLE themes ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL UNIQUE, + foreground TEXT NOT NULL, + background TEXT NOT NULL, + cursor TEXT NOT NULL, + black TEXT NOT NULL, + red TEXT NOT NULL, + green TEXT NOT NULL, + yellow TEXT NOT NULL, + blue TEXT NOT NULL, + magenta TEXT NOT NULL, + cyan TEXT NOT NULL, + white TEXT NOT NULL, + bright_black TEXT NOT NULL, + bright_red TEXT NOT NULL, + bright_green TEXT NOT NULL, + bright_yellow TEXT NOT NULL, + bright_blue TEXT NOT NULL, + bright_magenta TEXT NOT NULL, + bright_cyan TEXT NOT NULL, + bright_white TEXT NOT NULL, + selection_bg TEXT, + selection_fg TEXT, + is_builtin BOOLEAN DEFAULT 0 +); + +-- Connection history (for recent connections + frequency sorting) +CREATE TABLE connection_history ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + connection_id INTEGER NOT NULL REFERENCES connections(id) ON DELETE CASCADE, + protocol TEXT NOT NULL, + connected_at DATETIME DEFAULT CURRENT_TIMESTAMP, + disconnected_at DATETIME, + duration_secs INTEGER +); + +-- Known SSH host keys +CREATE TABLE host_keys ( + hostname TEXT NOT NULL, + port INTEGER NOT NULL, + key_type TEXT NOT NULL, + fingerprint TEXT NOT NULL, + raw_key TEXT, + first_seen DATETIME DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (hostname, port, key_type) +); + +-- App settings (key-value) +CREATE TABLE settings ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL +); +``` + +**`connections.options`** — JSON blob for protocol-specific settings. SSH: keepalive interval, preferred auth method, shell integration toggle. RDP: color depth, security mode (NLA/TLS/RDP), console session, audio redirection, display resolution. Keeps the schema clean and extensible as we discover edge cases without adding nullable columns. + +**`connections.tags`** — JSON array searchable via SQLite's `json_each()`. Enables filtering across groups (type "Prod" in search, see only production hosts regardless of which group they're in). + +**Connections → Credentials** is many-to-one. Multiple hosts can share the same credential. + +**SQLite WAL mode:** Enable Write-Ahead Logging (`PRAGMA journal_mode=WAL`) on database open in `db/sqlite.go`. WAL mode allows concurrent reads during writes, preventing "database is locked" errors when the frontend queries connections while the backend is writing session history or updating `last_connected` timestamps. Also set `PRAGMA busy_timeout=5000` as a safety net. + +**Host keys** are keyed by `(hostname, port, key_type)`. Supports multiple key types per host. Separated from connections so host key verification works independently of saved connections (e.g., quick connect). + +--- + +## 5. UI Layout + +### Visual Identity + +Dark theme inspired by the Wraith brand: deep dark backgrounds (#0d1117), blue accent (#58a6ff), green for SSH indicators (#3fb950), blue for RDP indicators (#1f6feb). The aesthetic is "operator command center" — atmospheric, moody, professional. Reference: `docs/karens-wraith-layout.png` for the target mood. + +Logo: `images/wraith-logo.png` — ghost with "$" symbol, used in the title bar and app icon. + +**The "alive" feel:** Tabs use a 0.5s CSS `transition` on `background-color` and `border-color` when switching between active and backgrounded states. The active tab's background subtly brightens; backgrounded tabs dim. This creates a fluid, "breathing" quality as you switch between sessions — the Wraith is present without being loud. Same 0.5s transition applies to sidebar item hover states and toolbar button interactions. No animations on the terminal itself — that would be distracting. + +### Main Window Layout + +``` +┌─────────────────────────────────────────────────────────────┐ +│ [👻 WRAITH v1.0] File View Tools Settings Help │ ← Title/Menu bar +├─────────────────────────────────────────────────────────────┤ +│ [⚡ Quick connect...] [+SSH] [+RDP] 4 sessions 🔒 ⚙ │ ← Toolbar +├────────────┬────────────────────────────────────────────────┤ +│ │ [Asgard ●] [Docker ●] [Predator ●] [VM01 ●]+ │ ← Tab bar +│ SIDEBAR │────────────────────────────────────────────────│ +│ │ │ +│ Toggles: │ Terminal / RDP Canvas │ +│ 📂 Conn │ │ +│ 📁 SFTP │ (xterm.js or ) │ +│ │ │ +│ Search │ Primary workspace area │ +│ Tags │ Takes dominant space │ +│ Groups │ │ +│ Tree │ │ +│ │ │ +├────────────┴────────────────────────────────────────────────┤ +│ SSH · root@asgard:22 ⚠️ ↑1.2K ↓3.4K Dark+ UTF-8 120×40 │ ← Status bar +└─────────────────────────────────────────────────────────────┘ +``` + +### Sidebar Behavior + +The left sidebar is a **single panel that toggles context** between Connections and SFTP (same as MobaXTerm, not two panels): + +- **Connections view:** Search bar, tag filter pills, recent connections, hierarchical group tree with connection entries. Green dots for SSH, blue dots for RDP. "Connected" indicator on active sessions. Right-click context menu for edit, delete, duplicate, move to group. +- **SFTP view:** Activates when an SSH session connects. Path bar showing current remote directory. Toolbar with upload, download, new file, new folder, refresh, delete. File tree with name, size, modified date. "Follow terminal folder" toggle at bottom. + +The sidebar is resizable. Minimum width ~200px, collapsible to icon-only rail. + +### Tab Bar + +- Color-coded dots: green = SSH, blue = RDP +- Protocol icon on each tab +- Environment badges: optional colored pills (PROD, ROOT, DEV) derived from connection tags +- Root session warning: tabs connected as root get a subtle warm accent +- Close button (×) on each tab +- Pop-out icon (↗) on hover for tab detach +- Overflow: chevron dropdown for hidden tabs when 10+ are open (not multi-line rows) +- Drag to reorder tabs +- Drag out of tab bar to detach into new window +- "+" button to open new session + +### Tab Detach/Reattach + +- **Detach:** Drag tab out of bar OR click ↗ icon → spawns new Wails window with that session still alive. Original tab shows "Session detached — [Reattach]" placeholder. +- **Reattach:** Click "Reattach" button in placeholder OR close the detached window → session snaps back into the tab bar. +- Works for both SSH and RDP sessions. +- The session lives in the Go backend, not the window. Detaching is just moving the view. + +### Command Palette (Ctrl+K) + +Modal overlay with fuzzy search across: +- Connection names, hostnames, group names, tags +- Actions: "New SSH", "New RDP", "Open Vault", "Settings", "Import MobaXTerm" +- Active sessions: "Switch to Asgard", "Disconnect Docker" +- Keyboard-first — arrow keys to navigate, Enter to select, Esc to close + +### Status Bar + +- Left: Protocol, user@host:port, privilege warning (⚠️ when root), transfer speed +- Right: Active theme name, encoding (UTF-8), terminal dimensions (cols×rows) +- Active session count in toolbar area + +### Terminal Theming + +Built-in themes: Dracula, Nord, Monokai, Solarized Dark, One Dark, Gruvbox, plus a "MobaXTerm Classic" theme matching the colors from the user's `.mobaconf` export. + +Custom theme creation via settings. Full 16-color ANSI palette + foreground/background/cursor maps directly to xterm.js `ITheme` objects. + +Per-connection theme override via `connections.options` JSON field. + +### Keyboard Shortcuts + +| Shortcut | Action | +|---|---| +| Ctrl+K | Command palette | +| Ctrl+T | New SSH session (from quick connect) | +| Ctrl+W | Close current tab | +| Ctrl+Tab | Next tab | +| Ctrl+Shift+Tab | Previous tab | +| Ctrl+1-9 | Switch to tab N | +| Ctrl+B | Toggle sidebar | +| Ctrl+Shift+D | Detach current tab | +| F11 | Fullscreen | +| Ctrl+Shift+C | Copy (terminal) | +| Ctrl+Shift+V | Paste (terminal) | +| Ctrl+F | Search in terminal scrollback | + +### CodeMirror 6 Editor (Separate Window) + +- Opens as a new Wails window when clicking a text file in the SFTP sidebar +- File size guard: files over 5MB refused for inline editing (offered download instead) +- Syntax highlighting based on file extension +- Save button writes content back to remote via SFTP +- Unsaved changes warning on close +- Window title: `filename — host — Wraith Editor` + +--- + +## 6. SSH + SFTP Flow + +### SSH Connection + +``` +User double-clicks "Asgard" in connection sidebar + → Go: ConnectionManager.Connect(connectionId) + → Go: VaultService.DecryptCredential(credentialId) → auth method + → Go: SSHService.Dial(hostname, port, authConfig) + → x/crypto/ssh.Dial() with host key callback + → If new host key: emit event to frontend, user accepts/rejects + → If changed host key: BLOCK connection, warn user (no silent accept) + → If accepted: store in host_keys table + → Go: SessionManager.Create(sshClient, connectionId) → sessionId + → Go: SSHService.RequestPTY(session, "xterm-256color", cols, rows) + → Go: SSHService.Shell(session) → stdin/stdout pipes + → Frontend: xterm.js instance created, bound to sessionId + → Wails bindings: bidirectional data flow + → xterm.js onData → Go SSHService.Write(sessionId, bytes) + → Go SSHService.Read(sessionId) → Wails event → xterm.js write +``` + +### SSH Authentication + +Supports three auth methods: + +1. **SSH Key:** Decrypt private key from vault. If key has passphrase, decrypt that too. Pass to `ssh.PublicKeys()` signer. +2. **Password:** Decrypt password from vault. Pass to `ssh.Password()`. +3. **Keyboard-Interactive:** For servers with 2FA/MFA prompts. `ssh.KeyboardInteractive()` callback relays challenge prompts to the frontend, user responds in a dialog. Common in MSP environments with PAM-based MFA. + +Auth methods are tried in order: key → password → keyboard-interactive. The credential type determines which are attempted first, but keyboard-interactive is always available as a fallback for servers that require it. + +### Terminal Resize + +``` +Frontend: xterm.js fit addon detects container resize + → Wails binding: SSHService.Resize(sessionId, cols, rows) + → Go: session.WindowChange(rows, cols) +``` + +### SFTP Sidebar + +``` +SSH connection established: + → Go: SFTPService.Open(sshClient) → pkg/sftp.NewClient(sshClient) + → Go: SFTPService.List(sessionId, homeDir) → directory listing + → Frontend: sidebar switches to SFTP view, renders file tree +``` + +SFTP uses the **same SSH connection** as the terminal (SFTP subsystem). No separate connection needed. + +**File operations:** All SFTP commands route through Go via Wails bindings, targeting the correct `pkg/sftp` client by sessionId. + +| Operation | Go function | Notes | +|---|---|---| +| List directory | `sftp.ReadDir(path)` | Lazy-loaded on tree expand | +| Upload | `sftp.Create(path)` + chunked write | Drag-and-drop from Windows Explorer | +| Download | `sftp.Open(path)` + read | Browser-style save dialog | +| Delete | `sftp.Remove(path)` / `sftp.RemoveAll(path)` | Confirmation prompt | +| Rename/Move | `sftp.Rename(old, new)` | | +| Mkdir | `sftp.Mkdir(path)` | | +| Chmod | `sftp.Chmod(path, mode)` | | +| Read file | `sftp.Open(path)` → content | Opens in CodeMirror window | +| Write file | `sftp.Create(path)` ← content | Save from CodeMirror | + +### CWD Following + +``` +SSH session starts: + → Go: injects shell hook after PTY is established: + PROMPT_COMMAND='printf "\033]7;file://%s%s\033\\" "$(hostname)" "$PWD"' + (or precmd for zsh) + → Go: SSHService reads stdout, scans for OSC 7 escape sequences + → Go: strips OSC 7 before forwarding to xterm.js (user never sees it) + → Go: emits CWD change event with new path + → Frontend: if "Follow terminal folder" is enabled, calls SFTPService.List(newPath) + → Frontend: SFTP tree navigates to new directory +``` + +"Follow terminal folder" is a per-session toggle (checkbox at bottom of SFTP sidebar), enabled by default. + +**Shell detection:** The OSC 7 injection assumes a bash-like shell (`PROMPT_COMMAND`) or zsh (`precmd`). For fish, the equivalent is `function fish_prompt; printf "\033]7;file://%s%s\033\\" (hostname) "$PWD"; end`. If shell detection fails (unknown shell, restricted shell, non-interactive session), CWD following is silently disabled — the SFTP sidebar stays at the initial home directory and requires manual navigation. + +### Upload Flow + +``` +User drags file from Windows Explorer onto SFTP sidebar: + → Frontend: reads file via File API, sends chunks to Go + → Go: SFTPService.Upload(sessionId, remotePath, fileData) + → Go: sftp.Create(remotePath) → write chunks → close + → Progress events emitted back to frontend + → SFTP tree refreshes on completion +``` + +--- + +## 7. RDP Flow + +### Architecture + +FreeRDP3 is loaded via `purego` (or `syscall.NewLazyDLL`) at runtime. No CGO, no C compiler needed. The Go binary loads `freerdp3.dll` from the application directory. + +### Connection + +``` +User double-clicks "CLT-VMHOST01" in connection sidebar: + → Go: ConnectionManager.Connect(connectionId) + → Go: VaultService.DecryptCredential(credentialId) → username, password, domain + → Go: RDPService.Connect(host, port, username, password, domain, options) + → purego: freerdp_new() → configure settings → freerdp_connect() + → Register BitmapUpdate callback + → Go: allocate pixel buffer (width × height × 4 bytes RGBA) + → FreeRDP: decoded bitmap frames written into pixel buffer + → Go: frame data served to frontend + → Frontend: renders frames via requestAnimationFrame (30fps target) +``` + +### Frame Delivery + +FreeRDP writes decoded frame data into a shared Go pixel buffer. The frontend retrieves frame data via one of: + +- **Local HTTP endpoint:** `localhost:{random_port}/frame` returns raw RGBA or PNG +- **Blob URL:** Go encodes frame, passes via Wails binding as base64 +- **Optimal approach TBD during implementation** — benchmark both + +Performance target: **1080p @ 30fps**. Focus on Bitmap Update callbacks. No H.264 pipeline needed — raw bitmap updates with basic RLE compression is sufficient for remote management work. + +### Input Handling + +``` +Frontend: mouse/keyboard events captured on element + → Wails binding → Go: RDPService.SendMouseEvent(sessionId, x, y, flags) + → Wails binding → Go: RDPService.SendKeyEvent(sessionId, keycode, pressed) + → Go: translate JS virtual keycodes to RDP scancodes via lookup table + → Go: purego calls freerdp_input_send_mouse_event / freerdp_input_send_keyboard_event +``` + +**Scancode mapping:** JavaScript `KeyboardEvent.code` values (e.g., "KeyA", "ShiftLeft") must be translated to RDP hardware scancodes that FreeRDP expects. A static lookup table in `internal/rdp/input.go` maps JS key codes → RDP scancodes. This is a known complexity in web-based RDP — the table must handle extended keys (e.g., right Alt, numpad) and platform-specific quirks. Reference: FreeRDP's `scancode.h` for the canonical scancode list. + +**System key pass-through:** The Windows key and Alt+Tab require special handling. By default, these keys are captured by the local OS. A per-connection toggle in `connections.options` (`"grabKeyboard": true`) controls whether system keys are forwarded to the remote host or stay local. When enabled, the RDP canvas captures all keyboard input including Win key, Alt+Tab, Ctrl+Alt+Del (via a toolbar button). Power users toggling between remote and local need this to be fast and obvious — surface it as an icon in the RDP toolbar. + +### Clipboard Sync + +``` +Remote → Local: + → Go: FreeRDP clipboard channel callback fires + → Go: emits clipboard event to frontend + → Frontend: writes to system clipboard via Wails API + +Local → Remote: + → Frontend: detects clipboard change (or user pastes) + → Wails binding → Go: RDPService.SendClipboard(sessionId, data) + → Go: writes to FreeRDP clipboard channel +``` + +### RDP Connection Options + +Stored in `connections.options` JSON field: + +```json +{ + "colorDepth": 32, + "security": "nla", + "consoleSession": false, + "audioRedirect": false, + "width": 1920, + "height": 1080, + "scaleFactor": 100 +} +``` + +**HiDPI / display scaling:** On Windows with display scaling (e.g., 150% on a 4K monitor), the RDP session resolution must account for the scale factor. `scaleFactor` in connection options controls whether to send the physical pixel resolution or the scaled logical resolution to FreeRDP. Default behavior: detect the current Windows DPI setting and scale the RDP resolution accordingly. Override via the `scaleFactor` option (100 = no scaling, 150 = 150%). + +--- + +## 8. Vault + Encryption + +### Master Password Flow + +``` +App launch: + → Master password prompt (modal, cannot be bypassed) + → If first launch: + → Generate random 32-byte salt + → Store salt in settings table (key: "vault_salt") + → Derive key: Argon2id(password, salt, t=3, m=65536, p=4, keyLen=32) + → Encrypt a known test value ("wraith-vault-check") with derived key + → Store encrypted test value in settings (key: "vault_check") + → If returning: + → Read salt and encrypted test value from settings + → Derive key with same parameters + → Attempt to decrypt test value + → If decryption succeeds → vault unlocked + → If fails → wrong password, prompt again + → Derived key held in memory only, never written to disk + → Key zeroed from memory on app close +``` + +### Encryption Functions + +``` +Encrypt(plaintext string) → string: + → Generate random 12-byte IV (crypto/rand) + → AES-256-GCM Seal(): returns ciphertext with authTag appended (Go's native format) + → Return "v1:{iv_hex}:{sealed_hex}" + → (sealed = ciphertext || authTag, as produced by cipher.AEAD.Seal()) + +Decrypt(blob string) → string: + → Parse version prefix, IV (12B), sealed data + → AES-256-GCM Open(): decrypts and verifies authTag (Go's native format) + → Return plaintext +``` + +The `v1:` version prefix enables future key rotation without re-encrypting all stored values. + +### What Gets Encrypted + +| Data | Encrypted | Reason | +|---|---|---| +| SSH private keys | Yes | Sensitive key material | +| SSH key passphrases | Yes | Passphrase is a secret | +| Password credentials | Yes | Passwords are secrets | +| RDP passwords | Yes | Via credential reference | +| Hostnames, ports, usernames | No | Not secrets, needed for display | +| Public keys, fingerprints | No | Public by definition | +| Group names, tags, notes | No | Not secrets | +| Settings, themes | No | User preferences | + +### Argon2id Parameters + +| Parameter | Value | Rationale | +|---|---|---| +| Time cost (t) | 3 | OWASP recommended minimum | +| Memory cost (m) | 65536 (64MB) | Resists GPU attacks | +| Parallelism (p) | 4 | Matches typical core count | +| Key length | 32 bytes (256-bit) | AES-256 key size | +| Salt | 32 bytes, random | Unique per installation | + +### Future: Windows DPAPI Integration (Post-MVP) + +The current vault is secure and portable (works on any Windows machine, backup the `.db` file and go). Post-MVP, an optional DPAPI layer could wrap the derived AES key with Windows Data Protection API, tying the vault to the current Windows user account. This would enable: + +- Transparent unlock when logged into Windows (no master password prompt) +- Hardware-backed key protection on machines with TPM +- Enterprise trust (DPAPI is a known quantity for IT departments) + +Implementation: the Argon2id-derived key gets wrapped with `CryptProtectData()` and stored. On unlock, DPAPI unwraps the key. Master password remains the fallback for portability (moving the database to another machine). This is designed-for but not built in MVP — the `v1:` encryption prefix enables adding a `v2:` scheme without re-encrypting existing data. + +--- + +## 9. MobaXTerm Importer + +### Config Format + +MobaXTerm exports configuration as `.mobaconf` files — INI format with `%`-delimited session strings. + +```ini +[Bookmarks_1] +SubRep=AAA Vantz's Stuff # Group name +ImgNum=41 # Icon index +*Asgard=#109#0%192.168.1.4%22%vstockwell%... # SSH session +CLT-VMHOST01=#91#4%100.64.1.204%3389%... # RDP session + +[SSH_Hostkeys] +ssh-ed25519@22:192.168.1.4=0x29ac... # Known host keys + +[Colors] +ForegroundColour=236,236,236 # Terminal colors +BackgroundColour=36,36,36 + +[Passwords] +vstockwell@192.168.1.214=_@9jajOXK... # Encrypted (can't import) +``` + +### Session String Parsing + +| Protocol | Type code | Fields (%-delimited) | +|---|---|---| +| SSH | `#109#` | host, port, username, ..., SSH key path, ..., colors | +| RDP | `#91#` | host, port, username, ..., color depth, security | + +### Import Flow + +``` +1. User: File → Import → Select .mobaconf file +2. Go: parse INI sections +3. Go: extract groups from [Bookmarks_N] SubRep values +4. Go: parse session strings → connections +5. Go: parse [SSH_Hostkeys] → host_keys table +6. Go: parse [Colors] + [Font] → create "MobaXTerm Import" theme +7. Frontend: show preview dialog: + "Found: 18 connections, 1 group, 4 host keys, 1 color theme" +8. User confirms import +9. Go: create groups, connections, host keys, theme in SQLite +10. Frontend: report results: + "Imported! 5 connections reference SSH keys — re-import key files. + 3 connections had stored passwords — re-enter in Wraith vault." +``` + +### What Gets Imported + +| Data | Imported | Notes | +|---|---|---| +| Connection names | Yes | | +| Groups (folder hierarchy) | Yes | From SubRep values | +| Hostnames, ports | Yes | | +| Usernames | Yes | | +| Protocol (SSH/RDP) | Yes | From type code #109# / #91# | +| SSH key file paths | As notes | User must re-import actual key files | +| Host keys | Yes | To host_keys table | +| Terminal colors | Yes | As a new theme | +| Font preferences | Yes | To settings | +| Encrypted passwords | No | MobaXTerm-encrypted, can't decrypt | + +--- + +## 10. Frontend Structure + +``` +frontend/ + src/ + App.vue # Root: master password → main layout + layouts/ + MainLayout.vue # Sidebar + tab container + status bar + UnlockLayout.vue # Master password prompt + components/ + sidebar/ + ConnectionTree.vue # Group tree with connection entries + SftpBrowser.vue # SFTP file tree + toolbar + SidebarToggle.vue # Connections ↔ SFTP toggle + session/ + SessionContainer.vue # Holds all active sessions (v-show, not v-if) + TabBar.vue # Draggable, detachable tab bar + TabBadge.vue # PROD/ROOT/DEV environment pills + terminal/ + TerminalView.vue # xterm.js instance wrapper + ThemePicker.vue # Terminal color scheme selector + rdp/ + RdpView.vue # Canvas-based RDP renderer + RdpToolbar.vue # Clipboard, fullscreen controls + sftp/ + FileTree.vue # Remote filesystem tree (lazy-loaded) + TransferProgress.vue # Upload/download progress indicator + vault/ + VaultManager.vue # SSH keys + credentials management + KeyImportDialog.vue # SSH key import modal + CredentialForm.vue # Password/key credential form + common/ + CommandPalette.vue # Ctrl+K fuzzy search overlay + QuickConnect.vue # Quick connect input + StatusBar.vue # Bottom status bar + composables/ + useSession.ts # Session lifecycle + tab management + useTerminal.ts # xterm.js + Wails binding bridge + useSftp.ts # SFTP operations via Wails bindings + useRdp.ts # RDP canvas rendering + input capture + useVault.ts # Key/credential CRUD + useConnections.ts # Connection CRUD + search + tags + useTheme.ts # Terminal theme management + useCommandPalette.ts # Command palette search + actions + stores/ + session.store.ts # Active sessions, tab order, detach state + connection.store.ts # Connections, groups, search state + app.store.ts # Global state: unlocked, settings, active theme +``` + +**Session architecture:** Active sessions render as persistent components inside `SessionContainer.vue`. Switching tabs toggles `v-show` visibility (not `v-if` destruction), so xterm.js and RDP canvas instances stay alive across tab switches. This is critical — destroying and recreating terminal instances would lose scrollback and session state. + +--- + +## 11. Go Backend Structure + +``` +internal/ + app/ + app.go # Wails app setup, window management + menu.go # Application menu definitions + session/ + manager.go # Session lifecycle, tab detach/reattach + session.go # Session struct (SSH or RDP, backend state) + ssh/ + service.go # SSH dial, PTY, shell, I/O pipes + hostkey.go # Host key verification + storage + cwd.go # OSC 7 parsing for CWD tracking + sftp/ + service.go # SFTP operations (list, upload, download, etc.) + rdp/ + service.go # RDP session management + freerdp.go # purego bindings to freerdp3.dll + pixelbuffer.go # Shared frame buffer management + input.go # Mouse/keyboard event translation + vault/ + service.go # Encrypt/decrypt, master password, key derivation + vault_test.go # Encryption round-trip tests + connections/ + service.go # Connection CRUD, group management + search.go # Full-text search + tag filtering + importer/ + mobaconf.go # MobaXTerm .mobaconf parser + mobaconf_test.go # Parser tests with real config samples + db/ + sqlite.go # SQLite connection, migrations + migrations/ # SQL migration files + settings/ + service.go # Key-value settings CRUD + theme/ + service.go # Theme CRUD, built-in theme definitions + builtins.go # Dracula, Nord, Monokai, etc. + plugin/ + interfaces.go # Plugin interfaces (ProtocolHandler, Importer, etc.) + registry.go # Plugin registration and lifecycle +``` + +### Plugin Interface + +Wraith exposes Go interfaces that community developers can implement to extend functionality: + +```go +// ProtocolHandler — add support for new protocols (VNC, Telnet, etc.) +type ProtocolHandler interface { + Name() string + Connect(config ConnectionConfig) (Session, error) + Disconnect(sessionId string) error +} + +// Importer — add support for importing from other tools +type Importer interface { + Name() string + FileExtensions() []string + Parse(data []byte) (*ImportResult, error) +} +``` + +Plugins are compiled into the binary (not runtime-loaded). Community developers fork the repo, implement the interface, register it in `plugin/registry.go`, and build. This keeps distribution simple (single binary) while enabling extensibility. + +--- + +## 12. MVP Scope + +### In MVP (launch-blocking) + +| Feature | Priority | Phase | Notes | +|---|---|---|---| +| Wails v3 scaffold + SQLite + vault | P0 | 1 | Foundation — nothing works without this | +| Connection manager sidebar | P0 | 1 | Groups, tree, search, tags | +| SSH terminal (xterm.js) | P0 | 2 | Multi-tab, 8+ concurrent sessions | +| SFTP sidebar | P0 | 2 | Auto-open, CWD following, file ops | +| Credential vault UI | P0 | 2 | SSH key import, credential management | +| Host key verification | P0 | 2 | Accept/reject new, block changed | +| RDP in tabs | P0 | 3 | FreeRDP3/purego, embedded canvas | +| MobaXTerm importer | P1 | 4 | Parse .mobaconf, first-run detection | +| Terminal theming | P1 | 4 | 6+ built-in themes, custom themes | +| Tab detach/reattach | P1 | 4 | Drag out, pop-out icon, reattach button | +| CodeMirror 6 editor | P1 | 4 | Separate window, syntax highlighting | +| Command palette (Ctrl+K) | P1 | 4 | Fuzzy search connections + actions | +| Session context awareness | P1 | 4 | Root warning, user@host in status bar | +| Tab badges | P1 | 4 | Protocol icon, environment tags | +| Quick connect | P1 | 4 | user@host:port in toolbar | +| Plugin interface | P1 | 1 | Define interfaces, implement in later phases | +| README.md | P1 | 1 | Developer docs, architecture, contribution guide | + +### Post-MVP + +| Feature | Notes | +|---|---| +| Split panes | Horizontal/vertical splits within a tab | +| Session recording/playback | asciinema-compatible | +| Jump host / bastion proxy | ProxyJump chain support | +| Port forwarding manager | Local, remote, dynamic SSH tunnels | +| Saved snippets/macros | Quick-execute command library | +| Tab grouping/stacking | Browser-style tab groups | +| Live latency monitoring | Ping/packet loss in status bar | +| Dual-pane SFTP | Server-to-server file operations | +| Auto-detect environment | Parse hostname for prod/dev/staging classification | +| Subtle glow effects | "Wraith" personality — energy on active sessions | +| Dynamic plugin loading | Drop-in plugins without recompilation (longer-term) | +| Windows DPAPI vault | Optional OS-backed encryption layer for transparent unlock | +| **Claude Code plugin** | **First official plugin — see below** | + +### Post-MVP Plugin: Claude Code Integration + +The first plugin built on the Wraith plugin interface. Embeds Claude Code directly into Wraith as a sidebar panel or tab, with full access to the active session's context. + +**Authentication:** User authenticates with their Anthropic API key or Claude account (stored encrypted in the vault alongside SSH keys and passwords). Key is decrypted on demand, never persisted in plaintext. + +**Core capabilities:** +- **Terminal integration:** Claude Code runs in a dedicated Wraith tab (xterm.js instance). It can see the active SSH session's terminal output and type commands into it — same as a human operator switching tabs. +- **SFTP-aware file access:** Claude Code can read and write files on the remote host via the active SFTP session. "Read `/etc/nginx/nginx.conf`" pulls the file through SFTP, Claude analyzes/modifies it, and writes it back. No need for Claude to SSH separately — it rides the existing Wraith session. +- **CodeMirror handoff:** Claude can open files in the CodeMirror editor window, make changes, and save back to the remote host. The user sees the edits happening in real-time. +- **Context awareness:** Claude sees which host you're connected to, the current working directory (via CWD tracking), and recent terminal output. "Fix the nginx config on this server" just works because Claude already knows where "this" is. + +**UX flow:** +1. User opens Claude Code panel (sidebar tab or dedicated session tab) +2. Types a prompt: "Check why nginx is returning 502 on this server" +3. Claude reads recent terminal output, pulls nginx config via SFTP, analyzes logs +4. Claude proposes a fix, user approves, Claude writes the file via SFTP +5. Claude types `nginx -t && systemctl reload nginx` into the terminal + +**Plugin interface usage:** This plugin implements `ProtocolHandler` (for the Claude Code tab) and extends the SFTP/terminal services to allow programmatic read/write. It proves the plugin architecture works and becomes the reference implementation for community plugin developers. + +--- + +## 13. Build Phases + +### Error Handling + Logging Strategy + +**Structured logging:** Use `log/slog` (Go 1.21+ standard library) with JSON output. Log levels: DEBUG, INFO, WARN, ERROR. Log to `%APPDATA%\Wraith\wraith.log` with daily rotation (keep 7 days). + +**Connection drops:** When an SSH/RDP connection drops unexpectedly: +1. Session Manager marks session as `disconnected` +2. Frontend tab shows "Connection lost — [Reconnect] [Close]" +3. Auto-reconnect is opt-in (configurable per connection via `options` JSON) +4. If auto-reconnect is enabled, retry 3 times with exponential backoff (1s, 2s, 4s) + +**Error surfacing:** Errors from Go backend are emitted as Wails events with a severity level. Frontend shows: +- Transient errors (network timeout) → toast notification, auto-dismiss +- Actionable errors (auth failure) → modal with explanation and action button +- Fatal errors (vault corruption) → full-screen error with instructions + +**Sensitive data in logs:** Never log passwords, private keys, or decrypted credentials. Log only: connection IDs, hostnames, session IDs, error types. + +### Crash Recovery + Workspace Restore + +When the app crashes, the system reboots, or Wails dies, SSH/RDP sessions are gone — there's no way to recover a dropped TCP connection. But the **workspace layout** can be restored. + +**Workspace snapshots:** The Session Manager periodically writes a workspace snapshot to SQLite (every 30 seconds and on clean shutdown): + +```json +{ + "tabs": [ + {"connectionId": 1, "protocol": "ssh", "position": 0, "detached": false}, + {"connectionId": 5, "protocol": "rdp", "position": 1, "detached": false}, + {"connectionId": 3, "protocol": "ssh", "position": 2, "detached": true, "windowBounds": {...}} + ], + "sidebarWidth": 240, + "sidebarMode": "connections", + "activeTab": 0 +} +``` + +**On restart after crash:** +1. Detect unclean shutdown (snapshot exists but no `clean_shutdown` flag) +2. Show: "Wraith closed unexpectedly. Restore previous workspace? [Restore] [Start Fresh]" +3. If Restore: recreate tab layout, attempt to reconnect each session +4. Tabs that fail to reconnect show "Connection lost — [Retry] [Close]" + +Users care about continuity more than perfection. Even if every session dies, restoring the layout and offering one-click reconnect is a massive UX win. + +### Resource Management + +With 20+ SSH sessions and multiple RDP sessions, resource awareness is critical: + +**Memory budget:** Each SSH session costs ~2-5MB (PTY buffer + SFTP client). Each RDP session costs ~8-12MB (pixel buffer at 1080p). Target: stable at 20 SSH + 3 RDP (~100-120MB total backend memory). + +**Session limits:** +- Default max: 32 concurrent sessions (SSH + RDP combined) +- Configurable via settings +- When limit reached: "Maximum sessions reached. Close a session to open a new one." + +**Inactive session handling:** +- Sessions idle for 30+ minutes get a subtle "idle" indicator on the tab (dimmed text) +- SSH keepalive (`ServerAliveInterval` equivalent) prevents server-side timeouts — configurable per connection via `options.keepAliveInterval` (default: 60 seconds) +- No automatic session suspension — users control their sessions explicitly +- SFTP idle connections are closed after 10 minutes of inactivity and silently reopened on next file operation + +**Monitoring:** Expose a "Sessions" panel in Settings showing per-session memory usage, connection duration, and idle time. Simple table, not a dashboard. + +--- + +## 14. Licensing + Open Source + +**License:** MIT. All dependencies must be MIT, Apache 2.0, BSD, or ISC compatible. **No GPL/AGPL dependencies.** + +Dependency license audit is part of Phase 1. Key libraries and their licenses: +- `golang.org/x/crypto` — BSD-3-Clause ✓ +- `github.com/pkg/sftp` — BSD-2-Clause ✓ +- `github.com/ebitengine/purego` — Apache 2.0 ✓ +- `modernc.org/sqlite` — BSD-3-Clause ✓ +- FreeRDP3 — Apache 2.0 ✓ (dynamically linked, no license contamination) +- xterm.js — MIT ✓ +- Vue 3 — MIT ✓ +- Naive UI — MIT ✓ +- Tailwind CSS — MIT ✓ +- CodeMirror 6 — MIT ✓ + +**Plugin architecture:** The Go backend exposes a plugin interface so community developers can extend Wraith with custom protocol handlers, importers, or sidebar panels. Plugins are Go packages that implement defined interfaces and are compiled into the binary (no runtime plugin loading — keeps the binary simple and portable). + +**README.md:** Comprehensive developer-facing documentation covering: architecture overview, build instructions, project structure walkthrough, plugin development guide, contribution guidelines, and the design philosophy. Written as part of Phase 1. + +--- + +## 15. First-Run Experience + +On first launch: +1. Master password creation dialog (set + confirm) +2. Detect if `.mobaconf` files exist in common locations (`%APPDATA%\MobaXterm\`, user's Documents folder) +3. If found: prompt "We found a MobaXTerm configuration. Import your sessions?" with file path shown +4. If not found: offer "Import from MobaXTerm" button + "Start fresh" +5. After import (or skip): land on the empty connection manager with a "Create your first connection" prompt + +--- + +## 16. Build Phases + +| Phase | Deliverables | +|---|---| +| **1: Foundation** | Wails v3 scaffold (including multi-window spike — validate Plan A/B/C), SQLite schema + migrations (WAL mode), vault service (master password, Argon2id, AES-256-GCM), connection CRUD, group tree, Vue 3 shell with sidebar + tab container, dark theme, Naive UI integration, plugin interface definitions, README.md, license audit, **RDP frame transport spike** (benchmark HTTP vs base64 with a test canvas — don't wait until Phase 3) | +| **2: SSH + SFTP** | SSH service (x/crypto/ssh), PTY + shell, xterm.js terminal rendering, multi-tab sessions, SFTP sidebar (pkg/sftp), file tree, upload/download, CWD following (OSC 7), CodeMirror 6 editor in separate window, workspace snapshot persistence | +| **3: RDP** | FreeRDP3 purego bindings, pixel buffer, canvas rendering (using proven transport from Phase 1 spike), mouse/keyboard input mapping (including scancode table + system key pass-through), clipboard sync, connection options | +| **4: Polish** | Command palette, tab detach/reattach, terminal theming (built-in + custom), MobaXTerm importer (with first-run detection), tab badges, session context awareness, quick connect, host key management UI, settings page, crash recovery / workspace restore, resource management panel, NSIS installer | diff --git a/docs/test-buildout-spec.md b/docs/test-buildout-spec.md new file mode 100644 index 0000000..5d82c54 --- /dev/null +++ b/docs/test-buildout-spec.md @@ -0,0 +1,108 @@ +# Wraith Remote — Test Suite Build-Out Spec + +**Date:** 2026-03-14 +**Scope:** Level B — Full service layer coverage (~80-100 tests) +**Status:** Pinned — awaiting green light to execute + +--- + +## Backend (Jest) + +Jest is already configured in `backend/package.json`. Zero spec files exist today. + +### Infrastructure + +- Tests co-located with source: `*.spec.ts` next to `*.ts` +- Shared mock factories in `backend/src/__mocks__/` for Prisma, JwtService, EncryptionService +- `beforeEach` resets all mocks to prevent test bleed + +### Test Files + +| File | Tests | Priority | +|------|-------|----------| +| `auth.service.spec.ts` | Login (valid, invalid, non-existent user timing), bcrypt→argon2 migration, TOTP setup/verify/disable, TOTP secret encryption/decryption, password hashing, profile update, admin CRUD | ~20 | +| `encryption.service.spec.ts` | v2 encrypt/decrypt round-trip, v1 backwards compat decrypt, v1→v2 upgrade, isV1 detection, invalid version handling, key derivation warmup | ~8 | +| `credentials.service.spec.ts` | findAll excludes encryptedValue, findOne with ownership check, create with encryption, update with password change, remove, decryptForConnection (password, SSH key, orphaned key, no auth) | ~10 | +| `ssh-keys.service.spec.ts` | Create with encryption, findAll (no private key leak), findOne ownership, update passphrase, remove, key type detection, fingerprint generation | ~8 | +| `jwt-auth.guard.spec.ts` | Passes valid JWT, rejects missing/expired/invalid JWT | ~3 | +| `admin.guard.spec.ts` | Allows admin role, blocks non-admin, blocks missing user | ~3 | +| `ws-auth.guard.spec.ts` | Cookie-based auth, WS ticket auth (valid, expired, reused), legacy URL token fallback, no token rejection | ~6 | +| `auth.controller.spec.ts` | Login sets cookie, logout clears cookie, ws-ticket issuance and consumption, TOTP endpoints wired correctly | ~8 | + +**Backend total: ~66 tests** + +### Mocking Strategy + +- **PrismaService:** Jest manual mock returning controlled data per test +- **EncryptionService:** Mock encrypt returns `v2:mock:...`, mock decrypt returns plaintext +- **JwtService:** Mock sign returns `mock-jwt-token`, mock verify returns payload +- **Argon2:** Real library (fast enough for unit tests, tests actual hashing behavior) +- **bcrypt:** Real library (needed to test migration path) + +--- + +## Frontend (Vitest) + +No test infrastructure exists today. Needs full setup. + +### Infrastructure + +- Install: `vitest`, `@vue/test-utils`, `@pinia/testing`, `happy-dom` +- Config: `frontend/vitest.config.ts` with happy-dom environment +- Global mock for `$fetch` (Nuxt auto-import) via setup file +- Global mock for `navigateTo` (Nuxt auto-import) + +### Test Files + +| File | Tests | Priority | +|------|-------|----------| +| `stores/auth.store.spec.ts` | Login success (stores user, no token in state), login TOTP flow, logout clears state + calls API, fetchProfile success/failure, getWsTicket, isAuthenticated/isAdmin getters | ~10 | +| `stores/connection.store.spec.ts` | fetchHosts, fetchTree, createHost, updateHost, deleteHost, group CRUD, no Authorization headers in requests | ~8 | +| `composables/useVault.spec.ts` | listKeys, importKey, deleteKey, listCredentials, createCredential, updateCredential, deleteCredential — all without Authorization headers | ~7 | +| `middleware/admin.spec.ts` | Redirects non-admin to /, allows admin through | ~3 | +| `plugins/auth.client.spec.ts` | Calls fetchProfile on init when user is null, skips when user exists | ~2 | + +**Frontend total: ~30 tests** + +--- + +## NPM Scripts + +```json +// backend/package.json +"test": "jest", +"test:watch": "jest --watch", +"test:cov": "jest --coverage" + +// frontend/package.json +"test": "vitest run", +"test:watch": "vitest", +"test:cov": "vitest run --coverage" +``` + +--- + +## Pre-commit Hook (Husky) + +- Install `husky` + `lint-staged` at repo root +- Pre-commit runs: backend Jest (changed files) + frontend Vitest (changed files) +- Fast feedback — only tests related to changed files run on commit + +--- + +## Execution Plan + +1. **Agent 1:** Backend test infra (mock factories) + auth service tests + auth controller tests + guard tests (~37 tests) +2. **Agent 2:** Backend vault tests — encryption, credentials, SSH keys (~26 tests) +3. **Agent 3:** Frontend test infra (Vitest setup) + store tests + middleware + plugin tests (~30 tests) +4. **XO (me):** Wire npm scripts, verify all pass, add Husky pre-commit hook, final integration check + +--- + +## Future (Level C — when ready) + +- Component tests with Vue Test Utils (render + interaction) +- Integration tests against a real test PostgreSQL (Docker test container) +- Gateway tests (WebSocket mocking for terminal, SFTP, RDP) +- E2E with Playwright +- CI pipeline (Gitea Actions) diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..33a57ad --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,12 @@ + + + + + + Wraith + + +
+ + + diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 0000000..8a11e8b --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,2829 @@ +{ + "name": "wraith-frontend", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "wraith-frontend", + "dependencies": { + "@codemirror/lang-javascript": "^6.2.5", + "@codemirror/lang-json": "^6.0.2", + "@codemirror/lang-markdown": "^6.5.0", + "@codemirror/lang-python": "^6.2.1", + "@codemirror/state": "^6.6.0", + "@codemirror/theme-one-dark": "^6.1.3", + "@codemirror/view": "^6.40.0", + "@wailsio/runtime": "latest", + "@xterm/addon-fit": "^0.11.0", + "@xterm/addon-search": "^0.16.0", + "@xterm/addon-web-links": "^0.12.0", + "@xterm/addon-webgl": "^0.19.0", + "@xterm/xterm": "^6.0.0", + "codemirror": "^6.0.2", + "naive-ui": "^2.40.0", + "pinia": "^2.2.0", + "vue": "^3.5.0", + "vue-router": "^4.4.0" + }, + "devDependencies": { + "@tailwindcss/vite": "^4.0.0", + "@vitejs/plugin-vue": "^5.0.0", + "tailwindcss": "^4.0.0", + "typescript": "^5.5.0", + "vite": "^6.0.0", + "vue-tsc": "^2.0.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==", + "license": "MIT", + "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==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.2.tgz", + "integrity": "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@codemirror/autocomplete": { + "version": "6.20.1", + "resolved": "https://registry.npmjs.org/@codemirror/autocomplete/-/autocomplete-6.20.1.tgz", + "integrity": "sha512-1cvg3Vz1dSSToCNlJfRA2WSI4ht3K+WplO0UMOgmUYPivCyy2oueZY6Lx7M9wThm7SDUBViRmuT+OG/i8+ON9A==", + "license": "MIT", + "dependencies": { + "@codemirror/language": "^6.0.0", + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.17.0", + "@lezer/common": "^1.0.0" + } + }, + "node_modules/@codemirror/commands": { + "version": "6.10.3", + "resolved": "https://registry.npmjs.org/@codemirror/commands/-/commands-6.10.3.tgz", + "integrity": "sha512-JFRiqhKu+bvSkDLI+rUhJwSxQxYb759W5GBezE8Uc8mHLqC9aV/9aTC7yJSqCtB3F00pylrLCwnyS91Ap5ej4Q==", + "license": "MIT", + "dependencies": { + "@codemirror/language": "^6.0.0", + "@codemirror/state": "^6.6.0", + "@codemirror/view": "^6.27.0", + "@lezer/common": "^1.1.0" + } + }, + "node_modules/@codemirror/lang-css": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/@codemirror/lang-css/-/lang-css-6.3.1.tgz", + "integrity": "sha512-kr5fwBGiGtmz6l0LSJIbno9QrifNMUusivHbnA1H6Dmqy4HZFte3UAICix1VuKo0lMPKQr2rqB+0BkKi/S3Ejg==", + "license": "MIT", + "dependencies": { + "@codemirror/autocomplete": "^6.0.0", + "@codemirror/language": "^6.0.0", + "@codemirror/state": "^6.0.0", + "@lezer/common": "^1.0.2", + "@lezer/css": "^1.1.7" + } + }, + "node_modules/@codemirror/lang-html": { + "version": "6.4.11", + "resolved": "https://registry.npmjs.org/@codemirror/lang-html/-/lang-html-6.4.11.tgz", + "integrity": "sha512-9NsXp7Nwp891pQchI7gPdTwBuSuT3K65NGTHWHNJ55HjYcHLllr0rbIZNdOzas9ztc1EUVBlHou85FFZS4BNnw==", + "license": "MIT", + "dependencies": { + "@codemirror/autocomplete": "^6.0.0", + "@codemirror/lang-css": "^6.0.0", + "@codemirror/lang-javascript": "^6.0.0", + "@codemirror/language": "^6.4.0", + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.17.0", + "@lezer/common": "^1.0.0", + "@lezer/css": "^1.1.0", + "@lezer/html": "^1.3.12" + } + }, + "node_modules/@codemirror/lang-javascript": { + "version": "6.2.5", + "resolved": "https://registry.npmjs.org/@codemirror/lang-javascript/-/lang-javascript-6.2.5.tgz", + "integrity": "sha512-zD4e5mS+50htS7F+TYjBPsiIFGanfVqg4HyUz6WNFikgOPf2BgKlx+TQedI1w6n/IqRBVBbBWmGFdLB/7uxO4A==", + "license": "MIT", + "dependencies": { + "@codemirror/autocomplete": "^6.0.0", + "@codemirror/language": "^6.6.0", + "@codemirror/lint": "^6.0.0", + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.17.0", + "@lezer/common": "^1.0.0", + "@lezer/javascript": "^1.0.0" + } + }, + "node_modules/@codemirror/lang-json": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/@codemirror/lang-json/-/lang-json-6.0.2.tgz", + "integrity": "sha512-x2OtO+AvwEHrEwR0FyyPtfDUiloG3rnVTSZV1W8UteaLL8/MajQd8DpvUb2YVzC+/T18aSDv0H9mu+xw0EStoQ==", + "license": "MIT", + "dependencies": { + "@codemirror/language": "^6.0.0", + "@lezer/json": "^1.0.0" + } + }, + "node_modules/@codemirror/lang-markdown": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/@codemirror/lang-markdown/-/lang-markdown-6.5.0.tgz", + "integrity": "sha512-0K40bZ35jpHya6FriukbgaleaqzBLZfOh7HuzqbMxBXkbYMJDxfF39c23xOgxFezR+3G+tR2/Mup+Xk865OMvw==", + "license": "MIT", + "dependencies": { + "@codemirror/autocomplete": "^6.7.1", + "@codemirror/lang-html": "^6.0.0", + "@codemirror/language": "^6.3.0", + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.0.0", + "@lezer/common": "^1.2.1", + "@lezer/markdown": "^1.0.0" + } + }, + "node_modules/@codemirror/lang-python": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/@codemirror/lang-python/-/lang-python-6.2.1.tgz", + "integrity": "sha512-IRjC8RUBhn9mGR9ywecNhB51yePWCGgvHfY1lWN/Mrp3cKuHr0isDKia+9HnvhiWNnMpbGhWrkhuWOc09exRyw==", + "license": "MIT", + "dependencies": { + "@codemirror/autocomplete": "^6.3.2", + "@codemirror/language": "^6.8.0", + "@codemirror/state": "^6.0.0", + "@lezer/common": "^1.2.1", + "@lezer/python": "^1.1.4" + } + }, + "node_modules/@codemirror/language": { + "version": "6.12.2", + "resolved": "https://registry.npmjs.org/@codemirror/language/-/language-6.12.2.tgz", + "integrity": "sha512-jEPmz2nGGDxhRTg3lTpzmIyGKxz3Gp3SJES4b0nAuE5SWQoKdT5GoQ69cwMmFd+wvFUhYirtDTr0/DRHpQAyWg==", + "license": "MIT", + "dependencies": { + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.23.0", + "@lezer/common": "^1.5.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.0.0", + "style-mod": "^4.0.0" + } + }, + "node_modules/@codemirror/lint": { + "version": "6.9.5", + "resolved": "https://registry.npmjs.org/@codemirror/lint/-/lint-6.9.5.tgz", + "integrity": "sha512-GElsbU9G7QT9xXhpUg1zWGmftA/7jamh+7+ydKRuT0ORpWS3wOSP0yT1FOlIZa7mIJjpVPipErsyvVqB9cfTFA==", + "license": "MIT", + "dependencies": { + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.35.0", + "crelt": "^1.0.5" + } + }, + "node_modules/@codemirror/search": { + "version": "6.6.0", + "resolved": "https://registry.npmjs.org/@codemirror/search/-/search-6.6.0.tgz", + "integrity": "sha512-koFuNXcDvyyotWcgOnZGmY7LZqEOXZaaxD/j6n18TCLx2/9HieZJ5H6hs1g8FiRxBD0DNfs0nXn17g872RmYdw==", + "license": "MIT", + "dependencies": { + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.37.0", + "crelt": "^1.0.5" + } + }, + "node_modules/@codemirror/state": { + "version": "6.6.0", + "resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.6.0.tgz", + "integrity": "sha512-4nbvra5R5EtiCzr9BTHiTLc+MLXK2QGiAVYMyi8PkQd3SR+6ixar/Q/01Fa21TBIDOZXgeWV4WppsQolSreAPQ==", + "license": "MIT", + "dependencies": { + "@marijn/find-cluster-break": "^1.0.0" + } + }, + "node_modules/@codemirror/theme-one-dark": { + "version": "6.1.3", + "resolved": "https://registry.npmjs.org/@codemirror/theme-one-dark/-/theme-one-dark-6.1.3.tgz", + "integrity": "sha512-NzBdIvEJmx6fjeremiGp3t/okrLPYT0d9orIc7AFun8oZcRk58aejkqhv6spnz4MLAevrKNPMQYXEWMg4s+sKA==", + "license": "MIT", + "dependencies": { + "@codemirror/language": "^6.0.0", + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.0.0", + "@lezer/highlight": "^1.0.0" + } + }, + "node_modules/@codemirror/view": { + "version": "6.40.0", + "resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.40.0.tgz", + "integrity": "sha512-WA0zdU7xfF10+5I3HhUUq3kqOx3KjqmtQ9lqZjfK7jtYk4G72YW9rezcSywpaUMCWOMlq+6E0pO1IWg1TNIhtg==", + "license": "MIT", + "dependencies": { + "@codemirror/state": "^6.6.0", + "crelt": "^1.0.6", + "style-mod": "^4.1.0", + "w3c-keyname": "^2.2.4" + } + }, + "node_modules/@css-render/plugin-bem": { + "version": "0.15.14", + "resolved": "https://registry.npmjs.org/@css-render/plugin-bem/-/plugin-bem-0.15.14.tgz", + "integrity": "sha512-QK513CJ7yEQxm/P3EwsI+d+ha8kSOcjGvD6SevM41neEMxdULE+18iuQK6tEChAWMOQNQPLG/Rw3Khb69r5neg==", + "license": "MIT", + "peerDependencies": { + "css-render": "~0.15.14" + } + }, + "node_modules/@css-render/vue3-ssr": { + "version": "0.15.14", + "resolved": "https://registry.npmjs.org/@css-render/vue3-ssr/-/vue3-ssr-0.15.14.tgz", + "integrity": "sha512-//8027GSbxE9n3QlD73xFY6z4ZbHbvrOVB7AO6hsmrEzGbg+h2A09HboUyDgu+xsmj7JnvJD39Irt+2D0+iV8g==", + "license": "MIT", + "peerDependencies": { + "vue": "^3.0.11" + } + }, + "node_modules/@emotion/hash": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/@emotion/hash/-/hash-0.8.0.tgz", + "integrity": "sha512-kBJtf7PH6aWwZ6fka3zQ0p6SBYzx4fl1LoZXE2RrnYST9Xljm7WfKJrU4g/Xr3Beg72MLrp1AWNUmuYJTL7Cow==", + "license": "MIT" + }, + "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, + "license": "MIT", + "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, + "license": "MIT", + "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, + "license": "MIT", + "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, + "license": "MIT", + "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, + "license": "MIT", + "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, + "license": "MIT", + "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, + "license": "MIT", + "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, + "license": "MIT", + "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, + "license": "MIT", + "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, + "license": "MIT", + "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, + "license": "MIT", + "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, + "license": "MIT", + "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, + "license": "MIT", + "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, + "license": "MIT", + "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, + "license": "MIT", + "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, + "license": "MIT", + "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, + "license": "MIT", + "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, + "license": "MIT", + "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, + "license": "MIT", + "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, + "license": "MIT", + "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, + "license": "MIT", + "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, + "license": "MIT", + "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, + "license": "MIT", + "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, + "license": "MIT", + "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, + "license": "MIT", + "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, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "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, + "license": "MIT", + "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, + "license": "MIT", + "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, + "license": "MIT", + "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==", + "license": "MIT" + }, + "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, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@juggle/resize-observer": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/@juggle/resize-observer/-/resize-observer-3.4.0.tgz", + "integrity": "sha512-dfLbk+PwWvFzSxwk3n5ySL0hfBog779o8h68wK/7/APo/7cgyWp5jcXockbxdk5kFRkbeXWm4Fbi9FrdN381sA==", + "license": "Apache-2.0" + }, + "node_modules/@lezer/common": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@lezer/common/-/common-1.5.1.tgz", + "integrity": "sha512-6YRVG9vBkaY7p1IVxL4s44n5nUnaNnGM2/AckNgYOnxTG2kWh1vR8BMxPseWPjRNpb5VtXnMpeYAEAADoRV1Iw==", + "license": "MIT" + }, + "node_modules/@lezer/css": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@lezer/css/-/css-1.3.1.tgz", + "integrity": "sha512-PYAKeUVBo3HFThruRyp/iK91SwiZJnzXh8QzkQlwijB5y+N5iB28+iLk78o2zmKqqV0uolNhCwFqB8LA7b0Svg==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.2.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.3.0" + } + }, + "node_modules/@lezer/highlight": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@lezer/highlight/-/highlight-1.2.3.tgz", + "integrity": "sha512-qXdH7UqTvGfdVBINrgKhDsVTJTxactNNxLk7+UMwZhU13lMHaOBlJe9Vqp907ya56Y3+ed2tlqzys7jDkTmW0g==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.3.0" + } + }, + "node_modules/@lezer/html": { + "version": "1.3.13", + "resolved": "https://registry.npmjs.org/@lezer/html/-/html-1.3.13.tgz", + "integrity": "sha512-oI7n6NJml729m7pjm9lvLvmXbdoMoi2f+1pwSDJkl9d68zGr7a9Btz8NdHTGQZtW2DA25ybeuv/SyDb9D5tseg==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.2.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.0.0" + } + }, + "node_modules/@lezer/javascript": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/@lezer/javascript/-/javascript-1.5.4.tgz", + "integrity": "sha512-vvYx3MhWqeZtGPwDStM2dwgljd5smolYD2lR2UyFcHfxbBQebqx8yjmFmxtJ/E6nN6u1D9srOiVWm3Rb4tmcUA==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.2.0", + "@lezer/highlight": "^1.1.3", + "@lezer/lr": "^1.3.0" + } + }, + "node_modules/@lezer/json": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@lezer/json/-/json-1.0.3.tgz", + "integrity": "sha512-BP9KzdF9Y35PDpv04r0VeSTKDeox5vVr3efE7eBbx3r4s3oNLfunchejZhjArmeieBH+nVOpgIiBJpEAv8ilqQ==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.2.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.0.0" + } + }, + "node_modules/@lezer/lr": { + "version": "1.4.8", + "resolved": "https://registry.npmjs.org/@lezer/lr/-/lr-1.4.8.tgz", + "integrity": "sha512-bPWa0Pgx69ylNlMlPvBPryqeLYQjyJjqPx+Aupm5zydLIF3NE+6MMLT8Yi23Bd9cif9VS00aUebn+6fDIGBcDA==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.0.0" + } + }, + "node_modules/@lezer/markdown": { + "version": "1.6.3", + "resolved": "https://registry.npmjs.org/@lezer/markdown/-/markdown-1.6.3.tgz", + "integrity": "sha512-jpGm5Ps+XErS+xA4urw7ogEGkeZOahVQF21Z6oECF0sj+2liwZopd2+I8uH5I/vZsRuuze3OxBREIANLf6KKUw==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.5.0", + "@lezer/highlight": "^1.0.0" + } + }, + "node_modules/@lezer/python": { + "version": "1.1.18", + "resolved": "https://registry.npmjs.org/@lezer/python/-/python-1.1.18.tgz", + "integrity": "sha512-31FiUrU7z9+d/ElGQLJFXl+dKOdx0jALlP3KEOsGTex8mvj+SoE1FgItcHWK/axkxCHGUSpqIHt6JAWfWu9Rhg==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.2.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.0.0" + } + }, + "node_modules/@marijn/find-cluster-break": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@marijn/find-cluster-break/-/find-cluster-break-1.0.2.tgz", + "integrity": "sha512-l0h88YhZFyKdXIFNfSWpyjStDjGHwZ/U7iobcK1cQQD8sejsONdQtTVU+1wVN1PBw40PiiHB1vA5S7VTfQiP9g==", + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.59.0.tgz", + "integrity": "sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.59.0.tgz", + "integrity": "sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.59.0.tgz", + "integrity": "sha512-W2Psnbh1J8ZJw0xKAd8zdNgF9HRLkdWwwdWqubSVk0pUuQkoHnv7rx4GiF9rT4t5DIZGAsConRE3AxCdJ4m8rg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.59.0.tgz", + "integrity": "sha512-ZW2KkwlS4lwTv7ZVsYDiARfFCnSGhzYPdiOU4IM2fDbL+QGlyAbjgSFuqNRbSthybLbIJ915UtZBtmuLrQAT/w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.59.0.tgz", + "integrity": "sha512-EsKaJ5ytAu9jI3lonzn3BgG8iRBjV4LxZexygcQbpiU0wU0ATxhNVEpXKfUa0pS05gTcSDMKpn3Sx+QB9RlTTA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.59.0.tgz", + "integrity": "sha512-d3DuZi2KzTMjImrxoHIAODUZYoUUMsuUiY4SRRcJy6NJoZ6iIqWnJu9IScV9jXysyGMVuW+KNzZvBLOcpdl3Vg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.59.0.tgz", + "integrity": "sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.59.0.tgz", + "integrity": "sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.59.0.tgz", + "integrity": "sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.59.0.tgz", + "integrity": "sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.59.0.tgz", + "integrity": "sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.59.0.tgz", + "integrity": "sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.59.0.tgz", + "integrity": "sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.59.0.tgz", + "integrity": "sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.59.0.tgz", + "integrity": "sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.59.0.tgz", + "integrity": "sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.59.0.tgz", + "integrity": "sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.59.0.tgz", + "integrity": "sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.59.0.tgz", + "integrity": "sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.59.0.tgz", + "integrity": "sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.59.0.tgz", + "integrity": "sha512-tt9KBJqaqp5i5HUZzoafHZX8b5Q2Fe7UjYERADll83O4fGqJ49O1FsL6LpdzVFQcpwvnyd0i+K/VSwu/o/nWlA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.59.0.tgz", + "integrity": "sha512-V5B6mG7OrGTwnxaNUzZTDTjDS7F75PO1ae6MJYdiMu60sq0CqN5CVeVsbhPxalupvTX8gXVSU9gq+Rx1/hvu6A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.59.0.tgz", + "integrity": "sha512-UKFMHPuM9R0iBegwzKF4y0C4J9u8C6MEJgFuXTBerMk7EJ92GFVFYBfOZaSGLu6COf7FxpQNqhNS4c4icUPqxA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.59.0.tgz", + "integrity": "sha512-laBkYlSS1n2L8fSo1thDNGrCTQMmxjYY5G0WFWjFFYZkKPjsMBsgJfGf4TLxXrF6RyhI60L8TMOjBMvXiTcxeA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.59.0.tgz", + "integrity": "sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@tailwindcss/node": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.2.1.tgz", + "integrity": "sha512-jlx6sLk4EOwO6hHe1oCGm1Q4AN/s0rSrTTPBGPM0/RQ6Uylwq17FuU8IeJJKEjtc6K6O07zsvP+gDO6MMWo7pg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.5", + "enhanced-resolve": "^5.19.0", + "jiti": "^2.6.1", + "lightningcss": "1.31.1", + "magic-string": "^0.30.21", + "source-map-js": "^1.2.1", + "tailwindcss": "4.2.1" + } + }, + "node_modules/@tailwindcss/oxide": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.2.1.tgz", + "integrity": "sha512-yv9jeEFWnjKCI6/T3Oq50yQEOqmpmpfzG1hcZsAOaXFQPfzWprWrlHSdGPEF3WQTi8zu8ohC9Mh9J470nT5pUw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 20" + }, + "optionalDependencies": { + "@tailwindcss/oxide-android-arm64": "4.2.1", + "@tailwindcss/oxide-darwin-arm64": "4.2.1", + "@tailwindcss/oxide-darwin-x64": "4.2.1", + "@tailwindcss/oxide-freebsd-x64": "4.2.1", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.2.1", + "@tailwindcss/oxide-linux-arm64-gnu": "4.2.1", + "@tailwindcss/oxide-linux-arm64-musl": "4.2.1", + "@tailwindcss/oxide-linux-x64-gnu": "4.2.1", + "@tailwindcss/oxide-linux-x64-musl": "4.2.1", + "@tailwindcss/oxide-wasm32-wasi": "4.2.1", + "@tailwindcss/oxide-win32-arm64-msvc": "4.2.1", + "@tailwindcss/oxide-win32-x64-msvc": "4.2.1" + } + }, + "node_modules/@tailwindcss/oxide-android-arm64": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.2.1.tgz", + "integrity": "sha512-eZ7G1Zm5EC8OOKaesIKuw77jw++QJ2lL9N+dDpdQiAB/c/B2wDh0QPFHbkBVrXnwNugvrbJFk1gK2SsVjwWReg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-arm64": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.2.1.tgz", + "integrity": "sha512-q/LHkOstoJ7pI1J0q6djesLzRvQSIfEto148ppAd+BVQK0JYjQIFSK3JgYZJa+Yzi0DDa52ZsQx2rqytBnf8Hw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-x64": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.2.1.tgz", + "integrity": "sha512-/f/ozlaXGY6QLbpvd/kFTro2l18f7dHKpB+ieXz+Cijl4Mt9AI2rTrpq7V+t04nK+j9XBQHnSMdeQRhbGyt6fw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-freebsd-x64": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.2.1.tgz", + "integrity": "sha512-5e/AkgYJT/cpbkys/OU2Ei2jdETCLlifwm7ogMC7/hksI2fC3iiq6OcXwjibcIjPung0kRtR3TxEITkqgn0TcA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.2.1.tgz", + "integrity": "sha512-Uny1EcVTTmerCKt/1ZuKTkb0x8ZaiuYucg2/kImO5A5Y/kBz41/+j0gxUZl+hTF3xkWpDmHX+TaWhOtba2Fyuw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.2.1.tgz", + "integrity": "sha512-CTrwomI+c7n6aSSQlsPL0roRiNMDQ/YzMD9EjcR+H4f0I1SQ8QqIuPnsVp7QgMkC1Qi8rtkekLkOFjo7OlEFRQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-musl": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.2.1.tgz", + "integrity": "sha512-WZA0CHRL/SP1TRbA5mp9htsppSEkWuQ4KsSUumYQnyl8ZdT39ntwqmz4IUHGN6p4XdSlYfJwM4rRzZLShHsGAQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-gnu": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.2.1.tgz", + "integrity": "sha512-qMFzxI2YlBOLW5PhblzuSWlWfwLHaneBE0xHzLrBgNtqN6mWfs+qYbhryGSXQjFYB1Dzf5w+LN5qbUTPhW7Y5g==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-musl": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.2.1.tgz", + "integrity": "sha512-5r1X2FKnCMUPlXTWRYpHdPYUY6a1Ar/t7P24OuiEdEOmms5lyqjDRvVY1yy9Rmioh+AunQ0rWiOTPE8F9A3v5g==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.2.1.tgz", + "integrity": "sha512-MGFB5cVPvshR85MTJkEvqDUnuNoysrsRxd6vnk1Lf2tbiqNlXpHYZqkqOQalydienEWOHHFyyuTSYRsLfxFJ2Q==", + "bundleDependencies": [ + "@napi-rs/wasm-runtime", + "@emnapi/core", + "@emnapi/runtime", + "@tybys/wasm-util", + "@emnapi/wasi-threads", + "tslib" + ], + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.8.1", + "@emnapi/runtime": "^1.8.1", + "@emnapi/wasi-threads": "^1.1.0", + "@napi-rs/wasm-runtime": "^1.1.1", + "@tybys/wasm-util": "^0.10.1", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.2.1.tgz", + "integrity": "sha512-YlUEHRHBGnCMh4Nj4GnqQyBtsshUPdiNroZj8VPkvTZSoHsilRCwXcVKnG9kyi0ZFAS/3u+qKHBdDc81SADTRA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-win32-x64-msvc": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.2.1.tgz", + "integrity": "sha512-rbO34G5sMWWyrN/idLeVxAZgAKWrn5LiR3/I90Q9MkA67s6T1oB0xtTe+0heoBvHSpbU9Mk7i6uwJnpo4u21XQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/vite": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.2.1.tgz", + "integrity": "sha512-TBf2sJjYeb28jD2U/OhwdW0bbOsxkWPwQ7SrqGf9sVcoYwZj7rkXljroBO9wKBut9XnmQLXanuDUeqQK0lGg/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@tailwindcss/node": "4.2.1", + "@tailwindcss/oxide": "4.2.1", + "tailwindcss": "4.2.1" + }, + "peerDependencies": { + "vite": "^5.2.0 || ^6 || ^7" + } + }, + "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, + "license": "MIT" + }, + "node_modules/@types/lodash": { + "version": "4.17.24", + "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.24.tgz", + "integrity": "sha512-gIW7lQLZbue7lRSWEFql49QJJWThrTFFeIMJdp3eH4tKoxm1OvEPg02rm4wCCSHS0cL3/Fizimb35b7k8atwsQ==", + "license": "MIT" + }, + "node_modules/@types/lodash-es": { + "version": "4.17.12", + "resolved": "https://registry.npmjs.org/@types/lodash-es/-/lodash-es-4.17.12.tgz", + "integrity": "sha512-0NgftHUcV4v34VhXm8QBSftKVXtbkBG3ViCjs6+eJ5a6y6Mi/jiFGPc1sC7QK+9BFhWrURE3EOggmWaSxL9OzQ==", + "license": "MIT", + "dependencies": { + "@types/lodash": "*" + } + }, + "node_modules/@vitejs/plugin-vue": { + "version": "5.2.4", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-5.2.4.tgz", + "integrity": "sha512-7Yx/SXSOcQq5HiiV3orevHUFn+pmMB4cgbEkDYgnkUWb0WfeQ/wa2yFv6D5ICiCQOVpjA7vYDXrC7AGO8yjDHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "peerDependencies": { + "vite": "^5.0.0 || ^6.0.0", + "vue": "^3.2.25" + } + }, + "node_modules/@volar/language-core": { + "version": "2.4.15", + "resolved": "https://registry.npmjs.org/@volar/language-core/-/language-core-2.4.15.tgz", + "integrity": "sha512-3VHw+QZU0ZG9IuQmzT68IyN4hZNd9GchGPhbD9+pa8CVv7rnoOZwo7T8weIbrRmihqy3ATpdfXFnqRrfPVK6CA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/source-map": "2.4.15" + } + }, + "node_modules/@volar/source-map": { + "version": "2.4.15", + "resolved": "https://registry.npmjs.org/@volar/source-map/-/source-map-2.4.15.tgz", + "integrity": "sha512-CPbMWlUN6hVZJYGcU/GSoHu4EnCHiLaXI9n8c9la6RaI9W5JHX+NqG+GSQcB0JdC2FIBLdZJwGsfKyBB71VlTg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@volar/typescript": { + "version": "2.4.15", + "resolved": "https://registry.npmjs.org/@volar/typescript/-/typescript-2.4.15.tgz", + "integrity": "sha512-2aZ8i0cqPGjXb4BhkMsPYDkkuc2ZQ6yOpqwAuNwUoncELqoy5fRgOQtLR9gB0g902iS0NAkvpIzs27geVyVdPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/language-core": "2.4.15", + "path-browserify": "^1.0.1", + "vscode-uri": "^3.0.8" + } + }, + "node_modules/@vue/compiler-core": { + "version": "3.5.30", + "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.30.tgz", + "integrity": "sha512-s3DfdZkcu/qExZ+td75015ljzHc6vE+30cFMGRPROYjqkroYI5NV2X1yAMX9UeyBNWB9MxCfPcsjpLS11nzkkw==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.0", + "@vue/shared": "3.5.30", + "entities": "^7.0.1", + "estree-walker": "^2.0.2", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-dom": { + "version": "3.5.30", + "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.30.tgz", + "integrity": "sha512-eCFYESUEVYHhiMuK4SQTldO3RYxyMR/UQL4KdGD1Yrkfdx4m/HYuZ9jSfPdA+nWJY34VWndiYdW/wZXyiPEB9g==", + "license": "MIT", + "dependencies": { + "@vue/compiler-core": "3.5.30", + "@vue/shared": "3.5.30" + } + }, + "node_modules/@vue/compiler-sfc": { + "version": "3.5.30", + "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.30.tgz", + "integrity": "sha512-LqmFPDn89dtU9vI3wHJnwaV6GfTRD87AjWpTWpyrdVOObVtjIuSeZr181z5C4PmVx/V3j2p+0f7edFKGRMpQ5A==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.0", + "@vue/compiler-core": "3.5.30", + "@vue/compiler-dom": "3.5.30", + "@vue/compiler-ssr": "3.5.30", + "@vue/shared": "3.5.30", + "estree-walker": "^2.0.2", + "magic-string": "^0.30.21", + "postcss": "^8.5.8", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-ssr": { + "version": "3.5.30", + "resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.30.tgz", + "integrity": "sha512-NsYK6OMTnx109PSL2IAyf62JP6EUdk4Dmj6AkWcJGBvN0dQoMYtVekAmdqgTtWQgEJo+Okstbf/1p7qZr5H+bA==", + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.30", + "@vue/shared": "3.5.30" + } + }, + "node_modules/@vue/compiler-vue2": { + "version": "2.7.16", + "resolved": "https://registry.npmjs.org/@vue/compiler-vue2/-/compiler-vue2-2.7.16.tgz", + "integrity": "sha512-qYC3Psj9S/mfu9uVi5WvNZIzq+xnXMhOwbTFKKDD7b1lhpnn71jXSFdTQ+WsIEk0ONCd7VV2IMm7ONl6tbQ86A==", + "dev": true, + "license": "MIT", + "dependencies": { + "de-indent": "^1.0.2", + "he": "^1.2.0" + } + }, + "node_modules/@vue/devtools-api": { + "version": "6.6.4", + "resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-6.6.4.tgz", + "integrity": "sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==", + "license": "MIT" + }, + "node_modules/@vue/language-core": { + "version": "2.2.12", + "resolved": "https://registry.npmjs.org/@vue/language-core/-/language-core-2.2.12.tgz", + "integrity": "sha512-IsGljWbKGU1MZpBPN+BvPAdr55YPkj2nB/TBNGNC32Vy2qLG25DYu/NBN2vNtZqdRbTRjaoYrahLrToim2NanA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/language-core": "2.4.15", + "@vue/compiler-dom": "^3.5.0", + "@vue/compiler-vue2": "^2.7.16", + "@vue/shared": "^3.5.0", + "alien-signals": "^1.0.3", + "minimatch": "^9.0.3", + "muggle-string": "^0.4.1", + "path-browserify": "^1.0.1" + }, + "peerDependencies": { + "typescript": "*" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@vue/reactivity": { + "version": "3.5.30", + "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.30.tgz", + "integrity": "sha512-179YNgKATuwj9gB+66snskRDOitDiuOZqkYia7mHKJaidOMo/WJxHKF8DuGc4V4XbYTJANlfEKb0yxTQotnx4Q==", + "license": "MIT", + "dependencies": { + "@vue/shared": "3.5.30" + } + }, + "node_modules/@vue/runtime-core": { + "version": "3.5.30", + "resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.30.tgz", + "integrity": "sha512-e0Z+8PQsUTdwV8TtEsLzUM7SzC7lQwYKePydb7K2ZnmS6jjND+WJXkmmfh/swYzRyfP1EY3fpdesyYoymCzYfg==", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.30", + "@vue/shared": "3.5.30" + } + }, + "node_modules/@vue/runtime-dom": { + "version": "3.5.30", + "resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.30.tgz", + "integrity": "sha512-2UIGakjU4WSQ0T4iwDEW0W7vQj6n7AFn7taqZ9Cvm0Q/RA2FFOziLESrDL4GmtI1wV3jXg5nMoJSYO66egDUBw==", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.30", + "@vue/runtime-core": "3.5.30", + "@vue/shared": "3.5.30", + "csstype": "^3.2.3" + } + }, + "node_modules/@vue/server-renderer": { + "version": "3.5.30", + "resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.30.tgz", + "integrity": "sha512-v+R34icapydRwbZRD0sXwtHqrQJv38JuMB4JxbOxd8NEpGLny7cncMp53W9UH/zo4j8eDHjQ1dEJXwzFQknjtQ==", + "license": "MIT", + "dependencies": { + "@vue/compiler-ssr": "3.5.30", + "@vue/shared": "3.5.30" + }, + "peerDependencies": { + "vue": "3.5.30" + } + }, + "node_modules/@vue/shared": { + "version": "3.5.30", + "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.30.tgz", + "integrity": "sha512-YXgQ7JjaO18NeK2K9VTbDHaFy62WrObMa6XERNfNOkAhD1F1oDSf3ZJ7K6GqabZ0BvSDHajp8qfS5Sa2I9n8uQ==", + "license": "MIT" + }, + "node_modules/@wailsio/runtime": { + "version": "3.0.0-alpha.79", + "resolved": "https://registry.npmjs.org/@wailsio/runtime/-/runtime-3.0.0-alpha.79.tgz", + "integrity": "sha512-NITzxKmJsMEruc39L166lbPJVECxzcbdqpHVqOOF7Cu/7Zqk/e3B/gNpkUjhNyo5rVb3V1wpS8oEgLUmpu1cwA==", + "license": "MIT" + }, + "node_modules/@xterm/addon-fit": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@xterm/addon-fit/-/addon-fit-0.11.0.tgz", + "integrity": "sha512-jYcgT6xtVYhnhgxh3QgYDnnNMYTcf8ElbxxFzX0IZo+vabQqSPAjC3c1wJrKB5E19VwQei89QCiZZP86DCPF7g==", + "license": "MIT" + }, + "node_modules/@xterm/addon-search": { + "version": "0.16.0", + "resolved": "https://registry.npmjs.org/@xterm/addon-search/-/addon-search-0.16.0.tgz", + "integrity": "sha512-9OeuBFu0/uZJPu+9AHKY6g/w0Czyb/Ut0A5t79I4ULoU4IfU5BEpPFVGQxP4zTTMdfZEYkVIRYbHBX1xWwjeSA==", + "license": "MIT" + }, + "node_modules/@xterm/addon-web-links": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/@xterm/addon-web-links/-/addon-web-links-0.12.0.tgz", + "integrity": "sha512-4Smom3RPyVp7ZMYOYDoC/9eGJJJqYhnPLGGqJ6wOBfB8VxPViJNSKdgRYb8NpaM6YSelEKbA2SStD7lGyqaobw==", + "license": "MIT" + }, + "node_modules/@xterm/addon-webgl": { + "version": "0.19.0", + "resolved": "https://registry.npmjs.org/@xterm/addon-webgl/-/addon-webgl-0.19.0.tgz", + "integrity": "sha512-b3fMOsyLVuCeNJWxolACEUED0vm7qC0cy4wRvf3oURSzDTYVQiGPhTnhWZwIHdvC48Y+oLhvYXnY4XDXPoJo6A==", + "license": "MIT" + }, + "node_modules/@xterm/xterm": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-6.0.0.tgz", + "integrity": "sha512-TQwDdQGtwwDt+2cgKDLn0IRaSxYu1tSUjgKarSDkUM0ZNiSRXFpjxEsvc/Zgc5kq5omJ+V0a8/kIM2WD3sMOYg==", + "license": "MIT", + "workspaces": [ + "addons/*" + ] + }, + "node_modules/alien-signals": { + "version": "1.0.13", + "resolved": "https://registry.npmjs.org/alien-signals/-/alien-signals-1.0.13.tgz", + "integrity": "sha512-OGj9yyTnJEttvzhTUWuscOvtqxq5vrhF7vL9oS0xJ2mK0ItPYP1/y+vCFebfxoEyAz0++1AIwJ5CMr+Fk3nDmg==", + "dev": true, + "license": "MIT" + }, + "node_modules/async-validator": { + "version": "4.2.5", + "resolved": "https://registry.npmjs.org/async-validator/-/async-validator-4.2.5.tgz", + "integrity": "sha512-7HhHjtERjqlNbZtqNqy2rckN/SpOOlmDliet+lP7k+eKZEjPk3DgyeU9lIXLdeLz0uBbbVp+9Qdow9wJWgwwfg==", + "license": "MIT" + }, + "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, + "license": "MIT" + }, + "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, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/codemirror": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/codemirror/-/codemirror-6.0.2.tgz", + "integrity": "sha512-VhydHotNW5w1UGK0Qj96BwSk/Zqbp9WbnyK2W/eVMv4QyF41INRGpjUhFJY7/uDNuudSc33a/PKr4iDqRduvHw==", + "license": "MIT", + "dependencies": { + "@codemirror/autocomplete": "^6.0.0", + "@codemirror/commands": "^6.0.0", + "@codemirror/language": "^6.0.0", + "@codemirror/lint": "^6.0.0", + "@codemirror/search": "^6.0.0", + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.0.0" + } + }, + "node_modules/crelt": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.6.tgz", + "integrity": "sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==", + "license": "MIT" + }, + "node_modules/css-render": { + "version": "0.15.14", + "resolved": "https://registry.npmjs.org/css-render/-/css-render-0.15.14.tgz", + "integrity": "sha512-9nF4PdUle+5ta4W5SyZdLCCmFd37uVimSjg1evcTqKJCyvCEEj12WKzOSBNak6r4im4J4iYXKH1OWpUV5LBYFg==", + "license": "MIT", + "dependencies": { + "@emotion/hash": "~0.8.0", + "csstype": "~3.0.5" + } + }, + "node_modules/css-render/node_modules/csstype": { + "version": "3.0.11", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.0.11.tgz", + "integrity": "sha512-sa6P2wJ+CAbgyy4KFssIb/JNMLxFvKF1pCYCSXS8ZMuqZnMsrxqI2E5sPyoTpxoPU/gVZMzr2zjOfg8GIZOMsw==", + "license": "MIT" + }, + "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==", + "license": "MIT" + }, + "node_modules/date-fns": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz", + "integrity": "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/kossnocorp" + } + }, + "node_modules/date-fns-tz": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/date-fns-tz/-/date-fns-tz-3.2.0.tgz", + "integrity": "sha512-sg8HqoTEulcbbbVXeg84u5UnlsQa8GS5QXMqjjYIhS4abEVVKIUwe0/l/UhrZdKaL/W5eWZNlbTeEIiOXTcsBQ==", + "license": "MIT", + "peerDependencies": { + "date-fns": "^3.0.0 || ^4.0.0" + } + }, + "node_modules/de-indent": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/de-indent/-/de-indent-1.0.2.tgz", + "integrity": "sha512-e/1zu3xH5MQryN2zdVaF0OrdNLUbvWxzMbi+iNA6Bky7l1RoP8a2fIbRocyHclXt/arDrrR6lL3TqFD9pMQTsg==", + "dev": true, + "license": "MIT" + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/enhanced-resolve": { + "version": "5.20.1", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.20.1.tgz", + "integrity": "sha512-Qohcme7V1inbAfvjItgw0EaxVX5q2rdVEZHRBrEQdRZTssLDGsL8Lwrznl8oQ/6kuTJONLaDcGjkNP247XEhcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.3.0" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/entities": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz", + "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "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, + "license": "MIT", + "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/estree-walker": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", + "license": "MIT" + }, + "node_modules/evtd": { + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/evtd/-/evtd-0.2.4.tgz", + "integrity": "sha512-qaeGN5bx63s/AXgQo8gj6fBkxge+OoLddLniox5qtLAEY5HSnuSlISXVPxnSae1dWblvTh4/HoMIB+mbMsvZzw==", + "license": "MIT" + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/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, + "license": "ISC" + }, + "node_modules/he": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz", + "integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==", + "dev": true, + "license": "MIT", + "bin": { + "he": "bin/he" + } + }, + "node_modules/highlight.js": { + "version": "11.11.1", + "resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-11.11.1.tgz", + "integrity": "sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=12.0.0" + } + }, + "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, + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/lightningcss": { + "version": "1.31.1", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.31.1.tgz", + "integrity": "sha512-l51N2r93WmGUye3WuFoN5k10zyvrVs0qfKBhyC5ogUQ6Ew6JUSswh78mbSO+IU3nTWsyOArqPCcShdQSadghBQ==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.31.1", + "lightningcss-darwin-arm64": "1.31.1", + "lightningcss-darwin-x64": "1.31.1", + "lightningcss-freebsd-x64": "1.31.1", + "lightningcss-linux-arm-gnueabihf": "1.31.1", + "lightningcss-linux-arm64-gnu": "1.31.1", + "lightningcss-linux-arm64-musl": "1.31.1", + "lightningcss-linux-x64-gnu": "1.31.1", + "lightningcss-linux-x64-musl": "1.31.1", + "lightningcss-win32-arm64-msvc": "1.31.1", + "lightningcss-win32-x64-msvc": "1.31.1" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.31.1", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.31.1.tgz", + "integrity": "sha512-HXJF3x8w9nQ4jbXRiNppBCqeZPIAfUo8zE/kOEGbW5NZvGc/K7nMxbhIr+YlFlHW5mpbg/YFPdbnCh1wAXCKFg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.31.1", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.31.1.tgz", + "integrity": "sha512-02uTEqf3vIfNMq3h/z2cJfcOXnQ0GRwQrkmPafhueLb2h7mqEidiCzkE4gBMEH65abHRiQvhdcQ+aP0D0g67sg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.31.1", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.31.1.tgz", + "integrity": "sha512-1ObhyoCY+tGxtsz1lSx5NXCj3nirk0Y0kB/g8B8DT+sSx4G9djitg9ejFnjb3gJNWo7qXH4DIy2SUHvpoFwfTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.31.1", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.31.1.tgz", + "integrity": "sha512-1RINmQKAItO6ISxYgPwszQE1BrsVU5aB45ho6O42mu96UiZBxEXsuQ7cJW4zs4CEodPUioj/QrXW1r9pLUM74A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "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.31.1", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.31.1.tgz", + "integrity": "sha512-OOCm2//MZJ87CdDK62rZIu+aw9gBv4azMJuA8/KB74wmfS3lnC4yoPHm0uXZ/dvNNHmnZnB8XLAZzObeG0nS1g==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "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.31.1", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.31.1.tgz", + "integrity": "sha512-WKyLWztD71rTnou4xAD5kQT+982wvca7E6QoLpoawZ1gP9JM0GJj4Tp5jMUh9B3AitHbRZ2/H3W5xQmdEOUlLg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "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.31.1", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.31.1.tgz", + "integrity": "sha512-mVZ7Pg2zIbe3XlNbZJdjs86YViQFoJSpc41CbVmKBPiGmC4YrfeOyz65ms2qpAobVd7WQsbW4PdsSJEMymyIMg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "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.31.1", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.31.1.tgz", + "integrity": "sha512-xGlFWRMl+0KvUhgySdIaReQdB4FNudfUTARn7q0hh/V67PVGCs3ADFjw+6++kG1RNd0zdGRlEKa+T13/tQjPMA==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "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.31.1", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.31.1.tgz", + "integrity": "sha512-eowF8PrKHw9LpoZii5tdZwnBcYDxRw2rRCyvAXLi34iyeYfqCQNA9rmUM0ce62NlPhCvof1+9ivRaTY6pSKDaA==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "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.31.1", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.31.1.tgz", + "integrity": "sha512-aJReEbSEQzx1uBlQizAOBSjcmr9dCdL3XuC/6HLXAxmtErsj2ICo5yYggg1qOODQMtnjNQv2UHb9NpOuFtYe4w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "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.31.1", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.31.1.tgz", + "integrity": "sha512-I9aiFrbd7oYHwlnQDqr1Roz+fTz61oDDJX7n9tYF9FJymH1cIN1DtKw3iYt6b8WZgEjoNwVSncwF4wx/ZedMhw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lodash": { + "version": "4.17.23", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz", + "integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==", + "license": "MIT" + }, + "node_modules/lodash-es": { + "version": "4.17.23", + "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.23.tgz", + "integrity": "sha512-kVI48u3PZr38HdYz98UmfPnXl2DXrpdctLrFLCd3kOx1xUkOmpFPx7gCWWM5MPkL/fD8zb+Ph0QzjGFs4+hHWg==", + "license": "MIT" + }, + "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==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/minimatch": { + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.2" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/muggle-string": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/muggle-string/-/muggle-string-0.4.1.tgz", + "integrity": "sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/naive-ui": { + "version": "2.44.1", + "resolved": "https://registry.npmjs.org/naive-ui/-/naive-ui-2.44.1.tgz", + "integrity": "sha512-reo8Esw0p58liZwbUutC7meW24Xbn3EwNv91zReWKm2W4JPu+zfgJRn/F7aO0BFmvN+h2brA2M5lRvYqLq4kuA==", + "license": "MIT", + "dependencies": { + "@css-render/plugin-bem": "^0.15.14", + "@css-render/vue3-ssr": "^0.15.14", + "@types/lodash": "^4.17.20", + "@types/lodash-es": "^4.17.12", + "async-validator": "^4.2.5", + "css-render": "^0.15.14", + "csstype": "^3.1.3", + "date-fns": "^4.1.0", + "date-fns-tz": "^3.2.0", + "evtd": "^0.2.4", + "highlight.js": "^11.8.0", + "lodash": "^4.17.21", + "lodash-es": "^4.17.21", + "seemly": "^0.3.10", + "treemate": "^0.3.11", + "vdirs": "^0.1.8", + "vooks": "^0.2.12", + "vueuc": "^0.4.65" + }, + "engines": { + "node": ">=20" + }, + "peerDependencies": { + "vue": "^3.0.0" + } + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/path-browserify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-browserify/-/path-browserify-1.0.1.tgz", + "integrity": "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pinia": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/pinia/-/pinia-2.3.1.tgz", + "integrity": "sha512-khUlZSwt9xXCaTbbxFYBKDc/bWAGWJjOgvxETwkTN7KRm66EeT1ZdZj6i2ceh9sP2Pzqsbc704r2yngBrxBVug==", + "license": "MIT", + "dependencies": { + "@vue/devtools-api": "^6.6.3", + "vue-demi": "^0.14.10" + }, + "funding": { + "url": "https://github.com/sponsors/posva" + }, + "peerDependencies": { + "typescript": ">=4.4.4", + "vue": "^2.7.0 || ^3.5.11" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/postcss": { + "version": "8.5.8", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz", + "integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==", + "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" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/rollup": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz", + "integrity": "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==", + "dev": true, + "license": "MIT", + "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.59.0", + "@rollup/rollup-android-arm64": "4.59.0", + "@rollup/rollup-darwin-arm64": "4.59.0", + "@rollup/rollup-darwin-x64": "4.59.0", + "@rollup/rollup-freebsd-arm64": "4.59.0", + "@rollup/rollup-freebsd-x64": "4.59.0", + "@rollup/rollup-linux-arm-gnueabihf": "4.59.0", + "@rollup/rollup-linux-arm-musleabihf": "4.59.0", + "@rollup/rollup-linux-arm64-gnu": "4.59.0", + "@rollup/rollup-linux-arm64-musl": "4.59.0", + "@rollup/rollup-linux-loong64-gnu": "4.59.0", + "@rollup/rollup-linux-loong64-musl": "4.59.0", + "@rollup/rollup-linux-ppc64-gnu": "4.59.0", + "@rollup/rollup-linux-ppc64-musl": "4.59.0", + "@rollup/rollup-linux-riscv64-gnu": "4.59.0", + "@rollup/rollup-linux-riscv64-musl": "4.59.0", + "@rollup/rollup-linux-s390x-gnu": "4.59.0", + "@rollup/rollup-linux-x64-gnu": "4.59.0", + "@rollup/rollup-linux-x64-musl": "4.59.0", + "@rollup/rollup-openbsd-x64": "4.59.0", + "@rollup/rollup-openharmony-arm64": "4.59.0", + "@rollup/rollup-win32-arm64-msvc": "4.59.0", + "@rollup/rollup-win32-ia32-msvc": "4.59.0", + "@rollup/rollup-win32-x64-gnu": "4.59.0", + "@rollup/rollup-win32-x64-msvc": "4.59.0", + "fsevents": "~2.3.2" + } + }, + "node_modules/seemly": { + "version": "0.3.10", + "resolved": "https://registry.npmjs.org/seemly/-/seemly-0.3.10.tgz", + "integrity": "sha512-2+SMxtG1PcsL0uyhkumlOU6Qo9TAQ/WyH7tthnPIOQB05/12jz9naq6GZ6iZ6ApVsO3rr2gsnTf3++OV63kE1Q==", + "license": "MIT" + }, + "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==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/style-mod": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/style-mod/-/style-mod-4.1.3.tgz", + "integrity": "sha512-i/n8VsZydrugj3Iuzll8+x/00GH2vnYsk1eomD8QiRrSAeW6ItbCQDtfXCeJHd0iwiNagqjQkvpvREEPtW3IoQ==", + "license": "MIT" + }, + "node_modules/tailwindcss": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.2.1.tgz", + "integrity": "sha512-/tBrSQ36vCleJkAOsy9kbNTgaxvGbyOamC30PRePTQe/o1MFwEKHQk4Cn7BNGaPtjp+PuUrByJehM1hgxfq4sw==", + "dev": true, + "license": "MIT" + }, + "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, + "license": "MIT", + "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, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/treemate": { + "version": "0.3.11", + "resolved": "https://registry.npmjs.org/treemate/-/treemate-0.3.11.tgz", + "integrity": "sha512-M8RGFoKtZ8dF+iwJfAJTOH/SM4KluKOKRJpjCMhI8bG3qB74zrFoArKZ62ll0Fr3mqkMJiQOmWYkdYgDeITYQg==", + "license": "MIT" + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "devOptional": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/vdirs": { + "version": "0.1.8", + "resolved": "https://registry.npmjs.org/vdirs/-/vdirs-0.1.8.tgz", + "integrity": "sha512-H9V1zGRLQZg9b+GdMk8MXDN2Lva0zx72MPahDKc30v+DtwKjfyOSXWRIX4t2mhDubM1H09gPhWeth/BJWPHGUw==", + "license": "MIT", + "dependencies": { + "evtd": "^0.2.2" + }, + "peerDependencies": { + "vue": "^3.0.11" + } + }, + "node_modules/vite": { + "version": "6.4.1", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.1.tgz", + "integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.25.0", + "fdir": "^6.4.4", + "picomatch": "^4.0.2", + "postcss": "^8.5.3", + "rollup": "^4.34.9", + "tinyglobby": "^0.2.13" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "jiti": ">=1.21.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "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/vooks": { + "version": "0.2.12", + "resolved": "https://registry.npmjs.org/vooks/-/vooks-0.2.12.tgz", + "integrity": "sha512-iox0I3RZzxtKlcgYaStQYKEzWWGAduMmq+jS7OrNdQo1FgGfPMubGL3uGHOU9n97NIvfFDBGnpSvkWyb/NSn/Q==", + "license": "MIT", + "dependencies": { + "evtd": "^0.2.2" + }, + "peerDependencies": { + "vue": "^3.0.0" + } + }, + "node_modules/vscode-uri": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/vscode-uri/-/vscode-uri-3.1.0.tgz", + "integrity": "sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/vue": { + "version": "3.5.30", + "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.30.tgz", + "integrity": "sha512-hTHLc6VNZyzzEH/l7PFGjpcTvUgiaPK5mdLkbjrTeWSRcEfxFrv56g/XckIYlE9ckuobsdwqd5mk2g1sBkMewg==", + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.30", + "@vue/compiler-sfc": "3.5.30", + "@vue/runtime-dom": "3.5.30", + "@vue/server-renderer": "3.5.30", + "@vue/shared": "3.5.30" + }, + "peerDependencies": { + "typescript": "*" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/vue-demi": { + "version": "0.14.10", + "resolved": "https://registry.npmjs.org/vue-demi/-/vue-demi-0.14.10.tgz", + "integrity": "sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==", + "hasInstallScript": true, + "license": "MIT", + "bin": { + "vue-demi-fix": "bin/vue-demi-fix.js", + "vue-demi-switch": "bin/vue-demi-switch.js" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + }, + "peerDependencies": { + "@vue/composition-api": "^1.0.0-rc.1", + "vue": "^3.0.0-0 || ^2.6.0" + }, + "peerDependenciesMeta": { + "@vue/composition-api": { + "optional": true + } + } + }, + "node_modules/vue-router": { + "version": "4.6.4", + "resolved": "https://registry.npmjs.org/vue-router/-/vue-router-4.6.4.tgz", + "integrity": "sha512-Hz9q5sa33Yhduglwz6g9skT8OBPii+4bFn88w6J+J4MfEo4KRRpmiNG/hHHkdbRFlLBOqxN8y8gf2Fb0MTUgVg==", + "license": "MIT", + "dependencies": { + "@vue/devtools-api": "^6.6.4" + }, + "funding": { + "url": "https://github.com/sponsors/posva" + }, + "peerDependencies": { + "vue": "^3.5.0" + } + }, + "node_modules/vue-tsc": { + "version": "2.2.12", + "resolved": "https://registry.npmjs.org/vue-tsc/-/vue-tsc-2.2.12.tgz", + "integrity": "sha512-P7OP77b2h/Pmk+lZdJ0YWs+5tJ6J2+uOQPo7tlBnY44QqQSPYvS0qVT4wqDJgwrZaLe47etJLLQRFia71GYITw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/typescript": "2.4.15", + "@vue/language-core": "2.2.12" + }, + "bin": { + "vue-tsc": "bin/vue-tsc.js" + }, + "peerDependencies": { + "typescript": ">=5.0.0" + } + }, + "node_modules/vueuc": { + "version": "0.4.65", + "resolved": "https://registry.npmjs.org/vueuc/-/vueuc-0.4.65.tgz", + "integrity": "sha512-lXuMl+8gsBmruudfxnMF9HW4be8rFziylXFu1VHVNbLVhRTXXV4njvpRuJapD/8q+oFEMSfQMH16E/85VoWRyQ==", + "license": "MIT", + "dependencies": { + "@css-render/vue3-ssr": "^0.15.10", + "@juggle/resize-observer": "^3.3.1", + "css-render": "^0.15.10", + "evtd": "^0.2.4", + "seemly": "^0.3.6", + "vdirs": "^0.1.4", + "vooks": "^0.2.4" + }, + "peerDependencies": { + "vue": "^3.0.11" + } + }, + "node_modules/w3c-keyname": { + "version": "2.2.8", + "resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz", + "integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==", + "license": "MIT" + } + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..a63810b --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,39 @@ +{ + "name": "wraith-frontend", + "private": true, + "type": "module", + "scripts": { + "dev": "vite", + "build": "vue-tsc --noEmit && vite build", + "build:dev": "vue-tsc --noEmit && vite build --minify false --mode development", + "preview": "vite preview" + }, + "dependencies": { + "@codemirror/lang-javascript": "^6.2.5", + "@codemirror/lang-json": "^6.0.2", + "@codemirror/lang-markdown": "^6.5.0", + "@codemirror/lang-python": "^6.2.1", + "@codemirror/state": "^6.6.0", + "@codemirror/theme-one-dark": "^6.1.3", + "@codemirror/view": "^6.40.0", + "@wailsio/runtime": "latest", + "@xterm/addon-fit": "^0.11.0", + "@xterm/addon-search": "^0.16.0", + "@xterm/addon-web-links": "^0.12.0", + "@xterm/addon-webgl": "^0.19.0", + "@xterm/xterm": "^6.0.0", + "codemirror": "^6.0.2", + "naive-ui": "^2.40.0", + "pinia": "^2.2.0", + "vue": "^3.5.0", + "vue-router": "^4.4.0" + }, + "devDependencies": { + "@tailwindcss/vite": "^4.0.0", + "@vitejs/plugin-vue": "^5.0.0", + "tailwindcss": "^4.0.0", + "typescript": "^5.5.0", + "vite": "^6.0.0", + "vue-tsc": "^2.0.0" + } +} diff --git a/frontend/src/App.vue b/frontend/src/App.vue new file mode 100644 index 0000000..52affe7 --- /dev/null +++ b/frontend/src/App.vue @@ -0,0 +1,30 @@ + + + diff --git a/frontend/src/assets/css/main.css b/frontend/src/assets/css/main.css new file mode 100644 index 0000000..537ee58 --- /dev/null +++ b/frontend/src/assets/css/main.css @@ -0,0 +1,29 @@ +@import "tailwindcss"; + +:root { + --wraith-bg-primary: #0d1117; + --wraith-bg-secondary: #161b22; + --wraith-bg-tertiary: #21262d; + --wraith-border: #30363d; + --wraith-text-primary: #e0e0e0; + --wraith-text-secondary: #8b949e; + --wraith-text-muted: #484f58; + --wraith-accent-blue: #58a6ff; + --wraith-accent-green: #3fb950; + --wraith-accent-red: #f85149; + --wraith-accent-yellow: #e3b341; +} + +body { + margin: 0; + font-family: system-ui, -apple-system, 'Segoe UI', sans-serif; + background: var(--wraith-bg-primary); + color: var(--wraith-text-primary); + overflow: hidden; + user-select: none; +} + +::-webkit-scrollbar { width: 8px; } +::-webkit-scrollbar-track { background: var(--wraith-bg-primary); } +::-webkit-scrollbar-thumb { background: var(--wraith-border); border-radius: 4px; } +::-webkit-scrollbar-thumb:hover { background: var(--wraith-text-muted); } diff --git a/frontend/src/assets/css/terminal.css b/frontend/src/assets/css/terminal.css new file mode 100644 index 0000000..2e67ee6 --- /dev/null +++ b/frontend/src/assets/css/terminal.css @@ -0,0 +1,50 @@ +/* xterm.js terminal container styling */ + +.terminal-container { + width: 100%; + height: 100%; + position: relative; + overflow: hidden; + background: var(--wraith-bg-primary); +} + +.terminal-container .xterm { + height: 100%; + padding: 4px; +} + +.terminal-container .xterm-viewport { + overflow-y: auto !important; +} + +.terminal-container .xterm-screen { + height: 100%; +} + +/* Selection styling */ +.terminal-container .xterm-selection div { + background-color: rgba(88, 166, 255, 0.3) !important; +} + +/* Cursor styling */ +.terminal-container .xterm-cursor-layer { + z-index: 4; +} + +/* Scrollbar inside terminal */ +.terminal-container .xterm-viewport::-webkit-scrollbar { + width: 8px; +} + +.terminal-container .xterm-viewport::-webkit-scrollbar-track { + background: var(--wraith-bg-primary); +} + +.terminal-container .xterm-viewport::-webkit-scrollbar-thumb { + background: var(--wraith-border); + border-radius: 4px; +} + +.terminal-container .xterm-viewport::-webkit-scrollbar-thumb:hover { + background: var(--wraith-text-muted); +} diff --git a/frontend/src/components/common/CommandPalette.vue b/frontend/src/components/common/CommandPalette.vue new file mode 100644 index 0000000..b26b165 --- /dev/null +++ b/frontend/src/components/common/CommandPalette.vue @@ -0,0 +1,267 @@ + + + + + diff --git a/frontend/src/components/common/ContextMenu.vue b/frontend/src/components/common/ContextMenu.vue new file mode 100644 index 0000000..c46b566 --- /dev/null +++ b/frontend/src/components/common/ContextMenu.vue @@ -0,0 +1,97 @@ + + + diff --git a/frontend/src/components/common/HostKeyDialog.vue b/frontend/src/components/common/HostKeyDialog.vue new file mode 100644 index 0000000..862d7b8 --- /dev/null +++ b/frontend/src/components/common/HostKeyDialog.vue @@ -0,0 +1,105 @@ + + + diff --git a/frontend/src/components/common/ImportDialog.vue b/frontend/src/components/common/ImportDialog.vue new file mode 100644 index 0000000..6a2bde3 --- /dev/null +++ b/frontend/src/components/common/ImportDialog.vue @@ -0,0 +1,247 @@ + + + diff --git a/frontend/src/components/common/StatusBar.vue b/frontend/src/components/common/StatusBar.vue new file mode 100644 index 0000000..f962b3c --- /dev/null +++ b/frontend/src/components/common/StatusBar.vue @@ -0,0 +1,65 @@ + + + diff --git a/frontend/src/components/common/ThemePicker.vue b/frontend/src/components/common/ThemePicker.vue new file mode 100644 index 0000000..c28a43e --- /dev/null +++ b/frontend/src/components/common/ThemePicker.vue @@ -0,0 +1,200 @@ + + + diff --git a/frontend/src/components/connections/ConnectionEditDialog.vue b/frontend/src/components/connections/ConnectionEditDialog.vue new file mode 100644 index 0000000..b25a7d7 --- /dev/null +++ b/frontend/src/components/connections/ConnectionEditDialog.vue @@ -0,0 +1,292 @@ +