diff --git a/.github/workflows/electron.yml b/.github/workflows/electron.yml index 011c7c6bf..4b7544326 100644 --- a/.github/workflows/electron.yml +++ b/.github/workflows/electron.yml @@ -143,12 +143,12 @@ jobs: - name: Build Windows (All Architectures) env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: npm run build && npx electron-builder --win --x64 --ia32 --publish never + run: npm run build && npx electron-builder --win --x64 --ia32 --arm64 --publish never - # The updater only installs the x64 NSIS build; 32-bit installs get a link. - # The combined x64+ia32 installer is never uploaded. + # The updater only installs the x64 NSIS build; 32-bit and ARM installs get a link. + # The combined multi-arch installer is never uploaded. - name: Fix Windows update files - run: node scripts/fix-update-info.cjs --drop ia32 --drop termix_windows_nsis.exe release/latest.yml release/beta.yml + run: node scripts/fix-update-info.cjs --drop ia32 --drop arm64 --drop termix_windows_nsis.exe release/latest.yml release/beta.yml - name: Upload Windows x64 NSIS Installer uses: actions/upload-artifact@v7 @@ -166,6 +166,14 @@ jobs: path: release/termix_windows_ia32_nsis.exe retention-days: 30 + - name: Upload Windows arm64 NSIS Installer + uses: actions/upload-artifact@v7 + if: hashFiles('release/termix_windows_arm64_nsis.exe') != '' && inputs.artifact_destination != 'none' + with: + name: termix_windows_arm64_nsis + path: release/termix_windows_arm64_nsis.exe + retention-days: 30 + - name: Upload Windows x64 MSI Installer uses: actions/upload-artifact@v7 if: hashFiles('release/termix_windows_x64_msi.msi') != '' && inputs.artifact_destination != 'none' @@ -182,6 +190,14 @@ jobs: path: release/termix_windows_ia32_msi.msi retention-days: 30 + - name: Upload Windows arm64 MSI Installer + uses: actions/upload-artifact@v7 + if: hashFiles('release/termix_windows_arm64_msi.msi') != '' && inputs.artifact_destination != 'none' + with: + name: termix_windows_arm64_msi + path: release/termix_windows_arm64_msi.msi + retention-days: 30 + - name: Create Windows x64 Portable zip if: hashFiles('release/win-unpacked/*') != '' run: | @@ -192,6 +208,11 @@ jobs: run: | Compress-Archive -Path "release\win-ia32-unpacked\*" -DestinationPath "termix_windows_ia32_portable.zip" + - name: Create Windows arm64 Portable zip + if: hashFiles('release/win-arm64-unpacked/*') != '' + run: | + Compress-Archive -Path "release\win-arm64-unpacked\*" -DestinationPath "termix_windows_arm64_portable.zip" + - name: Upload Windows update files uses: actions/upload-artifact@v7 if: hashFiles('release/*.yml') != '' && inputs.artifact_destination != 'none' @@ -219,6 +240,14 @@ jobs: path: termix_windows_ia32_portable.zip retention-days: 30 + - name: Upload Windows arm64 Portable + uses: actions/upload-artifact@v7 + if: hashFiles('termix_windows_arm64_portable.zip') != '' && inputs.artifact_destination != 'none' + with: + name: termix_windows_arm64_portable + path: termix_windows_arm64_portable.zip + retention-days: 30 + build-linux: runs-on: blacksmith-8vcpu-ubuntu-2404 if: (inputs.build_type == 'all' || inputs.build_type == 'linux' || inputs.build_type == '') && inputs.artifact_destination != 'submit' diff --git a/CHANGELOG.md b/CHANGELOG.md index a5c600d87..ff63f5dbd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,53 @@ # Changelog +## 26.10.2 + +### Added + +- Hide sidebar after opening, in Customize Sidebar, closes the host list once you open a host +- Sort the dashboard's Host Status card by name, IP address or status +- Windows on ARM builds of the desktop app (installer, MSI and portable) +- Plugins get an event when a host is added +- Plugins can start a short-lived session as a user and read the user's language, for tasks that run without a browser +- Plugins can pin a panel to the right edge of the window that pushes the page over + +### Changed + +- The Docker Compose files no longer start a guacd container. npm run dev -- --guacd also points remote desktop at the guacd it starts +- The side menu now opens over the page when it expands on hover, so open sessions like remote desktop are not resized each time. Turn on Push content when it expands in Settings to get the old behavior back +- On macOS, moving between split panes is now Cmd+Option+Arrow, so Option+Arrow can move the cursor by word in a terminal + +### Fixed + +- Picking a section lower down in the host editor, like Warpgate, stuttered on the way and sometimes stopped before it got there +- Sorting hosts by IP address put 10.0.0.210 before 10.0.0.3 +- The local terminal failed to start in the universal macOS build +- Hosts shared with you never showed up on a linked desktop +- A linked desktop could never turn on an unsigned plugin the server had enabled +- Creating, cloning or deleting hosts failed with "no such table: main.p_vault_profiles" after the Vault plugin was removed +- Opening a user in Settings, Users blocked the other admin sections until you went back to the user list +- A linked desktop could keep showing the same sync conflicts, or delete a local host, when the server could not send its copy +- Usernames are now unique +- Importing user data keeps foreign keys on +- Trusted proxy login checks the address nginx saw, not nginx itself +- The desktop login frame only trusts the server's own origin +- External login URLs and callback ports are checked on the desktop app +- A changed jump host key is no longer accepted without asking +- Synced plugins signed only by a key the server sent are no longer turned on by themselves +- Two connects to the same host at once could overwrite each other's connection +- Indonesian uses the same technical terms everywhere +- When the server was down or still starting, the login page said it could not connect to the database and stopped trying. It now says the server is not responding and continues on its own once the server is back +- nginx answered 404 instead of 502 while the backend was down +- A client tunnel that could not start always said "Endpoint SSH connection closed" on the desktop app instead of the real reason, like the target refusing the connection +- The Docker server crashed and restarted every few hours in ssh2's native crypto on Node 24 +- Plugin names and descriptions showed in English in the plugin manager, setup picker and host editor no matter the UI language +- Creating a host through the API without an auth type failed with a server error. It now picks the type from the credential, key or password, or says auth type is required +- The desktop app would not start its local server when another program used port 30001. It now picks a free port on its own +- A linked desktop could never finish syncing with a server on Postgres +- Routine plugin activity filled the audit log and pushed out real security entries +- Opening a full-screen view link while signed out showed a spinner forever. It now shows the login and opens the link after +- A linked desktop behind a proxy that needs extra headers, or with an allowed self-signed certificate, could not open remote terminals or tunnels + ## 26.10.1 ### Added diff --git a/README.md b/README.md index ef3cf839f..8f2aa812a 100644 --- a/README.md +++ b/README.md @@ -137,7 +137,7 @@ Want to build your own? Start from the [plugin template](https://github.com/Term See the [docs](https://docs.termix.site/install) for install steps on every platform. -Sample Docker Compose file (you can leave out `guacd` and the network if you don't plan to use the Remote Desktop plugin): +Sample Docker Compose file. Remote desktop (RDP, VNC and Telnet) is built in, so nothing else needs to run next to it: ```yaml services: @@ -151,31 +151,10 @@ services: - termix-data:/app/data environment: PORT: "8080" - GUACD_HOST: "guacd" - GUACD_TUNNEL_HOST: "termix" - GUACD_RECORDING_PATH: "/termix-data/session_recordings/guacamole" - GUACD_DRIVE_PATH: "/termix-data/rdp-drive" - depends_on: - - guacd - networks: - - termix-net - - guacd: - image: guacamole/guacd:1.6.0 - container_name: guacd - restart: unless-stopped - volumes: - - termix-data:/termix-data - networks: - - termix-net volumes: termix-data: driver: local - -networks: - termix-net: - driver: bridge ``` ### Cloud Hosting diff --git a/docker/Dockerfile b/docker/Dockerfile index e58cac2c5..0d3e76f6a 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -74,12 +74,15 @@ COPY vendor ./vendor COPY scripts/patch-ssh2-agent.cjs ./scripts/ +# ssh2's native crypto crashes Node 24 when a cipher is freed, so ssh2 uses +# OpenSSL instead (chacha20-poly1305 is then left out). RUN npm ci --omit=dev --ignore-scripts && \ node scripts/patch-ssh2-agent.cjs && \ rm -rf node_modules/better-sqlite3/prebuilds && \ npm run build-release --prefix node_modules/better-sqlite3 && \ test -f node_modules/better-sqlite3/build/Release/better_sqlite3.node && \ - npm rebuild bcryptjs ssh2 && \ + npm rebuild bcryptjs && \ + rm -rf node_modules/ssh2/lib/protocol/crypto/build && \ npm cache clean --force # Stage 6: Final optimized image diff --git a/docker/bundled-plugins.json b/docker/bundled-plugins.json index 421c7f6b8..75cc1d7ba 100644 --- a/docker/bundled-plugins.json +++ b/docker/bundled-plugins.json @@ -3,20 +3,20 @@ { "id": "acme-ssl", "source": "tmxplug", - "url": "https://github.com/Termix-SSH/Plugin-ACME-SSL/releases/download/v1.0.1/acme-ssl-1.0.1.tmxplug", - "sha256": "cb5b7c56a281272cfff229d01a2729a4fa3936de04c0ac487d65bcf8dbd2f38e" + "url": "https://github.com/Termix-SSH/Plugin-ACME-SSL/releases/download/v1.0.2/acme-ssl-1.0.2.tmxplug", + "sha256": "157d0f96dc42136ff64e618b6904db7862079cd31c1f1cf5f19846b1d127b00f" }, { "id": "ai", "source": "tmxplug", - "url": "https://github.com/Termix-SSH/Plugin-AI/releases/download/v1.0.1/ai-1.0.1.tmxplug", - "sha256": "2558c91e3e6823b3e18873d7b074762c4d76a1a0b96ff38d2dad13e0404b74a7" + "url": "https://github.com/Termix-SSH/Plugin-AI/releases/download/v1.1.0/ai-1.1.0.tmxplug", + "sha256": "71dda6940d62c9eede7821bbac21e99443fbc08728a47759f215e2416f1d162f" }, { "id": "alerts", "source": "tmxplug", - "url": "https://github.com/Termix-SSH/Plugin-Alerts/releases/download/v1.0.1/alerts-1.0.1.tmxplug", - "sha256": "f5f233c0ffdeee956e1839a17a323c18ce8eb389a08569c3c6e5e24b698dd72a", + "url": "https://github.com/Termix-SSH/Plugin-Alerts/releases/download/v1.0.2/alerts-1.0.2.tmxplug", + "sha256": "50768e77019276343a5d162c85d081659fb89d1cc3733ac45cd0db4436918faa", "onboarding": { "recommended": true, "consent": false @@ -25,14 +25,14 @@ { "id": "automations", "source": "tmxplug", - "url": "https://github.com/Termix-SSH/Plugin-Automations/releases/download/v1.0.1/automations-1.0.1.tmxplug", - "sha256": "d2bb5298f8afac3f479ded81d5b1a799d9844b3f4990c8aaf2dd8fed7222428d" + "url": "https://github.com/Termix-SSH/Plugin-Automations/releases/download/v1.0.2/automations-1.0.2.tmxplug", + "sha256": "674eb04276b4a0c77c794b30647f8680218a78de8d52b73e99ec900cc1b3fdb3" }, { "id": "docker", "source": "tmxplug", - "url": "https://github.com/Termix-SSH/Plugin-Docker/releases/download/v1.0.1/docker-1.0.1.tmxplug", - "sha256": "00e170e61ea9b7952449ea9ce30b09b983433f8d18d29f523cf3c6f98e7286e3", + "url": "https://github.com/Termix-SSH/Plugin-Docker/releases/download/v1.0.2/docker-1.0.2.tmxplug", + "sha256": "0e1c722682faac6a027fd9b01e6910eb53ce5acb13332c6c8d8868554f3701ab", "onboarding": { "recommended": true, "consent": false @@ -41,8 +41,8 @@ { "id": "file-manager", "source": "tmxplug", - "url": "https://github.com/Termix-SSH/Plugin-File-Manager/releases/download/v1.0.2/file-manager-1.0.2.tmxplug", - "sha256": "4c735f5b795a8c676590a0dc25db1bcc6036ac250b9817a61867cebdc17931c6", + "url": "https://github.com/Termix-SSH/Plugin-File-Manager/releases/download/v1.0.3/file-manager-1.0.3.tmxplug", + "sha256": "250e9779a6b1caf11721f1ee2baf76655ae6e52d963e894d8db84eb9677cf0b3", "onboarding": { "recommended": true, "consent": false @@ -51,20 +51,20 @@ { "id": "fleets", "source": "tmxplug", - "url": "https://github.com/Termix-SSH/Plugin-Fleets/releases/download/v1.0.1/fleets-1.0.1.tmxplug", - "sha256": "82ac71e69022c4991570e6fed109a9eec79421f081af0a1dcb6d8b8e4251ee3b" + "url": "https://github.com/Termix-SSH/Plugin-Fleets/releases/download/v1.0.2/fleets-1.0.2.tmxplug", + "sha256": "50dab38e1c6c95fdb4d0544ce50cdf84dae84789e64aa34ea9d04fdac3a38c59" }, { "id": "homepage", "source": "tmxplug", - "url": "https://github.com/Termix-SSH/Plugin-Homepage/releases/download/v1.0.1/homepage-1.0.1.tmxplug", - "sha256": "573375726f334e37bbf504d9411c4ad2d4bfc4363219ff10dcea6cb2cb00ed32" + "url": "https://github.com/Termix-SSH/Plugin-Homepage/releases/download/v1.0.2/homepage-1.0.2.tmxplug", + "sha256": "88cdea8ba8f458fcf124e7ab97e3d419896d8d22ffa9a372af34f1818872975b" }, { "id": "host-metrics", "source": "tmxplug", - "url": "https://github.com/Termix-SSH/Plugin-Host-Metrics/releases/download/v1.0.2/host-metrics-1.0.2.tmxplug", - "sha256": "433d5297e2fc263f2262b7ea33d7a795c9a970089aea328077861c3f032a23f5", + "url": "https://github.com/Termix-SSH/Plugin-Host-Metrics/releases/download/v1.0.3/host-metrics-1.0.3.tmxplug", + "sha256": "8f6022568349fc4318d509781bdb9d5b40973c010a9e27f720eed6baf68b881e", "onboarding": { "recommended": true, "consent": false @@ -73,32 +73,32 @@ { "id": "ldap", "source": "tmxplug", - "url": "https://github.com/Termix-SSH/Plugin-LDAP/releases/download/v1.0.1/ldap-1.0.1.tmxplug", - "sha256": "f5ba13c682d11060f373f85a9c7aaa131e587ec59c0498c0dabb013c2e474f5c" + "url": "https://github.com/Termix-SSH/Plugin-LDAP/releases/download/v1.0.2/ldap-1.0.2.tmxplug", + "sha256": "9ef269ad281f1c68cea53e0d43ccb7a5d4f949f11b30b22eac3d01047b20eb03" }, { "id": "network-topology", "source": "tmxplug", - "url": "https://github.com/Termix-SSH/Plugin-Network-Topology/releases/download/v1.0.1/network-topology-1.0.1.tmxplug", - "sha256": "4c8ec1ae0c39b8b72a89e0dce4b20a36df4adc826abe215b8ee9b69b44fe75ca" + "url": "https://github.com/Termix-SSH/Plugin-Network-Topology/releases/download/v1.0.2/network-topology-1.0.2.tmxplug", + "sha256": "8d32340a0d0f9674b872da5d3d53e90f529b80dfe0d73ab294eed00e565fbb56" }, { "id": "opkssh", "source": "tmxplug", - "url": "https://github.com/Termix-SSH/Plugin-OPKSSH/releases/download/v1.0.1/opkssh-1.0.1.tmxplug", - "sha256": "c19eadfe763e4dbecbd9a5e9e753576c577c75296ddc8e0af4cce98c2376b26f" + "url": "https://github.com/Termix-SSH/Plugin-OPKSSH/releases/download/v1.0.2/opkssh-1.0.2.tmxplug", + "sha256": "4bb8f1ac5d39bd18ac3d0fe82ddbf2f26ab4c01bd75e6f7a073d65c2e9aef219" }, { "id": "proxmox", "source": "tmxplug", - "url": "https://github.com/Termix-SSH/Plugin-Proxmox/releases/download/v1.0.1/proxmox-1.0.1.tmxplug", - "sha256": "4e0989856e061b9ab1ec25cc727051e03ea5e3dc1603e08895bd54e7ce132db7" + "url": "https://github.com/Termix-SSH/Plugin-Proxmox/releases/download/v1.0.2/proxmox-1.0.2.tmxplug", + "sha256": "b92be2644b0c8121732103b922069a6cb3cf00caad4e6549f7a8cb471bda5e4e" }, { "id": "remote-desktop", "source": "tmxplug", - "url": "https://github.com/Termix-SSH/Plugin-Remote-Desktop/releases/download/v1.0.2/remote-desktop-1.0.2.tmxplug", - "sha256": "70823a3f5161f6722fee22a9d5fe274dd47623960bc2188429c39a1c7e3e2b39", + "url": "https://github.com/Termix-SSH/Plugin-Remote-Desktop/releases/download/v1.1.0/remote-desktop-1.1.0.tmxplug", + "sha256": "6f74f1d26f91a43b2d2203417d744211f366c335d5fff7bed34bfce33874dd01", "onboarding": { "recommended": true, "consent": false @@ -107,14 +107,14 @@ { "id": "secret-sources", "source": "tmxplug", - "url": "https://github.com/Termix-SSH/Plugin-Secret-Sources/releases/download/v1.0.1/secret-sources-1.0.1.tmxplug", - "sha256": "9f5246325d3128c6376962f4cdbed33e78c487a4837f69235dfdcafeb293229a" + "url": "https://github.com/Termix-SSH/Plugin-Secret-Sources/releases/download/v1.0.2/secret-sources-1.0.2.tmxplug", + "sha256": "82e2b1e95b0837dd3e5fee277be4eea2d1ce68567b0e204941c48150f415d134" }, { "id": "serial", "source": "tmxplug", - "url": "https://github.com/Termix-SSH/Plugin-Serial/releases/download/v1.0.1/serial-1.0.1.tmxplug", - "sha256": "9908467fc184e40f108ff750bf2fb8044e000a436f84d0a7121109f4fcd5d121", + "url": "https://github.com/Termix-SSH/Plugin-Serial/releases/download/v1.0.2/serial-1.0.2.tmxplug", + "sha256": "6ede7e8726a7312429473f34a46f93ba6657c5b7ebc12bed18f80ec62c211715", "onboarding": { "recommended": "desktop", "consent": false @@ -123,20 +123,20 @@ { "id": "session-recording", "source": "tmxplug", - "url": "https://github.com/Termix-SSH/Plugin-Session-Recording/releases/download/v1.0.1/session-recording-1.0.1.tmxplug", - "sha256": "45a08f950244c862a3774f9a653b3e3645285edb507a49b5c8da123832faf1e7" + "url": "https://github.com/Termix-SSH/Plugin-Session-Recording/releases/download/v1.0.2/session-recording-1.0.2.tmxplug", + "sha256": "55ace6897710ce9a63f24e3d736a9e533cac557be0f3d98974956c94cf417d1c" }, { "id": "session-sharing", "source": "tmxplug", - "url": "https://github.com/Termix-SSH/Plugin-Session-Sharing/releases/download/v1.0.1/session-sharing-1.0.1.tmxplug", - "sha256": "753aa9c0998f194ccf5fc49fd39c133a87f21f21e803014c4bfac6ccc7f9acb1" + "url": "https://github.com/Termix-SSH/Plugin-Session-Sharing/releases/download/v1.0.2/session-sharing-1.0.2.tmxplug", + "sha256": "6408e7949a9549ac5ec94b35205729099138e730df1598d50f4bb618d8d90804" }, { "id": "snippets", "source": "tmxplug", - "url": "https://github.com/Termix-SSH/Plugin-Snippets/releases/download/v1.0.1/snippets-1.0.1.tmxplug", - "sha256": "bf643828beba86677220d802c0df74b441d7954538637f37413f78d59c08585f", + "url": "https://github.com/Termix-SSH/Plugin-Snippets/releases/download/v1.0.2/snippets-1.0.2.tmxplug", + "sha256": "cc1f5d74cf97f9a9c8f800fccf481c8a000e6fa516bc260ce24a7249d4f4c8e5", "onboarding": { "recommended": true, "consent": false @@ -145,8 +145,8 @@ { "id": "ssh-terminal", "source": "tmxplug", - "url": "https://github.com/Termix-SSH/Plugin-SSH-Terminal/releases/download/v1.0.2/ssh-terminal-1.0.2.tmxplug", - "sha256": "e65102a8cd6afdaa890095fa698f3f455a1035729b9fdca800a31cc6e5ed760f", + "url": "https://github.com/Termix-SSH/Plugin-SSH-Terminal/releases/download/v1.0.3/ssh-terminal-1.0.3.tmxplug", + "sha256": "bc288bd843e37b64bcda7462d175039147b75dc6de8866769e4a938036a9d023", "onboarding": { "recommended": true, "consent": false @@ -155,8 +155,8 @@ { "id": "sso", "source": "tmxplug", - "url": "https://github.com/Termix-SSH/Plugin-SSO/releases/download/v1.0.2/sso-1.0.2.tmxplug", - "sha256": "29ab0e4748ee71cb11f9b8dacd630430d284b6ce24561cffae9b4d7fe64e9c3b", + "url": "https://github.com/Termix-SSH/Plugin-SSO/releases/download/v1.0.3/sso-1.0.3.tmxplug", + "sha256": "87b03c0789d981dbc806162d73dfcc2c624150375277efd2ae40ab9d7657d48e", "onboarding": { "recommended": false, "consent": false, @@ -166,20 +166,20 @@ { "id": "step-ca", "source": "tmxplug", - "url": "https://github.com/Termix-SSH/Plugin-Step-CA/releases/download/v1.0.1/step-ca-1.0.1.tmxplug", - "sha256": "ff4df5bae8c48ff61fe659b6ed69a40ac070862bc423972711a194d558d77cc8" + "url": "https://github.com/Termix-SSH/Plugin-Step-CA/releases/download/v1.0.2/step-ca-1.0.2.tmxplug", + "sha256": "685b644fe44c2d3fa007baee16e8ea8a7d5204f039ebc8aa65e8093ad93adf8a" }, { "id": "tailscale", "source": "tmxplug", - "url": "https://github.com/Termix-SSH/Plugin-Tailscale/releases/download/v1.0.1/tailscale-1.0.1.tmxplug", - "sha256": "0efe1cbf0f431763611926b6a9e92630ca8c1a89ea57bb796c5d7bd3ce0ab55f" + "url": "https://github.com/Termix-SSH/Plugin-Tailscale/releases/download/v1.0.2/tailscale-1.0.2.tmxplug", + "sha256": "5c7b00d6b6b0186d833302e39a9d31b92ea92d4542e9e2dab550b95837a59d97" }, { "id": "telemetry", "source": "tmxplug", - "url": "https://github.com/Termix-SSH/Plugin-Telemetry/releases/download/v1.0.1/telemetry-1.0.1.tmxplug", - "sha256": "c7f70e149c67a570f2b004c2cceeba27ffe8cbd95cd4df4bbffd880cc3451469", + "url": "https://github.com/Termix-SSH/Plugin-Telemetry/releases/download/v1.0.2/telemetry-1.0.2.tmxplug", + "sha256": "5ffdc58b05cd8b37fd0a207f99d2d3a256164130c950032501c499f869edb9ec", "onboarding": { "recommended": true, "consent": true @@ -188,20 +188,20 @@ { "id": "termix-identity", "source": "tmxplug", - "url": "https://github.com/Termix-SSH/Plugin-Termix-Identity/releases/download/v1.0.1/termix-identity-1.0.1.tmxplug", - "sha256": "dc423ec53f012fdcc2cdccd872ac01353025e4da2ee9fea0d2a2d5988518a5a0" + "url": "https://github.com/Termix-SSH/Plugin-Termix-Identity/releases/download/v1.0.2/termix-identity-1.0.2.tmxplug", + "sha256": "ac6c90a48b4f1e9cff6773a5fd267b2bf7319d2fc7e2aa2cd4004cec82f0f927" }, { "id": "tmux-monitor", "source": "tmxplug", - "url": "https://github.com/Termix-SSH/Plugin-Tmux-Monitor/releases/download/v1.0.2/tmux-monitor-1.0.2.tmxplug", - "sha256": "ea41f1f90bdeab6164cfa2a594f359492afa123ed06990afa338be69a9d974c6" + "url": "https://github.com/Termix-SSH/Plugin-Tmux-Monitor/releases/download/v1.0.3/tmux-monitor-1.0.3.tmxplug", + "sha256": "f152d64f433d3f1de7c3fdf172e752ea7e333909f0fdda77336a6b8dddb9f242" }, { "id": "totp", "source": "tmxplug", - "url": "https://github.com/Termix-SSH/Plugin-TOTP/releases/download/v1.0.1/totp-1.0.1.tmxplug", - "sha256": "4a083871663c2f6034904bd49bb02c2d4dd4ed4d50fbce1522172dae84991474", + "url": "https://github.com/Termix-SSH/Plugin-TOTP/releases/download/v1.0.2/totp-1.0.2.tmxplug", + "sha256": "94a0772660fa8e4dfd9afc15b68d5bc9fe02fe28a1acfd35e65a57c304d460c0", "onboarding": { "recommended": true, "consent": false @@ -210,8 +210,8 @@ { "id": "tunnels", "source": "tmxplug", - "url": "https://github.com/Termix-SSH/Plugin-Tunnels/releases/download/v1.0.2/tunnels-1.0.2.tmxplug", - "sha256": "44799c0bdbcc576fb0e1c1ecce96f283d2cc32b44ac5aeeb7253f8ca0b650860", + "url": "https://github.com/Termix-SSH/Plugin-Tunnels/releases/download/v1.0.3/tunnels-1.0.3.tmxplug", + "sha256": "d0de9e59ccc9e1a0f2817dd1ec9951021f2d7e89304894bb1e8cefd65aebff0b", "onboarding": { "recommended": true, "consent": false @@ -220,32 +220,32 @@ { "id": "vault", "source": "tmxplug", - "url": "https://github.com/Termix-SSH/Plugin-Vault/releases/download/v1.0.1/vault-1.0.1.tmxplug", - "sha256": "c828eeebe37bf576020908e16a28e73073fc77178062b323058624f557a8c72a" + "url": "https://github.com/Termix-SSH/Plugin-Vault/releases/download/v1.0.2/vault-1.0.2.tmxplug", + "sha256": "48ef402d030912a836489edf0fdfcaf7929cbd67c21f16a2079cb4925134cc28" }, { "id": "wake-on-lan", "source": "tmxplug", - "url": "https://github.com/Termix-SSH/Plugin-Wake-On-LAN/releases/download/v1.0.1/wake-on-lan-1.0.1.tmxplug", - "sha256": "5301d26138cb37812e8ef1fc5d3df688415517d545667b365fca2f41166a8402" + "url": "https://github.com/Termix-SSH/Plugin-Wake-On-LAN/releases/download/v1.0.2/wake-on-lan-1.0.2.tmxplug", + "sha256": "3e9cf9a133b4dd96ec5f2b9fbf2d6a3ff7e08f8dfbf1e86b8d45de0c2f024f42" }, { "id": "warpgate", "source": "tmxplug", - "url": "https://github.com/Termix-SSH/Plugin-Warpgate/releases/download/v1.0.1/warpgate-1.0.1.tmxplug", - "sha256": "2f3b3169101ff04d0ea6229b31270ba0b5b8ff2c3d16e3f21a9e6c569d463ea8" + "url": "https://github.com/Termix-SSH/Plugin-Warpgate/releases/download/v1.0.2/warpgate-1.0.2.tmxplug", + "sha256": "5c2c1c9aef40dbbf98424d02303f2ee77e6e869334247881e8aa0fcd8c415b47" }, { "id": "web-endpoint", "source": "tmxplug", - "url": "https://github.com/Termix-SSH/Plugin-Web-Endpoint/releases/download/v1.0.1/web-endpoint-1.0.1.tmxplug", - "sha256": "88477231ab3c93206c99393c6cc8628c1ff8159af6978d0d09db969c28e1d637" + "url": "https://github.com/Termix-SSH/Plugin-Web-Endpoint/releases/download/v1.0.2/web-endpoint-1.0.2.tmxplug", + "sha256": "0326edb1052d1debd083004c2dd1711d21a902cfacb55ce74d1a8856a5122477" }, { "id": "webauthn", "source": "tmxplug", - "url": "https://github.com/Termix-SSH/Plugin-WebAuthn/releases/download/v1.0.1/webauthn-1.0.1.tmxplug", - "sha256": "f9ccd5d6fe110b5c7ad8b90fd95e0a201e03d440462c2c3f6559406222364464", + "url": "https://github.com/Termix-SSH/Plugin-WebAuthn/releases/download/v1.0.2/webauthn-1.0.2.tmxplug", + "sha256": "8269f6e407897332648c8526b65383c4db071ea782bf1e58aa7c7c00627f3f4b", "onboarding": { "recommended": true, "consent": false @@ -254,8 +254,8 @@ { "id": "workspaces", "source": "tmxplug", - "url": "https://github.com/Termix-SSH/Plugin-Workspaces/releases/download/v1.0.1/workspaces-1.0.1.tmxplug", - "sha256": "17f53044318d6f53ef1e332d204f6469c7043fd9985c4d1eae16b6a1c3248153", + "url": "https://github.com/Termix-SSH/Plugin-Workspaces/releases/download/v1.0.2/workspaces-1.0.2.tmxplug", + "sha256": "97519a01fbb2eb2eecf21d7b9ab9028c740457bcff9fc6b075fb6facbc5ccb6b", "onboarding": { "recommended": true, "consent": false diff --git a/docker/compose-dev.yml b/docker/compose-dev.yml index 3c181e5aa..caf202612 100644 --- a/docker/compose-dev.yml +++ b/docker/compose-dev.yml @@ -12,24 +12,9 @@ services: environment: PORT: "8080" NODE_ENV: development - GUACD_HOST: "guacd-dev" - GUACD_TUNNEL_HOST: "termix-dev" - GUACD_RECORDING_PATH: "/termix-data/session_recordings/guacamole" - GUACD_DRIVE_PATH: "/termix-data/rdp-drive" # REDIS_URL: "redis://redis:6379" # TERMIX_REDIS_PREFIX: "termix:collab" # TERMIX_STEP_CA_REDIS_PREFIX: "termix:step-ca" - depends_on: - - guacd-dev - networks: - - termix-dev-net - - guacd-dev: - image: guacamole/guacd:1.6.0 - container_name: guacd-dev - restart: unless-stopped - volumes: - - termix-dev-data:/termix-data networks: - termix-dev-net diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index 601ae1808..8a3a49d0f 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -9,12 +9,6 @@ services: - termix-data:/app/data environment: PORT: "8080" - GUACD_HOST: "guacd" - GUACD_TUNNEL_HOST: "termix" - GUACD_RECORDING_PATH: "/termix-data/session_recordings/guacamole" - # Where guacd stores files for RDP drive redirection (one folder per - # user is created underneath). Must be writable by guacd's user. - GUACD_DRIVE_PATH: "/termix-data/rdp-drive" # Multi-instance deployments should point every backend at the same # Redis. Without it, collaboration rooms keep their single-instance # in-memory fallback. @@ -28,21 +22,14 @@ services: # Trusted reverse-proxy authentication is disabled by default. When # enabled, do not expose this container directly to untrusted clients. # TRUSTED_PROXY_AUTH_ENABLED: "true" - # TRUSTED_PROXY_AUTH_TRUSTED_PROXIES: "172.16.0.0/12" + # List the address your authenticating proxy connects from, as this + # container sees it (for a proxy container on termix-net, its IP there). + # Keep it narrow: published ports can reach the container from the + # Docker gateway, so do not trust whole private ranges. + # TRUSTED_PROXY_AUTH_TRUSTED_PROXIES: "172.20.0.10" # TRUSTED_PROXY_AUTH_ROLE_MAP: '{"operators":["user"]}' # TRUSTED_PROXY_AUTH_USERNAME_HEADER: "x-forwarded-username" # TRUSTED_PROXY_AUTH_ROLE_HEADER: "x-forwarded-role" - depends_on: - - guacd - networks: - - termix-net - - guacd: - image: guacamole/guacd:1.6.0 - container_name: guacd - restart: unless-stopped - volumes: - - termix-data:/termix-data networks: - termix-net diff --git a/docker/nginx-https.conf b/docker/nginx-https.conf index dbcdfbc9c..fbe592f9c 100644 --- a/docker/nginx-https.conf +++ b/docker/nginx-https.conf @@ -200,6 +200,9 @@ http { proxy_http_version 1.1; proxy_set_header Host $http_host; proxy_set_header X-Real-IP $remote_addr; + # The TCP peer before set_real_ip_from rewrites $remote_addr from + # X-Forwarded-For; trusted proxy login checks this address. + proxy_set_header X-Termix-Proxy-Peer $realip_remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto; proxy_set_header X-Forwarded-Port $proxy_x_forwarded_port; @@ -473,9 +476,12 @@ http { } error_page 500 502 503 504 /50x.html; + # Only nginx's own errors land here (the backend is down or too slow). + # There is no 50x page, so answer directly instead of a 404. location = /50x.html { - root /app/html; internal; + default_type application/json; + return 502 '{"error":"The Termix server is not responding"}'; } } } diff --git a/docker/nginx.conf b/docker/nginx.conf index fa369e170..e529ce06c 100644 --- a/docker/nginx.conf +++ b/docker/nginx.conf @@ -169,6 +169,9 @@ http { proxy_http_version 1.1; proxy_set_header Host $http_host; proxy_set_header X-Real-IP $remote_addr; + # The TCP peer before set_real_ip_from rewrites $remote_addr from + # X-Forwarded-For; trusted proxy login checks this address. + proxy_set_header X-Termix-Proxy-Peer $realip_remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto; proxy_set_header X-Forwarded-Port $proxy_x_forwarded_port; @@ -442,9 +445,12 @@ http { } error_page 500 502 503 504 /50x.html; + # Only nginx's own errors land here (the backend is down or too slow). + # There is no 50x page, so answer directly instead of a 404. location = /50x.html { - root /app/html; internal; + default_type application/json; + return 502 '{"error":"The Termix server is not responding"}'; } } } diff --git a/drizzle/mysql/0072_unique_username.sql b/drizzle/mysql/0072_unique_username.sql new file mode 100644 index 000000000..31c9f60ca --- /dev/null +++ b/drizzle/mysql/0072_unique_username.sql @@ -0,0 +1,19 @@ +-- Choose an unused prefix from N + 1 disjoint prefixes for N existing users. +WITH ranked AS ( + SELECT id, username, + ROW_NUMBER() OVER (ORDER BY registered_at, id) AS sequence, + ROW_NUMBER() OVER (PARTITION BY username ORDER BY registered_at, id) AS occurrence + FROM users +), candidates AS ( + SELECT 0 AS candidate UNION ALL SELECT sequence FROM ranked +), available AS ( + SELECT MIN(candidate) AS candidate FROM candidates + WHERE NOT EXISTS ( + SELECT 1 FROM ranked WHERE username LIKE CONCAT(CONCAT('termix-recovered-', candidate, '-'), '%') + ) +) +UPDATE users JOIN ranked ON ranked.id = users.id CROSS JOIN available +SET users.username = CONCAT('termix-recovered-', available.candidate, '-', ranked.sequence) +WHERE ranked.occurrence > 1;--> statement-breakpoint +ALTER TABLE `users` MODIFY COLUMN `username` varchar(255) NOT NULL;--> statement-breakpoint +ALTER TABLE `users` ADD CONSTRAINT `idx_users_username` UNIQUE(`username`); diff --git a/drizzle/mysql/meta/0072_snapshot.json b/drizzle/mysql/meta/0072_snapshot.json new file mode 100644 index 000000000..5354374cb --- /dev/null +++ b/drizzle/mysql/meta/0072_snapshot.json @@ -0,0 +1,4745 @@ +{ + "version": "5", + "dialect": "mysql", + "id": "65ebfe5f-a44f-434d-9208-93c8ea964729", + "prevId": "16f4f5ab-8fba-436d-9cea-4aea80dd5234", + "tables": { + "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": "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": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + } + }, + "indexes": { + "idx_api_keys_user_id": { + "name": "idx_api_keys_user_id", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "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": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "resource_type": { + "name": "resource_type", + "type": "varchar(255)", + "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": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": { + "idx_audit_logs_timestamp": { + "name": "idx_audit_logs_timestamp", + "columns": [ + "timestamp" + ], + "isUnique": false + }, + "idx_audit_logs_user_ts": { + "name": "idx_audit_logs_user_ts", + "columns": [ + "user_id", + "timestamp" + ], + "isUnique": false + }, + "idx_audit_logs_action_ts": { + "name": "idx_audit_logs_action_ts", + "columns": [ + "action", + "timestamp" + ], + "isUnique": false + }, + "idx_audit_logs_resource_ts": { + "name": "idx_audit_logs_resource_ts", + "columns": [ + "resource_type", + "timestamp" + ], + "isUnique": false + } + }, + "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": {} + }, + "credential_access": { + "name": "credential_access", + "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 + }, + "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": "('use')" + }, + "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": { + "idx_credential_access_user_id": { + "name": "idx_credential_access_user_id", + "columns": [ + "user_id" + ], + "isUnique": false + }, + "idx_credential_access_role_id": { + "name": "idx_credential_access_role_id", + "columns": [ + "role_id" + ], + "isUnique": false + }, + "idx_credential_access_credential_id": { + "name": "idx_credential_access_credential_id", + "columns": [ + "credential_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "credential_access_credential_id_ssh_credentials_id_fk": { + "name": "credential_access_credential_id_ssh_credentials_id_fk", + "tableFrom": "credential_access", + "tableTo": "ssh_credentials", + "columnsFrom": [ + "credential_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_access_user_id_users_id_fk": { + "name": "credential_access_user_id_users_id_fk", + "tableFrom": "credential_access", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_access_role_id_roles_id_fk": { + "name": "credential_access_role_id_roles_id_fk", + "tableFrom": "credential_access", + "tableTo": "roles", + "columnsFrom": [ + "role_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_access_granted_by_users_id_fk": { + "name": "credential_access_granted_by_users_id_fk", + "tableFrom": "credential_access", + "tableTo": "users", + "columnsFrom": [ + "granted_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "credential_access_id": { + "name": "credential_access_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "credential_sidebar_preferences": { + "name": "credential_sidebar_preferences", + "columns": { + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "data": { + "name": "data", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": {}, + "foreignKeys": { + "credential_sidebar_preferences_user_id_users_id_fk": { + "name": "credential_sidebar_preferences_user_id_users_id_fk", + "tableFrom": "credential_sidebar_preferences", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "credential_sidebar_preferences_user_id": { + "name": "credential_sidebar_preferences_user_id", + "columns": [ + "user_id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "folder_access": { + "name": "folder_access", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "folder": { + "name": "folder", + "type": "varchar(255)", + "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)" + } + }, + "indexes": { + "idx_folder_access_owner_folder": { + "name": "idx_folder_access_owner_folder", + "columns": [ + "owner_user_id", + "folder" + ], + "isUnique": false + } + }, + "foreignKeys": { + "folder_access_owner_user_id_users_id_fk": { + "name": "folder_access_owner_user_id_users_id_fk", + "tableFrom": "folder_access", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "folder_access_user_id_users_id_fk": { + "name": "folder_access_user_id_users_id_fk", + "tableFrom": "folder_access", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "folder_access_role_id_roles_id_fk": { + "name": "folder_access_role_id_roles_id_fk", + "tableFrom": "folder_access", + "tableTo": "roles", + "columnsFrom": [ + "role_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "folder_access_granted_by_users_id_fk": { + "name": "folder_access_granted_by_users_id_fk", + "tableFrom": "folder_access", + "tableTo": "users", + "columnsFrom": [ + "granted_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "folder_access_id": { + "name": "folder_access_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "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": "varchar(255)", + "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": { + "idx_host_access_user_id": { + "name": "idx_host_access_user_id", + "columns": [ + "user_id" + ], + "isUnique": false + }, + "idx_host_access_role_id": { + "name": "idx_host_access_role_id", + "columns": [ + "role_id" + ], + "isUnique": false + }, + "idx_host_access_host_id": { + "name": "idx_host_access_host_id", + "columns": [ + "host_id" + ], + "isUnique": false + }, + "idx_host_access_expires_at": { + "name": "idx_host_access_expires_at", + "columns": [ + "expires_at" + ], + "isUnique": false + } + }, + "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_defaults": { + "name": "host_defaults", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "level": { + "name": "level", + "type": "enum('admin','user','folder')", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "scope_key": { + "name": "scope_key", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "folder_id": { + "name": "folder_id", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "namespace": { + "name": "namespace", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "key": { + "name": "key", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "updated_by": { + "name": "updated_by", + "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": { + "idx_host_defaults_scope_key": { + "name": "idx_host_defaults_scope_key", + "columns": [ + "scope_key", + "namespace", + "key" + ], + "isUnique": true + }, + "idx_host_defaults_user": { + "name": "idx_host_defaults_user", + "columns": [ + "user_id" + ], + "isUnique": false + }, + "idx_host_defaults_folder": { + "name": "idx_host_defaults_folder", + "columns": [ + "folder_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "host_defaults_user_id_users_id_fk": { + "name": "host_defaults_user_id_users_id_fk", + "tableFrom": "host_defaults", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "host_defaults_folder_id_ssh_folders_id_fk": { + "name": "host_defaults_folder_id_ssh_folders_id_fk", + "tableFrom": "host_defaults", + "tableTo": "ssh_folders", + "columnsFrom": [ + "folder_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "host_defaults_id": { + "name": "host_defaults_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "host_protocol_auth": { + "name": "host_protocol_auth", + "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 + }, + "auth_type": { + "name": "auth_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "('direct')" + }, + "credential_id": { + "name": "credential_id", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "fields": { + "name": "fields", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "secret_fields": { + "name": "secret_fields", + "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_host_protocol_auth_host_protocol": { + "name": "idx_host_protocol_auth_host_protocol", + "columns": [ + "host_id", + "protocol" + ], + "isUnique": true + }, + "idx_host_protocol_auth_user": { + "name": "idx_host_protocol_auth_user", + "columns": [ + "user_id" + ], + "isUnique": false + }, + "idx_host_protocol_auth_credential": { + "name": "idx_host_protocol_auth_credential", + "columns": [ + "credential_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "host_protocol_auth_host_id_ssh_data_id_fk": { + "name": "host_protocol_auth_host_id_ssh_data_id_fk", + "tableFrom": "host_protocol_auth", + "tableTo": "ssh_data", + "columnsFrom": [ + "host_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "host_protocol_auth_user_id_users_id_fk": { + "name": "host_protocol_auth_user_id_users_id_fk", + "tableFrom": "host_protocol_auth", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "host_protocol_auth_credential_id_ssh_credentials_id_fk": { + "name": "host_protocol_auth_credential_id_ssh_credentials_id_fk", + "tableFrom": "host_protocol_auth", + "tableTo": "ssh_credentials", + "columnsFrom": [ + "credential_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "host_protocol_auth_id": { + "name": "host_protocol_auth_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "host_sidebar_preferences": { + "name": "host_sidebar_preferences", + "columns": { + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "data": { + "name": "data", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": {}, + "foreignKeys": { + "host_sidebar_preferences_user_id_users_id_fk": { + "name": "host_sidebar_preferences_user_id_users_id_fk", + "tableFrom": "host_sidebar_preferences", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "host_sidebar_preferences_user_id": { + "name": "host_sidebar_preferences_user_id", + "columns": [ + "user_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": "text", + "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 + }, + "parent_host_id": { + "name": "parent_host_id", + "type": "int", + "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 + }, + "sort_order": { + "name": "sort_order", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "auth_type": { + "name": "auth_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": 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 + }, + "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 + }, + "jump_hosts": { + "name": "jump_hosts", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status_check_enabled": { + "name": "status_check_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "status_check_interval": { + "name": "status_check_interval", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "ssh_options": { + "name": "ssh_options", + "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 + }, + "ssh_port": { + "name": "ssh_port", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 22 + }, + "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 + }, + "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 + }, + "local_only": { + "name": "local_only", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "shared_source": { + "name": "shared_source", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "default_overrides": { + "name": "default_overrides", + "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_ssh_data_user_id": { + "name": "idx_ssh_data_user_id", + "columns": [ + "user_id" + ], + "isUnique": false + }, + "idx_ssh_data_parent_host": { + "name": "idx_ssh_data_parent_host", + "columns": [ + "parent_host_id" + ], + "isUnique": false + }, + "idx_ssh_data_credential": { + "name": "idx_ssh_data_credential", + "columns": [ + "credential_id" + ], + "isUnique": false + } + }, + "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_parent_host_id_ssh_data_id_fk": { + "name": "ssh_data_parent_host_id_ssh_data_id_fk", + "tableFrom": "ssh_data", + "tableTo": "ssh_data", + "columnsFrom": [ + "parent_host_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "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" + } + }, + "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": {} + }, + "plugin_install_counts": { + "name": "plugin_install_counts", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "plugin_id": { + "name": "plugin_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "registry_id": { + "name": "registry_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "count": { + "name": "count", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": { + "idx_plugin_install_counts_plugin_registry": { + "name": "idx_plugin_install_counts_plugin_registry", + "columns": [ + "plugin_id", + "registry_id" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "plugin_install_counts_id": { + "name": "plugin_install_counts_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "plugin_migrations": { + "name": "plugin_migrations", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "plugin_id": { + "name": "plugin_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "migration_id": { + "name": "migration_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "checksum": { + "name": "checksum", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "applied_at": { + "name": "applied_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": { + "idx_plugin_migrations_plugin_migration": { + "name": "idx_plugin_migrations_plugin_migration", + "columns": [ + "plugin_id", + "migration_id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "plugin_migrations_plugin_id_plugins_id_fk": { + "name": "plugin_migrations_plugin_id_plugins_id_fk", + "tableFrom": "plugin_migrations", + "tableTo": "plugins", + "columnsFrom": [ + "plugin_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "plugin_migrations_id": { + "name": "plugin_migrations_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "plugin_permission_grants": { + "name": "plugin_permission_grants", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "plugin_id": { + "name": "plugin_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "capability": { + "name": "capability", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "granted_at": { + "name": "granted_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "granted_by": { + "name": "granted_by", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "('admin')" + } + }, + "indexes": { + "idx_plugin_permission_grants_plugin_capability": { + "name": "idx_plugin_permission_grants_plugin_capability", + "columns": [ + "plugin_id", + "capability" + ], + "isUnique": true + } + }, + "foreignKeys": { + "plugin_permission_grants_plugin_id_plugins_id_fk": { + "name": "plugin_permission_grants_plugin_id_plugins_id_fk", + "tableFrom": "plugin_permission_grants", + "tableTo": "plugins", + "columnsFrom": [ + "plugin_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "plugin_permission_grants_granted_by_users_id_fk": { + "name": "plugin_permission_grants_granted_by_users_id_fk", + "tableFrom": "plugin_permission_grants", + "tableTo": "users", + "columnsFrom": [ + "granted_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "plugin_permission_grants_id": { + "name": "plugin_permission_grants_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "plugin_registries": { + "name": "plugin_registries", + "columns": { + "id": { + "name": "id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "('community')" + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "signing_key": { + "name": "signing_key", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_checked_at": { + "name": "last_checked_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_index_hash": { + "name": "last_index_hash", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "plugin_registries_id": { + "name": "plugin_registries_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "plugin_settings": { + "name": "plugin_settings", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "plugin_id": { + "name": "plugin_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "scope": { + "name": "scope", + "type": "enum('admin','user','host','secret')", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "scope_id": { + "name": "scope_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "key": { + "name": "key", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "encrypted": { + "name": "encrypted", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": { + "idx_plugin_settings_scope_key": { + "name": "idx_plugin_settings_scope_key", + "columns": [ + "plugin_id", + "scope", + "scope_id", + "key" + ], + "isUnique": true + }, + "idx_plugin_settings_plugin_scope": { + "name": "idx_plugin_settings_plugin_scope", + "columns": [ + "plugin_id", + "scope" + ], + "isUnique": false + } + }, + "foreignKeys": { + "plugin_settings_plugin_id_plugins_id_fk": { + "name": "plugin_settings_plugin_id_plugins_id_fk", + "tableFrom": "plugin_settings", + "tableTo": "plugins", + "columnsFrom": [ + "plugin_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "plugin_settings_id": { + "name": "plugin_settings_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "plugin_storage": { + "name": "plugin_storage", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "plugin_id": { + "name": "plugin_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "key": { + "name": "key", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": { + "idx_plugin_storage_plugin_key": { + "name": "idx_plugin_storage_plugin_key", + "columns": [ + "plugin_id", + "key" + ], + "isUnique": true + } + }, + "foreignKeys": { + "plugin_storage_plugin_id_plugins_id_fk": { + "name": "plugin_storage_plugin_id_plugins_id_fk", + "tableFrom": "plugin_storage", + "tableTo": "plugins", + "columnsFrom": [ + "plugin_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "plugin_storage_id": { + "name": "plugin_storage_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "plugins": { + "name": "plugins", + "columns": { + "id": { + "name": "id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "version": { + "name": "version", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "tier": { + "name": "tier", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "('available')" + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "('community')" + }, + "registry_id": { + "name": "registry_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "('disabled')" + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "installed_at": { + "name": "installed_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)" + }, + "auto_update": { + "name": "auto_update", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "pinned_version": { + "name": "pinned_version", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "channel": { + "name": "channel", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "('stable')" + }, + "manifest_json": { + "name": "manifest_json", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_plugins_registry_id": { + "name": "idx_plugins_registry_id", + "columns": [ + "registry_id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "plugins_id": { + "name": "plugins_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "rbac_applied_defaults": { + "name": "rbac_applied_defaults", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "role_name": { + "name": "role_name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "permission": { + "name": "permission", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "applied_at": { + "name": "applied_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": { + "idx_rbac_applied_defaults_role_permission": { + "name": "idx_rbac_applied_defaults_role_permission", + "columns": [ + "role_name", + "permission" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "rbac_applied_defaults_id": { + "name": "rbac_applied_defaults_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "rbac_known_permissions": { + "name": "rbac_known_permissions", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "permission": { + "name": "permission", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "plugin_id": { + "name": "plugin_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "first_seen_at": { + "name": "first_seen_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": { + "idx_rbac_known_permissions_permission": { + "name": "idx_rbac_known_permissions_permission", + "columns": [ + "permission" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "rbac_known_permissions_id": { + "name": "rbac_known_permissions_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": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": { + "idx_recent_activity_user_ts": { + "name": "idx_recent_activity_user_ts", + "columns": [ + "user_id", + "timestamp" + ], + "isUnique": false + } + }, + "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": {} + }, + "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 + }, + "external_session_ref": { + "name": "external_session_ref", + "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": "varchar(255)", + "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": { + "idx_sessions_user_id": { + "name": "idx_sessions_user_id", + "columns": [ + "user_id" + ], + "isUnique": false + }, + "idx_sessions_expires_at": { + "name": "idx_sessions_expires_at", + "columns": [ + "expires_at" + ], + "isUnique": false + } + }, + "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_credential_secrets": { + "name": "shared_credential_secrets", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "credential_access_id": { + "name": "credential_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 + }, + "credential_id": { + "name": "credential_id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "encrypted_username": { + "name": "encrypted_username", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "auth_type": { + "name": "auth_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "('password')" + }, + "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 + }, + "key_type": { + "name": "key_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "public_key": { + "name": "public_key", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cert_public_key": { + "name": "cert_public_key", + "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_credential_secrets_scope": { + "name": "idx_shared_credential_secrets_scope", + "columns": [ + "credential_access_id", + "target_user_id" + ], + "isUnique": true + }, + "idx_shared_credential_secrets_target": { + "name": "idx_shared_credential_secrets_target", + "columns": [ + "target_user_id", + "credential_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "shared_credential_secrets_target_user_id_users_id_fk": { + "name": "shared_credential_secrets_target_user_id_users_id_fk", + "tableFrom": "shared_credential_secrets", + "tableTo": "users", + "columnsFrom": [ + "target_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "shared_credential_secrets_credential_id_ssh_credentials_id_fk": { + "name": "shared_credential_secrets_credential_id_ssh_credentials_id_fk", + "tableFrom": "shared_credential_secrets", + "tableTo": "ssh_credentials", + "columnsFrom": [ + "credential_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "shared_cred_secrets_access_id_fk": { + "name": "shared_cred_secrets_access_id_fk", + "tableFrom": "shared_credential_secrets", + "tableTo": "credential_access", + "columnsFrom": [ + "credential_access_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "shared_credential_secrets_id": { + "name": "shared_credential_secrets_id", + "columns": [ + "id" + ] + } + }, + "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 + }, + "encrypted_fields": { + "name": "encrypted_fields", + "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": {} + }, + "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": { + "idx_ssh_credential_usage_credential": { + "name": "idx_ssh_credential_usage_credential", + "columns": [ + "credential_id" + ], + "isUnique": false + }, + "idx_ssh_credential_usage_user": { + "name": "idx_ssh_credential_usage_user", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "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": "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 + }, + "pin": { + "name": "pin", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "sort_order": { + "name": "sort_order", + "type": "int", + "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 + }, + "shared_source": { + "name": "shared_source", + "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_ssh_credentials_user_id": { + "name": "idx_ssh_credentials_user_id", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "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": "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 + }, + "sort_order": { + "name": "sort_order", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sync_id": { + "name": "sync_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "local_only": { + "name": "local_only", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 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_ssh_folders_user_id": { + "name": "idx_ssh_folders_user_id", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "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" + } + }, + "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": {} + }, + "sync_conflicts": { + "name": "sync_conflicts", + "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": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "local_row": { + "name": "local_row", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "server_revision": { + "name": "server_revision", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": { + "idx_sync_conflicts_user": { + "name": "idx_sync_conflicts_user", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "sync_conflicts_user_id_users_id_fk": { + "name": "sync_conflicts_user_id_users_id_fk", + "tableFrom": "sync_conflicts", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "sync_conflicts_id": { + "name": "sync_conflicts_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "sync_link": { + "name": "sync_link", + "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 + }, + "server_url": { + "name": "server_url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "server_name": { + "name": "server_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "server_version": { + "name": "server_version", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "session_token": { + "name": "session_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "custom_headers": { + "name": "custom_headers", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "basic_auth": { + "name": "basic_auth", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "allow_invalid_certificate": { + "name": "allow_invalid_certificate", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "remote_user_id": { + "name": "remote_user_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "remote_username": { + "name": "remote_username", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "account": { + "name": "account", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "known_types": { + "name": "known_types", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cursor": { + "name": "cursor", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "('idle')" + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "linked_at": { + "name": "linked_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "last_sync_at": { + "name": "last_sync_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "sync_link_user_id_users_id_fk": { + "name": "sync_link_user_id_users_id_fk", + "tableFrom": "sync_link", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "sync_link_id": { + "name": "sync_link_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "sync_records": { + "name": "sync_records", + "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": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "sync_id": { + "name": "sync_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "revision": { + "name": "revision", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "seq": { + "name": "seq", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "hash": { + "name": "hash", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "deleted": { + "name": "deleted", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "error_hash": { + "name": "error_hash", + "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": { + "idx_sync_records_user_entity_sync": { + "name": "idx_sync_records_user_entity_sync", + "columns": [ + "user_id", + "entity_type", + "sync_id" + ], + "isUnique": true + }, + "idx_sync_records_user_seq": { + "name": "idx_sync_records_user_seq", + "columns": [ + "user_id", + "seq" + ], + "isUnique": false + } + }, + "foreignKeys": { + "sync_records_user_id_users_id_fk": { + "name": "sync_records_user_id_users_id_fk", + "tableFrom": "sync_records", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "sync_records_id": { + "name": "sync_records_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": { + "idx_trusted_devices_user_id": { + "name": "idx_trusted_devices_user_id", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "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": {} + }, + "ui_preferences": { + "name": "ui_preferences", + "columns": { + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "data": { + "name": "data", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": {}, + "foreignKeys": { + "ui_preferences_user_id_users_id_fk": { + "name": "ui_preferences_user_id_users_id_fk", + "tableFrom": "ui_preferences", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "ui_preferences_user_id": { + "name": "ui_preferences_user_id", + "columns": [ + "user_id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "user_external_identities": { + "name": "user_external_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 + }, + "provider_id": { + "name": "provider_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "subject": { + "name": "subject", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email": { + "name": "email", + "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": { + "idx_user_external_identities_provider_subject": { + "name": "idx_user_external_identities_provider_subject", + "columns": [ + "provider_id", + "subject" + ], + "isUnique": true + }, + "idx_user_external_identities_user": { + "name": "idx_user_external_identities_user", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "user_external_identities_user_id_users_id_fk": { + "name": "user_external_identities_user_id_users_id_fk", + "tableFrom": "user_external_identities", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "user_external_identities_id": { + "name": "user_external_identities_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": { + "idx_user_open_tabs_user_id": { + "name": "idx_user_open_tabs_user_id", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "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_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 + }, + "show_pin_app_rail_button": { + "name": "show_pin_app_rail_button", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "folders_collapsed": { + "name": "folders_collapsed", + "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_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 + }, + "idx_user_roles_role_id": { + "name": "idx_user_roles_role_id", + "columns": [ + "role_id" + ], + "isUnique": false + } + }, + "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": {} + }, + "user_second_factors": { + "name": "user_second_factors", + "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 + }, + "plugin_id": { + "name": "plugin_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "factor_id": { + "name": "factor_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "enrolled_at": { + "name": "enrolled_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": { + "idx_user_second_factors_user_factor": { + "name": "idx_user_second_factors_user_factor", + "columns": [ + "user_id", + "plugin_id", + "factor_id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "user_second_factors_user_id_users_id_fk": { + "name": "user_second_factors_user_id_users_id_fk", + "tableFrom": "user_second_factors", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "user_second_factors_id": { + "name": "user_second_factors_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": "varchar(255)", + "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 + }, + "registered_at": { + "name": "registered_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": { + "idx_users_username": { + "name": "idx_users_username", + "columns": [ + "username" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "users_id": { + "name": "users_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 index 03afb6475..de667d10d 100644 --- a/drizzle/mysql/meta/_journal.json +++ b/drizzle/mysql/meta/_journal.json @@ -505,6 +505,13 @@ "when": 1791755801066, "tag": "0071_drop_install_count_source", "breakpoints": true + }, + { + "idx": 72, + "version": "5", + "when": 1791935283211, + "tag": "0072_unique_username", + "breakpoints": true } ] } \ No newline at end of file diff --git a/drizzle/postgres/0072_unique_username.sql b/drizzle/postgres/0072_unique_username.sql new file mode 100644 index 000000000..66daff2ab --- /dev/null +++ b/drizzle/postgres/0072_unique_username.sql @@ -0,0 +1,18 @@ +-- Choose an unused prefix from N + 1 disjoint prefixes for N existing users. +WITH ranked AS MATERIALIZED ( + SELECT id, username, + ROW_NUMBER() OVER (ORDER BY registered_at, id) AS sequence, + ROW_NUMBER() OVER (PARTITION BY username ORDER BY registered_at, id) AS occurrence + FROM users +), candidates AS ( + SELECT 0 AS candidate UNION ALL SELECT sequence FROM ranked +), available AS ( + SELECT MIN(candidate) AS candidate FROM candidates + WHERE NOT EXISTS ( + SELECT 1 FROM ranked WHERE username LIKE 'termix-recovered-' || candidate || '-' || '%' + ) +) +UPDATE users SET username = 'termix-recovered-' || (SELECT candidate FROM available) || '-' || ranked.sequence +FROM ranked WHERE ranked.id = users.id AND ranked.occurrence > 1;--> statement-breakpoint +ALTER TABLE "users" ALTER COLUMN "username" SET DATA TYPE varchar(255);--> statement-breakpoint +CREATE UNIQUE INDEX "idx_users_username" ON "users" USING btree ("username"); diff --git a/drizzle/postgres/meta/0072_snapshot.json b/drizzle/postgres/meta/0072_snapshot.json new file mode 100644 index 000000000..3ff888301 --- /dev/null +++ b/drizzle/postgres/meta/0072_snapshot.json @@ -0,0 +1,4779 @@ +{ + "id": "788b2d3a-f430-4726-b4ea-4958e1ed5d24", + "prevId": "75755c44-75cb-4871-b998-de818b8dbc96", + "version": "7", + "dialect": "postgresql", + "tables": { + "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": "text", + "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": { + "idx_api_keys_user_id": { + "name": "idx_api_keys_user_id", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "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": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "resource_type": { + "name": "resource_type", + "type": "varchar(255)", + "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": "varchar(255)", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "idx_audit_logs_timestamp": { + "name": "idx_audit_logs_timestamp", + "columns": [ + { + "expression": "timestamp", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_audit_logs_user_ts": { + "name": "idx_audit_logs_user_ts", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "timestamp", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_audit_logs_action_ts": { + "name": "idx_audit_logs_action_ts", + "columns": [ + { + "expression": "action", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "timestamp", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_audit_logs_resource_ts": { + "name": "idx_audit_logs_resource_ts", + "columns": [ + { + "expression": "resource_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "timestamp", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "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.credential_access": { + "name": "credential_access", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "credential_id": { + "name": "credential_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": "'use'" + }, + "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": { + "idx_credential_access_user_id": { + "name": "idx_credential_access_user_id", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_credential_access_role_id": { + "name": "idx_credential_access_role_id", + "columns": [ + { + "expression": "role_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_credential_access_credential_id": { + "name": "idx_credential_access_credential_id", + "columns": [ + { + "expression": "credential_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "credential_access_credential_id_ssh_credentials_id_fk": { + "name": "credential_access_credential_id_ssh_credentials_id_fk", + "tableFrom": "credential_access", + "tableTo": "ssh_credentials", + "columnsFrom": [ + "credential_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_access_user_id_users_id_fk": { + "name": "credential_access_user_id_users_id_fk", + "tableFrom": "credential_access", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_access_role_id_roles_id_fk": { + "name": "credential_access_role_id_roles_id_fk", + "tableFrom": "credential_access", + "tableTo": "roles", + "columnsFrom": [ + "role_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_access_granted_by_users_id_fk": { + "name": "credential_access_granted_by_users_id_fk", + "tableFrom": "credential_access", + "tableTo": "users", + "columnsFrom": [ + "granted_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.credential_sidebar_preferences": { + "name": "credential_sidebar_preferences", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "data": { + "name": "data", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": {}, + "foreignKeys": { + "credential_sidebar_preferences_user_id_users_id_fk": { + "name": "credential_sidebar_preferences_user_id_users_id_fk", + "tableFrom": "credential_sidebar_preferences", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.folder_access": { + "name": "folder_access", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "folder": { + "name": "folder", + "type": "varchar(255)", + "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" + } + }, + "indexes": { + "idx_folder_access_owner_folder": { + "name": "idx_folder_access_owner_folder", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "folder", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "folder_access_owner_user_id_users_id_fk": { + "name": "folder_access_owner_user_id_users_id_fk", + "tableFrom": "folder_access", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "folder_access_user_id_users_id_fk": { + "name": "folder_access_user_id_users_id_fk", + "tableFrom": "folder_access", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "folder_access_role_id_roles_id_fk": { + "name": "folder_access_role_id_roles_id_fk", + "tableFrom": "folder_access", + "tableTo": "roles", + "columnsFrom": [ + "role_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "folder_access_granted_by_users_id_fk": { + "name": "folder_access_granted_by_users_id_fk", + "tableFrom": "folder_access", + "tableTo": "users", + "columnsFrom": [ + "granted_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "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": "varchar(255)", + "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": { + "idx_host_access_user_id": { + "name": "idx_host_access_user_id", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_host_access_role_id": { + "name": "idx_host_access_role_id", + "columns": [ + { + "expression": "role_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_host_access_host_id": { + "name": "idx_host_access_host_id", + "columns": [ + { + "expression": "host_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_host_access_expires_at": { + "name": "idx_host_access_expires_at", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "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_defaults": { + "name": "host_defaults", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "level": { + "name": "level", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scope_key": { + "name": "scope_key", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "folder_id": { + "name": "folder_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "namespace": { + "name": "namespace", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_by": { + "name": "updated_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "idx_host_defaults_scope_key": { + "name": "idx_host_defaults_scope_key", + "columns": [ + { + "expression": "scope_key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "namespace", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_host_defaults_user": { + "name": "idx_host_defaults_user", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_host_defaults_folder": { + "name": "idx_host_defaults_folder", + "columns": [ + { + "expression": "folder_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "host_defaults_user_id_users_id_fk": { + "name": "host_defaults_user_id_users_id_fk", + "tableFrom": "host_defaults", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "host_defaults_folder_id_ssh_folders_id_fk": { + "name": "host_defaults_folder_id_ssh_folders_id_fk", + "tableFrom": "host_defaults", + "tableTo": "ssh_folders", + "columnsFrom": [ + "folder_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.host_protocol_auth": { + "name": "host_protocol_auth", + "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 + }, + "auth_type": { + "name": "auth_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'direct'" + }, + "credential_id": { + "name": "credential_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "fields": { + "name": "fields", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "secret_fields": { + "name": "secret_fields", + "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_host_protocol_auth_host_protocol": { + "name": "idx_host_protocol_auth_host_protocol", + "columns": [ + { + "expression": "host_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "protocol", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_host_protocol_auth_user": { + "name": "idx_host_protocol_auth_user", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_host_protocol_auth_credential": { + "name": "idx_host_protocol_auth_credential", + "columns": [ + { + "expression": "credential_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "host_protocol_auth_host_id_ssh_data_id_fk": { + "name": "host_protocol_auth_host_id_ssh_data_id_fk", + "tableFrom": "host_protocol_auth", + "tableTo": "ssh_data", + "columnsFrom": [ + "host_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "host_protocol_auth_user_id_users_id_fk": { + "name": "host_protocol_auth_user_id_users_id_fk", + "tableFrom": "host_protocol_auth", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "host_protocol_auth_credential_id_ssh_credentials_id_fk": { + "name": "host_protocol_auth_credential_id_ssh_credentials_id_fk", + "tableFrom": "host_protocol_auth", + "tableTo": "ssh_credentials", + "columnsFrom": [ + "credential_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.host_sidebar_preferences": { + "name": "host_sidebar_preferences", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "data": { + "name": "data", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": {}, + "foreignKeys": { + "host_sidebar_preferences_user_id_users_id_fk": { + "name": "host_sidebar_preferences_user_id_users_id_fk", + "tableFrom": "host_sidebar_preferences", + "tableTo": "users", + "columnsFrom": [ + "user_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": "text", + "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 + }, + "parent_host_id": { + "name": "parent_host_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "tags": { + "name": "tags", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "pin": { + "name": "pin", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "auth_type": { + "name": "auth_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "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 + }, + "credential_id": { + "name": "credential_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "override_credential_username": { + "name": "override_credential_username", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "jump_hosts": { + "name": "jump_hosts", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status_check_enabled": { + "name": "status_check_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "status_check_interval": { + "name": "status_check_interval", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "ssh_options": { + "name": "ssh_options", + "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 + }, + "ssh_port": { + "name": "ssh_port", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 22 + }, + "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 + }, + "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 + }, + "local_only": { + "name": "local_only", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "shared_source": { + "name": "shared_source", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "default_overrides": { + "name": "default_overrides", + "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_ssh_data_user_id": { + "name": "idx_ssh_data_user_id", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_ssh_data_parent_host": { + "name": "idx_ssh_data_parent_host", + "columns": [ + { + "expression": "parent_host_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_ssh_data_credential": { + "name": "idx_ssh_data_credential", + "columns": [ + { + "expression": "credential_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "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_parent_host_id_ssh_data_id_fk": { + "name": "ssh_data_parent_host_id_ssh_data_id_fk", + "tableFrom": "ssh_data", + "tableTo": "ssh_data", + "columnsFrom": [ + "parent_host_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "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" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "ssh_data_sync_id_unique": { + "name": "ssh_data_sync_id_unique", + "nullsNotDistinct": false, + "columns": [ + "sync_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.plugin_install_counts": { + "name": "plugin_install_counts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "plugin_id": { + "name": "plugin_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "registry_id": { + "name": "registry_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "count": { + "name": "count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "idx_plugin_install_counts_plugin_registry": { + "name": "idx_plugin_install_counts_plugin_registry", + "columns": [ + { + "expression": "plugin_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "registry_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.plugin_migrations": { + "name": "plugin_migrations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "plugin_id": { + "name": "plugin_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "migration_id": { + "name": "migration_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "checksum": { + "name": "checksum", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "applied_at": { + "name": "applied_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "idx_plugin_migrations_plugin_migration": { + "name": "idx_plugin_migrations_plugin_migration", + "columns": [ + { + "expression": "plugin_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "migration_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "plugin_migrations_plugin_id_plugins_id_fk": { + "name": "plugin_migrations_plugin_id_plugins_id_fk", + "tableFrom": "plugin_migrations", + "tableTo": "plugins", + "columnsFrom": [ + "plugin_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.plugin_permission_grants": { + "name": "plugin_permission_grants", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "plugin_id": { + "name": "plugin_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "capability": { + "name": "capability", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "granted_at": { + "name": "granted_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "granted_by": { + "name": "granted_by", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'admin'" + } + }, + "indexes": { + "idx_plugin_permission_grants_plugin_capability": { + "name": "idx_plugin_permission_grants_plugin_capability", + "columns": [ + { + "expression": "plugin_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "capability", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "plugin_permission_grants_plugin_id_plugins_id_fk": { + "name": "plugin_permission_grants_plugin_id_plugins_id_fk", + "tableFrom": "plugin_permission_grants", + "tableTo": "plugins", + "columnsFrom": [ + "plugin_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "plugin_permission_grants_granted_by_users_id_fk": { + "name": "plugin_permission_grants_granted_by_users_id_fk", + "tableFrom": "plugin_permission_grants", + "tableTo": "users", + "columnsFrom": [ + "granted_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.plugin_registries": { + "name": "plugin_registries", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'community'" + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "signing_key": { + "name": "signing_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_checked_at": { + "name": "last_checked_at", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_index_hash": { + "name": "last_index_hash", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.plugin_settings": { + "name": "plugin_settings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "plugin_id": { + "name": "plugin_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "scope": { + "name": "scope", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "scope_id": { + "name": "scope_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "key": { + "name": "key", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "encrypted": { + "name": "encrypted", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "idx_plugin_settings_scope_key": { + "name": "idx_plugin_settings_scope_key", + "columns": [ + { + "expression": "plugin_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "scope", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "scope_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_plugin_settings_plugin_scope": { + "name": "idx_plugin_settings_plugin_scope", + "columns": [ + { + "expression": "plugin_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "scope", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "plugin_settings_plugin_id_plugins_id_fk": { + "name": "plugin_settings_plugin_id_plugins_id_fk", + "tableFrom": "plugin_settings", + "tableTo": "plugins", + "columnsFrom": [ + "plugin_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.plugin_storage": { + "name": "plugin_storage", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "plugin_id": { + "name": "plugin_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "idx_plugin_storage_plugin_key": { + "name": "idx_plugin_storage_plugin_key", + "columns": [ + { + "expression": "plugin_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "plugin_storage_plugin_id_plugins_id_fk": { + "name": "plugin_storage_plugin_id_plugins_id_fk", + "tableFrom": "plugin_storage", + "tableTo": "plugins", + "columnsFrom": [ + "plugin_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.plugins": { + "name": "plugins", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "version": { + "name": "version", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tier": { + "name": "tier", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'available'" + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'community'" + }, + "registry_id": { + "name": "registry_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'disabled'" + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "installed_at": { + "name": "installed_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "auto_update": { + "name": "auto_update", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "pinned_version": { + "name": "pinned_version", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "channel": { + "name": "channel", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'stable'" + }, + "manifest_json": { + "name": "manifest_json", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "idx_plugins_registry_id": { + "name": "idx_plugins_registry_id", + "columns": [ + { + "expression": "registry_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.rbac_applied_defaults": { + "name": "rbac_applied_defaults", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "role_name": { + "name": "role_name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "permission": { + "name": "permission", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "applied_at": { + "name": "applied_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "idx_rbac_applied_defaults_role_permission": { + "name": "idx_rbac_applied_defaults_role_permission", + "columns": [ + { + "expression": "role_name", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "permission", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.rbac_known_permissions": { + "name": "rbac_known_permissions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "permission": { + "name": "permission", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "plugin_id": { + "name": "plugin_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "first_seen_at": { + "name": "first_seen_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "idx_rbac_known_permissions_permission": { + "name": "idx_rbac_known_permissions_permission", + "columns": [ + { + "expression": "permission", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "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": "varchar(255)", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "idx_recent_activity_user_ts": { + "name": "idx_recent_activity_user_ts", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "timestamp", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "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.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 + }, + "external_session_ref": { + "name": "external_session_ref", + "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": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "last_active_at": { + "name": "last_active_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "idx_sessions_user_id": { + "name": "idx_sessions_user_id", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_sessions_expires_at": { + "name": "idx_sessions_expires_at", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "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_credential_secrets": { + "name": "shared_credential_secrets", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "credential_access_id": { + "name": "credential_access_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "target_user_id": { + "name": "target_user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "credential_id": { + "name": "credential_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "encrypted_username": { + "name": "encrypted_username", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "auth_type": { + "name": "auth_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'password'" + }, + "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 + }, + "key_type": { + "name": "key_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "public_key": { + "name": "public_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cert_public_key": { + "name": "cert_public_key", + "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_credential_secrets_scope": { + "name": "idx_shared_credential_secrets_scope", + "columns": [ + { + "expression": "credential_access_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_shared_credential_secrets_target": { + "name": "idx_shared_credential_secrets_target", + "columns": [ + { + "expression": "target_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "credential_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "shared_credential_secrets_target_user_id_users_id_fk": { + "name": "shared_credential_secrets_target_user_id_users_id_fk", + "tableFrom": "shared_credential_secrets", + "tableTo": "users", + "columnsFrom": [ + "target_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "shared_credential_secrets_credential_id_ssh_credentials_id_fk": { + "name": "shared_credential_secrets_credential_id_ssh_credentials_id_fk", + "tableFrom": "shared_credential_secrets", + "tableTo": "ssh_credentials", + "columnsFrom": [ + "credential_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "shared_cred_secrets_access_id_fk": { + "name": "shared_cred_secrets_access_id_fk", + "tableFrom": "shared_credential_secrets", + "tableTo": "credential_access", + "columnsFrom": [ + "credential_access_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "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 + }, + "encrypted_fields": { + "name": "encrypted_fields", + "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.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": { + "idx_ssh_credential_usage_credential": { + "name": "idx_ssh_credential_usage_credential", + "columns": [ + { + "expression": "credential_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_ssh_credential_usage_user": { + "name": "idx_ssh_credential_usage_user", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "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": "text", + "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 + }, + "pin": { + "name": "pin", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "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 + }, + "shared_source": { + "name": "shared_source", + "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_ssh_credentials_user_id": { + "name": "idx_ssh_credentials_user_id", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "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": "text", + "primaryKey": false, + "notNull": true + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "icon": { + "name": "icon", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "sync_id": { + "name": "sync_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "local_only": { + "name": "local_only", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": 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_ssh_folders_user_id": { + "name": "idx_ssh_folders_user_id", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "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" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "ssh_folders_sync_id_unique": { + "name": "ssh_folders_sync_id_unique", + "nullsNotDistinct": false, + "columns": [ + "sync_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sync_conflicts": { + "name": "sync_conflicts", + "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": "text", + "primaryKey": false, + "notNull": true + }, + "local_row": { + "name": "local_row", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "server_revision": { + "name": "server_revision", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "idx_sync_conflicts_user": { + "name": "idx_sync_conflicts_user", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "sync_conflicts_user_id_users_id_fk": { + "name": "sync_conflicts_user_id_users_id_fk", + "tableFrom": "sync_conflicts", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sync_link": { + "name": "sync_link", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "server_url": { + "name": "server_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "server_name": { + "name": "server_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "server_version": { + "name": "server_version", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "session_token": { + "name": "session_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "custom_headers": { + "name": "custom_headers", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "basic_auth": { + "name": "basic_auth", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "allow_invalid_certificate": { + "name": "allow_invalid_certificate", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "remote_user_id": { + "name": "remote_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "remote_username": { + "name": "remote_username", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "account": { + "name": "account", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "known_types": { + "name": "known_types", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cursor": { + "name": "cursor", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'idle'" + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "linked_at": { + "name": "linked_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "last_sync_at": { + "name": "last_sync_at", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "sync_link_user_id_users_id_fk": { + "name": "sync_link_user_id_users_id_fk", + "tableFrom": "sync_link", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sync_records": { + "name": "sync_records", + "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": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "sync_id": { + "name": "sync_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "revision": { + "name": "revision", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "seq": { + "name": "seq", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "hash": { + "name": "hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "deleted": { + "name": "deleted", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error_hash": { + "name": "error_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "idx_sync_records_user_entity_sync": { + "name": "idx_sync_records_user_entity_sync", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "sync_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_sync_records_user_seq": { + "name": "idx_sync_records_user_seq", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "seq", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "sync_records_user_id_users_id_fk": { + "name": "sync_records_user_id_users_id_fk", + "tableFrom": "sync_records", + "tableTo": "users", + "columnsFrom": [ + "user_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": { + "idx_trusted_devices_user_id": { + "name": "idx_trusted_devices_user_id", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "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.ui_preferences": { + "name": "ui_preferences", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "data": { + "name": "data", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": {}, + "foreignKeys": { + "ui_preferences_user_id_users_id_fk": { + "name": "ui_preferences_user_id_users_id_fk", + "tableFrom": "ui_preferences", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_external_identities": { + "name": "user_external_identities", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "subject": { + "name": "subject", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "idx_user_external_identities_provider_subject": { + "name": "idx_user_external_identities_provider_subject", + "columns": [ + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "subject", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_user_external_identities_user": { + "name": "idx_user_external_identities_user", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_external_identities_user_id_users_id_fk": { + "name": "user_external_identities_user_id_users_id_fk", + "tableFrom": "user_external_identities", + "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": { + "idx_user_open_tabs_user_id": { + "name": "idx_user_open_tabs_user_id", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "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_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 + }, + "show_pin_app_rail_button": { + "name": "show_pin_app_rail_button", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "folders_collapsed": { + "name": "folders_collapsed", + "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_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": {} + }, + "idx_user_roles_role_id": { + "name": "idx_user_roles_role_id", + "columns": [ + { + "expression": "role_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "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.user_second_factors": { + "name": "user_second_factors", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "plugin_id": { + "name": "plugin_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "factor_id": { + "name": "factor_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "enrolled_at": { + "name": "enrolled_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "idx_user_second_factors_user_factor": { + "name": "idx_user_second_factors_user_factor", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "plugin_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "factor_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_second_factors_user_id_users_id_fk": { + "name": "user_second_factors_user_id_users_id_fk", + "tableFrom": "user_second_factors", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "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": "varchar(255)", + "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 + }, + "registered_at": { + "name": "registered_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "idx_users_username": { + "name": "idx_users_username", + "columns": [ + { + "expression": "username", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "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 index 14c7a0834..d2025bbdd 100644 --- a/drizzle/postgres/meta/_journal.json +++ b/drizzle/postgres/meta/_journal.json @@ -505,6 +505,13 @@ "when": 1791755800419, "tag": "0071_drop_install_count_source", "breakpoints": true + }, + { + "idx": 72, + "version": "7", + "when": 1791935282340, + "tag": "0072_unique_username", + "breakpoints": true } ] } \ No newline at end of file diff --git a/drizzle/sqlite/0067_unique_username.sql b/drizzle/sqlite/0067_unique_username.sql new file mode 100644 index 000000000..668231ccf --- /dev/null +++ b/drizzle/sqlite/0067_unique_username.sql @@ -0,0 +1,17 @@ +-- Choose an unused prefix from N + 1 disjoint prefixes for N existing users. +WITH ranked AS MATERIALIZED ( + SELECT id, username, + ROW_NUMBER() OVER (ORDER BY registered_at, id) AS sequence, + ROW_NUMBER() OVER (PARTITION BY username ORDER BY registered_at, id) AS occurrence + FROM users +), candidates AS ( + SELECT 0 AS candidate UNION ALL SELECT sequence FROM ranked +), available AS ( + SELECT MIN(candidate) AS candidate FROM candidates + WHERE NOT EXISTS ( + SELECT 1 FROM ranked WHERE username LIKE 'termix-recovered-' || candidate || '-' || '%' + ) +) +UPDATE users SET username = 'termix-recovered-' || (SELECT candidate FROM available) || '-' || ranked.sequence +FROM ranked WHERE ranked.id = users.id AND ranked.occurrence > 1;--> statement-breakpoint +CREATE UNIQUE INDEX `idx_users_username` ON `users` (`username`); diff --git a/drizzle/sqlite/meta/0067_snapshot.json b/drizzle/sqlite/meta/0067_snapshot.json new file mode 100644 index 000000000..94211eceb --- /dev/null +++ b/drizzle/sqlite/meta/0067_snapshot.json @@ -0,0 +1,4466 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "689622f2-8e11-46a3-bac9-b2c55444b3a2", + "prevId": "2c3cbe37-a6d6-49cf-9dc4-73466f5a1e99", + "tables": { + "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": { + "idx_api_keys_user_id": { + "name": "idx_api_keys_user_id", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "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": { + "idx_audit_logs_timestamp": { + "name": "idx_audit_logs_timestamp", + "columns": [ + "timestamp" + ], + "isUnique": false + }, + "idx_audit_logs_user_ts": { + "name": "idx_audit_logs_user_ts", + "columns": [ + "user_id", + "timestamp" + ], + "isUnique": false + }, + "idx_audit_logs_action_ts": { + "name": "idx_audit_logs_action_ts", + "columns": [ + "action", + "timestamp" + ], + "isUnique": false + }, + "idx_audit_logs_resource_ts": { + "name": "idx_audit_logs_resource_ts", + "columns": [ + "resource_type", + "timestamp" + ], + "isUnique": false + } + }, + "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": {} + }, + "credential_access": { + "name": "credential_access", + "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 + }, + "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": "'use'" + }, + "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": { + "idx_credential_access_user_id": { + "name": "idx_credential_access_user_id", + "columns": [ + "user_id" + ], + "isUnique": false + }, + "idx_credential_access_role_id": { + "name": "idx_credential_access_role_id", + "columns": [ + "role_id" + ], + "isUnique": false + }, + "idx_credential_access_credential_id": { + "name": "idx_credential_access_credential_id", + "columns": [ + "credential_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "credential_access_credential_id_ssh_credentials_id_fk": { + "name": "credential_access_credential_id_ssh_credentials_id_fk", + "tableFrom": "credential_access", + "tableTo": "ssh_credentials", + "columnsFrom": [ + "credential_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_access_user_id_users_id_fk": { + "name": "credential_access_user_id_users_id_fk", + "tableFrom": "credential_access", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_access_role_id_roles_id_fk": { + "name": "credential_access_role_id_roles_id_fk", + "tableFrom": "credential_access", + "tableTo": "roles", + "columnsFrom": [ + "role_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_access_granted_by_users_id_fk": { + "name": "credential_access_granted_by_users_id_fk", + "tableFrom": "credential_access", + "tableTo": "users", + "columnsFrom": [ + "granted_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "credential_sidebar_preferences": { + "name": "credential_sidebar_preferences", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "data": { + "name": "data", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": {}, + "foreignKeys": { + "credential_sidebar_preferences_user_id_users_id_fk": { + "name": "credential_sidebar_preferences_user_id_users_id_fk", + "tableFrom": "credential_sidebar_preferences", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "folder_access": { + "name": "folder_access", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "folder": { + "name": "folder", + "type": "text", + "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" + } + }, + "indexes": { + "idx_folder_access_owner_folder": { + "name": "idx_folder_access_owner_folder", + "columns": [ + "owner_user_id", + "folder" + ], + "isUnique": false + } + }, + "foreignKeys": { + "folder_access_owner_user_id_users_id_fk": { + "name": "folder_access_owner_user_id_users_id_fk", + "tableFrom": "folder_access", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "folder_access_user_id_users_id_fk": { + "name": "folder_access_user_id_users_id_fk", + "tableFrom": "folder_access", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "folder_access_role_id_roles_id_fk": { + "name": "folder_access_role_id_roles_id_fk", + "tableFrom": "folder_access", + "tableTo": "roles", + "columnsFrom": [ + "role_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "folder_access_granted_by_users_id_fk": { + "name": "folder_access_granted_by_users_id_fk", + "tableFrom": "folder_access", + "tableTo": "users", + "columnsFrom": [ + "granted_by" + ], + "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": { + "idx_host_access_user_id": { + "name": "idx_host_access_user_id", + "columns": [ + "user_id" + ], + "isUnique": false + }, + "idx_host_access_role_id": { + "name": "idx_host_access_role_id", + "columns": [ + "role_id" + ], + "isUnique": false + }, + "idx_host_access_host_id": { + "name": "idx_host_access_host_id", + "columns": [ + "host_id" + ], + "isUnique": false + }, + "idx_host_access_expires_at": { + "name": "idx_host_access_expires_at", + "columns": [ + "expires_at" + ], + "isUnique": false + } + }, + "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_defaults": { + "name": "host_defaults", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "level": { + "name": "level", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "scope_key": { + "name": "scope_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "folder_id": { + "name": "folder_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "namespace": { + "name": "namespace", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "updated_by": { + "name": "updated_by", + "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": { + "idx_host_defaults_scope_key": { + "name": "idx_host_defaults_scope_key", + "columns": [ + "scope_key", + "namespace", + "key" + ], + "isUnique": true + }, + "idx_host_defaults_user": { + "name": "idx_host_defaults_user", + "columns": [ + "user_id" + ], + "isUnique": false + }, + "idx_host_defaults_folder": { + "name": "idx_host_defaults_folder", + "columns": [ + "folder_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "host_defaults_user_id_users_id_fk": { + "name": "host_defaults_user_id_users_id_fk", + "tableFrom": "host_defaults", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "host_defaults_folder_id_ssh_folders_id_fk": { + "name": "host_defaults_folder_id_ssh_folders_id_fk", + "tableFrom": "host_defaults", + "tableTo": "ssh_folders", + "columnsFrom": [ + "folder_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "host_protocol_auth": { + "name": "host_protocol_auth", + "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 + }, + "auth_type": { + "name": "auth_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'direct'" + }, + "credential_id": { + "name": "credential_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "fields": { + "name": "fields", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "secret_fields": { + "name": "secret_fields", + "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_host_protocol_auth_host_protocol": { + "name": "idx_host_protocol_auth_host_protocol", + "columns": [ + "host_id", + "protocol" + ], + "isUnique": true + }, + "idx_host_protocol_auth_user": { + "name": "idx_host_protocol_auth_user", + "columns": [ + "user_id" + ], + "isUnique": false + }, + "idx_host_protocol_auth_credential": { + "name": "idx_host_protocol_auth_credential", + "columns": [ + "credential_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "host_protocol_auth_host_id_ssh_data_id_fk": { + "name": "host_protocol_auth_host_id_ssh_data_id_fk", + "tableFrom": "host_protocol_auth", + "tableTo": "ssh_data", + "columnsFrom": [ + "host_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "host_protocol_auth_user_id_users_id_fk": { + "name": "host_protocol_auth_user_id_users_id_fk", + "tableFrom": "host_protocol_auth", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "host_protocol_auth_credential_id_ssh_credentials_id_fk": { + "name": "host_protocol_auth_credential_id_ssh_credentials_id_fk", + "tableFrom": "host_protocol_auth", + "tableTo": "ssh_credentials", + "columnsFrom": [ + "credential_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "host_sidebar_preferences": { + "name": "host_sidebar_preferences", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "data": { + "name": "data", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": {}, + "foreignKeys": { + "host_sidebar_preferences_user_id_users_id_fk": { + "name": "host_sidebar_preferences_user_id_users_id_fk", + "tableFrom": "host_sidebar_preferences", + "tableTo": "users", + "columnsFrom": [ + "user_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 + }, + "parent_host_id": { + "name": "parent_host_id", + "type": "integer", + "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 + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "auth_type": { + "name": "auth_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": 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 + }, + "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 + }, + "jump_hosts": { + "name": "jump_hosts", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status_check_enabled": { + "name": "status_check_enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "status_check_interval": { + "name": "status_check_interval", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "ssh_options": { + "name": "ssh_options", + "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 + }, + "ssh_port": { + "name": "ssh_port", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 22 + }, + "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 + }, + "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 + }, + "local_only": { + "name": "local_only", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "shared_source": { + "name": "shared_source", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "default_overrides": { + "name": "default_overrides", + "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 + }, + "idx_ssh_data_user_id": { + "name": "idx_ssh_data_user_id", + "columns": [ + "user_id" + ], + "isUnique": false + }, + "idx_ssh_data_parent_host": { + "name": "idx_ssh_data_parent_host", + "columns": [ + "parent_host_id" + ], + "isUnique": false + }, + "idx_ssh_data_credential": { + "name": "idx_ssh_data_credential", + "columns": [ + "credential_id" + ], + "isUnique": false + } + }, + "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_parent_host_id_ssh_data_id_fk": { + "name": "ssh_data_parent_host_id_ssh_data_id_fk", + "tableFrom": "ssh_data", + "tableTo": "ssh_data", + "columnsFrom": [ + "parent_host_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "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" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "plugin_install_counts": { + "name": "plugin_install_counts", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "plugin_id": { + "name": "plugin_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "registry_id": { + "name": "registry_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "count": { + "name": "count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "idx_plugin_install_counts_plugin_registry": { + "name": "idx_plugin_install_counts_plugin_registry", + "columns": [ + "plugin_id", + "registry_id" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "plugin_migrations": { + "name": "plugin_migrations", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "plugin_id": { + "name": "plugin_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "migration_id": { + "name": "migration_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "checksum": { + "name": "checksum", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "applied_at": { + "name": "applied_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "idx_plugin_migrations_plugin_migration": { + "name": "idx_plugin_migrations_plugin_migration", + "columns": [ + "plugin_id", + "migration_id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "plugin_migrations_plugin_id_plugins_id_fk": { + "name": "plugin_migrations_plugin_id_plugins_id_fk", + "tableFrom": "plugin_migrations", + "tableTo": "plugins", + "columnsFrom": [ + "plugin_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "plugin_permission_grants": { + "name": "plugin_permission_grants", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "plugin_id": { + "name": "plugin_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "capability": { + "name": "capability", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "granted_at": { + "name": "granted_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "granted_by": { + "name": "granted_by", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'admin'" + } + }, + "indexes": { + "idx_plugin_permission_grants_plugin_capability": { + "name": "idx_plugin_permission_grants_plugin_capability", + "columns": [ + "plugin_id", + "capability" + ], + "isUnique": true + } + }, + "foreignKeys": { + "plugin_permission_grants_plugin_id_plugins_id_fk": { + "name": "plugin_permission_grants_plugin_id_plugins_id_fk", + "tableFrom": "plugin_permission_grants", + "tableTo": "plugins", + "columnsFrom": [ + "plugin_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "plugin_permission_grants_granted_by_users_id_fk": { + "name": "plugin_permission_grants_granted_by_users_id_fk", + "tableFrom": "plugin_permission_grants", + "tableTo": "users", + "columnsFrom": [ + "granted_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "plugin_registries": { + "name": "plugin_registries", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'community'" + }, + "enabled": { + "name": "enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "signing_key": { + "name": "signing_key", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_checked_at": { + "name": "last_checked_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_index_hash": { + "name": "last_index_hash", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "plugin_settings": { + "name": "plugin_settings", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "plugin_id": { + "name": "plugin_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "scope_id": { + "name": "scope_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "encrypted": { + "name": "encrypted", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "idx_plugin_settings_scope_key": { + "name": "idx_plugin_settings_scope_key", + "columns": [ + "plugin_id", + "scope", + "scope_id", + "key" + ], + "isUnique": true + }, + "idx_plugin_settings_plugin_scope": { + "name": "idx_plugin_settings_plugin_scope", + "columns": [ + "plugin_id", + "scope" + ], + "isUnique": false + } + }, + "foreignKeys": { + "plugin_settings_plugin_id_plugins_id_fk": { + "name": "plugin_settings_plugin_id_plugins_id_fk", + "tableFrom": "plugin_settings", + "tableTo": "plugins", + "columnsFrom": [ + "plugin_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "plugin_storage": { + "name": "plugin_storage", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "plugin_id": { + "name": "plugin_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "idx_plugin_storage_plugin_key": { + "name": "idx_plugin_storage_plugin_key", + "columns": [ + "plugin_id", + "key" + ], + "isUnique": true + } + }, + "foreignKeys": { + "plugin_storage_plugin_id_plugins_id_fk": { + "name": "plugin_storage_plugin_id_plugins_id_fk", + "tableFrom": "plugin_storage", + "tableTo": "plugins", + "columnsFrom": [ + "plugin_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "plugins": { + "name": "plugins", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "version": { + "name": "version", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "tier": { + "name": "tier", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'available'" + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'community'" + }, + "registry_id": { + "name": "registry_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'disabled'" + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "installed_at": { + "name": "installed_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" + }, + "auto_update": { + "name": "auto_update", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "pinned_version": { + "name": "pinned_version", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "channel": { + "name": "channel", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'stable'" + }, + "manifest_json": { + "name": "manifest_json", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_plugins_registry_id": { + "name": "idx_plugins_registry_id", + "columns": [ + "registry_id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "rbac_applied_defaults": { + "name": "rbac_applied_defaults", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "role_name": { + "name": "role_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "permission": { + "name": "permission", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "applied_at": { + "name": "applied_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "idx_rbac_applied_defaults_role_permission": { + "name": "idx_rbac_applied_defaults_role_permission", + "columns": [ + "role_name", + "permission" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "rbac_known_permissions": { + "name": "rbac_known_permissions", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "permission": { + "name": "permission", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "plugin_id": { + "name": "plugin_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "first_seen_at": { + "name": "first_seen_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "idx_rbac_known_permissions_permission": { + "name": "idx_rbac_known_permissions_permission", + "columns": [ + "permission" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "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": { + "idx_recent_activity_user_ts": { + "name": "idx_recent_activity_user_ts", + "columns": [ + "user_id", + "timestamp" + ], + "isUnique": false + } + }, + "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": {} + }, + "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 + }, + "external_session_ref": { + "name": "external_session_ref", + "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_active_at": { + "name": "last_active_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "idx_sessions_user_id": { + "name": "idx_sessions_user_id", + "columns": [ + "user_id" + ], + "isUnique": false + }, + "idx_sessions_expires_at": { + "name": "idx_sessions_expires_at", + "columns": [ + "expires_at" + ], + "isUnique": false + } + }, + "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_credential_secrets": { + "name": "shared_credential_secrets", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "credential_access_id": { + "name": "credential_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 + }, + "credential_id": { + "name": "credential_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "encrypted_username": { + "name": "encrypted_username", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "auth_type": { + "name": "auth_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'password'" + }, + "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 + }, + "key_type": { + "name": "key_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "public_key": { + "name": "public_key", + "type": "text(4096)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cert_public_key": { + "name": "cert_public_key", + "type": "text(8192)", + "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_credential_secrets_scope": { + "name": "idx_shared_credential_secrets_scope", + "columns": [ + "credential_access_id", + "target_user_id" + ], + "isUnique": true + }, + "idx_shared_credential_secrets_target": { + "name": "idx_shared_credential_secrets_target", + "columns": [ + "target_user_id", + "credential_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "shared_credential_secrets_target_user_id_users_id_fk": { + "name": "shared_credential_secrets_target_user_id_users_id_fk", + "tableFrom": "shared_credential_secrets", + "tableTo": "users", + "columnsFrom": [ + "target_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "shared_credential_secrets_credential_id_ssh_credentials_id_fk": { + "name": "shared_credential_secrets_credential_id_ssh_credentials_id_fk", + "tableFrom": "shared_credential_secrets", + "tableTo": "ssh_credentials", + "columnsFrom": [ + "credential_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "shared_cred_secrets_access_id_fk": { + "name": "shared_cred_secrets_access_id_fk", + "tableFrom": "shared_credential_secrets", + "tableTo": "credential_access", + "columnsFrom": [ + "credential_access_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "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 + }, + "encrypted_fields": { + "name": "encrypted_fields", + "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": {} + }, + "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": { + "idx_ssh_credential_usage_credential": { + "name": "idx_ssh_credential_usage_credential", + "columns": [ + "credential_id" + ], + "isUnique": false + }, + "idx_ssh_credential_usage_user": { + "name": "idx_ssh_credential_usage_user", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "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 + }, + "pin": { + "name": "pin", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "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 + }, + "shared_source": { + "name": "shared_source", + "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 + }, + "idx_ssh_credentials_user_id": { + "name": "idx_ssh_credentials_user_id", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "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 + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sync_id": { + "name": "sync_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "local_only": { + "name": "local_only", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 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 + }, + "idx_ssh_folders_user_id": { + "name": "idx_ssh_folders_user_id", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "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" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "sync_conflicts": { + "name": "sync_conflicts", + "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 + }, + "local_row": { + "name": "local_row", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "server_revision": { + "name": "server_revision", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "idx_sync_conflicts_user": { + "name": "idx_sync_conflicts_user", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "sync_conflicts_user_id_users_id_fk": { + "name": "sync_conflicts_user_id_users_id_fk", + "tableFrom": "sync_conflicts", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "sync_link": { + "name": "sync_link", + "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 + }, + "server_url": { + "name": "server_url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "server_name": { + "name": "server_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "server_version": { + "name": "server_version", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "session_token": { + "name": "session_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "custom_headers": { + "name": "custom_headers", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "basic_auth": { + "name": "basic_auth", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "allow_invalid_certificate": { + "name": "allow_invalid_certificate", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "remote_user_id": { + "name": "remote_user_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "remote_username": { + "name": "remote_username", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "account": { + "name": "account", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "known_types": { + "name": "known_types", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cursor": { + "name": "cursor", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'idle'" + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "linked_at": { + "name": "linked_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "last_sync_at": { + "name": "last_sync_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "sync_link_user_id_users_id_fk": { + "name": "sync_link_user_id_users_id_fk", + "tableFrom": "sync_link", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "sync_records": { + "name": "sync_records", + "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 + }, + "revision": { + "name": "revision", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "seq": { + "name": "seq", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "hash": { + "name": "hash", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "deleted": { + "name": "deleted", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "error_hash": { + "name": "error_hash", + "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": { + "idx_sync_records_user_entity_sync": { + "name": "idx_sync_records_user_entity_sync", + "columns": [ + "user_id", + "entity_type", + "sync_id" + ], + "isUnique": true + }, + "idx_sync_records_user_seq": { + "name": "idx_sync_records_user_seq", + "columns": [ + "user_id", + "seq" + ], + "isUnique": false + } + }, + "foreignKeys": { + "sync_records_user_id_users_id_fk": { + "name": "sync_records_user_id_users_id_fk", + "tableFrom": "sync_records", + "tableTo": "users", + "columnsFrom": [ + "user_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": { + "idx_trusted_devices_user_id": { + "name": "idx_trusted_devices_user_id", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "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": {} + }, + "ui_preferences": { + "name": "ui_preferences", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "data": { + "name": "data", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": {}, + "foreignKeys": { + "ui_preferences_user_id_users_id_fk": { + "name": "ui_preferences_user_id_users_id_fk", + "tableFrom": "ui_preferences", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "user_external_identities": { + "name": "user_external_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 + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "subject": { + "name": "subject", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email": { + "name": "email", + "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": { + "idx_user_external_identities_provider_subject": { + "name": "idx_user_external_identities_provider_subject", + "columns": [ + "provider_id", + "subject" + ], + "isUnique": true + }, + "idx_user_external_identities_user": { + "name": "idx_user_external_identities_user", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "user_external_identities_user_id_users_id_fk": { + "name": "user_external_identities_user_id_users_id_fk", + "tableFrom": "user_external_identities", + "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": { + "idx_user_open_tabs_user_id": { + "name": "idx_user_open_tabs_user_id", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "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_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 + }, + "show_pin_app_rail_button": { + "name": "show_pin_app_rail_button", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "folders_collapsed": { + "name": "folders_collapsed", + "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_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 + }, + "idx_user_roles_role_id": { + "name": "idx_user_roles_role_id", + "columns": [ + "role_id" + ], + "isUnique": false + } + }, + "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": {} + }, + "user_second_factors": { + "name": "user_second_factors", + "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 + }, + "plugin_id": { + "name": "plugin_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "factor_id": { + "name": "factor_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "enrolled_at": { + "name": "enrolled_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "idx_user_second_factors_user_factor": { + "name": "idx_user_second_factors_user_factor", + "columns": [ + "user_id", + "plugin_id", + "factor_id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "user_second_factors_user_id_users_id_fk": { + "name": "user_second_factors_user_id_users_id_fk", + "tableFrom": "user_second_factors", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "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 + }, + "registered_at": { + "name": "registered_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "idx_users_username": { + "name": "idx_users_username", + "columns": [ + "username" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "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 index feb1f0678..238908477 100644 --- a/drizzle/sqlite/meta/_journal.json +++ b/drizzle/sqlite/meta/_journal.json @@ -470,6 +470,13 @@ "when": 1791755799726, "tag": "0066_drop_install_count_source", "breakpoints": true + }, + { + "idx": 67, + "version": "6", + "when": 1791935281343, + "tag": "0067_unique_username", + "breakpoints": true } ] } \ No newline at end of file diff --git a/electron-builder.json b/electron-builder.json index 66bb8128c..1c5bdb623 100644 --- a/electron-builder.json +++ b/electron-builder.json @@ -46,11 +46,11 @@ "target": [ { "target": "nsis", - "arch": ["x64", "ia32"] + "arch": ["x64", "ia32", "arm64"] }, { "target": "msi", - "arch": ["x64", "ia32"] + "arch": ["x64", "ia32", "arm64"] } ], "icon": "public/icon.ico", diff --git a/electron/external-editor.cjs b/electron/external-editor.cjs new file mode 100644 index 000000000..0cc098608 --- /dev/null +++ b/electron/external-editor.cjs @@ -0,0 +1,70 @@ +const path = require("path"); + +// Saved by the file manager after the user picks an editor in a file dialog. +const EXTERNAL_EDITOR_SETTING = "fileManager.externalEditorPath"; + +// Extensions the OS would run rather than open in an editor. With no editor +// configured the temp copy goes to shell.openPath, and its name comes from +// the remote server. +const LAUNCHABLE_EXTENSIONS = new Set([ + // Windows + "application", + "appref-ms", + "bat", + "chm", + "cmd", + "com", + "cpl", + "exe", + "gadget", + "hta", + "inf", + "jar", + "js", + "jse", + "library-ms", + "lnk", + "msc", + "msi", + "msp", + "mst", + "pif", + "ps1", + "psd1", + "psm1", + "py", + "pyw", + "reg", + "scf", + "scr", + "search-ms", + "settingcontent-ms", + "url", + "vbe", + "vbs", + "ws", + "wsf", + "wsh", + "xll", + // macOS + "app", + "command", + "pkg", + "terminal", + "tool", + "workflow", + // Linux + "appimage", + "desktop", + "run", + "sh", +]); + +function isLaunchableFileName(fileName) { + // Windows drops trailing dots and spaces, so "a.exe. " is saved as a.exe. + const name = path.basename(String(fileName || "")).replace(/[. ]+$/, ""); + const extension = path.extname(name).slice(1).toLowerCase(); + return LAUNCHABLE_EXTENSIONS.has(extension); +} + +module.exports = { EXTERNAL_EDITOR_SETTING, isLaunchableFileName }; diff --git a/electron/external-login.cjs b/electron/external-login.cjs new file mode 100644 index 000000000..9b4d18d70 --- /dev/null +++ b/electron/external-login.cjs @@ -0,0 +1,20 @@ +/** + * The login URL a server hands the desktop app goes to shell.openExternal, + * so only web pages pass: any other scheme (file:, smb:, ms-msdt:, ...) + * would launch whatever OS handler is registered for it. + */ +function isExternalLoginUrl(value) { + try { + const { protocol } = new URL(value); + return protocol === "https:" || protocol === "http:"; + } catch { + return false; + } +} + +/** A non-privileged port for the loopback callback listener. */ +function isCallbackPort(value) { + return Number.isInteger(value) && value >= 1024 && value <= 65535; +} + +module.exports = { isExternalLoginUrl, isCallbackPort }; diff --git a/electron/linked-server.cjs b/electron/linked-server.cjs index 219df606d..a31a03250 100644 --- a/electron/linked-server.cjs +++ b/electron/linked-server.cjs @@ -6,9 +6,16 @@ let linked = null; +const WEBSOCKET_TO_HTTP = { "ws:": "http:", "wss:": "https:" }; + +// A WebSocket to the linked server (wss://host/...) counts as the link's +// https://host origin, so its upgrade carries the proxy headers too. function getOrigin(url) { try { - return new URL(url).origin; + const parsed = new URL(url); + const httpProtocol = WEBSOCKET_TO_HTTP[parsed.protocol]; + if (httpProtocol) parsed.protocol = httpProtocol; + return parsed.origin; } catch { return null; } diff --git a/electron/local-files.cjs b/electron/local-files.cjs index 487127134..971cfa9bf 100644 --- a/electron/local-files.cjs +++ b/electron/local-files.cjs @@ -330,7 +330,11 @@ const AUTH_TOKEN_PATTERN = /^[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$/; // path, so nothing here names it. Only a /plugin-api/ path is accepted, // which keeps every transfer on the backend's plugin routes. const API_PATH_PATTERN = /^\/plugin-api\/[a-z0-9][a-z0-9-]*$/; -const LOCAL_BACKEND = "http://localhost:30001"; +let localBackendPort = 30001; + +function setLocalBackendPort(port) { + localBackendPort = port; +} let registeredApiPath = null; function setTransferApiPath(apiPath) { @@ -403,7 +407,8 @@ function createTargetResolver({ localBaseUrl, getLinkedServer, apiPath }) { } return { url: `${normalizeHttpBase( - localBaseUrl ?? `${LOCAL_BACKEND}${resolveApiPath()}`, + localBaseUrl ?? + `http://localhost:${localBackendPort}${resolveApiPath()}`, "Local backend URL", )}${routePath}`, headers, @@ -1050,6 +1055,7 @@ module.exports = { createLocalFileHandlers, createTargetResolver, setTransferApiPath, + setLocalBackendPort, publishDownload, defaultPublishFs, assertWithinRoot, diff --git a/electron/local-shell.cjs b/electron/local-shell.cjs index bbf965fb2..39daa9a8b 100644 --- a/electron/local-shell.cjs +++ b/electron/local-shell.cjs @@ -39,7 +39,11 @@ function resolveLocalShell( * how many helpers were fixed. */ function repairSpawnHelpers(ptyDir, fsImpl = fs) { - const dir = ptyDir.replace(/app\.asar(?!\.unpacked)/, "app.asar.unpacked"); + // A universal macOS build ships app-arm64.asar and app-x64.asar. + const dir = ptyDir.replace( + /\b(app(?:-[a-z0-9]+)?)\.asar(?!\.unpacked)/, + "$1.asar.unpacked", + ); const candidates = [ path.join(dir, "build", "Release", "spawn-helper"), path.join( diff --git a/electron/main.cjs b/electron/main.cjs index bf781b319..ae3d49749 100644 --- a/electron/main.cjs +++ b/electron/main.cjs @@ -35,7 +35,15 @@ const { isCloseActiveTabInput } = require("./keyboard-shortcuts.cjs"); const { quitApp } = require("./app-quit.cjs"); const { selectLinuxPasswordStore } = require("./linux-password-store.cjs"); const { resolveLocalShell, repairSpawnHelpers } = require("./local-shell.cjs"); -const { registerLocalFileHandlers } = require("./local-files.cjs"); +const { + registerLocalFileHandlers, + setLocalBackendPort, +} = require("./local-files.cjs"); +const { + EXTERNAL_EDITOR_SETTING, + isLaunchableFileName, +} = require("./external-editor.cjs"); +const { isExternalLoginUrl, isCallbackPort } = require("./external-login.cjs"); const localTerminalSessions = new Map(); @@ -434,14 +442,6 @@ function isInsecureModeEnabled() { ); } -function getOrigin(url) { - try { - return new URL(url).origin; - } catch { - return null; - } -} - function isPrivateNetworkHost(hostname) { if ( hostname === "localhost" || @@ -485,7 +485,7 @@ function isInvalidCertificateAllowedForUrl(url) { // allows a self-signed certificate. const linked = linkedServer.getLinkedServer(); if (!linked?.allowInvalidCertificate) return false; - return getOrigin(url) === linked.origin; + return linkedServer.isLinkedOrigin(url); } function getTlsVerificationOptions(url) { @@ -700,6 +700,9 @@ let tray = null; let isQuitting = false; const tempFiles = new Map(); const externalEditorSessions = new Map(); +// Paths the user picked in an open dialog this session. Only these may be +// saved as the external editor, so the renderer cannot name an executable. +const dialogChosenPaths = new Set(); const isDev = process.env.NODE_ENV === "development" || !app.isPackaged; const appRoot = isDev ? process.cwd() : path.join(__dirname, ".."); @@ -1116,6 +1119,35 @@ function reapOrphanedBackendProcess() { } } +const DEFAULT_BACKEND_PORT = 30001; +let backendPort = DEFAULT_BACKEND_PORT; + +function canListenOn(port) { + return new Promise((resolve) => { + const probe = net.createServer(); + probe.once("error", () => resolve(null)); + probe.listen(port, "127.0.0.1", () => { + const { port: bound } = probe.address(); + probe.close(() => resolve(bound)); + }); + }); +} + +// 30001 when it is free, otherwise any free port. Only the desktop app talks +// to this backend, so the number itself does not matter. +async function findBackendPort() { + const preferred = await canListenOn(DEFAULT_BACKEND_PORT); + if (preferred) return preferred; + const fallback = await canListenOn(0); + if (fallback) { + logToFile( + `Port ${DEFAULT_BACKEND_PORT} is in use, starting the backend on ${fallback}`, + ); + return fallback; + } + return DEFAULT_BACKEND_PORT; +} + function startBackendServer() { reapOrphanedBackendProcess(); backendFailure = null; @@ -1156,7 +1188,8 @@ function startBackendServer() { DATA_DIR: dataDir, NODE_ENV: "production", ELECTRON_EMBEDDED: "true", - PORT: "30001", + PORT: String(backendPort), + EMBEDDED_BACKEND_PORT: String(backendPort), VERSION: app.getVersion(), }, stdio: ["pipe", "pipe", "pipe", "ipc"], @@ -1421,6 +1454,7 @@ function createWindow() { sandbox: true, webSecurity: true, preload: path.join(__dirname, "preload.js"), + additionalArguments: [`--termix-backend-port=${backendPort}`], partition: termixSessionPartition, allowRunningInsecureContent: false, webviewTag: false, @@ -1663,6 +1697,12 @@ ipcMain.handle("get-embedded-server-status", () => { ipcMain.handle( "external-browser-login", async (_event, authUrl, callbackPort) => { + if (!isExternalLoginUrl(authUrl)) { + return { success: false, error: "Invalid authentication URL" }; + } + if (!isCallbackPort(callbackPort)) { + return { success: false, error: "Invalid callback port" }; + } const http = require("http"); return new Promise((resolve) => { @@ -2375,6 +2415,8 @@ async function testC2SRelay( if (runtime) runtime.authSocket = ws; return new Promise((resolve) => { let settled = false; + // The relay sends its reason and then closes; keep the reason. + let relayError = null; const settle = (result) => { if (settled) return; settled = true; @@ -2411,6 +2453,10 @@ async function testC2SRelay( settle({ success: true }); } else if (message.type === "error") { clearTimeout(timer); + relayError = normalizeC2SErrorMessage( + message.error, + "Tunnel test failed", + ); settle(createC2SFailure(message.error, "Tunnel test failed")); } } catch (error) { @@ -2424,7 +2470,10 @@ async function testC2SRelay( }); ws.on("close", () => { if (runtime && c2sTunnelRuntimes.get(tunnel.name) === runtime) { - setC2STunnelError(tunnel.name, "Endpoint SSH connection closed"); + setC2STunnelError( + tunnel.name, + relayError || "Endpoint SSH connection closed", + ); } clearTimeout(timer); settle({ success: false, error: "Tunnel test connection closed" }); @@ -3156,18 +3205,16 @@ ipcMain.handle("get-c2s-tunnel-preset-default-name", () => { return `[${date}] ${computerName} (${platform} ${release})`; }); +function readSetting(key) { + const settingsPath = path.join(app.getPath("userData"), "settings.json"); + if (!fs.existsSync(settingsPath)) return null; + const settings = JSON.parse(fs.readFileSync(settingsPath, "utf8")); + return settings[key] !== undefined ? settings[key] : null; +} + ipcMain.handle("get-setting", (event, key) => { try { - const userDataPath = app.getPath("userData"); - const settingsPath = path.join(userDataPath, "settings.json"); - - if (!fs.existsSync(settingsPath)) { - return null; - } - - const settingsData = fs.readFileSync(settingsPath, "utf8"); - const settings = JSON.parse(settingsData); - return settings[key] !== undefined ? settings[key] : null; + return readSetting(key); } catch (error) { console.error("Error reading setting:", error); return null; @@ -3175,6 +3222,16 @@ ipcMain.handle("get-setting", (event, key) => { }); ipcMain.handle("set-setting", (event, key, value) => { + if (typeof key !== "string") { + return { success: false, error: "Invalid setting key" }; + } + if ( + key === EXTERNAL_EDITOR_SETTING && + value && + !dialogChosenPaths.has(value) + ) { + return { success: false, error: "Choose the editor in the file dialog" }; + } try { const userDataPath = app.getPath("userData"); const settingsPath = path.join(userDataPath, "settings.json"); @@ -3198,94 +3255,6 @@ ipcMain.handle("set-setting", (event, key, value) => { } }); -ipcMain.handle("get-session-cookie", async (_event, name, targetUrl) => { - try { - const ses = mainWindow?.webContents?.session; - if (!ses) return null; - const cookies = await ses.cookies.get({ - name, - ...(targetUrl ? { url: targetUrl } : {}), - }); - const cookie = cookies.find((candidate) => - cookieMatchesUrl(candidate, targetUrl), - ); - return ( - cookie?.value || - getRememberedElectronAuthCookie(name, targetUrl)?.value || - null - ); - } catch (error) { - console.error("Failed to get session cookie:", error); - return getRememberedElectronAuthCookie(name, targetUrl)?.value || null; - } -}); - -function cookieMatchesUrl(cookie, targetUrl) { - if (!targetUrl) return true; - - try { - const targetHost = new URL(targetUrl).hostname; - const cookieDomain = (cookie.domain || "").replace(/^\./, ""); - - return ( - cookieDomain === targetHost || - targetHost.endsWith(`.${cookieDomain}`) || - (!cookieDomain && targetHost === "localhost") - ); - } catch { - return true; - } -} - -ipcMain.handle( - "wait-session-cookie", - async (_event, name, targetUrl, previousValue, timeoutMs = 5000) => { - const ses = mainWindow?.webContents?.session; - if (!ses) return { success: false, error: "No Electron session" }; - - const existingCookies = await ses.cookies.get({ - name, - ...(targetUrl ? { url: targetUrl } : {}), - }); - const existingCookie = existingCookies.find((cookie) => - cookieMatchesUrl(cookie, targetUrl), - ); - if (existingCookie?.value && existingCookie.value !== previousValue) { - return { success: true, value: existingCookie.value }; - } - - const rememberedCookie = getRememberedElectronAuthCookie(name, targetUrl); - if (rememberedCookie?.value && rememberedCookie.value !== previousValue) { - return { success: true, value: rememberedCookie.value }; - } - - return new Promise((resolve) => { - const timeout = setTimeout(() => { - ses.cookies.off("changed", onCookieChanged); - resolve({ success: false, error: "Timed out waiting for cookie" }); - }, timeoutMs); - - function onCookieChanged(_event, cookie, _cause, removed) { - if ( - removed || - cookie.name !== name || - !cookie.value || - cookie.value === previousValue || - !cookieMatchesUrl(cookie, targetUrl) - ) { - return; - } - - clearTimeout(timeout); - ses.cookies.off("changed", onCookieChanged); - resolve({ success: true, value: cookie.value }); - } - - ses.cookies.on("changed", onCookieChanged); - }); - }, -); - ipcMain.handle("clear-session-cookies", async () => { try { clearPersistedElectronAuthCookies(); @@ -3413,7 +3382,9 @@ ipcMain.handle("show-save-dialog", async (_event, options) => { }); ipcMain.handle("show-open-dialog", async (_event, options) => { - return dialog.showOpenDialog(mainWindow, options || {}); + const result = await dialog.showOpenDialog(mainWindow, options || {}); + for (const filePath of result.filePaths) dialogChosenPaths.add(filePath); + return result; }); function toLocalFileType(stat) { @@ -3736,6 +3707,15 @@ ipcMain.handle("cleanup-temp-file", (_event, tempId) => { ipcMain.handle("open-external-editor", async (_event, fileData) => { try { + // The editor comes from the saved setting, never from the caller. + const editorPath = readSetting(EXTERNAL_EDITOR_SETTING) || null; + if (!editorPath && isLaunchableFileName(fileData?.fileName)) { + return { + success: false, + error: "Choose an external editor to open this file type", + }; + } + const result = createManagedTempFile( fileData?.fileName, fileData?.content, @@ -3757,10 +3737,6 @@ ipcMain.handle("open-external-editor", async (_event, fileData) => { lastMtimeMs: stat.mtimeMs, }); - const editorPath = - typeof fileData?.editorPath === "string" && fileData.editorPath.trim() - ? fileData.editorPath.trim() - : null; const openError = await openPathWithEditor(result.path, editorPath); if (openError) { closeExternalEditorSession(editId); @@ -3864,6 +3840,8 @@ app.whenReady().then(async () => { await clearElectronJwtCookiesAtStartup(); if (!isDev) { + backendPort = await findBackendPort(); + setLocalBackendPort(backendPort); const result = await startBackendServer(); logToFile("startBackendServer result:", result); } else { diff --git a/electron/preload.js b/electron/preload.js index 1586e8996..69eb4a4b0 100644 --- a/electron/preload.js +++ b/electron/preload.js @@ -14,7 +14,16 @@ function invokeAllowed(channel, ...args) { return ipcRenderer.invoke(channel, ...args); } +// The embedded backend's port, picked by the main process at startup. +const backendPortArg = process.argv.find((arg) => + arg.startsWith("--termix-backend-port="), +); +const backendPort = backendPortArg + ? Number(backendPortArg.split("=")[1]) + : undefined; + contextBridge.exposeInMainWorld("electronAPI", { + backendPort, getAppVersion: () => ipcRenderer.invoke("get-app-version"), // Kept so the interface size can reset a zoom an earlier build applied. setZoomFactor: (factor) => { @@ -87,16 +96,6 @@ contextBridge.exposeInMainWorld("electronAPI", { }, clearSessionCookies: () => ipcRenderer.invoke("clear-session-cookies"), - getSessionCookie: (name, targetUrl) => - ipcRenderer.invoke("get-session-cookie", name, targetUrl), - waitForSessionCookie: (name, targetUrl, previousValue, timeoutMs) => - ipcRenderer.invoke( - "wait-session-cookie", - name, - targetUrl, - previousValue, - timeoutMs, - ), externalBrowserLogin: (authUrl, callbackPort) => ipcRenderer.invoke("external-browser-login", authUrl, callbackPort), diff --git a/env.catalog.json b/env.catalog.json index b24538151..94988e38d 100644 --- a/env.catalog.json +++ b/env.catalog.json @@ -71,7 +71,6 @@ "group": "server", "description": "SSH agent socket used for hosts set to agent auth when they do not name their own socket." }, - { "name": "ENABLE_SSL", "group": "https", @@ -108,7 +107,6 @@ "internal": true, "description": "Set by the image when nginx serves HTTPS in front of the backend." }, - { "name": "DATABASE_DIALECT", "group": "database", @@ -161,7 +159,6 @@ "internal": true, "description": "Where database migrations are read from. Set by the image." }, - { "name": "JWT_SECRET", "group": "secrets", @@ -204,7 +201,6 @@ "secret": true, "description": "Only read to move data from installs older than 2.5. Never change it on an install that has it." }, - { "name": "ALLOW_PASSWORD_LOGIN", "group": "auth", @@ -283,7 +279,6 @@ "group": "auth", "description": "JSON map from proxy roles to Termix roles, like {\"admins\":[\"admin\"],\"staff\":[\"user\"]}. Required when trusted proxy login is on." }, - { "name": "TRUSTED_PROXIES", "group": "proxy", @@ -315,7 +310,6 @@ "group": "proxy", "description": "Comma separated hosts that skip the proxy." }, - { "name": "LOG_LEVEL", "group": "logging", @@ -355,7 +349,6 @@ "deprecated": "the session-recording plugin setting", "description": "Only read once to carry a 2.8 value into the session-recording plugin." }, - { "name": "TERMIX_PLUGIN_REGISTRY_URL", "group": "plugins", @@ -406,7 +399,6 @@ "internal": true, "description": "Set by npm run dev." }, - { "name": "ENABLE_INSECURE_MODE", "group": "desktop", @@ -435,6 +427,12 @@ "internal": true, "description": "Set by the desktop app when it runs its own backend." }, + { + "name": "EMBEDDED_BACKEND_PORT", + "group": "desktop", + "internal": true, + "description": "Set by the desktop app to the port its own backend listens on. 30001 unless another program holds it." + }, { "name": "TERMIX_DATA_DIR", "group": "desktop", @@ -459,14 +457,12 @@ "internal": true, "description": "Set by Flatpak. The desktop app links new versions instead of installing them." }, - { "name": "DESKTOP_SESSION", "group": "desktop", "internal": true, "description": "Read on Linux to pick the password store." }, - { "name": "VITE_BASE_PATH", "group": "internal", diff --git a/package-lock.json b/package-lock.json index 51b6bdaed..f0f821eb1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "termix", - "version": "26.10.1", + "version": "26.10.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "termix", - "version": "26.10.1", + "version": "26.10.2", "hasInstallScript": true, "workspaces": [ "packages/*" @@ -15058,7 +15058,7 @@ }, "packages/plugin-sdk": { "name": "@termix-ssh/plugin-sdk", - "version": "1.0.6", + "version": "1.0.11", "license": "Apache-2.0", "dependencies": { "@deadendjs/swagger-jsdoc": "^8.1.2", diff --git a/package.json b/package.json index 1b7331cba..7942d0a80 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,7 @@ "workspaces": [ "packages/*" ], - "version": "26.10.1", + "version": "26.10.2", "description": "Self-hosted, plugin-based server management.", "author": "Karmaa", "main": "electron/main.cjs", diff --git a/packages/plugin-sdk/CHANGELOG.md b/packages/plugin-sdk/CHANGELOG.md index bfa8b42a4..08db60eba 100644 --- a/packages/plugin-sdk/CHANGELOG.md +++ b/packages/plugin-sdk/CHANGELOG.md @@ -1,5 +1,43 @@ # Changelog +## 1.0.11 + +Plugin API 1.3.0. + +### Added + +- `ctx.auth.issueUserToken()` mints a short-lived session for a user, for calling Termix's own API as them from background work. Needs `users:impersonate` and `engine.api` 1.3 +- `ctx.users.getLocale()` reads a user's UI language. Needs `users:read` and `engine.api` 1.3 + +## 1.0.10 + +### Changed + +- `termix-plugin pack` includes the plugin's `LICENSE` and `NOTICE` files, so third-party notices ship with it + +## 1.0.9 + +### Added + +- Worker entries in `package.json` `termix.workers`, each bundled on its own into `dist/workers/.js` +- A `.wasm`, `.ttf` or `.otf` import gives the file's bytes, in `termix-plugin build` and in plugin tests + +### Fixed + +- The plugin guide listed jszip, guacamole-lite and @anthropic-ai/sdk as shared packages, but they are bundled + +## 1.0.8 + +### Added + +- `publicRateLimit` on `ctx.http.router()`, for a plugin whose public routes serve a proxied page + +## 1.0.7 + +### Fixed + +- A migration file, or a part of one, holding only comments made core fail to start the plugin + ## 1.0.6 ### Added diff --git a/packages/plugin-sdk/PLUGIN-GUIDE.md b/packages/plugin-sdk/PLUGIN-GUIDE.md index 13531978b..b252da84f 100644 --- a/packages/plugin-sdk/PLUGIN-GUIDE.md +++ b/packages/plugin-sdk/PLUGIN-GUIDE.md @@ -1,6 +1,6 @@ # Termix plugin guide -SDK 1.0.6 | Plugin API 1.2.0 +SDK 1.0.11 | Plugin API 1.3.0 This guide is for anyone, person or AI coding tool, building a Termix plugin. It ships inside `@termix-ssh/plugin-sdk`, so the copy in your `node_modules` always matches the SDK you have installed. @@ -90,7 +90,7 @@ npm test # termix-plugin test: Vitest npm run typecheck # tsc npm run validate # termix-plugin validate: manifest, files, migrations, changelog, docs npm run migrations # termix-plugin migrations: write migrations after changing tables.ts -npm run pack # termix-plugin pack: write -.tmxplug +npm run pack # termix-plugin pack: write -.tmxplug (with LICENSE and NOTICE) npm run format # Prettier npm run format:check ``` @@ -105,7 +105,7 @@ Before you call a change finished: - [ ] `npm run build` passes - [ ] `npm run format:check` passes - [ ] `npx termix-plugin migrations --check` passes (you ran `npm run migrations` after changing tables) -- [ ] New strings are in `locales/en.json` +- [ ] New strings are in `locales/en.json`, and `plugin.name` / `plugin.description` match the manifest - [ ] New routes have an `@openapi` block and a test, including without the permission - [ ] New env vars are listed in `env` in the manifest - [ ] `docs/` and the "About this plugin" section of `AGENTS.md` still match what the plugin does @@ -225,6 +225,8 @@ Bundled as ESM for Node 22+ into `dist/backend.js`. Backend imports of your own | `audit.record` | none | Write your own audit line | | `sync` | none | Sync your tables to a linked desktop | | `auth` | `auth:provide` | Login methods, second factors, SSH auth types | +| `auth.issueUserToken` | `users:impersonate` | A short-lived session for calling Termix's API as a user | +| `users.getLocale` | `users:read` | A user's UI language | | `system` | `system:tls` | The server's HTTPS certificate | | `plugins.list()` | `plugins:read` | Which plugins are installed | | `asUser(userId, fn)` | always audited | Run background work as a user | @@ -237,6 +239,13 @@ In a route the actor is the signed-in user, set by Termix. `ctx.currentActor()` work with no request (a timer, an event) uses `ctx.asUser(userId, fn)`, which is audited and still applies that user's permissions. +Background work that must call Termix's own HTTP API (the Termix CLI, say) asks for a session with +`ctx.auth.issueUserToken(userId, { purpose, ttlSeconds })` and sends it as `Authorization: Bearer `. +It needs `users:impersonate` and `engine.api` `^1.3`, lasts 300 seconds by default and 900 at most, and is +audited as `plugin_user_token_issued`. The user's permissions apply, and it is refused on routes that change +their password, second factors, API keys or linked logins. `ctx.users.getLocale(userId)` gives the user's +UI language, like `zh_CN`, or null, for writing to them in it. + ### Routes Routes live at `/plugin-api//`, sockets at `/plugin-ws//`, on the main Termix port. You never need @@ -283,7 +292,8 @@ router.post("/notes", async (req, res) => { - The `@openapi` path is the full path, starting with `/plugin-api//`. `build` turns these into `dist/openapi.json` and the docs site renders them. - `ctx.http.router({ public: ["/webhook/:token"], bodyLimit: "20mb", rawBody: false })`. Public routes - have no actor: check their token yourself and rate limit them. + have no actor: check their token yourself. Core allows 120 public requests a minute per address; + `publicRateLimit` raises that for a proxied page that loads its own assets. - `ctx.http.baseUrl(req)` is the public address, for callback URLs. - `ctx.ws.route("/live", (socket, conn) => ...)`. Long lived sockets should check `conn.isDataUnlocked()` per message. @@ -333,8 +343,27 @@ ctx.schedule.every(60_000, () => refresh(), { jitterMs: 5_000 }); ### Shared packages These come from Termix and are never bundled: `@termix-ssh/plugin-sdk`, `express`, `ssh2`, `ws`, `multer`, -`cookie-parser`, `axios`, `jszip`, `guacamole-lite`, `@anthropic-ai/sdk`, `drizzle-orm`, `sharp` and every -Node builtin. List the ones you use in `peerDependencies`. Any other npm package is bundled. +`cookie-parser`, `axios`, `drizzle-orm`, `sharp` and every Node builtin. List the ones you use in +`peerDependencies`. Any other npm package is bundled. + +### Workers and WebAssembly + +Heavy work (decoding, encoding, parsing) belongs in a worker thread so it never blocks the server. Name +each worker entry in `package.json`: + +```json +"termix": { "workers": { "engine": "src/backend/engine/worker.ts" } } +``` + +`build` bundles it alone into `dist/workers/engine.js`; start it with +`new Worker(new URL("./workers/engine.js", import.meta.url))`. A worker bundles everything it imports and +can't use the shared packages above, so it must not import `@termix-ssh/plugin-sdk`. Talk to it with +`postMessage`. + +Importing a `.wasm`, `.ttf` or `.otf` file gives its bytes as a `Uint8Array`, in `build` and in tests, so a module ships +inside the bundle with no file to find. Compile it once on the main thread with `new WebAssembly.Module` +and post that to each worker. Add `declare module "*.wasm" { const bytes: Uint8Array; export default bytes; }` +to a `.d.ts` in the plugin. ## Database @@ -464,7 +493,7 @@ your own host settings, under `host.pluginSettings[]`. Slots you can fill include `terminal.toolbar`, `terminal.toolbarStatus`, `terminal.sidePanel`, `terminal.overlay`, `dashboard.counters`, `dashboard.hostMetrics`, `admin.userTabs`, `hosts.importMenu`, -`hosts.panel`, `credentials.badges`, `shell.overlay` and `tab.menu`. See +`hosts.panel`, `credentials.badges`, `shell.overlay`, `shell.rightEdge` and `tab.menu`. See https://docs.termix.site/develop/more-extension-points. ## Looking native @@ -518,6 +547,20 @@ Plurals use `_one` and `_other` with `t("hosts", { count })`. Other languages go `locales/translated/.json` (like `de_DE.json`) with the same keys. Keep `{{placeholders}}` as they are. Untranslated strings show in English. +Termix shows your plugin's name and description in the plugin manager, the setup picker and settings. +Put them in `locales/en.json` as `plugin.name` and `plugin.description`, with the same text as `name` and +`description` in the manifest, so they get translated too. Without them, the English manifest text shows in +every language. + +```json +{ + "plugin": { + "name": "Host Metrics", + "description": "Live CPU, memory, disk, network and GPU stats." + } +} +``` + ## Docs `docs/index.md` is the first page: what the plugin does in a sentence or two, how to turn it on, how to use @@ -682,20 +725,20 @@ To run your own registry instead, see https://docs.termix.site/develop/registrie ## termix-plugin CLI -| Command | What it does | -| ------------------------------ | ---------------------------------------------------------------------------------------------- | -| `build` | Bundle `dist/backend.js` and `dist/frontend.js`, CSS, locales, migrations, `dist/openapi.json` | -| `validate` | Check the manifest, its files, migrations for every database, the changelog and `package.json` | -| `test` | Apply patches, run Vitest | -| `migrations` | Write a migration per database from `tables.ts`. `--check` fails when one is missing | -| `openapi` | Write `dist/openapi.json` from `@openapi` blocks | -| `pack` | Validate, then write `-.tmxplug`. `--out ` | -| `sign ` | Sign a `.tmxplug` with `TERMIX_PLUGIN_SIGNING_KEY` | -| `verify --key ` | Check a `.tmxplug` against its `.sig` | -| `keygen` | Write a new signing key pair. `--out ` | -| `changelog` | Print one version's notes. `--version x.y.z` | -| `patch` | Apply the plugin's dependency patches (`build` and `test` do this too) | -| `dev` | Build, pack and install on a server, again on every change. `--server`, `--key`, `--once` | +| Command | What it does | +| ------------------------------ | ----------------------------------------------------------------------------------------------- | +| `build` | Bundle `dist/backend.js`, `dist/frontend.js`, workers, CSS, locales, migrations, `openapi.json` | +| `validate` | Check the manifest, its files, migrations for every database, the changelog and `package.json` | +| `test` | Apply patches, run Vitest | +| `migrations` | Write a migration per database from `tables.ts`. `--check` fails when one is missing | +| `openapi` | Write `dist/openapi.json` from `@openapi` blocks | +| `pack` | Validate, then write `-.tmxplug`. `--out ` | +| `sign ` | Sign a `.tmxplug` with `TERMIX_PLUGIN_SIGNING_KEY` | +| `verify --key ` | Check a `.tmxplug` against its `.sig` | +| `keygen` | Write a new signing key pair. `--out ` | +| `changelog` | Print one version's notes. `--version x.y.z` | +| `patch` | Apply the plugin's dependency patches (`build` and `test` do this too) | +| `dev` | Build, pack and install on a server, again on every change. `--server`, `--key`, `--once` | ## SDK entry points @@ -720,37 +763,37 @@ To run your own registry instead, see https://docs.termix.site/develop/registrie ## Capabilities -| Capability | Risk | What it allows | -| -------------------- | -------- | ------------------------------------------------------ | -| `credentials:read` | critical | Read stored secrets for hosts the user can reach | -| `system:tls` | critical | Replace the HTTPS certificate, answer CA domain checks | -| `ssh:connect` | high | Run any command on hosts, with the user's access | -| `process:spawn` | high | Run programs on the Termix server | -| `users:write` | high | Add users and change their roles | -| `users:impersonate` | high | Run background work as another user, audited | -| `db:core-refs` | high | Query core users, roles and hosts tables (read only) | -| `auth:provide` | high | Add login methods, second factors, SSH auth types | -| `device:serial` | high | Open serial and USB ports on the server | -| `notify:hub` | high | Receive every alert (the alerts hub only) | -| `hosts:write` | medium | Add and edit hosts | -| `credentials:use` | medium | Connect with saved credentials without seeing them | -| `credentials:write` | medium | Save new credentials | -| `network:outbound` | medium | Outbound HTTP through `ctx.fetch` | -| `network:serve` | medium | HTTP routes and sockets | -| `network:broadcast` | medium | Local network broadcasts, like Wake-on-LAN | -| `users:read` | medium | Usernames and roles | -| `events:core` | medium | Listen to core events | -| `notify:send` | medium | Send alerts | -| `audit:read` | medium | Read the audit log | -| `desktop:window` | medium | Open windows in the desktop app | -| `hosts:read` | low | Read hosts the user can see | -| `db:own` | low | Your own tables | -| `kv:own` | low | Small key value state | -| `files:own` | low | Your own folder on disk | -| `secrets:own` | low | Your own encrypted secrets | -| `settings:read-core` | low | Read Termix server settings | -| `plugins:read` | low | See installed plugins | -| `ui:surface` | low | Add tabs, panels and settings | +| Capability | Risk | What it allows | +| -------------------- | -------- | ------------------------------------------------------- | +| `credentials:read` | critical | Read stored secrets for hosts the user can reach | +| `system:tls` | critical | Replace the HTTPS certificate, answer CA domain checks | +| `ssh:connect` | high | Run any command on hosts, with the user's access | +| `process:spawn` | high | Run programs on the Termix server | +| `users:write` | high | Add users and change their roles | +| `users:impersonate` | high | Act as another user, or get a session for them, audited | +| `db:core-refs` | high | Query core users, roles and hosts tables (read only) | +| `auth:provide` | high | Add login methods, second factors, SSH auth types | +| `device:serial` | high | Open serial and USB ports on the server | +| `notify:hub` | high | Receive every alert (the alerts hub only) | +| `hosts:write` | medium | Add and edit hosts | +| `credentials:use` | medium | Connect with saved credentials without seeing them | +| `credentials:write` | medium | Save new credentials | +| `network:outbound` | medium | Outbound HTTP through `ctx.fetch` | +| `network:serve` | medium | HTTP routes and sockets | +| `network:broadcast` | medium | Local network broadcasts, like Wake-on-LAN | +| `users:read` | medium | Usernames, roles and UI language | +| `events:core` | medium | Listen to core events | +| `notify:send` | medium | Send alerts | +| `audit:read` | medium | Read the audit log | +| `desktop:window` | medium | Open windows in the desktop app | +| `hosts:read` | low | Read hosts the user can see | +| `db:own` | low | Your own tables | +| `kv:own` | low | Small key value state | +| `files:own` | low | Your own folder on disk | +| `secrets:own` | low | Your own encrypted secrets | +| `settings:read-core` | low | Read Termix server settings | +| `plugins:read` | low | See installed plugins | +| `ui:surface` | low | Add tabs, panels and settings | An update that adds a capability waits for an admin to approve it. diff --git a/packages/plugin-sdk/cli/commands/build.mjs b/packages/plugin-sdk/cli/commands/build.mjs index 690762389..3d84a2eff 100644 --- a/packages/plugin-sdk/cli/commands/build.mjs +++ b/packages/plugin-sdk/cli/commands/build.mjs @@ -52,6 +52,43 @@ export function externalRequireInterop(externals) { }; } +// A .wasm or font import becomes its bytes (a Uint8Array) inside the bundle, +// so it ships in backend.js or a worker with no separate file to find. +export const BINARY_EXTENSIONS = [".wasm", ".ttf", ".otf"]; +const BINARY_LOADERS = Object.fromEntries( + BINARY_EXTENSIONS.map((ext) => [ext, "binary"]), +); + +const REQUIRE_BANNER = + "import { createRequire as __termixCreateRequire } from 'node:module'; const require = __termixCreateRequire(import.meta.url);"; + +/** + * package.json "termix": { "workers": { "engine": "src/backend/engine/worker.ts" } } + * bundles each entry on its own into dist/workers/.js, for + * new Worker(new URL("./workers/.js", import.meta.url)). Nothing is left + * external: core's import fallback only runs on the main thread, so a worker + * can't rely on host packages and must not import the SDK. + */ +export function readWorkerEntries(cwd, pluginId) { + const pkgPath = path.join(cwd, "package.json"); + const pkg = fs.existsSync(pkgPath) + ? JSON.parse(fs.readFileSync(pkgPath, "utf8")) + : {}; + const workers = pkg.termix?.workers ?? {}; + return Object.entries(workers).map(([name, entry]) => { + if (!/^[a-z0-9-]+$/.test(name)) { + throw new Error(`${pluginId}: worker name "${name}" must be lowercase`); + } + const file = path.resolve(cwd, String(entry)); + if (!fs.existsSync(file)) { + throw new Error( + `${pluginId}: worker "${name}" entry ${entry} is missing`, + ); + } + return { name, file }; + }); +} + const SDK_PATCHES = path.join( path.dirname(fileURLToPath(import.meta.url)), "..", @@ -216,13 +253,27 @@ export async function build({ cwd }) { // node_modules import instead, resolved at runtime like a host-provided // package. external: [...BACKEND_EXTERNALS, ...(manifest.nativeDependencies ?? [])], + loader: BINARY_LOADERS, // Bundled CJS deps still call require() for Node builtins at runtime. // ESM has no ambient require, so give esbuild's require shim a real one. - banner: { - js: "import { createRequire as __termixCreateRequire } from 'node:module'; const require = __termixCreateRequire(import.meta.url);", - }, + banner: { js: REQUIRE_BANNER }, }); + for (const worker of readWorkerEntries(cwd, pluginId)) { + await esbuild.build({ + entryPoints: [worker.file], + outfile: path.join(outDir, "workers", `${worker.name}.js`), + bundle: true, + format: "esm", + platform: "node", + target: "node22", + sourcemap: true, + logLevel: "warning", + loader: BINARY_LOADERS, + banner: { js: REQUIRE_BANNER }, + }); + } + // Without this Node finds the host's typeless package.json, tries backend.js // as CommonJS first and warns before reparsing it as ESM. fs.writeFileSync( diff --git a/packages/plugin-sdk/cli/lib/tmxplug.mjs b/packages/plugin-sdk/cli/lib/tmxplug.mjs index d44d44fc6..6c14d4fdc 100644 --- a/packages/plugin-sdk/cli/lib/tmxplug.mjs +++ b/packages/plugin-sdk/cli/lib/tmxplug.mjs @@ -11,6 +11,9 @@ export const TMXPLUG_CONTENTS = [ "README.md", "CHANGELOG.md", "icon.svg", + // Licence texts, so third-party notices travel with the plugin. + "LICENSE", + "NOTICE", ]; const BLOCK = 512; diff --git a/packages/plugin-sdk/package.json b/packages/plugin-sdk/package.json index f5b207942..ca163ae7f 100644 --- a/packages/plugin-sdk/package.json +++ b/packages/plugin-sdk/package.json @@ -1,6 +1,6 @@ { "name": "@termix-ssh/plugin-sdk", - "version": "1.0.6", + "version": "1.0.11", "description": "The contract between Termix core and a plugin: types, the capability catalog and the manifest schema.", "license": "Apache-2.0", "repository": { diff --git a/packages/plugin-sdk/src/backend.ts b/packages/plugin-sdk/src/backend.ts index 6ea70c0f8..e0d27c189 100644 --- a/packages/plugin-sdk/src/backend.ts +++ b/packages/plugin-sdk/src/backend.ts @@ -357,6 +357,13 @@ export interface PluginRouterOptions { * (multer, a raw body for signature checking, a proxied stream). */ rawBody?: boolean; + /** + * Requests per minute one address may make to this plugin's public routes. + * Defaults to 120, which a proxied page loading its own scripts and images + * can pass on its own. Capped at 10000, and an admin's + * TERMIX_PLUGIN_PUBLIC_RATE_LIMIT wins over it. + */ + publicRateLimit?: number; } /** @@ -1457,6 +1464,20 @@ export interface PluginSecondFactor { reset?: (userId: string) => Promise; } +export interface PluginUserTokenOptions { + /** Why the token exists, kept in the audit log and on the session. */ + purpose: string; + /** Seconds until it expires. Defaults to 300 and is capped at 900. */ + ttlSeconds?: number; +} + +export interface PluginUserToken { + /** Send as `Authorization: Bearer ` to core's own API. */ + token: string; + /** ISO time the session ends. */ + expiresAt: string; +} + /** * Login methods, second factors and SSH auth types. Every register call needs * auth:provide and a matching contributes.auth entry, and is undone on @@ -1518,6 +1539,26 @@ export interface PluginAuth { * delete a provider people still sign in with. */ countLinkedUsers: (provider: string) => Promise; + /** + * A short-lived session for an existing user, for calling core's own HTTP + * API as them from work with no request behind it, like a scheduled task + * driving the Termix CLI. The user's RBAC applies unchanged. Needs + * users:impersonate, not auth:provide, and every call is audited. + * + * The session is marked with the plugin and purpose. It cannot change the + * user's password, second factors, API keys or linked logins, so it can + * never outlive itself. + */ + issueUserToken: ( + userId: string, + options: PluginUserTokenOptions, + ) => Promise; +} + +/** What a plugin may learn about a user. Needs users:read. */ +export interface PluginUsers { + /** The user's UI language, like "zh_CN", or null when they never chose one. */ + getLocale: (userId: string) => Promise; } export interface PluginOpenIsolatedWindowRequest { @@ -2006,8 +2047,13 @@ export interface PluginContext { readonly hosts: PluginHosts; /** SSH through core's connect pipeline. Needs ssh:connect and credentials:use. */ readonly ssh: PluginSsh; - /** Login methods, second factors and SSH auth types. Needs auth:provide. */ + /** + * Login methods, second factors and SSH auth types. Needs auth:provide, + * except issueUserToken, which needs users:impersonate. + */ readonly auth: PluginAuth; + /** What a plugin may learn about a user. Needs users:read. */ + readonly users: PluginUsers; /** Opens Electron windows outside the main renderer. Needs desktop:window. */ readonly desktop: PluginDesktop; /** Host protocol credentials and the user's saved SSH keys. See PluginCredentials. */ @@ -2032,8 +2078,9 @@ export interface PluginContext { * no request behind it. Always audited, and every core API inside still * applies that user's RBAC. * - * Needs users:impersonate. That and a `userId` option on services.get or - * secrets.getShared are the only ways a plugin can name a user. + * Needs users:impersonate. That, auth.issueUserToken and a `userId` option + * on services.get or secrets.getShared are the only ways a plugin can act + * as a user it names. */ asUser: (userId: string, fn: () => Promise | T) => Promise; diff --git a/packages/plugin-sdk/src/ddl.ts b/packages/plugin-sdk/src/ddl.ts index fac788076..61d318d55 100644 --- a/packages/plugin-sdk/src/ddl.ts +++ b/packages/plugin-sdk/src/ddl.ts @@ -309,17 +309,27 @@ export function splitStatements(source: string): string[] { continue; } if (char === ";") { - if (current.trim()) statements.push(current.trim()); + if (hasSql(current)) statements.push(current.trim()); current = ""; continue; } current += char; } - if (current.trim()) statements.push(current.trim()); + if (hasSql(current)) statements.push(current.trim()); return statements; } +/** False for a chunk that is only comments, which a driver refuses to run. */ +function hasSql(chunk: string): boolean { + return ( + chunk + .replace(/--[^\n]*/g, "") + .replace(/\/\*[\s\S]*?\*\//g, "") + .trim() !== "" + ); +} + /** DROP statement for a definition, for removePluginData. */ export function dropTableSql( dialect: SqlDialect, diff --git a/packages/plugin-sdk/src/manifest.ts b/packages/plugin-sdk/src/manifest.ts index 8732e8173..c60138b44 100644 --- a/packages/plugin-sdk/src/manifest.ts +++ b/packages/plugin-sdk/src/manifest.ts @@ -36,7 +36,7 @@ export { * "^1.2" needs 1.2 or later). engine.termix is checked against the core * version by the server. */ -export const PLUGIN_API_VERSION = "1.2.0"; +export const PLUGIN_API_VERSION = "1.3.0"; /** The API major, the default engine.api for a new plugin. */ export const SUPPORTED_PLUGIN_API_VERSION = String( diff --git a/packages/plugin-sdk/src/testing.ts b/packages/plugin-sdk/src/testing.ts index 4b8868002..4515a62ea 100644 --- a/packages/plugin-sdk/src/testing.ts +++ b/packages/plugin-sdk/src/testing.ts @@ -174,6 +174,8 @@ export interface FakeContextOptions { linkedUsers?: Record; /** Failures before ctx.auth.loginRateLimit locks a key. Defaults to 5. */ loginAttemptLimit?: number; + /** What ctx.users.getLocale answers, by user id. A missing user answers null. */ + locales?: Record; } export interface FakeProcessOptions { @@ -247,6 +249,8 @@ export interface FakeAuthRegistrations { }>; /** Failures recorded through ctx.auth.loginRateLimit, by "|". */ loginFailures: Map; + /** Every ctx.auth.issueUserToken call, in order. */ + userTokens: Array<{ userId: string; purpose: string; ttlSeconds?: number }>; } export interface FakePluginContext { @@ -574,6 +578,7 @@ export function createFakeContext( completedLogins: [], revokedSessions: [], loginFailures: new Map(), + userTokens: [], }; const sshClient = options.sshClient ?? {}; let actor = options.actor; @@ -1140,6 +1145,18 @@ export function createFakeContext( }, countLinkedUsers: async (provider) => options.linkedUsers?.[provider] ?? 0, + issueUserToken: async (userId, tokenOptions) => { + auth.userTokens.push({ userId, ...tokenOptions }); + const ttl = Math.min(tokenOptions.ttlSeconds ?? 300, 900); + return { + token: `fake-token-${auth.userTokens.length}`, + expiresAt: new Date(Date.now() + ttl * 1000).toISOString(), + }; + }, + }, + + users: { + getLocale: async (userId) => options.locales?.[userId] ?? null, }, desktop: { @@ -1442,6 +1459,8 @@ export interface MockContextOptions { linkedUsers?: Record; /** See FakeContextOptions. */ loginAttemptLimit?: number; + /** See FakeContextOptions. */ + locales?: Record; } export interface MockPluginContext extends FakePluginContext { @@ -1458,7 +1477,8 @@ export interface MockPluginContext extends FakePluginContext { * topic outside the plugin's own namespace, settings:read-core on * ctx.settings.readCore, ssh:connect plus credentials:use on ctx.ssh, * notify:send on ctx.notify (notify:hub on serve), network:outbound on ctx.fetch, process:spawn on - * ctx.process, system:tls on ctx.system, plugins:read on ctx.plugins, and auth:provide on ctx.auth. Reading a plugin's own settings is deliberately + * ctx.process, system:tls on ctx.system, plugins:read on ctx.plugins, auth:provide on ctx.auth + * (users:impersonate on issueUserToken), and users:read on ctx.users. Reading a plugin's own settings is deliberately * ungated. As the SDK grows a member, add its gate here in the same shape. */ export function createMockCtx( @@ -1495,6 +1515,7 @@ export function createMockCtx( baseUrl: options.baseUrl, linkedUsers: options.linkedUsers, loginAttemptLimit: options.loginAttemptLimit, + locales: options.locales, manifest: { capabilities: options.capabilities ?? [], ...options.manifest, @@ -1786,6 +1807,17 @@ export function createMockCtx( require("auth:provide"); return ctx.auth.countLinkedUsers(provider); }, + issueUserToken: async (userId, tokenOptions) => { + require("users:impersonate"); + return ctx.auth.issueUserToken(userId, tokenOptions); + }, + }, + + users: { + getLocale: async (userId) => { + require("users:read"); + return ctx.users.getLocale(userId); + }, }, desktop: { diff --git a/packages/plugin-sdk/src/vitest-preset.ts b/packages/plugin-sdk/src/vitest-preset.ts index 2f3fe8ba6..6bfd03b43 100644 --- a/packages/plugin-sdk/src/vitest-preset.ts +++ b/packages/plugin-sdk/src/vitest-preset.ts @@ -30,6 +30,25 @@ function findRepoRoot(startDir: string): string | null { } } +/** + * termix-plugin build turns a .wasm or font import into its bytes. Tests get the same, + * so code that imports a module compiles it the same way under vitest. + */ +export function wasmBinaryPlugin() { + return { + name: "termix-wasm-binary", + enforce: "pre" as const, + load(id: string) { + const file = id.split("?")[0]; + if (![".wasm", ".ttf", ".otf"].some((ext) => file.endsWith(ext))) { + return null; + } + const base64 = fs.readFileSync(file).toString("base64"); + return `export default new Uint8Array(Buffer.from(${JSON.stringify(base64)}, "base64"));`; + }, + }; +} + export interface PluginVitestOptions { /** Extra aliases merged over the defaults. */ alias?: Record; @@ -96,7 +115,10 @@ export function pluginVitestConfig( // unexpectedly". Threads don't. const pool = "threads" as const; + const plugins = [wasmBinaryPlugin()]; + return { + plugins, resolve: { alias }, test: { globals: true, @@ -104,6 +126,7 @@ export function pluginVitestConfig( pool, projects: [ { + plugins, resolve: { alias }, test: { name: `${pluginId}:backend`, @@ -116,6 +139,7 @@ export function pluginVitestConfig( }, }, { + plugins, resolve: { alias }, test: { name: `${pluginId}:frontend`, diff --git a/scripts/dev-docker.test.ts b/scripts/dev-docker.test.ts index 325bbce30..959206159 100644 --- a/scripts/dev-docker.test.ts +++ b/scripts/dev-docker.test.ts @@ -42,6 +42,7 @@ describe("serviceEnv", () => { DATABASE_URL: "postgres://termix:termix@127.0.0.1:55432/termix", GUACD_HOST: "127.0.0.1", GUACD_PORT: "4822", + REMOTE_DESKTOP_ENGINE: "guacd", }); }); @@ -53,6 +54,7 @@ describe("serviceEnv", () => { DATABASE_URL: "mysql://root:termix@termix-dev-mysql:3306/termix", GUACD_HOST: "termix-dev-guacd", GUACD_PORT: "4822", + REMOTE_DESKTOP_ENGINE: "guacd", }); }); }); diff --git a/scripts/dev.mjs b/scripts/dev.mjs index 9d4474a27..2dcb80b74 100644 --- a/scripts/dev.mjs +++ b/scripts/dev.mjs @@ -14,7 +14,8 @@ * mounted. A plugin change rebuilds it and restarts the container. Core * changes wait for "r", since an image build is slow. * - * --db postgres|mysql and --guacd run those in Docker next to it. + * --db postgres|mysql and --guacd run those in Docker next to it; --guacd also + * switches remote desktop from its built-in engine to that guacd. */ import fs from "node:fs"; diff --git a/scripts/electron-backend-ready.test.ts b/scripts/electron-backend-ready.test.ts index f047ec074..773b544a8 100644 --- a/scripts/electron-backend-ready.test.ts +++ b/scripts/electron-backend-ready.test.ts @@ -53,6 +53,7 @@ function start() { isDev: false, appRoot: "/app", BACKEND_STDERR_TAIL_LIMIT: 4096, + backendPort: 30001, handleBackendRequest, setTimeout, clearTimeout, diff --git a/scripts/electron-external-editor.test.ts b/scripts/electron-external-editor.test.ts new file mode 100644 index 000000000..dd8862333 --- /dev/null +++ b/scripts/electron-external-editor.test.ts @@ -0,0 +1,40 @@ +import { createRequire } from "node:module"; +import { describe, expect, it } from "vitest"; + +const require = createRequire(import.meta.url); +const { isLaunchableFileName } = require("../electron/external-editor.cjs") as { + isLaunchableFileName: (fileName: unknown) => boolean; +}; + +describe("Electron external editor", () => { + it("refuses remote file names the OS would run instead of open", () => { + for (const name of [ + "payload.exe", + "run.BAT", + "script.ps1", + "a.exe. ", + "a.cmd...", + "../../evil.vbs", + "start.command", + "launch.desktop", + "install.sh", + ]) { + expect(isLaunchableFileName(name)).toBe(true); + } + }); + + it("opens ordinary text files", () => { + for (const name of [ + "nginx.conf", + "README.md", + "Dockerfile", + ".bashrc", + "config.json", + "index.ts", + "", + undefined, + ]) { + expect(isLaunchableFileName(name)).toBe(false); + } + }); +}); diff --git a/scripts/electron-external-login.test.ts b/scripts/electron-external-login.test.ts new file mode 100644 index 000000000..c143efe3d --- /dev/null +++ b/scripts/electron-external-login.test.ts @@ -0,0 +1,41 @@ +import { createRequire } from "node:module"; +import { describe, expect, it } from "vitest"; + +const require = createRequire(import.meta.url); +const { isExternalLoginUrl, isCallbackPort } = + require("../electron/external-login.cjs") as { + isExternalLoginUrl: (value: unknown) => boolean; + isCallbackPort: (value: unknown) => boolean; + }; + +describe("Electron external login", () => { + it.each([ + "https://idp.example.com/authorize?client_id=termix", + "http://keycloak.lan:8080/realms/home/protocol/openid-connect/auth", + ])("opens web login URLs: %s", (url) => { + expect(isExternalLoginUrl(url)).toBe(true); + }); + + it.each([ + "file:///C:/Windows/System32/calc.exe", + "smb://attacker/share", + "ms-msdt:/id PCWDiagnostic", + "javascript:alert(1)", + "not a url", + undefined, + ])("refuses other schemes: %s", (url) => { + expect(isExternalLoginUrl(url)).toBe(false); + }); + + it("accepts the ports the login screen picks", () => { + expect(isCallbackPort(17832)).toBe(true); + expect(isCallbackPort(17931)).toBe(true); + }); + + it.each([0, 80, 65536, 17832.5, "17832", NaN, undefined])( + "refuses callback port %s", + (port) => { + expect(isCallbackPort(port)).toBe(false); + }, + ); +}); diff --git a/scripts/electron-security-boundary.test.ts b/scripts/electron-security-boundary.test.ts index 1d1d0049b..6de78ce7d 100644 --- a/scripts/electron-security-boundary.test.ts +++ b/scripts/electron-security-boundary.test.ts @@ -32,9 +32,62 @@ describe("Electron security boundary", () => { } }); + it("never hands the session cookie to the renderer over IPC", () => { + for (const channel of ["get-session-cookie", "wait-session-cookie"]) { + expect(main).not.toContain(`"${channel}"`); + expect(preload).not.toContain(`"${channel}"`); + } + }); + + it("launches only the editor saved from a file dialog", () => { + expect(main).not.toContain("fileData?.editorPath"); + expect(main).toContain("readSetting(EXTERNAL_EDITOR_SETTING)"); + expect(main).toContain("dialogChosenPaths.has(value)"); + }); + it("scopes proxy headers and basic auth to the linked origin", () => { expect(main).toContain('"sync-proxy-config"'); expect(main).toContain("linkedServer.applyLinkHeaders("); expect(main).toContain("linkedServer.answerLogin("); }); }); + +it("rejects setting keys that coerce to the protected editor key", async () => { + const { runInNewContext } = await import("node:vm"); + const { vi } = await import("vitest"); + const writeFileSync = vi.fn(); + let handler: (event: unknown, key: unknown, value: unknown) => unknown; + runInNewContext( + main.slice( + main.indexOf('ipcMain.handle("set-setting"'), + main.indexOf('ipcMain.handle("clear-session-cookies"'), + ), + { + ipcMain: { + handle: (_: string, callback: typeof handler) => { + handler = callback; + }, + }, + EXTERNAL_EDITOR_SETTING: "fileManager.externalEditorPath", + dialogChosenPaths: new Set(["/chosen/editor"]), + app: { getPath: () => "/settings" }, + path: { join: (...parts: string[]) => parts.join("/") }, + fs: { existsSync: () => false, mkdirSync: vi.fn(), writeFileSync }, + console, + }, + ); + for (const key of [["fileManager.externalEditorPath"], null, {}, 42]) { + expect(handler!({}, key, "/unapproved/editor")).toEqual({ + success: false, + error: "Invalid setting key", + }); + } + expect(writeFileSync).not.toHaveBeenCalled(); + expect( + handler!({}, "fileManager.externalEditorPath", "/unapproved/editor"), + ).toEqual({ success: false, error: "Choose the editor in the file dialog" }); + expect( + handler!({}, "fileManager.externalEditorPath", "/chosen/editor"), + ).toEqual({ success: true }); + expect(writeFileSync).toHaveBeenCalledOnce(); +}); diff --git a/scripts/generate-release-body.cjs b/scripts/generate-release-body.cjs index 98d1fd43b..370680420 100644 --- a/scripts/generate-release-body.cjs +++ b/scripts/generate-release-body.cjs @@ -38,7 +38,7 @@ function buildTable(version, mobileVersion, mobileTag) { `| Architecture | Windows | Linux | Mac | Android | iOS |`, `|------------------|------------------------------------------------------------------------------------------|-------------------------------------------------------------------------------------------|------------------------------------------------------------------------------------------|---------------------------------------------|-----------------------------------|`, `| **x86-64 (64-bit)** | [EXE](${win("termix_windows_x64_nsis.exe")}) · [MSI](${win("termix_windows_x64_msi.msi")}) · [Portable](${win("termix_windows_x64_portable.zip")}) | [AppImage](${linux("termix_linux_x64_appimage.AppImage")}) · [DEB](${linux("termix_linux_x64_deb.deb")}) · [Portable](${linux("termix_linux_x64_portable.tar.gz")}) | [DMG](${mac("termix_macos_x64_dmg.dmg")}) | - | - |`, - `| **AArch64 (ARM64)** | - | [AppImage](${linux("termix_linux_arm64_appimage.AppImage")}) · [DEB](${linux("termix_linux_arm64_deb.deb")}) · [Portable](${linux("termix_linux_arm64_portable.tar.gz")}) | [DMG](${mac("termix_macos_arm64_dmg.dmg")}) | [APK (${mobileVersion})](${mobileBase}/termix_android.apk) | [IPA (${mobileVersion})](${mobileBase}/termix_ios.ipa) |`, + `| **AArch64 (ARM64)** | [EXE](${win("termix_windows_arm64_nsis.exe")}) · [MSI](${win("termix_windows_arm64_msi.msi")}) · [Portable](${win("termix_windows_arm64_portable.zip")}) | [AppImage](${linux("termix_linux_arm64_appimage.AppImage")}) · [DEB](${linux("termix_linux_arm64_deb.deb")}) · [Portable](${linux("termix_linux_arm64_portable.tar.gz")}) | [DMG](${mac("termix_macos_arm64_dmg.dmg")}) | [APK (${mobileVersion})](${mobileBase}/termix_android.apk) | [IPA (${mobileVersion})](${mobileBase}/termix_ios.ipa) |`, `| **ARMv7 (32-bit)** | - | [AppImage](${linux("termix_linux_armv7l_appimage.AppImage")}) · [DEB](${linux("termix_linux_armv7l_deb.deb")}) · [Portable](${linux("termix_linux_armv7l_portable.tar.gz")}) | - | - | - |`, `| **x86-32 (32-bit)** | [EXE](${win("termix_windows_ia32_nsis.exe")}) · [MSI](${win("termix_windows_ia32_msi.msi")}) · [Portable](${win("termix_windows_ia32_portable.zip")}) | - | - | - | - |`, `| **Universal** | [Chocolatey](https://docs.termix.site/install/apps/windows) | [Flatpak](https://docs.termix.site/install/apps/linux) | [DMG](${mac("termix_macos_universal_dmg.dmg")}) · [App Store](https://apps.apple.com/us/app/termix-ssh-companion/id6752672071) · [Homebrew](https://docs.termix.site/install/apps/macos) | - | - |`, diff --git a/scripts/lib/dev-docker.mjs b/scripts/lib/dev-docker.mjs index 79070ad98..5b5846a01 100644 --- a/scripts/lib/dev-docker.mjs +++ b/scripts/lib/dev-docker.mjs @@ -85,6 +85,8 @@ export function serviceEnv(options, inDocker) { const guacd = SERVICES.guacd; env.GUACD_HOST = inDocker ? guacd.container : "127.0.0.1"; env.GUACD_PORT = String(inDocker ? guacd.port : guacd.hostPort); + // Remote desktop runs built in unless told to use guacd. + env.REMOTE_DESKTOP_ENGINE = "guacd"; } return env; } diff --git a/scripts/lib/dev-runner.mjs b/scripts/lib/dev-runner.mjs index 13a2a074a..f1095a3b0 100644 --- a/scripts/lib/dev-runner.mjs +++ b/scripts/lib/dev-runner.mjs @@ -125,7 +125,7 @@ Options --plugins Plugin repos to build and watch (default ../Plugins) --no-local Use only the plugins pinned in docker/bundled-plugins.json --db sqlite (default), postgres or mysql. Postgres and MySQL run in Docker - --guacd Run guacd in Docker for remote desktop + --guacd Run guacd in Docker and use it instead of the built-in remote desktop --fresh Start from empty data (first run, onboarding) --data Data folder for the backend (default db/data, db/data-desktop for electron, plus - for postgres or mysql) diff --git a/scripts/patch-node-pty.cjs b/scripts/patch-node-pty.cjs index 197529a35..327a98ccf 100644 --- a/scripts/patch-node-pty.cjs +++ b/scripts/patch-node-pty.cjs @@ -3,43 +3,45 @@ const path = require("node:path"); const nodePtyDir = path.join(__dirname, "..", "node_modules", "node-pty"); -if (!fs.existsSync(nodePtyDir)) { - console.log("[patch-node-pty] node-pty not found, skipping"); - process.exit(0); -} - const unixTerminalPath = path.join(nodePtyDir, "lib", "unixTerminal.js"); -const originalHelperRewrite = [ - "helperPath = helperPath.replace('app.asar', 'app.asar.unpacked');", - "helperPath = helperPath.replace('node_modules.asar', 'node_modules.asar.unpacked');", -].join("\n"); +// Older installs may still carry the first version of this patch. +const knownHelperRewrites = [ + [ + "helperPath = helperPath.replace('app.asar', 'app.asar.unpacked');", + "helperPath = helperPath.replace('node_modules.asar', 'node_modules.asar.unpacked');", + ].join("\n"), + [ + "if (!helperPath.includes('app.asar.unpacked')) {", + " helperPath = helperPath.replace('app.asar', 'app.asar.unpacked');", + "}", + "if (!helperPath.includes('node_modules.asar.unpacked')) {", + " helperPath = helperPath.replace('node_modules.asar', 'node_modules.asar.unpacked');", + "}", + ].join("\n"), +]; -const patchedHelperRewrite = [ - "if (!helperPath.includes('app.asar.unpacked')) {", - " helperPath = helperPath.replace('app.asar', 'app.asar.unpacked');", - "}", - "if (!helperPath.includes('node_modules.asar.unpacked')) {", - " helperPath = helperPath.replace('node_modules.asar', 'node_modules.asar.unpacked');", - "}", -].join("\n"); +// A universal macOS build ships app-arm64.asar and app-x64.asar, not app.asar. +const patchedHelperRewrite = + "helperPath = helperPath.replace(/\\b(app(?:-[a-z0-9]+)?|node_modules)\\.asar(?!\\.unpacked)/g, '$1.asar.unpacked');"; -function patchUnixTerminalHelperRewrite() { - if (!fs.existsSync(unixTerminalPath)) { +function patchUnixTerminalHelperRewrite(file = unixTerminalPath) { + if (!fs.existsSync(file)) { return false; } - const source = fs.readFileSync(unixTerminalPath, "utf8"); + const source = fs.readFileSync(file, "utf8"); if (source.includes(patchedHelperRewrite)) { return false; } - if (!source.includes(originalHelperRewrite)) { + const known = knownHelperRewrites.find((rewrite) => source.includes(rewrite)); + if (!known) { return false; } fs.writeFileSync( - unixTerminalPath, - source.replace(originalHelperRewrite, patchedHelperRewrite), + file, + source.replace(known, () => patchedHelperRewrite), ); return true; } @@ -67,19 +69,31 @@ function chmodSpawnHelpers(rootDir) { return fixed; } -const helperRewritePatched = patchUnixTerminalHelperRewrite(); -const helpersChmodded = chmodSpawnHelpers(nodePtyDir); +// Tests and afterPack require this file; only a direct run patches node_modules. +function main() { + if (!fs.existsSync(nodePtyDir)) { + console.log("[patch-node-pty] node-pty not found, skipping"); + return; + } -if (helperRewritePatched) { - console.log("[patch-node-pty] Patched unixTerminal helper path rewrite"); + const helperRewritePatched = patchUnixTerminalHelperRewrite(); + const helpersChmodded = chmodSpawnHelpers(nodePtyDir); + + if (helperRewritePatched) { + console.log("[patch-node-pty] Patched unixTerminal helper path rewrite"); + } + if (helpersChmodded > 0) { + console.log( + `[patch-node-pty] Restored execute bit on ${helpersChmodded} spawn-helper binary(ies)`, + ); + } + if (!helperRewritePatched && helpersChmodded === 0) { + console.log("[patch-node-pty] Already patched or target files not found"); + } } -if (helpersChmodded > 0) { - console.log( - `[patch-node-pty] Restored execute bit on ${helpersChmodded} spawn-helper binary(ies)`, - ); -} -if (!helperRewritePatched && helpersChmodded === 0) { - console.log("[patch-node-pty] Already patched or target files not found"); + +if (require.main === module) { + main(); } module.exports = { chmodSpawnHelpers, patchUnixTerminalHelperRewrite }; diff --git a/scripts/plugin-build.test.ts b/scripts/plugin-build.test.ts index e4b1caa96..ec71c9361 100644 --- a/scripts/plugin-build.test.ts +++ b/scripts/plugin-build.test.ts @@ -92,6 +92,74 @@ describe("termix-plugin build", () => { }); }); +describe("termix-plugin build workers and wasm", () => { + // The smallest valid module: magic and version, no sections. + const EMPTY_WASM = Buffer.from([0x00, 0x61, 0x73, 0x6d, 0x01, 0, 0, 0]); + + it("inlines a .wasm import as its bytes", async () => { + vi.spyOn(console, "log").mockImplementation(() => {}); + const dir = fixturePlugin(); + fs.writeFileSync(path.join(dir, "src", "backend", "mod.wasm"), EMPTY_WASM); + fs.writeFileSync( + path.join(dir, "src", "backend", "index.ts"), + 'import bytes from "./mod.wasm";\nexport const valid = WebAssembly.validate(bytes);\nexport function activate() {}\n', + ); + + await build({ cwd: dir }); + + const mod = await import( + pathToFileURL(path.join(dir, "dist", "backend.js")).href + ); + expect(mod.valid).toBe(true); + }); + + it("bundles each worker entry on its own into dist/workers", async () => { + vi.spyOn(console, "log").mockImplementation(() => {}); + const dir = fixturePlugin(); + fs.writeFileSync( + path.join(dir, "package.json"), + JSON.stringify({ termix: { workers: { engine: "src/worker.ts" } } }), + ); + const dep = path.join(dir, "node_modules", "worker-dep"); + fs.mkdirSync(dep, { recursive: true }); + fs.writeFileSync( + path.join(dep, "package.json"), + JSON.stringify({ name: "worker-dep", main: "index.js" }), + ); + fs.writeFileSync(path.join(dep, "index.js"), "exports.answer = 42;\n"); + fs.writeFileSync( + path.join(dir, "src", "worker.ts"), + 'import { parentPort } from "node:worker_threads";\nimport { answer } from "worker-dep";\nparentPort?.postMessage(answer);\n', + ); + + await build({ cwd: dir }); + + const file = path.join(dir, "dist", "workers", "engine.js"); + expect(fs.readFileSync(file, "utf8")).not.toContain('from "worker-dep"'); + const { Worker } = await import("node:worker_threads"); + const answer = await new Promise((resolve, reject) => { + const worker = new Worker(file); + worker.once("message", (value) => { + void worker.terminate(); + resolve(value); + }); + worker.once("error", reject); + }); + expect(answer).toBe(42); + }); + + it("refuses a worker whose entry is missing", async () => { + vi.spyOn(console, "log").mockImplementation(() => {}); + const dir = fixturePlugin(); + fs.writeFileSync( + path.join(dir, "package.json"), + JSON.stringify({ termix: { workers: { engine: "src/nope.ts" } } }), + ); + + await expect(build({ cwd: dir })).rejects.toThrow(/entry src\/nope.ts/); + }); +}); + describe("plugin CSS layers", () => { it("renames the Tailwind layers so core's utilities always win", () => { expect( diff --git a/scripts/plugin-pack.test.ts b/scripts/plugin-pack.test.ts index b782aaef2..3d975a0f1 100644 --- a/scripts/plugin-pack.test.ts +++ b/scripts/plugin-pack.test.ts @@ -46,6 +46,8 @@ function fixturePlugin(manifest: Record = {}): string { writeFile(dir, "migrations/mysql/0001_init.sql", "SELECT 1;\n"); writeFile(dir, "README.md", "# Fixture\n"); writeFile(dir, "icon.svg", "\n"); + writeFile(dir, "LICENSE", "Apache-2.0\n"); + writeFile(dir, "NOTICE", "Third-party notices\n"); writeFile(dir, "src/backend/index.ts", "left out of the archive\n"); return dir; } @@ -91,6 +93,8 @@ describe("termix-plugin pack", () => { const names = await entries(file); expect(names).toEqual([ + "LICENSE", + "NOTICE", "README.md", "dist/backend.js", "dist/frontend.js", diff --git a/src/backend/database/database.ts b/src/backend/database/database.ts index 3f7530abd..c93ae8ea1 100644 --- a/src/backend/database/database.ts +++ b/src/backend/database/database.ts @@ -38,6 +38,7 @@ import { attachPluginWebSockets } from "../plugins/ws.js"; import pluginRoutes from "./routes/plugins.js"; import { createPluginAssetsRouter } from "../plugins/assets.js"; import { getPluginRuntime } from "../plugins/index.js"; +import { pluginEvents, TOPICS } from "../plugins/events.js"; import { createCorsMiddleware } from "../utils/cors-config.js"; import { createCompressionMiddleware } from "../utils/compression-config.js"; import fs from "fs"; @@ -79,7 +80,6 @@ import { createCurrentSshCredentialUsageRepository, createCurrentUserRepository, } from "./repositories/factory.js"; -import { withCurrentSqliteForeignKeysDisabled } from "./repositories/sqlite-foreign-keys.js"; import { applyPluginHostImportSettings } from "./routes/host-plugin-settings.js"; import { selectExportableSettings, @@ -1235,227 +1235,221 @@ app.post( }; try { - await withCurrentSqliteForeignKeysDisabled(async () => { - try { - const importedHosts = importDb - .prepare("SELECT * FROM ssh_data") - .all(); - const importedHostIds: number[] = []; - for (const host of importedHosts) { - try { - const hostRepository = createCurrentHostRepository(); - const exists = await hostRepository.existsForImportIdentity( - userId, - host.ip, - host.port, - host.username, - ); - - if (exists) { - result.summary.skippedItems++; - continue; - } - - const hostData = { - userId: userId, - name: host.name, - ip: host.ip, - port: host.port, - username: host.username, - folder: host.folder, - tags: host.tags, - pin: Boolean(host.pin), - authType: host.auth_type, - forceKeyboardInteractive: host.force_keyboard_interactive, - password: host.password, - key: host.key, - keyPassword: host.key_password, - keyType: host.key_type, - sudoPassword: host.sudo_password, - credentialId: host.credential_id || null, - overrideCredentialUsername: Boolean( - host.override_credential_username, - ), - jumpHosts: host.jump_hosts, - ...legacyStatusCheck(host), - // Exports from before 2.9.0 carry these in terminal_config. - sshOptions: - host.ssh_options ?? - sshOptionsForWrite({ - terminalConfig: host.terminal_config, - }) ?? - null, - notes: host.notes, - useSocks5: Boolean(host.use_socks5), - socks5Host: host.socks5_host, - socks5Port: host.socks5_port, - socks5Username: host.socks5_username, - socks5Password: host.socks5_password, - socks5ProxyChain: host.socks5_proxy_chain, - createdAt: host.created_at || new Date().toISOString(), - updatedAt: new Date().toISOString(), - }; - - (hostData as Record).defaultOverrides = - JSON.stringify( - await applyHostDefaultsToWrite({ - ownerId: userId, - hostId: null, - columns: hostData as Record, - body: hostData as Record, - }), - ); - const created = await hostRepository.createEncryptedForUser( - userId, - hostData, - ); - await applyPluginHostImportSettings( - Number(created.id), - importedHostPluginSettings(importDb, host), - ); - importedHostIds.push(Number(created.id)); - await importHostProtocolLogins( - importDb, - host, - Number(created.id), - userId, - ); - result.summary.sshHostsImported++; - } catch (hostError) { - result.summary.errors.push( - `SSH host import error: ${hostError.message}`, - ); - } - } - await applyDefaultsAfterHostWrites(importedHostIds); - } catch { - apiLogger.info("ssh_data table not found in import file, skipping"); - } - - try { - const importedCreds = importDb - .prepare("SELECT * FROM ssh_credentials") - .all(); - for (const cred of importedCreds) { - try { - const credentialRepository = - createCurrentCredentialRepository(); - const exists = - await credentialRepository.existsForImportIdentity( - userId, - cred.name, - cred.username, - ); - - if (exists) { - result.summary.skippedItems++; - continue; - } - - const credData = { - userId: userId, - name: cred.name, - description: cred.description, - folder: cred.folder, - tags: cred.tags, - authType: cred.auth_type, - username: cred.username, - password: cred.password, - key: cred.key, - privateKey: cred.private_key, - publicKey: cred.public_key, - keyPassword: cred.key_password, - keyType: cred.key_type, - detectedKeyType: cred.detected_key_type, - usageCount: cred.usage_count || 0, - lastUsed: cred.last_used, - createdAt: cred.created_at || new Date().toISOString(), - updatedAt: new Date().toISOString(), - }; - - await credentialRepository.createEncryptedForUser( - userId, - credData, - ); - result.summary.sshCredentialsImported++; - } catch (credError) { - result.summary.errors.push( - `SSH credential import error: ${credError.message}`, - ); - } - } - } catch { - apiLogger.info( - "ssh_credentials table not found in import file, skipping", - ); - } - - const pluginRows = await importUserPluginRows(importDb, userId); - result.summary.pluginItemsImported += pluginRows.imported; - result.summary.skippedItems += pluginRows.skipped; - result.summary.errors.push(...pluginRows.errors); - - const hostDefaults = await importHostDefaults(importDb, userId); - result.summary.skippedItems += hostDefaults.skipped; - if (hostDefaults.imported > 0) { - await recompute({ userIds: [userId] }); - } - - const targetUser = await userRepository.findById(userId); - if (targetUser?.isAdmin) { + // The file's credential ids belong to the server that wrote it. + const credentialIds = new Map(); + try { + const importedCreds = importDb + .prepare("SELECT * FROM ssh_credentials") + .all(); + for (const cred of importedCreds) { try { - const importedSettings = readImportedSettings(importDb); - for (const setting of importedSettings) { - try { - await upsertImportedSetting(setting); - result.summary.settingsImported++; - } catch (settingError) { - result.summary.errors.push( - `Setting import error (${setting.key}): ${settingError.message}`, - ); - } + const credentialRepository = createCurrentCredentialRepository(); + const existingId = + await credentialRepository.findIdForImportIdentity( + userId, + cred.name, + cred.username, + ); + + if (existingId !== null) { + credentialIds.set(cred.id, existingId); + result.summary.skippedItems++; + continue; } - } catch { - apiLogger.info( - "settings table not found in import file, skipping", + + const credData = { + userId: userId, + name: cred.name, + description: cred.description, + folder: cred.folder, + tags: cred.tags, + authType: cred.auth_type, + username: cred.username, + password: cred.password, + key: cred.key, + privateKey: cred.private_key, + publicKey: cred.public_key, + keyPassword: cred.key_password, + keyType: cred.key_type, + detectedKeyType: cred.detected_key_type, + usageCount: cred.usage_count || 0, + lastUsed: cred.last_used, + createdAt: cred.created_at || new Date().toISOString(), + updatedAt: new Date().toISOString(), + }; + + const created = await credentialRepository.createEncryptedForUser( + userId, + credData, + ); + credentialIds.set(cred.id, Number(created.id)); + result.summary.sshCredentialsImported++; + } catch (credError) { + result.summary.errors.push( + `SSH credential import error: ${credError.message}`, ); } - } else { - apiLogger.info( - "Settings import skipped - only admin users can import settings", - ); } + } catch { + apiLogger.info( + "ssh_credentials table not found in import file, skipping", + ); + } - result.success = true; - - try { - await DatabaseSaveTrigger.forceSave("database_import"); - } catch (saveError) { - apiLogger.error( - "Failed to persist imported data to disk", - saveError, - { - operation: "import_force_save_failed", + try { + const importedHosts = importDb + .prepare("SELECT * FROM ssh_data") + .all(); + const importedHostIds: number[] = []; + for (const host of importedHosts) { + try { + const hostRepository = createCurrentHostRepository(); + const exists = await hostRepository.existsForImportIdentity( userId, - }, - ); + host.ip, + host.port, + host.username, + ); + + if (exists) { + result.summary.skippedItems++; + continue; + } + + const hostData = { + userId: userId, + name: host.name, + ip: host.ip, + port: host.port, + username: host.username, + folder: host.folder, + tags: host.tags, + pin: Boolean(host.pin), + authType: host.auth_type, + forceKeyboardInteractive: host.force_keyboard_interactive, + password: host.password, + key: host.key, + keyPassword: host.key_password, + keyType: host.key_type, + sudoPassword: host.sudo_password, + credentialId: credentialIds.get(host.credential_id) ?? null, + overrideCredentialUsername: Boolean( + host.override_credential_username, + ), + jumpHosts: host.jump_hosts, + ...legacyStatusCheck(host), + // Exports from before 2.9.0 carry these in terminal_config. + sshOptions: + host.ssh_options ?? + sshOptionsForWrite({ + terminalConfig: host.terminal_config, + }) ?? + null, + notes: host.notes, + useSocks5: Boolean(host.use_socks5), + socks5Host: host.socks5_host, + socks5Port: host.socks5_port, + socks5Username: host.socks5_username, + socks5Password: host.socks5_password, + socks5ProxyChain: host.socks5_proxy_chain, + createdAt: host.created_at || new Date().toISOString(), + updatedAt: new Date().toISOString(), + }; + + (hostData as Record).defaultOverrides = + JSON.stringify( + await applyHostDefaultsToWrite({ + ownerId: userId, + hostId: null, + columns: hostData as Record, + body: hostData as Record, + }), + ); + const created = await hostRepository.createEncryptedForUser( + userId, + hostData, + ); + await applyPluginHostImportSettings( + Number(created.id), + importedHostPluginSettings(importDb, host), + ); + importedHostIds.push(Number(created.id)); + await importHostProtocolLogins( + importDb, + host, + Number(created.id), + userId, + credentialIds, + ); + result.summary.sshHostsImported++; + } catch (hostError) { + result.summary.errors.push( + `SSH host import error: ${hostError.message}`, + ); + } } - }); + await applyDefaultsAfterHostWrites(importedHostIds); + for (const hostId of importedHostIds) { + pluginEvents.emit(TOPICS.hostCreated, { hostId, userId }); + } + } catch { + apiLogger.info("ssh_data table not found in import file, skipping"); + } + + const pluginRows = await importUserPluginRows(importDb, userId); + result.summary.pluginItemsImported += pluginRows.imported; + result.summary.skippedItems += pluginRows.skipped; + result.summary.errors.push(...pluginRows.errors); + + const hostDefaults = await importHostDefaults(importDb, userId); + result.summary.skippedItems += hostDefaults.skipped; + if (hostDefaults.imported > 0) { + await recompute({ userIds: [userId] }); + } + + const targetUser = await userRepository.findById(userId); + if (targetUser?.isAdmin) { + try { + const importedSettings = readImportedSettings(importDb); + for (const setting of importedSettings) { + try { + await upsertImportedSetting(setting); + result.summary.settingsImported++; + } catch (settingError) { + result.summary.errors.push( + `Setting import error (${setting.key}): ${settingError.message}`, + ); + } + } + } catch { + apiLogger.info("settings table not found in import file, skipping"); + } + } else { + apiLogger.info( + "Settings import skipped - only admin users can import settings", + ); + } + + result.success = true; + + try { + await DatabaseSaveTrigger.forceSave("database_import"); + } catch (saveError) { + apiLogger.error( + "Failed to persist imported data to disk", + saveError, + { + operation: "import_force_save_failed", + userId, + }, + ); + } } finally { if (importDb) { importDb.close(); } } - try { - fs.unlinkSync(req.file.path); - } catch { - apiLogger.warn("Failed to clean up uploaded file", { - operation: "file_cleanup_warning", - filePath: req.file.path, - }); - } - res.json({ success: result.success, message: result.success @@ -1472,17 +1466,6 @@ app.post( }); } } catch (error) { - if (req.file?.path && fs.existsSync(req.file.path)) { - try { - fs.unlinkSync(req.file.path); - } catch { - apiLogger.warn("Failed to clean up uploaded file after error", { - operation: "file_cleanup_error", - filePath: req.file.path, - }); - } - } - apiLogger.error("SQLite import failed", error, { operation: "sqlite_import_api_failed", userId: (req as AuthenticatedRequest).userId, @@ -1491,6 +1474,17 @@ app.post( error: "Failed to import SQLite data", details: getErrorMessage(error), }); + } finally { + if (req.file) { + try { + fs.rmSync(req.file.path, { force: true }); + } catch { + apiLogger.warn("Failed to clean up uploaded file", { + operation: "file_cleanup_warning", + filePath: req.file.path, + }); + } + } } }), ); @@ -1726,7 +1720,8 @@ app.use( }, ); -const HTTP_PORT = 30001; +// The desktop app moves its embedded backend off 30001 when that is taken. +const HTTP_PORT = Number(process.env.EMBEDDED_BACKEND_PORT) || 30001; async function initializeSecurity() { try { @@ -2012,14 +2007,15 @@ function legacyStatusCheck(row: Record): { /** * A host's plugin protocol logins from an export. The file's credential ids - * belong to the server that wrote it, so a login keeps one only when it is - * a credential the importing user can use. + * belong to the server that wrote it, so a login keeps one only when it maps + * to a credential imported alongside it. */ async function importHostProtocolLogins( importDb: Database.Database, host: Record, hostId: number, userId: string, + credentialIds: Map, ): Promise { let rows: Array>; try { @@ -2040,7 +2036,7 @@ async function importHostProtocolLogins( } protocolAuth[String(row.protocol)] = { authType: row.auth_type, - credentialId: row.credential_id, + credentialId: credentialIds.get(row.credential_id as number) ?? null, username: row.username, password: row.password, fields, diff --git a/src/backend/database/db/index.ts b/src/backend/database/db/index.ts index 09353a472..e72930e99 100644 --- a/src/backend/database/db/index.ts +++ b/src/backend/database/db/index.ts @@ -925,6 +925,38 @@ const dropRetiredColumns = () => { } }; +// Registration checked for a taken username and then inserted with nothing +// enforcing it, so concurrent sign-ups could create two accounts with one +// name, and every lookup by username picked between them arbitrarily. Later +// duplicates are renamed rather than deleted, so no account is lost. +const enforceUniqueUsernames = () => { + try { + sqlite.exec(` + WITH ranked AS MATERIALIZED ( + SELECT id, username, + ROW_NUMBER() OVER (ORDER BY registered_at, id) AS sequence, + ROW_NUMBER() OVER (PARTITION BY username ORDER BY registered_at, id) AS occurrence + FROM users + ), candidates AS ( + SELECT 0 AS candidate UNION ALL SELECT sequence FROM ranked + ), available AS ( + SELECT MIN(candidate) AS candidate FROM candidates + WHERE NOT EXISTS ( + SELECT 1 FROM ranked WHERE username LIKE 'termix-recovered-' || candidate || '-' || '%' + ) + ) + UPDATE users SET username = 'termix-recovered-' || (SELECT candidate FROM available) || '-' || ranked.sequence + FROM ranked WHERE ranked.id = users.id AND ranked.occurrence > 1; + CREATE UNIQUE INDEX IF NOT EXISTS idx_users_username ON users (username); + `); + } catch (error) { + databaseLogger.warn("Failed to enforce unique usernames", { + operation: "schema_migration", + error: getErrorMessage(error), + }); + } +}; + const migrateSchema = () => { repairSnippetsColumns(sqlite); addColumnIfNotExists("user_preferences", "theme", "TEXT"); @@ -996,6 +1028,7 @@ const migrateSchema = () => { ); } } + enforceUniqueUsernames(); addColumnIfNotExists("sessions", "external_session_ref", "TEXT"); addColumnIfNotExists("ssh_data", "name", "TEXT"); @@ -1893,6 +1926,23 @@ async function moveRemoteLegacySudoPasswords(remote: { } } +async function dropMysqlForeignKeys( + remote: { execute: (query: SQL) => Promise }, + table: string, + column: string, +): Promise { + const keys = resultRows<{ name: string }>( + await remote.execute( + sql`SELECT CONSTRAINT_NAME AS name FROM information_schema.KEY_COLUMN_USAGE WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ${table} AND COLUMN_NAME = ${column} AND REFERENCED_TABLE_NAME IS NOT NULL`, + ), + ); + for (const key of keys) { + await remote.execute( + sql`ALTER TABLE ${sql.identifier(table)} DROP FOREIGN KEY ${sql.identifier(key.name)}`, + ); + } +} + /** * The columns 2.9.0 left in place without a drizzle migration, so drizzle no * longer knows them. Dropped by hand, only where they still exist. @@ -1928,6 +1978,10 @@ async function dropRemoteRetired( for (const column of columns) { if (!existing.has(`${table}.${column}`)) continue; try { + // Postgres drops a column's foreign keys with it; MySQL refuses. + if (dialect === "mysql") { + await dropMysqlForeignKeys(remote, table, column); + } await remote.execute( sql`ALTER TABLE ${sql.identifier(table)} DROP COLUMN ${sql.identifier(column)}`, ); diff --git a/src/backend/database/db/retired-columns.ts b/src/backend/database/db/retired-columns.ts index 2f431e3ac..b2784205d 100644 --- a/src/backend/database/db/retired-columns.ts +++ b/src/backend/database/db/retired-columns.ts @@ -5,8 +5,11 @@ * * Kept on purpose: columns a move only reads once the owner's data key opens * at a password login (users.totp_*, the rdp, vnc and telnet logins and - * domain), so a user who never signed in to 2.9 still gets them, and - * ssh_data.vault_profile_id, whose foreign key SQLite cannot drop in place. + * domain), so a user who never signed in to 2.9 still gets them. + * + * ssh_data.vault_profile_id goes because its foreign key follows the vault + * plugin's rename of vault_profiles: once the plugin is uninstalled, SQLite + * refuses every write to ssh_data and the other engines refuse the uninstall. */ /** Columns drizzle never knew were removed, dropped by hand on every engine. */ @@ -52,6 +55,7 @@ export const REMOTE_RETIRED_COLUMNS: Record = { "telnet_port", "tunnel_connections", "use_warpgate", + "vault_profile_id", "vnc_port", "web_ui_config", "wol_broadcast_address", diff --git a/src/backend/database/db/schema.mysql.ts b/src/backend/database/db/schema.mysql.ts index e1b6ea765..8876d9c1c 100644 --- a/src/backend/database/db/schema.mysql.ts +++ b/src/backend/database/db/schema.mysql.ts @@ -21,13 +21,19 @@ import { } 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), - registeredAt: text("registered_at").notNull().default(sql`(CURRENT_TIMESTAMP)`), -}); +export const users = mysqlTable( + "users", + { + id: varchar("id", { length: 255 }).primaryKey(), + username: varchar("username", { length: 255 }).notNull(), + passwordHash: text("password_hash").notNull(), + isAdmin: boolean("is_admin").notNull().default(false), + registeredAt: text("registered_at") + .notNull() + .default(sql`(CURRENT_TIMESTAMP)`), + }, + (table) => [uniqueIndex("idx_users_username").on(table.username)], +); export const settings = mysqlTable("settings", { key: varchar("key", { length: 255 }).primaryKey(), diff --git a/src/backend/database/db/schema.pg.ts b/src/backend/database/db/schema.pg.ts index 06d63719a..6f3de67fa 100644 --- a/src/backend/database/db/schema.pg.ts +++ b/src/backend/database/db/schema.pg.ts @@ -21,13 +21,19 @@ import { } 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), - registeredAt: text("registered_at").notNull().default(sql`CURRENT_TIMESTAMP`), -}); +export const users = pgTable( + "users", + { + id: varchar("id", { length: 255 }).primaryKey(), + username: varchar("username", { length: 255 }).notNull(), + passwordHash: text("password_hash").notNull(), + isAdmin: boolean("is_admin").notNull().default(false), + registeredAt: text("registered_at") + .notNull() + .default(sql`CURRENT_TIMESTAMP`), + }, + (table) => [uniqueIndex("idx_users_username").on(table.username)], +); export const settings = pgTable("settings", { key: varchar("key", { length: 255 }).primaryKey(), diff --git a/src/backend/database/db/schema.ts b/src/backend/database/db/schema.ts index bfa0c3eb4..0bd971ffb 100644 --- a/src/backend/database/db/schema.ts +++ b/src/backend/database/db/schema.ts @@ -9,13 +9,19 @@ import { } from "drizzle-orm/sqlite-core"; import { sql } from "drizzle-orm"; -export const users = sqliteTable("users", { - id: text("id").primaryKey(), - username: text("username").notNull(), - passwordHash: text("password_hash").notNull(), - isAdmin: integer("is_admin", { mode: "boolean" }).notNull().default(false), - registeredAt: text("registered_at").notNull().default(sql`CURRENT_TIMESTAMP`), -}); +export const users = sqliteTable( + "users", + { + id: text("id").primaryKey(), + username: text("username").notNull(), + passwordHash: text("password_hash").notNull(), + isAdmin: integer("is_admin", { mode: "boolean" }).notNull().default(false), + registeredAt: text("registered_at") + .notNull() + .default(sql`CURRENT_TIMESTAMP`), + }, + (table) => [uniqueIndex("idx_users_username").on(table.username)], +); export const settings = sqliteTable("settings", { key: text("key").primaryKey(), diff --git a/src/backend/database/repositories/credential-repository.ts b/src/backend/database/repositories/credential-repository.ts index db3fbd610..d5b76f353 100644 --- a/src/backend/database/repositories/credential-repository.ts +++ b/src/backend/database/repositories/credential-repository.ts @@ -103,11 +103,11 @@ export class CredentialRepository { .orderBy(desc(sshCredentials.updatedAt)); } - async existsForImportIdentity( + async findIdForImportIdentity( userId: string, name: string, username: string | null, - ): Promise { + ): Promise { const rows = await this.context.drizzle .select({ id: sshCredentials.id }) .from(sshCredentials) @@ -120,7 +120,7 @@ export class CredentialRepository { ) .limit(1); - return rows.length > 0; + return rows[0]?.id ?? null; } async findDecryptedByIdForUser( diff --git a/src/backend/database/repositories/sqlite-foreign-keys.ts b/src/backend/database/repositories/sqlite-foreign-keys.ts deleted file mode 100644 index cf4f4e5d5..000000000 --- a/src/backend/database/repositories/sqlite-foreign-keys.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { getCurrentRepositorySqlite } from "./factory.js"; -import { needsExplicitPersist, resolveDatabaseDialect } from "../db/dialect.js"; - -export interface SqliteForeignKeyClient { - exec(sql: string): unknown; -} - -export async function withSqliteForeignKeysDisabled( - sqlite: SqliteForeignKeyClient, - operation: () => Promise, -): Promise { - sqlite.exec("PRAGMA foreign_keys = OFF"); - try { - return await operation(); - } finally { - sqlite.exec("PRAGMA foreign_keys = ON"); - } -} - -/** - * 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. - * - * Postgres and MySQL keep their constraints enabled. The portable importer - * writes through repositories and handles individual row failures, so it must - * still be allowed to run there; only SQLite needs this connection-local - * relaxation for legacy backups whose rows are not dependency ordered. - */ -export async function withCurrentSqliteForeignKeysDisabled( - operation: () => Promise, -): Promise { - const dialect = resolveDatabaseDialect(); - if (!needsExplicitPersist(dialect)) { - return operation(); - } - - return withSqliteForeignKeysDisabled(getCurrentRepositorySqlite(), operation); -} diff --git a/src/backend/database/repositories/user-repository.ts b/src/backend/database/repositories/user-repository.ts index f5c148a66..c94961a52 100644 --- a/src/backend/database/repositories/user-repository.ts +++ b/src/backend/database/repositories/user-repository.ts @@ -24,6 +24,27 @@ const userColumns = { ), }; +// The unique-violation codes of better-sqlite3, node-postgres and mysql2. +const UNIQUE_VIOLATION_CODES = new Set([ + "SQLITE_CONSTRAINT_UNIQUE", + "23505", + "ER_DUP_ENTRY", +]); + +/** + * Whether creating a user failed because the username is taken. The id is + * generated, so the username index is the only one a new row can collide on. + * drizzle wraps driver errors, so the code may sit on a cause. + */ +export function isUsernameTaken(error: unknown): boolean { + for (let e = error; e instanceof Error; e = e.cause) { + if (UNIQUE_VIOLATION_CODES.has((e as { code?: string }).code ?? "")) { + return true; + } + } + return false; +} + export class UserRepository { constructor( private readonly context: DatabaseContext, diff --git a/src/backend/database/routes/host-bulk-routes.ts b/src/backend/database/routes/host-bulk-routes.ts index 3cdd575fe..74814a6b3 100644 --- a/src/backend/database/routes/host-bulk-routes.ts +++ b/src/backend/database/routes/host-bulk-routes.ts @@ -140,7 +140,14 @@ export function parseSSHConfig(content: string): SSHConfigHost[] { * Tells status checks and other pollers about hosts an import wrote, the way * a single host create or update does. */ -export function notifyImportedHosts(userId: string, hostIds: number[]): void { +export function notifyImportedHosts( + userId: string, + hostIds: number[], + createdHostIds: number[], +): void { + for (const hostId of createdHostIds) { + pluginEvents.emit(TOPICS.hostCreated, { hostId, userId }); + } for (const hostId of hostIds) { pluginEvents.emit(TOPICS.hostUpdated, { hostId, userId }); } @@ -587,6 +594,7 @@ export function registerHostBulkRoutes( const knownAuthTypes = listKnownAuthTypes(); const writtenHostIds: number[] = []; + const createdHostIds: number[] = []; for (const { host: hostData, index: i, exportId } of orderedHosts) { try { const effectiveConnectionType = hostData.connectionType || "ssh"; @@ -803,6 +811,7 @@ export function registerHostBulkRoutes( ); if (exportId !== undefined) importedIds.set(exportId, saved.id); savedHostId = saved.id; + createdHostIds.push(saved.id); results.success++; } @@ -834,7 +843,7 @@ export function registerHostBulkRoutes( } await applyDefaultsAfterHostWrites(writtenHostIds); - notifyImportedHosts(userId, writtenHostIds); + notifyImportedHosts(userId, writtenHostIds, createdHostIds); res.json({ message: `Import completed: ${results.success} created, ${results.updated} updated, ${results.skipped} skipped, ${results.failed} failed`, @@ -952,6 +961,7 @@ export function registerHostBulkRoutes( } const writtenHostIds: number[] = []; + const createdHostIds: number[] = []; for (let i = 0; i < hostsToImport.length; i++) { const hostData = normalizeImportedHost( hostsToImport[i] as Record, @@ -1032,6 +1042,7 @@ export function registerHostBulkRoutes( sshDataObj, ); writtenHostIds.push(saved.id); + createdHostIds.push(saved.id); results.success++; } } catch (error) { @@ -1043,7 +1054,7 @@ export function registerHostBulkRoutes( } await applyDefaultsAfterHostWrites(writtenHostIds); - notifyImportedHosts(userId, writtenHostIds); + notifyImportedHosts(userId, writtenHostIds, createdHostIds); res.json({ message: `Import completed: ${results.success} created, ${results.updated} updated, ${results.skipped} skipped, ${results.failed} failed`, diff --git a/src/backend/database/routes/host-normalizers.ts b/src/backend/database/routes/host-normalizers.ts index 0202681b3..54beeafbe 100644 --- a/src/backend/database/routes/host-normalizers.ts +++ b/src/backend/database/routes/host-normalizers.ts @@ -88,6 +88,23 @@ export function keepsStoredSshAuth(authType: unknown): boolean { return authType === undefined || authType === null; } +export function isOptionalAuthType(value: unknown): boolean { + return value === undefined || value === null || typeof value === "string"; +} + +/** + * The auth type a new SSH host's secrets point to when the body names none, + * as an API client creating a host from a credential does. + */ +export function impliedSshAuthType( + body: Record, +): string | undefined { + if (body.credentialId) return "credential"; + if (body.key) return "key"; + if (body.password) return "password"; + return undefined; +} + const FOLDER_PATH_SEPARATOR = " / "; /** diff --git a/src/backend/database/routes/host.ts b/src/backend/database/routes/host.ts index 9b25fd3e3..4f6001b82 100644 --- a/src/backend/database/routes/host.ts +++ b/src/backend/database/routes/host.ts @@ -31,8 +31,10 @@ import { import { applyHostKeyTypeUpdate, containsOwnerPrivateAuthUpdate, + impliedSshAuthType, keepsStoredSshAuth, isNonEmptyString, + isOptionalAuthType, isOptionalBoolean, isValidPort, normalizeProtocolEnableFields, @@ -259,7 +261,9 @@ router.post( !isNonEmptyString(ip) || !isValidPort(port) || !isOptionalBoolean(shareSshAuth) || - !isOptionalBoolean(enableSsh) + !isOptionalBoolean(enableSsh) || + !isOptionalAuthType(authType) || + !isOptionalAuthType(authMethod) ) { sshLogger.warn("Invalid SSH data input validation failed", { operation: "host_create", @@ -292,7 +296,9 @@ router.post( const effectiveAuthType = authType || authMethod || - (effectiveConnectionType !== "ssh" ? "password" : undefined); + (effectiveConnectionType !== "ssh" + ? "password" + : impliedSshAuthType(hostData)); const effectiveUsername = username || firstProtocolUsername(protocolAuthPatch); const effectiveName = @@ -403,6 +409,13 @@ router.post( body: hostData, }), ); + // Host defaults may have supplied it. + if (!sshDataObj.authType) { + return res.status(400).json({ + error: + "authType is required (password, key, credential, agent or none)", + }); + } const result = await createCurrentHostRepository().createEncryptedForUser( userId, sshDataObj, @@ -473,6 +486,7 @@ router.post( }); res.json(await withProtocolAuth(stripSensitiveFields(resolvedHost))); + pluginEvents.emit(TOPICS.hostCreated, { hostId: createdHost.id, userId }); notifyStatsHostUpdated(createdHost.id as number, userId, "host_create"); } catch (err) { sshLogger.error("Failed to save SSH host to database", err, { @@ -827,6 +841,8 @@ router.put( !isValidPort(port) || !isOptionalBoolean(shareSshAuth) || !isOptionalBoolean(enableSsh) || + !isOptionalAuthType(authType) || + !isOptionalAuthType(authMethod) || !hostId ) { sshLogger.warn("Invalid SSH data input validation failed for update", { diff --git a/src/backend/database/routes/plugins.ts b/src/backend/database/routes/plugins.ts index d37fa1002..73da18085 100644 --- a/src/backend/database/routes/plugins.ts +++ b/src/backend/database/routes/plugins.ts @@ -243,7 +243,9 @@ export async function listPreLoginPlugins(): Promise< * frontends in the same order the server does. * * On a desktop linked to a server, `runsOnServer` marks plugins that - * only run on that server and stay off here on purpose. + * only run on that server and stay off here on purpose, and, for + * admins, `managedByServer` marks plugins whose on/off state the server + * decides. * tags: * - Plugins * responses: @@ -370,6 +372,7 @@ router.get("/", authenticateJWT, async (req: Request, res: Response) => { features, env, signedBy: loaded?.signedBy ?? null, + managedByServer: linked && (await isManagedByLinkedServer(record.id)), capabilities, grantedCapabilities: grantsByPlugin.get(record.id) ?? [], lastError: loaded?.lastError ?? record.lastError ?? null, @@ -393,9 +396,7 @@ router.get("/", authenticateJWT, async (req: Request, res: Response) => { }); async function isManagedByLinkedServer(pluginId: string): Promise { - if (process.env.ELECTRON_EMBEDDED !== "true") return false; - const { getLink } = await import("../../sync/client/link-store.js"); - if (!(await getLink())) return false; + if (!(await isLinkedDesktop())) return false; const { isServerManaged } = await import("../../sync/client/plugins.js"); return isServerManaged(pluginId); } @@ -500,7 +501,7 @@ router.get( * /plugins/onboarding/apply: * post: * summary: Apply the onboarding plugin picker - * description: Sets every listed plugin to enabled, disabled or removed in one pass. Choices are first raised so that every kept plugin still has its hard dependencies (the adjustments list says which and why). Enabling runs dependencies first, disabling and removing run dependents first, and one plugin failing does not stop the rest. Removing a bundled plugin deletes it and its data; it can be reinstalled from the registry later. Clears the fresh-install pending flag. With dryRun nothing changes and the response lists what would. + * description: Sets every listed plugin to enabled, disabled or removed in one pass. Choices are first raised so that every kept plugin still has its hard dependencies (the adjustments list says which and why). Enabling runs dependencies first, disabling and removing run dependents first, and one plugin failing does not stop the rest. Removing a bundled plugin deletes it and its data; it can be reinstalled from the registry later. Clears the fresh-install pending flag. With dryRun nothing changes and the response lists what would. On a linked desktop, locally managed plugins require version and capabilities matching the reviewed manifest when enabling. * tags: * - Plugins * requestBody: @@ -887,7 +888,7 @@ router.delete( * /plugins/{id}/state: * patch: * summary: Enable or disable a plugin - * description: Persists the new state and starts or stops the plugin immediately. Disabling releases everything the plugin owns, including any port it was listening on. Enabling also starts any installed hard dependency that is off, and disabling also stops the running plugins that depend on it. With dryRun nothing changes and the response lists what would. + * description: Persists the new state and starts or stops the plugin immediately. Disabling releases everything the plugin owns, including any port it was listening on. Enabling also starts any installed hard dependency that is off, and disabling also stops the running plugins that depend on it. With dryRun nothing changes and the response lists what would. On a linked desktop, locally managed plugins require version and capabilities matching the reviewed manifest when enabling. * tags: * - Plugins * parameters: @@ -909,6 +910,12 @@ router.delete( * properties: * enabled: * type: boolean + * version: + * type: string + * capabilities: + * type: array + * items: + * type: string * responses: * 200: * description: The plugin's new state. @@ -962,7 +969,26 @@ router.patch( return; } - const result = await setPluginState(pluginId, enabled); + let result; + if (enabled && (await isLinkedDesktop())) { + const version = readVersion(req.body); + const capabilities = readCapabilities(req.body); + if (version === null || capabilities === null) { + res + .status(400) + .json({ error: "Invalid plugin version or capabilities" }); + return; + } + const { enablePluginWithConsent } = + await import("../../plugins/manage.js"); + result = await enablePluginWithConsent(pluginId, { + version, + capabilities, + userId: (req as AuthenticatedRequest).userId as string, + }); + } else { + result = await setPluginState(pluginId, enabled); + } await auditPluginAction( req, enabled ? "enable_plugin" : "disable_plugin", diff --git a/src/backend/database/routes/user-admin-routes.ts b/src/backend/database/routes/user-admin-routes.ts index cd4381b1c..fa9bec786 100644 --- a/src/backend/database/routes/user-admin-routes.ts +++ b/src/backend/database/routes/user-admin-routes.ts @@ -17,9 +17,10 @@ import { listUserSecondFactors, resetUserSecondFactors, } from "../../auth/second-factor-admin.js"; -import type { - UserRecord, - UserRepository, +import { + isUsernameTaken, + type UserRecord, + type UserRepository, } from "../repositories/user-repository.js"; function isNonEmptyString(val: unknown): val is string { @@ -541,6 +542,9 @@ export function registerUserAdminRoutes( toast: { type: "success", message: `User created: ${username}` }, }); } catch (err) { + if (isUsernameTaken(err)) { + return res.status(409).json({ error: "Username already exists" }); + } authLogger.error("Failed to admin-create user", err); res.status(500).json({ error: "Failed to create user" }); } diff --git a/src/backend/database/routes/users.ts b/src/backend/database/routes/users.ts index 1c8942b67..f5f464197 100644 --- a/src/backend/database/routes/users.ts +++ b/src/backend/database/routes/users.ts @@ -41,9 +41,13 @@ import { createCurrentUserAuthRepository, createCurrentUserRepository, } from "../repositories/factory.js"; -import type { UserRecord } from "../repositories/user-repository.js"; +import { + isUsernameTaken, + type UserRecord, +} from "../repositories/user-repository.js"; import { getTrustedProxyAuthConfig, + getTrustedProxySourceAddress, isTrustedProxyAddress, isTrustedProxyAuthEnabled, resolveTrustedProxyRoles, @@ -264,6 +268,10 @@ router.post("/create", async (req, res) => { toast: { type: "success", message: `User created: ${username}` }, }); } catch (err) { + // Lost a race with a concurrent sign-up for the same name. + if (isUsernameTaken(err)) { + return res.status(409).json({ error: "Username already exists" }); + } authLogger.error("Failed to create user", err); res.status(500).json({ error: "Failed to create user" }); } @@ -304,7 +312,7 @@ router.post("/proxy-login", async (req, res) => { } if (!config.enabled) return res.json({ enabled: false }); - const sourceAddress = req.socket.remoteAddress; + const sourceAddress = getTrustedProxySourceAddress(req); try { if (!isTrustedProxyAddress(sourceAddress, config.trustedProxies)) { authLogger.warn( diff --git a/src/backend/hosts/host-key-verifier.ts b/src/backend/hosts/host-key-verifier.ts index 89b54688c..11c14331a 100644 --- a/src/backend/hosts/host-key-verifier.ts +++ b/src/backend/hosts/host-key-verifier.ts @@ -263,28 +263,6 @@ export class SSHHostKeyVerifier { changeCount: host.hostKeyChangedCount || 0, }); - if (isJumpHost) { - await this.updateHostKey( - hostId, - fingerprint, - keyType, - algorithm, - host.hostKeyChangedCount || 0, - ); - sshLogger.warn("Jump host key changed - auto-accepted", { - operation: "host_key_updated", - hostId, - ip, - port, - fingerprint, - keyType, - userId, - isJumpHost: true, - }); - verify(true); - return; - } - if (!ws) { sshLogger.error( "Host key changed - please connect via Terminal to verify the new key", diff --git a/src/backend/hosts/ssh-connection-pool.ts b/src/backend/hosts/ssh-connection-pool.ts index d6816b0a9..8527d12ea 100644 --- a/src/backend/hosts/ssh-connection-pool.ts +++ b/src/backend/hosts/ssh-connection-pool.ts @@ -86,7 +86,6 @@ export class SSHConnectionPool { private async createPooledClient( key: string, factory: () => Promise, - existing: PooledConnection[], ): Promise { const generation = this.generations.get(key) || 0; this.pendingConnections.set( @@ -109,8 +108,11 @@ export class SSHConnectionPool { inUse: true, hostKey: key, }; - existing.push(pooled); - this.connections.set(key, existing); + // Re-read after the await: concurrent connects and removals may have + // replaced this key's array in the meantime. + const connections = this.connections.get(key) ?? []; + connections.push(pooled); + this.connections.set(key, connections); client.on("end", () => { this.removeConnection(key, client); @@ -187,7 +189,7 @@ export class SSHConnectionPool { if (queue.length === 0) this.waiters.delete(key); else this.waiters.set(key, queue); clearTimeout(waiter.timer); - this.createPooledClient(key, waiter.factory, connections) + this.createPooledClient(key, waiter.factory) .then(waiter.resolve) .catch(waiter.reject); return; @@ -225,7 +227,7 @@ export class SSHConnectionPool { } if (this.hasCapacity(key, connections)) { - return this.createPooledClient(key, factory, connections); + return this.createPooledClient(key, factory); } return this.enqueueWaiter(key, factory); diff --git a/src/backend/plugins/artifact-manifest.ts b/src/backend/plugins/artifact-manifest.ts index b1065db4f..7992dd8f8 100644 --- a/src/backend/plugins/artifact-manifest.ts +++ b/src/backend/plugins/artifact-manifest.ts @@ -1,50 +1,59 @@ +import path from "node:path"; import zlib from "node:zlib"; +import * as tar from "tar"; const MAX_UNPACKED_BYTES = 512 * 1024 * 1024; /** - * Reads one file out of a .tmxplug (a gzipped tar) without unpacking it to - * disk. Returns null when the archive has no such file. + * Reads every copy of one file out of a .tmxplug (a gzipped tar) without + * unpacking it to disk. Uses the same tar parser as the loader's unpack, so + * pax and long-name headers resolve the same way, and only plain files count + * because they are the only kind the unpack writes. Names compare + * case-insensitively since a case-insensitive disk folds them together. */ -export function readArtifactFile( - buffer: Buffer, - wanted: string, -): Buffer | null { - const tar = zlib.gunzipSync(buffer, { maxOutputLength: MAX_UNPACKED_BYTES }); - let offset = 0; - while (offset + 512 <= tar.length) { - const header = tar.subarray(offset, offset + 512); - if (header.every((byte) => byte === 0)) break; - const field = (start: number, length: number) => - header - .subarray(start, start + length) - .toString("utf8") - .replace(/\0.*$/s, ""); - const prefix = field(345, 155); - const name = ( - prefix ? `${prefix}/${field(0, 100)}` : field(0, 100) - ).replace(/^\.\//, ""); - const size = Number.parseInt(field(124, 12).trim() || "0", 8); - if (!Number.isFinite(size) || size < 0) break; - const start = offset + 512; - if (name === wanted) return tar.subarray(start, start + size); - offset = start + Math.ceil(size / 512) * 512; - } - return null; +export function readArtifactFiles(buffer: Buffer, wanted: string): Buffer[] { + const archive = zlib.gunzipSync(buffer, { + maxOutputLength: MAX_UNPACKED_BYTES, + }); + const key = wanted.toLowerCase(); + const found: Buffer[] = []; + const parser = new tar.Parser({ + strict: true, + onReadEntry: (entry) => { + const name = path.posix.normalize(entry.path.replace(/\\/g, "/")); + if (entry.type !== "File" || name.toLowerCase() !== key) { + entry.resume(); + return; + } + const chunks: Buffer[] = []; + entry.on("data", (chunk: Buffer) => chunks.push(chunk)); + entry.on("end", () => found.push(Buffer.concat(chunks))); + }, + }); + // The parser runs synchronously on an in-memory buffer. + parser.end(archive); + return found; } -/** The manifest.json packed in a .tmxplug, parsed but not validated. */ +/** + * The manifest.json packed in a .tmxplug, parsed but not validated. An + * archive with more than one is refused: the unpack keeps the last copy, so + * whichever one this read the other could carry different capabilities. + */ export function readArtifactManifest(buffer: Buffer): Record { - let raw: Buffer | null; + let copies: Buffer[]; try { - raw = readArtifactFile(buffer, "manifest.json"); + copies = readArtifactFiles(buffer, "manifest.json"); } catch { throw new Error("the file is not a valid .tmxplug archive"); } - if (!raw) throw new Error("the archive has no manifest.json"); + if (copies.length === 0) throw new Error("the archive has no manifest.json"); + if (copies.length > 1) { + throw new Error("the archive has more than one manifest.json"); + } let parsed: unknown; try { - parsed = JSON.parse(raw.toString("utf8")); + parsed = JSON.parse(copies[0].toString("utf8")); } catch { throw new Error("manifest.json is not valid JSON"); } diff --git a/src/backend/plugins/ctx-hosts.ts b/src/backend/plugins/ctx-hosts.ts index d4ea27262..c94ec7e71 100644 --- a/src/backend/plugins/ctx-hosts.ts +++ b/src/backend/plugins/ctx-hosts.ts @@ -30,6 +30,7 @@ import { hostStatusService } from "../hosts/status/host-status-service.js"; import { parseSshOptions } from "../hosts/ssh-options.js"; import { hostTerminalExport } from "../database/routes/host-normalizers.js"; import type { DisposableBag } from "./disposables.js"; +import { pluginEvents, TOPICS } from "./events.js"; import { pluginLogger } from "../utils/logger.js"; type AuditFn = ( @@ -502,6 +503,7 @@ export function createPluginHosts({ manifest, bag, audit }: Deps): PluginHosts { await writeSettings(created.id, writes); await markSettingsOwn(created.id, writes); await audit("hosts_create", `host ${created.id}`, { success: true }); + pluginEvents.emit(TOPICS.hostCreated, { hostId: created.id, userId }); return ( await toRecords([created as unknown as Record]) )[0]; diff --git a/src/backend/plugins/ctx-ssh-auth.ts b/src/backend/plugins/ctx-ssh-auth.ts index bc67adc59..36d64401c 100644 --- a/src/backend/plugins/ctx-ssh-auth.ts +++ b/src/backend/plugins/ctx-ssh-auth.ts @@ -25,6 +25,7 @@ import { import { getActor, getActorSessionId } from "./actor.js"; import type { DisposableBag } from "./disposables.js"; import { redactHostSecrets } from "./ctx-hosts.js"; +import { issueUserToken } from "./ctx-users.js"; import { getSshAuthProvider, listSshAuthProviders, @@ -901,5 +902,7 @@ export function createPluginAuth({ manifest, bag, audit }: Deps): PluginAuth { await import("../database/repositories/factory.js"); return createCurrentUserAuthRepository().countUsersForProvider(provider); }, + issueUserToken: (userId, options) => + issueUserToken(manifest, userId, options), }; } diff --git a/src/backend/plugins/ctx-users.ts b/src/backend/plugins/ctx-users.ts new file mode 100644 index 000000000..7794cec07 --- /dev/null +++ b/src/backend/plugins/ctx-users.ts @@ -0,0 +1,94 @@ +/** + * ctx.users, and ctx.auth.issueUserToken: what a plugin may learn about a + * user, and a short-lived session for calling core's own API as one. + */ + +import type { + PluginUserToken, + PluginUserTokenOptions, + PluginUsers, +} from "@termix-ssh/plugin-sdk/backend"; +import type { PluginManifest } from "@termix-ssh/plugin-sdk/manifest"; +import { assertCapability } from "./permissions.js"; +import { getActor } from "./actor.js"; + +const DEFAULT_TOKEN_TTL_SECONDS = 300; +const MAX_TOKEN_TTL_SECONDS = 900; +const MAX_PURPOSE_LENGTH = 200; + +export function createPluginUsers(manifest: PluginManifest): PluginUsers { + return { + getLocale: async (userId) => { + await assertCapability( + manifest.id, + "users:read", + manifest.capabilities, + "users_get_locale", + ); + const { createCurrentUserPreferenceRepository } = + await import("../database/repositories/factory.js"); + const preferences = + await createCurrentUserPreferenceRepository().findByUserId(userId); + return preferences?.language ?? null; + }, + }; +} + +export async function issueUserToken( + manifest: PluginManifest, + userId: string, + options: PluginUserTokenOptions, +): Promise { + await assertCapability( + manifest.id, + "users:impersonate", + manifest.capabilities, + "user_token_issued", + ); + const { logAudit } = await import("../utils/audit-logger.js"); + const purpose = String(options?.purpose ?? "") + .trim() + .slice(0, MAX_PURPOSE_LENGTH); + const ttlSeconds = Math.min( + options?.ttlSeconds ?? DEFAULT_TOKEN_TTL_SECONDS, + MAX_TOKEN_TTL_SECONDS, + ); + const audit = (success: boolean, errorMessage?: string) => + logAudit({ + userId: getActor() ?? null, + username: `plugin:${manifest.id}`, + action: "plugin_user_token_issued", + resourceType: "user", + resourceId: userId, + details: JSON.stringify({ pluginId: manifest.id, purpose, ttlSeconds }), + success, + errorMessage, + }); + + try { + if (!purpose) { + throw new Error("ctx.auth.issueUserToken needs a purpose"); + } + if (!Number.isInteger(ttlSeconds) || ttlSeconds <= 0) { + throw new Error("ttlSeconds must be a positive whole number"); + } + const { createCurrentUserRepository } = + await import("../database/repositories/factory.js"); + const user = userId + ? await createCurrentUserRepository().findById(userId) + : null; + if (!user) throw new Error(`User "${userId}" does not exist`); + + const { AuthManager } = await import("../utils/auth-manager.js"); + const issued = await AuthManager.getInstance().issuePluginSession(user.id, { + pluginId: manifest.id, + purpose, + ttlSeconds, + }); + await audit(true); + return issued; + } catch (error) { + await audit(false, error instanceof Error ? error.message : String(error)); + throw error; + } +} diff --git a/src/backend/plugins/ctx.ts b/src/backend/plugins/ctx.ts index 81334536a..eac8156fc 100644 --- a/src/backend/plugins/ctx.ts +++ b/src/backend/plugins/ctx.ts @@ -62,6 +62,7 @@ import { createPluginFetch, createPluginNotify } from "./ctx-notify.js"; import { createPluginProcess } from "./ctx-process.js"; import { createPluginSystem } from "./ctx-system.js"; import { createPluginPlugins } from "./ctx-plugins.js"; +import { createPluginUsers } from "./ctx-users.js"; import { createPluginCredentials } from "./ctx-credentials.js"; import { isElectronIpcAvailable } from "../utils/electron-ipc-bridge.js"; @@ -957,6 +958,7 @@ export function createPluginContext( auth: createPluginAuth({ manifest, bag: handle.bag, audit: auditCall }), system: createPluginSystem({ manifest, bag: handle.bag, audit: auditCall }), plugins: createPluginPlugins(manifest), + users: createPluginUsers(manifest), desktop: { openIsolatedWindow: (request) => desktopOpenIsolatedWindow(request), diff --git a/src/backend/plugins/events.ts b/src/backend/plugins/events.ts index 9b5554896..9751be450 100644 --- a/src/backend/plugins/events.ts +++ b/src/backend/plugins/events.ts @@ -26,6 +26,8 @@ export const TOPICS = { hostStatus: "host.status", /** A host's key was accepted again after it changed. */ hostKeyUpdated: "host.key.updated", + /** A host was added and anything inventorying hosts should pick it up. */ + hostCreated: "host.created", /** A host's connection details changed and pollers should re-read them. */ hostUpdated: "host.updated", /** A host was deleted and any poller holding it should drop it. */ diff --git a/src/backend/plugins/http.ts b/src/backend/plugins/http.ts index 695a4791b..7ebab8745 100644 --- a/src/backend/plugins/http.ts +++ b/src/backend/plugins/http.ts @@ -42,9 +42,16 @@ const routers = new Map(); * Requests per minute one address may make to one plugin's public routes. * They run without a login, so they are the cheap way to hammer a plugin. */ -function publicRateLimit(): number { +const MAX_PLUGIN_PUBLIC_RATE_LIMIT = 10_000; + +/** The admin's limit when set, else the plugin's own, else 120. */ +function publicRateLimit(pluginLimit?: number): number { const parsed = Number(process.env.TERMIX_PLUGIN_PUBLIC_RATE_LIMIT); - return Number.isFinite(parsed) && parsed > 0 ? parsed : 120; + if (Number.isFinite(parsed) && parsed > 0) return parsed; + if (Number.isFinite(pluginLimit) && (pluginLimit as number) > 0) { + return Math.min(pluginLimit as number, MAX_PLUGIN_PUBLIC_RATE_LIMIT); + } + return 120; } const PUBLIC_RATE_WINDOW_MS = 60_000; const publicHits = new Map(); @@ -54,6 +61,7 @@ export function overPublicRateLimit( pluginId: string, ip: string, now = Date.now(), + pluginLimit?: number, ): boolean { if (publicHits.size > 10_000) { for (const [hitKey, entry] of publicHits) { @@ -67,7 +75,7 @@ export function overPublicRateLimit( return false; } entry.count++; - return entry.count > publicRateLimit(); + return entry.count > publicRateLimit(pluginLimit); } /** Test seam. */ @@ -319,7 +327,9 @@ export function createPluginRouter({ outer.use((req: Request, res: Response, next: NextFunction) => { if (isPublicRequest(req, publicPaths, inner)) { const ip = req.ip || req.socket?.remoteAddress || "unknown"; - if (overPublicRateLimit(pluginId, ip)) { + if ( + overPublicRateLimit(pluginId, ip, Date.now(), options?.publicRateLimit) + ) { res.status(429).json({ error: "Too many requests" }); return; } diff --git a/src/backend/plugins/manage.ts b/src/backend/plugins/manage.ts index d4f586bee..6fd33de7e 100644 --- a/src/backend/plugins/manage.ts +++ b/src/backend/plugins/manage.ts @@ -715,6 +715,28 @@ export async function planStateChange( }; } +/** A local admin consents to the exact mirrored version before granting it access. */ +export function enablePluginWithConsent( + pluginId: string, + options: { version?: string; capabilities?: string[]; userId: string }, +): Promise { + return serialized(async () => { + const plugin = getPluginRuntime().loader.get(pluginId); + if (!plugin) + throw new PluginManageError("Plugin not found", 404, "NOT_FOUND"); + if (options.version !== plugin.manifest.version) { + throw new PluginManageError( + "The plugin version changed; review it again", + 409, + "CONSENT_MISMATCH", + ); + } + assertConsent(options.capabilities, plugin.manifest.capabilities); + await grantDeclared(plugin, options.userId); + return setPluginStateUnlocked(pluginId, true); + }); +} + export function setPluginState( pluginId: string, enabled: boolean, diff --git a/src/backend/plugins/registries.ts b/src/backend/plugins/registries.ts index ad690e8b7..a2d837cd1 100644 --- a/src/backend/plugins/registries.ts +++ b/src/backend/plugins/registries.ts @@ -19,6 +19,9 @@ import { type RegistrySource, } from "./registry-index.js"; import { + COMMUNITY_PLUGIN_KEYS, + TRUSTED_PLUGIN_KEYS, + getCustomPluginKeys, isPluginPublicKey, pluginKeyId, setCustomPluginKeys, @@ -28,6 +31,8 @@ import { const CONFIG_KEY = "plugin_registries"; /** Keys a linked desktop copied from its server, so mirrored plugins load. */ const SYNCED_KEYS_KEY = "plugin_registry_synced_keys"; +/** `addedIn` of a key only the linked server vouches for. */ +const SYNCED = "synced"; const ID_PATTERN = /^[a-z0-9][a-z0-9-]*$/; const MAX_ID_LENGTH = 40; const MAX_NAME_LENGTH = 80; @@ -117,8 +122,8 @@ async function writeRegistriesConfig(config: RegistriesConfig): Promise { await applyCustomKeys(config); } -function toKey(publicKey: string): TrustedPluginKey { - return { id: pluginKeyId(publicKey), publicKey, addedIn: "admin" }; +function toKey(publicKey: string, addedIn = "admin"): TrustedPluginKey { + return { id: pluginKeyId(publicKey), publicKey, addedIn }; } async function readSyncedKeys(): Promise { @@ -134,19 +139,32 @@ async function readSyncedKeys(): Promise { /** * Trusts the key of every custom registry, on or off: a plugin installed - * from a registry that was later turned off still has to load. + * from a registry that was later turned off still has to load. A key added + * here wins over the same key synced from a server. */ async function applyCustomKeys(config: RegistriesConfig): Promise { const keys = new Map(); + for (const publicKey of await readSyncedKeys()) { + keys.set(pluginKeyId(publicKey), toKey(publicKey, SYNCED)); + } for (const entry of config.custom) { keys.set(entry.keyId, toKey(entry.publicKey)); } - for (const publicKey of await readSyncedKeys()) { - keys.set(pluginKeyId(publicKey), toKey(publicKey)); - } setCustomPluginKeys([...keys.values()]); } +/** Whether this app trusts the key independently of its linked server. */ +export function isLocallyTrustedPluginKey(keyId: string): boolean { + return ( + [...TRUSTED_PLUGIN_KEYS, ...COMMUNITY_PLUGIN_KEYS].some( + (key) => key.id === keyId, + ) || + getCustomPluginKeys().some( + (key) => key.id === keyId && key.addedIn !== SYNCED, + ) + ); +} + /** Called before the boot scan so plugins from custom registries load. */ export async function loadCustomRegistryKeys(): Promise { try { diff --git a/src/backend/sync/client/engine.ts b/src/backend/sync/client/engine.ts index f4152ebf3..34b0c46ff 100644 --- a/src/backend/sync/client/engine.ts +++ b/src/backend/sync/client/engine.ts @@ -351,7 +351,10 @@ export class SyncEngine { const key = recordKey(entity.type, row.syncId); const record = records.get(key); if (record && !record.deleted && record.hash === row.hash) continue; - if (record?.errorHash === row.hash) continue; + // Unavailable server rows can recover without another local edit. + if (record?.errorHash === row.hash && record.error !== "unavailable") { + continue; + } ops.push({ entityType: entity.type, syncId: row.syncId, @@ -367,7 +370,12 @@ export class SyncEngine { if (record.revision === 0) continue; if (rows.has(record.syncId) || failed.has(record.syncId)) continue; if (entity.covers && !entity.covers(record.syncId)) continue; - if (record.errorHash === DELETED_HASH) continue; + if ( + record.errorHash === DELETED_HASH && + record.error !== "unavailable" + ) { + continue; + } ops.push({ entityType: entity.type, syncId: record.syncId, @@ -433,6 +441,9 @@ export class SyncEngine { return; } + // A live row the server could not send says nothing; keep ours to retry. + if (!result.deleted && !result.row) return; + // Conflict: the server moved on since this device last saw the row. Its // version wins here, and a local edit it replaced is kept for the user. if (op.op === "upsert" && op.row) { @@ -445,7 +456,7 @@ export class SyncEngine { ); } const resolvers = createResolvers(userId); - if (result.deleted || !result.row) { + if (result.deleted) { await deleteStoredRow(entity, userId, op.syncId); await putRecord(userId, op.entityType, op.syncId, { revision: result.revision, @@ -488,17 +499,21 @@ export class SyncEngine { ); all.push(...result.changes); next = result.nextCursor; - this.serverTypes = new Set(result.entityTypes.map((entry) => entry.type)); - this.state.serverTypes = result.entityTypes.map((entry) => ({ - type: entry.type, - owner: entry.owner, - readOnly: entry.readOnly, - })); + this.learnTypes(result); if (!result.hasMore) break; } return { changes: all, cursor: next }; } + private learnTypes(page: ChangesPage): void { + this.serverTypes = new Set(page.entityTypes.map((entry) => entry.type)); + this.state.serverTypes = page.entityTypes.map((entry) => ({ + type: entry.type, + owner: entry.owner, + readOnly: entry.readOnly, + })); + } + private async pull(link: SyncLink): Promise { const { changes, cursor } = await this.fetchChanges(link, link.cursor); const known = new Set(link.knownTypes); @@ -517,7 +532,14 @@ export class SyncEngine { /** Pulls every row of types this device has not pulled before. */ private async backfill(link: SyncLink): Promise { if (!this.serverTypes) { - await this.fetchChanges(link, Number.MAX_SAFE_INTEGER); + // Only the entity types are wanted, so a one-row page is enough. + this.learnTypes( + await remoteJson( + link, + `/sync/v2/changes?cursor=${link.cursor}&limit=1`, + { timeoutMs: 120_000 }, + ), + ); } const fresh = listEntities() .map((entity) => entity.type) diff --git a/src/backend/sync/client/plugins.ts b/src/backend/sync/client/plugins.ts index 8a0669954..ad841b539 100644 --- a/src/backend/sync/client/plugins.ts +++ b/src/backend/sync/client/plugins.ts @@ -4,7 +4,8 @@ * For every plugin in "mirror" mode, the server decides: a plugin installed * there is installed here, on or off to match, with the capabilities an * admin granted it there. Plugins marked "local" are left to the desktop and - * "server" ones never run on a linked desktop. + * "server" ones never run on a linked desktop. A user plugin no key trusted + * here signed is only ever switched off; the desktop's admin turns it on. */ import fs from "fs"; @@ -21,7 +22,10 @@ import { unloadPlugin, } from "../../plugins/index.js"; import { getPluginsDir } from "../../plugins/paths.js"; -import { setSyncedRegistryKeys } from "../../plugins/registries.js"; +import { + isLocallyTrustedPluginKey, + setSyncedRegistryKeys, +} from "../../plugins/registries.js"; import type { LoadedPlugin } from "../../plugins/loader.js"; import { createCurrentPluginPermissionGrantRepository, @@ -57,6 +61,14 @@ async function isEnabledLocally(pluginId: string): Promise { return record?.state === "enabled"; } +/** + * Signed by a key this desktop trusts on its own. A key the server sent + * would let the server's admin sign anything, so it does not count. + */ +function vouchedHere(local: LoadedPlugin): boolean { + return !!local.signedBy && isLocallyTrustedPluginKey(local.signedBy); +} + const PLUGIN_ID_PATTERN = /^[a-z0-9][a-z0-9-]*$/; /** Downloads next to the installed copy, which stays until the swap. */ @@ -112,6 +124,10 @@ async function replaceWithDownload( if (previous) await unloadPlugin(pluginId); try { const installed = await installPluginArtifact(file); + // Approval belongs to the reviewed version, regardless of remote metadata. + if (wasEnabled && installed.source === "user" && !vouchedHere(installed)) { + await setPluginEnabled(pluginId, false); + } await fs.promises.rm(backup, { force: true }); await fs.promises.rm(`${backup}.sig`, { force: true }); return installed; @@ -155,7 +171,7 @@ async function matchGrants(plugin: RemotePlugin): Promise { if (!local || local.source !== "user") return; // Grants are only copied for code a trusted key signed. Anything else the // desktop's own admin has to review, as with any plugin dropped on disk. - if (!local.signedBy) return; + if (!vouchedHere(local)) return; const declared = new Set(local.manifest.capabilities); const wanted = new Set(plugin.granted.filter((cap) => declared.has(cap))); const existing = await repository.listByPlugin(plugin.id); @@ -196,7 +212,7 @@ async function alertUnsigned(plugin: RemotePlugin): Promise { alertedVersions.add(key); await sendCoreAlert({ title: "Review a feature from your server", - body: `${plugin.name} (${plugin.version}) was copied from your server but is not signed, so it stays off here until you turn it on.`, + body: `${plugin.name} (${plugin.version}) was copied from your server but is not signed by a key this app trusts, so it stays off here until you turn it on.`, severity: "warning", category: "termix.sync.unsigned_plugin", dedupeKey: `sync:${key}`, @@ -239,7 +255,9 @@ async function mirrorOne(link: SyncLink, plugin: RemotePlugin): Promise { await alertOlderDesktop(plugin); throw error; } - if (wasEnabled) await setPluginEnabled(plugin.id, true); + if (wasEnabled && local.source === "bundled") { + await setPluginEnabled(plugin.id, true); + } } else if (newer) { await alertOlderDesktop(plugin); } @@ -263,19 +281,19 @@ async function mirrorOne(link: SyncLink, plugin: RemotePlugin): Promise { } } await matchGrants(plugin); - // Unsigned code is only ever switched off from here, never on. - if (!local.signedBy) { - const enabledHere = await isEnabledLocally(plugin.id); - if (!plugin.enabled && enabledHere) { - await setPluginEnabled(plugin.id, false); - } else if (plugin.enabled && !enabledHere) { - await alertUnsigned(plugin); - } - return; - } } if (!local) return; + // Remote metadata cannot confer bundled trust on a locally loaded user plugin. + if (local.source === "user" && !vouchedHere(local)) { + const enabledHere = await isEnabledLocally(plugin.id); + if (!plugin.enabled && enabledHere) { + await setPluginEnabled(plugin.id, false); + } else if (plugin.enabled && !enabledHere) { + await alertUnsigned(plugin); + } + return; + } if (plugin.enabled !== (await isEnabledLocally(plugin.id))) { await setPluginEnabled(plugin.id, plugin.enabled); } else if (plugin.enabled && local.state !== "active") { @@ -284,6 +302,7 @@ async function mirrorOne(link: SyncLink, plugin: RemotePlugin): Promise { } export async function mirrorPlugins(link: SyncLink): Promise { + const { serialized } = await import("../../plugins/manage.js"); const { plugins, registryKeys } = await remoteJson<{ plugins: RemotePlugin[]; registryKeys?: unknown[]; @@ -295,7 +314,8 @@ export async function mirrorPlugins(link: SyncLink): Promise { for (const plugin of plugins) { try { - await mirrorOne(link, plugin); + // Share the consent/enable lock so an update cannot swap code mid-approval. + await serialized(() => mirrorOne(link, plugin)); } catch (error) { syncLogger.warn("Could not match a feature to the server", { operation: "sync_plugin_mirror", @@ -315,8 +335,13 @@ export async function mirrorPlugins(link: SyncLink): Promise { } } -/** Whether a plugin's on/off state belongs to the linked server. */ +/** + * Whether a plugin's on/off state belongs to the linked server. Code no key + * trusted here vouches for is the desktop's own admin's call to turn on. + */ export function isServerManaged(pluginId: string): boolean { const local = getPluginRuntime().loader.get(pluginId); - return (local?.manifest.desktop ?? "mirror") !== "local"; + const mode = local?.manifest.desktop ?? "mirror"; + if (mode !== "mirror") return mode === "server"; + return local?.source !== "user" || vouchedHere(local); } diff --git a/src/backend/sync/entities.ts b/src/backend/sync/entities.ts index 4d969ed01..ee916cd58 100644 --- a/src/backend/sync/entities.ts +++ b/src/backend/sync/entities.ts @@ -5,7 +5,7 @@ * an entity in without renumbering; anything referenced syncs first. */ -import { and, eq } from "drizzle-orm"; +import { and, eq, getTableColumns } from "drizzle-orm"; import type { SyncRow } from "@termix-ssh/plugin-sdk/backend"; import type { PluginManifest } from "@termix-ssh/plugin-sdk/manifest"; import { @@ -22,6 +22,7 @@ import { import { FieldCrypto } from "../utils/field-crypto.js"; import { syncLogger } from "../utils/logger.js"; import { getPluginRuntime } from "../plugins/index.js"; +import { pluginEvents, TOPICS } from "../plugins/events.js"; import { singletonSyncId } from "./wire.js"; import { parseDefaultOverrides } from "../../types/host-defaults.js"; import { @@ -174,7 +175,7 @@ export function registerCoreSyncEntities(): void { ) : {}, }), - afterWrite: async ({ id, userId, wire, resolveId }) => { + afterWrite: async ({ id, userId, wire, created, resolveId }) => { if (id === null) return; // The server owns host defaults: it keeps what the host set itself // and resolves the rest. A desktop takes the server's values as sent. @@ -195,6 +196,9 @@ export function registerCoreSyncEntities(): void { const { SharedHostSecretsManager } = await import("../utils/shared-host-secrets-manager.js"); await SharedHostSecretsManager.getInstance().resyncHost(id); + if (created) { + pluginEvents.emit(TOPICS.hostCreated, { hostId: id, userId }); + } }, remove: async (row, userId) => { const { deleteOwnedHost } = await import("../hosts/delete-host.js"); @@ -493,6 +497,25 @@ const SHARED_HOST_DROP = [ ...HOST_LOCAL_FIELDS, ]; +const HOST_TEXT_COLUMNS = new Set( + Object.entries(getTableColumns(hosts)) + .filter(([, column]) => column.dataType === "string") + .map(([field]) => field), +); + +/** + * A resolved host field back in the form its column stores, as an owned host + * sends it: the resolver parses JSON columns and turns the "true"/"false" of + * forceKeyboardInteractive into a boolean, which a text column cannot bind. + */ +function storedForm(field: string, value: unknown): unknown { + if (value !== null && typeof value === "object") return JSON.stringify(value); + if (typeof value === "boolean" && HOST_TEXT_COLUMNS.has(field)) { + return String(value); + } + return value; +} + async function loadSharedHosts(userId: string): Promise { const { createCurrentRbacAccessRepository, @@ -540,10 +563,7 @@ async function loadSharedHosts(userId: string): Promise { const wire: SyncRow = {}; for (const [field, value] of Object.entries(host)) { if (SHARED_HOST_DROP.includes(field)) continue; - wire[field] = - value !== null && typeof value === "object" - ? JSON.stringify(value) - : value; + wire[field] = storedForm(field, value); } wire.jumpHosts = await jumpHostsToSyncIds(host.jumpHosts); wire.pluginSettings = await exportHostPluginSettings(hostId, { diff --git a/src/backend/sync/records.ts b/src/backend/sync/records.ts index c32883644..71377b295 100644 --- a/src/backend/sync/records.ts +++ b/src/backend/sync/records.ts @@ -140,6 +140,9 @@ export async function listRecordErrors(userId: string): Promise { return rows.filter((row) => !!row.error); } +/** The largest seq the column holds: a 32-bit integer on Postgres and MySQL. */ +const MAX_SEQ = 2_147_483_647; + export async function listChanges( userId: string, cursor: number, @@ -148,7 +151,8 @@ export async function listChanges( ): Promise { const conditions = [ eq(syncRecords.userId, userId), - gt(syncRecords.seq, cursor), + // Postgres rejects a bound value the column cannot hold. + gt(syncRecords.seq, Math.min(cursor, MAX_SEQ)), ]; if (types && types.length > 0) { conditions.push(inArray(syncRecords.entityType, types)); diff --git a/src/backend/sync/server/push.ts b/src/backend/sync/server/push.ts index 6cf4b41ee..d192e3dfc 100644 --- a/src/backend/sync/server/push.ts +++ b/src/backend/sync/server/push.ts @@ -134,12 +134,23 @@ export async function applyPush( const record = await getRecord(userId, op.entityType, op.syncId); const currentRevision = record?.revision ?? 0; if (op.baseRevision !== currentRevision) { + const row = record?.deleted ? null : (wire.get(key) ?? null); + // A live record with no row failed to serialize; a conflict without a + // row would read as a delete on the desktop. + if (record && !record.deleted && !row) { + results.set(key, { + ...base, + status: "rejected", + reason: "unavailable", + }); + continue; + } results.set(key, { ...base, status: "conflict", revision: currentRevision, deleted: !!record?.deleted, - row: record?.deleted ? null : (wire.get(key) ?? null), + row, }); continue; } diff --git a/src/backend/sync/server/routes.ts b/src/backend/sync/server/routes.ts index 1c3ce4923..fb1639d54 100644 --- a/src/backend/sync/server/routes.ts +++ b/src/backend/sync/server/routes.ts @@ -37,6 +37,17 @@ function userOf(req: Request): string { return (req as AuthenticatedRequest).userId; } +/** A non-negative integer query value, the fallback when absent, null when malformed. */ +function queryInt(value: unknown, fallback: number): number | null { + if (value === undefined) return fallback; + const parsed = Number(value); + return typeof value === "string" && + /^\d+$/.test(value) && + Number.isSafeInteger(parsed) + ? parsed + : null; +} + /** * @openapi * /sync/v2/info: @@ -224,6 +235,8 @@ router.post( * responses: * 200: * description: A page of changes, the next cursor and whether more remain. + * 400: + * description: The cursor or limit is not a non-negative integer. * 500: * description: Failed to read changes. */ @@ -232,11 +245,12 @@ router.get( authenticateJWT, async (req: Request, res: Response) => { const userId = userOf(req); - const cursor = Math.max(0, Number(req.query.cursor) || 0); - const limit = Math.min( - MAX_PAGE, - Math.max(1, Number(req.query.limit) || MAX_PAGE), - ); + const cursor = queryInt(req.query.cursor, 0); + const pageSize = queryInt(req.query.limit, MAX_PAGE); + if (cursor === null || pageSize === null) { + return res.status(400).json({ error: "Invalid cursor or limit" }); + } + const limit = Math.min(MAX_PAGE, Math.max(1, pageSize)); const types = typeof req.query.types === "string" && req.query.types ? req.query.types.split(",").filter(Boolean) diff --git a/src/backend/sync/store.ts b/src/backend/sync/store.ts index 430a3258e..2d6426e18 100644 --- a/src/backend/sync/store.ts +++ b/src/backend/sync/store.ts @@ -13,6 +13,7 @@ import { createCurrentRepositoryContext } from "../database/repositories/factory import { DataCrypto } from "../utils/data-crypto.js"; import { SystemCrypto } from "../utils/system-crypto.js"; import { DatabaseSaveTrigger } from "../utils/database-save-trigger.js"; +import { syncLogger } from "../utils/logger.js"; import { getEntity, type RegisteredSyncEntity, @@ -229,7 +230,13 @@ export async function loadWireRows( ); const wire = toWire(entity, serialized, syncId); rows.set(syncId, { syncId, wire, hash: hashWire(entity, wire, key) }); - } catch { + } catch (error) { + syncLogger.warn("Could not serialize a sync row", { + operation: "sync_serialize", + entityType: entity.type, + syncId, + error: error instanceof Error ? error.message : String(error), + }); failed.add(syncId); } } diff --git a/src/backend/tests/database/db/unique-username.test.ts b/src/backend/tests/database/db/unique-username.test.ts new file mode 100644 index 000000000..4dd31bb03 --- /dev/null +++ b/src/backend/tests/database/db/unique-username.test.ts @@ -0,0 +1,109 @@ +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"; + +/** + * Registration checked for a taken username before inserting, with nothing in + * the schema to back it up, so two concurrent sign-ups could both get the same + * name. Startup renames the later duplicates instead of deleting anyone, then + * makes the name unique. + */ +describe("users.username uniqueness", () => { + let dataDir: string; + + beforeEach(() => { + dataDir = fs.mkdtempSync(path.join(os.tmpdir(), "termix-unique-user-")); + 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 }); + }); + + function writeDatabaseWithDuplicates(): void { + const seed = new Database(":memory:"); + seed.exec(` + CREATE TABLE users ( + id TEXT PRIMARY KEY, + username TEXT NOT NULL, + password_hash TEXT NOT NULL, + is_admin INTEGER NOT NULL DEFAULT 0, + registered_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP + ); + + INSERT INTO users (id, username, password_hash, registered_at) VALUES + ('late', 'alice', 'hash', '2025-03-01 00:00:00'), + ('first', 'alice', 'hash', '2025-01-01 00:00:00'), + ('tie-b', 'bob', 'hash', '2025-01-01 00:00:00'), + ('tie-a', 'bob', 'hash', '2025-01-01 00:00:00'), + ('only', 'Alice', 'hash', '2025-02-01 00:00:00'); + `); + fs.writeFileSync(path.join(dataDir, "db.sqlite"), seed.serialize()); + seed.close(); + } + + async function bootAndGetSqlite(): Promise { + const db = await import("../../../database/db/index.js"); + await db.initializeDatabase(); + return db.getSqlite(); + } + + it("keeps the oldest holder of a name and renames the rest", async () => { + writeDatabaseWithDuplicates(); + + const sqlite = await bootAndGetSqlite(); + + const names = Object.fromEntries( + sqlite + .prepare("SELECT id, username FROM users") + .raw() + .all() as [string, string][], + ); + expect(names).toEqual({ + first: "alice", + late: "termix-recovered-0-5", + "tie-a": "bob", + "tie-b": "termix-recovered-0-3", + only: "Alice", + }); + }); + + it("rejects a second user with a taken name", async () => { + const sqlite = await bootAndGetSqlite(); + + const insert = sqlite.prepare( + "INSERT INTO users (id, username, password_hash) VALUES (?, ?, ?)", + ); + insert.run("u1", "alice", "hash"); + + expect(() => insert.run("u2", "alice", "hash")).toThrow(/UNIQUE/i); + }); +}); + +// Run the checked-in migration itself, including occupied recovery prefixes. +it("migrates duplicate names without colliding with existing names", () => { + const db = new Database(":memory:"); + try { + db.exec(`CREATE TABLE users (id TEXT PRIMARY KEY, username TEXT NOT NULL, registered_at TEXT NOT NULL); + INSERT INTO users VALUES + ('first', 'alice', '2025-01-01'), + ('late', 'alice', '2025-02-01'), + ('other', 'alice-late', '2025-01-01'), + ('occupied', 'termix-recovered-0-1', '2025-01-01');`); + db.exec(fs.readFileSync("drizzle/sqlite/0067_unique_username.sql", "utf8")); + const rows = db.prepare("SELECT username FROM users").pluck().all(); + expect(new Set(rows).size).toBe(4); + expect(rows).toContain("alice"); + expect(rows).toContain("alice-late"); + expect(rows).toContain("termix-recovered-0-1"); + expect(() => db.exec("INSERT INTO users VALUES ('new', 'alice', '2026-01-01')")).toThrow(/UNIQUE/i); + } finally { db.close(); } +}); diff --git a/src/backend/tests/database/db/vault-profile-foreign-key.test.ts b/src/backend/tests/database/db/vault-profile-foreign-key.test.ts new file mode 100644 index 000000000..7828b0a97 --- /dev/null +++ b/src/backend/tests/database/db/vault-profile-foreign-key.test.ts @@ -0,0 +1,89 @@ +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"; + +// ssh_data.vault_profile_id points at vault_profiles. The vault plugin renames +// that table to p_vault_profiles, which SQLite follows into ssh_data's foreign +// key, so uninstalling the plugin used to leave every host write failing with +// "no such table: main.p_vault_profiles". +describe("ssh_data and the vault plugin's tables", () => { + let dataDir: string; + + beforeEach(() => { + dataDir = fs.mkdtempSync(path.join(os.tmpdir(), "termix-vault-fk-")); + vi.resetModules(); + vi.stubEnv("DATA_DIR", dataDir); + vi.stubEnv("DB_FILE_ENCRYPTION", "false"); + vi.stubEnv("ALLOW_EMPTY_DATA_DIR", "true"); + }); + + afterEach(() => { + vi.unstubAllEnvs(); + fs.rmSync(dataDir, { recursive: true, force: true }); + }); + + function seed(afterAdoption: string): void { + const seed = new Database(":memory:"); + seed.exec( + fs.readFileSync( + new URL("../../fixtures/sqlite-2.8-schema.sql", import.meta.url), + "utf8", + ), + ); + seed.exec(` + CREATE TABLE plugin_migrations ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + plugin_id TEXT NOT NULL, + migration_id TEXT NOT NULL, + checksum TEXT NOT NULL, + applied_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + UNIQUE (plugin_id, migration_id), + FOREIGN KEY (plugin_id) REFERENCES plugins (id) ON DELETE CASCADE + ); + INSERT INTO users (id, username, password_hash) + VALUES ('owner', 'alice', 'hash'); + INSERT INTO ssh_data (id, user_id, ip, port, username, auth_type) + VALUES (1, 'owner', '192.0.2.1', 22, 'root', 'password'); + ALTER TABLE vault_profiles RENAME TO p_vault_profiles; + ALTER TABLE vault_tokens RENAME TO p_vault_tokens; + ${afterAdoption} + `); + fs.writeFileSync(path.join(dataDir, "db.sqlite"), seed.serialize()); + seed.close(); + } + + async function boot() { + const db = await import("../../../database/db/index.js"); + await db.initializeDatabase(); + return db.getSqlite(); + } + + function writeHosts(sqlite: Database.Database): void { + sqlite + .prepare( + "INSERT INTO ssh_data (user_id, ip, port, username, auth_type) VALUES ('owner', '192.0.2.2', 22, 'root', 'password')", + ) + .run(); + sqlite.prepare("DELETE FROM ssh_data WHERE id = 1").run(); + } + + it("repairs a database the plugin was already uninstalled from", async () => { + seed("DROP TABLE p_vault_tokens; DROP TABLE p_vault_profiles;"); + const sqlite = await boot(); + + expect(() => writeHosts(sqlite)).not.toThrow(); + }); + + it("leaves nothing in ssh_data pointing at the plugin", async () => { + seed(""); + const sqlite = await boot(); + + expect( + sqlite.prepare("PRAGMA foreign_key_list(ssh_data)").all(), + ).not.toContainEqual(expect.objectContaining({ table: "p_vault_profiles" })); + sqlite.exec("DROP TABLE p_vault_tokens; DROP TABLE p_vault_profiles;"); + expect(() => writeHosts(sqlite)).not.toThrow(); + }); +}); diff --git a/src/backend/tests/database/import-route.test.ts b/src/backend/tests/database/import-route.test.ts new file mode 100644 index 000000000..5670594fc --- /dev/null +++ b/src/backend/tests/database/import-route.test.ts @@ -0,0 +1,164 @@ +import * as fs from "node:fs"; +import { existsSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { runInNewContext } from "node:vm"; +import Database from "better-sqlite3"; +import ts from "typescript"; +import { expect, it, vi } from "vitest"; + +const source = readFileSync( + new URL("../../database/database.ts", import.meta.url), + "utf8", +); +const ast = ts.createSourceFile( + "database.ts", + source, + ts.ScriptTarget.Latest, + true, +); +const route = ast.statements.find( + (s) => + ts.isExpressionStatement(s) && + ts.isCallExpression(s.expression) && + s.expression.arguments[0]?.getText(ast) === '"/database/import"', +) as ts.ExpressionStatement; +const batched = (route.expression as ts.CallExpression) + .arguments[3] as ts.CallExpression; +const callback = batched.arguments[0]; + +const dir = mkdtempSync(join(tmpdir(), "termix-import-route-")); + +function exportFile(): string { + const path = join(dir, `export-${Math.random()}.db`); + const db = new Database(path); + db.exec(` + CREATE TABLE ssh_credentials (id INTEGER, name TEXT, username TEXT); + CREATE TABLE ssh_data (id INTEGER, ip TEXT, port INTEGER, username TEXT, credential_id INTEGER); + INSERT INTO ssh_credentials VALUES (7, 'fresh', 'root'), (8, 'kept', 'admin'); + INSERT INTO ssh_data VALUES + (1, '10.0.0.1', 22, 'root', 7), + (2, '10.0.0.2', 22, 'root', 8), + (3, '10.0.0.3', 22, 'root', 99), + (4, '10.0.0.4', 22, 'root', NULL); + `); + db.close(); + return path; +} + +async function invoke(path: string, user: unknown = { isExternal: false }) { + const writes: string[] = []; + const events: Array<[string, unknown]> = []; + const hosts: Array> = []; + const importHostProtocolLogins = vi.fn(); + const handler = runInNewContext( + ts.transpileModule(`(${callback.getText(ast)})`, { + compilerOptions: { target: ts.ScriptTarget.ES2022 }, + }).outputText, + { + fs, + Buffer, + Database, + parseUserAgent: () => ({ type: "web" }), + createCurrentUserRepository: () => ({ findById: async () => user }), + DataCrypto: { getUserDataKey: () => Buffer.from("key") }, + apiLogger: { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + success: vi.fn(), + }, + createCurrentCredentialRepository: () => ({ + findIdForImportIdentity: async (_: string, name: string) => + name === "kept" ? 42 : null, + createEncryptedForUser: async () => { + writes.push("credential"); + return { id: 100 }; + }, + }), + createCurrentHostRepository: () => ({ + existsForImportIdentity: async () => false, + createEncryptedForUser: async (_: string, host: never) => { + writes.push("host"); + hosts.push(host); + return { id: 500 + hosts.length }; + }, + }), + legacyStatusCheck: () => ({}), + sshOptionsForWrite: () => null, + applyHostDefaultsToWrite: async () => ({}), + applyPluginHostImportSettings: async () => {}, + importedHostPluginSettings: () => ({}), + importHostProtocolLogins, + applyDefaultsAfterHostWrites: async () => {}, + importUserPluginRows: async () => ({ + imported: 0, + skipped: 0, + errors: [], + }), + importHostDefaults: async () => ({ imported: 0, skipped: 0 }), + DatabaseSaveTrigger: { forceSave: async () => {} }, + getErrorMessage: (error: Error) => error.message, + pluginEvents: { + emit: (topic: string, payload: unknown) => + events.push([topic, payload]), + }, + TOPICS: { hostCreated: "host.created" }, + }, + ); + const res = { json: vi.fn(), status: vi.fn() }; + res.status.mockReturnValue(res); + await handler( + { userId: "user-1", file: { path, originalname: "export.db" } }, + res, + ); + return { res, writes, hosts, events, importHostProtocolLogins }; +} + +it("points imported hosts at the credentials imported with them", async () => { + const { res, writes, hosts, importHostProtocolLogins } = + await invoke(exportFile()); + + expect(res.status).not.toHaveBeenCalled(); + expect(writes).toEqual(["credential", "host", "host", "host", "host"]); + expect(hosts.map((host) => host.credentialId)).toEqual([100, 42, null, null]); + expect([...importHostProtocolLogins.mock.calls[0][4]]).toEqual([ + [7, 100], + [8, 42], + ]); +}); + +it("publishes host.created once for each imported host", async () => { + const { events } = await invoke(exportFile()); + + expect(events).toEqual( + [501, 502, 503, 504].map((hostId) => [ + "host.created", + { hostId, userId: "user-1" }, + ]), + ); +}); + +it("removes the uploaded file when the user is gone", async () => { + const path = exportFile(); + const { res } = await invoke(path, null); + + expect(res.status).toHaveBeenCalledWith(404); + expect(existsSync(path)).toBe(false); +}); + +it("removes the uploaded file when it is not a SQLite database", async () => { + const path = join(dir, "not-sqlite.db"); + writeFileSync(path, "definitely not a database"); + const { res } = await invoke(path); + + expect(res.status).toHaveBeenCalledWith(400); + expect(existsSync(path)).toBe(false); +}); + +it("removes the uploaded file after a successful import", async () => { + const path = exportFile(); + await invoke(path); + + expect(existsSync(path)).toBe(false); +}); 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 c4f99b54b..7efc0ada5 100644 --- a/src/backend/tests/database/repositories/host-credential-repositories.test.ts +++ b/src/backend/tests/database/repositories/host-credential-repositories.test.ts @@ -213,7 +213,7 @@ describe("HostRepository and CredentialRepository", () => { it("checks credential import identity", async () => { const repo = await createRepositories(); - await repo.credentials.create({ + const created = await repo.credentials.create({ userId: "user-1", name: "primary", authType: "password", @@ -221,11 +221,11 @@ describe("HostRepository and CredentialRepository", () => { }); await expect( - repo.credentials.existsForImportIdentity("user-1", "primary", "root"), - ).resolves.toBe(true); + repo.credentials.findIdForImportIdentity("user-1", "primary", "root"), + ).resolves.toBe(created.id); await expect( - repo.credentials.existsForImportIdentity("user-1", "primary", "admin"), - ).resolves.toBe(false); + repo.credentials.findIdForImportIdentity("user-1", "primary", "admin"), + ).resolves.toBeNull(); }); it("renames credential folders through the write boundary", async () => { diff --git a/src/backend/tests/database/repositories/sqlite-foreign-keys.test.ts b/src/backend/tests/database/repositories/sqlite-foreign-keys.test.ts deleted file mode 100644 index 9e674ea33..000000000 --- a/src/backend/tests/database/repositories/sqlite-foreign-keys.test.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { afterEach, describe, expect, it, vi } from "vitest"; - -const factory = vi.hoisted(() => ({ - getCurrentRepositorySqlite: vi.fn(), -})); - -vi.mock("../../../database/repositories/factory.js", () => factory); - -import { - withCurrentSqliteForeignKeysDisabled, - withSqliteForeignKeysDisabled, -} from "../../../database/repositories/sqlite-foreign-keys.js"; - -const previousDatabaseDialect = process.env.DATABASE_DIALECT; - -afterEach(() => { - if (previousDatabaseDialect === undefined) - delete process.env.DATABASE_DIALECT; - else process.env.DATABASE_DIALECT = previousDatabaseDialect; - vi.clearAllMocks(); -}); - -describe("withSqliteForeignKeysDisabled", () => { - it("restores foreign keys after an import", async () => { - const sqlite = { exec: vi.fn() }; - - await expect( - withSqliteForeignKeysDisabled(sqlite, async () => "imported"), - ).resolves.toBe("imported"); - expect(sqlite.exec.mock.calls).toEqual([ - ["PRAGMA foreign_keys = OFF"], - ["PRAGMA foreign_keys = ON"], - ]); - }); -}); - -describe("withCurrentSqliteForeignKeysDisabled", () => { - it.each(["postgres", "mysql"])( - "runs portable imports with constraints enabled on %s", - async (dialect) => { - process.env.DATABASE_DIALECT = dialect; - const operation = vi.fn().mockResolvedValue("imported"); - - await expect( - withCurrentSqliteForeignKeysDisabled(operation), - ).resolves.toBe("imported"); - expect(operation).toHaveBeenCalledOnce(); - expect(factory.getCurrentRepositorySqlite).not.toHaveBeenCalled(); - }, - ); -}); diff --git a/src/backend/tests/database/repositories/sync-records-changes.test.ts b/src/backend/tests/database/repositories/sync-records-changes.test.ts new file mode 100644 index 000000000..11b2973b5 --- /dev/null +++ b/src/backend/tests/database/repositories/sync-records-changes.test.ts @@ -0,0 +1,55 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { sql } from "drizzle-orm"; +import { TestSqliteDatabase } from "./test-support.js"; +import type { DatabaseContext } from "../../../database/repositories/database-context.js"; + +const state = vi.hoisted(() => ({ + context: null as DatabaseContext | null, +})); + +vi.mock("../../../database/repositories/factory.js", () => ({ + createCurrentRepositoryContext: () => state.context, +})); +vi.mock("../../../utils/database-save-trigger.js", () => ({ + DatabaseSaveTrigger: { triggerSave: vi.fn() }, +})); + +const { listChanges } = await import("../../../sync/records.js"); + +// Runs against Postgres and MySQL too in the database-dialects CI job, where +// sync_records.seq is a 32-bit integer. +describe("listChanges", () => { + let adapter: TestSqliteDatabase | null = null; + + afterEach(async () => { + if (adapter) { + await adapter.close(); + adapter = null; + } + state.context = null; + }); + + async function seed(): Promise { + adapter = new TestSqliteDatabase(); + state.context = await adapter.connect(); + await adapter.run( + sql`INSERT INTO users (id, username, password_hash) VALUES ('user-1', 'user-1', '')`, + ); + await adapter.run( + sql`INSERT INTO sync_records (user_id, entity_type, sync_id, revision, seq) VALUES ('user-1', 'hosts', 'a', 1, 1), ('user-1', 'hosts', 'b', 1, 2)`, + ); + } + + it("lists records after the cursor in seq order", async () => { + await seed(); + const records = await listChanges("user-1", 1, 10); + expect(records.map((record) => record.syncId)).toEqual(["b"]); + }); + + it("answers no records for a cursor past what seq can hold", async () => { + await seed(); + expect(await listChanges("user-1", Number.MAX_SAFE_INTEGER, 10)).toEqual( + [], + ); + }); +}); diff --git a/src/backend/tests/database/repositories/unique-username-migration.test.ts b/src/backend/tests/database/repositories/unique-username-migration.test.ts new file mode 100644 index 000000000..24ca38afc --- /dev/null +++ b/src/backend/tests/database/repositories/unique-username-migration.test.ts @@ -0,0 +1,44 @@ +import fs from "node:fs"; +import { sql } from "drizzle-orm"; +import { expect, it } from "vitest"; +import { TestSqliteDatabase, testDialect } from "./test-support.js"; + +it("migrates duplicate usernames without colliding with occupied recovery names", async () => { + const adapter = new TestSqliteDatabase(); + await adapter.connect(); + try { + await adapter.exec(`CREATE TABLE username_migration_fixture ( + id varchar(255) PRIMARY KEY, username TEXT NOT NULL, registered_at varchar(255) NOT NULL + ); + INSERT INTO username_migration_fixture VALUES + ('first', 'alice', '2025-01-01'), + ('late', 'alice', '2025-02-01'), + ('other', 'alice-late', '2025-01-01'), + ('occupied', 'termix-recovered-0-1', '2025-01-01');`); + const dialect = testDialect(); + const number = dialect === "sqlite" ? "0067" : "0072"; + const migration = fs + .readFileSync(`drizzle/${dialect}/${number}_unique_username.sql`, "utf8") + .replace(/\busers\b/g, "username_migration_fixture") + .replaceAll("idx_users_username", "idx_fixture_username"); + for (const statement of migration.split("--> statement-breakpoint")) { + if (statement.trim()) await adapter.run(sql.raw(statement)); + } + const rows = await adapter.query<{ username: string }>( + sql`SELECT username FROM username_migration_fixture`, + ); + const names = rows.map((row) => row.username); + expect(new Set(names).size).toBe(4); + expect(names).toEqual( + expect.arrayContaining(["alice", "alice-late", "termix-recovered-0-1"]), + ); + await expect(async () => { + await adapter.run( + sql`INSERT INTO username_migration_fixture VALUES ('new', 'alice', '2026-01-01')`, + ); + }).rejects.toThrow(); + } finally { + await adapter.exec("DROP TABLE IF EXISTS username_migration_fixture"); + await adapter.close(); + } +}); diff --git a/src/backend/tests/database/repositories/user-repository.test.ts b/src/backend/tests/database/repositories/user-repository.test.ts index 5ca831565..8d8e2b5c1 100644 --- a/src/backend/tests/database/repositories/user-repository.test.ts +++ b/src/backend/tests/database/repositories/user-repository.test.ts @@ -1,6 +1,9 @@ import { afterEach, describe, expect, it } from "vitest"; import { TestSqliteDatabase } from "./test-support.js"; -import { UserRepository } from "../../../database/repositories/user-repository.js"; +import { + UserRepository, + isUsernameTaken, +} from "../../../database/repositories/user-repository.js"; describe("UserRepository.listPage", () => { let adapter: TestSqliteDatabase | null = null; @@ -93,3 +96,27 @@ describe("UserRepository.listPage", () => { expect(page.total).toBe(5); }); }); + +describe("UserRepository.create", () => { + let adapter: TestSqliteDatabase | null = null; + + afterEach(async () => { + if (adapter) { + await adapter.close(); + adapter = null; + } + }); + + it("rejects a taken username in a way callers can recognise", async () => { + adapter = new TestSqliteDatabase(); + const repo = new UserRepository(await adapter.connect()); + await repo.create({ id: "u1", username: "alice", passwordHash: "hash" }); + + const error = await repo + .create({ id: "u2", username: "alice", passwordHash: "hash" }) + .catch((e: unknown) => e); + + expect(isUsernameTaken(error)).toBe(true); + expect(isUsernameTaken(new Error("unrelated"))).toBe(false); + }); +}); diff --git a/src/backend/tests/database/routes/host-bulk-routes.test.ts b/src/backend/tests/database/routes/host-bulk-routes.test.ts index 42832f7d6..969120fa1 100644 --- a/src/backend/tests/database/routes/host-bulk-routes.test.ts +++ b/src/backend/tests/database/routes/host-bulk-routes.test.ts @@ -143,7 +143,7 @@ describe("notifyImportedHosts", () => { seen.push(payload); }); try { - notifyImportedHosts("user-1", [4, 7]); + notifyImportedHosts("user-1", [4, 7], []); } finally { off(); } diff --git a/src/backend/tests/database/routes/host-create-auth-type.test.ts b/src/backend/tests/database/routes/host-create-auth-type.test.ts new file mode 100644 index 000000000..de7c0ed73 --- /dev/null +++ b/src/backend/tests/database/routes/host-create-auth-type.test.ts @@ -0,0 +1,216 @@ +/** + * The create and update host routes against a real SQLite database. A body + * that leaves out authType must never reach the NOT NULL auth_type column. + */ + +import express, { + type NextFunction, + type Request, + type Response, +} from "express"; +import request from "supertest"; +import { eq, sql } from "drizzle-orm"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { DatabaseContext } from "../../../database/repositories/database-context.js"; + +const state = vi.hoisted(() => ({ + ctx: null as null | { drizzle: unknown }, +})); + +const USER = "user-1"; +const pass = (_req: Request, _res: Response, next: NextFunction) => next(); + +vi.mock("../../../database/db/index.js", () => ({ + getDb: () => state.ctx!.drizzle, + getSqlite: () => null, +})); +vi.mock("../../../utils/database-save-trigger.js", () => ({ + DatabaseSaveTrigger: { + triggerSave: vi.fn(), + forceSave: vi.fn(async () => {}), + batched: (work: T) => work, + }, +})); +vi.mock("../../../utils/system-crypto.js", () => ({ + SystemCrypto: { + getInstance: () => ({ + getEncryptionKey: async () => Buffer.alloc(32, 7), + getJWTSecret: async () => "secret", + }), + }, +})); +vi.mock("../../../utils/data-crypto.js", () => ({ + DataCrypto: { + getUserDataKey: () => Buffer.alloc(32, 3), + validateUserAccess: () => Buffer.alloc(32, 3), + encryptRecord: (_table: string, record: unknown) => record, + decryptRecord: (_table: string, record: unknown) => record, + decryptRecords: (_table: string, records: unknown) => records, + }, +})); +vi.mock("../../../utils/auth-manager.js", () => ({ + AuthManager: { + getInstance: () => ({ + createAuthMiddleware: + () => (req: Request, _res: Response, next: NextFunction) => { + (req as Request & { userId: string }).userId = USER; + next(); + }, + createDataAccessMiddleware: () => pass, + createAdminMiddleware: () => pass, + }), + }, +})); +vi.mock("../../../utils/permission-manager.js", () => ({ + PermissionManager: { + getInstance: () => ({ + requirePermission: () => pass, + canAccessHost: async () => ({ hasAccess: true, isOwner: true }), + hasPermission: async () => true, + isAdmin: async () => true, + getUserPermissions: async () => [], + }), + }, +})); +vi.mock("../../../utils/audit-logger.js", () => ({ + logAudit: async () => {}, + getAuditUsername: async () => USER, + getRequestMeta: () => ({ ipAddress: "127.0.0.1", userAgent: "test" }), +})); + +const { TestSqliteDatabase } = await import("../repositories/test-support.js"); +const { hosts, sshCredentials } = + await import("../../../database/db/schema.js"); +const { saveLevel } = await import("../../../hosts/defaults/service.js"); +const { default: hostRouter } = + await import("../../../database/routes/host.js"); +const { pluginEvents, TOPICS } = await import("../../../plugins/events.js"); + +const app = express(); +app.use(express.json()); +app.use("/host", hostRouter); + +let db: InstanceType; +let ctx: DatabaseContext; + +beforeEach(async () => { + db = new TestSqliteDatabase("sqlite"); + ctx = await db.connect(); + state.ctx = ctx; + await ctx.drizzle.run( + sql`INSERT INTO users (id, username, password_hash, is_admin) VALUES (${USER}, ${USER}, '', 1)`, + ); +}); + +afterEach(async () => { + await db.close(); +}); + +async function addCredential(): Promise { + const [row] = await ctx.drizzle + .insert(sshCredentials) + .values({ + userId: USER, + name: "ops", + username: "root", + authType: "password", + password: "secret", + }) + .returning({ id: sshCredentials.id }); + return row.id; +} + +async function storedAuthType(id: number) { + const [row] = await ctx.drizzle + .select({ authType: hosts.authType }) + .from(hosts) + .where(eq(hosts.id, id)); + return row?.authType; +} + +const base = { ip: "10.0.0.5", port: 22, username: "root" }; + +describe("host create without authType", () => { + it("infers credential auth from a credentialId", async () => { + const credentialId = await addCredential(); + const res = await request(app) + .post("/host/db/host") + .send({ ...base, credentialId }); + expect(res.status).toBe(200); + expect(await storedAuthType(res.body.id)).toBe("credential"); + }); + + it("infers password auth from a password", async () => { + const res = await request(app) + .post("/host/db/host") + .send({ ...base, password: "pw" }); + expect(res.status).toBe(200); + expect(await storedAuthType(res.body.id)).toBe("password"); + }); + + it("rejects a body that says nothing about authentication", async () => { + const res = await request(app).post("/host/db/host").send(base); + expect(res.status).toBe(400); + expect(res.body.error).toMatch(/authType is required/); + }); + + it("takes the auth type from the user's host defaults", async () => { + const credentialId = await addCredential(); + await saveLevel( + { level: "user", userId: USER }, + { + set: new Map([["core.auth", { authType: "credential", credentialId }]]), + unset: new Set(), + errors: {}, + }, + USER, + ); + const res = await request(app).post("/host/db/host").send(base); + expect(res.status).toBe(200); + expect(await storedAuthType(res.body.id)).toBe("credential"); + }); +}); + +describe("host update authType", () => { + it("keeps the stored auth type when the update leaves it out", async () => { + const created = await request(app) + .post("/host/db/host") + .send({ ...base, authType: "password", password: "pw" }); + const res = await request(app) + .put(`/host/db/host/${created.body.id}`) + .send({ ...base, name: "renamed" }); + expect(res.status).toBe(200); + expect(await storedAuthType(created.body.id)).toBe("password"); + }); + + it("rejects an authType that is not a string", async () => { + const created = await request(app) + .post("/host/db/host") + .send({ ...base, authType: "password", password: "pw" }); + const res = await request(app) + .put(`/host/db/host/${created.body.id}`) + .send({ ...base, authType: { bad: true } }); + expect(res.status).toBe(400); + }); +}); + +describe("host.created", () => { + it("is published once when a host is created, and not on update", async () => { + const seen: unknown[] = []; + const off = pluginEvents.on(TOPICS.hostCreated, (payload) => { + seen.push(payload); + }); + try { + const created = await request(app) + .post("/host/db/host") + .send({ ...base, authType: "password", password: "pw" }); + expect(created.status).toBe(200); + await request(app) + .put(`/host/db/host/${created.body.id}`) + .send({ ...base, name: "renamed" }); + expect(seen).toEqual([{ hostId: created.body.id, userId: USER }]); + } finally { + off(); + } + }); +}); diff --git a/src/backend/tests/database/routes/host-import-terminal-settings.test.ts b/src/backend/tests/database/routes/host-import-terminal-settings.test.ts index e8a0ead2a..f61ff5649 100644 --- a/src/backend/tests/database/routes/host-import-terminal-settings.test.ts +++ b/src/backend/tests/database/routes/host-import-terminal-settings.test.ts @@ -36,7 +36,8 @@ vi.mock("../../../database/repositories/factory.js", () => ({ }), })); import { registerHostBulkRoutes } from "../../../database/routes/host-bulk-routes.js"; -import { buildExportPayload } from "../../../../ui/sidebar/host-export-payload.js"; +import { buildExportPayload } from "../../../../ui/sidebar/host-export-payload"; +import { pluginEvents, TOPICS } from "../../../plugins/events.js"; let handler: RequestHandler; let sshConfigHandler: RequestHandler; @@ -259,3 +260,53 @@ it("does not save dependent hosts when importing their jump host fails", async ( ); expect(mocks.create).toHaveBeenCalledTimes(1); }); + +function collectCreated() { + const seen: unknown[] = []; + const off = pluginEvents.on(TOPICS.hostCreated, (payload) => { + seen.push(payload); + }); + return { seen, off }; +} + +it("publishes host.created once for each host a JSON import creates", async () => { + mocks.list.mockResolvedValue([{ ...host, id: 19 }]); + mocks.create.mockResolvedValueOnce({ id: 501 }); + const { seen, off } = collectCreated(); + try { + await importHosts([host, { ...host, ip: "192.0.2.2" }], true); + } finally { + off(); + } + // The overwritten host already existed, so only the new one is announced. + expect(seen).toEqual([{ hostId: 501, userId: "user-1" }]); +}); + +it("publishes host.created once for each host an SSH config import creates", async () => { + mocks.create + .mockResolvedValueOnce({ id: 601 }) + .mockResolvedValueOnce({ id: 602 }); + const { seen, off } = collectCreated(); + try { + await sshConfigHandler( + { + userId: "user-1", + body: { + content: + "Host a\n HostName 10.0.0.1\n User root\nHost b\n HostName 10.0.0.2\n User root\n", + }, + } as unknown as Request, + { + json: vi.fn(), + status: vi.fn().mockReturnThis(), + } as unknown as Response, + vi.fn(), + ); + } finally { + off(); + } + expect(seen).toEqual([ + { hostId: 601, userId: "user-1" }, + { hostId: 602, userId: "user-1" }, + ]); +}); diff --git a/src/backend/tests/database/routes/host-normalizers.test.ts b/src/backend/tests/database/routes/host-normalizers.test.ts index 614e2c83e..4e6714dc0 100644 --- a/src/backend/tests/database/routes/host-normalizers.test.ts +++ b/src/backend/tests/database/routes/host-normalizers.test.ts @@ -3,6 +3,7 @@ import { setHostProtocolSource } from "../../../hosts/protocol-auth/registry.js" import { applyHostKeyTypeUpdate, containsOwnerPrivateAuthUpdate, + impliedSshAuthType, isNonEmptyString, keepsStoredSshAuth, isOptionalBoolean, @@ -515,3 +516,16 @@ describe("keepsStoredSshAuth", () => { expect(keepsStoredSshAuth("none")).toBe(false); }); }); + +describe("impliedSshAuthType", () => { + it("reads the auth type from the secret a create body carries", () => { + expect(impliedSshAuthType({ credentialId: 2, password: "pw" })).toBe( + "credential", + ); + expect(impliedSshAuthType({ key: "-----BEGIN", password: "pw" })).toBe( + "key", + ); + expect(impliedSshAuthType({ password: "pw" })).toBe("password"); + expect(impliedSshAuthType({})).toBeUndefined(); + }); +}); diff --git a/src/backend/tests/database/routes/plugins.test.ts b/src/backend/tests/database/routes/plugins.test.ts index 558c27447..a554d4271 100644 --- a/src/backend/tests/database/routes/plugins.test.ts +++ b/src/backend/tests/database/routes/plugins.test.ts @@ -176,6 +176,7 @@ const manageMock = vi.hoisted(() => ({ deletePluginData: vi.fn(), planStateChange: vi.fn(), setPluginState: vi.fn(), + enablePluginWithConsent: vi.fn(), getDeveloperMode: vi.fn(), setDeveloperMode: vi.fn(), stageUpload: vi.fn(), @@ -215,6 +216,12 @@ const onboardingMock = vi.hoisted(() => ({ vi.mock("../../../plugins/onboarding.js", () => onboardingMock); +const syncPluginsMock = vi.hoisted(() => ({ + isServerManaged: vi.fn((_id: string) => true), +})); + +vi.mock("../../../sync/client/plugins.js", () => syncPluginsMock); + const pluginRoutes = (await import("../../../database/routes/plugins.js")) .default; @@ -1139,6 +1146,65 @@ describe("plugins route", () => { expect(manageMock.installPlugin).not.toHaveBeenCalled(); }); + describe("on a linked desktop", () => { + const switchOn = (id: string) => + fetch(`${baseUrl}/plugins/${id}/state`, { + method: "PATCH", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + enabled: true, + version: "1.0.0", + capabilities: ["kv:own"], + }), + }); + + beforeEach(() => { + manageMock.isLinkedDesktop.mockResolvedValue(true); + state.plugins.set("sample-plugin", makePlugin({ state: "disabled" })); + }); + + afterEach(() => { + syncPluginsMock.isServerManaged.mockImplementation(() => true); + }); + + it("refuses to switch a plugin the server decides", async () => { + const res = await switchOn("sample-plugin"); + expect(res.status).toBe(409); + expect((await res.json()).code).toBe("MANAGED_BY_SERVER"); + expect(manageMock.setPluginState).not.toHaveBeenCalled(); + }); + + it("lets the desktop's admin switch one the server does not", async () => { + syncPluginsMock.isServerManaged.mockImplementation(() => false); + manageMock.enablePluginWithConsent.mockResolvedValue({ + enable: [], + disable: [], + }); + const res = await switchOn("sample-plugin"); + expect(res.status).toBe(200); + expect(manageMock.enablePluginWithConsent).toHaveBeenCalledWith( + "sample-plugin", + expect.objectContaining({ + version: "1.0.0", + capabilities: ["kv:own"], + }), + ); + }); + + it("says per plugin whether the server decides it", async () => { + state.plugins.set("other", makePlugin({ id: "other" })); + syncPluginsMock.isServerManaged.mockImplementation( + (id) => id === "other", + ); + const listed = (await ( + await fetch(`${baseUrl}/plugins`) + ).json()) as Array<{ id: string; managedByServer: boolean }>; + expect( + Object.fromEntries(listed.map((p) => [p.id, p.managedByServer])), + ).toEqual({ "sample-plugin": false, other: true }); + }); + }); + it("previews a state change without making it", async () => { state.plugins.set("sample-plugin", makePlugin()); manageMock.planStateChange.mockResolvedValue({ diff --git a/src/backend/tests/electron/c2s-runtime.test.ts b/src/backend/tests/electron/c2s-runtime.test.ts index febca3e4d..a65c224a7 100644 --- a/src/backend/tests/electron/c2s-runtime.test.ts +++ b/src/backend/tests/electron/c2s-runtime.test.ts @@ -145,6 +145,35 @@ describe("client tunnel runtime authentication", () => { expect(sockets).toHaveLength(1); }); + it("keeps the relay's reason when it closes after an error", async () => { + const { context, runtimes, sockets } = setup(); + const runtime = { status: { connected: true } }; + runtimes.set("web", runtime); + const test = context.testC2SRelay( + { name: "web" }, + "127.0.0.1", + 8443, + null, + runtime, + ); + await vi.waitFor(() => expect(sockets).toHaveLength(1)); + sockets[0].emit( + "message", + Buffer.from( + JSON.stringify({ + type: "error", + error: "Target 127.0.0.1:8443 refused", + }), + ), + false, + ); + sockets[0].close(); + expect(await test).toMatchObject({ success: false }); + expect(runtimes.get("web").status.reason).toBe( + "Target 127.0.0.1:8443 refused", + ); + }); + it("keeps the tunnel connected when one of its connections fails", async () => { const { context, runtimes, sockets } = setup(); runtimes.set("web", { status: { connected: true, status: "CONNECTED" } }); diff --git a/src/backend/tests/electron/linked-certificate.test.ts b/src/backend/tests/electron/linked-certificate.test.ts new file mode 100644 index 000000000..87bab23f6 --- /dev/null +++ b/src/backend/tests/electron/linked-certificate.test.ts @@ -0,0 +1,65 @@ +import fs from "node:fs"; +import { createRequire } from "node:module"; +import vm from "node:vm"; +import { describe, expect, it } from "vitest"; + +const require = createRequire(import.meta.url); +const linkedServer = require("../../../../electron/linked-server.cjs") as { + setLinkedServer: (config: unknown) => void; +}; + +const source = fs.readFileSync( + new URL("../../../../electron/main.cjs", import.meta.url), + "utf8", +); +const tls = source.slice( + source.indexOf("const INSECURE_MODE_VALUES"), + source.indexOf("function httpFetch("), +); + +const ORIGIN = "https://termix.example"; +const TERMINAL = "wss://termix.example/plugin-ws/ssh-terminal/terminal"; + +function load(link: Record | null) { + linkedServer.setLinkedServer(link); + const context = vm.createContext({ + URL, + process: { env: {} }, + linkedServer, + }); + vm.runInContext(tls, context); + return context as unknown as { + isInvalidCertificateAllowedForUrl: (url: string) => boolean; + getWebSocketOptions: ( + url: string, + options?: Record, + ) => Record; + }; +} + +describe("linked server certificate exception", () => { + it("covers WebSockets to the linked server", () => { + const main = load({ origin: ORIGIN, allowInvalidCertificate: true }); + expect(main.isInvalidCertificateAllowedForUrl(TERMINAL)).toBe(true); + expect(main.getWebSocketOptions(TERMINAL, { headers: {} })).toEqual({ + headers: {}, + rejectUnauthorized: false, + }); + }); + + it.each([ + "ws://termix.example/relay", + "wss://termix.example:8443/relay", + "wss://elsewhere.example/relay", + ])("keeps verifying %s", (url) => { + const main = load({ origin: ORIGIN, allowInvalidCertificate: true }); + expect(main.isInvalidCertificateAllowedForUrl(url)).toBe(false); + }); + + it("keeps verifying when the link does not allow it", () => { + const main = load({ origin: ORIGIN, allowInvalidCertificate: false }); + expect(main.getWebSocketOptions(TERMINAL)).toEqual({ + rejectUnauthorized: true, + }); + }); +}); diff --git a/src/backend/tests/electron/linked-server.test.ts b/src/backend/tests/electron/linked-server.test.ts index 954188205..fe97739d4 100644 --- a/src/backend/tests/electron/linked-server.test.ts +++ b/src/backend/tests/electron/linked-server.test.ts @@ -39,6 +39,48 @@ describe("linked server in Electron main", () => { ).toEqual({ Accept: "x" }); }); + it("treats WebSocket upgrades to the linked server as the linked origin", () => { + linked.setLinkedServer({ origin: ORIGIN }); + expect(linked.isLinkedOrigin(`${ORIGIN}/host/status`)).toBe(true); + expect( + linked.isLinkedOrigin("wss://termix.example/plugin-ws/ssh-terminal"), + ).toBe(true); + expect(linked.isLinkedOrigin("wss://termix.example:443/")).toBe(true); + expect(linked.isLinkedOrigin("ws://termix.example/")).toBe(false); + expect(linked.isLinkedOrigin("wss://termix.example:8443/")).toBe(false); + expect(linked.isLinkedOrigin("wss://other.example/")).toBe(false); + expect(linked.isLinkedOrigin("not a url")).toBe(false); + + linked.setLinkedServer({ origin: "http://termix.example:8080" }); + expect(linked.isLinkedOrigin("ws://termix.example:8080/ws")).toBe(true); + expect(linked.isLinkedOrigin("wss://termix.example:8080/ws")).toBe(false); + expect(linked.isLinkedOrigin("ws://termix.example/ws")).toBe(false); + }); + + it("adds proxy headers and basic auth to the terminal WebSocket", () => { + linked.setLinkedServer({ + origin: ORIGIN, + headers: [{ name: "CF-Access-Client-Id", value: "id" }], + basicAuth: { username: "gate", password: "pw" }, + }); + expect( + linked.applyLinkHeaders( + "wss://termix.example/plugin-ws/ssh-terminal/terminal", + { Upgrade: "websocket" }, + ), + ).toEqual({ + Upgrade: "websocket", + "CF-Access-Client-Id": "id", + Authorization: `Basic ${Buffer.from("gate:pw").toString("base64")}`, + }); + expect( + linked.answerLogin({}, "wss://termix.example/plugin-ws/ssh-terminal"), + ).toEqual({ username: "gate", password: "pw" }); + expect( + linked.applyLinkHeaders("wss://other.example/", { Upgrade: "websocket" }), + ).toEqual({ Upgrade: "websocket" }); + }); + it("moves a Termix bearer into the jwt cookie when basic auth is in front", () => { linked.setLinkedServer({ origin: ORIGIN, diff --git a/src/backend/tests/electron/local-shell.test.ts b/src/backend/tests/electron/local-shell.test.ts index 1d085023e..32a44a6fc 100644 --- a/src/backend/tests/electron/local-shell.test.ts +++ b/src/backend/tests/electron/local-shell.test.ts @@ -80,4 +80,20 @@ describe("repairSpawnHelpers", () => { expect(chmods[0][1] & 0o111).not.toBe(0); expect(chmods[0][0]).toContain("app.asar.unpacked"); }); + + it.each(["app-arm64.asar", "app-x64.asar"])( + "repairs the helper in the unpacked copy of a universal build's %s", + (asar) => { + const chmods: string[] = []; + const fsImpl = { + statSync: () => ({ mode: 0o644 }), + chmodSync: (file: string) => { + chmods.push(file.split(require("node:path").sep).join("/")); + }, + }; + const dir = `/Applications/Termix.app/Contents/Resources/${asar}/node_modules/node-pty`; + repairSpawnHelpers(dir, fsImpl); + expect(chmods[0]).toContain(`/${asar}.unpacked/node_modules/node-pty/`); + }, + ); }); diff --git a/src/backend/tests/electron/patch-node-pty.test.ts b/src/backend/tests/electron/patch-node-pty.test.ts index ea4086258..5205c5bc5 100644 --- a/src/backend/tests/electron/patch-node-pty.test.ts +++ b/src/backend/tests/electron/patch-node-pty.test.ts @@ -11,7 +11,48 @@ import { tmpdir } from "node:os"; import { describe, expect, it } from "vitest"; const require = createRequire(import.meta.url); -const { chmodSpawnHelpers } = require("../../../../scripts/patch-node-pty.cjs"); +const { + chmodSpawnHelpers, + patchUnixTerminalHelperRewrite, +} = require("../../../../scripts/patch-node-pty.cjs"); + +const upstreamRewrite = [ + "helperPath = helperPath.replace('app.asar', 'app.asar.unpacked');", + "helperPath = helperPath.replace('node_modules.asar', 'node_modules.asar.unpacked');", +].join("\n"); + +const previouslyPatchedRewrite = [ + "if (!helperPath.includes('app.asar.unpacked')) {", + " helperPath = helperPath.replace('app.asar', 'app.asar.unpacked');", + "}", + "if (!helperPath.includes('node_modules.asar.unpacked')) {", + " helperPath = helperPath.replace('node_modules.asar', 'node_modules.asar.unpacked');", + "}", +].join("\n"); + +const resources = "/Applications/Termix.app/Contents/Resources"; +const helperPaths = [ + ["app.asar", "app.asar.unpacked"], + ["app-arm64.asar", "app-arm64.asar.unpacked"], + ["app-x64.asar", "app-x64.asar.unpacked"], + ["app.asar.unpacked", "app.asar.unpacked"], + ["app-arm64.asar.unpacked", "app-arm64.asar.unpacked"], +].map(([from, to]) => [ + `${resources}/${from}/node_modules/node-pty/build/Release/spawn-helper`, + `${resources}/${to}/node_modules/node-pty/build/Release/spawn-helper`, +]); + +function writeUnixTerminal(rewrite: string) { + const file = join( + tmpdir(), + `termix-unix-terminal-${Date.now()}-${Math.random()}.cjs`, + ); + writeFileSync( + file, + `module.exports = function (helperPath) {\n${rewrite}\nreturn helperPath;\n};\n`, + ); + return file; +} // chmod's execute bit is a POSIX concept; Windows/NTFS has no equivalent mode bit. const itPosix = process.platform === "win32" ? it.skip : it; @@ -30,4 +71,22 @@ describe("patch-node-pty", () => { expect(chmodSpawnHelpers(root)).toBe(0); expect(existsSync(helper)).toBe(true); }); + + it.each([ + ["upstream", upstreamRewrite], + ["previously patched", previouslyPatchedRewrite], + ])( + "points the %s helper path at the unpacked copy of any app asar", + (_, rewrite) => { + const file = writeUnixTerminal(rewrite); + + expect(patchUnixTerminalHelperRewrite(file)).toBe(true); + expect(patchUnixTerminalHelperRewrite(file)).toBe(false); + + const rewriteHelperPath = require(file); + for (const [from, to] of helperPaths) { + expect(rewriteHelperPath(from)).toBe(to); + } + }, + ); }); diff --git a/src/backend/tests/hosts/defaults/write-rules.test.ts b/src/backend/tests/hosts/defaults/write-rules.test.ts index cb543ec88..321aa8dbf 100644 --- a/src/backend/tests/hosts/defaults/write-rules.test.ts +++ b/src/backend/tests/hosts/defaults/write-rules.test.ts @@ -106,6 +106,31 @@ describe("applyHostDefaultsToWrite", () => { expect(overrides.core).toEqual(["statusCheckEnabled", "username"]); }); + it("keeps a changed auth type on an update without a list", async () => { + userDefault("auth", { authType: "credential", credentialId: 3 }); + const stored = { + id: 5, + userId: "u1", + authType: "credential", + credentialId: 3, + defaultOverrides: JSON.stringify({ core: ["username"] }), + }; + const columns: Record = { + authType: "vault", + credentialId: null, + overrideCredentialUsername: 0, + }; + const overrides = await applyHostDefaultsToWrite({ + ownerId: "u1", + hostId: 5, + columns, + body: { authType: "vault", credentialId: 0 }, + stored: stored as never, + }); + expect(columns.authType).toBe("vault"); + expect(overrides.core).toContain("auth"); + }); + it("keeps a locked key's state for a shared editor", async () => { const overrides = await applyHostDefaultsToWrite({ ownerId: "u1", diff --git a/src/backend/tests/hosts/host-key-verifier.test.ts b/src/backend/tests/hosts/host-key-verifier.test.ts index 1e26cfb23..5e42db8f4 100644 --- a/src/backend/tests/hosts/host-key-verifier.test.ts +++ b/src/backend/tests/hosts/host-key-verifier.test.ts @@ -1,10 +1,16 @@ import { EventEmitter } from "node:events"; import type { WebSocket } from "ws"; -import { describe, expect, it, vi } from "vitest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; import { SSHHostKeyVerifier } from "../../hosts/host-key-verifier.js"; +const repo = vi.hoisted(() => ({ + updateHostKey: vi.fn(async () => {}), + storeHostKey: vi.fn(async () => {}), + touchHostKeyLastVerified: vi.fn(async () => {}), +})); + vi.mock("../../database/repositories/factory.js", () => ({ - createCurrentHostResolutionRepository: () => ({}), + createCurrentHostResolutionRepository: () => repo, })); vi.mock("../../utils/logger.js", () => ({ sshLogger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() }, @@ -57,3 +63,50 @@ describe("quick connect host keys", () => { expect(await verifyUnsaved(null)).toBe(true); }); }); + +const pinned = { + hostKeyFingerprint: Buffer.from("old-key").toString("hex"), + hostKeyType: "ssh-ed25519", + hostKeyAlgorithm: "sha256", + hostKeyChangedCount: 0, + name: "bastion", +}; + +async function verifyJumpHop(socket: WebSocket | null, key: string) { + const verifier = await SSHHostKeyVerifier.createHostVerifier( + 7, + "10.0.0.1", + 22, + socket, + "user", + true, + pinned, + ); + return new Promise((resolve) => verifier(Buffer.from(key), resolve)); +} + +describe("jump host key changes", () => { + beforeEach(() => { + repo.updateHostKey.mockClear(); + }); + + it("refuses a changed key and keeps the pin when nobody can be asked", async () => { + expect(await verifyJumpHop(null, "new-key")).toBe(false); + expect(repo.updateHostKey).not.toHaveBeenCalled(); + }); + + it("asks the user and only replaces the pin when they accept", async () => { + const no = socketAnswering("reject"); + expect(await verifyJumpHop(no.socket, "new-key")).toBe(false); + expect(no.sent[0]).toContain("host_key_changed"); + expect(repo.updateHostKey).not.toHaveBeenCalled(); + + const yes = socketAnswering("accept"); + expect(await verifyJumpHop(yes.socket, "new-key")).toBe(true); + expect(repo.updateHostKey).toHaveBeenCalledTimes(1); + }); + + it("still accepts the pinned key without asking", async () => { + expect(await verifyJumpHop(null, "old-key")).toBe(true); + }); +}); diff --git a/src/backend/tests/hosts/ssh-connection-pool.test.ts b/src/backend/tests/hosts/ssh-connection-pool.test.ts index ba3f4700b..5c9c9f322 100644 --- a/src/backend/tests/hosts/ssh-connection-pool.test.ts +++ b/src/backend/tests/hosts/ssh-connection-pool.test.ts @@ -42,6 +42,52 @@ describe("SSHConnectionPool", () => { pool.destroy(); }); + it("tracks every client from concurrent connects to a new host", async () => { + const pool = new SSHConnectionPool(); + const clients = Array.from({ length: 3 }, deferredClient); + let factoryCalls = 0; + const factory = vi.fn(() => clients[factoryCalls++].promise); + + const requests = Array.from({ length: 3 }, () => + pool.getConnection("new-host", factory), + ); + for (const pending of clients) pending.resolve(pending.client); + const connected = await Promise.all(requests); + + for (const client of connected) pool.releaseConnection("new-host", client); + const reused = await Promise.all( + Array.from({ length: 3 }, () => pool.getConnection("new-host", factory)), + ); + expect(new Set(reused)).toEqual(new Set(connected)); + expect(factory).toHaveBeenCalledTimes(3); + + pool.clearKeyConnections("new-host"); + for (const pending of clients) { + expect(pending.client.end).toHaveBeenCalledOnce(); + } + pool.destroy(); + }); + + it("does not resurrect a connection removed while another connects", async () => { + const pool = new SSHConnectionPool(); + const first = deferredClient(); + const second = deferredClient(); + first.resolve(first.client); + await pool.getConnection("host", () => first.promise); + + const request = pool.getConnection("host", () => second.promise); + first.client.emit("close"); + second.resolve(second.client); + await expect(request).resolves.toBe(second.client); + + pool.releaseConnection("host", first.client); + pool.releaseConnection("host", second.client); + await expect( + pool.getConnection("host", () => deferredClient().promise), + ).resolves.toBe(second.client); + pool.destroy(); + }); + it("discards a pending connection when its host pool is cleared", async () => { const pool = new SSHConnectionPool(); const pending = deferredClient(); diff --git a/src/backend/tests/plugins/artifact-manifest.test.ts b/src/backend/tests/plugins/artifact-manifest.test.ts index 0d11677a3..d4693cfe6 100644 --- a/src/backend/tests/plugins/artifact-manifest.test.ts +++ b/src/backend/tests/plugins/artifact-manifest.test.ts @@ -2,9 +2,10 @@ import { describe, expect, it } from "vitest"; import fs from "node:fs"; import os from "node:os"; import path from "node:path"; +import zlib from "node:zlib"; import * as tar from "tar"; import { - readArtifactFile, + readArtifactFiles, readArtifactManifest, sameCapabilities, } from "../../plugins/artifact-manifest.js"; @@ -27,20 +28,42 @@ function pack(files: Record, prefix = ""): Buffer { } } -describe("readArtifactFile", () => { +/** Two archives' entries back to back, so a name can appear twice. */ +function concat(first: Buffer, second: Buffer): Buffer { + const entries = (buffer: Buffer) => { + const raw = zlib.gunzipSync(buffer); + // Drop the two zero blocks that end every tar. + return raw.subarray(0, raw.length - 1024); + }; + return zlib.gzipSync( + Buffer.concat([entries(first), entries(second), Buffer.alloc(1024)]), + ); +} + +describe("readArtifactFiles", () => { it("finds a file by name, with or without a ./ prefix", () => { const plain = pack({ "manifest.json": "{}", "dist/a.js": "x" }); - expect(readArtifactFile(plain, "dist/a.js")?.toString()).toBe("x"); + expect( + readArtifactFiles(plain, "dist/a.js").map((b) => b.toString()), + ).toEqual(["x"]); const dotted = pack({ "manifest.json": '{"id":"a"}' }, "./"); - expect(readArtifactFile(dotted, "manifest.json")?.toString()).toBe( - '{"id":"a"}', + expect( + readArtifactFiles(dotted, "manifest.json").map((b) => b.toString()), + ).toEqual(['{"id":"a"}']); + }); + + it("returns nothing for a missing file", () => { + expect(readArtifactFiles(pack({ "a.txt": "a" }), "manifest.json")).toEqual( + [], ); }); - it("returns null for a missing file", () => { - expect(readArtifactFile(pack({ "a.txt": "a" }), "manifest.json")).toBe( - null, - ); + it("resolves names that need a pax header", () => { + const long = `dist/${"x".repeat(150)}.js`; + const archive = pack({ "manifest.json": "{}", [long]: "x" }); + expect(readArtifactFiles(archive, long).map((b) => b.toString())).toEqual([ + "x", + ]); }); }); @@ -68,6 +91,18 @@ describe("readArtifactManifest", () => { readArtifactManifest(pack({ "manifest.json": "[1]" })), ).toThrow(/not an object/); }); + + it("refuses an archive with two manifests", () => { + const first = pack({ "manifest.json": '{"capabilities":[]}' }); + const second = pack({ "manifest.json": '{"capabilities":["ssh"]}' }); + expect(() => readArtifactManifest(concat(first, second))).toThrow( + /more than one manifest.json/, + ); + const upper = pack({ "MANIFEST.JSON": '{"capabilities":["ssh"]}' }); + expect(() => readArtifactManifest(concat(first, upper))).toThrow( + /more than one manifest.json/, + ); + }); }); describe("sameCapabilities", () => { diff --git a/src/backend/tests/plugins/ctx-hosts.test.ts b/src/backend/tests/plugins/ctx-hosts.test.ts index a3f74e30d..e9d40c753 100644 --- a/src/backend/tests/plugins/ctx-hosts.test.ts +++ b/src/backend/tests/plugins/ctx-hosts.test.ts @@ -207,6 +207,7 @@ vi.mock("../../hosts/delete-host.js", () => ({ const { createPluginHosts } = await import("../../plugins/ctx-hosts.js"); const { PluginCapabilityError } = await import("@termix-ssh/plugin-sdk/backend"); +const { pluginEvents, TOPICS } = await import("../../plugins/events.js"); function manifest(capabilities: string[]) { return { @@ -436,6 +437,29 @@ describe("ctx.hosts", () => { ]); }); + it("publishes host.created for a host it creates", async () => { + h.granted = new Set(["hosts:write"]); + const hosts = createPluginHosts({ + manifest: manifest(["hosts:write"]), + audit: vi.fn(async () => {}), + }); + const seen: unknown[] = []; + const off = pluginEvents.on(TOPICS.hostCreated, (payload) => { + seen.push(payload); + }); + try { + await hosts.create({ + ip: "10.0.0.5", + port: 22, + username: "root", + authType: "password", + }); + } finally { + off(); + } + expect(seen).toEqual([{ hostId: 99, userId: "user-1" }]); + }); + it("refuses a field the SDK does not take instead of dropping it", async () => { h.granted = new Set(["hosts:write"]); const hosts = createPluginHosts({ diff --git a/src/backend/tests/plugins/ctx-users.test.ts b/src/backend/tests/plugins/ctx-users.test.ts new file mode 100644 index 000000000..e814f0080 --- /dev/null +++ b/src/backend/tests/plugins/ctx-users.test.ts @@ -0,0 +1,335 @@ +/** + * ctx.auth.issueUserToken mints a short-lived session core's own auth + * middleware accepts as the named user, under users:impersonate, audited and + * kept off account security routes. ctx.users.getLocale reads the user's UI + * language under users:read. + */ + +import crypto from "crypto"; +import jwt from "jsonwebtoken"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const state = vi.hoisted(() => ({ + grants: [] as string[], + users: new Map(), + sessions: new Map>(), + languages: new Map(), + audits: [] as Array>, +})); + +vi.mock("../../database/db/index.js", () => ({ + db: {}, + getDb: () => ({}), + saveMemoryDatabaseToFile: vi.fn(), +})); + +vi.mock("../../database/repositories/factory.js", () => ({ + createCurrentPluginPermissionGrantRepository: () => ({ + listByPlugin: async (pluginId: string) => + state.grants.map((capability) => ({ pluginId, capability })), + }), + createCurrentSettingsRepository: () => ({ get: async () => null }), + createCurrentSessionRepository: () => ({ + create: async (session: Record) => { + state.sessions.set(session.id as string, session); + return session; + }, + findById: async (id: string) => state.sessions.get(id) ?? null, + touch: async () => {}, + revoke: async (id: string) => state.sessions.delete(id), + }), + createCurrentUserRepository: () => ({ + findById: async (id: string) => state.users.get(id) ?? null, + }), + createCurrentUserPreferenceRepository: () => ({ + findByUserId: async (userId: string) => + state.languages.has(userId) + ? { userId, language: state.languages.get(userId) } + : null, + }), + createCurrentApiKeyRepository: () => ({}), + createCurrentTrustedDeviceRepository: () => ({}), + getCurrentSettingValue: () => null, +})); + +vi.mock("../../utils/audit-logger.js", () => ({ + logAudit: async (entry: Record) => { + state.audits.push(entry); + }, + getRequestMeta: () => ({ ipAddress: "", userAgent: "" }), +})); + +vi.mock("../../utils/system-crypto.js", () => ({ + SystemCrypto: { + getInstance: () => ({ + getJWTSecret: async () => "a".repeat(64), + getEncryptionKey: async () => crypto.randomBytes(32), + }), + }, +})); + +vi.mock("../../utils/user-keys.js", () => ({ + UserKeyManager: { + getInstance: () => ({ + hasUserDEK: () => true, + tryGetUserDEK: () => null, + invalidate: vi.fn(), + }), + }, +})); + +vi.mock("../../utils/crypto-migration/dek-migration.js", () => ({ + adoptRecoveredDEK: vi.fn(async () => {}), + migratePasswordUserAtLogin: vi.fn(async () => true), +})); + +const { createPluginContext, createPluginHandle } = + await import("../../plugins/ctx.js"); +const { invalidatePluginPermissionCache } = + await import("../../plugins/permissions.js"); +const { AuthManager } = await import("../../utils/auth-manager.js"); +const { registerSecondFactor, resetAuthRegistryForTests } = + await import("../../auth/registry.js"); +const { PluginCapabilityError } = + await import("@termix-ssh/plugin-sdk/backend"); + +const authManager = AuthManager.getInstance(); +const authenticate = authManager.createAuthMiddleware(); +const requireAdmin = authManager.createAdminMiddleware(); + +function contextFor(capabilities: string[], grants = capabilities) { + state.grants = grants; + const manifest = { + id: "ai", + name: "AI", + version: "1.0.0", + capabilities, + } as never; + return createPluginContext(manifest, createPluginHandle("ai", {} as never)); +} + +type Middleware = typeof authenticate; + +async function call(middleware: Middleware, token: string, url: string) { + const req = { + cookies: {}, + headers: { authorization: `Bearer ${token}` }, + method: "POST", + originalUrl: url, + url, + } as unknown as Parameters[0] & { userId?: string }; + const res = { + statusCode: 200, + body: null as unknown, + status(code: number) { + res.statusCode = code; + return res; + }, + json(body: unknown) { + res.body = body; + return res; + }, + clearCookie() { + return res; + }, + }; + let passed = false; + await middleware(req, res as never, () => { + passed = true; + }); + return { passed, status: res.statusCode, body: res.body, userId: req.userId }; +} + +beforeEach(() => { + state.users = new Map([ + ["u1", { id: "u1", username: "alice", isAdmin: false }], + ["admin", { id: "admin", username: "root", isAdmin: true }], + ]); + state.sessions.clear(); + state.languages.clear(); + state.audits = []; + invalidatePluginPermissionCache(); + resetAuthRegistryForTests(); +}); + +afterEach(() => { + vi.useRealTimers(); +}); + +describe("ctx.auth.issueUserToken", () => { + it("mints a session core's auth middleware accepts as that user", async () => { + const ctx = contextFor(["users:impersonate"]); + + const { token, expiresAt } = await ctx.auth.issueUserToken("u1", { + purpose: "nightly report", + }); + const result = await call(authenticate, token, "/host/db/host"); + + expect(result.passed).toBe(true); + expect(result.userId).toBe("u1"); + const session = [...state.sessions.values()][0]; + expect(session).toMatchObject({ + userId: "u1", + deviceType: "plugin", + deviceInfo: "ai: nightly report", + expiresAt, + }); + const payload = jwt.decode(token) as { exp: number; iat: number }; + expect(payload.exp - payload.iat).toBe(300); + }); + + it("keeps the user's own permissions", async () => { + const ctx = contextFor(["users:impersonate"]); + const user = await ctx.auth.issueUserToken("u1", { purpose: "check" }); + const admin = await ctx.auth.issueUserToken("admin", { purpose: "check" }); + + const refused = await call(requireAdmin, user.token, "/users/list"); + expect(refused.passed).toBe(false); + expect(refused.status).toBe(403); + expect((await call(requireAdmin, admin.token, "/users/list")).passed).toBe( + true, + ); + }); + + it("caps the lifetime at 900 seconds", async () => { + const ctx = contextFor(["users:impersonate"]); + + const { token, expiresAt } = await ctx.auth.issueUserToken("u1", { + purpose: "long task", + ttlSeconds: 86_400, + }); + + const payload = jwt.decode(token) as { exp: number; iat: number }; + expect(payload.exp - payload.iat).toBe(900); + expect(new Date(expiresAt).getTime() - Date.now()).toBeLessThanOrEqual( + 900_000, + ); + }); + + it("stops working when the session expires", async () => { + const ctx = contextFor(["users:impersonate"]); + const { token } = await ctx.auth.issueUserToken("u1", { + purpose: "short", + ttlSeconds: 60, + }); + + vi.useFakeTimers({ now: Date.now() + 61_000 }); + const result = await call(authenticate, token, "/host/db/host"); + + expect(result.passed).toBe(false); + expect(result.status).toBe(401); + }); + + it("audits every issue with the plugin, the user and the purpose", async () => { + const ctx = contextFor(["users:impersonate"]); + + await ctx.auth.issueUserToken("u1", { purpose: "nightly report" }); + + const entry = state.audits.find( + (audit) => audit.action === "plugin_user_token_issued", + ); + expect(entry).toMatchObject({ + username: "plugin:ai", + resourceType: "user", + resourceId: "u1", + success: true, + }); + expect(JSON.parse(entry!.details as string)).toEqual({ + pluginId: "ai", + purpose: "nightly report", + ttlSeconds: 300, + }); + }); + + it("is refused without a granted users:impersonate", async () => { + for (const ctx of [ + contextFor([]), + contextFor(["users:impersonate"], []), + contextFor(["users:read"]), + ]) { + await expect( + ctx.auth.issueUserToken("u1", { purpose: "x" }), + ).rejects.toBeInstanceOf(PluginCapabilityError); + } + expect(state.sessions.size).toBe(0); + expect(state.audits.every((audit) => audit.success === false)).toBe(true); + }); + + it("is refused for a user that does not exist, or without a purpose", async () => { + const ctx = contextFor(["users:impersonate"]); + + await expect( + ctx.auth.issueUserToken("ghost", { purpose: "x" }), + ).rejects.toThrow(/does not exist/); + await expect( + ctx.auth.issueUserToken("u1", { purpose: " " }), + ).rejects.toThrow(/purpose/); + expect(state.sessions.size).toBe(0); + expect( + state.audits.filter( + (audit) => + audit.action === "plugin_user_token_issued" && !audit.success, + ), + ).toHaveLength(2); + }); + + it("cannot change how the user signs in or mint lasting credentials", async () => { + registerSecondFactor({ id: "totp", pluginId: "totp" } as never); + const ctx = contextFor(["users:impersonate"]); + const { token } = await ctx.auth.issueUserToken("admin", { + purpose: "ops", + }); + + for (const url of [ + "/users/change-password", + "/users/link-external-to-password", + "/users/admin/reset-password", + "/users/admin/u1/second-factors", + "/plugin-api/totp/setup", + ]) { + const result = await call(authenticate, token, url); + expect(result.passed, url).toBe(false); + expect(result.body).toMatchObject({ code: "PLUGIN_SESSION_NOT_ALLOWED" }); + } + const apiKeys = await call(requireAdmin, token, "/users/api-keys"); + expect(apiKeys.passed).toBe(false); + expect(apiKeys.body).toMatchObject({ code: "PLUGIN_SESSION_NOT_ALLOWED" }); + expect( + (await call(authenticate, token, "/plugin-api/other/run")).passed, + ).toBe(true); + }); + + it("leaves a normal login session free to use those routes", async () => { + const token = await authManager.generateJWTToken("u1", { + deviceType: "web", + deviceInfo: "Chrome", + }); + + expect( + (await call(authenticate, token, "/users/change-password")).passed, + ).toBe(true); + }); +}); + +describe("ctx.users.getLocale", () => { + it("returns the stored language, or null", async () => { + state.languages.set("u1", "zh_CN"); + state.languages.set("admin", null); + const ctx = contextFor(["users:read"]); + + await expect(ctx.users.getLocale("u1")).resolves.toBe("zh_CN"); + await expect(ctx.users.getLocale("admin")).resolves.toBeNull(); + await expect(ctx.users.getLocale("nobody")).resolves.toBeNull(); + }); + + it("is refused without a granted users:read", async () => { + state.languages.set("u1", "zh_CN"); + + await expect( + contextFor(["users:read"], []).users.getLocale("u1"), + ).rejects.toBeInstanceOf(PluginCapabilityError); + await expect( + contextFor(["users:impersonate"]).users.getLocale("u1"), + ).rejects.toBeInstanceOf(PluginCapabilityError); + }); +}); diff --git a/src/backend/tests/plugins/http.test.ts b/src/backend/tests/plugins/http.test.ts index a947192bb..5b757303c 100644 --- a/src/backend/tests/plugins/http.test.ts +++ b/src/backend/tests/plugins/http.test.ts @@ -461,6 +461,19 @@ describe("public route rate limit", () => { expect(http.overPublicRateLimit("other", "1.2.3.4", now)).toBe(false); }); + it("lets a plugin raise its own limit, capped, unless the admin set one", () => { + const hits = (limit: number, count: number) => + Array.from({ length: count }, () => + http.overPublicRateLimit("proxy", "ip", 0, limit), + ).filter(Boolean).length; + expect(hits(500, 500)).toBe(0); + http.resetPublicRateLimits(); + expect(hits(50_000, 10_001)).toBe(1); + http.resetPublicRateLimits(); + process.env.TERMIX_PLUGIN_PUBLIC_RATE_LIMIT = "2"; + expect(hits(500, 3)).toBe(1); + }); + it("starts a new window after a minute", () => { process.env.TERMIX_PLUGIN_PUBLIC_RATE_LIMIT = "1"; expect(http.overPublicRateLimit("demo", "ip", 0)).toBe(false); diff --git a/src/backend/tests/plugins/manage.test.ts b/src/backend/tests/plugins/manage.test.ts index 485f41106..7ea95dba7 100644 --- a/src/backend/tests/plugins/manage.test.ts +++ b/src/backend/tests/plugins/manage.test.ts @@ -91,8 +91,8 @@ vi.mock("../../plugins/data.js", () => ({ forgetTables: () => {}, })); -vi.mock("../../plugins/permissions.js", () => ({ - invalidatePluginPermissionCache: () => {}, +vi.mock("../../plugins/permissions.js", async (importOriginal) => ({ + ...(await importOriginal()), })); vi.mock("../../plugins/registry-index.js", async (importOriginal) => { @@ -1084,3 +1084,48 @@ describe("more registries", () => { ).rejects.toMatchObject({ code: "KEY_CHANGED" }); }); }); + +it("requires exact consent before enabling a mirrored plugin with capabilities", async () => { + createFixturePlugin({ + id: "manual-mirror", + root: path.join(data, "plugins"), + capabilities: ["kv:own"], + backendSource: + 'export async function activate(ctx) { await ctx.kv.set("probe", "value"); }', + }); + await boot(); + const options = { + version: "1.0.0", + capabilities: ["kv:own"], + userId: "admin", + }; + await expect( + manage.enablePluginWithConsent("manual-mirror", { + ...options, + version: "0.9.0", + }), + ).rejects.toMatchObject({ code: "CONSENT_MISMATCH" }); + await expect( + manage.enablePluginWithConsent("manual-mirror", { + ...options, + capabilities: [], + }), + ).rejects.toMatchObject({ code: "CONSENT_MISMATCH" }); + await expect( + manage.enablePluginWithConsent("manual-mirror", { + version: "1.0.0", + userId: "admin", + }), + ).rejects.toMatchObject({ code: "CONSENT_REQUIRED" }); + expect(db.grants).toEqual([]); + expect(db.rows.get("manual-mirror")?.state).toBe("disabled"); + const result = await manage.enablePluginWithConsent("manual-mirror", options); + expect(result.state).toBe("active"); + expect(db.grants).toContainEqual( + expect.objectContaining({ + pluginId: "manual-mirror", + capability: "kv:own", + grantedBy: "admin", + }), + ); +}); diff --git a/src/backend/tests/plugins/registries.test.ts b/src/backend/tests/plugins/registries.test.ts index 905ee0243..f7b743d4a 100644 --- a/src/backend/tests/plugins/registries.test.ts +++ b/src/backend/tests/plugins/registries.test.ts @@ -256,4 +256,25 @@ describe("keys from a linked server", () => { await registries.setSyncedRegistryKeys([]); expect(getCustomPluginKeys()).toEqual([]); }); + + it("marks them as synced unless an admin added the same key here", async () => { + const synced = newKey(); + const shared = newKey(); + serve(URL_A, { registry: "acme", publicKey: shared }); + await registries.addRegistry(URL_A, pluginKeyId(shared)); + await registries.setSyncedRegistryKeys([synced, shared]); + + expect(registries.isLocallyTrustedPluginKey(pluginKeyId(synced))).toBe( + false, + ); + expect(registries.isLocallyTrustedPluginKey(pluginKeyId(shared))).toBe( + true, + ); + expect(registries.isLocallyTrustedPluginKey("ec40e29bcc4986b2")).toBe(true); + await registries.setSyncedRegistryKeys([]); + expect(registries.isLocallyTrustedPluginKey(pluginKeyId(synced))).toBe( + false, + ); + expect(registries.isLocallyTrustedPluginKey("unknown-key")).toBe(false); + }); }); diff --git a/src/backend/tests/plugins/security/ctx-guards.test.ts b/src/backend/tests/plugins/security/ctx-guards.test.ts index a2d83b39b..b0838c38c 100644 --- a/src/backend/tests/plugins/security/ctx-guards.test.ts +++ b/src/backend/tests/plugins/security/ctx-guards.test.ts @@ -75,7 +75,8 @@ const UNGATED: Record = { "schedule.every": "the plugin's own timers", "schedule.after": "the plugin's own timers", "audit.record": "writes under the runtime's actor, never a plugin value", - asUser: "always audited as plugin_as_user", + asUser: + "audited as plugin_as_user (identical successful repeats coalesced for 10 minutes)", currentActor: "reads the actor, cannot set it", "desktop.available": "a yes/no about the environment", "http.baseUrl": "reads the request's public origin", diff --git a/src/backend/tests/plugins/table-ddl.test.ts b/src/backend/tests/plugins/table-ddl.test.ts index 14f6c5aae..7f818e4e2 100644 --- a/src/backend/tests/plugins/table-ddl.test.ts +++ b/src/backend/tests/plugins/table-ddl.test.ts @@ -308,4 +308,11 @@ describe("adoptTableSql", () => { const joined = adoptTableSql("sqlite", "fleets", adopted).join("\n"); expect(splitStatements(joined)).toHaveLength(3); }); + + it("drops a chunk that is only comments", () => { + expect(splitStatements("-- nothing to do here.\n")).toEqual([]); + expect( + splitStatements("/* note */;\n-- why\nSELECT 1;\n-- trailing"), + ).toEqual(["-- why\nSELECT 1"]); + }); }); diff --git a/src/backend/tests/sync/client/plugins.test.ts b/src/backend/tests/sync/client/plugins.test.ts index 569776a35..6d33439be 100644 --- a/src/backend/tests/sync/client/plugins.test.ts +++ b/src/backend/tests/sync/client/plugins.test.ts @@ -11,6 +11,8 @@ const state = vi.hoisted(() => ({ fetch: vi.fn(), loadBundled: vi.fn(), syncedKeys: vi.fn(async (_keys: unknown[]) => {}), + syncedOnlyKeys: new Set(), + grants: [] as string[], })); vi.mock("../../../utils/logger.js", () => ({ @@ -28,10 +30,16 @@ vi.mock("../../../database/repositories/factory.js", () => ({ state: state.enabled.has(id) ? "enabled" : "disabled", }), }), - createCurrentPluginPermissionGrantRepository: () => ({}), + createCurrentPluginPermissionGrantRepository: () => ({ + listByPlugin: async () => [], + grant: async ({ capability }: { capability: string }) => + void state.grants.push(capability), + }), })); vi.mock("../../../plugins/registries.js", () => ({ setSyncedRegistryKeys: state.syncedKeys, + isLocallyTrustedPluginKey: (keyId: string) => + keyId === "local-key" && !state.syncedOnlyKeys.has(keyId), })); vi.mock("../../../sync/client/http.js", () => ({ remoteFetch: state.fetch, @@ -56,8 +64,10 @@ vi.mock("../../../plugins/index.js", () => ({ }), })); -const { mirrorPlugins } = await import("../../../sync/client/plugins.js"); +const { mirrorPlugins, isServerManaged } = + await import("../../../sync/client/plugins.js"); const { remoteJson } = await import("../../../sync/client/http.js"); +const { sendCoreAlert } = await import("../../../notify/core-notify.js"); const bundled = { id: "ssh-terminal", @@ -92,6 +102,9 @@ beforeEach(() => { state.install.mockReset(); state.fetch.mockReset(); state.loadBundled.mockReset(); + state.syncedOnlyKeys = new Set(); + state.grants = []; + vi.mocked(sendCoreAlert).mockClear(); state.loadBundled.mockImplementation(async (id: string) => { const plugin = { ...bundled, id }; state.loaded.set(id, plugin); @@ -171,4 +184,226 @@ describe("mirrorPlugins", () => { expect(state.loaded.has("ssh-terminal")).toBe(true); expect(state.enabled.has("ssh-terminal")).toBe(true); }); + + describe("a user plugin the server has on", () => { + function serverRunsSignedBy( + keyId: string, + enabled = true, + version = keyId === "removed-key" ? "1.0.1" : "1.0.0", + ) { + state.loaded.set("extra", { + id: "extra", + source: "user", + state: "loaded", + signedBy: keyId, + manifest: { id: "extra", version, capabilities: ["net"] }, + }); + vi.mocked(remoteJson).mockResolvedValue({ + plugins: [ + { + id: "extra", + name: "Extra", + version, + source: "user", + artifact: true, + enabled, + active: enabled, + desktop: "mirror", + syncEntities: [], + granted: ["net"], + }, + ], + }); + } + + it("turns it on when a key this desktop trusts signed it", async () => { + serverRunsSignedBy("local-key"); + + await mirrorPlugins({} as never); + + expect(state.enabled.has("extra")).toBe(true); + expect(state.grants).toEqual(["net"]); + }); + + it.each(["server-key", "removed-key"])( + "leaves it off for untrusted key %s", + async (keyId) => { + serverRunsSignedBy(keyId); + state.syncedOnlyKeys.add("server-key"); + + await mirrorPlugins({} as never); + + expect(state.enabled.has("extra")).toBe(false); + expect(state.grants).toEqual([]); + expect(sendCoreAlert).toHaveBeenCalledWith( + expect.objectContaining({ category: "termix.sync.unsigned_plugin" }), + ); + }, + ); + + it("leaves the server in charge of one a trusted key signed", () => { + serverRunsSignedBy("local-key"); + expect(isServerManaged("extra")).toBe(true); + }); + + it.each(["server-key", "removed-key"])( + "lets this desktop's admin decide on one signed by %s", + async (keyId) => { + serverRunsSignedBy(keyId); + state.syncedOnlyKeys.add("server-key"); + expect(isServerManaged("extra")).toBe(false); + + state.enabled.add("extra"); + await mirrorPlugins({} as never); + expect(state.enabled.has("extra")).toBe(true); + }, + ); + + it("still switches an untrusted one off when the server does", async () => { + serverRunsSignedBy("server-key", false); + state.syncedOnlyKeys.add("server-key"); + state.enabled.add("extra"); + + await mirrorPlugins({} as never); + + expect(state.enabled.has("extra")).toBe(false); + }); + + it.each([ + ["server-key", false], + ["local-key", true], + ])( + "after an update signed by %s, keeps it on: %s", + async (keyId, stays) => { + serverRunsSignedBy(keyId, true, "2.0.0"); + state.syncedOnlyKeys.add("server-key"); + const reviewed = state.loaded.get("extra")!; + state.loaded.set("extra", { + ...reviewed, + manifest: { id: "extra", version: "1.0.0", capabilities: ["net"] }, + }); + state.enabled.add("extra"); + state.fetch.mockResolvedValue({ + ok: true, + arrayBuffer: async () => new ArrayBuffer(4), + headers: new Headers(), + }); + state.install.mockImplementation(async () => { + const plugin = { ...reviewed }; + state.loaded.set("extra", plugin); + return plugin; + }); + + await mirrorPlugins({} as never); + + expect(state.install).toHaveBeenCalled(); + expect(state.enabled.has("extra")).toBe(stays); + expect(vi.mocked(sendCoreAlert).mock.calls.length > 0).toBe(!stays); + }, + ); + }); +}); + +it("server bundled label must not enable a synced-only user plugin", async () => { + state.syncedOnlyKeys.add("server-key"); + state.loaded.set("extra", { + id: "extra", + source: "user", + state: "loaded", + signedBy: "server-key", + manifest: { id: "extra", version: "1.0.0", capabilities: [] }, + }); + vi.mocked(remoteJson).mockResolvedValue({ + plugins: [ + { + id: "extra", + name: "Extra", + version: "1.0.0", + source: "bundled", + artifact: true, + enabled: true, + active: true, + desktop: "mirror", + syncEntities: [], + granted: [], + }, + ], + }); + await mirrorPlugins({} as never); + expect(state.enabled.has("extra")).toBe(false); +}); + +it("remote bundled label cannot preserve approval across an untrusted update", async () => { + const reviewed = { + id: "extra", + source: "user", + state: "loaded", + signedBy: "server-key", + manifest: { id: "extra", version: "1.0.0", capabilities: [] }, + }; + state.loaded.set("extra", reviewed); + state.syncedOnlyKeys.add("server-key"); + state.enabled.add("extra"); + vi.mocked(remoteJson).mockResolvedValue({ + plugins: [ + { + id: "extra", + name: "Extra", + version: "2.0.0", + source: "bundled", + artifact: true, + enabled: true, + active: true, + desktop: "mirror", + syncEntities: [], + granted: [], + }, + ], + }); + state.fetch.mockResolvedValue({ + ok: true, + arrayBuffer: async () => new ArrayBuffer(4), + headers: new Headers(), + }); + state.install.mockImplementation(async () => { + const plugin = { + ...reviewed, + manifest: { ...reviewed.manifest, version: "2.0.0" }, + }; + state.loaded.set("extra", plugin); + return plugin; + }); + await mirrorPlugins({} as never); + expect(state.install).toHaveBeenCalledOnce(); + expect(state.enabled.has("extra")).toBe(false); +}); + +it("waits for an in-flight local plugin change before applying a remote update", async () => { + const { serialized } = await import("../../../plugins/manage.js"); + let release!: () => void; + let entered!: () => void; + const ready = new Promise((resolve) => { + entered = resolve; + }); + const barrier = new Promise((resolve) => { + release = resolve; + }); + const localChange = serialized(async () => { + entered(); + await barrier; + }); + await ready; + serverHas("1.1.0"); + state.fetch.mockResolvedValue({ ok: false, status: 502 }); + vi.mocked(remoteJson).mockClear(); + const syncing = mirrorPlugins({} as never); + try { + await vi.waitFor(() => expect(remoteJson).toHaveBeenCalled()); + expect(state.fetch).not.toHaveBeenCalled(); + } finally { + release(); + } + await localChange; + await syncing; + expect(state.fetch).toHaveBeenCalledOnce(); }); diff --git a/src/backend/tests/sync/server/changes.test.ts b/src/backend/tests/sync/server/changes.test.ts new file mode 100644 index 000000000..75199a71a --- /dev/null +++ b/src/backend/tests/sync/server/changes.test.ts @@ -0,0 +1,75 @@ +import { beforeEach, expect, it, vi } from "vitest"; + +const { listChanges } = vi.hoisted(() => ({ listChanges: vi.fn() })); +vi.mock("../../../utils/auth-manager.js", () => ({ + AuthManager: { getInstance: () => ({ createAuthMiddleware: () => vi.fn() }) }, +})); +vi.mock("../../../utils/logger.js", () => ({ syncLogger: { error: vi.fn() } })); +vi.mock("../../../utils/app-version.js", () => ({ getLocalVersion: vi.fn() })); +vi.mock("../../../plugins/index.js", () => ({ getPluginRuntime: vi.fn() })); +vi.mock("../../../plugins/sync-registry.js", () => ({ + getEntity: vi.fn(), + listEntities: () => [{ type: "hosts", owner: "core", order: 10 }], +})); +vi.mock("../../../database/repositories/factory.js", () => ({})); +vi.mock("../../../sync/entities.js", () => ({ + registerCoreSyncEntities: vi.fn(), +})); +vi.mock("../../../sync/records.js", () => ({ + listChanges, + recordKey: vi.fn(), +})); +vi.mock("../../../sync/server/feed.js", () => ({ + withUserLock: (_userId: string, fn: () => unknown) => fn(), + reconcileUser: async () => ({ wire: new Map() }), +})); +vi.mock("../../../sync/server/push.js", () => ({})); +vi.mock("../../../database/routes/branding-settings.js", () => ({})); + +import router from "../../../sync/server/routes.js"; + +const route = router.stack.find( + (layer) => layer.route?.path === "/v2/changes", +)!.route!; +const handler = route.stack[route.stack.length - 1].handle; + +beforeEach(() => { + vi.clearAllMocks(); + listChanges.mockResolvedValue([]); +}); + +async function changes(query: Record) { + const res = { status: vi.fn().mockReturnThis(), json: vi.fn() }; + await handler({ userId: "user-1", query } as never, res as never, vi.fn()); + return res; +} + +it("answers an empty page with the entity types past the newest change", async () => { + const cursor = String(Number.MAX_SAFE_INTEGER); + const res = await changes({ cursor, limit: "1000" }); + expect(res.status).not.toHaveBeenCalled(); + expect(res.json).toHaveBeenCalledWith({ + changes: [], + nextCursor: Number.MAX_SAFE_INTEGER, + hasMore: false, + entityTypes: [{ type: "hosts", owner: "core", order: 10, readOnly: false }], + }); +}); + +it("defaults a missing cursor and limit", async () => { + await changes({}); + expect(listChanges).toHaveBeenCalledWith("user-1", 0, 1001, undefined); +}); + +it.each([ + { cursor: "abc" }, + { cursor: "-1" }, + { cursor: "1.5" }, + { cursor: "Infinity" }, + { limit: "1.5" }, + { limit: "-5" }, +])("rejects a malformed query %o", async (query) => { + const res = await changes(query); + expect(res.status).toHaveBeenCalledWith(400); + expect(listChanges).not.toHaveBeenCalled(); +}); diff --git a/src/backend/tests/sync/two-way.test.ts b/src/backend/tests/sync/two-way.test.ts index ded0f0ab2..35c7915e6 100644 --- a/src/backend/tests/sync/two-way.test.ts +++ b/src/backend/tests/sync/two-way.test.ts @@ -14,6 +14,8 @@ const state = vi.hoisted(() => ({ current: null as null | { drizzle: unknown }, server: null as null | { drizzle: unknown }, allow: true, + sharedHostIds: new Set(), + paths: [] as string[], })); const SERVER_USER = "srv-user"; @@ -38,13 +40,18 @@ vi.mock("../../utils/system-crypto.js", () => ({ }, })); vi.mock("../../utils/data-crypto.js", () => ({ - DataCrypto: { getUserDataKey: () => Buffer.alloc(32, 3) }, + DataCrypto: { + getUserDataKey: () => Buffer.alloc(32, 3), + decryptRecord: (_table: string, record: unknown) => record, + }, })); vi.mock("../../utils/permission-manager.js", () => ({ PermissionManager: { getInstance: () => ({ hasPermission: async () => state.allow, - canAccessHost: async () => ({ hasAccess: false }), + canAccessHost: async (_userId: string, hostId: number) => ({ + hasAccess: state.sharedHostIds.has(hostId), + }), getUserPermissions: async () => [], }), }, @@ -117,6 +124,7 @@ vi.mock("../../sync/client/http.js", async (importOriginal) => { path: string, init: { method?: string; body?: unknown } = {}, ) => { + state.paths.push(path); const previous = state.current; state.current = state.server; try { @@ -147,11 +155,15 @@ const { default: syncRouter } = await import("../../sync/server/routes.js"); const { SyncEngine } = await import("../../sync/client/engine.js"); const { createLink, getLink, updateLink } = await import("../../sync/client/link-store.js"); -const { markChanged, resetFeed } = await import("../../sync/server/feed.js"); +const { markChanged, reconcileUser, resetFeed } = + await import("../../sync/server/feed.js"); const { resetSeqAllocator, listConflicts, getConflict, deleteConflict } = await import("../../sync/records.js"); const { createResolvers, writeWireRow } = await import("../../sync/store.js"); const { getEntity } = await import("../../plugins/sync-registry.js"); +const { FieldCrypto } = await import("../../utils/field-crypto.js"); +const { syncLogger } = await import("../../utils/logger.js"); +const { pluginEvents, TOPICS } = await import("../../plugins/events.js"); const app = express(); app.use(express.json({ limit: "5mb" })); @@ -239,6 +251,8 @@ beforeEach(async () => { resetFeed(); resetSeqAllocator(); state.allow = true; + state.sharedHostIds.clear(); + state.paths = []; server = await side(SERVER_USER); state.server = server.ctx; desktops = [await side("desk-a"), await side("desk-b")]; @@ -307,6 +321,60 @@ describe("linking a desktop", () => { expect(host.ip).toBe("new"); expect(await on(desk, () => listConflicts(desk.userId))).toEqual([]); }); + + it("never asks for a cursor past what the server's seq column holds", async () => { + const [desk] = desktops; + await on(server, () => addHost(server, { name: "srv" })); + + const engine = await linkDesktop(desk); + await pass(desk, engine); + + const cursors = state.paths + .filter((path) => path.startsWith("/sync/v2/changes?")) + .map((path) => + Number(new URLSearchParams(path.split("?")[1]).get("cursor")), + ); + expect(cursors.length).toBeGreaterThan(0); + expect(Math.max(...cursors)).toBeLessThanOrEqual(2_147_483_647); + }); +}); + +describe("a synced host", () => { + it("publishes host.created when it is written for the first time only", async () => { + const [desk] = desktops; + const seen: unknown[] = []; + const off = pluginEvents.on(TOPICS.hostCreated, (payload) => { + seen.push(payload); + }); + const wire = { + syncId: "host-new", + name: "new", + ip: "10.0.0.2", + port: 22, + username: "root", + authType: "password", + }; + try { + await on(desk, async () => { + const resolvers = createResolvers(desk.userId); + const first = await writeWireRow( + getEntity("hosts")!, + desk.userId, + wire, + resolvers, + ); + await writeWireRow( + getEntity("hosts")!, + desk.userId, + { ...wire, name: "renamed" }, + resolvers, + ); + expect(seen).toEqual([{ hostId: first.id, userId: desk.userId }]); + }); + } finally { + off(); + } + }); }); describe("after linking", () => { @@ -480,6 +548,181 @@ describe("after linking", () => { }); }); +describe("a server row that cannot be read", () => { + async function recordOf(where: Side, syncId: string) { + const [record] = await on(where, () => + db(where) + .select() + .from(schema.syncRecords) + .where( + and( + eq(schema.syncRecords.entityType, "hosts"), + eq(schema.syncRecords.syncId, syncId), + ), + ), + ); + return record; + } + + it.each(["upsert", "delete"] as const)( + "retries a local %s after the server row becomes readable", + async (op) => { + const [desk] = desktops; + await on(server, () => addHost(server, { name: "a" })); + const engine = await linkDesktop(desk); + await pass(desk, engine); + + await on(desk, () => + (op === "delete" + ? db(desk).delete(schema.hosts) + : db(desk).update(schema.hosts).set({ ip: "desk-edit" }) + ).where(eq(schema.hosts.syncId, "host-a")), + ); + await on(server, async () => { + await db(server) + .update(schema.hosts) + .set({ ip: "server-edit" }) + .where(eq(schema.hosts.syncId, "host-a")); + await reconcileUser(SERVER_USER, { force: true }); + // Then its secret stops decrypting, as under a different key. + const [host] = await hostsOf(server); + await db(server) + .update(schema.hosts) + .set({ + password: FieldCrypto.encryptField( + "s3cret", + Buffer.alloc(32, 9), + String(host.id), + "password", + ), + }) + .where(eq(schema.hosts.id, host.id)); + }); + + const warn = vi.spyOn(syncLogger, "warn"); + try { + await pass(desk, engine); + expect(warn).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ + entityType: "hosts", + syncId: "host-a", + error: expect.any(String), + }), + ); + } finally { + warn.mockRestore(); + } + + const [local] = await on(desk, () => hostsOf(desk)); + expect(local?.ip).toBe(op === "upsert" ? "desk-edit" : undefined); + expect(await on(desk, () => listConflicts(desk.userId))).toEqual([]); + expect((await recordOf(desk, "host-a")).error).toBe("unavailable"); + + // Restore the same server content without another local edit or a new + // server revision. The failed operation must still be retried. + await on(server, () => + db(server) + .update(schema.hosts) + .set({ password: null }) + .where(eq(schema.hosts.syncId, "host-a")), + ); + await on(server, () => reconcileUser(SERVER_USER, { force: true })); + await pass(desk, engine); + const [after] = await on(desk, () => hostsOf(desk)); + expect(after?.ip).toBe("server-edit"); + expect((await recordOf(desk, "host-a")).error).toBeNull(); + const conflicts = await on(desk, () => listConflicts(desk.userId)); + expect(conflicts.map((c) => JSON.parse(c.localRow).ip)).toEqual( + op === "upsert" ? ["desk-edit"] : [], + ); + }, + ); + + it("keeps the local row when a conflict carries no row", async () => { + const [desk] = desktops; + await on(server, () => addHost(server, { name: "a" })); + const engine = await linkDesktop(desk); + await pass(desk, engine); + + await on(desk, async () => { + const link = (await getLink())!; + const settle = ( + engine as unknown as { + settle: (...args: unknown[]) => Promise; + } + ).settle.bind(engine); + await settle( + link, + { + entityType: "hosts", + syncId: "host-a", + baseRevision: 1, + op: "upsert", + row: { syncId: "host-a", name: "a" }, + }, + { + entityType: "hosts", + syncId: "host-a", + status: "conflict", + revision: 2, + deleted: false, + row: null, + }, + "hash", + ); + }); + + expect((await on(desk, () => hostsOf(desk))).map((h) => h.name)).toEqual([ + "a", + ]); + const record = await recordOf(desk, "host-a"); + expect(record.deleted).toBe(false); + expect(record.revision).toBe(1); + }); +}); + +describe("hosts shared with the account", () => { + it("arrive on the desktop with their text columns as stored", async () => { + const [desk] = desktops; + await on(server, async () => { + await server.db.run( + sql`INSERT INTO users (id, username, password_hash, is_admin) VALUES ('owner', 'owner', '', 0)`, + ); + await db(server).insert(schema.hosts).values({ + userId: "owner", + name: "theirs", + ip: "10.0.0.5", + port: 22, + username: "root", + authType: "none", + forceKeyboardInteractive: "true", + syncId: "host-theirs", + }); + const [host] = await db(server).select().from(schema.hosts); + await db(server).insert(schema.hostAccess).values({ + hostId: host.id, + userId: SERVER_USER, + grantedBy: "owner", + permissionLevel: "view", + }); + state.sharedHostIds.add(host.id); + markChanged(); + }); + + const engine = await linkDesktop(desk); + await pass(desk, engine); + + const [copy] = await on(desk, () => hostsOf(desk)); + expect(copy?.name).toBe("theirs"); + expect(copy.forceKeyboardInteractive).toBe("true"); + expect(JSON.parse(copy.sharedSource!)).toMatchObject({ + owner: "owner", + permissionLevel: "view", + }); + }); +}); + describe("two desktops on one account", () => { it("converge on edits, creates and deletes made offline on each", async () => { const [a, b] = desktops; diff --git a/src/backend/tests/utils/audit-logger.test.ts b/src/backend/tests/utils/audit-logger.test.ts index b515154bf..5403462f4 100644 --- a/src/backend/tests/utils/audit-logger.test.ts +++ b/src/backend/tests/utils/audit-logger.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect, vi, beforeEach } from "vitest"; +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; const createMock = vi.fn().mockResolvedValue(undefined); @@ -60,6 +60,101 @@ describe("logAudit", () => { }); }); +describe("logAudit plugin routine coalescing", () => { + const WINDOW_MS = 10 * 60 * 1000; + let run = 0; + let details = ""; + + const routine = (overrides: Record = {}) => + logAudit({ + userId: null, + username: "plugin:automations", + action: "plugin_as_user", + resourceType: "plugin", + resourceId: "automations", + details, + success: true, + ...overrides, + }); + + beforeEach(() => { + vi.clearAllMocks(); + createMock.mockResolvedValue(undefined); + vi.useFakeTimers(); + // The coalescing memory is module state: a fresh key per test keeps them + // independent. + details = `automations ran background work as user-${++run}`; + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("writes one row for identical successful plugin_as_user repeats in the window", async () => { + await routine(); + vi.advanceTimersByTime(WINDOW_MS - 1); + await routine(); + await routine(); + + expect(createMock).toHaveBeenCalledOnce(); + }); + + it("writes one row for identical successful plugin service calls in the window", async () => { + const service = { + action: "plugin_service_host_metrics_open", + resourceId: "host-metrics", + }; + await routine(service); + await routine(service); + + expect(createMock).toHaveBeenCalledOnce(); + }); + + it("writes again once the window has passed", async () => { + await routine(); + vi.advanceTimersByTime(WINDOW_MS); + await routine(); + + expect(createMock).toHaveBeenCalledTimes(2); + }); + + it("always writes failures", async () => { + await routine(); + await routine({ success: false, errorMessage: "boom" }); + await routine({ success: false, errorMessage: "boom" }); + + expect(createMock).toHaveBeenCalledTimes(3); + }); + + it("never coalesces non-plugin or non-routine actions", async () => { + const login = { + userId: "user-1", + username: "alice", + action: "login", + resourceType: "user", + }; + await routine(login); + await routine(login); + const secretRead = { action: "plugin_secret_shared_read" }; + await routine(secretRead); + await routine(secretRead); + const notPlugin = { username: "alice" }; + await routine(notPlugin); + await routine(notPlugin); + + expect(createMock).toHaveBeenCalledTimes(6); + }); + + it("does not coalesce entries with different details or resourceId", async () => { + await routine(); + await routine({ details: `${details} (other)` }); + await routine({ resourceId: "other-plugin" }); + await routine({ userId: "user-2" }); + + expect(createMock).toHaveBeenCalledTimes(4); + }); +}); + describe("getRequestMeta", () => { it("uses the proxy-validated Express IP", () => { const req = { diff --git a/src/backend/tests/utils/trusted-proxy-auth.test.ts b/src/backend/tests/utils/trusted-proxy-auth.test.ts index 34b06d3e0..1adfd01ea 100644 --- a/src/backend/tests/utils/trusted-proxy-auth.test.ts +++ b/src/backend/tests/utils/trusted-proxy-auth.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "vitest"; import { getTrustedProxyAuthConfig, + getTrustedProxySourceAddress, isTrustedProxyAddress, parseTrustedProxyRoleMap, resolveTrustedProxyRoles, @@ -28,6 +29,35 @@ describe("trusted proxy authentication config", () => { expect(isTrustedProxyAddress("2001:db8::5", trusted)).toBe(true); }); + it("judges requests relayed by the bundled nginx by nginx's own peer", () => { + const viaNginx = (peer?: string) => ({ + headers: peer ? { "x-termix-proxy-peer": peer } : {}, + socket: { remoteAddress: "::ffff:127.0.0.1" }, + }); + expect(getTrustedProxySourceAddress(viaNginx("172.20.0.10"))).toBe( + "172.20.0.10", + ); + // A client reaching nginx directly is reported as itself, so trusting + // the auth proxy no longer means trusting nginx on loopback. + expect( + isTrustedProxyAddress( + getTrustedProxySourceAddress(viaNginx("203.0.113.7")), + ["127.0.0.1"], + ), + ).toBe(false); + // A local reverse proxy that does not set the header keeps working. + expect(getTrustedProxySourceAddress(viaNginx())).toBe("::ffff:127.0.0.1"); + }); + + it("ignores the peer header on connections that are not from loopback", () => { + expect( + getTrustedProxySourceAddress({ + headers: { "x-termix-proxy-peer": "172.20.0.10" }, + socket: { remoteAddress: "198.51.100.9" }, + }), + ).toBe("198.51.100.9"); + }); + it("fails closed when a supplied external role is not mapped", () => { const roleMap = parseTrustedProxyRoleMap( JSON.stringify({ operators: ["operator"], viewers: "readonly" }), diff --git a/src/backend/utils/audit-logger.ts b/src/backend/utils/audit-logger.ts index 58d2f28b1..1cc0d1228 100644 --- a/src/backend/utils/audit-logger.ts +++ b/src/backend/utils/audit-logger.ts @@ -35,7 +35,49 @@ export interface AuditLogParams { errorMessage?: string; } +// Plugin routine calls (a headless viewer's heartbeat through ctx.asUser and a +// service) repeat identically every few seconds; written each time they push +// real security entries out of the capped log. Only successful plugin_as_user +// and plugin_service_* lines are coalesced; failures and everything else are +// always written. +const ROUTINE_COALESCE_MS = 10 * 60 * 1000; +const ROUTINE_COALESCE_MAX_KEYS = 5000; +const routineLastWritten = new Map(); + +function isCoalescedRoutine(params: AuditLogParams): boolean { + if ( + !params.success || + !params.username.startsWith("plugin:") || + !( + params.action === "plugin_as_user" || + params.action.startsWith("plugin_service_") + ) + ) { + return false; + } + const key = JSON.stringify([ + params.userId, + params.username, + params.action, + params.resourceType, + params.resourceId, + params.details, + ]); + const now = Date.now(); + const last = routineLastWritten.get(key); + if (last !== undefined && now - last < ROUTINE_COALESCE_MS) return true; + if (routineLastWritten.size >= ROUTINE_COALESCE_MAX_KEYS) { + for (const [k, at] of routineLastWritten) { + if (now - at >= ROUTINE_COALESCE_MS) routineLastWritten.delete(k); + } + } + routineLastWritten.set(key, now); + return false; +} + export async function logAudit(params: AuditLogParams): Promise { + if (isCoalescedRoutine(params)) return; + // 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(() => {}); diff --git a/src/backend/utils/auth-manager.ts b/src/backend/utils/auth-manager.ts index cfa26475d..0c6eccabc 100644 --- a/src/backend/utils/auth-manager.ts +++ b/src/backend/utils/auth-manager.ts @@ -15,6 +15,7 @@ import bcrypt from "bcryptjs"; import { nanoid } from "nanoid"; import { inArray, isNotNull } from "drizzle-orm"; import type { ExternalSessionRef } from "../auth/types.js"; +import { listSecondFactors } from "../auth/registry.js"; import type { DeviceType } from "./user-agent-parser.js"; import { getDb } from "../database/db/index.js"; import { sessions } from "../database/db/schema.js"; @@ -44,6 +45,11 @@ interface JWTPayload { pendingTOTP?: boolean; /** Set on a short-lived socket ticket, which only a socket upgrade takes. */ purpose?: "ws"; + /** + * A plugin minted the session. Read from the session row on verify, never + * from the token, so a refreshed token stays marked. + */ + pluginSession?: boolean; dataKeyWrap?: WrappedDataKey; iat?: number; exp?: number; @@ -103,6 +109,26 @@ const IMPERSONATION_PATH_ALLOWLIST = [/^\/host\/db\//, /^\/credentials(\/|$)/]; const PLUGIN_API_PATH = /^\/plugin-api\/([a-z][a-z0-9-]*)(\/|$)/; +/** sessions.device_type of a session a plugin minted with ctx.auth.issueUserToken. */ +export const PLUGIN_SESSION_DEVICE_TYPE = "plugin"; + +// Routes that change how a user signs in or mint a credential that outlives +// the session. A plugin-minted session is refused on them, and on every +// route of a plugin that provides a second factor. +const PLUGIN_SESSION_BLOCKLIST = [ + /^\/users\/(change-password|api-keys|link-external-to-password|unlink-external-from-password|admin\/reset-password)(\/|$)/, + /^\/users\/admin\/[^/]+\/second-factors(\/|$)/, +]; + +/** Whether a session a plugin minted may call this path. */ +export function allowsPluginSession(path: string): boolean { + if (PLUGIN_SESSION_BLOCKLIST.some((pattern) => pattern.test(path))) { + return false; + } + const plugin = PLUGIN_API_PATH.exec(path)?.[1]; + return !listSecondFactors().some((factor) => factor.pluginId === plugin); +} + let pluginAllowsImpersonation: (pluginId: string) => boolean = () => false; /** Set by the plugin runtime: whether a running plugin opted in. */ @@ -407,6 +433,38 @@ class AuthManager { } as jwt.SignOptions); } + /** + * A session a plugin mints for a user, to call core's API as them. It is + * an ordinary session row, so it verifies, expires and shows up like a + * login, marked with the plugin and why. + */ + async issuePluginSession( + userId: string, + options: { pluginId: string; purpose: string; ttlSeconds: number }, + ): Promise<{ token: string; expiresAt: string }> { + const sessionId = nanoid(); + const token = jwt.sign( + { userId, sessionId } satisfies JWTPayload, + await this.systemCrypto.getJWTSecret(), + { expiresIn: options.ttlSeconds }, + ); + const now = new Date(); + const expiresAt = new Date( + now.getTime() + options.ttlSeconds * 1000, + ).toISOString(); + await createCurrentSessionRepository().create({ + id: sessionId, + userId, + jwtToken: token, + deviceType: PLUGIN_SESSION_DEVICE_TYPE, + deviceInfo: `${options.pluginId}: ${options.purpose}`, + createdAt: now.toISOString(), + expiresAt, + lastActiveAt: now.toISOString(), + }); + return { token, expiresAt }; + } + async verifyJWTToken( token: string, options: { allowSocketTicket?: boolean } = {}, @@ -440,6 +498,8 @@ class AuthManager { if (new Date(sessionRecord.expiresAt).getTime() < Date.now()) { return null; } + payload.pluginSession = + sessionRecord.deviceType === PLUGIN_SESSION_DEVICE_TYPE; await this.migrateDataKeyFromPayload(payload); } catch (dbError) { @@ -787,6 +847,8 @@ class AuthManager { }); } + if (this.refusePluginSession(req, res, payload)) return; + if (payload.sessionId) { try { const sessionRepository = createCurrentSessionRepository(); @@ -953,6 +1015,21 @@ class AuthManager { return false; } + /** Sends the 403 and returns true when a plugin session may not use this route. */ + private refusePluginSession( + req: Request, + res: Response, + payload: JWTPayload, + ): boolean { + const path = (req.originalUrl || req.url || "").split("?")[0]; + if (!payload.pluginSession || allowsPluginSession(path)) return false; + res.status(403).json({ + error: "A session a plugin issued cannot use this route", + code: "PLUGIN_SESSION_NOT_ALLOWED", + }); + return true; + } + createDataAccessMiddleware() { return async (req: Request, res: Response, next: NextFunction) => { const authReq = req as AuthenticatedRequest; @@ -1007,6 +1084,8 @@ class AuthManager { }); } + if (this.refusePluginSession(req, res, payload)) return; + try { const user = await createCurrentUserRepository().findById( payload.userId, diff --git a/src/backend/utils/trusted-proxy-auth.ts b/src/backend/utils/trusted-proxy-auth.ts index a2e207b26..444315a8c 100644 --- a/src/backend/utils/trusted-proxy-auth.ts +++ b/src/backend/utils/trusted-proxy-auth.ts @@ -1,3 +1,4 @@ +import type { IncomingHttpHeaders } from "node:http"; import { BlockList, isIP } from "node:net"; export interface TrustedProxyAuthConfig { @@ -108,6 +109,31 @@ export function isTrustedProxyAddress( ); } +function isLoopbackAddress(address: string): boolean { + const normalized = normalizeAddress(address); + return normalized === "::1" || normalized.startsWith("127."); +} + +/** + * The address checked against the allowlist. The bundled nginx always + * connects from loopback and sets X-Termix-Proxy-Peer to its own TCP peer, + * overwriting whatever the client sent, so behind it the proxy that counts + * is that peer rather than nginx. Any other connection is judged by its + * socket address. + */ +export function getTrustedProxySourceAddress(req: { + headers: IncomingHttpHeaders; + socket: { remoteAddress?: string }; +}): string | undefined { + const socketAddress = req.socket.remoteAddress; + const peer = req.headers["x-termix-proxy-peer"]; + return socketAddress && + isLoopbackAddress(socketAddress) && + typeof peer === "string" + ? peer + : socketAddress; +} + export function resolveTrustedProxyRoles( header: string, roleMap: Map, diff --git a/src/main.tsx b/src/main.tsx index ea0ed8e93..9e7090dc1 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -6,6 +6,8 @@ import "./ui/index.css"; import { ThemeProvider } from "@/components/theme-provider"; import "./ui/i18n/i18n"; import { isElectron } from "@/lib/electron"; +import { isEmbeddedAuthFrame } from "@/lib/embedded-frame"; +import { rememberViewLink, resumeViewLink } from "@/lib/view-link-resume"; import { getEmbeddedServerFailure } from "@/lib/embedded-server-status"; import { Toaster } from "@/components/sonner"; import { @@ -84,6 +86,15 @@ function FullscreenApp() { } } +/** + * A signed-out browser gets the normal login for a full-screen link and comes + * back to it afterwards. The desktop app and the embedded sign-in frames + * hand a login to their own shell, so they keep the bare view. + */ +function signsInForViewLink(): boolean { + return !isElectron() && !isEmbeddedAuthFrame(); +} + function FullscreenAppGate() { const { t } = useTranslation(); const [ready, setReady] = useState(false); @@ -109,7 +120,16 @@ function FullscreenAppGate() { if (!cancelled) setReady(true); }) .catch(() => { - if (!cancelled) setAuthFailed(true); + if (cancelled) return; + if (signsInForViewLink()) { + rememberViewLink(); + window.history.replaceState( + {}, + document.title, + window.location.pathname, + ); + } + setAuthFailed(true); }); return () => { @@ -118,7 +138,7 @@ function FullscreenAppGate() { }, []); if (authFailed) { - return ; + return signsInForViewLink() ? : ; } if (!ready) { @@ -181,6 +201,7 @@ function App() { // Non-fatal: WebSocket connections will fall back to cookie auth } } + if (resumeViewLink()) return; setPhase("loading-app"); await Promise.all([ startPluginRuntime() @@ -262,6 +283,10 @@ function App() { function handleLogin(u: string) { loggingOutRef.current = false; clearDesktopManualLogout(); + if (resumeViewLink()) { + setPhase("loading-app"); + return; + } setAuthUsername(u); setPhase("loading-app"); void (async () => { diff --git a/src/types/host-sidebar-preferences.ts b/src/types/host-sidebar-preferences.ts index bcc90f7c8..56ea4bc1a 100644 --- a/src/types/host-sidebar-preferences.ts +++ b/src/types/host-sidebar-preferences.ts @@ -88,6 +88,8 @@ export interface HostSidebarDisplayPreferences extends HostRowFields { /** When false, nested folders hide the parent-path breadcrumb before their name. */ showFolderPaths: boolean; hostClickBehavior: HostClickBehavior; + /** When true, the sidebar closes after a host is opened from it. */ + closeOnOpen: boolean; /** * Host action id to whether it shows in the row's connect bar. An action * missing here follows its own default. Every action stays in the menu. @@ -163,6 +165,7 @@ export function defaultHostSidebarPreferences(): HostSidebarPreferences { openOnDoubleClick: false, showFolderPaths: true, hostClickBehavior: "newTab", + closeOnOpen: false, barActions: {}, ...defaultHostRowFields(), }, @@ -274,6 +277,10 @@ export function sanitizeHostSidebarPreferences( ) ? (displayObj.hostClickBehavior as HostClickBehavior) : defaults.display.hostClickBehavior, + closeOnOpen: + typeof displayObj.closeOnOpen === "boolean" + ? displayObj.closeOnOpen + : defaults.display.closeOnOpen, barActions: sanitizeBarActions(displayObj.barActions), }; diff --git a/src/ui/AppShell.tsx b/src/ui/AppShell.tsx index aa7793bbb..49db7806f 100644 --- a/src/ui/AppShell.tsx +++ b/src/ui/AppShell.tsx @@ -125,6 +125,7 @@ import type { } from "@/types/ui-types"; import { applyAccentColor, applyFontSize } from "@/lib/theme"; import { globalShortcutHandler } from "@/lib/global-shortcut-handler"; +import { getPaneNavDirection } from "@/lib/pane-nav-hotkey"; import { getTabJumpDigit } from "@/lib/tab-jump-hotkey"; import { useTheme } from "@/components/theme-provider"; import { @@ -829,20 +830,11 @@ export function AppShell({ } } - // Alt+Arrow moves between panes of the active split - if (e.altKey && !e.ctrlKey && !e.shiftKey && !e.metaKey) { - const direction = ( - { - ArrowLeft: "left", - ArrowRight: "right", - ArrowUp: "up", - ArrowDown: "down", - } as Record - )[e.code]; - if (direction && splitActionsRef.current?.navigate(direction)) { - e.preventDefault(); - return; - } + // Alt+Arrow (Cmd+Option+Arrow on macOS) moves between panes of the active split + const paneDirection = getPaneNavDirection(e); + if (paneDirection && splitActionsRef.current?.navigate(paneDirection)) { + e.preventDefault(); + return; } // Cmd+1..9 on macOS, Alt+1..9 elsewhere: jump directly to the tab at that position @@ -2698,6 +2690,7 @@ export function AppShell({ if (isMobile) setSidebarOpen(false); }} onEditHost={editHostInManager} + onCloseSidebar={() => setSidebarOpen(false)} hostTree={realHostTree ?? undefined} loading={hostsLoading} onEditingChange={panelEditingSetter("hosts")} @@ -3133,6 +3126,9 @@ export function AppShell({ {renderSidebarPanels(rightRailView, false)} )} + + {/* Plugin panels pinned to the right edge, sized by the plugin */} + diff --git a/src/ui/api/plugins-api.ts b/src/ui/api/plugins-api.ts index ae62c1d81..4d46e3629 100644 --- a/src/ui/api/plugins-api.ts +++ b/src/ui/api/plugins-api.ts @@ -263,6 +263,8 @@ export interface PluginSummary { /** Environment variables the plugin reads. Admins only. */ env?: PluginEnvVar[]; signedBy?: string | null; + /** A linked server decides whether it runs here. Admins only. */ + managedByServer?: boolean; } export async function getPlugins(): Promise { @@ -510,10 +512,12 @@ export async function previewPluginState( export async function setPluginState( pluginId: string, enabled: boolean, + consent?: { version: string; capabilities: string[] }, ): Promise { try { const response = await rbacApi.patch(`${pluginPath(pluginId)}/state`, { enabled, + ...consent, }); return response.data; } finally { diff --git a/src/ui/auth/Auth.tsx b/src/ui/auth/Auth.tsx index fcda8bb47..a6867a0ed 100644 --- a/src/ui/auth/Auth.tsx +++ b/src/ui/auth/Auth.tsx @@ -434,10 +434,11 @@ export function Auth({ onLogin }: AuthProps) { }) .catch(() => { if (cancelled) return; + // Past the first few tries, show the error but keep checking, so + // the page moves on by itself once the server is back. if (attemptNumber >= maxAttempts) { setDbConnectionFailed(true); setDbHealthChecking(false); - return; } const delay = Math.min(500 * 2 ** attemptNumber, 5000); retryTimer = setTimeout(() => { diff --git a/src/ui/auth/ElectronLoginForm.tsx b/src/ui/auth/ElectronLoginForm.tsx index dee0fa511..e53be08a0 100644 --- a/src/ui/auth/ElectronLoginForm.tsx +++ b/src/ui/auth/ElectronLoginForm.tsx @@ -73,11 +73,21 @@ export function ElectronLoginForm({ ); // postMessage from server Auth.tsx after the backend has set the HttpOnly cookie. - // Uses '*' as target origin because the iframe may be cross-origin (e.g. remote Docker server). + // Only the server's own origin is trusted: the iframe can navigate away + // (identity provider, redirects), and those pages must not hand us a token, + // start a system browser login, or receive the replies. useEffect(() => { + let serverOrigin: string; + try { + serverOrigin = new URL(serverUrl).origin; + } catch { + return; + } + const handleMessage = async (event: MessageEvent) => { try { if (event.source !== iframeRef.current?.contentWindow) return; + if (event.origin !== serverOrigin) return; if (!event.data || typeof event.data !== "object") return; const { type, @@ -115,7 +125,7 @@ export function ElectronLoginForm({ providerId, ...result, }, - "*", + serverOrigin, ); }; const electronAPI = ( @@ -181,14 +191,14 @@ export function ElectronLoginForm({ success: false, error, }, - "*", + serverOrigin, ); } }; window.addEventListener("message", handleMessage); return () => window.removeEventListener("message", handleMessage); - }, [handleAuthSuccess, t]); + }, [handleAuthSuccess, serverUrl, t]); useEffect(() => { const iframe = iframeRef.current; diff --git a/src/ui/dashboard/DashboardTab.tsx b/src/ui/dashboard/DashboardTab.tsx index 892140fa4..1c0e75bbd 100644 --- a/src/ui/dashboard/DashboardTab.tsx +++ b/src/ui/dashboard/DashboardTab.tsx @@ -53,6 +53,8 @@ import { } from "@/lib/ServerStatusContext"; import { withLiveHostStatus } from "@/sidebar/live-host-status"; import { sshHostToHost } from "@/sidebar/HostManagerData"; +import { sortHostTree, type SortKey } from "@/sidebar/host-sort"; +import { Select2 } from "@/components/select2"; import { CountersStrip, HostStatusList, @@ -66,6 +68,17 @@ import { docsUrl } from "@/lib/docs"; export { HostStatusList as HostStatusCard } from "./dashboard-core-cards"; +const HOST_STATUS_SORT_LABELS: Partial> = { + default: "hosts.sortDefault", + "name-asc": "hosts.sortNameAsc", + "name-desc": "hosts.sortNameDesc", + "ip-asc": "hosts.sortIpAsc", + "ip-desc": "hosts.sortIpDesc", + "status-online": "hosts.sortOnlineFirst", + "status-offline": "hosts.sortOfflineFirst", +}; +const HOST_STATUS_SORTS = Object.keys(HOST_STATUS_SORT_LABELS) as SortKey[]; + // ─── Types ──────────────────────────────────────────────────────────────────── type PanelId = "main" | "side"; @@ -460,7 +473,20 @@ export function DashboardTab({ const [dbHealth, setDbHealth] = useState<"healthy" | "error">("healthy"); const [credentialCount, setCredentialCount] = useState(0); const [activity, setActivity] = useState([]); - const statusCheckHosts = hosts.filter(isStatusCheckEnabled); + const [hostStatusSort, setHostStatusSort] = useState(() => { + try { + const saved = localStorage.getItem("dashboardTab.hostStatusSort"); + return HOST_STATUS_SORTS.includes(saved as SortKey) + ? (saved as SortKey) + : "default"; + } catch { + return "default"; + } + }); + const statusCheckHosts = sortHostTree( + { name: "", children: hosts.filter(isStatusCheckEnabled) }, + hostStatusSort, + ).children as Host[]; useEffect(() => { let mounted = true; @@ -580,7 +606,28 @@ export function DashboardTab({ icon: core.icon, frame: core.frame, action: - id === "recent_activity" && activity.length > 0 ? ( + id === "host_status" && statusCheckHosts.length > 1 ? ( + { + const key = e.target.value as SortKey; + setHostStatusSort(key); + try { + localStorage.setItem("dashboardTab.hostStatusSort", key); + } catch { + /* ignore */ + } + }} + className="h-6 border border-border bg-background px-1.5 text-[10px] outline-none focus:ring-1 focus:ring-ring" + > + {HOST_STATUS_SORTS.map((key) => ( + + ))} + + ) : id === "recent_activity" && activity.length > 0 ? ( } diff --git a/src/ui/plugins/PluginDetail.tsx b/src/ui/plugins/PluginDetail.tsx index 171b33e04..b31a1bb7e 100644 --- a/src/ui/plugins/PluginDetail.tsx +++ b/src/ui/plugins/PluginDetail.tsx @@ -177,7 +177,7 @@ export function PluginDetail({ - - - )} - -
- {!pluginsSettled - ? Array.from({ length: mainItems.length || 8 }, (_, i) => ( -
- -
- )) - : bands.map((band, i) => ( -
- {i > 0 && ( - - )} - {band.items.map(renderItem)} -
- ))} - - {pluginsSettled && hiddenItems.length > 0 && ( - <> - - - {managing && - hiddenItems.map((item) => { - const Icon = item.icon; - return ( - - ); - })} - - )} -
- -
- {showPinButton && ( +
setHovered(true)} + onMouseLeave={() => setHovered(false)} + onContextMenu={(e) => { + e.preventDefault(); + // Keep the menu on screen when right-clicking near the viewport edges + const MENU_W = 200; + const MENU_H = 180; + setMenuPos({ + x: Math.min(e.clientX, window.innerWidth - MENU_W - 8), + y: Math.min(e.clientY, window.innerHeight - MENU_H - 8), + }); + if (!(e.target as HTMLElement).closest("[data-rail-item]")) { + setMenuTarget(null); + } + }} + > + {onOpenPalette && ( <> )} - {footerItems.map(renderItem)} - {footerItems.length > 0 && !isElectron() && ( - - )} - {/* The desktop signs in to its own local profile by itself. */} - {!isElectron() && ( - - )} -
-
- -
+
+ {!pluginsSettled + ? Array.from({ length: mainItems.length || 8 }, (_, i) => ( +
+ +
+ )) + : bands.map((band, i) => ( +
+ {i > 0 && ( + + )} + {band.items.map(renderItem)} +
+ ))} - {menuPos && ( -
- {menuTarget && ( + {pluginsSettled && hiddenItems.length > 0 && ( <> - {menuTarget.promotable && ( - } - onClick={() => { - onOpenTab?.(menuTarget.id as TabType); - setMenuPos(null); - }} - > - {t("nav.openAsTab")} - - )} - {menuTarget.rightDockable && ( - } - onClick={() => { - onOpenInRightDock?.(menuTarget.id); - setMenuPos(null); - }} - > - {t("nav.openInRightDock")} - - )} - {menuTarget.hideable && ( - } - onClick={() => { - void toggleHiddenRailTab(menuTarget.id); - setMenuPos(null); - }} - > - {t("nav.hideFromRail")} - - )} -
+ + + {managing && + hiddenItems.map((item) => { + const Icon = item.icon; + return ( + + ); + })} )} - : null} - onClick={togglePinned} - checked={pinned} - > - {t("newUi.sidebar.userProfile.pinAppRail")} - - : null} - onClick={toggleExpandOnHover} - checked={expandOnHover} - > - {t("newUi.sidebar.userProfile.expandAppRailOnHover")} - -
- } - onClick={() => { - onOpenNavigationSettings(); - setMenuPos(null); - }} - > - {t("nav.openSettings")} -
- )} + +
+ {showPinButton && ( + <> + + + + )} + {footerItems.map(renderItem)} + {footerItems.length > 0 && !isElectron() && ( + + )} + {/* The desktop signs in to its own local profile by itself. */} + {!isElectron() && ( + + )} +
+ +
+ +
+ + {menuPos && ( +
+ {menuTarget && ( + <> + {menuTarget.promotable && ( + } + onClick={() => { + onOpenTab?.(menuTarget.id as TabType); + setMenuPos(null); + }} + > + {t("nav.openAsTab")} + + )} + {menuTarget.rightDockable && ( + } + onClick={() => { + onOpenInRightDock?.(menuTarget.id); + setMenuPos(null); + }} + > + {t("nav.openInRightDock")} + + )} + {menuTarget.hideable && ( + } + onClick={() => { + void toggleHiddenRailTab(menuTarget.id); + setMenuPos(null); + }} + > + {t("nav.hideFromRail")} + + )} +
+ + )} + : null} + onClick={togglePinned} + checked={pinned} + > + {t("newUi.sidebar.userProfile.pinAppRail")} + + : null} + onClick={toggleExpandOnHover} + checked={expandOnHover} + > + {t("newUi.sidebar.userProfile.expandAppRailOnHover")} + +
+ } + onClick={() => { + onOpenNavigationSettings(); + setMenuPos(null); + }} + > + {t("nav.openSettings")} + +
+ )} +
); } diff --git a/src/ui/sidebar/CustomizeSidebarPanel.tsx b/src/ui/sidebar/CustomizeSidebarPanel.tsx index 64014aaac..b2eca9270 100644 --- a/src/ui/sidebar/CustomizeSidebarPanel.tsx +++ b/src/ui/sidebar/CustomizeSidebarPanel.tsx @@ -223,6 +223,20 @@ export function CustomizeSidebarPanel({ } /> + + + update((prev) => ({ + ...prev, + display: { ...prev.display, closeOnOpen: v }, + })) + } + /> +
; /** Position among the group's tabs, core ones included. */ @@ -88,7 +88,7 @@ function sectionVisible( function mergeByOrder( core: (HostTab & { order: number })[], group: "top" | "ssh", - t: (key: string) => string, + t: (key: string, options?: { defaultValue?: string }) => string, protocols: Record | undefined, defaultsMode = false, ): HostTab[] { @@ -104,7 +104,7 @@ function mergeByOrder( const Icon = section.icon; return { id: section.id, - label: section.label ?? t(section.labelKey), + label: t(section.labelKey, { defaultValue: section.label }), icon: Icon ? : null, order: section.order ?? 1000, }; @@ -119,7 +119,7 @@ function mergeByOrder( * `visible`; without, every registered top tab is listed. */ export function makeHostTabs( - t: (key: string) => string, + t: (key: string, options?: { defaultValue?: string }) => string, protocols?: Record, defaultsMode = false, ): HostTab[] { @@ -146,7 +146,7 @@ export function makeHostTabs( } export function makeHostSshSubTabs( - t: (key: string) => string, + t: (key: string, options?: { defaultValue?: string }) => string, protocols?: Record, defaultsMode = false, ): HostTab[] { @@ -167,7 +167,7 @@ export function makeHostSshSubTabs( } export function makeCredentialTabs( - t: (key: string) => string, + t: (key: string, options?: { defaultValue?: string }) => string, ): CredentialTab[] { return [ { diff --git a/src/ui/sidebar/HostsPanel.tsx b/src/ui/sidebar/HostsPanel.tsx index d51f9ead5..af79a50c8 100644 --- a/src/ui/sidebar/HostsPanel.tsx +++ b/src/ui/sidebar/HostsPanel.tsx @@ -55,6 +55,7 @@ import type { Host, HostFolder, TabType } from "@/types/ui-types"; import { sortHostTree, type SortKey } from "@/sidebar/host-sort"; import { withLiveStatus } from "@/sidebar/live-host-status"; import { useServerStatus } from "@/lib/ServerStatusContext"; +import { pluginName } from "@/lib/plugin-i18n"; import { useHostSidebarPreferences } from "@/sidebar/tree/hooks/useHostSidebarPreferences"; import { useArrangeLock } from "@/sidebar/use-arrange-lock"; import type { @@ -201,6 +202,7 @@ export function HostsPanel({ hostTree, loading, active = true, + onCloseSidebar, }: { onOpenTab: ( host: Host, @@ -217,6 +219,7 @@ export function HostsPanel({ /** Unused since editing moved to the Manage tab. */ onEditingChange?: (editing: boolean) => void; active?: boolean; + onCloseSidebar?: () => void; }) { const { t } = useTranslation(); const { statuses, getStatus } = useServerStatus(); @@ -814,7 +817,7 @@ export function HostsPanel({ } onSelect={(e) => e.preventDefault()} > - {plugin.name} + {pluginName(t, plugin)} ))} {allTags.length > 0 && ( @@ -1044,7 +1047,10 @@ export function HostsPanel({ ).children : [] } - onOpenTab={onOpenTab} + onOpenTab={(host, type, options) => { + onOpenTab(host, type, options); + if (sidebarPrefs.display.closeOnOpen) onCloseSidebar?.(); + }} onEditHost={onEditHost} onShareHost={(host) => setShareModalHost(host)} query={hostSearch.trim().toLowerCase()} diff --git a/src/ui/sidebar/InterfacePresetSettings.tsx b/src/ui/sidebar/InterfacePresetSettings.tsx index b305c69a2..b46da8103 100644 --- a/src/ui/sidebar/InterfacePresetSettings.tsx +++ b/src/ui/sidebar/InterfacePresetSettings.tsx @@ -12,6 +12,7 @@ import { import { Button } from "@/components/button"; import { useUiPreferencesContext } from "@/contexts/UiPreferencesContext"; import { applyPresetSideEffects } from "@/lib/apply-ui-preset"; +import { pluginName } from "@/lib/plugin-i18n"; import { usePluginStore } from "@/plugin-host/plugin-store"; import { UI_AREA_KEYS, @@ -67,9 +68,10 @@ export function InterfacePresetSettings({ ) .map((area) => { const pluginId = area.slice("plugin:".length); + const summary = pluginStore.records.get(pluginId)?.summary; return { key: area, - label: pluginStore.records.get(pluginId)?.summary.name ?? pluginId, + label: summary ? pluginName(t, summary) : pluginId, }; }); return [...core, ...plugins]; diff --git a/src/ui/sidebar/UserProfilePanel.tsx b/src/ui/sidebar/UserProfilePanel.tsx index 6d7a029e5..576246da7 100644 --- a/src/ui/sidebar/UserProfilePanel.tsx +++ b/src/ui/sidebar/UserProfilePanel.tsx @@ -553,6 +553,9 @@ export function UserProfilePanel({ const [expandAppRailOnHover, setExpandAppRailOnHover] = useState(() => readRailPreference("expandAppRailOnHover"), ); + const [appRailHoverPushes, setAppRailHoverPushes] = useState(() => + readRailPreference("appRailHoverPushesContent"), + ); const [showPinAppRailButton, setShowPinAppRailButton] = useState(() => readRailPreference("showPinAppRailButton"), ); @@ -1684,6 +1687,21 @@ export function UserProfilePanel({ }} /> + + { + setAppRailHoverPushes(v); + setRailPreference("appRailHoverPushesContent", v); + }} + /> + = { pinAppRail: "pinAppRailChanged", expandAppRailOnHover: "expandAppRailOnHoverChanged", showPinAppRailButton: "showPinAppRailButtonChanged", + appRailHoverPushesContent: "appRailHoverPushesContentChanged", }; +/** Kept on this device only; the server has no column for it. */ +const LOCAL_ONLY: ReadonlySet = new Set([ + "appRailHoverPushesContent", +]); + // pinAppRail and showPinAppRailButton default off, expandAppRailOnHover // defaults on, so each key needs its own read rather than a shared // === "true" check. @@ -28,6 +37,7 @@ export function readRailPreference(key: RailPreference): boolean { export function setRailPreference(key: RailPreference, value: boolean): void { localStorage.setItem(key, String(value)); window.dispatchEvent(new Event(CHANGE_EVENT[key])); + if (LOCAL_ONLY.has(key)) return; void getUserPreferences() .then((preferences) => { if (preferences.storageMode === "cloud") { diff --git a/src/ui/sidebar/tree/SidebarTree.tsx b/src/ui/sidebar/tree/SidebarTree.tsx index ce5050d1a..4a6d1ffeb 100644 --- a/src/ui/sidebar/tree/SidebarTree.tsx +++ b/src/ui/sidebar/tree/SidebarTree.tsx @@ -11,6 +11,7 @@ import { import { updatePluginHostSettings } from "@/api/plugins-api"; import { resetHostToDefaults } from "@/api/host-defaults-api"; import { PluginIcon } from "@/lib/plugin-icon"; +import { pluginName } from "@/lib/plugin-i18n"; import { usePluginHostSections } from "@/settings/HostPluginSections"; import { useTranslation } from "react-i18next"; import { useVirtualizer } from "@tanstack/react-virtual"; @@ -1284,7 +1285,7 @@ export function SidebarTree({ value ? "hosts.enablePluginFeature" : "hosts.disablePluginFeature", - { name: plugin.name }, + { name: pluginName(t, plugin) }, )} )), @@ -1307,7 +1308,7 @@ export function SidebarTree({ { id: "core", label: t("hostDefaults.resetCore") }, ...hostSettingPlugins.map((plugin) => ({ id: plugin.id, - label: plugin.name, + label: pluginName(t, plugin), })), ].map((option) => ( { expect(mainAxios.getSetupRequired).not.toHaveBeenCalled(); }); + it("keeps checking after the server is unreachable and recovers on its own", async () => { + vi.useFakeTimers({ shouldAdvanceTime: true }); + try { + mainAxios.getSetupRequired.mockRejectedValue(new Error("502")); + renderAuth(); + await vi.advanceTimersByTimeAsync(20000); + expect(screen.getByText("errors.databaseConnection")).toBeTruthy(); + + mainAxios.getSetupRequired.mockResolvedValue({ setup_required: false }); + await vi.advanceTimersByTimeAsync(6000); + await waitFor(() => + expect(screen.queryByText("errors.databaseConnection")).toBeNull(), + ); + } finally { + vi.useRealTimers(); + } + }); + it("keeps register visible for normal web auth when registration is allowed", async () => { mainAxios.isElectron.mockReturnValue(false); diff --git a/src/ui/tests/auth/ElectronLoginForm.test.tsx b/src/ui/tests/auth/ElectronLoginForm.test.tsx index 6de5c0a1a..5b78f3b00 100644 --- a/src/ui/tests/auth/ElectronLoginForm.test.tsx +++ b/src/ui/tests/auth/ElectronLoginForm.test.tsx @@ -43,6 +43,7 @@ describe("ElectronLoginForm", () => { window.dispatchEvent( new MessageEvent("message", { source: frame.contentWindow, + origin: "https://termix.example.com", data: { type: "AUTH_SUCCESS", platform: "desktop", @@ -53,4 +54,87 @@ describe("ElectronLoginForm", () => { ); await waitFor(() => expect(onAuthSuccess).toHaveBeenCalledWith("jwt-1")); }); + + it("ignores messages once the frame has left the server's origin", async () => { + const onAuthSuccess = vi.fn(); + const externalBrowserLogin = vi.fn(); + (window as unknown as { electronAPI?: unknown }).electronAPI = { + externalBrowserLogin, + }; + render( + , + ); + const frame = screen.getByTitle( + "auth.serverAuthentication", + ) as HTMLIFrameElement; + for (const data of [ + { + type: "AUTH_SUCCESS", + platform: "desktop", + source: "auth_component", + token: "attacker-jwt", + }, + { + type: "OIDC_SYSTEM_BROWSER_AUTH", + authUrl: "https://evil.example/", + callbackPort: 17832, + }, + ]) { + window.dispatchEvent( + new MessageEvent("message", { + source: frame.contentWindow, + origin: "https://evil.example", + data, + }), + ); + } + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(onAuthSuccess).not.toHaveBeenCalled(); + expect(externalBrowserLogin).not.toHaveBeenCalled(); + delete (window as unknown as { electronAPI?: unknown }).electronAPI; + }); + + it("replies to the server's origin only", async () => { + (window as unknown as { electronAPI?: unknown }).electronAPI = { + externalBrowserLogin: vi.fn().mockResolvedValue({ + success: false, + secondFactor: true, + tempToken: "temp-1", + }), + }; + render( + , + ); + const frame = screen.getByTitle( + "auth.serverAuthentication", + ) as HTMLIFrameElement; + const postMessage = vi.spyOn(frame.contentWindow!, "postMessage"); + window.dispatchEvent( + new MessageEvent("message", { + source: frame.contentWindow, + origin: "https://termix.example.com", + data: { + type: "OIDC_SYSTEM_BROWSER_AUTH", + authUrl: "https://idp.example.com/authorize", + callbackPort: 17832, + providerId: -1, + }, + }), + ); + await waitFor(() => + expect(postMessage).toHaveBeenCalledWith( + expect.objectContaining({ tempToken: "temp-1" }), + "https://termix.example.com", + ), + ); + delete (window as unknown as { electronAPI?: unknown }).electronAPI; + }); }); diff --git a/src/ui/tests/lib/embedded-frame.test.ts b/src/ui/tests/lib/embedded-frame.test.ts index 0ed596678..fb696af67 100644 --- a/src/ui/tests/lib/embedded-frame.test.ts +++ b/src/ui/tests/lib/embedded-frame.test.ts @@ -40,4 +40,13 @@ describe("embedded frame detection", () => { expect(isInElectronWebView()).toBe(true); expect(isEmbeddedAuthFrame()).toBe(true); }); + + it("only treats a frame as the desktop link window inside the app", () => { + vi.spyOn(window, "top", "get").mockReturnValue({} as Window); + expect(isInElectronWebView()).toBe(false); + vi.spyOn(navigator, "userAgent", "get").mockReturnValue( + "Termix-Desktop/26.10.2 (win32; Electron/38.0.0)", + ); + expect(isInElectronWebView()).toBe(true); + }); }); diff --git a/src/ui/tests/lib/local-backend-port.test.ts b/src/ui/tests/lib/local-backend-port.test.ts new file mode 100644 index 000000000..cd6e56a8f --- /dev/null +++ b/src/ui/tests/lib/local-backend-port.test.ts @@ -0,0 +1,25 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { localBackendPort } from "@/lib/electron"; + +function setElectronAPI(api: unknown) { + (window as unknown as { electronAPI?: unknown }).electronAPI = api; +} + +afterEach(() => { + delete (window as unknown as { electronAPI?: unknown }).electronAPI; +}); + +describe("localBackendPort", () => { + it("uses the port the desktop app picked", () => { + setElectronAPI({ backendPort: 41234 }); + expect(localBackendPort()).toBe(41234); + }); + + it("falls back to 30001", () => { + expect(localBackendPort()).toBe(30001); + setElectronAPI({ backendPort: Number.NaN }); + expect(localBackendPort()).toBe(30001); + setElectronAPI({ backendPort: 70000 }); + expect(localBackendPort()).toBe(30001); + }); +}); diff --git a/src/ui/tests/lib/pane-nav-hotkey.test.ts b/src/ui/tests/lib/pane-nav-hotkey.test.ts new file mode 100644 index 000000000..d5d23df15 --- /dev/null +++ b/src/ui/tests/lib/pane-nav-hotkey.test.ts @@ -0,0 +1,54 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + getPaneNavDirection, + paneNavHotkeyLabel, +} from "../../lib/pane-nav-hotkey"; + +function keyEvent(init: Partial): KeyboardEvent { + return { + code: "ArrowLeft", + altKey: false, + ctrlKey: false, + metaKey: false, + shiftKey: false, + ...init, + } as KeyboardEvent; +} + +afterEach(() => vi.unstubAllGlobals()); + +describe("pane-nav-hotkey", () => { + it("uses Alt+Arrow outside macOS", () => { + vi.stubGlobal("navigator", { platform: "Win32" }); + expect(getPaneNavDirection(keyEvent({ altKey: true }))).toBe("left"); + expect( + getPaneNavDirection(keyEvent({ code: "ArrowDown", altKey: true })), + ).toBe("down"); + expect( + getPaneNavDirection(keyEvent({ altKey: true, metaKey: true })), + ).toBeNull(); + expect(paneNavHotkeyLabel()).toBe("Alt+Arrows"); + }); + + it("leaves Option+Arrow to the shell on macOS", () => { + vi.stubGlobal("navigator", { platform: "MacIntel" }); + expect(getPaneNavDirection(keyEvent({ altKey: true }))).toBeNull(); + expect( + getPaneNavDirection( + keyEvent({ code: "ArrowRight", altKey: true, metaKey: true }), + ), + ).toBe("right"); + expect(paneNavHotkeyLabel()).toBe("Cmd+Option+Arrows"); + }); + + it("ignores other keys and modifiers", () => { + vi.stubGlobal("navigator", { platform: "Win32" }); + expect( + getPaneNavDirection(keyEvent({ code: "KeyA", altKey: true })), + ).toBeNull(); + expect( + getPaneNavDirection(keyEvent({ altKey: true, shiftKey: true })), + ).toBeNull(); + expect(getPaneNavDirection(keyEvent({}))).toBeNull(); + }); +}); diff --git a/src/ui/tests/lib/plugin-i18n.test.ts b/src/ui/tests/lib/plugin-i18n.test.ts new file mode 100644 index 000000000..07757f05d --- /dev/null +++ b/src/ui/tests/lib/plugin-i18n.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it } from "vitest"; +import i18next from "i18next"; +import { pluginDescription, pluginName } from "@/lib/plugin-i18n"; + +async function zhCN() { + const i18n = i18next.createInstance(); + await i18n.init({ + lng: "zh-CN", + fallbackLng: "en", + fallbackNS: "translation", + resources: { + "zh-CN": { + translation: { common: { save: "保存" } }, + "host-metrics": { + plugin: { name: "主机指标", description: "查看主机负载" }, + }, + "no-description": { plugin: { name: "无描述" } }, + }, + }, + }); + return i18n.t; +} + +describe("pluginName", () => { + it("reads the name from the plugin's own namespace", async () => { + const t = await zhCN(); + expect(pluginName(t, { id: "host-metrics", name: "Host Metrics" })).toBe( + "主机指标", + ); + }); + + it("falls back to the manifest name when the plugin has no strings", async () => { + const t = await zhCN(); + expect(pluginName(t, { id: "not-loaded", name: "Not Loaded" })).toBe( + "Not Loaded", + ); + }); +}); + +describe("pluginDescription", () => { + it("reads the description from the plugin's own namespace", async () => { + const t = await zhCN(); + expect( + pluginDescription(t, { + id: "host-metrics", + description: "See host load", + }), + ).toBe("查看主机负载"); + }); + + it("falls back to the manifest description, or nothing", async () => { + const t = await zhCN(); + expect( + pluginDescription(t, { id: "no-description", description: "Plain" }), + ).toBe("Plain"); + expect(pluginDescription(t, { id: "not-loaded" })).toBe(""); + }); +}); diff --git a/src/ui/tests/lib/plugin-transport.test.ts b/src/ui/tests/lib/plugin-transport.test.ts index 15f665d3c..86c31281e 100644 --- a/src/ui/tests/lib/plugin-transport.test.ts +++ b/src/ui/tests/lib/plugin-transport.test.ts @@ -111,6 +111,21 @@ describe("pluginWsUrl in the browser", () => { expect(target?.protocols).toEqual(["termix.jwt.ticket-1"]); }); + it("goes through the Vite proxy in dev on any port", async () => { + vi.stubEnv("NODE_ENV", "development"); + try { + fetchMock.mockResolvedValueOnce( + new Response(JSON.stringify({ ticket: "ticket-1" })), + ); + const target = await pluginWsUrl("ssh-terminal", "/terminal"); + expect(target?.url).toBe( + `ws://${window.location.host}/__termix_api/30001/plugin-ws/ssh-terminal/terminal`, + ); + } finally { + vi.unstubAllEnvs(); + } + }); + it("falls back to the cookie alone for a guest", async () => { fetchMock.mockResolvedValueOnce(new Response("{}", { status: 401 })); diff --git a/src/ui/tests/lib/view-link-resume.test.ts b/src/ui/tests/lib/view-link-resume.test.ts new file mode 100644 index 000000000..ecf37509c --- /dev/null +++ b/src/ui/tests/lib/view-link-resume.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, it } from "vitest"; +import { consumeViewLink, toViewLinkPath } from "@/lib/view-link-resume"; + +describe("toViewLinkPath", () => { + it.each([ + ["/?view=example-plugin.request&request=123", true], + ["/termix/?view=example-plugin.request#top", true], + ["//evil.com/?view=x", false], + ["/\\evil.com/?view=x", false], + ["https://evil.com/?view=x", false], + ["/foo", false], + ["/?view=", false], + ["not a url", false], + ])("%s -> %s", (value, accepted) => { + expect(toViewLinkPath(value)).toBe(accepted ? value : null); + }); +}); + +describe("consumeViewLink", () => { + it("clears the stored link even when it is invalid", () => { + sessionStorage.setItem("termix-pending-view-link", "//evil.com/?view=x"); + expect(consumeViewLink()).toBeNull(); + expect(sessionStorage.getItem("termix-pending-view-link")).toBeNull(); + }); +}); diff --git a/src/ui/tests/main.test.tsx b/src/ui/tests/main.test.tsx new file mode 100644 index 000000000..685c1d663 --- /dev/null +++ b/src/ui/tests/main.test.tsx @@ -0,0 +1,186 @@ +import { cleanup, fireEvent, render, screen } from "@testing-library/react"; +import type { ReactNode } from "react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const KEY = "termix-pending-view-link"; + +const mocks = vi.hoisted(() => ({ + rendered: null as ReactNode, + isElectron: vi.fn(() => false), + getStoredAuth: vi.fn(), + getUserInfo: vi.fn(), + fetchGuestViews: vi.fn(), + startPluginRuntime: vi.fn(async () => {}), + standaloneViewFor: vi.fn(), +})); + +vi.mock("react-dom/client", () => ({ + createRoot: () => ({ + render: (node: ReactNode) => { + mocks.rendered = node; + }, + }), +})); +vi.mock("@/lib/client-cache-version", () => ({ + prepareClientCacheVersion: () => Promise.resolve(), +})); +vi.mock("@/i18n/i18n", () => ({})); +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ t: (key: string) => key }), +})); +vi.mock("@/components/theme-provider", () => ({ + ThemeProvider: ({ children }: { children: ReactNode }) => children, +})); +vi.mock("@/contexts/BrandingContext", () => ({ + BrandingProvider: ({ children }: { children: ReactNode }) => children, +})); +vi.mock("@/contexts/UiPreferencesContext", () => ({ + UiPreferencesProvider: ({ children }: { children: ReactNode }) => children, +})); +vi.mock("@/components/sonner", () => ({ Toaster: () => null })); +vi.mock("@/hooks/use-service-worker", () => ({ useServiceWorker: () => {} })); +vi.mock("@/lib/electron-wheel-zoom", () => ({ + installElectronWheelZoomGuard: () => {}, +})); +vi.mock("@/lib/electron", () => ({ isElectron: mocks.isElectron })); +vi.mock("@/lib/embedded-server-status", () => ({ + getEmbeddedServerFailure: async () => null, +})); +vi.mock("@/auth/Auth", () => ({ + Auth: ({ onLogin }: { onLogin: (username: string) => void }) => ( + + ), + getStoredAuth: mocks.getStoredAuth, + clearStoredAuth: vi.fn(), + markDesktopManualLogout: vi.fn(), + clearDesktopManualLogout: vi.fn(), +})); +vi.mock("@/main-axios", () => ({ + getUserInfo: mocks.getUserInfo, + getCurrentToken: vi.fn(async () => null), + appReadyPromise: Promise.resolve(), +})); +vi.mock("@/plugin-host/loader", () => ({ + fetchGuestViews: mocks.fetchGuestViews, + startPluginRuntime: mocks.startPluginRuntime, + stopPluginRuntime: vi.fn(async () => {}), +})); +vi.mock("@/plugin-host/plugin-store", () => ({ + settledPromise: async () => {}, +})); +vi.mock("@/plugin-host/shell-bridge", () => ({ resetShellBridge: vi.fn() })); +vi.mock("@/hooks/use-permissions", () => ({ + preloadPermissions: async () => {}, +})); +vi.mock("@/api/open-tabs-api", () => ({ + getUserPreferences: async () => undefined, +})); +vi.mock("@/shell/tab-registry", () => ({ + standaloneViewFor: mocks.standaloneViewFor, +})); +vi.mock("@/plugin-host/PluginViewPlaceholder", () => ({ + PluginViewPlaceholder: ({ viewId }: { viewId: string }) => ( +
placeholder:{viewId}
+ ), +})); +vi.mock("@/onboarding/startup", () => ({ + resolveStartupOnboarding: async () => null, +})); +vi.mock("@/AppShell", () => ({ AppShell: () =>
app shell
})); +vi.mock("@/onboarding/OnboardingStage", () => ({ + OnboardingStage: () => null, +})); + +const originalLocation = window.location; +let replace: ReturnType; + +/** Loads the entry point fresh, the way a page load would. */ +async function openPage(url: string) { + window.history.replaceState({}, "", url); + vi.resetModules(); + mocks.rendered = null; + await import("../../main"); + await vi.waitFor(() => expect(mocks.rendered).not.toBeNull()); + render(<>{mocks.rendered}); +} + +beforeEach(() => { + vi.clearAllMocks(); + sessionStorage.clear(); + localStorage.clear(); + mocks.isElectron.mockReturnValue(false); + mocks.getStoredAuth.mockReturnValue(null); + mocks.getUserInfo.mockRejectedValue({ status: 401 }); + mocks.fetchGuestViews.mockResolvedValue(["example-plugin.guest"]); + mocks.standaloneViewFor.mockImplementation((view: string) => ({ + standalone: () =>
standalone:{view}
, + })); + replace = vi.fn(); + Object.defineProperty(window, "location", { + configurable: true, + value: { + get origin() { + return originalLocation.origin; + }, + get pathname() { + return originalLocation.pathname; + }, + get search() { + return originalLocation.search; + }, + get hash() { + return originalLocation.hash; + }, + replace, + }, + }); +}); + +afterEach(() => { + cleanup(); + Object.defineProperty(window, "location", { + configurable: true, + value: originalLocation, + }); +}); + +describe("full-screen view links", () => { + it("shows the login when signed out and returns to the link after it", async () => { + await openPage("/?view=example-plugin.request&request=123"); + + const signIn = await screen.findByText("sign in"); + expect(sessionStorage.getItem(KEY)).toBe( + "/?view=example-plugin.request&request=123", + ); + fireEvent.click(signIn); + + await vi.waitFor(() => + expect(replace).toHaveBeenCalledWith( + "/?view=example-plugin.request&request=123", + ), + ); + expect(sessionStorage.getItem(KEY)).toBeNull(); + }); + + it("keeps the bare view in the desktop app", async () => { + mocks.isElectron.mockReturnValue(true); + mocks.standaloneViewFor.mockReturnValue(undefined); + await openPage("/?view=example-plugin.request"); + + expect( + await screen.findByText("placeholder:example-plugin.request"), + ).toBeTruthy(); + expect(screen.queryByText("sign in")).toBeNull(); + expect(sessionStorage.getItem(KEY)).toBeNull(); + }); + + it("serves a guest view without a session or a login", async () => { + await openPage("/?view=example-plugin.guest&room=1"); + + expect( + await screen.findByText("standalone:example-plugin.guest"), + ).toBeTruthy(); + expect(mocks.getUserInfo).not.toHaveBeenCalled(); + expect(sessionStorage.getItem(KEY)).toBeNull(); + }); +}); diff --git a/src/ui/tests/manage/EditorPane.test.tsx b/src/ui/tests/manage/EditorPane.test.tsx index caab9826e..fb6f2efd8 100644 --- a/src/ui/tests/manage/EditorPane.test.tsx +++ b/src/ui/tests/manage/EditorPane.test.tsx @@ -92,6 +92,25 @@ describe("EditorPane search", () => { }); }); +describe("EditorPane jump", () => { + it("mounts lazy sections before scrolling to one", async () => { + // Never intersecting, so lazy sections only mount when asked to. + vi.stubGlobal( + "IntersectionObserver", + class { + observe() {} + disconnect() {} + }, + ); + renderEditor(); + expect(screen.queryByText("Font size")).toBeNull(); + // The side nav, not the narrow-screen tab strip. + fireEvent.click(screen.getAllByRole("button", { name: "Terminal" })[0]); + await waitFor(() => expect(screen.getByText("Font size")).toBeTruthy()); + vi.unstubAllGlobals(); + }); +}); + describe("useScrollSpy scrollTo", () => { it("follows a section that moves while the form is still mounting", async () => { const container = document.createElement("div"); @@ -116,4 +135,65 @@ describe("useScrollSpy scrollTo", () => { await waitFor(() => expect(calls).toContain(892)); container.remove(); }); + + it("keeps following past 1.5s while lazy sections still mount", async () => { + vi.useFakeTimers({ toFake: ["Date", "requestAnimationFrame"] }); + const container = document.createElement("div"); + const section = document.createElement("div"); + section.dataset.section = "warpgate"; + container.appendChild(section); + document.body.appendChild(container); + let sectionTop = 300; + section.getBoundingClientRect = () => ({ top: sectionTop }) as DOMRect; + container.getBoundingClientRect = () => ({ top: 0 }) as DOMRect; + // Still scrolling toward the target, so the follow stays on. + Object.defineProperty(container, "scrollHeight", { value: 5000 }); + const calls: number[] = []; + container.scrollTo = ((options: ScrollToOptions) => { + calls.push(options.top ?? 0); + }) as typeof container.scrollTo; + + const ref = { current: container }; + const { result } = renderHook(() => + useScrollSpy(ref, ["general", "warpgate"]), + ); + act(() => result.current.scrollTo("warpgate")); + act(() => vi.advanceTimersByTime(2000)); + sectionTop = 1400; + act(() => vi.advanceTimersByTime(100)); + expect(calls).toContain(1392); + + vi.useRealTimers(); + container.remove(); + }); + + it("stops following once the user scrolls", () => { + vi.useFakeTimers({ toFake: ["Date", "requestAnimationFrame"] }); + const container = document.createElement("div"); + const section = document.createElement("div"); + section.dataset.section = "warpgate"; + container.appendChild(section); + document.body.appendChild(container); + let sectionTop = 300; + section.getBoundingClientRect = () => ({ top: sectionTop }) as DOMRect; + container.getBoundingClientRect = () => ({ top: 0 }) as DOMRect; + Object.defineProperty(container, "scrollHeight", { value: 5000 }); + const calls: number[] = []; + container.scrollTo = ((options: ScrollToOptions) => { + calls.push(options.top ?? 0); + }) as typeof container.scrollTo; + + const ref = { current: container }; + const { result } = renderHook(() => + useScrollSpy(ref, ["general", "warpgate"]), + ); + act(() => result.current.scrollTo("warpgate")); + fireEvent.wheel(container); + sectionTop = 900; + act(() => vi.advanceTimersByTime(200)); + expect(calls).toEqual([292]); + + vi.useRealTimers(); + container.remove(); + }); }); diff --git a/src/ui/tests/onboarding/steps/PluginsStep.test.tsx b/src/ui/tests/onboarding/steps/PluginsStep.test.tsx index 3958011ad..18a782e33 100644 --- a/src/ui/tests/onboarding/steps/PluginsStep.test.tsx +++ b/src/ui/tests/onboarding/steps/PluginsStep.test.tsx @@ -1,5 +1,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { act, fireEvent, render, screen, within } from "@testing-library/react"; +import i18next from "i18next"; +import { I18nextProvider, initReactI18next } from "react-i18next"; import type { OnboardingPluginList } from "@/api/plugins-api"; const api = vi.hoisted(() => ({ @@ -186,4 +188,38 @@ describe("PluginsStep", () => { expect(ok).toBe(true); expect(api.applyOnboardingPlugins).toHaveBeenCalledTimes(1); }); + + it("shows plugin names and descriptions in the UI language", async () => { + const i18n = i18next.createInstance(); + await i18n.use(initReactI18next).init({ + lng: "zh-CN", + fallbackNS: "translation", + resources: { + "zh-CN": { + translation: {}, + term: { plugin: { name: "终端", description: "终端插件" } }, + stats: { plugin: { name: "统计" } }, + }, + }, + }); + render( + + {}} + setBeforeNext={() => {}} + /> + , + ); + expect(await screen.findByText("终端")).toBeTruthy(); + expect(screen.getByText("终端插件")).toBeTruthy(); + expect(screen.getByRole("radiogroup", { name: "终端" })).toBeTruthy(); + expect(screen.getByText("统计")).toBeTruthy(); + expect(screen.getByText("stats plugin")).toBeTruthy(); + expect(screen.getByText("BASE")).toBeTruthy(); + expect(screen.getByText("base plugin")).toBeTruthy(); + }); }); diff --git a/src/ui/tests/plugins/PluginConsentPrompt.test.tsx b/src/ui/tests/plugins/PluginConsentPrompt.test.tsx index f6dcc1db8..70234086c 100644 --- a/src/ui/tests/plugins/PluginConsentPrompt.test.tsx +++ b/src/ui/tests/plugins/PluginConsentPrompt.test.tsx @@ -89,3 +89,26 @@ describe("PluginConsentPrompt", () => { expect(container.innerHTML).toBe(""); }); }); + +it("shows explicit enable consent and only confirms when clicked", () => { + const onConfirm = vi.fn(); + render( + {}} + onConfirm={onConfirm} + />, + ); + expect(screen.getByText("plugins.manager.consent.enableTitle")).toBeTruthy(); + expect( + screen.getByText("plugins.capabilities.ssh:connect.title"), + ).toBeTruthy(); + expect(onConfirm).not.toHaveBeenCalled(); + fireEvent.click(screen.getByText("plugins.manager.enable")); + expect(onConfirm).toHaveBeenCalledOnce(); +}); diff --git a/src/ui/tests/plugins/PluginsTab.test.tsx b/src/ui/tests/plugins/PluginsTab.test.tsx index 94440c2ae..4fccb589f 100644 --- a/src/ui/tests/plugins/PluginsTab.test.tsx +++ b/src/ui/tests/plugins/PluginsTab.test.tsx @@ -23,7 +23,9 @@ const plugin = { contributes: {}, versions: [], downloads: null, + managedByServer: false, }; +let linked = false; vi.mock("@/plugins/use-plugins-manager", () => ({ usePluginsManager: () => ({ @@ -32,7 +34,7 @@ vi.mock("@/plugins/use-plugins-manager", () => ({ refreshing: false, refresh: vi.fn(), registryError: null, - managedByServer: false, + managedByServer: linked, busy: new Set(), consent: null, setConsent: vi.fn(), @@ -56,7 +58,11 @@ async function renderTab() { ); } -beforeEach(() => localStorage.clear()); +beforeEach(() => { + localStorage.clear(); + linked = false; + plugin.managedByServer = false; +}); afterEach(cleanup); describe("PluginsTab", () => { @@ -123,4 +129,17 @@ describe("PluginsTab", () => { ); expect(grid?.style.gridTemplateColumns).toContain("auto-fill"); }); + + it("lets a linked desktop switch a plugin its server does not decide", async () => { + linked = true; + await renderTab(); + const toggle = () => + screen.getByRole("button", { name: en.plugins.manager.disable }); + expect(toggle().hasAttribute("disabled")).toBe(false); + + cleanup(); + plugin.managedByServer = true; + await renderTab(); + expect(toggle().hasAttribute("disabled")).toBe(true); + }); }); diff --git a/src/ui/tests/plugins/plugin-model.test.ts b/src/ui/tests/plugins/plugin-model.test.ts index 05806a9cb..8a71a1091 100644 --- a/src/ui/tests/plugins/plugin-model.test.ts +++ b/src/ui/tests/plugins/plugin-model.test.ts @@ -82,6 +82,14 @@ describe("mergePlugins", () => { }); }); + it("says whether the linked server decides an installed plugin", () => { + const rows = mergePlugins( + [summary({ managedByServer: true })], + [entry({ id: "other" })], + ); + expect(rows.map((row) => row.managedByServer)).toEqual([true, false]); + }); + it("lists registry plugins that are not installed with the latest capabilities", () => { const rows = mergePlugins( [], diff --git a/src/ui/tests/plugins/use-plugins-manager.test.tsx b/src/ui/tests/plugins/use-plugins-manager.test.tsx new file mode 100644 index 000000000..8cd538c22 --- /dev/null +++ b/src/ui/tests/plugins/use-plugins-manager.test.tsx @@ -0,0 +1,109 @@ +import { act, renderHook, waitFor } from "@testing-library/react"; +import { expect, it, vi } from "vitest"; + +const api = vi.hoisted(() => ({ + getPlugins: vi.fn(async () => [ + { + id: "sample-plugin", + name: "Sample", + version: "1.0.0", + enabled: false, + state: "loaded", + source: "user", + capabilities: ["kv:own"], + managedByServer: false, + contributes: {}, + }, + ]), + getPluginRegistry: vi.fn(async () => ({ + plugins: [], + managedByServer: true, + registry: {}, + })), + getDeveloperMode: vi.fn(async () => ({ enabled: false, signedOnly: false })), + previewPluginState: vi.fn(async () => ({ + enable: [], + disable: [], + missing: [], + })), + setPluginState: vi.fn(async () => ({ state: "active" })), +})); +vi.mock("@/api/plugins-api", async (importOriginal) => ({ + ...(await importOriginal()), + ...api, +})); +vi.mock("@/components/surface/surface-scope", () => ({ + useConfirm: () => vi.fn(async () => true), +})); +const i18n = vi.hoisted(() => { + const strings: Record = { + "sample-plugin:plugin.name": "示例", + "sample-plugin:plugin.description": "一个示例插件", + }; + return { + t: (key: string, options?: { defaultValue?: string }) => + strings[key] ?? options?.defaultValue ?? key, + }; +}); +vi.mock("react-i18next", () => ({ useTranslation: () => i18n })); +vi.mock("sonner", () => ({ toast: { error: vi.fn(), success: vi.fn() } })); + +import { usePluginsManager } from "@/plugins/use-plugins-manager"; + +it("waits for local consent and sends the reviewed version and capabilities", async () => { + const { result } = renderHook(() => usePluginsManager()); + await waitFor(() => expect(result.current.loading).toBe(false)); + const plugin = result.current.plugins[0]; + await act(async () => { + await result.current.toggle(plugin); + }); + expect(result.current.consent).toMatchObject({ + mode: "enable", + version: "1.0.0", + capabilities: ["kv:own"], + }); + expect(api.setPluginState).not.toHaveBeenCalled(); + act(() => result.current.setConsent(null)); + expect(api.setPluginState).not.toHaveBeenCalled(); + await act(async () => { + await result.current.toggle(plugin); + }); + await act(async () => { + await result.current.confirmConsent(); + }); + expect(api.setPluginState).toHaveBeenCalledExactlyOnceWith( + "sample-plugin", + true, + { version: "1.0.0", capabilities: ["kv:own"] }, + ); +}); + +it("names plugins in the UI language, falling back to the manifest", async () => { + api.getPluginRegistry.mockResolvedValueOnce({ + plugins: [ + { + id: "browse-only", + name: "Browse Only", + description: "Not installed", + author: "someone", + category: "Tools", + latestVersion: "1.0.0", + versions: [], + bundled: false, + }, + ], + managedByServer: true, + registry: {}, + } as never); + const { result } = renderHook(() => usePluginsManager()); + await waitFor(() => expect(result.current.loading).toBe(false)); + expect( + result.current.plugins.map(({ name, description }) => ({ + name, + description, + })), + ).toEqual([ + { name: "示例", description: "一个示例插件" }, + { name: "Browse Only", description: "Not installed" }, + ]); +}); diff --git a/src/ui/tests/settings/host-feature-tabs.test.tsx b/src/ui/tests/settings/host-feature-tabs.test.tsx index cb44b9b67..78c32d50e 100644 --- a/src/ui/tests/settings/host-feature-tabs.test.tsx +++ b/src/ui/tests/settings/host-feature-tabs.test.tsx @@ -33,7 +33,10 @@ vi.mock("@/api/plugins-api", () => ({ })); vi.mock("react-i18next", () => ({ - useTranslation: () => ({ t: (key: string) => key }), + useTranslation: () => ({ + t: (key: string, options?: { defaultValue?: string }) => + options?.defaultValue ?? key, + }), })); function plugin(overrides: Partial = {}): PluginSummary { @@ -75,9 +78,10 @@ describe("syncHostFeatureTabs", () => { "feature:docker", "feature:warpgate", ]); - expect(getHostEditorSection(hostFeatureTabId("docker"))?.label).toBe( - "Docker", - ); + expect(getHostEditorSection(hostFeatureTabId("docker"))).toMatchObject({ + labelKey: "docker:plugin.name", + label: "Docker", + }); expect(getHostEditorSection(hostFeatureTabId("warpgate"))).toBeDefined(); }); diff --git a/src/ui/tests/sidebar/AdminSettingsPanel.test.tsx b/src/ui/tests/sidebar/AdminSettingsPanel.test.tsx new file mode 100644 index 000000000..8402b00b9 --- /dev/null +++ b/src/ui/tests/sidebar/AdminSettingsPanel.test.tsx @@ -0,0 +1,113 @@ +import { describe, it, expect, vi, afterEach } from "vitest"; +import { cleanup, render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; + +import { AdminSettingsPanel } from "../../sidebar/AdminSettingsPanel"; +import type { AdminUser } from "../../sidebar/AdminManagementSections"; + +vi.mock("@/main-axios", () => ({ + getUserList: vi.fn(async () => ({ users: [], total: 0 })), + getSessions: vi.fn(async () => ({ sessions: [] })), + getRoles: vi.fn(async () => ({ roles: [] })), + getApiKeys: vi.fn(async () => ({ apiKeys: [] })), + getUserRoles: vi.fn(async () => ({ roles: [] })), + isElectron: () => false, +})); + +vi.mock("@/api/settings-api", () => ({})); +vi.mock("@/api/tls-api", () => ({ getTlsStatus: vi.fn(async () => null) })); +vi.mock("@/api/private-endpoints-api", () => ({})); + +vi.mock("@/contexts/BrandingContext", () => ({ + useBranding: () => ({ applyBranding: vi.fn() }), +})); + +vi.mock("@/components/surface/surface-scope", () => ({ + useConfirm: () => vi.fn(async () => true), +})); + +vi.mock("@/settings/FeatureSettingsSections", () => ({ + FeatureSettingsSection: () => null, + featureSectionId: (id: string) => `feature:${id}`, + useFeatureSettings: () => [], +})); + +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ t: (key: string) => key }), +})); + +vi.mock("sonner", () => ({ + toast: { success: vi.fn(), error: vi.fn() }, +})); + +const { bob, section } = vi.hoisted(() => ({ + bob: { + id: "u2", + username: "bob", + isAdmin: false, + isExternal: false, + passwordHash: "hash", + dataUnlocked: true, + } as AdminUser, + section: + (label: string) => + ({ open }: { open: boolean }) => + open ?
{label}
: null, +})); + +vi.mock("../../sidebar/AdminManagementSections", () => ({ + AdminUsersSection: ({ + open, + onManageUser, + }: { + open: boolean; + onManageUser: (user: AdminUser) => void; + }) => + open ? : null, + AdminSessionsSection: section("sessions section"), + AdminRolesSection: section("roles section"), +})); + +vi.mock("../../sidebar/AdminSettingsSections", () => ({ + AdminGeneralSettingsSection: section("general section"), + AdminHostDefaultsSection: section("host defaults section"), + AdminDatabaseSection: section("database section"), + AdminSSLSection: section("ssl section"), +})); + +vi.mock("../../sidebar/AdminApiKeysSection", () => ({ + AdminApiKeysSection: section("api keys section"), +})); +vi.mock("../../sidebar/AdminAuditLogSection", () => ({ + AdminAuditLogSection: section("audit log section"), +})); +vi.mock("../../sidebar/AdminBrandingSection", () => ({ + AdminBrandingSection: section("branding section"), +})); +vi.mock("../../sidebar/AdminUserDialogs", () => ({ + AdminCreateUserDialog: () => null, + AdminEditUserDialog: () => null, + AdminLinkAccountDialog: () => null, + AdminUnlinkAccountDialog: () => null, +})); +vi.mock("../../sidebar/AdminUserManagePanel", () => ({ + AdminUserManagePanel: ({ user }: { user: AdminUser }) => ( +
managing {user.username}
+ ), +})); + +afterEach(cleanup); + +describe("AdminSettingsPanel", () => { + it("leaves the user manage view when another section is selected", async () => { + const { rerender } = render(); + + await userEvent.click(screen.getByText("manage bob")); + expect(screen.getByText("managing bob")).toBeTruthy(); + + rerender(); + + expect(screen.getByText("sessions section")).toBeTruthy(); + expect(screen.queryByText("managing bob")).toBeNull(); + }); +}); diff --git a/src/ui/tests/sidebar/AppRail.test.tsx b/src/ui/tests/sidebar/AppRail.test.tsx index fc22a105e..379307125 100644 --- a/src/ui/tests/sidebar/AppRail.test.tsx +++ b/src/ui/tests/sidebar/AppRail.test.tsx @@ -54,6 +54,29 @@ describe("AppRail", () => { expect(screen.queryByLabelText("common.logout")).toBeNull(); }); + it("expands over the content on hover without taking more room", () => { + renderRail(); + const rail = screen + .getByLabelText("common.logout") + .closest(".absolute") as HTMLElement; + const slot = rail.parentElement as HTMLElement; + const collapsed = slot.style.width; + fireEvent.mouseEnter(rail); + expect(rail.style.width).not.toBe(collapsed); + expect(slot.style.width).toBe(collapsed); + }); + + it("pushes the content on hover when the user asks for it", () => { + localStorage.setItem("appRailHoverPushesContent", "true"); + renderRail(); + const rail = screen + .getByLabelText("common.logout") + .closest(".absolute") as HTMLElement; + const slot = rail.parentElement as HTMLElement; + fireEvent.mouseEnter(rail); + expect(slot.style.width).toBe(rail.style.width); + }); + it("puts a footer item above the profile with its badge", async () => { registerRailItem({ id: "inbox", diff --git a/src/ui/tests/sidebar/host-sort.test.ts b/src/ui/tests/sidebar/host-sort.test.ts index ffb84db1a..9c6ac6725 100644 --- a/src/ui/tests/sidebar/host-sort.test.ts +++ b/src/ui/tests/sidebar/host-sort.test.ts @@ -75,6 +75,44 @@ describe("sortHostTree", () => { }); }); +describe("sortHostTree ip mode", () => { + const ips = (folder: HostFolder) => + folder.children.map((child) => (child as Host).ip); + const tree: HostFolder = { + name: "root", + children: [ + "10.0.0.210", + "db.internal", + "10.0.0.3", + "10.0.0.255", + "10.0.0.1", + "10.0.0.69", + "10.0.0.9", + ].map((ip) => ({ ...host(ip), ip })), + }; + + it("orders IPv4 addresses numerically by octet", () => { + expect(ips(sortHostTree(tree, "ip-asc"))).toEqual([ + "10.0.0.1", + "10.0.0.3", + "10.0.0.9", + "10.0.0.69", + "10.0.0.210", + "10.0.0.255", + "db.internal", + ]); + expect(ips(sortHostTree(tree, "ip-desc"))).toEqual([ + "db.internal", + "10.0.0.255", + "10.0.0.210", + "10.0.0.69", + "10.0.0.9", + "10.0.0.3", + "10.0.0.1", + ]); + }); +}); + describe("sortHostTree manual mode", () => { it("sorts hosts by sortOrder, nulls last, name tie-break", () => { const tree: HostFolder = { diff --git a/src/ui/tests/types/host-sidebar-preferences.test.ts b/src/ui/tests/types/host-sidebar-preferences.test.ts index 470ab4c95..1491446fa 100644 --- a/src/ui/tests/types/host-sidebar-preferences.test.ts +++ b/src/ui/tests/types/host-sidebar-preferences.test.ts @@ -28,6 +28,7 @@ describe("defaultHostSidebarPreferences", () => { openOnDoubleClick: false, showFolderPaths: true, hostClickBehavior: "newTab", + closeOnOpen: false, barActions: {}, showAddress: true, showUsername: true, @@ -74,6 +75,7 @@ describe("sanitizeHostSidebarPreferences", () => { openOnDoubleClick: true, showFolderPaths: false, hostClickBehavior: "focusExistingDoubleClickNew", + closeOnOpen: true, barActions: { maintenance: true }, showAddress: false, showUsername: false,