diff --git a/.github/dependabot.yml b/.github/dependabot.yml index a207de01..cb2a696d 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -37,6 +37,14 @@ updates: major-updates: update-types: - "major" + ignore: + # typescript-eslint declares `typescript: >=4.8.4 <6.1.0`, and TypeScript 7 + # removed `ts.Extension`, which @typescript-eslint/typescript-estree reads + # at import time. Bumping to 7 makes `eslint .` fail to load its own config, + # so `npm run lint` cannot run at all. Drop this once typescript-eslint + # supports TypeScript 7. + - dependency-name: "typescript" + update-types: ["version-update:semver-major"] # Docker base images (docker/Dockerfile + docker-compose / compose-dev) - package-ecosystem: "docker" diff --git a/.github/workflows/beta-release.yml b/.github/workflows/beta-release.yml index 9abbddc3..05b5fbda 100644 --- a/.github/workflows/beta-release.yml +++ b/.github/workflows/beta-release.yml @@ -157,7 +157,7 @@ jobs: docker: needs: [prep, verify, create-release] - if: ${{ always() && needs.prep.outputs.dev_branch != '' && (needs.create-release.result == 'success' || needs.create-release.result == 'skipped') }} + if: ${{ always() && needs.prep.outputs.dev_branch != '' && needs.verify.result == 'success' && (needs.create-release.result == 'success' || needs.create-release.result == 'skipped') }} uses: ./.github/workflows/docker.yml with: version: ${{ needs.prep.outputs.beta_version }} diff --git a/.github/workflows/crowdin-sync.yml b/.github/workflows/crowdin-sync.yml index 8ab185cd..2a63ea1e 100644 --- a/.github/workflows/crowdin-sync.yml +++ b/.github/workflows/crowdin-sync.yml @@ -34,7 +34,7 @@ jobs: token: ${{ secrets.GHCR_TOKEN }} - name: Setup Node.js - uses: actions/setup-node@v6 + uses: actions/setup-node@v7 with: node-version-file: ".nvmrc" diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index 6a465bce..462a69ed 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -60,7 +60,7 @@ jobs: platforms: linux/amd64,linux/arm64 - name: Setup Docker Buildx - uses: useblacksmith/setup-docker-builder@v1 + uses: useblacksmith/setup-docker-builder@v2 - name: Determine tags id: tags diff --git a/.github/workflows/electron.yml b/.github/workflows/electron.yml index b09e52c6..dd56071a 100644 --- a/.github/workflows/electron.yml +++ b/.github/workflows/electron.yml @@ -559,7 +559,7 @@ jobs: CHECKSUM=$(shasum -a 256 "$DMG_PATH" | awk '{print $1}') mkdir -p homebrew-generated - cp packaging/Casks/termix.rb homebrew-generated/termix.rb + cp Casks/termix.rb homebrew-generated/termix.rb sed -i '' "s/VERSION_PLACEHOLDER/$VERSION/g" homebrew-generated/termix.rb sed -i '' "s/CHECKSUM_PLACEHOLDER/$CHECKSUM/g" homebrew-generated/termix.rb @@ -894,7 +894,7 @@ jobs: mkdir -p homebrew-submission/Casks/t - cp packaging/Casks/termix.rb homebrew-submission/Casks/t/termix.rb + cp Casks/termix.rb homebrew-submission/Casks/t/termix.rb sed -i '' "s/VERSION_PLACEHOLDER/$VERSION/g" homebrew-submission/Casks/t/termix.rb sed -i '' "s/CHECKSUM_PLACEHOLDER/$CHECKSUM/g" homebrew-submission/Casks/t/termix.rb diff --git a/.github/workflows/pr-check.yml b/.github/workflows/pr-check.yml index 123f4dcb..cfed886b 100644 --- a/.github/workflows/pr-check.yml +++ b/.github/workflows/pr-check.yml @@ -24,8 +24,10 @@ jobs: - name: Install dependencies run: npm ci - - name: Run ESLint - run: npx eslint . + - name: Lint + # npm run lint, not npx eslint — the script also checks that the + # generated dialect schemas match schema.ts, which eslint cannot see. + run: npm run lint - name: Run Prettier check run: npx prettier --check . @@ -35,3 +37,76 @@ jobs: - name: Build run: npm run build + + database-dialects: + name: Postgres and MySQL + runs-on: blacksmith-2vcpu-ubuntu-2404 + + # The test suite only ever sees SQLite. Everything that differs per engine — + # the RETURNING replacements, the read-then-write transactions, the + # migrations themselves — is only covered here, against real servers. + services: + postgres: + image: postgres:16 + env: + POSTGRES_USER: termix + POSTGRES_PASSWORD: termix + POSTGRES_DB: termix_test + ports: + - 5432:5432 + options: >- + --health-cmd pg_isready + --health-interval 10s + --health-timeout 5s + --health-retries 5 + + mysql: + image: mysql:8 + env: + MYSQL_ROOT_PASSWORD: termix + MYSQL_DATABASE: termix_test + MYSQL_USER: termix + MYSQL_PASSWORD: termix + ports: + - 3306:3306 + options: >- + --health-cmd "mysqladmin ping -h 127.0.0.1 -ptermix" + --health-interval 10s + --health-timeout 5s + --health-retries 10 + + steps: + - name: Checkout code + uses: actions/checkout@v7 + + - name: Setup Node.js + uses: actions/setup-node@v7 + with: + node-version-file: ".nvmrc" + cache: "npm" + + - name: Install dependencies + run: npm ci + + # Each run applies the migrations to an empty database first, so a + # migration that does not apply cleanly fails the build. + - name: Verify Postgres + run: npm run verify:dialect -- postgres://termix:termix@127.0.0.1:5432/termix_test + + - name: Verify MySQL + run: npm run verify:dialect -- mysql://termix:termix@127.0.0.1:3306/termix_test + + # The same repository suite the SQLite run executes, pointed at each + # engine. This is where a dialect difference in a query shows up as a + # failing assertion rather than as a bug report. + - name: Repository tests on Postgres + env: + TEST_DIALECT: postgres + TEST_DATABASE_URL: postgres://termix:termix@127.0.0.1:5432/termix_test + run: npx vitest run src/backend/tests/database/repositories --no-file-parallelism + + - name: Repository tests on MySQL + env: + TEST_DIALECT: mysql + TEST_DATABASE_URL: mysql://termix:termix@127.0.0.1:3306/termix_test + run: npx vitest run src/backend/tests/database/repositories --no-file-parallelism diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d4b51618..3560f2ce 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -395,10 +395,10 @@ jobs: git fetch origin main git checkout -B main origin/main - sed -i "s|version \".*\"|version \"$VERSION\"|g" packaging/Casks/termix.rb - sed -i "s|sha256 \".*\"|sha256 \"$DMG_SHA256\"|g" packaging/Casks/termix.rb + sed -i "s|version \".*\"|version \"$VERSION\"|g" Casks/termix.rb + sed -i "s|sha256 \".*\"|sha256 \"$DMG_SHA256\"|g" Casks/termix.rb - git add packaging/Casks/termix.rb + git add Casks/termix.rb if git diff --cached --quiet; then echo "Cask already up to date." exit 0 diff --git a/.prettierignore b/.prettierignore index befe2e1f..8cf32b0d 100644 --- a/.prettierignore +++ b/.prettierignore @@ -17,3 +17,6 @@ db *.min.js *.min.css openapi.json + +# Generated by drizzle-kit; formatting is the tool's own +drizzle/ diff --git a/packaging/Casks/termix.rb b/Casks/termix.rb similarity index 100% rename from packaging/Casks/termix.rb rename to Casks/termix.rb diff --git a/README.md b/README.md index b63c8678..848843e3 100644 --- a/README.md +++ b/README.md @@ -317,7 +317,7 @@ You can also run the Termix server on a cloud VPS instead of inside your own net Termix sends a small anonymous usage ping once every 24 hours to help understand how many instances are running and which features are actually used. This only includes a randomly generated instance ID, a count of users and hosts, the app version, and whether certain features (terminal, file manager, tunnels, docker, etc.) were used in the last 24 hours. It never includes usernames, hostnames, IP addresses, credentials, or any other identifying or connection data. -This is opt-out and enabled by default. You can disable it at any time in Admin Settings under **General**. +This is opt-out and enabled by default. You can disable it at any time in Admin Settings under General, or set `ENABLE_TELEMETRY=false` to turn it off before you ever spin-up Termix.
diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index caaa91f2..a855ec14 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -12,62 +12,59 @@ https://youtu.be/g0QjNdV3YYY -- Added simple telemetrics to PostHog (user count, total hosts across users, and version metrics). -- Reworked Electron desktop app to run standalone-first with a now optional sync to a remote Termix server. -- Added support for starting connections locally or from the remote server on desktop app. -- Added support for custom key shortcuts. -- Added support for more MFA types (SSH-only). -- Added multiplayer/shared sessions for terminals and remote desktop (share via link or user). -- Added support for logging into SSH hosts that require multi-factor authentication (like Duo or JumpCloud push/TOTP prompts). -- Added the option to convert a Quick Connect session into a saved host after connecting. -- Added an export option for sharing host entries without credentials, so a host list can be shared without leaking passwords or keys. -- Unified the folder picker across hosts, credentials, and snippets so they all use the same searchable, create-in-place selector. -- Allowed pasting into the key recording field from the clipboard. -- Allowed sharing hosts that use authentication type "none". -- Allowed setting authentication type "none" on RDP hosts. -- Added the option to show two or more sidebar panels open at the same time. -- Added global custom themes that can be applied across all hosts instead of per host. -- Added a button to quickly create a Credentials entry from a host's existing authentication details. -- Added persistent split screen, so your layout and assigned tabs are restored after closing and reopening the app. -- Added tag matching to host search, so searching now matches tags as well as hostnames. -- Brought back the ability to collapse snippets. -- Added the ability to assign login credentials to an entire folder of hosts instead of one at a time. -- Added the ability to share terminal, VNC, and RDP sessions with other users, including read-only and read-write modes. -- Added the ability to share entire folders of hosts with other users instead of sharing hosts one by one. -- Added the ability to make folders of hosts available to specific users instead of everyone recreating them. -- Reworked SSH credentials to support both a password and an SSH key on the same credential, with an option to auto fill the password when prompted. -- Added a custom group claim option for OIDC login, useful for identity providers like Zitadel that don't use a plain "groups" claim. +- Added support for multi disk usage in file manager/host metrics +- Added better Ctrl + F terminal search +- Added right click menu on app rail to pin sidebar faster +- Support for overriding shared SSH credential +- Added mapping for OIDC provider groups to RBAC roles +- Added host export dialog for more customizable host exporting +- Initial groundwork for supporting more database types (postgres and mysql) +- Added audit log export (CSV/NDJSON) and optional live forwarding to a SIEM +- Added configurable audit log retention by age and row count +- Audit entries for file manager, RDP/VNC/Telnet, Docker and tunnel sessions +- Encrypted SSO secrets instead of BASE64 encoding them +- Added support for Tailscale SSH check mode with in-terminal browser authentication + -- Fixed the Add Channel dialog failing with "config is required" when adding Webhook or ntfy alert channels. -- Fixed font size and UI scaling being too small even at the largest setting on high resolution displays. -- Fixed the Docker integration not working on hosts using authentication type "none". -- Fixed the latest Russian translation updates from Crowdin not being included in the app. -- Fixed missing Nerd Font symbol support in the Android app. -- Fixed credential changes on RDP hosts not saving properly. -- Fixed credential folders not showing up in the folder dropdown when editing a credential. -- Fixed RDP hosts not using their stored credential and falling back to a direct connection. -- Fixed Cmd + scroll on Mac resizing the terminal instead of scrolling. -- Fixed text repeating itself in the terminal when typing with a wireless keyboard on Android. -- Fixed RDP connections failing when going through a jump host. -- Fixed SSH connections failing when going through a jump host in some setups. -- Fixed OIDC login failing with a database error when the identity provider didn't return a client ID. -- Fixed SSH client-to-server tunnels failing with an authentication error. -- Fixed Host Metrics disk usage only showing the root filesystem and ignoring other mounted disks. -- Fixed Android navigation buttons covering the terminal's top bar keys. -- Fixed Proxmox discovery importing DHCP LXC containers with an IP of 0.0.0.0 instead of their real address. -- Fixed VNC connections to macOS Screen Sharing hanging after the handshake instead of connecting. -- Fixed File Manager delete still failing on Windows hosts running PowerShell 5.1. -- Fixed the terminal dropping characters while typing on iOS. -- Fixed SSH lines like "[username@host]" being wrongly highlighted as a log level and breaking output formatting. -- Fixed RDP touch mode on Android not registering taps as clicks. -- Fixed VNC connections still failing due to a guacd protocol version mismatch. -- Guacamole tab showing "connecting" instead of rendering the desktop. -- Fixed tmux not using Tailscale when starting connections. -- Fixed an invalid websocket frame from causing code 10006 crash triggering restart loop. -- Remove chacha20-poly1305 without native ssh2 binding. -- Corrected SSRF blocklist from false-positive on all IPv4. -- Fixed terminal background image incorrectly displaying. +- Hardened nginx headers/asset caching +- Deleting an account no longer deletes its audit entries and session recordings +- Made logger display expanded error messages +- Removed phantom port knocking +- Fixed Proxmox guest discovery failures over jump host +- Compare sync cursors independently of timestamp layout +- Fixed sync deleting not reaching other side +- Remote sync stalling after first pass and never propagating deletions +- DB_FILE_ENCRYPTION variable loading DB file as empty +- Removed unneeded field encryption boundaries +- SSH login alerts being dropped silently +- Honor lookupOptions.all in custom DNS lookup hook +- Jump host SOCKS proxy settings being ignored +- Jump host tunnels not reachable by guacd +- Per-host RDP/VNC recording flags being ignored +- RDP sessions not using the configured resolution +- OIDC login failing with unverifiable ID tokens or JWKs without alg +- Refuse to start with an empty database when data exists elsewhere +- Database not persisting during container shutdown +- Host command history setting not saving +- Desktop preference sync and remote sync account identity +- Desktop guacd calls not routed to the connected remote server +- File manager navigation getting stuck after permission errors +- Read-only shared hosts could be dragged into folders +- Terminal highlighting breaking inside split control strings +- Windows terminal Tab key and Android hardware keyboard keys +- tmux monitor failing on Tailscale-authenticated hosts +- Database export not staying same-origin on localhost +- Snippet execution results not reported correctly +- Shared hosts appearing twice +- Wake-on-LAN broadcast address being dropped +- Sharing an empty folder was rejected +- Remote sync losing references between linked records +- Desktop app failing to find its backend on some architectures +- Centralized outbound address validation for homepage proxy requests +- Default font size to medium instead of large +- Tailscale hosts hanging on connect when the tailnet ACL requires a periodic check + diff --git a/biome.json b/biome.json index 68bf4687..d754b9a2 100644 --- a/biome.json +++ b/biome.json @@ -4,7 +4,7 @@ "enabled": true, "clientKind": "git", "useIgnoreFile": true, - "defaultBranch": "dev-2.5.0" + "defaultBranch": "dev-2.6.1" }, "files": { "ignoreUnknown": true, diff --git a/docker/Dockerfile b/docker/Dockerfile index f4028795..a0bfa6b2 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -76,6 +76,9 @@ COPY --chown=node:node --from=frontend-builder /app/dist /app/html COPY --chown=node:node --from=production-deps /app/node_modules /app/node_modules COPY --chown=node:node --from=backend-builder /app/dist/backend ./dist/backend COPY --chown=node:node package.json ./ +# Schema for Postgres and MySQL. Unused by the default SQLite deployment, which +# builds its tables at startup instead. +COPY --chown=node:node drizzle ./drizzle VOLUME ["/app/data"] diff --git a/docker/compose-dev.yml b/docker/compose-dev.yml index 14b703a8..3ed77957 100644 --- a/docker/compose-dev.yml +++ b/docker/compose-dev.yml @@ -13,6 +13,7 @@ services: PORT: "8080" NODE_ENV: development GUACD_HOST: "guacd-dev" + GUACD_TUNNEL_HOST: "termix-dev" GUACD_RECORDING_PATH: "/termix-data/session_recordings/guacamole" depends_on: - guacd-dev diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index eed4d172..856f827b 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -10,6 +10,7 @@ services: environment: PORT: "8080" GUACD_HOST: "guacd" + GUACD_TUNNEL_HOST: "termix" GUACD_RECORDING_PATH: "/termix-data/session_recordings/guacamole" depends_on: - guacd diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh index 917ea608..17ca6aab 100644 --- a/docker/entrypoint.sh +++ b/docker/entrypoint.sh @@ -163,8 +163,4 @@ else echo "Warning: package.json not found" fi -node dist/backend/backend/starter.js - -echo "All services started" - -tail -f /dev/null +exec node dist/backend/backend/starter.js diff --git a/docker/nginx-https.conf b/docker/nginx-https.conf index ce22530d..fea90737 100644 --- a/docker/nginx-https.conf +++ b/docker/nginx-https.conf @@ -11,6 +11,8 @@ http { include /etc/nginx/mime.types; default_type application/octet-stream; + server_tokens off; + access_log /tmp/nginx/access.log; client_body_temp_path /tmp/nginx/client_body; @@ -69,7 +71,6 @@ http { add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always; add_header X-Content-Type-Options nosniff always; - add_header X-XSS-Protection "1; mode=block" always; location ^~ /.well-known/acme-challenge/ { root /app/data/acme-webroot; @@ -80,6 +81,8 @@ http { location = /sw.js { root /app/html; expires off; + add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always; + add_header X-Content-Type-Options nosniff always; add_header Cache-Control "no-store, no-cache, must-revalidate, proxy-revalidate, max-age=0" always; try_files $uri =404; } @@ -87,31 +90,64 @@ http { location = /manifest.json { root /app/html; expires off; + add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always; + add_header X-Content-Type-Options nosniff always; add_header Cache-Control "no-store, no-cache, must-revalidate, proxy-revalidate, max-age=0" always; try_files $uri =404; } - location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ { + location ^~ /assets/ { root /app/html; expires 1y; + add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always; + add_header X-Content-Type-Options nosniff always; add_header Cache-Control "public, max-age=31536000, immutable" always; try_files $uri =404; } + location ^~ /fonts/ { + root /app/html; + expires 1y; + add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always; + add_header X-Content-Type-Options nosniff always; + add_header Cache-Control "public, max-age=31536000, immutable" always; + try_files $uri =404; + } + + location ^~ /icons/ { + root /app/html; + expires 30d; + add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always; + add_header X-Content-Type-Options nosniff always; + add_header Cache-Control "public, max-age=2592000" always; + try_files $uri =404; + } + + location ~* ^/[^/]+\.(js|css|png|jpe?g|gif|ico|svg|webp|woff2?|ttf|eot)$ { + root /app/html; + expires 30d; + add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always; + add_header X-Content-Type-Options nosniff always; + add_header Cache-Control "public, max-age=2592000" always; + try_files $uri =404; + } + + location ~* \.map$ { + access_log off; + log_not_found off; + return 404; + } + location / { root /app/html; index index.html index.htm; expires off; + add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always; + add_header X-Content-Type-Options nosniff always; add_header Cache-Control "no-store, no-cache, must-revalidate, proxy-revalidate, max-age=0" always; try_files $uri $uri/ /index.html; } - location ~* \.map$ { - return 404; - access_log off; - log_not_found off; - } - location ~ ^/users/sessions(/.*)?$ { proxy_pass http://127.0.0.1:30001; proxy_http_version 1.1; @@ -372,7 +408,9 @@ http { proxy_cache_bypass 1; proxy_no_cache 1; - add_header Cache-Control "no-store, no-cache, must-revalidate, proxy-revalidate, max-age=0"; + add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always; + add_header X-Content-Type-Options nosniff always; + add_header Cache-Control "no-store, no-cache, must-revalidate, proxy-revalidate, max-age=0" always; } location ~ ^/host/opkssh-callback(/.*)?$ { @@ -387,7 +425,9 @@ http { proxy_cache_bypass 1; proxy_no_cache 1; - add_header Cache-Control "no-store, no-cache, must-revalidate, proxy-revalidate, max-age=0"; + add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always; + add_header X-Content-Type-Options nosniff always; + add_header Cache-Control "no-store, no-cache, must-revalidate, proxy-revalidate, max-age=0" always; } location /host/ { @@ -549,6 +589,8 @@ http { client_max_body_size 5G; client_body_timeout 300s; + add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always; + add_header X-Content-Type-Options nosniff always; add_header Cache-Control "no-store, no-cache, must-revalidate" always; proxy_pass http://127.0.0.1:30004; @@ -570,6 +612,8 @@ http { client_max_body_size 5G; client_body_timeout 300s; + add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always; + add_header X-Content-Type-Options nosniff always; add_header Cache-Control "no-store, no-cache, must-revalidate" always; proxy_pass http://127.0.0.1:30004; @@ -771,6 +815,7 @@ http { error_page 500 502 503 504 /50x.html; location = /50x.html { root /app/html; + internal; } } } diff --git a/docker/nginx.conf b/docker/nginx.conf index 235218b9..98aa8dcc 100644 --- a/docker/nginx.conf +++ b/docker/nginx.conf @@ -11,6 +11,8 @@ http { include /etc/nginx/mime.types; default_type application/octet-stream; + server_tokens off; + access_log /tmp/nginx/access.log; client_body_temp_path /tmp/nginx/client_body; @@ -58,7 +60,6 @@ http { absolute_redirect off; add_header X-Content-Type-Options nosniff always; - add_header X-XSS-Protection "1; mode=block" always; location ^~ /.well-known/acme-challenge/ { root /app/data/acme-webroot; @@ -69,6 +70,7 @@ http { location = /sw.js { root /app/html; expires off; + add_header X-Content-Type-Options nosniff always; add_header Cache-Control "no-store, no-cache, must-revalidate, proxy-revalidate, max-age=0" always; try_files $uri =404; } @@ -76,31 +78,58 @@ http { location = /manifest.json { root /app/html; expires off; + add_header X-Content-Type-Options nosniff always; add_header Cache-Control "no-store, no-cache, must-revalidate, proxy-revalidate, max-age=0" always; try_files $uri =404; } - location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ { + location ^~ /assets/ { root /app/html; expires 1y; + add_header X-Content-Type-Options nosniff always; add_header Cache-Control "public, max-age=31536000, immutable" always; try_files $uri =404; } + location ^~ /fonts/ { + root /app/html; + expires 1y; + add_header X-Content-Type-Options nosniff always; + add_header Cache-Control "public, max-age=31536000, immutable" always; + try_files $uri =404; + } + + location ^~ /icons/ { + root /app/html; + expires 30d; + add_header X-Content-Type-Options nosniff always; + add_header Cache-Control "public, max-age=2592000" always; + try_files $uri =404; + } + + location ~* ^/[^/]+\.(js|css|png|jpe?g|gif|ico|svg|webp|woff2?|ttf|eot)$ { + root /app/html; + expires 30d; + add_header X-Content-Type-Options nosniff always; + add_header Cache-Control "public, max-age=2592000" always; + try_files $uri =404; + } + + location ~* \.map$ { + access_log off; + log_not_found off; + return 404; + } + location / { root /app/html; index index.html index.htm; expires off; + add_header X-Content-Type-Options nosniff always; add_header Cache-Control "no-store, no-cache, must-revalidate, proxy-revalidate, max-age=0" always; try_files $uri $uri/ /index.html; } - location ~* \.map$ { - return 404; - access_log off; - log_not_found off; - } - location ~ ^/users/sessions(/.*)?$ { proxy_pass http://127.0.0.1:30001; proxy_http_version 1.1; @@ -361,7 +390,8 @@ http { proxy_cache_bypass 1; proxy_no_cache 1; - add_header Cache-Control "no-store, no-cache, must-revalidate, proxy-revalidate, max-age=0"; + add_header X-Content-Type-Options nosniff always; + add_header Cache-Control "no-store, no-cache, must-revalidate, proxy-revalidate, max-age=0" always; } location ~ ^/host/opkssh-callback(/.*)?$ { @@ -376,7 +406,8 @@ http { proxy_cache_bypass 1; proxy_no_cache 1; - add_header Cache-Control "no-store, no-cache, must-revalidate, proxy-revalidate, max-age=0"; + add_header X-Content-Type-Options nosniff always; + add_header Cache-Control "no-store, no-cache, must-revalidate, proxy-revalidate, max-age=0" always; } location /host/ { @@ -538,6 +569,7 @@ http { client_max_body_size 5G; client_body_timeout 300s; + add_header X-Content-Type-Options nosniff always; add_header Cache-Control "no-store, no-cache, must-revalidate" always; proxy_pass http://127.0.0.1:30004; @@ -559,6 +591,7 @@ http { client_max_body_size 5G; client_body_timeout 300s; + add_header X-Content-Type-Options nosniff always; add_header Cache-Control "no-store, no-cache, must-revalidate" always; proxy_pass http://127.0.0.1:30004; @@ -760,6 +793,7 @@ http { error_page 500 502 503 504 /50x.html; location = /50x.html { root /app/html; + internal; } } } diff --git a/docs/database-backends.md b/docs/database-backends.md new file mode 100644 index 00000000..0fdd4753 --- /dev/null +++ b/docs/database-backends.md @@ -0,0 +1,240 @@ +# Database backends + +Termix runs on SQLite by default. Postgres and MySQL are supported for +self-hosted deployments; this document records how the three differ, because the +differences are not only about SQL. + +## This is multi-backend, not a migration + +SQLite is not going away. The desktop app embeds its own backend and cannot ship +a database server, so it will always run on SQLite. Postgres and MySQL exist for +self-hosted deployments that need more than one process to reach the data — +multiple replicas, an external backup story, or an existing database estate. + +Anything that assumes a single engine is wrong. + +## Where the schema comes from + +`src/backend/database/db/schema.ts` is the single source of truth, written +against `drizzle-orm/sqlite-core`. + +`schema.pg.ts` and `schema.mysql.ts` are **generated** from it: + +```bash +npm run schema:generate # rewrite the generated modules +npm run schema:check # fail if they are out of date (runs as part of lint) +``` + +Never edit the generated files. `npm run lint` fails if they drift from the +source, so a schema change that forgets to regenerate cannot reach main. + +The transforms are mechanical: + +| sqlite | postgres | mysql | +| ------------------------------------------------ | ----------------- | ----------------------- | +| `integer(…, { mode: "boolean" })` | `boolean` | `boolean` | +| `integer(…).primaryKey({ autoIncrement: true })` | `serial` | `int().autoincrement()` | +| `integer` | `integer` | `int` | +| `real` | `doublePrecision` | `double` | +| `text` used as a key | `varchar(255)` | `varchar(255)` | + +A column becomes `varchar` if it is a primary key, is unique, or sits on either +end of a foreign key — MySQL cannot index an unbounded `TEXT`, and both sides of +a foreign key must agree. + +## Durability + +On SQLite the database is loaded into memory and serialised back to an encrypted +file, so every write needs an explicit flush. That is what the `onWrite` hook +each repository receives is for. + +On Postgres and MySQL a committed write is already durable. No hook is installed +at all — see `needsExplicitPersist` in `db/dialect.ts`. + +## Encryption: what changes, and what does not + +This is the part most likely to be misread, so it is spelled out. + +### Unchanged on every backend + +**Field-level encryption still applies.** Credentials and other sensitive values +are encrypted in the application before they reach the database, under a +per-user data key: + +- `ssh_data` — passwords, private keys, key passphrases, sudo/RDP/VNC/Telnet + secrets +- `ssh_credentials` — passwords, private and public keys +- `users` — TOTP secret and backup codes +- `vault_tokens`, `opkssh_tokens`, `termix_identity_ca` — certificates and keys +- `shared_host_secrets` — re-encrypted per recipient + +Installation-level secrets — the OIDC client secret and LDAP bind password — +are encrypted under the system key, since they have no owning user and must be +readable during login. + +This is the protection that matters most, and it is identical on all three +engines. + +### Different on Postgres and MySQL + +**Whole-file encryption does not exist.** On SQLite the database file itself is +encrypted at rest. There is no equivalent for a client-server engine: the data +lives in the server's storage, not in a file Termix owns. + +Concretely, on Postgres/MySQL the following are readable by anyone with database +access, where on SQLite they were covered by the file encryption: + +- host names, addresses, ports and usernames +- folder and snippet names, and **snippet contents** +- audit log entries +- session recording metadata and paths +- user names, roles and API key hashes + +None of these are credentials — those stay encrypted — but together they +describe your estate. + +**If you run Postgres or MySQL, encryption at rest is your responsibility**: +transparent data encryption, an encrypted volume, or an encrypted filesystem. +Termix does not provide it and cannot. + +### Threat model, side by side + +| | SQLite | Postgres / MySQL | +| ----------------------------------------------- | ------------------------------------------------ | ------------------------------------------------------------------ | +| Stolen database file / volume | credentials encrypted, everything else encrypted | credentials encrypted, **rest depends on your storage encryption** | +| Database access without app access | credentials unreadable | credentials unreadable | +| Application compromise while a user is unlocked | that user's secrets readable | same | +| Backups | inherit file encryption | **plain unless you encrypt them** | + +The second row is the point of field-level encryption, and it holds everywhere. +The first and last rows are where the backends genuinely differ. + +## Running on Postgres or MySQL + +Two variables. Unset, nothing changes and SQLite is used exactly as before. + +``` +DATABASE_DIALECT=postgres +DATABASE_URL=postgres://user:password@host:5432/termix +``` + +``` +DATABASE_DIALECT=mysql +DATABASE_URL=mysql://user:password@host:3306/termix +``` + +`mariadb://` is accepted for MySQL. The scheme is checked against the dialect +before a connection is attempted, so a mismatch fails with a readable message +rather than a driver error deep in a stack. + +Point it at an **empty** database. Migrations are applied at startup, from +`drizzle/postgres` or `drizzle/mysql`, and drizzle records what it has applied — +so several instances against one database are safe, and so is restarting. + +There is no migration path from an existing SQLite database. Exporting one and +importing it into Postgres is not something this branch does. + +### Docker + +`drizzle/` ships in the image. A compose service needs only the two variables: + +Added to the compose file in the README, that is one service and two variables: + +```yaml +services: + termix: + image: ghcr.io/lukegus/termix:latest + environment: + PORT: "8080" + DATABASE_DIALECT: postgres + DATABASE_URL: postgres://termix:termix@db:5432/termix + depends_on: + - db + + db: + image: postgres:16 + restart: unless-stopped + environment: + POSTGRES_USER: termix + POSTGRES_PASSWORD: termix + POSTGRES_DB: termix + volumes: + - pgdata:/var/lib/postgresql/data + +volumes: + pgdata: +``` + +`DATA_DIR` is still used for uploads and recordings on every backend. Only the +database itself moves. + +## What is verified, and how + +`npm run verify:dialect -- ` applies the migrations to an empty database and +drives the real repository classes against it, asserting values rather than the +absence of exceptions. + +The repository test suite also runs against each engine: + +``` +TEST_DIALECT=postgres TEST_DATABASE_URL= npx vitest run \ + src/backend/tests/database/repositories --no-file-parallelism +``` + +CI runs both, against PostgreSQL 16 and MySQL 8 service containers. Eighteen +tests assert on bytes stored by the SQLite driver and skip on other engines; +they still run in the SQLite pass. + +Tested against PostgreSQL 16 and MySQL 8. **MariaDB is not a substitute for +MySQL when testing** — it accepts DDL that MySQL 8 rejects, which has hidden a +real defect here more than once. + +### What neither of them covers + +Both harnesses build a `DatabaseContext` of their own, so neither runs +`createCurrentRepositoryContext()` — the one the application actually uses. +That gap hid a hardcoded `dialect: "sqlite"` in it: every engine reported +itself as SQLite at runtime while all three test passes stayed green, which on +MySQL meant `upsert` reached for `onConflictDoUpdate` and died with a +TypeError on the first write. + +Anything the factory decides from the dialect needs its own test against the +factory. Asserting it through a hand-built context proves nothing about what +runs in production. + +## Known limits + +- The desktop app always uses SQLite. It embeds its own backend and cannot ship + a database server. +- Repositories import the SQLite table definitions on every engine. That is + correct — the query builder needs identifiers and value encoders, and those + agree — but it means `PortableDatabase` is a named approximation rather than a + guarantee. See `repositories/database-context.ts`. +- `getCurrentSettingValue` is a synchronous read. On Postgres and MySQL it comes + from a cache primed at startup and kept current by `SettingsRepository`, + because those drivers have no synchronous query. + + That cache is per-process, so on a **multi-replica** deployment a setting + changed on one instance does not reach the others through the write path. Each + replica re-reads the settings table every 30 seconds + (`SETTINGS_CACHE_REFRESH_SECONDS`, 0 to disable), which does not make settings + immediately consistent — it bounds how long they can disagree. Changing a + setting takes effect on the replica that made the change at once, and on the + others within the interval. + +- **Importing a backup is SQLite-only.** The restore writes tables in an order + that is not dependency-safe and relies on `PRAGMA foreign_keys = OFF`, which + has no equivalent here: Postgres needs superuser to disable triggers, and + MySQL's session-scoped switch is not guaranteed across a pool. It refuses with + a message rather than failing partway through and leaving a half-restored + database. Restore into Postgres or MySQL with their own tooling. +- **`LIKE` is case-insensitive on SQLite and case-sensitive on Postgres.** The + four places that use it match folder path prefixes and settings keys, so the + practical effect is that renaming a folder `prod` on SQLite also catches + `PROD / api` and on Postgres does not. Postgres is arguably the more correct + of the two; nothing was changed to make them agree, because that would alter + SQLite behaviour for existing deployments. +- The SQLite-era data migrations — legacy shared-credential cleanup, the + shared-host-secrets rebuild, per-user field-encryption backfill — do not run on + the other engines. A database created by the drizzle migrations never had the + shapes they repair. diff --git a/drizzle.config.mysql.ts b/drizzle.config.mysql.ts new file mode 100644 index 00000000..5665bf7b --- /dev/null +++ b/drizzle.config.mysql.ts @@ -0,0 +1,7 @@ +import { defineConfig } from "drizzle-kit"; + +export default defineConfig({ + dialect: "mysql", + schema: "./src/backend/database/db/schema.mysql.ts", + out: "./drizzle/mysql", +}); diff --git a/drizzle.config.pg.ts b/drizzle.config.pg.ts new file mode 100644 index 00000000..91292349 --- /dev/null +++ b/drizzle.config.pg.ts @@ -0,0 +1,7 @@ +import { defineConfig } from "drizzle-kit"; + +export default defineConfig({ + dialect: "postgresql", + schema: "./src/backend/database/db/schema.pg.ts", + out: "./drizzle/postgres", +}); diff --git a/drizzle.config.sqlite.ts b/drizzle.config.sqlite.ts new file mode 100644 index 00000000..7941897c --- /dev/null +++ b/drizzle.config.sqlite.ts @@ -0,0 +1,7 @@ +import { defineConfig } from "drizzle-kit"; + +export default defineConfig({ + dialect: "sqlite", + schema: "./src/backend/database/db/schema.ts", + out: "./drizzle/sqlite", +}); diff --git a/drizzle/mysql/0000_clean_pretty_boy.sql b/drizzle/mysql/0000_clean_pretty_boy.sql new file mode 100644 index 00000000..d138297a --- /dev/null +++ b/drizzle/mysql/0000_clean_pretty_boy.sql @@ -0,0 +1,890 @@ +CREATE TABLE `alert_firings` ( + `id` int AUTO_INCREMENT NOT NULL, + `user_id` varchar(255) NOT NULL, + `rule_id` int NOT NULL, + `host_id` int NOT NULL, + `host_name` text NOT NULL, + `fired_at` text NOT NULL DEFAULT (CURRENT_TIMESTAMP), + `resolved_at` text, + `value` double, + `message` text NOT NULL, + `severity` text NOT NULL DEFAULT ('warning'), + `acknowledged` boolean NOT NULL DEFAULT false, + CONSTRAINT `alert_firings_id` PRIMARY KEY(`id`) +); +--> statement-breakpoint +CREATE TABLE `alert_rule_channels` ( + `id` int AUTO_INCREMENT NOT NULL, + `rule_id` int NOT NULL, + `channel_id` int NOT NULL, + CONSTRAINT `alert_rule_channels_id` PRIMARY KEY(`id`) +); +--> statement-breakpoint +CREATE TABLE `alert_rules` ( + `id` int AUTO_INCREMENT NOT NULL, + `user_id` varchar(255) NOT NULL, + `host_id` int, + `name` varchar(255) NOT NULL, + `enabled` boolean NOT NULL DEFAULT true, + `trigger_type` text NOT NULL, + `threshold_value` double, + `threshold_duration_seconds` int, + `cooldown_minutes` int NOT NULL DEFAULT 15, + `created_at` text NOT NULL DEFAULT (CURRENT_TIMESTAMP), + `updated_at` text NOT NULL DEFAULT (CURRENT_TIMESTAMP), + CONSTRAINT `alert_rules_id` PRIMARY KEY(`id`) +); +--> statement-breakpoint +CREATE TABLE `api_keys` ( + `id` varchar(255) NOT NULL, + `user_id` varchar(255) NOT NULL, + `name` varchar(255) NOT NULL, + `token_hash` text NOT NULL, + `token_prefix` text NOT NULL, + `created_at` text NOT NULL DEFAULT (CURRENT_TIMESTAMP), + `expires_at` text, + `last_used_at` text, + `is_active` boolean NOT NULL DEFAULT true, + CONSTRAINT `api_keys_id` PRIMARY KEY(`id`) +); +--> statement-breakpoint +CREATE TABLE `audit_logs` ( + `id` int AUTO_INCREMENT NOT NULL, + `user_id` varchar(255), + `username` text NOT NULL, + `action` text NOT NULL, + `resource_type` text NOT NULL, + `resource_id` text, + `resource_name` text, + `details` text, + `ip_address` text, + `user_agent` text, + `success` boolean NOT NULL, + `error_message` text, + `timestamp` text NOT NULL DEFAULT (CURRENT_TIMESTAMP), + CONSTRAINT `audit_logs_id` PRIMARY KEY(`id`) +); +--> statement-breakpoint +CREATE TABLE `c2s_tunnel_presets` ( + `id` int AUTO_INCREMENT NOT NULL, + `user_id` varchar(255) NOT NULL, + `name` varchar(255) NOT NULL, + `config` text NOT NULL, + `platform` text, + `computer_name` text, + `created_at` text NOT NULL DEFAULT (CURRENT_TIMESTAMP), + `updated_at` text NOT NULL DEFAULT (CURRENT_TIMESTAMP), + CONSTRAINT `c2s_tunnel_presets_id` PRIMARY KEY(`id`) +); +--> statement-breakpoint +CREATE TABLE `command_history` ( + `id` int AUTO_INCREMENT NOT NULL, + `user_id` varchar(255) NOT NULL, + `host_id` int NOT NULL, + `command` text NOT NULL, + `executed_at` text NOT NULL DEFAULT (CURRENT_TIMESTAMP), + CONSTRAINT `command_history_id` PRIMARY KEY(`id`) +); +--> statement-breakpoint +CREATE TABLE `dashboard_service_links` ( + `id` int AUTO_INCREMENT NOT NULL, + `user_id` varchar(255) NOT NULL, + `label` text NOT NULL, + `url` text NOT NULL, + `order` int NOT NULL DEFAULT 0, + `sync_id` varchar(255), + `created_at` text NOT NULL DEFAULT (CURRENT_TIMESTAMP), + `updated_at` text NOT NULL DEFAULT (CURRENT_TIMESTAMP), + CONSTRAINT `dashboard_service_links_id` PRIMARY KEY(`id`), + CONSTRAINT `dashboard_service_links_sync_id_unique` UNIQUE(`sync_id`) +); +--> statement-breakpoint +CREATE TABLE `dismissed_alerts` ( + `id` int AUTO_INCREMENT NOT NULL, + `user_id` varchar(255) NOT NULL, + `alert_id` text NOT NULL, + `dismissed_at` text NOT NULL DEFAULT (CURRENT_TIMESTAMP), + CONSTRAINT `dismissed_alerts_id` PRIMARY KEY(`id`) +); +--> statement-breakpoint +CREATE TABLE `file_manager_pinned` ( + `id` int AUTO_INCREMENT NOT NULL, + `user_id` varchar(255) NOT NULL, + `host_id` int NOT NULL, + `name` varchar(255) NOT NULL, + `path` text NOT NULL, + `pinned_at` text NOT NULL DEFAULT (CURRENT_TIMESTAMP), + CONSTRAINT `file_manager_pinned_id` PRIMARY KEY(`id`) +); +--> statement-breakpoint +CREATE TABLE `file_manager_recent` ( + `id` int AUTO_INCREMENT NOT NULL, + `user_id` varchar(255) NOT NULL, + `host_id` int NOT NULL, + `name` varchar(255) NOT NULL, + `path` text NOT NULL, + `last_opened` text NOT NULL DEFAULT (CURRENT_TIMESTAMP), + CONSTRAINT `file_manager_recent_id` PRIMARY KEY(`id`) +); +--> statement-breakpoint +CREATE TABLE `file_manager_shortcuts` ( + `id` int AUTO_INCREMENT NOT NULL, + `user_id` varchar(255) NOT NULL, + `host_id` int NOT NULL, + `name` varchar(255) NOT NULL, + `path` text NOT NULL, + `created_at` text NOT NULL DEFAULT (CURRENT_TIMESTAMP), + CONSTRAINT `file_manager_shortcuts_id` PRIMARY KEY(`id`) +); +--> statement-breakpoint +CREATE TABLE `homepage_items` ( + `id` int AUTO_INCREMENT NOT NULL, + `user_id` varchar(255) NOT NULL, + `type_id` text NOT NULL, + `title` text, + `config` text NOT NULL DEFAULT ('{}'), + `folder_id` int, + `sync_id` varchar(255), + `created_at` text NOT NULL DEFAULT (CURRENT_TIMESTAMP), + `updated_at` text NOT NULL DEFAULT (CURRENT_TIMESTAMP), + CONSTRAINT `homepage_items_id` PRIMARY KEY(`id`), + CONSTRAINT `homepage_items_sync_id_unique` UNIQUE(`sync_id`) +); +--> statement-breakpoint +CREATE TABLE `homepage_layouts` ( + `id` int AUTO_INCREMENT NOT NULL, + `user_id` varchar(255) NOT NULL, + `layout` text NOT NULL DEFAULT ('{}'), + `updated_at` text NOT NULL DEFAULT (CURRENT_TIMESTAMP), + CONSTRAINT `homepage_layouts_id` PRIMARY KEY(`id`), + CONSTRAINT `homepage_layouts_user_id_unique` UNIQUE(`user_id`) +); +--> statement-breakpoint +CREATE TABLE `host_access` ( + `id` int AUTO_INCREMENT NOT NULL, + `host_id` int NOT NULL, + `user_id` varchar(255), + `role_id` int, + `granted_by` varchar(255) NOT NULL, + `permission_level` text NOT NULL DEFAULT ('connect'), + `expires_at` text, + `created_at` text NOT NULL DEFAULT (CURRENT_TIMESTAMP), + `last_accessed_at` text, + `access_count` int NOT NULL DEFAULT 0, + CONSTRAINT `host_access_id` PRIMARY KEY(`id`) +); +--> statement-breakpoint +CREATE TABLE `host_health_checks` ( + `id` int AUTO_INCREMENT NOT NULL, + `user_id` varchar(255) NOT NULL, + `host_id` int NOT NULL, + `checks` text NOT NULL, + `interval_seconds` int NOT NULL DEFAULT 300, + `created_at` text NOT NULL DEFAULT (CURRENT_TIMESTAMP), + `updated_at` text NOT NULL DEFAULT (CURRENT_TIMESTAMP), + CONSTRAINT `host_health_checks_id` PRIMARY KEY(`id`), + CONSTRAINT `idx_host_health_checks_user_host` UNIQUE(`user_id`,`host_id`) +); +--> statement-breakpoint +CREATE TABLE `host_health_history` ( + `id` int AUTO_INCREMENT NOT NULL, + `user_id` varchar(255) NOT NULL, + `host_id` int NOT NULL, + `check_id` text NOT NULL, + `ts` text NOT NULL DEFAULT (CURRENT_TIMESTAMP), + `ok` boolean NOT NULL, + `latency_ms` int, + `detail` text, + CONSTRAINT `host_health_history_id` PRIMARY KEY(`id`) +); +--> statement-breakpoint +CREATE TABLE `host_metrics_history` ( + `id` int AUTO_INCREMENT NOT NULL, + `host_id` int NOT NULL, + `ts` text NOT NULL DEFAULT (CURRENT_TIMESTAMP), + `cpu_percent` double, + `mem_percent` double, + `disk_percent` double, + `net_rx_bytes` int, + `net_tx_bytes` int, + CONSTRAINT `host_metrics_history_id` PRIMARY KEY(`id`) +); +--> statement-breakpoint +CREATE TABLE `host_metrics_preferences` ( + `id` int AUTO_INCREMENT NOT NULL, + `user_id` varchar(255) NOT NULL, + `host_id` int NOT NULL, + `layout` text NOT NULL, + `created_at` text NOT NULL DEFAULT (CURRENT_TIMESTAMP), + `updated_at` text NOT NULL DEFAULT (CURRENT_TIMESTAMP), + CONSTRAINT `host_metrics_preferences_id` PRIMARY KEY(`id`), + CONSTRAINT `idx_host_metrics_prefs_user_host` UNIQUE(`user_id`,`host_id`) +); +--> statement-breakpoint +CREATE TABLE `ssh_data` ( + `id` int AUTO_INCREMENT NOT NULL, + `user_id` varchar(255) NOT NULL, + `connection_type` text NOT NULL DEFAULT ('ssh'), + `name` varchar(255), + `ip` text NOT NULL, + `port` int NOT NULL, + `username` text NOT NULL, + `folder` text, + `tags` text, + `pin` boolean NOT NULL DEFAULT false, + `auth_type` text NOT NULL, + `use_warpgate` boolean NOT NULL DEFAULT false, + `share_ssh_auth` boolean NOT NULL DEFAULT false, + `force_keyboard_interactive` text, + `password` text, + `key` text, + `key_password` text, + `key_type` text, + `sudo_password` text, + `autostart_password` text, + `autostart_key` text, + `autostart_key_password` text, + `credential_id` int, + `override_credential_username` boolean, + `vault_profile_id` int, + `enable_terminal` boolean NOT NULL DEFAULT true, + `enable_session_logging` boolean NOT NULL DEFAULT true, + `allow_session_sharing` boolean NOT NULL DEFAULT true, + `enable_command_history` boolean NOT NULL DEFAULT true, + `enable_tunnel` boolean NOT NULL DEFAULT true, + `tunnel_connections` text, + `jump_hosts` text, + `enable_file_manager` boolean NOT NULL DEFAULT true, + `scp_legacy` boolean NOT NULL DEFAULT false, + `enable_docker` boolean NOT NULL DEFAULT false, + `enable_tmux_monitor` boolean NOT NULL DEFAULT false, + `show_terminal_in_sidebar` boolean NOT NULL DEFAULT true, + `show_file_manager_in_sidebar` boolean NOT NULL DEFAULT false, + `show_tunnel_in_sidebar` boolean NOT NULL DEFAULT false, + `show_docker_in_sidebar` boolean NOT NULL DEFAULT false, + `show_server_stats_in_sidebar` boolean NOT NULL DEFAULT false, + `default_path` text, + `stats_config` text, + `docker_config` text, + `enable_proxmox` boolean NOT NULL DEFAULT false, + `proxmox_config` text, + `terminal_config` text, + `quick_actions` text, + `notes` text, + `enable_ssh` boolean NOT NULL DEFAULT true, + `enable_rdp` boolean NOT NULL DEFAULT false, + `enable_vnc` boolean NOT NULL DEFAULT false, + `enable_telnet` boolean NOT NULL DEFAULT false, + `ssh_port` int DEFAULT 22, + `rdp_port` int DEFAULT 3389, + `vnc_port` int DEFAULT 5900, + `telnet_port` int DEFAULT 23, + `rdp_credential_id` int, + `rdp_user` text, + `rdp_password` text, + `rdp_domain` text, + `rdp_security` text, + `rdp_ignore_cert` boolean DEFAULT false, + `vnc_credential_id` int, + `vnc_password` text, + `vnc_user` text, + `telnet_user` text, + `telnet_password` text, + `telnet_credential_id` int, + `rdp_auth_type` text, + `vnc_auth_type` text, + `telnet_auth_type` text, + `domain` text, + `security` text, + `ignore_cert` boolean DEFAULT false, + `guacamole_config` text, + `use_socks5` boolean, + `socks5_host` text, + `socks5_port` int, + `socks5_username` text, + `socks5_password` text, + `socks5_proxy_chain` text, + `connection_origin` text, + `mac_address` text, + `wol_broadcast_address` text, + `port_knock_sequence` text, + `host_key_fingerprint` text, + `host_key_type` text, + `host_key_algorithm` text DEFAULT ('sha256'), + `host_key_first_seen` text, + `host_key_last_verified` text, + `host_key_changed_count` int DEFAULT 0, + `sync_id` varchar(255), + `created_at` text NOT NULL DEFAULT (CURRENT_TIMESTAMP), + `updated_at` text NOT NULL DEFAULT (CURRENT_TIMESTAMP), + CONSTRAINT `ssh_data_id` PRIMARY KEY(`id`), + CONSTRAINT `ssh_data_sync_id_unique` UNIQUE(`sync_id`) +); +--> statement-breakpoint +CREATE TABLE `network_topology` ( + `id` int AUTO_INCREMENT NOT NULL, + `user_id` varchar(255) NOT NULL, + `topology` text, + `created_at` text NOT NULL DEFAULT (CURRENT_TIMESTAMP), + `updated_at` text NOT NULL DEFAULT (CURRENT_TIMESTAMP), + CONSTRAINT `network_topology_id` PRIMARY KEY(`id`) +); +--> statement-breakpoint +CREATE TABLE `notification_channels` ( + `id` int AUTO_INCREMENT NOT NULL, + `user_id` varchar(255) NOT NULL, + `name` varchar(255) NOT NULL, + `type` text NOT NULL, + `config` text NOT NULL, + `enabled` boolean NOT NULL DEFAULT true, + `created_at` text NOT NULL DEFAULT (CURRENT_TIMESTAMP), + CONSTRAINT `notification_channels_id` PRIMARY KEY(`id`) +); +--> statement-breakpoint +CREATE TABLE `opkssh_tokens` ( + `id` int AUTO_INCREMENT NOT NULL, + `user_id` varchar(255) NOT NULL, + `host_id` int NOT NULL, + `ssh_cert` text NOT NULL, + `private_key` text NOT NULL, + `email` text, + `sub` text, + `issuer` text, + `audience` text, + `created_at` text NOT NULL DEFAULT (CURRENT_TIMESTAMP), + `expires_at` text NOT NULL, + `last_used` text, + CONSTRAINT `opkssh_tokens_id` PRIMARY KEY(`id`), + CONSTRAINT `idx_opkssh_tokens_user_host` UNIQUE(`user_id`,`host_id`) +); +--> statement-breakpoint +CREATE TABLE `recent_activity` ( + `id` int AUTO_INCREMENT NOT NULL, + `user_id` varchar(255) NOT NULL, + `type` text NOT NULL, + `host_id` int NOT NULL, + `host_name` text, + `timestamp` text NOT NULL DEFAULT (CURRENT_TIMESTAMP), + CONSTRAINT `recent_activity_id` PRIMARY KEY(`id`) +); +--> statement-breakpoint +CREATE TABLE `roles` ( + `id` int AUTO_INCREMENT NOT NULL, + `name` varchar(255) NOT NULL, + `display_name` text NOT NULL, + `description` text, + `is_system` boolean NOT NULL DEFAULT false, + `permissions` text, + `created_at` text NOT NULL DEFAULT (CURRENT_TIMESTAMP), + `updated_at` text NOT NULL DEFAULT (CURRENT_TIMESTAMP), + CONSTRAINT `roles_id` PRIMARY KEY(`id`), + CONSTRAINT `roles_name_unique` UNIQUE(`name`) +); +--> statement-breakpoint +CREATE TABLE `session_recordings` ( + `id` int AUTO_INCREMENT NOT NULL, + `host_id` int NOT NULL, + `user_id` varchar(255), + `username` text, + `access_id` int, + `started_at` text NOT NULL DEFAULT (CURRENT_TIMESTAMP), + `ended_at` text, + `duration` int, + `commands` text, + `dangerous_actions` text, + `recording_path` text, + `protocol` varchar(255) NOT NULL DEFAULT 'ssh', + `format` text NOT NULL DEFAULT ('text'), + `terminated_by_owner` boolean DEFAULT false, + `termination_reason` text, + CONSTRAINT `session_recordings_id` PRIMARY KEY(`id`) +); +--> statement-breakpoint +CREATE TABLE `session_share_participants` ( + `id` int AUTO_INCREMENT NOT NULL, + `share_id` varchar(255) NOT NULL, + `user_id` varchar(255), + `guest_label` text, + `joined_at` text NOT NULL DEFAULT (CURRENT_TIMESTAMP), + `left_at` text, + CONSTRAINT `session_share_participants_id` PRIMARY KEY(`id`) +); +--> statement-breakpoint +CREATE TABLE `session_shares` ( + `id` varchar(255) NOT NULL, + `host_id` int NOT NULL, + `owner_user_id` varchar(255) NOT NULL, + `protocol` varchar(255) NOT NULL, + `session_id` text NOT NULL, + `tab_instance_id` text, + `share_type` text NOT NULL, + `target_user_id` varchar(255), + `link_token` varchar(255), + `permission_level` text NOT NULL DEFAULT ('read-only'), + `created_at` text NOT NULL DEFAULT (CURRENT_TIMESTAMP), + `expires_at` text NOT NULL, + `revoked_at` text, + `last_joined_at` text, + `join_count` int NOT NULL DEFAULT 0, + CONSTRAINT `session_shares_id` PRIMARY KEY(`id`), + CONSTRAINT `session_shares_link_token_unique` UNIQUE(`link_token`) +); +--> statement-breakpoint +CREATE TABLE `sessions` ( + `id` varchar(255) NOT NULL, + `user_id` varchar(255) NOT NULL, + `jwt_token` text NOT NULL, + `device_type` text NOT NULL, + `device_info` text NOT NULL, + `oidc_sub` text, + `oidc_sid` text, + `sso_provider_id` int, + `created_at` text NOT NULL DEFAULT (CURRENT_TIMESTAMP), + `expires_at` text NOT NULL, + `last_active_at` text NOT NULL DEFAULT (CURRENT_TIMESTAMP), + CONSTRAINT `sessions_id` PRIMARY KEY(`id`) +); +--> statement-breakpoint +CREATE TABLE `settings` ( + `key` varchar(255) NOT NULL, + `value` text NOT NULL, + CONSTRAINT `settings_key` PRIMARY KEY(`key`) +); +--> statement-breakpoint +CREATE TABLE `shared_host_auth_overrides` ( + `id` int AUTO_INCREMENT NOT NULL, + `host_id` int NOT NULL, + `user_id` varchar(255) NOT NULL, + `protocol` varchar(255) NOT NULL DEFAULT 'ssh', + `credential_id` int NOT NULL, + `created_at` text NOT NULL DEFAULT (CURRENT_TIMESTAMP), + `updated_at` text NOT NULL DEFAULT (CURRENT_TIMESTAMP), + CONSTRAINT `shared_host_auth_overrides_id` PRIMARY KEY(`id`), + CONSTRAINT `shared_host_auth_overrides_host_user_protocol_unique` UNIQUE(`host_id`,`user_id`,`protocol`) +); +--> statement-breakpoint +CREATE TABLE `shared_host_secrets` ( + `id` int AUTO_INCREMENT NOT NULL, + `host_access_id` int NOT NULL, + `target_user_id` varchar(255) NOT NULL, + `protocol` varchar(255) NOT NULL DEFAULT 'ssh', + `source_type` text NOT NULL DEFAULT ('credential'), + `original_credential_id` int, + `encrypted_username` text, + `encrypted_auth_type` text, + `encrypted_password` text, + `encrypted_key` text, + `encrypted_key_password` text, + `encrypted_key_type` text, + `encrypted_domain` text, + `created_at` text NOT NULL DEFAULT (CURRENT_TIMESTAMP), + `updated_at` text NOT NULL DEFAULT (CURRENT_TIMESTAMP), + CONSTRAINT `shared_host_secrets_id` PRIMARY KEY(`id`), + CONSTRAINT `idx_shared_host_secrets_scope` UNIQUE(`host_access_id`,`target_user_id`,`protocol`) +); +--> statement-breakpoint +CREATE TABLE `snippet_access` ( + `id` int AUTO_INCREMENT NOT NULL, + `snippet_id` int NOT NULL, + `user_id` varchar(255), + `role_id` int, + `granted_by` varchar(255) NOT NULL, + `permission_level` text NOT NULL DEFAULT ('view'), + `expires_at` text, + `created_at` text NOT NULL DEFAULT (CURRENT_TIMESTAMP), + CONSTRAINT `snippet_access_id` PRIMARY KEY(`id`) +); +--> statement-breakpoint +CREATE TABLE `snippet_folders` ( + `id` int AUTO_INCREMENT NOT NULL, + `user_id` varchar(255) NOT NULL, + `name` varchar(255) NOT NULL, + `color` text, + `icon` text, + `sync_id` varchar(255), + `created_at` text NOT NULL DEFAULT (CURRENT_TIMESTAMP), + `updated_at` text NOT NULL DEFAULT (CURRENT_TIMESTAMP), + CONSTRAINT `snippet_folders_id` PRIMARY KEY(`id`), + CONSTRAINT `snippet_folders_sync_id_unique` UNIQUE(`sync_id`) +); +--> statement-breakpoint +CREATE TABLE `snippets` ( + `id` int AUTO_INCREMENT NOT NULL, + `user_id` varchar(255) NOT NULL, + `name` varchar(255) NOT NULL, + `content` text NOT NULL, + `description` text, + `folder` text, + `order` int NOT NULL DEFAULT 0, + `sync_id` varchar(255), + `created_at` text NOT NULL DEFAULT (CURRENT_TIMESTAMP), + `updated_at` text NOT NULL DEFAULT (CURRENT_TIMESTAMP), + `host_filter` text, + CONSTRAINT `snippets_id` PRIMARY KEY(`id`), + CONSTRAINT `snippets_sync_id_unique` UNIQUE(`sync_id`) +); +--> statement-breakpoint +CREATE TABLE `ssh_credential_usage` ( + `id` int AUTO_INCREMENT NOT NULL, + `credential_id` int NOT NULL, + `host_id` int NOT NULL, + `user_id` varchar(255) NOT NULL, + `used_at` text NOT NULL DEFAULT (CURRENT_TIMESTAMP), + CONSTRAINT `ssh_credential_usage_id` PRIMARY KEY(`id`) +); +--> statement-breakpoint +CREATE TABLE `ssh_credentials` ( + `id` int AUTO_INCREMENT NOT NULL, + `user_id` varchar(255) NOT NULL, + `name` varchar(255) NOT NULL, + `description` text, + `folder` text, + `tags` text, + `auth_type` text NOT NULL, + `username` text, + `password` text, + `key` text, + `private_key` text, + `public_key` text, + `key_password` text, + `key_type` text, + `detected_key_type` text, + `cert_public_key` text, + `usage_count` int NOT NULL DEFAULT 0, + `last_used` text, + `sync_id` varchar(255), + `created_at` text NOT NULL DEFAULT (CURRENT_TIMESTAMP), + `updated_at` text NOT NULL DEFAULT (CURRENT_TIMESTAMP), + CONSTRAINT `ssh_credentials_id` PRIMARY KEY(`id`), + CONSTRAINT `ssh_credentials_sync_id_unique` UNIQUE(`sync_id`) +); +--> statement-breakpoint +CREATE TABLE `ssh_folders` ( + `id` int AUTO_INCREMENT NOT NULL, + `user_id` varchar(255) NOT NULL, + `name` varchar(255) NOT NULL, + `color` text, + `icon` text, + `credential_id` int, + `sync_id` varchar(255), + `created_at` text NOT NULL DEFAULT (CURRENT_TIMESTAMP), + `updated_at` text NOT NULL DEFAULT (CURRENT_TIMESTAMP), + CONSTRAINT `ssh_folders_id` PRIMARY KEY(`id`), + CONSTRAINT `ssh_folders_sync_id_unique` UNIQUE(`sync_id`) +); +--> statement-breakpoint +CREATE TABLE `sso_providers` ( + `id` int AUTO_INCREMENT NOT NULL, + `name` varchar(255) NOT NULL, + `type` text NOT NULL, + `enabled` boolean NOT NULL DEFAULT true, + `display_order` int NOT NULL DEFAULT 0, + `config` text NOT NULL, + `created_at` text NOT NULL DEFAULT (CURRENT_TIMESTAMP), + `updated_at` text NOT NULL DEFAULT (CURRENT_TIMESTAMP), + CONSTRAINT `sso_providers_id` PRIMARY KEY(`id`) +); +--> statement-breakpoint +CREATE TABLE `sync_tombstones` ( + `id` int AUTO_INCREMENT NOT NULL, + `user_id` varchar(255) NOT NULL, + `entity_type` text NOT NULL, + `sync_id` varchar(255) NOT NULL, + `deleted_at` text NOT NULL DEFAULT (CURRENT_TIMESTAMP), + CONSTRAINT `sync_tombstones_id` PRIMARY KEY(`id`) +); +--> statement-breakpoint +CREATE TABLE `termix_identities` ( + `id` int AUTO_INCREMENT NOT NULL, + `user_id` varchar(255) NOT NULL, + `handle` varchar(255) NOT NULL, + `description` text, + `created_at` text NOT NULL DEFAULT (CURRENT_TIMESTAMP), + `updated_at` text NOT NULL DEFAULT (CURRENT_TIMESTAMP), + CONSTRAINT `termix_identities_id` PRIMARY KEY(`id`), + CONSTRAINT `termix_identities_user_id_unique` UNIQUE(`user_id`), + CONSTRAINT `termix_identities_handle_unique` UNIQUE(`handle`) +); +--> statement-breakpoint +CREATE TABLE `termix_identity_ca` ( + `id` int AUTO_INCREMENT NOT NULL, + `identity_id` int NOT NULL, + `user_id` varchar(255) NOT NULL, + `public_key` text NOT NULL, + `private_key` text NOT NULL, + `validity_days` int NOT NULL DEFAULT 90, + `created_at` text NOT NULL DEFAULT (CURRENT_TIMESTAMP), + `updated_at` text NOT NULL DEFAULT (CURRENT_TIMESTAMP), + CONSTRAINT `termix_identity_ca_id` PRIMARY KEY(`id`), + CONSTRAINT `termix_identity_ca_identity_id_unique` UNIQUE(`identity_id`) +); +--> statement-breakpoint +CREATE TABLE `termix_identity_keys` ( + `id` int AUTO_INCREMENT NOT NULL, + `identity_id` int NOT NULL, + `user_id` varchar(255) NOT NULL, + `public_key` text NOT NULL, + `key_type` text NOT NULL, + `algorithm` text NOT NULL, + `label` text, + `comment` text, + `source` text NOT NULL DEFAULT ('manual'), + `credential_id` int, + `enabled` boolean NOT NULL DEFAULT true, + `created_at` text NOT NULL DEFAULT (CURRENT_TIMESTAMP), + CONSTRAINT `termix_identity_keys_id` PRIMARY KEY(`id`) +); +--> statement-breakpoint +CREATE TABLE `tmux_session_tags` ( + `id` int AUTO_INCREMENT NOT NULL, + `user_id` varchar(255) NOT NULL, + `host_id` int NOT NULL, + `session_name` text NOT NULL, + `tag` text NOT NULL, + `created_at` text NOT NULL DEFAULT (CURRENT_TIMESTAMP), + CONSTRAINT `tmux_session_tags_id` PRIMARY KEY(`id`) +); +--> statement-breakpoint +CREATE TABLE `transfer_recent` ( + `id` int AUTO_INCREMENT NOT NULL, + `user_id` varchar(255) NOT NULL, + `source_host_id` int NOT NULL, + `dest_host_id` int NOT NULL, + `dest_path` text NOT NULL, + `dest_path_label` text NOT NULL, + `last_used` text NOT NULL DEFAULT (CURRENT_TIMESTAMP), + CONSTRAINT `transfer_recent_id` PRIMARY KEY(`id`) +); +--> statement-breakpoint +CREATE TABLE `trusted_devices` ( + `id` varchar(255) NOT NULL, + `user_id` varchar(255) NOT NULL, + `device_fingerprint` text NOT NULL, + `device_type` text NOT NULL, + `device_info` text NOT NULL, + `created_at` text NOT NULL DEFAULT (CURRENT_TIMESTAMP), + `expires_at` text NOT NULL, + `last_used_at` text NOT NULL DEFAULT (CURRENT_TIMESTAMP), + CONSTRAINT `trusted_devices_id` PRIMARY KEY(`id`) +); +--> statement-breakpoint +CREATE TABLE `user_open_tabs` ( + `id` varchar(255) NOT NULL, + `user_id` varchar(255) NOT NULL, + `tab_type` text NOT NULL, + `host_id` int, + `label` text NOT NULL, + `tab_order` int NOT NULL DEFAULT 0, + `backend_session_id` text, + `created_at` text NOT NULL DEFAULT (CURRENT_TIMESTAMP), + `updated_at` text NOT NULL DEFAULT (CURRENT_TIMESTAMP), + CONSTRAINT `user_open_tabs_id` PRIMARY KEY(`id`) +); +--> statement-breakpoint +CREATE TABLE `user_preferences` ( + `user_id` varchar(255) NOT NULL, + `reopen_tabs_on_login` boolean NOT NULL DEFAULT false, + `theme` text, + `font_size` text, + `accent_color` text, + `language` text, + `storage_mode` text, + `command_autocomplete` boolean, + `command_palette_enabled` boolean, + `show_host_tags` boolean, + `host_tray_on_click` boolean, + `pin_app_rail` boolean, + `expand_app_rail_on_hover` boolean, + `folders_collapsed` boolean, + `confirm_snippet_execution` boolean, + `disable_update_check` boolean, + `confirm_tab_close` boolean, + `hidden_rail_tabs` text, + `compact_host_view` boolean, + `status_color_scheme` text, + `custom_themes` text, + `custom_keybindings` text, + `updated_at` text NOT NULL DEFAULT (CURRENT_TIMESTAMP), + CONSTRAINT `user_preferences_user_id` PRIMARY KEY(`user_id`) +); +--> statement-breakpoint +CREATE TABLE `user_roles` ( + `id` int AUTO_INCREMENT NOT NULL, + `user_id` varchar(255) NOT NULL, + `role_id` int NOT NULL, + `granted_by` varchar(255), + `granted_at` text NOT NULL DEFAULT (CURRENT_TIMESTAMP), + CONSTRAINT `user_roles_id` PRIMARY KEY(`id`), + CONSTRAINT `idx_user_roles_user_role` UNIQUE(`user_id`,`role_id`) +); +--> statement-breakpoint +CREATE TABLE `users` ( + `id` varchar(255) NOT NULL, + `username` text NOT NULL, + `password_hash` text NOT NULL, + `is_admin` boolean NOT NULL DEFAULT false, + `is_oidc` boolean NOT NULL DEFAULT false, + `oidc_identifier` text, + `sso_provider_id` int, + `client_id` text, + `client_secret` text, + `issuer_url` text, + `authorization_url` text, + `token_url` text, + `identifier_path` text, + `name_path` text, + `scopes` text DEFAULT ('openid email profile'), + `totp_secret` text, + `totp_enabled` boolean NOT NULL DEFAULT false, + `totp_backup_codes` text, + `registered_at` text NOT NULL DEFAULT (CURRENT_TIMESTAMP), + `donation_modal_dismissed` boolean NOT NULL DEFAULT false, + CONSTRAINT `users_id` PRIMARY KEY(`id`) +); +--> statement-breakpoint +CREATE TABLE `vault_profiles` ( + `id` int AUTO_INCREMENT NOT NULL, + `user_id` varchar(255) NOT NULL, + `name` varchar(255) NOT NULL, + `description` text, + `folder` text, + `tags` text, + `vault_addr` text NOT NULL, + `vault_namespace` text, + `oidc_mount` text, + `oidc_role` text, + `ssh_mount` text, + `ssh_role` text NOT NULL, + `valid_principals` text, + `key_type` text, + `shared` boolean NOT NULL DEFAULT false, + `sync_id` varchar(255), + `created_at` text NOT NULL DEFAULT (CURRENT_TIMESTAMP), + `updated_at` text NOT NULL DEFAULT (CURRENT_TIMESTAMP), + CONSTRAINT `vault_profiles_id` PRIMARY KEY(`id`), + CONSTRAINT `vault_profiles_sync_id_unique` UNIQUE(`sync_id`) +); +--> statement-breakpoint +CREATE TABLE `vault_tokens` ( + `id` int AUTO_INCREMENT NOT NULL, + `user_id` varchar(255) NOT NULL, + `profile_id` int NOT NULL, + `ssh_cert` text NOT NULL, + `private_key` text NOT NULL, + `created_at` text NOT NULL DEFAULT (CURRENT_TIMESTAMP), + `expires_at` text NOT NULL, + `last_used` text, + CONSTRAINT `vault_tokens_id` PRIMARY KEY(`id`), + CONSTRAINT `idx_vault_tokens_user_profile` UNIQUE(`user_id`,`profile_id`) +); +--> statement-breakpoint +CREATE TABLE `webauthn_credentials` ( + `id` varchar(255) NOT NULL, + `user_id` varchar(255) NOT NULL, + `name` varchar(255) NOT NULL, + `credential_id` text NOT NULL, + `public_key` text NOT NULL, + `counter` int NOT NULL DEFAULT 0, + `device_type` text, + `backed_up` boolean NOT NULL DEFAULT false, + `transports` text, + `user_verification` text NOT NULL DEFAULT ('preferred'), + `created_at` text NOT NULL DEFAULT (CURRENT_TIMESTAMP), + `last_used_at` text, + CONSTRAINT `webauthn_credentials_id` PRIMARY KEY(`id`) +); +--> statement-breakpoint +ALTER TABLE `alert_firings` ADD CONSTRAINT `alert_firings_user_id_users_id_fk` FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE `alert_firings` ADD CONSTRAINT `alert_firings_rule_id_alert_rules_id_fk` FOREIGN KEY (`rule_id`) REFERENCES `alert_rules`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE `alert_rule_channels` ADD CONSTRAINT `alert_rule_channels_rule_id_alert_rules_id_fk` FOREIGN KEY (`rule_id`) REFERENCES `alert_rules`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE `alert_rule_channels` ADD CONSTRAINT `alert_rule_channels_channel_id_notification_channels_id_fk` FOREIGN KEY (`channel_id`) REFERENCES `notification_channels`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE `alert_rules` ADD CONSTRAINT `alert_rules_user_id_users_id_fk` FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE `alert_rules` ADD CONSTRAINT `alert_rules_host_id_ssh_data_id_fk` FOREIGN KEY (`host_id`) REFERENCES `ssh_data`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE `api_keys` ADD CONSTRAINT `api_keys_user_id_users_id_fk` FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE `audit_logs` ADD CONSTRAINT `audit_logs_user_id_users_id_fk` FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE set null ON UPDATE no action;--> statement-breakpoint +ALTER TABLE `c2s_tunnel_presets` ADD CONSTRAINT `c2s_tunnel_presets_user_id_users_id_fk` FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE `command_history` ADD CONSTRAINT `command_history_user_id_users_id_fk` FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE `command_history` ADD CONSTRAINT `command_history_host_id_ssh_data_id_fk` FOREIGN KEY (`host_id`) REFERENCES `ssh_data`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE `dashboard_service_links` ADD CONSTRAINT `dashboard_service_links_user_id_users_id_fk` FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE `dismissed_alerts` ADD CONSTRAINT `dismissed_alerts_user_id_users_id_fk` FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE `file_manager_pinned` ADD CONSTRAINT `file_manager_pinned_user_id_users_id_fk` FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE `file_manager_pinned` ADD CONSTRAINT `file_manager_pinned_host_id_ssh_data_id_fk` FOREIGN KEY (`host_id`) REFERENCES `ssh_data`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE `file_manager_recent` ADD CONSTRAINT `file_manager_recent_user_id_users_id_fk` FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE `file_manager_recent` ADD CONSTRAINT `file_manager_recent_host_id_ssh_data_id_fk` FOREIGN KEY (`host_id`) REFERENCES `ssh_data`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE `file_manager_shortcuts` ADD CONSTRAINT `file_manager_shortcuts_user_id_users_id_fk` FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE `file_manager_shortcuts` ADD CONSTRAINT `file_manager_shortcuts_host_id_ssh_data_id_fk` FOREIGN KEY (`host_id`) REFERENCES `ssh_data`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE `homepage_items` ADD CONSTRAINT `homepage_items_user_id_users_id_fk` FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE `homepage_layouts` ADD CONSTRAINT `homepage_layouts_user_id_users_id_fk` FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE `host_access` ADD CONSTRAINT `host_access_host_id_ssh_data_id_fk` FOREIGN KEY (`host_id`) REFERENCES `ssh_data`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE `host_access` ADD CONSTRAINT `host_access_user_id_users_id_fk` FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE `host_access` ADD CONSTRAINT `host_access_role_id_roles_id_fk` FOREIGN KEY (`role_id`) REFERENCES `roles`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE `host_access` ADD CONSTRAINT `host_access_granted_by_users_id_fk` FOREIGN KEY (`granted_by`) REFERENCES `users`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE `host_health_checks` ADD CONSTRAINT `host_health_checks_user_id_users_id_fk` FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE `host_health_checks` ADD CONSTRAINT `host_health_checks_host_id_ssh_data_id_fk` FOREIGN KEY (`host_id`) REFERENCES `ssh_data`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE `host_health_history` ADD CONSTRAINT `host_health_history_user_id_users_id_fk` FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE `host_health_history` ADD CONSTRAINT `host_health_history_host_id_ssh_data_id_fk` FOREIGN KEY (`host_id`) REFERENCES `ssh_data`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE `host_metrics_history` ADD CONSTRAINT `host_metrics_history_host_id_ssh_data_id_fk` FOREIGN KEY (`host_id`) REFERENCES `ssh_data`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE `host_metrics_preferences` ADD CONSTRAINT `host_metrics_preferences_user_id_users_id_fk` FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE `host_metrics_preferences` ADD CONSTRAINT `host_metrics_preferences_host_id_ssh_data_id_fk` FOREIGN KEY (`host_id`) REFERENCES `ssh_data`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE `ssh_data` ADD CONSTRAINT `ssh_data_user_id_users_id_fk` FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE `ssh_data` ADD CONSTRAINT `ssh_data_credential_id_ssh_credentials_id_fk` FOREIGN KEY (`credential_id`) REFERENCES `ssh_credentials`(`id`) ON DELETE set null ON UPDATE no action;--> statement-breakpoint +ALTER TABLE `ssh_data` ADD CONSTRAINT `ssh_data_vault_profile_id_vault_profiles_id_fk` FOREIGN KEY (`vault_profile_id`) REFERENCES `vault_profiles`(`id`) ON DELETE set null ON UPDATE no action;--> statement-breakpoint +ALTER TABLE `ssh_data` ADD CONSTRAINT `ssh_data_rdp_credential_id_ssh_credentials_id_fk` FOREIGN KEY (`rdp_credential_id`) REFERENCES `ssh_credentials`(`id`) ON DELETE set null ON UPDATE no action;--> statement-breakpoint +ALTER TABLE `ssh_data` ADD CONSTRAINT `ssh_data_vnc_credential_id_ssh_credentials_id_fk` FOREIGN KEY (`vnc_credential_id`) REFERENCES `ssh_credentials`(`id`) ON DELETE set null ON UPDATE no action;--> statement-breakpoint +ALTER TABLE `ssh_data` ADD CONSTRAINT `ssh_data_telnet_credential_id_ssh_credentials_id_fk` FOREIGN KEY (`telnet_credential_id`) REFERENCES `ssh_credentials`(`id`) ON DELETE set null ON UPDATE no action;--> statement-breakpoint +ALTER TABLE `network_topology` ADD CONSTRAINT `network_topology_user_id_users_id_fk` FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE `notification_channels` ADD CONSTRAINT `notification_channels_user_id_users_id_fk` FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE `opkssh_tokens` ADD CONSTRAINT `opkssh_tokens_user_id_users_id_fk` FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE `opkssh_tokens` ADD CONSTRAINT `opkssh_tokens_host_id_ssh_data_id_fk` FOREIGN KEY (`host_id`) REFERENCES `ssh_data`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE `recent_activity` ADD CONSTRAINT `recent_activity_user_id_users_id_fk` FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE `recent_activity` ADD CONSTRAINT `recent_activity_host_id_ssh_data_id_fk` FOREIGN KEY (`host_id`) REFERENCES `ssh_data`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE `session_recordings` ADD CONSTRAINT `session_recordings_host_id_ssh_data_id_fk` FOREIGN KEY (`host_id`) REFERENCES `ssh_data`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE `session_recordings` ADD CONSTRAINT `session_recordings_user_id_users_id_fk` FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE set null ON UPDATE no action;--> statement-breakpoint +ALTER TABLE `session_recordings` ADD CONSTRAINT `session_recordings_access_id_host_access_id_fk` FOREIGN KEY (`access_id`) REFERENCES `host_access`(`id`) ON DELETE set null ON UPDATE no action;--> statement-breakpoint +ALTER TABLE `session_share_participants` ADD CONSTRAINT `session_share_participants_share_id_session_shares_id_fk` FOREIGN KEY (`share_id`) REFERENCES `session_shares`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE `session_share_participants` ADD CONSTRAINT `session_share_participants_user_id_users_id_fk` FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE `session_shares` ADD CONSTRAINT `session_shares_host_id_ssh_data_id_fk` FOREIGN KEY (`host_id`) REFERENCES `ssh_data`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE `session_shares` ADD CONSTRAINT `session_shares_owner_user_id_users_id_fk` FOREIGN KEY (`owner_user_id`) REFERENCES `users`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE `session_shares` ADD CONSTRAINT `session_shares_target_user_id_users_id_fk` FOREIGN KEY (`target_user_id`) REFERENCES `users`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE `sessions` ADD CONSTRAINT `sessions_user_id_users_id_fk` FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE `shared_host_auth_overrides` ADD CONSTRAINT `shared_host_auth_overrides_host_id_ssh_data_id_fk` FOREIGN KEY (`host_id`) REFERENCES `ssh_data`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE `shared_host_auth_overrides` ADD CONSTRAINT `shared_host_auth_overrides_user_id_users_id_fk` FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE `shared_host_auth_overrides` ADD CONSTRAINT `shared_host_auth_overrides_credential_id_ssh_credentials_id_fk` FOREIGN KEY (`credential_id`) REFERENCES `ssh_credentials`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE `shared_host_secrets` ADD CONSTRAINT `shared_host_secrets_host_access_id_host_access_id_fk` FOREIGN KEY (`host_access_id`) REFERENCES `host_access`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE `shared_host_secrets` ADD CONSTRAINT `shared_host_secrets_target_user_id_users_id_fk` FOREIGN KEY (`target_user_id`) REFERENCES `users`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE `shared_host_secrets` ADD CONSTRAINT `shared_host_secrets_original_credential_id_ssh_credentials_id_fk` FOREIGN KEY (`original_credential_id`) REFERENCES `ssh_credentials`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE `snippet_access` ADD CONSTRAINT `snippet_access_snippet_id_snippets_id_fk` FOREIGN KEY (`snippet_id`) REFERENCES `snippets`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE `snippet_access` ADD CONSTRAINT `snippet_access_user_id_users_id_fk` FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE `snippet_access` ADD CONSTRAINT `snippet_access_role_id_roles_id_fk` FOREIGN KEY (`role_id`) REFERENCES `roles`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE `snippet_access` ADD CONSTRAINT `snippet_access_granted_by_users_id_fk` FOREIGN KEY (`granted_by`) REFERENCES `users`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE `snippet_folders` ADD CONSTRAINT `snippet_folders_user_id_users_id_fk` FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE `snippets` ADD CONSTRAINT `snippets_user_id_users_id_fk` FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE `ssh_credential_usage` ADD CONSTRAINT `ssh_credential_usage_credential_id_ssh_credentials_id_fk` FOREIGN KEY (`credential_id`) REFERENCES `ssh_credentials`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE `ssh_credential_usage` ADD CONSTRAINT `ssh_credential_usage_host_id_ssh_data_id_fk` FOREIGN KEY (`host_id`) REFERENCES `ssh_data`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE `ssh_credential_usage` ADD CONSTRAINT `ssh_credential_usage_user_id_users_id_fk` FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE `ssh_credentials` ADD CONSTRAINT `ssh_credentials_user_id_users_id_fk` FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE `ssh_folders` ADD CONSTRAINT `ssh_folders_user_id_users_id_fk` FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE `ssh_folders` ADD CONSTRAINT `ssh_folders_credential_id_ssh_credentials_id_fk` FOREIGN KEY (`credential_id`) REFERENCES `ssh_credentials`(`id`) ON DELETE set null ON UPDATE no action;--> statement-breakpoint +ALTER TABLE `sync_tombstones` ADD CONSTRAINT `sync_tombstones_user_id_users_id_fk` FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE `termix_identities` ADD CONSTRAINT `termix_identities_user_id_users_id_fk` FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE `termix_identity_ca` ADD CONSTRAINT `termix_identity_ca_identity_id_termix_identities_id_fk` FOREIGN KEY (`identity_id`) REFERENCES `termix_identities`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE `termix_identity_ca` ADD CONSTRAINT `termix_identity_ca_user_id_users_id_fk` FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE `termix_identity_keys` ADD CONSTRAINT `termix_identity_keys_identity_id_termix_identities_id_fk` FOREIGN KEY (`identity_id`) REFERENCES `termix_identities`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE `termix_identity_keys` ADD CONSTRAINT `termix_identity_keys_user_id_users_id_fk` FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE `termix_identity_keys` ADD CONSTRAINT `termix_identity_keys_credential_id_ssh_credentials_id_fk` FOREIGN KEY (`credential_id`) REFERENCES `ssh_credentials`(`id`) ON DELETE set null ON UPDATE no action;--> statement-breakpoint +ALTER TABLE `tmux_session_tags` ADD CONSTRAINT `tmux_session_tags_user_id_users_id_fk` FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE `tmux_session_tags` ADD CONSTRAINT `tmux_session_tags_host_id_ssh_data_id_fk` FOREIGN KEY (`host_id`) REFERENCES `ssh_data`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE `transfer_recent` ADD CONSTRAINT `transfer_recent_user_id_users_id_fk` FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE `transfer_recent` ADD CONSTRAINT `transfer_recent_source_host_id_ssh_data_id_fk` FOREIGN KEY (`source_host_id`) REFERENCES `ssh_data`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE `transfer_recent` ADD CONSTRAINT `transfer_recent_dest_host_id_ssh_data_id_fk` FOREIGN KEY (`dest_host_id`) REFERENCES `ssh_data`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE `trusted_devices` ADD CONSTRAINT `trusted_devices_user_id_users_id_fk` FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE `user_open_tabs` ADD CONSTRAINT `user_open_tabs_user_id_users_id_fk` FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE `user_open_tabs` ADD CONSTRAINT `user_open_tabs_host_id_ssh_data_id_fk` FOREIGN KEY (`host_id`) REFERENCES `ssh_data`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE `user_preferences` ADD CONSTRAINT `user_preferences_user_id_users_id_fk` FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE `user_roles` ADD CONSTRAINT `user_roles_user_id_users_id_fk` FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE `user_roles` ADD CONSTRAINT `user_roles_role_id_roles_id_fk` FOREIGN KEY (`role_id`) REFERENCES `roles`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE `user_roles` ADD CONSTRAINT `user_roles_granted_by_users_id_fk` FOREIGN KEY (`granted_by`) REFERENCES `users`(`id`) ON DELETE set null ON UPDATE no action;--> statement-breakpoint +ALTER TABLE `vault_profiles` ADD CONSTRAINT `vault_profiles_user_id_users_id_fk` FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE `vault_tokens` ADD CONSTRAINT `vault_tokens_user_id_users_id_fk` FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE `vault_tokens` ADD CONSTRAINT `vault_tokens_profile_id_vault_profiles_id_fk` FOREIGN KEY (`profile_id`) REFERENCES `vault_profiles`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE `webauthn_credentials` ADD CONSTRAINT `webauthn_credentials_user_id_users_id_fk` FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE cascade ON UPDATE no action; \ No newline at end of file diff --git a/drizzle/mysql/meta/0000_snapshot.json b/drizzle/mysql/meta/0000_snapshot.json new file mode 100644 index 00000000..4c6f31cf --- /dev/null +++ b/drizzle/mysql/meta/0000_snapshot.json @@ -0,0 +1,6437 @@ +{ + "version": "5", + "dialect": "mysql", + "id": "2b238de4-3ad7-4b57-8c58-308d6da06c02", + "prevId": "00000000-0000-0000-0000-000000000000", + "tables": { + "alert_firings": { + "name": "alert_firings", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "rule_id": { + "name": "rule_id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "host_id": { + "name": "host_id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "host_name": { + "name": "host_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "fired_at": { + "name": "fired_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "resolved_at": { + "name": "resolved_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "double", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "severity": { + "name": "severity", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "('warning')" + }, + "acknowledged": { + "name": "acknowledged", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + } + }, + "indexes": {}, + "foreignKeys": { + "alert_firings_user_id_users_id_fk": { + "name": "alert_firings_user_id_users_id_fk", + "tableFrom": "alert_firings", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "alert_firings_rule_id_alert_rules_id_fk": { + "name": "alert_firings_rule_id_alert_rules_id_fk", + "tableFrom": "alert_firings", + "tableTo": "alert_rules", + "columnsFrom": [ + "rule_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "alert_firings_id": { + "name": "alert_firings_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "alert_rule_channels": { + "name": "alert_rule_channels", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "rule_id": { + "name": "rule_id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "channel_id": { + "name": "channel_id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "alert_rule_channels_rule_id_alert_rules_id_fk": { + "name": "alert_rule_channels_rule_id_alert_rules_id_fk", + "tableFrom": "alert_rule_channels", + "tableTo": "alert_rules", + "columnsFrom": [ + "rule_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "alert_rule_channels_channel_id_notification_channels_id_fk": { + "name": "alert_rule_channels_channel_id_notification_channels_id_fk", + "tableFrom": "alert_rule_channels", + "tableTo": "notification_channels", + "columnsFrom": [ + "channel_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "alert_rule_channels_id": { + "name": "alert_rule_channels_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "alert_rules": { + "name": "alert_rules", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "host_id": { + "name": "host_id", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "trigger_type": { + "name": "trigger_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "threshold_value": { + "name": "threshold_value", + "type": "double", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "threshold_duration_seconds": { + "name": "threshold_duration_seconds", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cooldown_minutes": { + "name": "cooldown_minutes", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 15 + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": {}, + "foreignKeys": { + "alert_rules_user_id_users_id_fk": { + "name": "alert_rules_user_id_users_id_fk", + "tableFrom": "alert_rules", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "alert_rules_host_id_ssh_data_id_fk": { + "name": "alert_rules_host_id_ssh_data_id_fk", + "tableFrom": "alert_rules", + "tableTo": "ssh_data", + "columnsFrom": [ + "host_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "alert_rules_id": { + "name": "alert_rules_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "api_keys": { + "name": "api_keys", + "columns": { + "id": { + "name": "id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "token_prefix": { + "name": "token_prefix", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "expires_at": { + "name": "expires_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_used_at": { + "name": "last_used_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + } + }, + "indexes": {}, + "foreignKeys": { + "api_keys_user_id_users_id_fk": { + "name": "api_keys_user_id_users_id_fk", + "tableFrom": "api_keys", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "api_keys_id": { + "name": "api_keys_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "audit_logs": { + "name": "audit_logs", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "resource_type": { + "name": "resource_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "resource_id": { + "name": "resource_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "resource_name": { + "name": "resource_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "details": { + "name": "details", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "success": { + "name": "success", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "timestamp": { + "name": "timestamp", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": {}, + "foreignKeys": { + "audit_logs_user_id_users_id_fk": { + "name": "audit_logs_user_id_users_id_fk", + "tableFrom": "audit_logs", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "audit_logs_id": { + "name": "audit_logs_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "c2s_tunnel_presets": { + "name": "c2s_tunnel_presets", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "config": { + "name": "config", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "platform": { + "name": "platform", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "computer_name": { + "name": "computer_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": {}, + "foreignKeys": { + "c2s_tunnel_presets_user_id_users_id_fk": { + "name": "c2s_tunnel_presets_user_id_users_id_fk", + "tableFrom": "c2s_tunnel_presets", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "c2s_tunnel_presets_id": { + "name": "c2s_tunnel_presets_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "command_history": { + "name": "command_history", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "host_id": { + "name": "host_id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "command": { + "name": "command", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "executed_at": { + "name": "executed_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": {}, + "foreignKeys": { + "command_history_user_id_users_id_fk": { + "name": "command_history_user_id_users_id_fk", + "tableFrom": "command_history", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "command_history_host_id_ssh_data_id_fk": { + "name": "command_history_host_id_ssh_data_id_fk", + "tableFrom": "command_history", + "tableTo": "ssh_data", + "columnsFrom": [ + "host_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "command_history_id": { + "name": "command_history_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "dashboard_service_links": { + "name": "dashboard_service_links", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "order": { + "name": "order", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "sync_id": { + "name": "sync_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": {}, + "foreignKeys": { + "dashboard_service_links_user_id_users_id_fk": { + "name": "dashboard_service_links_user_id_users_id_fk", + "tableFrom": "dashboard_service_links", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "dashboard_service_links_id": { + "name": "dashboard_service_links_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": { + "dashboard_service_links_sync_id_unique": { + "name": "dashboard_service_links_sync_id_unique", + "columns": [ + "sync_id" + ] + } + }, + "checkConstraint": {} + }, + "dismissed_alerts": { + "name": "dismissed_alerts", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "alert_id": { + "name": "alert_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "dismissed_at": { + "name": "dismissed_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": {}, + "foreignKeys": { + "dismissed_alerts_user_id_users_id_fk": { + "name": "dismissed_alerts_user_id_users_id_fk", + "tableFrom": "dismissed_alerts", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "dismissed_alerts_id": { + "name": "dismissed_alerts_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "file_manager_pinned": { + "name": "file_manager_pinned", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "host_id": { + "name": "host_id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "path": { + "name": "path", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "pinned_at": { + "name": "pinned_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": {}, + "foreignKeys": { + "file_manager_pinned_user_id_users_id_fk": { + "name": "file_manager_pinned_user_id_users_id_fk", + "tableFrom": "file_manager_pinned", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "file_manager_pinned_host_id_ssh_data_id_fk": { + "name": "file_manager_pinned_host_id_ssh_data_id_fk", + "tableFrom": "file_manager_pinned", + "tableTo": "ssh_data", + "columnsFrom": [ + "host_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "file_manager_pinned_id": { + "name": "file_manager_pinned_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "file_manager_recent": { + "name": "file_manager_recent", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "host_id": { + "name": "host_id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "path": { + "name": "path", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_opened": { + "name": "last_opened", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": {}, + "foreignKeys": { + "file_manager_recent_user_id_users_id_fk": { + "name": "file_manager_recent_user_id_users_id_fk", + "tableFrom": "file_manager_recent", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "file_manager_recent_host_id_ssh_data_id_fk": { + "name": "file_manager_recent_host_id_ssh_data_id_fk", + "tableFrom": "file_manager_recent", + "tableTo": "ssh_data", + "columnsFrom": [ + "host_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "file_manager_recent_id": { + "name": "file_manager_recent_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "file_manager_shortcuts": { + "name": "file_manager_shortcuts", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "host_id": { + "name": "host_id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "path": { + "name": "path", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": {}, + "foreignKeys": { + "file_manager_shortcuts_user_id_users_id_fk": { + "name": "file_manager_shortcuts_user_id_users_id_fk", + "tableFrom": "file_manager_shortcuts", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "file_manager_shortcuts_host_id_ssh_data_id_fk": { + "name": "file_manager_shortcuts_host_id_ssh_data_id_fk", + "tableFrom": "file_manager_shortcuts", + "tableTo": "ssh_data", + "columnsFrom": [ + "host_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "file_manager_shortcuts_id": { + "name": "file_manager_shortcuts_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "homepage_items": { + "name": "homepage_items", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type_id": { + "name": "type_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "config": { + "name": "config", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "('{}')" + }, + "folder_id": { + "name": "folder_id", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sync_id": { + "name": "sync_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": {}, + "foreignKeys": { + "homepage_items_user_id_users_id_fk": { + "name": "homepage_items_user_id_users_id_fk", + "tableFrom": "homepage_items", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "homepage_items_id": { + "name": "homepage_items_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": { + "homepage_items_sync_id_unique": { + "name": "homepage_items_sync_id_unique", + "columns": [ + "sync_id" + ] + } + }, + "checkConstraint": {} + }, + "homepage_layouts": { + "name": "homepage_layouts", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "layout": { + "name": "layout", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "('{}')" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": {}, + "foreignKeys": { + "homepage_layouts_user_id_users_id_fk": { + "name": "homepage_layouts_user_id_users_id_fk", + "tableFrom": "homepage_layouts", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "homepage_layouts_id": { + "name": "homepage_layouts_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": { + "homepage_layouts_user_id_unique": { + "name": "homepage_layouts_user_id_unique", + "columns": [ + "user_id" + ] + } + }, + "checkConstraint": {} + }, + "host_access": { + "name": "host_access", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "host_id": { + "name": "host_id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "role_id": { + "name": "role_id", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "granted_by": { + "name": "granted_by", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "permission_level": { + "name": "permission_level", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "('connect')" + }, + "expires_at": { + "name": "expires_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "last_accessed_at": { + "name": "last_accessed_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "access_count": { + "name": "access_count", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + } + }, + "indexes": {}, + "foreignKeys": { + "host_access_host_id_ssh_data_id_fk": { + "name": "host_access_host_id_ssh_data_id_fk", + "tableFrom": "host_access", + "tableTo": "ssh_data", + "columnsFrom": [ + "host_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "host_access_user_id_users_id_fk": { + "name": "host_access_user_id_users_id_fk", + "tableFrom": "host_access", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "host_access_role_id_roles_id_fk": { + "name": "host_access_role_id_roles_id_fk", + "tableFrom": "host_access", + "tableTo": "roles", + "columnsFrom": [ + "role_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "host_access_granted_by_users_id_fk": { + "name": "host_access_granted_by_users_id_fk", + "tableFrom": "host_access", + "tableTo": "users", + "columnsFrom": [ + "granted_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "host_access_id": { + "name": "host_access_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "host_health_checks": { + "name": "host_health_checks", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "host_id": { + "name": "host_id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "checks": { + "name": "checks", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "interval_seconds": { + "name": "interval_seconds", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 300 + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": { + "idx_host_health_checks_user_host": { + "name": "idx_host_health_checks_user_host", + "columns": [ + "user_id", + "host_id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "host_health_checks_user_id_users_id_fk": { + "name": "host_health_checks_user_id_users_id_fk", + "tableFrom": "host_health_checks", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "host_health_checks_host_id_ssh_data_id_fk": { + "name": "host_health_checks_host_id_ssh_data_id_fk", + "tableFrom": "host_health_checks", + "tableTo": "ssh_data", + "columnsFrom": [ + "host_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "host_health_checks_id": { + "name": "host_health_checks_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "host_health_history": { + "name": "host_health_history", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "host_id": { + "name": "host_id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "check_id": { + "name": "check_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ts": { + "name": "ts", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "ok": { + "name": "ok", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "latency_ms": { + "name": "latency_ms", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "detail": { + "name": "detail", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "host_health_history_user_id_users_id_fk": { + "name": "host_health_history_user_id_users_id_fk", + "tableFrom": "host_health_history", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "host_health_history_host_id_ssh_data_id_fk": { + "name": "host_health_history_host_id_ssh_data_id_fk", + "tableFrom": "host_health_history", + "tableTo": "ssh_data", + "columnsFrom": [ + "host_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "host_health_history_id": { + "name": "host_health_history_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "host_metrics_history": { + "name": "host_metrics_history", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "host_id": { + "name": "host_id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ts": { + "name": "ts", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "cpu_percent": { + "name": "cpu_percent", + "type": "double", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "mem_percent": { + "name": "mem_percent", + "type": "double", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "disk_percent": { + "name": "disk_percent", + "type": "double", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "net_rx_bytes": { + "name": "net_rx_bytes", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "net_tx_bytes": { + "name": "net_tx_bytes", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "host_metrics_history_host_id_ssh_data_id_fk": { + "name": "host_metrics_history_host_id_ssh_data_id_fk", + "tableFrom": "host_metrics_history", + "tableTo": "ssh_data", + "columnsFrom": [ + "host_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "host_metrics_history_id": { + "name": "host_metrics_history_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "host_metrics_preferences": { + "name": "host_metrics_preferences", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "host_id": { + "name": "host_id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "layout": { + "name": "layout", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": { + "idx_host_metrics_prefs_user_host": { + "name": "idx_host_metrics_prefs_user_host", + "columns": [ + "user_id", + "host_id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "host_metrics_preferences_user_id_users_id_fk": { + "name": "host_metrics_preferences_user_id_users_id_fk", + "tableFrom": "host_metrics_preferences", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "host_metrics_preferences_host_id_ssh_data_id_fk": { + "name": "host_metrics_preferences_host_id_ssh_data_id_fk", + "tableFrom": "host_metrics_preferences", + "tableTo": "ssh_data", + "columnsFrom": [ + "host_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "host_metrics_preferences_id": { + "name": "host_metrics_preferences_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "ssh_data": { + "name": "ssh_data", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "connection_type": { + "name": "connection_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "('ssh')" + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "ip": { + "name": "ip", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "port": { + "name": "port", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "folder": { + "name": "folder", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "tags": { + "name": "tags", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "pin": { + "name": "pin", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "auth_type": { + "name": "auth_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "use_warpgate": { + "name": "use_warpgate", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "share_ssh_auth": { + "name": "share_ssh_auth", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "force_keyboard_interactive": { + "name": "force_keyboard_interactive", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "key_password": { + "name": "key_password", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "key_type": { + "name": "key_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sudo_password": { + "name": "sudo_password", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "autostart_password": { + "name": "autostart_password", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "autostart_key": { + "name": "autostart_key", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "autostart_key_password": { + "name": "autostart_key_password", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "credential_id": { + "name": "credential_id", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "override_credential_username": { + "name": "override_credential_username", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "vault_profile_id": { + "name": "vault_profile_id", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "enable_terminal": { + "name": "enable_terminal", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "enable_session_logging": { + "name": "enable_session_logging", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "allow_session_sharing": { + "name": "allow_session_sharing", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "enable_command_history": { + "name": "enable_command_history", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "enable_tunnel": { + "name": "enable_tunnel", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "tunnel_connections": { + "name": "tunnel_connections", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "jump_hosts": { + "name": "jump_hosts", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "enable_file_manager": { + "name": "enable_file_manager", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "scp_legacy": { + "name": "scp_legacy", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "enable_docker": { + "name": "enable_docker", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "enable_tmux_monitor": { + "name": "enable_tmux_monitor", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "show_terminal_in_sidebar": { + "name": "show_terminal_in_sidebar", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "show_file_manager_in_sidebar": { + "name": "show_file_manager_in_sidebar", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "show_tunnel_in_sidebar": { + "name": "show_tunnel_in_sidebar", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "show_docker_in_sidebar": { + "name": "show_docker_in_sidebar", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "show_server_stats_in_sidebar": { + "name": "show_server_stats_in_sidebar", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "default_path": { + "name": "default_path", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "stats_config": { + "name": "stats_config", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "docker_config": { + "name": "docker_config", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "enable_proxmox": { + "name": "enable_proxmox", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "proxmox_config": { + "name": "proxmox_config", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "terminal_config": { + "name": "terminal_config", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "quick_actions": { + "name": "quick_actions", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "enable_ssh": { + "name": "enable_ssh", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "enable_rdp": { + "name": "enable_rdp", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "enable_vnc": { + "name": "enable_vnc", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "enable_telnet": { + "name": "enable_telnet", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "ssh_port": { + "name": "ssh_port", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 22 + }, + "rdp_port": { + "name": "rdp_port", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 3389 + }, + "vnc_port": { + "name": "vnc_port", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 5900 + }, + "telnet_port": { + "name": "telnet_port", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 23 + }, + "rdp_credential_id": { + "name": "rdp_credential_id", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "rdp_user": { + "name": "rdp_user", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "rdp_password": { + "name": "rdp_password", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "rdp_domain": { + "name": "rdp_domain", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "rdp_security": { + "name": "rdp_security", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "rdp_ignore_cert": { + "name": "rdp_ignore_cert", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": false + }, + "vnc_credential_id": { + "name": "vnc_credential_id", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "vnc_password": { + "name": "vnc_password", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "vnc_user": { + "name": "vnc_user", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "telnet_user": { + "name": "telnet_user", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "telnet_password": { + "name": "telnet_password", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "telnet_credential_id": { + "name": "telnet_credential_id", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "rdp_auth_type": { + "name": "rdp_auth_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "vnc_auth_type": { + "name": "vnc_auth_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "telnet_auth_type": { + "name": "telnet_auth_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "domain": { + "name": "domain", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "security": { + "name": "security", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "ignore_cert": { + "name": "ignore_cert", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": false + }, + "guacamole_config": { + "name": "guacamole_config", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "use_socks5": { + "name": "use_socks5", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "socks5_host": { + "name": "socks5_host", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "socks5_port": { + "name": "socks5_port", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "socks5_username": { + "name": "socks5_username", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "socks5_password": { + "name": "socks5_password", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "socks5_proxy_chain": { + "name": "socks5_proxy_chain", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "connection_origin": { + "name": "connection_origin", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "mac_address": { + "name": "mac_address", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "wol_broadcast_address": { + "name": "wol_broadcast_address", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "port_knock_sequence": { + "name": "port_knock_sequence", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "host_key_fingerprint": { + "name": "host_key_fingerprint", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "host_key_type": { + "name": "host_key_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "host_key_algorithm": { + "name": "host_key_algorithm", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "('sha256')" + }, + "host_key_first_seen": { + "name": "host_key_first_seen", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "host_key_last_verified": { + "name": "host_key_last_verified", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "host_key_changed_count": { + "name": "host_key_changed_count", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "sync_id": { + "name": "sync_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": {}, + "foreignKeys": { + "ssh_data_user_id_users_id_fk": { + "name": "ssh_data_user_id_users_id_fk", + "tableFrom": "ssh_data", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "ssh_data_credential_id_ssh_credentials_id_fk": { + "name": "ssh_data_credential_id_ssh_credentials_id_fk", + "tableFrom": "ssh_data", + "tableTo": "ssh_credentials", + "columnsFrom": [ + "credential_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "ssh_data_vault_profile_id_vault_profiles_id_fk": { + "name": "ssh_data_vault_profile_id_vault_profiles_id_fk", + "tableFrom": "ssh_data", + "tableTo": "vault_profiles", + "columnsFrom": [ + "vault_profile_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "ssh_data_rdp_credential_id_ssh_credentials_id_fk": { + "name": "ssh_data_rdp_credential_id_ssh_credentials_id_fk", + "tableFrom": "ssh_data", + "tableTo": "ssh_credentials", + "columnsFrom": [ + "rdp_credential_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "ssh_data_vnc_credential_id_ssh_credentials_id_fk": { + "name": "ssh_data_vnc_credential_id_ssh_credentials_id_fk", + "tableFrom": "ssh_data", + "tableTo": "ssh_credentials", + "columnsFrom": [ + "vnc_credential_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "ssh_data_telnet_credential_id_ssh_credentials_id_fk": { + "name": "ssh_data_telnet_credential_id_ssh_credentials_id_fk", + "tableFrom": "ssh_data", + "tableTo": "ssh_credentials", + "columnsFrom": [ + "telnet_credential_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "ssh_data_id": { + "name": "ssh_data_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": { + "ssh_data_sync_id_unique": { + "name": "ssh_data_sync_id_unique", + "columns": [ + "sync_id" + ] + } + }, + "checkConstraint": {} + }, + "network_topology": { + "name": "network_topology", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "topology": { + "name": "topology", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": {}, + "foreignKeys": { + "network_topology_user_id_users_id_fk": { + "name": "network_topology_user_id_users_id_fk", + "tableFrom": "network_topology", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "network_topology_id": { + "name": "network_topology_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "notification_channels": { + "name": "notification_channels", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "config": { + "name": "config", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": {}, + "foreignKeys": { + "notification_channels_user_id_users_id_fk": { + "name": "notification_channels_user_id_users_id_fk", + "tableFrom": "notification_channels", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "notification_channels_id": { + "name": "notification_channels_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "opkssh_tokens": { + "name": "opkssh_tokens", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "host_id": { + "name": "host_id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ssh_cert": { + "name": "ssh_cert", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "private_key": { + "name": "private_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sub": { + "name": "sub", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "issuer": { + "name": "issuer", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "audience": { + "name": "audience", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "expires_at": { + "name": "expires_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_used": { + "name": "last_used", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "idx_opkssh_tokens_user_host": { + "name": "idx_opkssh_tokens_user_host", + "columns": [ + "user_id", + "host_id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "opkssh_tokens_user_id_users_id_fk": { + "name": "opkssh_tokens_user_id_users_id_fk", + "tableFrom": "opkssh_tokens", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "opkssh_tokens_host_id_ssh_data_id_fk": { + "name": "opkssh_tokens_host_id_ssh_data_id_fk", + "tableFrom": "opkssh_tokens", + "tableTo": "ssh_data", + "columnsFrom": [ + "host_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "opkssh_tokens_id": { + "name": "opkssh_tokens_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "recent_activity": { + "name": "recent_activity", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "host_id": { + "name": "host_id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "host_name": { + "name": "host_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "timestamp": { + "name": "timestamp", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": {}, + "foreignKeys": { + "recent_activity_user_id_users_id_fk": { + "name": "recent_activity_user_id_users_id_fk", + "tableFrom": "recent_activity", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "recent_activity_host_id_ssh_data_id_fk": { + "name": "recent_activity_host_id_ssh_data_id_fk", + "tableFrom": "recent_activity", + "tableTo": "ssh_data", + "columnsFrom": [ + "host_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "recent_activity_id": { + "name": "recent_activity_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "roles": { + "name": "roles", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_system": { + "name": "is_system", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "permissions": { + "name": "permissions", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "roles_id": { + "name": "roles_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": { + "roles_name_unique": { + "name": "roles_name_unique", + "columns": [ + "name" + ] + } + }, + "checkConstraint": {} + }, + "session_recordings": { + "name": "session_recordings", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "host_id": { + "name": "host_id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "access_id": { + "name": "access_id", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "started_at": { + "name": "started_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "ended_at": { + "name": "ended_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "duration": { + "name": "duration", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "commands": { + "name": "commands", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "dangerous_actions": { + "name": "dangerous_actions", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "recording_path": { + "name": "recording_path", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "protocol": { + "name": "protocol", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'ssh'" + }, + "format": { + "name": "format", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "('text')" + }, + "terminated_by_owner": { + "name": "terminated_by_owner", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": false + }, + "termination_reason": { + "name": "termination_reason", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "session_recordings_host_id_ssh_data_id_fk": { + "name": "session_recordings_host_id_ssh_data_id_fk", + "tableFrom": "session_recordings", + "tableTo": "ssh_data", + "columnsFrom": [ + "host_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "session_recordings_user_id_users_id_fk": { + "name": "session_recordings_user_id_users_id_fk", + "tableFrom": "session_recordings", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "session_recordings_access_id_host_access_id_fk": { + "name": "session_recordings_access_id_host_access_id_fk", + "tableFrom": "session_recordings", + "tableTo": "host_access", + "columnsFrom": [ + "access_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "session_recordings_id": { + "name": "session_recordings_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "session_share_participants": { + "name": "session_share_participants", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "share_id": { + "name": "share_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "guest_label": { + "name": "guest_label", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "joined_at": { + "name": "joined_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "left_at": { + "name": "left_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "session_share_participants_share_id_session_shares_id_fk": { + "name": "session_share_participants_share_id_session_shares_id_fk", + "tableFrom": "session_share_participants", + "tableTo": "session_shares", + "columnsFrom": [ + "share_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "session_share_participants_user_id_users_id_fk": { + "name": "session_share_participants_user_id_users_id_fk", + "tableFrom": "session_share_participants", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "session_share_participants_id": { + "name": "session_share_participants_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "session_shares": { + "name": "session_shares", + "columns": { + "id": { + "name": "id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "host_id": { + "name": "host_id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "protocol": { + "name": "protocol", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "tab_instance_id": { + "name": "tab_instance_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "share_type": { + "name": "share_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "target_user_id": { + "name": "target_user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "link_token": { + "name": "link_token", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "permission_level": { + "name": "permission_level", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "('read-only')" + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "expires_at": { + "name": "expires_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "revoked_at": { + "name": "revoked_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_joined_at": { + "name": "last_joined_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "join_count": { + "name": "join_count", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + } + }, + "indexes": {}, + "foreignKeys": { + "session_shares_host_id_ssh_data_id_fk": { + "name": "session_shares_host_id_ssh_data_id_fk", + "tableFrom": "session_shares", + "tableTo": "ssh_data", + "columnsFrom": [ + "host_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "session_shares_owner_user_id_users_id_fk": { + "name": "session_shares_owner_user_id_users_id_fk", + "tableFrom": "session_shares", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "session_shares_target_user_id_users_id_fk": { + "name": "session_shares_target_user_id_users_id_fk", + "tableFrom": "session_shares", + "tableTo": "users", + "columnsFrom": [ + "target_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "session_shares_id": { + "name": "session_shares_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": { + "session_shares_link_token_unique": { + "name": "session_shares_link_token_unique", + "columns": [ + "link_token" + ] + } + }, + "checkConstraint": {} + }, + "sessions": { + "name": "sessions", + "columns": { + "id": { + "name": "id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "jwt_token": { + "name": "jwt_token", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "device_type": { + "name": "device_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "device_info": { + "name": "device_info", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "oidc_sub": { + "name": "oidc_sub", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "oidc_sid": { + "name": "oidc_sid", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sso_provider_id": { + "name": "sso_provider_id", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "expires_at": { + "name": "expires_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_active_at": { + "name": "last_active_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": {}, + "foreignKeys": { + "sessions_user_id_users_id_fk": { + "name": "sessions_user_id_users_id_fk", + "tableFrom": "sessions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "sessions_id": { + "name": "sessions_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "settings": { + "name": "settings", + "columns": { + "key": { + "name": "key", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "settings_key": { + "name": "settings_key", + "columns": [ + "key" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "shared_host_auth_overrides": { + "name": "shared_host_auth_overrides", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "host_id": { + "name": "host_id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "protocol": { + "name": "protocol", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'ssh'" + }, + "credential_id": { + "name": "credential_id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": { + "shared_host_auth_overrides_host_user_protocol_unique": { + "name": "shared_host_auth_overrides_host_user_protocol_unique", + "columns": [ + "host_id", + "user_id", + "protocol" + ], + "isUnique": true + } + }, + "foreignKeys": { + "shared_host_auth_overrides_host_id_ssh_data_id_fk": { + "name": "shared_host_auth_overrides_host_id_ssh_data_id_fk", + "tableFrom": "shared_host_auth_overrides", + "tableTo": "ssh_data", + "columnsFrom": [ + "host_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "shared_host_auth_overrides_user_id_users_id_fk": { + "name": "shared_host_auth_overrides_user_id_users_id_fk", + "tableFrom": "shared_host_auth_overrides", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "shared_host_auth_overrides_credential_id_ssh_credentials_id_fk": { + "name": "shared_host_auth_overrides_credential_id_ssh_credentials_id_fk", + "tableFrom": "shared_host_auth_overrides", + "tableTo": "ssh_credentials", + "columnsFrom": [ + "credential_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "shared_host_auth_overrides_id": { + "name": "shared_host_auth_overrides_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "shared_host_secrets": { + "name": "shared_host_secrets", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "host_access_id": { + "name": "host_access_id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "target_user_id": { + "name": "target_user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "protocol": { + "name": "protocol", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'ssh'" + }, + "source_type": { + "name": "source_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "('credential')" + }, + "original_credential_id": { + "name": "original_credential_id", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "encrypted_username": { + "name": "encrypted_username", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "encrypted_auth_type": { + "name": "encrypted_auth_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "encrypted_password": { + "name": "encrypted_password", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "encrypted_key": { + "name": "encrypted_key", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "encrypted_key_password": { + "name": "encrypted_key_password", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "encrypted_key_type": { + "name": "encrypted_key_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "encrypted_domain": { + "name": "encrypted_domain", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": { + "idx_shared_host_secrets_scope": { + "name": "idx_shared_host_secrets_scope", + "columns": [ + "host_access_id", + "target_user_id", + "protocol" + ], + "isUnique": true + } + }, + "foreignKeys": { + "shared_host_secrets_host_access_id_host_access_id_fk": { + "name": "shared_host_secrets_host_access_id_host_access_id_fk", + "tableFrom": "shared_host_secrets", + "tableTo": "host_access", + "columnsFrom": [ + "host_access_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "shared_host_secrets_target_user_id_users_id_fk": { + "name": "shared_host_secrets_target_user_id_users_id_fk", + "tableFrom": "shared_host_secrets", + "tableTo": "users", + "columnsFrom": [ + "target_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "shared_host_secrets_original_credential_id_ssh_credentials_id_fk": { + "name": "shared_host_secrets_original_credential_id_ssh_credentials_id_fk", + "tableFrom": "shared_host_secrets", + "tableTo": "ssh_credentials", + "columnsFrom": [ + "original_credential_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "shared_host_secrets_id": { + "name": "shared_host_secrets_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "snippet_access": { + "name": "snippet_access", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "snippet_id": { + "name": "snippet_id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "role_id": { + "name": "role_id", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "granted_by": { + "name": "granted_by", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "permission_level": { + "name": "permission_level", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "('view')" + }, + "expires_at": { + "name": "expires_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": {}, + "foreignKeys": { + "snippet_access_snippet_id_snippets_id_fk": { + "name": "snippet_access_snippet_id_snippets_id_fk", + "tableFrom": "snippet_access", + "tableTo": "snippets", + "columnsFrom": [ + "snippet_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "snippet_access_user_id_users_id_fk": { + "name": "snippet_access_user_id_users_id_fk", + "tableFrom": "snippet_access", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "snippet_access_role_id_roles_id_fk": { + "name": "snippet_access_role_id_roles_id_fk", + "tableFrom": "snippet_access", + "tableTo": "roles", + "columnsFrom": [ + "role_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "snippet_access_granted_by_users_id_fk": { + "name": "snippet_access_granted_by_users_id_fk", + "tableFrom": "snippet_access", + "tableTo": "users", + "columnsFrom": [ + "granted_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "snippet_access_id": { + "name": "snippet_access_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "snippet_folders": { + "name": "snippet_folders", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "icon": { + "name": "icon", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sync_id": { + "name": "sync_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": {}, + "foreignKeys": { + "snippet_folders_user_id_users_id_fk": { + "name": "snippet_folders_user_id_users_id_fk", + "tableFrom": "snippet_folders", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "snippet_folders_id": { + "name": "snippet_folders_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": { + "snippet_folders_sync_id_unique": { + "name": "snippet_folders_sync_id_unique", + "columns": [ + "sync_id" + ] + } + }, + "checkConstraint": {} + }, + "snippets": { + "name": "snippets", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "folder": { + "name": "folder", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "order": { + "name": "order", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "sync_id": { + "name": "sync_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "host_filter": { + "name": "host_filter", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "snippets_user_id_users_id_fk": { + "name": "snippets_user_id_users_id_fk", + "tableFrom": "snippets", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "snippets_id": { + "name": "snippets_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": { + "snippets_sync_id_unique": { + "name": "snippets_sync_id_unique", + "columns": [ + "sync_id" + ] + } + }, + "checkConstraint": {} + }, + "ssh_credential_usage": { + "name": "ssh_credential_usage", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "credential_id": { + "name": "credential_id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "host_id": { + "name": "host_id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "used_at": { + "name": "used_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": {}, + "foreignKeys": { + "ssh_credential_usage_credential_id_ssh_credentials_id_fk": { + "name": "ssh_credential_usage_credential_id_ssh_credentials_id_fk", + "tableFrom": "ssh_credential_usage", + "tableTo": "ssh_credentials", + "columnsFrom": [ + "credential_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "ssh_credential_usage_host_id_ssh_data_id_fk": { + "name": "ssh_credential_usage_host_id_ssh_data_id_fk", + "tableFrom": "ssh_credential_usage", + "tableTo": "ssh_data", + "columnsFrom": [ + "host_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "ssh_credential_usage_user_id_users_id_fk": { + "name": "ssh_credential_usage_user_id_users_id_fk", + "tableFrom": "ssh_credential_usage", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "ssh_credential_usage_id": { + "name": "ssh_credential_usage_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "ssh_credentials": { + "name": "ssh_credentials", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "folder": { + "name": "folder", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "tags": { + "name": "tags", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "auth_type": { + "name": "auth_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "private_key": { + "name": "private_key", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "public_key": { + "name": "public_key", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "key_password": { + "name": "key_password", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "key_type": { + "name": "key_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "detected_key_type": { + "name": "detected_key_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cert_public_key": { + "name": "cert_public_key", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "usage_count": { + "name": "usage_count", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "last_used": { + "name": "last_used", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sync_id": { + "name": "sync_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": {}, + "foreignKeys": { + "ssh_credentials_user_id_users_id_fk": { + "name": "ssh_credentials_user_id_users_id_fk", + "tableFrom": "ssh_credentials", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "ssh_credentials_id": { + "name": "ssh_credentials_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": { + "ssh_credentials_sync_id_unique": { + "name": "ssh_credentials_sync_id_unique", + "columns": [ + "sync_id" + ] + } + }, + "checkConstraint": {} + }, + "ssh_folders": { + "name": "ssh_folders", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "icon": { + "name": "icon", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "credential_id": { + "name": "credential_id", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sync_id": { + "name": "sync_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": {}, + "foreignKeys": { + "ssh_folders_user_id_users_id_fk": { + "name": "ssh_folders_user_id_users_id_fk", + "tableFrom": "ssh_folders", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "ssh_folders_credential_id_ssh_credentials_id_fk": { + "name": "ssh_folders_credential_id_ssh_credentials_id_fk", + "tableFrom": "ssh_folders", + "tableTo": "ssh_credentials", + "columnsFrom": [ + "credential_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "ssh_folders_id": { + "name": "ssh_folders_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": { + "ssh_folders_sync_id_unique": { + "name": "ssh_folders_sync_id_unique", + "columns": [ + "sync_id" + ] + } + }, + "checkConstraint": {} + }, + "sso_providers": { + "name": "sso_providers", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "display_order": { + "name": "display_order", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "config": { + "name": "config", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "sso_providers_id": { + "name": "sso_providers_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "sync_tombstones": { + "name": "sync_tombstones", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "entity_type": { + "name": "entity_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "sync_id": { + "name": "sync_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": {}, + "foreignKeys": { + "sync_tombstones_user_id_users_id_fk": { + "name": "sync_tombstones_user_id_users_id_fk", + "tableFrom": "sync_tombstones", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "sync_tombstones_id": { + "name": "sync_tombstones_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "termix_identities": { + "name": "termix_identities", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "handle": { + "name": "handle", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": {}, + "foreignKeys": { + "termix_identities_user_id_users_id_fk": { + "name": "termix_identities_user_id_users_id_fk", + "tableFrom": "termix_identities", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "termix_identities_id": { + "name": "termix_identities_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": { + "termix_identities_user_id_unique": { + "name": "termix_identities_user_id_unique", + "columns": [ + "user_id" + ] + }, + "termix_identities_handle_unique": { + "name": "termix_identities_handle_unique", + "columns": [ + "handle" + ] + } + }, + "checkConstraint": {} + }, + "termix_identity_ca": { + "name": "termix_identity_ca", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "identity_id": { + "name": "identity_id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "public_key": { + "name": "public_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "private_key": { + "name": "private_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "validity_days": { + "name": "validity_days", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 90 + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": {}, + "foreignKeys": { + "termix_identity_ca_identity_id_termix_identities_id_fk": { + "name": "termix_identity_ca_identity_id_termix_identities_id_fk", + "tableFrom": "termix_identity_ca", + "tableTo": "termix_identities", + "columnsFrom": [ + "identity_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "termix_identity_ca_user_id_users_id_fk": { + "name": "termix_identity_ca_user_id_users_id_fk", + "tableFrom": "termix_identity_ca", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "termix_identity_ca_id": { + "name": "termix_identity_ca_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": { + "termix_identity_ca_identity_id_unique": { + "name": "termix_identity_ca_identity_id_unique", + "columns": [ + "identity_id" + ] + } + }, + "checkConstraint": {} + }, + "termix_identity_keys": { + "name": "termix_identity_keys", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "identity_id": { + "name": "identity_id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "public_key": { + "name": "public_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "key_type": { + "name": "key_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "algorithm": { + "name": "algorithm", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "comment": { + "name": "comment", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "('manual')" + }, + "credential_id": { + "name": "credential_id", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": {}, + "foreignKeys": { + "termix_identity_keys_identity_id_termix_identities_id_fk": { + "name": "termix_identity_keys_identity_id_termix_identities_id_fk", + "tableFrom": "termix_identity_keys", + "tableTo": "termix_identities", + "columnsFrom": [ + "identity_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "termix_identity_keys_user_id_users_id_fk": { + "name": "termix_identity_keys_user_id_users_id_fk", + "tableFrom": "termix_identity_keys", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "termix_identity_keys_credential_id_ssh_credentials_id_fk": { + "name": "termix_identity_keys_credential_id_ssh_credentials_id_fk", + "tableFrom": "termix_identity_keys", + "tableTo": "ssh_credentials", + "columnsFrom": [ + "credential_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "termix_identity_keys_id": { + "name": "termix_identity_keys_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "tmux_session_tags": { + "name": "tmux_session_tags", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "host_id": { + "name": "host_id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "session_name": { + "name": "session_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "tag": { + "name": "tag", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": {}, + "foreignKeys": { + "tmux_session_tags_user_id_users_id_fk": { + "name": "tmux_session_tags_user_id_users_id_fk", + "tableFrom": "tmux_session_tags", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "tmux_session_tags_host_id_ssh_data_id_fk": { + "name": "tmux_session_tags_host_id_ssh_data_id_fk", + "tableFrom": "tmux_session_tags", + "tableTo": "ssh_data", + "columnsFrom": [ + "host_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "tmux_session_tags_id": { + "name": "tmux_session_tags_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "transfer_recent": { + "name": "transfer_recent", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "source_host_id": { + "name": "source_host_id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "dest_host_id": { + "name": "dest_host_id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "dest_path": { + "name": "dest_path", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "dest_path_label": { + "name": "dest_path_label", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_used": { + "name": "last_used", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": {}, + "foreignKeys": { + "transfer_recent_user_id_users_id_fk": { + "name": "transfer_recent_user_id_users_id_fk", + "tableFrom": "transfer_recent", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "transfer_recent_source_host_id_ssh_data_id_fk": { + "name": "transfer_recent_source_host_id_ssh_data_id_fk", + "tableFrom": "transfer_recent", + "tableTo": "ssh_data", + "columnsFrom": [ + "source_host_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "transfer_recent_dest_host_id_ssh_data_id_fk": { + "name": "transfer_recent_dest_host_id_ssh_data_id_fk", + "tableFrom": "transfer_recent", + "tableTo": "ssh_data", + "columnsFrom": [ + "dest_host_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "transfer_recent_id": { + "name": "transfer_recent_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "trusted_devices": { + "name": "trusted_devices", + "columns": { + "id": { + "name": "id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "device_fingerprint": { + "name": "device_fingerprint", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "device_type": { + "name": "device_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "device_info": { + "name": "device_info", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "expires_at": { + "name": "expires_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_used_at": { + "name": "last_used_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": {}, + "foreignKeys": { + "trusted_devices_user_id_users_id_fk": { + "name": "trusted_devices_user_id_users_id_fk", + "tableFrom": "trusted_devices", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "trusted_devices_id": { + "name": "trusted_devices_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "user_open_tabs": { + "name": "user_open_tabs", + "columns": { + "id": { + "name": "id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "tab_type": { + "name": "tab_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "host_id": { + "name": "host_id", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "tab_order": { + "name": "tab_order", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "backend_session_id": { + "name": "backend_session_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": {}, + "foreignKeys": { + "user_open_tabs_user_id_users_id_fk": { + "name": "user_open_tabs_user_id_users_id_fk", + "tableFrom": "user_open_tabs", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_open_tabs_host_id_ssh_data_id_fk": { + "name": "user_open_tabs_host_id_ssh_data_id_fk", + "tableFrom": "user_open_tabs", + "tableTo": "ssh_data", + "columnsFrom": [ + "host_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "user_open_tabs_id": { + "name": "user_open_tabs_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "user_preferences": { + "name": "user_preferences", + "columns": { + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "reopen_tabs_on_login": { + "name": "reopen_tabs_on_login", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "theme": { + "name": "theme", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "font_size": { + "name": "font_size", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "accent_color": { + "name": "accent_color", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "language": { + "name": "language", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "storage_mode": { + "name": "storage_mode", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "command_autocomplete": { + "name": "command_autocomplete", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "command_palette_enabled": { + "name": "command_palette_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "show_host_tags": { + "name": "show_host_tags", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "host_tray_on_click": { + "name": "host_tray_on_click", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "pin_app_rail": { + "name": "pin_app_rail", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "expand_app_rail_on_hover": { + "name": "expand_app_rail_on_hover", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "folders_collapsed": { + "name": "folders_collapsed", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "confirm_snippet_execution": { + "name": "confirm_snippet_execution", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "disable_update_check": { + "name": "disable_update_check", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "confirm_tab_close": { + "name": "confirm_tab_close", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "hidden_rail_tabs": { + "name": "hidden_rail_tabs", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "compact_host_view": { + "name": "compact_host_view", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status_color_scheme": { + "name": "status_color_scheme", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "custom_themes": { + "name": "custom_themes", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "custom_keybindings": { + "name": "custom_keybindings", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": {}, + "foreignKeys": { + "user_preferences_user_id_users_id_fk": { + "name": "user_preferences_user_id_users_id_fk", + "tableFrom": "user_preferences", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "user_preferences_user_id": { + "name": "user_preferences_user_id", + "columns": [ + "user_id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "user_roles": { + "name": "user_roles", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "role_id": { + "name": "role_id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "granted_by": { + "name": "granted_by", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "granted_at": { + "name": "granted_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": { + "idx_user_roles_user_role": { + "name": "idx_user_roles_user_role", + "columns": [ + "user_id", + "role_id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "user_roles_user_id_users_id_fk": { + "name": "user_roles_user_id_users_id_fk", + "tableFrom": "user_roles", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_roles_role_id_roles_id_fk": { + "name": "user_roles_role_id_roles_id_fk", + "tableFrom": "user_roles", + "tableTo": "roles", + "columnsFrom": [ + "role_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_roles_granted_by_users_id_fk": { + "name": "user_roles_granted_by_users_id_fk", + "tableFrom": "user_roles", + "tableTo": "users", + "columnsFrom": [ + "granted_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "user_roles_id": { + "name": "user_roles_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "users": { + "name": "users", + "columns": { + "id": { + "name": "id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "password_hash": { + "name": "password_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "is_admin": { + "name": "is_admin", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "is_oidc": { + "name": "is_oidc", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "oidc_identifier": { + "name": "oidc_identifier", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sso_provider_id": { + "name": "sso_provider_id", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "client_secret": { + "name": "client_secret", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "issuer_url": { + "name": "issuer_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "authorization_url": { + "name": "authorization_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "token_url": { + "name": "token_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "identifier_path": { + "name": "identifier_path", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "name_path": { + "name": "name_path", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "scopes": { + "name": "scopes", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "('openid email profile')" + }, + "totp_secret": { + "name": "totp_secret", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "totp_enabled": { + "name": "totp_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "totp_backup_codes": { + "name": "totp_backup_codes", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "registered_at": { + "name": "registered_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "donation_modal_dismissed": { + "name": "donation_modal_dismissed", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "users_id": { + "name": "users_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "vault_profiles": { + "name": "vault_profiles", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "folder": { + "name": "folder", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "tags": { + "name": "tags", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "vault_addr": { + "name": "vault_addr", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "vault_namespace": { + "name": "vault_namespace", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "oidc_mount": { + "name": "oidc_mount", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "oidc_role": { + "name": "oidc_role", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "ssh_mount": { + "name": "ssh_mount", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "ssh_role": { + "name": "ssh_role", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "valid_principals": { + "name": "valid_principals", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "key_type": { + "name": "key_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "shared": { + "name": "shared", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "sync_id": { + "name": "sync_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": {}, + "foreignKeys": { + "vault_profiles_user_id_users_id_fk": { + "name": "vault_profiles_user_id_users_id_fk", + "tableFrom": "vault_profiles", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "vault_profiles_id": { + "name": "vault_profiles_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": { + "vault_profiles_sync_id_unique": { + "name": "vault_profiles_sync_id_unique", + "columns": [ + "sync_id" + ] + } + }, + "checkConstraint": {} + }, + "vault_tokens": { + "name": "vault_tokens", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "profile_id": { + "name": "profile_id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ssh_cert": { + "name": "ssh_cert", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "private_key": { + "name": "private_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "expires_at": { + "name": "expires_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_used": { + "name": "last_used", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "idx_vault_tokens_user_profile": { + "name": "idx_vault_tokens_user_profile", + "columns": [ + "user_id", + "profile_id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "vault_tokens_user_id_users_id_fk": { + "name": "vault_tokens_user_id_users_id_fk", + "tableFrom": "vault_tokens", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "vault_tokens_profile_id_vault_profiles_id_fk": { + "name": "vault_tokens_profile_id_vault_profiles_id_fk", + "tableFrom": "vault_tokens", + "tableTo": "vault_profiles", + "columnsFrom": [ + "profile_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "vault_tokens_id": { + "name": "vault_tokens_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "webauthn_credentials": { + "name": "webauthn_credentials", + "columns": { + "id": { + "name": "id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "credential_id": { + "name": "credential_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "public_key": { + "name": "public_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "counter": { + "name": "counter", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "device_type": { + "name": "device_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "backed_up": { + "name": "backed_up", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "transports": { + "name": "transports", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "user_verification": { + "name": "user_verification", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "('preferred')" + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "last_used_at": { + "name": "last_used_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "webauthn_credentials_user_id_users_id_fk": { + "name": "webauthn_credentials_user_id_users_id_fk", + "tableFrom": "webauthn_credentials", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "webauthn_credentials_id": { + "name": "webauthn_credentials_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + } + }, + "views": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "tables": {}, + "indexes": {} + } +} \ No newline at end of file diff --git a/drizzle/mysql/meta/_journal.json b/drizzle/mysql/meta/_journal.json new file mode 100644 index 00000000..adef7524 --- /dev/null +++ b/drizzle/mysql/meta/_journal.json @@ -0,0 +1,13 @@ +{ + "version": "7", + "dialect": "mysql", + "entries": [ + { + "idx": 0, + "version": "5", + "when": 1785738871436, + "tag": "0000_clean_pretty_boy", + "breakpoints": true + } + ] +} \ No newline at end of file diff --git a/drizzle/postgres/0000_jazzy_infant_terrible.sql b/drizzle/postgres/0000_jazzy_infant_terrible.sql new file mode 100644 index 00000000..dd4c9317 --- /dev/null +++ b/drizzle/postgres/0000_jazzy_infant_terrible.sql @@ -0,0 +1,837 @@ +CREATE TABLE "alert_firings" ( + "id" serial PRIMARY KEY NOT NULL, + "user_id" varchar(255) NOT NULL, + "rule_id" integer NOT NULL, + "host_id" integer NOT NULL, + "host_name" text NOT NULL, + "fired_at" text DEFAULT CURRENT_TIMESTAMP NOT NULL, + "resolved_at" text, + "value" double precision, + "message" text NOT NULL, + "severity" text DEFAULT 'warning' NOT NULL, + "acknowledged" boolean DEFAULT false NOT NULL +); +--> statement-breakpoint +CREATE TABLE "alert_rule_channels" ( + "id" serial PRIMARY KEY NOT NULL, + "rule_id" integer NOT NULL, + "channel_id" integer NOT NULL +); +--> statement-breakpoint +CREATE TABLE "alert_rules" ( + "id" serial PRIMARY KEY NOT NULL, + "user_id" varchar(255) NOT NULL, + "host_id" integer, + "name" varchar(255) NOT NULL, + "enabled" boolean DEFAULT true NOT NULL, + "trigger_type" text NOT NULL, + "threshold_value" double precision, + "threshold_duration_seconds" integer, + "cooldown_minutes" integer DEFAULT 15 NOT NULL, + "created_at" text DEFAULT CURRENT_TIMESTAMP NOT NULL, + "updated_at" text DEFAULT CURRENT_TIMESTAMP NOT NULL +); +--> statement-breakpoint +CREATE TABLE "api_keys" ( + "id" varchar(255) PRIMARY KEY NOT NULL, + "user_id" varchar(255) NOT NULL, + "name" varchar(255) NOT NULL, + "token_hash" text NOT NULL, + "token_prefix" text NOT NULL, + "created_at" text DEFAULT CURRENT_TIMESTAMP NOT NULL, + "expires_at" text, + "last_used_at" text, + "is_active" boolean DEFAULT true NOT NULL +); +--> statement-breakpoint +CREATE TABLE "audit_logs" ( + "id" serial PRIMARY KEY NOT NULL, + "user_id" varchar(255), + "username" text NOT NULL, + "action" text NOT NULL, + "resource_type" text NOT NULL, + "resource_id" text, + "resource_name" text, + "details" text, + "ip_address" text, + "user_agent" text, + "success" boolean NOT NULL, + "error_message" text, + "timestamp" text DEFAULT CURRENT_TIMESTAMP NOT NULL +); +--> statement-breakpoint +CREATE TABLE "c2s_tunnel_presets" ( + "id" serial PRIMARY KEY NOT NULL, + "user_id" varchar(255) NOT NULL, + "name" varchar(255) NOT NULL, + "config" text NOT NULL, + "platform" text, + "computer_name" text, + "created_at" text DEFAULT CURRENT_TIMESTAMP NOT NULL, + "updated_at" text DEFAULT CURRENT_TIMESTAMP NOT NULL +); +--> statement-breakpoint +CREATE TABLE "command_history" ( + "id" serial PRIMARY KEY NOT NULL, + "user_id" varchar(255) NOT NULL, + "host_id" integer NOT NULL, + "command" text NOT NULL, + "executed_at" text DEFAULT CURRENT_TIMESTAMP NOT NULL +); +--> statement-breakpoint +CREATE TABLE "dashboard_service_links" ( + "id" serial PRIMARY KEY NOT NULL, + "user_id" varchar(255) NOT NULL, + "label" text NOT NULL, + "url" text NOT NULL, + "order" integer DEFAULT 0 NOT NULL, + "sync_id" varchar(255), + "created_at" text DEFAULT CURRENT_TIMESTAMP NOT NULL, + "updated_at" text DEFAULT CURRENT_TIMESTAMP NOT NULL, + CONSTRAINT "dashboard_service_links_sync_id_unique" UNIQUE("sync_id") +); +--> statement-breakpoint +CREATE TABLE "dismissed_alerts" ( + "id" serial PRIMARY KEY NOT NULL, + "user_id" varchar(255) NOT NULL, + "alert_id" text NOT NULL, + "dismissed_at" text DEFAULT CURRENT_TIMESTAMP NOT NULL +); +--> statement-breakpoint +CREATE TABLE "file_manager_pinned" ( + "id" serial PRIMARY KEY NOT NULL, + "user_id" varchar(255) NOT NULL, + "host_id" integer NOT NULL, + "name" varchar(255) NOT NULL, + "path" text NOT NULL, + "pinned_at" text DEFAULT CURRENT_TIMESTAMP NOT NULL +); +--> statement-breakpoint +CREATE TABLE "file_manager_recent" ( + "id" serial PRIMARY KEY NOT NULL, + "user_id" varchar(255) NOT NULL, + "host_id" integer NOT NULL, + "name" varchar(255) NOT NULL, + "path" text NOT NULL, + "last_opened" text DEFAULT CURRENT_TIMESTAMP NOT NULL +); +--> statement-breakpoint +CREATE TABLE "file_manager_shortcuts" ( + "id" serial PRIMARY KEY NOT NULL, + "user_id" varchar(255) NOT NULL, + "host_id" integer NOT NULL, + "name" varchar(255) NOT NULL, + "path" text NOT NULL, + "created_at" text DEFAULT CURRENT_TIMESTAMP NOT NULL +); +--> statement-breakpoint +CREATE TABLE "homepage_items" ( + "id" serial PRIMARY KEY NOT NULL, + "user_id" varchar(255) NOT NULL, + "type_id" text NOT NULL, + "title" text, + "config" text DEFAULT '{}' NOT NULL, + "folder_id" integer, + "sync_id" varchar(255), + "created_at" text DEFAULT CURRENT_TIMESTAMP NOT NULL, + "updated_at" text DEFAULT CURRENT_TIMESTAMP NOT NULL, + CONSTRAINT "homepage_items_sync_id_unique" UNIQUE("sync_id") +); +--> statement-breakpoint +CREATE TABLE "homepage_layouts" ( + "id" serial PRIMARY KEY NOT NULL, + "user_id" varchar(255) NOT NULL, + "layout" text DEFAULT '{}' NOT NULL, + "updated_at" text DEFAULT CURRENT_TIMESTAMP NOT NULL, + CONSTRAINT "homepage_layouts_user_id_unique" UNIQUE("user_id") +); +--> statement-breakpoint +CREATE TABLE "host_access" ( + "id" serial PRIMARY KEY NOT NULL, + "host_id" integer NOT NULL, + "user_id" varchar(255), + "role_id" integer, + "granted_by" varchar(255) NOT NULL, + "permission_level" text DEFAULT 'connect' NOT NULL, + "expires_at" text, + "created_at" text DEFAULT CURRENT_TIMESTAMP NOT NULL, + "last_accessed_at" text, + "access_count" integer DEFAULT 0 NOT NULL +); +--> statement-breakpoint +CREATE TABLE "host_health_checks" ( + "id" serial PRIMARY KEY NOT NULL, + "user_id" varchar(255) NOT NULL, + "host_id" integer NOT NULL, + "checks" text NOT NULL, + "interval_seconds" integer DEFAULT 300 NOT NULL, + "created_at" text DEFAULT CURRENT_TIMESTAMP NOT NULL, + "updated_at" text DEFAULT CURRENT_TIMESTAMP NOT NULL +); +--> statement-breakpoint +CREATE TABLE "host_health_history" ( + "id" serial PRIMARY KEY NOT NULL, + "user_id" varchar(255) NOT NULL, + "host_id" integer NOT NULL, + "check_id" text NOT NULL, + "ts" text DEFAULT CURRENT_TIMESTAMP NOT NULL, + "ok" boolean NOT NULL, + "latency_ms" integer, + "detail" text +); +--> statement-breakpoint +CREATE TABLE "host_metrics_history" ( + "id" serial PRIMARY KEY NOT NULL, + "host_id" integer NOT NULL, + "ts" text DEFAULT CURRENT_TIMESTAMP NOT NULL, + "cpu_percent" double precision, + "mem_percent" double precision, + "disk_percent" double precision, + "net_rx_bytes" integer, + "net_tx_bytes" integer +); +--> statement-breakpoint +CREATE TABLE "host_metrics_preferences" ( + "id" serial PRIMARY KEY NOT NULL, + "user_id" varchar(255) NOT NULL, + "host_id" integer NOT NULL, + "layout" text NOT NULL, + "created_at" text DEFAULT CURRENT_TIMESTAMP NOT NULL, + "updated_at" text DEFAULT CURRENT_TIMESTAMP NOT NULL +); +--> statement-breakpoint +CREATE TABLE "ssh_data" ( + "id" serial PRIMARY KEY NOT NULL, + "user_id" varchar(255) NOT NULL, + "connection_type" text DEFAULT 'ssh' NOT NULL, + "name" varchar(255), + "ip" text NOT NULL, + "port" integer NOT NULL, + "username" text NOT NULL, + "folder" text, + "tags" text, + "pin" boolean DEFAULT false NOT NULL, + "auth_type" text NOT NULL, + "use_warpgate" boolean DEFAULT false NOT NULL, + "share_ssh_auth" boolean DEFAULT false NOT NULL, + "force_keyboard_interactive" text, + "password" text, + "key" text, + "key_password" text, + "key_type" text, + "sudo_password" text, + "autostart_password" text, + "autostart_key" text, + "autostart_key_password" text, + "credential_id" integer, + "override_credential_username" boolean, + "vault_profile_id" integer, + "enable_terminal" boolean DEFAULT true NOT NULL, + "enable_session_logging" boolean DEFAULT true NOT NULL, + "allow_session_sharing" boolean DEFAULT true NOT NULL, + "enable_command_history" boolean DEFAULT true NOT NULL, + "enable_tunnel" boolean DEFAULT true NOT NULL, + "tunnel_connections" text, + "jump_hosts" text, + "enable_file_manager" boolean DEFAULT true NOT NULL, + "scp_legacy" boolean DEFAULT false NOT NULL, + "enable_docker" boolean DEFAULT false NOT NULL, + "enable_tmux_monitor" boolean DEFAULT false NOT NULL, + "show_terminal_in_sidebar" boolean DEFAULT true NOT NULL, + "show_file_manager_in_sidebar" boolean DEFAULT false NOT NULL, + "show_tunnel_in_sidebar" boolean DEFAULT false NOT NULL, + "show_docker_in_sidebar" boolean DEFAULT false NOT NULL, + "show_server_stats_in_sidebar" boolean DEFAULT false NOT NULL, + "default_path" text, + "stats_config" text, + "docker_config" text, + "enable_proxmox" boolean DEFAULT false NOT NULL, + "proxmox_config" text, + "terminal_config" text, + "quick_actions" text, + "notes" text, + "enable_ssh" boolean DEFAULT true NOT NULL, + "enable_rdp" boolean DEFAULT false NOT NULL, + "enable_vnc" boolean DEFAULT false NOT NULL, + "enable_telnet" boolean DEFAULT false NOT NULL, + "ssh_port" integer DEFAULT 22, + "rdp_port" integer DEFAULT 3389, + "vnc_port" integer DEFAULT 5900, + "telnet_port" integer DEFAULT 23, + "rdp_credential_id" integer, + "rdp_user" text, + "rdp_password" text, + "rdp_domain" text, + "rdp_security" text, + "rdp_ignore_cert" boolean DEFAULT false, + "vnc_credential_id" integer, + "vnc_password" text, + "vnc_user" text, + "telnet_user" text, + "telnet_password" text, + "telnet_credential_id" integer, + "rdp_auth_type" text, + "vnc_auth_type" text, + "telnet_auth_type" text, + "domain" text, + "security" text, + "ignore_cert" boolean DEFAULT false, + "guacamole_config" text, + "use_socks5" boolean, + "socks5_host" text, + "socks5_port" integer, + "socks5_username" text, + "socks5_password" text, + "socks5_proxy_chain" text, + "connection_origin" text, + "mac_address" text, + "wol_broadcast_address" text, + "port_knock_sequence" text, + "host_key_fingerprint" text, + "host_key_type" text, + "host_key_algorithm" text DEFAULT 'sha256', + "host_key_first_seen" text, + "host_key_last_verified" text, + "host_key_changed_count" integer DEFAULT 0, + "sync_id" varchar(255), + "created_at" text DEFAULT CURRENT_TIMESTAMP NOT NULL, + "updated_at" text DEFAULT CURRENT_TIMESTAMP NOT NULL, + CONSTRAINT "ssh_data_sync_id_unique" UNIQUE("sync_id") +); +--> statement-breakpoint +CREATE TABLE "network_topology" ( + "id" serial PRIMARY KEY NOT NULL, + "user_id" varchar(255) NOT NULL, + "topology" text, + "created_at" text DEFAULT CURRENT_TIMESTAMP NOT NULL, + "updated_at" text DEFAULT CURRENT_TIMESTAMP NOT NULL +); +--> statement-breakpoint +CREATE TABLE "notification_channels" ( + "id" serial PRIMARY KEY NOT NULL, + "user_id" varchar(255) NOT NULL, + "name" varchar(255) NOT NULL, + "type" text NOT NULL, + "config" text NOT NULL, + "enabled" boolean DEFAULT true NOT NULL, + "created_at" text DEFAULT CURRENT_TIMESTAMP NOT NULL +); +--> statement-breakpoint +CREATE TABLE "opkssh_tokens" ( + "id" serial PRIMARY KEY NOT NULL, + "user_id" varchar(255) NOT NULL, + "host_id" integer NOT NULL, + "ssh_cert" text NOT NULL, + "private_key" text NOT NULL, + "email" text, + "sub" text, + "issuer" text, + "audience" text, + "created_at" text DEFAULT CURRENT_TIMESTAMP NOT NULL, + "expires_at" text NOT NULL, + "last_used" text +); +--> statement-breakpoint +CREATE TABLE "recent_activity" ( + "id" serial PRIMARY KEY NOT NULL, + "user_id" varchar(255) NOT NULL, + "type" text NOT NULL, + "host_id" integer NOT NULL, + "host_name" text, + "timestamp" text DEFAULT CURRENT_TIMESTAMP NOT NULL +); +--> statement-breakpoint +CREATE TABLE "roles" ( + "id" serial PRIMARY KEY NOT NULL, + "name" varchar(255) NOT NULL, + "display_name" text NOT NULL, + "description" text, + "is_system" boolean DEFAULT false NOT NULL, + "permissions" text, + "created_at" text DEFAULT CURRENT_TIMESTAMP NOT NULL, + "updated_at" text DEFAULT CURRENT_TIMESTAMP NOT NULL, + CONSTRAINT "roles_name_unique" UNIQUE("name") +); +--> statement-breakpoint +CREATE TABLE "session_recordings" ( + "id" serial PRIMARY KEY NOT NULL, + "host_id" integer NOT NULL, + "user_id" varchar(255), + "username" text, + "access_id" integer, + "started_at" text DEFAULT CURRENT_TIMESTAMP NOT NULL, + "ended_at" text, + "duration" integer, + "commands" text, + "dangerous_actions" text, + "recording_path" text, + "protocol" varchar(255) DEFAULT 'ssh' NOT NULL, + "format" text DEFAULT 'text' NOT NULL, + "terminated_by_owner" boolean DEFAULT false, + "termination_reason" text +); +--> statement-breakpoint +CREATE TABLE "session_share_participants" ( + "id" serial PRIMARY KEY NOT NULL, + "share_id" varchar(255) NOT NULL, + "user_id" varchar(255), + "guest_label" text, + "joined_at" text DEFAULT CURRENT_TIMESTAMP NOT NULL, + "left_at" text +); +--> statement-breakpoint +CREATE TABLE "session_shares" ( + "id" varchar(255) PRIMARY KEY NOT NULL, + "host_id" integer NOT NULL, + "owner_user_id" varchar(255) NOT NULL, + "protocol" varchar(255) NOT NULL, + "session_id" text NOT NULL, + "tab_instance_id" text, + "share_type" text NOT NULL, + "target_user_id" varchar(255), + "link_token" varchar(255), + "permission_level" text DEFAULT 'read-only' NOT NULL, + "created_at" text DEFAULT CURRENT_TIMESTAMP NOT NULL, + "expires_at" text NOT NULL, + "revoked_at" text, + "last_joined_at" text, + "join_count" integer DEFAULT 0 NOT NULL, + CONSTRAINT "session_shares_link_token_unique" UNIQUE("link_token") +); +--> statement-breakpoint +CREATE TABLE "sessions" ( + "id" varchar(255) PRIMARY KEY NOT NULL, + "user_id" varchar(255) NOT NULL, + "jwt_token" text NOT NULL, + "device_type" text NOT NULL, + "device_info" text NOT NULL, + "oidc_sub" text, + "oidc_sid" text, + "sso_provider_id" integer, + "created_at" text DEFAULT CURRENT_TIMESTAMP NOT NULL, + "expires_at" text NOT NULL, + "last_active_at" text DEFAULT CURRENT_TIMESTAMP NOT NULL +); +--> statement-breakpoint +CREATE TABLE "settings" ( + "key" varchar(255) PRIMARY KEY NOT NULL, + "value" text NOT NULL +); +--> statement-breakpoint +CREATE TABLE "shared_host_auth_overrides" ( + "id" serial PRIMARY KEY NOT NULL, + "host_id" integer NOT NULL, + "user_id" varchar(255) NOT NULL, + "protocol" varchar(255) DEFAULT 'ssh' NOT NULL, + "credential_id" integer NOT NULL, + "created_at" text DEFAULT CURRENT_TIMESTAMP NOT NULL, + "updated_at" text DEFAULT CURRENT_TIMESTAMP NOT NULL +); +--> statement-breakpoint +CREATE TABLE "shared_host_secrets" ( + "id" serial PRIMARY KEY NOT NULL, + "host_access_id" integer NOT NULL, + "target_user_id" varchar(255) NOT NULL, + "protocol" varchar(255) DEFAULT 'ssh' NOT NULL, + "source_type" text DEFAULT 'credential' NOT NULL, + "original_credential_id" integer, + "encrypted_username" text, + "encrypted_auth_type" text, + "encrypted_password" text, + "encrypted_key" text, + "encrypted_key_password" text, + "encrypted_key_type" text, + "encrypted_domain" text, + "created_at" text DEFAULT CURRENT_TIMESTAMP NOT NULL, + "updated_at" text DEFAULT CURRENT_TIMESTAMP NOT NULL +); +--> statement-breakpoint +CREATE TABLE "snippet_access" ( + "id" serial PRIMARY KEY NOT NULL, + "snippet_id" integer NOT NULL, + "user_id" varchar(255), + "role_id" integer, + "granted_by" varchar(255) NOT NULL, + "permission_level" text DEFAULT 'view' NOT NULL, + "expires_at" text, + "created_at" text DEFAULT CURRENT_TIMESTAMP NOT NULL +); +--> statement-breakpoint +CREATE TABLE "snippet_folders" ( + "id" serial PRIMARY KEY NOT NULL, + "user_id" varchar(255) NOT NULL, + "name" varchar(255) NOT NULL, + "color" text, + "icon" text, + "sync_id" varchar(255), + "created_at" text DEFAULT CURRENT_TIMESTAMP NOT NULL, + "updated_at" text DEFAULT CURRENT_TIMESTAMP NOT NULL, + CONSTRAINT "snippet_folders_sync_id_unique" UNIQUE("sync_id") +); +--> statement-breakpoint +CREATE TABLE "snippets" ( + "id" serial PRIMARY KEY NOT NULL, + "user_id" varchar(255) NOT NULL, + "name" varchar(255) NOT NULL, + "content" text NOT NULL, + "description" text, + "folder" text, + "order" integer DEFAULT 0 NOT NULL, + "sync_id" varchar(255), + "created_at" text DEFAULT CURRENT_TIMESTAMP NOT NULL, + "updated_at" text DEFAULT CURRENT_TIMESTAMP NOT NULL, + "host_filter" text, + CONSTRAINT "snippets_sync_id_unique" UNIQUE("sync_id") +); +--> statement-breakpoint +CREATE TABLE "ssh_credential_usage" ( + "id" serial PRIMARY KEY NOT NULL, + "credential_id" integer NOT NULL, + "host_id" integer NOT NULL, + "user_id" varchar(255) NOT NULL, + "used_at" text DEFAULT CURRENT_TIMESTAMP NOT NULL +); +--> statement-breakpoint +CREATE TABLE "ssh_credentials" ( + "id" serial PRIMARY KEY NOT NULL, + "user_id" varchar(255) NOT NULL, + "name" varchar(255) NOT NULL, + "description" text, + "folder" text, + "tags" text, + "auth_type" text NOT NULL, + "username" text, + "password" text, + "key" text, + "private_key" text, + "public_key" text, + "key_password" text, + "key_type" text, + "detected_key_type" text, + "cert_public_key" text, + "usage_count" integer DEFAULT 0 NOT NULL, + "last_used" text, + "sync_id" varchar(255), + "created_at" text DEFAULT CURRENT_TIMESTAMP NOT NULL, + "updated_at" text DEFAULT CURRENT_TIMESTAMP NOT NULL, + CONSTRAINT "ssh_credentials_sync_id_unique" UNIQUE("sync_id") +); +--> statement-breakpoint +CREATE TABLE "ssh_folders" ( + "id" serial PRIMARY KEY NOT NULL, + "user_id" varchar(255) NOT NULL, + "name" varchar(255) NOT NULL, + "color" text, + "icon" text, + "credential_id" integer, + "sync_id" varchar(255), + "created_at" text DEFAULT CURRENT_TIMESTAMP NOT NULL, + "updated_at" text DEFAULT CURRENT_TIMESTAMP NOT NULL, + CONSTRAINT "ssh_folders_sync_id_unique" UNIQUE("sync_id") +); +--> statement-breakpoint +CREATE TABLE "sso_providers" ( + "id" serial PRIMARY KEY NOT NULL, + "name" varchar(255) NOT NULL, + "type" text NOT NULL, + "enabled" boolean DEFAULT true NOT NULL, + "display_order" integer DEFAULT 0 NOT NULL, + "config" text NOT NULL, + "created_at" text DEFAULT CURRENT_TIMESTAMP NOT NULL, + "updated_at" text DEFAULT CURRENT_TIMESTAMP NOT NULL +); +--> statement-breakpoint +CREATE TABLE "sync_tombstones" ( + "id" serial PRIMARY KEY NOT NULL, + "user_id" varchar(255) NOT NULL, + "entity_type" text NOT NULL, + "sync_id" varchar(255) NOT NULL, + "deleted_at" text DEFAULT CURRENT_TIMESTAMP NOT NULL +); +--> statement-breakpoint +CREATE TABLE "termix_identities" ( + "id" serial PRIMARY KEY NOT NULL, + "user_id" varchar(255) NOT NULL, + "handle" varchar(255) NOT NULL, + "description" text, + "created_at" text DEFAULT CURRENT_TIMESTAMP NOT NULL, + "updated_at" text DEFAULT CURRENT_TIMESTAMP NOT NULL, + CONSTRAINT "termix_identities_user_id_unique" UNIQUE("user_id"), + CONSTRAINT "termix_identities_handle_unique" UNIQUE("handle") +); +--> statement-breakpoint +CREATE TABLE "termix_identity_ca" ( + "id" serial PRIMARY KEY NOT NULL, + "identity_id" integer NOT NULL, + "user_id" varchar(255) NOT NULL, + "public_key" text NOT NULL, + "private_key" text NOT NULL, + "validity_days" integer DEFAULT 90 NOT NULL, + "created_at" text DEFAULT CURRENT_TIMESTAMP NOT NULL, + "updated_at" text DEFAULT CURRENT_TIMESTAMP NOT NULL, + CONSTRAINT "termix_identity_ca_identity_id_unique" UNIQUE("identity_id") +); +--> statement-breakpoint +CREATE TABLE "termix_identity_keys" ( + "id" serial PRIMARY KEY NOT NULL, + "identity_id" integer NOT NULL, + "user_id" varchar(255) NOT NULL, + "public_key" text NOT NULL, + "key_type" text NOT NULL, + "algorithm" text NOT NULL, + "label" text, + "comment" text, + "source" text DEFAULT 'manual' NOT NULL, + "credential_id" integer, + "enabled" boolean DEFAULT true NOT NULL, + "created_at" text DEFAULT CURRENT_TIMESTAMP NOT NULL +); +--> statement-breakpoint +CREATE TABLE "tmux_session_tags" ( + "id" serial PRIMARY KEY NOT NULL, + "user_id" varchar(255) NOT NULL, + "host_id" integer NOT NULL, + "session_name" text NOT NULL, + "tag" text NOT NULL, + "created_at" text DEFAULT CURRENT_TIMESTAMP NOT NULL +); +--> statement-breakpoint +CREATE TABLE "transfer_recent" ( + "id" serial PRIMARY KEY NOT NULL, + "user_id" varchar(255) NOT NULL, + "source_host_id" integer NOT NULL, + "dest_host_id" integer NOT NULL, + "dest_path" text NOT NULL, + "dest_path_label" text NOT NULL, + "last_used" text DEFAULT CURRENT_TIMESTAMP NOT NULL +); +--> statement-breakpoint +CREATE TABLE "trusted_devices" ( + "id" varchar(255) PRIMARY KEY NOT NULL, + "user_id" varchar(255) NOT NULL, + "device_fingerprint" text NOT NULL, + "device_type" text NOT NULL, + "device_info" text NOT NULL, + "created_at" text DEFAULT CURRENT_TIMESTAMP NOT NULL, + "expires_at" text NOT NULL, + "last_used_at" text DEFAULT CURRENT_TIMESTAMP NOT NULL +); +--> statement-breakpoint +CREATE TABLE "user_open_tabs" ( + "id" varchar(255) PRIMARY KEY NOT NULL, + "user_id" varchar(255) NOT NULL, + "tab_type" text NOT NULL, + "host_id" integer, + "label" text NOT NULL, + "tab_order" integer DEFAULT 0 NOT NULL, + "backend_session_id" text, + "created_at" text DEFAULT CURRENT_TIMESTAMP NOT NULL, + "updated_at" text DEFAULT CURRENT_TIMESTAMP NOT NULL +); +--> statement-breakpoint +CREATE TABLE "user_preferences" ( + "user_id" varchar(255) PRIMARY KEY NOT NULL, + "reopen_tabs_on_login" boolean DEFAULT false NOT NULL, + "theme" text, + "font_size" text, + "accent_color" text, + "language" text, + "storage_mode" text, + "command_autocomplete" boolean, + "command_palette_enabled" boolean, + "show_host_tags" boolean, + "host_tray_on_click" boolean, + "pin_app_rail" boolean, + "expand_app_rail_on_hover" boolean, + "folders_collapsed" boolean, + "confirm_snippet_execution" boolean, + "disable_update_check" boolean, + "confirm_tab_close" boolean, + "hidden_rail_tabs" text, + "compact_host_view" boolean, + "status_color_scheme" text, + "custom_themes" text, + "custom_keybindings" text, + "updated_at" text DEFAULT CURRENT_TIMESTAMP NOT NULL +); +--> statement-breakpoint +CREATE TABLE "user_roles" ( + "id" serial PRIMARY KEY NOT NULL, + "user_id" varchar(255) NOT NULL, + "role_id" integer NOT NULL, + "granted_by" varchar(255), + "granted_at" text DEFAULT CURRENT_TIMESTAMP NOT NULL +); +--> statement-breakpoint +CREATE TABLE "users" ( + "id" varchar(255) PRIMARY KEY NOT NULL, + "username" text NOT NULL, + "password_hash" text NOT NULL, + "is_admin" boolean DEFAULT false NOT NULL, + "is_oidc" boolean DEFAULT false NOT NULL, + "oidc_identifier" text, + "sso_provider_id" integer, + "client_id" text, + "client_secret" text, + "issuer_url" text, + "authorization_url" text, + "token_url" text, + "identifier_path" text, + "name_path" text, + "scopes" text DEFAULT 'openid email profile', + "totp_secret" text, + "totp_enabled" boolean DEFAULT false NOT NULL, + "totp_backup_codes" text, + "registered_at" text DEFAULT CURRENT_TIMESTAMP NOT NULL, + "donation_modal_dismissed" boolean DEFAULT false NOT NULL +); +--> statement-breakpoint +CREATE TABLE "vault_profiles" ( + "id" serial PRIMARY KEY NOT NULL, + "user_id" varchar(255) NOT NULL, + "name" varchar(255) NOT NULL, + "description" text, + "folder" text, + "tags" text, + "vault_addr" text NOT NULL, + "vault_namespace" text, + "oidc_mount" text, + "oidc_role" text, + "ssh_mount" text, + "ssh_role" text NOT NULL, + "valid_principals" text, + "key_type" text, + "shared" boolean DEFAULT false NOT NULL, + "sync_id" varchar(255), + "created_at" text DEFAULT CURRENT_TIMESTAMP NOT NULL, + "updated_at" text DEFAULT CURRENT_TIMESTAMP NOT NULL, + CONSTRAINT "vault_profiles_sync_id_unique" UNIQUE("sync_id") +); +--> statement-breakpoint +CREATE TABLE "vault_tokens" ( + "id" serial PRIMARY KEY NOT NULL, + "user_id" varchar(255) NOT NULL, + "profile_id" integer NOT NULL, + "ssh_cert" text NOT NULL, + "private_key" text NOT NULL, + "created_at" text DEFAULT CURRENT_TIMESTAMP NOT NULL, + "expires_at" text NOT NULL, + "last_used" text +); +--> statement-breakpoint +CREATE TABLE "webauthn_credentials" ( + "id" varchar(255) PRIMARY KEY NOT NULL, + "user_id" varchar(255) NOT NULL, + "name" varchar(255) NOT NULL, + "credential_id" text NOT NULL, + "public_key" text NOT NULL, + "counter" integer DEFAULT 0 NOT NULL, + "device_type" text, + "backed_up" boolean DEFAULT false NOT NULL, + "transports" text, + "user_verification" text DEFAULT 'preferred' NOT NULL, + "created_at" text DEFAULT CURRENT_TIMESTAMP NOT NULL, + "last_used_at" text +); +--> statement-breakpoint +ALTER TABLE "alert_firings" ADD CONSTRAINT "alert_firings_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "alert_firings" ADD CONSTRAINT "alert_firings_rule_id_alert_rules_id_fk" FOREIGN KEY ("rule_id") REFERENCES "public"."alert_rules"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "alert_rule_channels" ADD CONSTRAINT "alert_rule_channels_rule_id_alert_rules_id_fk" FOREIGN KEY ("rule_id") REFERENCES "public"."alert_rules"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "alert_rule_channels" ADD CONSTRAINT "alert_rule_channels_channel_id_notification_channels_id_fk" FOREIGN KEY ("channel_id") REFERENCES "public"."notification_channels"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "alert_rules" ADD CONSTRAINT "alert_rules_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "alert_rules" ADD CONSTRAINT "alert_rules_host_id_ssh_data_id_fk" FOREIGN KEY ("host_id") REFERENCES "public"."ssh_data"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "api_keys" ADD CONSTRAINT "api_keys_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "audit_logs" ADD CONSTRAINT "audit_logs_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "c2s_tunnel_presets" ADD CONSTRAINT "c2s_tunnel_presets_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "command_history" ADD CONSTRAINT "command_history_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "command_history" ADD CONSTRAINT "command_history_host_id_ssh_data_id_fk" FOREIGN KEY ("host_id") REFERENCES "public"."ssh_data"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "dashboard_service_links" ADD CONSTRAINT "dashboard_service_links_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "dismissed_alerts" ADD CONSTRAINT "dismissed_alerts_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "file_manager_pinned" ADD CONSTRAINT "file_manager_pinned_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "file_manager_pinned" ADD CONSTRAINT "file_manager_pinned_host_id_ssh_data_id_fk" FOREIGN KEY ("host_id") REFERENCES "public"."ssh_data"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "file_manager_recent" ADD CONSTRAINT "file_manager_recent_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "file_manager_recent" ADD CONSTRAINT "file_manager_recent_host_id_ssh_data_id_fk" FOREIGN KEY ("host_id") REFERENCES "public"."ssh_data"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "file_manager_shortcuts" ADD CONSTRAINT "file_manager_shortcuts_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "file_manager_shortcuts" ADD CONSTRAINT "file_manager_shortcuts_host_id_ssh_data_id_fk" FOREIGN KEY ("host_id") REFERENCES "public"."ssh_data"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "homepage_items" ADD CONSTRAINT "homepage_items_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "homepage_layouts" ADD CONSTRAINT "homepage_layouts_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "host_access" ADD CONSTRAINT "host_access_host_id_ssh_data_id_fk" FOREIGN KEY ("host_id") REFERENCES "public"."ssh_data"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "host_access" ADD CONSTRAINT "host_access_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "host_access" ADD CONSTRAINT "host_access_role_id_roles_id_fk" FOREIGN KEY ("role_id") REFERENCES "public"."roles"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "host_access" ADD CONSTRAINT "host_access_granted_by_users_id_fk" FOREIGN KEY ("granted_by") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "host_health_checks" ADD CONSTRAINT "host_health_checks_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "host_health_checks" ADD CONSTRAINT "host_health_checks_host_id_ssh_data_id_fk" FOREIGN KEY ("host_id") REFERENCES "public"."ssh_data"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "host_health_history" ADD CONSTRAINT "host_health_history_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "host_health_history" ADD CONSTRAINT "host_health_history_host_id_ssh_data_id_fk" FOREIGN KEY ("host_id") REFERENCES "public"."ssh_data"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "host_metrics_history" ADD CONSTRAINT "host_metrics_history_host_id_ssh_data_id_fk" FOREIGN KEY ("host_id") REFERENCES "public"."ssh_data"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "host_metrics_preferences" ADD CONSTRAINT "host_metrics_preferences_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "host_metrics_preferences" ADD CONSTRAINT "host_metrics_preferences_host_id_ssh_data_id_fk" FOREIGN KEY ("host_id") REFERENCES "public"."ssh_data"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "ssh_data" ADD CONSTRAINT "ssh_data_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "ssh_data" ADD CONSTRAINT "ssh_data_credential_id_ssh_credentials_id_fk" FOREIGN KEY ("credential_id") REFERENCES "public"."ssh_credentials"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "ssh_data" ADD CONSTRAINT "ssh_data_vault_profile_id_vault_profiles_id_fk" FOREIGN KEY ("vault_profile_id") REFERENCES "public"."vault_profiles"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "ssh_data" ADD CONSTRAINT "ssh_data_rdp_credential_id_ssh_credentials_id_fk" FOREIGN KEY ("rdp_credential_id") REFERENCES "public"."ssh_credentials"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "ssh_data" ADD CONSTRAINT "ssh_data_vnc_credential_id_ssh_credentials_id_fk" FOREIGN KEY ("vnc_credential_id") REFERENCES "public"."ssh_credentials"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "ssh_data" ADD CONSTRAINT "ssh_data_telnet_credential_id_ssh_credentials_id_fk" FOREIGN KEY ("telnet_credential_id") REFERENCES "public"."ssh_credentials"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "network_topology" ADD CONSTRAINT "network_topology_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "notification_channels" ADD CONSTRAINT "notification_channels_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "opkssh_tokens" ADD CONSTRAINT "opkssh_tokens_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "opkssh_tokens" ADD CONSTRAINT "opkssh_tokens_host_id_ssh_data_id_fk" FOREIGN KEY ("host_id") REFERENCES "public"."ssh_data"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "recent_activity" ADD CONSTRAINT "recent_activity_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "recent_activity" ADD CONSTRAINT "recent_activity_host_id_ssh_data_id_fk" FOREIGN KEY ("host_id") REFERENCES "public"."ssh_data"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "session_recordings" ADD CONSTRAINT "session_recordings_host_id_ssh_data_id_fk" FOREIGN KEY ("host_id") REFERENCES "public"."ssh_data"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "session_recordings" ADD CONSTRAINT "session_recordings_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "session_recordings" ADD CONSTRAINT "session_recordings_access_id_host_access_id_fk" FOREIGN KEY ("access_id") REFERENCES "public"."host_access"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "session_share_participants" ADD CONSTRAINT "session_share_participants_share_id_session_shares_id_fk" FOREIGN KEY ("share_id") REFERENCES "public"."session_shares"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "session_share_participants" ADD CONSTRAINT "session_share_participants_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "session_shares" ADD CONSTRAINT "session_shares_host_id_ssh_data_id_fk" FOREIGN KEY ("host_id") REFERENCES "public"."ssh_data"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "session_shares" ADD CONSTRAINT "session_shares_owner_user_id_users_id_fk" FOREIGN KEY ("owner_user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "session_shares" ADD CONSTRAINT "session_shares_target_user_id_users_id_fk" FOREIGN KEY ("target_user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "sessions" ADD CONSTRAINT "sessions_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "shared_host_auth_overrides" ADD CONSTRAINT "shared_host_auth_overrides_host_id_ssh_data_id_fk" FOREIGN KEY ("host_id") REFERENCES "public"."ssh_data"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "shared_host_auth_overrides" ADD CONSTRAINT "shared_host_auth_overrides_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "shared_host_auth_overrides" ADD CONSTRAINT "shared_host_auth_overrides_credential_id_ssh_credentials_id_fk" FOREIGN KEY ("credential_id") REFERENCES "public"."ssh_credentials"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "shared_host_secrets" ADD CONSTRAINT "shared_host_secrets_host_access_id_host_access_id_fk" FOREIGN KEY ("host_access_id") REFERENCES "public"."host_access"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "shared_host_secrets" ADD CONSTRAINT "shared_host_secrets_target_user_id_users_id_fk" FOREIGN KEY ("target_user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "shared_host_secrets" ADD CONSTRAINT "shared_host_secrets_original_credential_id_ssh_credentials_id_fk" FOREIGN KEY ("original_credential_id") REFERENCES "public"."ssh_credentials"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "snippet_access" ADD CONSTRAINT "snippet_access_snippet_id_snippets_id_fk" FOREIGN KEY ("snippet_id") REFERENCES "public"."snippets"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "snippet_access" ADD CONSTRAINT "snippet_access_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "snippet_access" ADD CONSTRAINT "snippet_access_role_id_roles_id_fk" FOREIGN KEY ("role_id") REFERENCES "public"."roles"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "snippet_access" ADD CONSTRAINT "snippet_access_granted_by_users_id_fk" FOREIGN KEY ("granted_by") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "snippet_folders" ADD CONSTRAINT "snippet_folders_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "snippets" ADD CONSTRAINT "snippets_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "ssh_credential_usage" ADD CONSTRAINT "ssh_credential_usage_credential_id_ssh_credentials_id_fk" FOREIGN KEY ("credential_id") REFERENCES "public"."ssh_credentials"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "ssh_credential_usage" ADD CONSTRAINT "ssh_credential_usage_host_id_ssh_data_id_fk" FOREIGN KEY ("host_id") REFERENCES "public"."ssh_data"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "ssh_credential_usage" ADD CONSTRAINT "ssh_credential_usage_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "ssh_credentials" ADD CONSTRAINT "ssh_credentials_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "ssh_folders" ADD CONSTRAINT "ssh_folders_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "ssh_folders" ADD CONSTRAINT "ssh_folders_credential_id_ssh_credentials_id_fk" FOREIGN KEY ("credential_id") REFERENCES "public"."ssh_credentials"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "sync_tombstones" ADD CONSTRAINT "sync_tombstones_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "termix_identities" ADD CONSTRAINT "termix_identities_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "termix_identity_ca" ADD CONSTRAINT "termix_identity_ca_identity_id_termix_identities_id_fk" FOREIGN KEY ("identity_id") REFERENCES "public"."termix_identities"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "termix_identity_ca" ADD CONSTRAINT "termix_identity_ca_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "termix_identity_keys" ADD CONSTRAINT "termix_identity_keys_identity_id_termix_identities_id_fk" FOREIGN KEY ("identity_id") REFERENCES "public"."termix_identities"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "termix_identity_keys" ADD CONSTRAINT "termix_identity_keys_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "termix_identity_keys" ADD CONSTRAINT "termix_identity_keys_credential_id_ssh_credentials_id_fk" FOREIGN KEY ("credential_id") REFERENCES "public"."ssh_credentials"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "tmux_session_tags" ADD CONSTRAINT "tmux_session_tags_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "tmux_session_tags" ADD CONSTRAINT "tmux_session_tags_host_id_ssh_data_id_fk" FOREIGN KEY ("host_id") REFERENCES "public"."ssh_data"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "transfer_recent" ADD CONSTRAINT "transfer_recent_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "transfer_recent" ADD CONSTRAINT "transfer_recent_source_host_id_ssh_data_id_fk" FOREIGN KEY ("source_host_id") REFERENCES "public"."ssh_data"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "transfer_recent" ADD CONSTRAINT "transfer_recent_dest_host_id_ssh_data_id_fk" FOREIGN KEY ("dest_host_id") REFERENCES "public"."ssh_data"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "trusted_devices" ADD CONSTRAINT "trusted_devices_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "user_open_tabs" ADD CONSTRAINT "user_open_tabs_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "user_open_tabs" ADD CONSTRAINT "user_open_tabs_host_id_ssh_data_id_fk" FOREIGN KEY ("host_id") REFERENCES "public"."ssh_data"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "user_preferences" ADD CONSTRAINT "user_preferences_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "user_roles" ADD CONSTRAINT "user_roles_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "user_roles" ADD CONSTRAINT "user_roles_role_id_roles_id_fk" FOREIGN KEY ("role_id") REFERENCES "public"."roles"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "user_roles" ADD CONSTRAINT "user_roles_granted_by_users_id_fk" FOREIGN KEY ("granted_by") REFERENCES "public"."users"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "vault_profiles" ADD CONSTRAINT "vault_profiles_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "vault_tokens" ADD CONSTRAINT "vault_tokens_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "vault_tokens" ADD CONSTRAINT "vault_tokens_profile_id_vault_profiles_id_fk" FOREIGN KEY ("profile_id") REFERENCES "public"."vault_profiles"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "webauthn_credentials" ADD CONSTRAINT "webauthn_credentials_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +CREATE UNIQUE INDEX "idx_host_health_checks_user_host" ON "host_health_checks" USING btree ("user_id","host_id");--> statement-breakpoint +CREATE UNIQUE INDEX "idx_host_metrics_prefs_user_host" ON "host_metrics_preferences" USING btree ("user_id","host_id");--> statement-breakpoint +CREATE UNIQUE INDEX "idx_opkssh_tokens_user_host" ON "opkssh_tokens" USING btree ("user_id","host_id");--> statement-breakpoint +CREATE UNIQUE INDEX "shared_host_auth_overrides_host_user_protocol_unique" ON "shared_host_auth_overrides" USING btree ("host_id","user_id","protocol");--> statement-breakpoint +CREATE UNIQUE INDEX "idx_shared_host_secrets_scope" ON "shared_host_secrets" USING btree ("host_access_id","target_user_id","protocol");--> statement-breakpoint +CREATE UNIQUE INDEX "idx_user_roles_user_role" ON "user_roles" USING btree ("user_id","role_id");--> statement-breakpoint +CREATE UNIQUE INDEX "idx_vault_tokens_user_profile" ON "vault_tokens" USING btree ("user_id","profile_id"); \ No newline at end of file diff --git a/drizzle/postgres/meta/0000_snapshot.json b/drizzle/postgres/meta/0000_snapshot.json new file mode 100644 index 00000000..3ace5944 --- /dev/null +++ b/drizzle/postgres/meta/0000_snapshot.json @@ -0,0 +1,5778 @@ +{ + "id": "3d15ec30-87a9-4af4-855a-75e325a74c5d", + "prevId": "00000000-0000-0000-0000-000000000000", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.alert_firings": { + "name": "alert_firings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "rule_id": { + "name": "rule_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "host_id": { + "name": "host_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "host_name": { + "name": "host_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "fired_at": { + "name": "fired_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "resolved_at": { + "name": "resolved_at", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "value": { + "name": "value", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "severity": { + "name": "severity", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'warning'" + }, + "acknowledged": { + "name": "acknowledged", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + } + }, + "indexes": {}, + "foreignKeys": { + "alert_firings_user_id_users_id_fk": { + "name": "alert_firings_user_id_users_id_fk", + "tableFrom": "alert_firings", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "alert_firings_rule_id_alert_rules_id_fk": { + "name": "alert_firings_rule_id_alert_rules_id_fk", + "tableFrom": "alert_firings", + "tableTo": "alert_rules", + "columnsFrom": [ + "rule_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.alert_rule_channels": { + "name": "alert_rule_channels", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "rule_id": { + "name": "rule_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "channel_id": { + "name": "channel_id", + "type": "integer", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "alert_rule_channels_rule_id_alert_rules_id_fk": { + "name": "alert_rule_channels_rule_id_alert_rules_id_fk", + "tableFrom": "alert_rule_channels", + "tableTo": "alert_rules", + "columnsFrom": [ + "rule_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "alert_rule_channels_channel_id_notification_channels_id_fk": { + "name": "alert_rule_channels_channel_id_notification_channels_id_fk", + "tableFrom": "alert_rule_channels", + "tableTo": "notification_channels", + "columnsFrom": [ + "channel_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.alert_rules": { + "name": "alert_rules", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "host_id": { + "name": "host_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "trigger_type": { + "name": "trigger_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "threshold_value": { + "name": "threshold_value", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "threshold_duration_seconds": { + "name": "threshold_duration_seconds", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "cooldown_minutes": { + "name": "cooldown_minutes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 15 + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": {}, + "foreignKeys": { + "alert_rules_user_id_users_id_fk": { + "name": "alert_rules_user_id_users_id_fk", + "tableFrom": "alert_rules", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "alert_rules_host_id_ssh_data_id_fk": { + "name": "alert_rules_host_id_ssh_data_id_fk", + "tableFrom": "alert_rules", + "tableTo": "ssh_data", + "columnsFrom": [ + "host_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.api_keys": { + "name": "api_keys", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token_prefix": { + "name": "token_prefix", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "expires_at": { + "name": "expires_at", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_used_at": { + "name": "last_used_at", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + } + }, + "indexes": {}, + "foreignKeys": { + "api_keys_user_id_users_id_fk": { + "name": "api_keys_user_id_users_id_fk", + "tableFrom": "api_keys", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.audit_logs": { + "name": "audit_logs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource_type": { + "name": "resource_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource_id": { + "name": "resource_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "resource_name": { + "name": "resource_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "details": { + "name": "details", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "success": { + "name": "success", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "timestamp": { + "name": "timestamp", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": {}, + "foreignKeys": { + "audit_logs_user_id_users_id_fk": { + "name": "audit_logs_user_id_users_id_fk", + "tableFrom": "audit_logs", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.c2s_tunnel_presets": { + "name": "c2s_tunnel_presets", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "config": { + "name": "config", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "platform": { + "name": "platform", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "computer_name": { + "name": "computer_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": {}, + "foreignKeys": { + "c2s_tunnel_presets_user_id_users_id_fk": { + "name": "c2s_tunnel_presets_user_id_users_id_fk", + "tableFrom": "c2s_tunnel_presets", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.command_history": { + "name": "command_history", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "host_id": { + "name": "host_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "command": { + "name": "command", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "executed_at": { + "name": "executed_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": {}, + "foreignKeys": { + "command_history_user_id_users_id_fk": { + "name": "command_history_user_id_users_id_fk", + "tableFrom": "command_history", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "command_history_host_id_ssh_data_id_fk": { + "name": "command_history_host_id_ssh_data_id_fk", + "tableFrom": "command_history", + "tableTo": "ssh_data", + "columnsFrom": [ + "host_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.dashboard_service_links": { + "name": "dashboard_service_links", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "order": { + "name": "order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "sync_id": { + "name": "sync_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": {}, + "foreignKeys": { + "dashboard_service_links_user_id_users_id_fk": { + "name": "dashboard_service_links_user_id_users_id_fk", + "tableFrom": "dashboard_service_links", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "dashboard_service_links_sync_id_unique": { + "name": "dashboard_service_links_sync_id_unique", + "nullsNotDistinct": false, + "columns": [ + "sync_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.dismissed_alerts": { + "name": "dismissed_alerts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "alert_id": { + "name": "alert_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "dismissed_at": { + "name": "dismissed_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": {}, + "foreignKeys": { + "dismissed_alerts_user_id_users_id_fk": { + "name": "dismissed_alerts_user_id_users_id_fk", + "tableFrom": "dismissed_alerts", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.file_manager_pinned": { + "name": "file_manager_pinned", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "host_id": { + "name": "host_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "path": { + "name": "path", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "pinned_at": { + "name": "pinned_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": {}, + "foreignKeys": { + "file_manager_pinned_user_id_users_id_fk": { + "name": "file_manager_pinned_user_id_users_id_fk", + "tableFrom": "file_manager_pinned", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "file_manager_pinned_host_id_ssh_data_id_fk": { + "name": "file_manager_pinned_host_id_ssh_data_id_fk", + "tableFrom": "file_manager_pinned", + "tableTo": "ssh_data", + "columnsFrom": [ + "host_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.file_manager_recent": { + "name": "file_manager_recent", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "host_id": { + "name": "host_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "path": { + "name": "path", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "last_opened": { + "name": "last_opened", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": {}, + "foreignKeys": { + "file_manager_recent_user_id_users_id_fk": { + "name": "file_manager_recent_user_id_users_id_fk", + "tableFrom": "file_manager_recent", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "file_manager_recent_host_id_ssh_data_id_fk": { + "name": "file_manager_recent_host_id_ssh_data_id_fk", + "tableFrom": "file_manager_recent", + "tableTo": "ssh_data", + "columnsFrom": [ + "host_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.file_manager_shortcuts": { + "name": "file_manager_shortcuts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "host_id": { + "name": "host_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "path": { + "name": "path", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": {}, + "foreignKeys": { + "file_manager_shortcuts_user_id_users_id_fk": { + "name": "file_manager_shortcuts_user_id_users_id_fk", + "tableFrom": "file_manager_shortcuts", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "file_manager_shortcuts_host_id_ssh_data_id_fk": { + "name": "file_manager_shortcuts_host_id_ssh_data_id_fk", + "tableFrom": "file_manager_shortcuts", + "tableTo": "ssh_data", + "columnsFrom": [ + "host_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.homepage_items": { + "name": "homepage_items", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "type_id": { + "name": "type_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "config": { + "name": "config", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "folder_id": { + "name": "folder_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "sync_id": { + "name": "sync_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": {}, + "foreignKeys": { + "homepage_items_user_id_users_id_fk": { + "name": "homepage_items_user_id_users_id_fk", + "tableFrom": "homepage_items", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "homepage_items_sync_id_unique": { + "name": "homepage_items_sync_id_unique", + "nullsNotDistinct": false, + "columns": [ + "sync_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.homepage_layouts": { + "name": "homepage_layouts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "layout": { + "name": "layout", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": {}, + "foreignKeys": { + "homepage_layouts_user_id_users_id_fk": { + "name": "homepage_layouts_user_id_users_id_fk", + "tableFrom": "homepage_layouts", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "homepage_layouts_user_id_unique": { + "name": "homepage_layouts_user_id_unique", + "nullsNotDistinct": false, + "columns": [ + "user_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.host_access": { + "name": "host_access", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "host_id": { + "name": "host_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "role_id": { + "name": "role_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "granted_by": { + "name": "granted_by", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "permission_level": { + "name": "permission_level", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'connect'" + }, + "expires_at": { + "name": "expires_at", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "last_accessed_at": { + "name": "last_accessed_at", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_count": { + "name": "access_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + } + }, + "indexes": {}, + "foreignKeys": { + "host_access_host_id_ssh_data_id_fk": { + "name": "host_access_host_id_ssh_data_id_fk", + "tableFrom": "host_access", + "tableTo": "ssh_data", + "columnsFrom": [ + "host_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "host_access_user_id_users_id_fk": { + "name": "host_access_user_id_users_id_fk", + "tableFrom": "host_access", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "host_access_role_id_roles_id_fk": { + "name": "host_access_role_id_roles_id_fk", + "tableFrom": "host_access", + "tableTo": "roles", + "columnsFrom": [ + "role_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "host_access_granted_by_users_id_fk": { + "name": "host_access_granted_by_users_id_fk", + "tableFrom": "host_access", + "tableTo": "users", + "columnsFrom": [ + "granted_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.host_health_checks": { + "name": "host_health_checks", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "host_id": { + "name": "host_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "checks": { + "name": "checks", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "interval_seconds": { + "name": "interval_seconds", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 300 + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "idx_host_health_checks_user_host": { + "name": "idx_host_health_checks_user_host", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "host_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "host_health_checks_user_id_users_id_fk": { + "name": "host_health_checks_user_id_users_id_fk", + "tableFrom": "host_health_checks", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "host_health_checks_host_id_ssh_data_id_fk": { + "name": "host_health_checks_host_id_ssh_data_id_fk", + "tableFrom": "host_health_checks", + "tableTo": "ssh_data", + "columnsFrom": [ + "host_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.host_health_history": { + "name": "host_health_history", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "host_id": { + "name": "host_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "check_id": { + "name": "check_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "ts": { + "name": "ts", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "ok": { + "name": "ok", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "latency_ms": { + "name": "latency_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "detail": { + "name": "detail", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "host_health_history_user_id_users_id_fk": { + "name": "host_health_history_user_id_users_id_fk", + "tableFrom": "host_health_history", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "host_health_history_host_id_ssh_data_id_fk": { + "name": "host_health_history_host_id_ssh_data_id_fk", + "tableFrom": "host_health_history", + "tableTo": "ssh_data", + "columnsFrom": [ + "host_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.host_metrics_history": { + "name": "host_metrics_history", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "host_id": { + "name": "host_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "ts": { + "name": "ts", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "cpu_percent": { + "name": "cpu_percent", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "mem_percent": { + "name": "mem_percent", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "disk_percent": { + "name": "disk_percent", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "net_rx_bytes": { + "name": "net_rx_bytes", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "net_tx_bytes": { + "name": "net_tx_bytes", + "type": "integer", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "host_metrics_history_host_id_ssh_data_id_fk": { + "name": "host_metrics_history_host_id_ssh_data_id_fk", + "tableFrom": "host_metrics_history", + "tableTo": "ssh_data", + "columnsFrom": [ + "host_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.host_metrics_preferences": { + "name": "host_metrics_preferences", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "host_id": { + "name": "host_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "layout": { + "name": "layout", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "idx_host_metrics_prefs_user_host": { + "name": "idx_host_metrics_prefs_user_host", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "host_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "host_metrics_preferences_user_id_users_id_fk": { + "name": "host_metrics_preferences_user_id_users_id_fk", + "tableFrom": "host_metrics_preferences", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "host_metrics_preferences_host_id_ssh_data_id_fk": { + "name": "host_metrics_preferences_host_id_ssh_data_id_fk", + "tableFrom": "host_metrics_preferences", + "tableTo": "ssh_data", + "columnsFrom": [ + "host_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.ssh_data": { + "name": "ssh_data", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "connection_type": { + "name": "connection_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'ssh'" + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "ip": { + "name": "ip", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "port": { + "name": "port", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "folder": { + "name": "folder", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tags": { + "name": "tags", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "pin": { + "name": "pin", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "auth_type": { + "name": "auth_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "use_warpgate": { + "name": "use_warpgate", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "share_ssh_auth": { + "name": "share_ssh_auth", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "force_keyboard_interactive": { + "name": "force_keyboard_interactive", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "key_password": { + "name": "key_password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "key_type": { + "name": "key_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sudo_password": { + "name": "sudo_password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "autostart_password": { + "name": "autostart_password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "autostart_key": { + "name": "autostart_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "autostart_key_password": { + "name": "autostart_key_password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "credential_id": { + "name": "credential_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "override_credential_username": { + "name": "override_credential_username", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "vault_profile_id": { + "name": "vault_profile_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "enable_terminal": { + "name": "enable_terminal", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "enable_session_logging": { + "name": "enable_session_logging", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "allow_session_sharing": { + "name": "allow_session_sharing", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "enable_command_history": { + "name": "enable_command_history", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "enable_tunnel": { + "name": "enable_tunnel", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "tunnel_connections": { + "name": "tunnel_connections", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "jump_hosts": { + "name": "jump_hosts", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "enable_file_manager": { + "name": "enable_file_manager", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "scp_legacy": { + "name": "scp_legacy", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "enable_docker": { + "name": "enable_docker", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "enable_tmux_monitor": { + "name": "enable_tmux_monitor", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "show_terminal_in_sidebar": { + "name": "show_terminal_in_sidebar", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "show_file_manager_in_sidebar": { + "name": "show_file_manager_in_sidebar", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "show_tunnel_in_sidebar": { + "name": "show_tunnel_in_sidebar", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "show_docker_in_sidebar": { + "name": "show_docker_in_sidebar", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "show_server_stats_in_sidebar": { + "name": "show_server_stats_in_sidebar", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "default_path": { + "name": "default_path", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stats_config": { + "name": "stats_config", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "docker_config": { + "name": "docker_config", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "enable_proxmox": { + "name": "enable_proxmox", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "proxmox_config": { + "name": "proxmox_config", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "terminal_config": { + "name": "terminal_config", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "quick_actions": { + "name": "quick_actions", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "enable_ssh": { + "name": "enable_ssh", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "enable_rdp": { + "name": "enable_rdp", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "enable_vnc": { + "name": "enable_vnc", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "enable_telnet": { + "name": "enable_telnet", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "ssh_port": { + "name": "ssh_port", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 22 + }, + "rdp_port": { + "name": "rdp_port", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 3389 + }, + "vnc_port": { + "name": "vnc_port", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 5900 + }, + "telnet_port": { + "name": "telnet_port", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 23 + }, + "rdp_credential_id": { + "name": "rdp_credential_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "rdp_user": { + "name": "rdp_user", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "rdp_password": { + "name": "rdp_password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "rdp_domain": { + "name": "rdp_domain", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "rdp_security": { + "name": "rdp_security", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "rdp_ignore_cert": { + "name": "rdp_ignore_cert", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "vnc_credential_id": { + "name": "vnc_credential_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "vnc_password": { + "name": "vnc_password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "vnc_user": { + "name": "vnc_user", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "telnet_user": { + "name": "telnet_user", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "telnet_password": { + "name": "telnet_password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "telnet_credential_id": { + "name": "telnet_credential_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "rdp_auth_type": { + "name": "rdp_auth_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "vnc_auth_type": { + "name": "vnc_auth_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "telnet_auth_type": { + "name": "telnet_auth_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "domain": { + "name": "domain", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "security": { + "name": "security", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ignore_cert": { + "name": "ignore_cert", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "guacamole_config": { + "name": "guacamole_config", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "use_socks5": { + "name": "use_socks5", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "socks5_host": { + "name": "socks5_host", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "socks5_port": { + "name": "socks5_port", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "socks5_username": { + "name": "socks5_username", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "socks5_password": { + "name": "socks5_password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "socks5_proxy_chain": { + "name": "socks5_proxy_chain", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "connection_origin": { + "name": "connection_origin", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "mac_address": { + "name": "mac_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "wol_broadcast_address": { + "name": "wol_broadcast_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "port_knock_sequence": { + "name": "port_knock_sequence", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "host_key_fingerprint": { + "name": "host_key_fingerprint", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "host_key_type": { + "name": "host_key_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "host_key_algorithm": { + "name": "host_key_algorithm", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'sha256'" + }, + "host_key_first_seen": { + "name": "host_key_first_seen", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "host_key_last_verified": { + "name": "host_key_last_verified", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "host_key_changed_count": { + "name": "host_key_changed_count", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "sync_id": { + "name": "sync_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": {}, + "foreignKeys": { + "ssh_data_user_id_users_id_fk": { + "name": "ssh_data_user_id_users_id_fk", + "tableFrom": "ssh_data", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "ssh_data_credential_id_ssh_credentials_id_fk": { + "name": "ssh_data_credential_id_ssh_credentials_id_fk", + "tableFrom": "ssh_data", + "tableTo": "ssh_credentials", + "columnsFrom": [ + "credential_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "ssh_data_vault_profile_id_vault_profiles_id_fk": { + "name": "ssh_data_vault_profile_id_vault_profiles_id_fk", + "tableFrom": "ssh_data", + "tableTo": "vault_profiles", + "columnsFrom": [ + "vault_profile_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "ssh_data_rdp_credential_id_ssh_credentials_id_fk": { + "name": "ssh_data_rdp_credential_id_ssh_credentials_id_fk", + "tableFrom": "ssh_data", + "tableTo": "ssh_credentials", + "columnsFrom": [ + "rdp_credential_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "ssh_data_vnc_credential_id_ssh_credentials_id_fk": { + "name": "ssh_data_vnc_credential_id_ssh_credentials_id_fk", + "tableFrom": "ssh_data", + "tableTo": "ssh_credentials", + "columnsFrom": [ + "vnc_credential_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "ssh_data_telnet_credential_id_ssh_credentials_id_fk": { + "name": "ssh_data_telnet_credential_id_ssh_credentials_id_fk", + "tableFrom": "ssh_data", + "tableTo": "ssh_credentials", + "columnsFrom": [ + "telnet_credential_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "ssh_data_sync_id_unique": { + "name": "ssh_data_sync_id_unique", + "nullsNotDistinct": false, + "columns": [ + "sync_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.network_topology": { + "name": "network_topology", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "topology": { + "name": "topology", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": {}, + "foreignKeys": { + "network_topology_user_id_users_id_fk": { + "name": "network_topology_user_id_users_id_fk", + "tableFrom": "network_topology", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.notification_channels": { + "name": "notification_channels", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "config": { + "name": "config", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": {}, + "foreignKeys": { + "notification_channels_user_id_users_id_fk": { + "name": "notification_channels_user_id_users_id_fk", + "tableFrom": "notification_channels", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.opkssh_tokens": { + "name": "opkssh_tokens", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "host_id": { + "name": "host_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "ssh_cert": { + "name": "ssh_cert", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "private_key": { + "name": "private_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sub": { + "name": "sub", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "issuer": { + "name": "issuer", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "audience": { + "name": "audience", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "expires_at": { + "name": "expires_at", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "last_used": { + "name": "last_used", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_opkssh_tokens_user_host": { + "name": "idx_opkssh_tokens_user_host", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "host_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "opkssh_tokens_user_id_users_id_fk": { + "name": "opkssh_tokens_user_id_users_id_fk", + "tableFrom": "opkssh_tokens", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "opkssh_tokens_host_id_ssh_data_id_fk": { + "name": "opkssh_tokens_host_id_ssh_data_id_fk", + "tableFrom": "opkssh_tokens", + "tableTo": "ssh_data", + "columnsFrom": [ + "host_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.recent_activity": { + "name": "recent_activity", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "host_id": { + "name": "host_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "host_name": { + "name": "host_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "timestamp": { + "name": "timestamp", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": {}, + "foreignKeys": { + "recent_activity_user_id_users_id_fk": { + "name": "recent_activity_user_id_users_id_fk", + "tableFrom": "recent_activity", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "recent_activity_host_id_ssh_data_id_fk": { + "name": "recent_activity_host_id_ssh_data_id_fk", + "tableFrom": "recent_activity", + "tableTo": "ssh_data", + "columnsFrom": [ + "host_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.roles": { + "name": "roles", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_system": { + "name": "is_system", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "permissions": { + "name": "permissions", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "roles_name_unique": { + "name": "roles_name_unique", + "nullsNotDistinct": false, + "columns": [ + "name" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.session_recordings": { + "name": "session_recordings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "host_id": { + "name": "host_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_id": { + "name": "access_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "started_at": { + "name": "started_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "ended_at": { + "name": "ended_at", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "duration": { + "name": "duration", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "commands": { + "name": "commands", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "dangerous_actions": { + "name": "dangerous_actions", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "recording_path": { + "name": "recording_path", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "protocol": { + "name": "protocol", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "default": "'ssh'" + }, + "format": { + "name": "format", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'text'" + }, + "terminated_by_owner": { + "name": "terminated_by_owner", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "termination_reason": { + "name": "termination_reason", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "session_recordings_host_id_ssh_data_id_fk": { + "name": "session_recordings_host_id_ssh_data_id_fk", + "tableFrom": "session_recordings", + "tableTo": "ssh_data", + "columnsFrom": [ + "host_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "session_recordings_user_id_users_id_fk": { + "name": "session_recordings_user_id_users_id_fk", + "tableFrom": "session_recordings", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "session_recordings_access_id_host_access_id_fk": { + "name": "session_recordings_access_id_host_access_id_fk", + "tableFrom": "session_recordings", + "tableTo": "host_access", + "columnsFrom": [ + "access_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.session_share_participants": { + "name": "session_share_participants", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "share_id": { + "name": "share_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "guest_label": { + "name": "guest_label", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "joined_at": { + "name": "joined_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "left_at": { + "name": "left_at", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "session_share_participants_share_id_session_shares_id_fk": { + "name": "session_share_participants_share_id_session_shares_id_fk", + "tableFrom": "session_share_participants", + "tableTo": "session_shares", + "columnsFrom": [ + "share_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "session_share_participants_user_id_users_id_fk": { + "name": "session_share_participants_user_id_users_id_fk", + "tableFrom": "session_share_participants", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.session_shares": { + "name": "session_shares", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "host_id": { + "name": "host_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "protocol": { + "name": "protocol", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tab_instance_id": { + "name": "tab_instance_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "share_type": { + "name": "share_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_user_id": { + "name": "target_user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "link_token": { + "name": "link_token", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "permission_level": { + "name": "permission_level", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'read-only'" + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "expires_at": { + "name": "expires_at", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "revoked_at": { + "name": "revoked_at", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_joined_at": { + "name": "last_joined_at", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "join_count": { + "name": "join_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + } + }, + "indexes": {}, + "foreignKeys": { + "session_shares_host_id_ssh_data_id_fk": { + "name": "session_shares_host_id_ssh_data_id_fk", + "tableFrom": "session_shares", + "tableTo": "ssh_data", + "columnsFrom": [ + "host_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "session_shares_owner_user_id_users_id_fk": { + "name": "session_shares_owner_user_id_users_id_fk", + "tableFrom": "session_shares", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "session_shares_target_user_id_users_id_fk": { + "name": "session_shares_target_user_id_users_id_fk", + "tableFrom": "session_shares", + "tableTo": "users", + "columnsFrom": [ + "target_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "session_shares_link_token_unique": { + "name": "session_shares_link_token_unique", + "nullsNotDistinct": false, + "columns": [ + "link_token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sessions": { + "name": "sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "jwt_token": { + "name": "jwt_token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "device_type": { + "name": "device_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "device_info": { + "name": "device_info", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "oidc_sub": { + "name": "oidc_sub", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "oidc_sid": { + "name": "oidc_sid", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sso_provider_id": { + "name": "sso_provider_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "expires_at": { + "name": "expires_at", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "last_active_at": { + "name": "last_active_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": {}, + "foreignKeys": { + "sessions_user_id_users_id_fk": { + "name": "sessions_user_id_users_id_fk", + "tableFrom": "sessions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.settings": { + "name": "settings", + "schema": "", + "columns": { + "key": { + "name": "key", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.shared_host_auth_overrides": { + "name": "shared_host_auth_overrides", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "host_id": { + "name": "host_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "protocol": { + "name": "protocol", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "default": "'ssh'" + }, + "credential_id": { + "name": "credential_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "shared_host_auth_overrides_host_user_protocol_unique": { + "name": "shared_host_auth_overrides_host_user_protocol_unique", + "columns": [ + { + "expression": "host_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "protocol", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "shared_host_auth_overrides_host_id_ssh_data_id_fk": { + "name": "shared_host_auth_overrides_host_id_ssh_data_id_fk", + "tableFrom": "shared_host_auth_overrides", + "tableTo": "ssh_data", + "columnsFrom": [ + "host_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "shared_host_auth_overrides_user_id_users_id_fk": { + "name": "shared_host_auth_overrides_user_id_users_id_fk", + "tableFrom": "shared_host_auth_overrides", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "shared_host_auth_overrides_credential_id_ssh_credentials_id_fk": { + "name": "shared_host_auth_overrides_credential_id_ssh_credentials_id_fk", + "tableFrom": "shared_host_auth_overrides", + "tableTo": "ssh_credentials", + "columnsFrom": [ + "credential_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.shared_host_secrets": { + "name": "shared_host_secrets", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "host_access_id": { + "name": "host_access_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "target_user_id": { + "name": "target_user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "protocol": { + "name": "protocol", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "default": "'ssh'" + }, + "source_type": { + "name": "source_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'credential'" + }, + "original_credential_id": { + "name": "original_credential_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "encrypted_username": { + "name": "encrypted_username", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "encrypted_auth_type": { + "name": "encrypted_auth_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "encrypted_password": { + "name": "encrypted_password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "encrypted_key": { + "name": "encrypted_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "encrypted_key_password": { + "name": "encrypted_key_password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "encrypted_key_type": { + "name": "encrypted_key_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "encrypted_domain": { + "name": "encrypted_domain", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "idx_shared_host_secrets_scope": { + "name": "idx_shared_host_secrets_scope", + "columns": [ + { + "expression": "host_access_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "protocol", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "shared_host_secrets_host_access_id_host_access_id_fk": { + "name": "shared_host_secrets_host_access_id_host_access_id_fk", + "tableFrom": "shared_host_secrets", + "tableTo": "host_access", + "columnsFrom": [ + "host_access_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "shared_host_secrets_target_user_id_users_id_fk": { + "name": "shared_host_secrets_target_user_id_users_id_fk", + "tableFrom": "shared_host_secrets", + "tableTo": "users", + "columnsFrom": [ + "target_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "shared_host_secrets_original_credential_id_ssh_credentials_id_fk": { + "name": "shared_host_secrets_original_credential_id_ssh_credentials_id_fk", + "tableFrom": "shared_host_secrets", + "tableTo": "ssh_credentials", + "columnsFrom": [ + "original_credential_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.snippet_access": { + "name": "snippet_access", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "snippet_id": { + "name": "snippet_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "role_id": { + "name": "role_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "granted_by": { + "name": "granted_by", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "permission_level": { + "name": "permission_level", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'view'" + }, + "expires_at": { + "name": "expires_at", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": {}, + "foreignKeys": { + "snippet_access_snippet_id_snippets_id_fk": { + "name": "snippet_access_snippet_id_snippets_id_fk", + "tableFrom": "snippet_access", + "tableTo": "snippets", + "columnsFrom": [ + "snippet_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "snippet_access_user_id_users_id_fk": { + "name": "snippet_access_user_id_users_id_fk", + "tableFrom": "snippet_access", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "snippet_access_role_id_roles_id_fk": { + "name": "snippet_access_role_id_roles_id_fk", + "tableFrom": "snippet_access", + "tableTo": "roles", + "columnsFrom": [ + "role_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "snippet_access_granted_by_users_id_fk": { + "name": "snippet_access_granted_by_users_id_fk", + "tableFrom": "snippet_access", + "tableTo": "users", + "columnsFrom": [ + "granted_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.snippet_folders": { + "name": "snippet_folders", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "icon": { + "name": "icon", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sync_id": { + "name": "sync_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": {}, + "foreignKeys": { + "snippet_folders_user_id_users_id_fk": { + "name": "snippet_folders_user_id_users_id_fk", + "tableFrom": "snippet_folders", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "snippet_folders_sync_id_unique": { + "name": "snippet_folders_sync_id_unique", + "nullsNotDistinct": false, + "columns": [ + "sync_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.snippets": { + "name": "snippets", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "folder": { + "name": "folder", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "order": { + "name": "order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "sync_id": { + "name": "sync_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "host_filter": { + "name": "host_filter", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "snippets_user_id_users_id_fk": { + "name": "snippets_user_id_users_id_fk", + "tableFrom": "snippets", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "snippets_sync_id_unique": { + "name": "snippets_sync_id_unique", + "nullsNotDistinct": false, + "columns": [ + "sync_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.ssh_credential_usage": { + "name": "ssh_credential_usage", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "credential_id": { + "name": "credential_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "host_id": { + "name": "host_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "used_at": { + "name": "used_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": {}, + "foreignKeys": { + "ssh_credential_usage_credential_id_ssh_credentials_id_fk": { + "name": "ssh_credential_usage_credential_id_ssh_credentials_id_fk", + "tableFrom": "ssh_credential_usage", + "tableTo": "ssh_credentials", + "columnsFrom": [ + "credential_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "ssh_credential_usage_host_id_ssh_data_id_fk": { + "name": "ssh_credential_usage_host_id_ssh_data_id_fk", + "tableFrom": "ssh_credential_usage", + "tableTo": "ssh_data", + "columnsFrom": [ + "host_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "ssh_credential_usage_user_id_users_id_fk": { + "name": "ssh_credential_usage_user_id_users_id_fk", + "tableFrom": "ssh_credential_usage", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.ssh_credentials": { + "name": "ssh_credentials", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "folder": { + "name": "folder", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tags": { + "name": "tags", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "auth_type": { + "name": "auth_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "private_key": { + "name": "private_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "public_key": { + "name": "public_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "key_password": { + "name": "key_password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "key_type": { + "name": "key_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "detected_key_type": { + "name": "detected_key_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cert_public_key": { + "name": "cert_public_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "usage_count": { + "name": "usage_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "last_used": { + "name": "last_used", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sync_id": { + "name": "sync_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": {}, + "foreignKeys": { + "ssh_credentials_user_id_users_id_fk": { + "name": "ssh_credentials_user_id_users_id_fk", + "tableFrom": "ssh_credentials", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "ssh_credentials_sync_id_unique": { + "name": "ssh_credentials_sync_id_unique", + "nullsNotDistinct": false, + "columns": [ + "sync_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.ssh_folders": { + "name": "ssh_folders", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "icon": { + "name": "icon", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "credential_id": { + "name": "credential_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "sync_id": { + "name": "sync_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": {}, + "foreignKeys": { + "ssh_folders_user_id_users_id_fk": { + "name": "ssh_folders_user_id_users_id_fk", + "tableFrom": "ssh_folders", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "ssh_folders_credential_id_ssh_credentials_id_fk": { + "name": "ssh_folders_credential_id_ssh_credentials_id_fk", + "tableFrom": "ssh_folders", + "tableTo": "ssh_credentials", + "columnsFrom": [ + "credential_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "ssh_folders_sync_id_unique": { + "name": "ssh_folders_sync_id_unique", + "nullsNotDistinct": false, + "columns": [ + "sync_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sso_providers": { + "name": "sso_providers", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "display_order": { + "name": "display_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "config": { + "name": "config", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sync_tombstones": { + "name": "sync_tombstones", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "entity_type": { + "name": "entity_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "sync_id": { + "name": "sync_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "deleted_at": { + "name": "deleted_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": {}, + "foreignKeys": { + "sync_tombstones_user_id_users_id_fk": { + "name": "sync_tombstones_user_id_users_id_fk", + "tableFrom": "sync_tombstones", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.termix_identities": { + "name": "termix_identities", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "handle": { + "name": "handle", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": {}, + "foreignKeys": { + "termix_identities_user_id_users_id_fk": { + "name": "termix_identities_user_id_users_id_fk", + "tableFrom": "termix_identities", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "termix_identities_user_id_unique": { + "name": "termix_identities_user_id_unique", + "nullsNotDistinct": false, + "columns": [ + "user_id" + ] + }, + "termix_identities_handle_unique": { + "name": "termix_identities_handle_unique", + "nullsNotDistinct": false, + "columns": [ + "handle" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.termix_identity_ca": { + "name": "termix_identity_ca", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "identity_id": { + "name": "identity_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "public_key": { + "name": "public_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "private_key": { + "name": "private_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "validity_days": { + "name": "validity_days", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 90 + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": {}, + "foreignKeys": { + "termix_identity_ca_identity_id_termix_identities_id_fk": { + "name": "termix_identity_ca_identity_id_termix_identities_id_fk", + "tableFrom": "termix_identity_ca", + "tableTo": "termix_identities", + "columnsFrom": [ + "identity_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "termix_identity_ca_user_id_users_id_fk": { + "name": "termix_identity_ca_user_id_users_id_fk", + "tableFrom": "termix_identity_ca", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "termix_identity_ca_identity_id_unique": { + "name": "termix_identity_ca_identity_id_unique", + "nullsNotDistinct": false, + "columns": [ + "identity_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.termix_identity_keys": { + "name": "termix_identity_keys", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "identity_id": { + "name": "identity_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "public_key": { + "name": "public_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key_type": { + "name": "key_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "algorithm": { + "name": "algorithm", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "comment": { + "name": "comment", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'manual'" + }, + "credential_id": { + "name": "credential_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": {}, + "foreignKeys": { + "termix_identity_keys_identity_id_termix_identities_id_fk": { + "name": "termix_identity_keys_identity_id_termix_identities_id_fk", + "tableFrom": "termix_identity_keys", + "tableTo": "termix_identities", + "columnsFrom": [ + "identity_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "termix_identity_keys_user_id_users_id_fk": { + "name": "termix_identity_keys_user_id_users_id_fk", + "tableFrom": "termix_identity_keys", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "termix_identity_keys_credential_id_ssh_credentials_id_fk": { + "name": "termix_identity_keys_credential_id_ssh_credentials_id_fk", + "tableFrom": "termix_identity_keys", + "tableTo": "ssh_credentials", + "columnsFrom": [ + "credential_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.tmux_session_tags": { + "name": "tmux_session_tags", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "host_id": { + "name": "host_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "session_name": { + "name": "session_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tag": { + "name": "tag", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": {}, + "foreignKeys": { + "tmux_session_tags_user_id_users_id_fk": { + "name": "tmux_session_tags_user_id_users_id_fk", + "tableFrom": "tmux_session_tags", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "tmux_session_tags_host_id_ssh_data_id_fk": { + "name": "tmux_session_tags_host_id_ssh_data_id_fk", + "tableFrom": "tmux_session_tags", + "tableTo": "ssh_data", + "columnsFrom": [ + "host_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.transfer_recent": { + "name": "transfer_recent", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "source_host_id": { + "name": "source_host_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "dest_host_id": { + "name": "dest_host_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "dest_path": { + "name": "dest_path", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "dest_path_label": { + "name": "dest_path_label", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "last_used": { + "name": "last_used", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": {}, + "foreignKeys": { + "transfer_recent_user_id_users_id_fk": { + "name": "transfer_recent_user_id_users_id_fk", + "tableFrom": "transfer_recent", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "transfer_recent_source_host_id_ssh_data_id_fk": { + "name": "transfer_recent_source_host_id_ssh_data_id_fk", + "tableFrom": "transfer_recent", + "tableTo": "ssh_data", + "columnsFrom": [ + "source_host_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "transfer_recent_dest_host_id_ssh_data_id_fk": { + "name": "transfer_recent_dest_host_id_ssh_data_id_fk", + "tableFrom": "transfer_recent", + "tableTo": "ssh_data", + "columnsFrom": [ + "dest_host_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.trusted_devices": { + "name": "trusted_devices", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "device_fingerprint": { + "name": "device_fingerprint", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "device_type": { + "name": "device_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "device_info": { + "name": "device_info", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "expires_at": { + "name": "expires_at", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "last_used_at": { + "name": "last_used_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": {}, + "foreignKeys": { + "trusted_devices_user_id_users_id_fk": { + "name": "trusted_devices_user_id_users_id_fk", + "tableFrom": "trusted_devices", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_open_tabs": { + "name": "user_open_tabs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "tab_type": { + "name": "tab_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "host_id": { + "name": "host_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tab_order": { + "name": "tab_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "backend_session_id": { + "name": "backend_session_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": {}, + "foreignKeys": { + "user_open_tabs_user_id_users_id_fk": { + "name": "user_open_tabs_user_id_users_id_fk", + "tableFrom": "user_open_tabs", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_open_tabs_host_id_ssh_data_id_fk": { + "name": "user_open_tabs_host_id_ssh_data_id_fk", + "tableFrom": "user_open_tabs", + "tableTo": "ssh_data", + "columnsFrom": [ + "host_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_preferences": { + "name": "user_preferences", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "reopen_tabs_on_login": { + "name": "reopen_tabs_on_login", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "theme": { + "name": "theme", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "font_size": { + "name": "font_size", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "accent_color": { + "name": "accent_color", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "language": { + "name": "language", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "storage_mode": { + "name": "storage_mode", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "command_autocomplete": { + "name": "command_autocomplete", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "command_palette_enabled": { + "name": "command_palette_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "show_host_tags": { + "name": "show_host_tags", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "host_tray_on_click": { + "name": "host_tray_on_click", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "pin_app_rail": { + "name": "pin_app_rail", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "expand_app_rail_on_hover": { + "name": "expand_app_rail_on_hover", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "folders_collapsed": { + "name": "folders_collapsed", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "confirm_snippet_execution": { + "name": "confirm_snippet_execution", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "disable_update_check": { + "name": "disable_update_check", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "confirm_tab_close": { + "name": "confirm_tab_close", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "hidden_rail_tabs": { + "name": "hidden_rail_tabs", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "compact_host_view": { + "name": "compact_host_view", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "status_color_scheme": { + "name": "status_color_scheme", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "custom_themes": { + "name": "custom_themes", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "custom_keybindings": { + "name": "custom_keybindings", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": {}, + "foreignKeys": { + "user_preferences_user_id_users_id_fk": { + "name": "user_preferences_user_id_users_id_fk", + "tableFrom": "user_preferences", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_roles": { + "name": "user_roles", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "role_id": { + "name": "role_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "granted_by": { + "name": "granted_by", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "granted_at": { + "name": "granted_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "idx_user_roles_user_role": { + "name": "idx_user_roles_user_role", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "role_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_roles_user_id_users_id_fk": { + "name": "user_roles_user_id_users_id_fk", + "tableFrom": "user_roles", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_roles_role_id_roles_id_fk": { + "name": "user_roles_role_id_roles_id_fk", + "tableFrom": "user_roles", + "tableTo": "roles", + "columnsFrom": [ + "role_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_roles_granted_by_users_id_fk": { + "name": "user_roles_granted_by_users_id_fk", + "tableFrom": "user_roles", + "tableTo": "users", + "columnsFrom": [ + "granted_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "password_hash": { + "name": "password_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "is_admin": { + "name": "is_admin", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "is_oidc": { + "name": "is_oidc", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "oidc_identifier": { + "name": "oidc_identifier", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sso_provider_id": { + "name": "sso_provider_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "client_secret": { + "name": "client_secret", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "issuer_url": { + "name": "issuer_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "authorization_url": { + "name": "authorization_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "token_url": { + "name": "token_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "identifier_path": { + "name": "identifier_path", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name_path": { + "name": "name_path", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "scopes": { + "name": "scopes", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'openid email profile'" + }, + "totp_secret": { + "name": "totp_secret", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "totp_enabled": { + "name": "totp_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "totp_backup_codes": { + "name": "totp_backup_codes", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "registered_at": { + "name": "registered_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "donation_modal_dismissed": { + "name": "donation_modal_dismissed", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.vault_profiles": { + "name": "vault_profiles", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "folder": { + "name": "folder", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tags": { + "name": "tags", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "vault_addr": { + "name": "vault_addr", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "vault_namespace": { + "name": "vault_namespace", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "oidc_mount": { + "name": "oidc_mount", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "oidc_role": { + "name": "oidc_role", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ssh_mount": { + "name": "ssh_mount", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ssh_role": { + "name": "ssh_role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "valid_principals": { + "name": "valid_principals", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "key_type": { + "name": "key_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "shared": { + "name": "shared", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "sync_id": { + "name": "sync_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": {}, + "foreignKeys": { + "vault_profiles_user_id_users_id_fk": { + "name": "vault_profiles_user_id_users_id_fk", + "tableFrom": "vault_profiles", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "vault_profiles_sync_id_unique": { + "name": "vault_profiles_sync_id_unique", + "nullsNotDistinct": false, + "columns": [ + "sync_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.vault_tokens": { + "name": "vault_tokens", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "profile_id": { + "name": "profile_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "ssh_cert": { + "name": "ssh_cert", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "private_key": { + "name": "private_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "expires_at": { + "name": "expires_at", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "last_used": { + "name": "last_used", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_vault_tokens_user_profile": { + "name": "idx_vault_tokens_user_profile", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "profile_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "vault_tokens_user_id_users_id_fk": { + "name": "vault_tokens_user_id_users_id_fk", + "tableFrom": "vault_tokens", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "vault_tokens_profile_id_vault_profiles_id_fk": { + "name": "vault_tokens_profile_id_vault_profiles_id_fk", + "tableFrom": "vault_tokens", + "tableTo": "vault_profiles", + "columnsFrom": [ + "profile_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.webauthn_credentials": { + "name": "webauthn_credentials", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "credential_id": { + "name": "credential_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "public_key": { + "name": "public_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "counter": { + "name": "counter", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "device_type": { + "name": "device_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "backed_up": { + "name": "backed_up", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "transports": { + "name": "transports", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_verification": { + "name": "user_verification", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'preferred'" + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "last_used_at": { + "name": "last_used_at", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "webauthn_credentials_user_id_users_id_fk": { + "name": "webauthn_credentials_user_id_users_id_fk", + "tableFrom": "webauthn_credentials", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/drizzle/postgres/meta/_journal.json b/drizzle/postgres/meta/_journal.json new file mode 100644 index 00000000..cebde34b --- /dev/null +++ b/drizzle/postgres/meta/_journal.json @@ -0,0 +1,13 @@ +{ + "version": "7", + "dialect": "postgresql", + "entries": [ + { + "idx": 0, + "version": "7", + "when": 1785738871078, + "tag": "0000_jazzy_infant_terrible", + "breakpoints": true + } + ] +} \ No newline at end of file diff --git a/drizzle/sqlite/0000_clever_hercules.sql b/drizzle/sqlite/0000_clever_hercules.sql new file mode 100644 index 00000000..ab475b07 --- /dev/null +++ b/drizzle/sqlite/0000_clever_hercules.sql @@ -0,0 +1,836 @@ +CREATE TABLE `alert_firings` ( + `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL, + `user_id` text NOT NULL, + `rule_id` integer NOT NULL, + `host_id` integer NOT NULL, + `host_name` text NOT NULL, + `fired_at` text DEFAULT CURRENT_TIMESTAMP NOT NULL, + `resolved_at` text, + `value` real, + `message` text NOT NULL, + `severity` text DEFAULT 'warning' NOT NULL, + `acknowledged` integer DEFAULT false NOT NULL, + FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE cascade, + FOREIGN KEY (`rule_id`) REFERENCES `alert_rules`(`id`) ON UPDATE no action ON DELETE cascade +); +--> statement-breakpoint +CREATE TABLE `alert_rule_channels` ( + `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL, + `rule_id` integer NOT NULL, + `channel_id` integer NOT NULL, + FOREIGN KEY (`rule_id`) REFERENCES `alert_rules`(`id`) ON UPDATE no action ON DELETE cascade, + FOREIGN KEY (`channel_id`) REFERENCES `notification_channels`(`id`) ON UPDATE no action ON DELETE cascade +); +--> statement-breakpoint +CREATE TABLE `alert_rules` ( + `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL, + `user_id` text NOT NULL, + `host_id` integer, + `name` text NOT NULL, + `enabled` integer DEFAULT true NOT NULL, + `trigger_type` text NOT NULL, + `threshold_value` real, + `threshold_duration_seconds` integer, + `cooldown_minutes` integer DEFAULT 15 NOT NULL, + `created_at` text DEFAULT CURRENT_TIMESTAMP NOT NULL, + `updated_at` text DEFAULT CURRENT_TIMESTAMP NOT NULL, + FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE cascade, + FOREIGN KEY (`host_id`) REFERENCES `ssh_data`(`id`) ON UPDATE no action ON DELETE cascade +); +--> statement-breakpoint +CREATE TABLE `api_keys` ( + `id` text PRIMARY KEY NOT NULL, + `user_id` text NOT NULL, + `name` text NOT NULL, + `token_hash` text NOT NULL, + `token_prefix` text NOT NULL, + `created_at` text DEFAULT CURRENT_TIMESTAMP NOT NULL, + `expires_at` text, + `last_used_at` text, + `is_active` integer DEFAULT true NOT NULL, + FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE cascade +); +--> statement-breakpoint +CREATE TABLE `audit_logs` ( + `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL, + `user_id` text, + `username` text NOT NULL, + `action` text NOT NULL, + `resource_type` text NOT NULL, + `resource_id` text, + `resource_name` text, + `details` text, + `ip_address` text, + `user_agent` text, + `success` integer NOT NULL, + `error_message` text, + `timestamp` text DEFAULT CURRENT_TIMESTAMP NOT NULL, + FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE set null +); +--> statement-breakpoint +CREATE TABLE `c2s_tunnel_presets` ( + `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL, + `user_id` text NOT NULL, + `name` text NOT NULL, + `config` text NOT NULL, + `platform` text, + `computer_name` text, + `created_at` text DEFAULT CURRENT_TIMESTAMP NOT NULL, + `updated_at` text DEFAULT CURRENT_TIMESTAMP NOT NULL, + FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE cascade +); +--> statement-breakpoint +CREATE TABLE `command_history` ( + `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL, + `user_id` text NOT NULL, + `host_id` integer NOT NULL, + `command` text NOT NULL, + `executed_at` text DEFAULT CURRENT_TIMESTAMP NOT NULL, + FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE cascade, + FOREIGN KEY (`host_id`) REFERENCES `ssh_data`(`id`) ON UPDATE no action ON DELETE cascade +); +--> statement-breakpoint +CREATE TABLE `dashboard_service_links` ( + `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL, + `user_id` text NOT NULL, + `label` text NOT NULL, + `url` text NOT NULL, + `order` integer DEFAULT 0 NOT NULL, + `sync_id` text, + `created_at` text DEFAULT CURRENT_TIMESTAMP NOT NULL, + `updated_at` text DEFAULT CURRENT_TIMESTAMP NOT NULL, + FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE cascade +); +--> statement-breakpoint +CREATE UNIQUE INDEX `dashboard_service_links_sync_id_unique` ON `dashboard_service_links` (`sync_id`);--> statement-breakpoint +CREATE TABLE `dismissed_alerts` ( + `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL, + `user_id` text NOT NULL, + `alert_id` text NOT NULL, + `dismissed_at` text DEFAULT CURRENT_TIMESTAMP NOT NULL, + FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE cascade +); +--> statement-breakpoint +CREATE TABLE `file_manager_pinned` ( + `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL, + `user_id` text NOT NULL, + `host_id` integer NOT NULL, + `name` text NOT NULL, + `path` text NOT NULL, + `pinned_at` text DEFAULT CURRENT_TIMESTAMP NOT NULL, + FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE cascade, + FOREIGN KEY (`host_id`) REFERENCES `ssh_data`(`id`) ON UPDATE no action ON DELETE cascade +); +--> statement-breakpoint +CREATE TABLE `file_manager_recent` ( + `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL, + `user_id` text NOT NULL, + `host_id` integer NOT NULL, + `name` text NOT NULL, + `path` text NOT NULL, + `last_opened` text DEFAULT CURRENT_TIMESTAMP NOT NULL, + FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE cascade, + FOREIGN KEY (`host_id`) REFERENCES `ssh_data`(`id`) ON UPDATE no action ON DELETE cascade +); +--> statement-breakpoint +CREATE TABLE `file_manager_shortcuts` ( + `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL, + `user_id` text NOT NULL, + `host_id` integer NOT NULL, + `name` text NOT NULL, + `path` text NOT NULL, + `created_at` text DEFAULT CURRENT_TIMESTAMP NOT NULL, + FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE cascade, + FOREIGN KEY (`host_id`) REFERENCES `ssh_data`(`id`) ON UPDATE no action ON DELETE cascade +); +--> statement-breakpoint +CREATE TABLE `homepage_items` ( + `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL, + `user_id` text NOT NULL, + `type_id` text NOT NULL, + `title` text, + `config` text DEFAULT '{}' NOT NULL, + `folder_id` integer, + `sync_id` text, + `created_at` text DEFAULT CURRENT_TIMESTAMP NOT NULL, + `updated_at` text DEFAULT CURRENT_TIMESTAMP NOT NULL, + FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE cascade +); +--> statement-breakpoint +CREATE UNIQUE INDEX `homepage_items_sync_id_unique` ON `homepage_items` (`sync_id`);--> statement-breakpoint +CREATE TABLE `homepage_layouts` ( + `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL, + `user_id` text NOT NULL, + `layout` text DEFAULT '{}' NOT NULL, + `updated_at` text DEFAULT CURRENT_TIMESTAMP NOT NULL, + FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE cascade +); +--> statement-breakpoint +CREATE UNIQUE INDEX `homepage_layouts_user_id_unique` ON `homepage_layouts` (`user_id`);--> statement-breakpoint +CREATE TABLE `host_access` ( + `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL, + `host_id` integer NOT NULL, + `user_id` text, + `role_id` integer, + `granted_by` text NOT NULL, + `permission_level` text DEFAULT 'connect' NOT NULL, + `expires_at` text, + `created_at` text DEFAULT CURRENT_TIMESTAMP NOT NULL, + `last_accessed_at` text, + `access_count` integer DEFAULT 0 NOT NULL, + FOREIGN KEY (`host_id`) REFERENCES `ssh_data`(`id`) ON UPDATE no action ON DELETE cascade, + FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE cascade, + FOREIGN KEY (`role_id`) REFERENCES `roles`(`id`) ON UPDATE no action ON DELETE cascade, + FOREIGN KEY (`granted_by`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE cascade +); +--> statement-breakpoint +CREATE TABLE `host_health_checks` ( + `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL, + `user_id` text NOT NULL, + `host_id` integer NOT NULL, + `checks` text NOT NULL, + `interval_seconds` integer DEFAULT 300 NOT NULL, + `created_at` text DEFAULT CURRENT_TIMESTAMP NOT NULL, + `updated_at` text DEFAULT CURRENT_TIMESTAMP NOT NULL, + FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE cascade, + FOREIGN KEY (`host_id`) REFERENCES `ssh_data`(`id`) ON UPDATE no action ON DELETE cascade +); +--> statement-breakpoint +CREATE UNIQUE INDEX `idx_host_health_checks_user_host` ON `host_health_checks` (`user_id`,`host_id`);--> statement-breakpoint +CREATE TABLE `host_health_history` ( + `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL, + `user_id` text NOT NULL, + `host_id` integer NOT NULL, + `check_id` text NOT NULL, + `ts` text DEFAULT CURRENT_TIMESTAMP NOT NULL, + `ok` integer NOT NULL, + `latency_ms` integer, + `detail` text, + FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE cascade, + FOREIGN KEY (`host_id`) REFERENCES `ssh_data`(`id`) ON UPDATE no action ON DELETE cascade +); +--> statement-breakpoint +CREATE TABLE `host_metrics_history` ( + `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL, + `host_id` integer NOT NULL, + `ts` text DEFAULT CURRENT_TIMESTAMP NOT NULL, + `cpu_percent` real, + `mem_percent` real, + `disk_percent` real, + `net_rx_bytes` integer, + `net_tx_bytes` integer, + FOREIGN KEY (`host_id`) REFERENCES `ssh_data`(`id`) ON UPDATE no action ON DELETE cascade +); +--> statement-breakpoint +CREATE TABLE `host_metrics_preferences` ( + `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL, + `user_id` text NOT NULL, + `host_id` integer NOT NULL, + `layout` text NOT NULL, + `created_at` text DEFAULT CURRENT_TIMESTAMP NOT NULL, + `updated_at` text DEFAULT CURRENT_TIMESTAMP NOT NULL, + FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE cascade, + FOREIGN KEY (`host_id`) REFERENCES `ssh_data`(`id`) ON UPDATE no action ON DELETE cascade +); +--> statement-breakpoint +CREATE UNIQUE INDEX `idx_host_metrics_prefs_user_host` ON `host_metrics_preferences` (`user_id`,`host_id`);--> statement-breakpoint +CREATE TABLE `ssh_data` ( + `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL, + `user_id` text NOT NULL, + `connection_type` text DEFAULT 'ssh' NOT NULL, + `name` text, + `ip` text NOT NULL, + `port` integer NOT NULL, + `username` text NOT NULL, + `folder` text, + `tags` text, + `pin` integer DEFAULT false NOT NULL, + `auth_type` text NOT NULL, + `use_warpgate` integer DEFAULT false NOT NULL, + `share_ssh_auth` integer DEFAULT false NOT NULL, + `force_keyboard_interactive` text, + `password` text, + `key` text(8192), + `key_password` text, + `key_type` text, + `sudo_password` text, + `autostart_password` text, + `autostart_key` text(8192), + `autostart_key_password` text, + `credential_id` integer, + `override_credential_username` integer, + `vault_profile_id` integer, + `enable_terminal` integer DEFAULT true NOT NULL, + `enable_session_logging` integer DEFAULT true NOT NULL, + `allow_session_sharing` integer DEFAULT true NOT NULL, + `enable_command_history` integer DEFAULT true NOT NULL, + `enable_tunnel` integer DEFAULT true NOT NULL, + `tunnel_connections` text, + `jump_hosts` text, + `enable_file_manager` integer DEFAULT true NOT NULL, + `scp_legacy` integer DEFAULT false NOT NULL, + `enable_docker` integer DEFAULT false NOT NULL, + `enable_tmux_monitor` integer DEFAULT false NOT NULL, + `show_terminal_in_sidebar` integer DEFAULT true NOT NULL, + `show_file_manager_in_sidebar` integer DEFAULT false NOT NULL, + `show_tunnel_in_sidebar` integer DEFAULT false NOT NULL, + `show_docker_in_sidebar` integer DEFAULT false NOT NULL, + `show_server_stats_in_sidebar` integer DEFAULT false NOT NULL, + `default_path` text, + `stats_config` text, + `docker_config` text, + `enable_proxmox` integer DEFAULT false NOT NULL, + `proxmox_config` text, + `terminal_config` text, + `quick_actions` text, + `notes` text, + `enable_ssh` integer DEFAULT true NOT NULL, + `enable_rdp` integer DEFAULT false NOT NULL, + `enable_vnc` integer DEFAULT false NOT NULL, + `enable_telnet` integer DEFAULT false NOT NULL, + `ssh_port` integer DEFAULT 22, + `rdp_port` integer DEFAULT 3389, + `vnc_port` integer DEFAULT 5900, + `telnet_port` integer DEFAULT 23, + `rdp_credential_id` integer, + `rdp_user` text, + `rdp_password` text, + `rdp_domain` text, + `rdp_security` text, + `rdp_ignore_cert` integer DEFAULT false, + `vnc_credential_id` integer, + `vnc_password` text, + `vnc_user` text, + `telnet_user` text, + `telnet_password` text, + `telnet_credential_id` integer, + `rdp_auth_type` text, + `vnc_auth_type` text, + `telnet_auth_type` text, + `domain` text, + `security` text, + `ignore_cert` integer DEFAULT false, + `guacamole_config` text, + `use_socks5` integer, + `socks5_host` text, + `socks5_port` integer, + `socks5_username` text, + `socks5_password` text, + `socks5_proxy_chain` text, + `connection_origin` text, + `mac_address` text, + `wol_broadcast_address` text, + `port_knock_sequence` text, + `host_key_fingerprint` text, + `host_key_type` text, + `host_key_algorithm` text DEFAULT 'sha256', + `host_key_first_seen` text, + `host_key_last_verified` text, + `host_key_changed_count` integer DEFAULT 0, + `sync_id` text, + `created_at` text DEFAULT CURRENT_TIMESTAMP NOT NULL, + `updated_at` text DEFAULT CURRENT_TIMESTAMP NOT NULL, + FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE cascade, + FOREIGN KEY (`credential_id`) REFERENCES `ssh_credentials`(`id`) ON UPDATE no action ON DELETE set null, + FOREIGN KEY (`vault_profile_id`) REFERENCES `vault_profiles`(`id`) ON UPDATE no action ON DELETE set null, + FOREIGN KEY (`rdp_credential_id`) REFERENCES `ssh_credentials`(`id`) ON UPDATE no action ON DELETE set null, + FOREIGN KEY (`vnc_credential_id`) REFERENCES `ssh_credentials`(`id`) ON UPDATE no action ON DELETE set null, + FOREIGN KEY (`telnet_credential_id`) REFERENCES `ssh_credentials`(`id`) ON UPDATE no action ON DELETE set null +); +--> statement-breakpoint +CREATE UNIQUE INDEX `ssh_data_sync_id_unique` ON `ssh_data` (`sync_id`);--> statement-breakpoint +CREATE TABLE `network_topology` ( + `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL, + `user_id` text NOT NULL, + `topology` text, + `created_at` text DEFAULT CURRENT_TIMESTAMP NOT NULL, + `updated_at` text DEFAULT CURRENT_TIMESTAMP NOT NULL, + FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE cascade +); +--> statement-breakpoint +CREATE TABLE `notification_channels` ( + `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL, + `user_id` text NOT NULL, + `name` text NOT NULL, + `type` text NOT NULL, + `config` text NOT NULL, + `enabled` integer DEFAULT true NOT NULL, + `created_at` text DEFAULT CURRENT_TIMESTAMP NOT NULL, + FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE cascade +); +--> statement-breakpoint +CREATE TABLE `opkssh_tokens` ( + `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL, + `user_id` text NOT NULL, + `host_id` integer NOT NULL, + `ssh_cert` text(8192) NOT NULL, + `private_key` text(8192) NOT NULL, + `email` text, + `sub` text, + `issuer` text, + `audience` text, + `created_at` text DEFAULT CURRENT_TIMESTAMP NOT NULL, + `expires_at` text NOT NULL, + `last_used` text, + FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE cascade, + FOREIGN KEY (`host_id`) REFERENCES `ssh_data`(`id`) ON UPDATE no action ON DELETE cascade +); +--> statement-breakpoint +CREATE UNIQUE INDEX `idx_opkssh_tokens_user_host` ON `opkssh_tokens` (`user_id`,`host_id`);--> statement-breakpoint +CREATE TABLE `recent_activity` ( + `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL, + `user_id` text NOT NULL, + `type` text NOT NULL, + `host_id` integer NOT NULL, + `host_name` text, + `timestamp` text DEFAULT CURRENT_TIMESTAMP NOT NULL, + FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE cascade, + FOREIGN KEY (`host_id`) REFERENCES `ssh_data`(`id`) ON UPDATE no action ON DELETE cascade +); +--> statement-breakpoint +CREATE TABLE `roles` ( + `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL, + `name` text NOT NULL, + `display_name` text NOT NULL, + `description` text, + `is_system` integer DEFAULT false NOT NULL, + `permissions` text, + `created_at` text DEFAULT CURRENT_TIMESTAMP NOT NULL, + `updated_at` text DEFAULT CURRENT_TIMESTAMP NOT NULL +); +--> statement-breakpoint +CREATE UNIQUE INDEX `roles_name_unique` ON `roles` (`name`);--> statement-breakpoint +CREATE TABLE `session_recordings` ( + `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL, + `host_id` integer NOT NULL, + `user_id` text, + `username` text, + `access_id` integer, + `started_at` text DEFAULT CURRENT_TIMESTAMP NOT NULL, + `ended_at` text, + `duration` integer, + `commands` text, + `dangerous_actions` text, + `recording_path` text, + `protocol` text DEFAULT 'ssh' NOT NULL, + `format` text DEFAULT 'text' NOT NULL, + `terminated_by_owner` integer DEFAULT false, + `termination_reason` text, + FOREIGN KEY (`host_id`) REFERENCES `ssh_data`(`id`) ON UPDATE no action ON DELETE cascade, + FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE set null, + FOREIGN KEY (`access_id`) REFERENCES `host_access`(`id`) ON UPDATE no action ON DELETE set null +); +--> statement-breakpoint +CREATE TABLE `session_share_participants` ( + `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL, + `share_id` text NOT NULL, + `user_id` text, + `guest_label` text, + `joined_at` text DEFAULT CURRENT_TIMESTAMP NOT NULL, + `left_at` text, + FOREIGN KEY (`share_id`) REFERENCES `session_shares`(`id`) ON UPDATE no action ON DELETE cascade, + FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE cascade +); +--> statement-breakpoint +CREATE TABLE `session_shares` ( + `id` text PRIMARY KEY NOT NULL, + `host_id` integer NOT NULL, + `owner_user_id` text NOT NULL, + `protocol` text NOT NULL, + `session_id` text NOT NULL, + `tab_instance_id` text, + `share_type` text NOT NULL, + `target_user_id` text, + `link_token` text, + `permission_level` text DEFAULT 'read-only' NOT NULL, + `created_at` text DEFAULT CURRENT_TIMESTAMP NOT NULL, + `expires_at` text NOT NULL, + `revoked_at` text, + `last_joined_at` text, + `join_count` integer DEFAULT 0 NOT NULL, + FOREIGN KEY (`host_id`) REFERENCES `ssh_data`(`id`) ON UPDATE no action ON DELETE cascade, + FOREIGN KEY (`owner_user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE cascade, + FOREIGN KEY (`target_user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE cascade +); +--> statement-breakpoint +CREATE UNIQUE INDEX `session_shares_link_token_unique` ON `session_shares` (`link_token`);--> statement-breakpoint +CREATE TABLE `sessions` ( + `id` text PRIMARY KEY NOT NULL, + `user_id` text NOT NULL, + `jwt_token` text NOT NULL, + `device_type` text NOT NULL, + `device_info` text NOT NULL, + `oidc_sub` text, + `oidc_sid` text, + `sso_provider_id` integer, + `created_at` text DEFAULT CURRENT_TIMESTAMP NOT NULL, + `expires_at` text NOT NULL, + `last_active_at` text DEFAULT CURRENT_TIMESTAMP NOT NULL, + FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE cascade +); +--> statement-breakpoint +CREATE TABLE `settings` ( + `key` text PRIMARY KEY NOT NULL, + `value` text NOT NULL +); +--> statement-breakpoint +CREATE TABLE `shared_host_auth_overrides` ( + `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL, + `host_id` integer NOT NULL, + `user_id` text NOT NULL, + `protocol` text DEFAULT 'ssh' NOT NULL, + `credential_id` integer NOT NULL, + `created_at` text DEFAULT CURRENT_TIMESTAMP NOT NULL, + `updated_at` text DEFAULT CURRENT_TIMESTAMP NOT NULL, + FOREIGN KEY (`host_id`) REFERENCES `ssh_data`(`id`) ON UPDATE no action ON DELETE cascade, + FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE cascade, + FOREIGN KEY (`credential_id`) REFERENCES `ssh_credentials`(`id`) ON UPDATE no action ON DELETE cascade +); +--> statement-breakpoint +CREATE UNIQUE INDEX `shared_host_auth_overrides_host_user_protocol_unique` ON `shared_host_auth_overrides` (`host_id`,`user_id`,`protocol`);--> statement-breakpoint +CREATE TABLE `shared_host_secrets` ( + `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL, + `host_access_id` integer NOT NULL, + `target_user_id` text NOT NULL, + `protocol` text DEFAULT 'ssh' NOT NULL, + `source_type` text DEFAULT 'credential' NOT NULL, + `original_credential_id` integer, + `encrypted_username` text, + `encrypted_auth_type` text, + `encrypted_password` text, + `encrypted_key` text(16384), + `encrypted_key_password` text, + `encrypted_key_type` text, + `encrypted_domain` text, + `created_at` text DEFAULT CURRENT_TIMESTAMP NOT NULL, + `updated_at` text DEFAULT CURRENT_TIMESTAMP NOT NULL, + FOREIGN KEY (`host_access_id`) REFERENCES `host_access`(`id`) ON UPDATE no action ON DELETE cascade, + FOREIGN KEY (`target_user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE cascade, + FOREIGN KEY (`original_credential_id`) REFERENCES `ssh_credentials`(`id`) ON UPDATE no action ON DELETE cascade +); +--> statement-breakpoint +CREATE UNIQUE INDEX `idx_shared_host_secrets_scope` ON `shared_host_secrets` (`host_access_id`,`target_user_id`,`protocol`);--> statement-breakpoint +CREATE TABLE `snippet_access` ( + `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL, + `snippet_id` integer NOT NULL, + `user_id` text, + `role_id` integer, + `granted_by` text NOT NULL, + `permission_level` text DEFAULT 'view' NOT NULL, + `expires_at` text, + `created_at` text DEFAULT CURRENT_TIMESTAMP NOT NULL, + FOREIGN KEY (`snippet_id`) REFERENCES `snippets`(`id`) ON UPDATE no action ON DELETE cascade, + FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE cascade, + FOREIGN KEY (`role_id`) REFERENCES `roles`(`id`) ON UPDATE no action ON DELETE cascade, + FOREIGN KEY (`granted_by`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE cascade +); +--> statement-breakpoint +CREATE TABLE `snippet_folders` ( + `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL, + `user_id` text NOT NULL, + `name` text NOT NULL, + `color` text, + `icon` text, + `sync_id` text, + `created_at` text DEFAULT CURRENT_TIMESTAMP NOT NULL, + `updated_at` text DEFAULT CURRENT_TIMESTAMP NOT NULL, + FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE cascade +); +--> statement-breakpoint +CREATE UNIQUE INDEX `snippet_folders_sync_id_unique` ON `snippet_folders` (`sync_id`);--> statement-breakpoint +CREATE TABLE `snippets` ( + `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL, + `user_id` text NOT NULL, + `name` text NOT NULL, + `content` text NOT NULL, + `description` text, + `folder` text, + `order` integer DEFAULT 0 NOT NULL, + `sync_id` text, + `created_at` text DEFAULT CURRENT_TIMESTAMP NOT NULL, + `updated_at` text DEFAULT CURRENT_TIMESTAMP NOT NULL, + `host_filter` text, + FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE cascade +); +--> statement-breakpoint +CREATE UNIQUE INDEX `snippets_sync_id_unique` ON `snippets` (`sync_id`);--> statement-breakpoint +CREATE TABLE `ssh_credential_usage` ( + `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL, + `credential_id` integer NOT NULL, + `host_id` integer NOT NULL, + `user_id` text NOT NULL, + `used_at` text DEFAULT CURRENT_TIMESTAMP NOT NULL, + FOREIGN KEY (`credential_id`) REFERENCES `ssh_credentials`(`id`) ON UPDATE no action ON DELETE cascade, + FOREIGN KEY (`host_id`) REFERENCES `ssh_data`(`id`) ON UPDATE no action ON DELETE cascade, + FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE cascade +); +--> statement-breakpoint +CREATE TABLE `ssh_credentials` ( + `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL, + `user_id` text NOT NULL, + `name` text NOT NULL, + `description` text, + `folder` text, + `tags` text, + `auth_type` text NOT NULL, + `username` text, + `password` text, + `key` text(16384), + `private_key` text(16384), + `public_key` text(4096), + `key_password` text, + `key_type` text, + `detected_key_type` text, + `cert_public_key` text(8192), + `usage_count` integer DEFAULT 0 NOT NULL, + `last_used` text, + `sync_id` text, + `created_at` text DEFAULT CURRENT_TIMESTAMP NOT NULL, + `updated_at` text DEFAULT CURRENT_TIMESTAMP NOT NULL, + FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE cascade +); +--> statement-breakpoint +CREATE UNIQUE INDEX `ssh_credentials_sync_id_unique` ON `ssh_credentials` (`sync_id`);--> statement-breakpoint +CREATE TABLE `ssh_folders` ( + `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL, + `user_id` text NOT NULL, + `name` text NOT NULL, + `color` text, + `icon` text, + `credential_id` integer, + `sync_id` text, + `created_at` text DEFAULT CURRENT_TIMESTAMP NOT NULL, + `updated_at` text DEFAULT CURRENT_TIMESTAMP NOT NULL, + FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE cascade, + FOREIGN KEY (`credential_id`) REFERENCES `ssh_credentials`(`id`) ON UPDATE no action ON DELETE set null +); +--> statement-breakpoint +CREATE UNIQUE INDEX `ssh_folders_sync_id_unique` ON `ssh_folders` (`sync_id`);--> statement-breakpoint +CREATE TABLE `sso_providers` ( + `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL, + `name` text NOT NULL, + `type` text NOT NULL, + `enabled` integer DEFAULT true NOT NULL, + `display_order` integer DEFAULT 0 NOT NULL, + `config` text NOT NULL, + `created_at` text DEFAULT CURRENT_TIMESTAMP NOT NULL, + `updated_at` text DEFAULT CURRENT_TIMESTAMP NOT NULL +); +--> statement-breakpoint +CREATE TABLE `sync_tombstones` ( + `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL, + `user_id` text NOT NULL, + `entity_type` text NOT NULL, + `sync_id` text NOT NULL, + `deleted_at` text DEFAULT CURRENT_TIMESTAMP NOT NULL, + FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE cascade +); +--> statement-breakpoint +CREATE TABLE `termix_identities` ( + `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL, + `user_id` text NOT NULL, + `handle` text NOT NULL, + `description` text, + `created_at` text DEFAULT CURRENT_TIMESTAMP NOT NULL, + `updated_at` text DEFAULT CURRENT_TIMESTAMP NOT NULL, + FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE cascade +); +--> statement-breakpoint +CREATE UNIQUE INDEX `termix_identities_user_id_unique` ON `termix_identities` (`user_id`);--> statement-breakpoint +CREATE UNIQUE INDEX `termix_identities_handle_unique` ON `termix_identities` (`handle`);--> statement-breakpoint +CREATE TABLE `termix_identity_ca` ( + `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL, + `identity_id` integer NOT NULL, + `user_id` text NOT NULL, + `public_key` text(4096) NOT NULL, + `private_key` text(8192) NOT NULL, + `validity_days` integer DEFAULT 90 NOT NULL, + `created_at` text DEFAULT CURRENT_TIMESTAMP NOT NULL, + `updated_at` text DEFAULT CURRENT_TIMESTAMP NOT NULL, + FOREIGN KEY (`identity_id`) REFERENCES `termix_identities`(`id`) ON UPDATE no action ON DELETE cascade, + FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE cascade +); +--> statement-breakpoint +CREATE UNIQUE INDEX `termix_identity_ca_identity_id_unique` ON `termix_identity_ca` (`identity_id`);--> statement-breakpoint +CREATE TABLE `termix_identity_keys` ( + `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL, + `identity_id` integer NOT NULL, + `user_id` text NOT NULL, + `public_key` text(8192) NOT NULL, + `key_type` text NOT NULL, + `algorithm` text NOT NULL, + `label` text, + `comment` text, + `source` text DEFAULT 'manual' NOT NULL, + `credential_id` integer, + `enabled` integer DEFAULT true NOT NULL, + `created_at` text DEFAULT CURRENT_TIMESTAMP NOT NULL, + FOREIGN KEY (`identity_id`) REFERENCES `termix_identities`(`id`) ON UPDATE no action ON DELETE cascade, + FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE cascade, + FOREIGN KEY (`credential_id`) REFERENCES `ssh_credentials`(`id`) ON UPDATE no action ON DELETE set null +); +--> statement-breakpoint +CREATE TABLE `tmux_session_tags` ( + `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL, + `user_id` text NOT NULL, + `host_id` integer NOT NULL, + `session_name` text NOT NULL, + `tag` text NOT NULL, + `created_at` text DEFAULT CURRENT_TIMESTAMP NOT NULL, + FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE cascade, + FOREIGN KEY (`host_id`) REFERENCES `ssh_data`(`id`) ON UPDATE no action ON DELETE cascade +); +--> statement-breakpoint +CREATE TABLE `transfer_recent` ( + `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL, + `user_id` text NOT NULL, + `source_host_id` integer NOT NULL, + `dest_host_id` integer NOT NULL, + `dest_path` text NOT NULL, + `dest_path_label` text NOT NULL, + `last_used` text DEFAULT CURRENT_TIMESTAMP NOT NULL, + FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE cascade, + FOREIGN KEY (`source_host_id`) REFERENCES `ssh_data`(`id`) ON UPDATE no action ON DELETE cascade, + FOREIGN KEY (`dest_host_id`) REFERENCES `ssh_data`(`id`) ON UPDATE no action ON DELETE cascade +); +--> statement-breakpoint +CREATE TABLE `trusted_devices` ( + `id` text PRIMARY KEY NOT NULL, + `user_id` text NOT NULL, + `device_fingerprint` text NOT NULL, + `device_type` text NOT NULL, + `device_info` text NOT NULL, + `created_at` text DEFAULT CURRENT_TIMESTAMP NOT NULL, + `expires_at` text NOT NULL, + `last_used_at` text DEFAULT CURRENT_TIMESTAMP NOT NULL, + FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE cascade +); +--> statement-breakpoint +CREATE TABLE `user_open_tabs` ( + `id` text PRIMARY KEY NOT NULL, + `user_id` text NOT NULL, + `tab_type` text NOT NULL, + `host_id` integer, + `label` text NOT NULL, + `tab_order` integer DEFAULT 0 NOT NULL, + `backend_session_id` text, + `created_at` text DEFAULT CURRENT_TIMESTAMP NOT NULL, + `updated_at` text DEFAULT CURRENT_TIMESTAMP NOT NULL, + FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE cascade, + FOREIGN KEY (`host_id`) REFERENCES `ssh_data`(`id`) ON UPDATE no action ON DELETE cascade +); +--> statement-breakpoint +CREATE TABLE `user_preferences` ( + `user_id` text PRIMARY KEY NOT NULL, + `reopen_tabs_on_login` integer DEFAULT false NOT NULL, + `theme` text, + `font_size` text, + `accent_color` text, + `language` text, + `storage_mode` text, + `command_autocomplete` integer, + `command_palette_enabled` integer, + `show_host_tags` integer, + `host_tray_on_click` integer, + `pin_app_rail` integer, + `expand_app_rail_on_hover` integer, + `folders_collapsed` integer, + `confirm_snippet_execution` integer, + `disable_update_check` integer, + `confirm_tab_close` integer, + `hidden_rail_tabs` text, + `compact_host_view` integer, + `status_color_scheme` text, + `custom_themes` text, + `custom_keybindings` text, + `updated_at` text DEFAULT CURRENT_TIMESTAMP NOT NULL, + FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE cascade +); +--> statement-breakpoint +CREATE TABLE `user_roles` ( + `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL, + `user_id` text NOT NULL, + `role_id` integer NOT NULL, + `granted_by` text, + `granted_at` text DEFAULT CURRENT_TIMESTAMP NOT NULL, + FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE cascade, + FOREIGN KEY (`role_id`) REFERENCES `roles`(`id`) ON UPDATE no action ON DELETE cascade, + FOREIGN KEY (`granted_by`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE set null +); +--> statement-breakpoint +CREATE UNIQUE INDEX `idx_user_roles_user_role` ON `user_roles` (`user_id`,`role_id`);--> statement-breakpoint +CREATE TABLE `users` ( + `id` text PRIMARY KEY NOT NULL, + `username` text NOT NULL, + `password_hash` text NOT NULL, + `is_admin` integer DEFAULT false NOT NULL, + `is_oidc` integer DEFAULT false NOT NULL, + `oidc_identifier` text, + `sso_provider_id` integer, + `client_id` text, + `client_secret` text, + `issuer_url` text, + `authorization_url` text, + `token_url` text, + `identifier_path` text, + `name_path` text, + `scopes` text DEFAULT 'openid email profile', + `totp_secret` text, + `totp_enabled` integer DEFAULT false NOT NULL, + `totp_backup_codes` text, + `registered_at` text DEFAULT CURRENT_TIMESTAMP NOT NULL, + `donation_modal_dismissed` integer DEFAULT false NOT NULL +); +--> statement-breakpoint +CREATE TABLE `vault_profiles` ( + `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL, + `user_id` text NOT NULL, + `name` text NOT NULL, + `description` text, + `folder` text, + `tags` text, + `vault_addr` text NOT NULL, + `vault_namespace` text, + `oidc_mount` text, + `oidc_role` text, + `ssh_mount` text, + `ssh_role` text NOT NULL, + `valid_principals` text, + `key_type` text, + `shared` integer DEFAULT false NOT NULL, + `sync_id` text, + `created_at` text DEFAULT CURRENT_TIMESTAMP NOT NULL, + `updated_at` text DEFAULT CURRENT_TIMESTAMP NOT NULL, + FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE cascade +); +--> statement-breakpoint +CREATE UNIQUE INDEX `vault_profiles_sync_id_unique` ON `vault_profiles` (`sync_id`);--> statement-breakpoint +CREATE TABLE `vault_tokens` ( + `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL, + `user_id` text NOT NULL, + `profile_id` integer NOT NULL, + `ssh_cert` text(8192) NOT NULL, + `private_key` text(8192) NOT NULL, + `created_at` text DEFAULT CURRENT_TIMESTAMP NOT NULL, + `expires_at` text NOT NULL, + `last_used` text, + FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE cascade, + FOREIGN KEY (`profile_id`) REFERENCES `vault_profiles`(`id`) ON UPDATE no action ON DELETE cascade +); +--> statement-breakpoint +CREATE UNIQUE INDEX `idx_vault_tokens_user_profile` ON `vault_tokens` (`user_id`,`profile_id`);--> statement-breakpoint +CREATE TABLE `webauthn_credentials` ( + `id` text PRIMARY KEY NOT NULL, + `user_id` text NOT NULL, + `name` text NOT NULL, + `credential_id` text NOT NULL, + `public_key` text NOT NULL, + `counter` integer DEFAULT 0 NOT NULL, + `device_type` text, + `backed_up` integer DEFAULT false NOT NULL, + `transports` text, + `user_verification` text DEFAULT 'preferred' NOT NULL, + `created_at` text DEFAULT CURRENT_TIMESTAMP NOT NULL, + `last_used_at` text, + FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE cascade +); diff --git a/drizzle/sqlite/meta/0000_snapshot.json b/drizzle/sqlite/meta/0000_snapshot.json new file mode 100644 index 00000000..305992fd --- /dev/null +++ b/drizzle/sqlite/meta/0000_snapshot.json @@ -0,0 +1,6080 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "426410c9-f34a-47bc-9df6-17748689ad0d", + "prevId": "00000000-0000-0000-0000-000000000000", + "tables": { + "alert_firings": { + "name": "alert_firings", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "rule_id": { + "name": "rule_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "host_id": { + "name": "host_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "host_name": { + "name": "host_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "fired_at": { + "name": "fired_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "resolved_at": { + "name": "resolved_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "severity": { + "name": "severity", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'warning'" + }, + "acknowledged": { + "name": "acknowledged", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + } + }, + "indexes": {}, + "foreignKeys": { + "alert_firings_user_id_users_id_fk": { + "name": "alert_firings_user_id_users_id_fk", + "tableFrom": "alert_firings", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "alert_firings_rule_id_alert_rules_id_fk": { + "name": "alert_firings_rule_id_alert_rules_id_fk", + "tableFrom": "alert_firings", + "tableTo": "alert_rules", + "columnsFrom": [ + "rule_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "alert_rule_channels": { + "name": "alert_rule_channels", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "rule_id": { + "name": "rule_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "channel_id": { + "name": "channel_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "alert_rule_channels_rule_id_alert_rules_id_fk": { + "name": "alert_rule_channels_rule_id_alert_rules_id_fk", + "tableFrom": "alert_rule_channels", + "tableTo": "alert_rules", + "columnsFrom": [ + "rule_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "alert_rule_channels_channel_id_notification_channels_id_fk": { + "name": "alert_rule_channels_channel_id_notification_channels_id_fk", + "tableFrom": "alert_rule_channels", + "tableTo": "notification_channels", + "columnsFrom": [ + "channel_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "alert_rules": { + "name": "alert_rules", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "host_id": { + "name": "host_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "enabled": { + "name": "enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "trigger_type": { + "name": "trigger_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "threshold_value": { + "name": "threshold_value", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "threshold_duration_seconds": { + "name": "threshold_duration_seconds", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cooldown_minutes": { + "name": "cooldown_minutes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 15 + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": {}, + "foreignKeys": { + "alert_rules_user_id_users_id_fk": { + "name": "alert_rules_user_id_users_id_fk", + "tableFrom": "alert_rules", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "alert_rules_host_id_ssh_data_id_fk": { + "name": "alert_rules_host_id_ssh_data_id_fk", + "tableFrom": "alert_rules", + "tableTo": "ssh_data", + "columnsFrom": [ + "host_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "api_keys": { + "name": "api_keys", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "token_prefix": { + "name": "token_prefix", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "expires_at": { + "name": "expires_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_used_at": { + "name": "last_used_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_active": { + "name": "is_active", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + } + }, + "indexes": {}, + "foreignKeys": { + "api_keys_user_id_users_id_fk": { + "name": "api_keys_user_id_users_id_fk", + "tableFrom": "api_keys", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "audit_logs": { + "name": "audit_logs", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "resource_type": { + "name": "resource_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "resource_id": { + "name": "resource_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "resource_name": { + "name": "resource_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "details": { + "name": "details", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "success": { + "name": "success", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "timestamp": { + "name": "timestamp", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": {}, + "foreignKeys": { + "audit_logs_user_id_users_id_fk": { + "name": "audit_logs_user_id_users_id_fk", + "tableFrom": "audit_logs", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "c2s_tunnel_presets": { + "name": "c2s_tunnel_presets", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "config": { + "name": "config", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "platform": { + "name": "platform", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "computer_name": { + "name": "computer_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": {}, + "foreignKeys": { + "c2s_tunnel_presets_user_id_users_id_fk": { + "name": "c2s_tunnel_presets_user_id_users_id_fk", + "tableFrom": "c2s_tunnel_presets", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "command_history": { + "name": "command_history", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "host_id": { + "name": "host_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "command": { + "name": "command", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "executed_at": { + "name": "executed_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": {}, + "foreignKeys": { + "command_history_user_id_users_id_fk": { + "name": "command_history_user_id_users_id_fk", + "tableFrom": "command_history", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "command_history_host_id_ssh_data_id_fk": { + "name": "command_history_host_id_ssh_data_id_fk", + "tableFrom": "command_history", + "tableTo": "ssh_data", + "columnsFrom": [ + "host_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "dashboard_service_links": { + "name": "dashboard_service_links", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "order": { + "name": "order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "sync_id": { + "name": "sync_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "dashboard_service_links_sync_id_unique": { + "name": "dashboard_service_links_sync_id_unique", + "columns": [ + "sync_id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "dashboard_service_links_user_id_users_id_fk": { + "name": "dashboard_service_links_user_id_users_id_fk", + "tableFrom": "dashboard_service_links", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "dismissed_alerts": { + "name": "dismissed_alerts", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "alert_id": { + "name": "alert_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "dismissed_at": { + "name": "dismissed_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": {}, + "foreignKeys": { + "dismissed_alerts_user_id_users_id_fk": { + "name": "dismissed_alerts_user_id_users_id_fk", + "tableFrom": "dismissed_alerts", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "file_manager_pinned": { + "name": "file_manager_pinned", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "host_id": { + "name": "host_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "path": { + "name": "path", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "pinned_at": { + "name": "pinned_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": {}, + "foreignKeys": { + "file_manager_pinned_user_id_users_id_fk": { + "name": "file_manager_pinned_user_id_users_id_fk", + "tableFrom": "file_manager_pinned", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "file_manager_pinned_host_id_ssh_data_id_fk": { + "name": "file_manager_pinned_host_id_ssh_data_id_fk", + "tableFrom": "file_manager_pinned", + "tableTo": "ssh_data", + "columnsFrom": [ + "host_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "file_manager_recent": { + "name": "file_manager_recent", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "host_id": { + "name": "host_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "path": { + "name": "path", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_opened": { + "name": "last_opened", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": {}, + "foreignKeys": { + "file_manager_recent_user_id_users_id_fk": { + "name": "file_manager_recent_user_id_users_id_fk", + "tableFrom": "file_manager_recent", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "file_manager_recent_host_id_ssh_data_id_fk": { + "name": "file_manager_recent_host_id_ssh_data_id_fk", + "tableFrom": "file_manager_recent", + "tableTo": "ssh_data", + "columnsFrom": [ + "host_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "file_manager_shortcuts": { + "name": "file_manager_shortcuts", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "host_id": { + "name": "host_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "path": { + "name": "path", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": {}, + "foreignKeys": { + "file_manager_shortcuts_user_id_users_id_fk": { + "name": "file_manager_shortcuts_user_id_users_id_fk", + "tableFrom": "file_manager_shortcuts", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "file_manager_shortcuts_host_id_ssh_data_id_fk": { + "name": "file_manager_shortcuts_host_id_ssh_data_id_fk", + "tableFrom": "file_manager_shortcuts", + "tableTo": "ssh_data", + "columnsFrom": [ + "host_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "homepage_items": { + "name": "homepage_items", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type_id": { + "name": "type_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "config": { + "name": "config", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'{}'" + }, + "folder_id": { + "name": "folder_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sync_id": { + "name": "sync_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "homepage_items_sync_id_unique": { + "name": "homepage_items_sync_id_unique", + "columns": [ + "sync_id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "homepage_items_user_id_users_id_fk": { + "name": "homepage_items_user_id_users_id_fk", + "tableFrom": "homepage_items", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "homepage_layouts": { + "name": "homepage_layouts", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "layout": { + "name": "layout", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'{}'" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "homepage_layouts_user_id_unique": { + "name": "homepage_layouts_user_id_unique", + "columns": [ + "user_id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "homepage_layouts_user_id_users_id_fk": { + "name": "homepage_layouts_user_id_users_id_fk", + "tableFrom": "homepage_layouts", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "host_access": { + "name": "host_access", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "host_id": { + "name": "host_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "role_id": { + "name": "role_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "granted_by": { + "name": "granted_by", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "permission_level": { + "name": "permission_level", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'connect'" + }, + "expires_at": { + "name": "expires_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "last_accessed_at": { + "name": "last_accessed_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "access_count": { + "name": "access_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + } + }, + "indexes": {}, + "foreignKeys": { + "host_access_host_id_ssh_data_id_fk": { + "name": "host_access_host_id_ssh_data_id_fk", + "tableFrom": "host_access", + "tableTo": "ssh_data", + "columnsFrom": [ + "host_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "host_access_user_id_users_id_fk": { + "name": "host_access_user_id_users_id_fk", + "tableFrom": "host_access", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "host_access_role_id_roles_id_fk": { + "name": "host_access_role_id_roles_id_fk", + "tableFrom": "host_access", + "tableTo": "roles", + "columnsFrom": [ + "role_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "host_access_granted_by_users_id_fk": { + "name": "host_access_granted_by_users_id_fk", + "tableFrom": "host_access", + "tableTo": "users", + "columnsFrom": [ + "granted_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "host_health_checks": { + "name": "host_health_checks", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "host_id": { + "name": "host_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "checks": { + "name": "checks", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "interval_seconds": { + "name": "interval_seconds", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 300 + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "idx_host_health_checks_user_host": { + "name": "idx_host_health_checks_user_host", + "columns": [ + "user_id", + "host_id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "host_health_checks_user_id_users_id_fk": { + "name": "host_health_checks_user_id_users_id_fk", + "tableFrom": "host_health_checks", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "host_health_checks_host_id_ssh_data_id_fk": { + "name": "host_health_checks_host_id_ssh_data_id_fk", + "tableFrom": "host_health_checks", + "tableTo": "ssh_data", + "columnsFrom": [ + "host_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "host_health_history": { + "name": "host_health_history", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "host_id": { + "name": "host_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "check_id": { + "name": "check_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ts": { + "name": "ts", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "ok": { + "name": "ok", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "latency_ms": { + "name": "latency_ms", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "detail": { + "name": "detail", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "host_health_history_user_id_users_id_fk": { + "name": "host_health_history_user_id_users_id_fk", + "tableFrom": "host_health_history", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "host_health_history_host_id_ssh_data_id_fk": { + "name": "host_health_history_host_id_ssh_data_id_fk", + "tableFrom": "host_health_history", + "tableTo": "ssh_data", + "columnsFrom": [ + "host_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "host_metrics_history": { + "name": "host_metrics_history", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "host_id": { + "name": "host_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ts": { + "name": "ts", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "cpu_percent": { + "name": "cpu_percent", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "mem_percent": { + "name": "mem_percent", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "disk_percent": { + "name": "disk_percent", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "net_rx_bytes": { + "name": "net_rx_bytes", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "net_tx_bytes": { + "name": "net_tx_bytes", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "host_metrics_history_host_id_ssh_data_id_fk": { + "name": "host_metrics_history_host_id_ssh_data_id_fk", + "tableFrom": "host_metrics_history", + "tableTo": "ssh_data", + "columnsFrom": [ + "host_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "host_metrics_preferences": { + "name": "host_metrics_preferences", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "host_id": { + "name": "host_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "layout": { + "name": "layout", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "idx_host_metrics_prefs_user_host": { + "name": "idx_host_metrics_prefs_user_host", + "columns": [ + "user_id", + "host_id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "host_metrics_preferences_user_id_users_id_fk": { + "name": "host_metrics_preferences_user_id_users_id_fk", + "tableFrom": "host_metrics_preferences", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "host_metrics_preferences_host_id_ssh_data_id_fk": { + "name": "host_metrics_preferences_host_id_ssh_data_id_fk", + "tableFrom": "host_metrics_preferences", + "tableTo": "ssh_data", + "columnsFrom": [ + "host_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "ssh_data": { + "name": "ssh_data", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "connection_type": { + "name": "connection_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'ssh'" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "ip": { + "name": "ip", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "port": { + "name": "port", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "folder": { + "name": "folder", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "tags": { + "name": "tags", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "pin": { + "name": "pin", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "auth_type": { + "name": "auth_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "use_warpgate": { + "name": "use_warpgate", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "share_ssh_auth": { + "name": "share_ssh_auth", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "force_keyboard_interactive": { + "name": "force_keyboard_interactive", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "key": { + "name": "key", + "type": "text(8192)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "key_password": { + "name": "key_password", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "key_type": { + "name": "key_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sudo_password": { + "name": "sudo_password", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "autostart_password": { + "name": "autostart_password", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "autostart_key": { + "name": "autostart_key", + "type": "text(8192)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "autostart_key_password": { + "name": "autostart_key_password", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "credential_id": { + "name": "credential_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "override_credential_username": { + "name": "override_credential_username", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "vault_profile_id": { + "name": "vault_profile_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "enable_terminal": { + "name": "enable_terminal", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "enable_session_logging": { + "name": "enable_session_logging", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "allow_session_sharing": { + "name": "allow_session_sharing", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "enable_command_history": { + "name": "enable_command_history", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "enable_tunnel": { + "name": "enable_tunnel", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "tunnel_connections": { + "name": "tunnel_connections", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "jump_hosts": { + "name": "jump_hosts", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "enable_file_manager": { + "name": "enable_file_manager", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "scp_legacy": { + "name": "scp_legacy", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "enable_docker": { + "name": "enable_docker", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "enable_tmux_monitor": { + "name": "enable_tmux_monitor", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "show_terminal_in_sidebar": { + "name": "show_terminal_in_sidebar", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "show_file_manager_in_sidebar": { + "name": "show_file_manager_in_sidebar", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "show_tunnel_in_sidebar": { + "name": "show_tunnel_in_sidebar", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "show_docker_in_sidebar": { + "name": "show_docker_in_sidebar", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "show_server_stats_in_sidebar": { + "name": "show_server_stats_in_sidebar", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "default_path": { + "name": "default_path", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "stats_config": { + "name": "stats_config", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "docker_config": { + "name": "docker_config", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "enable_proxmox": { + "name": "enable_proxmox", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "proxmox_config": { + "name": "proxmox_config", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "terminal_config": { + "name": "terminal_config", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "quick_actions": { + "name": "quick_actions", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "enable_ssh": { + "name": "enable_ssh", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "enable_rdp": { + "name": "enable_rdp", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "enable_vnc": { + "name": "enable_vnc", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "enable_telnet": { + "name": "enable_telnet", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "ssh_port": { + "name": "ssh_port", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 22 + }, + "rdp_port": { + "name": "rdp_port", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 3389 + }, + "vnc_port": { + "name": "vnc_port", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 5900 + }, + "telnet_port": { + "name": "telnet_port", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 23 + }, + "rdp_credential_id": { + "name": "rdp_credential_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "rdp_user": { + "name": "rdp_user", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "rdp_password": { + "name": "rdp_password", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "rdp_domain": { + "name": "rdp_domain", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "rdp_security": { + "name": "rdp_security", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "rdp_ignore_cert": { + "name": "rdp_ignore_cert", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": false + }, + "vnc_credential_id": { + "name": "vnc_credential_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "vnc_password": { + "name": "vnc_password", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "vnc_user": { + "name": "vnc_user", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "telnet_user": { + "name": "telnet_user", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "telnet_password": { + "name": "telnet_password", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "telnet_credential_id": { + "name": "telnet_credential_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "rdp_auth_type": { + "name": "rdp_auth_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "vnc_auth_type": { + "name": "vnc_auth_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "telnet_auth_type": { + "name": "telnet_auth_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "domain": { + "name": "domain", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "security": { + "name": "security", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "ignore_cert": { + "name": "ignore_cert", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": false + }, + "guacamole_config": { + "name": "guacamole_config", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "use_socks5": { + "name": "use_socks5", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "socks5_host": { + "name": "socks5_host", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "socks5_port": { + "name": "socks5_port", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "socks5_username": { + "name": "socks5_username", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "socks5_password": { + "name": "socks5_password", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "socks5_proxy_chain": { + "name": "socks5_proxy_chain", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "connection_origin": { + "name": "connection_origin", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "mac_address": { + "name": "mac_address", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "wol_broadcast_address": { + "name": "wol_broadcast_address", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "port_knock_sequence": { + "name": "port_knock_sequence", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "host_key_fingerprint": { + "name": "host_key_fingerprint", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "host_key_type": { + "name": "host_key_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "host_key_algorithm": { + "name": "host_key_algorithm", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'sha256'" + }, + "host_key_first_seen": { + "name": "host_key_first_seen", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "host_key_last_verified": { + "name": "host_key_last_verified", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "host_key_changed_count": { + "name": "host_key_changed_count", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "sync_id": { + "name": "sync_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "ssh_data_sync_id_unique": { + "name": "ssh_data_sync_id_unique", + "columns": [ + "sync_id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "ssh_data_user_id_users_id_fk": { + "name": "ssh_data_user_id_users_id_fk", + "tableFrom": "ssh_data", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "ssh_data_credential_id_ssh_credentials_id_fk": { + "name": "ssh_data_credential_id_ssh_credentials_id_fk", + "tableFrom": "ssh_data", + "tableTo": "ssh_credentials", + "columnsFrom": [ + "credential_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "ssh_data_vault_profile_id_vault_profiles_id_fk": { + "name": "ssh_data_vault_profile_id_vault_profiles_id_fk", + "tableFrom": "ssh_data", + "tableTo": "vault_profiles", + "columnsFrom": [ + "vault_profile_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "ssh_data_rdp_credential_id_ssh_credentials_id_fk": { + "name": "ssh_data_rdp_credential_id_ssh_credentials_id_fk", + "tableFrom": "ssh_data", + "tableTo": "ssh_credentials", + "columnsFrom": [ + "rdp_credential_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "ssh_data_vnc_credential_id_ssh_credentials_id_fk": { + "name": "ssh_data_vnc_credential_id_ssh_credentials_id_fk", + "tableFrom": "ssh_data", + "tableTo": "ssh_credentials", + "columnsFrom": [ + "vnc_credential_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "ssh_data_telnet_credential_id_ssh_credentials_id_fk": { + "name": "ssh_data_telnet_credential_id_ssh_credentials_id_fk", + "tableFrom": "ssh_data", + "tableTo": "ssh_credentials", + "columnsFrom": [ + "telnet_credential_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "network_topology": { + "name": "network_topology", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "topology": { + "name": "topology", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": {}, + "foreignKeys": { + "network_topology_user_id_users_id_fk": { + "name": "network_topology_user_id_users_id_fk", + "tableFrom": "network_topology", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "notification_channels": { + "name": "notification_channels", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "config": { + "name": "config", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "enabled": { + "name": "enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": {}, + "foreignKeys": { + "notification_channels_user_id_users_id_fk": { + "name": "notification_channels_user_id_users_id_fk", + "tableFrom": "notification_channels", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "opkssh_tokens": { + "name": "opkssh_tokens", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "host_id": { + "name": "host_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ssh_cert": { + "name": "ssh_cert", + "type": "text(8192)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "private_key": { + "name": "private_key", + "type": "text(8192)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sub": { + "name": "sub", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "issuer": { + "name": "issuer", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "audience": { + "name": "audience", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "expires_at": { + "name": "expires_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_used": { + "name": "last_used", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "idx_opkssh_tokens_user_host": { + "name": "idx_opkssh_tokens_user_host", + "columns": [ + "user_id", + "host_id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "opkssh_tokens_user_id_users_id_fk": { + "name": "opkssh_tokens_user_id_users_id_fk", + "tableFrom": "opkssh_tokens", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "opkssh_tokens_host_id_ssh_data_id_fk": { + "name": "opkssh_tokens_host_id_ssh_data_id_fk", + "tableFrom": "opkssh_tokens", + "tableTo": "ssh_data", + "columnsFrom": [ + "host_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "recent_activity": { + "name": "recent_activity", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "host_id": { + "name": "host_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "host_name": { + "name": "host_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "timestamp": { + "name": "timestamp", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": {}, + "foreignKeys": { + "recent_activity_user_id_users_id_fk": { + "name": "recent_activity_user_id_users_id_fk", + "tableFrom": "recent_activity", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "recent_activity_host_id_ssh_data_id_fk": { + "name": "recent_activity_host_id_ssh_data_id_fk", + "tableFrom": "recent_activity", + "tableTo": "ssh_data", + "columnsFrom": [ + "host_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "roles": { + "name": "roles", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_system": { + "name": "is_system", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "permissions": { + "name": "permissions", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "roles_name_unique": { + "name": "roles_name_unique", + "columns": [ + "name" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "session_recordings": { + "name": "session_recordings", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "host_id": { + "name": "host_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "access_id": { + "name": "access_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "started_at": { + "name": "started_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "ended_at": { + "name": "ended_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "duration": { + "name": "duration", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "commands": { + "name": "commands", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "dangerous_actions": { + "name": "dangerous_actions", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "recording_path": { + "name": "recording_path", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "protocol": { + "name": "protocol", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'ssh'" + }, + "format": { + "name": "format", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'text'" + }, + "terminated_by_owner": { + "name": "terminated_by_owner", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": false + }, + "termination_reason": { + "name": "termination_reason", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "session_recordings_host_id_ssh_data_id_fk": { + "name": "session_recordings_host_id_ssh_data_id_fk", + "tableFrom": "session_recordings", + "tableTo": "ssh_data", + "columnsFrom": [ + "host_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "session_recordings_user_id_users_id_fk": { + "name": "session_recordings_user_id_users_id_fk", + "tableFrom": "session_recordings", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "session_recordings_access_id_host_access_id_fk": { + "name": "session_recordings_access_id_host_access_id_fk", + "tableFrom": "session_recordings", + "tableTo": "host_access", + "columnsFrom": [ + "access_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "session_share_participants": { + "name": "session_share_participants", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "share_id": { + "name": "share_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "guest_label": { + "name": "guest_label", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "joined_at": { + "name": "joined_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "left_at": { + "name": "left_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "session_share_participants_share_id_session_shares_id_fk": { + "name": "session_share_participants_share_id_session_shares_id_fk", + "tableFrom": "session_share_participants", + "tableTo": "session_shares", + "columnsFrom": [ + "share_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "session_share_participants_user_id_users_id_fk": { + "name": "session_share_participants_user_id_users_id_fk", + "tableFrom": "session_share_participants", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "session_shares": { + "name": "session_shares", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "host_id": { + "name": "host_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "protocol": { + "name": "protocol", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "tab_instance_id": { + "name": "tab_instance_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "share_type": { + "name": "share_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "target_user_id": { + "name": "target_user_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "link_token": { + "name": "link_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "permission_level": { + "name": "permission_level", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'read-only'" + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "expires_at": { + "name": "expires_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "revoked_at": { + "name": "revoked_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_joined_at": { + "name": "last_joined_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "join_count": { + "name": "join_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + } + }, + "indexes": { + "session_shares_link_token_unique": { + "name": "session_shares_link_token_unique", + "columns": [ + "link_token" + ], + "isUnique": true + } + }, + "foreignKeys": { + "session_shares_host_id_ssh_data_id_fk": { + "name": "session_shares_host_id_ssh_data_id_fk", + "tableFrom": "session_shares", + "tableTo": "ssh_data", + "columnsFrom": [ + "host_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "session_shares_owner_user_id_users_id_fk": { + "name": "session_shares_owner_user_id_users_id_fk", + "tableFrom": "session_shares", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "session_shares_target_user_id_users_id_fk": { + "name": "session_shares_target_user_id_users_id_fk", + "tableFrom": "session_shares", + "tableTo": "users", + "columnsFrom": [ + "target_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "sessions": { + "name": "sessions", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "jwt_token": { + "name": "jwt_token", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "device_type": { + "name": "device_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "device_info": { + "name": "device_info", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "oidc_sub": { + "name": "oidc_sub", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "oidc_sid": { + "name": "oidc_sid", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sso_provider_id": { + "name": "sso_provider_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "expires_at": { + "name": "expires_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_active_at": { + "name": "last_active_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": {}, + "foreignKeys": { + "sessions_user_id_users_id_fk": { + "name": "sessions_user_id_users_id_fk", + "tableFrom": "sessions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "settings": { + "name": "settings", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "shared_host_auth_overrides": { + "name": "shared_host_auth_overrides", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "host_id": { + "name": "host_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "protocol": { + "name": "protocol", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'ssh'" + }, + "credential_id": { + "name": "credential_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "shared_host_auth_overrides_host_user_protocol_unique": { + "name": "shared_host_auth_overrides_host_user_protocol_unique", + "columns": [ + "host_id", + "user_id", + "protocol" + ], + "isUnique": true + } + }, + "foreignKeys": { + "shared_host_auth_overrides_host_id_ssh_data_id_fk": { + "name": "shared_host_auth_overrides_host_id_ssh_data_id_fk", + "tableFrom": "shared_host_auth_overrides", + "tableTo": "ssh_data", + "columnsFrom": [ + "host_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "shared_host_auth_overrides_user_id_users_id_fk": { + "name": "shared_host_auth_overrides_user_id_users_id_fk", + "tableFrom": "shared_host_auth_overrides", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "shared_host_auth_overrides_credential_id_ssh_credentials_id_fk": { + "name": "shared_host_auth_overrides_credential_id_ssh_credentials_id_fk", + "tableFrom": "shared_host_auth_overrides", + "tableTo": "ssh_credentials", + "columnsFrom": [ + "credential_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "shared_host_secrets": { + "name": "shared_host_secrets", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "host_access_id": { + "name": "host_access_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "target_user_id": { + "name": "target_user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "protocol": { + "name": "protocol", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'ssh'" + }, + "source_type": { + "name": "source_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'credential'" + }, + "original_credential_id": { + "name": "original_credential_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "encrypted_username": { + "name": "encrypted_username", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "encrypted_auth_type": { + "name": "encrypted_auth_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "encrypted_password": { + "name": "encrypted_password", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "encrypted_key": { + "name": "encrypted_key", + "type": "text(16384)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "encrypted_key_password": { + "name": "encrypted_key_password", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "encrypted_key_type": { + "name": "encrypted_key_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "encrypted_domain": { + "name": "encrypted_domain", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "idx_shared_host_secrets_scope": { + "name": "idx_shared_host_secrets_scope", + "columns": [ + "host_access_id", + "target_user_id", + "protocol" + ], + "isUnique": true + } + }, + "foreignKeys": { + "shared_host_secrets_host_access_id_host_access_id_fk": { + "name": "shared_host_secrets_host_access_id_host_access_id_fk", + "tableFrom": "shared_host_secrets", + "tableTo": "host_access", + "columnsFrom": [ + "host_access_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "shared_host_secrets_target_user_id_users_id_fk": { + "name": "shared_host_secrets_target_user_id_users_id_fk", + "tableFrom": "shared_host_secrets", + "tableTo": "users", + "columnsFrom": [ + "target_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "shared_host_secrets_original_credential_id_ssh_credentials_id_fk": { + "name": "shared_host_secrets_original_credential_id_ssh_credentials_id_fk", + "tableFrom": "shared_host_secrets", + "tableTo": "ssh_credentials", + "columnsFrom": [ + "original_credential_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "snippet_access": { + "name": "snippet_access", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "snippet_id": { + "name": "snippet_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "role_id": { + "name": "role_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "granted_by": { + "name": "granted_by", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "permission_level": { + "name": "permission_level", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'view'" + }, + "expires_at": { + "name": "expires_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": {}, + "foreignKeys": { + "snippet_access_snippet_id_snippets_id_fk": { + "name": "snippet_access_snippet_id_snippets_id_fk", + "tableFrom": "snippet_access", + "tableTo": "snippets", + "columnsFrom": [ + "snippet_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "snippet_access_user_id_users_id_fk": { + "name": "snippet_access_user_id_users_id_fk", + "tableFrom": "snippet_access", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "snippet_access_role_id_roles_id_fk": { + "name": "snippet_access_role_id_roles_id_fk", + "tableFrom": "snippet_access", + "tableTo": "roles", + "columnsFrom": [ + "role_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "snippet_access_granted_by_users_id_fk": { + "name": "snippet_access_granted_by_users_id_fk", + "tableFrom": "snippet_access", + "tableTo": "users", + "columnsFrom": [ + "granted_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "snippet_folders": { + "name": "snippet_folders", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "icon": { + "name": "icon", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sync_id": { + "name": "sync_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "snippet_folders_sync_id_unique": { + "name": "snippet_folders_sync_id_unique", + "columns": [ + "sync_id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "snippet_folders_user_id_users_id_fk": { + "name": "snippet_folders_user_id_users_id_fk", + "tableFrom": "snippet_folders", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "snippets": { + "name": "snippets", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "folder": { + "name": "folder", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "order": { + "name": "order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "sync_id": { + "name": "sync_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "host_filter": { + "name": "host_filter", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "snippets_sync_id_unique": { + "name": "snippets_sync_id_unique", + "columns": [ + "sync_id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "snippets_user_id_users_id_fk": { + "name": "snippets_user_id_users_id_fk", + "tableFrom": "snippets", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "ssh_credential_usage": { + "name": "ssh_credential_usage", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "credential_id": { + "name": "credential_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "host_id": { + "name": "host_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "used_at": { + "name": "used_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": {}, + "foreignKeys": { + "ssh_credential_usage_credential_id_ssh_credentials_id_fk": { + "name": "ssh_credential_usage_credential_id_ssh_credentials_id_fk", + "tableFrom": "ssh_credential_usage", + "tableTo": "ssh_credentials", + "columnsFrom": [ + "credential_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "ssh_credential_usage_host_id_ssh_data_id_fk": { + "name": "ssh_credential_usage_host_id_ssh_data_id_fk", + "tableFrom": "ssh_credential_usage", + "tableTo": "ssh_data", + "columnsFrom": [ + "host_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "ssh_credential_usage_user_id_users_id_fk": { + "name": "ssh_credential_usage_user_id_users_id_fk", + "tableFrom": "ssh_credential_usage", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "ssh_credentials": { + "name": "ssh_credentials", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "folder": { + "name": "folder", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "tags": { + "name": "tags", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "auth_type": { + "name": "auth_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "key": { + "name": "key", + "type": "text(16384)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "private_key": { + "name": "private_key", + "type": "text(16384)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "public_key": { + "name": "public_key", + "type": "text(4096)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "key_password": { + "name": "key_password", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "key_type": { + "name": "key_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "detected_key_type": { + "name": "detected_key_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cert_public_key": { + "name": "cert_public_key", + "type": "text(8192)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "usage_count": { + "name": "usage_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "last_used": { + "name": "last_used", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sync_id": { + "name": "sync_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "ssh_credentials_sync_id_unique": { + "name": "ssh_credentials_sync_id_unique", + "columns": [ + "sync_id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "ssh_credentials_user_id_users_id_fk": { + "name": "ssh_credentials_user_id_users_id_fk", + "tableFrom": "ssh_credentials", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "ssh_folders": { + "name": "ssh_folders", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "icon": { + "name": "icon", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "credential_id": { + "name": "credential_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sync_id": { + "name": "sync_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "ssh_folders_sync_id_unique": { + "name": "ssh_folders_sync_id_unique", + "columns": [ + "sync_id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "ssh_folders_user_id_users_id_fk": { + "name": "ssh_folders_user_id_users_id_fk", + "tableFrom": "ssh_folders", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "ssh_folders_credential_id_ssh_credentials_id_fk": { + "name": "ssh_folders_credential_id_ssh_credentials_id_fk", + "tableFrom": "ssh_folders", + "tableTo": "ssh_credentials", + "columnsFrom": [ + "credential_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "sso_providers": { + "name": "sso_providers", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "enabled": { + "name": "enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "display_order": { + "name": "display_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "config": { + "name": "config", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "sync_tombstones": { + "name": "sync_tombstones", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "entity_type": { + "name": "entity_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "sync_id": { + "name": "sync_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": {}, + "foreignKeys": { + "sync_tombstones_user_id_users_id_fk": { + "name": "sync_tombstones_user_id_users_id_fk", + "tableFrom": "sync_tombstones", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "termix_identities": { + "name": "termix_identities", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "handle": { + "name": "handle", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "termix_identities_user_id_unique": { + "name": "termix_identities_user_id_unique", + "columns": [ + "user_id" + ], + "isUnique": true + }, + "termix_identities_handle_unique": { + "name": "termix_identities_handle_unique", + "columns": [ + "handle" + ], + "isUnique": true + } + }, + "foreignKeys": { + "termix_identities_user_id_users_id_fk": { + "name": "termix_identities_user_id_users_id_fk", + "tableFrom": "termix_identities", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "termix_identity_ca": { + "name": "termix_identity_ca", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "identity_id": { + "name": "identity_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "public_key": { + "name": "public_key", + "type": "text(4096)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "private_key": { + "name": "private_key", + "type": "text(8192)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "validity_days": { + "name": "validity_days", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 90 + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "termix_identity_ca_identity_id_unique": { + "name": "termix_identity_ca_identity_id_unique", + "columns": [ + "identity_id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "termix_identity_ca_identity_id_termix_identities_id_fk": { + "name": "termix_identity_ca_identity_id_termix_identities_id_fk", + "tableFrom": "termix_identity_ca", + "tableTo": "termix_identities", + "columnsFrom": [ + "identity_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "termix_identity_ca_user_id_users_id_fk": { + "name": "termix_identity_ca_user_id_users_id_fk", + "tableFrom": "termix_identity_ca", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "termix_identity_keys": { + "name": "termix_identity_keys", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "identity_id": { + "name": "identity_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "public_key": { + "name": "public_key", + "type": "text(8192)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "key_type": { + "name": "key_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "algorithm": { + "name": "algorithm", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "comment": { + "name": "comment", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'manual'" + }, + "credential_id": { + "name": "credential_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "enabled": { + "name": "enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": {}, + "foreignKeys": { + "termix_identity_keys_identity_id_termix_identities_id_fk": { + "name": "termix_identity_keys_identity_id_termix_identities_id_fk", + "tableFrom": "termix_identity_keys", + "tableTo": "termix_identities", + "columnsFrom": [ + "identity_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "termix_identity_keys_user_id_users_id_fk": { + "name": "termix_identity_keys_user_id_users_id_fk", + "tableFrom": "termix_identity_keys", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "termix_identity_keys_credential_id_ssh_credentials_id_fk": { + "name": "termix_identity_keys_credential_id_ssh_credentials_id_fk", + "tableFrom": "termix_identity_keys", + "tableTo": "ssh_credentials", + "columnsFrom": [ + "credential_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "tmux_session_tags": { + "name": "tmux_session_tags", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "host_id": { + "name": "host_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "session_name": { + "name": "session_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "tag": { + "name": "tag", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": {}, + "foreignKeys": { + "tmux_session_tags_user_id_users_id_fk": { + "name": "tmux_session_tags_user_id_users_id_fk", + "tableFrom": "tmux_session_tags", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "tmux_session_tags_host_id_ssh_data_id_fk": { + "name": "tmux_session_tags_host_id_ssh_data_id_fk", + "tableFrom": "tmux_session_tags", + "tableTo": "ssh_data", + "columnsFrom": [ + "host_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "transfer_recent": { + "name": "transfer_recent", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "source_host_id": { + "name": "source_host_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "dest_host_id": { + "name": "dest_host_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "dest_path": { + "name": "dest_path", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "dest_path_label": { + "name": "dest_path_label", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_used": { + "name": "last_used", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": {}, + "foreignKeys": { + "transfer_recent_user_id_users_id_fk": { + "name": "transfer_recent_user_id_users_id_fk", + "tableFrom": "transfer_recent", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "transfer_recent_source_host_id_ssh_data_id_fk": { + "name": "transfer_recent_source_host_id_ssh_data_id_fk", + "tableFrom": "transfer_recent", + "tableTo": "ssh_data", + "columnsFrom": [ + "source_host_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "transfer_recent_dest_host_id_ssh_data_id_fk": { + "name": "transfer_recent_dest_host_id_ssh_data_id_fk", + "tableFrom": "transfer_recent", + "tableTo": "ssh_data", + "columnsFrom": [ + "dest_host_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "trusted_devices": { + "name": "trusted_devices", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "device_fingerprint": { + "name": "device_fingerprint", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "device_type": { + "name": "device_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "device_info": { + "name": "device_info", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "expires_at": { + "name": "expires_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_used_at": { + "name": "last_used_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": {}, + "foreignKeys": { + "trusted_devices_user_id_users_id_fk": { + "name": "trusted_devices_user_id_users_id_fk", + "tableFrom": "trusted_devices", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "user_open_tabs": { + "name": "user_open_tabs", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "tab_type": { + "name": "tab_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "host_id": { + "name": "host_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "tab_order": { + "name": "tab_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "backend_session_id": { + "name": "backend_session_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": {}, + "foreignKeys": { + "user_open_tabs_user_id_users_id_fk": { + "name": "user_open_tabs_user_id_users_id_fk", + "tableFrom": "user_open_tabs", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_open_tabs_host_id_ssh_data_id_fk": { + "name": "user_open_tabs_host_id_ssh_data_id_fk", + "tableFrom": "user_open_tabs", + "tableTo": "ssh_data", + "columnsFrom": [ + "host_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "user_preferences": { + "name": "user_preferences", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "reopen_tabs_on_login": { + "name": "reopen_tabs_on_login", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "theme": { + "name": "theme", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "font_size": { + "name": "font_size", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "accent_color": { + "name": "accent_color", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "language": { + "name": "language", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "storage_mode": { + "name": "storage_mode", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "command_autocomplete": { + "name": "command_autocomplete", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "command_palette_enabled": { + "name": "command_palette_enabled", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "show_host_tags": { + "name": "show_host_tags", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "host_tray_on_click": { + "name": "host_tray_on_click", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "pin_app_rail": { + "name": "pin_app_rail", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "expand_app_rail_on_hover": { + "name": "expand_app_rail_on_hover", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "folders_collapsed": { + "name": "folders_collapsed", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "confirm_snippet_execution": { + "name": "confirm_snippet_execution", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "disable_update_check": { + "name": "disable_update_check", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "confirm_tab_close": { + "name": "confirm_tab_close", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "hidden_rail_tabs": { + "name": "hidden_rail_tabs", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "compact_host_view": { + "name": "compact_host_view", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status_color_scheme": { + "name": "status_color_scheme", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "custom_themes": { + "name": "custom_themes", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "custom_keybindings": { + "name": "custom_keybindings", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": {}, + "foreignKeys": { + "user_preferences_user_id_users_id_fk": { + "name": "user_preferences_user_id_users_id_fk", + "tableFrom": "user_preferences", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "user_roles": { + "name": "user_roles", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "role_id": { + "name": "role_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "granted_by": { + "name": "granted_by", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "granted_at": { + "name": "granted_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "idx_user_roles_user_role": { + "name": "idx_user_roles_user_role", + "columns": [ + "user_id", + "role_id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "user_roles_user_id_users_id_fk": { + "name": "user_roles_user_id_users_id_fk", + "tableFrom": "user_roles", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_roles_role_id_roles_id_fk": { + "name": "user_roles_role_id_roles_id_fk", + "tableFrom": "user_roles", + "tableTo": "roles", + "columnsFrom": [ + "role_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_roles_granted_by_users_id_fk": { + "name": "user_roles_granted_by_users_id_fk", + "tableFrom": "user_roles", + "tableTo": "users", + "columnsFrom": [ + "granted_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "users": { + "name": "users", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "password_hash": { + "name": "password_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "is_admin": { + "name": "is_admin", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "is_oidc": { + "name": "is_oidc", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "oidc_identifier": { + "name": "oidc_identifier", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sso_provider_id": { + "name": "sso_provider_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "client_secret": { + "name": "client_secret", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "issuer_url": { + "name": "issuer_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "authorization_url": { + "name": "authorization_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "token_url": { + "name": "token_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "identifier_path": { + "name": "identifier_path", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "name_path": { + "name": "name_path", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "scopes": { + "name": "scopes", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'openid email profile'" + }, + "totp_secret": { + "name": "totp_secret", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "totp_enabled": { + "name": "totp_enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "totp_backup_codes": { + "name": "totp_backup_codes", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "registered_at": { + "name": "registered_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "donation_modal_dismissed": { + "name": "donation_modal_dismissed", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "vault_profiles": { + "name": "vault_profiles", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "folder": { + "name": "folder", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "tags": { + "name": "tags", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "vault_addr": { + "name": "vault_addr", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "vault_namespace": { + "name": "vault_namespace", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "oidc_mount": { + "name": "oidc_mount", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "oidc_role": { + "name": "oidc_role", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "ssh_mount": { + "name": "ssh_mount", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "ssh_role": { + "name": "ssh_role", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "valid_principals": { + "name": "valid_principals", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "key_type": { + "name": "key_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "shared": { + "name": "shared", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "sync_id": { + "name": "sync_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "vault_profiles_sync_id_unique": { + "name": "vault_profiles_sync_id_unique", + "columns": [ + "sync_id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "vault_profiles_user_id_users_id_fk": { + "name": "vault_profiles_user_id_users_id_fk", + "tableFrom": "vault_profiles", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "vault_tokens": { + "name": "vault_tokens", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "profile_id": { + "name": "profile_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ssh_cert": { + "name": "ssh_cert", + "type": "text(8192)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "private_key": { + "name": "private_key", + "type": "text(8192)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "expires_at": { + "name": "expires_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_used": { + "name": "last_used", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "idx_vault_tokens_user_profile": { + "name": "idx_vault_tokens_user_profile", + "columns": [ + "user_id", + "profile_id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "vault_tokens_user_id_users_id_fk": { + "name": "vault_tokens_user_id_users_id_fk", + "tableFrom": "vault_tokens", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "vault_tokens_profile_id_vault_profiles_id_fk": { + "name": "vault_tokens_profile_id_vault_profiles_id_fk", + "tableFrom": "vault_tokens", + "tableTo": "vault_profiles", + "columnsFrom": [ + "profile_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "webauthn_credentials": { + "name": "webauthn_credentials", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "credential_id": { + "name": "credential_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "public_key": { + "name": "public_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "counter": { + "name": "counter", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "device_type": { + "name": "device_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "backed_up": { + "name": "backed_up", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "transports": { + "name": "transports", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "user_verification": { + "name": "user_verification", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'preferred'" + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "last_used_at": { + "name": "last_used_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "webauthn_credentials_user_id_users_id_fk": { + "name": "webauthn_credentials_user_id_users_id_fk", + "tableFrom": "webauthn_credentials", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} \ No newline at end of file diff --git a/drizzle/sqlite/meta/_journal.json b/drizzle/sqlite/meta/_journal.json new file mode 100644 index 00000000..3ee197c3 --- /dev/null +++ b/drizzle/sqlite/meta/_journal.json @@ -0,0 +1,13 @@ +{ + "version": "7", + "dialect": "sqlite", + "entries": [ + { + "idx": 0, + "version": "6", + "when": 1785738870735, + "tag": "0000_clever_hercules", + "breakpoints": true + } + ] +} \ No newline at end of file diff --git a/electron/backend-paths.cjs b/electron/backend-paths.cjs new file mode 100644 index 00000000..b99791ab --- /dev/null +++ b/electron/backend-paths.cjs @@ -0,0 +1,8 @@ +function getUnpackedAppRoot(appRoot) { + return appRoot.replace( + /app(-[a-z0-9]+)?\.asar(?!\.unpacked)/, + "app$1.asar.unpacked", + ); +} + +module.exports = { getUnpackedAppRoot }; diff --git a/electron/main.cjs b/electron/main.cjs index 2e20608e..6e9c4082 100644 --- a/electron/main.cjs +++ b/electron/main.cjs @@ -12,6 +12,7 @@ const { nativeImage, } = require("electron"); const path = require("path"); +const { getUnpackedAppRoot } = require("./backend-paths.cjs"); const fs = require("fs"); const os = require("os"); const https = require("https"); @@ -800,10 +801,7 @@ function getBackendPaths() { // fork() does not go through Electron's asar redirector — use the unpacked path. // On macOS multi-arch builds (mergeASARs: false), electron-builder names the ASAR // app-arm64.asar / app-x64.asar instead of app.asar, so match all variants. - const unpackedRoot = appRoot.replace( - /app(-[a-z0-9]+)?\.asar(?!\.unpacked)/, - "app.asar.unpacked", - ); + const unpackedRoot = getUnpackedAppRoot(appRoot); const backendDir = path.join(unpackedRoot, "dist", "backend", "backend"); return { entryPath: path.join(backendDir, "starter.js"), @@ -1581,6 +1579,10 @@ ipcMain.handle("get-remote-sync-status", () => { return remoteSync.getRemoteSyncEngine()?.status || null; }); +ipcMain.handle("get-remote-sync-user-info", () => { + return remoteSync.getRemoteSyncUserInfo(); +}); + ipcMain.handle("remote-sync-now", async () => { return (await remoteSync.getRemoteSyncEngine()?.syncNow()) || null; }); diff --git a/electron/remote-sync-entities.cjs b/electron/remote-sync-entities.cjs new file mode 100644 index 00000000..a60ce8c4 --- /dev/null +++ b/electron/remote-sync-entities.cjs @@ -0,0 +1,15 @@ +const SYNCED_ENTITY_TYPES = Object.freeze([ + // Ordered by reference dependency: hosts and snippets resolve credential, + // vault and folder syncIds, so those have to exist on the other side first. + "sshCredentials", + "vaultProfiles", + "sshFolders", + "snippetFolders", + "hosts", + "snippets", + "dashboardServiceLinks", + "homepageItems", + "userPreferences", +]); + +module.exports = { SYNCED_ENTITY_TYPES }; diff --git a/electron/remote-sync.cjs b/electron/remote-sync.cjs index b34dd037..26ed9aca 100644 --- a/electron/remote-sync.cjs +++ b/electron/remote-sync.cjs @@ -14,17 +14,7 @@ const { app, safeStorage } = require("electron"); const fs = require("fs"); const path = require("path"); - -const SYNCED_ENTITY_TYPES = [ - "hosts", - "sshCredentials", - "sshFolders", - "snippets", - "snippetFolders", - "vaultProfiles", - "dashboardServiceLinks", - "homepageItems", -]; +const { SYNCED_ENTITY_TYPES } = require("./remote-sync-entities.cjs"); const SYNC_INTERVAL_MS = 90 * 1000; const EMBEDDED_BASE_URL = "http://127.0.0.1:30001"; @@ -135,6 +125,41 @@ function clearRemoteSyncJwt() { return { success: true }; } +async function getRemoteSyncUserInfo() { + const config = getRemoteSyncConfig(); + const token = getRemoteSyncJwt(); + if (!config?.serverUrl || !token || isJwtExpiredOrExpiringSoon(token)) { + return null; + } + + const baseUrl = config.serverUrl.replace(/\/$/, ""); + const userResponse = await fetch(`${baseUrl}/users/me`, { + headers: { Authorization: `Bearer ${token}`, "X-Electron-App": "true" }, + }); + if (!userResponse.ok) return null; + + const user = await userResponse.json(); + const rolesResponse = await fetch( + `${baseUrl}/rbac/users/${encodeURIComponent(user.userId)}/roles`, + { + headers: { Authorization: `Bearer ${token}`, "X-Electron-App": "true" }, + }, + ); + const roles = rolesResponse.ok + ? (await rolesResponse.json()).roles || [] + : []; + + return { + userId: user.userId, + username: user.username, + is_admin: !!user.is_admin, + is_oidc: !!user.is_oidc, + is_dual_auth: !!user.is_dual_auth, + totp_enabled: !!user.totp_enabled, + roles, + }; +} + function decodeJwtExpiry(token) { try { const payloadB64 = token.split(".")[1]; @@ -373,6 +398,15 @@ class RemoteSyncEngine { return data.rows || []; } + /** + * Every syncId a side currently holds, ignoring the incremental window. + * Used only to decide whether a deletion still has something to delete. + */ + async pullSyncIds(baseUrl, token, entityType) { + const rows = await this.pullSide(baseUrl, token, entityType, null); + return new Set(rows.filter((row) => row.syncId).map((row) => row.syncId)); + } + async pullTombstones(baseUrl, token, entityType, since) { const url = `${baseUrl}/sync/${entityType}/tombstones${since ? `?since=${encodeURIComponent(since)}` : ""}`; const data = await this.fetchJson(url, token); @@ -457,24 +491,46 @@ class RemoteSyncEngine { } // Apply tombstones to whichever side hasn't already deleted the row. - for (const tombstone of localTombstones) { - if (remoteBySyncId.has(tombstone.syncId)) { - await this.pushTombstone( - remoteBaseUrl, - remoteJwt, - entityType, - tombstone.syncId, - ); + // + // The presence check cannot use localRows/remoteRows: those are the + // incremental window, and a row deleted on one side while untouched on + // the other is by definition outside it, so every deletion was dropped. + // It also cannot be skipped -- pushing unconditionally makes the + // receiving side record a fresh tombstone, which the next pass would push + // back, forever. So ask the receiving side what it actually still holds, + // and only when there is a deletion to apply. + if (localTombstones.length) { + const remoteSyncIds = await this.pullSyncIds( + remoteBaseUrl, + remoteJwt, + entityType, + ); + for (const tombstone of localTombstones) { + if (remoteSyncIds.has(tombstone.syncId)) { + await this.pushTombstone( + remoteBaseUrl, + remoteJwt, + entityType, + tombstone.syncId, + ); + } } } - for (const tombstone of remoteTombstones) { - if (localBySyncId.has(tombstone.syncId)) { - await this.pushTombstone( - EMBEDDED_BASE_URL, - this.localJwt, - entityType, - tombstone.syncId, - ); + if (remoteTombstones.length) { + const localSyncIds = await this.pullSyncIds( + EMBEDDED_BASE_URL, + this.localJwt, + entityType, + ); + for (const tombstone of remoteTombstones) { + if (localSyncIds.has(tombstone.syncId)) { + await this.pushTombstone( + EMBEDDED_BASE_URL, + this.localJwt, + entityType, + tombstone.syncId, + ); + } } } @@ -511,6 +567,7 @@ module.exports = { saveRemoteSyncJwt, getRemoteSyncJwt, clearRemoteSyncJwt, + getRemoteSyncUserInfo, isJwtExpiredOrExpiringSoon, decodeJwtExpiry, }; diff --git a/eslint.config.mjs b/eslint.config.mjs index ee87445c..78798841 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -46,4 +46,57 @@ export default tseslint.config([ "react-refresh/only-export-components": "warn", }, }, + { + // MySQL has no RETURNING clause, and drizzle's mysql-core does not expose + // the method at all — a bare .returning() is a TypeError there, not a bad + // query, and it only fails on the engine no test in this repo runs against. + // + // 175 call sites were migrated off it. This is what stops number 176. + // Writes that need rows back go through repositories/returning.ts, which + // picks one statement or a read-then-write transaction per dialect. + files: ["src/backend/database/repositories/**/*.ts"], + ignores: [ + // The two files whose job is to absorb these differences. + "src/backend/database/repositories/returning.ts", + "src/backend/database/repositories/mutation-result.ts", + ], + rules: { + "no-restricted-syntax": [ + "error", + { + selector: "CallExpression[callee.property.name='returning']", + message: + "MySQL has no RETURNING. Use insertReturning/updateReturning/deleteReturning from ./returning.js, or rowsAffected() if you only need a count. Inside a proven sqlite-only branch, disable this rule with a comment saying so.", + }, + { + // `||` concatenates on SQLite and Postgres. On MySQL it is logical OR + // unless the server runs with PIPES_AS_CONCAT, so a folder path built + // this way silently became 0. Use CONCAT, which all three agree on. + selector: + "TaggedTemplateExpression[tag.name='sql'] TemplateElement[value.raw=/\\|\\|/]", + message: + "`||` is logical OR on MySQL, not concatenation. Use CONCAT(...).", + }, + { + // Postgres and SQLite spell it ON CONFLICT; MySQL spells it ON + // DUPLICATE KEY and names no columns, so drizzle's mysql-core has no + // onConflictDoUpdate at all — another TypeError, not a bad query. + selector: "CallExpression[callee.property.name='onConflictDoUpdate']", + message: + "MySQL has no ON CONFLICT. Use upsert() from ./returning.js.", + }, + { + // better-sqlite3 puts these on a write result; node-postgres and + // mysql2 do not, so reading them directly yields undefined — and + // Number(undefined) is NaN, which reaches the database as the string + // "NaN" and fails an integer column. Three call sites did exactly + // this and only broke on Postgres. + selector: + "MemberExpression[property.name=/^(lastInsertRowid|changes)$/]", + message: + "lastInsertRowid and changes are better-sqlite3 only. Use insertedId() or rowsAffected() from ./mutation-result.js.", + }, + ], + }, + }, ]); diff --git a/package-lock.json b/package-lock.json index 691a37fb..f6fc24b4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,72 +11,74 @@ "dependencies": { "@simplewebauthn/browser": "^13.3.0", "@simplewebauthn/server": "^13.3.2", - "@tanstack/react-virtual": "^3.14.6", + "@tanstack/react-virtual": "^3.14.9", "@types/ldapjs": "^3.0.6", - "axios": "^1.18.1", + "axios": "^1.19.0", "bcryptjs": "^3.0.3", - "better-sqlite3": "^12.11.1", + "better-sqlite3": "^13.0.2", "body-parser": "^2.3.0", - "chalk": "^5.6.2", + "chalk": "^6.0.0", "cookie-parser": "^1.4.7", "cors": "^2.8.6", "dotenv": "^17.4.2", "drizzle-orm": "^0.45.2", "express": "^5.2.1", "guacamole-lite": "^1.2.0", - "jose": "^6.2.2", - "js-yaml": "^5.2.1", + "jose": "^6.2.5", + "js-yaml": "^5.2.2", "jsonwebtoken": "^9.0.3", "jszip": "^3.10.1", "ldapjs": "^3.0.7", - "motion": "^12.42.2", + "motion": "^12.43.0", "multer": "^2.2.0", + "mysql2": "^3.23.2", "nanoid": "^6.0.0", + "pg": "^8.22.0", "qrcode": "^1.5.4", "serialport": "^13.0.0", "socks": "^2.8.7", "speakeasy": "^2.0.0", "ssh2": "^1.17.0", - "undici": "^8.7.0", + "undici": "^8.9.0", "ws": "^8.21.1" }, "devDependencies": { - "@biomejs/biome": "2.5.4", + "@biomejs/biome": "2.5.6", "@codemirror/autocomplete": "^6.20.3", "@codemirror/commands": "^6.10.4", "@codemirror/search": "^6.7.1", "@codemirror/theme-one-dark": "^6.1.3", - "@codemirror/view": "^6.43.6", - "@commitlint/cli": "^21.0.2", - "@commitlint/config-conventional": "^21.0.2", + "@codemirror/view": "^6.43.7", + "@commitlint/cli": "^21.2.1", + "@commitlint/config-conventional": "^21.2.0", "@deadendjs/swagger-jsdoc": "^8.1.2", "@electron/notarize": "^3.1.1", - "@electron/rebuild": "^4.0.4", + "@electron/rebuild": "^4.2.0", "@eslint/js": "^10.0.1", - "@fontsource-variable/jetbrains-mono": "^5.2.8", - "@fontsource/fira-code": "^5.2.7", - "@fontsource/jetbrains-mono": "^5.2.8", - "@fontsource/source-code-pro": "^5.2.7", + "@fontsource-variable/jetbrains-mono": "^5.3.0", + "@fontsource/fira-code": "^5.3.0", + "@fontsource/jetbrains-mono": "^5.3.0", + "@fontsource/source-code-pro": "^5.3.0", "@monaco-editor/react": "^4.7.0", - "@radix-ui/react-accordion": "^1.2.17", - "@radix-ui/react-alert-dialog": "^1.1.20", - "@radix-ui/react-checkbox": "^1.3.8", - "@radix-ui/react-dialog": "^1.1.20", - "@radix-ui/react-dropdown-menu": "^2.1.21", - "@radix-ui/react-label": "^2.1.12", - "@radix-ui/react-popover": "^1.1.20", - "@radix-ui/react-progress": "^1.1.13", - "@radix-ui/react-scroll-area": "^1.2.15", - "@radix-ui/react-select": "^2.3.4", - "@radix-ui/react-separator": "^1.1.12", - "@radix-ui/react-slider": "^1.4.4", - "@radix-ui/react-slot": "^1.3.0", - "@radix-ui/react-switch": "^1.3.4", - "@radix-ui/react-tabs": "^1.1.18", - "@radix-ui/react-tooltip": "^1.2.13", + "@radix-ui/react-accordion": "^1.2.20", + "@radix-ui/react-alert-dialog": "^1.1.23", + "@radix-ui/react-checkbox": "^1.3.11", + "@radix-ui/react-dialog": "^1.1.23", + "@radix-ui/react-dropdown-menu": "^2.1.24", + "@radix-ui/react-label": "^2.1.15", + "@radix-ui/react-popover": "^1.1.23", + "@radix-ui/react-progress": "^1.1.16", + "@radix-ui/react-scroll-area": "^1.2.18", + "@radix-ui/react-select": "^2.3.7", + "@radix-ui/react-separator": "^1.1.15", + "@radix-ui/react-slider": "^1.4.7", + "@radix-ui/react-slot": "^1.3.3", + "@radix-ui/react-switch": "^1.3.7", + "@radix-ui/react-tabs": "^1.1.21", + "@radix-ui/react-tooltip": "^1.2.16", "@tailwindcss/vite": "^4.3.3", "@testing-library/dom": "^10.4.1", - "@testing-library/jest-dom": "^6.9.1", + "@testing-library/jest-dom": "^7.0.0", "@testing-library/react": "^16.3.2", "@testing-library/user-event": "^14.6.1", "@types/better-sqlite3": "^7.6.13", @@ -86,52 +88,55 @@ "@types/guacamole-common-js": "^1.5.5", "@types/js-yaml": "^4.0.9", "@types/jsonwebtoken": "^9.0.10", - "@types/multer": "^2.1.0", - "@types/node": "^26.0.0", + "@types/multer": "^2.2.0", + "@types/node": "^26.1.2", + "@types/pg": "^8.20.0", "@types/qrcode": "^1.5.6", - "@types/react": "^19.2.17", - "@types/react-dom": "^19.2.3", + "@types/react": "^19.2.18", + "@types/react-dom": "^19.2.4", "@types/speakeasy": "^2.0.10", "@types/ssh2": "^1.15.5", "@types/ws": "^8.18.1", "@uiw/codemirror-extensions-langs": "^4.25.11", "@uiw/codemirror-theme-github": "^4.25.11", "@uiw/react-codemirror": "^4.25.11", - "@vitejs/plugin-react": "^6.0.3", + "@vitejs/plugin-react": "^6.0.5", "@vitest/coverage-v8": "^4.1.10", "@vitest/ui": "^4.1.10", "@xterm/addon-clipboard": "^0.2.0", "@xterm/addon-fit": "^0.11.0", + "@xterm/addon-search": "^0.16.0", "@xterm/addon-unicode11": "^0.9.0", "@xterm/addon-web-links": "^0.12.0", "@xterm/xterm": "^6.0.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cmdk": "^1.1.1", - "concurrently": "^10.0.3", + "concurrently": "^10.0.4", "cytoscape": "^3.34.0", - "electron": "^43.0.0", + "drizzle-kit": "^0.31.10", + "electron": "^43.2.0", "electron-builder": "^26.15.3", - "eslint": "^10.5.0", + "eslint": "^10.8.0", "eslint-plugin-react-hooks": "^7.1.1", "eslint-plugin-react-refresh": "^0.5.3", "eslint-plugin-unused-imports": "^4.4.1", - "globals": "^17.5.0", + "globals": "^17.8.0", "guacamole-common-js": "^1.5.0", "husky": "^9.1.7", "i18next": "^26.3.6", "i18next-browser-languagedetector": "^8.2.1", "jsdom": "^29.1.1", - "lint-staged": "^17.0.8", - "lucide-react": "^1.20.0", - "prettier": "3.8.4", - "radix-ui": "^1.6.3", - "react": "^19.2.7", + "lint-staged": "^17.2.0", + "lucide-react": "^1.28.0", + "prettier": "3.9.6", + "radix-ui": "^1.6.7", + "react": "^19.2.8", "react-cytoscapejs": "^2.0.0", - "react-dom": "^19.2.7", + "react-dom": "^19.2.8", "react-h5-audio-player": "^3.10.2", "react-hook-form": "^7.79.0", - "react-i18next": "^17.0.10", + "react-i18next": "^17.0.11", "react-icons": "^5.6.0", "react-markdown": "^10.1.0", "react-pdf": "^10.4.1", @@ -569,9 +574,9 @@ } }, "node_modules/@biomejs/biome": { - "version": "2.5.4", - "resolved": "https://registry.npmjs.org/@biomejs/biome/-/biome-2.5.4.tgz", - "integrity": "sha512-xy5FNE5kQJKyK5MR1gJy6ztXYx4WBAbYGlK04lMEgmyPRWKybY9NFwiG9yo0XdzOU8Xvhj41u034J1ywfoWfMw==", + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@biomejs/biome/-/biome-2.5.6.tgz", + "integrity": "sha512-lxVNjv7UF6KfhMJfL9gaUHbWdJdHbsAj6OSmwSYNdhRuG67NxNQ4Xdvh3TUxsSK9sBzJBQhEJj3AopmmNJ5pSA==", "dev": true, "license": "MIT OR Apache-2.0", "bin": { @@ -585,20 +590,20 @@ "url": "https://opencollective.com/biome" }, "optionalDependencies": { - "@biomejs/cli-darwin-arm64": "2.5.4", - "@biomejs/cli-darwin-x64": "2.5.4", - "@biomejs/cli-linux-arm64": "2.5.4", - "@biomejs/cli-linux-arm64-musl": "2.5.4", - "@biomejs/cli-linux-x64": "2.5.4", - "@biomejs/cli-linux-x64-musl": "2.5.4", - "@biomejs/cli-win32-arm64": "2.5.4", - "@biomejs/cli-win32-x64": "2.5.4" + "@biomejs/cli-darwin-arm64": "2.5.6", + "@biomejs/cli-darwin-x64": "2.5.6", + "@biomejs/cli-linux-arm64": "2.5.6", + "@biomejs/cli-linux-arm64-musl": "2.5.6", + "@biomejs/cli-linux-x64": "2.5.6", + "@biomejs/cli-linux-x64-musl": "2.5.6", + "@biomejs/cli-win32-arm64": "2.5.6", + "@biomejs/cli-win32-x64": "2.5.6" } }, "node_modules/@biomejs/cli-darwin-arm64": { - "version": "2.5.4", - "resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-arm64/-/cli-darwin-arm64-2.5.4.tgz", - "integrity": "sha512-4o3NFRobXHynkgcFVrlZsoDAFtF2ldlEGN8sORSws5ZQqyY4PXnPUIylu4ksfyHuwkfvDREuWh3JK+niRwGq3w==", + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-arm64/-/cli-darwin-arm64-2.5.6.tgz", + "integrity": "sha512-zMOLZP4oMrjh6m1zcSj1ud2awUPgTuMVbmQhYYWL7J8HwCnbHHBvTm7VBTRuY7epT5bez76IpKYQ11ZAqHFlnw==", "cpu": [ "arm64" ], @@ -613,9 +618,9 @@ } }, "node_modules/@biomejs/cli-darwin-x64": { - "version": "2.5.4", - "resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-x64/-/cli-darwin-x64-2.5.4.tgz", - "integrity": "sha512-D32P5HkU2Y6PySuC/WsVDTOgsDwVFmujzhhhOQjajtATpVWFDXuVd3oRbsWNSEA+aaFzyzZm22szsyydBYlSyQ==", + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-x64/-/cli-darwin-x64-2.5.6.tgz", + "integrity": "sha512-JAC1VqzvO7Th5ZplU0G2uGfkZbxEe9uDDektPAhF0JLusoz1w+T4okp2bkykI0bbaO2vslKiRfj4gU43JaGreA==", "cpu": [ "x64" ], @@ -630,13 +635,16 @@ } }, "node_modules/@biomejs/cli-linux-arm64": { - "version": "2.5.4", - "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64/-/cli-linux-arm64-2.5.4.tgz", - "integrity": "sha512-pSEfW7B8kTsXUjUxC1xVVK+y85Ht3C5XxZ9gclmC7/3Ku9Vqz8jmI7k0p/BNIjQ6t4sFERI2sFeH73ybiZl6YQ==", + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64/-/cli-linux-arm64-2.5.6.tgz", + "integrity": "sha512-6XsYwCFkp5sMxl85ffhgeGpGgs6A7dRYFnkceZ7WVxvycuTnGdD5xa534Z3xfrBQ0JCMK/mujT6ZNPJoghedwg==", "cpu": [ "arm64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT OR Apache-2.0", "optional": true, "os": [ @@ -647,13 +655,16 @@ } }, "node_modules/@biomejs/cli-linux-arm64-musl": { - "version": "2.5.4", - "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.5.4.tgz", - "integrity": "sha512-Rpm5/AT1m+DlJmUoYvS4/vXc+0tXJPJ2NQz25TGPyHVF5JrWy75PE0GH6kVxsKtQDuCH4OgzquZq0R4kj/wCVg==", + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.5.6.tgz", + "integrity": "sha512-eUa3jeeYvfMt19LBeh6E5PUZpxnTC4JqNWo+EDjTtQjAr2xLGnWaxACtVU1DQqmHYbvThlJzLX+ZsYgrqh2qVw==", "cpu": [ "arm64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT OR Apache-2.0", "optional": true, "os": [ @@ -664,13 +675,16 @@ } }, "node_modules/@biomejs/cli-linux-x64": { - "version": "2.5.4", - "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64/-/cli-linux-x64-2.5.4.tgz", - "integrity": "sha512-FNxojWJkL7EajAuzBgoLe0T2G0y112M4lBrDIFl/DomFTx8yqenYOIdsRLNXvOvBBofE8hJi85LjzLmBDpY7/Q==", + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64/-/cli-linux-x64-2.5.6.tgz", + "integrity": "sha512-Pop9VXCFUhFTMfFefZ39S+u2rOPyNp5iHlxbZRwXGACHLy2r0jjiRgJHmaEKJzL3SyxlVeGShXhvvElvWowonA==", "cpu": [ "x64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT OR Apache-2.0", "optional": true, "os": [ @@ -681,13 +695,16 @@ } }, "node_modules/@biomejs/cli-linux-x64-musl": { - "version": "2.5.4", - "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64-musl/-/cli-linux-x64-musl-2.5.4.tgz", - "integrity": "sha512-aby/PohmmgbShcHqFsZVzG8H6D98+P+A6xRWRrQcLW1pCjabcov5UUlke4UqNQBYTkDQav+jB4zyyDDeKB2GaA==", + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64-musl/-/cli-linux-x64-musl-2.5.6.tgz", + "integrity": "sha512-2Vp13QdKysH3HIWLaYLhUUwbK+jbZonJD1K+Lr0d0RO4wH7mkYd43vJixEDm8cUWrowoRz4UUHF1nm9Ae7ym8A==", "cpu": [ "x64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT OR Apache-2.0", "optional": true, "os": [ @@ -698,9 +715,9 @@ } }, "node_modules/@biomejs/cli-win32-arm64": { - "version": "2.5.4", - "resolved": "https://registry.npmjs.org/@biomejs/cli-win32-arm64/-/cli-win32-arm64-2.5.4.tgz", - "integrity": "sha512-emoXexPZIPAZkz2RKmA95WJUqK3I5MJNYtwEbL5ESciRzhmFMMyekDhNG8hpeOaK+ZGRDxAU4wvGuA5IHQ0h0w==", + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@biomejs/cli-win32-arm64/-/cli-win32-arm64-2.5.6.tgz", + "integrity": "sha512-tDGshcm6BdkZOCGnTDX0Y8/U4IfBSlnUU7T56nNDuPEfed+aHg+u8G36NB43fJVl0Os6+QURXIE1yuD7AaEofA==", "cpu": [ "arm64" ], @@ -715,9 +732,9 @@ } }, "node_modules/@biomejs/cli-win32-x64": { - "version": "2.5.4", - "resolved": "https://registry.npmjs.org/@biomejs/cli-win32-x64/-/cli-win32-x64-2.5.4.tgz", - "integrity": "sha512-U1jaluLw1qQc2Tx7/CeSoL9N5XcqIH+GWjpUAy1ouB5nVjSCMNO+NNHdY3RAs8zxNurLWAdj6pehQdCA2zyU+Q==", + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@biomejs/cli-win32-x64/-/cli-win32-x64-2.5.6.tgz", + "integrity": "sha512-WN05KwXnTO/2J45RQPvzZMXf7tZUIofHoR35xIPfCo7pQ2RFidxI8sfb5mGsaTxdMmEOzHzOPRCdA5/fCpc7xQ==", "cpu": [ "x64" ], @@ -1129,9 +1146,9 @@ } }, "node_modules/@codemirror/view": { - "version": "6.43.6", - "resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.43.6.tgz", - "integrity": "sha512-EVunGSYN1wz1p75WY1s3Xg7t3i8Yol0kGZGizNdX9BUFgMFILYVe8/u6EVpo7Ff5PwbZuILb4QAq7IZoKzIEQA==", + "version": "6.43.7", + "resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.43.7.tgz", + "integrity": "sha512-FZsExxkoxnAN+d9TgqXLg5g4A1oQwzX9WlkOT5i2PKkcW7xx3Bmu0vs90g6fo9Mpdsb/l96dnAraQ8932aO4/g==", "dev": true, "license": "MIT", "dependencies": { @@ -1142,17 +1159,18 @@ } }, "node_modules/@commitlint/cli": { - "version": "21.0.2", - "resolved": "https://registry.npmjs.org/@commitlint/cli/-/cli-21.0.2.tgz", - "integrity": "sha512-YMmfLbqBg+ZRvvmPhc+cilSQFrh/AgzVgCT1U/OifmUZEwPbvCtA8rN//YNaF9d5eoZphxVMGYtmwA2QgQORgg==", + "version": "21.2.1", + "resolved": "https://registry.npmjs.org/@commitlint/cli/-/cli-21.2.1.tgz", + "integrity": "sha512-blsZGe29hJ72VGEFVl72IVYX+1vsfINpjA9yWQA6i7OKD/McGEOXg08sKIRKjFk4JvzhV/9n0l3i6NooPLTNfg==", "dev": true, "license": "MIT", "dependencies": { - "@commitlint/format": "^21.0.1", - "@commitlint/lint": "^21.0.2", - "@commitlint/load": "^21.0.2", - "@commitlint/read": "^21.0.2", - "@commitlint/types": "^21.0.1", + "@commitlint/config-conventional": "^21.2.0", + "@commitlint/format": "^21.2.0", + "@commitlint/lint": "^21.2.0", + "@commitlint/load": "^21.2.0", + "@commitlint/read": "^21.2.1", + "@commitlint/types": "^21.2.0", "tinyexec": "^1.0.0", "yargs": "^18.0.0" }, @@ -1263,27 +1281,27 @@ } }, "node_modules/@commitlint/config-conventional": { - "version": "21.0.2", - "resolved": "https://registry.npmjs.org/@commitlint/config-conventional/-/config-conventional-21.0.2.tgz", - "integrity": "sha512-P/ZRhryQmkj0Z0dY9FOoRwe3xkwJyyAdtXwt01NT2kuZttcG2CNYp1q5Ci3u+nDT2jcbJRw2kt13Czl1qKNPfg==", + "version": "21.2.0", + "resolved": "https://registry.npmjs.org/@commitlint/config-conventional/-/config-conventional-21.2.0.tgz", + "integrity": "sha512-Qf8WRDVcyVd14if6VTWenebxFbKnVnbzPUJjlzjkyJGeHK2xCGd63Dr1XZzj0plXKQb9P0BfOxoc1HVeCo2BWQ==", "dev": true, "license": "MIT", "dependencies": { - "@commitlint/types": "^21.0.1", - "conventional-changelog-conventionalcommits": "^9.2.0" + "@commitlint/types": "^21.2.0", + "conventional-changelog-conventionalcommits": "^10.0.0" }, "engines": { "node": ">=22.12.0" } }, "node_modules/@commitlint/config-validator": { - "version": "21.0.1", - "resolved": "https://registry.npmjs.org/@commitlint/config-validator/-/config-validator-21.0.1.tgz", - "integrity": "sha512-Zd2UFdndeMMaW2O96HK0tdfT4gOImUvidMpAd/pws2zZ4m1nrAZ/9b/v2JYuE8fs86GpXv9F7LNaIuCIWhY+pA==", + "version": "21.2.0", + "resolved": "https://registry.npmjs.org/@commitlint/config-validator/-/config-validator-21.2.0.tgz", + "integrity": "sha512-t7AzNHAKeIdo/3NRGwzpufKHsKkPHmFs/56N2Fnsh0/r0rGtnQzTxk6vnFgjaGr4hdSQKNB50/KAhR9Yk4LJKA==", "dev": true, "license": "MIT", "dependencies": { - "@commitlint/types": "^21.0.1", + "@commitlint/types": "^21.2.0", "ajv": "^8.11.0" }, "engines": { @@ -1291,13 +1309,13 @@ } }, "node_modules/@commitlint/ensure": { - "version": "21.0.1", - "resolved": "https://registry.npmjs.org/@commitlint/ensure/-/ensure-21.0.1.tgz", - "integrity": "sha512-jJ1037967wU7YN/xkv+iRlOBlmaOXPhPO5KQSqya6GyXzBlwuLzELBFao16DVg9dZyqmNrhewzwZ3SAibetHBQ==", + "version": "21.2.0", + "resolved": "https://registry.npmjs.org/@commitlint/ensure/-/ensure-21.2.0.tgz", + "integrity": "sha512-76IF9vDNS13lAzEEik9eKwzt8f9hYhWiwVXZ2AnyLCz5/f511FsEQ3pw1X3/zSQpdRLQU7i5qDMVKyXi1GWjSg==", "dev": true, "license": "MIT", "dependencies": { - "@commitlint/types": "^21.0.1", + "@commitlint/types": "^21.2.0", "es-toolkit": "^1.46.0" }, "engines": { @@ -1315,13 +1333,13 @@ } }, "node_modules/@commitlint/format": { - "version": "21.0.1", - "resolved": "https://registry.npmjs.org/@commitlint/format/-/format-21.0.1.tgz", - "integrity": "sha512-ksmG2+cHGtuDPQQbhBbC4unwm444+6TiPw0d1bKf67hntgZqZ8E0g1MuYKUuyT5IH4IMmXZhKq22/Z3jBvtQIw==", + "version": "21.2.0", + "resolved": "https://registry.npmjs.org/@commitlint/format/-/format-21.2.0.tgz", + "integrity": "sha512-c4q64xaav2U83t7k7RyzJerBZurPer7FxUOY0RL5L/6CZijZ7K+s6HIBGIghj0ey1P2+seRX0J9XQYtDued6tg==", "dev": true, "license": "MIT", "dependencies": { - "@commitlint/types": "^21.0.1", + "@commitlint/types": "^21.2.0", "picocolors": "^1.1.1" }, "engines": { @@ -1329,13 +1347,13 @@ } }, "node_modules/@commitlint/is-ignored": { - "version": "21.0.2", - "resolved": "https://registry.npmjs.org/@commitlint/is-ignored/-/is-ignored-21.0.2.tgz", - "integrity": "sha512-H5z4t8PC9tUsmZ/o+EptM3Nq8sTFtskAShdcqxCoyzklW5eaVT5xbrDAET2uypzir9Vsj4ZZmBtyKjYe2XqgeQ==", + "version": "21.2.0", + "resolved": "https://registry.npmjs.org/@commitlint/is-ignored/-/is-ignored-21.2.0.tgz", + "integrity": "sha512-4/eB0vBN7L88O/oC4ajAEqi7j2ZfNgxl/+11RfAV9YosejZgDXhY2C9VcHnHJhOzPLoSy5P3Mg/46kqeyJfXKw==", "dev": true, "license": "MIT", "dependencies": { - "@commitlint/types": "^21.0.1", + "@commitlint/types": "^21.2.0", "semver": "^7.6.0" }, "engines": { @@ -1343,32 +1361,32 @@ } }, "node_modules/@commitlint/lint": { - "version": "21.0.2", - "resolved": "https://registry.npmjs.org/@commitlint/lint/-/lint-21.0.2.tgz", - "integrity": "sha512-PnUmLYGeGLfW8oVatR9KpNxSHYAnJOEWlMZzfdeFOUq6WUrFx1fGQaWCWJqMoIll/xPM+GdfJV+tKHZVHhl0Fg==", + "version": "21.2.0", + "resolved": "https://registry.npmjs.org/@commitlint/lint/-/lint-21.2.0.tgz", + "integrity": "sha512-ceO5dp9pLjEZ6y6qbq/uXWXDPykqqlTsyzoQ0NzecpisSJhK3kTy9qzQoPeJuWG/IMNdV1lO0RgmzqoAlSi1uw==", "dev": true, "license": "MIT", "dependencies": { - "@commitlint/is-ignored": "^21.0.2", - "@commitlint/parse": "^21.0.2", - "@commitlint/rules": "^21.0.2", - "@commitlint/types": "^21.0.1" + "@commitlint/is-ignored": "^21.2.0", + "@commitlint/parse": "^21.2.0", + "@commitlint/rules": "^21.2.0", + "@commitlint/types": "^21.2.0" }, "engines": { "node": ">=22.12.0" } }, "node_modules/@commitlint/load": { - "version": "21.0.2", - "resolved": "https://registry.npmjs.org/@commitlint/load/-/load-21.0.2.tgz", - "integrity": "sha512-lwUE70hN0/qE/ZRROhbaX65ly/FF12DrqfReLCESo37M0OQCFAf2jRS+2tSCSORq+bm4Kdju7qNDj46uc1QzTA==", + "version": "21.2.0", + "resolved": "https://registry.npmjs.org/@commitlint/load/-/load-21.2.0.tgz", + "integrity": "sha512-RjlzWQqruRwIenJEfZtq7kG97co97nKoHpflE5YnF61tDLXxHPrdWImgzw6VL6MlFyaOcVlk74eBV8ZQmc3oIA==", "dev": true, "license": "MIT", "dependencies": { - "@commitlint/config-validator": "^21.0.1", + "@commitlint/config-validator": "^21.2.0", "@commitlint/execute-rule": "^21.0.1", - "@commitlint/resolve-extends": "^21.0.1", - "@commitlint/types": "^21.0.1", + "@commitlint/resolve-extends": "^21.2.0", + "@commitlint/types": "^21.2.0", "cosmiconfig": "^9.0.1", "cosmiconfig-typescript-loader": "^6.1.0", "es-toolkit": "^1.46.0", @@ -1380,9 +1398,9 @@ } }, "node_modules/@commitlint/message": { - "version": "21.0.2", - "resolved": "https://registry.npmjs.org/@commitlint/message/-/message-21.0.2.tgz", - "integrity": "sha512-5n4aqHGD/FNnom/D5L8i7cYtV+xjuXcBL832C3w9VglEsZzIsoHpJsvxzJ7cgiOsOdc/2jU4t5+7qMHh7GBX3g==", + "version": "21.2.0", + "resolved": "https://registry.npmjs.org/@commitlint/message/-/message-21.2.0.tgz", + "integrity": "sha512-YxGoiXD/HXNXLJPrQwE5poXa+XH0CBEm+mdvbHQP0g6MV/dmJyUFCzPNzZbxL93GvZ70TmtTK0Z0/IBpAqHv8g==", "dev": true, "license": "MIT", "engines": { @@ -1390,30 +1408,30 @@ } }, "node_modules/@commitlint/parse": { - "version": "21.0.2", - "resolved": "https://registry.npmjs.org/@commitlint/parse/-/parse-21.0.2.tgz", - "integrity": "sha512-QVZJhGHTm+oiuWyEKOCTQ0ZM3mfJ0eGWFeHuj7WzSKEth+UukcCHac9GD8pgdFlg/qGkFWOtyaNd1T8REgagaw==", + "version": "21.2.0", + "resolved": "https://registry.npmjs.org/@commitlint/parse/-/parse-21.2.0.tgz", + "integrity": "sha512-QHWxG4d0PLTF634/AdyZ0MQS+CLn5YOuJlCFhMMlSGKFxzYGUetkHBj18xgBD+6fVzUrA2lrCdi/vlS2f/oYXg==", "dev": true, "license": "MIT", "dependencies": { - "@commitlint/types": "^21.0.1", - "conventional-changelog-angular": "^8.2.0", - "conventional-commits-parser": "^6.3.0" + "@commitlint/types": "^21.2.0", + "conventional-changelog-angular": "^9.0.0", + "conventional-commits-parser": "^7.0.0" }, "engines": { "node": ">=22.12.0" } }, "node_modules/@commitlint/read": { - "version": "21.0.2", - "resolved": "https://registry.npmjs.org/@commitlint/read/-/read-21.0.2.tgz", - "integrity": "sha512-BtsrnLVycSSKf4Q0gMch4giCj5NNlmcbhc8ra5vONgGtP2IjRDo33bEFtr5Pm+2N+5fXGWb2MksWPrspPfdhdw==", + "version": "21.2.1", + "resolved": "https://registry.npmjs.org/@commitlint/read/-/read-21.2.1.tgz", + "integrity": "sha512-hUW7EJQnNTL0vPOmVMNK4CrnrNBN0nN+JJHReFkdHO5y4iyHeEmTBwuC15OCqUTjxWo7idnH1LftfpWVIaPWIA==", "dev": true, "license": "MIT", "dependencies": { - "@commitlint/top-level": "^21.0.2", - "@commitlint/types": "^21.0.1", - "git-raw-commits": "^5.0.0", + "@commitlint/top-level": "^21.2.0", + "@commitlint/types": "^21.2.0", + "@conventional-changelog/git-client": "^3.0.0", "tinyexec": "^1.0.0" }, "engines": { @@ -1421,14 +1439,14 @@ } }, "node_modules/@commitlint/resolve-extends": { - "version": "21.0.1", - "resolved": "https://registry.npmjs.org/@commitlint/resolve-extends/-/resolve-extends-21.0.1.tgz", - "integrity": "sha512-0DhjYWL6uYrY16Efa032fYk3woGJDU4AGWiG1XXltT9AMUNYKyb5cIZU2ivbaMZ3+kKFqUjikD2cjh66Sbh/Sg==", + "version": "21.2.0", + "resolved": "https://registry.npmjs.org/@commitlint/resolve-extends/-/resolve-extends-21.2.0.tgz", + "integrity": "sha512-4O/1j51+79Wth9s/MGxt/5gs0XYLDgNlYpltQfhAvLE0itusLKs9zruxbiNg1oOkmkb9L9L4USYGjEj7n87NxA==", "dev": true, "license": "MIT", "dependencies": { - "@commitlint/config-validator": "^21.0.1", - "@commitlint/types": "^21.0.1", + "@commitlint/config-validator": "^21.2.0", + "@commitlint/types": "^21.2.0", "es-toolkit": "^1.46.0", "global-directory": "^5.0.0", "resolve-from": "^5.0.0" @@ -1438,16 +1456,16 @@ } }, "node_modules/@commitlint/rules": { - "version": "21.0.2", - "resolved": "https://registry.npmjs.org/@commitlint/rules/-/rules-21.0.2.tgz", - "integrity": "sha512-k6tQ69Td7t2qUSIbik8D3TL1q3ZJpkEbV+yLogDzCRAdOxJm4ndhtBNREsLA1/puRfWvzS9eioF2w43WT+hHgQ==", + "version": "21.2.0", + "resolved": "https://registry.npmjs.org/@commitlint/rules/-/rules-21.2.0.tgz", + "integrity": "sha512-C2yXMNpiB8ETZKfx5JD8+ExgF8vTU1VQMKPSUUYwqKpw9oJWQBrlXBpdU038mj2WPjof7o9UzFpmTyBeGMZwZg==", "dev": true, "license": "MIT", "dependencies": { - "@commitlint/ensure": "^21.0.1", - "@commitlint/message": "^21.0.2", + "@commitlint/ensure": "^21.2.0", + "@commitlint/message": "^21.2.0", "@commitlint/to-lines": "^21.0.1", - "@commitlint/types": "^21.0.1" + "@commitlint/types": "^21.2.0" }, "engines": { "node": ">=22.12.0" @@ -1464,9 +1482,9 @@ } }, "node_modules/@commitlint/top-level": { - "version": "21.0.2", - "resolved": "https://registry.npmjs.org/@commitlint/top-level/-/top-level-21.0.2.tgz", - "integrity": "sha512-s9KKM+e+mXgFeIh4n7KmOGAVT3mkJ3Fp1bBYHIK5pjeUwlEMzp/tZfb5u0Poa680AsQTXMEMRxZi1vQ9m2X5ug==", + "version": "21.2.0", + "resolved": "https://registry.npmjs.org/@commitlint/top-level/-/top-level-21.2.0.tgz", + "integrity": "sha512-Y5gmQ+KxzqCrBFJfLvFEPvvwD3LDiNZoTT2yeFBm96M8qhmqSzQc5DvX3rheAaAMjyIvMXOCLS/mWfdpONsjyQ==", "dev": true, "license": "MIT", "dependencies": { @@ -1477,13 +1495,13 @@ } }, "node_modules/@commitlint/types": { - "version": "21.0.1", - "resolved": "https://registry.npmjs.org/@commitlint/types/-/types-21.0.1.tgz", - "integrity": "sha512-4u7w8jcoCUFWhjWnASYzZHAP34OqOtuFBN87nQmFvqda03YU0T6z+yB4w0gSAMpekiRqqGk5rt+qSlW+a2vSEg==", + "version": "21.2.0", + "resolved": "https://registry.npmjs.org/@commitlint/types/-/types-21.2.0.tgz", + "integrity": "sha512-7zVFCDB2reMvJH5dmbKnOQPjZEvjdJTH8jc0U/PIPU1r3/+vf5pD1HlfitV2MWsWXrvu7u39iY1lyLUPOaN0Gw==", "dev": true, "license": "MIT", "dependencies": { - "conventional-commits-parser": "^6.3.0", + "conventional-commits-parser": "^7.0.0", "picocolors": "^1.1.1" }, "engines": { @@ -1491,22 +1509,22 @@ } }, "node_modules/@conventional-changelog/git-client": { - "version": "2.7.0", - "resolved": "https://registry.npmjs.org/@conventional-changelog/git-client/-/git-client-2.7.0.tgz", - "integrity": "sha512-j7A8/LBEQ+3rugMzPXoKYzyUPpw/0CBQCyvtTR7Lmu4olG4yRC/Tfkq79Mr3yuPs0SUitlO2HwGP3gitMJnRFw==", + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@conventional-changelog/git-client/-/git-client-3.1.0.tgz", + "integrity": "sha512-Tqa/gHco2WJWa740NRjOrfKVvzIqxkZpecb8bemaQ8sKM5PXb1UK4uTyTb/1wIqNuOVaDOFxyBdhTIQZn6gdjQ==", "dev": true, "license": "MIT", "dependencies": { - "@simple-libs/child-process-utils": "^1.0.0", - "@simple-libs/stream-utils": "^1.2.0", + "@simple-libs/child-process-utils": "^2.0.0", + "@simple-libs/stream-utils": "^2.0.0", "semver": "^7.5.2" }, "engines": { - "node": ">=18" + "node": ">=22" }, "peerDependencies": { - "conventional-commits-filter": "^5.0.0", - "conventional-commits-parser": "^6.4.0" + "conventional-commits-filter": "^6.0.1", + "conventional-commits-parser": "^7.0.1" }, "peerDependenciesMeta": { "conventional-commits-filter": { @@ -1517,6 +1535,16 @@ } } }, + "node_modules/@conventional-changelog/template": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@conventional-changelog/template/-/template-1.2.1.tgz", + "integrity": "sha512-TzlTVpKPjaqW6qOYjQcYUDuGsLCNsvFHVBXkYGTAnf5V37jCWrE5haKNXzz0WZUtVHjrpV76L1buANjwXMfT8w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=22" + } + }, "node_modules/@csstools/color-helpers": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.0.2.tgz", @@ -1676,6 +1704,13 @@ "node": ">=20.0.0" } }, + "node_modules/@drizzle-team/brocli": { + "version": "0.10.2", + "resolved": "https://registry.npmjs.org/@drizzle-team/brocli/-/brocli-0.10.2.tgz", + "integrity": "sha512-z33Il7l5dKjUgGULTqBsQBQwckHh5AbIuxhdsIxDDiZAzBOrZO6q9ogcWC65kU382AfynTfgNumVcNIjuIua6w==", + "dev": true, + "license": "Apache-2.0" + }, "node_modules/@electron-internal/extract-zip": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/@electron-internal/extract-zip/-/extract-zip-1.0.3.tgz", @@ -1888,9 +1923,9 @@ } }, "node_modules/@electron/rebuild": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/@electron/rebuild/-/rebuild-4.0.4.tgz", - "integrity": "sha512-Rzc39XPdk/+/wBG8MfwAHohXflep0ITUfulb6Rgz3R0NeSB1noE+E9/M/cb8ftCAiyDD9PPhLuuWgE1GaInbKg==", + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@electron/rebuild/-/rebuild-4.2.0.tgz", + "integrity": "sha512-RKL/O+jGoXJMxrx/5771y1n0xTKmFuOYGO3gMmwypBM6rsH0kou0mswwdXA2JrhIkE4xyC7v9vGk0n6NPzgOxQ==", "dev": true, "license": "MIT", "dependencies": { @@ -2009,6 +2044,884 @@ "tslib": "^2.4.0" } }, + "node_modules/@esbuild-kit/core-utils": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/@esbuild-kit/core-utils/-/core-utils-3.3.2.tgz", + "integrity": "sha512-sPRAnw9CdSsRmEtnsl2WXWdyquogVpB3yZ3dgwJfe8zrOzTsV7cJvmwrKVa+0ma5BoiGJ+BoqkMvawbayKUsqQ==", + "deprecated": "Merged into tsx: https://tsx.hirok.io", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.18.20", + "source-map-support": "^0.5.21" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/android-arm": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.18.20.tgz", + "integrity": "sha512-fyi7TDI/ijKKNZTUJAQqiG5T7YjJXgnzkURqmGj13C6dCqckZBLdl4h7bkhHt/t0WP+zO9/zwroDvANaOqO5Sw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/android-arm64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.18.20.tgz", + "integrity": "sha512-Nz4rJcchGDtENV0eMKUNa6L12zz2zBDXuhj/Vjh18zGqB44Bi7MBMSXjgunJgjRhCmKOjnPuZp4Mb6OKqtMHLQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/android-x64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.18.20.tgz", + "integrity": "sha512-8GDdlePJA8D6zlZYJV/jnrRAi6rOiNaCC/JclcXpB+KIuvfBN4owLtgzY2bsxnx666XjJx2kDPUmnTtR8qKQUg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/darwin-arm64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.18.20.tgz", + "integrity": "sha512-bxRHW5kHU38zS2lPTPOyuyTm+S+eobPUnTNkdJEfAddYgEcll4xkT8DB9d2008DtTbl7uJag2HuE5NZAZgnNEA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/darwin-x64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.18.20.tgz", + "integrity": "sha512-pc5gxlMDxzm513qPGbCbDukOdsGtKhfxD1zJKXjCCcU7ju50O7MeAZ8c4krSJcOIJGFR+qx21yMMVYwiQvyTyQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/freebsd-arm64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.18.20.tgz", + "integrity": "sha512-yqDQHy4QHevpMAaxhhIwYPMv1NECwOvIpGCZkECn8w2WFHXjEwrBn3CeNIYsibZ/iZEUemj++M26W3cNR5h+Tw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/freebsd-x64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.18.20.tgz", + "integrity": "sha512-tgWRPPuQsd3RmBZwarGVHZQvtzfEBOreNuxEMKFcd5DaDn2PbBxfwLcj4+aenoh7ctXcbXmOQIn8HI6mCSw5MQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/linux-arm": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.18.20.tgz", + "integrity": "sha512-/5bHkMWnq1EgKr1V+Ybz3s1hWXok7mDFUMQ4cG10AfW3wL02PSZi5kFpYKrptDsgb2WAJIvRcDm+qIvXf/apvg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/linux-arm64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.18.20.tgz", + "integrity": "sha512-2YbscF+UL7SQAVIpnWvYwM+3LskyDmPhe31pE7/aoTMFKKzIc9lLbyGUpmmb8a8AixOL61sQ/mFh3jEjHYFvdA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/linux-ia32": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.18.20.tgz", + "integrity": "sha512-P4etWwq6IsReT0E1KHU40bOnzMHoH73aXp96Fs8TIT6z9Hu8G6+0SHSw9i2isWrD2nbx2qo5yUqACgdfVGx7TA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/linux-loong64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.18.20.tgz", + "integrity": "sha512-nXW8nqBTrOpDLPgPY9uV+/1DjxoQ7DoB2N8eocyq8I9XuqJ7BiAMDMf9n1xZM9TgW0J8zrquIb/A7s3BJv7rjg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/linux-mips64el": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.18.20.tgz", + "integrity": "sha512-d5NeaXZcHp8PzYy5VnXV3VSd2D328Zb+9dEq5HE6bw6+N86JVPExrA6O68OPwobntbNJ0pzCpUFZTo3w0GyetQ==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/linux-ppc64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.18.20.tgz", + "integrity": "sha512-WHPyeScRNcmANnLQkq6AfyXRFr5D6N2sKgkFo2FqguP44Nw2eyDlbTdZwd9GYk98DZG9QItIiTlFLHJHjxP3FA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/linux-riscv64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.18.20.tgz", + "integrity": "sha512-WSxo6h5ecI5XH34KC7w5veNnKkju3zBRLEQNY7mv5mtBmrP/MjNBCAlsM2u5hDBlS3NGcTQpoBvRzqBcRtpq1A==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/linux-s390x": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.18.20.tgz", + "integrity": "sha512-+8231GMs3mAEth6Ja1iK0a1sQ3ohfcpzpRLH8uuc5/KVDFneH6jtAJLFGafpzpMRO6DzJ6AvXKze9LfFMrIHVQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/linux-x64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.18.20.tgz", + "integrity": "sha512-UYqiqemphJcNsFEskc73jQ7B9jgwjWrSayxawS6UVFZGWrAAtkzjxSqnoclCXxWtfwLdzU+vTpcNYhpn43uP1w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/netbsd-x64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.18.20.tgz", + "integrity": "sha512-iO1c++VP6xUBUmltHZoMtCUdPlnPGdBom6IrO4gyKPFFVBKioIImVooR5I83nTew5UOYrk3gIJhbZh8X44y06A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/openbsd-x64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.18.20.tgz", + "integrity": "sha512-e5e4YSsuQfX4cxcygw/UCPIEP6wbIL+se3sxPdCiMbFLBWu0eiZOJ7WoD+ptCLrmjZBK1Wk7I6D/I3NglUGOxg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/sunos-x64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.18.20.tgz", + "integrity": "sha512-kDbFRFp0YpTQVVrqUd5FTYmWo45zGaXe0X8E1G/LKFC0v8x0vWrhOWSLITcCn63lmZIxfOMXtCfti/RxN/0wnQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/win32-arm64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.18.20.tgz", + "integrity": "sha512-ddYFR6ItYgoaq4v4JmQQaAI5s7npztfV4Ag6NrhiaW0RrnOXqBkgwZLofVTlq1daVTQNhtI5oieTvkRPfZrePg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/win32-ia32": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.18.20.tgz", + "integrity": "sha512-Wv7QBi3ID/rROT08SABTS7eV4hX26sVduqDOTe1MvGMjNd3EjOz4b7zeexIR62GTIEKrfJXKL9LFxTYgkyeu7g==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/win32-x64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.18.20.tgz", + "integrity": "sha512-kTdfRcSiDfQca/y9QIkng02avJ+NCaQvrMejlsB3RRv5sE9rRoeBPISaZpKxHELzRxZyLvNts1P27W3wV+8geQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/esbuild": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.18.20.tgz", + "integrity": "sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/android-arm": "0.18.20", + "@esbuild/android-arm64": "0.18.20", + "@esbuild/android-x64": "0.18.20", + "@esbuild/darwin-arm64": "0.18.20", + "@esbuild/darwin-x64": "0.18.20", + "@esbuild/freebsd-arm64": "0.18.20", + "@esbuild/freebsd-x64": "0.18.20", + "@esbuild/linux-arm": "0.18.20", + "@esbuild/linux-arm64": "0.18.20", + "@esbuild/linux-ia32": "0.18.20", + "@esbuild/linux-loong64": "0.18.20", + "@esbuild/linux-mips64el": "0.18.20", + "@esbuild/linux-ppc64": "0.18.20", + "@esbuild/linux-riscv64": "0.18.20", + "@esbuild/linux-s390x": "0.18.20", + "@esbuild/linux-x64": "0.18.20", + "@esbuild/netbsd-x64": "0.18.20", + "@esbuild/openbsd-x64": "0.18.20", + "@esbuild/sunos-x64": "0.18.20", + "@esbuild/win32-arm64": "0.18.20", + "@esbuild/win32-ia32": "0.18.20", + "@esbuild/win32-x64": "0.18.20" + } + }, + "node_modules/@esbuild-kit/esm-loader": { + "version": "2.6.5", + "resolved": "https://registry.npmjs.org/@esbuild-kit/esm-loader/-/esm-loader-2.6.5.tgz", + "integrity": "sha512-FxEMIkJKnodyA1OaCUoEvbYRkoZlLZ4d/eXFu9Fh8CbBBgP5EmZxrfTRyN0qpXZ4vOvqnE5YdRdcrmUUXuU+dA==", + "deprecated": "Merged into tsx: https://tsx.hirok.io", + "dev": true, + "license": "MIT", + "dependencies": { + "@esbuild-kit/core-utils": "^3.3.2", + "get-tsconfig": "^4.7.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "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/@eslint-community/eslint-utils": { "version": "4.9.1", "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", @@ -2054,9 +2967,9 @@ } }, "node_modules/@eslint/config-helpers": { - "version": "0.6.0", - "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.6.0.tgz", - "integrity": "sha512-ii6Bw9jJ2zi2cWA2Z+9/QZ/+3DX6kwaV5Q986D/CdP3Lap3w/pgQZ373FV7byY/i7L4IRH/G43I5dz1ClsCbpA==", + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.7.0.tgz", + "integrity": "sha512-DObd/KKUsU+FaFv4PLxSRenpXfQWmPXXP3pPZ6/K1PCrMu2vQpMDMuQe/BqYeoLcz8ro0bVDF1RxOJgfVEdhUw==", "dev": true, "license": "Apache-2.0", "dependencies": { @@ -2185,9 +3098,9 @@ "license": "MIT" }, "node_modules/@fontsource-variable/jetbrains-mono": { - "version": "5.2.8", - "resolved": "https://registry.npmjs.org/@fontsource-variable/jetbrains-mono/-/jetbrains-mono-5.2.8.tgz", - "integrity": "sha512-WBA9elru6Jdp5df2mES55wuOO0WIrn3kpXnI4+W2ek5u3ZgLS9XS4gmIlcQhiZOWEKl95meYdvK7xI+ETLCq/Q==", + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/@fontsource-variable/jetbrains-mono/-/jetbrains-mono-5.3.0.tgz", + "integrity": "sha512-F32xpS2NsGYoQi2ADSkKTgpJj7ozajsGgDJ8woTnqjmIB+dxDIqImjl4pXZVEExu8UFZ2ndhmX18EBS/hdz3Lw==", "dev": true, "license": "OFL-1.1", "funding": { @@ -2195,9 +3108,9 @@ } }, "node_modules/@fontsource/fira-code": { - "version": "5.2.7", - "resolved": "https://registry.npmjs.org/@fontsource/fira-code/-/fira-code-5.2.7.tgz", - "integrity": "sha512-tnB9NNund9TwIym8/7DMJe573nlPEQb+fKUV5GL8TBYXjIhDvL0D7mgmNVNQUPhXp+R7RylQeiBdkA4EbOHPGQ==", + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/@fontsource/fira-code/-/fira-code-5.3.0.tgz", + "integrity": "sha512-EJL968RJRkakubAj/coU8pSUaeTE5UNoRjtzAr6kGiSZ3jWuN8/AKWHwym/PFUaQL1q7IL/H+EXs4358YhrTBQ==", "dev": true, "license": "OFL-1.1", "funding": { @@ -2205,9 +3118,9 @@ } }, "node_modules/@fontsource/jetbrains-mono": { - "version": "5.2.8", - "resolved": "https://registry.npmjs.org/@fontsource/jetbrains-mono/-/jetbrains-mono-5.2.8.tgz", - "integrity": "sha512-6w8/SG4kqvIMu7xd7wt6x3idn1Qux3p9N62s6G3rfldOUYHpWcc2FKrqf+Vo44jRvqWj2oAtTHrZXEP23oSKwQ==", + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/@fontsource/jetbrains-mono/-/jetbrains-mono-5.3.0.tgz", + "integrity": "sha512-fqDfB5I9f1p1TV486aUgB9t8zP84P0O1FtQR5Ol9vjwPy+S+EIGlVYm1cvj2W5shcZMTg2nZFdVMoH5wFu8a1A==", "dev": true, "license": "OFL-1.1", "funding": { @@ -2215,9 +3128,9 @@ } }, "node_modules/@fontsource/source-code-pro": { - "version": "5.2.7", - "resolved": "https://registry.npmjs.org/@fontsource/source-code-pro/-/source-code-pro-5.2.7.tgz", - "integrity": "sha512-7papq9TH94KT+S5VSY8cU7tFmwuGkIe3qxXRMscuAXH6AjMU+KJI75f28FzgBVDrlMfA0jjlTV4/x5+H5o/5EQ==", + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/@fontsource/source-code-pro/-/source-code-pro-5.3.0.tgz", + "integrity": "sha512-JxaCODU69HDS3mVra9u96nyBF911La6IvtGLgpQD+PZLxJ1i9IxooNfLR6Y37kx06IFjVGkvoUmg3WPwh/8gBg==", "dev": true, "license": "OFL-1.1", "funding": { @@ -3822,27 +4735,27 @@ "license": "MIT" }, "node_modules/@radix-ui/number": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@radix-ui/number/-/number-1.1.2.tgz", - "integrity": "sha512-ceTwaxc4I5IOi97DgCotl3pqiyRGvffcc0oOsE2dQYaJOFIDsDt4VWG6xEbg1QePv9QWausCEIppud/tJ1wNig==", + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/number/-/number-1.1.3.tgz", + "integrity": "sha512-Road2bidD0uu/1BGDOWNdPI06g0lIRy6IF9GZcIrDK2KGItfor8IQwQa+yM2ERgHM1MmHxaxpTzk0/Jp42lNfA==", "dev": true, "license": "MIT" }, "node_modules/@radix-ui/primitive": { - "version": "1.1.6", - "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.6.tgz", - "integrity": "sha512-w9hl+724uYEgCGR3bhuRepjBtrNB/6gkhCnAf58Ke+SLbHPPQqVZZB59z60roB+5H+nh3nWTcdJhQdFMEydWmw==", + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.7.tgz", + "integrity": "sha512-rqWnm76nYT8HoNNqEjpgJ7Pw/DrBj5iBTrmEPo6HTX5+VJyBNOqTdv4g89G63HuR5g0AaENoAcH7Is5fF2kZ8Q==", "dev": true, "license": "MIT" }, "node_modules/@radix-ui/react-accessible-icon": { - "version": "1.1.12", - "resolved": "https://registry.npmjs.org/@radix-ui/react-accessible-icon/-/react-accessible-icon-1.1.12.tgz", - "integrity": "sha512-Y0zhCQ/XUdTom5hAxvE8RlXqR4hZmKGK6g2//LfgHmb88PJFOpXSh9B/7FlfYXezVY5FKGjRYWCYz5FXxZ9WZQ==", + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/@radix-ui/react-accessible-icon/-/react-accessible-icon-1.1.15.tgz", + "integrity": "sha512-WTQwcAvQf5sOcuUyi90lKPbhwcvQ+j55cjrSmeaN+L2vKU3DooOvlKw2MDeiJ5IkV5N905KW0/fGojKOBhD11A==", "dev": true, "license": "MIT", "dependencies": { - "@radix-ui/react-visually-hidden": "1.2.8" + "@radix-ui/react-visually-hidden": "1.2.11" }, "peerDependencies": { "@types/react": "*", @@ -3860,21 +4773,21 @@ } }, "node_modules/@radix-ui/react-accordion": { - "version": "1.2.17", - "resolved": "https://registry.npmjs.org/@radix-ui/react-accordion/-/react-accordion-1.2.17.tgz", - "integrity": "sha512-l3Dmp+qPPc3SqT8+SPnxIgoWBEU2MMBxcQ7BsoRgak2UT75xY83SFvFcrUkUAWukOV3LFF+BQ9aBIFtZsIG8yQ==", + "version": "1.2.20", + "resolved": "https://registry.npmjs.org/@radix-ui/react-accordion/-/react-accordion-1.2.20.tgz", + "integrity": "sha512-jDhG9FvAEnlhnjrsINbNXcUa4G+L1KqSkJSunkbKEzFRcAb52jvM0PjPxPRvhe1HNc5F5yc0yzzWeeqlH4yBIg==", "dev": true, "license": "MIT", "dependencies": { - "@radix-ui/primitive": "1.1.6", - "@radix-ui/react-collapsible": "1.1.17", - "@radix-ui/react-collection": "1.1.12", - "@radix-ui/react-compose-refs": "1.1.3", - "@radix-ui/react-context": "1.2.0", - "@radix-ui/react-direction": "1.1.2", - "@radix-ui/react-id": "1.1.2", - "@radix-ui/react-primitive": "2.1.7", - "@radix-ui/react-use-controllable-state": "1.2.4" + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-collapsible": "1.1.20", + "@radix-ui/react-collection": "1.1.15", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-direction": "1.1.4", + "@radix-ui/react-id": "1.1.4", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-use-controllable-state": "1.2.6" }, "peerDependencies": { "@types/react": "*", @@ -3892,17 +4805,17 @@ } }, "node_modules/@radix-ui/react-alert-dialog": { - "version": "1.1.20", - "resolved": "https://registry.npmjs.org/@radix-ui/react-alert-dialog/-/react-alert-dialog-1.1.20.tgz", - "integrity": "sha512-Ft1W+jPqSh5BKfSTe4dpq6UYQKKQJ5Tvq3wfux+WVlg7nPwFK/3pIlHTb3Rbe+b/tNurx8YGXD9em91ujmgwuQ==", + "version": "1.1.23", + "resolved": "https://registry.npmjs.org/@radix-ui/react-alert-dialog/-/react-alert-dialog-1.1.23.tgz", + "integrity": "sha512-VAYOiQRqj3GPpYJE0I9J+X8Ip05cyVlNdKOFeiGS2Ou1HHGfpl0BxOyZm6nmVDyU+W+NF3/XLzmjHmVGydhwgA==", "dev": true, "license": "MIT", "dependencies": { - "@radix-ui/primitive": "1.1.6", - "@radix-ui/react-compose-refs": "1.1.3", - "@radix-ui/react-context": "1.2.0", - "@radix-ui/react-dialog": "1.1.20", - "@radix-ui/react-primitive": "2.1.7" + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-dialog": "1.1.23", + "@radix-ui/react-primitive": "2.1.10" }, "peerDependencies": { "@types/react": "*", @@ -3920,13 +4833,13 @@ } }, "node_modules/@radix-ui/react-arrow": { - "version": "1.1.12", - "resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.1.12.tgz", - "integrity": "sha512-ltXCE0glRomMZ9+u10d9o1Go+edqa1aLxufH59JRNNM3Yz1uvaeNWSaS1HeVh1X64agtdBG5JA1W1I6ySqWiwA==", + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.1.15.tgz", + "integrity": "sha512-v4zggRcjadnI+ClKDuijlQEW4tw3NoaeHc/PwpKnLoLLKNUG4InLegkstooLcRIUWCs+8L22dGURCVuFfOKfnA==", "dev": true, "license": "MIT", "dependencies": { - "@radix-ui/react-primitive": "2.1.7" + "@radix-ui/react-primitive": "2.1.10" }, "peerDependencies": { "@types/react": "*", @@ -3944,13 +4857,13 @@ } }, "node_modules/@radix-ui/react-aspect-ratio": { - "version": "1.1.12", - "resolved": "https://registry.npmjs.org/@radix-ui/react-aspect-ratio/-/react-aspect-ratio-1.1.12.tgz", - "integrity": "sha512-Sok2IBJxA1XO4pU3ldzZMwUBMumIt64EY8zOUlVq5CdS+i0FrEbajVslfDB+YGWLMsrjY2kZQB0DgkrZXLZvcg==", + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/@radix-ui/react-aspect-ratio/-/react-aspect-ratio-1.1.15.tgz", + "integrity": "sha512-fy+dyVR+90nelK8rqIznFlxzx7uPcGbhxH8Nfr2bHb4UfSe+e3hklOC0luK0hDwVwnRX7xTRySpsrQVeW+/oNQ==", "dev": true, "license": "MIT", "dependencies": { - "@radix-ui/react-primitive": "2.1.7" + "@radix-ui/react-primitive": "2.1.10" }, "peerDependencies": { "@types/react": "*", @@ -3968,18 +4881,18 @@ } }, "node_modules/@radix-ui/react-avatar": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/@radix-ui/react-avatar/-/react-avatar-1.2.3.tgz", - "integrity": "sha512-peavtnApRB1tABx42tHw+rPU83GSg5tXicMYO/Xi1/lqNcRsF6jkr6L7Njo7gj4q/xtDRDKBkqJvbMtoOMYWtA==", + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@radix-ui/react-avatar/-/react-avatar-1.2.6.tgz", + "integrity": "sha512-4ULOTJ/mqy2hT9GlWa/MFHxHSvH3nJzHnZM1waNsc5Bonv7i70aNenghXmD97S6OJ81ekXONGGt4nT1r0PfEdA==", "dev": true, "license": "MIT", "dependencies": { - "@radix-ui/primitive": "1.1.6", - "@radix-ui/react-context": "1.2.0", - "@radix-ui/react-primitive": "2.1.7", - "@radix-ui/react-use-callback-ref": "1.1.2", - "@radix-ui/react-use-is-hydrated": "0.1.1", - "@radix-ui/react-use-layout-effect": "1.1.2" + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-use-callback-ref": "1.1.4", + "@radix-ui/react-use-is-hydrated": "0.1.3", + "@radix-ui/react-use-layout-effect": "1.1.4" }, "peerDependencies": { "@types/react": "*", @@ -3997,19 +4910,19 @@ } }, "node_modules/@radix-ui/react-checkbox": { - "version": "1.3.8", - "resolved": "https://registry.npmjs.org/@radix-ui/react-checkbox/-/react-checkbox-1.3.8.tgz", - "integrity": "sha512-wfN60IGuxynWK7rP4Ks2p7u9G7gqirzkAiFptuzVbsR1ot2/K+PavNUAtxiKxyRfLOvSbVfvvm9m3rFqLEXz7A==", + "version": "1.3.11", + "resolved": "https://registry.npmjs.org/@radix-ui/react-checkbox/-/react-checkbox-1.3.11.tgz", + "integrity": "sha512-Gnptr9pDDQxD3hgq2dtPbtrp/c2qH1mBwIzw3X/ivrMb2e1t0jMTi606fVEqFPaQR1ggXIVQWKj3P2WW9v7zGQ==", "dev": true, "license": "MIT", "dependencies": { - "@radix-ui/primitive": "1.1.6", - "@radix-ui/react-compose-refs": "1.1.3", - "@radix-ui/react-context": "1.2.0", - "@radix-ui/react-presence": "1.1.8", - "@radix-ui/react-primitive": "2.1.7", - "@radix-ui/react-use-controllable-state": "1.2.4", - "@radix-ui/react-use-size": "1.1.2" + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-presence": "1.1.10", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-use-controllable-state": "1.2.6", + "@radix-ui/react-use-size": "1.1.4" }, "peerDependencies": { "@types/react": "*", @@ -4027,20 +4940,20 @@ } }, "node_modules/@radix-ui/react-collapsible": { - "version": "1.1.17", - "resolved": "https://registry.npmjs.org/@radix-ui/react-collapsible/-/react-collapsible-1.1.17.tgz", - "integrity": "sha512-DJgqGsNXa0df3ifz9PFNgvgj/bzIu5QTVWCt5nQWaUkM6y0EarUv4QG4s6mCoeQdOIyVOT/Q1osFuEGub2TDXQ==", + "version": "1.1.20", + "resolved": "https://registry.npmjs.org/@radix-ui/react-collapsible/-/react-collapsible-1.1.20.tgz", + "integrity": "sha512-mcGesGplBnzN2sbvJETzpCNfSMyPnb29q1GRLU+Ib7bJrpIG2ywmRoh2V5VbA2uNvKikKUlVbAPks7JDjz4A8Q==", "dev": true, "license": "MIT", "dependencies": { - "@radix-ui/primitive": "1.1.6", - "@radix-ui/react-compose-refs": "1.1.3", - "@radix-ui/react-context": "1.2.0", - "@radix-ui/react-id": "1.1.2", - "@radix-ui/react-presence": "1.1.8", - "@radix-ui/react-primitive": "2.1.7", - "@radix-ui/react-use-controllable-state": "1.2.4", - "@radix-ui/react-use-layout-effect": "1.1.2" + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-id": "1.1.4", + "@radix-ui/react-presence": "1.1.10", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-use-controllable-state": "1.2.6", + "@radix-ui/react-use-layout-effect": "1.1.4" }, "peerDependencies": { "@types/react": "*", @@ -4058,16 +4971,16 @@ } }, "node_modules/@radix-ui/react-collection": { - "version": "1.1.12", - "resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.12.tgz", - "integrity": "sha512-nb67INpE0IahJKN7EYPp9m9YGwYeKlnzxT3MwXVkgCskaSJia97kG4T0ywpjNUSSnoJk/uvk12V8vbrEHEj+/Q==", + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.15.tgz", + "integrity": "sha512-9W+B9NPF0NaaPh/1NJd3+KqsnlLqU9H7T2rvww+fp+T/evVXdNAyYcnfRQZFOjkR1ajQp3yORlqnI8soawLvNA==", "dev": true, "license": "MIT", "dependencies": { - "@radix-ui/react-compose-refs": "1.1.3", - "@radix-ui/react-context": "1.2.0", - "@radix-ui/react-primitive": "2.1.7", - "@radix-ui/react-slot": "1.3.0" + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-slot": "1.3.3" }, "peerDependencies": { "@types/react": "*", @@ -4085,9 +4998,9 @@ } }, "node_modules/@radix-ui/react-compose-refs": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.3.tgz", - "integrity": "sha512-rYOP8OMnuuPMQF1uhPVlGNcCDlkokKqGFE3JcxFViIkAXP7EvFWUliJAstrapypaBLJNHbZL6jGhbVDGTwmVhA==", + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.5.tgz", + "integrity": "sha512-+48PbAAbq3didjJxa+OaWY2ZwgAKsNiRGyeHKszblZMQ+kcpd9pAaT11cMkGEie0vsOi3QdeTE6d5Fe3Gn61kA==", "dev": true, "license": "MIT", "peerDependencies": { @@ -4101,9 +5014,9 @@ } }, "node_modules/@radix-ui/react-context": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.2.0.tgz", - "integrity": "sha512-fOE+JtN9rygNZkCnHRBEP0TAvLldlhyOxMsbwFvTP4nAs+nBmfnna+o/Zski2wkmY1YMrFC0aSzsHoLY47iLrg==", + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.2.2.tgz", + "integrity": "sha512-RHCUGwKHDr0hDGg4X7ma4JG4/+12qxw8rkh5QKdDldlCvtja6nUx1Ef/8HVrJze81lEsgLQlqjzjGNHantgnQA==", "dev": true, "license": "MIT", "peerDependencies": { @@ -4117,17 +5030,17 @@ } }, "node_modules/@radix-ui/react-context-menu": { - "version": "2.3.4", - "resolved": "https://registry.npmjs.org/@radix-ui/react-context-menu/-/react-context-menu-2.3.4.tgz", - "integrity": "sha512-eO9tkvHvo4dNwb+lytEcKWjy8c8To+ttLwNt0f9XzzsVFIaspqt3i1/c0JaaksxBB5G//zPo9CCgn39huWQyBA==", + "version": "2.3.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context-menu/-/react-context-menu-2.3.7.tgz", + "integrity": "sha512-CtXP35dxaB5T3zXSd+E3uHe/QpXcpYnZmxp6OaIbfthtfW4wyb77M23BG+bwIJDtsMwEP/YssdsmNyZu7jhWew==", "dev": true, "license": "MIT", "dependencies": { - "@radix-ui/primitive": "1.1.6", - "@radix-ui/react-context": "1.2.0", - "@radix-ui/react-menu": "2.1.21", - "@radix-ui/react-primitive": "2.1.7", - "@radix-ui/react-use-controllable-state": "1.2.4" + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-menu": "2.1.24", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-use-controllable-state": "1.2.6" }, "peerDependencies": { "@types/react": "*", @@ -4145,25 +5058,25 @@ } }, "node_modules/@radix-ui/react-dialog": { - "version": "1.1.20", - "resolved": "https://registry.npmjs.org/@radix-ui/react-dialog/-/react-dialog-1.1.20.tgz", - "integrity": "sha512-cngVJcvK0yMvR7wICJpv+1uW3Qw4T7QM5sdbb+oE/lxOdTdvF00oaRpWUjVgmjyXe3J+xh7eZyXZlVF3g2g59g==", + "version": "1.1.23", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dialog/-/react-dialog-1.1.23.tgz", + "integrity": "sha512-Ksw4WeROkO4rC9k/onilX/Ao2Cr1ku1unMNH+XSCcP4jSXYu7HDsg9n4ojMjVb22XpYjAQ9qfrFlVbru1vXDUA==", "dev": true, "license": "MIT", "dependencies": { - "@radix-ui/primitive": "1.1.6", - "@radix-ui/react-compose-refs": "1.1.3", - "@radix-ui/react-context": "1.2.0", - "@radix-ui/react-dismissable-layer": "1.1.16", - "@radix-ui/react-focus-guards": "1.1.4", - "@radix-ui/react-focus-scope": "1.1.13", - "@radix-ui/react-id": "1.1.2", - "@radix-ui/react-portal": "1.1.14", - "@radix-ui/react-presence": "1.1.8", - "@radix-ui/react-primitive": "2.1.7", - "@radix-ui/react-slot": "1.3.0", - "@radix-ui/react-use-controllable-state": "1.2.4", - "@radix-ui/react-use-layout-effect": "1.1.2", + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-dismissable-layer": "1.1.19", + "@radix-ui/react-focus-guards": "1.1.6", + "@radix-ui/react-focus-scope": "1.1.16", + "@radix-ui/react-id": "1.1.4", + "@radix-ui/react-portal": "1.1.17", + "@radix-ui/react-presence": "1.1.10", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-slot": "1.3.3", + "@radix-ui/react-use-controllable-state": "1.2.6", + "@radix-ui/react-use-layout-effect": "1.1.4", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.7.2" }, @@ -4183,9 +5096,9 @@ } }, "node_modules/@radix-ui/react-direction": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.1.2.tgz", - "integrity": "sha512-C3vFhbyi4SW3PmbAi6Awpu4OzJtd0MxGurvSsYtr7p7nM8RNB3VAF3CUmnp2j50knpkrRcB7+ycVXzgLgF6yNA==", + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.1.4.tgz", + "integrity": "sha512-5pzg4FGQNpExhnhT2zlrP1wZFaYCd1K0nYWoFAdcYoYK868IEigqMX3B3f8yIoRlAhAeDWciLI6ZdCKHF9P4Vg==", "dev": true, "license": "MIT", "peerDependencies": { @@ -4199,17 +5112,17 @@ } }, "node_modules/@radix-ui/react-dismissable-layer": { - "version": "1.1.16", - "resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.16.tgz", - "integrity": "sha512-t45h68IjFx0ccBnPJqk0X6ecv69LkCFWd6DNCFQX56mUnVEXZbNOLCH/u9fHlAjFZ1RrFdl8/m4zev7B7NyhXQ==", + "version": "1.1.19", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.19.tgz", + "integrity": "sha512-8g4pfOL9HoKKLWGiypT+dphVqjFfmcXO5GBnhsG6zI+lxAx/8feQpr+1LSN8Re3hiZ+XkLNS4O9ztK11/LzQ6w==", "dev": true, "license": "MIT", "dependencies": { - "@radix-ui/primitive": "1.1.6", - "@radix-ui/react-compose-refs": "1.1.3", - "@radix-ui/react-primitive": "2.1.7", - "@radix-ui/react-use-callback-ref": "1.1.2", - "@radix-ui/react-use-effect-event": "0.0.3" + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-use-callback-ref": "1.1.4", + "@radix-ui/react-use-effect-event": "0.0.5" }, "peerDependencies": { "@types/react": "*", @@ -4227,19 +5140,19 @@ } }, "node_modules/@radix-ui/react-dropdown-menu": { - "version": "2.1.21", - "resolved": "https://registry.npmjs.org/@radix-ui/react-dropdown-menu/-/react-dropdown-menu-2.1.21.tgz", - "integrity": "sha512-gavFM1iWLmWdxWNdGJHVeWeSQul5WE/0pxfvWWt1QnD71hyyujyMCDVacqBomaSOjdxwDzYB+Ng4+MxOvrFB1A==", + "version": "2.1.24", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dropdown-menu/-/react-dropdown-menu-2.1.24.tgz", + "integrity": "sha512-geq8l2rJkxvkXsT9RMgtUE3P8pITFpTsvYpbySi1IH4fZEABD/Gp85myayFgxk0ktljGMJnCbeFkyTusvSvv7g==", "dev": true, "license": "MIT", "dependencies": { - "@radix-ui/primitive": "1.1.6", - "@radix-ui/react-compose-refs": "1.1.3", - "@radix-ui/react-context": "1.2.0", - "@radix-ui/react-id": "1.1.2", - "@radix-ui/react-menu": "2.1.21", - "@radix-ui/react-primitive": "2.1.7", - "@radix-ui/react-use-controllable-state": "1.2.4" + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-id": "1.1.4", + "@radix-ui/react-menu": "2.1.24", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-use-controllable-state": "1.2.6" }, "peerDependencies": { "@types/react": "*", @@ -4257,9 +5170,9 @@ } }, "node_modules/@radix-ui/react-focus-guards": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-guards/-/react-focus-guards-1.1.4.tgz", - "integrity": "sha512-cot/aB/mOm0IYVYTTmQcEEK1M48lZWi8FlYe5nDPQQ8NYZUlXEFgncJ9p2Kzer3RKSrY7cTTpEMLZKNo9QoP5Q==", + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-guards/-/react-focus-guards-1.1.6.tgz", + "integrity": "sha512-RNOJjfZMTyBM6xYmV3IVGXkPjIhcBAuv48POevAXwrGJhkWZ9p1rFoIS1JFooPuT193AZmRsCPhpoVJxx6OPoQ==", "dev": true, "license": "MIT", "peerDependencies": { @@ -4273,15 +5186,15 @@ } }, "node_modules/@radix-ui/react-focus-scope": { - "version": "1.1.13", - "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-scope/-/react-focus-scope-1.1.13.tgz", - "integrity": "sha512-dE04aPEuP9rvKKT0d0KjSOtTEYNg6bmCYFsoSJpfC+y91Hic28ZfDCGgv6aJ+2Kw/LBXYipMZpyqVj/OD3Z8Gg==", + "version": "1.1.16", + "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-scope/-/react-focus-scope-1.1.16.tgz", + "integrity": "sha512-wmRZ2WWLvmt6KHy2rNPOdPUjwq5xOHY02+m+udwJTn0aNIox/rkskAvJTyTLGhPK6KgrUjlJUJpgmx/+wFiFIQ==", "dev": true, "license": "MIT", "dependencies": { - "@radix-ui/react-compose-refs": "1.1.3", - "@radix-ui/react-primitive": "2.1.7", - "@radix-ui/react-use-callback-ref": "1.1.2" + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-use-callback-ref": "1.1.4" }, "peerDependencies": { "@types/react": "*", @@ -4299,18 +5212,18 @@ } }, "node_modules/@radix-ui/react-form": { - "version": "0.1.13", - "resolved": "https://registry.npmjs.org/@radix-ui/react-form/-/react-form-0.1.13.tgz", - "integrity": "sha512-PopvWqiutoZh5TJXk9EV9Wh+khbp+LQ+A0H4uHocIjVcKIi6gMlBy4sAaW15thwUSc6PrR8J62nB2uM+htqrcg==", + "version": "0.1.16", + "resolved": "https://registry.npmjs.org/@radix-ui/react-form/-/react-form-0.1.16.tgz", + "integrity": "sha512-Q4TLEn2A7TAypxwmd6R9EwrlXDvkfYSDMrq9/887AXAGh+G1rH+kYJKSTv+Si9Y0JPKTwKYv6PviAJosysNimA==", "dev": true, "license": "MIT", "dependencies": { - "@radix-ui/primitive": "1.1.6", - "@radix-ui/react-compose-refs": "1.1.3", - "@radix-ui/react-context": "1.2.0", - "@radix-ui/react-id": "1.1.2", - "@radix-ui/react-label": "2.1.12", - "@radix-ui/react-primitive": "2.1.7" + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-id": "1.1.4", + "@radix-ui/react-label": "2.1.15", + "@radix-ui/react-primitive": "2.1.10" }, "peerDependencies": { "@types/react": "*", @@ -4328,21 +5241,21 @@ } }, "node_modules/@radix-ui/react-hover-card": { - "version": "1.1.20", - "resolved": "https://registry.npmjs.org/@radix-ui/react-hover-card/-/react-hover-card-1.1.20.tgz", - "integrity": "sha512-UPmdiR8NsngWjG/y9mClzFg+Rbbpy8u0p0SKM+t7mfH4V07TiLsuylqR0RhJiRibopsawoTtMQudm/TxwHWa9w==", + "version": "1.1.23", + "resolved": "https://registry.npmjs.org/@radix-ui/react-hover-card/-/react-hover-card-1.1.23.tgz", + "integrity": "sha512-H8qONfZd3ltrU3+jHCIgITbWo6e1iTKvP9DHdrvYbX48ooRM5FjEDTn16AMwdfuOGkWdZEhpl3PLL/Wk/AnHDQ==", "dev": true, "license": "MIT", "dependencies": { - "@radix-ui/primitive": "1.1.6", - "@radix-ui/react-compose-refs": "1.1.3", - "@radix-ui/react-context": "1.2.0", - "@radix-ui/react-dismissable-layer": "1.1.16", - "@radix-ui/react-popper": "1.3.4", - "@radix-ui/react-portal": "1.1.14", - "@radix-ui/react-presence": "1.1.8", - "@radix-ui/react-primitive": "2.1.7", - "@radix-ui/react-use-controllable-state": "1.2.4" + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-dismissable-layer": "1.1.19", + "@radix-ui/react-popper": "1.3.7", + "@radix-ui/react-portal": "1.1.17", + "@radix-ui/react-presence": "1.1.10", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-use-controllable-state": "1.2.6" }, "peerDependencies": { "@types/react": "*", @@ -4360,13 +5273,13 @@ } }, "node_modules/@radix-ui/react-id": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.1.2.tgz", - "integrity": "sha512-orBC88futVpqCmhX1p4cvquNHsELQ+w+vBJnuj3ftETI5bJb0bZn3Tqu3SWN2IOcPycTnMGnhwoermvISt72sA==", + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.1.4.tgz", + "integrity": "sha512-TMQp2llA+RYn7JcjnrMnz7wN4pcVttPZnRZo52PLQsoLVKzNlVwUeHmfePgTgRluXFvlD3GD5g5MOVVTJCO0qA==", "dev": true, "license": "MIT", "dependencies": { - "@radix-ui/react-use-layout-effect": "1.1.2" + "@radix-ui/react-use-layout-effect": "1.1.4" }, "peerDependencies": { "@types/react": "*", @@ -4379,13 +5292,13 @@ } }, "node_modules/@radix-ui/react-label": { - "version": "2.1.12", - "resolved": "https://registry.npmjs.org/@radix-ui/react-label/-/react-label-2.1.12.tgz", - "integrity": "sha512-dxioNQ7VOrYKKWJIxMRmJPDSWQN0gNCUy3zaqUSBwsuFAiFzI0yLGJr2q3ml07k/HlOk55N8KEfwa1ZgfprJ3w==", + "version": "2.1.15", + "resolved": "https://registry.npmjs.org/@radix-ui/react-label/-/react-label-2.1.15.tgz", + "integrity": "sha512-o/rdYEwZTTo5tjknnPeyQFU45kUC4i/XyeDPP+HGyi6XqpOP6Zf5Ya5vh/Yfe9Id5JiuWnnAx2XqIeD3UYZt0g==", "dev": true, "license": "MIT", "dependencies": { - "@radix-ui/react-primitive": "2.1.7" + "@radix-ui/react-primitive": "2.1.10" }, "peerDependencies": { "@types/react": "*", @@ -4403,28 +5316,28 @@ } }, "node_modules/@radix-ui/react-menu": { - "version": "2.1.21", - "resolved": "https://registry.npmjs.org/@radix-ui/react-menu/-/react-menu-2.1.21.tgz", - "integrity": "sha512-2BHtaJHvvoWTECyrja1mOjN6z2dWdpeHL6b8PxqZYgex8J8xakT2KAchpZIaMwNPauIRHH/VlPJYhSSKe8lz2g==", + "version": "2.1.24", + "resolved": "https://registry.npmjs.org/@radix-ui/react-menu/-/react-menu-2.1.24.tgz", + "integrity": "sha512-uW7RVuU6Lp/ZtfeY4b3kL32zccgEWvPv1+cf17ubYzHa9cL8AHokmk36cG/XEiH/smbQvumnieXX9j/e9RqJWA==", "dev": true, "license": "MIT", "dependencies": { - "@radix-ui/primitive": "1.1.6", - "@radix-ui/react-collection": "1.1.12", - "@radix-ui/react-compose-refs": "1.1.3", - "@radix-ui/react-context": "1.2.0", - "@radix-ui/react-direction": "1.1.2", - "@radix-ui/react-dismissable-layer": "1.1.16", - "@radix-ui/react-focus-guards": "1.1.4", - "@radix-ui/react-focus-scope": "1.1.13", - "@radix-ui/react-id": "1.1.2", - "@radix-ui/react-popper": "1.3.4", - "@radix-ui/react-portal": "1.1.14", - "@radix-ui/react-presence": "1.1.8", - "@radix-ui/react-primitive": "2.1.7", - "@radix-ui/react-roving-focus": "1.1.16", - "@radix-ui/react-slot": "1.3.0", - "@radix-ui/react-use-callback-ref": "1.1.2", + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-collection": "1.1.15", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-direction": "1.1.4", + "@radix-ui/react-dismissable-layer": "1.1.19", + "@radix-ui/react-focus-guards": "1.1.6", + "@radix-ui/react-focus-scope": "1.1.16", + "@radix-ui/react-id": "1.1.4", + "@radix-ui/react-popper": "1.3.7", + "@radix-ui/react-portal": "1.1.17", + "@radix-ui/react-presence": "1.1.10", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-roving-focus": "1.1.19", + "@radix-ui/react-slot": "1.3.3", + "@radix-ui/react-use-callback-ref": "1.1.4", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.7.2" }, @@ -4444,22 +5357,22 @@ } }, "node_modules/@radix-ui/react-menubar": { - "version": "1.1.21", - "resolved": "https://registry.npmjs.org/@radix-ui/react-menubar/-/react-menubar-1.1.21.tgz", - "integrity": "sha512-uQONG1qM4D8FSEt0xRs5yDpzeSWggf8lOKqHa84NvqoVoc1qJ6XN+gdkrJuQCyEY55793gLlQI3wjgWO5A/Oqg==", + "version": "1.1.24", + "resolved": "https://registry.npmjs.org/@radix-ui/react-menubar/-/react-menubar-1.1.24.tgz", + "integrity": "sha512-eeVs0vf7cuqXaM0qLQCPcufImiJNVBXdJDLu7ZGYl2732UH23Qat/foNGrr6vYV3/DdTsBqASoggUFgH14OcZA==", "dev": true, "license": "MIT", "dependencies": { - "@radix-ui/primitive": "1.1.6", - "@radix-ui/react-collection": "1.1.12", - "@radix-ui/react-compose-refs": "1.1.3", - "@radix-ui/react-context": "1.2.0", - "@radix-ui/react-direction": "1.1.2", - "@radix-ui/react-id": "1.1.2", - "@radix-ui/react-menu": "2.1.21", - "@radix-ui/react-primitive": "2.1.7", - "@radix-ui/react-roving-focus": "1.1.16", - "@radix-ui/react-use-controllable-state": "1.2.4" + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-collection": "1.1.15", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-direction": "1.1.4", + "@radix-ui/react-id": "1.1.4", + "@radix-ui/react-menu": "2.1.24", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-roving-focus": "1.1.19", + "@radix-ui/react-use-controllable-state": "1.2.6" }, "peerDependencies": { "@types/react": "*", @@ -4477,26 +5390,26 @@ } }, "node_modules/@radix-ui/react-navigation-menu": { - "version": "1.2.19", - "resolved": "https://registry.npmjs.org/@radix-ui/react-navigation-menu/-/react-navigation-menu-1.2.19.tgz", - "integrity": "sha512-58OVQUrpWx/zGVV3lxGUyAtjX4n0305Z8xIdUAq2QlFO2m2hd1eBS4x1yIVtV8bzCQJja0TJttWcwiPI6y6tmw==", + "version": "1.2.22", + "resolved": "https://registry.npmjs.org/@radix-ui/react-navigation-menu/-/react-navigation-menu-1.2.22.tgz", + "integrity": "sha512-ou7iLEJ+yrhQndkkA4U21XIdS/CS45F4iXIkTZcb6/Ne9EMsOuDudVmCwmDnfFZZ+y1FZqXRNSIgBy+YMvZVZg==", "dev": true, "license": "MIT", "dependencies": { - "@radix-ui/primitive": "1.1.6", - "@radix-ui/react-collection": "1.1.12", - "@radix-ui/react-compose-refs": "1.1.3", - "@radix-ui/react-context": "1.2.0", - "@radix-ui/react-direction": "1.1.2", - "@radix-ui/react-dismissable-layer": "1.1.16", - "@radix-ui/react-id": "1.1.2", - "@radix-ui/react-presence": "1.1.8", - "@radix-ui/react-primitive": "2.1.7", - "@radix-ui/react-use-callback-ref": "1.1.2", - "@radix-ui/react-use-controllable-state": "1.2.4", - "@radix-ui/react-use-layout-effect": "1.1.2", - "@radix-ui/react-use-previous": "1.1.2", - "@radix-ui/react-visually-hidden": "1.2.8" + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-collection": "1.1.15", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-direction": "1.1.4", + "@radix-ui/react-dismissable-layer": "1.1.19", + "@radix-ui/react-id": "1.1.4", + "@radix-ui/react-presence": "1.1.10", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-use-callback-ref": "1.1.4", + "@radix-ui/react-use-controllable-state": "1.2.6", + "@radix-ui/react-use-layout-effect": "1.1.4", + "@radix-ui/react-use-previous": "1.1.4", + "@radix-ui/react-visually-hidden": "1.2.11" }, "peerDependencies": { "@types/react": "*", @@ -4514,24 +5427,24 @@ } }, "node_modules/@radix-ui/react-one-time-password-field": { - "version": "0.1.13", - "resolved": "https://registry.npmjs.org/@radix-ui/react-one-time-password-field/-/react-one-time-password-field-0.1.13.tgz", - "integrity": "sha512-reLtbZtEBsMcqXkjd/wOga4e8t9uxzFHdX9W/j/ZfGznTNJxLGjRrDNGnGOOWcBazMH1BI/b7Cx+hblSWSD7aw==", + "version": "0.1.16", + "resolved": "https://registry.npmjs.org/@radix-ui/react-one-time-password-field/-/react-one-time-password-field-0.1.16.tgz", + "integrity": "sha512-Tj9P6ntAJEw52oq/F0AGknXR4XncxEt7XU47O3xJQOiWfLzEy3d9gtgKfvjSzGxzHkfL+VzvxGu2KTFsloJqXw==", "dev": true, "license": "MIT", "dependencies": { - "@radix-ui/number": "1.1.2", - "@radix-ui/primitive": "1.1.6", - "@radix-ui/react-collection": "1.1.12", - "@radix-ui/react-compose-refs": "1.1.3", - "@radix-ui/react-context": "1.2.0", - "@radix-ui/react-direction": "1.1.2", - "@radix-ui/react-primitive": "2.1.7", - "@radix-ui/react-roving-focus": "1.1.16", - "@radix-ui/react-use-controllable-state": "1.2.4", - "@radix-ui/react-use-effect-event": "0.0.3", - "@radix-ui/react-use-is-hydrated": "0.1.1", - "@radix-ui/react-use-layout-effect": "1.1.2" + "@radix-ui/number": "1.1.3", + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-collection": "1.1.15", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-direction": "1.1.4", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-roving-focus": "1.1.19", + "@radix-ui/react-use-controllable-state": "1.2.6", + "@radix-ui/react-use-effect-event": "0.0.5", + "@radix-ui/react-use-is-hydrated": "0.1.3", + "@radix-ui/react-use-layout-effect": "1.1.4" }, "peerDependencies": { "@types/react": "*", @@ -4549,20 +5462,20 @@ } }, "node_modules/@radix-ui/react-password-toggle-field": { - "version": "0.1.8", - "resolved": "https://registry.npmjs.org/@radix-ui/react-password-toggle-field/-/react-password-toggle-field-0.1.8.tgz", - "integrity": "sha512-NH9puF7Es5Loh8vFELm+SyayzV27nyBw8kiP/uD9wbkwgq359FfbkKEvccrNk75z0LiSqC4REWk1iL9xdeWJkQ==", + "version": "0.1.11", + "resolved": "https://registry.npmjs.org/@radix-ui/react-password-toggle-field/-/react-password-toggle-field-0.1.11.tgz", + "integrity": "sha512-4gvFnmDXu3dgj21CqsufzIameRvlRd4SBqaWhcrlrNhRo0Y5i/49AmRJYe1fdAM3G2VNBbmin4b0D6cdQocwgw==", "dev": true, "license": "MIT", "dependencies": { - "@radix-ui/primitive": "1.1.6", - "@radix-ui/react-compose-refs": "1.1.3", - "@radix-ui/react-context": "1.2.0", - "@radix-ui/react-id": "1.1.2", - "@radix-ui/react-primitive": "2.1.7", - "@radix-ui/react-use-controllable-state": "1.2.4", - "@radix-ui/react-use-effect-event": "0.0.3", - "@radix-ui/react-use-is-hydrated": "0.1.1" + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-id": "1.1.4", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-use-controllable-state": "1.2.6", + "@radix-ui/react-use-effect-event": "0.0.5", + "@radix-ui/react-use-is-hydrated": "0.1.3" }, "peerDependencies": { "@types/react": "*", @@ -4580,25 +5493,25 @@ } }, "node_modules/@radix-ui/react-popover": { - "version": "1.1.20", - "resolved": "https://registry.npmjs.org/@radix-ui/react-popover/-/react-popover-1.1.20.tgz", - "integrity": "sha512-/PYqbsyuDkNj+IxMcRx71qNt6GelnuNulMwdCV7AtFEhUyK6XkbwreEN6CCLydMeTiDozBV4uv5aF5d12dDH7w==", + "version": "1.1.23", + "resolved": "https://registry.npmjs.org/@radix-ui/react-popover/-/react-popover-1.1.23.tgz", + "integrity": "sha512-mw58MrBlyHWFisTOYignD0vf/3gdcgAR+9of1s9G/38CbFiUwH1nCDkc0AUM9IrXFgN5Ue8n45j9WCgyM1sbiQ==", "dev": true, "license": "MIT", "dependencies": { - "@radix-ui/primitive": "1.1.6", - "@radix-ui/react-compose-refs": "1.1.3", - "@radix-ui/react-context": "1.2.0", - "@radix-ui/react-dismissable-layer": "1.1.16", - "@radix-ui/react-focus-guards": "1.1.4", - "@radix-ui/react-focus-scope": "1.1.13", - "@radix-ui/react-id": "1.1.2", - "@radix-ui/react-popper": "1.3.4", - "@radix-ui/react-portal": "1.1.14", - "@radix-ui/react-presence": "1.1.8", - "@radix-ui/react-primitive": "2.1.7", - "@radix-ui/react-slot": "1.3.0", - "@radix-ui/react-use-controllable-state": "1.2.4", + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-dismissable-layer": "1.1.19", + "@radix-ui/react-focus-guards": "1.1.6", + "@radix-ui/react-focus-scope": "1.1.16", + "@radix-ui/react-id": "1.1.4", + "@radix-ui/react-popper": "1.3.7", + "@radix-ui/react-portal": "1.1.17", + "@radix-ui/react-presence": "1.1.10", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-slot": "1.3.3", + "@radix-ui/react-use-controllable-state": "1.2.6", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.7.2" }, @@ -4618,22 +5531,22 @@ } }, "node_modules/@radix-ui/react-popper": { - "version": "1.3.4", - "resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.3.4.tgz", - "integrity": "sha512-PXnCa3XgTQk0FegMctxgqJXtFLZe4IFJdbUkB7jKSCKEpb6utEO4S9Vog/pkyCfEPdzM331gvE4xpztmBAfMng==", + "version": "1.3.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.3.7.tgz", + "integrity": "sha512-UsJrrd7w4wuKKTdvd/DNERVlwSlUcyXzjhyDwBk+3aPOsCjOY6ZSbxuw8E6lZTjjfP8Cpd0J8VVkrYUWyGYXyg==", "dev": true, "license": "MIT", "dependencies": { "@floating-ui/react-dom": "^2.0.0", - "@radix-ui/react-arrow": "1.1.12", - "@radix-ui/react-compose-refs": "1.1.3", - "@radix-ui/react-context": "1.2.0", - "@radix-ui/react-primitive": "2.1.7", - "@radix-ui/react-use-callback-ref": "1.1.2", - "@radix-ui/react-use-layout-effect": "1.1.2", - "@radix-ui/react-use-rect": "1.1.2", - "@radix-ui/react-use-size": "1.1.2", - "@radix-ui/rect": "1.1.2" + "@radix-ui/react-arrow": "1.1.15", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-use-callback-ref": "1.1.4", + "@radix-ui/react-use-layout-effect": "1.1.4", + "@radix-ui/react-use-rect": "1.1.4", + "@radix-ui/react-use-size": "1.1.4", + "@radix-ui/rect": "1.1.3" }, "peerDependencies": { "@types/react": "*", @@ -4651,14 +5564,14 @@ } }, "node_modules/@radix-ui/react-portal": { - "version": "1.1.14", - "resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.14.tgz", - "integrity": "sha512-REwjAGPMa3J9oyDE4cuWkZbwnCbbyky66NurquQklXMSDn67cl6oGFx2gO7KZhPtFNbNw9xTWNrti3VIhgluYw==", + "version": "1.1.17", + "resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.17.tgz", + "integrity": "sha512-vKQLcWypUnwZVvfV7UkGahH2g6ySe8M8R+zYBwPrv5byZ9QAW6cQVvNKo7GgmD+p8aYb6D9JBuvy8/WhOno2wQ==", "dev": true, "license": "MIT", "dependencies": { - "@radix-ui/react-primitive": "2.1.7", - "@radix-ui/react-use-layout-effect": "1.1.2" + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-use-layout-effect": "1.1.4" }, "peerDependencies": { "@types/react": "*", @@ -4676,13 +5589,13 @@ } }, "node_modules/@radix-ui/react-presence": { - "version": "1.1.8", - "resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.8.tgz", - "integrity": "sha512-0hhyrQdXMaATgq4ammLG9+iPqsXxzZkgTSIxdrJHdfLnXO4Uo5L7BoO3/Xf0AEaettadGZWGGJMw6ujzQvIpGA==", + "version": "1.1.10", + "resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.10.tgz", + "integrity": "sha512-3wyzCQ6+ubRA+D4uv9m95JYLXxmOHp05qjrkjeA7uKHHtjpPggQzc6DAb0URl7j67oR0K2foO4ip27TiX037Bw==", "dev": true, "license": "MIT", "dependencies": { - "@radix-ui/react-use-layout-effect": "1.1.2" + "@radix-ui/react-use-layout-effect": "1.1.4" }, "peerDependencies": { "@types/react": "*", @@ -4700,13 +5613,13 @@ } }, "node_modules/@radix-ui/react-primitive": { - "version": "2.1.7", - "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.7.tgz", - "integrity": "sha512-bC3NiwsprbxKjuon9l7X6BUTw7FPVzEYaL92MPEY5SCd/9hUTPXVFtVwRix7778wtRsVao+zE062gL79FZleeQ==", + "version": "2.1.10", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.10.tgz", + "integrity": "sha512-MucOnzh6hR5mid6VpkbglRAMYMjKLqRnGBbjXkzjK52fuQDd1qbkx78a5P40mkcnVXJdEVxm26E9OPAiUq7nBg==", "dev": true, "license": "MIT", "dependencies": { - "@radix-ui/react-slot": "1.3.0" + "@radix-ui/react-slot": "1.3.3" }, "peerDependencies": { "@types/react": "*", @@ -4724,14 +5637,14 @@ } }, "node_modules/@radix-ui/react-progress": { - "version": "1.1.13", - "resolved": "https://registry.npmjs.org/@radix-ui/react-progress/-/react-progress-1.1.13.tgz", - "integrity": "sha512-1dUdKDd63Tz9FfbTw20MVr28ohG4v7HOJ1dsavGBBPBS3KGzLOyLKiMJAC1OdgiY18nTSHpD4fULGK5gsLY/ww==", + "version": "1.1.16", + "resolved": "https://registry.npmjs.org/@radix-ui/react-progress/-/react-progress-1.1.16.tgz", + "integrity": "sha512-5XnomAsoZZCY+KNTxbIghpGqPruZvKFNlvcAljVAOdDRDsH4/OZQxhtwo5wdtoDM5R6MhJBb2sPnDuRFep3lzg==", "dev": true, "license": "MIT", "dependencies": { - "@radix-ui/react-context": "1.2.0", - "@radix-ui/react-primitive": "2.1.7" + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-primitive": "2.1.10" }, "peerDependencies": { "@types/react": "*", @@ -4749,21 +5662,21 @@ } }, "node_modules/@radix-ui/react-radio-group": { - "version": "1.4.4", - "resolved": "https://registry.npmjs.org/@radix-ui/react-radio-group/-/react-radio-group-1.4.4.tgz", - "integrity": "sha512-OpbUmp/korY+tjEQmHwGyQ+QQ3LBlCPC70z03Q/NSqGaHf2EijuwpjQPnswrH6cZLWyT2J6FmB+kzRoMUtPBig==", + "version": "1.4.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-radio-group/-/react-radio-group-1.4.7.tgz", + "integrity": "sha512-cgYFEkntCxppHZgtSZ+7vh0wbZQ+IC7PPMw8DSnRG27B6kDd32/Zw0OJt7dGDigCoprMuWHjg2PvUn3PYvPFoQ==", "dev": true, "license": "MIT", "dependencies": { - "@radix-ui/primitive": "1.1.6", - "@radix-ui/react-compose-refs": "1.1.3", - "@radix-ui/react-context": "1.2.0", - "@radix-ui/react-direction": "1.1.2", - "@radix-ui/react-presence": "1.1.8", - "@radix-ui/react-primitive": "2.1.7", - "@radix-ui/react-roving-focus": "1.1.16", - "@radix-ui/react-use-controllable-state": "1.2.4", - "@radix-ui/react-use-size": "1.1.2" + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-direction": "1.1.4", + "@radix-ui/react-presence": "1.1.10", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-roving-focus": "1.1.19", + "@radix-ui/react-use-controllable-state": "1.2.6", + "@radix-ui/react-use-size": "1.1.4" }, "peerDependencies": { "@types/react": "*", @@ -4781,23 +5694,23 @@ } }, "node_modules/@radix-ui/react-roving-focus": { - "version": "1.1.16", - "resolved": "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-1.1.16.tgz", - "integrity": "sha512-w7lLsTSd3940vFYEshKkHw+NGf7H0QDJPHYsy8NRjDCVbO6ZdKW1X/xoJSYHZtttnrdZiYqbN2O/2uHGB0zasw==", + "version": "1.1.19", + "resolved": "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-1.1.19.tgz", + "integrity": "sha512-V9jI6hDjT7l3jsCQD9bLNvDLM3tH/gdbOTp7Tefp3hbbgCGQoK7tUvrWiRlcoBHIZ809ElXwNQwVo0B98LuTXQ==", "dev": true, "license": "MIT", "dependencies": { - "@radix-ui/primitive": "1.1.6", - "@radix-ui/react-collection": "1.1.12", - "@radix-ui/react-compose-refs": "1.1.3", - "@radix-ui/react-context": "1.2.0", - "@radix-ui/react-direction": "1.1.2", - "@radix-ui/react-id": "1.1.2", - "@radix-ui/react-primitive": "2.1.7", - "@radix-ui/react-use-callback-ref": "1.1.2", - "@radix-ui/react-use-controllable-state": "1.2.4", - "@radix-ui/react-use-is-hydrated": "0.1.1", - "@radix-ui/react-use-layout-effect": "1.1.2" + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-collection": "1.1.15", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-direction": "1.1.4", + "@radix-ui/react-id": "1.1.4", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-use-callback-ref": "1.1.4", + "@radix-ui/react-use-controllable-state": "1.2.6", + "@radix-ui/react-use-is-hydrated": "0.1.3", + "@radix-ui/react-use-layout-effect": "1.1.4" }, "peerDependencies": { "@types/react": "*", @@ -4815,21 +5728,21 @@ } }, "node_modules/@radix-ui/react-scroll-area": { - "version": "1.2.15", - "resolved": "https://registry.npmjs.org/@radix-ui/react-scroll-area/-/react-scroll-area-1.2.15.tgz", - "integrity": "sha512-JVBHNfTBbGd9hhq/xZZOgmVnBCXhLs8PJJ8vMzgwI0pLZNsKckW9pkoqHyxokUCt1hoxbwDNvF9DItEeZsG68g==", + "version": "1.2.18", + "resolved": "https://registry.npmjs.org/@radix-ui/react-scroll-area/-/react-scroll-area-1.2.18.tgz", + "integrity": "sha512-Zn5Cd171wxsO3Dfg8HaW6RifTb9CYTKQJHs/G4+LN1GfmJpaQMZQyQxMprVPHpaz7QY4l9BxK2JwQuzHsXC8nA==", "dev": true, "license": "MIT", "dependencies": { - "@radix-ui/number": "1.1.2", - "@radix-ui/primitive": "1.1.6", - "@radix-ui/react-compose-refs": "1.1.3", - "@radix-ui/react-context": "1.2.0", - "@radix-ui/react-direction": "1.1.2", - "@radix-ui/react-presence": "1.1.8", - "@radix-ui/react-primitive": "2.1.7", - "@radix-ui/react-use-callback-ref": "1.1.2", - "@radix-ui/react-use-layout-effect": "1.1.2" + "@radix-ui/number": "1.1.3", + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-direction": "1.1.4", + "@radix-ui/react-presence": "1.1.10", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-use-callback-ref": "1.1.4", + "@radix-ui/react-use-layout-effect": "1.1.4" }, "peerDependencies": { "@types/react": "*", @@ -4847,32 +5760,32 @@ } }, "node_modules/@radix-ui/react-select": { - "version": "2.3.4", - "resolved": "https://registry.npmjs.org/@radix-ui/react-select/-/react-select-2.3.4.tgz", - "integrity": "sha512-E2JxqAvaTUEhWtBptWo02g8FnLYPymv9ahEvW/cZQPPV4ySeyo0M8n3sXccsLUAIfMbexnfXt91qF7UjTbTMMg==", + "version": "2.3.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-select/-/react-select-2.3.7.tgz", + "integrity": "sha512-WFGImkmbzcfxeIwq/+4HvRN0pizBwbwQUED4I13ezQsDdfl38ZntN6TmR8XaSzPBqoCToe8rF75j6NPNDSzhbg==", "dev": true, "license": "MIT", "dependencies": { - "@radix-ui/number": "1.1.2", - "@radix-ui/primitive": "1.1.6", - "@radix-ui/react-collection": "1.1.12", - "@radix-ui/react-compose-refs": "1.1.3", - "@radix-ui/react-context": "1.2.0", - "@radix-ui/react-direction": "1.1.2", - "@radix-ui/react-dismissable-layer": "1.1.16", - "@radix-ui/react-focus-guards": "1.1.4", - "@radix-ui/react-focus-scope": "1.1.13", - "@radix-ui/react-id": "1.1.2", - "@radix-ui/react-popper": "1.3.4", - "@radix-ui/react-portal": "1.1.14", - "@radix-ui/react-presence": "1.1.8", - "@radix-ui/react-primitive": "2.1.7", - "@radix-ui/react-slot": "1.3.0", - "@radix-ui/react-use-callback-ref": "1.1.2", - "@radix-ui/react-use-controllable-state": "1.2.4", - "@radix-ui/react-use-layout-effect": "1.1.2", - "@radix-ui/react-use-previous": "1.1.2", - "@radix-ui/react-visually-hidden": "1.2.8", + "@radix-ui/number": "1.1.3", + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-collection": "1.1.15", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-direction": "1.1.4", + "@radix-ui/react-dismissable-layer": "1.1.19", + "@radix-ui/react-focus-guards": "1.1.6", + "@radix-ui/react-focus-scope": "1.1.16", + "@radix-ui/react-id": "1.1.4", + "@radix-ui/react-popper": "1.3.7", + "@radix-ui/react-portal": "1.1.17", + "@radix-ui/react-presence": "1.1.10", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-slot": "1.3.3", + "@radix-ui/react-use-callback-ref": "1.1.4", + "@radix-ui/react-use-controllable-state": "1.2.6", + "@radix-ui/react-use-layout-effect": "1.1.4", + "@radix-ui/react-use-previous": "1.1.4", + "@radix-ui/react-visually-hidden": "1.2.11", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.7.2" }, @@ -4892,13 +5805,13 @@ } }, "node_modules/@radix-ui/react-separator": { - "version": "1.1.12", - "resolved": "https://registry.npmjs.org/@radix-ui/react-separator/-/react-separator-1.1.12.tgz", - "integrity": "sha512-2hezgFBBR5jU3S9L9bIZ9Uag6LnvxuFBNsLCfTR8qx+NshuvFmpL4C72+5zMS3Z6UgHNSU1thOw2UaBBPEDpsQ==", + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/@radix-ui/react-separator/-/react-separator-1.1.15.tgz", + "integrity": "sha512-jOLO4lssEzWpoDu7G+Ze4VjwMRUBt291pnZD0gmalREZipnTX3wadQo7Fy48GCTfe14/YRN6rw/rOJqrE85Wxw==", "dev": true, "license": "MIT", "dependencies": { - "@radix-ui/react-primitive": "2.1.7" + "@radix-ui/react-primitive": "2.1.10" }, "peerDependencies": { "@types/react": "*", @@ -4916,23 +5829,23 @@ } }, "node_modules/@radix-ui/react-slider": { - "version": "1.4.4", - "resolved": "https://registry.npmjs.org/@radix-ui/react-slider/-/react-slider-1.4.4.tgz", - "integrity": "sha512-8dUytW34KoJaB22ctfP7hqUCuyYa8xn2w7H8kCneeOtS5oM7UBivcnZtR8P4kPYMgdoAZlkMhE9/qkYZ5MlRzQ==", + "version": "1.4.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slider/-/react-slider-1.4.7.tgz", + "integrity": "sha512-mTSLf1GC/C0moWjTbvCM6Qn/gBjvlFt1azuWF2v7MN5C3Zq2U2J2lN3ZEYkpujuOU5Ro7A28wkviSxaKnG0BYg==", "dev": true, "license": "MIT", "dependencies": { - "@radix-ui/number": "1.1.2", - "@radix-ui/primitive": "1.1.6", - "@radix-ui/react-collection": "1.1.12", - "@radix-ui/react-compose-refs": "1.1.3", - "@radix-ui/react-context": "1.2.0", - "@radix-ui/react-direction": "1.1.2", - "@radix-ui/react-primitive": "2.1.7", - "@radix-ui/react-use-controllable-state": "1.2.4", - "@radix-ui/react-use-layout-effect": "1.1.2", - "@radix-ui/react-use-previous": "1.1.2", - "@radix-ui/react-use-size": "1.1.2" + "@radix-ui/number": "1.1.3", + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-collection": "1.1.15", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-direction": "1.1.4", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-use-controllable-state": "1.2.6", + "@radix-ui/react-use-layout-effect": "1.1.4", + "@radix-ui/react-use-previous": "1.1.4", + "@radix-ui/react-use-size": "1.1.4" }, "peerDependencies": { "@types/react": "*", @@ -4950,13 +5863,13 @@ } }, "node_modules/@radix-ui/react-slot": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.3.0.tgz", - "integrity": "sha512-MojKku4U/miO8Av4Dkb+ctMAQx7JmY96LmtDQlAarCRtd7rN52QCSzBF+XAvr5S6coSVj9HEPBgHAHKEJVk/WA==", + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.3.3.tgz", + "integrity": "sha512-qx7oqnYbxnK9kYI9m317qmFmEgo6ywqWvbTogdj7cL9p3/yx4M48p7Rnw5z3H890cL/ow/EeWJsuTykeZVXP5Q==", "dev": true, "license": "MIT", "dependencies": { - "@radix-ui/react-compose-refs": "1.1.3" + "@radix-ui/react-compose-refs": "1.1.5" }, "peerDependencies": { "@types/react": "*", @@ -4969,18 +5882,18 @@ } }, "node_modules/@radix-ui/react-switch": { - "version": "1.3.4", - "resolved": "https://registry.npmjs.org/@radix-ui/react-switch/-/react-switch-1.3.4.tgz", - "integrity": "sha512-7iGMj1SfZBAc6xRiy0Y3Wr/v52viQeDhOmaM3fNRyNf2nbYooZA3kKoEDGLPtrDv8JitpzcueqdZLusVMojLdQ==", + "version": "1.3.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-switch/-/react-switch-1.3.7.tgz", + "integrity": "sha512-48tB/4dn2UVLBCYhTu9AuR63IHl73l/qLbLgxd86noTUor4/K4LFDAcYjK+isP5313qxaFpjPVogE7+Y0/V3Kw==", "dev": true, "license": "MIT", "dependencies": { - "@radix-ui/primitive": "1.1.6", - "@radix-ui/react-compose-refs": "1.1.3", - "@radix-ui/react-context": "1.2.0", - "@radix-ui/react-primitive": "2.1.7", - "@radix-ui/react-use-controllable-state": "1.2.4", - "@radix-ui/react-use-size": "1.1.2" + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-use-controllable-state": "1.2.6", + "@radix-ui/react-use-size": "1.1.4" }, "peerDependencies": { "@types/react": "*", @@ -4998,20 +5911,20 @@ } }, "node_modules/@radix-ui/react-tabs": { - "version": "1.1.18", - "resolved": "https://registry.npmjs.org/@radix-ui/react-tabs/-/react-tabs-1.1.18.tgz", - "integrity": "sha512-1zq2XkQkK/KfbZn84edytYpOLquhNalra5LXc3NAMKhNRSGtyXqjMv6OyC9jlSuNKpqvQtsb57WKoICNk1v/sQ==", + "version": "1.1.21", + "resolved": "https://registry.npmjs.org/@radix-ui/react-tabs/-/react-tabs-1.1.21.tgz", + "integrity": "sha512-UKxJlZid7FVtsk/WTxj4i4uSEgj2Au+KBbS7SQyTlzMhhn+86Cz3tISZdTa87bfEfcuvZezf2ZsxD4xuEKtkog==", "dev": true, "license": "MIT", "dependencies": { - "@radix-ui/primitive": "1.1.6", - "@radix-ui/react-context": "1.2.0", - "@radix-ui/react-direction": "1.1.2", - "@radix-ui/react-id": "1.1.2", - "@radix-ui/react-presence": "1.1.8", - "@radix-ui/react-primitive": "2.1.7", - "@radix-ui/react-roving-focus": "1.1.16", - "@radix-ui/react-use-controllable-state": "1.2.4" + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-direction": "1.1.4", + "@radix-ui/react-id": "1.1.4", + "@radix-ui/react-presence": "1.1.10", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-roving-focus": "1.1.19", + "@radix-ui/react-use-controllable-state": "1.2.6" }, "peerDependencies": { "@types/react": "*", @@ -5029,24 +5942,24 @@ } }, "node_modules/@radix-ui/react-toast": { - "version": "1.2.20", - "resolved": "https://registry.npmjs.org/@radix-ui/react-toast/-/react-toast-1.2.20.tgz", - "integrity": "sha512-S28OtO1IvYSpWfaUBtiYCTTwRLF8doafj+a+uQw8rc8dLINS52uuG3CIPCeZc3Jfdb/S7o7HhlQxLoXlIYRu6g==", + "version": "1.2.23", + "resolved": "https://registry.npmjs.org/@radix-ui/react-toast/-/react-toast-1.2.23.tgz", + "integrity": "sha512-ofhyAsYaocRGOs/n0XWdUOSVzEAG6BfrMVM8z0c0kLEWY38w/0WuMFPTJP/HVaZPYkMvHZoKIIhNcjbTCBILPg==", "dev": true, "license": "MIT", "dependencies": { - "@radix-ui/primitive": "1.1.6", - "@radix-ui/react-collection": "1.1.12", - "@radix-ui/react-compose-refs": "1.1.3", - "@radix-ui/react-context": "1.2.0", - "@radix-ui/react-dismissable-layer": "1.1.16", - "@radix-ui/react-portal": "1.1.14", - "@radix-ui/react-presence": "1.1.8", - "@radix-ui/react-primitive": "2.1.7", - "@radix-ui/react-use-callback-ref": "1.1.2", - "@radix-ui/react-use-controllable-state": "1.2.4", - "@radix-ui/react-use-layout-effect": "1.1.2", - "@radix-ui/react-visually-hidden": "1.2.8" + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-collection": "1.1.15", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-dismissable-layer": "1.1.19", + "@radix-ui/react-portal": "1.1.17", + "@radix-ui/react-presence": "1.1.10", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-use-callback-ref": "1.1.4", + "@radix-ui/react-use-controllable-state": "1.2.6", + "@radix-ui/react-use-layout-effect": "1.1.4", + "@radix-ui/react-visually-hidden": "1.2.11" }, "peerDependencies": { "@types/react": "*", @@ -5064,15 +5977,15 @@ } }, "node_modules/@radix-ui/react-toggle": { - "version": "1.1.15", - "resolved": "https://registry.npmjs.org/@radix-ui/react-toggle/-/react-toggle-1.1.15.tgz", - "integrity": "sha512-tyCejFjhJ51UKFVIG8jh9nTdRIsFPxrgrI4IdlxuJeP+AKTfTko+0gBueyBFLHqsyE71Aj9PKHjMnG+YRPyKhA==", + "version": "1.1.18", + "resolved": "https://registry.npmjs.org/@radix-ui/react-toggle/-/react-toggle-1.1.18.tgz", + "integrity": "sha512-7lonPlKfSacd20GlOBx2ltuVKz9oqWYZz+oMQyOltw6t1y2nyftj2ZmwwUHYn49kqfDWcp8dNZm5NgV+5Z+mug==", "dev": true, "license": "MIT", "dependencies": { - "@radix-ui/primitive": "1.1.6", - "@radix-ui/react-primitive": "2.1.7", - "@radix-ui/react-use-controllable-state": "1.2.4" + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-use-controllable-state": "1.2.6" }, "peerDependencies": { "@types/react": "*", @@ -5090,19 +6003,19 @@ } }, "node_modules/@radix-ui/react-toggle-group": { - "version": "1.1.16", - "resolved": "https://registry.npmjs.org/@radix-ui/react-toggle-group/-/react-toggle-group-1.1.16.tgz", - "integrity": "sha512-uil+A0Um3LaZQJkMap4nIg0VgqWc0j3iNU4AXf9a/zHOgPHNYWfVk5WVsG2296Y8HLv1bxiN7uQJblHc1+00tw==", + "version": "1.1.19", + "resolved": "https://registry.npmjs.org/@radix-ui/react-toggle-group/-/react-toggle-group-1.1.19.tgz", + "integrity": "sha512-OtnwuSVjd1Ofi+AdnvhsjQdyuhCDwYs1w9RyB5BN/OavXOVQo42SYqQjwUnbPnaiPFBpQ9aX70dWeee+v2oBLA==", "dev": true, "license": "MIT", "dependencies": { - "@radix-ui/primitive": "1.1.6", - "@radix-ui/react-context": "1.2.0", - "@radix-ui/react-direction": "1.1.2", - "@radix-ui/react-primitive": "2.1.7", - "@radix-ui/react-roving-focus": "1.1.16", - "@radix-ui/react-toggle": "1.1.15", - "@radix-ui/react-use-controllable-state": "1.2.4" + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-direction": "1.1.4", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-roving-focus": "1.1.19", + "@radix-ui/react-toggle": "1.1.18", + "@radix-ui/react-use-controllable-state": "1.2.6" }, "peerDependencies": { "@types/react": "*", @@ -5120,19 +6033,19 @@ } }, "node_modules/@radix-ui/react-toolbar": { - "version": "1.1.16", - "resolved": "https://registry.npmjs.org/@radix-ui/react-toolbar/-/react-toolbar-1.1.16.tgz", - "integrity": "sha512-ZnvUAH+ftoRYzUzFQ8gqKnQ1lUFYb3amguGu+BXpfjvLIkjmXCcHCJlQeBLBlJCOtGNVtP+wHrZaUCC/zYKQMg==", + "version": "1.1.19", + "resolved": "https://registry.npmjs.org/@radix-ui/react-toolbar/-/react-toolbar-1.1.19.tgz", + "integrity": "sha512-Ph0IvtYw4VB12ZnZg+YtrGs8yJQsnizwo/zu0R4Y/nWugtJzA7Pg1eWeuDR9+LSqn+xjamss+UOSOJJJ4gx8jw==", "dev": true, "license": "MIT", "dependencies": { - "@radix-ui/primitive": "1.1.6", - "@radix-ui/react-context": "1.2.0", - "@radix-ui/react-direction": "1.1.2", - "@radix-ui/react-primitive": "2.1.7", - "@radix-ui/react-roving-focus": "1.1.16", - "@radix-ui/react-separator": "1.1.12", - "@radix-ui/react-toggle-group": "1.1.16" + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-direction": "1.1.4", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-roving-focus": "1.1.19", + "@radix-ui/react-separator": "1.1.15", + "@radix-ui/react-toggle-group": "1.1.19" }, "peerDependencies": { "@types/react": "*", @@ -5150,25 +6063,25 @@ } }, "node_modules/@radix-ui/react-tooltip": { - "version": "1.2.13", - "resolved": "https://registry.npmjs.org/@radix-ui/react-tooltip/-/react-tooltip-1.2.13.tgz", - "integrity": "sha512-56XPNYGMnGBcPyiBTaEXB7IGPybbsdNkFgSv90SCrHkXnu2Av1HhsyZMegzXlTu/QHA3V6/l22GZCv9iEoiqmQ==", + "version": "1.2.16", + "resolved": "https://registry.npmjs.org/@radix-ui/react-tooltip/-/react-tooltip-1.2.16.tgz", + "integrity": "sha512-6EamKFRRnlpdadndbZ6LMwycfwkwPte1B42hs6QA0gYhjaOKqW4PZ4pjaW9UrlDX5eVt/OjncE7BFTPL5nmZhg==", "dev": true, "license": "MIT", "dependencies": { - "@radix-ui/primitive": "1.1.6", - "@radix-ui/react-compose-refs": "1.1.3", - "@radix-ui/react-context": "1.2.0", - "@radix-ui/react-dismissable-layer": "1.1.16", - "@radix-ui/react-id": "1.1.2", - "@radix-ui/react-popper": "1.3.4", - "@radix-ui/react-portal": "1.1.14", - "@radix-ui/react-presence": "1.1.8", - "@radix-ui/react-primitive": "2.1.7", - "@radix-ui/react-slot": "1.3.0", - "@radix-ui/react-use-controllable-state": "1.2.4", - "@radix-ui/react-use-layout-effect": "1.1.2", - "@radix-ui/react-visually-hidden": "1.2.8" + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-dismissable-layer": "1.1.19", + "@radix-ui/react-id": "1.1.4", + "@radix-ui/react-popper": "1.3.7", + "@radix-ui/react-portal": "1.1.17", + "@radix-ui/react-presence": "1.1.10", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-slot": "1.3.3", + "@radix-ui/react-use-controllable-state": "1.2.6", + "@radix-ui/react-use-layout-effect": "1.1.4", + "@radix-ui/react-visually-hidden": "1.2.11" }, "peerDependencies": { "@types/react": "*", @@ -5186,9 +6099,9 @@ } }, "node_modules/@radix-ui/react-use-callback-ref": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.2.tgz", - "integrity": "sha512-xCso9j1/u8sEgP1RNHjFrXJLApL8LiqOkI1R4ywuN00rxWdYg4oQXuwKLS3i0j5NWLromUD27/4nlxj2UFVvIw==", + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.4.tgz", + "integrity": "sha512-R6OUY2e2fA6Yn6s+VSx5KBV6Nx8LQEhu+cz7LCej18rQ1HLyg9PSC9jP/ZNx0o6FAIK9c0F1kHylzSxKsdlkrQ==", "dev": true, "license": "MIT", "peerDependencies": { @@ -5202,15 +6115,15 @@ } }, "node_modules/@radix-ui/react-use-controllable-state": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.2.4.tgz", - "integrity": "sha512-cx2DixxmSfjCcEoRvDvy1NLd6SWK94XFcEEOZUcharUlXbmahFQGKCfwdKZL2ub34iIwOPOEFVF80xb+yfLYiA==", + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.2.6.tgz", + "integrity": "sha512-uEQJGT97ZA/TgP/Hydw47lHu+/vQj6z/0jA+WeTbK1o9Rx45GImjpD0tc3W5ad3D6XTSR6e1yEO0FvGq6WQfVQ==", "dev": true, "license": "MIT", "dependencies": { - "@radix-ui/primitive": "1.1.6", - "@radix-ui/react-use-effect-event": "0.0.3", - "@radix-ui/react-use-layout-effect": "1.1.2" + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-use-effect-event": "0.0.5", + "@radix-ui/react-use-layout-effect": "1.1.4" }, "peerDependencies": { "@types/react": "*", @@ -5223,13 +6136,13 @@ } }, "node_modules/@radix-ui/react-use-effect-event": { - "version": "0.0.3", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-effect-event/-/react-use-effect-event-0.0.3.tgz", - "integrity": "sha512-6c8ZqvPTWILEKnyVkP53EGRCcpnJiKTC21sS/6R1GF5xKyHJJWQEPfkqlcgUkdRQivd6tb23abUwe4ngWmY0JA==", + "version": "0.0.5", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-effect-event/-/react-use-effect-event-0.0.5.tgz", + "integrity": "sha512-7cshFL8HGS/7HEiHH+9kL9HBwp2sa9yX18Knwek6KYWmXwM7pegMgta2AXMQKI+rq3JnfSj9x8wYqFMTdG1Jgg==", "dev": true, "license": "MIT", "dependencies": { - "@radix-ui/react-use-layout-effect": "1.1.2" + "@radix-ui/react-use-layout-effect": "1.1.4" }, "peerDependencies": { "@types/react": "*", @@ -5242,13 +6155,13 @@ } }, "node_modules/@radix-ui/react-use-escape-keydown": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-escape-keydown/-/react-use-escape-keydown-1.1.3.tgz", - "integrity": "sha512-3wEkMiPHXha/2VadZ68rYBcmYnPINVGl4Y3gtcM7fKRjANk0OscK+cdqBgUWdozb7YJxsh0vefM7vgAMHXOjqg==", + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-escape-keydown/-/react-use-escape-keydown-1.1.5.tgz", + "integrity": "sha512-ge3ipobwSXTj4JyVtswQ7qZj0ZHdtbGuOno/LrgAAeSxtsJ6Vs4Gz5IkPH2bmqpjcLUFoqGhA/mueuIf63UXlA==", "dev": true, "license": "MIT", "dependencies": { - "@radix-ui/react-use-callback-ref": "1.1.2" + "@radix-ui/react-use-callback-ref": "1.1.4" }, "peerDependencies": { "@types/react": "*", @@ -5261,9 +6174,9 @@ } }, "node_modules/@radix-ui/react-use-is-hydrated": { - "version": "0.1.1", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-is-hydrated/-/react-use-is-hydrated-0.1.1.tgz", - "integrity": "sha512-qwOiz4Tjo8CNnrOLAYUMXeZwDzXgXpvK4TKQPmWLECM9XoWvA6+0Z2/7Ag3A4ivjS4ovbLJPbskkxioFyBhr8A==", + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-is-hydrated/-/react-use-is-hydrated-0.1.3.tgz", + "integrity": "sha512-umO/aJ+82CpOnhDZUTbILCQf7kU/g0iv+oGs/Q8jw7IkhWBzaEP4sA268PhFAJTFetbwp3ICc6ktpI4TqtxcIw==", "dev": true, "license": "MIT", "peerDependencies": { @@ -5277,9 +6190,9 @@ } }, "node_modules/@radix-ui/react-use-layout-effect": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.2.tgz", - "integrity": "sha512-jrBWOxZITuGcnjRCM2t2U5ZPkCLxD+Ym6DjfssS5haTj2iiak/DOb64JeN6OdLfLgptb6/e2kKR+ZuTrGoZTPA==", + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.4.tgz", + "integrity": "sha512-K20DkRkUwDnxEYMBPcg3Y6voLkEy5p5QQmszZgLngKKiC7dzBR/aEuK3w1qlx2JWDUNH6FluahYdgR3BP+QbYw==", "dev": true, "license": "MIT", "peerDependencies": { @@ -5293,9 +6206,9 @@ } }, "node_modules/@radix-ui/react-use-previous": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-previous/-/react-use-previous-1.1.2.tgz", - "integrity": "sha512-IGBQPtRFdhN6MQ8dbegVmBq1LVZluya3F1jWY+puIcQC3MHctRwTDSBWCkL/3ZcnMJLTMJ++Z+ktmvg0F89iCw==", + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-previous/-/react-use-previous-1.1.4.tgz", + "integrity": "sha512-XoSLhbRbqxFtgJoi2fNHA3C6pDlY34x508vUpUGoFZfvePfHXHbE1lC4FYFMnJWgiCRroSTw6fOsXQoVS9RwZg==", "dev": true, "license": "MIT", "peerDependencies": { @@ -5309,13 +6222,13 @@ } }, "node_modules/@radix-ui/react-use-rect": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-rect/-/react-use-rect-1.1.2.tgz", - "integrity": "sha512-d8a+bBY/FxikNPlgJJoaBHZX+zKVbWHYJGTLnLvveQgFSTntkGdEKv3JDtHrMS0DNYpllz2nRsTLGLKYttbpmw==", + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-rect/-/react-use-rect-1.1.4.tgz", + "integrity": "sha512-cSOCh6JlkmfjLyNcLiu2nB4v+nm+dkZ+Q5KHWk/soo4U7ZLiEQFKHK9/YmtBHjfCEaU43IBKQOc4/uJmCaiCTQ==", "dev": true, "license": "MIT", "dependencies": { - "@radix-ui/rect": "1.1.2" + "@radix-ui/rect": "1.1.3" }, "peerDependencies": { "@types/react": "*", @@ -5328,13 +6241,13 @@ } }, "node_modules/@radix-ui/react-use-size": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-size/-/react-use-size-1.1.2.tgz", - "integrity": "sha512-giWQp+4mxjBPt4KZ0MmyuykFNWfbDxKt4x+fPkRYmgRFJSbCZFzUglvMb/Kjn38tm10YP4ufiQZDx3zna4LU6w==", + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-size/-/react-use-size-1.1.4.tgz", + "integrity": "sha512-D3anSY15EJoxrihpsXI6SMrmmonnQtR2ni7arO+Lfdg3O95b9hNXxONk8jA5C8ANdF/h5HMAxejgs8PWJ6rlhw==", "dev": true, "license": "MIT", "dependencies": { - "@radix-ui/react-use-layout-effect": "1.1.2" + "@radix-ui/react-use-layout-effect": "1.1.4" }, "peerDependencies": { "@types/react": "*", @@ -5347,13 +6260,13 @@ } }, "node_modules/@radix-ui/react-visually-hidden": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/@radix-ui/react-visually-hidden/-/react-visually-hidden-1.2.8.tgz", - "integrity": "sha512-FjsQEpkNBJJYiPSat6jh2LGKLPX2jAoDVS3AZSBNX3cOUoEGhw/f+z2FCY8Cf1NkoYIbytJ1f4mlWPQpR+MjVg==", + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@radix-ui/react-visually-hidden/-/react-visually-hidden-1.2.11.tgz", + "integrity": "sha512-NFS86RYYZb4/exihaESBGOpMJFz8MGLAfu3mOBSGByVnVPC9JPASfYubxd/8KbkQK0sYAv8lVQDEQukDX/qXvQ==", "dev": true, "license": "MIT", "dependencies": { - "@radix-ui/react-primitive": "2.1.7" + "@radix-ui/react-primitive": "2.1.10" }, "peerDependencies": { "@types/react": "*", @@ -5371,9 +6284,9 @@ } }, "node_modules/@radix-ui/rect": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@radix-ui/rect/-/rect-1.1.2.tgz", - "integrity": "sha512-xnXE7wG13PI+cxieVssYXlQJuYVRhH9NBoxt3KNwzghDIA69GMm7d4wXRouHIYjE+KvS6U/MsMO73NdS2MH9ZA==", + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/rect/-/rect-1.1.3.tgz", + "integrity": "sha512-JtyZR+mqgBibTo8xea3B6ZRmzZiM/YeVBtUkas6zMuXjAlfIFIW2FgqeM9eLyvEaYX66vr6DJMK+4U6LV0KhNw==", "dev": true, "license": "MIT" }, @@ -5953,29 +6866,29 @@ } }, "node_modules/@simple-libs/child-process-utils": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@simple-libs/child-process-utils/-/child-process-utils-1.0.2.tgz", - "integrity": "sha512-/4R8QKnd/8agJynkNdJmNw2MBxuFTRcNFnE5Sg/G+jkSsV8/UBgULMzhizWWW42p8L5H7flImV2ATi79Ove2Tw==", + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@simple-libs/child-process-utils/-/child-process-utils-2.0.0.tgz", + "integrity": "sha512-dvNoRKLijXnD0XoJAz94pbNuB5GQgDr55UhpSPhffDkTT0Cmcqh9jSCOtwfT2d4H6MI9E7c4SgtMuJXZ6F3c6A==", "dev": true, "license": "MIT", "dependencies": { - "@simple-libs/stream-utils": "^1.2.0" + "@simple-libs/stream-utils": "^2.0.0" }, "engines": { - "node": ">=18" + "node": ">=22" }, "funding": { "url": "https://ko-fi.com/dangreen" } }, "node_modules/@simple-libs/stream-utils": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/@simple-libs/stream-utils/-/stream-utils-1.2.0.tgz", - "integrity": "sha512-KxXvfapcixpz6rVEB6HPjOUZT22yN6v0vI0urQSk1L8MlEWPDFCZkhw2xmkyoTGYeFw7tWTZd7e3lVzRZRN/EA==", + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@simple-libs/stream-utils/-/stream-utils-2.0.0.tgz", + "integrity": "sha512-fCTuZK4QBa+39Oz9l4OGfJfz+GpwCp3AqO7Zch3to99xHPgstVsRFpeQ8LNd2o1Gv8raL2mCFwiaHh7bFSp5DQ==", "dev": true, "license": "MIT", "engines": { - "node": ">=18" + "node": ">=22" }, "funding": { "url": "https://ko-fi.com/dangreen" @@ -6627,12 +7540,12 @@ } }, "node_modules/@tanstack/react-virtual": { - "version": "3.14.6", - "resolved": "https://registry.npmjs.org/@tanstack/react-virtual/-/react-virtual-3.14.6.tgz", - "integrity": "sha512-4+Uq8m0/gzO4kMCHUEpTtGX1RnONK0C+g88b2ltwPMWUBiaVarBuWKoPJaz7gj1cKCVRAdyu+U8GcKhwCc2beA==", + "version": "3.14.9", + "resolved": "https://registry.npmjs.org/@tanstack/react-virtual/-/react-virtual-3.14.9.tgz", + "integrity": "sha512-qZyr0FZDP8rDC4WBhsryIZmAd9bveJvFGUJJtskWaew6/0dTRS6wZxnR6VQ5bY2KwL3LjerrHqQLk3a0GKcPXQ==", "license": "MIT", "dependencies": { - "@tanstack/virtual-core": "3.17.4" + "@tanstack/virtual-core": "3.17.7" }, "funding": { "type": "github", @@ -6644,9 +7557,9 @@ } }, "node_modules/@tanstack/virtual-core": { - "version": "3.17.4", - "resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.17.4.tgz", - "integrity": "sha512-nGm5KteqxasUdThLc2izl6dHUqLv0LQj7Nuyo5gYalTPf/U8a9ermvsl7reT+6ioBW1l8WfpP/mcU338nLXpqw==", + "version": "3.17.7", + "resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.17.7.tgz", + "integrity": "sha512-bp+v10y65sp2H7WpWfIMyxTNfl8ZVfxFTLRjPIFRryi6FV/J33z4IS53WO4pTk36KlvJ4iLiQz+oaydDC1xbcA==", "license": "MIT", "funding": { "type": "github", @@ -6691,9 +7604,9 @@ "license": "MIT" }, "node_modules/@testing-library/jest-dom": { - "version": "6.9.1", - "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.9.1.tgz", - "integrity": "sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA==", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-7.0.0.tgz", + "integrity": "sha512-HKAH9C6mBo5yBG6yRO5i43L2iisencAo5z+o5P/saHUoY+miC5ivXRxHBJcFyB5ypPNxHJdK3BoF/3O4DIptMg==", "dev": true, "license": "MIT", "dependencies": { @@ -6705,9 +7618,12 @@ "redent": "^3.0.0" }, "engines": { - "node": ">=14", + "node": ">=22", "npm": ">=6", "yarn": ">=1" + }, + "peerDependencies": { + "@testing-library/dom": ">=10 <11" } }, "node_modules/@testing-library/react": { @@ -6984,9 +7900,9 @@ "license": "MIT" }, "node_modules/@types/multer": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/@types/multer/-/multer-2.1.0.tgz", - "integrity": "sha512-zYZb0+nJhOHtPpGDb3vqPjwpdeGlGC157VpkqNQL+UU2qwoacoQ7MpsAmUptI/0Oa127X32JzWDqQVEXp2RcIA==", + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@types/multer/-/multer-2.2.0.tgz", + "integrity": "sha512-3U1troeqGV8Ntp7Q3klwf4zr23VEoqYVocYXaswm9+8z3O9UHDYAqLxjJ/h550iRADTjKdOdhhasXw6gD6kYtg==", "dev": true, "license": "MIT", "dependencies": { @@ -6994,14 +7910,26 @@ } }, "node_modules/@types/node": { - "version": "26.0.0", - "resolved": "https://registry.npmjs.org/@types/node/-/node-26.0.0.tgz", - "integrity": "sha512-vf2YFi1iY9lHGwNJMs01biZFbKJkrZR1T6/MlzjhJLPdntOHLhTrDSnSVcdtvjihi4VQNlrFRIxLsDBlQpAipA==", + "version": "26.1.2", + "resolved": "https://registry.npmjs.org/@types/node/-/node-26.1.2.tgz", + "integrity": "sha512-Vu4a5UFA9rIIFJ7rB/Vaafh9lrCQszopTCx6KjFboXTGQbPNasehVR5TEiithSDGyd1DEiUByggTZsg8jukeIg==", "license": "MIT", "dependencies": { "undici-types": "~8.3.0" } }, + "node_modules/@types/pg": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.20.0.tgz", + "integrity": "sha512-bEPFOaMAHTEP1EzpvHTbmwR8UsFyHSKsRisLIHVMXnpNefSbGA1bD6CVy+qKjGSqmZqNqBDV2azOBo8TgkcVow==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "pg-protocol": "*", + "pg-types": "^2.2.0" + } + }, "node_modules/@types/prismjs": { "version": "1.26.6", "resolved": "https://registry.npmjs.org/@types/prismjs/-/prismjs-1.26.6.tgz", @@ -7034,9 +7962,9 @@ "license": "MIT" }, "node_modules/@types/react": { - "version": "19.2.17", - "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.17.tgz", - "integrity": "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==", + "version": "19.2.18", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.18.tgz", + "integrity": "sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w==", "dev": true, "license": "MIT", "dependencies": { @@ -7044,9 +7972,9 @@ } }, "node_modules/@types/react-dom": { - "version": "19.2.3", - "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", - "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", + "version": "19.2.4", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.4.tgz", + "integrity": "sha512-Bsc+QHgp+P/F02XDzNCY9jnZNCUuLki36KT7VKrTXXLdHf+vHMNZnW1rVu5DNW/rCK+fya3DATySbLM4yhtKUw==", "dev": true, "license": "MIT", "peerDependencies": { @@ -7495,9 +8423,9 @@ "license": "ISC" }, "node_modules/@vitejs/plugin-react": { - "version": "6.0.3", - "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.3.tgz", - "integrity": "sha512-vmFvco5/QuC2f9Oj+wTk0+9XeDFkHxSamwZKYc7MxYwKICfvUvlMhqKI0VuICPltGqh1neqBKDvO4kes1ya8vg==", + "version": "6.0.5", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.5.tgz", + "integrity": "sha512-BOVzne/NL162sMdResB25mUv+vWMF5NoAjNf09TeGlE7ZpszZWSD3winycicLJw72yeVsoCn/2kOhEuCvEShMA==", "dev": true, "license": "MIT", "dependencies": { @@ -7723,6 +8651,13 @@ "dev": true, "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==", + "dev": true, + "license": "MIT" + }, "node_modules/@xterm/addon-unicode11": { "version": "0.9.0", "resolved": "https://registry.npmjs.org/@xterm/addon-unicode11/-/addon-unicode11-0.9.0.tgz", @@ -7841,22 +8776,6 @@ } } }, - "node_modules/ansi-escapes": { - "version": "7.3.0", - "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-7.3.0.tgz", - "integrity": "sha512-BvU8nYgGQBxcmMuEeUEmNTvrMVjJNSH7RgW24vXexN4Ven6qCvy4TntnvlnwnMLTVlcRQQdbRY8NKnaIoeWDNg==", - "dev": true, - "license": "MIT", - "dependencies": { - "environment": "^1.0.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/ansi-regex": { "version": "6.2.2", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", @@ -8076,6 +8995,19 @@ "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", "license": "Python-2.0" }, + "node_modules/argue-cli": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/argue-cli/-/argue-cli-3.1.0.tgz", + "integrity": "sha512-DhBpBfXL4SS2uC0N922MMajKR3CdrTG0u2or1PNYgXMsrSzViJrbtvT0nCLlLGUI0plam/ZZCs7aAauHtW9thw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=22" + }, + "funding": { + "url": "https://ko-fi.com/dangreen" + } + }, "node_modules/aria-hidden": { "version": "1.2.6", "resolved": "https://registry.npmjs.org/aria-hidden/-/aria-hidden-1.2.6.tgz", @@ -8099,13 +9031,6 @@ "node": ">= 0.4" } }, - "node_modules/array-ify": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/array-ify/-/array-ify-1.0.0.tgz", - "integrity": "sha512-c5AMf34bKdvPhQ7tBGhqkgKNUzMr4WUs+WDtC2ZUGOUncbxKMTvqxYctiseW3+L4bA8ec+GcZ6/A/FW4m8ukng==", - "dev": true, - "license": "MIT" - }, "node_modules/asn1": { "version": "0.2.6", "resolved": "https://registry.npmjs.org/asn1/-/asn1-0.2.6.tgz", @@ -8210,6 +9135,15 @@ "node": ">= 4.0.0" } }, + "node_modules/aws-ssl-profiles": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/aws-ssl-profiles/-/aws-ssl-profiles-1.1.2.tgz", + "integrity": "sha512-NZKeq9AfyQvEeNlN0zSYAaWrmBffJh3IELMZfRpJVWgrpEbtEpnjvzqBPf+mxoI287JohRDoa+/nsfqqiZmF6g==", + "license": "MIT", + "engines": { + "node": ">= 6.0.0" + } + }, "node_modules/aws4": { "version": "1.13.2", "resolved": "https://registry.npmjs.org/aws4/-/aws4-1.13.2.tgz", @@ -8218,13 +9152,13 @@ "license": "MIT" }, "node_modules/axios": { - "version": "1.18.1", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.18.1.tgz", - "integrity": "sha512-3nTvFlvpn9Zu/RkHUqtc7/+al4UpRW5az71ap5zccp6e8RAYEzhMTecX8Dz1wWDYrPpUoB1HAQEGEAEvUr7S9g==", + "version": "1.19.0", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.19.0.tgz", + "integrity": "sha512-ht/iuYZXEjFxLH/Hkezgd7m6JKlHHXEUSneaDz8uZe1Gj5QZtCnpyDsckvAiEnT89OEbCLmnte4R4sn7P0EKFw==", "license": "MIT", "dependencies": { "follow-redirects": "^1.16.0", - "form-data": "^4.0.5", + "form-data": "^4.0.6", "https-proxy-agent": "^5.0.1", "proxy-from-env": "^2.1.0" } @@ -8254,20 +9188,6 @@ "node": ">= 6" } }, - "node_modules/b4a": { - "version": "1.8.1", - "resolved": "https://registry.npmjs.org/b4a/-/b4a-1.8.1.tgz", - "integrity": "sha512-aiqre1Nr0B/6DgE2N5vwTc+2/oQZ4Wh1t4NznYY4E00y8LCt6NqdRv81so00oo27D8MVKTpUa/MwUUtBLXCoDw==", - "license": "Apache-2.0", - "peerDependencies": { - "react-native-b4a": "*" - }, - "peerDependenciesMeta": { - "react-native-b4a": { - "optional": true - } - } - }, "node_modules/backoff": { "version": "2.5.0", "resolved": "https://registry.npmjs.org/backoff/-/backoff-2.5.0.tgz", @@ -8301,97 +9221,6 @@ "node": "18 || 20 || >=22" } }, - "node_modules/bare-events": { - "version": "2.8.3", - "resolved": "https://registry.npmjs.org/bare-events/-/bare-events-2.8.3.tgz", - "integrity": "sha512-HdUm8EMQBLaJvGUdidNNbqpA1kYkwNcb+MYxkxCLAPJGQzlv9J0C24h8V65Z4c5GLd/JEALDvpFCQgpLJqc0zw==", - "license": "Apache-2.0", - "peerDependencies": { - "bare-abort-controller": "*" - }, - "peerDependenciesMeta": { - "bare-abort-controller": { - "optional": true - } - } - }, - "node_modules/bare-fs": { - "version": "4.7.1", - "resolved": "https://registry.npmjs.org/bare-fs/-/bare-fs-4.7.1.tgz", - "integrity": "sha512-WDRsyVN52eAx/lBamKD6uyw8H4228h/x0sGGGegOamM2cd7Pag88GfMQalobXI+HaEUxpCkbKQUDOQqt9wawRw==", - "license": "Apache-2.0", - "dependencies": { - "bare-events": "^2.5.4", - "bare-path": "^3.0.0", - "bare-stream": "^2.6.4", - "bare-url": "^2.2.2", - "fast-fifo": "^1.3.2" - }, - "engines": { - "bare": ">=1.16.0" - }, - "peerDependencies": { - "bare-buffer": "*" - }, - "peerDependenciesMeta": { - "bare-buffer": { - "optional": true - } - } - }, - "node_modules/bare-os": { - "version": "3.9.1", - "resolved": "https://registry.npmjs.org/bare-os/-/bare-os-3.9.1.tgz", - "integrity": "sha512-6M5XjcnsygQNPMCMPXSK379xrJFiZ/AEMNBmFEmQW8d/789VQATvriyi5r0HYTL9TkQ26rn3kgdTG3aisbrXkQ==", - "license": "Apache-2.0", - "engines": { - "bare": ">=1.14.0" - } - }, - "node_modules/bare-path": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/bare-path/-/bare-path-3.0.0.tgz", - "integrity": "sha512-tyfW2cQcB5NN8Saijrhqn0Zh7AnFNsnczRcuWODH0eYAXBsJ5gVxAUuNr7tsHSC6IZ77cA0SitzT+s47kot8Mw==", - "license": "Apache-2.0", - "dependencies": { - "bare-os": "^3.0.1" - } - }, - "node_modules/bare-stream": { - "version": "2.13.1", - "resolved": "https://registry.npmjs.org/bare-stream/-/bare-stream-2.13.1.tgz", - "integrity": "sha512-Vp0cnjYyrEC4whYTymQ+YZi6pBpfiICZO3cfRG8sy67ZNWe951urv1x4eW1BKNngw3U+3fPYb5JQvHbCtxH7Ow==", - "license": "Apache-2.0", - "dependencies": { - "streamx": "^2.25.0", - "teex": "^1.0.1" - }, - "peerDependencies": { - "bare-abort-controller": "*", - "bare-buffer": "*", - "bare-events": "*" - }, - "peerDependenciesMeta": { - "bare-abort-controller": { - "optional": true - }, - "bare-buffer": { - "optional": true - }, - "bare-events": { - "optional": true - } - } - }, - "node_modules/bare-url": { - "version": "2.4.3", - "resolved": "https://registry.npmjs.org/bare-url/-/bare-url-2.4.3.tgz", - "integrity": "sha512-Kccpc7ACfXaxfeInfqKcZtW4pT5YBn1mesc4sCsun6sRwtbJ4h+sNOaksUpYEJUKfN65YWC6Bw2OJEFiKxq8nQ==", - "license": "Apache-2.0", - "dependencies": { - "bare-path": "^3.0.0" - } - }, "node_modules/base32.js": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/base32.js/-/base32.js-0.0.1.tgz", @@ -8451,17 +9280,15 @@ } }, "node_modules/better-sqlite3": { - "version": "12.11.1", - "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-12.11.1.tgz", - "integrity": "sha512-dq9AtApgg5PGFtBzPFSBl3HZQjHok5gaQCM6zh2Yk0aSmDCs1CbnVI8/HgASQkNKsWFpseIO9beg5xxpYhbIfA==", - "hasInstallScript": true, + "version": "13.0.2", + "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-13.0.2.tgz", + "integrity": "sha512-jW6oufeDhXZaiX9Lw5A+oerVClx4iFrI6uDj1zu7SqUAjak9vbJvA0NEcKLNxHiQHb6kYCoFzzXYV0YOauhV3g==", "license": "MIT", "dependencies": { - "bindings": "^1.5.0", - "prebuild-install": "^7.1.1" + "node-addon-api": "^8.0.0" }, "engines": { - "node": "20.x || 22.x || 23.x || 24.x || 25.x || 26.x" + "node": ">=22" } }, "node_modules/bidi-js": { @@ -8474,15 +9301,6 @@ "require-from-string": "^2.0.2" } }, - "node_modules/bindings": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz", - "integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==", - "license": "MIT", - "dependencies": { - "file-uri-to-path": "1.0.0" - } - }, "node_modules/bluebird": { "version": "3.7.2", "resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.7.2.tgz", @@ -8820,12 +9638,12 @@ } }, "node_modules/chalk": { - "version": "5.6.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", - "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==", + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-6.0.0.tgz", + "integrity": "sha512-2uNTXIuTTxk7ciZgAU1BQcgnchcG0xXnrs6jzkQfj9SsRa9M2s5zE8WT96hS6KmG4MzWHSrvH43DF1m4XRkrFg==", "license": "MIT", "engines": { - "node": "^12.17.0 || ^14.13 || >=16.0.0" + "node": ">=22" }, "funding": { "url": "https://github.com/chalk/chalk?sponsor=1" @@ -8921,22 +9739,6 @@ "url": "https://polar.sh/cva" } }, - "node_modules/cli-cursor": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-5.0.0.tgz", - "integrity": "sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw==", - "dev": true, - "license": "MIT", - "dependencies": { - "restore-cursor": "^5.0.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/cliui": { "version": "8.0.1", "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", @@ -9099,17 +9901,6 @@ "node": ">=20" } }, - "node_modules/compare-func": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/compare-func/-/compare-func-2.0.0.tgz", - "integrity": "sha512-zHig5N+tPWARooBnb0Zx1MFcdfpyJrfTJ3Y5L+IFvUm8rM74hHz66z0gw0x4tijh5CorKkKUCnW82R2vmpeCRA==", - "dev": true, - "license": "MIT", - "dependencies": { - "array-ify": "^1.0.0", - "dot-prop": "^5.1.0" - } - }, "node_modules/compare-version": { "version": "0.1.2", "resolved": "https://registry.npmjs.org/compare-version/-/compare-version-0.1.2.tgz", @@ -9157,15 +9948,15 @@ } }, "node_modules/concurrently": { - "version": "10.0.3", - "resolved": "https://registry.npmjs.org/concurrently/-/concurrently-10.0.3.tgz", - "integrity": "sha512-hc3LH4UaKWd/bbyDK/IGVa4RB6PtQ3CUYwtrkzqHn+wIG3Hr5fhpRlk0L/gCa8ZE1L/Ufj50Zho69cI5w8SQBA==", + "version": "10.0.4", + "resolved": "https://registry.npmjs.org/concurrently/-/concurrently-10.0.4.tgz", + "integrity": "sha512-trZql+7l/0+WRAsAnEdctr4+iiOS6ZrViI6H8QWcCF9MFS/LT0dKpe8vluB1to6it+OxSI4VospFTIFMW8DJRw==", "dev": true, "license": "MIT", "dependencies": { "chalk": "5.6.2", "rxjs": "7.8.2", - "shell-quote": "1.8.4", + "shell-quote": "1.9.0", "supports-color": "10.2.2", "tree-kill": "1.2.2", "yargs": "18.0.0" @@ -9194,6 +9985,19 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "node_modules/concurrently/node_modules/chalk": { + "version": "5.6.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", + "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.17.0 || ^14.13 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, "node_modules/concurrently/node_modules/cliui": { "version": "9.0.1", "resolved": "https://registry.npmjs.org/cliui/-/cliui-9.0.1.tgz", @@ -9303,46 +10107,46 @@ } }, "node_modules/conventional-changelog-angular": { - "version": "8.3.1", - "resolved": "https://registry.npmjs.org/conventional-changelog-angular/-/conventional-changelog-angular-8.3.1.tgz", - "integrity": "sha512-6gfI3otXK5Ph5DfCOI1dblr+kN3FAm5a97hYoQkqNZxOaYa5WKfXH+AnpsmS+iUH2mgVC2Cg2Qw9m5OKcmNrIg==", + "version": "9.2.1", + "resolved": "https://registry.npmjs.org/conventional-changelog-angular/-/conventional-changelog-angular-9.2.1.tgz", + "integrity": "sha512-oWSL6ZhnXbYraOFTK3PgRAQJ8fADDAEv5K6AdeyQPLvjFmhG8+ejL0jZZp/R7vTmGJaBvZEE+sE7dB4bCv7sAw==", "dev": true, "license": "ISC", "dependencies": { - "compare-func": "^2.0.0" + "@conventional-changelog/template": "^1.2.1" }, "engines": { - "node": ">=18" + "node": ">=22" } }, "node_modules/conventional-changelog-conventionalcommits": { - "version": "9.3.1", - "resolved": "https://registry.npmjs.org/conventional-changelog-conventionalcommits/-/conventional-changelog-conventionalcommits-9.3.1.tgz", - "integrity": "sha512-dTYtpIacRpcZgrvBYvBfArMmK2xvIpv2TaxM0/ZI5CBtNUzvF2x0t15HsbRABWprS6UPmvj+PzHVjSx4qAVKyw==", + "version": "10.2.1", + "resolved": "https://registry.npmjs.org/conventional-changelog-conventionalcommits/-/conventional-changelog-conventionalcommits-10.2.1.tgz", + "integrity": "sha512-n4Kr1HFMTf3iMbES0TMxKIcYtUUv4rKqyQQp2JwfOEfFCOfGT3Tq4mCyJ8S9/YPyWhydjfKrrvnyl+gCjA+mJQ==", "dev": true, "license": "ISC", "dependencies": { - "compare-func": "^2.0.0" + "@conventional-changelog/template": "^1.2.1" }, "engines": { - "node": ">=18" + "node": ">=22" } }, "node_modules/conventional-commits-parser": { - "version": "6.4.0", - "resolved": "https://registry.npmjs.org/conventional-commits-parser/-/conventional-commits-parser-6.4.0.tgz", - "integrity": "sha512-tvRg7FIBNlyPzjdG8wWRlPHQJJHI7DylhtRGeU9Lq+JuoPh5BKpPRX83ZdLrvXuOSu5Eo/e7SzOQhU4Hd2Miuw==", + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/conventional-commits-parser/-/conventional-commits-parser-7.1.2.tgz", + "integrity": "sha512-O+x4N2yH+ijvqWlIyTHsXTAP+algNWgGbjY2duCe8w2vUMvUB95cLRslCPfTMQyLAKlet3bhZTdu6ozn4M+QJQ==", "dev": true, "license": "MIT", "dependencies": { - "@simple-libs/stream-utils": "^1.2.0", - "meow": "^13.0.0" + "@simple-libs/stream-utils": "^2.0.0", + "argue-cli": "^3.1.0" }, "bin": { "conventional-commits-parser": "dist/cli/index.js" }, "engines": { - "node": ">=18" + "node": ">=22" } }, "node_modules/convert-source-map": { @@ -9459,9 +10263,9 @@ } }, "node_modules/cosmiconfig/node_modules/js-yaml": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.2.0.tgz", - "integrity": "sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw==", + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.1.tgz", + "integrity": "sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==", "dev": true, "funding": [ { @@ -9616,21 +10420,6 @@ "url": "https://github.com/sponsors/wooorm" } }, - "node_modules/decompress-response": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", - "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", - "license": "MIT", - "dependencies": { - "mimic-response": "^3.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/deep-extend": { "version": "0.6.0", "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", @@ -9656,6 +10445,15 @@ "node": ">=0.4.0" } }, + "node_modules/denque": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz", + "integrity": "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.10" + } + }, "node_modules/depd": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", @@ -9679,6 +10477,7 @@ "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" @@ -9807,19 +10606,6 @@ "tslib": "^2.0.3" } }, - "node_modules/dot-prop": { - "version": "5.3.0", - "resolved": "https://registry.npmjs.org/dot-prop/-/dot-prop-5.3.0.tgz", - "integrity": "sha512-QM8q3zDe58hqUqjraQOmzZ1LIH9SWQJTlEKCH4kJ2oQvLZk7RbQXvtDM2XEq3fwkV9CCvvH4LA0AV+ogFsBM2Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-obj": "^2.0.0" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/dotenv": { "version": "17.4.2", "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.4.2.tgz", @@ -9861,6 +10647,22 @@ "url": "https://dotenvx.com" } }, + "node_modules/drizzle-kit": { + "version": "0.31.10", + "resolved": "https://registry.npmjs.org/drizzle-kit/-/drizzle-kit-0.31.10.tgz", + "integrity": "sha512-7OZcmQUrdGI+DUNNsKBn1aW8qSoKuTH7d0mYgSP8bAzdFzKoovxEFnoGQp2dVs82EOJeYycqRtciopszwUf8bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@drizzle-team/brocli": "^0.10.2", + "@esbuild-kit/esm-loader": "^2.5.5", + "esbuild": "^0.25.4", + "tsx": "^4.21.0" + }, + "bin": { + "drizzle-kit": "bin.cjs" + } + }, "node_modules/drizzle-orm": { "version": "0.45.2", "resolved": "https://registry.npmjs.org/drizzle-orm/-/drizzle-orm-0.45.2.tgz", @@ -10042,9 +10844,9 @@ } }, "node_modules/electron": { - "version": "43.0.0", - "resolved": "https://registry.npmjs.org/electron/-/electron-43.0.0.tgz", - "integrity": "sha512-PV60GsWU6qufhuOhw3n+Yix3WPDcqDtBqE8orbEQGQGHEkgp9o/JCPgb7L4vIL0r1HnfPdqSRtboOTqbDkcFDQ==", + "version": "43.2.0", + "resolved": "https://registry.npmjs.org/electron/-/electron-43.2.0.tgz", + "integrity": "sha512-80zvrgG7ZRXD+tD0IyLvrnN9n+veSxadMRsMaC9wKKP3iUbtC7rGM8+dVuCmOb0Rrwwv8ESW4awnUZh9Hbp1fA==", "dev": true, "license": "MIT", "dependencies": { @@ -10203,15 +11005,6 @@ "node": ">= 0.8" } }, - "node_modules/end-of-stream": { - "version": "1.4.5", - "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", - "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", - "license": "MIT", - "dependencies": { - "once": "^1.4.0" - } - }, "node_modules/enhanced-resolve": { "version": "5.24.2", "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.24.2.tgz", @@ -10249,19 +11042,6 @@ "node": ">=6" } }, - "node_modules/environment": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/environment/-/environment-1.1.0.tgz", - "integrity": "sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/err-code": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/err-code/-/err-code-2.0.3.tgz", @@ -10332,16 +11112,59 @@ } }, "node_modules/es-toolkit": { - "version": "1.47.0", - "resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.47.0.tgz", - "integrity": "sha512-n1GuoD0WEQZMBk5tttoZSqwgyLx01oqa5XsBmCHwPyNe1S9jPBEmtR2pSgp2kJuWE3ciFZ6yRHmY4pM4C3OOkw==", + "version": "1.50.0", + "resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.50.0.tgz", + "integrity": "sha512-OyZKhUVvEep9ITEiwHn8GKnMRQIVqoSIX7WnRbkWgJkllCujilqP2rD0u979tkl8wqyc8ICwlc1UBVv/Sl1G6w==", "dev": true, "license": "MIT", "workspaces": [ "docs", - "benchmarks" + "benchmarks", + "tests/types" ] }, + "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/escalade": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", @@ -10372,9 +11195,9 @@ } }, "node_modules/eslint": { - "version": "10.5.0", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.5.0.tgz", - "integrity": "sha512-1y+7C+vi12bUK1IpZeaV3gsH9fHLBmPvYmPx42pvT/E9yG0IC8g3PUZZgp0+JLJl7ZDK0flc2gc+Aw9dpCvIsQ==", + "version": "10.8.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.8.0.tgz", + "integrity": "sha512-nuKKvN+oIBO0koN7Tm7dlkmnkc21mtt0QJLwAKzjLq14y6lRTdVG36MZHJ8eQHwdJMwZbQNMlPOYedMq/oVJvQ==", "dev": true, "license": "MIT", "workspaces": [ @@ -10384,7 +11207,7 @@ "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.2", "@eslint/config-array": "^0.23.5", - "@eslint/config-helpers": "^0.6.0", + "@eslint/config-helpers": "^0.7.0", "@eslint/core": "^1.2.1", "@eslint/plugin-kit": "^0.7.2", "@humanfs/node": "^0.16.6", @@ -10408,7 +11231,7 @@ "imurmurhash": "^0.1.4", "is-glob": "^4.0.0", "json-stable-stringify-without-jsonify": "^1.0.1", - "minimatch": "^10.2.4", + "minimatch": "^10.2.5", "natural-compare": "^1.4.0", "optionator": "^0.9.3" }, @@ -10623,31 +11446,6 @@ "node": ">= 0.6" } }, - "node_modules/eventemitter3": { - "version": "5.0.4", - "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz", - "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==", - "dev": true, - "license": "MIT" - }, - "node_modules/events-universal": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/events-universal/-/events-universal-1.0.1.tgz", - "integrity": "sha512-LUd5euvbMLpwOF8m6ivPCbhQeSiYVNb8Vs0fQ8QjXo0JTkEHpz8pxdQf0gStltaPpw0Cca8b39KxvK9cfKRiAw==", - "license": "Apache-2.0", - "dependencies": { - "bare-events": "^2.7.0" - } - }, - "node_modules/expand-template": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz", - "integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==", - "license": "(MIT OR WTFPL)", - "engines": { - "node": ">=6" - } - }, "node_modules/expect-type": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", @@ -10740,12 +11538,6 @@ "dev": true, "license": "MIT" }, - "node_modules/fast-fifo": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/fast-fifo/-/fast-fifo-1.3.2.tgz", - "integrity": "sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==", - "license": "MIT" - }, "node_modules/fast-json-stable-stringify": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", @@ -10829,12 +11621,6 @@ "node": ">=16.0.0" } }, - "node_modules/file-uri-to-path": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz", - "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==", - "license": "MIT" - }, "node_modules/filelist": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/filelist/-/filelist-1.0.6.tgz", @@ -10972,16 +11758,16 @@ } }, "node_modules/form-data": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", - "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz", + "integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==", "license": "MIT", "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "es-set-tostringtag": "^2.1.0", - "hasown": "^2.0.2", - "mime-types": "^2.1.12" + "hasown": "^2.0.4", + "mime-types": "^2.1.35" }, "engines": { "node": ">= 6" @@ -11027,12 +11813,12 @@ } }, "node_modules/framer-motion": { - "version": "12.42.2", - "resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.42.2.tgz", - "integrity": "sha512-5XY9luDiu0oHfHBjpDthFMh0ES+122w6p/papSJBweMkO8Sn+PW2QaEgRblQBpWFnuvZS5qvarpt/hO2pjGmnw==", + "version": "12.43.0", + "resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.43.0.tgz", + "integrity": "sha512-1eaL3RvR/kAlbG7UYcpMptEyzPoENO0c6w7ZnB3/hh2vSAz/6uGAFn6fdoqTBguNstf3MsFhJHsD/0DHiclG+g==", "license": "MIT", "dependencies": { - "motion-dom": "^12.42.2", + "motion-dom": "^12.43.0", "motion-utils": "^12.39.0", "tslib": "^2.4.0" }, @@ -11101,6 +11887,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/generate-function": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/generate-function/-/generate-function-2.3.1.tgz", + "integrity": "sha512-eeB5GfMNeevm/GRYq20ShmsaGcmI81kIX2K9XQx5miC8KdHaC6Jm0qQ8ZNeGOi7wYB8OsdxKs+Y2oVuTFuVwKQ==", + "license": "MIT", + "dependencies": { + "is-property": "^1.0.2" + } + }, "node_modules/gensync": { "version": "1.0.0-beta.2", "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", @@ -11180,29 +11975,19 @@ "node": ">= 0.4" } }, - "node_modules/git-raw-commits": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/git-raw-commits/-/git-raw-commits-5.0.1.tgz", - "integrity": "sha512-Y+csSm2GD/PCSh6Isd/WiMjNAydu0VBiG9J7EdQsNA5P9uXvLayqjmTsNlK5Gs9IhblFZqOU0yid5Il5JPoLiQ==", + "node_modules/get-tsconfig": { + "version": "4.14.0", + "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.14.0.tgz", + "integrity": "sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA==", "dev": true, "license": "MIT", "dependencies": { - "@conventional-changelog/git-client": "^2.6.0", - "meow": "^13.0.0" + "resolve-pkg-maps": "^1.0.0" }, - "bin": { - "git-raw-commits": "src/cli.js" - }, - "engines": { - "node": ">=18" + "funding": { + "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" } }, - "node_modules/github-from-package": { - "version": "0.0.0", - "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz", - "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==", - "license": "MIT" - }, "node_modules/glob": { "version": "12.0.0", "resolved": "https://registry.npmjs.org/glob/-/glob-12.0.0.tgz", @@ -11257,9 +12042,9 @@ } }, "node_modules/globals": { - "version": "17.6.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-17.6.0.tgz", - "integrity": "sha512-sepffkT8stwnIYbsMBpoCHJuJM5l98FUF2AnE07hfvE0m/qp3R586hw4jF4uadbhvg1ooIdzuu7CsfD2jzCaNA==", + "version": "17.9.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-17.9.0.tgz", + "integrity": "sha512-m/MvAW61QVU5VDNF1Vj8axt016h8w7L5TU1e9zlab7XIttAT2YAlCwl75K1fOqvMM9apmD7lbCIRhpfkhmxhCg==", "dev": true, "license": "MIT", "engines": { @@ -11346,9 +12131,9 @@ } }, "node_modules/hasown": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.3.tgz", - "integrity": "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==", + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", "license": "MIT", "dependencies": { "function-bind": "^1.1.2" @@ -11519,13 +12304,13 @@ "license": "MIT" }, "node_modules/html-parse-stringify": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/html-parse-stringify/-/html-parse-stringify-3.0.1.tgz", - "integrity": "sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg==", + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/html-parse-stringify/-/html-parse-stringify-4.0.1.tgz", + "integrity": "sha512-0zHsZJrK7S3K2aucXWL6ycoYJ/iNtIcFHC/nYQgFklPtrv5LpJctIiSCroWZWeuoXvuyFdzp6KzjJQ+OT5MfFw==", "dev": true, "license": "MIT", - "dependencies": { - "void-elements": "3.1.0" + "funding": { + "url": "https://locize.com" } }, "node_modules/html-url-attributes": { @@ -11849,16 +12634,6 @@ "url": "https://github.com/sponsors/wooorm" } }, - "node_modules/is-obj": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/is-obj/-/is-obj-2.0.0.tgz", - "integrity": "sha512-drqDG3cbczxxEJRoOXcOjtdp1J/lyp1mNn0xaznRs8+muBhgQcrnbspox5X5fOw0HnMnbfDzvnEMEtqDEJEo8w==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, "node_modules/is-plain-obj": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-4.1.0.tgz", @@ -11885,6 +12660,12 @@ "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", "license": "MIT" }, + "node_modules/is-property": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-property/-/is-property-1.0.2.tgz", + "integrity": "sha512-Ks/IoX00TtClbGQr4TWXemAnktAQvYB7HzcCxDGqEZU6oCmb2INHuOoKxbtR+HFkmYWBKv/dOZtGRiAjDhj92g==", + "license": "MIT" + }, "node_modules/isarray": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", @@ -12008,9 +12789,9 @@ } }, "node_modules/jose": { - "version": "6.2.3", - "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.3.tgz", - "integrity": "sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw==", + "version": "6.2.7", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.7.tgz", + "integrity": "sha512-hq1OB1bALKfydZNoViyg6hPVGV4i93ny9Op+n4zP5RSf7SCZEXa/TsG2O3IEr7+WlHRTPnpqDmHfMH6qXAD60w==", "license": "MIT", "funding": { "url": "https://github.com/sponsors/panva" @@ -12031,9 +12812,9 @@ "license": "MIT" }, "node_modules/js-yaml": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-5.2.1.tgz", - "integrity": "sha512-zfLtNfQqxVqq3uaTqSkh4x4hZw3KHobGUA0fJUj4wawW8bsQLTVqpHdXSIzidh7o+4lEW36tANuAGdaFx6Zgnw==", + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-5.2.2.tgz", + "integrity": "sha512-dayzUzKkJ1MkuUtZglSebU43utNXH0OWQByK9rKOOuYIO8M5TV1y+n8ALMdG0rdzBnfNkOmZEqrURepb0ejqBw==", "funding": [ { "type": "github", @@ -12567,14 +13348,13 @@ "license": "MIT" }, "node_modules/lint-staged": { - "version": "17.0.8", - "resolved": "https://registry.npmjs.org/lint-staged/-/lint-staged-17.0.8.tgz", - "integrity": "sha512-B2P/d+jVW0UXOQ0MVMLrB/9ydA1P+zz6jYfdrbbEd9ur3S2rcbduFWKiUCC02Sm5hbC8nrm7y24WuYMG54HfxA==", + "version": "17.3.0", + "resolved": "https://registry.npmjs.org/lint-staged/-/lint-staged-17.3.0.tgz", + "integrity": "sha512-woZS3vNe3UKqBaLPvbLOtKRY4tLANpWQhom12MGWqC8Mh1lCOO+WgSwmX2amjJAqTY9BkXYW87fCUH5H9Ph6xw==", "dev": true, "license": "MIT", "dependencies": { - "listr2": "^10.2.1", - "picomatch": "^4.0.4", + "picomatch": "^4.0.5", "string-argv": "^0.3.2", "tinyexec": "^1.2.4" }, @@ -12608,103 +13388,6 @@ "url": "https://github.com/sponsors/eemeli" } }, - "node_modules/listr2": { - "version": "10.2.1", - "resolved": "https://registry.npmjs.org/listr2/-/listr2-10.2.1.tgz", - "integrity": "sha512-7I5knELsJKTUjXG+A6BkKAiGkW1i25fNa/xlUl9hFtk15WbE9jndA89xu5FzQKrY5llajE1hfZZFMILXkDHk/Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "cli-truncate": "^5.2.0", - "eventemitter3": "^5.0.4", - "log-update": "^6.1.0", - "rfdc": "^1.4.1", - "wrap-ansi": "^10.0.0" - }, - "engines": { - "node": ">=22.13.0" - } - }, - "node_modules/listr2/node_modules/ansi-styles": { - "version": "6.2.3", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", - "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/listr2/node_modules/cli-truncate": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/cli-truncate/-/cli-truncate-5.2.0.tgz", - "integrity": "sha512-xRwvIOMGrfOAnM1JYtqQImuaNtDEv9v6oIYAs4LIHwTiKee8uwvIi363igssOC0O5U04i4AlENs79LQLu9tEMw==", - "dev": true, - "license": "MIT", - "dependencies": { - "slice-ansi": "^8.0.0", - "string-width": "^8.2.0" - }, - "engines": { - "node": ">=20" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/listr2/node_modules/is-fullwidth-code-point": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-5.1.0.tgz", - "integrity": "sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "get-east-asian-width": "^1.3.1" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/listr2/node_modules/slice-ansi": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-8.0.0.tgz", - "integrity": "sha512-stxByr12oeeOyY2BlviTNQlYV5xOj47GirPr4yA1hE9JCtxfQN0+tVbkxwCtYDQWhEKWFHsEK48ORg5jrouCAg==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^6.2.3", - "is-fullwidth-code-point": "^5.1.0" - }, - "engines": { - "node": ">=20" - }, - "funding": { - "url": "https://github.com/chalk/slice-ansi?sponsor=1" - } - }, - "node_modules/listr2/node_modules/string-width": { - "version": "8.2.1", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-8.2.1.tgz", - "integrity": "sha512-IIaP0g3iy9Cyy18w3M9YcaDudujEAVHKt3a3QJg1+sr/oX96TbaGUubG0hJyCjCBThFH+tFpcIyoUHUn1ogaLA==", - "dev": true, - "license": "MIT", - "dependencies": { - "get-east-asian-width": "^1.5.0", - "strip-ansi": "^7.1.2" - }, - "engines": { - "node": ">=20" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/locate-path": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", @@ -12770,114 +13453,11 @@ "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==", "license": "MIT" }, - "node_modules/log-update": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/log-update/-/log-update-6.1.0.tgz", - "integrity": "sha512-9ie8ItPR6tjY5uYJh8K/Zrv/RMZ5VOlOWvtZdEHYSTFKZfIBPQa9tOAEeAWhd+AnIneLJ22w5fjOYtoutpWq5w==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-escapes": "^7.0.0", - "cli-cursor": "^5.0.0", - "slice-ansi": "^7.1.0", - "strip-ansi": "^7.1.0", - "wrap-ansi": "^9.0.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/log-update/node_modules/ansi-styles": { - "version": "6.2.3", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", - "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/log-update/node_modules/emoji-regex": { - "version": "10.6.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", - "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", - "dev": true, - "license": "MIT" - }, - "node_modules/log-update/node_modules/is-fullwidth-code-point": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-5.1.0.tgz", - "integrity": "sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "get-east-asian-width": "^1.3.1" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/log-update/node_modules/slice-ansi": { - "version": "7.1.2", - "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-7.1.2.tgz", - "integrity": "sha512-iOBWFgUX7caIZiuutICxVgX1SdxwAVFFKwt1EvMYYec/NWO5meOJ6K5uQxhrYBdQJne4KxiqZc+KptFOWFSI9w==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^6.2.1", - "is-fullwidth-code-point": "^5.0.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/chalk/slice-ansi?sponsor=1" - } - }, - "node_modules/log-update/node_modules/string-width": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", - "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "emoji-regex": "^10.3.0", - "get-east-asian-width": "^1.0.0", - "strip-ansi": "^7.1.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/log-update/node_modules/wrap-ansi": { - "version": "9.0.2", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz", - "integrity": "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^6.2.1", - "string-width": "^7.0.0", - "strip-ansi": "^7.1.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" - } + "node_modules/long": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", + "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==", + "license": "Apache-2.0" }, "node_modules/longest-streak": { "version": "3.1.0", @@ -12938,10 +13518,25 @@ "yallist": "^3.0.2" } }, + "node_modules/lru.min": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/lru.min/-/lru.min-1.1.4.tgz", + "integrity": "sha512-DqC6n3QQ77zdFpCMASA1a3Jlb64Hv2N2DciFGkO/4L9+q/IpIAuRlKOvCXabtRW6cQf8usbmM6BE/TOPysCdIA==", + "license": "MIT", + "engines": { + "bun": ">=1.0.0", + "deno": ">=1.30.0", + "node": ">=8.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wellwelwel" + } + }, "node_modules/lucide-react": { - "version": "1.20.0", - "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.20.0.tgz", - "integrity": "sha512-jhXLeC/7m0/tjL1nzMdKk6x256zWA6AtbhTVreHOiKPoeX2d6MK4FbyIQPpVq0E6iPWBisyy1TW+pEge/uMEuQ==", + "version": "1.28.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.28.0.tgz", + "integrity": "sha512-fARAFJULsGuDDydjp6+6blekG/sBIM29TerzLjc9bQUKAcEfrSc4ZQKb25KRz4OMKd87cZTb5dgq0w/T6KufVg==", "dev": true, "license": "ISC", "peerDependencies": { @@ -13350,19 +13945,6 @@ "node": ">= 0.8" } }, - "node_modules/meow": { - "version": "13.2.0", - "resolved": "https://registry.npmjs.org/meow/-/meow-13.2.0.tgz", - "integrity": "sha512-pxQJQzB6djGPXh08dacEloMFopsOqGVRKFPYvPOt9XDZ1HasbgDZA74CJGreSU4G3Ak7EFJGoiH2auq+yXISgA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/merge-descriptors": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", @@ -14022,31 +14604,6 @@ "url": "https://opencollective.com/express" } }, - "node_modules/mimic-function": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/mimic-function/-/mimic-function-5.0.1.tgz", - "integrity": "sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/mimic-response": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", - "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/min-indent": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz", @@ -14077,6 +14634,7 @@ "version": "1.2.8", "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "dev": true, "license": "MIT", "funding": { "url": "https://github.com/sponsors/ljharb" @@ -14105,19 +14663,13 @@ "node": ">= 18" } }, - "node_modules/mkdirp-classic": { - "version": "0.5.3", - "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz", - "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==", - "license": "MIT" - }, "node_modules/motion": { - "version": "12.42.2", - "resolved": "https://registry.npmjs.org/motion/-/motion-12.42.2.tgz", - "integrity": "sha512-Atvv11yUKIid41cVrRBDVX5m8tF8kNpExRSlbpt6APClhDjtwQssgFHhQzejxw7/7YYbjHSPKBVbHo05BuJT5Q==", + "version": "12.43.0", + "resolved": "https://registry.npmjs.org/motion/-/motion-12.43.0.tgz", + "integrity": "sha512-BQgQbSa9Hn3/mtbib0MK53y6JSANa+YKUKlaYnWzAVDH424RYQ5LVpV3pNiWH00BA2z4ojsSdMzqT7g2FQwjuQ==", "license": "MIT", "dependencies": { - "framer-motion": "^12.42.2", + "framer-motion": "^12.43.0", "tslib": "^2.4.0" }, "peerDependencies": { @@ -14138,9 +14690,9 @@ } }, "node_modules/motion-dom": { - "version": "12.42.2", - "resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.42.2.tgz", - "integrity": "sha512-5gIMWLp/PycBtJRJWRgjxke5n8dlvkSn2DrYW+tr3XcqAZY1xZh6BJyooJXCM8wdfM7wfMjkBJNLge1CKPUIRA==", + "version": "12.43.0", + "resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.43.0.tgz", + "integrity": "sha512-azKON4d9S65PEoFUiQTMTgPheEmzf2QngdRc50AKfJp9Q9mmcBVw22c8eMq9k8kxOFHdL7+WZY7N/5F/lwiDag==", "license": "MIT", "dependencies": { "motion-utils": "^12.39.0" @@ -14230,6 +14782,40 @@ "node": ">= 0.6" } }, + "node_modules/mysql2": { + "version": "3.23.2", + "resolved": "https://registry.npmjs.org/mysql2/-/mysql2-3.23.2.tgz", + "integrity": "sha512-fxh3HpQ8vJtu/Mmnd4Xsur19jGjHGzRLMxptiDtOkbX7EVBgnafGSGDx1WGGVmJLClVh2LeeBMMo24IFv8wCyQ==", + "license": "MIT", + "dependencies": { + "aws-ssl-profiles": "^1.1.2", + "denque": "^2.1.0", + "generate-function": "^2.3.1", + "iconv-lite": "^0.7.2", + "long": "^5.3.2", + "lru.min": "^1.1.4", + "named-placeholders": "^1.1.6", + "sql-escaper": "^1.5.1" + }, + "engines": { + "node": ">= 8.0" + }, + "peerDependencies": { + "@types/node": ">= 8" + } + }, + "node_modules/named-placeholders": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/named-placeholders/-/named-placeholders-1.1.6.tgz", + "integrity": "sha512-Tz09sEL2EEuv5fFowm419c1+a/jSMiBjI9gHxVLrVdbUkkNUUfjsVYs9pVZu5oCon/kmRh9TfLEObFtkVxmY0w==", + "license": "MIT", + "dependencies": { + "lru.min": "^1.1.0" + }, + "engines": { + "node": ">=8.0.0" + } + }, "node_modules/nan": { "version": "2.27.0", "resolved": "https://registry.npmjs.org/nan/-/nan-2.27.0.tgz", @@ -14255,12 +14841,6 @@ "node": "^22 || ^24 || >=26" } }, - "node_modules/napi-build-utils": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-1.0.2.tgz", - "integrity": "sha512-ONmRUqK7zj7DWX0D9ADe03wbwOBZxNAfF20PlGfCWQcD3+/MakShIHrMqx9YwPTfxDdF1zLeL+RGZiR9kGMLdg==", - "license": "MIT" - }, "node_modules/natural-compare": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", @@ -14478,22 +15058,6 @@ "wrappy": "1" } }, - "node_modules/onetime": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/onetime/-/onetime-7.0.0.tgz", - "integrity": "sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "mimic-function": "^5.0.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/optionator": { "version": "0.9.4", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", @@ -14761,6 +15325,95 @@ "url": "https://github.com/sponsors/jet2jet" } }, + "node_modules/pg": { + "version": "8.22.0", + "resolved": "https://registry.npmjs.org/pg/-/pg-8.22.0.tgz", + "integrity": "sha512-8wih1vVIBMxoUM2oB4soJsD9tDnDpLv4OXBJ+EJzFsvycD+lfyIreC2gGHq78f8jbLLt+bvlPTFdFZfJkOuzAA==", + "license": "MIT", + "dependencies": { + "pg-connection-string": "^2.14.0", + "pg-pool": "^3.14.0", + "pg-protocol": "^1.15.0", + "pg-types": "2.2.0", + "pgpass": "1.0.5" + }, + "engines": { + "node": ">= 16.0.0" + }, + "optionalDependencies": { + "pg-cloudflare": "^1.4.0" + }, + "peerDependencies": { + "pg-native": ">=3.0.1" + }, + "peerDependenciesMeta": { + "pg-native": { + "optional": true + } + } + }, + "node_modules/pg-cloudflare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.4.0.tgz", + "integrity": "sha512-Vo7z/6rrQYxpNRylp4Tlob2elzbh+N/MOQbxFVWCxS7oEx6jF53GTJFxK2WWpKuBRkmiin4Mt+xofFDjx09R0A==", + "license": "MIT", + "optional": true + }, + "node_modules/pg-connection-string": { + "version": "2.14.0", + "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.14.0.tgz", + "integrity": "sha512-XwWDGcLRGCXAR8F/AM5bG7Q+A3Wm2s6QeEjlOKZLlH3UYcguiqCWKyWXVag5TLTIjR7oOJUY8kcADaZgWPyLeg==", + "license": "MIT" + }, + "node_modules/pg-int8": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz", + "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==", + "license": "ISC", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/pg-pool": { + "version": "3.14.0", + "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.14.0.tgz", + "integrity": "sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw==", + "license": "MIT", + "peerDependencies": { + "pg": ">=8.0" + } + }, + "node_modules/pg-protocol": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.15.0.tgz", + "integrity": "sha512-cq9sECI5s0+uPUXjbz8ioyPJni6RzsRib0US67i5IoTZKw8fNeYlVE7u8F4dG7vEJJtc5wdD1K189lCCUwqWTQ==", + "license": "MIT" + }, + "node_modules/pg-types": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz", + "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==", + "license": "MIT", + "dependencies": { + "pg-int8": "1.0.1", + "postgres-array": "~2.0.0", + "postgres-bytea": "~1.0.0", + "postgres-date": "~1.0.4", + "postgres-interval": "^1.1.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/pgpass": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz", + "integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==", + "license": "MIT", + "dependencies": { + "split2": "^4.1.0" + } + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -14769,9 +15422,9 @@ "license": "ISC" }, "node_modules/picomatch": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", - "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", "dev": true, "license": "MIT", "engines": { @@ -14884,43 +15537,43 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, - "node_modules/prebuild-install": { - "name": "@mmomtchev/prebuild-install", - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@mmomtchev/prebuild-install/-/prebuild-install-1.0.2.tgz", - "integrity": "sha512-0Vje0eg5XQa8Ta1jtQiWqnT1kS/P7OAAvFJ4VZG5EXT2gJmpmPA5SrsDrh++BqNjIxYy8O6aF4wZjJc/k4JQ6w==", + "node_modules/postgres-array": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz", + "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==", "license": "MIT", - "dependencies": { - "detect-libc": "^2.0.3", - "expand-template": "^2.0.3", - "github-from-package": "^0.0.0", - "minimist": "^1.2.8", - "mkdirp-classic": "^0.5.3", - "napi-build-utils": "^1.0.2", - "node-abi": "^3.63.0", - "pump": "^3.0.0", - "rc": "^1.2.8", - "simple-get": "^4.0.1", - "tar-fs": "^3.0.6", - "tunnel-agent": "^0.6.0" - }, - "bin": { - "prebuild-install": "bin.js" - }, "engines": { - "node": ">=18" + "node": ">=4" } }, - "node_modules/prebuild-install/node_modules/node-abi": { - "version": "3.92.0", - "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.92.0.tgz", - "integrity": "sha512-KdHvFWZjEKDf0cakgFjebl371GPsISX2oZHcuyKqM7DtogIsHrqKeLTo8wBHxaXRAQlY2PsPlZmfo+9ZCxEREQ==", + "node_modules/postgres-bytea": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz", + "integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-date": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz", + "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-interval": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz", + "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==", "license": "MIT", "dependencies": { - "semver": "^7.3.5" + "xtend": "^4.0.0" }, "engines": { - "node": ">=10" + "node": ">=0.10.0" } }, "node_modules/precond": { @@ -14942,9 +15595,9 @@ } }, "node_modules/prettier": { - "version": "3.8.4", - "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.4.tgz", - "integrity": "sha512-N2MylSdi48+5N/6S5j+maeHbUSIzzZ5uOcX5Hm4QpV8Dkb1HFjfAKTKX6yNPJQD9AhcT3ifHNB66tWTTJDi11Q==", + "version": "3.9.6", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.9.6.tgz", + "integrity": "sha512-OpN0zzVdiaiAhxpuuj5efpIS4sY9j7bY6uR5mnj5yPzGkdkjNKSJeUThPb60Jw29QuAZgA4o+/iB49kFiaBX6g==", "dev": true, "license": "MIT", "bin": { @@ -15122,16 +15775,6 @@ "node": ">=10" } }, - "node_modules/pump": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz", - "integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==", - "license": "MIT", - "dependencies": { - "end-of-stream": "^1.1.0", - "once": "^1.3.1" - } - }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", @@ -15341,67 +15984,67 @@ } }, "node_modules/radix-ui": { - "version": "1.6.3", - "resolved": "https://registry.npmjs.org/radix-ui/-/radix-ui-1.6.3.tgz", - "integrity": "sha512-KmhSq0NfxIwN9q6ZpEaZ+J0hiVFQcGyrPYYhbxg34q9B8CIrQoccLJ3mJ9znLRslLoaogsP2ml8JKOVoKMXgvQ==", + "version": "1.6.7", + "resolved": "https://registry.npmjs.org/radix-ui/-/radix-ui-1.6.7.tgz", + "integrity": "sha512-QBdhh1arIEUvPC0dQ5+nwWAxt7+N+oP/9jPwjJkGFoSk/sqxg32gJtSXGtFh8frAIcS6oC9cx2Q+7KYCQLOAeA==", "dev": true, "license": "MIT", "dependencies": { - "@radix-ui/primitive": "1.1.6", - "@radix-ui/react-accessible-icon": "1.1.12", - "@radix-ui/react-accordion": "1.2.17", - "@radix-ui/react-alert-dialog": "1.1.20", - "@radix-ui/react-arrow": "1.1.12", - "@radix-ui/react-aspect-ratio": "1.1.12", - "@radix-ui/react-avatar": "1.2.3", - "@radix-ui/react-checkbox": "1.3.8", - "@radix-ui/react-collapsible": "1.1.17", - "@radix-ui/react-collection": "1.1.12", - "@radix-ui/react-compose-refs": "1.1.3", - "@radix-ui/react-context": "1.2.0", - "@radix-ui/react-context-menu": "2.3.4", - "@radix-ui/react-dialog": "1.1.20", - "@radix-ui/react-direction": "1.1.2", - "@radix-ui/react-dismissable-layer": "1.1.16", - "@radix-ui/react-dropdown-menu": "2.1.21", - "@radix-ui/react-focus-guards": "1.1.4", - "@radix-ui/react-focus-scope": "1.1.13", - "@radix-ui/react-form": "0.1.13", - "@radix-ui/react-hover-card": "1.1.20", - "@radix-ui/react-label": "2.1.12", - "@radix-ui/react-menu": "2.1.21", - "@radix-ui/react-menubar": "1.1.21", - "@radix-ui/react-navigation-menu": "1.2.19", - "@radix-ui/react-one-time-password-field": "0.1.13", - "@radix-ui/react-password-toggle-field": "0.1.8", - "@radix-ui/react-popover": "1.1.20", - "@radix-ui/react-popper": "1.3.4", - "@radix-ui/react-portal": "1.1.14", - "@radix-ui/react-presence": "1.1.8", - "@radix-ui/react-primitive": "2.1.7", - "@radix-ui/react-progress": "1.1.13", - "@radix-ui/react-radio-group": "1.4.4", - "@radix-ui/react-roving-focus": "1.1.16", - "@radix-ui/react-scroll-area": "1.2.15", - "@radix-ui/react-select": "2.3.4", - "@radix-ui/react-separator": "1.1.12", - "@radix-ui/react-slider": "1.4.4", - "@radix-ui/react-slot": "1.3.0", - "@radix-ui/react-switch": "1.3.4", - "@radix-ui/react-tabs": "1.1.18", - "@radix-ui/react-toast": "1.2.20", - "@radix-ui/react-toggle": "1.1.15", - "@radix-ui/react-toggle-group": "1.1.16", - "@radix-ui/react-toolbar": "1.1.16", - "@radix-ui/react-tooltip": "1.2.13", - "@radix-ui/react-use-callback-ref": "1.1.2", - "@radix-ui/react-use-controllable-state": "1.2.4", - "@radix-ui/react-use-effect-event": "0.0.3", - "@radix-ui/react-use-escape-keydown": "1.1.3", - "@radix-ui/react-use-is-hydrated": "0.1.1", - "@radix-ui/react-use-layout-effect": "1.1.2", - "@radix-ui/react-use-size": "1.1.2", - "@radix-ui/react-visually-hidden": "1.2.8" + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-accessible-icon": "1.1.15", + "@radix-ui/react-accordion": "1.2.20", + "@radix-ui/react-alert-dialog": "1.1.23", + "@radix-ui/react-arrow": "1.1.15", + "@radix-ui/react-aspect-ratio": "1.1.15", + "@radix-ui/react-avatar": "1.2.6", + "@radix-ui/react-checkbox": "1.3.11", + "@radix-ui/react-collapsible": "1.1.20", + "@radix-ui/react-collection": "1.1.15", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-context-menu": "2.3.7", + "@radix-ui/react-dialog": "1.1.23", + "@radix-ui/react-direction": "1.1.4", + "@radix-ui/react-dismissable-layer": "1.1.19", + "@radix-ui/react-dropdown-menu": "2.1.24", + "@radix-ui/react-focus-guards": "1.1.6", + "@radix-ui/react-focus-scope": "1.1.16", + "@radix-ui/react-form": "0.1.16", + "@radix-ui/react-hover-card": "1.1.23", + "@radix-ui/react-label": "2.1.15", + "@radix-ui/react-menu": "2.1.24", + "@radix-ui/react-menubar": "1.1.24", + "@radix-ui/react-navigation-menu": "1.2.22", + "@radix-ui/react-one-time-password-field": "0.1.16", + "@radix-ui/react-password-toggle-field": "0.1.11", + "@radix-ui/react-popover": "1.1.23", + "@radix-ui/react-popper": "1.3.7", + "@radix-ui/react-portal": "1.1.17", + "@radix-ui/react-presence": "1.1.10", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-progress": "1.1.16", + "@radix-ui/react-radio-group": "1.4.7", + "@radix-ui/react-roving-focus": "1.1.19", + "@radix-ui/react-scroll-area": "1.2.18", + "@radix-ui/react-select": "2.3.7", + "@radix-ui/react-separator": "1.1.15", + "@radix-ui/react-slider": "1.4.7", + "@radix-ui/react-slot": "1.3.3", + "@radix-ui/react-switch": "1.3.7", + "@radix-ui/react-tabs": "1.1.21", + "@radix-ui/react-toast": "1.2.23", + "@radix-ui/react-toggle": "1.1.18", + "@radix-ui/react-toggle-group": "1.1.19", + "@radix-ui/react-toolbar": "1.1.19", + "@radix-ui/react-tooltip": "1.2.16", + "@radix-ui/react-use-callback-ref": "1.1.4", + "@radix-ui/react-use-controllable-state": "1.2.6", + "@radix-ui/react-use-effect-event": "0.0.5", + "@radix-ui/react-use-escape-keydown": "1.1.5", + "@radix-ui/react-use-is-hydrated": "0.1.3", + "@radix-ui/react-use-layout-effect": "1.1.4", + "@radix-ui/react-use-size": "1.1.4", + "@radix-ui/react-visually-hidden": "1.2.11" }, "peerDependencies": { "@types/react": "*", @@ -15442,40 +16085,10 @@ "node": ">= 0.10" } }, - "node_modules/rc": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", - "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==", - "license": "(BSD-2-Clause OR MIT OR Apache-2.0)", - "dependencies": { - "deep-extend": "^0.6.0", - "ini": "~1.3.0", - "minimist": "^1.2.0", - "strip-json-comments": "~2.0.1" - }, - "bin": { - "rc": "cli.js" - } - }, - "node_modules/rc/node_modules/ini": { - "version": "1.3.8", - "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", - "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", - "license": "ISC" - }, - "node_modules/rc/node_modules/strip-json-comments": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", - "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/react": { - "version": "19.2.7", - "resolved": "https://registry.npmjs.org/react/-/react-19.2.7.tgz", - "integrity": "sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ==", + "version": "19.2.8", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.8.tgz", + "integrity": "sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==", "dev": true, "license": "MIT", "engines": { @@ -15497,16 +16110,16 @@ } }, "node_modules/react-dom": { - "version": "19.2.7", - "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.7.tgz", - "integrity": "sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ==", + "version": "19.2.8", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.8.tgz", + "integrity": "sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ==", "dev": true, "license": "MIT", "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { - "react": "^19.2.7" + "react": "^19.2.8" } }, "node_modules/react-h5-audio-player": { @@ -15545,14 +16158,14 @@ } }, "node_modules/react-i18next": { - "version": "17.0.10", - "resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-17.0.10.tgz", - "integrity": "sha512-XneHftyYA774MJkkccSkZ5oKrUpCnXIPmxio3wemqrVzCRLWiGXOMbIzObrer03fNDEnm8g8R5yYls4HcE+esg==", + "version": "17.0.11", + "resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-17.0.11.tgz", + "integrity": "sha512-cDtkXgxjuFTWUH6V+aQn1Ve5vDiUztCNPWW5GtSHDccsgRXO1nE6QFWCEmc1KAutrb3OUv87wFShJL5RhUwPXg==", "dev": true, "license": "MIT", "dependencies": { "@babel/runtime": "^7.29.2", - "html-parse-stringify": "^3.0.1", + "html-parse-stringify": "^4.0.1", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { @@ -15955,21 +16568,14 @@ "node": ">=8" } }, - "node_modules/restore-cursor": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-5.1.0.tgz", - "integrity": "sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA==", + "node_modules/resolve-pkg-maps": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", + "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", "dev": true, "license": "MIT", - "dependencies": { - "onetime": "^7.0.0", - "signal-exit": "^4.1.0" - }, - "engines": { - "node": ">=18" - }, "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" } }, "node_modules/retry": { @@ -15982,13 +16588,6 @@ "node": ">= 4" } }, - "node_modules/rfdc": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz", - "integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==", - "dev": true, - "license": "MIT" - }, "node_modules/rolldown": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.3.tgz", @@ -16309,9 +16908,9 @@ } }, "node_modules/shell-quote": { - "version": "1.8.4", - "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.4.tgz", - "integrity": "sha512-VsC6n6vz1ihYYyZZwX7YZSF5l5x36ca17OC+a69h94YqB7X6XLwf+5MOgynYir2SLFUbl8gIYvBo8K8RoNQ6bQ==", + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.9.0.tgz", + "integrity": "sha512-Iov+JwFv/2HcTpcwNMKd8+IWNb8tboQJNQTkAY/LLVK7gGH9jy+LGkVqPxfekHl+yMmiqXszdGWXgkfml7hjqA==", "dev": true, "license": "MIT", "engines": { @@ -16413,51 +17012,6 @@ "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/simple-concat": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz", - "integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT" - }, - "node_modules/simple-get": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz", - "integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT", - "dependencies": { - "decompress-response": "^6.0.0", - "once": "^1.3.1", - "simple-concat": "^1.0.0" - } - }, "node_modules/simple-update-notifier": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/simple-update-notifier/-/simple-update-notifier-2.0.0.tgz", @@ -16586,6 +17140,30 @@ "node": ">= 0.10.0" } }, + "node_modules/split2": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", + "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", + "license": "ISC", + "engines": { + "node": ">= 10.x" + } + }, + "node_modules/sql-escaper": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/sql-escaper/-/sql-escaper-1.5.1.tgz", + "integrity": "sha512-4toX5E1fQbBrpfXidaHnF0669nkAdETeIPTs2SUjxxD7RRIs9ICG4gtpmfc68JCEKehsdwLFqBu9VlQqZ1P1gg==", + "license": "MIT", + "engines": { + "bun": ">=1.0.0", + "deno": ">=2.0.0", + "node": ">=12.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/mysqljs/sql-escaper?sponsor=1" + } + }, "node_modules/ssh2": { "version": "1.17.0", "resolved": "https://registry.npmjs.org/ssh2/-/ssh2-1.17.0.tgz", @@ -16651,17 +17229,6 @@ "node": ">=10.0.0" } }, - "node_modules/streamx": { - "version": "2.26.0", - "resolved": "https://registry.npmjs.org/streamx/-/streamx-2.26.0.tgz", - "integrity": "sha512-VvNG1K72Po/xwJzxZFnZ++Tbrv4lwSptsbkFuzXCJAYZvCK5nnxsvXU6ajqkv7chyiI1Y0YXq2Jh8Iy8Y7NF/A==", - "license": "MIT", - "dependencies": { - "events-universal": "^1.0.0", - "fast-fifo": "^1.3.2", - "text-decoder": "^1.1.0" - } - }, "node_modules/string_decoder": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", @@ -16882,32 +17449,6 @@ "node": ">=18" } }, - "node_modules/tar-fs": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-3.1.2.tgz", - "integrity": "sha512-QGxxTxxyleAdyM3kpFs14ymbYmNFrfY+pHj7Z8FgtbZ7w2//VAgLMac7sT6nRpIHjppXO2AwwEOg0bPFVRcmXw==", - "license": "MIT", - "dependencies": { - "pump": "^3.0.0", - "tar-stream": "^3.1.5" - }, - "optionalDependencies": { - "bare-fs": "^4.0.1", - "bare-path": "^3.0.0" - } - }, - "node_modules/tar-stream": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-3.2.0.tgz", - "integrity": "sha512-ojzvCvVaNp6aOTFmG7jaRD0meowIAuPc3cMMhSgKiVWws1GyHbGd/xvnyuRKcKlMpt3qvxx6r0hreCNITP9hIg==", - "license": "MIT", - "dependencies": { - "b4a": "^1.6.4", - "bare-fs": "^4.5.5", - "fast-fifo": "^1.2.0", - "streamx": "^2.15.0" - } - }, "node_modules/tar/node_modules/yallist": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz", @@ -16918,15 +17459,6 @@ "node": ">=18" } }, - "node_modules/teex": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/teex/-/teex-1.0.1.tgz", - "integrity": "sha512-eYE6iEI62Ni1H8oIa7KlDU6uQBtqr4Eajni3wX7rpfXD8ysFx8z0+dri+KWEPWpBsxXfxu58x/0jvTVT1ekOSg==", - "license": "MIT", - "dependencies": { - "streamx": "^2.12.5" - } - }, "node_modules/temp-file": { "version": "3.4.0", "resolved": "https://registry.npmjs.org/temp-file/-/temp-file-3.4.0.tgz", @@ -16938,15 +17470,6 @@ "fs-extra": "^10.0.0" } }, - "node_modules/text-decoder": { - "version": "1.2.7", - "resolved": "https://registry.npmjs.org/text-decoder/-/text-decoder-1.2.7.tgz", - "integrity": "sha512-vlLytXkeP4xvEq2otHeJfSQIRyWxo/oZGEbXrtEEF9Hnmrdly59sUbzZ/QgyWuLYHctCHxFF4tRQZNQ9k60ExQ==", - "license": "Apache-2.0", - "dependencies": { - "b4a": "^1.6.4" - } - }, "node_modules/tiny-async-pool": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/tiny-async-pool/-/tiny-async-pool-1.3.0.tgz", @@ -17164,6 +17687,509 @@ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", "license": "0BSD" }, + "node_modules/tsx": { + "version": "4.23.1", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.1.tgz", + "integrity": "sha512-GQHnkIfxyx1wYCOS/wonik5MVRZU9hi1TEZmzGZSCJB1y9YgoZ8H6itNE/u4suE+yLmOzuE4E5S4TZ/ZX2wcWQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.28.0" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/tsx/node_modules/@esbuild/aix-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz", + "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/android-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz", + "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/android-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz", + "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/android-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz", + "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/darwin-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz", + "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/darwin-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz", + "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz", + "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/freebsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz", + "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz", + "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz", + "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz", + "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-loong64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz", + "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-mips64el": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz", + "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz", + "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-riscv64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz", + "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-s390x": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz", + "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz", + "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz", + "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/netbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz", + "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz", + "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/openbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz", + "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz", + "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/sunos-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz", + "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/win32-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz", + "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/win32-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz", + "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/win32-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz", + "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/esbuild": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", + "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.1", + "@esbuild/android-arm": "0.28.1", + "@esbuild/android-arm64": "0.28.1", + "@esbuild/android-x64": "0.28.1", + "@esbuild/darwin-arm64": "0.28.1", + "@esbuild/darwin-x64": "0.28.1", + "@esbuild/freebsd-arm64": "0.28.1", + "@esbuild/freebsd-x64": "0.28.1", + "@esbuild/linux-arm": "0.28.1", + "@esbuild/linux-arm64": "0.28.1", + "@esbuild/linux-ia32": "0.28.1", + "@esbuild/linux-loong64": "0.28.1", + "@esbuild/linux-mips64el": "0.28.1", + "@esbuild/linux-ppc64": "0.28.1", + "@esbuild/linux-riscv64": "0.28.1", + "@esbuild/linux-s390x": "0.28.1", + "@esbuild/linux-x64": "0.28.1", + "@esbuild/netbsd-arm64": "0.28.1", + "@esbuild/netbsd-x64": "0.28.1", + "@esbuild/openbsd-arm64": "0.28.1", + "@esbuild/openbsd-x64": "0.28.1", + "@esbuild/openharmony-arm64": "0.28.1", + "@esbuild/sunos-x64": "0.28.1", + "@esbuild/win32-arm64": "0.28.1", + "@esbuild/win32-ia32": "0.28.1", + "@esbuild/win32-x64": "0.28.1" + } + }, "node_modules/tsyringe": { "version": "4.10.0", "resolved": "https://registry.npmjs.org/tsyringe/-/tsyringe-4.10.0.tgz", @@ -17182,18 +18208,6 @@ "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==", "license": "0BSD" }, - "node_modules/tunnel-agent": { - "version": "0.6.0", - "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", - "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==", - "license": "Apache-2.0", - "dependencies": { - "safe-buffer": "^5.0.1" - }, - "engines": { - "node": "*" - } - }, "node_modules/tw-animate-css": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/tw-animate-css/-/tw-animate-css-1.4.0.tgz", @@ -17299,9 +18313,9 @@ } }, "node_modules/undici": { - "version": "8.7.0", - "resolved": "https://registry.npmjs.org/undici/-/undici-8.7.0.tgz", - "integrity": "sha512-N7iQtfyLhIMOFgQubvmLV26svHpO0bqKnAiWotTQCVKCmWrcGbBotPuW1x+xwYZ2VHdSTVUfPQQnlEt1/LouTQ==", + "version": "8.9.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-8.9.0.tgz", + "integrity": "sha512-aWZpUj7XoGonMClx4gdDRfgBjqeA+F473aDmROQQbM9n6PRfK/u1q/a0X4wMTgcHfT8H6fpbt98PFuDUwFg2YA==", "license": "MIT", "engines": { "node": ">=22.19.0" @@ -17837,16 +18851,6 @@ } } }, - "node_modules/void-elements": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/void-elements/-/void-elements-3.1.0.tgz", - "integrity": "sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/w3c-keyname": { "version": "2.2.8", "resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz", @@ -17975,54 +18979,6 @@ "node": ">=0.10.0" } }, - "node_modules/wrap-ansi": { - "version": "10.0.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-10.0.0.tgz", - "integrity": "sha512-SGcvg80f0wUy2/fXES19feHMz8E0JoXv2uNgHOu4Dgi2OrCy1lqwFYEJz1BLbDI0exjPMe/ZdzZ/YpGECBG/aQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^6.2.3", - "string-width": "^8.2.0", - "strip-ansi": "^7.1.2" - }, - "engines": { - "node": ">=20" - }, - "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" - } - }, - "node_modules/wrap-ansi/node_modules/ansi-styles": { - "version": "6.2.3", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", - "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/wrap-ansi/node_modules/string-width": { - "version": "8.2.1", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-8.2.1.tgz", - "integrity": "sha512-IIaP0g3iy9Cyy18w3M9YcaDudujEAVHKt3a3QJg1+sr/oX96TbaGUubG0hJyCjCBThFH+tFpcIyoUHUn1ogaLA==", - "dev": true, - "license": "MIT", - "dependencies": { - "get-east-asian-width": "^1.5.0", - "strip-ansi": "^7.1.2" - }, - "engines": { - "node": ">=20" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/wrappy": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", @@ -18077,6 +19033,15 @@ "dev": true, "license": "MIT" }, + "node_modules/xtend": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", + "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", + "license": "MIT", + "engines": { + "node": ">=0.4" + } + }, "node_modules/y18n": { "version": "5.0.8", "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", diff --git a/package.json b/package.json index f66d52a0..e953f573 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "termix", "private": true, - "version": "2.6.0", + "version": "2.6.1", "description": "Self-hosted SSH and remote desktop management.", "author": "Karmaa", "main": "electron/main.cjs", @@ -16,10 +16,11 @@ "biome:fix": "biome check --write biome.json package.json", "postinstall": "node scripts/patch-app-builder-lib.cjs && node scripts/patch-guacamole-lite.cjs && node scripts/patch-guacamole-common-js.cjs && node scripts/patch-better-sqlite3.cjs && node scripts/patch-nan.cjs && node scripts/patch-xterm-android-ime.cjs", "prebuild": "node scripts/write-electron-build-info.cjs", - "lint": "eslint .", + "lint": "node scripts/generate-dialect-schema.cjs --check && eslint .", "lint:fix": "eslint --fix .", "type-check": "tsc --noEmit", "test": "vitest run", + "verify:dialect": "tsx scripts/verify-dialects.mjs", "test:watch": "vitest", "test:ui": "vitest --ui", "test:coverage": "vitest run --coverage", @@ -40,77 +41,82 @@ "build:linux-appimage": "npm run build && npm run electron:rebuild && npm run electron:patch-builder && electron-builder --linux AppImage", "build:linux-targz": "npm run build && npm run electron:rebuild && npm run electron:patch-builder && electron-builder --linux tar.gz", "build:mac": "npm run build && npm run electron:rebuild && npm run electron:patch-builder && electron-builder --mac --universal", - "build:mac-dev": "npm run build && npm run electron:rebuild && npm run electron:patch-builder && electron-builder --mac dir --publish=never" + "build:mac-dev": "npm run build && npm run electron:rebuild && npm run electron:patch-builder && electron-builder --mac dir --publish=never", + "schema:generate": "node scripts/generate-dialect-schema.cjs", + "schema:check": "node scripts/generate-dialect-schema.cjs --check", + "schema:migrations": "drizzle-kit generate --config=drizzle.config.sqlite.ts && drizzle-kit generate --config=drizzle.config.pg.ts && drizzle-kit generate --config=drizzle.config.mysql.ts" }, "dependencies": { "@simplewebauthn/browser": "^13.3.0", "@simplewebauthn/server": "^13.3.2", - "@tanstack/react-virtual": "^3.14.6", + "@tanstack/react-virtual": "^3.14.9", "@types/ldapjs": "^3.0.6", - "axios": "^1.18.1", + "axios": "^1.19.0", "bcryptjs": "^3.0.3", - "better-sqlite3": "^12.11.1", + "better-sqlite3": "^13.0.2", "body-parser": "^2.3.0", - "chalk": "^5.6.2", + "chalk": "^6.0.0", "cookie-parser": "^1.4.7", "cors": "^2.8.6", "dotenv": "^17.4.2", "drizzle-orm": "^0.45.2", "express": "^5.2.1", "guacamole-lite": "^1.2.0", - "jose": "^6.2.2", - "js-yaml": "^5.2.1", + "jose": "^6.2.5", + "js-yaml": "^5.2.2", "jsonwebtoken": "^9.0.3", "jszip": "^3.10.1", "ldapjs": "^3.0.7", - "motion": "^12.42.2", + "motion": "^12.43.0", "multer": "^2.2.0", + "mysql2": "^3.23.2", "nanoid": "^6.0.0", + "pg": "^8.22.0", "qrcode": "^1.5.4", "serialport": "^13.0.0", "socks": "^2.8.7", "speakeasy": "^2.0.0", "ssh2": "^1.17.0", - "undici": "^8.7.0", + "undici": "^8.9.0", "ws": "^8.21.1" }, "devDependencies": { - "@biomejs/biome": "2.5.4", + "@biomejs/biome": "2.5.6", "@codemirror/autocomplete": "^6.20.3", "@codemirror/commands": "^6.10.4", "@codemirror/search": "^6.7.1", "@codemirror/theme-one-dark": "^6.1.3", - "@codemirror/view": "^6.43.6", - "@commitlint/cli": "^21.0.2", - "@commitlint/config-conventional": "^21.0.2", + "@codemirror/view": "^6.43.7", + "@commitlint/cli": "^21.2.1", + "@commitlint/config-conventional": "^21.2.0", "@deadendjs/swagger-jsdoc": "^8.1.2", "@electron/notarize": "^3.1.1", - "@electron/rebuild": "^4.0.4", + "@electron/rebuild": "^4.2.0", "@eslint/js": "^10.0.1", - "@fontsource-variable/jetbrains-mono": "^5.2.8", - "@fontsource/fira-code": "^5.2.7", - "@fontsource/jetbrains-mono": "^5.2.8", - "@fontsource/source-code-pro": "^5.2.7", + "@fontsource-variable/jetbrains-mono": "^5.3.0", + "@fontsource/fira-code": "^5.3.0", + "@fontsource/jetbrains-mono": "^5.3.0", + "@fontsource/source-code-pro": "^5.3.0", "@monaco-editor/react": "^4.7.0", - "@radix-ui/react-accordion": "^1.2.17", - "@radix-ui/react-alert-dialog": "^1.1.20", - "@radix-ui/react-checkbox": "^1.3.8", - "@radix-ui/react-dialog": "^1.1.20", - "@radix-ui/react-dropdown-menu": "^2.1.21", - "@radix-ui/react-label": "^2.1.12", - "@radix-ui/react-popover": "^1.1.20", - "@radix-ui/react-progress": "^1.1.13", - "@radix-ui/react-scroll-area": "^1.2.15", - "@radix-ui/react-select": "^2.3.4", - "@radix-ui/react-separator": "^1.1.12", - "@radix-ui/react-slider": "^1.4.4", - "@radix-ui/react-slot": "^1.3.0", - "@radix-ui/react-switch": "^1.3.4", - "@radix-ui/react-tabs": "^1.1.18", - "@radix-ui/react-tooltip": "^1.2.13", + "@radix-ui/react-accordion": "^1.2.20", + "@radix-ui/react-alert-dialog": "^1.1.23", + "@radix-ui/react-checkbox": "^1.3.11", + "@radix-ui/react-dialog": "^1.1.23", + "@radix-ui/react-dropdown-menu": "^2.1.24", + "@radix-ui/react-label": "^2.1.15", + "@radix-ui/react-popover": "^1.1.23", + "@radix-ui/react-progress": "^1.1.16", + "@radix-ui/react-scroll-area": "^1.2.18", + "@radix-ui/react-select": "^2.3.7", + "@radix-ui/react-separator": "^1.1.15", + "@radix-ui/react-slider": "^1.4.7", + "@radix-ui/react-slot": "^1.3.3", + "@radix-ui/react-switch": "^1.3.7", + "@radix-ui/react-tabs": "^1.1.21", + "@radix-ui/react-tooltip": "^1.2.16", "@tailwindcss/vite": "^4.3.3", "@testing-library/dom": "^10.4.1", - "@testing-library/jest-dom": "^6.9.1", + "@testing-library/jest-dom": "^7.0.0", "@testing-library/react": "^16.3.2", "@testing-library/user-event": "^14.6.1", "@types/better-sqlite3": "^7.6.13", @@ -120,52 +126,55 @@ "@types/guacamole-common-js": "^1.5.5", "@types/js-yaml": "^4.0.9", "@types/jsonwebtoken": "^9.0.10", - "@types/multer": "^2.1.0", - "@types/node": "^26.0.0", + "@types/multer": "^2.2.0", + "@types/node": "^26.1.2", + "@types/pg": "^8.20.0", "@types/qrcode": "^1.5.6", - "@types/react": "^19.2.17", - "@types/react-dom": "^19.2.3", + "@types/react": "^19.2.18", + "@types/react-dom": "^19.2.4", "@types/speakeasy": "^2.0.10", "@types/ssh2": "^1.15.5", "@types/ws": "^8.18.1", "@uiw/codemirror-extensions-langs": "^4.25.11", "@uiw/codemirror-theme-github": "^4.25.11", "@uiw/react-codemirror": "^4.25.11", - "@vitejs/plugin-react": "^6.0.3", + "@vitejs/plugin-react": "^6.0.5", "@vitest/coverage-v8": "^4.1.10", "@vitest/ui": "^4.1.10", "@xterm/addon-clipboard": "^0.2.0", "@xterm/addon-fit": "^0.11.0", + "@xterm/addon-search": "^0.16.0", "@xterm/addon-unicode11": "^0.9.0", "@xterm/addon-web-links": "^0.12.0", "@xterm/xterm": "^6.0.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cmdk": "^1.1.1", - "concurrently": "^10.0.3", + "concurrently": "^10.0.4", "cytoscape": "^3.34.0", - "electron": "^43.0.0", + "drizzle-kit": "^0.31.10", + "electron": "^43.2.0", "electron-builder": "^26.15.3", - "eslint": "^10.5.0", + "eslint": "^10.8.0", "eslint-plugin-react-hooks": "^7.1.1", "eslint-plugin-react-refresh": "^0.5.3", "eslint-plugin-unused-imports": "^4.4.1", - "globals": "^17.5.0", + "globals": "^17.8.0", "guacamole-common-js": "^1.5.0", "husky": "^9.1.7", "i18next": "^26.3.6", "i18next-browser-languagedetector": "^8.2.1", "jsdom": "^29.1.1", - "lint-staged": "^17.0.8", - "lucide-react": "^1.20.0", - "prettier": "3.8.4", - "radix-ui": "^1.6.3", - "react": "^19.2.7", + "lint-staged": "^17.2.0", + "lucide-react": "^1.28.0", + "prettier": "3.9.6", + "radix-ui": "^1.6.7", + "react": "^19.2.8", "react-cytoscapejs": "^2.0.0", - "react-dom": "^19.2.7", + "react-dom": "^19.2.8", "react-h5-audio-player": "^3.10.2", "react-hook-form": "^7.79.0", - "react-i18next": "^17.0.10", + "react-i18next": "^17.0.11", "react-icons": "^5.6.0", "react-markdown": "^10.1.0", "react-pdf": "^10.4.1", diff --git a/scripts/generate-dialect-schema.cjs b/scripts/generate-dialect-schema.cjs new file mode 100644 index 00000000..06c5b0c4 --- /dev/null +++ b/scripts/generate-dialect-schema.cjs @@ -0,0 +1,228 @@ +/** + * Generates the Postgres and MySQL schema modules from the SQLite one. + * + * ## These files produce DDL. They are not used at runtime. + * + * drizzle-kit reads them to emit the migrations in drizzle/postgres and + * drizzle/mysql. Nothing imports them to run a query. + * + * That is not an oversight. The query builder needs two things from a table + * object — the identifiers to interpolate, and the encoders that turn JS values + * into driver values — and the sqlite definitions supply both correctly for + * every engine, which is why all 44 repositories import schema.ts directly: + * + * - text and integer encode as themselves everywhere + * - integer({ mode: "boolean" }) writes 1/0, which Postgres and MySQL both + * accept for a boolean column, and reads back through `Number(v) === 1`, + * which is true for JS `true` as well as for 1 + * - real is a plain number on all three + * + * What genuinely differs between the dialects is DDL — column types, key + * lengths, autoincrement syntax — and DDL is exactly what these files exist to + * generate. See scripts/verify-dialects.mjs, which asserts the round-trips + * above against real servers rather than trusting this comment. + * + * The schema is declared once, in sqlite-core, and the other two dialects are + * derived. Hand-maintaining three copies of 52 tables would mean a renamed + * table has to land in three places consistently or a foreign key silently + * points at the wrong one — and the schema is regular enough that the mapping + * is mechanical. + * + * What varies between dialects is small and closed: + * - booleans are integers on sqlite, native elsewhere + * - autoincrement keys are `integer primary key autoincrement`, `serial`, + * and `int auto_increment` + * - MySQL cannot index unbounded TEXT, so any column that is a primary key, + * is unique, or participates in a foreign key must be varchar + * - MySQL rejects a bare DEFAULT CURRENT_TIMESTAMP on a text column, so it is + * written as a parenthesised expression default + * + * Usage: node scripts/generate-dialect-schema.cjs [--check] + * --check verifies the committed files match what would be generated, + * for CI to catch a schema edit that forgot to regenerate. + */ + +const fs = require("fs"); +const path = require("path"); + +const ROOT = path.join(__dirname, ".."); +const SOURCE = path.join(ROOT, "src/backend/database/db/schema.ts"); +const TARGETS = { + postgres: path.join(ROOT, "src/backend/database/db/schema.pg.ts"), + mysql: path.join(ROOT, "src/backend/database/db/schema.mysql.ts"), +}; + +const KEY_LENGTH = 255; + +/** + * Columns that must be varchar rather than text on MySQL. A column qualifies if + * it is a primary key, is unique, or is either end of a foreign key. + */ +function collectKeyColumns(source) { + const keyed = new Set(); + + // `name: text("col")....primaryKey()` / `.unique()` / `.references(...)` + const declaration = + /(\w+):\s*text\("([a-z0-9_]+)"\)((?:\s*\.\w+\([^)]*\))*)/g; + let match; + while ((match = declaration.exec(source)) !== null) { + const [, prop, column, modifiers] = match; + if (/\.(primaryKey|unique|references)\(/.test(modifiers)) { + keyed.add(column); + } + void prop; + } + + // Multi-line form: the modifiers land on following lines. + const multiline = + /(\w+):\s*text\("([a-z0-9_]+)"\)\s*\n(\s*\.\w+\([\s\S]*?\),)/g; + while ((match = multiline.exec(source)) !== null) { + if (/\.(primaryKey|unique|references)\(/.test(match[3])) { + keyed.add(match[2]); + } + } + + // A referenced column implies the referencing side too; both must match. + const reference = /\.references\(\(\)\s*=>\s*\w+\.(\w+)/g; + while ((match = reference.exec(source)) !== null) { + keyed.add(camelToSnake(match[1])); + } + + // Table-level indexes: `(table) => [uniqueIndex("x").on(table.a, table.b)]`. + // These were invisible here at first, and MySQL rejected the migration with + // "BLOB/TEXT column used in key specification without a key length" — but + // only on MySQL 8; MariaDB took it. + const tableIndex = /uniqueIndex\("[a-z0-9_]+"\)\.on\(([^)]*)\)/g; + while ((match = tableIndex.exec(source)) !== null) { + for (const column of match[1].split(",")) { + const name = column.trim().replace(/^\w+\./, ""); + if (name) keyed.add(camelToSnake(name)); + } + } + + return keyed; +} + +function camelToSnake(value) { + return value.replace(/[A-Z]/g, (c) => `_${c.toLowerCase()}`); +} + +function transform(source, dialect) { + const keyed = collectKeyColumns(source); + const isPg = dialect === "postgres"; + let out = source; + + // Autoincrement primary keys, before the plain integer rule below. + out = out.replace( + /integer\("([a-z0-9_]+)"\)\.primaryKey\(\{\s*autoIncrement:\s*true\s*\}\)/g, + (_, col) => + isPg + ? `serial("${col}").primaryKey()` + : `int("${col}").autoincrement().primaryKey()`, + ); + + // Integer-backed booleans become native ones. Prettier wraps the longer + // declarations across lines, so this has to span newlines too. + out = out.replace( + /integer\(\s*"([a-z0-9_]+)",\s*\{\s*mode:\s*"boolean",?\s*\},?\s*\)/g, + (_, col) => `boolean("${col}")`, + ); + + // Remaining integers. + if (!isPg) { + out = out.replace( + /\binteger\("([a-z0-9_]+)"\)/g, + (_, col) => `int("${col}")`, + ); + + // Timestamps are stored as text (see sql-timestamp.ts). MySQL only accepts + // DEFAULT CURRENT_TIMESTAMP on a DATETIME or TIMESTAMP column — on a TEXT + // one it is ER_INVALID_DEFAULT, "Invalid default value". Since 8.0.13 an + // expression default works on any type, and an expression is written + // parenthesised. MariaDB accepts the bare form, which is why this only + // surfaces against real MySQL. + out = out.replace(/sql`CURRENT_TIMESTAMP`/g, "sql`(CURRENT_TIMESTAMP)`"); + } + + // Floating point. + out = out.replace(/\breal\("([a-z0-9_]+)"\)/g, (_, col) => + isPg ? `doublePrecision("${col}")` : `double("${col}")`, + ); + + // Key-bearing strings must be indexable. + out = out.replace(/\btext\("([a-z0-9_]+)"\)/g, (whole, col) => + keyed.has(col) ? `varchar("${col}", { length: ${KEY_LENGTH} })` : whole, + ); + + // text("x", { length: n }) is sqlite-only sugar; drop the length. + out = out.replace( + /\btext\("([a-z0-9_]+)",\s*\{\s*length:\s*\d+\s*\}\)/g, + (_, col) => `text("${col}")`, + ); + + out = out.replace(/\bsqliteTable\(/g, isPg ? "pgTable(" : "mysqlTable("); + + const imports = isPg + ? `import {\n pgTable,\n text,\n varchar,\n integer,\n serial,\n boolean,\n doublePrecision,\n uniqueIndex,\n} from "drizzle-orm/pg-core";` + : `import {\n mysqlTable,\n text,\n varchar,\n int,\n boolean,\n double,\n uniqueIndex,\n} from "drizzle-orm/mysql-core";`; + + out = out.replace( + /import\s*\{[^}]*\}\s*from\s*"drizzle-orm\/sqlite-core";/, + imports, + ); + + return `${header(dialect)}\n${out}`; +} + +function header(dialect) { + return `// GENERATED FILE — do not edit. +// +// Produced from schema.ts by scripts/generate-dialect-schema.cjs. +// Edit the sqlite schema and re-run \`node scripts/generate-dialect-schema.cjs\`. +// Target dialect: ${dialect}. +// +// DDL source for drizzle-kit. NOT imported to run queries — repositories use +// schema.ts on every dialect. See the generator header for why that is correct. +`; +} + +function main() { + const check = process.argv.includes("--check"); + const source = fs.readFileSync(SOURCE, "utf8"); + let drift = false; + + for (const [dialect, target] of Object.entries(TARGETS)) { + const generated = transform(source, dialect); + + if (check) { + const current = fs.existsSync(target) + ? fs.readFileSync(target, "utf8") + : ""; + if (current !== generated) { + console.error( + `[generate-dialect-schema] ${path.relative(ROOT, target)} is out of date`, + ); + drift = true; + } + continue; + } + + fs.writeFileSync(target, generated); + console.log( + `[generate-dialect-schema] wrote ${path.relative(ROOT, target)}`, + ); + } + + if (drift) { + console.error( + "[generate-dialect-schema] run `node scripts/generate-dialect-schema.cjs` and commit the result", + ); + process.exit(1); + } +} + +module.exports = { transform, collectKeyColumns }; + +if (require.main === module) { + main(); +} diff --git a/scripts/generate-dialect-schema.test.ts b/scripts/generate-dialect-schema.test.ts new file mode 100644 index 00000000..dad931f3 --- /dev/null +++ b/scripts/generate-dialect-schema.test.ts @@ -0,0 +1,140 @@ +import { describe, expect, it } from "vitest"; +import { createRequire } from "node:module"; + +const require = createRequire(import.meta.url); +const { transform, collectKeyColumns } = + require("./generate-dialect-schema.cjs") as { + transform: (source: string, dialect: "postgres" | "mysql") => string; + collectKeyColumns: (source: string) => Set; + }; + +const SOURCE = `import { sqliteTable, text, integer, real } from "drizzle-orm/sqlite-core"; +import { sql } from "drizzle-orm"; + +export const users = sqliteTable("users", { + id: text("id").primaryKey(), + username: text("username").notNull(), + isAdmin: integer("is_admin", { mode: "boolean" }).notNull().default(false), + wrapped: integer("wrapped", { + mode: "boolean", + }) + .notNull() + .default(true), + score: real("score"), + ssoProviderId: integer("sso_provider_id"), +}); + +export const folders = sqliteTable("folders", { + id: integer("id").primaryKey({ autoIncrement: true }), + userId: text("user_id") + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + name: text("name").notNull(), + syncId: text("sync_id").unique(), + cert: text("cert", { length: 8192 }), +}); +`; + +describe("collectKeyColumns", () => { + it("finds columns that must be indexable", () => { + const keyed = collectKeyColumns(SOURCE); + + // primary key, unique, and both ends of the foreign key + expect(keyed.has("id")).toBe(true); + expect(keyed.has("sync_id")).toBe(true); + expect(keyed.has("user_id")).toBe(true); + }); + + it("leaves ordinary strings alone", () => { + const keyed = collectKeyColumns(SOURCE); + + expect(keyed.has("username")).toBe(false); + expect(keyed.has("name")).toBe(false); + expect(keyed.has("cert")).toBe(false); + }); +}); + +describe("postgres output", () => { + const out = transform(SOURCE, "postgres"); + + it("is marked generated", () => { + expect(out.startsWith("// GENERATED FILE")).toBe(true); + }); + + it("uses pg-core", () => { + expect(out).toContain('from "drizzle-orm/pg-core"'); + expect(out).not.toContain("sqlite-core"); + expect(out).toContain("pgTable("); + expect(out).not.toContain("sqliteTable("); + }); + + it("maps autoincrement keys to serial", () => { + expect(out).toContain('serial("id").primaryKey()'); + expect(out).not.toContain("autoIncrement"); + }); + + it("maps integer-backed booleans, including the wrapped form", () => { + expect(out).toContain('boolean("is_admin")'); + // Prettier splits longer declarations across lines; both must convert. + expect(out).toContain('boolean("wrapped")'); + expect(out).not.toMatch(/mode:\s*"boolean"/); + }); + + it("keeps plain integers and maps real", () => { + expect(out).toContain('integer("sso_provider_id")'); + expect(out).toContain('doublePrecision("score")'); + }); + + it("makes key columns varchar and leaves the rest text", () => { + expect(out).toContain('varchar("id", { length: 255 })'); + expect(out).toContain('varchar("user_id", { length: 255 })'); + expect(out).toContain('varchar("sync_id", { length: 255 })'); + expect(out).toContain('text("username")'); + expect(out).toContain('text("name")'); + }); + + it("drops the sqlite-only text length", () => { + expect(out).toContain('text("cert")'); + expect(out).not.toContain("length: 8192"); + }); +}); + +describe("mysql output", () => { + const out = transform(SOURCE, "mysql"); + + it("uses mysql-core", () => { + expect(out).toContain('from "drizzle-orm/mysql-core"'); + expect(out).toContain("mysqlTable("); + }); + + it("maps autoincrement keys to int auto_increment", () => { + expect(out).toContain('int("id").autoincrement().primaryKey()'); + }); + + it("renames integer to int", () => { + expect(out).toContain('int("sso_provider_id")'); + expect(out).not.toMatch(/\binteger\(/); + }); + + it("maps real to double", () => { + expect(out).toContain('double("score")'); + }); + + it("makes key columns varchar — MySQL cannot index unbounded TEXT", () => { + expect(out).toContain('varchar("user_id", { length: 255 })'); + expect(out).toContain('text("name")'); + }); +}); + +describe("determinism", () => { + it("produces identical output for identical input", () => { + expect(transform(SOURCE, "postgres")).toBe(transform(SOURCE, "postgres")); + expect(transform(SOURCE, "mysql")).toBe(transform(SOURCE, "mysql")); + }); + + it("keeps foreign key behaviour verbatim", () => { + for (const dialect of ["postgres", "mysql"] as const) { + expect(transform(SOURCE, dialect)).toContain('onDelete: "cascade"'); + } + }); +}); diff --git a/scripts/patch-guacamole-lite.cjs b/scripts/patch-guacamole-lite.cjs index b9a76efe..c3d37bce 100644 --- a/scripts/patch-guacamole-lite.cjs +++ b/scripts/patch-guacamole-lite.cjs @@ -35,6 +35,20 @@ if ( process.exit(0); } +// Every patch below is required for correctness: protocol negotiation, the +// guacd 1.6.0 name handshake, dynamic argument answering, UTF-8 tokens and +// read-only joins. If an upstream release moves an anchor string, silently +// skipping would ship a Termix that looks fine and then drops VNC/RDP sessions +// at runtime, so a missing anchor has to stop the install instead. +function missingAnchor(patch) { + console.error( + `[patch-guacamole-lite] ${patch} anchor not found in guacamole-lite. ` + + "The upstream file has changed and this patch no longer applies — " + + "update scripts/patch-guacamole-lite.cjs to match the new source.", + ); + process.exit(1); +} + let guacdClientContent = fs.readFileSync(guacdClientPath, "utf8"); let cryptContent = fs.readFileSync(cryptPath, "utf8"); let clientConnectionContent = fs.readFileSync(clientConnectionPath, "utf8"); @@ -157,18 +171,14 @@ if (!guacdClientContent.includes("} else if (/^1_\\d+_0$/.test(version)) {")) { newVersionBlock, ); } else { - console.log( - "[patch-guacamole-lite] Version check target not found, skipping", - ); - process.exit(0); + missingAnchor("Version check"); } patched = true; } if (!guacdClientContent.includes(newTimezone)) { if (!guacdClientContent.includes(oldTimezone)) { - console.log("[patch-guacamole-lite] Timezone target not found, skipping"); - process.exit(0); + missingAnchor("Timezone"); } guacdClientContent = guacdClientContent.replace(oldTimezone, newTimezone); patched = true; @@ -180,20 +190,14 @@ if (!guacdClientContent.includes(newConnect)) { } else if (guacdClientContent.includes(oldConnect)) { guacdClientContent = guacdClientContent.replace(oldConnect, newConnect); } else { - console.log( - "[patch-guacamole-lite] Connect target not found, skipping name patch", - ); - process.exit(0); + missingAnchor("Connect"); } patched = true; } if (!guacdClientContent.includes("this.nextArgumentStreamIndex = 0;")) { if (!guacdClientContent.includes(oldSendBuffer)) { - console.log( - "[patch-guacamole-lite] Argument stream index target not found, skipping", - ); - process.exit(0); + missingAnchor("Argument stream index"); } guacdClientContent = guacdClientContent.replace(oldSendBuffer, newSendBuffer); patched = true; @@ -201,10 +205,7 @@ if (!guacdClientContent.includes("this.nextArgumentStreamIndex = 0;")) { if (!guacdClientContent.includes("sendRequiredArguments(params) {")) { if (!guacdClientContent.includes(oldSendInstructionBlock)) { - console.log( - "[patch-guacamole-lite] Required argument helper target not found, skipping", - ); - process.exit(0); + missingAnchor("Required argument helper"); } guacdClientContent = guacdClientContent.replace( oldSendInstructionBlock, @@ -217,10 +218,7 @@ if ( !guacdClientContent.includes("opcode === 'required' || opcode === 'require'") ) { if (!guacdClientContent.includes(oldReadyHandler)) { - console.log( - "[patch-guacamole-lite] Required opcode target not found, skipping", - ); - process.exit(0); + missingAnchor("Required opcode"); } guacdClientContent = guacdClientContent.replace( oldReadyHandler, @@ -273,10 +271,7 @@ if (!cryptContent.includes(newDecryptBlock)) { newDecryptBlock, ); } else { - console.log( - "[patch-guacamole-lite] UTF-8 token decrypt target not found, skipping", - ); - process.exit(0); + missingAnchor("UTF-8 token decrypt"); } patched = true; } @@ -329,10 +324,7 @@ const newSendMessageToGuacd = if (!clientConnectionContent.includes("isReadOnlyJoin()")) { if (!clientConnectionContent.includes(oldSendMessageToGuacd)) { - console.log( - "[patch-guacamole-lite] sendMessageToGuacd target not found, skipping read-only patch", - ); - process.exit(0); + missingAnchor("sendMessageToGuacd"); } clientConnectionContent = clientConnectionContent.replace( oldSendMessageToGuacd, @@ -357,10 +349,7 @@ const newPreserveJoin = if (!clientConnectionContent.includes("compiledSettings.readOnly")) { if (!clientConnectionContent.includes(oldPreserveJoin)) { - console.log( - "[patch-guacamole-lite] join-preserve target not found, skipping readOnly propagation patch", - ); - process.exit(0); + missingAnchor("join-preserve"); } clientConnectionContent = clientConnectionContent.replace( oldPreserveJoin, diff --git a/scripts/verify-dialects.mjs b/scripts/verify-dialects.mjs new file mode 100644 index 00000000..308a530b --- /dev/null +++ b/scripts/verify-dialects.mjs @@ -0,0 +1,140 @@ +/** + * Runs the repository layer against a real Postgres or MySQL server. + * + * The unit tests only ever see SQLite, so the parts of this codebase that + * differ per engine — the RETURNING replacements, the read-then-write + * transactions, the value encoders — have no coverage there at all. This is + * what covers them, and it needs a live server, which is why it is a script + * rather than a test. + * + * Usage: + * npm run verify:dialect -- postgres://user:pass@host:5432/db + * npm run verify:dialect -- mysql://user:pass@host:3306/db + * + * Applies the migrations first, through the same runRemoteMigrations() the + * application uses at startup — so a broken migration fails here rather than in + * production. Writes real rows: point it at a scratch database. + */ + +import { randomUUID } from "crypto"; + +const url = process.argv[2]; +if (!url) { + console.error("usage: node scripts/verify-dialects.mjs "); + process.exit(2); +} + +const scheme = url.split("://", 1)[0].toLowerCase(); +const dialect = scheme.startsWith("postgres") + ? "postgres" + : scheme === "mysql" || scheme === "mariadb" + ? "mysql" + : null; + +if (!dialect) { + console.error(`unsupported URL scheme "${scheme}://"`); + process.exit(2); +} + +const { drizzle } = await import( + dialect === "postgres" ? "drizzle-orm/node-postgres" : "drizzle-orm/mysql2" +); + +// No schema option on purpose — see connect.ts. +const db = drizzle(url); +const context = { dialect, drizzle: db }; + +const { runRemoteMigrations } = + await import("../src/backend/database/db/migrate.js"); +await runRemoteMigrations(dialect, db); + +const { UserRepository } = + await import("../src/backend/database/repositories/user-repository.js"); +const { HostRepository } = + await import("../src/backend/database/repositories/host-repository.js"); +const { SettingsRepository } = + await import("../src/backend/database/repositories/settings-repository.js"); + +let failures = 0; +const check = (label, got, want) => { + const ok = JSON.stringify(got) === JSON.stringify(want); + if (!ok) failures++; + console.log( + ` ${ok ? "ok " : "FAIL"} ${label}` + + (ok + ? "" + : `\n got ${JSON.stringify(got)}, want ${JSON.stringify(want)}`), + ); +}; + +console.log(`\nverifying ${dialect} at ${url.replace(/:[^:@]*@/, ":***@")}\n`); + +const users = new UserRepository(context); +const userId = `verify-${randomUUID()}`; + +// insertReturning: on MySQL this is an insert plus a read inside a transaction. +const created = await users.create({ + id: userId, + username: "before", + passwordHash: "x", + isAdmin: true, +}); +check("insert returns the stored row", created?.username, "before"); + +// The one non-identity value encoder in the schema. Booleans are integers in +// the sqlite definitions the repositories import, so this asserts that 1/0 +// survives a round trip through a native boolean column. +check("boolean true survives the round trip", created?.isAdmin, true); + +// updateReturning must report the state AFTER the write. Reading first would +// return the value the update replaced — silently, with no error. +const updated = await users.update(userId, { username: "after" }); +check("update returns the new value", updated?.username, "after"); + +const hosts = new HostRepository(context); +const host = await hosts.create({ + userId, + name: "verify", + ip: "127.0.0.1", + port: 22, + username: "root", + authType: "password", + enableTerminal: true, +}); +check( + "autoincrement id came back", + typeof host?.id === "number" && host.id > 0, + true, +); +check( + "database-assigned createdAt came back", + typeof host?.createdAt === "string" && host.createdAt.length > 0, + true, +); + +// deleteReturning must report the state BEFORE the write. Reading afterwards +// would find nothing at all. +const settings = new SettingsRepository(context); +const prefix = `verify-${randomUUID()}`; +await settings.set(`${prefix}-a`, "1"); +await settings.set(`${prefix}-b`, "2"); +check( + "delete reports the rows it removed", + await settings.deleteLike(`${prefix}-%`), + 2, +); +check( + "and they are actually gone", + (await settings.listAll()).filter((row) => row.key.startsWith(prefix)).length, + 0, +); + +await hosts.deleteForUser(userId, host.id); +check("host really deleted", await hosts.findById(host.id), null); + +console.log( + failures === 0 + ? `\n${dialect}: all checks passed\n` + : `\n${dialect}: ${failures} FAILED\n`, +); +process.exit(failures === 0 ? 0 : 1); diff --git a/src/backend/database/database.ts b/src/backend/database/database.ts index 28e5d051..8950ae41 100644 --- a/src/backend/database/database.ts +++ b/src/backend/database/database.ts @@ -713,7 +713,7 @@ app.post("/database/export", authenticateJWT, async (req, res) => { } const timestamp = new Date().toISOString().replace(/[:.]/g, "-"); - const filename = `termix-export-${user[0].username}-${timestamp}.sqlite`; + const filename = `termix-export-${user.username}-${timestamp}.sqlite`; const tempPath = path.join(tempDir, filename); apiLogger.info("Creating export database", { @@ -882,7 +882,7 @@ app.post("/database/export", authenticateJWT, async (req, res) => { ); `); - const userRecord = user[0]; + const userRecord = user; const insertUser = exportDb.prepare(` INSERT INTO users (id, username, password_hash, is_admin, is_oidc, oidc_identifier, client_id, client_secret, issuer_url, authorization_url, token_url, identifier_path, name_path, scopes, totp_secret, totp_enabled, totp_backup_codes) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) diff --git a/src/backend/database/db/column-kit.ts b/src/backend/database/db/column-kit.ts new file mode 100644 index 00000000..bcda1b8d --- /dev/null +++ b/src/backend/database/db/column-kit.ts @@ -0,0 +1,71 @@ +import * as sqlite from "drizzle-orm/sqlite-core"; +import * as pg from "drizzle-orm/pg-core"; +import * as mysql from "drizzle-orm/mysql-core"; + +/** + * Per-dialect column constructors, so a table can be declared once instead of + * three times. + * + * The existing schema only uses three column types (text, integer, real) plus + * an integer-backed boolean, which is what makes this tractable — the surface + * to abstract is small and closed. Anything a dialect cannot express the same + * way is spelled out here rather than at 52 call sites. + * + * Notable differences this papers over: + * - booleans are integers in SQLite, native in Postgres and tinyint in MySQL + * - autoincrement keys are `integer primary key autoincrement`, `serial`, and + * `int auto_increment` respectively + * - MySQL cannot index an unbounded TEXT, so keyed/indexed strings must be + * varchar; `shortText` exists for columns used as keys or in unique indexes + */ +export interface ColumnKit { + table: typeof sqlite.sqliteTable | typeof pg.pgTable | typeof mysql.mysqlTable; + /** Free-form string; unbounded where the engine allows it. */ + text: (name: string) => AnyColumnBuilder; + /** String used as a key, unique or indexed — bounded so MySQL can index it. */ + shortText: (name: string, length?: number) => AnyColumnBuilder; + int: (name: string) => AnyColumnBuilder; + /** Auto-incrementing surrogate primary key. */ + serial: (name: string) => AnyColumnBuilder; + bool: (name: string) => AnyColumnBuilder; + real: (name: string) => AnyColumnBuilder; +} + +// drizzle's builders are heavily generic; the schema modules keep their own +// precise types, so this alias only exists to describe the kit's shape. +type AnyColumnBuilder = ReturnType; + +const DEFAULT_KEY_LENGTH = 255; + +export const sqliteKit = { + table: sqlite.sqliteTable, + text: (name: string) => sqlite.text(name), + shortText: (name: string) => sqlite.text(name), + int: (name: string) => sqlite.integer(name), + serial: (name: string) => + sqlite.integer(name).primaryKey({ autoIncrement: true }), + bool: (name: string) => sqlite.integer(name, { mode: "boolean" }), + real: (name: string) => sqlite.real(name), +} as const; + +export const pgKit = { + table: pg.pgTable, + text: (name: string) => pg.text(name), + shortText: (name: string, length = DEFAULT_KEY_LENGTH) => + pg.varchar(name, { length }), + int: (name: string) => pg.integer(name), + serial: (name: string) => pg.serial(name).primaryKey(), + bool: (name: string) => pg.boolean(name), + real: (name: string) => pg.doublePrecision(name), +} as const; + +export const mysqlKit = { + table: mysql.mysqlTable, + text: (name: string) => mysql.text(name), + shortText: (name: string, length = DEFAULT_KEY_LENGTH) => + mysql.varchar(name, { length }), + int: (name: string) => mysql.int(name), + serial: (name: string) => mysql.int(name).autoincrement().primaryKey(), + bool: (name: string) => mysql.boolean(name), + real: (name: string) => mysql.double(name), +} as const; diff --git a/src/backend/database/db/connect.ts b/src/backend/database/db/connect.ts new file mode 100644 index 00000000..db35b207 --- /dev/null +++ b/src/backend/database/db/connect.ts @@ -0,0 +1,74 @@ +import type { DatabaseDialect } from "./dialect.js"; +import type { PortableDatabase } from "../repositories/database-context.js"; + +export const DATABASE_URL_ENV = "DATABASE_URL"; + +/** + * Opens a connection to a client-server engine. + * + * SQLite is not handled here — it has its own lifecycle in db/index.ts, where + * the database is decrypted into memory and serialised back to a file. This + * covers the engines that connect to something already running. + * + * The returned handle is typed as PortableDatabase; see the note there on why + * that is an approximation and what guarantees it. + */ +export function databaseUrl(env: NodeJS.ProcessEnv = process.env): string | null { + const url = env[DATABASE_URL_ENV]?.trim(); + return url ? url : null; +} + +/** + * Checks the connection string suits the configured engine before trying to + * open it, so a mismatch fails with something readable rather than a driver + * error thirty frames down. + */ +export function assertUrlMatchesDialect( + url: string, + dialect: DatabaseDialect, +): void { + const scheme = url.split("://", 1)[0].toLowerCase(); + + const expected: Record = { + postgres: ["postgres", "postgresql"], + mysql: ["mysql", "mariadb"], + }; + + const allowed = expected[dialect]; + if (!allowed) { + throw new Error(`${dialect} does not use ${DATABASE_URL_ENV}`); + } + + if (!allowed.includes(scheme)) { + throw new Error( + `${DATABASE_URL_ENV} is a "${scheme}://" URL but DATABASE_DIALECT is "${dialect}". ` + + `Expected one of ${allowed.map((s) => `${s}://`).join(", ")}.`, + ); + } +} + +export async function connectRemoteDatabase( + dialect: DatabaseDialect, + env: NodeJS.ProcessEnv = process.env, +): Promise { + const url = databaseUrl(env); + if (!url) { + throw new Error( + `${DATABASE_URL_ENV} must be set when DATABASE_DIALECT is "${dialect}".`, + ); + } + + assertUrlMatchesDialect(url, dialect); + + // No `schema` option: it only feeds drizzle's relational query API + // (`db.query.*`), which nothing here uses. The query builder takes its table + // names and value encoders from the table objects the repositories import — + // see the note in schema.pg.ts on why the generated schemas are DDL-only. + if (dialect === "postgres") { + const { drizzle } = await import("drizzle-orm/node-postgres"); + return drizzle(url) as unknown as PortableDatabase; + } + + const { drizzle } = await import("drizzle-orm/mysql2"); + return drizzle(url) as unknown as PortableDatabase; +} diff --git a/src/backend/database/db/dialect.ts b/src/backend/database/db/dialect.ts new file mode 100644 index 00000000..171017b5 --- /dev/null +++ b/src/backend/database/db/dialect.ts @@ -0,0 +1,50 @@ +/** + * Which engine the schema and repositories are built against. + * + * SQLite is not going away: the desktop app embeds its backend and cannot ship + * a database server, so it will always run on SQLite. Postgres and MySQL are + * for self-hosted deployments that need more than one process to reach the + * data. This is a multi-backend story, not a migration off SQLite. + */ +export type DatabaseDialect = "sqlite" | "postgres" | "mysql"; + +export const DATABASE_DIALECT_ENV = "DATABASE_DIALECT"; + +const SUPPORTED: readonly DatabaseDialect[] = ["sqlite", "postgres", "mysql"]; + +export function isDatabaseDialect(value: unknown): value is DatabaseDialect { + return ( + typeof value === "string" && + (SUPPORTED as readonly string[]).includes(value) + ); +} + +/** + * Resolves the configured dialect, defaulting to SQLite so existing + * deployments and the desktop build are unaffected by this being added. + */ +export function resolveDatabaseDialect( + env: NodeJS.ProcessEnv = process.env, +): DatabaseDialect { + const raw = env[DATABASE_DIALECT_ENV]?.trim().toLowerCase(); + if (!raw) return "sqlite"; + + if (!isDatabaseDialect(raw)) { + throw new Error( + `Unsupported ${DATABASE_DIALECT_ENV}: "${raw}". Expected one of ${SUPPORTED.join(", ")}.`, + ); + } + return raw; +} + +/** + * Whether a write has to be explicitly persisted after it commits. + * + * SQLite here is an in-memory database serialised back to an encrypted file, so + * every write needs a trigger to flush it. Client-server engines have already + * durably committed by the time the query returns — there is no file to write + * and nothing to schedule. + */ +export function needsExplicitPersist(dialect: DatabaseDialect): boolean { + return dialect === "sqlite"; +} diff --git a/src/backend/database/db/index.ts b/src/backend/database/db/index.ts index b3f0f97b..3317260b 100644 --- a/src/backend/database/db/index.ts +++ b/src/backend/database/db/index.ts @@ -7,8 +7,21 @@ import { databaseLogger } from "../../utils/logger.js"; import { DatabaseFileEncryption } from "../../utils/database-file-encryption.js"; import { SystemCrypto } from "../../utils/system-crypto.js"; import { DatabaseMigration } from "../../utils/database-migration.js"; +import { + ensureSharedHostAuthOverrideProtocolSchema, + migrateLegacySharedHostAuthOverrides, +} from "../../utils/shared-host-auth-override-migration.js"; import { DatabaseSaveTrigger } from "../../utils/database-save-trigger.js"; +import { migrateAuditRetention } from "../../utils/audit-retention-migration.js"; +import { + assertDataDirIsNotMisconfigured, + DataDirMisconfiguredError, +} from "../../utils/data-dir-guard.js"; import { getDefaultGuacdUrl } from "../../utils/guacd-config.js"; +import { resolveDatabaseDialect, type DatabaseDialect } from "./dialect.js"; +import { connectRemoteDatabase } from "./connect.js"; +import { runRemoteMigrations } from "./migrate.js"; +import type { PortableDatabase } from "../repositories/database-context.js"; const dataDir = process.env.DATA_DIR || "./db/data"; const dbDir = path.resolve(dataDir); @@ -104,11 +117,16 @@ async function initializeDatabaseAsync(): Promise { ); } } else { + assertDataDirIsNotMisconfigured(dataDir); memoryDatabase = new Database(":memory:"); isNewDatabase = true; } } } catch (error) { + // Not a decryption problem: the database is fine, we are pointed at the + // wrong directory. Surface that message as-is. + if (error instanceof DataDirMisconfiguredError) throw error; + databaseLogger.error("Failed to initialize memory database", error, { operation: "db_memory_init_failed", errorMessage: error instanceof Error ? error.message : "Unknown error", @@ -145,8 +163,35 @@ async function initializeDatabaseAsync(): Promise { ); } } else { - memoryDatabase = new Database(":memory:"); - isNewDatabase = true; + assertDataDirIsNotMisconfigured(dataDir); + + // The database still lives in memory and is serialised out on every write; + // turning encryption off only changes whether that file is ciphertext. It + // has to be read back, or each restart starts empty and silently discards + // everything the previous run saved. + const existing = readPlainDatabaseFile(); + if (existing) { + memoryDatabase = new Database(existing); + databaseLogger.info("Loaded unencrypted database from disk", { + operation: "db_load_plain", + path: dbPath, + bytes: existing.length, + }); + } else { + memoryDatabase = new Database(":memory:"); + isNewDatabase = true; + } + } +} + +/** The plain database file, or null when there is nothing to restore. */ +function readPlainDatabaseFile(): Buffer | null { + try { + const contents = fs.readFileSync(dbPath); + return contents.length > 0 ? contents : null; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return null; + throw error; } } @@ -472,13 +517,14 @@ async function initializeCompleteDatabase(): Promise { success INTEGER NOT NULL, error_message TEXT, timestamp TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, - FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE + FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE SET NULL ); CREATE TABLE IF NOT EXISTS session_recordings ( id INTEGER PRIMARY KEY AUTOINCREMENT, host_id INTEGER NOT NULL, - user_id TEXT NOT NULL, + user_id TEXT, + username TEXT, access_id INTEGER, started_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, ended_at TEXT, @@ -491,7 +537,7 @@ async function initializeCompleteDatabase(): Promise { terminated_by_owner INTEGER DEFAULT 0, termination_reason TEXT, FOREIGN KEY (host_id) REFERENCES ssh_data (id) ON DELETE CASCADE, - FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE, + FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE SET NULL, FOREIGN KEY (access_id) REFERENCES host_access (id) ON DELETE SET NULL ); @@ -1418,6 +1464,28 @@ const migrateSchema = () => { } } + try { + ensureSharedHostAuthOverrideProtocolSchema(sqlite); + } catch (schemaError) { + databaseLogger.warn("Failed to prepare shared_host_auth_overrides table", { + operation: "schema_migration", + error: schemaError, + }); + } + + try { + migrateLegacySharedHostAuthOverrides( + sqlite, + getRawSettingValue, + setRawSettingValue, + ); + } catch (migrateError) { + databaseLogger.warn("Failed to migrate shared host auth overrides", { + operation: "schema_migration", + error: migrateError, + }); + } + try { sqlite.prepare("SELECT credential_id FROM ssh_folders LIMIT 1").get(); } catch { @@ -1448,6 +1516,7 @@ const migrateSchema = () => { { column: "connection_type", sql: "ALTER TABLE ssh_data ADD COLUMN connection_type TEXT NOT NULL DEFAULT 'ssh'" }, { column: "credential_id", sql: "ALTER TABLE ssh_data ADD COLUMN credential_id INTEGER" }, { column: "override_credential_username", sql: "ALTER TABLE ssh_data ADD COLUMN override_credential_username INTEGER" }, + { column: "share_ssh_auth", sql: "ALTER TABLE ssh_data ADD COLUMN share_ssh_auth INTEGER NOT NULL DEFAULT 0" }, { column: "jump_hosts", sql: "ALTER TABLE ssh_data ADD COLUMN jump_hosts TEXT" }, { column: "show_terminal_in_sidebar", sql: "ALTER TABLE ssh_data ADD COLUMN show_terminal_in_sidebar INTEGER NOT NULL DEFAULT 1" }, { column: "show_file_manager_in_sidebar", sql: "ALTER TABLE ssh_data ADD COLUMN show_file_manager_in_sidebar INTEGER NOT NULL DEFAULT 0" }, @@ -1637,7 +1706,7 @@ const migrateSchema = () => { sqlite.exec(` CREATE TABLE IF NOT EXISTS audit_logs ( id INTEGER PRIMARY KEY AUTOINCREMENT, - user_id TEXT NOT NULL, + user_id TEXT, username TEXT NOT NULL, action TEXT NOT NULL, resource_type TEXT NOT NULL, @@ -1649,7 +1718,7 @@ const migrateSchema = () => { success INTEGER NOT NULL, error_message TEXT, timestamp TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, - FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE + FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE SET NULL ); `); } catch (createError) { @@ -2497,6 +2566,10 @@ const migrateSchema = () => { } // --- sync end --- + // Audit trails and session recordings used to be deleted along with the user + // they referenced, which defeats the point of keeping them. + migrateAuditRetention(sqlite); + databaseLogger.success("Schema migration completed", { operation: "schema_migration", }); @@ -2580,10 +2653,54 @@ async function handlePostInitFileEncryption() { } async function initializeDatabase(): Promise { + const dialect = resolveDatabaseDialect(); + + if (dialect !== "sqlite") { + await initializeRemoteDatabase(dialect); + return; + } + await initializeCompleteDatabase(); await handlePostInitFileEncryption(); } +/** + * Startup against Postgres or MySQL. + * + * Shorter than the SQLite path because most of what that one does has no + * counterpart here: there is no file to decrypt, no in-memory copy to keep in + * step with disk, and the schema comes from drizzle-kit migrations instead of + * the inline DDL below. + * + * What does carry over is the settings cache. 27 call sites read settings + * synchronously, which better-sqlite3 allows and no remote driver does, so the + * table is loaded once here before anything asks for it. + */ +async function initializeRemoteDatabase( + dialect: Exclude, +): Promise { + databaseLogger.info(`Connecting to ${dialect} database`, { + operation: "db_init", + dialect, + }); + + db = await connectRemoteDatabase(dialect); + await runRemoteMigrations(dialect, db); + + // Imported here rather than at the top: factory.ts imports getDb from this + // module, and a static import would close the cycle at module-load time. + const { primeCurrentSettingsCache, startSettingsCacheRefresh } = await import( + "../repositories/factory.js" + ); + await primeCurrentSettingsCache(); + startSettingsCacheRefresh(); + + databaseLogger.info(`${dialect} database ready`, { + operation: "db_init_complete", + dialect, + }); +} + export { initializeDatabase }; async function cleanupDatabase() { @@ -2661,9 +2778,9 @@ process.on("SIGTERM", async () => { process.exit(0); }); -let db: ReturnType>; +let db: PortableDatabase; -export function getDb(): ReturnType> { +export function getDb(): PortableDatabase { if (!db) { throw new Error( "Database not initialized. Ensure initializeDatabase() is called before accessing db.", @@ -2674,6 +2791,13 @@ export function getDb(): ReturnType> { export function getSqlite(): Database.Database { if (!sqlite) { + const dialect = resolveDatabaseDialect(); + if (dialect !== "sqlite") { + throw new Error( + `No SQLite handle: DATABASE_DIALECT is "${dialect}". This caller needs a ` + + `synchronous query, which only SQLite offers — give it an async path instead.`, + ); + } throw new Error( "SQLite not initialized. Ensure initializeDatabase() is called before accessing sqlite.", ); diff --git a/src/backend/database/db/migrate.ts b/src/backend/database/db/migrate.ts new file mode 100644 index 00000000..57d99912 --- /dev/null +++ b/src/backend/database/db/migrate.ts @@ -0,0 +1,51 @@ +import path from "path"; +import type { DatabaseDialect } from "./dialect.js"; +import type { PortableDatabase } from "../repositories/database-context.js"; + +export const MIGRATIONS_DIR_ENV = "DRIZZLE_MIGRATIONS_DIR"; + +/** + * Where the generated migrations live. + * + * SQLite does not appear here: it builds its schema from the DDL in index.ts + * and patches it forward with migrateSchema(). Only the client-server engines + * use drizzle-kit migrations, and each has its own folder because the + * generated SQL differs per dialect. + */ +export function migrationsFolder( + dialect: DatabaseDialect, + env: NodeJS.ProcessEnv = process.env, +): string { + const override = env[MIGRATIONS_DIR_ENV]?.trim(); + const root = override || path.resolve(process.cwd(), "drizzle"); + return path.join(root, dialect); +} + +/** + * Brings a remote database up to the current schema. + * + * drizzle's migrator records what it has applied in its own table, so this is + * safe to run on every start — including against a database another instance + * already migrated. + */ +export async function runRemoteMigrations( + dialect: DatabaseDialect, + db: PortableDatabase, + env: NodeJS.ProcessEnv = process.env, +): Promise { + if (dialect === "sqlite") { + throw new Error("SQLite builds its schema in index.ts, not from drizzle/"); + } + + const folder = migrationsFolder(dialect, env); + + const { migrate } = + dialect === "postgres" + ? await import("drizzle-orm/node-postgres/migrator") + : await import("drizzle-orm/mysql2/migrator"); + + await (migrate as (db: unknown, config: { migrationsFolder: string }) => Promise)( + db, + { migrationsFolder: folder }, + ); +} diff --git a/src/backend/database/db/schema.mysql.ts b/src/backend/database/db/schema.mysql.ts new file mode 100644 index 00000000..b86c18c2 --- /dev/null +++ b/src/backend/database/db/schema.mysql.ts @@ -0,0 +1,1283 @@ +// GENERATED FILE — do not edit. +// +// Produced from schema.ts by scripts/generate-dialect-schema.cjs. +// Edit the sqlite schema and re-run `node scripts/generate-dialect-schema.cjs`. +// Target dialect: mysql. +// +// DDL source for drizzle-kit. NOT imported to run queries — repositories use +// schema.ts on every dialect. See the generator header for why that is correct. + +import { + mysqlTable, + text, + varchar, + int, + boolean, + double, + uniqueIndex, +} from "drizzle-orm/mysql-core"; +import { sql } from "drizzle-orm"; + +export const users = mysqlTable("users", { + id: varchar("id", { length: 255 }).primaryKey(), + username: text("username").notNull(), + passwordHash: text("password_hash").notNull(), + isAdmin: boolean("is_admin").notNull().default(false), + + isOidc: boolean("is_oidc").notNull().default(false), + oidcIdentifier: text("oidc_identifier"), + ssoProviderId: int("sso_provider_id"), + clientId: text("client_id"), + clientSecret: text("client_secret"), + issuerUrl: text("issuer_url"), + authorizationUrl: text("authorization_url"), + tokenUrl: text("token_url"), + identifierPath: text("identifier_path"), + namePath: text("name_path"), + scopes: text().default("openid email profile"), + + totpSecret: text("totp_secret"), + totpEnabled: boolean("totp_enabled") + .notNull() + .default(false), + totpBackupCodes: text("totp_backup_codes"), + + registeredAt: text("registered_at").notNull().default(sql`(CURRENT_TIMESTAMP)`), + donationModalDismissed: boolean("donation_modal_dismissed") + .notNull() + .default(false), +}); + +export const settings = mysqlTable("settings", { + key: varchar("key", { length: 255 }).primaryKey(), + value: text("value").notNull(), +}); + +export const ssoProviders = mysqlTable("sso_providers", { + id: int("id").autoincrement().primaryKey(), + name: varchar("name", { length: 255 }).notNull(), + type: text("type").notNull(), + enabled: boolean("enabled").notNull().default(true), + displayOrder: int("display_order").notNull().default(0), + config: text("config").notNull(), + createdAt: text("created_at") + .notNull() + .default(sql`(CURRENT_TIMESTAMP)`), + updatedAt: text("updated_at") + .notNull() + .default(sql`(CURRENT_TIMESTAMP)`), +}); + +export const sessions = mysqlTable("sessions", { + id: varchar("id", { length: 255 }).primaryKey(), + userId: varchar("user_id", { length: 255 }) + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + jwtToken: text("jwt_token").notNull(), + deviceType: text("device_type").notNull(), + deviceInfo: text("device_info").notNull(), + oidcSub: text("oidc_sub"), + oidcSid: text("oidc_sid"), + ssoProviderId: int("sso_provider_id"), + createdAt: text("created_at") + .notNull() + .default(sql`(CURRENT_TIMESTAMP)`), + expiresAt: text("expires_at").notNull(), + lastActiveAt: text("last_active_at") + .notNull() + .default(sql`(CURRENT_TIMESTAMP)`), +}); + +export const trustedDevices = mysqlTable("trusted_devices", { + id: varchar("id", { length: 255 }).primaryKey(), + userId: varchar("user_id", { length: 255 }) + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + deviceFingerprint: text("device_fingerprint").notNull(), + deviceType: text("device_type").notNull(), + deviceInfo: text("device_info").notNull(), + createdAt: text("created_at") + .notNull() + .default(sql`(CURRENT_TIMESTAMP)`), + expiresAt: text("expires_at").notNull(), + lastUsedAt: text("last_used_at") + .notNull() + .default(sql`(CURRENT_TIMESTAMP)`), +}); + +export const webauthnCredentials = mysqlTable("webauthn_credentials", { + id: varchar("id", { length: 255 }).primaryKey(), + userId: varchar("user_id", { length: 255 }) + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + name: varchar("name", { length: 255 }).notNull(), + credentialId: text("credential_id").notNull(), + publicKey: text("public_key").notNull(), + counter: int("counter").notNull().default(0), + deviceType: text("device_type"), + backedUp: boolean("backed_up").notNull().default(false), + transports: text("transports"), + userVerification: text("user_verification").notNull().default("preferred"), + createdAt: text("created_at") + .notNull() + .default(sql`(CURRENT_TIMESTAMP)`), + lastUsedAt: text("last_used_at"), +}); + +export const hosts = mysqlTable("ssh_data", { + id: int("id").autoincrement().primaryKey(), + userId: varchar("user_id", { length: 255 }) + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + connectionType: text("connection_type").notNull().default("ssh"), + name: varchar("name", { length: 255 }), + ip: text("ip").notNull(), + port: int("port").notNull(), + username: text("username").notNull(), + folder: text("folder"), + tags: text("tags"), + pin: boolean("pin").notNull().default(false), + authType: text("auth_type").notNull(), + useWarpgate: boolean("use_warpgate").notNull().default(false), + shareSshAuth: boolean("share_ssh_auth") + .notNull() + .default(false), + forceKeyboardInteractive: text("force_keyboard_interactive"), + + password: text("password"), + key: text("key"), + keyPassword: text("key_password"), + keyType: text("key_type"), + sudoPassword: text("sudo_password"), + + autostartPassword: text("autostart_password"), + autostartKey: text("autostart_key"), + autostartKeyPassword: text("autostart_key_password"), + + credentialId: int("credential_id").references(() => sshCredentials.id, { onDelete: "set null" }), + overrideCredentialUsername: boolean("override_credential_username"), + // When authType is "vault", the host authenticates via a Vault SSH signer + // profile (shared settings, no secrets). The signing certificate is obtained + // per-user at connect time via an interactive Vault OIDC flow. + vaultProfileId: int("vault_profile_id").references( + () => vaultProfiles.id, + { onDelete: "set null" }, + ), + enableTerminal: boolean("enable_terminal") + .notNull() + .default(true), + enableSessionLogging: boolean("enable_session_logging") + .notNull() + .default(true), + allowSessionSharing: boolean("allow_session_sharing") + .notNull() + .default(true), + enableCommandHistory: boolean("enable_command_history") + .notNull() + .default(true), + enableTunnel: boolean("enable_tunnel") + .notNull() + .default(true), + tunnelConnections: text("tunnel_connections"), + jumpHosts: text("jump_hosts"), + enableFileManager: boolean("enable_file_manager") + .notNull() + .default(true), + scpLegacy: boolean("scp_legacy").notNull().default(false), + enableDocker: boolean("enable_docker") + .notNull() + .default(false), + enableTmuxMonitor: boolean("enable_tmux_monitor") + .notNull() + .default(false), + showTerminalInSidebar: boolean("show_terminal_in_sidebar") + .notNull() + .default(true), + showFileManagerInSidebar: boolean("show_file_manager_in_sidebar") + .notNull() + .default(false), + showTunnelInSidebar: boolean("show_tunnel_in_sidebar") + .notNull() + .default(false), + showDockerInSidebar: boolean("show_docker_in_sidebar") + .notNull() + .default(false), + showServerStatsInSidebar: boolean("show_server_stats_in_sidebar") + .notNull() + .default(false), + defaultPath: text("default_path"), + statsConfig: text("stats_config"), + dockerConfig: text("docker_config"), + enableProxmox: boolean("enable_proxmox") + .notNull() + .default(false), + proxmoxConfig: text("proxmox_config"), + terminalConfig: text("terminal_config"), + quickActions: text("quick_actions"), + notes: text("notes"), + enableSsh: boolean("enable_ssh").notNull().default(true), + enableRdp: boolean("enable_rdp").notNull().default(false), + enableVnc: boolean("enable_vnc").notNull().default(false), + enableTelnet: boolean("enable_telnet").notNull().default(false), + + sshPort: int("ssh_port").default(22), + rdpPort: int("rdp_port").default(3389), + vncPort: int("vnc_port").default(5900), + telnetPort: int("telnet_port").default(23), + + rdpCredentialId: int("rdp_credential_id").references(() => sshCredentials.id, { onDelete: "set null" }), + rdpUser: text("rdp_user"), + rdpPassword: text("rdp_password"), + rdpDomain: text("rdp_domain"), + rdpSecurity: text("rdp_security"), + rdpIgnoreCert: boolean("rdp_ignore_cert").default(false), + + vncCredentialId: int("vnc_credential_id").references(() => sshCredentials.id, { onDelete: "set null" }), + vncPassword: text("vnc_password"), + vncUser: text("vnc_user"), + + telnetUser: text("telnet_user"), + telnetPassword: text("telnet_password"), + telnetCredentialId: int("telnet_credential_id").references(() => sshCredentials.id, { onDelete: "set null" }), + + rdpAuthType: text("rdp_auth_type"), + vncAuthType: text("vnc_auth_type"), + telnetAuthType: text("telnet_auth_type"), + + domain: text("domain"), + security: text("security"), + ignoreCert: boolean("ignore_cert").default(false), + guacamoleConfig: text("guacamole_config"), + + useSocks5: boolean("use_socks5"), + socks5Host: text("socks5_host"), + socks5Port: int("socks5_port"), + socks5Username: text("socks5_username"), + socks5Password: text("socks5_password"), + socks5ProxyChain: text("socks5_proxy_chain"), + + // null = use the desktop app's global default; "local" | "remote" pins + // this specific host's SSH/Docker-console/Serial connections to originate + // from the embedded local backend or a connected remote sync server. + // Ignored for rdp/vnc/telnet, which always require the remote server. + connectionOrigin: text("connection_origin"), + + macAddress: text("mac_address"), + wolBroadcastAddress: text("wol_broadcast_address"), + portKnockSequence: text("port_knock_sequence"), + + hostKeyFingerprint: text("host_key_fingerprint"), + hostKeyType: text("host_key_type"), + hostKeyAlgorithm: text("host_key_algorithm").default("sha256"), + hostKeyFirstSeen: text("host_key_first_seen"), + hostKeyLastVerified: text("host_key_last_verified"), + hostKeyChangedCount: int("host_key_changed_count").default(0), + + // Stable identity used to match this row across two independently-seeded + // databases (the embedded backend and a connected remote server) during + // sync -- local autoincrement ids collide across instances. + syncId: varchar("sync_id", { length: 255 }).unique(), + + createdAt: text("created_at") + .notNull() + .default(sql`(CURRENT_TIMESTAMP)`), + updatedAt: text("updated_at") + .notNull() + .default(sql`(CURRENT_TIMESTAMP)`), +}); + +export const fileManagerRecent = mysqlTable("file_manager_recent", { + id: int("id").autoincrement().primaryKey(), + userId: varchar("user_id", { length: 255 }) + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + hostId: int("host_id") + .notNull() + .references(() => hosts.id, { onDelete: "cascade" }), + name: varchar("name", { length: 255 }).notNull(), + path: text("path").notNull(), + lastOpened: text("last_opened") + .notNull() + .default(sql`(CURRENT_TIMESTAMP)`), +}); + +export const fileManagerPinned = mysqlTable("file_manager_pinned", { + id: int("id").autoincrement().primaryKey(), + userId: varchar("user_id", { length: 255 }) + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + hostId: int("host_id") + .notNull() + .references(() => hosts.id, { onDelete: "cascade" }), + name: varchar("name", { length: 255 }).notNull(), + path: text("path").notNull(), + pinnedAt: text("pinned_at") + .notNull() + .default(sql`(CURRENT_TIMESTAMP)`), +}); + +export const fileManagerShortcuts = mysqlTable("file_manager_shortcuts", { + id: int("id").autoincrement().primaryKey(), + userId: varchar("user_id", { length: 255 }) + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + hostId: int("host_id") + .notNull() + .references(() => hosts.id, { onDelete: "cascade" }), + name: varchar("name", { length: 255 }).notNull(), + path: text("path").notNull(), + createdAt: text("created_at") + .notNull() + .default(sql`(CURRENT_TIMESTAMP)`), +}); + +export const transferRecent = mysqlTable("transfer_recent", { + id: int("id").autoincrement().primaryKey(), + userId: varchar("user_id", { length: 255 }) + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + sourceHostId: int("source_host_id") + .notNull() + .references(() => hosts.id, { onDelete: "cascade" }), + destHostId: int("dest_host_id") + .notNull() + .references(() => hosts.id, { onDelete: "cascade" }), + destPath: text("dest_path").notNull(), + destPathLabel: text("dest_path_label").notNull(), + lastUsed: text("last_used") + .notNull() + .default(sql`(CURRENT_TIMESTAMP)`), +}); + +export const dismissedAlerts = mysqlTable("dismissed_alerts", { + id: int("id").autoincrement().primaryKey(), + userId: varchar("user_id", { length: 255 }) + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + alertId: text("alert_id").notNull(), + dismissedAt: text("dismissed_at") + .notNull() + .default(sql`(CURRENT_TIMESTAMP)`), +}); + +export const sshCredentials = mysqlTable("ssh_credentials", { + id: int("id").autoincrement().primaryKey(), + userId: varchar("user_id", { length: 255 }) + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + name: varchar("name", { length: 255 }).notNull(), + description: text("description"), + folder: text("folder"), + tags: text("tags"), + authType: text("auth_type").notNull(), + username: text("username"), + password: text("password"), + key: text("key"), + privateKey: text("private_key"), + publicKey: text("public_key"), + keyPassword: text("key_password"), + keyType: text("key_type"), + detectedKeyType: text("detected_key_type"), + + certPublicKey: text("cert_public_key"), + + + usageCount: int("usage_count").notNull().default(0), + lastUsed: text("last_used"), + syncId: varchar("sync_id", { length: 255 }).unique(), + createdAt: text("created_at") + .notNull() + .default(sql`(CURRENT_TIMESTAMP)`), + updatedAt: text("updated_at") + .notNull() + .default(sql`(CURRENT_TIMESTAMP)`), +}); + +export const sshCredentialUsage = mysqlTable("ssh_credential_usage", { + id: int("id").autoincrement().primaryKey(), + credentialId: int("credential_id") + .notNull() + .references(() => sshCredentials.id, { onDelete: "cascade" }), + hostId: int("host_id") + .notNull() + .references(() => hosts.id, { onDelete: "cascade" }), + userId: varchar("user_id", { length: 255 }) + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + usedAt: text("used_at") + .notNull() + .default(sql`(CURRENT_TIMESTAMP)`), +}); + +export const snippets = mysqlTable("snippets", { + id: int("id").autoincrement().primaryKey(), + userId: varchar("user_id", { length: 255 }) + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + name: varchar("name", { length: 255 }).notNull(), + content: text("content").notNull(), + description: text("description"), + folder: text("folder"), + order: int("order").notNull().default(0), + syncId: varchar("sync_id", { length: 255 }).unique(), + createdAt: text("created_at") + .notNull() + .default(sql`(CURRENT_TIMESTAMP)`), + updatedAt: text("updated_at") + .notNull() + .default(sql`(CURRENT_TIMESTAMP)`), + hostFilter: text("host_filter"), +}); + +export const snippetFolders = mysqlTable("snippet_folders", { + id: int("id").autoincrement().primaryKey(), + userId: varchar("user_id", { length: 255 }) + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + name: varchar("name", { length: 255 }).notNull(), + color: text("color"), + icon: text("icon"), + syncId: varchar("sync_id", { length: 255 }).unique(), + createdAt: text("created_at") + .notNull() + .default(sql`(CURRENT_TIMESTAMP)`), + updatedAt: text("updated_at") + .notNull() + .default(sql`(CURRENT_TIMESTAMP)`), +}); + +export const c2sTunnelPresets = mysqlTable("c2s_tunnel_presets", { + id: int("id").autoincrement().primaryKey(), + userId: varchar("user_id", { length: 255 }) + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + name: varchar("name", { length: 255 }).notNull(), + config: text("config").notNull(), + platform: text("platform"), + computerName: text("computer_name"), + createdAt: text("created_at") + .notNull() + .default(sql`(CURRENT_TIMESTAMP)`), + updatedAt: text("updated_at") + .notNull() + .default(sql`(CURRENT_TIMESTAMP)`), +}); + +export const snippetAccess = mysqlTable("snippet_access", { + id: int("id").autoincrement().primaryKey(), + snippetId: int("snippet_id") + .notNull() + .references(() => snippets.id, { onDelete: "cascade" }), + + userId: varchar("user_id", { length: 255 }).references(() => users.id, { onDelete: "cascade" }), + roleId: int("role_id").references(() => roles.id, { + onDelete: "cascade", + }), + + grantedBy: varchar("granted_by", { length: 255 }) + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + + permissionLevel: text("permission_level").notNull().default("view"), + + expiresAt: text("expires_at"), + + createdAt: text("created_at") + .notNull() + .default(sql`(CURRENT_TIMESTAMP)`), +}); + +export const sshFolders = mysqlTable("ssh_folders", { + id: int("id").autoincrement().primaryKey(), + userId: varchar("user_id", { length: 255 }) + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + name: varchar("name", { length: 255 }).notNull(), + color: text("color"), + icon: text("icon"), + credentialId: int("credential_id").references(() => sshCredentials.id, { + onDelete: "set null", + }), + syncId: varchar("sync_id", { length: 255 }).unique(), + createdAt: text("created_at") + .notNull() + .default(sql`(CURRENT_TIMESTAMP)`), + updatedAt: text("updated_at") + .notNull() + .default(sql`(CURRENT_TIMESTAMP)`), +}); + +export const recentActivity = mysqlTable("recent_activity", { + id: int("id").autoincrement().primaryKey(), + userId: varchar("user_id", { length: 255 }) + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + type: text("type").notNull(), + hostId: int("host_id") + .notNull() + .references(() => hosts.id, { onDelete: "cascade" }), + hostName: text("host_name"), + timestamp: text("timestamp") + .notNull() + .default(sql`(CURRENT_TIMESTAMP)`), +}); + +export const commandHistory = mysqlTable("command_history", { + id: int("id").autoincrement().primaryKey(), + userId: varchar("user_id", { length: 255 }) + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + hostId: int("host_id") + .notNull() + .references(() => hosts.id, { onDelete: "cascade" }), + command: text("command").notNull(), + executedAt: text("executed_at") + .notNull() + .default(sql`(CURRENT_TIMESTAMP)`), +}); + +export const networkTopology = mysqlTable("network_topology", { + id: int("id").autoincrement().primaryKey(), + userId: varchar("user_id", { length: 255 }) + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + topology: text("topology"), + createdAt: text("created_at") + .notNull() + .default(sql`(CURRENT_TIMESTAMP)`), + updatedAt: text("updated_at") + .notNull() + .default(sql`(CURRENT_TIMESTAMP)`), +}); + +export const hostAccess = mysqlTable("host_access", { + id: int("id").autoincrement().primaryKey(), + hostId: int("host_id") + .notNull() + .references(() => hosts.id, { onDelete: "cascade" }), + + userId: varchar("user_id", { length: 255 }) + .references(() => users.id, { onDelete: "cascade" }), + roleId: int("role_id") + .references(() => roles.id, { onDelete: "cascade" }), + + grantedBy: varchar("granted_by", { length: 255 }) + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + + permissionLevel: text("permission_level") + .notNull() + .default("connect"), + + expiresAt: text("expires_at"), + + createdAt: text("created_at") + .notNull() + .default(sql`(CURRENT_TIMESTAMP)`), + lastAccessedAt: text("last_accessed_at"), + accessCount: int("access_count").notNull().default(0), +}); + +export const sharedHostAuthOverrides = mysqlTable( + "shared_host_auth_overrides", + { + id: int("id").autoincrement().primaryKey(), + hostId: int("host_id") + .notNull() + .references(() => hosts.id, { onDelete: "cascade" }), + userId: varchar("user_id", { length: 255 }) + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + protocol: varchar("protocol", { length: 255 }).notNull().default("ssh"), + credentialId: int("credential_id") + .notNull() + .references(() => sshCredentials.id, { onDelete: "cascade" }), + createdAt: text("created_at") + .notNull() + .default(sql`(CURRENT_TIMESTAMP)`), + updatedAt: text("updated_at") + .notNull() + .default(sql`(CURRENT_TIMESTAMP)`), + }, + (table) => [ + uniqueIndex("shared_host_auth_overrides_host_user_protocol_unique").on( + table.hostId, + table.userId, + table.protocol, + ), + ], +); + +export const sharedHostSecrets = mysqlTable( + "shared_host_secrets", + { + id: int("id").autoincrement().primaryKey(), + + hostAccessId: int("host_access_id") + .notNull() + .references(() => hostAccess.id, { onDelete: "cascade" }), + + targetUserId: varchar("target_user_id", { length: 255 }) + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + + protocol: varchar("protocol", { length: 255 }).notNull().default("ssh"), + sourceType: text("source_type").notNull().default("credential"), + + originalCredentialId: int("original_credential_id").references( + () => sshCredentials.id, + { onDelete: "cascade" }, + ), + + encryptedUsername: text("encrypted_username"), + encryptedAuthType: text("encrypted_auth_type"), + encryptedPassword: text("encrypted_password"), + encryptedKey: text("encrypted_key"), + encryptedKeyPassword: text("encrypted_key_password"), + encryptedKeyType: text("encrypted_key_type"), + encryptedDomain: text("encrypted_domain"), + + createdAt: text("created_at") + .notNull() + .default(sql`(CURRENT_TIMESTAMP)`), + updatedAt: text("updated_at") + .notNull() + .default(sql`(CURRENT_TIMESTAMP)`), + }, + // Declared inline in the production DDL as UNIQUE(...), but never here, + // so the generated Postgres and MySQL schemas allowed duplicates the + // SQLite deployment forbids — and the upsert had nothing to conflict on. + (table) => [ + uniqueIndex("idx_shared_host_secrets_scope").on( + table.hostAccessId, + table.targetUserId, + table.protocol, + ), + ], +); + +export const roles = mysqlTable("roles", { + id: int("id").autoincrement().primaryKey(), + name: varchar("name", { length: 255 }).notNull().unique(), + displayName: text("display_name").notNull(), + description: text("description"), + + isSystem: boolean("is_system") + .notNull() + .default(false), + + permissions: text("permissions"), + + createdAt: text("created_at") + .notNull() + .default(sql`(CURRENT_TIMESTAMP)`), + updatedAt: text("updated_at") + .notNull() + .default(sql`(CURRENT_TIMESTAMP)`), +}); + +export const userRoles = mysqlTable( + "user_roles", + { + id: int("id").autoincrement().primaryKey(), + userId: varchar("user_id", { length: 255 }) + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + roleId: int("role_id") + .notNull() + .references(() => roles.id, { onDelete: "cascade" }), + + grantedBy: varchar("granted_by", { length: 255 }).references(() => users.id, { + onDelete: "set null", + }), + grantedAt: text("granted_at") + .notNull() + .default(sql`(CURRENT_TIMESTAMP)`), + }, + // Declared inline in the production DDL as UNIQUE(...), but never here, + // so the generated Postgres and MySQL schemas allowed duplicates the + // SQLite deployment forbids — and the upsert had nothing to conflict on. + (table) => [uniqueIndex("idx_user_roles_user_role").on(table.userId, table.roleId)], +); + +export const auditLogs = mysqlTable("audit_logs", { + id: int("id").autoincrement().primaryKey(), + + // Nullable on purpose: the trail outlives the account, and username keeps the + // entry attributable once the reference is gone. + userId: varchar("user_id", { length: 255 }).references(() => users.id, { onDelete: "set null" }), + username: text("username").notNull(), + + action: text("action").notNull(), + resourceType: text("resource_type").notNull(), + resourceId: text("resource_id"), + resourceName: text("resource_name"), + + details: text("details"), + ipAddress: text("ip_address"), + userAgent: text("user_agent"), + + success: boolean("success").notNull(), + errorMessage: text("error_message"), + + timestamp: text("timestamp") + .notNull() + .default(sql`(CURRENT_TIMESTAMP)`), +}); + +export const sessionRecordings = mysqlTable("session_recordings", { + id: int("id").autoincrement().primaryKey(), + + hostId: int("host_id") + .notNull() + .references(() => hosts.id, { onDelete: "cascade" }), + // Nullable on purpose: a recording is evidence about the host as much as the + // person, so it outlives the account. username keeps it attributable. + userId: varchar("user_id", { length: 255 }).references(() => users.id, { onDelete: "set null" }), + username: text("username"), + accessId: int("access_id").references(() => hostAccess.id, { + onDelete: "set null", + }), + + startedAt: text("started_at") + .notNull() + .default(sql`(CURRENT_TIMESTAMP)`), + endedAt: text("ended_at"), + duration: int("duration"), + + commands: text("commands"), + dangerousActions: text("dangerous_actions"), + + recordingPath: text("recording_path"), + protocol: varchar("protocol", { length: 255 }).notNull().default("ssh"), + format: text("format").notNull().default("text"), + + terminatedByOwner: boolean("terminated_by_owner") + .default(false), + terminationReason: text("termination_reason"), +}); + +export const sessionShares = mysqlTable("session_shares", { + id: varchar("id", { length: 255 }).primaryKey(), + + hostId: int("host_id") + .notNull() + .references(() => hosts.id, { onDelete: "cascade" }), + ownerUserId: varchar("owner_user_id", { length: 255 }) + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + + protocol: varchar("protocol", { length: 255 }).notNull(), + + // Live-session binding: TerminalSessionManager's session.id for SSH, or + // guacd's own guacamoleConnectionId for rdp/vnc/telnet. Neither is a DB + // row (process-local, in-memory) so this intentionally has no FK. + sessionId: text("session_id").notNull(), + tabInstanceId: text("tab_instance_id"), + + shareType: text("share_type").notNull(), // "link" | "user" + targetUserId: varchar("target_user_id", { length: 255 }).references(() => users.id, { + onDelete: "cascade", + }), + linkToken: varchar("link_token", { length: 255 }).unique(), + + permissionLevel: text("permission_level").notNull().default("read-only"), + + createdAt: text("created_at") + .notNull() + .default(sql`(CURRENT_TIMESTAMP)`), + expiresAt: text("expires_at").notNull(), + revokedAt: text("revoked_at"), + + lastJoinedAt: text("last_joined_at"), + joinCount: int("join_count").notNull().default(0), +}); + +export const sessionShareParticipants = mysqlTable( + "session_share_participants", + { + id: int("id").autoincrement().primaryKey(), + shareId: varchar("share_id", { length: 255 }) + .notNull() + .references(() => sessionShares.id, { onDelete: "cascade" }), + + userId: varchar("user_id", { length: 255 }).references(() => users.id, { + onDelete: "cascade", + }), + guestLabel: text("guest_label"), + + joinedAt: text("joined_at") + .notNull() + .default(sql`(CURRENT_TIMESTAMP)`), + leftAt: text("left_at"), + }, +); + +export const opksshTokens = mysqlTable( + "opkssh_tokens", + { + id: int("id").autoincrement().primaryKey(), + userId: varchar("user_id", { length: 255 }) + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + hostId: int("host_id") + .notNull() + .references(() => hosts.id, { onDelete: "cascade" }), + + sshCert: text("ssh_cert").notNull(), + privateKey: text("private_key").notNull(), + + email: text("email"), + sub: text("sub"), + issuer: text("issuer"), + audience: text("audience"), + + createdAt: text("created_at") + .notNull() + .default(sql`(CURRENT_TIMESTAMP)`), + expiresAt: text("expires_at").notNull(), + lastUsed: text("last_used"), + }, + // Declared inline in the production DDL as UNIQUE(...), but never here, + // so the generated Postgres and MySQL schemas allowed duplicates the + // SQLite deployment forbids — and the upsert had nothing to conflict on. + (table) => [uniqueIndex("idx_opkssh_tokens_user_host").on(table.userId, table.hostId)], +); + +// Vault SSH signer profiles. These hold ONLY non-secret connection settings and +// are intended to be shared across users (shared === true makes a profile +// visible to every user on the server). Each user authenticates to Vault via an +// interactive OIDC flow at connect time; no tokens or keys are stored here. +export const vaultProfiles = mysqlTable("vault_profiles", { + id: int("id").autoincrement().primaryKey(), + userId: varchar("user_id", { length: 255 }) + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + name: varchar("name", { length: 255 }).notNull(), + description: text("description"), + folder: text("folder"), + tags: text("tags"), + // Vault server connection (non-secret) + vaultAddr: text("vault_addr").notNull(), + vaultNamespace: text("vault_namespace"), + // OIDC auth method mount + role used to obtain a Vault token interactively + oidcMount: text("oidc_mount"), + oidcRole: text("oidc_role"), + // SSH secrets engine mount + signer role used to sign the ephemeral key + sshMount: text("ssh_mount"), + sshRole: text("ssh_role").notNull(), + validPrincipals: text("valid_principals"), + // Ephemeral keypair algorithm to generate per connection + keyType: text("key_type"), + // When true the profile is visible/usable by all users on the server + shared: boolean("shared").notNull().default(false), + syncId: varchar("sync_id", { length: 255 }).unique(), + createdAt: text("created_at") + .notNull() + .default(sql`(CURRENT_TIMESTAMP)`), + updatedAt: text("updated_at") + .notNull() + .default(sql`(CURRENT_TIMESTAMP)`), +}); + +// Per-user cache of the ephemeral SSH private key + Vault-signed certificate. +// Transient: rows live only until the certificate expires. Secret fields are +// encrypted under the user's data-encryption key (see field-crypto.ts). +export const vaultTokens = mysqlTable( + "vault_tokens", + { + id: int("id").autoincrement().primaryKey(), + userId: varchar("user_id", { length: 255 }) + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + profileId: int("profile_id") + .notNull() + .references(() => vaultProfiles.id, { onDelete: "cascade" }), + + sshCert: text("ssh_cert").notNull(), + privateKey: text("private_key").notNull(), + + createdAt: text("created_at") + .notNull() + .default(sql`(CURRENT_TIMESTAMP)`), + expiresAt: text("expires_at").notNull(), + lastUsed: text("last_used"), + }, + // Declared inline in the production DDL as UNIQUE(...), but never here, + // so the generated Postgres and MySQL schemas allowed duplicates the + // SQLite deployment forbids — and the upsert had nothing to conflict on. + (table) => [uniqueIndex("idx_vault_tokens_user_profile").on(table.userId, table.profileId)], +); + +export const apiKeys = mysqlTable("api_keys", { + id: varchar("id", { length: 255 }).primaryKey(), + userId: varchar("user_id", { length: 255 }) + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + name: varchar("name", { length: 255 }).notNull(), + tokenHash: text("token_hash").notNull(), + tokenPrefix: text("token_prefix").notNull(), + createdAt: text("created_at").notNull().default(sql`(CURRENT_TIMESTAMP)`), + expiresAt: text("expires_at"), + lastUsedAt: text("last_used_at"), + isActive: boolean("is_active").notNull().default(true), +}); + +export const userOpenTabs = mysqlTable("user_open_tabs", { + id: varchar("id", { length: 255 }).primaryKey(), + userId: varchar("user_id", { length: 255 }) + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + tabType: text("tab_type").notNull(), + hostId: int("host_id").references(() => hosts.id, { onDelete: "cascade" }), + label: text("label").notNull(), + tabOrder: int("tab_order").notNull().default(0), + backendSessionId: text("backend_session_id"), + createdAt: text("created_at") + .notNull() + .default(sql`(CURRENT_TIMESTAMP)`), + updatedAt: text("updated_at") + .notNull() + .default(sql`(CURRENT_TIMESTAMP)`), +}); + +export const userPreferences = mysqlTable("user_preferences", { + userId: varchar("user_id", { length: 255 }) + .primaryKey() + .references(() => users.id, { onDelete: "cascade" }), + reopenTabsOnLogin: boolean("reopen_tabs_on_login") + .notNull() + .default(false), + theme: text("theme"), + fontSize: text("font_size"), + accentColor: text("accent_color"), + language: text("language"), + storageMode: text("storage_mode"), + commandAutocomplete: boolean("command_autocomplete"), + commandPaletteEnabled: boolean("command_palette_enabled"), + showHostTags: boolean("show_host_tags"), + hostTrayOnClick: boolean("host_tray_on_click"), + pinAppRail: boolean("pin_app_rail"), + expandAppRailOnHover: boolean("expand_app_rail_on_hover"), + foldersCollapsed: boolean("folders_collapsed"), + confirmSnippetExecution: boolean("confirm_snippet_execution"), + disableUpdateCheck: boolean("disable_update_check"), + confirmTabClose: boolean("confirm_tab_close"), + hiddenRailTabs: text("hidden_rail_tabs"), + compactHostView: boolean("compact_host_view"), + statusColorScheme: text("status_color_scheme"), + customThemes: text("custom_themes"), + customKeybindings: text("custom_keybindings"), + updatedAt: text("updated_at") + .notNull() + .default(sql`(CURRENT_TIMESTAMP)`), +}); + +export const hostMetricsPreferences = mysqlTable( + "host_metrics_preferences", + { + id: int("id").autoincrement().primaryKey(), + userId: varchar("user_id", { length: 255 }) + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + hostId: int("host_id") + .notNull() + .references(() => hosts.id, { onDelete: "cascade" }), + // JSON-encoded HostMetricsLayout. Layout has no secrets, so it is stored as + // plain JSON (no field-level encryption). + layout: text("layout").notNull(), + createdAt: text("created_at") + .notNull() + .default(sql`(CURRENT_TIMESTAMP)`), + updatedAt: text("updated_at") + .notNull() + .default(sql`(CURRENT_TIMESTAMP)`), + }, + // One layout per user per host. Enforced in production since the inline DDL + // creates it, but it was never declared here, so the generated Postgres and + // MySQL schemas lacked it — and the upsert has nothing to conflict on. + (table) => [ + uniqueIndex("idx_host_metrics_prefs_user_host").on(table.userId, table.hostId), + ], +); + +export const hostHealthChecks = mysqlTable( + "host_health_checks", + { + id: int("id").autoincrement().primaryKey(), + userId: varchar("user_id", { length: 255 }) + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + hostId: int("host_id") + .notNull() + .references(() => hosts.id, { onDelete: "cascade" }), + // JSON array of { id, name, type: "tcp"|"http", target, port, path } + checks: text("checks").notNull(), + intervalSeconds: int("interval_seconds").notNull().default(300), + createdAt: text("created_at") + .notNull() + .default(sql`(CURRENT_TIMESTAMP)`), + updatedAt: text("updated_at") + .notNull() + .default(sql`(CURRENT_TIMESTAMP)`), + }, + // Same as above: one set of checks per user per host. + (table) => [ + uniqueIndex("idx_host_health_checks_user_host").on(table.userId, table.hostId), + ], +); + +export const hostHealthHistory = mysqlTable("host_health_history", { + id: int("id").autoincrement().primaryKey(), + userId: varchar("user_id", { length: 255 }) + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + hostId: int("host_id") + .notNull() + .references(() => hosts.id, { onDelete: "cascade" }), + checkId: text("check_id").notNull(), + ts: text("ts").notNull().default(sql`(CURRENT_TIMESTAMP)`), + ok: boolean("ok").notNull(), + latencyMs: int("latency_ms"), + detail: text("detail"), +}); + +export const dashboardServiceLinks = mysqlTable("dashboard_service_links", { + id: int("id").autoincrement().primaryKey(), + userId: varchar("user_id", { length: 255 }) + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + label: text("label").notNull(), + url: text("url").notNull(), + order: int("order").notNull().default(0), + syncId: varchar("sync_id", { length: 255 }).unique(), + createdAt: text("created_at") + .notNull() + .default(sql`(CURRENT_TIMESTAMP)`), + updatedAt: text("updated_at") + .notNull() + .default(sql`(CURRENT_TIMESTAMP)`), +}); + +// --- termix-id begin --- +// A user claims a unique public handle. Their published SSH public keys are +// served at an unauthenticated resolver endpoint in authorized_keys format, +// so any server can be provisioned with `curl /termix-id/u/ >> ~/.ssh/authorized_keys`. +export const termixIdentities = mysqlTable("termix_identities", { + id: int("id").autoincrement().primaryKey(), + // One Termix ID per user — enforced in schema, not just in code. + userId: varchar("user_id", { length: 255 }) + .notNull() + .unique() + .references(() => users.id, { onDelete: "cascade" }), + handle: varchar("handle", { length: 255 }).notNull().unique(), + description: text("description"), + createdAt: text("created_at") + .notNull() + .default(sql`(CURRENT_TIMESTAMP)`), + updatedAt: text("updated_at") + .notNull() + .default(sql`(CURRENT_TIMESTAMP)`), +}); + +export const termixIdentityKeys = mysqlTable("termix_identity_keys", { + id: int("id").autoincrement().primaryKey(), + identityId: int("identity_id") + .notNull() + .references(() => termixIdentities.id, { onDelete: "cascade" }), + userId: varchar("user_id", { length: 255 }) + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + // Public keys are non-secret, so they are stored in plaintext (no field-level + // encryption). This is what lets the unauthenticated resolver serve them. + publicKey: text("public_key").notNull(), + // Raw algorithm token (e.g. "ssh-ed25519"), and a normalized group used for + // the / resolver filter (RSA / ED25519 / ECDSA / ...). + keyType: text("key_type").notNull(), + algorithm: text("algorithm").notNull(), + label: text("label"), + comment: text("comment"), + // "manual" (pasted) or "credential" (imported from an ssh_credentials entry). + source: text("source").notNull().default("manual"), + credentialId: int("credential_id").references(() => sshCredentials.id, { + onDelete: "set null", + }), + enabled: boolean("enabled").notNull().default(true), + createdAt: text("created_at") + .notNull() + .default(sql`(CURRENT_TIMESTAMP)`), +}); +// Per-identity certificate authority. Servers that trust this CA (via +// TrustedUserCAKeys / @cert-authority) accept any user certificate it signs, +// giving central revocation (rotate the CA) and expiry (cert validity). +export const termixIdentityCa = mysqlTable("termix_identity_ca", { + id: int("id").autoincrement().primaryKey(), + identityId: int("identity_id") + .notNull() + .unique() + .references(() => termixIdentities.id, { onDelete: "cascade" }), + userId: varchar("user_id", { length: 255 }) + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + // CA public key (plaintext — it is published); CA private key is field-encrypted. + publicKey: text("public_key").notNull(), + privateKey: text("private_key").notNull(), + validityDays: int("validity_days").notNull().default(90), + createdAt: text("created_at") + .notNull() + .default(sql`(CURRENT_TIMESTAMP)`), + updatedAt: text("updated_at") + .notNull() + .default(sql`(CURRENT_TIMESTAMP)`), +}); +// --- termix-id end --- + +// --- tmux-monitor begin --- +export const tmuxSessionTags = mysqlTable("tmux_session_tags", { + id: int("id").autoincrement().primaryKey(), + userId: varchar("user_id", { length: 255 }) + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + hostId: int("host_id") + .notNull() + .references(() => hosts.id, { onDelete: "cascade" }), + sessionName: text("session_name").notNull(), + tag: text("tag").notNull(), + createdAt: text("created_at") + .notNull() + .default(sql`(CURRENT_TIMESTAMP)`), +}); +// --- tmux-monitor end --- + +// --- metrics-history begin --- +export const hostMetricsHistory = mysqlTable("host_metrics_history", { + id: int("id").autoincrement().primaryKey(), + hostId: int("host_id") + .notNull() + .references(() => hosts.id, { onDelete: "cascade" }), + ts: text("ts") + .notNull() + .default(sql`(CURRENT_TIMESTAMP)`), + cpuPercent: double("cpu_percent"), + memPercent: double("mem_percent"), + diskPercent: double("disk_percent"), + netRxBytes: int("net_rx_bytes"), + netTxBytes: int("net_tx_bytes"), +}); +// --- metrics-history end --- + +// --- alerts begin --- +export const alertRules = mysqlTable("alert_rules", { + id: int("id").autoincrement().primaryKey(), + userId: varchar("user_id", { length: 255 }) + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + hostId: int("host_id").references(() => hosts.id, { onDelete: "cascade" }), + name: varchar("name", { length: 255 }).notNull(), + enabled: boolean("enabled").notNull().default(true), + triggerType: text("trigger_type").notNull(), + thresholdValue: double("threshold_value"), + thresholdDurationSeconds: int("threshold_duration_seconds"), + cooldownMinutes: int("cooldown_minutes").notNull().default(15), + createdAt: text("created_at") + .notNull() + .default(sql`(CURRENT_TIMESTAMP)`), + updatedAt: text("updated_at") + .notNull() + .default(sql`(CURRENT_TIMESTAMP)`), +}); + +export const notificationChannels = mysqlTable("notification_channels", { + id: int("id").autoincrement().primaryKey(), + userId: varchar("user_id", { length: 255 }) + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + name: varchar("name", { length: 255 }).notNull(), + type: text("type").notNull(), + config: text("config").notNull(), + enabled: boolean("enabled").notNull().default(true), + createdAt: text("created_at") + .notNull() + .default(sql`(CURRENT_TIMESTAMP)`), +}); + +export const alertRuleChannels = mysqlTable("alert_rule_channels", { + id: int("id").autoincrement().primaryKey(), + ruleId: int("rule_id") + .notNull() + .references(() => alertRules.id, { onDelete: "cascade" }), + channelId: int("channel_id") + .notNull() + .references(() => notificationChannels.id, { onDelete: "cascade" }), +}); + +export const alertFirings = mysqlTable("alert_firings", { + id: int("id").autoincrement().primaryKey(), + userId: varchar("user_id", { length: 255 }) + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + ruleId: int("rule_id") + .notNull() + .references(() => alertRules.id, { onDelete: "cascade" }), + hostId: int("host_id").notNull(), + hostName: text("host_name").notNull(), + firedAt: text("fired_at") + .notNull() + .default(sql`(CURRENT_TIMESTAMP)`), + resolvedAt: text("resolved_at"), + value: double("value"), + message: text("message").notNull(), + severity: text("severity").notNull().default("warning"), + acknowledged: boolean("acknowledged").notNull().default(false), +}); +// --- alerts end --- + +// --- homepage begin --- +export const homepageItems = mysqlTable("homepage_items", { + id: int("id").autoincrement().primaryKey(), + userId: varchar("user_id", { length: 255 }) + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + typeId: text("type_id").notNull(), + title: text("title"), + config: text("config").notNull().default("{}"), + folderId: int("folder_id"), + syncId: varchar("sync_id", { length: 255 }).unique(), + createdAt: text("created_at") + .notNull() + .default(sql`(CURRENT_TIMESTAMP)`), + updatedAt: text("updated_at") + .notNull() + .default(sql`(CURRENT_TIMESTAMP)`), +}); + +export const homepageLayouts = mysqlTable("homepage_layouts", { + id: int("id").autoincrement().primaryKey(), + userId: varchar("user_id", { length: 255 }) + .notNull() + .unique() + .references(() => users.id, { onDelete: "cascade" }), + // JSON: { entries: HomepageLayoutEntry[], pan: {x,y}, zoom: number } + layout: text("layout").notNull().default("{}"), + updatedAt: text("updated_at") + .notNull() + .default(sql`(CURRENT_TIMESTAMP)`), +}); +// --- homepage end --- + +// --- sync begin --- +// Records a delete for a synced entity type so the other side of a sync +// pair (embedded desktop backend <-> connected remote server) learns about +// the deletion instead of re-creating the row on its next pull. +export const syncTombstones = mysqlTable("sync_tombstones", { + id: int("id").autoincrement().primaryKey(), + userId: varchar("user_id", { length: 255 }) + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + entityType: text("entity_type").notNull(), + syncId: varchar("sync_id", { length: 255 }).notNull(), + deletedAt: text("deleted_at") + .notNull() + .default(sql`(CURRENT_TIMESTAMP)`), +}); +// --- sync end --- diff --git a/src/backend/database/db/schema.pg.ts b/src/backend/database/db/schema.pg.ts new file mode 100644 index 00000000..1549fc86 --- /dev/null +++ b/src/backend/database/db/schema.pg.ts @@ -0,0 +1,1284 @@ +// GENERATED FILE — do not edit. +// +// Produced from schema.ts by scripts/generate-dialect-schema.cjs. +// Edit the sqlite schema and re-run `node scripts/generate-dialect-schema.cjs`. +// Target dialect: postgres. +// +// DDL source for drizzle-kit. NOT imported to run queries — repositories use +// schema.ts on every dialect. See the generator header for why that is correct. + +import { + pgTable, + text, + varchar, + integer, + serial, + boolean, + doublePrecision, + uniqueIndex, +} from "drizzle-orm/pg-core"; +import { sql } from "drizzle-orm"; + +export const users = pgTable("users", { + id: varchar("id", { length: 255 }).primaryKey(), + username: text("username").notNull(), + passwordHash: text("password_hash").notNull(), + isAdmin: boolean("is_admin").notNull().default(false), + + isOidc: boolean("is_oidc").notNull().default(false), + oidcIdentifier: text("oidc_identifier"), + ssoProviderId: integer("sso_provider_id"), + clientId: text("client_id"), + clientSecret: text("client_secret"), + issuerUrl: text("issuer_url"), + authorizationUrl: text("authorization_url"), + tokenUrl: text("token_url"), + identifierPath: text("identifier_path"), + namePath: text("name_path"), + scopes: text().default("openid email profile"), + + totpSecret: text("totp_secret"), + totpEnabled: boolean("totp_enabled") + .notNull() + .default(false), + totpBackupCodes: text("totp_backup_codes"), + + registeredAt: text("registered_at").notNull().default(sql`CURRENT_TIMESTAMP`), + donationModalDismissed: boolean("donation_modal_dismissed") + .notNull() + .default(false), +}); + +export const settings = pgTable("settings", { + key: varchar("key", { length: 255 }).primaryKey(), + value: text("value").notNull(), +}); + +export const ssoProviders = pgTable("sso_providers", { + id: serial("id").primaryKey(), + name: varchar("name", { length: 255 }).notNull(), + type: text("type").notNull(), + enabled: boolean("enabled").notNull().default(true), + displayOrder: integer("display_order").notNull().default(0), + config: text("config").notNull(), + createdAt: text("created_at") + .notNull() + .default(sql`CURRENT_TIMESTAMP`), + updatedAt: text("updated_at") + .notNull() + .default(sql`CURRENT_TIMESTAMP`), +}); + +export const sessions = pgTable("sessions", { + id: varchar("id", { length: 255 }).primaryKey(), + userId: varchar("user_id", { length: 255 }) + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + jwtToken: text("jwt_token").notNull(), + deviceType: text("device_type").notNull(), + deviceInfo: text("device_info").notNull(), + oidcSub: text("oidc_sub"), + oidcSid: text("oidc_sid"), + ssoProviderId: integer("sso_provider_id"), + createdAt: text("created_at") + .notNull() + .default(sql`CURRENT_TIMESTAMP`), + expiresAt: text("expires_at").notNull(), + lastActiveAt: text("last_active_at") + .notNull() + .default(sql`CURRENT_TIMESTAMP`), +}); + +export const trustedDevices = pgTable("trusted_devices", { + id: varchar("id", { length: 255 }).primaryKey(), + userId: varchar("user_id", { length: 255 }) + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + deviceFingerprint: text("device_fingerprint").notNull(), + deviceType: text("device_type").notNull(), + deviceInfo: text("device_info").notNull(), + createdAt: text("created_at") + .notNull() + .default(sql`CURRENT_TIMESTAMP`), + expiresAt: text("expires_at").notNull(), + lastUsedAt: text("last_used_at") + .notNull() + .default(sql`CURRENT_TIMESTAMP`), +}); + +export const webauthnCredentials = pgTable("webauthn_credentials", { + id: varchar("id", { length: 255 }).primaryKey(), + userId: varchar("user_id", { length: 255 }) + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + name: varchar("name", { length: 255 }).notNull(), + credentialId: text("credential_id").notNull(), + publicKey: text("public_key").notNull(), + counter: integer("counter").notNull().default(0), + deviceType: text("device_type"), + backedUp: boolean("backed_up").notNull().default(false), + transports: text("transports"), + userVerification: text("user_verification").notNull().default("preferred"), + createdAt: text("created_at") + .notNull() + .default(sql`CURRENT_TIMESTAMP`), + lastUsedAt: text("last_used_at"), +}); + +export const hosts = pgTable("ssh_data", { + id: serial("id").primaryKey(), + userId: varchar("user_id", { length: 255 }) + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + connectionType: text("connection_type").notNull().default("ssh"), + name: varchar("name", { length: 255 }), + ip: text("ip").notNull(), + port: integer("port").notNull(), + username: text("username").notNull(), + folder: text("folder"), + tags: text("tags"), + pin: boolean("pin").notNull().default(false), + authType: text("auth_type").notNull(), + useWarpgate: boolean("use_warpgate").notNull().default(false), + shareSshAuth: boolean("share_ssh_auth") + .notNull() + .default(false), + forceKeyboardInteractive: text("force_keyboard_interactive"), + + password: text("password"), + key: text("key"), + keyPassword: text("key_password"), + keyType: text("key_type"), + sudoPassword: text("sudo_password"), + + autostartPassword: text("autostart_password"), + autostartKey: text("autostart_key"), + autostartKeyPassword: text("autostart_key_password"), + + credentialId: integer("credential_id").references(() => sshCredentials.id, { onDelete: "set null" }), + overrideCredentialUsername: boolean("override_credential_username"), + // When authType is "vault", the host authenticates via a Vault SSH signer + // profile (shared settings, no secrets). The signing certificate is obtained + // per-user at connect time via an interactive Vault OIDC flow. + vaultProfileId: integer("vault_profile_id").references( + () => vaultProfiles.id, + { onDelete: "set null" }, + ), + enableTerminal: boolean("enable_terminal") + .notNull() + .default(true), + enableSessionLogging: boolean("enable_session_logging") + .notNull() + .default(true), + allowSessionSharing: boolean("allow_session_sharing") + .notNull() + .default(true), + enableCommandHistory: boolean("enable_command_history") + .notNull() + .default(true), + enableTunnel: boolean("enable_tunnel") + .notNull() + .default(true), + tunnelConnections: text("tunnel_connections"), + jumpHosts: text("jump_hosts"), + enableFileManager: boolean("enable_file_manager") + .notNull() + .default(true), + scpLegacy: boolean("scp_legacy").notNull().default(false), + enableDocker: boolean("enable_docker") + .notNull() + .default(false), + enableTmuxMonitor: boolean("enable_tmux_monitor") + .notNull() + .default(false), + showTerminalInSidebar: boolean("show_terminal_in_sidebar") + .notNull() + .default(true), + showFileManagerInSidebar: boolean("show_file_manager_in_sidebar") + .notNull() + .default(false), + showTunnelInSidebar: boolean("show_tunnel_in_sidebar") + .notNull() + .default(false), + showDockerInSidebar: boolean("show_docker_in_sidebar") + .notNull() + .default(false), + showServerStatsInSidebar: boolean("show_server_stats_in_sidebar") + .notNull() + .default(false), + defaultPath: text("default_path"), + statsConfig: text("stats_config"), + dockerConfig: text("docker_config"), + enableProxmox: boolean("enable_proxmox") + .notNull() + .default(false), + proxmoxConfig: text("proxmox_config"), + terminalConfig: text("terminal_config"), + quickActions: text("quick_actions"), + notes: text("notes"), + enableSsh: boolean("enable_ssh").notNull().default(true), + enableRdp: boolean("enable_rdp").notNull().default(false), + enableVnc: boolean("enable_vnc").notNull().default(false), + enableTelnet: boolean("enable_telnet").notNull().default(false), + + sshPort: integer("ssh_port").default(22), + rdpPort: integer("rdp_port").default(3389), + vncPort: integer("vnc_port").default(5900), + telnetPort: integer("telnet_port").default(23), + + rdpCredentialId: integer("rdp_credential_id").references(() => sshCredentials.id, { onDelete: "set null" }), + rdpUser: text("rdp_user"), + rdpPassword: text("rdp_password"), + rdpDomain: text("rdp_domain"), + rdpSecurity: text("rdp_security"), + rdpIgnoreCert: boolean("rdp_ignore_cert").default(false), + + vncCredentialId: integer("vnc_credential_id").references(() => sshCredentials.id, { onDelete: "set null" }), + vncPassword: text("vnc_password"), + vncUser: text("vnc_user"), + + telnetUser: text("telnet_user"), + telnetPassword: text("telnet_password"), + telnetCredentialId: integer("telnet_credential_id").references(() => sshCredentials.id, { onDelete: "set null" }), + + rdpAuthType: text("rdp_auth_type"), + vncAuthType: text("vnc_auth_type"), + telnetAuthType: text("telnet_auth_type"), + + domain: text("domain"), + security: text("security"), + ignoreCert: boolean("ignore_cert").default(false), + guacamoleConfig: text("guacamole_config"), + + useSocks5: boolean("use_socks5"), + socks5Host: text("socks5_host"), + socks5Port: integer("socks5_port"), + socks5Username: text("socks5_username"), + socks5Password: text("socks5_password"), + socks5ProxyChain: text("socks5_proxy_chain"), + + // null = use the desktop app's global default; "local" | "remote" pins + // this specific host's SSH/Docker-console/Serial connections to originate + // from the embedded local backend or a connected remote sync server. + // Ignored for rdp/vnc/telnet, which always require the remote server. + connectionOrigin: text("connection_origin"), + + macAddress: text("mac_address"), + wolBroadcastAddress: text("wol_broadcast_address"), + portKnockSequence: text("port_knock_sequence"), + + hostKeyFingerprint: text("host_key_fingerprint"), + hostKeyType: text("host_key_type"), + hostKeyAlgorithm: text("host_key_algorithm").default("sha256"), + hostKeyFirstSeen: text("host_key_first_seen"), + hostKeyLastVerified: text("host_key_last_verified"), + hostKeyChangedCount: integer("host_key_changed_count").default(0), + + // Stable identity used to match this row across two independently-seeded + // databases (the embedded backend and a connected remote server) during + // sync -- local autoincrement ids collide across instances. + syncId: varchar("sync_id", { length: 255 }).unique(), + + createdAt: text("created_at") + .notNull() + .default(sql`CURRENT_TIMESTAMP`), + updatedAt: text("updated_at") + .notNull() + .default(sql`CURRENT_TIMESTAMP`), +}); + +export const fileManagerRecent = pgTable("file_manager_recent", { + id: serial("id").primaryKey(), + userId: varchar("user_id", { length: 255 }) + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + hostId: integer("host_id") + .notNull() + .references(() => hosts.id, { onDelete: "cascade" }), + name: varchar("name", { length: 255 }).notNull(), + path: text("path").notNull(), + lastOpened: text("last_opened") + .notNull() + .default(sql`CURRENT_TIMESTAMP`), +}); + +export const fileManagerPinned = pgTable("file_manager_pinned", { + id: serial("id").primaryKey(), + userId: varchar("user_id", { length: 255 }) + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + hostId: integer("host_id") + .notNull() + .references(() => hosts.id, { onDelete: "cascade" }), + name: varchar("name", { length: 255 }).notNull(), + path: text("path").notNull(), + pinnedAt: text("pinned_at") + .notNull() + .default(sql`CURRENT_TIMESTAMP`), +}); + +export const fileManagerShortcuts = pgTable("file_manager_shortcuts", { + id: serial("id").primaryKey(), + userId: varchar("user_id", { length: 255 }) + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + hostId: integer("host_id") + .notNull() + .references(() => hosts.id, { onDelete: "cascade" }), + name: varchar("name", { length: 255 }).notNull(), + path: text("path").notNull(), + createdAt: text("created_at") + .notNull() + .default(sql`CURRENT_TIMESTAMP`), +}); + +export const transferRecent = pgTable("transfer_recent", { + id: serial("id").primaryKey(), + userId: varchar("user_id", { length: 255 }) + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + sourceHostId: integer("source_host_id") + .notNull() + .references(() => hosts.id, { onDelete: "cascade" }), + destHostId: integer("dest_host_id") + .notNull() + .references(() => hosts.id, { onDelete: "cascade" }), + destPath: text("dest_path").notNull(), + destPathLabel: text("dest_path_label").notNull(), + lastUsed: text("last_used") + .notNull() + .default(sql`CURRENT_TIMESTAMP`), +}); + +export const dismissedAlerts = pgTable("dismissed_alerts", { + id: serial("id").primaryKey(), + userId: varchar("user_id", { length: 255 }) + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + alertId: text("alert_id").notNull(), + dismissedAt: text("dismissed_at") + .notNull() + .default(sql`CURRENT_TIMESTAMP`), +}); + +export const sshCredentials = pgTable("ssh_credentials", { + id: serial("id").primaryKey(), + userId: varchar("user_id", { length: 255 }) + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + name: varchar("name", { length: 255 }).notNull(), + description: text("description"), + folder: text("folder"), + tags: text("tags"), + authType: text("auth_type").notNull(), + username: text("username"), + password: text("password"), + key: text("key"), + privateKey: text("private_key"), + publicKey: text("public_key"), + keyPassword: text("key_password"), + keyType: text("key_type"), + detectedKeyType: text("detected_key_type"), + + certPublicKey: text("cert_public_key"), + + + usageCount: integer("usage_count").notNull().default(0), + lastUsed: text("last_used"), + syncId: varchar("sync_id", { length: 255 }).unique(), + createdAt: text("created_at") + .notNull() + .default(sql`CURRENT_TIMESTAMP`), + updatedAt: text("updated_at") + .notNull() + .default(sql`CURRENT_TIMESTAMP`), +}); + +export const sshCredentialUsage = pgTable("ssh_credential_usage", { + id: serial("id").primaryKey(), + credentialId: integer("credential_id") + .notNull() + .references(() => sshCredentials.id, { onDelete: "cascade" }), + hostId: integer("host_id") + .notNull() + .references(() => hosts.id, { onDelete: "cascade" }), + userId: varchar("user_id", { length: 255 }) + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + usedAt: text("used_at") + .notNull() + .default(sql`CURRENT_TIMESTAMP`), +}); + +export const snippets = pgTable("snippets", { + id: serial("id").primaryKey(), + userId: varchar("user_id", { length: 255 }) + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + name: varchar("name", { length: 255 }).notNull(), + content: text("content").notNull(), + description: text("description"), + folder: text("folder"), + order: integer("order").notNull().default(0), + syncId: varchar("sync_id", { length: 255 }).unique(), + createdAt: text("created_at") + .notNull() + .default(sql`CURRENT_TIMESTAMP`), + updatedAt: text("updated_at") + .notNull() + .default(sql`CURRENT_TIMESTAMP`), + hostFilter: text("host_filter"), +}); + +export const snippetFolders = pgTable("snippet_folders", { + id: serial("id").primaryKey(), + userId: varchar("user_id", { length: 255 }) + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + name: varchar("name", { length: 255 }).notNull(), + color: text("color"), + icon: text("icon"), + syncId: varchar("sync_id", { length: 255 }).unique(), + createdAt: text("created_at") + .notNull() + .default(sql`CURRENT_TIMESTAMP`), + updatedAt: text("updated_at") + .notNull() + .default(sql`CURRENT_TIMESTAMP`), +}); + +export const c2sTunnelPresets = pgTable("c2s_tunnel_presets", { + id: serial("id").primaryKey(), + userId: varchar("user_id", { length: 255 }) + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + name: varchar("name", { length: 255 }).notNull(), + config: text("config").notNull(), + platform: text("platform"), + computerName: text("computer_name"), + createdAt: text("created_at") + .notNull() + .default(sql`CURRENT_TIMESTAMP`), + updatedAt: text("updated_at") + .notNull() + .default(sql`CURRENT_TIMESTAMP`), +}); + +export const snippetAccess = pgTable("snippet_access", { + id: serial("id").primaryKey(), + snippetId: integer("snippet_id") + .notNull() + .references(() => snippets.id, { onDelete: "cascade" }), + + userId: varchar("user_id", { length: 255 }).references(() => users.id, { onDelete: "cascade" }), + roleId: integer("role_id").references(() => roles.id, { + onDelete: "cascade", + }), + + grantedBy: varchar("granted_by", { length: 255 }) + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + + permissionLevel: text("permission_level").notNull().default("view"), + + expiresAt: text("expires_at"), + + createdAt: text("created_at") + .notNull() + .default(sql`CURRENT_TIMESTAMP`), +}); + +export const sshFolders = pgTable("ssh_folders", { + id: serial("id").primaryKey(), + userId: varchar("user_id", { length: 255 }) + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + name: varchar("name", { length: 255 }).notNull(), + color: text("color"), + icon: text("icon"), + credentialId: integer("credential_id").references(() => sshCredentials.id, { + onDelete: "set null", + }), + syncId: varchar("sync_id", { length: 255 }).unique(), + createdAt: text("created_at") + .notNull() + .default(sql`CURRENT_TIMESTAMP`), + updatedAt: text("updated_at") + .notNull() + .default(sql`CURRENT_TIMESTAMP`), +}); + +export const recentActivity = pgTable("recent_activity", { + id: serial("id").primaryKey(), + userId: varchar("user_id", { length: 255 }) + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + type: text("type").notNull(), + hostId: integer("host_id") + .notNull() + .references(() => hosts.id, { onDelete: "cascade" }), + hostName: text("host_name"), + timestamp: text("timestamp") + .notNull() + .default(sql`CURRENT_TIMESTAMP`), +}); + +export const commandHistory = pgTable("command_history", { + id: serial("id").primaryKey(), + userId: varchar("user_id", { length: 255 }) + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + hostId: integer("host_id") + .notNull() + .references(() => hosts.id, { onDelete: "cascade" }), + command: text("command").notNull(), + executedAt: text("executed_at") + .notNull() + .default(sql`CURRENT_TIMESTAMP`), +}); + +export const networkTopology = pgTable("network_topology", { + id: serial("id").primaryKey(), + userId: varchar("user_id", { length: 255 }) + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + topology: text("topology"), + createdAt: text("created_at") + .notNull() + .default(sql`CURRENT_TIMESTAMP`), + updatedAt: text("updated_at") + .notNull() + .default(sql`CURRENT_TIMESTAMP`), +}); + +export const hostAccess = pgTable("host_access", { + id: serial("id").primaryKey(), + hostId: integer("host_id") + .notNull() + .references(() => hosts.id, { onDelete: "cascade" }), + + userId: varchar("user_id", { length: 255 }) + .references(() => users.id, { onDelete: "cascade" }), + roleId: integer("role_id") + .references(() => roles.id, { onDelete: "cascade" }), + + grantedBy: varchar("granted_by", { length: 255 }) + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + + permissionLevel: text("permission_level") + .notNull() + .default("connect"), + + expiresAt: text("expires_at"), + + createdAt: text("created_at") + .notNull() + .default(sql`CURRENT_TIMESTAMP`), + lastAccessedAt: text("last_accessed_at"), + accessCount: integer("access_count").notNull().default(0), +}); + +export const sharedHostAuthOverrides = pgTable( + "shared_host_auth_overrides", + { + id: serial("id").primaryKey(), + hostId: integer("host_id") + .notNull() + .references(() => hosts.id, { onDelete: "cascade" }), + userId: varchar("user_id", { length: 255 }) + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + protocol: varchar("protocol", { length: 255 }).notNull().default("ssh"), + credentialId: integer("credential_id") + .notNull() + .references(() => sshCredentials.id, { onDelete: "cascade" }), + createdAt: text("created_at") + .notNull() + .default(sql`CURRENT_TIMESTAMP`), + updatedAt: text("updated_at") + .notNull() + .default(sql`CURRENT_TIMESTAMP`), + }, + (table) => [ + uniqueIndex("shared_host_auth_overrides_host_user_protocol_unique").on( + table.hostId, + table.userId, + table.protocol, + ), + ], +); + +export const sharedHostSecrets = pgTable( + "shared_host_secrets", + { + id: serial("id").primaryKey(), + + hostAccessId: integer("host_access_id") + .notNull() + .references(() => hostAccess.id, { onDelete: "cascade" }), + + targetUserId: varchar("target_user_id", { length: 255 }) + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + + protocol: varchar("protocol", { length: 255 }).notNull().default("ssh"), + sourceType: text("source_type").notNull().default("credential"), + + originalCredentialId: integer("original_credential_id").references( + () => sshCredentials.id, + { onDelete: "cascade" }, + ), + + encryptedUsername: text("encrypted_username"), + encryptedAuthType: text("encrypted_auth_type"), + encryptedPassword: text("encrypted_password"), + encryptedKey: text("encrypted_key"), + encryptedKeyPassword: text("encrypted_key_password"), + encryptedKeyType: text("encrypted_key_type"), + encryptedDomain: text("encrypted_domain"), + + createdAt: text("created_at") + .notNull() + .default(sql`CURRENT_TIMESTAMP`), + updatedAt: text("updated_at") + .notNull() + .default(sql`CURRENT_TIMESTAMP`), + }, + // Declared inline in the production DDL as UNIQUE(...), but never here, + // so the generated Postgres and MySQL schemas allowed duplicates the + // SQLite deployment forbids — and the upsert had nothing to conflict on. + (table) => [ + uniqueIndex("idx_shared_host_secrets_scope").on( + table.hostAccessId, + table.targetUserId, + table.protocol, + ), + ], +); + +export const roles = pgTable("roles", { + id: serial("id").primaryKey(), + name: varchar("name", { length: 255 }).notNull().unique(), + displayName: text("display_name").notNull(), + description: text("description"), + + isSystem: boolean("is_system") + .notNull() + .default(false), + + permissions: text("permissions"), + + createdAt: text("created_at") + .notNull() + .default(sql`CURRENT_TIMESTAMP`), + updatedAt: text("updated_at") + .notNull() + .default(sql`CURRENT_TIMESTAMP`), +}); + +export const userRoles = pgTable( + "user_roles", + { + id: serial("id").primaryKey(), + userId: varchar("user_id", { length: 255 }) + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + roleId: integer("role_id") + .notNull() + .references(() => roles.id, { onDelete: "cascade" }), + + grantedBy: varchar("granted_by", { length: 255 }).references(() => users.id, { + onDelete: "set null", + }), + grantedAt: text("granted_at") + .notNull() + .default(sql`CURRENT_TIMESTAMP`), + }, + // Declared inline in the production DDL as UNIQUE(...), but never here, + // so the generated Postgres and MySQL schemas allowed duplicates the + // SQLite deployment forbids — and the upsert had nothing to conflict on. + (table) => [uniqueIndex("idx_user_roles_user_role").on(table.userId, table.roleId)], +); + +export const auditLogs = pgTable("audit_logs", { + id: serial("id").primaryKey(), + + // Nullable on purpose: the trail outlives the account, and username keeps the + // entry attributable once the reference is gone. + userId: varchar("user_id", { length: 255 }).references(() => users.id, { onDelete: "set null" }), + username: text("username").notNull(), + + action: text("action").notNull(), + resourceType: text("resource_type").notNull(), + resourceId: text("resource_id"), + resourceName: text("resource_name"), + + details: text("details"), + ipAddress: text("ip_address"), + userAgent: text("user_agent"), + + success: boolean("success").notNull(), + errorMessage: text("error_message"), + + timestamp: text("timestamp") + .notNull() + .default(sql`CURRENT_TIMESTAMP`), +}); + +export const sessionRecordings = pgTable("session_recordings", { + id: serial("id").primaryKey(), + + hostId: integer("host_id") + .notNull() + .references(() => hosts.id, { onDelete: "cascade" }), + // Nullable on purpose: a recording is evidence about the host as much as the + // person, so it outlives the account. username keeps it attributable. + userId: varchar("user_id", { length: 255 }).references(() => users.id, { onDelete: "set null" }), + username: text("username"), + accessId: integer("access_id").references(() => hostAccess.id, { + onDelete: "set null", + }), + + startedAt: text("started_at") + .notNull() + .default(sql`CURRENT_TIMESTAMP`), + endedAt: text("ended_at"), + duration: integer("duration"), + + commands: text("commands"), + dangerousActions: text("dangerous_actions"), + + recordingPath: text("recording_path"), + protocol: varchar("protocol", { length: 255 }).notNull().default("ssh"), + format: text("format").notNull().default("text"), + + terminatedByOwner: boolean("terminated_by_owner") + .default(false), + terminationReason: text("termination_reason"), +}); + +export const sessionShares = pgTable("session_shares", { + id: varchar("id", { length: 255 }).primaryKey(), + + hostId: integer("host_id") + .notNull() + .references(() => hosts.id, { onDelete: "cascade" }), + ownerUserId: varchar("owner_user_id", { length: 255 }) + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + + protocol: varchar("protocol", { length: 255 }).notNull(), + + // Live-session binding: TerminalSessionManager's session.id for SSH, or + // guacd's own guacamoleConnectionId for rdp/vnc/telnet. Neither is a DB + // row (process-local, in-memory) so this intentionally has no FK. + sessionId: text("session_id").notNull(), + tabInstanceId: text("tab_instance_id"), + + shareType: text("share_type").notNull(), // "link" | "user" + targetUserId: varchar("target_user_id", { length: 255 }).references(() => users.id, { + onDelete: "cascade", + }), + linkToken: varchar("link_token", { length: 255 }).unique(), + + permissionLevel: text("permission_level").notNull().default("read-only"), + + createdAt: text("created_at") + .notNull() + .default(sql`CURRENT_TIMESTAMP`), + expiresAt: text("expires_at").notNull(), + revokedAt: text("revoked_at"), + + lastJoinedAt: text("last_joined_at"), + joinCount: integer("join_count").notNull().default(0), +}); + +export const sessionShareParticipants = pgTable( + "session_share_participants", + { + id: serial("id").primaryKey(), + shareId: varchar("share_id", { length: 255 }) + .notNull() + .references(() => sessionShares.id, { onDelete: "cascade" }), + + userId: varchar("user_id", { length: 255 }).references(() => users.id, { + onDelete: "cascade", + }), + guestLabel: text("guest_label"), + + joinedAt: text("joined_at") + .notNull() + .default(sql`CURRENT_TIMESTAMP`), + leftAt: text("left_at"), + }, +); + +export const opksshTokens = pgTable( + "opkssh_tokens", + { + id: serial("id").primaryKey(), + userId: varchar("user_id", { length: 255 }) + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + hostId: integer("host_id") + .notNull() + .references(() => hosts.id, { onDelete: "cascade" }), + + sshCert: text("ssh_cert").notNull(), + privateKey: text("private_key").notNull(), + + email: text("email"), + sub: text("sub"), + issuer: text("issuer"), + audience: text("audience"), + + createdAt: text("created_at") + .notNull() + .default(sql`CURRENT_TIMESTAMP`), + expiresAt: text("expires_at").notNull(), + lastUsed: text("last_used"), + }, + // Declared inline in the production DDL as UNIQUE(...), but never here, + // so the generated Postgres and MySQL schemas allowed duplicates the + // SQLite deployment forbids — and the upsert had nothing to conflict on. + (table) => [uniqueIndex("idx_opkssh_tokens_user_host").on(table.userId, table.hostId)], +); + +// Vault SSH signer profiles. These hold ONLY non-secret connection settings and +// are intended to be shared across users (shared === true makes a profile +// visible to every user on the server). Each user authenticates to Vault via an +// interactive OIDC flow at connect time; no tokens or keys are stored here. +export const vaultProfiles = pgTable("vault_profiles", { + id: serial("id").primaryKey(), + userId: varchar("user_id", { length: 255 }) + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + name: varchar("name", { length: 255 }).notNull(), + description: text("description"), + folder: text("folder"), + tags: text("tags"), + // Vault server connection (non-secret) + vaultAddr: text("vault_addr").notNull(), + vaultNamespace: text("vault_namespace"), + // OIDC auth method mount + role used to obtain a Vault token interactively + oidcMount: text("oidc_mount"), + oidcRole: text("oidc_role"), + // SSH secrets engine mount + signer role used to sign the ephemeral key + sshMount: text("ssh_mount"), + sshRole: text("ssh_role").notNull(), + validPrincipals: text("valid_principals"), + // Ephemeral keypair algorithm to generate per connection + keyType: text("key_type"), + // When true the profile is visible/usable by all users on the server + shared: boolean("shared").notNull().default(false), + syncId: varchar("sync_id", { length: 255 }).unique(), + createdAt: text("created_at") + .notNull() + .default(sql`CURRENT_TIMESTAMP`), + updatedAt: text("updated_at") + .notNull() + .default(sql`CURRENT_TIMESTAMP`), +}); + +// Per-user cache of the ephemeral SSH private key + Vault-signed certificate. +// Transient: rows live only until the certificate expires. Secret fields are +// encrypted under the user's data-encryption key (see field-crypto.ts). +export const vaultTokens = pgTable( + "vault_tokens", + { + id: serial("id").primaryKey(), + userId: varchar("user_id", { length: 255 }) + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + profileId: integer("profile_id") + .notNull() + .references(() => vaultProfiles.id, { onDelete: "cascade" }), + + sshCert: text("ssh_cert").notNull(), + privateKey: text("private_key").notNull(), + + createdAt: text("created_at") + .notNull() + .default(sql`CURRENT_TIMESTAMP`), + expiresAt: text("expires_at").notNull(), + lastUsed: text("last_used"), + }, + // Declared inline in the production DDL as UNIQUE(...), but never here, + // so the generated Postgres and MySQL schemas allowed duplicates the + // SQLite deployment forbids — and the upsert had nothing to conflict on. + (table) => [uniqueIndex("idx_vault_tokens_user_profile").on(table.userId, table.profileId)], +); + +export const apiKeys = pgTable("api_keys", { + id: varchar("id", { length: 255 }).primaryKey(), + userId: varchar("user_id", { length: 255 }) + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + name: varchar("name", { length: 255 }).notNull(), + tokenHash: text("token_hash").notNull(), + tokenPrefix: text("token_prefix").notNull(), + createdAt: text("created_at").notNull().default(sql`CURRENT_TIMESTAMP`), + expiresAt: text("expires_at"), + lastUsedAt: text("last_used_at"), + isActive: boolean("is_active").notNull().default(true), +}); + +export const userOpenTabs = pgTable("user_open_tabs", { + id: varchar("id", { length: 255 }).primaryKey(), + userId: varchar("user_id", { length: 255 }) + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + tabType: text("tab_type").notNull(), + hostId: integer("host_id").references(() => hosts.id, { onDelete: "cascade" }), + label: text("label").notNull(), + tabOrder: integer("tab_order").notNull().default(0), + backendSessionId: text("backend_session_id"), + createdAt: text("created_at") + .notNull() + .default(sql`CURRENT_TIMESTAMP`), + updatedAt: text("updated_at") + .notNull() + .default(sql`CURRENT_TIMESTAMP`), +}); + +export const userPreferences = pgTable("user_preferences", { + userId: varchar("user_id", { length: 255 }) + .primaryKey() + .references(() => users.id, { onDelete: "cascade" }), + reopenTabsOnLogin: boolean("reopen_tabs_on_login") + .notNull() + .default(false), + theme: text("theme"), + fontSize: text("font_size"), + accentColor: text("accent_color"), + language: text("language"), + storageMode: text("storage_mode"), + commandAutocomplete: boolean("command_autocomplete"), + commandPaletteEnabled: boolean("command_palette_enabled"), + showHostTags: boolean("show_host_tags"), + hostTrayOnClick: boolean("host_tray_on_click"), + pinAppRail: boolean("pin_app_rail"), + expandAppRailOnHover: boolean("expand_app_rail_on_hover"), + foldersCollapsed: boolean("folders_collapsed"), + confirmSnippetExecution: boolean("confirm_snippet_execution"), + disableUpdateCheck: boolean("disable_update_check"), + confirmTabClose: boolean("confirm_tab_close"), + hiddenRailTabs: text("hidden_rail_tabs"), + compactHostView: boolean("compact_host_view"), + statusColorScheme: text("status_color_scheme"), + customThemes: text("custom_themes"), + customKeybindings: text("custom_keybindings"), + updatedAt: text("updated_at") + .notNull() + .default(sql`CURRENT_TIMESTAMP`), +}); + +export const hostMetricsPreferences = pgTable( + "host_metrics_preferences", + { + id: serial("id").primaryKey(), + userId: varchar("user_id", { length: 255 }) + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + hostId: integer("host_id") + .notNull() + .references(() => hosts.id, { onDelete: "cascade" }), + // JSON-encoded HostMetricsLayout. Layout has no secrets, so it is stored as + // plain JSON (no field-level encryption). + layout: text("layout").notNull(), + createdAt: text("created_at") + .notNull() + .default(sql`CURRENT_TIMESTAMP`), + updatedAt: text("updated_at") + .notNull() + .default(sql`CURRENT_TIMESTAMP`), + }, + // One layout per user per host. Enforced in production since the inline DDL + // creates it, but it was never declared here, so the generated Postgres and + // MySQL schemas lacked it — and the upsert has nothing to conflict on. + (table) => [ + uniqueIndex("idx_host_metrics_prefs_user_host").on(table.userId, table.hostId), + ], +); + +export const hostHealthChecks = pgTable( + "host_health_checks", + { + id: serial("id").primaryKey(), + userId: varchar("user_id", { length: 255 }) + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + hostId: integer("host_id") + .notNull() + .references(() => hosts.id, { onDelete: "cascade" }), + // JSON array of { id, name, type: "tcp"|"http", target, port, path } + checks: text("checks").notNull(), + intervalSeconds: integer("interval_seconds").notNull().default(300), + createdAt: text("created_at") + .notNull() + .default(sql`CURRENT_TIMESTAMP`), + updatedAt: text("updated_at") + .notNull() + .default(sql`CURRENT_TIMESTAMP`), + }, + // Same as above: one set of checks per user per host. + (table) => [ + uniqueIndex("idx_host_health_checks_user_host").on(table.userId, table.hostId), + ], +); + +export const hostHealthHistory = pgTable("host_health_history", { + id: serial("id").primaryKey(), + userId: varchar("user_id", { length: 255 }) + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + hostId: integer("host_id") + .notNull() + .references(() => hosts.id, { onDelete: "cascade" }), + checkId: text("check_id").notNull(), + ts: text("ts").notNull().default(sql`CURRENT_TIMESTAMP`), + ok: boolean("ok").notNull(), + latencyMs: integer("latency_ms"), + detail: text("detail"), +}); + +export const dashboardServiceLinks = pgTable("dashboard_service_links", { + id: serial("id").primaryKey(), + userId: varchar("user_id", { length: 255 }) + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + label: text("label").notNull(), + url: text("url").notNull(), + order: integer("order").notNull().default(0), + syncId: varchar("sync_id", { length: 255 }).unique(), + createdAt: text("created_at") + .notNull() + .default(sql`CURRENT_TIMESTAMP`), + updatedAt: text("updated_at") + .notNull() + .default(sql`CURRENT_TIMESTAMP`), +}); + +// --- termix-id begin --- +// A user claims a unique public handle. Their published SSH public keys are +// served at an unauthenticated resolver endpoint in authorized_keys format, +// so any server can be provisioned with `curl /termix-id/u/ >> ~/.ssh/authorized_keys`. +export const termixIdentities = pgTable("termix_identities", { + id: serial("id").primaryKey(), + // One Termix ID per user — enforced in schema, not just in code. + userId: varchar("user_id", { length: 255 }) + .notNull() + .unique() + .references(() => users.id, { onDelete: "cascade" }), + handle: varchar("handle", { length: 255 }).notNull().unique(), + description: text("description"), + createdAt: text("created_at") + .notNull() + .default(sql`CURRENT_TIMESTAMP`), + updatedAt: text("updated_at") + .notNull() + .default(sql`CURRENT_TIMESTAMP`), +}); + +export const termixIdentityKeys = pgTable("termix_identity_keys", { + id: serial("id").primaryKey(), + identityId: integer("identity_id") + .notNull() + .references(() => termixIdentities.id, { onDelete: "cascade" }), + userId: varchar("user_id", { length: 255 }) + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + // Public keys are non-secret, so they are stored in plaintext (no field-level + // encryption). This is what lets the unauthenticated resolver serve them. + publicKey: text("public_key").notNull(), + // Raw algorithm token (e.g. "ssh-ed25519"), and a normalized group used for + // the / resolver filter (RSA / ED25519 / ECDSA / ...). + keyType: text("key_type").notNull(), + algorithm: text("algorithm").notNull(), + label: text("label"), + comment: text("comment"), + // "manual" (pasted) or "credential" (imported from an ssh_credentials entry). + source: text("source").notNull().default("manual"), + credentialId: integer("credential_id").references(() => sshCredentials.id, { + onDelete: "set null", + }), + enabled: boolean("enabled").notNull().default(true), + createdAt: text("created_at") + .notNull() + .default(sql`CURRENT_TIMESTAMP`), +}); +// Per-identity certificate authority. Servers that trust this CA (via +// TrustedUserCAKeys / @cert-authority) accept any user certificate it signs, +// giving central revocation (rotate the CA) and expiry (cert validity). +export const termixIdentityCa = pgTable("termix_identity_ca", { + id: serial("id").primaryKey(), + identityId: integer("identity_id") + .notNull() + .unique() + .references(() => termixIdentities.id, { onDelete: "cascade" }), + userId: varchar("user_id", { length: 255 }) + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + // CA public key (plaintext — it is published); CA private key is field-encrypted. + publicKey: text("public_key").notNull(), + privateKey: text("private_key").notNull(), + validityDays: integer("validity_days").notNull().default(90), + createdAt: text("created_at") + .notNull() + .default(sql`CURRENT_TIMESTAMP`), + updatedAt: text("updated_at") + .notNull() + .default(sql`CURRENT_TIMESTAMP`), +}); +// --- termix-id end --- + +// --- tmux-monitor begin --- +export const tmuxSessionTags = pgTable("tmux_session_tags", { + id: serial("id").primaryKey(), + userId: varchar("user_id", { length: 255 }) + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + hostId: integer("host_id") + .notNull() + .references(() => hosts.id, { onDelete: "cascade" }), + sessionName: text("session_name").notNull(), + tag: text("tag").notNull(), + createdAt: text("created_at") + .notNull() + .default(sql`CURRENT_TIMESTAMP`), +}); +// --- tmux-monitor end --- + +// --- metrics-history begin --- +export const hostMetricsHistory = pgTable("host_metrics_history", { + id: serial("id").primaryKey(), + hostId: integer("host_id") + .notNull() + .references(() => hosts.id, { onDelete: "cascade" }), + ts: text("ts") + .notNull() + .default(sql`CURRENT_TIMESTAMP`), + cpuPercent: doublePrecision("cpu_percent"), + memPercent: doublePrecision("mem_percent"), + diskPercent: doublePrecision("disk_percent"), + netRxBytes: integer("net_rx_bytes"), + netTxBytes: integer("net_tx_bytes"), +}); +// --- metrics-history end --- + +// --- alerts begin --- +export const alertRules = pgTable("alert_rules", { + id: serial("id").primaryKey(), + userId: varchar("user_id", { length: 255 }) + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + hostId: integer("host_id").references(() => hosts.id, { onDelete: "cascade" }), + name: varchar("name", { length: 255 }).notNull(), + enabled: boolean("enabled").notNull().default(true), + triggerType: text("trigger_type").notNull(), + thresholdValue: doublePrecision("threshold_value"), + thresholdDurationSeconds: integer("threshold_duration_seconds"), + cooldownMinutes: integer("cooldown_minutes").notNull().default(15), + createdAt: text("created_at") + .notNull() + .default(sql`CURRENT_TIMESTAMP`), + updatedAt: text("updated_at") + .notNull() + .default(sql`CURRENT_TIMESTAMP`), +}); + +export const notificationChannels = pgTable("notification_channels", { + id: serial("id").primaryKey(), + userId: varchar("user_id", { length: 255 }) + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + name: varchar("name", { length: 255 }).notNull(), + type: text("type").notNull(), + config: text("config").notNull(), + enabled: boolean("enabled").notNull().default(true), + createdAt: text("created_at") + .notNull() + .default(sql`CURRENT_TIMESTAMP`), +}); + +export const alertRuleChannels = pgTable("alert_rule_channels", { + id: serial("id").primaryKey(), + ruleId: integer("rule_id") + .notNull() + .references(() => alertRules.id, { onDelete: "cascade" }), + channelId: integer("channel_id") + .notNull() + .references(() => notificationChannels.id, { onDelete: "cascade" }), +}); + +export const alertFirings = pgTable("alert_firings", { + id: serial("id").primaryKey(), + userId: varchar("user_id", { length: 255 }) + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + ruleId: integer("rule_id") + .notNull() + .references(() => alertRules.id, { onDelete: "cascade" }), + hostId: integer("host_id").notNull(), + hostName: text("host_name").notNull(), + firedAt: text("fired_at") + .notNull() + .default(sql`CURRENT_TIMESTAMP`), + resolvedAt: text("resolved_at"), + value: doublePrecision("value"), + message: text("message").notNull(), + severity: text("severity").notNull().default("warning"), + acknowledged: boolean("acknowledged").notNull().default(false), +}); +// --- alerts end --- + +// --- homepage begin --- +export const homepageItems = pgTable("homepage_items", { + id: serial("id").primaryKey(), + userId: varchar("user_id", { length: 255 }) + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + typeId: text("type_id").notNull(), + title: text("title"), + config: text("config").notNull().default("{}"), + folderId: integer("folder_id"), + syncId: varchar("sync_id", { length: 255 }).unique(), + createdAt: text("created_at") + .notNull() + .default(sql`CURRENT_TIMESTAMP`), + updatedAt: text("updated_at") + .notNull() + .default(sql`CURRENT_TIMESTAMP`), +}); + +export const homepageLayouts = pgTable("homepage_layouts", { + id: serial("id").primaryKey(), + userId: varchar("user_id", { length: 255 }) + .notNull() + .unique() + .references(() => users.id, { onDelete: "cascade" }), + // JSON: { entries: HomepageLayoutEntry[], pan: {x,y}, zoom: number } + layout: text("layout").notNull().default("{}"), + updatedAt: text("updated_at") + .notNull() + .default(sql`CURRENT_TIMESTAMP`), +}); +// --- homepage end --- + +// --- sync begin --- +// Records a delete for a synced entity type so the other side of a sync +// pair (embedded desktop backend <-> connected remote server) learns about +// the deletion instead of re-creating the row on its next pull. +export const syncTombstones = pgTable("sync_tombstones", { + id: serial("id").primaryKey(), + userId: varchar("user_id", { length: 255 }) + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + entityType: text("entity_type").notNull(), + syncId: varchar("sync_id", { length: 255 }).notNull(), + deletedAt: text("deleted_at") + .notNull() + .default(sql`CURRENT_TIMESTAMP`), +}); +// --- sync end --- diff --git a/src/backend/database/db/schema.ts b/src/backend/database/db/schema.ts index 423154b4..645f796a 100644 --- a/src/backend/database/db/schema.ts +++ b/src/backend/database/db/schema.ts @@ -1,4 +1,10 @@ -import { sqliteTable, text, integer, real } from "drizzle-orm/sqlite-core"; +import { + sqliteTable, + text, + integer, + real, + uniqueIndex, +} from "drizzle-orm/sqlite-core"; import { sql } from "drizzle-orm"; export const users = sqliteTable("users", { @@ -124,6 +130,9 @@ export const hosts = sqliteTable("ssh_data", { pin: integer("pin", { mode: "boolean" }).notNull().default(false), authType: text("auth_type").notNull(), useWarpgate: integer("use_warpgate", { mode: "boolean" }).notNull().default(false), + shareSshAuth: integer("share_ssh_auth", { mode: "boolean" }) + .notNull() + .default(false), forceKeyboardInteractive: text("force_keyboard_interactive"), password: text("password"), @@ -560,46 +569,85 @@ export const hostAccess = sqliteTable("host_access", { .default(sql`CURRENT_TIMESTAMP`), lastAccessedAt: text("last_accessed_at"), accessCount: integer("access_count").notNull().default(0), - overrideCredentialId: integer("override_credential_id").references( - () => sshCredentials.id, - { onDelete: "set null" }, - ), }); -export const sharedHostSecrets = sqliteTable("shared_host_secrets", { - id: integer("id").primaryKey({ autoIncrement: true }), +export const sharedHostAuthOverrides = sqliteTable( + "shared_host_auth_overrides", + { + id: integer("id").primaryKey({ autoIncrement: true }), + hostId: integer("host_id") + .notNull() + .references(() => hosts.id, { onDelete: "cascade" }), + userId: text("user_id") + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + protocol: text("protocol").notNull().default("ssh"), + credentialId: integer("credential_id") + .notNull() + .references(() => sshCredentials.id, { onDelete: "cascade" }), + createdAt: text("created_at") + .notNull() + .default(sql`CURRENT_TIMESTAMP`), + updatedAt: text("updated_at") + .notNull() + .default(sql`CURRENT_TIMESTAMP`), + }, + (table) => [ + uniqueIndex("shared_host_auth_overrides_host_user_protocol_unique").on( + table.hostId, + table.userId, + table.protocol, + ), + ], +); - hostAccessId: integer("host_access_id") - .notNull() - .references(() => hostAccess.id, { onDelete: "cascade" }), +export const sharedHostSecrets = sqliteTable( + "shared_host_secrets", + { + id: integer("id").primaryKey({ autoIncrement: true }), - targetUserId: text("target_user_id") - .notNull() - .references(() => users.id, { onDelete: "cascade" }), + hostAccessId: integer("host_access_id") + .notNull() + .references(() => hostAccess.id, { onDelete: "cascade" }), - protocol: text("protocol").notNull().default("ssh"), - sourceType: text("source_type").notNull().default("credential"), + targetUserId: text("target_user_id") + .notNull() + .references(() => users.id, { onDelete: "cascade" }), - originalCredentialId: integer("original_credential_id").references( - () => sshCredentials.id, - { onDelete: "cascade" }, - ), + protocol: text("protocol").notNull().default("ssh"), + sourceType: text("source_type").notNull().default("credential"), - encryptedUsername: text("encrypted_username"), - encryptedAuthType: text("encrypted_auth_type"), - encryptedPassword: text("encrypted_password"), - encryptedKey: text("encrypted_key", { length: 16384 }), - encryptedKeyPassword: text("encrypted_key_password"), - encryptedKeyType: text("encrypted_key_type"), - encryptedDomain: text("encrypted_domain"), + originalCredentialId: integer("original_credential_id").references( + () => sshCredentials.id, + { onDelete: "cascade" }, + ), - createdAt: text("created_at") - .notNull() - .default(sql`CURRENT_TIMESTAMP`), - updatedAt: text("updated_at") - .notNull() - .default(sql`CURRENT_TIMESTAMP`), -}); + encryptedUsername: text("encrypted_username"), + encryptedAuthType: text("encrypted_auth_type"), + encryptedPassword: text("encrypted_password"), + encryptedKey: text("encrypted_key", { length: 16384 }), + encryptedKeyPassword: text("encrypted_key_password"), + encryptedKeyType: text("encrypted_key_type"), + encryptedDomain: text("encrypted_domain"), + + createdAt: text("created_at") + .notNull() + .default(sql`CURRENT_TIMESTAMP`), + updatedAt: text("updated_at") + .notNull() + .default(sql`CURRENT_TIMESTAMP`), + }, + // Declared inline in the production DDL as UNIQUE(...), but never here, + // so the generated Postgres and MySQL schemas allowed duplicates the + // SQLite deployment forbids — and the upsert had nothing to conflict on. + (table) => [ + uniqueIndex("idx_shared_host_secrets_scope").on( + table.hostAccessId, + table.targetUserId, + table.protocol, + ), + ], +); export const roles = sqliteTable("roles", { id: integer("id").primaryKey({ autoIncrement: true }), @@ -621,29 +669,36 @@ export const roles = sqliteTable("roles", { .default(sql`CURRENT_TIMESTAMP`), }); -export const userRoles = sqliteTable("user_roles", { - id: integer("id").primaryKey({ autoIncrement: true }), - userId: text("user_id") - .notNull() - .references(() => users.id, { onDelete: "cascade" }), - roleId: integer("role_id") - .notNull() - .references(() => roles.id, { onDelete: "cascade" }), - - grantedBy: text("granted_by").references(() => users.id, { - onDelete: "set null", - }), - grantedAt: text("granted_at") - .notNull() - .default(sql`CURRENT_TIMESTAMP`), -}); +export const userRoles = sqliteTable( + "user_roles", + { + id: integer("id").primaryKey({ autoIncrement: true }), + userId: text("user_id") + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + roleId: integer("role_id") + .notNull() + .references(() => roles.id, { onDelete: "cascade" }), + + grantedBy: text("granted_by").references(() => users.id, { + onDelete: "set null", + }), + grantedAt: text("granted_at") + .notNull() + .default(sql`CURRENT_TIMESTAMP`), + }, + // Declared inline in the production DDL as UNIQUE(...), but never here, + // so the generated Postgres and MySQL schemas allowed duplicates the + // SQLite deployment forbids — and the upsert had nothing to conflict on. + (table) => [uniqueIndex("idx_user_roles_user_role").on(table.userId, table.roleId)], +); export const auditLogs = sqliteTable("audit_logs", { id: integer("id").primaryKey({ autoIncrement: true }), - userId: text("user_id") - .notNull() - .references(() => users.id, { onDelete: "cascade" }), + // Nullable on purpose: the trail outlives the account, and username keeps the + // entry attributable once the reference is gone. + userId: text("user_id").references(() => users.id, { onDelete: "set null" }), username: text("username").notNull(), action: text("action").notNull(), @@ -669,9 +724,10 @@ export const sessionRecordings = sqliteTable("session_recordings", { hostId: integer("host_id") .notNull() .references(() => hosts.id, { onDelete: "cascade" }), - userId: text("user_id") - .notNull() - .references(() => users.id, { onDelete: "cascade" }), + // Nullable on purpose: a recording is evidence about the host as much as the + // person, so it outlives the account. username keeps it attributable. + userId: text("user_id").references(() => users.id, { onDelete: "set null" }), + username: text("username"), accessId: integer("access_id").references(() => hostAccess.id, { onDelete: "set null", }), @@ -750,29 +806,36 @@ export const sessionShareParticipants = sqliteTable( }, ); -export const opksshTokens = sqliteTable("opkssh_tokens", { - id: integer("id").primaryKey({ autoIncrement: true }), - userId: text("user_id") - .notNull() - .references(() => users.id, { onDelete: "cascade" }), - hostId: integer("host_id") - .notNull() - .references(() => hosts.id, { onDelete: "cascade" }), - - sshCert: text("ssh_cert", { length: 8192 }).notNull(), - privateKey: text("private_key", { length: 8192 }).notNull(), - - email: text("email"), - sub: text("sub"), - issuer: text("issuer"), - audience: text("audience"), - - createdAt: text("created_at") - .notNull() - .default(sql`CURRENT_TIMESTAMP`), - expiresAt: text("expires_at").notNull(), - lastUsed: text("last_used"), -}); +export const opksshTokens = sqliteTable( + "opkssh_tokens", + { + id: integer("id").primaryKey({ autoIncrement: true }), + userId: text("user_id") + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + hostId: integer("host_id") + .notNull() + .references(() => hosts.id, { onDelete: "cascade" }), + + sshCert: text("ssh_cert", { length: 8192 }).notNull(), + privateKey: text("private_key", { length: 8192 }).notNull(), + + email: text("email"), + sub: text("sub"), + issuer: text("issuer"), + audience: text("audience"), + + createdAt: text("created_at") + .notNull() + .default(sql`CURRENT_TIMESTAMP`), + expiresAt: text("expires_at").notNull(), + lastUsed: text("last_used"), + }, + // Declared inline in the production DDL as UNIQUE(...), but never here, + // so the generated Postgres and MySQL schemas allowed duplicates the + // SQLite deployment forbids — and the upsert had nothing to conflict on. + (table) => [uniqueIndex("idx_opkssh_tokens_user_host").on(table.userId, table.hostId)], +); // Vault SSH signer profiles. These hold ONLY non-secret connection settings and // are intended to be shared across users (shared === true makes a profile @@ -813,24 +876,31 @@ export const vaultProfiles = sqliteTable("vault_profiles", { // Per-user cache of the ephemeral SSH private key + Vault-signed certificate. // Transient: rows live only until the certificate expires. Secret fields are // encrypted under the user's data-encryption key (see field-crypto.ts). -export const vaultTokens = sqliteTable("vault_tokens", { - id: integer("id").primaryKey({ autoIncrement: true }), - userId: text("user_id") - .notNull() - .references(() => users.id, { onDelete: "cascade" }), - profileId: integer("profile_id") - .notNull() - .references(() => vaultProfiles.id, { onDelete: "cascade" }), - - sshCert: text("ssh_cert", { length: 8192 }).notNull(), - privateKey: text("private_key", { length: 8192 }).notNull(), - - createdAt: text("created_at") - .notNull() - .default(sql`CURRENT_TIMESTAMP`), - expiresAt: text("expires_at").notNull(), - lastUsed: text("last_used"), -}); +export const vaultTokens = sqliteTable( + "vault_tokens", + { + id: integer("id").primaryKey({ autoIncrement: true }), + userId: text("user_id") + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + profileId: integer("profile_id") + .notNull() + .references(() => vaultProfiles.id, { onDelete: "cascade" }), + + sshCert: text("ssh_cert", { length: 8192 }).notNull(), + privateKey: text("private_key", { length: 8192 }).notNull(), + + createdAt: text("created_at") + .notNull() + .default(sql`CURRENT_TIMESTAMP`), + expiresAt: text("expires_at").notNull(), + lastUsed: text("last_used"), + }, + // Declared inline in the production DDL as UNIQUE(...), but never here, + // so the generated Postgres and MySQL schemas allowed duplicates the + // SQLite deployment forbids — and the upsert had nothing to conflict on. + (table) => [uniqueIndex("idx_vault_tokens_user_profile").on(table.userId, table.profileId)], +); export const apiKeys = sqliteTable("api_keys", { id: text("id").primaryKey(), @@ -898,7 +968,9 @@ export const userPreferences = sqliteTable("user_preferences", { .default(sql`CURRENT_TIMESTAMP`), }); -export const hostMetricsPreferences = sqliteTable("host_metrics_preferences", { +export const hostMetricsPreferences = sqliteTable( + "host_metrics_preferences", + { id: integer("id").primaryKey({ autoIncrement: true }), userId: text("user_id") .notNull() @@ -915,9 +987,18 @@ export const hostMetricsPreferences = sqliteTable("host_metrics_preferences", { updatedAt: text("updated_at") .notNull() .default(sql`CURRENT_TIMESTAMP`), -}); + }, + // One layout per user per host. Enforced in production since the inline DDL + // creates it, but it was never declared here, so the generated Postgres and + // MySQL schemas lacked it — and the upsert has nothing to conflict on. + (table) => [ + uniqueIndex("idx_host_metrics_prefs_user_host").on(table.userId, table.hostId), + ], +); -export const hostHealthChecks = sqliteTable("host_health_checks", { +export const hostHealthChecks = sqliteTable( + "host_health_checks", + { id: integer("id").primaryKey({ autoIncrement: true }), userId: text("user_id") .notNull() @@ -934,7 +1015,12 @@ export const hostHealthChecks = sqliteTable("host_health_checks", { updatedAt: text("updated_at") .notNull() .default(sql`CURRENT_TIMESTAMP`), -}); + }, + // Same as above: one set of checks per user per host. + (table) => [ + uniqueIndex("idx_host_health_checks_user_host").on(table.userId, table.hostId), + ], +); export const hostHealthHistory = sqliteTable("host_health_history", { id: integer("id").primaryKey({ autoIncrement: true }), diff --git a/src/backend/database/repositories/alert-repository.ts b/src/backend/database/repositories/alert-repository.ts index 9b55ca36..1c3edd31 100644 --- a/src/backend/database/repositories/alert-repository.ts +++ b/src/backend/database/repositories/alert-repository.ts @@ -1,4 +1,4 @@ -import { and, count, desc, eq, inArray, isNull, or } from "drizzle-orm"; +import { and, count, desc, eq, inArray, isNull, lt, or } from "drizzle-orm"; import { alertFirings, alertRuleChannels, @@ -7,6 +7,9 @@ import { notificationChannels, } from "../db/schema.js"; import type { DatabaseContext } from "./database-context.js"; +import { sqlTimestampDaysAgo } from "./sql-timestamp.js"; +import { rowsAffected } from "./mutation-result.js"; +import { insertReturning, updateReturning } from "./returning.js"; type AlertRuleRecord = typeof alertRules.$inferSelect; type NotificationChannelRecord = typeof notificationChannels.$inferSelect; @@ -117,16 +120,17 @@ export class AlertRepository { config: string; enabled: boolean; }): Promise { - const [created] = await this.context.drizzle - .insert(notificationChannels) - .values({ + const [created] = await insertReturning( + this.context, + notificationChannels, + { userId: input.userId, name: input.name, type: input.type, config: input.config, enabled: input.enabled, - }) - .returning(); + }, + ); await this.afterWrite(); return mapChannelRow(created); @@ -146,16 +150,15 @@ export class AlertRepository { return this.findNotificationChannelForUser(id, userId); } - const [updated] = await this.context.drizzle - .update(notificationChannels) - .set(input) - .where( - and( - eq(notificationChannels.id, id), - eq(notificationChannels.userId, userId), - ), - ) - .returning(); + const [updated] = await updateReturning( + this.context, + notificationChannels, + input, + and( + eq(notificationChannels.id, id), + eq(notificationChannels.userId, userId), + ), + ); if (!updated) return null; await this.afterWrite(); @@ -166,17 +169,16 @@ export class AlertRepository { id: number, userId: string, ): Promise { - const deleted = await this.context.drizzle + const result = await this.context.drizzle .delete(notificationChannels) .where( and( eq(notificationChannels.id, id), eq(notificationChannels.userId, userId), ), - ) - .returning({ id: notificationChannels.id }); + ); - if (deleted.length === 0) return false; + if (rowsAffected(result) === 0) return false; await this.afterWrite(); return true; } @@ -210,21 +212,18 @@ export class AlertRepository { channels: number[]; now: string; }): Promise { - const [created] = await this.context.drizzle - .insert(alertRules) - .values({ - userId: input.userId, - hostId: input.hostId, - name: input.name, - enabled: input.enabled, - triggerType: input.triggerType, - thresholdValue: input.thresholdValue, - thresholdDurationSeconds: input.thresholdDurationSeconds, - cooldownMinutes: input.cooldownMinutes, - createdAt: input.now, - updatedAt: input.now, - }) - .returning(); + const [created] = await insertReturning(this.context, alertRules, { + userId: input.userId, + hostId: input.hostId, + name: input.name, + enabled: input.enabled, + triggerType: input.triggerType, + thresholdValue: input.thresholdValue, + thresholdDurationSeconds: input.thresholdDurationSeconds, + cooldownMinutes: input.cooldownMinutes, + createdAt: input.now, + updatedAt: input.now, + }); const channels = await this.replaceRuleChannels( created.id, @@ -263,9 +262,10 @@ export class AlertRepository { now: string; }, ): Promise { - const [updated] = await this.context.drizzle - .update(alertRules) - .set({ + const [updated] = await updateReturning( + this.context, + alertRules, + { ...(input.name !== undefined ? { name: input.name } : {}), ...(input.hostId !== undefined ? { hostId: input.hostId } : {}), ...(input.enabled !== undefined ? { enabled: input.enabled } : {}), @@ -282,9 +282,9 @@ export class AlertRepository { ? { cooldownMinutes: input.cooldownMinutes } : {}), updatedAt: input.now, - }) - .where(and(eq(alertRules.id, id), eq(alertRules.userId, userId))) - .returning(); + }, + and(eq(alertRules.id, id), eq(alertRules.userId, userId)), + ); if (!updated) return null; @@ -298,12 +298,11 @@ export class AlertRepository { } async deleteAlertRule(id: number, userId: string): Promise { - const deleted = await this.context.drizzle + const result = await this.context.drizzle .delete(alertRules) - .where(and(eq(alertRules.id, id), eq(alertRules.userId, userId))) - .returning({ id: alertRules.id }); + .where(and(eq(alertRules.id, id), eq(alertRules.userId, userId))); - if (deleted.length === 0) return false; + if (rowsAffected(result) === 0) return false; await this.afterWrite(); return true; } @@ -411,12 +410,15 @@ export class AlertRepository { await this.afterWrite(); } - pruneFiringsOlderThan(userId: string, days: number): void { - this.context.sqlite - ?.prepare( - "DELETE FROM alert_firings WHERE user_id = ? AND fired_at < datetime('now', ?)", - ) - .run(userId, `-${days} days`); + async pruneFiringsOlderThan(userId: string, days: number): Promise { + await this.context.drizzle + .delete(alertFirings) + .where( + and( + eq(alertFirings.userId, userId), + lt(alertFirings.firedAt, sqlTimestampDaysAgo(days)), + ), + ); } async deleteByUserId(userId: string): Promise<{ @@ -438,10 +440,9 @@ export class AlertRepository { .where(eq(notificationChannels.userId, userId)) ).map((row) => row.id); - const firingRows = await this.context.drizzle + const firingResult = await this.context.drizzle .delete(alertFirings) - .where(eq(alertFirings.userId, userId)) - .returning({ id: alertFirings.id }); + .where(eq(alertFirings.userId, userId)); const linkFilters = [ ...(ruleIds.length > 0 @@ -451,37 +452,34 @@ export class AlertRepository { ? [inArray(alertRuleChannels.channelId, channelIds)] : []), ]; - const linkRows = + const linkResult = linkFilters.length === 0 - ? [] + ? null : await this.context.drizzle .delete(alertRuleChannels) - .where(or(...linkFilters)) - .returning({ id: alertRuleChannels.id }); + .where(or(...linkFilters)); - const ruleRows = await this.context.drizzle + const ruleResult = await this.context.drizzle .delete(alertRules) - .where(eq(alertRules.userId, userId)) - .returning({ id: alertRules.id }); - const channelRows = await this.context.drizzle + .where(eq(alertRules.userId, userId)); + const result = await this.context.drizzle .delete(notificationChannels) - .where(eq(notificationChannels.userId, userId)) - .returning({ id: notificationChannels.id }); + .where(eq(notificationChannels.userId, userId)); if ( - firingRows.length > 0 || - linkRows.length > 0 || - ruleRows.length > 0 || - channelRows.length > 0 + rowsAffected(firingResult) > 0 || + rowsAffected(linkResult) > 0 || + rowsAffected(ruleResult) > 0 || + rowsAffected(result) > 0 ) { await this.afterWrite(); } return { - firingsDeleted: firingRows.length, - ruleLinksDeleted: linkRows.length, - rulesDeleted: ruleRows.length, - channelsDeleted: channelRows.length, + firingsDeleted: rowsAffected(firingResult), + ruleLinksDeleted: rowsAffected(linkResult), + rulesDeleted: rowsAffected(ruleResult), + channelsDeleted: rowsAffected(result), }; } diff --git a/src/backend/database/repositories/api-key-repository.ts b/src/backend/database/repositories/api-key-repository.ts index a24b880a..c037f780 100644 --- a/src/backend/database/repositories/api-key-repository.ts +++ b/src/backend/database/repositories/api-key-repository.ts @@ -1,6 +1,8 @@ import { eq, and } from "drizzle-orm"; import { apiKeys, users } from "../db/schema.js"; import type { DatabaseContext } from "./database-context.js"; +import { rowsAffected } from "./mutation-result.js"; +import { deleteReturning, insertReturning } from "./returning.js"; export type ApiKeyRecord = typeof apiKeys.$inferSelect; export type NewApiKeyRecord = typeof apiKeys.$inferInsert; @@ -24,10 +26,7 @@ export class ApiKeyRepository { ) {} async create(apiKey: NewApiKeyRecord): Promise { - const rows = await this.context.drizzle - .insert(apiKeys) - .values(apiKey) - .returning(); + const rows = await insertReturning(this.context, apiKeys, apiKey); await this.afterWrite(); return rows[0]; } @@ -78,23 +77,23 @@ export class ApiKeyRepository { } async delete(id: string): Promise { - const rows = await this.context.drizzle - .delete(apiKeys) - .where(eq(apiKeys.id, id)) - .returning(); + const rows = await deleteReturning( + this.context, + apiKeys, + eq(apiKeys.id, id), + ); await this.afterWrite(); return rows[0] ?? null; } async deleteByUserId(userId: string): Promise { - const rows = await this.context.drizzle + const result = await this.context.drizzle .delete(apiKeys) - .where(eq(apiKeys.userId, userId)) - .returning({ id: apiKeys.id }); + .where(eq(apiKeys.userId, userId)); await this.afterWrite(); - return rows.length; + return rowsAffected(result); } private async afterWrite(): Promise { diff --git a/src/backend/database/repositories/audit-log-repository.ts b/src/backend/database/repositories/audit-log-repository.ts index 6e9c86a9..6cae51e4 100644 --- a/src/backend/database/repositories/audit-log-repository.ts +++ b/src/backend/database/repositories/audit-log-repository.ts @@ -1,6 +1,9 @@ -import { and, asc, desc, eq, gte, inArray, lte, sql } from "drizzle-orm"; +import { and, asc, desc, eq, gte, inArray, lt, lte, sql } from "drizzle-orm"; import { auditLogs } from "../db/schema.js"; import type { DatabaseContext } from "./database-context.js"; +import { sqlTimestampDaysAgo } from "./sql-timestamp.js"; +import { databaseLogger } from "../../utils/logger.js"; +import { countValue, rowsAffected } from "./mutation-result.js"; export type AuditLogRecord = typeof auditLogs.$inferSelect; export type NewAuditLogRecord = typeof auditLogs.$inferInsert; @@ -19,8 +22,31 @@ export type AuditLogPage = { total: number; }; -const PRUNE_MAX = 10000; -const PRUNE_TARGET = 9000; +export const AUDIT_RETENTION_DAYS_ENV = "AUDIT_LOG_RETENTION_DAYS"; +export const AUDIT_MAX_ENTRIES_ENV = "AUDIT_LOG_MAX_ENTRIES"; + +const DEFAULT_MAX_ENTRIES = 10000; +const PRUNE_TARGET_RATIO = 0.9; + +function positiveIntEnv(key: string, env: NodeJS.ProcessEnv): number | null { + const raw = Number(env[key]); + return Number.isFinite(raw) && raw > 0 ? Math.floor(raw) : null; +} + +/** + * How long entries are kept. Unset means "no time limit", in which case only + * the row cap applies. + */ +export function auditRetentionDays( + env: NodeJS.ProcessEnv = process.env, +): number | null { + return positiveIntEnv(AUDIT_RETENTION_DAYS_ENV, env); +} + +/** Hard ceiling on stored entries, so a busy install cannot fill the disk. */ +export function auditMaxEntries(env: NodeJS.ProcessEnv = process.env): number { + return positiveIntEnv(AUDIT_MAX_ENTRIES_ENV, env) ?? DEFAULT_MAX_ENTRIES; +} export class AuditLogRepository { constructor( @@ -57,10 +83,31 @@ export class AuditLogRepository { return { logs, - total: totalResult[0]?.count ?? 0, + total: countValue(totalResult[0]?.count), }; } + /** + * Reads matching entries in ascending time order for export. + * + * Paged rather than fetched whole so an export cannot pull an unbounded + * result set into memory, and ascending so a resumed or appended export + * continues where the previous one stopped. + */ + async listForExport(input: { + filters: AuditLogFilters; + limit: number; + offset: number; + }): Promise { + return this.context.drizzle + .select() + .from(auditLogs) + .where(this.buildWhere(input.filters)) + .orderBy(asc(auditLogs.timestamp), asc(auditLogs.id)) + .limit(input.limit) + .offset(input.offset); + } + async listDistinctActions(): Promise { const rows = await this.context.drizzle .selectDistinct({ action: auditLogs.action }) @@ -70,17 +117,38 @@ export class AuditLogRepository { return rows.map((row) => row.action); } - async deleteByUserId(userId: string): Promise { - const rows = await this.context.drizzle - .delete(auditLogs) - .where(eq(auditLogs.userId, userId)) - .returning({ id: auditLogs.id }); + /** + * Detaches entries from a user being deleted instead of removing them. + * + * The schema already relaxed this foreign key to ON DELETE SET NULL, but the + * account-deletion path deletes the rows explicitly, which undoes that. An + * audit trail that vanishes with the account it recorded cannot answer the + * question it exists for, and offboarding is exactly when that question gets + * asked. `username` is denormalised, so the entry stays attributable. + */ + async anonymizeByUserId(userId: string): Promise { + const result = await this.context.drizzle + .update(auditLogs) + .set({ userId: null }) + .where(eq(auditLogs.userId, userId)); - if (rows.length > 0) { + if (rowsAffected(result) > 0) { await this.afterWrite(); } - return rows.length; + return rowsAffected(result); + } + + async deleteByUserId(userId: string): Promise { + const result = await this.context.drizzle + .delete(auditLogs) + .where(eq(auditLogs.userId, userId)); + + if (rowsAffected(result) > 0) { + await this.afterWrite(); + } + + return rowsAffected(result); } private buildWhere(filters: AuditLogFilters) { @@ -105,28 +173,73 @@ export class AuditLogRepository { } private async pruneIfNeeded(): Promise { + await this.pruneExpired(); + await this.pruneOverflow(); + } + + /** Drops entries past the configured retention window. */ + private async pruneExpired(): Promise { + const days = auditRetentionDays(); + if (days === null) return; + + const cutoff = sqlTimestampDaysAgo(days); + const result = await this.context.drizzle + .delete(auditLogs) + .where(lt(auditLogs.timestamp, cutoff)); + + if (rowsAffected(result) > 0) { + databaseLogger.info( + `Pruned ${rowsAffected(result)} audit entries past retention`, + { + operation: "audit_retention_prune", + removed: rowsAffected(result), + retentionDays: days, + cutoff, + }, + ); + } + } + + /** + * Enforces the row cap. Unlike retention this discards entries that are still + * within the window, so it is reported as a warning: it means the ceiling is + * too low for how much this install audits, and evidence is being lost. + */ + private async pruneOverflow(): Promise { + const max = auditMaxEntries(); const countResult = await this.context.drizzle .select({ count: sql`COUNT(*)` }) .from(auditLogs); - const count = countResult[0]?.count ?? 0; + const count = countValue(countResult[0]?.count); - if (count < PRUNE_MAX) { - return; - } + if (count < max) return; - const deleteCount = count - PRUNE_TARGET; + const deleteCount = count - Math.floor(max * PRUNE_TARGET_RATIO); const rows = await this.context.drizzle - .select({ id: auditLogs.id }) + .select({ id: auditLogs.id, timestamp: auditLogs.timestamp }) .from(auditLogs) .orderBy(asc(auditLogs.timestamp)) .limit(deleteCount); - const ids = rows.map((row) => row.id); + if (rows.length === 0) return; - if (ids.length > 0) { - await this.context.drizzle - .delete(auditLogs) - .where(inArray(auditLogs.id, ids)); - } + await this.context.drizzle.delete(auditLogs).where( + inArray( + auditLogs.id, + rows.map((row) => row.id), + ), + ); + + databaseLogger.warn( + `Audit log hit its ${max}-entry cap; discarded ${rows.length} entries`, + { + operation: "audit_overflow_prune", + removed: rows.length, + maxEntries: max, + oldestRemoved: rows[0]?.timestamp, + newestRemoved: rows[rows.length - 1]?.timestamp, + hint: `Raise ${AUDIT_MAX_ENTRIES_ENV}, or set ${AUDIT_RETENTION_DAYS_ENV} and export older entries before they are dropped.`, + }, + ); } private async afterWrite(): Promise { diff --git a/src/backend/database/repositories/c2s-tunnel-preset-repository.ts b/src/backend/database/repositories/c2s-tunnel-preset-repository.ts index b0417132..0c14689a 100644 --- a/src/backend/database/repositories/c2s-tunnel-preset-repository.ts +++ b/src/backend/database/repositories/c2s-tunnel-preset-repository.ts @@ -1,6 +1,8 @@ import { and, asc, eq, sql } from "drizzle-orm"; import { c2sTunnelPresets } from "../db/schema.js"; import type { DatabaseContext } from "./database-context.js"; +import { rowsAffected } from "./mutation-result.js"; +import { insertReturning, updateReturning } from "./returning.js"; export type C2sTunnelPresetRecord = typeof c2sTunnelPresets.$inferSelect; @@ -64,16 +66,13 @@ export class C2sTunnelPresetRepository { userId: string, input: C2sTunnelPresetCreateInput, ): Promise { - const [created] = await this.context.drizzle - .insert(c2sTunnelPresets) - .values({ - userId, - name: input.name, - config: input.config, - platform: input.platform ?? null, - computerName: input.computerName ?? null, - }) - .returning(); + const [created] = await insertReturning(this.context, c2sTunnelPresets, { + userId, + name: input.name, + config: input.config, + platform: input.platform ?? null, + computerName: input.computerName ?? null, + }); await this.afterWrite(); return created; @@ -84,16 +83,15 @@ export class C2sTunnelPresetRepository { id: number, updates: C2sTunnelPresetUpdateInput, ): Promise { - const [updated] = await this.context.drizzle - .update(c2sTunnelPresets) - .set({ + const [updated] = await updateReturning( + this.context, + c2sTunnelPresets, + { ...updates, updatedAt: sql`CURRENT_TIMESTAMP`, - }) - .where( - and(eq(c2sTunnelPresets.id, id), eq(c2sTunnelPresets.userId, userId)), - ) - .returning(); + }, + and(eq(c2sTunnelPresets.id, id), eq(c2sTunnelPresets.userId, userId)), + ); if (updated) { await this.afterWrite(); @@ -103,31 +101,29 @@ export class C2sTunnelPresetRepository { } async deleteForUser(userId: string, id: number): Promise { - const rows = await this.context.drizzle + const result = await this.context.drizzle .delete(c2sTunnelPresets) .where( and(eq(c2sTunnelPresets.id, id), eq(c2sTunnelPresets.userId, userId)), - ) - .returning({ id: c2sTunnelPresets.id }); + ); - if (rows.length > 0) { + if (rowsAffected(result) > 0) { await this.afterWrite(); } - return rows.length > 0; + return rowsAffected(result) > 0; } async deleteByUserId(userId: string): Promise { - const rows = await this.context.drizzle + const result = await this.context.drizzle .delete(c2sTunnelPresets) - .where(eq(c2sTunnelPresets.userId, userId)) - .returning({ id: c2sTunnelPresets.id }); + .where(eq(c2sTunnelPresets.userId, userId)); - if (rows.length > 0) { + if (rowsAffected(result) > 0) { await this.afterWrite(); } - return rows.length; + return rowsAffected(result); } private async afterWrite(): Promise { diff --git a/src/backend/database/repositories/command-history-repository.ts b/src/backend/database/repositories/command-history-repository.ts index 5bf72b3a..da7b6571 100644 --- a/src/backend/database/repositories/command-history-repository.ts +++ b/src/backend/database/repositories/command-history-repository.ts @@ -1,6 +1,8 @@ import { and, desc, eq, inArray, sql } from "drizzle-orm"; import { commandHistory } from "../db/schema.js"; import type { DatabaseContext } from "./database-context.js"; +import { rowsAffected } from "./mutation-result.js"; +import { insertReturning } from "./returning.js"; export type CommandHistoryRecord = typeof commandHistory.$inferSelect; @@ -16,10 +18,12 @@ export class CommandHistoryRepository { command: string, executedAt = new Date().toISOString(), ): Promise { - const [created] = await this.context.drizzle - .insert(commandHistory) - .values({ userId, hostId, command, executedAt }) - .returning(); + const [created] = await insertReturning(this.context, commandHistory, { + userId, + hostId, + command, + executedAt, + }); await this.afterWrite(); return created; } @@ -76,7 +80,7 @@ export class CommandHistoryRepository { hostId: number, command: string, ): Promise { - const rows = await this.context.drizzle + const result = await this.context.drizzle .delete(commandHistory) .where( and( @@ -84,45 +88,42 @@ export class CommandHistoryRepository { eq(commandHistory.hostId, hostId), eq(commandHistory.command, command), ), - ) - .returning({ id: commandHistory.id }); + ); - if (rows.length > 0) { + if (rowsAffected(result) > 0) { await this.afterWrite(); } - return rows.length; + return rowsAffected(result); } async deleteByUserAndHost(userId: string, hostId: number): Promise { - const rows = await this.context.drizzle + const result = await this.context.drizzle .delete(commandHistory) .where( and( eq(commandHistory.userId, userId), eq(commandHistory.hostId, hostId), ), - ) - .returning({ id: commandHistory.id }); + ); - if (rows.length > 0) { + if (rowsAffected(result) > 0) { await this.afterWrite(); } - return rows.length; + return rowsAffected(result); } async deleteByHostId(hostId: number): Promise { - const rows = await this.context.drizzle + const result = await this.context.drizzle .delete(commandHistory) - .where(eq(commandHistory.hostId, hostId)) - .returning({ id: commandHistory.id }); + .where(eq(commandHistory.hostId, hostId)); - if (rows.length > 0) { + if (rowsAffected(result) > 0) { await this.afterWrite(); } - return rows.length; + return rowsAffected(result); } async deleteByHostIds(hostIds: number[]): Promise { @@ -130,29 +131,27 @@ export class CommandHistoryRepository { return 0; } - const rows = await this.context.drizzle + const result = await this.context.drizzle .delete(commandHistory) - .where(inArray(commandHistory.hostId, hostIds)) - .returning({ id: commandHistory.id }); + .where(inArray(commandHistory.hostId, hostIds)); - if (rows.length > 0) { + if (rowsAffected(result) > 0) { await this.afterWrite(); } - return rows.length; + return rowsAffected(result); } async deleteByUserId(userId: string): Promise { - const rows = await this.context.drizzle + const result = await this.context.drizzle .delete(commandHistory) - .where(eq(commandHistory.userId, userId)) - .returning({ id: commandHistory.id }); + .where(eq(commandHistory.userId, userId)); - if (rows.length > 0) { + if (rowsAffected(result) > 0) { await this.afterWrite(); } - return rows.length; + return rowsAffected(result); } private async afterWrite(): Promise { diff --git a/src/backend/database/repositories/credential-repository.ts b/src/backend/database/repositories/credential-repository.ts index 5ec291be..dd6b12d1 100644 --- a/src/backend/database/repositories/credential-repository.ts +++ b/src/backend/database/repositories/credential-repository.ts @@ -3,6 +3,12 @@ import { randomUUID } from "crypto"; import { sshCredentials, sshCredentialUsage } from "../db/schema.js"; import type { DatabaseContext } from "./database-context.js"; import { DataCrypto } from "../../utils/data-crypto.js"; +import { rowsAffected } from "./mutation-result.js"; +import { + deleteReturning, + insertReturning, + updateReturning, +} from "./returning.js"; export type CredentialRecord = typeof sshCredentials.$inferSelect; export type NewCredentialRecord = typeof sshCredentials.$inferInsert; @@ -17,10 +23,10 @@ export class CredentialRepository { ) {} async create(credential: NewCredentialRecord): Promise { - const rows = await this.context.drizzle - .insert(sshCredentials) - .values({ syncId: randomUUID(), ...credential }) - .returning(); + const rows = await insertReturning(this.context, sshCredentials, { + syncId: randomUUID(), + ...credential, + }); await this.afterWrite(); return rows[0]; } @@ -46,10 +52,11 @@ export class CredentialRepository { delete (encryptedCredential as Partial).id; } - const rows = await this.context.drizzle - .insert(sshCredentials) - .values(encryptedCredential as NewCredentialRecord) - .returning(); + const rows = await insertReturning( + this.context, + sshCredentials, + encryptedCredential as NewCredentialRecord, + ); await this.afterWrite(); return DataCrypto.decryptRecord( @@ -143,7 +150,7 @@ export class CredentialRepository { oldName: string, newName: string, ): Promise { - const rows = await this.context.drizzle + const result = await this.context.drizzle .update(sshCredentials) .set({ folder: newName, updatedAt: sql`CURRENT_TIMESTAMP` }) .where( @@ -151,14 +158,13 @@ export class CredentialRepository { eq(sshCredentials.userId, userId), eq(sshCredentials.folder, oldName), ), - ) - .returning({ id: sshCredentials.id }); + ); - if (rows.length > 0) { + if (rowsAffected(result) > 0) { await this.afterWrite(); } - return rows.length; + return rowsAffected(result); } async updateForUser( @@ -166,16 +172,15 @@ export class CredentialRepository { credentialId: number, update: CredentialUpdate, ): Promise { - const rows = await this.context.drizzle - .update(sshCredentials) - .set({ ...update, updatedAt: sql`CURRENT_TIMESTAMP` }) - .where( - and( - eq(sshCredentials.id, credentialId), - eq(sshCredentials.userId, userId), - ), - ) - .returning(); + const rows = await updateReturning( + this.context, + sshCredentials, + { ...update, updatedAt: sql`CURRENT_TIMESTAMP` }, + and( + eq(sshCredentials.id, credentialId), + eq(sshCredentials.userId, userId), + ), + ); await this.afterWrite(); return rows[0] ?? null; @@ -193,16 +198,15 @@ export class CredentialRepository { userDataKey, ); - const rows = await this.context.drizzle - .update(sshCredentials) - .set({ ...encryptedUpdate, updatedAt: sql`CURRENT_TIMESTAMP` }) - .where( - and( - eq(sshCredentials.id, credentialId), - eq(sshCredentials.userId, userId), - ), - ) - .returning(); + const rows = await updateReturning( + this.context, + sshCredentials, + { ...encryptedUpdate, updatedAt: sql`CURRENT_TIMESTAMP` }, + and( + eq(sshCredentials.id, credentialId), + eq(sshCredentials.userId, userId), + ), + ); await this.afterWrite(); return this.decryptOne(rows[0] ?? null, userId); @@ -212,31 +216,29 @@ export class CredentialRepository { userId: string, credentialId: number, ): Promise<{ syncId: string | null } | null> { - const rows = await this.context.drizzle - .delete(sshCredentials) - .where( - and( - eq(sshCredentials.id, credentialId), - eq(sshCredentials.userId, userId), - ), - ) - .returning({ syncId: sshCredentials.syncId }); + const rows = await deleteReturning( + this.context, + sshCredentials, + and( + eq(sshCredentials.id, credentialId), + eq(sshCredentials.userId, userId), + ), + ); await this.afterWrite(); - return rows[0] ?? null; + return rows[0] ? { syncId: rows[0].syncId } : null; } async deleteByUserId(userId: string): Promise { - const rows = await this.context.drizzle + const result = await this.context.drizzle .delete(sshCredentials) - .where(eq(sshCredentials.userId, userId)) - .returning({ id: sshCredentials.id }); + .where(eq(sshCredentials.userId, userId)); - if (rows.length > 0) { + if (rowsAffected(result) > 0) { await this.afterWrite(); } - return rows.length; + return rowsAffected(result); } async recordUsage( diff --git a/src/backend/database/repositories/dashboard-service-link-repository.ts b/src/backend/database/repositories/dashboard-service-link-repository.ts index b06c2100..3aa6d253 100644 --- a/src/backend/database/repositories/dashboard-service-link-repository.ts +++ b/src/backend/database/repositories/dashboard-service-link-repository.ts @@ -2,6 +2,12 @@ import { and, asc, eq } from "drizzle-orm"; import { randomUUID } from "crypto"; import { dashboardServiceLinks } from "../db/schema.js"; import type { DatabaseContext } from "./database-context.js"; +import { rowsAffected } from "./mutation-result.js"; +import { + deleteReturning, + insertReturning, + updateReturning, +} from "./returning.js"; export type DashboardServiceLinkRecord = typeof dashboardServiceLinks.$inferSelect; @@ -38,9 +44,10 @@ export class DashboardServiceLinkRepository { const nextOrder = existing.length > 0 ? existing[existing.length - 1].order + 1 : 0; - const [created] = await this.context.drizzle - .insert(dashboardServiceLinks) - .values({ + const [created] = await insertReturning( + this.context, + dashboardServiceLinks, + { syncId: randomUUID(), userId, label: input.label, @@ -48,8 +55,8 @@ export class DashboardServiceLinkRepository { order: nextOrder, createdAt, updatedAt: createdAt, - }) - .returning(); + }, + ); await this.afterWrite(); return created; } @@ -77,16 +84,15 @@ export class DashboardServiceLinkRepository { id: number, updates: DashboardServiceLinkUpdate, ): Promise { - const [updated] = await this.context.drizzle - .update(dashboardServiceLinks) - .set({ ...updates, updatedAt: new Date().toISOString() }) - .where( - and( - eq(dashboardServiceLinks.id, id), - eq(dashboardServiceLinks.userId, userId), - ), - ) - .returning(); + const [updated] = await updateReturning( + this.context, + dashboardServiceLinks, + { ...updates, updatedAt: new Date().toISOString() }, + and( + eq(dashboardServiceLinks.id, id), + eq(dashboardServiceLinks.userId, userId), + ), + ); if (updated) { await this.afterWrite(); @@ -99,32 +105,30 @@ export class DashboardServiceLinkRepository { userId: string, id: number, ): Promise<{ syncId: string | null } | null> { - const rows = await this.context.drizzle - .delete(dashboardServiceLinks) - .where( - and( - eq(dashboardServiceLinks.id, id), - eq(dashboardServiceLinks.userId, userId), - ), - ) - .returning({ syncId: dashboardServiceLinks.syncId }); + const rows = await deleteReturning( + this.context, + dashboardServiceLinks, + and( + eq(dashboardServiceLinks.id, id), + eq(dashboardServiceLinks.userId, userId), + ), + ); if (rows.length === 0) return null; await this.afterWrite(); - return rows[0]; + return { syncId: rows[0].syncId }; } async deleteByUserId(userId: string): Promise { - const rows = await this.context.drizzle + const result = await this.context.drizzle .delete(dashboardServiceLinks) - .where(eq(dashboardServiceLinks.userId, userId)) - .returning({ id: dashboardServiceLinks.id }); + .where(eq(dashboardServiceLinks.userId, userId)); - if (rows.length > 0) { + if (rowsAffected(result) > 0) { await this.afterWrite(); } - return rows.length; + return rowsAffected(result); } private async afterWrite(): Promise { diff --git a/src/backend/database/repositories/database-context.ts b/src/backend/database/repositories/database-context.ts index 7666cc41..4f0bb476 100644 --- a/src/backend/database/repositories/database-context.ts +++ b/src/backend/database/repositories/database-context.ts @@ -1,9 +1,41 @@ import type { BetterSQLite3Database } from "drizzle-orm/better-sqlite3"; -import type { Database as BetterSqliteDatabase } from "better-sqlite3"; import type * as schema from "../db/schema.js"; +// Re-exported so repositories can keep importing it from here, but defined in +// db/dialect.ts — a local copy that said "sqlite" survived here for a while and +// typed every context as SQLite-only while the runtime already carried all +// three, which silently made the dialect branches unreachable to the checker. +export type { DatabaseDialect } from "../db/dialect.js"; +import type { DatabaseDialect } from "../db/dialect.js"; + +/** + * The database handle repositories work against. + * + * Typed as the SQLite instance on purpose. drizzle's three Database classes + * share no base class and their signatures are incompatible: a union is not + * callable, and a generic would have to be threaded through all 43 + * repositories and every method on them. + * + * This is a deliberate approximation, not an accident. The query-builder + * surface the repositories actually use is the same on all three engines, and + * that equivalence is asserted in multi-dialect.test.ts rather than assumed — + * identifier quoting, placeholder style and value coercion are all covered + * there. At runtime this may hold a Postgres or MySQL instance. + * + * The one place the surfaces genuinely differ is RETURNING, which MySQL lacks; + * see mutation-result.ts for how that is absorbed. + */ +export type PortableDatabase = BetterSQLite3Database; + +/** + * What a repository is allowed to touch. + * + * Deliberately drizzle-only: with no raw driver handle here, no repository can + * reach for engine-specific SQL. Retention queries that previously needed + * `datetime('now', ?)` compute their cutoff in JS instead — see + * ./sql-timestamp.ts. + */ export interface DatabaseContext { - dialect: "sqlite"; - drizzle: BetterSQLite3Database; - sqlite?: BetterSqliteDatabase; + dialect: DatabaseDialect; + drizzle: PortableDatabase; } diff --git a/src/backend/database/repositories/dismissed-alert-repository.ts b/src/backend/database/repositories/dismissed-alert-repository.ts index e44d20b1..87cf40d8 100644 --- a/src/backend/database/repositories/dismissed-alert-repository.ts +++ b/src/backend/database/repositories/dismissed-alert-repository.ts @@ -1,6 +1,7 @@ import { and, eq } from "drizzle-orm"; import { dismissedAlerts } from "../db/schema.js"; import type { DatabaseContext } from "./database-context.js"; +import { rowsAffected } from "./mutation-result.js"; export type DismissedAlertRecord = typeof dismissedAlerts.$inferSelect; @@ -72,34 +73,32 @@ export class DismissedAlertRepository { } async deleteForUser(userId: string, alertId: string): Promise { - const rows = await this.context.drizzle + const result = await this.context.drizzle .delete(dismissedAlerts) .where( and( eq(dismissedAlerts.userId, userId), eq(dismissedAlerts.alertId, alertId), ), - ) - .returning({ id: dismissedAlerts.id }); + ); - if (rows.length > 0) { + if (rowsAffected(result) > 0) { await this.afterWrite(); } - return rows.length > 0; + return rowsAffected(result) > 0; } async deleteByUserId(userId: string): Promise { - const rows = await this.context.drizzle + const result = await this.context.drizzle .delete(dismissedAlerts) - .where(eq(dismissedAlerts.userId, userId)) - .returning({ id: dismissedAlerts.id }); + .where(eq(dismissedAlerts.userId, userId)); - if (rows.length > 0) { + if (rowsAffected(result) > 0) { await this.afterWrite(); } - return rows.length; + return rowsAffected(result); } private async afterWrite(): Promise { diff --git a/src/backend/database/repositories/factory.ts b/src/backend/database/repositories/factory.ts index 42dd08d8..30673432 100644 --- a/src/backend/database/repositories/factory.ts +++ b/src/backend/database/repositories/factory.ts @@ -1,5 +1,7 @@ import { DatabaseSaveTrigger } from "../../utils/database-save-trigger.js"; import { getDb, getSqlite } from "../db/index.js"; +import { needsExplicitPersist, resolveDatabaseDialect } from "../db/dialect.js"; +import { primeSettingsCache, readCachedSetting } from "./settings-cache.js"; import type { DatabaseContext } from "./database-context.js"; import { WebauthnCredentialRepository } from "./webauthn-credential-repository.js"; import { AlertRepository } from "./alert-repository.js"; @@ -29,6 +31,7 @@ import { SessionRecordingRepository } from "./session-recording-repository.js"; import { SessionRepository } from "./session-repository.js"; import { SessionShareRepository } from "./session-share-repository.js"; import { SettingsRepository } from "./settings-repository.js"; +import { SharedHostAuthOverrideRepository } from "./shared-host-auth-override-repository.js"; import { SharedHostSecretsRepository } from "./shared-host-secrets-repository.js"; import { SnippetRepository } from "./snippet-repository.js"; import { SshCredentialUsageRepository } from "./ssh-credential-usage-repository.js"; @@ -45,25 +48,62 @@ import { UserRepository } from "./user-repository.js"; import { VaultProfileRepository } from "./vault-profile-repository.js"; import { VaultTokenRepository } from "./vault-token-repository.js"; +/** + * The context every repository runs against. + * + * The dialect has to be resolved, not assumed: it is what `returning.ts` reads + * to decide whether it can ask for RETURNING, and whether an upsert spells + * itself `onConflictDoUpdate` or `onDuplicateKeyUpdate`. Reporting "sqlite" + * while connected to MySQL makes the second of those a TypeError on the first + * write. + * + * Both cross-dialect harnesses build a DatabaseContext themselves, so neither + * exercises this function — see tests/database/repositories/factory-context. + */ export function createCurrentRepositoryContext(): DatabaseContext { return { - dialect: "sqlite", + dialect: resolveDatabaseDialect(), drizzle: getDb(), - sqlite: getSqlite(), }; } +/** + * Post-write hook handed to every repository. + * + * Only meaningful for SQLite, where the database lives in memory and has to be + * serialised back to its encrypted file. On Postgres and MySQL the write is + * already durable, so no hook is installed at all rather than one that does + * nothing — repositories call it as `this.onWrite?.()`. + */ export function createCurrentRepositoryWriteHook( reason: string, -): () => Promise { +): (() => Promise) | undefined { + if (!needsExplicitPersist(resolveDatabaseDialect())) return undefined; return () => DatabaseSaveTrigger.forceSave(reason); } +/** + * Raw driver handle for the few synchronous call sites that cannot await — + * getCurrentSettingValue below, and settings reads during startup. Repositories + * must not use this: they take a DatabaseContext, which is drizzle-only. + * Porting to another engine means giving these callers an async path first. + */ export function getCurrentRepositorySqlite() { return getSqlite(); } +/** + * Synchronous settings read. + * + * SQLite can be queried synchronously, so it is read directly and stays + * authoritative. Other engines have no synchronous query, so the value comes + * from the cache primed at startup and kept current by SettingsRepository. + */ export function getCurrentSettingValue(key: string): string | null { + if (!needsExplicitPersist(resolveDatabaseDialect())) { + return readCachedSetting(key); + } + const row = getCurrentRepositorySqlite() .prepare("SELECT value FROM settings WHERE key = ?") .get(key) as { value?: string } | undefined; @@ -283,6 +323,15 @@ export function createCurrentSharedHostSecretsRepository(): SharedHostSecretsRep ); } +export function createCurrentSharedHostAuthOverrideRepository(): SharedHostAuthOverrideRepository { + return new SharedHostAuthOverrideRepository( + createCurrentRepositoryContext(), + createCurrentRepositoryWriteHook( + "shared_host_auth_override_repository_write", + ), + ); +} + export function createCurrentSnippetRepository(): SnippetRepository { return new SnippetRepository( createCurrentRepositoryContext(), @@ -370,3 +419,69 @@ export function createCurrentVaultTokenRepository(): VaultTokenRepository { createCurrentRepositoryWriteHook("vault_token_repository_write"), ); } + +/** + * Loads the settings cache. Must run during startup on engines without a + * synchronous read, before anything calls getCurrentSettingValue. + */ +export async function primeCurrentSettingsCache(): Promise { + const rows = await createCurrentSettingsRepository().listAll(); + primeSettingsCache(rows); +} + +/** + * How often a replica re-reads the settings table. + * + * Override with SETTINGS_CACHE_REFRESH_SECONDS; 0 disables the refresh. + */ +const REFRESH_SECONDS_ENV = "SETTINGS_CACHE_REFRESH_SECONDS"; +const DEFAULT_REFRESH_SECONDS = 30; + +let refreshTimer: NodeJS.Timeout | null = null; + +/** + * Keeps the settings cache from drifting on a multi-replica deployment. + * + * The cache is per-process and updated in the process that writes. That is + * enough for SQLite, where there is only ever one process. On Postgres and + * MySQL — which exist here precisely so more than one instance can share the + * data — a setting changed on one replica would otherwise never reach the + * others, because the synchronous read has no way to go back to the database. + * + * Periodic re-priming does not make the value immediately consistent. It bounds + * how long it can be wrong, which is the difference between a setting that + * takes effect on the next tick and one that takes effect at the next restart. + */ +export function startSettingsCacheRefresh( + env = process.env, + refresh: () => Promise = primeCurrentSettingsCache, +): void { + if (refreshTimer) return; + + const seconds = refreshIntervalSeconds(env); + if (seconds === null) return; + + refreshTimer = setInterval(() => { + void refresh().catch(() => { + // A failed refresh leaves the previous values in place, which is the + // right outcome: a transient database blip should not blank the cache. + // Every caller reads a missing setting as "use the default", so an empty + // cache would silently revert configuration across the deployment. + }); + }, seconds * 1000); + + refreshTimer.unref(); +} + +/** The configured interval, or null when refreshing is switched off. */ +export function refreshIntervalSeconds(env = process.env): number | null { + const seconds = Number(env[REFRESH_SECONDS_ENV] ?? DEFAULT_REFRESH_SECONDS); + return Number.isFinite(seconds) && seconds > 0 ? seconds : null; +} + +/** Test seam. */ +export function stopSettingsCacheRefresh(): void { + if (!refreshTimer) return; + clearInterval(refreshTimer); + refreshTimer = null; +} diff --git a/src/backend/database/repositories/field-encryption-boundary.ts b/src/backend/database/repositories/field-encryption-boundary.ts deleted file mode 100644 index 04cdf02d..00000000 --- a/src/backend/database/repositories/field-encryption-boundary.ts +++ /dev/null @@ -1,158 +0,0 @@ -import { FieldCrypto } from "../../utils/field-crypto.js"; -import { LazyFieldEncryption } from "../../utils/lazy-field-encryption.js"; - -const FIELD_ENCRYPTION_POLICY = { - users: { - sensitive: new Set([ - "passwordHash", - "clientSecret", - "totpSecret", - "totpBackupCodes", - "oidcIdentifier", - ]), - plaintext: new Set(["id", "username", "isAdmin", "isOidc"]), - }, - ssh_data: { - sensitive: new Set([ - "password", - "key", - "keyPassword", - "sudoPassword", - "autostartPassword", - "autostartKey", - "autostartKeyPassword", - "socks5Password", - "rdpPassword", - "vncPassword", - "telnetPassword", - ]), - plaintext: new Set([ - "id", - "userId", - "connectionType", - "name", - "ip", - "port", - "username", - "folder", - "tags", - "authType", - "credentialId", - ]), - }, - ssh_credentials: { - sensitive: new Set([ - "password", - "key", - "privateKey", - "publicKey", - "keyPassword", - ]), - plaintext: new Set([ - "id", - "userId", - "name", - "description", - "folder", - "tags", - "authType", - "username", - "keyType", - "detectedKeyType", - "usageCount", - "lastUsed", - ]), - }, - opkssh_tokens: { - sensitive: new Set(["sshCert", "privateKey"]), - plaintext: new Set(["id", "userId", "hostId", "createdAt", "expiresAt"]), - }, - termix_identity_ca: { - sensitive: new Set(["privateKey"]), - plaintext: new Set(["id", "publicKey", "createdAt", "updatedAt"]), - }, - vault_tokens: { - sensitive: new Set(["sshCert", "privateKey"]), - plaintext: new Set(["id", "userId", "profileId", "expiresAt"]), - }, -} as const; - -type PolicyTable = keyof typeof FIELD_ENCRYPTION_POLICY; -export type FieldClassification = "sensitive" | "plaintext" | "unknown"; - -export class FieldEncryptionBoundary { - static classifyField( - tableName: string, - fieldName: string, - ): FieldClassification { - const policy = this.getPolicy(tableName); - if (!policy) return "unknown"; - if (policy.sensitive.has(fieldName)) return "sensitive"; - if (policy.plaintext.has(fieldName)) return "plaintext"; - return "unknown"; - } - - static getSensitiveFields(tableName: string): string[] { - const policy = this.getPolicy(tableName); - return policy ? [...policy.sensitive].sort() : []; - } - - static encryptRecord>( - tableName: string, - record: T, - userDataKey: Buffer, - recordId = record.id, - ): T { - const id = this.requireRecordId(recordId); - const encryptedRecord: Record = { ...record }; - - for (const fieldName of this.getSensitiveFields(tableName)) { - const value = encryptedRecord[fieldName]; - if (typeof value === "string" && value) { - encryptedRecord[fieldName] = FieldCrypto.encryptField( - value, - userDataKey, - id, - fieldName, - ); - } - } - - return encryptedRecord as T; - } - - static decryptRecord>( - tableName: string, - record: T, - userDataKey: Buffer, - recordId = record.id, - ): T { - const id = this.requireRecordId(recordId); - const decryptedRecord: Record = { ...record }; - - for (const fieldName of this.getSensitiveFields(tableName)) { - const value = decryptedRecord[fieldName]; - if (typeof value === "string" && value) { - decryptedRecord[fieldName] = LazyFieldEncryption.safeGetFieldValue( - value, - userDataKey, - id, - fieldName, - ); - } - } - - return decryptedRecord as T; - } - - private static getPolicy(tableName: string) { - return FIELD_ENCRYPTION_POLICY[tableName as PolicyTable]; - } - - private static requireRecordId(recordId: unknown): string { - if (recordId === null || recordId === undefined || recordId === "") { - throw new Error("Field encryption requires a stable record id."); - } - return String(recordId); - } -} diff --git a/src/backend/database/repositories/file-manager-bookmark-repository.ts b/src/backend/database/repositories/file-manager-bookmark-repository.ts index dfd68b3f..351b2af7 100644 --- a/src/backend/database/repositories/file-manager-bookmark-repository.ts +++ b/src/backend/database/repositories/file-manager-bookmark-repository.ts @@ -5,6 +5,7 @@ import { fileManagerShortcuts, } from "../db/schema.js"; import type { DatabaseContext } from "./database-context.js"; +import { rowsAffected } from "./mutation-result.js"; export type FileManagerRecentRecord = typeof fileManagerRecent.$inferSelect; export type FileManagerPinnedRecord = typeof fileManagerPinned.$inferSelect; @@ -112,7 +113,7 @@ export class FileManagerBookmarkRepository { userId: string, input: Pick, ): Promise { - const rows = await this.context.drizzle + const result = await this.context.drizzle .delete(fileManagerRecent) .where( and( @@ -120,14 +121,13 @@ export class FileManagerBookmarkRepository { eq(fileManagerRecent.hostId, input.hostId), eq(fileManagerRecent.path, input.path), ), - ) - .returning({ id: fileManagerRecent.id }); + ); - if (rows.length > 0) { + if (rowsAffected(result) > 0) { await this.afterWrite(); } - return rows.length; + return rowsAffected(result); } async listPinnedForHost( @@ -199,7 +199,7 @@ export class FileManagerBookmarkRepository { userId: string, input: Pick, ): Promise { - const rows = await this.context.drizzle + const result = await this.context.drizzle .delete(fileManagerPinned) .where( and( @@ -207,14 +207,13 @@ export class FileManagerBookmarkRepository { eq(fileManagerPinned.hostId, input.hostId), eq(fileManagerPinned.path, input.path), ), - ) - .returning({ id: fileManagerPinned.id }); + ); - if (rows.length > 0) { + if (rowsAffected(result) > 0) { await this.afterWrite(); } - return rows.length; + return rowsAffected(result); } async listShortcutsForHost( @@ -288,7 +287,7 @@ export class FileManagerBookmarkRepository { userId: string, input: Pick, ): Promise { - const rows = await this.context.drizzle + const result = await this.context.drizzle .delete(fileManagerShortcuts) .where( and( @@ -296,14 +295,13 @@ export class FileManagerBookmarkRepository { eq(fileManagerShortcuts.hostId, input.hostId), eq(fileManagerShortcuts.path, input.path), ), - ) - .returning({ id: fileManagerShortcuts.id }); + ); - if (rows.length > 0) { + if (rowsAffected(result) > 0) { await this.afterWrite(); } - return rows.length; + return rowsAffected(result); } async deleteByUserId(userId: string): Promise { @@ -456,75 +454,66 @@ export class FileManagerBookmarkRepository { } private async deleteRecentByUserId(userId: string): Promise { - const rows = await this.context.drizzle + const result = await this.context.drizzle .delete(fileManagerRecent) - .where(eq(fileManagerRecent.userId, userId)) - .returning({ id: fileManagerRecent.id }); - return rows.length; + .where(eq(fileManagerRecent.userId, userId)); + return rowsAffected(result); } private async deletePinnedByUserId(userId: string): Promise { - const rows = await this.context.drizzle + const result = await this.context.drizzle .delete(fileManagerPinned) - .where(eq(fileManagerPinned.userId, userId)) - .returning({ id: fileManagerPinned.id }); - return rows.length; + .where(eq(fileManagerPinned.userId, userId)); + return rowsAffected(result); } private async deleteShortcutsByUserId(userId: string): Promise { - const rows = await this.context.drizzle + const result = await this.context.drizzle .delete(fileManagerShortcuts) - .where(eq(fileManagerShortcuts.userId, userId)) - .returning({ id: fileManagerShortcuts.id }); - return rows.length; + .where(eq(fileManagerShortcuts.userId, userId)); + return rowsAffected(result); } private async deleteRecentByHostId(hostId: number): Promise { - const rows = await this.context.drizzle + const result = await this.context.drizzle .delete(fileManagerRecent) - .where(eq(fileManagerRecent.hostId, hostId)) - .returning({ id: fileManagerRecent.id }); - return rows.length; + .where(eq(fileManagerRecent.hostId, hostId)); + return rowsAffected(result); } private async deletePinnedByHostId(hostId: number): Promise { - const rows = await this.context.drizzle + const result = await this.context.drizzle .delete(fileManagerPinned) - .where(eq(fileManagerPinned.hostId, hostId)) - .returning({ id: fileManagerPinned.id }); - return rows.length; + .where(eq(fileManagerPinned.hostId, hostId)); + return rowsAffected(result); } private async deleteShortcutsByHostId(hostId: number): Promise { - const rows = await this.context.drizzle + const result = await this.context.drizzle .delete(fileManagerShortcuts) - .where(eq(fileManagerShortcuts.hostId, hostId)) - .returning({ id: fileManagerShortcuts.id }); - return rows.length; + .where(eq(fileManagerShortcuts.hostId, hostId)); + return rowsAffected(result); } private async deleteRecentByHostIds(hostIds: number[]): Promise { - const rows = await this.context.drizzle + const result = await this.context.drizzle .delete(fileManagerRecent) - .where(inArray(fileManagerRecent.hostId, hostIds)) - .returning({ id: fileManagerRecent.id }); - return rows.length; + .where(inArray(fileManagerRecent.hostId, hostIds)); + return rowsAffected(result); } private async deletePinnedByHostIds(hostIds: number[]): Promise { - const rows = await this.context.drizzle + const result = await this.context.drizzle .delete(fileManagerPinned) - .where(inArray(fileManagerPinned.hostId, hostIds)) - .returning({ id: fileManagerPinned.id }); - return rows.length; + .where(inArray(fileManagerPinned.hostId, hostIds)); + return rowsAffected(result); } private async deleteShortcutsByHostIds(hostIds: number[]): Promise { - const rows = await this.context.drizzle + const result = await this.context.drizzle .delete(fileManagerShortcuts) - .where(inArray(fileManagerShortcuts.hostId, hostIds)) - .returning({ id: fileManagerShortcuts.id }); - return rows.length; + .where(inArray(fileManagerShortcuts.hostId, hostIds)); + return rowsAffected(result); } private async afterWrite(): Promise { diff --git a/src/backend/database/repositories/homepage-item-repository.ts b/src/backend/database/repositories/homepage-item-repository.ts index 2be7ebed..7f4a88ce 100644 --- a/src/backend/database/repositories/homepage-item-repository.ts +++ b/src/backend/database/repositories/homepage-item-repository.ts @@ -2,6 +2,12 @@ import { and, asc, eq } from "drizzle-orm"; import { randomUUID } from "crypto"; import { homepageItems } from "../db/schema.js"; import type { DatabaseContext } from "./database-context.js"; +import { rowsAffected } from "./mutation-result.js"; +import { + deleteReturning, + insertReturning, + updateReturning, +} from "./returning.js"; export type HomepageItemRecord = typeof homepageItems.$inferSelect; @@ -35,18 +41,15 @@ export class HomepageItemRepository { input: HomepageItemCreateInput, now = new Date().toISOString(), ): Promise { - const [created] = await this.context.drizzle - .insert(homepageItems) - .values({ - syncId: randomUUID(), - userId, - typeId: input.typeId, - title: input.title, - config: input.config, - createdAt: now, - updatedAt: now, - }) - .returning(); + const [created] = await insertReturning(this.context, homepageItems, { + syncId: randomUUID(), + userId, + typeId: input.typeId, + title: input.title, + config: input.config, + createdAt: now, + updatedAt: now, + }); await this.afterWrite(); return created; @@ -71,11 +74,12 @@ export class HomepageItemRepository { updates: HomepageItemUpdateInput, updatedAt = new Date().toISOString(), ): Promise { - const [updated] = await this.context.drizzle - .update(homepageItems) - .set({ ...updates, updatedAt }) - .where(and(eq(homepageItems.id, id), eq(homepageItems.userId, userId))) - .returning(); + const [updated] = await updateReturning( + this.context, + homepageItems, + { ...updates, updatedAt }, + and(eq(homepageItems.id, id), eq(homepageItems.userId, userId)), + ); if (updated) { await this.afterWrite(); @@ -88,27 +92,27 @@ export class HomepageItemRepository { userId: string, id: number, ): Promise<{ syncId: string | null } | null> { - const rows = await this.context.drizzle - .delete(homepageItems) - .where(and(eq(homepageItems.id, id), eq(homepageItems.userId, userId))) - .returning({ syncId: homepageItems.syncId }); + const rows = await deleteReturning( + this.context, + homepageItems, + and(eq(homepageItems.id, id), eq(homepageItems.userId, userId)), + ); if (rows.length === 0) return null; await this.afterWrite(); - return rows[0]; + return { syncId: rows[0].syncId }; } async deleteByUserId(userId: string): Promise { - const rows = await this.context.drizzle + const result = await this.context.drizzle .delete(homepageItems) - .where(eq(homepageItems.userId, userId)) - .returning({ id: homepageItems.id }); + .where(eq(homepageItems.userId, userId)); - if (rows.length > 0) { + if (rowsAffected(result) > 0) { await this.afterWrite(); } - return rows.length; + return rowsAffected(result); } private async afterWrite(): Promise { diff --git a/src/backend/database/repositories/homepage-layout-repository.ts b/src/backend/database/repositories/homepage-layout-repository.ts index bb10c453..d1e2d8c8 100644 --- a/src/backend/database/repositories/homepage-layout-repository.ts +++ b/src/backend/database/repositories/homepage-layout-repository.ts @@ -1,6 +1,8 @@ import { eq } from "drizzle-orm"; import { homepageLayouts } from "../db/schema.js"; import type { DatabaseContext } from "./database-context.js"; +import { rowsAffected } from "./mutation-result.js"; +import { insertReturning, updateReturning } from "./returning.js"; export type HomepageLayoutRecord = typeof homepageLayouts.$inferSelect; @@ -28,34 +30,35 @@ export class HomepageLayoutRepository { const existing = await this.findByUserId(userId); if (!existing) { - const [created] = await this.context.drizzle - .insert(homepageLayouts) - .values({ userId, layout, updatedAt }) - .returning(); + const [created] = await insertReturning(this.context, homepageLayouts, { + userId, + layout, + updatedAt, + }); await this.afterWrite(); return created; } - const [updated] = await this.context.drizzle - .update(homepageLayouts) - .set({ layout, updatedAt }) - .where(eq(homepageLayouts.userId, userId)) - .returning(); + const [updated] = await updateReturning( + this.context, + homepageLayouts, + { layout, updatedAt }, + eq(homepageLayouts.userId, userId), + ); await this.afterWrite(); return updated; } async deleteByUserId(userId: string): Promise { - const rows = await this.context.drizzle + const result = await this.context.drizzle .delete(homepageLayouts) - .where(eq(homepageLayouts.userId, userId)) - .returning({ id: homepageLayouts.id }); + .where(eq(homepageLayouts.userId, userId)); - if (rows.length > 0) { + if (rowsAffected(result) > 0) { await this.afterWrite(); } - return rows.length; + return rowsAffected(result); } private async afterWrite(): Promise { diff --git a/src/backend/database/repositories/host-folder-repository.ts b/src/backend/database/repositories/host-folder-repository.ts index d83df5a3..f7c739e4 100644 --- a/src/backend/database/repositories/host-folder-repository.ts +++ b/src/backend/database/repositories/host-folder-repository.ts @@ -3,6 +3,12 @@ import { randomUUID } from "crypto"; import type { SQLiteColumn } from "drizzle-orm/sqlite-core"; import { hosts, sshCredentials, sshFolders } from "../db/schema.js"; import type { DatabaseContext } from "./database-context.js"; +import { rowsAffected } from "./mutation-result.js"; +import { + deleteReturning, + insertReturning, + updateReturning, +} from "./returning.js"; export type HostFolderRecord = typeof sshFolders.$inferSelect; export type HostFolderHostRecord = typeof hosts.$inferSelect; @@ -24,19 +30,31 @@ export class HostFolderRepository { newName: string, now = new Date().toISOString(), ): Promise { + // CAST target: every engine spells the text type differently enough to + // matter here — MySQL has no `text` cast and wants `char`. + const textType = this.context.dialect === "mysql" ? "char" : "text"; const oldPrefix = `${oldName} / `; const newPrefix = `${newName} / `; const childLike = `${oldPrefix}%`; + // CONCAT, not `||`: MySQL reads `||` as logical OR unless the server runs + // with PIPES_AS_CONCAT, so the child paths would have been rewritten to 0. + // No error, just wrong folder names. CONCAT and SUBSTR mean the same thing + // on all three engines. + // + // The prefix is inlined rather than bound: CONCAT is variadic, so Postgres + // cannot infer a parameter's type from its position and rejects the + // statement with 42P18 before it runs. The value is a folder name the + // caller supplied, so it goes through a bound placeholder in a plain + // concatenation instead of sql.raw. const renameExpr = (col: SQLiteColumn) => - sql`CASE WHEN ${col} = ${oldName} THEN ${newName} ELSE ${newPrefix} || substr(${col}, ${oldPrefix.length + 1}) END`; + sql`CASE WHEN ${col} = ${oldName} THEN ${newName} ELSE CONCAT(CAST(${newPrefix} AS ${sql.raw(textType)}), SUBSTR(${col}, ${sql.raw(String(oldPrefix.length + 1))})) END`; const folderMatch = (col: SQLiteColumn) => or(eq(col, oldName), like(col, childLike)); const updatedHosts = await this.context.drizzle .update(hosts) .set({ folder: renameExpr(hosts.folder), updatedAt: now }) - .where(and(eq(hosts.userId, userId), folderMatch(hosts.folder))) - .returning({ id: hosts.id }); + .where(and(eq(hosts.userId, userId), folderMatch(hosts.folder))); const updatedCredentials = await this.context.drizzle .update(sshCredentials) @@ -46,8 +64,7 @@ export class HostFolderRepository { eq(sshCredentials.userId, userId), folderMatch(sshCredentials.folder), ), - ) - .returning({ id: sshCredentials.id }); + ); await this.context.drizzle .update(sshFolders) @@ -56,8 +73,8 @@ export class HostFolderRepository { await this.afterWrite(); return { - updatedHosts: updatedHosts.length, - updatedCredentials: updatedCredentials.length, + updatedHosts: rowsAffected(updatedHosts), + updatedCredentials: rowsAffected(updatedCredentials), }; } @@ -78,35 +95,33 @@ export class HostFolderRepository { ): Promise<{ folder: HostFolderRecord; created: boolean }> { const existing = await this.findFolder(userId, name); if (existing) { - const [updated] = await this.context.drizzle - .update(sshFolders) - .set({ + const [updated] = await updateReturning( + this.context, + sshFolders, + { color, icon, credentialId: credentialId === undefined ? existing.credentialId : credentialId, updatedAt: now, - }) - .where(and(eq(sshFolders.userId, userId), eq(sshFolders.name, name))) - .returning(); + }, + and(eq(sshFolders.userId, userId), eq(sshFolders.name, name)), + ); await this.afterWrite(); return { folder: updated, created: false }; } - const [created] = await this.context.drizzle - .insert(sshFolders) - .values({ - syncId: randomUUID(), - userId, - name, - color, - icon, - credentialId: credentialId ?? null, - createdAt: now, - updatedAt: now, - }) - .returning(); + const [created] = await insertReturning(this.context, sshFolders, { + syncId: randomUUID(), + userId, + name, + color, + icon, + credentialId: credentialId ?? null, + createdAt: now, + updatedAt: now, + }); await this.afterWrite(); return { folder: created, created: true }; @@ -139,10 +154,11 @@ export class HostFolderRepository { .where(and(eq(hosts.userId, userId), folderMatch(hosts.folder))); } - const deletedFolders = await this.context.drizzle - .delete(sshFolders) - .where(and(eq(sshFolders.userId, userId), folderMatch(sshFolders.name))) - .returning({ syncId: sshFolders.syncId }); + const deletedFolders = await deleteReturning( + this.context, + sshFolders, + and(eq(sshFolders.userId, userId), folderMatch(sshFolders.name)), + ); await this.afterWrite(); @@ -157,16 +173,15 @@ export class HostFolderRepository { } async deleteByUserId(userId: string): Promise { - const rows = await this.context.drizzle + const result = await this.context.drizzle .delete(sshFolders) - .where(eq(sshFolders.userId, userId)) - .returning({ id: sshFolders.id }); + .where(eq(sshFolders.userId, userId)); - if (rows.length > 0) { + if (rowsAffected(result) > 0) { await this.afterWrite(); } - return rows.length; + return rowsAffected(result); } private async findFolder( diff --git a/src/backend/database/repositories/host-health-repository.ts b/src/backend/database/repositories/host-health-repository.ts index 5aac9308..875e1d74 100644 --- a/src/backend/database/repositories/host-health-repository.ts +++ b/src/backend/database/repositories/host-health-repository.ts @@ -1,6 +1,8 @@ -import { and, desc, eq } from "drizzle-orm"; +import { and, desc, eq, notInArray } from "drizzle-orm"; import { hostHealthChecks, hostHealthHistory } from "../db/schema.js"; import type { DatabaseContext } from "./database-context.js"; +import { rowsAffected } from "./mutation-result.js"; +import { insertReturning, updateReturning } from "./returning.js"; export type HostHealthCheckRecord = typeof hostHealthChecks.$inferSelect; export type HostHealthHistoryRecord = typeof hostHealthHistory.$inferSelect; @@ -45,27 +47,25 @@ export class HostHealthRepository { ): Promise { const existing = await this.findChecksByUserAndHost(userId, hostId); if (existing) { - const [updated] = await this.context.drizzle - .update(hostHealthChecks) - .set({ checks, intervalSeconds, updatedAt: now }) - .where(eq(hostHealthChecks.id, existing.id)) - .returning(); + const [updated] = await updateReturning( + this.context, + hostHealthChecks, + { checks, intervalSeconds, updatedAt: now }, + eq(hostHealthChecks.id, existing.id), + ); await this.afterWrite(); return updated; } - const [created] = await this.context.drizzle - .insert(hostHealthChecks) - .values({ - userId, - hostId, - checks, - intervalSeconds, - createdAt: now, - updatedAt: now, - }) - .returning(); + const [created] = await insertReturning(this.context, hostHealthChecks, { + userId, + hostId, + checks, + intervalSeconds, + createdAt: now, + updatedAt: now, + }); await this.afterWrite(); return created; @@ -94,7 +94,7 @@ export class HostHealthRepository { })), ); - this.pruneHistory(userId, hostId, keep); + await this.pruneHistory(userId, hostId, keep); await this.afterWrite(); return results.length; } @@ -121,41 +121,55 @@ export class HostHealthRepository { checksDeleted: number; historyDeleted: number; }> { - const historyRows = await this.context.drizzle + const historyResult = await this.context.drizzle .delete(hostHealthHistory) - .where(eq(hostHealthHistory.userId, userId)) - .returning({ id: hostHealthHistory.id }); + .where(eq(hostHealthHistory.userId, userId)); - const checkRows = await this.context.drizzle + const result = await this.context.drizzle .delete(hostHealthChecks) - .where(eq(hostHealthChecks.userId, userId)) - .returning({ id: hostHealthChecks.id }); + .where(eq(hostHealthChecks.userId, userId)); - if (historyRows.length > 0 || checkRows.length > 0) { + if (rowsAffected(historyResult) > 0 || rowsAffected(result) > 0) { await this.afterWrite(); } return { - checksDeleted: checkRows.length, - historyDeleted: historyRows.length, + checksDeleted: rowsAffected(result), + historyDeleted: rowsAffected(historyResult), }; } - private pruneHistory(userId: string, hostId: number, keep: number): void { - this.context.sqlite - ?.prepare( - `DELETE FROM host_health_history - WHERE id IN ( - SELECT id FROM host_health_history - WHERE user_id = ? AND host_id = ? - AND id NOT IN ( - SELECT id FROM host_health_history - WHERE user_id = ? AND host_id = ? - ORDER BY ts DESC LIMIT ? - ) - )`, - ) - .run(userId, hostId, userId, hostId, keep); + /** Keeps the newest `keep` rows for the host and drops the rest. */ + private async pruneHistory( + userId: string, + hostId: number, + keep: number, + ): Promise { + const scope = and( + eq(hostHealthHistory.userId, userId), + eq(hostHealthHistory.hostId, hostId), + ); + + const retained = await this.context.drizzle + .select({ id: hostHealthHistory.id }) + .from(hostHealthHistory) + .where(scope) + .orderBy(desc(hostHealthHistory.ts)) + .limit(keep); + + // Nothing retained means nothing to keep back, so the scope alone is the + // delete condition. + await this.context.drizzle.delete(hostHealthHistory).where( + retained.length + ? and( + scope, + notInArray( + hostHealthHistory.id, + retained.map((row) => row.id), + ), + ) + : scope, + ); } private async afterWrite(): Promise { diff --git a/src/backend/database/repositories/host-metrics-history-repository.ts b/src/backend/database/repositories/host-metrics-history-repository.ts index cbad4e2f..62bdb0e8 100644 --- a/src/backend/database/repositories/host-metrics-history-repository.ts +++ b/src/backend/database/repositories/host-metrics-history-repository.ts @@ -1,6 +1,7 @@ -import { and, asc, eq, gte, lte } from "drizzle-orm"; +import { and, asc, eq, gte, lt, lte } from "drizzle-orm"; import { hostMetricsHistory } from "../db/schema.js"; import type { DatabaseContext } from "./database-context.js"; +import { sqlTimestampDaysAgo } from "./sql-timestamp.js"; export type HostMetricsHistoryRecord = typeof hostMetricsHistory.$inferSelect; @@ -32,12 +33,15 @@ export class HostMetricsHistoryRepository { await this.afterWrite(); } - pruneOlderThan(hostId: number, retentionDays: number): void { - this.context.sqlite - ?.prepare( - "DELETE FROM host_metrics_history WHERE host_id = ? AND ts < datetime('now', ?)", - ) - .run(hostId, `-${retentionDays} days`); + async pruneOlderThan(hostId: number, retentionDays: number): Promise { + await this.context.drizzle + .delete(hostMetricsHistory) + .where( + and( + eq(hostMetricsHistory.hostId, hostId), + lt(hostMetricsHistory.ts, sqlTimestampDaysAgo(retentionDays)), + ), + ); } async listRange( diff --git a/src/backend/database/repositories/host-metrics-preference-repository.ts b/src/backend/database/repositories/host-metrics-preference-repository.ts index 070063d7..108acf8a 100644 --- a/src/backend/database/repositories/host-metrics-preference-repository.ts +++ b/src/backend/database/repositories/host-metrics-preference-repository.ts @@ -1,6 +1,8 @@ import { and, eq } from "drizzle-orm"; import { hostMetricsPreferences, hosts } from "../db/schema.js"; import type { DatabaseContext } from "./database-context.js"; +import { rowsAffected } from "./mutation-result.js"; +import { insertReturning, updateReturning } from "./returning.js"; export type HostMetricsPreferenceRecord = typeof hostMetricsPreferences.$inferSelect; @@ -37,26 +39,28 @@ export class HostMetricsPreferenceRepository { ): Promise { const existing = await this.findByUserAndHost(userId, hostId); if (existing) { - const [updated] = await this.context.drizzle - .update(hostMetricsPreferences) - .set({ layout, updatedAt: now }) - .where(eq(hostMetricsPreferences.id, existing.id)) - .returning(); + const [updated] = await updateReturning( + this.context, + hostMetricsPreferences, + { layout, updatedAt: now }, + eq(hostMetricsPreferences.id, existing.id), + ); await this.afterWrite(); return updated; } - const [created] = await this.context.drizzle - .insert(hostMetricsPreferences) - .values({ + const [created] = await insertReturning( + this.context, + hostMetricsPreferences, + { userId, hostId, layout, createdAt: now, updatedAt: now, - }) - .returning(); + }, + ); await this.afterWrite(); return created; @@ -67,28 +71,26 @@ export class HostMetricsPreferenceRepository { hostId: number, statsConfig: string, ): Promise { - const rows = await this.context.drizzle + const result = await this.context.drizzle .update(hosts) .set({ statsConfig }) - .where(and(eq(hosts.id, hostId), eq(hosts.userId, userId))) - .returning({ id: hosts.id }); + .where(and(eq(hosts.id, hostId), eq(hosts.userId, userId))); - if (rows.length === 0) return false; + if (rowsAffected(result) === 0) return false; await this.afterWrite(); return true; } async deleteByUserId(userId: string): Promise { - const rows = await this.context.drizzle + const result = await this.context.drizzle .delete(hostMetricsPreferences) - .where(eq(hostMetricsPreferences.userId, userId)) - .returning({ id: hostMetricsPreferences.id }); + .where(eq(hostMetricsPreferences.userId, userId)); - if (rows.length > 0) { + if (rowsAffected(result) > 0) { await this.afterWrite(); } - return rows.length; + return rowsAffected(result); } private async afterWrite(): Promise { diff --git a/src/backend/database/repositories/host-repository.ts b/src/backend/database/repositories/host-repository.ts index c7d2cf72..e3319444 100644 --- a/src/backend/database/repositories/host-repository.ts +++ b/src/backend/database/repositories/host-repository.ts @@ -3,6 +3,12 @@ import { randomUUID } from "crypto"; import { hostAccess, hosts } from "../db/schema.js"; import type { DatabaseContext } from "./database-context.js"; import { DataCrypto } from "../../utils/data-crypto.js"; +import { rowsAffected } from "./mutation-result.js"; +import { + deleteReturning, + insertReturning, + updateReturning, +} from "./returning.js"; export type HostRecord = typeof hosts.$inferSelect; export type NewHostRecord = typeof hosts.$inferInsert; @@ -21,10 +27,10 @@ export class HostRepository { ) {} async create(host: NewHostRecord): Promise { - const rows = await this.context.drizzle - .insert(hosts) - .values({ syncId: randomUUID(), ...host }) - .returning(); + const rows = await insertReturning(this.context, hosts, { + syncId: randomUUID(), + ...host, + }); await this.afterWrite(); return rows[0]; } @@ -51,10 +57,11 @@ export class HostRepository { delete (encryptedHost as Partial).id; } - const rows = await this.context.drizzle - .insert(hosts) - .values(encryptedHost as NewHostRecord) - .returning(); + const rows = await insertReturning( + this.context, + hosts, + encryptedHost as NewHostRecord, + ); await this.afterWrite(); return DataCrypto.decryptRecord("ssh_data", rows[0], userId, userDataKey); @@ -150,11 +157,12 @@ export class HostRepository { hostId: number, update: HostUpdate, ): Promise { - const rows = await this.context.drizzle - .update(hosts) - .set({ ...update, updatedAt: sql`CURRENT_TIMESTAMP` }) - .where(and(eq(hosts.id, hostId), eq(hosts.userId, userId))) - .returning(); + const rows = await updateReturning( + this.context, + hosts, + { ...update, updatedAt: sql`CURRENT_TIMESTAMP` }, + and(eq(hosts.id, hostId), eq(hosts.userId, userId)), + ); await this.afterWrite(); return rows[0] ?? null; @@ -173,11 +181,12 @@ export class HostRepository { userDataKey, ); - const rows = await this.context.drizzle - .update(hosts) - .set({ ...encryptedUpdate, updatedAt: sql`CURRENT_TIMESTAMP` }) - .where(and(eq(hosts.id, hostId), eq(hosts.userId, userId))) - .returning(); + const rows = await updateReturning( + this.context, + hosts, + { ...encryptedUpdate, updatedAt: sql`CURRENT_TIMESTAMP` }, + and(eq(hosts.id, hostId), eq(hosts.userId, userId)), + ); await this.afterWrite(); return rows[0] @@ -213,17 +222,16 @@ export class HostRepository { return 0; } - const rows = await this.context.drizzle + const result = await this.context.drizzle .update(hosts) .set({ ...update, updatedAt: sql`CURRENT_TIMESTAMP` }) - .where(and(inArray(hosts.id, hostIds), eq(hosts.userId, userId))) - .returning({ id: hosts.id }); + .where(and(inArray(hosts.id, hostIds), eq(hosts.userId, userId))); - if (rows.length > 0) { + if (rowsAffected(result) > 0) { await this.afterWrite(); } - return rows.length; + return rowsAffected(result); } async deleteForUser( @@ -232,39 +240,38 @@ export class HostRepository { ): Promise<{ syncId: string | null } | null> { await this.deleteAccessForHost(hostId); - const rows = await this.context.drizzle - .delete(hosts) - .where(and(eq(hosts.id, hostId), eq(hosts.userId, userId))) - .returning({ syncId: hosts.syncId }); + const rows = await deleteReturning( + this.context, + hosts, + and(eq(hosts.id, hostId), eq(hosts.userId, userId)), + ); await this.afterWrite(); - return rows[0] ?? null; + return rows[0] ? { syncId: rows[0].syncId } : null; } async deleteByUserId(userId: string): Promise { - const rows = await this.context.drizzle + const result = await this.context.drizzle .delete(hosts) - .where(eq(hosts.userId, userId)) - .returning({ id: hosts.id }); + .where(eq(hosts.userId, userId)); - if (rows.length > 0) { + if (rowsAffected(result) > 0) { await this.afterWrite(); } - return rows.length; + return rowsAffected(result); } async deleteAccessForHost(hostId: number): Promise { - const rows = await this.context.drizzle + const result = await this.context.drizzle .delete(hostAccess) - .where(eq(hostAccess.hostId, hostId)) - .returning({ id: hostAccess.id }); + .where(eq(hostAccess.hostId, hostId)); - if (rows.length > 0) { + if (rowsAffected(result) > 0) { await this.afterWrite(); } - return rows.length; + return rowsAffected(result); } private async afterWrite(): Promise { diff --git a/src/backend/database/repositories/host-resolution-repository.ts b/src/backend/database/repositories/host-resolution-repository.ts index 31926f0b..93946159 100644 --- a/src/backend/database/repositories/host-resolution-repository.ts +++ b/src/backend/database/repositories/host-resolution-repository.ts @@ -1,5 +1,5 @@ import { and, eq, inArray, isNotNull } from "drizzle-orm"; -import { hostAccess, hosts, sshCredentials, sshFolders } from "../db/schema.js"; +import { hosts, sshCredentials, sshFolders } from "../db/schema.js"; import type { DatabaseContext } from "./database-context.js"; import { DataCrypto } from "../../utils/data-crypto.js"; @@ -26,6 +26,28 @@ export interface HostListAccessEntry { permissionLevel: string; expiresAt: string | null; } + +const HOST_PERMISSION_RANK: Record = { + connect: 1, + view: 2, + edit: 3, + manage: 4, +}; + +function preferHostAccess( + current: HostListAccessEntry, + candidate: HostListAccessEntry, +): HostListAccessEntry { + const currentRank = HOST_PERMISSION_RANK[current.permissionLevel] ?? 0; + const candidateRank = HOST_PERMISSION_RANK[candidate.permissionLevel] ?? 0; + if (candidateRank !== currentRank) { + return candidateRank > currentRank ? candidate : current; + } + if (current.expiresAt === null) return current; + if (candidate.expiresAt === null) return candidate; + return candidate.expiresAt > current.expiresAt ? candidate : current; +} + export type HostListRow = HostResolutionHostRecord & { ownerId: string; isShared: boolean; @@ -103,9 +125,15 @@ export class HostResolutionRepository { .from(hosts) .where(eq(hosts.userId, userId)); - const sharedHostIds = Array.from( - new Set(accessEntries.map((access) => access.hostId)), - ); + const accessByHostId = new Map(); + for (const access of accessEntries) { + const current = accessByHostId.get(access.hostId); + accessByHostId.set( + access.hostId, + current ? preferHostAccess(current, access) : access, + ); + } + const sharedHostIds = Array.from(accessByHostId.keys()); const sharedHostRows = sharedHostIds.length > 0 ? await this.context.drizzle @@ -125,7 +153,7 @@ export class HostResolutionRepository { permissionLevel: undefined, expiresAt: undefined, })), - ...accessEntries.flatMap((access) => { + ...Array.from(accessByHostId.values()).flatMap((access) => { const host = sharedHostsById.get(access.hostId); if (!host || host.userId === userId) { return []; @@ -302,19 +330,6 @@ export class HostResolutionRepository { return this.decryptOne("ssh_credentials", rows[0], decryptUserId); } - async findOverrideCredentialId( - hostId: number, - userId: string, - ): Promise { - const rows = await this.context.drizzle - .select({ overrideCredentialId: hostAccess.overrideCredentialId }) - .from(hostAccess) - .where(and(eq(hostAccess.hostId, hostId), eq(hostAccess.userId, userId))) - .limit(1); - - return rows[0]?.overrideCredentialId ?? null; - } - /** * Resolve the nearest assigned credential for a folder path, walking up * through parent folders (e.g. "Switches / Floor1" falls back to diff --git a/src/backend/database/repositories/mutation-result.ts b/src/backend/database/repositories/mutation-result.ts new file mode 100644 index 00000000..d849c774 --- /dev/null +++ b/src/backend/database/repositories/mutation-result.ts @@ -0,0 +1,157 @@ +import type { DatabaseDialect } from "../db/dialect.js"; + +/** + * Reading the outcome of a write without depending on RETURNING. + * + * SQLite and Postgres can attach `.returning()` to a delete or update and get + * the affected rows back. **MySQL cannot** — it has no RETURNING clause, and + * drizzle's mysql-core does not expose the method at all, so the call is a + * TypeError rather than a bad query. 175 call sites here read a write's result, + * so the difference has to be absorbed somewhere. + * + * The split that matters is what the caller actually needs: + * + * - **How many rows changed** — the majority, and none of them need the rows. + * They used to ask for them anyway, via `.returning().length`. Dropping the + * `.returning()` and reading the driver's own count is both portable and one + * less thing for the database to send back. + * - **The rows themselves** — cannot be emulated on MySQL without reading + * first, which needs a transaction to stay correct under concurrency. Those + * call sites are handled individually rather than behind a helper that hides + * an extra round trip. + */ + +/** + * The count each driver reports for a write, under its own name. + * + * Every engine says how many rows a write touched. None of them agree on what + * to call it: + * + * | driver | shape | + * |----------------|----------------------------------------| + * | better-sqlite3 | `{ changes, lastInsertRowid }` | + * | node-postgres | `{ rowCount, rows, command }` | + * | mysql2 | `[{ affectedRows, insertId }, fields]` | + * + * These are the shapes returned when NO `.returning()` is attached — which is + * the portable way to write, since MySQL has no RETURNING clause at all. + */ +interface WriteHeader { + changes?: number; + rowCount?: number; + affectedRows?: number; + lastInsertRowid?: number | bigint; + insertId?: number; +} + +const COUNT_FIELDS = ["changes", "rowCount", "affectedRows"] as const; + +/** + * mysql2 hands back `[ResultSetHeader, fields]`, which is itself an array — so + * "is it an array" cannot distinguish a write header from a returning() result. + * The header is identified by carrying one of the fields above instead. + */ +function asWriteHeader(result: unknown): WriteHeader | null { + const candidate = + Array.isArray(result) && result.length > 0 ? result[0] : result; + + if (!candidate || typeof candidate !== "object") return null; + const header = candidate as WriteHeader; + + const known = + COUNT_FIELDS.some((field) => typeof header[field] === "number") || + typeof header.insertId === "number" || + typeof header.lastInsertRowid === "number" || + typeof header.lastInsertRowid === "bigint"; + + return known ? header : null; +} + +/** + * Number of rows a write touched. + * + * Pass the result of the write itself — every driver's header is understood, so + * the caller neither branches on the dialect nor attaches `.returning()` just to + * count what came back. + * + * A `.returning()` array is still accepted, for the call sites that need the + * rows for their own reasons and would rather not count them twice. + */ +export function rowsAffected(result: unknown): number { + const header = asWriteHeader(result); + if (header) { + for (const field of COUNT_FIELDS) { + const count = header[field]; + if (typeof count === "number") return count; + } + // A header with only insertId: one row went in. + return 0; + } + + if (Array.isArray(result)) return result.length; + return 0; +} + +/** + * Id assigned by an insert. + * + * **Only meaningful on the result of an insert.** SQLite's `lastInsertRowid` and + * MySQL's `insertId` are connection-level values that survive the statement that + * set them — after a delete, SQLite still reports whatever the last insert + * produced. Passing an update or delete result here gets a stale id, not null. + * + * Returns null when the table has no autoincrement key. + */ +export function insertedId(result: unknown): number | null { + const header = asWriteHeader(result); + if (header) { + // MySQL and SQLite both use 0 for "no autoincrement column". + if (typeof header.insertId === "number") { + return header.insertId > 0 ? header.insertId : null; + } + if (typeof header.lastInsertRowid === "bigint") { + return header.lastInsertRowid > 0n + ? Number(header.lastInsertRowid) + : null; + } + if (typeof header.lastInsertRowid === "number") { + return header.lastInsertRowid > 0 ? header.lastInsertRowid : null; + } + return null; + } + + if (Array.isArray(result)) { + const first = result[0] as { id?: unknown } | undefined; + return typeof first?.id === "number" ? first.id : null; + } + + return null; +} + +/** + * Whether `.returning()` can be attached to a write on this engine. + * + * Call sites that genuinely need the affected rows use this to choose between + * one statement and a read-then-write inside a transaction. + */ +export function supportsReturning(dialect: DatabaseDialect): boolean { + return dialect !== "mysql"; +} + +/** + * Reads an aggregate count as a number. + * + * `sql` is a type assertion, not a conversion. Postgres returns COUNT() + * as bigint, which node-postgres hands back as a **string** so that values past + * 2^53 survive — so the annotation is a lie there and comparisons like + * `count < max` compare a string to a number. + */ +export function countValue(value: unknown): number { + if (typeof value === "number") return value; + if (typeof value === "bigint") return Number(value); + if (typeof value === "string") { + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : 0; + } + return 0; +} diff --git a/src/backend/database/repositories/network-topology-repository.ts b/src/backend/database/repositories/network-topology-repository.ts index fa8024fb..074c442a 100644 --- a/src/backend/database/repositories/network-topology-repository.ts +++ b/src/backend/database/repositories/network-topology-repository.ts @@ -1,6 +1,7 @@ import { eq } from "drizzle-orm"; import { networkTopology } from "../db/schema.js"; import type { DatabaseContext } from "./database-context.js"; +import { rowsAffected } from "./mutation-result.js"; export type NetworkTopologyRecord = typeof networkTopology.$inferSelect; @@ -45,16 +46,15 @@ export class NetworkTopologyRepository { } async deleteByUserId(userId: string): Promise { - const rows = await this.context.drizzle + const result = await this.context.drizzle .delete(networkTopology) - .where(eq(networkTopology.userId, userId)) - .returning({ id: networkTopology.id }); + .where(eq(networkTopology.userId, userId)); - if (rows.length > 0) { + if (rowsAffected(result) > 0) { await this.afterWrite(); } - return rows.length; + return rowsAffected(result); } private async afterWrite(): Promise { diff --git a/src/backend/database/repositories/open-tab-repository.ts b/src/backend/database/repositories/open-tab-repository.ts index 5cad21de..832386c0 100644 --- a/src/backend/database/repositories/open-tab-repository.ts +++ b/src/backend/database/repositories/open-tab-repository.ts @@ -1,6 +1,7 @@ import { and, eq, gt } from "drizzle-orm"; import { userOpenTabs } from "../db/schema.js"; import type { DatabaseContext } from "./database-context.js"; +import { rowsAffected } from "./mutation-result.js"; export type OpenTabRecord = typeof userOpenTabs.$inferSelect; export type NewOpenTabRecord = typeof userOpenTabs.$inferInsert; @@ -111,43 +112,40 @@ export class OpenTabRepository { update: OpenTabUpdate, updatedAt = new Date().toISOString(), ): Promise { - const rows = await this.context.drizzle + const result = await this.context.drizzle .update(userOpenTabs) .set({ ...update, updatedAt }) - .where(and(eq(userOpenTabs.id, id), eq(userOpenTabs.userId, userId))) - .returning({ id: userOpenTabs.id }); + .where(and(eq(userOpenTabs.id, id), eq(userOpenTabs.userId, userId))); - if (rows.length > 0) { + if (rowsAffected(result) > 0) { await this.afterWrite(); } - return rows.length > 0; + return rowsAffected(result) > 0; } async deleteForUser(userId: string, id: string): Promise { - const rows = await this.context.drizzle + const result = await this.context.drizzle .delete(userOpenTabs) - .where(and(eq(userOpenTabs.id, id), eq(userOpenTabs.userId, userId))) - .returning({ id: userOpenTabs.id }); + .where(and(eq(userOpenTabs.id, id), eq(userOpenTabs.userId, userId))); - if (rows.length > 0) { + if (rowsAffected(result) > 0) { await this.afterWrite(); } - return rows.length; + return rowsAffected(result); } async deleteByUserId(userId: string): Promise { - const rows = await this.context.drizzle + const result = await this.context.drizzle .delete(userOpenTabs) - .where(eq(userOpenTabs.userId, userId)) - .returning({ id: userOpenTabs.id }); + .where(eq(userOpenTabs.userId, userId)); - if (rows.length > 0) { + if (rowsAffected(result) > 0) { await this.afterWrite(); } - return rows.length; + return rowsAffected(result); } private async findByIdForUser( diff --git a/src/backend/database/repositories/opkssh-token-repository.ts b/src/backend/database/repositories/opkssh-token-repository.ts index 0c15bddd..e802ef88 100644 --- a/src/backend/database/repositories/opkssh-token-repository.ts +++ b/src/backend/database/repositories/opkssh-token-repository.ts @@ -1,6 +1,8 @@ import { and, eq } from "drizzle-orm"; import { opksshTokens } from "../db/schema.js"; import type { DatabaseContext } from "./database-context.js"; +import { rowsAffected } from "./mutation-result.js"; +import { upsert } from "./returning.js"; export type OpksshTokenRecord = typeof opksshTokens.$inferSelect; @@ -26,9 +28,10 @@ export class OpksshTokenRepository { async upsert(input: OpksshTokenUpsertInput): Promise { const createdAt = input.createdAt ?? new Date().toISOString(); - await this.context.drizzle - .insert(opksshTokens) - .values({ + await upsert( + this.context, + opksshTokens, + { userId: input.userId, hostId: input.hostId, sshCert: input.sshCert, @@ -38,8 +41,8 @@ export class OpksshTokenRepository { issuer: input.issuer, audience: input.audience, expiresAt: input.expiresAt, - }) - .onConflictDoUpdate({ + }, + { target: [opksshTokens.userId, opksshTokens.hostId], set: { sshCert: input.sshCert, @@ -51,7 +54,8 @@ export class OpksshTokenRepository { expiresAt: input.expiresAt, createdAt, }, - }); + }, + ); await this.afterWrite(); } @@ -76,47 +80,44 @@ export class OpksshTokenRepository { hostId: number, lastUsed = new Date().toISOString(), ): Promise { - const rows = await this.context.drizzle + const result = await this.context.drizzle .update(opksshTokens) .set({ lastUsed }) .where( and(eq(opksshTokens.userId, userId), eq(opksshTokens.hostId, hostId)), - ) - .returning({ id: opksshTokens.id }); + ); - if (rows.length > 0) { + if (rowsAffected(result) > 0) { await this.afterWrite(); } - return rows.length > 0; + return rowsAffected(result) > 0; } async deleteByUserAndHost(userId: string, hostId: number): Promise { - const rows = await this.context.drizzle + const result = await this.context.drizzle .delete(opksshTokens) .where( and(eq(opksshTokens.userId, userId), eq(opksshTokens.hostId, hostId)), - ) - .returning({ id: opksshTokens.id }); + ); - if (rows.length > 0) { + if (rowsAffected(result) > 0) { await this.afterWrite(); } - return rows.length > 0; + return rowsAffected(result) > 0; } async deleteByUserId(userId: string): Promise { - const rows = await this.context.drizzle + const result = await this.context.drizzle .delete(opksshTokens) - .where(eq(opksshTokens.userId, userId)) - .returning({ id: opksshTokens.id }); + .where(eq(opksshTokens.userId, userId)); - if (rows.length > 0) { + if (rowsAffected(result) > 0) { await this.afterWrite(); } - return rows.length; + return rowsAffected(result); } private async afterWrite(): Promise { diff --git a/src/backend/database/repositories/rbac-access-repository.ts b/src/backend/database/repositories/rbac-access-repository.ts index 92a2463e..ec8aafe7 100644 --- a/src/backend/database/repositories/rbac-access-repository.ts +++ b/src/backend/database/repositories/rbac-access-repository.ts @@ -9,6 +9,8 @@ import { users, } from "../db/schema.js"; import type { DatabaseContext } from "./database-context.js"; +import { rowsAffected } from "./mutation-result.js"; +import { insertReturning } from "./returning.js"; export type RbacAccessTargetType = "user" | "role"; @@ -156,7 +158,7 @@ export class RbacAccessRepository { return { id: existing.id, created: false }; } - const result = await this.context.drizzle.insert(hostAccess).values({ + const [created] = await insertReturning(this.context, hostAccess, { hostId: input.hostId, userId: input.targetType === "user" ? input.targetUserId : null, roleId: input.targetType === "role" ? input.targetRoleId : null, @@ -166,7 +168,7 @@ export class RbacAccessRepository { }); await this.afterWrite(); - return { id: Number(result.lastInsertRowid), created: true }; + return { id: created.id, created: true }; } async revokeHostAccess(accessId: number, hostId: number): Promise { @@ -177,16 +179,15 @@ export class RbacAccessRepository { } async deleteHostAccessForHost(hostId: number): Promise { - const rows = await this.context.drizzle + const result = await this.context.drizzle .delete(hostAccess) - .where(eq(hostAccess.hostId, hostId)) - .returning({ id: hostAccess.id }); + .where(eq(hostAccess.hostId, hostId)); - if (rows.length > 0) { + if (rowsAffected(result) > 0) { await this.afterWrite(); } - return rows.length; + return rowsAffected(result); } async deleteHostAccessForHosts(hostIds: number[]): Promise { @@ -194,30 +195,27 @@ export class RbacAccessRepository { return 0; } - const rows = await this.context.drizzle + const result = await this.context.drizzle .delete(hostAccess) - .where(inArray(hostAccess.hostId, hostIds)) - .returning({ id: hostAccess.id }); + .where(inArray(hostAccess.hostId, hostIds)); - if (rows.length > 0) { + if (rowsAffected(result) > 0) { await this.afterWrite(); } - return rows.length; + return rowsAffected(result); } async deleteHostAccessForUserReferences(userId: string): Promise { - const directRows = await this.context.drizzle + const directResult = await this.context.drizzle .delete(hostAccess) - .where(eq(hostAccess.userId, userId)) - .returning({ id: hostAccess.id }); + .where(eq(hostAccess.userId, userId)); - const grantedRows = await this.context.drizzle + const result = await this.context.drizzle .delete(hostAccess) - .where(eq(hostAccess.grantedBy, userId)) - .returning({ id: hostAccess.id }); + .where(eq(hostAccess.grantedBy, userId)); - const deletedCount = directRows.length + grantedRows.length; + const deletedCount = rowsAffected(directResult) + rowsAffected(result); if (deletedCount > 0) { await this.afterWrite(); } @@ -238,17 +236,6 @@ export class RbacAccessRepository { return rows[0] ?? null; } - async updateHostAccessOverrideCredential( - accessId: number, - credentialId: number | null, - ): Promise { - await this.context.drizzle - .update(hostAccess) - .set({ overrideCredentialId: credentialId }) - .where(eq(hostAccess.id, accessId)); - await this.afterWrite(); - } - async listSnippetAccess(snippetId: number): Promise { const rows = await this.context.drizzle .select({ @@ -291,7 +278,7 @@ export class RbacAccessRepository { return { id: existing.id, created: false }; } - const result = await this.context.drizzle.insert(snippetAccess).values({ + const [created] = await insertReturning(this.context, snippetAccess, { snippetId: input.snippetId, userId: input.targetType === "user" ? input.targetUserId : null, roleId: input.targetType === "role" ? input.targetRoleId : null, @@ -301,7 +288,7 @@ export class RbacAccessRepository { }); await this.afterWrite(); - return { id: Number(result.lastInsertRowid), created: true }; + return { id: created.id, created: true }; } async revokeSnippetAccess( @@ -512,21 +499,20 @@ export class RbacAccessRepository { async deleteExpiredHostAccess( now = new Date().toISOString(), ): Promise { - const rows = await this.context.drizzle + const result = await this.context.drizzle .delete(hostAccess) .where( and( sql`${hostAccess.expiresAt} IS NOT NULL`, sql`${hostAccess.expiresAt} <= ${now}`, ), - ) - .returning({ id: hostAccess.id }); + ); - if (rows.length > 0) { + if (rowsAffected(result) > 0) { await this.afterWrite(); } - return rows.length; + return rowsAffected(result); } async findActiveHostAccess( @@ -635,17 +621,16 @@ export class RbacAccessRepository { hostId: number, update: { permissionLevel?: string; expiresAt?: string | null }, ): Promise { - const rows = await this.context.drizzle + const result = await this.context.drizzle .update(hostAccess) .set(update) - .where(and(eq(hostAccess.id, accessId), eq(hostAccess.hostId, hostId))) - .returning({ id: hostAccess.id }); + .where(and(eq(hostAccess.id, accessId), eq(hostAccess.hostId, hostId))); - if (rows.length > 0) { + if (rowsAffected(result) > 0) { await this.afterWrite(); } - return rows.length > 0; + return rowsAffected(result) > 0; } async findHostAccessOwnerId(hostAccessId: number): Promise { diff --git a/src/backend/database/repositories/recent-activity-repository.ts b/src/backend/database/repositories/recent-activity-repository.ts index bd7979fc..30b5fd09 100644 --- a/src/backend/database/repositories/recent-activity-repository.ts +++ b/src/backend/database/repositories/recent-activity-repository.ts @@ -1,6 +1,8 @@ import { desc, eq, inArray } from "drizzle-orm"; import { recentActivity } from "../db/schema.js"; import type { DatabaseContext } from "./database-context.js"; +import { rowsAffected } from "./mutation-result.js"; +import { insertReturning } from "./returning.js"; export type RecentActivityRecord = typeof recentActivity.$inferSelect; export type NewRecentActivityRecord = typeof recentActivity.$inferInsert; @@ -26,10 +28,7 @@ export class RecentActivityRepository { async create( activity: NewRecentActivityRecord, ): Promise { - const rows = await this.context.drizzle - .insert(recentActivity) - .values(activity) - .returning(); + const rows = await insertReturning(this.context, recentActivity, activity); await this.afterWrite(); return rows[0]; @@ -51,42 +50,39 @@ export class RecentActivityRepository { return 0; } - const deletedRows = await this.context.drizzle + const result = await this.context.drizzle .delete(recentActivity) - .where(inArray(recentActivity.id, idsToDelete)) - .returning({ id: recentActivity.id }); + .where(inArray(recentActivity.id, idsToDelete)); - if (deletedRows.length > 0) { + if (rowsAffected(result) > 0) { await this.afterWrite(); } - return deletedRows.length; + return rowsAffected(result); } async deleteByUserId(userId: string): Promise { - const rows = await this.context.drizzle + const result = await this.context.drizzle .delete(recentActivity) - .where(eq(recentActivity.userId, userId)) - .returning({ id: recentActivity.id }); + .where(eq(recentActivity.userId, userId)); - if (rows.length > 0) { + if (rowsAffected(result) > 0) { await this.afterWrite(); } - return rows.length; + return rowsAffected(result); } async deleteByHostId(hostId: number): Promise { - const rows = await this.context.drizzle + const result = await this.context.drizzle .delete(recentActivity) - .where(eq(recentActivity.hostId, hostId)) - .returning({ id: recentActivity.id }); + .where(eq(recentActivity.hostId, hostId)); - if (rows.length > 0) { + if (rowsAffected(result) > 0) { await this.afterWrite(); } - return rows.length; + return rowsAffected(result); } async deleteByHostIds(hostIds: number[]): Promise { @@ -94,16 +90,15 @@ export class RecentActivityRepository { return 0; } - const rows = await this.context.drizzle + const result = await this.context.drizzle .delete(recentActivity) - .where(inArray(recentActivity.hostId, hostIds)) - .returning({ id: recentActivity.id }); + .where(inArray(recentActivity.hostId, hostIds)); - if (rows.length > 0) { + if (rowsAffected(result) > 0) { await this.afterWrite(); } - return rows.length; + return rowsAffected(result); } private async afterWrite(): Promise { diff --git a/src/backend/database/repositories/returning.ts b/src/backend/database/repositories/returning.ts new file mode 100644 index 00000000..fe2e05b1 --- /dev/null +++ b/src/backend/database/repositories/returning.ts @@ -0,0 +1,227 @@ +import { eq, type SQL } from "drizzle-orm"; +import type { SQLiteColumn, SQLiteTable } from "drizzle-orm/sqlite-core"; +import type { DatabaseContext } from "./database-context.js"; +import { + insertedId, + rowsAffected, + supportsReturning, +} from "./mutation-result.js"; + +/** + * Writes that need the affected rows back. + * + * `mutation-result.ts` covers the call sites that only wanted a count. These are + * the ones that genuinely read the rows — an updated record to return to the + * caller, a deleted row's fields to clean up alongside it. + * + * SQLite and Postgres do this in one statement with RETURNING. MySQL has no + * such clause, so the read is a second statement, and the pair has to be atomic: + * + * - **insert** — write, then read the row back by its key. + * - **update** — write, then read. Reading first would return the old values. + * - **delete** — read, then write. Reading after would return nothing. + * + * Both run in a transaction. Without one, a concurrent write between the two + * statements makes the returned rows describe a state that never existed, and + * with a connection pool the second statement might not even reach the same + * connection. + * + * ## The trap, and why it cannot bite silently + * + * On MySQL the update path re-reads using the same `where`. If the update + * changes a column that `where` tests, the read finds nothing — SQLite would + * have returned the row. Every current caller filters on an id it does not + * modify, but that is a convention, not a guarantee, so the mismatch is + * detected and thrown rather than returned as an empty array. Same for an + * insert whose row cannot be read back. + * + * Row types come from the table, so call sites keep the typing they had with + * `.returning()` and nothing has to be annotated by hand. + */ + +/** + * What `.set()` accepts: a column's own type, or a SQL expression in its place — + * `updatedAt: sql`CURRENT_TIMESTAMP`` is the common one here. + */ +type UpdateValues = { + [K in keyof T["$inferInsert"]]?: T["$inferInsert"][K] | SQL; +}; + +export async function updateReturning( + context: DatabaseContext, + table: T, + values: UpdateValues, + where: SQL, +): Promise { + const db = context.drizzle; + + if (supportsReturning(context.dialect)) { + // The cast resolves a conditional in drizzle's return type that TypeScript + // cannot narrow while T is still generic. The runtime shape is the rows. + return db.update(table).set(values).where(where).returning() as Promise< + T["$inferSelect"][] + >; + } + + return db.transaction(async (tx) => { + const written = await tx.update(table).set(values).where(where); + const rows = await tx.select().from(table).where(where); + + // The trap this catches: if the update changed a column that `where` tests, + // the read finds nothing and the caller gets [] — on MySQL only, with no + // error, where SQLite would have returned the row. Rows changed but none + // readable back is exactly that case, so make it loud instead. + if (rows.length === 0 && rowsAffected(written) > 0) { + throw new Error( + `updateReturning wrote ${rowsAffected(written)} row(s) but could not read ` + + `them back: the update changed a column the where clause filters on. ` + + `Read the rows first, or filter on a column the update leaves alone.`, + ); + } + + return rows; + }); +} + +export async function deleteReturning( + context: DatabaseContext, + table: T, + where: SQL, +): Promise { + const db = context.drizzle; + + if (supportsReturning(context.dialect)) { + return db.delete(table).where(where).returning() as Promise< + T["$inferSelect"][] + >; + } + + return db.transaction(async (tx) => { + const rows = await tx.select().from(table).where(where); + await tx.delete(table).where(where); + return rows; + }); +} + +/** A table this can read a single row back from. */ +type Keyed = SQLiteTable & { id: SQLiteColumn }; + +/** + * Inserts one row and returns it as stored, including whatever the database + * filled in — defaults, an autoincrement id, a CURRENT_TIMESTAMP. + * + * This is the one case Postgres cannot shortcut either: without RETURNING there + * is no id to read back by. Hence the split is genuinely three-way — except + * that sqlite and pg both have RETURNING, so it collapses to two again. + * + * On MySQL the key comes from one of two places: + * + * - the caller supplied it (tables keyed by a text id, like `users`) + * - the engine assigned it, reported as `insertId` + * + * Restricted to tables with an `id` column, so a table keyed some other way is + * a compile error here rather than a row that silently fails to come back. + */ +export async function insertReturning( + context: DatabaseContext, + table: T, + values: T["$inferInsert"], +): Promise { + const db = context.drizzle; + + if (supportsReturning(context.dialect)) { + return db.insert(table).values(values).returning() as Promise< + T["$inferSelect"][] + >; + } + + return db.transaction(async (tx) => { + const result = await tx.insert(table).values(values); + + const supplied = (values as { id?: string | number }).id; + const key = supplied ?? insertedId(result); + if (key === null || key === undefined) { + throw new Error( + `Insert into ${String(table)} returned no id to read the row back by.`, + ); + } + + const rows = await tx.select().from(table).where(eq(table.id, key)); + if (rows.length === 0) { + throw new Error( + `Inserted into ${String(table)} but could not read the row back by id ${key}.`, + ); + } + return rows; + }); +} + +/** + * Inserts one row into a table keyed by something other than `id`, reading it + * back by an explicit condition. + * + * `user_preferences` is keyed by `userId` and has no `id` column at all, so + * there is no insertId to read back by — the caller has to say what identifies + * the row it just wrote. + */ +export async function insertReturningWhere( + context: DatabaseContext, + table: T, + values: T["$inferInsert"], + where: SQL, +): Promise { + const db = context.drizzle; + + if (supportsReturning(context.dialect)) { + return db.insert(table).values(values).returning() as Promise< + T["$inferSelect"][] + >; + } + + return db.transaction(async (tx) => { + await tx.insert(table).values(values); + const rows = await tx.select().from(table).where(where); + if (rows.length === 0) { + throw new Error( + `Inserted into ${String(table)} but the read-back condition matched nothing.`, + ); + } + return rows; + }); +} + +/** + * Insert, or update the row that collides with it. + * + * The clause has three spellings. SQLite and Postgres take + * `ON CONFLICT (cols) DO UPDATE`; **MySQL takes `ON DUPLICATE KEY UPDATE` and + * names no columns** — it uses whichever unique key was violated. drizzle + * follows suit, so `onConflictDoUpdate` does not exist on mysql-core at all and + * calling it is a TypeError rather than a rejected query. + * + * The conflict target still has to be passed: it is what SQLite and Postgres + * need, and stating it keeps the caller honest about which unique constraint it + * is relying on — four of those were missing from the schema entirely until the + * cross-dialect tests went looking. + */ +export async function upsert( + context: DatabaseContext, + table: T, + values: T["$inferInsert"], + conflict: { target: SQLiteColumn[]; set: UpdateValues }, +): Promise { + const db = context.drizzle; + + if (context.dialect === "mysql") { + const insert = db.insert(table).values(values) as unknown as { + onDuplicateKeyUpdate: (config: { set: UpdateValues }) => Promise; + }; + await insert.onDuplicateKeyUpdate({ set: conflict.set }); + return; + } + + await db + .insert(table) + .values(values) + .onConflictDoUpdate({ target: conflict.target, set: conflict.set }); +} diff --git a/src/backend/database/repositories/role-repository.ts b/src/backend/database/repositories/role-repository.ts index 47f53d76..3790f238 100644 --- a/src/backend/database/repositories/role-repository.ts +++ b/src/backend/database/repositories/role-repository.ts @@ -1,6 +1,8 @@ import { and, eq, inArray } from "drizzle-orm"; import { hostAccess, roles, userRoles } from "../db/schema.js"; import type { DatabaseContext } from "./database-context.js"; +import { rowsAffected } from "./mutation-result.js"; +import { deleteReturning, insertReturning } from "./returning.js"; export type RoleRecord = typeof roles.$inferSelect; export type NewRoleRecord = typeof roles.$inferInsert; @@ -62,27 +64,27 @@ export class RoleRepository { } async createRole(role: NewRoleRecord): Promise { - const result = await this.context.drizzle.insert(roles).values(role); + const [created] = await insertReturning(this.context, roles, role); await this.afterWrite(); - return Number(result.lastInsertRowid); + return created.id; } async updateRole(id: number, update: RoleUpdate): Promise { - const rows = await this.context.drizzle + const result = await this.context.drizzle .update(roles) .set(update) - .where(eq(roles.id, id)) - .returning({ id: roles.id }); + .where(eq(roles.id, id)); await this.afterWrite(); - return rows.length > 0; + return rowsAffected(result) > 0; } async deleteRole(id: number): Promise<{ deletedUserIds: string[] }> { - const deletedUserRoles = await this.context.drizzle - .delete(userRoles) - .where(eq(userRoles.roleId, id)) - .returning({ userId: userRoles.userId }); + const deletedUserRoles = await deleteReturning( + this.context, + userRoles, + eq(userRoles.roleId, id), + ); await this.context.drizzle .delete(hostAccess) @@ -169,16 +171,15 @@ export class RoleRepository { } if (removeRole) { - const rows = await this.context.drizzle + const result = await this.context.drizzle .delete(userRoles) .where( and( eq(userRoles.userId, input.userId), eq(userRoles.roleId, removeRole.id), ), - ) - .returning({ id: userRoles.id }); - removed = rows.length > 0; + ); + removed = rowsAffected(result) > 0; } if (added || removed) { @@ -196,16 +197,15 @@ export class RoleRepository { } async removeAllRolesFromUser(userId: string): Promise { - const rows = await this.context.drizzle + const result = await this.context.drizzle .delete(userRoles) - .where(eq(userRoles.userId, userId)) - .returning({ id: userRoles.id }); + .where(eq(userRoles.userId, userId)); - if (rows.length > 0) { + if (rowsAffected(result) > 0) { await this.afterWrite(); } - return rows.length; + return rowsAffected(result); } async listUserRoleIds(userId: string): Promise { diff --git a/src/backend/database/repositories/session-recording-repository.ts b/src/backend/database/repositories/session-recording-repository.ts index fa3efc10..7961e473 100644 --- a/src/backend/database/repositories/session-recording-repository.ts +++ b/src/backend/database/repositories/session-recording-repository.ts @@ -1,6 +1,8 @@ import { and, desc, eq, inArray, lt } from "drizzle-orm"; import { hosts, sessionRecordings } from "../db/schema.js"; import type { DatabaseContext } from "./database-context.js"; +import { rowsAffected } from "./mutation-result.js"; +import { insertReturning } from "./returning.js"; export type SessionRecordingRecord = typeof sessionRecordings.$inferSelect; @@ -47,10 +49,11 @@ export class SessionRecordingRepository { async create( input: SessionRecordingCreateInput, ): Promise { - const [created] = await this.context.drizzle - .insert(sessionRecordings) - .values(input) - .returning(); + const [created] = await insertReturning( + this.context, + sessionRecordings, + input, + ); await this.afterWrite(); return created; @@ -170,57 +173,71 @@ export class SessionRecordingRepository { } async deleteById(id: number): Promise { - const rows = await this.context.drizzle + const result = await this.context.drizzle .delete(sessionRecordings) - .where(eq(sessionRecordings.id, id)) - .returning({ id: sessionRecordings.id }); + .where(eq(sessionRecordings.id, id)); - if (rows.length > 0) { + if (rowsAffected(result) > 0) { await this.afterWrite(); } - return rows.length > 0; + return rowsAffected(result) > 0; } async deleteForUser(userId: string, id: number): Promise { - const rows = await this.context.drizzle + const result = await this.context.drizzle .delete(sessionRecordings) .where( and(eq(sessionRecordings.id, id), eq(sessionRecordings.userId, userId)), - ) - .returning({ id: sessionRecordings.id }); + ); - if (rows.length > 0) { + if (rowsAffected(result) > 0) { await this.afterWrite(); } - return rows.length > 0; + return rowsAffected(result) > 0; + } + + /** + * Detaches recordings from a user being deleted instead of removing them. + * A recording is evidence about the host as much as about the person, and the + * file stays on disk regardless — deleting only the row would orphan it. + */ + async anonymizeByUserId(userId: string): Promise { + const result = await this.context.drizzle + .update(sessionRecordings) + .set({ userId: null }) + .where(eq(sessionRecordings.userId, userId)); + + if (rowsAffected(result) > 0) { + await this.afterWrite(); + } + + return rowsAffected(result); } async deleteByUserId(userId: string): Promise { - const rows = await this.context.drizzle + const result = await this.context.drizzle .delete(sessionRecordings) - .where(eq(sessionRecordings.userId, userId)) - .returning({ id: sessionRecordings.id }); + .where(eq(sessionRecordings.userId, userId)); - if (rows.length > 0) { + if (rowsAffected(result) > 0) { await this.afterWrite(); } - return rows.length; + return rowsAffected(result); } async deleteByHostId(hostId: number): Promise { - const rows = await this.context.drizzle + const result = await this.context.drizzle .delete(sessionRecordings) - .where(eq(sessionRecordings.hostId, hostId)) - .returning({ id: sessionRecordings.id }); + .where(eq(sessionRecordings.hostId, hostId)); - if (rows.length > 0) { + if (rowsAffected(result) > 0) { await this.afterWrite(); } - return rows.length; + return rowsAffected(result); } async deleteByHostIds(hostIds: number[]): Promise { @@ -228,16 +245,15 @@ export class SessionRecordingRepository { return 0; } - const rows = await this.context.drizzle + const result = await this.context.drizzle .delete(sessionRecordings) - .where(inArray(sessionRecordings.hostId, hostIds)) - .returning({ id: sessionRecordings.id }); + .where(inArray(sessionRecordings.hostId, hostIds)); - if (rows.length > 0) { + if (rowsAffected(result) > 0) { await this.afterWrite(); } - return rows.length; + return rowsAffected(result); } private async afterWrite(): Promise { diff --git a/src/backend/database/repositories/session-repository.ts b/src/backend/database/repositories/session-repository.ts index ff83c46e..6379a214 100644 --- a/src/backend/database/repositories/session-repository.ts +++ b/src/backend/database/repositories/session-repository.ts @@ -1,6 +1,8 @@ import { and, eq, lte, ne } from "drizzle-orm"; import { sessions } from "../db/schema.js"; import type { DatabaseContext } from "./database-context.js"; +import { rowsAffected } from "./mutation-result.js"; +import { insertReturning } from "./returning.js"; export type SessionRecord = typeof sessions.$inferSelect; export type NewSessionRecord = typeof sessions.$inferInsert; @@ -12,10 +14,7 @@ export class SessionRepository { ) {} async create(session: NewSessionRecord): Promise { - const rows = await this.context.drizzle - .insert(sessions) - .values(session) - .returning(); + const rows = await insertReturning(this.context, sessions, session); await this.afterWrite(); return rows[0]; } @@ -72,13 +71,12 @@ export class SessionRepository { } async revoke(id: string): Promise { - const rows = await this.context.drizzle + const result = await this.context.drizzle .delete(sessions) - .where(eq(sessions.id, id)) - .returning({ id: sessions.id }); + .where(eq(sessions.id, id)); await this.afterWrite(); - return rows.length > 0; + return rowsAffected(result) > 0; } async revokeAllForUser( @@ -89,23 +87,19 @@ export class SessionRepository { ? and(eq(sessions.userId, userId), ne(sessions.id, exceptSessionId)) : eq(sessions.userId, userId); - const rows = await this.context.drizzle - .delete(sessions) - .where(where) - .returning({ id: sessions.id }); + const result = await this.context.drizzle.delete(sessions).where(where); await this.afterWrite(); - return rows.length; + return rowsAffected(result); } async deleteExpired(now = new Date()): Promise { - const rows = await this.context.drizzle + const result = await this.context.drizzle .delete(sessions) - .where(lte(sessions.expiresAt, now.toISOString())) - .returning({ id: sessions.id }); + .where(lte(sessions.expiresAt, now.toISOString())); await this.afterWrite(); - return rows.length; + return rowsAffected(result); } private async afterWrite(): Promise { diff --git a/src/backend/database/repositories/session-share-repository.ts b/src/backend/database/repositories/session-share-repository.ts index 016c44f1..39a8495b 100644 --- a/src/backend/database/repositories/session-share-repository.ts +++ b/src/backend/database/repositories/session-share-repository.ts @@ -6,6 +6,8 @@ import { users, } from "../db/schema.js"; import type { DatabaseContext } from "./database-context.js"; +import { rowsAffected } from "./mutation-result.js"; +import { insertReturning } from "./returning.js"; export type SessionShareRecord = typeof sessionShares.$inferSelect; export type SessionShareParticipantRecord = @@ -49,22 +51,19 @@ export class SessionShareRepository { ) {} async create(input: SessionShareCreateInput): Promise { - const [created] = await this.context.drizzle - .insert(sessionShares) - .values({ - id: input.id, - hostId: input.hostId, - ownerUserId: input.ownerUserId, - protocol: input.protocol, - sessionId: input.sessionId, - tabInstanceId: input.tabInstanceId ?? null, - shareType: input.shareType, - targetUserId: input.targetUserId ?? null, - linkToken: input.linkToken ?? null, - permissionLevel: input.permissionLevel, - expiresAt: input.expiresAt, - }) - .returning(); + const [created] = await insertReturning(this.context, sessionShares, { + id: input.id, + hostId: input.hostId, + ownerUserId: input.ownerUserId, + protocol: input.protocol, + sessionId: input.sessionId, + tabInstanceId: input.tabInstanceId ?? null, + shareType: input.shareType, + targetUserId: input.targetUserId ?? null, + linkToken: input.linkToken ?? null, + permissionLevel: input.permissionLevel, + expiresAt: input.expiresAt, + }); await this.afterWrite(); return created; @@ -151,7 +150,7 @@ export class SessionShareRepository { } async revoke(shareId: string, requestingUserId: string): Promise { - const rows = await this.context.drizzle + const result = await this.context.drizzle .update(sessionShares) .set({ revokedAt: new Date().toISOString() }) .where( @@ -159,38 +158,35 @@ export class SessionShareRepository { eq(sessionShares.id, shareId), eq(sessionShares.ownerUserId, requestingUserId), ), - ) - .returning({ id: sessionShares.id }); + ); - if (rows.length > 0) { + if (rowsAffected(result) > 0) { await this.afterWrite(); } - return rows.length > 0; + return rowsAffected(result) > 0; } async revokeAsAdmin(shareId: string): Promise { - const rows = await this.context.drizzle + const result = await this.context.drizzle .update(sessionShares) .set({ revokedAt: new Date().toISOString() }) - .where(eq(sessionShares.id, shareId)) - .returning({ id: sessionShares.id }); + .where(eq(sessionShares.id, shareId)); - if (rows.length > 0) { + if (rowsAffected(result) > 0) { await this.afterWrite(); } - return rows.length > 0; + return rowsAffected(result) > 0; } async deleteExpiredShares(now = new Date().toISOString()): Promise { - const rows = await this.context.drizzle + const result = await this.context.drizzle .delete(sessionShares) - .where(lt(sessionShares.expiresAt, now)) - .returning({ id: sessionShares.id }); + .where(lt(sessionShares.expiresAt, now)); - if (rows.length > 0) { + if (rowsAffected(result) > 0) { await this.afterWrite(); } - return rows.length; + return rowsAffected(result); } async touchShareUsage( @@ -213,10 +209,11 @@ export class SessionShareRepository { userId: string | null, guestLabel: string | null, ): Promise { - const [created] = await this.context.drizzle - .insert(sessionShareParticipants) - .values({ shareId, userId, guestLabel }) - .returning(); + const [created] = await insertReturning( + this.context, + sessionShareParticipants, + { shareId, userId, guestLabel }, + ); await this.afterWrite(); return created; } @@ -230,15 +227,14 @@ export class SessionShareRepository { } async deleteSharesForHost(hostId: number): Promise { - const rows = await this.context.drizzle + const result = await this.context.drizzle .delete(sessionShares) - .where(eq(sessionShares.hostId, hostId)) - .returning({ id: sessionShares.id }); + .where(eq(sessionShares.hostId, hostId)); - if (rows.length > 0) { + if (rowsAffected(result) > 0) { await this.afterWrite(); } - return rows.length; + return rowsAffected(result); } private async afterWrite(): Promise { diff --git a/src/backend/database/repositories/settings-cache.ts b/src/backend/database/repositories/settings-cache.ts new file mode 100644 index 00000000..a8b43f81 --- /dev/null +++ b/src/backend/database/repositories/settings-cache.ts @@ -0,0 +1,52 @@ +/** + * Synchronous read-through cache for the settings table. + * + * 27 call sites read settings synchronously — during startup, inside request + * handlers, and from the guacd server bootstrap. On SQLite that works because + * better-sqlite3 is synchronous; on Postgres or MySQL there is no synchronous + * query at all, and making all 27 async would push `await` through code paths + * that have no business being asynchronous. + * + * Settings are a handful of low-cardinality configuration rows that change + * rarely and are read constantly, so they are cached in full. Writes go through + * SettingsRepository, which updates the cache in the same call, and the cache is + * primed once at startup. + */ + +let cache: Map | null = null; + +export function isSettingsCachePrimed(): boolean { + return cache !== null; +} + +/** Loads the full settings table. Called once during startup. */ +export function primeSettingsCache( + rows: { key: string; value: string }[], +): void { + cache = new Map(rows.map((row) => [row.key, row.value])); +} + +/** + * Reads a cached setting. + * + * Returns null both for "not set" and "cache not primed yet" — every caller + * already treats a missing setting as "use the default", and startup ordering + * means a read before priming should behave the same way rather than throw. + */ +export function readCachedSetting(key: string): string | null { + return cache?.get(key) ?? null; +} + +/** Keeps the cache in step with a write. */ +export function updateCachedSetting(key: string, value: string): void { + cache?.set(key, value); +} + +export function forgetCachedSetting(key: string): void { + cache?.delete(key); +} + +/** Test seam. */ +export function resetSettingsCache(): void { + cache = null; +} diff --git a/src/backend/database/repositories/settings-repository.ts b/src/backend/database/repositories/settings-repository.ts index 3b69fbbc..ac043aab 100644 --- a/src/backend/database/repositories/settings-repository.ts +++ b/src/backend/database/repositories/settings-repository.ts @@ -1,6 +1,8 @@ import { eq, like } from "drizzle-orm"; import { settings } from "../db/schema.js"; import type { DatabaseContext } from "./database-context.js"; +import { forgetCachedSetting, updateCachedSetting } from "./settings-cache.js"; +import { deleteReturning } from "./returning.js"; export class SettingsRepository { constructor( @@ -34,6 +36,9 @@ export class SettingsRepository { const existing = await this.get(key); if (existing === null) { await this.context.drizzle.insert(settings).values({ key, value }); + // Kept in step here so the synchronous readers cannot observe a stale + // value after a write in the same process. + updateCachedSetting(key, value); await this.afterWrite(); return; } @@ -42,6 +47,7 @@ export class SettingsRepository { .update(settings) .set({ value }) .where(eq(settings.key, key)); + updateCachedSetting(key, value); await this.afterWrite(); } @@ -51,14 +57,17 @@ export class SettingsRepository { async delete(key: string): Promise { await this.context.drizzle.delete(settings).where(eq(settings.key, key)); + forgetCachedSetting(key); await this.afterWrite(); } async deleteLike(pattern: string): Promise { - const rows = await this.context.drizzle - .delete(settings) - .where(like(settings.key, pattern)) - .returning({ key: settings.key }); + const rows = await deleteReturning( + this.context, + settings, + like(settings.key, pattern), + ); + for (const row of rows) forgetCachedSetting(row.key); await this.afterWrite(); return rows.length; } diff --git a/src/backend/database/repositories/shared-host-auth-override-repository.ts b/src/backend/database/repositories/shared-host-auth-override-repository.ts new file mode 100644 index 00000000..653c50fb --- /dev/null +++ b/src/backend/database/repositories/shared-host-auth-override-repository.ts @@ -0,0 +1,102 @@ +import { and, eq } from "drizzle-orm"; +import type { AuthOverrideProtocol } from "../../../types/auth-protocols.js"; +import { sharedHostAuthOverrides } from "../db/schema.js"; +import type { DatabaseContext } from "./database-context.js"; +import { deleteReturning, upsert } from "./returning.js"; + +export type SharedHostAuthOverrideRecord = + typeof sharedHostAuthOverrides.$inferSelect; + +export class SharedHostAuthOverrideRepository { + constructor( + private readonly context: DatabaseContext, + private readonly onWrite?: () => void | Promise, + ) {} + + async findForHostUser( + hostId: number, + userId: string, + protocol: AuthOverrideProtocol, + ): Promise { + const rows = await this.context.drizzle + .select() + .from(sharedHostAuthOverrides) + .where( + and( + eq(sharedHostAuthOverrides.hostId, hostId), + eq(sharedHostAuthOverrides.userId, userId), + eq(sharedHostAuthOverrides.protocol, protocol), + ), + ) + .limit(1); + + return rows[0] ?? null; + } + + async findCredentialId( + hostId: number, + userId: string, + protocol: AuthOverrideProtocol, + ): Promise { + return ( + (await this.findForHostUser(hostId, userId, protocol))?.credentialId ?? + null + ); + } + + async setCredential( + hostId: number, + userId: string, + protocol: AuthOverrideProtocol, + credentialId: number, + ): Promise { + await upsert( + this.context, + sharedHostAuthOverrides, + { + hostId, + userId, + protocol, + credentialId, + }, + { + target: [ + sharedHostAuthOverrides.hostId, + sharedHostAuthOverrides.userId, + sharedHostAuthOverrides.protocol, + ], + set: { + credentialId, + updatedAt: new Date().toISOString(), + }, + }, + ); + + await this.afterWrite(); + } + + async clearCredential( + hostId: number, + userId: string, + protocol: AuthOverrideProtocol, + ): Promise { + const rows = await deleteReturning( + this.context, + sharedHostAuthOverrides, + and( + eq(sharedHostAuthOverrides.hostId, hostId), + eq(sharedHostAuthOverrides.userId, userId), + eq(sharedHostAuthOverrides.protocol, protocol), + ), + ); + + if (rows.length > 0) { + await this.afterWrite(); + } + return rows.length > 0; + } + + private async afterWrite(): Promise { + await this.onWrite?.(); + } +} diff --git a/src/backend/database/repositories/shared-host-secrets-repository.ts b/src/backend/database/repositories/shared-host-secrets-repository.ts index 23e2a86d..56cd2249 100644 --- a/src/backend/database/repositories/shared-host-secrets-repository.ts +++ b/src/backend/database/repositories/shared-host-secrets-repository.ts @@ -1,6 +1,7 @@ import { and, eq, inArray, or } from "drizzle-orm"; import { hostAccess, hosts, sharedHostSecrets } from "../db/schema.js"; import type { DatabaseContext } from "./database-context.js"; +import { rowsAffected } from "./mutation-result.js"; export type SharedHostSecretRecord = typeof sharedHostSecrets.$inferSelect; export type NewSharedHostSecretRecord = typeof sharedHostSecrets.$inferInsert; @@ -108,16 +109,15 @@ export class SharedHostSecretsRepository { } async deleteByHostAccessId(hostAccessId: number): Promise { - const rows = await this.context.drizzle + const result = await this.context.drizzle .delete(sharedHostSecrets) - .where(eq(sharedHostSecrets.hostAccessId, hostAccessId)) - .returning({ id: sharedHostSecrets.id }); + .where(eq(sharedHostSecrets.hostAccessId, hostAccessId)); - if (rows.length > 0) { + if (rowsAffected(result) > 0) { await this.afterWrite(); } - return rows.length; + return rowsAffected(result); } async deleteForRoleMember( @@ -148,29 +148,27 @@ export class SharedHostSecretsRepository { } async deleteByOriginalCredentialId(credentialId: number): Promise { - const rows = await this.context.drizzle + const result = await this.context.drizzle .delete(sharedHostSecrets) - .where(eq(sharedHostSecrets.originalCredentialId, credentialId)) - .returning({ id: sharedHostSecrets.id }); + .where(eq(sharedHostSecrets.originalCredentialId, credentialId)); - if (rows.length > 0) { + if (rowsAffected(result) > 0) { await this.afterWrite(); } - return rows.length; + return rowsAffected(result); } async deleteByTargetUserId(userId: string): Promise { - const rows = await this.context.drizzle + const result = await this.context.drizzle .delete(sharedHostSecrets) - .where(eq(sharedHostSecrets.targetUserId, userId)) - .returning({ id: sharedHostSecrets.id }); + .where(eq(sharedHostSecrets.targetUserId, userId)); - if (rows.length > 0) { + if (rowsAffected(result) > 0) { await this.afterWrite(); } - return rows.length; + return rowsAffected(result); } async findHostIdsReferencingCredential( diff --git a/src/backend/database/repositories/snippet-repository.ts b/src/backend/database/repositories/snippet-repository.ts index 60e6bf17..8b511c3c 100644 --- a/src/backend/database/repositories/snippet-repository.ts +++ b/src/backend/database/repositories/snippet-repository.ts @@ -2,6 +2,12 @@ import { and, asc, eq, sql } from "drizzle-orm"; import { randomUUID } from "crypto"; import { snippetFolders, snippets } from "../db/schema.js"; import type { DatabaseContext } from "./database-context.js"; +import { rowsAffected } from "./mutation-result.js"; +import { + deleteReturning, + insertReturning, + updateReturning, +} from "./returning.js"; export type SnippetRecord = typeof snippets.$inferSelect; export type SnippetFolderRecord = typeof snippetFolders.$inferSelect; @@ -84,11 +90,16 @@ export class SnippetRepository { } async listSnippetsForExport(userId: string): Promise { - return this.context.drizzle - .select() - .from(snippets) - .where(eq(snippets.userId, userId)) - .orderBy(asc(snippets.folder), asc(snippets.order)); + return ( + this.context.drizzle + .select() + .from(snippets) + .where(eq(snippets.userId, userId)) + // coalesce, not asc(folder): folder is nullable, and NULLs sort first on + // SQLite and MySQL but last on Postgres. An export whose row order depends + // on the engine is not much of an export. + .orderBy(sql`coalesce(${snippets.folder}, '')`, asc(snippets.order)) + ); } async listFoldersForExport(userId: string): Promise { @@ -149,19 +160,16 @@ export class SnippetRepository { ? await this.nextOrderForFolder(userId, folderValue) : input.order; - const rows = await this.context.drizzle - .insert(snippets) - .values({ - syncId: randomUUID(), - userId, - name: input.name.trim(), - content: input.content.trim(), - description: input.description?.trim() || null, - folder: input.folder?.trim() || null, - order, - hostFilter: input.hostFilter ? JSON.stringify(input.hostFilter) : null, - }) - .returning(); + const rows = await insertReturning(this.context, snippets, { + syncId: randomUUID(), + userId, + name: input.name.trim(), + content: input.content.trim(), + description: input.description?.trim() || null, + folder: input.folder?.trim() || null, + order, + hostFilter: input.hostFilter ? JSON.stringify(input.hostFilter) : null, + }); await this.afterWrite(); return rows[0]; @@ -200,11 +208,12 @@ export class SnippetRepository { ? JSON.stringify(input.hostFilter) : null; - const rows = await this.context.drizzle - .update(snippets) - .set(updateFields) - .where(and(eq(snippets.id, snippetId), eq(snippets.userId, userId))) - .returning(); + const rows = await updateReturning( + this.context, + snippets, + updateFields, + and(eq(snippets.id, snippetId), eq(snippets.userId, userId)), + ); await this.afterWrite(); return { existing, updated: rows[0] }; @@ -229,23 +238,21 @@ export class SnippetRepository { snippetsDeleted: number; foldersDeleted: number; }> { - const deletedSnippets = await this.context.drizzle + const snippetResult = await this.context.drizzle .delete(snippets) - .where(eq(snippets.userId, userId)) - .returning({ id: snippets.id }); + .where(eq(snippets.userId, userId)); - const deletedFolders = await this.context.drizzle + const result = await this.context.drizzle .delete(snippetFolders) - .where(eq(snippetFolders.userId, userId)) - .returning({ id: snippetFolders.id }); + .where(eq(snippetFolders.userId, userId)); - if (deletedSnippets.length > 0 || deletedFolders.length > 0) { + if (rowsAffected(snippetResult) > 0 || rowsAffected(result) > 0) { await this.afterWrite(); } return { - snippetsDeleted: deletedSnippets.length, - foldersDeleted: deletedFolders.length, + snippetsDeleted: rowsAffected(snippetResult), + foldersDeleted: rowsAffected(result), }; } @@ -377,16 +384,13 @@ export class SnippetRepository { const existing = await this.findFolderByName(userId, name); if (existing) return null; - const rows = await this.context.drizzle - .insert(snippetFolders) - .values({ - syncId: randomUUID(), - userId, - name: name.trim(), - color: color?.trim() || null, - icon: icon?.trim() || null, - }) - .returning(); + const rows = await insertReturning(this.context, snippetFolders, { + syncId: randomUUID(), + userId, + name: name.trim(), + color: color?.trim() || null, + icon: icon?.trim() || null, + }); if (triggerSave) { await this.afterWrite(); @@ -414,13 +418,12 @@ export class SnippetRepository { if (color !== undefined) updateFields.color = color?.trim() || null; if (icon !== undefined) updateFields.icon = icon?.trim() || null; - const rows = await this.context.drizzle - .update(snippetFolders) - .set(updateFields) - .where( - and(eq(snippetFolders.userId, userId), eq(snippetFolders.name, name)), - ) - .returning(); + const rows = await updateReturning( + this.context, + snippetFolders, + updateFields, + and(eq(snippetFolders.userId, userId), eq(snippetFolders.name, name)), + ); await this.afterWrite(); return rows[0] ?? null; @@ -465,15 +468,14 @@ export class SnippetRepository { .set({ folder: null }) .where(and(eq(snippets.userId, userId), eq(snippets.folder, name))); - const rows = await this.context.drizzle - .delete(snippetFolders) - .where( - and(eq(snippetFolders.userId, userId), eq(snippetFolders.name, name)), - ) - .returning({ syncId: snippetFolders.syncId }); + const rows = await deleteReturning( + this.context, + snippetFolders, + and(eq(snippetFolders.userId, userId), eq(snippetFolders.name, name)), + ); await this.afterWrite(); - return rows[0] ?? null; + return rows[0] ? { syncId: rows[0].syncId } : null; } private async findFolderByName( diff --git a/src/backend/database/repositories/sql-timestamp.ts b/src/backend/database/repositories/sql-timestamp.ts new file mode 100644 index 00000000..3d41168e --- /dev/null +++ b/src/backend/database/repositories/sql-timestamp.ts @@ -0,0 +1,20 @@ +/** + * Timestamp columns are stored as text defaulting to `CURRENT_TIMESTAMP`, which + * every supported engine writes as `YYYY-MM-DD HH:MM:SS` in UTC. That format + * sorts lexicographically in time order, so retention cutoffs can be plain + * string comparisons. + * + * Computing the cutoff here rather than with `datetime('now', ?)` keeps the + * queries free of engine-specific date functions. + */ +export function sqlTimestampDaysAgo( + days: number, + now: Date = new Date(), +): string { + const cutoff = new Date(now.getTime() - days * 24 * 60 * 60 * 1000); + return formatSqlTimestamp(cutoff); +} + +export function formatSqlTimestamp(date: Date): string { + return date.toISOString().slice(0, 19).replace("T", " "); +} diff --git a/src/backend/database/repositories/sqlite-foreign-keys.ts b/src/backend/database/repositories/sqlite-foreign-keys.ts index 09abf115..8b55d755 100644 --- a/src/backend/database/repositories/sqlite-foreign-keys.ts +++ b/src/backend/database/repositories/sqlite-foreign-keys.ts @@ -1,4 +1,5 @@ import { getCurrentRepositorySqlite } from "./factory.js"; +import { needsExplicitPersist, resolveDatabaseDialect } from "../db/dialect.js"; export interface SqliteForeignKeyClient { exec(sql: string): unknown; @@ -16,8 +17,28 @@ export async function withSqliteForeignKeysDisabled( } } +/** + * Runs a bulk import with foreign keys relaxed. + * + * Backup restore writes tables in an order that is not dependency-safe, so the + * constraints have to stand down for the duration. + * + * **This has no equivalent on Postgres or MySQL here.** Postgres needs + * superuser to disable triggers, and MySQL's `SET FOREIGN_KEY_CHECKS = 0` is + * per-connection, which a pool does not guarantee. Rather than run the import + * with constraints enforced and have it fail partway through — leaving a + * half-restored database — it refuses with a message that says why. + */ export async function withCurrentSqliteForeignKeysDisabled( operation: () => Promise, ): Promise { + const dialect = resolveDatabaseDialect(); + if (!needsExplicitPersist(dialect)) { + throw new Error( + `Importing a backup is only supported on SQLite; this deployment uses ${dialect}. ` + + `Restore into the database directly with its own tooling instead.`, + ); + } + return withSqliteForeignKeysDisabled(getCurrentRepositorySqlite(), operation); } diff --git a/src/backend/database/repositories/ssh-credential-usage-repository.ts b/src/backend/database/repositories/ssh-credential-usage-repository.ts index 97d1aa0e..4558d64a 100644 --- a/src/backend/database/repositories/ssh-credential-usage-repository.ts +++ b/src/backend/database/repositories/ssh-credential-usage-repository.ts @@ -1,6 +1,8 @@ import { eq, inArray } from "drizzle-orm"; import { sshCredentialUsage } from "../db/schema.js"; import type { DatabaseContext } from "./database-context.js"; +import { rowsAffected } from "./mutation-result.js"; +import { insertReturning } from "./returning.js"; export type SshCredentialUsageRecord = typeof sshCredentialUsage.$inferSelect; @@ -22,38 +24,37 @@ export class SshCredentialUsageRepository { hostId: number, userId: string, ): Promise { - const [created] = await this.context.drizzle - .insert(sshCredentialUsage) - .values({ credentialId, hostId, userId }) - .returning(); + const [created] = await insertReturning(this.context, sshCredentialUsage, { + credentialId, + hostId, + userId, + }); await this.afterWrite(); return created; } async deleteByUserId(userId: string): Promise { - const rows = await this.context.drizzle + const result = await this.context.drizzle .delete(sshCredentialUsage) - .where(eq(sshCredentialUsage.userId, userId)) - .returning({ id: sshCredentialUsage.id }); + .where(eq(sshCredentialUsage.userId, userId)); - if (rows.length > 0) { + if (rowsAffected(result) > 0) { await this.afterWrite(); } - return rows.length; + return rowsAffected(result); } async deleteByHostId(hostId: number): Promise { - const rows = await this.context.drizzle + const result = await this.context.drizzle .delete(sshCredentialUsage) - .where(eq(sshCredentialUsage.hostId, hostId)) - .returning({ id: sshCredentialUsage.id }); + .where(eq(sshCredentialUsage.hostId, hostId)); - if (rows.length > 0) { + if (rowsAffected(result) > 0) { await this.afterWrite(); } - return rows.length; + return rowsAffected(result); } async deleteByHostIds(hostIds: number[]): Promise { @@ -61,16 +62,15 @@ export class SshCredentialUsageRepository { return 0; } - const rows = await this.context.drizzle + const result = await this.context.drizzle .delete(sshCredentialUsage) - .where(inArray(sshCredentialUsage.hostId, hostIds)) - .returning({ id: sshCredentialUsage.id }); + .where(inArray(sshCredentialUsage.hostId, hostIds)); - if (rows.length > 0) { + if (rowsAffected(result) > 0) { await this.afterWrite(); } - return rows.length; + return rowsAffected(result); } private async afterWrite(): Promise { diff --git a/src/backend/database/repositories/sso-provider-repository.ts b/src/backend/database/repositories/sso-provider-repository.ts index 403a81a0..275e3981 100644 --- a/src/backend/database/repositories/sso-provider-repository.ts +++ b/src/backend/database/repositories/sso-provider-repository.ts @@ -1,6 +1,8 @@ import { asc, eq } from "drizzle-orm"; import { ssoProviders, users } from "../db/schema.js"; import type { DatabaseContext } from "./database-context.js"; +import { rowsAffected } from "./mutation-result.js"; +import { insertReturning, updateReturning } from "./returning.js"; export type SsoProviderRecord = typeof ssoProviders.$inferSelect; export type NewSsoProviderRecord = typeof ssoProviders.$inferInsert; @@ -76,10 +78,7 @@ export class SsoProviderRepository { } async create(provider: NewSsoProviderRecord): Promise { - const rows = await this.context.drizzle - .insert(ssoProviders) - .values(provider) - .returning(); + const rows = await insertReturning(this.context, ssoProviders, provider); await this.afterWrite(); return rows[0]; @@ -89,27 +88,27 @@ export class SsoProviderRepository { id: number, update: SsoProviderUpdate, ): Promise { - const rows = await this.context.drizzle - .update(ssoProviders) - .set(update) - .where(eq(ssoProviders.id, id)) - .returning(); + const rows = await updateReturning( + this.context, + ssoProviders, + update, + eq(ssoProviders.id, id), + ); await this.afterWrite(); return rows[0] ?? null; } async delete(id: number): Promise { - const rows = await this.context.drizzle + const result = await this.context.drizzle .delete(ssoProviders) - .where(eq(ssoProviders.id, id)) - .returning({ id: ssoProviders.id }); + .where(eq(ssoProviders.id, id)); - if (rows.length > 0) { + if (rowsAffected(result) > 0) { await this.afterWrite(); } - return rows.length > 0; + return rowsAffected(result) > 0; } async countUsersByProviderId(providerId: number): Promise { diff --git a/src/backend/database/repositories/sync-tombstone-repository.ts b/src/backend/database/repositories/sync-tombstone-repository.ts index 1fe4953f..a0378aae 100644 --- a/src/backend/database/repositories/sync-tombstone-repository.ts +++ b/src/backend/database/repositories/sync-tombstone-repository.ts @@ -1,5 +1,6 @@ -import { and, eq, gt } from "drizzle-orm"; +import { and, eq } from "drizzle-orm"; import { syncTombstones } from "../db/schema.js"; +import { timestampAtOrAfter } from "../sync-timestamp.js"; import type { DatabaseContext } from "./database-context.js"; export type SyncTombstoneRecord = typeof syncTombstones.$inferSelect; @@ -12,7 +13,8 @@ export type SyncEntityType = | "snippetFolders" | "vaultProfiles" | "dashboardServiceLinks" - | "homepageItems"; + | "homepageItems" + | "userPreferences"; export class SyncTombstoneRepository { constructor( @@ -56,7 +58,8 @@ export class SyncTombstoneRepository { eq(syncTombstones.userId, userId), eq(syncTombstones.entityType, entityType), ]; - if (since) conditions.push(gt(syncTombstones.deletedAt, since)); + if (since) + conditions.push(timestampAtOrAfter(syncTombstones.deletedAt, since)); return this.context.drizzle .select() diff --git a/src/backend/database/repositories/termix-identity-ca-repository.ts b/src/backend/database/repositories/termix-identity-ca-repository.ts index ea1fcd3b..c95e9cef 100644 --- a/src/backend/database/repositories/termix-identity-ca-repository.ts +++ b/src/backend/database/repositories/termix-identity-ca-repository.ts @@ -2,6 +2,12 @@ import { eq } from "drizzle-orm"; import { termixIdentityCa } from "../db/schema.js"; import type { DatabaseContext } from "./database-context.js"; import { DataCrypto } from "../../utils/data-crypto.js"; +import { + insertedId, + rowsAffected, + supportsReturning, +} from "./mutation-result.js"; +import { updateReturning } from "./returning.js"; export type TermixIdentityCaRecord = typeof termixIdentityCa.$inferSelect; export type NewTermixIdentityCaRecord = typeof termixIdentityCa.$inferInsert; @@ -54,27 +60,7 @@ export class TermixIdentityCaRepository { ca: NewTermixIdentityCaRecord, ): Promise { const userDataKey = DataCrypto.validateUserAccess(userId); - const result = this.context.drizzle.transaction((tx) => { - const inserted = tx - .insert(termixIdentityCa) - .values({ ...ca, privateKey: "" }) - .returning() - .all(); - const row = inserted[0]; - const encrypted = DataCrypto.encryptRecord( - "termix_identity_ca", - { id: row.id, privateKey: ca.privateKey }, - userId, - userDataKey, - ); - - return tx - .update(termixIdentityCa) - .set({ privateKey: encrypted.privateKey }) - .where(eq(termixIdentityCa.id, row.id)) - .returning() - .all()[0]; - }); + const result = await this.insertThenEncrypt(userId, ca, userDataKey); await this.afterWrite(); return DataCrypto.decryptRecord( @@ -85,6 +71,81 @@ export class TermixIdentityCaRepository { ); } + /** + * Writes a CA in two steps, because the ciphertext depends on the id. + * + * The private key is encrypted with the row's own id as context, which does + * not exist until the row does. So: insert with an empty key, encrypt, update. + * The empty key must never be observable, hence the transaction. + * + * Two branches because better-sqlite3 rejects an async transaction callback — + * see the same note in UserRepository. + */ + private async insertThenEncrypt( + userId: string, + ca: NewTermixIdentityCaRecord, + userDataKey: Buffer, + ): Promise { + const draft = { ...ca, privateKey: "" }; + + const seal = (id: number) => + DataCrypto.encryptRecord( + "termix_identity_ca", + { id, privateKey: ca.privateKey }, + userId, + userDataKey, + ).privateKey; + + if (this.context.dialect === "sqlite") { + /* eslint-disable no-restricted-syntax -- sqlite-only branch: the dialect + is checked directly above, and better-sqlite3 needs the synchronous + .all() form, which has no async equivalent. */ + return this.context.drizzle.transaction((tx) => { + const row = tx + .insert(termixIdentityCa) + .values(draft) + .returning() + .all()[0]; + return tx + .update(termixIdentityCa) + .set({ privateKey: seal(row.id) }) + .where(eq(termixIdentityCa.id, row.id)) + .returning() + .all()[0]; + }); + /* eslint-enable no-restricted-syntax */ + } + + return this.context.drizzle.transaction(async (tx) => { + let id: number | null; + if (supportsReturning(this.context.dialect)) { + // eslint-disable-next-line no-restricted-syntax -- guarded by the check above + const rows = await tx + .insert(termixIdentityCa) + .values(draft) + .returning(); + id = rows[0]?.id ?? null; + } else { + id = insertedId(await tx.insert(termixIdentityCa).values(draft)); + } + + if (id === null) { + throw new Error("Insert into termix_identity_ca returned no id."); + } + + await tx + .update(termixIdentityCa) + .set({ privateKey: seal(id) }) + .where(eq(termixIdentityCa.id, id)); + + const [row] = await tx + .select() + .from(termixIdentityCa) + .where(eq(termixIdentityCa.id, id)); + return row; + }); + } + async updateEncryptedForIdentity( userId: string, identityId: number, @@ -103,43 +164,42 @@ export class TermixIdentityCaRepository { ).privateKey : undefined; - const rows = await this.context.drizzle - .update(termixIdentityCa) - .set({ + const rows = await updateReturning( + this.context, + termixIdentityCa, + { ...update, ...(encryptedPrivateKey ? { privateKey: encryptedPrivateKey } : {}), - }) - .where(eq(termixIdentityCa.identityId, identityId)) - .returning(); + }, + eq(termixIdentityCa.identityId, identityId), + ); await this.afterWrite(); return this.decryptOne(rows[0] ?? null, userId); } async deleteByIdentityId(identityId: number): Promise { - const rows = await this.context.drizzle + const result = await this.context.drizzle .delete(termixIdentityCa) - .where(eq(termixIdentityCa.identityId, identityId)) - .returning({ id: termixIdentityCa.id }); + .where(eq(termixIdentityCa.identityId, identityId)); - if (rows.length > 0) { + if (rowsAffected(result) > 0) { await this.afterWrite(); } - return rows.length > 0; + return rowsAffected(result) > 0; } async deleteByUserId(userId: string): Promise { - const rows = await this.context.drizzle + const result = await this.context.drizzle .delete(termixIdentityCa) - .where(eq(termixIdentityCa.userId, userId)) - .returning({ id: termixIdentityCa.id }); + .where(eq(termixIdentityCa.userId, userId)); - if (rows.length > 0) { + if (rowsAffected(result) > 0) { await this.afterWrite(); } - return rows.length; + return rowsAffected(result); } private decryptOne>( diff --git a/src/backend/database/repositories/termix-identity-repository.ts b/src/backend/database/repositories/termix-identity-repository.ts index 9badeedd..eae585f8 100644 --- a/src/backend/database/repositories/termix-identity-repository.ts +++ b/src/backend/database/repositories/termix-identity-repository.ts @@ -1,6 +1,8 @@ import { and, asc, eq } from "drizzle-orm"; import { termixIdentities, termixIdentityKeys } from "../db/schema.js"; import type { DatabaseContext } from "./database-context.js"; +import { rowsAffected } from "./mutation-result.js"; +import { insertReturning, updateReturning } from "./returning.js"; export type TermixIdentityRecord = typeof termixIdentities.$inferSelect; export type NewTermixIdentityRecord = typeof termixIdentities.$inferInsert; @@ -57,10 +59,11 @@ export class TermixIdentityRepository { async createIdentity( identity: NewTermixIdentityRecord, ): Promise { - const rows = await this.context.drizzle - .insert(termixIdentities) - .values(identity) - .returning(); + const rows = await insertReturning( + this.context, + termixIdentities, + identity, + ); await this.afterWrite(); return rows[0]; @@ -70,11 +73,12 @@ export class TermixIdentityRepository { userId: string, update: TermixIdentityUpdate, ): Promise { - const rows = await this.context.drizzle - .update(termixIdentities) - .set(update) - .where(eq(termixIdentities.userId, userId)) - .returning(); + const rows = await updateReturning( + this.context, + termixIdentities, + update, + eq(termixIdentities.userId, userId), + ); if (rows.length > 0) { await this.afterWrite(); @@ -84,39 +88,36 @@ export class TermixIdentityRepository { } async deleteIdentityForUser(userId: string): Promise { - const rows = await this.context.drizzle + const result = await this.context.drizzle .delete(termixIdentities) - .where(eq(termixIdentities.userId, userId)) - .returning({ id: termixIdentities.id }); + .where(eq(termixIdentities.userId, userId)); - if (rows.length > 0) { + if (rowsAffected(result) > 0) { await this.afterWrite(); } - return rows.length > 0; + return rowsAffected(result) > 0; } async deleteByUserId(userId: string): Promise<{ identitiesDeleted: number; keysDeleted: number; }> { - const keyRows = await this.context.drizzle + const keyResult = await this.context.drizzle .delete(termixIdentityKeys) - .where(eq(termixIdentityKeys.userId, userId)) - .returning({ id: termixIdentityKeys.id }); + .where(eq(termixIdentityKeys.userId, userId)); - const identityRows = await this.context.drizzle + const result = await this.context.drizzle .delete(termixIdentities) - .where(eq(termixIdentities.userId, userId)) - .returning({ id: termixIdentities.id }); + .where(eq(termixIdentities.userId, userId)); - if (keyRows.length > 0 || identityRows.length > 0) { + if (rowsAffected(keyResult) > 0 || rowsAffected(result) > 0) { await this.afterWrite(); } return { - identitiesDeleted: identityRows.length, - keysDeleted: keyRows.length, + identitiesDeleted: rowsAffected(result), + keysDeleted: rowsAffected(keyResult), }; } @@ -170,10 +171,7 @@ export class TermixIdentityRepository { async createKey( key: NewTermixIdentityKeyRecord, ): Promise { - const rows = await this.context.drizzle - .insert(termixIdentityKeys) - .values(key) - .returning(); + const rows = await insertReturning(this.context, termixIdentityKeys, key); await this.afterWrite(); return rows[0]; @@ -184,16 +182,12 @@ export class TermixIdentityRepository { id: number, update: TermixIdentityKeyUpdate, ): Promise { - const rows = await this.context.drizzle - .update(termixIdentityKeys) - .set(update) - .where( - and( - eq(termixIdentityKeys.id, id), - eq(termixIdentityKeys.userId, userId), - ), - ) - .returning(); + const rows = await updateReturning( + this.context, + termixIdentityKeys, + update, + and(eq(termixIdentityKeys.id, id), eq(termixIdentityKeys.userId, userId)), + ); if (rows.length > 0) { await this.afterWrite(); @@ -203,21 +197,20 @@ export class TermixIdentityRepository { } async deleteKeyForUser(userId: string, id: number): Promise { - const rows = await this.context.drizzle + const result = await this.context.drizzle .delete(termixIdentityKeys) .where( and( eq(termixIdentityKeys.id, id), eq(termixIdentityKeys.userId, userId), ), - ) - .returning({ id: termixIdentityKeys.id }); + ); - if (rows.length > 0) { + if (rowsAffected(result) > 0) { await this.afterWrite(); } - return rows.length > 0; + return rowsAffected(result) > 0; } async findKeyForUser( diff --git a/src/backend/database/repositories/tmux-session-tag-repository.ts b/src/backend/database/repositories/tmux-session-tag-repository.ts index 3d89d856..60735f0f 100644 --- a/src/backend/database/repositories/tmux-session-tag-repository.ts +++ b/src/backend/database/repositories/tmux-session-tag-repository.ts @@ -1,6 +1,7 @@ import { and, eq } from "drizzle-orm"; import { tmuxSessionTags } from "../db/schema.js"; import type { DatabaseContext } from "./database-context.js"; +import { rowsAffected } from "./mutation-result.js"; export type TmuxSessionTagRecord = typeof tmuxSessionTags.$inferSelect; @@ -45,7 +46,7 @@ export class TmuxSessionTagRepository { sessionName: string, newSessionName: string, ): Promise { - const rows = await this.context.drizzle + const result = await this.context.drizzle .update(tmuxSessionTags) .set({ sessionName: newSessionName }) .where( @@ -53,35 +54,33 @@ export class TmuxSessionTagRepository { eq(tmuxSessionTags.hostId, hostId), eq(tmuxSessionTags.sessionName, sessionName), ), - ) - .returning({ id: tmuxSessionTags.id }); + ); - if (rows.length > 0) { + if (rowsAffected(result) > 0) { await this.afterWrite(); } - return rows.length; + return rowsAffected(result); } async deleteSessionForHost( hostId: number, sessionName: string, ): Promise { - const rows = await this.context.drizzle + const result = await this.context.drizzle .delete(tmuxSessionTags) .where( and( eq(tmuxSessionTags.hostId, hostId), eq(tmuxSessionTags.sessionName, sessionName), ), - ) - .returning({ id: tmuxSessionTags.id }); + ); - if (rows.length > 0) { + if (rowsAffected(result) > 0) { await this.afterWrite(); } - return rows.length; + return rowsAffected(result); } async replaceForUserHostSession( @@ -90,7 +89,7 @@ export class TmuxSessionTagRepository { sessionName: string, tags: string[], ): Promise { - const deletedRows = await this.context.drizzle + const result = await this.context.drizzle .delete(tmuxSessionTags) .where( and( @@ -98,8 +97,7 @@ export class TmuxSessionTagRepository { eq(tmuxSessionTags.hostId, hostId), eq(tmuxSessionTags.sessionName, sessionName), ), - ) - .returning({ id: tmuxSessionTags.id }); + ); if (tags.length > 0) { await this.context.drizzle.insert(tmuxSessionTags).values( @@ -112,7 +110,7 @@ export class TmuxSessionTagRepository { ); } - const changedRows = deletedRows.length + tags.length; + const changedRows = rowsAffected(result) + tags.length; if (changedRows > 0) { await this.afterWrite(); } @@ -121,16 +119,15 @@ export class TmuxSessionTagRepository { } async deleteByUserId(userId: string): Promise { - const rows = await this.context.drizzle + const result = await this.context.drizzle .delete(tmuxSessionTags) - .where(eq(tmuxSessionTags.userId, userId)) - .returning({ id: tmuxSessionTags.id }); + .where(eq(tmuxSessionTags.userId, userId)); - if (rows.length > 0) { + if (rowsAffected(result) > 0) { await this.afterWrite(); } - return rows.length; + return rowsAffected(result); } private async afterWrite(): Promise { diff --git a/src/backend/database/repositories/transfer-recent-repository.ts b/src/backend/database/repositories/transfer-recent-repository.ts index 309b270e..de1266ea 100644 --- a/src/backend/database/repositories/transfer-recent-repository.ts +++ b/src/backend/database/repositories/transfer-recent-repository.ts @@ -1,6 +1,7 @@ import { and, desc, eq, inArray, or } from "drizzle-orm"; import { transferRecent } from "../db/schema.js"; import type { DatabaseContext } from "./database-context.js"; +import { rowsAffected } from "./mutation-result.js"; export type TransferRecentRecord = typeof transferRecent.$inferSelect; @@ -100,47 +101,44 @@ export class TransferRecentRepository { return 0; } - const deleted = await this.context.drizzle + const result = await this.context.drizzle .delete(transferRecent) - .where(inArray(transferRecent.id, idsToDelete)) - .returning({ id: transferRecent.id }); + .where(inArray(transferRecent.id, idsToDelete)); - if (deleted.length > 0) { + if (rowsAffected(result) > 0) { await this.afterWrite(); } - return deleted.length; + return rowsAffected(result); } async deleteByUserId(userId: string): Promise { - const rows = await this.context.drizzle + const result = await this.context.drizzle .delete(transferRecent) - .where(eq(transferRecent.userId, userId)) - .returning({ id: transferRecent.id }); + .where(eq(transferRecent.userId, userId)); - if (rows.length > 0) { + if (rowsAffected(result) > 0) { await this.afterWrite(); } - return rows.length; + return rowsAffected(result); } async deleteByHostId(hostId: number): Promise { - const rows = await this.context.drizzle + const result = await this.context.drizzle .delete(transferRecent) .where( or( eq(transferRecent.sourceHostId, hostId), eq(transferRecent.destHostId, hostId), ), - ) - .returning({ id: transferRecent.id }); + ); - if (rows.length > 0) { + if (rowsAffected(result) > 0) { await this.afterWrite(); } - return rows.length; + return rowsAffected(result); } async deleteByHostIds(hostIds: number[]): Promise { @@ -148,21 +146,20 @@ export class TransferRecentRepository { return 0; } - const rows = await this.context.drizzle + const result = await this.context.drizzle .delete(transferRecent) .where( or( inArray(transferRecent.sourceHostId, hostIds), inArray(transferRecent.destHostId, hostIds), ), - ) - .returning({ id: transferRecent.id }); + ); - if (rows.length > 0) { + if (rowsAffected(result) > 0) { await this.afterWrite(); } - return rows.length; + return rowsAffected(result); } private async afterWrite(): Promise { diff --git a/src/backend/database/repositories/user-preference-repository.ts b/src/backend/database/repositories/user-preference-repository.ts index 7163c284..6a37a030 100644 --- a/src/backend/database/repositories/user-preference-repository.ts +++ b/src/backend/database/repositories/user-preference-repository.ts @@ -1,6 +1,8 @@ import { eq } from "drizzle-orm"; import { userPreferences } from "../db/schema.js"; import type { DatabaseContext } from "./database-context.js"; +import { rowsAffected } from "./mutation-result.js"; +import { insertReturningWhere, updateReturning } from "./returning.js"; export type UserPreferenceRecord = typeof userPreferences.$inferSelect; export type NewUserPreferenceRecord = typeof userPreferences.$inferInsert; @@ -31,34 +33,36 @@ export class UserPreferenceRepository { const existing = await this.findByUserId(userId); if (!existing) { - const rows = await this.context.drizzle - .insert(userPreferences) - .values({ userId, ...update }) - .returning(); + const rows = await insertReturningWhere( + this.context, + userPreferences, + { userId, ...update }, + eq(userPreferences.userId, userId), + ); await this.afterWrite(); return rows[0]; } - const rows = await this.context.drizzle - .update(userPreferences) - .set(update) - .where(eq(userPreferences.userId, userId)) - .returning(); + const rows = await updateReturning( + this.context, + userPreferences, + update, + eq(userPreferences.userId, userId), + ); await this.afterWrite(); return rows[0]; } async deleteByUserId(userId: string): Promise { - const rows = await this.context.drizzle + const result = await this.context.drizzle .delete(userPreferences) - .where(eq(userPreferences.userId, userId)) - .returning({ userId: userPreferences.userId }); + .where(eq(userPreferences.userId, userId)); - if (rows.length > 0) { + if (rowsAffected(result) > 0) { await this.afterWrite(); } - return rows.length; + return rowsAffected(result); } private async afterWrite(): Promise { diff --git a/src/backend/database/repositories/user-repository.ts b/src/backend/database/repositories/user-repository.ts index 68442f4f..59d6b75b 100644 --- a/src/backend/database/repositories/user-repository.ts +++ b/src/backend/database/repositories/user-repository.ts @@ -1,6 +1,8 @@ import { eq, inArray } from "drizzle-orm"; import { users } from "../db/schema.js"; import type { DatabaseContext } from "./database-context.js"; +import { rowsAffected, supportsReturning } from "./mutation-result.js"; +import { insertReturning, updateReturning } from "./returning.js"; export type UserRecord = typeof users.$inferSelect; export type NewUserRecord = typeof users.$inferInsert; @@ -62,10 +64,7 @@ export class UserRepository { } async create(user: NewUserRecord): Promise { - const rows = await this.context.drizzle - .insert(users) - .values(user) - .returning(); + const rows = await insertReturning(this.context, users, user); await this.afterWrite(); return rows[0]; } @@ -73,17 +72,10 @@ export class UserRepository { async createFirstLocalUser( user: NewFirstLocalUserRecord, ): Promise<{ user: UserRecord; isFirstUser: boolean }> { - const result = this.context.drizzle.transaction((tx) => { - const existingUsers = tx.select({ id: users.id }).from(users).all(); - const isFirstUser = existingUsers.length === 0; - const rows = tx - .insert(users) - .values({ ...user, isAdmin: isFirstUser }) - .returning() - .all(); - - return { user: rows[0], isFirstUser }; - }); + const result = await this.createCheckingIfFirst((isFirstUser) => ({ + ...user, + isAdmin: isFirstUser, + })); await this.afterWrite(); return result; @@ -92,41 +84,87 @@ export class UserRepository { async createFirstSsoUser( user: NewUserRecord, ): Promise<{ user: UserRecord; isFirstUser: boolean }> { - const result = this.context.drizzle.transaction((tx) => { - const existingUsers = tx.select({ id: users.id }).from(users).all(); - const isFirstUser = existingUsers.length === 0; - const rows = tx - .insert(users) - .values({ ...user, isAdmin: isFirstUser || Boolean(user.isAdmin) }) - .returning() - .all(); - - return { user: rows[0], isFirstUser }; - }); + const result = await this.createCheckingIfFirst((isFirstUser) => ({ + ...user, + isAdmin: isFirstUser || Boolean(user.isAdmin), + })); await this.afterWrite(); return result; } + /** + * Creates a user, making them an admin if the table was empty. + * + * The check and the insert have to be one transaction: two people signing up + * at once would otherwise both see an empty table and both become admin. + * + * The two branches are not a style choice. better-sqlite3 is synchronous and + * rejects an async transaction callback outright — "Transaction function + * cannot return a promise" — so a single body cannot serve both. It fails + * loudly rather than silently skipping the write, which is the one mercy here. + */ + private async createCheckingIfFirst( + build: (isFirstUser: boolean) => NewUserRecord, + ): Promise<{ user: UserRecord; isFirstUser: boolean }> { + if (this.context.dialect === "sqlite") { + /* eslint-disable no-restricted-syntax -- sqlite-only branch: the dialect + is checked directly above, and better-sqlite3 rejects an async + transaction callback, so this cannot use the shared helpers. */ + return this.context.drizzle.transaction((tx) => { + const isFirstUser = + tx.select({ id: users.id }).from(users).all().length === 0; + const rows = tx + .insert(users) + .values(build(isFirstUser)) + .returning() + .all(); + return { user: rows[0], isFirstUser }; + }); + /* eslint-enable no-restricted-syntax */ + } + + return this.context.drizzle.transaction(async (tx) => { + const existing = await tx.select({ id: users.id }).from(users); + const isFirstUser = existing.length === 0; + const values = build(isFirstUser); + + if (supportsReturning(this.context.dialect)) { + // eslint-disable-next-line no-restricted-syntax -- guarded by the check on this line + const rows = await tx.insert(users).values(values).returning(); + return { user: rows[0], isFirstUser }; + } + + // users is keyed by a text id the caller supplies, so there is something + // to read back by even without RETURNING. + await tx.insert(users).values(values); + const [user] = await tx + .select() + .from(users) + .where(eq(users.id, values.id)); + return { user, isFirstUser }; + }); + } + async update(id: string, update: UserUpdate): Promise { - const rows = await this.context.drizzle - .update(users) - .set(update) - .where(eq(users.id, id)) - .returning(); + const rows = await updateReturning( + this.context, + users, + update, + eq(users.id, id), + ); await this.afterWrite(); return rows[0] ?? null; } async delete(id: string): Promise { - const rows = await this.context.drizzle + const result = await this.context.drizzle .delete(users) - .where(eq(users.id, id)) - .returning({ id: users.id }); + .where(eq(users.id, id)); await this.afterWrite(); - return rows.length > 0; + return rowsAffected(result) > 0; } async countAdmins(): Promise { diff --git a/src/backend/database/repositories/vault-profile-repository.ts b/src/backend/database/repositories/vault-profile-repository.ts index 64ff3706..bcf8cec5 100644 --- a/src/backend/database/repositories/vault-profile-repository.ts +++ b/src/backend/database/repositories/vault-profile-repository.ts @@ -2,6 +2,12 @@ import { desc, eq, or } from "drizzle-orm"; import { randomUUID } from "crypto"; import { vaultProfiles } from "../db/schema.js"; import type { DatabaseContext } from "./database-context.js"; +import { rowsAffected } from "./mutation-result.js"; +import { + deleteReturning, + insertReturning, + updateReturning, +} from "./returning.js"; export type VaultProfileRecord = typeof vaultProfiles.$inferSelect; @@ -45,26 +51,23 @@ export class VaultProfileRepository { } async create(input: VaultProfileCreateInput): Promise { - const [created] = await this.context.drizzle - .insert(vaultProfiles) - .values({ - syncId: randomUUID(), - userId: input.userId, - name: input.name, - description: input.description, - folder: input.folder, - tags: input.tags, - vaultAddr: input.vaultAddr, - vaultNamespace: input.vaultNamespace, - oidcMount: input.oidcMount, - oidcRole: input.oidcRole, - sshMount: input.sshMount, - sshRole: input.sshRole, - validPrincipals: input.validPrincipals, - keyType: input.keyType, - shared: input.shared ?? false, - }) - .returning(); + const [created] = await insertReturning(this.context, vaultProfiles, { + syncId: randomUUID(), + userId: input.userId, + name: input.name, + description: input.description, + folder: input.folder, + tags: input.tags, + vaultAddr: input.vaultAddr, + vaultNamespace: input.vaultNamespace, + oidcMount: input.oidcMount, + oidcRole: input.oidcRole, + sshMount: input.sshMount, + sshRole: input.sshRole, + validPrincipals: input.validPrincipals, + keyType: input.keyType, + shared: input.shared ?? false, + }); await this.afterWrite(); return created; @@ -84,14 +87,15 @@ export class VaultProfileRepository { id: number, input: VaultProfileUpdateInput, ): Promise { - const [updated] = await this.context.drizzle - .update(vaultProfiles) - .set({ + const [updated] = await updateReturning( + this.context, + vaultProfiles, + { ...input, updatedAt: input.updatedAt ?? new Date().toISOString(), - }) - .where(eq(vaultProfiles.id, id)) - .returning(); + }, + eq(vaultProfiles.id, id), + ); if (updated) { await this.afterWrite(); @@ -101,27 +105,27 @@ export class VaultProfileRepository { } async deleteById(id: number): Promise<{ syncId: string | null } | null> { - const rows = await this.context.drizzle - .delete(vaultProfiles) - .where(eq(vaultProfiles.id, id)) - .returning({ syncId: vaultProfiles.syncId }); + const rows = await deleteReturning( + this.context, + vaultProfiles, + eq(vaultProfiles.id, id), + ); if (rows.length === 0) return null; await this.afterWrite(); - return rows[0]; + return { syncId: rows[0].syncId }; } async deleteByUserId(userId: string): Promise { - const rows = await this.context.drizzle + const result = await this.context.drizzle .delete(vaultProfiles) - .where(eq(vaultProfiles.userId, userId)) - .returning({ id: vaultProfiles.id }); + .where(eq(vaultProfiles.userId, userId)); - if (rows.length > 0) { + if (rowsAffected(result) > 0) { await this.afterWrite(); } - return rows.length; + return rowsAffected(result); } private async afterWrite(): Promise { diff --git a/src/backend/database/repositories/vault-token-repository.ts b/src/backend/database/repositories/vault-token-repository.ts index 1db262f0..a14ea70b 100644 --- a/src/backend/database/repositories/vault-token-repository.ts +++ b/src/backend/database/repositories/vault-token-repository.ts @@ -1,6 +1,8 @@ import { and, eq } from "drizzle-orm"; import { vaultTokens } from "../db/schema.js"; import type { DatabaseContext } from "./database-context.js"; +import { rowsAffected } from "./mutation-result.js"; +import { upsert } from "./returning.js"; export type VaultTokenRecord = typeof vaultTokens.$inferSelect; @@ -22,16 +24,17 @@ export class VaultTokenRepository { async upsert(input: VaultTokenUpsertInput): Promise { const createdAt = input.createdAt ?? new Date().toISOString(); - await this.context.drizzle - .insert(vaultTokens) - .values({ + await upsert( + this.context, + vaultTokens, + { userId: input.userId, profileId: input.profileId, sshCert: input.sshCert, privateKey: input.privateKey, expiresAt: input.expiresAt, - }) - .onConflictDoUpdate({ + }, + { target: [vaultTokens.userId, vaultTokens.profileId], set: { sshCert: input.sshCert, @@ -39,7 +42,8 @@ export class VaultTokenRepository { expiresAt: input.expiresAt, createdAt, }, - }); + }, + ); await this.afterWrite(); } @@ -67,7 +71,7 @@ export class VaultTokenRepository { profileId: number, lastUsed = new Date().toISOString(), ): Promise { - const rows = await this.context.drizzle + const result = await this.context.drizzle .update(vaultTokens) .set({ lastUsed }) .where( @@ -75,48 +79,45 @@ export class VaultTokenRepository { eq(vaultTokens.userId, userId), eq(vaultTokens.profileId, profileId), ), - ) - .returning({ id: vaultTokens.id }); + ); - if (rows.length > 0) { + if (rowsAffected(result) > 0) { await this.afterWrite(); } - return rows.length > 0; + return rowsAffected(result) > 0; } async deleteByUserAndProfile( userId: string, profileId: number, ): Promise { - const rows = await this.context.drizzle + const result = await this.context.drizzle .delete(vaultTokens) .where( and( eq(vaultTokens.userId, userId), eq(vaultTokens.profileId, profileId), ), - ) - .returning({ id: vaultTokens.id }); + ); - if (rows.length > 0) { + if (rowsAffected(result) > 0) { await this.afterWrite(); } - return rows.length > 0; + return rowsAffected(result) > 0; } async deleteByUserId(userId: string): Promise { - const rows = await this.context.drizzle + const result = await this.context.drizzle .delete(vaultTokens) - .where(eq(vaultTokens.userId, userId)) - .returning({ id: vaultTokens.id }); + .where(eq(vaultTokens.userId, userId)); - if (rows.length > 0) { + if (rowsAffected(result) > 0) { await this.afterWrite(); } - return rows.length; + return rowsAffected(result); } private async afterWrite(): Promise { diff --git a/src/backend/database/repositories/webauthn-credential-repository.ts b/src/backend/database/repositories/webauthn-credential-repository.ts index f5dac86d..5810cade 100644 --- a/src/backend/database/repositories/webauthn-credential-repository.ts +++ b/src/backend/database/repositories/webauthn-credential-repository.ts @@ -1,6 +1,8 @@ import { and, eq } from "drizzle-orm"; import { webauthnCredentials } from "../db/schema.js"; import type { DatabaseContext } from "./database-context.js"; +import { rowsAffected } from "./mutation-result.js"; +import { insertReturning } from "./returning.js"; export type WebauthnCredentialRecord = typeof webauthnCredentials.$inferSelect; export type NewWebauthnCredentialRecord = @@ -41,10 +43,11 @@ export class WebauthnCredentialRepository { async create( record: NewWebauthnCredentialRecord, ): Promise { - const rows = await this.context.drizzle - .insert(webauthnCredentials) - .values(record) - .returning(); + const rows = await insertReturning( + this.context, + webauthnCredentials, + record, + ); await this.afterWrite(); return rows[0]; @@ -63,21 +66,20 @@ export class WebauthnCredentialRepository { } async deleteForUser(userId: string, id: string): Promise { - const rows = await this.context.drizzle + const result = await this.context.drizzle .delete(webauthnCredentials) .where( and( eq(webauthnCredentials.id, id), eq(webauthnCredentials.userId, userId), ), - ) - .returning({ id: webauthnCredentials.id }); + ); - if (rows.length > 0) { + if (rowsAffected(result) > 0) { await this.afterWrite(); } - return rows.length > 0; + return rowsAffected(result) > 0; } private async afterWrite(): Promise { diff --git a/src/backend/database/routes/audit-log-routes.ts b/src/backend/database/routes/audit-log-routes.ts index 91273b91..60dea304 100644 --- a/src/backend/database/routes/audit-log-routes.ts +++ b/src/backend/database/routes/audit-log-routes.ts @@ -5,6 +5,12 @@ import { createCurrentUserRepository, } from "../repositories/factory.js"; import { apiLogger } from "../../utils/logger.js"; +import { exportFilename, toCsv, toNdjson } from "../../utils/audit-export.js"; +import { + logAudit, + getAuditUsername, + getRequestMeta, +} from "../../utils/audit-logger.js"; async function isAdminUser(userId: string | undefined): Promise { if (!userId) return false; @@ -132,4 +138,125 @@ export function registerAuditLogRoutes( .json({ error: "Failed to fetch audit log actions" }); } }); + + /** + * @openapi + * /audit-logs/export: + * get: + * summary: Export audit logs + * description: Streams the full filtered result set as CSV or NDJSON. Accepts the same filters as GET /audit-logs. Admin only. The export is itself audited. + * tags: + * - Audit + * parameters: + * - in: query + * name: format + * schema: { type: string, enum: [csv, ndjson], default: csv } + * - in: query + * name: userId + * schema: { type: string } + * - in: query + * name: action + * schema: { type: string } + * - in: query + * name: resourceType + * schema: { type: string } + * - in: query + * name: success + * schema: { type: string, enum: [true, false] } + * - in: query + * name: startDate + * schema: { type: string, format: date-time } + * - in: query + * name: endDate + * schema: { type: string, format: date-time } + * responses: + * 200: + * description: Audit log file. + * 403: + * description: Not authorized. + * 500: + * description: Failed to export audit logs. + */ + router.get("/audit-logs/export", authenticateJWT, async (req, res) => { + const authReq = req as AuthenticatedRequest; + try { + if (!(await isAdminUser(authReq.userId))) { + return res.status(403).json({ error: "Not authorized" }); + } + + const format = req.query.format === "ndjson" ? "ndjson" : "csv"; + const { userId, action, resourceType, success, startDate, endDate } = + req.query as Record; + const filters = { + userId, + action, + resourceType, + success: + success !== undefined && success !== "" + ? success === "true" + : undefined, + startDate, + endDate, + }; + + res.setHeader( + "Content-Type", + format === "csv" ? "text/csv; charset=utf-8" : "application/x-ndjson", + ); + res.setHeader( + "Content-Disposition", + `attachment; filename="${exportFilename(format, new Date())}"`, + ); + + // Streamed in batches: an export is unbounded by definition, and the + // whole point is to move data out before retention drops it. + const BATCH = 500; + let offset = 0; + let exported = 0; + + for (;;) { + const rows = await createCurrentAuditLogRepository().listForExport({ + filters, + limit: BATCH, + offset, + }); + if (rows.length === 0) break; + + if (format === "csv") { + // Header only on the first batch. + const chunk = toCsv(rows); + res.write( + offset === 0 ? chunk : chunk.slice(chunk.indexOf("\n") + 1), + ); + } else { + res.write(toNdjson(rows)); + } + + exported += rows.length; + offset += rows.length; + if (rows.length < BATCH) break; + } + + res.end(); + + // Reading the whole trail is itself worth recording. + const { ipAddress, userAgent } = getRequestMeta(req); + void logAudit({ + userId: authReq.userId!, + username: await getAuditUsername(authReq.userId!), + action: "export_audit_logs", + resourceType: "audit_log", + details: JSON.stringify({ format, exported, filters }), + ipAddress, + userAgent, + success: true, + }); + } catch (err) { + apiLogger.error("Failed to export audit logs", err); + if (!res.headersSent) { + return res.status(500).json({ error: "Failed to export audit logs" }); + } + res.end(); + } + }); } diff --git a/src/backend/database/routes/credentials.ts b/src/backend/database/routes/credentials.ts index bd5d49b3..3772e775 100644 --- a/src/backend/database/routes/credentials.ts +++ b/src/backend/database/routes/credentials.ts @@ -6,12 +6,15 @@ import { AuthManager } from "../../utils/auth-manager.js"; import { parseSSHKey } from "../../utils/ssh-key-utils.js"; import { registerCredentialKeyRoutes } from "./credential-key-routes.js"; import { registerCredentialDeployRoutes } from "./credential-deploy-routes.js"; -import { logAudit, getRequestMeta } from "../../utils/audit-logger.js"; +import { + logAudit, + getAuditUsername, + getRequestMeta, +} from "../../utils/audit-logger.js"; import { createCurrentCredentialRepository, createCurrentHostResolutionRepository, createCurrentHostRepository, - createCurrentUserRepository, createCurrentSyncTombstoneRepository, } from "../repositories/factory.js"; @@ -25,11 +28,6 @@ const authManager = AuthManager.getInstance(); const authenticateJWT = authManager.createAuthMiddleware(); const requireDataAccess = authManager.createDataAccessMiddleware(); -async function getAuditUsername(userId: string): Promise { - const actor = await createCurrentUserRepository().findById(userId); - return actor?.username ?? userId; -} - /** * @openapi * /credentials: diff --git a/src/backend/database/routes/delete-user-data.ts b/src/backend/database/routes/delete-user-data.ts index b3ec33e1..268cd92d 100644 --- a/src/backend/database/routes/delete-user-data.ts +++ b/src/backend/database/routes/delete-user-data.ts @@ -44,7 +44,9 @@ export async function deleteUserAndRelatedData(userId: string): Promise { userId, ); - await createCurrentSessionRecordingRepository().deleteByUserId(userId); + // Retained rather than deleted: these outlive the account by design. + // See anonymizeByUserId on each repository. + await createCurrentSessionRecordingRepository().anonymizeByUserId(userId); await createCurrentRbacAccessRepository().deleteHostAccessForUserReferences( userId, @@ -56,7 +58,7 @@ export async function deleteUserAndRelatedData(userId: string): Promise { await createCurrentRoleRepository().removeAllRolesFromUser(userId); await createCurrentAlertRepository().deleteByUserId(userId); - await createCurrentAuditLogRepository().deleteByUserId(userId); + await createCurrentAuditLogRepository().anonymizeByUserId(userId); await createCurrentSshCredentialUsageRepository().deleteByUserId(userId); diff --git a/src/backend/database/routes/homepage-proxy-routes.ts b/src/backend/database/routes/homepage-proxy-routes.ts index eaf23a1b..6551b8b8 100644 --- a/src/backend/database/routes/homepage-proxy-routes.ts +++ b/src/backend/database/routes/homepage-proxy-routes.ts @@ -3,8 +3,9 @@ import express from "express"; import https from "https"; import http from "http"; import { lookup } from "dns/promises"; -import { BlockList, isIP } from "net"; +import { isIP } from "net"; import { homepageLogger } from "../../utils/logger.js"; +import { isBlockedAddress } from "../../utils/safe-outbound-fetch.js"; export const homepageProxyRouter = express.Router(); @@ -17,40 +18,6 @@ const proxyCache = new Map(); const CACHE_SIZE = 50; const FETCH_TIMEOUT_MS = 8000; -const blockedAddresses = new BlockList(); -for (const [network, prefix] of [ - ["0.0.0.0", 8], - ["10.0.0.0", 8], - ["100.64.0.0", 10], - ["127.0.0.0", 8], - ["169.254.0.0", 16], - ["172.16.0.0", 12], - ["192.168.0.0", 16], - ["198.18.0.0", 15], - ["224.0.0.0", 4], - ["240.0.0.0", 4], -] as const) { - blockedAddresses.addSubnet(network, prefix, "ipv4"); -} -for (const [network, prefix] of [ - ["::", 128], - ["::1", 128], - ["::ffff:0:0", 96], - ["fc00::", 7], - ["fe80::", 10], - ["ff00::", 8], -] as const) { - blockedAddresses.addSubnet(network, prefix, "ipv6"); -} - -function isBlockedAddress(address: string): boolean { - const family = isIP(address); - return ( - family === 0 || - blockedAddresses.check(address, family === 4 ? "ipv4" : "ipv6") - ); -} - async function resolvePublicUrl(rawUrl: string): Promise<{ url: URL; address: string; diff --git a/src/backend/database/routes/host-normalizers.ts b/src/backend/database/routes/host-normalizers.ts index 9c17ac07..ff06d869 100644 --- a/src/backend/database/routes/host-normalizers.ts +++ b/src/backend/database/routes/host-normalizers.ts @@ -1,3 +1,5 @@ +import type { AuthOverrideProtocol } from "../../../types/auth-protocols.js"; + export function isNonEmptyString(value: unknown): value is string { return typeof value === "string" && value.trim().length > 0; } @@ -6,6 +8,56 @@ export function isValidPort(port: unknown): port is number { return typeof port === "number" && port > 0 && port <= 65535; } +export function isOptionalBoolean( + value: unknown, +): value is boolean | undefined { + return value === undefined || typeof value === "boolean"; +} + +export const OWNER_PRIVATE_AUTH_FIELDS = { + ssh: [ + "authType", + "authMethod", + "credentialId", + "vaultProfileId", + "overrideCredentialUsername", + "shareSshAuth", + "password", + "key", + "keyPassword", + "keyType", + "sudoPassword", + ], + rdp: [ + "rdpAuthType", + "rdpCredentialId", + "rdpUser", + "rdpPassword", + "rdpDomain", + ], + vnc: ["vncAuthType", "vncCredentialId", "vncUser", "vncPassword"], + telnet: [ + "telnetAuthType", + "telnetCredentialId", + "telnetUser", + "telnetPassword", + ], +} as const satisfies Record; + +export const OWNER_PRIVATE_TERMINAL_CONFIG_FIELDS = [ + "sudoPassword", + "agentSocketPath", +] as const; + +export function containsOwnerPrivateAuthUpdate( + hostData: Record, + protocol: AuthOverrideProtocol, +): boolean { + return OWNER_PRIVATE_AUTH_FIELDS[protocol].some((field) => + Object.prototype.hasOwnProperty.call(hostData, field), + ); +} + export const FOLDER_PATH_SEPARATOR = " / "; /** @@ -231,6 +283,17 @@ export function stripSensitiveFields( for (const field of SENSITIVE_FIELDS) { delete result[field]; } + if ( + result.terminalConfig && + typeof result.terminalConfig === "object" && + !Array.isArray(result.terminalConfig) + ) { + const terminalConfig = { + ...(result.terminalConfig as Record), + }; + delete terminalConfig.sudoPassword; + result.terminalConfig = terminalConfig; + } return result; } @@ -251,8 +314,9 @@ const CONNECT_LEVEL_FIELDS = new Set([ "tags", "pin", "authType", + "shareSshAuth", + "authOverrides", "connectionType", - "credentialId", "enableTerminal", "enableTunnel", "enableFileManager", @@ -290,6 +354,37 @@ export function sanitizeHostForRecipient( permissionLevel: string | undefined, ): Record { const stripped = stripSensitiveFields(host); + delete stripped.credentialId; + delete stripped.overrideCredentialUsername; + if ( + stripped.terminalConfig && + typeof stripped.terminalConfig === "object" && + !Array.isArray(stripped.terminalConfig) + ) { + const terminalConfig = { + ...(stripped.terminalConfig as Record), + }; + delete terminalConfig.agentSocketPath; + stripped.terminalConfig = terminalConfig; + } + const authOverrides = + stripped.authOverrides && + typeof stripped.authOverrides === "object" && + !Array.isArray(stripped.authOverrides) + ? (stripped.authOverrides as Record) + : undefined; + const sshOverride = + authOverrides?.ssh && + typeof authOverrides.ssh === "object" && + !Array.isArray(authOverrides.ssh) + ? (authOverrides.ssh as Record) + : undefined; + if (!sshOverride?.credentialId) { + stripped.hasPassword = false; + stripped.hasKey = false; + stripped.hasKeyPassword = false; + stripped.hasSudoPassword = false; + } if (permissionLevel !== "connect") { return stripped; @@ -316,6 +411,7 @@ export function transformHostResponse( : [] : [], pin: !!host.pin, + shareSshAuth: !!host.shareSshAuth, enableTerminal: !!host.enableTerminal, enableTunnel: !!host.enableTunnel, enableFileManager: host.enableFileManager !== false, diff --git a/src/backend/database/routes/host.ts b/src/backend/database/routes/host.ts index c216c3f1..70dbd5b4 100644 --- a/src/backend/database/routes/host.ts +++ b/src/backend/database/routes/host.ts @@ -29,8 +29,12 @@ import { createCurrentSyncTombstoneRepository, } from "../repositories/factory.js"; import { + containsOwnerPrivateAuthUpdate, isNonEmptyString, + isOptionalBoolean, isValidPort, + OWNER_PRIVATE_AUTH_FIELDS, + OWNER_PRIVATE_TERMINAL_CONFIG_FIELDS, sanitizeHostForRecipient, stripSensitiveFields, transformHostResponse, @@ -47,7 +51,16 @@ import { applyHostEnrollmentDefaults, requireHostEnrollmentAccessForPath, } from "./host-enrollment-auth.js"; -import { logAudit, getRequestMeta } from "../../utils/audit-logger.js"; +import { + logAudit, + getAuditUsername, + getRequestMeta, +} from "../../utils/audit-logger.js"; +import type { HostResolutionHostRecord } from "../repositories/host-resolution-repository.js"; +import { + requiresPersonalHostAuthentication, + resolveRecipientSharedHostAuthentication, +} from "../../utils/shared-host-auth-resolver.js"; const router = express.Router(); @@ -55,11 +68,6 @@ const upload = multer({ storage: multer.memoryStorage() }); const STATS_SERVER_URL = "http://localhost:30005"; -async function getAuditUsername(userId: string): Promise { - const actor = await createCurrentUserRepository().findById(userId); - return actor?.username ?? userId; -} - function notifyStatsHostUpdated( hostId: number, headers: Pick, @@ -162,6 +170,7 @@ router.post( authMethod, authType, useWarpgate, + shareSshAuth, credentialId, vaultProfileId, key, @@ -170,6 +179,7 @@ router.post( sudoPassword, pin, enableTerminal, + enableCommandHistory, enableTunnel, enableFileManager, scpLegacy, @@ -241,7 +251,8 @@ router.post( if ( !isNonEmptyString(userId) || !isNonEmptyString(ip) || - !isValidPort(port) + !isValidPort(port) || + !isOptionalBoolean(shareSshAuth) ) { sshLogger.warn("Invalid SSH data input validation failed", { operation: "host_create", @@ -273,11 +284,13 @@ router.post( username: effectiveUsername, authType: effectiveAuthType, useWarpgate: useWarpgate ? 1 : 0, + shareSshAuth: shareSshAuth === true ? 1 : 0, credentialId: credentialId || null, vaultProfileId: vaultProfileId || null, overrideCredentialUsername: overrideCredentialUsername ? 1 : 0, pin: pin ? 1 : 0, enableTerminal: enableTerminal ? 1 : 0, + enableCommandHistory: enableCommandHistory ? 1 : 0, enableTunnel: enableTunnel ? 1 : 0, tunnelConnections: Array.isArray(tunnelConnections) ? JSON.stringify(tunnelConnections) @@ -666,8 +679,7 @@ router.post( } resolvedPassword = pickResolvedPassword(password, cred.password) as - | string - | undefined; + string | undefined; resolvedKey = cred.privateKey as string | undefined; resolvedKeyPassword = cred.keyPassword as string | undefined; resolvedKeyType = cred.keyType as string | undefined; @@ -809,6 +821,7 @@ router.put( authMethod, authType, useWarpgate, + shareSshAuth, credentialId, vaultProfileId, key, @@ -817,6 +830,7 @@ router.put( sudoPassword, pin, enableTerminal, + enableCommandHistory, enableTunnel, enableFileManager, scpLegacy, @@ -889,6 +903,7 @@ router.put( !isNonEmptyString(userId) || !isNonEmptyString(ip) || !isValidPort(port) || + !isOptionalBoolean(shareSshAuth) || !hostId ) { sshLogger.warn("Invalid SSH data input validation failed for update", { @@ -917,11 +932,13 @@ router.put( username: effectiveUsername, authType: effectiveAuthType, useWarpgate: useWarpgate ? 1 : 0, + shareSshAuth: shareSshAuth === true ? 1 : 0, credentialId: credentialId || null, vaultProfileId: vaultProfileId || null, overrideCredentialUsername: overrideCredentialUsername ? 1 : 0, pin: pin ? 1 : 0, enableTerminal: enableTerminal ? 1 : 0, + enableCommandHistory: enableCommandHistory ? 1 : 0, enableTunnel: enableTunnel ? 1 : 0, tunnelConnections: Array.isArray(tunnelConnections) ? JSON.stringify(tunnelConnections) @@ -1116,31 +1133,104 @@ router.put( const ownerId = hostRecord.userId; if (!accessInfo.isOwner) { - // Shared editors work on the owner's real record but may never - // repoint it at credential/vault references (those live in the - // owner's personal vault) or switch the authentication type. + // Shared editors work on the owner's real record, but the owner's SSH + // authentication is private and can only be changed by that owner. + if (containsOwnerPrivateAuthUpdate(hostData, "ssh")) { + return res.status(403).json({ + error: + "Only the host owner can change the host's SSH authentication", + }); + } + + const parseTerminalConfig = ( + value: unknown, + ): Record | null => { + if (!value) return null; + if ( + typeof value === "object" && + value !== null && + !Array.isArray(value) + ) { + return { ...(value as Record) }; + } + if (typeof value === "string") { + const parsed = JSON.parse(value) as unknown; + if ( + typeof parsed === "object" && + parsed !== null && + !Array.isArray(parsed) + ) { + return { ...(parsed as Record) }; + } + } + return null; + }; + + if (hostData.terminalConfig === undefined) { + delete sshDataObj.terminalConfig; + } else { + let incomingTerminalConfig: Record | null; + try { + incomingTerminalConfig = parseTerminalConfig( + hostData.terminalConfig, + ); + } catch { + return res.status(400).json({ error: "Invalid terminal config" }); + } + + if (!incomingTerminalConfig) { + return res.status(400).json({ error: "Invalid terminal config" }); + } + const protectedTerminalConfigField = + OWNER_PRIVATE_TERMINAL_CONFIG_FIELDS.find((field) => + Object.prototype.hasOwnProperty.call( + incomingTerminalConfig, + field, + ), + ); + if (protectedTerminalConfigField) { + return res.status(403).json({ + error: + "Only the host owner can change private SSH authentication settings", + }); + } + + const ownerHost = + await createCurrentHostResolutionRepository().findHostById( + Number(hostId), + ownerId, + ); + const ownerTerminalConfig = parseTerminalConfig( + ownerHost?.terminalConfig, + ); + if (ownerTerminalConfig) { + for (const field of OWNER_PRIVATE_TERMINAL_CONFIG_FIELDS) { + if ( + Object.prototype.hasOwnProperty.call(ownerTerminalConfig, field) + ) { + incomingTerminalConfig[field] = ownerTerminalConfig[field]; + } + } + } + sshDataObj.terminalConfig = JSON.stringify(incomingTerminalConfig); + } + const referenceViolations: Array<[unknown, number | null, string]> = [ - [sshDataObj.credentialId, hostRecord.credentialId, "credential"], [ - sshDataObj.rdpCredentialId, + hostData.rdpCredentialId, hostRecord.rdpCredentialId, "RDP credential", ], [ - sshDataObj.vncCredentialId, + hostData.vncCredentialId, hostRecord.vncCredentialId, "VNC credential", ], [ - sshDataObj.telnetCredentialId, + hostData.telnetCredentialId, hostRecord.telnetCredentialId, "Telnet credential", ], - [ - sshDataObj.vaultProfileId, - hostRecord.vaultProfileId, - "Vault profile", - ], ]; for (const [incoming, current, label] of referenceViolations) { @@ -1151,13 +1241,8 @@ router.put( } } - if ( - sshDataObj.authType !== undefined && - sshDataObj.authType !== hostRecord.authType - ) { - return res.status(403).json({ - error: "Only the host owner can change the authentication type", - }); + for (const field of OWNER_PRIVATE_AUTH_FIELDS.ssh) { + delete sshDataObj[field]; } } @@ -1465,9 +1550,14 @@ router.get( sharedExpiresAt: accessInfo.expiresAt || undefined, ownerUsername, }; + const resolvedSharedResult = + (await resolveHostCredentials(sharedResult, userId)) || sharedResult; res.json( - sanitizeHostForRecipient(sharedResult, accessInfo.permissionLevel), + sanitizeHostForRecipient( + resolvedSharedResult, + accessInfo.permissionLevel, + ), ); } catch (err) { sshLogger.error("Failed to fetch SSH host by ID from database", err, { @@ -1529,10 +1619,11 @@ router.get( } try { - const host = await createCurrentHostResolutionRepository().findHostById( - hostId, - userId, - ); + const host = + await createCurrentHostResolutionRepository().findHostByIdForUser( + hostId, + userId, + ); if (!host) { return res.status(404).json({ error: "Host not found" }); @@ -2206,61 +2297,136 @@ async function resolveHostCredentials( requestingUserId?: string, ): Promise> { try { - if (host.credentialId && (host.userId || host.ownerId)) { - const credentialId = host.credentialId as number; - const ownerId = (host.ownerId || host.userId) as string; + const ownerId = (host.ownerId || host.userId) as string | undefined; + if ( + requestingUserId && + ownerId && + requestingUserId !== ownerId && + typeof host.id === "number" + ) { + const authHost = host as unknown as HostResolutionHostRecord; + const needsPersonalCredential = requiresPersonalHostAuthentication( + authHost, + "ssh", + ); + const baseSshOverrideState = { + required: needsPersonalCredential, + ownerAuthShared: !!host.shareSshAuth, + }; + const recipientHost: Record = { + ...host, + credentialId: null, + password: null, + key: null, + keyPassword: null, + keyType: null, + authOverrides: { + ssh: baseSshOverrideState, + }, + }; - if (requestingUserId && requestingUserId !== ownerId) { - try { - const { SharedHostSecretsManager } = - await import("../../utils/shared-host-secrets-manager.js"); - const sharedCred = - await SharedHostSecretsManager.getInstance().getSecretForUser( - host.id as number, - requestingUserId, - "ssh", - ); + try { + const resolution = await resolveRecipientSharedHostAuthentication( + authHost, + host.id, + requestingUserId, + "ssh", + ); - if (sharedCred) { - const resolvedHost: Record = { - ...host, - password: sharedCred.password, - key: sharedCred.key, - keyPassword: sharedCred.keyPassword, - keyType: sharedCred.keyType, + if (resolution.source === "personal-override") { + const credential = resolution.credential; + return { + ...recipientHost, + authOverrides: { + ssh: { + credentialId: resolution.credentialId, + required: false, + ownerAuthShared: !!host.shareSshAuth, + }, + }, + authType: + credential.key || credential.privateKey + ? "key" + : credential.password + ? "password" + : "none", + username: credential.username || recipientHost.username, + password: credential.password, + key: credential.privateKey || credential.key, + keyPassword: credential.keyPassword, + keyType: credential.keyType, + }; + } + + if (resolution.source === "owner-shared") { + if (resolution.authType === "agent") { + return { + ...recipientHost, + authOverrides: { + ssh: { + required: false, + ownerAuthShared: true, + }, + }, + authType: "agent", }; + } + const sharedAuth = resolution.secret; + if (sharedAuth) { const resolvedUsername = pickResolvedUsername( - host.username, - sharedCred.username, + recipientHost.username, + sharedAuth.username, host.overrideCredentialUsername, ); - if (resolvedUsername !== undefined) { - resolvedHost.username = resolvedUsername; - } - - return resolvedHost; + return { + ...recipientHost, + authOverrides: { + ssh: { + required: false, + ownerAuthShared: true, + }, + }, + authType: sharedAuth.key + ? "key" + : sharedAuth.password + ? "password" + : "none", + username: resolvedUsername, + password: sharedAuth.password, + key: sharedAuth.key, + keyPassword: sharedAuth.keyPassword, + keyType: sharedAuth.keyType, + }; } - } catch (sharedCredError) { - sshLogger.warn( - "Failed to get shared credential, falling back to owner credential", - { - operation: "resolve_shared_credential_fallback", - hostId: host.id as number, - requestingUserId, - error: - sharedCredError instanceof Error - ? sharedCredError.message - : "Unknown error", - }, - ); } + + if (resolution.source === "secretless") { + return { + ...recipientHost, + authOverrides: { + ssh: { + required: false, + ownerAuthShared: !!host.shareSshAuth, + }, + }, + }; + } + } catch { + // A missing/deleted override or snapshot behaves like unavailable auth. } + return recipientHost; + } + + if (host.credentialId && (host.userId || host.ownerId)) { + const credentialId = host.credentialId as number; + const credentialOwnerId = (host.ownerId || host.userId) as string; + const credential = await createCurrentHostResolutionRepository().findCredentialByIdForUser( credentialId, - ownerId, + credentialOwnerId, ); if (credential) { diff --git a/src/backend/database/routes/proxmox-import-auth.ts b/src/backend/database/routes/proxmox-import-auth.ts new file mode 100644 index 00000000..00306c8e --- /dev/null +++ b/src/backend/database/routes/proxmox-import-auth.ts @@ -0,0 +1,44 @@ +// Pure decision: which auth settings an imported Proxmox guest inherits. +// +// The frontend carries a parallel copy in +// src/ui/components/proxmox/proxmox-import-auth.ts. The two drifting apart is +// what produced the reported import bug, so both are kept behaviourally +// identical and each is unit-tested against the same matrix. +export function resolveProxmoxImportAuth( + defaultAuthType: string | undefined, + credentialId: number | null | undefined, +): { + authType: string; + credentialId: number | null; + overrideCredentialUsername: number; +} { + // An explicit special auth type (none/opkssh/tailscale/vault/…) wins. + if ( + defaultAuthType && + defaultAuthType !== "credential" && + !["password", "key"].includes(defaultAuthType) + ) { + return { + authType: defaultAuthType, + credentialId: null, + overrideCredentialUsername: 0, + }; + } + + // A credential (configured default OR inherited from the source host) is a + // concrete auth source -> use it, even when defaultAuthType is the + // "password"/"key" default. + if (credentialId) { + return { + authType: "credential", + credentialId, + overrideCredentialUsername: 1, + }; + } + + return { + authType: "none", + credentialId: null, + overrideCredentialUsername: 0, + }; +} diff --git a/src/backend/database/routes/proxmox.ts b/src/backend/database/routes/proxmox.ts index 553c1b20..1fc78372 100644 --- a/src/backend/database/routes/proxmox.ts +++ b/src/backend/database/routes/proxmox.ts @@ -2,14 +2,14 @@ import express from "express"; import { Client as SSHClient } from "ssh2"; import { logger } from "../../utils/logger.js"; import { DataCrypto } from "../../utils/data-crypto.js"; -import { - createCurrentCredentialRepository, - createCurrentHostRepository, -} from "../repositories/factory.js"; +import { createCurrentHostRepository } from "../repositories/factory.js"; import { AuthManager } from "../../utils/auth-manager.js"; import type { AuthenticatedRequest } from "../../../types/index.js"; import type { SSHHost } from "../../../types/index.js"; import { SSHHostKeyVerifier } from "../../hosts/host-key-verifier.js"; +import { resolveHostById } from "../../hosts/host-resolver.js"; +import { createJumpHostChain } from "../../hosts/jump-host-chain.js"; +import { resolveProxmoxImportAuth } from "./proxmox-import-auth.js"; const router = express.Router(); const proxmoxLogger = logger; @@ -35,7 +35,7 @@ function isSafeNodeName(name: string): boolean { function execCommand( client: SSHClient, command: string, - timeoutMs = 8000, + timeoutMs = 25000, ): Promise { return new Promise((resolve, reject) => { let settled = false; @@ -187,6 +187,20 @@ type ProxmoxSyncResult = { errors: string[]; }; +function parseJumpHostsField(raw: unknown): unknown[] | null { + if (!raw) return null; + if (Array.isArray(raw)) return raw; + if (typeof raw === "string") { + try { + const parsed = JSON.parse(raw); + return Array.isArray(parsed) ? parsed : null; + } catch { + return null; + } + } + return null; +} + function parseJsonObject(value: unknown): Record { if (!value) return {}; if (typeof value === "object") return value as Record; @@ -248,43 +262,16 @@ function mergeTags( .join(","); } -function resolveProxmoxImportAuth( - defaultAuthType: string | undefined, - credentialId: number | null | undefined, -): { - authType: string; - credentialId: number | null; - overrideCredentialUsername: number; -} { - if (defaultAuthType === "credential" || (!defaultAuthType && credentialId)) { - return credentialId - ? { authType: "credential", credentialId, overrideCredentialUsername: 1 } - : { authType: "none", credentialId: null, overrideCredentialUsername: 0 }; - } - - if (defaultAuthType && !["password", "key"].includes(defaultAuthType)) { - return { - authType: defaultAuthType, - credentialId: null, - overrideCredentialUsername: 0, - }; - } - - return { - authType: "none", - credentialId: null, - overrideCredentialUsername: 0, - }; -} - async function discoverProxmoxGuestsForHost( userId: string, parsedHostId: number, + onProgress?: (done: number, total: number) => void, ): Promise<{ host: SSHHost; guests: ProxmoxGuest[]; credentialId: number | null; defaultCredentialId: number | null; + jumpHosts: unknown[] | null; config: ReturnType; }> { if (!DataCrypto.canUserAccessData(userId)) { @@ -293,34 +280,18 @@ async function discoverProxmoxGuestsForHost( throw error; } - const hostRecord = await createCurrentHostRepository().findDecryptedByIdAs( - userId, - parsedHostId, - ); - - if (!hostRecord) { + const resolvedHost = await resolveHostById(parsedHostId, userId); + if (!resolvedHost) { const error = new Error("Host not found"); (error as Error & { status?: number }).status = 404; throw error; } - const host = hostRecord as unknown as SSHHost; + const host = resolvedHost as SSHHost; const proxmoxCfgRaw = parseJsonObject(host.proxmoxConfig); const config = parseProxmoxConfig(proxmoxCfgRaw); - if (host.userId !== userId) { - const { PermissionManager } = - await import("../../utils/permission-manager.js"); - const pm = PermissionManager.getInstance(); - const access = await pm.canAccessHost(userId, parsedHostId, "connect"); - if (!access.hasAccess) { - const error = new Error("Access denied"); - (error as Error & { status?: number }).status = 403; - throw error; - } - } - - let resolvedCredentials: { + const resolvedCredentials: { password?: string; sshKey?: string; keyPassword?: string; @@ -334,50 +305,6 @@ async function discoverProxmoxGuestsForHost( const hostCredentialId = host.credentialId ?? null; - if (host.credentialId) { - if (userId !== host.userId) { - try { - const { SharedHostSecretsManager } = - await import("../../utils/shared-host-secrets-manager.js"); - const sharedCred = - await SharedHostSecretsManager.getInstance().getSecretForUser( - host.id, - userId, - "ssh", - ); - if (sharedCred) { - resolvedCredentials = { - password: sharedCred.password, - sshKey: sharedCred.key, - keyPassword: sharedCred.keyPassword, - authType: sharedCred.authType, - }; - } - } catch (err) { - proxmoxLogger.error("Failed to resolve shared credential", err, { - operation: "proxmox_discover", - hostId: parsedHostId, - userId, - }); - } - } else { - const cred = - await createCurrentCredentialRepository().findDecryptedByIdForUser( - userId, - host.credentialId as number, - ); - if (cred) { - const c = cred; - resolvedCredentials = { - password: c.password as string | undefined, - sshKey: (c.key || c.privateKey) as string | undefined, - keyPassword: c.keyPassword as string | undefined, - authType: c.authType as string | undefined, - }; - } - } - } - const sshConfig: Record = { host: host.ip?.replace(/^\[|\]$/g, "") || host.ip, port: host.port || 22, @@ -420,7 +347,51 @@ async function discoverProxmoxGuestsForHost( await new Promise((resolve, reject) => { client.on("ready", resolve); client.on("error", reject); - client.connect(sshConfig as import("ssh2").ConnectConfig); + + // Reuse the shared jump-host chain (same path terminal/metrics use) + // so Proxmox hosts that are only reachable via a jump host work too + // (otherwise the direct connect fails with EHOSTUNREACH). jumpHosts is + // stored as a JSON string on the decrypted record, so parse it first. + let parsedJumpHosts: Array<{ hostId: number }> = []; + try { + const rawJumpHosts = (host as { jumpHosts?: unknown }).jumpHosts; + const parsed = + typeof rawJumpHosts === "string" + ? JSON.parse(rawJumpHosts) + : rawJumpHosts; + if (Array.isArray(parsed)) parsedJumpHosts = parsed; + } catch { + parsedJumpHosts = []; + } + + if (parsedJumpHosts.length > 0) { + createJumpHostChain(parsedJumpHosts, userId) + .then((jumpClient) => { + if (!jumpClient) { + reject(new Error("Jump host chain could not be established")); + return; + } + jumpClient.forwardOut( + "127.0.0.1", + 0, + sshConfig.host as string, + sshConfig.port as number, + (err, stream) => { + if (err || !stream) { + reject(err || new Error("Jump host forward failed")); + return; + } + sshConfig.sock = stream; + delete sshConfig.host; + delete sshConfig.port; + client.connect(sshConfig as import("ssh2").ConnectConfig); + }, + ); + }) + .catch(reject); + } else { + client.connect(sshConfig as import("ssh2").ConnectConfig); + } }); proxmoxLogger.info("Proxmox discovery SSH connection established", { @@ -493,7 +464,7 @@ async function discoverProxmoxGuestsForHost( const cfgJson = await execCommand( client, `pvesh get /nodes/${g.node}/lxc/${g.vmid}/config --output-format json 2>/dev/null`, - 8000, + 25000, ); configIp = parseLxcIp(JSON.parse(cfgJson), config.preferredPrefixes); } catch { @@ -507,7 +478,7 @@ async function discoverProxmoxGuestsForHost( const ifRaw = await execCommand( client, `pvesh get /nodes/${g.node}/lxc/${g.vmid}/interfaces --output-format json 2>/dev/null`, - 5000, + 12000, ); const data = JSON.parse(ifRaw); const entries: Array> = Array.isArray(data) @@ -539,7 +510,7 @@ async function discoverProxmoxGuestsForHost( const ifJson = await execCommand( client, `pvesh get /nodes/${g.node}/qemu/${g.vmid}/agent/network-get-interfaces --output-format json 2>/dev/null`, - 5000, + 12000, ); const data = JSON.parse(ifJson); const ifaces: Array> = Array.isArray( @@ -577,13 +548,21 @@ async function discoverProxmoxGuestsForHost( return null; } - const CONCURRENCY = 6; + // Low concurrency on purpose: pvesh is heavy and small Proxmox nodes + // (especially reached over a high-latency jump chain) suffer severe + // contention when many run at once — calls then exceed execCommand's + // timeout and IPs come back empty. 2 keeps each call well under budget. + const CONCURRENCY = 2; const ips: (string | null)[] = new Array(guestBases.length).fill(null); let cursor = 0; + let completed = 0; + onProgress?.(0, guestBases.length); async function ipWorker() { while (cursor < guestBases.length) { const i = cursor++; ips[i] = await resolveIp(guestBases[i]); + completed++; + onProgress?.(completed, guestBases.length); } } await Promise.all( @@ -613,6 +592,9 @@ async function discoverProxmoxGuestsForHost( guests, credentialId: hostCredentialId, defaultCredentialId: config.defaultCredentialId, + jumpHosts: parseJumpHostsField( + (host as unknown as { jumpHosts?: unknown }).jumpHosts, + ), config, }; } finally { @@ -688,14 +670,6 @@ async function syncProxmoxHost( missingSince: null, }; - if (!existing && !guest.ip) { - result.skipped++; - result.errors.push( - `${guest.name}: skipped because no IP address was discovered`, - ); - continue; - } - const baseConfig = existing ? parseJsonObject(existing.proxmoxConfig) : {}; @@ -718,11 +692,11 @@ async function syncProxmoxHost( typeof existing?.username === "string" && existing.username ? existing.username : connectionType === "rdp" - ? null + ? "" : "root"; const update: Record = { name: guest.name, - ip: guest.ip || existing?.ip, + ip: guest.ip || existing?.ip || "0.0.0.0", port, username, connectionType, @@ -781,7 +755,9 @@ async function syncProxmoxHost( telnetPort: null, defaultPath: "/", tunnelConnections: "[]", - jumpHosts: null, + jumpHosts: + (discovery.host as unknown as { jumpHosts?: string | null }) + .jumpHosts ?? null, quickActions: null, statsConfig: null, dockerConfig: null, @@ -1010,6 +986,81 @@ proxmoxAutoSyncStartupTimer.unref?.(); * 500: * description: Discovery failed. */ +router.get( + "/discover/stream", + authenticateJWT, + requireDataAccess, + async (req, res) => { + const userId = (req as unknown as AuthenticatedRequest).userId; + const parsedHostId = Number((req.query as { hostId?: unknown }).hostId); + if (!parsedHostId || !Number.isInteger(parsedHostId) || parsedHostId <= 0) { + return res.status(400).json({ error: "Missing or invalid hostId" }); + } + + res.writeHead(200, { + "Content-Type": "text/event-stream", + "Cache-Control": "no-store, no-transform", + Connection: "keep-alive", + "X-Accel-Buffering": "no", + }); + res.flushHeaders?.(); + + let closed = false; + const send = (event: string, data: unknown) => { + if (closed) return; + try { + res.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`); + } catch { + closed = true; + } + }; + const heartbeat = setInterval(() => { + if (closed) return; + try { + res.write(": keepalive\n\n"); + } catch { + closed = true; + clearInterval(heartbeat); + } + }, 15000); + req.on("close", () => { + closed = true; + clearInterval(heartbeat); + }); + + try { + const discovery = await discoverProxmoxGuestsForHost( + userId, + parsedHostId, + (done, total) => send("progress", { done, total }), + ); + send("result", { + guests: discovery.guests, + credentialId: discovery.credentialId, + defaultCredentialId: discovery.defaultCredentialId, + jumpHosts: discovery.jumpHosts, + }); + } catch (err: unknown) { + const message = err instanceof Error ? err.message : "Unknown error"; + proxmoxLogger.error("Proxmox discovery (stream) failed", err, { + operation: "proxmox_discover", + hostId: parsedHostId, + userId, + }); + send("fail", { message }); + } finally { + clearInterval(heartbeat); + if (!closed) { + try { + res.end(); + } catch { + // ignore end errors + } + } + } + }, +); + router.post( "/discover", authenticateJWT, @@ -1032,6 +1083,7 @@ router.post( guests: discovery.guests, credentialId: discovery.credentialId, defaultCredentialId: discovery.defaultCredentialId, + jumpHosts: discovery.jumpHosts, }); } catch (err: unknown) { const message = err instanceof Error ? err.message : "Unknown error"; diff --git a/src/backend/database/routes/rbac.ts b/src/backend/database/routes/rbac.ts index 8be290c4..e30ce3c4 100644 --- a/src/backend/database/routes/rbac.ts +++ b/src/backend/database/routes/rbac.ts @@ -3,6 +3,12 @@ import express from "express"; import type { Response } from "express"; import { databaseLogger } from "../../utils/logger.js"; import { AuthManager } from "../../utils/auth-manager.js"; +import { getRequestMeta } from "../../utils/audit-logger.js"; +import { isAuthOverrideProtocol } from "../../../types/auth-protocols.js"; +import { + SharedHostAuthOverrideService, + SharedHostAuthOverrideServiceError, +} from "../../utils/shared-host-auth-override-service.js"; import { PermissionManager, SHARE_PERMISSION_LEVELS, @@ -13,7 +19,6 @@ import { isValidPermission, } from "../../utils/permission-catalog.js"; import { - createCurrentCredentialRepository, createCurrentHostFolderRepository, createCurrentHostResolutionRepository, createCurrentRbacAccessRepository, @@ -28,6 +33,9 @@ const authManager = AuthManager.getInstance(); const permissionManager = PermissionManager.getInstance(); const authenticateJWT = authManager.createAuthMiddleware(); +const requireDataAccess = authManager.createDataAccessMiddleware(); +const sharedHostAuthOverrideService = + SharedHostAuthOverrideService.getInstance(); function isNonEmptyString(value: unknown): value is string { return typeof value === "string" && value.trim().length > 0; @@ -95,7 +103,7 @@ function parseShareTargets( * /rbac/host/{id}/share: * post: * summary: Share a host - * description: Shares a host with one or more users and/or roles at a permission level (connect, view, edit, manage). Allowed for the host owner or recipients holding the manage level. Every auth type is shareable; per-recipient secret snapshots are created automatically. + * description: Shares a host with one or more users and/or roles at a permission level (connect, view, edit, manage). SSH authentication remains private to the owner; recipients may select one of their own saved SSH credentials. * tags: * - RBAC * parameters: @@ -352,8 +360,6 @@ router.post( * description: Folder shared successfully. * 400: * description: Invalid request body. - * 404: - * description: Folder has no hosts. * 500: * description: Failed to share folder. */ @@ -415,9 +421,6 @@ router.post( userId, folder, ); - if (hostsInFolder.length === 0) { - return res.status(404).json({ error: "Folder has no hosts" }); - } const expiresAt = expiryFromDuration(durationHours); const rbacAccessRepository = createCurrentRbacAccessRepository(); @@ -1765,50 +1768,106 @@ router.get( }, ); +/** + * @openapi + * /rbac/host-access/{hostId}/auth/{protocol}: + * put: + * summary: Set personal authentication for a shared host protocol + * description: Selects one of the authenticated recipient's own credentials, or clears the selection with null. Only SSH is currently supported. + * tags: [RBAC] + * security: + * - bearerAuth: [] + */ router.put( - "/host-access/:hostId/credential", + "/host-access/:hostId/auth/:protocol", + authenticateJWT, + requireDataAccess, async (req: express.Request, res: express.Response) => { try { const userId = (req as AuthenticatedRequest).userId!; const hostId = Number.parseInt(String(req.params.hostId), 10); + const protocol = req.params.protocol; const { credentialId } = req.body; - if (!hostId || isNaN(hostId)) { + if (!Number.isInteger(hostId) || hostId <= 0) { return res.status(400).json({ error: "Invalid host ID" }); } - - const access = - await createCurrentRbacAccessRepository().findDirectHostAccess( - hostId, - userId, - ); - - if (!access) { - return res.status(403).json({ error: "No access to this host" }); + if (!isAuthOverrideProtocol(protocol)) { + return res + .status(400) + .json({ error: "Invalid authentication protocol" }); } - if (credentialId) { - const cred = await createCurrentCredentialRepository().findByIdForUser( - userId, - credentialId, - ); - - if (!cred) { - return res.status(404).json({ error: "Credential not found" }); - } + if ( + credentialId !== null && + (!Number.isInteger(credentialId) || credentialId <= 0) + ) { + return res.status(400).json({ + error: "credentialId must be a positive integer or null", + }); } - await createCurrentRbacAccessRepository().updateHostAccessOverrideCredential( - access.id, - credentialId || null, + const { ipAddress, userAgent } = getRequestMeta(req); + await sharedHostAuthOverrideService.setCredentialId( + hostId, + userId, + protocol, + credentialId, + { ipAddress, userAgent }, ); - - res.json({ success: true }); + res.json({ success: true, protocol, credentialId }); } catch (error) { + if (error instanceof SharedHostAuthOverrideServiceError) { + return res.status(error.statusCode).json({ error: error.message }); + } databaseLogger.error("Failed to set override credential", error); res.status(500).json({ error: "Failed to update credential" }); } }, ); +/** + * @openapi + * /rbac/host-access/{hostId}/auth/{protocol}: + * get: + * summary: Get the current recipient's shared-host protocol authentication override + * tags: [RBAC] + * security: + * - bearerAuth: [] + */ +router.get( + "/host-access/:hostId/auth/:protocol", + authenticateJWT, + requireDataAccess, + async (req: express.Request, res: express.Response) => { + try { + const userId = (req as AuthenticatedRequest).userId!; + const hostId = Number.parseInt(String(req.params.hostId), 10); + const protocol = req.params.protocol; + + if (!Number.isInteger(hostId) || hostId <= 0) { + return res.status(400).json({ error: "Invalid host ID" }); + } + if (!isAuthOverrideProtocol(protocol)) { + return res + .status(400) + .json({ error: "Invalid authentication protocol" }); + } + + const credentialId = await sharedHostAuthOverrideService.getCredentialId( + hostId, + userId, + protocol, + ); + res.json({ protocol, credentialId }); + } catch (error) { + if (error instanceof SharedHostAuthOverrideServiceError) { + return res.status(error.statusCode).json({ error: error.message }); + } + databaseLogger.error("Failed to get override credential", error); + res.status(500).json({ error: "Failed to fetch credential" }); + } + }, +); + export default router; diff --git a/src/backend/database/routes/snippets-execution.ts b/src/backend/database/routes/snippets-execution.ts new file mode 100644 index 00000000..f13bd4ac --- /dev/null +++ b/src/backend/database/routes/snippets-execution.ts @@ -0,0 +1,29 @@ +export interface SnippetExecutionResult { + success: boolean; + output: string; + error?: string; +} + +export function getSnippetExecutionTimeoutMs( + value = process.env.SNIPPET_EXECUTION_TIMEOUT_SECONDS, +): number | undefined { + if (value === undefined || value.trim() === "") return undefined; + + const seconds = Number(value); + if (!Number.isFinite(seconds) || seconds <= 0) return undefined; + + return seconds * 1000; +} + +export function createSnippetExecutionResult( + exitCode: number | null, + output: string, + errorOutput: string, +): SnippetExecutionResult { + const success = exitCode === 0 || (exitCode === null && !errorOutput); + return { + success, + output, + ...(errorOutput ? { error: errorOutput } : {}), + }; +} diff --git a/src/backend/database/routes/snippets.ts b/src/backend/database/routes/snippets.ts index 0680ebc7..8b9f4db7 100644 --- a/src/backend/database/routes/snippets.ts +++ b/src/backend/database/routes/snippets.ts @@ -5,6 +5,10 @@ import { authLogger, databaseLogger } from "../../utils/logger.js"; import { AuthManager } from "../../utils/auth-manager.js"; import { SSH_ALGORITHMS } from "../../utils/ssh-algorithms.js"; import { extractSnippetReorderUpdates } from "./snippets-reorder.js"; +import { + createSnippetExecutionResult, + getSnippetExecutionTimeoutMs, +} from "./snippets-execution.js"; import { logAudit, getRequestMeta } from "../../utils/audit-logger.js"; import { createCurrentHostResolutionRepository, @@ -594,8 +598,7 @@ router.post( authType = (cred.authType || authType) as string; password = (cred.password || undefined) as string | undefined; privateKey = (cred.privateKey || cred.key || undefined) as - | string - | undefined; + string | undefined; passphrase = (cred.keyPassword || undefined) as string | undefined; } } @@ -609,10 +612,8 @@ router.post( output: string; error?: string; }>((resolve, reject) => { - const timeout = setTimeout(() => { - conn.end(); - reject(new Error("Command execution timeout (30s)")); - }, 30000); + const timeoutMs = getSnippetExecutionTimeoutMs(); + let timeout: NodeJS.Timeout | undefined; conn.on("ready", () => { conn.exec(snippet.content, (err, stream) => { @@ -622,14 +623,21 @@ router.post( return reject(err); } - stream.on("close", () => { + if (timeoutMs) { + timeout = setTimeout(() => { + conn.end(); + reject( + new Error(`Command execution timeout (${timeoutMs / 1000}s)`), + ); + }, timeoutMs); + } + + stream.on("close", (exitCode: number | null) => { clearTimeout(timeout); conn.end(); - if (errorOutput) { - resolve({ success: false, output, error: errorOutput }); - } else { - resolve({ success: true, output }); - } + resolve( + createSnippetExecutionResult(exitCode, output, errorOutput), + ); }); stream.on("data", (data: Buffer) => { diff --git a/src/backend/database/routes/sso-provider-routes.ts b/src/backend/database/routes/sso-provider-routes.ts index fc29e095..d5837b44 100644 --- a/src/backend/database/routes/sso-provider-routes.ts +++ b/src/backend/database/routes/sso-provider-routes.ts @@ -8,54 +8,39 @@ import { AuthManager } from "../../utils/auth-manager.js"; import type { SSOProviderType } from "../../../types/index.js"; import { createCurrentSsoProviderRepository } from "../repositories/factory.js"; import { getOIDCConfigFromEnv } from "./user-oidc-utils.js"; +import { + decryptSsoConfigSecrets, + encryptSsoConfigSecrets, +} from "../../utils/system-secret-crypto.js"; const authManager = AuthManager.getInstance(); -function decryptProviderConfig( +/** + * SSO secrets belong to the installation, not to a user: `sso_providers` has no + * userId and the values must be readable during login, before anyone is + * authenticated. They are encrypted with the system key rather than a user DEK. + * Values written by the previous base64 scheme still decode, and are upgraded + * the next time the provider is saved. + */ +async function decryptProviderConfig( configJson: string, _userId: string, -): Record { +): Promise> { let config: Record; try { config = JSON.parse(configJson); } catch { return {}; } - - for (const field of ["client_secret", "bindPassword"] as const) { - const val = config[field] as string | undefined; - if (val?.startsWith("encoded:")) { - try { - config[field] = Buffer.from(val.substring(8), "base64").toString( - "utf8", - ); - } catch { - config[field] = "[ENCODING ERROR]"; - } - } - } - return config; + return decryptSsoConfigSecrets(config); } -function encryptProviderConfig( +async function encryptProviderConfig( config: Record, _userId: string, _providerId: string, -): string { - const encoded: Record = { ...config }; - if ( - typeof config.client_secret === "string" && - !config.client_secret.startsWith("encoded:") - ) { - encoded.client_secret = `encoded:${Buffer.from(config.client_secret).toString("base64")}`; - } - if ( - typeof config.bindPassword === "string" && - !config.bindPassword.startsWith("encoded:") - ) { - encoded.bindPassword = `encoded:${Buffer.from(config.bindPassword).toString("base64")}`; - } - return JSON.stringify(encoded); +): Promise { + return JSON.stringify(await encryptSsoConfigSecrets(config)); } function applyProviderDefaults( @@ -141,10 +126,12 @@ export function registerSSOProviderRoutes(router: Router): void { try { const rows = await createCurrentSsoProviderRepository().listAll(); - const result = rows.map((row) => ({ - ...row, - config: decryptProviderConfig(row.config, userId), - })); + const result = await Promise.all( + rows.map(async (row) => ({ + ...row, + config: await decryptProviderConfig(row.config, userId), + })), + ); res.json(result); } catch (err) { authLogger.error("Failed to list SSO providers (admin)", err); @@ -253,7 +240,7 @@ export function registerSSOProviderRoutes(router: Router): void { } const tempId = `new-${Date.now()}`; - const encryptedConfig = encryptProviderConfig( + const encryptedConfig = await encryptProviderConfig( configWithDefaults as Record, userId, tempId, @@ -275,7 +262,7 @@ export function registerSSOProviderRoutes(router: Router): void { }); res.status(201).json({ ...inserted, - config: decryptProviderConfig(inserted.config, userId), + config: await decryptProviderConfig(inserted.config, userId), }); } catch (err) { authLogger.error("Failed to create SSO provider", err); @@ -332,7 +319,7 @@ export function registerSSOProviderRoutes(router: Router): void { let encryptedConfig = existing.config; if (rawConfig !== undefined) { - const existingDecrypted = decryptProviderConfig( + const existingDecrypted = await decryptProviderConfig( existing.config, userId, ); @@ -342,7 +329,7 @@ export function registerSSOProviderRoutes(router: Router): void { ), ...rawConfig, }; - encryptedConfig = encryptProviderConfig( + encryptedConfig = await encryptProviderConfig( mergedConfig, userId, String(providerId), @@ -369,7 +356,7 @@ export function registerSSOProviderRoutes(router: Router): void { }); res.json({ ...updated, - config: decryptProviderConfig(updated.config, userId), + config: await decryptProviderConfig(updated.config, userId), }); } catch (err) { authLogger.error("Failed to update SSO provider", err); diff --git a/src/backend/database/routes/sync-references.ts b/src/backend/database/routes/sync-references.ts new file mode 100644 index 00000000..1ed573ca --- /dev/null +++ b/src/backend/database/routes/sync-references.ts @@ -0,0 +1,95 @@ +import type { SyncEntityType } from "../repositories/sync-tombstone-repository.js"; + +export type SyncReferenceEntity = "sshCredentials" | "vaultProfiles"; + +interface SyncReference { + field: string; + syncField: string; + entityType: SyncReferenceEntity; +} + +const HOST_REFERENCES: SyncReference[] = [ + { + field: "credentialId", + syncField: "credentialSyncId", + entityType: "sshCredentials", + }, + { + field: "rdpCredentialId", + syncField: "rdpCredentialSyncId", + entityType: "sshCredentials", + }, + { + field: "vncCredentialId", + syncField: "vncCredentialSyncId", + entityType: "sshCredentials", + }, + { + field: "telnetCredentialId", + syncField: "telnetCredentialSyncId", + entityType: "sshCredentials", + }, + { + field: "vaultProfileId", + syncField: "vaultProfileSyncId", + entityType: "vaultProfiles", + }, +]; + +const REFERENCES: Partial> = { + hosts: HOST_REFERENCES, + sshFolders: [HOST_REFERENCES[0]], +}; + +export async function serializeSyncReferences( + entityType: SyncEntityType, + row: Record, + resolveSyncId: ( + entityType: SyncReferenceEntity, + id: number, + ) => Promise, +): Promise> { + const serialized = { ...row }; + for (const reference of REFERENCES[entityType] ?? []) { + const id = serialized[reference.field]; + serialized[reference.syncField] = + typeof id === "number" + ? await resolveSyncId(reference.entityType, id) + : null; + delete serialized[reference.field]; + } + return serialized; +} + +export async function deserializeSyncReferences( + entityType: SyncEntityType, + row: Record, + resolveId: ( + entityType: SyncReferenceEntity, + syncId: string, + ) => Promise, +): Promise> { + const deserialized = { ...row }; + for (const reference of REFERENCES[entityType] ?? []) { + const syncId = deserialized[reference.syncField]; + delete deserialized[reference.syncField]; + delete deserialized[reference.field]; + + if (syncId == null) { + deserialized[reference.field] = null; + continue; + } + if (typeof syncId !== "string") { + throw new Error(`Invalid ${reference.syncField}`); + } + + const id = await resolveId(reference.entityType, syncId); + if (id === null) { + throw new Error( + `Missing ${reference.entityType} dependency ${reference.syncField}=${syncId}`, + ); + } + deserialized[reference.field] = id; + } + return deserialized; +} diff --git a/src/backend/database/routes/sync.ts b/src/backend/database/routes/sync.ts index 3f1198c4..297d22e2 100644 --- a/src/backend/database/routes/sync.ts +++ b/src/backend/database/routes/sync.ts @@ -1,6 +1,6 @@ import type { Request, Response } from "express"; import express from "express"; -import { and, eq, gt } from "drizzle-orm"; +import { and, eq } from "drizzle-orm"; import { hosts, sshCredentials, @@ -10,6 +10,7 @@ import { vaultProfiles, dashboardServiceLinks, homepageItems, + userPreferences, } from "../db/schema.js"; import { AuthManager } from "../../utils/auth-manager.js"; import { DataCrypto } from "../../utils/data-crypto.js"; @@ -21,6 +22,12 @@ import { createCurrentSyncTombstoneRepository, } from "../repositories/factory.js"; import type { SyncEntityType } from "../repositories/sync-tombstone-repository.js"; +import { + deserializeSyncReferences, + serializeSyncReferences, + type SyncReferenceEntity, +} from "./sync-references.js"; +import { timestampAtOrAfter } from "../sync-timestamp.js"; const router = express.Router(); const authManager = AuthManager.getInstance(); @@ -43,11 +50,13 @@ interface EntityConfig { | typeof snippetFolders | typeof vaultProfiles | typeof dashboardServiceLinks - | typeof homepageItems; + | typeof homepageItems + | typeof userPreferences; // Fields that only make sense on the device that created the row, or // that are managed elsewhere and must never be overwritten by a sync // payload from the other side. readOnlyFields: string[]; + singleton?: boolean; } const ENTITY_CONFIG: Record = { @@ -62,14 +71,73 @@ const ENTITY_CONFIG: Record = { vaultProfiles: { table: vaultProfiles, readOnlyFields: [] }, dashboardServiceLinks: { table: dashboardServiceLinks, readOnlyFields: [] }, homepageItems: { table: homepageItems, readOnlyFields: [] }, + userPreferences: { + table: userPreferences, + readOnlyFields: ["storageMode"], + singleton: true, + }, }; const VALID_ENTITY_TYPES = new Set(Object.keys(ENTITY_CONFIG)); +type RepositoryContext = ReturnType; export function isValidEntityType(value: unknown): value is SyncEntityType { return typeof value === "string" && VALID_ENTITY_TYPES.has(value); } +async function findReferenceSyncId( + context: RepositoryContext, + entityType: SyncReferenceEntity, + id: number, + userId: string, +): Promise { + if (entityType === "sshCredentials") { + const [row] = await context.drizzle + .select({ syncId: sshCredentials.syncId }) + .from(sshCredentials) + .where(and(eq(sshCredentials.id, id), eq(sshCredentials.userId, userId))) + .limit(1); + return row?.syncId ?? null; + } + + const [row] = await context.drizzle + .select({ syncId: vaultProfiles.syncId }) + .from(vaultProfiles) + .where(and(eq(vaultProfiles.id, id), eq(vaultProfiles.userId, userId))) + .limit(1); + return row?.syncId ?? null; +} + +async function findReferenceId( + context: RepositoryContext, + entityType: SyncReferenceEntity, + syncId: string, + userId: string, +): Promise { + if (entityType === "sshCredentials") { + const [row] = await context.drizzle + .select({ id: sshCredentials.id }) + .from(sshCredentials) + .where( + and( + eq(sshCredentials.syncId, syncId), + eq(sshCredentials.userId, userId), + ), + ) + .limit(1); + return row?.id ?? null; + } + + const [row] = await context.drizzle + .select({ id: vaultProfiles.id }) + .from(vaultProfiles) + .where( + and(eq(vaultProfiles.syncId, syncId), eq(vaultProfiles.userId, userId)), + ) + .limit(1); + return row?.id ?? null; +} + function requireUserDataKey(userId: string): Buffer { return DataCrypto.validateUserAccess(userId); } @@ -161,11 +229,13 @@ router.get( : null; try { - const { table } = ENTITY_CONFIG[entityType]; + const { table, singleton } = ENTITY_CONFIG[entityType]; const context = createCurrentRepositoryContext(); const conditions = [eq(table.userId, userId)]; if (since && "updatedAt" in table) { - conditions.push(gt((table as typeof hosts).updatedAt, since)); + conditions.push( + timestampAtOrAfter((table as typeof hosts).updatedAt, since), + ); } const rows = await context.drizzle @@ -173,8 +243,18 @@ router.get( .from(table as typeof hosts) .where(and(...conditions)); - const decrypted = rows.map((row) => - decryptIfNeeded(entityType, row as Record, userId), + const decrypted = await Promise.all( + rows.map(async (row) => { + const result = await serializeSyncReferences( + entityType, + decryptIfNeeded(entityType, row as Record, userId), + (referenceType, id) => + findReferenceSyncId(context, referenceType, id, userId), + ); + return singleton + ? { ...result, syncId: `${entityType}:singleton` } + : result; + }), ); res.json({ rows: decrypted }); @@ -189,6 +269,71 @@ router.get( }, ); +/** + * @openapi + * /sync/tombstones: + * post: + * summary: Report a deletion from the other side of a sync pair + * description: Applies a remote deletion locally (if the row still exists) and records the tombstone so future pulls stay consistent. + * tags: + * - Sync + * responses: + * 200: + * description: Deletion applied (or row already absent). + * 400: + * description: Unknown entity type or missing syncId. + * 500: + * description: Failed to apply deletion. + */ +router.post( + "/tombstones", + authenticateJWT, + async (req: Request, res: Response) => { + const userId = (req as AuthenticatedRequest).userId; + const entityType = req.body?.entityType; + const syncId = req.body?.syncId; + if ( + !isValidEntityType(entityType) || + typeof syncId !== "string" || + !syncId + ) { + return res.status(400).json({ error: "Missing entityType or syncId" }); + } + + try { + const { table, singleton } = ENTITY_CONFIG[entityType]; + const context = createCurrentRepositoryContext(); + + await context.drizzle + .delete(table as typeof hosts) + .where( + singleton + ? eq(table.userId, userId) + : and( + eq((table as typeof hosts).syncId, syncId), + eq(table.userId, userId), + ), + ); + + await createCurrentSyncTombstoneRepository().record( + userId, + entityType, + syncId, + ); + await DatabaseSaveTrigger.forceSave("sync_tombstone_applied"); + + res.json({ success: true }); + } catch (err) { + databaseLogger.error("Failed to apply sync tombstone", err, { + operation: "sync_tombstone_apply", + entityType, + userId, + }); + res.status(500).json({ error: "Failed to apply deletion" }); + } + }, +); + /** * @openapi * /sync/{entityType}: @@ -227,22 +372,30 @@ router.post( } try { - const { table } = ENTITY_CONFIG[entityType]; + const { table, singleton } = ENTITY_CONFIG[entityType]; const context = createCurrentRepositoryContext(); const existingRows = await context.drizzle .select() .from(table as typeof hosts) .where( - and( - eq((table as typeof hosts).syncId, syncId), - eq(table.userId, userId), - ), + singleton + ? eq(table.userId, userId) + : and( + eq((table as typeof hosts).syncId, syncId), + eq(table.userId, userId), + ), ) .limit(1); const existing = existingRows[0] as Record | undefined; - const writePayload = stripWritePayload(entityType, payload); + const resolvedPayload = await deserializeSyncReferences( + entityType, + payload, + (referenceType, referenceSyncId) => + findReferenceId(context, referenceType, referenceSyncId, userId), + ); + const writePayload = stripWritePayload(entityType, resolvedPayload); const encryptedPayload = encryptIfNeeded( entityType, writePayload, @@ -265,11 +418,15 @@ router.post( } else { const insertedRows = await context.drizzle .insert(table as typeof hosts) - .values({ - ...encryptedPayload, - userId, - syncId, - } as typeof hosts.$inferInsert) + .values( + (singleton + ? { ...encryptedPayload, userId } + : { + ...encryptedPayload, + userId, + syncId, + }) as typeof hosts.$inferInsert, + ) .returning(); resultRow = insertedRows[0] as Record; } @@ -349,67 +506,4 @@ router.get( }, ); -/** - * @openapi - * /sync/tombstones: - * post: - * summary: Report a deletion from the other side of a sync pair - * description: Applies a remote deletion locally (if the row still exists) and records the tombstone so future pulls stay consistent. - * tags: - * - Sync - * responses: - * 200: - * description: Deletion applied (or row already absent). - * 400: - * description: Unknown entity type or missing syncId. - * 500: - * description: Failed to apply deletion. - */ -router.post( - "/tombstones", - authenticateJWT, - async (req: Request, res: Response) => { - const userId = (req as AuthenticatedRequest).userId; - const entityType = req.body?.entityType; - const syncId = req.body?.syncId; - if ( - !isValidEntityType(entityType) || - typeof syncId !== "string" || - !syncId - ) { - return res.status(400).json({ error: "Missing entityType or syncId" }); - } - - try { - const { table } = ENTITY_CONFIG[entityType]; - const context = createCurrentRepositoryContext(); - - await context.drizzle - .delete(table as typeof hosts) - .where( - and( - eq((table as typeof hosts).syncId, syncId), - eq(table.userId, userId), - ), - ); - - await createCurrentSyncTombstoneRepository().record( - userId, - entityType, - syncId, - ); - await DatabaseSaveTrigger.forceSave("sync_tombstone_applied"); - - res.json({ success: true }); - } catch (err) { - databaseLogger.error("Failed to apply sync tombstone", err, { - operation: "sync_tombstone_apply", - entityType, - userId, - }); - res.status(500).json({ error: "Failed to apply deletion" }); - } - }, -); - export default router; diff --git a/src/backend/database/routes/user-oidc-utils.ts b/src/backend/database/routes/user-oidc-utils.ts index 3b3e66e5..5d441994 100644 --- a/src/backend/database/routes/user-oidc-utils.ts +++ b/src/backend/database/routes/user-oidc-utils.ts @@ -1,6 +1,7 @@ import { authLogger } from "../../utils/logger.js"; import type { SSOProviderType } from "../../../types/index.js"; import { DataCrypto } from "../../utils/data-crypto.js"; +import { decryptSsoConfigSecrets } from "../../utils/system-secret-crypto.js"; import { Agent } from "undici"; import { createCurrentSettingsRepository, @@ -10,6 +11,17 @@ import { const BACKCHANNEL_LOGOUT_EVENT = "http://schemas.openid.net/event/backchannel-logout"; +/** + * Raised when a token cannot be verified because it is not a compact JWS, + * as opposed to a signature or claim check that actually failed. + */ +export class OIDCTokenFormatError extends Error { + constructor(message: string) { + super(message); + this.name = "OIDCTokenFormatError"; + } +} + function normalizeIssuer(url: string): string { return url.trim().replace(/\/+$/, ""); } @@ -27,6 +39,7 @@ export type OIDCConfig = { allowed_users: string; admin_group: string; group_claim?: string; + role_map?: string; ca_cert?: string; }; @@ -35,6 +48,26 @@ export function buildFetchOptions(caCert?: string): Record { return { dispatcher: new Agent({ connect: { ca: caCert } }) }; } +/** + * Renders why a fetch failed in a form an administrator can act on. + * + * undici reports every transport failure as the same "fetch failed" message + * and puts the reason that actually matters -- ENOTFOUND, ECONNREFUSED, + * UNABLE_TO_VERIFY_LEAF_SIGNATURE, a timeout -- on the cause. Reporting only + * the outer message says nothing at all. + */ +export function describeFetchFailure(error: unknown): string { + if (!(error instanceof Error)) return String(error); + const cause = (error as { cause?: unknown }).cause; + if (cause instanceof Error) { + const code = (cause as { code?: unknown }).code; + return code + ? `${error.message}: ${cause.message} (${code})` + : `${error.message}: ${cause.message}`; + } + return cause ? `${error.message}: ${String(cause)}` : error.message; +} + export function getOIDCConfigFromEnv(): OIDCConfig | null { const client_id = process.env.OIDC_CLIENT_ID; const client_secret = process.env.OIDC_CLIENT_SECRET; @@ -65,9 +98,77 @@ export function getOIDCConfigFromEnv(): OIDCConfig | null { allowed_users: process.env.OIDC_ALLOWED_USERS || "", admin_group: process.env.OIDC_ADMIN_GROUP || "", group_claim: process.env.OIDC_GROUP_CLAIM || "", + role_map: process.env.OIDC_ROLE_MAP || "", }; } +/** + * Normalizes a group name for comparison. Providers are inconsistent about + * whether they emit bare names (`devops-interns`) or full paths + * (`/devops-interns`, Keycloak's "Full group path" option), so leading slashes + * are stripped and case is ignored. + */ +function normalizeGroupName(group: string): string { + return group.trim().replace(/^\/+/, "").toLowerCase(); +} + +/** + * Parses `OIDC_ROLE_MAP` into a group -> role-name lookup. + * + * Format is a comma- or newline-separated list of `group:role` pairs, e.g. + * `devops-interns:devops-intern,devops-seniors:devops-senior`. Group keys are + * normalized via {@link normalizeGroupName}; role names are passed through + * verbatim because they must match `roles.name` exactly. + * + * Malformed entries are skipped rather than throwing — a typo in one pair must + * not lock every user out of login. + */ +export function parseOidcRoleMap(raw?: string | null): Map { + const map = new Map(); + if (!raw || !raw.trim()) return map; + + for (const entry of raw.split(/[\n,]/)) { + const trimmed = entry.trim(); + if (!trimmed) continue; + + // rsplit on the last ":" so group names containing a colon still work. + const separator = trimmed.lastIndexOf(":"); + if (separator <= 0 || separator === trimmed.length - 1) continue; + + const group = normalizeGroupName(trimmed.slice(0, separator)); + const roleName = trimmed.slice(separator + 1).trim(); + if (!group || !roleName) continue; + + map.set(group, roleName); + } + + return map; +} + +/** + * Resolves which mapped roles a user should hold, given their provider groups. + * + * Returns both the `desired` roles (mapped groups the user is actually in) and + * the full set of `managed` roles (every role named in the map). Callers must + * only ever add/remove roles within `managed` — roles assigned by hand in + * Termix, and the `admin`/`user` roles maintained by the admin-group sync, are + * deliberately left alone. + */ +export function resolveOidcMappedRoles( + groups: string[], + roleMap: Map, +): { desired: Set; managed: Set } { + const managed = new Set(roleMap.values()); + const desired = new Set(); + + for (const group of groups) { + const roleName = roleMap.get(normalizeGroupName(group)); + if (roleName) desired.add(roleName); + } + + return { desired, managed }; +} + /** * Extracts the list of group/role names from an OIDC userInfo payload. * @@ -149,6 +250,15 @@ export async function verifyOIDCToken( clientId: string, caCert?: string, ): Promise> { + const segments = idToken.split("."); + if (segments.length !== 3) { + throw new OIDCTokenFormatError( + segments.length === 5 + ? "Token is a JWE (encrypted). Termix cannot verify encrypted tokens; disable token encryption for this client in your OIDC provider." + : `Token is not a compact JWS: expected 3 segments, got ${segments.length}.`, + ); + } + const fetchOptions = buildFetchOptions(caCert); const normalizedIssuerUrl = issuerUrl.endsWith("/") ? issuerUrl.slice(0, -1) @@ -166,20 +276,30 @@ export async function verifyOIDCToken( `${normalizedIssuerUrl.replace(/\/application\/o\/[^/]+$/, "")}/.well-known/jwks.json`, ]; + // Every attempt records why it failed. Without this the only thing an + // administrator ever sees is "Failed to fetch JWKS from any URL", which + // does not distinguish an issuer URL typo from a proxy, a private CA, or + // a provider outage. + const attempts: string[] = []; + + const discoveryUrl = `${normalizedIssuerUrl}/.well-known/openid-configuration`; try { - const discoveryUrl = `${normalizedIssuerUrl}/.well-known/openid-configuration`; const discoveryResponse = await fetch(discoveryUrl, fetchOptions); - if (discoveryResponse.ok) { + if (!discoveryResponse.ok) { + attempts.push(`${discoveryUrl}: HTTP ${discoveryResponse.status}`); + } else { const discovery = (await discoveryResponse.json()) as Record< string, unknown >; - if (discovery.jwks_uri) { - jwksUrls.unshift(discovery.jwks_uri as string); + if (typeof discovery.jwks_uri === "string" && discovery.jwks_uri) { + jwksUrls.unshift(discovery.jwks_uri); + } else { + attempts.push(`${discoveryUrl}: no jwks_uri in the discovery document`); } } } catch (discoveryError) { - authLogger.error(`OIDC discovery failed: ${discoveryError}`); + attempts.push(`${discoveryUrl}: ${describeFetchFailure(discoveryError)}`); } let jwks: Record | null = null; @@ -187,26 +307,25 @@ export async function verifyOIDCToken( for (const url of jwksUrls) { try { const response = await fetch(url, fetchOptions); - if (response.ok) { - const jwksData = (await response.json()) as Record; - if (jwksData && jwksData.keys && Array.isArray(jwksData.keys)) { - jwks = jwksData; - break; - } else { - authLogger.error( - `Invalid JWKS structure from ${url}: ${JSON.stringify(jwksData)}`, - ); - } - } else { - // expected - non-ok response, try next URL + if (!response.ok) { + attempts.push(`${url}: HTTP ${response.status}`); + continue; } - } catch { - continue; + const jwksData = (await response.json()) as Record; + if (jwksData && Array.isArray(jwksData.keys)) { + jwks = jwksData; + break; + } + attempts.push(`${url}: response contains no "keys" array`); + } catch (error) { + attempts.push(`${url}: ${describeFetchFailure(error)}`); } } if (!jwks) { - throw new Error("Failed to fetch JWKS from any URL"); + throw new Error( + `Failed to fetch JWKS from any URL. Attempts:\n ${attempts.join("\n ")}`, + ); } if (!jwks.keys || !Array.isArray(jwks.keys)) { @@ -215,9 +334,8 @@ export async function verifyOIDCToken( ); } - const header = JSON.parse( - Buffer.from(idToken.split(".")[0], "base64").toString(), - ); + const { decodeProtectedHeader, importJWK, jwtVerify } = await import("jose"); + const header = decodeProtectedHeader(idToken); const keyId = header.kid; const publicKey = jwks.keys.find( @@ -229,8 +347,9 @@ export async function verifyOIDCToken( ); } - const { importJWK, jwtVerify } = await import("jose"); - const key = await importJWK(publicKey); + const algorithm = + typeof publicKey.alg === "string" ? publicKey.alg : header.alg; + const key = await importJWK(publicKey, algorithm); const { payload } = await jwtVerify(idToken, key, { issuer: possibleIssuers, @@ -283,30 +402,15 @@ function applyProviderDefaults( }; } -function decryptConfigSecret( +/** + * Reads the provider secrets. System-key encrypted values are decrypted; + * values still carrying a legacy base64 prefix are decoded so login keeps + * working until the provider is next saved. + */ +async function decryptConfigSecret( config: Record, -): Record { - const out = { ...config }; - for (const field of ["client_secret", "bindPassword"] as const) { - const val = out[field] as string | undefined; - if (val?.startsWith("encoded:")) { - try { - out[field] = Buffer.from(val.substring(8), "base64").toString("utf8"); - } catch { - // leave as-is - } - } else if (val?.startsWith("encrypted:")) { - // encrypted: prefix means it was encrypted with DataCrypto; without a - // userId/dataKey here we cannot decrypt it. The caller should use the - // full admin decrypt path when possible. Fall back to stripping prefix. - try { - out[field] = Buffer.from(val.substring(10), "base64").toString("utf8"); - } catch { - // leave as-is - } - } - } - return out; +): Promise> { + return decryptSsoConfigSecrets(config); } export async function loadProviderConfig( @@ -340,10 +444,10 @@ export async function loadProviderConfig( ); } } catch { - parsed = decryptConfigSecret(parsed); + parsed = await decryptConfigSecret(parsed); } } else { - parsed = decryptConfigSecret(parsed); + parsed = await decryptConfigSecret(parsed); } const providerType = row.type as SSOProviderType; const config = applyProviderDefaults( @@ -380,7 +484,7 @@ export async function loadProviderConfig( } catch { parsed = {}; } - parsed = decryptConfigSecret(parsed); + parsed = await decryptConfigSecret(parsed); const oidcProviderType = oidcRow.type as SSOProviderType; return { config: applyProviderDefaults( @@ -401,7 +505,7 @@ export async function loadProviderConfig( await createCurrentSettingsRepository().get("oidc_config"); if (legacyValue) { let config = JSON.parse(legacyValue) as Record; - config = decryptConfigSecret(config); + config = await decryptConfigSecret(config); return { config: config as unknown as OIDCConfig, providerType: "oidc", @@ -432,7 +536,7 @@ export async function resolveProviderByIssuer(issuer: string): Promise<{ } catch { continue; } - parsed = decryptConfigSecret(parsed); + parsed = await decryptConfigSecret(parsed); const providerType = row.type as SSOProviderType; const config = applyProviderDefaults( parsed as unknown as OIDCConfig, diff --git a/src/backend/database/routes/user-settings-routes.ts b/src/backend/database/routes/user-settings-routes.ts index 27819bff..36bbafb0 100644 --- a/src/backend/database/routes/user-settings-routes.ts +++ b/src/backend/database/routes/user-settings-routes.ts @@ -7,6 +7,7 @@ import { setGlobalLogLevel, } from "../../utils/logger.js"; import { logAudit, getRequestMeta } from "../../utils/audit-logger.js"; +import { getTelemetryEnvOverride } from "../../utils/analytics.js"; import { createCurrentSettingsRepository, createCurrentUserRepository, @@ -524,7 +525,7 @@ export function registerUserSettingsRoutes( * /users/analytics-enabled: * get: * summary: Get analytics enabled setting - * description: Returns whether anonymous usage telemetry is enabled. + * description: Returns whether anonymous usage telemetry is enabled, and whether the value is locked by the ENABLE_TELEMETRY environment variable. * tags: * - Users * responses: @@ -537,14 +538,21 @@ export function registerUserSettingsRoutes( * properties: * enabled: * type: boolean + * locked: + * type: boolean */ router.get("/analytics-enabled", authenticateJWT, async (_req, res) => { try { + const override = getTelemetryEnvOverride(); + if (override !== null) { + return res.json({ enabled: override, locked: true }); + } res.json({ enabled: await createCurrentSettingsRepository().getBoolean( "analytics_enabled", true, ), + locked: false, }); } catch (err) { authLogger.error("Failed to get analytics enabled setting", err); @@ -576,6 +584,8 @@ export function registerUserSettingsRoutes( * description: Setting updated. * 403: * description: Not authorized. + * 409: + * description: Setting is locked by the ENABLE_TELEMETRY environment variable. * 500: * description: Failed to update setting. */ @@ -586,6 +596,11 @@ export function registerUserSettingsRoutes( if (!actor) { return res.status(403).json({ error: "Not authorized" }); } + if (getTelemetryEnvOverride() !== null) { + return res.status(409).json({ + error: "Telemetry is locked by the ENABLE_TELEMETRY env variable", + }); + } const { enabled } = req.body; if (typeof enabled !== "boolean") { return res.status(400).json({ error: "enabled must be a boolean" }); diff --git a/src/backend/database/routes/user-webauthn-routes.ts b/src/backend/database/routes/user-webauthn-routes.ts index 2d247dcd..fc9d8fb0 100644 --- a/src/backend/database/routes/user-webauthn-routes.ts +++ b/src/backend/database/routes/user-webauthn-routes.ts @@ -421,8 +421,7 @@ export function registerUserWebAuthnRoutes( } const response = req.body?.response as - | AuthenticationResponseJSON - | undefined; + AuthenticationResponseJSON | undefined; if (!response?.id) { return res.status(400).json({ error: "Invalid passkey response" }); } diff --git a/src/backend/database/routes/users.ts b/src/backend/database/routes/users.ts index 51de8958..1d213d46 100644 --- a/src/backend/database/routes/users.ts +++ b/src/backend/database/routes/users.ts @@ -24,11 +24,15 @@ import { resolveDesktopAutoSessionUser, } from "./desktop-auto-session.js"; import { shouldShowDonationModal } from "./donation-modal-utils.js"; +import { PermissionManager } from "../../utils/permission-manager.js"; import { getOIDCConfigFromEnv, isOIDCUserAllowed, + OIDCTokenFormatError, verifyOIDCToken, extractOidcGroups, + parseOidcRoleMap, + resolveOidcMappedRoles, loadProviderConfig, buildFetchOptions, resolveProviderByIssuer, @@ -1049,20 +1053,37 @@ router.get("/oidc/callback", async (req, res) => { ); if (tokenData.id_token) { - userInfo = await verifyOIDCToken( - tokenData.id_token as string, - config.issuer_url, - config.client_id, - caCert, - ); + try { + userInfo = await verifyOIDCToken( + tokenData.id_token as string, + config.issuer_url, + config.client_id, + caCert, + ); - const expectedNonce = storedNonce; - if (userInfo.nonce !== expectedNonce) { - authLogger.warn("OIDC ID token nonce mismatch", { - operation: "oidc_nonce_mismatch", - providerId: callbackProviderId, - }); - return res.status(401).json({ error: "Invalid OIDC token nonce" }); + const expectedNonce = storedNonce; + if (userInfo.nonce !== expectedNonce) { + authLogger.warn("OIDC ID token nonce mismatch", { + operation: "oidc_nonce_mismatch", + providerId: callbackProviderId, + }); + return res.status(401).json({ error: "Invalid OIDC token nonce" }); + } + } catch (error) { + // A token we cannot parse as a JWS carries no claims we could trust, so + // fall through to the userinfo endpoint instead of failing the login. + // Signature and claim failures still reject: those are real rejections. + if (!(error instanceof OIDCTokenFormatError)) throw error; + + userInfo = null; + authLogger.warn( + "OIDC ID token cannot be verified, falling back to userinfo endpoint", + { + operation: "oidc_id_token_unverifiable", + providerId: callbackProviderId, + reason: error.message, + }, + ); } } @@ -1331,6 +1352,89 @@ router.get("/oidc/callback", async (req, res) => { } } + // Sync RBAC roles from provider group membership (OIDC_ROLE_MAP). + // + // This is what makes environment-scoped access work without hand-assigning + // roles: map a provider group to a Termix role, grant that role access to a + // set of hosts once, and membership follows the identity provider. + // + // Only roles named in the map are touched. Roles assigned by hand, and the + // admin/user pair maintained by the admin-group sync above, are never + // removed here — otherwise this would fight that block on every login. + // + // Non-fatal by design: a role-sync failure must not block a valid login. + try { + const roleMap = parseOidcRoleMap( + config.role_map ?? process.env.OIDC_ROLE_MAP, + ); + + if (roleMap.size > 0) { + const groups = extractOidcGroups( + userInfo as Record, + config.group_claim, + ); + const { desired, managed } = resolveOidcMappedRoles(groups, roleMap); + + const roleRepository = createCurrentRoleRepository(); + const currentRoles = await roleRepository.listUserRoles(userRecord.id); + const currentNames = new Set(currentRoles.map((r) => r.roleName)); + + const toAdd = [...desired].filter((name) => !currentNames.has(name)); + const toRemove = currentRoles.filter( + (r) => managed.has(r.roleName) && !desired.has(r.roleName), + ); + + authLogger.info( + `Evaluating OIDC role map sync. parsedGroups: ${JSON.stringify(groups)}, desiredRoles: ${JSON.stringify([...desired])}, managedRoles: ${JSON.stringify([...managed])}, groupClaim: ${config.group_claim || "(default)"}`, + { + operation: "oidc_role_map_sync_eval", + userId: userRecord.id, + }, + ); + + for (const roleName of toAdd) { + const assigned = await roleRepository.assignRoleNameToUser({ + userId: userRecord.id, + roleName, + grantedBy: userRecord.id, + }); + if (!assigned) { + authLogger.warn( + "OIDC role map references a role that does not exist", + { + operation: "oidc_role_map_missing_role", + userId: userRecord.id, + roleName, + }, + ); + } + } + + for (const role of toRemove) { + await roleRepository.removeRoleFromUser(userRecord.id, role.roleId); + } + + if (toAdd.length > 0 || toRemove.length > 0) { + authLogger.info("OIDC roles synced from group membership", { + operation: "oidc_role_map_sync", + userId: userRecord.id, + added: toAdd, + removed: toRemove.map((r) => r.roleName), + }); + // Host access is resolved through cached role permissions; drop the + // cache so the new roles apply to this session immediately. + PermissionManager.getInstance().invalidateUserPermissionCache( + userRecord.id, + ); + } + } + } catch (roleSyncError) { + authLogger.error("Failed to sync OIDC roles", roleSyncError, { + operation: "oidc_role_map_sync_failed", + userId: userRecord.id, + }); + } + try { await authManager.authenticateOIDCUser(userRecord.id, deviceInfo.type); } catch (setupError) { diff --git a/src/backend/database/sync-timestamp.ts b/src/backend/database/sync-timestamp.ts new file mode 100644 index 00000000..40cfe9cd --- /dev/null +++ b/src/backend/database/sync-timestamp.ts @@ -0,0 +1,35 @@ +import { sql, type SQLWrapper } from "drizzle-orm"; + +/** + * Sync cursors and stored timestamps do not share a layout. + * + * `updated_at` and `deleted_at` are TEXT columns written both by + * `default(sql`CURRENT_TIMESTAMP`)` ("2026-07-29 10:11:21") and by + * `new Date().toISOString()` ("2026-07-29T10:11:21.123Z"), while the desktop + * sync engine always sends the ISO form as `since`. Comparing those as text is + * decided at position 10, where ' ' (0x20) sorts below 'T' (0x54), so the + * answer depends on which writer produced the row rather than on when it was + * written -- and `column > :isoCursor` is false for every row stored in the + * CURRENT_TIMESTAMP form, however new it is. + * + * Both layouts share a prefix once the separator is levelled, so comparing + * "YYYY-MM-DD HH:MM:SS" on both sides is layout-independent. `replace` and + * `substr` are used rather than `datetime()` to keep the expression portable + * across engines. + */ +export const CANONICAL_TIMESTAMP_LENGTH = 19; + +export function normalizeSyncTimestamp(value: string): string { + return value.replace("T", " ").slice(0, CANONICAL_TIMESTAMP_LENGTH); +} + +/** + * `>=` rather than `>`: normalising truncates sub-second precision, so a strict + * comparison would permanently skip rows written in the same second as the + * cursor. Re-sending that boundary second costs nothing -- the sync engine only + * pushes a row when one side is strictly newer, so rows equal on both sides are + * a no-op. + */ +export function timestampAtOrAfter(column: SQLWrapper, since: string) { + return sql`substr(replace(${column}, 'T', ' '), 1, ${CANONICAL_TIMESTAMP_LENGTH}) >= ${normalizeSyncTimestamp(since)}`; +} diff --git a/src/backend/hosts/docker/console.ts b/src/backend/hosts/docker/console.ts index 7fb1d003..febc32ac 100644 --- a/src/backend/hosts/docker/console.ts +++ b/src/backend/hosts/docker/console.ts @@ -139,8 +139,7 @@ async function createJumpHostChain( resolvedCredentials = { password: credential.password as string | undefined, sshKey: (credential.key || credential.privateKey) as - | string - | undefined, + string | undefined, keyPassword: credential.keyPassword as string | undefined, authType: credential.authType as string | undefined, }; @@ -220,8 +219,7 @@ async function createJumpHostChain( const result = await applyAgentAuth( config, jumpHost.terminalConfig as unknown as - | Record - | undefined, + Record | undefined, ); if ("error" in result) { throw new Error(result.error); @@ -442,8 +440,7 @@ wss.on("connection", async (ws: WebSocket, req) => { const result = await applyAgentAuth( config, resolvedHost.terminalConfig as unknown as - | Record - | undefined, + Record | undefined, ); if ("error" in result) { ws.send( diff --git a/src/backend/hosts/docker/routes.ts b/src/backend/hosts/docker/routes.ts index 68e7d741..62ab7db3 100644 --- a/src/backend/hosts/docker/routes.ts +++ b/src/backend/hosts/docker/routes.ts @@ -3,11 +3,13 @@ import axios from "axios"; import { Client as SSHClient } from "ssh2"; import { logger } from "../../utils/logger.js"; import { - createCurrentCredentialRepository, - createCurrentHostRepository, - createCurrentHostResolutionRepository, -} from "../../database/repositories/factory.js"; + logAudit, + getAuditUsername, + getRequestMeta, +} from "../../utils/audit-logger.js"; +import { createCurrentHostRepository } from "../../database/repositories/factory.js"; import { createJumpHostChain } from "../jump-host-chain.js"; +import { resolveHostById } from "../host-resolver.js"; import { createConnectionLog } from "../connection-log.js"; import { DataCrypto } from "../../utils/data-crypto.js"; import { AuthManager } from "../../utils/auth-manager.js"; @@ -158,13 +160,8 @@ export function registerDockerSshRoutes(app: express.Express): void { ); try { - const hostRecord = - await createCurrentHostResolutionRepository().findHostById( - hostId, - userId, - ); - - if (!hostRecord) { + const resolvedHost = await resolveHostById(hostId, userId); + if (!resolvedHost) { connectionLogs.push( createConnectionLog("error", "docker_connecting", "Host not found"), ); @@ -173,36 +170,7 @@ export function registerDockerSshRoutes(app: express.Express): void { .json({ error: "Host not found", connectionLogs }); } - const host = hostRecord as unknown as SSHHost; - - if (host.userId !== userId) { - const { PermissionManager } = - await import("../../utils/permission-manager.js"); - const permissionManager = PermissionManager.getInstance(); - const accessInfo = await permissionManager.canAccessHost( - userId, - hostId, - "connect", - ); - - if (!accessInfo.hasAccess) { - sshLogger.warn("User does not have access to host", { - operation: "docker_connect", - hostId, - userId, - }); - connectionLogs.push( - createConnectionLog( - "error", - "docker_connecting", - "Access denied to host", - ), - ); - return res - .status(403) - .json({ error: "Access denied", connectionLogs }); - } - } + const host = resolvedHost as SSHHost; if (typeof host.jumpHosts === "string" && host.jumpHosts) { try { host.jumpHosts = JSON.parse(host.jumpHosts); @@ -266,7 +234,7 @@ export function registerDockerSshRoutes(app: express.Express): void { delete pendingTOTPSessions[sessionId]; } - let resolvedCredentials: { + const resolvedCredentials: { password?: string; sshKey?: string; keyPassword?: string; @@ -290,55 +258,6 @@ export function registerDockerSshRoutes(app: express.Express): void { resolvedCredentials.keyPassword = userProvidedKeyPassword; } - if (host.credentialId) { - const ownerId = host.userId; - - if (userId !== ownerId) { - try { - const { SharedHostSecretsManager } = - await import("../../utils/shared-host-secrets-manager.js"); - const sharedCred = - await SharedHostSecretsManager.getInstance().getSecretForUser( - host.id, - userId, - "ssh", - ); - - if (sharedCred) { - resolvedCredentials = { - password: sharedCred.password, - sshKey: sharedCred.key, - keyPassword: sharedCred.keyPassword, - authType: sharedCred.authType, - }; - } - } catch (error) { - sshLogger.error("Failed to resolve shared credential", error, { - operation: "docker_connect", - hostId, - userId, - }); - } - } else { - const credential = - await createCurrentCredentialRepository().findDecryptedByIdForUser( - userId, - host.credentialId as number, - ); - - if (credential) { - resolvedCredentials = { - password: credential.password as string | undefined, - sshKey: (credential.key || credential.privateKey) as - | string - | undefined, - keyPassword: credential.keyPassword as string | undefined, - authType: credential.authType as string | undefined, - }; - } - } - } - const client = new SSHClient(); const config: Record = { @@ -591,6 +510,20 @@ export function registerDockerSshRoutes(app: express.Express): void { } }); + void (async () => { + const { ipAddress, userAgent } = getRequestMeta(req); + await logAudit({ + userId, + username: await getAuditUsername(userId), + action: "docker_connect", + resourceType: "host", + resourceId: hostId ? String(hostId) : undefined, + ipAddress, + userAgent, + success: true, + }); + })(); + res.json({ success: true, message: "SSH connection established", @@ -994,7 +927,6 @@ export function registerDockerSshRoutes(app: express.Express): void { const jumpClient = await createJumpHostChain( host.jumpHosts as Array<{ hostId: number }>, userId, - proxyConfig, ); if (!jumpClient) { diff --git a/src/backend/hosts/file-manager/index.ts b/src/backend/hosts/file-manager/index.ts index 5f378a64..0bd743f8 100644 --- a/src/backend/hosts/file-manager/index.ts +++ b/src/backend/hosts/file-manager/index.ts @@ -1,4 +1,9 @@ import express from "express"; +import { + logAudit, + getAuditUsername, + getRequestMeta, +} from "../../utils/audit-logger.js"; import { createCorsMiddleware } from "../../utils/cors-config.js"; import cookieParser from "cookie-parser"; import axios from "axios"; @@ -289,11 +294,7 @@ async function startDedicatedTransferConnect( const hasJumpHosts = jumpHosts && jumpHosts.length > 0; if (hasJumpHosts) { - const jumpClient = await createJumpHostChain( - jumpHosts, - userId, - proxyConfig, - ); + const jumpClient = await createJumpHostChain(jumpHosts, userId); if (!jumpClient) { throw new Error("Failed to connect through jump hosts for transfer"); } @@ -764,8 +765,7 @@ app.post("/ssh/file_manager/ssh/connect", async (req, res) => { sudoPassword: resolvedHost.sudoPassword as string | undefined, }; resolvedTerminalConfig = resolvedHost.terminalConfig as unknown as - | Record - | undefined; + Record | undefined; hostKeepaliveInterval = resolvedHost.terminalConfig?.keepaliveInterval; hostKeepaliveCountMax = resolvedHost.terminalConfig?.keepaliveCountMax; resolvedScpLegacy = resolvedHost.scpLegacy ?? false; @@ -823,8 +823,7 @@ app.post("/ssh/file_manager/ssh/connect", async (req, res) => { sudoPassword: resolvedHost.sudoPassword as string | undefined, }; resolvedTerminalConfig = resolvedHost.terminalConfig as unknown as - | Record - | undefined; + Record | undefined; hostKeepaliveInterval = resolvedHost.terminalConfig?.keepaliveInterval; hostKeepaliveCountMax = resolvedHost.terminalConfig?.keepaliveCountMax; resolvedScpLegacy = resolvedHost.scpLegacy ?? false; @@ -1169,6 +1168,24 @@ app.post("/ssh/file_manager/ssh/connect", async (req, res) => { scpLegacy: resolvedScpLegacy, }; scheduleSessionCleanup(sessionId); + + if (userId) { + const { ipAddress, userAgent } = getRequestMeta(req); + void (async () => { + await logAudit({ + userId, + username: await getAuditUsername(userId), + action: "file_manager_connect", + resourceType: "host", + resourceId: hostId ? String(hostId) : undefined, + resourceName: `${username}@${ip}:${port}`, + ipAddress, + userAgent, + success: true, + }); + })(); + } + res.json({ status: "success", message: "SSH connection established", @@ -1616,11 +1633,7 @@ app.post("/ssh/file_manager/ssh/connect", async (req, res) => { `Connecting via ${resolvedJumpHosts.length} jump host(s)`, ), ); - const jumpClient = await createJumpHostChain( - resolvedJumpHosts, - userId, - proxyConfig, - ); + const jumpClient = await createJumpHostChain(resolvedJumpHosts, userId); if (!jumpClient) { fileLogger.error("Failed to establish jump host chain", { diff --git a/src/backend/hosts/file-manager/transfer-engine.ts b/src/backend/hosts/file-manager/transfer-engine.ts index 4a83a280..093b09dd 100644 --- a/src/backend/hosts/file-manager/transfer-engine.ts +++ b/src/backend/hosts/file-manager/transfer-engine.ts @@ -76,22 +76,13 @@ export interface HostTransferDeps { } export type TransferPhase = - | "compressing" - | "transferring" - | "extracting" - | "reconnecting"; + "compressing" | "transferring" | "extracting" | "reconnecting"; export type TransferStatus = - | "running" - | "success" - | "partial" - | "error" - | "cancelled"; + "running" | "success" | "partial" | "error" | "cancelled"; export type TransferMethod = "stream" | "tar" | "item_sftp"; export type TransferHopId = - | "source_read" - | "dest_sftp_write" - | "dest_local_write"; + "source_read" | "dest_sftp_write" | "dest_local_write"; export interface TransferHopMetrics { id: TransferHopId; diff --git a/src/backend/hosts/guacamole/guacamole-server.ts b/src/backend/hosts/guacamole/guacamole-server.ts index efc8654d..6b63e8e9 100644 --- a/src/backend/hosts/guacamole/guacamole-server.ts +++ b/src/backend/hosts/guacamole/guacamole-server.ts @@ -104,10 +104,16 @@ async function persistGuacamoleRecording( await new Promise((resolve) => setTimeout(resolve, 100)); } if (!fs.existsSync(resolvedPath)) { + const guacdPath = recording.guacdPath ?? GUACAMOLE_RECORDINGS_DIR; guacLogger.warn("Guacamole recording file was not found", { operation: "guac_recording_missing", hostId: recording.hostId, path: resolvedPath, + guacdPath, + hint: + "guacd writes the recording to guacdPath, the backend reads it from path. " + + "When guacd runs in its own container these must be the same volume — set " + + "GUACD_RECORDING_PATH to guacd's mount point and GUACD_RECORDING_BACKEND_PATH to this one.", }); return; } diff --git a/src/backend/hosts/guacamole/jump-tunnel-endpoint.ts b/src/backend/hosts/guacamole/jump-tunnel-endpoint.ts new file mode 100644 index 00000000..e05f1e29 --- /dev/null +++ b/src/backend/hosts/guacamole/jump-tunnel-endpoint.ts @@ -0,0 +1,15 @@ +const LOOPBACK_HOSTS = new Set(["localhost", "127.0.0.1", "::1"]); + +export function resolveJumpTunnelEndpoint( + guacdHost: string, + tunnelHost = process.env.GUACD_TUNNEL_HOST, +): { bindHost: string; advertisedHost: string } { + if (LOOPBACK_HOSTS.has(guacdHost.toLowerCase())) { + return { bindHost: "127.0.0.1", advertisedHost: "127.0.0.1" }; + } + + return { + bindHost: "0.0.0.0", + advertisedHost: tunnelHost?.trim() || "termix", + }; +} diff --git a/src/backend/hosts/guacamole/recording-settings.ts b/src/backend/hosts/guacamole/recording-settings.ts new file mode 100644 index 00000000..32369169 --- /dev/null +++ b/src/backend/hosts/guacamole/recording-settings.ts @@ -0,0 +1,22 @@ +/** + * Merges Termix's recording bookkeeping into a host's guacd settings. + * + * Location and filename are not the host's to choose: recordings are indexed by + * them for playback, and the backend refuses to read anything outside its + * recordings directory. What a recording *contains* is a host-level decision, so + * those flags are only defaulted, never overwritten. + */ +export function withRecordingSettings( + guacConfig: Record, + recordingPath: string, + recordingName: string, +): Record { + return { + ...guacConfig, + "recording-path": recordingPath, + "recording-name": recordingName, + "create-recording-path": true, + "recording-exclude-output": guacConfig["recording-exclude-output"] ?? false, + "recording-include-keys": guacConfig["recording-include-keys"] ?? true, + }; +} diff --git a/src/backend/hosts/guacamole/routes.ts b/src/backend/hosts/guacamole/routes.ts index f5b07d5d..47b1cfca 100644 --- a/src/backend/hosts/guacamole/routes.ts +++ b/src/backend/hosts/guacamole/routes.ts @@ -1,20 +1,26 @@ import express from "express"; import { GuacamoleTokenService } from "./token-service.js"; +import { withRecordingSettings } from "./recording-settings.js"; import { guacLogger } from "../../utils/logger.js"; import { AuthManager } from "../../utils/auth-manager.js"; import { PermissionManager } from "../../utils/permission-manager.js"; import net from "net"; import crypto from "crypto"; import path from "path"; -import type { AuthenticatedRequest, ProxyNode } from "../../../types/index.js"; +import type { AuthenticatedRequest } from "../../../types/index.js"; import { createCurrentHostResolutionRepository, createCurrentSettingsRepository, } from "../../database/repositories/factory.js"; import { resolveGuacdOptions } from "../../utils/guacd-config.js"; import { createJumpHostChain } from "../jump-host-chain.js"; -import type { SOCKS5Config } from "../../utils/socks5-helper.js"; import { waitForGuacdOpen } from "./guacamole-server.js"; +import { + logAudit, + getAuditUsername, + getRequestMeta, +} from "../../utils/audit-logger.js"; +import { resolveJumpTunnelEndpoint } from "./jump-tunnel-endpoint.js"; const router = express.Router(); const tokenService = GuacamoleTokenService.getInstance(); @@ -293,8 +299,7 @@ router.post( // Extract per-connection guacd proxy settings before passing the rest as connection settings const perConnectionGuacdHost = guacConfig["guacd-hostname"] as - | string - | undefined; + string | undefined; const perConnectionGuacdPortRaw = guacConfig["guacd-port"]; const perConnectionGuacdPort = perConnectionGuacdPortRaw ? parseInt(String(perConnectionGuacdPortRaw), 10) || undefined @@ -485,40 +490,21 @@ router.post( if (jumpHosts.length > 0) { try { - let socks5ProxyChain: ProxyNode[] = []; - if (hostRecord.socks5ProxyChain) { - try { - socks5ProxyChain = - typeof hostRecord.socks5ProxyChain === "string" - ? JSON.parse(hostRecord.socks5ProxyChain as string) - : (hostRecord.socks5ProxyChain as ProxyNode[]); - } catch { - socks5ProxyChain = []; - } + let guacdUrl: string | undefined; + try { + guacdUrl = + (await createCurrentSettingsRepository().get("guac_url")) ?? + undefined; + } catch { + // Environment/default guacd configuration remains available. } + const guacdHost = + perConnectionGuacdHost || resolveGuacdOptions(guacdUrl).host; + const tunnelEndpoint = resolveJumpTunnelEndpoint(guacdHost); - const proxyConfig: SOCKS5Config | null = - hostRecord.useSocks5 && - (hostRecord.socks5Host || socks5ProxyChain.length > 0) - ? { - useSocks5: hostRecord.useSocks5 as boolean, - socks5Host: hostRecord.socks5Host as string | undefined, - socks5Port: hostRecord.socks5Port as number | undefined, - socks5Username: hostRecord.socks5Username as - | string - | undefined, - socks5Password: hostRecord.socks5Password as - | string - | undefined, - socks5ProxyChain, - } - : null; - - const jumpClient = await createJumpHostChain( - jumpHosts, - userId, - proxyConfig, - ); + // The chain dials the first hop through that hop's own SOCKS5 + // settings; the target host's proxy config does not apply to it. + const jumpClient = await createJumpHostChain(jumpHosts, userId); if (!jumpClient) { guacLogger.error( @@ -550,7 +536,7 @@ router.post( ); }); server.on("error", reject); - server.listen(0, "127.0.0.1", () => { + server.listen(0, tunnelEndpoint.bindHost, () => { const addr = server.address() as net.AddressInfo; // Auto-cleanup after 1 hour setTimeout( @@ -563,7 +549,7 @@ router.post( resolve(addr.port); }); }); - hostname = "127.0.0.1"; + hostname = tunnelEndpoint.advertisedHost; port = tunnelPort; guacLogger.info("SSH tunnel established for guacamole", { operation: "guac_ssh_tunnel", @@ -602,15 +588,16 @@ router.post( userId, protocol: connectionType as "rdp" | "vnc" | "telnet", path: recordingName, + guacdPath: recordingPath, startedAt: new Date().toISOString(), } : undefined; if (recordingEnabled) { - guacConfig["recording-path"] = recordingPath; - guacConfig["recording-name"] = recordingName; - guacConfig["create-recording-path"] = true; - guacConfig["recording-exclude-output"] = false; - guacConfig["recording-include-keys"] = true; + guacConfig = withRecordingSettings( + guacConfig, + recordingPath, + recordingName, + ); } const termixConnectId = crypto.randomUUID(); @@ -685,6 +672,19 @@ router.post( const sessionInfo = await waitForGuacdOpen(termixConnectId, 10000); + const { ipAddress, userAgent } = getRequestMeta(req); + await logAudit({ + userId, + username: await getAuditUsername(userId), + action: `${connectionType}_connect`, + resourceType: "host", + resourceId: String(hostId), + resourceName: `${hostname}:${port}`, + ipAddress, + userAgent, + success: true, + }); + res.json({ token, guacamoleConnectionId: sessionInfo?.guacamoleConnectionId ?? null, diff --git a/src/backend/hosts/guacamole/token-service.ts b/src/backend/hosts/guacamole/token-service.ts index a77e6203..8bd34c4b 100644 --- a/src/backend/hosts/guacamole/token-service.ts +++ b/src/backend/hosts/guacamole/token-service.ts @@ -48,6 +48,9 @@ export interface GuacamoleRecordingMetadata { userId: string; protocol: "rdp" | "vnc" | "telnet"; path: string; + /** Directory guacd was told to write into; differs from the backend's view + * when guacd runs in its own container. */ + guacdPath?: string; startedAt: string; } diff --git a/src/backend/hosts/host-resolver.ts b/src/backend/hosts/host-resolver.ts index df5d8bd4..53e3c5cb 100644 --- a/src/backend/hosts/host-resolver.ts +++ b/src/backend/hosts/host-resolver.ts @@ -3,8 +3,10 @@ import { createCurrentVaultProfileRepository, createCurrentUserRepository, } from "../database/repositories/factory.js"; +import type { HostResolutionHostRecord } from "../database/repositories/host-resolution-repository.js"; import { logAudit } from "../utils/audit-logger.js"; import { logger } from "../utils/logger.js"; +import { resolveRecipientSharedHostAuthentication } from "../utils/shared-host-auth-resolver.js"; import { pickResolvedPassword, pickResolvedUsername, @@ -99,6 +101,17 @@ export async function resolveHostById( host.terminalConfig = undefined; } } + if ( + !ownerEquivalent && + host.terminalConfig && + typeof host.terminalConfig === "object" && + !Array.isArray(host.terminalConfig) + ) { + host.terminalConfig = { + ...(host.terminalConfig as Record), + sudoPassword: null, + }; + } if (typeof host.socks5ProxyChain === "string" && host.socks5ProxyChain) { try { host.socks5ProxyChain = JSON.parse(host.socks5ProxyChain as string); @@ -113,15 +126,18 @@ export async function resolveHostById( host.quickActions = []; } } + if (typeof host.portKnockSequence === "string" && host.portKnockSequence) { + try { + host.portKnockSequence = JSON.parse(host.portKnockSequence as string); + } catch { + host.portKnockSequence = []; + } + } + let sharedAuthResolution: SharedAuthResolution | undefined; if (!ownerEquivalent) { - const resolved = await resolveSharedSshSecrets( - host, - hostId, - userId, - repository, - ); - if (!resolved) return null; + sharedAuthResolution = await resolveRecipientSshAuth(host, hostId, userId); + if (!sharedAuthResolution) return null; } else { let effectiveCredentialId = host.credentialId as number | null | undefined; if ( @@ -187,7 +203,7 @@ export async function resolveHostById( // Resolve a Vault SSH signer profile (shared settings, no secrets). The // certificate itself is obtained per-user at connect time via Vault OIDC. - if (host.vaultProfileId) { + if (host.vaultProfileId && sharedAuthResolution !== "recipient-override") { try { const profile = await createCurrentVaultProfileRepository().findById( host.vaultProfileId as number, @@ -209,87 +225,78 @@ export async function resolveHostById( } /** - * Fill in SSH auth secrets for a shared (non-owner) requester. Order: - * the recipient's own override credential, then their re-encrypted share - * snapshot. Secret-less auth types (opkssh, vault, agent, none) pass through - * untouched. Returns false when a secret-bearing host has no usable source. + * Resolve SSH auth for a shared (non-owner) requester without exposing the + * owner's password, key, or credential reference. A recipient-owned override + * fully replaces the host auth. An owner-enabled shared snapshot is the + * fallback; otherwise only secret-less auth types pass. */ -async function resolveSharedSshSecrets( +type SharedAuthResolution = + "recipient-override" | "shared-snapshot" | "shared-agent" | "secretless"; + +async function resolveRecipientSshAuth( host: Record, hostId: number, userId: string, - repository: ReturnType, -): Promise { +): Promise { + const ownerAuthHost = { ...host } as HostResolutionHostRecord; + + // The host row is decrypted under its owner's DEK so connection settings are + // available. Remove owner SSH auth before resolving anything for a recipient. + host.password = null; + host.key = null; + host.keyPassword = null; + host.keyType = null; + host.certPublicKey = null; + host.credentialId = null; + try { - const overrideCredId = await repository.findOverrideCredentialId( + const resolution = await resolveRecipientSharedHostAuthentication( + ownerAuthHost, hostId, userId, + "ssh", ); - if (overrideCredId) { - const cred = (await repository.findCredentialByIdForUser( - overrideCredId, - userId, - )) as Record | null; - if (cred) { - host.password = cred.password; - host.key = (cred.privateKey || cred.key) as string | null; - host.keyPassword = cred.keyPassword; - host.keyType = cred.keyType; + + if (resolution.source === "personal-override") { + const credential = resolution.credential; + host.password = credential.password; + host.key = credential.privateKey || credential.key; + host.keyPassword = credential.keyPassword; + host.keyType = credential.keyType; + host.certPublicKey = credential.certPublicKey || null; + host.username = credential.username || host.username; + host.authType = host.key ? "key" : host.password ? "password" : "none"; + return "recipient-override"; + } + + if (resolution.source === "owner-shared") { + if (resolution.authType === "agent") { + return "shared-agent"; + } + const sharedAuth = resolution.secret; + if (sharedAuth) { + host.password = sharedAuth.password || null; + host.key = sharedAuth.key || null; + host.keyPassword = sharedAuth.keyPassword || null; + host.keyType = sharedAuth.keyType || null; host.username = pickResolvedUsername( host.username, - cred.username, + sharedAuth.username, host.overrideCredentialUsername, ); host.authType = host.key ? "key" : host.password ? "password" : "none"; - return true; + return "shared-snapshot"; } } - } catch { - // fall through to the share snapshot - } - try { - const { SharedHostSecretsManager } = - await import("../utils/shared-host-secrets-manager.js"); - const secret = - await SharedHostSecretsManager.getInstance().getSecretForUser( - hostId, - userId, - "ssh", - ); - if (secret) { - host.password = secret.password; - host.key = secret.key; - host.keyPassword = secret.keyPassword; - host.keyType = secret.keyType; - host.username = pickResolvedUsername( - host.username, - secret.username, - host.overrideCredentialUsername, - ); - host.authType = secret.key - ? "key" - : secret.password - ? "password" - : "none"; - return true; + if (resolution.source === "secretless") { + return "secretless"; } - } catch (e) { - sshLogger.warn("Failed to get shared host secret", { - operation: "host_resolver_shared_secret", - hostId, - error: e instanceof Error ? e.message : "Unknown", - }); + } catch { + // A missing/deleted override or snapshot behaves like unavailable auth. } - const needsSecrets = - !!host.credentialId || - host.authType === "password" || - host.authType === "key" || - host.authType === "credential"; - if (!needsSecrets) return true; - - return false; + return null; } /** diff --git a/src/backend/hosts/jump-host-chain.ts b/src/backend/hosts/jump-host-chain.ts index 5821fe76..b7441552 100644 --- a/src/backend/hosts/jump-host-chain.ts +++ b/src/backend/hosts/jump-host-chain.ts @@ -1,14 +1,11 @@ import { Client as SSHClient } from "ssh2"; -import { createCurrentHostResolutionRepository } from "../database/repositories/factory.js"; import { fileLogger } from "../utils/logger.js"; -import { - createSocks5Connection, - type SOCKS5Config, -} from "../utils/socks5-helper.js"; +import { createSocks5Connection } from "../utils/socks5-helper.js"; import { SSH_ALGORITHMS } from "../utils/ssh-algorithms.js"; import { SSHHostKeyVerifier } from "./host-key-verifier.js"; import { getJumpHostSocks5Config } from "./jump-host-proxy.js"; import { applyAgentAuth } from "./terminal-auth-helpers.js"; +import { resolveHostById } from "./host-resolver.js"; type JumpHostConfig = { id: number; @@ -35,64 +32,10 @@ async function resolveJumpHost( userId: string, ): Promise { try { - const repository = createCurrentHostResolutionRepository(); - const ownerId = (await repository.findHostOwnerId(hostId)) ?? userId; - const resolvedHost = await repository.findHostById(hostId, ownerId); - - if (!resolvedHost) { - return null; - } - - const host = resolvedHost as Record; - - if (host.credentialId) { - if (userId !== ownerId) { - try { - const { SharedHostSecretsManager } = - await import("../utils/shared-host-secrets-manager.js"); - const secret = - await SharedHostSecretsManager.getInstance().getSecretForUser( - hostId, - userId, - "ssh", - ); - if (secret) { - return { - ...host, - password: secret.password, - key: secret.key, - keyPassword: secret.keyPassword, - keyType: secret.keyType, - authType: secret.key - ? "key" - : secret.password - ? "password" - : "none", - } as JumpHostConfig; - } - } catch { - // fall through to owner credential lookup - } - } - - const credential = (await repository.findCredentialByIdForUser( - host.credentialId as number, - ownerId, - )) as Record | null; - - if (credential) { - return { - ...host, - password: credential.password as string | undefined, - key: (credential.key || credential.privateKey) as string | undefined, - keyPassword: credential.keyPassword as string | undefined, - keyType: credential.keyType as string | undefined, - authType: credential.authType as string | undefined, - } as JumpHostConfig; - } - } - - return host as JumpHostConfig; + return (await resolveHostById( + hostId, + userId, + )) as unknown as JumpHostConfig | null; } catch (error) { fileLogger.error("Failed to resolve jump host", error, { operation: "resolve_jump_host", @@ -106,7 +49,6 @@ async function resolveJumpHost( export async function createJumpHostChain( jumpHosts: Array<{ hostId: number }>, userId: string, - socks5Config?: SOCKS5Config | null, ): Promise { if (!jumpHosts || jumpHosts.length === 0) { return null; @@ -138,10 +80,7 @@ export async function createJumpHostChain( } } - const firstHopSocks5Config = getJumpHostSocks5Config( - jumpHostConfigs[0], - socks5Config, - ); + const firstHopSocks5Config = getJumpHostSocks5Config(jumpHostConfigs[0]); let proxySocket: import("net").Socket | null = null; if (firstHopSocks5Config?.useSocks5) { const firstHop = jumpHostConfigs[0]!; @@ -263,8 +202,7 @@ export async function createJumpHostChain( const result = await applyAgentAuth( connectConfig, jumpHostConfig.terminalConfig as - | Record - | undefined, + Record | undefined, ); if ("error" in result) { throw new Error(result.error); diff --git a/src/backend/hosts/jump-host-proxy.test.ts b/src/backend/hosts/jump-host-proxy.test.ts new file mode 100644 index 00000000..877998e6 --- /dev/null +++ b/src/backend/hosts/jump-host-proxy.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it } from "vitest"; +import { getJumpHostSocks5Config } from "./jump-host-proxy.js"; + +describe("getJumpHostSocks5Config", () => { + it("uses the first jump host proxy settings", () => { + expect( + getJumpHostSocks5Config({ + useSocks5: true, + socks5Host: "proxy.internal", + socks5Port: 1080, + socks5Username: "user", + socks5Password: "secret", + }), + ).toEqual({ + useSocks5: true, + socks5Host: "proxy.internal", + socks5Port: 1080, + socks5Username: "user", + socks5Password: "secret", + socks5ProxyChain: [], + }); + }); + + it("does not use destination proxy settings for the first jump host", () => { + expect(getJumpHostSocks5Config({ useSocks5: false })).toBeNull(); + }); + + it("accepts a serialized proxy chain from the first jump host", () => { + const chain = [ + { + id: "proxy-1", + name: "Proxy 1", + host: "proxy.internal", + port: 1080, + type: "socks5" as const, + }, + ]; + + expect( + getJumpHostSocks5Config({ + useSocks5: true, + socks5ProxyChain: JSON.stringify(chain), + }), + ).toEqual({ + useSocks5: true, + socks5Host: undefined, + socks5Port: undefined, + socks5Username: undefined, + socks5Password: undefined, + socks5ProxyChain: chain, + }); + }); +}); diff --git a/src/backend/hosts/jump-host-proxy.ts b/src/backend/hosts/jump-host-proxy.ts index e16cb746..890446e0 100644 --- a/src/backend/hosts/jump-host-proxy.ts +++ b/src/backend/hosts/jump-host-proxy.ts @@ -29,15 +29,14 @@ function parseProxyChain(value: JumpHostProxyConfig["socks5ProxyChain"]) { export function getJumpHostSocks5Config( firstHop: JumpHostProxyConfig | null | undefined, - fallbackConfig?: SOCKS5Config | null, ): SOCKS5Config | null { if (!firstHop?.useSocks5) { - return fallbackConfig ?? null; + return null; } const socks5ProxyChain = parseProxyChain(firstHop.socks5ProxyChain); if (!firstHop.socks5Host && socks5ProxyChain.length === 0) { - return fallbackConfig ?? null; + return null; } return { diff --git a/src/backend/hosts/metrics/alert-engine.ts b/src/backend/hosts/metrics/alert-engine.ts index deface56..eb963f5f 100644 --- a/src/backend/hosts/metrics/alert-engine.ts +++ b/src/backend/hosts/metrics/alert-engine.ts @@ -225,7 +225,7 @@ export class AlertEngine { severity: context.severity, }); - repository.pruneFiringsOlderThan(rule.userId, 30); + await repository.pruneFiringsOlderThan(rule.userId, 30); } catch (err) { statsLogger.warn("Failed to write alert firing", { operation: "alert_firing_insert_error", diff --git a/src/backend/hosts/metrics/index.ts b/src/backend/hosts/metrics/index.ts index 8f71f1d9..61933599 100644 --- a/src/backend/hosts/metrics/index.ts +++ b/src/backend/hosts/metrics/index.ts @@ -24,7 +24,10 @@ import type { } from "../../../types/connection-log.js"; import { collectCpuMetrics } from "./widgets/cpu-collector.js"; import { collectMemoryMetrics } from "./widgets/memory-collector.js"; -import { collectDiskMetrics } from "./widgets/disk-collector.js"; +import { + collectDiskMetrics, + type DiskFilesystem, +} from "./widgets/disk-collector.js"; import { collectNetworkMetrics } from "./widgets/network-collector.js"; import { collectUptimeMetrics } from "./widgets/uptime-collector.js"; import { collectProcessesMetrics } from "./widgets/processes-collector.js"; @@ -50,6 +53,7 @@ import { resolveSshConnectConfigHost } from "../ssh-dns.js"; import { AccessDeniedError } from "./managers/route-helpers.js"; import type { ManagerHost } from "./managers/types.js"; import { createJumpHostChain } from "../jump-host-chain.js"; +import { resolveHostById } from "../host-resolver.js"; import { isTcpPingEnabled, supportsMetrics, @@ -464,24 +468,9 @@ class PollingManager { let isOnline: boolean; if (refreshedHost.jumpHosts && refreshedHost.jumpHosts.length > 0) { - const proxyConfig: SOCKS5Config | null = - refreshedHost.useSocks5 && - (refreshedHost.socks5Host || - (refreshedHost.socks5ProxyChain && - refreshedHost.socks5ProxyChain.length > 0)) - ? { - useSocks5: true, - socks5Host: refreshedHost.socks5Host, - socks5Port: refreshedHost.socks5Port, - socks5Username: refreshedHost.socks5Username, - socks5Password: refreshedHost.socks5Password, - socks5ProxyChain: refreshedHost.socks5ProxyChain, - } - : null; const jumpClient = await createJumpHostChain( refreshedHost.jumpHosts, userId, - proxyConfig, ); isOnline = jumpClient ? await tcpPingThroughJumpHost( @@ -631,7 +620,7 @@ class PollingManager { }); const retentionDays = this.getRetentionDays(); - repository.pruneOlderThan(hostId, retentionDays); + await repository.pruneOlderThan(hostId, retentionDays); } catch (err) { statsLogger.warn("Failed to write metrics history", { operation: "insert_metrics_history", @@ -857,6 +846,38 @@ app.use((_req, res, next) => { next(); }); +// Internal endpoint — only accepts calls from localhost. Registered before +// the auth middleware since it's a service-to-service call authenticated by +// IP + shared secret, not a user JWT. +// Used by the main backend to notify the metrics service of SSH login events. +app.post("/internal/login-alert", async (req, res) => { + const remoteIp = req.socket.remoteAddress; + if ( + remoteIp !== "127.0.0.1" && + remoteIp !== "::1" && + remoteIp !== "::ffff:127.0.0.1" + ) { + return res.status(403).json({ error: "Forbidden" }); + } + const systemCrypto = (await import("../../utils/system-crypto.js")) + .SystemCrypto; + const expectedToken = await systemCrypto.getInstance().getInternalAuthToken(); + const token = req.headers["x-internal-auth"]; + if (!token || token !== expectedToken) { + return res.status(403).json({ error: "Forbidden" }); + } + const { hostId, userId, sshUser, fromIp } = req.body as { + hostId: number; + userId: string; + sshUser: string; + fromIp: string; + }; + AlertEngine.getInstance() + .evaluateUserLogin(hostId, userId, sshUser, fromIp) + .catch(() => {}); + res.json({ ok: true }); +}); + app.use(authManager.createAuthMiddleware()); const requireAdmin = authManager.createAdminMiddleware(); @@ -897,14 +918,9 @@ async function fetchHostById( return undefined; } - const accessInfo = await permissionManager.canAccessHost( - userId, - id, - "connect", - ); - - if (!accessInfo.hasAccess) { - statsLogger.warn(`User ${userId} cannot access host ${id}`, { + const host = await resolveHostById(id, userId); + if (!host) { + statsLogger.warn(`User ${userId} cannot resolve host ${id}`, { operation: "fetch_host_access_denied", userId, hostId: id, @@ -912,14 +928,7 @@ async function fetchHostById( return undefined; } - const repository = createCurrentHostResolutionRepository(); - const host = await repository.findHostById(id, userId); - - if (!host) { - return undefined; - } - - return await resolveHostCredentials(host, userId); + return host as SSHHostWithCredentials; } catch (err) { statsLogger.error(`Failed to fetch host ${id}`, err); return undefined; @@ -1295,11 +1304,7 @@ function createSshFactory(host: SSHHostWithCredentials): () => Promise { let jumpClient: Client | null = null; if (hasJumpHosts) { - jumpClient = await createJumpHostChain( - host.jumpHosts!, - host.userId!, - proxyConfig, - ); + jumpClient = await createJumpHostChain(host.jumpHosts!, host.userId!); if (!jumpClient) { throw new Error("Failed to establish jump host chain"); @@ -1461,6 +1466,8 @@ async function collectMetrics(host: SSHHostWithCredentials): Promise<{ usedHuman: string | null; totalHuman: string | null; availableHuman: string | null; + mount: string | null; + filesystems: DiskFilesystem[]; }; network: { interfaces: Array<{ @@ -2028,6 +2035,8 @@ app.get("/metrics/:id", validateHostId, async (req, res) => { usedHuman: null, totalHuman: null, availableHuman: null, + mount: null, + filesystems: [], }, network: { interfaces: [] }, uptime: { seconds: null, formatted: null }, @@ -2803,36 +2812,6 @@ registerManagerRoutes(app, { }, }); -// Internal endpoint — only accepts calls from localhost. -// Used by the main backend to notify the metrics service of SSH login events. -app.post("/internal/login-alert", async (req, res) => { - const remoteIp = req.socket.remoteAddress; - if ( - remoteIp !== "127.0.0.1" && - remoteIp !== "::1" && - remoteIp !== "::ffff:127.0.0.1" - ) { - return res.status(403).json({ error: "Forbidden" }); - } - const systemCrypto = (await import("../../utils/system-crypto.js")) - .SystemCrypto; - const expectedToken = await systemCrypto.getInstance().getInternalAuthToken(); - const token = req.headers["x-internal-auth"]; - if (!token || token !== expectedToken) { - return res.status(403).json({ error: "Forbidden" }); - } - const { hostId, userId, sshUser, fromIp } = req.body as { - hostId: number; - userId: string; - sshUser: string; - fromIp: string; - }; - AlertEngine.getInstance() - .evaluateUserLogin(hostId, userId, sshUser, fromIp) - .catch(() => {}); - res.json({ ok: true }); -}); - process.on("SIGINT", () => { pollingManager.destroy(); connectionPool.destroy(); diff --git a/src/backend/hosts/metrics/managers/validation.ts b/src/backend/hosts/metrics/managers/validation.ts index 73e10ada..7f42cb76 100644 --- a/src/backend/hosts/metrics/managers/validation.ts +++ b/src/backend/hosts/metrics/managers/validation.ts @@ -64,12 +64,7 @@ export function isValidSignal(sig: unknown): sig is Signal { } export type ServiceAction = - | "start" - | "stop" - | "restart" - | "reload" - | "enable" - | "disable"; + "start" | "stop" | "restart" | "reload" | "enable" | "disable"; const SERVICE_ACTIONS: ServiceAction[] = [ "start", "stop", diff --git a/src/backend/hosts/metrics/widgets/disk-collector.ts b/src/backend/hosts/metrics/widgets/disk-collector.ts index eec042d3..3af373f2 100644 --- a/src/backend/hosts/metrics/widgets/disk-collector.ts +++ b/src/backend/hosts/metrics/widgets/disk-collector.ts @@ -9,6 +9,18 @@ export interface DfRow { parts: string[]; } +export interface DiskFilesystem { + filesystem: string; + mount: string; + percent: number | null; + usedHuman: string | null; + totalHuman: string | null; + availableHuman: string | null; + usedBytes: number | null; + totalBytes: number | null; + availableBytes: number | null; +} + export function parseDfLines(output: string): DfRow[] { return output .split("\n") @@ -62,17 +74,73 @@ export function findWorstMountIndex(bytesRows: DfRow[]): { }; } +// Merges the `df -B1` and `df -h` row sets into one filesystem list. Byte rows +// drive the maths; human rows only supply the display strings, matched by mount +// point so a mismatched row count can't shift the columns. +export function buildFilesystemList( + bytesRows: DfRow[], + humanRows: DfRow[], +): DiskFilesystem[] { + const aligned = humanRows.length === bytesRows.length; + + return bytesRows + .map((row, index) => { + const totalBytes = Number(row.parts[1]); + const usedBytes = Number(row.parts[2]); + const availableBytes = Number(row.parts[3]); + if (!Number.isFinite(totalBytes) || totalBytes <= 0) return null; + + const humanRow = aligned + ? humanRows[index] + : humanRows.find((h) => h.mount === row.mount); + + const percent = Number.isFinite(usedBytes) + ? Math.max(0, Math.min(100, (usedBytes / totalBytes) * 100)) + : null; + + return { + filesystem: row.filesystem, + mount: row.mount, + percent: toFixedNum(percent, 0), + usedHuman: humanRow?.parts[2] || null, + totalHuman: humanRow?.parts[1] || null, + availableHuman: humanRow?.parts[3] || null, + usedBytes: Number.isFinite(usedBytes) ? usedBytes : null, + totalBytes, + availableBytes: Number.isFinite(availableBytes) ? availableBytes : null, + }; + }) + .filter((fs): fs is DiskFilesystem => fs !== null); +} + +// The headline disk figure should be the root filesystem - that is what users +// mean by "the server's disk". Only when there is no root mount (containers, +// chroots) do we fall back to the most-utilized mount. +export function selectPrimaryFilesystem( + filesystems: DiskFilesystem[], +): DiskFilesystem | null { + if (filesystems.length === 0) return null; + + const root = filesystems.find((fs) => fs.mount === "/"); + if (root) return root; + + let best = filesystems[0]; + for (const fs of filesystems) { + const ratio = (fs.usedBytes ?? 0) / (fs.totalBytes || 1); + const bestRatio = (best.usedBytes ?? 0) / (best.totalBytes || 1); + if (ratio > bestRatio) best = fs; + } + return best; +} + export async function collectDiskMetrics(client: Client): Promise<{ percent: number | null; usedHuman: string | null; totalHuman: string | null; availableHuman: string | null; + mount: string | null; + filesystems: DiskFilesystem[]; }> { - let diskPercent: number | null = null; - let usedHuman: string | null = null; - let totalHuman: string | null = null; - let availableHuman: string | null = null; - try { const [diskOutHuman, diskOutBytes] = await Promise.all([ execCommand(client, "df -h -P | tail -n +2"), @@ -81,35 +149,25 @@ export async function collectDiskMetrics(client: Client): Promise<{ const humanRows = parseDfLines(diskOutHuman.stdout); const bytesRows = parseDfLines(diskOutBytes.stdout); - const worst = findWorstMountIndex(bytesRows); + const filesystems = buildFilesystemList(bytesRows, humanRows); + const primary = selectPrimaryFilesystem(filesystems); - if (worst.totalBytes > 0) { - diskPercent = Math.max( - 0, - Math.min(100, (worst.usedBytes / worst.totalBytes) * 100), - ); - - const humanRow = - humanRows.length === bytesRows.length - ? humanRows[worst.index] - : humanRows.find((row) => row.mount === bytesRows[worst.index].mount); - if (humanRow) { - totalHuman = humanRow.parts[1] || null; - usedHuman = humanRow.parts[2] || null; - availableHuman = humanRow.parts[3] || null; - } - } + return { + percent: primary?.percent ?? null, + usedHuman: primary?.usedHuman ?? null, + totalHuman: primary?.totalHuman ?? null, + availableHuman: primary?.availableHuman ?? null, + mount: primary?.mount ?? null, + filesystems, + }; } catch { - diskPercent = null; - usedHuman = null; - totalHuman = null; - availableHuman = null; + return { + percent: null, + usedHuman: null, + totalHuman: null, + availableHuman: null, + mount: null, + filesystems: [], + }; } - - return { - percent: toFixedNum(diskPercent, 0), - usedHuman, - totalHuman, - availableHuman, - }; } diff --git a/src/backend/hosts/opkssh-auth.ts b/src/backend/hosts/opkssh-auth.ts index 1988bc96..0d1b0a3b 100644 --- a/src/backend/hosts/opkssh-auth.ts +++ b/src/backend/hosts/opkssh-auth.ts @@ -26,11 +26,7 @@ interface OPKSSHAuthSession { remoteRedirectUri: string; providers: Array<{ alias: string; issuer: string }>; status: - | "starting" - | "waiting_for_auth" - | "authenticating" - | "completed" - | "error"; + "starting" | "waiting_for_auth" | "authenticating" | "completed" | "error"; ws: WebSocket; stdoutBuffer: string; privateKeyBuffer: string; diff --git a/src/backend/hosts/tailscale-check.ts b/src/backend/hosts/tailscale-check.ts new file mode 100644 index 00000000..5df26b85 --- /dev/null +++ b/src/backend/hosts/tailscale-check.ts @@ -0,0 +1,42 @@ +// Tailscale SSH "check mode" sends its re-authentication prompt as an SSH auth +// banner during the "none" auth method, then long-polls its control plane while +// the connection stays open. The banner text itself comes from the control plane +// (it is not in the tailscaled source), so match on the login URL rather than the +// surrounding wording, which can change without notice. + +const TAILSCALE_CHECK_URL = /https:\/\/login\.tailscale\.com\/a\/[A-Za-z0-9]+/; + +const CHECK_COMPLETE = /authentication checked/i; + +export interface TailscaleCheckBanner { + url: string; + message: string; +} + +function stripCommentMarkers(banner: string): string { + return banner + .split(/\r?\n/) + .map((line) => line.replace(/^\s*#\s?/, "").trim()) + .filter((line) => line.length > 0) + .join("\n") + .trim(); +} + +export function parseTailscaleCheckBanner( + banner: string, +): TailscaleCheckBanner | null { + if (!banner) return null; + + const match = banner.match(TAILSCALE_CHECK_URL); + if (!match) return null; + + return { + url: match[0], + message: stripCommentMarkers(banner), + }; +} + +export function isTailscaleCheckCompleteBanner(banner: string): boolean { + if (!banner) return false; + return CHECK_COMPLETE.test(banner); +} diff --git a/src/backend/hosts/terminal/index.ts b/src/backend/hosts/terminal/index.ts index b575c749..0d1071d5 100644 --- a/src/backend/hosts/terminal/index.ts +++ b/src/backend/hosts/terminal/index.ts @@ -20,6 +20,10 @@ import { SSHAuthManager } from "../auth-manager.js"; import type { ProxyNode } from "../../../types/index.js"; import { SSHHostKeyVerifier } from "../host-key-verifier.js"; import { createJumpHostChain } from "../jump-host-chain.js"; +import { + parseTailscaleCheckBanner, + isTailscaleCheckCompleteBanner, +} from "../tailscale-check.js"; import { sessionManager, isMessageAllowedForParticipant, @@ -106,6 +110,10 @@ interface WebSocketMessage { const authManager = AuthManager.getInstance(); +// Tailscale holds a check-mode connection open for up to 30 minutes while the +// user completes the browser login, so match that rather than timing out first. +const TAILSCALE_CHECK_TIMEOUT_MS = 1_800_000; + const userConnections = new Map>(); const wss = new WebSocketServer({ @@ -1246,8 +1254,7 @@ wss.on("connection", async (ws: WebSocket, req) => { { userId, permissionLevel: share.permissionLevel as - | "read-write" - | "read-only", + "read-write" | "read-only", tabInstanceId: joinData.tabInstanceId, shareId: share.id, }, @@ -1411,7 +1418,7 @@ wss.on("connection", async (ws: WebSocket, req) => { sendLog("dns", "info", `Starting address resolution of ${ip}`); sendLog("tcp", "info", `Connecting to ${ip} port ${port}`); - const connectionTimeout = setTimeout(() => { + const onConnectionTimeout = () => { if (sshConn && isConnecting && !isConnected) { sshLogger.error("SSH connection timeout", undefined, { operation: "ssh_connect", @@ -1429,7 +1436,15 @@ wss.on("connection", async (ws: WebSocket, req) => { } cleanupAuthState(connectionTimeout); } - }, 120000); + }; + + // Reassigned when Tailscale check mode starts, so the short connect timeout + // does not tear down a connection the server is deliberately holding open. + let connectionTimeout = setTimeout(onConnectionTimeout, 120000); + + let tailscaleCheckPending = false; + let tailscaleForcePasswordAttempted = false; + let isTailscaleRetrying = false; let resolvedHostData: | (Record & { @@ -1642,8 +1657,54 @@ wss.on("connection", async (ws: WebSocket, req) => { } sendLog("tcp", "info", `Connecting to ${ip} port ${port}`); + // Tailscale SSH check mode delivers its re-auth URL as an auth banner and then + // blocks for up to 30 minutes while the user logs in via the browser. + sshConn.on("banner", (banner: string) => { + const check = parseTailscaleCheckBanner(banner); + if (check) { + tailscaleCheckPending = true; + + clearTimeout(connectionTimeout); + connectionTimeout = setTimeout( + onConnectionTimeout, + TAILSCALE_CHECK_TIMEOUT_MS, + ); + + sendLog( + "auth", + "info", + `Tailscale SSH requires an additional check. Waiting for browser authentication at ${check.url}`, + ); + + ws.send( + JSON.stringify({ + type: "tailscale_check_required", + hostId: id, + url: check.url, + message: check.message, + }), + ); + return; + } + + if (tailscaleCheckPending && isTailscaleCheckCompleteBanner(banner)) { + tailscaleCheckPending = false; + sendLog("auth", "info", "Tailscale SSH check completed"); + ws.send( + JSON.stringify({ type: "tailscale_check_completed", hostId: id }), + ); + } + }); + sshConn.on("ready", () => { clearTimeout(connectionTimeout); + isTailscaleRetrying = false; + if (tailscaleCheckPending) { + tailscaleCheckPending = false; + ws.send( + JSON.stringify({ type: "tailscale_check_completed", hostId: id }), + ); + } sshLogger.success("SSH connection established", { operation: "terminal_ssh_connected", sessionId, @@ -2314,6 +2375,51 @@ wss.on("connection", async (ws: WebSocket, req) => { return; } + // Tailscale documents the "+password" username suffix as the workaround for + // clients that mishandle a successful reply to auth type "none". It routes + // through PasswordCallback into the same check-mode flow, and the password + // value is ignored. Retry once before reporting an auth failure. + // Skipped when tunnelled: connectConfig.sock is a one-shot stream that + // cannot be reused for a second connect. + if ( + resolvedCredentials.authType === "tailscale" && + !tailscaleForcePasswordAttempted && + !tailscaleCheckPending && + !connectConfig.sock && + (authMethodNotAvailable || + err.message.includes("All configured authentication methods failed")) + ) { + tailscaleForcePasswordAttempted = true; + + sendLog( + "auth", + "info", + "Retrying Tailscale SSH in forced password mode", + ); + sshLogger.info("Retrying Tailscale SSH with +password suffix", { + operation: "tailscale_force_password_retry", + hostId: id, + userId, + username, + }); + + clearTimeout(connectionTimeout); + connectionTimeout = setTimeout( + onConnectionTimeout, + TAILSCALE_CHECK_TIMEOUT_MS, + ); + + connectConfig.username = `${username}+password`; + connectConfig.password = "termix"; + connectConfig.tryKeyboard = false; + + // ssh2's connect() ends an open socket and reconnects on close, keeping + // every listener attached, so the same client can be reused here. + isTailscaleRetrying = true; + sshConn.connect(connectConfig); + return; + } + if ( resolvedCredentials.authType === "tailscale" && (authMethodNotAvailable || @@ -2475,6 +2581,12 @@ wss.on("connection", async (ws: WebSocket, req) => { }); sshConn.on("close", () => { + // The +password retry ends the socket before reconnecting; that close is + // part of the retry, not a disconnect. + if (isTailscaleRetrying) { + return; + } + clearTimeout(connectionTimeout); sshLogger.info("SSH connection closed", { operation: "terminal_ssh_disconnected", @@ -2605,10 +2717,17 @@ wss.on("connection", async (ws: WebSocket, req) => { typeof hostKeepaliveCountMax === "number" ? Math.max(1, hostKeepaliveCountMax) : 5, - readyTimeout: 120000, + readyTimeout: + resolvedCredentials.authType === "tailscale" + ? TAILSCALE_CHECK_TIMEOUT_MS + : 120000, tcpKeepAlive: true, tcpKeepAliveInitialDelay: 30000, - timeout: 120000, + // The socket sits idle while a Tailscale check-mode login is pending. + timeout: + resolvedCredentials.authType === "tailscale" + ? TAILSCALE_CHECK_TIMEOUT_MS + : 120000, hostVerifier: await SSHHostKeyVerifier.createHostVerifier( id, ip, @@ -2791,8 +2910,7 @@ wss.on("connection", async (ws: WebSocket, req) => { sendLog("auth", "info", "Using Vault SSH signer authentication"); try { const vaultProfile = resolvedHostData?.vaultProfile as - | { id: number } - | undefined; + { id: number } | undefined; if (!vaultProfile?.id) { throw new Error("Host has no Vault signer profile configured"); } @@ -2941,8 +3059,7 @@ wss.on("connection", async (ws: WebSocket, req) => { // Cloudflare Tunnel: connect via WebSocket proxy const cfConfig = hostConfig.terminalConfig as - | Record - | undefined; + Record | undefined; if (cfConfig?.cfAccessClientId && cfConfig?.cfAccessClientSecret) { try { const WebSocket = (await import("ws")).default; @@ -3000,7 +3117,6 @@ wss.on("connection", async (ws: WebSocket, req) => { const jumpClient = await createJumpHostChain( hostConfig.jumpHosts!, hostConfig.userId!, - proxyConfig, ); if (!jumpClient) { diff --git a/src/backend/hosts/tmux/auth-utils.ts b/src/backend/hosts/tmux/auth-utils.ts new file mode 100644 index 00000000..726b1f8d --- /dev/null +++ b/src/backend/hosts/tmux/auth-utils.ts @@ -0,0 +1,11 @@ +import type { SSHHost } from "../../../types/index.js"; + +export function getTmuxAuthBehavior(authType: SSHHost["authType"]): { + credentialless: boolean; + tryKeyboard: boolean; +} { + return { + credentialless: authType === "none" || authType === "tailscale", + tryKeyboard: authType !== "tailscale", + }; +} diff --git a/src/backend/hosts/tmux/index.ts b/src/backend/hosts/tmux/index.ts index 1755c5cb..5eb65258 100644 --- a/src/backend/hosts/tmux/index.ts +++ b/src/backend/hosts/tmux/index.ts @@ -40,6 +40,7 @@ import { type PaneMetrics, } from "./monitor-helpers.js"; import type { SSHHost, AuthenticatedRequest } from "../../../types/index.js"; +import { getTmuxAuthBehavior } from "./auth-utils.js"; const PANE_ID_RE = /^%\d+$/; // tmux session names cannot contain ":" or "."; keep to a conservative @@ -59,11 +60,12 @@ interface TmuxSessionOverview extends TmuxSessionSummary { // and docker; jump hosts and SOCKS5 reuse the shared helpers) async function buildSshConfig(host: SSHHost): Promise { + const authBehavior = getTmuxAuthBehavior(host.authType); const base: ConnectConfig = { host: (host.ip || "").replace(/^\[|\]$/g, ""), port: host.port, username: host.username, - tryKeyboard: true, + tryKeyboard: authBehavior.tryKeyboard, keepaliveInterval: 30000, keepaliveCountMax: 3, readyTimeout: 60000, @@ -94,7 +96,7 @@ async function buildSshConfig(host: SSHHost): Promise { if (host.keyPassword) { (base as Record).passphrase = host.keyPassword; } - } else if (host.authType === "none") { + } else if (authBehavior.credentialless) { // no credentials needed } else if (host.authType === "vault") { // cert auth setup happens in connectToHost (needs client instance) @@ -143,11 +145,7 @@ export function connectToHost(host: SSHHost): () => Promise { let jumpClient: Client | null = null; if (host.jumpHosts && host.jumpHosts.length > 0 && host.userId) { - jumpClient = await createJumpHostChain( - host.jumpHosts, - host.userId, - proxyConfig, - ); + jumpClient = await createJumpHostChain(host.jumpHosts, host.userId); if (!jumpClient) { throw new Error("Failed to establish jump host chain"); } @@ -429,10 +427,7 @@ async function auditTmuxAction( // Typed error codes so the frontend can render a helpful state instead of a // raw 500 (same pattern as SESSION_EXPIRED handling in main-axios). type TmuxErrorCode = - | "TMUX_NOT_INSTALLED" - | "TMUX_NO_SERVER" - | "HOST_UNREACHABLE" - | "TMUX_ERROR"; + "TMUX_NOT_INSTALLED" | "TMUX_NO_SERVER" | "HOST_UNREACHABLE" | "TMUX_ERROR"; function classifyTmuxError(err: unknown): TmuxErrorCode { const msg = err instanceof Error ? err.message : ""; diff --git a/src/backend/hosts/tunnel/manager.ts b/src/backend/hosts/tunnel/manager.ts index 527c5934..129170ca 100644 --- a/src/backend/hosts/tunnel/manager.ts +++ b/src/backend/hosts/tunnel/manager.ts @@ -1019,8 +1019,7 @@ export async function connectSSHTunnel( resolvedEndpointCredentials = { password: credential.password as string | undefined, sshKey: (credential.key || credential.privateKey) as - | string - | undefined, + string | undefined, keyPassword: credential.keyPassword as string | undefined, keyType: credential.keyType as string | undefined, authMethod: credential.authType as string, diff --git a/src/backend/hosts/tunnel/routes.ts b/src/backend/hosts/tunnel/routes.ts index 6eadcf9f..38b2006f 100644 --- a/src/backend/hosts/tunnel/routes.ts +++ b/src/backend/hosts/tunnel/routes.ts @@ -7,6 +7,11 @@ import type { AuthenticatedRequest, } from "../../../types/index.js"; import { CONNECTION_STATES } from "../../../types/index.js"; +import { + logAudit, + getAuditUsername, + getRequestMeta, +} from "../../utils/audit-logger.js"; import { tunnelLogger } from "../../utils/logger.js"; import { SystemCrypto } from "../../utils/system-crypto.js"; import { AuthManager } from "../../utils/auth-manager.js"; @@ -363,6 +368,26 @@ export function registerTunnelRoutes(app: express.Express): void { pendingTunnelOperations.set(tunnelName, operation); + const { ipAddress, userAgent } = getRequestMeta(req); + await logAudit({ + userId, + username: await getAuditUsername(userId), + action: "tunnel_connect", + resourceType: "tunnel", + resourceId: tunnelConfig.sourceHostId + ? String(tunnelConfig.sourceHostId) + : undefined, + resourceName: tunnelName, + details: JSON.stringify({ + endpointHost: tunnelConfig.endpointHost, + endpointPort: tunnelConfig.endpointPort, + sourcePort: tunnelConfig.sourcePort, + }), + ipAddress, + userAgent, + success: true, + }); + res.json({ message: "Connection request received", tunnelName }); operation diff --git a/src/backend/starter.ts b/src/backend/starter.ts index 15d75092..f2830b50 100644 --- a/src/backend/starter.ts +++ b/src/backend/starter.ts @@ -171,10 +171,18 @@ async function provisionLocalDesktopUserIfNeeded(): Promise { await authManager.initialize(); DataCrypto.initialize(); + const { runLegacySharedSshAuthOptInMigration } = + await import("./utils/crypto-migration/legacy-shared-ssh-auth-opt-in-migration.js"); + await runLegacySharedSshAuthOptInMigration(); + const { runSharedHostSecretsMigration } = await import("./utils/crypto-migration/shared-host-secrets-migration.js"); await runSharedHostSecretsMigration(); + const { runPrivateSharedSshAuthMigration } = + await import("./utils/crypto-migration/private-shared-ssh-auth-migration.js"); + await runPrivateSharedSshAuthMigration(); + if (process.env.ELECTRON_EMBEDDED === "true") { await provisionLocalDesktopUserIfNeeded(); } diff --git a/src/backend/tests/database/db/connect.test.ts b/src/backend/tests/database/db/connect.test.ts new file mode 100644 index 00000000..f2bfb1d8 --- /dev/null +++ b/src/backend/tests/database/db/connect.test.ts @@ -0,0 +1,78 @@ +import { describe, expect, it } from "vitest"; +import { + assertUrlMatchesDialect, + connectRemoteDatabase, + databaseUrl, + DATABASE_URL_ENV, +} from "../../../database/db/connect.js"; + +describe("databaseUrl", () => { + it("is absent unless set", () => { + expect(databaseUrl({})).toBeNull(); + expect(databaseUrl({ [DATABASE_URL_ENV]: " " })).toBeNull(); + }); + + it("trims surrounding whitespace", () => { + expect( + databaseUrl({ [DATABASE_URL_ENV]: " postgres://db/termix " }), + ).toBe("postgres://db/termix"); + }); +}); + +describe("assertUrlMatchesDialect", () => { + it("accepts the schemes each engine answers to", () => { + expect(() => + assertUrlMatchesDialect("postgres://db/termix", "postgres"), + ).not.toThrow(); + expect(() => + assertUrlMatchesDialect("postgresql://db/termix", "postgres"), + ).not.toThrow(); + expect(() => + assertUrlMatchesDialect("mysql://db/termix", "mysql"), + ).not.toThrow(); + // MariaDB speaks the MySQL protocol. + expect(() => + assertUrlMatchesDialect("mariadb://db/termix", "mysql"), + ).not.toThrow(); + }); + + it("is case-insensitive about the scheme", () => { + expect(() => + assertUrlMatchesDialect("POSTGRES://db/termix", "postgres"), + ).not.toThrow(); + }); + + it("catches a mismatch and says what is wrong", () => { + // The failure mode this exists to prevent: a driver error thirty frames + // down that never mentions the actual misconfiguration. + expect(() => + assertUrlMatchesDialect("mysql://db/termix", "postgres"), + ).toThrow(/is a "mysql:\/\/" URL but DATABASE_DIALECT is "postgres"/); + + expect(() => + assertUrlMatchesDialect("postgres://db/termix", "mysql"), + ).toThrow(/expected one of mysql:\/\/, mariadb:\/\//i); + }); + + it("rejects sqlite, which does not use a URL", () => { + expect(() => + assertUrlMatchesDialect("postgres://db/termix", "sqlite"), + ).toThrow(/does not use DATABASE_URL/); + }); +}); + +describe("connectRemoteDatabase", () => { + it("refuses to connect without a URL, naming the variable", () => { + return expect(connectRemoteDatabase("postgres", {})).rejects.toThrow( + /DATABASE_URL must be set when DATABASE_DIALECT is "postgres"/, + ); + }); + + it("rejects a mismatched URL before opening a connection", () => { + return expect( + connectRemoteDatabase("postgres", { + [DATABASE_URL_ENV]: "mysql://db/termix", + }), + ).rejects.toThrow(/DATABASE_DIALECT is "postgres"/); + }); +}); diff --git a/src/backend/tests/database/db/migrate.test.ts b/src/backend/tests/database/db/migrate.test.ts new file mode 100644 index 00000000..d61eb7be --- /dev/null +++ b/src/backend/tests/database/db/migrate.test.ts @@ -0,0 +1,39 @@ +import path from "path"; +import { describe, expect, it } from "vitest"; +import { + migrationsFolder, + runRemoteMigrations, + MIGRATIONS_DIR_ENV, +} from "../../../database/db/migrate.js"; + +describe("migrationsFolder", () => { + it("gives each engine its own folder", () => { + // The generated SQL differs per dialect, so they cannot share one. + expect(migrationsFolder("postgres", {})).toBe( + path.resolve(process.cwd(), "drizzle", "postgres"), + ); + expect(migrationsFolder("mysql", {})).toBe( + path.resolve(process.cwd(), "drizzle", "mysql"), + ); + }); + + it("honours an explicit root", () => { + expect( + migrationsFolder("postgres", { [MIGRATIONS_DIR_ENV]: "/srv/migrations" }), + ).toBe(path.join("/srv/migrations", "postgres")); + }); + + it("ignores a blank override", () => { + expect(migrationsFolder("mysql", { [MIGRATIONS_DIR_ENV]: " " })).toBe( + path.resolve(process.cwd(), "drizzle", "mysql"), + ); + }); +}); + +describe("runRemoteMigrations", () => { + it("refuses sqlite, which builds its schema elsewhere", () => { + return expect( + runRemoteMigrations("sqlite", {} as never), + ).rejects.toThrow(/SQLite builds its schema in index.ts/); + }); +}); diff --git a/src/backend/tests/database/db/multi-dialect.test.ts b/src/backend/tests/database/db/multi-dialect.test.ts new file mode 100644 index 00000000..df103c82 --- /dev/null +++ b/src/backend/tests/database/db/multi-dialect.test.ts @@ -0,0 +1,223 @@ +import { describe, expect, it } from "vitest"; +import { eq } from "drizzle-orm"; +import { drizzle as sqliteDrizzle } from "drizzle-orm/better-sqlite3"; +import { drizzle as pgDrizzle } from "drizzle-orm/node-postgres"; +import { drizzle as mysqlDrizzle } from "drizzle-orm/mysql2"; +import { getTableConfig as sqliteTableConfig } from "drizzle-orm/sqlite-core"; +import { getTableConfig as pgTableConfig } from "drizzle-orm/pg-core"; +import { getTableConfig as mysqlTableConfig } from "drizzle-orm/mysql-core"; +import Database from "better-sqlite3"; +import * as sqliteSchema from "../../../database/db/schema.js"; +import * as pgSchema from "../../../database/db/schema.pg.js"; +import * as mysqlSchema from "../../../database/db/schema.mysql.js"; +import { + DATABASE_DIALECT_ENV, + isDatabaseDialect, + needsExplicitPersist, + resolveDatabaseDialect, +} from "../../../database/db/dialect.js"; + +describe("resolveDatabaseDialect", () => { + it("defaults to sqlite so existing deployments are unaffected", () => { + expect(resolveDatabaseDialect({})).toBe("sqlite"); + expect(resolveDatabaseDialect({ [DATABASE_DIALECT_ENV]: "" })).toBe( + "sqlite", + ); + }); + + it("accepts the supported engines, case-insensitively", () => { + expect(resolveDatabaseDialect({ [DATABASE_DIALECT_ENV]: "postgres" })).toBe( + "postgres", + ); + expect(resolveDatabaseDialect({ [DATABASE_DIALECT_ENV]: "MySQL" })).toBe( + "mysql", + ); + }); + + it("refuses an unknown engine rather than silently using sqlite", () => { + expect(() => + resolveDatabaseDialect({ [DATABASE_DIALECT_ENV]: "oracle" }), + ).toThrow(/Unsupported/); + }); + + it("narrows correctly", () => { + expect(isDatabaseDialect("mysql")).toBe(true); + expect(isDatabaseDialect("mongo")).toBe(false); + }); +}); + +describe("needsExplicitPersist", () => { + it("is true only for sqlite", () => { + // SQLite runs in memory and is serialised back to an encrypted file, so + // every write needs a flush. The others have already committed durably. + expect(needsExplicitPersist("sqlite")).toBe(true); + expect(needsExplicitPersist("postgres")).toBe(false); + expect(needsExplicitPersist("mysql")).toBe(false); + }); +}); + +/** + * schema.pg.ts and schema.mysql.ts are generated from schema.ts. These check the + * generated output is usable rather than merely syntactically valid — the + * repository layer's correctness rests on all three behaving the same way. + */ +describe("generated schemas", () => { + it("declares the same tables in all three dialects", () => { + const tablesOf = (schema: Record) => + Object.keys(schema).sort(); + + expect(tablesOf(pgSchema)).toEqual(tablesOf(sqliteSchema)); + expect(tablesOf(mysqlSchema)).toEqual(tablesOf(sqliteSchema)); + // Guard against a generator that silently emits nothing. + expect(tablesOf(sqliteSchema).length).toBeGreaterThan(40); + }); + + it("maps each column to the right storage type per dialect", () => { + expect(sqliteSchema.users.isAdmin.getSQLType()).toBe("integer"); + expect(pgSchema.users.isAdmin.getSQLType()).toBe("boolean"); + expect(mysqlSchema.users.isAdmin.getSQLType()).toBe("boolean"); + + // A primary key must be indexable, which rules out unbounded TEXT on MySQL. + expect(sqliteSchema.users.id.getSQLType()).toBe("text"); + expect(pgSchema.users.id.getSQLType()).toContain("varchar"); + expect(mysqlSchema.users.id.getSQLType()).toContain("varchar"); + }); + + it("spells the autoincrement key three different ways", () => { + expect(sqliteSchema.auditLogs.id.getSQLType()).toBe("integer"); + expect(pgSchema.auditLogs.id.getSQLType()).toBe("serial"); + expect(mysqlSchema.auditLogs.id.getSQLType()).toBe("int"); + + for (const schema of [sqliteSchema, pgSchema, mysqlSchema]) { + expect(schema.auditLogs.id.primary).toBe(true); + } + }); + + it("preserves both foreign-key behaviours", () => { + // 80 cascade + 12 set null across the schema; set null is what keeps the + // audit trail after a user is deleted (#1132). + const perDialect = [ + { schema: sqliteSchema, config: sqliteTableConfig }, + { schema: pgSchema, config: pgTableConfig }, + { schema: mysqlSchema, config: mysqlTableConfig }, + ] as const; + + for (const { schema, config } of perDialect) { + const read = config as (table: unknown) => { + foreignKeys: { onDelete?: string }[]; + }; + + const auditFks = read(schema.auditLogs).foreignKeys; + expect(auditFks).toHaveLength(1); + expect(auditFks[0].onDelete).toBe("set null"); + + const folderFks = read(schema.sshFolders).foreignKeys; + expect(folderFks.map((fk) => fk.onDelete).sort()).toEqual([ + "cascade", + "set null", + ]); + } + }); + + it("keeps nullability and uniqueness", () => { + for (const schema of [sqliteSchema, pgSchema, mysqlSchema]) { + expect(schema.auditLogs.userId.notNull).toBe(false); + expect(schema.sshFolders.userId.notNull).toBe(true); + expect(schema.sshFolders.syncId.isUnique).toBe(true); + } + }); +}); + +/** + * Queries are built, never executed, so no server is required. What matters is + * that identical repository-style code produces correct SQL for each engine. + */ +describe("query generation per dialect", () => { + const sqliteDb = sqliteDrizzle(new Database(":memory:"), { + schema: sqliteSchema, + }); + const pgDb = pgDrizzle.mock({ schema: pgSchema }); + const mysqlDb = mysqlDrizzle.mock({ schema: mysqlSchema, mode: "default" }); + + it("quotes identifiers the way each engine expects", () => { + const built = [ + sqliteDb + .select() + .from(sqliteSchema.settings) + .where(eq(sqliteSchema.settings.key, "guac_url")) + .toSQL(), + pgDb + .select() + .from(pgSchema.settings) + .where(eq(pgSchema.settings.key, "guac_url")) + .toSQL(), + mysqlDb + .select() + .from(mysqlSchema.settings) + .where(eq(mysqlSchema.settings.key, "guac_url")) + .toSQL(), + ]; + + expect(built[0].sql).toContain('"settings"'); + expect(built[1].sql).toContain('"settings"'); + expect(built[2].sql).toContain("`settings`"); + + // The value is parameterised either way, never inlined. + for (const sql of built) { + expect(sql.params).toEqual(["guac_url"]); + } + }); + + it("uses each engine's placeholder style", () => { + expect( + pgDb + .select() + .from(pgSchema.users) + .where(eq(pgSchema.users.id, "u-1")) + .toSQL().sql, + ).toContain("$1"); + + expect( + mysqlDb + .select() + .from(mysqlSchema.users) + .where(eq(mysqlSchema.users.id, "u-1")) + .toSQL().sql, + ).toContain("?"); + }); + + it("stores booleans as the type each engine expects", () => { + const row = { id: "u-1", username: "alice", passwordHash: "hash" }; + + const sqliteSql = sqliteDb + .insert(sqliteSchema.users) + .values({ ...row, isAdmin: true }) + .toSQL(); + const pgSql = pgDb + .insert(pgSchema.users) + .values({ ...row, isAdmin: true }) + .toSQL(); + + // The storage difference the generator exists to absorb. + expect(sqliteSql.params).toContain(1); + expect(pgSql.params).toContain(true); + }); + + it("round-trips on the engine that is actually wired up", () => { + const sqlite = new Database(":memory:"); + sqlite.exec( + `CREATE TABLE settings (key TEXT PRIMARY KEY, value TEXT NOT NULL);`, + ); + const db = sqliteDrizzle(sqlite, { schema: sqliteSchema }); + + db.insert(sqliteSchema.settings) + .values({ key: "guac_url", value: "guacd:4822" }) + .run(); + + expect(db.select().from(sqliteSchema.settings).all()).toEqual([ + { key: "guac_url", value: "guacd:4822" }, + ]); + + sqlite.close(); + }); +}); diff --git a/src/backend/tests/database/db/unencrypted-persistence.test.ts b/src/backend/tests/database/db/unencrypted-persistence.test.ts new file mode 100644 index 00000000..1e0a0111 --- /dev/null +++ b/src/backend/tests/database/db/unencrypted-persistence.test.ts @@ -0,0 +1,80 @@ +import fs from "fs"; +import os from "os"; +import path from "path"; +import Database from "better-sqlite3"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +/** + * `DB_FILE_ENCRYPTION=false` used to mean "start empty, every time". + * + * The database lives in memory on every backend and is serialised to disk after + * writes; the flag only decides whether that file is ciphertext. The plain + * branch wrote `db.sqlite` faithfully and then never read it back, so each + * restart began with an empty database and silently discarded everything the + * previous run had saved. The data-dir guard made it worse by confirming a + * database was present in DATA_DIR immediately before it was thrown away. + */ +describe("unencrypted database persistence", () => { + let dataDir: string; + + beforeEach(() => { + dataDir = fs.mkdtempSync(path.join(os.tmpdir(), "termix-plain-db-")); + vi.resetModules(); + process.env.DATA_DIR = dataDir; + process.env.DB_FILE_ENCRYPTION = "false"; + process.env.ALLOW_EMPTY_DATA_DIR = "true"; + }); + + afterEach(() => { + delete process.env.DATA_DIR; + delete process.env.DB_FILE_ENCRYPTION; + delete process.env.ALLOW_EMPTY_DATA_DIR; + fs.rmSync(dataDir, { recursive: true, force: true }); + }); + + /** A database file with one row, as a previous run would have left it. */ + function writeExistingDatabase(): void { + const seed = new Database(":memory:"); + seed.exec("CREATE TABLE settings (key TEXT PRIMARY KEY, value TEXT)"); + seed + .prepare("INSERT INTO settings (key, value) VALUES (?, ?)") + .run("survives_restart", "yes"); + fs.writeFileSync(path.join(dataDir, "db.sqlite"), seed.serialize()); + seed.close(); + } + + it("reads back what an earlier run wrote", async () => { + writeExistingDatabase(); + + const db = await import("../../../database/db/index.js"); + await db.initializeDatabase(); + + const row = db + .getSqlite() + .prepare("SELECT value FROM settings WHERE key = ?") + .get("survives_restart") as { value: string } | undefined; + + expect(row?.value).toBe("yes"); + }); + + it("starts empty when there is no file yet", async () => { + const db = await import("../../../database/db/index.js"); + await db.initializeDatabase(); + + // Startup creates its own tables; the point is that it does not throw on a + // missing file and does not carry rows over from nowhere. + const row = db + .getSqlite() + .prepare("SELECT COUNT(*) AS count FROM users") + .get() as { count: number }; + + expect(row.count).toBe(0); + }); + + it("ignores a zero-length file rather than failing to open it", async () => { + fs.writeFileSync(path.join(dataDir, "db.sqlite"), ""); + + const db = await import("../../../database/db/index.js"); + await expect(db.initializeDatabase()).resolves.not.toThrow(); + }); +}); diff --git a/src/backend/tests/database/repositories/alert-repository.test.ts b/src/backend/tests/database/repositories/alert-repository.test.ts index e5fefb81..8cb02c81 100644 --- a/src/backend/tests/database/repositories/alert-repository.test.ts +++ b/src/backend/tests/database/repositories/alert-repository.test.ts @@ -17,68 +17,11 @@ describe("AlertRepository", () => { ): Promise { adapter = new TestSqliteDatabase(); const context = await adapter.connect(); - context.sqlite?.exec(` - CREATE TABLE users ( - id TEXT PRIMARY KEY, - username TEXT NOT NULL, - password_hash TEXT NOT NULL - ); - - CREATE TABLE ssh_data ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - user_id TEXT NOT NULL, - name TEXT, - ip TEXT NOT NULL - ); - - CREATE TABLE alert_rules ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - user_id TEXT NOT NULL, - host_id INTEGER, - name TEXT NOT NULL, - enabled INTEGER NOT NULL DEFAULT 1, - trigger_type TEXT NOT NULL, - threshold_value REAL, - threshold_duration_seconds INTEGER, - cooldown_minutes INTEGER NOT NULL DEFAULT 15, - created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, - updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP - ); - - CREATE TABLE notification_channels ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - user_id TEXT NOT NULL, - name TEXT NOT NULL, - type TEXT NOT NULL, - config TEXT NOT NULL, - enabled INTEGER NOT NULL DEFAULT 1, - created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP - ); - - CREATE TABLE alert_rule_channels ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - rule_id INTEGER NOT NULL, - channel_id INTEGER NOT NULL - ); - - CREATE TABLE alert_firings ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - user_id TEXT NOT NULL, - rule_id INTEGER NOT NULL, - host_id INTEGER NOT NULL, - host_name TEXT NOT NULL, - fired_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, - resolved_at TEXT, - value REAL, - message TEXT NOT NULL, - severity TEXT NOT NULL DEFAULT 'warning', - acknowledged INTEGER NOT NULL DEFAULT 0 - ); - + await adapter.exec(` INSERT INTO users (id, username, password_hash) VALUES ('user-1', 'alice', 'hash'), ('user-2', 'bob', 'hash'); - INSERT INTO ssh_data (id, user_id, name, ip) - VALUES (1, 'user-1', 'alpha', '127.0.0.1'); + INSERT INTO ssh_data (id, user_id, name, ip, port, username, auth_type) + VALUES (1, 'user-1', 'alpha', '127.0.0.1', 22, 'root', 'password'); `); return new AlertRepository(context, onWrite); @@ -229,7 +172,7 @@ describe("AlertRepository", () => { expect(unacknowledged.total).toBe(0); await repo.acknowledgeAllFirings("user-1"); - repo.pruneFiringsOlderThan("user-1", 0); + await repo.pruneFiringsOlderThan("user-1", 0); }); it("loads enabled rules and notification channels for the alert engine", async () => { diff --git a/src/backend/tests/database/repositories/api-key-repository.test.ts b/src/backend/tests/database/repositories/api-key-repository.test.ts index 888460f5..c43d4bdd 100644 --- a/src/backend/tests/database/repositories/api-key-repository.test.ts +++ b/src/backend/tests/database/repositories/api-key-repository.test.ts @@ -17,28 +17,7 @@ describe("ApiKeyRepository", () => { }> { adapter = new TestSqliteDatabase(); const context = await adapter.connect(); - context.sqlite?.exec(` - CREATE TABLE users ( - id TEXT PRIMARY KEY, - username TEXT NOT NULL, - password_hash TEXT NOT NULL, - is_admin INTEGER NOT NULL DEFAULT 0, - is_oidc INTEGER NOT NULL DEFAULT 0 - ); - - CREATE TABLE api_keys ( - id TEXT PRIMARY KEY, - user_id TEXT NOT NULL, - name TEXT NOT NULL, - token_hash TEXT NOT NULL, - token_prefix TEXT NOT NULL, - created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, - expires_at TEXT, - last_used_at TEXT, - is_active INTEGER NOT NULL DEFAULT 1, - FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE - ); - + await adapter.exec(` INSERT INTO users (id, username, password_hash) VALUES ('user-1', 'admin', 'hash'), ('user-2', 'target', 'hash'); diff --git a/src/backend/tests/database/repositories/audit-log-repository.test.ts b/src/backend/tests/database/repositories/audit-log-repository.test.ts index 2f1fed7a..b177d9fe 100644 --- a/src/backend/tests/database/repositories/audit-log-repository.test.ts +++ b/src/backend/tests/database/repositories/audit-log-repository.test.ts @@ -17,31 +17,7 @@ describe("AuditLogRepository", () => { ): Promise { adapter = new TestSqliteDatabase(); const context = await adapter.connect(); - context.sqlite?.exec(` - CREATE TABLE users ( - id TEXT PRIMARY KEY, - username TEXT NOT NULL, - password_hash TEXT NOT NULL, - is_admin INTEGER NOT NULL DEFAULT 0, - is_oidc INTEGER NOT NULL DEFAULT 0 - ); - - CREATE TABLE audit_logs ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - user_id TEXT NOT NULL, - username TEXT NOT NULL, - action TEXT NOT NULL, - resource_type TEXT NOT NULL, - resource_id TEXT, - resource_name TEXT, - details TEXT, - ip_address TEXT, - user_agent TEXT, - success INTEGER NOT NULL, - error_message TEXT, - timestamp TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP - ); - + await adapter.exec(` INSERT INTO users (id, username, password_hash) VALUES ('user-1', 'alice', 'hash'), ('user-2', 'bob', 'hash'); `); @@ -129,4 +105,46 @@ describe("AuditLogRepository", () => { ).logs.map((log) => log.userId), ).toEqual(["user-2"]); }); + + it("keeps entries when their user is deleted, detaching instead of removing", async () => { + const repo = await createRepository(); + + await repo.create({ + userId: "user-1", + username: "alice", + action: "delete_host", + resourceType: "host", + resourceId: "9", + success: true, + timestamp: "2026-07-01T00:00:00.000Z", + }); + await repo.create({ + userId: "user-2", + username: "bob", + action: "create_host", + resourceType: "host", + resourceId: "8", + success: true, + timestamp: "2026-07-02T00:00:00.000Z", + }); + + expect(await repo.anonymizeByUserId("user-1")).toBe(1); + + const { logs } = await repo.listPage({ filters: {}, limit: 10, offset: 0 }); + expect(logs).toHaveLength(2); + + const detached = logs.find((log) => log.action === "delete_host"); + // The account is gone; the entry and its actor name are not. + expect(detached?.userId).toBeNull(); + expect(detached?.username).toBe("alice"); + expect(logs.find((log) => log.action === "create_host")?.userId).toBe( + "user-2", + ); + }); + + it("reports nothing to detach for a user with no entries", async () => { + const repo = await createRepository(); + + expect(await repo.anonymizeByUserId("user-2")).toBe(0); + }); }); diff --git a/src/backend/tests/database/repositories/audit-log-retention.test.ts b/src/backend/tests/database/repositories/audit-log-retention.test.ts new file mode 100644 index 00000000..e0a290c5 --- /dev/null +++ b/src/backend/tests/database/repositories/audit-log-retention.test.ts @@ -0,0 +1,161 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const logs = vi.hoisted(() => ({ info: vi.fn(), warn: vi.fn() })); + +vi.mock("../../../utils/logger.js", () => ({ + databaseLogger: logs, +})); + +const { TestSqliteDatabase } = await import("./test-support.js"); +const { + AuditLogRepository, + auditRetentionDays, + auditMaxEntries, + AUDIT_RETENTION_DAYS_ENV, + AUDIT_MAX_ENTRIES_ENV, +} = await import("../../../database/repositories/audit-log-repository.js"); + +let adapter: InstanceType | null = null; +const savedEnv: Record = {}; + +beforeEach(() => { + logs.info.mockReset(); + logs.warn.mockReset(); + for (const key of [AUDIT_RETENTION_DAYS_ENV, AUDIT_MAX_ENTRIES_ENV]) { + savedEnv[key] = process.env[key]; + delete process.env[key]; + } +}); + +afterEach(async () => { + for (const [key, value] of Object.entries(savedEnv)) { + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } + if (adapter) { + await adapter.close(); + adapter = null; + } +}); + +async function createRepository() { + adapter = new TestSqliteDatabase(); + const context = await adapter.connect(); + await adapter.exec(` + INSERT INTO users (id, username, password_hash) VALUES + ('u-1', 'u-1', 'hash'); + `); + return new AuditLogRepository(context); +} + +function daysAgo(days: number): string { + const d = new Date(Date.now() - days * 24 * 60 * 60 * 1000); + return d.toISOString().slice(0, 19).replace("T", " "); +} + +async function seed( + repo: Awaited>, + timestamp: string, + action = "create_host", +) { + await repo.create({ + userId: "u-1", + username: "alice", + action, + resourceType: "host", + success: true, + timestamp, + }); +} + +describe("audit retention configuration", () => { + it("has no time limit unless one is configured", () => { + expect(auditRetentionDays({})).toBeNull(); + expect(auditRetentionDays({ [AUDIT_RETENTION_DAYS_ENV]: "90" })).toBe(90); + }); + + it("ignores values that are not a positive count", () => { + for (const bad of ["0", "-5", "", "abc"]) { + expect( + auditRetentionDays({ [AUDIT_RETENTION_DAYS_ENV]: bad }), + ).toBeNull(); + } + }); + + it("falls back to the built-in cap", () => { + expect(auditMaxEntries({})).toBe(10000); + expect(auditMaxEntries({ [AUDIT_MAX_ENTRIES_ENV]: "250" })).toBe(250); + expect(auditMaxEntries({ [AUDIT_MAX_ENTRIES_ENV]: "-1" })).toBe(10000); + }); +}); + +describe("audit retention pruning", () => { + it("keeps everything when no retention is set", async () => { + const repo = await createRepository(); + + await seed(repo, daysAgo(400)); + await seed(repo, daysAgo(1)); + + const { total } = await repo.listPage({ + filters: {}, + limit: 10, + offset: 0, + }); + expect(total).toBe(2); + expect(logs.info).not.toHaveBeenCalled(); + }); + + it("drops entries past the retention window and says so", async () => { + process.env[AUDIT_RETENTION_DAYS_ENV] = "30"; + const repo = await createRepository(); + + await seed(repo, daysAgo(90), "old_action"); + await seed(repo, daysAgo(5), "recent_action"); + + const { logs: rows } = await repo.listPage({ + filters: {}, + limit: 10, + offset: 0, + }); + expect(rows.map((r) => r.action)).toEqual(["recent_action"]); + + expect(logs.info).toHaveBeenCalledWith( + expect.stringContaining("past retention"), + expect.objectContaining({ operation: "audit_retention_prune" }), + ); + }); + + it("warns when the row cap discards entries still inside the window", async () => { + process.env[AUDIT_MAX_ENTRIES_ENV] = "5"; + const repo = await createRepository(); + + for (let i = 0; i < 6; i++) { + await seed(repo, daysAgo(10 - i), `action_${i}`); + } + + // The cap is not a retention policy: these entries were still current. + expect(logs.warn).toHaveBeenCalledWith( + expect.stringContaining("cap"), + expect.objectContaining({ + operation: "audit_overflow_prune", + maxEntries: 5, + }), + ); + + const { total } = await repo.listPage({ + filters: {}, + limit: 20, + offset: 0, + }); + expect(total).toBeLessThan(6); + }); + + it("stays quiet while under the cap", async () => { + process.env[AUDIT_MAX_ENTRIES_ENV] = "100"; + const repo = await createRepository(); + + await seed(repo, daysAgo(1)); + + expect(logs.warn).not.toHaveBeenCalled(); + }); +}); diff --git a/src/backend/tests/database/repositories/c2s-tunnel-preset-repository.test.ts b/src/backend/tests/database/repositories/c2s-tunnel-preset-repository.test.ts index 0e3196d7..5922ca4f 100644 --- a/src/backend/tests/database/repositories/c2s-tunnel-preset-repository.test.ts +++ b/src/backend/tests/database/repositories/c2s-tunnel-preset-repository.test.ts @@ -17,24 +17,7 @@ describe("C2sTunnelPresetRepository", () => { ): Promise { adapter = new TestSqliteDatabase(); const context = await adapter.connect(); - context.sqlite?.exec(` - CREATE TABLE users ( - id TEXT PRIMARY KEY, - username TEXT NOT NULL, - password_hash TEXT NOT NULL - ); - - CREATE TABLE c2s_tunnel_presets ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - user_id TEXT NOT NULL, - name TEXT NOT NULL, - config TEXT NOT NULL, - platform TEXT, - computer_name TEXT, - created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, - updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP - ); - + await adapter.exec(` INSERT INTO users (id, username, password_hash) VALUES ('user-1', 'alice', 'hash'), ('user-2', 'bob', 'hash'); `); diff --git a/src/backend/tests/database/repositories/command-history-repository.test.ts b/src/backend/tests/database/repositories/command-history-repository.test.ts index ea58d029..83e821a9 100644 --- a/src/backend/tests/database/repositories/command-history-repository.test.ts +++ b/src/backend/tests/database/repositories/command-history-repository.test.ts @@ -17,33 +17,11 @@ describe("CommandHistoryRepository", () => { ): Promise { adapter = new TestSqliteDatabase(); const context = await adapter.connect(); - context.sqlite?.exec(` - CREATE TABLE users ( - id TEXT PRIMARY KEY, - username TEXT NOT NULL, - password_hash TEXT NOT NULL, - is_admin INTEGER NOT NULL DEFAULT 0, - is_oidc INTEGER NOT NULL DEFAULT 0 - ); - - CREATE TABLE hosts ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - user_id TEXT NOT NULL, - name TEXT NOT NULL - ); - - CREATE TABLE command_history ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - user_id TEXT NOT NULL, - host_id INTEGER NOT NULL, - command TEXT NOT NULL, - executed_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP - ); - + await adapter.exec(` INSERT INTO users (id, username, password_hash) VALUES ('user-1', 'alice', 'hash'), ('user-2', 'bob', 'hash'); - INSERT INTO hosts (id, user_id, name) - VALUES (1, 'user-1', 'one'), (2, 'user-1', 'two'), (3, 'user-2', 'other'); + INSERT INTO ssh_data (id, user_id, name, ip, port, username, auth_type) + VALUES (1, 'user-1', 'one', '10.0.0.1', 22, 'root', 'password'), (2, 'user-1', 'two', '10.0.0.1', 22, 'root', 'password'), (3, 'user-2', 'other', '10.0.0.1', 22, 'root', 'password'); `); return new CommandHistoryRepository(context, onWrite); diff --git a/src/backend/tests/database/repositories/dashboard-service-link-repository.test.ts b/src/backend/tests/database/repositories/dashboard-service-link-repository.test.ts index a68bb268..4f3c47ce 100644 --- a/src/backend/tests/database/repositories/dashboard-service-link-repository.test.ts +++ b/src/backend/tests/database/repositories/dashboard-service-link-repository.test.ts @@ -17,26 +17,7 @@ describe("DashboardServiceLinkRepository", () => { ): Promise { adapter = new TestSqliteDatabase(); const context = await adapter.connect(); - context.sqlite?.exec(` - CREATE TABLE users ( - id TEXT PRIMARY KEY, - username TEXT NOT NULL, - password_hash TEXT NOT NULL, - is_admin INTEGER NOT NULL DEFAULT 0, - is_oidc INTEGER NOT NULL DEFAULT 0 - ); - - CREATE TABLE dashboard_service_links ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - user_id TEXT NOT NULL, - label TEXT NOT NULL, - url TEXT NOT NULL, - "order" INTEGER NOT NULL DEFAULT 0, - sync_id TEXT, - created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, - updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP - ); - + await adapter.exec(` INSERT INTO users (id, username, password_hash) VALUES ('user-1', 'alice', 'hash'), ('user-2', 'bob', 'hash'); `); diff --git a/src/backend/tests/database/repositories/dismissed-alert-repository.test.ts b/src/backend/tests/database/repositories/dismissed-alert-repository.test.ts index 9cbff302..abc488bd 100644 --- a/src/backend/tests/database/repositories/dismissed-alert-repository.test.ts +++ b/src/backend/tests/database/repositories/dismissed-alert-repository.test.ts @@ -17,22 +17,7 @@ describe("DismissedAlertRepository", () => { ): Promise { adapter = new TestSqliteDatabase(); const context = await adapter.connect(); - context.sqlite?.exec(` - CREATE TABLE users ( - id TEXT PRIMARY KEY, - username TEXT NOT NULL, - password_hash TEXT NOT NULL, - is_admin INTEGER NOT NULL DEFAULT 0, - is_oidc INTEGER NOT NULL DEFAULT 0 - ); - - CREATE TABLE dismissed_alerts ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - user_id TEXT NOT NULL, - alert_id TEXT NOT NULL, - dismissed_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP - ); - + await adapter.exec(` INSERT INTO users (id, username, password_hash) VALUES ('user-1', 'alice', 'hash'), ('user-2', 'bob', 'hash'); `); diff --git a/src/backend/tests/database/repositories/factory-context.test.ts b/src/backend/tests/database/repositories/factory-context.test.ts new file mode 100644 index 00000000..5f9fe8d8 --- /dev/null +++ b/src/backend/tests/database/repositories/factory-context.test.ts @@ -0,0 +1,66 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { DATABASE_DIALECT_ENV } from "../../../database/db/dialect.js"; + +// getDb() throws unless a database was initialized; the context's handle is +// not what this file is about. +vi.mock("../../../database/db/index.js", () => ({ + getDb: () => ({}), + getSqlite: () => ({}), + DatabaseSaveTrigger: { forceSave: vi.fn() }, +})); + +const { createCurrentRepositoryContext, createCurrentRepositoryWriteHook } = + await import("../../../database/repositories/factory.js"); + +// Neither cross-dialect harness reaches this function: both +// tests/database/repositories/test-support.ts and scripts/verify-dialects.mjs +// construct a DatabaseContext of their own. That is why the production path +// could report "sqlite" while connected to MySQL with CI green on all three +// engines, and why this asserts on the real factory rather than a fixture. +describe("createCurrentRepositoryContext", () => { + const saved = process.env[DATABASE_DIALECT_ENV]; + + beforeEach(() => { + delete process.env[DATABASE_DIALECT_ENV]; + }); + + afterEach(() => { + if (saved === undefined) delete process.env[DATABASE_DIALECT_ENV]; + else process.env[DATABASE_DIALECT_ENV] = saved; + }); + + it("defaults to sqlite when nothing is configured", () => { + expect(createCurrentRepositoryContext().dialect).toBe("sqlite"); + }); + + it("reports the configured dialect", () => { + for (const dialect of ["sqlite", "postgres", "mysql"]) { + process.env[DATABASE_DIALECT_ENV] = dialect; + expect(createCurrentRepositoryContext().dialect).toBe(dialect); + } + }); + + it("rejects an unsupported dialect rather than falling back to sqlite", () => { + process.env[DATABASE_DIALECT_ENV] = "oracle"; + expect(() => createCurrentRepositoryContext()).toThrow(/oracle/); + }); +}); + +describe("createCurrentRepositoryWriteHook", () => { + const saved = process.env[DATABASE_DIALECT_ENV]; + + afterEach(() => { + if (saved === undefined) delete process.env[DATABASE_DIALECT_ENV]; + else process.env[DATABASE_DIALECT_ENV] = saved; + }); + + it("installs a persist hook only for sqlite", () => { + process.env[DATABASE_DIALECT_ENV] = "sqlite"; + expect(createCurrentRepositoryWriteHook("test")).toBeTypeOf("function"); + + for (const dialect of ["postgres", "mysql"]) { + process.env[DATABASE_DIALECT_ENV] = dialect; + expect(createCurrentRepositoryWriteHook("test")).toBeUndefined(); + } + }); +}); diff --git a/src/backend/tests/database/repositories/field-encryption-boundary.test.ts b/src/backend/tests/database/repositories/field-encryption-boundary.test.ts deleted file mode 100644 index a7dd89d7..00000000 --- a/src/backend/tests/database/repositories/field-encryption-boundary.test.ts +++ /dev/null @@ -1,103 +0,0 @@ -import crypto from "crypto"; -import { describe, expect, it } from "vitest"; -import { FieldEncryptionBoundary } from "../../../database/repositories/field-encryption-boundary.js"; - -describe("FieldEncryptionBoundary", () => { - const userDataKey = crypto.randomBytes(32); - - it("encrypts sensitive host fields while leaving queryable metadata plaintext", () => { - const host = { - id: 42, - userId: "user-1", - name: "prod-db", - ip: "10.0.0.5", - username: "root", - password: "secret", - rdpPassword: "rdp-secret", - }; - - const encrypted = FieldEncryptionBoundary.encryptRecord( - "ssh_data", - host, - userDataKey, - ); - - expect(encrypted.password).not.toBe("secret"); - expect(encrypted.rdpPassword).not.toBe("rdp-secret"); - expect(encrypted.ip).toBe("10.0.0.5"); - expect(encrypted.name).toBe("prod-db"); - - const decrypted = FieldEncryptionBoundary.decryptRecord( - "ssh_data", - encrypted, - userDataKey, - ); - expect(decrypted).toMatchObject(host); - }); - - it("encrypts credential secret fields and keeps metadata plaintext", () => { - const credential = { - id: 7, - userId: "user-1", - name: "primary credential", - authType: "key", - key: "private-key-material", - keyPassword: "key-password", - }; - - const encrypted = FieldEncryptionBoundary.encryptRecord( - "ssh_credentials", - credential, - userDataKey, - ); - - expect(encrypted.key).not.toBe("private-key-material"); - expect(encrypted.keyPassword).not.toBe("key-password"); - expect(encrypted.name).toBe("primary credential"); - - expect( - FieldEncryptionBoundary.decryptRecord( - "ssh_credentials", - encrypted, - userDataKey, - ), - ).toMatchObject(credential); - }); - - it("keeps empty and non-string sensitive values unchanged", () => { - const encrypted = FieldEncryptionBoundary.encryptRecord( - "ssh_data", - { - id: 1, - password: "", - key: null, - }, - userDataKey, - ); - - expect(encrypted.password).toBe(""); - expect(encrypted.key).toBeNull(); - }); - - it("requires a stable record id instead of inventing a temporary encryption context", () => { - expect(() => - FieldEncryptionBoundary.encryptRecord( - "ssh_data", - { password: "secret" }, - userDataKey, - ), - ).toThrow(/stable record id/); - }); - - it("classifies sensitive, plaintext, and unknown fields", () => { - expect(FieldEncryptionBoundary.classifyField("ssh_data", "password")).toBe( - "sensitive", - ); - expect(FieldEncryptionBoundary.classifyField("ssh_data", "ip")).toBe( - "plaintext", - ); - expect(FieldEncryptionBoundary.classifyField("ssh_data", "newField")).toBe( - "unknown", - ); - }); -}); diff --git a/src/backend/tests/database/repositories/file-manager-bookmark-repository.test.ts b/src/backend/tests/database/repositories/file-manager-bookmark-repository.test.ts index 5ae4405b..71cf3df0 100644 --- a/src/backend/tests/database/repositories/file-manager-bookmark-repository.test.ts +++ b/src/backend/tests/database/repositories/file-manager-bookmark-repository.test.ts @@ -17,50 +17,11 @@ describe("FileManagerBookmarkRepository", () => { ): Promise { adapter = new TestSqliteDatabase(); const context = await adapter.connect(); - context.sqlite?.exec(` - CREATE TABLE users ( - id TEXT PRIMARY KEY, - username TEXT NOT NULL, - password_hash TEXT NOT NULL - ); - - CREATE TABLE hosts ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - user_id TEXT NOT NULL, - name TEXT NOT NULL - ); - - CREATE TABLE file_manager_recent ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - user_id TEXT NOT NULL, - host_id INTEGER NOT NULL, - name TEXT NOT NULL, - path TEXT NOT NULL, - last_opened TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP - ); - - CREATE TABLE file_manager_pinned ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - user_id TEXT NOT NULL, - host_id INTEGER NOT NULL, - name TEXT NOT NULL, - path TEXT NOT NULL, - pinned_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP - ); - - CREATE TABLE file_manager_shortcuts ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - user_id TEXT NOT NULL, - host_id INTEGER NOT NULL, - name TEXT NOT NULL, - path TEXT NOT NULL, - created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP - ); - + await adapter.exec(` INSERT INTO users (id, username, password_hash) VALUES ('user-1', 'alice', 'hash'), ('user-2', 'bob', 'hash'); - INSERT INTO hosts (id, user_id, name) - VALUES (1, 'user-1', 'one'), (2, 'user-1', 'two'), (3, 'user-2', 'other'); + INSERT INTO ssh_data (id, user_id, name, ip, port, username, auth_type) + VALUES (1, 'user-1', 'one', '10.0.0.1', 22, 'root', 'password'), (2, 'user-1', 'two', '10.0.0.1', 22, 'root', 'password'), (3, 'user-2', 'other', '10.0.0.1', 22, 'root', 'password'); `); return new FileManagerBookmarkRepository(context, onWrite); diff --git a/src/backend/tests/database/repositories/homepage-item-repository.test.ts b/src/backend/tests/database/repositories/homepage-item-repository.test.ts index 3ea1525e..a81f7eaf 100644 --- a/src/backend/tests/database/repositories/homepage-item-repository.test.ts +++ b/src/backend/tests/database/repositories/homepage-item-repository.test.ts @@ -17,25 +17,7 @@ describe("HomepageItemRepository", () => { ): Promise { adapter = new TestSqliteDatabase(); const context = await adapter.connect(); - context.sqlite?.exec(` - CREATE TABLE users ( - id TEXT PRIMARY KEY, - username TEXT NOT NULL, - password_hash TEXT NOT NULL - ); - - CREATE TABLE homepage_items ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - user_id TEXT NOT NULL, - type_id TEXT NOT NULL, - title TEXT, - config TEXT NOT NULL DEFAULT '{}', - folder_id INTEGER, - sync_id TEXT, - created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, - updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP - ); - + await adapter.exec(` INSERT INTO users (id, username, password_hash) VALUES ('user-1', 'alice', 'hash'), ('user-2', 'bob', 'hash'); `); diff --git a/src/backend/tests/database/repositories/homepage-layout-repository.test.ts b/src/backend/tests/database/repositories/homepage-layout-repository.test.ts index 479ae067..56bd3b76 100644 --- a/src/backend/tests/database/repositories/homepage-layout-repository.test.ts +++ b/src/backend/tests/database/repositories/homepage-layout-repository.test.ts @@ -17,22 +17,7 @@ describe("HomepageLayoutRepository", () => { ): Promise { adapter = new TestSqliteDatabase(); const context = await adapter.connect(); - context.sqlite?.exec(` - CREATE TABLE users ( - id TEXT PRIMARY KEY, - username TEXT NOT NULL, - password_hash TEXT NOT NULL, - is_admin INTEGER NOT NULL DEFAULT 0, - is_oidc INTEGER NOT NULL DEFAULT 0 - ); - - CREATE TABLE homepage_layouts ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - user_id TEXT NOT NULL UNIQUE, - layout TEXT NOT NULL DEFAULT '{}', - updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP - ); - + await adapter.exec(` INSERT INTO users (id, username, password_hash) VALUES ('user-1', 'alice', 'hash'), ('user-2', 'bob', 'hash'); `); diff --git a/src/backend/tests/database/repositories/host-credential-repositories.test.ts b/src/backend/tests/database/repositories/host-credential-repositories.test.ts index e97dce23..866f87a3 100644 --- a/src/backend/tests/database/repositories/host-credential-repositories.test.ts +++ b/src/backend/tests/database/repositories/host-credential-repositories.test.ts @@ -1,3 +1,4 @@ +import { sql } from "drizzle-orm"; import { afterEach, describe, expect, it, vi } from "vitest"; import { TestSqliteDatabase } from "./test-support.js"; import { CredentialRepository } from "../../../database/repositories/credential-repository.js"; @@ -21,174 +22,10 @@ describe("HostRepository and CredentialRepository", () => { ): Promise<{ credentials: CredentialRepository; hosts: HostRepository; - sqlite: NonNullable< - Awaited>["sqlite"] - >; }> { adapter = new TestSqliteDatabase(); const context = await adapter.connect(); - context.sqlite?.exec(` - CREATE TABLE users ( - id TEXT PRIMARY KEY, - username TEXT NOT NULL, - password_hash TEXT NOT NULL, - is_admin INTEGER NOT NULL DEFAULT 0, - is_oidc INTEGER NOT NULL DEFAULT 0 - ); - - CREATE TABLE ssh_credentials ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - user_id TEXT NOT NULL, - name TEXT NOT NULL, - description TEXT, - folder TEXT, - tags TEXT, - auth_type TEXT NOT NULL, - username TEXT, - password TEXT, - key TEXT, - private_key TEXT, - public_key TEXT, - key_password TEXT, - key_type TEXT, - detected_key_type TEXT, - cert_public_key TEXT, - usage_count INTEGER NOT NULL DEFAULT 0, - last_used TEXT, - sync_id TEXT, - created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, - updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, - FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE - ); - - CREATE TABLE ssh_data ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - user_id TEXT NOT NULL, - connection_type TEXT NOT NULL DEFAULT 'ssh', - name TEXT, - ip TEXT NOT NULL, - port INTEGER NOT NULL, - username TEXT NOT NULL, - folder TEXT, - tags TEXT, - pin INTEGER NOT NULL DEFAULT 0, - auth_type TEXT NOT NULL, - use_warpgate INTEGER NOT NULL DEFAULT 0, - force_keyboard_interactive TEXT, - password TEXT, - key TEXT, - key_password TEXT, - key_type TEXT, - sudo_password TEXT, - autostart_password TEXT, - autostart_key TEXT, - autostart_key_password TEXT, - credential_id INTEGER, - override_credential_username INTEGER, - vault_profile_id INTEGER, - enable_terminal INTEGER NOT NULL DEFAULT 1, - enable_session_logging INTEGER NOT NULL DEFAULT 1, - allow_session_sharing INTEGER NOT NULL DEFAULT 1, - enable_command_history INTEGER NOT NULL DEFAULT 1, - enable_tunnel INTEGER NOT NULL DEFAULT 1, - tunnel_connections TEXT, - jump_hosts TEXT, - enable_file_manager INTEGER NOT NULL DEFAULT 1, - scp_legacy INTEGER NOT NULL DEFAULT 0, - enable_docker INTEGER NOT NULL DEFAULT 0, - enable_tmux_monitor INTEGER NOT NULL DEFAULT 0, - show_terminal_in_sidebar INTEGER NOT NULL DEFAULT 1, - show_file_manager_in_sidebar INTEGER NOT NULL DEFAULT 0, - show_tunnel_in_sidebar INTEGER NOT NULL DEFAULT 0, - show_docker_in_sidebar INTEGER NOT NULL DEFAULT 0, - show_server_stats_in_sidebar INTEGER NOT NULL DEFAULT 0, - default_path TEXT, - stats_config TEXT, - docker_config TEXT, - enable_proxmox INTEGER NOT NULL DEFAULT 0, - proxmox_config TEXT, - terminal_config TEXT, - quick_actions TEXT, - notes TEXT, - enable_ssh INTEGER NOT NULL DEFAULT 1, - enable_rdp INTEGER NOT NULL DEFAULT 0, - enable_vnc INTEGER NOT NULL DEFAULT 0, - enable_telnet INTEGER NOT NULL DEFAULT 0, - ssh_port INTEGER DEFAULT 22, - rdp_port INTEGER DEFAULT 3389, - vnc_port INTEGER DEFAULT 5900, - telnet_port INTEGER DEFAULT 23, - rdp_credential_id INTEGER, - rdp_user TEXT, - rdp_password TEXT, - rdp_domain TEXT, - rdp_security TEXT, - rdp_ignore_cert INTEGER DEFAULT 0, - vnc_credential_id INTEGER, - vnc_password TEXT, - vnc_user TEXT, - telnet_user TEXT, - telnet_password TEXT, - telnet_credential_id INTEGER, - rdp_auth_type TEXT, - vnc_auth_type TEXT, - telnet_auth_type TEXT, - domain TEXT, - security TEXT, - ignore_cert INTEGER DEFAULT 0, - guacamole_config TEXT, - use_socks5 INTEGER, - socks5_host TEXT, - socks5_port INTEGER, - socks5_username TEXT, - socks5_password TEXT, - socks5_proxy_chain TEXT, - mac_address TEXT, - wol_broadcast_address TEXT, - port_knock_sequence TEXT, - host_key_fingerprint TEXT, - host_key_type TEXT, - host_key_algorithm TEXT DEFAULT 'sha256', - host_key_first_seen TEXT, - host_key_last_verified TEXT, - host_key_changed_count INTEGER DEFAULT 0, - connection_origin TEXT, - sync_id TEXT, - created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, - updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, - FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, - FOREIGN KEY (credential_id) REFERENCES ssh_credentials(id) ON DELETE SET NULL - ); - - CREATE TABLE host_access ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - host_id INTEGER NOT NULL, - user_id TEXT, - role_id INTEGER, - granted_by TEXT NOT NULL, - permission_level TEXT NOT NULL DEFAULT 'view', - expires_at TEXT, - created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, - last_accessed_at TEXT, - access_count INTEGER NOT NULL DEFAULT 0, - override_credential_id INTEGER, - FOREIGN KEY (host_id) REFERENCES ssh_data(id) ON DELETE CASCADE, - FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, - FOREIGN KEY (granted_by) REFERENCES users(id) ON DELETE CASCADE, - FOREIGN KEY (override_credential_id) REFERENCES ssh_credentials(id) ON DELETE SET NULL - ); - - CREATE TABLE ssh_credential_usage ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - credential_id INTEGER NOT NULL, - host_id INTEGER NOT NULL, - user_id TEXT NOT NULL, - used_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, - FOREIGN KEY (credential_id) REFERENCES ssh_credentials(id) ON DELETE CASCADE, - FOREIGN KEY (host_id) REFERENCES ssh_data(id) ON DELETE CASCADE, - FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE - ); - + await adapter.exec(` INSERT INTO users (id, username, password_hash) VALUES ('user-1', 'user', 'hash'), ('user-2', 'other', 'hash'); @@ -197,7 +34,6 @@ describe("HostRepository and CredentialRepository", () => { return { credentials: new CredentialRepository(context, onCredentialWrite), hosts: new HostRepository(context, onHostWrite), - sqlite: context.sqlite!, }; } @@ -224,9 +60,9 @@ describe("HostRepository and CredentialRepository", () => { // deterministically observable regardless of clock resolution -- // the sync engine's last-write-wins conflict resolution depends on // every mutating update actually advancing this column. - repo.sqlite - .prepare("UPDATE ssh_credentials SET updated_at = ? WHERE id = ?") - .run("2000-01-01 00:00:00", created.id); + await adapter!.run( + sql`UPDATE ssh_credentials SET updated_at = ${"2000-01-01 00:00:00"} WHERE id = ${created.id}`, + ); const updated = await repo.credentials.updateForUser("user-1", created.id, { folder: "ops", @@ -342,23 +178,27 @@ describe("HostRepository and CredentialRepository", () => { password: "secret", }); - const raw = repo.sqlite - .prepare("SELECT password FROM ssh_credentials WHERE id = ?") - .get(created.id) as { password: string }; + const raw = ( + await adapter!.query( + sql`SELECT password FROM ssh_credentials WHERE id = ${created.id}`, + ) + )[0] as { password: string }; expect(raw.password).toBe("user-encrypted-password"); - repo.sqlite - .prepare("UPDATE ssh_credentials SET updated_at = ? WHERE id = ?") - .run("2000-01-01 00:00:00", created.id); + await adapter!.run( + sql`UPDATE ssh_credentials SET updated_at = ${"2000-01-01 00:00:00"} WHERE id = ${created.id}`, + ); await repo.credentials.updateEncryptedForUser("user-1", created.id, { password: "updated-secret", }); - const updatedRaw = repo.sqlite - .prepare("SELECT password, updated_at FROM ssh_credentials WHERE id = ?") - .get(created.id) as { password: string; updated_at: string }; + const updatedRaw = ( + await adapter!.query( + sql`SELECT password, updated_at FROM ssh_credentials WHERE id = ${created.id}`, + ) + )[0] as { password: string; updated_at: string }; expect(updatedRaw.password).toBe("user-encrypted-password"); expect(updatedRaw.updated_at).not.toBe("2000-01-01 00:00:00"); @@ -410,9 +250,9 @@ describe("HostRepository and CredentialRepository", () => { authType: "password", folder: "prod", }); - repo.sqlite - .prepare("UPDATE ssh_credentials SET updated_at = ? WHERE id = ?") - .run("2000-01-01 00:00:00", primary.id); + await adapter!.run( + sql`UPDATE ssh_credentials SET updated_at = ${"2000-01-01 00:00:00"} WHERE id = ${primary.id}`, + ); onWrite.mockClear(); await expect( @@ -423,9 +263,11 @@ describe("HostRepository and CredentialRepository", () => { expect(await repo.credentials.listFolders("user-2")).toEqual(["prod"]); expect(onWrite).toHaveBeenCalledTimes(1); - const renamedRow = repo.sqlite - .prepare("SELECT updated_at FROM ssh_credentials WHERE id = ?") - .get(primary.id) as { updated_at: string }; + const renamedRow = ( + await adapter!.query( + sql`SELECT updated_at FROM ssh_credentials WHERE id = ${primary.id}`, + ) + )[0] as { updated_at: string }; expect(renamedRow.updated_at).not.toBe("2000-01-01 00:00:00"); }); @@ -467,9 +309,9 @@ describe("HostRepository and CredentialRepository", () => { (await repo.hosts.listByUserId("user-1")).map((item) => item.id), ).toEqual([host.id]); - repo.sqlite - .prepare("UPDATE ssh_data SET updated_at = ? WHERE id = ?") - .run("2000-01-01 00:00:00", host.id); + await adapter!.run( + sql`UPDATE ssh_data SET updated_at = ${"2000-01-01 00:00:00"} WHERE id = ${host.id}`, + ); const updated = await repo.hosts.updateForUser("user-1", host.id, { name: "web-1-renamed", @@ -511,23 +353,27 @@ describe("HostRepository and CredentialRepository", () => { password: "secret", }); - const raw = repo.sqlite - .prepare("SELECT password FROM ssh_data WHERE id = ?") - .get(created.id) as { password: string }; + const raw = ( + await adapter!.query( + sql`SELECT password FROM ssh_data WHERE id = ${created.id}`, + ) + )[0] as { password: string }; expect(raw.password).toBe("encrypted-host-password"); - repo.sqlite - .prepare("UPDATE ssh_data SET updated_at = ? WHERE id = ?") - .run("2000-01-01 00:00:00", created.id); + await adapter!.run( + sql`UPDATE ssh_data SET updated_at = ${"2000-01-01 00:00:00"} WHERE id = ${created.id}`, + ); await repo.hosts.updateEncryptedForUser("user-1", created.id, { password: "updated-secret", }); - const updatedRaw = repo.sqlite - .prepare("SELECT password, updated_at FROM ssh_data WHERE id = ?") - .get(created.id) as { password: string; updated_at: string }; + const updatedRaw = ( + await adapter!.query( + sql`SELECT password, updated_at FROM ssh_data WHERE id = ${created.id}`, + ) + )[0] as { password: string; updated_at: string }; expect(updatedRaw.password).toBe("encrypted-host-password"); expect(updatedRaw.updated_at).not.toBe("2000-01-01 00:00:00"); @@ -655,9 +501,9 @@ describe("HostRepository and CredentialRepository", () => { username: "root", authType: "password", }); - repo.sqlite - .prepare("UPDATE ssh_data SET updated_at = ? WHERE id IN (?, ?)") - .run("2000-01-01 00:00:00", first.id, second.id); + await adapter!.run( + sql`UPDATE ssh_data SET updated_at = ${"2000-01-01 00:00:00"} WHERE id IN (${first.id}, ${second.id})`, + ); onWrite.mockClear(); const states = await repo.hosts.listBulkUpdateState("user-1", [ @@ -726,11 +572,9 @@ describe("HostRepository and CredentialRepository", () => { authType: "password", }); - repo.sqlite - .prepare( - "INSERT INTO host_access (host_id, user_id, granted_by) VALUES (?, ?, ?)", - ) - .run(host.id, "user-2", "user-1"); + await adapter!.run( + sql`INSERT INTO host_access (host_id, user_id, granted_by) VALUES (${host.id}, ${"user-2"}, ${"user-1"})`, + ); expect(await repo.hosts.deleteAccessForHost(host.id)).toBe(1); expect(await repo.hosts.deleteForUser("user-1", host.id)).toEqual({ diff --git a/src/backend/tests/database/repositories/host-folder-repository.test.ts b/src/backend/tests/database/repositories/host-folder-repository.test.ts index 7b94bea4..f156d20d 100644 --- a/src/backend/tests/database/repositories/host-folder-repository.test.ts +++ b/src/backend/tests/database/repositories/host-folder-repository.test.ts @@ -1,3 +1,4 @@ +import { sql } from "drizzle-orm"; import { afterEach, describe, expect, it } from "vitest"; import { TestSqliteDatabase } from "./test-support.js"; import { HostFolderRepository } from "../../../database/repositories/host-folder-repository.js"; @@ -14,153 +15,21 @@ describe("HostFolderRepository", () => { async function createRepository( onWrite?: () => void | Promise, - ): Promise<{ - repository: HostFolderRepository; - sqlite: NonNullable< - Awaited>["sqlite"] - >; - }> { + ): Promise<{ repository: HostFolderRepository }> { adapter = new TestSqliteDatabase(); const context = await adapter.connect(); - context.sqlite?.exec(` - CREATE TABLE users ( - id TEXT PRIMARY KEY, - username TEXT NOT NULL, - password_hash TEXT NOT NULL - ); - - CREATE TABLE ssh_credentials ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - user_id TEXT NOT NULL, - name TEXT NOT NULL, - folder TEXT, - auth_type TEXT NOT NULL, - sync_id TEXT, - created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, - updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP - ); - - CREATE TABLE ssh_data ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - user_id TEXT NOT NULL, - connection_type TEXT NOT NULL DEFAULT 'ssh', - name TEXT, - ip TEXT NOT NULL, - port INTEGER NOT NULL, - username TEXT NOT NULL, - folder TEXT, - tags TEXT, - pin INTEGER NOT NULL DEFAULT 0, - auth_type TEXT NOT NULL, - use_warpgate INTEGER NOT NULL DEFAULT 0, - force_keyboard_interactive TEXT, - password TEXT, - key TEXT, - key_password TEXT, - key_type TEXT, - sudo_password TEXT, - autostart_password TEXT, - autostart_key TEXT, - autostart_key_password TEXT, - credential_id INTEGER, - override_credential_username INTEGER, - vault_profile_id INTEGER, - enable_terminal INTEGER NOT NULL DEFAULT 1, - enable_session_logging INTEGER NOT NULL DEFAULT 1, - allow_session_sharing INTEGER NOT NULL DEFAULT 1, - enable_command_history INTEGER NOT NULL DEFAULT 1, - enable_tunnel INTEGER NOT NULL DEFAULT 1, - tunnel_connections TEXT, - jump_hosts TEXT, - enable_file_manager INTEGER NOT NULL DEFAULT 1, - scp_legacy INTEGER NOT NULL DEFAULT 0, - enable_docker INTEGER NOT NULL DEFAULT 0, - enable_tmux_monitor INTEGER NOT NULL DEFAULT 0, - show_terminal_in_sidebar INTEGER NOT NULL DEFAULT 1, - show_file_manager_in_sidebar INTEGER NOT NULL DEFAULT 0, - show_tunnel_in_sidebar INTEGER NOT NULL DEFAULT 0, - show_docker_in_sidebar INTEGER NOT NULL DEFAULT 0, - show_server_stats_in_sidebar INTEGER NOT NULL DEFAULT 0, - default_path TEXT, - stats_config TEXT, - docker_config TEXT, - enable_proxmox INTEGER NOT NULL DEFAULT 0, - proxmox_config TEXT, - terminal_config TEXT, - quick_actions TEXT, - notes TEXT, - enable_ssh INTEGER NOT NULL DEFAULT 1, - enable_rdp INTEGER NOT NULL DEFAULT 0, - enable_vnc INTEGER NOT NULL DEFAULT 0, - enable_telnet INTEGER NOT NULL DEFAULT 0, - ssh_port INTEGER DEFAULT 22, - rdp_port INTEGER DEFAULT 3389, - vnc_port INTEGER DEFAULT 5900, - telnet_port INTEGER DEFAULT 23, - rdp_credential_id INTEGER, - rdp_user TEXT, - rdp_password TEXT, - rdp_domain TEXT, - rdp_security TEXT, - rdp_ignore_cert INTEGER DEFAULT 0, - vnc_credential_id INTEGER, - vnc_password TEXT, - vnc_user TEXT, - telnet_user TEXT, - telnet_password TEXT, - telnet_credential_id INTEGER, - rdp_auth_type TEXT, - vnc_auth_type TEXT, - telnet_auth_type TEXT, - domain TEXT, - security TEXT, - ignore_cert INTEGER DEFAULT 0, - guacamole_config TEXT, - use_socks5 INTEGER, - socks5_host TEXT, - socks5_port INTEGER, - socks5_username TEXT, - socks5_password TEXT, - socks5_proxy_chain TEXT, - mac_address TEXT, - wol_broadcast_address TEXT, - port_knock_sequence TEXT, - host_key_fingerprint TEXT, - host_key_type TEXT, - host_key_algorithm TEXT DEFAULT 'sha256', - host_key_first_seen TEXT, - host_key_last_verified TEXT, - host_key_changed_count INTEGER DEFAULT 0, - connection_origin TEXT, - sync_id TEXT, - created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, - updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP - ); - - CREATE TABLE ssh_folders ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - user_id TEXT NOT NULL, - name TEXT NOT NULL, - color TEXT, - icon TEXT, - credential_id INTEGER, - sync_id TEXT, - created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, - updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP - ); - + await adapter.exec(` INSERT INTO users (id, username, password_hash) VALUES ('user-1', 'alice', 'hash'), ('user-2', 'bob', 'hash'); + INSERT INTO ssh_credentials (id, user_id, name, folder, auth_type, username) + VALUES (1, 'user-1', 'cred-one', 'prod', 'password', 'root'), + (2, 'user-1', 'cred-two', 'prod / api', 'password', 'root'), + (3, 'user-2', 'cred-other', 'prod', 'password', 'root'); INSERT INTO ssh_data (id, user_id, name, ip, port, username, folder, auth_type) VALUES (1, 'user-1', 'one', '10.0.0.1', 22, 'root', 'prod', 'password'), (2, 'user-1', 'two', '10.0.0.2', 22, 'root', 'prod / api', 'password'), (3, 'user-2', 'other', '10.0.0.3', 22, 'root', 'prod', 'password'); - INSERT INTO ssh_credentials (id, user_id, name, folder, auth_type) - VALUES - (1, 'user-1', 'cred-one', 'prod', 'password'), - (2, 'user-1', 'cred-two', 'prod / api', 'password'), - (3, 'user-2', 'cred-other', 'prod', 'password'); INSERT INTO ssh_folders (id, user_id, name, color, icon) VALUES (1, 'user-1', 'prod', '#111111', 'server'), @@ -168,15 +37,12 @@ describe("HostFolderRepository", () => { (3, 'user-2', 'prod', '#333333', 'user'); `); - return { - repository: new HostFolderRepository(context, onWrite), - sqlite: context.sqlite!, - }; + return { repository: new HostFolderRepository(context, onWrite) }; } it("renames folders across hosts, credentials, and folder records", async () => { let writes = 0; - const { repository, sqlite } = await createRepository(() => { + const { repository } = await createRepository(() => { writes += 1; }); @@ -189,22 +55,23 @@ describe("HostFolderRepository", () => { ), ).resolves.toEqual({ updatedHosts: 2, updatedCredentials: 2 }); + // Portable on purpose: the rename builds the child path with string + // concatenation, which is the one place a dialect difference shows up as + // wrong data rather than an error. expect( - sqlite - .prepare("SELECT folder FROM ssh_data WHERE user_id = ? ORDER BY id") - .all("user-1"), + await adapter!.query( + sql`SELECT folder FROM ssh_data WHERE user_id = 'user-1' ORDER BY id`, + ), ).toEqual([{ folder: "ops" }, { folder: "ops / api" }]); expect( - sqlite - .prepare( - "SELECT folder FROM ssh_credentials WHERE user_id = ? ORDER BY id", - ) - .all("user-1"), + await adapter!.query( + sql`SELECT folder FROM ssh_credentials WHERE user_id = 'user-1' ORDER BY id`, + ), ).toEqual([{ folder: "ops" }, { folder: "ops / api" }]); expect( - sqlite - .prepare("SELECT name FROM ssh_folders WHERE user_id = ? ORDER BY id") - .all("user-1"), + await adapter!.query( + sql`SELECT name FROM ssh_folders WHERE user_id = 'user-1' ORDER BY id`, + ), ).toEqual([{ name: "ops" }, { name: "ops / api" }]); expect(writes).toBe(1); }); @@ -269,7 +136,7 @@ describe("HostFolderRepository", () => { it("lists and deletes hosts and folder records in a folder tree", async () => { let writes = 0; - const { repository, sqlite } = await createRepository(() => { + const { repository } = await createRepository(() => { writes += 1; }); @@ -278,28 +145,28 @@ describe("HostFolderRepository", () => { await repository.deleteHostsAndFolderRecords("user-1", "prod"); - expect(sqlite.prepare("SELECT id FROM ssh_data ORDER BY id").all()).toEqual( - [{ id: 3 }], - ); expect( - sqlite.prepare("SELECT id FROM ssh_folders ORDER BY id").all(), + await adapter!.query(sql`SELECT id FROM ssh_data ORDER BY id`), + ).toEqual([{ id: 3 }]); + expect( + await adapter!.query(sql`SELECT id FROM ssh_folders ORDER BY id`), ).toEqual([{ id: 3 }]); expect(writes).toBe(1); }); it("deletes folder records for a user", async () => { let writes = 0; - const { repository, sqlite } = await createRepository(() => { + const { repository } = await createRepository(() => { writes += 1; }); await expect(repository.deleteByUserId("user-1")).resolves.toBe(2); - expect(sqlite.prepare("SELECT id FROM ssh_data ORDER BY id").all()).toEqual( - [{ id: 1 }, { id: 2 }, { id: 3 }], - ); expect( - sqlite.prepare("SELECT id FROM ssh_folders ORDER BY id").all(), + await adapter!.query(sql`SELECT id FROM ssh_data ORDER BY id`), + ).toEqual([{ id: 1 }, { id: 2 }, { id: 3 }]); + expect( + await adapter!.query(sql`SELECT id FROM ssh_folders ORDER BY id`), ).toEqual([{ id: 3 }]); expect(writes).toBe(1); }); diff --git a/src/backend/tests/database/repositories/host-health-repository.test.ts b/src/backend/tests/database/repositories/host-health-repository.test.ts index 29ff31f6..99058e34 100644 --- a/src/backend/tests/database/repositories/host-health-repository.test.ts +++ b/src/backend/tests/database/repositories/host-health-repository.test.ts @@ -17,44 +17,11 @@ describe("HostHealthRepository", () => { ): Promise { adapter = new TestSqliteDatabase(); const context = await adapter.connect(); - context.sqlite?.exec(` - CREATE TABLE users ( - id TEXT PRIMARY KEY, - username TEXT NOT NULL, - password_hash TEXT NOT NULL - ); - - CREATE TABLE hosts ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - user_id TEXT NOT NULL, - name TEXT NOT NULL - ); - - CREATE TABLE host_health_checks ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - user_id TEXT NOT NULL, - host_id INTEGER NOT NULL, - checks TEXT NOT NULL, - interval_seconds INTEGER NOT NULL DEFAULT 300, - created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, - updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP - ); - - CREATE TABLE host_health_history ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - user_id TEXT NOT NULL, - host_id INTEGER NOT NULL, - check_id TEXT NOT NULL, - ts TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, - ok INTEGER NOT NULL, - latency_ms INTEGER, - detail TEXT - ); - + await adapter.exec(` INSERT INTO users (id, username, password_hash) VALUES ('user-1', 'alice', 'hash'), ('user-2', 'bob', 'hash'); - INSERT INTO hosts (id, user_id, name) - VALUES (1, 'user-1', 'one'), (2, 'user-2', 'two'); + INSERT INTO ssh_data (id, user_id, name, ip, port, username, auth_type) + VALUES (1, 'user-1', 'one', '10.0.0.1', 22, 'root', 'password'), (2, 'user-2', 'two', '10.0.0.1', 22, 'root', 'password'); INSERT INTO host_health_checks ( user_id, host_id, checks, interval_seconds, created_at, updated_at ) diff --git a/src/backend/tests/database/repositories/host-metrics-history-repository.test.ts b/src/backend/tests/database/repositories/host-metrics-history-repository.test.ts index f20f5c42..3fb64e67 100644 --- a/src/backend/tests/database/repositories/host-metrics-history-repository.test.ts +++ b/src/backend/tests/database/repositories/host-metrics-history-repository.test.ts @@ -17,26 +17,13 @@ describe("HostMetricsHistoryRepository", () => { ): Promise { adapter = new TestSqliteDatabase(); const context = await adapter.connect(); - context.sqlite?.exec(` - CREATE TABLE hosts ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - user_id TEXT NOT NULL, - name TEXT NOT NULL - ); + await adapter.exec(` + INSERT INTO users (id, username, password_hash) VALUES + ('user-1', 'user-1', 'hash'), + ('user-2', 'user-2', 'hash'); - CREATE TABLE host_metrics_history ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - host_id INTEGER NOT NULL, - ts TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, - cpu_percent REAL, - mem_percent REAL, - disk_percent REAL, - net_rx_bytes INTEGER, - net_tx_bytes INTEGER - ); - - INSERT INTO hosts (id, user_id, name) - VALUES (1, 'user-1', 'one'), (2, 'user-2', 'two'); + INSERT INTO ssh_data (id, user_id, name, ip, port, username, auth_type) + VALUES (1, 'user-1', 'one', '10.0.0.1', 22, 'root', 'password'), (2, 'user-2', 'two', '10.0.0.1', 22, 'root', 'password'); INSERT INTO host_metrics_history ( host_id, ts, cpu_percent, mem_percent, disk_percent, net_rx_bytes, net_tx_bytes ) @@ -78,7 +65,7 @@ describe("HostMetricsHistoryRepository", () => { it("prunes old history for a host only", async () => { const repo = await createRepository(); - repo.pruneOlderThan(1, 1); + await repo.pruneOlderThan(1, 1); const rows = await repo.listRange( 1, diff --git a/src/backend/tests/database/repositories/host-metrics-preference-repository.test.ts b/src/backend/tests/database/repositories/host-metrics-preference-repository.test.ts index cc1ceeba..e45cb6e0 100644 --- a/src/backend/tests/database/repositories/host-metrics-preference-repository.test.ts +++ b/src/backend/tests/database/repositories/host-metrics-preference-repository.test.ts @@ -17,33 +17,11 @@ describe("HostMetricsPreferenceRepository", () => { ): Promise { adapter = new TestSqliteDatabase(); const context = await adapter.connect(); - context.sqlite?.exec(` - CREATE TABLE users ( - id TEXT PRIMARY KEY, - username TEXT NOT NULL, - password_hash TEXT NOT NULL - ); - - CREATE TABLE ssh_data ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - user_id TEXT NOT NULL, - name TEXT NOT NULL, - stats_config TEXT - ); - - CREATE TABLE host_metrics_preferences ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - user_id TEXT NOT NULL, - host_id INTEGER NOT NULL, - layout TEXT NOT NULL, - created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, - updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP - ); - + await adapter.exec(` INSERT INTO users (id, username, password_hash) VALUES ('user-1', 'alice', 'hash'), ('user-2', 'bob', 'hash'); - INSERT INTO ssh_data (id, user_id, name, stats_config) - VALUES (1, 'user-1', 'one', '{}'), (2, 'user-2', 'two', '{}'); + INSERT INTO ssh_data (id, user_id, name, stats_config, ip, port, username, auth_type) + VALUES (1, 'user-1', 'one', '{}', '10.0.0.1', 22, 'root', 'password'), (2, 'user-2', 'two', '{}', '10.0.0.1', 22, 'root', 'password'); INSERT INTO host_metrics_preferences ( user_id, host_id, layout, created_at, updated_at ) diff --git a/src/backend/tests/database/repositories/host-resolution-repository.test.ts b/src/backend/tests/database/repositories/host-resolution-repository.test.ts index ad9d0351..c7610c15 100644 --- a/src/backend/tests/database/repositories/host-resolution-repository.test.ts +++ b/src/backend/tests/database/repositories/host-resolution-repository.test.ts @@ -27,162 +27,15 @@ describe("HostResolutionRepository", () => { ): Promise { adapter = new TestSqliteDatabase(); const context = await adapter.connect(); - context.sqlite?.exec(` - CREATE TABLE users ( - id TEXT PRIMARY KEY, - username TEXT NOT NULL, - password_hash TEXT NOT NULL - ); - - CREATE TABLE ssh_data ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - user_id TEXT NOT NULL, - connection_type TEXT NOT NULL DEFAULT 'ssh', - name TEXT, - ip TEXT NOT NULL, - port INTEGER NOT NULL, - username TEXT NOT NULL, - folder TEXT, - tags TEXT, - pin INTEGER NOT NULL DEFAULT 0, - auth_type TEXT NOT NULL, - use_warpgate INTEGER NOT NULL DEFAULT 0, - force_keyboard_interactive TEXT, - password TEXT, - key TEXT, - key_password TEXT, - key_type TEXT, - sudo_password TEXT, - autostart_password TEXT, - autostart_key TEXT, - autostart_key_password TEXT, - credential_id INTEGER, - override_credential_username INTEGER, - vault_profile_id INTEGER, - enable_terminal INTEGER NOT NULL DEFAULT 1, - enable_session_logging INTEGER NOT NULL DEFAULT 1, - allow_session_sharing INTEGER NOT NULL DEFAULT 1, - enable_command_history INTEGER NOT NULL DEFAULT 1, - enable_tunnel INTEGER NOT NULL DEFAULT 1, - tunnel_connections TEXT, - jump_hosts TEXT, - enable_file_manager INTEGER NOT NULL DEFAULT 1, - scp_legacy INTEGER NOT NULL DEFAULT 0, - enable_docker INTEGER NOT NULL DEFAULT 0, - enable_tmux_monitor INTEGER NOT NULL DEFAULT 0, - show_terminal_in_sidebar INTEGER NOT NULL DEFAULT 1, - show_file_manager_in_sidebar INTEGER NOT NULL DEFAULT 0, - show_tunnel_in_sidebar INTEGER NOT NULL DEFAULT 0, - show_docker_in_sidebar INTEGER NOT NULL DEFAULT 0, - show_server_stats_in_sidebar INTEGER NOT NULL DEFAULT 0, - default_path TEXT, - stats_config TEXT, - docker_config TEXT, - enable_proxmox INTEGER NOT NULL DEFAULT 0, - proxmox_config TEXT, - terminal_config TEXT, - quick_actions TEXT, - notes TEXT, - enable_ssh INTEGER NOT NULL DEFAULT 1, - enable_rdp INTEGER NOT NULL DEFAULT 0, - enable_vnc INTEGER NOT NULL DEFAULT 0, - enable_telnet INTEGER NOT NULL DEFAULT 0, - ssh_port INTEGER DEFAULT 22, - rdp_port INTEGER DEFAULT 3389, - vnc_port INTEGER DEFAULT 5900, - telnet_port INTEGER DEFAULT 23, - rdp_credential_id INTEGER, - rdp_user TEXT, - rdp_password TEXT, - rdp_domain TEXT, - rdp_security TEXT, - rdp_ignore_cert INTEGER DEFAULT 0, - vnc_credential_id INTEGER, - vnc_password TEXT, - vnc_user TEXT, - telnet_user TEXT, - telnet_password TEXT, - telnet_credential_id INTEGER, - rdp_auth_type TEXT, - vnc_auth_type TEXT, - telnet_auth_type TEXT, - domain TEXT, - security TEXT, - ignore_cert INTEGER DEFAULT 0, - guacamole_config TEXT, - use_socks5 INTEGER, - socks5_host TEXT, - socks5_port INTEGER, - socks5_username TEXT, - socks5_password TEXT, - socks5_proxy_chain TEXT, - mac_address TEXT, - wol_broadcast_address TEXT, - port_knock_sequence TEXT, - host_key_fingerprint TEXT, - host_key_type TEXT, - host_key_algorithm TEXT DEFAULT 'sha256', - host_key_first_seen TEXT, - host_key_last_verified TEXT, - host_key_changed_count INTEGER DEFAULT 0, - connection_origin TEXT, - sync_id TEXT, - created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, - updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP - ); - - CREATE TABLE ssh_credentials ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - user_id TEXT NOT NULL, - name TEXT NOT NULL, - description TEXT, - folder TEXT, - tags TEXT, - auth_type TEXT NOT NULL, - username TEXT, - password TEXT, - key TEXT, - private_key TEXT, - public_key TEXT, - key_password TEXT, - key_type TEXT, - detected_key_type TEXT, - cert_public_key TEXT, - usage_count INTEGER NOT NULL DEFAULT 0, - last_used TEXT, - sync_id TEXT, - created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, - updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP - ); - - CREATE TABLE host_access ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - host_id INTEGER NOT NULL, - user_id TEXT, - role_id INTEGER, - granted_by TEXT NOT NULL, - permission_level TEXT NOT NULL DEFAULT 'view', - expires_at TEXT, - created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, - last_accessed_at TEXT, - access_count INTEGER NOT NULL DEFAULT 0, - override_credential_id INTEGER - ); - - CREATE TABLE ssh_folders ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - user_id TEXT NOT NULL, - name TEXT NOT NULL, - color TEXT, - icon TEXT, - credential_id INTEGER, - sync_id TEXT, - created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, - updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP - ); - + await adapter.exec(` INSERT INTO users (id, username, password_hash) VALUES ('user-1', 'alice', 'hash'), ('user-2', 'bob', 'hash'); + INSERT INTO ssh_credentials ( + id, user_id, name, auth_type, username, password, private_key, key_password + ) + VALUES + (7, 'user-1', 'owner', 'password', 'root', 'secret', NULL, NULL), + (8, 'user-2', 'override', 'key', 'alice', NULL, 'private', 'pass'); INSERT INTO ssh_data ( id, user_id, name, ip, port, username, auth_type, credential_id, tunnel_connections @@ -191,21 +44,15 @@ describe("HostResolutionRepository", () => { (1, 'user-1', 'web', '10.0.0.1', 22, 'root', 'password', 7, '[{"autoStart":true}]'), (2, 'user-1', 'db', '10.0.0.2', 22, 'admin', 'none', NULL, NULL), (3, 'user-2', 'other', '10.0.0.3', 22, 'root', 'none', NULL, '[{"autoStart":false}]'); - INSERT INTO ssh_credentials ( - id, user_id, name, auth_type, username, password, private_key, key_password - ) - VALUES - (7, 'user-1', 'owner', 'password', 'root', 'secret', NULL, NULL), - (8, 'user-2', 'override', 'key', 'alice', NULL, 'private', 'pass'); - INSERT INTO host_access ( - host_id, user_id, granted_by, permission_level, override_credential_id - ) - VALUES (1, 'user-2', 'user-1', 'execute', 8); INSERT INTO ssh_folders (user_id, name, credential_id) VALUES ('user-1', 'switches', 7), ('user-1', 'switches / floor1', NULL), ('user-1', 'no-cred', NULL); + INSERT INTO host_access ( + host_id, user_id, granted_by, permission_level + ) + VALUES (1, 'user-2', 'user-1', 'execute'); `); return new HostResolutionRepository(context, onWrite); @@ -297,7 +144,12 @@ describe("HostResolutionRepository", () => { const repository = await createRepository(); const rows = await repository.listHostRowsForAccessList("user-2", [ - { hostId: 1, permissionLevel: "execute", expiresAt: null }, + { hostId: 1, permissionLevel: "view", expiresAt: null }, + { + hostId: 1, + permissionLevel: "manage", + expiresAt: "2026-07-01T00:00:00.000Z", + }, { hostId: 3, permissionLevel: "view", expiresAt: null }, { hostId: 999, permissionLevel: "view", expiresAt: null }, ]); @@ -316,8 +168,8 @@ describe("HostResolutionRepository", () => { userId: "user-1", ownerId: "user-1", isShared: true, - permissionLevel: "execute", - expiresAt: null, + permissionLevel: "manage", + expiresAt: "2026-07-01T00:00:00.000Z", }); expect(DataCrypto.decryptRecord).not.toHaveBeenCalled(); }); @@ -503,17 +355,6 @@ describe("HostResolutionRepository", () => { ).resolves.toBeNull(); }); - it("loads override credential ids for shared host resolution", async () => { - const repository = await createRepository(); - - await expect( - repository.findOverrideCredentialId(1, "user-2"), - ).resolves.toBe(8); - await expect( - repository.findOverrideCredentialId(1, "user-1"), - ).resolves.toBeNull(); - }); - it("resolves a folder's assigned credential, walking up to parent folders", async () => { const repository = await createRepository(); diff --git a/src/backend/tests/database/repositories/mutation-result.test.ts b/src/backend/tests/database/repositories/mutation-result.test.ts new file mode 100644 index 00000000..b905de24 --- /dev/null +++ b/src/backend/tests/database/repositories/mutation-result.test.ts @@ -0,0 +1,87 @@ +import { describe, expect, it } from "vitest"; +import { + insertedId, + rowsAffected, + supportsReturning, +} from "../../../database/repositories/mutation-result.js"; + +describe("rowsAffected", () => { + it("counts a returning() array from sqlite or postgres", () => { + expect(rowsAffected([{ id: 1 }, { id: 2 }, { id: 3 }])).toBe(3); + expect(rowsAffected([])).toBe(0); + }); + + it("reads affectedRows from a mysql write result", () => { + expect(rowsAffected({ affectedRows: 4, insertId: 0 })).toBe(4); + expect(rowsAffected({ affectedRows: 0 })).toBe(0); + }); + + it("reads changes from a better-sqlite3 write result", () => { + // The shape of a write with no .returning() attached — verified against + // the driver, not assumed. + expect(rowsAffected({ changes: 1, lastInsertRowid: 7 })).toBe(1); + expect(rowsAffected({ changes: 0, lastInsertRowid: 7 })).toBe(0); + }); + + it("reads rowCount from a node-postgres write result", () => { + expect(rowsAffected({ rowCount: 3, rows: [], command: "DELETE" })).toBe(3); + }); + + it("unwraps the [header, fields] tuple mysql2 returns", () => { + expect(rowsAffected([{ affectedRows: 2 }, []])).toBe(2); + }); + + it("does not mistake a returning() array for a mysql header", () => { + // A single returned row is one row, not whatever affectedRows might say. + expect(rowsAffected([{ id: 7 }])).toBe(1); + }); + + it("reports zero for a shape it does not recognise", () => { + expect(rowsAffected(undefined)).toBe(0); + expect(rowsAffected(null)).toBe(0); + expect(rowsAffected({})).toBe(0); + }); +}); + +describe("insertedId", () => { + it("reads the id from a returning() array", () => { + expect(insertedId([{ id: 42 }])).toBe(42); + }); + + it("reads insertId from a mysql write result", () => { + expect(insertedId({ affectedRows: 1, insertId: 42 })).toBe(42); + expect(insertedId([{ affectedRows: 1, insertId: 42 }, []])).toBe(42); + }); + + it("treats mysql's zero insertId as absent", () => { + // MySQL reports 0 when the table has no autoincrement column. + expect(insertedId({ affectedRows: 1, insertId: 0 })).toBeNull(); + }); + + it("reads lastInsertRowid from better-sqlite3, as number or bigint", () => { + expect(insertedId({ changes: 1, lastInsertRowid: 9 })).toBe(9); + expect(insertedId({ changes: 1, lastInsertRowid: 9n })).toBe(9); + expect(insertedId({ changes: 1, lastInsertRowid: 0 })).toBeNull(); + }); + + it("returns null when nothing was inserted", () => { + expect(insertedId([])).toBeNull(); + expect(insertedId({})).toBeNull(); + expect(insertedId(undefined)).toBeNull(); + }); + + it("returns null for a non-numeric id", () => { + // Tables keyed by a text id, e.g. users. + expect(insertedId([{ id: "u-1" }])).toBeNull(); + }); +}); + +describe("supportsReturning", () => { + it("is false only for mysql", () => { + expect(supportsReturning("sqlite")).toBe(true); + expect(supportsReturning("postgres")).toBe(true); + // No RETURNING clause in MySQL, and drizzle's mysql-core does not expose + // the method — call sites that need rows back must read first. + expect(supportsReturning("mysql")).toBe(false); + }); +}); diff --git a/src/backend/tests/database/repositories/network-topology-repository.test.ts b/src/backend/tests/database/repositories/network-topology-repository.test.ts index 1c8ad72a..985336ee 100644 --- a/src/backend/tests/database/repositories/network-topology-repository.test.ts +++ b/src/backend/tests/database/repositories/network-topology-repository.test.ts @@ -17,23 +17,7 @@ describe("NetworkTopologyRepository", () => { ): Promise { adapter = new TestSqliteDatabase(); const context = await adapter.connect(); - context.sqlite?.exec(` - CREATE TABLE users ( - id TEXT PRIMARY KEY, - username TEXT NOT NULL, - password_hash TEXT NOT NULL, - is_admin INTEGER NOT NULL DEFAULT 0, - is_oidc INTEGER NOT NULL DEFAULT 0 - ); - - CREATE TABLE network_topology ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - user_id TEXT NOT NULL, - topology TEXT, - created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, - updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP - ); - + await adapter.exec(` INSERT INTO users (id, username, password_hash) VALUES ('user-1', 'alice', 'hash'), ('user-2', 'bob', 'hash'); `); diff --git a/src/backend/tests/database/repositories/open-tab-repository.test.ts b/src/backend/tests/database/repositories/open-tab-repository.test.ts index 0d7f88c9..abae4917 100644 --- a/src/backend/tests/database/repositories/open-tab-repository.test.ts +++ b/src/backend/tests/database/repositories/open-tab-repository.test.ts @@ -17,29 +17,12 @@ describe("OpenTabRepository", () => { ): Promise { adapter = new TestSqliteDatabase(); const context = await adapter.connect(); - context.sqlite?.exec(` - CREATE TABLE users ( - id TEXT PRIMARY KEY, - username TEXT NOT NULL, - password_hash TEXT NOT NULL, - is_admin INTEGER NOT NULL DEFAULT 0, - is_oidc INTEGER NOT NULL DEFAULT 0 - ); - - CREATE TABLE user_open_tabs ( - id TEXT PRIMARY KEY, - user_id TEXT NOT NULL, - tab_type TEXT NOT NULL, - host_id INTEGER, - label TEXT NOT NULL, - tab_order INTEGER NOT NULL DEFAULT 0, - backend_session_id TEXT, - created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, - updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP - ); - + await adapter.exec(` INSERT INTO users (id, username, password_hash) VALUES ('user-1', 'alice', 'hash'), ('user-2', 'bob', 'hash'); + INSERT INTO ssh_data (id, user_id, name, ip, port, username, auth_type) VALUES + (1, 'user-1', 'host-1', '10.0.0.1', 22, 'root', 'password'), + (2, 'user-1', 'host-2', '10.0.0.2', 22, 'root', 'password'); `); return new OpenTabRepository(context, onWrite); diff --git a/src/backend/tests/database/repositories/opkssh-token-repository.test.ts b/src/backend/tests/database/repositories/opkssh-token-repository.test.ts index 4700e214..de7efde3 100644 --- a/src/backend/tests/database/repositories/opkssh-token-repository.test.ts +++ b/src/backend/tests/database/repositories/opkssh-token-repository.test.ts @@ -17,39 +17,11 @@ describe("OpksshTokenRepository", () => { ): Promise { adapter = new TestSqliteDatabase(); const context = await adapter.connect(); - context.sqlite?.exec(` - CREATE TABLE users ( - id TEXT PRIMARY KEY, - username TEXT NOT NULL, - password_hash TEXT NOT NULL - ); - - CREATE TABLE hosts ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - user_id TEXT NOT NULL, - name TEXT NOT NULL - ); - - CREATE TABLE opkssh_tokens ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - user_id TEXT NOT NULL, - host_id INTEGER NOT NULL, - ssh_cert TEXT NOT NULL, - private_key TEXT NOT NULL, - email TEXT, - sub TEXT, - issuer TEXT, - audience TEXT, - created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, - expires_at TEXT NOT NULL, - last_used TEXT, - UNIQUE(user_id, host_id) - ); - + await adapter.exec(` INSERT INTO users (id, username, password_hash) VALUES ('user-1', 'alice', 'hash'), ('user-2', 'bob', 'hash'); - INSERT INTO hosts (id, user_id, name) - VALUES (1, 'user-1', 'one'), (2, 'user-1', 'two'), (3, 'user-2', 'other'); + INSERT INTO ssh_data (id, user_id, name, ip, port, username, auth_type) + VALUES (1, 'user-1', 'one', '10.0.0.1', 22, 'root', 'password'), (2, 'user-1', 'two', '10.0.0.1', 22, 'root', 'password'), (3, 'user-2', 'other', '10.0.0.1', 22, 'root', 'password'); INSERT INTO opkssh_tokens ( user_id, host_id, ssh_cert, private_key, email, expires_at ) diff --git a/src/backend/tests/database/repositories/rbac-access-repository.test.ts b/src/backend/tests/database/repositories/rbac-access-repository.test.ts index 1cc8e509..e2920804 100644 --- a/src/backend/tests/database/repositories/rbac-access-repository.test.ts +++ b/src/backend/tests/database/repositories/rbac-access-repository.test.ts @@ -18,112 +18,30 @@ describe("RbacAccessRepository", () => { ): Promise { adapter = new TestSqliteDatabase(); const context = await adapter.connect(); - context.sqlite?.exec(` - CREATE TABLE users ( - id TEXT PRIMARY KEY, - username TEXT NOT NULL, - password_hash TEXT NOT NULL, - is_admin INTEGER NOT NULL DEFAULT 0, - is_oidc INTEGER NOT NULL DEFAULT 0 - ); - - CREATE TABLE roles ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - name TEXT NOT NULL UNIQUE, - display_name TEXT NOT NULL, - description TEXT, - is_system INTEGER NOT NULL DEFAULT 0, - permissions TEXT, - created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, - updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP - ); - - CREATE TABLE host_access ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - host_id INTEGER NOT NULL, - user_id TEXT, - role_id INTEGER, - granted_by TEXT NOT NULL, - permission_level TEXT NOT NULL DEFAULT 'view', - expires_at TEXT, - created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, - last_accessed_at TEXT, - access_count INTEGER NOT NULL DEFAULT 0, - override_credential_id INTEGER - ); - - CREATE TABLE shared_host_secrets ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - host_access_id INTEGER NOT NULL, - target_user_id TEXT NOT NULL, - protocol TEXT NOT NULL DEFAULT 'ssh', - source_type TEXT NOT NULL DEFAULT 'credential', - original_credential_id INTEGER, - encrypted_username TEXT, - encrypted_auth_type TEXT, - encrypted_password TEXT, - encrypted_key TEXT, - encrypted_key_password TEXT, - encrypted_key_type TEXT, - encrypted_domain TEXT, - created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, - updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, - UNIQUE(host_access_id, target_user_id, protocol) - ); - - CREATE TABLE ssh_data ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - user_id TEXT NOT NULL, - name TEXT, - ip TEXT NOT NULL, - port INTEGER NOT NULL, - username TEXT NOT NULL, - credential_id INTEGER, - rdp_credential_id INTEGER, - vnc_credential_id INTEGER, - telnet_credential_id INTEGER, - folder TEXT, - tags TEXT - ); - - CREATE TABLE snippets ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - user_id TEXT NOT NULL, - name TEXT NOT NULL, - content TEXT NOT NULL, - description TEXT, - folder TEXT, - "order" INTEGER NOT NULL DEFAULT 0, - created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, - updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, - host_filter TEXT - ); - - CREATE TABLE snippet_access ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - snippet_id INTEGER NOT NULL, - user_id TEXT, - role_id INTEGER, - granted_by TEXT NOT NULL, - permission_level TEXT NOT NULL DEFAULT 'view', - expires_at TEXT, - created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP - ); - + await adapter.exec(` INSERT INTO users (id, username, password_hash, is_admin, is_oidc) VALUES ('admin', 'admin', 'hash', 1, 0), ('user-1', 'alice', 'hash', 0, 0), ('owner-1', 'owner', 'hash', 0, 0); - INSERT INTO roles (id, name, display_name, is_system) VALUES (7, 'ops', 'Operations', 0); - + INSERT INTO ssh_credentials (id, user_id, name, username, auth_type) VALUES + (123, 'admin', 'cred-123', 'root', 'password'), + (124, 'admin', 'cred-124', 'root', 'password'), + (125, 'admin', 'cred-125', 'root', 'password'), + (126, 'admin', 'cred-126', 'root', 'password'); + INSERT INTO ssh_data (id, user_id, name, ip, port, username, auth_type) VALUES + (43, 'admin', 'host-43', '10.0.0.43', 22, 'root', 'password'); + INSERT INTO ssh_data (id, user_id, name, ip, port, username, auth_type) VALUES + (44, 'admin', 'host-44', '10.0.0.45', 22, 'root', 'password'); INSERT INTO ssh_data ( - id, user_id, name, ip, port, username, credential_id, rdp_credential_id, vnc_credential_id, telnet_credential_id, folder, tags - ) - VALUES (42, 'owner-1', 'prod', '10.0.0.42', 22, 'root', 123, 124, 125, 126, 'servers', 'linux'); - + id, user_id, name, ip, port, username, credential_id, rdp_credential_id, vnc_credential_id, telnet_credential_id, folder, tags, auth_type) + VALUES (42, 'owner-1', 'prod', '10.0.0.42', 22, 'root', 123, 124, 125, 126, 'servers', 'linux', 'password'); + INSERT INTO snippets (id, user_id, name, content) + VALUES + (99, 'owner-1', 'deploy', 'echo deploy'), + (100, 'owner-1', 'rollback', 'echo rollback'); INSERT INTO host_access ( id, host_id, user_id, role_id, granted_by, permission_level, expires_at, created_at ) @@ -131,23 +49,18 @@ describe("RbacAccessRepository", () => { (1, 42, 'user-1', NULL, 'admin', 'view', NULL, '2026-06-26T00:00:00.000Z'), (2, 42, NULL, 7, 'admin', 'view', '2026-06-27T00:00:00.000Z', '2026-06-26T01:00:00.000Z'), (5, 44, 'user-1', NULL, 'admin', 'view', '2026-06-25T00:00:00.000Z', '2026-06-24T00:00:00.000Z'); - - INSERT INTO shared_host_secrets ( - id, host_access_id, target_user_id, protocol, source_type, original_credential_id, encrypted_username, encrypted_auth_type - ) - VALUES - (8, 2, 'user-1', 'ssh', 'credential', 123, 'enc-user', 'enc-auth'), - (9, 2, 'user-1', 'rdp', 'inline', NULL, 'enc-rdp-user', 'direct'); - - INSERT INTO snippets (id, user_id, name, content) - VALUES (99, 'owner-1', 'deploy', 'echo deploy'); - INSERT INTO snippet_access ( id, snippet_id, user_id, role_id, granted_by, permission_level, expires_at, created_at ) VALUES (3, 99, 'user-1', NULL, 'admin', 'view', NULL, '2026-06-26T00:00:00.000Z'), (4, 99, NULL, 7, 'admin', 'view', '2026-06-27T00:00:00.000Z', '2026-06-26T01:00:00.000Z'); + INSERT INTO shared_host_secrets ( + id, host_access_id, target_user_id, protocol, source_type, original_credential_id, encrypted_username, encrypted_auth_type + ) + VALUES + (8, 2, 'user-1', 'ssh', 'credential', 123, 'enc-user', 'enc-auth'), + (9, 2, 'user-1', 'rdp', 'inline', NULL, 'enc-rdp-user', 'direct'); `); return new RbacAccessRepository(context, onWrite); @@ -436,11 +349,6 @@ describe("RbacAccessRepository", () => { const directAccess = await repo.findDirectHostAccess(42, "user-1"); expect(directAccess?.id).toBe(1); - await repo.updateHostAccessOverrideCredential(1, 123); - expect( - (await repo.findDirectHostAccess(42, "user-1"))?.overrideCredentialId, - ).toBe(123); - await repo.touchHostAccess(1, "2026-06-26T03:00:00.000Z"); expect( (await repo.findDirectHostAccess(42, "user-1"))?.lastAccessedAt, @@ -448,7 +356,7 @@ describe("RbacAccessRepository", () => { await repo.revokeHostAccess(1, 42); expect(await repo.findDirectHostAccess(42, "user-1")).toBeNull(); - expect(writeCount).toBe(5); + expect(writeCount).toBe(4); }); it("finds active host access and deletes expired host access", async () => { diff --git a/src/backend/tests/database/repositories/recent-activity-repository.test.ts b/src/backend/tests/database/repositories/recent-activity-repository.test.ts index 52c0d405..08087947 100644 --- a/src/backend/tests/database/repositories/recent-activity-repository.test.ts +++ b/src/backend/tests/database/repositories/recent-activity-repository.test.ts @@ -1,3 +1,4 @@ +import { sql } from "drizzle-orm"; import { afterEach, describe, expect, it } from "vitest"; import { TestSqliteDatabase } from "./test-support.js"; import { RecentActivityRepository } from "../../../database/repositories/recent-activity-repository.js"; @@ -16,40 +17,14 @@ describe("RecentActivityRepository", () => { onWrite?: () => void | Promise, ): Promise<{ repository: RecentActivityRepository; - sqlite: NonNullable< - Awaited>["sqlite"] - >; }> { adapter = new TestSqliteDatabase(); const context = await adapter.connect(); - context.sqlite?.exec(` - CREATE TABLE users ( - id TEXT PRIMARY KEY, - username TEXT NOT NULL, - password_hash TEXT NOT NULL, - is_admin INTEGER NOT NULL DEFAULT 0, - is_oidc INTEGER NOT NULL DEFAULT 0 - ); - - CREATE TABLE hosts ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - user_id TEXT NOT NULL, - name TEXT NOT NULL - ); - - CREATE TABLE recent_activity ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - user_id TEXT NOT NULL, - type TEXT NOT NULL, - host_id INTEGER NOT NULL, - host_name TEXT, - timestamp TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP - ); - + await adapter.exec(` INSERT INTO users (id, username, password_hash) VALUES ('user-1', 'alice', 'hash'), ('user-2', 'bob', 'hash'); - INSERT INTO hosts (id, user_id, name) - VALUES (1, 'user-1', 'one'), (2, 'user-1', 'two'), (3, 'user-2', 'other'); + INSERT INTO ssh_data (id, user_id, name, ip, port, username, auth_type) + VALUES (1, 'user-1', 'one', '10.0.0.1', 22, 'root', 'password'), (2, 'user-1', 'two', '10.0.0.1', 22, 'root', 'password'), (3, 'user-2', 'other', '10.0.0.1', 22, 'root', 'password'); INSERT INTO recent_activity (id, user_id, type, host_id, host_name, timestamp) VALUES (1, 'user-1', 'connect', 1, 'one', '2026-06-26T00:00:00.000Z'), @@ -59,13 +34,12 @@ describe("RecentActivityRepository", () => { return { repository: new RecentActivityRepository(context, onWrite), - sqlite: context.sqlite!, }; } it("lists, creates, and trims recent activity", async () => { let writeCount = 0; - const { repository, sqlite } = await createRepository(() => { + const { repository } = await createRepository(() => { writeCount += 1; }); @@ -88,11 +62,9 @@ describe("RecentActivityRepository", () => { expect(await repository.trimUserActivity("user-1", 2)).toBe(1); expect( - sqlite - .prepare( - "SELECT id FROM recent_activity WHERE user_id = ? ORDER BY timestamp DESC", - ) - .all("user-1"), + await adapter!.query( + sql`SELECT id FROM recent_activity WHERE user_id = 'user-1' ORDER BY timestamp DESC`, + ), ).toEqual([{ id: created.id }, { id: 2 }]); expect(writeCount).toBe(2); }); diff --git a/src/backend/tests/database/repositories/returning.test.ts b/src/backend/tests/database/repositories/returning.test.ts new file mode 100644 index 00000000..42c1111c --- /dev/null +++ b/src/backend/tests/database/repositories/returning.test.ts @@ -0,0 +1,177 @@ +import { sql } from "drizzle-orm"; +import { describe, expect, it } from "vitest"; +import { + deleteReturning, + updateReturning, +} from "../../../database/repositories/returning.js"; +import type { DatabaseContext } from "../../../database/repositories/database-context.js"; +import type { DatabaseDialect } from "../../../database/db/dialect.js"; + +/** + * The MySQL path cannot be exercised against a real engine here, and its whole + * correctness is an ordering property: an update must be read AFTER the write, + * a delete BEFORE it. Get either backwards and the rows describe the wrong + * state — silently, with no error anywhere. + * + * So the drizzle handle is stubbed and the order of calls is recorded. + */ +function recordingContext(dialect: DatabaseDialect) { + const calls: string[] = []; + const rows = [{ id: 1, name: "before" }]; + + const chain = (label: string, result: unknown) => { + calls.push(label); + const thenable = { + set: () => thenable, + from: () => thenable, + where: () => thenable, + returning: () => Promise.resolve(result), + then: (resolve: (v: unknown) => void) => + Promise.resolve(result).then(resolve), + }; + return thenable; + }; + + const db = { + update: () => chain("update", rows), + delete: () => chain("delete", rows), + select: () => chain("select", rows), + transaction: (fn: (tx: unknown) => Promise) => { + calls.push("begin"); + return fn(db).then((value) => { + calls.push("commit"); + return value; + }); + }, + }; + + return { + context: { dialect, drizzle: db } as unknown as DatabaseContext, + calls, + }; +} + +const where = sql`id = 1`; + +describe("updateReturning", () => { + it.each(["sqlite", "postgres"] as const)( + "uses a single statement on %s, where RETURNING exists", + async (dialect) => { + const { context, calls } = recordingContext(dialect); + await updateReturning(context, {} as never, {}, where); + expect(calls).toEqual(["update"]); + }, + ); + + it("on mysql, writes first and reads the new state after", async () => { + const { context, calls } = recordingContext("mysql"); + await updateReturning(context, {} as never, {}, where); + + // Reading first would return the values the update replaced. + expect(calls).toEqual(["begin", "update", "select", "commit"]); + }); +}); + +describe("updateReturning, when the read-back cannot find the rows", () => { + /** + * The failure mode: an update that changes a column its own `where` filters + * on. MySQL writes the rows, then the re-read matches nothing. Returning [] + * would be indistinguishable from "matched nothing" and silently wrong. + */ + function contextThatWritesButCannotReadBack() { + const chain = (result: unknown) => { + const thenable: Record = { + set: () => thenable, + from: () => thenable, + where: () => thenable, + then: (resolve: (v: unknown) => void) => + Promise.resolve(result).then(resolve), + }; + return thenable; + }; + const db = { + update: () => chain({ affectedRows: 3 }), + select: () => chain([]), + transaction: (fn: (tx: unknown) => Promise) => fn(db), + }; + return { dialect: "mysql", drizzle: db } as unknown as DatabaseContext; + } + + it("throws instead of returning an empty array", async () => { + await expect( + updateReturning( + contextThatWritesButCannotReadBack(), + {} as never, + {}, + where, + ), + ).rejects.toThrow(/wrote 3 row\(s\) but could not read them back/); + }); + + it("says how to fix it", async () => { + await expect( + updateReturning( + contextThatWritesButCannotReadBack(), + {} as never, + {}, + where, + ), + ).rejects.toThrow(/filter on a column the update leaves alone/); + }); + + it("still returns [] when the update genuinely matched nothing", async () => { + const { context } = recordingContext("mysql"); + // recordingContext reports rows for select, so use a zero-write stub. + const chain = (result: unknown) => { + const t: Record = { + set: () => t, + from: () => t, + where: () => t, + then: (r: (v: unknown) => void) => Promise.resolve(result).then(r), + }; + return t; + }; + const db = { + update: () => chain({ affectedRows: 0 }), + select: () => chain([]), + transaction: (fn: (tx: unknown) => Promise) => fn(db), + }; + void context; + await expect( + updateReturning( + { dialect: "mysql", drizzle: db } as unknown as DatabaseContext, + {} as never, + {}, + where, + ), + ).resolves.toEqual([]); + }); +}); + +describe("deleteReturning", () => { + it("uses a single statement where RETURNING exists", async () => { + const { context, calls } = recordingContext("postgres"); + await deleteReturning(context, {} as never, where); + expect(calls).toEqual(["delete"]); + }); + + it("on mysql, reads first and deletes after", async () => { + const { context, calls } = recordingContext("mysql"); + const rows = await deleteReturning(context, {} as never, where); + + // Reading after the delete would find nothing at all. + expect(calls).toEqual(["begin", "select", "delete", "commit"]); + expect(rows).toHaveLength(1); + }); + + it("keeps both statements in one transaction", async () => { + const { context, calls } = recordingContext("mysql"); + await deleteReturning(context, {} as never, where); + + // Without this, a concurrent write between them makes the returned rows + // describe a state that never existed — and with a pool the second + // statement need not even reach the same connection. + expect(calls[0]).toBe("begin"); + expect(calls[calls.length - 1]).toBe("commit"); + }); +}); diff --git a/src/backend/tests/database/repositories/role-repository.test.ts b/src/backend/tests/database/repositories/role-repository.test.ts index 585f9379..963c5953 100644 --- a/src/backend/tests/database/repositories/role-repository.test.ts +++ b/src/backend/tests/database/repositories/role-repository.test.ts @@ -17,45 +17,7 @@ describe("RoleRepository", () => { ): Promise { adapter = new TestSqliteDatabase(); const context = await adapter.connect(); - context.sqlite?.exec(` - CREATE TABLE users ( - id TEXT PRIMARY KEY, - username TEXT NOT NULL, - password_hash TEXT NOT NULL, - is_admin INTEGER NOT NULL DEFAULT 0, - is_oidc INTEGER NOT NULL DEFAULT 0 - ); - - CREATE TABLE roles ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - name TEXT NOT NULL UNIQUE, - display_name TEXT NOT NULL, - description TEXT, - is_system INTEGER NOT NULL DEFAULT 0, - permissions TEXT, - created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, - updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP - ); - - CREATE TABLE user_roles ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - user_id TEXT NOT NULL, - role_id INTEGER NOT NULL, - granted_by TEXT, - granted_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP - ); - - CREATE TABLE host_access ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - host_id INTEGER NOT NULL, - user_id TEXT, - role_id INTEGER, - granted_by TEXT NOT NULL, - permission_level TEXT NOT NULL DEFAULT 'view', - expires_at TEXT, - created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP - ); - + await adapter.exec(` INSERT INTO users (id, username, password_hash, is_admin, is_oidc) VALUES ('admin', 'admin', 'hash', 1, 0), ('user-1', 'user', 'hash', 0, 0); `); diff --git a/src/backend/tests/database/repositories/session-recording-repository.test.ts b/src/backend/tests/database/repositories/session-recording-repository.test.ts index 7d2437cf..9da43e44 100644 --- a/src/backend/tests/database/repositories/session-recording-repository.test.ts +++ b/src/backend/tests/database/repositories/session-recording-repository.test.ts @@ -17,41 +17,11 @@ describe("SessionRecordingRepository", () => { ): Promise { adapter = new TestSqliteDatabase(); const context = await adapter.connect(); - context.sqlite?.exec(` - CREATE TABLE users ( - id TEXT PRIMARY KEY, - username TEXT NOT NULL, - password_hash TEXT NOT NULL - ); - - CREATE TABLE ssh_data ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - user_id TEXT NOT NULL, - name TEXT NOT NULL, - ip TEXT - ); - - CREATE TABLE session_recordings ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - host_id INTEGER NOT NULL, - user_id TEXT NOT NULL, - access_id INTEGER, - started_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, - ended_at TEXT, - duration INTEGER, - commands TEXT, - dangerous_actions TEXT, - recording_path TEXT, - protocol TEXT NOT NULL DEFAULT 'ssh', - format TEXT NOT NULL DEFAULT 'text', - terminated_by_owner INTEGER DEFAULT 0, - termination_reason TEXT - ); - + await adapter.exec(` INSERT INTO users (id, username, password_hash) VALUES ('user-1', 'alice', 'hash'), ('user-2', 'bob', 'hash'); - INSERT INTO ssh_data (id, user_id, name, ip) - VALUES (1, 'user-1', 'one', '10.0.0.1'), (2, 'user-1', 'two', '10.0.0.2'), (3, 'user-2', 'other', '10.0.0.3'); + INSERT INTO ssh_data (id, user_id, name, ip, port, username, auth_type) + VALUES (1, 'user-1', 'one', '10.0.0.1', 22, 'root', 'password'), (2, 'user-1', 'two', '10.0.0.2', 22, 'root', 'password'), (3, 'user-2', 'other', '10.0.0.3', 22, 'root', 'password'); `); return new SessionRecordingRepository(context, onWrite); diff --git a/src/backend/tests/database/repositories/session-share-repository.test.ts b/src/backend/tests/database/repositories/session-share-repository.test.ts index a7cc0318..9f8e16cc 100644 --- a/src/backend/tests/database/repositories/session-share-repository.test.ts +++ b/src/backend/tests/database/repositories/session-share-repository.test.ts @@ -17,51 +17,11 @@ describe("SessionShareRepository", () => { ): Promise { adapter = new TestSqliteDatabase(); const context = await adapter.connect(); - context.sqlite?.exec(` - CREATE TABLE users ( - id TEXT PRIMARY KEY, - username TEXT NOT NULL, - password_hash TEXT NOT NULL - ); - - CREATE TABLE ssh_data ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - user_id TEXT NOT NULL, - name TEXT NOT NULL, - ip TEXT - ); - - CREATE TABLE session_shares ( - id TEXT PRIMARY KEY, - host_id INTEGER NOT NULL, - owner_user_id TEXT NOT NULL, - protocol TEXT NOT NULL, - session_id TEXT NOT NULL, - tab_instance_id TEXT, - share_type TEXT NOT NULL, - target_user_id TEXT, - link_token TEXT UNIQUE, - permission_level TEXT NOT NULL DEFAULT 'read-only', - created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, - expires_at TEXT NOT NULL, - revoked_at TEXT, - last_joined_at TEXT, - join_count INTEGER NOT NULL DEFAULT 0 - ); - - CREATE TABLE session_share_participants ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - share_id TEXT NOT NULL, - user_id TEXT, - guest_label TEXT, - joined_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, - left_at TEXT - ); - + await adapter.exec(` INSERT INTO users (id, username, password_hash) VALUES ('owner-1', 'alice', 'hash'), ('guest-1', 'bob', 'hash'); - INSERT INTO ssh_data (id, user_id, name, ip) - VALUES (1, 'owner-1', 'host-one', '10.0.0.1'), (2, 'owner-1', 'host-two', '10.0.0.2'); + INSERT INTO ssh_data (id, user_id, name, ip, port, username, auth_type) + VALUES (1, 'owner-1', 'host-one', '10.0.0.1', 22, 'root', 'password'), (2, 'owner-1', 'host-two', '10.0.0.2', 22, 'root', 'password'); `); return new SessionShareRepository(context, onWrite); diff --git a/src/backend/tests/database/repositories/settings-cache-refresh.test.ts b/src/backend/tests/database/repositories/settings-cache-refresh.test.ts new file mode 100644 index 00000000..1f644d4c --- /dev/null +++ b/src/backend/tests/database/repositories/settings-cache-refresh.test.ts @@ -0,0 +1,104 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + refreshIntervalSeconds, + startSettingsCacheRefresh, + stopSettingsCacheRefresh, +} from "../../../database/repositories/factory.js"; + +/** + * The settings cache lives in one process and is updated by whichever process + * wrote the setting. On SQLite that is the only process there is. On Postgres + * and MySQL — the reason those exist here is to let several instances share one + * database — a setting changed on one replica would otherwise never reach the + * others, because the synchronous read cannot go back to the database. + * + * Re-priming on a timer does not make settings immediately consistent. It + * bounds how long they can disagree. + */ +describe("settings cache refresh", () => { + afterEach(() => stopSettingsCacheRefresh()); + + describe("interval", () => { + it("defaults to something short enough to matter", () => { + expect(refreshIntervalSeconds({})).toBe(30); + }); + + it("is configurable", () => { + expect( + refreshIntervalSeconds({ SETTINGS_CACHE_REFRESH_SECONDS: "5" }), + ).toBe(5); + }); + + it("treats zero and nonsense as off", () => { + expect( + refreshIntervalSeconds({ SETTINGS_CACHE_REFRESH_SECONDS: "0" }), + ).toBeNull(); + expect( + refreshIntervalSeconds({ SETTINGS_CACHE_REFRESH_SECONDS: "-1" }), + ).toBeNull(); + expect( + refreshIntervalSeconds({ SETTINGS_CACHE_REFRESH_SECONDS: "soon" }), + ).toBeNull(); + }); + }); + + it("re-reads on the interval", async () => { + const refresh = vi.fn().mockResolvedValue(undefined); + + startSettingsCacheRefresh( + { SETTINGS_CACHE_REFRESH_SECONDS: "0.01" }, + refresh, + ); + + await vi.waitFor(() => + expect(refresh.mock.calls.length).toBeGreaterThan(1), + ); + }); + + it("keeps running after a refresh throws", async () => { + // A transient database blip must not stop the loop, or the replica is stuck + // on stale settings until it restarts — the exact failure this prevents. + const refresh = vi + .fn() + .mockRejectedValueOnce(new Error("connection reset")) + .mockResolvedValue(undefined); + + startSettingsCacheRefresh( + { SETTINGS_CACHE_REFRESH_SECONDS: "0.01" }, + refresh, + ); + + await vi.waitFor(() => + expect(refresh.mock.calls.length).toBeGreaterThan(1), + ); + }); + + it("does nothing when switched off", async () => { + const refresh = vi.fn().mockResolvedValue(undefined); + + startSettingsCacheRefresh({ SETTINGS_CACHE_REFRESH_SECONDS: "0" }, refresh); + await new Promise((resolve) => setTimeout(resolve, 40)); + + expect(refresh).not.toHaveBeenCalled(); + }); + + it("stops when told to, and does not stack timers", async () => { + const refresh = vi.fn().mockResolvedValue(undefined); + + startSettingsCacheRefresh( + { SETTINGS_CACHE_REFRESH_SECONDS: "0.02" }, + refresh, + ); + startSettingsCacheRefresh( + { SETTINGS_CACHE_REFRESH_SECONDS: "0.02" }, + refresh, + ); + + await new Promise((resolve) => setTimeout(resolve, 70)); + stopSettingsCacheRefresh(); + + const afterStop = refresh.mock.calls.length; + await new Promise((resolve) => setTimeout(resolve, 50)); + expect(refresh.mock.calls.length).toBe(afterStop); + }); +}); diff --git a/src/backend/tests/database/repositories/settings-cache.test.ts b/src/backend/tests/database/repositories/settings-cache.test.ts new file mode 100644 index 00000000..81099b3e --- /dev/null +++ b/src/backend/tests/database/repositories/settings-cache.test.ts @@ -0,0 +1,82 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { + forgetCachedSetting, + isSettingsCachePrimed, + primeSettingsCache, + readCachedSetting, + resetSettingsCache, + updateCachedSetting, +} from "../../../database/repositories/settings-cache.js"; + +afterEach(() => resetSettingsCache()); + +describe("settings cache", () => { + it("starts unprimed", () => { + expect(isSettingsCachePrimed()).toBe(false); + }); + + it("reads back what was primed", () => { + primeSettingsCache([ + { key: "guac_url", value: "guacd:4822" }, + { key: "allow_registration", value: "false" }, + ]); + + expect(isSettingsCachePrimed()).toBe(true); + expect(readCachedSetting("guac_url")).toBe("guacd:4822"); + expect(readCachedSetting("allow_registration")).toBe("false"); + }); + + it("returns null for a key that is not set", () => { + primeSettingsCache([{ key: "guac_url", value: "guacd:4822" }]); + + expect(readCachedSetting("missing")).toBeNull(); + }); + + it("returns null rather than throwing before priming", () => { + // Startup ordering means a read can land first. Every caller already + // treats null as "use the default", so this must not throw. + expect(readCachedSetting("guac_url")).toBeNull(); + }); + + it("reflects a write immediately", () => { + primeSettingsCache([{ key: "log_level", value: "info" }]); + + updateCachedSetting("log_level", "debug"); + + // A synchronous reader must not see the pre-write value. + expect(readCachedSetting("log_level")).toBe("debug"); + }); + + it("accepts a key that did not exist at prime time", () => { + primeSettingsCache([]); + + updateCachedSetting("new_key", "value"); + + expect(readCachedSetting("new_key")).toBe("value"); + }); + + it("forgets a deleted key", () => { + primeSettingsCache([{ key: "guac_url", value: "guacd:4822" }]); + + forgetCachedSetting("guac_url"); + + expect(readCachedSetting("guac_url")).toBeNull(); + }); + + it("ignores writes while unprimed instead of half-populating", () => { + // A partially filled cache would be worse than an empty one: readers + // could not tell a real value from a missing prime. + updateCachedSetting("guac_url", "guacd:4822"); + + expect(isSettingsCachePrimed()).toBe(false); + expect(readCachedSetting("guac_url")).toBeNull(); + }); + + it("replaces the previous contents when primed again", () => { + primeSettingsCache([{ key: "old", value: "1" }]); + primeSettingsCache([{ key: "new", value: "2" }]); + + expect(readCachedSetting("old")).toBeNull(); + expect(readCachedSetting("new")).toBe("2"); + }); +}); diff --git a/src/backend/tests/database/repositories/settings-repository.test.ts b/src/backend/tests/database/repositories/settings-repository.test.ts index 94e1ca9a..1db75791 100644 --- a/src/backend/tests/database/repositories/settings-repository.test.ts +++ b/src/backend/tests/database/repositories/settings-repository.test.ts @@ -15,12 +15,6 @@ describe("SettingsRepository", () => { async function createRepository(): Promise { adapter = new TestSqliteDatabase(); const context = await adapter.connect(); - context.sqlite?.exec(` - CREATE TABLE settings ( - key TEXT PRIMARY KEY, - value TEXT NOT NULL - ) - `); return new SettingsRepository(context); } diff --git a/src/backend/tests/database/repositories/shared-host-auth-override-repository.test.ts b/src/backend/tests/database/repositories/shared-host-auth-override-repository.test.ts new file mode 100644 index 00000000..3348d6e0 --- /dev/null +++ b/src/backend/tests/database/repositories/shared-host-auth-override-repository.test.ts @@ -0,0 +1,97 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { sql } from "drizzle-orm"; +import { SharedHostAuthOverrideRepository } from "../../../database/repositories/shared-host-auth-override-repository.js"; +import { TestSqliteDatabase } from "./test-support.js"; + +describe("SharedHostAuthOverrideRepository", () => { + let adapter: TestSqliteDatabase | null = null; + + afterEach(async () => { + await adapter?.close(); + adapter = null; + }); + + async function createRepository(onWrite?: () => void) { + adapter = new TestSqliteDatabase(); + const context = await adapter.connect(); + await adapter.exec(` + INSERT INTO users (id, username, password_hash) + VALUES ('owner', 'owner', 'hash'), ('recipient', 'recipient', 'hash'); + INSERT INTO ssh_data (id, user_id, ip, port, username, auth_type) + VALUES (42, 'owner', '10.0.0.1', 22, 'root', 'password'); + INSERT INTO ssh_credentials (id, user_id, name, auth_type) + VALUES (7, 'recipient', 'cred-seven', 'password'), + (8, 'recipient', 'cred-eight', 'password'); + `); + + return { + repository: new SharedHostAuthOverrideRepository(context, onWrite), + }; + } + + it("creates, reads, updates and clears overrides by host, user, and protocol", async () => { + let writeCount = 0; + const { repository } = await createRepository(() => { + writeCount += 1; + }); + + await expect( + repository.findCredentialId(42, "recipient", "ssh"), + ).resolves.toBeNull(); + + await repository.setCredential(42, "recipient", "ssh", 7); + await expect( + repository.findCredentialId(42, "recipient", "ssh"), + ).resolves.toBe(7); + + await repository.setCredential(42, "recipient", "ssh", 8); + await repository.setCredential(42, "recipient", "rdp", 7); + await expect( + repository.findCredentialId(42, "recipient", "ssh"), + ).resolves.toBe(8); + await expect( + repository.findCredentialId(42, "recipient", "rdp"), + ).resolves.toBe(7); + + await expect( + repository.clearCredential(42, "recipient", "ssh"), + ).resolves.toBe(true); + await expect( + repository.clearCredential(42, "recipient", "ssh"), + ).resolves.toBe(false); + await expect( + repository.findCredentialId(42, "recipient", "rdp"), + ).resolves.toBe(7); + expect(writeCount).toBe(4); + }); + + it("removes overrides when the host, user, or credential is deleted", async () => { + const { repository } = await createRepository(); + + await repository.setCredential(42, "recipient", "ssh", 7); + await adapter!.run(sql`DELETE FROM ssh_credentials WHERE id = 7`); + await expect( + repository.findCredentialId(42, "recipient", "ssh"), + ).resolves.toBeNull(); + + await adapter!.run( + sql`INSERT INTO ssh_credentials (id, user_id, name, auth_type) + VALUES (7, 'recipient', 'cred-seven', 'password')`, + ); + await repository.setCredential(42, "recipient", "ssh", 7); + await adapter!.run(sql`DELETE FROM ssh_data WHERE id = 42`); + await expect( + repository.findCredentialId(42, "recipient", "ssh"), + ).resolves.toBeNull(); + + await adapter!.run( + sql`INSERT INTO ssh_data (id, user_id, ip, port, username, auth_type) + VALUES (42, 'owner', '10.0.0.1', 22, 'root', 'password')`, + ); + await repository.setCredential(42, "recipient", "ssh", 7); + await adapter!.run(sql`DELETE FROM users WHERE id = 'recipient'`); + await expect( + repository.findCredentialId(42, "recipient", "ssh"), + ).resolves.toBeNull(); + }); +}); diff --git a/src/backend/tests/database/repositories/shared-host-secrets-repository.test.ts b/src/backend/tests/database/repositories/shared-host-secrets-repository.test.ts index 75acf718..9eeb5680 100644 --- a/src/backend/tests/database/repositories/shared-host-secrets-repository.test.ts +++ b/src/backend/tests/database/repositories/shared-host-secrets-repository.test.ts @@ -16,65 +16,25 @@ describe("SharedHostSecretsRepository", () => { onWrite?: () => void | Promise, ): Promise<{ repository: SharedHostSecretsRepository; - sqlite: NonNullable< - Awaited>["sqlite"] - >; }> { adapter = new TestSqliteDatabase(); const context = await adapter.connect(); - context.sqlite!.exec(` - CREATE TABLE host_access ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - host_id INTEGER NOT NULL, - user_id TEXT, - role_id INTEGER, - granted_by TEXT NOT NULL, - permission_level TEXT NOT NULL DEFAULT 'connect', - expires_at TEXT, - created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, - last_accessed_at TEXT, - access_count INTEGER NOT NULL DEFAULT 0, - override_credential_id INTEGER - ); - - CREATE TABLE ssh_data ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - user_id TEXT NOT NULL, - name TEXT, - ip TEXT NOT NULL, - port INTEGER NOT NULL, - username TEXT NOT NULL, - credential_id INTEGER, - rdp_credential_id INTEGER, - vnc_credential_id INTEGER, - telnet_credential_id INTEGER - ); - - CREATE TABLE shared_host_secrets ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - host_access_id INTEGER NOT NULL, - target_user_id TEXT NOT NULL, - protocol TEXT NOT NULL DEFAULT 'ssh', - source_type TEXT NOT NULL DEFAULT 'credential', - original_credential_id INTEGER, - encrypted_username TEXT, - encrypted_auth_type TEXT, - encrypted_password TEXT, - encrypted_key TEXT, - encrypted_key_password TEXT, - encrypted_key_type TEXT, - encrypted_domain TEXT, - created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, - updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, - UNIQUE(host_access_id, target_user_id, protocol) - ); - - INSERT INTO ssh_data (id, user_id, name, ip, port, username, credential_id, rdp_credential_id) - VALUES - (42, 'owner-1', 'prod', '10.0.0.42', 22, 'root', 123, 124), - (43, 'owner-1', 'staging', '10.0.0.43', 22, 'root', NULL, NULL), - (44, 'owner-2', 'other', '10.0.0.44', 22, 'root', 123, NULL); - + await adapter.exec(` + INSERT INTO users (id, username, password_hash) VALUES + ('owner-1', 'owner-1', 'hash'), + ('owner-2', 'owner-2', 'hash'); + INSERT INTO users (id, username, password_hash) VALUES + ('user-1', 'user-1', 'hash'), + ('user-2', 'user-2', 'hash'); + INSERT INTO roles (id, name, display_name, is_system) VALUES + (7, 'role-7', 'Role 7', 0); + INSERT INTO ssh_credentials (id, user_id, name, username, auth_type) VALUES + (123, 'user-1', 'cred-123', 'root', 'password'), + (124, 'user-1', 'cred-124', 'root', 'password'); + INSERT INTO ssh_data (id, user_id, name, ip, port, username, credential_id, rdp_credential_id, auth_type) + VALUES (42, 'owner-1', 'prod', '10.0.0.42', 22, 'root', 123, 124, 'password'), + (43, 'owner-1', 'staging', '10.0.0.43', 22, 'root', NULL, NULL, 'password'), + (44, 'owner-2', 'other', '10.0.0.44', 22, 'root', 123, NULL, 'password'); INSERT INTO host_access (id, host_id, user_id, role_id, granted_by) VALUES (1, 42, 'user-1', NULL, 'owner-1'), @@ -84,7 +44,6 @@ describe("SharedHostSecretsRepository", () => { return { repository: new SharedHostSecretsRepository(context, onWrite), - sqlite: context.sqlite!, }; } diff --git a/src/backend/tests/database/repositories/snippet-repository.test.ts b/src/backend/tests/database/repositories/snippet-repository.test.ts index f4d51377..b7909c57 100644 --- a/src/backend/tests/database/repositories/snippet-repository.test.ts +++ b/src/backend/tests/database/repositories/snippet-repository.test.ts @@ -1,3 +1,4 @@ +import { sql } from "drizzle-orm"; import { afterEach, describe, expect, it, vi } from "vitest"; import { TestSqliteDatabase } from "./test-support.js"; import { SnippetRepository } from "../../../database/repositories/snippet-repository.js"; @@ -14,37 +15,13 @@ describe("SnippetRepository", () => { async function createRepository(onWrite?: () => void): Promise<{ repository: SnippetRepository; - sqlite: NonNullable< - Awaited>["sqlite"] - >; }> { adapter = new TestSqliteDatabase(); const context = await adapter.connect(); - context.sqlite?.exec(` - CREATE TABLE snippets ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - user_id TEXT NOT NULL, - name TEXT NOT NULL, - content TEXT NOT NULL, - description TEXT, - folder TEXT, - "order" INTEGER NOT NULL DEFAULT 0, - sync_id TEXT, - created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, - updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, - host_filter TEXT - ); - - CREATE TABLE snippet_folders ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - user_id TEXT NOT NULL, - name TEXT NOT NULL, - color TEXT, - icon TEXT, - sync_id TEXT, - created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, - updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP - ); + await adapter.exec(` + INSERT INTO users (id, username, password_hash) VALUES + ('user-1', 'user-1', 'hash'), + ('user-2', 'user-2', 'hash'); INSERT INTO snippets ( id, user_id, name, content, description, folder, "order", host_filter @@ -53,7 +30,6 @@ describe("SnippetRepository", () => { (1, 'user-1', 'root', 'uptime', NULL, NULL, 2, NULL), (2, 'user-1', 'deploy', 'make deploy', 'Deploy app', 'ops', 1, 'linux'), (3, 'user-2', 'other', 'whoami', NULL, NULL, 1, NULL); - INSERT INTO snippet_folders (id, user_id, name, color, icon) VALUES (1, 'user-1', 'ops', '#123456', 'terminal'), @@ -63,7 +39,6 @@ describe("SnippetRepository", () => { return { repository: new SnippetRepository(context, onWrite), - sqlite: context.sqlite!, }; } @@ -189,18 +164,18 @@ describe("SnippetRepository", () => { it("deletes all snippets and folders for a user", async () => { const onWrite = vi.fn(); - const { repository, sqlite } = await createRepository(onWrite); + const { repository } = await createRepository(onWrite); await expect(repository.deleteByUserId("user-1")).resolves.toEqual({ snippetsDeleted: 2, foldersDeleted: 2, }); - expect(sqlite.prepare("SELECT id FROM snippets ORDER BY id").all()).toEqual( - [{ id: 3 }], - ); expect( - sqlite.prepare("SELECT id FROM snippet_folders ORDER BY id").all(), + await adapter!.query(sql`SELECT id FROM snippets ORDER BY id`), + ).toEqual([{ id: 3 }]); + expect( + await adapter!.query(sql`SELECT id FROM snippet_folders ORDER BY id`), ).toEqual([{ id: 3 }]); expect(onWrite).toHaveBeenCalledTimes(1); }); diff --git a/src/backend/tests/database/repositories/sql-timestamp.test.ts b/src/backend/tests/database/repositories/sql-timestamp.test.ts new file mode 100644 index 00000000..7707ad75 --- /dev/null +++ b/src/backend/tests/database/repositories/sql-timestamp.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from "vitest"; +import { + formatSqlTimestamp, + sqlTimestampDaysAgo, +} from "../../../database/repositories/sql-timestamp.js"; + +describe("sql timestamps", () => { + it("matches the CURRENT_TIMESTAMP text format", () => { + expect(formatSqlTimestamp(new Date("2026-07-28T01:23:45.678Z"))).toBe( + "2026-07-28 01:23:45", + ); + }); + + it("subtracts whole days in UTC", () => { + const now = new Date("2026-07-28T01:23:45.000Z"); + + expect(sqlTimestampDaysAgo(7, now)).toBe("2026-07-21 01:23:45"); + expect(sqlTimestampDaysAgo(30, now)).toBe("2026-06-28 01:23:45"); + expect(sqlTimestampDaysAgo(0, now)).toBe("2026-07-28 01:23:45"); + }); + + it("crosses month and year boundaries", () => { + expect(sqlTimestampDaysAgo(1, new Date("2026-01-01T00:00:00.000Z"))).toBe( + "2025-12-31 00:00:00", + ); + }); + + it("stays lexicographically ordered, which is what the cutoff comparison relies on", () => { + const now = new Date("2026-07-28T01:23:45.000Z"); + const older = sqlTimestampDaysAgo(30, now); + const newer = sqlTimestampDaysAgo(7, now); + + expect(older < newer).toBe(true); + expect(newer < formatSqlTimestamp(now)).toBe(true); + }); +}); diff --git a/src/backend/tests/database/repositories/ssh-credential-usage-repository.test.ts b/src/backend/tests/database/repositories/ssh-credential-usage-repository.test.ts index 21b25aa0..9847b538 100644 --- a/src/backend/tests/database/repositories/ssh-credential-usage-repository.test.ts +++ b/src/backend/tests/database/repositories/ssh-credential-usage-repository.test.ts @@ -17,41 +17,13 @@ describe("SshCredentialUsageRepository", () => { ): Promise { adapter = new TestSqliteDatabase(); const context = await adapter.connect(); - context.sqlite?.exec(` - CREATE TABLE users ( - id TEXT PRIMARY KEY, - username TEXT NOT NULL, - password_hash TEXT NOT NULL, - is_admin INTEGER NOT NULL DEFAULT 0, - is_oidc INTEGER NOT NULL DEFAULT 0 - ); - - CREATE TABLE hosts ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - user_id TEXT NOT NULL, - name TEXT NOT NULL - ); - - CREATE TABLE ssh_credentials ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - user_id TEXT NOT NULL, - name TEXT NOT NULL - ); - - CREATE TABLE ssh_credential_usage ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - credential_id INTEGER NOT NULL, - host_id INTEGER NOT NULL, - user_id TEXT NOT NULL, - used_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP - ); - + await adapter.exec(` INSERT INTO users (id, username, password_hash) VALUES ('user-1', 'alice', 'hash'), ('user-2', 'bob', 'hash'); - INSERT INTO hosts (id, user_id, name) - VALUES (1, 'user-1', 'one'), (2, 'user-1', 'two'), (3, 'user-2', 'other'); - INSERT INTO ssh_credentials (id, user_id, name) - VALUES (1, 'user-1', 'cred-one'), (2, 'user-2', 'cred-two'); + INSERT INTO ssh_credentials (id, user_id, name, username, auth_type) + VALUES (1, 'user-1', 'cred-one', 'root', 'password'), (2, 'user-2', 'cred-two', 'root', 'password'); + INSERT INTO ssh_data (id, user_id, name, ip, port, username, auth_type) + VALUES (1, 'user-1', 'one', '10.0.0.1', 22, 'root', 'password'), (2, 'user-1', 'two', '10.0.0.1', 22, 'root', 'password'), (3, 'user-2', 'other', '10.0.0.1', 22, 'root', 'password'); `); return new SshCredentialUsageRepository(context, onWrite); diff --git a/src/backend/tests/database/repositories/sso-provider-repository.test.ts b/src/backend/tests/database/repositories/sso-provider-repository.test.ts index ec0d7cd0..7c0b78d5 100644 --- a/src/backend/tests/database/repositories/sso-provider-repository.test.ts +++ b/src/backend/tests/database/repositories/sso-provider-repository.test.ts @@ -4,13 +4,11 @@ import { SsoProviderRepository } from "../../../database/repositories/sso-provid describe("SsoProviderRepository", () => { let adapter: TestSqliteDatabase | null = null; - let sqlite: Awaited>["sqlite"]; afterEach(async () => { if (adapter) { await adapter.close(); adapter = null; - sqlite = undefined; } }); @@ -19,28 +17,6 @@ describe("SsoProviderRepository", () => { ): Promise { adapter = new TestSqliteDatabase(); const context = await adapter.connect(); - sqlite = context.sqlite; - context.sqlite?.exec(` - CREATE TABLE users ( - id TEXT PRIMARY KEY, - username TEXT NOT NULL, - password_hash TEXT NOT NULL, - is_admin INTEGER NOT NULL DEFAULT 0, - is_oidc INTEGER NOT NULL DEFAULT 0, - sso_provider_id INTEGER - ); - - CREATE TABLE sso_providers ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - name TEXT NOT NULL, - type TEXT NOT NULL, - enabled INTEGER NOT NULL DEFAULT 1, - display_order INTEGER NOT NULL DEFAULT 0, - config TEXT NOT NULL, - created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, - updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP - ); - `); return new SsoProviderRepository(context, onWrite); } @@ -96,7 +72,7 @@ describe("SsoProviderRepository", () => { config: "{}", }); - sqlite?.exec(` + await adapter!.exec(` INSERT INTO users (id, username, password_hash, sso_provider_id) VALUES ('user-1', 'u1', 'hash', ${provider.id}), ('user-2', 'u2', 'hash', ${provider.id}), diff --git a/src/backend/tests/database/repositories/sync-tombstone-repository.test.ts b/src/backend/tests/database/repositories/sync-tombstone-repository.test.ts index cd85f2d9..d03deb22 100644 --- a/src/backend/tests/database/repositories/sync-tombstone-repository.test.ts +++ b/src/backend/tests/database/repositories/sync-tombstone-repository.test.ts @@ -17,21 +17,7 @@ describe("SyncTombstoneRepository", () => { ): Promise { adapter = new TestSqliteDatabase(); const context = await adapter.connect(); - context.sqlite?.exec(` - CREATE TABLE users ( - id TEXT PRIMARY KEY, - username TEXT NOT NULL, - password_hash TEXT NOT NULL - ); - - CREATE TABLE sync_tombstones ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - user_id TEXT NOT NULL, - entity_type TEXT NOT NULL, - sync_id TEXT NOT NULL, - deleted_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP - ); - + await adapter.exec(` INSERT INTO users (id, username, password_hash) VALUES ('user-1', 'alice', 'hash'), ('user-2', 'bob', 'hash'); `); @@ -102,24 +88,9 @@ describe("SyncTombstoneRepository", () => { const adapterLocal = new TestSqliteDatabase(); adapter = adapterLocal; const context = await adapterLocal.connect(); - context.sqlite?.exec(` - CREATE TABLE users ( - id TEXT PRIMARY KEY, - username TEXT NOT NULL, - password_hash TEXT NOT NULL - ); - - CREATE TABLE sync_tombstones ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - user_id TEXT NOT NULL, - entity_type TEXT NOT NULL, - sync_id TEXT NOT NULL, - deleted_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP - ); - + await adapter.exec(` INSERT INTO users (id, username, password_hash) VALUES ('user-1', 'alice', 'hash'); - INSERT INTO sync_tombstones (user_id, entity_type, sync_id, deleted_at) VALUES ('user-1', 'hosts', 'old', '2026-01-01T00:00:00.000Z'), diff --git a/src/backend/tests/database/repositories/sync-tombstone-since.test.ts b/src/backend/tests/database/repositories/sync-tombstone-since.test.ts new file mode 100644 index 00000000..89caed12 --- /dev/null +++ b/src/backend/tests/database/repositories/sync-tombstone-since.test.ts @@ -0,0 +1,111 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { TestSqliteDatabase } from "./test-support.js"; +import { SyncTombstoneRepository } from "../../../database/repositories/sync-tombstone-repository.js"; +import { + normalizeSyncTimestamp, + timestampAtOrAfter, +} from "../../../database/sync-timestamp.js"; +import { sshCredentials } from "../../../database/db/schema.js"; +import type { DatabaseContext } from "../../../database/repositories/database-context.js"; +import { and, eq } from "drizzle-orm"; + +// The desktop sync engine always sends its cursor as new Date().toISOString(). +const ISO_CURSOR = "2026-07-29T09:00:00.000Z"; + +describe("sync cursors across timestamp layouts", () => { + let adapter: TestSqliteDatabase | null = null; + + afterEach(async () => { + if (adapter) { + await adapter.close(); + adapter = null; + } + }); + + /** + * The harness migrates the schema itself, so the seeds below are INSERTs into + * the real tables. Both `sync_tombstones.user_id` and `ssh_credentials.user_id` + * are foreign keys into `users`, which the harness enforces, so the owning row + * has to exist before either seed runs. + */ + async function connect(): Promise<{ + db: TestSqliteDatabase; + context: DatabaseContext; + }> { + const db = new TestSqliteDatabase(); + adapter = db; + const context = await db.connect(); + await db.exec(` + INSERT INTO users (id, username, password_hash) VALUES + ('user-1', 'user', 'hash'); + `); + return { db, context }; + } + + it("normalizes both layouts to one comparable form", () => { + expect(normalizeSyncTimestamp("2026-07-29T10:11:21.123Z")).toBe( + "2026-07-29 10:11:21", + ); + expect(normalizeSyncTimestamp("2026-07-29 10:11:21")).toBe( + "2026-07-29 10:11:21", + ); + }); + + it("returns tombstones recorded after an ISO cursor, whatever layout they were stored in", async () => { + const { db, context } = await connect(); + await db.exec(` + INSERT INTO sync_tombstones (user_id, entity_type, sync_id, deleted_at) VALUES + ('user-1', 'sshCredentials', 'sqlite-layout', '2026-07-29 10:07:32'), + ('user-1', 'sshCredentials', 'iso-layout', '2026-07-29T10:07:32.500Z'), + ('user-1', 'sshCredentials', 'too-old', '2026-07-29 08:00:00'); + `); + + const repo = new SyncTombstoneRepository(context); + const rows = await repo.listSince("user-1", "sshCredentials", ISO_CURSOR); + + expect(rows.map((row) => row.syncId).sort()).toEqual([ + "iso-layout", + "sqlite-layout", + ]); + }); + + it("returns rows written by CURRENT_TIMESTAMP against an ISO cursor", async () => { + const { db, context } = await connect(); + await db.exec(` + INSERT INTO ssh_credentials (user_id, name, auth_type, updated_at) VALUES + ('user-1', 'newer-sqlite-layout', 'password', '2026-07-29 10:11:21'), + ('user-1', 'newer-iso-layout', 'password', '2026-07-29T10:11:21.123Z'), + ('user-1', 'older', 'password', '2026-07-29 08:59:59'); + `); + + const rows = await context.drizzle + .select({ name: sshCredentials.name }) + .from(sshCredentials) + .where( + and( + eq(sshCredentials.userId, "user-1"), + timestampAtOrAfter(sshCredentials.updatedAt, ISO_CURSOR), + ), + ); + + expect(rows.map((row) => row.name).sort()).toEqual([ + "newer-iso-layout", + "newer-sqlite-layout", + ]); + }); + + it("keeps rows written in the same second as the cursor", async () => { + const { db, context } = await connect(); + await db.exec(` + INSERT INTO ssh_credentials (user_id, name, auth_type, updated_at) + VALUES ('user-1', 'same-second', 'password', '2026-07-29 09:00:00'); + `); + + const rows = await context.drizzle + .select({ name: sshCredentials.name }) + .from(sshCredentials) + .where(timestampAtOrAfter(sshCredentials.updatedAt, ISO_CURSOR)); + + expect(rows.map((row) => row.name)).toEqual(["same-second"]); + }); +}); diff --git a/src/backend/tests/database/repositories/termix-identity-ca-repository.test.ts b/src/backend/tests/database/repositories/termix-identity-ca-repository.test.ts index 26d2119d..ce099d53 100644 --- a/src/backend/tests/database/repositories/termix-identity-ca-repository.test.ts +++ b/src/backend/tests/database/repositories/termix-identity-ca-repository.test.ts @@ -1,4 +1,5 @@ -import { afterEach, describe, expect, it, vi } from "vitest"; +import { sql } from "drizzle-orm"; +import { afterEach, describe, expect, vi } from "vitest"; import { TestSqliteDatabase } from "./test-support.js"; import { DataCrypto } from "../../../utils/data-crypto.js"; import { TermixIdentityCaRepository } from "../../../database/repositories/termix-identity-ca-repository.js"; @@ -16,45 +17,11 @@ describe("TermixIdentityCaRepository", () => { async function createRepository(onWrite = vi.fn()): Promise<{ repo: TermixIdentityCaRepository; - sqlite: NonNullable< - Awaited>["sqlite"] - >; onWrite: ReturnType; }> { adapter = new TestSqliteDatabase(); const context = await adapter.connect(); - context.sqlite?.exec(` - CREATE TABLE users ( - id TEXT PRIMARY KEY, - username TEXT NOT NULL, - password_hash TEXT NOT NULL, - is_admin INTEGER NOT NULL DEFAULT 0, - is_oidc INTEGER NOT NULL DEFAULT 0 - ); - - CREATE TABLE termix_identities ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - user_id TEXT NOT NULL UNIQUE, - handle TEXT NOT NULL UNIQUE, - description TEXT, - created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, - updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, - FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE - ); - - CREATE TABLE termix_identity_ca ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - identity_id INTEGER NOT NULL UNIQUE, - user_id TEXT NOT NULL, - public_key TEXT NOT NULL, - private_key TEXT NOT NULL, - validity_days INTEGER NOT NULL DEFAULT 90, - created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, - updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, - FOREIGN KEY (identity_id) REFERENCES termix_identities(id) ON DELETE CASCADE, - FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE - ); - + await adapter.exec(` INSERT INTO users (id, username, password_hash) VALUES ('user-1', 'alice', 'hash'); INSERT INTO termix_identities (id, user_id, handle) @@ -63,7 +30,6 @@ describe("TermixIdentityCaRepository", () => { return { repo: new TermixIdentityCaRepository(context, onWrite), - sqlite: context.sqlite!, onWrite, }; } @@ -95,7 +61,7 @@ describe("TermixIdentityCaRepository", () => { } it("creates CA private keys with the real row id before encryption", async () => { - const { repo, sqlite, onWrite } = await createRepository(); + const { repo, onWrite } = await createRepository(); mockCrypto(); const created = await repo.createEncryptedForUser("user-1", { @@ -106,16 +72,14 @@ describe("TermixIdentityCaRepository", () => { validityDays: 120, }); - const raw = sqlite - .prepare( - "SELECT id, public_key, private_key, validity_days FROM termix_identity_ca WHERE identity_id = ?", - ) - .get(7) as { + const [raw] = (await adapter!.query( + sql`SELECT id, public_key, private_key, validity_days FROM termix_identity_ca WHERE identity_id = 7`, + )) as { id: number; public_key: string; private_key: string; validity_days: number; - }; + }[]; expect(created.privateKey).toBe("decrypted-ca-private"); expect(raw.private_key).toBe("encrypted-ca-private"); @@ -131,13 +95,12 @@ describe("TermixIdentityCaRepository", () => { }); it("reads public CA metadata without decrypting private key material", async () => { - const { repo, sqlite } = await createRepository(); + const { repo } = await createRepository(); const decryptSpy = vi.spyOn(DataCrypto, "decryptRecord"); - sqlite - .prepare( - "INSERT INTO termix_identity_ca (identity_id, user_id, public_key, private_key, validity_days) VALUES (?, ?, ?, ?, ?)", - ) - .run(7, "user-1", "ssh-ed25519 public", "encrypted-ca-private", 45); + await adapter!.run( + sql`INSERT INTO termix_identity_ca (identity_id, user_id, public_key, private_key, validity_days) + VALUES (7, 'user-1', 'ssh-ed25519 public', 'encrypted-ca-private', 45)`, + ); await expect(repo.findPublicByIdentityId(7)).resolves.toEqual({ publicKey: "ssh-ed25519 public", @@ -147,13 +110,12 @@ describe("TermixIdentityCaRepository", () => { }); it("decrypts CA private keys through the user data boundary", async () => { - const { repo, sqlite } = await createRepository(); + const { repo } = await createRepository(); mockCrypto(); - sqlite - .prepare( - "INSERT INTO termix_identity_ca (identity_id, user_id, public_key, private_key, validity_days) VALUES (?, ?, ?, ?, ?)", - ) - .run(7, "user-1", "ssh-ed25519 public", "encrypted-ca-private", 45); + await adapter!.run( + sql`INSERT INTO termix_identity_ca (identity_id, user_id, public_key, private_key, validity_days) + VALUES (7, 'user-1', 'ssh-ed25519 public', 'encrypted-ca-private', 45)`, + ); const ca = await repo.findDecryptedByIdentityId("user-1", 7); @@ -172,13 +134,11 @@ describe("TermixIdentityCaRepository", () => { }); it("updates CA private keys through encrypted writes", async () => { - const { repo, sqlite, onWrite } = await createRepository(); + const { repo, onWrite } = await createRepository(); mockCrypto(); - sqlite - .prepare( - "INSERT INTO termix_identity_ca (identity_id, user_id, public_key, private_key, validity_days) VALUES (?, ?, ?, ?, ?)", - ) - .run(7, "user-1", "ssh-ed25519 old", "encrypted-ca-private", 45); + await adapter!.run( + sql`INSERT INTO termix_identity_ca (identity_id, user_id, public_key, private_key, validity_days) VALUES (7, 'user-1', 'ssh-ed25519 old', 'encrypted-ca-private', 45)`, + ); onWrite.mockClear(); const updated = await repo.updateEncryptedForIdentity("user-1", 7, { @@ -187,15 +147,13 @@ describe("TermixIdentityCaRepository", () => { validityDays: 90, }); - const raw = sqlite - .prepare( - "SELECT public_key, private_key, validity_days FROM termix_identity_ca WHERE identity_id = ?", - ) - .get(7) as { + const [raw] = (await adapter!.query( + sql`SELECT public_key, private_key, validity_days FROM termix_identity_ca WHERE identity_id = 7`, + )) as { public_key: string; private_key: string; validity_days: number; - }; + }[]; expect(updated).toMatchObject({ publicKey: "ssh-ed25519 new", @@ -219,55 +177,47 @@ describe("TermixIdentityCaRepository", () => { }); it("deletes CA rows through the write boundary", async () => { - const { repo, sqlite, onWrite } = await createRepository(); - sqlite - .prepare( - "INSERT INTO termix_identity_ca (identity_id, user_id, public_key, private_key, validity_days) VALUES (?, ?, ?, ?, ?)", - ) - .run(7, "user-1", "ssh-ed25519 public", "encrypted-ca-private", 45); + const { repo, onWrite } = await createRepository(); + await adapter!.run( + sql`INSERT INTO termix_identity_ca (identity_id, user_id, public_key, private_key, validity_days) VALUES (7, 'user-1', 'ssh-ed25519 public', 'encrypted-ca-private', 45)`, + ); onWrite.mockClear(); await expect(repo.deleteByIdentityId(7)).resolves.toBe(true); await expect(repo.deleteByIdentityId(7)).resolves.toBe(false); expect( - sqlite.prepare("SELECT COUNT(*) AS count FROM termix_identity_ca").get(), - ).toEqual({ count: 0 }); + ( + await adapter!.query( + sql`SELECT COUNT(*) AS count FROM termix_identity_ca`, + ) + ).map((row) => Number((row as { count: unknown }).count)), + ).toEqual([0]); expect(onWrite).toHaveBeenCalledTimes(1); }); it("deletes CA rows for a user", async () => { - const { repo, sqlite, onWrite } = await createRepository(); - sqlite - .prepare( - "INSERT INTO users (id, username, password_hash) VALUES (?, ?, ?)", - ) - .run("user-2", "bob", "hash"); - sqlite - .prepare( - "INSERT INTO termix_identities (id, user_id, handle) VALUES (?, ?, ?)", - ) - .run(8, "user-2", "bob"); - sqlite - .prepare( - "INSERT INTO termix_identity_ca (identity_id, user_id, public_key, private_key, validity_days) VALUES (?, ?, ?, ?, ?)", - ) - .run(7, "user-1", "ssh-ed25519 public", "encrypted-ca-private", 45); - sqlite - .prepare( - "INSERT INTO termix_identity_ca (identity_id, user_id, public_key, private_key, validity_days) VALUES (?, ?, ?, ?, ?)", - ) - .run(8, "user-2", "ssh-ed25519 other", "encrypted-other", 90); + const { repo, onWrite } = await createRepository(); + await adapter!.run( + sql`INSERT INTO users (id, username, password_hash) VALUES ('user-2', 'bob', 'hash')`, + ); + await adapter!.run( + sql`INSERT INTO termix_identities (id, user_id, handle) VALUES (8, 'user-2', 'bob')`, + ); + await adapter!.run( + sql`INSERT INTO termix_identity_ca (identity_id, user_id, public_key, private_key, validity_days) VALUES (7, 'user-1', 'ssh-ed25519 public', 'encrypted-ca-private', 45)`, + ); + await adapter!.run( + sql`INSERT INTO termix_identity_ca (identity_id, user_id, public_key, private_key, validity_days) VALUES (8, 'user-2', 'ssh-ed25519 other', 'encrypted-other', 90)`, + ); onWrite.mockClear(); await expect(repo.deleteByUserId("user-1")).resolves.toBe(1); await expect(repo.deleteByUserId("missing")).resolves.toBe(0); expect( - sqlite - .prepare( - "SELECT user_id, public_key FROM termix_identity_ca ORDER BY user_id", - ) - .all(), + await adapter!.query( + sql`SELECT user_id, public_key FROM termix_identity_ca ORDER BY user_id`, + ), ).toEqual([{ user_id: "user-2", public_key: "ssh-ed25519 other" }]); expect(onWrite).toHaveBeenCalledTimes(1); }); diff --git a/src/backend/tests/database/repositories/termix-identity-repository.test.ts b/src/backend/tests/database/repositories/termix-identity-repository.test.ts index b9c2ad21..c801faf8 100644 --- a/src/backend/tests/database/repositories/termix-identity-repository.test.ts +++ b/src/backend/tests/database/repositories/termix-identity-repository.test.ts @@ -18,44 +18,12 @@ describe("TermixIdentityRepository", () => { }> { adapter = new TestSqliteDatabase(); const context = await adapter.connect(); - context.sqlite?.exec(` - CREATE TABLE users ( - id TEXT PRIMARY KEY, - username TEXT NOT NULL, - password_hash TEXT NOT NULL, - is_admin INTEGER NOT NULL DEFAULT 0, - is_oidc INTEGER NOT NULL DEFAULT 0 - ); - - CREATE TABLE termix_identities ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - user_id TEXT NOT NULL UNIQUE, - handle TEXT NOT NULL UNIQUE, - description TEXT, - created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, - updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, - FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE - ); - - CREATE TABLE termix_identity_keys ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - identity_id INTEGER NOT NULL, - user_id TEXT NOT NULL, - public_key TEXT NOT NULL, - key_type TEXT NOT NULL, - algorithm TEXT NOT NULL, - label TEXT, - comment TEXT, - source TEXT NOT NULL DEFAULT 'manual', - credential_id INTEGER, - enabled INTEGER NOT NULL DEFAULT 1, - created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, - FOREIGN KEY (identity_id) REFERENCES termix_identities(id) ON DELETE CASCADE, - FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE - ); - + await adapter.exec(` INSERT INTO users (id, username, password_hash) VALUES ('user-1', 'alice', 'hash'), ('user-2', 'bob', 'hash'); + INSERT INTO ssh_credentials (id, user_id, name, username, auth_type) VALUES + (10, 'user-1', 'cred-10', 'root', 'password'), + (20, 'user-1', 'cred-20', 'root', 'password'); `); return { diff --git a/src/backend/tests/database/repositories/test-support.ts b/src/backend/tests/database/repositories/test-support.ts index 6aab29fd..d44b66ae 100644 --- a/src/backend/tests/database/repositories/test-support.ts +++ b/src/backend/tests/database/repositories/test-support.ts @@ -1,31 +1,452 @@ import Database from "better-sqlite3"; import { drizzle } from "drizzle-orm/better-sqlite3"; +import { + getTableColumns, + getTableName, + is, + sql, + Table, + type SQL, +} from "drizzle-orm"; +import fs from "fs"; +import path from "path"; import * as schema from "../../../database/db/schema.js"; import type { DatabaseContext } from "../../../database/repositories/database-context.js"; +import type { DatabaseDialect } from "../../../database/db/dialect.js"; + +/** + * Which engine the repository tests run against. + * + * Defaults to SQLite, so `npm test` behaves as it always has and needs no + * server. Set TEST_DIALECT=postgres or mysql, plus TEST_DATABASE_URL, to run + * the same tests against a real one — see the database-dialects CI job. + */ +export function testDialect(env = process.env): DatabaseDialect { + const value = env.TEST_DIALECT?.trim().toLowerCase(); + if (value === "postgres" || value === "mysql") return value; + return "sqlite"; +} + +/** Every table drizzle knows about, for wiping between tests. */ +function allTableNames(): string[] { + return Object.values(schema) + .filter((value) => is(value, Table)) + .map((table) => getTableName(table as Table)); +} export class TestSqliteDatabase { private sqlite: Database.Database | null = null; private context: DatabaseContext | null = null; + private readonly dialect: DatabaseDialect; + + constructor(dialect: DatabaseDialect = testDialect()) { + this.dialect = dialect; + } async connect(): Promise { if (this.context) return this.context; + if (this.dialect !== "sqlite") { + this.context = await this.connectRemote(); + return this.context; + } + this.sqlite = new Database(":memory:"); this.sqlite.exec("PRAGMA foreign_keys = ON"); + this.sqlite.exec(sqliteSchemaSql()); this.context = { dialect: "sqlite", drizzle: drizzle(this.sqlite, { schema }), - sqlite: this.sqlite, }; return this.context; } + private async connectRemote(): Promise { + const url = process.env.TEST_DATABASE_URL; + if (!url) { + throw new Error( + `TEST_DIALECT=${this.dialect} requires TEST_DATABASE_URL to be set.`, + ); + } + + const { drizzle: connect } = await import( + this.dialect === "postgres" + ? "drizzle-orm/node-postgres" + : "drizzle-orm/mysql2" + ); + const db = connect(url) as unknown as DatabaseContext["drizzle"]; + const context: DatabaseContext = { dialect: this.dialect, drizzle: db }; + + await migrateOnce(this.dialect, db); + await truncateAll(context); + + return context; + } + + /** + * Runs seed SQL. Synchronous on SQLite, which is what the tests were written + * against; on the other engines it returns a promise the caller must await. + * + * The seeds are plain INSERTs, portable apart from identifier quoting, which + * `portableSql` fixes up. + */ + exec(statements: string): void | Promise { + if (this.sqlite) { + this.sqlite.exec(statements); + return; + } + const context = this.context; + if (!context) throw new Error("connect() must be called before exec()"); + + return (async () => { + const touched = new Set(); + for (const statement of splitStatements(statements)) { + await runSql(context, sql.raw(portableSql(statement, context.dialect))); + const table = /INSERT INTO\s+([a-z_]+)/i.exec(statement)?.[1]; + if (table) touched.add(table); + } + await resyncAutoIncrement(context, touched); + })(); + } + + /** + * Portable read for assertions. Build the statement with drizzle's `sql` + * template so placeholders and quoting come out right on each engine. + */ + async query>(statement: SQL): Promise { + if (!this.context) + throw new Error("connect() must be called before query()"); + return runSql(this.context, statement); + } + + /** + * Portable write for test setup. + * + * Separate from query() because better-sqlite3 refuses `.all()` on a + * statement that returns no rows — "This statement does not return data". + */ + async run(statement: SQL): Promise { + const context = this.context; + if (!context) throw new Error("connect() must be called before run()"); + + if (this.sqlite) { + (context.drizzle as unknown as { run: (s: SQL) => unknown }).run( + statement, + ); + return; + } + await runSql(context, statement); + } + async close(): Promise { if (this.sqlite) { this.sqlite.close(); this.sqlite = null; - this.context = null; + } + this.context = null; + } +} + +async function runSql( + context: DatabaseContext, + statement: SQL, +): Promise { + const db = context.drizzle as unknown as { + all?: (s: SQL) => Promise | T[]; + execute?: (s: SQL) => Promise; + }; + + if (context.dialect === "sqlite" && db.all) { + return (await db.all(statement)) as T[]; + } + + const result = (await db.execute!(statement)) as + { rows?: T[] } | T[] | undefined; + + // mysql2 answers [rows, fields]; node-postgres answers { rows }. + if (Array.isArray(result)) { + return (Array.isArray(result[0]) ? result[0] : result) as T[]; + } + return (result?.rows ?? []) as T[]; +} + +/** + * Empties every table between tests on the client-server engines, where the + * database outlives the process and cannot be thrown away like an in-memory + * SQLite one. + */ +async function truncateAll(context: DatabaseContext): Promise { + const tables = allTableNames(); + + if (context.dialect === "postgres") { + const list = tables.map((t) => `"${t}"`).join(", "); + await runSql( + context, + sql.raw(`TRUNCATE TABLE ${list} RESTART IDENTITY CASCADE`), + ); + return; + } + + // Truncating all 53 tables takes ~2s on MySQL, which every test would pay. + // Ask which ones actually hold rows first: after the first test only a + // handful do, and the check is a single query. + // Each branch is parenthesised: LIMIT binds to the whole UNION otherwise. + const counts = tables + .map((t) => `(SELECT '${t}' AS name FROM \`${t}\` LIMIT 1)`) + .join(" UNION ALL "); + const occupied = await runSql<{ name: string }>(context, sql.raw(counts)); + if (occupied.length === 0) return; + + await runSql(context, sql.raw("SET FOREIGN_KEY_CHECKS = 0")); + for (const { name } of occupied) { + await runSql(context, sql.raw(`TRUNCATE TABLE \`${name}\``)); + } + await runSql(context, sql.raw("SET FOREIGN_KEY_CHECKS = 1")); +} + +/** + * Splits seed SQL into statements, ignoring semicolons inside string literals — + * JSON payloads in the fixtures contain them. + */ +function splitStatements(sql: string): string[] { + const out: string[] = []; + let current = ""; + let inString = false; + + for (let i = 0; i < sql.length; i++) { + const ch = sql[i]; + if (ch === "'") { + // '' is an escaped quote inside a string, not a delimiter. + if (inString && sql[i + 1] === "'") { + current += "''"; + i++; + continue; + } + inString = !inString; + } + if (ch === ";" && !inString) { + if (current.trim()) out.push(current.trim()); + current = ""; + continue; + } + current += ch; + } + if (current.trim()) out.push(current.trim()); + return out; +} + +let cachedBooleanColumns: Set | null = null; + +/** + * Columns the schema declares as booleans, by table.column. + * + * Read from drizzle rather than listed here, so a new boolean column needs no + * change in this file. + */ +function booleanColumns(): Set { + if (cachedBooleanColumns) return cachedBooleanColumns; + + const found = new Set(); + for (const value of Object.values(schema)) { + if (!is(value, Table)) continue; + const table = getTableName(value as Table); + for (const column of Object.values(getTableColumns(value as Table))) { + if (column.dataType === "boolean") found.add(`${table}.${column.name}`); + } + } + cachedBooleanColumns = found; + return found; +} + +/** + * Seeds are written in SQLite's dialect. Two things do not carry: + * + * - a reserved word used as a column name is `"order"` on SQLite and Postgres, + * `` `order` `` on MySQL + * - SQLite stores booleans as 0/1, and writing an integer into a native boolean + * column is an error on Postgres. Every engine understands the TRUE/FALSE + * keywords, so boolean columns are rewritten to those. + */ +function portableSql(statement: string, dialect: DatabaseDialect): string { + const out = rewriteBooleanLiterals(statement); + if (dialect !== "mysql") return out; + + // Only the column list, before VALUES. A blanket replace also mangles the + // double quotes inside JSON payloads in the values — '{"slots":[]}' became + // '{`slots`:[]}', which is valid SQL and silently wrong data. + const split = /^(.*?\bVALUES\b)(.*)$/is.exec(out); + if (!split) return out.replace(/"([a-z_]+)"/g, "`$1`"); + return split[1].replace(/"([a-z_]+)"/g, "`$1`") + split[2]; +} + +/** Rewrites 0/1 to FALSE/TRUE in the value positions of boolean columns. */ +function rewriteBooleanLiterals(statement: string): string { + const booleans = booleanColumns(); + + return statement.replace( + /INSERT INTO\s+([a-z_]+)\s*\(([^)]*)\)\s*VALUES\s*((?:\([^()]*\)\s*,?\s*)+)/gis, + (whole, table: string, cols: string, values: string) => { + const names = cols.split(",").map((c) => c.trim().replace(/["`]/g, "")); + const flags = names.map((n) => booleans.has(`${table}.${n}`)); + if (!flags.some(Boolean)) return whole; + + const rewritten = values.replace( + /\(([^()]*)\)/g, + (row, inner: string) => { + const parts = splitValues(inner); + return `(${parts + .map((v, i) => + flags[i] && /^[01]$/.test(v.trim()) + ? v.trim() === "1" + ? "TRUE" + : "FALSE" + : v, + ) + .join(",")})`; + }, + ); + return `INSERT INTO ${table} (${cols}) VALUES ${rewritten}`; + }, + ); +} + +/** Splits a VALUES row on commas that are not inside a string literal. */ +function splitValues(row: string): string[] { + const parts: string[] = []; + let current = ""; + let inString = false; + for (let i = 0; i < row.length; i++) { + const ch = row[i]; + if (ch === "'") { + if (inString && row[i + 1] === "'") { + current += "''"; + i++; + continue; + } + inString = !inString; + } + if (ch === "," && !inString) { + parts.push(current); + current = ""; + continue; + } + current += ch; + } + parts.push(current); + return parts; +} + +/** + * Moves each table's id generator past the ids the seed inserted by hand. + * + * SQLite picks `max(id) + 1` when a row omits the key, so a fixture that writes + * `id = 1, 2, 3` and then lets the repository insert one more just works. A + * Postgres sequence or a MySQL auto_increment counter does not know about rows + * inserted with an explicit id, so it hands out 1 again and the insert collides + * with the fixture's own data. + */ +async function resyncAutoIncrement( + context: DatabaseContext, + tables: Set, +): Promise { + for (const table of tables) { + // Only tables whose id is generated. A text primary key, like users.id, + // has no sequence and no counter to move. + if (context.dialect === "postgres") { + const [seq] = await runSql<{ name: string | null }>( + context, + sql.raw(`SELECT pg_get_serial_sequence('${table}', 'id') AS name`), + ); + if (!seq?.name) continue; + + await runSql( + context, + sql.raw( + `SELECT setval('${seq.name}', ` + + `COALESCE((SELECT MAX(id) FROM "${table}"), 0) + 1, false)`, + ), + ); + continue; + } + + const [column] = await runSql<{ extra: string }>( + context, + sql.raw( + `SELECT EXTRA AS extra FROM information_schema.columns ` + + `WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = '${table}' ` + + `AND COLUMN_NAME = 'id'`, + ), + ); + if (!column?.extra?.includes("auto_increment")) continue; + + const [row] = await runSql<{ next: number | null }>( + context, + sql.raw(`SELECT MAX(id) + 1 AS next FROM \`${table}\``), + ); + if (row?.next) { + await runSql( + context, + sql.raw(`ALTER TABLE \`${table}\` AUTO_INCREMENT = ${row.next}`), + ); } } } + +/** + * Migrations run once per worker, not once per fixture. + * + * Every test builds a fixture, and each would otherwise re-run the migrator + * against the same shared database. drizzle's journal makes that a no-op only + * when the first run finished — several fixtures racing inside one file hit + * "table already exists" instead. + */ +const migrations = new Map>(); + +function migrateOnce( + dialect: DatabaseDialect, + db: DatabaseContext["drizzle"], +): Promise { + const key = `${dialect}:${process.env.TEST_DATABASE_URL}`; + let running = migrations.get(key); + if (!running) { + running = (async () => { + const { runRemoteMigrations } = + await import("../../../database/db/migrate.js"); + await runRemoteMigrations(dialect, db); + })(); + migrations.set(key, running); + } + return running; +} + +let cachedSqliteSchema: string | null = null; + +/** + * The full schema, from the generated SQLite migration rather than hand-written + * DDL in each test file. + * + * Tests used to declare a cut-down version of every table they touched — a + * `users` with five columns where the real one has thirty. That drifts from the + * schema silently, and it is the reason the same tests could not be pointed at + * another engine. + */ +function sqliteSchemaSql(): string { + if (cachedSqliteSchema) return cachedSqliteSchema; + + const dir = path.resolve(process.cwd(), "drizzle", "sqlite"); + const file = fs + .readdirSync(dir) + .filter((name) => name.endsWith(".sql")) + .sort() + .at(-1); + + if (!file) throw new Error(`No SQLite migration found in ${dir}`); + + cachedSqliteSchema = fs + .readFileSync(path.join(dir, file), "utf8") + .split("--> statement-breakpoint") + .join("\n"); + + return cachedSqliteSchema; +} diff --git a/src/backend/tests/database/repositories/tmux-session-tag-repository.test.ts b/src/backend/tests/database/repositories/tmux-session-tag-repository.test.ts index a866b54d..f55be5b3 100644 --- a/src/backend/tests/database/repositories/tmux-session-tag-repository.test.ts +++ b/src/backend/tests/database/repositories/tmux-session-tag-repository.test.ts @@ -17,32 +17,11 @@ describe("TmuxSessionTagRepository", () => { ): Promise { adapter = new TestSqliteDatabase(); const context = await adapter.connect(); - context.sqlite?.exec(` - CREATE TABLE users ( - id TEXT PRIMARY KEY, - username TEXT NOT NULL, - password_hash TEXT NOT NULL - ); - - CREATE TABLE hosts ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - user_id TEXT NOT NULL, - name TEXT NOT NULL - ); - - CREATE TABLE tmux_session_tags ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - user_id TEXT NOT NULL, - host_id INTEGER NOT NULL, - session_name TEXT NOT NULL, - tag TEXT NOT NULL, - created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP - ); - + await adapter.exec(` INSERT INTO users (id, username, password_hash) VALUES ('user-1', 'alice', 'hash'), ('user-2', 'bob', 'hash'); - INSERT INTO hosts (id, user_id, name) - VALUES (1, 'user-1', 'one'), (2, 'user-2', 'two'); + INSERT INTO ssh_data (id, user_id, name, ip, port, username, auth_type) + VALUES (1, 'user-1', 'one', '10.0.0.1', 22, 'root', 'password'), (2, 'user-2', 'two', '10.0.0.1', 22, 'root', 'password'); INSERT INTO tmux_session_tags (user_id, host_id, session_name, tag) VALUES ('user-1', 1, 'api', 'prod'), diff --git a/src/backend/tests/database/repositories/transfer-recent-repository.test.ts b/src/backend/tests/database/repositories/transfer-recent-repository.test.ts index 5ad97830..65d2eccb 100644 --- a/src/backend/tests/database/repositories/transfer-recent-repository.test.ts +++ b/src/backend/tests/database/repositories/transfer-recent-repository.test.ts @@ -17,33 +17,11 @@ describe("TransferRecentRepository", () => { ): Promise { adapter = new TestSqliteDatabase(); const context = await adapter.connect(); - context.sqlite?.exec(` - CREATE TABLE users ( - id TEXT PRIMARY KEY, - username TEXT NOT NULL, - password_hash TEXT NOT NULL - ); - - CREATE TABLE hosts ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - user_id TEXT NOT NULL, - name TEXT NOT NULL - ); - - CREATE TABLE transfer_recent ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - user_id TEXT NOT NULL, - source_host_id INTEGER NOT NULL, - dest_host_id INTEGER NOT NULL, - dest_path TEXT NOT NULL, - dest_path_label TEXT NOT NULL, - last_used TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP - ); - + await adapter.exec(` INSERT INTO users (id, username, password_hash) VALUES ('user-1', 'alice', 'hash'), ('user-2', 'bob', 'hash'); - INSERT INTO hosts (id, user_id, name) - VALUES (1, 'user-1', 'source'), (2, 'user-1', 'dest-a'), (3, 'user-1', 'dest-b'), (4, 'user-2', 'other'); + INSERT INTO ssh_data (id, user_id, name, ip, port, username, auth_type) + VALUES (1, 'user-1', 'source', '10.0.0.1', 22, 'root', 'password'), (2, 'user-1', 'dest-a', '10.0.0.1', 22, 'root', 'password'), (3, 'user-1', 'dest-b', '10.0.0.1', 22, 'root', 'password'), (4, 'user-2', 'other', '10.0.0.1', 22, 'root', 'password'); `); return new TransferRecentRepository(context, onWrite); diff --git a/src/backend/tests/database/repositories/trusted-device-repository.test.ts b/src/backend/tests/database/repositories/trusted-device-repository.test.ts index 290bc10c..80e7c068 100644 --- a/src/backend/tests/database/repositories/trusted-device-repository.test.ts +++ b/src/backend/tests/database/repositories/trusted-device-repository.test.ts @@ -17,27 +17,7 @@ describe("TrustedDeviceRepository", () => { }> { adapter = new TestSqliteDatabase(); const context = await adapter.connect(); - context.sqlite?.exec(` - CREATE TABLE users ( - id TEXT PRIMARY KEY, - username TEXT NOT NULL, - password_hash TEXT NOT NULL, - is_admin INTEGER NOT NULL DEFAULT 0, - is_oidc INTEGER NOT NULL DEFAULT 0 - ); - - CREATE TABLE trusted_devices ( - id TEXT PRIMARY KEY, - user_id TEXT NOT NULL, - device_fingerprint TEXT NOT NULL, - device_type TEXT NOT NULL, - device_info TEXT NOT NULL, - created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, - expires_at TEXT NOT NULL, - last_used_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, - FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE - ); - + await adapter.exec(` INSERT INTO users (id, username, password_hash) VALUES ('user-1', 'admin', 'hash'), ('user-2', 'user', 'hash'); diff --git a/src/backend/tests/database/repositories/user-data-export-repository.test.ts b/src/backend/tests/database/repositories/user-data-export-repository.test.ts index 2a2796f3..e2185227 100644 --- a/src/backend/tests/database/repositories/user-data-export-repository.test.ts +++ b/src/backend/tests/database/repositories/user-data-export-repository.test.ts @@ -15,146 +15,17 @@ describe("UserDataExportRepository", () => { async function createRepository(): Promise { adapter = new TestSqliteDatabase(); const context = await adapter.connect(); - context.sqlite?.exec(` - CREATE TABLE users ( - id TEXT PRIMARY KEY, - username TEXT NOT NULL, - password_hash TEXT NOT NULL - ); - - CREATE TABLE ssh_data ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - user_id TEXT NOT NULL, - connection_type TEXT NOT NULL DEFAULT 'ssh', - name TEXT, - ip TEXT NOT NULL, - port INTEGER NOT NULL, - username TEXT NOT NULL, - folder TEXT, - tags TEXT, - pin INTEGER NOT NULL DEFAULT 0, - auth_type TEXT NOT NULL, - use_warpgate INTEGER NOT NULL DEFAULT 0, - force_keyboard_interactive TEXT, - password TEXT, - key TEXT, - key_password TEXT, - key_type TEXT, - sudo_password TEXT, - autostart_password TEXT, - autostart_key TEXT, - autostart_key_password TEXT, - credential_id INTEGER, - override_credential_username INTEGER, - vault_profile_id INTEGER, - enable_terminal INTEGER NOT NULL DEFAULT 1, - enable_session_logging INTEGER NOT NULL DEFAULT 1, - allow_session_sharing INTEGER NOT NULL DEFAULT 1, - enable_command_history INTEGER NOT NULL DEFAULT 1, - enable_tunnel INTEGER NOT NULL DEFAULT 1, - tunnel_connections TEXT, - jump_hosts TEXT, - enable_file_manager INTEGER NOT NULL DEFAULT 1, - scp_legacy INTEGER NOT NULL DEFAULT 0, - enable_docker INTEGER NOT NULL DEFAULT 0, - enable_tmux_monitor INTEGER NOT NULL DEFAULT 0, - show_terminal_in_sidebar INTEGER NOT NULL DEFAULT 1, - show_file_manager_in_sidebar INTEGER NOT NULL DEFAULT 0, - show_tunnel_in_sidebar INTEGER NOT NULL DEFAULT 0, - show_docker_in_sidebar INTEGER NOT NULL DEFAULT 0, - show_server_stats_in_sidebar INTEGER NOT NULL DEFAULT 0, - default_path TEXT, - stats_config TEXT, - docker_config TEXT, - enable_proxmox INTEGER NOT NULL DEFAULT 0, - proxmox_config TEXT, - terminal_config TEXT, - quick_actions TEXT, - notes TEXT, - enable_ssh INTEGER NOT NULL DEFAULT 1, - enable_rdp INTEGER NOT NULL DEFAULT 0, - enable_vnc INTEGER NOT NULL DEFAULT 0, - enable_telnet INTEGER NOT NULL DEFAULT 0, - ssh_port INTEGER DEFAULT 22, - rdp_port INTEGER DEFAULT 3389, - vnc_port INTEGER DEFAULT 5900, - telnet_port INTEGER DEFAULT 23, - rdp_credential_id INTEGER, - rdp_user TEXT, - rdp_password TEXT, - rdp_domain TEXT, - rdp_security TEXT, - rdp_ignore_cert INTEGER DEFAULT 0, - vnc_credential_id INTEGER, - vnc_password TEXT, - vnc_user TEXT, - telnet_user TEXT, - telnet_password TEXT, - telnet_credential_id INTEGER, - rdp_auth_type TEXT, - vnc_auth_type TEXT, - telnet_auth_type TEXT, - domain TEXT, - security TEXT, - ignore_cert INTEGER DEFAULT 0, - guacamole_config TEXT, - use_socks5 INTEGER, - socks5_host TEXT, - socks5_port INTEGER, - socks5_username TEXT, - socks5_password TEXT, - socks5_proxy_chain TEXT, - mac_address TEXT, - wol_broadcast_address TEXT, - port_knock_sequence TEXT, - host_key_fingerprint TEXT, - host_key_type TEXT, - host_key_algorithm TEXT DEFAULT 'sha256', - host_key_first_seen TEXT, - host_key_last_verified TEXT, - host_key_changed_count INTEGER DEFAULT 0, - connection_origin TEXT, - sync_id TEXT, - created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, - updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP - ); - - CREATE TABLE ssh_credentials ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - user_id TEXT NOT NULL, - name TEXT NOT NULL, - description TEXT, - folder TEXT, - tags TEXT, - auth_type TEXT NOT NULL, - username TEXT, - password TEXT, - key TEXT, - private_key TEXT, - public_key TEXT, - key_password TEXT, - key_type TEXT, - detected_key_type TEXT, - cert_public_key TEXT, - usage_count INTEGER NOT NULL DEFAULT 0, - last_used TEXT, - sync_id TEXT, - created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, - updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP - ); - + await adapter.exec(` INSERT INTO users (id, username, password_hash) VALUES ('user-1', 'alice', 'hash'), ('user-2', 'bob', 'hash'); - - INSERT INTO ssh_data (id, user_id, name, ip, port, username, auth_type) - VALUES - (1, 'user-1', 'web', '10.0.0.1', 22, 'root', 'password'), - (2, 'user-2', 'db', '10.0.0.2', 22, 'root', 'password'); - INSERT INTO ssh_credentials (id, user_id, name, auth_type, username, password) VALUES (1, 'user-1', 'prod', 'password', 'root', 'secret'), (2, 'user-2', 'other', 'password', 'root', 'secret'); + INSERT INTO ssh_data (id, user_id, name, ip, port, username, auth_type) + VALUES + (1, 'user-1', 'web', '10.0.0.1', 22, 'root', 'password'), + (2, 'user-2', 'db', '10.0.0.2', 22, 'root', 'password'); `); return new UserDataExportRepository(context); diff --git a/src/backend/tests/database/repositories/user-preference-repository.test.ts b/src/backend/tests/database/repositories/user-preference-repository.test.ts index 307fa363..febf757c 100644 --- a/src/backend/tests/database/repositories/user-preference-repository.test.ts +++ b/src/backend/tests/database/repositories/user-preference-repository.test.ts @@ -17,41 +17,7 @@ describe("UserPreferenceRepository", () => { ): Promise { adapter = new TestSqliteDatabase(); const context = await adapter.connect(); - context.sqlite?.exec(` - CREATE TABLE users ( - id TEXT PRIMARY KEY, - username TEXT NOT NULL, - password_hash TEXT NOT NULL, - is_admin INTEGER NOT NULL DEFAULT 0, - is_oidc INTEGER NOT NULL DEFAULT 0 - ); - - CREATE TABLE user_preferences ( - user_id TEXT PRIMARY KEY, - reopen_tabs_on_login INTEGER NOT NULL DEFAULT 0, - theme TEXT, - font_size TEXT, - accent_color TEXT, - language TEXT, - storage_mode TEXT, - command_autocomplete INTEGER, - command_palette_enabled INTEGER, - show_host_tags INTEGER, - host_tray_on_click INTEGER, - pin_app_rail INTEGER, - expand_app_rail_on_hover INTEGER, - folders_collapsed INTEGER, - confirm_snippet_execution INTEGER, - disable_update_check INTEGER, - confirm_tab_close INTEGER, - hidden_rail_tabs TEXT, - compact_host_view INTEGER, - status_color_scheme TEXT, - custom_themes TEXT, - custom_keybindings TEXT, - updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP - ); - + await adapter.exec(` INSERT INTO users (id, username, password_hash) VALUES ('user-1', 'alice', 'hash'); `); diff --git a/src/backend/tests/database/repositories/user-session-repositories.test.ts b/src/backend/tests/database/repositories/user-session-repositories.test.ts index e26c508a..f53391dc 100644 --- a/src/backend/tests/database/repositories/user-session-repositories.test.ts +++ b/src/backend/tests/database/repositories/user-session-repositories.test.ts @@ -35,45 +35,6 @@ describe("UserRepository and SessionRepository", () => { }> { adapter = new TestSqliteDatabase(); const context = await adapter.connect(); - context.sqlite?.exec(` - CREATE TABLE users ( - id TEXT PRIMARY KEY, - username TEXT NOT NULL, - password_hash TEXT NOT NULL, - is_admin INTEGER NOT NULL DEFAULT 0, - is_oidc INTEGER NOT NULL DEFAULT 0, - oidc_identifier TEXT, - sso_provider_id INTEGER, - client_id TEXT, - client_secret TEXT, - issuer_url TEXT, - authorization_url TEXT, - token_url TEXT, - identifier_path TEXT, - name_path TEXT, - scopes TEXT DEFAULT 'openid email profile', - totp_secret TEXT, - totp_enabled INTEGER NOT NULL DEFAULT 0, - totp_backup_codes TEXT, - registered_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, - donation_modal_dismissed INTEGER NOT NULL DEFAULT 0 - ); - - CREATE TABLE sessions ( - id TEXT PRIMARY KEY, - user_id TEXT NOT NULL, - jwt_token TEXT NOT NULL, - device_type TEXT NOT NULL, - device_info TEXT NOT NULL, - oidc_sub TEXT, - oidc_sid TEXT, - sso_provider_id INTEGER, - created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, - expires_at TEXT NOT NULL, - last_active_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, - FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE - ); - `); return { users: new UserRepository(context, options.onUserWrite), diff --git a/src/backend/tests/database/repositories/vault-profile-repository.test.ts b/src/backend/tests/database/repositories/vault-profile-repository.test.ts index c0ade51e..b5e9c4e2 100644 --- a/src/backend/tests/database/repositories/vault-profile-repository.test.ts +++ b/src/backend/tests/database/repositories/vault-profile-repository.test.ts @@ -17,41 +17,13 @@ describe("VaultProfileRepository", () => { ): Promise { adapter = new TestSqliteDatabase(); const context = await adapter.connect(); - context.sqlite?.exec(` - CREATE TABLE users ( - id TEXT PRIMARY KEY, - username TEXT NOT NULL, - password_hash TEXT NOT NULL - ); - - CREATE TABLE vault_profiles ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - user_id TEXT NOT NULL, - name TEXT NOT NULL, - description TEXT, - folder TEXT, - tags TEXT, - vault_addr TEXT NOT NULL, - vault_namespace TEXT, - oidc_mount TEXT, - oidc_role TEXT, - ssh_mount TEXT, - ssh_role TEXT NOT NULL, - valid_principals TEXT, - key_type TEXT, - shared INTEGER NOT NULL DEFAULT 0, - sync_id TEXT, - created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, - updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP - ); - + await adapter.exec(` INSERT INTO users (id, username, password_hash) VALUES ('user-1', 'alice', 'hash'), ('user-2', 'bob', 'hash'); INSERT INTO vault_profiles ( id, user_id, name, vault_addr, ssh_role, shared, updated_at ) - VALUES - (1, 'user-1', 'owned', 'https://vault.one', 'role-one', 0, '2026-01-01T00:00:00.000Z'), + VALUES (1, 'user-1', 'owned', 'https://vault.one', 'role-one', 0, '2026-01-01T00:00:00.000Z'), (2, 'user-2', 'shared', 'https://vault.two', 'role-two', 1, '2026-01-02T00:00:00.000Z'), (3, 'user-2', 'hidden', 'https://vault.three', 'role-three', 0, '2026-01-03T00:00:00.000Z'); `); diff --git a/src/backend/tests/database/repositories/vault-token-repository.test.ts b/src/backend/tests/database/repositories/vault-token-repository.test.ts index a67d104d..01484f5b 100644 --- a/src/backend/tests/database/repositories/vault-token-repository.test.ts +++ b/src/backend/tests/database/repositories/vault-token-repository.test.ts @@ -17,35 +17,11 @@ describe("VaultTokenRepository", () => { ): Promise { adapter = new TestSqliteDatabase(); const context = await adapter.connect(); - context.sqlite?.exec(` - CREATE TABLE users ( - id TEXT PRIMARY KEY, - username TEXT NOT NULL, - password_hash TEXT NOT NULL - ); - - CREATE TABLE vault_profiles ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - user_id TEXT NOT NULL, - name TEXT NOT NULL - ); - - CREATE TABLE vault_tokens ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - user_id TEXT NOT NULL, - profile_id INTEGER NOT NULL, - ssh_cert TEXT NOT NULL, - private_key TEXT NOT NULL, - created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, - expires_at TEXT NOT NULL, - last_used TEXT, - UNIQUE(user_id, profile_id) - ); - + await adapter.exec(` INSERT INTO users (id, username, password_hash) VALUES ('user-1', 'alice', 'hash'), ('user-2', 'bob', 'hash'); - INSERT INTO vault_profiles (id, user_id, name) - VALUES (1, 'user-1', 'one'), (2, 'user-2', 'two'); + INSERT INTO vault_profiles (id, user_id, name, vault_addr, ssh_role) + VALUES (1, 'user-1', 'one', 'http://vault', 'r'), (2, 'user-2', 'two', 'http://vault', 'r'); INSERT INTO vault_tokens ( user_id, profile_id, ssh_cert, private_key, expires_at ) diff --git a/src/backend/tests/database/routes/host-normalizers.test.ts b/src/backend/tests/database/routes/host-normalizers.test.ts index 812f99bf..ded4e463 100644 --- a/src/backend/tests/database/routes/host-normalizers.test.ts +++ b/src/backend/tests/database/routes/host-normalizers.test.ts @@ -1,6 +1,8 @@ import { describe, it, expect } from "vitest"; import { + containsOwnerPrivateAuthUpdate, isNonEmptyString, + isOptionalBoolean, isValidPort, normalizeImportedHost, renameFolderPath, @@ -9,6 +11,45 @@ import { transformHostResponse, } from "../../../database/routes/host-normalizers.js"; +describe("containsOwnerPrivateAuthUpdate", () => { + it("detects owner-only SSH auth fields, including explicit clears", () => { + expect(containsOwnerPrivateAuthUpdate({ password: null }, "ssh")).toBe( + true, + ); + expect( + containsOwnerPrivateAuthUpdate({ credentialId: undefined }, "ssh"), + ).toBe(true); + expect( + containsOwnerPrivateAuthUpdate({ authType: "password" }, "ssh"), + ).toBe(true); + expect(containsOwnerPrivateAuthUpdate({ shareSshAuth: true }, "ssh")).toBe( + true, + ); + }); + + it("keeps protocol field definitions isolated", () => { + expect(containsOwnerPrivateAuthUpdate({ rdpCredentialId: 7 }, "rdp")).toBe( + true, + ); + expect(containsOwnerPrivateAuthUpdate({ rdpCredentialId: 7 }, "ssh")).toBe( + false, + ); + }); + + it("allows shared editors to update non-authentication host settings", () => { + expect( + containsOwnerPrivateAuthUpdate( + { + name: "renamed", + ip: "10.0.0.5", + notes: "updated", + }, + "ssh", + ), + ).toBe(false); + }); +}); + describe("isNonEmptyString", () => { it("accepts non-blank strings", () => { expect(isNonEmptyString("hello")).toBe(true); @@ -24,6 +65,21 @@ describe("isNonEmptyString", () => { }); }); +describe("isOptionalBoolean", () => { + it("accepts booleans and an omitted value", () => { + expect(isOptionalBoolean(true)).toBe(true); + expect(isOptionalBoolean(false)).toBe(true); + expect(isOptionalBoolean(undefined)).toBe(true); + }); + + it("rejects truthy string and numeric lookalikes", () => { + expect(isOptionalBoolean("false")).toBe(false); + expect(isOptionalBoolean("0")).toBe(false); + expect(isOptionalBoolean(1)).toBe(false); + expect(isOptionalBoolean(null)).toBe(false); + }); +}); + describe("renameFolderPath", () => { it("renames an exact folder match", () => { expect(renameFolderPath("Production", "Production", "Prod")).toBe("Prod"); @@ -143,11 +199,16 @@ describe("stripSensitiveFields", () => { key: "PRIVATE KEY", keyPassword: "kp", sudoPassword: "sp", + terminalConfig: { + theme: "termix", + sudoPassword: "nested-sudo", + }, }); expect(result.password).toBeUndefined(); expect(result.key).toBeUndefined(); expect(result.keyPassword).toBeUndefined(); expect(result.sudoPassword).toBeUndefined(); + expect(result.terminalConfig).toEqual({ theme: "termix" }); expect(result.hasPassword).toBe(true); expect(result.hasKey).toBe(true); expect(result.hasKeyPassword).toBe(true); @@ -190,11 +251,13 @@ describe("transformHostResponse", () => { tags: "a,b,c", enableTerminal: 1, enableTunnel: 0, + shareSshAuth: 1, pin: 1, }); expect(result.tags).toEqual(["a", "b", "c"]); expect(result.enableTerminal).toBe(true); expect(result.enableTunnel).toBe(false); + expect(result.shareSshAuth).toBe(true); expect(result.pin).toBe(true); }); @@ -258,6 +321,9 @@ describe("sanitizeHostForRecipient", () => { tags: ["linux"], notes: "secret runbook", quickActions: [{ name: "restart", snippetId: "1" }], + credentialId: 7, + shareSshAuth: true, + overrideCredentialUsername: true, password: "hunter2", key: "PRIVATE", sudoPassword: "sudo", @@ -268,6 +334,11 @@ describe("sanitizeHostForRecipient", () => { sshPort: 22, rdpPort: 3389, defaultPath: "/srv", + terminalConfig: { + theme: "termix", + sudoPassword: "nested-sudo", + agentSocketPath: "/run/user/1000/ssh-agent.sock", + }, }; it("always strips secrets for recipients", () => { @@ -277,6 +348,12 @@ describe("sanitizeHostForRecipient", () => { expect(result.sudoPassword).toBeUndefined(); expect(result.rdpPassword).toBeUndefined(); expect(result.socks5Password).toBeUndefined(); + expect(result.credentialId).toBeUndefined(); + expect(result.overrideCredentialUsername).toBeUndefined(); + expect(result.terminalConfig).toEqual({ theme: "termix" }); + expect(result.shareSshAuth).toBe(true); + expect(result.hasPassword).toBe(false); + expect(result.hasKey).toBe(false); // view keeps configuration fields expect(result.notes).toBe("secret runbook"); expect(result.quickActions).toEqual(sharedHost.quickActions); @@ -284,7 +361,17 @@ describe("sanitizeHostForRecipient", () => { it("reduces connect-level hosts to connection essentials", () => { const result = sanitizeHostForRecipient( - { ...sharedHost, permissionLevel: "connect" }, + { + ...sharedHost, + permissionLevel: "connect", + authOverrides: { + ssh: { + credentialId: 9, + required: false, + ownerAuthShared: true, + }, + }, + }, "connect", ); expect(result.name).toBe("prod"); @@ -292,6 +379,14 @@ describe("sanitizeHostForRecipient", () => { expect(result.enableRdp).toBe(true); expect(result.rdpPort).toBe(3389); expect(result.permissionLevel).toBe("connect"); + expect(result.shareSshAuth).toBe(true); + expect(result.authOverrides).toEqual({ + ssh: { + credentialId: 9, + required: false, + ownerAuthShared: true, + }, + }); expect(result.notes).toBeUndefined(); expect(result.quickActions).toBeUndefined(); expect(result.password).toBeUndefined(); diff --git a/src/backend/tests/database/routes/proxmox-import-auth.test.ts b/src/backend/tests/database/routes/proxmox-import-auth.test.ts new file mode 100644 index 00000000..0d40493f --- /dev/null +++ b/src/backend/tests/database/routes/proxmox-import-auth.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it } from "vitest"; +import { resolveProxmoxImportAuth } from "../../../database/routes/proxmox-import-auth.js"; + +// The frontend carries its own copy of this decision in +// src/ui/components/proxmox/proxmox-import-auth.ts. The two drifting apart is +// what produced the reported bug, so both are held to the same matrix. +describe("resolveProxmoxImportAuth", () => { + it("uses the default credential for key auth when one is configured", () => { + expect(resolveProxmoxImportAuth("key", 7)).toEqual({ + authType: "credential", + credentialId: 7, + overrideCredentialUsername: 1, + }); + }); + + it("uses the default credential for password auth when one is configured", () => { + expect(resolveProxmoxImportAuth("password", 7)).toEqual({ + authType: "credential", + credentialId: 7, + overrideCredentialUsername: 1, + }); + }); + + it("falls back to none when a secret-backed default has no credential", () => { + for (const authType of ["password", "key", "credential"]) { + expect(resolveProxmoxImportAuth(authType, null)).toEqual({ + authType: "none", + credentialId: null, + overrideCredentialUsername: 0, + }); + } + }); + + it("uses the credential when no default auth type is configured", () => { + expect(resolveProxmoxImportAuth(undefined, 42)).toEqual({ + authType: "credential", + credentialId: 42, + overrideCredentialUsername: 1, + }); + expect(resolveProxmoxImportAuth(undefined, null)).toEqual({ + authType: "none", + credentialId: null, + overrideCredentialUsername: 0, + }); + }); + + it("keeps secretless auth types, with or without a credential", () => { + for (const authType of ["none", "agent", "opkssh", "tailscale", "vault"]) { + for (const credentialId of [null, 7]) { + expect(resolveProxmoxImportAuth(authType, credentialId)).toEqual({ + authType, + credentialId: null, + overrideCredentialUsername: 0, + }); + } + } + }); +}); diff --git a/src/backend/tests/database/routes/rbac-host-auth-override.test.ts b/src/backend/tests/database/routes/rbac-host-auth-override.test.ts new file mode 100644 index 00000000..232bf0d6 --- /dev/null +++ b/src/backend/tests/database/routes/rbac-host-auth-override.test.ts @@ -0,0 +1,274 @@ +import express from "express"; +import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; + +const state = vi.hoisted(() => ({ + authenticated: true, + access: { + hasAccess: true, + isShared: true, + isAdminBypass: false, + }, + credentialOwned: true, + credentialId: 7 as number | null, + writes: [] as Array<{ protocol: string; credentialId: number | null }>, + auditCalls: [] as Array>, +})); + +vi.mock("../../../utils/auth-manager.js", () => ({ + AuthManager: { + getInstance: () => ({ + createAuthMiddleware: + () => + ( + req: express.Request & { userId?: string }, + res: express.Response, + next: express.NextFunction, + ) => { + if (!state.authenticated) { + res.status(401).json({ error: "Not authenticated" }); + return; + } + req.userId = "recipient"; + next(); + }, + createDataAccessMiddleware: + () => + ( + _req: express.Request, + _res: express.Response, + next: express.NextFunction, + ) => + next(), + }), + }, +})); + +vi.mock("../../../utils/permission-manager.js", () => ({ + SHARE_PERMISSION_LEVELS: ["connect", "view", "edit", "manage"], + PermissionManager: { + getInstance: () => ({ + canAccessHost: async () => state.access, + requireAdmin: + () => + ( + _req: express.Request, + _res: express.Response, + next: express.NextFunction, + ) => + next(), + invalidateUserPermissionCache: vi.fn(), + isAdmin: async () => false, + }), + }, +})); + +vi.mock("../../../database/repositories/factory.js", () => ({ + createCurrentCredentialRepository: () => ({ + findByIdForUser: async () => + state.credentialOwned ? { id: state.credentialId } : null, + }), + createCurrentSharedHostAuthOverrideRepository: () => ({ + findCredentialId: async () => state.credentialId, + setCredential: async ( + _hostId: number, + _userId: string, + protocol: string, + id: number, + ) => { + state.credentialId = id; + state.writes.push({ protocol, credentialId: id }); + }, + clearCredential: async ( + _hostId: number, + _userId: string, + protocol: string, + ) => { + state.credentialId = null; + state.writes.push({ protocol, credentialId: null }); + return true; + }, + }), + createCurrentUserRepository: () => ({ + findById: async () => ({ id: "recipient", username: "recipient" }), + }), + createCurrentHostFolderRepository: vi.fn(), + createCurrentHostResolutionRepository: vi.fn(), + createCurrentRbacAccessRepository: vi.fn(), + createCurrentRoleRepository: vi.fn(), + createCurrentSnippetRepository: vi.fn(), +})); + +vi.mock("../../../utils/audit-logger.js", () => ({ + getRequestMeta: () => ({ ipAddress: "", userAgent: "" }), + logAudit: vi.fn(async (entry: Record) => { + state.auditCalls.push(entry); + }), +})); + +vi.mock("../../../utils/logger.js", () => ({ + databaseLogger: { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + success: vi.fn(), + }, +})); + +describe("shared host authentication override routes", () => { + let router: express.Router; + + beforeAll(async () => { + ({ default: router } = await import("../../../database/routes/rbac.js")); + }); + + async function invoke( + method: "get" | "put", + body: Record = {}, + protocol = "ssh", + ): Promise<{ status: number; body: unknown }> { + const routeLayer = ( + router as unknown as { + stack: Array<{ + route?: { + path: string; + methods: Record; + stack: Array<{ + handle: ( + req: express.Request, + res: express.Response, + next: express.NextFunction, + ) => unknown; + }>; + }; + }>; + } + ).stack.find( + (layer) => + layer.route?.path === "/host-access/:hostId/auth/:protocol" && + layer.route.methods[method], + ); + if (!routeLayer?.route) throw new Error(`Missing ${method} route`); + + const handlers = routeLayer.route.stack.map((layer) => layer.handle); + const req = { + params: { hostId: "42", protocol }, + body, + headers: {}, + ip: "127.0.0.1", + } as unknown as express.Request; + + return new Promise((resolve, reject) => { + let index = 0; + let status = 200; + const res = { + status(code: number) { + status = code; + return this; + }, + json(responseBody: unknown) { + resolve({ status, body: responseBody }); + return this; + }, + } as unknown as express.Response; + + const next: express.NextFunction = (error?: unknown) => { + if (error) { + reject(error); + return; + } + const handler = handlers[index++]; + if (!handler) { + resolve({ status, body: undefined }); + return; + } + try { + Promise.resolve(handler(req, res, next)).catch(reject); + } catch (handlerError) { + reject(handlerError); + } + }; + next(); + }); + } + + beforeEach(() => { + state.authenticated = true; + state.access = { + hasAccess: true, + isShared: true, + isAdminBypass: false, + }; + state.credentialOwned = true; + state.credentialId = 7; + state.writes = []; + state.auditCalls = []; + }); + + it("returns the current override for a role-derived shared recipient", async () => { + const response = await invoke("get"); + expect(response).toEqual({ + status: 200, + body: { protocol: "ssh", credentialId: 7 }, + }); + }); + + it("sets and clears a direct recipient's own credential", async () => { + const setResponse = await invoke("put", { credentialId: 8 }); + expect(setResponse.status).toBe(200); + expect(state.writes).toEqual([{ protocol: "ssh", credentialId: 8 }]); + + const clearResponse = await invoke("put", { credentialId: null }); + expect(clearResponse.status).toBe(200); + expect(state.writes).toEqual([ + { protocol: "ssh", credentialId: 8 }, + { protocol: "ssh", credentialId: null }, + ]); + expect(state.auditCalls).toHaveLength(2); + expect(JSON.parse(String(state.auditCalls[0].details))).toEqual({ + protocol: "ssh", + credentialId: 8, + }); + }); + + it("rejects owners, admin bypasses, and users without active access", async () => { + for (const access of [ + { hasAccess: true, isShared: false, isAdminBypass: false }, + { hasAccess: true, isShared: false, isAdminBypass: true }, + { hasAccess: false, isShared: true, isAdminBypass: false }, + ]) { + state.access = access; + const response = await invoke("get"); + expect(response.status).toBe(403); + } + }); + + it("rejects invalid or foreign credentials and unauthenticated requests", async () => { + const invalidResponse = await invoke("put", { credentialId: 0 }); + expect(invalidResponse.status).toBe(400); + + state.credentialOwned = false; + const foreignResponse = await invoke("put", { credentialId: 99 }); + expect(foreignResponse.status).toBe(404); + + state.authenticated = false; + const unauthenticatedResponse = await invoke("get"); + expect(unauthenticatedResponse.status).toBe(401); + }); + + it("rejects recognized but unsupported protocols and invalid protocol names", async () => { + const unsupportedResponse = await invoke("get", {}, "rdp"); + expect(unsupportedResponse).toEqual({ + status: 400, + body: { + error: "RDP authentication overrides are not supported yet", + }, + }); + expect(state.writes).toEqual([]); + + const invalidResponse = await invoke("get", {}, "smtp"); + expect(invalidResponse).toEqual({ + status: 400, + body: { error: "Invalid authentication protocol" }, + }); + }); +}); diff --git a/src/backend/tests/database/routes/session-log-routes.test.ts b/src/backend/tests/database/routes/session-log-routes.test.ts index 1bbbbf54..73dfe884 100644 --- a/src/backend/tests/database/routes/session-log-routes.test.ts +++ b/src/backend/tests/database/routes/session-log-routes.test.ts @@ -28,6 +28,25 @@ vi.mock("../../../utils/auth-manager.js", () => ({ }, })); +// The route module calls PermissionManager.getInstance() at import time and +// pulls in the repository factory, which loads the drizzle schema and the +// better-sqlite3 native binding. Importing that tree costs seconds under a +// concurrent full run — enough to blow the 5s test timeout — and none of it is +// under test here. +vi.mock("../../../utils/permission-manager.js", () => ({ + PermissionManager: { + getInstance: () => ({ + canAccessHost: vi.fn(), + }), + }, +})); + +vi.mock("../../../database/repositories/factory.js", () => ({ + createCurrentSessionRecordingRepository: vi.fn(), + createCurrentSettingsRepository: vi.fn(), + getCurrentSettingValue: vi.fn(), +})); + const mockReadFile = vi.fn(); const mockStat = vi.fn(); const mockUnlink = vi.fn(); diff --git a/src/backend/tests/database/routes/snippets-execution.test.ts b/src/backend/tests/database/routes/snippets-execution.test.ts new file mode 100644 index 00000000..35ea7e6e --- /dev/null +++ b/src/backend/tests/database/routes/snippets-execution.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it } from "vitest"; +import { + createSnippetExecutionResult, + getSnippetExecutionTimeoutMs, +} from "../../../database/routes/snippets-execution.js"; + +describe("snippet execution", () => { + it("treats stderr as diagnostic output when the command succeeds", () => { + expect(createSnippetExecutionResult(0, "done\n", "warning\n")).toEqual({ + success: true, + output: "done\n", + error: "warning\n", + }); + }); + + it("uses the exit code to report command failure", () => { + expect(createSnippetExecutionResult(1, "", "failed\n")).toEqual({ + success: false, + output: "", + error: "failed\n", + }); + }); + + it("preserves the previous fallback when no exit code is available", () => { + expect(createSnippetExecutionResult(null, "done\n", "")).toEqual({ + success: true, + output: "done\n", + }); + expect(createSnippetExecutionResult(null, "", "failed\n").success).toBe( + false, + ); + }); + + it("disables the command timeout by default", () => { + expect(getSnippetExecutionTimeoutMs(undefined)).toBeUndefined(); + }); + + it("converts a configured timeout from seconds to milliseconds", () => { + expect(getSnippetExecutionTimeoutMs("45")).toBe(45_000); + }); + + it.each(["", "0", "-1", "invalid"])( + "ignores invalid timeout value %j", + (value) => { + expect(getSnippetExecutionTimeoutMs(value)).toBeUndefined(); + }, + ); +}); diff --git a/src/backend/tests/database/routes/sync.test.ts b/src/backend/tests/database/routes/sync.test.ts index 55d27bb2..dc8cd679 100644 --- a/src/backend/tests/database/routes/sync.test.ts +++ b/src/backend/tests/database/routes/sync.test.ts @@ -1,9 +1,30 @@ import { describe, expect, it } from "vitest"; -import { +import syncRouter, { isValidEntityType, stripWritePayload, } from "../../../database/routes/sync.js"; +describe("sync route order", () => { + it("registers POST /tombstones before the POST /:entityType wildcard", () => { + const postPaths = ( + syncRouter as unknown as { + stack: Array<{ route?: { path: string; methods: { post?: boolean } } }>; + } + ).stack + .filter((layer) => layer.route?.methods?.post) + .map((layer) => layer.route!.path); + + // "/tombstones" is a valid value for :entityType as far as Express is + // concerned, so registering the wildcard first makes the tombstone + // endpoint unreachable -- every deletion push answers 400 "Unknown entity + // type" instead of applying the deletion. + expect(postPaths).toContain("/tombstones"); + expect(postPaths.indexOf("/tombstones")).toBeLessThan( + postPaths.indexOf("/:entityType"), + ); + }); +}); + describe("isValidEntityType", () => { it("accepts every whitelisted sync entity type", () => { for (const type of [ @@ -15,6 +36,7 @@ describe("isValidEntityType", () => { "vaultProfiles", "dashboardServiceLinks", "homepageItems", + "userPreferences", ]) { expect(isValidEntityType(type)).toBe(true); } @@ -52,6 +74,16 @@ describe("stripWritePayload", () => { expect(stripWritePayload("hosts", payload)).toEqual({ name: "web" }); }); + it("keeps preference storage mode local to each device", () => { + expect( + stripWritePayload("userPreferences", { + syncId: "userPreferences:singleton", + theme: "dark", + storageMode: "cloud", + }), + ).toEqual({ theme: "dark" }); + }); + it("does not mutate the original payload object", () => { const payload = { id: 1, userId: "user-1", syncId: "abc", name: "x" }; stripWritePayload("snippets", payload); diff --git a/src/backend/tests/database/routes/user-oidc-utils.test.ts b/src/backend/tests/database/routes/user-oidc-utils.test.ts index 96dcc723..85b5c493 100644 --- a/src/backend/tests/database/routes/user-oidc-utils.test.ts +++ b/src/backend/tests/database/routes/user-oidc-utils.test.ts @@ -16,11 +16,149 @@ const { getOIDCConfigFromEnv, extractOidcGroups, validateLogoutTokenClaims, + parseOidcRoleMap, + resolveOidcMappedRoles, + verifyOIDCToken, + describeFetchFailure, } = await import("../../../database/routes/user-oidc-utils.js"); const BACKCHANNEL_LOGOUT_EVENT = "http://schemas.openid.net/event/backchannel-logout"; +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe("describeFetchFailure", () => { + it("unwraps the undici cause, which carries the reason that matters", () => { + // Every transport failure surfaces as this same outer message. + const error = new TypeError("fetch failed", { + cause: Object.assign(new Error("getaddrinfo ENOTFOUND idp.example"), { + code: "ENOTFOUND", + }), + }); + expect(describeFetchFailure(error)).toBe( + "fetch failed: getaddrinfo ENOTFOUND idp.example (ENOTFOUND)", + ); + }); + + it("falls back to the outer message when there is no cause", () => { + expect(describeFetchFailure(new Error("boom"))).toBe("boom"); + }); + + it("handles a non-Error throw", () => { + expect(describeFetchFailure("nope")).toBe("nope"); + }); +}); + +describe("verifyOIDCToken JWKS diagnostics", () => { + const issuer = "https://login.microsoftonline.com/example/v2.0"; + const token = "header.payload.signature"; + + it("reports every attempted URL and why it failed", async () => { + vi.spyOn(globalThis, "fetch").mockResolvedValue( + new Response("not found", { status: 404 }), + ); + + const error = await verifyOIDCToken(token, issuer, "client").catch( + (e) => e as Error, + ); + expect(error.message).toMatch(/^Failed to fetch JWKS from any URL/); + expect(error.message).toContain( + `${issuer}/.well-known/openid-configuration: HTTP 404`, + ); + expect(error.message).toContain( + `${issuer}/.well-known/jwks.json: HTTP 404`, + ); + expect(error.message).toContain(`${issuer}/jwks/: HTTP 404`); + }); + + it("reports a transport failure with its underlying cause", async () => { + vi.spyOn(globalThis, "fetch").mockRejectedValue( + new TypeError("fetch failed", { + cause: Object.assign(new Error("self-signed certificate"), { + code: "SELF_SIGNED_CERT_IN_CHAIN", + }), + }), + ); + + const error = await verifyOIDCToken(token, issuer, "client").catch( + (e) => e as Error, + ); + expect(error.message).toContain( + "self-signed certificate (SELF_SIGNED_CERT_IN_CHAIN)", + ); + }); + + it("says so when discovery succeeds but advertises no jwks_uri", async () => { + vi.spyOn(globalThis, "fetch") + .mockResolvedValueOnce( + new Response(JSON.stringify({ issuer }), { status: 200 }), + ) + .mockResolvedValue(new Response("not found", { status: 404 })); + + const error = await verifyOIDCToken(token, issuer, "client").catch( + (e) => e as Error, + ); + expect(error.message).toContain("no jwks_uri in the discovery document"); + }); + + it("says so when a JWKS response carries no keys array", async () => { + vi.spyOn(globalThis, "fetch") + .mockResolvedValueOnce( + new Response(JSON.stringify({ jwks_uri: "https://idp.example/keys" }), { + status: 200, + }), + ) + .mockResolvedValue( + new Response(JSON.stringify({ error: "unauthorized" }), { + status: 200, + }), + ); + + const error = await verifyOIDCToken(token, issuer, "client").catch( + (e) => e as Error, + ); + expect(error.message).toContain( + 'https://idp.example/keys: response contains no "keys" array', + ); + }); +}); + +describe("verifyOIDCToken", () => { + it("uses the protected-header algorithm when the provider JWK omits alg", async () => { + const { exportJWK, generateKeyPair, SignJWT } = await import("jose"); + const { publicKey, privateKey } = await generateKeyPair("RS256"); + const jwk = await exportJWK(publicKey); + jwk.kid = "entra-key"; + + const issuer = "https://login.microsoftonline.com/example/v2.0"; + const clientId = "termix-client"; + const token = await new SignJWT({ sub: "user-1" }) + .setProtectedHeader({ alg: "RS256", kid: jwk.kid }) + .setIssuer(issuer) + .setAudience(clientId) + .setExpirationTime("5m") + .sign(privateKey); + + const fetchMock = vi + .spyOn(globalThis, "fetch") + .mockResolvedValueOnce( + new Response(JSON.stringify({ jwks_uri: "https://idp.example/keys" }), { + status: 200, + }), + ) + .mockResolvedValueOnce( + new Response(JSON.stringify({ keys: [jwk] }), { status: 200 }), + ); + + const payload = await verifyOIDCToken(token, issuer, clientId); + + expect(payload.sub).toBe("user-1"); + expect(fetchMock).toHaveBeenCalledTimes(2); + }); +}); + describe("isOIDCUserAllowed", () => { it("allows everyone when the allow-list is empty", () => { expect(isOIDCUserAllowed("", "alice", "alice@x.com")).toBe(true); @@ -251,3 +389,143 @@ describe("validateLogoutTokenClaims", () => { ).toThrow("must contain sub and/or sid"); }); }); + +describe("parseOidcRoleMap", () => { + it("returns an empty map for blank input", () => { + expect(parseOidcRoleMap(undefined).size).toBe(0); + expect(parseOidcRoleMap(null).size).toBe(0); + expect(parseOidcRoleMap(" ").size).toBe(0); + }); + + it("parses comma-separated group:role pairs", () => { + const map = parseOidcRoleMap( + "devops-interns:devops-intern,devops-seniors:devops-senior", + ); + expect(map.get("devops-interns")).toBe("devops-intern"); + expect(map.get("devops-seniors")).toBe("devops-senior"); + expect(map.size).toBe(2); + }); + + it("parses newline-separated pairs and trims whitespace", () => { + const map = parseOidcRoleMap(" a : role-a \n b:role-b \n"); + expect(map.get("a")).toBe("role-a"); + expect(map.get("b")).toBe("role-b"); + }); + + it("normalizes leading slashes and case in group names", () => { + const map = parseOidcRoleMap("/DevOps-Interns:devops-intern"); + expect(map.get("devops-interns")).toBe("devops-intern"); + }); + + it("skips malformed entries instead of throwing", () => { + const map = parseOidcRoleMap("no-colon,:missing-group,missing-role:,ok:r"); + expect(map.size).toBe(1); + expect(map.get("ok")).toBe("r"); + }); + + it("splits on the last colon so group names may contain colons", () => { + const map = parseOidcRoleMap("ns:team:role-x"); + expect(map.get("ns:team")).toBe("role-x"); + }); + + it("preserves role-name case verbatim", () => { + // Role names must match roles.name exactly, so they are not lowercased. + expect(parseOidcRoleMap("g:DevOps_Senior").get("g")).toBe("DevOps_Senior"); + }); +}); + +describe("resolveOidcMappedRoles", () => { + const roleMap = parseOidcRoleMap( + "devops-interns:devops-intern,devops-seniors:devops-senior", + ); + + it("reports every mapped role as managed regardless of membership", () => { + const { managed } = resolveOidcMappedRoles([], roleMap); + expect([...managed].sort()).toEqual(["devops-intern", "devops-senior"]); + }); + + it("desires only the roles whose groups the user is in", () => { + const { desired } = resolveOidcMappedRoles(["devops-interns"], roleMap); + expect([...desired]).toEqual(["devops-intern"]); + }); + + it("matches full group paths emitted by Keycloak", () => { + const { desired } = resolveOidcMappedRoles(["/devops-seniors"], roleMap); + expect([...desired]).toEqual(["devops-senior"]); + }); + + it("ignores groups that are not mapped", () => { + const { desired } = resolveOidcMappedRoles( + ["finance", "devops-interns"], + roleMap, + ); + expect([...desired]).toEqual(["devops-intern"]); + }); + + it("supports a user in multiple mapped groups", () => { + const { desired } = resolveOidcMappedRoles( + ["devops-interns", "devops-seniors"], + roleMap, + ); + expect([...desired].sort()).toEqual(["devops-intern", "devops-senior"]); + }); + + it("desires nothing when the map is empty", () => { + const { desired, managed } = resolveOidcMappedRoles( + ["devops-interns"], + new Map(), + ); + expect(desired.size).toBe(0); + expect(managed.size).toBe(0); + }); +}); + +// Imported as a namespace rather than destructured into the shared block at the +// top of the file, so this suite stays independent of what that block binds. +const oidcUtils = await import("../../../database/routes/user-oidc-utils.js"); + +describe("verifyOIDCToken token shape", () => { + const issuer = "https://idp.example.com/application/o/termix"; + + // The shape check runs before any network call, so no fetch stub is needed. + const fetchSpy = vi.fn(); + beforeEach(() => { + vi.stubGlobal("fetch", fetchSpy); + }); + afterEach(() => { + vi.unstubAllGlobals(); + fetchSpy.mockReset(); + }); + + it("reports an encrypted (JWE) token as a format error", async () => { + const jwe = ["header", "key", "iv", "ciphertext", "tag"].join("."); + + await expect( + oidcUtils.verifyOIDCToken(jwe, issuer, "client"), + ).rejects.toThrow(oidcUtils.OIDCTokenFormatError); + await expect( + oidcUtils.verifyOIDCToken(jwe, issuer, "client"), + ).rejects.toThrow(/JWE \(encrypted\)/); + expect(fetchSpy).not.toHaveBeenCalled(); + }); + + it("reports any other non-JWS segment count as a format error", async () => { + await expect( + oidcUtils.verifyOIDCToken("header.payload", issuer, "client"), + ).rejects.toThrow(/expected 3 segments, got 2/); + await expect( + oidcUtils.verifyOIDCToken("opaque", issuer, "client"), + ).rejects.toThrow(/expected 3 segments, got 1/); + expect(fetchSpy).not.toHaveBeenCalled(); + }); + + it("lets a three-segment token through to key resolution", async () => { + fetchSpy.mockResolvedValue({ ok: false }); + + // Reaches JWKS fetching, so it fails on the key lookup rather than the shape. + await expect( + oidcUtils.verifyOIDCToken("header.payload.signature", issuer, "client"), + ).rejects.not.toThrow(oidcUtils.OIDCTokenFormatError); + expect(fetchSpy).toHaveBeenCalled(); + }); +}); diff --git a/src/backend/tests/database/sync-references.test.ts b/src/backend/tests/database/sync-references.test.ts new file mode 100644 index 00000000..ac25805e --- /dev/null +++ b/src/backend/tests/database/sync-references.test.ts @@ -0,0 +1,70 @@ +import { describe, expect, it } from "vitest"; +import { + deserializeSyncReferences, + serializeSyncReferences, +} from "../../database/routes/sync-references.js"; + +describe("sync references", () => { + it("serializes database-local host IDs as stable sync IDs", async () => { + const row = await serializeSyncReferences( + "hosts", + { + id: 7, + credentialId: 12, + rdpCredentialId: 13, + vncCredentialId: null, + telnetCredentialId: null, + vaultProfileId: 4, + }, + async (entityType, id) => `${entityType}-${id}`, + ); + + expect(row).toMatchObject({ + credentialSyncId: "sshCredentials-12", + rdpCredentialSyncId: "sshCredentials-13", + vncCredentialSyncId: null, + telnetCredentialSyncId: null, + vaultProfileSyncId: "vaultProfiles-4", + }); + expect(row).not.toHaveProperty("credentialId"); + expect(row).not.toHaveProperty("vaultProfileId"); + }); + + it("resolves stable sync IDs to IDs from the receiving database", async () => { + const ids = new Map([ + ["sshCredentials:credential-sync", 91], + ["vaultProfiles:vault-sync", 37], + ]); + const row = await deserializeSyncReferences( + "hosts", + { + credentialId: 12, + credentialSyncId: "credential-sync", + rdpCredentialSyncId: null, + vncCredentialSyncId: null, + telnetCredentialSyncId: null, + vaultProfileSyncId: "vault-sync", + }, + async (entityType, syncId) => ids.get(`${entityType}:${syncId}`) ?? null, + ); + + expect(row).toMatchObject({ + credentialId: 91, + rdpCredentialId: null, + vncCredentialId: null, + telnetCredentialId: null, + vaultProfileId: 37, + }); + expect(row).not.toHaveProperty("credentialSyncId"); + }); + + it("rejects a row whose referenced dependency has not synced", async () => { + await expect( + deserializeSyncReferences( + "sshFolders", + { credentialSyncId: "missing" }, + async () => null, + ), + ).rejects.toThrow("Missing sshCredentials dependency"); + }); +}); diff --git a/src/backend/tests/electron/backend-paths.test.ts b/src/backend/tests/electron/backend-paths.test.ts new file mode 100644 index 00000000..a0647e4d --- /dev/null +++ b/src/backend/tests/electron/backend-paths.test.ts @@ -0,0 +1,33 @@ +import { createRequire } from "node:module"; +import { describe, expect, it } from "vitest"; + +const require = createRequire(import.meta.url); +const { getUnpackedAppRoot } = + require("../../../../electron/backend-paths.cjs") as { + getUnpackedAppRoot: (appRoot: string) => string; + }; + +describe("getUnpackedAppRoot", () => { + it.each([ + [ + "/Applications/Termix.app/Contents/Resources/app.asar", + "/Applications/Termix.app/Contents/Resources/app.asar.unpacked", + ], + [ + "/Applications/Termix.app/Contents/Resources/app-arm64.asar", + "/Applications/Termix.app/Contents/Resources/app-arm64.asar.unpacked", + ], + [ + "/Applications/Termix.app/Contents/Resources/app-x64.asar", + "/Applications/Termix.app/Contents/Resources/app-x64.asar.unpacked", + ], + ])("maps %s to its matching unpacked directory", (appRoot, expected) => { + expect(getUnpackedAppRoot(appRoot)).toBe(expected); + }); + + it("does not append the suffix twice", () => { + const appRoot = + "/Applications/Termix.app/Contents/Resources/app-arm64.asar.unpacked"; + expect(getUnpackedAppRoot(appRoot)).toBe(appRoot); + }); +}); diff --git a/src/backend/tests/hosts/guacamole/jump-tunnel-endpoint.test.ts b/src/backend/tests/hosts/guacamole/jump-tunnel-endpoint.test.ts new file mode 100644 index 00000000..cca9f1c9 --- /dev/null +++ b/src/backend/tests/hosts/guacamole/jump-tunnel-endpoint.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from "vitest"; +import { resolveJumpTunnelEndpoint } from "../../../hosts/guacamole/jump-tunnel-endpoint.js"; + +describe("resolveJumpTunnelEndpoint", () => { + it("keeps an in-process guacd tunnel on loopback", () => { + expect(resolveJumpTunnelEndpoint("localhost")).toEqual({ + bindHost: "127.0.0.1", + advertisedHost: "127.0.0.1", + }); + }); + + it("exposes the tunnel to a separate guacd container", () => { + expect(resolveJumpTunnelEndpoint("guacd")).toEqual({ + bindHost: "0.0.0.0", + advertisedHost: "termix", + }); + }); + + it("supports a custom backend hostname for external guacd", () => { + expect( + resolveJumpTunnelEndpoint("guacd.example", "termix-backend"), + ).toEqual({ + bindHost: "0.0.0.0", + advertisedHost: "termix-backend", + }); + }); +}); diff --git a/src/backend/tests/hosts/guacamole/recording-settings.test.ts b/src/backend/tests/hosts/guacamole/recording-settings.test.ts new file mode 100644 index 00000000..68b19c53 --- /dev/null +++ b/src/backend/tests/hosts/guacamole/recording-settings.test.ts @@ -0,0 +1,68 @@ +import { describe, expect, it } from "vitest"; +import { withRecordingSettings } from "../../../hosts/guacamole/recording-settings.js"; + +const PATH = "/app/data/session_recordings/guacamole"; +const NAME = "b7e6c0f2-0000-4000-8000-000000000000.guac"; + +describe("withRecordingSettings", () => { + it("takes ownership of the location and filename", () => { + const merged = withRecordingSettings( + { + "recording-path": "/var/lib/termix/recordings", + "recording-name": "${GUAC_USERNAME}-${GUAC_DATE}", + "create-recording-path": false, + }, + PATH, + NAME, + ); + + expect(merged).toMatchObject({ + "recording-path": PATH, + "recording-name": NAME, + "create-recording-path": true, + }); + }); + + it("defaults the content flags when the host has no opinion", () => { + expect(withRecordingSettings({}, PATH, NAME)).toMatchObject({ + "recording-exclude-output": false, + "recording-include-keys": true, + }); + }); + + it("keeps the host's content flags, including the falsy ones", () => { + const merged = withRecordingSettings( + { + "recording-exclude-output": true, + "recording-include-keys": false, + }, + PATH, + NAME, + ); + + expect(merged).toMatchObject({ + "recording-exclude-output": true, + "recording-include-keys": false, + }); + }); + + it("leaves unrelated settings alone", () => { + const merged = withRecordingSettings( + { "recording-exclude-mouse": true, width: "1920" }, + PATH, + NAME, + ); + + expect(merged).toMatchObject({ + "recording-exclude-mouse": true, + width: "1920", + }); + }); + + it("does not mutate the settings it was given", () => { + const original = { "recording-path": "/tmp/mine" }; + withRecordingSettings(original, PATH, NAME); + + expect(original).toEqual({ "recording-path": "/tmp/mine" }); + }); +}); diff --git a/src/backend/tests/hosts/host-resolver.test.ts b/src/backend/tests/hosts/host-resolver.test.ts index 68a9fc56..f068e15c 100644 --- a/src/backend/tests/hosts/host-resolver.test.ts +++ b/src/backend/tests/hosts/host-resolver.test.ts @@ -6,22 +6,25 @@ const state = vi.hoisted(() => ({ isAdminBypass: false, overrideCredentialId: null as number | null, credentials: new Map>(), - sharedSecret: null as Record | null, + vaultProfile: null as Record | null, auditCalls: [] as Record[], folderCredentialId: null as number | null, + sharedSecret: null as Record | null, })); vi.mock("../../database/repositories/factory.js", () => ({ createCurrentHostResolutionRepository: () => ({ findHostOwnerId: async () => (state.host?.userId as string) ?? null, findHostById: async () => (state.host ? { ...state.host } : null), - findOverrideCredentialId: async () => state.overrideCredentialId, findCredentialByIdForUser: async (credentialId: number, userId: string) => state.credentials.get(`${credentialId}:${userId}`) ?? null, findFolderCredentialId: async () => state.folderCredentialId, }), + createCurrentSharedHostAuthOverrideRepository: () => ({ + findCredentialId: async () => state.overrideCredentialId, + }), createCurrentVaultProfileRepository: () => ({ - findById: async () => null, + findById: async () => state.vaultProfile, }), createCurrentUserRepository: () => ({ findById: async (userId: string) => ({ id: userId, username: userId }), @@ -79,6 +82,7 @@ function baseHost(overrides: Record = {}) { keyPassword: null, keyType: null, credentialId: null, + shareSshAuth: false, vaultProfileId: null, sudoPassword: "owner-sudo", autostartPassword: "auto-pass", @@ -101,9 +105,10 @@ beforeEach(() => { state.isAdminBypass = false; state.overrideCredentialId = null; state.credentials.clear(); - state.sharedSecret = null; + state.vaultProfile = null; state.auditCalls = []; state.folderCredentialId = null; + state.sharedSecret = null; }); describe("resolveHostById", () => { @@ -198,8 +203,12 @@ describe("resolveHostById", () => { expect(host.password).toBe("host-pass"); }); - it("uses the share snapshot for a non-owner and strips owner-only secrets", async () => { - state.host = baseHost({ username: "" }); + it("does not expose the owner's secret-backed SSH authentication", async () => { + expect(await resolveHostById(42, "recipient")).toBeNull(); + }); + + it("uses the owner-provided SSH snapshot when sharing is enabled", async () => { + state.host = baseHost({ shareSshAuth: true, username: "host-user" }); state.sharedSecret = { username: "shared-user", authType: "password", @@ -210,14 +219,56 @@ describe("resolveHostById", () => { string, unknown >; + expect(host.username).toBe("host-user"); expect(host.password).toBe("shared-pass"); - expect(host.username).toBe("shared-user"); - expect(host.sudoPassword).toBeNull(); - expect(host.autostartPassword).toBeNull(); + expect(host.authType).toBe("password"); }); - it("prefers the recipient's override credential over the snapshot", async () => { - state.host = baseHost({ username: "" }); + it("denies shared secret-backed auth when the opted-in snapshot is missing", async () => { + state.host = baseHost({ shareSshAuth: true }); + expect(await resolveHostById(42, "recipient")).toBeNull(); + }); + + it("keeps SSH agent authentication private unless the owner opts in", async () => { + state.host = baseHost({ + authType: "agent", + password: null, + terminalConfig: JSON.stringify({ + agentSocketPath: "/run/user/1000/ssh-agent.sock", + }), + }); + + expect(await resolveHostById(42, "recipient")).toBeNull(); + }); + + it("allows SSH agent authentication after the owner explicitly opts in", async () => { + state.host = baseHost({ + authType: "agent", + password: null, + shareSshAuth: true, + terminalConfig: JSON.stringify({ + agentSocketPath: "/run/user/1000/ssh-agent.sock", + }), + }); + + const host = (await resolveHostById(42, "recipient")) as Record< + string, + unknown + >; + expect(host.authType).toBe("agent"); + expect(host.terminalConfig).toEqual({ + agentSocketPath: "/run/user/1000/ssh-agent.sock", + sudoPassword: null, + }); + }); + + it("uses the recipient's credential instead of the owner's authentication", async () => { + state.host = baseHost({ username: "", shareSshAuth: true }); + state.sharedSecret = { + username: "shared-user", + authType: "password", + password: "shared-pass", + }; state.overrideCredentialId = 5; state.credentials.set("5:recipient", { id: 5, @@ -229,11 +280,6 @@ describe("resolveHostById", () => { keyPassword: null, keyType: null, }); - state.sharedSecret = { - username: "shared-user", - authType: "password", - password: "shared-pass", - }; const host = (await resolveHostById(42, "recipient")) as Record< string, @@ -243,14 +289,122 @@ describe("resolveHostById", () => { expect(host.username).toBe("my-user"); }); - it("denies a non-owner when a secret-bearing host has no snapshot", async () => { + it("uses the recipient credential username even when the owner forces their own credential username", async () => { + state.host = baseHost({ + username: "owner-login", + overrideCredentialUsername: true, + }); + state.overrideCredentialId = 5; + state.credentials.set("5:recipient", { + id: 5, + username: "recipient-login", + authType: "key", + password: null, + privateKey: "RECIPIENT-KEY", + key: null, + keyPassword: null, + keyType: "ssh-ed25519", + }); + + const host = (await resolveHostById(42, "recipient")) as Record< + string, + unknown + >; + expect(host.username).toBe("recipient-login"); + expect(host.authType).toBe("key"); + expect(host.key).toBe("RECIPIENT-KEY"); + }); + + it("fully replaces Vault authentication with the recipient override", async () => { + state.host = baseHost({ + authType: "vault", + password: null, + vaultProfileId: 7, + }); + state.vaultProfile = { id: 7 }; + state.overrideCredentialId = 5; + state.credentials.set("5:recipient", { + id: 5, + username: "recipient-login", + authType: "key", + password: null, + privateKey: "RECIPIENT-KEY", + key: null, + keyPassword: null, + keyType: "ssh-ed25519", + certPublicKey: "ssh-ed25519-cert-v01@example certificate", + }); + + const host = (await resolveHostById(42, "recipient")) as Record< + string, + unknown + >; + expect(host.authType).toBe("key"); + expect(host.key).toBe("RECIPIENT-KEY"); + expect(host.certPublicKey).toBe("ssh-ed25519-cert-v01@example certificate"); + expect(host.vaultProfile).toBeUndefined(); + }); + + it("falls back to the host username when the override credential has none", async () => { + state.host = baseHost({ username: "shared-login" }); + state.overrideCredentialId = 5; + state.credentials.set("5:recipient", { + id: 5, + username: null, + authType: "password", + password: "my-pass", + privateKey: null, + key: null, + keyPassword: null, + keyType: null, + }); + + const host = (await resolveHostById(42, "recipient")) as Record< + string, + unknown + >; + expect(host.username).toBe("shared-login"); + }); + + it("denies a non-owner when a secret-bearing host has no personal credential", async () => { + expect(await resolveHostById(42, "recipient")).toBeNull(); + }); + + it("ignores a stored override when shared access is inactive", async () => { + state.hasAccess = false; + state.overrideCredentialId = 5; + state.credentials.set("5:recipient", { + id: 5, + username: "recipient", + authType: "password", + password: "my-pass", + }); + expect(await resolveHostById(42, "recipient")).toBeNull(); }); it("lets a non-owner through on secret-less auth types without a snapshot", async () => { - state.host = baseHost({ authType: "none", password: null }); - const host = await resolveHostById(42, "recipient"); - expect(host).not.toBeNull(); + state.host = baseHost({ + authType: "none", + password: "stale-owner-password", + key: "stale-owner-key", + credentialId: null, + terminalConfig: JSON.stringify({ + theme: "termix", + sudoPassword: "owner-sudo", + }), + }); + const host = (await resolveHostById(42, "recipient")) as Record< + string, + unknown + >; + expect(host.password).toBeNull(); + expect(host.key).toBeNull(); + expect(host.credentialId).toBeNull(); + expect(host.terminalConfig).toEqual({ + theme: "termix", + sudoPassword: null, + }); }); it("resolves an admin bypass like the owner, keeping owner-only secrets", async () => { @@ -301,4 +455,26 @@ describe("resolveHostById", () => { await resolveHostById(42, "owner"); expect(state.auditCalls).toHaveLength(0); }); + + it("parses an empty port_knock_sequence '[]' string into an empty array (no bogus knock)", async () => { + state.host = baseHost({ portKnockSequence: "[]" }); + const host = (await resolveHostById(42, "owner")) as Record< + string, + unknown + >; + expect(host.portKnockSequence).toEqual([]); + }); + + it("parses a real port_knock_sequence JSON string into an array", async () => { + state.host = baseHost({ + portKnockSequence: '[{"port":1234,"protocol":"tcp","delay":100}]', + }); + const host = (await resolveHostById(42, "owner")) as Record< + string, + unknown + >; + expect(host.portKnockSequence).toEqual([ + { port: 1234, protocol: "tcp", delay: 100 }, + ]); + }); }); diff --git a/src/backend/tests/hosts/metrics/login-alert-route-order.test.ts b/src/backend/tests/hosts/metrics/login-alert-route-order.test.ts new file mode 100644 index 00000000..11f4c422 --- /dev/null +++ b/src/backend/tests/hosts/metrics/login-alert-route-order.test.ts @@ -0,0 +1,27 @@ +import { describe, it, expect } from "vitest"; +import fs from "fs"; +import path from "path"; + +// Regression guard for: the /internal/login-alert route was registered +// after the global JWT auth middleware, so every service-to-service login +// alert got rejected with 401 before the route's own IP+token check ever +// ran. Spinning up the full metrics-service Express app (DB, SSH clients, +// polling managers, etc.) just to hit this one route is out of scope, so +// this asserts the registration order directly against the source instead. +describe("metrics service /internal/login-alert route order", () => { + it("is registered before the global auth middleware", () => { + const source = fs.readFileSync( + path.resolve(__dirname, "../../../hosts/metrics/index.ts"), + "utf8", + ); + + const routeIndex = source.indexOf('app.post("/internal/login-alert"'); + const authMiddlewareIndex = source.indexOf( + "app.use(authManager.createAuthMiddleware())", + ); + + expect(routeIndex).toBeGreaterThan(-1); + expect(authMiddlewareIndex).toBeGreaterThan(-1); + expect(routeIndex).toBeLessThan(authMiddlewareIndex); + }); +}); diff --git a/src/backend/tests/hosts/metrics/widgets/disk-collector.test.ts b/src/backend/tests/hosts/metrics/widgets/disk-collector.test.ts index 70d17e30..165354a8 100644 --- a/src/backend/tests/hosts/metrics/widgets/disk-collector.test.ts +++ b/src/backend/tests/hosts/metrics/widgets/disk-collector.test.ts @@ -2,6 +2,8 @@ import { describe, it, expect } from "vitest"; import { parseDfLines, findWorstMountIndex, + buildFilesystemList, + selectPrimaryFilesystem, } from "../../../../hosts/metrics/widgets/disk-collector.js"; describe("parseDfLines", () => { @@ -58,3 +60,73 @@ describe("findWorstMountIndex", () => { expect(worst.totalBytes).toBe(0); }); }); + +const BYTES_OUTPUT = + "/dev/nvme0n1p2 1000 400 600 40% /\n" + + "/dev/nvme1n1p1 2000 1900 100 95% /data\n"; +const HUMAN_OUTPUT = + "/dev/nvme0n1p2 1.0K 400 600 40% /\n" + + "/dev/nvme1n1p1 2.0K 1.9K 100 95% /data\n"; + +describe("buildFilesystemList", () => { + it("returns every real filesystem with byte maths and human strings", () => { + const list = buildFilesystemList( + parseDfLines(BYTES_OUTPUT), + parseDfLines(HUMAN_OUTPUT), + ); + expect(list).toHaveLength(2); + expect(list[0]).toMatchObject({ + mount: "/", + percent: 40, + usedHuman: "400", + totalHuman: "1.0K", + availableHuman: "600", + usedBytes: 400, + totalBytes: 1000, + }); + expect(list[1]).toMatchObject({ mount: "/data", percent: 95 }); + }); + + it("matches human rows by mount when the row counts differ", () => { + const list = buildFilesystemList( + parseDfLines(BYTES_OUTPUT), + parseDfLines("/dev/nvme1n1p1 2.0K 1.9K 100 95% /data\n"), + ); + expect(list[0].totalHuman).toBeNull(); + expect(list[1].totalHuman).toBe("2.0K"); + }); + + it("drops filesystems with a zero or invalid total", () => { + const list = buildFilesystemList( + parseDfLines("/dev/sda1 0 0 0 0% /broken\n/dev/sda2 100 40 60 40% /ok\n"), + [], + ); + expect(list).toHaveLength(1); + expect(list[0].mount).toBe("/ok"); + }); +}); + +describe("selectPrimaryFilesystem", () => { + it("prefers root over a fuller secondary mount", () => { + const list = buildFilesystemList( + parseDfLines(BYTES_OUTPUT), + parseDfLines(HUMAN_OUTPUT), + ); + expect(selectPrimaryFilesystem(list)?.mount).toBe("/"); + }); + + it("falls back to the most-utilized mount when there is no root", () => { + const list = buildFilesystemList( + parseDfLines( + "/dev/sda1 1000 100 900 10% /mnt/a\n" + + "/dev/sda2 1000 800 200 80% /mnt/b\n", + ), + [], + ); + expect(selectPrimaryFilesystem(list)?.mount).toBe("/mnt/b"); + }); + + it("returns null for an empty list", () => { + expect(selectPrimaryFilesystem([])).toBeNull(); + }); +}); diff --git a/src/backend/tests/hosts/tailscale-check.test.ts b/src/backend/tests/hosts/tailscale-check.test.ts new file mode 100644 index 00000000..73058263 --- /dev/null +++ b/src/backend/tests/hosts/tailscale-check.test.ts @@ -0,0 +1,76 @@ +import { describe, it, expect } from "vitest"; +import { + parseTailscaleCheckBanner, + isTailscaleCheckCompleteBanner, +} from "../../hosts/tailscale-check.js"; + +describe("parseTailscaleCheckBanner", () => { + it("extracts the login URL from a real check-mode banner", () => { + const banner = + "# Tailscale SSH requires an additional check.\n# To authenticate, visit: https://login.tailscale.com/a/lefcb2f3377403\n"; + + const result = parseTailscaleCheckBanner(banner); + + expect(result).not.toBeNull(); + expect(result?.url).toBe("https://login.tailscale.com/a/lefcb2f3377403"); + }); + + it("strips comment markers from the message it returns", () => { + const banner = + "# Tailscale SSH requires an additional check.\n# To authenticate, visit: https://login.tailscale.com/a/abc123\n"; + + const result = parseTailscaleCheckBanner(banner); + + expect(result?.message).toBe( + "Tailscale SSH requires an additional check.\nTo authenticate, visit: https://login.tailscale.com/a/abc123", + ); + }); + + it("returns null for an ordinary MOTD banner", () => { + const banner = + "Welcome to Ubuntu 24.04 LTS\nLast login: Tue Aug 5 09:12:03 2026\n"; + + expect(parseTailscaleCheckBanner(banner)).toBeNull(); + }); + + it("returns null for a lookalike URL on another host", () => { + const banner = + "# To authenticate, visit: https://login.tailscale.com.evil.example/a/abc123\n"; + + expect(parseTailscaleCheckBanner(banner)).toBeNull(); + }); + + it("returns null for empty input", () => { + expect(parseTailscaleCheckBanner("")).toBeNull(); + }); +}); + +describe("isTailscaleCheckCompleteBanner", () => { + it("recognises the completion banner", () => { + expect( + isTailscaleCheckCompleteBanner( + "# Authentication checked with Tailscale SSH.", + ), + ).toBe(true); + }); + + it("recognises the completion banner with a time suffix", () => { + expect( + isTailscaleCheckCompleteBanner( + "Authentication checked with Tailscale SSH. Time since last authentication: 0s", + ), + ).toBe(true); + }); + + it("does not match the check-required banner", () => { + expect( + isTailscaleCheckCompleteBanner( + "# Tailscale SSH requires an additional check.", + ), + ).toBe(false); + }); + + it("does not match empty input", () => { + expect(isTailscaleCheckCompleteBanner("")).toBe(false); + }); +}); diff --git a/src/backend/tests/hosts/tmux/auth-utils.test.ts b/src/backend/tests/hosts/tmux/auth-utils.test.ts new file mode 100644 index 00000000..23488c86 --- /dev/null +++ b/src/backend/tests/hosts/tmux/auth-utils.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, it } from "vitest"; +import { getTmuxAuthBehavior } from "../../../hosts/tmux/auth-utils.js"; + +describe("getTmuxAuthBehavior", () => { + it("uses credentialless non-interactive authentication for Tailscale SSH", () => { + expect(getTmuxAuthBehavior("tailscale")).toEqual({ + credentialless: true, + tryKeyboard: false, + }); + }); + + it("preserves keyboard-interactive fallback for none authentication", () => { + expect(getTmuxAuthBehavior("none")).toEqual({ + credentialless: true, + tryKeyboard: true, + }); + }); + + it("does not treat password authentication as credentialless", () => { + expect(getTmuxAuthBehavior("password")).toEqual({ + credentialless: false, + tryKeyboard: true, + }); + }); +}); diff --git a/src/backend/tests/utils/alert-trigger.test.ts b/src/backend/tests/utils/alert-trigger.test.ts new file mode 100644 index 00000000..1edf42db --- /dev/null +++ b/src/backend/tests/utils/alert-trigger.test.ts @@ -0,0 +1,94 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { triggerLoginAlert } from "../../utils/alert-trigger.js"; +import { SystemCrypto } from "../../utils/system-crypto.js"; +import { sshLogger } from "../../utils/logger.js"; + +describe("triggerLoginAlert", () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("reports a rejected metrics-service request", async () => { + vi.spyOn(SystemCrypto, "getInstance").mockReturnValue({ + getInternalAuthToken: vi.fn().mockResolvedValue("internal-token"), + } as never); + vi.spyOn(globalThis, "fetch").mockResolvedValue( + new Response('{"error":"Missing authentication token"}', { + status: 401, + }), + ); + const warn = vi.spyOn(sshLogger, "warn").mockImplementation(() => {}); + + await triggerLoginAlert(7, "user-1", "root", "192.0.2.1"); + + expect(warn).toHaveBeenCalledWith( + "Failed to trigger login alert", + expect.objectContaining({ + operation: "login_alert_trigger_error", + hostId: 7, + error: + 'Metrics service returned 401: {"error":"Missing authentication token"}', + }), + ); + }); + + it("does not log a warning when the metrics service accepts the event", async () => { + vi.spyOn(SystemCrypto, "getInstance").mockReturnValue({ + getInternalAuthToken: vi.fn().mockResolvedValue("internal-token"), + } as never); + vi.spyOn(globalThis, "fetch").mockResolvedValue( + new Response('{"ok":true}', { status: 200 }), + ); + const warn = vi.spyOn(sshLogger, "warn").mockImplementation(() => {}); + + await triggerLoginAlert(7, "user-1", "root", "192.0.2.1"); + + expect(warn).not.toHaveBeenCalled(); + }); + + it("sends the internal auth token and login details the metrics service expects", async () => { + vi.spyOn(SystemCrypto, "getInstance").mockReturnValue({ + getInternalAuthToken: vi.fn().mockResolvedValue("internal-token"), + } as never); + const fetchSpy = vi + .spyOn(globalThis, "fetch") + .mockResolvedValue(new Response('{"ok":true}', { status: 200 })); + + await triggerLoginAlert(42, "user-1", "root", "10.0.0.5"); + + expect(fetchSpy).toHaveBeenCalledWith( + "http://localhost:30005/internal/login-alert", + expect.objectContaining({ + method: "POST", + headers: expect.objectContaining({ + "x-internal-auth": "internal-token", + }), + body: JSON.stringify({ + hostId: 42, + userId: "user-1", + sshUser: "root", + fromIp: "10.0.0.5", + }), + }), + ); + }); + + it("logs a warning if the fetch itself throws, instead of propagating", async () => { + vi.spyOn(SystemCrypto, "getInstance").mockReturnValue({ + getInternalAuthToken: vi.fn().mockResolvedValue("internal-token"), + } as never); + vi.spyOn(globalThis, "fetch").mockRejectedValue( + new Error("connect ECONNREFUSED"), + ); + const warn = vi.spyOn(sshLogger, "warn").mockImplementation(() => {}); + + await expect( + triggerLoginAlert(1, "user-1", "root", "127.0.0.1"), + ).resolves.toBeUndefined(); + + expect(warn).toHaveBeenCalledWith( + "Failed to trigger login alert", + expect.objectContaining({ hostId: 1 }), + ); + }); +}); diff --git a/src/backend/tests/utils/analytics.test.ts b/src/backend/tests/utils/analytics.test.ts index a64b3bdd..a121051c 100644 --- a/src/backend/tests/utils/analytics.test.ts +++ b/src/backend/tests/utils/analytics.test.ts @@ -53,6 +53,7 @@ describe("analytics", () => { beforeEach(() => { vi.clearAllMocks(); process.env = { ...originalEnv }; + delete process.env.ENABLE_TELEMETRY; }); afterEach(() => { @@ -60,6 +61,7 @@ describe("analytics", () => { }); it("isAnalyticsEnabled defaults to true via the settings repository", async () => { + delete process.env.ENABLE_TELEMETRY; mockGetBoolean.mockResolvedValue(true); const { isAnalyticsEnabled } = await import("../../utils/analytics.js"); @@ -69,6 +71,60 @@ describe("analytics", () => { expect(mockGetBoolean).toHaveBeenCalledWith("analytics_enabled", true); }); + it("ENABLE_TELEMETRY=false disables analytics without consulting the database", async () => { + process.env.ENABLE_TELEMETRY = "false"; + const { isAnalyticsEnabled } = await import("../../utils/analytics.js"); + + const result = await isAnalyticsEnabled(); + + expect(result).toBe(false); + expect(mockGetBoolean).not.toHaveBeenCalled(); + }); + + it("ENABLE_TELEMETRY=true forces analytics on without consulting the database", async () => { + process.env.ENABLE_TELEMETRY = "TRUE"; + const { isAnalyticsEnabled } = await import("../../utils/analytics.js"); + + const result = await isAnalyticsEnabled(); + + expect(result).toBe(true); + expect(mockGetBoolean).not.toHaveBeenCalled(); + }); + + it("getTelemetryEnvOverride returns null when unset or blank", async () => { + const { getTelemetryEnvOverride } = + await import("../../utils/analytics.js"); + + delete process.env.ENABLE_TELEMETRY; + expect(getTelemetryEnvOverride()).toBe(null); + + process.env.ENABLE_TELEMETRY = " "; + expect(getTelemetryEnvOverride()).toBe(null); + }); + + it("startAnalyticsHeartbeat sends nothing when ENABLE_TELEMETRY=false", async () => { + process.env.ENABLE_TELEMETRY = "false"; + process.env.POSTHOG_API_KEY = "phc_test"; + const { startAnalyticsHeartbeat } = + await import("../../utils/analytics.js"); + + startAnalyticsHeartbeat(); + await Promise.resolve(); + + expect(mockPost).not.toHaveBeenCalled(); + }); + + it("collectAndSendHeartbeat does not call PostHog when ENABLE_TELEMETRY=false", async () => { + process.env.ENABLE_TELEMETRY = "false"; + process.env.POSTHOG_API_KEY = "phc_test"; + const { collectAndSendHeartbeat } = + await import("../../utils/analytics.js"); + + await collectAndSendHeartbeat(); + + expect(mockPost).not.toHaveBeenCalled(); + }); + it("getOrCreateInstanceId returns the existing id without generating one", async () => { mockGet.mockResolvedValue("existing-id"); const { getOrCreateInstanceId } = await import("../../utils/analytics.js"); diff --git a/src/backend/tests/utils/audit-export.test.ts b/src/backend/tests/utils/audit-export.test.ts new file mode 100644 index 00000000..9e31342e --- /dev/null +++ b/src/backend/tests/utils/audit-export.test.ts @@ -0,0 +1,125 @@ +import { describe, expect, it } from "vitest"; +import { + escapeCsvField, + exportFilename, + toCsv, + toNdjson, +} from "../../utils/audit-export.js"; +import type { AuditLogRecord } from "../../database/repositories/audit-log-repository.js"; + +function entry(overrides: Partial = {}): AuditLogRecord { + return { + id: 1, + userId: "u-1", + username: "alice", + action: "delete_host", + resourceType: "host", + resourceId: "9", + resourceName: "prod-db", + details: null, + ipAddress: "203.0.113.9", + userAgent: "Mozilla/5.0", + success: true, + errorMessage: null, + timestamp: "2026-07-28 10:00:00", + ...overrides, + } as AuditLogRecord; +} + +describe("escapeCsvField", () => { + it("leaves plain values alone", () => { + expect(escapeCsvField("prod-db")).toBe("prod-db"); + expect(escapeCsvField(42)).toBe("42"); + expect(escapeCsvField(true)).toBe("true"); + }); + + it("renders null and undefined as empty", () => { + expect(escapeCsvField(null)).toBe(""); + expect(escapeCsvField(undefined)).toBe(""); + }); + + it("quotes and doubles embedded quotes", () => { + expect(escapeCsvField('say "hi"')).toBe('"say ""hi"""'); + }); + + it("quotes values containing commas or newlines", () => { + expect(escapeCsvField("a,b")).toBe('"a,b"'); + expect(escapeCsvField("line1\nline2")).toBe('"line1\nline2"'); + }); + + it("neutralises spreadsheet formulas", () => { + // An audit entry can carry an attacker-chosen resource name; without this + // the exported file executes it when opened. + expect(escapeCsvField("=1+1")).toBe("'=1+1"); + expect(escapeCsvField("+SUM(A1)")).toBe("'+SUM(A1)"); + expect(escapeCsvField("-2+3")).toBe("'-2+3"); + expect(escapeCsvField("@import")).toBe("'@import"); + }); + + it("still quotes a formula that also contains a comma", () => { + expect(escapeCsvField("=A1,B2")).toBe(`"'=A1,B2"`); + }); +}); + +describe("toCsv", () => { + it("writes a header even with no rows", () => { + expect(toCsv([])).toBe( + "id,timestamp,username,userId,action,resourceType,resourceId,resourceName,success,ipAddress,userAgent,errorMessage,details\n", + ); + }); + + it("writes one line per entry in column order", () => { + const lines = toCsv([entry(), entry({ id: 2, username: "bob" })]) + .trim() + .split("\n"); + + expect(lines).toHaveLength(3); + expect( + lines[1].startsWith("1,2026-07-28 10:00:00,alice,u-1,delete_host"), + ).toBe(true); + expect(lines[2].startsWith("2,")).toBe(true); + }); + + it("keeps a detached entry readable", () => { + const line = toCsv([entry({ userId: null })]) + .trim() + .split("\n")[1]; + + // username survives so the row still names who acted. + expect(line).toContain("alice"); + expect(line.split(",")[3]).toBe(""); + }); +}); + +describe("toNdjson", () => { + it("emits one parseable object per line", () => { + const out = toNdjson([entry(), entry({ id: 2 })]); + const parsed = out + .trim() + .split("\n") + .map((line) => JSON.parse(line)); + + expect(parsed).toHaveLength(2); + expect(parsed[0].action).toBe("delete_host"); + expect(parsed[1].id).toBe(2); + }); + + it("returns nothing for an empty set", () => { + expect(toNdjson([])).toBe(""); + }); +}); + +describe("exportFilename", () => { + it("is filesystem-safe and carries the timestamp", () => { + const name = exportFilename("csv", new Date("2026-07-28T10:11:12.000Z")); + + expect(name).toBe("termix-audit-2026-07-28-10-11-12.csv"); + expect(name).not.toMatch(/[:\s]/); + }); + + it("uses the ndjson extension for the streaming format", () => { + expect(exportFilename("ndjson", new Date("2026-07-28T10:11:12.000Z"))).toBe( + "termix-audit-2026-07-28-10-11-12.ndjson", + ); + }); +}); diff --git a/src/backend/tests/utils/audit-forwarder.test.ts b/src/backend/tests/utils/audit-forwarder.test.ts new file mode 100644 index 00000000..fdfa6073 --- /dev/null +++ b/src/backend/tests/utils/audit-forwarder.test.ts @@ -0,0 +1,166 @@ +import { describe, expect, it, vi, beforeEach } from "vitest"; + +const safeFetch = vi.hoisted(() => vi.fn()); +const logs = vi.hoisted(() => ({ info: vi.fn(), warn: vi.fn() })); + +vi.mock("../../utils/safe-outbound-fetch.js", () => ({ + safeOutboundFetch: safeFetch, +})); +vi.mock("../../utils/logger.js", () => ({ databaseLogger: logs })); + +const { + auditForwardTarget, + forwardAuditEntry, + forwardPayload, + resetAuditForwarderState, + AUDIT_FORWARD_URL_ENV, + AUDIT_FORWARD_TOKEN_ENV, +} = await import("../../utils/audit-forwarder.js"); + +const ENTRY = { + userId: "u-1", + username: "alice", + action: "delete_host", + resourceType: "host", + resourceId: "9", + success: true, + ipAddress: "203.0.113.9", +}; + +const NOW = new Date("2026-07-28T10:00:00.000Z"); + +beforeEach(() => { + safeFetch.mockReset(); + logs.info.mockReset(); + logs.warn.mockReset(); + resetAuditForwarderState(); +}); + +describe("auditForwardTarget", () => { + it("is off unless a URL is configured", () => { + expect(auditForwardTarget({})).toBeNull(); + expect(auditForwardTarget({ [AUDIT_FORWARD_URL_ENV]: " " })).toBeNull(); + }); + + it("carries an optional bearer token", () => { + expect( + auditForwardTarget({ [AUDIT_FORWARD_URL_ENV]: "https://siem/ingest" }), + ).toEqual({ url: "https://siem/ingest" }); + + expect( + auditForwardTarget({ + [AUDIT_FORWARD_URL_ENV]: "https://siem/ingest", + [AUDIT_FORWARD_TOKEN_ENV]: "secret", + }), + ).toEqual({ url: "https://siem/ingest", token: "secret" }); + }); +}); + +describe("forwardPayload", () => { + it("matches the export shape, with absent fields as null", () => { + expect(forwardPayload(ENTRY, NOW)).toEqual({ + timestamp: "2026-07-28T10:00:00.000Z", + userId: "u-1", + username: "alice", + action: "delete_host", + resourceType: "host", + resourceId: "9", + resourceName: null, + success: true, + ipAddress: "203.0.113.9", + userAgent: null, + errorMessage: null, + details: null, + }); + }); +}); + +describe("forwardAuditEntry", () => { + const env = { [AUDIT_FORWARD_URL_ENV]: "https://siem.example/ingest" }; + + it("does nothing when forwarding is not configured", async () => { + await expect(forwardAuditEntry(ENTRY, NOW, {})).resolves.toBe(false); + expect(safeFetch).not.toHaveBeenCalled(); + }); + + it("posts one NDJSON line through the SSRF-checked fetch", async () => { + safeFetch.mockResolvedValueOnce({ ok: true, status: 200 }); + + await expect(forwardAuditEntry(ENTRY, NOW, env)).resolves.toBe(true); + + const [url, init] = safeFetch.mock.calls[0]; + expect(url).toBe("https://siem.example/ingest"); + expect(init.method).toBe("POST"); + expect(init.headers["Content-Type"]).toBe("application/x-ndjson"); + expect(init.headers.Authorization).toBeUndefined(); + expect(JSON.parse(init.body.trim()).action).toBe("delete_host"); + expect(init.body.endsWith("\n")).toBe(true); + }); + + it("sends the bearer token when one is set", async () => { + safeFetch.mockResolvedValueOnce({ ok: true, status: 200 }); + + await forwardAuditEntry(ENTRY, NOW, { + ...env, + [AUDIT_FORWARD_TOKEN_ENV]: "secret", + }); + + expect(safeFetch.mock.calls[0][1].headers.Authorization).toBe( + "Bearer secret", + ); + }); + + it("reports a rejected delivery without throwing", async () => { + safeFetch.mockResolvedValueOnce({ ok: false, status: 503 }); + + await expect(forwardAuditEntry(ENTRY, NOW, env)).resolves.toBe(false); + expect(logs.warn).toHaveBeenCalledWith( + "Failed to forward audit entry", + expect.objectContaining({ reason: "collector returned 503" }), + ); + }); + + it("swallows transport errors — a dead SIEM must not break auditing", async () => { + safeFetch.mockRejectedValueOnce(new Error("ECONNREFUSED")); + + await expect(forwardAuditEntry(ENTRY, NOW, env)).resolves.toBe(false); + expect(logs.warn).toHaveBeenCalledWith( + "Failed to forward audit entry", + expect.objectContaining({ reason: "ECONNREFUSED" }), + ); + }); + + it("stops repeating itself once the collector is persistently down", async () => { + safeFetch.mockResolvedValue({ ok: false, status: 500 }); + + for (let i = 0; i < 8; i++) { + await forwardAuditEntry(ENTRY, NOW, env); + } + + // 5 per-entry warnings, then one suppression notice — not 8. + const perEntry = logs.warn.mock.calls.filter( + (call) => call[0] === "Failed to forward audit entry", + ); + expect(perEntry).toHaveLength(5); + expect( + logs.warn.mock.calls.some((call) => + String(call[0]).includes("suppressing further messages"), + ), + ).toBe(true); + // It keeps trying regardless. + expect(safeFetch).toHaveBeenCalledTimes(8); + }); + + it("announces recovery after a suppressed outage", async () => { + safeFetch.mockResolvedValue({ ok: false, status: 500 }); + for (let i = 0; i < 6; i++) await forwardAuditEntry(ENTRY, NOW, env); + + safeFetch.mockResolvedValueOnce({ ok: true, status: 200 }); + await forwardAuditEntry(ENTRY, NOW, env); + + expect(logs.info).toHaveBeenCalledWith( + "Audit forwarding recovered", + expect.objectContaining({ operation: "audit_forward_recovered" }), + ); + }); +}); diff --git a/src/backend/tests/utils/audit-retention-migration.test.ts b/src/backend/tests/utils/audit-retention-migration.test.ts new file mode 100644 index 00000000..a4c4c477 --- /dev/null +++ b/src/backend/tests/utils/audit-retention-migration.test.ts @@ -0,0 +1,190 @@ +import { afterEach, describe, expect, it } from "vitest"; +import Database from "better-sqlite3"; +import { + migrateAuditRetention, + userDeleteIsDestructive, +} from "../../utils/audit-retention-migration.js"; + +let db: Database.Database | null = null; + +afterEach(() => { + db?.close(); + db = null; +}); + +/** The pre-migration shape: both tables cascade from users. */ +function legacyDatabase(): Database.Database { + const sqlite = new Database(":memory:"); + sqlite.exec(` + PRAGMA foreign_keys = ON; + + CREATE TABLE users ( + id TEXT PRIMARY KEY, + username TEXT NOT NULL + ); + + CREATE TABLE ssh_data ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL + ); + + CREATE TABLE host_access ( + id INTEGER PRIMARY KEY AUTOINCREMENT + ); + + CREATE TABLE audit_logs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id TEXT NOT NULL, + username TEXT NOT NULL, + action TEXT NOT NULL, + resource_type TEXT NOT NULL, + resource_id TEXT, + resource_name TEXT, + details TEXT, + ip_address TEXT, + user_agent TEXT, + success INTEGER NOT NULL, + error_message TEXT, + timestamp TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE + ); + + CREATE TABLE session_recordings ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + host_id INTEGER NOT NULL, + user_id TEXT NOT NULL, + access_id INTEGER, + started_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + ended_at TEXT, + duration INTEGER, + commands TEXT, + dangerous_actions TEXT, + recording_path TEXT, + protocol TEXT NOT NULL DEFAULT 'ssh', + format TEXT NOT NULL DEFAULT 'text', + terminated_by_owner INTEGER DEFAULT 0, + termination_reason TEXT, + FOREIGN KEY (host_id) REFERENCES ssh_data (id) ON DELETE CASCADE, + FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE, + FOREIGN KEY (access_id) REFERENCES host_access (id) ON DELETE SET NULL + ); + + INSERT INTO users (id, username) VALUES ('u-1', 'alice'), ('u-2', 'bob'); + INSERT INTO ssh_data (id, name) VALUES (1, 'prod-db'); + + INSERT INTO audit_logs + (user_id, username, action, resource_type, resource_id, success, timestamp) + VALUES + ('u-1', 'alice', 'host.delete', 'host', '1', 1, '2026-07-01 10:00:00'), + ('u-1', 'alice', 'credential.view', 'credential', '9', 1, '2026-07-02 11:00:00'), + ('u-2', 'bob', 'host.create', 'host', '2', 1, '2026-07-03 12:00:00'); + + INSERT INTO session_recordings + (host_id, user_id, started_at, recording_path, protocol, format) + VALUES + (1, 'u-1', '2026-07-01 10:00:00', '/rec/a.guac', 'ssh', 'text'), + (1, 'u-2', '2026-07-03 12:00:00', '/rec/b.guac', 'ssh', 'text'); + `); + return sqlite; +} + +describe("audit retention migration", () => { + it("detects the destructive shape and reports it fixed afterwards", () => { + db = legacyDatabase(); + + expect(userDeleteIsDestructive(db, "audit_logs")).toBe(true); + expect(userDeleteIsDestructive(db, "session_recordings")).toBe(true); + + expect(migrateAuditRetention(db)).toEqual([ + "audit_logs", + "session_recordings", + ]); + + expect(userDeleteIsDestructive(db, "audit_logs")).toBe(false); + expect(userDeleteIsDestructive(db, "session_recordings")).toBe(false); + }); + + it("keeps the audit trail when the user is deleted", () => { + db = legacyDatabase(); + migrateAuditRetention(db); + + db.exec("DELETE FROM users WHERE id = 'u-1'"); + + const rows = db + .prepare( + "SELECT user_id, username, action FROM audit_logs ORDER BY timestamp", + ) + .all() as { user_id: string | null; username: string; action: string }[]; + + expect(rows).toHaveLength(3); + // The account is gone, but the record still names who acted. + expect(rows[0]).toEqual({ + user_id: null, + username: "alice", + action: "host.delete", + }); + expect(rows[2].user_id).toBe("u-2"); + }); + + it("backfills a username onto recordings so they stay attributable", () => { + db = legacyDatabase(); + migrateAuditRetention(db); + + db.exec("DELETE FROM users WHERE id = 'u-1'"); + + const rows = db + .prepare( + "SELECT user_id, username, recording_path FROM session_recordings ORDER BY started_at", + ) + .all() as { user_id: string | null; username: string | null }[]; + + expect(rows).toHaveLength(2); + expect(rows[0].user_id).toBeNull(); + expect(rows[0].username).toBe("alice"); + }); + + it("loses no data in the copy", () => { + db = legacyDatabase(); + const before = db + .prepare("SELECT * FROM audit_logs ORDER BY id") + .all() as Record[]; + + migrateAuditRetention(db); + + const after = db + .prepare("SELECT * FROM audit_logs ORDER BY id") + .all() as Record[]; + + expect(after).toEqual(before); + }); + + it("still cascades recordings when their host is deleted", () => { + db = legacyDatabase(); + migrateAuditRetention(db); + + db.exec("PRAGMA foreign_keys = ON"); + db.exec("DELETE FROM ssh_data WHERE id = 1"); + + expect( + db.prepare("SELECT COUNT(*) AS n FROM session_recordings").get(), + ).toEqual({ n: 0 }); + }); + + it("is idempotent and leaves an already-migrated database alone", () => { + db = legacyDatabase(); + migrateAuditRetention(db); + + const rowsAfterFirst = db.prepare("SELECT * FROM audit_logs").all(); + expect(migrateAuditRetention(db)).toEqual([]); + expect(db.prepare("SELECT * FROM audit_logs").all()).toEqual( + rowsAfterFirst, + ); + }); + + it("does nothing on a database without the tables", () => { + db = new Database(":memory:"); + + expect(() => migrateAuditRetention(db)).not.toThrow(); + expect(migrateAuditRetention(db)).toEqual([]); + }); +}); diff --git a/src/backend/tests/utils/audit-username.test.ts b/src/backend/tests/utils/audit-username.test.ts new file mode 100644 index 00000000..db5e6b17 --- /dev/null +++ b/src/backend/tests/utils/audit-username.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, it, vi, beforeEach } from "vitest"; + +const findById = vi.hoisted(() => vi.fn()); + +vi.mock("../../database/repositories/factory.js", () => ({ + createCurrentAuditLogRepository: () => ({ create: vi.fn() }), + createCurrentUserRepository: () => ({ findById }), +})); + +const { getAuditUsername, getRequestMeta } = + await import("../../utils/audit-logger.js"); + +beforeEach(() => findById.mockReset()); + +describe("getAuditUsername", () => { + it("resolves the username to store alongside the entry", async () => { + findById.mockResolvedValueOnce({ id: "u-1", username: "alice" }); + + await expect(getAuditUsername("u-1")).resolves.toBe("alice"); + }); + + it("falls back to the id for an account that no longer exists", async () => { + findById.mockResolvedValueOnce(undefined); + + await expect(getAuditUsername("u-gone")).resolves.toBe("u-gone"); + }); + + it("never throws, so it cannot break the operation being audited", async () => { + findById.mockRejectedValueOnce(new Error("database unavailable")); + + await expect(getAuditUsername("u-1")).resolves.toBe("u-1"); + }); +}); + +describe("getRequestMeta", () => { + it("prefers the first x-forwarded-for hop", () => { + const meta = getRequestMeta({ + headers: { + "x-forwarded-for": "203.0.113.9, 10.0.0.1", + "user-agent": "Mozilla/5.0", + }, + ip: "10.0.0.1", + } as never); + + expect(meta).toEqual({ + ipAddress: "203.0.113.9", + userAgent: "Mozilla/5.0", + }); + }); + + it("falls back to the socket address", () => { + const meta = getRequestMeta({ headers: {}, ip: "192.0.2.5" } as never); + + expect(meta.ipAddress).toBe("192.0.2.5"); + expect(meta.userAgent).toBe(""); + }); +}); diff --git a/src/backend/tests/utils/crypto-migration/legacy-shared-ssh-auth-opt-in-migration.test.ts b/src/backend/tests/utils/crypto-migration/legacy-shared-ssh-auth-opt-in-migration.test.ts new file mode 100644 index 00000000..5c3e477c --- /dev/null +++ b/src/backend/tests/utils/crypto-migration/legacy-shared-ssh-auth-opt-in-migration.test.ts @@ -0,0 +1,164 @@ +import Database from "better-sqlite3"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const state = vi.hoisted(() => ({ + sqlite: null as unknown as Database.Database, + settings: new Map(), + resyncedHostIds: [] as number[], + saves: [] as string[], +})); + +vi.mock("../../../database/repositories/factory.js", () => ({ + createCurrentSettingsRepository: () => ({ + get: async (key: string) => state.settings.get(key) ?? null, + set: async (key: string, value: string) => { + state.settings.set(key, value); + }, + }), + getCurrentRepositorySqlite: () => state.sqlite, +})); + +vi.mock("../../../utils/shared-host-secrets-manager.js", () => ({ + SharedHostSecretsManager: { + getInstance: () => ({ + resyncHost: async (hostId: number) => { + state.resyncedHostIds.push(hostId); + }, + }), + }, +})); + +vi.mock("../../../utils/database-save-trigger.js", () => ({ + DatabaseSaveTrigger: { + forceSave: async (reason: string) => { + state.saves.push(reason); + }, + }, +})); + +vi.mock("../../../utils/logger.js", () => ({ + databaseLogger: { + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + success: vi.fn(), + }, +})); + +import { runLegacySharedSshAuthOptInMigration } from "../../../utils/crypto-migration/legacy-shared-ssh-auth-opt-in-migration.js"; + +beforeEach(() => { + state.sqlite = new Database(":memory:"); + state.sqlite.exec(` + CREATE TABLE ssh_data ( + id INTEGER PRIMARY KEY, + share_ssh_auth INTEGER NOT NULL DEFAULT 0 + ); + CREATE TABLE host_access ( + id INTEGER PRIMARY KEY, + host_id INTEGER NOT NULL, + expires_at TEXT + ); + CREATE TABLE shared_host_secrets ( + id INTEGER PRIMARY KEY, + host_access_id INTEGER NOT NULL, + protocol TEXT NOT NULL + ); + INSERT INTO ssh_data (id, share_ssh_auth) + VALUES (1, 0), (2, 0), (3, 1), (4, 0), (5, 0); + INSERT INTO host_access (id, host_id, expires_at) + VALUES + (10, 1, NULL), + (30, 3, NULL), + (40, 4, NULL), + (50, 5, '2000-01-01T00:00:00.000Z'); + INSERT INTO shared_host_secrets (id, host_access_id, protocol) + VALUES + (100, 10, 'ssh'), + (400, 40, 'rdp'), + (500, 50, 'ssh'); + `); + state.settings.clear(); + state.resyncedHostIds = []; + state.saves = []; +}); + +afterEach(() => { + state.sqlite.close(); +}); + +describe("runLegacySharedSshAuthOptInMigration", () => { + it("preserves preexisting sharing while leaving unshared hosts private", async () => { + await expect(runLegacySharedSshAuthOptInMigration()).resolves.toEqual({ + enabled: 1, + resynced: 2, + skipped: 0, + }); + + expect( + state.sqlite + .prepare("SELECT id, share_ssh_auth FROM ssh_data ORDER BY id") + .all(), + ).toEqual([ + { id: 1, share_ssh_auth: 1 }, + { id: 2, share_ssh_auth: 0 }, + { id: 3, share_ssh_auth: 1 }, + { id: 4, share_ssh_auth: 0 }, + { id: 5, share_ssh_auth: 0 }, + ]); + expect(state.resyncedHostIds).toEqual([1, 3]); + expect(state.settings.get("legacy_shared_ssh_auth_opt_in_v1")).toBe("done"); + expect(state.saves).toEqual(["legacy_shared_ssh_auth_opt_in_migration"]); + }); + + it("recognizes a legacy SSH credential snapshot as prior sharing evidence", async () => { + state.sqlite.exec(` + CREATE TABLE shared_credentials ( + id INTEGER PRIMARY KEY, + host_access_id INTEGER NOT NULL + ); + INSERT INTO shared_credentials (id, host_access_id) VALUES (1, 40); + `); + + await expect(runLegacySharedSshAuthOptInMigration()).resolves.toEqual({ + enabled: 2, + resynced: 3, + skipped: 0, + }); + + expect( + state.sqlite + .prepare("SELECT share_ssh_auth FROM ssh_data WHERE id = 4") + .get(), + ).toEqual({ share_ssh_auth: 1 }); + expect(state.resyncedHostIds).toEqual([1, 3, 4]); + }); + + it("is idempotent", async () => { + await runLegacySharedSshAuthOptInMigration(); + state.resyncedHostIds = []; + state.saves = []; + + expect(await runLegacySharedSshAuthOptInMigration()).toBeNull(); + expect(state.resyncedHostIds).toEqual([]); + expect(state.saves).toEqual([]); + }); + + it("does not re-share private hosts after the privacy migration has run", async () => { + state.settings.set("private_shared_ssh_auth_v1", "done"); + + await expect(runLegacySharedSshAuthOptInMigration()).resolves.toEqual({ + enabled: 0, + resynced: 1, + skipped: 0, + }); + + expect( + state.sqlite + .prepare("SELECT share_ssh_auth FROM ssh_data WHERE id = 1") + .get(), + ).toEqual({ share_ssh_auth: 0 }); + expect(state.resyncedHostIds).toEqual([3]); + }); +}); diff --git a/src/backend/tests/utils/crypto-migration/private-shared-ssh-auth-migration.test.ts b/src/backend/tests/utils/crypto-migration/private-shared-ssh-auth-migration.test.ts new file mode 100644 index 00000000..aa408d80 --- /dev/null +++ b/src/backend/tests/utils/crypto-migration/private-shared-ssh-auth-migration.test.ts @@ -0,0 +1,104 @@ +import Database from "better-sqlite3"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const state = vi.hoisted(() => ({ + sqlite: null as unknown as Database.Database, + settings: new Map(), + saves: [] as string[], +})); + +vi.mock("../../../database/repositories/factory.js", () => ({ + createCurrentSettingsRepository: () => ({ + get: async (key: string) => state.settings.get(key) ?? null, + set: async (key: string, value: string) => { + state.settings.set(key, value); + }, + }), + getCurrentRepositorySqlite: () => state.sqlite, +})); + +vi.mock("../../../utils/database-save-trigger.js", () => ({ + DatabaseSaveTrigger: { + forceSave: async (reason: string) => { + state.saves.push(reason); + }, + }, +})); + +vi.mock("../../../utils/logger.js", () => ({ + databaseLogger: { + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + success: vi.fn(), + }, +})); + +import { runPrivateSharedSshAuthMigration } from "../../../utils/crypto-migration/private-shared-ssh-auth-migration.js"; + +beforeEach(() => { + state.sqlite = new Database(":memory:"); + state.sqlite.exec(` + CREATE TABLE ssh_data ( + id INTEGER PRIMARY KEY, + share_ssh_auth INTEGER NOT NULL DEFAULT 0 + ); + CREATE TABLE host_access ( + id INTEGER PRIMARY KEY, + host_id INTEGER NOT NULL + ); + CREATE TABLE shared_host_secrets ( + id INTEGER PRIMARY KEY, + host_access_id INTEGER NOT NULL, + protocol TEXT NOT NULL + ); + INSERT INTO ssh_data (id, share_ssh_auth) + VALUES (1, 1), (2, 0); + INSERT INTO host_access (id, host_id) + VALUES (10, 1), (20, 2); + INSERT INTO shared_host_secrets (id, host_access_id, protocol) + VALUES + (1, 10, 'ssh'), + (2, 10, 'rdp'), + (3, 20, 'ssh'), + (4, 20, 'vnc'); + `); + state.settings.clear(); + state.saves = []; +}); + +afterEach(() => { + state.sqlite.close(); +}); + +describe("runPrivateSharedSshAuthMigration", () => { + it("preserves opted-in SSH snapshots and removes only private ones", async () => { + expect(await runPrivateSharedSshAuthMigration()).toBe(1); + expect( + state.sqlite + .prepare( + "SELECT host_access_id, protocol FROM shared_host_secrets ORDER BY id", + ) + .all(), + ).toEqual([ + { host_access_id: 10, protocol: "ssh" }, + { host_access_id: 10, protocol: "rdp" }, + { host_access_id: 20, protocol: "vnc" }, + ]); + expect(state.settings.get("private_shared_ssh_auth_v1")).toBe("done"); + expect(state.saves).toEqual(["private_shared_ssh_auth_migration"]); + }); + + it("is idempotent", async () => { + state.settings.set("private_shared_ssh_auth_v1", "done"); + + expect(await runPrivateSharedSshAuthMigration()).toBeNull(); + expect( + state.sqlite + .prepare("SELECT COUNT(*) AS count FROM shared_host_secrets") + .get(), + ).toEqual({ count: 4 }); + expect(state.saves).toHaveLength(0); + }); +}); diff --git a/src/backend/tests/utils/data-dir-guard.test.ts b/src/backend/tests/utils/data-dir-guard.test.ts new file mode 100644 index 00000000..c1aa0951 --- /dev/null +++ b/src/backend/tests/utils/data-dir-guard.test.ts @@ -0,0 +1,135 @@ +import { afterEach, describe, expect, it } from "vitest"; +import fs from "fs"; +import os from "os"; +import path from "path"; +import { + ALLOW_EMPTY_DATA_DIR_ENV, + assertDataDirIsNotMisconfigured, + DataDirMisconfiguredError, + findDatabaseOutsideDataDir, +} from "../../utils/data-dir-guard.js"; + +const tempDirs: string[] = []; + +function makeTempDir(): string { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "termix-datadir-")); + tempDirs.push(dir); + return dir; +} + +/** Writes a plain (unencrypted) database file into `dir`. */ +function writePlainDatabase(dir: string, size = 4096): string { + fs.mkdirSync(dir, { recursive: true }); + const dbPath = path.join(dir, "db.sqlite"); + fs.writeFileSync(dbPath, Buffer.alloc(size, 1)); + return dbPath; +} + +afterEach(() => { + for (const dir of tempDirs.splice(0)) { + fs.rmSync(dir, { recursive: true, force: true }); + } +}); + +describe("findDatabaseOutsideDataDir", () => { + it("returns null on a genuinely fresh install", () => { + const cwd = makeTempDir(); + const dataDir = path.join(cwd, "db", "data"); + + expect(findDatabaseOutsideDataDir(dataDir, cwd)).toBeNull(); + }); + + it("finds a database left in the legacy data directory", () => { + const cwd = makeTempDir(); + const legacyDir = path.join(cwd, "data"); + writePlainDatabase(legacyDir); + + expect(findDatabaseOutsideDataDir(path.join(cwd, "db", "data"), cwd)).toBe( + legacyDir, + ); + }); + + it("finds a database under the default directory when DATA_DIR points elsewhere", () => { + const cwd = makeTempDir(); + const defaultDir = path.join(cwd, "db", "data"); + writePlainDatabase(defaultDir); + + expect(findDatabaseOutsideDataDir("/mnt/unmounted-volume", cwd)).toBe( + defaultDir, + ); + }); + + it("ignores the configured data directory itself", () => { + const cwd = makeTempDir(); + const dataDir = path.join(cwd, "data"); + writePlainDatabase(dataDir); + + expect(findDatabaseOutsideDataDir(dataDir, cwd)).toBeNull(); + }); + + it("ignores a zero-length database file", () => { + const cwd = makeTempDir(); + writePlainDatabase(path.join(cwd, "data"), 0); + + expect(findDatabaseOutsideDataDir(path.join(cwd, "db", "data"), cwd)).toBe( + null, + ); + }); +}); + +describe("assertDataDirIsNotMisconfigured", () => { + it("passes when no database exists anywhere else", () => { + const cwd = makeTempDir(); + + expect(() => + assertDataDirIsNotMisconfigured(path.join(cwd, "db", "data"), {}, cwd), + ).not.toThrow(); + }); + + it("refuses to start and names both directories", () => { + const cwd = makeTempDir(); + const legacyDir = path.join(cwd, "data"); + writePlainDatabase(legacyDir); + const dataDir = path.join(cwd, "db", "data"); + + expect(() => assertDataDirIsNotMisconfigured(dataDir, {}, cwd)).toThrow( + DataDirMisconfiguredError, + ); + // Matched as substrings, not patterns: Windows paths are full of + // backslash sequences a RegExp would read as escapes. + expect(() => assertDataDirIsNotMisconfigured(dataDir, {}, cwd)).toThrow( + legacyDir, + ); + expect(() => assertDataDirIsNotMisconfigured(dataDir, {}, cwd)).toThrow( + dataDir, + ); + }); + + it("can be overridden to start with a new database", () => { + const cwd = makeTempDir(); + writePlainDatabase(path.join(cwd, "data")); + + for (const value of ["true", "1", "YES", "on"]) { + expect(() => + assertDataDirIsNotMisconfigured( + path.join(cwd, "db", "data"), + { [ALLOW_EMPTY_DATA_DIR_ENV]: value }, + cwd, + ), + ).not.toThrow(); + } + }); + + it("still refuses when the override is not a truthy value", () => { + const cwd = makeTempDir(); + writePlainDatabase(path.join(cwd, "data")); + + expect(() => + assertDataDirIsNotMisconfigured( + path.join(cwd, "db", "data"), + { [ALLOW_EMPTY_DATA_DIR_ENV]: "false" }, + cwd, + ), + ).toThrow(DataDirMisconfiguredError); + }); +}); diff --git a/src/backend/tests/utils/database-save-trigger.test.ts b/src/backend/tests/utils/database-save-trigger.test.ts index 82b38e13..c33d33a9 100644 --- a/src/backend/tests/utils/database-save-trigger.test.ts +++ b/src/backend/tests/utils/database-save-trigger.test.ts @@ -38,4 +38,28 @@ describe("DatabaseSaveTrigger", () => { expect(DatabaseSaveTrigger.isDirty).toBe(false); expect(DatabaseSaveTrigger.getStatus().pendingSave).toBe(false); }); + + it("queues a force save behind an in-flight save", async () => { + let finishFirstSave: (() => void) | undefined; + const firstSave = new Promise((resolve) => { + finishFirstSave = resolve; + }); + const save = vi + .fn<() => Promise>() + .mockReturnValueOnce(firstSave) + .mockResolvedValueOnce(undefined); + DatabaseSaveTrigger.initialize(save); + + const first = DatabaseSaveTrigger.forceSave("first_write"); + await vi.waitFor(() => expect(save).toHaveBeenCalledTimes(1)); + + const second = DatabaseSaveTrigger.forceSave("sso_provider_write"); + expect(save).toHaveBeenCalledTimes(1); + + finishFirstSave?.(); + await Promise.all([first, second]); + + expect(save).toHaveBeenCalledTimes(2); + expect(DatabaseSaveTrigger.getStatus().pendingSave).toBe(false); + }); }); diff --git a/src/backend/tests/utils/safe-outbound-fetch.test.ts b/src/backend/tests/utils/safe-outbound-fetch.test.ts index f20b5406..6f244910 100644 --- a/src/backend/tests/utils/safe-outbound-fetch.test.ts +++ b/src/backend/tests/utils/safe-outbound-fetch.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it, vi } from "vitest"; -import type { LookupAddress, LookupAllOptions } from "dns"; +import type { LookupAddress, LookupAllOptions, LookupOptions } from "dns"; import { createDnsLookupHook, isBlockedAddress, @@ -53,55 +53,133 @@ describe("isBlockedAddress", () => { // private — and testing it through a real Agent/fetch call would only // add flakiness (real TCP connects, undici's own quirks) without adding // coverage of the logic that actually broke. +// +// `lookupOptions.all` controls the *caller's* expected callback shape +// (single address vs. full array) — this is the flag Node's happy-eyeballs +// autoSelectFamily sets to `true`. It's independent of the internal call to +// the underlying resolver, which the hook always forces to `all: true` so it +// has every candidate address available to run the blocklist check against. function runHook( addresses: LookupAddress[], error: NodeJS.ErrnoException | null = null, + lookupOptions: LookupOptions = { all: true }, ) { - const fakeLookup = ( - _host: string, - _opts: LookupAllOptions, - cb: (err: NodeJS.ErrnoException | null, addrs: LookupAddress[]) => void, - ) => cb(error, addresses); + const fakeLookup = vi.fn( + ( + _host: string, + _opts: LookupAllOptions, + cb: (err: NodeJS.ErrnoException | null, addrs: LookupAddress[]) => void, + ) => cb(error, addresses), + ); const hook = createDnsLookupHook(fakeLookup); const callback = vi.fn(); - hook("example.invalid", { all: true }, callback); - return callback; + hook("example.invalid", lookupOptions, callback); + return { callback, fakeLookup }; } +// The three lookupOptions shapes a real caller can pass, and the tail args +// (everything after the leading null/error arg) the hook must answer with +// for each — [] for the array form Node's autoSelectFamily expects, ["", 0] +// for the legacy single-address form. Reused as plain data across the +// it.each tables below, matching the flat tuple style used elsewhere in +// this test suite (see termix-id-keys.test.ts, oidc-desktop-callback.test.ts) +// rather than nesting a parameterized describe block. +const lookupOptionsCases: Array<[string, LookupOptions, unknown[]]> = [ + ["all:true (Node's autoSelectFamily/happy-eyeballs)", { all: true }, [[]]], + ["all:false (legacy)", { all: false } as LookupOptions, ["", 0]], + ["all omitted (legacy)", {} as LookupOptions, ["", 0]], +]; + +// Fixed answer used by the success table below — kept separate from +// lookupOptionsCases because the expected tail args here are the resolved +// address(es) themselves, not a fixed "", 0 vs [] shape. +const publicAddresses = [ + { address: "104.21.52.150", family: 4 }, + { address: "2606:4700:3034::ac43:c88d", family: 6 }, +]; +const successCases: Array<[string, LookupOptions, unknown[]]> = [ + [ + "all:true (Node's autoSelectFamily/happy-eyeballs)", + { all: true }, + [publicAddresses], + ], + [ + "all:false (legacy)", + { all: false } as LookupOptions, + [publicAddresses[0].address, publicAddresses[0].family], + ], + [ + "all omitted (legacy)", + {} as LookupOptions, + [publicAddresses[0].address, publicAddresses[0].family], + ], +]; + describe("createDnsLookupHook", () => { - it("allows a public IPv4 address through", () => { - const callback = runHook([{ address: "104.21.52.150", family: 4 }]); - expect(callback).toHaveBeenCalledWith(null, "104.21.52.150", 4); - }); + it.each(successCases)( + "returns the resolved address(es) on a fully public answer (%s)", + (_label, lookupOptions, tailArgs) => { + const { callback } = runHook(publicAddresses, null, lookupOptions); + expect(callback).toHaveBeenCalledWith(null, ...tailArgs); + }, + ); - it("rejects a private address with the private-destination error", () => { - const callback = runHook([{ address: "192.168.1.1", family: 4 }]); - expect(callback).toHaveBeenCalledWith( - expect.objectContaining({ - message: "Private destinations are not allowed", - }), - "", - 0, + it.each(lookupOptionsCases)( + "rejects if any address is private, including an IPv4-mapped IPv6 spoof not in first position (%s)", + (_label, lookupOptions, tailArgs) => { + const { callback } = runHook( + [ + { address: "104.21.52.150", family: 4 }, + { address: "::ffff:192.168.1.1", family: 6 }, + { address: "2606:4700:3034::ac43:c88d", family: 6 }, + ], + null, + lookupOptions, + ); + expect(callback).toHaveBeenCalledWith( + expect.objectContaining({ + message: "Private destinations are not allowed", + }), + ...tailArgs, + ); + }, + ); + + it.each(lookupOptionsCases)( + "rejects with a distinct error when DNS returns no addresses (%s)", + (_label, lookupOptions, tailArgs) => { + const { callback } = runHook([], null, lookupOptions); + expect(callback).toHaveBeenCalledWith( + expect.objectContaining({ + message: "DNS resolution returned no addresses", + }), + ...tailArgs, + ); + }, + ); + + it.each(lookupOptionsCases)( + "propagates a real DNS lookup error untouched (%s)", + (_label, lookupOptions, tailArgs) => { + const dnsError = Object.assign(new Error("getaddrinfo ENOTFOUND"), { + code: "ENOTFOUND", + }); + const { callback } = runHook([], dnsError, lookupOptions); + expect(callback).toHaveBeenCalledWith(dnsError, ...tailArgs); + }, + ); + + it("always asks the underlying resolver for all:true regardless of the caller's option", () => { + const { fakeLookup } = runHook( + [{ address: "104.21.52.150", family: 4 }], + null, + { all: false }, ); - }); - - it("rejects with a distinct error when DNS returns no addresses", () => { - const callback = runHook([]); - expect(callback).toHaveBeenCalledWith( - expect.objectContaining({ - message: "DNS resolution returned no addresses", - }), - "", - 0, + expect(fakeLookup).toHaveBeenCalledWith( + "example.invalid", + expect.objectContaining({ all: true, verbatim: true }), + expect.any(Function), ); }); - - it("propagates a real DNS lookup error untouched", () => { - const dnsError = Object.assign(new Error("getaddrinfo ENOTFOUND"), { - code: "ENOTFOUND", - }); - const callback = runHook([], dnsError); - expect(callback).toHaveBeenCalledWith(dnsError, "", 0); - }); }); diff --git a/src/backend/tests/utils/shared-host-auth-override-migration.test.ts b/src/backend/tests/utils/shared-host-auth-override-migration.test.ts new file mode 100644 index 00000000..92dc0f50 --- /dev/null +++ b/src/backend/tests/utils/shared-host-auth-override-migration.test.ts @@ -0,0 +1,166 @@ +import Database from "better-sqlite3"; +import { afterEach, describe, expect, it } from "vitest"; +import { + ensureSharedHostAuthOverrideProtocolSchema, + migrateLegacySharedHostAuthOverrides, +} from "../../utils/shared-host-auth-override-migration.js"; + +describe("migrateLegacySharedHostAuthOverrides", () => { + let sqlite: Database.Database | null = null; + + afterEach(() => { + sqlite?.close(); + sqlite = null; + }); + + it("creates protocol-aware storage with SSH as the default", () => { + sqlite = new Database(":memory:"); + sqlite.exec(` + CREATE TABLE users (id TEXT PRIMARY KEY); + CREATE TABLE ssh_data (id INTEGER PRIMARY KEY); + CREATE TABLE ssh_credentials (id INTEGER PRIMARY KEY); + INSERT INTO users (id) VALUES ('recipient'); + INSERT INTO ssh_data (id) VALUES (42); + INSERT INTO ssh_credentials (id) VALUES (7); + `); + + expect(ensureSharedHostAuthOverrideProtocolSchema(sqlite)).toBe("created"); + sqlite + .prepare( + "INSERT INTO shared_host_auth_overrides (host_id, user_id, credential_id) VALUES (?, ?, ?)", + ) + .run(42, "recipient", 7); + expect( + sqlite + .prepare( + "SELECT protocol, credential_id FROM shared_host_auth_overrides", + ) + .get(), + ).toEqual({ protocol: "ssh", credential_id: 7 }); + expect(ensureSharedHostAuthOverrideProtocolSchema(sqlite)).toBe("current"); + }); + + it("moves direct-share overrides once and clears the legacy column", () => { + sqlite = new Database(":memory:"); + sqlite.exec(` + CREATE TABLE host_access ( + id INTEGER PRIMARY KEY, + host_id INTEGER NOT NULL, + user_id TEXT, + role_id INTEGER, + override_credential_id INTEGER + ); + CREATE TABLE shared_host_auth_overrides ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + host_id INTEGER NOT NULL, + user_id TEXT NOT NULL, + protocol TEXT NOT NULL DEFAULT 'ssh', + credential_id INTEGER NOT NULL, + UNIQUE(host_id, user_id, protocol) + ); + INSERT INTO host_access + (id, host_id, user_id, role_id, override_credential_id) + VALUES + (1, 42, 'direct-user', NULL, 7), + (2, 42, NULL, 3, 8), + (3, 43, 'no-override', NULL, NULL); + `); + const settings = new Map(); + + expect( + migrateLegacySharedHostAuthOverrides( + sqlite, + (key) => settings.get(key) ?? null, + (key, value) => settings.set(key, value), + ), + ).toBe(true); + + expect( + sqlite + .prepare( + "SELECT host_id, user_id, protocol, credential_id FROM shared_host_auth_overrides", + ) + .all(), + ).toEqual([ + { + host_id: 42, + user_id: "direct-user", + protocol: "ssh", + credential_id: 7, + }, + ]); + expect( + sqlite + .prepare("SELECT override_credential_id FROM host_access WHERE id = 1") + .get(), + ).toEqual({ override_credential_id: null }); + + sqlite + .prepare("UPDATE host_access SET override_credential_id = 9 WHERE id = 1") + .run(); + expect( + migrateLegacySharedHostAuthOverrides( + sqlite, + (key) => settings.get(key) ?? null, + (key, value) => settings.set(key, value), + ), + ).toBe(false); + expect( + sqlite + .prepare( + "SELECT credential_id FROM shared_host_auth_overrides WHERE host_id = 42", + ) + .get(), + ).toEqual({ credential_id: 7 }); + }); + + it("preserves pre-protocol rows as SSH and permits protocol isolation", () => { + sqlite = new Database(":memory:"); + sqlite.exec(` + CREATE TABLE users (id TEXT PRIMARY KEY); + CREATE TABLE ssh_data (id INTEGER PRIMARY KEY); + CREATE TABLE ssh_credentials (id INTEGER PRIMARY KEY); + CREATE TABLE shared_host_auth_overrides ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + host_id INTEGER NOT NULL, + user_id TEXT NOT NULL, + credential_id INTEGER NOT NULL, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + UNIQUE(host_id, user_id) + ); + INSERT INTO users (id) VALUES ('recipient'); + INSERT INTO ssh_data (id) VALUES (42); + INSERT INTO ssh_credentials (id) VALUES (7), (8); + INSERT INTO shared_host_auth_overrides + (host_id, user_id, credential_id) + VALUES (42, 'recipient', 7); + `); + + expect(ensureSharedHostAuthOverrideProtocolSchema(sqlite)).toBe("migrated"); + expect( + sqlite + .prepare( + "SELECT protocol, credential_id FROM shared_host_auth_overrides WHERE host_id = 42", + ) + .all(), + ).toEqual([{ protocol: "ssh", credential_id: 7 }]); + + sqlite + .prepare( + "INSERT INTO shared_host_auth_overrides (host_id, user_id, protocol, credential_id) VALUES (?, ?, ?, ?)", + ) + .run(42, "recipient", "rdp", 8); + expect( + sqlite + .prepare( + "SELECT protocol, credential_id FROM shared_host_auth_overrides ORDER BY protocol", + ) + .all(), + ).toEqual([ + { protocol: "rdp", credential_id: 8 }, + { protocol: "ssh", credential_id: 7 }, + ]); + expect(ensureSharedHostAuthOverrideProtocolSchema(sqlite)).toBe("current"); + }); +}); diff --git a/src/backend/tests/utils/shared-host-secrets-manager.test.ts b/src/backend/tests/utils/shared-host-secrets-manager.test.ts index 9c41e806..4785db78 100644 --- a/src/backend/tests/utils/shared-host-secrets-manager.test.ts +++ b/src/backend/tests/utils/shared-host-secrets-manager.test.ts @@ -4,13 +4,6 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; const ownerDEK = crypto.randomBytes(32); const targetDEK = crypto.randomBytes(32); -type SecretRow = Record & { - id: number; - hostAccessId: number; - targetUserId: string; - protocol: string; -}; - const state = vi.hoisted(() => ({ hosts: new Map>(), credentials: new Map>(), @@ -142,6 +135,7 @@ function baseHost(overrides: Record = {}) { keyPassword: null, keyType: null, credentialId: null, + shareSshAuth: false, enableSsh: true, enableRdp: false, enableVnc: false, @@ -173,26 +167,29 @@ beforeEach(() => { }); describe("SharedHostSecretsManager", () => { - it("snapshots an inline-password SSH host and the target can decrypt it", async () => { + it("keeps an inline-password SSH host private by default", async () => { state.hosts.set(42, baseHost()); await manager.snapshotForUser(7, 42, "target", "owner"); - expect(state.secretRows).toHaveLength(1); - const row = state.secretRows[0]; - expect(row.protocol).toBe("ssh"); - expect(row.sourceType).toBe("inline"); - expect(row.encryptedPassword).not.toBe("hunter2"); + expect(state.secretRows).toHaveLength(0); + expect(await manager.getSecretForUser(42, "target", "ssh")).toBeNull(); + }); - const secret = await manager.getSecretForUser(42, "target", "ssh"); - expect(secret).toMatchObject({ + it("snapshots inline SSH authentication when the owner opts in", async () => { + state.hosts.set(42, baseHost({ shareSshAuth: true })); + + await manager.snapshotForUser(7, 42, "target", "owner"); + + expect(state.secretRows.map((row) => row.protocol)).toEqual(["ssh"]); + expect(await manager.getSecretForUser(42, "target", "ssh")).toMatchObject({ username: "root", authType: "password", password: "hunter2", }); }); - it("snapshots every enabled protocol from credential and inline sources", async () => { + it("snapshots opted-in SSH credential auth alongside enabled non-SSH protocols", async () => { state.credentials.set(123, { id: 123, userId: "owner", @@ -209,6 +206,7 @@ describe("SharedHostSecretsManager", () => { baseHost({ authType: "credential", credentialId: 123, + shareSshAuth: true, password: null, enableRdp: true, rdpUser: "rdp-admin", @@ -228,8 +226,7 @@ describe("SharedHostSecretsManager", () => { "telnet", ]); - const ssh = await manager.getSecretForUser(42, "target", "ssh"); - expect(ssh).toMatchObject({ + expect(await manager.getSecretForUser(42, "target", "ssh")).toMatchObject({ username: "cred-user", authType: "key", key: "PRIVATE-KEY", @@ -253,28 +250,28 @@ describe("SharedHostSecretsManager", () => { }); it("produces no snapshot rows for secret-less auth types", async () => { - state.hosts.set(42, baseHost({ authType: "opkssh", password: null })); + state.hosts.set( + 42, + baseHost({ + authType: "opkssh", + password: null, + shareSshAuth: true, + }), + ); await manager.snapshotForUser(7, 42, "target", "owner"); expect(state.secretRows).toHaveLength(0); }); - it("removes stale protocol rows on re-snapshot", async () => { - state.hosts.set( - 42, - baseHost({ - enableRdp: true, - rdpUser: "rdp-admin", - rdpPassword: "rdp-pass", - }), - ); + it("removes the SSH snapshot when the owner disables sharing", async () => { + state.hosts.set(42, baseHost({ shareSshAuth: true })); await manager.snapshotForUser(7, 42, "target", "owner"); - expect(state.secretRows).toHaveLength(2); + expect(state.secretRows).toHaveLength(1); - // Owner turns RDP off; the RDP snapshot must disappear. + // Owner makes SSH authentication private again. state.hosts.set(42, baseHost()); await manager.snapshotForUser(7, 42, "target", "owner"); - expect(state.secretRows.map((row) => row.protocol)).toEqual(["ssh"]); + expect(state.secretRows).toHaveLength(0); }); it("fails fast when a participant has no DEK", async () => { @@ -286,7 +283,14 @@ describe("SharedHostSecretsManager", () => { }); it("cannot be decrypted with the wrong DEK", async () => { - state.hosts.set(42, baseHost()); + state.hosts.set( + 42, + baseHost({ + enableRdp: true, + rdpUser: "rdp-admin", + rdpPassword: "rdp-pass", + }), + ); await manager.snapshotForUser(7, 42, "target", "owner"); const row = state.secretRows[0]; @@ -294,14 +298,21 @@ describe("SharedHostSecretsManager", () => { FieldCrypto.decryptField( row.encryptedPassword as string, ownerDEK, - "shared-7-target-ssh", + "shared-7-target-rdp", "password", ), ).toThrow(); }); it("resyncHost re-snapshots direct grants and role members", async () => { - state.hosts.set(42, baseHost()); + state.hosts.set( + 42, + baseHost({ + enableRdp: true, + rdpUser: "rdp-admin", + rdpPassword: "rdp-pass", + }), + ); state.accessToHost = new Map([ [1, 42], [2, 42], @@ -322,16 +333,30 @@ describe("SharedHostSecretsManager", () => { [2, "member-1"], ]); - // Owner rotates the inline password; resync updates the copies. - state.hosts.set(42, baseHost({ password: "rotated" })); + // Owner rotates the non-SSH password; resync updates those copies. + state.hosts.set( + 42, + baseHost({ + enableRdp: true, + rdpUser: "rdp-admin", + rdpPassword: "rotated", + }), + ); await manager.resyncHost(42); - const secret = await manager.getSecretForUser(42, "target", "ssh"); + const secret = await manager.getSecretForUser(42, "target", "rdp"); expect(secret?.password).toBe("rotated"); }); it("snapshotForRoleMember fans out from role grants", async () => { - state.hosts.set(42, baseHost()); + state.hosts.set( + 42, + baseHost({ + enableRdp: true, + rdpUser: "rdp-admin", + rdpPassword: "rdp-pass", + }), + ); state.accessToHost = new Map([[2, 42]]); state.grants = [{ id: 2, hostId: 42, userId: null, roleId: 9 }]; @@ -341,7 +366,7 @@ describe("SharedHostSecretsManager", () => { expect(state.secretRows[0]).toMatchObject({ hostAccessId: 2, targetUserId: "member-1", - protocol: "ssh", + protocol: "rdp", }); }); }); diff --git a/src/backend/tests/utils/system-secret-crypto.test.ts b/src/backend/tests/utils/system-secret-crypto.test.ts new file mode 100644 index 00000000..df5a1c19 --- /dev/null +++ b/src/backend/tests/utils/system-secret-crypto.test.ts @@ -0,0 +1,157 @@ +import { describe, expect, it, vi, beforeEach } from "vitest"; +import crypto from "crypto"; + +const systemKey = crypto.randomBytes(32); +const getEncryptionKey = vi.hoisted(() => vi.fn()); + +vi.mock("../../utils/system-crypto.js", () => ({ + SystemCrypto: { getInstance: () => ({ getEncryptionKey }) }, +})); + +const { + decryptSsoConfigSecrets, + decryptSystemSecret, + encryptSsoConfigSecrets, + encryptSystemSecret, + isSystemEncrypted, + SSO_SECRET_FIELDS, +} = await import("../../utils/system-secret-crypto.js"); + +beforeEach(() => { + getEncryptionKey.mockReset(); + getEncryptionKey.mockResolvedValue(systemKey); +}); + +describe("system secret encryption", () => { + it("round-trips a secret", async () => { + const sealed = await encryptSystemSecret("s3cr3t-client-secret"); + + expect(sealed).not.toContain("s3cr3t"); + expect(isSystemEncrypted(sealed)).toBe(true); + await expect(decryptSystemSecret(sealed)).resolves.toBe( + "s3cr3t-client-secret", + ); + }); + + it("produces a different ciphertext each time", async () => { + const a = await encryptSystemSecret("same"); + const b = await encryptSystemSecret("same"); + + // Random IV per call, so identical secrets are not identifiable. + expect(a).not.toBe(b); + await expect(decryptSystemSecret(a)).resolves.toBe("same"); + await expect(decryptSystemSecret(b)).resolves.toBe("same"); + }); + + it("does not double-encrypt an already sealed value", async () => { + const once = await encryptSystemSecret("value"); + const twice = await encryptSystemSecret(once); + + expect(twice).toBe(once); + }); + + it("leaves empty values alone", async () => { + await expect(encryptSystemSecret("")).resolves.toBe(""); + await expect(decryptSystemSecret("")).resolves.toBe(""); + }); + + it("detects tampering", async () => { + const sealed = await encryptSystemSecret("value"); + const parts = sealed.replace("sysenc:v1:", "").split(":"); + const flipped = Buffer.from(parts[2], "base64"); + flipped[0] ^= 0xff; + const tampered = `sysenc:v1:${parts[0]}:${parts[1]}:${flipped.toString("base64")}`; + + // GCM auth tag must reject a modified payload rather than return garbage. + await expect(decryptSystemSecret(tampered)).rejects.toThrow(); + }); + + it("rejects a malformed sealed value", async () => { + await expect( + decryptSystemSecret("sysenc:v1:only-one-part"), + ).rejects.toThrow(/Malformed/); + }); +}); + +describe("legacy compatibility", () => { + it("decodes values written by the old base64 scheme", async () => { + const legacy = `encoded:${Buffer.from("old-secret").toString("base64")}`; + + // Must keep working: an existing install cannot be locked out of SSO login + // just because the storage format changed. + await expect(decryptSystemSecret(legacy)).resolves.toBe("old-secret"); + }); + + it("decodes the mislabelled 'encrypted:' variant too", async () => { + const legacy = `encrypted:${Buffer.from("old-secret").toString("base64")}`; + + await expect(decryptSystemSecret(legacy)).resolves.toBe("old-secret"); + }); + + it("passes through a value that was never encoded", async () => { + await expect(decryptSystemSecret("plain-secret")).resolves.toBe( + "plain-secret", + ); + }); + + it("upgrades a legacy value on the next write", async () => { + const legacy = `encoded:${Buffer.from("old-secret").toString("base64")}`; + const plaintext = await decryptSystemSecret(legacy); + const sealed = await encryptSystemSecret(plaintext); + + expect(isSystemEncrypted(sealed)).toBe(true); + await expect(decryptSystemSecret(sealed)).resolves.toBe("old-secret"); + }); +}); + +describe("SSO provider config", () => { + it("seals only the secret fields", async () => { + const sealed = await encryptSsoConfigSecrets({ + client_id: "termix", + client_secret: "shhh", + bindPassword: "ldap-pw", + issuer_url: "https://idp.example", + }); + + expect(sealed.client_id).toBe("termix"); + expect(sealed.issuer_url).toBe("https://idp.example"); + expect(isSystemEncrypted(sealed.client_secret as string)).toBe(true); + expect(isSystemEncrypted(sealed.bindPassword as string)).toBe(true); + }); + + it("round-trips a whole config", async () => { + const original = { + client_id: "termix", + client_secret: "shhh", + bindPassword: "ldap-pw", + }; + + const restored = await decryptSsoConfigSecrets( + await encryptSsoConfigSecrets(original), + ); + + expect(restored).toEqual(original); + }); + + it("covers both secret fields", () => { + expect([...SSO_SECRET_FIELDS]).toEqual(["client_secret", "bindPassword"]); + }); + + it("leaves a config without secrets untouched", async () => { + const config = { client_id: "termix", scopes: "openid" }; + + await expect(encryptSsoConfigSecrets(config)).resolves.toEqual(config); + await expect(decryptSsoConfigSecrets(config)).resolves.toEqual(config); + }); + + it("does not let one unreadable secret take down the provider", async () => { + const restored = await decryptSsoConfigSecrets({ + client_id: "termix", + client_secret: "sysenc:v1:bad", + }); + + // The rest of the config survives; login fails later with a clearer error. + expect(restored.client_id).toBe("termix"); + expect(restored.client_secret).toBe("sysenc:v1:bad"); + }); +}); diff --git a/src/backend/utils/alert-trigger.ts b/src/backend/utils/alert-trigger.ts index c716465b..2098bec0 100644 --- a/src/backend/utils/alert-trigger.ts +++ b/src/backend/utils/alert-trigger.ts @@ -11,14 +11,23 @@ export async function triggerLoginAlert( ): Promise { try { const token = await SystemCrypto.getInstance().getInternalAuthToken(); - await fetch(`${METRICS_SERVICE_URL}/internal/login-alert`, { - method: "POST", - headers: { - "Content-Type": "application/json", - "x-internal-auth": token, + const response = await fetch( + `${METRICS_SERVICE_URL}/internal/login-alert`, + { + method: "POST", + headers: { + "Content-Type": "application/json", + "x-internal-auth": token, + }, + body: JSON.stringify({ hostId, userId, sshUser, fromIp }), }, - body: JSON.stringify({ hostId, userId, sshUser, fromIp }), - }); + ); + if (!response.ok) { + const details = await response.text(); + throw new Error( + `Metrics service returned ${response.status}${details ? `: ${details}` : ""}`, + ); + } } catch (err) { sshLogger.warn("Failed to trigger login alert", { operation: "login_alert_trigger_error", diff --git a/src/backend/utils/analytics.ts b/src/backend/utils/analytics.ts index 9271962a..0013f9c0 100644 --- a/src/backend/utils/analytics.ts +++ b/src/backend/utils/analytics.ts @@ -24,7 +24,18 @@ const FEATURE_ACTIVITY_TYPES = [ const POSTHOG_HOST = process.env.POSTHOG_HOST || "https://us.i.posthog.com"; const HEARTBEAT_INTERVAL_MS = 24 * 60 * 60 * 1000; +export function getTelemetryEnvOverride(): boolean | null { + const envVal = process.env.ENABLE_TELEMETRY; + if (envVal === undefined) return null; + const normalized = envVal.trim().toLowerCase(); + if (normalized === "") return null; + return normalized === "true"; +} + export async function isAnalyticsEnabled(): Promise { + const override = getTelemetryEnvOverride(); + if (override !== null) return override; + return createCurrentSettingsRepository().getBoolean( "analytics_enabled", true, @@ -122,6 +133,13 @@ export async function collectAndSendHeartbeat(): Promise { } export function startAnalyticsHeartbeat(): void { + if (getTelemetryEnvOverride() === false) { + analyticsLogger.info("Telemetry disabled by ENABLE_TELEMETRY", { + operation: "analytics_disabled_by_env", + }); + return; + } + if (!process.env.POSTHOG_API_KEY) { analyticsLogger.info("Analytics disabled: POSTHOG_API_KEY not set", { operation: "analytics_disabled_no_key", diff --git a/src/backend/utils/audit-export.ts b/src/backend/utils/audit-export.ts new file mode 100644 index 00000000..5f84891c --- /dev/null +++ b/src/backend/utils/audit-export.ts @@ -0,0 +1,70 @@ +import type { AuditLogRecord } from "../database/repositories/audit-log-repository.js"; + +/** Column order for CSV export; also the header row. */ +const COLUMNS = [ + "id", + "timestamp", + "username", + "userId", + "action", + "resourceType", + "resourceId", + "resourceName", + "success", + "ipAddress", + "userAgent", + "errorMessage", + "details", +] as const; + +/** + * RFC 4180 field escaping. + * + * The leading-character guard is not part of RFC 4180: a field starting with + * `=`, `+`, `-` or `@` is treated as a formula by spreadsheet software, so an + * audit entry containing an attacker-chosen resource name could execute on + * open. Prefixing with a single quote neutralises that while keeping the value + * readable. + */ +export function escapeCsvField(value: unknown): string { + if (value === null || value === undefined) return ""; + + let text = typeof value === "boolean" ? String(value) : String(value); + if (/^[=+\-@\t\r]/.test(text)) { + text = `'${text}`; + } + + if (/[",\n\r]/.test(text)) { + return `"${text.replace(/"/g, '""')}"`; + } + return text; +} + +export function toCsv(rows: AuditLogRecord[]): string { + const lines = [COLUMNS.join(",")]; + for (const row of rows) { + lines.push( + COLUMNS.map((column) => + escapeCsvField((row as Record)[column]), + ).join(","), + ); + } + // Trailing newline so the file ends cleanly when appended to or concatenated. + return `${lines.join("\n")}\n`; +} + +/** + * Newline-delimited JSON: one entry per line, which is what log shippers and + * SIEM bulk endpoints expect, and which streams without holding the whole set. + */ +export function toNdjson(rows: AuditLogRecord[]): string { + return ( + rows.map((row) => JSON.stringify(row)).join("\n") + + (rows.length ? "\n" : "") + ); +} + +export function exportFilename(format: "csv" | "ndjson", now: Date): string { + const stamp = now.toISOString().slice(0, 19).replace(/[:T]/g, "-"); + return `termix-audit-${stamp}.${format === "csv" ? "csv" : "ndjson"}`; +} diff --git a/src/backend/utils/audit-forwarder.ts b/src/backend/utils/audit-forwarder.ts new file mode 100644 index 00000000..8b906243 --- /dev/null +++ b/src/backend/utils/audit-forwarder.ts @@ -0,0 +1,132 @@ +import { safeOutboundFetch } from "./safe-outbound-fetch.js"; +import { databaseLogger } from "./logger.js"; +import type { AuditLogParams } from "./audit-logger.js"; + +export const AUDIT_FORWARD_URL_ENV = "AUDIT_LOG_FORWARD_URL"; +export const AUDIT_FORWARD_TOKEN_ENV = "AUDIT_LOG_FORWARD_TOKEN"; + +/** + * How many consecutive failures before the forwarder stops complaining on every + * entry. It keeps trying — this only throttles the log noise, and it reports + * again once delivery recovers. + */ +const QUIET_AFTER_FAILURES = 5; + +let consecutiveFailures = 0; +let quietened = false; + +export interface AuditForwardTarget { + url: string; + token?: string; +} + +export function auditForwardTarget( + env: NodeJS.ProcessEnv = process.env, +): AuditForwardTarget | null { + const url = env[AUDIT_FORWARD_URL_ENV]?.trim(); + if (!url) return null; + const token = env[AUDIT_FORWARD_TOKEN_ENV]?.trim(); + return token ? { url, token } : { url }; +} + +/** The wire shape: one JSON object per entry, matching the export's NDJSON. */ +export function forwardPayload( + entry: AuditLogParams, + now: Date, +): Record { + return { + timestamp: now.toISOString(), + userId: entry.userId, + username: entry.username, + action: entry.action, + resourceType: entry.resourceType, + resourceId: entry.resourceId ?? null, + resourceName: entry.resourceName ?? null, + success: entry.success, + ipAddress: entry.ipAddress ?? null, + userAgent: entry.userAgent ?? null, + errorMessage: entry.errorMessage ?? null, + details: entry.details ?? null, + }; +} + +/** Exposed for tests; forwarding state is process-wide otherwise. */ +export function resetAuditForwarderState(): void { + consecutiveFailures = 0; + quietened = false; +} + +/** + * Ships one entry to the configured collector. + * + * Never throws and never blocks the audited operation: a SIEM being unreachable + * must not stop Termix from recording locally, which stays the source of truth. + * Delivery goes through safeOutboundFetch so a misconfigured URL cannot be used + * to probe the internal network. + */ +export async function forwardAuditEntry( + entry: AuditLogParams, + now: Date = new Date(), + env: NodeJS.ProcessEnv = process.env, +): Promise { + const target = auditForwardTarget(env); + if (!target) return false; + + try { + const response = await safeOutboundFetch(target.url, { + method: "POST", + headers: { + "Content-Type": "application/x-ndjson", + ...(target.token ? { Authorization: `Bearer ${target.token}` } : {}), + }, + body: `${JSON.stringify(forwardPayload(entry, now))}\n`, + signal: AbortSignal.timeout(5000), + }); + + if (!response.ok) { + noteFailure(`collector returned ${response.status}`, entry.action); + return false; + } + + noteSuccess(); + return true; + } catch (error) { + noteFailure( + error instanceof Error ? error.message : String(error), + entry.action, + ); + return false; + } +} + +function noteFailure(reason: string, action: string): void { + consecutiveFailures++; + + if (quietened) return; + + databaseLogger.warn("Failed to forward audit entry", { + operation: "audit_forward_failed", + action, + reason, + consecutiveFailures, + }); + + if (consecutiveFailures >= QUIET_AFTER_FAILURES) { + quietened = true; + databaseLogger.warn( + `Audit forwarding has failed ${consecutiveFailures} times; suppressing further messages until it recovers`, + { operation: "audit_forward_suppressed" }, + ); + } +} + +function noteSuccess(): void { + if (quietened) { + databaseLogger.info("Audit forwarding recovered", { + operation: "audit_forward_recovered", + afterFailures: consecutiveFailures, + }); + } + consecutiveFailures = 0; + quietened = false; +} diff --git a/src/backend/utils/audit-logger.ts b/src/backend/utils/audit-logger.ts index c48c0380..ed5e5f3b 100644 --- a/src/backend/utils/audit-logger.ts +++ b/src/backend/utils/audit-logger.ts @@ -1,5 +1,22 @@ import type { Request } from "express"; -import { createCurrentAuditLogRepository } from "../database/repositories/factory.js"; +import { forwardAuditEntry } from "./audit-forwarder.js"; +import { + createCurrentAuditLogRepository, + createCurrentUserRepository, +} from "../database/repositories/factory.js"; + +/** + * Resolves the display name to store alongside the entry. It is denormalised on + * purpose: the record has to stay readable after the account is gone. + */ +export async function getAuditUsername(userId: string): Promise { + try { + const actor = await createCurrentUserRepository().findById(userId); + return actor?.username ?? userId; + } catch { + return userId; + } +} export interface AuditLogParams { userId: string; @@ -16,6 +33,10 @@ export interface AuditLogParams { } export async function logAudit(params: AuditLogParams): Promise { + // Local storage is the source of truth and runs first; forwarding is a copy + // and must never delay or fail the audited operation. + void forwardAuditEntry(params).catch(() => {}); + try { await createCurrentAuditLogRepository().create({ userId: params.userId, diff --git a/src/backend/utils/audit-retention-migration.ts b/src/backend/utils/audit-retention-migration.ts new file mode 100644 index 00000000..ef019165 --- /dev/null +++ b/src/backend/utils/audit-retention-migration.ts @@ -0,0 +1,221 @@ +import { databaseLogger } from "./logger.js"; + +export interface MigratableSqlite { + exec(sql: string): unknown; + prepare(sql: string): { + all(...params: unknown[]): unknown[]; + get(...params: unknown[]): unknown; + run(...params: unknown[]): unknown; + }; +} + +interface ForeignKeyRow { + table?: string; + from?: string; + on_delete?: string; +} + +interface RetainedTable { + name: string; + /** Column list for the copy, in the order the rebuilt table declares them. */ + columns: string[]; + createSql: string; +} + +/** + * `audit_logs` already denormalises `username`, so nulling `user_id` still + * leaves a record of who acted. `session_recordings` does not, which is why the + * column is added and backfilled before its foreign key is relaxed — otherwise + * relaxing it would trade deleted evidence for anonymous evidence. + */ +const AUDIT_LOGS: RetainedTable = { + name: "audit_logs", + columns: [ + "id", + "user_id", + "username", + "action", + "resource_type", + "resource_id", + "resource_name", + "details", + "ip_address", + "user_agent", + "success", + "error_message", + "timestamp", + ], + createSql: ` + CREATE TABLE audit_logs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id TEXT, + username TEXT NOT NULL, + action TEXT NOT NULL, + resource_type TEXT NOT NULL, + resource_id TEXT, + resource_name TEXT, + details TEXT, + ip_address TEXT, + user_agent TEXT, + success INTEGER NOT NULL, + error_message TEXT, + timestamp TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE SET NULL + ); + `, +}; + +const SESSION_RECORDINGS: RetainedTable = { + name: "session_recordings", + columns: [ + "id", + "host_id", + "user_id", + "username", + "access_id", + "started_at", + "ended_at", + "duration", + "commands", + "dangerous_actions", + "recording_path", + "protocol", + "format", + "terminated_by_owner", + "termination_reason", + ], + createSql: ` + CREATE TABLE session_recordings ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + host_id INTEGER NOT NULL, + user_id TEXT, + username TEXT, + access_id INTEGER, + started_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + ended_at TEXT, + duration INTEGER, + commands TEXT, + dangerous_actions TEXT, + recording_path TEXT, + protocol TEXT NOT NULL DEFAULT 'ssh', + format TEXT NOT NULL DEFAULT 'text', + terminated_by_owner INTEGER DEFAULT 0, + termination_reason TEXT, + FOREIGN KEY (host_id) REFERENCES ssh_data (id) ON DELETE CASCADE, + FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE SET NULL, + FOREIGN KEY (access_id) REFERENCES host_access (id) ON DELETE SET NULL + ); + `, +}; + +const RETAINED_TABLES = [AUDIT_LOGS, SESSION_RECORDINGS]; + +export function userDeleteIsDestructive( + sqlite: MigratableSqlite, + table: string, +): boolean { + let rows: ForeignKeyRow[]; + try { + rows = sqlite + .prepare(`PRAGMA foreign_key_list(${table})`) + .all() as ForeignKeyRow[]; + } catch { + // Table absent on a fresh database; it is created in the target shape. + return false; + } + + return rows.some( + (row) => + row.table === "users" && + row.from === "user_id" && + (row.on_delete ?? "").toUpperCase() === "CASCADE", + ); +} + +function hasColumn( + sqlite: MigratableSqlite, + table: string, + column: string, +): boolean { + try { + sqlite.prepare(`SELECT "${column}" FROM ${table} LIMIT 1`).get(); + return true; + } catch { + return false; + } +} + +/** + * Gives session_recordings a username before its user_id can become null, so + * existing rows stay attributable. + */ +function ensureRecordingUsername(sqlite: MigratableSqlite): void { + if (hasColumn(sqlite, "session_recordings", "username")) return; + + sqlite.exec(`ALTER TABLE session_recordings ADD COLUMN username TEXT;`); + sqlite.exec(` + UPDATE session_recordings + SET username = (SELECT username FROM users WHERE users.id = session_recordings.user_id) + WHERE username IS NULL; + `); +} + +/** + * SQLite cannot alter a foreign key in place, so the table is copied into a new + * one with the intended constraint and swapped. Foreign keys must be off. + */ +function rebuildTable(sqlite: MigratableSqlite, table: RetainedTable): void { + const columns = table.columns.join(", "); + const temp = `${table.name}_retained`; + + sqlite.exec(table.createSql.replace(table.name, temp)); + sqlite.exec( + `INSERT INTO ${temp} (${columns}) SELECT ${columns} FROM ${table.name};`, + ); + sqlite.exec(`DROP TABLE ${table.name};`); + sqlite.exec(`ALTER TABLE ${temp} RENAME TO ${table.name};`); +} + +/** + * Turns ON DELETE CASCADE into ON DELETE SET NULL for the tables that have to + * outlive the account they reference. Idempotent. + */ +export function migrateAuditRetention(sqlite: MigratableSqlite): string[] { + const migrated: string[] = []; + + for (const table of RETAINED_TABLES) { + if (!userDeleteIsDestructive(sqlite, table.name)) continue; + + try { + if (table.name === "session_recordings") { + ensureRecordingUsername(sqlite); + } + + sqlite.exec("PRAGMA foreign_keys = OFF"); + sqlite.exec("BEGIN TRANSACTION"); + rebuildTable(sqlite, table); + sqlite.exec("COMMIT"); + + migrated.push(table.name); + databaseLogger.info(`${table.name} now survives user deletion`, { + operation: "audit_retention_migration", + table: table.name, + }); + } catch (error) { + try { + sqlite.exec("ROLLBACK"); + } catch { + // no transaction open + } + databaseLogger.warn(`Could not migrate ${table.name} retention`, { + operation: "audit_retention_migration_failed", + table: table.name, + error: error instanceof Error ? error.message : String(error), + }); + } finally { + sqlite.exec("PRAGMA foreign_keys = ON"); + } + } + + return migrated; +} diff --git a/src/backend/utils/crypto-migration/legacy-share-cleanup.ts b/src/backend/utils/crypto-migration/legacy-share-cleanup.ts index 64cbb058..4fa020ba 100644 --- a/src/backend/utils/crypto-migration/legacy-share-cleanup.ts +++ b/src/backend/utils/crypto-migration/legacy-share-cleanup.ts @@ -1,6 +1,10 @@ import { databaseLogger } from "../logger.js"; import { DatabaseSaveTrigger } from "../database-save-trigger.js"; import { getCurrentRepositorySqlite } from "../../database/repositories/factory.js"; +import { + needsExplicitPersist, + resolveDatabaseDialect, +} from "../../database/db/dialect.js"; interface SqliteLike { prepare(sql: string): { @@ -39,9 +43,16 @@ function dropColumnIfExists( export async function runLegacySharedCredentialCleanup(): Promise<{ columnsDropped: number; }> { - const sqlite = getCurrentRepositorySqlite() as unknown as SqliteLike; const result = { columnsDropped: 0 }; + // Nothing to clean up on an engine this application has never run on. A + // Postgres or MySQL database is created by the drizzle migrations, which have + // never emitted these legacy columns, so there is nothing to drop — and the + // check itself needs a synchronous PRAGMA that only SQLite offers. + if (!needsExplicitPersist(resolveDatabaseDialect())) return result; + + const sqlite = getCurrentRepositorySqlite() as unknown as SqliteLike; + for (const [table, column] of [ ["ssh_credentials", "system_password"], ["ssh_credentials", "system_key"], diff --git a/src/backend/utils/crypto-migration/legacy-shared-ssh-auth-opt-in-migration.ts b/src/backend/utils/crypto-migration/legacy-shared-ssh-auth-opt-in-migration.ts new file mode 100644 index 00000000..c6429318 --- /dev/null +++ b/src/backend/utils/crypto-migration/legacy-shared-ssh-auth-opt-in-migration.ts @@ -0,0 +1,138 @@ +import { DatabaseSaveTrigger } from "../database-save-trigger.js"; +import { databaseLogger } from "../logger.js"; +import { + createCurrentSettingsRepository, + getCurrentRepositorySqlite, +} from "../../database/repositories/factory.js"; +import { SharedHostSecretsManager } from "../shared-host-secrets-manager.js"; + +const MIGRATION_FLAG = "legacy_shared_ssh_auth_opt_in_v1"; + +interface SharedHostRow { + id: number; +} + +export interface LegacySharedSshAuthOptInResult { + enabled: number; + resynced: number; + skipped: number; +} + +/** + * Before SSH authentication became owner-controlled, every shared host shared + * its SSH auth automatically. Preserve that behavior for hosts which already + * have access grants while keeping the schema default private for new hosts. + * + * Re-syncing also repairs snapshots for hosts already marked as shared. If the + * privacy migration has previously completed, false values are treated as an + * explicit choice and are never changed back to shared. + */ +export async function runLegacySharedSshAuthOptInMigration(): Promise { + const settingsRepository = createCurrentSettingsRepository(); + + try { + if ((await settingsRepository.get(MIGRATION_FLAG)) === "done") { + return null; + } + + const sqlite = getCurrentRepositorySqlite(); + const privacyMigrationAlreadyRan = + (await settingsRepository.get("private_shared_ssh_auth_v1")) === "done"; + const hasLegacySharedCredentialsTable = !!sqlite + .prepare( + "SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'shared_credentials'", + ) + .get(); + const now = new Date().toISOString(); + const legacySnapshotEvidence = hasLegacySharedCredentialsTable + ? ` OR EXISTS ( + SELECT 1 + FROM shared_credentials sc + INNER JOIN host_access legacy_ha + ON legacy_ha.id = sc.host_access_id + WHERE legacy_ha.host_id = ssh_data.id + AND ( + legacy_ha.expires_at IS NULL + OR legacy_ha.expires_at >= ? + ) + )` + : ""; + const updateResult = privacyMigrationAlreadyRan + ? { changes: 0 } + : sqlite + .prepare( + `UPDATE ssh_data + SET share_ssh_auth = 1 + WHERE share_ssh_auth = 0 + AND ( + EXISTS ( + SELECT 1 + FROM shared_host_secrets shs + INNER JOIN host_access ha + ON ha.id = shs.host_access_id + WHERE ha.host_id = ssh_data.id + AND shs.protocol = 'ssh' + AND ( + ha.expires_at IS NULL + OR ha.expires_at >= ? + ) + ) + ${legacySnapshotEvidence} + )`, + ) + .run(...(hasLegacySharedCredentialsTable ? [now, now] : [now])); + const sharedHosts = sqlite + .prepare( + `SELECT DISTINCT h.id + FROM ssh_data h + INNER JOIN host_access ha ON ha.host_id = h.id + WHERE h.share_ssh_auth = 1`, + ) + .all() as SharedHostRow[]; + + const result: LegacySharedSshAuthOptInResult = { + enabled: updateResult.changes, + resynced: 0, + skipped: 0, + }; + const secretsManager = SharedHostSecretsManager.getInstance(); + + for (const host of sharedHosts) { + try { + await secretsManager.resyncHost(host.id); + result.resynced++; + } catch (error) { + result.skipped++; + databaseLogger.warn( + "Failed to resync legacy shared SSH authentication", + { + operation: "legacy_shared_ssh_auth_opt_in_resync_skip", + hostId: host.id, + error: error instanceof Error ? error.message : "Unknown error", + }, + ); + } + } + + await settingsRepository.set(MIGRATION_FLAG, "done"); + await DatabaseSaveTrigger.forceSave( + "legacy_shared_ssh_auth_opt_in_migration", + ); + + databaseLogger.info("Preserved legacy shared SSH authentication behavior", { + operation: "legacy_shared_ssh_auth_opt_in_migration", + ...result, + }); + + return result; + } catch (error) { + databaseLogger.error( + "Failed to preserve legacy shared SSH authentication behavior", + error, + { + operation: "legacy_shared_ssh_auth_opt_in_migration", + }, + ); + return { enabled: 0, resynced: 0, skipped: 0 }; + } +} diff --git a/src/backend/utils/crypto-migration/private-shared-ssh-auth-migration.ts b/src/backend/utils/crypto-migration/private-shared-ssh-auth-migration.ts new file mode 100644 index 00000000..3e64b4ba --- /dev/null +++ b/src/backend/utils/crypto-migration/private-shared-ssh-auth-migration.ts @@ -0,0 +1,57 @@ +import { databaseLogger } from "../logger.js"; +import { DatabaseSaveTrigger } from "../database-save-trigger.js"; +import { + createCurrentSettingsRepository, + getCurrentRepositorySqlite, +} from "../../database/repositories/factory.js"; + +const MIGRATION_FLAG = "private_shared_ssh_auth_v1"; + +/** + * Remove SSH snapshots only for hosts whose owners have not enabled SSH auth + * sharing. Legacy shared hosts are opted in before this cleanup runs. + */ +export async function runPrivateSharedSshAuthMigration(): Promise< + number | null +> { + const settingsRepository = createCurrentSettingsRepository(); + + try { + if ((await settingsRepository.get(MIGRATION_FLAG)) === "done") { + return null; + } + + const result = getCurrentRepositorySqlite() + .prepare( + `DELETE FROM shared_host_secrets + WHERE protocol = ? + AND NOT EXISTS ( + SELECT 1 + FROM host_access ha + INNER JOIN ssh_data h ON h.id = ha.host_id + WHERE ha.id = shared_host_secrets.host_access_id + AND h.share_ssh_auth = 1 + )`, + ) + .run("ssh"); + + await settingsRepository.set(MIGRATION_FLAG, "done"); + await DatabaseSaveTrigger.forceSave("private_shared_ssh_auth_migration"); + + databaseLogger.info("Removed legacy shared SSH authentication snapshots", { + operation: "private_shared_ssh_auth_migration", + removed: result.changes, + }); + + return result.changes; + } catch (error) { + databaseLogger.error( + "Failed to remove legacy shared SSH authentication snapshots", + error, + { + operation: "private_shared_ssh_auth_migration", + }, + ); + return 0; + } +} diff --git a/src/backend/utils/crypto-migration/shared-host-secrets-migration.ts b/src/backend/utils/crypto-migration/shared-host-secrets-migration.ts index 963bdbeb..b6e6a463 100644 --- a/src/backend/utils/crypto-migration/shared-host-secrets-migration.ts +++ b/src/backend/utils/crypto-migration/shared-host-secrets-migration.ts @@ -6,6 +6,10 @@ import { getCurrentRepositorySqlite, } from "../../database/repositories/factory.js"; import { SharedHostSecretsManager } from "../shared-host-secrets-manager.js"; +import { + needsExplicitPersist, + resolveDatabaseDialect, +} from "../../database/db/dialect.js"; const MIGRATION_FLAG = "shared_host_secrets_migrated_v1"; @@ -35,9 +39,15 @@ export async function runSharedHostSecretsMigration(): Promise<{ return null; } - const sqlite = getCurrentRepositorySqlite(); const result = { snapshotted: 0, skipped: 0 }; + // Same reasoning as legacy-share-cleanup: this rebuilds shares that only a + // pre-existing SQLite deployment can have, using a synchronous query no other + // driver provides. + if (!needsExplicitPersist(resolveDatabaseDialect())) return result; + + const sqlite = getCurrentRepositorySqlite(); + try { const grants = sqlite .prepare( diff --git a/src/backend/utils/data-crypto.ts b/src/backend/utils/data-crypto.ts index a92de14b..77eb3169 100644 --- a/src/backend/utils/data-crypto.ts +++ b/src/backend/utils/data-crypto.ts @@ -1,5 +1,9 @@ import { FieldCrypto } from "./field-crypto.js"; import { LazyFieldEncryption } from "./lazy-field-encryption.js"; +import { + needsExplicitPersist, + resolveDatabaseDialect, +} from "../database/db/dialect.js"; import { UserKeyManager } from "./user-keys.js"; import { DatabaseSaveTrigger } from "./database-save-trigger.js"; import { databaseLogger } from "./logger.js"; @@ -218,6 +222,14 @@ class DataCrypto { migratedTables: string[]; migratedFieldsCount: number; }> { + // Only a database that predates field encryption has plaintext to migrate, + // and only SQLite deployments can predate it — Postgres and MySQL support + // arrived after. The store also needs synchronous queries no other driver + // has, so this would throw rather than find nothing to do. + if (!needsExplicitPersist(resolveDatabaseDialect())) { + return { migrated: false, migratedTables: [], migratedFieldsCount: 0 }; + } + const result = await this.migrateUserSensitiveFieldsInStore( userId, userDataKey, diff --git a/src/backend/utils/data-dir-guard.ts b/src/backend/utils/data-dir-guard.ts new file mode 100644 index 00000000..0fabc6c3 --- /dev/null +++ b/src/backend/utils/data-dir-guard.ts @@ -0,0 +1,88 @@ +import fs from "fs"; +import path from "path"; +import { DatabaseFileEncryption } from "./database-file-encryption.js"; + +export const ALLOW_EMPTY_DATA_DIR_ENV = "ALLOW_EMPTY_DATA_DIR"; + +/** Thrown when the data directory looks misconfigured rather than empty. */ +export class DataDirMisconfiguredError extends Error { + constructor(message: string) { + super(message); + this.name = "DataDirMisconfiguredError"; + } +} + +/** + * Directories Termix has shipped or documented as a data location. A deployment + * that loses DATA_DIR — an unloaded .env file, an unmounted volume — falls back + * to the default and finds an empty directory, which is indistinguishable from a + * first run. Checking these tells the two apart. + */ +const KNOWN_DATA_DIRS = ["db/data", "data", "/app/data"]; + +const TRUE_VALUES = new Set(["1", "true", "yes", "on"]); + +function hasDatabaseFile(dir: string): boolean { + const dbPath = path.join(dir, "db.sqlite"); + + if (DatabaseFileEncryption.isEncryptedDatabaseFile(`${dbPath}.encrypted`)) { + return true; + } + + try { + return fs.statSync(dbPath).size > 0; + } catch { + return false; + } +} + +/** + * Looks for a database outside the configured data directory. Returns the + * directory holding it, or null when this really is a fresh install. + */ +export function findDatabaseOutsideDataDir( + dataDir: string, + cwd: string = process.cwd(), +): string | null { + const resolvedDataDir = path.resolve(dataDir); + + for (const candidate of KNOWN_DATA_DIRS) { + const dir = path.resolve(cwd, candidate); + if (dir === resolvedDataDir) continue; + if (hasDatabaseFile(dir)) return dir; + } + + return null; +} + +export function isEmptyDataDirAllowed( + env: NodeJS.ProcessEnv = process.env, +): boolean { + return TRUE_VALUES.has( + env[ALLOW_EMPTY_DATA_DIR_ENV]?.trim().toLowerCase() ?? "", + ); +} + +/** + * Refuses to start with a blank database when an existing one sits elsewhere. + * Creating a fresh database in that state looks exactly like data loss: the user + * is asked to register an admin account again while their real data is intact + * one directory over. + */ +export function assertDataDirIsNotMisconfigured( + dataDir: string, + env: NodeJS.ProcessEnv = process.env, + cwd: string = process.cwd(), +): void { + if (isEmptyDataDirAllowed(env)) return; + + const existing = findDatabaseOutsideDataDir(dataDir, cwd); + if (!existing) return; + + throw new DataDirMisconfiguredError( + `No database found in DATA_DIR (${path.resolve(dataDir)}), but an existing database is present in ${existing}. ` + + `Starting here would create an empty database and hide your data. ` + + `Set DATA_DIR=${existing} (check that your .env file is loaded and any volume is mounted), ` + + `or set ${ALLOW_EMPTY_DATA_DIR_ENV}=true to start with a new database anyway.`, + ); +} diff --git a/src/backend/utils/database-save-trigger.ts b/src/backend/utils/database-save-trigger.ts index 08d07e43..45788b44 100644 --- a/src/backend/utils/database-save-trigger.ts +++ b/src/backend/utils/database-save-trigger.ts @@ -4,6 +4,7 @@ export class DatabaseSaveTrigger { private static saveFunction: (() => Promise) | null = null; private static isInitialized = false; private static pendingSave = false; + private static activeSave: Promise | null = null; private static saveTimeout: NodeJS.Timeout | null = null; private static _dirty = false; @@ -38,14 +39,10 @@ export class DatabaseSaveTrigger { } this.saveTimeout = setTimeout(async () => { - if (this.pendingSave) { - return; - } - - this.pendingSave = true; + this.saveTimeout = null; try { - await this.saveFunction!(); + await this.runSave(); this._dirty = false; } catch (error) { databaseLogger.error("Database save failed", error, { @@ -53,8 +50,6 @@ export class DatabaseSaveTrigger { reason, error: error instanceof Error ? error.message : "Unknown error", }); - } finally { - this.pendingSave = false; } }, 2000); } @@ -76,14 +71,9 @@ export class DatabaseSaveTrigger { this.saveTimeout = null; } - if (this.pendingSave) { - return; - } - - this.pendingSave = true; - try { - await this.saveFunction(); + await this.runSave(); + this._dirty = false; } catch (error) { databaseLogger.error("Database force save failed", error, { operation: "db_save_trigger_force_failed", @@ -91,8 +81,29 @@ export class DatabaseSaveTrigger { error: error instanceof Error ? error.message : "Unknown error", }); throw error; + } + } + + private static async runSave(): Promise { + while (this.activeSave) { + try { + await this.activeSave; + } catch { + // The queued save must still run after an earlier save failed. + } + } + + const save = Promise.resolve().then(() => this.saveFunction!()); + this.activeSave = save; + this.pendingSave = true; + + try { + await save; } finally { - this.pendingSave = false; + if (this.activeSave === save) { + this.activeSave = null; + this.pendingSave = false; + } } } @@ -115,6 +126,7 @@ export class DatabaseSaveTrigger { } this.pendingSave = false; + this.activeSave = null; this.isInitialized = false; this.saveFunction = null; } diff --git a/src/backend/utils/logger.ts b/src/backend/utils/logger.ts index 73b28f61..af45a7e8 100644 --- a/src/backend/utils/logger.ts +++ b/src/backend/utils/logger.ts @@ -151,6 +151,8 @@ export class Logger { contextParts.push(`req:${sanitizedContext.requestId}`); if (sanitizedContext.duration) contextParts.push(`duration:${sanitizedContext.duration}ms`); + if (sanitizedContext.error) + contextParts.push(`error:${sanitizedContext.error}`); if (contextParts.length > 0) { contextStr = chalk.gray(` [${contextParts.join(",")}]`); diff --git a/src/backend/utils/proxy-helper.ts b/src/backend/utils/proxy-helper.ts index a2d17959..e7a607e3 100644 --- a/src/backend/utils/proxy-helper.ts +++ b/src/backend/utils/proxy-helper.ts @@ -3,23 +3,9 @@ import type { SocksClientOptions } from "socks"; import net from "net"; import dns from "dns/promises"; import { sshLogger } from "./logger.js"; +import { isBlockedAddress } from "./safe-outbound-fetch.js"; import type { ProxyNode } from "../../types/index.js"; -function isBlockedAddress(ip: string): boolean { - if (ip === "0.0.0.0" || ip === "::1" || ip === "::") return true; - - const parts = ip.split(".").map(Number); - if (parts.length !== 4) return false; - - if (parts[0] === 127) return true; - if (parts[0] === 10) return true; - if (parts[0] === 172 && parts[1] >= 16 && parts[1] <= 31) return true; - if (parts[0] === 192 && parts[1] === 168) return true; - if (parts[0] === 169 && parts[1] === 254) return true; - - return false; -} - async function validateHost(host: string): Promise { if (net.isIP(host)) { if (isBlockedAddress(host)) { diff --git a/src/backend/utils/safe-outbound-fetch.ts b/src/backend/utils/safe-outbound-fetch.ts index 46f5bbbb..c5c65989 100644 --- a/src/backend/utils/safe-outbound-fetch.ts +++ b/src/backend/utils/safe-outbound-fetch.ts @@ -1,4 +1,9 @@ -import { lookup, type LookupAddress, type LookupAllOptions } from "dns"; +import { + lookup, + type LookupAddress, + type LookupAllOptions, + type LookupOptions, +} from "dns"; import { BlockList, isIP } from "net"; import { Agent } from "undici"; @@ -13,8 +18,8 @@ type DnsLookupFn = ( type LookupHookCallback = ( error: NodeJS.ErrnoException | Error | null, - address: string, - family: number, + address: string | LookupAddress[], + family?: number, ) => void; const blockedAddresses = new BlockList(); @@ -71,28 +76,27 @@ export function isBlockedAddress(address: string): boolean { export function createDnsLookupHook(dnsLookup: DnsLookupFn = lookup) { return function lookupHook( host: string, - lookupOptions: LookupAllOptions, + lookupOptions: LookupOptions, callback: LookupHookCallback, ): void { + const fail = (error: NodeJS.ErrnoException | Error) => { + if (lookupOptions.all) return callback(error, []); + callback(error, "", 0); + }; + dnsLookup( host, { ...lookupOptions, all: true, verbatim: true }, (error, addresses) => { - if (error) return callback(error, "", 0); + if (error) return fail(error); if (!addresses.length) { - return callback( - new Error("DNS resolution returned no addresses"), - "", - 0, - ); + return fail(new Error("DNS resolution returned no addresses")); } if (addresses.some(({ address }) => isBlockedAddress(address))) { - return callback( - new Error("Private destinations are not allowed"), - "", - 0, - ); + return fail(new Error("Private destinations are not allowed")); } + if (lookupOptions.all) return callback(null, addresses); + const selected = addresses[0]; callback(null, selected.address, selected.family); }, diff --git a/src/backend/utils/shared-host-auth-override-migration.ts b/src/backend/utils/shared-host-auth-override-migration.ts new file mode 100644 index 00000000..5f8be1e4 --- /dev/null +++ b/src/backend/utils/shared-host-auth-override-migration.ts @@ -0,0 +1,101 @@ +import type Database from "better-sqlite3"; + +const MIGRATION_KEY = "shared_host_auth_overrides_v1"; + +const createProtocolAwareTableSql = ` + CREATE TABLE shared_host_auth_overrides ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + host_id INTEGER NOT NULL, + user_id TEXT NOT NULL, + protocol TEXT NOT NULL DEFAULT 'ssh', + credential_id INTEGER NOT NULL, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (host_id) REFERENCES ssh_data (id) ON DELETE CASCADE, + FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE, + FOREIGN KEY (credential_id) REFERENCES ssh_credentials (id) ON DELETE CASCADE + ); + CREATE UNIQUE INDEX shared_host_auth_overrides_host_user_protocol_unique + ON shared_host_auth_overrides (host_id, user_id, protocol); +`; + +export type SharedHostAuthOverrideSchemaResult = + "created" | "migrated" | "current"; + +/** + * Keeps the override storage protocol-capable without enabling any additional + * protocol. Pre-protocol rows are preserved as SSH overrides. + */ +export function ensureSharedHostAuthOverrideProtocolSchema( + sqlite: Database.Database, +): SharedHostAuthOverrideSchemaResult { + const tableExists = sqlite + .prepare( + "SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'shared_host_auth_overrides'", + ) + .get(); + + if (!tableExists) { + sqlite.exec(createProtocolAwareTableSql); + return "created"; + } + + const hasProtocol = sqlite + .prepare( + "SELECT 1 FROM pragma_table_info('shared_host_auth_overrides') WHERE name = 'protocol'", + ) + .get(); + if (hasProtocol) { + return "current"; + } + + sqlite.transaction(() => { + sqlite.exec(` + ALTER TABLE shared_host_auth_overrides + RENAME TO shared_host_auth_overrides_pre_protocol; + + ${createProtocolAwareTableSql} + + INSERT INTO shared_host_auth_overrides + (id, host_id, user_id, protocol, credential_id, created_at, updated_at) + SELECT + id, host_id, user_id, 'ssh', credential_id, created_at, updated_at + FROM shared_host_auth_overrides_pre_protocol; + + DROP TABLE shared_host_auth_overrides_pre_protocol; + `); + })(); + + return "migrated"; +} + +export function migrateLegacySharedHostAuthOverrides( + sqlite: Database.Database, + getSetting: (key: string) => string | null, + setSetting: (key: string, value: string) => void, +): boolean { + if (getSetting(MIGRATION_KEY) !== null) return false; + + const hasLegacyColumn = sqlite + .prepare( + "SELECT 1 FROM pragma_table_info('host_access') WHERE name = 'override_credential_id'", + ) + .get(); + + if (hasLegacyColumn) { + sqlite.exec(` + INSERT OR IGNORE INTO shared_host_auth_overrides + (host_id, user_id, protocol, credential_id) + SELECT host_id, user_id, 'ssh', override_credential_id + FROM host_access + WHERE user_id IS NOT NULL AND override_credential_id IS NOT NULL; + + UPDATE host_access + SET override_credential_id = NULL + WHERE override_credential_id IS NOT NULL; + `); + } + + setSetting(MIGRATION_KEY, "done"); + return true; +} diff --git a/src/backend/utils/shared-host-auth-override-service.ts b/src/backend/utils/shared-host-auth-override-service.ts new file mode 100644 index 00000000..532685f5 --- /dev/null +++ b/src/backend/utils/shared-host-auth-override-service.ts @@ -0,0 +1,136 @@ +import { + AUTH_PROTOCOL_METADATA, + isSupportedAuthOverrideProtocol, + type AuthOverrideProtocol, +} from "../../types/auth-protocols.js"; +import { + createCurrentCredentialRepository, + createCurrentSharedHostAuthOverrideRepository, + createCurrentUserRepository, +} from "../database/repositories/factory.js"; +import { logAudit } from "./audit-logger.js"; +import { PermissionManager } from "./permission-manager.js"; + +export interface SharedHostAuthOverrideAuditContext { + ipAddress?: string; + userAgent?: string; +} + +export class SharedHostAuthOverrideServiceError extends Error { + constructor( + message: string, + readonly statusCode: number, + ) { + super(message); + this.name = "SharedHostAuthOverrideServiceError"; + } +} + +export class SharedHostAuthOverrideService { + private static instance: SharedHostAuthOverrideService; + + private constructor() {} + + static getInstance(): SharedHostAuthOverrideService { + if (!this.instance) { + this.instance = new SharedHostAuthOverrideService(); + } + return this.instance; + } + + async getCredentialId( + hostId: number, + userId: string, + protocol: AuthOverrideProtocol, + ): Promise { + this.requireSupportedProtocol(protocol); + await this.requireSharedHostAccess(hostId, userId); + return createCurrentSharedHostAuthOverrideRepository().findCredentialId( + hostId, + userId, + protocol, + ); + } + + async setCredentialId( + hostId: number, + userId: string, + protocol: AuthOverrideProtocol, + credentialId: number | null, + auditContext: SharedHostAuthOverrideAuditContext = {}, + ): Promise { + this.requireSupportedProtocol(protocol); + await this.requireSharedHostAccess(hostId, userId); + + if (credentialId !== null) { + const credential = + await createCurrentCredentialRepository().findByIdForUser( + userId, + credentialId, + ); + if (!credential) { + throw new SharedHostAuthOverrideServiceError( + "Credential not found", + 404, + ); + } + } + + const repository = createCurrentSharedHostAuthOverrideRepository(); + if (credentialId === null) { + await repository.clearCredential(hostId, userId, protocol); + } else { + await repository.setCredential(hostId, userId, protocol, credentialId); + } + + try { + const actor = await createCurrentUserRepository().findById(userId); + await logAudit({ + userId, + username: actor?.username ?? userId, + action: + credentialId === null + ? "clear_shared_host_auth_override" + : "set_shared_host_auth_override", + resourceType: "host", + resourceId: String(hostId), + details: JSON.stringify({ + protocol, + credentialId, + }), + ipAddress: auditContext.ipAddress, + userAgent: auditContext.userAgent, + success: true, + }); + } catch { + // Audit bookkeeping must never turn a successful override write into a + // failed API response. + } + } + + private requireSupportedProtocol(protocol: AuthOverrideProtocol): void { + if (!isSupportedAuthOverrideProtocol(protocol)) { + throw new SharedHostAuthOverrideServiceError( + `${AUTH_PROTOCOL_METADATA[protocol].label} authentication overrides are not supported yet`, + 400, + ); + } + } + + private async requireSharedHostAccess( + hostId: number, + userId: string, + ): Promise { + const access = await PermissionManager.getInstance().canAccessHost( + userId, + hostId, + "connect", + ); + if (!access.hasAccess || !access.isShared || access.isAdminBypass) { + throw new SharedHostAuthOverrideServiceError( + "Authentication overrides require active shared host access", + 403, + ); + } + } +} diff --git a/src/backend/utils/shared-host-auth-resolver.ts b/src/backend/utils/shared-host-auth-resolver.ts new file mode 100644 index 00000000..d434995c --- /dev/null +++ b/src/backend/utils/shared-host-auth-resolver.ts @@ -0,0 +1,128 @@ +import { + isSupportedAuthOverrideProtocol, + type AuthOverrideProtocol, +} from "../../types/auth-protocols.js"; +import { + createCurrentHostResolutionRepository, + createCurrentSharedHostAuthOverrideRepository, +} from "../database/repositories/factory.js"; +import type { + HostResolutionCredentialRecord, + HostResolutionHostRecord, +} from "../database/repositories/host-resolution-repository.js"; +import type { SharedSecretData } from "./shared-host-secrets-manager.js"; +import { SharedHostSecretsManager } from "./shared-host-secrets-manager.js"; + +export type RecipientSharedHostAuthResolution = + | { + source: "personal-override"; + credentialId: number; + credential: HostResolutionCredentialRecord; + } + | { + source: "owner-shared"; + authType: string; + secret: SharedSecretData | null; + } + | { source: "secretless" } + | { source: "required" }; + +export function requiresPersonalHostAuthentication( + host: Pick, + protocol: AuthOverrideProtocol, +): boolean { + switch (protocol) { + case "ssh": + return ( + !!host.credentialId || + host.authType === "password" || + host.authType === "key" || + host.authType === "credential" || + host.authType === "agent" + ); + // These cases document the extension point without enabling behavior. + case "rdp": + case "vnc": + case "telnet": + throw new Error( + `${protocol.toUpperCase()} shared-host authentication is not implemented`, + ); + } +} + +/** + * Applies the shared-host authentication precedence independently from any + * transport: recipient override, explicitly shared owner auth, secretless + * auth, then "required". Only SSH is currently enabled by callers. + */ +export async function resolveRecipientSharedHostAuthentication( + host: HostResolutionHostRecord, + hostId: number, + userId: string, + protocol: AuthOverrideProtocol, +): Promise { + if (!isSupportedAuthOverrideProtocol(protocol)) { + throw new Error( + `${protocol.toUpperCase()} shared-host authentication is not implemented`, + ); + } + + const repository = createCurrentHostResolutionRepository(); + let overrideCredentialId: number | null = null; + try { + overrideCredentialId = + await createCurrentSharedHostAuthOverrideRepository().findCredentialId( + hostId, + userId, + protocol, + ); + } catch { + // A missing/deleted override behaves like no personal credential. + } + + if (overrideCredentialId) { + const credential = await repository.findCredentialByIdForUser( + overrideCredentialId, + userId, + ); + if (credential) { + return { + source: "personal-override", + credentialId: overrideCredentialId, + credential, + }; + } + } + + if (protocol === "ssh" && host.shareSshAuth) { + if (host.authType === "agent") { + return { + source: "owner-shared", + authType: "agent", + secret: null, + }; + } + + try { + const secret = + await SharedHostSecretsManager.getInstance().getSecretForUser( + hostId, + userId, + protocol, + ); + if (secret) { + return { + source: "owner-shared", + authType: secret.authType, + secret, + }; + } + } catch { + // An unreadable owner snapshot cannot expose the owner's auth. + } + } + + return requiresPersonalHostAuthentication(host, protocol) + ? { source: "required" } + : { source: "secretless" }; +} diff --git a/src/backend/utils/shared-host-secrets-manager.ts b/src/backend/utils/shared-host-secrets-manager.ts index be70acd2..db2c560b 100644 --- a/src/backend/utils/shared-host-secrets-manager.ts +++ b/src/backend/utils/shared-host-secrets-manager.ts @@ -57,9 +57,9 @@ function enabledProtocols( }; } -// Per-recipient copies of a shared host's connection secrets, re-encrypted -// under the recipient's DEK. Every enabled protocol gets its own snapshot; -// secret-less auth types (opkssh, vault, agent, none, ...) produce none. +// Per-recipient copies of connection secrets, re-encrypted under the +// recipient's DEK. SSH authentication is copied only when the host owner +// explicitly opts in; recipient-owned credential overrides remain separate. class SharedHostSecretsManager { private static instance: SharedHostSecretsManager; @@ -366,7 +366,7 @@ class SharedHostSecretsManager { const enabled = enabledProtocols(host); const snapshots: ProtocolSnapshot[] = []; - if (enabled.ssh) { + if (enabled.ssh && host.shareSshAuth) { if (host.credentialId) { const credential = await repository.findCredentialByIdForUser( host.credentialId, diff --git a/src/backend/utils/system-secret-crypto.ts b/src/backend/utils/system-secret-crypto.ts new file mode 100644 index 00000000..b177228c --- /dev/null +++ b/src/backend/utils/system-secret-crypto.ts @@ -0,0 +1,121 @@ +import crypto from "crypto"; +import { SystemCrypto } from "./system-crypto.js"; + +/** + * Encryption for secrets that belong to the installation rather than to a user. + * + * Per-user field encryption (DataCrypto/FieldCrypto) derives its key from the + * user's DEK, which works for host passwords and SSH keys. It does not work for + * SSO provider configuration: `sso_providers` has no `userId`, and the OIDC + * client secret and LDAP bind password must be readable during login — before + * any user is authenticated, let alone unlocked. + * + * Those secrets were previously stored base64-encoded behind an `encoded:` + * prefix, which is not encryption. This uses the system encryption key, the + * same one already protecting other installation-level material. + */ + +const ALGORITHM = "aes-256-gcm"; +const IV_LENGTH = 12; +const PREFIX = "sysenc:v1:"; +const LEGACY_PREFIX = "encoded:"; +/** Written by an older path that base64-encoded behind an "encrypted:" prefix. */ +const LEGACY_MISLABELLED_PREFIX = "encrypted:"; + +export function isSystemEncrypted(value: string): boolean { + return value.startsWith(PREFIX); +} + +export async function encryptSystemSecret(plaintext: string): Promise { + if (!plaintext) return plaintext; + if (isSystemEncrypted(plaintext)) return plaintext; + + const key = await SystemCrypto.getInstance().getEncryptionKey(); + const iv = crypto.randomBytes(IV_LENGTH); + const cipher = crypto.createCipheriv(ALGORITHM, key, iv); + + const encrypted = Buffer.concat([ + cipher.update(plaintext, "utf8"), + cipher.final(), + ]); + const tag = cipher.getAuthTag(); + + return `${PREFIX}${iv.toString("base64")}:${tag.toString("base64")}:${encrypted.toString("base64")}`; +} + +/** + * Reads a stored secret, transparently handling values written before this + * existed. Legacy values are returned as plaintext so login keeps working; they + * are upgraded on the next write. + */ +export async function decryptSystemSecret(stored: string): Promise { + if (!stored) return stored; + + if (!isSystemEncrypted(stored)) { + for (const legacy of [LEGACY_PREFIX, LEGACY_MISLABELLED_PREFIX]) { + if (stored.startsWith(legacy)) { + try { + return Buffer.from(stored.slice(legacy.length), "base64").toString( + "utf8", + ); + } catch { + return stored; + } + } + } + // Never encoded at all. + return stored; + } + + const [ivPart, tagPart, dataPart] = stored.slice(PREFIX.length).split(":"); + if (!ivPart || !tagPart || !dataPart) { + throw new Error("Malformed system-encrypted secret"); + } + + const key = await SystemCrypto.getInstance().getEncryptionKey(); + const decipher = crypto.createDecipheriv( + ALGORITHM, + key, + Buffer.from(ivPart, "base64"), + ); + decipher.setAuthTag(Buffer.from(tagPart, "base64")); + + return Buffer.concat([ + decipher.update(Buffer.from(dataPart, "base64")), + decipher.final(), + ]).toString("utf8"); +} + +/** Fields inside an SSO provider config that must not be stored readable. */ +export const SSO_SECRET_FIELDS = ["client_secret", "bindPassword"] as const; + +export async function encryptSsoConfigSecrets( + config: Record, +): Promise> { + const out = { ...config }; + for (const field of SSO_SECRET_FIELDS) { + const value = out[field]; + if (typeof value === "string" && value) { + out[field] = await encryptSystemSecret(value); + } + } + return out; +} + +export async function decryptSsoConfigSecrets( + config: Record, +): Promise> { + const out = { ...config }; + for (const field of SSO_SECRET_FIELDS) { + const value = out[field]; + if (typeof value === "string" && value) { + try { + out[field] = await decryptSystemSecret(value); + } catch { + // A secret we cannot read must not take the whole provider down; + // login will fail with a clearer error downstream. + } + } + } + return out; +} diff --git a/src/main.tsx b/src/main.tsx index fb07801e..b719f689 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -72,11 +72,7 @@ const SharedSessionView = lazy( ); type Phase = - | "verifying" - | "idle-auth" - | "fading-in" - | "idle-app" - | "fading-out"; + "verifying" | "idle-auth" | "fading-in" | "idle-app" | "fading-out"; function FullscreenApp() { const searchParams = new URLSearchParams(window.location.search); @@ -196,7 +192,7 @@ function App() { const savedSize = localStorage.getItem( "termix-font-size", ) as FontSizeId | null; - applyFontSize(savedSize ?? "lg"); + applyFontSize(savedSize ?? "md"); return () => { if (timerRef.current) clearTimeout(timerRef.current); }; diff --git a/src/types/auth-protocols.ts b/src/types/auth-protocols.ts new file mode 100644 index 00000000..664f2006 --- /dev/null +++ b/src/types/auth-protocols.ts @@ -0,0 +1,71 @@ +export const AUTH_OVERRIDE_PROTOCOLS = ["ssh", "rdp", "vnc", "telnet"] as const; + +export type AuthOverrideProtocol = (typeof AUTH_OVERRIDE_PROTOCOLS)[number]; + +// Storage and API contracts are protocol-aware, but SSH is intentionally the +// only protocol whose recipient override flow is enabled today. +export const SUPPORTED_AUTH_OVERRIDE_PROTOCOLS = [ + "ssh", +] as const satisfies readonly AuthOverrideProtocol[]; + +export const AUTH_PROTOCOL_METADATA = { + ssh: { + label: "SSH", + enableField: "enableSsh", + credentialField: "credentialId", + }, + rdp: { + label: "RDP", + enableField: "enableRdp", + credentialField: "rdpCredentialId", + }, + vnc: { + label: "VNC", + enableField: "enableVnc", + credentialField: "vncCredentialId", + }, + telnet: { + label: "Telnet", + enableField: "enableTelnet", + credentialField: "telnetCredentialId", + }, +} as const satisfies Record< + AuthOverrideProtocol, + { + label: string; + enableField: "enableSsh" | "enableRdp" | "enableVnc" | "enableTelnet"; + credentialField: + | "credentialId" + | "rdpCredentialId" + | "vncCredentialId" + | "telnetCredentialId"; + } +>; + +export function isAuthOverrideProtocol( + value: unknown, +): value is AuthOverrideProtocol { + return ( + typeof value === "string" && + AUTH_OVERRIDE_PROTOCOLS.includes(value as AuthOverrideProtocol) + ); +} + +export function isSupportedAuthOverrideProtocol( + protocol: AuthOverrideProtocol, +): boolean { + return SUPPORTED_AUTH_OVERRIDE_PROTOCOLS.includes( + protocol as (typeof SUPPORTED_AUTH_OVERRIDE_PROTOCOLS)[number], + ); +} + +export interface HostAuthOverrideState< + CredentialId extends number | string = number, +> { + credentialId?: CredentialId; + required: boolean; + ownerAuthShared: boolean; +} + +export type HostAuthOverrides = + Partial>>; diff --git a/src/types/guacamole-common-js.d.ts b/src/types/guacamole-common-js.d.ts index 24079905..f82f8373 100644 --- a/src/types/guacamole-common-js.d.ts +++ b/src/types/guacamole-common-js.d.ts @@ -49,8 +49,7 @@ declare module "guacamole-common-js" { onplay: (() => void) | null; onpause: (() => void) | null; onseek: - | ((position: number, current: number, total: number) => void) - | null; + ((position: number, current: number, total: number) => void) | null; getDisplay(): Display; getPosition(): number; getDuration(): number; diff --git a/src/types/homepage-types.ts b/src/types/homepage-types.ts index b6611876..733533c5 100644 --- a/src/types/homepage-types.ts +++ b/src/types/homepage-types.ts @@ -108,13 +108,7 @@ export interface NotesConfig { } export type HostMetricKey = - | "cpu" - | "memory" - | "disk" - | "uptime" - | "network" - | "system" - | "processes"; + "cpu" | "memory" | "disk" | "uptime" | "network" | "system" | "processes"; export interface HostStatusConfig { hostId: number; @@ -155,11 +149,7 @@ export interface RssFeedConfig { // ---- New widget configs ---- export type MetricsChartMetric = - | "cpu" - | "memory" - | "disk" - | "net_rx" - | "net_tx"; + "cpu" | "memory" | "disk" | "net_rx" | "net_tx"; export type MetricsChartRange = "15m" | "1h" | "6h" | "24h"; export interface MetricsChartConfig { @@ -192,13 +182,7 @@ export interface PingStatusConfig { } export type ActivityType = - | "terminal" - | "file_manager" - | "docker" - | "tunnel" - | "rdp" - | "vnc" - | "telnet"; + "terminal" | "file_manager" | "docker" | "tunnel" | "rdp" | "vnc" | "telnet"; export interface RecentActivityConfig { maxItems: number; diff --git a/src/types/index.ts b/src/types/index.ts index 0c1b8c80..6a359ca2 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -1,6 +1,13 @@ import type { Client } from "ssh2"; import type { Request } from "express"; import type { RefObject } from "react"; +import type { HostAuthOverrides } from "./auth-protocols.js"; + +export type { + AuthOverrideProtocol, + HostAuthOverrideState, + HostAuthOverrides, +} from "./auth-protocols.js"; // ============================================================================ // SSO / AUTHENTICATION PROVIDER TYPES @@ -59,12 +66,7 @@ export interface LDAPProviderConfig { export type ConnectionType = "ssh" | "rdp" | "vnc" | "telnet"; export type SSHAuthType = - | "password" - | "key" - | "credential" - | "none" - | "opkssh" - | "tailscale"; + "password" | "key" | "credential" | "none" | "opkssh" | "tailscale"; export type GuacamoleAuthType = "password" | "credential"; @@ -126,6 +128,7 @@ export interface Host { | "agent" | "vault"; useWarpgate?: boolean; + shareSshAuth?: boolean; password?: string; key?: string; keyPassword?: string; @@ -218,6 +221,7 @@ export interface Host { hasKeyPassword?: boolean; isShared?: boolean; + authOverrides?: HostAuthOverrides; permissionLevel?: "connect" | "view" | "edit" | "manage"; sharedExpiresAt?: string; ownerUsername?: string; @@ -257,6 +261,7 @@ export interface HostData { | "tailscale" | "agent"; useWarpgate?: boolean; + shareSshAuth?: boolean; password?: string; key?: File | string | null; keyPassword?: string; @@ -771,12 +776,7 @@ export type ErrorType = // ============================================================================ export type AuthType = - | "password" - | "key" - | "credential" - | "none" - | "opkssh" - | "tailscale"; + "password" | "key" | "credential" | "none" | "opkssh" | "tailscale"; export type KeyType = "rsa" | "ecdsa" | "ed25519"; diff --git a/src/types/proxmox.ts b/src/types/proxmox.ts index e7507e32..ed2b4e53 100644 --- a/src/types/proxmox.ts +++ b/src/types/proxmox.ts @@ -13,6 +13,7 @@ export interface ProxmoxDiscoverResult { guests: ProxmoxGuest[]; credentialId: number | null; defaultCredentialId: number | null; + jumpHosts?: unknown[] | null; } export interface ProxmoxSyncResult { diff --git a/src/types/ui-types.ts b/src/types/ui-types.ts index 4064ca60..eeb16385 100644 --- a/src/types/ui-types.ts +++ b/src/types/ui-types.ts @@ -1,3 +1,11 @@ +import type { HostAuthOverrides } from "./auth-protocols.js"; + +export type { + AuthOverrideProtocol, + HostAuthOverrideState, + HostAuthOverrides, +} from "./auth-protocols.js"; + export type Host = { id: string; name: string; @@ -20,6 +28,7 @@ export type Host = { | "vault" | "agent"; useWarpgate?: boolean; + shareSshAuth?: boolean; credentialId?: string; vaultProfileId?: string; overrideCredentialUsername?: boolean; @@ -178,6 +187,7 @@ export type Host = { forceKeyboardInteractive?: boolean; isShared?: boolean; + authOverrides?: HostAuthOverrides; permissionLevel?: SharePermissionLevel; sharedExpiresAt?: string; ownerUsername?: string; @@ -304,11 +314,7 @@ export type Tab = { }; export type DockerContainerStatus = - | "running" - | "exited" - | "paused" - | "created" - | "restarting"; + "running" | "exited" | "paused" | "created" | "restarting"; export type DockerContainer = { id: string; @@ -358,10 +364,7 @@ export type LayoutPreset = { }; export type UserProfileSection = - | "account" - | "appearance" - | "security" - | "api-keys"; + "account" | "appearance" | "security" | "api-keys"; export type AdminSection = | "general" | "sso" @@ -389,13 +392,7 @@ export type FontSizeId = "xs" | "sm" | "md" | "lg" | "xl"; export type ToolsTab = "ssh-tools" | "snippets" | "history" | "split-screen"; export type SplitMode = - | "none" - | "2-way" - | "3-way" - | "3-way-horizontal" - | "4-way" - | "5-way" - | "6-way"; + "none" | "2-way" | "3-way" | "3-way-horizontal" | "4-way" | "5-way" | "6-way"; export type Snippet = { id: number; diff --git a/src/ui/AppShell.tsx b/src/ui/AppShell.tsx index 2729ce48..07bd6952 100644 --- a/src/ui/AppShell.tsx +++ b/src/ui/AppShell.tsx @@ -5,7 +5,7 @@ import { useTranslation } from "react-i18next"; import { Separator } from "@/components/separator"; import { Button } from "@/components/button"; import { Sheet, SheetContent } from "@/components/sheet"; -import { ChevronLeft, ChevronRight, Maximize2 } from "lucide-react"; +import { ChevronLeft, ChevronRight, Maximize2, Minimize2 } from "lucide-react"; import { useState, useRef, @@ -125,6 +125,7 @@ import { createSSHHost, getActiveSessions, getUserPreferences, + saveUserPreferences, dismissDonationModal, isElectron, type UserPreferences, @@ -139,7 +140,7 @@ import { ServerStatusProvider } from "@/lib/ServerStatusContext"; import { TransferMonitor } from "@/features/file-manager/TransferMonitor.tsx"; import { sshHostToHost } from "@/sidebar/HostManagerData"; import { resolveHostTabType } from "@/lib/host-connection-tabs"; -import { changeAppLanguage } from "@/i18n/i18n"; +import { changeAppLanguage, consumeLoginLanguage } from "@/i18n/i18n"; import { quickConnectHostToPayload } from "@/sidebar/quick-connect-host"; function buildHostTree( @@ -263,6 +264,7 @@ export function AppShell({ }); const [sidebarDragging, setSidebarDragging] = useState(false); const [sidebarEditing, setSidebarEditing] = useState(false); + const [settingsFullscreen, setSettingsFullscreen] = useState(false); const [isAppFullscreen, setIsAppFullscreen] = useState( () => !!document.fullscreenElement, ); @@ -287,6 +289,21 @@ export function AppShell({ }, [paneTabIds, tabs]); const isMobile = useIsMobile(); + const isSettingsView = + railView === "user-profile" || railView === "admin-settings"; + + useEffect(() => { + if (!settingsFullscreen) return; + const closeOnEscape = (event: KeyboardEvent) => { + if (event.key === "Escape") setSettingsFullscreen(false); + }; + window.addEventListener("keydown", closeOnEscape); + return () => window.removeEventListener("keydown", closeOnEscape); + }, [settingsFullscreen]); + + useEffect(() => { + if (!isSettingsView) setSettingsFullscreen(false); + }, [isSettingsView]); const sidebarOpenBeforeMobile = useRef(sidebarOpen); useEffect(() => { @@ -581,8 +598,7 @@ export function AppShell({ const termRef = terminalRefs.current.get(tabId); ( termRef?.current as - | import("@/features/terminal/Terminal").TerminalHandle - | null + import("@/features/terminal/Terminal").TerminalHandle | null )?.focus(); } return; @@ -691,6 +707,7 @@ export function AppShell({ useEffect(() => { getUserPreferences() .then((prefs) => { + const loginLanguage = consumeLoginLanguage(); setUserPrefs(prefs); if (prefs.storageMode === "cloud") { // Persist the current browser values before overwriting, so any tab can restore them @@ -724,8 +741,12 @@ export function AppShell({ localStorage.setItem("termix-accent", prefs.accentColor); applyAccentColor(prefs.accentColor); } - if (prefs.language && prefs.language !== i18n.language) { - void changeAppLanguage(prefs.language); + const preferredLanguage = loginLanguage ?? prefs.language; + if (preferredLanguage && preferredLanguage !== i18n.language) { + void changeAppLanguage(preferredLanguage); + } + if (loginLanguage && loginLanguage !== prefs.language) { + void saveUserPreferences({ language: loginLanguage }); } if ( prefs.commandAutocomplete !== null && @@ -1510,6 +1531,7 @@ export function AppShell({ setSidebarOpen(false); } else { if (view !== railView) setSidebarEditing(false); + if (view !== railView) setSettingsFullscreen(false); setRailView(view); setSidebarOpen(true); } @@ -1867,12 +1889,42 @@ export function AppShell({ )} + {isSettingsView && ( + <> + + + + )} @@ -1904,30 +1956,38 @@ export function AppShell({ )}
{/* Skinny icon rail — desktop only, hidden on mobile */} - + {!settingsFullscreen && ( + + )} {/* Desktop: inline resizable sidebar */} {!isMobile && (
{sidebarHeader} {sidebarPanelContent} - {sidebarOpen && !sidebarEditing && ( + {sidebarOpen && !sidebarEditing && !settingsFullscreen && (
{sidebarHeader} @@ -1953,6 +2013,8 @@ export function AppShell({ {/* Main content area */}
{!isMobile && !sidebarOpen && ( diff --git a/src/ui/api/alerts-api.ts b/src/ui/api/alerts-api.ts index 2b1ef500..55ad91ee 100644 --- a/src/ui/api/alerts-api.ts +++ b/src/ui/api/alerts-api.ts @@ -101,8 +101,7 @@ function mapRule(r: Record): AlertRule { enabled: Boolean(r.enabled), triggerType: (r.trigger_type ?? r.triggerType) as string, thresholdValue: (r.threshold_value ?? r.thresholdValue ?? null) as - | number - | null, + number | null, thresholdDurationSeconds: (r.threshold_duration_seconds ?? r.thresholdDurationSeconds ?? null) as number | null, diff --git a/src/ui/api/guacamole-api.ts b/src/ui/api/guacamole-api.ts index f47dd455..310241b5 100644 --- a/src/ui/api/guacamole-api.ts +++ b/src/ui/api/guacamole-api.ts @@ -1,4 +1,20 @@ -import { authApi, handleApiError } from "@/main-axios"; +import { + authApi, + getRemoteGuacamoleApi, + handleApiError, + isElectron, +} from "@/main-axios"; +import type { AxiosInstance } from "axios"; + +/** + * The embedded desktop backend does not bundle guacd, which is why + * resolveConnectionOrigin() pins RDP/VNC/Telnet to "remote". These calls have to + * follow: asking the embedded backend reports the guacd *it* cannot reach, + * rather than the one on the connected server that serves the session. + */ +function guacamoleApi(): AxiosInstance { + return isElectron() ? getRemoteGuacamoleApi() : authApi; +} export interface GuacamoleTokenRequest { protocol: "rdp" | "vnc" | "telnet"; @@ -189,7 +205,7 @@ export async function getGuacamoleToken( try { const guacParams = toGuacamoleParams(request.guacamoleConfig); - const response = await authApi.post("/guacamole/token", { + const response = await guacamoleApi().post("/guacamole/token", { type: request.protocol, hostname: request.hostname, port: request.port, @@ -212,15 +228,18 @@ export async function getGuacamoleTokenFromHost( promptedCredentials?: { username?: string; password?: string }, ): Promise { try { - const response = await authApi.post(`/guacamole/connect-host/${hostId}`, { - ...(protocol ? { protocol } : {}), - ...(promptedCredentials?.username - ? { promptedUsername: promptedCredentials.username } - : {}), - ...(promptedCredentials?.password - ? { promptedPassword: promptedCredentials.password } - : {}), - }); + const response = await guacamoleApi().post( + `/guacamole/connect-host/${hostId}`, + { + ...(protocol ? { protocol } : {}), + ...(promptedCredentials?.username + ? { promptedUsername: promptedCredentials.username } + : {}), + ...(promptedCredentials?.password + ? { promptedPassword: promptedCredentials.password } + : {}), + }, + ); return response.data; } catch (error) { throw handleApiError(error, "get guacamole token from host"); @@ -230,6 +249,6 @@ export async function getGuacamoleTokenFromHost( export async function getGuacdStatus(): Promise<{ guacd: { status: string }; }> { - const response = await authApi.get("/guacamole/status"); + const response = await guacamoleApi().get("/guacamole/status"); return response.data; } diff --git a/src/ui/api/rbac-api.ts b/src/ui/api/rbac-api.ts index 808843f8..15e37a50 100644 --- a/src/ui/api/rbac-api.ts +++ b/src/ui/api/rbac-api.ts @@ -1,5 +1,6 @@ import { handleApiError, rbacApi } from "@/main-axios"; import type { AccessRecord, Role, UserRole } from "@/main-axios"; +import type { AuthOverrideProtocol } from "@/types/auth-protocols"; export async function getRoles(): Promise<{ roles: Role[] }> { try { @@ -232,6 +233,40 @@ export async function revokeHostAccess( } } +export async function getHostAuthOverride( + hostId: number, + protocol: AuthOverrideProtocol, +): Promise<{ protocol: AuthOverrideProtocol; credentialId: number | null }> { + try { + const response = await rbacApi.get( + `/rbac/host-access/${hostId}/auth/${protocol}`, + ); + return response.data; + } catch (error) { + throw handleApiError(error, "fetch host authentication override"); + } +} + +export async function setHostAuthOverride( + hostId: number, + protocol: AuthOverrideProtocol, + credentialId: number | null, +): Promise<{ + success: boolean; + protocol: AuthOverrideProtocol; + credentialId: number | null; +}> { + try { + const response = await rbacApi.put( + `/rbac/host-access/${hostId}/auth/${protocol}`, + { credentialId }, + ); + return response.data; + } catch (error) { + throw handleApiError(error, "update host authentication override"); + } +} + // ============================================================================ // SNIPPET SHARING // ============================================================================ diff --git a/src/ui/api/settings-api.ts b/src/ui/api/settings-api.ts index f49f8ff4..7b4fff31 100644 --- a/src/ui/api/settings-api.ts +++ b/src/ui/api/settings-api.ts @@ -149,7 +149,10 @@ export async function updateGuacamoleSettings(settings: { // ANALYTICS SETTINGS // ============================================================================ -export async function getAnalyticsEnabled(): Promise<{ enabled: boolean }> { +export async function getAnalyticsEnabled(): Promise<{ + enabled: boolean; + locked?: boolean; +}> { try { const response = await authApi.get("/users/analytics-enabled"); return response.data; diff --git a/src/ui/api/ssh-file-operations-api.ts b/src/ui/api/ssh-file-operations-api.ts index f2798fe8..8abfd046 100644 --- a/src/ui/api/ssh-file-operations-api.ts +++ b/src/ui/api/ssh-file-operations-api.ts @@ -800,11 +800,7 @@ export async function compressSSHFiles( // ============================================================================ export type HostConnectionState = - | "disconnected" - | "connecting" - | "ready" - | "auth_required" - | "error"; + "disconnected" | "connecting" | "ready" | "auth_required" | "error"; export interface EnsureSSHSessionResult { state: HostConnectionState; diff --git a/src/ui/api/ssh-host-management-api.ts b/src/ui/api/ssh-host-management-api.ts index 294a65dc..963defa3 100644 --- a/src/ui/api/ssh-host-management-api.ts +++ b/src/ui/api/ssh-host-management-api.ts @@ -174,6 +174,60 @@ export async function discoverProxmoxGuests( } } +export function discoverProxmoxGuestsStream( + hostId: number, + handlers: { + onProgress?: (done: number, total: number) => void; + onResult: (result: ProxmoxDiscoverResult) => void; + onError: (message: string) => void; + }, +): () => void { + const baseURL = (authApi.defaults.baseURL || "").replace(/\/$/, ""); + const source = new EventSource( + `${baseURL}/proxmox/discover/stream?hostId=${encodeURIComponent( + String(hostId), + )}`, + { withCredentials: true }, + ); + let settled = false; + const close = () => { + settled = true; + source.close(); + }; + source.addEventListener("progress", (event) => { + try { + const data = JSON.parse((event as MessageEvent).data); + handlers.onProgress?.(data.done, data.total); + } catch { + // ignore malformed progress frames + } + }); + source.addEventListener("result", (event) => { + close(); + try { + handlers.onResult(JSON.parse((event as MessageEvent).data)); + } catch { + handlers.onError("Failed to parse discovery result"); + } + }); + source.addEventListener("fail", (event) => { + close(); + let message = "Discovery failed"; + try { + message = JSON.parse((event as MessageEvent).data).message || message; + } catch { + // keep default message + } + handlers.onError(message); + }); + source.onerror = () => { + if (settled) return; + close(); + handlers.onError("Discovery connection lost"); + }; + return close; +} + export async function syncProxmoxGuests( hostId: number, ): Promise { diff --git a/src/ui/auth/Auth.tsx b/src/ui/auth/Auth.tsx index 80fc649d..7c59df4e 100644 --- a/src/ui/auth/Auth.tsx +++ b/src/ui/auth/Auth.tsx @@ -37,7 +37,11 @@ import { import { getSSOProviders, ldapLogin } from "@/api/sso-provider-api"; import type { SSOProviderPublic } from "@/types/index"; import { Checkbox } from "@/components/checkbox"; -import { changeAppLanguage, normalizeLanguageCode } from "@/i18n/i18n"; +import { + changeAppLanguage, + normalizeLanguageCode, + rememberLoginLanguage, +} from "@/i18n/i18n"; import { removeSilentSigninFromSearch, shouldTriggerSilentSignin, @@ -239,7 +243,8 @@ export function Auth({ onLogin }: AuthProps) { ); function handleLanguageChange(code: string) { - void changeAppLanguage(code) + const language = rememberLoginLanguage(code); + void changeAppLanguage(language) .then((language) => setLanguage(language)) .catch(() => {}); } diff --git a/src/ui/components/proxmox/ProxmoxDiscoverDialog.tsx b/src/ui/components/proxmox/ProxmoxDiscoverDialog.tsx index d823b339..8eef51c5 100644 --- a/src/ui/components/proxmox/ProxmoxDiscoverDialog.tsx +++ b/src/ui/components/proxmox/ProxmoxDiscoverDialog.tsx @@ -1,4 +1,4 @@ -import { useState } from "react"; +import { useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { Server, RefreshCw, CheckSquare, Square, Download } from "lucide-react"; import { toast } from "sonner"; @@ -18,7 +18,7 @@ import { SelectValue, } from "@/components/select"; import { - discoverProxmoxGuests, + discoverProxmoxGuestsStream, bulkImportSSHHosts, getSSHHosts, } from "@/main-axios"; @@ -56,10 +56,18 @@ export function ProxmoxDiscoverDialog({ preselectedHostId ? String(preselectedHostId) : "", ); const [discovering, setDiscovering] = useState(false); + const [progress, setProgress] = useState<{ + done: number; + total: number; + } | null>(null); + const streamCloseRef = useRef<(() => void) | null>(null); const [guests, setGuests] = useState(null); const [discoveredCredentialId, setDiscoveredCredentialId] = useState< number | null >(null); + const [discoveredJumpHosts, setDiscoveredJumpHosts] = useState< + unknown[] | null + >(null); const [selected, setSelected] = useState>(new Set()); const [importing, setImporting] = useState(false); @@ -82,35 +90,50 @@ export function ProxmoxDiscoverDialog({ if (!preselectedHostId) setSelectedHostId(""); setGuests(null); setDiscoveredCredentialId(null); + setDiscoveredJumpHosts(null); setSelected(new Set()); + streamCloseRef.current?.(); + streamCloseRef.current = null; + setProgress(null); setDiscovering(false); setImporting(false); } - async function handleDiscover() { + function handleDiscover() { const hostId = preselectedHostId ?? (selectedHostId ? Number(selectedHostId) : null); if (!hostId) return; setDiscovering(true); setGuests(null); setDiscoveredCredentialId(null); + setDiscoveredJumpHosts(null); setSelected(new Set()); - try { - const result = await discoverProxmoxGuests(hostId); - setGuests(result.guests); - setDiscoveredCredentialId(result.credentialId ?? null); - setSelected( - new Set( - result.guests - .filter((g) => g.status === "running") - .map((g) => g.vmid), - ), - ); - } catch (err: any) { - toast.error(err?.message ?? t("hosts.proxmoxDiscoveryFailed")); - } finally { - setDiscovering(false); - } + setProgress(null); + streamCloseRef.current?.(); + streamCloseRef.current = discoverProxmoxGuestsStream(hostId, { + onProgress: (done, total) => setProgress({ done, total }), + onResult: (result) => { + setGuests(result.guests); + setDiscoveredCredentialId(result.credentialId ?? null); + setDiscoveredJumpHosts(result.jumpHosts ?? null); + setSelected( + new Set( + result.guests + .filter((g) => g.status === "running") + .map((g) => g.vmid), + ), + ); + setDiscovering(false); + setProgress(null); + streamCloseRef.current = null; + }, + onError: (message) => { + toast.error(message ?? t("hosts.proxmoxDiscoveryFailed")); + setDiscovering(false); + setProgress(null); + streamCloseRef.current = null; + }, + }); } async function handleImport() { @@ -122,38 +145,41 @@ export function ProxmoxDiscoverDialog({ const importAuth = resolveProxmoxImportAuth(defaultAuthType, credId); const selectedGuests = guests.filter((g) => selected.has(g.vmid)); - const skippedNoIp = selectedGuests.filter((g) => !g.ip).length; - const toImport = selectedGuests - .filter((g) => !!g.ip) - .map((g) => ({ - name: g.name, - ip: g.ip as string, - port: g.connectionType === "rdp" ? 3389 : 22, - username: defaultUsername ?? "root", - folder: importFolder, - ...importAuth, - enableTerminal: g.connectionType !== "rdp", - enableFileManager: g.connectionType !== "rdp", - enableTunnel: g.connectionType !== "rdp", - enableSsh: g.connectionType !== "rdp", - enableRdp: g.connectionType === "rdp", - enableDocker: g.enableDocker, - connectionType: g.connectionType, - tags: ["proxmox", g.type, g.node], - proxmoxConfig: { - source: { - source: "proxmox", - sourceHostId: Number(effectiveHostId), - node: g.node, - vmid: g.vmid, - type: g.type, - lastSeenAt: new Date().toISOString(), - lastStatus: g.status, - missingSince: null, - }, + const toImport = selectedGuests.map((g) => ({ + name: g.name, + // No IP discovered (e.g. QEMU without a running guest agent): import + // with a placeholder so the host is created and the user can fill in + // the real IP. Re-sync keeps the manual value (guest.ip || existing.ip). + ip: g.ip || "0.0.0.0", + port: g.connectionType === "rdp" ? 3389 : 22, + username: defaultUsername ?? "root", + folder: importFolder, + // Inherit the jump-host chain from the scanned Proxmox host so the + // imported guests are reachable the same way; user can override. + jumpHosts: discoveredJumpHosts ?? undefined, + ...importAuth, + enableTerminal: g.connectionType !== "rdp", + enableFileManager: g.connectionType !== "rdp", + enableTunnel: g.connectionType !== "rdp", + enableSsh: g.connectionType !== "rdp", + enableRdp: g.connectionType === "rdp", + enableDocker: g.enableDocker, + connectionType: g.connectionType, + tags: ["proxmox", g.type, g.node], + proxmoxConfig: { + source: { + source: "proxmox", + sourceHostId: Number(effectiveHostId), + node: g.node, + vmid: g.vmid, + type: g.type, + lastSeenAt: new Date().toISOString(), + lastStatus: g.status, + missingSince: null, }, - })); + }, + })); const result = toImport.length ? await bulkImportSSHHosts(toImport, false) @@ -175,9 +201,6 @@ export function ProxmoxDiscoverDialog({ result.failed ? t("hosts.proxmoxResultFailed", { count: result.failed }) : null, - skippedNoIp - ? t("hosts.proxmoxResultSkippedNoIp", { count: skippedNoIp }) - : null, ] .filter(Boolean) .join(", "); @@ -285,7 +308,9 @@ export function ProxmoxDiscoverDialog({ className={`size-3.5 mr-1.5 ${discovering ? "animate-spin" : ""}`} /> {discovering - ? t("hosts.proxmoxDiscovering") + ? progress + ? `${t("hosts.proxmoxDiscovering")} ${progress.done}/${progress.total}` + : t("hosts.proxmoxDiscovering") : t("hosts.proxmoxDiscoverGuests")} )} @@ -357,11 +382,15 @@ export function ProxmoxDiscoverDialog({ > {g.status} - {g.ip && ( - - {g.ip} - - )} + + {g.ip || "no IP"} + ))}
diff --git a/src/ui/components/proxmox/proxmox-import-auth.ts b/src/ui/components/proxmox/proxmox-import-auth.ts index b4257b01..e96dbf21 100644 --- a/src/ui/components/proxmox/proxmox-import-auth.ts +++ b/src/ui/components/proxmox/proxmox-import-auth.ts @@ -1,5 +1,11 @@ const SECRET_BACKED_AUTH_TYPES = new Set(["password", "key"]); -const SECRETLESS_AUTH_TYPES = new Set(["none", "opkssh", "tailscale", "vault"]); +const SECRETLESS_AUTH_TYPES = new Set([ + "none", + "agent", + "opkssh", + "tailscale", + "vault", +]); export type ProxmoxImportAuth = { authType: string; @@ -11,21 +17,29 @@ export function resolveProxmoxImportAuth( defaultAuthType: string | undefined, credentialId: number | null | undefined, ): ProxmoxImportAuth { - if (defaultAuthType === "credential" || (!defaultAuthType && credentialId)) { - return credentialId - ? { - authType: "credential", - credentialId, - overrideCredentialUsername: true, - } - : { authType: "none" }; - } - + // An explicit secretless auth choice (none/opkssh/tailscale/vault) wins. if (defaultAuthType && SECRETLESS_AUTH_TYPES.has(defaultAuthType)) { return { authType: defaultAuthType }; } - if (defaultAuthType && !SECRET_BACKED_AUTH_TYPES.has(defaultAuthType)) { + // A credential (configured default OR inherited from the source Proxmox host) + // is a concrete auth source -> use it, even when defaultAuthType is the + // "password"/"key" default. Otherwise imported guests end up as authType + // "none" although the host authenticates via a credential. + if (credentialId) { + return { + authType: "credential", + credentialId, + overrideCredentialUsername: true, + }; + } + + // Explicit non secret-backed special type without a credential. + if ( + defaultAuthType && + defaultAuthType !== "credential" && + !SECRET_BACKED_AUTH_TYPES.has(defaultAuthType) + ) { return { authType: defaultAuthType }; } diff --git a/src/ui/dashboard/DashboardTab.tsx b/src/ui/dashboard/DashboardTab.tsx index fddd09bc..5b182598 100644 --- a/src/ui/dashboard/DashboardTab.tsx +++ b/src/ui/dashboard/DashboardTab.tsx @@ -1237,8 +1237,7 @@ export function DashboardTab({ () => { try { return (localStorage.getItem("dashboardView") ?? "dashboard") as - | "dashboard" - | "homepage"; + "dashboard" | "homepage"; } catch { return "dashboard"; } diff --git a/src/ui/features/file-manager/FileManager.tsx b/src/ui/features/file-manager/FileManager.tsx index a8bced7d..f88e1c56 100644 --- a/src/ui/features/file-manager/FileManager.tsx +++ b/src/ui/features/file-manager/FileManager.tsx @@ -67,6 +67,7 @@ import { transferToHost, addTransferRecent, type TransferMethodPreference, + type DiskFilesystem, } from "@/main-axios.ts"; import { beginTransferProgressMonitoring } from "./transferProgressMonitor.tsx"; import { createFormatTransferMetrics } from "./transferMetricsFormat.ts"; @@ -106,6 +107,9 @@ function FileManagerContent({ const [currentPath, setCurrentPath] = useState( initialPath || initialHost?.defaultPath || "/", ); + const lastSuccessfulPathRef = useRef( + initialPath || initialHost?.defaultPath || "/", + ); const [navHistory, setNavHistory] = useState([ initialPath || initialHost?.defaultPath || "/", ]); @@ -152,6 +156,8 @@ function FileManagerContent({ usedHuman: string; totalHuman: string; percent: number; + mount: string | null; + filesystems: DiskFilesystem[]; } | null>(null); const [contextMenu, setContextMenu] = useState<{ @@ -497,6 +503,7 @@ function FileManagerContent({ ? response : response?.files || []; setFiles(files); + lastSuccessfulPathRef.current = currentPath; clearSelection(); initialLoadDoneRef.current = true; @@ -565,7 +572,7 @@ function FileManagerContent({ } const loadDirectory = useCallback( - async (path: string): Promise => { + async (path: string, conflictAttempt = 0): Promise => { if (!sshSessionId) { console.error("Cannot load directory: no SSH session ID"); return false; @@ -595,6 +602,7 @@ function FileManagerContent({ : response?.files || []; setFiles(files); + lastSuccessfulPathRef.current = resolvedPath; clearSelection(); return true; } catch (error: unknown) { @@ -617,12 +625,27 @@ function FileManagerContent({ const httpStatus = apiError.status ?? apiError.response?.status; - // 409 = concurrent request already in flight — silently drop + // The sidebar may be listing the same path to populate its tree. + // Retry instead of leaving the breadcrumb and visible files out of sync. if (httpStatus === 409) { + if (conflictAttempt < 3) { + await new Promise((resolve) => setTimeout(resolve, 250)); + return loadDirectory(resolvedPath, conflictAttempt + 1); + } + const previousPath = lastSuccessfulPathRef.current; + lastPathChangeRef.current = previousPath; + setCurrentPath((current) => + current === resolvedPath ? previousPath : current, + ); return false; } if (apiError.response?.data?.needsSudo) { + const previousPath = lastSuccessfulPathRef.current; + lastPathChangeRef.current = previousPath; + setCurrentPath((current) => + current === resolvedPath ? previousPath : current, + ); if (!sudoDialogOpen) { setPendingSudoOperation({ type: "navigate", path: resolvedPath }); setSudoDialogOpen(true); @@ -700,7 +723,7 @@ function FileManagerContent({ } } }, - [sshSessionId, isLoading, clearSelection, t, sudoDialogOpen, currentHost], + [sshSessionId, clearSelection, t, sudoDialogOpen, currentHost], ); const debouncedLoadDirectory = useCallback( @@ -2785,6 +2808,8 @@ function FileManagerContent({ usedHuman: metrics.disk.usedHuman, totalHuman: metrics.disk.totalHuman, percent: metrics.disk.percent, + mount: metrics.disk.mount ?? null, + filesystems: metrics.disk.filesystems ?? [], }); } }) diff --git a/src/ui/features/file-manager/FileManagerSidebar.tsx b/src/ui/features/file-manager/FileManagerSidebar.tsx index 22ae9739..6fa84f5a 100644 --- a/src/ui/features/file-manager/FileManagerSidebar.tsx +++ b/src/ui/features/file-manager/FileManagerSidebar.tsx @@ -6,7 +6,15 @@ import React, { useMemo, } from "react"; import { cn } from "@/lib/utils.ts"; -import { Star, Clock, Bookmark, File, Folder } from "lucide-react"; +import { + Star, + Clock, + Bookmark, + File, + Folder, + ChevronDown, + Check, +} from "lucide-react"; import { useTranslation } from "react-i18next"; import type { SSHHost } from "@/types"; import { @@ -17,9 +25,16 @@ import { removeRecentFile, removePinnedFile, removeFolderShortcut, + type DiskFilesystem, } from "@/main-axios.ts"; import { toast } from "sonner"; import FolderTree from "@/components/folder.tsx"; +import { + DropdownMenu, + DropdownMenuTrigger, + DropdownMenuContent, + DropdownMenuItem, +} from "@/components/dropdown-menu.tsx"; // ─── Interfaces ──────────────────────────────────────────────────────────────── @@ -71,7 +86,92 @@ interface FileManagerSidebarProps { onItemContextMenu?: (event: React.MouseEvent, item: SidebarItem) => void; sshSessionId?: string; refreshTrigger?: number; - diskInfo?: { usedHuman: string; totalHuman: string; percent: number }; + diskInfo?: { + usedHuman: string; + totalHuman: string; + percent: number; + mount?: string | null; + filesystems?: DiskFilesystem[]; + }; +} + +// ─── Storage meter ───────────────────────────────────────────────────────────── + +function StorageMeter({ + storage, + filesystems, + selectedMount, + onSelectMount, +}: { + storage: { percent: number; usedHuman: string; totalHuman: string }; + filesystems: DiskFilesystem[]; + selectedMount: string | null; + onSelectMount: (mount: string) => void; +}) { + const { t } = useTranslation(); + const hasPicker = filesystems.length > 1; + + return ( +
+
+ {hasPicker ? ( + + + + {selectedMount ?? t("fileManager.disk")} + + + + + {filesystems.map((fs) => ( + onSelectMount(fs.mount)} + className="gap-3" + > + + + {fs.mount} + + + {fs.usedHuman && fs.totalHuman + ? `${fs.usedHuman}/${fs.totalHuman}` + : "N/A"} + {fs.percent != null ? ` · ${fs.percent}%` : ""} + + + ))} + + + ) : ( + + {selectedMount ?? t("fileManager.disk")} + + )} + + {storage.percent}% {t("fileManager.used")} + +
+
+
+
+ + {storage.usedHuman} {t("fileManager.of")} {storage.totalHuman}{" "} + {t("fileManager.used").toLowerCase()} + +
+ ); } // ─── Component ───────────────────────────────────────────────────────────────── @@ -96,6 +196,9 @@ export function FileManagerSidebar({ // ── Directory tree state ────────────────────────────────────────────────────── const [directoryTree, setDirectoryTree] = useState([]); + // ── Storage state ───────────────────────────────────────────────────────────── + const [selectedMount, setSelectedMount] = useState(null); + /** * Tracks which folder paths have already been lazy-loaded so we don't * re-fetch on every re-selection / collapse-reopen. @@ -228,8 +331,8 @@ export function FileManagerSidebar({ * Called the first time a folder is expanded via FolderTree's onSelect. */ const loadSubdirectory = useCallback( - async (folderId: string, folderPath: string) => { - if (!sshSessionId) return; + async (folderId: string, folderPath: string): Promise => { + if (!sshSessionId) return false; try { const subResponse = await listSSHFiles(sshSessionId, folderPath); @@ -262,16 +365,20 @@ export function FileManagerSidebar({ }); return updateChildren(prevTree); }); + loadedFoldersRef.current.add(folderPath); + return true; } catch (error: unknown) { const status = (error as { status?: number })?.status || (error as { response?: { status?: number } })?.response?.status; if (status === 409) { // Another request was listing this path — retry after the lock clears - setTimeout(() => loadSubdirectory(folderId, folderPath), 600); - return; + setTimeout(() => void loadSubdirectory(folderId, folderPath), 600); + return false; } + loadedFoldersRef.current.delete(folderPath); console.error("Failed to load subdirectory:", error); + return false; } }, [sshSessionId], @@ -309,8 +416,7 @@ export function FileManagerSidebar({ const parent = findByPath(directoryTree); if (parent && !loadedFoldersRef.current.has(parent.path)) { - loadedFoldersRef.current.add(parent.path); - loadSubdirectory(parent.id, parent.path); + void loadSubdirectory(parent.id, parent.path); } }, [currentPath, directoryTree, loadSubdirectory, sshSessionId]); @@ -416,7 +522,6 @@ export function FileManagerSidebar({ item.path !== "/" && !loadedFoldersRef.current.has(item.path) ) { - loadedFoldersRef.current.add(item.path); await loadSubdirectory(id, item.path); } }, @@ -586,7 +691,27 @@ export function FileManagerSidebar({ // ─── Render ─────────────────────────────────────────────────────────────────── - const storageUsedPct = diskInfo?.percent ?? null; + const storageFilesystems = diskInfo?.filesystems ?? []; + const activeStorageMount = selectedMount ?? diskInfo?.mount ?? null; + const selectedFs = + selectedMount !== null + ? (storageFilesystems.find((fs) => fs.mount === selectedMount) ?? null) + : null; + + const storage = + selectedFs && selectedFs.percent !== null + ? { + percent: selectedFs.percent, + usedHuman: selectedFs.usedHuman ?? "N/A", + totalHuman: selectedFs.totalHuman ?? "N/A", + } + : diskInfo && diskInfo.percent !== null + ? { + percent: diskInfo.percent, + usedHuman: diskInfo.usedHuman, + totalHuman: diskInfo.totalHuman, + } + : null; return ( <> @@ -667,7 +792,7 @@ export function FileManagerSidebar({
{/* ── Storage — mobile only (inside scroll) ──────────────── */} - {diskInfo && storageUsedPct !== null && ( + {storage && (
@@ -675,47 +800,30 @@ export function FileManagerSidebar({ {t("fileManager.storage")}
-
-
- {t("fileManager.disk")} - - {storageUsedPct}% {t("fileManager.used")} - -
-
-
-
- - {diskInfo.usedHuman} {t("fileManager.of")}{" "} - {diskInfo.totalHuman} {t("fileManager.used").toLowerCase()} - +
+
)}
{/* ── Storage — desktop only (bottom of sidebar) ──────────── */} - {diskInfo && storageUsedPct !== null && ( + {storage && (
-
- {t("fileManager.storage")} - - {storageUsedPct}% {t("fileManager.used")} - -
-
-
-
- - {diskInfo.usedHuman} {t("fileManager.of")} {diskInfo.totalHuman}{" "} - {t("fileManager.used").toLowerCase()} + + {t("fileManager.storage")} +
)}
diff --git a/src/ui/features/file-manager/file-manager-types.ts b/src/ui/features/file-manager/file-manager-types.ts index 537d29a6..f8baf234 100644 --- a/src/ui/features/file-manager/file-manager-types.ts +++ b/src/ui/features/file-manager/file-manager-types.ts @@ -33,5 +33,4 @@ export interface CreateIntent { } export type PendingSudoOperation = - | { type: "delete"; files: FileItem[] } - | { type: "navigate"; path: string }; + { type: "delete"; files: FileItem[] } | { type: "navigate"; path: string }; diff --git a/src/ui/features/guacamole/GuacamoleApp.tsx b/src/ui/features/guacamole/GuacamoleApp.tsx index a978557d..4c9f80a6 100644 --- a/src/ui/features/guacamole/GuacamoleApp.tsx +++ b/src/ui/features/guacamole/GuacamoleApp.tsx @@ -17,6 +17,7 @@ import { logActivity, isElectron, } from "@/main-axios.ts"; +import { readConfiguredDimension } from "@/features/guacamole/guacamole-display-size.ts"; import { resolveConnectionOrigin } from "@/lib/connection-origin.ts"; import { useTranslation } from "react-i18next"; import { AlertCircle, RefreshCw } from "lucide-react"; @@ -362,7 +363,15 @@ const GuacamoleAppInner = React.forwardRef< } const resolvedProtocol = resolvedProtocolForConnect; - const configuredDpi = Number(hostConfig.guacamoleConfig?.dpi); + const configuredDpi = readConfiguredDimension( + hostConfig.guacamoleConfig?.dpi, + ); + const configuredWidth = readConfiguredDimension( + hostConfig.guacamoleConfig?.width, + ); + const configuredHeight = readConfiguredDimension( + hostConfig.guacamoleConfig?.height, + ); return (
@@ -400,10 +409,9 @@ const GuacamoleAppInner = React.forwardRef< token, protocol: resolvedProtocol, type: resolvedProtocol, - dpi: - Number.isFinite(configuredDpi) && configuredDpi > 0 - ? configuredDpi - : undefined, + width: configuredWidth, + height: configuredHeight, + dpi: configuredDpi, }} isVisible={isVisible} touchMode={touchMode} diff --git a/src/ui/features/guacamole/GuacamoleDisplay.tsx b/src/ui/features/guacamole/GuacamoleDisplay.tsx index 671fa6f7..74e14856 100644 --- a/src/ui/features/guacamole/GuacamoleDisplay.tsx +++ b/src/ui/features/guacamole/GuacamoleDisplay.tsx @@ -69,6 +69,10 @@ export const GuacamoleDisplay = forwardRef< ref, ) { const { t } = useTranslation(); + // The host config pins the session resolution; without it the display follows + // the container. + const hasConfiguredSize = + connectionConfig.width != null && connectionConfig.height != null; const containerRef = useRef(null); const displayRef = useRef(null); const displayElementRef = useRef(null); @@ -493,7 +497,10 @@ export const GuacamoleDisplay = forwardRef< isConnectingRef.current = false; setIsReady(true); onConnect?.(); - if (containerRef.current) { + // A configured resolution is the size the session should render at; + // resizing it to the container would discard it. rescaleDisplay still + // fits that fixed display into whatever space is available. + if (!hasConfiguredSize && containerRef.current) { const rect = containerRef.current.getBoundingClientRect(); const size = getGuacamoleDisplaySize( rect.width, @@ -601,6 +608,7 @@ export const GuacamoleDisplay = forwardRef< connectionConfig.protocol, connectionConfig.type, connectionConfig.dpi, + hasConfiguredSize, touchMode, t, ]); @@ -675,15 +683,17 @@ export const GuacamoleDisplay = forwardRef< resizeTimeoutRef.current = setTimeout(() => { if (clientRef.current && containerRef.current) { const rect = containerRef.current.getBoundingClientRect(); - const size = getGuacamoleDisplaySize( - rect.width, - rect.height, - connectionConfig.protocol ?? connectionConfig.type, - window.devicePixelRatio, - connectionConfig.dpi, - ); if (rect.width > 0 && rect.height > 0) { - clientRef.current.sendSize(size.width, size.height); + if (!hasConfiguredSize) { + const size = getGuacamoleDisplaySize( + rect.width, + rect.height, + connectionConfig.protocol ?? connectionConfig.type, + window.devicePixelRatio, + connectionConfig.dpi, + ); + clientRef.current.sendSize(size.width, size.height); + } rescaleDisplay(true); } } @@ -699,6 +709,7 @@ export const GuacamoleDisplay = forwardRef< connectionConfig.dpi, connectionConfig.protocol, connectionConfig.type, + hasConfiguredSize, rescaleDisplay, ]); diff --git a/src/ui/features/guacamole/guacamole-display-size.ts b/src/ui/features/guacamole/guacamole-display-size.ts index a16a3475..27e34f3a 100644 --- a/src/ui/features/guacamole/guacamole-display-size.ts +++ b/src/ui/features/guacamole/guacamole-display-size.ts @@ -1,6 +1,15 @@ const DEFAULT_RDP_DPI = 96; const MAX_DEVICE_PIXEL_RATIO = 3; +/** + * Reads a guacamoleConfig display field. The UI stores these as strings, and + * leaves them empty when the size should follow the browser window. + */ +export function readConfiguredDimension(value: unknown): number | undefined { + const parsed = Number(value); + return Number.isFinite(parsed) && parsed > 0 ? parsed : undefined; +} + export interface GuacamoleDisplaySize { width: number; height: number; diff --git a/src/ui/features/host-metrics/cards/DiskCard.tsx b/src/ui/features/host-metrics/cards/DiskCard.tsx index 4deff019..e778f828 100644 --- a/src/ui/features/host-metrics/cards/DiskCard.tsx +++ b/src/ui/features/host-metrics/cards/DiskCard.tsx @@ -1,7 +1,8 @@ -import { useState, useEffect } from "react"; +import { useState, useEffect, useMemo } from "react"; import { HardDrive } from "lucide-react"; import { useTranslation } from "react-i18next"; import type { ServerMetrics } from "@/main-axios"; +import { FilesystemPicker } from "./FilesystemPicker"; import { RadialGauge, Sparkline, MiniStat } from "@/components/charts"; import { MetricCard } from "./MetricCard"; import { LineChart, type LineChartSeries } from "@/components/charts/LineChart"; @@ -45,10 +46,41 @@ export function DiskCard({ }; }, [activeTab, hostId]); - const percent = metrics?.disk?.percent ?? null; - const usedHuman = metrics?.disk?.usedHuman ?? null; - const totalHuman = metrics?.disk?.totalHuman ?? null; - const availableHuman = metrics?.disk?.availableHuman ?? null; + const filesystems = useMemo( + () => metrics?.disk?.filesystems ?? [], + [metrics?.disk?.filesystems], + ); + const defaultMount = metrics?.disk?.mount ?? null; + const [selectedMount, setSelectedMount] = useState(null); + + // Drop a stale selection if the mount disappears between polls, so the card + // falls back to the primary filesystem instead of rendering nothing. + useEffect(() => { + if ( + selectedMount !== null && + filesystems.length > 0 && + !filesystems.some((fs) => fs.mount === selectedMount) + ) { + setSelectedMount(null); + } + }, [filesystems, selectedMount]); + + const activeMount = selectedMount ?? defaultMount; + const activeFs = filesystems.find((fs) => fs.mount === activeMount) ?? null; + const isCustomMount = selectedMount !== null && activeFs !== null; + + const percent = isCustomMount + ? activeFs.percent + : (metrics?.disk?.percent ?? null); + const usedHuman = isCustomMount + ? activeFs.usedHuman + : (metrics?.disk?.usedHuman ?? null); + const totalHuman = isCustomMount + ? activeFs.totalHuman + : (metrics?.disk?.totalHuman ?? null); + const availableHuman = isCustomMount + ? activeFs.availableHuman + : (metrics?.disk?.availableHuman ?? null); const series: LineChartSeries[] = [ { @@ -65,9 +97,18 @@ export function DiskCard({ title={t("hostMetrics.diskUsage")} icon={} action={ - hostId != null ? ( - - ) : undefined +
+ {activeTab === "live" && ( + + )} + {hostId != null && ( + + )} +
} >
@@ -77,7 +118,7 @@ export function DiskCard({
void; + align?: "start" | "end"; + className?: string; +}) { + const { t } = useTranslation(); + + if (filesystems.length <= 1) return null; + + const selected = filesystems.find((fs) => fs.mount === value); + + return ( + + + {selected?.mount ?? value ?? "-"} + + + + {filesystems.map((fs) => ( + onChange(fs.mount)} + className="gap-3" + > + + + {fs.mount} + + + {fs.usedHuman && fs.totalHuman + ? `${fs.usedHuman}/${fs.totalHuman}` + : "N/A"} + {fs.percent != null ? ` · ${fs.percent}%` : ""} + + + ))} + + + ); +} diff --git a/src/ui/features/terminal/Terminal.tsx b/src/ui/features/terminal/Terminal.tsx index f13dc7d8..6b89c2ef 100644 --- a/src/ui/features/terminal/Terminal.tsx +++ b/src/ui/features/terminal/Terminal.tsx @@ -15,6 +15,7 @@ import { RobustClipboardProvider } from "@/lib/clipboard-provider"; import { copyToClipboard } from "@/lib/clipboard"; import { Unicode11Addon } from "@xterm/addon-unicode11"; import { WebLinksAddon } from "@xterm/addon-web-links"; +import { SearchAddon } from "@xterm/addon-search"; import { useTranslation } from "react-i18next"; import { getBasePath } from "@/lib/base-path"; import { @@ -35,6 +36,7 @@ import { SSHAuthDialog } from "@/ssh/dialogs/SSHAuthDialog.tsx"; import { PassphraseDialog } from "@/ssh/dialogs/PassphraseDialog.tsx"; import { WarpgateDialog } from "@/ssh/dialogs/WarpgateDialog.tsx"; import { OPKSSHDialog } from "@/ssh/dialogs/OPKSSHDialog.tsx"; +import { TailscaleCheckDialog } from "@/ssh/dialogs/TailscaleCheckDialog.tsx"; import { HostKeyVerificationDialog } from "@/ssh/dialogs/HostKeyVerificationDialog.tsx"; import { TmuxSessionPicker } from "@/ssh/dialogs/TmuxSessionPicker.tsx"; import { @@ -45,9 +47,14 @@ import { ensureTerminalFontsLoaded } from "./terminal-global-styles.ts"; import { useTheme } from "@/components/theme-provider.tsx"; import { globalShortcutHandler } from "@/lib/global-shortcut-handler"; import { useCommandTracker } from "@/features/terminal/command-history/useCommandTracker.ts"; -import { highlightTerminalOutput } from "@/lib/terminal-syntax-highlighter.ts"; +import { + highlightTerminalOutput, + updateControlStringMode, +} from "@/lib/terminal-syntax-highlighter.ts"; import { useCommandHistory } from "@/features/terminal/command-history/CommandHistoryContext.tsx"; +import { getAndroidHardwareKeySequence } from "@/features/terminal/android-hardware-keyboard.ts"; import { CommandAutocomplete } from "./command-history/CommandAutocomplete.tsx"; +import { TerminalSearchBar } from "./search/TerminalSearchBar.tsx"; import { SimpleLoader } from "@/lib/SimpleLoader.tsx"; import { useConfirmation } from "@/hooks/use-confirmation.ts"; import { @@ -65,6 +72,7 @@ import { getNextTerminalFontSize, getTerminalFontZoomDirection, } from "./terminal-font-zoom.ts"; +import { isTabKeyEvent } from "./terminal-key-event.ts"; import { getUserPreferences, parseCustomKeybindings, @@ -222,6 +230,15 @@ const TerminalInner = forwardRef( } | null>(null); const opksshTimeoutRef = useRef(null); + const [tailscaleCheckDialog, setTailscaleCheckDialog] = useState<{ + isOpen: boolean; + authUrl: string; + message?: string; + stage: "prompt" | "waiting"; + } | null>(null); + const tailscaleCheckTimeoutRef = useRef(null); + const tailscaleCheckPendingRef = useRef(false); + const opksshFailedRef = useRef(false); const currentHostIdRef = useRef(null); const currentHostConfigRef = useRef(null); @@ -347,6 +364,22 @@ const TerminalInner = forwardRef( const autocompleteSuggestionsRef = useRef([]); const autocompleteSelectedIndexRef = useRef(0); + const searchAddonRef = useRef(null); + const searchInputRef = useRef(null); + const [showSearch, setShowSearch] = useState(false); + const [searchQuery, setSearchQuery] = useState(""); + const [searchCaseSensitive, setSearchCaseSensitive] = useState(false); + const [searchWholeWord, setSearchWholeWord] = useState(false); + const [searchRegex, setSearchRegex] = useState(false); + const [searchResultIndex, setSearchResultIndex] = useState(-1); + const [searchResultCount, setSearchResultCount] = useState(0); + + const showSearchRef = useRef(false); + const searchQueryRef = useRef(""); + const searchCaseSensitiveRef = useRef(false); + const searchWholeWordRef = useRef(false); + const searchRegexRef = useRef(false); + const [showHistoryDialog] = useState(false); const [, setCommandHistory] = useState([]); const [, setIsLoadingHistory] = useState(false); @@ -416,9 +449,37 @@ const TerminalInner = forwardRef( autocompleteSelectedIndexRef.current = autocompleteSelectedIndex; }, [autocompleteSelectedIndex]); + useEffect(() => { + showSearchRef.current = showSearch; + }, [showSearch]); + + useEffect(() => { + searchQueryRef.current = searchQuery; + }, [searchQuery]); + + useEffect(() => { + searchCaseSensitiveRef.current = searchCaseSensitive; + }, [searchCaseSensitive]); + + useEffect(() => { + searchWholeWordRef.current = searchWholeWord; + }, [searchWholeWord]); + + useEffect(() => { + searchRegexRef.current = searchRegex; + }, [searchRegex]); + + useEffect(() => { + if (showSearch) { + searchInputRef.current?.focus(); + searchInputRef.current?.select(); + } + }, [showSearch]); + const activityLoggingRef = useRef(false); const passwordPromptShownRef = useRef(false); const alternateScreenModeRef = useRef(false); + const controlStringModeRef = useRef(false); const lastSentSizeRef = useRef<{ cols: number; rows: number } | null>(null); const pendingSizeRef = useRef<{ cols: number; rows: number } | null>(null); @@ -530,6 +591,85 @@ const TerminalInner = forwardRef( hardRefresh(); } + function getSearchOptions() { + return { + caseSensitive: searchCaseSensitiveRef.current, + wholeWord: searchWholeWordRef.current, + regex: searchRegexRef.current, + incremental: true, + decorations: { + matchBackground: `${themeColors.yellow}55`, + matchBorder: themeColors.yellow, + matchOverviewRuler: themeColors.yellow, + activeMatchBackground: `${themeColors.foreground}33`, + activeMatchBorder: themeColors.foreground, + activeMatchColorOverviewRuler: themeColors.foreground, + }, + }; + } + + function runSearch(direction: "next" | "previous", term?: string) { + const searchAddon = searchAddonRef.current; + const query = term ?? searchQueryRef.current; + if (!searchAddon || !query) return; + + if (direction === "next") { + searchAddon.findNext(query, getSearchOptions()); + } else { + searchAddon.findPrevious(query, { + ...getSearchOptions(), + incremental: false, + }); + } + } + + function openSearch() { + setShowSearch(true); + if (searchQueryRef.current) { + runSearch("next", searchQueryRef.current); + } + } + + function closeSearch() { + searchAddonRef.current?.clearDecorations(); + setShowSearch(false); + setSearchResultIndex(-1); + setSearchResultCount(0); + setTimeout(() => terminal?.focus(), 0); + } + + function handleSearchQueryChange(value: string) { + setSearchQuery(value); + searchQueryRef.current = value; + + if (!value) { + searchAddonRef.current?.clearDecorations(); + setSearchResultIndex(-1); + setSearchResultCount(0); + return; + } + + runSearch("next", value); + } + + function toggleSearchCaseSensitive() { + searchCaseSensitiveRef.current = !searchCaseSensitiveRef.current; + setSearchCaseSensitive(searchCaseSensitiveRef.current); + runSearch("next"); + } + + function toggleSearchWholeWord() { + searchWholeWordRef.current = !searchWholeWordRef.current; + setSearchWholeWord(searchWholeWordRef.current); + runSearch("next"); + } + + function toggleSearchRegex() { + searchRegexRef.current = !searchRegexRef.current; + setSearchRegex(searchRegexRef.current); + runSearch("next"); + } + function handleTotpSubmit(code: string) { const isPushMode = mfaPromptMode === "push"; if (webSocketRef.current && (code || isPushMode)) { @@ -689,12 +829,22 @@ const TerminalInner = forwardRef( ); alternateScreenModeRef.current = alternateScreen.isActive; + // Must run for every chunk, including ones we go on to skip, or the + // control-string state stops tracking the stream. + const controlString = updateControlStringMode( + output, + controlStringModeRef.current, + ); + controlStringModeRef.current = controlString.isActive; + const syntaxHighlightingEnabled = hostConfig.terminalConfig?.syntaxHighlighting !== false; if ( !syntaxHighlightingEnabled || alternateScreen.sawSequence || - alternateScreen.isActive + alternateScreen.isActive || + controlString.wasActive || + controlString.isActive ) { return output; } @@ -1004,10 +1154,7 @@ const TerminalInner = forwardRef( const origin = await resolveConnectionOrigin({ connectionType: "ssh", connectionOrigin: hostConfig.connectionOrigin as - | "local" - | "remote" - | null - | undefined, + "local" | "remote" | null | undefined, }); const resolvedUrl = await buildOriginWsUrl({ origin, @@ -1061,11 +1208,13 @@ const TerminalInner = forwardRef( ) { ws.addEventListener("open", () => { alternateScreenModeRef.current = false; + controlStringModeRef.current = false; connectionTimeoutRef.current = setTimeout(() => { if ( !isConnected && !totpRequired && !isPasswordPrompt && + !tailscaleCheckPendingRef.current && !connectionErrorRef.current ) { if (terminal) { @@ -1627,6 +1776,42 @@ const TerminalInner = forwardRef( stage: "error", error: msg.instructions || msg.error, }); + } else if (msg.type === "tailscale_check_required") { + if (connectionErrorRef.current) return; + tailscaleCheckPendingRef.current = true; + + // Tailscale holds the connection open while the user authenticates, + // so the normal connect timeout must not fire during the wait. + if (connectionTimeoutRef.current) { + clearTimeout(connectionTimeoutRef.current); + connectionTimeoutRef.current = null; + } + + setTailscaleCheckDialog({ + isOpen: true, + authUrl: msg.url || "", + message: msg.message, + stage: "prompt", + }); + + if (tailscaleCheckTimeoutRef.current) { + clearTimeout(tailscaleCheckTimeoutRef.current); + } + tailscaleCheckTimeoutRef.current = setTimeout(() => { + tailscaleCheckPendingRef.current = false; + setTailscaleCheckDialog(null); + updateConnectionError(t("terminal.tailscaleCheckTimeout")); + if (webSocketRef.current) { + webSocketRef.current.close(); + } + }, 1800000); + } else if (msg.type === "tailscale_check_completed") { + tailscaleCheckPendingRef.current = false; + if (tailscaleCheckTimeoutRef.current) { + clearTimeout(tailscaleCheckTimeoutRef.current); + tailscaleCheckTimeoutRef.current = null; + } + setTailscaleCheckDialog(null); } else if (msg.type === "keyboard_interactive_available") { setKeyboardInteractiveDetected(true); setIsConnecting(false); @@ -1821,6 +2006,13 @@ const TerminalInner = forwardRef( totpTimeoutRef.current = null; } + tailscaleCheckPendingRef.current = false; + if (tailscaleCheckTimeoutRef.current) { + clearTimeout(tailscaleCheckTimeoutRef.current); + tailscaleCheckTimeoutRef.current = null; + } + setTailscaleCheckDialog(null); + if (wasSessionExpiredRef.current) { wasSessionExpiredRef.current = false; const cols = terminal?.cols || 80; @@ -2053,10 +2245,7 @@ const TerminalInner = forwardRef( terminal.options.letterSpacing = config.letterSpacing; terminal.options.lineHeight = config.lineHeight; terminal.options.bellStyle = config.bellStyle as - | "none" - | "sound" - | "visual" - | "both"; + "none" | "sound" | "visual" | "both"; terminal.options.theme = { background: config.backgroundImage @@ -2165,6 +2354,7 @@ const TerminalInner = forwardRef( const clipboardProvider = new RobustClipboardProvider(); const clipboardAddon = new ClipboardAddon(undefined, clipboardProvider); const unicode11Addon = new Unicode11Addon(); + const searchAddon = new SearchAddon(); const webLinksAddon = new WebLinksAddon((_event, uri) => { const url = uri.startsWith("http://") || uri.startsWith("https://") @@ -2184,10 +2374,17 @@ const TerminalInner = forwardRef( }); fitAddonRef.current = fitAddon; + searchAddonRef.current = searchAddon; terminal.loadAddon(fitAddon); terminal.loadAddon(clipboardAddon); terminal.loadAddon(unicode11Addon); terminal.loadAddon(webLinksAddon); + terminal.loadAddon(searchAddon); + + searchAddon.onDidChangeResults(({ resultIndex, resultCount }) => { + setSearchResultIndex(resultIndex); + setSearchResultCount(resultCount); + }); terminal.unicode.activeVersion = "11"; @@ -2328,7 +2525,7 @@ const TerminalInner = forwardRef( // the capture phase blocks that traversal while still allowing the event to // reach xterm.js's internal handler (which fires our attachCustomKeyEventHandler). const handleTabCapture = (e: KeyboardEvent) => { - if (e.key === "Tab") { + if (isTabKeyEvent(e)) { e.preventDefault(); } }; @@ -2478,6 +2675,50 @@ const TerminalInner = forwardRef( } } + if ( + showSearchRef.current && + e.key === "Escape" && + !e.ctrlKey && + !e.altKey && + !e.metaKey && + !e.shiftKey + ) { + e.preventDefault(); + e.stopPropagation(); + closeSearch(); + return false; + } + + if ( + (e.ctrlKey || e.metaKey) && + !e.altKey && + !e.shiftKey && + e.key.toLowerCase() === "f" + ) { + e.preventDefault(); + e.stopPropagation(); + openSearch(); + return false; + } + + if (navigator.userAgent.includes("Android")) { + const sequence = getAndroidHardwareKeySequence( + e, + terminal.modes.applicationCursorKeysMode, + hostConfig.terminalConfig?.backspaceMode, + ); + if (sequence) { + e.preventDefault(); + e.stopPropagation(); + if (webSocketRef.current?.readyState === WebSocket.OPEN) { + webSocketRef.current.send( + JSON.stringify({ type: "input", data: sequence }), + ); + } + return false; + } + } + // Forward global app shortcuts to AppShell directly — xterm swallows // all keydown events and synthetic re-dispatch is unreliable. // stopPropagation prevents the same event from also firing the window listener. @@ -2678,7 +2919,7 @@ const TerminalInner = forwardRef( } if ( - e.key === "Tab" && + isTabKeyEvent(e) && !e.ctrlKey && !e.altKey && !e.metaKey && @@ -2701,7 +2942,7 @@ const TerminalInner = forwardRef( } if ( - e.key === "Tab" && + isTabKeyEvent(e) && e.shiftKey && !e.ctrlKey && !e.altKey && @@ -2718,7 +2959,7 @@ const TerminalInner = forwardRef( } if ( - e.key === "Tab" && + isTabKeyEvent(e) && !e.ctrlKey && !e.altKey && !e.metaKey && @@ -3110,6 +3351,33 @@ const TerminalInner = forwardRef( /> )} + {tailscaleCheckDialog?.isOpen && ( + { + tailscaleCheckPendingRef.current = false; + if (tailscaleCheckTimeoutRef.current) { + clearTimeout(tailscaleCheckTimeoutRef.current); + tailscaleCheckTimeoutRef.current = null; + } + setTailscaleCheckDialog(null); + if (webSocketRef.current) { + webSocketRef.current.close(); + } + }} + onOpenUrl={() => { + window.open(tailscaleCheckDialog.authUrl, "_blank"); + setTailscaleCheckDialog((prev) => + prev ? { ...prev, stage: "waiting" } : null, + ); + }} + backgroundColor={backgroundColor} + /> + )} + {vaultDialog && (
@@ -3248,6 +3516,24 @@ const TerminalInner = forwardRef( onSelect={handleAutocompleteSelect} /> + runSearch("next")} + onFindPrevious={() => runSearch("previous")} + onClose={closeSearch} + caseSensitive={searchCaseSensitive} + onToggleCaseSensitive={toggleSearchCaseSensitive} + wholeWord={searchWholeWord} + onToggleWholeWord={toggleSearchWholeWord} + regex={searchRegex} + onToggleRegex={toggleSearchRegex} + resultIndex={searchResultIndex} + resultCount={searchResultCount} + inputRef={searchInputRef} + /> + {linkClickDialog && createPortal(
= + { + ArrowUp: ["\x1b[A", "\x1bOA"], + ArrowDown: ["\x1b[B", "\x1bOB"], + ArrowRight: ["\x1b[C", "\x1bOC"], + ArrowLeft: ["\x1b[D", "\x1bOD"], + }; + +export function getAndroidHardwareKeySequence( + event: Pick< + KeyboardEvent, + "key" | "ctrlKey" | "altKey" | "metaKey" | "shiftKey" + >, + applicationCursorKeys: boolean, + backspaceMode: HostBackspaceMode | undefined, +): string | null { + if (event.ctrlKey || event.altKey || event.metaKey || event.shiftKey) { + return null; + } + + const cursor = CURSOR_SEQUENCES[event.key]; + if (cursor) return cursor[applicationCursorKeys ? 1 : 0]; + if (event.key === "Delete") return "\x1b[3~"; + if (event.key === "Backspace" && backspaceMode !== "control-h") { + return "\x7f"; + } + return null; +} diff --git a/src/ui/features/terminal/command-history/CommandAutocomplete.tsx b/src/ui/features/terminal/command-history/CommandAutocomplete.tsx index 5867a046..8284cc13 100644 --- a/src/ui/features/terminal/command-history/CommandAutocomplete.tsx +++ b/src/ui/features/terminal/command-history/CommandAutocomplete.tsx @@ -38,7 +38,7 @@ export function CommandAutocomplete({ return (
onSelect(suggestion)} @@ -65,7 +65,7 @@ export function CommandAutocomplete({
))}
-
+
Tab/Enter to complete • ↑↓ to navigate • Esc to close
diff --git a/src/ui/features/terminal/search/TerminalSearchBar.tsx b/src/ui/features/terminal/search/TerminalSearchBar.tsx new file mode 100644 index 00000000..c30f3158 --- /dev/null +++ b/src/ui/features/terminal/search/TerminalSearchBar.tsx @@ -0,0 +1,249 @@ +import React from "react"; +import { ChevronDown, ChevronUp, X } from "lucide-react"; +import { useTranslation } from "react-i18next"; +import { Input } from "@/components/input"; +import { Button } from "@/components/button"; +import { Separator } from "@/components/separator"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@/components/tooltip"; +import { cn } from "@/lib/utils.ts"; + +interface TerminalSearchBarProps { + visible: boolean; + query: string; + onQueryChange: (query: string) => void; + onFindNext: () => void; + onFindPrevious: () => void; + onClose: () => void; + caseSensitive: boolean; + onToggleCaseSensitive: () => void; + wholeWord: boolean; + onToggleWholeWord: () => void; + regex: boolean; + onToggleRegex: () => void; + resultIndex: number; + resultCount: number; + inputRef: React.RefObject; +} + +interface SearchToggleProps { + label: string; + active: boolean; + onClick: () => void; + className?: string; + children: React.ReactNode; +} + +function SearchToggle({ + label, + active, + onClick, + className, + children, +}: SearchToggleProps) { + return ( + + + + + + {label} + + + ); +} + +export function TerminalSearchBar({ + visible, + query, + onQueryChange, + onFindNext, + onFindPrevious, + onClose, + caseSensitive, + onToggleCaseSensitive, + wholeWord, + onToggleWholeWord, + regex, + onToggleRegex, + resultIndex, + resultCount, + inputRef, +}: TerminalSearchBarProps) { + const { t } = useTranslation(); + + if (!visible) { + return null; + } + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === "Enter") { + e.preventDefault(); + if (e.shiftKey) { + onFindPrevious(); + } else { + onFindNext(); + } + return; + } + + if (e.key === "Escape") { + e.preventDefault(); + onClose(); + return; + } + + // Stop keys like Ctrl+C/Ctrl+V from bubbling up to xterm's document-level + // paste/clipboard handling while the search input is focused. + e.stopPropagation(); + }; + + const hasQuery = query.length > 0; + const noResults = hasQuery && resultCount === 0; + const resultLabel = !hasQuery + ? "" + : resultCount === 0 + ? t("terminal.searchNoResults") + : t("terminal.searchResultCount", { + index: resultIndex + 1, + count: resultCount, + }); + + return ( + +
e.stopPropagation()} + > +
+ onQueryChange(e.target.value)} + onKeyDown={handleKeyDown} + placeholder={t("terminal.searchPlaceholder")} + aria-label={t("terminal.searchPlaceholder")} + className={cn( + "h-7 w-48 pr-16 font-mono", + noResults && + "border-destructive focus-visible:border-destructive focus-visible:ring-destructive/20", + )} + autoComplete="off" + autoCorrect="off" + autoCapitalize="none" + spellCheck={false} + /> + + {resultLabel} + +
+ + + +
+ + Aa + + + ab + + + .* + +
+ + + +
+ + + + + + {t("terminal.searchPrevious")} + + + + + + + + {t("terminal.searchNext")} + + + + + + + + {t("terminal.searchClose")} + + +
+
+
+ ); +} diff --git a/src/ui/features/terminal/terminal-key-event.ts b/src/ui/features/terminal/terminal-key-event.ts new file mode 100644 index 00000000..4009ffe9 --- /dev/null +++ b/src/ui/features/terminal/terminal-key-event.ts @@ -0,0 +1,3 @@ +export function isTabKeyEvent(event: KeyboardEvent): boolean { + return event.key === "Tab" || event.code === "Tab" || event.keyCode === 9; +} diff --git a/src/ui/i18n/i18n.ts b/src/ui/i18n/i18n.ts index 22686032..b360e455 100644 --- a/src/ui/i18n/i18n.ts +++ b/src/ui/i18n/i18n.ts @@ -44,6 +44,7 @@ const localeLoaders = { } satisfies Record Promise>; export const supportedLngs = ["en", ...Object.keys(localeLoaders)]; +const PENDING_LOGIN_LANGUAGE_KEY = "termix-pending-login-language"; export function normalizeLanguageCode(language?: string | null): string { if (!language) return "en"; @@ -123,4 +124,16 @@ export async function changeAppLanguage(language: string): Promise { return normalizedLanguage; } +export function rememberLoginLanguage(language: string): string { + const normalizedLanguage = normalizeLanguageCode(language); + sessionStorage.setItem(PENDING_LOGIN_LANGUAGE_KEY, normalizedLanguage); + return normalizedLanguage; +} + +export function consumeLoginLanguage(): string | null { + const language = sessionStorage.getItem(PENDING_LOGIN_LANGUAGE_KEY); + sessionStorage.removeItem(PENDING_LOGIN_LANGUAGE_KEY); + return language ? normalizeLanguageCode(language) : null; +} + export default i18n; diff --git a/src/ui/lib/database-transfer-url.ts b/src/ui/lib/database-transfer-url.ts index e0f3c5fd..c7394bd3 100644 --- a/src/ui/lib/database-transfer-url.ts +++ b/src/ui/lib/database-transfer-url.ts @@ -20,10 +20,7 @@ export function getDatabaseTransferUrl( return `${serverUrl.replace(/\/$/, "")}/database/${operation}`; } - const development = - location.port === "5173" || - location.hostname === "localhost" || - location.hostname === "127.0.0.1"; + const development = location.port === "5173"; if (development) { return `http://localhost:30001/database/${operation}`; diff --git a/src/ui/lib/terminal-syntax-highlighter.ts b/src/ui/lib/terminal-syntax-highlighter.ts index 3799e59f..96fcb85a 100644 --- a/src/ui/lib/terminal-syntax-highlighter.ts +++ b/src/ui/lib/terminal-syntax-highlighter.ts @@ -221,6 +221,55 @@ function hasIncompleteAnsiSequence(text: string): boolean { return /\x1b\[[0-9;?>=!]*$/.test(text); } +/** + * Tracks whether the stream is inside a control string (OSC/DCS/APC/PM) across + * chunk boundaries. + * + * A control string carries text that must never reach the screen — an OSC 0 + * title, for instance, contains the user, host and path. Its opening `ESC ]` + * and its terminator often land in different websocket frames, and the + * continuation frame contains no escape byte at all, so every single-chunk + * guard here misses it. Highlighting that continuation injects an SGR sequence + * into the middle of the string, which aborts it early in xterm.js and dumps + * the rest of the payload on screen as ordinary text. + * + * A trailing lone ESC counts as active for the same reason: its intent is only + * knowable from the next chunk. + */ +export function updateControlStringMode( + output: string, + currentMode: boolean, +): { isActive: boolean; wasActive: boolean } { + const wasActive = currentMode; + let isActive = currentMode; + + for (let i = 0; i < output.length; i++) { + const char = output[i]; + + if (isActive) { + if (char === "\x07") { + isActive = false; + } else if (char === "\x1b") { + // ST (ESC \) closes it; any other ESC aborts it. + isActive = false; + if (output[i + 1] === "\\") i++; + } + continue; + } + + if (char !== "\x1b") continue; + + const next = output[i + 1]; + if (next === undefined) return { isActive: true, wasActive }; + if (next === "]" || next === "P" || next === "^" || next === "_") { + isActive = true; + i++; + } + } + + return { isActive, wasActive }; +} + function parseAnsiSegments(text: string): TextSegment[] { const segments: TextSegment[] = []; ANSI_REGEX.lastIndex = 0; diff --git a/src/ui/locales/en.json b/src/ui/locales/en.json index 8fc2d478..150eb037 100644 --- a/src/ui/locales/en.json +++ b/src/ui/locales/en.json @@ -546,6 +546,7 @@ "sshTools": "SSH Tools", "history": "History", "sessionLogs": "Session Logs", + "sidebarSettings": "Sidebar Settings...", "hosts": "Hosts", "snippets": "Snippets", "hostManager": "Host Manager", @@ -755,6 +756,8 @@ "agentSocketPathLabel": "Agent Socket Path", "agentSocketPathPlaceholder": "Leave empty to use SSH_AUTH_SOCK", "agentSocketPathHint": "Leave empty to auto-detect from the SSH_AUTH_SOCK environment variable, or enter a custom socket path (e.g. /run/user/1000/gnupg/S.gpg-agent.ssh).", + "shareSshAuthLabel": "Share SSH Authentication", + "shareSshAuthDesc": "Give recipients encrypted copies of this host's SSH authentication. A recipient's personal credential still takes precedence.", "tailscaleDeviceSelect": "Select Tailscale device", "tailscaleDeviceSelectPlaceholder": "Select a device...", "tailscaleNoApiKey": "No Tailscale API key configured. Add one in Admin Settings to enable device discovery.", @@ -1173,15 +1176,39 @@ "generateFromPrivateKey": "Generate from Private Key", "refreshBtn2": "Refresh", "exitSelectionTitle": "Exit selection", - "exportAll": "Export All", - "exportForSharing": "Export for Sharing", "addHostBtn2": "Add Host", "addCredentialBtn2": "Add Credential", "checkingHostStatuses": "Checking host statuses...", "pinnedSection": "Pinned", "hostsExported": "Hosts exported successfully", - "hostsShareExported": "Shareable hosts exported successfully", - "exportFailed": "Failed to export hosts", + "export": { + "menuItem": "Export...", + "title": "Export hosts", + "scope": "Scope", + "scopeAll": "All", + "scopeSelected": "Selected", + "searchHosts": "Search hosts...", + "include": "Include", + "groupConnection": "Connection", + "groupCredentials": "Credentials", + "groupNotes": "Notes", + "groupTags": "Tags & pin", + "groupTunnels": "Tunnels", + "groupJumpHosts": "Jump hosts", + "groupQuickActions": "Quick actions", + "groupFeatureFlags": "Feature flags", + "groupAdvanced": "Advanced config", + "preview": "Preview", + "moreHosts": "... {{count}} more hosts", + "summary": "{{selected}} of {{total}} hosts", + "credentialsIncluded": "credentials included", + "credentialsExcluded": "credentials excluded", + "noneSelected": "No hosts selected", + "cancel": "Cancel", + "confirm": "Export", + "fetchFailed": "Failed to load hosts for export", + "bulkButton": "Export" + }, "sampleDownloaded": "Sample file downloaded", "failedToDeleteCredential2": "Failed to delete credential", "noFolderOption": "(No folder)", @@ -1266,7 +1293,7 @@ }, "edit": { "label": "Edit", - "description": "View, plus modify the host. Secrets can be replaced but never read; credential assignments stay owner-only." + "description": "View, plus modify non-authentication host settings. The owner's SSH authentication stays private and owner-only." }, "manage": { "label": "Manage", @@ -1299,7 +1326,24 @@ "sharedBadgeTooltip": "Shared by {{owner}} ({{level}} access)", "viewOnlyBanner": "This host is shared with you by {{owner}} with view access. The configuration is read-only.", "sharedEditBanner": "This host is shared with you by {{owner}} with edit access. Changes apply to the real host; authentication references can only be changed by the owner.", - "ownerOnlyControl": "Only the host owner can change this" + "ownerOnlyControl": "Only the host owner can change this", + "ownerAuthPrivate": "The host owner's SSH authentication is private. Use “Set personal SSH authentication” from the host menu to choose your own credential.", + "ownerAuthShared": "The host owner has shared SSH authentication for this host. You can use it or choose your own credential from “Set personal SSH authentication.”", + "authOverrideAction": "Set personal SSH authentication", + "authOverrideTitle": "Personal SSH authentication", + "authOverrideDescriptionPrivate": "The host owner's SSH credentials stay private. Choose one of your saved credentials for connections to {{host}}.", + "authOverrideDescriptionShared": "Use the authentication shared by the host owner, or replace it with one of your saved credentials for connections to {{host}}.", + "authOverrideCredentialLabel": "Authentication credential", + "useSharedAuthentication": "Use shared host authentication", + "noPersonalCredential": "No personal credential", + "authOverrideNoCredentials": "You do not have any saved SSH credentials yet. Create one in Credentials to connect to hosts that require authentication.", + "authOverrideRequired": "This host requires one of your saved credentials before you can connect.", + "authOverridePrivateHint": "This credential is private to you. The host owner and other recipients cannot see or use it.", + "authOverrideSaved": "Personal SSH authentication saved", + "authOverrideCleared": "Personal SSH authentication removed", + "authOverrideClearedToShared": "Using shared host authentication", + "authOverrideLoadError": "Failed to load your SSH authentication. Please try again.", + "authOverrideSaveError": "Failed to save your SSH authentication" }, "guac": { "connection": "Connection", @@ -1627,6 +1671,15 @@ "tmuxCopyHint": "Adjust selection and press Enter to copy to clipboard", "tmuxDetach": "Detach from tmux session", "tmuxDetached": "Detached from tmux session", + "searchPlaceholder": "Find", + "searchCaseSensitive": "Match Case", + "searchWholeWord": "Match Whole Word", + "searchRegex": "Use Regular Expression", + "searchNoResults": "No results", + "searchResultCount": "{{index}} of {{count}}", + "searchNext": "Next Match (Enter)", + "searchPrevious": "Previous Match (Shift+Enter)", + "searchClose": "Close (Escape)", "maxReconnectAttemptsReached": "Maximum reconnection attempts reached", "closeTab": "Close", "connectionTimeout": "Connection timeout", @@ -1654,6 +1707,11 @@ "opksshTimeout": "Authentication timed out. Please try again.", "opksshAuthFailed": "Authentication failed. Please check your credentials and try again.", "opksshSignInWith": "Sign in with {{provider}}", + "tailscaleCheckRequired": "Tailscale Authentication Required", + "tailscaleCheckDescription": "Tailscale SSH requires an additional check. Authenticate in your browser to continue.", + "tailscaleCheckOpenBrowser": "Open Browser to Authenticate", + "tailscaleCheckWaiting": "Waiting for Tailscale authentication...", + "tailscaleCheckTimeout": "Tailscale authentication timed out. Please try again.", "vaultAuthTitle": "Vault sign-in required", "vaultAuthDescription": "A window has opened to sign in to HashiCorp Vault. Complete the sign-in there; this connection will continue automatically.", "vaultAuthFailed": "Vault authentication failed. Please try again.", @@ -2145,6 +2203,7 @@ "cpuUsage": "CPU Usage", "memoryUsage": "Memory Usage", "diskUsage": "Disk Usage", + "selectFilesystem": "Select filesystem", "temperature": "Temperature", "highestTemperature": "Highest temperature", "failedToFetchHostConfig": "Failed to fetch host configuration", @@ -2819,6 +2878,7 @@ "updateCommandHistoryFailed": "Failed to update command history setting", "analyticsEnabled": "Share Anonymous Usage Statistics", "analyticsEnabledDesc": "Sends an anonymous daily count of users, hosts, and feature usage to help improve Termix. No personal data or connection details are ever included.", + "analyticsEnabledLockedDesc": "This setting is locked by the ENABLE_TELEMETRY environment variable and cannot be changed here.", "updateAnalyticsFailed": "Failed to update analytics setting", "sessionSharingGloballyEnabled": "Allow Session Sharing", "sessionSharingGloballyEnabledDesc": "Allow live terminal, RDP, VNC, and Telnet sessions to be shared instance-wide. Overrides every per-host sharing toggle when disabled.", @@ -3323,6 +3383,8 @@ "resetToDefaultsSuccess": "Settings reset to defaults.", "storageModeSwitch": "Preference Storage", "sectionAccount": "Account", + "desktopProfileTitle": "Automatic local desktop profile", + "desktopProfileDescription": "This profile is restricted to the embedded backend and signs in automatically. It has no login password; Remote Sync below uses a separate server account.", "sectionAppearance": "Appearance", "sectionSecurity": "Security", "sectionApiKeys": "API Keys", @@ -3379,6 +3441,8 @@ "statusColorsDesc": "Use green/red for online/offline status instead of the accent color", "pinAppRail": "Pin App Rail", "pinAppRailDesc": "Keep the left sidebar app rail always expanded instead of expanding on hover", + "openFullscreenSettings": "Open settings full screen", + "exitFullscreenSettings": "Exit full-screen settings", "expandAppRailOnHover": "Expand App Rail on Hover", "expandAppRailOnHoverDesc": "Allow the left sidebar app rail to expand when the pointer moves over it", "settingsNavigation": "Navigation", diff --git a/src/ui/locales/translated/af_ZA.json b/src/ui/locales/translated/af_ZA.json index 42363f70..7ac38473 100644 --- a/src/ui/locales/translated/af_ZA.json +++ b/src/ui/locales/translated/af_ZA.json @@ -546,6 +546,7 @@ "sshTools": "SSH-gereedskap", "history": "Geskiedenis", "sessionLogs": "Sessielogboeke", + "sidebarSettings": "Sybalkinstellings...", "hosts": "Gashere", "snippets": "Brokkies", "hostManager": "Gasheerbestuurder", @@ -755,6 +756,8 @@ "agentSocketPathLabel": "Agent-sokpad", "agentSocketPathPlaceholder": "Los leeg om SSH_AUTH_SOCK te gebruik", "agentSocketPathHint": "Los leeg om outomaties op te spoor vanaf die SSH_AUTH_SOCK omgewing veranderlike, of voer 'n persoonlike sokpad in (bv. /run/user/1000/gnupg/S.gpg-agent.ssh).", + "shareSshAuthLabel": "Deel SSH-verifikasie", + "shareSshAuthDesc": "Gee ontvangers geïnkripteerde kopieë van hierdie gasheer se SSH-verifikasie. 'n Ontvanger se persoonlike geloofsbriewe geniet steeds voorrang.", "tailscaleDeviceSelect": "Kies Tailscale-toestel", "tailscaleDeviceSelectPlaceholder": "Kies 'n toestel...", "tailscaleNoApiKey": "Geen Tailscale API-sleutel gekonfigureer nie. Voeg een by in Admin-instellings om toestelontdekking te aktiveer.", @@ -1173,15 +1176,39 @@ "generateFromPrivateKey": "Genereer vanaf Privaat Sleutel", "refreshBtn2": "Verfris", "exitSelectionTitle": "Verlaat seleksie", - "exportAll": "Voer alles uit", - "exportForSharing": "Uitvoer vir deling", "addHostBtn2": "Voeg gasheer by", "addCredentialBtn2": "Voeg geloofsbriewe by", "checkingHostStatuses": "Kontroleer gasheerstatusse...", "pinnedSection": "Vasgepen", "hostsExported": "Gashere suksesvol uitgevoer", - "hostsShareExported": "Deelbare gashere is suksesvol uitgevoer", - "exportFailed": "Kon nie gashere uitvoer nie", + "export": { + "menuItem": "Uitvoer...", + "title": "Uitvoer gashere", + "scope": "Omvang", + "scopeAll": "Alles", + "scopeSelected": "Geselekteerde", + "searchHosts": "Soek gashere...", + "include": "Sluit in", + "groupConnection": "Verbinding", + "groupCredentials": "Geloofsbriewe", + "groupNotes": "Notas", + "groupTags": "Etikette en pen", + "groupTunnels": "Tonnels", + "groupJumpHosts": "Spring-gashere", + "groupQuickActions": "Vinnige aksies", + "groupFeatureFlags": "Kenmerkvlae", + "groupAdvanced": "Gevorderde konfigurasie", + "preview": "Voorskou", + "moreHosts": "... {{count}} meer gashere", + "summary": "{{selected}} van {{total}} gashere", + "credentialsIncluded": "geloofsbriewe ingesluit", + "credentialsExcluded": "geloofsbriewe uitgesluit", + "noneSelected": "Geen gashere gekies nie", + "cancel": "Kanselleer", + "confirm": "Uitvoer", + "fetchFailed": "Kon nie gashere vir uitvoer laai nie", + "bulkButton": "Uitvoer" + }, "sampleDownloaded": "Voorbeeldlêer afgelaai", "failedToDeleteCredential2": "Kon nie geloofsbriewe verwyder nie", "noFolderOption": "(Geen vouer nie)", @@ -1266,7 +1293,7 @@ }, "edit": { "label": "Wysig", - "description": "Bekyk, plus wysig die gasheer. Geheime kan vervang word, maar nooit gelees word nie; geloofsbriewe-toewysings bly slegs vir die eienaar." + "description": "Bekyk, plus wysig nie-verifikasie gasheerinstellings. Die eienaar se SSH-verifikasie bly privaat en slegs vir die eienaar." }, "manage": { "label": "Bestuur", @@ -1299,7 +1326,24 @@ "sharedBadgeTooltip": "Gedeel deur {{owner}} ({{level}} toegang)", "viewOnlyBanner": "Hierdie gasheer word met jou gedeel deur {{owner}} met kyktoegang. Die konfigurasie is slegs leesbaar.", "sharedEditBanner": "Hierdie gasheer word met jou gedeel deur {{owner}} met wysigingstoegang. Veranderinge is van toepassing op die werklike gasheer; verifikasieverwysings kan slegs deur die eienaar verander word.", - "ownerOnlyControl": "Slegs die gasheer-eienaar kan dit verander" + "ownerOnlyControl": "Slegs die gasheer-eienaar kan dit verander", + "ownerAuthPrivate": "Die gasheer-eienaar se SSH-verifikasie is privaat. Gebruik \"Stel persoonlike SSH-verifikasie\" vanaf die gasheer-kieslys om jou eie geloofsbriewe te kies.", + "ownerAuthShared": "Die gasheer-eienaar het gedeelde SSH-verifikasie vir hierdie gasheer. Jy kan dit gebruik of jou eie geloofsbriewe kies vanaf \"Stel persoonlike SSH-verifikasie\".", + "authOverrideAction": "Stel persoonlike SSH-verifikasie", + "authOverrideTitle": "Persoonlike SSH-verifikasie", + "authOverrideDescriptionPrivate": "Die gasheer-eienaar se SSH-besonderhede bly privaat. Kies een van jou gestoorde besonderhede vir verbindings met {{host}}.", + "authOverrideDescriptionShared": "Gebruik die verifikasie wat deur die gasheer-eienaar gedeel word, of vervang dit met een van jou gestoorde geloofsbriewe vir verbindings met {{host}}.", + "authOverrideCredentialLabel": "Verifikasiebewys", + "useSharedAuthentication": "Gebruik gedeelde gasheerverifikasie", + "noPersonalCredential": "Geen persoonlike geloofsbriewe nie", + "authOverrideNoCredentials": "Jy het nog geen gestoorde SSH-bewyse nie. Skep een in Bewyse om te koppel aan gashere wat verifikasie vereis.", + "authOverrideRequired": "Hierdie gasheer benodig een van jou gestoorde aanmeldbesonderhede voordat jy kan koppel.", + "authOverridePrivateHint": "Hierdie geloofsbriewe is privaat vir jou. Die gasheer-eienaar en ander ontvangers kan dit nie sien of gebruik nie.", + "authOverrideSaved": "Persoonlike SSH-verifikasie gestoor", + "authOverrideCleared": "Persoonlike SSH-verifikasie is verwyder", + "authOverrideClearedToShared": "Gebruik van gedeelde gasheerverifikasie", + "authOverrideLoadError": "Kon nie jou SSH-verifikasie laai nie. Probeer asseblief weer.", + "authOverrideSaveError": "Kon nie jou SSH-verifikasie stoor nie" }, "guac": { "connection": "Verbinding", @@ -1627,6 +1671,15 @@ "tmuxCopyHint": "Pas seleksie aan en druk Enter om na knipbord te kopieer", "tmuxDetach": "Ontkoppel van tmux-sessie", "tmuxDetached": "Losgemaak van tmux-sessie", + "searchPlaceholder": "Vind", + "searchCaseSensitive": "Pasmaat", + "searchWholeWord": "Pas die hele woord by", + "searchRegex": "Gebruik Gereelde Uitdrukking", + "searchNoResults": "Geen resultate nie", + "searchResultCount": "{{index}} van {{count}}", + "searchNext": "Volgende Wedstryd (Enter)", + "searchPrevious": "Vorige Wedersydse (Shift+Enter)", + "searchClose": "Maak toe (Escape)", "maxReconnectAttemptsReached": "Maksimum herverbindingspogings bereik", "closeTab": "Maak toe", "connectionTimeout": "Verbindingstydverstryking", @@ -1654,6 +1707,11 @@ "opksshTimeout": "Verifikasie het verstryk. Probeer asseblief weer.", "opksshAuthFailed": "Verifikasie het misluk. Kontroleer asseblief jou geloofsbriewe en probeer weer.", "opksshSignInWith": "Teken aan met {{provider}}", + "tailscaleCheckRequired": "Stertskaal-verifikasie vereis", + "tailscaleCheckDescription": "Tailscale SSH vereis 'n bykomende kontrole. Verifieer in jou blaaier om voort te gaan.", + "tailscaleCheckOpenBrowser": "Maak blaaier oop om te verifieer", + "tailscaleCheckWaiting": "Wag vir Tailscale-verifikasie...", + "tailscaleCheckTimeout": "Tailscale-verifikasie het verstryk. Probeer asseblief weer.", "vaultAuthTitle": "Kluisaanmelding vereis", "vaultAuthDescription": "'n Venster het oopgemaak om by HashiCorp Vault aan te meld. Voltooi die aanmelding daar; hierdie verbinding sal outomaties voortgaan.", "vaultAuthFailed": "Kluisverifikasie het misluk. Probeer asseblief weer.", @@ -2145,6 +2203,7 @@ "cpuUsage": "SVE-gebruik", "memoryUsage": "Geheuegebruik", "diskUsage": "Skyfgebruik", + "selectFilesystem": "Kies lêerstelsel", "temperature": "Temperatuur", "highestTemperature": "Hoogste temperatuur", "failedToFetchHostConfig": "Kon nie gasheerkonfigurasie haal nie", @@ -2819,6 +2878,7 @@ "updateCommandHistoryFailed": "Kon nie die instelling vir opdraggeskiedenis opdateer nie", "analyticsEnabled": "Deel Anonieme Gebruiksstatistieke", "analyticsEnabledDesc": "Stuur 'n anonieme daaglikse telling van gebruikers, gashere en funksiegebruik om Termix te help verbeter. Geen persoonlike data of verbindingsbesonderhede word ooit ingesluit nie.", + "analyticsEnabledLockedDesc": "Hierdie instelling word gesluit deur die ENABLE_TELEMETRY omgewingveranderlike en kan nie hier verander word nie.", "updateAnalyticsFailed": "Kon nie analitiese instelling opdateer nie", "sessionSharingGloballyEnabled": "Laat Sessiedeling Toe", "sessionSharingGloballyEnabledDesc": "Laat lewendige terminaal-, RDP-, VNC- en Telnet-sessies toe om instansiewyd gedeel te word. Oorskryf elke deelskakelaar per gasheer wanneer dit gedeaktiveer is.", @@ -3323,6 +3383,8 @@ "resetToDefaultsSuccess": "Instellings word teruggestel na verstekwaardes.", "storageModeSwitch": "Voorkeurberging", "sectionAccount": "Rekening", + "desktopProfileTitle": "Outomatiese plaaslike lessenaarprofiel", + "desktopProfileDescription": "Hierdie profiel is beperk tot die ingebedde backend en meld outomaties aan. Dit het geen aanmeldwagwoord nie; Afstandsinkronisering hieronder gebruik 'n aparte bedienerrekening.", "sectionAppearance": "Voorkoms", "sectionSecurity": "Sekuriteit", "sectionApiKeys": "API-sleutels", @@ -3379,6 +3441,8 @@ "statusColorsDesc": "Gebruik groen/rooi vir aanlyn/aflyn status in plaas van die aksentkleur", "pinAppRail": "Speld App-spoor vas", "pinAppRailDesc": "Hou die linkerkantbalk-apprail altyd uitgebrei in plaas daarvan om uit te brei wanneer jy beweeg", + "openFullscreenSettings": "Maak instellings volskerm oop", + "exitFullscreenSettings": "Verlaat volskerminstellings", "expandAppRailOnHover": "Vou programreling uit met sweefbeweging", "expandAppRailOnHoverDesc": "Laat die linkerkantbalk-apprail toe om uit te brei wanneer die wyser daaroor beweeg", "settingsNavigation": "Navigasie", diff --git a/src/ui/locales/translated/ar_SA.json b/src/ui/locales/translated/ar_SA.json index 67d0d554..6da2ddd3 100644 --- a/src/ui/locales/translated/ar_SA.json +++ b/src/ui/locales/translated/ar_SA.json @@ -546,6 +546,7 @@ "sshTools": "أدوات SSH", "history": "السجل", "sessionLogs": "سجلات الجلسات", + "sidebarSettings": "إعدادات الشريط الجانبي...", "hosts": "المضيفون", "snippets": "المقتطفات", "hostManager": "مدير المضيفين", @@ -755,6 +756,8 @@ "agentSocketPathLabel": "مسار مقبس الوكيل", "agentSocketPathPlaceholder": "اتركه فارغًا لاستخدام SSH_AUTH_SOCK", "agentSocketPathHint": "اتركه فارغًا للاكتشاف التلقائي من متغير البيئة SSH_AUTH_SOCK، أو أدخل مسار مقبس مخصص (مثال: /run/user/1000/gnupg/S.gpg-agent.ssh).", + "shareSshAuthLabel": "مشاركة مصادقة SSH", + "shareSshAuthDesc": "قم بتزويد المستلمين بنسخ مشفرة من بيانات مصادقة SSH الخاصة بهذا المضيف. مع ذلك، تظل بيانات اعتماد المستلم الشخصية هي المرجع الأساسي.", "tailscaleDeviceSelect": "اختيار جهاز Tailscale", "tailscaleDeviceSelectPlaceholder": "اختر جهازًا...", "tailscaleNoApiKey": "لم يتم تكوين مفتاح API لـ Tailscale. أضف واحدًا في إعدادات المسؤول لتمكين اكتشاف الأجهزة.", @@ -1173,15 +1176,39 @@ "generateFromPrivateKey": "إنشاء من المفتاح الخاص", "refreshBtn2": "تحديث", "exitSelectionTitle": "الخروج من التحديد", - "exportAll": "تصدير الكل", - "exportForSharing": "تصدير للمشاركة", "addHostBtn2": "إضافة مضيف", "addCredentialBtn2": "إضافة بيانات اعتماد", "checkingHostStatuses": "جارٍ التحقق من حالات المضيفين...", "pinnedSection": "مثبت", "hostsExported": "تم تصدير المضيفين بنجاح", - "hostsShareExported": "تم تصدير المضيفين القابلين للمشاركة بنجاح", - "exportFailed": "فشل تصدير المضيفين", + "export": { + "menuItem": "يصدّر...", + "title": "مضيفات التصدير", + "scope": "نِطَاق", + "scopeAll": "الجميع", + "scopeSelected": "مختار", + "searchHosts": "ابحث عن المضيفين...", + "include": "يشمل", + "groupConnection": "اتصال", + "groupCredentials": "أوراق اعتماد", + "groupNotes": "ملحوظات", + "groupTags": "العلامات والدبابيس", + "groupTunnels": "الأنفاق", + "groupJumpHosts": "مضيفو القفز", + "groupQuickActions": "إجراءات سريعة", + "groupFeatureFlags": "ميزات مميزة", + "groupAdvanced": "الإعدادات المتقدمة", + "preview": "معاينة", + "moreHosts": "... {{count}} المزيد من المضيفين", + "summary": "{{selected}} من {{total}} مضيف", + "credentialsIncluded": "تضمنت المؤهلات", + "credentialsExcluded": "تم استبعاد بيانات الاعتماد", + "noneSelected": "لم يتم تحديد أي مضيفين", + "cancel": "يلغي", + "confirm": "يصدّر", + "fetchFailed": "فشل تحميل المضيفين للتصدير", + "bulkButton": "يصدّر" + }, "sampleDownloaded": "تم تنزيل الملف النموذجي", "failedToDeleteCredential2": "فشل حذف بيانات الاعتماد", "noFolderOption": "(بدون مجلد)", @@ -1266,7 +1293,7 @@ }, "edit": { "label": "تعديل", - "description": "العرض، بالإضافة إلى تعديل المضيف. يمكن استبدال الأسرار ولكن لا يمكن قراءتها أبدًا؛ تظل تعيينات بيانات الاعتماد للمالك فقط." + "description": "يمكنك عرض وتعديل إعدادات المضيف غير المتعلقة بالمصادقة. تبقى مصادقة SSH الخاصة بالمالك سرية ومخصصة له فقط." }, "manage": { "label": "إدارة", @@ -1299,7 +1326,24 @@ "sharedBadgeTooltip": "مُشارَك بواسطة {{owner}} (مستوى وصول {{level}})", "viewOnlyBanner": "تمت مشاركة هذا المضيف معك من قبل {{owner}} بصلاحية العرض فقط. التكوين للقراءة فقط.", "sharedEditBanner": "تمت مشاركة هذا المضيف معك من قبل {{owner}} بصلاحية التعديل. التغييرات تؤثر على المضيف الفعلي؛ لا يمكن تغيير مراجع المصادقة إلا من قبل المالك.", - "ownerOnlyControl": "يمكن لمالك المضيف فقط تغيير هذا" + "ownerOnlyControl": "يمكن لمالك المضيف فقط تغيير هذا", + "ownerAuthPrivate": "بيانات اعتماد SSH الخاصة بمالك المضيف خاصة. استخدم خيار \"تعيين بيانات اعتماد SSH الشخصية\" من قائمة المضيف لاختيار بيانات اعتمادك الخاصة.", + "ownerAuthShared": "قام مالك المضيف بمشاركة بيانات اعتماد SSH لهذا المضيف. يمكنك استخدامها أو اختيار بيانات اعتمادك الخاصة من \"تعيين بيانات اعتماد SSH الشخصية\".", + "authOverrideAction": "قم بتعيين مصادقة SSH الشخصية", + "authOverrideTitle": "مصادقة SSH الشخصية", + "authOverrideDescriptionPrivate": "تبقى بيانات اعتماد SSH الخاصة بمالك المضيف سرية. اختر إحدى بيانات الاعتماد المحفوظة لديك للاتصال بـ {{host}}.", + "authOverrideDescriptionShared": "استخدم بيانات المصادقة التي شاركها مالك المضيف، أو استبدلها بإحدى بيانات الاعتماد المحفوظة لديك للاتصال بـ {{host}}.", + "authOverrideCredentialLabel": "بيانات اعتماد المصادقة", + "useSharedAuthentication": "استخدم مصادقة المضيف المشترك", + "noPersonalCredential": "لا توجد وثائق اعتماد شخصية", + "authOverrideNoCredentials": "ليس لديك أي بيانات اعتماد SSH محفوظة حتى الآن. أنشئ واحدة في قسم بيانات الاعتماد للاتصال بالأجهزة المضيفة التي تتطلب مصادقة.", + "authOverrideRequired": "يتطلب هذا المضيف إحدى بيانات الاعتماد المحفوظة لديك قبل أن تتمكن من الاتصال.", + "authOverridePrivateHint": "هذه البيانات خاصة بك وحدك. لا يمكن لمالك المضيف أو المستلمين الآخرين رؤيتها أو استخدامها.", + "authOverrideSaved": "تم حفظ مصادقة SSH الشخصية", + "authOverrideCleared": "تمت إزالة مصادقة SSH الشخصية", + "authOverrideClearedToShared": "استخدام مصادقة المضيف المشترك", + "authOverrideLoadError": "فشل تحميل بيانات مصادقة SSH الخاصة بك. يرجى المحاولة مرة أخرى.", + "authOverrideSaveError": "فشل حفظ بيانات مصادقة SSH الخاصة بك" }, "guac": { "connection": "اتصال", @@ -1627,6 +1671,15 @@ "tmuxCopyHint": "اضبط التحديد واضغط Enter للنسخ إلى الحافظة", "tmuxDetach": "فصل من جلسة tmux", "tmuxDetached": "تم الفصل من جلسة tmux", + "searchPlaceholder": "يجد", + "searchCaseSensitive": "علبة كبريت", + "searchWholeWord": "تطابق الكلمة الكاملة", + "searchRegex": "استخدم التعبير النمطي", + "searchNoResults": "لا توجد نتائج", + "searchResultCount": "{{index}} من {{count}}", + "searchNext": "المباراة التالية (إدخال)", + "searchPrevious": "المباراة السابقة (Shift+Enter)", + "searchClose": "إغلاق (الهروب)", "maxReconnectAttemptsReached": "تم الوصول إلى الحد الأقصى لمحاولات إعادة الاتصال", "closeTab": "إغلاق", "connectionTimeout": "انتهت مهلة الاتصال", @@ -1654,6 +1707,11 @@ "opksshTimeout": "انتهت مهلة المصادقة. يرجى المحاولة مرة أخرى.", "opksshAuthFailed": "فشلت المصادقة. يرجى التحقق من بيانات الاعتماد والمحاولة مرة أخرى.", "opksshSignInWith": "تسجيل الدخول باستخدام {{provider}}", + "tailscaleCheckRequired": "يلزم التحقق من الهوية على مستوى ذيل الطائرة", + "tailscaleCheckDescription": "يتطلب Tailscale SSH إجراء فحص إضافي. قم بتسجيل الدخول إلى متصفحك للمتابعة.", + "tailscaleCheckOpenBrowser": "افتح المتصفح للمصادقة", + "tailscaleCheckWaiting": "في انتظار مصادقة Tailscale...", + "tailscaleCheckTimeout": "انتهت مهلة مصادقة Tailscale. يرجى المحاولة مرة أخرى.", "vaultAuthTitle": "مطلوب تسجيل الدخول إلى Vault", "vaultAuthDescription": "تم فتح نافذة لتسجيل الدخول إلى HashiCorp Vault. أكمل تسجيل الدخول هناك؛ سيستمر هذا الاتصال تلقائيًا.", "vaultAuthFailed": "فشلت مصادقة Vault. يرجى المحاولة مرة أخرى.", @@ -2145,6 +2203,7 @@ "cpuUsage": "استخدام المعالج", "memoryUsage": "استخدام الذاكرة", "diskUsage": "استخدام القرص", + "selectFilesystem": "حدد نظام الملفات", "temperature": "درجة الحرارة", "highestTemperature": "أعلى درجة حرارة", "failedToFetchHostConfig": "فشل جلب تكوين المضيف", @@ -2819,6 +2878,7 @@ "updateCommandHistoryFailed": "فشل تحديث إعداد سجل الأوامر", "analyticsEnabled": "مشاركة إحصائيات الاستخدام المجهولة", "analyticsEnabledDesc": "يرسل النظام إحصاءً يومياً مجهولاً للمستخدمين والمضيفين واستخدام الميزات للمساعدة في تحسين Termix. ولا يتم تضمين أي بيانات شخصية أو تفاصيل اتصال على الإطلاق.", + "analyticsEnabledLockedDesc": "هذا الإعداد مقفل بواسطة متغير البيئة ENABLE_TELEMETRY ولا يمكن تغييره هنا.", "updateAnalyticsFailed": "فشل تحديث إعدادات التحليلات", "sessionSharingGloballyEnabled": "السماح بمشاركة الجلسة", "sessionSharingGloballyEnabledDesc": "يسمح هذا الخيار بمشاركة جلسات الطرفية المباشرة، وجلسات RDP، وVNC، وTelnet على مستوى الجهاز. ويتجاوز هذا الخيار جميع إعدادات المشاركة الخاصة بكل مضيف عند تعطيله.", @@ -3323,6 +3383,8 @@ "resetToDefaultsSuccess": "تمت إعادة تعيين الإعدادات إلى الوضع الافتراضي.", "storageModeSwitch": "تخزين التفضيلات", "sectionAccount": "الحساب", + "desktopProfileTitle": "ملف تعريف سطح المكتب المحلي التلقائي", + "desktopProfileDescription": "يقتصر هذا الملف الشخصي على الواجهة الخلفية المدمجة ويتم تسجيل الدخول إليه تلقائيًا. لا يتطلب كلمة مرور لتسجيل الدخول؛ يستخدم المزامنة عن بُعد أدناه حساب خادم منفصل.", "sectionAppearance": "المظهر", "sectionSecurity": "الأمان", "sectionApiKeys": "مفاتيح API", @@ -3379,6 +3441,8 @@ "statusColorsDesc": "استخدام الأخضر/الأحمر لحالة الاتصال/عدم الاتصال بدلاً من لون التمييز", "pinAppRail": "تثبيت شريط التطبيقات", "pinAppRailDesc": "إبقاء شريط التطبيقات الأيسر موسعًا دائمًا بدلاً من التوسيع عند التحويم", + "openFullscreenSettings": "افتح الإعدادات في وضع ملء الشاشة", + "exitFullscreenSettings": "الخروج من إعدادات ملء الشاشة", "expandAppRailOnHover": "توسيع شريط التطبيقات عند التحويم", "expandAppRailOnHoverDesc": "السماح بتوسيع شريط التطبيقات الأيسر عند تحريك المؤشر فوقه", "settingsNavigation": "التنقل", diff --git a/src/ui/locales/translated/bg_BG.json b/src/ui/locales/translated/bg_BG.json index de153062..377d6689 100644 --- a/src/ui/locales/translated/bg_BG.json +++ b/src/ui/locales/translated/bg_BG.json @@ -546,6 +546,7 @@ "sshTools": "SSH инструменти", "history": "История", "sessionLogs": "Дневници на сесиите", + "sidebarSettings": "Настройки на страничната лента...", "hosts": "Домакини", "snippets": "Откъси", "hostManager": "Мениджър на домакини", @@ -755,6 +756,8 @@ "agentSocketPathLabel": "Път на сокета на агента", "agentSocketPathPlaceholder": "Оставете празно, за да използвате SSH_AUTH_SOCK", "agentSocketPathHint": "Оставете празно, за да се открие автоматично от променливата на средата SSH_AUTH_SOCK, или въведете персонализиран път до сокета (напр. /run/user/1000/gnupg/S.gpg-agent.ssh).", + "shareSshAuthLabel": "Споделяне на SSH удостоверяване", + "shareSshAuthDesc": "Дайте на получателите криптирани копия на SSH удостоверяването на този хост. Личните идентификационни данни на получателя все още имат приоритет.", "tailscaleDeviceSelect": "Изберете устройство за Tailscale", "tailscaleDeviceSelectPlaceholder": "Изберете устройство...", "tailscaleNoApiKey": "Няма конфигуриран ключ за Tailscale API. Добавете такъв в настройките на администратора, за да активирате откриването на устройства.", @@ -1173,15 +1176,39 @@ "generateFromPrivateKey": "Генериране от частен ключ", "refreshBtn2": "Обновяване", "exitSelectionTitle": "Изход от селекцията", - "exportAll": "Експортиране на всички", - "exportForSharing": "Експортиране за споделяне", "addHostBtn2": "Добавяне на хост", "addCredentialBtn2": "Добавяне на идентификационни данни", "checkingHostStatuses": "Проверка на състоянието на хоста...", "pinnedSection": "Закачено", "hostsExported": "Хостовете са експортирани успешно", - "hostsShareExported": "Споделяемите хостове бяха експортирани успешно", - "exportFailed": "Експортирането на хостове не бе успешно", + "export": { + "menuItem": "Експорт...", + "title": "Експортиране на хостове", + "scope": "Обхват", + "scopeAll": "Всички", + "scopeSelected": "Избрано", + "searchHosts": "Търсене на хостове...", + "include": "Включи", + "groupConnection": "Връзка", + "groupCredentials": "Пълномощия", + "groupNotes": "Бележки", + "groupTags": "Етикети и закачане", + "groupTunnels": "Тунели", + "groupJumpHosts": "Домакини за преходи", + "groupQuickActions": "Бързи действия", + "groupFeatureFlags": "Флагове на функциите", + "groupAdvanced": "Разширена конфигурация", + "preview": "Преглед", + "moreHosts": "... {{count}} още хостове", + "summary": "{{selected}} от {{total}} хостове", + "credentialsIncluded": "включени идентификационни данни", + "credentialsExcluded": "изключени идентификационни данни", + "noneSelected": "Няма избрани хостове", + "cancel": "Отказ", + "confirm": "Експорт", + "fetchFailed": "Зареждането на хостове за експортиране не бе успешно", + "bulkButton": "Експорт" + }, "sampleDownloaded": "Примерен файл е изтеглен", "failedToDeleteCredential2": "Неуспешно изтриване на идентификационните данни", "noFolderOption": "(Няма папка)", @@ -1266,7 +1293,7 @@ }, "edit": { "label": "Редактиране", - "description": "Преглеждайте и променяйте хоста. Тайните могат да бъдат заменени, но никога прочетени; присвояванията на идентификационни данни остават само за собственика." + "description": "Преглеждайте и променяйте настройките на хоста без удостоверяване. SSH удостоверяването на собственика остава частно и само за собственика." }, "manage": { "label": "Управление", @@ -1299,7 +1326,24 @@ "sharedBadgeTooltip": "Споделено от {{owner}} (достъп{{level}})", "viewOnlyBanner": "Този хост е споделен с вас от {{owner}} с достъп за преглед. Конфигурацията е само за четене.", "sharedEditBanner": "Този хост е споделен с вас от {{owner}} с достъп за редактиране. Промените се отнасят за истинския хост; референтните данни за удостоверяване могат да бъдат променяни само от собственика.", - "ownerOnlyControl": "Само собственикът на хоста може да промени това" + "ownerOnlyControl": "Само собственикът на хоста може да промени това", + "ownerAuthPrivate": "SSH удостоверяването на собственика на хоста е частно. Използвайте „Задаване на лично SSH удостоверяване“ от менюто на хоста, за да изберете свои собствени идентификационни данни.", + "ownerAuthShared": "Собственикът на хоста е споделил SSH удостоверяване за този хост. Можете да го използвате или да изберете свои собствени идентификационни данни от „Задаване на лично SSH удостоверяване“.", + "authOverrideAction": "Задаване на лично SSH удостоверяване", + "authOverrideTitle": "Лично SSH удостоверяване", + "authOverrideDescriptionPrivate": "SSH идентификационните данни на собственика на хоста остават поверителни. Изберете едни от запазените си идентификационни данни за връзки към {{host}}.", + "authOverrideDescriptionShared": "Използвайте удостоверяването, споделено от собственика на хоста, или го заменете с едно от запазените си идентификационни данни за връзки към {{host}}.", + "authOverrideCredentialLabel": "Идентификационни данни за удостоверяване", + "useSharedAuthentication": "Използване на удостоверяване на споделен хост", + "noPersonalCredential": "Без лични акредитиви", + "authOverrideNoCredentials": "Все още нямате запазени SSH идентификационни данни. Създайте си в „Идентификационни данни“, за да се свързвате с хостове, които изискват удостоверяване.", + "authOverrideRequired": "Този хост изисква едно от вашите запазени идентификационни данни, преди да можете да се свържете.", + "authOverridePrivateHint": "Тези идентификационни данни са поверителни за вас. Собственикът на хоста и другите получатели не могат да ги видят или използват.", + "authOverrideSaved": "Личното SSH удостоверяване е запазено", + "authOverrideCleared": "Личното SSH удостоверяване е премахнато", + "authOverrideClearedToShared": "Използване на удостоверяване на споделен хост", + "authOverrideLoadError": "Зареждането на SSH удостоверяването не бе успешно. Моля, опитайте отново.", + "authOverrideSaveError": "Запазването на SSH удостоверяването не бе успешно" }, "guac": { "connection": "Връзка", @@ -1627,6 +1671,15 @@ "tmuxCopyHint": "Променете селекцията и натиснете Enter, за да я копирате в клипборда", "tmuxDetach": "Отделяне от tmux сесия", "tmuxDetached": "Отделен от tmux сесията", + "searchPlaceholder": "Намерете", + "searchCaseSensitive": "Съвпадение на регистъра", + "searchWholeWord": "Съвпадение на цяла дума", + "searchRegex": "Използвайте регулярни изрази", + "searchNoResults": "Няма резултати", + "searchResultCount": "{{index}} от {{count}}", + "searchNext": "Следващ мач (Enter)", + "searchPrevious": "Предишно съвпадение (Shift+Enter)", + "searchClose": "Затвори (Escape)", "maxReconnectAttemptsReached": "Достигнат е максималният брой опити за повторно свързване", "closeTab": "Затвори", "connectionTimeout": "Време за изчакване на връзката", @@ -1654,6 +1707,11 @@ "opksshTimeout": "Времето за изчакване на удостоверяването изтече. Моля, опитайте отново.", "opksshAuthFailed": "Удостоверяването не бе успешно. Моля, проверете идентификационните си данни и опитайте отново.", "opksshSignInWith": "Влезте с {{provider}}", + "tailscaleCheckRequired": "Изисква се удостоверяване на Tailscale", + "tailscaleCheckDescription": "Tailscale SSH изисква допълнителна проверка. Удостоверете се в браузъра си, за да продължите.", + "tailscaleCheckOpenBrowser": "Отворете браузъра за удостоверяване", + "tailscaleCheckWaiting": "Чака се удостоверяване на Tailscale...", + "tailscaleCheckTimeout": "Времето за изчакване на удостоверяването на Tailscale изтече. Моля, опитайте отново.", "vaultAuthTitle": "Изисква се влизане в трезора", "vaultAuthDescription": "Отвори се прозорец за влизане в HashiCorp Vault. Завършете влизането там; тази връзка ще продължи автоматично.", "vaultAuthFailed": "Удостоверяването на трезора не бе успешно. Моля, опитайте отново.", @@ -2145,6 +2203,7 @@ "cpuUsage": "Използване на процесора", "memoryUsage": "Използване на паметта", "diskUsage": "Използване на диска", + "selectFilesystem": "Изберете файлова система", "temperature": "Температура", "highestTemperature": "Най-висока температура", "failedToFetchHostConfig": "Неуспешно извличане на конфигурацията на хоста", @@ -2819,6 +2878,7 @@ "updateCommandHistoryFailed": "Актуализирането на настройката за историята на командите не бе успешно", "analyticsEnabled": "Споделяне на анонимни статистически данни за употреба", "analyticsEnabledDesc": "Изпраща анонимен дневен брой потребители, хостове и използване на функции, за да помогне за подобряването на Termix. Никога не се включват лични данни или подробности за връзката.", + "analyticsEnabledLockedDesc": "Тази настройка е заключена от променливата на средата ENABLE_TELEMETRY и не може да бъде променена тук.", "updateAnalyticsFailed": "Актуализирането на настройката за анализ не бе успешно", "sessionSharingGloballyEnabled": "Разрешаване на споделяне на сесия", "sessionSharingGloballyEnabledDesc": "Разрешава споделянето на сесии на живо на терминал, RDP, VNC и Telnet в целия екземпляр. Заменя всяко превключвател за споделяне на хост, когато е деактивиран.", @@ -3323,6 +3383,8 @@ "resetToDefaultsSuccess": "Настройките са нулирани до фабричните.", "storageModeSwitch": "Съхранение на предпочитания", "sectionAccount": "Профил", + "desktopProfileTitle": "Автоматичен профил за локален десктоп", + "desktopProfileDescription": "Този профил е ограничен до вградения бекенд и се включва автоматично. Няма парола за вход; „Отдалечена синхронизация“ по-долу използва отделен сървърен акаунт.", "sectionAppearance": "Външен вид", "sectionSecurity": "Сигурност", "sectionApiKeys": "API ключове", @@ -3379,6 +3441,8 @@ "statusColorsDesc": "Използвайте зелено/червено за онлайн/офлайн статус вместо акцентния цвят", "pinAppRail": "Pin App Rail", "pinAppRailDesc": "Поддържайте релсата на приложението в лявата странична лента винаги разгъната, вместо да се разширява при задържане на курсора на мишката", + "openFullscreenSettings": "Отваряне на настройките на цял екран", + "exitFullscreenSettings": "Изход от настройките за цял екран", "expandAppRailOnHover": "Разгъване на лентата на приложенията при задържане на курсора на мишката", "expandAppRailOnHoverDesc": "Разрешаване на разширяването на лентата с приложения в лявата странична лента, когато курсорът се движи върху нея", "settingsNavigation": "Навигация", diff --git a/src/ui/locales/translated/bn_BD.json b/src/ui/locales/translated/bn_BD.json index 84678a37..399e9b79 100644 --- a/src/ui/locales/translated/bn_BD.json +++ b/src/ui/locales/translated/bn_BD.json @@ -546,6 +546,7 @@ "sshTools": "SSH টুলস", "history": "ইতিহাস", "sessionLogs": "সেশন লগ", + "sidebarSettings": "সাইডবার সেটিংস...", "hosts": "হোস্টরা", "snippets": "খণ্ডাংশ", "hostManager": "হোস্ট ম্যানেজার", @@ -755,6 +756,8 @@ "agentSocketPathLabel": "এজেন্ট সকেট পাথ", "agentSocketPathPlaceholder": "SSH_AUTH_SOCK ব্যবহার করতে খালি রাখুন", "agentSocketPathHint": "SSH_AUTH_SOCK এনভায়রনমেন্ট ভেরিয়েবল থেকে স্বয়ংক্রিয়ভাবে শনাক্ত করার জন্য এটি খালি রাখুন, অথবা একটি কাস্টম সকেট পাথ লিখুন (যেমন /run/user/1000/gnupg/S.gpg-agent.ssh)।", + "shareSshAuthLabel": "SSH প্রমাণীকরণ শেয়ার করুন", + "shareSshAuthDesc": "প্রাপকদের এই হোস্টের SSH প্রমাণীকরণের এনক্রিপ্টেড কপি দিন। প্রাপকের ব্যক্তিগত পরিচয়পত্রই অগ্রাধিকার পাবে।", "tailscaleDeviceSelect": "টেইলস্কেল ডিভাইস নির্বাচন করুন", "tailscaleDeviceSelectPlaceholder": "একটি ডিভাইস নির্বাচন করুন...", "tailscaleNoApiKey": "কোনো টেইলস্কেল এপিআই কী কনফিগার করা নেই। ডিভাইস ডিসকভারি সক্ষম করতে অ্যাডমিন সেটিংসে একটি যোগ করুন।", @@ -1173,15 +1176,39 @@ "generateFromPrivateKey": "প্রাইভেট কী থেকে তৈরি করুন", "refreshBtn2": "রিফ্রেশ", "exitSelectionTitle": "প্রস্থান নির্বাচন", - "exportAll": "সব রপ্তানি করুন", - "exportForSharing": "শেয়ার করার জন্য রপ্তানি করুন", "addHostBtn2": "হোস্ট যোগ করুন", "addCredentialBtn2": "পরিচয়পত্র যোগ করুন", "checkingHostStatuses": "হোস্টের অবস্থা যাচাই করা হচ্ছে...", "pinnedSection": "পিন করা", "hostsExported": "হোস্টগুলি সফলভাবে রপ্তানি করা হয়েছে", - "hostsShareExported": "শেয়ারযোগ্য হোস্টগুলি সফলভাবে রপ্তানি করা হয়েছে", - "exportFailed": "হোস্ট রপ্তানি করতে ব্যর্থ হয়েছে", + "export": { + "menuItem": "রপ্তানি...", + "title": "হোস্ট রপ্তানি করুন", + "scope": "পরিধি", + "scopeAll": "সব", + "scopeSelected": "নির্বাচিত", + "searchHosts": "হোস্ট অনুসন্ধান করুন...", + "include": "অন্তর্ভুক্ত করুন", + "groupConnection": "সংযোগ", + "groupCredentials": "যোগ্যতা", + "groupNotes": "নোট", + "groupTags": "ট্যাগ ও পিন", + "groupTunnels": "টানেল", + "groupJumpHosts": "জাম্প হোস্ট", + "groupQuickActions": "দ্রুত পদক্ষেপ", + "groupFeatureFlags": "বৈশিষ্ট্য পতাকা", + "groupAdvanced": "উন্নত কনফিগারেশন", + "preview": "প্রিভিউ", + "moreHosts": "... {{count}} আরও হোস্ট", + "summary": "{{selected}} এর {{total}} হোস্ট", + "credentialsIncluded": "পরিচয়পত্র অন্তর্ভুক্ত", + "credentialsExcluded": "পরিচয়পত্র বাদ দেওয়া হয়েছে", + "noneSelected": "কোন হোস্ট নির্বাচন করা হয়নি", + "cancel": "বাতিল করুন", + "confirm": "রপ্তানি", + "fetchFailed": "রপ্তানির জন্য হোস্ট লোড করতে ব্যর্থ হয়েছে", + "bulkButton": "রপ্তানি" + }, "sampleDownloaded": "নমুনা ফাইল ডাউনলোড করা হয়েছে", "failedToDeleteCredential2": "ক্রেডেনশিয়াল মুছে ফেলতে ব্যর্থ হয়েছে", "noFolderOption": "(কোন ফোল্ডার নেই)", @@ -1266,7 +1293,7 @@ }, "edit": { "label": "সম্পাদনা", - "description": "হোস্টটি দেখুন এবং পরিবর্তন করুন। গোপনীয় তথ্য প্রতিস্থাপন করা গেলেও পড়া যাবে না; ক্রেডেনশিয়াল অ্যাসাইনমেন্ট শুধুমাত্র মালিকের কাছেই থাকবে।" + "description": "নন-অথেনটিকেশন হোস্ট সেটিংস দেখুন এবং পরিবর্তন করুন। মালিকের SSH অথেনটিকেশন ব্যক্তিগত এবং শুধুমাত্র মালিকের জন্যই সংরক্ষিত থাকে।" }, "manage": { "label": "পরিচালনা করুন", @@ -1299,7 +1326,24 @@ "sharedBadgeTooltip": "{{owner}} ({{level}} অ্যাক্সেস) দ্বারা শেয়ার করা হয়েছে", "viewOnlyBanner": "এই হোস্টটি {{owner}} আপনার সাথে দেখার অ্যাক্সেস সহ শেয়ার করেছে। কনফিগারেশনটি শুধুমাত্র পঠনযোগ্য।", "sharedEditBanner": "এই হোস্টটি {{owner}} দ্বারা আপনার সাথে সম্পাদনার অ্যাক্সেস সহ শেয়ার করা হয়েছে। পরিবর্তনগুলি আসল হোস্টে প্রযোজ্য হবে; প্রমাণীকরণ রেফারেন্স শুধুমাত্র মালিক দ্বারা পরিবর্তন করা যাবে।", - "ownerOnlyControl": "শুধুমাত্র হোস্টের মালিকই এটি পরিবর্তন করতে পারেন।" + "ownerOnlyControl": "শুধুমাত্র হোস্টের মালিকই এটি পরিবর্তন করতে পারেন।", + "ownerAuthPrivate": "হোস্ট মালিকের SSH প্রমাণীকরণ ব্যক্তিগত। আপনার নিজস্ব ক্রেডেনশিয়াল বেছে নিতে হোস্ট মেনু থেকে “ব্যক্তিগত SSH প্রমাণীকরণ সেট করুন” ব্যবহার করুন।", + "ownerAuthShared": "হোস্টের মালিক এই হোস্টের জন্য শেয়ার্ড SSH অথেন্টিকেশন সেট করেছেন। আপনি এটি ব্যবহার করতে পারেন অথবা “ব্যক্তিগত SSH অথেন্টিকেশন সেট করুন” থেকে আপনার নিজস্ব ক্রেডেনশিয়াল বেছে নিতে পারেন।", + "authOverrideAction": "ব্যক্তিগত SSH প্রমাণীকরণ সেট করুন", + "authOverrideTitle": "ব্যক্তিগত SSH প্রমাণীকরণ", + "authOverrideDescriptionPrivate": "হোস্ট মালিকের SSH ক্রেডেনশিয়াল গোপন থাকে। {{host}} -এ সংযোগের জন্য আপনার সংরক্ষিত ক্রেডেনশিয়ালগুলির মধ্যে একটি বেছে নিন।", + "authOverrideDescriptionShared": "{{host}} -এ সংযোগের জন্য হোস্ট মালিকের দেওয়া প্রমাণীকরণ ব্যবহার করুন, অথবা এটিকে আপনার সংরক্ষিত ক্রেডেনশিয়ালগুলির একটি দিয়ে প্রতিস্থাপন করুন।", + "authOverrideCredentialLabel": "প্রমাণীকরণ শংসাপত্র", + "useSharedAuthentication": "শেয়ার্ড হোস্ট প্রমাণীকরণ ব্যবহার করুন", + "noPersonalCredential": "কোন ব্যক্তিগত পরিচয়পত্র নেই", + "authOverrideNoCredentials": "আপনার এখনও কোনো সংরক্ষিত SSH ক্রেডেনশিয়াল নেই। প্রমাণীকরণের প্রয়োজন এমন হোস্টগুলিতে সংযোগ করতে ক্রেডেনশিয়ালস-এ একটি তৈরি করুন।", + "authOverrideRequired": "সংযোগ করার আগে এই হোস্টের আপনার সংরক্ষিত পরিচয়পত্রগুলোর মধ্যে একটি প্রয়োজন।", + "authOverridePrivateHint": "এই পরিচয়পত্রটি আপনার জন্য ব্যক্তিগত। হোস্টের মালিক এবং অন্যান্য প্রাপকরা এটি দেখতে বা ব্যবহার করতে পারবেন না।", + "authOverrideSaved": "ব্যক্তিগত SSH প্রমাণীকরণ সংরক্ষিত", + "authOverrideCleared": "ব্যক্তিগত SSH প্রমাণীকরণ মুছে ফেলা হয়েছে", + "authOverrideClearedToShared": "শেয়ার্ড হোস্ট প্রমাণীকরণ ব্যবহার করে", + "authOverrideLoadError": "আপনার SSH প্রমাণীকরণ লোড করা সম্ভব হয়নি। অনুগ্রহ করে আবার চেষ্টা করুন।", + "authOverrideSaveError": "আপনার SSH প্রমাণীকরণ সংরক্ষণ করা যায়নি" }, "guac": { "connection": "সংযোগ", @@ -1627,6 +1671,15 @@ "tmuxCopyHint": "নির্বাচনটি ঠিক করুন এবং ক্লিপবোর্ডে কপি করতে এন্টার চাপুন।", "tmuxDetach": "tmux সেশন থেকে বিচ্ছিন্ন করুন", "tmuxDetached": "tmux সেশন থেকে বিচ্ছিন্ন", + "searchPlaceholder": "খুঁজুন", + "searchCaseSensitive": "দেশলাইয়ের বাক্স", + "searchWholeWord": "সম্পূর্ণ শব্দ মেলান", + "searchRegex": "রেগুলার এক্সপ্রেশন ব্যবহার করুন", + "searchNoResults": "কোন ফলাফল নেই", + "searchResultCount": "{{index}} এর {{count}}", + "searchNext": "পরবর্তী ম্যাচ (প্রবেশ করুন)", + "searchPrevious": "পূর্ববর্তী ম্যাচ (Shift+Enter)", + "searchClose": "বন্ধ করুন (পলায়ন)", "maxReconnectAttemptsReached": "পুনঃসংযোগের সর্বোচ্চ প্রচেষ্টা শেষ হয়েছে", "closeTab": "বন্ধ করুন", "connectionTimeout": "সংযোগের সময়সীমা শেষ", @@ -1654,6 +1707,11 @@ "opksshTimeout": "প্রমাণীকরণের সময়সীমা শেষ হয়ে গেছে। অনুগ্রহ করে আবার চেষ্টা করুন।", "opksshAuthFailed": "প্রমাণীকরণ ব্যর্থ হয়েছে। অনুগ্রহ করে আপনার পরিচয়পত্র যাচাই করে আবার চেষ্টা করুন।", "opksshSignInWith": "{{provider}} দিয়ে সাইন ইন করুন", + "tailscaleCheckRequired": "টেইলস্কেল প্রমাণীকরণ আবশ্যক", + "tailscaleCheckDescription": "টেইলস্কেল SSH-এর জন্য একটি অতিরিক্ত যাচাইকরণ প্রয়োজন। চালিয়ে যাওয়ার জন্য আপনার ব্রাউজারে প্রমাণীকরণ করুন।", + "tailscaleCheckOpenBrowser": "প্রমাণীকরণের জন্য ব্রাউজার খুলুন", + "tailscaleCheckWaiting": "টেইলস্কেল প্রমাণীকরণের জন্য অপেক্ষা করা হচ্ছে...", + "tailscaleCheckTimeout": "টেইলস্কেল প্রমাণীকরণের সময়সীমা শেষ হয়ে গেছে। অনুগ্রহ করে আবার চেষ্টা করুন।", "vaultAuthTitle": "ভল্টে সাইন-ইন করা আবশ্যক", "vaultAuthDescription": "হ্যাশিকর্প ভল্টে সাইন ইন করার জন্য একটি উইন্ডো খুলেছে। সেখানে সাইন-ইন সম্পন্ন করুন; এই সংযোগটি স্বয়ংক্রিয়ভাবে চলতে থাকবে।", "vaultAuthFailed": "ভল্ট প্রমাণীকরণ ব্যর্থ হয়েছে। অনুগ্রহ করে আবার চেষ্টা করুন।", @@ -2145,6 +2203,7 @@ "cpuUsage": "সিপিইউ ব্যবহার", "memoryUsage": "মেমরি ব্যবহার", "diskUsage": "ডিস্ক ব্যবহার", + "selectFilesystem": "ফাইলসিস্টেম নির্বাচন করুন", "temperature": "তাপমাত্রা", "highestTemperature": "সর্বোচ্চ তাপমাত্রা", "failedToFetchHostConfig": "হোস্ট কনফিগারেশন আনতে ব্যর্থ হয়েছে", @@ -2819,6 +2878,7 @@ "updateCommandHistoryFailed": "কমান্ড হিস্ট্রি সেটিং আপডেট করতে ব্যর্থ হয়েছে", "analyticsEnabled": "বেনামী ব্যবহারের পরিসংখ্যান শেয়ার করুন", "analyticsEnabledDesc": "টারমিক্সকে উন্নত করতে সাহায্য করার জন্য ব্যবহারকারী, হোস্ট এবং ফিচার ব্যবহারের একটি বেনামী দৈনিক গণনা পাঠায়। এতে কোনো ব্যক্তিগত তথ্য বা সংযোগের বিবরণ অন্তর্ভুক্ত করা হয় না।", + "analyticsEnabledLockedDesc": "এই সেটিংটি ENABLE_TELEMETRY এনভায়রনমেন্ট ভেরিয়েবল দ্বারা লক করা আছে এবং এখান থেকে এটি পরিবর্তন করা যাবে না।", "updateAnalyticsFailed": "অ্যানালিটিক্স সেটিং আপডেট করতে ব্যর্থ হয়েছে", "sessionSharingGloballyEnabled": "সেশন শেয়ারিং অনুমোদন করুন", "sessionSharingGloballyEnabledDesc": "লাইভ টার্মিনাল, RDP, VNC, এবং টেলনেট সেশনগুলোকে ইনস্ট্যান্স-ব্যাপী শেয়ার করার অনুমতি দিন। এটি নিষ্ক্রিয় করা হলে, প্রতি-হোস্ট শেয়ারিং টগলকে ওভাররাইড করে।", @@ -3323,6 +3383,8 @@ "resetToDefaultsSuccess": "সেটিংস ডিফল্ট অবস্থায় রিসেট করা হয়েছে।", "storageModeSwitch": "পছন্দের স্টোরেজ", "sectionAccount": "অ্যাকাউন্ট", + "desktopProfileTitle": "স্বয়ংক্রিয় স্থানীয় ডেস্কটপ প্রোফাইল", + "desktopProfileDescription": "এই প্রোফাইলটি এমবেডেড ব্যাকএন্ডের জন্য সীমাবদ্ধ এবং স্বয়ংক্রিয়ভাবে সাইন ইন হয়। এটির কোনো লগইন পাসওয়ার্ড নেই; নিচের রিমোট সিঙ্ক একটি আলাদা সার্ভার অ্যাকাউন্ট ব্যবহার করে।", "sectionAppearance": "চেহারা", "sectionSecurity": "নিরাপত্তা", "sectionApiKeys": "এপিআই কী", @@ -3379,6 +3441,8 @@ "statusColorsDesc": "অ্যাকসেন্ট কালারের পরিবর্তে অনলাইন/অফলাইন স্ট্যাটাসের জন্য সবুজ/লাল ব্যবহার করুন।", "pinAppRail": "পিন অ্যাপ রেল", "pinAppRailDesc": "হোভার করার সময় প্রসারিত না হয়ে, বাম সাইডবারের অ্যাপ রেলটিকে সর্বদা প্রসারিত রাখুন।", + "openFullscreenSettings": "পূর্ণ স্ক্রিনে সেটিংস খুলুন", + "exitFullscreenSettings": "পূর্ণ-স্ক্রিন সেটিংস থেকে প্রস্থান করুন", "expandAppRailOnHover": "হোভার করলে অ্যাপ রেল প্রসারিত করুন", "expandAppRailOnHoverDesc": "পয়েন্টারটি বাম সাইডবারের অ্যাপ রেলের উপর নিয়ে গেলে সেটিকে প্রসারিত হতে দিন।", "settingsNavigation": "নেভিগেশন", diff --git a/src/ui/locales/translated/ca_ES.json b/src/ui/locales/translated/ca_ES.json index 034fac7b..4b14f9b2 100644 --- a/src/ui/locales/translated/ca_ES.json +++ b/src/ui/locales/translated/ca_ES.json @@ -546,6 +546,7 @@ "sshTools": "Eines SSH", "history": "Història", "sessionLogs": "Registres de sessió", + "sidebarSettings": "Configuració de la barra lateral...", "hosts": "Amfitrions", "snippets": "Fragments", "hostManager": "Gestor d'amfitrions", @@ -755,6 +756,8 @@ "agentSocketPathLabel": "Ruta del sòcol de l'agent", "agentSocketPathPlaceholder": "Deixeu-ho en blanc per utilitzar SSH_AUTH_SOCK", "agentSocketPathHint": "Deixeu-ho en blanc per detectar-ho automàticament des de la variable d'entorn SSH_AUTH_SOCK o introduïu una ruta de sòcol personalitzada (per exemple, /run/user/1000/gnupg/S.gpg-agent.ssh).", + "shareSshAuthLabel": "Comparteix l'autenticació SSH", + "shareSshAuthDesc": "Doneu als destinataris còpies xifrades de l'autenticació SSH d'aquest amfitrió. Les credencials personals d'un destinatari encara tenen prioritat.", "tailscaleDeviceSelect": "Selecciona el dispositiu Tailscale", "tailscaleDeviceSelectPlaceholder": "Selecciona un dispositiu...", "tailscaleNoApiKey": "No hi ha cap clau d'API de Tailscale configurada. Afegeix-ne una a la configuració de l'administrador per habilitar la detecció de dispositius.", @@ -1173,15 +1176,39 @@ "generateFromPrivateKey": "Generar a partir de la clau privada", "refreshBtn2": "Actualitza", "exitSelectionTitle": "Sortir de la selecció", - "exportAll": "Exporta-ho tot", - "exportForSharing": "Exporta per compartir", "addHostBtn2": "Afegeix amfitrió", "addCredentialBtn2": "Afegeix credencials", "checkingHostStatuses": "S'estan comprovant els estats de l'amfitrió...", "pinnedSection": "Fixat", "hostsExported": "Els amfitrions s'han exportat correctament", - "hostsShareExported": "Els amfitrions compartibles s'han exportat correctament", - "exportFailed": "No s'han pogut exportar els amfitrions", + "export": { + "menuItem": "Exporta...", + "title": "Exporta els amfitrions", + "scope": "Àmbit", + "scopeAll": "Tot", + "scopeSelected": "Seleccionat", + "searchHosts": "Cerca amfitrions...", + "include": "Inclou", + "groupConnection": "Connexió", + "groupCredentials": "Credencials", + "groupNotes": "Notes", + "groupTags": "Etiquetes i pin", + "groupTunnels": "Túnels", + "groupJumpHosts": "Amfitrions de salt", + "groupQuickActions": "Accions ràpides", + "groupFeatureFlags": "Banderes de funcions", + "groupAdvanced": "Configuració avançada", + "preview": "Vista prèvia", + "moreHosts": "... {{count}} més amfitrions", + "summary": "{{selected}} de {{total}} amfitrions", + "credentialsIncluded": "credencials incloses", + "credentialsExcluded": "credencials excloses", + "noneSelected": "No s'ha seleccionat cap amfitrió", + "cancel": "Cancel·la", + "confirm": "Exporta", + "fetchFailed": "No s'han pogut carregar els hosts per a l'exportació", + "bulkButton": "Exporta" + }, "sampleDownloaded": "Fitxer de mostra descarregat", "failedToDeleteCredential2": "No s'ha pogut suprimir la credencial", "noFolderOption": "(Sense carpeta)", @@ -1266,7 +1293,7 @@ }, "edit": { "label": "Edita", - "description": "Visualitza i modifica l'amfitrió. Els secrets es poden substituir però mai es poden llegir; les assignacions de credencials només són del propietari." + "description": "Visualitza i modifica la configuració de l'amfitrió que no és d'autenticació. L'autenticació SSH del propietari es manté privada i només del propietari." }, "manage": { "label": "Gestiona", @@ -1299,7 +1326,24 @@ "sharedBadgeTooltip": "Compartit per {{owner}} ( accés{{level}})", "viewOnlyBanner": "Aquest amfitrió està compartit amb tu per {{owner}} amb accés de visualització. La configuració és de només lectura.", "sharedEditBanner": "Aquest amfitrió està compartit amb tu per {{owner}} amb accés d'edició. Els canvis s'apliquen a l'amfitrió real; les referències d'autenticació només les pot canviar el propietari.", - "ownerOnlyControl": "Només el propietari de l'amfitrió pot canviar això" + "ownerOnlyControl": "Només el propietari de l'amfitrió pot canviar això", + "ownerAuthPrivate": "L'autenticació SSH del propietari de l'amfitrió és privada. Utilitzeu \"Configura l'autenticació SSH personal\" al menú de l'amfitrió per triar les vostres pròpies credencials.", + "ownerAuthShared": "El propietari de l'amfitrió ha compartit l'autenticació SSH per a aquest amfitrió. Podeu utilitzar-la o triar les vostres pròpies credencials des de \"Defineix l'autenticació SSH personal\".", + "authOverrideAction": "Configura l'autenticació SSH personal", + "authOverrideTitle": "Autenticació SSH personal", + "authOverrideDescriptionPrivate": "Les credencials SSH del propietari de l'amfitrió romanen privades. Trieu una de les vostres credencials desades per a connexions a {{host}}.", + "authOverrideDescriptionShared": "Feu servir l'autenticació compartida pel propietari de l'amfitrió o substituïu-la per una de les vostres credencials desades per a connexions a {{host}}.", + "authOverrideCredentialLabel": "Credencial d'autenticació", + "useSharedAuthentication": "Utilitza l'autenticació d'amfitrió compartit", + "noPersonalCredential": "Sense credencial personal", + "authOverrideNoCredentials": "Encara no teniu cap credencial SSH desada. Creeu-ne una a Credencials per connectar-vos a hosts que requereixen autenticació.", + "authOverrideRequired": "Aquest amfitrió necessita una de les teves credencials desades abans que et puguis connectar.", + "authOverridePrivateHint": "Aquesta credencial és privada per a tu. El propietari de l'amfitrió i altres destinataris no la poden veure ni utilitzar.", + "authOverrideSaved": "S'ha desat l'autenticació SSH personal", + "authOverrideCleared": "S'ha eliminat l'autenticació SSH personal", + "authOverrideClearedToShared": "Ús de l'autenticació d'amfitrió compartit", + "authOverrideLoadError": "No s'ha pogut carregar l'autenticació SSH. Torna-ho a provar.", + "authOverrideSaveError": "No s'ha pogut desar l'autenticació SSH." }, "guac": { "connection": "Connexió", @@ -1627,6 +1671,15 @@ "tmuxCopyHint": "Ajusta la selecció i prem Intro per copiar al porta-retalls", "tmuxDetach": "Desconnecta de la sessió de tmux", "tmuxDetached": "Desconnectat de la sessió tmux", + "searchPlaceholder": "Troba", + "searchCaseSensitive": "Coincideix amb el cas", + "searchWholeWord": "Coincideix amb la paraula sencera", + "searchRegex": "Utilitza l'expressió regular", + "searchNoResults": "Sense resultats", + "searchResultCount": "{{index}} de {{count}}", + "searchNext": "Pròxima coincidència (Intro)", + "searchPrevious": "Coincidència anterior (Maj+Intro)", + "searchClose": "Tanca (Escapada)", "maxReconnectAttemptsReached": "S'ha arribat al màxim d'intents de reconnexió", "closeTab": "Tanca", "connectionTimeout": "Temps d'espera de connexió", @@ -1654,6 +1707,11 @@ "opksshTimeout": "S'ha esgotat el temps d'autenticació. Torna-ho a provar.", "opksshAuthFailed": "L'autenticació ha fallat. Si us plau, comproveu les vostres credencials i torneu-ho a intentar.", "opksshSignInWith": "Inicia la sessió amb {{provider}}", + "tailscaleCheckRequired": "Autenticació a escala final requerida", + "tailscaleCheckDescription": "L'SSH a escala de punta requereix una comprovació addicional. Autentiqueu-vos al navegador per continuar.", + "tailscaleCheckOpenBrowser": "Obre el navegador per autenticar-te", + "tailscaleCheckWaiting": "Esperant l'autenticació de Tailscale...", + "tailscaleCheckTimeout": "S'ha esgotat el temps d'espera de l'autenticació a escala final. Torna-ho a provar.", "vaultAuthTitle": "Cal iniciar sessió a la caixa forta", "vaultAuthDescription": "S'ha obert una finestra per iniciar la sessió a HashiCorp Vault. Completeu l'inici de sessió allà; aquesta connexió continuarà automàticament.", "vaultAuthFailed": "L'autenticació de la caixa forta ha fallat. Torna-ho a provar.", @@ -2145,6 +2203,7 @@ "cpuUsage": "Ús de la CPU", "memoryUsage": "Ús de memòria", "diskUsage": "Ús del disc", + "selectFilesystem": "Selecciona el sistema de fitxers", "temperature": "Temperatura", "highestTemperature": "Temperatura més alta", "failedToFetchHostConfig": "No s'ha pogut obtenir la configuració de l'amfitrió", @@ -2819,6 +2878,7 @@ "updateCommandHistoryFailed": "No s'ha pogut actualitzar la configuració de l'historial d'ordres", "analyticsEnabled": "Comparteix estadístiques d'ús anònimes", "analyticsEnabledDesc": "Envia un recompte diari anònim d'usuaris, amfitrions i ús de funcions per ajudar a millorar Termix. No s'hi inclouen mai dades personals ni detalls de connexió.", + "analyticsEnabledLockedDesc": "Aquesta configuració està bloquejada per la variable d'entorn ENABLE_TELEMETRY i no es pot canviar aquí.", "updateAnalyticsFailed": "No s'ha pogut actualitzar la configuració d'analítica", "sessionSharingGloballyEnabled": "Permetre la compartició de sessions", "sessionSharingGloballyEnabledDesc": "Permet que les sessions de terminal, RDP, VNC i Telnet en directe es comparteixin a tota la instància. Anul·la tots els activables i desactivables per compartir per host quan estan desactivats.", @@ -3323,6 +3383,8 @@ "resetToDefaultsSuccess": "S'han restablert els valors predeterminats.", "storageModeSwitch": "Emmagatzematge de preferències", "sectionAccount": "Compte", + "desktopProfileTitle": "Perfil d'escriptori local automàtic", + "desktopProfileDescription": "Aquest perfil està restringit al backend integrat i inicia la sessió automàticament. No té contrasenya d'inici de sessió; la sincronització remota següent utilitza un compte de servidor separat.", "sectionAppearance": "Aspecte", "sectionSecurity": "Seguretat", "sectionApiKeys": "Claus API", @@ -3379,6 +3441,8 @@ "statusColorsDesc": "Utilitzeu verd/vermell per a l'estat en línia/fora de línia en lloc del color d'accent.", "pinAppRail": "Pin App Rail", "pinAppRailDesc": "Mantingueu el rail de l'aplicació de la barra lateral esquerra sempre expandit en comptes d'expandir-se en passar el cursor per sobre", + "openFullscreenSettings": "Obre la configuració a pantalla completa", + "exitFullscreenSettings": "Sortir de la configuració de pantalla completa", "expandAppRailOnHover": "Expandeix l'aplicació Rail en passar el cursor per sobre", "expandAppRailOnHoverDesc": "Permet que el carril de l'aplicació de la barra lateral esquerra s'expandeixi quan el punter es mou per sobre", "settingsNavigation": "Navegació", diff --git a/src/ui/locales/translated/cs_CZ.json b/src/ui/locales/translated/cs_CZ.json index 0f2e385e..6726e130 100644 --- a/src/ui/locales/translated/cs_CZ.json +++ b/src/ui/locales/translated/cs_CZ.json @@ -546,6 +546,7 @@ "sshTools": "SSH nástroje", "history": "Dějiny", "sessionLogs": "Protokoly relací", + "sidebarSettings": "Nastavení postranního panelu...", "hosts": "Hostitelé", "snippets": "Úryvky", "hostManager": "Hostitelský manažer", @@ -755,6 +756,8 @@ "agentSocketPathLabel": "Cesta soketu agenta", "agentSocketPathPlaceholder": "Pro použití SSH_AUTH_SOCK ponechte prázdné.", "agentSocketPathHint": "Nechte prázdné pro automatickou detekci z proměnné prostředí SSH_AUTH_SOCK nebo zadejte vlastní cestu k socketu (např. /run/user/1000/gnupg/S.gpg-agent.ssh).", + "shareSshAuthLabel": "Sdílení ověřování SSH", + "shareSshAuthDesc": "Poskytněte příjemcům šifrované kopie SSH ověření tohoto hostitele. Osobní přihlašovací údaje příjemce mají stále přednost.", "tailscaleDeviceSelect": "Vyberte zařízení Tailscale", "tailscaleDeviceSelectPlaceholder": "Vyberte zařízení...", "tailscaleNoApiKey": "Není nakonfigurován žádný klíč Tailscale API. Přidejte jej v nastavení správce, abyste povolili vyhledávání zařízení.", @@ -1173,15 +1176,39 @@ "generateFromPrivateKey": "Generovat ze soukromého klíče", "refreshBtn2": "Obnovit", "exitSelectionTitle": "Ukončit výběr", - "exportAll": "Exportovat vše", - "exportForSharing": "Exportovat pro sdílení", "addHostBtn2": "Přidat hostitele", "addCredentialBtn2": "Přidat přihlašovací údaje", "checkingHostStatuses": "Kontrola stavů hostitelů...", "pinnedSection": "Připnuto", "hostsExported": "Hostitelé byli úspěšně exportováni", - "hostsShareExported": "Sdílitelné hostitele byly úspěšně exportovány", - "exportFailed": "Export hostitelů se nezdařilo", + "export": { + "menuItem": "Vývozní...", + "title": "Exportovat hostitele", + "scope": "Rozsah", + "scopeAll": "Vše", + "scopeSelected": "Vybraný", + "searchHosts": "Hledat hostitele...", + "include": "Zahrnout", + "groupConnection": "Spojení", + "groupCredentials": "Pověření", + "groupNotes": "Poznámky", + "groupTags": "Štítky a pin", + "groupTunnels": "Tunely", + "groupJumpHosts": "Hostitelé skoků", + "groupQuickActions": "Rychlé akce", + "groupFeatureFlags": "Vlajky funkcí", + "groupAdvanced": "Pokročilá konfigurace", + "preview": "Náhled", + "moreHosts": "... {{count}} více hostitelů", + "summary": "{{selected}} z {{total}} hostitelů", + "credentialsIncluded": "zahrnuty přihlašovací údaje", + "credentialsExcluded": "vyloučené přihlašovací údaje", + "noneSelected": "Žádní hostitelé nejsou vybráni", + "cancel": "Zrušit", + "confirm": "Vývozní", + "fetchFailed": "Nepodařilo se načíst hostitele pro export", + "bulkButton": "Vývozní" + }, "sampleDownloaded": "Ukázkový soubor stažen", "failedToDeleteCredential2": "Nepodařilo se smazat přihlašovací údaje", "noFolderOption": "(Žádná složka)", @@ -1266,7 +1293,7 @@ }, "edit": { "label": "Upravit", - "description": "Zobrazení a úprava hostitele. Tajné údaje lze nahradit, ale nikdy nečíst; přiřazení pověření zůstává pouze pro vlastníka." + "description": "Zobrazení a úprava nastavení hostitele bez ověřování. Ověřování vlastníka přes SSH zůstává soukromé a pouze pro vlastníka." }, "manage": { "label": "Spravovat", @@ -1299,7 +1326,24 @@ "sharedBadgeTooltip": "Sdíleno uživatelem {{owner}} ( přístup{{level}})", "viewOnlyBanner": "Tento hostitel je s vámi sdílen uživatelem {{owner}} s oprávněním pro zobrazení. Konfigurace je pouze pro čtení.", "sharedEditBanner": "Tento hostitel je s vámi sdílen uživatelem {{owner}} s oprávněním k úpravám. Změny se vztahují na skutečného hostitele; ověřovací reference může změnit pouze vlastník.", - "ownerOnlyControl": "Toto může změnit pouze majitel hostitele" + "ownerOnlyControl": "Toto může změnit pouze majitel hostitele", + "ownerAuthPrivate": "Ověřování SSH vlastníka hostitele je soukromé. Pro výběr vlastních přihlašovacích údajů použijte v nabídce hostitele možnost „Nastavit osobní ověřování SSH“.", + "ownerAuthShared": "Vlastník hostitele sdílí SSH ověřování pro tohoto hostitele. Můžete ho použít nebo si vybrat vlastní přihlašovací údaje v části „Nastavit osobní SSH ověřování“.", + "authOverrideAction": "Nastavení osobního SSH ověřování", + "authOverrideTitle": "Osobní SSH autentizace", + "authOverrideDescriptionPrivate": "Přihlašovací údaje SSH vlastníka hostitele zůstanou soukromé. Vyberte si jedny z uložených přihlašovacích údajů pro připojení k {{host}}.", + "authOverrideDescriptionShared": "Pro připojení k {{host}} použijte ověřování sdílené vlastníkem hostitele nebo jej nahraďte jedním z vašich uložených přihlašovacích údajů.", + "authOverrideCredentialLabel": "Ověřovací údaje", + "useSharedAuthentication": "Použít ověřování sdíleného hostitele", + "noPersonalCredential": "Žádné osobní pověření", + "authOverrideNoCredentials": "Zatím nemáte žádné uložené přihlašovací údaje SSH. Vytvořte si je v části Přihlašovací údaje pro připojení k hostitelům, které vyžadují ověřování.", + "authOverrideRequired": "Tento hostitel vyžaduje před připojením jeden z vašich uložených přihlašovacích údajů.", + "authOverridePrivateHint": "Tyto přihlašovací údaje jsou soukromé. Vlastník hostitele ani ostatní příjemci je nemohou vidět ani používat.", + "authOverrideSaved": "Osobní SSH ověřování uloženo", + "authOverrideCleared": "Osobní SSH ověřování bylo odstraněno", + "authOverrideClearedToShared": "Používání ověřování sdíleného hostitele", + "authOverrideLoadError": "Nepodařilo se načíst vaše ověřování SSH. Zkuste to prosím znovu.", + "authOverrideSaveError": "Uložení ověřování SSH se nezdařilo" }, "guac": { "connection": "Spojení", @@ -1627,6 +1671,15 @@ "tmuxCopyHint": "Upravte výběr a stiskněte Enter pro zkopírování do schránky", "tmuxDetach": "Odpojit se od relace tmux", "tmuxDetached": "Odpojeno od relace tmux", + "searchPlaceholder": "Nalézt", + "searchCaseSensitive": "Zápasová karta", + "searchWholeWord": "Shoda celého slova", + "searchRegex": "Použít regulární výraz", + "searchNoResults": "Žádné výsledky", + "searchResultCount": "{{index}} z {{count}}", + "searchNext": "Další zápas (Enter)", + "searchPrevious": "Předchozí shoda (Shift+Enter)", + "searchClose": "Zavřít (Escape)", "maxReconnectAttemptsReached": "Dosažen maximální počet pokusů o opětovné připojení", "closeTab": "Blízko", "connectionTimeout": "Časový limit připojení", @@ -1654,6 +1707,11 @@ "opksshTimeout": "Časový limit ověření vypršel. Zkuste to prosím znovu.", "opksshAuthFailed": "Ověření se nezdařilo. Zkontrolujte prosím své přihlašovací údaje a zkuste to znovu.", "opksshSignInWith": "Přihlásit se pomocí {{provider}}", + "tailscaleCheckRequired": "Vyžadováno ověřování Tailscale", + "tailscaleCheckDescription": "Tailscale SSH vyžaduje dodatečnou kontrolu. Pro pokračování se ověřte ve svém prohlížeči.", + "tailscaleCheckOpenBrowser": "Otevřít prohlížeč pro ověření", + "tailscaleCheckWaiting": "Čekání na ověření Tailscale...", + "tailscaleCheckTimeout": "Časový limit pro ověřování Tailscale vypršel. Zkuste to prosím znovu.", "vaultAuthTitle": "Vyžaduje se přihlášení do trezoru", "vaultAuthDescription": "Otevřelo se okno pro přihlášení do HashiCorp Vault. Dokončete přihlášení tam; toto připojení bude automaticky pokračovat.", "vaultAuthFailed": "Ověření v trezoru se nezdařilo. Zkuste to prosím znovu.", @@ -2145,6 +2203,7 @@ "cpuUsage": "Využití CPU", "memoryUsage": "Využití paměti", "diskUsage": "Využití disku", + "selectFilesystem": "Vyberte souborový systém", "temperature": "Teplota", "highestTemperature": "Nejvyšší teplota", "failedToFetchHostConfig": "Nepodařilo se načíst konfiguraci hostitele", @@ -2819,6 +2878,7 @@ "updateCommandHistoryFailed": "Nepodařilo se aktualizovat nastavení historie příkazů.", "analyticsEnabled": "Sdílení anonymních statistik používání", "analyticsEnabledDesc": "Odesílá anonymní denní počet uživatelů, hostitelů a využití funkcí, aby pomohl vylepšit Termix. Nikdy neobsahuje žádné osobní údaje ani podrobnosti o připojení.", + "analyticsEnabledLockedDesc": "Toto nastavení je uzamčeno proměnnou prostředí ENABLE_TELEMETRY a nelze jej zde změnit.", "updateAnalyticsFailed": "Nepodařilo se aktualizovat nastavení analytických nástrojů", "sessionSharingGloballyEnabled": "Povolit sdílení relace", "sessionSharingGloballyEnabledDesc": "Povolit sdílení relací živého terminálu, RDP, VNC a Telnet v rámci celé instance. Pokud je tato možnost zakázána, přepíše všechny přepínače sdílení pro jednotlivé hostitele.", @@ -3323,6 +3383,8 @@ "resetToDefaultsSuccess": "Nastavení se resetovala na výchozí hodnoty.", "storageModeSwitch": "Úložiště preferencí", "sectionAccount": "Účet", + "desktopProfileTitle": "Automatický profil lokální plochy", + "desktopProfileDescription": "Tento profil je omezen na integrovaný backend a přihlašuje se automaticky. Nemá žádné přihlašovací heslo; Vzdálená synchronizace níže používá samostatný serverový účet.", "sectionAppearance": "Vzhled", "sectionSecurity": "Zabezpečení", "sectionApiKeys": "Klíče API", @@ -3379,6 +3441,8 @@ "statusColorsDesc": "Pro stav online/offline použijte zelenou/červenou místo zvýrazňující barvy.", "pinAppRail": "Pin App Rail", "pinAppRailDesc": "Udržovat levý postranní panel aplikace vždy rozbalený, místo aby se rozbaloval při najetí myší", + "openFullscreenSettings": "Otevřít nastavení na celou obrazovku", + "exitFullscreenSettings": "Ukončit nastavení celoobrazovkového režimu", "expandAppRailOnHover": "Rozbalit lištu aplikací při najetí myší", "expandAppRailOnHoverDesc": "Povolit rozbalení lišty aplikace v levém postranním panelu, když se nad ní přesune kurzor", "settingsNavigation": "Navigace", diff --git a/src/ui/locales/translated/da_DK.json b/src/ui/locales/translated/da_DK.json index 55da78ee..2c27968d 100644 --- a/src/ui/locales/translated/da_DK.json +++ b/src/ui/locales/translated/da_DK.json @@ -546,6 +546,7 @@ "sshTools": "SSH-værktøjer", "history": "Historie", "sessionLogs": "Sessionslogfiler", + "sidebarSettings": "Indstillinger for sidebjælke...", "hosts": "Værter", "snippets": "Uddrag", "hostManager": "Værtsadministrator", @@ -755,6 +756,8 @@ "agentSocketPathLabel": "Agent Socket-sti", "agentSocketPathPlaceholder": "Lad feltet stå tomt for at bruge SSH_AUTH_SOCK", "agentSocketPathHint": "Lad feltet stå tomt for automatisk at detektere fra miljøvariablen SSH_AUTH_SOCK, eller indtast en brugerdefineret socket-sti (f.eks. /run/user/1000/gnupg/S.gpg-agent.ssh).", + "shareSshAuthLabel": "Del SSH-godkendelse", + "shareSshAuthDesc": "Giv modtagerne krypterede kopier af denne værts SSH-godkendelse. En modtagers personlige legitimationsoplysninger har stadig forrang.", "tailscaleDeviceSelect": "Vælg Tailscale-enhed", "tailscaleDeviceSelectPlaceholder": "Vælg en enhed...", "tailscaleNoApiKey": "Ingen Tailscale API-nøgle konfigureret. Tilføj en i administratorindstillinger for at aktivere enhedsregistrering.", @@ -1173,15 +1176,39 @@ "generateFromPrivateKey": "Generer fra privat nøgle", "refreshBtn2": "Opfriske", "exitSelectionTitle": "Afslut valg", - "exportAll": "Eksporter alle", - "exportForSharing": "Eksportér til deling", "addHostBtn2": "Tilføj vært", "addCredentialBtn2": "Tilføj legitimationsoplysninger", "checkingHostStatuses": "Tjekker værtsstatusser...", "pinnedSection": "Fastgjort", "hostsExported": "Værter eksporteret", - "hostsShareExported": "Delbare værter blev eksporteret", - "exportFailed": "Kunne ikke eksportere værter", + "export": { + "menuItem": "Eksportere...", + "title": "Eksportér værter", + "scope": "Omfang", + "scopeAll": "Alle", + "scopeSelected": "Valgt", + "searchHosts": "Søg efter værter...", + "include": "Omfatte", + "groupConnection": "Forbindelse", + "groupCredentials": "Akkreditiver", + "groupNotes": "Noter", + "groupTags": "Tags og pin", + "groupTunnels": "Tunneler", + "groupJumpHosts": "Spring værter", + "groupQuickActions": "Hurtige handlinger", + "groupFeatureFlags": "Funktionsflag", + "groupAdvanced": "Avanceret konfiguration", + "preview": "Forhåndsvisning", + "moreHosts": "... {{count}} flere værter", + "summary": "{{selected}} af {{total}} værter", + "credentialsIncluded": "legitimationsoplysninger inkluderet", + "credentialsExcluded": "legitimationsoplysninger ekskluderet", + "noneSelected": "Ingen værter valgt", + "cancel": "Ophæve", + "confirm": "Eksportere", + "fetchFailed": "Kunne ikke indlæse værter til eksport", + "bulkButton": "Eksportere" + }, "sampleDownloaded": "Eksempelfil downloadet", "failedToDeleteCredential2": "Kunne ikke slette legitimationsoplysninger", "noFolderOption": "(Ingen mappe)", @@ -1266,7 +1293,7 @@ }, "edit": { "label": "Redigere", - "description": "Vis og rediger værten. Hemmeligheder kan erstattes, men aldrig læses; tildeling af legitimationsoplysninger forbliver kun for ejeren." + "description": "Se og rediger ikke-godkendelseshostindstillinger. Ejerens SSH-godkendelse forbliver privat og kun for ejeren." }, "manage": { "label": "Styre", @@ -1299,7 +1326,24 @@ "sharedBadgeTooltip": "Delt af {{owner}} ({{level}} adgang)", "viewOnlyBanner": "Denne vært deles med dig af {{owner}} med læseadgang. Konfigurationen er skrivebeskyttet.", "sharedEditBanner": "Denne vært deles med dig af {{owner}} med redigeringsadgang. Ændringer gælder for den rigtige vært; godkendelsesreferencer kan kun ændres af ejeren.", - "ownerOnlyControl": "Kun værtsejeren kan ændre dette" + "ownerOnlyControl": "Kun værtsejeren kan ændre dette", + "ownerAuthPrivate": "Host-ejerens SSH-godkendelse er privat. Brug \"Indstil personlig SSH-godkendelse\" i værtsmenuen for at vælge dine egne legitimationsoplysninger.", + "ownerAuthShared": "Ejeren af værten har delt SSH-godkendelse for denne vært. Du kan bruge den eller vælge dine egne legitimationsoplysninger under \"Angiv personlig SSH-godkendelse\".", + "authOverrideAction": "Indstil personlig SSH-godkendelse", + "authOverrideTitle": "Personlig SSH-godkendelse", + "authOverrideDescriptionPrivate": "Værtsejerens SSH-loginoplysninger forbliver private. Vælg en af dine gemte loginoplysninger til forbindelser til {{host}}.", + "authOverrideDescriptionShared": "Brug den godkendelse, der deles af værtsejeren, eller erstat den med en af dine gemte legitimationsoplysninger til forbindelser til {{host}}.", + "authOverrideCredentialLabel": "Godkendelsesoplysninger", + "useSharedAuthentication": "Brug delt værtsgodkendelse", + "noPersonalCredential": "Ingen personlig legitimation", + "authOverrideNoCredentials": "Du har endnu ingen gemte SSH-legitimationsoplysninger. Opret en i Legitimationsoplysninger for at oprette forbindelse til værter, der kræver godkendelse.", + "authOverrideRequired": "Denne vært kræver en af dine gemte loginoplysninger, før du kan oprette forbindelse.", + "authOverridePrivateHint": "Denne legitimationsoplysninger er privat for dig. Værtsejeren og andre modtagere kan ikke se eller bruge den.", + "authOverrideSaved": "Personlig SSH-godkendelse gemt", + "authOverrideCleared": "Personlig SSH-godkendelse fjernet", + "authOverrideClearedToShared": "Brug af delt værtsgodkendelse", + "authOverrideLoadError": "Din SSH-godkendelse kunne ikke indlæses. Prøv igen.", + "authOverrideSaveError": "Din SSH-godkendelse kunne ikke gemmes" }, "guac": { "connection": "Forbindelse", @@ -1627,6 +1671,15 @@ "tmuxCopyHint": "Juster valget, og tryk på Enter for at kopiere til udklipsholderen", "tmuxDetach": "Afbryd forbindelsen til tmux-sessionen", "tmuxDetached": "Afkoblet fra tmux-session", + "searchPlaceholder": "Finde", + "searchCaseSensitive": "Match-sag", + "searchWholeWord": "Match hele ordet", + "searchRegex": "Brug regulært udtryk", + "searchNoResults": "Ingen resultater", + "searchResultCount": "{{index}} af {{count}}", + "searchNext": "Næste kamp (Enter)", + "searchPrevious": "Forrige match (Shift+Enter)", + "searchClose": "Luk (Escape)", "maxReconnectAttemptsReached": "Maksimalt antal forsøg på genoprettelse nået", "closeTab": "Tæt", "connectionTimeout": "Forbindelsestimeout", @@ -1654,6 +1707,11 @@ "opksshTimeout": "Godkendelsen udløb. Prøv igen.", "opksshAuthFailed": "Godkendelse mislykkedes. Tjek dine loginoplysninger, og prøv igen.", "opksshSignInWith": "Log ind med {{provider}}", + "tailscaleCheckRequired": "Tailscale-godkendelse påkrævet", + "tailscaleCheckDescription": "Tailscale SSH kræver en yderligere kontrol. Godkend i din browser for at fortsætte.", + "tailscaleCheckOpenBrowser": "Åbn browseren for at godkende", + "tailscaleCheckWaiting": "Venter på Tailscale-godkendelse...", + "tailscaleCheckTimeout": "Tailscale-godkendelsen er udløbet. Prøv igen.", "vaultAuthTitle": "Login til Vault kræves", "vaultAuthDescription": "Et vindue er åbnet, hvor du kan logge ind på HashiCorp Vault. Fuldfør loginprocessen der; forbindelsen fortsætter automatisk.", "vaultAuthFailed": "Godkendelse af arkiv mislykkedes. Prøv igen.", @@ -2145,6 +2203,7 @@ "cpuUsage": "CPU-forbrug", "memoryUsage": "Hukommelsesforbrug", "diskUsage": "Diskforbrug", + "selectFilesystem": "Vælg filsystem", "temperature": "Temperatur", "highestTemperature": "Højeste temperatur", "failedToFetchHostConfig": "Kunne ikke hente værtkonfigurationen", @@ -2819,6 +2878,7 @@ "updateCommandHistoryFailed": "Kunne ikke opdatere indstillingen for kommandohistorik", "analyticsEnabled": "Del anonym brugsstatistik", "analyticsEnabledDesc": "Sender en anonym daglig optælling af brugere, værter og funktionsbrug for at forbedre Termix. Ingen personlige data eller forbindelsesoplysninger inkluderes nogensinde.", + "analyticsEnabledLockedDesc": "Denne indstilling er låst af miljøvariablen ENABLE_TELEMETRY og kan ikke ændres her.", "updateAnalyticsFailed": "Analyseindstillingen kunne ikke opdateres", "sessionSharingGloballyEnabled": "Tillad deling af sessioner", "sessionSharingGloballyEnabledDesc": "Tillad deling af live terminal-, RDP-, VNC- og Telnet-sessioner på tværs af instanser. Tilsidesætter alle delingsindstillinger pr. vært, når de er deaktiveret.", @@ -3323,6 +3383,8 @@ "resetToDefaultsSuccess": "Indstillingerne nulstilles til standardindstillingerne.", "storageModeSwitch": "Præferencelagring", "sectionAccount": "Konto", + "desktopProfileTitle": "Automatisk lokal skrivebordsprofil", + "desktopProfileDescription": "Denne profil er begrænset til den integrerede backend og logger ind automatisk. Den har ingen login-adgangskode; Fjernsynkronisering nedenfor bruger en separat serverkonto.", "sectionAppearance": "Udseende", "sectionSecurity": "Sikkerhed", "sectionApiKeys": "API-nøgler", @@ -3379,6 +3441,8 @@ "statusColorsDesc": "Brug grøn/rød til online/offline status i stedet for accentfarven", "pinAppRail": "Fastgør app-skinne", "pinAppRailDesc": "Hold appskinnen i venstre sidebar altid udvidet i stedet for at udvide den, når du holder musen over den", + "openFullscreenSettings": "Åbn indstillinger i fuld skærm", + "exitFullscreenSettings": "Afslut fuldskærmsindstillinger", "expandAppRailOnHover": "Udvid app-skinne ved musepeker", "expandAppRailOnHoverDesc": "Tillad, at appens skinne i venstre sidebar udvides, når markøren bevæges hen over den", "settingsNavigation": "Navigation", diff --git a/src/ui/locales/translated/de_DE.json b/src/ui/locales/translated/de_DE.json index 26556355..1e8662d8 100644 --- a/src/ui/locales/translated/de_DE.json +++ b/src/ui/locales/translated/de_DE.json @@ -546,6 +546,7 @@ "sshTools": "SSH-Tools", "history": "Verlauf", "sessionLogs": "Sitzungsprotokolle", + "sidebarSettings": "Seitenleisteneinstellungen...", "hosts": "Hosts", "snippets": "Snippets", "hostManager": "Host-Manager", @@ -755,6 +756,8 @@ "agentSocketPathLabel": "Agent-Socket-Pfad", "agentSocketPathPlaceholder": "Leer lassen, um SSH_AUTH_SOCK zu verwenden", "agentSocketPathHint": "Leer lassen, um automatisch über die Umgebungsvariable SSH_AUTH_SOCK zu erkennen, oder geben Sie einen benutzerdefinierten Socket-Pfad ein (z. B. /run/user/1000/gnupg/S.gpg-agent.ssh).", + "shareSshAuthLabel": "SSH-Authentifizierung teilen", + "shareSshAuthDesc": "Geben Sie den Empfängern verschlüsselte Kopien der SSH-Authentifizierungsinformationen dieses Hosts. Die persönlichen Anmeldeinformationen des Empfängers haben weiterhin Vorrang.", "tailscaleDeviceSelect": "Tailscale-Gerät auswählen", "tailscaleDeviceSelectPlaceholder": "Gerät auswählen...", "tailscaleNoApiKey": "Kein Tailscale API-Schlüssel konfiguriert. Fügen Sie einen in den Admin-Einstellungen hinzu, um die Geräteerkennung zu aktivieren.", @@ -1173,15 +1176,39 @@ "generateFromPrivateKey": "Aus privatem Schlüssel generieren", "refreshBtn2": "Aktualisieren", "exitSelectionTitle": "Auswahl verlassen", - "exportAll": "Alle exportieren", - "exportForSharing": "Zum Teilen exportieren", "addHostBtn2": "Host hinzufügen", "addCredentialBtn2": "Zugangsdaten hinzufügen", "checkingHostStatuses": "Host-Status werden überprüft...", "pinnedSection": "Angeheftet", "hostsExported": "Hosts erfolgreich exportiert", - "hostsShareExported": "Teilbare Hosts erfolgreich exportiert", - "exportFailed": "Export der Hosts fehlgeschlagen", + "export": { + "menuItem": "Export...", + "title": "Export-Hosts", + "scope": "Umfang", + "scopeAll": "Alle", + "scopeSelected": "Ausgewählt", + "searchHosts": "Hosts suchen...", + "include": "Enthalten", + "groupConnection": "Verbindung", + "groupCredentials": "Anmeldeinformationen", + "groupNotes": "Anmerkungen", + "groupTags": "Etiketten & Pins", + "groupTunnels": "Tunnel", + "groupJumpHosts": "Jump-Moderatoren", + "groupQuickActions": "Schnelle Aktionen", + "groupFeatureFlags": "Funktionsflaggen", + "groupAdvanced": "Erweiterte Konfiguration", + "preview": "Vorschau", + "moreHosts": "... {{count}} weitere Hosts", + "summary": "{{selected}} von {{total}} Gastgebern", + "credentialsIncluded": "Anmeldeinformationen enthalten", + "credentialsExcluded": "Anmeldeinformationen ausgeschlossen", + "noneSelected": "Keine Hosts ausgewählt", + "cancel": "Stornieren", + "confirm": "Export", + "fetchFailed": "Fehler beim Laden der Hosts für den Export", + "bulkButton": "Export" + }, "sampleDownloaded": "Beispieldatei heruntergeladen", "failedToDeleteCredential2": "Löschen der Zugangsdaten fehlgeschlagen", "noFolderOption": "(Kein Ordner)", @@ -1266,7 +1293,7 @@ }, "edit": { "label": "Bearbeiten", - "description": "Anzeigen sowie Host bearbeiten. Geheimnisse können ersetzt, aber nie gelesen werden; Zugangsdaten-Zuweisungen bleiben nur dem Besitzer vorbehalten." + "description": "Sie können die Einstellungen für Hosts ohne Authentifizierung anzeigen und bearbeiten. Die SSH-Authentifizierung des Besitzers bleibt privat und ist nur für den Besitzer zugänglich." }, "manage": { "label": "Verwalten", @@ -1299,7 +1326,24 @@ "sharedBadgeTooltip": "Freigegeben von {{owner}} ({{level}}-Zugriff)", "viewOnlyBanner": "Dieser Host wurde von {{owner}} mit Lesezugriff für Sie freigegeben. Die Konfiguration ist schreibgeschützt.", "sharedEditBanner": "Dieser Host wurde von {{owner}} mit Bearbeitungszugriff für Sie freigegeben. Änderungen wirken sich auf den tatsächlichen Host aus; Authentifizierungsreferenzen können nur vom Besitzer geändert werden.", - "ownerOnlyControl": "Nur der Host-Besitzer kann dies ändern" + "ownerOnlyControl": "Nur der Host-Besitzer kann dies ändern", + "ownerAuthPrivate": "Die SSH-Authentifizierung des Host-Besitzers ist privat. Verwenden Sie im Host-Menü die Option „Persönliche SSH-Authentifizierung einrichten“, um Ihre eigenen Anmeldeinformationen festzulegen.", + "ownerAuthShared": "Der Hostinhaber hat für diesen Host eine gemeinsame SSH-Authentifizierung eingerichtet. Sie können diese verwenden oder Ihre eigenen Anmeldeinformationen unter „Persönliche SSH-Authentifizierung einrichten“ auswählen.", + "authOverrideAction": "Persönliche SSH-Authentifizierung einrichten", + "authOverrideTitle": "Persönliche SSH-Authentifizierung", + "authOverrideDescriptionPrivate": "Die SSH-Zugangsdaten des Host-Besitzers bleiben privat. Wählen Sie eines Ihrer gespeicherten Zugangsdaten für Verbindungen zu {{host}} aus.", + "authOverrideDescriptionShared": "Verwenden Sie die vom Host-Besitzer freigegebene Authentifizierung oder ersetzen Sie diese durch eine Ihrer gespeicherten Anmeldeinformationen für Verbindungen zu {{host}}.", + "authOverrideCredentialLabel": "Authentifizierungsdaten", + "useSharedAuthentication": "Shared-Host-Authentifizierung verwenden", + "noPersonalCredential": "Keine persönlichen Ausweispapiere", + "authOverrideNoCredentials": "Sie haben noch keine SSH-Anmeldeinformationen gespeichert. Erstellen Sie welche unter „Anmeldeinformationen“, um Verbindungen zu Hosts herzustellen, die eine Authentifizierung erfordern.", + "authOverrideRequired": "Dieser Host benötigt eines Ihrer gespeicherten Zugangsdaten, bevor Sie eine Verbindung herstellen können.", + "authOverridePrivateHint": "Diese Zugangsdaten sind privat und nur für Sie bestimmt. Der Host-Betreiber und andere Empfänger können sie weder einsehen noch verwenden.", + "authOverrideSaved": "Persönliche SSH-Authentifizierung gespeichert", + "authOverrideCleared": "Persönliche SSH-Authentifizierung entfernt", + "authOverrideClearedToShared": "Verwendung der Shared-Host-Authentifizierung", + "authOverrideLoadError": "Die SSH-Authentifizierung konnte nicht geladen werden. Bitte versuchen Sie es erneut.", + "authOverrideSaveError": "Die SSH-Authentifizierung konnte nicht gespeichert werden." }, "guac": { "connection": "Verbindung", @@ -1627,6 +1671,15 @@ "tmuxCopyHint": "Auswahl anpassen und Enter drücken, um in die Zwischenablage zu kopieren", "tmuxDetach": "Von tmux-Sitzung trennen", "tmuxDetached": "Von tmux-Sitzung getrennt", + "searchPlaceholder": "Finden", + "searchCaseSensitive": "Streichholzschachtel", + "searchWholeWord": "Gleiches Wort aus", + "searchRegex": "Regulären Ausdruck verwenden", + "searchNoResults": "Keine Ergebnisse", + "searchResultCount": "{{index}} von {{count}}", + "searchNext": "Nächstes Spiel (Eingabe)", + "searchPrevious": "Vorheriges Spiel (Umschalt+Eingabe)", + "searchClose": "Schließen (Flucht)", "maxReconnectAttemptsReached": "Maximale Wiederverbindungsversuche erreicht", "closeTab": "Schließen", "connectionTimeout": "Verbindungs-Timeout", @@ -1654,6 +1707,11 @@ "opksshTimeout": "Authentifizierung abgelaufen. Bitte versuchen Sie es erneut.", "opksshAuthFailed": "Authentifizierung fehlgeschlagen. Bitte überprüfen Sie Ihre Anmeldeinformationen und versuchen Sie es erneut.", "opksshSignInWith": "Anmelden mit {{provider}}", + "tailscaleCheckRequired": "Tailscale-Authentifizierung erforderlich", + "tailscaleCheckDescription": "Tailscale SSH erfordert eine zusätzliche Überprüfung. Authentifizieren Sie sich in Ihrem Browser, um fortzufahren.", + "tailscaleCheckOpenBrowser": "Öffnen Sie den Browser zur Authentifizierung", + "tailscaleCheckWaiting": "Warten auf Tailscale-Authentifizierung...", + "tailscaleCheckTimeout": "Die Tailscale-Authentifizierung ist fehlgeschlagen. Bitte versuchen Sie es erneut.", "vaultAuthTitle": "Vault-Anmeldung erforderlich", "vaultAuthDescription": "Ein Fenster zur Anmeldung bei HashiCorp Vault wurde geöffnet. Schließen Sie die Anmeldung dort ab; diese Verbindung wird automatisch fortgesetzt.", "vaultAuthFailed": "Vault-Authentifizierung fehlgeschlagen. Bitte versuchen Sie es erneut.", @@ -2145,6 +2203,7 @@ "cpuUsage": "CPU-Auslastung", "memoryUsage": "Arbeitsspeicherauslastung", "diskUsage": "Festplattenauslastung", + "selectFilesystem": "Dateisystem auswählen", "temperature": "Temperatur", "highestTemperature": "Höchste Temperatur", "failedToFetchHostConfig": "Fehler beim Abrufen der Host-Konfiguration", @@ -2819,6 +2878,7 @@ "updateCommandHistoryFailed": "Fehler beim Aktualisieren der Einstellung für den Befehlsverlauf", "analyticsEnabled": "Anonyme Nutzungsstatistiken teilen", "analyticsEnabledDesc": "Sendet täglich anonyme Zählungen von Nutzern, Hosts und Funktionsnutzung, um Termix zu verbessern. Es werden niemals personenbezogene Daten oder Verbindungsdaten übermittelt.", + "analyticsEnabledLockedDesc": "Diese Einstellung ist durch die Umgebungsvariable ENABLE_TELEMETRY gesperrt und kann hier nicht geändert werden.", "updateAnalyticsFailed": "Die Aktualisierung der Analyseeinstellungen ist fehlgeschlagen.", "sessionSharingGloballyEnabled": "Sitzungsfreigabe zulassen", "sessionSharingGloballyEnabledDesc": "Ermöglicht die instanzweite Freigabe von Live-Terminal-, RDP-, VNC- und Telnet-Sitzungen. Überschreibt alle Freigabeeinstellungen pro Host, wenn diese deaktiviert sind.", @@ -3323,6 +3383,8 @@ "resetToDefaultsSuccess": "Einstellungen wurden auf die Standardwerte zurückgesetzt.", "storageModeSwitch": "Einstellungsspeicher", "sectionAccount": "Konto", + "desktopProfileTitle": "Automatisches lokales Desktop-Profil", + "desktopProfileDescription": "Dieses Profil ist auf das eingebettete Backend beschränkt und meldet sich automatisch an. Es hat kein Anmeldepasswort; die unten beschriebene Remote-Synchronisierung verwendet ein separates Serverkonto.", "sectionAppearance": "Erscheinungsbild", "sectionSecurity": "Sicherheit", "sectionApiKeys": "API-Schlüssel", @@ -3379,6 +3441,8 @@ "statusColorsDesc": "Grün/Rot für Online-/Offline-Status anstelle der Akzentfarbe verwenden", "pinAppRail": "App-Leiste anheften", "pinAppRailDesc": "Linke Seitenleisten-App-Leiste immer erweitert lassen, anstatt sie bei Hover zu erweitern", + "openFullscreenSettings": "Einstellungen im Vollbildmodus öffnen", + "exitFullscreenSettings": "Vollbildeinstellungen beenden", "expandAppRailOnHover": "App-Leiste bei Hover erweitern", "expandAppRailOnHoverDesc": "App-Leiste beim Überfahren mit der Maus erweitern", "settingsNavigation": "Navigation", diff --git a/src/ui/locales/translated/el_GR.json b/src/ui/locales/translated/el_GR.json index 4500c33f..83376d50 100644 --- a/src/ui/locales/translated/el_GR.json +++ b/src/ui/locales/translated/el_GR.json @@ -546,6 +546,7 @@ "sshTools": "Εργαλεία SSH", "history": "Ιστορία", "sessionLogs": "Αρχεία καταγραφής συνεδρίας", + "sidebarSettings": "Ρυθμίσεις πλευρικής γραμμής...", "hosts": "Οικοδεσπότες", "snippets": "Αποσπάσματα", "hostManager": "Διευθυντής Υποδοχής", @@ -755,6 +756,8 @@ "agentSocketPathLabel": "Διαδρομή υποδοχής παράγοντα", "agentSocketPathPlaceholder": "Αφήστε κενό για να χρησιμοποιήσετε το SSH_AUTH_SOCK", "agentSocketPathHint": "Αφήστε το κενό για αυτόματη ανίχνευση από τη μεταβλητή περιβάλλοντος SSH_AUTH_SOCK ή εισαγάγετε μια προσαρμοσμένη διαδρομή υποδοχής (π.χ. /run/user/1000/gnupg/S.gpg-agent.ssh).", + "shareSshAuthLabel": "Κοινή χρήση ελέγχου ταυτότητας SSH", + "shareSshAuthDesc": "Δώστε στους παραλήπτες κρυπτογραφημένα αντίγραφα του ελέγχου ταυτότητας SSH αυτού του κεντρικού υπολογιστή. Τα προσωπικά διαπιστευτήρια ενός παραλήπτη εξακολουθούν να έχουν προτεραιότητα.", "tailscaleDeviceSelect": "Επιλογή συσκευής κλίμακας ουράς", "tailscaleDeviceSelectPlaceholder": "Επιλέξτε μια συσκευή...", "tailscaleNoApiKey": "Δεν έχει ρυθμιστεί κλειδί API Tailscale. Προσθέστε ένα στις Ρυθμίσεις διαχειριστή για να ενεργοποιήσετε την ανακάλυψη συσκευών.", @@ -1173,15 +1176,39 @@ "generateFromPrivateKey": "Δημιουργία από ιδιωτικό κλειδί", "refreshBtn2": "Φρεσκάρω", "exitSelectionTitle": "Έξοδος από την επιλογή", - "exportAll": "Εξαγωγή όλων", - "exportForSharing": "Εξαγωγή για κοινή χρήση", "addHostBtn2": "Προσθήκη κεντρικού υπολογιστή", "addCredentialBtn2": "Προσθήκη διαπιστευτηρίων", "checkingHostStatuses": "Έλεγχος καταστάσεων κεντρικού υπολογιστή...", "pinnedSection": "Καρφιτσωμένο", "hostsExported": "Οι κεντρικοί υπολογιστές εξήχθησαν με επιτυχία", - "hostsShareExported": "Οι κοινόχρηστοι κεντρικοί υπολογιστές εξήχθησαν με επιτυχία", - "exportFailed": "Αποτυχία εξαγωγής κεντρικών υπολογιστών", + "export": { + "menuItem": "Εξαγωγή...", + "title": "Εξαγωγή κεντρικών υπολογιστών", + "scope": "Εκταση", + "scopeAll": "Ολοι", + "scopeSelected": "Επιλεγμένο", + "searchHosts": "Αναζήτηση κεντρικών υπολογιστών...", + "include": "Συμπεριλαμβάνω", + "groupConnection": "Σύνδεση", + "groupCredentials": "Διαπιστευτήρια", + "groupNotes": "Σημειώσεις", + "groupTags": "Ετικέτες & καρφίτσα", + "groupTunnels": "Σήραγγες", + "groupJumpHosts": "Jump hosts", + "groupQuickActions": "Γρήγορες ενέργειες", + "groupFeatureFlags": "Σημαίες χαρακτηριστικών", + "groupAdvanced": "Προηγμένη διαμόρφωση", + "preview": "Πρεμιέρα", + "moreHosts": "... {{count}} περισσότεροι οικοδεσπότες", + "summary": "{{selected}} από {{total}} κεντρικούς υπολογιστές", + "credentialsIncluded": "περιλαμβάνονται διαπιστευτήρια", + "credentialsExcluded": "εξαιρούνται τα διαπιστευτήρια", + "noneSelected": "Δεν έχουν επιλεγεί κεντρικοί υπολογιστές", + "cancel": "Ματαίωση", + "confirm": "Εξαγωγή", + "fetchFailed": "Αποτυχία φόρτωσης κεντρικών υπολογιστών για εξαγωγή", + "bulkButton": "Εξαγωγή" + }, "sampleDownloaded": "Λήψη δείγματος αρχείου", "failedToDeleteCredential2": "Η διαγραφή των διαπιστευτηρίων απέτυχε", "noFolderOption": "(Δεν υπάρχει φάκελος)", @@ -1266,7 +1293,7 @@ }, "edit": { "label": "Εκδίδω", - "description": "Προβολή και τροποποίηση του κεντρικού υπολογιστή. Τα μυστικά μπορούν να αντικατασταθούν αλλά ποτέ να διαβαστούν. Οι εκχωρήσεις διαπιστευτηρίων παραμένουν μόνο για τον κάτοχο." + "description": "Προβολή και τροποποίηση ρυθμίσεων κεντρικού υπολογιστή που δεν απαιτούν έλεγχο ταυτότητας. Ο έλεγχος ταυτότητας SSH του κατόχου παραμένει ιδιωτικός και μόνο για τον κάτοχο." }, "manage": { "label": "Διαχειρίζομαι", @@ -1299,7 +1326,24 @@ "sharedBadgeTooltip": "Κοινή χρήση από {{owner}} ( πρόσβαση{{level}})", "viewOnlyBanner": "Αυτός ο κεντρικός υπολογιστής είναι κοινόχρηστος μαζί σας από τον χρήστη {{owner}} με πρόσβαση προβολής. Η διαμόρφωση είναι μόνο για ανάγνωση.", "sharedEditBanner": "Αυτός ο κεντρικός υπολογιστής είναι κοινόχρηστος μαζί σας από τον χρήστη {{owner}} με πρόσβαση επεξεργασίας. Οι αλλαγές ισχύουν για τον πραγματικό κεντρικό υπολογιστή. Οι αναφορές ελέγχου ταυτότητας μπορούν να αλλάξουν μόνο από τον κάτοχο.", - "ownerOnlyControl": "Μόνο ο κάτοχος του κεντρικού υπολογιστή μπορεί να το αλλάξει αυτό" + "ownerOnlyControl": "Μόνο ο κάτοχος του κεντρικού υπολογιστή μπορεί να το αλλάξει αυτό", + "ownerAuthPrivate": "Ο έλεγχος ταυτότητας SSH του κατόχου του κεντρικού υπολογιστή είναι ιδιωτικός. Χρησιμοποιήστε την επιλογή \"Ορισμός προσωπικού ελέγχου ταυτότητας SSH\" από το μενού του κεντρικού υπολογιστή για να επιλέξετε τα δικά σας διαπιστευτήρια.", + "ownerAuthShared": "Ο κάτοχος του κεντρικού υπολογιστή έχει κοινόχρηστο έλεγχο ταυτότητας SSH για αυτόν τον κεντρικό υπολογιστή. Μπορείτε να τον χρησιμοποιήσετε ή να επιλέξετε τα δικά σας διαπιστευτήρια από την ενότητα \"Ορισμός προσωπικού ελέγχου ταυτότητας SSH\".", + "authOverrideAction": "Ορισμός προσωπικού ελέγχου ταυτότητας SSH", + "authOverrideTitle": "Προσωπικός έλεγχος ταυτότητας SSH", + "authOverrideDescriptionPrivate": "Τα διαπιστευτήρια SSH του κατόχου του κεντρικού υπολογιστή παραμένουν ιδιωτικά. Επιλέξτε ένα από τα αποθηκευμένα διαπιστευτήριά σας για συνδέσεις με το {{host}}.", + "authOverrideDescriptionShared": "Χρησιμοποιήστε τον έλεγχο ταυτότητας που έχει κοινοποιηθεί από τον κάτοχο του κεντρικού υπολογιστή ή αντικαταστήστε τον με ένα από τα αποθηκευμένα διαπιστευτήριά σας για συνδέσεις με το {{host}}.", + "authOverrideCredentialLabel": "Διαπιστευτήρια ελέγχου ταυτότητας", + "useSharedAuthentication": "Χρήση ελέγχου ταυτότητας κοινόχρηστου κεντρικού υπολογιστή", + "noPersonalCredential": "Χωρίς προσωπικά διαπιστευτήρια", + "authOverrideNoCredentials": "Δεν έχετε ακόμη αποθηκευμένα διαπιστευτήρια SSH. Δημιουργήστε ένα στα Διαπιστευτήρια για να συνδεθείτε σε κεντρικούς υπολογιστές που απαιτούν έλεγχο ταυτότητας.", + "authOverrideRequired": "Αυτός ο κεντρικός υπολογιστής απαιτεί ένα από τα αποθηκευμένα διαπιστευτήριά σας πριν μπορέσετε να συνδεθείτε.", + "authOverridePrivateHint": "Αυτό το διαπιστευτήριο είναι ιδιωτικό για εσάς. Ο κάτοχος του κεντρικού υπολογιστή και άλλοι παραλήπτες δεν μπορούν να το δουν ή να το χρησιμοποιήσουν.", + "authOverrideSaved": "Αποθηκεύτηκε προσωπικός έλεγχος ταυτότητας SSH", + "authOverrideCleared": "Ο προσωπικός έλεγχος ταυτότητας SSH καταργήθηκε.", + "authOverrideClearedToShared": "Χρήση ελέγχου ταυτότητας κοινόχρηστου κεντρικού υπολογιστή", + "authOverrideLoadError": "Αποτυχία φόρτωσης του ελέγχου ταυτότητας SSH. Δοκιμάστε ξανά.", + "authOverrideSaveError": "Αποτυχία αποθήκευσης του ελέγχου ταυτότητας SSH" }, "guac": { "connection": "Σύνδεση", @@ -1627,6 +1671,15 @@ "tmuxCopyHint": "Προσαρμόστε την επιλογή και πατήστε Enter για αντιγραφή στο πρόχειρο", "tmuxDetach": "Αποσύνδεση από την περίοδο λειτουργίας tmux", "tmuxDetached": "Αποσπάστηκε από την περίοδο λειτουργίας tmux", + "searchPlaceholder": "Εύρημα", + "searchCaseSensitive": "Ταίριασμα υπόθεσης", + "searchWholeWord": "Ταίριασμα ολόκληρης λέξης", + "searchRegex": "Χρήση κανονικής έκφρασης", + "searchNoResults": "Δεν υπάρχουν αποτελέσματα", + "searchResultCount": "{{index}} από {{count}}", + "searchNext": "Επόμενος αγώνας (Είσοδος)", + "searchPrevious": "Προηγούμενος αγώνας (Shift+Enter)", + "searchClose": "Κλείσιμο (Escape)", "maxReconnectAttemptsReached": "Επιτεύχθηκε ο μέγιστος αριθμός προσπαθειών επανασύνδεσης", "closeTab": "Κοντά", "connectionTimeout": "Λήξη χρονικού ορίου σύνδεσης", @@ -1654,6 +1707,11 @@ "opksshTimeout": "Το χρονικό όριο ελέγχου ταυτότητας έληξε. Δοκιμάστε ξανά.", "opksshAuthFailed": "Ο έλεγχος ταυτότητας απέτυχε. Ελέγξτε τα διαπιστευτήριά σας και προσπαθήστε ξανά.", "opksshSignInWith": "Συνδεθείτε με {{provider}}", + "tailscaleCheckRequired": "Απαιτείται έλεγχος ταυτότητας κλίμακας ουράς", + "tailscaleCheckDescription": "Το Tailscale SSH απαιτεί έναν επιπλέον έλεγχο. Επαληθεύστε τον κωδικό σας στο πρόγραμμα περιήγησής σας για να συνεχίσετε.", + "tailscaleCheckOpenBrowser": "Άνοιγμα προγράμματος περιήγησης για έλεγχο ταυτότητας", + "tailscaleCheckWaiting": "Αναμονή για έλεγχο ταυτότητας Tailscale...", + "tailscaleCheckTimeout": "Το χρονικό όριο ελέγχου ταυτότητας κλίμακας έληξε. Δοκιμάστε ξανά.", "vaultAuthTitle": "Απαιτείται σύνδεση στο Vault", "vaultAuthDescription": "Έχει ανοίξει ένα παράθυρο για να συνδεθείτε στο HashiCorp Vault. Ολοκληρώστε τη σύνδεση εκεί. Αυτή η σύνδεση θα συνεχιστεί αυτόματα.", "vaultAuthFailed": "Ο έλεγχος ταυτότητας του vault απέτυχε. Δοκιμάστε ξανά.", @@ -2145,6 +2203,7 @@ "cpuUsage": "Χρήση CPU", "memoryUsage": "Χρήση μνήμης", "diskUsage": "Χρήση δίσκου", + "selectFilesystem": "Επιλογή συστήματος αρχείων", "temperature": "Θερμοκρασία", "highestTemperature": "Υψηλότερη θερμοκρασία", "failedToFetchHostConfig": "Αποτυχία ανάκτησης διαμόρφωσης κεντρικού υπολογιστή", @@ -2819,6 +2878,7 @@ "updateCommandHistoryFailed": "Αποτυχία ενημέρωσης της ρύθμισης ιστορικού εντολών", "analyticsEnabled": "Κοινοποίηση ανώνυμων στατιστικών χρήσης", "analyticsEnabledDesc": "Αποστέλλει έναν ανώνυμο ημερήσιο αριθμό χρηστών, κεντρικών υπολογιστών και χρήσης λειτουργιών για να βοηθήσει στη βελτίωση του Termix. Δεν περιλαμβάνονται ποτέ προσωπικά δεδομένα ή λεπτομέρειες σύνδεσης.", + "analyticsEnabledLockedDesc": "Αυτή η ρύθμιση είναι κλειδωμένη από τη μεταβλητή περιβάλλοντος ENABLE_TELEMETRY και δεν μπορεί να αλλάξει εδώ.", "updateAnalyticsFailed": "Η ενημέρωση της ρύθμισης αναλυτικών στοιχείων απέτυχε", "sessionSharingGloballyEnabled": "Επιτρέψτε την κοινή χρήση περιόδου σύνδεσης", "sessionSharingGloballyEnabledDesc": "Επιτρέπεται η κοινή χρήση των ζωντανών συνεδριών τερματικού, RDP, VNC και Telnet σε ολόκληρη την παρουσία. Παρακάμπτει κάθε εναλλαγή κοινής χρήσης ανά κεντρικό υπολογιστή όταν είναι απενεργοποιημένη.", @@ -3323,6 +3383,8 @@ "resetToDefaultsSuccess": "Οι ρυθμίσεις επαναφέρθηκαν στις προεπιλογές.", "storageModeSwitch": "Προτιμώμενη αποθήκευση", "sectionAccount": "Λογαριασμός", + "desktopProfileTitle": "Αυτόματο προφίλ τοπικής επιφάνειας εργασίας", + "desktopProfileDescription": "Αυτό το προφίλ περιορίζεται στο ενσωματωμένο backend και συνδέεται αυτόματα. Δεν έχει κωδικό πρόσβασης σύνδεσης. Ο Απομακρυσμένος Συγχρονισμός παρακάτω χρησιμοποιεί ξεχωριστό λογαριασμό διακομιστή.", "sectionAppearance": "Εμφάνιση", "sectionSecurity": "Ασφάλεια", "sectionApiKeys": "Κλειδιά API", @@ -3379,6 +3441,8 @@ "statusColorsDesc": "Χρησιμοποιήστε πράσινο/κόκκινο για την κατάσταση online/offline αντί για το χρώμα έμφασης", "pinAppRail": "Καρφίτσωμα εφαρμογής Rail", "pinAppRailDesc": "Διατηρήστε το αριστερό πλευρικό πλαίσιο εφαρμογής πάντα ανοιχτό αντί να επεκτείνεται κατά την τοποθέτηση του δείκτη του ποντικιού", + "openFullscreenSettings": "Άνοιγμα ρυθμίσεων σε πλήρη οθόνη", + "exitFullscreenSettings": "Έξοδος από τις ρυθμίσεις πλήρους οθόνης", "expandAppRailOnHover": "Ανάπτυξη εφαρμογής Rail κατά την τοποθέτηση του δείκτη του ποντικιού", "expandAppRailOnHoverDesc": "Να επιτρέπεται η επέκταση του κιγκλιδώματος εφαρμογής στην αριστερή πλαϊνή γραμμή όταν ο δείκτης μετακινείται πάνω από αυτό", "settingsNavigation": "Πλοήγηση", diff --git a/src/ui/locales/translated/es_ES.json b/src/ui/locales/translated/es_ES.json index 438bed59..879ff619 100644 --- a/src/ui/locales/translated/es_ES.json +++ b/src/ui/locales/translated/es_ES.json @@ -546,6 +546,7 @@ "sshTools": "Herramientas SSH", "history": "Historial", "sessionLogs": "Registros de sesión", + "sidebarSettings": "Configuración de la barra lateral...", "hosts": "Hosts", "snippets": "Fragmentos", "hostManager": "Gestor de hosts", @@ -755,6 +756,8 @@ "agentSocketPathLabel": "Ruta del socket del agente", "agentSocketPathPlaceholder": "Dejar vacío para usar SSH_AUTH_SOCK", "agentSocketPathHint": "Dejar vacío para detectar automáticamente desde la variable de entorno SSH_AUTH_SOCK, o introduzca una ruta de socket personalizada (p. ej. /run/user/1000/gnupg/S.gpg-agent.ssh).", + "shareSshAuthLabel": "Compartir autenticación SSH", + "shareSshAuthDesc": "Entregue a los destinatarios copias cifradas de la autenticación SSH de este host. La credencial personal del destinatario seguirá teniendo prioridad.", "tailscaleDeviceSelect": "Seleccionar dispositivo Tailscale", "tailscaleDeviceSelectPlaceholder": "Seleccione un dispositivo...", "tailscaleNoApiKey": "No se ha configurado ninguna clave de API de Tailscale. Añada una en Ajustes de administración para habilitar el descubrimiento de dispositivos.", @@ -1173,15 +1176,39 @@ "generateFromPrivateKey": "Generar a partir de clave privada", "refreshBtn2": "Actualizar", "exitSelectionTitle": "Salir de la selección", - "exportAll": "Exportar todo", - "exportForSharing": "Exportar para compartir", "addHostBtn2": "Añadir host", "addCredentialBtn2": "Añadir credencial", "checkingHostStatuses": "Comprobando estados de los hosts...", "pinnedSection": "Fijados", "hostsExported": "Hosts exportados correctamente", - "hostsShareExported": "Hosts compartibles exportados correctamente", - "exportFailed": "Error al exportar hosts", + "export": { + "menuItem": "Exportar...", + "title": "Hosts de exportación", + "scope": "Alcance", + "scopeAll": "Todo", + "scopeSelected": "Seleccionado", + "searchHosts": "Buscar hosts...", + "include": "Incluir", + "groupConnection": "Conexión", + "groupCredentials": "Cartas credenciales", + "groupNotes": "Notas", + "groupTags": "Etiquetas y pines", + "groupTunnels": "Túneles", + "groupJumpHosts": "Anfitriones de salto", + "groupQuickActions": "Acciones rápidas", + "groupFeatureFlags": "Banderas de características", + "groupAdvanced": "Configuración avanzada", + "preview": "Avance", + "moreHosts": "... {{count}} más anfitriones", + "summary": "{{selected}} de {{total}} anfitriones", + "credentialsIncluded": "credenciales incluidas", + "credentialsExcluded": "credenciales excluidas", + "noneSelected": "No se ha seleccionado ningún host.", + "cancel": "Cancelar", + "confirm": "Exportar", + "fetchFailed": "No se pudieron cargar los hosts para la exportación.", + "bulkButton": "Exportar" + }, "sampleDownloaded": "Archivo de muestra descargado", "failedToDeleteCredential2": "Error al eliminar credencial", "noFolderOption": "(Sin carpeta)", @@ -1266,7 +1293,7 @@ }, "edit": { "label": "Editar", - "description": "Ver, además de modificar el host. Los secretos se pueden reemplazar pero nunca leer; las asignaciones de credenciales permanecen solo para el propietario." + "description": "Visualice y modifique la configuración del host que no requiere autenticación. La autenticación SSH del propietario permanece privada y solo accesible para él." }, "manage": { "label": "Gestionar", @@ -1299,7 +1326,24 @@ "sharedBadgeTooltip": "Compartido por {{owner}} (acceso {{level}})", "viewOnlyBanner": "Este host ha sido compartido contigo por {{owner}} con acceso de solo lectura. La configuración es de solo lectura.", "sharedEditBanner": "Este host ha sido compartido contigo por {{owner}} con acceso de edición. Los cambios se aplican al host real; las referencias de autenticación solo pueden ser modificadas por el propietario.", - "ownerOnlyControl": "Solo el propietario del host puede modificar esto" + "ownerOnlyControl": "Solo el propietario del host puede modificar esto", + "ownerAuthPrivate": "La autenticación SSH del propietario del host es privada. Utilice la opción \"Configurar autenticación SSH personal\" del menú del host para elegir sus propias credenciales.", + "ownerAuthShared": "El propietario del host ha compartido la autenticación SSH para este host. Puedes usarla o elegir tus propias credenciales en “Configurar autenticación SSH personal”.", + "authOverrideAction": "Configurar la autenticación SSH personal", + "authOverrideTitle": "Autenticación SSH personal", + "authOverrideDescriptionPrivate": "Las credenciales SSH del propietario del host permanecen privadas. Elija una de sus credenciales guardadas para conectarse a {{host}}.", + "authOverrideDescriptionShared": "Utilice la autenticación compartida por el propietario del host, o reemplácela con una de sus credenciales guardadas para las conexiones a {{host}}.", + "authOverrideCredentialLabel": "Credencial de autenticación", + "useSharedAuthentication": "Utilice la autenticación de host compartido.", + "noPersonalCredential": "Sin credenciales personales", + "authOverrideNoCredentials": "Aún no tienes credenciales SSH guardadas. Crea una en Credenciales para conectarte a los hosts que requieren autenticación.", + "authOverrideRequired": "Este servidor requiere una de tus credenciales guardadas antes de que puedas conectarte.", + "authOverridePrivateHint": "Esta credencial es privada y solo usted puede usarla. El propietario del servidor y otros destinatarios no pueden verla ni usarla.", + "authOverrideSaved": "Autenticación SSH personal guardada", + "authOverrideCleared": "Autenticación SSH personal eliminada", + "authOverrideClearedToShared": "Utilizando la autenticación de host compartido", + "authOverrideLoadError": "No se pudo cargar la autenticación SSH. Inténtelo de nuevo.", + "authOverrideSaveError": "No se pudo guardar la autenticación SSH." }, "guac": { "connection": "Conexión", @@ -1627,6 +1671,15 @@ "tmuxCopyHint": "Ajuste la selección y pulse Intro para copiar al portapapeles", "tmuxDetach": "Desconectar de la sesión tmux", "tmuxDetached": "Desconectado de la sesión tmux", + "searchPlaceholder": "Encontrar", + "searchCaseSensitive": "Caja de cerillas", + "searchWholeWord": "Emparejar palabra completa", + "searchRegex": "Utilizar expresiones regulares", + "searchNoResults": "No hay resultados", + "searchResultCount": "{{index}} de {{count}}", + "searchNext": "Siguiente partido (Entrar)", + "searchPrevious": "Coincidencia anterior (Shift+Enter)", + "searchClose": "Cerrar (Escape)", "maxReconnectAttemptsReached": "Número máximo de intentos de reconexión alcanzado", "closeTab": "Cerrar", "connectionTimeout": "Timeout de conexión", @@ -1654,6 +1707,11 @@ "opksshTimeout": "La autenticación expiró. Inténtalo de nuevo.", "opksshAuthFailed": "La autenticación falló. Revisa tus credenciales e inténtalo de nuevo.", "opksshSignInWith": "Iniciar sesión con {{provider}}", + "tailscaleCheckRequired": "Se requiere autenticación de Tailscale.", + "tailscaleCheckDescription": "Tailscale SSH requiere una verificación adicional. Autentícate en tu navegador para continuar.", + "tailscaleCheckOpenBrowser": "Abra el navegador para autenticarse.", + "tailscaleCheckWaiting": "Esperando la autenticación de Tailscale...", + "tailscaleCheckTimeout": "La autenticación de Tailscale ha caducado. Inténtalo de nuevo.", "vaultAuthTitle": "Inicio de sesión en Vault requerido", "vaultAuthDescription": "Se ha abierto una ventana para iniciar sesión en HashiCorp Vault. Completa el inicio de sesión allí; esta conexión continuará automáticamente.", "vaultAuthFailed": "La autenticación de Vault falló. Inténtalo de nuevo.", @@ -2145,6 +2203,7 @@ "cpuUsage": "Uso de CPU", "memoryUsage": "Uso de memoria", "diskUsage": "Uso del disco", + "selectFilesystem": "Seleccionar sistema de archivos", "temperature": "Temperatura", "highestTemperature": "Temperatura más alta", "failedToFetchHostConfig": "Error al obtener la configuración del host", @@ -2819,6 +2878,7 @@ "updateCommandHistoryFailed": "Error al actualizar la configuración del historial de comandos", "analyticsEnabled": "Compartir estadísticas de uso anónimo", "analyticsEnabledDesc": "Envía un recuento diario anónimo de usuarios, hosts y uso de funciones para ayudar a mejorar Termix. No se incluyen datos personales ni detalles de conexión.", + "analyticsEnabledLockedDesc": "Esta configuración está bloqueada por la variable de entorno ENABLE_TELEMETRY y no se puede cambiar aquí.", "updateAnalyticsFailed": "No se pudo actualizar la configuración de análisis.", "sessionSharingGloballyEnabled": "Permitir compartir la sesión", "sessionSharingGloballyEnabledDesc": "Permite compartir sesiones de terminal en vivo, RDP, VNC y Telnet en toda la instancia. Cuando está desactivado, anula cualquier configuración de uso compartido por host.", @@ -3323,6 +3383,8 @@ "resetToDefaultsSuccess": "Ajustes restablecidos a los valores predeterminados.", "storageModeSwitch": "Almacenamiento de preferencias", "sectionAccount": "Cuenta", + "desktopProfileTitle": "Perfil de escritorio local automático", + "desktopProfileDescription": "Este perfil está restringido al backend integrado e inicia sesión automáticamente. No tiene contraseña de inicio de sesión; la función Sincronización remota que se describe a continuación utiliza una cuenta de servidor independiente.", "sectionAppearance": "Apariencia", "sectionSecurity": "Seguridad", "sectionApiKeys": "Claves API", @@ -3379,6 +3441,8 @@ "statusColorsDesc": "Usar verde/rojo para estado en línea/desconectado en lugar del color de acento", "pinAppRail": "Fijar barra de aplicaciones", "pinAppRailDesc": "Mantener la barra lateral izquierda de aplicaciones siempre expandida en lugar de expandirse al pasar el cursor", + "openFullscreenSettings": "Abrir configuración en pantalla completa", + "exitFullscreenSettings": "Salir de la configuración de pantalla completa", "expandAppRailOnHover": "Expandir barra de aplicaciones al pasar el cursor", "expandAppRailOnHoverDesc": "Permitir que la barra lateral izquierda de aplicaciones se expanda cuando el puntero se mueve sobre ella", "settingsNavigation": "Navegación", diff --git a/src/ui/locales/translated/fi_FI.json b/src/ui/locales/translated/fi_FI.json index 39cd7228..f2d17480 100644 --- a/src/ui/locales/translated/fi_FI.json +++ b/src/ui/locales/translated/fi_FI.json @@ -546,6 +546,7 @@ "sshTools": "SSH-työkalut", "history": "Historia", "sessionLogs": "Istuntolokit", + "sidebarSettings": "Sivupalkin asetukset...", "hosts": "Isännät", "snippets": "Katkelmat", "hostManager": "Isäntäpäällikkö", @@ -755,6 +756,8 @@ "agentSocketPathLabel": "Agentin soketin polku", "agentSocketPathPlaceholder": "Jätä tyhjäksi käyttääksesi SSH_AUTH_SOCKia", "agentSocketPathHint": "Jätä tyhjäksi, jos haluat automaattisen tunnistuksen SSH_AUTH_SOCK-ympäristömuuttujasta, tai anna mukautettu soketin polku (esim. /run/user/1000/gnupg/S.gpg-agent.ssh).", + "shareSshAuthLabel": "Jaa SSH-todennus", + "shareSshAuthDesc": "Anna vastaanottajille salatut kopiot tämän isännän SSH-todennuksesta. Vastaanottajan henkilökohtaiset tunnistetiedot ovat edelleen etusijalla.", "tailscaleDeviceSelect": "Valitse Tailscale-laite", "tailscaleDeviceSelectPlaceholder": "Valitse laite...", "tailscaleNoApiKey": "Tailscale API -avainta ei ole määritetty. Lisää sellainen järjestelmänvalvojan asetuksissa, jotta laitteiden löytäminen on mahdollista.", @@ -1173,15 +1176,39 @@ "generateFromPrivateKey": "Luo yksityisestä avaimesta", "refreshBtn2": "Päivitä", "exitSelectionTitle": "Poistu valinnasta", - "exportAll": "Vie kaikki", - "exportForSharing": "Vie jakamista varten", "addHostBtn2": "Lisää isäntä", "addCredentialBtn2": "Lisää tunnistetiedot", "checkingHostStatuses": "Tarkistetaan isännän tiloja...", "pinnedSection": "Kiinnitetty", "hostsExported": "Isännät vietiin onnistuneesti", - "hostsShareExported": "Jaettavien isäntien vienti onnistui", - "exportFailed": "Isäntien vienti epäonnistui", + "export": { + "menuItem": "Viedä...", + "title": "Vie isännät", + "scope": "Soveltamisala", + "scopeAll": "Kaikki", + "scopeSelected": "Valittu", + "searchHosts": "Hae isäntiä...", + "include": "Sisällytä", + "groupConnection": "Yhteys", + "groupCredentials": "Valtakirjat", + "groupNotes": "Muistiinpanoja", + "groupTags": "Tunnisteet ja kiinnitys", + "groupTunnels": "Tunnelit", + "groupJumpHosts": "Jump-isännät", + "groupQuickActions": "Pikatoiminnot", + "groupFeatureFlags": "Ominaisuusliput", + "groupAdvanced": "Lisäasetukset", + "preview": "Esikatselu", + "moreHosts": "... {{count}} lisää isäntiä", + "summary": "{{selected}} / {{total}} isäntää", + "credentialsIncluded": "valtakirjat sisältyvät", + "credentialsExcluded": "tunnistetiedot pois suljettu", + "noneSelected": "Ei valittuja isäntiä", + "cancel": "Peruuttaa", + "confirm": "Viedä", + "fetchFailed": "Vientiä varten tarkoitettujen isäntien lataaminen epäonnistui", + "bulkButton": "Viedä" + }, "sampleDownloaded": "Näytetiedosto ladattu", "failedToDeleteCredential2": "Tunnuksen poistaminen epäonnistui", "noFolderOption": "(Ei kansiota)", @@ -1266,7 +1293,7 @@ }, "edit": { "label": "Muokata", - "description": "Näytä ja muokkaa isäntää. Salaisuuksia voidaan korvata, mutta niitä ei voida koskaan lukea; tunnistetietojen määritykset pysyvät vain omistajalla." + "description": "Näytä ja muokkaa ei-todennukseen perustuvia isäntäasetuksia. Omistajan SSH-todennus pysyy yksityisenä ja vain omistajalle tarkoitettuna." }, "manage": { "label": "Hallitse", @@ -1299,7 +1326,24 @@ "sharedBadgeTooltip": "Jaettu käyttäjän {{owner}} toimesta ({{level}} pääsy)", "viewOnlyBanner": "{{owner}} jakaa tämän isännän kanssasi ja hänellä on katseluoikeudet. Määritykset ovat vain luku -tilassa.", "sharedEditBanner": "{{owner}} jakaa tämän isännän kanssasi muokkausoikeuksin. Muutokset koskevat varsinaista isäntää; vain omistaja voi muuttaa todennusviitteitä.", - "ownerOnlyControl": "Vain isännän omistaja voi muuttaa tätä" + "ownerOnlyControl": "Vain isännän omistaja voi muuttaa tätä", + "ownerAuthPrivate": "Isännän omistajan SSH-todennus on yksityinen. Valitse omat tunnistetietosi isännän valikosta kohdasta ”Aseta henkilökohtainen SSH-todennus”.", + "ownerAuthShared": "Isännän omistaja on jakanut SSH-todennuksen tälle isännälle. Voit käyttää sitä tai valita omat tunnistetietosi kohdasta ”Aseta henkilökohtainen SSH-todennus”.", + "authOverrideAction": "Aseta henkilökohtainen SSH-todennus", + "authOverrideTitle": "Henkilökohtainen SSH-todennus", + "authOverrideDescriptionPrivate": "Isännän omistajan SSH-tunnukset pysyvät yksityisinä. Valitse jokin tallennetuista tunnistetiedoistasi yhteyksiä varten kohteeseen {{host}}.", + "authOverrideDescriptionShared": "Käytä isännän omistajan jakamaa todennusta tai korvaa se jollakin tallennetuista tunnistetiedoistasi yhteyksiä varten kohteeseen {{host}}.", + "authOverrideCredentialLabel": "Todennustiedot", + "useSharedAuthentication": "Käytä jaetun isännän todennusta", + "noPersonalCredential": "Ei henkilökohtaista tunnistetta", + "authOverrideNoCredentials": "Sinulla ei ole vielä tallennettuja SSH-tunnuksia. Luo sellainen kohdassa Tunnistetiedot, jos haluat muodostaa yhteyden todennusta vaativiin isäntiin.", + "authOverrideRequired": "Tämä isäntä vaatii yhden tallennetuista tunnistetiedoistasi ennen kuin voit muodostaa yhteyden.", + "authOverridePrivateHint": "Nämä tunnistetiedot ovat yksityisiä. Isännän omistaja ja muut vastaanottajat eivät voi nähdä tai käyttää niitä.", + "authOverrideSaved": "Henkilökohtainen SSH-todennus tallennettu", + "authOverrideCleared": "Henkilökohtainen SSH-todennus poistettu", + "authOverrideClearedToShared": "Jaetun isäntätodennuksen käyttäminen", + "authOverrideLoadError": "SSH-todennuksen lataaminen epäonnistui. Yritä uudelleen.", + "authOverrideSaveError": "SSH-todennuksen tallentaminen epäonnistui" }, "guac": { "connection": "Yhteys", @@ -1627,6 +1671,15 @@ "tmuxCopyHint": "Säädä valintaa ja kopioi leikepöydälle painamalla Enter", "tmuxDetach": "Irrota tmux-istunnosta", "tmuxDetached": "Irrotettu tmux-istunnosta", + "searchPlaceholder": "Löytää", + "searchCaseSensitive": "Ottelukotelo", + "searchWholeWord": "Koko sanan täsmäys", + "searchRegex": "Käytä säännöllistä lauseketta", + "searchNoResults": "Ei tuloksia", + "searchResultCount": "{{index}} / {{count}}", + "searchNext": "Seuraava osuma (Enter)", + "searchPrevious": "Edellinen osuma (Vaihto+Enter)", + "searchClose": "Sulje (Esc)", "maxReconnectAttemptsReached": "Yhteyden muodostamisen uudelleenyritysten enimmäismäärä saavutettu", "closeTab": "Lähellä", "connectionTimeout": "Yhteyden aikakatkaisu", @@ -1654,6 +1707,11 @@ "opksshTimeout": "Todennus aikakatkaistiin. Yritä uudelleen.", "opksshAuthFailed": "Todennus epäonnistui. Tarkista tunnistetietosi ja yritä uudelleen.", "opksshSignInWith": "Kirjaudu sisään tunnuksella {{provider}}", + "tailscaleCheckRequired": "Tailscale-todennus vaaditaan", + "tailscaleCheckDescription": "Tailscale SSH vaatii lisätarkistuksen. Jatka tunnistautumalla selaimessasi.", + "tailscaleCheckOpenBrowser": "Avaa selain todennusta varten", + "tailscaleCheckWaiting": "Odotetaan Tailscale-todennusta...", + "tailscaleCheckTimeout": "Tailscale-todennus aikakatkaistiin. Yritä uudelleen.", "vaultAuthTitle": "Holviin kirjautuminen vaaditaan", "vaultAuthDescription": "Ikkuna HashiCorp-holviin kirjautumista varten on avautunut. Kirjaudu sisään siinä. Yhteys jatkuu automaattisesti.", "vaultAuthFailed": "Holvin todennus epäonnistui. Yritä uudelleen.", @@ -2145,6 +2203,7 @@ "cpuUsage": "Suorittimen käyttö", "memoryUsage": "Muistin käyttö", "diskUsage": "Levyn käyttö", + "selectFilesystem": "Valitse tiedostojärjestelmä", "temperature": "Lämpötila", "highestTemperature": "Korkein lämpötila", "failedToFetchHostConfig": "Isännän määritysten nouto epäonnistui", @@ -2819,6 +2878,7 @@ "updateCommandHistoryFailed": "Komentohistoria-asetuksen päivittäminen epäonnistui", "analyticsEnabled": "Jaa anonyymit käyttötilastot", "analyticsEnabledDesc": "Lähettää nimettömän päivittäisen määrän käyttäjistä, isännöistä ja ominaisuuksien käytöstä Termixin parantamiseksi. Henkilötietoja tai yhteystietoja ei koskaan sisällytetä mukaan.", + "analyticsEnabledLockedDesc": "Tämä asetus on lukittu ENABLE_TELEMETRY-ympäristömuuttujalla, eikä sitä voi muuttaa täällä.", "updateAnalyticsFailed": "Analytiikka-asetuksen päivittäminen epäonnistui", "sessionSharingGloballyEnabled": "Salli istunnon jakaminen", "sessionSharingGloballyEnabledDesc": "Salli reaaliaikaisten pääte-, RDP-, VNC- ja Telnet-istuntojen jakaminen koko instanssin kesken. Poistamalla tämän asetuksen käytöstä ohitetaan kaikki isäntäkohtaisesti jakamiseen liittyvät asetukset.", @@ -3323,6 +3383,8 @@ "resetToDefaultsSuccess": "Asetukset palautettu oletusasetuksiin.", "storageModeSwitch": "Ensisijainen tallennustila", "sectionAccount": "Tili", + "desktopProfileTitle": "Automaattinen paikallinen työpöytäprofiili", + "desktopProfileDescription": "Tämä profiili on rajoitettu upotettuun taustajärjestelmään ja kirjautuu sisään automaattisesti. Sillä ei ole kirjautumissalasanoja; alla oleva etäsynkronointi käyttää erillistä palvelintiliä.", "sectionAppearance": "Ulkonäkö", "sectionSecurity": "Turvallisuus", "sectionApiKeys": "API-avaimet", @@ -3379,6 +3441,8 @@ "statusColorsDesc": "Käytä vihreää/punaista online-/offline-tilan ilmaisemiseen korostusvärin sijaan", "pinAppRail": "Kiinnitä sovellusraide", "pinAppRailDesc": "Pidä vasemman sivupalkin sovelluspalkki aina laajennettuna sen sijaan, että se laajenisi hiiren osoittimen vaikutuksesta", + "openFullscreenSettings": "Avaa asetukset koko näytöllä", + "exitFullscreenSettings": "Poistu koko näytön asetuksista", "expandAppRailOnHover": "Laajenna sovellusraita hiiren osoittimen päällä", "expandAppRailOnHoverDesc": "Salli vasemman sivupalkin sovelluskiskon laajentua, kun osoitin liikkuu sen päälle", "settingsNavigation": "Navigointi", diff --git a/src/ui/locales/translated/fr_FR.json b/src/ui/locales/translated/fr_FR.json index e539c0d2..224670bb 100644 --- a/src/ui/locales/translated/fr_FR.json +++ b/src/ui/locales/translated/fr_FR.json @@ -546,6 +546,7 @@ "sshTools": "Outils SSH", "history": "Historique", "sessionLogs": "Journaux de session", + "sidebarSettings": "Paramètres de la barre latérale...", "hosts": "Hôtes", "snippets": "Extraits", "hostManager": "Gestionnaire d'hôtes", @@ -755,6 +756,8 @@ "agentSocketPathLabel": "Chemin du socket de l'agent", "agentSocketPathPlaceholder": "Laisser vide pour utiliser SSH_AUTH_SOCK", "agentSocketPathHint": "Laisser vide pour détecter automatiquement à partir de la variable d'environnement SSH_AUTH_SOCK, ou saisir un chemin de socket personnalisé (ex. /run/user/1000/gnupg/S.gpg-agent.ssh).", + "shareSshAuthLabel": "Authentification SSH partagée", + "shareSshAuthDesc": "Fournissez aux destinataires des copies chiffrées de l'authentification SSH de cet hôte. Les informations d'identification personnelles du destinataire restent prioritaires.", "tailscaleDeviceSelect": "Sélectionner un appareil Tailscale", "tailscaleDeviceSelectPlaceholder": "Sélectionner un appareil...", "tailscaleNoApiKey": "Aucune clé API Tailscale configurée. Ajoutez-en une dans les paramètres d'administration pour activer la découverte d'appareils.", @@ -1173,15 +1176,39 @@ "generateFromPrivateKey": "Générer à partir de la clé privée", "refreshBtn2": "Actualiser", "exitSelectionTitle": "Quitter la sélection", - "exportAll": "Tout exporter", - "exportForSharing": "Exporter pour partage", "addHostBtn2": "Ajouter un hôte", "addCredentialBtn2": "Ajouter un identifiant", "checkingHostStatuses": "Vérification des statuts des hôtes...", "pinnedSection": "Épinglés", "hostsExported": "Hôtes exportés avec succès", - "hostsShareExported": "Hôtes partageables exportés avec succès", - "exportFailed": "Échec de l'exportation des hôtes", + "export": { + "menuItem": "Exporter...", + "title": "Hôtes d'exportation", + "scope": "Portée", + "scopeAll": "Tous", + "scopeSelected": "Choisi", + "searchHosts": "Rechercher des hôtes...", + "include": "Inclure", + "groupConnection": "Connexion", + "groupCredentials": "Informations d'identification", + "groupNotes": "Notes", + "groupTags": "Étiquettes et épingles", + "groupTunnels": "Tunnels", + "groupJumpHosts": "Hôtes de saut", + "groupQuickActions": "Actions rapides", + "groupFeatureFlags": "drapeaux de fonctionnalités", + "groupAdvanced": "Configuration avancée", + "preview": "Aperçu", + "moreHosts": "... {{count}} plus d'hôtes", + "summary": "{{selected}} des hôtes {{total}}", + "credentialsIncluded": "les titres de compétences comprenaient", + "credentialsExcluded": "identifiants exclus", + "noneSelected": "Aucun hôte sélectionné", + "cancel": "Annuler", + "confirm": "Exporter", + "fetchFailed": "Échec du chargement des hôtes pour l'exportation", + "bulkButton": "Exporter" + }, "sampleDownloaded": "Fichier d'exemple téléchargé", "failedToDeleteCredential2": "Échec de la suppression de l'identifiant", "noFolderOption": "(Aucun dossier)", @@ -1266,7 +1293,7 @@ }, "edit": { "label": "Modification", - "description": "Consultation, plus modification de l'hôte. Les secrets peuvent être remplacés mais jamais lus ; les attributions d’identifiants restent réservées au propriétaire." + "description": "Consultez et modifiez les paramètres de l'hôte sans authentification. L'authentification SSH du propriétaire reste privée et accessible uniquement à ce dernier." }, "manage": { "label": "Gestion", @@ -1299,7 +1326,24 @@ "sharedBadgeTooltip": "Partagé par {{owner}} (accès {{level}})", "viewOnlyBanner": "Cet hôte vous est partagé par {{owner}} avec un accès en lecture seule. La configuration est en lecture seule.", "sharedEditBanner": "Cet hôte vous est partagé par {{owner}} avec un accès en modification. Les modifications s'appliquent à l'hôte réel ; les références d'authentification ne peuvent être modifiées que par le propriétaire.", - "ownerOnlyControl": "Modifiable uniquement par le propriétaire de l’hôte" + "ownerOnlyControl": "Modifiable uniquement par le propriétaire de l’hôte", + "ownerAuthPrivate": "L'authentification SSH du propriétaire de l'hôte est privée. Utilisez l'option « Définir l'authentification SSH personnelle » du menu de l'hôte pour choisir vos propres identifiants.", + "ownerAuthShared": "L'administrateur de cet hôte a partagé l'authentification SSH. Vous pouvez l'utiliser ou choisir vos propres identifiants dans « Définir l'authentification SSH personnelle ».", + "authOverrideAction": "Configurer l'authentification SSH personnelle", + "authOverrideTitle": "Authentification SSH personnelle", + "authOverrideDescriptionPrivate": "Les identifiants SSH du propriétaire de l'hôte restent privés. Choisissez l'un de vos identifiants enregistrés pour les connexions à {{host}}.", + "authOverrideDescriptionShared": "Utilisez l'authentification partagée par le propriétaire de l'hôte, ou remplacez-la par l'une de vos informations d'identification enregistrées pour les connexions à {{host}}.", + "authOverrideCredentialLabel": "Identifiants d'authentification", + "useSharedAuthentication": "Utiliser l'authentification d'hôte partagé", + "noPersonalCredential": "Aucune accréditation personnelle", + "authOverrideNoCredentials": "Vous n'avez pas encore enregistré d'identifiants SSH. Créez-en un dans la section « Identifiants » pour vous connecter aux hôtes nécessitant une authentification.", + "authOverrideRequired": "Cet hôte exige l'un de vos identifiants enregistrés avant que vous puissiez vous connecter.", + "authOverridePrivateHint": "Ces identifiants sont privés et ne peuvent être ni vus ni utilisés par l'hôte ni par les autres destinataires.", + "authOverrideSaved": "Authentification SSH personnelle enregistrée", + "authOverrideCleared": "Authentification SSH personnelle supprimée", + "authOverrideClearedToShared": "Authentification via hôte partagé", + "authOverrideLoadError": "Impossible de charger votre authentification SSH. Veuillez réessayer.", + "authOverrideSaveError": "Impossible d'enregistrer votre authentification SSH" }, "guac": { "connection": "Connexion", @@ -1627,6 +1671,15 @@ "tmuxCopyHint": "Ajustez la sélection et appuyez sur Entrée pour copier dans le presse-papiers", "tmuxDetach": "Se détacher de la session tmux", "tmuxDetached": "Détaché de la session tmux", + "searchPlaceholder": "Trouver", + "searchCaseSensitive": "Boîte d'allumettes", + "searchWholeWord": "Associer le mot entier", + "searchRegex": "Utilisez une expression régulière", + "searchNoResults": "Aucun résultat", + "searchResultCount": "{{index}} de {{count}}", + "searchNext": "Prochain match (Entrée)", + "searchPrevious": "Match précédent (Maj+Entrée)", + "searchClose": "Fermer (Échap)", "maxReconnectAttemptsReached": "Nombre maximal de tentatives de reconnexion atteint", "closeTab": "Fermer", "connectionTimeout": "Délai de connexion dépassé", @@ -1654,6 +1707,11 @@ "opksshTimeout": "L'authentification a expiré. Veuillez réessayer.", "opksshAuthFailed": "Échec de l'authentification. Veuillez vérifier vos identifiants et réessayer.", "opksshSignInWith": "Se connecter avec {{provider}}", + "tailscaleCheckRequired": "Authentification Tailscale requise", + "tailscaleCheckDescription": "Tailscale SSH requiert une vérification supplémentaire. Authentifiez-vous dans votre navigateur pour continuer.", + "tailscaleCheckOpenBrowser": "Ouvrez votre navigateur pour vous authentifier.", + "tailscaleCheckWaiting": "En attente de l'authentification Tailscale...", + "tailscaleCheckTimeout": "L'authentification Tailscale a expiré. Veuillez réessayer.", "vaultAuthTitle": "Connexion à Vault requise", "vaultAuthDescription": "Une fenêtre s'est ouverte pour vous connecter à HashiCorp Vault. Terminez la connexion dans cette fenêtre ; cette connexion continuera automatiquement.", "vaultAuthFailed": "Échec de l'authentification Vault. Veuillez réessayer.", @@ -2145,6 +2203,7 @@ "cpuUsage": "Utilisation CPU", "memoryUsage": "Utilisation mémoire", "diskUsage": "Utilisation disque", + "selectFilesystem": "Sélectionner le système de fichiers", "temperature": "Température", "highestTemperature": "Température maximale", "failedToFetchHostConfig": "Échec de la récupération de la configuration de l'hôte", @@ -2819,6 +2878,7 @@ "updateCommandHistoryFailed": "Échec de la mise à jour du paramètre d'historique des commandes", "analyticsEnabled": "Statistiques d'utilisation anonymes partagées", "analyticsEnabledDesc": "Envoie quotidiennement un décompte anonyme des utilisateurs, des hôtes et de l'utilisation des fonctionnalités afin d'améliorer Termix. Aucune donnée personnelle ni information de connexion n'est jamais incluse.", + "analyticsEnabledLockedDesc": "Ce paramètre est verrouillé par la variable d'environnement ENABLE_TELEMETRY et ne peut pas être modifié ici.", "updateAnalyticsFailed": "Échec de la mise à jour des paramètres d'analyse", "sessionSharingGloballyEnabled": "Autoriser le partage de session", "sessionSharingGloballyEnabledDesc": "Autorise le partage des sessions de terminal en direct, RDP, VNC et Telnet à l'échelle de l'instance. Remplace toute option de partage par hôte lorsqu'elle est désactivée.", @@ -3323,6 +3383,8 @@ "resetToDefaultsSuccess": "Paramètres réinitialisés aux valeurs par défaut.", "storageModeSwitch": "Stockage des préférences", "sectionAccount": "Compte", + "desktopProfileTitle": "Profil de bureau local automatique", + "desktopProfileDescription": "Ce profil est limité au système intégré et la connexion est automatique. Il ne nécessite aucun mot de passe ; la synchronisation à distance décrite ci-dessous utilise un compte serveur distinct.", "sectionAppearance": "Apparence", "sectionSecurity": "Sécurité", "sectionApiKeys": "Clés API", @@ -3379,6 +3441,8 @@ "statusColorsDesc": "Utiliser vert/rouge pour l’état en ligne/hors ligne au lieu de la couleur d’accentuation", "pinAppRail": "Épingler la barre d’applications", "pinAppRailDesc": "Garder la barre d’applications de la barre latérale gauche toujours développée au lieu de se développer au survol", + "openFullscreenSettings": "Ouvrir les paramètres en plein écran", + "exitFullscreenSettings": "Quitter les paramètres plein écran", "expandAppRailOnHover": "Développer la barre d’applications au survol", "expandAppRailOnHoverDesc": "Permettre à la barre d’applications de la barre latérale gauche de se développer lorsque le pointeur la survole", "settingsNavigation": "Navigation", diff --git a/src/ui/locales/translated/he_IL.json b/src/ui/locales/translated/he_IL.json index 24bfc958..b018789b 100644 --- a/src/ui/locales/translated/he_IL.json +++ b/src/ui/locales/translated/he_IL.json @@ -546,6 +546,7 @@ "sshTools": "כלי SSH", "history": "הִיסטוֹרִיָה", "sessionLogs": "יומני סשן", + "sidebarSettings": "הגדרות סרגל צד...", "hosts": "מארחים", "snippets": "קטעי טקסט", "hostManager": "מנהל מארח", @@ -755,6 +756,8 @@ "agentSocketPathLabel": "נתיב שקע הסוכן", "agentSocketPathPlaceholder": "השאר ריק כדי להשתמש ב-SSH_AUTH_SOCK", "agentSocketPathHint": "השאר ריק כדי לזהות אוטומטית ממשתנה הסביבה SSH_AUTH_SOCK, או הזן נתיב socket מותאם אישית (לדוגמה /run/user/1000/gnupg/S.gpg-agent.ssh).", + "shareSshAuthLabel": "שתף אימות SSH", + "shareSshAuthDesc": "תן לנמענים עותקים מוצפנים של אימות SSH של מארח זה. אישורים אישיים של הנמען עדיין מקבלים עדיפות.", "tailscaleDeviceSelect": "בחר מכשיר Tailscale", "tailscaleDeviceSelectPlaceholder": "בחר מכשיר...", "tailscaleNoApiKey": "לא הוגדר מפתח API של Tailscale. הוסף אחד בהגדרות הניהול כדי לאפשר גילוי מכשירים.", @@ -1173,15 +1176,39 @@ "generateFromPrivateKey": "יצירה ממפתח פרטי", "refreshBtn2": "לְרַעֲנֵן", "exitSelectionTitle": "יציאה מבחירה", - "exportAll": "ייצוא הכל", - "exportForSharing": "ייצוא לשיתוף", "addHostBtn2": "הוסף מארח", "addCredentialBtn2": "הוסף אישור", "checkingHostStatuses": "בודק סטטוסים של מארחים...", "pinnedSection": "מוצמד", "hostsExported": "מארחים יוצאו בהצלחה", - "hostsShareExported": "מארחים הניתנים לשיתוף יוצאו בהצלחה", - "exportFailed": "ייצוא המארחים נכשל", + "export": { + "menuItem": "יְצוּא...", + "title": "ייצוא מארחים", + "scope": "תְחוּם", + "scopeAll": "כֹּל", + "scopeSelected": "נִבחָר", + "searchHosts": "חיפוש מארחים...", + "include": "לִכלוֹל", + "groupConnection": "קֶשֶׁר", + "groupCredentials": "אישורים", + "groupNotes": "הערות", + "groupTags": "תגיות וסיכה", + "groupTunnels": "מנהרות", + "groupJumpHosts": "מארחים קפיציים", + "groupQuickActions": "פעולות מהירות", + "groupFeatureFlags": "דגלי תכונה", + "groupAdvanced": "תצורה מתקדמת", + "preview": "תצוגה מקדימה", + "moreHosts": "... {{count}} מארחים נוספים", + "summary": "{{selected}} מתוך {{total}} מארחים", + "credentialsIncluded": "אישורים כלולים", + "credentialsExcluded": "פרטי גישה לא כלולים", + "noneSelected": "לא נבחרו מארחים", + "cancel": "לְבַטֵל", + "confirm": "יְצוּא", + "fetchFailed": "נכשלה טעינת המארחים לייצוא", + "bulkButton": "יְצוּא" + }, "sampleDownloaded": "קובץ לדוגמה שהורד", "failedToDeleteCredential2": "מחיקת האישורים נכשלה", "noFolderOption": "(אין תיקייה)", @@ -1266,7 +1293,7 @@ }, "edit": { "label": "לַעֲרוֹך", - "description": "הצג ושנה את המארח. ניתן להחליף סודות אך לעולם לא לקרוא אותם; הקצאות אישורים נשארות לבעלים בלבד." + "description": "הצג ושנה הגדרות של מארח שאינו קשור לאימות. אימות ה-SSH של הבעלים נשאר פרטי ולבעלים בלבד." }, "manage": { "label": "לְנַהֵל", @@ -1299,7 +1326,24 @@ "sharedBadgeTooltip": "משותף על ידי {{owner}} (גישה ל-{{level}})", "viewOnlyBanner": "מארח זה משותף איתך על ידי {{owner}} עם גישת צפייה. התצורה היא לקריאה בלבד.", "sharedEditBanner": "מארח זה משותף איתך על ידי {{owner}} עם גישת עריכה. השינויים חלים על המארח האמיתי; רק הבעלים יכול לשנות הפניות לאימות.", - "ownerOnlyControl": "רק בעל המארח יכול לשנות זאת" + "ownerOnlyControl": "רק בעל המארח יכול לשנות זאת", + "ownerAuthPrivate": "אימות SSH של בעל המארח הוא פרטי. השתמש ב\"הגדר אימות SSH אישי\" מתפריט המארח כדי לבחור את האישור שלך.", + "ownerAuthShared": "בעל המארח שיתף אימות SSH עבור מארח זה. באפשרותך להשתמש בו או לבחור אישור משלך מתוך \"הגדר אימות SSH אישי\".", + "authOverrideAction": "הגדר אימות SSH אישי", + "authOverrideTitle": "אימות SSH אישי", + "authOverrideDescriptionPrivate": "פרטי הגישה של בעל המארח ל-SSH נשארים פרטיים. בחר אחד מהפרטים השמורים שלך עבור חיבורים אל {{host}}.", + "authOverrideDescriptionShared": "השתמש באימות המשותף על ידי בעל המארח, או החלף אותו באחת מהאישורים השמורים שלך עבור חיבורים אל {{host}}.", + "authOverrideCredentialLabel": "אישורי אימות", + "useSharedAuthentication": "השתמש באימות מארח משותף", + "noPersonalCredential": "אין אישור אישי", + "authOverrideNoCredentials": "עדיין אין לך אישורי SSH שמורים. צור אחד ב'אישרורים' כדי להתחבר למארחים הדורשים אימות.", + "authOverrideRequired": "מארח זה דורש אחד מהפרטים השמורים שלך לפני שתוכל להתחבר.", + "authOverridePrivateHint": "אישור זה פרטי עבורך. בעל המארח ונמענים אחרים אינם יכולים לראותו או להשתמש בו.", + "authOverrideSaved": "אימות SSH אישי נשמר", + "authOverrideCleared": "אימות SSH אישי הוסר", + "authOverrideClearedToShared": "שימוש באימות מארח משותף", + "authOverrideLoadError": "טעינת אימות ה-SSH שלך נכשלה. אנא נסה שוב.", + "authOverrideSaveError": "נכשלה שמירת אימות ה-SSH שלך" }, "guac": { "connection": "קֶשֶׁר", @@ -1627,6 +1671,15 @@ "tmuxCopyHint": "התאם את הבחירה ולחץ על Enter כדי להעתיק ללוח", "tmuxDetach": "ניתוק מסשן tmux", "tmuxDetached": "מנותק מסשן tmux", + "searchPlaceholder": "לִמצוֹא", + "searchCaseSensitive": "מארז התאמה", + "searchWholeWord": "התאמת מילה שלמה", + "searchRegex": "השתמש בביטוי רגולרי", + "searchNoResults": "אין תוצאות", + "searchResultCount": "{{index}} מתוך {{count}}", + "searchNext": "המשחק הבא (Enter)", + "searchPrevious": "התאמה קודמת (Shift+Enter)", + "searchClose": "סגור (Escape)", "maxReconnectAttemptsReached": "הגעת למספר המקסימלי של ניסיונות חיבור מחדש", "closeTab": "לִסְגוֹר", "connectionTimeout": "זמן קצוב לחיבור", @@ -1654,6 +1707,11 @@ "opksshTimeout": "הזמן שהוקצב לאימות הסתיים. אנא נסה שוב.", "opksshAuthFailed": "האימות נכשל. אנא בדוק את פרטי הגישה שלך ונסה שוב.", "opksshSignInWith": "התחבר באמצעות {{provider}}", + "tailscaleCheckRequired": "נדרש אימות של Tailscale", + "tailscaleCheckDescription": "‏SSH של Tailscale דורש בדיקה נוספת. יש לבצע אימות בדפדפן כדי להמשיך.", + "tailscaleCheckOpenBrowser": "פתח את הדפדפן כדי לאמת", + "tailscaleCheckWaiting": "ממתין לאימות Tailscale...", + "tailscaleCheckTimeout": "אימות Tailscale הסתיים. אנא נסה שוב.", "vaultAuthTitle": "נדרשת כניסה לכספת", "vaultAuthDescription": "נפתח חלון לכניסה לכספת HashiCorp. השלם את תהליך הכניסה שם; חיבור זה ימשיך אוטומטית.", "vaultAuthFailed": "אימות הכספת נכשל. אנא נסה שוב.", @@ -2145,6 +2203,7 @@ "cpuUsage": "שימוש במעבד", "memoryUsage": "שימוש בזיכרון", "diskUsage": "שימוש בדיסק", + "selectFilesystem": "בחירת מערכת קבצים", "temperature": "טֶמפֶּרָטוּרָה", "highestTemperature": "הטמפרטורה הגבוהה ביותר", "failedToFetchHostConfig": "נכשל באחזור תצורת המארח", @@ -2819,6 +2878,7 @@ "updateCommandHistoryFailed": "נכשל עדכון הגדרת היסטוריית הפקודות", "analyticsEnabled": "שתף סטטיסטיקות שימוש אנונימיות", "analyticsEnabledDesc": "שולח ספירה יומית אנונימית של משתמשים, מארחים ושימוש בתכונות כדי לסייע בשיפור Termix. לא כלולים נתונים אישיים או פרטי חיבור.", + "analyticsEnabledLockedDesc": "הגדרה זו נעולה על ידי משתנה הסביבה ENABLE_TELEMETRY ולא ניתן לשנות אותה כאן.", "updateAnalyticsFailed": "נכשל עדכון הגדרת הניתוח", "sessionSharingGloballyEnabled": "אפשר שיתוף סשנים", "sessionSharingGloballyEnabledDesc": "מאפשר שיתוף של הפעלות מסוף חי, RDP, VNC ו-Telnet בכל המופעים. מבטל כל אפשרות שיתוף לפי מארח כאשר היא מושבתת.", @@ -3323,6 +3383,8 @@ "resetToDefaultsSuccess": "ההגדרות מאופסות לברירות המחדל.", "storageModeSwitch": "אחסון העדפות", "sectionAccount": "חֶשְׁבּוֹן", + "desktopProfileTitle": "פרופיל שולחן עבודה מקומי אוטומטי", + "desktopProfileDescription": "פרופיל זה מוגבל לשרת האחורי המוטמע ומתחבר אוטומטית. אין לו סיסמת התחברות; הסנכרון מרחוק שלמטה משתמש בחשבון שרת נפרד.", "sectionAppearance": "הוֹפָעָה", "sectionSecurity": "בִּטָחוֹן", "sectionApiKeys": "מפתחות API", @@ -3379,6 +3441,8 @@ "statusColorsDesc": "השתמש בירוק/אדום עבור סטטוס מקוון/לא מקוון במקום בצבע הדגשה", "pinAppRail": "אפליקציית Pin Rail", "pinAppRailDesc": "השאר את פס האפליקציה של הצד השמאלי פתוח תמיד במקום להרחיב בעת ריחוף", + "openFullscreenSettings": "פתיחת הגדרות במסך מלא", + "exitFullscreenSettings": "יציאה מהגדרות מסך מלא", "expandAppRailOnHover": "הרחב את מסילת האפליקציה בעת ריחוף", "expandAppRailOnHoverDesc": "אפשר לפס האפליקציה של סרגל הצד השמאלי להתרחב כאשר המצביע זז מעליו", "settingsNavigation": "ניווט", diff --git a/src/ui/locales/translated/hi_IN.json b/src/ui/locales/translated/hi_IN.json index af81e18e..8042a882 100644 --- a/src/ui/locales/translated/hi_IN.json +++ b/src/ui/locales/translated/hi_IN.json @@ -546,6 +546,7 @@ "sshTools": "SSH टूल्स", "history": "इतिहास", "sessionLogs": "सत्र लॉग्स", + "sidebarSettings": "साइडबार सेटिंग्स...", "hosts": "होस्ट्स", "snippets": "स्निपेट्स", "hostManager": "होस्ट प्रबंधक", @@ -755,6 +756,8 @@ "agentSocketPathLabel": "एजेंट सॉकेट पथ", "agentSocketPathPlaceholder": "SSH_AUTH_SOCK का उपयोग करने के लिए खाली छोड़ें", "agentSocketPathHint": "SSH_AUTH_SOCK पर्यावरण चर से स्वतः पता लगाने के लिए खाली छोड़ें, या एक कस्टम सॉकेट पथ दर्ज करें (जैसे /run/user/1000/gnupg/S.gpg-agent.ssh)।", + "shareSshAuthLabel": "SSH प्रमाणीकरण साझा करें", + "shareSshAuthDesc": "प्राप्तकर्ताओं को इस होस्ट के SSH प्रमाणीकरण की एन्क्रिप्टेड प्रतियां प्रदान करें। प्राप्तकर्ता के व्यक्तिगत क्रेडेंशियल को ही प्राथमिकता दी जाएगी।", "tailscaleDeviceSelect": "Tailscale डिवाइस चुनें", "tailscaleDeviceSelectPlaceholder": "एक डिवाइस चुनें...", "tailscaleNoApiKey": "कोई Tailscale API कुंजी कॉन्फ़िगर नहीं की गई है। डिवाइस खोज सक्षम करने के लिए व्यवस्थापक सेटिंग में एक जोड़ें।", @@ -1173,15 +1176,39 @@ "generateFromPrivateKey": "प्राइवेट की से जनरेट करें", "refreshBtn2": "रीफ़्रेश", "exitSelectionTitle": "चयन से बाहर निकलें", - "exportAll": "सभी निर्यात करें", - "exportForSharing": "साझा करने के लिए निर्यात करें", "addHostBtn2": "होस्ट जोड़ें", "addCredentialBtn2": "क्रेडेंशियल जोड़ें", "checkingHostStatuses": "होस्ट की स्थितियाँ जाँच रहे हैं...", "pinnedSection": "पिन किए गए", "hostsExported": "होस्ट सफलतापूर्वक निर्यात किए गए", - "hostsShareExported": "साझा करने योग्य होस्ट सफलतापूर्वक निर्यात किए गए", - "exportFailed": "होस्ट निर्यात करने में विफल", + "export": { + "menuItem": "निर्यात करना...", + "title": "निर्यात होस्ट", + "scope": "दायरा", + "scopeAll": "सभी", + "scopeSelected": "चयनित", + "searchHosts": "होस्ट खोजें...", + "include": "शामिल करना", + "groupConnection": "संबंध", + "groupCredentials": "साख", + "groupNotes": "नोट्स", + "groupTags": "टैग और पिन", + "groupTunnels": "सुरंगों", + "groupJumpHosts": "जंप होस्ट", + "groupQuickActions": "त्वरित कार्रवाइयां", + "groupFeatureFlags": "फ़ीचर फ़्लैग", + "groupAdvanced": "उन्नत कॉन्फ़िगरेशन", + "preview": "पूर्व दर्शन", + "moreHosts": "... {{count}} अधिक होस्ट", + "summary": "{{selected}} में से {{total}} मेजबान", + "credentialsIncluded": "प्रमाण-पत्रों में शामिल हैं", + "credentialsExcluded": "क्रेडेंशियल्स को बाहर रखा गया है", + "noneSelected": "कोई होस्ट चयनित नहीं", + "cancel": "रद्द करना", + "confirm": "निर्यात", + "fetchFailed": "निर्यात के लिए होस्ट लोड करने में विफल", + "bulkButton": "निर्यात" + }, "sampleDownloaded": "नमूना फ़ाइल डाउनलोड की गई", "failedToDeleteCredential2": "क्रेडेंशियल हटाने में विफल", "noFolderOption": "(कोई फ़ोल्डर नहीं)", @@ -1266,7 +1293,7 @@ }, "edit": { "label": "संपादित करें", - "description": "देखें, और होस्ट को संशोधित करें। सीक्रेट बदले जा सकते हैं लेकिन कभी पढ़े नहीं जाते; क्रेडेंशियल असाइनमेंट केवल स्वामी के लिए रहते हैं।" + "description": "आप बिना प्रमाणीकरण वाले होस्ट की सेटिंग्स देख सकते हैं और उनमें बदलाव कर सकते हैं। मालिक का SSH प्रमाणीकरण गोपनीय रहता है और केवल मालिक के लिए ही उपलब्ध होता है।" }, "manage": { "label": "प्रबंधित करें", @@ -1299,7 +1326,24 @@ "sharedBadgeTooltip": "{{owner}} द्वारा साझा ({{level}} पहुँच)", "viewOnlyBanner": "यह होस्ट आपके साथ {{owner}} द्वारा दृश्य पहुँच के साथ साझा किया गया है। कॉन्फ़िगरेशन केवल-पठनीय है।", "sharedEditBanner": "यह होस्ट आपके साथ {{owner}} द्वारा संपादन पहुँच के साथ साझा किया गया है। परिवर्तन वास्तविक होस्ट पर लागू होते हैं; प्रमाणीकरण संदर्भ केवल स्वामी द्वारा बदले जा सकते हैं।", - "ownerOnlyControl": "केवल होस्ट स्वामी ही इसे बदल सकता है" + "ownerOnlyControl": "केवल होस्ट स्वामी ही इसे बदल सकता है", + "ownerAuthPrivate": "होस्ट के मालिक का SSH प्रमाणीकरण निजी है। अपने क्रेडेंशियल चुनने के लिए होस्ट मेनू से \"व्यक्तिगत SSH प्रमाणीकरण सेट करें\" विकल्प का उपयोग करें।", + "ownerAuthShared": "होस्ट के स्वामी ने इस होस्ट के लिए साझा SSH प्रमाणीकरण सेट किया है। आप इसका उपयोग कर सकते हैं या \"व्यक्तिगत SSH प्रमाणीकरण सेट करें\" से अपने स्वयं के क्रेडेंशियल चुन सकते हैं।", + "authOverrideAction": "व्यक्तिगत SSH प्रमाणीकरण सेट करें", + "authOverrideTitle": "व्यक्तिगत SSH प्रमाणीकरण", + "authOverrideDescriptionPrivate": "होस्ट मालिक के SSH क्रेडेंशियल गोपनीय रहते हैं। {{host}} से कनेक्शन के लिए अपने सहेजे गए क्रेडेंशियल में से एक चुनें।", + "authOverrideDescriptionShared": "होस्ट स्वामी द्वारा साझा किए गए प्रमाणीकरण का उपयोग करें, या {{host}} से कनेक्शन के लिए इसे अपने सहेजे गए क्रेडेंशियल्स में से किसी एक से बदलें।", + "authOverrideCredentialLabel": "प्रमाणीकरण क्रेडेंशियल", + "useSharedAuthentication": "साझा होस्ट प्रमाणीकरण का उपयोग करें", + "noPersonalCredential": "कोई व्यक्तिगत पहचान पत्र नहीं", + "authOverrideNoCredentials": "आपके पास अभी तक कोई सेव्ड SSH क्रेडेंशियल नहीं है। प्रमाणीकरण की आवश्यकता वाले होस्ट से कनेक्ट करने के लिए क्रेडेंशियल फ़ोल्डर में एक क्रेडेंशियल बनाएं।", + "authOverrideRequired": "इस होस्ट को कनेक्ट करने से पहले आपके सहेजे गए क्रेडेंशियल्स में से एक की आवश्यकता होगी।", + "authOverridePrivateHint": "यह क्रेडेंशियल आपके लिए निजी है। होस्ट का मालिक और अन्य प्राप्तकर्ता इसे देख या उपयोग नहीं कर सकते।", + "authOverrideSaved": "व्यक्तिगत SSH प्रमाणीकरण सहेज लिया गया", + "authOverrideCleared": "व्यक्तिगत SSH प्रमाणीकरण हटा दिया गया", + "authOverrideClearedToShared": "साझा होस्ट प्रमाणीकरण का उपयोग करना", + "authOverrideLoadError": "आपका SSH प्रमाणीकरण लोड करने में विफल रहा। कृपया पुनः प्रयास करें।", + "authOverrideSaveError": "आपका SSH प्रमाणीकरण सहेजने में विफल रहा।" }, "guac": { "connection": "कनेक्शन", @@ -1627,6 +1671,15 @@ "tmuxCopyHint": "चयन समायोजित करें और क्लिपबोर्ड पर कॉपी करने के लिए Enter दबाएं", "tmuxDetach": "tmux सत्र से डिटैच करें", "tmuxDetached": "tmux सत्र से डिटैच हो गया", + "searchPlaceholder": "खोजो", + "searchCaseSensitive": "मामले मिलाएं", + "searchWholeWord": "पूरे शब्द का मिलान करें", + "searchRegex": "नियमित अभिव्यक्ति का उपयोग करें", + "searchNoResults": "कोई परिणाम नहीं", + "searchResultCount": "{{index}} का {{count}}", + "searchNext": "अगला मैच (प्रवेश करें)", + "searchPrevious": "पिछला मैच (शिफ्ट+एंटर)", + "searchClose": "बंद करें (एस्केप)", "maxReconnectAttemptsReached": "अधिकतम पुनः कनेक्ट प्रयास पूर्ण हो गए", "closeTab": "बंद करें", "connectionTimeout": "कनेक्शन टाइमआउट", @@ -1654,6 +1707,11 @@ "opksshTimeout": "प्रमाणीकरण का समय समाप्त हो गया। कृपया पुनः प्रयास करें।", "opksshAuthFailed": "प्रमाणीकरण विफल। कृपया अपने क्रेडेंशियल जाँचें और पुनः प्रयास करें।", "opksshSignInWith": "{{provider}} से साइन इन करें", + "tailscaleCheckRequired": "टेलस्केल प्रमाणीकरण आवश्यक है", + "tailscaleCheckDescription": "Tailscale SSH के लिए एक अतिरिक्त जांच की आवश्यकता है। जारी रखने के लिए अपने ब्राउज़र में प्रमाणीकरण करें।", + "tailscaleCheckOpenBrowser": "प्रमाणीकरण के लिए ब्राउज़र खोलें", + "tailscaleCheckWaiting": "टेलस्केल प्रमाणीकरण की प्रतीक्षा की जा रही है...", + "tailscaleCheckTimeout": "टेलस्केल प्रमाणीकरण का समय समाप्त हो गया। कृपया पुनः प्रयास करें।", "vaultAuthTitle": "Vault साइन-इन आवश्यक है", "vaultAuthDescription": "HashiCorp Vault में साइन इन करने के लिए एक विंडो खुल गई है। वहाँ साइन-इन पूरा करें; यह कनेक्शन स्वचालित रूप से जारी रहेगा।", "vaultAuthFailed": "Vault प्रमाणीकरण विफल। कृपया पुनः प्रयास करें।", @@ -2145,6 +2203,7 @@ "cpuUsage": "CPU उपयोग", "memoryUsage": "मेमोरी उपयोग", "diskUsage": "डिस्क उपयोग", + "selectFilesystem": "फ़ाइल सिस्टम का चयन करें", "temperature": "तापमान", "highestTemperature": "उच्चतम तापमान", "failedToFetchHostConfig": "होस्ट कॉन्फ़िगरेशन प्राप्त करने में विफल", @@ -2819,6 +2878,7 @@ "updateCommandHistoryFailed": "कमांड इतिहास सेटिंग अपडेट करने में विफल", "analyticsEnabled": "अनाम उपयोग के आँकड़े साझा करें", "analyticsEnabledDesc": "यह Termix को बेहतर बनाने में मदद करने के लिए उपयोगकर्ताओं, होस्टों और फ़ीचर उपयोग की दैनिक गुमनाम गणना भेजता है। इसमें कभी भी कोई व्यक्तिगत डेटा या कनेक्शन विवरण शामिल नहीं किया जाता है।", + "analyticsEnabledLockedDesc": "यह सेटिंग ENABLE_TELEMETRY पर्यावरण चर द्वारा लॉक की गई है और इसे यहां बदला नहीं जा सकता है।", "updateAnalyticsFailed": "एनालिटिक्स सेटिंग को अपडेट करने में विफल", "sessionSharingGloballyEnabled": "सेशन शेयरिंग की अनुमति दें", "sessionSharingGloballyEnabledDesc": "लाइव टर्मिनल, RDP, VNC और Telnet सेशन को पूरे इंस्टेंस में साझा करने की अनुमति दें। अक्षम होने पर यह प्रति-होस्ट साझाकरण टॉगल को ओवरराइड कर देता है।", @@ -3323,6 +3383,8 @@ "resetToDefaultsSuccess": "सेटिंग्स डिफ़ॉल्ट पर रीसेट कर दी गई हैं।", "storageModeSwitch": "वरीयता संग्रहण", "sectionAccount": "खाता", + "desktopProfileTitle": "स्वचालित स्थानीय डेस्कटॉप प्रोफ़ाइल", + "desktopProfileDescription": "यह प्रोफ़ाइल केवल एम्बेडेड बैकएंड तक सीमित है और स्वचालित रूप से साइन इन हो जाती है। इसमें कोई लॉगिन पासवर्ड नहीं है; नीचे दिया गया रिमोट सिंक एक अलग सर्वर खाते का उपयोग करता है।", "sectionAppearance": "स्वरूप", "sectionSecurity": "सुरक्षा", "sectionApiKeys": "API कुंजियाँ", @@ -3379,6 +3441,8 @@ "statusColorsDesc": "ऑनलाइन/ऑफ़लाइन स्थिति के लिए एक्सेंट रंग के बजाय हरा/लाल उपयोग करें", "pinAppRail": "ऐप रेल पिन करें", "pinAppRailDesc": "बाएं साइडबार ऐप रेल को होवर पर विस्तृत करने के बजाय हमेशा विस्तृत रखें", + "openFullscreenSettings": "सेटिंग्स को फुल स्क्रीन में खोलें", + "exitFullscreenSettings": "पूर्ण-स्क्रीन सेटिंग से बाहर निकलें", "expandAppRailOnHover": "होवर पर ऐप रेल विस्तृत करें", "expandAppRailOnHoverDesc": "जब पॉइंटर इसके ऊपर जाए तो बाएं साइडबार ऐप रेल को विस्तृत होने दें", "settingsNavigation": "नेविगेशन", diff --git a/src/ui/locales/translated/hu_HU.json b/src/ui/locales/translated/hu_HU.json index a291427f..1bdc9f0c 100644 --- a/src/ui/locales/translated/hu_HU.json +++ b/src/ui/locales/translated/hu_HU.json @@ -546,6 +546,7 @@ "sshTools": "SSH eszközök", "history": "Történelem", "sessionLogs": "Munkamenet-naplók", + "sidebarSettings": "Oldalsáv beállításai...", "hosts": "Házigazdák", "snippets": "Kódrészletek", "hostManager": "Házigazda-kezelő", @@ -755,6 +756,8 @@ "agentSocketPathLabel": "Ügynök socket útvonala", "agentSocketPathPlaceholder": "Hagyja üresen az SSH_AUTH_SOCK használatához", "agentSocketPathHint": "Hagyja üresen az SSH_AUTH_SOCK környezeti változóból történő automatikus felismeréshez, vagy adjon meg egyéni socket elérési utat (pl. /run/user/1000/gnupg/S.gpg-agent.ssh).", + "shareSshAuthLabel": "SSH-hitelesítés megosztása", + "shareSshAuthDesc": "Küldje el a címzetteknek a gazdagép SSH-hitelesítésének titkosított másolatait. A címzett személyes hitelesítő adatai továbbra is elsőbbséget élveznek.", "tailscaleDeviceSelect": "Tailscale eszköz kiválasztása", "tailscaleDeviceSelectPlaceholder": "Válasszon egy eszközt...", "tailscaleNoApiKey": "Nincs konfigurálva Tailscale API-kulcs. Adjon hozzá egyet az adminisztrátori beállításokban az eszközfelderítés engedélyezéséhez.", @@ -1173,15 +1176,39 @@ "generateFromPrivateKey": "Privát kulcsból generálás", "refreshBtn2": "Frissítés", "exitSelectionTitle": "Kilépés a kijelölésből", - "exportAll": "Összes exportálása", - "exportForSharing": "Exportálás megosztásra", "addHostBtn2": "Gazdagép hozzáadása", "addCredentialBtn2": "Hitelesítő adat hozzáadása", "checkingHostStatuses": "Gazdagépek állapotának ellenőrzése...", "pinnedSection": "Rögzítve", "hostsExported": "A hosztok exportálása sikeresen megtörtént.", - "hostsShareExported": "Megosztható hosztok exportálása sikeresen megtörtént", - "exportFailed": "Nem sikerült exportálni a gazdagépeket", + "export": { + "menuItem": "Export...", + "title": "Exportálási hostok", + "scope": "Hatály", + "scopeAll": "Minden", + "scopeSelected": "Kiválasztott", + "searchHosts": "Gazdagépek keresése...", + "include": "Tartalmazza", + "groupConnection": "Kapcsolat", + "groupCredentials": "Hitelesítő adatok", + "groupNotes": "Megjegyzések", + "groupTags": "Címkék és kitűzés", + "groupTunnels": "Alagutak", + "groupJumpHosts": "Ugrásszervezők", + "groupQuickActions": "Gyors műveletek", + "groupFeatureFlags": "Jellemzőjelzők", + "groupAdvanced": "Speciális konfiguráció", + "preview": "Előnézet", + "moreHosts": "... {{count}} további házigazdák", + "summary": "{{selected}} a {{total}} gazdagépből", + "credentialsIncluded": "hitelesítő adatok", + "credentialsExcluded": "hitelesítő adatok kizárva", + "noneSelected": "Nincsenek kiválasztva hosztok", + "cancel": "Mégsem", + "confirm": "Export", + "fetchFailed": "Nem sikerült betölteni az exportálandó gazdagépeket", + "bulkButton": "Export" + }, "sampleDownloaded": "Mintafájl letöltve", "failedToDeleteCredential2": "Nem sikerült törölni a hitelesítő adatokat", "noFolderOption": "(Nincs mappa)", @@ -1266,7 +1293,7 @@ }, "edit": { "label": "Szerkesztés", - "description": "A gazdagép megtekintése és módosítása. A titkos kódok lecserélhetők, de soha nem olvashatók; a hitelesítő adatok hozzárendelése csak a tulajdonos számára marad." + "description": "A nem hitelesítési hosztbeállítások megtekintése és módosítása. A tulajdonos SSH-hitelesítése privát és csak a tulajdonos számára elérhető marad." }, "manage": { "label": "Kezelés", @@ -1299,7 +1326,24 @@ "sharedBadgeTooltip": "Megosztotta {{owner}} ({{level}} hozzáférés)", "viewOnlyBanner": "Ezt a hosztot {{owner}} osztotta meg veled megtekintési hozzáféréssel. A konfiguráció csak olvasható.", "sharedEditBanner": "Ezt a hosztot {{owner}} osztotta meg veled szerkesztési hozzáféréssel. A változtatások a valódi hosztra vonatkoznak; a hitelesítési hivatkozásokat csak a tulajdonos módosíthatja.", - "ownerOnlyControl": "Csak a tárhely tulajdonosa módosíthatja ezt" + "ownerOnlyControl": "Csak a tárhely tulajdonosa módosíthatja ezt", + "ownerAuthPrivate": "A tárhelyszolgáltató SSH-hitelesítése privát. A tárhelyszolgáltató menüjének „Személyes SSH-hitelesítés beállítása” lehetőségével válassza ki saját hitelesítő adatait.", + "ownerAuthShared": "A host tulajdonosa megosztott SSH-hitelesítést állított be ehhez a hosthoz. Használhatja azt, vagy kiválaszthatja saját hitelesítő adatait a „Személyes SSH-hitelesítés beállítása” lehetőségnél.", + "authOverrideAction": "Személyes SSH-hitelesítés beállítása", + "authOverrideTitle": "Személyes SSH-hitelesítés", + "authOverrideDescriptionPrivate": "A gazdagép tulajdonosának SSH hitelesítő adatai bizalmasak maradnak. Válasszon egyet a mentett hitelesítő adatai közül a {{host}} szolgáltatáshoz való kapcsolódáshoz.", + "authOverrideDescriptionShared": "Használja a gazdagép tulajdonosa által megosztott hitelesítést, vagy cserélje le az egyik mentett hitelesítő adatára a {{host}} címhez való kapcsolódáshoz.", + "authOverrideCredentialLabel": "Hitelesítési adatok", + "useSharedAuthentication": "Megosztott host hitelesítés használata", + "noPersonalCredential": "Nincs személyes igazolvány", + "authOverrideNoCredentials": "Még nincsenek mentett SSH hitelesítő adatai. Hozzon létre egyet a Hitelesítő adatok részben, hogy hitelesítést igénylő gazdagépekhez csatlakozhasson.", + "authOverrideRequired": "Ehhez a gazdagéphez a csatlakozás előtt szükség van az egyik mentett hitelesítő adatodra.", + "authOverridePrivateHint": "Ez a hitelesítő adat privát, csak az Öné. A tárhely tulajdonosa és a többi címzett nem láthatja és nem használhatja.", + "authOverrideSaved": "Személyes SSH-hitelesítés mentve", + "authOverrideCleared": "Személyes SSH-hitelesítés eltávolítva", + "authOverrideClearedToShared": "Megosztott host hitelesítés használata", + "authOverrideLoadError": "Nem sikerült betölteni az SSH-hitelesítést. Próbáld újra.", + "authOverrideSaveError": "Nem sikerült menteni az SSH-hitelesítést" }, "guac": { "connection": "Kapcsolat", @@ -1627,6 +1671,15 @@ "tmuxCopyHint": "Módosítsa a kijelölést, és nyomja meg az Enter billentyűt a vágólapra másoláshoz", "tmuxDetach": "Leválasztás a tmux munkamenetről", "tmuxDetached": "Leválasztva a tmux munkamenetről", + "searchPlaceholder": "Lelet", + "searchCaseSensitive": "Gyufa tok", + "searchWholeWord": "Egész szó egyezése", + "searchRegex": "Reguláris kifejezés használata", + "searchNoResults": "Nincs találat", + "searchResultCount": "{{index}} a {{count}}-ból", + "searchNext": "Következő találat (Enter)", + "searchPrevious": "Előző találat (Shift+Enter)", + "searchClose": "Bezárás (Escape)", "maxReconnectAttemptsReached": "Elérte az újracsatlakozási kísérletek maximális számát", "closeTab": "Közeli", "connectionTimeout": "Kapcsolati időtúllépés", @@ -1654,6 +1707,11 @@ "opksshTimeout": "A hitelesítés időtúllépést okozott. Kérjük, próbálja újra.", "opksshAuthFailed": "Sikertelen hitelesítés. Kérjük, ellenőrizze a hitelesítő adatait, és próbálja újra.", "opksshSignInWith": "Bejelentkezés {{provider}} felhasználónévvel", + "tailscaleCheckRequired": "Tailscale hitelesítés szükséges", + "tailscaleCheckDescription": "A Tailscale SSH további ellenőrzést igényel. A folytatáshoz hitelesítse magát a böngészőjében.", + "tailscaleCheckOpenBrowser": "Böngésző megnyitása a hitelesítéshez", + "tailscaleCheckWaiting": "Tailscale hitelesítésre várunk...", + "tailscaleCheckTimeout": "A farokskálázási hitelesítés időtúllépést okozott. Próbálja újra.", "vaultAuthTitle": "Bejelentkezés szükséges a széfbe", "vaultAuthDescription": "Megnyílt egy ablak a HashiCorp Vaultba való bejelentkezéshez. Fejezze be a bejelentkezést ott; a kapcsolat automatikusan folytatódik.", "vaultAuthFailed": "A trezor hitelesítése sikertelen. Próbálja újra.", @@ -2145,6 +2203,7 @@ "cpuUsage": "CPU-használat", "memoryUsage": "Memóriahasználat", "diskUsage": "Lemezhasználat", + "selectFilesystem": "Fájlrendszer kiválasztása", "temperature": "Hőmérséklet", "highestTemperature": "Legmagasabb hőmérséklet", "failedToFetchHostConfig": "Nem sikerült lekérni a gazdagép konfigurációját", @@ -2819,6 +2878,7 @@ "updateCommandHistoryFailed": "Nem sikerült frissíteni a parancselőzmények beállítását", "analyticsEnabled": "Névtelen használati statisztikák megosztása", "analyticsEnabledDesc": "Naponta névtelenül küldi a felhasználók, a hosztok és a funkciók használatának számát a Termix fejlesztése érdekében. Személyes adatokat vagy kapcsolati adatokat nem tartalmaz.", + "analyticsEnabledLockedDesc": "Ezt a beállítást az ENABLE_TELEMETRY környezeti változó zárolja, és itt nem módosítható.", "updateAnalyticsFailed": "Nem sikerült frissíteni az analitikai beállításokat", "sessionSharingGloballyEnabled": "Munkamenet-megosztás engedélyezése", "sessionSharingGloballyEnabledDesc": "Engedélyezi az élő terminál-, RDP-, VNC- és Telnet-munkamenetek példányonkénti megosztását. Letiltás esetén felülírja az összes gazdagépenkénti megosztási kapcsolót.", @@ -3323,6 +3383,8 @@ "resetToDefaultsSuccess": "Beállítások visszaállítva az alapértelmezett értékekre.", "storageModeSwitch": "Preferencia tárhely", "sectionAccount": "Fiók", + "desktopProfileTitle": "Automatikus helyi asztali profil", + "desktopProfileDescription": "Ez a profil a beágyazott háttérre korlátozódik, és automatikusan bejelentkezik. Nincs bejelentkezési jelszava; az alábbi távoli szinkronizálás külön szerverfiókot használ.", "sectionAppearance": "Megjelenés", "sectionSecurity": "Biztonság", "sectionApiKeys": "API-kulcsok", @@ -3379,6 +3441,8 @@ "statusColorsDesc": "Használj zöld/piros színt az online/offline állapothoz a kiemelő szín helyett", "pinAppRail": "Alkalmazássín rögzítése", "pinAppRailDesc": "A bal oldalsáv alkalmazássávjának mindig legyen kibontva, ne pedig az egérmutatóra történő kibontás esetén", + "openFullscreenSettings": "Beállítások megnyitása teljes képernyőn", + "exitFullscreenSettings": "Kilépés a teljes képernyős beállításokból", "expandAppRailOnHover": "Alkalmazássáv kibontása egérrel", "expandAppRailOnHoverDesc": "A bal oldalsáv alkalmazássávjának kibontása, amikor a mutató fölé kerül", "settingsNavigation": "Navigáció", diff --git a/src/ui/locales/translated/id_ID.json b/src/ui/locales/translated/id_ID.json index 2857a377..5e3deb4c 100644 --- a/src/ui/locales/translated/id_ID.json +++ b/src/ui/locales/translated/id_ID.json @@ -546,6 +546,7 @@ "sshTools": "Alat SSH", "history": "Sejarah", "sessionLogs": "Log Sesi", + "sidebarSettings": "Pengaturan Sidebar...", "hosts": "Tuan rumah", "snippets": "Cuplikan", "hostManager": "Manajer Host", @@ -755,6 +756,8 @@ "agentSocketPathLabel": "Jalur Soket Agen", "agentSocketPathPlaceholder": "Biarkan kosong untuk menggunakan SSH_AUTH_SOCK", "agentSocketPathHint": "Biarkan kosong untuk mendeteksi secara otomatis dari variabel lingkungan SSH_AUTH_SOCK, atau masukkan jalur soket khusus (misalnya /run/user/1000/gnupg/S.gpg-agent.ssh).", + "shareSshAuthLabel": "Bagikan Otentikasi SSH", + "shareSshAuthDesc": "Berikan kepada penerima salinan terenkripsi dari otentikasi SSH host ini. Kredensial pribadi penerima tetap diutamakan.", "tailscaleDeviceSelect": "Pilih perangkat Tailscale", "tailscaleDeviceSelectPlaceholder": "Pilih perangkat...", "tailscaleNoApiKey": "Belum ada kunci API Tailscale yang dikonfigurasi. Tambahkan satu di Pengaturan Admin untuk mengaktifkan penemuan perangkat.", @@ -1173,15 +1176,39 @@ "generateFromPrivateKey": "Hasilkan dari Kunci Pribadi", "refreshBtn2": "Menyegarkan", "exitSelectionTitle": "Keluar dari pilihan", - "exportAll": "Ekspor Semua", - "exportForSharing": "Ekspor untuk Berbagi", "addHostBtn2": "Tambahkan Host", "addCredentialBtn2": "Tambahkan Kredensial", "checkingHostStatuses": "Memeriksa status host...", "pinnedSection": "Disematkan", "hostsExported": "Host berhasil diekspor.", - "hostsShareExported": "Host yang dapat dibagikan berhasil diekspor.", - "exportFailed": "Gagal mengekspor host", + "export": { + "menuItem": "Ekspor...", + "title": "Ekspor host", + "scope": "Cakupan", + "scopeAll": "Semua", + "scopeSelected": "Terpilih", + "searchHosts": "Cari host...", + "include": "Termasuk", + "groupConnection": "Koneksi", + "groupCredentials": "Kredensial", + "groupNotes": "Catatan", + "groupTags": "Label & pin", + "groupTunnels": "Terowongan", + "groupJumpHosts": "Pembawa acara Jump", + "groupQuickActions": "Tindakan cepat", + "groupFeatureFlags": "Bendera fitur", + "groupAdvanced": "Konfigurasi lanjutan", + "preview": "Pratinjau", + "moreHosts": "... {{count}} lebih banyak host", + "summary": "{{selected}} dari {{total}} tuan rumah", + "credentialsIncluded": "kredensial disertakan", + "credentialsExcluded": "kredensial dikecualikan", + "noneSelected": "Tidak ada host yang dipilih", + "cancel": "Membatalkan", + "confirm": "Ekspor", + "fetchFailed": "Gagal memuat host untuk ekspor.", + "bulkButton": "Ekspor" + }, "sampleDownloaded": "File contoh yang diunduh", "failedToDeleteCredential2": "Gagal menghapus kredensial", "noFolderOption": "(Tidak ada folder)", @@ -1266,7 +1293,7 @@ }, "edit": { "label": "Edit", - "description": "Lihat, dan modifikasi host. Rahasia dapat diganti tetapi tidak pernah dibaca; penetapan kredensial tetap hanya untuk pemilik." + "description": "Lihat, dan ubah pengaturan host non-autentikasi. Autentikasi SSH pemilik tetap bersifat pribadi dan hanya untuk pemilik." }, "manage": { "label": "Mengelola", @@ -1299,7 +1326,24 @@ "sharedBadgeTooltip": "Dibagikan oleh {{owner}} ( akses{{level}})", "viewOnlyBanner": "Host ini dibagikan kepada Anda oleh {{owner}} dengan akses lihat. Konfigurasinya hanya baca.", "sharedEditBanner": "Host ini dibagikan kepada Anda oleh {{owner}} dengan akses edit. Perubahan berlaku untuk host sebenarnya; referensi otentikasi hanya dapat diubah oleh pemilik.", - "ownerOnlyControl": "Hanya pemilik host yang dapat mengubah ini." + "ownerOnlyControl": "Hanya pemilik host yang dapat mengubah ini.", + "ownerAuthPrivate": "Autentikasi SSH pemilik host bersifat pribadi. Gunakan “Atur autentikasi SSH pribadi” dari menu host untuk memilih kredensial Anda sendiri.", + "ownerAuthShared": "Pemilik host telah membagikan otentikasi SSH untuk host ini. Anda dapat menggunakannya atau memilih kredensial Anda sendiri dari “Atur otentikasi SSH pribadi.”", + "authOverrideAction": "Atur otentikasi SSH pribadi", + "authOverrideTitle": "Autentikasi SSH pribadi", + "authOverrideDescriptionPrivate": "Kredensial SSH pemilik host tetap bersifat pribadi. Pilih salah satu kredensial yang telah Anda simpan untuk koneksi ke {{host}}.", + "authOverrideDescriptionShared": "Gunakan otentikasi yang dibagikan oleh pemilik host, atau ganti dengan salah satu kredensial tersimpan Anda untuk koneksi ke {{host}}.", + "authOverrideCredentialLabel": "Kredensial otentikasi", + "useSharedAuthentication": "Gunakan autentikasi host bersama.", + "noPersonalCredential": "Tidak ada kredensial pribadi", + "authOverrideNoCredentials": "Anda belum memiliki kredensial SSH yang tersimpan. Buat satu di bagian Kredensial untuk terhubung ke host yang memerlukan autentikasi.", + "authOverrideRequired": "Host ini memerlukan salah satu kredensial yang telah Anda simpan sebelum Anda dapat terhubung.", + "authOverridePrivateHint": "Kredensial ini bersifat pribadi untuk Anda. Pemilik host dan penerima lainnya tidak dapat melihat atau menggunakannya.", + "authOverrideSaved": "Autentikasi SSH pribadi tersimpan.", + "authOverrideCleared": "Autentikasi SSH pribadi telah dihapus.", + "authOverrideClearedToShared": "Menggunakan autentikasi host bersama", + "authOverrideLoadError": "Otentikasi SSH Anda gagal dimuat. Silakan coba lagi.", + "authOverrideSaveError": "Gagal menyimpan otentikasi SSH Anda." }, "guac": { "connection": "Koneksi", @@ -1627,6 +1671,15 @@ "tmuxCopyHint": "Sesuaikan pilihan dan tekan Enter untuk menyalin ke papan klip.", "tmuxDetach": "Lepaskan diri dari sesi tmux", "tmuxDetached": "Terputus dari sesi tmux", + "searchPlaceholder": "Menemukan", + "searchCaseSensitive": "Kotak Korek Api", + "searchWholeWord": "Cocokkan Seluruh Kata", + "searchRegex": "Gunakan Ekspresi Reguler", + "searchNoResults": "Tidak ada hasil", + "searchResultCount": "{{index}} dari {{count}}", + "searchNext": "Pertandingan Berikutnya (Masuk)", + "searchPrevious": "Pertandingan Sebelumnya (Shift+Enter)", + "searchClose": "Tutup (Keluar)", "maxReconnectAttemptsReached": "Upaya penyambungan kembali maksimum telah tercapai.", "closeTab": "Menutup", "connectionTimeout": "Waktu habis koneksi", @@ -1654,6 +1707,11 @@ "opksshTimeout": "Autentikasi habis waktu. Silakan coba lagi.", "opksshAuthFailed": "Autentikasi gagal. Harap periksa kredensial Anda dan coba lagi.", "opksshSignInWith": "Masuk dengan {{provider}}", + "tailscaleCheckRequired": "Diperlukan Otentikasi Sisik Ekor", + "tailscaleCheckDescription": "Tailscale SSH memerlukan pemeriksaan tambahan. Lakukan otentikasi di browser Anda untuk melanjutkan.", + "tailscaleCheckOpenBrowser": "Buka Browser untuk Melakukan Otentikasi", + "tailscaleCheckWaiting": "Menunggu autentikasi Tailscale...", + "tailscaleCheckTimeout": "Autentikasi Tailscale habis waktu. Silakan coba lagi.", "vaultAuthTitle": "Login ke Vault diperlukan.", "vaultAuthDescription": "Jendela telah terbuka untuk masuk ke HashiCorp Vault. Selesaikan proses masuk di sana; koneksi ini akan berlanjut secara otomatis.", "vaultAuthFailed": "Autentikasi Vault gagal. Silakan coba lagi.", @@ -2145,6 +2203,7 @@ "cpuUsage": "Penggunaan CPU", "memoryUsage": "Penggunaan Memori", "diskUsage": "Penggunaan Disk", + "selectFilesystem": "Pilih sistem file", "temperature": "Suhu", "highestTemperature": "Suhu tertinggi", "failedToFetchHostConfig": "Gagal mengambil konfigurasi host", @@ -2819,6 +2878,7 @@ "updateCommandHistoryFailed": "Gagal memperbarui pengaturan riwayat perintah.", "analyticsEnabled": "Bagikan Statistik Penggunaan Anonim", "analyticsEnabledDesc": "Mengirimkan data harian anonim mengenai jumlah pengguna, host, dan penggunaan fitur untuk membantu meningkatkan Termix. Tidak ada data pribadi atau detail koneksi yang disertakan.", + "analyticsEnabledLockedDesc": "Pengaturan ini dikunci oleh variabel lingkungan ENABLE_TELEMETRY dan tidak dapat diubah di sini.", "updateAnalyticsFailed": "Gagal memperbarui pengaturan analitik.", "sessionSharingGloballyEnabled": "Izinkan Berbagi Sesi", "sessionSharingGloballyEnabledDesc": "Izinkan sesi terminal langsung, RDP, VNC, dan Telnet untuk dibagikan di seluruh instance. Menggantikan setiap pengaturan berbagi per host saat dinonaktifkan.", @@ -3323,6 +3383,8 @@ "resetToDefaultsSuccess": "Pengaturan kembali ke pengaturan default.", "storageModeSwitch": "Penyimpanan Preferensi", "sectionAccount": "Akun", + "desktopProfileTitle": "Profil desktop lokal otomatis", + "desktopProfileDescription": "Profil ini dibatasi untuk backend yang tertanam dan masuk secara otomatis. Profil ini tidak memiliki kata sandi login; Sinkronisasi Jarak Jauh di bawah ini menggunakan akun server terpisah.", "sectionAppearance": "Penampilan", "sectionSecurity": "Keamanan", "sectionApiKeys": "Kunci API", @@ -3379,6 +3441,8 @@ "statusColorsDesc": "Gunakan warna hijau/merah untuk status online/offline, bukan warna aksen.", "pinAppRail": "Pin App Rail", "pinAppRailDesc": "Pertahankan agar bilah sisi kiri aplikasi selalu terbuka, alih-alih terbuka saat diarahkan kursor.", + "openFullscreenSettings": "Buka pengaturan dalam mode layar penuh.", + "exitFullscreenSettings": "Keluar dari pengaturan layar penuh", "expandAppRailOnHover": "Perluas App Rail saat Diarahkan Kursor", "expandAppRailOnHoverDesc": "Izinkan bilah sisi kiri aplikasi untuk melebar saat kursor diarahkan ke atasnya.", "settingsNavigation": "Navigasi", diff --git a/src/ui/locales/translated/it_IT.json b/src/ui/locales/translated/it_IT.json index 53aa0d14..a40e7668 100644 --- a/src/ui/locales/translated/it_IT.json +++ b/src/ui/locales/translated/it_IT.json @@ -546,6 +546,7 @@ "sshTools": "Strumenti SSH", "history": "Cronologia", "sessionLogs": "Log di sessione", + "sidebarSettings": "Impostazioni della barra laterale...", "hosts": "Host", "snippets": "Frammenti", "hostManager": "Gestione host", @@ -755,6 +756,8 @@ "agentSocketPathLabel": "Percorso socket agente", "agentSocketPathPlaceholder": "Lascia vuoto per usare SSH_AUTH_SOCK", "agentSocketPathHint": "Lascia vuoto per il rilevamento automatico dalla variabile d'ambiente SSH_AUTH_SOCK o inserisci un percorso socket personalizzato (es. /run/user/1000/gnupg/S.gpg-agent.ssh).", + "shareSshAuthLabel": "Autenticazione SSH condivisa", + "shareSshAuthDesc": "Fornite ai destinatari copie crittografate delle credenziali di autenticazione SSH di questo host. Le credenziali personali del destinatario hanno comunque la precedenza.", "tailscaleDeviceSelect": "Seleziona dispositivo Tailscale", "tailscaleDeviceSelectPlaceholder": "Seleziona un dispositivo...", "tailscaleNoApiKey": "Nessuna chiave API Tailscale configurata. Aggiungine una in Impostazioni amministratore per abilitare il rilevamento dispositivi.", @@ -1173,15 +1176,39 @@ "generateFromPrivateKey": "Genera da chiave privata", "refreshBtn2": "Aggiorna", "exitSelectionTitle": "Esci dalla selezione", - "exportAll": "Esporta tutto", - "exportForSharing": "Esporta per condivisione", "addHostBtn2": "Aggiungi host", "addCredentialBtn2": "Aggiungi credenziale", "checkingHostStatuses": "Verifica degli stati degli host in corso...", "pinnedSection": "Fissati", "hostsExported": "Host esportati con successo", - "hostsShareExported": "Host condivisibili esportati con successo", - "exportFailed": "Esportazione host non riuscita", + "export": { + "menuItem": "Esportare...", + "title": "Host di esportazione", + "scope": "Ambito di applicazione", + "scopeAll": "Tutto", + "scopeSelected": "Selezionato", + "searchHosts": "Cerca host...", + "include": "Includi", + "groupConnection": "Connessione", + "groupCredentials": "Credenziali", + "groupNotes": "Note", + "groupTags": "Tag e pin", + "groupTunnels": "Gallerie", + "groupJumpHosts": "Ospiti di Jump", + "groupQuickActions": "Azioni rapide", + "groupFeatureFlags": "Flag di funzionalità", + "groupAdvanced": "Configurazione avanzata", + "preview": "Anteprima", + "moreHosts": "... {{count}} altri host", + "summary": "{{selected}} di {{total}} host", + "credentialsIncluded": "credenziali incluse", + "credentialsExcluded": "credenziali escluse", + "noneSelected": "Nessun host selezionato", + "cancel": "Cancellare", + "confirm": "Esportare", + "fetchFailed": "Impossibile caricare gli host per l'esportazione", + "bulkButton": "Esportare" + }, "sampleDownloaded": "File di esempio scaricato", "failedToDeleteCredential2": "Eliminazione credenziale non riuscita", "noFolderOption": "(Nessuna cartella)", @@ -1266,7 +1293,7 @@ }, "edit": { "label": "Modifica", - "description": "Visualizza, più modifica l'host. I segreti possono essere sostituiti ma mai letti; le assegnazioni delle credenziali restano solo del proprietario." + "description": "Visualizza e modifica le impostazioni host senza autenticazione. L'autenticazione SSH del proprietario rimane privata e accessibile solo al proprietario." }, "manage": { "label": "Gestisci", @@ -1299,7 +1326,24 @@ "sharedBadgeTooltip": "Condiviso da {{owner}} (accesso {{level}})", "viewOnlyBanner": "Questo host è condiviso con te da {{owner}} con accesso di sola lettura. La configurazione è di sola lettura.", "sharedEditBanner": "Questo host è condiviso con te da {{owner}} con accesso di modifica. Le modifiche si applicano all'host reale; i riferimenti di autenticazione possono essere modificati solo dal proprietario.", - "ownerOnlyControl": "Solo il proprietario dell'host può modificare questa impostazione" + "ownerOnlyControl": "Solo il proprietario dell'host può modificare questa impostazione", + "ownerAuthPrivate": "L'autenticazione SSH del proprietario dell'host è privata. Utilizza l'opzione \"Imposta autenticazione SSH personale\" dal menu dell'host per scegliere le tue credenziali.", + "ownerAuthShared": "Il proprietario dell'host ha condiviso le credenziali di autenticazione SSH per questo host. Puoi utilizzarle oppure scegliere le tue credenziali da \"Imposta autenticazione SSH personale\".", + "authOverrideAction": "Imposta l'autenticazione SSH personale", + "authOverrideTitle": "Autenticazione SSH personale", + "authOverrideDescriptionPrivate": "Le credenziali SSH del proprietario dell'host rimangono private. Scegli una delle tue credenziali salvate per connetterti a {{host}}.", + "authOverrideDescriptionShared": "Utilizza le credenziali di autenticazione condivise dal proprietario dell'host oppure sostituiscile con una delle tue credenziali salvate per le connessioni a {{host}}.", + "authOverrideCredentialLabel": "Credenziali di autenticazione", + "useSharedAuthentication": "Utilizzare l'autenticazione dell'host condiviso.", + "noPersonalCredential": "Nessuna credenziale personale", + "authOverrideNoCredentials": "Non hai ancora salvato alcuna credenziale SSH. Creane una in Credenziali per connetterti agli host che richiedono l'autenticazione.", + "authOverrideRequired": "Questo host richiede una delle tue credenziali salvate prima che tu possa connetterti.", + "authOverridePrivateHint": "Queste credenziali sono personali e ad uso esclusivo dell'utente. Il proprietario dell'host e gli altri destinatari non possono visualizzarle né utilizzarle.", + "authOverrideSaved": "Autenticazione SSH personale salvata", + "authOverrideCleared": "Autenticazione SSH personale rimossa", + "authOverrideClearedToShared": "Utilizzo dell'autenticazione dell'host condiviso", + "authOverrideLoadError": "Impossibile caricare le credenziali di autenticazione SSH. Riprova.", + "authOverrideSaveError": "Impossibile salvare le credenziali SSH." }, "guac": { "connection": "Connessione", @@ -1627,6 +1671,15 @@ "tmuxCopyHint": "Regola la selezione e premi Invio per copiare negli appunti", "tmuxDetach": "Scollegati dalla sessione tmux", "tmuxDetached": "Scollegato dalla sessione tmux", + "searchPlaceholder": "Trovare", + "searchCaseSensitive": "Scatola di fiammiferi", + "searchWholeWord": "Abbina la parola intera", + "searchRegex": "Utilizzare le espressioni regolari", + "searchNoResults": "Nessun risultato", + "searchResultCount": "{{index}} di {{count}}", + "searchNext": "Prossima partita (Invio)", + "searchPrevious": "Partita precedente (Maiusc+Invio)", + "searchClose": "Chiudi (Esc)", "maxReconnectAttemptsReached": "Raggiunto il numero massimo di tentativi di riconnessione", "closeTab": "Chiudi", "connectionTimeout": "Timeout di connessione", @@ -1654,6 +1707,11 @@ "opksshTimeout": "Autenticazione scaduta. Riprova.", "opksshAuthFailed": "Autenticazione fallita. Controlla le credenziali e riprova.", "opksshSignInWith": "Accedi con {{provider}}", + "tailscaleCheckRequired": "Autenticazione Tailscale richiesta", + "tailscaleCheckDescription": "Tailscale SSH richiede un controllo aggiuntivo. Autenticati nel tuo browser per continuare.", + "tailscaleCheckOpenBrowser": "Apri il browser per autenticarti", + "tailscaleCheckWaiting": "In attesa dell'autenticazione di Tailscale...", + "tailscaleCheckTimeout": "Autenticazione di Tailscale scaduta. Riprova.", "vaultAuthTitle": "Accesso a Vault richiesto", "vaultAuthDescription": "Una finestra si è aperta per accedere a HashiCorp Vault. Completa l'accesso lì; la connessione continuerà automaticamente.", "vaultAuthFailed": "Autenticazione Vault fallita. Riprova.", @@ -2145,6 +2203,7 @@ "cpuUsage": "Utilizzo CPU", "memoryUsage": "Utilizzo memoria", "diskUsage": "Utilizzo disco", + "selectFilesystem": "Seleziona il filesystem", "temperature": "Temperatura", "highestTemperature": "Temperatura massima", "failedToFetchHostConfig": "Recupero configurazione host non riuscito", @@ -2819,6 +2878,7 @@ "updateCommandHistoryFailed": "Aggiornamento dell'impostazione della cronologia comandi non riuscito", "analyticsEnabled": "Condividi le statistiche di utilizzo anonime", "analyticsEnabledDesc": "Invia un conteggio giornaliero anonimo di utenti, host e utilizzo delle funzionalità per contribuire al miglioramento di Termix. Non vengono mai inclusi dati personali o dettagli di connessione.", + "analyticsEnabledLockedDesc": "Questa impostazione è bloccata dalla variabile d'ambiente ENABLE_TELEMETRY e non può essere modificata qui.", "updateAnalyticsFailed": "Impossibile aggiornare le impostazioni di analisi", "sessionSharingGloballyEnabled": "Consenti la condivisione della sessione", "sessionSharingGloballyEnabledDesc": "Consente la condivisione di sessioni live di terminale, RDP, VNC e Telnet a livello di istanza. Quando disabilitata, questa opzione sovrascrive tutte le impostazioni di condivisione per singolo host.", @@ -3323,6 +3383,8 @@ "resetToDefaultsSuccess": "Impostazioni ripristinate ai valori predefiniti.", "storageModeSwitch": "Memorizzazione preferenze", "sectionAccount": "Account", + "desktopProfileTitle": "Profilo desktop locale automatico", + "desktopProfileDescription": "Questo profilo è limitato al backend integrato e accede automaticamente. Non richiede password di accesso; la funzione di sincronizzazione remota descritta di seguito utilizza un account server separato.", "sectionAppearance": "Aspetto", "sectionSecurity": "Sicurezza", "sectionApiKeys": "Chiavi API", @@ -3379,6 +3441,8 @@ "statusColorsDesc": "Usa verde/rosso per lo stato online/offline invece del colore accento", "pinAppRail": "Fissa barra app", "pinAppRailDesc": "Mantieni la barra app laterale sinistra sempre espansa, invece di espanderla al passaggio del mouse", + "openFullscreenSettings": "Apri le impostazioni a schermo intero", + "exitFullscreenSettings": "Esci dalle impostazioni a schermo intero", "expandAppRailOnHover": "Espandi barra app al passaggio del mouse", "expandAppRailOnHoverDesc": "Consenti alla barra app laterale sinistra di espandersi quando il puntatore vi passa sopra", "settingsNavigation": "Navigazione", diff --git a/src/ui/locales/translated/ja_JP.json b/src/ui/locales/translated/ja_JP.json index 7309f87c..5d15ce5e 100644 --- a/src/ui/locales/translated/ja_JP.json +++ b/src/ui/locales/translated/ja_JP.json @@ -546,6 +546,7 @@ "sshTools": "SSHツール", "history": "履歴", "sessionLogs": "セッションログ", + "sidebarSettings": "サイドバーの設定...", "hosts": "ホスト", "snippets": "スニペット", "hostManager": "ホストマネージャー", @@ -755,6 +756,8 @@ "agentSocketPathLabel": "エージェントソケットパス", "agentSocketPathPlaceholder": "SSH_AUTH_SOCKを使用するには空のままにしてください", "agentSocketPathHint": "SSH_AUTH_SOCK環境変数から自動検出するには空のままにするか、カスタムソケットパスを入力してください(例: /run/user/1000/gnupg/S.gpg-agent.ssh)。", + "shareSshAuthLabel": "SSH認証を共有する", + "shareSshAuthDesc": "受信者には、このホストのSSH認証情報を暗号化して送信します。受信者の個人認証情報が優先されます。", "tailscaleDeviceSelect": "Tailscaleデバイスを選択", "tailscaleDeviceSelectPlaceholder": "デバイスを選択...", "tailscaleNoApiKey": "Tailscale APIキーが設定されていません。デバイス検出を有効にするには、管理者設定で追加してください。", @@ -1173,15 +1176,39 @@ "generateFromPrivateKey": "秘密鍵から生成", "refreshBtn2": "更新", "exitSelectionTitle": "選択を終了", - "exportAll": "すべてエクスポート", - "exportForSharing": "共有用にエクスポート", "addHostBtn2": "ホストを追加", "addCredentialBtn2": "認証情報を追加", "checkingHostStatuses": "ホストの状態を確認しています...", "pinnedSection": "ピン留め", "hostsExported": "ホストを正常にエクスポートしました", - "hostsShareExported": "共有用ホストを正常にエクスポートしました", - "exportFailed": "ホストのエクスポートに失敗しました", + "export": { + "menuItem": "輸出...", + "title": "エクスポートホスト", + "scope": "範囲", + "scopeAll": "全て", + "scopeSelected": "選ばれた", + "searchHosts": "ホストを検索...", + "include": "含む", + "groupConnection": "繋がり", + "groupCredentials": "資格", + "groupNotes": "注記", + "groupTags": "タグとピン", + "groupTunnels": "トンネル", + "groupJumpHosts": "ジャンプホスト", + "groupQuickActions": "クイックアクション", + "groupFeatureFlags": "機能フラグ", + "groupAdvanced": "詳細設定", + "preview": "プレビュー", + "moreHosts": "... {{count}} その他のホスト", + "summary": "{{selected}} の {{total}} ホスト", + "credentialsIncluded": "認証情報が含まれています", + "credentialsExcluded": "認証情報は除外されています", + "noneSelected": "ホストは選択されていません", + "cancel": "キャンセル", + "confirm": "輸出", + "fetchFailed": "エクスポート用のホストの読み込みに失敗しました", + "bulkButton": "輸出" + }, "sampleDownloaded": "サンプルファイルをダウンロードしました", "failedToDeleteCredential2": "認証情報の削除に失敗しました", "noFolderOption": "(フォルダーなし)", @@ -1266,7 +1293,7 @@ }, "edit": { "label": "編集", - "description": "表示に加え、ホストを編集できます。シークレットは置き換え可能ですが読み取りはできません。認証情報の割り当ては所有者のみが行えます。" + "description": "認証不要のホスト設定を表示および変更できます。所有者のSSH認証情報は非公開で、所有者のみがアクセスできます。" }, "manage": { "label": "管理", @@ -1299,7 +1326,24 @@ "sharedBadgeTooltip": "{{owner}} により共有 ({{level}}アクセス)", "viewOnlyBanner": "このホストは{{owner}}によりビューアクセスで共有されています。設定は読み取り専用です。", "sharedEditBanner": "このホストは{{owner}}により編集アクセスで共有されています。変更は実際のホストに適用されます。認証情報は所有者のみ変更できます。", - "ownerOnlyControl": "ホスト所有者のみ変更できます" + "ownerOnlyControl": "ホスト所有者のみ変更できます", + "ownerAuthPrivate": "ホスト所有者のSSH認証は非公開です。ホストメニューから「個人用SSH認証の設定」を選択して、独自の認証情報を選択してください。", + "ownerAuthShared": "ホストの所有者はこのホストのSSH認証情報を共有しています。それを使用するか、「個人用SSH認証情報の設定」から独自の認証情報を選択できます。", + "authOverrideAction": "個人用SSH認証を設定する", + "authOverrideTitle": "個人用SSH認証", + "authOverrideDescriptionPrivate": "ホスト所有者の SSH 認証情報は非公開です。 {{host}} への接続には、保存済みの認証情報のいずれかを選択してください。", + "authOverrideDescriptionShared": "ホスト所有者と共有されている認証情報を使用するか、 {{host}} への接続のために保存されている認証情報のいずれかに置き換えてください。", + "authOverrideCredentialLabel": "認証資格情報", + "useSharedAuthentication": "共有ホスト認証を使用する", + "noPersonalCredential": "個人認証情報なし", + "authOverrideNoCredentials": "SSH認証情報がまだ保存されていません。認証が必要なホストに接続するには、「認証情報」で認証情報を作成してください。", + "authOverrideRequired": "このホストに接続するには、保存済みの認証情報のいずれかが必要です。", + "authOverridePrivateHint": "この認証情報はあなた専用のものです。ホストの所有者やその他の受信者は、この認証情報を見たり使用したりすることはできません。", + "authOverrideSaved": "個人用SSH認証が保存されました", + "authOverrideCleared": "個人用SSH認証が削除されました", + "authOverrideClearedToShared": "共有ホスト認証を使用する", + "authOverrideLoadError": "SSH認証情報の読み込みに失敗しました。もう一度お試しください。", + "authOverrideSaveError": "SSH認証情報の保存に失敗しました" }, "guac": { "connection": "接続", @@ -1627,6 +1671,15 @@ "tmuxCopyHint": "選択範囲を調整してEnterでクリップボードにコピー", "tmuxDetach": "tmuxセッションからデタッチ", "tmuxDetached": "tmuxセッションからデタッチしました", + "searchPlaceholder": "探す", + "searchCaseSensitive": "マッチケース", + "searchWholeWord": "単語全体を一致させる", + "searchRegex": "正規表現を使用する", + "searchNoResults": "検索結果なし", + "searchResultCount": "{{index}} / {{count}}", + "searchNext": "次の試合(入場)", + "searchPrevious": "前の試合 (Shift+Enter)", + "searchClose": "閉じる(Escキー)", "maxReconnectAttemptsReached": "再接続の最大試行回数に達しました", "closeTab": "閉じる", "connectionTimeout": "接続タイムアウト", @@ -1654,6 +1707,11 @@ "opksshTimeout": "認証がタイムアウトしました。もう一度お試しください。", "opksshAuthFailed": "認証に失敗しました。資格情報を確認して再試行してください。", "opksshSignInWith": "{{provider}} でサインイン", + "tailscaleCheckRequired": "テールスケール認証が必要です", + "tailscaleCheckDescription": "TailscaleのSSH接続には追加の認証が必要です。ブラウザで認証を行ってから続行してください。", + "tailscaleCheckOpenBrowser": "認証のためにブラウザを開く", + "tailscaleCheckWaiting": "Tailscaleの認証を待っています...", + "tailscaleCheckTimeout": "Tailscaleの認証がタイムアウトしました。もう一度お試しください。", "vaultAuthTitle": "Vault サインインが必要です", "vaultAuthDescription": "HashiCorp Vaultへのサインイン用のウィンドウが開きました。そこでサインインを完了すると、この接続は自動的に続行されます。", "vaultAuthFailed": "Vault 認証に失敗しました。もう一度お試しください。", @@ -2145,6 +2203,7 @@ "cpuUsage": "CPU使用率", "memoryUsage": "メモリ使用率", "diskUsage": "ディスク使用率", + "selectFilesystem": "ファイルシステムを選択", "temperature": "温度", "highestTemperature": "最高温度", "failedToFetchHostConfig": "ホスト設定の取得に失敗しました", @@ -2819,6 +2878,7 @@ "updateCommandHistoryFailed": "コマンド履歴設定の更新に失敗しました", "analyticsEnabled": "匿名利用統計を共有する", "analyticsEnabledDesc": "Termixの改善に役立てるため、ユーザー数、ホスト数、機能使用状況に関する匿名データを毎日送信します。個人データや接続情報は一切含まれません。", + "analyticsEnabledLockedDesc": "この設定は環境変数ENABLE_TELEMETRYによってロックされているため、ここで変更することはできません。", "updateAnalyticsFailed": "分析設定の更新に失敗しました", "sessionSharingGloballyEnabled": "セッション共有を許可する", "sessionSharingGloballyEnabledDesc": "ライブターミナル、RDP、VNC、およびTelnetセッションをインスタンス全体で共有できるようにします。無効にすると、ホストごとの共有設定がすべて上書きされます。", @@ -3323,6 +3383,8 @@ "resetToDefaultsSuccess": "設定がデフォルトにリセットされました。", "storageModeSwitch": "設定の保存先", "sectionAccount": "アカウント", + "desktopProfileTitle": "自動ローカルデスクトッププロファイル", + "desktopProfileDescription": "このプロファイルは組み込みバックエンドに限定されており、自動的にサインインします。ログインパスワードは不要です。下記のリモート同期は別のサーバーアカウントを使用します。", "sectionAppearance": "外観", "sectionSecurity": "セキュリティ", "sectionApiKeys": "APIキー", @@ -3379,6 +3441,8 @@ "statusColorsDesc": "オンライン/オフラインのステータスにアクセントカラーではなく緑/赤を使用します", "pinAppRail": "アプリレールを固定", "pinAppRailDesc": "左側のサイドバーアプリレールを常に展開状態に保ち、ホバーで展開しないようにします", + "openFullscreenSettings": "設定を全画面で開く", + "exitFullscreenSettings": "全画面表示設定を終了する", "expandAppRailOnHover": "ホバーでアプリレールを展開", "expandAppRailOnHoverDesc": "ポインターが上に移動したときに左側のサイドバーアプリレールを展開できるようにします", "settingsNavigation": "ナビゲーション", diff --git a/src/ui/locales/translated/ko_KR.json b/src/ui/locales/translated/ko_KR.json index c86f3cf8..c73804dc 100644 --- a/src/ui/locales/translated/ko_KR.json +++ b/src/ui/locales/translated/ko_KR.json @@ -546,6 +546,7 @@ "sshTools": "SSH 도구", "history": "기록", "sessionLogs": "세션 로그", + "sidebarSettings": "사이드바 설정...", "hosts": "호스트", "snippets": "스니펫", "hostManager": "호스트 관리자", @@ -755,6 +756,8 @@ "agentSocketPathLabel": "에이전트 소켓 경로", "agentSocketPathPlaceholder": "SSH_AUTH_SOCK을 사용하려면 비워 두세요.", "agentSocketPathHint": "비워 두면 SSH_AUTH_SOCK 환경 변수에서 자동 감지합니다. 또는 사용자 지정 소켓 경로를 입력하세요(예: /run/user/1000/gnupg/S.gpg-agent.ssh).", + "shareSshAuthLabel": "SSH 인증 공유", + "shareSshAuthDesc": "수신자에게 이 호스트의 SSH 인증 정보를 암호화한 사본을 제공하십시오. 수신자의 개인 자격 증명이 여전히 우선합니다.", "tailscaleDeviceSelect": "Tailscale 기기 선택", "tailscaleDeviceSelectPlaceholder": "기기 선택...", "tailscaleNoApiKey": "Tailscale API 키가 구성되지 않았습니다. 기기 검색을 활성화하려면 관리자 설정에서 추가하세요.", @@ -1173,15 +1176,39 @@ "generateFromPrivateKey": "비공개 키로 생성", "refreshBtn2": "새로 고침", "exitSelectionTitle": "선택 모드 종료", - "exportAll": "전체 내보내기", - "exportForSharing": "공유용 내보내기", "addHostBtn2": "호스트 추가", "addCredentialBtn2": "자격 증명 추가", "checkingHostStatuses": "호스트 상태 확인 중...", "pinnedSection": "고정됨", "hostsExported": "호스트를 성공적으로 내보냈습니다.", - "hostsShareExported": "공유 가능한 호스트를 성공적으로 내보냈습니다.", - "exportFailed": "호스트 내보내기에 실패했습니다.", + "export": { + "menuItem": "내보내다...", + "title": "호스트 내보내기", + "scope": "범위", + "scopeAll": "모두", + "scopeSelected": "선택된", + "searchHosts": "호스트를 검색하세요...", + "include": "포함하다", + "groupConnection": "연결", + "groupCredentials": "신임장", + "groupNotes": "메모", + "groupTags": "태그 및 핀", + "groupTunnels": "터널", + "groupJumpHosts": "점프 호스트", + "groupQuickActions": "빠른 조치", + "groupFeatureFlags": "기능 플래그", + "groupAdvanced": "고급 설정", + "preview": "시사", + "moreHosts": "... {{count}} 더 많은 호스트", + "summary": "{{selected}} {{total}} 호스트", + "credentialsIncluded": "자격 증명 포함", + "credentialsExcluded": "자격 증명 제외됨", + "noneSelected": "선택된 호스트가 없습니다.", + "cancel": "취소", + "confirm": "내보내다", + "fetchFailed": "내보내기용 호스트를 로드하는 데 실패했습니다.", + "bulkButton": "내보내다" + }, "sampleDownloaded": "예제 파일이 다운로드되었습니다.", "failedToDeleteCredential2": "자격 증명 삭제에 실패했습니다.", "noFolderOption": "(폴더 없음)", @@ -1266,7 +1293,7 @@ }, "edit": { "label": "편집", - "description": "보기 및 호스트 수정. 비밀 정보는 교체 가능하지만 읽을 수 없습니다. 자격 증명 할당은 소유자 전용입니다." + "description": "인증이 필요하지 않은 호스트 설정을 보고 수정할 수 있습니다. 소유자의 SSH 인증 정보는 비공개로 유지되며 소유자만 볼 수 있습니다." }, "manage": { "label": "관리", @@ -1299,7 +1326,24 @@ "sharedBadgeTooltip": "{{owner}}님이 공유함 ({{level}} 권한)", "viewOnlyBanner": "이 호스트는 {{owner}}님이 보기 권한으로 공유했습니다. 설정은 읽기 전용입니다.", "sharedEditBanner": "이 호스트는 {{owner}}님이 편집 권한으로 공유했습니다. 변경 사항은 실제 호스트에 적용되며, 인증 참조는 소유자만 변경할 수 있습니다.", - "ownerOnlyControl": "호스트 소유자만 변경할 수 있습니다" + "ownerOnlyControl": "호스트 소유자만 변경할 수 있습니다", + "ownerAuthPrivate": "호스트 소유자의 SSH 인증은 비공개입니다. 호스트 메뉴에서 \"개인 SSH 인증 설정\"을 사용하여 사용자 고유의 자격 증명을 선택하십시오.", + "ownerAuthShared": "호스트 소유자가 이 호스트에 대한 SSH 인증 정보를 공유했습니다. 해당 정보를 사용하거나 \"개인 SSH 인증 설정\"에서 사용자 고유의 자격 증명을 선택할 수 있습니다.", + "authOverrideAction": "개인 SSH 인증 설정", + "authOverrideTitle": "개인 SSH 인증", + "authOverrideDescriptionPrivate": "호스트 소유자의 SSH 자격 증명은 비공개로 유지됩니다. {{host}}에 연결하려면 저장된 자격 증명 중 하나를 선택하십시오.", + "authOverrideDescriptionShared": "호스트 소유자가 공유한 인증을 사용하거나 {{host}}에 연결하기 위해 저장된 자격 증명 중 하나로 대체하세요.", + "authOverrideCredentialLabel": "인증 자격 증명", + "useSharedAuthentication": "공유 호스트 인증을 사용하세요", + "noPersonalCredential": "개인 자격증명 없음", + "authOverrideNoCredentials": "아직 저장된 SSH 자격 증명이 없습니다. 인증이 필요한 호스트에 연결하려면 자격 증명에서 자격 증명을 생성하세요.", + "authOverrideRequired": "이 호스트에 연결하려면 저장된 자격 증명 중 하나가 필요합니다.", + "authOverridePrivateHint": "이 자격 증명은 사용자 본인만 사용할 수 있습니다. 호스트 소유자 및 다른 수신자는 이 자격 증명을 볼 수도 사용할 수도 없습니다.", + "authOverrideSaved": "개인 SSH 인증 정보가 저장되었습니다.", + "authOverrideCleared": "개인 SSH 인증이 제거되었습니다.", + "authOverrideClearedToShared": "공유 호스트 인증 사용", + "authOverrideLoadError": "SSH 인증 정보를 불러오는 데 실패했습니다. 다시 시도해 주세요.", + "authOverrideSaveError": "SSH 인증 정보를 저장하는 데 실패했습니다." }, "guac": { "connection": "연결", @@ -1627,6 +1671,15 @@ "tmuxCopyHint": "선택 영역을 조정하고 Enter 키를 누르면 클립보드에 복사됩니다.", "tmuxDetach": "tmux 세션에서 분리", "tmuxDetached": "tmux 세션에서 분리됨", + "searchPlaceholder": "찾다", + "searchCaseSensitive": "대소문자를 일치시키세요", + "searchWholeWord": "단어 전체를 일치시키세요", + "searchRegex": "정규 표현식을 사용하세요", + "searchNoResults": "결과가 없습니다", + "searchResultCount": "{{index}} {{count}}", + "searchNext": "다음 매치 (Enter)", + "searchPrevious": "이전 일치 항목 (Shift+Enter)", + "searchClose": "닫기 (탈출)", "maxReconnectAttemptsReached": "최대 재연결 시도 횟수에 도달했습니다.", "closeTab": "닫기", "connectionTimeout": "연결 시간 초과", @@ -1654,6 +1707,11 @@ "opksshTimeout": "인증 시간이 초과되었습니다. 다시 시도하세요.", "opksshAuthFailed": "인증에 실패했습니다. 자격 증명을 확인하고 다시 시도하세요.", "opksshSignInWith": "{{provider}}로 로그인", + "tailscaleCheckRequired": "Tailscale 인증이 필요합니다.", + "tailscaleCheckDescription": "Tailscale SSH는 추가 인증이 필요합니다. 계속하려면 브라우저에서 인증하십시오.", + "tailscaleCheckOpenBrowser": "인증하려면 브라우저를 여세요", + "tailscaleCheckWaiting": "Tailscale 인증을 기다리는 중...", + "tailscaleCheckTimeout": "Tailscale 인증 시간이 초과되었습니다. 다시 시도해 주세요.", "vaultAuthTitle": "Vault 로그인 필요", "vaultAuthDescription": "HashiCorp Vault 로그인을 위한 창이 열렸습니다. 해당 창에서 로그인을 완료하면 연결이 자동으로 계속됩니다.", "vaultAuthFailed": "Vault 인증에 실패했습니다. 다시 시도하세요.", @@ -2145,6 +2203,7 @@ "cpuUsage": "CPU 사용률", "memoryUsage": "메모리 사용률", "diskUsage": "디스크 사용률", + "selectFilesystem": "파일 시스템을 선택하세요", "temperature": "온도", "highestTemperature": "최고 온도", "failedToFetchHostConfig": "호스트 구성 가져오기 실패", @@ -2819,6 +2878,7 @@ "updateCommandHistoryFailed": "명령어 히스토리 설정 업데이트 실패", "analyticsEnabled": "익명 사용 통계 공유", "analyticsEnabledDesc": "Termix 개선을 위해 사용자, 호스트 및 기능 사용량에 대한 익명의 일일 통계를 전송합니다. 개인 정보나 연결 정보는 절대 포함되지 않습니다.", + "analyticsEnabledLockedDesc": "이 설정은 ENABLE_TELEMETRY 환경 변수에 의해 잠겨 있으므로 여기에서 변경할 수 없습니다.", "updateAnalyticsFailed": "분석 설정 업데이트에 실패했습니다.", "sessionSharingGloballyEnabled": "세션 공유 허용", "sessionSharingGloballyEnabledDesc": "라이브 터미널, RDP, VNC 및 Telnet 세션을 인스턴스 전체에서 공유할 수 있도록 허용합니다. 이 기능을 비활성화하면 호스트별 공유 설정이 모두 무시됩니다.", @@ -3323,6 +3383,8 @@ "resetToDefaultsSuccess": "설정이 기본값으로 초기화되었습니다.", "storageModeSwitch": "설정 저장소", "sectionAccount": "계정", + "desktopProfileTitle": "자동 로컬 데스크톱 프로필", + "desktopProfileDescription": "이 프로필은 내장 백엔드에만 사용할 수 있으며 자동으로 로그인됩니다. 로그인 비밀번호가 없으며, 아래의 원격 동기화는 별도의 서버 계정을 사용합니다.", "sectionAppearance": "화면 설정", "sectionSecurity": "보안", "sectionApiKeys": "API 키", @@ -3379,6 +3441,8 @@ "statusColorsDesc": "강조 색상 대신 온라인/오프라인 상태에 녹색/빨간색을 사용합니다.", "pinAppRail": "앱 레일 고정", "pinAppRailDesc": "마우스 오버로 확장되지 않도록 왼쪽 사이드바 앱 레일을 항상 확장된 상태로 유지합니다.", + "openFullscreenSettings": "설정 화면을 전체 화면으로 열기", + "exitFullscreenSettings": "전체 화면 설정 종료", "expandAppRailOnHover": "마우스 오버 시 앱 레일 확장", "expandAppRailOnHoverDesc": "포인터를 올리면 왼쪽 사이드바 앱 레일이 확장되도록 허용합니다.", "settingsNavigation": "탐색", diff --git a/src/ui/locales/translated/nl_NL.json b/src/ui/locales/translated/nl_NL.json index 0f1d1792..dbda7b11 100644 --- a/src/ui/locales/translated/nl_NL.json +++ b/src/ui/locales/translated/nl_NL.json @@ -546,6 +546,7 @@ "sshTools": "SSH-tools", "history": "Geschiedenis", "sessionLogs": "Sessielogboeken", + "sidebarSettings": "Instellingen zijbalk...", "hosts": "Gastheren", "snippets": "Fragmenten", "hostManager": "Hostmanager", @@ -755,6 +756,8 @@ "agentSocketPathLabel": "Agent Socket Path", "agentSocketPathPlaceholder": "Laat dit veld leeg om SSH_AUTH_SOCK te gebruiken.", "agentSocketPathHint": "Laat dit veld leeg om automatisch te detecteren via de omgevingsvariabele SSH_AUTH_SOCK, of voer een aangepast socketpad in (bijv. /run/user/1000/gnupg/S.gpg-agent.ssh).", + "shareSshAuthLabel": "Deel SSH-authenticatie", + "shareSshAuthDesc": "Geef ontvangers versleutelde kopieën van de SSH-authenticatiegegevens van deze host. De persoonlijke inloggegevens van de ontvanger hebben echter nog steeds voorrang.", "tailscaleDeviceSelect": "Selecteer het Tailscale-apparaat", "tailscaleDeviceSelectPlaceholder": "Selecteer een apparaat...", "tailscaleNoApiKey": "Er is geen Tailscale API-sleutel geconfigureerd. Voeg er een toe in de beheerdersinstellingen om apparaatdetectie in te schakelen.", @@ -1173,15 +1176,39 @@ "generateFromPrivateKey": "Genereren vanuit privésleutel", "refreshBtn2": "Vernieuwen", "exitSelectionTitle": "Selectie verlaten", - "exportAll": "Alles exporteren", - "exportForSharing": "Exporteren om te delen", "addHostBtn2": "Voeg een host toe", "addCredentialBtn2": "Voeg inloggegevens toe", "checkingHostStatuses": "De status van de hosts controleren...", "pinnedSection": "Vastgepind", "hostsExported": "Hosts succesvol geëxporteerd", - "hostsShareExported": "Deelbare hosts succesvol geëxporteerd", - "exportFailed": "Exporteren van hosts is mislukt", + "export": { + "menuItem": "Exporteren...", + "title": "Exporteer hosts", + "scope": "Domein", + "scopeAll": "Alle", + "scopeSelected": "Gekozen", + "searchHosts": "Hosts zoeken...", + "include": "Erbij betrekken", + "groupConnection": "Verbinding", + "groupCredentials": "Referenties", + "groupNotes": "Notities", + "groupTags": "Labels en speld", + "groupTunnels": "Tunnels", + "groupJumpHosts": "Jump-hosts", + "groupQuickActions": "Snelle acties", + "groupFeatureFlags": "Functievlaggen", + "groupAdvanced": "Geavanceerde configuratie", + "preview": "Voorbeeld", + "moreHosts": "... {{count}} meer hosts", + "summary": "{{selected}} van {{total}} hosts", + "credentialsIncluded": "referenties inbegrepen", + "credentialsExcluded": "inloggegevens uitgesloten", + "noneSelected": "Geen hosts geselecteerd", + "cancel": "Annuleren", + "confirm": "Exporteren", + "fetchFailed": "Het laden van hosts voor export is mislukt.", + "bulkButton": "Exporteren" + }, "sampleDownloaded": "Voorbeeldbestand gedownload", "failedToDeleteCredential2": "Het verwijderen van de inloggegevens is mislukt.", "noFolderOption": "(Geen map)", @@ -1266,7 +1293,7 @@ }, "edit": { "label": "Bewerking", - "description": "Bekijk en wijzig de host. Geheimen kunnen worden vervangen, maar nooit gelezen; toegangsrechten blijven alleen voor de eigenaar." + "description": "Bekijk en wijzig de instellingen van de host die geen authenticatie vereist. De SSH-authenticatie van de eigenaar blijft privé en alleen toegankelijk voor de eigenaar." }, "manage": { "label": "Beheren", @@ -1299,7 +1326,24 @@ "sharedBadgeTooltip": "Gedeeld door {{owner}} ({{level}} toegang)", "viewOnlyBanner": "Deze host wordt met u gedeeld door {{owner}} met leesrechten. De configuratie is alleen-lezen.", "sharedEditBanner": "Deze host wordt met u gedeeld door {{owner}} met bewerkingsrechten. Wijzigingen zijn van toepassing op de daadwerkelijke host; authenticatiegegevens kunnen alleen door de eigenaar worden gewijzigd.", - "ownerOnlyControl": "Alleen de eigenaar van de host kan dit wijzigen." + "ownerOnlyControl": "Alleen de eigenaar van de host kan dit wijzigen.", + "ownerAuthPrivate": "De SSH-authenticatie van de hosteigenaar is privé. Gebruik 'Persoonlijke SSH-authenticatie instellen' in het hostmenu om uw eigen inloggegevens te kiezen.", + "ownerAuthShared": "De hosteigenaar heeft SSH-authenticatie voor deze host gedeeld. U kunt deze gebruiken of uw eigen inloggegevens kiezen via 'Persoonlijke SSH-authenticatie instellen'.", + "authOverrideAction": "Stel persoonlijke SSH-authenticatie in", + "authOverrideTitle": "Persoonlijke SSH-authenticatie", + "authOverrideDescriptionPrivate": "De SSH-gegevens van de hosteigenaar blijven privé. Kies een van uw opgeslagen gegevens voor verbindingen met {{host}}.", + "authOverrideDescriptionShared": "Gebruik de authenticatiegegevens die door de hosteigenaar worden gedeeld, of vervang deze door een van uw opgeslagen inloggegevens voor verbindingen met {{host}}.", + "authOverrideCredentialLabel": "Authenticatiegegevens", + "useSharedAuthentication": "Gebruik authenticatie voor gedeelde hosts.", + "noPersonalCredential": "Geen persoonlijke referenties", + "authOverrideNoCredentials": "Je hebt nog geen SSH-gegevens opgeslagen. Maak er een aan in 'Referenties' om verbinding te maken met hosts die authenticatie vereisen.", + "authOverrideRequired": "Deze host vereist een van uw opgeslagen inloggegevens voordat u verbinding kunt maken.", + "authOverridePrivateHint": "Deze inloggegevens zijn alleen voor u toegankelijk. De hosteigenaar en andere ontvangers kunnen deze niet zien of gebruiken.", + "authOverrideSaved": "Persoonlijke SSH-authenticatie opgeslagen", + "authOverrideCleared": "Persoonlijke SSH-authenticatie verwijderd", + "authOverrideClearedToShared": "Authenticatie via gedeelde host", + "authOverrideLoadError": "Het laden van uw SSH-authenticatie is mislukt. Probeer het opnieuw.", + "authOverrideSaveError": "Het opslaan van uw SSH-authenticatie is mislukt." }, "guac": { "connection": "Verbinding", @@ -1627,6 +1671,15 @@ "tmuxCopyHint": "Pas je selectie aan en druk op Enter om naar het klembord te kopiëren.", "tmuxDetach": "Ontkoppel van de tmux-sessie", "tmuxDetached": "Losgekoppeld van de tmux-sessie", + "searchPlaceholder": "Vinden", + "searchCaseSensitive": "Lucifersdoosje", + "searchWholeWord": "Combineer het hele woord", + "searchRegex": "Gebruik een reguliere expressie.", + "searchNoResults": "Geen resultaten", + "searchResultCount": "{{index}} van {{count}}", + "searchNext": "Volgende wedstrijd (Invoeren)", + "searchPrevious": "Vorige wedstrijd (Shift+Enter)", + "searchClose": "Sluiten (Escape)", "maxReconnectAttemptsReached": "Maximaal aantal herverbindingspogingen bereikt", "closeTab": "Dichtbij", "connectionTimeout": "Verbindingstime-out", @@ -1654,6 +1707,11 @@ "opksshTimeout": "De authenticatie is mislukt vanwege een time-out. Probeer het opnieuw.", "opksshAuthFailed": "Authenticatie mislukt. Controleer uw inloggegevens en probeer het opnieuw.", "opksshSignInWith": "Aanmelden met {{provider}}", + "tailscaleCheckRequired": "Tailscale-authenticatie vereist", + "tailscaleCheckDescription": "Tailscale SSH vereist een extra controle. Authenticeer in uw browser om verder te gaan.", + "tailscaleCheckOpenBrowser": "Open de browser om te authenticeren.", + "tailscaleCheckWaiting": "Wachten op Tailscale-authenticatie...", + "tailscaleCheckTimeout": "De authenticatie bij Tailscale is mislukt vanwege een time-out. Probeer het opnieuw.", "vaultAuthTitle": "Aanmelden bij Vault is vereist.", "vaultAuthDescription": "Er is een venster geopend om in te loggen bij HashiCorp Vault. Voltooi de aanmelding daar; deze verbinding wordt automatisch tot stand gebracht.", "vaultAuthFailed": "Authenticatie voor de kluis is mislukt. Probeer het opnieuw.", @@ -2145,6 +2203,7 @@ "cpuUsage": "CPU-gebruik", "memoryUsage": "Geheugengebruik", "diskUsage": "Schijfgebruik", + "selectFilesystem": "Selecteer bestandssysteem", "temperature": "Temperatuur", "highestTemperature": "Hoogste temperatuur", "failedToFetchHostConfig": "Het ophalen van de hostconfiguratie is mislukt.", @@ -2819,6 +2878,7 @@ "updateCommandHistoryFailed": "Het bijwerken van de instelling voor de opdrachtgeschiedenis is mislukt.", "analyticsEnabled": "Deel anonieme gebruiksstatistieken", "analyticsEnabledDesc": "Verstuurt dagelijks anoniem een telling van gebruikers, hosts en functiegebruik om Termix te verbeteren. Er worden nooit persoonlijke gegevens of verbindingsdetails meegestuurd.", + "analyticsEnabledLockedDesc": "Deze instelling is vergrendeld door de omgevingsvariabele ENABLE_TELEMETRY en kan hier niet worden gewijzigd.", "updateAnalyticsFailed": "Het bijwerken van de analyse-instellingen is mislukt.", "sessionSharingGloballyEnabled": "Sessiedeling toestaan", "sessionSharingGloballyEnabledDesc": "Sta toe dat live terminal-, RDP-, VNC- en Telnet-sessies instantiebreed worden gedeeld. Deze functie overschrijft elke instelling voor delen per host wanneer deze is uitgeschakeld.", @@ -3323,6 +3383,8 @@ "resetToDefaultsSuccess": "Instellingen teruggezet naar standaardwaarden.", "storageModeSwitch": "Voorkeurenopslag", "sectionAccount": "Rekening", + "desktopProfileTitle": "Automatisch lokaal bureaubladprofiel", + "desktopProfileDescription": "Dit profiel is beperkt tot de ingebouwde backend en meldt zich automatisch aan. Het heeft geen inlogwachtwoord; Remote Sync hieronder gebruikt een apart serveraccount.", "sectionAppearance": "Verschijning", "sectionSecurity": "Beveiliging", "sectionApiKeys": "API-sleutels", @@ -3379,6 +3441,8 @@ "statusColorsDesc": "Gebruik groen/rood voor de online/offline status in plaats van de accentkleur.", "pinAppRail": "Pin App Rail", "pinAppRailDesc": "Houd de app-rail aan de linkerkant altijd uitgevouwen in plaats van deze uit te vouwen wanneer je er met de muis overheen beweegt.", + "openFullscreenSettings": "Open de instellingen in volledig scherm.", + "exitFullscreenSettings": "Instellingen voor volledig scherm verlaten", "expandAppRailOnHover": "App-balk uitklappen bij muisovergang", "expandAppRailOnHoverDesc": "Zorg ervoor dat de app-rail aan de linkerkant uitklapt wanneer de muiswijzer eroverheen beweegt.", "settingsNavigation": "Navigatie", diff --git a/src/ui/locales/translated/no_NO.json b/src/ui/locales/translated/no_NO.json index b58ed134..1558beb4 100644 --- a/src/ui/locales/translated/no_NO.json +++ b/src/ui/locales/translated/no_NO.json @@ -546,6 +546,7 @@ "sshTools": "SSH-verktøy", "history": "Historie", "sessionLogs": "Øktlogger", + "sidebarSettings": "Innstillinger for sidefelt...", "hosts": "Verter", "snippets": "Utdrag", "hostManager": "Vertsadministrator", @@ -755,6 +756,8 @@ "agentSocketPathLabel": "Agent Socket-bane", "agentSocketPathPlaceholder": "La stå tomt for å bruke SSH_AUTH_SOCK", "agentSocketPathHint": "La feltet stå tomt for automatisk deteksjon fra miljøvariabelen SSH_AUTH_SOCK, eller skriv inn en egendefinert socket-sti (f.eks. /run/user/1000/gnupg/S.gpg-agent.ssh).", + "shareSshAuthLabel": "Del SSH-autentisering", + "shareSshAuthDesc": "Gi mottakerne krypterte kopier av denne vertens SSH-autentisering. Mottakerens personlige legitimasjon har fortsatt forrang.", "tailscaleDeviceSelect": "Velg Tailscale-enhet", "tailscaleDeviceSelectPlaceholder": "Velg en enhet...", "tailscaleNoApiKey": "Ingen Tailscale API-nøkkel konfigurert. Legg til en i administratorinnstillingene for å aktivere enhetsgjenkjenning.", @@ -1173,15 +1176,39 @@ "generateFromPrivateKey": "Generer fra privat nøkkel", "refreshBtn2": "Forfriske", "exitSelectionTitle": "Avslutt valg", - "exportAll": "Eksporter alle", - "exportForSharing": "Eksporter for deling", "addHostBtn2": "Legg til vert", "addCredentialBtn2": "Legg til legitimasjon", "checkingHostStatuses": "Sjekker vertsstatuser ...", "pinnedSection": "Festet", "hostsExported": "Verter eksportert", - "hostsShareExported": "Delbare verter eksportert", - "exportFailed": "Kunne ikke eksportere verter", + "export": { + "menuItem": "Eksport...", + "title": "Eksporter verter", + "scope": "Omfang", + "scopeAll": "Alle", + "scopeSelected": "Valgt", + "searchHosts": "Søk etter verter...", + "include": "Inkludere", + "groupConnection": "Forbindelse", + "groupCredentials": "Legitimasjon", + "groupNotes": "Notater", + "groupTags": "Tagger og pin", + "groupTunnels": "Tunneler", + "groupJumpHosts": "Hoppverter", + "groupQuickActions": "Hurtighandlinger", + "groupFeatureFlags": "Funksjonsflagg", + "groupAdvanced": "Avansert konfigurasjon", + "preview": "Forhåndsvisning", + "moreHosts": "... {{count}} flere verter", + "summary": "{{selected}} av {{total}} verter", + "credentialsIncluded": "legitimasjon inkludert", + "credentialsExcluded": "legitimasjon ekskludert", + "noneSelected": "Ingen verter valgt", + "cancel": "Kansellere", + "confirm": "Eksport", + "fetchFailed": "Kunne ikke laste inn verter for eksport", + "bulkButton": "Eksport" + }, "sampleDownloaded": "Eksempelfil nedlastet", "failedToDeleteCredential2": "Kunne ikke slette legitimasjonen", "noFolderOption": "(Ingen mappe)", @@ -1266,7 +1293,7 @@ }, "edit": { "label": "Redigere", - "description": "Vis og endre verten. Hemmeligheter kan erstattes, men aldri leses; tildeling av legitimasjon forblir kun for eieren." + "description": "Vis og endre innstillinger for verter uten autentisering. Eierens SSH-autentisering forblir privat og kun for eieren." }, "manage": { "label": "Administrer", @@ -1299,7 +1326,24 @@ "sharedBadgeTooltip": "Delt av {{owner}} ({{level}} tilgang)", "viewOnlyBanner": "Denne verten deles med deg av {{owner}} med lesetilgang. Konfigurasjonen er skrivebeskyttet.", "sharedEditBanner": "Denne verten deles med deg av {{owner}} med redigeringstilgang. Endringene gjelder for den virkelige verten; autentiseringsreferanser kan bare endres av eieren.", - "ownerOnlyControl": "Bare vertseieren kan endre dette" + "ownerOnlyControl": "Bare vertseieren kan endre dette", + "ownerAuthPrivate": "Vertseierens SSH-autentisering er privat. Bruk «Angi personlig SSH-autentisering» fra vertsmenyen for å velge din egen legitimasjon.", + "ownerAuthShared": "Vertseieren har delt SSH-autentisering for denne verten. Du kan bruke den eller velge din egen legitimasjon fra «Angi personlig SSH-autentisering».", + "authOverrideAction": "Angi personlig SSH-autentisering", + "authOverrideTitle": "Personlig SSH-autentisering", + "authOverrideDescriptionPrivate": "SSH-legitimasjonen til vertseieren forblir privat. Velg en av dine lagrede legitimasjonsopplysninger for tilkoblinger til {{host}}.", + "authOverrideDescriptionShared": "Bruk autentiseringen som deles av vertseieren, eller erstatt den med en av dine lagrede påloggingsinformasjon for tilkoblinger til {{host}}.", + "authOverrideCredentialLabel": "Autentiseringslegitimasjon", + "useSharedAuthentication": "Bruk delt vertsgodkjenning", + "noPersonalCredential": "Ingen personlig legitimasjon", + "authOverrideNoCredentials": "Du har ingen lagrede SSH-legitimasjon ennå. Opprett en i Legitimasjon for å koble til verter som krever autentisering.", + "authOverrideRequired": "Denne verten krever en av dine lagrede påloggingsinformasjon før du kan koble til.", + "authOverridePrivateHint": "Denne legitimasjonen er privat for deg. Vertseieren og andre mottakere kan ikke se eller bruke den.", + "authOverrideSaved": "Personlig SSH-autentisering lagret", + "authOverrideCleared": "Personlig SSH-autentisering fjernet", + "authOverrideClearedToShared": "Bruk av delt vertsgodkjenning", + "authOverrideLoadError": "Kunne ikke laste inn SSH-autentiseringen din. Prøv på nytt.", + "authOverrideSaveError": "Kunne ikke lagre SSH-autentiseringen din" }, "guac": { "connection": "Forbindelse", @@ -1627,6 +1671,15 @@ "tmuxCopyHint": "Juster valget og trykk Enter for å kopiere til utklippstavlen", "tmuxDetach": "Koble fra tmux-økten", "tmuxDetached": "Frakoblet fra tmux-økten", + "searchPlaceholder": "Finne", + "searchCaseSensitive": "Match-saken", + "searchWholeWord": "Finn hele ordet", + "searchRegex": "Bruk regulært uttrykk", + "searchNoResults": "Ingen resultater", + "searchResultCount": "{{index}} av {{count}}", + "searchNext": "Neste kamp (Enter)", + "searchPrevious": "Forrige treff (Shift+Enter)", + "searchClose": "Lukk (Escape)", "maxReconnectAttemptsReached": "Maksimalt antall forsøk på å koble til igjen er nådd", "closeTab": "Lukke", "connectionTimeout": "Tidsavbrudd for tilkobling", @@ -1654,6 +1707,11 @@ "opksshTimeout": "Autentiseringen ble tidsavbrutt. Prøv på nytt.", "opksshAuthFailed": "Autentiseringen mislyktes. Sjekk legitimasjonen din og prøv på nytt.", "opksshSignInWith": "Logg inn med {{provider}}", + "tailscaleCheckRequired": "Tailscale-autentisering kreves", + "tailscaleCheckDescription": "Tailscale SSH krever en ekstra sjekk. Autentiser i nettleseren din for å fortsette.", + "tailscaleCheckOpenBrowser": "Åpne nettleseren for å autentisere", + "tailscaleCheckWaiting": "Venter på Tailscale-autentisering...", + "tailscaleCheckTimeout": "Tailscale-autentiseringen er tidsavbrutt. Prøv på nytt.", "vaultAuthTitle": "Pålogging til arkivet kreves", "vaultAuthDescription": "Et vindu har åpnet seg for å logge på HashiCorp Vault. Fullfør påloggingen der; denne tilkoblingen vil fortsette automatisk.", "vaultAuthFailed": "Hvelv-autentisering mislyktes. Prøv på nytt.", @@ -2145,6 +2203,7 @@ "cpuUsage": "CPU-bruk", "memoryUsage": "Minnebruk", "diskUsage": "Diskbruk", + "selectFilesystem": "Velg filsystem", "temperature": "Temperatur", "highestTemperature": "Høyeste temperatur", "failedToFetchHostConfig": "Kunne ikke hente vertskonfigurasjonen", @@ -2819,6 +2878,7 @@ "updateCommandHistoryFailed": "Kunne ikke oppdatere innstillingen for kommandohistorikk", "analyticsEnabled": "Del anonym bruksstatistikk", "analyticsEnabledDesc": "Sender en anonym daglig telling av brukere, verter og funksjonsbruk for å forbedre Termix. Ingen personopplysninger eller tilkoblingsdetaljer inkluderes.", + "analyticsEnabledLockedDesc": "Denne innstillingen er låst av miljøvariabelen ENABLE_TELEMETRY og kan ikke endres her.", "updateAnalyticsFailed": "Kunne ikke oppdatere analyseinnstillingen", "sessionSharingGloballyEnabled": "Tillat øktdeling", "sessionSharingGloballyEnabledDesc": "Tillat deling av Live Terminal-, RDP-, VNC- og Telnet-økter på tvers av hele instansen. Overstyrer alle delingsalternativer per vert når de er deaktivert.", @@ -3323,6 +3383,8 @@ "resetToDefaultsSuccess": "Innstillingene tilbakestilles til standardinnstillinger.", "storageModeSwitch": "Preferanselagring", "sectionAccount": "Konto", + "desktopProfileTitle": "Automatisk lokal skrivebordsprofil", + "desktopProfileDescription": "Denne profilen er begrenset til den innebygde backend-funksjonen og logger seg på automatisk. Den har ikke noe påloggingspassord; Ekstern synkronisering nedenfor bruker en separat serverkonto.", "sectionAppearance": "Utseende", "sectionSecurity": "Sikkerhet", "sectionApiKeys": "API-nøkler", @@ -3379,6 +3441,8 @@ "statusColorsDesc": "Bruk grønn/rød for online/offline-status i stedet for aksentfargen", "pinAppRail": "Pin App Rail", "pinAppRailDesc": "Hold appskinnen i venstre sidefelt alltid utvidet i stedet for å utvides når du holder musepekeren over den", + "openFullscreenSettings": "Åpne innstillinger i fullskjermmodus", + "exitFullscreenSettings": "Avslutt fullskjerminnstillinger", "expandAppRailOnHover": "Utvid appskinnen ved musepeker", "expandAppRailOnHoverDesc": "Tillat at appskinnen i venstre sidefelt utvides når pekeren beveger seg over den", "settingsNavigation": "Navigasjon", diff --git a/src/ui/locales/translated/pl_PL.json b/src/ui/locales/translated/pl_PL.json index 5309fbdd..06597e44 100644 --- a/src/ui/locales/translated/pl_PL.json +++ b/src/ui/locales/translated/pl_PL.json @@ -546,6 +546,7 @@ "sshTools": "Narzędzia SSH", "history": "Historia", "sessionLogs": "Dzienniki sesji", + "sidebarSettings": "Ustawienia paska bocznego...", "hosts": "Zastępy niebieskie", "snippets": "Fragmenty", "hostManager": "Menedżer hosta", @@ -755,6 +756,8 @@ "agentSocketPathLabel": "Ścieżka gniazda agenta", "agentSocketPathPlaceholder": "Pozostaw puste, aby użyć SSH_AUTH_SOCK", "agentSocketPathHint": "Pozostaw puste, aby automatycznie wykryć na podstawie zmiennej środowiskowej SSH_AUTH_SOCK, lub wprowadź niestandardową ścieżkę gniazda (np. /run/user/1000/gnupg/S.gpg-agent.ssh).", + "shareSshAuthLabel": "Udostępnij uwierzytelnianie SSH", + "shareSshAuthDesc": "Przekaż odbiorcom zaszyfrowane kopie uwierzytelnienia SSH tego hosta. Dane osobowe odbiorcy nadal mają pierwszeństwo.", "tailscaleDeviceSelect": "Wybierz urządzenie Tailscale", "tailscaleDeviceSelectPlaceholder": "Wybierz urządzenie...", "tailscaleNoApiKey": "Nie skonfigurowano klucza API Tailscale. Dodaj go w Ustawieniach administratora, aby włączyć wykrywanie urządzeń.", @@ -1173,15 +1176,39 @@ "generateFromPrivateKey": "Wygeneruj z klucza prywatnego", "refreshBtn2": "Odświeżać", "exitSelectionTitle": "Wyjście z wyboru", - "exportAll": "Eksportuj wszystko", - "exportForSharing": "Eksportuj w celu udostępnienia", "addHostBtn2": "Dodaj hosta", "addCredentialBtn2": "Dodaj poświadczenia", "checkingHostStatuses": "Sprawdzanie statusu hosta...", "pinnedSection": "Przypięte", "hostsExported": "Hosty zostały pomyślnie wyeksportowane", - "hostsShareExported": "Udostępniane hosty zostały pomyślnie wyeksportowane", - "exportFailed": "Nie udało się wyeksportować hostów", + "export": { + "menuItem": "Eksport...", + "title": "Eksportuj hosty", + "scope": "Zakres", + "scopeAll": "Wszystko", + "scopeSelected": "Wybrany", + "searchHosts": "Wyszukaj hostów...", + "include": "Włączać", + "groupConnection": "Połączenie", + "groupCredentials": "Referencje", + "groupNotes": "Notatki", + "groupTags": "Tagi i przypinanie", + "groupTunnels": "Tunele", + "groupJumpHosts": "Hosty skoku", + "groupQuickActions": "Szybkie akcje", + "groupFeatureFlags": "Flagi funkcji", + "groupAdvanced": "Zaawansowana konfiguracja", + "preview": "Zapowiedź", + "moreHosts": "... {{count}} więcej gospodarzy", + "summary": "{{selected}} z {{total}} gospodarzy", + "credentialsIncluded": "dołączone poświadczenia", + "credentialsExcluded": "poświadczenia wykluczone", + "noneSelected": "Nie wybrano żadnych gospodarzy", + "cancel": "Anulować", + "confirm": "Eksport", + "fetchFailed": "Nie udało się załadować hostów do eksportu", + "bulkButton": "Eksport" + }, "sampleDownloaded": "Pobrano przykładowy plik", "failedToDeleteCredential2": "Nie udało się usunąć poświadczeń", "noFolderOption": "(Brak folderu)", @@ -1266,7 +1293,7 @@ }, "edit": { "label": "Redagować", - "description": "Wyświetlaj i modyfikuj hosta. Sekrety można zastąpić, ale nigdy odczytać; przypisanie poświadczeń pozostaje przypisane tylko właścicielowi." + "description": "Przeglądaj i modyfikuj ustawienia hosta bez uwierzytelniania. Uwierzytelnianie SSH właściciela pozostaje prywatne i dostępne tylko dla właściciela." }, "manage": { "label": "Zarządzać", @@ -1299,7 +1326,24 @@ "sharedBadgeTooltip": "Udostępnione przez {{owner}} (dostęp{{level}})", "viewOnlyBanner": "Ten host jest udostępniany Tobie przez {{owner}} z dostępem do przeglądania. Konfiguracja jest tylko do odczytu.", "sharedEditBanner": "Ten host jest udostępniany Tobie przez {{owner}} z dostępem do edycji. Zmiany dotyczą rzeczywistego hosta; referencje uwierzytelniające może zmieniać tylko właściciel.", - "ownerOnlyControl": "Tylko właściciel hosta może to zmienić" + "ownerOnlyControl": "Tylko właściciel hosta może to zmienić", + "ownerAuthPrivate": "Uwierzytelnianie SSH właściciela hosta jest prywatne. Użyj opcji „Ustaw osobiste uwierzytelnianie SSH” w menu hosta, aby wybrać własne dane uwierzytelniające.", + "ownerAuthShared": "Właściciel hosta udostępnił uwierzytelnianie SSH dla tego hosta. Możesz z niego skorzystać lub wybrać własne dane uwierzytelniające w sekcji „Ustaw osobiste uwierzytelnianie SSH”.", + "authOverrideAction": "Ustaw osobiste uwierzytelnianie SSH", + "authOverrideTitle": "Osobiste uwierzytelnianie SSH", + "authOverrideDescriptionPrivate": "Dane logowania SSH właściciela hosta pozostają prywatne. Wybierz jedno z zapisanych danych logowania do połączeń z {{host}}.", + "authOverrideDescriptionShared": "Użyj uwierzytelniania udostępnionego przez właściciela hosta lub zastąp je jednym ze swoich zapisanych poświadczeń w przypadku połączeń z {{host}}.", + "authOverrideCredentialLabel": "Dane uwierzytelniające", + "useSharedAuthentication": "Użyj uwierzytelniania hosta współdzielonego", + "noPersonalCredential": "Brak danych osobowych", + "authOverrideNoCredentials": "Nie masz jeszcze żadnych zapisanych danych logowania SSH. Utwórz je w sekcji Dane logowania, aby łączyć się z hostami wymagającymi uwierzytelnienia.", + "authOverrideRequired": "Aby móc się połączyć, ten host wymaga podania jednego z zapisanych przez Ciebie danych uwierzytelniających.", + "authOverridePrivateHint": "Te dane uwierzytelniające są prywatne i dostępne tylko dla Ciebie. Właściciel hosta i inni odbiorcy nie mogą ich zobaczyć ani używać.", + "authOverrideSaved": "Zapisano osobiste uwierzytelnianie SSH", + "authOverrideCleared": "Usunięto osobiste uwierzytelnianie SSH", + "authOverrideClearedToShared": "Korzystanie z uwierzytelniania hosta współdzielonego", + "authOverrideLoadError": "Nie udało się załadować uwierzytelnienia SSH. Spróbuj ponownie.", + "authOverrideSaveError": "Nie udało się zapisać uwierzytelnienia SSH" }, "guac": { "connection": "Połączenie", @@ -1627,6 +1671,15 @@ "tmuxCopyHint": "Dostosuj wybór i naciśnij Enter, aby skopiować do schowka", "tmuxDetach": "Odłącz od sesji tmux", "tmuxDetached": "Odłączono od sesji tmux", + "searchPlaceholder": "Znajdować", + "searchCaseSensitive": "Etui na zapałki", + "searchWholeWord": "Dopasuj całe słowo", + "searchRegex": "Użyj wyrażenia regularnego", + "searchNoResults": "Brak wyników", + "searchResultCount": "{{index}} z {{count}}", + "searchNext": "Następny mecz (Enter)", + "searchPrevious": "Poprzednie dopasowanie (Shift+Enter)", + "searchClose": "Zamknij (Ucieczka)", "maxReconnectAttemptsReached": "Osiągnięto maksymalną liczbę prób ponownego połączenia", "closeTab": "Zamknąć", "connectionTimeout": "Przekroczono limit czasu połączenia", @@ -1654,6 +1707,11 @@ "opksshTimeout": "Utracono limit czasu uwierzytelniania. Spróbuj ponownie.", "opksshAuthFailed": "Uwierzytelnienie nie powiodło się. Sprawdź swoje dane logowania i spróbuj ponownie.", "opksshSignInWith": "Zaloguj się za pomocą {{provider}}", + "tailscaleCheckRequired": "Wymagane uwierzytelnienie Tailscale", + "tailscaleCheckDescription": "Tailscale SSH wymaga dodatkowej weryfikacji. Uwierzytelnij się w przeglądarce, aby kontynuować.", + "tailscaleCheckOpenBrowser": "Otwórz przeglądarkę, aby uwierzytelnić", + "tailscaleCheckWaiting": "Oczekiwanie na uwierzytelnienie Tailscale...", + "tailscaleCheckTimeout": "Upłynął limit czasu uwierzytelniania Tailscale. Spróbuj ponownie.", "vaultAuthTitle": "Wymagane jest zalogowanie się do Vault", "vaultAuthDescription": "Otworzyło się okno logowania do HashiCorp Vault. Dokończ logowanie, a połączenie zostanie nawiązane automatycznie.", "vaultAuthFailed": "Uwierzytelnienie skarbca nie powiodło się. Spróbuj ponownie.", @@ -2145,6 +2203,7 @@ "cpuUsage": "Wykorzystanie procesora", "memoryUsage": "Wykorzystanie pamięci", "diskUsage": "Wykorzystanie dysku", + "selectFilesystem": "Wybierz system plików", "temperature": "Temperatura", "highestTemperature": "Najwyższa temperatura", "failedToFetchHostConfig": "Nie udało się pobrać konfiguracji hosta", @@ -2819,6 +2878,7 @@ "updateCommandHistoryFailed": "Nie udało się zaktualizować ustawień historii poleceń", "analyticsEnabled": "Udostępnij anonimowe statystyki użytkowania", "analyticsEnabledDesc": "Wysyła anonimowe, codzienne zestawienie użytkowników, hostów i wykorzystania funkcji, aby pomóc w ulepszaniu Termix. Nie zawiera ono żadnych danych osobowych ani szczegółów połączenia.", + "analyticsEnabledLockedDesc": "To ustawienie jest zablokowane przez zmienną środowiskową ENABLE_TELEMETRY i nie można go tutaj zmienić.", "updateAnalyticsFailed": "Nie udało się zaktualizować ustawień analityki", "sessionSharingGloballyEnabled": "Zezwól na udostępnianie sesji", "sessionSharingGloballyEnabledDesc": "Zezwalaj na udostępnianie sesji terminala na żywo, RDP, VNC i Telnet w obrębie instancji. Po wyłączeniu zastępuje wszystkie przełączniki udostępniania dla poszczególnych hostów.", @@ -3323,6 +3383,8 @@ "resetToDefaultsSuccess": "Ustawienia zostały przywrócone do domyślnych.", "storageModeSwitch": "Preferencje dotyczące przechowywania", "sectionAccount": "Konto", + "desktopProfileTitle": "Automatyczny profil pulpitu lokalnego", + "desktopProfileDescription": "Ten profil jest ograniczony do wbudowanego zaplecza i loguje automatycznie. Nie ma hasła logowania; synchronizacja zdalna poniżej korzysta z osobnego konta na serwerze.", "sectionAppearance": "Wygląd", "sectionSecurity": "Bezpieczeństwo", "sectionApiKeys": "Klucze API", @@ -3379,6 +3441,8 @@ "statusColorsDesc": "Do oznaczania statusu online/offline używaj koloru zielonego/czerwonego zamiast koloru akcentującego", "pinAppRail": "Aplikacja Pin Rail", "pinAppRailDesc": "Utrzymuj zawsze rozwinięty lewy pasek boczny aplikacji zamiast rozwijać go po najechaniu kursorem", + "openFullscreenSettings": "Otwórz ustawienia w trybie pełnoekranowym", + "exitFullscreenSettings": "Wyjdź z ustawień pełnoekranowych", "expandAppRailOnHover": "Rozwiń App Rail po najechaniu kursorem", "expandAppRailOnHoverDesc": "Zezwól na rozszerzanie się lewego paska bocznego aplikacji, gdy najedziesz na niego kursorem", "settingsNavigation": "Nawigacja", diff --git a/src/ui/locales/translated/pt_BR.json b/src/ui/locales/translated/pt_BR.json index 7f9f9ade..e776ffc6 100644 --- a/src/ui/locales/translated/pt_BR.json +++ b/src/ui/locales/translated/pt_BR.json @@ -546,6 +546,7 @@ "sshTools": "Ferramentas SSH", "history": "História", "sessionLogs": "Registros de sessão", + "sidebarSettings": "Configurações da barra lateral...", "hosts": "Anfitriões", "snippets": "Trechos", "hostManager": "Gerente de Hospedagem", @@ -755,6 +756,8 @@ "agentSocketPathLabel": "Caminho do soquete do agente", "agentSocketPathPlaceholder": "Deixe em branco para usar SSH_AUTH_SOCK", "agentSocketPathHint": "Deixe em branco para detecção automática a partir da variável de ambiente SSH_AUTH_SOCK ou insira um caminho de socket personalizado (por exemplo, /run/user/1000/gnupg/S.gpg-agent.ssh).", + "shareSshAuthLabel": "Compartilhar autenticação SSH", + "shareSshAuthDesc": "Forneça aos destinatários cópias criptografadas da autenticação SSH deste host. As credenciais pessoais do destinatário ainda têm precedência.", "tailscaleDeviceSelect": "Selecione o dispositivo Tailscale", "tailscaleDeviceSelectPlaceholder": "Selecione um dispositivo...", "tailscaleNoApiKey": "Nenhuma chave de API do Tailscale configurada. Adicione uma nas Configurações de Administrador para habilitar a descoberta de dispositivos.", @@ -1173,15 +1176,39 @@ "generateFromPrivateKey": "Gerar a partir da chave privada", "refreshBtn2": "Atualizar", "exitSelectionTitle": "Seleção de saída", - "exportAll": "Exportar tudo", - "exportForSharing": "Exportar para compartilhamento", "addHostBtn2": "Adicionar host", "addCredentialBtn2": "Adicionar credencial", "checkingHostStatuses": "Verificando o status dos hosts...", "pinnedSection": "Fixado", "hostsExported": "Os hosts foram exportados com sucesso.", - "hostsShareExported": "Hosts compartilháveis exportados com sucesso", - "exportFailed": "Falha ao exportar hosts", + "export": { + "menuItem": "Exportar...", + "title": "Hosts de exportação", + "scope": "Escopo", + "scopeAll": "Todos", + "scopeSelected": "Selecionado", + "searchHosts": "Pesquisar hosts...", + "include": "Incluir", + "groupConnection": "Conexão", + "groupCredentials": "Credenciais", + "groupNotes": "Notas", + "groupTags": "Etiquetas e alfinete", + "groupTunnels": "Túneis", + "groupJumpHosts": "Apresentadores do Jump", + "groupQuickActions": "Ações rápidas", + "groupFeatureFlags": "Sinalizadores de recursos", + "groupAdvanced": "Configuração avançada", + "preview": "Pré-visualização", + "moreHosts": "... {{count}} mais hosts", + "summary": "{{selected}} de {{total}} anfitriões", + "credentialsIncluded": "credenciais incluídas", + "credentialsExcluded": "credenciais excluídas", + "noneSelected": "Nenhum host selecionado", + "cancel": "Cancelar", + "confirm": "Exportar", + "fetchFailed": "Falha ao carregar os hosts para exportação.", + "bulkButton": "Exportar" + }, "sampleDownloaded": "Arquivo de amostra baixado", "failedToDeleteCredential2": "Falha ao excluir credencial", "noFolderOption": "(Sem pasta)", @@ -1266,7 +1293,7 @@ }, "edit": { "label": "Editar", - "description": "Visualize e modifique o host. Os segredos podem ser substituídos, mas nunca lidos; as atribuições de credenciais permanecem exclusivas do proprietário." + "description": "Visualize e modifique as configurações do host que não exigem autenticação. A autenticação SSH do proprietário permanece privada e restrita ao proprietário." }, "manage": { "label": "Gerenciar", @@ -1299,7 +1326,24 @@ "sharedBadgeTooltip": "Compartilhado por {{owner}} ({{level}} acesso)", "viewOnlyBanner": "Este host foi compartilhado com você por {{owner}} com acesso de visualização. A configuração é somente leitura.", "sharedEditBanner": "Este host foi compartilhado com você por {{owner}} com acesso de edição. As alterações se aplicam ao host real; as referências de autenticação só podem ser alteradas pelo proprietário.", - "ownerOnlyControl": "Somente o proprietário do host pode alterar isso." + "ownerOnlyControl": "Somente o proprietário do host pode alterar isso.", + "ownerAuthPrivate": "A autenticação SSH do proprietário do host é privada. Use a opção “Definir autenticação SSH pessoal” no menu do host para escolher suas próprias credenciais.", + "ownerAuthShared": "O proprietário do host compartilhou a autenticação SSH para este host. Você pode usá-la ou escolher suas próprias credenciais em \"Definir autenticação SSH pessoal\".", + "authOverrideAction": "Configure a autenticação SSH pessoal.", + "authOverrideTitle": "Autenticação SSH pessoal", + "authOverrideDescriptionPrivate": "As credenciais SSH do proprietário do host permanecem privadas. Escolha uma das suas credenciais salvas para conexões com {{host}}.", + "authOverrideDescriptionShared": "Use a autenticação compartilhada pelo proprietário do host ou substitua-a por uma de suas credenciais salvas para conexões com {{host}}.", + "authOverrideCredentialLabel": "Credencial de autenticação", + "useSharedAuthentication": "Usar autenticação de host compartilhada", + "noPersonalCredential": "Sem credencial pessoal", + "authOverrideNoCredentials": "Você ainda não salvou nenhuma credencial SSH. Crie uma em Credenciais para se conectar a hosts que exigem autenticação.", + "authOverrideRequired": "Este host requer uma de suas credenciais salvas para que você possa se conectar.", + "authOverridePrivateHint": "Esta credencial é privada e intransferível. O proprietário do host e outros destinatários não podem vê-la nem usá-la.", + "authOverrideSaved": "Autenticação SSH pessoal salva", + "authOverrideCleared": "Autenticação SSH pessoal removida", + "authOverrideClearedToShared": "Usando autenticação de host compartilhado", + "authOverrideLoadError": "Falha ao carregar sua autenticação SSH. Tente novamente.", + "authOverrideSaveError": "Falha ao salvar sua autenticação SSH." }, "guac": { "connection": "Conexão", @@ -1627,6 +1671,15 @@ "tmuxCopyHint": "Ajuste a seleção e pressione Enter para copiar para a área de transferência.", "tmuxDetach": "Desconectar da sessão tmux", "tmuxDetached": "Sessão desconectada do tmux", + "searchPlaceholder": "Encontrar", + "searchCaseSensitive": "Caixa de fósforos", + "searchWholeWord": "Combine a palavra inteira", + "searchRegex": "Utilizar expressão regular", + "searchNoResults": "Nenhum resultado", + "searchResultCount": "{{index}} de {{count}}", + "searchNext": "Próxima partida (Entrar)", + "searchPrevious": "Partida anterior (Shift+Enter)", + "searchClose": "Fechar (Esc)", "maxReconnectAttemptsReached": "Número máximo de tentativas de reconexão atingido", "closeTab": "Fechar", "connectionTimeout": "Tempo limite de conexão", @@ -1654,6 +1707,11 @@ "opksshTimeout": "A autenticação expirou. Tente novamente.", "opksshAuthFailed": "A autenticação falhou. Verifique suas credenciais e tente novamente.", "opksshSignInWith": "Faça login com {{provider}}", + "tailscaleCheckRequired": "Autenticação Tailscale necessária", + "tailscaleCheckDescription": "O Tailscale SSH requer uma verificação adicional. Autentique-se no seu navegador para continuar.", + "tailscaleCheckOpenBrowser": "Abra o navegador para autenticar.", + "tailscaleCheckWaiting": "Aguardando autenticação do Tailscale...", + "tailscaleCheckTimeout": "A autenticação no Tailscale expirou. Tente novamente.", "vaultAuthTitle": "É necessário fazer login no cofre.", "vaultAuthDescription": "Uma janela foi aberta para fazer login no HashiCorp Vault. Conclua o login; esta conexão será estabelecida automaticamente.", "vaultAuthFailed": "A autenticação do cofre falhou. Tente novamente.", @@ -2145,6 +2203,7 @@ "cpuUsage": "Utilização da CPU", "memoryUsage": "Uso de memória", "diskUsage": "Utilização do disco", + "selectFilesystem": "Selecione o sistema de arquivos", "temperature": "Temperatura", "highestTemperature": "Temperatura mais alta", "failedToFetchHostConfig": "Falha ao obter a configuração do host", @@ -2819,6 +2878,7 @@ "updateCommandHistoryFailed": "Falha ao atualizar a configuração do histórico de comandos", "analyticsEnabled": "Compartilhar estatísticas de uso anônimas", "analyticsEnabledDesc": "Envia uma contagem diária anônima de usuários, hosts e uso de recursos para ajudar a melhorar o Termix. Nenhum dado pessoal ou detalhe de conexão é incluído.", + "analyticsEnabledLockedDesc": "Essa configuração está bloqueada pela variável de ambiente ENABLE_TELEMETRY e não pode ser alterada aqui.", "updateAnalyticsFailed": "Falha ao atualizar as configurações de análise", "sessionSharingGloballyEnabled": "Permitir compartilhamento de sessão", "sessionSharingGloballyEnabledDesc": "Permite que sessões de terminal ao vivo, RDP, VNC e Telnet sejam compartilhadas em toda a instância. Substitui todas as configurações de compartilhamento por host quando desativada.", @@ -3323,6 +3383,8 @@ "resetToDefaultsSuccess": "As configurações foram redefinidas para os padrões de fábrica.", "storageModeSwitch": "Armazenamento de preferências", "sectionAccount": "Conta", + "desktopProfileTitle": "Perfil de área de trabalho local automático", + "desktopProfileDescription": "Este perfil é restrito ao backend integrado e o login é automático. Não possui senha de login; a Sincronização Remota abaixo utiliza uma conta de servidor separada.", "sectionAppearance": "Aparência", "sectionSecurity": "Segurança", "sectionApiKeys": "Chaves de API", @@ -3379,6 +3441,8 @@ "statusColorsDesc": "Use verde/vermelho para indicar o status online/offline em vez da cor de destaque.", "pinAppRail": "Aplicativo Pin Rail", "pinAppRailDesc": "Mantenha a barra lateral esquerda do aplicativo sempre expandida, em vez de expandir ao passar o cursor sobre ela.", + "openFullscreenSettings": "Abrir configurações em tela cheia", + "exitFullscreenSettings": "Sair do modo de tela cheia", "expandAppRailOnHover": "Expandir o App Rail ao passar o cursor", "expandAppRailOnHoverDesc": "Permitir que a barra lateral esquerda se expanda quando o ponteiro passar sobre ela.", "settingsNavigation": "Navegação", diff --git a/src/ui/locales/translated/pt_PT.json b/src/ui/locales/translated/pt_PT.json index b13e1042..5e5c8990 100644 --- a/src/ui/locales/translated/pt_PT.json +++ b/src/ui/locales/translated/pt_PT.json @@ -546,6 +546,7 @@ "sshTools": "Ferramentas SSH", "history": "Histórico", "sessionLogs": "Registos de Sessão", + "sidebarSettings": "Definições da barra lateral...", "hosts": "Máquinas", "snippets": "Fragmentos", "hostManager": "Gestor de Máquinas", @@ -755,6 +756,8 @@ "agentSocketPathLabel": "Caminho do socket do agente", "agentSocketPathPlaceholder": "Deixar vazio para usar SSH_AUTH_SOCK", "agentSocketPathHint": "Deixar vazio para detetar automaticamente a partir da variável de ambiente SSH_AUTH_SOCK, ou introduza um caminho de socket personalizado (ex.: /run/user/1000/gnupg/S.gpg-agent.ssh).", + "shareSshAuthLabel": "Partilhar autenticação SSH", + "shareSshAuthDesc": "Forneça aos destinatários cópias encriptadas da autenticação SSH deste host. As credenciais pessoais do destinatário ainda têm precedência.", "tailscaleDeviceSelect": "Selecionar dispositivo Tailscale", "tailscaleDeviceSelectPlaceholder": "Selecionar um dispositivo...", "tailscaleNoApiKey": "Nenhuma chave de API Tailscale configurada. Adicione uma nas Definições de Administrador para ativar a descoberta de dispositivos.", @@ -1173,15 +1176,39 @@ "generateFromPrivateKey": "Gerar a partir da chave privada", "refreshBtn2": "Atualizar", "exitSelectionTitle": "Sair da seleção", - "exportAll": "Exportar tudo", - "exportForSharing": "Exportar para partilha", "addHostBtn2": "Adicionar host", "addCredentialBtn2": "Adicionar credencial", "checkingHostStatuses": "A verificar o estado dos hosts...", "pinnedSection": "Fixados", "hostsExported": "Hosts exportados com sucesso", - "hostsShareExported": "Hosts partilháveis exportados com sucesso", - "exportFailed": "Falha ao exportar hosts", + "export": { + "menuItem": "Exportar...", + "title": "Hosts de exportação", + "scope": "Âmbito", + "scopeAll": "Tudo", + "scopeSelected": "Selecionado", + "searchHosts": "Pesquisar hosts...", + "include": "Incluir", + "groupConnection": "Conexão", + "groupCredentials": "Credenciais", + "groupNotes": "Notas", + "groupTags": "Etiquetas e alfinete", + "groupTunnels": "Túneis", + "groupJumpHosts": "Apresentadores do Jump", + "groupQuickActions": "Ações rápidas", + "groupFeatureFlags": "Sinalizadores de recursos", + "groupAdvanced": "Configuração avançada", + "preview": "Pré-visualização", + "moreHosts": "... {{count}} mais hosts", + "summary": "{{selected}} de {{total}} anfitriões", + "credentialsIncluded": "credenciais incluídas", + "credentialsExcluded": "credenciais excluídas", + "noneSelected": "Nenhum host selecionado", + "cancel": "Cancelar", + "confirm": "Exportar", + "fetchFailed": "Falha ao carregar os hosts para exportação.", + "bulkButton": "Exportar" + }, "sampleDownloaded": "Ficheiro de exemplo descarregado", "failedToDeleteCredential2": "Falha ao eliminar a credencial", "noFolderOption": "(Sem pasta)", @@ -1266,7 +1293,7 @@ }, "edit": { "label": "Editar", - "description": "Ver e modificar o anfitrião. Os segredos podem ser substituídos, mas nunca lidos; as atribuições de credenciais permanecem apenas para o proprietário." + "description": "Visualize e modifique as definições do host que não requerem autenticação. A autenticação SSH do proprietário permanece privada e restrita ao proprietário." }, "manage": { "label": "Gerir", @@ -1299,7 +1326,24 @@ "sharedBadgeTooltip": "Partilhado por {{owner}} (acesso de {{level}})", "viewOnlyBanner": "Este anfitrião é partilhado consigo por {{owner}} com acesso de visualização. A configuração é só de leitura.", "sharedEditBanner": "Este anfitrião é partilhado consigo por {{owner}} com acesso de edição. As alterações aplicam-se ao anfitrião real; as referências de autenticação só podem ser alteradas pelo proprietário.", - "ownerOnlyControl": "Apenas o proprietário do anfitrião pode alterar isto" + "ownerOnlyControl": "Apenas o proprietário do anfitrião pode alterar isto", + "ownerAuthPrivate": "A autenticação SSH do proprietário do host é privada. Utilize a opção “Definir autenticação SSH pessoal” no menu do host para escolher as suas próprias credenciais.", + "ownerAuthShared": "O proprietário do host partilhou a autenticação SSH para este host. Pode utilizá-la ou escolher as suas próprias credenciais em \"Definir autenticação SSH pessoal\".", + "authOverrideAction": "Configure a autenticação SSH pessoal.", + "authOverrideTitle": "Autenticação SSH pessoal", + "authOverrideDescriptionPrivate": "As credenciais SSH do proprietário do host permanecem privadas. Escolha uma das suas credenciais guardadas para ligações a {{host}}.", + "authOverrideDescriptionShared": "Utilize a autenticação partilhada pelo proprietário do host ou substitua-a por uma das suas credenciais guardadas para ligações a {{host}}.", + "authOverrideCredentialLabel": "Credencial de autenticação", + "useSharedAuthentication": "Utilizar autenticação de host partilhada", + "noPersonalCredential": "Sem credencial pessoal", + "authOverrideNoCredentials": "Ainda não guardou nenhuma credencial SSH. Crie uma em Credenciais para se ligar a hosts que exijam autenticação.", + "authOverrideRequired": "Este host requer uma das suas credenciais guardadas para que se possa ligar.", + "authOverridePrivateHint": "Esta credencial é privada e intransmissível. O proprietário do host e outros destinatários não a podem ver nem utilizar.", + "authOverrideSaved": "Autenticação SSH pessoal guardada", + "authOverrideCleared": "Autenticação SSH pessoal removida", + "authOverrideClearedToShared": "Utilizando autenticação de host partilhado", + "authOverrideLoadError": "Falha ao carregar a sua autenticação SSH. Tente novamente.", + "authOverrideSaveError": "Falha ao guardar a sua autenticação SSH." }, "guac": { "connection": "Ligação", @@ -1627,6 +1671,15 @@ "tmuxCopyHint": "Ajuste a seleção e prima Enter para copiar para a área de transferência", "tmuxDetach": "Desanexar da sessão tmux", "tmuxDetached": "Desanexado da sessão tmux", + "searchPlaceholder": "Encontrar", + "searchCaseSensitive": "Caixa de fósforos", + "searchWholeWord": "Combine a palavra inteira", + "searchRegex": "Utilizar expressão regular", + "searchNoResults": "Nenhum resultado", + "searchResultCount": "{{index}} de {{count}}", + "searchNext": "Próxima partida (Entrar)", + "searchPrevious": "Partida anterior (Shift+Enter)", + "searchClose": "Fechar (Esc)", "maxReconnectAttemptsReached": "Número máximo de tentativas de restabelecimento de ligação atingido", "closeTab": "Fechar", "connectionTimeout": "Tempo limite da ligação", @@ -1654,6 +1707,11 @@ "opksshTimeout": "A autenticação expirou. Tente novamente.", "opksshAuthFailed": "A autenticação falhou. Verifique as suas credenciais e tente novamente.", "opksshSignInWith": "Iniciar sessão com {{provider}}", + "tailscaleCheckRequired": "Autenticação Tailscale necessária", + "tailscaleCheckDescription": "O Tailscale SSH requer uma verificação adicional. Autentique-se no seu browser para continuar.", + "tailscaleCheckOpenBrowser": "Abra o browser para autenticar.", + "tailscaleCheckWaiting": "Aguarda-se autenticação do Tailscale...", + "tailscaleCheckTimeout": "A autenticação no Tailscale expirou. Tente novamente.", "vaultAuthTitle": "Início de sessão no Vault necessário", "vaultAuthDescription": "Foi aberta uma janela para iniciar sessão no HashiCorp Vault. Conclua o início de sessão aí; esta ligação continuará automaticamente.", "vaultAuthFailed": "A autenticação no Vault falhou. Tente novamente.", @@ -2145,6 +2203,7 @@ "cpuUsage": "Utilização da CPU", "memoryUsage": "Utilização da memória", "diskUsage": "Utilização do disco", + "selectFilesystem": "Selecione o sistema de ficheiros", "temperature": "Temperatura", "highestTemperature": "Temperatura máxima", "failedToFetchHostConfig": "Falha ao obter a configuração do servidor", @@ -2819,6 +2878,7 @@ "updateCommandHistoryFailed": "Falha ao atualizar a definição de histórico de comandos", "analyticsEnabled": "Partilhar estatísticas de uso anónimas", "analyticsEnabledDesc": "Envia uma contagem diária anónima de utilizadores, hosts e utilização de recursos para ajudar a melhorar o Termix. Nenhum dado pessoal ou detalhe de ligação é incluído.", + "analyticsEnabledLockedDesc": "Esta definição está bloqueada pela variável de ambiente ENABLE_TELEMETRY e não pode ser alterada aqui.", "updateAnalyticsFailed": "Falha ao atualizar as definições de análise", "sessionSharingGloballyEnabled": "Permitir partilha de sessão", "sessionSharingGloballyEnabledDesc": "Permite que as sessões de terminal ao vivo, RDP, VNC e Telnet sejam partilhadas em toda a instância. Substitui todas as definições de partilha por host quando desativada.", @@ -3323,6 +3383,8 @@ "resetToDefaultsSuccess": "Definições repostas para as predefinições.", "storageModeSwitch": "Armazenamento de Preferências", "sectionAccount": "Conta", + "desktopProfileTitle": "Perfil de área de trabalho local automático", + "desktopProfileDescription": "Este perfil está restrito ao backend integrado e o login é automático. Não possui password de login; a Sincronização Remota abaixo utiliza uma conta de servidor separada.", "sectionAppearance": "Aparência", "sectionSecurity": "Segurança", "sectionApiKeys": "Chaves API", @@ -3379,6 +3441,8 @@ "statusColorsDesc": "Usar verde/vermelho para estado online/offline em vez da cor de destaque", "pinAppRail": "Fixar barra de aplicações", "pinAppRailDesc": "Manter a barra de aplicações da barra lateral esquerda sempre expandida em vez de expandir ao passar o rato", + "openFullscreenSettings": "Abrir definições em tela cheia", + "exitFullscreenSettings": "Sair do modo de ecrã inteiro", "expandAppRailOnHover": "Expandir barra de aplicações ao passar o rato", "expandAppRailOnHoverDesc": "Permitir que a barra de aplicações da barra lateral esquerda se expanda quando o ponteiro passa sobre ela", "settingsNavigation": "Navegação", diff --git a/src/ui/locales/translated/ro_RO.json b/src/ui/locales/translated/ro_RO.json index 8410c4ba..0cdfb951 100644 --- a/src/ui/locales/translated/ro_RO.json +++ b/src/ui/locales/translated/ro_RO.json @@ -546,6 +546,7 @@ "sshTools": "Instrumente SSH", "history": "Istorie", "sessionLogs": "Jurnalele de sesiune", + "sidebarSettings": "Setări bară laterală...", "hosts": "Gazde", "snippets": "Fragmente", "hostManager": "Manager de gazdă", @@ -755,6 +756,8 @@ "agentSocketPathLabel": "Calea socketului agentului", "agentSocketPathPlaceholder": "Lăsați gol pentru a utiliza SSH_AUTH_SOCK", "agentSocketPathHint": "Lăsați câmpul gol pentru detectarea automată din variabila de mediu SSH_AUTH_SOCK sau introduceți o cale de socket personalizată (de exemplu, /run/user/1000/gnupg/S.gpg-agent.ssh).", + "shareSshAuthLabel": "Partajați autentificarea SSH", + "shareSshAuthDesc": "Oferiți destinatarilor copii criptate ale autentificării SSH a acestei gazde. Acreditările personale ale destinatarului au în continuare prioritate.", "tailscaleDeviceSelect": "Selectați dispozitivul Tailscale", "tailscaleDeviceSelectPlaceholder": "Selectați un dispozitiv...", "tailscaleNoApiKey": "Nu este configurată nicio cheie API Tailscale. Adăugați una în Setările de administrare pentru a activa descoperirea dispozitivelor.", @@ -1173,15 +1176,39 @@ "generateFromPrivateKey": "Generați din cheia privată", "refreshBtn2": "Reîmprospăta", "exitSelectionTitle": "Ieșire selecție", - "exportAll": "Exportă tot", - "exportForSharing": "Export pentru partajare", "addHostBtn2": "Adăugați gazdă", "addCredentialBtn2": "Adăugați acreditări", "checkingHostStatuses": "Se verifică starea gazdei...", "pinnedSection": "Fixat", "hostsExported": "Gazdele au fost exportate cu succes", - "hostsShareExported": "Gazdele partajabile au fost exportate cu succes", - "exportFailed": "Nu s-au putut exporta gazdele", + "export": { + "menuItem": "Export...", + "title": "Gazde de export", + "scope": "Domeniu de aplicare", + "scopeAll": "Toate", + "scopeSelected": "Selectat", + "searchHosts": "Căutați gazde...", + "include": "Include", + "groupConnection": "Conexiune", + "groupCredentials": "Acreditări", + "groupNotes": "Note", + "groupTags": "Etichete și pinuri", + "groupTunnels": "Tuneluri", + "groupJumpHosts": "Gazde de salt", + "groupQuickActions": "Acțiuni rapide", + "groupFeatureFlags": "Steaguri de caracteristici", + "groupAdvanced": "Configurație avansată", + "preview": "Previzualizare", + "moreHosts": "... {{count}} mai multe gazde", + "summary": "{{selected}} din {{total}} gazde", + "credentialsIncluded": "acreditări incluse", + "credentialsExcluded": "acreditări excluse", + "noneSelected": "Nicio gazdă selectată", + "cancel": "Anula", + "confirm": "Export", + "fetchFailed": "Nu s-au putut încărca gazdele pentru export", + "bulkButton": "Export" + }, "sampleDownloaded": "Fișier exemplu descărcat", "failedToDeleteCredential2": "Ștergerea acreditării nu a reușit", "noFolderOption": "(Fără dosar)", @@ -1266,7 +1293,7 @@ }, "edit": { "label": "Edita", - "description": "Vizualizați și modificați gazda. Secretele pot fi înlocuite, dar niciodată citite; atribuirea acreditărilor rămâne doar proprietarului." + "description": "Vizualizați și modificați setările gazdei fără autentificare. Autentificarea SSH a proprietarului rămâne privată și exclusiv pentru proprietar." }, "manage": { "label": "Gestionează", @@ -1299,7 +1326,24 @@ "sharedBadgeTooltip": "Distribuit de {{owner}} ( acces{{level}})", "viewOnlyBanner": "Această gazdă este partajată cu dvs. de către {{owner}} cu acces de vizualizare. Configurația este doar pentru citire.", "sharedEditBanner": "Această gazdă este partajată cu dvs. de către {{owner}} cu acces de editare. Modificările se aplică gazdei reale; referințele de autentificare pot fi modificate numai de către proprietar.", - "ownerOnlyControl": "Doar proprietarul gazdei poate modifica acest lucru" + "ownerOnlyControl": "Doar proprietarul gazdei poate modifica acest lucru", + "ownerAuthPrivate": "Autentificarea SSH a proprietarului gazdei este privată. Folosește „Setează autentificarea SSH personală” din meniul gazdei pentru a-ți alege propriile credențiale.", + "ownerAuthShared": "Proprietarul gazdei a partajat autentificarea SSH pentru această gazdă. O puteți utiliza sau puteți alege propriile credențiale din „Setați autentificarea SSH personală”.", + "authOverrideAction": "Setează autentificarea SSH personală", + "authOverrideTitle": "Autentificare SSH personală", + "authOverrideDescriptionPrivate": "Acreditările SSH ale proprietarului gazdei rămân private. Alegeți una dintre acreditările salvate pentru conexiunile la {{host}}.", + "authOverrideDescriptionShared": "Folosește autentificarea partajată de proprietarul gazdei sau înlocuiește-o cu una dintre credențialele salvate pentru conexiunile la {{host}}.", + "authOverrideCredentialLabel": "Credențiale de autentificare", + "useSharedAuthentication": "Utilizați autentificarea gazdei partajate", + "noPersonalCredential": "Fără acreditare personală", + "authOverrideNoCredentials": "Nu aveți încă nicio autentificare SSH salvată. Creați una în Autentificare pentru a vă conecta la gazde care necesită autentificare.", + "authOverrideRequired": "Această gazdă necesită una dintre acreditările dvs. salvate înainte de a vă putea conecta.", + "authOverridePrivateHint": "Aceste acreditări sunt private pentru tine. Proprietarul gazdei și alți destinatari nu le pot vedea sau utiliza.", + "authOverrideSaved": "Autentificare SSH personală salvată", + "authOverrideCleared": "Autentificarea SSH personală a fost eliminată", + "authOverrideClearedToShared": "Utilizarea autentificării gazdei partajate", + "authOverrideLoadError": "Nu s-a putut încărca autentificarea SSH. Vă rugăm să încercați din nou.", + "authOverrideSaveError": "Nu s-a putut salva autentificarea SSH." }, "guac": { "connection": "Conexiune", @@ -1627,6 +1671,15 @@ "tmuxCopyHint": "Ajustați selecția și apăsați Enter pentru a copia în clipboard", "tmuxDetach": "Detașare de la sesiunea tmux", "tmuxDetached": "Detașat de sesiunea tmux", + "searchPlaceholder": "Găsi", + "searchCaseSensitive": "Potrivire caz", + "searchWholeWord": "Potrivire cuvânt întreg", + "searchRegex": "Folosește expresia regulată", + "searchNoResults": "Niciun rezultat", + "searchResultCount": "{{index}} din {{count}}", + "searchNext": "Următorul meci (Introduce)", + "searchPrevious": "Potrivire anterioară (Shift+Enter)", + "searchClose": "Închide (Escape)", "maxReconnectAttemptsReached": "S-a atins numărul maxim de încercări de reconectare", "closeTab": "Aproape", "connectionTimeout": "Expirare conexiune", @@ -1654,6 +1707,11 @@ "opksshTimeout": "Autentificarea a expirat. Vă rugăm să încercați din nou.", "opksshAuthFailed": "Autentificarea a eșuat. Verificați acreditările și încercați din nou.", "opksshSignInWith": "Conectați-vă cu {{provider}}", + "tailscaleCheckRequired": "Autentificare la scară mică necesară", + "tailscaleCheckDescription": "SSH-ul Tailscale necesită o verificare suplimentară. Autentificați-vă în browser pentru a continua.", + "tailscaleCheckOpenBrowser": "Deschideți browserul pentru autentificare", + "tailscaleCheckWaiting": "Se așteaptă autentificarea Tailscale...", + "tailscaleCheckTimeout": "Autentificarea la scară mică a expirat. Vă rugăm să încercați din nou.", "vaultAuthTitle": "Este necesară conectarea la seif", "vaultAuthDescription": "S-a deschis o fereastră pentru a vă conecta la HashiCorp Vault. Finalizați conectarea acolo; această conexiune va continua automat.", "vaultAuthFailed": "Autentificarea în seif a eșuat. Vă rugăm să încercați din nou.", @@ -2145,6 +2203,7 @@ "cpuUsage": "Utilizarea procesorului", "memoryUsage": "Utilizarea memoriei", "diskUsage": "Utilizarea discului", + "selectFilesystem": "Selectați sistemul de fișiere", "temperature": "Temperatură", "highestTemperature": "Cea mai ridicată temperatură", "failedToFetchHostConfig": "Nu s-a putut prelua configurația gazdei", @@ -2819,6 +2878,7 @@ "updateCommandHistoryFailed": "Nu s-a putut actualiza setarea istoricului comenzilor", "analyticsEnabled": "Distribuiți statistici de utilizare anonime", "analyticsEnabledDesc": "Trimite un număr zilnic anonim de utilizatori, gazde și utilizare a funcțiilor pentru a ajuta la îmbunătățirea Termix. Nu sunt incluse niciodată date personale sau detalii de conexiune.", + "analyticsEnabledLockedDesc": "Această setare este blocată de variabila de mediu ENABLE_TELEMETRY și nu poate fi modificată aici.", "updateAnalyticsFailed": "Setările de analiză nu au putut fi actualizate", "sessionSharingGloballyEnabled": "Permiteți partajarea sesiunii", "sessionSharingGloballyEnabledDesc": "Permite partajarea sesiunilor live de terminal, RDP, VNC și Telnet la nivel de instanță. Suprascrie fiecare comutare de partajare per gazdă atunci când este dezactivată.", @@ -3323,6 +3383,8 @@ "resetToDefaultsSuccess": "Setările sunt resetate la valorile implicite.", "storageModeSwitch": "Stocare preferată", "sectionAccount": "Cont", + "desktopProfileTitle": "Profil automat de desktop local", + "desktopProfileDescription": "Acest profil este restricționat la backend-ul încorporat și se conectează automat. Nu are parolă de conectare; sincronizarea la distanță de mai jos utilizează un cont de server separat.", "sectionAppearance": "Aspect", "sectionSecurity": "Securitate", "sectionApiKeys": "Chei API", @@ -3379,6 +3441,8 @@ "statusColorsDesc": "Folosește verde/roșu pentru starea online/offline în loc de culoarea de accent", "pinAppRail": "Fixează aplicația Pin Rail", "pinAppRailDesc": "Mențineți șina aplicației din bara laterală stângă mereu extinsă, în loc să se extindă la trecerea cu mouse-ul peste mouse", + "openFullscreenSettings": "Deschide setările pe ecran complet", + "exitFullscreenSettings": "Ieșiți din setările ecran complet", "expandAppRailOnHover": "Extindeți aplicația Rail la trecerea cu mouse-ul peste", "expandAppRailOnHoverDesc": "Permite extinderea șinei aplicației din bara laterală stângă atunci când cursorul se mișcă peste ea", "settingsNavigation": "Navigare", diff --git a/src/ui/locales/translated/ru_RU.json b/src/ui/locales/translated/ru_RU.json index e5c724f0..29327386 100644 --- a/src/ui/locales/translated/ru_RU.json +++ b/src/ui/locales/translated/ru_RU.json @@ -546,6 +546,7 @@ "sshTools": "Инструменты SSH", "history": "История", "sessionLogs": "Журналы сессий", + "sidebarSettings": "Настройки боковой панели...", "hosts": "Хосты", "snippets": "Сниппеты", "hostManager": "Менеджер хостов", @@ -755,6 +756,8 @@ "agentSocketPathLabel": "Путь к сокету агента", "agentSocketPathPlaceholder": "Оставьте пустым для использования SSH_AUTH_SOCK", "agentSocketPathHint": "Оставьте пустым для автоопределения из переменной окружения SSH_AUTH_SOCK или укажите свой путь к сокету (например, /run/user/1000/gnupg/S.gpg-agent.ssh).", + "shareSshAuthLabel": "Совместное использование SSH-аутентификации", + "shareSshAuthDesc": "Предоставьте получателям зашифрованные копии SSH-аутентификации этого хоста. Личные учетные данные получателя по-прежнему имеют приоритет.", "tailscaleDeviceSelect": "Выберите устройство Tailscale", "tailscaleDeviceSelectPlaceholder": "Выберите устройство...", "tailscaleNoApiKey": "Ключ API Tailscale не настроен. Добавьте его в настройках администратора, чтобы включить обнаружение устройств.", @@ -1173,15 +1176,39 @@ "generateFromPrivateKey": "Сгенерировать из закрытого ключа", "refreshBtn2": "Обновить", "exitSelectionTitle": "Выйти из выбора", - "exportAll": "Экспортировать всё", - "exportForSharing": "Экспортировать для обмена", "addHostBtn2": "Добавить хост", "addCredentialBtn2": "Добавить учётные данные", "checkingHostStatuses": "Проверка статусов хостов...", "pinnedSection": "Закреплённые", "hostsExported": "Хосты успешно экспортированы", - "hostsShareExported": "Хосты для обмена успешно экспортированы", - "exportFailed": "Не удалось экспортировать хосты", + "export": { + "menuItem": "Экспорт...", + "title": "Экспорт хостов", + "scope": "Объем", + "scopeAll": "Все", + "scopeSelected": "Избранные", + "searchHosts": "Поиск хостов...", + "include": "Включать", + "groupConnection": "Связь", + "groupCredentials": "Реквизиты для входа", + "groupNotes": "Примечания", + "groupTags": "Теги и значок", + "groupTunnels": "Туннели", + "groupJumpHosts": "Переключение хостов", + "groupQuickActions": "Быстрые действия", + "groupFeatureFlags": "Флаги функций", + "groupAdvanced": "Расширенные настройки", + "preview": "Предварительный просмотр", + "moreHosts": "... {{count}} больше хостов", + "summary": "{{selected}} из {{total}} хостов", + "credentialsIncluded": "в число документов, подтверждающих квалификацию, вошли", + "credentialsExcluded": "учетные данные исключены", + "noneSelected": "Хосты не выбраны", + "cancel": "Отмена", + "confirm": "Экспорт", + "fetchFailed": "Не удалось загрузить хосты для экспорта.", + "bulkButton": "Экспорт" + }, "sampleDownloaded": "Образец файла загружен", "failedToDeleteCredential2": "Не удалось удалить учётные данные", "noFolderOption": "(Без папки)", @@ -1266,7 +1293,7 @@ }, "edit": { "label": "Редактирование", - "description": "Просмотр и изменение хоста. Секреты можно заменить, но нельзя прочитать; назначения учётных данных остаются только у владельца." + "description": "Просмотр и изменение настроек хоста без аутентификации. SSH-аутентификация владельца остается конфиденциальной и доступна только владельцу." }, "manage": { "label": "Управление", @@ -1299,7 +1326,24 @@ "sharedBadgeTooltip": "Предоставлен пользователем {{owner}} (доступ: {{level}})", "viewOnlyBanner": "Этот хост предоставлен вам пользователем {{owner}} с доступом только для просмотра. Конфигурация доступна только для чтения.", "sharedEditBanner": "Этот хост предоставлен вам пользователем {{owner}} с доступом на редактирование. Изменения применяются к реальному хосту; параметры аутентификации может изменять только владелец.", - "ownerOnlyControl": "Только владелец хоста может изменять это" + "ownerOnlyControl": "Только владелец хоста может изменять это", + "ownerAuthPrivate": "Аутентификация SSH владельца хоста является частной. Используйте пункт «Настроить личную аутентификацию SSH» в меню хоста, чтобы выбрать собственные учетные данные.", + "ownerAuthShared": "Владелец хоста предоставил общий доступ к SSH-аутентификации для этого хоста. Вы можете использовать его или выбрать собственные учетные данные в разделе «Настройка персональной SSH-аутентификации».", + "authOverrideAction": "Настройте персональную аутентификацию SSH.", + "authOverrideTitle": "Персональная SSH-аутентификация", + "authOverrideDescriptionPrivate": "Учетные данные SSH владельца хоста остаются конфиденциальными. Выберите одни из сохраненных учетных данных для подключения к {{host}}.", + "authOverrideDescriptionShared": "Используйте данные для аутентификации, предоставленные владельцем хоста, или замените их одним из сохраненных вами учетных данных для подключения к {{host}}.", + "authOverrideCredentialLabel": "Учетные данные для аутентификации", + "useSharedAuthentication": "Используйте аутентификацию общего хоста.", + "noPersonalCredential": "Нет личных данных", + "authOverrideNoCredentials": "У вас пока нет сохраненных учетных данных SSH. Создайте их в разделе «Учетные данные» для подключения к хостам, требующим аутентификации.", + "authOverrideRequired": "Для подключения к этому хосту потребуется ввести одни из ваших сохраненных учетных данных.", + "authOverridePrivateHint": "Эти учетные данные являются вашей личной информацией. Владелец хоста и другие получатели не могут их видеть или использовать.", + "authOverrideSaved": "Сохранена персональная аутентификация SSH.", + "authOverrideCleared": "Персональная SSH-аутентификация удалена", + "authOverrideClearedToShared": "Использование аутентификации на общем хосте", + "authOverrideLoadError": "Не удалось загрузить вашу SSH-аутентификацию. Пожалуйста, попробуйте еще раз.", + "authOverrideSaveError": "Не удалось сохранить ваши SSH-аутентификационные данные." }, "guac": { "connection": "Подключение", @@ -1627,6 +1671,15 @@ "tmuxCopyHint": "Скорректируйте выделение и нажмите Enter, чтобы скопировать в буфер обмена", "tmuxDetach": "Отключиться от сессии tmux", "tmuxDetached": "Отключён от сессии tmux", + "searchPlaceholder": "Находить", + "searchCaseSensitive": "Спичечный коробок", + "searchWholeWord": "Сопоставьте целое слово", + "searchRegex": "Используйте регулярные выражения", + "searchNoResults": "Результаты отсутствуют", + "searchResultCount": "{{index}} из {{count}}", + "searchNext": "Следующий матч (Вход)", + "searchPrevious": "Предыдущий матч (Shift+Enter)", + "searchClose": "Закрыть (Выход)", "maxReconnectAttemptsReached": "Достигнуто максимальное количество попыток переподключения", "closeTab": "Закрыть", "connectionTimeout": "Тайм-аут подключения", @@ -1654,6 +1707,11 @@ "opksshTimeout": "Время аутентификации истекло. Попробуйте снова.", "opksshAuthFailed": "Аутентификация не удалась. Проверьте учётные данные и попробуйте снова.", "opksshSignInWith": "Войти через {{provider}}", + "tailscaleCheckRequired": "Требуется аутентификация Tailscale", + "tailscaleCheckDescription": "Для работы Tailscale SSH требуется дополнительная проверка. Для продолжения выполните аутентификацию в браузере.", + "tailscaleCheckOpenBrowser": "Откройте браузер для аутентификации.", + "tailscaleCheckWaiting": "Ожидание аутентификации Tailscale...", + "tailscaleCheckTimeout": "Время ожидания аутентификации Tailscale истекло. Пожалуйста, попробуйте еще раз.", "vaultAuthTitle": "Требуется вход в Vault", "vaultAuthDescription": "Открыто окно для входа в HashiCorp Vault. Завершите вход там; подключение продолжится автоматически.", "vaultAuthFailed": "Аутентификация Vault не удалась. Попробуйте снова.", @@ -2145,6 +2203,7 @@ "cpuUsage": "Использование ЦП", "memoryUsage": "Использование памяти", "diskUsage": "Использование диска", + "selectFilesystem": "Выберите файловую систему", "temperature": "Температура", "highestTemperature": "Максимальная температура", "failedToFetchHostConfig": "Не удалось получить конфигурацию хоста", @@ -2819,6 +2878,7 @@ "updateCommandHistoryFailed": "Не удалось обновить настройку истории команд", "analyticsEnabled": "Делитесь анонимной статистикой использования.", "analyticsEnabledDesc": "Ежедневно отправляет анонимный подсчет пользователей, хостов и использования функций, чтобы помочь улучшить Termix. Никакие личные данные или сведения о подключении никогда не включаются.", + "analyticsEnabledLockedDesc": "Этот параметр заблокирован переменной среды ENABLE_TELEMETRY и не может быть изменен здесь.", "updateAnalyticsFailed": "Не удалось обновить настройки аналитики.", "sessionSharingGloballyEnabled": "Разрешить совместное использование сеанса", "sessionSharingGloballyEnabledDesc": "Разрешить общий доступ к сеансам Live Terminal, RDP, VNC и Telnet для всего экземпляра. При отключении этой функции отменяет все настройки общего доступа для отдельных хостов.", @@ -3323,6 +3383,8 @@ "resetToDefaultsSuccess": "Настройки сброшены до значений по умолчанию.", "storageModeSwitch": "Хранилище настроек", "sectionAccount": "Учетная запись", + "desktopProfileTitle": "Автоматический локальный профиль рабочего стола", + "desktopProfileDescription": "Этот профиль предназначен только для встроенной серверной части и автоматически входит в систему. Для входа в систему пароль отсутствует; функция удаленной синхронизации, описанная ниже, использует отдельную учетную запись сервера.", "sectionAppearance": "Внешний вид", "sectionSecurity": "Безопасность", "sectionApiKeys": "API-ключи", @@ -3379,6 +3441,8 @@ "statusColorsDesc": "Использовать зелёный/красный для отображения статуса онлайн/офлайн вместо акцентного цвета", "pinAppRail": "Закрепить панель приложений", "pinAppRailDesc": "Оставлять левую боковую панель приложений всегда развёрнутой, а не разворачивать при наведении", + "openFullscreenSettings": "Открыть настройки в полноэкранном режиме", + "exitFullscreenSettings": "Выйти из настроек полноэкранного режима", "expandAppRailOnHover": "Разворачивать панель приложений при наведении", "expandAppRailOnHoverDesc": "Позволять левой боковой панели приложений разворачиваться при наведении указателя", "settingsNavigation": "Навигация", diff --git a/src/ui/locales/translated/sr_SP.json b/src/ui/locales/translated/sr_SP.json index 964b9fc1..cf33b7e0 100644 --- a/src/ui/locales/translated/sr_SP.json +++ b/src/ui/locales/translated/sr_SP.json @@ -546,6 +546,7 @@ "sshTools": "SSH алати", "history": "Историја", "sessionLogs": "Записи сесија", + "sidebarSettings": "Подешавања бочне траке...", "hosts": "Домаћини", "snippets": "Исечци", "hostManager": "Менаџер домаћина", @@ -755,6 +756,8 @@ "agentSocketPathLabel": "Путања агентског сокета", "agentSocketPathPlaceholder": "Оставите празно да бисте користили SSH_AUTH_SOCK", "agentSocketPathHint": "Оставите празно да би се аутоматски детектовало из променљиве окружења SSH_AUTH_SOCK или унесите прилагођену путању до сокета (нпр. /run/user/1000/gnupg/S.gpg-agent.ssh).", + "shareSshAuthLabel": "Дељење SSH аутентификације", + "shareSshAuthDesc": "Доставите примаоцима шифроване копије SSH аутентификације овог хоста. Лични акредитив примаоца и даље има предност.", "tailscaleDeviceSelect": "Изаберите уређај Tailscale", "tailscaleDeviceSelectPlaceholder": "Изаберите уређај...", "tailscaleNoApiKey": "Није конфигурисан Tailscale API кључ. Додајте га у администраторским подешавањима да бисте омогућили откривање уређаја.", @@ -1173,15 +1176,39 @@ "generateFromPrivateKey": "Генериши из приватног кључа", "refreshBtn2": "Освежи", "exitSelectionTitle": "Излаз из селекције", - "exportAll": "Извези све", - "exportForSharing": "Извоз за дељење", "addHostBtn2": "Додај хоста", "addCredentialBtn2": "Додај акредитив", "checkingHostStatuses": "Провера статуса хоста...", "pinnedSection": "Закачено", "hostsExported": "Хостови су успешно извезени", - "hostsShareExported": "Дељиви хостови су успешно извезени", - "exportFailed": "Извоз хостова није успео", + "export": { + "menuItem": "Извоз...", + "title": "Извоз хостова", + "scope": "Обим", + "scopeAll": "Сви", + "scopeSelected": "Изабрано", + "searchHosts": "Претражи хостове...", + "include": "Укључи", + "groupConnection": "Веза", + "groupCredentials": "Акредитиви", + "groupNotes": "Белешке", + "groupTags": "Ознаке и пин", + "groupTunnels": "Тунели", + "groupJumpHosts": "Јумп хостови", + "groupQuickActions": "Брзе акције", + "groupFeatureFlags": "Заставице функција", + "groupAdvanced": "Напредна конфигурација", + "preview": "Преглед", + "moreHosts": "... {{count}} још домаћина", + "summary": "{{selected}} од {{total}} хостова", + "credentialsIncluded": "укључени акредитиви", + "credentialsExcluded": "акредитиви искључени", + "noneSelected": "Ниједан домаћин није изабран", + "cancel": "Откажи", + "confirm": "Извоз", + "fetchFailed": "Учитавање хостова за извоз није успело", + "bulkButton": "Извоз" + }, "sampleDownloaded": "Пример датотеке је преузет", "failedToDeleteCredential2": "Брисање акредитива није успело", "noFolderOption": "(Нема фасцикле)", @@ -1266,7 +1293,7 @@ }, "edit": { "label": "Измени", - "description": "Преглед и измена хоста. Тајне се могу заменити, али никада читати; доделе акредитива остају само за власника." + "description": "Прегледајте и мењајте подешавања хоста која не захтевају аутентификацију. SSH аутентификација власника остаје приватна и доступна само власнику." }, "manage": { "label": "Управљај", @@ -1299,7 +1326,24 @@ "sharedBadgeTooltip": "Дели {{owner}} ({{level}} приступ)", "viewOnlyBanner": "Овај хост дели са вама {{owner}} са приступом за преглед. Конфигурација је само за читање.", "sharedEditBanner": "Овај хост дели са вама {{owner}} са приступом за уређивање. Промене се примењују на правог хоста; референце за аутентификацију може променити само власник.", - "ownerOnlyControl": "Само власник хоста може ово да промени" + "ownerOnlyControl": "Само власник хоста може ово да промени", + "ownerAuthPrivate": "SSH аутентификација власника хоста је приватна. Користите „Подеси личну SSH аутентификацију“ из менија хоста да бисте изабрали сопствене акредитиве.", + "ownerAuthShared": "Власник хоста је поделио SSH аутентификацију за овај хост. Можете је користити или изабрати сопствене акредитиве из „Подеси личну SSH аутентификацију“.", + "authOverrideAction": "Подесите личну SSH аутентификацију", + "authOverrideTitle": "Лична SSH аутентификација", + "authOverrideDescriptionPrivate": "SSH акредитиви власника хоста остају приватни. Изаберите један од сачуваних акредитива за повезивање са {{host}}.", + "authOverrideDescriptionShared": "Користите аутентификацију коју дели власник хоста или је замените једном од ваших сачуваних акредитива за повезивање са {{host}}.", + "authOverrideCredentialLabel": "Акредитив за аутентификацију", + "useSharedAuthentication": "Користите аутентификацију дељеног хоста", + "noPersonalCredential": "Без личног акредитива", + "authOverrideNoCredentials": "Још увек немате сачуване SSH акредитиве. Направите један у одељку Акредитиви да бисте се повезали са хостовима који захтевају аутентификацију.", + "authOverrideRequired": "Овај хост захтева један од ваших сачуваних акредитива пре него што се можете повезати.", + "authOverridePrivateHint": "Овај акредитив је приватан за вас. Власник хоста и други примаоци не могу да га виде нити користе.", + "authOverrideSaved": "Лична SSH аутентификација је сачувана", + "authOverrideCleared": "Лична SSH аутентификација је уклоњена", + "authOverrideClearedToShared": "Коришћење аутентификације дељеног хоста", + "authOverrideLoadError": "Није успело учитавање ваше SSH аутентификације. Молимо покушајте поново.", + "authOverrideSaveError": "Није успело чување ваше SSH аутентификације" }, "guac": { "connection": "Веза", @@ -1627,6 +1671,15 @@ "tmuxCopyHint": "Прилагодите избор и притисните Ентер да бисте копирали у међуспремник", "tmuxDetach": "Одвоји се од tmux сесије", "tmuxDetached": "Одвојено од tmux сесије", + "searchPlaceholder": "Пронађи", + "searchCaseSensitive": "Упаривање великог и малог дела", + "searchWholeWord": "Подударање целе речи", + "searchRegex": "Користите регуларни израз", + "searchNoResults": "Нема резултата", + "searchResultCount": "{{index}} од {{count}}", + "searchNext": "Следећи меч (Унесите)", + "searchPrevious": "Претходно подударање (Shift+Enter)", + "searchClose": "Затвори (Escape)", "maxReconnectAttemptsReached": "Достигнут је максималан број покушаја поновног повезивања", "closeTab": "Затвори", "connectionTimeout": "Временско ограничење везе", @@ -1654,6 +1707,11 @@ "opksshTimeout": "Временско ограничење за аутентификацију је истекло. Молимо покушајте поново.", "opksshAuthFailed": "Аутентификација није успела. Проверите своје акредитиве и покушајте поново.", "opksshSignInWith": "Пријавите се са {{provider}}", + "tailscaleCheckRequired": "Потребна је аутентификација на таилскалу", + "tailscaleCheckDescription": "Tailscale SSH захтева додатну проверу. Аутентификујте се у прегледачу да бисте наставили.", + "tailscaleCheckOpenBrowser": "Отворите прегледач за аутентификацију", + "tailscaleCheckWaiting": "Чекање на Tailscale аутентификацију...", + "tailscaleCheckTimeout": "Временско ограничење за аутентификацију Tailscale-а је истекло. Молимо покушајте поново.", "vaultAuthTitle": "Потребно је пријављивање у трезор", "vaultAuthDescription": "Отворио се прозор за пријављивање на HashiCorp Vault. Завршите пријављивање тамо; ова веза ће се аутоматски наставити.", "vaultAuthFailed": "Аутентификација трезора није успела. Молимо покушајте поново.", @@ -2145,6 +2203,7 @@ "cpuUsage": "Искоришћеност процесора", "memoryUsage": "Коришћење меморије", "diskUsage": "Искоришћеност диска", + "selectFilesystem": "Изаберите фајл систем", "temperature": "Температура", "highestTemperature": "Највиша температура", "failedToFetchHostConfig": "Није успело преузимање конфигурације хоста", @@ -2819,6 +2878,7 @@ "updateCommandHistoryFailed": "Ажурирање подешавања историје команди није успело", "analyticsEnabled": "Делите анонимну статистику коришћења", "analyticsEnabledDesc": "Шаље анонимни дневни број корисника, хостова и коришћења функција како би се побољшао Termix. Никада се не укључују лични подаци или детаљи везе.", + "analyticsEnabledLockedDesc": "Ово подешавање је закључано променљивом окружења ENABLE_TELEMETRY и не може се овде променити.", "updateAnalyticsFailed": "Ажурирање подешавања аналитике није успело", "sessionSharingGloballyEnabled": "Дозволи дељење сесије", "sessionSharingGloballyEnabledDesc": "Дозвољава дељење сесија уживо терминала, RDP, VNC и Telnet сесија на нивоу целе инстанце. Замењује сваки прекидач за дељење по хосту када је онемогућен.", @@ -3323,6 +3383,8 @@ "resetToDefaultsSuccess": "Подешавања су ресетована на подразумеване вредности.", "storageModeSwitch": "Складиште преференција", "sectionAccount": "Налог", + "desktopProfileTitle": "Аутоматски локални профил на рачунару", + "desktopProfileDescription": "Овај профил је ограничен на уграђени бекенд и аутоматски се пријављује. Нема лозинку за пријаву; Даљинска синхронизација испод користи посебан серверски налог.", "sectionAppearance": "Изглед", "sectionSecurity": "Безбедност", "sectionApiKeys": "API кључеви", @@ -3379,6 +3441,8 @@ "statusColorsDesc": "Користите зелену/црвену за статус онлајн/офлајн уместо акцентне боје", "pinAppRail": "Закачи шину апликација", "pinAppRailDesc": "Нека лева бочна трака апликације увек буде проширена уместо да се проширује при преласку мишем преко ње", + "openFullscreenSettings": "Отвори подешавања преко целог екрана", + "exitFullscreenSettings": "Изађи из подешавања целог екрана", "expandAppRailOnHover": "Прошири шину апликација при преласку мишем", "expandAppRailOnHoverDesc": "Дозволите да се шина апликације на левој бочној траци прошири када се показивач помери преко ње", "settingsNavigation": "Навигација", diff --git a/src/ui/locales/translated/sv_SE.json b/src/ui/locales/translated/sv_SE.json index 0dfe00cf..67bd93f1 100644 --- a/src/ui/locales/translated/sv_SE.json +++ b/src/ui/locales/translated/sv_SE.json @@ -546,6 +546,7 @@ "sshTools": "SSH-verktyg", "history": "Historia", "sessionLogs": "Sessionsloggar", + "sidebarSettings": "Inställningar för sidofältet...", "hosts": "Värdar", "snippets": "Snuttar", "hostManager": "Värdhanterare", @@ -755,6 +756,8 @@ "agentSocketPathLabel": "Agent Socket-sökväg", "agentSocketPathPlaceholder": "Lämna tomt för att använda SSH_AUTH_SOCK", "agentSocketPathHint": "Lämna tomt för att automatiskt identifiera från miljövariabeln SSH_AUTH_SOCK, eller ange en anpassad socket-sökväg (t.ex. /run/user/1000/gnupg/S.gpg-agent.ssh).", + "shareSshAuthLabel": "Dela SSH-autentisering", + "shareSshAuthDesc": "Ge mottagarna krypterade kopior av denna värds SSH-autentisering. Mottagarens personliga inloggningsuppgifter har fortfarande företräde.", "tailscaleDeviceSelect": "Välj Tailscale-enhet", "tailscaleDeviceSelectPlaceholder": "Välj en enhet...", "tailscaleNoApiKey": "Ingen Tailscale API-nyckel konfigurerad. Lägg till en i administratörsinställningarna för att aktivera enhetsidentifiering.", @@ -1173,15 +1176,39 @@ "generateFromPrivateKey": "Generera från privat nyckel", "refreshBtn2": "Uppdatera", "exitSelectionTitle": "Avsluta valet", - "exportAll": "Exportera alla", - "exportForSharing": "Exportera för delning", "addHostBtn2": "Lägg till värd", "addCredentialBtn2": "Lägg till autentiseringsuppgifter", "checkingHostStatuses": "Kontrollerar värdstatusar...", "pinnedSection": "Fäst", "hostsExported": "Värdar exporterades", - "hostsShareExported": "Delbara värdar exporterades", - "exportFailed": "Misslyckades med att exportera värdar", + "export": { + "menuItem": "Exportera...", + "title": "Exportera värdar", + "scope": "Omfattning", + "scopeAll": "Alla", + "scopeSelected": "Vald", + "searchHosts": "Sök värdar...", + "include": "Omfatta", + "groupConnection": "Förbindelse", + "groupCredentials": "Referenser", + "groupNotes": "Anteckningar", + "groupTags": "Taggar och pin", + "groupTunnels": "Tunnlar", + "groupJumpHosts": "Hoppa värdar", + "groupQuickActions": "Snabbåtgärder", + "groupFeatureFlags": "Funktionsflaggor", + "groupAdvanced": "Avancerad konfiguration", + "preview": "Förhandsvisning", + "moreHosts": "... {{count}} fler värdar", + "summary": "{{selected}} av {{total}} värdar", + "credentialsIncluded": "inloggningsuppgifter ingår", + "credentialsExcluded": "uteslutna inloggningsuppgifter", + "noneSelected": "Inga värdar valda", + "cancel": "Avboka", + "confirm": "Exportera", + "fetchFailed": "Misslyckades med att läsa in värdar för export", + "bulkButton": "Exportera" + }, "sampleDownloaded": "Exempelfil nedladdad", "failedToDeleteCredential2": "Misslyckades med att ta bort inloggningsuppgifter", "noFolderOption": "(Ingen mapp)", @@ -1266,7 +1293,7 @@ }, "edit": { "label": "Redigera", - "description": "Visa och ändra värden. Hemligheter kan ersättas men aldrig läsas; tilldelningar av autentiseringsuppgifter förblir endast ägarens ansvar." + "description": "Visa och ändra inställningar för icke-autentiseringsvärdar. Ägarens SSH-autentisering förblir privat och endast för ägaren." }, "manage": { "label": "Hantera", @@ -1299,7 +1326,24 @@ "sharedBadgeTooltip": "Delad av {{owner}} ({{level}} åtkomst)", "viewOnlyBanner": "Denna värd delas med dig av {{owner}} med läsbehörighet. Konfigurationen är skrivskyddad.", "sharedEditBanner": "Denna värd delas med dig av {{owner}} med redigeringsåtkomst. Ändringarna gäller för den verkliga värden; autentiseringsreferenser kan endast ändras av ägaren.", - "ownerOnlyControl": "Endast värdägaren kan ändra detta" + "ownerOnlyControl": "Endast värdägaren kan ändra detta", + "ownerAuthPrivate": "Värdägarens SSH-autentisering är privat. Använd \"Ange personlig SSH-autentisering\" från värdmenyn för att välja dina egna inloggningsuppgifter.", + "ownerAuthShared": "Värdägaren har delat SSH-autentisering för den här värden. Du kan använda den eller välja dina egna inloggningsuppgifter från \"Ange personlig SSH-autentisering\".", + "authOverrideAction": "Ställ in personlig SSH-autentisering", + "authOverrideTitle": "Personlig SSH-autentisering", + "authOverrideDescriptionPrivate": "Värdägarens SSH-inloggningsuppgifter förblir privata. Välj en av dina sparade inloggningsuppgifter för anslutningar till {{host}}.", + "authOverrideDescriptionShared": "Använd autentiseringen som delas av värdägaren, eller ersätt den med en av dina sparade inloggningsuppgifter för anslutningar till {{host}}.", + "authOverrideCredentialLabel": "Autentiseringsuppgifter", + "useSharedAuthentication": "Använd autentisering av delad värd", + "noPersonalCredential": "Ingen personlig legitimation", + "authOverrideNoCredentials": "Du har inga sparade SSH-inloggningsuppgifter ännu. Skapa en i Inloggningsuppgifter för att ansluta till värdar som kräver autentisering.", + "authOverrideRequired": "Den här värden kräver en av dina sparade inloggningsuppgifter innan du kan ansluta.", + "authOverridePrivateHint": "Denna inloggningsuppgift är privat för dig. Värdägaren och andra mottagare kan inte se eller använda den.", + "authOverrideSaved": "Personlig SSH-autentisering sparad", + "authOverrideCleared": "Personlig SSH-autentisering har tagits bort", + "authOverrideClearedToShared": "Använda autentisering med delad värd", + "authOverrideLoadError": "Misslyckades med att ladda din SSH-autentisering. Försök igen.", + "authOverrideSaveError": "Det gick inte att spara din SSH-autentisering" }, "guac": { "connection": "Förbindelse", @@ -1627,6 +1671,15 @@ "tmuxCopyHint": "Justera markeringen och tryck på Enter för att kopiera till urklipp", "tmuxDetach": "Koppla bort från tmux-sessionen", "tmuxDetached": "Losskopplad från tmux-sessionen", + "searchPlaceholder": "Hitta", + "searchCaseSensitive": "Matchfodral", + "searchWholeWord": "Matcha hela ordet", + "searchRegex": "Använd reguljärt uttryck", + "searchNoResults": "Inga resultat", + "searchResultCount": "{{index}} av {{count}}", + "searchNext": "Nästa match (Enter)", + "searchPrevious": "Föregående matchning (Shift+Enter)", + "searchClose": "Stäng (Escape)", "maxReconnectAttemptsReached": "Maximalt antal återanslutningsförsök uppnådda", "closeTab": "Nära", "connectionTimeout": "Anslutningstimeout", @@ -1654,6 +1707,11 @@ "opksshTimeout": "Autentiseringen har gått ut. Försök igen.", "opksshAuthFailed": "Autentiseringen misslyckades. Kontrollera dina inloggningsuppgifter och försök igen.", "opksshSignInWith": "Logga in med {{provider}}", + "tailscaleCheckRequired": "Tailscale-autentisering krävs", + "tailscaleCheckDescription": "Tailscale SSH kräver en ytterligare kontroll. Autentisera i din webbläsare för att fortsätta.", + "tailscaleCheckOpenBrowser": "Öppna webbläsaren för att autentisera", + "tailscaleCheckWaiting": "Väntar på Tailscale-autentisering...", + "tailscaleCheckTimeout": "Tailscale-autentiseringen har gått ut. Försök igen.", "vaultAuthTitle": "Inloggning till arkivet krävs", "vaultAuthDescription": "Ett fönster har öppnats för att logga in på HashiCorp Vault. Slutför inloggningen där; anslutningen fortsätter automatiskt.", "vaultAuthFailed": "Valvautentisering misslyckades. Försök igen.", @@ -2145,6 +2203,7 @@ "cpuUsage": "CPU-användning", "memoryUsage": "Minnesanvändning", "diskUsage": "Diskanvändning", + "selectFilesystem": "Välj filsystem", "temperature": "Temperatur", "highestTemperature": "Högsta temperatur", "failedToFetchHostConfig": "Misslyckades med att hämta värdkonfigurationen", @@ -2819,6 +2878,7 @@ "updateCommandHistoryFailed": "Misslyckades med att uppdatera inställningen för kommandohistorik", "analyticsEnabled": "Dela anonym användningsstatistik", "analyticsEnabledDesc": "Skickar en anonym daglig räkning av användare, värdar och funktionsanvändning för att förbättra Termix. Inga personuppgifter eller anslutningsdetaljer inkluderas någonsin.", + "analyticsEnabledLockedDesc": "Den här inställningen är låst av miljövariabeln ENABLE_TELEMETRY och kan inte ändras här.", "updateAnalyticsFailed": "Det gick inte att uppdatera analysinställningen", "sessionSharingGloballyEnabled": "Tillåt sessionsdelning", "sessionSharingGloballyEnabledDesc": "Tillåt att liveterminal-, RDP-, VNC- och Telnet-sessioner delas instansövergripande. Åsidosätter alla delningsknappar per värd när de är inaktiverade.", @@ -3323,6 +3383,8 @@ "resetToDefaultsSuccess": "Inställningarna återställs till standardinställningarna.", "storageModeSwitch": "Preferenslagring", "sectionAccount": "Konto", + "desktopProfileTitle": "Automatisk lokal skrivbordsprofil", + "desktopProfileDescription": "Den här profilen är begränsad till den inbäddade backend-funktionen och loggar in automatiskt. Den har inget inloggningslösenord; fjärrsynkronisering nedan använder ett separat serverkonto.", "sectionAppearance": "Utseende", "sectionSecurity": "Säkerhet", "sectionApiKeys": "API-nycklar", @@ -3379,6 +3441,8 @@ "statusColorsDesc": "Använd grönt/rött för online-/offline-status istället för accentfärgen", "pinAppRail": "Pin App Rail", "pinAppRailDesc": "Håll applist i vänster sidofält alltid expanderad istället för att expandera när du håller muspekaren över den", + "openFullscreenSettings": "Öppna inställningar i helskärmsläge", + "exitFullscreenSettings": "Avsluta helskärmsinställningar", "expandAppRailOnHover": "Expandera appens spår vid muspekare", "expandAppRailOnHoverDesc": "Tillåt att den vänstra sidofältets applist expanderar när pekaren flyttas över den", "settingsNavigation": "Navigering", diff --git a/src/ui/locales/translated/th_TH.json b/src/ui/locales/translated/th_TH.json index 0ce290d9..1b6f8e66 100644 --- a/src/ui/locales/translated/th_TH.json +++ b/src/ui/locales/translated/th_TH.json @@ -546,6 +546,7 @@ "sshTools": "เครื่องมือ SSH", "history": "ประวัติศาสตร์", "sessionLogs": "บันทึกเซสชัน", + "sidebarSettings": "การตั้งค่าแถบด้านข้าง...", "hosts": "โฮสต์", "snippets": "เศษเสี้ยว", "hostManager": "ผู้จัดการโฮสต์", @@ -755,6 +756,8 @@ "agentSocketPathLabel": "เส้นทางซ็อกเก็ตเอเจนต์", "agentSocketPathPlaceholder": "เว้นว่างไว้เพื่อใช้ SSH_AUTH_SOCK", "agentSocketPathHint": "เว้นว่างไว้เพื่อให้ระบบตรวจจับอัตโนมัติจากตัวแปรสภาพแวดล้อม SSH_AUTH_SOCK หรือป้อนเส้นทางซ็อกเก็ตแบบกำหนดเอง (เช่น /run/user/1000/gnupg/S.gpg-agent.ssh)", + "shareSshAuthLabel": "แชร์การตรวจสอบสิทธิ์ SSH", + "shareSshAuthDesc": "ส่งสำเนาการเข้ารหัสของข้อมูลรับรอง SSH ของโฮสต์นี้ให้แก่ผู้รับ อย่างไรก็ตาม ข้อมูลรับรองส่วนบุคคลของผู้รับยังคงมีความสำคัญเหนือกว่า", "tailscaleDeviceSelect": "เลือกอุปกรณ์ Tailscale", "tailscaleDeviceSelectPlaceholder": "เลือกอุปกรณ์...", "tailscaleNoApiKey": "ไม่ได้กำหนดค่าคีย์ API ของ Tailscale ไว้ โปรดเพิ่มคีย์ใน การตั้งค่าผู้ดูแลระบบ เพื่อเปิดใช้งานการค้นหาอุปกรณ์", @@ -1173,15 +1176,39 @@ "generateFromPrivateKey": "สร้างจากรหัสส่วนตัว", "refreshBtn2": "รีเฟรช", "exitSelectionTitle": "การเลือกทางออก", - "exportAll": "ส่งออกทั้งหมด", - "exportForSharing": "ส่งออกเพื่อแบ่งปัน", "addHostBtn2": "เพิ่มโฮสต์", "addCredentialBtn2": "เพิ่มข้อมูลรับรอง", "checkingHostStatuses": "กำลังตรวจสอบสถานะของโฮสต์...", "pinnedSection": "ปักหมุด", "hostsExported": "การส่งออกโฮสต์สำเร็จแล้ว", - "hostsShareExported": "การส่งออกโฮสต์ที่แชร์ได้สำเร็จแล้ว", - "exportFailed": "ไม่สามารถส่งออกโฮสต์ได้", + "export": { + "menuItem": "ส่งออก...", + "title": "ส่งออกโฮสต์", + "scope": "ขอบเขต", + "scopeAll": "ทั้งหมด", + "scopeSelected": "เลือกแล้ว", + "searchHosts": "ค้นหาโฮสต์...", + "include": "รวม", + "groupConnection": "การเชื่อมต่อ", + "groupCredentials": "คุณสมบัติ", + "groupNotes": "หมายเหตุ", + "groupTags": "แท็กและพิน", + "groupTunnels": "อุโมงค์", + "groupJumpHosts": "โฮสต์ Jump", + "groupQuickActions": "การดำเนินการอย่างรวดเร็ว", + "groupFeatureFlags": "ธงคุณสมบัติ", + "groupAdvanced": "การกำหนดค่าขั้นสูง", + "preview": "ตัวอย่าง", + "moreHosts": "... {{count}} โฮสต์เพิ่มเติม", + "summary": "{{selected}} ของโฮสต์ {{total}}", + "credentialsIncluded": "รวมถึงข้อมูลประจำตัว", + "credentialsExcluded": "ไม่รวมข้อมูลประจำตัว", + "noneSelected": "ไม่ได้เลือกโฮสต์", + "cancel": "ยกเลิก", + "confirm": "ส่งออก", + "fetchFailed": "ไม่สามารถโหลดโฮสต์สำหรับการส่งออกได้", + "bulkButton": "ส่งออก" + }, "sampleDownloaded": "ดาวน์โหลดไฟล์ตัวอย่างแล้ว", "failedToDeleteCredential2": "ไม่สามารถลบข้อมูลประจำตัวได้", "noFolderOption": "(ไม่มีโฟลเดอร์)", @@ -1266,7 +1293,7 @@ }, "edit": { "label": "แก้ไข", - "description": "ดูและแก้ไขข้อมูลโฮสต์ได้ สามารถเปลี่ยนรหัสลับได้ แต่ไม่สามารถอ่านได้ การกำหนดสิทธิ์การเข้าถึงจะสงวนไว้เฉพาะเจ้าของเท่านั้น" + "description": "ดูและแก้ไขการตั้งค่าโฮสต์ที่ไม่ต้องยืนยันตัวตน การยืนยันตัวตน SSH ของเจ้าของจะยังคงเป็นส่วนตัวและเข้าถึงได้เฉพาะเจ้าของเท่านั้น" }, "manage": { "label": "จัดการ", @@ -1299,7 +1326,24 @@ "sharedBadgeTooltip": "แชร์โดย {{owner}} ({{level}} เข้าถึงได้)", "viewOnlyBanner": "โฮสต์นี้ถูกแชร์กับคุณโดย {{owner}} โดยมีสิทธิ์ในการดู การตั้งค่าเป็นแบบอ่านอย่างเดียว", "sharedEditBanner": "โฮสต์นี้ถูกแชร์กับคุณโดย {{owner}} โดยมีสิทธิ์ในการแก้ไข การเปลี่ยนแปลงจะมีผลกับโฮสต์จริงเท่านั้น ข้อมูลอ้างอิงการตรวจสอบสิทธิ์สามารถเปลี่ยนแปลงได้โดยเจ้าของเท่านั้น", - "ownerOnlyControl": "เฉพาะเจ้าของโฮสต์เท่านั้นที่สามารถเปลี่ยนแปลงสิ่งนี้ได้" + "ownerOnlyControl": "เฉพาะเจ้าของโฮสต์เท่านั้นที่สามารถเปลี่ยนแปลงสิ่งนี้ได้", + "ownerAuthPrivate": "การตรวจสอบสิทธิ์ SSH ของผู้เป็นเจ้าของโฮสต์เป็นแบบส่วนตัว ใช้เมนู “ตั้งค่าการตรวจสอบสิทธิ์ SSH ส่วนบุคคล” จากเมนูโฮสต์เพื่อเลือกข้อมูลประจำตัวของคุณเอง", + "ownerAuthShared": "เจ้าของโฮสต์ได้แชร์ข้อมูลรับรอง SSH สำหรับโฮสต์นี้แล้ว คุณสามารถใช้ข้อมูลรับรองนี้หรือเลือกข้อมูลรับรองของคุณเองได้จาก “ตั้งค่าข้อมูลรับรอง SSH ส่วนบุคคล”", + "authOverrideAction": "ตั้งค่าการตรวจสอบสิทธิ์ SSH ส่วนบุคคล", + "authOverrideTitle": "การตรวจสอบสิทธิ์ SSH ส่วนบุคคล", + "authOverrideDescriptionPrivate": "ข้อมูลประจำตัว SSH ของผู้เป็นเจ้าของโฮสต์จะถูกเก็บเป็นความลับ เลือกข้อมูลประจำตัวที่คุณบันทึกไว้เพื่อเชื่อมต่อกับ {{host}}", + "authOverrideDescriptionShared": "ใช้ข้อมูลรับรองที่เจ้าของโฮสต์แชร์ไว้ หรือแทนที่ด้วยข้อมูลรับรองที่คุณบันทึกไว้สำหรับการเชื่อมต่อกับ {{host}}", + "authOverrideCredentialLabel": "ข้อมูลประจำตัวการตรวจสอบสิทธิ์", + "useSharedAuthentication": "ใช้การตรวจสอบสิทธิ์โฮสต์ที่ใช้ร่วมกัน", + "noPersonalCredential": "ไม่มีเอกสารรับรองส่วนบุคคล", + "authOverrideNoCredentials": "คุณยังไม่ได้บันทึกข้อมูลรับรอง SSH ใดๆ ไว้ สร้างข้อมูลรับรองในส่วนข้อมูลรับรองเพื่อเชื่อมต่อกับโฮสต์ที่ต้องการการตรวจสอบสิทธิ์", + "authOverrideRequired": "โฮสต์นี้ต้องการข้อมูลประจำตัวที่คุณบันทึกไว้ก่อนจึงจะสามารถเชื่อมต่อได้", + "authOverridePrivateHint": "ข้อมูลประจำตัวนี้เป็นข้อมูลส่วนตัวของคุณเท่านั้น เจ้าของโฮสต์และผู้รับรายอื่นไม่สามารถเห็นหรือใช้งานได้", + "authOverrideSaved": "บันทึกการตรวจสอบสิทธิ์ SSH ส่วนบุคคลแล้ว", + "authOverrideCleared": "การตรวจสอบสิทธิ์ SSH ส่วนบุคคลถูกลบออกแล้ว", + "authOverrideClearedToShared": "การใช้การตรวจสอบสิทธิ์โฮสต์ร่วม", + "authOverrideLoadError": "ไม่สามารถโหลดข้อมูลรับรอง SSH ของคุณได้ โปรดลองอีกครั้ง", + "authOverrideSaveError": "ไม่สามารถบันทึกการตรวจสอบสิทธิ์ SSH ของคุณได้" }, "guac": { "connection": "การเชื่อมต่อ", @@ -1627,6 +1671,15 @@ "tmuxCopyHint": "ปรับตำแหน่งที่เลือกแล้วกด Enter เพื่อคัดลอกไปยังคลิปบอร์ด", "tmuxDetach": "ยกเลิกการเชื่อมต่อจากเซสชัน tmux", "tmuxDetached": "ตัดการเชื่อมต่อจากเซสชัน tmux แล้ว", + "searchPlaceholder": "หา", + "searchCaseSensitive": "กล่องไม้ขีดไฟ", + "searchWholeWord": "จับคู่คำทั้งหมด", + "searchRegex": "ใช้ Regular Expression", + "searchNoResults": "ไม่พบผลลัพธ์", + "searchResultCount": "{{index}} ของ {{count}}", + "searchNext": "การแข่งขันถัดไป (กดเข้าร่วม)", + "searchPrevious": "การแข่งขันครั้งก่อนหน้า (Shift+Enter)", + "searchClose": "ปิด (หนี)", "maxReconnectAttemptsReached": "จำนวนครั้งการเชื่อมต่อใหม่สูงสุดครบแล้ว", "closeTab": "ปิด", "connectionTimeout": "หมดเวลาการเชื่อมต่อ", @@ -1654,6 +1707,11 @@ "opksshTimeout": "การตรวจสอบสิทธิ์หมดเวลา โปรดลองอีกครั้ง", "opksshAuthFailed": "การตรวจสอบสิทธิ์ล้มเหลว โปรดตรวจสอบข้อมูลประจำตัวของคุณและลองอีกครั้ง", "opksshSignInWith": "ลงชื่อเข้าใช้ด้วย {{provider}}", + "tailscaleCheckRequired": "จำเป็นต้องยืนยันตัวตนด้วย Tailscale", + "tailscaleCheckDescription": "การเชื่อมต่อ Tailscale SSH ต้องการการตรวจสอบเพิ่มเติม โปรดยืนยันตัวตนในเบราว์เซอร์ของคุณเพื่อดำเนินการต่อ", + "tailscaleCheckOpenBrowser": "เปิดเบราว์เซอร์เพื่อยืนยันตัวตน", + "tailscaleCheckWaiting": "กำลังรอการยืนยันตัวตนจาก Tailscale...", + "tailscaleCheckTimeout": "การตรวจสอบสิทธิ์ Tailscale หมดเวลา โปรดลองอีกครั้ง", "vaultAuthTitle": "ต้องลงชื่อเข้าใช้ Vault", "vaultAuthDescription": "หน้าต่างสำหรับเข้าสู่ระบบ HashiCorp Vault ได้เปิดขึ้นแล้ว โปรดทำการเข้าสู่ระบบให้เสร็จสมบูรณ์ การเชื่อมต่อนี้จะดำเนินการต่อโดยอัตโนมัติ", "vaultAuthFailed": "การตรวจสอบสิทธิ์ Vault ล้มเหลว โปรดลองอีกครั้ง", @@ -2145,6 +2203,7 @@ "cpuUsage": "การใช้งาน CPU", "memoryUsage": "การใช้งานหน่วยความจำ", "diskUsage": "การใช้งานดิสก์", + "selectFilesystem": "เลือกไฟล์ระบบ", "temperature": "อุณหภูมิ", "highestTemperature": "อุณหภูมิสูงสุด", "failedToFetchHostConfig": "ไม่สามารถดึงข้อมูลการกำหนดค่าโฮสต์ได้", @@ -2819,6 +2878,7 @@ "updateCommandHistoryFailed": "ไม่สามารถอัปเดตการตั้งค่าประวัติคำสั่งได้", "analyticsEnabled": "แบ่งปันสถิติการใช้งานแบบไม่ระบุตัวตน", "analyticsEnabledDesc": "ส่งรายงานจำนวนผู้ใช้ โฮสต์ และการใช้งานฟีเจอร์รายวันแบบไม่ระบุตัวตน เพื่อช่วยปรับปรุง Termix ไม่มีข้อมูลส่วนบุคคลหรือรายละเอียดการเชื่อมต่อใดๆ รวมอยู่ด้วย", + "analyticsEnabledLockedDesc": "การตั้งค่านี้ถูกล็อกโดยตัวแปรสภาพแวดล้อม ENABLE_TELEMETRY และไม่สามารถเปลี่ยนแปลงได้ที่นี่", "updateAnalyticsFailed": "ไม่สามารถอัปเดตการตั้งค่าการวิเคราะห์ได้", "sessionSharingGloballyEnabled": "อนุญาตการแชร์เซสชัน", "sessionSharingGloballyEnabledDesc": "อนุญาตให้แชร์เซสชันเทอร์มินัลสด, RDP, VNC และ Telnet ทั่วทั้งอินสแตนซ์ การตั้งค่านี้จะแทนที่การตั้งค่าการแชร์ต่อโฮสต์ทั้งหมดเมื่อปิดใช้งานอยู่", @@ -3323,6 +3383,8 @@ "resetToDefaultsSuccess": "การตั้งค่าจะถูกรีเซ็ตเป็นค่าเริ่มต้น", "storageModeSwitch": "การจัดเก็บการตั้งค่า", "sectionAccount": "บัญชี", + "desktopProfileTitle": "โปรไฟล์เดสก์ท็อปโลคอลอัตโนมัติ", + "desktopProfileDescription": "โปรไฟล์นี้จำกัดการใช้งานเฉพาะในระบบแบ็กเอนด์แบบฝังตัวและจะเข้าสู่ระบบโดยอัตโนมัติ ไม่มีรหัสผ่านสำหรับการเข้าสู่ระบบ การซิงค์ระยะไกลด้านล่างใช้บัญชีเซิร์ฟเวอร์แยกต่างหาก", "sectionAppearance": "รูปร่าง", "sectionSecurity": "ความปลอดภัย", "sectionApiKeys": "คีย์ API", @@ -3379,6 +3441,8 @@ "statusColorsDesc": "ใช้สีเขียว/แดงสำหรับสถานะออนไลน์/ออฟไลน์แทนสีเน้น", "pinAppRail": "พินแอปเรล", "pinAppRailDesc": "ควรตั้งค่าให้แถบด้านข้างซ้ายของแอปแสดงอยู่ตลอดเวลาโดยไม่ต้องขยายเมื่อวางเมาส์เหนือแถบ", + "openFullscreenSettings": "เปิดการตั้งค่าแบบเต็มหน้าจอ", + "exitFullscreenSettings": "ออกจากการตั้งค่าแบบเต็มหน้าจอ", "expandAppRailOnHover": "ขยาย App Rail เมื่อวางเมาส์เหนือ Hover", "expandAppRailOnHoverDesc": "อนุญาตให้แถบด้านข้างซ้ายขยายออกเมื่อตัวชี้เมาส์เลื่อนไปอยู่เหนือแถบนั้น", "settingsNavigation": "การนำทาง", diff --git a/src/ui/locales/translated/tr_TR.json b/src/ui/locales/translated/tr_TR.json index e89ece0b..4ddd60f8 100644 --- a/src/ui/locales/translated/tr_TR.json +++ b/src/ui/locales/translated/tr_TR.json @@ -546,6 +546,7 @@ "sshTools": "SSH Araçları", "history": "Geçmiş", "sessionLogs": "Oturum Kayıtları", + "sidebarSettings": "Kenar Çubuğu Ayarları...", "hosts": "Ana Bilgisayarlar", "snippets": "Kod Parçacıkları", "hostManager": "Ana Bilgisayar Yöneticisi", @@ -755,6 +756,8 @@ "agentSocketPathLabel": "Ajan Soket Yolu", "agentSocketPathPlaceholder": "SSH_AUTH_SOCK kullanmak için boş bırakın", "agentSocketPathHint": "SSH_AUTH_SOCK ortam değişkeninden otomatik algılamak için boş bırakın veya özel bir soket yolu girin (örn. /run/user/1000/gnupg/S.gpg-agent.ssh).", + "shareSshAuthLabel": "SSH Kimlik Doğrulamasını Paylaş", + "shareSshAuthDesc": "Alıcılara bu sunucunun SSH kimlik doğrulama bilgilerinin şifrelenmiş kopyalarını verin. Alıcının kişisel kimlik bilgileri öncelikli olmaya devam eder.", "tailscaleDeviceSelect": "Tailscale cihazı seçin", "tailscaleDeviceSelectPlaceholder": "Bir cihaz seçin...", "tailscaleNoApiKey": "Tailscale API anahtarı yapılandırılmamış. Cihaz keşfini etkinleştirmek için Yönetici Ayarları'ndan bir tane ekleyin.", @@ -1173,15 +1176,39 @@ "generateFromPrivateKey": "Özel Anahtardan Oluştur", "refreshBtn2": "Yenile", "exitSelectionTitle": "Seçimden çık", - "exportAll": "Tümünü Dışa Aktar", - "exportForSharing": "Paylaşım için Dışa Aktar", "addHostBtn2": "Sunucu Ekle", "addCredentialBtn2": "Kimlik Bilgisi Ekle", "checkingHostStatuses": "Sunucu durumları kontrol ediliyor...", "pinnedSection": "Sabitlenmiş", "hostsExported": "Sunucular başarıyla dışa aktarıldı", - "hostsShareExported": "Paylaşılabilir sunucular başarıyla dışa aktarıldı", - "exportFailed": "Sunucular dışa aktarılamadı", + "export": { + "menuItem": "İhracat...", + "title": "Ana bilgisayarları dışa aktarın", + "scope": "Kapsam", + "scopeAll": "Tüm", + "scopeSelected": "Seçildi", + "searchHosts": "Sunucu ara...", + "include": "Katmak", + "groupConnection": "Bağlantı", + "groupCredentials": "Kimlik Bilgileri", + "groupNotes": "Notlar", + "groupTags": "Etiketler ve pinler", + "groupTunnels": "Tüneller", + "groupJumpHosts": "Sunucuları atla", + "groupQuickActions": "Hızlı işlemler", + "groupFeatureFlags": "Özellik bayrakları", + "groupAdvanced": "Gelişmiş yapılandırma", + "preview": "Önizleme", + "moreHosts": "... {{count}} daha fazla sunucu", + "summary": "{{selected}} / {{total}} ana bilgisayar", + "credentialsIncluded": "kimlik bilgileri dahil", + "credentialsExcluded": "kimlik bilgileri hariç tutuldu", + "noneSelected": "Hiçbir sunucu seçilmedi.", + "cancel": "İptal etmek", + "confirm": "İhracat", + "fetchFailed": "Dışa aktarma için sunucular yüklenemedi.", + "bulkButton": "İhracat" + }, "sampleDownloaded": "Örnek dosya indirildi", "failedToDeleteCredential2": "Kimlik bilgisi silinemedi", "noFolderOption": "(Klasör yok)", @@ -1266,7 +1293,7 @@ }, "edit": { "label": "Düzenle", - "description": "Görüntüleme iznine ek olarak sunucuyu düzenleyebilir. Gizli bilgiler değiştirilebilir ancak asla okunamaz; kimlik bilgisi atamaları yalnızca sahibi tarafından yönetilebilir." + "description": "Kimlik doğrulaması gerektirmeyen sunucu ayarlarını görüntüleyin ve değiştirin. Sahibin SSH kimlik doğrulaması gizli kalır ve yalnızca sahibine özeldir." }, "manage": { "label": "Yönet", @@ -1299,7 +1326,24 @@ "sharedBadgeTooltip": "{{owner}} tarafından paylaşıldı ({{level}} erişimi)", "viewOnlyBanner": "Bu sunucu {{owner}} tarafından görüntüleme erişimiyle paylaşıldı. Yapılandırma salt okunur.", "sharedEditBanner": "Bu sunucu {{owner}} tarafından düzenleme erişimiyle paylaşıldı. Değişiklikler gerçek sunucuya uygulanır; kimlik doğrulama referansları yalnızca sahibi tarafından değiştirilebilir.", - "ownerOnlyControl": "Bunu yalnızca sunucu sahibi değiştirebilir" + "ownerOnlyControl": "Bunu yalnızca sunucu sahibi değiştirebilir", + "ownerAuthPrivate": "Sunucu sahibinin SSH kimlik doğrulaması özeldir. Kendi kimlik bilgilerinizi seçmek için sunucu menüsünden \"Kişisel SSH kimlik doğrulaması ayarla\" seçeneğini kullanın.", + "ownerAuthShared": "Sunucu sahibi bu sunucu için SSH kimlik doğrulamasını paylaştı. Bunu kullanabilir veya \"Kişisel SSH kimlik doğrulamasını ayarla\" bölümünden kendi kimlik bilgilerinizi seçebilirsiniz.", + "authOverrideAction": "Kişisel SSH kimlik doğrulamasını ayarlayın.", + "authOverrideTitle": "Kişisel SSH kimlik doğrulaması", + "authOverrideDescriptionPrivate": "Sunucu sahibinin SSH kimlik bilgileri gizli kalır. {{host}} adresine bağlantı kurmak için kaydedilmiş kimlik bilgilerinizden birini seçin.", + "authOverrideDescriptionShared": "Sunucu sahibinin paylaştığı kimlik doğrulama yöntemini kullanın veya {{host}} adresine bağlantılar için kaydettiğiniz kimlik bilgilerinizden birini kullanın.", + "authOverrideCredentialLabel": "Kimlik doğrulama bilgisi", + "useSharedAuthentication": "Paylaşımlı sunucu kimlik doğrulamasını kullanın.", + "noPersonalCredential": "Kişisel kimlik belgesi yok.", + "authOverrideNoCredentials": "Henüz kayıtlı SSH kimlik bilgileriniz yok. Kimlik doğrulaması gerektiren sunuculara bağlanmak için Kimlik Bilgileri bölümünden bir tane oluşturun.", + "authOverrideRequired": "Bu sunucuya bağlanabilmeniz için kayıtlı kimlik bilgilerinizden birine ihtiyacınız var.", + "authOverridePrivateHint": "Bu kimlik bilgisi size özeldir. Sunucu sahibi ve diğer alıcılar bunu göremez veya kullanamaz.", + "authOverrideSaved": "Kişisel SSH kimlik doğrulaması kaydedildi.", + "authOverrideCleared": "Kişisel SSH kimlik doğrulaması kaldırıldı.", + "authOverrideClearedToShared": "Paylaşımlı sunucu kimlik doğrulamasını kullanma", + "authOverrideLoadError": "SSH kimlik doğrulamanız yüklenemedi. Lütfen tekrar deneyin.", + "authOverrideSaveError": "SSH kimlik doğrulamanız kaydedilemedi." }, "guac": { "connection": "Bağlantı", @@ -1627,6 +1671,15 @@ "tmuxCopyHint": "Seçimi ayarlayın ve panoya kopyalamak için Enter tuşuna basın", "tmuxDetach": "tmux oturumundan ayrıl", "tmuxDetached": "tmux oturumundan ayrıldı", + "searchPlaceholder": "Bulmak", + "searchCaseSensitive": "Kibrit Kutusu", + "searchWholeWord": "Kelimenin tamamını eşleştir", + "searchRegex": "Düzenli İfade Kullanımı", + "searchNoResults": "Sonuç bulunamadı.", + "searchResultCount": "{{index}} / {{count}}", + "searchNext": "Sonraki Maç (Giriş)", + "searchPrevious": "Önceki Maç (Shift+Enter)", + "searchClose": "Kapat (Escape)", "maxReconnectAttemptsReached": "Maksimum yeniden bağlanma denemesine ulaşıldı", "closeTab": "Kapat", "connectionTimeout": "Bağlantı zaman aşımı", @@ -1654,6 +1707,11 @@ "opksshTimeout": "Kimlik doğrulaması zaman aşımına uğradı. Lütfen tekrar deneyin.", "opksshAuthFailed": "Kimlik doğrulaması başarısız oldu. Lütfen kimlik bilgilerinizi kontrol edip tekrar deneyin.", "opksshSignInWith": "{{provider}} ile oturum aç", + "tailscaleCheckRequired": "Tailscale Kimlik Doğrulaması Gerekli", + "tailscaleCheckDescription": "Tailscale SSH ek bir doğrulama gerektiriyor. Devam etmek için tarayıcınızda kimlik doğrulaması yapın.", + "tailscaleCheckOpenBrowser": "Kimlik doğrulaması için tarayıcıyı açın.", + "tailscaleCheckWaiting": "Tailscale kimlik doğrulamasının tamamlanmasını bekliyoruz...", + "tailscaleCheckTimeout": "Tailscale kimlik doğrulama işlemi zaman aşımına uğradı. Lütfen tekrar deneyin.", "vaultAuthTitle": "Vault Oturum Açması Gerekli", "vaultAuthDescription": "HashiCorp Vault'ta oturum açmak için bir pencere açıldı. Orada oturum açmayı tamamlayın; bu bağlantı otomatik olarak devam edecek.", "vaultAuthFailed": "Vault kimlik doğrulaması başarısız oldu. Lütfen tekrar deneyin.", @@ -2145,6 +2203,7 @@ "cpuUsage": "CPU Kullanımı", "memoryUsage": "Bellek Kullanımı", "diskUsage": "Disk Kullanımı", + "selectFilesystem": "Dosya sistemini seçin", "temperature": "Sıcaklık", "highestTemperature": "En yüksek sıcaklık", "failedToFetchHostConfig": "Host yapılandırması alınamadı", @@ -2819,6 +2878,7 @@ "updateCommandHistoryFailed": "Komut geçmişi ayarı güncellenemedi", "analyticsEnabled": "Anonim Kullanım İstatistiklerini Paylaşın", "analyticsEnabledDesc": "Termix'i geliştirmeye yardımcı olmak için kullanıcı, sunucu ve özellik kullanımına ilişkin anonim günlük sayım gönderir. Hiçbir kişisel veri veya bağlantı bilgisi asla dahil edilmez.", + "analyticsEnabledLockedDesc": "Bu ayar, ENABLE_TELEMETRY ortam değişkeni tarafından kilitlenmiştir ve burada değiştirilemez.", "updateAnalyticsFailed": "Analiz ayarlarını güncelleme başarısız oldu.", "sessionSharingGloballyEnabled": "Oturum Paylaşımına İzin Ver", "sessionSharingGloballyEnabledDesc": "Canlı terminal, RDP, VNC ve Telnet oturumlarının tüm örnek genelinde paylaşılmasına izin verin. Devre dışı bırakıldığında, sunucu başına paylaşım ayarlarının tümünü geçersiz kılar.", @@ -3323,6 +3383,8 @@ "resetToDefaultsSuccess": "Ayarlar varsayılanlara sıfırlandı.", "storageModeSwitch": "Tercih Depolama", "sectionAccount": "Hesap", + "desktopProfileTitle": "Otomatik yerel masaüstü profili", + "desktopProfileDescription": "Bu profil, gömülü arka uç ile sınırlıdır ve otomatik olarak oturum açar. Giriş şifresi yoktur; aşağıdaki Uzaktan Senkronizasyon ayrı bir sunucu hesabı kullanır.", "sectionAppearance": "Görünüm", "sectionSecurity": "Güvenlik", "sectionApiKeys": "API Anahtarları", @@ -3379,6 +3441,8 @@ "statusColorsDesc": "Vurgu rengi yerine çevrimiçi/çevrimdışı durum için yeşil/kırmızı kullan", "pinAppRail": "Uygulama Rayını Sabitle", "pinAppRailDesc": "Farenin üzerine gelindiğinde genişletmek yerine sol kenar çubuğu uygulama rayını her zaman geniş tut", + "openFullscreenSettings": "Ayarları tam ekran aç", + "exitFullscreenSettings": "Tam ekran ayarlarından çık", "expandAppRailOnHover": "Fareyle Üzerine Gelince Uygulama Rayını Genişlet", "expandAppRailOnHoverDesc": "İmleç üzerine geldiğinde sol kenar çubuğu uygulama rayının genişlemesine izin ver", "settingsNavigation": "Gezinti", diff --git a/src/ui/locales/translated/uk_UA.json b/src/ui/locales/translated/uk_UA.json index 6aa86dcc..b973cc84 100644 --- a/src/ui/locales/translated/uk_UA.json +++ b/src/ui/locales/translated/uk_UA.json @@ -546,6 +546,7 @@ "sshTools": "SSH-інструменти", "history": "Історія", "sessionLogs": "Журнали сеансів", + "sidebarSettings": "Налаштування бічної панелі...", "hosts": "Хости", "snippets": "Фрагменти", "hostManager": "Менеджер хостингу", @@ -755,6 +756,8 @@ "agentSocketPathLabel": "Шлях сокета агента", "agentSocketPathPlaceholder": "Залиште поле порожнім, щоб використовувати SSH_AUTH_SOCK", "agentSocketPathHint": "Залиште поле порожнім для автоматичного визначення зі змінної середовища SSH_AUTH_SOCK або введіть власний шлях до сокета (наприклад, /run/user/1000/gnupg/S.gpg-agent.ssh).", + "shareSshAuthLabel": "Спільний доступ до SSH-автентифікації", + "shareSshAuthDesc": "Надати одержувачам зашифровані копії SSH-автентифікації цього хоста. Особисті облікові дані одержувача все ще мають пріоритет.", "tailscaleDeviceSelect": "Виберіть пристрій Tailscale", "tailscaleDeviceSelectPlaceholder": "Виберіть пристрій...", "tailscaleNoApiKey": "Ключ API Tailscale не налаштовано. Додайте його в налаштуваннях адміністратора, щоб увімкнути виявлення пристроїв.", @@ -1173,15 +1176,39 @@ "generateFromPrivateKey": "Згенерувати з закритого ключа", "refreshBtn2": "Оновити", "exitSelectionTitle": "Вихід з вибору", - "exportAll": "Експортувати все", - "exportForSharing": "Експорт для спільного доступу", "addHostBtn2": "Додати хоста", "addCredentialBtn2": "Додати облікові дані", "checkingHostStatuses": "Перевірка статусів хостів...", "pinnedSection": "Закріплено", "hostsExported": "Хости успішно експортовано", - "hostsShareExported": "Спільні хости успішно експортовано", - "exportFailed": "Не вдалося експортувати хости", + "export": { + "menuItem": "Експорт...", + "title": "Експорт хостів", + "scope": "Сфера застосування", + "scopeAll": "Усі", + "scopeSelected": "Вибрано", + "searchHosts": "Пошук хостів...", + "include": "Включити", + "groupConnection": "З'єднання", + "groupCredentials": "Облікові дані", + "groupNotes": "Нотатки", + "groupTags": "Теги та закріплення", + "groupTunnels": "Тунелі", + "groupJumpHosts": "Хости Jump", + "groupQuickActions": "Швидкі дії", + "groupFeatureFlags": "Прапорці функцій", + "groupAdvanced": "Розширена конфігурація", + "preview": "Попередній перегляд", + "moreHosts": "... {{count}} більше хостів", + "summary": "{{selected}} з {{total}} хостів", + "credentialsIncluded": "включено облікові дані", + "credentialsExcluded": "облікові дані виключені", + "noneSelected": "Не вибрано хостів", + "cancel": "Скасувати", + "confirm": "Експорт", + "fetchFailed": "Не вдалося завантажити хости для експорту", + "bulkButton": "Експорт" + }, "sampleDownloaded": "Зразок файлу завантажено", "failedToDeleteCredential2": "Не вдалося видалити облікові дані", "noFolderOption": "(Без папки)", @@ -1266,7 +1293,7 @@ }, "edit": { "label": "Редагувати", - "description": "Перегляд і зміна хоста. Секрети можна замінити, але ніколи не прочитати; призначення облікових даних залишається лише для власника." + "description": "Переглядайте та змінюйте налаштування хоста без автентифікації. SSH-автентифікація власника залишається приватною та доступною лише для власника." }, "manage": { "label": "Керувати", @@ -1299,7 +1326,24 @@ "sharedBadgeTooltip": "Поділився {{owner}} (доступ{{level}})", "viewOnlyBanner": "Цей хост надано вам спільний доступ до перегляду користувачем {{owner}} . Конфігурація доступна лише для читання.", "sharedEditBanner": "Цей хост надано вам спільний доступ до редагування користувачем {{owner}} . Зміни застосовуються до справжнього хоста; посилання на автентифікацію може змінити лише власник.", - "ownerOnlyControl": "Тільки власник хоста може це змінити" + "ownerOnlyControl": "Тільки власник хоста може це змінити", + "ownerAuthPrivate": "Автентифікація SSH власника хоста є приватною. Використайте пункт «Встановити особисту автентифікацію SSH» у меню хоста, щоб вибрати власні облікові дані.", + "ownerAuthShared": "Власник хосту надав спільну SSH-автентифікацію для цього хосту. Ви можете скористатися нею або вибрати власні облікові дані з розділу «Налаштувати особисту SSH-автентифікацію».", + "authOverrideAction": "Налаштування персональної SSH-автентифікації", + "authOverrideTitle": "Персональна SSH-автентифікація", + "authOverrideDescriptionPrivate": "Облікові дані SSH власника хоста залишаються конфіденційними. Виберіть одні зі збережених облікових даних для підключення до {{host}}.", + "authOverrideDescriptionShared": "Використовуйте автентифікацію, надану власником хоста, або замініть її одними зі збережених облікових даних для підключень до {{host}}.", + "authOverrideCredentialLabel": "Облікові дані автентифікації", + "useSharedAuthentication": "Використовувати автентифікацію спільного хоста", + "noPersonalCredential": "Без особистого посвідчення", + "authOverrideNoCredentials": "У вас ще немає збережених облікових даних SSH. Створіть їх у розділі \"Облікові дані\", щоб підключатися до хостів, які потребують автентифікації.", + "authOverrideRequired": "Цей хост вимагає один із ваших збережених облікових даних, перш ніж ви зможете підключитися.", + "authOverridePrivateHint": "Ці облікові дані є приватними. Власник хоста та інші одержувачі не можуть їх бачити чи використовувати.", + "authOverrideSaved": "Персональна SSH-автентифікація збережена", + "authOverrideCleared": "Особисту SSH-автентифікацію видалено", + "authOverrideClearedToShared": "Використання автентифікації спільного хоста", + "authOverrideLoadError": "Не вдалося завантажити вашу SSH-автентифікацію. Спробуйте ще раз.", + "authOverrideSaveError": "Не вдалося зберегти вашу SSH-автентифікацію" }, "guac": { "connection": "З'єднання", @@ -1627,6 +1671,15 @@ "tmuxCopyHint": "Відрегулюйте виділення та натисніть Enter, щоб скопіювати його в буфер обміну", "tmuxDetach": "Від’єднатися від сеансу tmux", "tmuxDetached": "Від’єднано від сеансу tmux", + "searchPlaceholder": "Знайти", + "searchCaseSensitive": "Зіставте регістр", + "searchWholeWord": "Зіставлення цілого слова", + "searchRegex": "Використовуйте регулярний вираз", + "searchNoResults": "Немає результатів", + "searchResultCount": "{{index}} з {{count}}", + "searchNext": "Наступний матч (Enter)", + "searchPrevious": "Попередній збіг (Shift+Enter)", + "searchClose": "Закрити (Escape)", "maxReconnectAttemptsReached": "Досягнуто максимальної кількості спроб повторного підключення", "closeTab": "Закрити", "connectionTimeout": "Тайм-аут з'єднання", @@ -1654,6 +1707,11 @@ "opksshTimeout": "Час очікування автентифікації минув. Спробуйте ще раз.", "opksshAuthFailed": "Помилка автентифікації. Перевірте свої облікові дані та спробуйте ще раз.", "opksshSignInWith": "Увійти за допомогою {{provider}}", + "tailscaleCheckRequired": "Потрібна автентифікація Tailscale", + "tailscaleCheckDescription": "Для Tailscale SSH потрібна додаткова перевірка. Щоб продовжити, автентифікуйтеся у своєму браузері.", + "tailscaleCheckOpenBrowser": "Відкрийте браузер для автентифікації", + "tailscaleCheckWaiting": "Очікування автентифікації Tailscale...", + "tailscaleCheckTimeout": "Час очікування автентифікації Tailscale минув. Будь ласка, спробуйте ще раз.", "vaultAuthTitle": "Потрібен вхід до Сейфа", "vaultAuthDescription": "Відкрилося вікно для входу в HashiCorp Vault. Завершіть вхід там; це з’єднання буде продовжено автоматично.", "vaultAuthFailed": "Не вдалося виконати автентифікацію сховища. Спробуйте ще раз.", @@ -2145,6 +2203,7 @@ "cpuUsage": "Використання процесора", "memoryUsage": "Використання пам'яті", "diskUsage": "Використання диска", + "selectFilesystem": "Виберіть файлову систему", "temperature": "Температура", "highestTemperature": "Найвища температура", "failedToFetchHostConfig": "Не вдалося отримати конфігурацію хоста", @@ -2819,6 +2878,7 @@ "updateCommandHistoryFailed": "Не вдалося оновити налаштування історії команд", "analyticsEnabled": "Поділитися анонімною статистикою використання", "analyticsEnabledDesc": "Надсилає анонімний щоденний підрахунок користувачів, хостів та використання функцій, щоб допомогти покращити Termix. Жодні особисті дані чи деталі з’єднання ніколи не включаються.", + "analyticsEnabledLockedDesc": "Цей параметр заблоковано змінною середовища ENABLE_TELEMETRY і його не можна змінити тут.", "updateAnalyticsFailed": "Не вдалося оновити налаштування аналітики", "sessionSharingGloballyEnabled": "Дозволити спільний доступ до сеансу", "sessionSharingGloballyEnabledDesc": "Дозволити спільний доступ до сеансів терміналу, RDP, VNC та Telnet для всього екземпляра. Перевизначає кожен перемикач спільного доступу для кожного хоста, якщо його вимкнено.", @@ -3323,6 +3383,8 @@ "resetToDefaultsSuccess": "Налаштування скинуто до заводських значень.", "storageModeSwitch": "Зберігання налаштувань", "sectionAccount": "Обліковий запис", + "desktopProfileTitle": "Автоматичний профіль локального робочого столу", + "desktopProfileDescription": "Цей профіль обмежений вбудованим сервером і входить автоматично. Він не має пароля для входу; для віддаленої синхронізації нижче використовується окремий обліковий запис сервера.", "sectionAppearance": "Зовнішній вигляд", "sectionSecurity": "Безпека", "sectionApiKeys": "Ключі API", @@ -3379,6 +3441,8 @@ "statusColorsDesc": "Використовуйте зелений/червоний для статусу онлайн/офлайн замість акцентного кольору", "pinAppRail": "Закріпити рейку додатків", "pinAppRailDesc": "Залишати ліву бічну панель програм завжди розгорнутою, а не при наведенні курсора", + "openFullscreenSettings": "Відкрити налаштування на весь екран", + "exitFullscreenSettings": "Вихід із налаштувань повноекранного режиму", "expandAppRailOnHover": "Розгорнути панель програм при наведенні курсора", "expandAppRailOnHoverDesc": "Дозволити розгортання лівої бічної панелі програми, коли курсор переміщується по ній", "settingsNavigation": "Навігація", diff --git a/src/ui/locales/translated/vi_VN.json b/src/ui/locales/translated/vi_VN.json index ec4cd1ff..34cefac1 100644 --- a/src/ui/locales/translated/vi_VN.json +++ b/src/ui/locales/translated/vi_VN.json @@ -546,6 +546,7 @@ "sshTools": "Công cụ SSH", "history": "Lịch sử", "sessionLogs": "Nhật ký phiên", + "sidebarSettings": "Cài đặt thanh bên...", "hosts": "Máy chủ", "snippets": "Đoạn mã", "hostManager": "Quản lý máy chủ", @@ -755,6 +756,8 @@ "agentSocketPathLabel": "Đường dẫn socket của Agent", "agentSocketPathPlaceholder": "Để trống để sử dụng SSH_AUTH_SOCK", "agentSocketPathHint": "Để trống để tự động phát hiện từ biến môi trường SSH_AUTH_SOCK, hoặc nhập đường dẫn socket tùy chỉnh (ví dụ: /run/user/1000/gnupg/S.gpg-agent.ssh).", + "shareSshAuthLabel": "Chia sẻ xác thực SSH", + "shareSshAuthDesc": "Cung cấp cho người nhận bản sao đã mã hóa thông tin xác thực SSH của máy chủ này. Thông tin xác thực cá nhân của người nhận vẫn được ưu tiên.", "tailscaleDeviceSelect": "Chọn thiết bị Tailscale", "tailscaleDeviceSelectPlaceholder": "Chọn thiết bị...", "tailscaleNoApiKey": "Chưa cấu hình khóa API Tailscale. Thêm một khóa trong Cài đặt Quản trị để bật khám phá thiết bị.", @@ -1173,15 +1176,39 @@ "generateFromPrivateKey": "Tạo từ khóa riêng tư", "refreshBtn2": "Làm mới", "exitSelectionTitle": "Thoát chọn", - "exportAll": "Xuất tất cả", - "exportForSharing": "Xuất để chia sẻ", "addHostBtn2": "Thêm máy chủ", "addCredentialBtn2": "Thêm thông tin xác thực", "checkingHostStatuses": "Đang kiểm tra trạng thái máy chủ...", "pinnedSection": "Đã ghim", "hostsExported": "Đã xuất máy chủ thành công", - "hostsShareExported": "Đã xuất máy chủ có thể chia sẻ thành công", - "exportFailed": "Xuất máy chủ thất bại", + "export": { + "menuItem": "Xuất khẩu...", + "title": "Máy chủ xuất khẩu", + "scope": "Phạm vi", + "scopeAll": "Tất cả", + "scopeSelected": "Đã chọn", + "searchHosts": "Tìm kiếm máy chủ...", + "include": "Bao gồm", + "groupConnection": "Sự liên quan", + "groupCredentials": "Thông tin xác thực", + "groupNotes": "Ghi chú", + "groupTags": "Thẻ & ghim", + "groupTunnels": "Đường hầm", + "groupJumpHosts": "Người dẫn chương trình Jump", + "groupQuickActions": "Hành động nhanh chóng", + "groupFeatureFlags": "Cờ tính năng", + "groupAdvanced": "Cấu hình nâng cao", + "preview": "Xem trước", + "moreHosts": "... {{count}} thêm máy chủ", + "summary": "{{selected}} trong số {{total}} máy chủ", + "credentialsIncluded": "bao gồm thông tin xác thực", + "credentialsExcluded": "thông tin xác thực bị loại trừ", + "noneSelected": "Chưa có máy chủ nào được chọn.", + "cancel": "Hủy bỏ", + "confirm": "Xuất khẩu", + "fetchFailed": "Không thể tải các máy chủ để xuất.", + "bulkButton": "Xuất khẩu" + }, "sampleDownloaded": "Đã tải tệp mẫu", "failedToDeleteCredential2": "Xóa thông tin xác thực thất bại", "noFolderOption": "(Không có thư mục)", @@ -1266,7 +1293,7 @@ }, "edit": { "label": "Chỉnh sửa", - "description": "Xem, và sửa đổi máy chủ. Bí mật có thể được thay thế nhưng không bao giờ đọc; việc gán thông tin xác thực chỉ thuộc về chủ sở hữu." + "description": "Xem và chỉnh sửa cài đặt máy chủ không yêu cầu xác thực. Thông tin xác thực SSH của chủ sở hữu vẫn được giữ kín và chỉ dành riêng cho chủ sở hữu." }, "manage": { "label": "Quản lý", @@ -1299,7 +1326,24 @@ "sharedBadgeTooltip": "Được chia sẻ bởi {{owner}} (quyền truy cập {{level}})", "viewOnlyBanner": "Máy chủ này được {{owner}} chia sẻ cho bạn với quyền xem. Cấu hình chỉ đọc.", "sharedEditBanner": "Máy chủ này được {{owner}} chia sẻ cho bạn với quyền chỉnh sửa. Thay đổi áp dụng cho máy chủ thực; tham chiếu xác thực chỉ có thể được thay đổi bởi chủ sở hữu.", - "ownerOnlyControl": "Chỉ chủ sở hữu máy chủ mới có thể thay đổi" + "ownerOnlyControl": "Chỉ chủ sở hữu máy chủ mới có thể thay đổi", + "ownerAuthPrivate": "Thông tin xác thực SSH của chủ sở hữu máy chủ là riêng tư. Sử dụng tùy chọn “Thiết lập xác thực SSH cá nhân” từ menu máy chủ để chọn thông tin đăng nhập của riêng bạn.", + "ownerAuthShared": "Chủ sở hữu máy chủ đã chia sẻ thông tin xác thực SSH cho máy chủ này. Bạn có thể sử dụng thông tin đó hoặc chọn thông tin đăng nhập của riêng mình từ mục “Thiết lập xác thực SSH cá nhân”.", + "authOverrideAction": "Thiết lập xác thực SSH cá nhân", + "authOverrideTitle": "Xác thực SSH cá nhân", + "authOverrideDescriptionPrivate": "Thông tin đăng nhập SSH của chủ sở hữu máy chủ được giữ bí mật. Hãy chọn một trong các thông tin đăng nhập đã lưu của bạn để kết nối với {{host}}.", + "authOverrideDescriptionShared": "Sử dụng thông tin xác thực do chủ sở hữu máy chủ cung cấp, hoặc thay thế nó bằng một trong những thông tin đăng nhập đã lưu của bạn để kết nối với {{host}}.", + "authOverrideCredentialLabel": "Thông tin xác thực", + "useSharedAuthentication": "Sử dụng xác thực máy chủ dùng chung", + "noPersonalCredential": "Không có giấy tờ chứng nhận cá nhân", + "authOverrideNoCredentials": "Bạn chưa lưu thông tin đăng nhập SSH nào. Hãy tạo một thông tin đăng nhập trong mục Thông tin đăng nhập để kết nối với các máy chủ yêu cầu xác thực.", + "authOverrideRequired": "Máy chủ này yêu cầu bạn nhập một trong những thông tin đăng nhập đã lưu trước khi có thể kết nối.", + "authOverridePrivateHint": "Thông tin đăng nhập này chỉ dành riêng cho bạn. Chủ sở hữu máy chủ và những người nhận khác không thể xem hoặc sử dụng nó.", + "authOverrideSaved": "Thông tin xác thực SSH cá nhân đã được lưu.", + "authOverrideCleared": "Xác thực SSH cá nhân đã bị xóa.", + "authOverrideClearedToShared": "Sử dụng xác thực máy chủ dùng chung", + "authOverrideLoadError": "Không thể tải thông tin xác thực SSH của bạn. Vui lòng thử lại.", + "authOverrideSaveError": "Không thể lưu thông tin xác thực SSH của bạn." }, "guac": { "connection": "Kết nối", @@ -1627,6 +1671,15 @@ "tmuxCopyHint": "Điều chỉnh lựa chọn và nhấn Enter để sao chép vào clipboard", "tmuxDetach": "Ngắt khỏi phiên tmux", "tmuxDetached": "Đã ngắt khỏi phiên tmux", + "searchPlaceholder": "Tìm thấy", + "searchCaseSensitive": "Hộp diêm", + "searchWholeWord": "Ghép toàn bộ từ", + "searchRegex": "Sử dụng biểu thức chính quy", + "searchNoResults": "Không có kết quả", + "searchResultCount": "{{index}} của {{count}}", + "searchNext": "Trận đấu tiếp theo (Nhập)", + "searchPrevious": "Kết quả khớp trước đó (Shift+Enter)", + "searchClose": "Đóng (Thoát)", "maxReconnectAttemptsReached": "Đã đạt số lần kết nối lại tối đa", "closeTab": "Đóng", "connectionTimeout": "Hết thời gian kết nối", @@ -1654,6 +1707,11 @@ "opksshTimeout": "Xác thực đã hết thời gian chờ. Vui lòng thử lại.", "opksshAuthFailed": "Xác thực thất bại. Vui lòng kiểm tra thông tin đăng nhập và thử lại.", "opksshSignInWith": "Đăng nhập bằng {{provider}}", + "tailscaleCheckRequired": "Cần xác thực Tailscale", + "tailscaleCheckDescription": "Tailscale SSH yêu cầu kiểm tra bổ sung. Vui lòng xác thực trong trình duyệt của bạn để tiếp tục.", + "tailscaleCheckOpenBrowser": "Mở trình duyệt để xác thực.", + "tailscaleCheckWaiting": "Đang chờ xác thực Tailscale...", + "tailscaleCheckTimeout": "Quá trình xác thực Tailscale đã hết hạn. Vui lòng thử lại.", "vaultAuthTitle": "Yêu cầu đăng nhập Vault", "vaultAuthDescription": "Một cửa sổ đã mở để đăng nhập vào HashiCorp Vault. Hoàn tất đăng nhập ở đó; kết nối này sẽ tự động tiếp tục.", "vaultAuthFailed": "Xác thực Vault thất bại. Vui lòng thử lại.", @@ -2145,6 +2203,7 @@ "cpuUsage": "Mức sử dụng CPU", "memoryUsage": "Mức sử dụng bộ nhớ", "diskUsage": "Mức sử dụng ổ đĩa", + "selectFilesystem": "Chọn hệ thống tệp", "temperature": "Nhiệt độ", "highestTemperature": "Nhiệt độ cao nhất", "failedToFetchHostConfig": "Không tải được cấu hình máy chủ", @@ -2819,6 +2878,7 @@ "updateCommandHistoryFailed": "Không thể cập nhật cài đặt lịch sử lệnh", "analyticsEnabled": "Chia sẻ số liệu thống kê sử dụng ẩn danh", "analyticsEnabledDesc": "Gửi báo cáo hàng ngày ẩn danh về số lượng người dùng, máy chủ và mức sử dụng tính năng để giúp cải thiện Termix. Không có dữ liệu cá nhân hoặc chi tiết kết nối nào được tiết lộ.", + "analyticsEnabledLockedDesc": "Cài đặt này bị khóa bởi biến môi trường ENABLE_TELEMETRY và không thể thay đổi ở đây.", "updateAnalyticsFailed": "Không thể cập nhật cài đặt phân tích.", "sessionSharingGloballyEnabled": "Cho phép chia sẻ phiên", "sessionSharingGloballyEnabledDesc": "Cho phép chia sẻ các phiên terminal trực tiếp, RDP, VNC và Telnet trên toàn bộ máy chủ. Khi bị vô hiệu hóa, tùy chọn này sẽ ghi đè lên mọi tùy chọn chia sẻ trên từng máy chủ riêng lẻ.", @@ -3323,6 +3383,8 @@ "resetToDefaultsSuccess": "Đã đặt lại cài đặt về mặc định.", "storageModeSwitch": "Lưu trữ tùy chọn", "sectionAccount": "Tài khoản", + "desktopProfileTitle": "Hồ sơ máy tính để bàn cục bộ tự động", + "desktopProfileDescription": "Hồ sơ này chỉ dành cho hệ thống phụ trợ nhúng và đăng nhập tự động. Nó không có mật khẩu đăng nhập; Chức năng Đồng bộ từ xa bên dưới sử dụng một tài khoản máy chủ riêng biệt.", "sectionAppearance": "Giao diện", "sectionSecurity": "Bảo mật", "sectionApiKeys": "Khóa API", @@ -3379,6 +3441,8 @@ "statusColorsDesc": "Dùng màu xanh/đỏ cho trạng thái trực tuyến/ngoại tuyến thay vì màu nhấn", "pinAppRail": "Ghim thanh ứng dụng", "pinAppRailDesc": "Giữ thanh ứng dụng bên trái luôn mở rộng thay vì chỉ mở khi di chuột", + "openFullscreenSettings": "Mở cài đặt ở chế độ toàn màn hình", + "exitFullscreenSettings": "Thoát khỏi chế độ toàn màn hình", "expandAppRailOnHover": "Mở rộng thanh ứng dụng khi di chuột", "expandAppRailOnHoverDesc": "Cho phép thanh ứng dụng bên trái mở rộng khi con trỏ di chuyển qua", "settingsNavigation": "Điều hướng", diff --git a/src/ui/locales/translated/zh_CN.json b/src/ui/locales/translated/zh_CN.json index 938fa4ef..b7097ebe 100644 --- a/src/ui/locales/translated/zh_CN.json +++ b/src/ui/locales/translated/zh_CN.json @@ -546,6 +546,7 @@ "sshTools": "SSH 工具", "history": "历史记录", "sessionLogs": "会话日志", + "sidebarSettings": "侧边栏设置...", "hosts": "主机", "snippets": "代码片段", "hostManager": "主机管理器", @@ -755,6 +756,8 @@ "agentSocketPathLabel": "代理套接字路径", "agentSocketPathPlaceholder": "留空以使用 SSH_AUTH_SOCK", "agentSocketPathHint": "留空以从 SSH_AUTH_SOCK 环境变量自动检测,或输入自定义套接字路径(例如 /run/user/1000/gnupg/S.gpg-agent.ssh)。", + "shareSshAuthLabel": "共享 SSH 身份验证", + "shareSshAuthDesc": "向收件人提供此主机 SSH 身份验证信息的加密副本。收件人的个人凭证仍然优先。", "tailscaleDeviceSelect": "选择 Tailscale 设备", "tailscaleDeviceSelectPlaceholder": "选择设备...", "tailscaleNoApiKey": "未配置 Tailscale API 密钥。请在管理设置中添加一个以启用设备发现。", @@ -1173,15 +1176,39 @@ "generateFromPrivateKey": "从私钥生成", "refreshBtn2": "刷新", "exitSelectionTitle": "退出选择", - "exportAll": "导出全部", - "exportForSharing": "导出共享", "addHostBtn2": "添加主机", "addCredentialBtn2": "添加凭据", "checkingHostStatuses": "正在检查主机状态...", "pinnedSection": "已置顶", "hostsExported": "主机已成功导出", - "hostsShareExported": "可共享的主机已成功导出", - "exportFailed": "导出主机失败", + "export": { + "menuItem": "出口...", + "title": "导出主机", + "scope": "范围", + "scopeAll": "全部", + "scopeSelected": "已选", + "searchHosts": "搜索主机...", + "include": "包括", + "groupConnection": "联系", + "groupCredentials": "证书", + "groupNotes": "笔记", + "groupTags": "标签和别针", + "groupTunnels": "隧道", + "groupJumpHosts": "跳转主机", + "groupQuickActions": "快速行动", + "groupFeatureFlags": "功能标志", + "groupAdvanced": "高级配置", + "preview": "预览", + "moreHosts": "... {{count}} 更多主机", + "summary": "{{selected}} 的 {{total}} 个主机", + "credentialsIncluded": "资质包括", + "credentialsExcluded": "已排除凭证", + "noneSelected": "未选择主机", + "cancel": "取消", + "confirm": "出口", + "fetchFailed": "导出主机失败", + "bulkButton": "出口" + }, "sampleDownloaded": "示例文件已下载", "failedToDeleteCredential2": "删除凭据失败", "noFolderOption": "(无文件夹)", @@ -1266,7 +1293,7 @@ }, "edit": { "label": "编辑", - "description": "查看,并修改主机。凭据可替换但不可读取;凭据分配仅限所有者。" + "description": "查看并修改无需身份验证的主机设置。所有者的 SSH 身份验证信息将保持私密,仅限所有者本人查看。" }, "manage": { "label": "管理", @@ -1299,7 +1326,24 @@ "sharedBadgeTooltip": "由 {{owner}} 共享({{level}} 访问权限)", "viewOnlyBanner": "此主机由 {{owner}} 与您共享,仅限查看权限。配置为只读。", "sharedEditBanner": "此主机由 {{owner}} 与您共享,具有编辑权限。更改将应用于真实主机;凭据引用只能由所有者更改。", - "ownerOnlyControl": "仅主机所有者可更改此项" + "ownerOnlyControl": "仅主机所有者可更改此项", + "ownerAuthPrivate": "主机所有者的 SSH 身份验证是私有的。请使用主机菜单中的“设置个人 SSH 身份验证”来选择您自己的凭据。", + "ownerAuthShared": "主机所有者已为此主机共享了 SSH 身份验证。您可以使用此身份验证,也可以从“设置个人 SSH 身份验证”中选择您自己的凭据。", + "authOverrideAction": "设置个人 SSH 身份验证", + "authOverrideTitle": "个人 SSH 身份验证", + "authOverrideDescriptionPrivate": "主机所有者的 SSH 凭据是私密的。请选择您保存的凭据之一连接到 {{host}}。", + "authOverrideDescriptionShared": "使用主机所有者共享的身份验证,或者将其替换为您保存的凭据之一,以便连接到 {{host}}。", + "authOverrideCredentialLabel": "身份验证凭证", + "useSharedAuthentication": "使用共享主机身份验证", + "noPersonalCredential": "没有个人凭证", + "authOverrideNoCredentials": "您目前还没有保存任何 SSH 凭据。请在“凭据”中创建一个凭据,以便连接到需要身份验证的主机。", + "authOverrideRequired": "此主机需要您保存的凭据之一才能连接。", + "authOverridePrivateHint": "此凭证仅您本人所有。主机所有者和其他接收者无法查看或使用此凭证。", + "authOverrideSaved": "已保存个人 SSH 身份验证信息", + "authOverrideCleared": "已移除个人 SSH 身份验证", + "authOverrideClearedToShared": "使用共享主机身份验证", + "authOverrideLoadError": "SSH身份验证加载失败,请重试。", + "authOverrideSaveError": "SSH 身份验证保存失败" }, "guac": { "connection": "连接", @@ -1627,6 +1671,15 @@ "tmuxCopyHint": "调整选择并按 Enter 复制到剪贴板", "tmuxDetach": "从 tmux 会话分离", "tmuxDetached": "已从 tmux 会话分离", + "searchPlaceholder": "寻找", + "searchCaseSensitive": "火柴盒", + "searchWholeWord": "整词匹配", + "searchRegex": "使用正则表达式", + "searchNoResults": "没有结果", + "searchResultCount": "{{index}} 的 {{count}}", + "searchNext": "下一场比赛(输入)", + "searchPrevious": "上一场比赛(Shift+Enter)", + "searchClose": "关闭(退出)", "maxReconnectAttemptsReached": "已达到最大重连尝试次数", "closeTab": "关闭", "connectionTimeout": "连接超时", @@ -1654,6 +1707,11 @@ "opksshTimeout": "认证超时,请重试。", "opksshAuthFailed": "认证失败,请检查凭据并重试。", "opksshSignInWith": "使用 {{provider}} 登录", + "tailscaleCheckRequired": "需要 Tailscale 身份验证", + "tailscaleCheckDescription": "Tailscale SSH 需要进行额外的验证。请在浏览器中进行身份验证以继续。", + "tailscaleCheckOpenBrowser": "打开浏览器进行身份验证", + "tailscaleCheckWaiting": "正在等待 Tailscale 身份验证……", + "tailscaleCheckTimeout": "Tailscale身份验证超时,请重试。", "vaultAuthTitle": "需要 Vault 登录", "vaultAuthDescription": "已打开窗口以登录 HashiCorp Vault。在该处完成登录;此连接将自动继续。", "vaultAuthFailed": "Vault 认证失败,请重试。", @@ -2145,6 +2203,7 @@ "cpuUsage": "CPU 使用率", "memoryUsage": "内存使用率", "diskUsage": "磁盘使用率", + "selectFilesystem": "选择文件系统", "temperature": "温度", "highestTemperature": "最高温度", "failedToFetchHostConfig": "获取主机配置失败", @@ -2819,6 +2878,7 @@ "updateCommandHistoryFailed": "更新命令历史设置失败", "analyticsEnabled": "分享匿名使用统计数据", "analyticsEnabledDesc": "每日匿名发送用户、主机和功能使用情况统计数据,以帮助改进 Termix。绝不包含任何个人数据或连接详情。", + "analyticsEnabledLockedDesc": "此设置受 ENABLE_TELEMETRY 环境变量锁定,无法在此处更改。", "updateAnalyticsFailed": "分析设置更新失败", "sessionSharingGloballyEnabled": "允许会话共享", "sessionSharingGloballyEnabledDesc": "允许在整个实例范围内共享实时终端、RDP、VNC 和 Telnet 会话。禁用此功能将覆盖所有主机级别的共享设置。", @@ -3323,6 +3383,8 @@ "resetToDefaultsSuccess": "设置已重置为默认值。", "storageModeSwitch": "偏好设置存储", "sectionAccount": "账户", + "desktopProfileTitle": "自动本地桌面配置文件", + "desktopProfileDescription": "此配置文件仅限用于嵌入式后端,并自动登录。它没有登录密码;下面的远程同步使用单独的服务器帐户。", "sectionAppearance": "外观", "sectionSecurity": "安全", "sectionApiKeys": "API 密钥", @@ -3379,6 +3441,8 @@ "statusColorsDesc": "使用绿色/红色表示在线/离线状态,而非强调色", "pinAppRail": "固定应用边栏", "pinAppRailDesc": "保持左侧应用边栏始终展开,而非悬停展开", + "openFullscreenSettings": "打开设置全屏", + "exitFullscreenSettings": "退出全屏设置", "expandAppRailOnHover": "悬停展开应用边栏", "expandAppRailOnHoverDesc": "允许鼠标悬停时展开左侧应用边栏", "settingsNavigation": "导航", diff --git a/src/ui/locales/translated/zh_TW.json b/src/ui/locales/translated/zh_TW.json index 4bde145c..20b2df8a 100644 --- a/src/ui/locales/translated/zh_TW.json +++ b/src/ui/locales/translated/zh_TW.json @@ -546,6 +546,7 @@ "sshTools": "SSH 工具", "history": "歷史記錄", "sessionLogs": "工作階段記錄", + "sidebarSettings": "側邊欄設定...", "hosts": "主機", "snippets": "程式碼片段", "hostManager": "主機管理員", @@ -755,6 +756,8 @@ "agentSocketPathLabel": "代理 Socket 路徑", "agentSocketPathPlaceholder": "留空以使用 SSH_AUTH_SOCK", "agentSocketPathHint": "留空以從 SSH_AUTH_SOCK 環境變數自動偵測,或輸入自訂 socket 路徑(例如 /run/user/1000/gnupg/S.gpg-agent.ssh)。", + "shareSshAuthLabel": "共用 SSH 驗證", + "shareSshAuthDesc": "向收件者提供此主機 SSH 驗證資訊的加密副本。收件人的個人憑證仍然優先。", "tailscaleDeviceSelect": "選取 Tailscale 裝置", "tailscaleDeviceSelectPlaceholder": "選取裝置...", "tailscaleNoApiKey": "未設定 Tailscale API 金鑰。請在管理設定中新增一個以啟用裝置探索。", @@ -1173,15 +1176,39 @@ "generateFromPrivateKey": "從私密金鑰產生", "refreshBtn2": "重新整理", "exitSelectionTitle": "離開選取", - "exportAll": "全部匯出", - "exportForSharing": "匯出供分享", "addHostBtn2": "新增主機", "addCredentialBtn2": "新增憑證", "checkingHostStatuses": "正在檢查主機狀態...", "pinnedSection": "已釘選", "hostsExported": "主機已成功匯出", - "hostsShareExported": "可分享的主機已成功匯出", - "exportFailed": "主機匯出失敗", + "export": { + "menuItem": "出口...", + "title": "匯出主機", + "scope": "範圍", + "scopeAll": "全部", + "scopeSelected": "已選", + "searchHosts": "搜尋主機...", + "include": "包括", + "groupConnection": "聯繫", + "groupCredentials": "證書", + "groupNotes": "筆記", + "groupTags": "標籤和別針", + "groupTunnels": "隧道", + "groupJumpHosts": "跳轉主機", + "groupQuickActions": "快速行動", + "groupFeatureFlags": "功能標誌", + "groupAdvanced": "進階配置", + "preview": "預覽", + "moreHosts": "... {{count}} 更多主機", + "summary": "{{selected}} 的 {{total}} 個主機", + "credentialsIncluded": "資質包括", + "credentialsExcluded": "已排除憑證", + "noneSelected": "未選擇主機", + "cancel": "取消", + "confirm": "出口", + "fetchFailed": "匯出主機失敗", + "bulkButton": "出口" + }, "sampleDownloaded": "範例檔案已下載", "failedToDeleteCredential2": "憑證刪除失敗", "noFolderOption": "(無資料夾)", @@ -1266,7 +1293,7 @@ }, "edit": { "label": "編輯", - "description": "檢視,並可修改主機。可更換機密資料但無法讀取;憑證指派僅限擁有者。" + "description": "查看並修改無需身份驗證的主機設定。所有者的 SSH 身份驗證資訊將保持私密,僅限所有者本人查看。" }, "manage": { "label": "管理", @@ -1299,7 +1326,24 @@ "sharedBadgeTooltip": "由 {{owner}} 分享({{level}} 存取權限)", "viewOnlyBanner": "此主機由 {{owner}} 與您分享,僅供檢視。設定為唯讀。", "sharedEditBanner": "此主機由 {{owner}} 與您分享,具編輯權限。變更會套用至實際主機;驗證設定僅能由擁有者變更。", - "ownerOnlyControl": "僅主機擁有者可變更此項目" + "ownerOnlyControl": "僅主機擁有者可變更此項目", + "ownerAuthPrivate": "主機所有者的 SSH 身份驗證是私有的。請使用主機選單中的「設定個人 SSH 驗證」來選擇您自己的憑證。", + "ownerAuthShared": "主機所有者已為此主機共享了 SSH 身份驗證。您可以使用此身份驗證,也可以從「設定個人 SSH 身份驗證」中選擇您自己的憑證。", + "authOverrideAction": "設定個人 SSH 身份驗證", + "authOverrideTitle": "個人 SSH 驗證", + "authOverrideDescriptionPrivate": "主機擁有者的 SSH 憑證是私密的。請選擇您已儲存的憑證之一連接至 {{host}}。", + "authOverrideDescriptionShared": "使用主機擁有者共享的身份驗證,或將其替換為您儲存的憑證之一,以便連接到 {{host}}。", + "authOverrideCredentialLabel": "身份驗證憑證", + "useSharedAuthentication": "使用共享主機身份驗證", + "noPersonalCredential": "沒有個人憑證", + "authOverrideNoCredentials": "您目前還沒有儲存任何 SSH 憑證。請在「憑證」中建立一個憑證,以便連線到需要驗證的主機。", + "authOverrideRequired": "此主機需要您儲存的憑證之一才能連線。", + "authOverridePrivateHint": "此憑證僅您本人所有。主機擁有者和其他接收者無法檢視或使用此憑證。", + "authOverrideSaved": "已儲存個人 SSH 驗證訊息", + "authOverrideCleared": "已移除個人 SSH 驗證", + "authOverrideClearedToShared": "使用共享主機身份驗證", + "authOverrideLoadError": "SSH身份驗證載入失敗,請重試。", + "authOverrideSaveError": "SSH 驗證保存失敗" }, "guac": { "connection": "連線", @@ -1627,6 +1671,15 @@ "tmuxCopyHint": "調整選取範圍並按下 Enter 以複製到剪貼簿", "tmuxDetach": "從 tmux 工作階段中斷連線", "tmuxDetached": "已從 tmux 工作階段中斷連線", + "searchPlaceholder": "尋找", + "searchCaseSensitive": "火柴盒", + "searchWholeWord": "整詞匹配", + "searchRegex": "使用正規表示式", + "searchNoResults": "沒有結果", + "searchResultCount": "{{index}} 的 {{count}}", + "searchNext": "下一場比賽(輸入)", + "searchPrevious": "最後一場比賽(Shift+Enter)", + "searchClose": "關閉(退出)", "maxReconnectAttemptsReached": "已達最大重新連線嘗試次數", "closeTab": "關閉", "connectionTimeout": "連線逾時", @@ -1654,6 +1707,11 @@ "opksshTimeout": "驗證逾時。請再試一次。", "opksshAuthFailed": "驗證失敗。請檢查您的憑證並再試一次。", "opksshSignInWith": "使用 {{provider}} 登入", + "tailscaleCheckRequired": "需要 Tailscale 身份驗證", + "tailscaleCheckDescription": "Tailscale SSH 需要額外的驗證。請在瀏覽器中進行身份驗證以繼續。", + "tailscaleCheckOpenBrowser": "開啟瀏覽器進行身份驗證", + "tailscaleCheckWaiting": "正在等待 Tailscale 身份驗證…", + "tailscaleCheckTimeout": "Tailscale身份驗證逾時,請重試。", "vaultAuthTitle": "需要 Vault 登入", "vaultAuthDescription": "已開啟視窗以登入 HashiCorp Vault。在該處完成登入;此連線將自動繼續。", "vaultAuthFailed": "Vault 驗證失敗。請再試一次。", @@ -2145,6 +2203,7 @@ "cpuUsage": "CPU 使用率", "memoryUsage": "記憶體使用率", "diskUsage": "磁碟使用率", + "selectFilesystem": "選擇檔案系統", "temperature": "溫度", "highestTemperature": "最高溫度", "failedToFetchHostConfig": "無法取得主機設定", @@ -2819,6 +2878,7 @@ "updateCommandHistoryFailed": "無法更新指令歷史記錄設定", "analyticsEnabled": "分享匿名使用統計數據", "analyticsEnabledDesc": "每日匿名傳送使用者、主機和功能使用統計數據,以協助改善 Termix。絕不包含任何個人資料或連結詳情。", + "analyticsEnabledLockedDesc": "此設定受 ENABLE_TELEMETRY 環境變數鎖定,無法在此變更。", "updateAnalyticsFailed": "分析設定更新失敗", "sessionSharingGloballyEnabled": "允許會話共享", "sessionSharingGloballyEnabledDesc": "允許在整個執行個體範圍內共用即時終端、RDP、VNC 和 Telnet 會話。停用此功能將覆蓋所有主機層級的共用設定。", @@ -3323,6 +3383,8 @@ "resetToDefaultsSuccess": "設定已重設為預設值。", "storageModeSwitch": "偏好設定儲存", "sectionAccount": "帳號", + "desktopProfileTitle": "自動本機桌面設定檔", + "desktopProfileDescription": "此設定檔僅限用於嵌入式後端,並自動登入。它沒有登入密碼;下面的遠端同步使用單獨的伺服器帳戶。", "sectionAppearance": "外觀", "sectionSecurity": "安全性", "sectionApiKeys": "API 金鑰", @@ -3379,6 +3441,8 @@ "statusColorsDesc": "使用綠色/紅色代替強調色來表示線上/離線狀態", "pinAppRail": "固定應用程式側欄", "pinAppRailDesc": "保持左側應用程式側欄永遠展開,而不是懸停時才展開", + "openFullscreenSettings": "開啟設定全螢幕", + "exitFullscreenSettings": "退出全螢幕設置", "expandAppRailOnHover": "懸停時展開應用程式側欄", "expandAppRailOnHoverDesc": "允許左側應用程式側欄在指標移至其上方時展開", "settingsNavigation": "導覽", diff --git a/src/ui/main-axios.ts b/src/ui/main-axios.ts index 0579404f..638d83f2 100644 --- a/src/ui/main-axios.ts +++ b/src/ui/main-axios.ts @@ -77,11 +77,25 @@ interface MemoryMetrics { totalGiB: number | null; } +export interface DiskFilesystem { + filesystem: string; + mount: string; + percent: number | null; + usedHuman: string | null; + totalHuman: string | null; + availableHuman: string | null; + usedBytes: number | null; + totalBytes: number | null; + availableBytes: number | null; +} + interface DiskMetrics { percent: number | null; usedHuman: string | null; totalHuman: string | null; availableHuman?: string | null; + mount?: string | null; + filesystems?: DiskFilesystem[]; } export interface NetworkInterface { @@ -199,11 +213,16 @@ export interface UserInfo { username: string; is_admin: boolean; is_oidc: boolean; + is_dual_auth?: boolean; password_hash?: string; data_unlocked?: boolean; show_donation_modal?: boolean; } +export interface RemoteSyncUserInfo extends UserInfo { + roles: UserRole[]; +} + interface UserCount { count: number; } @@ -365,8 +384,7 @@ export function isCurrentAuthInvalidationError(error: unknown): boolean { const axiosError = error as AxiosError; const apiError = error as ApiError; const responseData = axiosError.response?.data as - | Record - | undefined; + Record | undefined; const errorCode = responseData?.code || apiError.code; const errorMessage = responseData?.error || apiError.message; const status = axiosError.response?.status || apiError.status; @@ -763,6 +781,7 @@ function createRemoteOriginApiInstance(path: string): AxiosInstance { let remoteFileManagerApi: AxiosInstance | null = null; let remoteTunnelApi: AxiosInstance | null = null; let remoteStatsApi: AxiosInstance | null = null; +let remoteGuacamoleApi: AxiosInstance | null = null; export function getRemoteFileManagerApi(): AxiosInstance { if (!remoteFileManagerApi) { @@ -785,6 +804,13 @@ export function getRemoteStatsApi(): AxiosInstance { return remoteStatsApi; } +export function getRemoteGuacamoleApi(): AxiosInstance { + if (!remoteGuacamoleApi) { + remoteGuacamoleApi = createRemoteOriginApiInstance(""); + } + return remoteGuacamoleApi; +} + // Maps a live SSH session (keyed by sessionId, which today is the host's // numeric id as a string -- see ensureSSHSessionForHost) to the resolved // origin it was connected through, so every subsequent file-manager call @@ -1438,6 +1464,7 @@ export { bulkImportSSHHosts, importSSHConfigHosts, discoverProxmoxGuests, + discoverProxmoxGuestsStream, syncProxmoxGuests, bulkUpdateSSHHosts, deleteSSHHost, @@ -1681,6 +1708,18 @@ export async function getUserInfo(): Promise { } } +export async function getRemoteSyncUserInfo(): Promise { + if (!isElectron()) return null; + try { + // ?? null so a missing preload bridge matches the declared return type + // rather than resolving to undefined. + return ((await window.electronAPI?.invoke?.("get-remote-sync-user-info")) ?? + null) as RemoteSyncUserInfo | null; + } catch { + return null; + } +} + export async function dismissDonationModal(): Promise { try { await authApi.post("/users/me/dismiss-donation-modal"); @@ -2087,6 +2126,8 @@ export { updateHostAccess, getHostAccess, revokeHostAccess, + getHostAuthOverride, + setHostAuthOverride, getPermissionsCatalog, getSharedHosts, shareSnippet, diff --git a/src/ui/settings/remote-sync-state.ts b/src/ui/settings/remote-sync-state.ts new file mode 100644 index 00000000..d73d29b1 --- /dev/null +++ b/src/ui/settings/remote-sync-state.ts @@ -0,0 +1,7 @@ +export function shouldForceLocalPreferenceStorage( + isDesktop: boolean, + remoteSyncConnected: boolean | null, + storageMode: "local" | "cloud", +): boolean { + return isDesktop && remoteSyncConnected === false && storageMode === "cloud"; +} diff --git a/src/ui/sidebar/AdminSettingsPanel.tsx b/src/ui/sidebar/AdminSettingsPanel.tsx index 9ea84d03..9c338cb0 100644 --- a/src/ui/sidebar/AdminSettingsPanel.tsx +++ b/src/ui/sidebar/AdminSettingsPanel.tsx @@ -133,6 +133,7 @@ export function AdminSettingsPanel({ const [tailscaleApiKey, setTailscaleApiKey] = useState(""); const [commandHistoryEnabled, setCommandHistoryEnabled] = useState(true); const [analyticsEnabled, setAnalyticsEnabled] = useState(true); + const [analyticsLocked, setAnalyticsLocked] = useState(false); const [sessionSharingGloballyEnabled, setSessionSharingGloballyEnabled] = useState(true); const [hostDefaults, setHostDefaults] = useState({}); @@ -342,6 +343,7 @@ export function AdminSettingsPanel({ } if (analytics.status === "fulfilled") { setAnalyticsEnabled(analytics.value.enabled); + setAnalyticsLocked(analytics.value.locked ?? false); } if (sessionSharingEnabled.status === "fulfilled") { setSessionSharingGloballyEnabled(sessionSharingEnabled.value.enabled); @@ -454,6 +456,7 @@ export function AdminSettingsPanel({ } async function handleToggleAnalytics() { + if (analyticsLocked) return; const newVal = !analyticsEnabled; setAnalyticsEnabled(newVal); try { @@ -923,11 +926,12 @@ export function AdminSettingsPanel({ } return ( -
+
toggle("general")} analyticsEnabled={analyticsEnabled} + analyticsLocked={analyticsLocked} handleToggleAnalytics={handleToggleAnalytics} sessionSharingGloballyEnabled={sessionSharingGloballyEnabled} handleToggleSessionSharingGloballyEnabled={ diff --git a/src/ui/sidebar/AdminSettingsSections.tsx b/src/ui/sidebar/AdminSettingsSections.tsx index 82c61bc8..7266191e 100644 --- a/src/ui/sidebar/AdminSettingsSections.tsx +++ b/src/ui/sidebar/AdminSettingsSections.tsx @@ -4,13 +4,6 @@ import { Button } from "@/components/button"; import { Input } from "@/components/input"; import { PasswordInput } from "@/components/password-input"; import { SettingRow } from "@/components/section-card"; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@/components/select"; import { Database, Lock, @@ -31,6 +24,7 @@ type GeneralSettingsSectionProps = { open: boolean; onToggle: () => void; analyticsEnabled: boolean; + analyticsLocked: boolean; handleToggleAnalytics: () => void; sessionSharingGloballyEnabled: boolean; handleToggleSessionSharingGloballyEnabled: () => void; @@ -72,6 +66,7 @@ export function AdminGeneralSettingsSection({ open, onToggle, analyticsEnabled, + analyticsLocked, handleToggleAnalytics, sessionSharingGloballyEnabled, handleToggleSessionSharingGloballyEnabled, @@ -120,9 +115,17 @@ export function AdminGeneralSettingsSection({
- + {t("admin.sslChallengeType")} - + + + + {t("admin.sslChallengeTypeDesc")} diff --git a/src/ui/sidebar/AdminSettingsShared.tsx b/src/ui/sidebar/AdminSettingsShared.tsx index 40b92d4e..d8516ed5 100644 --- a/src/ui/sidebar/AdminSettingsShared.tsx +++ b/src/ui/sidebar/AdminSettingsShared.tsx @@ -4,14 +4,17 @@ import { ChevronDown } from "lucide-react"; export function AdminToggle({ on, onToggle, + disabled = false, }: { on: boolean; onToggle: () => void; + disabled?: boolean; }) { return (
+ + {menuPos && ( +
+ + +
+ +
+ )}
); } diff --git a/src/ui/sidebar/CredentialEditorView.tsx b/src/ui/sidebar/CredentialEditorView.tsx index 12f44285..4e7095d1 100644 --- a/src/ui/sidebar/CredentialEditorView.tsx +++ b/src/ui/sidebar/CredentialEditorView.tsx @@ -283,9 +283,7 @@ export function CredentialEditorView({ try { const result = await generateKeyPair( keyType as - | "ssh-ed25519" - | "ssh-rsa" - | "ecdsa-sha2-nistp256", + "ssh-ed25519" | "ssh-rsa" | "ecdsa-sha2-nistp256", bits, credForm.passphrase === "existing_key_password" ? undefined diff --git a/src/ui/sidebar/CredentialsPanel.tsx b/src/ui/sidebar/CredentialsPanel.tsx index 814cd03e..2332f621 100644 --- a/src/ui/sidebar/CredentialsPanel.tsx +++ b/src/ui/sidebar/CredentialsPanel.tsx @@ -22,11 +22,7 @@ import { } from "@/components/dropdown-menu"; export type CredentialSortKey = - | "default" - | "name-asc" - | "name-desc" - | "username-asc" - | "username-desc"; + "default" | "name-asc" | "name-desc" | "username-asc" | "username-desc"; export type CredentialFilterState = { type: ("password" | "key")[]; diff --git a/src/ui/sidebar/HostAuthOverrideModal.tsx b/src/ui/sidebar/HostAuthOverrideModal.tsx new file mode 100644 index 00000000..50435a91 --- /dev/null +++ b/src/ui/sidebar/HostAuthOverrideModal.tsx @@ -0,0 +1,194 @@ +import { useEffect, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { toast } from "sonner"; +import { Button } from "@/components/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/dialog"; +import { + getCredentials, + getHostAuthOverride, + setHostAuthOverride, +} from "@/main-axios"; +import type { Credential, Host } from "@/types/ui-types"; +import type { AuthOverrideProtocol } from "@/types/auth-protocols"; +import { mapCredentials } from "./HostManagerData"; + +export function HostAuthOverrideModal({ + open, + onOpenChange, + host, + protocol, +}: { + open: boolean; + onOpenChange: (open: boolean) => void; + host: Host; + protocol: AuthOverrideProtocol; +}) { + const { t } = useTranslation(); + const [credentials, setCredentials] = useState([]); + const [selectedId, setSelectedId] = useState(""); + const [initialId, setInitialId] = useState(""); + const [loading, setLoading] = useState(false); + const [loadError, setLoadError] = useState(false); + const [saving, setSaving] = useState(false); + const overrideState = host.authOverrides?.[protocol]; + const ownerAuthShared = + overrideState?.ownerAuthShared ?? + (protocol === "ssh" ? !!host.shareSshAuth : false); + + useEffect(() => { + if (!open) return; + let cancelled = false; + setLoading(true); + setLoadError(false); + + Promise.all([ + getCredentials(), + getHostAuthOverride(Number(host.id), protocol), + ]) + .then(([credentialResult, overrideResult]) => { + if (cancelled) return; + const nextCredentials = mapCredentials(credentialResult); + const nextId = + overrideResult.credentialId == null + ? "" + : String(overrideResult.credentialId); + setCredentials(nextCredentials); + setSelectedId(nextId); + setInitialId(nextId); + }) + .catch(() => { + if (!cancelled) setLoadError(true); + }) + .finally(() => { + if (!cancelled) setLoading(false); + }); + + return () => { + cancelled = true; + }; + }, [host.id, open, protocol]); + + const handleSave = async () => { + setSaving(true); + try { + const credentialId = selectedId ? Number(selectedId) : null; + await setHostAuthOverride(Number(host.id), protocol, credentialId); + toast.success( + credentialId === null + ? t( + ownerAuthShared + ? "hosts.sharing.authOverrideClearedToShared" + : "hosts.sharing.authOverrideCleared", + ) + : t("hosts.sharing.authOverrideSaved"), + ); + window.dispatchEvent(new CustomEvent("termix:hosts-changed")); + onOpenChange(false); + } catch { + toast.error(t("hosts.sharing.authOverrideSaveError")); + } finally { + setSaving(false); + } + }; + return ( + event.stopPropagation()} + onPointerDown={(event) => event.stopPropagation()} + > + + + + {t("hosts.sharing.authOverrideTitle")} + + {t( + ownerAuthShared + ? "hosts.sharing.authOverrideDescriptionShared" + : "hosts.sharing.authOverrideDescriptionPrivate", + { host: host.name }, + )} + + + + {loading ? ( +

+ {t("common.loading")} +

+ ) : loadError ? ( +

+ {t("hosts.sharing.authOverrideLoadError")} +

+ ) : ( +
+ + + {credentials.length === 0 && ( +

+ {t("hosts.sharing.authOverrideNoCredentials")} +

+ )} + {overrideState?.required && selectedId === "" && ( +

+ {t("hosts.sharing.authOverrideRequired")} +

+ )} +

+ {t("hosts.sharing.authOverridePrivateHint")} +

+
+ )} + + + + + +
+
+
+ ); +} diff --git a/src/ui/sidebar/HostEditor.tsx b/src/ui/sidebar/HostEditor.tsx index a315c29a..dcc7ab11 100644 --- a/src/ui/sidebar/HostEditor.tsx +++ b/src/ui/sidebar/HostEditor.tsx @@ -60,6 +60,7 @@ import { buildHostEditorPayload, createHostEditorForm, mapSnippetResponse, + omitOwnerSshAuthFromSharedEdit, type HostAuthType, type HostBellStyle, type HostBackspaceMode, @@ -268,7 +269,10 @@ export function HostEditor({ const handleSave = async () => { setSaving(true); try { - const data = buildHostEditorPayload(form, protocols); + const fullData = buildHostEditorPayload(form, protocols); + const data = lockAuthReferences + ? omitOwnerSshAuthFromSharedEdit(fullData) + : fullData; let saved: SSHHost; if (adminTargetUserId) { saved = host @@ -460,6 +464,7 @@ export function HostEditor({ title={t("hosts.authenticationLabel")} icon={} action={ + !isSharedHost && canQuickCreateCredential && ( + +
+
+ + setSearch(e.target.value)} + disabled={scope === "all"} + /> +
+
+ {visibleHosts.map((host) => { + const key = hostKey(host as unknown as Record); + return ( + + ); + })} +
+
+ + {/* Field groups */} +
+
+ {t("hosts.export.include")} +
+ + + {GROUPS.filter((g) => g.key !== "connection").map((group) => ( + + ))} +
+ + {/* Preview */} +
+
+ {t("hosts.export.preview")} +
+
+              {preview}
+            
+
+
+ +
+ {count === 0 + ? t("hosts.export.noneSelected") + : `${t("hosts.export.summary", { + selected: count, + total: exportableHosts.length, + })} · ${ + withCredentials + ? t("hosts.export.credentialsIncluded") + : t("hosts.export.credentialsExcluded") + }`} +
+ + + + + + + + ); +} diff --git a/src/ui/sidebar/HostManagerData.ts b/src/ui/sidebar/HostManagerData.ts index 2f2d466e..b3e121c7 100644 --- a/src/ui/sidebar/HostManagerData.ts +++ b/src/ui/sidebar/HostManagerData.ts @@ -49,6 +49,7 @@ export function sshHostToHost(h: SSHHostWithStatus): Host { lastAccess: "", tags: h.tags ?? [], authType: h.authType, + shareSshAuth: h.shareSshAuth ?? false, password: h.password, hasKey: !!host.hasKey || !!(typeof h.key === "string" && h.key), hasKeyPassword: !!host.hasKeyPassword || !!h.keyPassword, @@ -63,6 +64,7 @@ export function sshHostToHost(h: SSHHostWithStatus): Host { notes: h.notes, pin: h.pin ?? false, macAddress: h.macAddress, + wolBroadcastAddress: h.wolBroadcastAddress, enableSsh: h.enableSsh != null ? h.enableSsh : isSshHost, enableTerminal: h.enableTerminal ?? (h.enableSsh != null ? h.enableSsh : isSshHost), @@ -133,6 +135,22 @@ export function sshHostToHost(h: SSHHostWithStatus): Host { socks5ProxyChain: parseJson(h.socks5ProxyChain) ?? [], overrideCredentialUsername: h.overrideCredentialUsername ?? false, isShared: h.isShared ?? false, + authOverrides: h.authOverrides + ? Object.fromEntries( + Object.entries(h.authOverrides).map(([protocol, state]) => [ + protocol, + state + ? { + ...state, + credentialId: + state.credentialId != null + ? String(state.credentialId) + : undefined, + } + : state, + ]), + ) + : undefined, permissionLevel: h.permissionLevel, sharedExpiresAt: h.sharedExpiresAt, ownerUsername: h.ownerUsername, diff --git a/src/ui/sidebar/HostsPanel.tsx b/src/ui/sidebar/HostsPanel.tsx index 01d33b76..404e6559 100644 --- a/src/ui/sidebar/HostsPanel.tsx +++ b/src/ui/sidebar/HostsPanel.tsx @@ -22,6 +22,7 @@ import { toast } from "sonner"; import { SidebarTree, isFolder } from "@/sidebar/SidebarTree"; import { HostManager } from "@/sidebar/HostManager"; import { HostShareModal } from "@/sidebar/HostShareModal"; +import { HostExportDialog } from "@/sidebar/HostExportDialog"; import { ProxmoxDiscoverDialog } from "@/components/proxmox/ProxmoxDiscoverDialog"; import { Button } from "@/components/button"; import { @@ -40,7 +41,6 @@ import { getSSHHosts, bulkImportSSHHosts, importSSHConfigHosts, - exportAllSSHHosts, } from "@/main-axios"; import type { SSHHostWithStatus } from "@/main-axios"; import type { Host, HostFolder, TabType } from "@/types/ui-types"; @@ -200,6 +200,10 @@ export function HostsPanel({ const [refreshing, setRefreshing] = useState(false); const [rawHosts, setRawHosts] = useState([]); const [shareModalHost, setShareModalHost] = useState(null); + const [exportDialogOpen, setExportDialogOpen] = useState(false); + const [exportPreselection, setExportPreselection] = useState>( + new Set(), + ); const [proxmoxDialogOpen, setProxmoxDialogOpen] = useState(false); const [proxmoxHostId, setProxmoxHostId] = useState( undefined, @@ -335,29 +339,6 @@ export function HostsPanel({ } } - async function handleExportHosts(share = false) { - try { - const result = await exportAllSSHHosts( - share ? { share: true } : undefined, - ); - const data = JSON.stringify(result, null, 2); - const blob = new Blob([data], { type: "application/json" }); - const url = URL.createObjectURL(blob); - const a = document.createElement("a"); - a.href = url; - a.download = share ? "termix-hosts-share.json" : "termix-hosts.json"; - document.body.appendChild(a); - a.click(); - document.body.removeChild(a); - URL.revokeObjectURL(url); - toast.success( - t(share ? "hosts.hostsShareExported" : "hosts.hostsExported"), - ); - } catch { - toast.error(t("hosts.exportFailed")); - } - } - function handleDownloadSample() { const sample = JSON.stringify( { @@ -607,18 +588,14 @@ export function HostsPanel({ handleExportHosts(false)} + onClick={() => { + setExportPreselection(new Set()); + setExportDialogOpen(true); + }} disabled={rawHosts.length === 0} > - {t("hosts.exportAll")} - - handleExportHosts(true)} - disabled={rawHosts.length === 0} - > - - {t("hosts.exportForSharing")} + {t("hosts.export.menuItem")} @@ -967,18 +944,14 @@ export function HostsPanel({ {t("hosts.importSSHConfig")} handleExportHosts(false)} + onClick={() => { + setExportPreselection(new Set()); + setExportDialogOpen(true); + }} disabled={rawHosts.length === 0} > - {t("hosts.exportAll")} - - handleExportHosts(true)} - disabled={rawHosts.length === 0} - > - - {t("hosts.exportForSharing")} + {t("hosts.export.menuItem")} @@ -1033,6 +1006,10 @@ export function HostsPanel({ selectionMode={selectionMode} onToggleSelectionMode={toggleSelectionMode} loading={loading} + onExportSelected={(ids) => { + setExportPreselection(new Set(ids)); + setExportDialogOpen(true); + }} />
@@ -1048,6 +1025,13 @@ export function HostsPanel({ host={shareModalHost} /> + setExportDialogOpen(false)} + hosts={rawHosts} + preselectedHostIds={exportPreselection} + /> + { diff --git a/src/ui/sidebar/SidebarTree.tsx b/src/ui/sidebar/SidebarTree.tsx index 880c3c63..16da2610 100644 --- a/src/ui/sidebar/SidebarTree.tsx +++ b/src/ui/sidebar/SidebarTree.tsx @@ -2,6 +2,7 @@ import { useState, useEffect, + useMemo, useRef, useLayoutEffect, type MouseEvent, @@ -17,6 +18,7 @@ import { Copy, CopyPlus, Cpu, + Download, FolderOpen, FolderSearch, Key, @@ -68,10 +70,12 @@ import { copyToClipboard } from "@/lib/clipboard"; import { canDeleteHost, canEditHost, + canOverrideHostAuth, canShareHost, } from "@/sidebar/host-permissions"; import { FolderMetadataDialog } from "./FolderMetadataDialog"; import { HostShareModal } from "@/sidebar/HostShareModal"; +import { HostAuthOverrideModal } from "@/sidebar/HostAuthOverrideModal"; import { useStatusColorScheme, getStatusClasses, @@ -341,6 +345,8 @@ export function HostItem({ const shouldUseClickTray = trayOnClick || isTouchOnly; const showPasswordCopy = !host.isShared && canCopyHostPassword(host); const showSudoPasswordCopy = !host.isShared && canCopyHostSudoPassword(host); + const canOverrideAuth = canOverrideHostAuth(host, "ssh"); + const [authOverrideOpen, setAuthOverrideOpen] = useState(false); async function handleCopyPassword( e: MouseEvent, @@ -404,7 +410,7 @@ export function HostItem({ if (compactHostView) { return (
{ e.dataTransfer.effectAllowed = "move"; onDragStart?.(); @@ -670,7 +676,10 @@ export function HostItem({ - + { e.stopPropagation(); @@ -681,6 +690,17 @@ export function HostItem({ {t("hosts.copyAddress")} + {canOverrideAuth && ( + { + e.stopPropagation(); + setAuthOverrideOpen(true); + }} + > + + {t("hosts.sharing.authOverrideAction")} + + )} {showPasswordCopy && ( handleCopyPassword(e, "password")} @@ -870,7 +890,10 @@ export function HostItem({ - + { e.stopPropagation(); @@ -881,6 +904,17 @@ export function HostItem({ {t("hosts.copyAddress")} + {canOverrideAuth && ( + { + e.stopPropagation(); + setAuthOverrideOpen(true); + }} + > + + {t("hosts.sharing.authOverrideAction")} + + )} {showPasswordCopy && ( handleCopyPassword(e, "password")} @@ -927,6 +961,14 @@ export function HostItem({
)} + {canOverrideAuth && ( + + )}
); @@ -934,7 +976,7 @@ export function HostItem({ return (
{ e.dataTransfer.effectAllowed = "move"; onDragStart?.(); @@ -1341,7 +1383,10 @@ export function HostItem({ - + { e.stopPropagation(); @@ -1352,6 +1397,17 @@ export function HostItem({ {t("hosts.copyAddress")} + {canOverrideAuth && ( + { + e.stopPropagation(); + setAuthOverrideOpen(true); + }} + > + + {t("hosts.sharing.authOverrideAction")} + + )} {showPasswordCopy && ( handleCopyPassword(e, "password")} @@ -1534,6 +1590,14 @@ export function HostItem({
+ {canOverrideAuth && ( + + )}
); @@ -1794,6 +1858,7 @@ export function SidebarTree({ selectionMode, onToggleSelectionMode, loading = false, + onExportSelected, }: { children: (Host | HostFolder)[]; onOpenTab: (host: Host, type: TabType) => void; @@ -1804,6 +1869,7 @@ export function SidebarTree({ selectionMode: boolean; onToggleSelectionMode: () => void; loading?: boolean; + onExportSelected?: (hostIds: string[]) => void; }) { const { t } = useTranslation(); const [openFolders, setOpenFolders] = useState>(() => { @@ -1861,6 +1927,12 @@ export function SidebarTree({ }; }, []); + const hostsById = useMemo(() => { + const map = new Map(); + for (const host of collectAllHosts(children)) map.set(host.id, host); + return map; + }, [children]); + function handleDragHostStart(hostId: string) { // When the dragged host is part of an active selection, move the whole set. if (selectionMode && selectedHostIds.has(hostId)) { @@ -1875,12 +1947,19 @@ export function SidebarTree({ targetPath: string, ) { setDraggedHostIds(null); + // A selection can mix owned hosts with shared ones the recipient may not + // edit; moving those would fail server-side and take the whole batch down. + const movableIds = hostIds.filter((id) => { + const host = hostsById.get(id); + return !host || canEditHost(host); + }); + if (movableIds.length === 0) return; try { - await bulkUpdateSSHHosts(hostIds.map(Number), { folder: targetPath }); + await bulkUpdateSSHHosts(movableIds.map(Number), { folder: targetPath }); window.dispatchEvent(new CustomEvent("termix:hosts-changed")); toast.success( t("hosts.movedToFolder", { - count: hostIds.length, + count: movableIds.length, folder: targetPath || t("hosts.folderPickerNone"), }), ); @@ -2467,6 +2546,18 @@ export function SidebarTree({ ))} +
-
- + )} @@ -1844,9 +1893,7 @@ export function UserProfilePanel({ checked={pinAppRail} onChange={(v) => { setPinAppRail(v); - localStorage.setItem("pinAppRail", v.toString()); - window.dispatchEvent(new Event("pinAppRailChanged")); - if (storageMode === "cloud") saveToCloud({ pinAppRail: v }); + setRailPreference("pinAppRail", v); }} /> @@ -1860,12 +1907,7 @@ export function UserProfilePanel({ checked={expandAppRailOnHover} onChange={(v) => { setExpandAppRailOnHover(v); - localStorage.setItem("expandAppRailOnHover", v.toString()); - window.dispatchEvent( - new Event("expandAppRailOnHoverChanged"), - ); - if (storageMode === "cloud") - saveToCloud({ expandAppRailOnHover: v }); + setRailPreference("expandAppRailOnHover", v); }} /> @@ -2032,8 +2074,11 @@ export function UserProfilePanel({ - {/* Security */} + {/* The embedded desktop backend auto-authenticates its machine-local + profile, so server login controls would imply protection they do + not provide. Remote Sync owns its separate account UI above. */}