diff --git a/.github/workflows/electron.yml b/.github/workflows/electron.yml index 4b7544326..36cf88960 100644 --- a/.github/workflows/electron.yml +++ b/.github/workflows/electron.yml @@ -115,7 +115,9 @@ jobs: Write-Host "Spectre libs present: $($found.FullName)" - name: Install dependencies - run: npm ci + run: | + npm ci + node scripts/install-windows-sharp.cjs # Bundled plugins are counted as downloads in the plugin store, so a # build only fetches them when the pins change. diff --git a/.prettierignore b/.prettierignore index 11fe2e1de..db6faabac 100644 --- a/.prettierignore +++ b/.prettierignore @@ -11,4 +11,3 @@ db *.min.css openapi.json drizzle/ -src/ui/settings/settings-search-index.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index ff63f5dbd..fccf2ea28 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,65 @@ # Changelog +## 26.10.3 + +### Added + +- Choose the layout of the whole app in Settings, Appearance. Admins can also set the default layout and theme for everyone +- Plugins can add layouts and themes +- A repair screen shows when the chosen layout cannot load, with ways to update, switch, reset or turn it off. Open it any time with ?safe-layout=1, or for everyone with TERMIX_SAFE_LAYOUT=1 +- Hovering a theme in Settings previews it +- Restore the bundled copy of a plugin from the repair screen, which also pins the shipped version +- Switching layouts closes the tabs only the old layout could show, like its Settings tab +- A chosen layout whose plugin was uninstalled falls back to the default instead of the repair screen +- Filter plugins by type, like layouts, themes or tabs, from a filter list beside the results +- Browse shows plugins a page at a time +- Plugins that need a newer Termix are hidden in Browse, with a toggle to show them +- Hosts can be managed from a config file through a plugin. Managed hosts are read only for everyone, but pins and order still work +- A plugin can block adding hosts by hand, so every host comes from one place +- Plugins can set login rules: sign out after a time without activity, limit how long a session lasts, turn off remember me and require a second factor +- A user who must have a second factor but has none can sign in only to set one up +- Admins can redirect HTTP to HTTPS and send HSTS while HTTPS is on. Off by default so reverse proxies keep working +- A plugin can answer all sync requests and use the replica store, change feed and linked server list, so sync no longer lives in the core app +- The desktop app can hold several accounts, each with its own copy of a server's data. The app signs in as the active one and its auto session follows a switch +- The linked server list holds one link per account, with room for more connection options per server +- A plugin can carry SSH connections over its own stream, and core relays a host's connection over a socket for another Termix +- Plugins can create a folder with a color and icon and remove it when it is empty +- Another Termix server can manage this one with an API key, which can now ask for a one minute ticket to open plugin sockets as its user +- FIDO2 security keys (ed25519-sk and ecdsa-sk) work through SSH Agent authentication when Termix runs in the desktop app. The key asks for a touch or PIN on your own machine +- Pasting a security key file now explains how to use it instead of failing to decode it +- Plugins can add a panel that sits beside a host tab, like a terminal. You can open, resize and move it +- The desktop app can link to a server that requires a client certificate (mutual TLS). It uses a PFX or P12 file for sync and the certificate in your system store for the app window +- Plugins can show a web page in a native view in the desktop app, for sites that do not load in an iframe +- The desktop app draws its own title bar in the colors of your theme instead of the native white one + +### Changed + +- Server and desktop sync moved out of the core app into a plugin, including the Sync screen, the link wizard and the first run step. Existing links and offline copies keep working +- The icon rail, sidebar, tab bar, dock, Dashboard, Manage, Settings and command palette now come from a bundled layout. It can be swapped for another layout but not removed, and a fix to it no longer needs a Termix release +- Your theme is saved to your account so your other devices use it too, unless you keep settings on this device only +- The themes besides light and dark now come from a bundled themes plugin. It can be swapped but not removed, and Termix keeps a basic light and dark theme of its own +- A theme that no longer exists shows as System instead of an unstyled page +- Installs and updates from a registry check the Termix version a plugin needs, not only its plugin API +- Host online and offline dots now come from the Host Status plugin. The check interval setting moved to its settings and keeps its value. Without the plugin hosts show no dot +- Audit log forwarding and the CSV and NDJSON export now come from the Audit Tools plugin. The collector URL and token move to its settings on upgrade. The audit log viewer, retention and the audit log itself stay in Termix +- Client tunnels on your own computer and opening a host in Windows Remote Desktop moved out of the desktop app into plugins, so fixes to them no longer need a new desktop build +- The local terminal, the local side of the file manager with its transfers, and opening a file in an external editor moved out of the desktop app into plugins, so fixes to them no longer need a new desktop build +- The recent activity list and the first run steps for the preset and for theme, accent, font and language now come from bundled plugins. Existing entries and choices are kept, and people who went through those steps are not asked again +- Branding (app name, tagline and logo) is now saved by the bundled layout plugin. An existing setup keeps its branding, and the sign in screen still reads it from Termix +- Without the plugin that stores recent activity, the list stays empty and nothing is recorded +- Importing hosts from a JSON or SSH config file and exporting hosts now come from the Import and Export plugin. Exports are now written to the audit log. Without the plugin there is no import or export +- Generating and checking SSH keys in the credential editor and deploying a key to a host now come from the Credential Tools plugin. Deploys are now written to the audit log. Without the plugin those buttons are hidden + +### Fixed + +- Linking the desktop app to a server no longer stalls on the server dashboard after sign in +- Exporting your data no longer fails when a plugin table has a text key, like saved passkeys +- The Windows on ARM desktop app starts again instead of failing with a local server error + +### Removed + +- The desktop app no longer has its own client tunnel and native Remote Desktop code + ## 26.10.2 ### Added diff --git a/README.md b/README.md index 8f2aa812a..b4b270adc 100644 --- a/README.md +++ b/README.md @@ -57,42 +57,51 @@ What every install has, with or without plugins: Plugins add everything else. The official ones ship with Termix, and admins can install, update or remove plugins from the Plugins tab. Each plugin has its own repo. -| Plugin | What it does | -| --------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------- | -| [ACME Certificates](https://github.com/Termix-SSH/Plugin-ACME-SSL) | Gets the Termix HTTPS certificate from Let's Encrypt or any ACME provider and renews it for you. | -| [AI Assistant](https://github.com/Termix-SSH/Plugin-AI) | An AI assistant that reads your setup and proposes changes for you to approve. | -| [Alerts](https://github.com/Termix-SSH/Plugin-Alerts) | One inbox for every alert in Termix, with delivery to webhooks, ntfy, Discord and email. | -| [Automations](https://github.com/Termix-SSH/Plugin-Automations) | Run steps on your hosts on a schedule or when something happens. | -| [Docker](https://github.com/Termix-SSH/Plugin-Docker) | Manage Docker and Podman containers on your hosts over SSH, with logs, stats and a console. | -| [File Manager](https://github.com/Termix-SSH/Plugin-File-Manager) | Browse, edit and move files over SFTP, including straight from one server to another. | -| [Fleets](https://github.com/Termix-SSH/Plugin-Fleets) | Group hosts into fleets and run commands, package actions and file transfers on all of them at once. | -| [HashiCorp Vault](https://github.com/Termix-SSH/Plugin-Vault) | Connect to hosts with short-lived SSH certificates signed by HashiCorp Vault. | -| [Homepage](https://github.com/Termix-SSH/Plugin-Homepage) | A drag and drop widget page for your servers and services, plus service links for the dashboard. | -| [Host Metrics](https://github.com/Termix-SSH/Plugin-Host-Metrics) | Live CPU, memory, disk, network and GPU stats, plus tools to manage your hosts. | -| [LDAP](https://github.com/Termix-SSH/Plugin-LDAP) | Sign in with your LDAP or Active Directory username and password. | -| [Network Topology](https://github.com/Termix-SSH/Plugin-Network-Topology) | Draws your hosts and the links between them as a live, interactive graph. | -| [OPKSSH](https://github.com/Termix-SSH/Plugin-OPKSSH) | Connect to hosts with short-lived OpenPubkey SSH certificates after signing in with your identity provider. | -| [Passkeys](https://github.com/Termix-SSH/Plugin-WebAuthn) | Sign in with a passkey or security key instead of a password. | -| [Proxmox](https://github.com/Termix-SSH/Plugin-Proxmox) | Import Proxmox VE guests as hosts, keep them in sync and watch node stats. | -| [Remote Desktop](https://github.com/Termix-SSH/Plugin-Remote-Desktop) | RDP, VNC and Telnet sessions in your browser, with clipboard, file transfer, sharing and recording. | -| [Secret Sources](https://github.com/Termix-SSH/Plugin-Secret-Sources) | Pull host passwords and keys from 1Password when you connect instead of storing them in Termix. | -| [Serial](https://github.com/Termix-SSH/Plugin-Serial) | Open a serial console to a router, switch or other device plugged into your computer. | -| [Session Recording](https://github.com/Termix-SSH/Plugin-Session-Recording) | Record terminal and remote desktop sessions and play them back or download them later. | -| [Session Sharing](https://github.com/Termix-SSH/Plugin-Session-Sharing) | Share live sessions by link or with another user, and present sessions to a group in meeting rooms. | -| [Single sign-on](https://github.com/Termix-SSH/Plugin-SSO) | Sign in with any OpenID Connect provider, GitHub or Google. | -| [Snippets](https://github.com/Termix-SSH/Plugin-Snippets) | Save the commands you run often and run them in a terminal or on many hosts in one click. | -| [SSH Terminal](https://github.com/Termix-SSH/Plugin-SSH-Terminal) | SSH terminal sessions with tabs, split screen, history, macros and a local terminal in the desktop app. | -| [Step CA](https://github.com/Termix-SSH/Plugin-Step-CA) | Connect to hosts with short-lived SSH certificates from a smallstep step-ca server. | -| [Tailscale](https://github.com/Termix-SSH/Plugin-Tailscale) | Add tailnet devices as hosts and connect with Tailscale SSH. | -| [Termix Identity](https://github.com/Termix-SSH/Plugin-Termix-Identity) | Publish your SSH public keys under a public handle and run your own SSH certificate authority. | -| [Tmux Monitor](https://github.com/Termix-SSH/Plugin-Tmux-Monitor) | Browse and control tmux sessions, windows and panes across your hosts. | -| [TOTP](https://github.com/Termix-SSH/Plugin-TOTP) | Two-factor sign in with a code from an authenticator app, with backup codes. | -| [Tunnels](https://github.com/Termix-SSH/Plugin-Tunnels) | SSH port forwarding that reconnects on its own, plus client tunnels from the desktop app. | -| [Usage Statistics](https://github.com/Termix-SSH/Plugin-Telemetry) | Sends a small anonymous daily report so we can see how Termix is used. | -| [Wake-on-LAN](https://github.com/Termix-SSH/Plugin-Wake-On-LAN) | Wake sleeping machines by sending a Wake-on-LAN packet to their MAC address. | -| [Warpgate](https://github.com/Termix-SSH/Plugin-Warpgate) | Connect to hosts through a Warpgate SSH bastion. | -| [Web Endpoint](https://github.com/Termix-SSH/Plugin-Web-Endpoint) | Open a host's web UI inside Termix, directly or over an SSH tunnel. | -| [Workspaces](https://github.com/Termix-SSH/Plugin-Workspaces) | Save your open tabs and split layouts and reopen them in one click. | +| Plugin | What it does | +| --------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------- | +| [ACME Certificates](https://github.com/Termix-SSH/Plugin-ACME-SSL) | Gets the Termix HTTPS certificate from Let's Encrypt or any ACME provider and renews it for you. | +| [AI Assistant](https://github.com/Termix-SSH/Plugin-AI) | An AI assistant that reads your setup and proposes changes for you to approve. | +| [Alerts](https://github.com/Termix-SSH/Plugin-Alerts) | One inbox for every alert in Termix, with delivery to webhooks, ntfy, Discord and email. | +| [Audit Tools](https://github.com/Termix-SSH/Plugin-Audit-Tools) | Send the audit log to your SIEM and export it as CSV or NDJSON. | +| [Automations](https://github.com/Termix-SSH/Plugin-Automations) | Run steps on your hosts on a schedule or when something happens. | +| [Classic Layout](https://github.com/Termix-SSH/Plugin-Classic-Layout) | The classic Termix layout with an icon rail, sidebar, tab bar and dock. | +| [Classic Themes](https://github.com/Termix-SSH/Plugin-Classic-Themes) | Light, dark and seven more color themes for Termix. | +| [Credential Tools](https://github.com/Termix-SSH/Plugin-Credential-Tools) | Generate SSH keys, check them and copy a public key to a host. | +| [Docker](https://github.com/Termix-SSH/Plugin-Docker) | Manage Docker and Podman containers on your hosts over SSH, with logs, stats and a console. | +| [File Manager](https://github.com/Termix-SSH/Plugin-File-Manager) | Browse, edit and move files over SFTP, including straight from one server to another. | +| [Fleets](https://github.com/Termix-SSH/Plugin-Fleets) | Group hosts into fleets and run commands, package actions and file transfers on all of them at once. | +| [HashiCorp Vault](https://github.com/Termix-SSH/Plugin-Vault) | Connect to hosts with short-lived SSH certificates signed by HashiCorp Vault. | +| [Homepage](https://github.com/Termix-SSH/Plugin-Homepage) | A drag and drop widget page for your servers and services, plus service links for the dashboard. | +| [Host Metrics](https://github.com/Termix-SSH/Plugin-Host-Metrics) | Live CPU, memory, disk, network and GPU stats, plus tools to manage your hosts. | +| [Host Status](https://github.com/Termix-SSH/Plugin-Host-Status) | Shows if each host is online with a dot in the host list, checked by connecting to its port. | +| [Hosts Config](https://github.com/Termix-SSH/Plugin-Hosts-Config) | Keep hosts, folders and sharing in a YAML or JSON file. Termix loads them as read only hosts and reloads when the file changes. | +| [Import and Export](https://github.com/Termix-SSH/Plugin-Import-Export) | Import hosts from a JSON file or an SSH config file and export them to share or back up. | +| [LDAP](https://github.com/Termix-SSH/Plugin-LDAP) | Sign in with your LDAP or Active Directory username and password. | +| [Login Security](https://github.com/Termix-SSH/Plugin-Login-Security) | Sign people out when idle, limit session length, turn off remember me and require a second factor at login. | +| [Network Topology](https://github.com/Termix-SSH/Plugin-Network-Topology) | Draws your hosts and the links between them as a live, interactive graph. | +| [OPKSSH](https://github.com/Termix-SSH/Plugin-OPKSSH) | Connect to hosts with short-lived OpenPubkey SSH certificates after signing in with your identity provider. | +| [Passkeys](https://github.com/Termix-SSH/Plugin-WebAuthn) | Sign in with a passkey or security key instead of a password. | +| [Proxmox](https://github.com/Termix-SSH/Plugin-Proxmox) | Import Proxmox VE guests as hosts, keep them in sync and watch node stats. | +| [Remote Desktop](https://github.com/Termix-SSH/Plugin-Remote-Desktop) | RDP, VNC and Telnet sessions in your browser, with clipboard, file transfer, sharing and recording. | +| [Secret Sources](https://github.com/Termix-SSH/Plugin-Secret-Sources) | Pull host passwords and keys from 1Password when you connect instead of storing them in Termix. | +| [Serial](https://github.com/Termix-SSH/Plugin-Serial) | Open a serial console to a router, switch or other device plugged into your computer. | +| [Session Recording](https://github.com/Termix-SSH/Plugin-Session-Recording) | Record terminal and remote desktop sessions and play them back or download them later. | +| [Session Sharing](https://github.com/Termix-SSH/Plugin-Session-Sharing) | Share live sessions by link or with another user, and present sessions to a group in meeting rooms. | +| [Single sign-on](https://github.com/Termix-SSH/Plugin-SSO) | Sign in with any OpenID Connect provider, GitHub or Google. | +| [Snippets](https://github.com/Termix-SSH/Plugin-Snippets) | Save the commands you run often and run them in a terminal or on many hosts in one click. | +| [SSH Terminal](https://github.com/Termix-SSH/Plugin-SSH-Terminal) | SSH terminal sessions with tabs, split screen, history, macros and a local terminal in the desktop app. | +| [Step CA](https://github.com/Termix-SSH/Plugin-Step-CA) | Connect to hosts with short-lived SSH certificates from a smallstep step-ca server. | +| [Sync](https://github.com/Termix-SSH/Plugin-Sync) | Sync the desktop app with one or more servers, and manage other Termix servers as remote sites from a master. | +| [Tailscale](https://github.com/Termix-SSH/Plugin-Tailscale) | Add tailnet devices as hosts and connect with Tailscale SSH. | +| [Termix Identity](https://github.com/Termix-SSH/Plugin-Termix-Identity) | Publish your SSH public keys under a public handle and run your own SSH certificate authority. | +| [Tmux Monitor](https://github.com/Termix-SSH/Plugin-Tmux-Monitor) | Browse and control tmux sessions, windows and panes across your hosts. | +| [TOTP](https://github.com/Termix-SSH/Plugin-TOTP) | Two-factor sign in with a code from an authenticator app, with backup codes. | +| [Tunnels](https://github.com/Termix-SSH/Plugin-Tunnels) | SSH port forwarding that reconnects on its own, plus client tunnels from the desktop app. | +| [Usage Statistics](https://github.com/Termix-SSH/Plugin-Telemetry) | Sends a small anonymous daily report so we can see how Termix is used. | +| [Wake-on-LAN](https://github.com/Termix-SSH/Plugin-Wake-On-LAN) | Wake sleeping machines by sending a Wake-on-LAN packet to their MAC address. | +| [Warpgate](https://github.com/Termix-SSH/Plugin-Warpgate) | Connect to hosts through a Warpgate SSH bastion. | +| [Web Endpoint](https://github.com/Termix-SSH/Plugin-Web-Endpoint) | Open a host's web UI inside Termix, directly or over an SSH tunnel. | +| [Workspaces](https://github.com/Termix-SSH/Plugin-Workspaces) | Save your open tabs and split layouts and reopen them in one click. | Want to build your own? Start from the [plugin template](https://github.com/Termix-SSH/Termix-Plugin-Template). Plugins you can install are listed in the [registry](https://github.com/Termix-SSH/Termix-Registry). Turn on the community registry in Plugins, Registries to install plugins made by other people, or add your own registry. See [submitting a plugin](https://docs.termix.site/develop/community-registry) and [hosting your own registry](https://docs.termix.site/develop/registries#host-your-own-registry). diff --git a/docker/bundled-plugins.json b/docker/bundled-plugins.json index 75cc1d7ba..05e3489aa 100644 --- a/docker/bundled-plugins.json +++ b/docker/bundled-plugins.json @@ -9,8 +9,8 @@ { "id": "ai", "source": "tmxplug", - "url": "https://github.com/Termix-SSH/Plugin-AI/releases/download/v1.1.0/ai-1.1.0.tmxplug", - "sha256": "71dda6940d62c9eede7821bbac21e99443fbc08728a47759f215e2416f1d162f" + "url": "https://github.com/Termix-SSH/Plugin-AI/releases/download/v1.2.0/ai-1.2.0.tmxplug", + "sha256": "3b486133af78ae5168e6f5b8a4c71f3db3403cfdae537ed1ca8d6cc16035d8dd" }, { "id": "alerts", @@ -22,17 +22,43 @@ "consent": false } }, + { + "id": "audit-tools", + "source": "tmxplug", + "url": "https://github.com/Termix-SSH/Plugin-Audit-Tools/releases/download/v1.0.0/audit-tools-1.0.0.tmxplug", + "sha256": "131479c294d692b1c0ed4d6cec273730a3a67a28ecd44825ffd36bade9aa863b" + }, { "id": "automations", "source": "tmxplug", - "url": "https://github.com/Termix-SSH/Plugin-Automations/releases/download/v1.0.2/automations-1.0.2.tmxplug", - "sha256": "674eb04276b4a0c77c794b30647f8680218a78de8d52b73e99ec900cc1b3fdb3" + "url": "https://github.com/Termix-SSH/Plugin-Automations/releases/download/v1.0.3/automations-1.0.3.tmxplug", + "sha256": "b5e5b39a053010b72ad1ba03ea6bb05afbbb2b789a86e0e98634d9ada543d02d" + }, + { + "id": "classic-layout", + "source": "tmxplug", + "url": "https://github.com/Termix-SSH/Plugin-Classic-Layout/releases/download/v1.0.0/classic-layout-1.0.0.tmxplug", + "sha256": "aff01b91a28a17013cd4cf64b47dcc02cb85a35710e3382aa9adfe7d93e0fb3a", + "defaultLook": true + }, + { + "id": "classic-themes", + "source": "tmxplug", + "url": "https://github.com/Termix-SSH/Plugin-Classic-Themes/releases/download/v1.0.0/classic-themes-1.0.0.tmxplug", + "sha256": "919283f95701d41727c826637a7d349bd6a646abd61f9ac9017149bd0bee782c", + "defaultLook": true + }, + { + "id": "credential-tools", + "source": "tmxplug", + "url": "https://github.com/Termix-SSH/Plugin-Credential-Tools/releases/download/v1.0.0/credential-tools-1.0.0.tmxplug", + "sha256": "413eff710b5c832a2575a890bbab1c90d28ea55f10cd7a7b94b851d51dd77dce" }, { "id": "docker", "source": "tmxplug", - "url": "https://github.com/Termix-SSH/Plugin-Docker/releases/download/v1.0.2/docker-1.0.2.tmxplug", - "sha256": "0e1c722682faac6a027fd9b01e6910eb53ce5acb13332c6c8d8868554f3701ab", + "url": "https://github.com/Termix-SSH/Plugin-Docker/releases/download/v1.1.0/docker-1.1.0.tmxplug", + "sha256": "da32dfbe9d4cffec5d80c4a1037a79e7591a3d16c50f882db6a146322a50c2b7", "onboarding": { "recommended": true, "consent": false @@ -41,8 +67,8 @@ { "id": "file-manager", "source": "tmxplug", - "url": "https://github.com/Termix-SSH/Plugin-File-Manager/releases/download/v1.0.3/file-manager-1.0.3.tmxplug", - "sha256": "250e9779a6b1caf11721f1ee2baf76655ae6e52d963e894d8db84eb9677cf0b3", + "url": "https://github.com/Termix-SSH/Plugin-File-Manager/releases/download/v1.1.0/file-manager-1.1.0.tmxplug", + "sha256": "807e9ffa80d6054a5911522721a555e0daa3e50f6edffdebe97efe7c91aea7a2", "onboarding": { "recommended": true, "consent": false @@ -57,25 +83,49 @@ { "id": "homepage", "source": "tmxplug", - "url": "https://github.com/Termix-SSH/Plugin-Homepage/releases/download/v1.0.2/homepage-1.0.2.tmxplug", - "sha256": "88cdea8ba8f458fcf124e7ab97e3d419896d8d22ffa9a372af34f1818872975b" + "url": "https://github.com/Termix-SSH/Plugin-Homepage/releases/download/v1.1.0/homepage-1.1.0.tmxplug", + "sha256": "5a9d5d2cc337fcee5590ea957e35ddeea5a71596aeb59410bf1420065b9b5b00" }, { "id": "host-metrics", "source": "tmxplug", - "url": "https://github.com/Termix-SSH/Plugin-Host-Metrics/releases/download/v1.0.3/host-metrics-1.0.3.tmxplug", - "sha256": "8f6022568349fc4318d509781bdb9d5b40973c010a9e27f720eed6baf68b881e", + "url": "https://github.com/Termix-SSH/Plugin-Host-Metrics/releases/download/v1.1.0/host-metrics-1.1.0.tmxplug", + "sha256": "4f1d422ec1d0565c79f94fa814ee0864d556d7367793b11035e68239e49e297f", "onboarding": { "recommended": true, "consent": false } }, + { + "id": "host-status", + "source": "tmxplug", + "url": "https://github.com/Termix-SSH/Plugin-Host-Status/releases/download/v1.0.0/host-status-1.0.0.tmxplug", + "sha256": "1ba62577dc7373a2a2787bab7efdb89b544a30a8003b1de91ce51f372fc4f170" + }, + { + "id": "hosts-config", + "source": "tmxplug", + "url": "https://github.com/Termix-SSH/Plugin-Hosts-Config/releases/download/v1.0.0/hosts-config-1.0.0.tmxplug", + "sha256": "60a2b2a6d9ba13bad12a511c93af70a30a10fbc560066b6419acf533d806a3ad" + }, + { + "id": "import-export", + "source": "tmxplug", + "url": "https://github.com/Termix-SSH/Plugin-Import-Export/releases/download/v1.0.0/import-export-1.0.0.tmxplug", + "sha256": "6670b6c147e81eed0ddfd249f9a7c9564f1ab50e45f2771161d2e30c47f6fd58" + }, { "id": "ldap", "source": "tmxplug", "url": "https://github.com/Termix-SSH/Plugin-LDAP/releases/download/v1.0.2/ldap-1.0.2.tmxplug", "sha256": "9ef269ad281f1c68cea53e0d43ccb7a5d4f949f11b30b22eac3d01047b20eb03" }, + { + "id": "login-security", + "source": "tmxplug", + "url": "https://github.com/Termix-SSH/Plugin-Login-Security/releases/download/v1.0.0/login-security-1.0.0.tmxplug", + "sha256": "b21dff5722e72a02022d563ef27130e8b56a53e273f1668fade4ec7d030374aa" + }, { "id": "network-topology", "source": "tmxplug", @@ -97,8 +147,8 @@ { "id": "remote-desktop", "source": "tmxplug", - "url": "https://github.com/Termix-SSH/Plugin-Remote-Desktop/releases/download/v1.1.0/remote-desktop-1.1.0.tmxplug", - "sha256": "6f74f1d26f91a43b2d2203417d744211f366c335d5fff7bed34bfce33874dd01", + "url": "https://github.com/Termix-SSH/Plugin-Remote-Desktop/releases/download/v1.2.0/remote-desktop-1.2.0.tmxplug", + "sha256": "0de40896bfeb4b1a702c3ce815355b7b7b0bb4bfe424f33e905bb5a0209d02f6", "onboarding": { "recommended": true, "consent": false @@ -145,8 +195,8 @@ { "id": "ssh-terminal", "source": "tmxplug", - "url": "https://github.com/Termix-SSH/Plugin-SSH-Terminal/releases/download/v1.0.3/ssh-terminal-1.0.3.tmxplug", - "sha256": "bc288bd843e37b64bcda7462d175039147b75dc6de8866769e4a938036a9d023", + "url": "https://github.com/Termix-SSH/Plugin-SSH-Terminal/releases/download/v1.1.0/ssh-terminal-1.1.0.tmxplug", + "sha256": "dacdbadb3c6a0d7014b1de4f62b499f80abf358c7799a6e6326674660b055e4a", "onboarding": { "recommended": true, "consent": false @@ -169,6 +219,12 @@ "url": "https://github.com/Termix-SSH/Plugin-Step-CA/releases/download/v1.0.2/step-ca-1.0.2.tmxplug", "sha256": "685b644fe44c2d3fa007baee16e8ea8a7d5204f039ebc8aa65e8093ad93adf8a" }, + { + "id": "sync", + "source": "tmxplug", + "url": "https://github.com/Termix-SSH/Plugin-Sync/releases/download/v1.0.0/sync-1.0.0.tmxplug", + "sha256": "12ced05523f7c1a9151b5e680726bd31f53888e8bb0047d7bd12a2570ea168bf" + }, { "id": "tailscale", "source": "tmxplug", @@ -194,8 +250,8 @@ { "id": "tmux-monitor", "source": "tmxplug", - "url": "https://github.com/Termix-SSH/Plugin-Tmux-Monitor/releases/download/v1.0.3/tmux-monitor-1.0.3.tmxplug", - "sha256": "f152d64f433d3f1de7c3fdf172e752ea7e333909f0fdda77336a6b8dddb9f242" + "url": "https://github.com/Termix-SSH/Plugin-Tmux-Monitor/releases/download/v1.0.4/tmux-monitor-1.0.4.tmxplug", + "sha256": "42b8a2284e68a321c1736984f5c641b1857c87c6a65ce8cf694c6bc946de2444" }, { "id": "totp", @@ -210,8 +266,8 @@ { "id": "tunnels", "source": "tmxplug", - "url": "https://github.com/Termix-SSH/Plugin-Tunnels/releases/download/v1.0.3/tunnels-1.0.3.tmxplug", - "sha256": "d0de9e59ccc9e1a0f2817dd1ec9951021f2d7e89304894bb1e8cefd65aebff0b", + "url": "https://github.com/Termix-SSH/Plugin-Tunnels/releases/download/v1.1.0/tunnels-1.1.0.tmxplug", + "sha256": "b1e4f1b6dd2bbd15a9a240ae1c1d381111c3609258c8946d19493a83f861ea1e", "onboarding": { "recommended": true, "consent": false @@ -238,14 +294,14 @@ { "id": "web-endpoint", "source": "tmxplug", - "url": "https://github.com/Termix-SSH/Plugin-Web-Endpoint/releases/download/v1.0.2/web-endpoint-1.0.2.tmxplug", - "sha256": "0326edb1052d1debd083004c2dd1711d21a902cfacb55ce74d1a8856a5122477" + "url": "https://github.com/Termix-SSH/Plugin-Web-Endpoint/releases/download/v1.0.3/web-endpoint-1.0.3.tmxplug", + "sha256": "014cbf23c82cab20e485bc4f4c9b8baa779aee9bb3ed644b41f22a9b1be95687" }, { "id": "webauthn", "source": "tmxplug", - "url": "https://github.com/Termix-SSH/Plugin-WebAuthn/releases/download/v1.0.2/webauthn-1.0.2.tmxplug", - "sha256": "8269f6e407897332648c8526b65383c4db071ea782bf1e58aa7c7c00627f3f4b", + "url": "https://github.com/Termix-SSH/Plugin-WebAuthn/releases/download/v1.1.0/webauthn-1.1.0.tmxplug", + "sha256": "83a61a530db0273f9fef2080d6aa027a82ef8374fb4e0f158a8559a2770299de", "onboarding": { "recommended": true, "consent": false diff --git a/drizzle/mysql/0073_user_look_preferences.sql b/drizzle/mysql/0073_user_look_preferences.sql new file mode 100644 index 000000000..51ee2231b --- /dev/null +++ b/drizzle/mysql/0073_user_look_preferences.sql @@ -0,0 +1,2 @@ +ALTER TABLE `user_preferences` ADD `layout_id` text;--> statement-breakpoint +ALTER TABLE `user_preferences` ADD `theme_id` text; \ No newline at end of file diff --git a/drizzle/mysql/0074_mature_puff_adder.sql b/drizzle/mysql/0074_mature_puff_adder.sql new file mode 100644 index 000000000..4a1b9eb98 --- /dev/null +++ b/drizzle/mysql/0074_mature_puff_adder.sql @@ -0,0 +1,4 @@ +-- recent_activity moved to the homepage plugin, whose migration renames it to +-- p_homepage_recent_activity. Dropping it here would lose every entry, so this +-- only stops drizzle tracking it. SELECT 1 because MySQL rejects an empty query. +SELECT 1; diff --git a/drizzle/mysql/0075_session_policy.sql b/drizzle/mysql/0075_session_policy.sql new file mode 100644 index 000000000..681091935 --- /dev/null +++ b/drizzle/mysql/0075_session_policy.sql @@ -0,0 +1,2 @@ +ALTER TABLE `sessions` ADD `restriction` text;--> statement-breakpoint +ALTER TABLE `sessions` ADD `idle_timeout` int; \ No newline at end of file diff --git a/drizzle/mysql/meta/0073_snapshot.json b/drizzle/mysql/meta/0073_snapshot.json new file mode 100644 index 000000000..be815ab65 --- /dev/null +++ b/drizzle/mysql/meta/0073_snapshot.json @@ -0,0 +1,4759 @@ +{ + "version": "5", + "dialect": "mysql", + "id": "5d267d9f-4402-4b0e-a14c-3d69a3854963", + "prevId": "65ebfe5f-a44f-434d-9208-93c8ea964729", + "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 + }, + "layout_id": { + "name": "layout_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "theme_id": { + "name": "theme_id", + "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/0074_snapshot.json b/drizzle/mysql/meta/0074_snapshot.json new file mode 100644 index 000000000..2adb78371 --- /dev/null +++ b/drizzle/mysql/meta/0074_snapshot.json @@ -0,0 +1,4663 @@ +{ + "version": "5", + "dialect": "mysql", + "id": "d510d006-0efe-4be9-b1a2-7ee7ba966a0b", + "prevId": "5d267d9f-4402-4b0e-a14c-3d69a3854963", + "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": {} + }, + "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 + }, + "layout_id": { + "name": "layout_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "theme_id": { + "name": "theme_id", + "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/0075_snapshot.json b/drizzle/mysql/meta/0075_snapshot.json new file mode 100644 index 000000000..87f925288 --- /dev/null +++ b/drizzle/mysql/meta/0075_snapshot.json @@ -0,0 +1,4677 @@ +{ + "version": "5", + "dialect": "mysql", + "id": "43553b8f-2819-4a82-84bd-2b884c4980f2", + "prevId": "d510d006-0efe-4be9-b1a2-7ee7ba966a0b", + "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": {} + }, + "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)" + }, + "restriction": { + "name": "restriction", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "idle_timeout": { + "name": "idle_timeout", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "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 + }, + "layout_id": { + "name": "layout_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "theme_id": { + "name": "theme_id", + "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 de667d10d..844b3b360 100644 --- a/drizzle/mysql/meta/_journal.json +++ b/drizzle/mysql/meta/_journal.json @@ -512,6 +512,27 @@ "when": 1791935283211, "tag": "0072_unique_username", "breakpoints": true + }, + { + "idx": 73, + "version": "5", + "when": 1792357129912, + "tag": "0073_user_look_preferences", + "breakpoints": true + }, + { + "idx": 74, + "version": "5", + "when": 1792374489419, + "tag": "0074_mature_puff_adder", + "breakpoints": true + }, + { + "idx": 75, + "version": "5", + "when": 1792430772041, + "tag": "0075_session_policy", + "breakpoints": true } ] } \ No newline at end of file diff --git a/drizzle/postgres/0073_user_look_preferences.sql b/drizzle/postgres/0073_user_look_preferences.sql new file mode 100644 index 000000000..dee98a8a0 --- /dev/null +++ b/drizzle/postgres/0073_user_look_preferences.sql @@ -0,0 +1,2 @@ +ALTER TABLE "user_preferences" ADD COLUMN "layout_id" text;--> statement-breakpoint +ALTER TABLE "user_preferences" ADD COLUMN "theme_id" text; \ No newline at end of file diff --git a/drizzle/postgres/0074_bouncy_sersi.sql b/drizzle/postgres/0074_bouncy_sersi.sql new file mode 100644 index 000000000..4a1b9eb98 --- /dev/null +++ b/drizzle/postgres/0074_bouncy_sersi.sql @@ -0,0 +1,4 @@ +-- recent_activity moved to the homepage plugin, whose migration renames it to +-- p_homepage_recent_activity. Dropping it here would lose every entry, so this +-- only stops drizzle tracking it. SELECT 1 because MySQL rejects an empty query. +SELECT 1; diff --git a/drizzle/postgres/0075_session_policy.sql b/drizzle/postgres/0075_session_policy.sql new file mode 100644 index 000000000..82e724699 --- /dev/null +++ b/drizzle/postgres/0075_session_policy.sql @@ -0,0 +1,2 @@ +ALTER TABLE "sessions" ADD COLUMN "restriction" text;--> statement-breakpoint +ALTER TABLE "sessions" ADD COLUMN "idle_timeout" integer; \ No newline at end of file diff --git a/drizzle/postgres/meta/0073_snapshot.json b/drizzle/postgres/meta/0073_snapshot.json new file mode 100644 index 000000000..056a527af --- /dev/null +++ b/drizzle/postgres/meta/0073_snapshot.json @@ -0,0 +1,4791 @@ +{ + "id": "4d77fb47-8211-4b7b-a4d3-8c3c3c414bbf", + "prevId": "788b2d3a-f430-4726-b4ea-4958e1ed5d24", + "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 + }, + "layout_id": { + "name": "layout_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "theme_id": { + "name": "theme_id", + "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/0074_snapshot.json b/drizzle/postgres/meta/0074_snapshot.json new file mode 100644 index 000000000..bb1fff386 --- /dev/null +++ b/drizzle/postgres/meta/0074_snapshot.json @@ -0,0 +1,4692 @@ +{ + "id": "54fb180b-bc95-4868-b619-b9317d3c22da", + "prevId": "4d77fb47-8211-4b7b-a4d3-8c3c3c414bbf", + "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.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 + }, + "layout_id": { + "name": "layout_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "theme_id": { + "name": "theme_id", + "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/0075_snapshot.json b/drizzle/postgres/meta/0075_snapshot.json new file mode 100644 index 000000000..71d8b934c --- /dev/null +++ b/drizzle/postgres/meta/0075_snapshot.json @@ -0,0 +1,4704 @@ +{ + "id": "0fdc4dc3-cbf9-420b-b8a4-5a59ccd2305e", + "prevId": "54fb180b-bc95-4868-b619-b9317d3c22da", + "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.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" + }, + "restriction": { + "name": "restriction", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "idle_timeout": { + "name": "idle_timeout", + "type": "integer", + "primaryKey": false, + "notNull": false + } + }, + "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 + }, + "layout_id": { + "name": "layout_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "theme_id": { + "name": "theme_id", + "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 d2025bbdd..fa5eb4fc8 100644 --- a/drizzle/postgres/meta/_journal.json +++ b/drizzle/postgres/meta/_journal.json @@ -512,6 +512,27 @@ "when": 1791935282340, "tag": "0072_unique_username", "breakpoints": true + }, + { + "idx": 73, + "version": "7", + "when": 1792357128746, + "tag": "0073_user_look_preferences", + "breakpoints": true + }, + { + "idx": 74, + "version": "7", + "when": 1792374488837, + "tag": "0074_bouncy_sersi", + "breakpoints": true + }, + { + "idx": 75, + "version": "7", + "when": 1792430770990, + "tag": "0075_session_policy", + "breakpoints": true } ] } \ No newline at end of file diff --git a/drizzle/sqlite/0068_user_look_preferences.sql b/drizzle/sqlite/0068_user_look_preferences.sql new file mode 100644 index 000000000..51ee2231b --- /dev/null +++ b/drizzle/sqlite/0068_user_look_preferences.sql @@ -0,0 +1,2 @@ +ALTER TABLE `user_preferences` ADD `layout_id` text;--> statement-breakpoint +ALTER TABLE `user_preferences` ADD `theme_id` text; \ No newline at end of file diff --git a/drizzle/sqlite/0069_normal_ozymandias.sql b/drizzle/sqlite/0069_normal_ozymandias.sql new file mode 100644 index 000000000..4a1b9eb98 --- /dev/null +++ b/drizzle/sqlite/0069_normal_ozymandias.sql @@ -0,0 +1,4 @@ +-- recent_activity moved to the homepage plugin, whose migration renames it to +-- p_homepage_recent_activity. Dropping it here would lose every entry, so this +-- only stops drizzle tracking it. SELECT 1 because MySQL rejects an empty query. +SELECT 1; diff --git a/drizzle/sqlite/0070_session_policy.sql b/drizzle/sqlite/0070_session_policy.sql new file mode 100644 index 000000000..78123a5bf --- /dev/null +++ b/drizzle/sqlite/0070_session_policy.sql @@ -0,0 +1,2 @@ +ALTER TABLE `sessions` ADD `restriction` text(32);--> statement-breakpoint +ALTER TABLE `sessions` ADD `idle_timeout` integer; \ No newline at end of file diff --git a/drizzle/sqlite/meta/0068_snapshot.json b/drizzle/sqlite/meta/0068_snapshot.json new file mode 100644 index 000000000..e71c25455 --- /dev/null +++ b/drizzle/sqlite/meta/0068_snapshot.json @@ -0,0 +1,4480 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "811cbb62-1490-48cd-9959-fd470be269d2", + "prevId": "689622f2-8e11-46a3-bac9-b2c55444b3a2", + "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 + }, + "layout_id": { + "name": "layout_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "theme_id": { + "name": "theme_id", + "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/0069_snapshot.json b/drizzle/sqlite/meta/0069_snapshot.json new file mode 100644 index 000000000..8fcf5565e --- /dev/null +++ b/drizzle/sqlite/meta/0069_snapshot.json @@ -0,0 +1,4391 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "35475485-31b7-4d14-b093-f4378c8826e6", + "prevId": "811cbb62-1490-48cd-9959-fd470be269d2", + "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": {} + }, + "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 + }, + "layout_id": { + "name": "layout_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "theme_id": { + "name": "theme_id", + "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/0070_snapshot.json b/drizzle/sqlite/meta/0070_snapshot.json new file mode 100644 index 000000000..0496885f0 --- /dev/null +++ b/drizzle/sqlite/meta/0070_snapshot.json @@ -0,0 +1,4405 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "245aa2fc-7c71-4256-b599-fba35bfee894", + "prevId": "35475485-31b7-4d14-b093-f4378c8826e6", + "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": {} + }, + "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" + }, + "restriction": { + "name": "restriction", + "type": "text(32)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "idle_timeout": { + "name": "idle_timeout", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "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 + }, + "layout_id": { + "name": "layout_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "theme_id": { + "name": "theme_id", + "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 238908477..c8d796286 100644 --- a/drizzle/sqlite/meta/_journal.json +++ b/drizzle/sqlite/meta/_journal.json @@ -477,6 +477,27 @@ "when": 1791935281343, "tag": "0067_unique_username", "breakpoints": true + }, + { + "idx": 68, + "version": "6", + "when": 1792357127581, + "tag": "0068_user_look_preferences", + "breakpoints": true + }, + { + "idx": 69, + "version": "6", + "when": 1792374488196, + "tag": "0069_normal_ozymandias", + "breakpoints": true + }, + { + "idx": 70, + "version": "6", + "when": 1792430769899, + "tag": "0070_session_policy", + "breakpoints": true } ] } \ No newline at end of file diff --git a/electron/c2s-auth.cjs b/electron/c2s-auth.cjs deleted file mode 100644 index 4bb52944a..000000000 --- a/electron/c2s-auth.cjs +++ /dev/null @@ -1,78 +0,0 @@ -const { randomUUID } = require("node:crypto"); - -function createC2SAuthBridge(ipcMain, getWindow) { - const pending = new Map(); - ipcMain.handle("answer-c2s-auth", (event, id, answer) => { - const item = pending.get(id); - if (!item || item.sender !== event.sender) return false; - if (answer !== null && (typeof answer !== "string" || answer.length > 8192)) - return false; - item.finish(answer); - return true; - }); - return function attach(ws, tunnelName) { - const active = new Set(); - const close = () => { - for (const id of active) pending.get(id)?.finish(null); - ws.off("message", onMessage); - ws.off("close", close); - ws.off("error", close); - }; - const onMessage = (raw, binary) => { - if (binary) return; - let message; - try { - message = JSON.parse(raw.toString()); - } catch { - return; - } - if (message.type !== "auth-prompt") return; - const window = getWindow(); - if ( - !window || - window.isDestroyed() || - active.size || - typeof message.requestId !== "string" || - !["totp", "input", "browser"].includes(message.request?.kind) // plugin-id-ok: SSH prompt kind, not the plugin - ) { - ws.close(); - return; - } - const sender = window.webContents; - const id = randomUUID(); - const finish = (answer) => { - if (!pending.delete(id)) return; - active.delete(id); - clearTimeout(timer); - sender.off("destroyed", cancelled); - sender.off("did-start-loading", cancelled); - if (!sender.isDestroyed()) - sender.send("c2s-auth-prompt", { id, closed: true }); - if (ws.readyState === 1) - ws.send( - JSON.stringify({ - type: "auth-response", - requestId: message.requestId, - answer, - }), - ); - if (answer === null) ws.close(); - }; - const cancelled = () => finish(null); - const timer = setTimeout(cancelled, 60_000); - active.add(id); - pending.set(id, { sender, finish }); - sender.once("destroyed", cancelled); - sender.once("did-start-loading", cancelled); - sender.send("c2s-auth-prompt", { - id, - tunnelName, - request: message.request, - }); - }; - ws.on("message", onMessage); - ws.once("close", close); - ws.once("error", close); - }; -} -module.exports = { createC2SAuthBridge }; diff --git a/electron/embedded-view.cjs b/electron/embedded-view.cjs new file mode 100644 index 000000000..c6967fa4d --- /dev/null +++ b/electron/embedded-view.cjs @@ -0,0 +1,165 @@ +const { isolatedUrl } = require("./isolated-window.cjs"); + +function cleanBounds(bounds) { + const value = (n) => Math.max(0, Math.round(Number(n) || 0)); + return { + x: value(bounds?.x), + y: value(bounds?.y), + width: value(bounds?.width), + height: value(bounds?.height), + }; +} + +/** + * Native web views drawn inside the main window, for pages that do not work + * in a sandboxed iframe. Each one is a WebContentsView in its own + * non-persistent session, with the same limits as an isolated window: no + * permissions, no downloads, http(s) and ws(s) only. The renderer owns the + * placement and sends the bounds of the element the view should cover. + */ +function createEmbeddedViews({ WebContentsView, session, getMainWindow }) { + const views = new Map(); + + function destroy(id) { + const entry = views.get(id); + if (!entry) return; + views.delete(id); + const main = getMainWindow(); + if (main && !main.isDestroyed()) { + try { + main.contentView.removeChildView(entry.view); + } catch { + /* already detached */ + } + } + if (!entry.view.webContents.isDestroyed()) entry.view.webContents.close(); + void Promise.allSettled([ + entry.session.clearStorageData(), + entry.session.clearCache(), + entry.session.closeAllConnections(), + ]); + } + + function destroyAll() { + for (const id of [...views.keys()]) destroy(id); + } + + function open(options = {}) { + const main = getMainWindow(); + if (!main) throw new Error("No window to attach a view to"); + const id = String(options.id || ""); + if (!id) throw new Error("A view needs an id"); + const url = isolatedUrl(options.url); + const existing = views.get(id); + if (existing) { + if ( + existing.url === url.href && + existing.ignoreCert === !!options.ignoreCert + ) { + setBounds(id, options.bounds); + setVisible(id, options.visible !== false); + return { success: true }; + } + destroy(id); + } + + const isolated = session.fromPartition( + `embedded-view-${id}-${Date.now()}`, + { + cache: false, + }, + ); + isolated.setPermissionRequestHandler((_c, _p, callback) => callback(false)); + isolated.setPermissionCheckHandler(() => false); + isolated.on("will-download", (event) => event.preventDefault()); + isolated.webRequest.onBeforeRequest((details, callback) => { + const protocol = new URL(details.url).protocol; + callback({ + cancel: !["http:", "https:", "ws:", "wss:"].includes(protocol), + }); + }); + + const view = new WebContentsView({ + webPreferences: { + session: isolated, + sandbox: true, + contextIsolation: true, + nodeIntegration: false, + webviewTag: false, + webSecurity: true, + }, + }); + const canNavigate = (target) => { + try { + return target === "about:blank" || !!isolatedUrl(target); + } catch { + return false; + } + }; + for (const name of ["will-navigate", "will-redirect"]) { + view.webContents.on(name, (event, target) => { + if (!canNavigate(target)) event.preventDefault(); + }); + } + view.webContents.setWindowOpenHandler(({ url: target }) => + canNavigate(target) + ? { + action: "allow", + overrideBrowserWindowOptions: { + webPreferences: { session: isolated, sandbox: true }, + }, + } + : { action: "deny" }, + ); + + views.set(id, { + view, + session: isolated, + url: url.href, + origin: url.origin, + ignoreCert: !!options.ignoreCert, + }); + main.contentView.addChildView(view); + setBounds(id, options.bounds); + setVisible(id, options.visible !== false); + view.webContents.loadURL(url.href).catch(() => {}); + return { success: true }; + } + + function setBounds(id, bounds) { + const entry = views.get(id); + if (entry) entry.view.setBounds(cleanBounds(bounds)); + } + + function setVisible(id, visible) { + const entry = views.get(id); + if (entry) entry.view.setVisible(!!visible); + } + + function handleCertificateError(event, contents, url, callback) { + for (const entry of views.values()) { + if (entry.view.webContents !== contents) continue; + event.preventDefault(); + let allowed = false; + try { + allowed = entry.ignoreCert && new URL(url).origin === entry.origin; + } catch { + /* malformed URL is refused */ + } + callback(allowed); + return true; + } + return false; + } + + return { + open, + setBounds, + setVisible, + destroy, + destroyAll, + handleCertificateError, + }; +} + +module.exports = { createEmbeddedViews, cleanBounds }; diff --git a/electron/external-editor.cjs b/electron/external-editor.cjs deleted file mode 100644 index 0cc098608..000000000 --- a/electron/external-editor.cjs +++ /dev/null @@ -1,70 +0,0 @@ -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/linked-server.cjs b/electron/linked-server.cjs index a31a03250..5dc91ffa6 100644 --- a/electron/linked-server.cjs +++ b/electron/linked-server.cjs @@ -34,6 +34,8 @@ function setLinkedServer(config) { basicAuth: config.basicAuth && config.basicAuth.username ? config.basicAuth : null, allowInvalidCertificate: !!config.allowInvalidCertificate, + clientCert: + config.clientCert && config.clientCert.pfx ? config.clientCert : null, }; } @@ -45,6 +47,16 @@ function isLinkedOrigin(url) { return !!linked && getOrigin(url) === linked.origin; } +// The PFX of the link's client certificate, for TLS this process makes itself +// to the linked origin. Null for any other origin. +function clientCertOptionsFor(url) { + if (!isLinkedOrigin(url) || !linked.clientCert) return {}; + return { + pfx: Buffer.from(linked.clientCert.pfx, "base64"), + passphrase: linked.clientCert.passphrase || "", + }; +} + function basicAuthValue(basicAuth) { return `Basic ${Buffer.from( `${basicAuth.username}:${basicAuth.password || ""}`, @@ -99,6 +111,7 @@ module.exports = { setLinkedServer, getLinkedServer, isLinkedOrigin, + clientCertOptionsFor, applyLinkHeaders, linkedRequestHeaders, answerLogin, diff --git a/electron/local-files.cjs b/electron/local-files.cjs deleted file mode 100644 index 971cfa9bf..000000000 --- a/electron/local-files.cjs +++ /dev/null @@ -1,1064 +0,0 @@ -// Local filesystem access + streamed local<->remote transfers for the -// desktop app's dual-pane file manager. -// -// The renderer has no Node access (contextIsolation), so browsing the user's -// own disk and moving bytes between it and the backend has to happen here. -// Uploads/downloads are streamed through Electron's `net` stack against the -// same file-manager HTTP routes the renderer already uses, so nothing is ever -// buffered whole in memory. -// -// Trust boundary: the renderer never supplies a URL or headers. It names an -// origin ("local" | "remote") and a route from a fixed allowlist; the main -// process resolves the actual Termix backend URL and attaches credentials -// itself (session cookies for the embedded backend, the linked server's -// session). Nothing here can be pointed at another host. - -const fs = require("fs"); -const fsp = require("fs/promises"); -const os = require("os"); -const path = require("path"); -const { URL } = require("url"); - -const IPC = { - HOME: "local-fs:home", - LIST: "local-fs:list", - MKDIR: "local-fs:mkdir", - CREATE_FILE: "local-fs:create-file", - RENAME: "local-fs:rename", - TRASH: "local-fs:trash", - ENSURE_DIR: "local-fs:ensure-dir", - EXISTS: "local-fs:exists", - WALK: "local-fs:walk", - REVEAL: "local-fs:reveal", - OPEN: "local-fs:open", - UPLOAD: "local-transfer:upload", - DOWNLOAD: "local-transfer:download", - CANCEL: "local-transfer:cancel", - PROGRESS: "local-transfer:progress", - SET_API: "local-transfer:set-api", -}; - -const READ_CHUNK_BYTES = 1024 * 1024; -const PROGRESS_INTERVAL_MS = 150; - -const activeTransfers = new Map(); - -function isAbsoluteLocalPath(candidate) { - return typeof candidate === "string" && path.isAbsolute(candidate); -} - -function normalizeLocalPath(candidate) { - if (!isAbsoluteLocalPath(candidate)) { - throw new Error("A local absolute path is required"); - } - return path.normalize(candidate); -} - -function isHiddenEntry(name) { - return name.startsWith("."); -} - -// A single path segment: no separators, not "." / "..", no NULs. -function requireEntryName(name, what) { - const safeName = String(name || "").trim(); - if ( - !safeName || - safeName === "." || - safeName === ".." || - /[\/\\\0]/.test(safeName) - ) { - throw new Error(`Invalid ${what}`); - } - return safeName; -} - -// Asserts that `candidate` (already absolute) lies strictly inside `root` -// after normalisation on the *current* platform. `pathImpl` is injectable so -// the Windows rules can be exercised in tests on any OS. Traversal that -// survives normalisation ("..\\x" is a plain file name on POSIX but a parent -// reference on Windows) is caught here, as are absolute / drive-qualified -// names that path.join or path.resolve would let take over. -function assertWithinRoot(rootPath, candidate, pathImpl = path) { - if (typeof rootPath !== "string" || !rootPath.trim()) { - throw new LocalFileError("EINVAL", "A download folder is required"); - } - const root = pathImpl.normalize(pathImpl.resolve(rootPath)); - const target = pathImpl.normalize(pathImpl.resolve(candidate)); - const rel = pathImpl.relative(root, target); - const escapes = - rel === "" || - rel === ".." || - rel.startsWith(`..${pathImpl.sep}`) || - pathImpl.isAbsolute(rel) || - // A different drive on Windows yields an absolute relative path; a UNC - // or drive-qualified segment must never survive either. - rel.split(pathImpl.sep).some((seg) => seg === ".." || seg === ""); - if (escapes) { - throw new LocalFileError( - "EINVAL", - `"${pathImpl.basename(candidate)}" would be written outside the selected folder`, - ); - } - return target; -} - -// Resolve a deliberately selected linked root once, but never follow links -// supplied as descendants of a downloaded tree. -async function prepareDownloadPath(rootPath, candidate, directory = false) { - const target = assertWithinRoot(rootPath, normalizeLocalPath(candidate)); - const selected = path.resolve(rootPath); - const realRoot = await fsp.realpath(selected); - if (!(await fsp.stat(realRoot)).isDirectory()) { - throw new LocalFileError("EINVAL", "The download root is not a directory"); - } - const parts = path.relative(selected, target).split(path.sep); - let current = realRoot; - for (let i = 0; i < parts.length; i++) { - current = path.join(current, parts[i]); - const needsDirectory = directory || i < parts.length - 1; - if (needsDirectory) { - await fsp.mkdir(current).catch((error) => { - if (error.code !== "EEXIST") throw error; - }); - } - const stat = await fsp.lstat(current).catch((error) => { - if (error.code === "ENOENT" && !needsDirectory) return null; - throw error; - }); - if (stat?.isSymbolicLink()) { - throw new LocalFileError( - "EINVAL", - "Downloads cannot follow links inside the selected folder", - ); - } - if (needsDirectory && !stat.isDirectory()) { - throw new LocalFileError( - "ENOTDIR", - "A download parent is not a directory", - ); - } - } - return { root: realRoot, path: current }; -} - -async function pathExists(target) { - try { - await fsp.lstat(target); - return true; - } catch { - return false; - } -} - -async function describeEntry(dirPath, dirent) { - const entryPath = path.join(dirPath, dirent.name); - let type = "file"; - let size = 0; - let modifiedTimestamp; - let linkTarget; - - try { - if (dirent.isSymbolicLink()) { - type = "link"; - try { - linkTarget = await fsp.readlink(entryPath); - } catch { - // dangling or unreadable link - } - // Follow the link so a symlinked directory still navigates like one. - try { - const target = await fsp.stat(entryPath); - if (target.isDirectory()) type = "directory"; - size = target.size; - modifiedTimestamp = target.mtimeMs; - } catch { - const own = await fsp.lstat(entryPath); - modifiedTimestamp = own.mtimeMs; - } - } else if (dirent.isDirectory()) { - type = "directory"; - const stat = await fsp.stat(entryPath); - modifiedTimestamp = stat.mtimeMs; - } else { - const stat = await fsp.stat(entryPath); - size = stat.size; - modifiedTimestamp = stat.mtimeMs; - } - } catch { - // Unreadable entry: still list it so the user sees it exists. - } - - return { - name: dirent.name, - path: entryPath, - type, - size, - modifiedTimestamp, - linkTarget, - hidden: isHiddenEntry(dirent.name), - }; -} - -async function listDirectory(dirPath) { - const resolved = normalizeLocalPath(dirPath); - const dirents = await fsp.readdir(resolved, { withFileTypes: true }); - const entries = await Promise.all( - dirents.map((dirent) => describeEntry(resolved, dirent)), - ); - const parent = path.dirname(resolved); - return { - path: resolved, - parent: parent === resolved ? null : parent, - entries, - }; -} - -// Expands a set of dropped local paths into the flat list of files (with -// paths relative to the drop root) plus any empty directories, mirroring what -// the browser's FileSystemEntry walker produces for OS drops. -async function walkPaths(rootPaths) { - const files = []; - const emptyDirs = []; - let totalBytes = 0; - - async function walkDir(absDir, relDir) { - const dirents = await fsp.readdir(absDir, { withFileTypes: true }); - if (dirents.length === 0) { - emptyDirs.push(relDir); - return; - } - for (const dirent of dirents) { - const abs = path.join(absDir, dirent.name); - const rel = `${relDir}/${dirent.name}`; - if (dirent.isDirectory()) { - await walkDir(abs, rel); - } else if (dirent.isFile()) { - const stat = await fsp.stat(abs); - files.push({ localPath: abs, relativePath: rel, size: stat.size }); - totalBytes += stat.size; - } else if (dirent.isSymbolicLink()) { - // Upload what the link points at, if it is a regular file. - try { - const stat = await fsp.stat(abs); - if (stat.isFile()) { - files.push({ localPath: abs, relativePath: rel, size: stat.size }); - totalBytes += stat.size; - } - } catch { - // dangling link: skip - } - } - } - } - - for (const rootPath of rootPaths) { - const abs = normalizeLocalPath(rootPath); - const stat = await fsp.stat(abs); - const name = path.basename(abs); - if (stat.isDirectory()) { - await walkDir(abs, name); - } else if (stat.isFile()) { - files.push({ localPath: abs, relativePath: name, size: stat.size }); - totalBytes += stat.size; - } - } - - return { files, emptyDirs, totalBytes }; -} - -function toHeaderMap(headers) { - const out = {}; - if (!headers || typeof headers !== "object") return out; - for (const [key, value] of Object.entries(headers)) { - if (value === undefined || value === null) continue; - out[key] = String(value); - } - return out; -} - -function makeProgressReporter(sender, transferId) { - let lastSentAt = 0; - return (transferred, total, force = false) => { - const now = Date.now(); - if (!force && now - lastSentAt < PROGRESS_INTERVAL_MS) return; - lastSentAt = now; - if (sender.isDestroyed()) return; - sender.send(IPC.PROGRESS, { transferId, transferred, total }); - }; -} - -function collectBody(response) { - return new Promise((resolve) => { - const chunks = []; - response.on("data", (chunk) => chunks.push(chunk)); - response.on("end", () => resolve(Buffer.concat(chunks).toString("utf8"))); - response.on("error", () => resolve("")); - }); -} - -function describeHttpError(statusCode, bodyText) { - try { - const parsed = JSON.parse(bodyText); - if (parsed && typeof parsed.error === "string") return parsed.error; - if (parsed && typeof parsed.message === "string") return parsed.message; - } catch { - // not JSON - } - return bodyText?.trim() || `Request failed with status ${statusCode}`; -} - -function writeToRequest(request, chunk) { - return new Promise((resolve, reject) => { - try { - request.write(chunk, () => resolve()); - } catch (error) { - reject(error); - } - }); -} - -const TRANSFER_ROUTES = Object.freeze({ - uploadFileStream: "/uploadFileStream", - downloadFileStream: "/downloadFileStream", -}); - -const DEVICE_ID_PATTERN = /^[A-Za-z0-9._:-]{1,128}$/; -// A compact JWT (base64url segments joined by dots); anything else is refused. -const AUTH_TOKEN_PATTERN = /^[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$/; - -// The plugin that serves the two streaming routes tells us its /plugin-api/ -// 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-]*$/; -let localBackendPort = 30001; - -function setLocalBackendPort(port) { - localBackendPort = port; -} -let registeredApiPath = null; - -function setTransferApiPath(apiPath) { - if (typeof apiPath !== "string" || !API_PATH_PATTERN.test(apiPath)) { - throw new Error("Invalid transfer API path"); - } - registeredApiPath = apiPath; -} - -function normalizeHttpBase(candidate, what) { - let parsed; - try { - parsed = new URL(String(candidate || "")); - } catch { - throw new Error(`${what} is not a valid URL`); - } - if (parsed.protocol !== "http:" && parsed.protocol !== "https:") { - throw new Error(`${what} must use http or https`); - } - return parsed.toString().replace(/\/$/, ""); -} - -// Resolves where a transfer may go. Only the two file-manager streaming -// routes are reachable, and only on the embedded backend or the linked -// server; credentials come from the main process, not the caller. -function createTargetResolver({ localBaseUrl, getLinkedServer, apiPath }) { - const resolveApiPath = () => { - const resolved = apiPath ?? registeredApiPath; - if (!resolved) - throw new Error("Local file transfers are not available yet"); - return resolved; - }; - return function resolveTransferTarget({ - origin, - route, - deviceId, - authToken, - } = {}) { - const routePath = TRANSFER_ROUTES[route]; - if (!routePath) { - throw new Error(`Unknown transfer route: ${String(route)}`); - } - - const headers = { "X-Electron-App": "true" }; - if (deviceId !== undefined && deviceId !== null && deviceId !== "") { - if (typeof deviceId !== "string" || !DEVICE_ID_PATTERN.test(deviceId)) { - throw new Error("Invalid device id"); - } - headers["X-Termix-Device-ID"] = deviceId; - } - - if (origin === "local") { - // The desktop renderer authenticates against the embedded backend with - // the token it keeps in localStorage (see the Electron branch of the - // axios request interceptor), and the `jwt` cookie only exists for a - // while after an interactive login. Send that same token here, so a - // transfer does not depend on a cookie the rest of the app no longer - // needs; the session cookie still rides along as a fallback. The - // renderer already uses this token on every request it makes itself, - // so forwarding it grants nothing new, and the URL stays ours. - if (authToken !== undefined && authToken !== null && authToken !== "") { - if ( - typeof authToken !== "string" || - authToken.length > 8192 || - !AUTH_TOKEN_PATTERN.test(authToken) - ) { - throw new Error("Invalid auth token"); - } - headers.Authorization = `Bearer ${authToken}`; - } - return { - url: `${normalizeHttpBase( - localBaseUrl ?? - `http://localhost:${localBackendPort}${resolveApiPath()}`, - "Local backend URL", - )}${routePath}`, - headers, - }; - } - - if (origin === "remote") { - const linked = - typeof getLinkedServer === "function" ? getLinkedServer() : null; - if (!linked || !linked.serverUrl) { - throw new Error("This device is not linked to a server"); - } - const base = normalizeHttpBase(linked.serverUrl, "Linked server URL"); - if (linked.token) headers.Authorization = `Bearer ${linked.token}`; - return { url: `${base}${resolveApiPath()}${routePath}`, headers }; - } - - throw new Error(`Unknown transfer origin: ${String(origin)}`); - }; -} - -function createNetRequest(net, event, method, url) { - return net.request({ - method, - url, - session: event.sender.session, - useSessionCookies: true, - }); -} - -// Streams one local file to the backend's multipart `uploadFileStream` route. -async function uploadLocalFile({ net, resolveTransferTarget }, event, options) { - const { - transferId, - origin, - deviceId, - authToken, - fields, - localPath, - fileName, - } = options || {}; - - if (!transferId || !localPath) { - throw new Error("Missing upload parameters"); - } - const { url, headers } = resolveTransferTarget({ - origin, - route: "uploadFileStream", - deviceId, - authToken, - }); - - const absPath = normalizeLocalPath(localPath); - const stat = await fsp.stat(absPath); - if (!stat.isFile()) { - throw new Error("Only regular files can be uploaded"); - } - - const boundary = `----TermixLocalUpload${Date.now()}${Math.random() - .toString(36) - .slice(2)}`; - const safeName = String(fileName || path.basename(absPath)) - .replace(/[\r\n]/g, " ") - .replace(/"/g, "%22"); - - let preamble = ""; - for (const [key, value] of Object.entries(fields || {})) { - if (value === undefined || value === null) continue; - preamble += - `--${boundary}\r\n` + - `Content-Disposition: form-data; name="${key}"\r\n\r\n` + - `${String(value)}\r\n`; - } - preamble += - `--${boundary}\r\n` + - `Content-Disposition: form-data; name="file"; filename="${safeName}"\r\n` + - `Content-Type: application/octet-stream\r\n\r\n`; - const epilogue = `\r\n--${boundary}--\r\n`; - - const preambleBuffer = Buffer.from(preamble, "utf8"); - const epilogueBuffer = Buffer.from(epilogue, "utf8"); - - const request = createNetRequest(net, event, "POST", url); - for (const [key, value] of Object.entries(toHeaderMap(headers))) { - request.setHeader(key, value); - } - request.setHeader( - "Content-Type", - `multipart/form-data; boundary=${boundary}`, - ); - // Without chunked encoding Electron buffers the whole body in the main - // process before sending; chunked keeps memory flat for multi-GB files. - // Busboy on the backend parses the multipart stream incrementally either way. - request.chunkedEncoding = true; - - const report = makeProgressReporter(event.sender, transferId); - const readStream = fs.createReadStream(absPath, { - highWaterMark: READ_CHUNK_BYTES, - }); - - const state = { - cancelled: false, - abort: () => { - state.cancelled = true; - readStream.destroy(); - request.abort(); - }, - }; - activeTransfers.set(transferId, state); - - // The server may answer early (auth failure, missing session) while the - // body is still streaming; stop pushing bytes as soon as it does. - let responded = false; - const responsePromise = new Promise((resolve, reject) => { - request.on("response", async (response) => { - responded = true; - const bodyText = await collectBody(response); - resolve({ statusCode: response.statusCode, bodyText }); - }); - request.on("error", (error) => reject(error)); - request.on("abort", () => reject(new Error("Transfer cancelled"))); - }); - // Avoid an unhandled rejection if we bail out before awaiting below. - responsePromise.catch(() => {}); - - // A write callback never fires once the request has errored or been - // aborted, so every write races against the response promise's rejection. - const write = (chunk) => - Promise.race([writeToRequest(request, chunk), responsePromise]); - - try { - await write(preambleBuffer); - let sent = 0; - for await (const chunk of readStream) { - if (state.cancelled) throw new Error("Transfer cancelled"); - if (responded) break; - await write(chunk); - sent += chunk.length; - report(sent, stat.size); - } - if (!responded) { - await write(epilogueBuffer); - request.end(); - } - - const { statusCode, bodyText } = await responsePromise; - if (statusCode < 200 || statusCode >= 300) { - throw new Error(describeHttpError(statusCode, bodyText)); - } - report(stat.size, stat.size, true); - return { success: true, bytes: stat.size }; - } catch (error) { - readStream.destroy(); - throw error; - } finally { - activeTransfers.delete(transferId); - } -} - -class LocalFileError extends Error { - constructor(code, message) { - super(message); - this.name = "LocalFileError"; - this.code = code; - } -} - -// Destinations with a download in flight, so two transfers can never race -// each other onto the same file. -const activeDestinations = new Set(); - -function partialPathFor(absDest, transferId) { - const token = String(transferId) - .replace(/[^A-Za-z0-9_-]/g, "") - .slice(0, 48); - return `${absDest}.${token || "transfer"}.termix-part`; -} - -// File operations used to publish a download, injectable so the replace -// strategy can be tested against Windows-like semantics (where rename() onto -// an existing name fails) without running on Windows. -const defaultPublishFs = Object.freeze({ - rename: (from, to) => fsp.rename(from, to), - link: (from, to) => fsp.link(from, to), - copyFileExcl: (from, to) => - fsp.copyFile(from, to, fs.constants.COPYFILE_EXCL), - rm: (target) => fsp.rm(target, { force: true }), - lstat: (target) => fsp.lstat(target), -}); - -function replacedPathFor(absDest, transferId) { - const token = String(transferId) - .replace(/[^A-Za-z0-9_-]/g, "") - .slice(0, 48); - return `${absDest}.${token || "transfer"}.termix-replaced`; -} - -// Puts `sourcePath` under `absDest` without ever replacing an existing file: -// link() is atomic and fails with EEXIST if the name is taken; filesystems -// without hard links fall back to an exclusive copy. The source is removed -// once the destination exists. -async function publishExclusive(io, sourcePath, absDest) { - const exists = () => - new LocalFileError("EEXIST", `"${path.basename(absDest)}" already exists`); - try { - await io.link(sourcePath, absDest); - } catch (error) { - if (error && error.code === "EEXIST") throw exists(); - try { - await io.copyFileExcl(sourcePath, absDest); - } catch (copyError) { - if (copyError && copyError.code === "EEXIST") throw exists(); - throw copyError; - } - } - await io.rm(sourcePath); -} - -// Replaces `absDest` with the finished partial. rename() over an existing -// file is not portable: POSIX replaces it, but on Windows the call commonly -// fails (EEXIST / EPERM, always when the file is open), so the swap never -// renames onto an occupied name. Instead: -// 1. move the current file aside to a transfer-unique sibling -// (renaming to a fresh name is safe everywhere); -// 2. publish the partial exclusively under the now-free name; -// 3. delete the aside copy. -// If step 1 fails nothing has changed and the caller gets EBUSY. If step 2 -// fails the aside copy is moved back, so the original survives. -async function replaceExisting(io, partialPath, absDest, transferId) { - let current; - try { - current = await io.lstat(absDest); - } catch (error) { - if (error && error.code === "ENOENT") { - // Nothing to replace after all (the file went away meanwhile). - await publishExclusive(io, partialPath, absDest); - return; - } - throw error; - } - if (current.isDirectory()) { - throw new LocalFileError( - "EISDIR", - `"${path.basename(absDest)}" is a folder and cannot be replaced by a file`, - ); - } - - const asidePath = replacedPathFor(absDest, transferId); - try { - await io.rename(absDest, asidePath); - } catch (error) { - if ( - error && - (error.code === "EPERM" || - error.code === "EBUSY" || - error.code === "EACCES") - ) { - throw new LocalFileError( - "EBUSY", - `"${path.basename(absDest)}" is in use and could not be replaced`, - ); - } - throw error; - } - - try { - await publishExclusive(io, partialPath, absDest); - } catch (error) { - // Put the original back under its name; the partial is cleaned up by - // the caller. - await io.rm(absDest).catch(() => {}); - await io.rename(asidePath, absDest).catch(() => {}); - throw error; - } - - // The old contents are no longer reachable under the real name; removing - // the aside copy can still fail on Windows if another process holds it - // open, so retry once before giving up and leaving it for the user. - try { - await io.rm(asidePath); - } catch { - await new Promise((resolve) => setTimeout(resolve, 100)); - await io.rm(asidePath).catch((error) => { - console.warn( - `[local-files] replaced "${absDest}" but could not remove the previous copy at "${asidePath}": ${error && error.message}`, - ); - }); - } -} - -// Moves a finished partial file onto its final name. Without `overwrite` the -// publish is exclusive: an existing file is never replaced, even if it -// appeared while the download was running. With `overwrite` the existing file -// is swapped out in a way that also works on Windows. -async function publishDownload( - partialPath, - absDest, - overwrite, - transferId, - io = defaultPublishFs, -) { - if (overwrite) { - await replaceExisting(io, partialPath, absDest, transferId); - return; - } - await publishExclusive(io, partialPath, absDest); -} - -// Streams one remote file (via the backend's `downloadFileStream` route) into -// a local destination. Bytes go to a transfer-unique temp sibling first and -// are published under the real name only on success, so a failed transfer -// never leaves a truncated file behind. Existing files are refused unless -// the caller explicitly asked to overwrite. -async function downloadToLocal( - { net, resolveTransferTarget, publishFs }, - event, - options, -) { - const { - transferId, - origin, - deviceId, - authToken, - body, - destPath, - rootPath, - expectedSize, - } = options || {}; - const overwrite = options?.overwrite === true; - - if (!transferId || !destPath) { - throw new Error("Missing download parameters"); - } - const { url, headers } = resolveTransferTarget({ - origin, - route: "downloadFileStream", - deviceId, - authToken, - }); - - // The renderer builds destPath from remote names; never trust that it - // stayed inside the folder the user picked. - const destination = await prepareDownloadPath(rootPath, destPath); - const absDest = destination.path; - if (activeDestinations.has(absDest)) { - throw new LocalFileError( - "EBUSY", - `"${path.basename(absDest)}" is already being downloaded`, - ); - } - activeDestinations.add(absDest); - - const partialPath = partialPathFor( - path.join(destination.root, path.basename(absDest)), - transferId, - ); - const report = makeProgressReporter(event.sender, transferId); - const state = { - cancelled: false, - abort: () => { - state.cancelled = true; - request.abort(); - }, - }; - let request = null; - - try { - if (!overwrite && (await pathExists(absDest))) { - throw new LocalFileError( - "EEXIST", - `"${path.basename(absDest)}" already exists`, - ); - } - - request = createNetRequest(net, event, "POST", url); - for (const [key, value] of Object.entries(toHeaderMap(headers))) { - request.setHeader(key, value); - } - const payload = Buffer.from(JSON.stringify(body || {}), "utf8"); - request.setHeader("Content-Type", "application/json"); - // No explicit Content-Length: Electron's net module forbids apps from - // setting it (the request fails with net::ERR_INVALID_ARGUMENT) and - // computes it itself from the buffered body when chunked encoding is off. - activeTransfers.set(transferId, state); - - await new Promise((resolve, reject) => { - let settled = false; - const fail = (error) => { - if (settled) return; - settled = true; - reject(error); - }; - - request.on("error", fail); - request.on("abort", () => fail(new Error("Transfer cancelled"))); - request.on("response", (response) => { - const statusCode = response.statusCode; - if (statusCode < 200 || statusCode >= 300) { - collectBody(response).then((bodyText) => - fail(new Error(describeHttpError(statusCode, bodyText))), - ); - return; - } - - const lengthHeader = response.headers["content-length"]; - const total = - Number( - Array.isArray(lengthHeader) ? lengthHeader[0] : lengthHeader, - ) || - Number(expectedSize) || - undefined; - - // "wx": the temp name is ours alone; refuse to reuse a stale one. - const writeStream = fs.createWriteStream(partialPath, { flags: "wx" }); - let received = 0; - - response.on("data", (chunk) => { - received += chunk.length; - const ok = writeStream.write(chunk); - if (!ok) { - response.pause(); - writeStream.once("drain", () => response.resume()); - } - report(received, total); - }); - response.on("end", () => { - writeStream.end(() => { - report(received, total ?? received, true); - if (settled) return; - settled = true; - resolve(); - }); - }); - response.on("error", (error) => { - writeStream.destroy(); - fail(error); - }); - writeStream.on("error", (error) => { - request.abort(); - fail(error); - }); - }); - - request.end(payload); - }); - - await prepareDownloadPath(destination.root, absDest); - await publishDownload( - partialPath, - absDest, - overwrite, - transferId, - publishFs || defaultPublishFs, - ); - return { success: true, path: absDest }; - } catch (error) { - await fsp.rm(partialPath, { force: true }).catch(() => {}); - throw error; - } finally { - activeTransfers.delete(transferId); - activeDestinations.delete(absDest); - } -} - -function wrap(handler) { - return async (event, ...args) => { - try { - const result = await handler(event, ...args); - return { success: true, ...(result || {}) }; - } catch (error) { - return { - success: false, - error: error && error.message ? error.message : String(error), - code: error && error.code ? error.code : undefined, - }; - } - }; -} - -// Builds the IPC handler map from injected dependencies so the whole boundary -// can be exercised in tests with a Node http shim instead of Electron. -function createLocalFileHandlers({ - net, - shell, - getLinkedServer, - localBaseUrl, - publishFs, -}) { - if (!net || typeof net.request !== "function") { - throw new Error("createLocalFileHandlers requires a net implementation"); - } - const resolveTransferTarget = createTargetResolver({ - localBaseUrl, - getLinkedServer, - }); - const deps = { - net, - resolveTransferTarget, - publishFs: publishFs || defaultPublishFs, - }; - - return { - [IPC.SET_API]: wrap(async (_event, apiPath) => { - setTransferApiPath(apiPath); - return {}; - }), - - [IPC.HOME]: wrap(async () => ({ - home: os.homedir(), - separator: path.sep, - platform: process.platform, - })), - - [IPC.LIST]: wrap(async (_event, dirPath) => listDirectory(dirPath)), - - [IPC.MKDIR]: wrap(async (_event, parentPath, name) => { - const parent = normalizeLocalPath(parentPath); - const safeName = requireEntryName(name, "folder name"); - const target = path.join(parent, safeName); - await fsp.mkdir(target); - return { path: target }; - }), - - [IPC.CREATE_FILE]: wrap(async (_event, parentPath, name) => { - const parent = normalizeLocalPath(parentPath); - const safeName = requireEntryName(name, "file name"); - const target = path.join(parent, safeName); - // "wx" fails if the file already exists rather than truncating it. - await fsp.writeFile(target, "", { flag: "wx" }); - return { path: target }; - }), - - [IPC.RENAME]: wrap(async (_event, oldPath, newName) => { - const source = normalizeLocalPath(oldPath); - const safeName = requireEntryName(newName, "name"); - const target = path.join(path.dirname(source), safeName); - if (target === source) return { path: source }; - if (await pathExists(target)) { - throw new LocalFileError("EEXIST", `"${safeName}" already exists`); - } - await fsp.rename(source, target); - return { path: target }; - }), - - // Moves entries to the OS trash (Finder Trash / Recycle Bin) rather than - // deleting outright, so a mis-click in the file manager is recoverable. - [IPC.TRASH]: wrap(async (_event, targetPaths) => { - if (!Array.isArray(targetPaths) || targetPaths.length === 0) { - throw new Error("No local paths provided"); - } - const failed = []; - for (const candidate of targetPaths) { - const target = normalizeLocalPath(candidate); - try { - await shell.trashItem(target); - } catch (error) { - failed.push({ - path: target, - error: error && error.message ? error.message : String(error), - }); - } - } - return { trashed: targetPaths.length - failed.length, failed }; - }), - - [IPC.ENSURE_DIR]: wrap(async (_event, dirPath, rootPath) => { - let target = normalizeLocalPath(dirPath); - // Transfers pass the folder the user picked; the directory skeleton of - // a downloaded tree must stay inside it. - if (rootPath !== undefined) { - target = (await prepareDownloadPath(rootPath, target, true)).path; - } else { - await fsp.mkdir(target, { recursive: true }); - } - return { path: target }; - }), - - // Which of the given paths already exist; lets the renderer ask about - // collisions before a batch download starts. - [IPC.EXISTS]: wrap(async (_event, targetPaths) => { - if (!Array.isArray(targetPaths)) { - throw new Error("Expected a list of paths"); - } - const existing = []; - for (const candidate of targetPaths) { - const target = normalizeLocalPath(candidate); - if (await pathExists(target)) existing.push(target); - } - return { existing }; - }), - - [IPC.WALK]: wrap(async (_event, rootPaths) => { - if (!Array.isArray(rootPaths) || rootPaths.length === 0) { - throw new Error("No local paths provided"); - } - return walkPaths(rootPaths); - }), - - [IPC.REVEAL]: wrap(async (_event, targetPath) => { - shell.showItemInFolder(normalizeLocalPath(targetPath)); - }), - - [IPC.OPEN]: wrap(async (_event, targetPath) => { - const error = await shell.openPath(normalizeLocalPath(targetPath)); - if (error) throw new Error(error); - }), - - [IPC.UPLOAD]: wrap((event, options) => - uploadLocalFile(deps, event, options), - ), - - [IPC.DOWNLOAD]: wrap((event, options) => - downloadToLocal(deps, event, options), - ), - - [IPC.CANCEL]: wrap(async (_event, transferId) => { - const state = activeTransfers.get(transferId); - if (!state) return { cancelled: false }; - state.abort(); - return { cancelled: true }; - }), - }; -} - -function registerLocalFileHandlers({ ipcMain, shell }) { - // Real Electron wiring; tests build the handlers directly instead. - const { net } = require("electron"); - const { getLinkedServer } = require("./linked-server.cjs"); - const handlers = createLocalFileHandlers({ - net, - shell, - getLinkedServer, - }); - for (const [channel, handler] of Object.entries(handlers)) { - ipcMain.handle(channel, handler); - } -} - -module.exports = { - IPC, - TRANSFER_ROUTES, - registerLocalFileHandlers, - // exported for tests / reuse - createLocalFileHandlers, - createTargetResolver, - setTransferApiPath, - setLocalBackendPort, - publishDownload, - defaultPublishFs, - assertWithinRoot, - walkPaths, - listDirectory, -}; diff --git a/electron/local-shell.cjs b/electron/local-shell.cjs deleted file mode 100644 index 39daa9a8b..000000000 --- a/electron/local-shell.cjs +++ /dev/null @@ -1,71 +0,0 @@ -const fs = require("node:fs"); -const path = require("node:path"); - -function isUsableShell(file, exists) { - return !!file && (!path.isAbsolute(file) || exists(file)); -} - -function resolveLocalShell( - platform, - requestedShell, - env = process.env, - exists = fs.existsSync, -) { - if (platform === "win32") { - if (requestedShell === "wsl") { - return { file: "wsl.exe", args: [] }; - } - return { - file: env.TERMIX_LOCAL_SHELL || "powershell.exe", - args: ["-NoLogo"], - }; - } - - // A SHELL left pointing at an uninstalled shell makes the spawn fail. - const fallbacks = - platform === "darwin" - ? ["/bin/zsh", "/bin/bash", "/bin/sh"] - : ["/bin/bash", "/bin/sh"]; - const file = - [env.TERMIX_LOCAL_SHELL, env.SHELL, ...fallbacks].find((candidate) => - isUsableShell(candidate, exists), - ) || fallbacks[0]; - return { file, args: ["-l"] }; -} - -/** - * node-pty starts every shell through its spawn-helper binary, and a packaged - * copy that lost its execute bit fails with "posix_spawnp failed". Returns - * how many helpers were fixed. - */ -function repairSpawnHelpers(ptyDir, fsImpl = fs) { - // 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( - dir, - "prebuilds", - `${process.platform}-${process.arch}`, - "spawn-helper", - ), - ]; - let fixed = 0; - for (const helper of candidates) { - try { - const mode = fsImpl.statSync(helper).mode; - if ((mode & 0o111) === 0) { - fsImpl.chmodSync(helper, mode | 0o755); - fixed += 1; - } - } catch { - // Missing or read-only; nothing to fix here. - } - } - return fixed; -} - -module.exports = { resolveLocalShell, repairSpawnHelpers }; diff --git a/electron/main.cjs b/electron/main.cjs index ae3d49749..ac39f468f 100644 --- a/electron/main.cjs +++ b/electron/main.cjs @@ -1,6 +1,7 @@ const { app, BrowserWindow, + WebContentsView, shell, ipcMain, dialog, @@ -15,56 +16,28 @@ const path = require("path"); const { getUnpackedAppRoot } = require("./backend-paths.cjs"); const { classifyBackendFailure } = require("./backend-failure.cjs"); const fs = require("fs"); -const { renameLocalPath } = require("./rename-local-path.cjs"); -const os = require("os"); -const https = require("https"); const http = require("http"); const net = require("net"); const tls = require("tls"); const zlib = require("zlib"); const crypto = require("crypto"); const { URL, pathToFileURL } = require("url"); -const { fork, spawn } = require("child_process"); -const pty = require("node-pty"); -const WebSocket = require("ws"); +const { fork } = require("child_process"); const linkedServer = require("./linked-server.cjs"); const desktopSettings = require("./desktop-settings.cjs"); const autoUpdates = require("./auto-updater.cjs"); -const { launchNativeRdp } = require("./native-rdp.cjs"); 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, - setLocalBackendPort, -} = require("./local-files.cjs"); -const { - EXTERNAL_EDITOR_SETTING, - isLaunchableFileName, -} = require("./external-editor.cjs"); +// Saved by the file manager after the user picks an editor in a file dialog. +const EXTERNAL_EDITOR_SETTING = "fileManager.externalEditorPath"; + +// The height of the title bar the app draws itself. The renderer uses the +// same number, so the bar and the window controls always line up. +const TITLE_BAR_HEIGHT = 40; const { isExternalLoginUrl, isCallbackPort } = require("./external-login.cjs"); -const localTerminalSessions = new Map(); - -function ownedLocalTerminal(event, sessionId) { - if (typeof sessionId !== "string" || !/^[a-f0-9-]{36}$/.test(sessionId)) { - return null; - } - const session = localTerminalSessions.get(sessionId); - return session?.ownerId === event.sender.id ? session : null; -} - -function closeLocalTerminalsFor(ownerId) { - for (const [sessionId, session] of localTerminalSessions) { - if (session.ownerId !== ownerId) continue; - session.process.kill(); - localTerminalSessions.delete(sessionId); - } -} - -// The main process's Node.js networking (the `https`/`http` modules used by -// httpFetch below and the global `fetch`) only +// The main process's Node.js networking (the global `fetch`) only // trusts Node's bundled Mozilla CA list by default, not the OS/system trust // store. Chromium (the renderer, i.e. the web app and the login iframe) uses // the OS trust store instead, so a certificate that's valid in-browser -- @@ -491,101 +464,10 @@ function isInvalidCertificateAllowedForUrl(url) { function getTlsVerificationOptions(url) { return { rejectUnauthorized: !isInvalidCertificateAllowedForUrl(url), + ...linkedServer.clientCertOptionsFor(url), }; } -function getWebSocketOptions(url, options = {}) { - return { - ...options, - ...(String(url).startsWith("wss:") ? getTlsVerificationOptions(url) : {}), - }; -} - -function httpFetch(url, options = {}) { - return new Promise((resolve, reject) => { - const urlObj = new URL(url); - const isHttps = urlObj.protocol === "https:"; - const client = isHttps ? https : http; - - const requestOptions = { - method: options.method || "GET", - headers: options.headers || {}, - timeout: options.timeout || 10000, - ...(isHttps - ? options.allowInvalidCertificate - ? { rejectUnauthorized: false } - : getTlsVerificationOptions(url) - : {}), - }; - - const req = client.request(url, requestOptions, (res) => { - const chunks = []; - // Reverse proxies (nginx and friends) commonly gzip/deflate/br-compress - // responses regardless of client Accept-Encoding. Unlike browser fetch, - // Node's http/https modules never auto-decompress, so an unhandled - // content-encoding here silently turns the body into garbage bytes. - let stream = res; - const maxResponseBytes = options.maxResponseBytes || 10 * 1024 * 1024; - let responseBytes = 0; - let settled = false; - const fail = (error) => { - if (settled) return; - settled = true; - stream.destroy(); - req.destroy(); - reject(error); - }; - const encoding = (res.headers["content-encoding"] || "") - .toLowerCase() - .trim(); - try { - if (encoding === "gzip" || encoding === "x-gzip") { - stream = res.pipe(zlib.createGunzip()); - } else if (encoding === "br") { - stream = res.pipe(zlib.createBrotliDecompress()); - } else if (encoding === "deflate") { - stream = res.pipe(zlib.createInflate()); - } - } catch (decompressError) { - reject(decompressError); - return; - } - - stream.on("data", (chunk) => { - responseBytes += chunk.length; - if (responseBytes > maxResponseBytes) { - fail(new Error(`Response exceeds ${maxResponseBytes} bytes`)); - return; - } - chunks.push(chunk); - }); - stream.on("end", () => { - if (settled) return; - settled = true; - const data = Buffer.concat(chunks).toString("utf8"); - resolve({ - ok: res.statusCode >= 200 && res.statusCode < 300, - status: res.statusCode, - text: () => Promise.resolve(data), - json: () => Promise.resolve(JSON.parse(data)), - }); - }); - stream.on("error", fail); - }); - - req.on("error", reject); - req.on("timeout", () => { - req.destroy(); - reject(new Error("Request timeout")); - }); - - if (options.body) { - req.write(options.body); - } - req.end(); - }); -} - if (process.platform === "linux") { app.commandLine.appendSwitch("--ozone-platform-hint=auto"); @@ -620,25 +502,61 @@ app.commandLine.appendSwitch("--enable-features=NetworkService"); let mainWindow = null; const { createIsolatedWindows } = require("./isolated-window.cjs"); +const { createEmbeddedViews } = require("./embedded-view.cjs"); const isolatedWindows = createIsolatedWindows({ BrowserWindow, session, getMainWindow: () => mainWindow, }); +const embeddedViews = createEmbeddedViews({ + WebContentsView, + session, + getMainWindow: () => mainWindow, +}); + +// Only the app's own page may place native views, never a frame inside it. +function fromMainFrame(event) { + return !!mainWindow && event.senderFrame === mainWindow.webContents.mainFrame; +} + +ipcMain.handle("embedded-view-open", (event, options) => { + if (!fromMainFrame(event)) throw new Error("Not allowed"); + return embeddedViews.open(options); +}); +ipcMain.handle("embedded-view-bounds", (event, id, bounds) => { + if (fromMainFrame(event)) embeddedViews.setBounds(String(id), bounds); +}); +ipcMain.handle("embedded-view-visible", (event, id, visible) => { + if (fromMainFrame(event)) embeddedViews.setVisible(String(id), visible); +}); +ipcMain.handle("embedded-view-close", (event, id) => { + if (fromMainFrame(event)) embeddedViews.destroy(String(id)); +}); + // Requests from the embedded backend over the fork IPC channel (the other // direction from the "shutdown" message main already sends it). A plugin's // ctx.desktop.openIsolatedWindow is the one caller today. const BACKEND_REQUEST_HANDLERS = { "open-isolated-window": (payload) => isolatedWindows.open(payload), - // The OS client for a protocol. Only RDP has one wired up. - "launch-external-client": (payload) => - payload?.protocol === "rdp" - ? launchNativeRdp(payload) - : { - success: false, - error: `No external client for ${payload?.protocol ?? "this protocol"}`, - }, + // Plugins open OS clients themselves now (desktop:local), so none is wired here. + "launch-external-client": (payload) => ({ + success: false, + error: `No external client for ${payload?.protocol ?? "this protocol"}`, + }), + // Generic shell actions for a plugin's desktop:local file work. + "shell-trash-item": async (payload) => { + await shell.trashItem(String(payload?.path ?? "")); + return { success: true }; + }, + "shell-show-item": (payload) => { + shell.showItemInFolder(String(payload?.path ?? "")); + return { success: true }; + }, + "shell-open-path": async (payload) => { + const error = await shell.openPath(String(payload?.path ?? "")); + return error ? { success: false, error } : { success: true }; + }, "sync-proxy-config": (payload) => { linkedServer.setLinkedServer(payload); return { success: true }; @@ -699,7 +617,6 @@ let backendStderrTail = ""; 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(); @@ -751,67 +668,6 @@ function cleanupManagedTempFile(tempId) { tempFiles.delete(tempId); } -function closeExternalEditorSession(editId) { - const session = externalEditorSessions.get(editId); - if (!session) return; - if (session.timer) clearTimeout(session.timer); - try { - session.watcher.close(); - } catch { - // watcher may already be closed - } - externalEditorSessions.delete(editId); - cleanupManagedTempFile(editId); -} - -function notifyExternalEditorSaved(editId) { - const session = externalEditorSessions.get(editId); - if (!session || !mainWindow || mainWindow.isDestroyed()) return; - - try { - const stat = fs.statSync(session.path); - if (stat.mtimeMs === session.lastMtimeMs) return; - session.lastMtimeMs = stat.mtimeMs; - - const content = fs.readFileSync(session.path, "utf8"); - mainWindow.webContents.send("external-editor-saved", { - editId, - content, - encoding: "utf8", - path: session.path, - }); - } catch (error) { - logToFile("Failed to read external editor file:", error.message); - } -} - -function openPathWithEditor(filePath, editorPath) { - if (!editorPath) { - return shell.openPath(filePath); - } - - return new Promise((resolve) => { - let settled = false; - const child = spawn(editorPath, [filePath], { - detached: true, - stdio: "ignore", - }); - - child.once("error", (error) => { - if (settled) return; - settled = true; - resolve(error.message); - }); - - setTimeout(() => { - if (settled) return; - settled = true; - child.unref(); - resolve(""); - }, 500); - }); -} - // Origins the renderer has explicitly marked as allowed to present an invalid // certificate, for web endpoints whose ignoreCert is set. Session scoped and // never persisted. @@ -866,6 +722,39 @@ ipcMain.handle("allow-invalid-certificate-for-origin", (_event, origin) => { } }); +// Mutual TLS for the linked server: Chromium asks which certificate from the +// OS store to present. Other origins keep Chromium's default (none). +const chosenClientCerts = new Map(); + +app.on( + "select-client-certificate", + async (event, _webContents, url, list, callback) => { + if (!linkedServer.isLinkedOrigin(url) || !list.length) return; + event.preventDefault(); + const origin = new URL(url).origin; + const remembered = list.find( + (cert) => cert.fingerprint === chosenClientCerts.get(origin), + ); + let chosen = remembered || (list.length === 1 ? list[0] : null); + if (!chosen) { + const { response } = await dialog.showMessageBox(mainWindow, { + type: "question", + title: "Client certificate", + message: "Choose the certificate for this server", + detail: origin, + buttons: [ + ...list.slice(0, 8).map((cert) => cert.subjectName), + "Cancel", + ], + cancelId: Math.min(list.length, 8), + }); + chosen = list[response] || null; + } + if (chosen) chosenClientCerts.set(origin, chosen.fingerprint); + callback(chosen || undefined); + }, +); + app.on( "certificate-error", (event, _webContents, url, error, certificate, callback) => { @@ -873,6 +762,10 @@ app.on( isolatedWindows.handleCertificateError(event, _webContents, url, callback) ) return; + if ( + embeddedViews.handleCertificateError(event, _webContents, url, callback) + ) + return; if (isWebEndpointCertificateAllowed(url)) { event.preventDefault(); logToFile("Allowed invalid certificate for configured web endpoint", { @@ -1190,6 +1083,7 @@ function startBackendServer() { ELECTRON_EMBEDDED: "true", PORT: String(backendPort), EMBEDDED_BACKEND_PORT: String(backendPort), + ELECTRON_USER_DATA: app.getPath("userData"), VERSION: app.getVersion(), }, stdio: ["pipe", "pipe", "pipe", "ipc"], @@ -1437,12 +1331,27 @@ function createWindow() { ? "macOS" : "Linux"; + const isMac = process.platform === "darwin"; + mainWindow = new BrowserWindow({ width: 1200, height: 800, minWidth: 800, minHeight: 600, title: "Termix", + // The app draws its own title bar so it follows the active theme. macOS + // keeps the traffic lights on top of it, Windows and Linux get the + // native controls as an overlay whose colors the renderer keeps in sync. + titleBarStyle: isMac ? "hiddenInset" : "hidden", + ...(isMac + ? { trafficLightPosition: { x: 14, y: 16 } } + : { + titleBarOverlay: { + color: "#0f0f14", + symbolColor: "#e6e6ea", + height: TITLE_BAR_HEIGHT, + }, + }), icon: path.join( appRoot, "public", @@ -1649,7 +1558,24 @@ function createWindow() { } }); + mainWindow.webContents.on("did-start-navigation", (details) => { + if (details.isMainFrame && !details.isSameDocument) { + embeddedViews.destroyAll(); + } + }); + + const sendTitleBarVisible = () => { + if (!mainWindow || mainWindow.isDestroyed()) return; + mainWindow.webContents.send( + "title-bar-visible-changed", + titleBarVisible(mainWindow), + ); + }; + mainWindow.on("enter-full-screen", sendTitleBarVisible); + mainWindow.on("leave-full-screen", sendTitleBarVisible); + mainWindow.on("closed", () => { + embeddedViews.destroyAll(); mainWindow = null; }); @@ -1677,10 +1603,50 @@ ipcMain.handle("get-app-version", () => { return app.getVersion(); }); +/** The window an IPC call came from, so the main window and the extra + * windows (isolated views, sign-in) can each answer for themselves. */ +function windowFor(event) { + const win = BrowserWindow.fromWebContents(event.sender); + if (win && !win.isDestroyed()) return win; + if (mainWindow && !mainWindow.isDestroyed()) return mainWindow; + return null; +} + ipcMain.handle("get-platform", () => { return process.platform; }); +// Whether the app-drawn title bar shows in the window that asks. Only the +// main window hides the native bar, and in full screen macOS hides the +// traffic lights, so the bar goes away there too. +function titleBarVisible(win) { + return !!win && win === mainWindow && !win.isFullScreen(); +} + +ipcMain.handle("get-title-bar-visible", (event) => { + return titleBarVisible(windowFor(event)); +}); + +// The renderer sends the colors of the active theme so the window controls +// the system draws (Windows and Linux) match the app. +ipcMain.handle("set-title-bar-overlay", (event, colors) => { + const win = windowFor(event); + if (!win || process.platform === "darwin") return; + const options = {}; + if (typeof colors?.color === "string" && colors.color) { + options.color = colors.color; + } + if (typeof colors?.symbolColor === "string" && colors.symbolColor) { + options.symbolColor = colors.symbolColor; + } + if (Object.keys(options).length === 0) return; + try { + win.setTitleBarOverlay(options); + } catch (error) { + logToFile("setTitleBarOverlay failed:", error); + } +}); + ipcMain.handle("get-embedded-server-status", () => { return { running: @@ -1858,1353 +1824,6 @@ ipcMain.handle("get-previous-server-url", () => { return { serverUrl: desktopSettings.getPreviousServerUrl() }; }); -function getC2STunnelConfigPath() { - return path.join(app.getPath("userData"), "c2s-tunnels.json"); -} - -ipcMain.handle("get-c2s-tunnel-config", () => { - try { - const configPath = getC2STunnelConfigPath(); - if (!fs.existsSync(configPath)) { - return []; - } - const configData = fs.readFileSync(configPath, "utf8"); - const parsed = JSON.parse(configData); - return Array.isArray(parsed) ? parsed : []; - } catch (error) { - console.error("Error reading C2S tunnel config:", error); - return []; - } -}); - -ipcMain.handle("save-c2s-tunnel-config", async (_event, config) => { - try { - if (!Array.isArray(config)) { - return { success: false, error: "C2S tunnel config must be an array" }; - } - const autoStartListeners = new Set(); - const autoStartRemoteListeners = new Set(); - for (const tunnel of config) { - if (!tunnel?.autoStart) continue; - const mode = tunnel.mode || tunnel.tunnelType || "local"; - if (mode === "remote") { - const sourceHostId = Number(tunnel.sourceHostId); - const sourcePort = Number(tunnel.sourcePort); - const remoteAddress = getC2SRemoteAddress(tunnel); - if ( - !Number.isInteger(sourceHostId) || - sourceHostId < 1 || - !Number.isInteger(sourcePort) || - sourcePort < 1 || - sourcePort > 65535 - ) { - return { - success: false, - error: "Invalid remote client tunnel endpoint or port", - }; - } - const listenerKey = `${sourceHostId}:${remoteAddress}:${sourcePort}`; - if (autoStartRemoteListeners.has(listenerKey)) { - return { - success: false, - error: `Another auto-start client tunnel already uses remote ${listenerKey}`, - }; - } - autoStartRemoteListeners.add(listenerKey); - continue; - } - - const bindHost = getC2SLocalAddress(tunnel); - const sourcePort = Number(tunnel.sourcePort); - const listenerKey = `${bindHost}:${sourcePort}`; - if (autoStartListeners.has(listenerKey)) { - return { - success: false, - error: `Another auto-start client tunnel already uses ${listenerKey}`, - }; - } - autoStartListeners.add(listenerKey); - } - for (const listenerKey of autoStartListeners) { - const sourcePortSeparator = listenerKey.lastIndexOf(":"); - const bindHost = listenerKey.slice(0, sourcePortSeparator); - const sourcePort = listenerKey.slice(sourcePortSeparator + 1); - const result = await checkLocalPortAvailable( - bindHost, - Number(sourcePort), - ); - const ownedByClientTunnel = Array.from(c2sTunnelRuntimes.values()).some( - (runtime) => - runtime.bindHost === bindHost && - runtime.sourcePort === Number(sourcePort), - ); - if (!result.available && !ownedByClientTunnel) { - return { - success: false, - error: `Cannot auto-start client tunnel on ${listenerKey}: ${result.error || "port is already in use"}`, - }; - } - } - const userDataPath = app.getPath("userData"); - if (!fs.existsSync(userDataPath)) { - fs.mkdirSync(userDataPath, { recursive: true }); - } - fs.writeFileSync(getC2STunnelConfigPath(), JSON.stringify(config, null, 2)); - return { success: true }; - } catch (error) { - console.error("Error saving C2S tunnel config:", error); - return { success: false, error: error.message }; - } -}); - -function checkLocalPortAvailable(host, port) { - return new Promise((resolve) => { - const server = net.createServer(); - server.once("error", (error) => { - resolve({ available: false, error: error.message }); - }); - server.once("listening", () => { - server.close(() => resolve({ available: true })); - }); - server.listen({ host, port }); - }); -} - -function checkTcpConnection(host, port) { - return new Promise((resolve) => { - const socket = net.createConnection({ host, port }); - const timer = setTimeout(() => { - socket.destroy(); - resolve({ success: false, error: "Connection timed out" }); - }, 5000); - - socket.once("connect", () => { - clearTimeout(timer); - socket.destroy(); - resolve({ success: true }); - }); - socket.once("error", (error) => { - clearTimeout(timer); - socket.destroy(); - resolve({ success: false, error: error.message }); - }); - }); -} - -function getC2SAddress(value, fallback) { - const address = typeof value === "string" ? value.trim() : ""; - return address || fallback; -} - -function getC2SLocalAddress(tunnel) { - return getC2SAddress(tunnel?.localAddress || tunnel?.bindHost, "127.0.0.1"); -} - -function getC2SRemoteAddress(tunnel) { - return getC2SAddress( - tunnel?.remoteAddress || tunnel?.targetHost, - "127.0.0.1", - ); -} - -function normalizeC2STunnelAddresses(tunnel) { - const localAddress = getC2SLocalAddress(tunnel); - const remoteAddress = getC2SRemoteAddress(tunnel); - return { - ...tunnel, - localAddress, - remoteAddress, - bindHost: localAddress, - targetHost: remoteAddress, - }; -} - -const c2sTunnelRuntimes = new Map(); -const C2S_WS_HIGH_WATERMARK = 1024 * 1024; -const C2S_WS_LOW_WATERMARK = 256 * 1024; -const C2S_STREAM_WRITE_LIMIT = 8 * 1024 * 1024; - -// A proxy in front of the server may drop an idle WebSocket, which stopped -// the tunnel. Pings count as traffic. -function keepC2SSocketAlive(ws) { - const timer = setInterval(() => { - if (ws.readyState === WebSocket.OPEN) ws.ping(); - }, 25_000); - timer.unref?.(); - ws.once("close", () => clearInterval(timer)); -} - -// C2S (client-to-server) tunnels relay through the server this desktop is -// linked to, not the embedded backend. -function getC2SRemoteBaseUrl() { - const serverUrl = linkedServer.getLinkedServer()?.serverUrl; - if (!serverUrl) { - throw new Error( - "This device is not linked to a Termix server. Link it in Sync first.", - ); - } - return serverUrl.replace(/\/$/, ""); -} - -// The socket path on the linked server that relays C2S streams. The plugin -// that owns the relay tells us where it is, so nothing here names it. -let c2sRelayPath = null; -const C2S_RELAY_PATH_PATTERN = - /^\/plugin-ws\/[a-z0-9][a-z0-9-]*\/[A-Za-z0-9/_-]+$/; - -ipcMain.handle("set-c2s-relay-path", (_event, relayPath) => { - if ( - typeof relayPath !== "string" || - !C2S_RELAY_PATH_PATTERN.test(relayPath) - ) { - return { success: false, error: "Invalid relay path" }; - } - c2sRelayPath = relayPath; - return { success: true }; -}); - -function getC2SRelayUrl() { - if (!c2sRelayPath) { - throw new Error("Client tunnels are not available yet. Try again shortly."); - } - const relayHttpUrl = `${getC2SRemoteBaseUrl()}${c2sRelayPath}`; - return relayHttpUrl.replace(/^http:/, "ws:").replace(/^https:/, "wss:"); -} - -const C2S_REMOTE_SESSION_EXPIRED_ERROR = - "This device was signed out of its server. Sign in again from Sync and try again."; - -function normalizeC2SAuthToken(authToken) { - return typeof authToken === "string" ? authToken.trim() : ""; -} - -function isC2SAuthError(message) { - const value = String(message || "") - .trim() - .toLowerCase(); - return ( - value === "authentication required" || - value === "missing authentication token" || - value === "invalid token" || - value === "session expired" || - value === "session not found" || - // The plugin relay refuses an unauthenticated upgrade before any message. - value === "unexpected server response: 401" - ); -} - -function normalizeC2SErrorMessage(message, fallback = "Client tunnel failed") { - const value = message || fallback; - return isC2SAuthError(value) ? C2S_REMOTE_SESSION_EXPIRED_ERROR : value; -} - -function createC2SFailure(message, fallback) { - const error = normalizeC2SErrorMessage(message, fallback); - return { - success: false, - error, - ...(error === C2S_REMOTE_SESSION_EXPIRED_ERROR - ? { code: "REMOTE_SESSION_EXPIRED" } - : {}), - }; -} - -async function fetchC2SRemoteJson(pathname) { - if (!linkedServer.getLinkedServer()?.token) { - throw new Error(C2S_REMOTE_SESSION_EXPIRED_ERROR); - } - - const url = `${getC2SRemoteBaseUrl()}${pathname}`; - const response = await httpFetch(url, { - headers: linkedServer.linkedRequestHeaders(), - }); - if (response.status === 401 || response.status === 403) { - throw new Error(C2S_REMOTE_SESSION_EXPIRED_ERROR); - } - if (!response.ok) { - throw new Error(`Request failed (${response.status}): ${url}`); - } - return response.json(); -} - -async function resolveC2SRemoteSourceHost(tunnel) { - const normalized = normalizeC2STunnelAddresses(tunnel); - const sourceHostSyncId = - typeof normalized.sourceHostSyncId === "string" - ? normalized.sourceHostSyncId.trim() - : ""; - if (!sourceHostSyncId) return normalized; - - let remoteHost = null; - try { - remoteHost = await fetchC2SRemoteJson( - `/sync/v2/hosts/${encodeURIComponent(sourceHostSyncId)}`, - ); - } catch (error) { - if (error.message === C2S_REMOTE_SESSION_EXPIRED_ERROR) throw error; - } - const remoteHostId = Number(remoteHost?.id); - if (!Number.isInteger(remoteHostId) || remoteHostId < 1) { - throw new Error( - "The intermediate host is not on the server yet. Sync, then try again.", - ); - } - - return { - ...normalized, - sourceHostId: remoteHostId, - sourceHostSyncId, - sourceHostName: remoteHost.name || normalized.sourceHostName, - endpointHost: normalized.endpointHost || remoteHost.name, - }; -} - -async function getC2SRelayHeaders(relayUrl) { - if (linkedServer.getLinkedServer()?.token) { - return linkedServer.linkedRequestHeaders(); - } - const headers = { "X-Electron-App": "true" }; - - if (!mainWindow?.webContents?.session) { - throw new Error(C2S_REMOTE_SESSION_EXPIRED_ERROR); - } - - const cookieUrl = relayUrl - .replace(/^ws:/, "http:") - .replace(/^wss:/, "https:"); - const cookies = await mainWindow.webContents.session.cookies.get({ - url: cookieUrl, - name: "jwt", - }); - const jwt = cookies[0]?.value; - if (!jwt) { - throw new Error(C2S_REMOTE_SESSION_EXPIRED_ERROR); - } - - headers.Cookie = `jwt=${encodeURIComponent(jwt)}`; - return headers; -} - -const attachC2SAuth = require("./c2s-auth.cjs").createC2SAuthBridge( - ipcMain, - () => mainWindow, -); - -function getC2STunnelName(tunnel, index = 0) { - if (tunnel.name) return tunnel.name; - const localAddress = getC2SLocalAddress(tunnel); - const remoteAddress = getC2SRemoteAddress(tunnel); - return [ - "c2s", - index, - tunnel.sourceHostId || 0, - tunnel.mode || tunnel.tunnelType || "local", - localAddress, - remoteAddress, - tunnel.sourcePort, - tunnel.endpointPort || 0, - ].join("::"); -} - -function getC2STunnelStatus(tunnelName) { - return ( - c2sTunnelRuntimes.get(tunnelName)?.status || { - connected: false, - status: "DISCONNECTED", - } - ); -} - -function getAllC2STunnelStatuses() { - const statuses = {}; - for (const [tunnelName] of c2sTunnelRuntimes.entries()) { - statuses[tunnelName] = getC2STunnelStatus(tunnelName); - } - return statuses; -} - -function emitC2STunnelStatuses() { - if (!mainWindow || mainWindow.isDestroyed()) return; - mainWindow.webContents.send("c2s-tunnel-statuses", getAllC2STunnelStatuses()); -} - -function setC2STunnelStatus(tunnelName, status) { - const runtime = c2sTunnelRuntimes.get(tunnelName); - if (runtime) { - runtime.status = status; - emitC2STunnelStatuses(); - } -} - -function setC2STunnelError(tunnelName, message) { - logToFile(`[c2s] ${tunnelName} failed:`, message); - setC2STunnelStatus(tunnelName, { - connected: false, - status: "ERROR", - reason: message, - }); -} - -function parseSocks5Target(buffer) { - if (buffer.length < 7 || buffer[0] !== 0x05 || buffer[1] !== 0x01) { - return null; - } - - const addressType = buffer[3]; - let offset = 4; - let host; - - if (addressType === 0x01) { - if (buffer.length < offset + 4 + 2) return null; - host = Array.from(buffer.subarray(offset, offset + 4)).join("."); - offset += 4; - } else if (addressType === 0x03) { - const length = buffer[offset]; - offset += 1; - if (buffer.length < offset + length + 2) return null; - host = buffer.subarray(offset, offset + length).toString("utf8"); - offset += length; - } else if (addressType === 0x04) { - if (buffer.length < offset + 16 + 2) return null; - const parts = []; - for (let i = 0; i < 16; i += 2) { - parts.push(buffer.readUInt16BE(offset + i).toString(16)); - } - host = parts.join(":"); - offset += 16; - } else { - throw new Error("Unsupported SOCKS5 address type"); - } - - const port = buffer.readUInt16BE(offset); - return { host, port, bytesRead: offset + 2 }; -} - -async function openC2SRelay( - tunnel, - targetHost, - targetPort, - socket, - initialData, - authToken, -) { - const tunnelConfig = normalizeC2STunnelAddresses(tunnel); - const tunnelName = tunnel.name || getC2STunnelName(tunnel); - const relayUrl = getC2SRelayUrl(); - const headers = await getC2SRelayHeaders(relayUrl, authToken); - logToFile(`[c2s] opening relay for ${tunnelName}`, { - relayUrl, - targetHost, - targetPort, - }); - const ws = new WebSocket( - relayUrl, - getWebSocketOptions(relayUrl, { headers }), - ); - attachC2SAuth(ws, tunnel.name || getC2STunnelName(tunnel)); - keepC2SSocketAlive(ws); - const pendingChunks = []; - let ready = false; - let closed = false; - - const cleanup = () => { - if (closed) return; - closed = true; - try { - socket.destroy(); - } catch { - // expected during shutdown - } - try { - ws.close(); - } catch { - // expected during shutdown - } - }; - - const sendChunk = (chunk) => { - if (ready && ws.readyState === WebSocket.OPEN) { - ws.send(chunk); - } else { - pendingChunks.push(chunk); - } - }; - - socket.on("data", sendChunk); - socket.on("close", cleanup); - // One connection failing must not stop the tunnel: the listener refuses - // new connections while the tunnel is not connected, and a browser opens - // many at once. - socket.on("error", (error) => { - logToFile(`[c2s] ${tunnelName} local socket error:`, error.message); - cleanup(); - }); - ws.on("close", cleanup); - ws.on("error", (error) => { - logToFile(`[c2s] ${tunnelName} relay error:`, error.message); - cleanup(); - }); - - ws.on("open", () => { - logToFile(`[c2s] relay connected for ${tunnelName}`); - ws.send( - JSON.stringify({ - type: "open", - tunnelConfig, - targetHost, - targetPort, - }), - ); - }); - - ws.on("message", (data, isBinary) => { - if (isBinary) { - socket.write(Buffer.isBuffer(data) ? data : Buffer.from(data)); - return; - } - - try { - const message = JSON.parse(data.toString()); - if (message.type === "ready") { - ready = true; - logToFile(`[c2s] relay ready for ${tunnelName}`); - if (initialData?.length) { - ws.send(initialData); - } - while (pendingChunks.length > 0) { - ws.send(pendingChunks.shift()); - } - } else if (message.type === "error") { - const relayError = normalizeC2SErrorMessage( - message.error, - "Relay rejected the client tunnel", - ); - logToFile("[c2s] relay error:", relayError); - if (relayError === C2S_REMOTE_SESSION_EXPIRED_ERROR) { - setC2STunnelError(tunnelName, relayError); - } - cleanup(); - } - } catch (error) { - logToFile("[c2s] invalid relay message:", error.message); - cleanup(); - } - }); -} - -async function testC2SRelay( - tunnel, - targetHost, - targetPort, - authToken, - runtime, -) { - const tunnelConfig = normalizeC2STunnelAddresses(tunnel); - const relayUrl = getC2SRelayUrl(); - const headers = await getC2SRelayHeaders(relayUrl, authToken); - if (runtime && c2sTunnelRuntimes.get(tunnel.name) !== runtime) { - return { success: false, error: "Tunnel stopped" }; - } - const ws = new WebSocket( - relayUrl, - getWebSocketOptions(relayUrl, { headers }), - ); - attachC2SAuth(ws, tunnel.name || getC2STunnelName(tunnel)); - keepC2SSocketAlive(ws); - - 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; - try { - if (!runtime || !result.success) ws.close(); - } catch { - // expected during shutdown - } - resolve(result); - }; - - const timer = setTimeout(() => { - settle({ success: false, error: "Tunnel test timed out" }); - }, 180_000); - - ws.on("open", () => { - ws.send( - JSON.stringify({ - type: "test", - keepAlive: !!runtime, - tunnelConfig, - targetHost, - targetPort, - }), - ); - }); - ws.on("message", (data, isBinary) => { - if (isBinary) return; - - try { - const message = JSON.parse(data.toString()); - if (message.type === "ready") { - clearTimeout(timer); - 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) { - clearTimeout(timer); - settle(createC2SFailure(error.message, "Tunnel test failed")); - } - }); - ws.on("error", (error) => { - clearTimeout(timer); - settle(createC2SFailure(error.message, "Tunnel test failed")); - }); - ws.on("close", () => { - if (runtime && c2sTunnelRuntimes.get(tunnel.name) === runtime) { - setC2STunnelError( - tunnel.name, - relayError || "Endpoint SSH connection closed", - ); - } - clearTimeout(timer); - settle({ success: false, error: "Tunnel test connection closed" }); - }); - }); -} - -async function testC2STunnel(tunnel, index = 0, authToken) { - const resolvedTunnel = await resolveC2SRemoteSourceHost(tunnel); - const mode = resolvedTunnel.mode || resolvedTunnel.tunnelType || "local"; - const testTunnel = { - ...normalizeC2STunnelAddresses(resolvedTunnel), - name: `${getC2STunnelName(resolvedTunnel, index)}::test`, - mode, - }; - const localAddress = getC2SLocalAddress(testTunnel); - const remoteAddress = getC2SRemoteAddress(testTunnel); - const sourcePort = Number(resolvedTunnel.sourcePort); - const endpointPort = Number(resolvedTunnel.endpointPort); - - if (!resolvedTunnel.sourceHostId) { - return { success: false, error: "Endpoint SSH host is required" }; - } - - if (mode === "remote") { - const localTarget = await checkTcpConnection(localAddress, endpointPort); - if (!localTarget.success) { - return { - success: false, - error: `Local target ${localAddress}:${endpointPort} is not reachable: ${localTarget.error}`, - }; - } - - return testC2SRelay(testTunnel, undefined, undefined, authToken); - } - - if (!Number.isInteger(sourcePort) || sourcePort < 1 || sourcePort > 65535) { - return { success: false, error: "Invalid local port" }; - } - - const runtime = c2sTunnelRuntimes.get( - getC2STunnelName(resolvedTunnel, index), - ); - if (!runtime) { - const availability = await checkLocalPortAvailable( - localAddress, - sourcePort, - ); - if (!availability.available) { - return { - success: false, - error: `Local listener ${localAddress}:${sourcePort} is not available: ${availability.error}`, - }; - } - } - - if (mode === "dynamic") { - return testC2SRelay(testTunnel, undefined, undefined, authToken); - } - - if (!Number.isInteger(endpointPort) || endpointPort < 1) { - return { success: false, error: "Invalid remote port" }; - } - - return testC2SRelay(testTunnel, remoteAddress, endpointPort, authToken); -} - -function handleC2SDynamicConnection(tunnel, socket, authToken) { - const tunnelName = tunnel.name || getC2STunnelName(tunnel); - let buffer = Buffer.alloc(0); - let stage = "greeting"; - - const fail = (code = 0x01, message = "SOCKS5 request failed") => { - setC2STunnelError(tunnelName, message); - if (!socket.destroyed) { - socket.write(Buffer.from([0x05, code, 0x00, 0x01, 0, 0, 0, 0, 0, 0])); - socket.destroy(); - } - }; - - const onData = (chunk) => { - buffer = Buffer.concat([buffer, chunk]); - - try { - if (stage === "greeting") { - if (buffer.length < 2) return; - if (buffer[0] !== 0x05) { - fail(0x01, "Invalid SOCKS5 greeting"); - return; - } - const methodsLength = buffer[1]; - if (buffer.length < 2 + methodsLength) return; - socket.write(Buffer.from([0x05, 0x00])); - buffer = buffer.subarray(2 + methodsLength); - stage = "connect"; - } - - if (stage === "connect") { - const target = parseSocks5Target(buffer); - if (!target) return; - - stage = "piping"; - socket.off("data", onData); - const remainder = buffer.subarray(target.bytesRead); - socket.write(Buffer.from([0x05, 0x00, 0x00, 0x01, 0, 0, 0, 0, 0, 0])); - openC2SRelay( - tunnel, - target.host, - target.port, - socket, - remainder, - authToken, - ).catch((error) => { - const message = normalizeC2SErrorMessage( - error.message, - "Dynamic relay failed", - ); - logToFile("[c2s] dynamic relay failed:", message); - fail(0x05, message); - }); - } - } catch (error) { - logToFile("[c2s] SOCKS5 parse failed:", error.message); - fail(0x01, error.message || "SOCKS5 parse failed"); - } - }; - - socket.on("data", onData); - socket.on("error", () => socket.destroy()); -} - -function handleC2SLocalConnection(tunnel, socket, authToken) { - const tunnelName = tunnel.name || getC2STunnelName(tunnel); - const targetHost = getC2SRemoteAddress(tunnel); - const targetPort = Number(tunnel.endpointPort); - openC2SRelay( - tunnel, - targetHost, - targetPort, - socket, - undefined, - authToken, - ).catch((error) => { - const message = normalizeC2SErrorMessage( - error.message, - "Local relay failed", - ); - logToFile("[c2s] local relay failed:", message); - if (message === C2S_REMOTE_SESSION_EXPIRED_ERROR) { - setC2STunnelError(tunnelName, message); - } - socket.destroy(); - }); -} - -function pauseSourceForC2SWebSocket(ws, source) { - if (!source?.pause || !source?.resume) return; - if (ws.bufferedAmount <= C2S_WS_HIGH_WATERMARK) return; - - source.pause(); - const resumeTimer = setInterval(() => { - if ( - ws.readyState !== WebSocket.OPEN || - source.destroyed || - ws.bufferedAmount <= C2S_WS_LOW_WATERMARK - ) { - clearInterval(resumeTimer); - if (ws.readyState === WebSocket.OPEN && !source.destroyed) { - source.resume(); - } - } - }, 25); -} - -function sendC2SRemoteMessage(ws, message, source) { - if (ws.readyState === WebSocket.OPEN) { - ws.send(JSON.stringify(message), (error) => { - if (error && source?.destroy) { - source.destroy(error); - } - }); - pauseSourceForC2SWebSocket(ws, source); - } -} - -function writeC2SRemoteChunk(target, chunk, ws, closeTarget) { - if (!target || target.destroyed) return; - - if (target.writableLength > C2S_STREAM_WRITE_LIMIT) { - closeTarget(); - return; - } - - const canContinue = target.write(chunk); - if (!canContinue && typeof ws.pause === "function") { - ws.pause(); - target.once("drain", () => { - if (ws.readyState === WebSocket.OPEN && typeof ws.resume === "function") { - ws.resume(); - } - }); - } -} - -async function startC2SRemoteTunnel(tunnel, index = 0, authToken) { - const tunnelName = getC2STunnelName(tunnel, index); - const tunnelConfig = normalizeC2STunnelAddresses(tunnel); - const localHost = getC2SLocalAddress(tunnelConfig); - const remoteHost = getC2SRemoteAddress(tunnelConfig); - const localPort = Number(tunnel.endpointPort); - const remotePort = Number(tunnel.sourcePort); - - if (!tunnel.sourceHostId) { - return { success: false, error: "Endpoint SSH host is required" }; - } - if (!Number.isInteger(remotePort) || remotePort < 1 || remotePort > 65535) { - return { success: false, error: "Invalid remote port" }; - } - if (!Number.isInteger(localPort) || localPort < 1 || localPort > 65535) { - return { success: false, error: "Invalid local port" }; - } - - const localTarget = await checkTcpConnection(localHost, localPort); - if (!localTarget.success) { - return { - success: false, - error: `Local target ${localHost}:${localPort} is not reachable: ${localTarget.error}`, - }; - } - - const existing = c2sTunnelRuntimes.get(tunnelName); - if (existing) { - return { success: true, tunnelName }; - } - - for (const runtime of c2sTunnelRuntimes.values()) { - if ( - runtime.mode === "remote" && - runtime.sourceHostId === Number(tunnel.sourceHostId) && - runtime.remoteAddress === remoteHost && - runtime.sourcePort === remotePort - ) { - return { - success: false, - error: `Another client remote tunnel already uses ${remoteHost}:${remotePort} on this endpoint`, - }; - } - } - - const relayUrl = getC2SRelayUrl(); - const headers = await getC2SRelayHeaders(relayUrl, authToken); - const ws = new WebSocket( - relayUrl, - getWebSocketOptions(relayUrl, { headers }), - ); - attachC2SAuth(ws, tunnel.name || getC2STunnelName(tunnel)); - keepC2SSocketAlive(ws); - const sockets = new Map(); - let closed = false; - - const cleanup = () => { - if (closed) return; - closed = true; - for (const socket of sockets.values()) { - socket.destroy(); - } - sockets.clear(); - try { - ws.close(); - } catch { - // expected during shutdown - } - }; - - c2sTunnelRuntimes.set(tunnelName, { - ws, - sockets, - mode: "remote", - sourceHostId: Number(tunnel.sourceHostId), - sourcePort: remotePort, - bindHost: localHost, - remoteAddress: remoteHost, - status: { connected: false, status: "CONNECTING" }, - close: cleanup, - }); - emitC2STunnelStatuses(); - - return new Promise((resolve) => { - let settled = false; - const settle = (result) => { - if (settled) return; - settled = true; - resolve(result); - }; - - ws.on("open", () => { - logToFile(`[c2s] opening remote tunnel ${tunnelName}`, { - relayUrl, - remoteHost, - remotePort, - localHost, - localPort, - }); - ws.send( - JSON.stringify({ - type: "open", - tunnelConfig: { ...tunnelConfig, name: tunnelName, mode: "remote" }, - }), - ); - }); - - ws.on("message", (data, isBinary) => { - if (isBinary) return; - - let message; - try { - message = JSON.parse(data.toString()); - } catch (error) { - const message = normalizeC2SErrorMessage( - error.message, - "Invalid relay message", - ); - setC2STunnelError(tunnelName, message); - cleanup(); - settle(createC2SFailure(message, "Invalid relay message")); - return; - } - - if (message.type === "ready") { - setC2STunnelStatus(tunnelName, { - connected: true, - status: "CONNECTED", - }); - settle({ success: true, tunnelName }); - return; - } - - if (message.type === "error") { - const error = normalizeC2SErrorMessage( - message.error, - "Relay rejected the client tunnel", - ); - setC2STunnelError(tunnelName, error); - cleanup(); - c2sTunnelRuntimes.delete(tunnelName); - emitC2STunnelStatuses(); - settle(createC2SFailure(error, "Relay rejected the client tunnel")); - return; - } - - if (message.type === "connection" && message.streamId) { - const socket = net.createConnection( - { host: localHost, port: localPort }, - () => { - logToFile(`[c2s] remote stream ${message.streamId} connected`, { - tunnelName, - localHost, - localPort, - }); - }, - ); - sockets.set(message.streamId, socket); - socket.on("data", (chunk) => { - sendC2SRemoteMessage( - ws, - { - type: "data", - streamId: message.streamId, - data: chunk.toString("base64"), - }, - socket, - ); - }); - socket.on("close", () => { - sockets.delete(message.streamId); - sendC2SRemoteMessage(ws, { - type: "close", - streamId: message.streamId, - }); - }); - socket.on("error", (error) => { - logToFile(`[c2s] remote stream ${message.streamId} failed:`, { - tunnelName, - error: error.message, - }); - sockets.delete(message.streamId); - sendC2SRemoteMessage(ws, { - type: "close", - streamId: message.streamId, - error: error.message, - }); - }); - return; - } - - if (message.type === "data" && message.streamId && message.data) { - const socket = sockets.get(message.streamId); - writeC2SRemoteChunk( - socket, - Buffer.from(message.data, "base64"), - ws, - () => { - if (socket) { - sockets.delete(message.streamId); - socket.destroy(); - } - }, - ); - return; - } - - if (message.type === "close" && message.streamId) { - const socket = sockets.get(message.streamId); - if (socket) { - sockets.delete(message.streamId); - socket.destroy(); - } - } - }); - - ws.on("close", () => { - cleanup(); - c2sTunnelRuntimes.delete(tunnelName); - emitC2STunnelStatuses(); - settle({ success: false, error: "Remote tunnel relay closed" }); - }); - - ws.on("error", (error) => { - const message = normalizeC2SErrorMessage( - error.message, - "Relay connection failed", - ); - setC2STunnelError(tunnelName, message); - cleanup(); - c2sTunnelRuntimes.delete(tunnelName); - emitC2STunnelStatuses(); - settle(createC2SFailure(message, "Relay connection failed")); - }); - }); -} - -async function startC2STunnel(tunnel, index = 0, authToken) { - const resolvedTunnel = await resolveC2SRemoteSourceHost(tunnel); - const mode = resolvedTunnel.mode || resolvedTunnel.tunnelType || "local"; - const tunnelName = getC2STunnelName(resolvedTunnel, index); - const tunnelConfig = { - ...normalizeC2STunnelAddresses(resolvedTunnel), - sessionId: crypto.randomUUID(), - }; - const bindHost = getC2SLocalAddress(tunnelConfig); - const sourcePort = Number(resolvedTunnel.sourcePort); - logToFile(`[c2s] starting tunnel ${tunnelName}`, { - mode, - bindHost, - remoteAddress: getC2SRemoteAddress(tunnelConfig), - sourcePort, - sourceHostId: resolvedTunnel.sourceHostId, - sourceHostSyncId: resolvedTunnel.sourceHostSyncId, - endpointPort: resolvedTunnel.endpointPort, - }); - - if (mode === "remote") { - return startC2SRemoteTunnel(resolvedTunnel, index, authToken); - } - if (!resolvedTunnel.sourceHostId) { - return { success: false, error: "Endpoint SSH host is required" }; - } - if (!Number.isInteger(sourcePort) || sourcePort < 1 || sourcePort > 65535) { - return { success: false, error: "Invalid local port" }; - } - - const existing = c2sTunnelRuntimes.get(tunnelName); - if (existing) { - return { success: true, tunnelName }; - } - - for (const runtime of c2sTunnelRuntimes.values()) { - if ( - runtime.mode !== "remote" && - runtime.bindHost === bindHost && - runtime.sourcePort === sourcePort - ) { - return { - success: false, - error: `Another client tunnel already uses ${bindHost}:${sourcePort}`, - }; - } - } - - const availability = await checkLocalPortAvailable(bindHost, sourcePort); - if (!availability.available) { - return { - success: false, - error: availability.error || "Port is already in use", - }; - } - - const sockets = new Set(); - const server = net.createServer((socket) => { - if (!c2sTunnelRuntimes.get(tunnelName)?.status.connected) { - socket.destroy(); - return; - } - sockets.add(socket); - socket.on("close", () => sockets.delete(socket)); - if (mode === "dynamic") { - handleC2SDynamicConnection( - { ...tunnelConfig, name: tunnelName, mode }, - socket, - authToken, - ); - } else { - handleC2SLocalConnection( - { ...tunnelConfig, name: tunnelName, mode }, - socket, - authToken, - ); - } - }); - - c2sTunnelRuntimes.set(tunnelName, { - server, - sockets, - bindHost, - sourcePort, - status: { connected: false, status: "CONNECTING" }, - }); - - return new Promise((resolve) => { - server.once("error", (error) => { - c2sTunnelRuntimes.delete(tunnelName); - logToFile(`[c2s] failed to listen for ${tunnelName}:`, error.message); - emitC2STunnelStatuses(); - resolve({ success: false, error: error.message }); - }); - server.listen({ host: bindHost, port: sourcePort }, () => { - logToFile( - `[c2s] listening for ${tunnelName} on ${bindHost}:${sourcePort}`, - ); - setC2STunnelStatus(tunnelName, { - connected: false, - status: "CONNECTING", - reason: "Verifying endpoint SSH connection", - }); - - const runtime = c2sTunnelRuntimes.get(tunnelName); - const verifyTunnel = testC2SRelay( - { ...tunnelConfig, name: tunnelName, mode }, - mode === "dynamic" ? undefined : getC2SRemoteAddress(tunnelConfig), - mode === "dynamic" ? undefined : Number(tunnelConfig.endpointPort), - authToken, - runtime, - ); - - verifyTunnel - .then((result) => { - if (c2sTunnelRuntimes.get(tunnelName) !== runtime) return; - if (result.success) { - setC2STunnelStatus(tunnelName, { - connected: true, - status: "CONNECTED", - }); - } else { - setC2STunnelError( - tunnelName, - result.error || "Endpoint SSH connection failed", - ); - } - }) - .catch((error) => { - if (c2sTunnelRuntimes.get(tunnelName) === runtime) { - setC2STunnelError( - tunnelName, - error.message || "Endpoint SSH connection failed", - ); - } - }); - - resolve({ success: true, tunnelName }); - }); - }); -} - -async function stopC2STunnel(tunnelName) { - const runtime = c2sTunnelRuntimes.get(tunnelName); - if (!runtime) { - return { success: true }; - } - - setC2STunnelStatus(tunnelName, { - connected: false, - status: "DISCONNECTING", - }); - runtime.authSocket?.close(); - - return new Promise((resolve) => { - if (typeof runtime.close === "function") { - runtime.close(); - c2sTunnelRuntimes.delete(tunnelName); - emitC2STunnelStatuses(); - resolve({ success: true }); - return; - } - - for (const socket of runtime.sockets || []) { - socket.destroy(); - } - runtime.server?.close(() => { - c2sTunnelRuntimes.delete(tunnelName); - emitC2STunnelStatuses(); - resolve({ success: true }); - }); - }); -} - -function stopAllC2STunnels() { - for (const [tunnelName, runtime] of c2sTunnelRuntimes.entries()) { - try { - runtime.authSocket?.close(); - if (typeof runtime.close === "function") { - runtime.close(); - } else { - for (const socket of runtime.sockets || []) { - socket.destroy(); - } - runtime.server?.close(); - } - } catch (error) { - logToFile(`[c2s] failed to stop tunnel ${tunnelName}:`, error.message); - } - c2sTunnelRuntimes.delete(tunnelName); - } - emitC2STunnelStatuses(); -} - -async function startC2SAutoStartTunnels() { - const configPath = getC2STunnelConfigPath(); - if (!fs.existsSync(configPath)) { - return { success: true, started: 0, errors: [] }; - } - - const config = JSON.parse(fs.readFileSync(configPath, "utf8")); - const tunnels = Array.isArray(config) ? config : []; - const errors = []; - let started = 0; - - for (let index = 0; index < tunnels.length; index += 1) { - const tunnel = tunnels[index]; - if (!tunnel?.autoStart) continue; - const result = await startC2STunnel(tunnel, index); - if (result.success) { - started += 1; - } else { - errors.push(result.error || "Failed to start client tunnel"); - } - } - - return { success: errors.length === 0, started, errors }; -} - -ipcMain.handle("check-local-port-available", async (_event, host, port) => { - const sourcePort = Number(port); - if ( - !host || - !Number.isInteger(sourcePort) || - sourcePort < 1 || - sourcePort > 65535 - ) { - return { available: false, error: "Invalid local bind address or port" }; - } - return checkLocalPortAvailable(host, sourcePort); -}); - -ipcMain.handle("start-c2s-tunnel", async (_event, tunnel, index, authToken) => { - try { - return await startC2STunnel( - tunnel, - Number(index) || 0, - normalizeC2SAuthToken(authToken), - ); - } catch (error) { - return createC2SFailure(error.message, "Failed to start client tunnel"); - } -}); - -ipcMain.handle("test-c2s-tunnel", async (_event, tunnel, index, authToken) => { - try { - return await testC2STunnel( - tunnel, - Number(index) || 0, - normalizeC2SAuthToken(authToken), - ); - } catch (error) { - return createC2SFailure(error.message, "Failed to test client tunnel"); - } -}); - -ipcMain.handle("stop-c2s-tunnel", async (_event, tunnelName) => { - try { - return await stopC2STunnel(tunnelName); - } catch (error) { - return { success: false, error: error.message }; - } -}); - -ipcMain.handle("get-c2s-tunnel-statuses", () => { - return getAllC2STunnelStatuses(); -}); - -ipcMain.handle("start-c2s-autostart-tunnels", async () => { - try { - return await startC2SAutoStartTunnels(); - } catch (error) { - return { success: false, started: 0, errors: [error.message] }; - } -}); - -ipcMain.handle("get-c2s-tunnel-preset-default-name", () => { - const now = new Date(); - const date = now.toISOString().slice(0, 10); - const platform = - process.platform === "darwin" - ? "macOS" - : process.platform === "win32" - ? "Windows" - : "Linux"; - const release = os.release(); - const computerName = os.hostname(); - return `[${date}] ${computerName} (${platform} ${release})`; -}); - function readSetting(key) { const settingsPath = path.join(app.getPath("userData"), "settings.json"); if (!fs.existsSync(settingsPath)) return null; @@ -3277,106 +1896,6 @@ ipcMain.handle("clipboard-write-text", (_event, text) => { ipcMain.handle("clipboard-read-text", () => clipboard.readText()); -ipcMain.handle("local-terminal-start", (event, dimensions = {}) => { - const cols = Math.min(500, Math.max(2, Number(dimensions.cols) || 80)); - const rows = Math.min(300, Math.max(1, Number(dimensions.rows) || 24)); - const sessionId = crypto.randomUUID(); - const shellConfig = resolveLocalShell(process.platform, dimensions.shell); - const spawnShell = () => - pty.spawn(shellConfig.file, shellConfig.args, { - name: "xterm-256color", - cols, - rows, - cwd: os.homedir(), - env: { - ...process.env, - TERM: "xterm-256color", - COLORTERM: "truecolor", - }, - }); - let child; - try { - child = spawnShell(); - } catch (error) { - const ptyDir = path.dirname(require.resolve("node-pty/package.json")); - if ( - !String(error?.message).includes("posix_spawnp") || - repairSpawnHelpers(ptyDir) === 0 - ) { - throw error; - } - logToFile("[local-terminal] restored the spawn-helper execute bit"); - child = spawnShell(); - } - const ownerId = event.sender.id; - const session = { ownerId, process: child, ready: false, buffered: "" }; - localTerminalSessions.set(sessionId, session); - child.onData((data) => { - if (!session.ready) { - session.buffered = (session.buffered + data).slice(-1024 * 1024); - return; - } - if (!event.sender.isDestroyed()) { - event.sender.send(`local-terminal:data:${sessionId}`, data); - } - }); - child.onExit(({ exitCode }) => { - localTerminalSessions.delete(sessionId); - if (!event.sender.isDestroyed()) { - event.sender.send(`local-terminal:exit:${sessionId}`, exitCode); - } - }); - event.sender.once("destroyed", () => closeLocalTerminalsFor(ownerId)); - return { sessionId, shell: shellConfig.file }; -}); - -ipcMain.handle("local-terminal-ready", (event, sessionId) => { - const session = ownedLocalTerminal(event, sessionId); - if (!session) return false; - session.ready = true; - if (session.buffered && !event.sender.isDestroyed()) { - event.sender.send(`local-terminal:data:${sessionId}`, session.buffered); - session.buffered = ""; - } - return true; -}); - -ipcMain.handle("local-terminal-write", (event, sessionId, data) => { - const session = ownedLocalTerminal(event, sessionId); - if (!session || typeof data !== "string" || data.length > 64 * 1024) { - return false; - } - session.process.write(data); - return true; -}); - -ipcMain.handle("local-terminal-resize", (event, sessionId, cols, rows) => { - const session = ownedLocalTerminal(event, sessionId); - const width = Number(cols); - const height = Number(rows); - if ( - !session || - !Number.isInteger(width) || - !Number.isInteger(height) || - width < 2 || - width > 500 || - height < 1 || - height > 300 - ) { - return false; - } - session.process.resize(width, height); - return true; -}); - -ipcMain.handle("local-terminal-close", (event, sessionId) => { - const session = ownedLocalTerminal(event, sessionId); - if (!session) return false; - localTerminalSessions.delete(sessionId); - session.process.kill(); - return true; -}); - ipcMain.handle("show-save-dialog", async (_event, options) => { return dialog.showSaveDialog(mainWindow, options || {}); }); @@ -3387,255 +1906,6 @@ ipcMain.handle("show-open-dialog", async (_event, options) => { return result; }); -function toLocalFileType(stat) { - if (stat.isDirectory()) return "directory"; - if (stat.isFile()) return "file"; - if (stat.isSymbolicLink()) return "link"; - return "other"; -} - -function getLocalEntry(entryPath, name = path.basename(entryPath)) { - const stat = fs.lstatSync(entryPath); - return { - name, - path: entryPath, - type: toLocalFileType(stat), - size: stat.isFile() ? stat.size : 0, - created: stat.birthtime.toISOString(), - modified: stat.mtime.toISOString(), - modifiedTimestamp: stat.mtimeMs, - hidden: name.startsWith("."), - permissions: (stat.mode & 0o777).toString(8).padStart(3, "0"), - owner: String(stat.uid), - group: String(stat.gid), - }; -} - -function normalizeRelativeLocalPath(relativePath) { - return relativePath.split(path.sep).join("/"); -} - -function validateLocalName(name) { - if (typeof name !== "string" || !name.trim()) { - throw new Error("Missing name"); - } - const trimmed = name.trim(); - if ( - trimmed.includes("/") || - trimmed.includes("\\") || - trimmed === "." || - trimmed === ".." - ) { - throw new Error("Name cannot contain path separators"); - } - return trimmed; -} - -function ensureLocalDirectory(dirPath) { - if (typeof dirPath !== "string" || !dirPath) { - throw new Error("Missing directory path"); - } - const stat = fs.statSync(dirPath); - if (!stat.isDirectory()) { - throw new Error("Path is not a directory"); - } -} - -function collectLocalFilesFromPath(rootPath, rootName, files, limit) { - if (files.length >= limit) return; - - const stat = fs.lstatSync(rootPath); - if (stat.isSymbolicLink()) return; - - if (stat.isFile()) { - files.push({ - path: rootPath, - name: path.basename(rootPath), - relativePath: normalizeRelativeLocalPath(rootName), - size: stat.size, - created: stat.birthtime.toISOString(), - modified: stat.mtime.toISOString(), - }); - return; - } - - if (!stat.isDirectory()) return; - - for (const childName of fs.readdirSync(rootPath)) { - if (files.length >= limit) return; - const childPath = path.join(rootPath, childName); - collectLocalFilesFromPath( - childPath, - path.join(rootName, childName), - files, - limit, - ); - } -} - -ipcMain.handle("get-local-home-directory", () => os.homedir()); - -ipcMain.handle("list-local-directory", (_event, dirPath) => { - try { - const requestedPath = - typeof dirPath === "string" && dirPath.trim() ? dirPath : os.homedir(); - const stat = fs.statSync(requestedPath); - if (!stat.isDirectory()) { - return { - success: false, - path: requestedPath, - entries: [], - error: "Path is not a directory", - }; - } - - const entries = fs - .readdirSync(requestedPath, { withFileTypes: true }) - .map((entry) => { - try { - return getLocalEntry( - path.join(requestedPath, entry.name), - entry.name, - ); - } catch { - return null; - } - }) - .filter(Boolean) - .sort((a, b) => { - if (a.type === "directory" && b.type !== "directory") return -1; - if (a.type !== "directory" && b.type === "directory") return 1; - return a.name.localeCompare(b.name); - }); - - return { - success: true, - path: requestedPath, - parent: path.dirname(requestedPath), - entries, - }; - } catch (error) { - return { - success: false, - path: typeof dirPath === "string" ? dirPath : "", - entries: [], - error: error instanceof Error ? error.message : "Failed to list path", - }; - } -}); - -ipcMain.handle("stat-local-paths", (_event, paths) => { - const pathList = Array.isArray(paths) ? paths : []; - return pathList.map((entryPath) => { - try { - return { success: true, ...getLocalEntry(entryPath) }; - } catch (error) { - return { - success: false, - path: entryPath, - error: error instanceof Error ? error.message : "Failed to stat path", - }; - } - }); -}); - -ipcMain.handle("collect-local-files", (_event, paths) => { - try { - const pathList = Array.isArray(paths) ? paths : []; - const files = []; - const limit = 10000; - - for (const entryPath of pathList) { - if (typeof entryPath !== "string" || !entryPath) continue; - collectLocalFilesFromPath( - entryPath, - path.basename(entryPath), - files, - limit + 1, - ); - if (files.length > limit) break; - } - - return { - success: true, - files: files.slice(0, limit), - truncated: files.length > limit, - }; - } catch (error) { - return { - success: false, - files: [], - error: error instanceof Error ? error.message : "Failed to collect files", - }; - } -}); - -ipcMain.handle("create-local-folder", (_event, parentPath, folderName) => { - try { - ensureLocalDirectory(parentPath); - const safeName = validateLocalName(folderName); - const targetPath = path.join(parentPath, safeName); - fs.mkdirSync(targetPath); - return { success: true, ...getLocalEntry(targetPath, safeName) }; - } catch (error) { - return { - success: false, - error: error instanceof Error ? error.message : "Failed to create folder", - }; - } -}); - -ipcMain.handle("rename-local-path", (_event, entryPath, newName) => { - try { - if (typeof entryPath !== "string" || !entryPath) { - throw new Error("Missing path"); - } - const safeName = validateLocalName(newName); - const targetPath = path.join(path.dirname(entryPath), safeName); - renameLocalPath(entryPath, targetPath); - return { success: true, ...getLocalEntry(targetPath, safeName) }; - } catch (error) { - return { - success: false, - error: error instanceof Error ? error.message : "Failed to rename path", - }; - } -}); - -ipcMain.handle("trash-local-path", async (_event, entryPath) => { - try { - if (typeof entryPath !== "string" || !entryPath) { - throw new Error("Missing path"); - } - await shell.trashItem(entryPath); - return { success: true }; - } catch (error) { - return { - success: false, - error: error instanceof Error ? error.message : "Failed to delete path", - }; - } -}); - -ipcMain.handle("chmod-local-path", (_event, entryPath, permissions) => { - try { - if (typeof entryPath !== "string" || !entryPath) { - throw new Error("Missing path"); - } - if (typeof permissions !== "string" || !/^[0-7]{3,4}$/.test(permissions)) { - throw new Error("Permissions must be an octal value"); - } - fs.chmodSync(entryPath, parseInt(permissions, 8)); - return { success: true, ...getLocalEntry(entryPath) }; - } catch (error) { - return { - success: false, - error: - error instanceof Error ? error.message : "Failed to update permissions", - }; - } -}); - ipcMain.handle("create-temp-file", async (_event, fileData) => { try { const result = createManagedTempFile( @@ -3705,63 +1975,6 @@ 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, - fileData?.encoding, - ); - const editId = result.tempId; - const stat = fs.statSync(result.path); - const watcher = fs.watch(result.path, { persistent: false }, () => { - const session = externalEditorSessions.get(editId); - if (!session) return; - if (session.timer) clearTimeout(session.timer); - session.timer = setTimeout(() => notifyExternalEditorSaved(editId), 500); - }); - - externalEditorSessions.set(editId, { - path: result.path, - watcher, - timer: null, - lastMtimeMs: stat.mtimeMs, - }); - - const openError = await openPathWithEditor(result.path, editorPath); - if (openError) { - closeExternalEditorSession(editId); - return { success: false, error: openError }; - } - - return { success: true, editId, path: result.path }; - } catch (error) { - return { success: false, error: error.message }; - } -}); - -ipcMain.handle("close-external-editor", (_event, editId) => { - try { - closeExternalEditorSession(editId); - return { success: true }; - } catch (error) { - return { success: false, error: error.message }; - } -}); - -// Local disk browsing + streamed local<->remote transfers for the file -// manager's dual-pane mode (see electron/local-files.cjs). -registerLocalFileHandlers({ ipcMain, shell }); - function createMenu() { if (process.platform === "darwin") { const template = [ @@ -3841,7 +2054,6 @@ app.whenReady().then(async () => { if (!isDev) { backendPort = await findBackendPort(); - setLocalBackendPort(backendPort); const result = await startBackendServer(); logToFile("startBackendServer result:", result); } else { @@ -3881,13 +2093,9 @@ app.on("before-quit", () => { app.on("will-quit", () => { console.log("App will quit..."); - for (const editId of externalEditorSessions.keys()) { - closeExternalEditorSession(editId); - } for (const tempId of tempFiles.keys()) { cleanupManagedTempFile(tempId); } - stopAllC2STunnels(); stopBackendServer(); }); diff --git a/electron/native-rdp.cjs b/electron/native-rdp.cjs deleted file mode 100644 index 49dd68877..000000000 --- a/electron/native-rdp.cjs +++ /dev/null @@ -1,85 +0,0 @@ -const fs = require("fs"); -const os = require("os"); -const path = require("path"); -const { spawn } = require("child_process"); - -function singleLine(value, maxLength = 255) { - return String(value ?? "") - .replace(/[\r\n\0]/g, "") - .trim() - .slice(0, maxLength); -} - -function validateNativeRdpOptions(options) { - const host = singleLine(options?.host); - const port = Number(options?.port ?? 3389); - if (!host || host.length > 253 || /[\\/]/.test(host)) { - throw new Error("Invalid RDP host"); - } - if (!Number.isInteger(port) || port < 1 || port > 65535) { - throw new Error("Invalid RDP port"); - } - return { - host, - port, - username: singleLine(options?.username), - domain: singleLine(options?.domain), - }; -} - -function buildRdpFile(options) { - const { host, port, username, domain } = validateNativeRdpOptions(options); - const address = host.includes(":") ? `[${host}]:${port}` : `${host}:${port}`; - const qualifiedUsername = username - ? domain - ? `${domain}\\${username}` - : username - : ""; - return [ - `full address:s:${address}`, - "prompt for credentials:i:1", - "administrative session:i:0", - ...(qualifiedUsername ? [`username:s:${qualifiedUsername}`] : []), - "", - ].join("\r\n"); -} - -async function launchNativeRdp(options, platform = process.platform) { - if (platform !== "win32") { - return { - success: false, - error: "Windows Remote Desktop is only available on Windows", - }; - } - - const rdpContent = buildRdpFile(options); - const tempDir = await fs.promises.mkdtemp( - path.join(os.tmpdir(), "termix-rdp-"), - ); - const rdpPath = path.join(tempDir, "connection.rdp"); - await fs.promises.writeFile(rdpPath, rdpContent, { - encoding: "utf8", - mode: 0o600, - }); - - return new Promise((resolve) => { - const child = spawn("mstsc.exe", [rdpPath], { - detached: true, - windowsHide: true, - stdio: "ignore", - }); - const cleanup = () => - fs.promises.rm(tempDir, { recursive: true, force: true }).catch(() => {}); - child.once("error", (error) => { - cleanup(); - resolve({ success: false, error: error.message }); - }); - child.once("spawn", () => { - child.unref(); - setTimeout(cleanup, 30_000).unref(); - resolve({ success: true }); - }); - }); -} - -module.exports = { buildRdpFile, launchNativeRdp, validateNativeRdpOptions }; diff --git a/electron/preload.js b/electron/preload.js index 69eb4a4b0..4ceb8fb2b 100644 --- a/electron/preload.js +++ b/electron/preload.js @@ -32,6 +32,19 @@ contextBridge.exposeInMainWorld("electronAPI", { webFrame.setZoomFactor(Math.min(2, Math.max(0.5, value))); }, getPlatform: () => ipcRenderer.invoke("get-platform"), + + // Whether the app-drawn title bar shows in this window, and when it changes + // (full screen hides it). + getTitleBarVisible: () => ipcRenderer.invoke("get-title-bar-visible"), + onTitleBarVisibleChange: (callback) => { + const listener = (_event, visible) => callback(visible); + ipcRenderer.on("title-bar-visible-changed", listener); + return () => + ipcRenderer.removeListener("title-bar-visible-changed", listener); + }, + // Keeps the window controls the system draws in the colors of the theme. + setTitleBarOverlay: (colors) => + ipcRenderer.invoke("set-title-bar-overlay", colors), getEmbeddedServerStatus: () => ipcRenderer.invoke("get-embedded-server-status"), @@ -58,36 +71,6 @@ contextBridge.exposeInMainWorld("electronAPI", { getSetting: (key) => ipcRenderer.invoke("get-setting", key), setSetting: (key, value) => ipcRenderer.invoke("set-setting", key, value), - answerC2SAuth: (id, answer) => - ipcRenderer.invoke("answer-c2s-auth", id, answer), - onC2SAuthPrompt: (callback) => { - const listener = (_event, prompt) => callback(prompt); - ipcRenderer.on("c2s-auth-prompt", listener); - return () => ipcRenderer.removeListener("c2s-auth-prompt", listener); - }, - getC2STunnelConfig: () => ipcRenderer.invoke("get-c2s-tunnel-config"), - saveC2STunnelConfig: (config) => - ipcRenderer.invoke("save-c2s-tunnel-config", config), - checkLocalPortAvailable: (host, port) => - ipcRenderer.invoke("check-local-port-available", host, port), - getC2STunnelPresetDefaultName: () => - ipcRenderer.invoke("get-c2s-tunnel-preset-default-name"), - startC2STunnel: (tunnel, index, authToken) => - ipcRenderer.invoke("start-c2s-tunnel", tunnel, index, authToken), - testC2STunnel: (tunnel, index, authToken) => - ipcRenderer.invoke("test-c2s-tunnel", tunnel, index, authToken), - stopC2STunnel: (tunnelName) => - ipcRenderer.invoke("stop-c2s-tunnel", tunnelName), - getC2STunnelStatuses: () => ipcRenderer.invoke("get-c2s-tunnel-statuses"), - onC2STunnelStatuses: (callback) => { - const listener = (_event, statuses) => callback(statuses); - ipcRenderer.on("c2s-tunnel-statuses", listener); - return () => ipcRenderer.removeListener("c2s-tunnel-statuses", listener); - }, - startC2SAutoStartTunnels: () => - ipcRenderer.invoke("start-c2s-autostart-tunnels"), - setC2SRelayPath: (relayPath) => - ipcRenderer.invoke("set-c2s-relay-path", relayPath), onCloseActiveTab: (callback) => { const listener = () => callback(); @@ -100,32 +83,8 @@ contextBridge.exposeInMainWorld("electronAPI", { externalBrowserLogin: (authUrl, callbackPort) => ipcRenderer.invoke("external-browser-login", authUrl, callbackPort), - openExternalEditor: (fileData) => - ipcRenderer.invoke("open-external-editor", fileData), - closeExternalEditor: (editId) => - ipcRenderer.invoke("close-external-editor", editId), - onExternalEditorSaved: (callback) => { - const listener = (_event, payload) => callback(payload); - ipcRenderer.on("external-editor-saved", listener); - return () => ipcRenderer.removeListener("external-editor-saved", listener); - }, - showSaveDialog: (options) => ipcRenderer.invoke("show-save-dialog", options), showOpenDialog: (options) => ipcRenderer.invoke("show-open-dialog", options), - getLocalHomeDirectory: () => ipcRenderer.invoke("get-local-home-directory"), - listLocalDirectory: (dirPath) => - ipcRenderer.invoke("list-local-directory", dirPath), - statLocalPaths: (paths) => ipcRenderer.invoke("stat-local-paths", paths), - collectLocalFiles: (paths) => - ipcRenderer.invoke("collect-local-files", paths), - createLocalFolder: (parentPath, folderName) => - ipcRenderer.invoke("create-local-folder", parentPath, folderName), - renameLocalPath: (entryPath, newName) => - ipcRenderer.invoke("rename-local-path", entryPath, newName), - trashLocalPath: (entryPath) => - ipcRenderer.invoke("trash-local-path", entryPath), - chmodLocalPath: (entryPath, permissions) => - ipcRenderer.invoke("chmod-local-path", entryPath, permissions), createTempFile: (fileData) => ipcRenderer.invoke("create-temp-file", fileData), createTempFolder: (folderData) => @@ -134,61 +93,14 @@ contextBridge.exposeInMainWorld("electronAPI", { ipcRenderer.invoke("start-drag-to-desktop", dragData), cleanupTempFile: (tempId) => ipcRenderer.invoke("cleanup-temp-file", tempId), - startLocalTerminal: (dimensions) => - ipcRenderer.invoke("local-terminal-start", dimensions), - writeLocalTerminal: (sessionId, data) => - ipcRenderer.invoke("local-terminal-write", sessionId, data), - readyLocalTerminal: (sessionId) => - ipcRenderer.invoke("local-terminal-ready", sessionId), - resizeLocalTerminal: (sessionId, cols, rows) => - ipcRenderer.invoke("local-terminal-resize", sessionId, cols, rows), - closeLocalTerminal: (sessionId) => - ipcRenderer.invoke("local-terminal-close", sessionId), - onLocalTerminalData: (sessionId, callback) => { - const channel = `local-terminal:data:${sessionId}`; - const listener = (_event, data) => callback(data); - ipcRenderer.on(channel, listener); - return () => ipcRenderer.removeListener(channel, listener); - }, - onLocalTerminalExit: (sessionId, callback) => { - const channel = `local-terminal:exit:${sessionId}`; - const listener = (_event, exitCode) => callback(exitCode); - ipcRenderer.on(channel, listener); - return () => ipcRenderer.removeListener(channel, listener); - }, - - // Dual-pane file manager: local disk browsing and streamed transfers. - localFs: { - home: () => ipcRenderer.invoke("local-fs:home"), - list: (dirPath) => ipcRenderer.invoke("local-fs:list", dirPath), - mkdir: (parentPath, name) => - ipcRenderer.invoke("local-fs:mkdir", parentPath, name), - createFile: (parentPath, name) => - ipcRenderer.invoke("local-fs:create-file", parentPath, name), - rename: (oldPath, newName) => - ipcRenderer.invoke("local-fs:rename", oldPath, newName), - trash: (paths) => ipcRenderer.invoke("local-fs:trash", paths), - ensureDir: (dirPath, rootPath) => - ipcRenderer.invoke("local-fs:ensure-dir", dirPath, rootPath), - exists: (paths) => ipcRenderer.invoke("local-fs:exists", paths), - walk: (paths) => ipcRenderer.invoke("local-fs:walk", paths), - reveal: (targetPath) => ipcRenderer.invoke("local-fs:reveal", targetPath), - open: (targetPath) => ipcRenderer.invoke("local-fs:open", targetPath), - }, - localTransfer: { - setApiPath: (apiPath) => - ipcRenderer.invoke("local-transfer:set-api", apiPath), - upload: (options) => ipcRenderer.invoke("local-transfer:upload", options), - download: (options) => - ipcRenderer.invoke("local-transfer:download", options), - cancel: (transferId) => - ipcRenderer.invoke("local-transfer:cancel", transferId), - onProgress: (callback) => { - const listener = (_event, payload) => callback(payload); - ipcRenderer.on("local-transfer:progress", listener); - return () => - ipcRenderer.removeListener("local-transfer:progress", listener); - }, + // Native web views placed over an element of the page (WebContentsView). + embeddedView: { + open: (options) => ipcRenderer.invoke("embedded-view-open", options), + setBounds: (id, bounds) => + ipcRenderer.invoke("embedded-view-bounds", id, bounds), + setVisible: (id, visible) => + ipcRenderer.invoke("embedded-view-visible", id, visible), + close: (id) => ipcRenderer.invoke("embedded-view-close", id), }, invoke: invokeAllowed, diff --git a/electron/rename-local-path.cjs b/electron/rename-local-path.cjs deleted file mode 100644 index b989985ba..000000000 --- a/electron/rename-local-path.cjs +++ /dev/null @@ -1,22 +0,0 @@ -const fs = require("node:fs"); - -function renameLocalPath(source, target) { - if (source === target) return; - try { - fs.lstatSync(target); - const error = new Error("A file or folder with that name already exists"); - error.code = "EEXIST"; - throw error; - } catch (error) { - if (error.code !== "ENOENT") throw error; - } - if (fs.lstatSync(source).isDirectory()) { - fs.renameSync(source, target); - return; - } - // link is exclusive: a destination created concurrently is never replaced. - fs.linkSync(source, target); - fs.unlinkSync(source); -} - -module.exports = { renameLocalPath }; diff --git a/env.catalog.json b/env.catalog.json index 94988e38d..8993819a3 100644 --- a/env.catalog.json +++ b/env.catalog.json @@ -332,17 +332,6 @@ "default": "10000", "description": "Most audit entries kept. The oldest go first." }, - { - "name": "AUDIT_LOG_FORWARD_URL", - "group": "logging", - "description": "Also POST every audit entry as JSON to this URL. Overrides the admin setting." - }, - { - "name": "AUDIT_LOG_FORWARD_TOKEN", - "group": "logging", - "secret": true, - "description": "Bearer token sent with forwarded audit entries." - }, { "name": "SESSION_RECORDING_RETENTION_DAYS", "group": "logging", @@ -370,6 +359,12 @@ "default": "false", "description": "Only load plugins signed by a trusted key. Blocks uploads of unsigned files even in developer mode." }, + { + "name": "TERMIX_SAFE_LAYOUT", + "group": "plugins", + "default": "false", + "description": "Start everyone in the layout repair view instead of the chosen layout. Use it when a layout plugin breaks the app." + }, { "name": "TERMIX_BUNDLED_PLUGINS_DIR", "group": "plugins", @@ -427,6 +422,12 @@ "internal": true, "description": "Set by the desktop app when it runs its own backend." }, + { + "name": "ELECTRON_USER_DATA", + "group": "desktop", + "internal": true, + "description": "Set by the desktop app to its user data folder." + }, { "name": "EMBEDDED_BACKEND_PORT", "group": "desktop", diff --git a/package-lock.json b/package-lock.json index f0f821eb1..d1782c3af 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "termix", - "version": "26.10.2", + "version": "26.10.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "termix", - "version": "26.10.2", + "version": "26.10.3", "hasInstallScript": true, "workspaces": [ "packages/*" @@ -15058,7 +15058,7 @@ }, "packages/plugin-sdk": { "name": "@termix-ssh/plugin-sdk", - "version": "1.0.11", + "version": "1.0.13", "license": "Apache-2.0", "dependencies": { "@deadendjs/swagger-jsdoc": "^8.1.2", diff --git a/package.json b/package.json index 7942d0a80..84733561a 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,7 @@ "workspaces": [ "packages/*" ], - "version": "26.10.2", + "version": "26.10.3", "description": "Self-hosted, plugin-based server management.", "author": "Karmaa", "main": "electron/main.cjs", @@ -15,10 +15,10 @@ "scripts": { "format": "prettier --write .", "format:check": "prettier --check .", - "postinstall": "node scripts/patch-app-builder-lib.cjs && node scripts/patch-better-sqlite3.cjs && node scripts/patch-nan.cjs && node scripts/patch-node-pty.cjs && node scripts/patch-ssh2-agent.cjs", + "postinstall": "node scripts/patch-app-builder-lib.cjs && node scripts/patch-better-sqlite3.cjs && node scripts/patch-nan.cjs && node scripts/patch-node-pty.cjs && node scripts/patch-ssh2-agent.cjs && node scripts/patch-ssh2-sk.cjs", "prepare": "husky || true", "prebuild": "node scripts/write-electron-build-info.cjs", - "lint": "node scripts/generate-dialect-schema.cjs --check && eslint . && node scripts/check-importmap-csp.cjs && node scripts/check-core-plugin-ids.cjs && node scripts/check-sdk-ui-exports.cjs && node scripts/check-sdk-guide.cjs && node scripts/generate-settings-search-index.cjs --check && node scripts/check-test-locations.cjs && node scripts/check-changelog.cjs && node scripts/check-env-catalog.cjs", + "lint": "node scripts/generate-dialect-schema.cjs --check && eslint . && node scripts/check-importmap-csp.cjs && node scripts/check-core-plugin-ids.cjs && node scripts/check-sdk-ui-exports.cjs && node scripts/check-sdk-guide.cjs && node scripts/check-test-locations.cjs && node scripts/check-changelog.cjs && node scripts/check-env-catalog.cjs", "lint:fix": "eslint --fix .", "type-check": "npm run build:sdk && tsc -b --force", "test": "vitest run", diff --git a/packages/plugin-sdk/CHANGELOG.md b/packages/plugin-sdk/CHANGELOG.md index 08db60eba..4fa91dc9f 100644 --- a/packages/plugin-sdk/CHANGELOG.md +++ b/packages/plugin-sdk/CHANGELOG.md @@ -1,5 +1,107 @@ # Changelog +## 1.0.19 + +Plugin API 1.11.0. + +### Added + +- `app.registerCompanionLayout` and `useCompanions`, so a plugin can let each host turn the panel bar on or off, move it and choose which panels show and in what order + +## 1.0.18 + +Plugin API 1.10.0. + +### Added + +- `clientCert` on the sync link, link patch and remote target, and the `client_cert` remote error kind, so a sync plugin can link to a server that needs mutual TLS + +## 1.0.17 + +Plugin API 1.9.0. + +### Added + +- `app.registerCompanion` and `app.toggleCompanion`, so a plugin can add a panel beside any host tab. The user opens, resizes and moves it, and the tab keeps running +- `NativeView` in `@termix-ssh/plugin-sdk/ui`, which shows a web page in a native view in the desktop app, for sites that do not work in an iframe + +## 1.0.16 + +Plugin API 1.8.0. + +### Added + +- `ctx.sync.provide` and the `sync:provide` capability, so a plugin can answer every /sync request +- `ctx.ssh.transports.provide` and the `ssh:transport` capability, so a plugin can carry SSH connections over its own stream +- `ctx.hosts.folders.ensure` and `removeEmpty` + +### Removed + +- The sync screens and calls in `@termix-ssh/plugin-sdk/ui`: `LinkServerDialog`, `ProxySettingsFields`, `useSyncStatus`, `refreshSyncStatus`, `setSyncStatus`, `getSyncConflicts`, `getSyncErrors`, `retrySyncErrors`, `settleSyncConflict`, `syncNow`, `unlinkServer`, `updateSyncSettings` and their types. They moved into the sync plugin + +## 1.0.15 + +Plugin API 1.7.0. + +### Added + +- `ctx.auth.policy.provide` and the `auth:policy` capability, so a plugin can set an idle timeout, cap session length, turn off remember me and require a second factor +- `ctx.auth.listSecondFactors` lists the second factors from every running plugin +- `AuthEnrollmentSections` takes `factorsOnly` +- `getHttpsSettings` and `updateHttpsSettings` in `@termix-ssh/plugin-sdk/ui` + +### Fixed + +- Plugin frontend tests against a locally linked SDK failed with a second copy of React + +## 1.0.14 + +Plugin API 1.6.0. + +### Added + +- `ctx.hosts.locks.provide` and the `hosts:lock` capability, so a plugin can manage hosts that stay read only for everyone else and block adding hosts by hand +- `ctx.hosts.setShares` to replace every grant on a host in one call +- `ctx.credentials.list` returns the acting user's credentials by name, without secrets +- `ctx.hosts.create` and `update` take `password`, `key`, `keyPassword`, `keyType`, `sudoPassword`, `protocolAuth` and `pluginEnable` + +## 1.0.13 + +Plugin API 1.5.0. + +### Added + +- `runtime: "desktop"` in the manifest for a plugin that only loads on the desktop app +- `desktop:local` and `ctx.desktop.local`: file system, local shell and machine info on the desktop app's own backend +- `ctx.audit.subscribe`, `ctx.audit.list` and `ctx.audit.exportBatches` under `audit:read` +- `ctx.hosts.importMany`, `importSshConfig`, `exportMany` and `registerImportNormalizer`, with the `hosts:export` capability +- `ctx.credentials.publicKey` and `resolveKeyRefs`, `ctx.ssh.exec`, and `@termix-ssh/plugin-sdk/ssh-keys` +- `ctx.hosts.status.provide` and `ctx.hosts.activity.provide` so a plugin can own host status and recent activity, with `ctx.network.tcpProbe`, `ctx.ssh.probe`, `ctx.hosts.probeTargets` and `ctx.hosts.sessionActive` +- `registerHostStatusIndicator` and `registerHostEditorToolbarItem`, and `order` for host badges +- Onboarding steps can sit in a `start` section, take a `position` and `replaces` a core step +- A plugin can provide the app name, tagline and logo through the `.brandingProvider` registry key + +## 1.0.12 + +Plugin API 1.4.0. + +### Added + +- `contributes.layouts` and `app.registerLayout()` for a whole-app layout, with the `ui:layout` capability. Needs `engine.api` 1.4 +- `contributes.themes` for CSS-only themes, with the `ui:theme` capability. `termix-plugin validate` checks theme CSS and `pack` ships it +- `useShell()` gives a layout the live shell: tabs, splits, hosts, docks, the command palette and the registries. The shell's home tab is left out until some plugin registers a `dashboard` tab type +- `TabStage` and `ShellConfirmCapture` in `@termix-ssh/plugin-sdk/ui`, for a layout to place tabs +- `validateThemeCss()` in `@termix-ssh/plugin-sdk/manifest` +- `@termix-ssh/plugin-sdk/ui` also exports what a whole layout is built from: the API client, shared types, hooks, `useShellController()` and `APP_VERSION`. These reach into core, so they can change with a core release +- `@termix-ssh/plugin-sdk/testing-host` exports the registries and permission seam a layout's own tests fill directly +- A plugin that only holds themes needs no source: `termix-plugin build` writes an empty backend and `validate` does not ask for a frontend +- `termix-plugin validate` flags `ui:layout` or `ui:theme` with nothing contributed, and a layout plugin with no frontend + +### Fixed + +- The manifest JSON schema lists the `notify:hub` and `plugins:read` capabilities +- Plugin tests and type checks work with the SDK linked from a local Termix checkout + ## 1.0.11 Plugin API 1.3.0. diff --git a/packages/plugin-sdk/PLUGIN-GUIDE.md b/packages/plugin-sdk/PLUGIN-GUIDE.md index b252da84f..9b2ef1834 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.11 | Plugin API 1.3.0 +SDK 1.0.19 | Plugin API 1.11.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. @@ -164,7 +164,7 @@ Unknown fields are refused at every level, so a typo fails validation. The schem | `icon` | A [Lucide](https://lucide.dev/icons/) icon name, like `Hand`. | | `features` | Up to 20 short lines shown on the plugin's page. | | `docs` | An https link to your docs. | -| `engine.termix` | Termix versions you work with, like `>=26.10.0`. | +| `engine.termix` | Termix versions you work with, like `>=26.10.0`. Older Termix builds don't list a release they can't run. | | `engine.api` | Plugin API range, like `^1.2`. A major bump of the API is breaking. | | `capabilities` | What your code may do. See the reference at the end. | | `dependencies`, `optionalDependencies` | Other plugins, `{ "snippets": "^1.0.0" }`. You must keep working without optional ones. | @@ -175,7 +175,7 @@ Unknown fields are refused at every level, so a typo fails validation. The schem `contributes` holds `tabs`, `panels`, `dashboardCards`, `actions`, `actionSlots`, `permissions`, `settings`, `protocols`, `auth`, `syncEntities`, `keybindingActions`, `http`, `guest`, `guestViews`, -`uiPresets` and `uses`. Frontend `register*` calls need their ids declared here first. +`uiPresets`, `uses`, `layouts` and `themes`. Frontend `register*` calls need their ids declared here first. Capabilities and permissions are different things. A capability (`hosts:read`, with a colon) is what your code may reach. A permission (`hello.use`, with a dot) is what a user may do. @@ -202,36 +202,40 @@ Bundled as ESM for Node 22+ into `dist/backend.js`. Backend imports of your own ### What ctx has -| Member | Capability | What it does | -| ---------------------- | -------------------------------- | -------------------------------------------------------------- | -| `pluginId`, `manifest` | none | Your id and manifest | -| `log` | none | Log lines tagged with your plugin | -| `db` | `db:own` | Your own tables | -| `kv` | `kv:own` | Small key value state | -| `files.dataDir()` | `files:own` | A folder of your own | -| `settings` | none | Your settings | -| `secrets` | `secrets:own` | Encrypted values per user, `seal`/`unseal` for your own tables | -| `http`, `ws` | `network:serve` | Routes and sockets | -| `rbac` | none | Check the actor's permissions | -| `hosts` | `hosts:read`, `hosts:write` | List, read, create, update and share hosts as the actor | -| `ssh` | `ssh:connect`, `credentials:use` | Connect to hosts through Termix's SSH pipeline | -| `credentials` | varies | The actor's saved keys, and protocol logins | -| `services` | per service | Call other plugins and offer your own | -| `events` | `events:core` for core topics | Emit and listen for events | -| `notify` | `notify:send` | Send alerts to the Alerts inbox | -| `fetch` | `network:outbound` | Outbound HTTP with SSRF protection | -| `process` | `process:spawn` | Run programs on the server | -| `schedule` | none | Timers that stop on disable and never overlap | -| `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 | -| `currentActor()` | none | The user the current call runs for | -| `disposables.add(fn)` | none | Extra cleanup on disable | +| Member | Capability | What it does | +| ---------------------- | -------------------------------- | --------------------------------------------------------------- | +| `pluginId`, `manifest` | none | Your id and manifest | +| `log` | none | Log lines tagged with your plugin | +| `db` | `db:own` | Your own tables | +| `kv` | `kv:own` | Small key value state | +| `files.dataDir()` | `files:own` | A folder of your own | +| `settings` | none | Your settings | +| `secrets` | `secrets:own` | Encrypted values per user, `seal`/`unseal` for your own tables | +| `http`, `ws` | `network:serve` | Routes and sockets | +| `rbac` | none | Check the actor's permissions | +| `hosts` | `hosts:read`, `hosts:write` | List, read, create, update and share hosts as the actor | +| `ssh` | `ssh:connect`, `credentials:use` | Connect to hosts through Termix's SSH pipeline | +| `credentials` | varies | The actor's saved keys, and protocol logins | +| `services` | per service | Call other plugins and offer your own | +| `events` | `events:core` for core topics | Emit and listen for events | +| `notify` | `notify:send` | Send alerts to the Alerts inbox | +| `fetch` | `network:outbound` | Outbound HTTP with SSRF protection | +| `process` | `process:spawn` | Run programs on the server | +| `schedule` | none | Timers that stop on disable and never overlap | +| `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 | +| `auth.policy` | `auth:policy` | Login rules: idle timeout, session length, second factors | +| `sync.provide` | `sync:provide` | Answers all /sync requests: server sync, linked desktops | +| `ssh.transports` | `ssh:transport` | Carries a host's SSH connection over your own stream | +| `hosts.folders` | `hosts:write` | Create a folder row with a color and icon, remove it when empty | +| `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 | +| `currentActor()` | none | The user the current call runs for | +| `disposables.add(fn)` | none | Extra cleanup on disable | ### The actor @@ -300,6 +304,14 @@ router.post("/notes", async (req, res) => { - `ctx.rbac.require("manage")` on a single route, `await ctx.rbac.has("manage")` in code. A full id like `hosts.edit` checks a core permission. - Host access: `ctx.hosts.checkAccess(hostId, "connect" | "view" | "edit" | "manage")`. +- Transports: with `ssh:transport`, `ctx.ssh.transports.provide({ claims(hostId), open({ id, ip, port }) })` + returns a stream the SSH client uses instead of a direct, proxy or jump host connection. Core also serves + `/plugin-ws/core/ssh-relay?hostId=` for callers with connect access, so one Termix can relay to another. +- Managed hosts: with `hosts:lock`, `ctx.hosts.locks.provide({ isLocked, note, allowManualCreate })` makes + the hosts you manage read only for the UI, the API and other plugins (pins and order still work), and + `allowManualCreate: () => false` blocks adding hosts by hand. Your own `ctx.hosts` writes still go + through. `ctx.hosts.setShares(hostId, grants)` replaces a host's whole grant list, and + `ctx.credentials.list()` finds the acting user's credentials by name. ### Errors @@ -325,6 +337,24 @@ try { commands. `@termix-ssh/plugin-sdk/host-commands` has helpers to detect the OS and run package manager and sudo commands. Hosts from `ctx.hosts` never carry secrets. Pass the host or its id to `ctx.ssh`. +### The desktop app + +Set `"runtime": "desktop"` in the manifest for a plugin that only makes sense on a desktop app. It never +loads on a server, ignores server sync and may declare `desktop:local`. Any plugin can declare +`desktop:local` and check `ctx.desktop.local.available()` at runtime. + +`ctx.desktop.local` works on the desktop app's own backend only: + +- `info()`: home folder, platform, user data folder, backend port and the linked server (url and token) or null +- `fs`: `list`, `mkdir`, `createFile`, `rename`, `ensureDir`, `exists`, `walk`, `trash`, `reveal`, `open`, + `openRead` and `openDownload`. Downloads stay inside the folder the user picked, links in it are refused and + an existing file is never replaced by accident +- `pty.spawn()`: a local shell, stopped when the plugin stops +- `pluginFetch(origin, subPath, init)`: calls this plugin's own routes on the desktop's backend ("local") or the linked server ("remote") as the acting user. Bodies can be streams, so a plugin can move big files without the renderer + +Use `ctx.audit.subscribe`, `list` and `exportBatches` (`audit:read`) to read the audit trail. Subscribers +are called after a line is accepted and never delay the action that wrote it. + ### Events, timers, alerts, services ```ts @@ -465,27 +495,42 @@ your plugin's error boundary and Suspense. | ---------------------------------------------------------------------------------- | ----------------------------------------------------------------- | | `registerRailItem` | A sidebar rail button | | `registerTab`, `registerPanel` | A tab type or sidebar panel (id in `contributes.tabs` / `panels`) | +| `registerCompanion`, `toggleCompanion` | A panel beside any host tab, bound to its host and handle | +| `registerCompanionLayout` | Per host on/off, side, order and hidden panels for a tab type | | `registerDashboardCard` | A dashboard card (`contributes.dashboardCards`) | | `registerHostAction` | A host button, palette entry or default connect | | `registerHostEditorSection` | Your own host editor section (host settings get one for free) | | `registerHostProtocol` | A connection protocol next to SSH | | `registerHostBadge`, `registerHostContextMenuItem` | Badges and menu items on host rows | +| `registerHostStatusIndicator` | The status dot or stripe on a host row (a layout draws it) | +| `registerHostEditorToolbarItem` | A control in the host editor toolbar (a layout draws it) | | `registerPaletteEntry`, `registerPaletteGroup` | Command palette entries | | `registerKeybindingAction`, `registerKeybindingDefault` | Bindable actions | | `registerSettingsComponent` | A component for a `custom` settings field | | `registerAction`, `invokeAction` | Actions across plugins | | `registerSlotContribution`, `declareActionSlot` | Fill or own a UI slot | | `registerComponent`, `registerExtension` | Offer a component by id, add to an extension point | -| `registerOnboardingStep` | A first run setup step | +| `registerOnboardingStep` | A first run setup step (`section`, `position`, `replaces`) | | `registerLoginMethod`, `registerSecondFactorUI`, `registerSshAuthEditor` | Sign in UI | +| `registerLayout` | A whole-app layout (`contributes.layouts`, API 1.4) | | `api` | Axios client for your `/plugin-api//` routes, auth included | | `fetch(path)`, `wsUrl(path)` | Raw fetch for streams, socket URL and auth | | `docs`, `tabs`, `confirm`, `listHosts`, `getHost`, `t`, `hasPermission`, `desktop` | Helpers | | `onSettingsChanged`, `onDispose` | Hooks | +Badges, status indicators and toolbar items take `{ id, when(host), component, order? }`; lower `order` +comes first. An onboarding step sits in a section (`start`, `setup`, `explore`, `security`) or at an exact +`position` from 1 to 99. `replaces: ["preset"]` lists older step keys the step took over, so people who saw +those are not shown it again. + +Branding: a plugin that owns the app name, tagline and logo provides +`ctx.registry.provide(".brandingProvider", async () => ({ appName, tagline, logo }))` in its backend. +Core reads it for `GET /users/branding` (the login page, the browser tab and the sync server name) and falls +back to "Termix" with no logo when no plugin provides one. `logo` is a PNG data URL or null. + Hooks from `@termix-ssh/plugin-sdk/frontend`: `useTranslation`, `usePermission`, `useSettings`, `useHost`, `useHosts`, `usePluginApi`, `useCurrentUser`, `useTheme`, `useToast`, `useDocsUrl`, `useTabs`, -`useSlotContributions`, `useSshAuthTypes`, `useExtensions`. +`useSlotContributions`, `useSshAuthTypes`, `useExtensions`, `useShell`. Shared from Termix and never bundled: `react`, `react-dom`, `i18next`, `react-i18next`, `sonner` and the SDK's `frontend` and `ui`. Anything else, like `lucide-react`, is bundled. A host your code gets only carries @@ -496,12 +541,100 @@ Slots you can fill include `terminal.toolbar`, `terminal.toolbarStatus`, `termin `hosts.panel`, `credentials.badges`, `shell.overlay`, `shell.rightEdge` and `tab.menu`. See https://docs.termix.site/develop/more-extension-points. +## Layouts and themes + +A plugin can replace the whole app layout or add themes (API 1.4). Users pick both in Settings, Appearance; +admins set the default for everyone. + +A layout is declared in `contributes.layouts` (`id`, `label`, optional `description` and `defaultTheme`) and +needs the `ui:layout` capability, which admins see as high risk because a layout draws every screen. Register +it in `activate`: + +```tsx +import { useShell, type TermixApp } from "@termix-ssh/plugin-sdk/frontend"; +import { + ShellConfirmCapture, + SurfaceScope, + TabStage, +} from "@termix-ssh/plugin-sdk/ui"; + +function MyLayout() { + const shell = useShell(); + if (!shell) return null; + return ( +
+
+ {/* tabs from shell.tabs.topLevel, shell.palette.open() and so on */} +
+ + + + +
+ ); +} + +export function activate(app: TermixApp) { + app.registerLayout({ + id: "my-layout", + label: "layout.name", + component: MyLayout, + }); +} +``` + +- `useShell()` is the live shell with nothing drawn: tabs, splits, hosts, the left and right docks, the + command palette, the registries as hooks and the user. Core keeps all the state, so switching layouts keeps + every session. +- `TabStage` is where tabs show. Place it anywhere; tabs are never remounted when it moves, so a terminal + keeps its connection. Wrap it in `SurfaceScope kind="tab"` with a `ShellConfirmCapture` inside, so + close questions and inline views show over the tab area. Overlays and onboarding stay in core. +- The layout draws the command palette. Core keeps whether it is open (`palette.isOpen`, Ctrl+K calls + `palette.open()`) and what is in it (`usePaletteEntries`, `usePaletteGroups`, rail items, hosts). +- The shell always keeps a home tab with the id and type `dashboard`. `useShell()` leaves it out until some + plugin registers a `dashboard` tab type, so a layout without a home screen has no empty tab to draw. +- Settings, Manage and the host editor belong to Classic Layout. A layout without them should still render + `LookSettings` somewhere so people can switch back. `tabs.openSingleton("plugins")` opens core's own + Plugins tab in any layout. Switching layouts closes the tabs the old layout's plugin declared. +- `mobileComponent` is used on phones and narrow windows. The desktop app always gets `component`. +- Only the chosen layout's plugin is loaded. If it fails to load or crashes twice, Termix shows a repair + screen where people can switch, reset, update, turn it off or (for a bundled plugin) restore the copy that + shipped. `?safe-layout=1` opens it any time. +- Termix has no layout of its own. The default is the bundled Classic Layout plugin + (https://github.com/Termix-SSH/Plugin-Classic-Layout), which is also a full example. It owns the rail, + sidebar, tab bar, docks, Dashboard, Manage, Settings and command palette, registers the `dashboard`, + `host-manager` and `settings` tab types, and offers the host list and dashboard as the components + `classic-layout.hostTree` and `classic-layout.dashboard` for other plugins to place with `PluginComponent`. + It reads the shell with `useShellController()` from `@termix-ssh/plugin-sdk/ui`, which exposes more than + `useShell()` and may change with Termix. A third party layout should start from `useShell()`. + +A theme is CSS only. Declare it in `contributes.themes` (`id`, `label`, `mode` light or dark, `css` path, +optional `swatch` colors for the picker) with the `ui:theme` capability. The id becomes a class on ``, +so the file may only hold rules whose selectors start with `.`, `:root.` or `html.`, optionally +inside `@media` or `@supports`. `@import`, `url()` to another host and other at-rules are refused by +`termix-plugin validate` and by the server. Set the same tokens core's themes set (`--background`, +`--foreground`, `--card`, `--border`, `--muted` and the rest). + +A plugin that only holds themes needs no code. Leave out `src/`: `termix-plugin build` writes an empty backend and +`validate` does not ask for a frontend. Keep the CSS in `themes/` and a test that checks it with +`validateThemeCss()`. Notes: + +- A theme with the id of a built-in (`light` or `dark`) replaces core's basic copy while the plugin runs. +- The first theme a bundled default-look plugin lists is the default for new installs. +- Themes show in the picker in the order core lists its own, then the plugin's order. +- A theme someone saved that no longer exists shows as System. It comes back when the plugin does. +- Accent color, UI font, font size and density are not part of a theme. Core applies them on top. +- Bundled Classic Themes (https://github.com/Termix-SSH/Plugin-Classic-Themes) is a full example. + ## Looking native Build UI from `@termix-ssh/plugin-sdk/ui` so it matches Termix and follows the theme. The full list of exports with props is `ui.api.txt` in the Termix repo (https://github.com/Termix-SSH/Termix/blob/main/packages/plugin-sdk/ui.api.txt). +- Web pages that break in an iframe: `NativeView` (props `url`, `ignoreCert`, `hidden`, `fallback`) shows the + page in a native view in the desktop app. It fills its element, hides while a dialog or menu is open and renders + `fallback` in a browser. - Square: panels, cards and containers have no rounded corners. Small controls may stay rounded. - Flat: borders, not shadows. Use theme tokens like `border-border`, `bg-background`, `text-muted-foreground`, `text-destructive`. One accent, `text-accent-brand`. Don't add colors. @@ -578,6 +711,21 @@ closely. Declare ids in `contributes.auth` and register them with `ctx.auth.regi `registerSecondFactor` or `registerSshAuthProvider`. Connection protocols go in `contributes.protocols` and `app.registerHostProtocol`. Read https://docs.termix.site/develop/auth before starting one. +Login rules need `auth:policy`. `ctx.auth.policy.provide({ evaluate, allowRememberDevice })` is asked at +every login with the user, the method, the device and every second factor (`ctx.auth.listSecondFactors()` +lists them too). `evaluate` returns `allowRememberDevice`, `maxSessionSeconds`, `idleTimeoutSeconds` and +`secondFactor` (`"always"` or `"new-device"`). The strictest answer from all plugins wins, and a throw +refuses the login. A user who must have a second factor but has none gets a session that can only set one +up, and `/users/me` reports `restriction: "second-factor-setup"`. + +Sync is answered by one plugin. `sync:provide` is high risk: `ctx.sync.provide({ handle })` takes over every +request under `/sync` (an Express style handler, 404 when nothing provides it), and `ctx.sync.host` hands +over the neutral parts core keeps: the replica store (`replica`), the change feed and push applier +(`feed`), the linked server list (`links`, one per local user), desktop accounts (`accounts`), sessions +(`sessions`), host lookups, plugin mirroring and the proxy settings the desktop app needs. A test passes its +own `syncHost` to `createMockCtx`. An API key can ask `POST /users/ws-ticket/api-key` for a one minute +ticket that opens plugin sockets as its user, which is how one server opens sessions through another. + ## Testing ```ts @@ -742,58 +890,70 @@ To run your own registry instead, see https://docs.termix.site/develop/registrie ## SDK entry points -| Import | What it is | -| -------------------------------------------------------------------- | --------------------------------------------------------------------- | -| `@termix-ssh/plugin-sdk/backend` | `PluginContext` and the backend types | -| `@termix-ssh/plugin-sdk/frontend` | `TermixApp`, `TabProps` and the hooks | -| `@termix-ssh/plugin-sdk/ui` | The UI kit, served by Termix at runtime | -| `@termix-ssh/plugin-sdk/db` | `defineTable` and column builders | -| `@termix-ssh/plugin-sdk/manifest` | `PluginManifest` type and manifest checks | -| `@termix-ssh/plugin-sdk/capabilities` | The capability catalog | -| `@termix-ssh/plugin-sdk/settings` | Settings field validation | -| `@termix-ssh/plugin-sdk/testing` | `createMockCtx`, `createTestDb`, `renderWithApp`, `createFakeContext` | -| `@termix-ssh/plugin-sdk/vitest-preset` | `pluginVitestConfig` | -| `@termix-ssh/plugin-sdk/host-commands` | OS detection, package manager and sudo helpers over ssh2 | -| `@termix-ssh/plugin-sdk/ssh-certs` | `applyCertificateAuth()` for certificate based SSH auth | -| `@termix-ssh/plugin-sdk/desktop` | The desktop app bridge, typed. Check it exists before use | -| `@termix-ssh/plugin-sdk/openapi` | `buildOpenApi()` | -| `@termix-ssh/plugin-sdk/changelog` | Changelog parsing and checks | -| `@termix-ssh/plugin-sdk/docs` | Docs link and env var helpers | -| `@termix-ssh/plugin-sdk/ddl`, `@termix-ssh/plugin-sdk/table-builder` | Table to SQL and Drizzle mapping. You rarely need these | +| Import | What it is | +| -------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------- | +| `@termix-ssh/plugin-sdk/backend` | `PluginContext` and the backend types | +| `@termix-ssh/plugin-sdk/frontend` | `TermixApp`, `TabProps` and the hooks | +| `@termix-ssh/plugin-sdk/ui` | The UI kit, served by Termix at runtime | +| `@termix-ssh/plugin-sdk/db` | `defineTable` and column builders | +| `@termix-ssh/plugin-sdk/manifest` | `PluginManifest` type and manifest checks | +| `@termix-ssh/plugin-sdk/capabilities` | The capability catalog | +| `@termix-ssh/plugin-sdk/settings` | Settings field validation | +| `@termix-ssh/plugin-sdk/testing` | `createMockCtx`, `createTestDb`, `renderWithApp`, `createFakeContext` | +| `@termix-ssh/plugin-sdk/vitest-preset` | `pluginVitestConfig` | +| `@termix-ssh/plugin-sdk/host-commands` | OS detection, package manager and sudo helpers over ssh2 | +| `@termix-ssh/plugin-sdk/ssh-certs` | `applyCertificateAuth()` for certificate based SSH auth | +| `@termix-ssh/plugin-sdk/ssh-keys` | `generateSshKeyPair()`, `detectSshKeyType()`, `publicKeyFromPrivate()`, `validateSshKeyPair()` | +| `@termix-ssh/plugin-sdk/desktop` | The desktop app bridge, typed. Check it exists before use | +| `@termix-ssh/plugin-sdk/openapi` | `buildOpenApi()` | +| `@termix-ssh/plugin-sdk/changelog` | Changelog parsing and checks | +| `@termix-ssh/plugin-sdk/docs` | Docs link and env var helpers | +| `@termix-ssh/plugin-sdk/ddl`, `@termix-ssh/plugin-sdk/table-builder` | Table to SQL and Drizzle mapping. You rarely need these | ## 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 | 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 | +| 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 | +| `auth:policy` | high | Set login rules: idle timeout, session length, 2FA | +| `sync:provide` | high | Run sync: answer every /sync request | +| `device:serial` | high | Open serial and USB ports on the server | +| `desktop:local` | high | Use the user's own computer, desktop app only | +| `hosts:export` | high | Export hosts with their saved logins | +| `notify:hub` | high | Receive every alert (the alerts hub only) | +| `ui:layout` | high | Replace the whole app layout | +| `hosts:write` | medium | Add and edit hosts | +| `hosts:status-provide` | medium | Be the source of host status dots | +| `hosts:activity-provide` | medium | Be the source of the recent activity list | +| `ssh:transport` | high | Carry SSH connections for hosts it claims over its own stream | +| `hosts:lock` | medium | Make hosts it manages read only for everyone else | +| `network:probe` | medium | Check that a host answers on a TCP port | +| `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 | +| `ui:theme` | low | Add color themes, CSS only | 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 3d84a2eff..b9d754180 100644 --- a/packages/plugin-sdk/cli/commands/build.mjs +++ b/packages/plugin-sdk/cli/commands/build.mjs @@ -5,7 +5,12 @@ import { fileURLToPath } from "node:url"; import * as esbuild from "esbuild"; import { BACKEND_EXTERNALS, FRONTEND_EXTERNALS } from "../lib/externals.mjs"; import { staticUrlImports } from "../lib/static-url-imports.mjs"; -import { readManifest, resolveEntry, copyDir } from "../lib/plugin-dir.mjs"; +import { + readManifest, + resolveEntry, + copyDir, + isThemeOnly, +} from "../lib/plugin-dir.mjs"; import { openapi } from "./openapi.mjs"; import { splitByCoreClasses } from "../lib/css-classes.mjs"; @@ -232,32 +237,41 @@ export async function build({ cwd }) { fs.mkdirSync(outDir, { recursive: true }); const backendEntry = resolveEntry(cwd, BACKEND_ENTRIES); - if (!backendEntry) { + if (!backendEntry && !isThemeOnly(manifest)) { throw new Error( `${pluginId}: no backend entry (looked for ${BACKEND_ENTRIES.join(", ")})`, ); } - await esbuild.build({ - entryPoints: [backendEntry], - outfile: path.join(outDir, "backend.js"), - bundle: true, - format: "esm", - platform: "node", - target: "node22", - sourcemap: true, - logLevel: "warning", - // A native dependency's compiled .node binary is resolved by the - // package's own relative paths, which break once esbuild inlines its JS - // elsewhere. Declaring it in nativeDependencies keeps it a real - // 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: REQUIRE_BANNER }, - }); + if (backendEntry) { + await esbuild.build({ + entryPoints: [backendEntry], + outfile: path.join(outDir, "backend.js"), + bundle: true, + format: "esm", + platform: "node", + target: "node22", + sourcemap: true, + logLevel: "warning", + // A native dependency's compiled .node binary is resolved by the + // package's own relative paths, which break once esbuild inlines its JS + // elsewhere. Declaring it in nativeDependencies keeps it a real + // 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: REQUIRE_BANNER }, + }); + } else { + // Core loads every plugin through a backend entry, so a theme only + // plugin gets an empty one. + fs.writeFileSync( + path.join(outDir, "backend.js"), + "export function activate() {}\n", + ); + } for (const worker of readWorkerEntries(cwd, pluginId)) { await esbuild.build({ diff --git a/packages/plugin-sdk/cli/commands/pack.mjs b/packages/plugin-sdk/cli/commands/pack.mjs index 73115b12f..4092cf9a2 100644 --- a/packages/plugin-sdk/cli/commands/pack.mjs +++ b/packages/plugin-sdk/cli/commands/pack.mjs @@ -1,7 +1,12 @@ import fs from "node:fs"; import path from "node:path"; import { readManifest } from "../lib/plugin-dir.mjs"; -import { collectFiles, createTmxplug } from "../lib/tmxplug.mjs"; +import { + collectFiles, + createTmxplug, + themeFiles, + TMXPLUG_CONTENTS, +} from "../lib/tmxplug.mjs"; import { sha256 } from "../lib/signing.mjs"; import { validate } from "./validate.mjs"; @@ -32,7 +37,12 @@ export async function pack({ cwd, args = [] }) { fs.mkdirSync(outDir, { recursive: true }); const outfile = path.join(outDir, `${pluginId}-${version}.tmxplug`); - const archive = createTmxplug(cwd, collectFiles(cwd)); + const archive = createTmxplug( + cwd, + collectFiles(cwd, [ + ...new Set([...TMXPLUG_CONTENTS, ...themeFiles(manifest)]), + ]), + ); fs.writeFileSync(outfile, archive); console.log(`packed ${path.basename(outfile)}`); diff --git a/packages/plugin-sdk/cli/commands/validate.mjs b/packages/plugin-sdk/cli/commands/validate.mjs index 10b423f2a..58c069339 100644 --- a/packages/plugin-sdk/cli/commands/validate.mjs +++ b/packages/plugin-sdk/cli/commands/validate.mjs @@ -1,7 +1,7 @@ import fs from "node:fs"; import path from "node:path"; import { fileURLToPath } from "node:url"; -import { readManifest } from "../lib/plugin-dir.mjs"; +import { readManifest, isThemeOnly } from "../lib/plugin-dir.mjs"; /** * The manifest rules live in src/manifest.ts, which the server uses too, so @@ -17,7 +17,7 @@ export async function loadSdkModule(name) { export async function validate({ cwd }) { const raw = readManifest(cwd); - const { parseManifest } = await loadSdkModule("manifest"); + const { parseManifest, validateThemeCss } = await loadSdkModule("manifest"); const { errors } = parseManifest(raw); const problems = [...errors]; @@ -25,7 +25,8 @@ export async function validate({ cwd }) { // The manifest names files. They have to be there. const referenced = [ raw.backend ?? "dist/backend.js", - raw.frontend ?? "dist/frontend.js", + // A theme only plugin has no frontend code. + ...(isThemeOnly(raw) ? [] : [raw.frontend ?? "dist/frontend.js"]), raw.locales ?? "locales", ]; for (const rel of referenced) { @@ -34,6 +35,8 @@ export async function validate({ cwd }) { } } + problems.push(...validateThemeFiles(cwd, raw, validateThemeCss)); + problems.push(...validateLookCapabilities(cwd, raw)); problems.push( ...(await validateMigrations(cwd, raw.id ?? path.basename(cwd))), ); @@ -53,6 +56,63 @@ export async function validate({ cwd }) { console.log(`ok ${raw.id ?? path.basename(cwd)}`); } +/** Every declared theme file exists and holds only CSS a theme may hold. */ +export function validateThemeFiles(cwd, raw, validateThemeCss) { + const problems = []; + const themes = raw?.contributes?.themes; + if (!Array.isArray(themes)) return problems; + for (const theme of themes) { + if (typeof theme?.css !== "string" || typeof theme?.id !== "string") { + continue; + } + const file = path.join(cwd, theme.css); + if (!fs.existsSync(file)) { + problems.push(`theme ${theme.id}: ${theme.css} does not exist`); + continue; + } + for (const error of validateThemeCss( + fs.readFileSync(file, "utf8"), + theme.id, + )) { + problems.push(`theme ${theme.id}: ${error}`); + } + } + return problems; +} + +/** + * ui:layout and ui:theme must match what the manifest contributes, both + * ways. The manifest check already flags a contribution without its + * capability; this flags a capability with nothing behind it, and a layout + * with no frontend to register it. + */ +export function validateLookCapabilities(cwd, raw) { + const problems = []; + const capabilities = Array.isArray(raw?.capabilities) ? raw.capabilities : []; + const layouts = raw?.contributes?.layouts; + const themes = raw?.contributes?.themes; + const hasLayouts = Array.isArray(layouts) && layouts.length > 0; + const hasThemes = Array.isArray(themes) && themes.length > 0; + if (capabilities.includes("ui:layout") && !hasLayouts) { + problems.push('"ui:layout" is declared but contributes.layouts is empty'); + } + if (capabilities.includes("ui:theme") && !hasThemes) { + problems.push('"ui:theme" is declared but contributes.themes is empty'); + } + if (hasLayouts) { + const entries = ["index.tsx", "index.ts", "index.mjs", "index.js"]; + const frontend = entries.some((entry) => + fs.existsSync(path.join(cwd, "src", "frontend", entry)), + ); + if (!frontend && raw.frontend === undefined) { + problems.push( + "contributes.layouts needs a frontend that calls app.registerLayout", + ); + } + } + return problems; +} + /** Env vars core sets for everyone, so a plugin need not list them. */ const CORE_ENV = new Set([ "DATA_DIR", diff --git a/packages/plugin-sdk/cli/lib/plugin-dir.mjs b/packages/plugin-sdk/cli/lib/plugin-dir.mjs index 427543c8f..93920d78b 100644 --- a/packages/plugin-sdk/cli/lib/plugin-dir.mjs +++ b/packages/plugin-sdk/cli/lib/plugin-dir.mjs @@ -14,6 +14,17 @@ export function readManifest(cwd) { } /** The first of these that exists, or null. */ +/** Only themes: CSS files and no code, so no source entry is needed. */ +export function isThemeOnly(manifest) { + const capabilities = manifest.capabilities ?? []; + return ( + capabilities.length > 0 && + capabilities.every((capability) => capability === "ui:theme") && + Array.isArray(manifest.contributes?.themes) && + manifest.contributes.themes.length > 0 + ); +} + export function resolveEntry(cwd, candidates) { for (const candidate of candidates) { const full = path.join(cwd, candidate); diff --git a/packages/plugin-sdk/cli/lib/tmxplug.mjs b/packages/plugin-sdk/cli/lib/tmxplug.mjs index 6c14d4fdc..d12e14385 100644 --- a/packages/plugin-sdk/cli/lib/tmxplug.mjs +++ b/packages/plugin-sdk/cli/lib/tmxplug.mjs @@ -2,6 +2,15 @@ import fs from "node:fs"; import path from "node:path"; import zlib from "node:zlib"; +/** Theme CSS files a manifest declares, which travel with the plugin too. */ +export function themeFiles(manifest) { + const themes = manifest?.contributes?.themes; + if (!Array.isArray(themes)) return []; + return themes + .map((theme) => theme?.css) + .filter((css) => typeof css === "string"); +} + /** What a .tmxplug holds, in the order the spec lists it. */ export const TMXPLUG_CONTENTS = [ "manifest.json", diff --git a/packages/plugin-sdk/package.json b/packages/plugin-sdk/package.json index ca163ae7f..113a45031 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.11", + "version": "1.0.19", "description": "The contract between Termix core and a plugin: types, the capability catalog and the manifest schema.", "license": "Apache-2.0", "repository": { @@ -85,6 +85,10 @@ "types": "./dist/ssh-certs.d.ts", "default": "./dist/ssh-certs.js" }, + "./ssh-keys": { + "types": "./dist/ssh-keys.d.ts", + "default": "./dist/ssh-keys.js" + }, "./tsconfig.plugin.json": "./tsconfig.plugin.json", "./schema/manifest.schema.json": "./schema/manifest.schema.json", "./ui": { diff --git a/packages/plugin-sdk/schema/manifest.schema.json b/packages/plugin-sdk/schema/manifest.schema.json index 4ef59dd72..ee22422e2 100644 --- a/packages/plugin-sdk/schema/manifest.schema.json +++ b/packages/plugin-sdk/schema/manifest.schema.json @@ -82,7 +82,11 @@ "features": { "type": "array", "maxItems": 20, - "items": { "type": "string", "minLength": 1, "maxLength": 160 }, + "items": { + "type": "string", + "minLength": 1, + "maxLength": 160 + }, "description": "Short lines listing what the plugin does, shown on its page." }, "docs": { @@ -98,11 +102,24 @@ "additionalProperties": false, "required": ["name", "description"], "properties": { - "name": { "type": "string", "pattern": "^[A-Z][A-Z0-9_]{0,63}$" }, - "description": { "type": "string", "minLength": 1, "maxLength": 300 }, - "default": { "type": "string" }, - "required": { "type": "boolean" }, - "secret": { "type": "boolean" } + "name": { + "type": "string", + "pattern": "^[A-Z][A-Z0-9_]{0,63}$" + }, + "description": { + "type": "string", + "minLength": 1, + "maxLength": 300 + }, + "default": { + "type": "string" + }, + "required": { + "type": "boolean" + }, + "secret": { + "type": "boolean" + } } } }, @@ -134,9 +151,13 @@ "process:spawn", "users:write", "auth:provide", + "auth:policy", + "sync:provide", + "ssh:transport", "system:tls", "device:serial", "hosts:write", + "hosts:export", "credentials:use", "credentials:write", "network:outbound", @@ -146,7 +167,12 @@ "events:core", "notify:send", "audit:read", + "network:probe", + "hosts:status-provide", + "hosts:activity-provide", + "hosts:lock", "desktop:window", + "desktop:local", "users:impersonate", "db:core-refs", "hosts:read", @@ -155,7 +181,11 @@ "files:own", "secrets:own", "settings:read-core", - "ui:surface" + "ui:surface", + "notify:hub", + "plugins:read", + "ui:layout", + "ui:theme" ] } }, @@ -944,7 +974,9 @@ "defaultLevels": { "type": "array", "minItems": 1, - "items": { "enum": ["admin", "user", "folder"] }, + "items": { + "enum": ["admin", "user", "folder"] + }, "description": "Levels that may set a default for the field. All three by default." }, "personal": { @@ -1127,6 +1159,86 @@ "type": "string", "pattern": "^[A-Za-z0-9-]+(\\.[A-Za-z0-9-]+)+$" } + }, + "layouts": { + "type": "array", + "description": "Whole-app layouts the frontend registers with app.registerLayout. Needs the ui:layout capability.", + "items": { + "type": "object", + "additionalProperties": false, + "required": ["id", "label"], + "properties": { + "id": { + "type": "string", + "pattern": "^[a-z][a-z0-9-]{1,39}$" + }, + "label": { + "type": "string", + "minLength": 1, + "description": "i18n key in the plugin namespace, or plain text." + }, + "description": { + "type": "string", + "minLength": 1 + }, + "defaultTheme": { + "type": "string", + "pattern": "^[a-z][a-z0-9-]{1,39}$", + "description": "Theme picked with this layout when nobody chose one." + }, + "default": { + "type": "boolean", + "description": "The layout a default-look plugin starts with. At most one." + } + } + } + }, + "themes": { + "type": "array", + "description": "Themes: one CSS file each with token rules scoped under .. Needs the ui:theme capability.", + "items": { + "type": "object", + "additionalProperties": false, + "required": ["id", "label", "mode", "css"], + "properties": { + "id": { + "type": "string", + "pattern": "^[a-z][a-z0-9-]{1,39}$" + }, + "label": { + "type": "string", + "minLength": 1 + }, + "mode": { + "type": "string", + "enum": ["light", "dark"] + }, + "css": { + "type": "string", + "pattern": ".css$", + "description": "Path of the CSS file inside the plugin." + }, + "swatch": { + "type": "object", + "additionalProperties": false, + "required": ["background", "panel", "text"], + "properties": { + "background": { + "type": "string", + "pattern": "^#[0-9a-fA-F]{3,8}$" + }, + "panel": { + "type": "string", + "pattern": "^#[0-9a-fA-F]{3,8}$" + }, + "text": { + "type": "string", + "pattern": "^#[0-9a-fA-F]{3,8}$" + } + } + } + } + } } } }, @@ -1158,6 +1270,11 @@ "enum": ["mirror", "local", "server"], "description": "How a desktop linked to a server treats this plugin: mirror follows the server, local is decided on each desktop, server never runs on a linked desktop. Defaults to mirror." }, + "runtime": { + "type": "string", + "enum": ["desktop"], + "description": "desktop: the plugin only loads on a desktop app's own embedded backend, never on a server. It implies desktop local and may declare desktop:local." + }, "nativeDependencies": { "type": "array", "minItems": 1, diff --git a/packages/plugin-sdk/src/backend.ts b/packages/plugin-sdk/src/backend.ts index e0d27c189..bf1ccfab6 100644 --- a/packages/plugin-sdk/src/backend.ts +++ b/packages/plugin-sdk/src/backend.ts @@ -236,6 +236,448 @@ export interface SyncEntityRegistration { */ export interface PluginSync { registerEntity: (entity: SyncEntityRegistration) => void; + /** Needs sync:provide. Takes over /sync/v2 and /sync/link. Removed on deactivate. */ + provide: (provider: PluginSyncProvider) => () => void; + /** Needs sync:provide. The replica store, change feed and link registry. */ + readonly host: PluginSyncHost; +} + +/** + * The plugin that owns server and desktop sync. Core forwards every request + * under /sync to `handle` (an Express style handler) and answers 404 when no + * plugin provides it. Call `next()` to let core answer instead. + */ +export interface PluginSyncProvider { + handle: ( + req: unknown, + res: unknown, + next: (error?: unknown) => void, + ) => void | Promise; +} + +/** A kind of data that syncs, as the sync provider sees it. */ +export interface SyncEntityInfo { + type: string; + owner: string; + order: number; + readOnly: boolean; + /** Whether the row would show in a load even if it exists. */ + covers?: (syncId: string) => boolean; +} + +/** A row ready for the wire, with its content hash. */ +export interface SyncWireRow { + syncId: string; + wire: SyncRow; + hash: string; +} + +export interface SyncRecordInfo { + entityType: string; + syncId: string; + revision: number; + hash: string | null; + deleted: boolean; + error: string | null; + errorHash: string | null; + seq: number; +} + +export interface SyncConflictInfo { + id: number; + entityType: string; + syncId: string; + localRow: string; + createdAt: string; +} + +export interface SyncAccountInfo { + id: string; + username: string; + isAdmin: boolean; + active: boolean; +} + +export interface SyncProxyHeader { + name: string; + value: string; +} + +export interface SyncBasicAuth { + username: string; + password: string; +} + +/** A client certificate for a server that asks for mutual TLS. */ +export interface SyncClientCert { + /** Base64 of the PFX/P12 file. */ + pfx: string; + passphrase: string; + /** The file name, to show which certificate is saved. */ + name: string | null; +} + +/** Extra per server connection options. Kept open so new ones need no migration. */ +export type SyncLinkOptions = Record; + +/** One server a desktop is linked to, with a replica of its data. */ +export interface SyncLinkInfo { + id: number; + /** The local user the replica belongs to. */ + userId: string; + serverUrl: string; + serverName: string | null; + serverVersion: string | null; + sessionToken: string | null; + customHeaders: SyncProxyHeader[]; + basicAuth: SyncBasicAuth | null; + allowInvalidCertificate: boolean; + clientCert: SyncClientCert | null; + remoteUserId: string | null; + remoteUsername: string | null; + account: { + remoteUserId?: string; + username?: string; + isAdmin?: boolean; + roles?: string[]; + permissions?: string[]; + } | null; + disabledTypes: string[]; + /** Extra connection options for this server, kept open for new ones. */ + options: SyncLinkOptions; + knownTypes: string[]; + cursor: number; + status: "idle" | "syncing" | "offline" | "error" | "signed_out"; + lastError: string | null; + linkedAt: string; + lastSyncAt: string | null; +} + +export interface SyncLinkCreate { + userId: string; + serverUrl: string; + serverName?: string | null; + serverVersion?: string | null; + sessionToken: string; + customHeaders?: SyncProxyHeader[]; + basicAuth?: SyncBasicAuth | null; + allowInvalidCertificate?: boolean; + clientCert?: SyncClientCert | null; + remoteUserId?: string | null; + remoteUsername?: string | null; +} + +export type SyncLinkPatch = Partial< + Pick< + SyncLinkInfo, + | "serverName" + | "serverVersion" + | "sessionToken" + | "customHeaders" + | "basicAuth" + | "allowInvalidCertificate" + | "clientCert" + | "remoteUserId" + | "remoteUsername" + | "account" + | "disabledTypes" + | "options" + | "knownTypes" + | "cursor" + | "status" + | "lastError" + | "lastSyncAt" + > +>; + +export type SyncRemoteErrorKind = + | "offline" + | "tls" + | "client_cert" + | "proxy" + | "basic_auth" + | "signed_out" + | "server"; + +export interface SyncRemoteError extends Error { + readonly kind: SyncRemoteErrorKind; + readonly status?: number; +} + +export interface SyncRemoteTarget { + serverUrl: string; + sessionToken?: string | null; + customHeaders?: SyncProxyHeader[]; + basicAuth?: SyncBasicAuth | null; + allowInvalidCertificate?: boolean; + clientCert?: SyncClientCert | null; +} + +export interface SyncRemoteRequest { + method?: string; + body?: unknown; + timeoutMs?: number; + signal?: AbortSignal; +} + +export interface SyncPushOp { + entityType: string; + syncId: string; + baseRevision: number; + op: "upsert" | "delete"; + row?: SyncRow; +} + +export type SyncPushResult = + | { syncId: string; entityType: string; status: "ok"; revision: number } + | { + syncId: string; + entityType: string; + status: "conflict"; + revision: number; + deleted: boolean; + row: SyncRow | null; + } + | { syncId: string; entityType: string; status: "rejected"; reason: string }; + +export interface SyncRemotePlugin { + id: string; + name: string; + version: string; + source: string; + artifact: boolean; + enabled: boolean; + active: boolean; + desktop: string; + syncEntities: string[]; + granted: string[]; +} + +/** + * What the sync provider gets from core: the replica store, the change feed, + * the linked server registry, sessions and a few helpers. Core keeps all of + * it neutral; the provider decides when and how to use it. Needs sync:provide. + */ +export interface PluginSyncHost { + /** True on the desktop app's own backend. */ + readonly embedded: boolean; + info: { appName: () => Promise; version: () => string }; + + entities: { + list: () => SyncEntityInfo[]; + get: (type: string) => SyncEntityInfo | undefined; + /** Registers core's own entities once. */ + ensureCore: () => Promise; + onChanged: (listener: () => void) => () => void; + onLocalWrite: (listener: () => void) => () => void; + }; + + /** Rows and bookkeeping of one local user's replica. */ + replica: { + resolvers: (userId: string, onMissing?: () => void) => unknown; + loadRows: ( + type: string, + userId: string, + resolvers: unknown, + ) => Promise<{ rows: Map; failed: Set }>; + loadRow: ( + type: string, + userId: string, + syncId: string, + resolvers: unknown, + ) => Promise; + writeRow: ( + type: string, + userId: string, + wire: SyncRow, + resolvers: unknown, + ) => Promise; + deleteRow: (type: string, userId: string, syncId: string) => Promise; + orderSelfReferences: (type: string, rows: SyncRow[]) => SyncRow[]; + counts: (userId: string) => Promise>; + wipe: (userId: string) => Promise; + regenerateSyncIds: (userId: string) => Promise; + removeSharedCopies: (userId: string) => Promise; + records: { + list: (userId: string) => Promise>; + put: ( + userId: string, + type: string, + syncId: string, + values: { revision: number; hash: string | null; deleted: boolean }, + ) => Promise; + setError: ( + userId: string, + type: string, + syncId: string, + reason: string, + hash: string, + ) => Promise; + clearErrors: (userId: string) => Promise; + listErrors: (userId: string) => Promise; + deleteForUser: (userId: string) => Promise; + key: (type: string, syncId: string) => string; + }; + conflicts: { + save: ( + userId: string, + type: string, + syncId: string, + row: SyncRow, + revision: number, + ) => Promise; + list: (userId: string) => Promise; + get: (userId: string, id: number) => Promise; + delete: (userId: string, id: number) => Promise; + }; + }; + + /** The server side: what changed for an account and what a desktop pushed. */ + feed: { + withUserLock: (userId: string, run: () => Promise) => Promise; + changes: ( + userId: string, + cursor: number, + limit: number, + types?: string[], + ) => Promise<{ + records: Array<{ + seq: number; + entityType: string; + syncId: string; + revision: number; + deleted: boolean; + row: SyncRow | null; + }>; + hasMore: boolean; + nextCursor: number; + entityTypes: Array<{ + type: string; + owner: string; + order: number; + readOnly: boolean; + }>; + }>; + parsePushOps: (body: unknown) => Promise; + applyPush: (userId: string, ops: SyncPushOp[]) => Promise; + /** Streams a change notice to the account's open event streams. */ + listen: (userId: string, res: unknown) => Promise<() => void>; + }; + + /** + * The linked server registry, kept in core so it is there before plugins. + * One link per local user. Without a user, the active account is meant. + */ + links: { + list: () => Promise; + get: (userId?: string) => Promise; + create: (link: SyncLinkCreate) => Promise; + update: ( + patch: SyncLinkPatch, + userId?: string, + ) => Promise; + remove: (userId?: string) => Promise; + }; + + /** Desktop accounts: the local user and the replica user of each server. */ + accounts: { + list: () => Promise; + /** A new empty account, ready to be linked to a server. */ + create: () => Promise; + /** Which account the app signs in as from now on. */ + setActive: (userId: string) => Promise; + activeId: () => Promise; + /** Deletes an account and all its data. The last one cannot go. */ + remove: (userId: string) => Promise; + }; + + http: { + json: ( + target: SyncRemoteTarget, + pathname: string, + request?: SyncRemoteRequest, + ) => Promise; + fetch: ( + target: SyncRemoteTarget, + pathname: string, + request?: SyncRemoteRequest, + ) => Promise<{ + ok: boolean; + status: number; + body: AsyncIterable | null; + }>; + normalizeServerUrl: (input: string) => string | null; + isRemoteError: (error: unknown) => error is SyncRemoteError; + }; + + sessions: { + /** A long lived session for a linked desktop. Shows in the account's sessions. */ + issueDesktop: ( + userId: string, + options: { days: number; deviceInfo: string }, + ) => Promise; + revoke: (sessionId: string) => Promise; + find: ( + sessionId: string, + ) => Promise<{ expiresAt: string; deviceInfo: string | null } | null>; + findUser: ( + userId: string, + ) => Promise<{ id: string; username: string } | null>; + /** The user and session behind a token, or null when it is not valid. */ + verify: ( + token: string, + ) => Promise<{ userId: string; sessionId?: string } | null>; + /** Sets the sign in cookie on a response, like a login does. A name sets another cookie. */ + setCookie: ( + req: unknown, + res: unknown, + value: string, + options?: { name?: string; days?: number }, + ) => void; + /** Removes the sign in cookie, without ending the session behind it. */ + clearCookie: (req: unknown, res: unknown, name?: string) => void; + /** Express middleware that signs the request in and sets userId and sessionId. */ + middleware: () => unknown; + /** Express middleware that lets a request through only on a desktop. */ + desktopOnly: () => unknown; + }; + + hosts: { + findForSync: ( + userId: string, + syncId: string, + ) => Promise<{ id: number; name: string | null } | null>; + statusBySyncId: (userId: string) => Promise>; + }; + + plugins: { + listForDesktop: () => Promise<{ + plugins: SyncRemotePlugin[]; + registryKeys: unknown; + }>; + /** Sends a plugin archive; false when there is no such plugin to share. */ + sendPackage: (pluginId: string, res: unknown) => Promise; + mirror: (link: SyncLinkInfo) => Promise; + lastRemote: () => SyncRemotePlugin[]; + localNames: () => Map; + }; + + desktop: { + /** Tells the app's main process how to reach the linked origin. */ + applyProxy: (link: SyncLinkInfo | null) => Promise; + deviceName: () => string; + }; + + alerts: { + send: (alert: { + title: string; + body: string; + severity: "info" | "warning" | "error"; + category: string; + dedupeKey: string; + link?: { tab: string }; + }) => Promise; + }; } /** @@ -698,6 +1140,36 @@ export interface PluginHostCreateInput { sshOptions?: HostSshOptions | null; /** Host settings by plugin id, the shape a Termix export carries. */ pluginSettings?: Record>; + /** + * Each plugin's own host switch (its manifest enableKey), by plugin id. + * A plugin that is not running is skipped and logged; one with no switch + * is refused. + */ + pluginEnable?: Record; + /** + * Secrets, stored encrypted with the owner's data key and never returned + * in a PluginHostRecord. An empty string or null clears the stored value. + */ + password?: string | null; + key?: string | null; + keyPassword?: string | null; + keyType?: string | null; + sudoPassword?: string | null; + /** + * Logins for plugin protocols (contributes.protocols), keyed by protocol + * id. null removes that protocol's login. Unknown protocol ids are skipped. + */ + protocolAuth?: Record; +} + +/** One protocol login on a host write. Absent keys keep what is stored. */ +export interface PluginHostProtocolAuthInput { + authType?: "direct" | "credential" | "none"; + credentialId?: number | null; + username?: string | null; + password?: string | null; + /** The protocol's declared credentialFields, secret ones included. */ + fields?: Record; } /** Fields a plugin may change on a host the acting user owns. */ @@ -725,6 +1197,13 @@ export const PLUGIN_HOST_INPUT_KEYS = [ "forceKeyboardInteractive", "sshOptions", "pluginSettings", + "pluginEnable", + "password", + "key", + "keyPassword", + "keyType", + "sudoPassword", + "protocolAuth", ] as const satisfies readonly (keyof PluginHostCreateInput)[]; /** The same shape canAccessHost returns internally, without secrets. */ @@ -761,6 +1240,34 @@ export interface PluginShareableRole { displayName: string | null; } +/** One grant in ctx.hosts.setShares. */ +export interface PluginHostShareGrant { + target: PluginShareTarget; + permissionLevel: PluginHostShareLevel; + /** Omit for a grant that never expires. */ + durationHours?: number; +} + +/** + * Hosts a plugin manages itself, for example from a config file. Core refuses + * edits, deletes and share changes on a locked host from the UI, the API and + * other plugins, and marks it in host reads so a layout can show it read only. + * Pins and order stay with the user. Only the plugin that locks a host may + * still write it. + */ +export interface PluginHostLockProvider { + isLocked: (hostId: number) => boolean; + /** A short line shown under the managed note, or null. */ + note: (hostId: number) => string | null; + /** False blocks adding hosts by hand (editor, import, other plugins). */ + allowManualCreate: () => boolean; +} + +export interface PluginHostLocksApi { + /** Registers this plugin's locks. Needs hosts:lock. Removed on deactivate. */ + provide: (provider: PluginHostLockProvider) => () => void; +} + /** * Hosts the acting user can see, and the RBAC + sharing operations a plugin * needs to build a feature (like fleets) on top of hosts it does not own. @@ -823,6 +1330,15 @@ export interface PluginHosts { permissionLevel: PluginHostShareLevel, durationHours?: number, ) => Promise; + /** + * Replaces every grant on a host the acting user owns with this list. + * Grants that stay keep their row and counters, missing ones are revoked. + * Needs hosts:write. + */ + setShares: ( + hostId: number, + grants: PluginHostShareGrant[], + ) => Promise; /** Users the caller may pick as a share target, for building a picker UI. */ listUsers: () => Promise; /** Non-system roles the caller may pick as a share target. */ @@ -848,6 +1364,182 @@ export interface PluginHosts { * Needs hosts:read. */ status: PluginHostStatusApi; + /** + * The hosts the acting user may connect to (their own and shared ones) as a + * status provider needs them: address, port and jump hosts, no secrets. + * Pass hostIds to narrow it. Needs hosts:read. + */ + probeTargets: (filter?: { + hostIds?: number[]; + }) => Promise; + /** Whether a session is open to the host right now. Needs hosts:read. */ + sessionActive: (hostId: number) => boolean; + /** + * Imports Termix JSON host rows for the acting user, the same way POST + * /host/bulk-import does: up to 100 rows, jump hosts remapped by exportId, + * defaults, protocol logins and plugin settings applied, one save at the end. + * Needs hosts:write, plus credentials:write when credentials are passed, and + * the acting user needs the core hosts.create and hosts.edit permissions. + */ + importMany: ( + hosts: Record[], + options?: PluginHostImportOptions, + ) => Promise; + /** Imports the Host entries of an OpenSSH config file. Same rules as importMany. */ + importSshConfig: ( + content: string, + options?: Pick, + ) => Promise; + /** + * The acting user's own hosts as Termix JSON. Without share the secrets are + * included, decrypted. With share every secret is left out and credential + * logins point at a scrubbed credentials list by alias. Needs hosts:export + * and is always audited. + */ + exportMany: (options?: { + share?: boolean; + /** Only these of the user's hosts. */ + hostIds?: number[]; + }) => Promise; + /** + * Validates and writes this plugin's own host settings from an imported + * row. Called for every imported row once the host exists. Return the + * values to write, or null to write nothing. Removed when the plugin + * deactivates. Needs hosts:write. + */ + registerImportNormalizer: ( + normalizer: PluginHostImportNormalizer, + ) => () => void; + /** The recent activity list on the dashboard. */ + activity: PluginHostActivityApi; + /** Read only hosts this plugin manages. */ + locks: PluginHostLocksApi; + /** Folder rows for the acting user. Needs hosts:write. */ + folders: PluginHostFoldersApi; +} + +export interface PluginHostFoldersApi { + /** Creates the folder row if missing and sets its color and icon. */ + ensure: ( + name: string, + style?: { color?: string | null; icon?: string | null }, + ) => Promise; + /** Removes the folder row and subfolders once no host is left in them. */ + removeEmpty: (name: string) => Promise; +} + +export interface PluginHostImportOptions { + /** Update a host that matches on ip, port and username instead of skipping it. */ + overwrite?: boolean; + /** + * All or nothing: if any row fails, the hosts this call created are deleted + * and the result says rolledBack. Cannot be combined with overwrite. + */ + atomic?: boolean; + /** Credential placeholders rows can refer to by credentialAlias. */ + credentials?: Array>; +} + +export interface PluginHostImportResult { + success: number; + updated: number; + skipped: number; + failed: number; + errors: string[]; + createdHostIds: number[]; + rolledBack?: boolean; +} + +export interface PluginHostExport { + hosts: Array>; + /** Share exports only. */ + version?: string; + exportedAt?: string; + credentials?: Array>; +} + +export type PluginHostImportNormalizer = ( + raw: Record, +) => Record | null; + +export interface PluginHostProbeTarget { + id: number; + /** The user who owns the host; its jump hosts are theirs. */ + ownerUserId: string; + ip: string; + port: number; + connectionType: string; + jumpHosts: Array<{ hostId: number }>; + statusCheckEnabled: boolean; + /** Seconds, or null to use the global interval. */ + statusCheckInterval: number | null; +} + +/** + * The plugin that owns host status. Core keeps the routes, the sync feed and + * ctx.hosts.status; this fills them. One provider at a time. + */ +export interface PluginHostStatusProvider { + start: () => void; + stop: () => void; + get: (hostId: number) => PluginHostStatusEntry | null; + /** + * Every status known, after polling is brought in line with the request: + * with hostIds, exactly those of the user's hosts; without, all of them. + */ + statusesFor: ( + userId: string, + hostIds: number[] | null, + ) => Promise>; + /** Restarts the user's checks. */ + refresh: (userId: string) => Promise; + check: (hostId: number) => Promise; + reportLogin: (hostId: number, outcome: { ok: boolean }) => void; + registerPort: ( + connectionType: string, + resolve: ( + hostId: number, + ) => number | undefined | Promise, + ) => () => void; + /** The global check interval in seconds. */ + getIntervalSeconds: () => Promise; + setIntervalSeconds: (seconds: number) => Promise; +} + +export interface PluginActivityEntry { + id: number; + userId: string; + type: string; + hostId: number; + hostName: string | null; + timestamp: string; +} + +export type PluginRecordActivityResult = + | { status: "logged"; id: number } + | { status: "rate_limited" } + | { status: "invalid_type" } + | { status: "denied" } + | { status: "unavailable" }; + +/** The plugin that stores recent activity. One provider at a time. */ +export interface PluginActivityProvider { + record: ( + userId: string, + entry: { type: string; hostId: number; hostName: string }, + ) => Promise; + list: (userId: string, limit: number) => Promise; + deleteByUserId: (userId: string) => Promise; + deleteByHostIds: (hostIds: number[]) => Promise; +} + +export interface PluginHostActivityApi { + /** + * Becomes the store behind the dashboard's recent activity and + * ctx.hosts.recordActivity. Needs hosts:activity-provide. Removed on + * deactivate. + */ + provide: (provider: PluginActivityProvider) => () => void; } /** @@ -896,6 +1588,11 @@ export interface PluginHostStatusApi { hostId: number, ) => number | undefined | Promise, ) => () => void; + /** + * Becomes core's host status. Needs hosts:status-provide. Removed on + * deactivate, which hands status back to core's built-in checks. + */ + provide: (provider: PluginHostStatusProvider) => () => void; } /** @@ -1060,6 +1757,25 @@ export interface PluginSshConnectOptions { sock?: unknown; } +export interface PluginSshExecOptions extends PluginSshConnectOptions { + /** Time to wait for the command to finish. Default 30s, at most 10 minutes. */ + timeoutMs?: number; + /** Most bytes kept of each of stdout and stderr. Default 256 KB, at most 4 MB. */ + maxOutputBytes?: number; + /** Written to the command's standard input, then closed. */ + stdin?: string; +} + +export interface PluginSshExecResult { + stdout: string; + stderr: string; + /** The exit code. Null when the command was killed or ran out of time. */ + code: number | null; + /** Output went past maxOutputBytes and the rest was dropped. */ + truncated: boolean; + timedOut: boolean; +} + export interface PluginSshConnection { /** An ssh2 Client, already "ready". */ client: Client; @@ -1079,12 +1795,69 @@ export interface PluginSshConnection { * audit line per new connection. Connections still open on deactivate are * closed. */ +export interface PluginNetwork { + /** + * Whether a TCP port answers. An SSH banner is read and answered politely; + * nothing is ever sent that logs in. Loopback, private and link-local + * addresses are allowed, since status checks target the user's own hosts. + */ + tcpProbe: ( + host: string, + port: number, + options?: { timeoutMs?: number }, + ) => Promise; +} + +/** + * A way to reach some hosts other than a plain TCP connection. `claims` is + * asked for every connection, so it must be quick. `open` returns a stream + * the SSH client runs over, like a relayed socket. + */ +export interface PluginSshTransportProvider { + claims: (hostId: number) => boolean; + open: (host: { + id: number; + ip: string; + port: number; + }) => Promise; +} + +export interface PluginSshTransports { + /** Needs ssh:transport. Removed on deactivate. */ + provide: (provider: PluginSshTransportProvider) => () => void; +} + export interface PluginSsh { + /** Other ways to reach hosts, used by every connection to them. */ + transports: PluginSshTransports; + + /** + * Whether a TCP port answers through the host's jump host chain. Chains are + * shared and closed when idle. The acting user must be able to connect to + * the host. Needs ssh:connect. + */ + probe: ( + hostId: number, + options?: { port?: number; timeoutMs?: number }, + ) => Promise; + connect: ( host: number | PluginSshHost, options?: PluginSshConnectOptions, ) => Promise>; + /** + * Runs one command on a host with its stored login and closes the + * connection. The acting user needs connect access to the host, the same as + * connect. Output is capped and the command is stopped at the time limit. + * Needs ssh:connect and credentials:use. + */ + exec: ( + host: number | PluginSshHost, + command: string, + options?: PluginSshExecOptions, + ) => Promise; + /** Borrows a pooled connection. The pool key is `::
`. */ withConnection: ( host: number | PluginSshHost, @@ -1464,6 +2237,54 @@ export interface PluginSecondFactor { reset?: (userId: string) => Promise; } +/** A login core is about to turn into a session. */ +export interface PluginAuthPolicyInput { + userId: string; + /** Login method id, `"proxy"` for a trusted proxy login. */ + methodId: string; + /** The method signs in through an outside identity provider (SSO, LDAP). */ + external: boolean; + deviceType: "web" | "desktop" | "mobile"; + /** The device passed a second factor before and was remembered. */ + trustedDevice: boolean; + /** The method itself proved a second factor, like a verified passkey. */ + mfaSatisfied: boolean; + /** Every second factor core has, and whether this user set it up. */ + secondFactors: { id: string; pluginId: string; enrolled: boolean }[]; +} + +export interface PluginAuthPolicyDecision { + /** False turns remember me off and ignores remembered devices. */ + allowRememberDevice?: boolean; + /** Caps how long the session lasts. */ + maxSessionSeconds?: number; + /** Ends the session after this many seconds without a request. */ + idleTimeoutSeconds?: number; + /** + * Requires a second factor even for users who have none: they get a + * session that can only set one up. `"new-device"` skips it on a + * remembered device. + */ + secondFactor?: "always" | "new-device"; +} + +/** + * Login rules from a plugin. Core asks every provider and the strictest + * answer wins. A provider that throws refuses the login. + */ +export interface PluginAuthPolicyProvider { + evaluate: ( + input: PluginAuthPolicyInput, + ) => PluginAuthPolicyDecision | Promise; + /** Whether the login page should offer remember me. */ + allowRememberDevice?: () => boolean; +} + +export interface PluginAuthPolicyApi { + /** Needs auth:policy. Removed on deactivate. */ + provide: (provider: PluginAuthPolicyProvider) => () => void; +} + export interface PluginUserTokenOptions { /** Why the token exists, kept in the audit log and on the session. */ purpose: string; @@ -1553,6 +2374,10 @@ export interface PluginAuth { userId: string, options: PluginUserTokenOptions, ) => Promise; + /** Login rules: idle timeout, session length, remember me, second factors. */ + policy: PluginAuthPolicyApi; + /** Second factors from every running plugin. Needs no capability. */ + listSecondFactors: () => { id: string; pluginId: string }[]; } /** What a plugin may learn about a user. Needs users:read. */ @@ -1603,6 +2428,148 @@ export interface PluginDesktop { ) => Promise<{ success: boolean; error?: string }>; /** Whether the server runs embedded in the desktop app, so the calls above can work. */ available: () => boolean; + /** + * The user's own machine. Only works on the desktop app's embedded backend + * and needs desktop:local; every call is audited once per plugin start. + */ + readonly local: PluginDesktopLocal; +} + +export interface PluginLocalDirEntry { + name: string; + path: string; + type: "file" | "directory" | "link"; + size: number; + modifiedTimestamp?: number; + linkTarget?: string; + hidden: boolean; +} + +export interface PluginLocalWalkResult { + files: Array<{ localPath: string; relativePath: string; size: number }>; + emptyDirs: string[]; + totalBytes: number; +} + +export interface PluginLocalPty { + readonly pid: number; + /** The shell program that was started. */ + readonly shell: string; + write: (data: string) => void; + resize: (cols: number, rows: number) => void; + kill: () => void; + onData: (handler: (data: string) => void) => () => void; + onExit: (handler: (exitCode: number) => void) => () => void; +} + +/** A download being written next to its final name, published when done. */ +export interface PluginLocalDownload { + /** Final path inside the chosen folder, links inside it already refused. */ + readonly path: string; + readonly stream: NodeJS.WritableStream; + /** Moves the finished partial onto its final name. */ + publish: (options?: { overwrite?: boolean }) => Promise; + /** Removes the partial file. Safe to call after publish. */ + abort: () => Promise; +} + +export interface PluginLocalResponse { + readonly status: number; + readonly ok: boolean; + readonly headers: { get: (name: string) => string | null }; + /** A web stream of the body, or null when there is none. */ + readonly body: AsyncIterable | null; + text: () => Promise; +} + +export interface PluginDesktopLocal { + /** Whether local access is possible: embedded backend and capability granted. */ + available: () => boolean; + /** + * Calls this plugin's own HTTP routes on the embedded backend ("local") or + * on the server the desktop is linked to ("remote"), as the acting user. + * The path is under /plugin-api/; nothing else is + * reachable. The link's proxy headers and certificate rules apply. A body + * may be a string, a buffer or a Node stream. + */ + pluginFetch: ( + origin: "local" | "remote", + subPath: string, + init?: { + method?: string; + headers?: Record; + body?: string | Buffer | NodeJS.ReadableStream; + signal?: AbortSignal; + }, + ) => Promise; + info: () => Promise<{ + home: string; + separator: string; + platform: NodeJS.Platform; + /** The desktop app's user data folder. */ + userDataDir: string; + /** The port the embedded backend listens on. */ + backendPort: number; + /** The server this desktop is linked to, or null. */ + link: { + serverUrl: string; + token: string | null; + /** + * Headers for a request to the linked server: the session, plus the + * proxy headers and basic auth the link was set up with. + */ + headers: Record; + /** The link accepts a self-signed certificate. */ + allowInvalidCertificate: boolean; + } | null; + }>; + /** + * GET a JSON route on the linked server with the link's session, proxy + * headers and certificate rules. Rejects with an error whose `kind` is + * "signed_out" when the server refused the session. + */ + linkJson: (pathname: string) => Promise; + readonly fs: { + list: (dirPath: string) => Promise<{ + path: string; + parent: string | null; + entries: PluginLocalDirEntry[]; + }>; + mkdir: (parentPath: string, name: string) => Promise; + createFile: (parentPath: string, name: string) => Promise; + /** Renames inside the same folder; never replaces an existing name. */ + rename: (path: string, newName: string) => Promise; + /** Creates a folder; with rootPath it must stay inside that folder. */ + ensureDir: (dirPath: string, rootPath?: string) => Promise; + /** Which of the given paths exist. */ + exists: (paths: string[]) => Promise; + walk: (rootPaths: string[]) => Promise; + /** Sends entries to the operating system's trash. */ + trash: (paths: string[]) => Promise<{ + trashed: number; + failed: Array<{ path: string; error: string }>; + }>; + reveal: (path: string) => Promise; + open: (path: string) => Promise; + openRead: (path: string) => Promise<{ + stream: NodeJS.ReadableStream; + size: number; + }>; + openDownload: (options: { + rootPath: string; + destPath: string; + transferId: string; + overwrite?: boolean; + }) => Promise; + }; + readonly pty: { + spawn: (options?: { + cols?: number; + rows?: number; + /** "wsl" on Windows starts WSL instead of PowerShell. */ + shell?: string; + }) => Promise; + }; } /** @@ -1648,6 +2615,15 @@ export interface PluginSshKeyCredential { publicKey: string | null; } +/** A saved credential without its secrets. */ +export interface PluginCredentialSummary { + id: number; + name: string; + username: string | null; + authType: string; + folder: string | null; +} + /** A key pair to save as a new credential for the acting user. */ export interface PluginSshKeyCredentialInput { name: string; @@ -1669,6 +2645,12 @@ export interface PluginCredentials { * Needs credentials:use and the core credentials.view permission. Audited. */ listSshKeys: () => Promise; + /** + * The acting user's own credentials by name, with no secret material, so a + * plugin can point a host at one. Needs credentials:use and the core + * credentials.view permission. Audited. + */ + list: () => Promise; /** * Saves a key pair as a new encrypted credential owned by the acting user * and returns its id. Needs credentials:write, the core credentials.create @@ -1699,6 +2681,23 @@ export interface PluginCredentials { scheme: string, resolve: (userId: string, reference: string) => Promise, ) => void; + /** + * The public key line of an SSH key credential the acting user can see + * (their own, with the core credentials.view permission). Derived from the + * private key when none is stored, including a key kept behind a secret + * reference. Null when the credential is missing, is not a key, or cannot + * be read. Needs credentials:use. Audited. + */ + publicKey: (credentialId: number) => Promise; + /** + * Replaces secret references ("op://...") in a key and its passphrase with + * the real values, for the acting user. Plain values pass through. Hands + * private key material to the plugin. Needs credentials:read. Audited. + */ + resolveKeyRefs: ( + key: string, + passphrase?: string | null, + ) => Promise<{ key: string; passphrase?: string }>; } /** @@ -1721,6 +2720,57 @@ export interface PluginAudit { */ request?: unknown; }) => Promise; + /** + * Called for every audit line core or any plugin writes, after it is + * accepted. Fire and forget: a slow or throwing handler never delays or + * fails the audited action. Lines written before the plugin started are not + * replayed; read them with list or export. Returns an unsubscribe function; + * it is also removed when the plugin deactivates. Needs audit:read. + */ + subscribe: (handler: (entry: PluginAuditEntry) => void) => () => void; + /** A page of stored lines, newest first. Needs audit:read. */ + list: (options?: { + filters?: PluginAuditFilters; + limit?: number; + offset?: number; + }) => Promise<{ entries: PluginAuditEntry[]; total: number }>; + /** + * Stored lines in time order, in batches, so an export never holds the + * whole trail in memory. Needs audit:read. + */ + exportBatches: ( + filters?: PluginAuditFilters, + batchSize?: number, + ) => AsyncGenerator; +} + +export interface PluginAuditFilters { + userId?: string; + action?: string; + resourceType?: string; + success?: boolean; + /** ISO date-time. */ + startDate?: string; + /** ISO date-time. */ + endDate?: string; +} + +export interface PluginAuditEntry { + /** Only on stored lines (list and export). */ + id?: number; + /** ISO date-time. */ + timestamp: string; + userId: string | null; + username: string; + action: string; + resourceType: string; + resourceId: string | null; + resourceName: string | null; + details: string | null; + ipAddress: string | null; + userAgent: string | null; + success: boolean; + errorMessage: string | null; } /** A notification channel a user set up. Its config never leaves the hub. */ @@ -2066,6 +3116,8 @@ export interface PluginContext { readonly notify: PluginNotify; /** Outbound HTTP through core's SSRF guard. Needs network:outbound. */ readonly fetch: PluginFetch; + /** Plain reachability checks. Needs network:probe. */ + readonly network: PluginNetwork; /** Programs on the Termix server. Needs process:spawn. */ readonly process: PluginProcess; /** The server certificate. Needs system:tls. */ diff --git a/packages/plugin-sdk/src/capabilities.ts b/packages/plugin-sdk/src/capabilities.ts index 58f7cb93e..e03def44c 100644 --- a/packages/plugin-sdk/src/capabilities.ts +++ b/packages/plugin-sdk/src/capabilities.ts @@ -47,20 +47,30 @@ export const CAPABILITY_CATALOG: readonly CapabilityInfo[] = [ entry("users:impersonate", "high"), entry("db:core-refs", "high"), entry("auth:provide", "high"), + entry("auth:policy", "high"), + entry("sync:provide", "high"), + entry("ssh:transport", "high"), entry("device:serial", "high"), entry("notify:hub", "high"), + entry("ui:layout", "high"), entry("hosts:write", "medium"), + entry("hosts:export", "high"), entry("credentials:use", "medium"), entry("credentials:write", "medium"), entry("network:outbound", "medium"), entry("network:serve", "medium"), entry("network:broadcast", "medium"), + entry("network:probe", "medium"), + entry("hosts:status-provide", "medium"), + entry("hosts:activity-provide", "medium"), + entry("hosts:lock", "medium"), entry("users:read", "medium"), entry("events:core", "medium"), entry("notify:send", "medium"), entry("audit:read", "medium"), entry("desktop:window", "medium"), + entry("desktop:local", "high"), entry("hosts:read", "low"), entry("db:own", "low"), @@ -70,6 +80,7 @@ export const CAPABILITY_CATALOG: readonly CapabilityInfo[] = [ entry("settings:read-core", "low"), entry("plugins:read", "low"), entry("ui:surface", "low"), + entry("ui:theme", "low"), ] as const; export const CAPABILITY_IDS: readonly string[] = CAPABILITY_CATALOG.map( diff --git a/packages/plugin-sdk/src/db.ts b/packages/plugin-sdk/src/db.ts index 918ef7285..823789c8f 100644 --- a/packages/plugin-sdk/src/db.ts +++ b/packages/plugin-sdk/src/db.ts @@ -256,6 +256,7 @@ export const LEGACY_TABLE_OWNERS: Readonly> = { homepage_items: "homepage", homepage_layouts: "homepage", dashboard_service_links: "homepage", + recent_activity: "homepage", secret_sources: "secret-sources", opkssh_tokens: "opkssh", vault_profiles: "vault", diff --git a/packages/plugin-sdk/src/desktop.ts b/packages/plugin-sdk/src/desktop.ts index 86e73e633..811bee573 100644 --- a/packages/plugin-sdk/src/desktop.ts +++ b/packages/plugin-sdk/src/desktop.ts @@ -3,8 +3,6 @@ * only inside the Termix desktop app; check isElectron before calling it. */ -import type { PluginSshPromptRequest } from "./backend.js"; - export interface ServerConfig { serverUrl?: string; allowInvalidCertificate?: boolean; @@ -33,31 +31,21 @@ export interface DialogResult { [key: string]: unknown; } -export interface LocalDirectoryResult { - success: boolean; - path: string; - parent?: string; - entries: LocalFileEntry[]; - error?: string; -} - -export interface LocalCollectedFile { - path: string; - name: string; - relativePath: string; - size: number; - created?: string; - modified: string; -} - -export type LocalPathMutationResult = - | ({ success: true } & Partial) - | { success: false; error?: string }; - export interface ElectronAPI { getAppVersion: () => Promise; setZoomFactor?: (factor: number) => void; getPlatform: () => Promise; + /** Whether the app-drawn title bar shows in this window. */ + getTitleBarVisible?: () => Promise; + /** Calls back when the title bar shows or hides; returns an unsubscribe. */ + onTitleBarVisibleChange?: ( + callback: (visible: boolean) => void, + ) => () => void; + /** Keeps the window controls the system draws in the colors of the theme. */ + setTitleBarOverlay?: (colors: { + color?: string; + symbolColor?: string; + }) => Promise; getSetting?: (key: string) => Promise; setSetting?: (key: string, value: string) => Promise; @@ -67,60 +55,6 @@ export interface ElectronAPI { serverUrl: string, allowInvalidCertificate?: boolean, ) => Promise; - answerC2SAuth: (id: string, answer: string | null) => Promise; - onC2SAuthPrompt: ( - callback: (prompt: { - id: string; - closed?: boolean; - tunnelName?: string; - request?: PluginSshPromptRequest; - }) => void, - ) => () => void; - getC2STunnelConfig: () => Promise; - saveC2STunnelConfig: ( - config: unknown[], - ) => Promise<{ success: boolean; error?: string }>; - checkLocalPortAvailable: ( - host: string, - port: number, - ) => Promise<{ available: boolean; error?: string }>; - getC2STunnelPresetDefaultName: () => Promise; - startC2STunnel: ( - tunnel: unknown, - index: number, - authToken?: string, - ) => Promise<{ - success: boolean; - tunnelName?: string; - error?: string; - code?: string; - }>; - testC2STunnel: ( - tunnel: unknown, - index: number, - authToken?: string, - ) => Promise<{ - success: boolean; - message?: string; - error?: string; - code?: string; - }>; - stopC2STunnel: ( - tunnelName: string, - ) => Promise<{ success: boolean; error?: string }>; - getC2STunnelStatuses: () => Promise>; - onC2STunnelStatuses?: ( - callback: (statuses: Record) => void, - ) => () => void; - startC2SAutoStartTunnels: () => Promise<{ - success: boolean; - started: number; - errors: string[]; - }>; - /** Where the linked server relays client tunnel streams, a /plugin-ws/ path. */ - setC2SRelayPath?: ( - relayPath: string, - ) => Promise<{ success: boolean; error?: string }>; onCloseActiveTab?: (callback: () => void) => () => void; clearSessionCookies: () => Promise; getSessionCookie: ( @@ -136,62 +70,6 @@ export interface ElectronAPI { showSaveDialog: (options: DialogOptions) => Promise; showOpenDialog: (options: DialogOptions) => Promise; - getLocalHomeDirectory?: () => Promise; - listLocalDirectory?: (path?: string) => Promise; - statLocalPaths?: ( - paths: string[], - ) => Promise< - Array< - | (LocalFileEntry & { success: true }) - | { success: false; path: string; error?: string } - > - >; - collectLocalFiles?: (paths: string[]) => Promise<{ - success: boolean; - files: LocalCollectedFile[]; - truncated?: boolean; - error?: string; - }>; - createLocalFolder?: ( - parentPath: string, - folderName: string, - ) => Promise; - renameLocalPath?: ( - path: string, - newName: string, - ) => Promise; - trashLocalPath?: (path: string) => Promise; - chmodLocalPath?: ( - path: string, - permissions: string, - ) => Promise; - - openExternalEditor: (fileData: { - fileName: string; - content: string; - encoding?: "utf8" | "base64"; - editorPath?: string | null; - }) => Promise<{ - success: boolean; - editId?: string; - path?: string; - error?: string; - }>; - - closeExternalEditor: (editId: string) => Promise<{ - success: boolean; - error?: string; - }>; - - onExternalEditorSaved?: ( - callback: (payload: { - editId: string; - content: string; - encoding: "utf8"; - path: string; - }) => void, - ) => () => void; - onUpdateAvailable: (callback: () => void) => void; onUpdateDownloaded: (callback: () => void) => void; @@ -238,159 +116,6 @@ export interface ElectronAPI { success: boolean; error?: string; }>; - - startLocalTerminal(dimensions: { - cols: number; - rows: number; - shell?: "default" | "wsl"; - }): Promise<{ sessionId: string; shell: string }>; - readyLocalTerminal(sessionId: string): Promise; - writeLocalTerminal(sessionId: string, data: string): Promise; - resizeLocalTerminal( - sessionId: string, - cols: number, - rows: number, - ): Promise; - closeLocalTerminal(sessionId: string): Promise; - onLocalTerminalData( - sessionId: string, - callback: (data: string) => void, - ): () => void; - onLocalTerminalExit( - sessionId: string, - callback: (exitCode: number) => void, - ): () => void; - - /** Local disk browsing for the dual-pane file manager (desktop only). */ - localFs?: { - home: () => Promise>; - list: (dirPath: string) => Promise>; - mkdir: ( - parentPath: string, - name: string, - ) => Promise>; - createFile: ( - parentPath: string, - name: string, - ) => Promise>; - rename: ( - oldPath: string, - newName: string, - ) => Promise>; - trash: (paths: string[]) => Promise>; - ensureDir: ( - dirPath: string, - rootPath?: string, - ) => Promise>; - exists: (paths: string[]) => Promise>; - walk: (paths: string[]) => Promise>; - reveal: (targetPath: string) => Promise>; - open: (targetPath: string) => Promise>; - }; - - /** Streamed local<->remote transfers driven by the main process. */ - localTransfer?: { - /** The /plugin-api/ path that serves the streaming routes. */ - setApiPath?: (apiPath: string) => Promise>; - upload: ( - options: LocalUploadRequest, - ) => Promise>; - download: ( - options: LocalDownloadRequest, - ) => Promise>; - cancel: ( - transferId: string, - ) => Promise>; - onProgress: ( - callback: (payload: LocalTransferProgress) => void, - ) => () => void; - }; -} - -export type LocalFsResult = - ({ success: true } & T) | { success: false; error: string; code?: string }; - -export interface LocalFsHomeInfo { - home: string; - separator: string; - platform: string; -} - -export interface LocalFileEntry { - name: string; - path: string; - type: "file" | "directory" | "link" | "other"; - created?: string; - modified?: string; - permissions?: string; - owner?: string; - group?: string; - size: number; - modifiedTimestamp?: number; - linkTarget?: string; - hidden: boolean; -} - -export interface LocalDirectoryListing { - path: string; - parent: string | null; - entries: LocalFileEntry[]; -} - -export interface LocalTrashResult { - trashed: number; - failed: Array<{ path: string; error: string }>; -} - -export interface LocalWalkFile { - localPath: string; - /** Path relative to the drop root; "/"-separated and includes the root's own name. */ - relativePath: string; - size: number; -} - -export interface LocalWalkResult { - files: LocalWalkFile[]; - emptyDirs: string[]; - totalBytes: number; -} - -/** - * Which Termix backend a transfer talks to. The main process resolves the - * actual URL and credentials for the origin; the renderer never supplies them. - */ -export type LocalTransferOrigin = "local" | "remote"; - -export interface LocalUploadRequest { - transferId: string; - origin: LocalTransferOrigin; - fields: Record; - localPath: string; - fileName: string; - deviceId?: string; - /** The renderer's token for the embedded backend (origin "local"); sent as a Bearer header. */ - authToken?: string; -} - -export interface LocalDownloadRequest { - transferId: string; - origin: LocalTransferOrigin; - body: Record; - destPath: string; - /** Selected download folder; destPath must resolve strictly inside it (code EINVAL otherwise). */ - rootPath: string; - expectedSize?: number; - /** Replace an existing file at destPath; otherwise the transfer is refused with code EEXIST. */ - overwrite?: boolean; - deviceId?: string; - /** The renderer's token for the embedded backend (origin "local"); sent as a Bearer header. */ - authToken?: string; -} - -export interface LocalTransferProgress { - transferId: string; - transferred: number; - total?: number; } declare global { diff --git a/packages/plugin-sdk/src/frontend.ts b/packages/plugin-sdk/src/frontend.ts index d7995dccd..845b44200 100644 --- a/packages/plugin-sdk/src/frontend.ts +++ b/packages/plugin-sdk/src/frontend.ts @@ -248,6 +248,129 @@ export interface TabsApi extends Pick< onReady: (listener: () => void) => Disposer; } +// --------------------------------------------------------------------------- +// Layouts (plugin API 1.4) +// --------------------------------------------------------------------------- + +/** + * A whole-app layout. Core renders the chosen one inside its providers and an + * error boundary; everything it shows comes from useShell(). Declare it in + * manifest contributes.layouts and add the ui:layout capability. + */ +export interface LayoutContribution { + id: string; + /** Your own i18n key or plain text, the same as the manifest's label. */ + label: string; + component: ComponentType; + /** Shown on phones and narrow windows. The desktop app never uses it. */ + mobileComponent?: ComponentType; + /** Theme to start with when nobody chose one. */ + defaultTheme?: string; +} + +/** A tab as a layout sees it. Splits are tabs too, with a `split` field. */ +export interface ShellTabState extends PluginTabRecord { + instanceId: string; + customLabel?: string; + /** Set while the tab sits in a split pane. */ + parentSplitTabId?: string; + openedAt?: number; +} + +/** What a dock region shows and how it is sized, in Normal-size pixels. */ +export interface ShellDockState { + view: string | null; + open: boolean; + width: number; + dragging: boolean; + setView: (view: string | null) => void; + setWidth: (width: number) => void; + /** Starts a drag from the dock's edge, from a mousedown. */ + startResize: (event: { clientX: number; preventDefault: () => void }) => void; + resetWidth: () => void; +} + +/** The live shell: state and actions, nothing drawn. Read with useShell(). */ +export interface ShellController { + tabs: { + list: ShellTabState[]; + /** What a tab bar shows: tabs not sitting in a split. */ + topLevel: ShellTabState[]; + activeId: string; + /** True once saved tabs were restored after login. */ + ready: boolean; + /** The user turned on tab numbers. */ + showNumbers: boolean; + setActive: (id: string) => void; + reorder: (topLevel: ShellTabState[]) => void; + close: (id: string) => Promise; + rename: (id: string, label: string) => void; + /** Reconnects or reloads a session tab. */ + refresh: (id: string) => void; + reconnectAll: () => void; + openSingleton: (type: string) => string | undefined; + }; + split: { + /** The split shown now, or null. */ + activeId: string | null; + focusedPaneId: string | null; + /** Every split with its panes, for a split menu. */ + summaries: Array<{ + id: string; + label: string; + full: boolean; + panes: Array<{ + id: string; + index: number; + tabId: string | null; + label: string | null; + }>; + }>; + activeFull: boolean; + /** Split a tab, apply a preset, unsplit, add to a pane. */ + handleAction: (action: Record & { kind: string }) => void; + }; + hosts: { + list: PluginHostRecord[]; + loading: boolean; + loaded: boolean; + reload: () => Promise; + }; + regions: { + left: ShellDockState & { view: string }; + right: ShellDockState; + /** What a rail click does: toggles or switches the left dock. */ + railClick: (view: string) => void; + openInRightDock: (view: string) => void; + toggleRightDock: () => void; + }; + palette: { isOpen: boolean; open: () => void; close: () => void }; + fullscreen: { active: boolean; toggle: () => void }; + /** The same callbacks tabs and panels get. */ + actions: ShellApi; + env: { + isMobile: boolean; + isElectron: boolean; + user: { username: string; isAdmin: boolean }; + permissions: string[]; + branding: { + appName: string | null; + tagline: string | null; + logo: string | null; + }; + }; + /** Core registries, as hooks a layout calls while rendering. */ + registries: { + useRailItems: () => ReadonlyArray<{ id: string } & Record>; + usePanels: () => ReadonlyArray<{ id: string } & Record>; + useTabTypes: () => ReadonlyArray<{ id: string } & Record>; + useDashboardCards: () => ReadonlyArray< + { id: string } & Record + >; + }; + logout: (options?: { manual?: boolean }) => void; +} + // --------------------------------------------------------------------------- // Registrations // --------------------------------------------------------------------------- @@ -530,8 +653,16 @@ export interface HostBadgeContribution { id: string; when: (host: PluginHostRecord) => boolean; component: ComponentType<{ host: PluginHostRecord }>; + /** Lower comes first. Ties sort by id. */ + order?: number; } +/** The dot or stripe that shows a host's status on its row. */ +export type HostStatusIndicatorContribution = HostBadgeContribution; + +/** A control in the host editor's toolbar. */ +export type HostEditorToolbarItemContribution = HostBadgeContribution; + export interface HostContextMenuItemContribution { id: string; titleKey: string; @@ -683,6 +814,66 @@ export interface SettingsComponentProps { running: boolean; } +/** What a companion starts with. Read from the plugin's own settings. */ +export interface CompanionDefaults { + /** Open by itself the first time a matching tab shows. */ + open?: boolean; + side?: "left" | "right" | "bottom"; + /** Pixels of width, or height at the bottom. */ + size?: number; + /** Show the edge button that opens and closes it. Default true. */ + edgeButton?: boolean; +} + +/** A panel a tab type can show beside it, for a picker. */ +export interface CompanionInfo { + /** The id registerCompanionLayout's hidden and order lists use. */ + id: string; + titleKey: string; + icon?: IconComponent; +} + +/** How a host wants the panel bar beside a tab. */ +export interface CompanionLayout { + /** False removes the bar and its panels. */ + enabled?: boolean; + /** Where a panel opens until the user moves it. */ + side?: "left" | "right" | "bottom"; + /** Companion ids in bar order. */ + order?: string[]; + /** Companion ids left out. */ + hidden?: string[]; +} + +export interface CompanionProps { + /** The tab it sits beside. */ + tab: PluginTabRecord; + host?: PluginHostRecord; + /** The host in the shape the connection APIs take. */ + sshHost?: Record; + /** The tab's surface handle (see TabHandle), read at call time. */ + getHandle: () => TabHandle | null; + side: "left" | "right" | "bottom"; + /** False while the tab is in the background or the panel is closed. */ + isVisible: boolean; + close: () => void; + setSide: (side: "left" | "right" | "bottom") => void; +} + +export interface CompanionOptions { + icon?: IconComponent; + titleKey?: string; + order?: number; + /** Tab types it applies to. Any tab with a host when empty. */ + tabTypes?: string[]; + when?: (tab: PluginTabRecord, host?: PluginHostRecord) => boolean; + /** + * A hook returning the starting state. It runs in the plugin's scope, so it + * can read the plugin's settings. + */ + useDefaults?: () => CompanionDefaults; +} + export type ActionHandler = (...args: never[]) => unknown; export interface ActionSlotDefinition { @@ -715,10 +906,11 @@ export type OnboardingMode = "full" | "partial" | "rerun"; * Where a plugin step sits in onboarding. "setup" comes right after the * appearance steps, for things that need doing before the feature works * (like pointing at a guacd server). "explore" (the default) comes after - * that, "security" after the account security step. Onboarding is for + * that, "security" after the account security step. "start" comes before + * the appearance steps, for choices that shape how the rest looks. Onboarding is for * choices, not for explaining features. */ -export type OnboardingSection = "setup" | "explore" | "security"; +export type OnboardingSection = "start" | "setup" | "explore" | "security"; export interface OnboardingStepProps { /** @@ -761,6 +953,16 @@ export interface OnboardingStepContribution { permission?: string; section?: OnboardingSection; order?: number; + /** + * Exact place in the flow, 1 to 99, instead of a section. Core steps sit at + * 10 (plugins), 20, 25, 30 (preset), 40 (appearance) and 90 (security). + */ + position?: number; + /** + * Older step keys this step took over, like "preset". People who already + * saw one of them are not shown this step again. + */ + replaces?: string[]; when?: (context: { isAdmin: boolean; isDesktop: boolean; @@ -926,12 +1128,40 @@ export interface TermixApp extends TermixAppInfo { component: ComponentType, options?: TabOptions, ) => Disposer; + /** + * A panel that sits beside any host tab the options match (a terminal, a + * desktop, ...), bound to that tab's host. The user opens, resizes and moves + * it; the tab keeps running underneath. + */ + registerCompanion: ( + id: string, + component: ComponentType, + options?: CompanionOptions, + ) => Disposer; + /** + * Lets a plugin decide, per host, how the panel bar looks on one tab type. + * Read from the host payload, so it must not call hooks. + */ + registerCompanionLayout: ( + tabType: string, + resolve: (host: PluginHostRecord | undefined) => CompanionLayout, + ) => Disposer; + /** Opens a companion on a tab, or closes it when it is showing. */ + toggleCompanion: (tab: PluginTabRecord, id?: string) => void; registerHostEditorSection: ( section: HostEditorSectionContribution, ) => Disposer; registerHostAction: (action: HostActionContribution) => Disposer; registerHostProtocol: (protocol: HostProtocolContribution) => Disposer; registerHostBadge: (badge: HostBadgeContribution) => Disposer; + /** Draws a host row's status. A layout renders every registered one. */ + registerHostStatusIndicator: ( + indicator: HostStatusIndicatorContribution, + ) => Disposer; + /** Adds a control to the host editor's toolbar. */ + registerHostEditorToolbarItem: ( + item: HostEditorToolbarItemContribution, + ) => Disposer; registerHostContextMenuItem: ( item: HostContextMenuItemContribution, ) => Disposer; @@ -988,6 +1218,12 @@ export interface TermixApp extends TermixAppInfo { registerSshAuthEditor: (editor: SshAuthEditorContribution) => Disposer; registerLoginMethod: (method: LoginMethodContribution) => Disposer; registerSecondFactorUI: (factor: SecondFactorContribution) => Disposer; + /** + * A whole-app layout. Declared in manifest contributes.layouts with the + * ui:layout capability. Only the chosen layout's plugin is loaded, so + * register it in activate. Needs plugin API 1.4. + */ + registerLayout: (layout: LayoutContribution) => Disposer; /** * Translates a key from the plugin's own locales, for code outside a @@ -1122,6 +1358,7 @@ export interface PluginHostBridge { useHosts: () => { hosts: PluginHostRecord[]; loaded: boolean }; useCurrentUser: () => CurrentUser | null; useTheme: () => { theme: "light" | "dark" }; + useShell: () => ShellController | null; toast: ToastApi; getApi: (pluginId: string) => PluginApiClient; getApiFor: (pluginId: string, origin: unknown) => PluginApiClient; @@ -1138,6 +1375,7 @@ export interface PluginHostBridge { hostProtocols: (host: PluginHostRecord) => string[]; useHostStatus: (hostId: number | undefined) => HostStatusInfo | null; useHostActions: () => HostActionContribution[]; + useCompanions: (tabType: string) => CompanionInfo[]; useActivityTypes: () => string[]; activityTarget: (type: string) => ActivityTargetInfo | undefined; useExtensions: (pointId: string) => ExtensionContribution[]; @@ -1372,6 +1610,14 @@ export function useTheme(): { theme: "light" | "dark" } { return requireHost().useTheme(); } +/** + * The live shell, for a layout. Null outside the signed-in app. Needs plugin + * API 1.4. + */ +export function useShell(): ShellController | null { + return requireHost().useShell(); +} + export function useToast(): ToastApi { return requireHost().toast; } @@ -1539,6 +1785,14 @@ export function useHostActions(): HostActionContribution[] { return requireHost().useHostActions(); } +/** + * Every panel that can open beside a tab of this type, for a settings screen + * that lets the user hide or reorder them. Reactive. + */ +export function useCompanions(tabType: string): CompanionInfo[] { + return requireHost().useCompanions(tabType); +} + /** * Recent-activity types every registered tab claims (registerTab's * activityTypes), core's and plugins'. For a filter that lists what a user diff --git a/packages/plugin-sdk/src/manifest.ts b/packages/plugin-sdk/src/manifest.ts index c60138b44..37cb39b55 100644 --- a/packages/plugin-sdk/src/manifest.ts +++ b/packages/plugin-sdk/src/manifest.ts @@ -19,6 +19,7 @@ import { type PluginEnvVar, } from "./docs.js"; +export { validateThemeCss } from "./theme-css.js"; export { ENV_NAME_PATTERN, MAX_ENV_DESCRIPTION_LENGTH, @@ -36,7 +37,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.3.0"; +export const PLUGIN_API_VERSION = "1.11.0"; /** The API major, the default engine.api for a new plugin. */ export const SUPPORTED_PLUGIN_API_VERSION = String( @@ -426,6 +427,40 @@ export interface PluginAuthContribution { keyboardInteractive?: string[]; } +/** A layout id or theme id: short, lowercase, used as a CSS class for themes. */ +export const LOOK_ID_PATTERN = /^[a-z][a-z0-9-]{1,39}$/; + +/** + * A whole-app layout the frontend registers with app.registerLayout. Needs + * the ui:layout capability. Declared here so core can list and choose it + * before any plugin code runs. + */ +export interface PluginLayoutContribution { + id: string; + /** i18n key in the plugin's namespace, or plain text. */ + label: string; + description?: string; + /** Theme picked with this layout when nobody chose one. */ + defaultTheme?: string; + /** The layout a default-look plugin starts with. */ + default?: boolean; +} + +/** + * A theme: one CSS file of token rules scoped under `.`, applied as a + * class on . Needs the ui:theme capability. No code runs for a theme. + */ +export interface PluginThemeContribution { + id: string; + /** i18n key in the plugin's namespace, or plain text. */ + label: string; + mode: "light" | "dark"; + /** Path of the CSS file inside the plugin. */ + css: string; + /** Colors the theme picker previews it with. */ + swatch?: { background: string; panel: string; text: string }; +} + export interface PluginContributions { tabs?: PluginTabContribution[]; panels?: PluginViewContribution[]; @@ -487,6 +522,10 @@ export interface PluginContributions { * ctx.credentials.resolveHostProtocol. */ protocols?: PluginProtocolContribution[]; + /** Whole-app layouts. Needs the ui:layout capability. */ + layouts?: PluginLayoutContribution[]; + /** Themes. Needs the ui:theme capability. */ + themes?: PluginThemeContribution[]; } /** A field a protocol login carries besides username and password. */ @@ -649,6 +688,21 @@ export interface PluginManifest { nativeDependencies?: string[]; /** How a linked desktop treats this plugin. Defaults to "mirror". */ desktop?: PluginDesktopMode; + /** + * "desktop": the plugin only loads on a desktop app's own embedded backend, + * never on a server. It implies desktop "local" and may declare desktop:local. + */ + runtime?: PluginRuntime; +} + +export type PluginRuntime = "desktop"; +export const PLUGIN_RUNTIMES: readonly PluginRuntime[] = ["desktop"]; + +/** The desktop mode a manifest resolves to. */ +export function resolvePluginDesktopMode( + m: Pick, +): PluginDesktopMode { + return m.desktop ?? (m.runtime === "desktop" ? "local" : "mirror"); } export const DEFAULT_BACKEND_ENTRY = "dist/backend.js"; @@ -688,6 +742,7 @@ const ALLOWED_TOP_LEVEL = new Set([ "platforms", "nativeDependencies", "desktop", + "runtime", ]); const ALLOWED_CONTRIBUTES = new Set([ @@ -708,6 +763,8 @@ const ALLOWED_CONTRIBUTES = new Set([ "uses", "keybindingActions", "protocols", + "layouts", + "themes", ]); const ALLOWED_SETTINGS_FIELD = [ @@ -863,6 +920,21 @@ export function validateManifest(manifest: unknown): string[] { `Field "desktop" must be one of: ${PLUGIN_DESKTOP_MODES.join(", ")}`, ); } + if ( + m.runtime !== undefined && + !PLUGIN_RUNTIMES.includes(m.runtime as PluginRuntime) + ) { + errors.push( + `Field "runtime" must be one of: ${PLUGIN_RUNTIMES.join(", ")}`, + ); + } + if ( + m.runtime === "desktop" && + m.desktop !== undefined && + m.desktop !== "local" + ) { + errors.push(`A "desktop" runtime plugin must use desktop "local"`); + } validateDependencyMap(m.dependencies, "dependencies", errors); validateDependencyMap(m.optionalDependencies, "optionalDependencies", errors); validateProvides(m.provides, errors); @@ -1300,6 +1372,111 @@ function validateContributes( validateUiPresets(contributes.uiPresets, errors); validateKeybindingActions(contributes.keybindingActions, errors); validateProtocols(contributes.protocols, errors); + validateLayouts(contributes.layouts, errors); + validateThemes(contributes.themes, errors); +} + +/** An array of objects with unique look ids; calls `each` for every valid entry. */ +function validateLookList( + value: unknown, + field: string, + allowed: string[], + errors: string[], + each: (raw: Record, where: string) => void, +): void { + if (value === undefined) return; + if (!Array.isArray(value)) { + errors.push(`Field "contributes.${field}" must be an array`); + return; + } + const seen = new Set(); + value.forEach((raw, index) => { + const where = `contributes.${field}[${index}]`; + if (!isPlainObject(raw)) { + errors.push(`${where} must be an object`); + return; + } + rejectUnknown(raw, allowed, where, errors); + if (typeof raw.id !== "string" || !LOOK_ID_PATTERN.test(raw.id)) { + errors.push( + `${where}.id must match ${LOOK_ID_PATTERN} (lowercase letters, digits, dashes)`, + ); + } else { + if (seen.has(raw.id)) errors.push(`${where}.id duplicates "${raw.id}"`); + seen.add(raw.id); + } + requireString(raw.label, `${where}.label`, errors); + each(raw, where); + }); +} + +function validateLayouts(value: unknown, errors: string[]): void { + validateLookList( + value, + "layouts", + ["id", "label", "description", "defaultTheme", "default"], + errors, + (raw, where) => { + if (raw.description !== undefined) + requireString(raw.description, `${where}.description`, errors); + if ( + raw.defaultTheme !== undefined && + (typeof raw.defaultTheme !== "string" || + !LOOK_ID_PATTERN.test(raw.defaultTheme)) + ) { + errors.push(`${where}.defaultTheme must be a theme id`); + } + if (raw.default !== undefined && typeof raw.default !== "boolean") { + errors.push(`${where}.default must be a boolean`); + } + }, + ); + if ( + Array.isArray(value) && + value.filter((raw) => isPlainObject(raw) && raw.default === true).length > 1 + ) { + errors.push('Only one entry in "contributes.layouts" may set default'); + } +} + +const HEX_COLOR = /^#[0-9a-fA-F]{3,8}$/; + +function validateThemes(value: unknown, errors: string[]): void { + validateLookList( + value, + "themes", + ["id", "label", "mode", "css", "swatch"], + errors, + (raw, where) => { + if (raw.mode !== "light" && raw.mode !== "dark") { + errors.push(`${where}.mode must be "light" or "dark"`); + } + if (requireString(raw.css, `${where}.css`, errors)) { + requireRelativePath(raw.css, `${where}.css`, errors); + if (!raw.css.endsWith(".css")) { + errors.push(`${where}.css must be a .css file`); + } + } + if (raw.swatch !== undefined) { + if (!isPlainObject(raw.swatch)) { + errors.push(`${where}.swatch must be an object`); + return; + } + rejectUnknown( + raw.swatch, + ["background", "panel", "text"], + `${where}.swatch`, + errors, + ); + for (const key of ["background", "panel", "text"]) { + const color = raw.swatch[key]; + if (typeof color !== "string" || !HEX_COLOR.test(color)) { + errors.push(`${where}.swatch.${key} must be a hex color`); + } + } + } + }, + ); } export const PROTOCOL_ID_PATTERN = /^[a-z][a-z0-9-]{0,31}$/; @@ -2324,6 +2501,23 @@ export function parseManifest(raw: unknown): ParsedManifest { ); } + if ( + (manifest.contributes?.layouts?.length ?? 0) > 0 && + !manifest.capabilities.includes("ui:layout") + ) { + errors.push( + 'contributes.layouts needs the "ui:layout" capability in the capabilities array', + ); + } + if ( + (manifest.contributes?.themes?.length ?? 0) > 0 && + !manifest.capabilities.includes("ui:theme") + ) { + errors.push( + 'contributes.themes needs the "ui:theme" capability in the capabilities array', + ); + } + for (const secret of manifest.requiresSecret ?? []) { if (secret.plugin === manifest.id) { errors.push( diff --git a/packages/plugin-sdk/src/ssh-keys.ts b/packages/plugin-sdk/src/ssh-keys.ts new file mode 100644 index 000000000..ad294c056 --- /dev/null +++ b/packages/plugin-sdk/src/ssh-keys.ts @@ -0,0 +1,122 @@ +/** + * SSH key helpers for plugins that make, check or deploy keys: generate a key + * pair, tell what a key is, derive a public key and check a pair belongs + * together. Pure functions over ssh2, which the host supplies. Core keeps its + * own copy for the connect pipeline. + */ + +import ssh2 from "ssh2"; + +export type SshKeyKind = "ssh-ed25519" | "ssh-rsa" | "ecdsa-sha2-nistp256"; + +export interface GeneratedSshKeyPair { + privateKey: string; + publicKey: string; + keyType: SshKeyKind; +} + +export interface GenerateSshKeyPairOptions { + /** RSA only. 2048 to 8192, default 4096. */ + bits?: number; + passphrase?: string; + comment?: string; +} + +/** Makes a new key pair; the public half is one authorized_keys line. */ +export function generateSshKeyPair( + type: SshKeyKind, + options: GenerateSshKeyPairOptions = {}, +): GeneratedSshKeyPair { + const algorithm = + type === "ssh-rsa" ? "rsa" : type === "ssh-ed25519" ? "ed25519" : "ecdsa"; + if ( + type !== "ssh-rsa" && + type !== "ssh-ed25519" && + type !== "ecdsa-sha2-nistp256" + ) { + throw new Error(`Unsupported key type: ${String(type)}`); + } + const generateOptions: Record = { + comment: options.comment ?? "", + }; + if (options.passphrase) { + generateOptions.passphrase = options.passphrase; + generateOptions.cipher = "aes256-ctr"; + } + if (type === "ssh-rsa") { + const bits = options.bits ?? 4096; + if (!Number.isInteger(bits) || bits < 2048 || bits > 8192) { + throw new Error("RSA keys need 2048 to 8192 bits"); + } + generateOptions.bits = bits; + } + if (type === "ecdsa-sha2-nistp256") generateOptions.bits = 256; + + const pair = ssh2.utils.generateKeyPairSync( + algorithm as "rsa", + generateOptions as never, + ); + return { + privateKey: pair.private, + publicKey: pair.public.trim(), + keyType: type, + }; +} + +function parse(key: string, passphrase?: string) { + const parsed = ssh2.utils.parseKey(key.trim(), passphrase || undefined); + const first = Array.isArray(parsed) ? parsed[0] : parsed; + if (!first || first instanceof Error) { + throw first instanceof Error ? first : new Error("Not a readable SSH key"); + } + return first; +} + +/** The key's algorithm, like "ssh-ed25519", or null when it cannot be read. */ +export function detectSshKeyType( + key: string, + passphrase?: string, +): string | null { + try { + return parse(key, passphrase).type; + } catch { + return null; + } +} + +/** The authorized_keys line for a private key, or null. */ +export function publicKeyFromPrivate( + privateKey: string, + passphrase?: string, +): string | null { + try { + const parsed = parse(privateKey, passphrase); + return `${parsed.type} ${parsed.getPublicSSH().toString("base64")}`; + } catch { + return null; + } +} + +/** Whether a public key line belongs to the private key. */ +export function validateSshKeyPair( + privateKey: string, + publicKey: string, + passphrase?: string, +): { isValid: boolean; error?: string } { + try { + const priv = parse(privateKey, passphrase); + const pub = parse(publicKey); + const same = priv.getPublicSSH().equals(pub.getPublicSSH()); + return same + ? { isValid: true } + : { + isValid: false, + error: "The public key does not match the private key", + }; + } catch (error) { + return { + isValid: false, + error: error instanceof Error ? error.message : String(error), + }; + } +} diff --git a/packages/plugin-sdk/src/testing.ts b/packages/plugin-sdk/src/testing.ts index 4515a62ea..7b0d3f053 100644 --- a/packages/plugin-sdk/src/testing.ts +++ b/packages/plugin-sdk/src/testing.ts @@ -14,7 +14,10 @@ import { PluginCapabilityError, } from "./backend.js"; import type { + PluginAuditEntry, + PluginAuditFilters, PluginContext, + PluginDesktopLocal, PluginDisposables, PluginLogger, } from "./backend.js"; @@ -65,12 +68,19 @@ import type { PluginSshKeyCredential, PluginSshKeyCredentialInput, } from "./backend.js"; -import type { SyncEntityRegistration } from "./backend.js"; +import type { PluginSyncHost, SyncEntityRegistration } from "./backend.js"; import type { PluginDatabase } from "./backend.js"; import type { PluginExternalClientRequest, PluginProtocolTarget, + PluginHostProbeTarget, PluginHostStatusEntry, + PluginActivityProvider, + PluginHostLockProvider, + PluginAuthPolicyProvider, + PluginCredentialSummary, + PluginHostShareGrant, + PluginHostStatusProvider, PluginNotification, PluginNotificationChannel, PluginNotifyHub, @@ -94,6 +104,8 @@ export interface FakeScheduledJob { export interface FakeContextOptions { pluginId?: string; + /** What ctx.sync.host answers, for a sync provider's tests. */ + syncHost?: Partial; manifest?: Partial; /** Acting user returned by currentActor and used by asUser. */ actor?: string; @@ -101,6 +113,8 @@ export interface FakeContextOptions { settings?: Record; /** Seed for the core settings ctx.settings.readCore can reach. */ coreSettings?: Record; + /** What ctx.files.dataDir() answers. Defaults to /tmp/. */ + dataDir?: string; /** What ctx.ssh.connect and withConnection hand back as the client. */ sshClient?: unknown; /** A real database behind ctx.db, usually createTestDb().database. */ @@ -132,10 +146,22 @@ export interface FakeContextOptions { protocolTargets?: Record; /** What ctx.credentials.listSshKeys answers. */ sshKeyCredentials?: PluginSshKeyCredential[]; + /** What ctx.credentials.list answers. */ + credentialSummaries?: PluginCredentialSummary[]; /** What ctx.desktop.available() answers. Defaults to false. */ desktopAvailable?: boolean; + /** What ctx.desktop.local is. Calls throw when this is not given. */ + desktopLocal?: PluginDesktopLocal; + /** The stored audit lines ctx.audit.list and exportBatches serve. */ + auditEntries?: PluginAuditEntry[]; /** What ctx.hosts.status.get and check answer, by host id. */ hostStatuses?: Record; + /** What ctx.hosts.probeTargets answers. */ + probeTargets?: PluginHostProbeTarget[]; + /** Host ids ctx.hosts.sessionActive answers true for. */ + activeSessions?: number[]; + /** What ctx.network.tcpProbe and ctx.ssh.probe answer. Defaults to false. */ + reachable?: (target: string | number, port?: number) => boolean; /** The acting user's channels, what ctx.notify.channels answers. */ notificationChannels?: PluginNotificationChannel[]; /** @@ -172,6 +198,8 @@ export interface FakeContextOptions { baseUrl?: string; /** What ctx.auth.countLinkedUsers answers, by provider. Defaults to 0. */ linkedUsers?: Record; + /** Second factors from other plugins that ctx.auth.listSecondFactors adds. */ + secondFactors?: { id: string; pluginId: string }[]; /** 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. */ @@ -251,6 +279,8 @@ export interface FakeAuthRegistrations { loginFailures: Map; /** Every ctx.auth.issueUserToken call, in order. */ userTokens: Array<{ userId: string; purpose: string; ttlSeconds?: number }>; + /** Providers passed to ctx.auth.policy.provide that are still registered. */ + policies: PluginAuthPolicyProvider[]; } export interface FakePluginContext { @@ -263,6 +293,12 @@ export interface FakePluginContext { ) => Promise>; /** Everything the plugin registered through ctx.disposables. */ disposals: Array<() => void | Promise>; + /** Providers registered through ctx.hosts.status.provide. */ + hostStatusProviders: PluginHostStatusProvider[]; + /** Every provider passed to ctx.hosts.activity.provide that is still registered. */ + activityProviders: PluginActivityProvider[]; + /** Every provider passed to ctx.hosts.locks.provide that is still registered. */ + hostLockProviders: PluginHostLockProvider[]; /** Topics emitted through ctx.events.emit, in order. */ emitted: Array<{ topic: string; payload: unknown }>; /** Backing store behind ctx.kv. */ @@ -295,6 +331,8 @@ export interface FakePluginContext { permissionLevel: string; durationHours?: number; }>; + /** Every ctx.hosts.setShares call, in order. */ + hostShareSets: Array<{ hostId: number; grants: PluginHostShareGrant[] }>; /** Everything registered through ctx.auth. */ auth: FakeAuthRegistrations; /** Every ctx.desktop.openIsolatedWindow call, in order. */ @@ -338,6 +376,8 @@ export interface FakePluginContext { challenges: Map; renewers: number; }; + /** Calls every ctx.audit.subscribe handler, like a line core just accepted. */ + emitAudit: (entry: PluginAuditEntry) => void; /** Every ctx.audit.record entry, in order. */ audits: Array<{ action: string; success: boolean; [key: string]: unknown }>; /** Every ctx.hosts.recordActivity call, in order. */ @@ -464,7 +504,14 @@ function applyHostInput( const { tags, pluginSettings: _settings, + pluginEnable: _pluginEnable, forceKeyboardInteractive: _fki, + password: _password, + key: _key, + keyPassword: _keyPassword, + keyType: _keyType, + sudoPassword: _sudoPassword, + protocolAuth: _protocolAuth, ...rest } = input; const next: PluginHostRecord = { ...record }; @@ -491,6 +538,9 @@ export function createFakeContext( const pluginId = options.pluginId ?? "test-plugin"; const disposals: Array<() => void | Promise> = []; const emitted: Array<{ topic: string; payload: unknown }> = []; + const hostStatusProviders: PluginHostStatusProvider[] = []; + const activityProviders: PluginActivityProvider[] = []; + const hostLockProviders: PluginHostLockProvider[] = []; const kv = new Map(); const tables: PluginTableDefinition[] = []; const syncEntities: SyncEntityRegistration[] = []; @@ -508,6 +558,7 @@ export function createFakeContext( }>(); const sshConnections: FakePluginContext["sshConnections"] = []; const hostShares: FakePluginContext["hostShares"] = []; + const hostShareSets: FakePluginContext["hostShareSets"] = []; const desktopWindows: FakePluginContext["desktopWindows"] = []; const externalClientLaunches: FakePluginContext["externalClientLaunches"] = []; @@ -531,6 +582,7 @@ export function createFakeContext( }; const secretStore = new Map(); const audits: FakePluginContext["audits"] = []; + const auditSubscribers = new Set<(entry: PluginAuditEntry) => void>(); const activities: FakePluginContext["activities"] = []; const trackedSessions: number[] = []; const interactions: FakePluginContext["interactions"] = []; @@ -579,6 +631,7 @@ export function createFakeContext( revokedSessions: [], loginFailures: new Map(), userTokens: [], + policies: [], }; const sshClient = options.sshClient ?? {}; let actor = options.actor; @@ -665,7 +718,7 @@ export function createFakeContext( files: { // A fake path; a test that needs a real folder uses the OS temp dir // itself, not this double. - dataDir: async () => `/tmp/${pluginId}`, + dataDir: async () => options.dataDir ?? `/tmp/${pluginId}`, }, db: options.db @@ -705,6 +758,12 @@ export function createFakeContext( registerEntity: (entity) => { syncEntities.push(entity); }, + provide: () => () => {}, + get host() { + if (!options.syncHost) + throw new Error("Pass syncHost to use ctx.sync.host"); + return options.syncHost as PluginSyncHost; + }, }, registry: { @@ -902,6 +961,27 @@ export function createFakeContext( disposables, hosts: { + importMany: async (rows) => { + const created = rows.map(() => nextHostId++); + return { + success: created.length, + updated: 0, + skipped: 0, + failed: 0, + errors: [], + createdHostIds: created, + }; + }, + importSshConfig: async () => ({ + success: 0, + updated: 0, + skipped: 0, + failed: 0, + errors: [], + createdHostIds: [], + }), + exportMany: async () => ({ hosts: [] }), + registerImportNormalizer: () => () => {}, list: async () => [...hostsById.values()], get: async (hostId) => hostsById.get(hostId) ?? null, checkAccess: async (hostId): Promise => { @@ -956,6 +1036,10 @@ export function createFakeContext( hostShares.push({ hostId, targets, permissionLevel, durationHours }); return { hostId, shared: true }; }, + setShares: async (hostId, grants) => { + hostShareSets.push({ hostId, grants: [...grants] }); + return { hostId, shared: true }; + }, listUsers: async () => options.shareableUsers ?? [], listRoles: async () => options.shareableRoles ?? [], trackSession: (hostId) => { @@ -971,7 +1055,42 @@ export function createFakeContext( recordActivity: async (hostId, type, hostName) => { activities.push({ hostId, type, hostName }); }, + probeTargets: async (filter) => + (options.probeTargets ?? []).filter( + (target) => !filter?.hostIds || filter.hostIds.includes(target.id), + ), + sessionActive: (hostId) => + options.activeSessions?.includes(hostId) ?? false, + activity: { + provide: (provider) => { + activityProviders.push(provider); + return () => { + const at = activityProviders.indexOf(provider); + if (at >= 0) activityProviders.splice(at, 1); + }; + }, + }, + folders: { + ensure: async () => {}, + removeEmpty: async () => true, + }, + locks: { + provide: (provider) => { + hostLockProviders.push(provider); + return () => { + const at = hostLockProviders.indexOf(provider); + if (at >= 0) hostLockProviders.splice(at, 1); + }; + }, + }, status: { + provide: (provider) => { + hostStatusProviders.push(provider); + return () => { + const at = hostStatusProviders.indexOf(provider); + if (at >= 0) hostStatusProviders.splice(at, 1); + }; + }, get: async (hostId) => hostStatuses.get(hostId) ?? null, check: async (hostId) => hostStatuses.get(hostId) ?? null, reportLogin: (hostId, outcome) => { @@ -996,12 +1115,46 @@ export function createFakeContext( }, audit: { + subscribe: (handler) => { + auditSubscribers.add(handler); + return () => { + auditSubscribers.delete(handler); + }; + }, + list: async (listOptions) => { + const matching = filterAuditEntries( + options.auditEntries ?? [], + listOptions?.filters, + ).reverse(); + const offset = listOptions?.offset ?? 0; + return { + entries: matching.slice(offset, offset + (listOptions?.limit ?? 50)), + total: matching.length, + }; + }, + exportBatches: async function* (filters, batchSize) { + const matching = filterAuditEntries( + options.auditEntries ?? [], + filters, + ); + const size = batchSize ?? 500; + for (let at = 0; at < matching.length; at += size) { + yield matching.slice(at, at + size); + } + }, record: async (entry) => { audits.push({ ...entry }); }, }, + network: { + tcpProbe: async (host, port) => options.reachable?.(host, port) ?? false, + }, + ssh: { + transports: { provide: () => () => {} }, + probe: async (hostId, probeOptions) => + options.reachable?.(hostId, probeOptions?.port) ?? false, connect: async (host, connectOptions) => { sshConnections.push({ host, options: connectOptions }); return { @@ -1013,6 +1166,16 @@ export function createFakeContext( dispose: () => {}, }; }, + exec: async (host) => { + sshConnections.push({ host }); + return { + stdout: "", + stderr: "", + code: 0, + truncated: false, + timedOut: false, + }; + }, withConnection: async (host, connectOptions, fn) => { sshConnections.push({ host, pool: connectOptions.pool }); return fn(sshClient as never); @@ -1153,6 +1316,22 @@ export function createFakeContext( expiresAt: new Date(Date.now() + ttl * 1000).toISOString(), }; }, + policy: { + provide: (provider) => { + auth.policies.push(provider); + return () => { + const at = auth.policies.indexOf(provider); + if (at >= 0) auth.policies.splice(at, 1); + }; + }, + }, + listSecondFactors: () => [ + ...auth.secondFactors.map((factor) => ({ + id: factor.id, + pluginId, + })), + ...(options.secondFactors ?? []), + ], }, users: { @@ -1169,9 +1348,11 @@ export function createFakeContext( return { success: true }; }, available: () => options.desktopAvailable ?? false, + local: options.desktopLocal ?? unavailableDesktopLocal(), }, credentials: { + list: async () => [...(options.credentialSummaries ?? [])], listSshKeys: async () => [ ...(options.sshKeyCredentials ?? []), ...createdSshKeys.map((key) => ({ @@ -1195,6 +1376,13 @@ export function createFakeContext( credentialReads.push({ hostId, protocol }); return options.protocolTargets?.[`${hostId}:${protocol}`] ?? null; }, + publicKey: async (credentialId) => + (options.sshKeyCredentials ?? []).find((k) => k.id === credentialId) + ?.publicKey ?? null, + resolveKeyRefs: async (key, passphrase) => ({ + key, + passphrase: passphrase ?? undefined, + }), registerSecretResolver: (scheme, resolve) => { auth.secretResolvers.set(scheme, resolve); disposals.push(() => { @@ -1345,6 +1533,9 @@ export function createFakeContext( return errors; }, disposals, + hostStatusProviders, + activityProviders, + hostLockProviders, emitted, kv, tables, @@ -1355,6 +1546,7 @@ export function createFakeContext( coreSettings, sshConnections, hostShares, + hostShareSets, auth, desktopWindows, externalClientLaunches, @@ -1368,6 +1560,9 @@ export function createFakeContext( tls, secretStore, audits, + emitAudit: (entry) => { + for (const handler of auditSubscribers) handler(entry); + }, activities, trackedSessions, interactions, @@ -1400,6 +1595,8 @@ export function createFakeContext( export interface MockContextOptions { pluginId?: string; + /** What ctx.sync.host answers, for a sync provider's tests. */ + syncHost?: Partial; manifest?: Partial; /** Capabilities the plugin is granted. Anything else throws. */ capabilities?: string[]; @@ -1409,6 +1606,8 @@ export interface MockContextOptions { settings?: Record; /** Seed for the core settings readCore can reach. */ coreSettings?: Record; + /** What ctx.files.dataDir() answers. */ + dataDir?: string; /** Acting user returned by currentActor and used by asUser. */ actor?: string; /** What ctx.ssh.connect and withConnection hand back as the client. */ @@ -1432,10 +1631,22 @@ export interface MockContextOptions { protocolTargets?: Record; /** What ctx.credentials.listSshKeys serves. */ sshKeyCredentials?: PluginSshKeyCredential[]; + /** What ctx.credentials.list serves. */ + credentialSummaries?: PluginCredentialSummary[]; /** What ctx.desktop.available() answers. */ desktopAvailable?: boolean; + /** What ctx.desktop.local is. See FakeContextOptions. */ + desktopLocal?: PluginDesktopLocal; + /** See FakeContextOptions. */ + auditEntries?: PluginAuditEntry[]; /** What ctx.hosts.status answers. See FakeContextOptions. */ hostStatuses?: Record; + /** What ctx.hosts.probeTargets answers. */ + probeTargets?: PluginHostProbeTarget[]; + /** Host ids ctx.hosts.sessionActive answers true for. */ + activeSessions?: number[]; + /** What ctx.network.tcpProbe and ctx.ssh.probe answer. */ + reachable?: (target: string | number, port?: number) => boolean; /** The acting user's channels, what ctx.notify.channels answers. */ notificationChannels?: PluginNotificationChannel[]; /** @@ -1460,6 +1671,8 @@ export interface MockContextOptions { /** See FakeContextOptions. */ loginAttemptLimit?: number; /** See FakeContextOptions. */ + secondFactors?: FakeContextOptions["secondFactors"]; + /** See FakeContextOptions. */ locales?: Record; } @@ -1481,6 +1694,63 @@ export interface MockPluginContext extends FakePluginContext { * (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. */ +function filterAuditEntries( + entries: PluginAuditEntry[], + filters: PluginAuditFilters | undefined, +): PluginAuditEntry[] { + return entries.filter( + (entry) => + (filters?.userId === undefined || entry.userId === filters.userId) && + (filters?.action === undefined || entry.action === filters.action) && + (filters?.resourceType === undefined || + entry.resourceType === filters.resourceType) && + (filters?.success === undefined || entry.success === filters.success) && + (filters?.startDate === undefined || + entry.timestamp >= filters.startDate) && + (filters?.endDate === undefined || entry.timestamp <= filters.endDate), + ); +} + +function gateDesktopLocal( + target: PluginDesktopLocal, + check: () => void, +): PluginDesktopLocal { + const wrap = (value: unknown): unknown => { + if (typeof value === "function") { + return (...args: unknown[]) => { + check(); + return (value as (...a: unknown[]) => unknown)(...args); + }; + } + if (value && typeof value === "object") { + return new Proxy(value as object, { + get: (obj, key) => wrap((obj as Record)[key]), + }); + } + return value; + }; + return new Proxy(target, { + get: (obj, key) => + key === "available" + ? obj.available + : wrap((obj as unknown as Record)[key]), + }); +} + +function unavailableDesktopLocal(): PluginDesktopLocal { + const fail = (): never => { + throw new Error("ctx.desktop.local needs the desktopLocal test option"); + }; + const deep = (): unknown => + new Proxy(() => fail(), { get: () => deep(), apply: () => fail() }); + return { + available: () => false, + info: fail, + fs: deep(), + pty: deep(), + } as unknown as PluginDesktopLocal; +} + export function createMockCtx( options: MockContextOptions = {}, ): MockPluginContext { @@ -1489,9 +1759,11 @@ export function createMockCtx( const base = createFakeContext({ pluginId: options.pluginId, + syncHost: options.syncHost, actor: options.actor, settings: options.settings, coreSettings: options.coreSettings, + dataDir: options.dataDir, sshClient: options.sshClient, db: options.db, router: options.router, @@ -1503,8 +1775,14 @@ export function createMockCtx( services: options.services, protocolTargets: options.protocolTargets, sshKeyCredentials: options.sshKeyCredentials, + credentialSummaries: options.credentialSummaries, desktopAvailable: options.desktopAvailable, + desktopLocal: options.desktopLocal, + auditEntries: options.auditEntries, hostStatuses: options.hostStatuses, + probeTargets: options.probeTargets, + activeSessions: options.activeSessions, + reachable: options.reachable, notificationChannels: options.notificationChannels, fetch: options.fetch, process: options.process, @@ -1515,6 +1793,7 @@ export function createMockCtx( baseUrl: options.baseUrl, linkedUsers: options.linkedUsers, loginAttemptLimit: options.loginAttemptLimit, + secondFactors: options.secondFactors, locales: options.locales, manifest: { capabilities: options.capabilities ?? [], @@ -1650,6 +1929,23 @@ export function createMockCtx( }, hosts: { + importMany: async (rows, importOptions) => { + require("hosts:write"); + if (importOptions?.credentials?.length) require("credentials:write"); + return ctx.hosts.importMany(rows, importOptions); + }, + importSshConfig: async (content, importOptions) => { + require("hosts:write"); + return ctx.hosts.importSshConfig(content, importOptions); + }, + exportMany: async (exportOptions) => { + require("hosts:export"); + return ctx.hosts.exportMany(exportOptions); + }, + registerImportNormalizer: (normalizer) => { + require("hosts:write"); + return ctx.hosts.registerImportNormalizer(normalizer); + }, list: async () => { require("hosts:read"); return ctx.hosts.list(); @@ -1682,6 +1978,10 @@ export function createMockCtx( require("hosts:write"); return ctx.hosts.share(hostId, targets, permissionLevel, durationHours); }, + setShares: async (hostId, grants) => { + require("hosts:write"); + return ctx.hosts.setShares(hostId, grants); + }, listUsers: async () => { require("hosts:write"); return ctx.hosts.listUsers(); @@ -1698,7 +1998,42 @@ export function createMockCtx( require("hosts:read"); return ctx.hosts.recordActivity(hostId, type, hostName); }, + probeTargets: async (filter) => { + require("hosts:read"); + return ctx.hosts.probeTargets(filter); + }, + sessionActive: (hostId) => { + require("hosts:read"); + return ctx.hosts.sessionActive(hostId); + }, + activity: { + provide: (provider) => { + require("hosts:activity-provide"); + return ctx.hosts.activity.provide(provider); + }, + }, + folders: { + ensure: async (name, style) => { + require("hosts:write"); + return ctx.hosts.folders.ensure(name, style); + }, + removeEmpty: async (name) => { + require("hosts:write"); + return ctx.hosts.folders.removeEmpty(name); + }, + }, + locks: { + provide: (provider) => { + require("hosts:lock"); + return ctx.hosts.locks.provide(provider); + }, + }, status: { + provide: (provider) => { + require("hosts:read"); + require("hosts:status-provide"); + return ctx.hosts.status.provide(provider); + }, get: async (hostId) => { require("hosts:read"); return ctx.hosts.status.get(hostId); @@ -1720,11 +2055,26 @@ export function createMockCtx( ssh: { ...ctx.ssh, + transports: { + provide: (provider) => { + require("ssh:transport"); + return ctx.ssh.transports.provide(provider); + }, + }, + probe: async (hostId, probeOptions) => { + require("ssh:connect"); + return ctx.ssh.probe(hostId, probeOptions); + }, connect: async (host, connectOptions) => { require("ssh:connect"); require("credentials:use"); return ctx.ssh.connect(host, connectOptions); }, + exec: async (host, command, execOptions) => { + require("ssh:connect"); + require("credentials:use"); + return ctx.ssh.exec(host, command, execOptions); + }, withConnection: async (host, connectOptions, fn) => { require("ssh:connect"); require("credentials:use"); @@ -1811,6 +2161,13 @@ export function createMockCtx( require("users:impersonate"); return ctx.auth.issueUserToken(userId, tokenOptions); }, + policy: { + provide: (provider) => { + require("auth:policy"); + return ctx.auth.policy.provide(provider); + }, + }, + listSecondFactors: () => ctx.auth.listSecondFactors(), }, users: { @@ -1820,6 +2177,22 @@ export function createMockCtx( }, }, + audit: { + record: (entry) => ctx.audit.record(entry), + subscribe: (handler) => { + require("audit:read"); + return ctx.audit.subscribe(handler); + }, + list: async (listOptions) => { + require("audit:read"); + return ctx.audit.list(listOptions); + }, + exportBatches: (filters, batchSize) => { + require("audit:read"); + return ctx.audit.exportBatches(filters, batchSize); + }, + }, + desktop: { openIsolatedWindow: async (request) => { require("desktop:window"); @@ -1830,9 +2203,16 @@ export function createMockCtx( return ctx.desktop.launchExternalClient(request); }, available: () => ctx.desktop.available(), + local: gateDesktopLocal(ctx.desktop.local, () => + require("desktop:local"), + ), }, credentials: { + list: async () => { + require("credentials:use"); + return ctx.credentials.list(); + }, listSshKeys: async () => { require("credentials:use"); return ctx.credentials.listSshKeys(); @@ -1851,6 +2231,14 @@ export function createMockCtx( } return ctx.credentials.resolveHostProtocol(hostId, protocol); }, + publicKey: async (credentialId) => { + require("credentials:use"); + return ctx.credentials.publicKey(credentialId); + }, + resolveKeyRefs: async (key, passphrase) => { + require("credentials:read"); + return ctx.credentials.resolveKeyRefs(key, passphrase); + }, registerSecretResolver: (scheme, resolve) => { require("auth:provide"); ctx.credentials.registerSecretResolver(scheme, resolve); @@ -1877,6 +2265,13 @@ export function createMockCtx( return ctx.fetch(url, init); }, + network: { + tcpProbe: async (host, port, probeOptions) => { + require("network:probe"); + return ctx.network.tcpProbe(host, port, probeOptions); + }, + }, + process: { run: async (file, args, runOptions) => { require("process:spawn"); diff --git a/packages/plugin-sdk/src/testing/setup.ts b/packages/plugin-sdk/src/testing/setup.ts index 80fbf1892..fa8b77956 100644 --- a/packages/plugin-sdk/src/testing/setup.ts +++ b/packages/plugin-sdk/src/testing/setup.ts @@ -97,6 +97,7 @@ const offline = () => useHostStatus: () => null, useCurrentUser: () => null, useTheme: () => ({ theme: "dark" }), + useShell: () => null, toast: { success: noop, error: noop, info: noop, warning: noop }, getApi: () => ({ get: offline, @@ -138,6 +139,7 @@ const offline = () => }), hostProtocols: () => ["ssh"], useHostActions: () => [], + useCompanions: () => [], useActivityTypes: () => [], activityTarget: () => undefined, useExtensions: () => [], diff --git a/packages/plugin-sdk/src/theme-css.ts b/packages/plugin-sdk/src/theme-css.ts new file mode 100644 index 000000000..883480e03 --- /dev/null +++ b/packages/plugin-sdk/src/theme-css.ts @@ -0,0 +1,150 @@ +/** + * Checks a theme's CSS before core serves it. + * + * A theme is colors and styles only, so its file may hold nothing but rules + * whose every selector starts with the theme's own class (`.nord`, + * `:root.nord`, `html.nord ...`), optionally inside @media or @supports. + * Anything that could load from elsewhere or run code is refused: @import, + * url() to another host, other at-rules, expression() and javascript:. + */ + +const ALLOWED_BLOCK_AT_RULES = new Set(["media", "supports"]); + +function stripComments(css: string): string { + return css.replace(/\/\*[\s\S]*?\*\//g, " "); +} + +/** Splits on commas that are not inside parentheses or brackets. */ +function splitSelectors(prelude: string): string[] { + const parts: string[] = []; + let depth = 0; + let current = ""; + for (const ch of prelude) { + if (ch === "(" || ch === "[") depth += 1; + if (ch === ")" || ch === "]") depth -= 1; + if (ch === "," && depth === 0) { + parts.push(current); + current = ""; + continue; + } + current += ch; + } + parts.push(current); + return parts.map((part) => part.trim()); +} + +function withoutStrings(css: string): string { + return css.replace(/"(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*'/g, '""'); +} + +function escapeRegExp(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +/** Index of the brace closing the one at `open`, skipping strings. */ +function matchingBrace(css: string, open: number): number { + let depth = 0; + let quote: string | null = null; + for (let i = open; i < css.length; i += 1) { + const ch = css[i]; + if (quote) { + if (ch === "\\") i += 1; + else if (ch === quote) quote = null; + continue; + } + if (ch === '"' || ch === "'") quote = ch; + else if (ch === "{") depth += 1; + else if (ch === "}") { + depth -= 1; + if (depth === 0) return i; + } + } + return -1; +} + +function checkUrls(css: string, errors: string[]): void { + const urls = css.matchAll(/url\(\s*(['"]?)([^'")]*)\1\s*\)/gi); + for (const match of urls) { + const target = match[2].trim(); + if (target.startsWith("data:")) continue; + if (/^[a-z][a-z0-9+.-]*:/i.test(target) || target.startsWith("//")) { + errors.push(`url(${target}) points outside the plugin`); + } + } +} + +function checkRules( + css: string, + scope: RegExp, + themeId: string, + errors: string[], +): void { + let index = 0; + while (index < css.length) { + const rest = css.slice(index); + if (rest.trim() === "") return; + const open = css.indexOf("{", index); + const semicolon = css.indexOf(";", index); + if (semicolon !== -1 && (open === -1 || semicolon < open)) { + const statement = css.slice(index, semicolon).trim(); + errors.push( + statement.startsWith("@") + ? `${statement.split(/\s/)[0]} is not allowed in a theme` + : `Unexpected "${statement}" outside a rule`, + ); + index = semicolon + 1; + continue; + } + if (open === -1) { + errors.push(`Unexpected "${rest.trim()}" at the end`); + return; + } + const close = matchingBrace(css, open); + if (close === -1) { + errors.push("A rule is missing its closing brace"); + return; + } + const prelude = css.slice(index, open).trim(); + const body = css.slice(open + 1, close); + + if (prelude.startsWith("@")) { + const name = prelude.slice(1).split(/[\s(]/)[0].toLowerCase(); + if (ALLOWED_BLOCK_AT_RULES.has(name)) { + checkRules(body, scope, themeId, errors); + } else { + errors.push(`@${name} is not allowed in a theme`); + } + } else { + for (const selector of splitSelectors(prelude)) { + if (!scope.test(selector)) { + errors.push( + `Selector "${selector}" must start with .${themeId}, :root.${themeId} or html.${themeId}`, + ); + } + } + if (withoutStrings(body).includes("{")) { + errors.push(`Nested rules are not allowed in "${prelude}"`); + } + } + index = close + 1; + } +} + +/** Every problem with a theme's CSS, empty when it is safe to serve. */ +export function validateThemeCss(css: string, themeId: string): string[] { + const errors: string[] = []; + const clean = stripComments(css); + if (/@import\b/i.test(clean)) + errors.push("@import is not allowed in a theme"); + if (/expression\s*\(/i.test(clean)) + errors.push("expression() is not allowed in a theme"); + if (/javascript:/i.test(clean)) + errors.push("javascript: is not allowed in a theme"); + if (/-moz-binding|behavior\s*:/i.test(clean)) + errors.push("Script bindings are not allowed in a theme"); + checkUrls(clean, errors); + const id = escapeRegExp(themeId); + const scope = new RegExp(`^(?::root|html)?\\.${id}(?![\\w-])`); + checkRules(clean, scope, themeId, errors); + return [...new Set(errors)]; +} diff --git a/packages/plugin-sdk/src/vitest-preset.ts b/packages/plugin-sdk/src/vitest-preset.ts index 6bfd03b43..16a367dab 100644 --- a/packages/plugin-sdk/src/vitest-preset.ts +++ b/packages/plugin-sdk/src/vitest-preset.ts @@ -9,6 +9,7 @@ * Two projects: tests/backend runs in node, tests/frontend in jsdom. */ +import { createRequire } from "node:module"; import fs from "node:fs"; import path from "node:path"; import { fileURLToPath } from "node:url"; @@ -117,9 +118,41 @@ export function pluginVitestConfig( const plugins = [wasmBinaryPlugin()]; + // A local SDK linked into node_modules sits outside the plugin folder, so + // Vite must be allowed to read it, and React must come from the plugin. + const sdkDir = fs.realpathSync(fileURLToPath(new URL("..", import.meta.url))); + const server = { fs: { allow: [pluginDir, sdkDir] } }; + // dedupe alone is not enough: react-dom or the testing library loaded from + // the linked SDK's own node_modules pull in a second React, and hooks then + // see no renderer. + if (!sdkDir.startsWith(pluginDir)) { + const pluginRequire = createRequire(path.join(pluginDir, "package.json")); + for (const name of [ + "react", + "react-dom", + "@testing-library/react", + "react-i18next", + "i18next", + ]) { + if (alias[name]) continue; + try { + alias[name] = path.dirname( + pluginRequire.resolve(`${name}/package.json`), + ); + } catch { + // Not installed in the plugin: leave it to normal resolution. + } + } + } + const resolve = { + alias, + dedupe: ["react", "react-dom", "react-i18next", "i18next"], + }; + return { plugins, - resolve: { alias }, + server, + resolve, test: { globals: true, setupFiles: [setupFile], @@ -127,7 +160,8 @@ export function pluginVitestConfig( projects: [ { plugins, - resolve: { alias }, + server, + resolve, test: { name: `${pluginId}:backend`, root: pluginDir, @@ -140,7 +174,8 @@ export function pluginVitestConfig( }, { plugins, - resolve: { alias }, + server, + resolve, test: { name: `${pluginId}:frontend`, root: pluginDir, diff --git a/packages/plugin-sdk/tsconfig.plugin.json b/packages/plugin-sdk/tsconfig.plugin.json index 911890125..2ca748f5a 100644 --- a/packages/plugin-sdk/tsconfig.plugin.json +++ b/packages/plugin-sdk/tsconfig.plugin.json @@ -12,6 +12,7 @@ "moduleDetection": "force", "esModuleInterop": true, "resolveJsonModule": true, + "preserveSymlinks": true, "noEmit": true, "strict": false, "noUnusedLocals": false, diff --git a/packages/plugin-sdk/ui.api.txt b/packages/plugin-sdk/ui.api.txt index 3a9b122c3..a2e578d7c 100644 --- a/packages/plugin-sdk/ui.api.txt +++ b/packages/plugin-sdk/ui.api.txt @@ -1,5 +1,10 @@ +ACCENT_PRESET_COLORS (value): { label: string; value: string; }[] +APP_VERSION (value): string +AccessRecord (type): AccessRecord ActionSlot (value): ({ slotId, context, className, enabled, renderItem, hideLabels, when, }: ActionSlotProps) => Element +ActiveSessionInfo (type): ActiveSessionInfo AddButton (value): ({ label, compact, className, ...rest }: { label?: string; compact?: boolean; className?: string; } & Omit, "children"> & { ref?: Ref; }) => Element +AdminSection (type): AdminSection Alert (value): ({ className, variant, ...props }: ClassAttributes & HTMLAttributes & VariantProps<(props?: ConfigVariants<{ variant: { default: string; destructive: string; }; }> & ClassProp) => string>) => Element AlertDescription (value): ({ className, ...props }: DetailedHTMLProps, HTMLDivElement>) => Element AlertDialog (value): ({ ...props }: AlertDialogProps) => Element @@ -14,14 +19,23 @@ AlertDialogPortal (value): ({ ...props }: AlertDialogPortalProps) => Element AlertDialogTitle (value): ({ className, ...props }: AlertDialogTitleProps & RefAttributes) => Element AlertDialogTrigger (value): ({ ...props }: AlertDialogTriggerProps & RefAttributes) => Element AlertTitle (value): ({ className, ...props }: DetailedHTMLProps, HTMLDivElement>) => Element +ApiKey (type): ApiKey +AuditLog (type): AuditLog +AuditLogFilters (type): AuditLogFilters +AuthEnrollmentSections (value): ({ factorsOnly, }?: { factorsOnly?: boolean; }) => Element +AuthOverrideProtocol (type): string BackButton (value): ({ onClick, label, className, }: { onClick: () => void; label?: string; className?: string; }) => Element Badge (value): ({ className, variant, asChild, ...props }: ClassAttributes & HTMLAttributes & VariantProps<(props?: ConfigVariants<{ variant: { default: string; secondary: string; destructive: string; outline: string; }; }> & ClassProp) => string> & { asChild?: boolean; }) => Element BarSeries (value): ({ items, max, className, colorClassName, }: BarSeriesProps) => Element BarSeriesItem (type): BarSeriesItem BarSeriesProps (type): BarSeriesProps +BrandingSettings (type): BrandingSettings BrowserSignInDialog (value): ({ isOpen, label, url, code, onContinue, onCancel, onOpenUrl, backgroundColor, }: BrowserSignInDialogProps) => Element Button (value): ({ className, variant, size, asChild, ...props }: ClassAttributes & ButtonHTMLAttributes & VariantProps<(props?: ConfigVariants<{ variant: { default: string; outline: string; secondary: string; ghost: string; destructive: string; link: string; }; size: { default: string; xs: string; sm: string; lg: string; icon: string; "icon-xs": string; "icon-sm": string; "icon-lg": string; }; }> & ClassProp) => string> & { asChild?: boolean; }) => Element ButtonProps (type): ButtonProps +CORE_HOST_DEFAULTS (value): readonly CoreHostDefault[] +CORE_NAMESPACE (value): "core" +CREDENTIAL_ROW_FIELD_KEYS (value): (keyof CredentialRowFields)[] Card (value): ({ className, size, ...props }: ClassAttributes & HTMLAttributes & { size?: "default" | "sm"; }) => Element CardAction (value): ({ className, ...props }: DetailedHTMLProps, HTMLDivElement>) => Element CardContent (value): ({ className, ...props }: DetailedHTMLProps, HTMLDivElement>) => Element @@ -33,6 +47,12 @@ CardMasonry (value): ({ columns, stack, className, children, }: { columns: numbe CardTitle (value): ({ className, ...props }: DetailedHTMLProps, HTMLDivElement>) => Element Checkbox (value): ({ className, ...props }: CheckboxProps & RefAttributes) => Element ColumnCountStepper (value): ({ columns, onChange, }: { columns: number; onChange: (columns: number) => void; }) => Element +Command (value): ForwardRefExoticComponent, HTMLDivElement>, "key" | keyof HTMLAttributes> & { ref?: Ref; } & { asChild?: boolean; }, "key" | "asChild" | keyof HTMLAttributes> & { label?: string; shouldFilter?: boolean; filter?: CommandFilter; defaultValue?: string; value?: string; onValueChange?: (value: string) => void; loop?: boolean; disablePointerSelection?: boolean; vimBindings?: boolean; } & RefAttributes, "ref"> & RefAttributes> +CommandEmpty (value): ForwardRefExoticComponent, HTMLDivElement>, "key" | keyof HTMLAttributes> & { ref?: Ref; } & { asChild?: boolean; }, "key" | "asChild" | keyof HTMLAttributes> & RefAttributes, "ref"> & RefAttributes> +CommandGroup (value): ForwardRefExoticComponent, HTMLDivElement>, "key" | keyof HTMLAttributes> & { ref?: Ref; } & { asChild?: boolean; }, "key" | "asChild" | keyof HTMLAttributes>, "value" | "heading"> & { heading?: ReactNode; value?: string; forceMount?: boolean; } & RefAttributes, "ref"> & RefAttributes> +CommandItem (value): ForwardRefExoticComponent, HTMLDivElement>, "key" | keyof HTMLAttributes> & { ref?: Ref; } & { asChild?: boolean; }, "key" | "asChild" | keyof HTMLAttributes>, "disabled" | "value" | "onSelect"> & { disabled?: boolean; onSelect?: (value: string) => void; value?: string; keywords?: string[]; forceMount?: boolean; } & RefAttributes, "ref"> & RefAttributes> +CommandList (value): ForwardRefExoticComponent, HTMLDivElement>, "key" | keyof HTMLAttributes> & { ref?: Ref; } & { asChild?: boolean; }, "key" | "asChild" | keyof HTMLAttributes> & { label?: string; } & RefAttributes, "ref"> & RefAttributes> +CommandPrimitive (value): ForwardRefExoticComponent, HTMLDivElement>, "key" | keyof HTMLAttributes> & { ref?: Ref; } & { asChild?: boolean; }, "key" | "asChild" | keyof HTMLAttributes> & { label?: string; shouldFilter?: boolean; filter?: CommandFilter; defaultValue?: string; value?: string; onValueChange?: (value: string) => void; loop?: boolean; disablePointerSelection?: boolean; vimBindings?: boolean; } & RefAttributes> & { List: ForwardRefExoticComponent, HTMLDivElement>, "key" | keyof HTMLAttributes> & { ref?: Ref; } & { asChild?: boolean; }, "key" | "asChild" | keyof HTMLAttributes> & { label?: string; } & RefAttributes>; Item: ForwardRefExoticComponent, HTMLDivElement>, "key" | keyof HTMLAttributes> & { ref?: Ref; } & { asChild?: boolean; }, "key" | "asChild" | keyof HTMLAttributes>, "disabled" | "value" | "onSelect"> & { disabled?: boolean; onSelect?: (value: string) => void; value?: string; keywords?: string[]; forceMount?: boolean; } & RefAttributes>; Input: ForwardRefExoticComponent, HTMLInputElement>, "key" | keyof InputHTMLAttributes> & { ref?: Ref; } & { asChild?: boolean; }, "key" | "asChild" | keyof InputHTMLAttributes>, "type" | "value" | "onChange"> & { value?: string; onValueChange?: (search: string) => void; } & RefAttributes>; Group: ForwardRefExoticComponent, HTMLDivElement>, "key" | keyof HTMLAttributes> & { ref?: Ref; } & { asChild?: boolean; }, "key" | "asChild" | keyof HTMLAttributes>, "value" | "heading"> & { heading?: ReactNode; value?: string; forceMount?: boolean; } & RefAttributes>; Separator: ForwardRefExoticComponent, HTMLDivElement>, "key" | keyof HTMLAttributes> & { ref?: Ref; } & { asChild?: boolean; }, "key" | "asChild" | keyof HTMLAttributes> & { alwaysRender?: boolean; } & RefAttributes>; Dialog: ForwardRefExoticComponent, HTMLDivElement>, "key" | keyof HTMLAttributes> & { ref?: Ref; } & { asChild?: boolean; }, "key" | "asChild" | keyof HTMLAttributes> & { label?: string; shouldFilter?: boolean; filter?: CommandFilter; defaultValue?: string; value?: string; onValueChange?: (value: string) => void; loop?: boolean; disablePointerSelection?: boolean; vimBindings?: boolean; } & { overlayClassName?: string; contentClassName?: string; container?: HTMLElement; } & RefAttributes>; Empty: ForwardRefExoticComponent, HTMLDivElement>, "key" | keyof HTMLAttributes> & { ref?: Ref; } & { asChild?: boolean; }, "key" | "asChild" | keyof HTMLAttributes> & RefAttributes>; Loading: ForwardRefExoticComponent, HTMLDivElement>, "key" | keyof HTMLAttributes> & { ref?: Ref; } & { asChild?: boolean; }, "key" | "asChild" | keyof HTMLAttributes> & { progress?: number; label?: string; } & RefAttributes>; } ComponentSlot (value): ({ slotId, props, when, }: { slotId: string; props?: Record; when?: Record; }) => Element ConfirmOptions (type): ConfirmOptions ConnectionGate (value): (props: ConnectionGateProps) => Element @@ -45,9 +65,26 @@ ConnectionScreen (value): ({ status, message, detail, errorDetail, unavailable, ConnectionStage (type): ConnectionStage ConnectionStatus (type): ConnectionStatus ConnectionUnavailable (type): ConnectionUnavailable +CreatedApiKey (type): CreatedApiKey +Credential (type): Credential +CredentialDensity (type): CredentialDensity +CredentialPermissionLevel (type): CredentialPermissionLevel +CredentialRowFields (type): CredentialRowFields +CredentialSidebarFilterState (type): CredentialSidebarFilterState +CredentialSidebarPreferences (type): CredentialSidebarPreferences +CredentialSortKey (type): CredentialSortKey +CredentialTrayTrigger (type): CredentialTrayTrigger CustomKeybinding (type): CustomKeybinding +DASHBOARD_CARDS (value): DashboardCardConfig[] +DEFAULT_FOLDER_COLOR (value): "#f59145" +DEFAULT_FOLDER_ICON (value): "folder" +DashboardCardId (type): DashboardCardId DataColumn (type): DataColumn DataView (value): ({ items, view, density, getKey, renderCard, columns, listColumns, onRowClick, isRowActive, empty, className, }: { items: T[]; view: PanelViewMode; density?: PanelDensity; getKey: (item: T) => string; renderCard: (item: T) => ReactNode; columns: GridColumns; listColumns: DataColumn[]; onRowClick?: (item: T) => void; isRowActive?: (item: T) => boolean; empty: ReactNode; className?: string; }) => Element +DefaultOverrides (type): DefaultOverrides +DefaultsOnly (value): ({ settingKey, children, }: { settingKey: string; children: ReactNode; }) => Element +DesktopUpdateMode (type): DesktopUpdateMode +DesktopUpdateState (type): DesktopUpdateState Dialog (value): ({ ...props }: DialogProps) => Element DialogClose (value): ({ ...props }: DialogCloseProps & RefAttributes) => Element DialogContent (value): ({ className, children, showCloseButton, ...props }: DialogContentProps & RefAttributes & { showCloseButton?: boolean; }) => Element @@ -60,6 +97,7 @@ DialogTitle (value): ({ className, ...props }: DialogTitleProps & RefAttributes< DialogTrigger (value): ({ ...props }: DialogTriggerProps & RefAttributes) => Element DocsLink (value): ({ core, page, anchor, href, variant, children, className, }: DocsLinkProps) => Element DocsLinkProps (type): DocsLinkProps +DocsPage (type): "dashboard" | "users" | "sessions" | "roles" | "branding" | "database" | "manage" | "settings" | "plugins" | "install" | "cli" | "develop" | "home" | "updating" | "betas" | "envVars" | "https" | "reverseProxy" | "desktopSync" | "interface" | "hosts" | "portKnocking" | "hostDefaults" | "credentials" | "sharing" | "account" | "accountSecurity" | "accountData" | "apiKeys" | "commandPalette" | "interfaceSettings" | "appearance" | "keybindings" | "adminGeneral" | "adminDatabase" | "adminSsl" | "auditLog" | "importExport" | "pluginCatalog" | "communityRegistry" | "pluginRegistries" | "hostRegistry" DropdownMenu (value): ({ ...props }: DropdownMenuProps) => Element DropdownMenuCheckboxItem (value): ({ className, children, checked, inset, ...props }: DropdownMenuCheckboxItemProps & RefAttributes & { inset?: boolean; }) => Element DropdownMenuContent (value): ({ className, align, sideOffset, ...props }: DropdownMenuContentProps & RefAttributes) => Element @@ -78,10 +116,14 @@ DropdownMenuTrigger (value): ({ ...props }: DropdownMenuTriggerProps & RefAttrib EditingWidth (type): EditingWidth EmptyState (value): ({ icon: Icon, title, hint, action, className, }: { icon?: LucideIcon; title: string; hint?: string; action?: ReactNode; className?: string; }) => Element FOLDER_COLORS (value): string[] +FONT_SIZES (value): { id: FontSizeId; label: string; }[] Facts (value): ({ className, children, }: { className?: string; children: ReactNode; }) => Element FakeSwitch (value): ({ defaultChecked, disabled, checked, onChange, }: { defaultChecked?: boolean; disabled?: boolean; checked?: boolean; onChange?: (v: boolean) => void; }) => Element Field (value): ({ label, hint, error, aside, htmlFor, className, children, }: { label: string; hint?: ReactNode; error?: string; aside?: ReactNode; htmlFor?: string; className?: string; children: ReactNode; }) => Element FieldPair (value): ({ children }: { children: ReactNode; }) => Element +FolderAccessRule (type): FolderAccessRule +FolderIconEl (value): ({ icon, className, style, }: { icon?: string; className?: string; style?: CSSProperties; }) => Element +FontSizeId (type): FontSizeId FormFooter (value): ({ onCancel, onSave, saveLabel, cancelLabel, saving, dirty, status, disabled, onDelete, deleteLabel, extra, saveType, form, }: { onCancel?: () => void; onSave?: () => void; saveLabel?: string; cancelLabel?: string; saving?: boolean; dirty?: boolean; status?: ReactNode; disabled?: boolean; onDelete?: () => void; deleteLabel?: string; extra?: ReactNode; saveType?: "button" | "submit"; form?: string; }) => Element FullScreenAppPhase (type): FullScreenAppPhase FullScreenAppWrapper (value): FC @@ -91,11 +133,36 @@ GridColumns (type): GridColumns GridLayout (type): GridLayout GridSlot (type): GridSlot GroupHeading (value): ({ title, count, action, className, }: { title: string; count?: number; action?: ReactNode; className?: string; }) => Element +HexColorPicker (value): ({ value, onChange, }: { value: string; onChange: (hex: string) => void; }) => Element +Host (type): Host +HostActionDef (type): HostActionDef +HostClickBehavior (type): HostClickBehavior +HostData (type): HostData HostDefaultBadge (value): ({ settingKey }: { settingKey: string; }) => Element HostDefaultField (value): ({ settingKey, children, }: { settingKey: string; children: ReactNode; }) => Element +HostDefaultsChange (type): HostDefaultsChange +HostDefaultsContext (value): Context +HostDefaultsContextValue (type): HostDefaultsContextValue +HostDefaultsLevel (type): HostDefaultsLevel +HostDefaultsTarget (type): HostDefaultsTarget +HostDensity (type): HostDensity HostFeatureFields (value): ({ form, updateForm, }: HostFeatureFieldsProps) => Element HostFeatureFieldsProps (type): HostFeatureFieldsProps +HostFolder (type): HostFolder +HostGroupKey (type): HostGroupKey HostKeyVerificationDialog (value): ({ isOpen, scenario, ip, port, hostname, fingerprint, oldFingerprint, onAccept, onReject, backgroundColor, }: HostKeyVerificationDialogProps) => Element +HostLock (type): HostLock +HostOnly (value): ({ children }: { children: ReactNode; }) => Element +HostPolicy (type): HostPolicy +HostProtocolAuthInput (type): HostProtocolAuthInput +HostProtocolDef (type): HostProtocolDef +HostProtocols (type): HostProtocols +HostRowFields (type): HostRowFields +HostSidebarFilterState (type): HostSidebarFilterState +HostSidebarPreferences (type): HostSidebarPreferences +HostTrayTrigger (type): HostTrayTrigger +HttpsSettings (type): HttpsSettings +IconPicker (value): ({ value, color, onChange, }: { value: string; color?: string; onChange: (icon: string) => void; }) => Element InlineView (value): ({ open, onOpenChange, title, icon, status, actions, toolbar, footer, width, onBeforeClose, scroll, bare, className, children, }: { open: boolean; onOpenChange: (open: boolean) => void; title: ReactNode; icon?: ReactNode; status?: ReactNode; actions?: ReactNode; toolbar?: ReactNode; footer?: ReactNode; width?: EditingWidth; onBeforeClose?: () => boolean | Promise; scroll?: boolean; bare?: boolean; className?: string; children: ReactNode; }) => ReactPortal Input (value): ({ className, type, ...props }: DetailedHTMLProps, HTMLInputElement>) => Element Kbd (value): ForwardRefExoticComponent> @@ -103,10 +170,13 @@ KbdKey (value): { ({ className, ...props }: HTMLAttributes): El KbdSeparator (value): { ({ className, ...props }: HTMLAttributes): Element; displayName: string; } KeyCombo (type): KeyCombo KeybindingAction (type): KeybindingAction +KeybindingActionDef (type): KeybindingActionDef KeybindingActionType (type): string +LANGUAGES (value): readonly { code: string; label: string; }[] Label (value): ({ className, ...props }: LabelProps & RefAttributes) => Element LineChart (value): ({ series, timestamps, domain, yFormatter, height, className, }: LineChartProps) => Element LineChartSeries (type): LineChartSeries +LinkedAccountInfo (type): LinkedAccountInfo ListBadge (value): ({ tone, className, children, }: { tone?: "destructive" | "muted" | "brand" | "warning" | "success"; className?: string; children: ReactNode; }) => Element ListRow (value): ({ title, icon, badges, meta, stripe, tone, color, selected, active, dimmed, leading, trailing, actions, onClick, className, children, ...rest }: { title: ReactNode; icon?: ReactNode; badges?: ReactNode; meta?: ReactNode; stripe?: number; tone?: ListRowTone; color?: string; selected?: boolean; active?: boolean; dimmed?: boolean; leading?: ReactNode; trailing?: ReactNode; actions?: ReactNode; onClick?: () => void; className?: string; children?: ReactNode; } & Omit, "title" | "children" | "onClick">) => Element ListRowAction (value): ({ label, tone, className, children, ...rest }: { label: string; tone?: "default" | "destructive" | "brand"; className?: string; children: ReactNode; } & Omit, "children"> & { ref?: Ref; }) => Element @@ -114,18 +184,29 @@ ListRowFolder (value): ({ name, icon, count, open, onToggle, stripe, actions, dr ListRowTone (type): ListRowTone LogContext (type): LogContext LogEntry (type): LogEntry +LookSettings (value): ({ isAdmin }: { isAdmin: boolean; }) => Element +MANAGE_REQUEST_EVENT (value): "manage:request" +MAX_PANES (value): 8 MFAPromptMode (type): MFAPromptMode +ManageMode (type): ManageMode +ManageRequest (type): ManageRequest ManagerCardError (type): ManagerCardError ManagerCardShell (value): ({ title, icon, loading, error, onRefresh, empty, emptyMessage, children, headerExtra, }: { title: string; icon: ReactNode; loading: boolean; error: ManagerCardError; onRefresh: () => void; empty?: boolean; emptyMessage?: string; children: ReactNode; headerExtra?: ReactNode; }) => Element ManagerSearch (value): ({ value, onChange, placeholder, count, extra, }: { value: string; onChange: (value: string) => void; placeholder?: string; count?: number; extra?: ReactNode; }) => Element Meter (value): ({ percent, className, }: { percent: number; className?: string; }) => Element MetricCard (value): ({ title, icon, action, children, bodyClassName, scroll, scrollMax, }: { title: string; icon: ReactNode; action?: ReactNode; children: ReactNode; bodyClassName?: string; scroll?: boolean; scrollMax?: number; }) => Element MiniStat (value): ({ caption, value, valueClassName, className, }: { caption: ReactNode; value: ReactNode; valueClassName?: string; className?: string; }) => Element +NativeView (value): ({ url, ignoreCert, hidden, fallback, className, style, }: NativeViewProps) => Element +NativeViewProps (type): NativeViewProps NumberField (value): ({ label, value, onChange, placeholder, hint, error, min, max, disabled, className, }: { label: string; value: number; onChange: (value: number) => void; placeholder?: string; hint?: ReactNode; error?: string; min?: number; max?: number; disabled?: boolean; className?: string; }) => Element +OPEN_PLUGINS_EVENT (value): "termix:open-plugins" +OpenTabRecord (type): OpenTabRecord PANEL (value): { readonly body: "p-2.5"; readonly gap: "gap-2"; readonly band: "px-3 py-2"; readonly cardHead: "px-3 py-2.5"; readonly header: "h-12.5"; } +PRESETS (value): Record<"simple" | "balanced" | "advanced", UiAreaPreferences> PROMPT_BUTTON (value): "inline-flex h-7 items-center justify-center gap-1.5 border border-border px-3 text-xs text-muted-foreground transition-colors hover:bg-muted hover:text-foreground focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50" PROMPT_DESTRUCTIVE_BUTTON (value): "inline-flex h-7 items-center justify-center gap-1.5 bg-destructive px-3 text-xs font-medium text-white transition-colors hover:bg-destructive/90 focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50" PROMPT_PRIMARY_BUTTON (value): "inline-flex h-7 items-center justify-center gap-1.5 border border-accent-brand/40 px-3 text-xs font-medium text-accent-brand transition-colors hover:bg-accent-brand/10 focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50" +PaletteItemDef (type): PaletteItemDef PanePrompt (value): ({ open, title, titleId, description, icon, tone, actions, onCancel, role, labelledBy, layer, backgroundColor, className, children, }: { open: boolean; title: ReactNode; titleId?: string; description?: ReactNode; icon?: ReactNode; tone?: "default" | "destructive"; actions?: ReactNode; onCancel?: () => void; role?: "dialog" | "alertdialog"; labelledBy?: string; layer?: "connection" | "pane"; backgroundColor?: string; className?: string; children?: ReactNode; }) => Element PanelDensity (type): PanelDensity PanelList (value): ({ empty, className, children, }: { empty?: ReactNode; className?: string; children?: ReactNode; }) => Element @@ -134,18 +215,34 @@ PanelShell (value): ({ chrome, icon, title, status, leading, actions, toolbar, t PanelViewMode (type): PanelViewMode PassphraseDialog (value): ({ isOpen, onSubmit, onCancel, hostInfo, backgroundColor, }: PassphraseDialogProps) => Element PasswordInput (value): ForwardRefExoticComponent> +PermissionCatalogEntry (type): PermissionCatalogEntry PluginComponent (value): ({ id, fallback, ...props }: { [prop: string]: unknown; id: string; fallback?: ReactNode; }) => ReactNode +PluginIcon (value): ({ name, ...props }: { name?: string; } & LucideProps) => Element +PluginSettingsErrors (type): PluginSettingsErrors +PluginSettingsField (type): PluginSettingsField +PluginSettingsValidationError (value): typeof PluginSettingsValidationError +PluginSummary (type): PluginSummary PluginViewPlaceholder (value): ({ kind, viewId, compact, }: { kind: ViewKind; viewId: string; compact?: boolean; }) => Element Popover (value): ({ ...props }: PopoverProps) => Element PopoverAnchor (value): ({ ...props }: PopoverAnchorProps & RefAttributes) => Element PopoverContent (value): ({ className, align, sideOffset, ...props }: PopoverContentProps & RefAttributes) => Element PopoverTrigger (value): ({ ...props }: PopoverTriggerProps & RefAttributes) => Element +ProxyNode (type): ProxyNode RadialGauge (value): ({ value, label, caption, size, strokeWidth, className, colorClassName, }: RadialGaugeProps) => Element RadialGaugeProps (type): RadialGaugeProps +RailGroup (type): RailGroup +RailItemDef (type): RailItemDef RecentActivityItem (type): RecentActivityItem +ReleaseInfo (type): ReleaseInfo Repeater (value): ({ label, items, onChange, makeItem, empty, addLabel, hint, renderItem, }: { label: string; items: T[]; onChange: (items: T[]) => void; makeItem: () => T; empty: string; addLabel: string; hint?: ReactNode; renderItem: (item: T, update: (next: T) => void, index: number) => ReactNode; }) => Element +ResolvedHostDefault (type): ResolvedHostDefault RobustClipboardProvider (value): typeof RobustClipboardProvider +Role (type): Role SSHAuthDialog (value): ({ isOpen, reason, onSubmit, onCancel, hostInfo, backgroundColor, }: SSHAuthDialogProps) => Element +SSHHost (type): Host +SSHHostData (type): HostData +SSHHostWithStatus (type): SSHHostWithStatus +SSH_AUTH_PROTOCOL (value): "ssh" ScrollArea (value): ({ className, children, ...props }: ScrollAreaProps & RefAttributes) => Element ScrollBar (value): ({ className, orientation, ...props }: ScrollAreaScrollbarProps & RefAttributes) => Element SecretField (value): ({ label, value, onChange, placeholder, hint, error, disabled, className, }: { label: string; value: string; onChange: (value: string) => void; placeholder?: string; hint?: ReactNode; error?: string; disabled?: boolean; className?: string; }) => Element @@ -165,6 +262,9 @@ SelectTrigger (value): ({ className, size, children, ...props }: SelectTriggerPr SelectValue (value): ({ ...props }: SelectValueProps & RefAttributes) => Element Separator (value): ({ className, orientation, decorative, ...props }: SeparatorProps & RefAttributes) => Element SettingRow (value): ({ label, badge, description, defaultKey, rowId, children, }: { label: string; badge?: string; description?: ReactNode; defaultKey?: string; rowId?: string; children: ReactNode; }) => Element +SettingsFieldRow (value): ({ pluginId, field, values, setValue, running, error, disabled, defaultKey, }: SettingsFieldProps) => Element +SharePermissionLevel (type): SharePermissionLevel +ShareTarget (type): ShareTarget Sheet (value): ({ ...props }: DialogProps) => Element SheetClose (value): ({ ...props }: DialogCloseProps & RefAttributes) => Element SheetContent (value): ({ className, children, side, showCloseButton, ...props }: DialogContentProps & RefAttributes & { side?: "top" | "right" | "bottom" | "left"; showCloseButton?: boolean; }) => Element @@ -173,71 +273,360 @@ SheetFooter (value): ({ className, ...props }: DetailedHTMLProps, HTMLDivElement>) => Element SheetTitle (value): ({ className, ...props }: DialogTitleProps & RefAttributes) => Element SheetTrigger (value): ({ ...props }: DialogTriggerProps & RefAttributes) => Element +ShellConfirmCapture (value): () => null Skeleton (value): ({ className, ...props }: DetailedHTMLProps, HTMLDivElement>) => Element Slider (value): ({ className, defaultValue, value, min, max, ...props }: SliderProps & RefAttributes) => Element +SortKey (type): SortKey Sparkline (value): ({ data, domain, className, colorClassName, showLastDot, height, }: SparklineProps) => Element SparklineProps (type): SparklineProps +SplitLayoutMenu (value): ({ activeIsSplit, canAddPane, onSplit, onPreset, }: { activeIsSplit: boolean; canAddPane: boolean; onSplit: (edge: "right" | "bottom") => void; onPreset: (id: SplitPresetId) => void; }) => Element +SplitOpenTarget (type): SplitOpenTarget +SplitSummary (type): SplitSummary StatRow (value): ({ label, value, mono, className, }: { label: ReactNode; value: ReactNode; mono?: boolean; className?: string; }) => Element +StatusValue (type): StatusValue SurfaceKind (type): SurfaceKind SurfaceScope (value): ({ kind, onEditingChange, onClose, className, children, }: { kind?: SurfaceKind; onClose?: () => void; onEditingChange?: (editing: EditingWidth) => void; className?: string; children: ReactNode; }) => Element Switch (value): ({ className, ...props }: SwitchProps & RefAttributes) => Element SwitchRow (value): ({ label, description, badge, checked, onChange, disabled, defaultKey, rowId, }: { label: string; description?: ReactNode; badge?: string; checked: boolean; onChange: (value: boolean) => void; disabled?: boolean; defaultKey?: string; rowId?: string; }) => Element TOTPDialog (value): ({ isOpen, prompt, mode, waiting, onSubmit, onCancel, backgroundColor, }: TOTPDialogProps) => Element +Tab (type): Tab +TabShellCallbacks (type): TabShellCallbacks +TabSplitAction (type): TabSplitAction +TabStage (value): ({ className }: { className?: string; }) => Element TabStrip (value): ({ tabs, activeTab, onTabChange, isActive, variant, trailing, className, }: { tabs: TabStripItem[]; activeTab: string; onTabChange: (id: string) => void; isActive?: (id: string) => boolean; variant?: "secondary" | "primary"; trailing?: ReactNode; className?: string; }) => Element TabStripItem (type): TabStripItem +TabType (type): TabType TagInput (value): ({ tags, input, placeholder, onTagsChange, onInputChange, }: { tags: string[]; input: string; placeholder: string; onTagsChange: (tags: string[]) => void; onInputChange: (value: string) => void; }) => Element TextAreaField (value): ({ label, value, onChange, placeholder, rows, hint, error, aside, mono, disabled, className, }: { label: string; value: string; onChange: (value: string) => void; placeholder?: string; rows?: number; hint?: ReactNode; error?: string; aside?: ReactNode; mono?: boolean; disabled?: boolean; className?: string; }) => Element TextField (value): ({ label, value, onChange, placeholder, hint, error, aside, disabled, mono, className, }: { label: string; value: string; onChange: (value: string) => void; placeholder?: string; hint?: ReactNode; error?: string; aside?: ReactNode; disabled?: boolean; mono?: boolean; className?: string; }) => Element Textarea (value): ForwardRefExoticComponent> TextareaProps (type): TextareaProps +ThemeId (type): string +TlsStatus (type): TlsStatus +ToolsTab (type): ToolsTab Tooltip (value): ({ ...props }: TooltipProps) => Element TooltipContent (value): ({ className, sideOffset, children, ...props }: TooltipContentProps & RefAttributes) => Element TooltipProvider (value): ({ delayDuration, ...props }: TooltipProviderProps) => Element TooltipTrigger (value): ({ ...props }: TooltipTriggerProps & RefAttributes) => Element +UI_AREA_KEYS (value): UiAreaKey[] +UI_FONTS (value): { id: UiFontId; label: string; family: string; }[] +UiAreaKey (type): UiAreaKey +UiFontId (type): UiFontId +UiPreset (type): UiPreset +UpdateChannelState (type): UpdateChannelState UptimeInfo (type): UptimeInfo UsagePair (value): ({ label, percent, detail, }: { label?: string; percent: number; detail?: string; }) => Element +UserPreferences (type): UserPreferences +UserRole (type): UserRole +UserSecondFactorSummary (type): UserSecondFactorSummary +VersionBadge (value): ({ status, releaseUrl, className, }: VersionBadgeProps) => Element VersionInfo (type): VersionInfo ViewToggle (value): ({ view, onView, density, onDensity, }: { view: PanelViewMode; onView: (next: PanelViewMode) => void; density?: PanelDensity; onDensity?: (next: PanelDensity) => void; }) => Element WebSocketConnectionTarget (type): WebSocketConnectionTarget WidgetTitle (value): ({ title, icon, fallback }: WidgetTitleProps) => Element +actionOrder (value): (action: HostActionDef) => number +activityTarget (value): (type: string) => ActivityTarget +adminCreateUser (value): (username: string, password: string) => Promise> +adminCreateUserCredential (value): (targetUserId: string, credentialData: Record) => Promise> +adminCreateUserHost (value): (targetUserId: string, hostData: HostData) => Promise +adminDeleteUserCredential (value): (targetUserId: string, credentialId: number) => Promise> +adminDeleteUserHost (value): (targetUserId: string, hostId: number) => Promise> +adminExportUserData (value): (userId: string) => Promise> +adminGetHostPassword (value): (targetUserId: string, hostId: number, field?: "key" | "password" | "sudoPassword" | "keyPassword") => Promise +adminGetUserCredentials (value): (targetUserId: string) => Promise | Record[]> +adminGetUserHosts (value): (targetUserId: string) => Promise +adminResetUserPassword (value): (userId: string, newPassword: string, confirmDataWipe?: boolean) => Promise<{ message: string; dataWiped?: boolean; }> +adminUpdateUserCredential (value): (targetUserId: string, credentialId: number, credentialData: Record) => Promise> +adminUpdateUserHost (value): (targetUserId: string, hostId: number, hostData: HostData) => Promise +applyAccentColor (value): (colorValue: string) => void +applyFontSize (value): (id: FontSizeId) => void +applyPresetSideEffects (value): (preset: "simple" | "balanced" | "advanced") => Promise +applyUiFont (value): (id: UiFontId) => void +assignRoleToUser (value): (userId: string, roleId: number) => Promise<{ success: boolean; }> +beginSplitDrag (value): (source: SplitDragSource, x: number, y: number) => void buildOriginWsUrl (value): ({ origin, localPort, localPath, remotePath, includeJwt, }: { origin: ConnectionOrigin; localPort: number; localPath: string; remotePath: string; includeJwt?: boolean; }) => Promise +bulkUpdateSSHHosts (value): (hostIds: number[], updates: Record) => Promise<{ updated: number; failed: number; errors: string[]; }> +canConfigureUpdates (value): (state: DesktopUpdateState) => state is DesktopUpdateState +canJoinSplit (value): (tab: Tab) => boolean +changeAppLanguage (value): (language: string) => Promise +changePassword (value): (oldPassword: string, newPassword: string) => Promise +clearLocalAdaptivePreferences (value): () => void cn (value): (...inputs: ClassValue[]) => string copyToClipboard (value): (text: string) => Promise +createApiKey (value): (name: string, userId: string, expiresAt?: string) => Promise +createCredential (value): (credentialData: Record) => Promise> createFrontendLogger (value): (serviceName: string, serviceIcon?: string, serviceColor?: string) => FrontendLogger createQuickConnectHost (value): (input: QuickConnectInput) => Host +createRole (value): (roleData: { name: string; displayName: string; description?: string; }) => Promise<{ role: Role; }> +createSSHHost (value): (hostData: HostData) => Promise +defaultConnectAction (value): (all: HostActionDef[], host: Host) => HostActionDef +defaultCredentialRowFields (value): () => CredentialRowFields +defaultCredentialSidebarPreferences (value): () => CredentialSidebarPreferences +defaultHostRowFields (value): () => HostRowFields +defaultHostSidebarPreferences (value): () => HostSidebarPreferences +defaultValuesEqual (value): (a: unknown, b: unknown) => boolean +deleteAccount (value): (password: string) => Promise> +deleteAllHostsInFolder (value): (folderName: string) => Promise<{ deletedCount: number; }> +deleteApiKey (value): (keyId: string) => Promise<{ success: boolean; }> +deleteCredential (value): (credentialId: number) => Promise> +deleteOpenTab (value): (instanceId: string) => Promise +deleteRole (value): (roleId: number) => Promise<{ success: boolean; }> +deleteSSHHost (value): (hostId: number) => Promise> +deleteUser (value): (username: string, successorUserId?: string) => Promise> +desktopUpdates (value): () => DesktopUpdatesBridge +docsUrl (value): (page?: "dashboard" | "users" | "sessions" | "roles" | "branding" | "database" | "manage" | "settings" | "plugins" | "install" | "cli" | "develop" | "home" | "updating" | "betas" | "envVars" | "https" | "reverseProxy" | "desktopSync" | "interface" | "hosts" | "portKnocking" | "hostDefaults" | "credentials" | "sharing" | "account" | "accountSecurity" | "accountData" | "apiKeys" | "commandPalette" | "interfaceSettings" | "appearance" | "keybindings" | "adminGeneral" | "adminDatabase" | "adminSsl" | "auditLog" | "importExport" | "pluginCatalog" | "communityRegistry" | "pluginRegistries" | "hostRegistry") => string +duplicateCredential (value): (credentialId: number, data: Record) => Promise> +enabledHostProtocols (value): (host: { pluginSettings?: Record>; }, list?: HostProtocolDef[]) => HostProtocolDef[] +endSplitDrag (value): (commit: boolean) => void +filterPaletteItems (value): (items: PaletteItemDef[], query: string, showWhenEmpty?: boolean) => PaletteItemDef[] +findCoreHostDefault (value): (key: string) => CoreHostDefault findMatchingKeybinding (value): (e: KeyboardEvent, bindings: CustomKeybinding[]) => CustomKeybinding +getActiveSessions (value): () => Promise getAdaptiveResourceBudget (value): (kind: AdaptiveTaskKind) => AdaptiveResourceBudget +getApiKeys (value): () => Promise<{ apiKeys: ApiKey[]; }> +getAuditLogActions (value): () => Promise<{ actions: string[]; }> +getAuditLogs (value): (filters?: AuditLogFilters) => Promise getBasePath (value): () => string +getBranding (value): () => Promise +getChannelReleases (value): () => Promise<{ localVersion: string; stable: ReleaseInfo; beta: ReleaseInfo; }> +getConnectedRemoteApi (value): () => Promise +getCredentialAccess (value): (credentialId: number) => Promise<{ access: AccessRecord[]; }> +getCredentialDetails (value): (credentialId: number) => Promise> +getCredentialSidebarPreferences (value): () => Promise +getCredentials (value): () => Promise | Record[]> getDatabaseHealth (value): () => Promise> +getDatabaseTransferUrl (value): (operation: "export" | "import", { electron, configuredServerUrl, location }: DatabaseTransferUrlOptions) => string +getDefaultConnectionTab (value): (host: Host) => TabType +getDeveloperMode (value): () => Promise getDeviceId (value): () => string +getErrorMessage (value): (error: unknown, fallback?: string) => string +getExternalAutoProvision (value): () => Promise<{ enabled: boolean; }> +getFolderAccess (value): (folder: string) => Promise<{ rules: FolderAccessRule[]; }> +getFolderDefaultsId (value): (name: string) => Promise +getHostAccess (value): (hostId: number, syncId?: string) => Promise<{ accessList: AccessRecord[]; isOwner?: boolean; }> +getHostAuthOverride (value): (hostId: number, protocol: string, sharedCopySyncId?: string) => Promise<{ protocol: string; credentialId: number; }> +getHostDefaultsLevel (value): (target: HostDefaultsTarget) => Promise +getHostEditorSection (value): (id: string) => HostEditorSectionDef +getHostPassword (value): (hostId: number, field?: "key" | "password" | "sudoPassword" | "keyPassword") => Promise +getHostPolicy (value): () => Promise +getHostSidebarPreferences (value): () => Promise +getHostTags (value): () => Promise +getHttpsSettings (value): () => Promise +getLiveHostStatus (value): (hostId: number) => StatusValue +getLogLevel (value): () => Promise<{ level: string; }> +getNotificationPrivateEndpoints (value): () => Promise +getPanel (value): (id: string) => PanelDef +getPasswordLoginAllowed (value): () => Promise<{ allowed: boolean; forced?: boolean; }> +getPasswordResetAllowed (value): () => Promise +getPermissionsCatalog (value): () => Promise<{ catalog: PermissionCatalogEntry[]; }> +getPluginAdminSettings (value): (pluginId: string) => Promise +getPluginHostSettings (value): (pluginId: string, hostId: number) => Promise +getPluginUserSettings (value): (pluginId: string) => Promise +getPlugins (value): () => Promise getPollingEnvironmentMultiplier (value): (connection?: NetworkInformationLike) => number +getPreferredHostAction (value): (hostId: string, candidates: readonly TabType[], fallback: TabType) => TabType getRecentActivity (value): (limit?: number) => Promise +getRegisteredDashboardCard (value): (id: string) => RegisteredDashboardCard +getRegistrationAllowed (value): () => Promise<{ allowed: boolean; }> +getRoles (value): () => Promise<{ roles: Role[]; }> +getSSHFolders (value): () => Promise +getSSHHosts (value): () => Promise +getSecondFactorAfterExternalLogin (value): () => Promise<{ enabled: boolean; }> +getSessionTimeout (value): () => Promise<{ timeoutHours: number; }> +getSessionTimeoutMinutes (value): () => Promise +getSessions (value): () => Promise<{ sessions: { id: string; userId: string; username?: string; deviceType: string; deviceInfo: string; createdAt: string; expiresAt: string; lastActiveAt: string; isRevoked?: boolean; isCurrentSession?: boolean; }[]; }> +getStatusCheckSettings (value): () => Promise<{ statusCheckInterval: number; }> +getStatusClasses (value): (status: boolean | "online" | "offline" | "unknown", scheme: StatusColorScheme, variant: "dot" | "stripe" | "badge", loading?: boolean) => string +getTabType (value): (id: string) => TabTypeDef +getTlsStatus (value): () => Promise +getUpdateChannel (value): () => Promise getUptime (value): () => Promise +getUserInfo (value): () => Promise +getUserList (value): (options?: UserListOptions) => Promise<{ users: UserInfo[]; total?: number; }> +getUserPreferences (value): () => Promise +getUserRoles (value): (userId: string) => Promise<{ roles: UserRole[]; }> +getUserSecondFactors (value): (userId: string) => Promise getVersionInfo (value): (checkRemote?: boolean) => Promise globalShortcutHandler (value): { current: (e: KeyboardEvent) => void; } +groupRailItems (value): (items: RailItemDef[]) => { group: RailGroup; items: RailItemDef[]; }[] +hasVisibleFields (value): (fields: PluginSettingsField[]) => boolean +hostActionsFor (value): (all: HostActionDef[], host: Host, kind?: "open" | "connect") => HostActionDef[] +hostBadgesFor (value): (all: HostBadgeDef[], host: Host) => HostBadgeDef[] +hostEditorToolbarItemsFor (value): (all: HostBadgeDef[], host: Host) => HostBadgeDef[] +hostMenuItemsFor (value): (all: HostContextMenuItemDef[], host: Host) => HostContextMenuItemDef[] +hostProtocolFlags (value): (host: { enableSsh?: boolean; pluginSettings?: Record>; }, list?: HostProtocolDef[]) => HostProtocols +hostStatusIndicatorsFor (value): (all: HostBadgeDef[], host: Host) => HostBadgeDef[] hydrateLocalSharedHostAuth (value): (host: T) => Promise +invalidateHostsAndStatusCaches (value): () => void +invalidateServerStatusCache (value): () => void +isCoreRailView (value): (id: string) => boolean isElectron (value): () => boolean +isFieldShown (value): (field: PluginSettingsField, values: Record) => boolean isMacPlatform (value): () => boolean +isPersistentTabType (value): (type: string) => boolean +isPrerelease (value): (version: string) => boolean +isSessionTabType (value): (type: string) => boolean +isSplitDragging (value): () => boolean +isSplitTab (value): (tab: Tab) => tab is SplitTab isTabJumpHotkey (value): (e: KeyboardEvent) => boolean +linkExternalToPasswordAccount (value): (externalUserId: string, targetUsername: string) => Promise<{ success: boolean; message: string; }> linkedServerUrl (value): () => Promise +listHostActions (value): () => HostActionDef[] +listHostProtocols (value): () => HostProtocolDef[] +loadPaletteGroup (value): (group: PaletteGroupDef) => Promise +logoutUser (value): () => Promise<{ success: boolean; message: string; }> +makeHostSshSubTabs (value): (t: (key: string, options?: { defaultValue?: string; }) => string, protocols?: Record, defaultsMode?: boolean) => HostTab[] +makeHostTabs (value): (t: (key: string, options?: { defaultValue?: string; }) => string, protocols?: Record, defaultsMode?: boolean) => HostTab[] +makeUserAdmin (value): (userId: string) => Promise> +mapCredentials (value): (res: unknown) => Credential[] markAdaptiveResourceUsed (value): (kind: AdaptiveTaskKind, action: string, now?: number) => void +markTabSurfaceUsed (value): (type: TabType) => void +moveRailItem (value): (items: RailItemDef[], id: string, beforeId: string) => string[] +moveSplitDrag (value): (x: number, y: number) => void +normalizeLanguageCode (value): (language?: string) => string +notifySyncChanged (value): () => void +openInSplit (value): (target: SplitOpenTarget, open: () => void) => void +paletteEntriesFor (value): (all: PaletteEntryDef[], scope: "global" | "host", host?: Host) => PaletteEntryDef[] +parseCustomKeybindings (value): (raw?: string) => CustomKeybinding[] +parseServerTime (value): (ts: string) => Date +pluginKey (value): { (pluginId: string, key: string): string; (pluginId: string, key: string): string; } +pluginName (value): (t: Translator, plugin: { id: string; name: string; }) => string pluginWsUrl (value): (pluginId: string, path: string, options?: { origin?: ConnectionOrigin; }) => Promise +preloadTabSurface (value): (type: TabType) => void +previewHostDefaultsLevel (value): (target: HostDefaultsTarget, change: HostDefaultsChange) => Promise<{ changedHosts: number; }> +promotableIds (value): () => string[] +protocolEnabled (value): (pluginSettings: Record>, protocol: HostProtocolDef) => boolean +protocolPort (value): (pluginSettings: Record>, protocol: HostProtocolDef) => number +quickConnectTargets (value): (all: HostActionDef[], host: Host) => HostActionDef[] +railItemLabel (value): (id: string, t: (key: string) => string) => string readFromClipboard (value): () => Promise +recordHostActionPreference (value): (hostId: string, action: TabType) => void +registerLayout (value): (item: RegisteredLayout) => () => void +registerTabCloseGuard (value): (tabId: string, guard: Guard) => () => void +releaseUrlFrom (value): (info: VersionInfo) => string +rem (value): (px: number) => string +removeAdminStatus (value): (userId: string) => Promise> +removeRoleFromUser (value): (userId: string, roleId: number) => Promise<{ success: boolean; }> +renameCredentialFolder (value): (oldName: string, newName: string) => Promise> +renameFolder (value): (oldName: string, newName: string) => Promise> +reorderCredentials (value): (positions: { id: number; sortOrder: number; }[]) => Promise<{ updated: number; }> +reorderFolders (value): (positions: { name: string; sortOrder: number; }[]) => Promise<{ updated: number; }> +reorderSSHHosts (value): (positions: { id: number; sortOrder: number; }[]) => Promise<{ updated: number; }> +reportBetaFeedbackUrl (value): (termixVersion?: string, betaPlugins?: { id: string; version: string; }[]) => string +reportCoreIssueUrl (value): (termixVersion?: string) => string +requestFromLegacyEvent (value): (name: string, detail?: unknown) => ManageRequest +requestManage (value): (request: ManageRequest) => void +resetHostToDefaults (value): (hostIds: number[], reset: { all?: boolean; namespaces?: string[]; keys?: string[]; }) => Promise +resetRecentActivity (value): () => Promise<{ message: string; }> +resetUserSecondFactors (value): (userId: string) => Promise resolveConnectionOrigin (value): (host: OriginResolvableHost, options?: { defaultRemote?: boolean; }) => Promise +resolveHostDefaults (value): (where: { hostId?: number; folder?: string; parentHostId?: number; }, adminTargetUserId?: string) => Promise> +resolveHostTabType (value): (host: Host, preferredType?: TabType) => TabType resolveRemoteHostId (value): (syncId: string) => Promise +revokeAllUserSessions (value): (userId: string) => Promise<{ success: boolean; message: string; }> +revokeCredentialAccess (value): (credentialId: number, accessId: number) => Promise +revokeFolderAccess (value): (ruleId: number) => Promise +revokeHostAccess (value): (hostId: number, accessId: number, syncId?: string) => Promise<{ success: boolean; }> +revokeSession (value): (sessionId: string) => Promise<{ success: boolean; message: string; }> +rightDockableIds (value): () => string[] +roleLabel (value): (t: Translator, displayName: T) => string | T runAdaptiveBackgroundTask (value): (kind: AdaptiveTaskKind, action: string, task: () => Promise, options?: AdaptiveTaskOptions) => boolean runAdaptivePolling (value): (poll: () => AdaptivePollResult | Promise, policy: AdaptivePollingPolicy, options?: { enabled?: () => boolean; visible?: () => boolean; runImmediately?: boolean; random?: () => number; intervalMultiplier?: () => number; onError?: (error: unknown) => void; }) => () => void +runHostAction (value): (action: HostActionDef, host: Host, shell: TabShellCallbacks) => void runVisibleInterval (value): (tick: () => void, intervalMs: number) => () => void +sanitizeCredentialSidebarPreferences (value): (input: unknown) => CredentialSidebarPreferences +sanitizeHostSidebarPreferences (value): (input: unknown) => HostSidebarPreferences +saveCredentialSidebarPreferences (value): (preferences: CredentialSidebarPreferences) => Promise +saveHostDefaultsLevel (value): (target: HostDefaultsTarget, change: HostDefaultsChange) => Promise<{ changedHosts?: number; jobId?: string; }> +saveHostSidebarPreferences (value): (preferences: HostSidebarPreferences) => Promise +saveHostTags (value): (tags: string[]) => Promise +saveUserPreferences (value): (prefs: Partial) => Promise +setAllPluginChannels (value): (channel: PluginChannel) => Promise<{ changed: string[]; }> +setDeveloperMode (value): (enabled: boolean) => Promise +setHostAuthOverride (value): (hostId: number, protocol: string, credentialId: number, sharedCopySyncId?: string) => Promise<{ success: boolean; protocol: string; credentialId: number; }> +setNotificationPrivateEndpoints (value): (hosts: string[]) => Promise +setPluginOptions (value): (pluginId: string, options: { autoUpdate?: boolean; pinned?: boolean; channel?: PluginChannel; }) => Promise<{ autoUpdate: boolean; pinnedVersion: string; channel: PluginChannel; }> +setUpdateChannel (value): (channel: UpdateChannel) => Promise +shareCredential (value): (credentialId: number, targets: ShareTarget[], permissionLevel: CredentialPermissionLevel, durationHours?: number) => Promise<{ success: boolean; expiresAt: string; }> +shareFolder (value): (folder: string, shareData: { targets: ShareTarget[]; permissionLevel: SharePermissionLevel; durationHours?: number; }) => Promise<{ success: boolean; expiresAt: string; hostsShared: number; hostsTotal: number; hostResults: { hostId: number; shared: boolean; reason?: string; }[]; }> +shareHost (value): (hostId: number, shareData: { targets: ShareTarget[]; permissionLevel: SharePermissionLevel; durationHours?: number; }, syncId?: string) => Promise<{ success: boolean; expiresAt: string; results: { type: "role" | "user"; id: string | number; accessId: number; created: boolean; }[]; }> +shell (value): TabShellCallbacks +splitDefaultKey (value): (fullKey: string) => [string, string] +sshHostToHost (value): (h: SSHHostWithStatus) => Host +tabIcon (value): (type: TabType) => Element +tabJumpHotkeyKeys (value): () => string[] +takePendingManageRequest (value): () => ManageRequest +unlinkExternalFromPasswordAccount (value): (userId: string) => Promise<{ success: boolean; message: string; }> +updateCredential (value): (credentialId: number, credentialData: Record) => Promise> +updateExternalAutoProvision (value): (enabled: boolean) => Promise> +updateFolderMetadata (value): (name: string, color?: string, icon?: string, credentialId?: number, localOnly?: boolean) => Promise +updateHostAccess (value): (hostId: number, accessId: number, update: { permissionLevel?: SharePermissionLevel; durationHours?: number; }, syncId?: string) => Promise<{ success: boolean; expiresAt: string; }> +updateHttpsSettings (value): (forceHttps: boolean) => Promise +updateLogLevel (value): (level: string) => Promise +updatePasswordLoginAllowed (value): (allowed: boolean) => Promise<{ allowed: boolean; }> +updatePasswordResetAllowed (value): (allowed: boolean) => Promise<{ allowed: boolean; }> +updatePluginAdminSettings (value): (pluginId: string, values: PluginSettingsValues) => Promise +updatePluginHostSettings (value): (pluginId: string, hostId: number, values: PluginSettingsValues) => Promise +updatePluginUserSettings (value): (pluginId: string, values: PluginSettingsValues) => Promise +updateRegistrationAllowed (value): (allowed: boolean) => Promise> +updateRole (value): (roleId: number, roleData: { displayName?: string; description?: string; permissions?: string[]; }) => Promise<{ role: Role; }> +updateSSHHost (value): (hostId: number, hostData: HostData) => Promise +updateSecondFactorAfterExternalLogin (value): (enabled: boolean) => Promise> +updateSessionTimeout (value): (timeoutHours: number) => Promise +updateStatusCheckSettings (value): (settings: { statusCheckInterval: number; }) => Promise +uploadTlsCertificate (value): (payload: { certificate: string; privateKey: string; }) => Promise usageBarColor (value): (percent: number) => string usageColor (value): (percent: number) => string +useActionSlot (value): (slotId: string, context?: Record) => SlotContribution[] useAdaptivePolling (value): (poll: () => AdaptivePollResult | Promise, policy: AdaptivePollingPolicy, enabled?: boolean, options?: { runImmediately?: boolean; onError?: (error: unknown) => void; }) => void useAppTheme (value): () => ThemeProviderState +useAreaPreferences (value): (area: A) => UiAreaPreferences[A] +useBranding (value): () => BrandingContextValue +useChooseTheme (value): () => (themeId: string) => void useConfirm (value): () => (options: ConfirmOptions) => Promise useConnectionGate (value): () => ConnectionGateController useConnectionLog (value): () => ConnectionLogContextType +useDesktopLinked (value): () => boolean +useDesktopUpdate (value): () => DesktopUpdateState +useEditorDefaultKeys (value): () => Map +useHostActions (value): () => HostActionDef[] +useHostBadges (value): () => HostBadgeDef[] +useHostContextMenuItems (value): () => HostContextMenuItemDef[] +useHostEditorSections (value): () => HostEditorSectionDef[] +useHostEditorToolbarItems (value): () => HostBadgeDef[] +useHostProtocols (value): () => HostProtocolDef[] +useHostStatus (value): (hostId: number, statusCheckEnabled?: boolean) => StatusValue +useHostStatusIndicators (value): () => HostBadgeDef[] +useInstalledPlugins (value): () => PluginSummary[] useIsDefaultsEditor (value): () => boolean useIsMobile (value): () => boolean +useKeybindingActions (value): () => KeybindingActionDef[] +useKeybindingDefaults (value): () => KeybindingDefaultDef[] useOptionalConnectionLog (value): () => ConnectionLogContextType +usePageVisibleInterval (value): (callback: () => void, intervalMs: number, enabled?: boolean, options?: Options) => void +usePaletteEntries (value): () => PaletteEntryDef[] +usePaletteGroups (value): () => PaletteGroupDef[] +usePanels (value): () => PanelDef[] +usePermissions (value): () => PermissionsState +usePluginDocsUrl (value): (pluginId: string, page?: string, anchor?: string) => string +usePluginHostSections (value): () => PluginSummary[] +usePluginStore (value): () => StoreState +useRailItems (value): () => RailItemDef[] +useRegisteredDashboardCards (value): () => RegisteredDashboardCard[] +useServerStatus (value): () => ServerStatusContextType +useServerStatusMeta (value): () => { initialLoadComplete: boolean; } +useShellController (value): () => ShellController +useSplitTargets (value): () => SplitTargetsSnapshot +useSshAuthEditors (value): () => SshAuthEditorDef[] +useSshAuthProviders (value): () => { providers: SshAuthTypeOption[]; loaded: boolean; find: (type: string) => SshAuthTypeOption; } +useStatusColorScheme (value): () => StatusColorScheme useSurfaceKind (value): () => SurfaceKind +useTabHandlesVersion (value): () => number useTabs (value): () => TabContextType useTabsSafe (value): () => TabContextType +useTheme (value): () => ThemeProviderState +useThemeOptions (value): () => ThemeOption[] +useUiPreference (value): (area: A, key: K) => UiAreaPreferences[A][K] +useUiPreferencesContext (value): () => UiPreferencesContextValue +waitForDefaultsJob (value): (id: string, intervalMs?: number, maxWaitMs?: number) => Promise +withProtocolSettings (value): (pluginSettings: Record>, flags: HostProtocols, list?: HostProtocolDef[]) => Record> diff --git a/scripts/bundled-plugins.test.ts b/scripts/bundled-plugins.test.ts index 2f77790c5..2d9e3969d 100644 --- a/scripts/bundled-plugins.test.ts +++ b/scripts/bundled-plugins.test.ts @@ -276,6 +276,33 @@ describe("pinsFromIndex", () => { expect(pins[0]).not.toHaveProperty("onboarding"); }); + it("pins a release kept in gated", () => { + const pins = pinsFromIndex( + { + plugins: [ + { + id: "look", + versions: [], + gated: [ + { + version: "1.1.0-beta.1", + url: "https://example.test/look-1.1.0-beta.1.tmxplug", + sha256: "d".repeat(64), + }, + { + version: "1.0.0", + url: "https://example.test/look-1.0.0.tmxplug", + sha256: "e".repeat(64), + }, + ], + }, + ], + }, + null, + ); + expect(pins[0].url).toBe("https://example.test/look-1.0.0.tmxplug"); + }); + it("pins only the ids asked for", () => { expect(pinsFromIndex(index, ["beta"]).map((pin) => pin.id)).toEqual([ "beta", @@ -416,3 +443,71 @@ describe("rebuilding local plugins", () => { expect(run.sort()).toEqual(["a", "b"]); }); }); + +describe("default look", () => { + const entry = { source: "tmxplug", path: "a", sha256: SHA }; + + it("reads the defaultLook flag and refuses anything but a boolean", () => { + const { plugins, problems } = parseBundledPlugins({ + plugins: [ + { ...entry, id: "look", defaultLook: true }, + { ...entry, id: "plain" }, + { ...entry, id: "odd", defaultLook: "yes" }, + ], + }); + expect(problems).toEqual(["odd defaultLook must be a boolean"]); + expect( + plugins.map((p: { id: string; defaultLook: boolean }) => [ + p.id, + p.defaultLook, + ]), + ).toEqual([ + ["look", true], + ["plain", false], + ]); + }); + + it("carries it into the bundled index and through a re-pin", () => { + expect( + buildBundledIndex( + [ + { id: "look", onboarding: null, defaultLook: true }, + { id: "plain", onboarding: null, defaultLook: false }, + ], + ["look", "plain"], + ), + ).toEqual({ version: 1, plugins: { look: { defaultLook: true } } }); + + const index = { + plugins: [ + { + id: "look", + versions: [ + { version: "1.0.0", url: "https://x/l.tmxplug", sha256: SHA }, + ], + }, + ], + }; + expect( + pinsFromIndex(index, null, [{ id: "look", defaultLook: true }])[0], + ).toMatchObject({ id: "look", defaultLook: true }); + }); + + it("copies a local build's theme files", () => { + const repo = tempDir(); + fs.mkdirSync(path.join(repo, "dist"), { recursive: true }); + fs.mkdirSync(path.join(repo, "themes"), { recursive: true }); + fs.writeFileSync(path.join(repo, "dist", "backend.js"), "export {}"); + fs.writeFileSync(path.join(repo, "themes", "paper.css"), ".paper{}"); + fs.writeFileSync( + path.join(repo, "manifest.json"), + JSON.stringify({ + id: "inks", + contributes: { themes: [{ id: "paper", css: "themes/paper.css" }] }, + }), + ); + const out = path.join(tempDir(), "inks"); + copyLocalBuild(repo, out); + expect(fs.existsSync(path.join(out, "themes", "paper.css"))).toBe(true); + }); +}); diff --git a/scripts/check-core-plugin-ids.cjs b/scripts/check-core-plugin-ids.cjs index 884914da3..64bf1fb9c 100644 --- a/scripts/check-core-plugin-ids.cjs +++ b/scripts/check-core-plugin-ids.cjs @@ -32,12 +32,18 @@ const OFFICIAL_IDS = [ "acme-ssl", "ai", "alerts", + "audit-tools", "automations", + "classic-layout", + "classic-themes", + "credential-tools", "docker", "file-manager", "fleets", "homepage", "host-metrics", + "host-status", + "import-export", "ldap", "network-topology", "opkssh", diff --git a/scripts/check-sdk-ui-exports.cjs b/scripts/check-sdk-ui-exports.cjs index c8072663d..bf020c3ef 100644 --- a/scripts/check-sdk-ui-exports.cjs +++ b/scripts/check-sdk-ui-exports.cjs @@ -19,8 +19,13 @@ const SDK_UI = path.join(ROOT, "src", "ui", "plugin-host", "sdk-ui.ts"); const TSCONFIG = path.join(ROOT, "tsconfig.app.json"); const ALLOWED = [ + "ACCENT_PRESET_COLORS", + "APP_VERSION", + "AccessRecord", "ActionSlot", + "ActiveSessionInfo", "AddButton", + "AdminSection", "Alert", "AlertDescription", "AlertDialog", @@ -35,14 +40,23 @@ const ALLOWED = [ "AlertDialogTitle", "AlertDialogTrigger", "AlertTitle", + "ApiKey", + "AuditLog", + "AuditLogFilters", + "AuthEnrollmentSections", + "AuthOverrideProtocol", "BackButton", "Badge", "BarSeries", "BarSeriesItem", "BarSeriesProps", + "BrandingSettings", "BrowserSignInDialog", "Button", "ButtonProps", + "CORE_HOST_DEFAULTS", + "CORE_NAMESPACE", + "CREDENTIAL_ROW_FIELD_KEYS", "Card", "CardAction", "CardContent", @@ -54,6 +68,12 @@ const ALLOWED = [ "CardTitle", "Checkbox", "ColumnCountStepper", + "Command", + "CommandEmpty", + "CommandGroup", + "CommandItem", + "CommandList", + "CommandPrimitive", "ComponentSlot", "ConfirmOptions", "ConnectionGate", @@ -66,9 +86,26 @@ const ALLOWED = [ "ConnectionStage", "ConnectionStatus", "ConnectionUnavailable", + "CreatedApiKey", + "Credential", + "CredentialDensity", + "CredentialPermissionLevel", + "CredentialRowFields", + "CredentialSidebarFilterState", + "CredentialSidebarPreferences", + "CredentialSortKey", + "CredentialTrayTrigger", "CustomKeybinding", + "DASHBOARD_CARDS", + "DEFAULT_FOLDER_COLOR", + "DEFAULT_FOLDER_ICON", + "DashboardCardId", "DataColumn", "DataView", + "DefaultOverrides", + "DefaultsOnly", + "DesktopUpdateMode", + "DesktopUpdateState", "Dialog", "DialogClose", "DialogContent", @@ -81,6 +118,7 @@ const ALLOWED = [ "DialogTrigger", "DocsLink", "DocsLinkProps", + "DocsPage", "DropdownMenu", "DropdownMenuCheckboxItem", "DropdownMenuContent", @@ -99,10 +137,14 @@ const ALLOWED = [ "EditingWidth", "EmptyState", "FOLDER_COLORS", + "FONT_SIZES", "Facts", "FakeSwitch", "Field", "FieldPair", + "FolderAccessRule", + "FolderIconEl", + "FontSizeId", "FormFooter", "FullScreenAppPhase", "FullScreenAppWrapper", @@ -112,11 +154,36 @@ const ALLOWED = [ "GridLayout", "GridSlot", "GroupHeading", + "HexColorPicker", + "Host", + "HostActionDef", + "HostClickBehavior", + "HostData", "HostDefaultBadge", "HostDefaultField", + "HostDefaultsChange", + "HostDefaultsContext", + "HostDefaultsContextValue", + "HostDefaultsLevel", + "HostDefaultsTarget", + "HostDensity", "HostFeatureFields", + "HostLock", + "HostPolicy", + "HttpsSettings", "HostFeatureFieldsProps", + "HostFolder", + "HostGroupKey", "HostKeyVerificationDialog", + "HostOnly", + "HostProtocolAuthInput", + "HostProtocolDef", + "HostProtocols", + "HostRowFields", + "HostSidebarFilterState", + "HostSidebarPreferences", + "HostTrayTrigger", + "IconPicker", "InlineView", "Input", "Kbd", @@ -124,10 +191,13 @@ const ALLOWED = [ "KbdSeparator", "KeyCombo", "KeybindingAction", + "KeybindingActionDef", "KeybindingActionType", + "LANGUAGES", "Label", "LineChart", "LineChartSeries", + "LinkedAccountInfo", "ListBadge", "ListRow", "ListRowAction", @@ -135,19 +205,29 @@ const ALLOWED = [ "ListRowTone", "LogContext", "LogEntry", + "LookSettings", + "MANAGE_REQUEST_EVENT", + "MAX_PANES", "MFAPromptMode", + "ManageMode", + "ManageRequest", "ManagerCardError", "ManagerCardShell", "ManagerSearch", "Meter", "MetricCard", "MiniStat", + "NativeView", + "NativeViewProps", "NumberField", + "OPEN_PLUGINS_EVENT", + "OpenTabRecord", "PANEL", - "PanePrompt", "PROMPT_BUTTON", "PROMPT_DESTRUCTIVE_BUTTON", "PROMPT_PRIMARY_BUTTON", + "PaletteItemDef", + "PanePrompt", "PanelDensity", "PanelList", "PanelSearch", @@ -155,18 +235,35 @@ const ALLOWED = [ "PanelViewMode", "PassphraseDialog", "PasswordInput", + "PermissionCatalogEntry", "PluginComponent", + "PRESETS", + "PluginIcon", + "PluginSettingsErrors", + "PluginSettingsField", + "PluginSettingsValidationError", + "PluginSummary", "PluginViewPlaceholder", "Popover", "PopoverAnchor", "PopoverContent", "PopoverTrigger", + "ProxyNode", "RadialGauge", "RadialGaugeProps", + "RailGroup", + "RailItemDef", "RecentActivityItem", + "ReleaseInfo", "Repeater", + "ResolvedHostDefault", "RobustClipboardProvider", + "Role", "SSHAuthDialog", + "SSHHost", + "SSHHostData", + "SSHHostWithStatus", + "SSH_AUTH_PROTOCOL", "ScrollArea", "ScrollBar", "SecretField", @@ -186,6 +283,9 @@ const ALLOWED = [ "SelectValue", "Separator", "SettingRow", + "SettingsFieldRow", + "SharePermissionLevel", + "ShareTarget", "Sheet", "SheetClose", "SheetContent", @@ -194,74 +294,363 @@ const ALLOWED = [ "SheetHeader", "SheetTitle", "SheetTrigger", + "ShellConfirmCapture", "Skeleton", "Slider", + "SortKey", "Sparkline", "SparklineProps", + "SplitLayoutMenu", + "SplitOpenTarget", + "SplitSummary", "StatRow", + "StatusValue", "SurfaceKind", "SurfaceScope", "Switch", "SwitchRow", "TOTPDialog", + "Tab", + "TabShellCallbacks", + "TabSplitAction", + "TabStage", "TabStrip", "TabStripItem", + "TabType", "TagInput", "TextAreaField", "TextField", "Textarea", "TextareaProps", + "ThemeId", + "TlsStatus", + "ToolsTab", "Tooltip", "TooltipContent", "TooltipProvider", "TooltipTrigger", + "UI_AREA_KEYS", + "UI_FONTS", + "UiAreaKey", + "UiFontId", + "UiPreset", + "UpdateChannelState", "UptimeInfo", "UsagePair", + "UserPreferences", + "UserRole", + "UserSecondFactorSummary", + "VersionBadge", "VersionInfo", "ViewToggle", "WebSocketConnectionTarget", "WidgetTitle", + "actionOrder", + "activityTarget", + "adminCreateUser", + "adminCreateUserCredential", + "adminCreateUserHost", + "adminDeleteUserCredential", + "adminDeleteUserHost", + "adminExportUserData", + "adminGetHostPassword", + "adminGetUserCredentials", + "adminGetUserHosts", + "adminResetUserPassword", + "adminUpdateUserCredential", + "adminUpdateUserHost", + "applyAccentColor", + "applyFontSize", + "applyPresetSideEffects", + "applyUiFont", + "assignRoleToUser", + "beginSplitDrag", "buildOriginWsUrl", + "bulkUpdateSSHHosts", + "canConfigureUpdates", + "canJoinSplit", + "changeAppLanguage", + "changePassword", + "clearLocalAdaptivePreferences", "cn", "copyToClipboard", + "createApiKey", + "createCredential", "createFrontendLogger", "createQuickConnectHost", + "createRole", + "createSSHHost", + "defaultConnectAction", + "defaultCredentialRowFields", + "defaultCredentialSidebarPreferences", + "defaultHostRowFields", + "defaultHostSidebarPreferences", + "defaultValuesEqual", + "deleteAccount", + "deleteAllHostsInFolder", + "deleteApiKey", + "deleteCredential", + "deleteOpenTab", + "deleteRole", + "deleteSSHHost", + "deleteUser", + "desktopUpdates", + "docsUrl", + "duplicateCredential", + "enabledHostProtocols", + "endSplitDrag", + "filterPaletteItems", + "findCoreHostDefault", "findMatchingKeybinding", + "getActiveSessions", "getAdaptiveResourceBudget", + "getApiKeys", + "getAuditLogActions", + "getAuditLogs", "getBasePath", + "getBranding", + "getChannelReleases", + "getConnectedRemoteApi", + "getCredentialAccess", + "getCredentialDetails", + "getCredentialSidebarPreferences", + "getCredentials", "getDatabaseHealth", + "getDatabaseTransferUrl", + "getDefaultConnectionTab", + "getDeveloperMode", "getDeviceId", + "getErrorMessage", + "getExternalAutoProvision", + "getFolderAccess", + "getFolderDefaultsId", + "getHostAccess", + "getHostAuthOverride", + "getHostDefaultsLevel", + "getHostEditorSection", + "getHostPassword", + "getHostPolicy", + "getHostSidebarPreferences", + "getHostTags", + "getLiveHostStatus", + "getHttpsSettings", + "getLogLevel", + "getNotificationPrivateEndpoints", + "getPanel", + "getPasswordLoginAllowed", + "getPasswordResetAllowed", + "getPermissionsCatalog", + "getPluginAdminSettings", + "getPluginHostSettings", + "getPluginUserSettings", + "getPlugins", "getPollingEnvironmentMultiplier", + "getPreferredHostAction", "getRecentActivity", + "getRegisteredDashboardCard", + "getRegistrationAllowed", + "getRoles", + "getSSHFolders", + "getSSHHosts", + "getSecondFactorAfterExternalLogin", + "getSessionTimeout", + "getSessionTimeoutMinutes", + "getSessions", + "getStatusCheckSettings", + "getStatusClasses", + "getTabType", + "getTlsStatus", + "getUpdateChannel", "getUptime", + "getUserInfo", + "getUserList", + "getUserPreferences", + "getUserRoles", + "getUserSecondFactors", "getVersionInfo", "globalShortcutHandler", + "groupRailItems", + "hasVisibleFields", + "hostActionsFor", + "hostBadgesFor", + "hostEditorToolbarItemsFor", + "hostMenuItemsFor", + "hostProtocolFlags", + "hostStatusIndicatorsFor", "hydrateLocalSharedHostAuth", + "invalidateHostsAndStatusCaches", + "invalidateServerStatusCache", + "isCoreRailView", "isElectron", + "isFieldShown", "isMacPlatform", + "isPersistentTabType", + "isPrerelease", + "isSessionTabType", + "isSplitDragging", + "isSplitTab", "isTabJumpHotkey", + "linkExternalToPasswordAccount", "linkedServerUrl", + "listHostActions", + "listHostProtocols", + "loadPaletteGroup", + "logoutUser", + "makeHostSshSubTabs", + "makeHostTabs", + "makeUserAdmin", + "mapCredentials", "markAdaptiveResourceUsed", + "markTabSurfaceUsed", + "moveRailItem", + "moveSplitDrag", + "normalizeLanguageCode", + "notifySyncChanged", + "openInSplit", + "paletteEntriesFor", + "parseCustomKeybindings", + "parseServerTime", + "pluginKey", + "pluginName", "pluginWsUrl", + "preloadTabSurface", + "previewHostDefaultsLevel", + "promotableIds", + "protocolEnabled", + "protocolPort", + "quickConnectTargets", + "railItemLabel", "readFromClipboard", + "recordHostActionPreference", + "registerLayout", + "registerTabCloseGuard", + "releaseUrlFrom", + "rem", + "removeAdminStatus", + "removeRoleFromUser", + "renameCredentialFolder", + "renameFolder", + "reorderCredentials", + "reorderFolders", + "reorderSSHHosts", + "reportBetaFeedbackUrl", + "reportCoreIssueUrl", + "requestFromLegacyEvent", + "requestManage", + "resetHostToDefaults", + "resetRecentActivity", + "resetUserSecondFactors", "resolveConnectionOrigin", + "resolveHostDefaults", + "resolveHostTabType", "resolveRemoteHostId", + "revokeAllUserSessions", + "revokeCredentialAccess", + "revokeFolderAccess", + "revokeHostAccess", + "revokeSession", + "rightDockableIds", + "roleLabel", "runAdaptiveBackgroundTask", "runAdaptivePolling", + "runHostAction", "runVisibleInterval", + "sanitizeCredentialSidebarPreferences", + "sanitizeHostSidebarPreferences", + "saveCredentialSidebarPreferences", + "saveHostDefaultsLevel", + "saveHostSidebarPreferences", + "saveHostTags", + "saveUserPreferences", + "setAllPluginChannels", + "setDeveloperMode", + "setHostAuthOverride", + "setNotificationPrivateEndpoints", + "setPluginOptions", + "setUpdateChannel", + "shareCredential", + "shareFolder", + "shareHost", + "shell", + "splitDefaultKey", + "sshHostToHost", + "tabIcon", + "tabJumpHotkeyKeys", + "takePendingManageRequest", + "unlinkExternalFromPasswordAccount", + "updateCredential", + "updateExternalAutoProvision", + "updateFolderMetadata", + "updateHostAccess", + "updateLogLevel", + "updatePasswordLoginAllowed", + "updatePasswordResetAllowed", + "updatePluginAdminSettings", + "updatePluginHostSettings", + "updatePluginUserSettings", + "updateRegistrationAllowed", + "updateRole", + "updateSSHHost", + "updateSecondFactorAfterExternalLogin", + "updateSessionTimeout", + "updateHttpsSettings", + "updateStatusCheckSettings", + "uploadTlsCertificate", "usageBarColor", "usageColor", + "useActionSlot", "useAdaptivePolling", "useAppTheme", + "useAreaPreferences", + "useBranding", + "useChooseTheme", "useConfirm", "useConnectionGate", "useConnectionLog", + "useDesktopLinked", + "useDesktopUpdate", + "useEditorDefaultKeys", + "useHostActions", + "useHostBadges", + "useHostContextMenuItems", + "useHostEditorToolbarItems", + "useHostStatusIndicators", + "useHostEditorSections", + "useHostProtocols", + "useHostStatus", + "useInstalledPlugins", "useIsDefaultsEditor", "useIsMobile", + "useKeybindingActions", + "useKeybindingDefaults", "useOptionalConnectionLog", + "usePageVisibleInterval", + "usePaletteEntries", + "usePaletteGroups", + "usePanels", + "usePermissions", + "usePluginDocsUrl", + "usePluginHostSections", + "usePluginStore", + "useRailItems", + "useRegisteredDashboardCards", + "useServerStatus", + "useServerStatusMeta", + "useShellController", + "useSplitTargets", + "useSshAuthEditors", + "useSshAuthProviders", + "useStatusColorScheme", "useSurfaceKind", + "useTabHandlesVersion", "useTabs", "useTabsSafe", + "useTheme", + "useThemeOptions", + "useUiPreference", + "useUiPreferencesContext", + "waitForDefaultsJob", + "withProtocolSettings", ]; /** Every name a module exports, resolved the way the compiler sees it. */ diff --git a/scripts/electron-backend-ready.test.ts b/scripts/electron-backend-ready.test.ts index 773b544a8..eb12cdca1 100644 --- a/scripts/electron-backend-ready.test.ts +++ b/scripts/electron-backend-ready.test.ts @@ -48,7 +48,11 @@ function start() { writeFileSync: vi.fn(), }, fork: () => child, - app: { isPackaged: true, getVersion: () => "2.9.0" }, + app: { + isPackaged: true, + getVersion: () => "2.9.0", + getPath: () => "/user-data", + }, process: { env: { LOG_LEVEL: "error" } }, isDev: false, appRoot: "/app", diff --git a/scripts/electron-external-editor.test.ts b/scripts/electron-external-editor.test.ts deleted file mode 100644 index dd8862333..000000000 --- a/scripts/electron-external-editor.test.ts +++ /dev/null @@ -1,40 +0,0 @@ -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-security-boundary.test.ts b/scripts/electron-security-boundary.test.ts index 6de78ce7d..90fa9d36b 100644 --- a/scripts/electron-security-boundary.test.ts +++ b/scripts/electron-security-boundary.test.ts @@ -39,12 +39,24 @@ describe("Electron security boundary", () => { } }); - it("launches only the editor saved from a file dialog", () => { - expect(main).not.toContain("fileData?.editorPath"); - expect(main).toContain("readSetting(EXTERNAL_EDITOR_SETTING)"); + it("saves an external editor only if it was chosen in a file dialog", () => { expect(main).toContain("dialogChosenPaths.has(value)"); }); + it("keeps local files, the local terminal and the external editor out of Electron", () => { + for (const channel of [ + "open-external-editor", + "close-external-editor", + "local-terminal-start", + "local-fs:", + "local-transfer:", + "list-local-directory", + ]) { + expect(main).not.toContain(channel); + expect(preload).not.toContain(channel); + } + }); + it("scopes proxy headers and basic auth to the linked origin", () => { expect(main).toContain('"sync-proxy-config"'); expect(main).toContain("linkedServer.applyLinkHeaders("); diff --git a/scripts/generate-settings-search-index.cjs b/scripts/generate-settings-search-index.cjs deleted file mode 100644 index 42df28a75..000000000 --- a/scripts/generate-settings-search-index.cjs +++ /dev/null @@ -1,176 +0,0 @@ -#!/usr/bin/env node -/** - * Builds src/ui/settings/settings-search-index.ts: the i18n keys each core - * settings section shows, so the Settings search can match on any label in - * a section without mounting it. Run it after changing a settings section. - * - * --check fails when the committed file is out of date. - */ - -const fs = require("node:fs"); -const path = require("node:path"); - -const ROOT = path.resolve(__dirname, ".."); -const SIDEBAR = path.join(ROOT, "src", "ui", "sidebar"); -const OUT = path.join( - ROOT, - "src", - "ui", - "settings", - "settings-search-index.ts", -); - -function flatKeys(obj, prefix = "") { - return Object.entries(obj).flatMap(([key, value]) => - typeof value === "object" - ? flatKeys(value, `${prefix}${key}.`) - : [`${prefix}${key}`], - ); -} - -const EN_KEYS = new Set( - flatKeys(require(path.join(ROOT, "src", "ui", "locales", "en.json"))), -); - -// t("x", { count }) reads x_one or x_other, so index the plural key. -function realKey(key) { - return EN_KEYS.has(key) || !EN_KEYS.has(`${key}_other`) - ? key - : `${key}_other`; -} - -function keysIn(text) { - return [ - ...new Set( - [...text.matchAll(/\bt\(\s*"([\w.-]+)"/g)].map((m) => realKey(m[1])), - ), - ]; -} - -function read(file) { - return fs.readFileSync(path.join(SIDEBAR, file), "utf8"); -} - -/** The body of one exported or local function, by name. */ -function functionBody(source, name) { - const start = source.search(new RegExp(`function ${name}\\b`)); - if (start < 0) throw new Error(`No function ${name}`); - const next = source.slice(start + 1).search(/\n(export )?function \w+/); - return next < 0 ? source.slice(start) : source.slice(start, start + 1 + next); -} - -/** Profile sections, split on their AccordionSection ids. */ -function profileSections() { - const source = read("UserProfilePanel.tsx"); - const lines = source.split("\n"); - const starts = []; - lines.forEach((line, i) => { - if (/^\s{6} { - const end = k + 1 < starts.length ? starts[k + 1] : lines.length; - const chunk = lines.slice(start, end).join("\n"); - const id = (chunk.match(/\bid="([^"]+)"/) || [])[1]; - if (id) out[id] = keysIn(chunk); - }); - out.interface = [ - ...new Set([ - ...(out.interface ?? []), - ...keysIn(read("InterfacePresetSettings.tsx")), - ]), - ]; - out.security = [ - ...new Set([ - ...(out.security ?? []), - ...keysIn(read("AuthEnrollmentSections.tsx")), - ...keysIn(functionBody(source, "PasswordChangeSection")), - ]), - ]; - out["api-keys"] = [ - ...new Set([ - ...(out["api-keys"] ?? []), - ...keysIn(functionBody(source, "NewApiKeyDialog")), - ]), - ]; - out.keybindings = keysIn( - fs.readFileSync( - path.join(ROOT, "src", "ui", "settings", "KeybindingsSettings.tsx"), - "utf8", - ), - ); - return out; -} - -function adminSections() { - const settings = read("AdminSettingsSections.tsx"); - const management = read("AdminManagementSections.tsx"); - return { - "admin-general": [ - ...new Set([ - ...keysIn(functionBody(settings, "AdminGeneralSettingsSection")), - ...keysIn(read("AdminPluginDeveloperMode.tsx")), - ]), - ], - "admin-users": [ - ...new Set([ - ...keysIn(functionBody(management, "AdminUsersSection")), - ...keysIn(read("AdminUserDialogs.tsx")), - ]), - ], - "admin-sessions": keysIn(functionBody(management, "AdminSessionsSection")), - "admin-roles": keysIn(functionBody(management, "AdminRolesSection")), - "admin-host-defaults": [ - ...new Set([ - ...keysIn(functionBody(settings, "AdminHostDefaultsSection")), - ...keysIn(read("AdminHostTags.tsx")), - ]), - ], - "admin-branding": keysIn(read("AdminBrandingSection.tsx")), - "admin-database": keysIn(functionBody(settings, "AdminDatabaseSection")), - "admin-ssl": keysIn(functionBody(settings, "AdminSSLSection")), - "admin-api-keys": keysIn(read("AdminApiKeysSection.tsx")), - "admin-audit-log": keysIn(read("AdminAuditLogSection.tsx")), - updates: [ - ...new Set( - ["UpdatesSettings.tsx", "DesktopUpdatesCard.tsx"].flatMap((file) => - keysIn( - fs.readFileSync( - path.join(ROOT, "src", "ui", "settings", file), - "utf8", - ), - ), - ), - ), - ], - }; -} - -function render() { - const index = { ...profileSections(), ...adminSections() }; - const body = Object.entries(index) - .sort(([a], [b]) => a.localeCompare(b)) - .map( - ([id, keys]) => - ` ${JSON.stringify(id)}: [\n${keys - .sort() - .map((key) => ` ${JSON.stringify(key)},`) - .join("\n")}\n ],`, - ) - .join("\n"); - return `// Generated by scripts/generate-settings-search-index.cjs. Do not edit.\n\n/** The i18n keys each core settings section shows, for search. */\nexport const SETTINGS_SEARCH_INDEX: Record = {\n${body}\n};\n`; -} - -const next = render(); -if (process.argv.includes("--check")) { - const current = fs.existsSync(OUT) ? fs.readFileSync(OUT, "utf8") : ""; - if (current !== next) { - console.error( - "src/ui/settings/settings-search-index.ts is out of date. Run: node scripts/generate-settings-search-index.cjs", - ); - process.exit(1); - } -} else { - fs.writeFileSync(OUT, next); - console.log(`Wrote ${path.relative(ROOT, OUT)}`); -} diff --git a/scripts/install-windows-sharp.cjs b/scripts/install-windows-sharp.cjs new file mode 100644 index 000000000..1a6a1391e --- /dev/null +++ b/scripts/install-windows-sharp.cjs @@ -0,0 +1,26 @@ +const fs = require("node:fs"); +const path = require("node:path"); +const { execFileSync } = require("node:child_process"); + +function findPackage(start) { + let directory = path.dirname(start); + while (directory !== path.dirname(directory)) { + const manifest = path.join(directory, "package.json"); + if (fs.existsSync(manifest)) return JSON.parse(fs.readFileSync(manifest)); + directory = path.dirname(directory); + } + throw new Error("Could not locate the installed sharp package manifest"); +} + +// The build runs on x64 but ships arm64 and ia32 too, so npm ci alone +// only installs the x64 binary. +const sharpPackage = findPackage(require.resolve("sharp")); +const packages = [ + "@img/sharp-win32-arm64", + "@img/sharp-win32-ia32", + "@img/sharp-win32-x64", +].map((name) => `${name}@${sharpPackage.optionalDependencies[name]}`); + +execFileSync("npm.cmd", ["install", "--force", "--no-save", ...packages], { + stdio: "inherit", +}); diff --git a/scripts/lib/bundled-plugins.cjs b/scripts/lib/bundled-plugins.cjs index ddbbc034d..fcb2be13d 100644 --- a/scripts/lib/bundled-plugins.cjs +++ b/scripts/lib/bundled-plugins.cjs @@ -15,11 +15,17 @@ * enabledByEnv counts the plugin as recommended when any of those * environment variables is set, for a server configured before first boot. * Plugins without it start unchecked. + * + * "defaultLook": true marks the plugin whose layout (and that layout's + * theme) a fresh install shows. Core finds the default look this way, so it + * never names a plugin. Such a plugin always starts enabled and cannot be + * uninstalled, only swapped for another look. */ const crypto = require("node:crypto"); const fs = require("node:fs"); const path = require("node:path"); +const semver = require("semver"); const ID = /^[a-z0-9][a-z0-9-]*$/; const ENV_NAME = /^[A-Z][A-Z0-9_]*$/; @@ -110,6 +116,12 @@ function parseBundledPlugins(raw) { } const before = problems.length; const onboarding = parseOnboarding(entry.onboarding, entry.id, problems); + if ( + entry.defaultLook !== undefined && + typeof entry.defaultLook !== "boolean" + ) { + problems.push(`${entry.id} defaultLook must be a boolean`); + } if (problems.length > before) continue; plugins.push({ id: entry.id, @@ -118,6 +130,7 @@ function parseBundledPlugins(raw) { path: hasPath ? entry.path : null, sha256: entry.sha256, onboarding, + defaultLook: entry.defaultLook === true, }); } else { problems.push(`${entry.id} has an unknown source "${entry.source}"`); @@ -218,8 +231,12 @@ function buildBundledIndex(plugins, stagedIds) { const staged = new Set(stagedIds); const out = {}; for (const plugin of plugins) { - if (!staged.has(plugin.id) || !plugin.onboarding) continue; - out[plugin.id] = { onboarding: plugin.onboarding }; + if (!staged.has(plugin.id)) continue; + if (!plugin.onboarding && !plugin.defaultLook) continue; + out[plugin.id] = { + ...(plugin.onboarding ? { onboarding: plugin.onboarding } : {}), + ...(plugin.defaultLook ? { defaultLook: true } : {}), + }; } return { version: 1, plugins: out }; } @@ -235,15 +252,22 @@ function pinsFromIndex(index, ids, current = []) { const currentById = new Map(current.map((entry) => [entry.id, entry])); const wanted = ids ?? plugins.map((plugin) => plugin.id); return [...wanted].sort().map((id) => { - const latest = byId.get(id)?.versions?.[0]; + const listed = byId.get(id); + // gated holds releases that need a newer Termix than old builds check for. + const latest = [...(listed?.versions ?? []), ...(listed?.gated ?? [])] + .filter((entry) => semver.valid(entry.version)) + .filter((entry) => !semver.prerelease(entry.version)) + .sort((a, b) => semver.rcompare(a.version, b.version))[0]; if (!latest) throw new Error(`${id} is not in the registry index`); const onboarding = currentById.get(id)?.onboarding; + const defaultLook = currentById.get(id)?.defaultLook === true; return { id, source: "tmxplug", url: latest.url, sha256: latest.sha256, ...(onboarding ? { onboarding } : {}), + ...(defaultLook ? { defaultLook } : {}), }; }); } @@ -357,9 +381,26 @@ function rebuildLocalPlugins(dir, ids, run) { } /** Copies a local plugin build into the staging folder. */ +/** Theme CSS files the repo's manifest declares, which ship with it too. */ +function localThemeFiles(repo) { + try { + const manifest = JSON.parse( + fs.readFileSync(path.join(repo, "manifest.json"), "utf8"), + ); + const themes = manifest?.contributes?.themes; + return Array.isArray(themes) + ? themes + .map((theme) => theme?.css) + .filter((css) => typeof css === "string") + : []; + } catch { + return []; + } +} + function copyLocalBuild(repo, destination) { fs.mkdirSync(destination, { recursive: true }); - for (const entry of LOCAL_FILES) { + for (const entry of new Set([...LOCAL_FILES, ...localThemeFiles(repo)])) { const from = path.join(repo, entry); if (fs.existsSync(from)) { fs.cpSync(from, path.join(destination, entry), { recursive: true }); diff --git a/scripts/patch-ssh2-sk.cjs b/scripts/patch-ssh2-sk.cjs new file mode 100644 index 000000000..4fa99d279 --- /dev/null +++ b/scripts/patch-ssh2-sk.cjs @@ -0,0 +1,149 @@ +const fs = require("node:fs"); + +const SK_ED = "sk-ssh-ed25519@openssh.com"; +const SK_EC = "sk-ecdsa-sha2-nistp256@openssh.com"; + +function replaceOnce(source, from, to, label) { + const first = source.indexOf(from); + if (first === -1 || source.indexOf(from, first + 1) !== -1) { + throw new Error(`ssh2 changed (${label}); review the security key patch`); + } + return source.replace(from, () => to); +} + +function patchKeyParserSource(source) { + if (source.includes("// termix-sk")) return source; + let out = replaceOnce( + source, + ` case 'ecdsa-sha2-nistp521': + return true; + case 'ssh-ed25519': + if (eddsaSupported) + return true;`, + ` case 'ecdsa-sha2-nistp521': + return true; + case '${SK_EC}': // termix-sk + return true; + case '${SK_ED}': + case 'ssh-ed25519': + if (eddsaSupported) + return true;`, + "key types", + ); + out = replaceOnce( + out, + ` default: + return new Error(\`Unsupported OpenSSH public key type: \${baseType}\`); + } + + return new OpenSSH_Public(`, + ` case '${SK_ED}': + case '${SK_EC}': { + // Public blob only; the security key signs, so there is no private half. + const rest = data.subarray(data._pos || 0); + const typeLen = Buffer.byteLength(baseType); + pubSSH = Buffer.allocUnsafe(4 + typeLen + rest.length); + writeUInt32BE(pubSSH, typeLen, 0); + pubSSH.utf8Write(baseType, 4, typeLen); + pubSSH.set(rest, 4 + typeLen); + algo = baseType === '${SK_ED}' ? null : 'sha256'; + break; + } + default: + return new Error(\`Unsupported OpenSSH public key type: \${baseType}\`); + } + + return new OpenSSH_Public(`, + "parseDER", + ); + return out; +} + +function patchAgentSource(source) { + if (source.includes("skTrailer")) return source; + return replaceOnce( + source, + ` binaryParser.init(signature, 0); + binaryParser.readString(true); + signature = binaryParser.readString(); + binaryParser.clear();`, + ` binaryParser.init(signature, 0); + binaryParser.readString(true); + signature = binaryParser.readString(); + // Security key signatures end with flags and a counter. + const skTrailer = signature === undefined + ? undefined + : binaryParser.readRaw(); + binaryParser.clear(); + if (skTrailer && skTrailer.length) + signature.skTrailer = Buffer.from(skTrailer);`, + "agent sign response", + ); +} + +function patchProtocolSource(source) { + if (source.includes("skTrailer")) return source; + const start = source.indexOf(" authPK(username, pubKey, keyAlgo, cbSign) {"); + const end = source.indexOf(" authHostbased(", start); + if (start === -1 || end === -1) { + throw new Error("ssh2 changed (authPK); review the security key patch"); + } + let region = source.slice(start, end); + region = replaceOnce( + region, + ` signature = convertSignature(signature, keyType);`, + ` const skTrailer = signature && signature.skTrailer; + signature = convertSignature(signature, keyType);`, + "authPK sign", + ); + region = replaceOnce( + region, + ` + 4 + algoLen + 4 + sigLen + );`, + ` + 4 + algoLen + 4 + sigLen + (skTrailer ? skTrailer.length : 0) + );`, + "authPK alloc", + ); + region = replaceOnce( + region, + ` writeUInt32BE(packet, 4 + algoLen + 4 + sigLen, p += pubKeyLen);`, + ` writeUInt32BE( + packet, + 4 + algoLen + 4 + sigLen + (skTrailer ? skTrailer.length : 0), + p += pubKeyLen + );`, + "authPK length", + ); + region = replaceOnce( + region, + ` packet.set(signature, p += 4);`, + ` packet.set(signature, p += 4); + if (skTrailer) + packet.set(skTrailer, p + sigLen);`, + "authPK copy", + ); + return source.slice(0, start) + region + source.slice(end); +} + +if (require.main === module) { + const targets = [ + ["ssh2/lib/protocol/keyParser.js", patchKeyParserSource], + ["ssh2/lib/agent.js", patchAgentSource], + ["ssh2/lib/protocol/Protocol.js", patchProtocolSource], + ]; + for (const [id, patch] of targets) { + const target = require.resolve(id); + const source = fs.readFileSync(target, "utf8"); + const result = patch(source); + if (result !== source) fs.writeFileSync(target, result); + } + console.log( + "[patch-ssh2-sk] Security key public keys and signatures enabled", + ); +} + +module.exports = { + patchKeyParserSource, + patchAgentSource, + patchProtocolSource, +}; diff --git a/scripts/plugin-build.test.ts b/scripts/plugin-build.test.ts index ec71c9361..3f8422d64 100644 --- a/scripts/plugin-build.test.ts +++ b/scripts/plugin-build.test.ts @@ -35,6 +35,42 @@ function fixturePlugin(): string { } describe("termix-plugin build", () => { + it("builds a theme only plugin with an empty backend and no source", async () => { + vi.spyOn(console, "log").mockImplementation(() => {}); + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "termix-build-")); + cleanups.push(() => fs.rmSync(dir, { recursive: true, force: true })); + fs.writeFileSync( + path.join(dir, "manifest.json"), + JSON.stringify({ + id: "theme-fixture", + capabilities: ["ui:theme"], + contributes: { themes: [{ id: "paper" }] }, + }), + ); + + await build({ cwd: dir }); + + expect( + fs.readFileSync(path.join(dir, "dist", "backend.js"), "utf8"), + ).toContain("export function activate()"); + expect(fs.existsSync(path.join(dir, "dist", "frontend.js"))).toBe(false); + }); + + it("still needs a backend entry once a plugin holds more than themes", async () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "termix-build-")); + cleanups.push(() => fs.rmSync(dir, { recursive: true, force: true })); + fs.writeFileSync( + path.join(dir, "manifest.json"), + JSON.stringify({ + id: "mixed-fixture", + capabilities: ["ui:theme", "ui:layout"], + contributes: { themes: [{ id: "paper" }] }, + }), + ); + + await expect(build({ cwd: dir })).rejects.toThrow("no backend entry"); + }); + it("marks dist as ESM so Node loads backend.js without reparsing", async () => { vi.spyOn(console, "log").mockImplementation(() => {}); const dir = fixturePlugin(); diff --git a/scripts/plugin-pack.test.ts b/scripts/plugin-pack.test.ts index 3d975a0f1..60e5aaa80 100644 --- a/scripts/plugin-pack.test.ts +++ b/scripts/plugin-pack.test.ts @@ -142,3 +142,29 @@ describe("termix-plugin pack", () => { expect(fs.readdirSync(out)).toEqual([]); }); }); + +describe("termix-plugin pack themes", () => { + it("ships the CSS files the manifest's themes declare", async () => { + vi.spyOn(console, "log").mockImplementation(() => {}); + vi.spyOn(console, "warn").mockImplementation(() => {}); + const dir = fixturePlugin({ + capabilities: ["ui:theme"], + contributes: { + themes: [ + { + id: "paper", + label: "Paper", + mode: "light", + css: "styles/paper.css", + }, + ], + }, + }); + writeFile(dir, "styles/paper.css", ".paper { --background: #fff; }\n"); + writeFile(dir, "styles/draft.css", ".draft {}\n"); + const file = await pack({ cwd: dir, args: ["--out", tempDir("t-")] }); + const names = await entries(file); + expect(names).toContain("styles/paper.css"); + expect(names).not.toContain("styles/draft.css"); + }); +}); diff --git a/scripts/plugin-validate.test.ts b/scripts/plugin-validate.test.ts index 06b9602d1..16e859956 100644 --- a/scripts/plugin-validate.test.ts +++ b/scripts/plugin-validate.test.ts @@ -49,3 +49,98 @@ describe("termix-plugin validate migrations", () => { expect(output).not.toMatch(/migrations\/\w+ is missing/); }); }); + +describe("termix-plugin validate themes", () => { + function themed(css: string | null) { + const root = plugin([]); + fs.writeFileSync( + path.join(root, "manifest.json"), + JSON.stringify({ + id: "fixture", + version: "1.0.0", + capabilities: ["ui:theme"], + contributes: { + themes: [ + { + id: "paper", + label: "Paper", + mode: "light", + css: "themes/paper.css", + }, + ], + }, + }), + ); + if (css !== null) { + fs.mkdirSync(path.join(root, "themes")); + fs.writeFileSync(path.join(root, "themes", "paper.css"), css); + } + return root; + } + + it("reports a missing theme file", async () => { + expect(await problems(themed(null))).toContain( + "theme paper: themes/paper.css does not exist", + ); + }); + + it("reports theme CSS that reaches outside or leaves its class", async () => { + const output = await problems( + themed('@import "x.css"; body { color: red }'), + ); + expect(output).toContain("theme paper: @import is not allowed in a theme"); + expect(output).toContain( + 'theme paper: Selector "body" must start with .paper', + ); + }); + + it("says nothing about CSS that is fine", async () => { + const output = await problems(themed(".paper { --background: #fff; }")); + expect(output).not.toContain("theme paper"); + }); +}); + +describe("termix-plugin validate layouts and capabilities", () => { + function withManifest(extra: Record) { + const root = plugin([]); + fs.writeFileSync( + path.join(root, "manifest.json"), + JSON.stringify({ id: "fixture", version: "1.0.0", ...extra }), + ); + return root; + } + + it("flags ui:theme with no themes and ui:layout with no layouts", async () => { + const output = await problems( + withManifest({ capabilities: ["ui:theme", "ui:layout"] }), + ); + expect(output).toContain( + '"ui:theme" is declared but contributes.themes is empty', + ); + expect(output).toContain( + '"ui:layout" is declared but contributes.layouts is empty', + ); + }); + + it("flags a layout plugin with no frontend source", async () => { + const output = await problems( + withManifest({ + capabilities: ["ui:layout"], + contributes: { layouts: [{ id: "wide", label: "Wide" }] }, + }), + ); + expect(output).toContain("contributes.layouts needs a frontend"); + }); + + it("accepts a layout plugin that has a frontend entry", async () => { + const root = withManifest({ + capabilities: ["ui:layout"], + contributes: { layouts: [{ id: "wide", label: "Wide" }] }, + }); + fs.mkdirSync(path.join(root, "src", "frontend"), { recursive: true }); + fs.writeFileSync(path.join(root, "src", "frontend", "index.tsx"), ""); + const output = await problems(root); + expect(output).not.toContain("contributes.layouts needs a frontend"); + expect(output).not.toContain("is declared but"); + }); +}); diff --git a/src/backend/auth/login-pipeline.ts b/src/backend/auth/login-pipeline.ts index 7ed3dffe7..5348beafa 100644 --- a/src/backend/auth/login-pipeline.ts +++ b/src/backend/auth/login-pipeline.ts @@ -26,6 +26,12 @@ import { isSecondFactorAfterExternalLoginEnabled, } from "./core-auth.js"; import { findOrProvisionExternalUser } from "./provisioning.js"; +import { + SECOND_FACTOR_SETUP, + evaluateAuthPolicy, + hasAuthPolicies, + rememberDeviceAllowed, +} from "./policy.js"; import { getLoginMethod, getSecondFactor, @@ -122,12 +128,15 @@ export async function evaluateSecondFactors( * governs external methods (SSO, LDAP) alone. */ function shouldRunSecondFactors(methodId: string): boolean { - ensureCoreLoginProviders(); - const method = getLoginMethod(methodId); - if (!method?.external) return true; + if (!isExternalMethod(methodId)) return true; return isSecondFactorAfterExternalLoginEnabled(); } +function isExternalMethod(methodId: string): boolean { + ensureCoreLoginProviders(); + return !!getLoginMethod(methodId)?.external; +} + export const TRUST_DEVICE_COOKIE = "termix_trust_device"; const TRUST_TOKEN_PATTERN = /^[a-f0-9]{64}$/; const TRUST_TOKEN_MAX_AGE_MS = 30 * 24 * 60 * 60 * 1000; @@ -294,9 +303,40 @@ export async function runLogin( `${context.methodId}_role_shared_credentials`, ); - const factors = shouldRunSecondFactors(context.methodId) - ? await evaluateSecondFactors(user.id) - : { required: [], blocked: [] }; + // A login policy may want a factor where core would skip it, so it always + // sees the full picture. + const runFactors = shouldRunSecondFactors(context.methodId); + const factors = + runFactors || hasAuthPolicies() + ? await evaluateSecondFactors(user.id) + : { required: [], blocked: [] }; + const trusted = + factors.required.length > 0 || hasAuthPolicies() + ? await isTrustedDevice(req, user.id) + : false; + const policy = await evaluateAuthPolicy({ + userId: user.id, + methodId: context.methodId, + external: isExternalMethod(context.methodId), + deviceType, + trustedDevice: trusted, + mfaSatisfied: !!identity.mfaSatisfied, + secondFactors: listSecondFactors().map((factor) => ({ + id: factor.id, + pluginId: factor.pluginId, + enrolled: factors.required.some( + (required) => + required.id === factor.id && required.pluginId === factor.pluginId, + ), + })), + }); + if (!runFactors && !policy.secondFactor) { + factors.required = []; + factors.blocked = []; + } + const rememberMe = context.rememberMe && policy.allowRememberDevice; + const skipByDevice = + trusted && policy.allowRememberDevice && policy.secondFactor !== "always"; if (factors.blocked.length > 0) { authLogger.warn("Login refused: an enrolled second factor is unavailable", { operation: "second_factor_unavailable", @@ -324,11 +364,7 @@ export async function runLogin( const ssoClaims = { externalSession: identity.externalSession ?? null }; - if ( - factors.required.length > 0 && - !identity.mfaSatisfied && - !(await isTrustedDevice(req, user.id)) - ) { + if (factors.required.length > 0 && !identity.mfaSatisfied && !skipByDevice) { const tempToken = await AuthManager.getInstance().generateJWTToken( user.id, { pendingTOTP: true, expiresIn: "10m" }, @@ -337,14 +373,17 @@ export async function runLogin( pendingLogins.set(pendingKey(tempToken), { userId: user.id, methodId: context.methodId, - rememberMe: context.rememberMe, + rememberMe, + allowRememberDevice: policy.allowRememberDevice, + maxSessionSeconds: policy.maxSessionSeconds, + idleTimeoutSeconds: policy.idleTimeoutSeconds, ...ssoClaims, createdAt: Date.now(), }); return { kind: "second-factor", tempToken, - rememberMe: context.rememberMe, + rememberMe, factors: factors.required.map((factor) => ({ id: factor.id, pluginId: factor.pluginId, @@ -353,11 +392,22 @@ export async function runLogin( }; } + // Required but nothing set up yet: a session that can only set one up. + const needsSetup = + !!policy.secondFactor && + factors.required.length === 0 && + !identity.mfaSatisfied && + !skipByDevice; + const session = await issueSession(req, user, { methodId: context.methodId, - rememberMe: context.rememberMe, + rememberMe, rateLimitUsername: context.rateLimitUsername ?? identity.rateLimitUsername, - longLivedForApps: identity.kind === "external", + longLivedForApps: + identity.kind === "external" && policy.allowRememberDevice, + maxAgeSeconds: policy.maxSessionSeconds, + idleTimeoutSeconds: policy.idleTimeoutSeconds, + restriction: needsSetup ? SECOND_FACTOR_SETUP : null, ...ssoClaims, }); return { kind: "session", ...session }; @@ -510,10 +560,15 @@ export async function verifySecondFactorAndRespond( } loginRateLimiter.resetTOTPAttempts(user.id); + // A pending entry lost to a restart falls back to the current policy. + const allowRemember = lookup.pending + ? lookup.pending.allowRememberDevice !== false + : rememberDeviceAllowed(); const rememberMe = - typeof req.body?.rememberMe === "boolean" + allowRemember && + (typeof req.body?.rememberMe === "boolean" ? req.body.rememberMe - : (lookup.pending?.rememberMe ?? false); + : (lookup.pending?.rememberMe ?? false)); let trustToken: string | null = null; if (rememberMe) { @@ -545,6 +600,8 @@ export async function verifySecondFactorAndRespond( methodId: pending?.methodId ?? "password", rememberMe, externalSession: pending?.externalSession ?? null, + maxAgeSeconds: pending?.maxSessionSeconds ?? null, + idleTimeoutSeconds: pending?.idleTimeoutSeconds ?? null, }); if (trustToken && isNativeAppRequest(req)) { diff --git a/src/backend/auth/policy.ts b/src/backend/auth/policy.ts new file mode 100644 index 000000000..48fb22a65 --- /dev/null +++ b/src/backend/auth/policy.ts @@ -0,0 +1,125 @@ +/** + * Login policy from plugins through ctx.auth.policy.provide: idle timeout, + * session length, remember me and a required second factor. Core asks every + * provider and the strictest answer wins. A provider that throws at login + * refuses the login, since skipping a security rule is worse than a failed + * sign in. The idle timeout is stored on the session at login. Desktop + * loopback, sync link and plugin minted sessions are device credentials, not + * logins, so the policy never reaches them. + */ + +import type { + PluginAuthPolicyDecision, + PluginAuthPolicyInput, + PluginAuthPolicyProvider, +} from "@termix-ssh/plugin-sdk/backend"; +import { authLogger } from "../utils/logger.js"; +import { LoginMethodError } from "./types.js"; + +/** sessions.restriction of a session that may only set up a second factor. */ +export const SECOND_FACTOR_SETUP = "second-factor-setup"; + +interface Entry { + pluginId: string; + provider: PluginAuthPolicyProvider; +} + +const providers = new Map(); + +export function provideAuthPolicy( + pluginId: string, + provider: PluginAuthPolicyProvider, +): () => void { + const key = Symbol(pluginId); + providers.set(key, { pluginId, provider }); + return () => { + providers.delete(key); + }; +} + +/** Test seam. */ +export function clearAuthPolicies(): void { + providers.clear(); +} + +export function hasAuthPolicies(): boolean { + return providers.size > 0; +} + +export interface AuthPolicy { + allowRememberDevice: boolean; + maxSessionSeconds: number | null; + idleTimeoutSeconds: number | null; + secondFactor: "always" | "new-device" | null; +} + +const NO_POLICY: AuthPolicy = { + allowRememberDevice: true, + maxSessionSeconds: null, + idleTimeoutSeconds: null, + secondFactor: null, +}; + +function shortest(current: number | null, next: unknown): number | null { + if (typeof next !== "number" || !(next > 0)) return current; + const value = Math.floor(next); + return current === null ? value : Math.min(current, value); +} + +function merge(policy: AuthPolicy, next: PluginAuthPolicyDecision): AuthPolicy { + let secondFactor = policy.secondFactor; + if (next.secondFactor === "always") secondFactor = "always"; + else if (next.secondFactor === "new-device" && secondFactor !== "always") { + secondFactor = "new-device"; + } + return { + allowRememberDevice: + policy.allowRememberDevice && next.allowRememberDevice !== false, + maxSessionSeconds: shortest( + policy.maxSessionSeconds, + next.maxSessionSeconds, + ), + idleTimeoutSeconds: shortest( + policy.idleTimeoutSeconds, + next.idleTimeoutSeconds, + ), + secondFactor, + }; +} + +export async function evaluateAuthPolicy( + input: PluginAuthPolicyInput, +): Promise { + let policy = NO_POLICY; + for (const entry of providers.values()) { + let decision: PluginAuthPolicyDecision; + try { + decision = (await entry.provider.evaluate(input)) ?? {}; + } catch (error) { + authLogger.warn("Login policy failed, refusing the login", { + operation: "auth_policy", + pluginId: entry.pluginId, + error: error instanceof Error ? error.message : String(error), + }); + throw new LoginMethodError( + "Sign in is not available right now. Contact an admin.", + 503, + "auth_policy_unavailable", + ); + } + policy = merge(policy, decision); + } + return policy; +} + +/** Whether the login page should offer remember me. */ +export function rememberDeviceAllowed(): boolean { + for (const entry of providers.values()) { + try { + if (entry.provider.allowRememberDevice?.() === false) return false; + } catch { + return false; + } + } + return true; +} diff --git a/src/backend/auth/session-issuer.ts b/src/backend/auth/session-issuer.ts index 17aeb3c81..4fa01017e 100644 --- a/src/backend/auth/session-issuer.ts +++ b/src/backend/auth/session-issuer.ts @@ -68,6 +68,11 @@ export interface IssueSessionOptions { externalSession?: ExternalSessionRef | null; /** SSO logins keep desktop and mobile apps signed in for 30 days. */ longLivedForApps?: boolean; + /** Login policy cap on the session length. */ + maxAgeSeconds?: number | null; + /** See SECOND_FACTOR_SETUP. */ + restriction?: string | null; + idleTimeoutSeconds?: number | null; } export interface IssuedSession { @@ -93,6 +98,9 @@ export async function issueSession( deviceType: deviceInfo.type, deviceInfo: deviceInfo.deviceInfo, externalSession: options.externalSession ?? null, + maxAgeSeconds: options.maxAgeSeconds ?? null, + restriction: options.restriction ?? null, + idleTimeoutSeconds: options.idleTimeoutSeconds ?? null, }); if (options.rateLimitUsername) { @@ -123,11 +131,14 @@ export async function issueSession( method: options.methodId, }); - const maxAge = + let maxAge = options.longLivedForApps && (deviceInfo.type === "desktop" || deviceInfo.type === "mobile") ? THIRTY_DAYS_MS : await sessionCookieMaxAge(options.rememberMe); + if (options.maxAgeSeconds) { + maxAge = Math.min(maxAge, options.maxAgeSeconds * 1000); + } return { token, @@ -142,6 +153,7 @@ export async function issueSession( totp_enabled: await createCurrentUserAuthRepository().hasSecondFactor( user.id, ), + ...(options.restriction ? { restriction: options.restriction } : {}), ...(isNativeAppRequest(req) ? { token } : {}), }, }; diff --git a/src/backend/auth/types.ts b/src/backend/auth/types.ts index 938c6d8f9..627696fb9 100644 --- a/src/backend/auth/types.ts +++ b/src/backend/auth/types.ts @@ -35,5 +35,9 @@ export interface PendingLogin { methodId: string; rememberMe: boolean; externalSession?: ExternalSessionRef | null; + /** Login policy that applied when the login paused. */ + allowRememberDevice?: boolean; + maxSessionSeconds?: number | null; + idleTimeoutSeconds?: number | null; createdAt: number; } diff --git a/src/backend/database/database.ts b/src/backend/database/database.ts index c93ae8ea1..94b3b76e0 100644 --- a/src/backend/database/database.ts +++ b/src/backend/database/database.ts @@ -22,12 +22,12 @@ import sshAuthRoutes from "./routes/ssh-auth-routes.js"; import rbacRoutes from "./routes/rbac.js"; import openTabsRoutes from "./routes/open-tabs.js"; import userPreferencesRoutes from "./routes/user-preferences.js"; +import lookRoutes from "./routes/look.js"; import hostSidebarPreferencesRoutes from "./routes/host-sidebar-preferences.js"; import credentialSidebarPreferencesRoutes from "./routes/credential-sidebar-preferences.js"; import uiPreferencesRoutes from "./routes/ui-preferences.js"; import { registerAuditLogRoutes } from "./routes/audit-log-routes.js"; -import syncRoutes from "../sync/server/routes.js"; -import syncLinkRoutes from "../sync/client/routes.js"; +import { syncForwarder } from "../sync/provider.js"; import { syncChangeWatcher } from "../sync/server/change-watcher.js"; import dashboardRoutes from "./routes/dashboard-routes.js"; import { @@ -35,6 +35,7 @@ import { mountPluginLegacyPaths, } from "./routes/plugin-api-routes.js"; import { attachPluginWebSockets } from "../plugins/ws.js"; +import { registerSshRelay } from "../hosts/ssh-relay.js"; import pluginRoutes from "./routes/plugins.js"; import { createPluginAssetsRouter } from "../plugins/assets.js"; import { getPluginRuntime } from "../plugins/index.js"; @@ -72,6 +73,7 @@ import { } from "../plugins/user-data.js"; import { configureDirectHttps, getTlsConfig } from "../tls/tls-service.js"; import { acmeChallengeHandler } from "../tls/acme-challenges.js"; +import { httpsPolicyMiddleware } from "../tls/https-policy.js"; import { createCurrentCredentialRepository, createCurrentHostRepository, @@ -104,6 +106,7 @@ const __dirname = path.dirname(__filename); const app = express(); app.set("trust proxy", "loopback"); +app.use(httpsPolicyMiddleware); const authManager = AuthManager.getInstance(); const authenticateJWT = authManager.createAuthMiddleware(); @@ -1616,13 +1619,13 @@ app.use("/credentials", credentialsRoutes); app.use("/ssh-auth", sshAuthRoutes); app.use("/rbac", rbacRoutes); app.use("/open-tabs", openTabsRoutes); +app.use("/user-preferences/look", lookRoutes); app.use("/user-preferences", userPreferencesRoutes); app.use("/host-sidebar/preferences", hostSidebarPreferencesRoutes); app.use("/credential-sidebar/preferences", credentialSidebarPreferencesRoutes); app.use("/ui-preferences", uiPreferencesRoutes); registerAuditLogRoutes(app, authenticateJWT); -app.use("/sync", syncRoutes); -app.use("/sync", syncLinkRoutes); +app.use("/sync", syncForwarder); app.use("/dashboard", dashboardRoutes); app.use("/plugins", pluginRoutes); app.use( @@ -1896,6 +1899,7 @@ const httpServer = http.createServer(app); // Plugin sockets ride this server at /plugin-ws//. Anything else is // left alone, so core's own upgrade handling is unaffected. attachPluginWebSockets(httpServer); +registerSshRelay(); httpServer.on("error", (err: NodeJS.ErrnoException) => { if (err.code === "EADDRINUSE") { diff --git a/src/backend/database/db/index.ts b/src/backend/database/db/index.ts index e72930e99..bdbad7b18 100644 --- a/src/backend/database/db/index.ts +++ b/src/backend/database/db/index.ts @@ -370,17 +370,6 @@ async function initializeCompleteDatabase(): Promise { FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE ); - CREATE TABLE IF NOT EXISTS recent_activity ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - user_id TEXT NOT NULL, - type TEXT NOT NULL, - host_id INTEGER NOT NULL, - host_name TEXT, - timestamp TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, - FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE, - FOREIGN KEY (host_id) REFERENCES ssh_data (id) ON DELETE CASCADE - ); - CREATE TABLE IF NOT EXISTS host_access ( id INTEGER PRIMARY KEY AUTOINCREMENT, host_id INTEGER NOT NULL, @@ -985,6 +974,8 @@ const migrateSchema = () => { addColumnIfNotExists("user_preferences", "compact_host_view", "INTEGER"); addColumnIfNotExists("user_preferences", "status_color_scheme", "TEXT"); addColumnIfNotExists("user_preferences", "custom_keybindings", "TEXT"); + addColumnIfNotExists("user_preferences", "layout_id", "TEXT"); + addColumnIfNotExists("user_preferences", "theme_id", "TEXT"); addColumnIfNotExists("users", "is_admin", "INTEGER NOT NULL DEFAULT 0"); @@ -1030,6 +1021,8 @@ const migrateSchema = () => { } enforceUniqueUsernames(); addColumnIfNotExists("sessions", "external_session_ref", "TEXT"); + addColumnIfNotExists("sessions", "restriction", "TEXT"); + addColumnIfNotExists("sessions", "idle_timeout", "INTEGER"); addColumnIfNotExists("ssh_data", "name", "TEXT"); addColumnIfNotExists("ssh_data", "folder", "TEXT"); diff --git a/src/backend/database/db/performance-indexes.ts b/src/backend/database/db/performance-indexes.ts index 9c0afb1c6..3e0513e10 100644 --- a/src/backend/database/db/performance-indexes.ts +++ b/src/backend/database/db/performance-indexes.ts @@ -98,12 +98,7 @@ export const PERFORMANCE_INDEXES: PerformanceIndex[] = [ columns: "user_id", }, - // Per-user history and file manager surfaces. - { - name: "idx_recent_activity_user_ts", - table: "recent_activity", - columns: "user_id, timestamp", - }, + // Per-user open tabs. { name: "idx_user_open_tabs_user_id", table: "user_open_tabs", diff --git a/src/backend/database/db/schema.mysql.ts b/src/backend/database/db/schema.mysql.ts index 8876d9c1c..8d9dc86ea 100644 --- a/src/backend/database/db/schema.mysql.ts +++ b/src/backend/database/db/schema.mysql.ts @@ -59,6 +59,10 @@ export const sessions = mysqlTable( lastActiveAt: text("last_active_at") .notNull() .default(sql`(CURRENT_TIMESTAMP)`), + // Set while the session may only set up a second factor. + restriction: text("restriction"), + // Seconds without a request before the session ends, from the login policy. + idleTimeout: int("idle_timeout"), }, // Listing a user's devices, and the startup sweep of expired rows. (table) => [ @@ -414,28 +418,6 @@ export const hostDefaults = mysqlTable( ], ); -export const recentActivity = mysqlTable( - "recent_activity", - { - id: int("id").autoincrement().primaryKey(), - userId: varchar("user_id", { length: 255 }) - .notNull() - .references(() => users.id, { onDelete: "cascade" }), - type: text("type").notNull(), - hostId: int("host_id") - .notNull() - .references(() => hosts.id, { onDelete: "cascade" }), - hostName: text("host_name"), - timestamp: varchar("timestamp", { length: 255 }) - .notNull() - .default(sql`(CURRENT_TIMESTAMP)`), - }, - // Always read newest-first for one user, so timestamp follows user_id. - (table) => [ - index("idx_recent_activity_user_ts").on(table.userId, table.timestamp), - ], -); - export const hostAccess = mysqlTable( "host_access", { @@ -709,6 +691,8 @@ export const userPreferences = mysqlTable("user_preferences", { compactHostView: boolean("compact_host_view"), statusColorScheme: text("status_color_scheme"), customKeybindings: text("custom_keybindings"), + layoutId: text("layout_id"), + themeId: text("theme_id"), updatedAt: text("updated_at") .notNull() .default(sql`(CURRENT_TIMESTAMP)`), diff --git a/src/backend/database/db/schema.pg.ts b/src/backend/database/db/schema.pg.ts index 6f3de67fa..231598fef 100644 --- a/src/backend/database/db/schema.pg.ts +++ b/src/backend/database/db/schema.pg.ts @@ -59,6 +59,10 @@ export const sessions = pgTable( lastActiveAt: text("last_active_at") .notNull() .default(sql`CURRENT_TIMESTAMP`), + // Set while the session may only set up a second factor. + restriction: text("restriction"), + // Seconds without a request before the session ends, from the login policy. + idleTimeout: integer("idle_timeout"), }, // Listing a user's devices, and the startup sweep of expired rows. (table) => [ @@ -414,28 +418,6 @@ export const hostDefaults = pgTable( ], ); -export const recentActivity = pgTable( - "recent_activity", - { - id: serial("id").primaryKey(), - userId: varchar("user_id", { length: 255 }) - .notNull() - .references(() => users.id, { onDelete: "cascade" }), - type: text("type").notNull(), - hostId: integer("host_id") - .notNull() - .references(() => hosts.id, { onDelete: "cascade" }), - hostName: text("host_name"), - timestamp: varchar("timestamp", { length: 255 }) - .notNull() - .default(sql`CURRENT_TIMESTAMP`), - }, - // Always read newest-first for one user, so timestamp follows user_id. - (table) => [ - index("idx_recent_activity_user_ts").on(table.userId, table.timestamp), - ], -); - export const hostAccess = pgTable( "host_access", { @@ -709,6 +691,8 @@ export const userPreferences = pgTable("user_preferences", { compactHostView: boolean("compact_host_view"), statusColorScheme: text("status_color_scheme"), customKeybindings: text("custom_keybindings"), + layoutId: text("layout_id"), + themeId: text("theme_id"), updatedAt: text("updated_at") .notNull() .default(sql`CURRENT_TIMESTAMP`), diff --git a/src/backend/database/db/schema.ts b/src/backend/database/db/schema.ts index 0bd971ffb..2ad02eb0d 100644 --- a/src/backend/database/db/schema.ts +++ b/src/backend/database/db/schema.ts @@ -47,6 +47,10 @@ export const sessions = sqliteTable( lastActiveAt: text("last_active_at") .notNull() .default(sql`CURRENT_TIMESTAMP`), + // Set while the session may only set up a second factor. + restriction: text("restriction", { length: 32 }), + // Seconds without a request before the session ends, from the login policy. + idleTimeout: integer("idle_timeout"), }, // Listing a user's devices, and the startup sweep of expired rows. (table) => [ @@ -404,28 +408,6 @@ export const hostDefaults = sqliteTable( ], ); -export const recentActivity = sqliteTable( - "recent_activity", - { - id: integer("id").primaryKey({ autoIncrement: true }), - userId: text("user_id") - .notNull() - .references(() => users.id, { onDelete: "cascade" }), - type: text("type").notNull(), - hostId: integer("host_id") - .notNull() - .references(() => hosts.id, { onDelete: "cascade" }), - hostName: text("host_name"), - timestamp: text("timestamp") - .notNull() - .default(sql`CURRENT_TIMESTAMP`), - }, - // Always read newest-first for one user, so timestamp follows user_id. - (table) => [ - index("idx_recent_activity_user_ts").on(table.userId, table.timestamp), - ], -); - export const hostAccess = sqliteTable( "host_access", { @@ -703,6 +685,8 @@ export const userPreferences = sqliteTable("user_preferences", { compactHostView: integer("compact_host_view", { mode: "boolean" }), statusColorScheme: text("status_color_scheme"), customKeybindings: text("custom_keybindings"), + layoutId: text("layout_id"), + themeId: text("theme_id"), updatedAt: text("updated_at") .notNull() .default(sql`CURRENT_TIMESTAMP`), diff --git a/src/backend/database/repositories/factory.ts b/src/backend/database/repositories/factory.ts index 2c5fdb800..4c05f385e 100644 --- a/src/backend/database/repositories/factory.ts +++ b/src/backend/database/repositories/factory.ts @@ -23,7 +23,6 @@ import { PluginPermissionGrantRepository } from "./plugin-permission-grant-repos import { UserAuthRepository } from "./user-auth-repository.js"; import { RbacAccessRepository } from "./rbac-access-repository.js"; import { RbacPermissionRepository } from "./rbac-permission-repository.js"; -import { RecentActivityRepository } from "./recent-activity-repository.js"; import { RoleRepository } from "./role-repository.js"; import { SessionRepository } from "./session-repository.js"; import { CredentialAccessRepository } from "./credential-access-repository.js"; @@ -269,13 +268,6 @@ export function createCurrentRbacAccessRepository(): RbacAccessRepository { ); } -export function createCurrentRecentActivityRepository(): RecentActivityRepository { - return new RecentActivityRepository( - createCurrentRepositoryContext(), - createCurrentRepositoryWriteHook("recent_activity_repository_write"), - ); -} - export function createCurrentRoleRepository(): RoleRepository { return new RoleRepository( createCurrentRepositoryContext(), diff --git a/src/backend/database/repositories/host-folder-repository.ts b/src/backend/database/repositories/host-folder-repository.ts index 9f561bad7..de337d7d9 100644 --- a/src/backend/database/repositories/host-folder-repository.ts +++ b/src/backend/database/repositories/host-folder-repository.ts @@ -236,6 +236,29 @@ export class HostFolderRepository { .where(and(eq(hosts.userId, userId), folderMatch(hosts.folder))); } + /** Drops the folder rows (and subfolders) only when no host is left in them. */ + async deleteEmptyFolderRecords( + userId: string, + folderName: string, + ): Promise { + if ((await this.listHostsInFolder(userId, folderName)).length > 0) { + return false; + } + await deleteReturning( + this.context, + sshFolders, + and( + eq(sshFolders.userId, userId), + or( + eq(sshFolders.name, folderName), + like(sshFolders.name, `${folderName} / %`), + ), + ), + ); + await this.afterWrite(); + return true; + } + async deleteHostsAndFolderRecords( userId: string, folderName: string, diff --git a/src/backend/database/repositories/recent-activity-repository.ts b/src/backend/database/repositories/recent-activity-repository.ts deleted file mode 100644 index 30b5fd097..000000000 --- a/src/backend/database/repositories/recent-activity-repository.ts +++ /dev/null @@ -1,107 +0,0 @@ -import { desc, eq, inArray } from "drizzle-orm"; -import { recentActivity } from "../db/schema.js"; -import type { DatabaseContext } from "./database-context.js"; -import { rowsAffected } from "./mutation-result.js"; -import { insertReturning } from "./returning.js"; - -export type RecentActivityRecord = typeof recentActivity.$inferSelect; -export type NewRecentActivityRecord = typeof recentActivity.$inferInsert; - -export class RecentActivityRepository { - constructor( - private readonly context: DatabaseContext, - private readonly onWrite?: () => void | Promise, - ) {} - - async listByUserId( - userId: string, - limit: number, - ): Promise { - return this.context.drizzle - .select() - .from(recentActivity) - .where(eq(recentActivity.userId, userId)) - .orderBy(desc(recentActivity.timestamp)) - .limit(limit); - } - - async create( - activity: NewRecentActivityRecord, - ): Promise { - const rows = await insertReturning(this.context, recentActivity, activity); - - await this.afterWrite(); - return rows[0]; - } - - async trimUserActivity(userId: string, keepCount: number): Promise { - const rows = await this.context.drizzle - .select({ id: recentActivity.id }) - .from(recentActivity) - .where(eq(recentActivity.userId, userId)) - .orderBy(desc(recentActivity.timestamp)); - - const idsToDelete = rows - .slice(keepCount) - .map((row) => row.id) - .filter((id) => typeof id === "number"); - - if (idsToDelete.length === 0) { - return 0; - } - - const result = await this.context.drizzle - .delete(recentActivity) - .where(inArray(recentActivity.id, idsToDelete)); - - if (rowsAffected(result) > 0) { - await this.afterWrite(); - } - - return rowsAffected(result); - } - - async deleteByUserId(userId: string): Promise { - const result = await this.context.drizzle - .delete(recentActivity) - .where(eq(recentActivity.userId, userId)); - - if (rowsAffected(result) > 0) { - await this.afterWrite(); - } - - return rowsAffected(result); - } - - async deleteByHostId(hostId: number): Promise { - const result = await this.context.drizzle - .delete(recentActivity) - .where(eq(recentActivity.hostId, hostId)); - - if (rowsAffected(result) > 0) { - await this.afterWrite(); - } - - return rowsAffected(result); - } - - async deleteByHostIds(hostIds: number[]): Promise { - if (hostIds.length === 0) { - return 0; - } - - const result = await this.context.drizzle - .delete(recentActivity) - .where(inArray(recentActivity.hostId, hostIds)); - - if (rowsAffected(result) > 0) { - await this.afterWrite(); - } - - return rowsAffected(result); - } - - private async afterWrite(): Promise { - await this.onWrite?.(); - } -} diff --git a/src/backend/database/repositories/session-repository.ts b/src/backend/database/repositories/session-repository.ts index 7799e956c..736e8cc75 100644 --- a/src/backend/database/repositories/session-repository.ts +++ b/src/backend/database/repositories/session-repository.ts @@ -69,6 +69,14 @@ export class SessionRepository { return true; } + async clearRestrictionForUser(userId: string): Promise { + await this.context.drizzle + .update(sessions) + .set({ restriction: null }) + .where(eq(sessions.userId, userId)); + await this.afterWrite(); + } + async updateToken( id: string, jwtToken: string, diff --git a/src/backend/database/routes/audit-log-routes.ts b/src/backend/database/routes/audit-log-routes.ts index 60dea304b..91273b914 100644 --- a/src/backend/database/routes/audit-log-routes.ts +++ b/src/backend/database/routes/audit-log-routes.ts @@ -5,12 +5,6 @@ import { createCurrentUserRepository, } from "../repositories/factory.js"; import { apiLogger } from "../../utils/logger.js"; -import { exportFilename, toCsv, toNdjson } from "../../utils/audit-export.js"; -import { - logAudit, - getAuditUsername, - getRequestMeta, -} from "../../utils/audit-logger.js"; async function isAdminUser(userId: string | undefined): Promise { if (!userId) return false; @@ -138,125 +132,4 @@ export function registerAuditLogRoutes( .json({ error: "Failed to fetch audit log actions" }); } }); - - /** - * @openapi - * /audit-logs/export: - * get: - * summary: Export audit logs - * description: Streams the full filtered result set as CSV or NDJSON. Accepts the same filters as GET /audit-logs. Admin only. The export is itself audited. - * tags: - * - Audit - * parameters: - * - in: query - * name: format - * schema: { type: string, enum: [csv, ndjson], default: csv } - * - in: query - * name: userId - * schema: { type: string } - * - in: query - * name: action - * schema: { type: string } - * - in: query - * name: resourceType - * schema: { type: string } - * - in: query - * name: success - * schema: { type: string, enum: [true, false] } - * - in: query - * name: startDate - * schema: { type: string, format: date-time } - * - in: query - * name: endDate - * schema: { type: string, format: date-time } - * responses: - * 200: - * description: Audit log file. - * 403: - * description: Not authorized. - * 500: - * description: Failed to export audit logs. - */ - router.get("/audit-logs/export", authenticateJWT, async (req, res) => { - const authReq = req as AuthenticatedRequest; - try { - if (!(await isAdminUser(authReq.userId))) { - return res.status(403).json({ error: "Not authorized" }); - } - - const format = req.query.format === "ndjson" ? "ndjson" : "csv"; - const { userId, action, resourceType, success, startDate, endDate } = - req.query as Record; - const filters = { - userId, - action, - resourceType, - success: - success !== undefined && success !== "" - ? success === "true" - : undefined, - startDate, - endDate, - }; - - res.setHeader( - "Content-Type", - format === "csv" ? "text/csv; charset=utf-8" : "application/x-ndjson", - ); - res.setHeader( - "Content-Disposition", - `attachment; filename="${exportFilename(format, new Date())}"`, - ); - - // Streamed in batches: an export is unbounded by definition, and the - // whole point is to move data out before retention drops it. - const BATCH = 500; - let offset = 0; - let exported = 0; - - for (;;) { - const rows = await createCurrentAuditLogRepository().listForExport({ - filters, - limit: BATCH, - offset, - }); - if (rows.length === 0) break; - - if (format === "csv") { - // Header only on the first batch. - const chunk = toCsv(rows); - res.write( - offset === 0 ? chunk : chunk.slice(chunk.indexOf("\n") + 1), - ); - } else { - res.write(toNdjson(rows)); - } - - exported += rows.length; - offset += rows.length; - if (rows.length < BATCH) break; - } - - res.end(); - - // Reading the whole trail is itself worth recording. - const { ipAddress, userAgent } = getRequestMeta(req); - void logAudit({ - userId: authReq.userId!, - username: await getAuditUsername(authReq.userId!), - action: "export_audit_logs", - resourceType: "audit_log", - details: JSON.stringify({ format, exported, filters }), - ipAddress, - userAgent, - success: true, - }); - } catch (err) { - apiLogger.error("Failed to export audit logs", err); - if (!res.headersSent) { - return res.status(500).json({ error: "Failed to export audit logs" }); - } - res.end(); - } - }); } diff --git a/src/backend/database/routes/auth-routes.ts b/src/backend/database/routes/auth-routes.ts index 9260f351d..c6b9af76f 100644 --- a/src/backend/database/routes/auth-routes.ts +++ b/src/backend/database/routes/auth-routes.ts @@ -25,6 +25,7 @@ import { } from "../../auth/registry.js"; import { isTrustedProxyAuthEnabled } from "../../utils/trusted-proxy-auth.js"; import { LoginMethodError, type VerifiedIdentity } from "../../auth/types.js"; +import { rememberDeviceAllowed } from "../../auth/policy.js"; export interface PublicLoginMethod { id: string; @@ -243,7 +244,7 @@ export function registerAuthRoutes(router: Router): void { * /users/auth/methods: * get: * summary: List login methods - * description: Public. The login methods with at least one enabled instance, for the login screen. Carries ids and labels only. + * description: Public. The login methods with at least one enabled instance, for the login screen. Carries ids and labels only, plus rememberDevice, false when the login policy turned remember me off. * tags: * - Auth * responses: @@ -252,7 +253,10 @@ export function registerAuthRoutes(router: Router): void { */ router.get("/auth/methods", async (_req, res) => { try { - res.json({ methods: await listPublicLoginMethods() }); + res.json({ + methods: await listPublicLoginMethods(), + rememberDevice: rememberDeviceAllowed(), + }); } catch (error) { authLogger.error("Failed to list login methods", error); res.status(500).json({ error: "Failed to list login methods" }); diff --git a/src/backend/database/routes/branding-routes.ts b/src/backend/database/routes/branding-routes.ts index da30d147a..7be688ac5 100644 --- a/src/backend/database/routes/branding-routes.ts +++ b/src/backend/database/routes/branding-routes.ts @@ -1,98 +1,9 @@ -import type { Request, RequestHandler, Response, Router } from "express"; +import type { Request, Response, Router } from "express"; import { databaseLogger } from "../../utils/logger.js"; import { createCurrentSettingsRepository } from "../repositories/factory.js"; -import { - BRANDING_KEYS, - parseBrandingAppName, - parseBrandingLogoDataUrl, - parseBrandingTagline, - resolveBrandingSettings, -} from "./branding-settings.js"; +import { resolveBrandingSettings } from "./branding-settings.js"; -const PATCHABLE_FIELDS = ["appName", "tagline", "logo"] as const; -type PatchableField = (typeof PATCHABLE_FIELDS)[number]; - -function invalidField(res: Response, field: string): void { - res.status(400).json({ - error: `Invalid value for ${field}`, - code: "BRANDING_SETTINGS_INVALID", - field, - }); -} - -/** - * Validates a PATCH body and returns the settings-table writes/deletes it - * implies, or null after the response has already been failed with a 400. - * `logo: null` clears the custom logo (falls back to the bundled default). - */ -async function buildBrandingWrites( - body: unknown, - res: Response, -): Promise<{ - writes: Array<{ key: string; value: string }>; - deletes: string[]; -} | null> { - if (typeof body !== "object" || body === null || Array.isArray(body)) { - res.status(400).json({ - error: "Invalid request body", - code: "BRANDING_SETTINGS_INVALID", - }); - return null; - } - - for (const field of Object.keys(body)) { - if (!(PATCHABLE_FIELDS as readonly string[]).includes(field)) { - res.status(400).json({ - error: `Unknown setting field: ${field}`, - code: "BRANDING_SETTINGS_UNKNOWN_FIELD", - field, - }); - return null; - } - } - - const input = body as Partial>; - const writes: Array<{ key: string; value: string }> = []; - const deletes: string[] = []; - - if (input.appName !== undefined) { - const appName = parseBrandingAppName(input.appName); - if (appName === null) { - invalidField(res, "appName"); - return null; - } - writes.push({ key: BRANDING_KEYS.appName, value: appName }); - } - - if (input.tagline !== undefined) { - const tagline = parseBrandingTagline(input.tagline); - if (tagline === null) { - invalidField(res, "tagline"); - return null; - } - writes.push({ key: BRANDING_KEYS.tagline, value: tagline }); - } - - if (input.logo !== undefined) { - if (input.logo === null) { - deletes.push(BRANDING_KEYS.logo); - } else { - const logo = await parseBrandingLogoDataUrl(input.logo); - if (logo === null) { - invalidField(res, "logo"); - return null; - } - writes.push({ key: BRANDING_KEYS.logo, value: logo }); - } - } - - return { writes, deletes }; -} - -export function registerBrandingRoutes( - router: Router, - requireAdmin: RequestHandler, -): void { +export function registerBrandingRoutes(router: Router): void { /** * @openapi * /users/branding: @@ -118,48 +29,4 @@ export function registerBrandingRoutes( res.status(500).json({ error: "Failed to load branding settings" }); } }); - - /** - * @openapi - * /users/branding: - * patch: - * summary: Update white-label branding (admin only) - * description: Persists a partial update to appName, tagline and/or logo (a PNG/JPEG/WEBP data URL; pass null to clear the logo). Invalid values are rejected with a 400. - * tags: - * - Users - * responses: - * 200: - * description: Updated effective branding settings. - * 400: - * description: Invalid or unknown setting field. - * 401: - * description: Not authenticated. - * 403: - * description: Admin access required. - * 500: - * description: Failed to save branding settings. - */ - router.patch( - "/branding", - requireAdmin, - async (req: Request, res: Response) => { - const result = await buildBrandingWrites(req.body, res); - if (result === null) return; - - try { - const settings = createCurrentSettingsRepository(); - if (result.writes.length > 0) { - await settings.setMany(result.writes); - } - for (const key of result.deletes) { - await settings.delete(key); - } - const resolved = await resolveBrandingSettings(settings); - res.json(resolved); - } catch (err) { - databaseLogger.error("Failed to save branding settings", err); - res.status(500).json({ error: "Failed to save branding settings" }); - } - }, - ); } diff --git a/src/backend/database/routes/branding-settings.ts b/src/backend/database/routes/branding-settings.ts index 883816587..7a491d036 100644 --- a/src/backend/database/routes/branding-settings.ts +++ b/src/backend/database/routes/branding-settings.ts @@ -1,5 +1,4 @@ -import sharp from "sharp"; -import { databaseLogger } from "../../utils/logger.js"; +import { consumeByName } from "../../plugins/registry.js"; /** * White-label branding: admin-configurable app name, logo and tagline shown @@ -20,25 +19,33 @@ export const BRANDING_KEYS = { } as const; export const BRANDING_DEFAULT_APP_NAME = "Termix"; -const APP_NAME_MAX_LENGTH = 60; -const TAGLINE_MAX_LENGTH = 160; - -/** - * Cap applies to the decoded image bytes, not the base64 text. The settings - * table is fully cached in memory per backend process (see - * settings-cache.ts), so an oversized logo would be permanently resident RAM - * on every instance, not just a one-time upload cost. - */ -export const BRANDING_LOGO_MAX_BYTES = 750 * 1024; -const BRANDING_LOGO_MAX_INPUT_PIXELS = 20_000_000; interface SettingSource { get(key: string): Promise; } +export const BRANDING_PROVIDER_NAME = "brandingProvider"; + +/** A plugin supplies branding by providing ".brandingProvider". */ +export type BrandingProvider = () => + Promise | BrandingSettings; + export async function resolveBrandingSettings( settings: SettingSource, ): Promise { + const provider = consumeByName(BRANDING_PROVIDER_NAME); + if (provider) { + try { + const provided = await provider(); + return { + appName: provided.appName?.trim() || BRANDING_DEFAULT_APP_NAME, + tagline: provided.tagline ?? "", + logo: provided.logo || null, + }; + } catch { + // A broken provider falls back to the stored settings. + } + } const [appName, tagline, logo] = await Promise.all([ settings.get(BRANDING_KEYS.appName), settings.get(BRANDING_KEYS.tagline), @@ -53,74 +60,3 @@ export async function resolveBrandingSettings( } /** Trimmed, 1-60 chars. Falls back to the default when cleared. */ -export function parseBrandingAppName(value: unknown): string | null { - if (typeof value !== "string") return null; - const trimmed = value.trim(); - if (trimmed.length === 0) return BRANDING_DEFAULT_APP_NAME; - if (trimmed.length > APP_NAME_MAX_LENGTH) return null; - return trimmed; -} - -/** Trimmed, 0-160 chars. An empty string means "use the built-in default". */ -export function parseBrandingTagline(value: unknown): string | null { - if (typeof value !== "string") return null; - const trimmed = value.trim(); - if (trimmed.length > TAGLINE_MAX_LENGTH) return null; - return trimmed; -} - -/** - * Validates and re-encodes an uploaded logo. Only raster formats are - * accepted (no SVG), sidestepping SVG-script XSS entirely rather than trying - * to sanitize markup. The image is always re-encoded to PNG through sharp: - * this both proves the payload is a genuine image (not a disguised file - * behind a spoofed data-URL prefix) and strips any embedded metadata. - * - * Returns null on any validation failure; the caller is expected to reject - * the request with a 400 without echoing back details of what was rejected. - */ -export async function parseBrandingLogoDataUrl( - value: unknown, -): Promise { - if (typeof value !== "string") return null; - const match = value.match( - /^data:image\/(png|jpe?g|webp);base64,([a-zA-Z0-9+/]+=*)$/, - ); - if (!match) return null; - - const [, , base64] = match; - let buffer: Buffer; - try { - buffer = Buffer.from(base64, "base64"); - } catch { - return null; - } - if (buffer.length === 0 || buffer.length > BRANDING_LOGO_MAX_BYTES * 4) { - // Reject wildly oversized input before decoding it with sharp; the final - // size check happens below on the re-encoded PNG. - return null; - } - - try { - const source = sharp(buffer, { - failOn: "error", - limitInputPixels: BRANDING_LOGO_MAX_INPUT_PIXELS, - }); - const metadata = await source.metadata(); - if ( - !metadata.format || - !["png", "jpeg", "webp"].includes(metadata.format) - ) { - return null; - } - const normalized = await source.rotate().png().toBuffer(); - if (normalized.length > BRANDING_LOGO_MAX_BYTES) return null; - return `data:image/png;base64,${normalized.toString("base64")}`; - } catch (err) { - databaseLogger.warn("Rejected invalid branding logo upload", { - operation: "branding_logo_invalid", - error: err instanceof Error ? err.message : String(err), - }); - return null; - } -} diff --git a/src/backend/database/routes/credential-deploy-routes.ts b/src/backend/database/routes/credential-deploy-routes.ts deleted file mode 100644 index 1af7c45fd..000000000 --- a/src/backend/database/routes/credential-deploy-routes.ts +++ /dev/null @@ -1,436 +0,0 @@ -import { getErrorMessage } from "../../utils/error-message.js"; -import type { - AuthenticatedRequest, - CredentialBackend, -} from "../../../types/index.js"; -import type { Request, RequestHandler, Response, Router } from "express"; -import type { Client as SshClient } from "ssh2"; -import { createCurrentHostResolutionRepository } from "../repositories/factory.js"; -import { connectHost } from "../../hosts/connect/connect-host.js"; -import type { SshConnectHost } from "../../hosts/connect/types.js"; -import { resolveHostById } from "../../hosts/host-resolver.js"; -import { isSecretReference } from "../../hosts/external-secrets.js"; -import { resolveKeyReferences } from "../../hosts/stored-ssh-key.js"; -import { parseSSHKey } from "../../utils/ssh-key-utils.js"; - -function describeDeployError(err: unknown): string { - const message = getErrorMessage(err, "Connection failed"); - if (message.includes("All configured authentication methods failed")) { - return "Authentication failed. Please check your credentials and ensure the SSH service is running."; - } - if (message.includes("ENOTFOUND") || message.includes("ENOENT")) { - return "Could not resolve hostname or connect to server."; - } - if (message.includes("ECONNREFUSED")) { - return "Connection refused. The server may not be running or the port may be incorrect."; - } - if (message.includes("ETIMEDOUT")) { - return "Connection timed out. Check your network connection and server availability."; - } - if ( - message.includes("authentication failed") || - message.includes("Permission denied") - ) { - return "Authentication failed. Please check your username and password/key."; - } - if (message === "SSH connection timeout") return "Connection timeout"; - return message; -} - -async function deploySSHKeyToHost( - host: SshConnectHost, - userId: string, - credData: CredentialBackend, -): Promise<{ success: boolean; message?: string; error?: string }> { - const publicKey = credData.publicKey as string; - - let conn: SshClient; - try { - ({ client: conn } = await connectHost(host, { - userId, - purpose: "credential-deploy", - timeoutMs: 120000, - })); - } catch (error) { - return { success: false, error: describeDeployError(error) }; - } - - return new Promise((resolve) => { - void (async () => { - try { - await new Promise((resolveCmd, rejectCmd) => { - const cmdTimeout = setTimeout(() => { - rejectCmd(new Error("mkdir command timeout")); - }, 10000); - - conn.exec( - "test -d ~/.ssh || mkdir -p ~/.ssh; chmod 700 ~/.ssh", - (err, stream) => { - if (err) { - clearTimeout(cmdTimeout); - return rejectCmd(err); - } - - stream.on("close", (code) => { - clearTimeout(cmdTimeout); - if (code === 0) { - resolveCmd(); - } else { - rejectCmd( - new Error(`mkdir command failed with code ${code}`), - ); - } - }); - - stream.on("data", () => { - // Ignore output - }); - }, - ); - }); - - const keyExists = await new Promise( - (resolveCheck, rejectCheck) => { - const checkTimeout = setTimeout(() => { - rejectCheck(new Error("Key check timeout")); - }, 5000); - - let actualPublicKey = publicKey; - try { - const parsed = JSON.parse(publicKey); - if (parsed.data) { - actualPublicKey = parsed.data; - } - } catch { - // Ignore parse errors - } - - const keyParts = actualPublicKey.trim().split(" "); - if (keyParts.length < 2) { - clearTimeout(checkTimeout); - return rejectCheck( - new Error( - "Invalid public key format - must contain at least 2 parts", - ), - ); - } - - const keyPattern = keyParts[1]; - if (!/^[A-Za-z0-9+/]+={0,2}$/.test(keyPattern)) { - clearTimeout(checkTimeout); - return rejectCheck(new Error("Invalid public key data")); - } - - conn.exec( - `if [ -f ~/.ssh/authorized_keys ]; then grep -F "${keyPattern}" ~/.ssh/authorized_keys >/dev/null 2>&1; echo $?; else echo 1; fi`, - (err, stream) => { - if (err) { - clearTimeout(checkTimeout); - return rejectCheck(err); - } - - let output = ""; - stream.on("data", (data) => { - output += data.toString(); - }); - - stream.on("close", () => { - clearTimeout(checkTimeout); - const exists = output.trim() === "0"; - resolveCheck(exists); - }); - }, - ); - }, - ); - - if (keyExists) { - conn.end(); - resolve({ success: true, message: "SSH key already deployed" }); - return; - } - - await new Promise((resolveAdd, rejectAdd) => { - const addTimeout = setTimeout(() => { - rejectAdd(new Error("Key add timeout")); - }, 30000); - - let actualPublicKey = publicKey; - try { - const parsed = JSON.parse(publicKey); - if (parsed.data) { - actualPublicKey = parsed.data; - } - } catch { - // Ignore parse errors - } - - const escapedKey = actualPublicKey - .replace(/\\/g, "\\\\") - .replace(/'/g, "'\\''"); - const escapedName = credData.name - .replace(/\\/g, "\\\\") - .replace(/'/g, "'\\''"); - - conn.exec( - `printf '%s\n' '${escapedKey} ${escapedName}@Termix' >> ~/.ssh/authorized_keys && chmod 600 ~/.ssh/authorized_keys`, - (err, stream) => { - if (err) { - clearTimeout(addTimeout); - return rejectAdd(err); - } - - stream.on("data", () => { - // Consume output - }); - - stream.on("close", (code) => { - clearTimeout(addTimeout); - if (code === 0) { - resolveAdd(); - } else { - rejectAdd( - new Error(`Key deployment failed with code ${code}`), - ); - } - }); - }, - ); - }); - - const verifySuccess = await new Promise( - (resolveVerify, rejectVerify) => { - const verifyTimeout = setTimeout(() => { - rejectVerify(new Error("Key verification timeout")); - }, 5000); - - let actualPublicKey = publicKey; - try { - const parsed = JSON.parse(publicKey); - if (parsed.data) { - actualPublicKey = parsed.data; - } - } catch { - // Ignore parse errors - } - - const keyParts = actualPublicKey.trim().split(" "); - if (keyParts.length < 2) { - clearTimeout(verifyTimeout); - return rejectVerify( - new Error( - "Invalid public key format - must contain at least 2 parts", - ), - ); - } - - const keyPattern = keyParts[1]; - if (!/^[A-Za-z0-9+/]+={0,2}$/.test(keyPattern)) { - clearTimeout(verifyTimeout); - return rejectVerify(new Error("Invalid public key data")); - } - conn.exec( - `grep -F "${keyPattern}" ~/.ssh/authorized_keys >/dev/null 2>&1; echo $?`, - (err, stream) => { - if (err) { - clearTimeout(verifyTimeout); - return rejectVerify(err); - } - - let output = ""; - stream.on("data", (data) => { - output += data.toString(); - }); - - stream.on("close", () => { - clearTimeout(verifyTimeout); - const verified = output.trim() === "0"; - resolveVerify(verified); - }); - }, - ); - }, - ); - - conn.end(); - - if (verifySuccess) { - resolve({ success: true, message: "SSH key deployed successfully" }); - } else { - resolve({ - success: false, - error: "Key deployment verification failed", - }); - } - } catch (error) { - conn.end(); - resolve({ - success: false, - error: getErrorMessage(error, "Deployment failed"), - }); - } - })(); - }); -} - -export function registerCredentialDeployRoutes( - router: Router, - authenticateJWT: RequestHandler, - requireCredentialViewPermission: RequestHandler, - requireHostEditPermission: RequestHandler, - requireDataAccess: RequestHandler, -): void { - /** - * @openapi - * /credentials/{id}/deploy-to-host: - * post: - * summary: Deploy SSH key to a host - * description: Deploys an SSH public key to a target host's authorized_keys file. - * tags: - * - Credentials - * parameters: - * - in: path - * name: id - * required: true - * schema: - * type: integer - * requestBody: - * required: true - * content: - * application/json: - * schema: - * type: object - * properties: - * targetHostId: - * type: integer - * responses: - * 200: - * description: SSH key deployed successfully. - * 400: - * description: Credential ID and target host ID are required. - * 401: - * description: Authentication required. - * 404: - * description: Credential or target host not found. - * 500: - * description: Failed to deploy SSH key. - */ - router.post( - "/:id/deploy-to-host", - authenticateJWT, - requireCredentialViewPermission, - requireHostEditPermission, - requireDataAccess, - async (req: Request, res: Response) => { - const id = Array.isArray(req.params.id) - ? req.params.id[0] - : req.params.id; - const credentialId = parseInt(id); - const { targetHostId } = req.body; - - if (!credentialId || !targetHostId) { - return res.status(400).json({ - success: false, - error: "Credential ID and target host ID are required", - }); - } - - try { - const userId = (req as AuthenticatedRequest).userId; - if (!userId) { - return res.status(401).json({ - success: false, - error: "Authentication required", - }); - } - - const repository = createCurrentHostResolutionRepository(); - const credential = await repository.findCredentialByIdForUser( - credentialId, - userId, - ); - - if (!credential) { - return res.status(404).json({ - success: false, - error: "Credential not found", - }); - } - - const credData = credential as unknown as CredentialBackend; - - if (credData.authType !== "key") { - return res.status(400).json({ - success: false, - error: "Only SSH key-based credentials can be deployed", - }); - } - - let publicKey = credData.publicKey; - const storedKey = credData.privateKey || credData.key; - if (!publicKey && isSecretReference(storedKey)) { - // A key kept in a secret source has no stored public key; derive - // it from the resolved key. - const resolved = await resolveKeyReferences( - userId, - storedKey, - (credData as { keyPassword?: string | null }).keyPassword, - ); - publicKey = - parseSSHKey(resolved.key, resolved.passphrase).publicKey || - undefined; - } - if (!publicKey) { - return res.status(400).json({ - success: false, - error: "Public key is required for deployment", - }); - } - // Writing authorized_keys is an owner action, so ownership is checked - // before the shared resolver fills in the credentials. - const hostData = await repository.findHostByIdForUser( - targetHostId, - userId, - ); - - if (!hostData) { - return res.status(404).json({ - success: false, - error: "Target host not found", - }); - } - - const resolvedHost = await resolveHostById(targetHostId, userId); - if (!resolvedHost) { - return res.status(400).json({ - success: false, - error: "Host credential not found", - }); - } - - const deployResult = await deploySSHKeyToHost( - resolvedHost as unknown as SshConnectHost, - userId, - credData, - ); - - if (deployResult.success) { - res.json({ - success: true, - message: deployResult.message || "SSH key deployed successfully", - }); - } else { - res.status(500).json({ - success: false, - error: deployResult.error || "Deployment failed", - }); - } - } catch (error) { - res.status(500).json({ - success: false, - error: getErrorMessage(error, "Failed to deploy SSH key"), - }); - } - }, - ); -} diff --git a/src/backend/database/routes/credential-key-routes.ts b/src/backend/database/routes/credential-key-routes.ts deleted file mode 100644 index 746095d56..000000000 --- a/src/backend/database/routes/credential-key-routes.ts +++ /dev/null @@ -1,519 +0,0 @@ -import { getErrorMessage } from "../../utils/error-message.js"; -import type { Request, RequestHandler, Response, Router } from "express"; -import crypto from "crypto"; -import ssh2Pkg from "ssh2"; -import { authLogger } from "../../utils/logger.js"; -import { - parsePublicKey, - parseSSHKey, - validateKeyPair, -} from "../../utils/ssh-key-utils.js"; -import { resolveKeyReferences } from "../../hosts/stored-ssh-key.js"; -import type { AuthenticatedRequest } from "../../../types/index.js"; - -const { utils: ssh2Utils } = ssh2Pkg; - -function generateSSHKeyPair( - keyType: string, - keySize?: number, - passphrase?: string, -): { - success: boolean; - privateKey?: string; - publicKey?: string; - error?: string; -} { - try { - let ssh2Type = keyType; - const options: { - bits?: number; - passphrase?: string; - cipher?: string; - } = {}; - - if (keyType === "ssh-rsa") { - ssh2Type = "rsa"; - options.bits = keySize || 2048; - } else if (keyType === "ssh-ed25519") { - ssh2Type = "ed25519"; - } else if (keyType === "ecdsa-sha2-nistp256") { - ssh2Type = "ecdsa"; - options.bits = 256; - } - - if (passphrase && passphrase.trim()) { - options.passphrase = passphrase; - options.cipher = "aes128-cbc"; - } - - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const keyPair = ssh2Utils.generateKeyPairSync(ssh2Type as any, options); - - return { - success: true, - privateKey: keyPair.private, - publicKey: keyPair.public, - }; - } catch (error) { - return { - success: false, - error: getErrorMessage(error, "SSH key generation failed"), - }; - } -} - -export function registerCredentialKeyRoutes( - router: Router, - authenticateJWT: RequestHandler, - requireCredentialViewPermission: RequestHandler, - requireDataAccess: RequestHandler, -): void { - /** - * @openapi - * /credentials/detect-key-type: - * post: - * summary: Detect SSH key type - * description: Detects the type of an SSH private key. - * tags: - * - Credentials - * requestBody: - * required: true - * content: - * application/json: - * schema: - * type: object - * properties: - * privateKey: - * type: string - * keyPassword: - * type: string - * responses: - * 200: - * description: Key type detection result. - * 400: - * description: Private key is required. - * 500: - * description: Failed to detect key type. - */ - router.post( - "/detect-key-type", - authenticateJWT, - requireCredentialViewPermission, - requireDataAccess, - async (req: Request, res: Response) => { - if (!req.body?.privateKey || typeof req.body.privateKey !== "string") { - return res.status(400).json({ error: "Private key is required" }); - } - - try { - const { key: privateKey, passphrase: keyPassword } = - await resolveKeyReferences( - (req as AuthenticatedRequest).userId, - req.body.privateKey, - req.body.keyPassword, - ); - const keyInfo = parseSSHKey(privateKey, keyPassword); - - const response = { - success: keyInfo.success, - keyType: keyInfo.keyType, - detectedKeyType: keyInfo.keyType, - hasPublicKey: !!keyInfo.publicKey, - error: keyInfo.error || null, - }; - - res.json(response); - } catch (error) { - authLogger.error("Failed to detect key type", error); - res.status(500).json({ - error: getErrorMessage(error, "Failed to detect key type"), - }); - } - }, - ); - - /** - * @openapi - * /credentials/detect-public-key-type: - * post: - * summary: Detect SSH public key type - * description: Detects the type of an SSH public key. - * tags: - * - Credentials - * requestBody: - * required: true - * content: - * application/json: - * schema: - * type: object - * properties: - * publicKey: - * type: string - * responses: - * 200: - * description: Key type detection result. - * 400: - * description: Public key is required. - * 500: - * description: Failed to detect public key type. - */ - router.post( - "/detect-public-key-type", - authenticateJWT, - requireCredentialViewPermission, - requireDataAccess, - async (req: Request, res: Response) => { - const { publicKey } = req.body; - - if (!publicKey || typeof publicKey !== "string") { - return res.status(400).json({ error: "Public key is required" }); - } - - try { - const keyInfo = parsePublicKey(publicKey); - - const response = { - success: keyInfo.success, - keyType: keyInfo.keyType, - detectedKeyType: keyInfo.keyType, - error: keyInfo.error || null, - }; - - res.json(response); - } catch (error) { - authLogger.error("Failed to detect public key type", error); - res.status(500).json({ - error: getErrorMessage(error, "Failed to detect public key type"), - }); - } - }, - ); - - /** - * @openapi - * /credentials/validate-key-pair: - * post: - * summary: Validate SSH key pair - * description: Validates if a given SSH private key and public key match. - * tags: - * - Credentials - * requestBody: - * required: true - * content: - * application/json: - * schema: - * type: object - * properties: - * privateKey: - * type: string - * publicKey: - * type: string - * keyPassword: - * type: string - * responses: - * 200: - * description: Key pair validation result. - * 400: - * description: Private key and public key are required. - * 500: - * description: Failed to validate key pair. - */ - router.post( - "/validate-key-pair", - authenticateJWT, - requireCredentialViewPermission, - requireDataAccess, - async (req: Request, res: Response) => { - const { privateKey, publicKey, keyPassword } = req.body; - - if (!privateKey || typeof privateKey !== "string") { - return res.status(400).json({ error: "Private key is required" }); - } - - if (!publicKey || typeof publicKey !== "string") { - return res.status(400).json({ error: "Public key is required" }); - } - - try { - const validationResult = validateKeyPair( - privateKey, - publicKey, - keyPassword, - ); - - const response = { - isValid: validationResult.isValid, - privateKeyType: validationResult.privateKeyType, - publicKeyType: validationResult.publicKeyType, - generatedPublicKey: validationResult.generatedPublicKey, - error: validationResult.error || null, - }; - - res.json(response); - } catch (error) { - authLogger.error("Failed to validate key pair", error); - res.status(500).json({ - error: getErrorMessage(error, "Failed to validate key pair"), - }); - } - }, - ); - - /** - * @openapi - * /credentials/generate-key-pair: - * post: - * summary: Generate new SSH key pair - * description: Generates a new SSH key pair. - * tags: - * - Credentials - * requestBody: - * content: - * application/json: - * schema: - * type: object - * properties: - * keyType: - * type: string - * keySize: - * type: integer - * passphrase: - * type: string - * responses: - * 200: - * description: The new key pair. - * 500: - * description: Failed to generate SSH key pair. - */ - router.post( - "/generate-key-pair", - authenticateJWT, - requireCredentialViewPermission, - requireDataAccess, - async (req: Request, res: Response) => { - const { keyType = "ssh-ed25519", keySize = 2048, passphrase } = req.body; - - try { - const result = generateSSHKeyPair(keyType, keySize, passphrase); - - if (result.success && result.privateKey && result.publicKey) { - const response = { - success: true, - privateKey: result.privateKey, - publicKey: result.publicKey, - keyType: keyType, - format: "ssh", - algorithm: keyType, - keySize: keyType === "ssh-rsa" ? keySize : undefined, - curve: keyType === "ecdsa-sha2-nistp256" ? "nistp256" : undefined, - }; - - res.json(response); - } else { - res.status(500).json({ - success: false, - error: result.error || "Failed to generate SSH key pair", - }); - } - } catch (error) { - authLogger.error("Failed to generate key pair", error); - res.status(500).json({ - success: false, - error: getErrorMessage(error, "Failed to generate key pair"), - }); - } - }, - ); - - /** - * @openapi - * /credentials/generate-public-key: - * post: - * summary: Generate public key from private key - * description: Generates a public key from a given private key. - * tags: - * - Credentials - * requestBody: - * required: true - * content: - * application/json: - * schema: - * type: object - * properties: - * privateKey: - * type: string - * keyPassword: - * type: string - * responses: - * 200: - * description: The generated public key. - * 400: - * description: Private key is required. - * 500: - * description: Failed to generate public key. - */ - router.post( - "/generate-public-key", - authenticateJWT, - requireCredentialViewPermission, - requireDataAccess, - async (req: Request, res: Response) => { - if (!req.body?.privateKey || typeof req.body.privateKey !== "string") { - return res.status(400).json({ error: "Private key is required" }); - } - - try { - const { key: privateKey, passphrase: keyPassword } = - await resolveKeyReferences( - (req as AuthenticatedRequest).userId, - req.body.privateKey, - req.body.keyPassword, - ); - let privateKeyObj; - const parseAttempts = []; - - try { - privateKeyObj = crypto.createPrivateKey({ - key: privateKey, - passphrase: keyPassword, - }); - } catch (error) { - parseAttempts.push(`Method 1 (with passphrase): ${error.message}`); - } - - if (!privateKeyObj) { - try { - privateKeyObj = crypto.createPrivateKey(privateKey); - } catch (error) { - parseAttempts.push( - `Method 2 (without passphrase): ${error.message}`, - ); - } - } - - if (!privateKeyObj) { - try { - privateKeyObj = crypto.createPrivateKey({ - key: privateKey, - format: "pem", - type: "pkcs8", - }); - } catch (error) { - parseAttempts.push(`Method 3 (PKCS#8): ${error.message}`); - } - } - - if ( - !privateKeyObj && - privateKey.includes("-----BEGIN RSA PRIVATE KEY-----") - ) { - try { - privateKeyObj = crypto.createPrivateKey({ - key: privateKey, - format: "pem", - type: "pkcs1", - }); - } catch (error) { - parseAttempts.push(`Method 4 (PKCS#1): ${error.message}`); - } - } - - if ( - !privateKeyObj && - privateKey.includes("-----BEGIN EC PRIVATE KEY-----") - ) { - try { - privateKeyObj = crypto.createPrivateKey({ - key: privateKey, - format: "pem", - type: "sec1", - }); - } catch (error) { - parseAttempts.push(`Method 5 (SEC1): ${error.message}`); - } - } - - if (!privateKeyObj) { - try { - const keyInfo = parseSSHKey(privateKey, keyPassword); - - if (keyInfo.success && keyInfo.publicKey) { - const publicKeyString = String(keyInfo.publicKey); - return res.json({ - success: true, - publicKey: publicKeyString, - keyType: keyInfo.keyType, - }); - } else { - parseAttempts.push( - `SSH2 fallback: ${keyInfo.error || "No public key generated"}`, - ); - } - } catch (error) { - parseAttempts.push(`SSH2 fallback exception: ${error.message}`); - } - } - - if (!privateKeyObj) { - return res.status(400).json({ - success: false, - error: "Unable to parse private key. Tried multiple formats.", - details: parseAttempts, - }); - } - - const publicKeyObj = crypto.createPublicKey(privateKeyObj); - const publicKeyPem = publicKeyObj.export({ - type: "spki", - format: "pem", - }); - - const publicKeyString = - typeof publicKeyPem === "string" - ? publicKeyPem - : (publicKeyPem as Buffer).toString("utf8"); - - let keyType = "unknown"; - const asymmetricKeyType = privateKeyObj.asymmetricKeyType; - - if (asymmetricKeyType === "rsa") { - keyType = "ssh-rsa"; - } else if (asymmetricKeyType === "ed25519") { - keyType = "ssh-ed25519"; - } else if (asymmetricKeyType === "ec") { - keyType = "ecdsa-sha2-nistp256"; - } - - let finalPublicKey = publicKeyString; - let formatType = "pem"; - - try { - const ssh2PrivateKey = ssh2Utils.parseKey(privateKey, keyPassword); - if (!(ssh2PrivateKey instanceof Error)) { - const publicKeyBuffer = ssh2PrivateKey.getPublicSSH(); - const base64Data = publicKeyBuffer.toString("base64"); - finalPublicKey = `${keyType} ${base64Data}`; - formatType = "ssh"; - } - } catch { - // Ignore validation errors - } - - const response = { - success: true, - publicKey: finalPublicKey, - keyType: keyType, - format: formatType, - }; - - res.json(response); - } catch (error) { - authLogger.error("Failed to generate public key", error); - res.status(500).json({ - success: false, - error: getErrorMessage(error, "Failed to generate public key"), - }); - } - }, - ); -} diff --git a/src/backend/database/routes/credentials.ts b/src/backend/database/routes/credentials.ts index ae1d3777e..f8782c73a 100644 --- a/src/backend/database/routes/credentials.ts +++ b/src/backend/database/routes/credentials.ts @@ -8,8 +8,6 @@ import { authLogger } from "../../utils/logger.js"; import { PermissionManager } from "../../utils/permission-manager.js"; import { AuthManager } from "../../utils/auth-manager.js"; import { parseKeyForStorage } from "../../hosts/stored-ssh-key.js"; -import { registerCredentialKeyRoutes } from "./credential-key-routes.js"; -import { registerCredentialDeployRoutes } from "./credential-deploy-routes.js"; import { registerCredentialBulkRoutes } from "./credential-bulk-routes.js"; import { logAudit, @@ -1211,19 +1209,4 @@ router.put( }, ); -registerCredentialKeyRoutes( - router, - authenticateJWT, - permissionManager.requirePermission("credentials.view"), - requireDataAccess, -); - -registerCredentialDeployRoutes( - router, - authenticateJWT, - permissionManager.requirePermission("credentials.view"), - permissionManager.requirePermission("hosts.edit"), - requireDataAccess, -); - export default router; diff --git a/src/backend/database/routes/dashboard-routes.ts b/src/backend/database/routes/dashboard-routes.ts index 7e609e82a..ffb9debbd 100644 --- a/src/backend/database/routes/dashboard-routes.ts +++ b/src/backend/database/routes/dashboard-routes.ts @@ -3,8 +3,7 @@ import express, { type Request, type Response } from "express"; import { dashboardLogger } from "../../utils/logger.js"; import { AuthManager } from "../../utils/auth-manager.js"; import { DataCrypto } from "../../utils/data-crypto.js"; -import { createCurrentRecentActivityRepository } from "../repositories/factory.js"; -import { recordRecentActivity } from "../../services/recent-activity.js"; +import { recentActivity } from "../../plugins/activity-provider.js"; const router = express.Router(); @@ -99,11 +98,7 @@ router.get( const limit = Number(req.query.limit) || 20; - const activities = - await createCurrentRecentActivityRepository().listByUserId( - userId, - limit, - ); + const activities = await recentActivity.list(userId, limit); res.json(activities); } catch (err) { @@ -169,7 +164,7 @@ router.post( }); } - const result = await recordRecentActivity(userId, { + const result = await recentActivity.record(userId, { type, hostId, hostName, @@ -185,6 +180,11 @@ router.post( message: "Activity already logged recently (rate limited)", }); } + if (result.status === "unavailable") { + return res + .status(404) + .json({ error: "Recent activity is not available" }); + } if (result.status === "denied") { return res .status(404) @@ -222,7 +222,7 @@ router.delete( try { const userId = (req as AuthenticatedRequest).userId; - await createCurrentRecentActivityRepository().deleteByUserId(userId); + await recentActivity.deleteByUserId(userId); dashboardLogger.success("Recent activity cleared", { operation: "reset_recent_activity", diff --git a/src/backend/database/routes/delete-user-data.ts b/src/backend/database/routes/delete-user-data.ts index 03db9b78f..1457579a6 100644 --- a/src/backend/database/routes/delete-user-data.ts +++ b/src/backend/database/routes/delete-user-data.ts @@ -1,4 +1,5 @@ import { authLogger } from "../../utils/logger.js"; +import { recentActivity } from "../../plugins/activity-provider.js"; import { createCurrentApiKeyRepository, createCurrentAuditLogRepository, @@ -9,7 +10,6 @@ import { createCurrentCredentialSidebarPreferenceRepository, createCurrentUiPreferenceRepository, createCurrentOpenTabRepository, - createCurrentRecentActivityRepository, createCurrentRbacAccessRepository, createCurrentRoleRepository, createCurrentSessionRepository, @@ -64,7 +64,7 @@ export async function deleteUserAndRelatedData( await createCurrentSshCredentialUsageRepository().deleteByUserId(userId); - await createCurrentRecentActivityRepository().deleteByUserId(userId); + await recentActivity.deleteByUserId(userId); await createCurrentHostFolderRepository().deleteByUserId(userId); diff --git a/src/backend/database/routes/desktop-auto-session.ts b/src/backend/database/routes/desktop-auto-session.ts index 915e1bfd5..1c8dac924 100644 --- a/src/backend/database/routes/desktop-auto-session.ts +++ b/src/backend/database/routes/desktop-auto-session.ts @@ -61,15 +61,23 @@ export function isNativeTokenExportRequest(req: Request): boolean { * during testing, or a household sharing one install) -- a user in that * state deserves to get into the app they installed, not a confusing, * unexplained dead end. So this always returns a single, deterministic - * user: the admin account if one exists, else the earliest-registered - * account. It never returns null. + * user: the account the user switched to if it still exists, else the admin + * account if one exists, else the earliest-registered account. It never + * returns null when there are users. */ export function resolveDesktopAutoSessionUser( allUsers: UserRecord[], + preferredId?: string | null, ): UserRecord | null { if (allUsers.length === 0) return null; if (allUsers.length === 1) return allUsers[0]; + // An account the user switched to on purpose, while it still exists. + const preferred = preferredId + ? allUsers.find((user) => user.id === preferredId) + : undefined; + if (preferred) return preferred; + const admin = allUsers.find((user) => user.isAdmin); if (admin) return admin; diff --git a/src/backend/database/routes/host-bulk-routes.ts b/src/backend/database/routes/host-bulk-routes.ts index 74814a6b3..4055704b0 100644 --- a/src/backend/database/routes/host-bulk-routes.ts +++ b/src/backend/database/routes/host-bulk-routes.ts @@ -1,172 +1,23 @@ -import { - prepareHostImports, - remapImportedJumpHosts, -} from "./host-import-order.js"; -import { sshOptionsForWrite } from "../../hosts/ssh-options.js"; -import { - applyDefaultsAfterHostWrites, - applyHostDefaultsToWrite, -} from "../../hosts/defaults/index.js"; import { changeHostOverrides, type OverrideChange, } from "../../hosts/defaults/overrides.js"; import { recompute } from "../../hosts/defaults/recompute.js"; import { splitDefaultKey } from "../../../types/host-defaults.js"; -import { - keepUsableProtocolCredentials, - readProtocolAuthPayload, - writeProtocolAuth, -} from "../../hosts/protocol-auth/protocol-auth.js"; -import { getErrorMessage } from "../../utils/error-message.js"; + import type { AuthenticatedRequest } from "../../../types/index.js"; import type { Request, RequestHandler, Response, Router } from "express"; import { sshLogger } from "../../utils/logger.js"; import { DatabaseSaveTrigger } from "../../utils/database-save-trigger.js"; -import { pluginEvents, TOPICS } from "../../plugins/events.js"; -import { - createCurrentCredentialRepository, - createCurrentHostRepository, - createCurrentHostResolutionRepository, - createCurrentHostDefaultsRepository, -} from "../repositories/factory.js"; +import { createCurrentHostRepository } from "../repositories/factory.js"; import { validateParentHostId } from "./host-parent-validation.js"; -import { - listSshAuthProviders, - listSshAuthTypeOwners, -} from "../../hosts/connect/auth-provider-registry.js"; -import { - applyPluginHostImportSettings, - setHostPluginEnabled, -} from "./host-plugin-settings.js"; -import { - isNonEmptyString, - isValidPort, - normalizeImportedHost, -} from "./host-normalizers.js"; +import { assertHostsUnlocked, sendHostLocked } from "../../hosts/locks.js"; -type SSHConfigHost = { - name: string; - hostname?: string; - user?: string; - port?: number; - identityFile?: string; - proxyJump?: string; -}; - -type ShareCredential = { - alias?: unknown; - name?: unknown; - description?: unknown; - folder?: unknown; - tags?: unknown; - authType?: unknown; - username?: unknown; - keyType?: unknown; -}; - -function textValue(value: unknown): string | null { - return typeof value === "string" && value.trim() ? value.trim() : null; -} - -function tagString(value: unknown): string { - if (Array.isArray(value)) { - return value - .map((tag) => textValue(tag)) - .filter((tag): tag is string => !!tag) - .join(","); - } - return textValue(value) || ""; -} - -function normalizeCredentialAuthType(value: unknown): "password" | "key" { - return value === "key" ? "key" : "password"; -} - -export function parseSSHConfig(content: string): SSHConfigHost[] { - const results: SSHConfigHost[] = []; - let current: SSHConfigHost | null = null; - - for (const rawLine of content.split("\n")) { - const line = rawLine.trim(); - if (!line || line.startsWith("#")) continue; - - const match = /^(\S+?)(?:\s*=\s*|\s+)(.+)$/.exec(line); - if (!match) continue; - - const key = match[1].toLowerCase(); - const value = match[2]; - - if (key === "host") { - if (current && current.hostname) results.push(current); - // Skip wildcard patterns - if (value === "*" || value.includes("*") || value.includes("?")) { - current = null; - } else { - current = { name: value }; - } - continue; - } - - if (!current) continue; - - switch (key) { - case "hostname": - current.hostname = value; - break; - case "user": - current.user = value; - break; - case "port": { - const p = Number.parseInt(value, 10); - if (p > 0 && p <= 65535) current.port = p; - break; - } - case "identityfile": - if (!current.identityFile) current.identityFile = value; - break; - case "proxyjump": - current.proxyJump = value; - break; - } - } - - if (current && current.hostname) results.push(current); - - return results; -} - -/** - * 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[], - 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 }); - } -} - -export function importedHostUsername( - connectionType: string, - authType: unknown, - username: unknown, -): string | null { - if (isNonEmptyString(username)) return username; - if (connectionType !== "ssh" || authType === "credential") return ""; - return null; -} +import { setHostPluginEnabled } from "./host-plugin-settings.js"; export function registerHostBulkRoutes( router: Router, authenticateJWT: RequestHandler, - requireCreatePermission: RequestHandler, requireEditPermission: RequestHandler, requireDataAccess: RequestHandler, ): void { @@ -291,6 +142,15 @@ export function registerHostBulkRoutes( ); const ownedIds = ownedHosts.map((h) => h.id); + // Pins stay the user's own on a managed host. + if (Object.keys(updates).some((key) => key !== "pin")) { + try { + assertHostsUnlocked(ownedIds); + } catch (error) { + if (sendHostLocked(res, error)) return; + throw error; + } + } const unauthorizedIds = hostIds.filter( (id: number) => !ownedIds.includes(id), ); @@ -467,674 +327,6 @@ export function registerHostBulkRoutes( } }, ); - - router.post( - "/bulk-import", - authenticateJWT, - requireCreatePermission, - requireEditPermission, - requireDataAccess, - DatabaseSaveTrigger.batched(async (req: Request, res: Response) => { - const userId = (req as AuthenticatedRequest).userId; - const { - hosts: hostsToImport, - overwrite, - credentials: credentialsToImport, - } = req.body; - - if (!Array.isArray(hostsToImport) || hostsToImport.length === 0) { - return res - .status(400) - .json({ error: "Hosts array is required and must not be empty" }); - } - - if (hostsToImport.length > 100) { - return res - .status(400) - .json({ error: "Maximum 100 hosts allowed per import" }); - } - - let orderedHosts: ReturnType; - try { - orderedHosts = prepareHostImports(hostsToImport); - } catch (error) { - return res.status(400).json({ error: getErrorMessage(error) }); - } - const importedIds = new Map(); - - const results = { - success: 0, - updated: 0, - skipped: 0, - failed: 0, - errors: [] as string[], - }; - - const credentialAliasMap = new Map(); - const addCredentialAlias = (alias: unknown, id: number) => { - const key = textValue(alias); - if (key) credentialAliasMap.set(key.toLowerCase(), id); - }; - - try { - const credentialRepository = createCurrentCredentialRepository(); - const existingCredentials = - await credentialRepository.listDecryptedByUserId(userId); - - for (const credential of existingCredentials) { - addCredentialAlias(credential.name, credential.id as number); - } - - if (Array.isArray(credentialsToImport)) { - for (const rawCredential of credentialsToImport as ShareCredential[]) { - const alias = textValue(rawCredential.alias); - const name = textValue(rawCredential.name) || alias; - if (!alias || !name) continue; - - const existingId = credentialAliasMap.get(name.toLowerCase()); - if (existingId) { - addCredentialAlias(alias, existingId); - continue; - } - - const now = new Date().toISOString(); - const created = await credentialRepository.createEncryptedForUser( - userId, - { - userId, - name, - description: - textValue(rawCredential.description) || - "Imported placeholder. Add the secret before connecting.", - folder: textValue(rawCredential.folder), - tags: tagString(rawCredential.tags), - authType: normalizeCredentialAuthType(rawCredential.authType), - username: textValue(rawCredential.username), - password: null, - key: null, - privateKey: null, - publicKey: null, - keyPassword: null, - keyType: textValue(rawCredential.keyType), - detectedKeyType: null, - usageCount: 0, - lastUsed: null, - createdAt: now, - updatedAt: now, - }, - ); - - const createdCredential = created as Record; - addCredentialAlias(alias, createdCredential.id as number); - addCredentialAlias(name, createdCredential.id as number); - } - } - } catch (error) { - results.errors.push( - `Credential placeholders: ${getErrorMessage(error, "failed to prepare credential aliases")}`, - ); - } - - // Loaded for both modes: overwrite updates a match, skip leaves it alone. - let existingHostMap: Map | undefined; - const hostRepository = createCurrentHostRepository(); - try { - const allHosts = - await createCurrentHostResolutionRepository().findHostsByUserId( - userId, - ); - existingHostMap = new Map(); - for (const h of allHosts) { - const key = `${h.ip}:${h.port}:${h.username}`; - existingHostMap.set(key, { id: h.id as number }); - } - } catch { - existingHostMap = undefined; - } - - const knownAuthTypes = listKnownAuthTypes(); - const writtenHostIds: number[] = []; - const createdHostIds: number[] = []; - for (const { host: hostData, index: i, exportId } of orderedHosts) { - try { - const effectiveConnectionType = hostData.connectionType || "ssh"; - - if ( - effectiveConnectionType === "ssh" && - hostData.authType === "credential" && - !hostData.credentialId && - hostData.credentialAlias - ) { - hostData.credentialId = credentialAliasMap.get( - hostData.credentialAlias.toLowerCase(), - ); - } - - if (!isNonEmptyString(hostData.ip) || !isValidPort(hostData.port)) { - results.failed++; - results.errors.push( - `Host ${i + 1}: Missing required fields (ip, port)`, - ); - continue; - } - - const username = importedHostUsername( - effectiveConnectionType, - hostData.authType, - hostData.username, - ); - if (username === null) { - results.failed++; - results.errors.push( - `Host ${i + 1}: Username required for SSH connections`, - ); - continue; - } - - const lookupKey = `${hostData.ip}:${hostData.port}:${hostData.username}`; - const existing = existingHostMap?.get(lookupKey); - if (existing && !overwrite) { - // Hosts that jump through this one still resolve to the kept host. - if (exportId !== undefined) importedIds.set(exportId, existing.id); - results.skipped++; - continue; - } - - if ( - effectiveConnectionType === "ssh" && - hostData.authType && - !knownAuthTypes.has(hostData.authType) - ) { - results.failed++; - results.errors.push( - `Host ${i + 1}: Invalid authType. Must be one of ${[...knownAuthTypes].join(", ")}`, - ); - continue; - } - - if ( - effectiveConnectionType === "ssh" && - hostData.authType === "password" && - !isNonEmptyString(hostData.password) - ) { - results.failed++; - results.errors.push( - `Host ${i + 1}: Password required for password authentication`, - ); - continue; - } - - if ( - effectiveConnectionType === "ssh" && - hostData.authType === "key" && - !isNonEmptyString(hostData.key) - ) { - results.failed++; - results.errors.push( - `Host ${i + 1}: Key required for key authentication`, - ); - continue; - } - - if ( - effectiveConnectionType === "ssh" && - hostData.authType === "credential" && - !hostData.credentialId - ) { - results.failed++; - results.errors.push( - `Host ${i + 1}: credentialId required for credential authentication`, - ); - continue; - } - - if ( - effectiveConnectionType === "ssh" && - hostData.authType === "credential" && - hostData.credentialId - ) { - const credentialRepository = createCurrentCredentialRepository(); - const cred = await credentialRepository.findByIdForUser( - userId, - hostData.credentialId, - ); - - if (!cred) { - const fallback = await credentialRepository.listByUserId(userId); - - if (fallback.length > 0) { - hostData.credentialId = fallback[0].id; - } else if (isNonEmptyString(hostData.key)) { - hostData.authType = "key"; - hostData.credentialId = undefined; - } else if (isNonEmptyString(hostData.password)) { - hostData.authType = "password"; - hostData.credentialId = undefined; - } else { - results.failed++; - results.errors.push( - `Host ${i + 1}: credentialId ${hostData.credentialId} not found and no fallback credential available`, - ); - continue; - } - } - } - - const jumpHosts = remapImportedJumpHosts( - hostData.jumpHosts, - importedIds, - ); - const sshDataObj: Record = { - userId: userId, - connectionType: effectiveConnectionType, - name: hostData.name || `${username}@${hostData.ip}`, - folder: hostData.folder || "Default", - tags: Array.isArray(hostData.tags) ? hostData.tags.join(",") : "", - ip: hostData.ip, - port: hostData.port, - username, - pin: hostData.pin || false, - sudoPassword: hostData.sudoPassword || null, - jumpHosts: jumpHosts ? JSON.stringify(jumpHosts) : null, - ...importedStatusCheck(hostData as Record), - // A 2.8 export carries these inside terminalConfig. - sshOptions: - sshOptionsForWrite({ - sshOptions: hostData.sshOptions, - terminalConfig: hostData.terminalConfig, - }) ?? null, - forceKeyboardInteractive: hostData.forceKeyboardInteractive - ? "true" - : "false", - notes: hostData.notes || null, - useSocks5: hostData.useSocks5 ? 1 : 0, - socks5Host: hostData.socks5Host || null, - socks5Port: hostData.socks5Port || null, - socks5Username: hostData.socks5Username || null, - socks5Password: hostData.socks5Password || null, - socks5ProxyChain: hostData.socks5ProxyChain - ? JSON.stringify(hostData.socks5ProxyChain) - : null, - portKnockSequence: hostData.portKnockSequence - ? JSON.stringify(hostData.portKnockSequence) - : null, - overrideCredentialUsername: hostData.overrideCredentialUsername - ? 1 - : 0, - enableSsh: hostData.enableSsh ?? effectiveConnectionType === "ssh", - updatedAt: new Date().toISOString(), - }; - - if (effectiveConnectionType !== "ssh") { - sshDataObj.password = hostData.password || null; - sshDataObj.authType = "password"; - sshDataObj.credentialId = null; - sshDataObj.key = null; - sshDataObj.keyPassword = null; - sshDataObj.keyType = null; - } else { - sshDataObj.password = - hostData.authType === "password" ? hostData.password : null; - sshDataObj.authType = hostData.authType || "password"; - sshDataObj.credentialId = - hostData.authType === "credential" ? hostData.credentialId : null; - sshDataObj.key = hostData.authType === "key" ? hostData.key : null; - sshDataObj.keyPassword = - hostData.authType === "key" ? hostData.keyPassword || null : null; - sshDataObj.keyType = - hostData.authType === "key" ? hostData.keyType || "auto" : null; - } - - await withHostDefaults( - userId, - existing?.id ?? null, - sshDataObj, - hostData as Record, - ); - - let savedHostId: number; - if (existing) { - const saved = await hostRepository.updateEncryptedForUser( - userId, - existing.id, - sshDataObj, - ); - if (!saved) throw new Error("Host no longer exists"); - if (exportId !== undefined) importedIds.set(exportId, existing.id); - savedHostId = existing.id; - results.updated++; - } else { - sshDataObj.createdAt = new Date().toISOString(); - const saved = await hostRepository.createEncryptedForUser( - userId, - sshDataObj, - ); - if (exportId !== undefined) importedIds.set(exportId, saved.id); - savedHostId = saved.id; - createdHostIds.push(saved.id); - results.success++; - } - - const protocolAuth = readProtocolAuthPayload( - hostData as Record, - ); - if (protocolAuth) { - await writeProtocolAuth( - userId, - savedHostId, - await keepUsableProtocolCredentials(protocolAuth, userId), - { isOwner: true }, - ); - } - - // Every enabled plugin that declares host-scope settings and - // registered a hostImportNormalizer validates and writes its own - // fields here, so this loop does not need to know which plugins - // exist. See host-plugin-settings.ts. - await applyPluginHostImportSettings( - savedHostId, - hostData as Record, - ); - writtenHostIds.push(savedHostId); - } catch (error) { - results.failed++; - results.errors.push(`Host ${i + 1}: ${getErrorMessage(error)}`); - } - } - - await applyDefaultsAfterHostWrites(writtenHostIds); - notifyImportedHosts(userId, writtenHostIds, createdHostIds); - - res.json({ - message: `Import completed: ${results.success} created, ${results.updated} updated, ${results.skipped} skipped, ${results.failed} failed`, - success: results.success, - updated: results.updated, - skipped: results.skipped, - failed: results.failed, - errors: results.errors, - }); - }), - ); - - /** - * @openapi - * /host/ssh-config-import: - * post: - * summary: Import hosts from an OpenSSH config file - * description: Parses an OpenSSH ~/.ssh/config file and imports the defined hosts. - * tags: - * - SSH - * requestBody: - * required: true - * content: - * application/json: - * schema: - * type: object - * required: - * - content - * properties: - * content: - * type: string - * description: Raw text content of the SSH config file. - * overwrite: - * type: boolean - * responses: - * 200: - * description: Import completed. - * 400: - * description: Invalid request body. - */ - router.post( - "/ssh-config-import", - authenticateJWT, - requireCreatePermission, - requireEditPermission, - requireDataAccess, - DatabaseSaveTrigger.batched(async (req: Request, res: Response) => { - const userId = (req as AuthenticatedRequest).userId; - const { content, overwrite } = req.body; - - if (!isNonEmptyString(content)) { - return res.status(400).json({ - error: "content is required and must be a non-empty string", - }); - } - - let parsed: SSHConfigHost[]; - try { - parsed = parseSSHConfig(content); - } catch { - return res - .status(400) - .json({ error: "Failed to parse SSH config file" }); - } - - if (parsed.length === 0) { - return res.status(400).json({ - error: "No valid Host entries found in the SSH config file", - }); - } - - if (parsed.length > 100) { - return res - .status(400) - .json({ error: "Maximum 100 hosts allowed per import" }); - } - - const hostsToImport = parsed.map((h) => ({ - name: h.name, - ip: h.hostname, - port: h.port ?? 22, - username: h.user, - authType: h.identityFile ? "key" : undefined, - connectionType: "ssh", - enableSsh: true, - ...(h.proxyJump - ? { - jumpHosts: [{ host: h.proxyJump, port: 22 }], - } - : {}), - })); - - const results = { - success: 0, - updated: 0, - skipped: 0, - failed: 0, - errors: [] as string[], - }; - - let existingHostMap: Map | undefined; - const hostRepository = createCurrentHostRepository(); - try { - const allHosts = - await createCurrentHostResolutionRepository().findHostsByUserId( - userId, - ); - existingHostMap = new Map(); - for (const h of allHosts) { - const key = `${h.ip}:${h.port}:${h.username}`; - existingHostMap.set(key, { id: h.id as number }); - } - } catch { - existingHostMap = undefined; - } - - const writtenHostIds: number[] = []; - const createdHostIds: number[] = []; - for (let i = 0; i < hostsToImport.length; i++) { - const hostData = normalizeImportedHost( - hostsToImport[i] as Record, - ); - - try { - if (!isNonEmptyString(hostData.ip) || !isValidPort(hostData.port)) { - results.failed++; - results.errors.push( - `Host "${parsed[i].name}": Missing required fields (HostName, Port)`, - ); - continue; - } - - const lookupKey = `${hostData.ip}:${hostData.port}:${hostData.username}`; - const existing = existingHostMap?.get(lookupKey); - if (existing && !overwrite) { - results.skipped++; - continue; - } - - const sshDataObj: Record = { - userId, - connectionType: "ssh", - name: hostData.name || hostData.ip, - folder: "Default", - tags: "", - ip: hostData.ip, - port: hostData.port, - username: hostData.username || null, - authType: hostData.authType || "none", - password: null, - key: null, - keyPassword: null, - keyType: null, - credentialId: null, - pin: false, - sudoPassword: null, - jumpHosts: hostData.jumpHosts - ? JSON.stringify(hostData.jumpHosts) - : null, - statusCheckEnabled: true, - statusCheckInterval: null, - sshOptions: null, - forceKeyboardInteractive: "false", - notes: null, - useSocks5: 0, - socks5Host: null, - socks5Port: null, - socks5Username: null, - socks5Password: null, - socks5ProxyChain: null, - portKnockSequence: null, - overrideCredentialUsername: 0, - enableSsh: true, - updatedAt: new Date().toISOString(), - }; - - await withHostDefaults( - userId, - existing?.id ?? null, - sshDataObj, - hostData as unknown as Record, - ); - - if (existing) { - await hostRepository.updateEncryptedForUser( - userId, - existing.id, - sshDataObj, - ); - writtenHostIds.push(existing.id); - results.updated++; - } else { - sshDataObj.createdAt = new Date().toISOString(); - const saved = await hostRepository.createEncryptedForUser( - userId, - sshDataObj, - ); - writtenHostIds.push(saved.id); - createdHostIds.push(saved.id); - results.success++; - } - } catch (error) { - results.failed++; - results.errors.push( - `Host "${parsed[i].name}": ${getErrorMessage(error)}`, - ); - } - } - - await applyDefaultsAfterHostWrites(writtenHostIds); - notifyImportedHosts(userId, writtenHostIds, createdHostIds); - - res.json({ - message: `Import completed: ${results.success} created, ${results.updated} updated, ${results.skipped} skipped, ${results.failed} failed`, - success: results.success, - updated: results.updated, - skipped: results.skipped, - failed: results.failed, - errors: results.errors, - }); - }), - ); -} - -/** - * Status check fields from an import row. Exports from before 2.9.0 carried - * them inside statsConfig. - */ -function importedStatusCheck(raw: Record): { - statusCheckEnabled: boolean; - statusCheckInterval: number | null; -} { - const legacy = - raw.statsConfig && typeof raw.statsConfig === "object" - ? (raw.statsConfig as Record) - : {}; - const enabled = raw.statusCheckEnabled ?? legacy.statusCheckEnabled; - const useGlobal = legacy.useGlobalStatusInterval !== false; - const interval = - raw.statusCheckInterval ?? (useGlobal ? null : legacy.statusCheckInterval); - const seconds = Number(interval); - return { - statusCheckEnabled: enabled !== false && legacy.disableTcpPing !== true, - statusCheckInterval: - interval != null && Number.isInteger(seconds) && seconds >= 5 - ? seconds - : null, - }; -} - -const BUILTIN_SSH_AUTH_TYPES = [ - "password", - "key", - "credential", - "agent", - "none", -]; - -/** Core's own types plus every type a plugin registers or declares. */ -function listKnownAuthTypes(): Set { - return new Set([ - ...BUILTIN_SSH_AUTH_TYPES, - ...listSshAuthProviders().map((provider) => provider.type), - ...listSshAuthTypeOwners().map((owner) => owner.type), - ]); -} - -/** Fills an imported host's inherited keys and records what it sets itself. */ -async function withHostDefaults( - userId: string, - hostId: number | null, - columns: Record, - body: Record, -): Promise { - const stored = - hostId === null - ? null - : (( - await createCurrentHostDefaultsRepository().listHosts({ - hostIds: [hostId], - }) - )[0] ?? null); - columns.defaultOverrides = JSON.stringify( - await applyHostDefaultsToWrite({ - ownerId: userId, - hostId, - columns, - body, - stored, - }), - ); } /** A resetDefaults body as an override change, or null when there is none. */ diff --git a/src/backend/database/routes/host-defaults-routes.ts b/src/backend/database/routes/host-defaults-routes.ts index 16d8358a9..fd8c57664 100644 --- a/src/backend/database/routes/host-defaults-routes.ts +++ b/src/backend/database/routes/host-defaults-routes.ts @@ -32,6 +32,7 @@ import { } from "../../hosts/defaults/service.js"; import { changeHostOverrides } from "../../hosts/defaults/overrides.js"; import { readDefaultsReset } from "./host-bulk-routes.js"; +import { assertHostsUnlocked, sendHostLocked } from "../../hosts/locks.js"; type HostDefaultsRoutesDeps = { authenticateJWT: RequestHandler; @@ -678,6 +679,7 @@ export function registerHostDefaultsRoutes( const reset = readDefaultsReset(req.body); if (!reset) return res.status(400).json({ error: "Nothing to reset" }); try { + assertHostsUnlocked([hostId]); const access = await PermissionManager.getInstance().canAccessHost( userId, hostId, @@ -700,6 +702,7 @@ export function registerHostDefaultsRoutes( await changeHostOverrides([hostId], reset); res.json({ message: "Host reset to its defaults" }); } catch (error) { + if (sendHostLocked(res, error)) return; databaseLogger.error("Failed to reset host defaults", error); res.status(500).json({ error: "Failed to reset host defaults" }); } diff --git a/src/backend/database/routes/host-folder-routes.ts b/src/backend/database/routes/host-folder-routes.ts index 4ef829549..7da515c81 100644 --- a/src/backend/database/routes/host-folder-routes.ts +++ b/src/backend/database/routes/host-folder-routes.ts @@ -2,11 +2,11 @@ import type { Request, RequestHandler, Response, Router } from "express"; import type { AuthenticatedRequest } from "../../../types/index.js"; import { databaseLogger, sshLogger } from "../../utils/logger.js"; import { pluginEvents, TOPICS } from "../../plugins/events.js"; +import { recentActivity } from "../../plugins/activity-provider.js"; import { createCurrentCredentialRepository, createCurrentHostFolderRepository, createCurrentFolderAccessRepository, - createCurrentRecentActivityRepository, createCurrentRbacAccessRepository, createCurrentSshCredentialUsageRepository, } from "../repositories/factory.js"; @@ -16,6 +16,31 @@ import { folderCredentialIds, setFolderCredentialDefault, } from "../../hosts/defaults/service.js"; +import { + assertHostsUnlocked, + hasHostLocks, + sendHostLocked, +} from "../../hosts/locks.js"; + +/** Refuses a folder change that would touch a locked host. */ +async function folderHostsUnlocked( + userId: string, + folderName: string, + res: Response, +): Promise { + if (!hasHostLocks()) return true; + const inFolder = await createCurrentHostFolderRepository().listHostsInFolder( + userId, + folderName, + ); + try { + assertHostsUnlocked(inFolder.map((host) => host.id)); + return true; + } catch (error) { + if (sendHostLocked(res, error)) return false; + throw error; + } +} type HostFolderRoutesDeps = { authenticateJWT: RequestHandler; @@ -86,6 +111,7 @@ export function registerHostFolderRoutes( } try { + if (!(await folderHostsUnlocked(userId, oldName, res))) return; const { updatedHosts, updatedCredentials } = await createCurrentHostFolderRepository().renameFolder( userId, @@ -417,6 +443,7 @@ export function registerHostFolderRoutes( }); try { + if (!(await folderHostsUnlocked(userId, folderName, res))) return; const hostFolderRepository = createCurrentHostFolderRepository(); const hostsToDelete = await hostFolderRepository.listHostsInFolder( userId, @@ -432,9 +459,7 @@ export function registerHostFolderRoutes( hostIds, ); - await createCurrentRecentActivityRepository().deleteByHostIds( - hostIds, - ); + await recentActivity.deleteByHostIds(hostIds); await createCurrentRbacAccessRepository().deleteHostAccessForHosts( hostIds, diff --git a/src/backend/database/routes/host-normalizers.ts b/src/backend/database/routes/host-normalizers.ts index 54beeafbe..005ac2aaf 100644 --- a/src/backend/database/routes/host-normalizers.ts +++ b/src/backend/database/routes/host-normalizers.ts @@ -5,6 +5,7 @@ import { } from "../../hosts/ssh-options.js"; import { listHostProtocols } from "../../hosts/protocol-auth/registry.js"; import { sanitizeProtocolAuthForRecipient } from "../../hosts/protocol-auth/summary.js"; +import { hostLock } from "../../hosts/locks.js"; export function isNonEmptyString(value: unknown): value is string { return typeof value === "string" && value.trim().length > 0; @@ -337,6 +338,7 @@ const CONNECT_LEVEL_FIELDS = new Set([ "sshPort", "protocolAuth", "jumpHosts", + "lock", "createdAt", "updatedAt", ]); @@ -439,8 +441,10 @@ export function transformHostResponse( host: Record, ): Record { const shared = parseSharedSource(host.sharedSource); + const lock = typeof host.id === "number" ? hostLock(host.id) : null; return { ...host, + ...(lock ? { lock } : {}), sharedSource: undefined, localOnly: !!host.localOnly, sharedCopy: !!shared, diff --git a/src/backend/database/routes/host-status-routes.ts b/src/backend/database/routes/host-status-routes.ts index 10bdb139a..46fdc5697 100644 --- a/src/backend/database/routes/host-status-routes.ts +++ b/src/backend/database/routes/host-status-routes.ts @@ -3,13 +3,10 @@ import type { Request, RequestHandler, Response, Router } from "express"; import { PermissionManager } from "../../utils/permission-manager.js"; import { DataCrypto } from "../../utils/data-crypto.js"; import { sshLogger } from "../../utils/logger.js"; -import { createCurrentSettingsRepository } from "../repositories/factory.js"; import { - DEFAULT_STATUS_INTERVAL, - GLOBAL_STATUS_INTERVAL_KEY, - hostStatusService, + hostStatus, type HostStatusEntry, -} from "../../hosts/status/host-status-service.js"; +} from "../../plugins/host-status-provider.js"; interface HostStatusRoutesDeps { authenticateJWT: RequestHandler; @@ -71,7 +68,7 @@ export function registerHostStatusRoutes( return sessionExpired(res); const requested = parseStatusHostIds(req.query.hostIds); - const statuses = await hostStatusService.statusesFor(userId, requested); + const statuses = await hostStatus.statusesFor(userId, requested); // One batched permission check: this is polled every few seconds, and a // per-host check made it linear in host count against the database. @@ -110,15 +107,7 @@ export function registerHostStatusRoutes( authenticateJWT, requireAdmin, async (_req: Request, res: Response) => { - const value = Number( - await createCurrentSettingsRepository().get(GLOBAL_STATUS_INTERVAL_KEY), - ); - res.json({ - statusCheckInterval: - Number.isInteger(value) && value >= 5 - ? value - : DEFAULT_STATUS_INTERVAL, - }); + res.json({ statusCheckInterval: await hostStatus.getIntervalSeconds() }); }, ); @@ -161,11 +150,7 @@ export function registerHostStatusRoutes( .json({ error: "Status check interval must be 5 to 3600 seconds" }); } try { - await createCurrentSettingsRepository().set( - GLOBAL_STATUS_INTERVAL_KEY, - String(seconds), - ); - hostStatusService.retimeAll(); + await hostStatus.setIntervalSeconds(seconds); res.json({ statusCheckInterval: seconds }); } catch (error) { sshLogger.error("Failed to save status check settings", error, { @@ -198,7 +183,7 @@ export function registerHostStatusRoutes( if (DataCrypto.getUserDataKey(userId) === null) { return sessionExpired(res); } - await hostStatusService.refresh(userId); + await hostStatus.refresh(userId); res.json({ message: "Status checks restarted" }); }, ); @@ -247,9 +232,8 @@ export function registerHostStatusRoutes( return res.status(404).json({ error: "Status not available" }); } - await hostStatusService.statusesFor(userId, null); - const entry = - hostStatusService.get(id) ?? (await hostStatusService.check(id)); + await hostStatus.statusesFor(userId, null); + const entry = hostStatus.get(id) ?? (await hostStatus.check(id)); if (!entry) { return res.status(404).json({ error: "Status not available" }); } diff --git a/src/backend/database/routes/host.ts b/src/backend/database/routes/host.ts index 4f6001b82..ca768aa6c 100644 --- a/src/backend/database/routes/host.ts +++ b/src/backend/database/routes/host.ts @@ -7,25 +7,21 @@ import type { AuthenticatedRequest } from "../../../types/index.js"; import express, { type Request, type Response } from "express"; import multer from "multer"; import { sshLogger, databaseLogger } from "../../utils/logger.js"; +import { resolveHostCredentials } from "../../hosts/resolve-host-credentials.js"; import { pluginEvents, TOPICS } from "../../plugins/events.js"; import { AuthManager } from "../../utils/auth-manager.js"; import { PermissionManager } from "../../utils/permission-manager.js"; import { DataCrypto } from "../../utils/data-crypto.js"; import { parseKeyForStorage } from "../../hosts/stored-ssh-key.js"; -import { - pickResolvedPassword, - pickResolvedUsername, -} from "../../hosts/credential-username.js"; +import { pickResolvedPassword } from "../../hosts/credential-username.js"; import { emitInternalEvent } from "../../hosts/internal-events.js"; import { deleteOwnedHost } from "../../hosts/delete-host.js"; import { - createCurrentCredentialRepository, createCurrentRbacAccessRepository, createCurrentRoleRepository, createCurrentHostResolutionRepository, createCurrentHostRepository, createCurrentUserRepository, - createCurrentSharedHostAuthOverrideRepository, createCurrentHostDefaultsRepository, } from "../repositories/factory.js"; import { @@ -41,7 +37,6 @@ import { OWNER_PRIVATE_AUTH_FIELDS, OWNER_PRIVATE_SSH_OPTION_FIELDS, OWNER_PRIVATE_TERMINAL_CONFIG_FIELDS, - hostTerminalExport, sanitizeHostForRecipient, stripSensitiveFields, transformHostResponse, @@ -67,14 +62,8 @@ import { getAuditUsername, getRequestMeta, } from "../../utils/audit-logger.js"; -import type { - HostResolutionCredentialRecord, - HostResolutionHostRecord, -} from "../repositories/host-resolution-repository.js"; -import { - requiresPersonalHostAuthentication, - resolveRecipientSharedHostAuthentication, -} from "../../utils/shared-host-auth-resolver.js"; +import type { HostResolutionCredentialRecord } from "../repositories/host-resolution-repository.js"; + import { rejectSharedCopyWrites } from "../../sync/shared-copy-guard.js"; import { sshOptionsForWrite } from "../../hosts/ssh-options.js"; import { @@ -91,15 +80,16 @@ import { planProtocolAuthWrite, ProtocolAuthWriteError, readProtocolAuthPayload, - toPortableLogins, withProtocolAuth, writeProtocolAuth, type PlannedProtocolAuth, } from "../../hosts/protocol-auth/protocol-auth.js"; +import { findHostProtocol } from "../../hosts/protocol-auth/registry.js"; import { - findHostProtocol, - listHostProtocols, -} from "../../hosts/protocol-auth/registry.js"; + manualCreateBlockedBy, + requireManualCreate, + requireUnlockedHost, +} from "../../hosts/locks.js"; import { mergeStoredSshOptions, parseTerminalConfig, @@ -172,6 +162,7 @@ router.post( authenticateJWT, permissionManager.requirePermission("hosts.create"), requireDataAccess, + requireManualCreate, requireHostEnrollmentAccessForPath, upload.single("key"), async (req: Request, res: Response) => { @@ -751,6 +742,7 @@ router.put( authenticateJWT, permissionManager.requirePermission("hosts.edit"), requireDataAccess, + requireUnlockedHost, upload.single("key"), async (req: Request, res: Response) => { const hostId = Array.isArray(req.params.id) @@ -1220,6 +1212,27 @@ router.put( }, ); +/** + * @openapi + * /host/policy: + * get: + * summary: Host creation policy + * description: Whether hosts can be added by hand, or a plugin manages them all. + * tags: + * - SSH + * responses: + * 200: + * description: "{ manualCreate, blockedBy? } where blockedBy is the managing plugin's name." + */ +router.get("/policy", authenticateJWT, (_req: Request, res: Response) => { + const blocked = manualCreateBlockedBy(); + res.json( + blocked + ? { manualCreate: false, blockedBy: blocked.pluginName } + : { manualCreate: true }, + ); +}); + /** * @openapi * /host/db/host: @@ -1688,340 +1701,6 @@ router.get( }, ); -/** - * @openapi - * /host/db/host/{id}/export: - * get: - * summary: Export SSH host - * description: Exports a specific SSH host with decrypted credentials. - * tags: - * - SSH - * parameters: - * - in: path - * name: id - * required: true - * schema: - * type: integer - * responses: - * 200: - * description: The exported SSH host. - * 400: - * description: Invalid userId or hostId. - * 404: - * description: SSH host not found. - * 500: - * description: Failed to export SSH host. - */ -router.get( - "/db/host/:id/export", - authenticateJWT, - requireDataAccess, - async (req: Request, res: Response) => { - const hostId = Array.isArray(req.params.id) - ? req.params.id[0] - : req.params.id; - const userId = (req as AuthenticatedRequest).userId; - - if (!isNonEmptyString(userId) || !hostId) { - return res.status(400).json({ error: "Invalid userId or hostId" }); - } - - try { - const host = - await createCurrentHostResolutionRepository().findHostByIdForUser( - Number(hostId), - userId, - ); - - if (!host) { - return res.status(404).json({ error: "SSH host not found" }); - } - - const resolvedHost = (await resolveHostCredentials(host, userId)) || host; - const hostPluginSettings = ( - await loadHostPluginSettings([Number(hostId)]) - ).get(Number(hostId)); - - const exportedConnectionType = - (resolvedHost.connectionType as string) || "ssh"; - const isRemoteDesktop = exportedConnectionType !== "ssh"; - - const baseExportData = { - exportId: resolvedHost.id, - connectionType: exportedConnectionType, - name: resolvedHost.name, - ip: resolvedHost.ip, - port: resolvedHost.port, - username: resolvedHost.username, - password: resolvedHost.password || null, - folder: resolvedHost.folder, - tags: - typeof resolvedHost.tags === "string" - ? resolvedHost.tags.split(",").filter(Boolean) - : resolvedHost.tags || [], - pin: !!resolvedHost.pin, - notes: resolvedHost.notes || null, - // Every plugin's host settings, secrets redacted, for import to hand back. - pluginSettings: hostPluginSettings ?? {}, - // Each plugin protocol's login, secrets included like the SSH ones. - protocolAuth: toPortableLogins( - await listProtocolLogins(Number(hostId), userId), - ), - }; - - const exportData = isRemoteDesktop - ? baseExportData - : { - ...baseExportData, - authType: resolvedHost.authType, - key: resolvedHost.key || null, - keyPassword: resolvedHost.keyPassword || null, - keyType: resolvedHost.keyType || null, - credentialId: resolvedHost.credentialId || null, - overrideCredentialUsername: - !!resolvedHost.overrideCredentialUsername, - sudoPassword: resolvedHost.sudoPassword || null, - jumpHosts: resolvedHost.jumpHosts - ? JSON.parse(resolvedHost.jumpHosts as string) - : null, - terminalConfig: - hostTerminalExport(resolvedHost).terminalConfig ?? null, - sshOptions: hostTerminalExport(resolvedHost).sshOptions, - forceKeyboardInteractive: - resolvedHost.forceKeyboardInteractive === "true", - useSocks5: !!resolvedHost.useSocks5, - socks5Host: resolvedHost.socks5Host || null, - socks5Port: resolvedHost.socks5Port || null, - socks5Username: resolvedHost.socks5Username || null, - socks5Password: resolvedHost.socks5Password || null, - socks5ProxyChain: resolvedHost.socks5ProxyChain - ? JSON.parse(resolvedHost.socks5ProxyChain as string) - : null, - portKnockSequence: resolvedHost.portKnockSequence - ? JSON.parse(resolvedHost.portKnockSequence as string) - : null, - }; - - sshLogger.success("Host exported with decrypted credentials", { - operation: "host_export", - hostId: parseInt(hostId), - userId, - }); - - res.json(exportData); - } catch (err) { - sshLogger.error("Failed to export SSH host", err, { - operation: "host_export", - hostId: parseInt(hostId), - userId, - }); - res.status(500).json({ error: "Failed to export SSH host" }); - } - }, -); - -/** - * @openapi - * /host/db/hosts/export: - * get: - * summary: Export all SSH hosts - * description: Exports all SSH hosts for the current user. By default credentials are decrypted and embedded. With `share=1`, secrets are omitted and credential-authenticated hosts instead reference a scrubbed `credentials` array by alias, suitable for handing off to another user. - * tags: - * - SSH - * parameters: - * - in: query - * name: share - * required: false - * schema: - * type: string - * description: Set to "1" to export without embedded secrets. - * responses: - * 200: - * description: All exported SSH hosts. - * 400: - * description: Invalid userId. - * 500: - * description: Failed to export SSH hosts. - */ -router.get( - "/db/hosts/export", - authenticateJWT, - requireDataAccess, - async (req: Request, res: Response) => { - const userId = (req as AuthenticatedRequest).userId; - const shareMode = req.query.share === "1" || req.query.share === "true"; - - if (!isNonEmptyString(userId)) { - return res.status(400).json({ error: "Invalid userId" }); - } - - try { - const allHosts = - await createCurrentHostResolutionRepository().findHostsByUserId(userId); - const pluginSettingsByHost = await loadHostPluginSettings( - allHosts.map((h) => h.id as number), - ); - - const exportedHosts = []; - const usedCredentialIds = new Set(); - - for (const host of allHosts) { - const resolvedHost = shareMode - ? host - : (await resolveHostCredentials(host, userId)) || host; - const hostPluginSettings = pluginSettingsByHost.get(host.id as number); - - const exportedConnectionType = - (resolvedHost.connectionType as string) || "ssh"; - const isRemoteDesktop = exportedConnectionType !== "ssh"; - - const baseExportData = { - exportId: resolvedHost.id, - connectionType: exportedConnectionType, - name: resolvedHost.name, - ip: resolvedHost.ip, - port: resolvedHost.port, - username: resolvedHost.username, - password: shareMode ? null : resolvedHost.password || null, - folder: resolvedHost.folder, - tags: - typeof resolvedHost.tags === "string" - ? resolvedHost.tags.split(",").filter(Boolean) - : resolvedHost.tags || [], - pin: !!resolvedHost.pin, - notes: resolvedHost.notes || null, - // Every plugin's host settings, secrets redacted, for import to hand back. - pluginSettings: hostPluginSettings ?? {}, - protocolAuth: shareableLogins( - toPortableLogins( - await listProtocolLogins(host.id as number, userId), - ), - shareMode, - ), - }; - - const exportData = isRemoteDesktop - ? baseExportData - : { - ...baseExportData, - authType: resolvedHost.authType, - key: shareMode ? null : resolvedHost.key || null, - keyPassword: shareMode ? null : resolvedHost.keyPassword || null, - keyType: resolvedHost.keyType || null, - credentialId: resolvedHost.credentialId || null, - overrideCredentialUsername: - !!resolvedHost.overrideCredentialUsername, - sudoPassword: shareMode - ? null - : resolvedHost.sudoPassword || null, - jumpHosts: resolvedHost.jumpHosts - ? JSON.parse(resolvedHost.jumpHosts as string) - : null, - terminalConfig: - hostTerminalExport(resolvedHost).terminalConfig ?? null, - sshOptions: hostTerminalExport(resolvedHost).sshOptions, - forceKeyboardInteractive: - resolvedHost.forceKeyboardInteractive === "true", - useSocks5: !!resolvedHost.useSocks5, - socks5Host: resolvedHost.socks5Host || null, - socks5Port: resolvedHost.socks5Port || null, - socks5Username: resolvedHost.socks5Username || null, - socks5Password: shareMode - ? null - : resolvedHost.socks5Password || null, - socks5ProxyChain: resolvedHost.socks5ProxyChain - ? JSON.parse(resolvedHost.socks5ProxyChain as string) - : null, - }; - - if ( - shareMode && - !isRemoteDesktop && - resolvedHost.authType === "credential" && - resolvedHost.credentialId - ) { - usedCredentialIds.add(resolvedHost.credentialId as number); - } - - exportedHosts.push(exportData); - } - - if (!shareMode) { - sshLogger.success("All hosts exported with decrypted credentials", { - operation: "hosts_export_all", - count: exportedHosts.length, - userId, - }); - - return res.json({ hosts: exportedHosts }); - } - - const exportedCredentials: Record[] = []; - if (usedCredentialIds.size > 0) { - const credentialRepository = createCurrentCredentialRepository(); - const ownedCredentials = - await credentialRepository.listDecryptedByUserId(userId); - const credentialById = new Map( - ownedCredentials.map((credential) => [credential.id, credential]), - ); - - for (const host of exportedHosts as Record[]) { - const credentialId = host.credentialId as number | null; - if (!credentialId) continue; - const credential = credentialById.get(credentialId); - if (!credential) continue; - - host.credentialAlias = credential.name; - - if ( - !exportedCredentials.some( - (entry) => entry.alias === credential.name, - ) - ) { - exportedCredentials.push({ - alias: credential.name, - name: credential.name, - description: credential.description || null, - folder: credential.folder || null, - tags: - typeof credential.tags === "string" - ? credential.tags.split(",").filter(Boolean) - : [], - authType: credential.authType, - username: credential.username || null, - keyType: credential.keyType || null, - }); - } - } - } - - for (const host of exportedHosts as Record[]) { - delete host.credentialId; - } - - sshLogger.success("All hosts exported for sharing without secrets", { - operation: "hosts_export_all_share", - count: exportedHosts.length, - credentialCount: exportedCredentials.length, - userId, - }); - - res.json({ - version: "1", - exportedAt: new Date().toISOString(), - credentials: exportedCredentials, - hosts: exportedHosts, - }); - } catch (err) { - sshLogger.error("Failed to export all SSH hosts", err, { - operation: "hosts_export_all", - userId, - }); - res.status(500).json({ error: "Failed to export SSH hosts" }); - } - }, -); - /** * @openapi * /host/db/host/{id}: @@ -2051,6 +1730,7 @@ router.delete( authenticateJWT, permissionManager.requirePermission("hosts.delete"), requireDataAccess, + requireUnlockedHost, async (req: Request, res: Response) => { const userId = (req as AuthenticatedRequest).userId; const hostId = Array.isArray(req.params.id) @@ -2117,231 +1797,6 @@ router.delete( // the file-manager plugin, under /plugin-api/file-manager/, and command // history to the ssh-terminal plugin, under /plugin-api/ssh-terminal/. -/** - * A share export leaves out every secret and credential link, the same as - * it does for SSH. - */ -function shareableLogins( - logins: ReturnType, - shareMode: boolean, -): ReturnType { - if (!shareMode) return logins; - const out: ReturnType = {}; - for (const [protocol, login] of Object.entries(logins)) { - const declared = findHostProtocol(protocol); - const secret = new Set( - (declared?.credentialFields ?? []) - .filter((field) => field.secret) - .map((field) => field.key), - ); - out[protocol] = { - authType: login.authType === "credential" ? "direct" : login.authType, - credentialId: null, - username: login.username, - password: null, - fields: Object.fromEntries( - Object.entries(login.fields).filter( - ([key]) => declared && !secret.has(key), - ), - ), - }; - } - return out; -} - -async function resolveHostCredentials( - host: Record, - requestingUserId?: string, - /** - * Credentials already fetched for this request, keyed by id. The host list - * preloads them in one query; single-host callers omit it and fall back to - * fetching the one credential they need. - */ - preloadedCredentials?: Map, -): Promise> { - try { - const ownerId = (host.ownerId || host.userId) as string | undefined; - if ( - requestingUserId && - ownerId && - requestingUserId !== ownerId && - typeof host.id === "number" - ) { - const authHost = host as unknown as HostResolutionHostRecord; - const needsPersonalCredential = requiresPersonalHostAuthentication( - authHost, - "ssh", - ); - const baseSshOverrideState = { - required: needsPersonalCredential, - ownerAuthShared: !!host.shareSshAuth, - }; - // Owner auth for the remote desktop protocols is always snapshotted - // for recipients; only their own override credential varies per user. - const authOverrides: Record = { - ssh: baseSshOverrideState, - }; - const overrideCredentialIds = - await createCurrentSharedHostAuthOverrideRepository().listCredentialIds( - host.id, - requestingUserId, - ); - // Whether a plugin protocol is on is its plugin's host setting; the - // client only offers an override for one that is. - for (const { id: protocol } of listHostProtocols()) { - authOverrides[protocol] = { - credentialId: overrideCredentialIds[protocol], - required: false, - ownerAuthShared: true, - }; - } - const recipientHost: Record = { - ...host, - credentialId: null, - password: null, - key: null, - keyPassword: null, - keyType: null, - authOverrides, - }; - - try { - const resolution = await resolveRecipientSharedHostAuthentication( - authHost, - host.id, - requestingUserId, - "ssh", - ); - - if (resolution.source === "personal-override") { - const credential = resolution.credential; - return { - ...recipientHost, - authOverrides: { - ...authOverrides, - ssh: { - credentialId: resolution.credentialId, - required: false, - ownerAuthShared: !!host.shareSshAuth, - }, - }, - authType: - credential.key || credential.privateKey - ? "key" - : credential.password - ? "password" - : "none", - username: credential.username || recipientHost.username, - password: credential.password, - key: credential.privateKey || credential.key, - keyPassword: credential.keyPassword, - keyType: credential.keyType, - }; - } - - if (resolution.source === "owner-shared") { - if (resolution.authType === "agent") { - return { - ...recipientHost, - authOverrides: { - ...authOverrides, - ssh: { - required: false, - ownerAuthShared: true, - }, - }, - authType: "agent", - }; - } - - const sharedAuth = resolution.secret; - if (sharedAuth) { - const resolvedUsername = pickResolvedUsername( - recipientHost.username, - sharedAuth.username, - host.overrideCredentialUsername, - ); - return { - ...recipientHost, - authOverrides: { - ...authOverrides, - ssh: { - required: false, - ownerAuthShared: true, - }, - }, - authType: sharedAuth.key - ? "key" - : sharedAuth.password - ? "password" - : "none", - username: resolvedUsername, - password: sharedAuth.password, - key: sharedAuth.key, - keyPassword: sharedAuth.keyPassword, - keyType: sharedAuth.keyType, - }; - } - } - - if (resolution.source === "secretless") { - return { - ...recipientHost, - authOverrides: { - ...authOverrides, - ssh: { - required: false, - ownerAuthShared: !!host.shareSshAuth, - }, - }, - }; - } - } catch { - // A missing/deleted override or snapshot behaves like unavailable auth. - } - - return recipientHost; - } - - if (host.credentialId && (host.userId || host.ownerId)) { - const credentialId = host.credentialId as number; - const credentialOwnerId = (host.ownerId || host.userId) as string; - - const credential = - preloadedCredentials?.get(credentialId) ?? - (await findUsableCredential(credentialId, credentialOwnerId)); - - if (credential) { - const resolvedHost: Record = { - ...host, - password: pickResolvedPassword(host.password, credential.password), - key: credential.key, - keyPassword: credential.keyPassword, - keyType: credential.keyType, - }; - - const resolvedUsername = pickResolvedUsername( - host.username, - credential.username, - host.overrideCredentialUsername, - ); - if (resolvedUsername !== undefined) { - resolvedHost.username = resolvedUsername; - } - - return resolvedHost; - } - } - - return { ...host }; - } catch (error) { - sshLogger.warn( - `Failed to resolve credentials for host ${host.id}: ${getErrorMessage(error)}`, - ); - return host; - } -} - registerHostFolderRoutes(router, { authenticateJWT, requireViewPermission: permissionManager.requirePermission("hosts.view"), @@ -2355,7 +1810,6 @@ registerHostFolderRoutes(router, { registerHostBulkRoutes( router, authenticateJWT, - permissionManager.requirePermission("hosts.create"), permissionManager.requirePermission("hosts.edit"), requireDataAccess, ); diff --git a/src/backend/database/routes/look.ts b/src/backend/database/routes/look.ts new file mode 100644 index 000000000..c008dc9ed --- /dev/null +++ b/src/backend/database/routes/look.ts @@ -0,0 +1,210 @@ +import type { AuthenticatedRequest } from "../../../types/index.js"; +import express, { type Request, type Response } from "express"; +import { databaseLogger } from "../../utils/logger.js"; +import { AuthManager } from "../../utils/auth-manager.js"; +import { logAudit, getRequestMeta } from "../../utils/audit-logger.js"; +import { + createCurrentSettingsRepository, + createCurrentUserPreferenceRepository, + createCurrentUserRepository, +} from "../repositories/factory.js"; +import { getPluginRuntime } from "../../plugins/index.js"; +import { + LAYOUT_SETTING, + LOOK_ID_PATTERN, + THEME_SETTING, + installedLayoutIds, + resolveDefaultLook, + safeLayoutForced, + unavailableLooks, +} from "../../plugins/looks.js"; + +const router = express.Router(); +const authManager = AuthManager.getInstance(); +const authenticateJWT = authManager.createAuthMiddleware(); + +async function adminDefaults() { + const settings = createCurrentSettingsRepository(); + return { + layout: await settings.get(LAYOUT_SETTING), + theme: await settings.get(THEME_SETTING), + }; +} + +/** + * @openapi + * /user-preferences/look: + * get: + * summary: Get the layout and theme to show the current user + * description: The user's own choice, the admin defaults (a layout no installed plugin brings reads as null), the default look of the bundled plugins, whether TERMIX_SAFE_LAYOUT forces the repair view, and installed layout or theme plugins that could not load. The browser resolves the final look from these. + * tags: + * - User Preferences + * responses: + * 200: + * description: What the look is chosen from. + * content: + * application/json: + * schema: + * type: object + * properties: + * layoutId: + * type: string + * nullable: true + * themeId: + * type: string + * nullable: true + * defaults: + * type: object + * description: Admin defaults, null where unset. + * properties: + * layout: + * type: string + * nullable: true + * theme: + * type: string + * nullable: true + * defaultLook: + * type: object + * description: The default-look plugin's layout and theme, null where none is installed. + * properties: + * layout: + * type: string + * nullable: true + * theme: + * type: string + * nullable: true + * safeLayout: + * type: boolean + * unavailable: + * type: array + * items: + * type: object + * properties: + * id: + * type: string + * version: + * type: string + * nullable: true + * layouts: + * type: array + * items: + * type: string + * themes: + * type: array + * items: + * type: string + * error: + * type: string + */ +router.get("/", authenticateJWT, async (req: Request, res: Response) => { + const userId = (req as AuthenticatedRequest).userId; + try { + const preferences = + await createCurrentUserPreferenceRepository().findByUserId(userId); + const { loader } = getPluginRuntime(); + const installed = installedLayoutIds(loader); + const known = (id: string | null | undefined) => + id && installed.has(id) ? id : null; + const defaults = await adminDefaults(); + res.json({ + layoutId: known(preferences?.layoutId), + themeId: preferences?.themeId ?? null, + defaults: { ...defaults, layout: known(defaults.layout) }, + defaultLook: resolveDefaultLook(loader), + safeLayout: safeLayoutForced(), + unavailable: unavailableLooks(loader), + }); + } catch (error) { + databaseLogger.error("Failed to get the look", error, { + operation: "get_look", + userId, + }); + res.status(500).json({ error: "Failed to get the look" }); + } +}); + +/** + * @openapi + * /user-preferences/look/defaults: + * put: + * summary: Set the default layout and theme (admin only) + * description: What everyone sees until they choose their own. Null clears a default so the default look applies. + * tags: + * - User Preferences + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * properties: + * layout: + * type: string + * nullable: true + * theme: + * type: string + * nullable: true + * responses: + * 200: + * description: The defaults now in effect. + * 400: + * description: Not a layout or theme id. + * 403: + * description: Not an admin. + */ +router.put( + "/defaults", + authenticateJWT, + async (req: Request, res: Response) => { + const userId = (req as AuthenticatedRequest).userId; + try { + const actor = await createCurrentUserRepository().findById(userId); + if (!actor?.isAdmin) { + return res.status(403).json({ error: "Not authorized" }); + } + const body = (req.body ?? {}) as { layout?: unknown; theme?: unknown }; + const changes: Array<[string, unknown]> = [ + [LAYOUT_SETTING, body.layout], + [THEME_SETTING, body.theme], + ]; + for (const [key, value] of changes) { + if ( + value !== undefined && + value !== null && + (typeof value !== "string" || !LOOK_ID_PATTERN.test(value)) + ) { + return res + .status(400) + .json({ error: `${key} must be a layout or theme id, or null` }); + } + } + const settings = createCurrentSettingsRepository(); + for (const [key, value] of changes) { + if (value === undefined) continue; + if (value === null) await settings.delete(key); + else await settings.set(key, value as string); + } + + const { ipAddress, userAgent } = getRequestMeta(req); + await logAudit({ + userId, + username: actor.username ?? userId, + action: "update_default_look", + resourceType: "setting", + details: JSON.stringify({ layout: body.layout, theme: body.theme }), + ipAddress, + userAgent, + success: true, + }); + res.json(await adminDefaults()); + } catch (error) { + databaseLogger.error("Failed to set the default look", error, { + operation: "set_default_look", + userId, + }); + res.status(500).json({ error: "Failed to set the default look" }); + } + }, +); + +export default router; diff --git a/src/backend/database/routes/plugins.ts b/src/backend/database/routes/plugins.ts index 73da18085..03702851f 100644 --- a/src/backend/database/routes/plugins.ts +++ b/src/backend/database/routes/plugins.ts @@ -13,7 +13,9 @@ import { createCurrentPluginRepository, } from "../repositories/factory.js"; import { getPluginRuntime } from "../../plugins/index.js"; +import { resolvePluginDesktopMode } from "@termix-ssh/plugin-sdk/manifest"; import { describePluginFrontend } from "../../plugins/assets.js"; +import { isDefaultLookPlugin } from "../../plugins/bundled-index.js"; import { invalidatePluginPermissionCache } from "../../plugins/permissions.js"; import { getPluginPublicHttpRoutes } from "../../plugins/http.js"; import { getPluginPublicWsRoutes } from "../../plugins/ws.js"; @@ -42,6 +44,7 @@ import { validateSettingsSave, type PluginSettingsScope, } from "../../plugins/settings.js"; +import { assertHostsUnlocked, sendHostLocked } from "../../hosts/locks.js"; import { getAuditUsername, getRequestMeta, @@ -296,7 +299,9 @@ router.get("/", authenticateJWT, async (req: Request, res: Response) => { let features: string[] = []; let docs: string | undefined; let env: PluginEnvVar[] = []; - let desktopMode: unknown = loaded?.manifest.desktop; + let desktopMode: unknown = loaded + ? resolvePluginDesktopMode(loaded.manifest) + : undefined; try { const manifest = JSON.parse(record.manifestJson) as { contributes?: unknown; @@ -312,8 +317,12 @@ router.get("/", authenticateJWT, async (req: Request, res: Response) => { dependencies?: Record; optionalDependencies?: Record; desktop?: unknown; + runtime?: unknown; }; - desktopMode = loaded?.manifest.desktop ?? manifest?.desktop; + desktopMode = loaded + ? resolvePluginDesktopMode(loaded.manifest) + : (manifest?.desktop ?? + (manifest?.runtime === "desktop" ? "local" : undefined)); contributes = manifest?.contributes ?? null; capabilities = Array.isArray(manifest?.capabilities) ? (manifest.capabilities as string[]) @@ -352,6 +361,7 @@ router.get("/", authenticateJWT, async (req: Request, res: Response) => { dependencies, optionalDependencies, docs, + defaultLook: isDefaultLookPlugin(record.id), ...describePluginFrontend(loaded), }; @@ -1869,6 +1879,18 @@ router.put( const inherit = Array.isArray(inheritRaw) ? inheritRaw.filter((key): key is string => typeof key === "string") : []; + // A managed host keeps its settings; only personal ones stay per user. + const changesShared = [...Object.keys(written), ...inherit].some( + (key) => !findField(manifest, "host", key)?.personal, + ); + if (changesShared) { + try { + assertHostsUnlocked([hostId]); + } catch (error) { + if (sendHostLocked(res, error)) return; + throw error; + } + } const errors = await applySettings( userId, manifest, @@ -2279,6 +2301,63 @@ router.post( }, ); +/** + * @openapi + * /plugins/{id}/restore-bundled: + * post: + * summary: Restore the bundled copy of a plugin + * description: > + * Drops any update downloaded over a plugin that ships with Termix and + * loads the copy from the image again, so a broken release can be rolled + * back from the layout repair view. The shipped version is pinned so + * auto update does not bring the release back. Dependents restart. + * tags: + * - Plugins + * parameters: + * - in: path + * name: id + * required: true + * schema: + * type: string + * responses: + * 200: + * description: The restored version and the plugin's state. + * 400: + * description: Invalid plugin id. + * 403: + * description: The caller lacks admin.plugins.manage. + * 409: + * description: The plugin has no bundled copy, or a linked server manages it. + */ +router.post( + "/:id/restore-bundled", + authenticateJWT, + requireManagePlugins, + async (req: Request, res: Response) => { + const pluginId = String(req.params.id); + if (!ID_PATTERN.test(pluginId)) { + res.status(400).json({ error: "Invalid plugin id" }); + return; + } + if (await refuseOnLinkedDesktop(res)) return; + try { + const { restoreBundledPlugin } = await import("../../plugins/manage.js"); + const result = await restoreBundledPlugin(pluginId); + await auditPluginAction(req, "restore_bundled_plugin", pluginId, { + version: result.version, + }); + res.json(result); + } catch (error) { + sendManageError( + res, + error, + "Failed to restore the bundled copy", + "plugin_restore_bundled", + ); + } + }, +); + /** * @openapi * /plugins/{id}: @@ -2306,7 +2385,7 @@ router.post( * 404: * description: No such plugin. * 409: - * description: Other running plugins depend on it, or managed by a linked server. + * description: Other running plugins depend on it, it is the default look, or a linked server manages it. */ router.delete( "/:id", diff --git a/src/backend/database/routes/rbac.ts b/src/backend/database/routes/rbac.ts index f5b6c11a4..2f88ccc04 100644 --- a/src/backend/database/routes/rbac.ts +++ b/src/backend/database/routes/rbac.ts @@ -9,6 +9,12 @@ import { logAudit, } from "../../utils/audit-logger.js"; import { isAuthOverrideProtocol } from "../../hosts/protocol-auth/registry.js"; +import { + assertHostsUnlocked, + hasHostLocks, + requireUnlockedHost, + sendHostLocked, +} from "../../hosts/locks.js"; import { SharedHostAuthOverrideService, SharedHostAuthOverrideServiceError, @@ -229,6 +235,7 @@ router.post( "/host/:id/share", authenticateJWT, permissionManager.requirePermission("hosts.share"), + requireUnlockedHost, async (req: AuthenticatedRequest, res: Response) => { const id = Array.isArray(req.params.id) ? req.params.id[0] : req.params.id; const hostId = parseInt(id, 10); @@ -451,6 +458,14 @@ router.post( } try { + if (hasHostLocks()) { + const inFolder = + await createCurrentHostFolderRepository().listHostsInFolder( + userId, + folder, + ); + assertHostsUnlocked(inFolder.map((host) => host.id)); + } const targets = parseShareTargets(req.body ?? {}); if (!targets) { return res.status(400).json({ @@ -613,6 +628,7 @@ router.post( hostResults, }); } catch (error) { + if (sendHostLocked(res, error)) return; databaseLogger.error("Failed to share folder", error, { operation: "share_folder", folder, @@ -786,6 +802,7 @@ router.delete( router.patch( "/host/:id/access/:accessId", authenticateJWT, + requireUnlockedHost, async (req: AuthenticatedRequest, res: Response) => { const id = Array.isArray(req.params.id) ? req.params.id[0] : req.params.id; const accessIdParam = Array.isArray(req.params.accessId) @@ -908,6 +925,7 @@ router.patch( router.delete( "/host/:id/access/:accessId", authenticateJWT, + requireUnlockedHost, async (req: AuthenticatedRequest, res: Response) => { const id = Array.isArray(req.params.id) ? req.params.id[0] : req.params.id; const accessIdParam = Array.isArray(req.params.accessId) diff --git a/src/backend/database/routes/user-password-reset-routes.ts b/src/backend/database/routes/user-password-reset-routes.ts index 20a60caaf..cd452af26 100644 --- a/src/backend/database/routes/user-password-reset-routes.ts +++ b/src/backend/database/routes/user-password-reset-routes.ts @@ -2,13 +2,13 @@ import type { Router } from "express"; import crypto from "crypto"; import bcrypt from "bcryptjs"; import { nanoid } from "nanoid"; +import { recentActivity } from "../../plugins/activity-provider.js"; import { AuthManager } from "../../utils/auth-manager.js"; import { authLogger } from "../../utils/logger.js"; import { loginRateLimiter } from "../../utils/login-rate-limiter.js"; import { createCurrentCredentialRepository, createCurrentHostRepository, - createCurrentRecentActivityRepository, createCurrentSettingsRepository, createCurrentSshCredentialUsageRepository, createCurrentUserRepository, @@ -68,7 +68,7 @@ export async function resetUserPassword( await userRepository.update(userId, { passwordHash }); await createCurrentSshCredentialUsageRepository().deleteByUserId(userId); - await createCurrentRecentActivityRepository().deleteByUserId(userId); + await recentActivity.deleteByUserId(userId); await createCurrentHostRepository().deleteByUserId(userId); await createCurrentCredentialRepository().deleteByUserId(userId); // A plugin holding this user's data (anything keyed by diff --git a/src/backend/database/routes/user-preferences.ts b/src/backend/database/routes/user-preferences.ts index 6cd16af89..86cd5b200 100644 --- a/src/backend/database/routes/user-preferences.ts +++ b/src/backend/database/routes/user-preferences.ts @@ -32,8 +32,13 @@ const pickPreferences = (row?: UserPreferenceRecord | null) => ({ compactHostView: row?.compactHostView ?? null, statusColorScheme: row?.statusColorScheme ?? null, customKeybindings: row?.customKeybindings ?? null, + layoutId: row?.layoutId ?? null, + themeId: row?.themeId ?? null, }); +/** Layout and theme ids, the same shape the plugin manifest allows. */ +const LOOK_ID = /^[a-z][a-z0-9-]{1,39}$/; + /** * @openapi * /user-preferences: @@ -104,6 +109,14 @@ const pickPreferences = (row?: UserPreferenceRecord | null) => ({ * type: string * nullable: true * description: JSON-encoded array of the user's custom keybindings. An action's type is one of the shell's own or one a plugin declares in contributes.keybindingActions. + * layoutId: + * type: string + * nullable: true + * description: The layout this user chose, overriding the admin default. Null follows the default. + * themeId: + * type: string + * nullable: true + * description: The theme this user chose, overriding the admin default. Null follows the default. */ router.get("/", authenticateJWT, async (req: Request, res: Response) => { const userId = (req as AuthenticatedRequest).userId; @@ -165,6 +178,14 @@ router.get("/", authenticateJWT, async (req: Request, res: Response) => { * customKeybindings: * type: string * description: JSON-encoded array of the user's custom keybindings. Each action is checked against the shell's own types and the parameters a plugin declares for its type. + * layoutId: + * type: string + * nullable: true + * description: A layout id, or null to follow the admin default. + * themeId: + * type: string + * nullable: true + * description: A theme id, or null to follow the admin default. * responses: * 200: * description: Preferences updated successfully. @@ -186,6 +207,8 @@ router.put("/", authenticateJWT, async (req: Request, res: Response) => { confirmTabClose, hiddenRailTabs, customKeybindings, + layoutId, + themeId, } = req.body as { reopenTabsOnLogin?: boolean; theme?: string | null; @@ -201,6 +224,8 @@ router.put("/", authenticateJWT, async (req: Request, res: Response) => { confirmTabClose?: boolean | null; hiddenRailTabs?: string | null; customKeybindings?: string | null; + layoutId?: string | null; + themeId?: string | null; }; // showHostTags, hostTrayOnClick, compactHostView, statusColorScheme are no // longer writable here -- they moved to /host-sidebar/preferences as of the @@ -258,6 +283,18 @@ router.put("/", authenticateJWT, async (req: Request, res: Response) => { } } + for (const [key, value] of Object.entries({ layoutId, themeId })) { + if ( + value !== undefined && + value !== null && + (typeof value !== "string" || !LOOK_ID.test(value)) + ) { + return res + .status(400) + .json({ error: `${key} must be a layout or theme id, or null` }); + } + } + const boolFields: Record = { commandPaletteEnabled, pinAppRail, @@ -290,6 +327,8 @@ router.put("/", authenticateJWT, async (req: Request, res: Response) => { if (confirmTabClose !== undefined) updates.confirmTabClose = confirmTabClose; if (customKeybindings !== undefined) updates.customKeybindings = customKeybindings; + if (layoutId !== undefined) updates.layoutId = layoutId; + if (themeId !== undefined) updates.themeId = themeId; if (Object.keys(updates).length === 1) { return res.status(400).json({ error: "No preferences provided" }); diff --git a/src/backend/database/routes/user-settings-routes.ts b/src/backend/database/routes/user-settings-routes.ts index b1b74d944..71da2e7c8 100644 --- a/src/backend/database/routes/user-settings-routes.ts +++ b/src/backend/database/routes/user-settings-routes.ts @@ -6,11 +6,6 @@ import { setGlobalLogLevel, } from "../../utils/logger.js"; import { logAudit, getRequestMeta } from "../../utils/audit-logger.js"; -import { - AUDIT_FORWARD_TOKEN_SETTING, - AUDIT_FORWARD_URL_ENV, - AUDIT_FORWARD_URL_SETTING, -} from "../../utils/audit-forwarder.js"; import { NOTIFICATION_PRIVATE_ALLOWLIST_KEY, parseNotificationAllowlist, @@ -20,6 +15,8 @@ import { createCurrentUserRepository, } from "../repositories/factory.js"; import type { UserRecord } from "../repositories/user-repository.js"; +import { isForceHttpsEnabled, setForceHttps } from "../../tls/https-policy.js"; +import { getTlsConfig } from "../../tls/tls-service.js"; async function getAdminActor( userId: string | undefined, @@ -194,46 +191,39 @@ export function registerUserSettingsRoutes( /** * @openapi - * /users/audit-forwarding: + * /users/https-settings: * get: - * summary: Get audit log forwarding settings (admin only) + * summary: Get the HTTPS redirect setting (admin only) + * description: Whether plain HTTP is redirected to HTTPS with HSTS, and whether HTTPS is on at all. The redirect only applies while HTTPS is on. * tags: * - Users * responses: * 200: - * description: Forwarding target. The token itself is never returned. + * description: forceHttps and tlsEnabled. + * 403: + * description: Not authorized. */ - router.get("/audit-forwarding", authenticateJWT, async (req, res) => { - const userId = (req as AuthenticatedRequest).userId; + router.get("/https-settings", authenticateJWT, async (req, res) => { try { - if (!(await getAdminActor(userId))) { + if (!(await getAdminActor((req as AuthenticatedRequest).userId))) { return res.status(403).json({ error: "Not authorized" }); } - const settingsRepository = createCurrentSettingsRepository(); - const url = - (await settingsRepository.get(AUDIT_FORWARD_URL_SETTING)) ?? ""; - const hasToken = !!(await settingsRepository.get( - AUDIT_FORWARD_TOKEN_SETTING, - )); res.json({ - url, - hasToken, - envConfigured: !!process.env[AUDIT_FORWARD_URL_ENV]?.trim(), + forceHttps: await isForceHttpsEnabled(), + tlsEnabled: getTlsConfig().enabled, }); } catch (err) { - authLogger.error("Failed to get audit forwarding settings", err); - res - .status(500) - .json({ error: "Failed to get audit forwarding settings" }); + authLogger.error("Failed to get HTTPS settings", err); + res.status(500).json({ error: "Failed to get HTTPS settings" }); } }); /** * @openapi - * /users/audit-forwarding: + * /users/https-settings: * patch: - * summary: Update audit log forwarding settings (admin only) - * description: Sets the collector URL audit entries are shipped to. An empty URL disables forwarding and clears the stored token. Omitting token keeps the stored one. + * summary: Update the HTTPS redirect setting (admin only) + * description: Turns the redirect from HTTP to HTTPS and the HSTS header on or off. Off by default so reverse proxies keep working. * tags: * - Users * requestBody: @@ -243,71 +233,43 @@ export function registerUserSettingsRoutes( * schema: * type: object * properties: - * url: - * type: string - * token: - * type: string + * forceHttps: + * type: boolean * responses: * 200: * description: Setting updated. + * 400: + * description: Invalid value. * 403: * description: Not authorized. */ - router.patch("/audit-forwarding", authenticateJWT, async (req, res) => { + router.patch("/https-settings", authenticateJWT, async (req, res) => { const userId = (req as AuthenticatedRequest).userId; try { const actor = await getAdminActor(userId); if (!actor) { return res.status(403).json({ error: "Not authorized" }); } - const { url, token } = req.body ?? {}; - if ( - typeof url !== "string" || - (token !== undefined && typeof token !== "string") - ) { - return res.status(400).json({ error: "url must be a string" }); + const { forceHttps } = req.body ?? {}; + if (typeof forceHttps !== "boolean") { + return res.status(400).json({ error: "forceHttps must be a boolean" }); } - const trimmedUrl = url.trim(); - if (trimmedUrl && !/^https?:\/\//i.test(trimmedUrl)) { - return res.status(400).json({ error: "url must be http(s)" }); - } - - const settingsRepository = createCurrentSettingsRepository(); - if (!trimmedUrl) { - await settingsRepository.delete(AUDIT_FORWARD_URL_SETTING); - await settingsRepository.delete(AUDIT_FORWARD_TOKEN_SETTING); - } else { - await settingsRepository.set(AUDIT_FORWARD_URL_SETTING, trimmedUrl); - if (token !== undefined) { - if (token.trim()) { - await settingsRepository.set( - AUDIT_FORWARD_TOKEN_SETTING, - token.trim(), - ); - } else { - await settingsRepository.delete(AUDIT_FORWARD_TOKEN_SETTING); - } - } - } - + await setForceHttps(forceHttps); const { ipAddress, userAgent } = getRequestMeta(req); await logAudit({ userId, username: actor.username ?? userId, - action: "update_audit_forwarding", + action: "update_force_https", resourceType: "setting", - details: JSON.stringify({ enabled: !!trimmedUrl }), + details: JSON.stringify({ forceHttps }), ipAddress, userAgent, success: true, }); - - res.json({ url: trimmedUrl, hasToken: !!token?.trim() }); + res.json({ forceHttps, tlsEnabled: getTlsConfig().enabled }); } catch (err) { - authLogger.error("Failed to update audit forwarding settings", err); - res - .status(500) - .json({ error: "Failed to update audit forwarding settings" }); + authLogger.error("Failed to set HTTPS settings", err); + res.status(500).json({ error: "Failed to set HTTPS settings" }); } }); diff --git a/src/backend/database/routes/user-socket-ticket-routes.ts b/src/backend/database/routes/user-socket-ticket-routes.ts index cf30a01b7..19f2f8ee1 100644 --- a/src/backend/database/routes/user-socket-ticket-routes.ts +++ b/src/backend/database/routes/user-socket-ticket-routes.ts @@ -61,4 +61,38 @@ export function registerUserSocketTicketRoutes( res.status(500).json({ error: "Failed to issue a socket ticket" }); } }); + + /** + * @openapi + * /users/ws-ticket/api-key: + * post: + * summary: Get a WebSocket ticket for an API key + * description: For another Termix server that manages this one with an API key. Returns a token that is valid for one minute and only opens plugin WebSockets, as the key's user. It cannot call any other route. + * tags: + * - Users + * responses: + * 200: + * description: The ticket. + * 401: + * description: The request did not use an API key. + * 500: + * description: Failed to issue the ticket. + */ + router.post("/ws-ticket/api-key", authenticateJWT, async (req, res) => { + const authReq = req as AuthenticatedRequest; + if (!authReq.apiKeyId) { + return res.status(401).json({ error: "An API key is required" }); + } + try { + const ticket = await authManager.issueApiKeySocketTicket( + authReq.userId, + authReq.apiKeyId, + ); + res.setHeader("Cache-Control", "no-store"); + res.json({ ticket }); + } catch (err) { + authLogger.error("Failed to issue an API key socket ticket", err); + res.status(500).json({ error: "Failed to issue a socket ticket" }); + } + }); } diff --git a/src/backend/database/routes/users.ts b/src/backend/database/routes/users.ts index f5f464197..bc4a915bf 100644 --- a/src/backend/database/routes/users.ts +++ b/src/backend/database/routes/users.ts @@ -27,6 +27,7 @@ import { registerUserSettingsRoutes } from "./user-settings-routes.js"; import { registerTlsRoutes } from "./tls-routes.js"; import { registerUserSessionRoutes } from "./user-session-routes.js"; import { registerUserSocketTicketRoutes } from "./user-socket-ticket-routes.js"; +import { shownIdentity } from "../../sync/shown-identity.js"; import { registerUserExternalAccountRoutes } from "./user-external-account-routes.js"; import { registerUserPasswordResetRoutes } from "./user-password-reset-routes.js"; import { registerUserAdminRoutes } from "./user-admin-routes.js"; @@ -61,6 +62,7 @@ import { syncSharedCredentialsForUserRoles, } from "../../auth/session-issuer.js"; import { EXTERNAL_AUTO_PROVISION_KEY } from "../../auth/provisioning.js"; +import { evaluateAuthPolicy } from "../../auth/policy.js"; const authManager = AuthManager.getInstance(); @@ -420,9 +422,21 @@ router.post("/proxy-login", async (req, res) => { userRecord.id, "trusted_proxy_login_role_shared_credentials", ); + // The proxy owns second factors, so only the session length applies. + const policy = await evaluateAuthPolicy({ + userId: userRecord.id, + methodId: "proxy", + external: true, + deviceType: deviceInfo.type, + trustedDevice: false, + mfaSatisfied: false, + secondFactors: [], + }); const token = await authManager.generateJWTToken(userRecord.id, { deviceType: deviceInfo.type, deviceInfo: deviceInfo.deviceInfo, + maxAgeSeconds: policy.maxSessionSeconds, + idleTimeoutSeconds: policy.idleTimeoutSeconds, }); const payload = await authManager.verifyJWTToken(token); const { ipAddress, userAgent } = getRequestMeta(req); @@ -443,7 +457,16 @@ router.post("/proxy-login", async (req, res) => { }); return res - .cookie("jwt", token, authManager.getSecureCookieOptions(req)) + .cookie( + "jwt", + token, + authManager.getSecureCookieOptions( + req, + policy.maxSessionSeconds + ? Math.min(24 * 60 * 60 * 1000, policy.maxSessionSeconds * 1000) + : undefined, + ), + ) .json({ enabled: true, success: true, @@ -546,30 +569,6 @@ router.post("/logout", authenticateJWT, async (req, res) => { } }); -/** - * On a desktop linked to a server, the account it is signed in to there. - * That account is who the user is; the local profile only mirrors it. - */ -async function describeDesktopLink(userId: string) { - if (process.env.ELECTRON_EMBEDDED !== "true") return null; - try { - const { getLink } = await import("../../sync/client/link-store.js"); - const link = await getLink(); - if (!link || link.userId !== userId) return null; - return { - serverUrl: link.serverUrl, - serverName: link.serverName, - username: link.account?.username ?? link.remoteUsername, - isAdmin: !!link.account?.isAdmin, - roles: link.account?.roles ?? [], - permissions: link.account?.permissions ?? [], - status: link.status, - }; - } catch { - return null; - } -} - /** * @openapi * /users/me: @@ -602,18 +601,21 @@ router.get("/me", authenticateJWT, async (req: Request, res: Response) => { const hasPassword = user.passwordHash && user.passwordHash.trim() !== ""; const isDualAuth = hasPassword && isExternalAccount(user); + const shown = await shownIdentity(user); res.json({ userId: user.id, - username: user.username, - is_admin: !!user.isAdmin, + username: shown.username, + is_admin: shown.isAdmin, is_external: isExternalAccount(user), is_dual_auth: isDualAuth, // Any second factor; the name is what 2.8 clients read. totp_enabled: await createCurrentUserAuthRepository().hasSecondFactor( user.id, ), - linked: await describeDesktopLink(user.id), + linked: shown.linked, + // "second-factor-setup" while the login policy wants a factor set up. + restriction: (req as AuthenticatedRequest).sessionRestriction ?? null, }); } catch (err) { authLogger.error("Failed to get username", err); @@ -719,7 +721,11 @@ router.post("/internal/auto-session", async (req, res) => { const userRepository = createCurrentUserRepository(); const allUsers = await userRepository.listAll(); - const userRecord = resolveDesktopAutoSessionUser(allUsers); + const { getSavedActiveUserId } = await import("../../sync/accounts.js"); + const userRecord = resolveDesktopAutoSessionUser( + allUsers, + await getSavedActiveUserId(), + ); if (!userRecord) { return res.status(403).json({ error: "No local users exist", @@ -730,10 +736,11 @@ router.post("/internal/auto-session", async (req, res) => { if (existingToken) { const existingPayload = await authManager.verifyJWTToken(existingToken); if (existingPayload?.userId === userRecord.id) { + const shownNow = await shownIdentity(userRecord); return res.json({ success: true, - is_admin: !!userRecord.isAdmin, - username: userRecord.username, + is_admin: shownNow.isAdmin, + username: shownNow.username, token: existingToken, }); } @@ -745,10 +752,11 @@ router.post("/internal/auto-session", async (req, res) => { rememberMe: true, }); + const shown = await shownIdentity(userRecord); const response = { success: true, - is_admin: !!userRecord.isAdmin, - username: userRecord.username, + is_admin: shown.isAdmin, + username: shown.username, token, }; @@ -1543,7 +1551,7 @@ registerUserSocketTicketRoutes(router, { authenticateJWT, authManager }); registerTlsRoutes(router, authenticateJWT); registerUserApiKeyRoutes(router, requireAdmin); -registerBrandingRoutes(router, requireAdmin); +registerBrandingRoutes(router); registerAuthRoutes(router); registerAuthCompatRoutes(router); diff --git a/src/backend/hosts/connect/transport-providers.ts b/src/backend/hosts/connect/transport-providers.ts new file mode 100644 index 000000000..8059fd321 --- /dev/null +++ b/src/backend/hosts/connect/transport-providers.ts @@ -0,0 +1,51 @@ +/** + * Other ways to reach a host, provided by plugins through + * ctx.ssh.transports.provide. A provider claims some hosts by id and hands the + * pipeline an open stream to them instead of a TCP connection, for example a + * relay through another Termix server. The first provider that claims a host + * wins, and nothing else (jump hosts, proxies) is tried for it. + */ + +import type { Duplex } from "node:stream"; +import type { PluginSshTransportProvider } from "@termix-ssh/plugin-sdk/backend"; + +interface Entry { + pluginId: string; + provider: PluginSshTransportProvider; +} + +const providers = new Map(); + +export function provideSshTransport( + pluginId: string, + provider: PluginSshTransportProvider, +): () => void { + const key = Symbol(pluginId); + providers.set(key, { pluginId, provider }); + return () => { + providers.delete(key); + }; +} + +/** Test seam. */ +export function clearSshTransports(): void { + providers.clear(); +} + +/** Opens a stream to the host if a provider claims it, else null. */ +export async function openProvidedTransport(host: { + id: number; + ip: string; + port: number; +}): Promise { + for (const { provider } of providers.values()) { + let claimed = false; + try { + claimed = provider.claims(host.id); + } catch { + claimed = false; + } + if (claimed) return provider.open(host); + } + return null; +} diff --git a/src/backend/hosts/connect/transport.ts b/src/backend/hosts/connect/transport.ts index f75137abf..5f8a7c069 100644 --- a/src/backend/hosts/connect/transport.ts +++ b/src/backend/hosts/connect/transport.ts @@ -1,6 +1,6 @@ /** - * How the bytes get to a host: port knocking, a jump host chain or a SOCKS5 - * proxy, in that order. Sets config.sock and + * How the bytes get to a host: a transport a plugin provides, else port + * knocking, a jump host chain or a SOCKS5 proxy, in that order. Sets config.sock and * returns the jump client so the caller can close it with the connection. */ @@ -13,6 +13,7 @@ import { } from "../../utils/socks5-helper.js"; import type { ProxyNode } from "../../../types/index.js"; import { createJumpHostChain } from "../jump-host-chain.js"; +import { openProvidedTransport } from "./transport-providers.js"; import { resolveSshConnectConfigHost } from "../ssh-dns.js"; import { performPortKnocking } from "../terminal-auth-helpers.js"; import type { @@ -59,7 +60,7 @@ export interface OpenTransportOptions { export interface OpenedTransport { jumpClient: Client | null; - via: "direct" | "proxy" | "jump"; + via: "direct" | "proxy" | "jump" | "provided"; } function forwardThrough( @@ -94,6 +95,16 @@ export async function openSshTransport( config: MutableConnectConfig, options: OpenTransportOptions = {}, ): Promise { + const provided = await openProvidedTransport({ + id: host.id, + ip: host.ip, + port: host.port || 22, + }); + if (provided) { + config.sock = provided; + return { jumpClient: null, via: "provided" }; + } + if ( options.portKnock !== false && Array.isArray(host.portKnockSequence) && diff --git a/src/backend/hosts/delete-host.ts b/src/backend/hosts/delete-host.ts index 3dfc870c7..4a0d98d87 100644 --- a/src/backend/hosts/delete-host.ts +++ b/src/backend/hosts/delete-host.ts @@ -2,10 +2,10 @@ import { createCurrentHostRepository, createCurrentHostResolutionRepository, createCurrentRbacAccessRepository, - createCurrentRecentActivityRepository, createCurrentSshCredentialUsageRepository, } from "../database/repositories/factory.js"; import { pluginEvents, TOPICS } from "../plugins/events.js"; +import { recentActivity } from "../plugins/activity-provider.js"; import { sshLogger } from "../utils/logger.js"; import { emitInternalEvent } from "./internal-events.js"; @@ -33,7 +33,7 @@ export async function deleteOwnedHost( // Plugin rows tied to a host cascade on their refHost() foreign keys. await createCurrentSshCredentialUsageRepository().deleteByHostId(hostId); - await createCurrentRecentActivityRepository().deleteByHostId(hostId); + await recentActivity.deleteByHostId(hostId); await createCurrentRbacAccessRepository().deleteHostAccessForHost(hostId); await createCurrentHostRepository().deleteForUser(userId, hostId); diff --git a/src/backend/hosts/import/host-export.ts b/src/backend/hosts/import/host-export.ts new file mode 100644 index 000000000..6b15a6de2 --- /dev/null +++ b/src/backend/hosts/import/host-export.ts @@ -0,0 +1,213 @@ +import { + createCurrentCredentialRepository, + createCurrentHostResolutionRepository, +} from "../../database/repositories/factory.js"; +import { hostTerminalExport } from "../../database/routes/host-normalizers.js"; +import { loadHostPluginSettings } from "../../database/routes/host-plugin-settings.js"; +import { + listProtocolLogins, + toPortableLogins, +} from "../protocol-auth/protocol-auth.js"; +import { findHostProtocol } from "../protocol-auth/registry.js"; +import { resolveHostCredentials } from "../resolve-host-credentials.js"; + +/** + * A share export leaves out every secret and credential link, the same as + * it does for SSH. + */ +function shareableLogins( + logins: ReturnType, + shareMode: boolean, +): ReturnType { + if (!shareMode) return logins; + const out: ReturnType = {}; + for (const [protocol, login] of Object.entries(logins)) { + const declared = findHostProtocol(protocol); + const secret = new Set( + (declared?.credentialFields ?? []) + .filter((field) => field.secret) + .map((field) => field.key), + ); + out[protocol] = { + authType: login.authType === "credential" ? "direct" : login.authType, + credentialId: null, + username: login.username, + password: null, + fields: Object.fromEntries( + Object.entries(login.fields).filter( + ([key]) => declared && !secret.has(key), + ), + ), + }; + } + return out; +} + +type Row = Record; + +/** One host as the Termix JSON export carries it. */ +async function exportRow( + host: Row, + userId: string, + shareMode: boolean, + pluginSettings: Awaited>, +): Promise<{ data: Row; isRemoteDesktop: boolean; resolved: Row }> { + const resolvedHost = shareMode + ? host + : (await resolveHostCredentials(host, userId)) || host; + const connectionType = (resolvedHost.connectionType as string) || "ssh"; + const isRemoteDesktop = connectionType !== "ssh"; + + const base = { + exportId: resolvedHost.id, + connectionType, + name: resolvedHost.name, + ip: resolvedHost.ip, + port: resolvedHost.port, + username: resolvedHost.username, + password: shareMode ? null : resolvedHost.password || null, + folder: resolvedHost.folder, + tags: + typeof resolvedHost.tags === "string" + ? resolvedHost.tags.split(",").filter(Boolean) + : resolvedHost.tags || [], + pin: !!resolvedHost.pin, + notes: resolvedHost.notes || null, + // Every plugin's host settings, secrets redacted, for import to hand back. + pluginSettings: pluginSettings.get(host.id as number) ?? {}, + // Each plugin protocol's login, secrets included like the SSH ones. + protocolAuth: shareableLogins( + toPortableLogins(await listProtocolLogins(host.id as number, userId)), + shareMode, + ), + }; + + if (isRemoteDesktop) { + return { data: base, isRemoteDesktop, resolved: resolvedHost }; + } + + const terminal = hostTerminalExport(resolvedHost); + const data = { + ...base, + authType: resolvedHost.authType, + key: shareMode ? null : resolvedHost.key || null, + keyPassword: shareMode ? null : resolvedHost.keyPassword || null, + keyType: resolvedHost.keyType || null, + credentialId: resolvedHost.credentialId || null, + overrideCredentialUsername: !!resolvedHost.overrideCredentialUsername, + sudoPassword: shareMode ? null : resolvedHost.sudoPassword || null, + jumpHosts: resolvedHost.jumpHosts + ? JSON.parse(resolvedHost.jumpHosts as string) + : null, + terminalConfig: terminal.terminalConfig ?? null, + sshOptions: terminal.sshOptions, + forceKeyboardInteractive: resolvedHost.forceKeyboardInteractive === "true", + useSocks5: !!resolvedHost.useSocks5, + socks5Host: resolvedHost.socks5Host || null, + socks5Port: resolvedHost.socks5Port || null, + socks5Username: resolvedHost.socks5Username || null, + socks5Password: shareMode ? null : resolvedHost.socks5Password || null, + socks5ProxyChain: resolvedHost.socks5ProxyChain + ? JSON.parse(resolvedHost.socks5ProxyChain as string) + : null, + portKnockSequence: resolvedHost.portKnockSequence + ? JSON.parse(resolvedHost.portKnockSequence as string) + : null, + }; + return { data, isRemoteDesktop, resolved: resolvedHost }; +} + +export interface HostExport { + hosts: Row[]; + /** Share mode only. */ + version?: string; + exportedAt?: string; + credentials?: Row[]; +} + +/** + * All of the user's own hosts. Without shareMode credentials are decrypted and + * embedded. With it every secret is left out and credential logins point at a + * scrubbed credentials list by alias. + */ +export async function exportAllHosts( + userId: string, + shareMode: boolean, + hostIds?: number[], +): Promise { + const owned = + await createCurrentHostResolutionRepository().findHostsByUserId(userId); + const wanted = hostIds ? new Set(hostIds) : null; + const allHosts = wanted + ? owned.filter((host) => wanted.has(host.id as number)) + : owned; + const settings = await loadHostPluginSettings( + allHosts.map((h) => h.id as number), + ); + + const hosts: Row[] = []; + const usedCredentialIds = new Set(); + + for (const host of allHosts) { + const { data, isRemoteDesktop, resolved } = await exportRow( + host as unknown as Row, + userId, + shareMode, + settings, + ); + if ( + shareMode && + !isRemoteDesktop && + resolved.authType === "credential" && + resolved.credentialId + ) { + usedCredentialIds.add(resolved.credentialId as number); + } + hosts.push(data); + } + + if (!shareMode) return { hosts }; + + const credentials: Row[] = []; + if (usedCredentialIds.size > 0) { + const owned = + await createCurrentCredentialRepository().listDecryptedByUserId(userId); + const byId = new Map( + owned.map((credential) => [credential.id, credential]), + ); + + for (const host of hosts) { + const credentialId = host.credentialId as number | null; + if (!credentialId) continue; + const credential = byId.get(credentialId); + if (!credential) continue; + + host.credentialAlias = credential.name; + + if (!credentials.some((entry) => entry.alias === credential.name)) { + credentials.push({ + alias: credential.name, + name: credential.name, + description: credential.description || null, + folder: credential.folder || null, + tags: + typeof credential.tags === "string" + ? credential.tags.split(",").filter(Boolean) + : [], + authType: credential.authType, + username: credential.username || null, + keyType: credential.keyType || null, + }); + } + } + } + + for (const host of hosts) delete host.credentialId; + + return { + version: "1", + exportedAt: new Date().toISOString(), + credentials, + hosts, + }; +} diff --git a/src/backend/hosts/import/host-import.ts b/src/backend/hosts/import/host-import.ts new file mode 100644 index 000000000..d9acb3b8c --- /dev/null +++ b/src/backend/hosts/import/host-import.ts @@ -0,0 +1,855 @@ +import { + prepareHostImports, + remapImportedJumpHosts, +} from "../../database/routes/host-import-order.js"; +import { sshOptionsForWrite } from "../ssh-options.js"; +import { + applyDefaultsAfterHostWrites, + applyHostDefaultsToWrite, +} from "../defaults/index.js"; +import { + keepUsableProtocolCredentials, + readProtocolAuthPayload, + writeProtocolAuth, +} from "../protocol-auth/protocol-auth.js"; +import { getErrorMessage } from "../../utils/error-message.js"; +import { pluginEvents, TOPICS } from "../../plugins/events.js"; +import { + createCurrentCredentialRepository, + createCurrentHostRepository, + createCurrentHostResolutionRepository, + createCurrentHostDefaultsRepository, +} from "../../database/repositories/factory.js"; +import { + listSshAuthProviders, + listSshAuthTypeOwners, +} from "../connect/auth-provider-registry.js"; +import { applyPluginHostImportSettings } from "../../database/routes/host-plugin-settings.js"; +import { + isNonEmptyString, + isValidPort, + normalizeImportedHost, +} from "../../database/routes/host-normalizers.js"; +import { hasHostLocks, hostLock, manualCreateBlockedBy } from "../locks.js"; + +/** A request the caller got wrong. Routes answer it with a 400. */ +export class ImportError extends Error { + constructor(message: string) { + super(message); + this.name = "ImportError"; + } +} + +export interface ImportResults { + success: number; + updated: number; + skipped: number; + failed: number; + errors: string[]; + /** Hosts this call created. */ + createdHostIds: number[]; + /** True when atomic was set and a failure undid the creates. */ + rolledBack?: boolean; +} + +export interface BulkImportInput { + hosts: unknown; + overwrite?: unknown; + credentials?: unknown; + /** All or nothing: any failure deletes what this call created. */ + atomic?: boolean; +} + +type SSHConfigHost = { + name: string; + hostname?: string; + user?: string; + port?: number; + identityFile?: string; + proxyJump?: string; +}; + +type ShareCredential = { + alias?: unknown; + name?: unknown; + description?: unknown; + folder?: unknown; + tags?: unknown; + authType?: unknown; + username?: unknown; + keyType?: unknown; +}; + +function textValue(value: unknown): string | null { + return typeof value === "string" && value.trim() ? value.trim() : null; +} + +function tagString(value: unknown): string { + if (Array.isArray(value)) { + return value + .map((tag) => textValue(tag)) + .filter((tag): tag is string => !!tag) + .join(","); + } + return textValue(value) || ""; +} + +function normalizeCredentialAuthType(value: unknown): "password" | "key" { + return value === "key" ? "key" : "password"; +} + +export function parseSSHConfig(content: string): SSHConfigHost[] { + const results: SSHConfigHost[] = []; + let current: SSHConfigHost | null = null; + + for (const rawLine of content.split("\n")) { + const line = rawLine.trim(); + if (!line || line.startsWith("#")) continue; + + const match = /^(\S+?)(?:\s*=\s*|\s+)(.+)$/.exec(line); + if (!match) continue; + + const key = match[1].toLowerCase(); + const value = match[2]; + + if (key === "host") { + if (current && current.hostname) results.push(current); + // Skip wildcard patterns + if (value === "*" || value.includes("*") || value.includes("?")) { + current = null; + } else { + current = { name: value }; + } + continue; + } + + if (!current) continue; + + switch (key) { + case "hostname": + current.hostname = value; + break; + case "user": + current.user = value; + break; + case "port": { + const p = Number.parseInt(value, 10); + if (p > 0 && p <= 65535) current.port = p; + break; + } + case "identityfile": + if (!current.identityFile) current.identityFile = value; + break; + case "proxyjump": + current.proxyJump = value; + break; + } + } + + if (current && current.hostname) results.push(current); + + return results; +} + +/** + * 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[], + 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 }); + } +} + +export function importedHostUsername( + connectionType: string, + authType: unknown, + username: unknown, +): string | null { + if (isNonEmptyString(username)) return username; + if (connectionType !== "ssh" || authType === "credential") return ""; + return null; +} + +/** + * Status check fields from an import row. Exports from before 2.9.0 carried + * them inside statsConfig. + */ +function importedStatusCheck(raw: Record): { + statusCheckEnabled: boolean; + statusCheckInterval: number | null; +} { + const legacy = + raw.statsConfig && typeof raw.statsConfig === "object" + ? (raw.statsConfig as Record) + : {}; + const enabled = raw.statusCheckEnabled ?? legacy.statusCheckEnabled; + const useGlobal = legacy.useGlobalStatusInterval !== false; + const interval = + raw.statusCheckInterval ?? (useGlobal ? null : legacy.statusCheckInterval); + const seconds = Number(interval); + return { + statusCheckEnabled: enabled !== false && legacy.disableTcpPing !== true, + statusCheckInterval: + interval != null && Number.isInteger(seconds) && seconds >= 5 + ? seconds + : null, + }; +} + +const BUILTIN_SSH_AUTH_TYPES = [ + "password", + "key", + "credential", + "agent", + "none", +]; + +/** Core's own types plus every type a plugin registers or declares. */ +function listKnownAuthTypes(): Set { + return new Set([ + ...BUILTIN_SSH_AUTH_TYPES, + ...listSshAuthProviders().map((provider) => provider.type), + ...listSshAuthTypeOwners().map((owner) => owner.type), + ]); +} + +/** Fills an imported host's inherited keys and records what it sets itself. */ +async function withHostDefaults( + userId: string, + hostId: number | null, + columns: Record, + body: Record, +): Promise { + const stored = + hostId === null + ? null + : (( + await createCurrentHostDefaultsRepository().listHosts({ + hostIds: [hostId], + }) + )[0] ?? null); + columns.defaultOverrides = JSON.stringify( + await applyHostDefaultsToWrite({ + ownerId: userId, + hostId, + columns, + body, + stored, + }), + ); +} + +export function importSummary(results: ImportResults): string { + return `Import completed: ${results.success} created, ${results.updated} updated, ${results.skipped} skipped, ${results.failed} failed`; +} + +export function importResponse(results: ImportResults) { + return { + message: importSummary(results), + success: results.success, + updated: results.updated, + skipped: results.skipped, + failed: results.failed, + errors: results.errors, + }; +} + +export const MAX_IMPORT_HOSTS = 100; + +/** Why an import row can't be written while a plugin manages hosts, if it can't. */ +function importLockError(existingId: number | undefined): string | null { + if (!hasHostLocks()) return null; + if (existingId === undefined) { + const blocked = manualCreateBlockedBy(); + return blocked + ? `Hosts are managed by ${blocked.pluginName}, so new hosts can't be added` + : null; + } + const lock = hostLock(existingId); + return lock ? `This host is managed by ${lock.pluginName}` : null; +} + +/** Deletes what an atomic import created, newest first. */ +async function rollBackCreated( + userId: string, + results: ImportResults, +): Promise { + const repository = createCurrentHostRepository(); + for (const hostId of [...results.createdHostIds].reverse()) { + await repository.deleteForUser(userId, hostId).catch(() => {}); + } + results.rolledBack = true; + results.success = 0; + results.createdHostIds = []; +} + +/** + * The Termix JSON import: shared by POST /host/bulk-import and + * ctx.hosts.importMany. Callers wrap it in DatabaseSaveTrigger.batched. + */ +export async function runBulkImport( + userId: string, + input: BulkImportInput, +): Promise { + const { + hosts: hostsToImport, + overwrite, + credentials: credentialsToImport, + } = input; + if (input.atomic && overwrite) { + throw new ImportError("An atomic import cannot overwrite existing hosts"); + } + + if (!Array.isArray(hostsToImport) || hostsToImport.length === 0) { + throw new ImportError("Hosts array is required and must not be empty"); + } + + if (hostsToImport.length > 100) { + throw new ImportError("Maximum 100 hosts allowed per import"); + } + + let orderedHosts: ReturnType; + try { + orderedHosts = prepareHostImports(hostsToImport); + } catch (error) { + throw new ImportError(getErrorMessage(error)); + } + const importedIds = new Map(); + + const results: ImportResults = { + success: 0, + updated: 0, + skipped: 0, + failed: 0, + errors: [], + createdHostIds: [], + }; + + const credentialAliasMap = new Map(); + const addCredentialAlias = (alias: unknown, id: number) => { + const key = textValue(alias); + if (key) credentialAliasMap.set(key.toLowerCase(), id); + }; + + try { + const credentialRepository = createCurrentCredentialRepository(); + const existingCredentials = + await credentialRepository.listDecryptedByUserId(userId); + + for (const credential of existingCredentials) { + addCredentialAlias(credential.name, credential.id as number); + } + + if (Array.isArray(credentialsToImport)) { + for (const rawCredential of credentialsToImport as ShareCredential[]) { + const alias = textValue(rawCredential.alias); + const name = textValue(rawCredential.name) || alias; + if (!alias || !name) continue; + + const existingId = credentialAliasMap.get(name.toLowerCase()); + if (existingId) { + addCredentialAlias(alias, existingId); + continue; + } + + const now = new Date().toISOString(); + const created = await credentialRepository.createEncryptedForUser( + userId, + { + userId, + name, + description: + textValue(rawCredential.description) || + "Imported placeholder. Add the secret before connecting.", + folder: textValue(rawCredential.folder), + tags: tagString(rawCredential.tags), + authType: normalizeCredentialAuthType(rawCredential.authType), + username: textValue(rawCredential.username), + password: null, + key: null, + privateKey: null, + publicKey: null, + keyPassword: null, + keyType: textValue(rawCredential.keyType), + detectedKeyType: null, + usageCount: 0, + lastUsed: null, + createdAt: now, + updatedAt: now, + }, + ); + + const createdCredential = created as Record; + addCredentialAlias(alias, createdCredential.id as number); + addCredentialAlias(name, createdCredential.id as number); + } + } + } catch (error) { + results.errors.push( + `Credential placeholders: ${getErrorMessage(error, "failed to prepare credential aliases")}`, + ); + } + + // Loaded for both modes: overwrite updates a match, skip leaves it alone. + let existingHostMap: Map | undefined; + const hostRepository = createCurrentHostRepository(); + try { + const allHosts = + await createCurrentHostResolutionRepository().findHostsByUserId(userId); + existingHostMap = new Map(); + for (const h of allHosts) { + const key = `${h.ip}:${h.port}:${h.username}`; + existingHostMap.set(key, { id: h.id as number }); + } + } catch { + existingHostMap = undefined; + } + + const knownAuthTypes = listKnownAuthTypes(); + const writtenHostIds: number[] = []; + const createdHostIds: number[] = []; + for (const { host: hostData, index: i, exportId } of orderedHosts) { + try { + const effectiveConnectionType = hostData.connectionType || "ssh"; + + if ( + effectiveConnectionType === "ssh" && + hostData.authType === "credential" && + !hostData.credentialId && + hostData.credentialAlias + ) { + hostData.credentialId = credentialAliasMap.get( + hostData.credentialAlias.toLowerCase(), + ); + } + + if (!isNonEmptyString(hostData.ip) || !isValidPort(hostData.port)) { + results.failed++; + results.errors.push( + `Host ${i + 1}: Missing required fields (ip, port)`, + ); + continue; + } + + const username = importedHostUsername( + effectiveConnectionType, + hostData.authType, + hostData.username, + ); + if (username === null) { + results.failed++; + results.errors.push( + `Host ${i + 1}: Username required for SSH connections`, + ); + continue; + } + + const lookupKey = `${hostData.ip}:${hostData.port}:${hostData.username}`; + const existing = existingHostMap?.get(lookupKey); + if (existing && !overwrite) { + // Hosts that jump through this one still resolve to the kept host. + if (exportId !== undefined) importedIds.set(exportId, existing.id); + results.skipped++; + continue; + } + const locked = importLockError(existing?.id); + if (locked) { + results.failed++; + results.errors.push(`Host ${i + 1}: ${locked}`); + continue; + } + + if ( + effectiveConnectionType === "ssh" && + hostData.authType && + !knownAuthTypes.has(hostData.authType) + ) { + results.failed++; + results.errors.push( + `Host ${i + 1}: Invalid authType. Must be one of ${[...knownAuthTypes].join(", ")}`, + ); + continue; + } + + if ( + effectiveConnectionType === "ssh" && + hostData.authType === "password" && + !isNonEmptyString(hostData.password) + ) { + results.failed++; + results.errors.push( + `Host ${i + 1}: Password required for password authentication`, + ); + continue; + } + + if ( + effectiveConnectionType === "ssh" && + hostData.authType === "key" && + !isNonEmptyString(hostData.key) + ) { + results.failed++; + results.errors.push( + `Host ${i + 1}: Key required for key authentication`, + ); + continue; + } + + if ( + effectiveConnectionType === "ssh" && + hostData.authType === "credential" && + !hostData.credentialId + ) { + results.failed++; + results.errors.push( + `Host ${i + 1}: credentialId required for credential authentication`, + ); + continue; + } + + if ( + effectiveConnectionType === "ssh" && + hostData.authType === "credential" && + hostData.credentialId + ) { + const credentialRepository = createCurrentCredentialRepository(); + const cred = await credentialRepository.findByIdForUser( + userId, + hostData.credentialId, + ); + + if (!cred) { + const fallback = await credentialRepository.listByUserId(userId); + + if (fallback.length > 0) { + hostData.credentialId = fallback[0].id; + } else if (isNonEmptyString(hostData.key)) { + hostData.authType = "key"; + hostData.credentialId = undefined; + } else if (isNonEmptyString(hostData.password)) { + hostData.authType = "password"; + hostData.credentialId = undefined; + } else { + results.failed++; + results.errors.push( + `Host ${i + 1}: credentialId ${hostData.credentialId} not found and no fallback credential available`, + ); + continue; + } + } + } + + const jumpHosts = remapImportedJumpHosts(hostData.jumpHosts, importedIds); + const sshDataObj: Record = { + userId: userId, + connectionType: effectiveConnectionType, + name: hostData.name || `${username}@${hostData.ip}`, + folder: hostData.folder || "Default", + tags: Array.isArray(hostData.tags) ? hostData.tags.join(",") : "", + ip: hostData.ip, + port: hostData.port, + username, + pin: hostData.pin || false, + sudoPassword: hostData.sudoPassword || null, + jumpHosts: jumpHosts ? JSON.stringify(jumpHosts) : null, + ...importedStatusCheck(hostData as Record), + // A 2.8 export carries these inside terminalConfig. + sshOptions: + sshOptionsForWrite({ + sshOptions: hostData.sshOptions, + terminalConfig: hostData.terminalConfig, + }) ?? null, + forceKeyboardInteractive: hostData.forceKeyboardInteractive + ? "true" + : "false", + notes: hostData.notes || null, + useSocks5: hostData.useSocks5 ? 1 : 0, + socks5Host: hostData.socks5Host || null, + socks5Port: hostData.socks5Port || null, + socks5Username: hostData.socks5Username || null, + socks5Password: hostData.socks5Password || null, + socks5ProxyChain: hostData.socks5ProxyChain + ? JSON.stringify(hostData.socks5ProxyChain) + : null, + portKnockSequence: hostData.portKnockSequence + ? JSON.stringify(hostData.portKnockSequence) + : null, + overrideCredentialUsername: hostData.overrideCredentialUsername ? 1 : 0, + enableSsh: hostData.enableSsh ?? effectiveConnectionType === "ssh", + updatedAt: new Date().toISOString(), + }; + + if (effectiveConnectionType !== "ssh") { + sshDataObj.password = hostData.password || null; + sshDataObj.authType = "password"; + sshDataObj.credentialId = null; + sshDataObj.key = null; + sshDataObj.keyPassword = null; + sshDataObj.keyType = null; + } else { + sshDataObj.password = + hostData.authType === "password" ? hostData.password : null; + sshDataObj.authType = hostData.authType || "password"; + sshDataObj.credentialId = + hostData.authType === "credential" ? hostData.credentialId : null; + sshDataObj.key = hostData.authType === "key" ? hostData.key : null; + sshDataObj.keyPassword = + hostData.authType === "key" ? hostData.keyPassword || null : null; + sshDataObj.keyType = + hostData.authType === "key" ? hostData.keyType || "auto" : null; + } + + await withHostDefaults( + userId, + existing?.id ?? null, + sshDataObj, + hostData as Record, + ); + + let savedHostId: number; + if (existing) { + const saved = await hostRepository.updateEncryptedForUser( + userId, + existing.id, + sshDataObj, + ); + if (!saved) throw new Error("Host no longer exists"); + if (exportId !== undefined) importedIds.set(exportId, existing.id); + savedHostId = existing.id; + results.updated++; + } else { + sshDataObj.createdAt = new Date().toISOString(); + const saved = await hostRepository.createEncryptedForUser( + userId, + sshDataObj, + ); + if (exportId !== undefined) importedIds.set(exportId, saved.id); + savedHostId = saved.id; + createdHostIds.push(saved.id); + results.success++; + } + + const protocolAuth = readProtocolAuthPayload( + hostData as Record, + ); + if (protocolAuth) { + await writeProtocolAuth( + userId, + savedHostId, + await keepUsableProtocolCredentials(protocolAuth, userId), + { isOwner: true }, + ); + } + + // Every enabled plugin that declares host-scope settings and + // registered a hostImportNormalizer validates and writes its own + // fields here, so this loop does not need to know which plugins + // exist. See host-plugin-settings.ts. + await applyPluginHostImportSettings( + savedHostId, + hostData as Record, + ); + writtenHostIds.push(savedHostId); + } catch (error) { + results.failed++; + results.errors.push(`Host ${i + 1}: ${getErrorMessage(error)}`); + } + } + + results.createdHostIds = createdHostIds; + if (input.atomic && results.failed > 0) { + await rollBackCreated(userId, results); + return results; + } + + await applyDefaultsAfterHostWrites(writtenHostIds); + notifyImportedHosts(userId, writtenHostIds, createdHostIds); + results.createdHostIds = createdHostIds; + return results; +} + +/** The OpenSSH config import, shared by the route and ctx.hosts. */ +export async function runSshConfigImport( + userId: string, + input: { content?: unknown; overwrite?: unknown; atomic?: boolean }, +): Promise { + const { content, overwrite } = input; + if (input.atomic && overwrite) { + throw new ImportError("An atomic import cannot overwrite existing hosts"); + } + + if (!isNonEmptyString(content)) { + throw new ImportError("content is required and must be a non-empty string"); + } + + let parsed: SSHConfigHost[]; + try { + parsed = parseSSHConfig(content); + } catch { + throw new ImportError("Failed to parse SSH config file"); + } + + if (parsed.length === 0) { + throw new ImportError("No valid Host entries found in the SSH config file"); + } + + if (parsed.length > 100) { + throw new ImportError("Maximum 100 hosts allowed per import"); + } + + const hostsToImport = parsed.map((h) => ({ + name: h.name, + ip: h.hostname, + port: h.port ?? 22, + username: h.user, + authType: h.identityFile ? "key" : undefined, + connectionType: "ssh", + enableSsh: true, + ...(h.proxyJump + ? { + jumpHosts: [{ host: h.proxyJump, port: 22 }], + } + : {}), + })); + + const results: ImportResults = { + success: 0, + updated: 0, + skipped: 0, + failed: 0, + errors: [], + createdHostIds: [], + }; + + let existingHostMap: Map | undefined; + const hostRepository = createCurrentHostRepository(); + try { + const allHosts = + await createCurrentHostResolutionRepository().findHostsByUserId(userId); + existingHostMap = new Map(); + for (const h of allHosts) { + const key = `${h.ip}:${h.port}:${h.username}`; + existingHostMap.set(key, { id: h.id as number }); + } + } catch { + existingHostMap = undefined; + } + + const writtenHostIds: number[] = []; + const createdHostIds: number[] = []; + for (let i = 0; i < hostsToImport.length; i++) { + const hostData = normalizeImportedHost( + hostsToImport[i] as Record, + ); + + try { + if (!isNonEmptyString(hostData.ip) || !isValidPort(hostData.port)) { + results.failed++; + results.errors.push( + `Host "${parsed[i].name}": Missing required fields (HostName, Port)`, + ); + continue; + } + + const lookupKey = `${hostData.ip}:${hostData.port}:${hostData.username}`; + const existing = existingHostMap?.get(lookupKey); + if (existing && !overwrite) { + results.skipped++; + continue; + } + const locked = importLockError(existing?.id); + if (locked) { + results.failed++; + results.errors.push(`Host "${parsed[i].name}": ${locked}`); + continue; + } + + const sshDataObj: Record = { + userId, + connectionType: "ssh", + name: hostData.name || hostData.ip, + folder: "Default", + tags: "", + ip: hostData.ip, + port: hostData.port, + username: hostData.username || null, + authType: hostData.authType || "none", + password: null, + key: null, + keyPassword: null, + keyType: null, + credentialId: null, + pin: false, + sudoPassword: null, + jumpHosts: hostData.jumpHosts + ? JSON.stringify(hostData.jumpHosts) + : null, + statusCheckEnabled: true, + statusCheckInterval: null, + sshOptions: null, + forceKeyboardInteractive: "false", + notes: null, + useSocks5: 0, + socks5Host: null, + socks5Port: null, + socks5Username: null, + socks5Password: null, + socks5ProxyChain: null, + portKnockSequence: null, + overrideCredentialUsername: 0, + enableSsh: true, + updatedAt: new Date().toISOString(), + }; + + await withHostDefaults( + userId, + existing?.id ?? null, + sshDataObj, + hostData as unknown as Record, + ); + + if (existing) { + await hostRepository.updateEncryptedForUser( + userId, + existing.id, + sshDataObj, + ); + writtenHostIds.push(existing.id); + results.updated++; + } else { + sshDataObj.createdAt = new Date().toISOString(); + const saved = await hostRepository.createEncryptedForUser( + userId, + sshDataObj, + ); + writtenHostIds.push(saved.id); + createdHostIds.push(saved.id); + results.success++; + } + } catch (error) { + results.failed++; + results.errors.push( + `Host "${parsed[i].name}": ${getErrorMessage(error)}`, + ); + } + } + + results.createdHostIds = createdHostIds; + if (input.atomic && results.failed > 0) { + await rollBackCreated(userId, results); + return results; + } + + await applyDefaultsAfterHostWrites(writtenHostIds); + notifyImportedHosts(userId, writtenHostIds, createdHostIds); + results.createdHostIds = createdHostIds; + return results; +} diff --git a/src/backend/hosts/locks.ts b/src/backend/hosts/locks.ts new file mode 100644 index 000000000..8685a0c25 --- /dev/null +++ b/src/backend/hosts/locks.ts @@ -0,0 +1,173 @@ +/** + * Host locks: hosts a plugin manages itself (for example from a config file) + * through ctx.hosts.locks.provide. Core refuses edits, deletes and share + * changes on a locked host everywhere, except from the plugin that locks it, + * and a provider can block adding hosts by hand. Pins and order stay with the + * user. + */ + +import type { PluginHostLockProvider } from "@termix-ssh/plugin-sdk/backend"; +import { pluginLogger } from "../utils/logger.js"; + +export interface HostLockInfo { + pluginId: string; + pluginName: string; + note: string | null; +} + +interface Entry { + pluginId: string; + pluginName: string; + provider: PluginHostLockProvider; +} + +const providers = new Map(); + +export class HostLockedError extends Error { + readonly status = 403; + readonly code = "HOST_LOCKED"; + constructor( + message: string, + readonly lock: { pluginId: string; pluginName: string }, + ) { + super(message); + this.name = "HostLockedError"; + } +} + +export function provideHostLocks( + pluginId: string, + pluginName: string, + provider: PluginHostLockProvider, +): () => void { + const key = Symbol(pluginId); + providers.set(key, { pluginId, pluginName, provider }); + return () => { + providers.delete(key); + }; +} + +/** Test seam. */ +export function clearHostLocks(): void { + providers.clear(); +} + +// A provider that throws never locks: a broken plugin must not freeze hosts. +function safely(entry: Entry, fallback: T, read: () => T): T { + try { + return read(); + } catch (error) { + pluginLogger.warn("Host lock provider failed", { + operation: "host_lock", + pluginId: entry.pluginId, + error: error instanceof Error ? error.message : String(error), + }); + return fallback; + } +} + +export function hostLock( + hostId: number, + exemptPluginId?: string, +): HostLockInfo | null { + if (!Number.isInteger(hostId)) return null; + for (const entry of providers.values()) { + if (entry.pluginId === exemptPluginId) continue; + if (!safely(entry, false, () => entry.provider.isLocked(hostId))) continue; + return { + pluginId: entry.pluginId, + pluginName: entry.pluginName, + note: safely(entry, null, () => entry.provider.note(hostId) ?? null), + }; + } + return null; +} + +export function hasHostLocks(): boolean { + return providers.size > 0; +} + +/** Throws HostLockedError for the first locked host. */ +export function assertHostsUnlocked( + hostIds: Iterable, + exemptPluginId?: string, +): void { + if (providers.size === 0) return; + for (const hostId of hostIds) { + const lock = hostLock(Number(hostId), exemptPluginId); + if (lock) { + throw new HostLockedError( + `This host is managed by ${lock.pluginName} and can't be changed here`, + lock, + ); + } + } +} + +/** The plugin that blocks adding hosts by hand, if any. */ +export function manualCreateBlockedBy( + exemptPluginId?: string, +): { pluginId: string; pluginName: string } | null { + for (const entry of providers.values()) { + if (entry.pluginId === exemptPluginId) continue; + if (!safely(entry, true, () => entry.provider.allowManualCreate())) { + return { pluginId: entry.pluginId, pluginName: entry.pluginName }; + } + } + return null; +} + +export function assertManualCreateAllowed(exemptPluginId?: string): void { + const blocked = manualCreateBlockedBy(exemptPluginId); + if (blocked) { + throw new HostLockedError( + `Hosts are managed by ${blocked.pluginName}, so new hosts can't be added here`, + blocked, + ); + } +} + +type Res = { status: (code: number) => { json: (body: unknown) => unknown } }; +type Next = () => void; + +/** Route guard for creating hosts by hand. */ +export function requireManualCreate(_req: unknown, res: Res, next: Next) { + try { + assertManualCreateAllowed(); + } catch (error) { + if (sendHostLocked(res, error)) return; + throw error; + } + next(); +} + +/** Route guard for routes that change the host named by :id. */ +export function requireUnlockedHost( + req: { params: Record }, + res: Res, + next: Next, +) { + const raw = req.params.id; + const hostId = Number(Array.isArray(raw) ? raw[0] : raw); + try { + assertHostsUnlocked([hostId]); + } catch (error) { + if (sendHostLocked(res, error)) return; + throw error; + } + next(); +} + +/** Sends a HostLockedError as a 403. False when the error is something else. */ +export function sendHostLocked( + res: { status: (code: number) => { json: (body: unknown) => unknown } }, + error: unknown, +): boolean { + if (!(error instanceof HostLockedError)) return false; + res.status(403).json({ + error: error.message, + code: error.code, + lockedBy: error.lock.pluginId, + }); + return true; +} diff --git a/src/backend/hosts/resolve-host-credentials.ts b/src/backend/hosts/resolve-host-credentials.ts new file mode 100644 index 000000000..e3250020f --- /dev/null +++ b/src/backend/hosts/resolve-host-credentials.ts @@ -0,0 +1,211 @@ +import { getErrorMessage } from "../utils/error-message.js"; +import { sshLogger } from "../utils/logger.js"; +import { findUsableCredential } from "./usable-credential.js"; +import { + pickResolvedPassword, + pickResolvedUsername, +} from "./credential-username.js"; +import { createCurrentSharedHostAuthOverrideRepository } from "../database/repositories/factory.js"; +import { listHostProtocols } from "./protocol-auth/registry.js"; +import { + requiresPersonalHostAuthentication, + resolveRecipientSharedHostAuthentication, +} from "../utils/shared-host-auth-resolver.js"; +import type { + HostResolutionCredentialRecord, + HostResolutionHostRecord, +} from "../database/repositories/host-resolution-repository.js"; + +/** A host with its credential's secrets filled in for the asking user. */ +export async function resolveHostCredentials( + host: Record, + requestingUserId?: string, + /** + * Credentials already fetched for this request, keyed by id. The host list + * preloads them in one query; single-host callers omit it and fall back to + * fetching the one credential they need. + */ + preloadedCredentials?: Map, +): Promise> { + try { + const ownerId = (host.ownerId || host.userId) as string | undefined; + if ( + requestingUserId && + ownerId && + requestingUserId !== ownerId && + typeof host.id === "number" + ) { + const authHost = host as unknown as HostResolutionHostRecord; + const needsPersonalCredential = requiresPersonalHostAuthentication( + authHost, + "ssh", + ); + const baseSshOverrideState = { + required: needsPersonalCredential, + ownerAuthShared: !!host.shareSshAuth, + }; + // Owner auth for the remote desktop protocols is always snapshotted + // for recipients; only their own override credential varies per user. + const authOverrides: Record = { + ssh: baseSshOverrideState, + }; + const overrideCredentialIds = + await createCurrentSharedHostAuthOverrideRepository().listCredentialIds( + host.id, + requestingUserId, + ); + // Whether a plugin protocol is on is its plugin's host setting; the + // client only offers an override for one that is. + for (const { id: protocol } of listHostProtocols()) { + authOverrides[protocol] = { + credentialId: overrideCredentialIds[protocol], + required: false, + ownerAuthShared: true, + }; + } + const recipientHost: Record = { + ...host, + credentialId: null, + password: null, + key: null, + keyPassword: null, + keyType: null, + authOverrides, + }; + + try { + const resolution = await resolveRecipientSharedHostAuthentication( + authHost, + host.id, + requestingUserId, + "ssh", + ); + + if (resolution.source === "personal-override") { + const credential = resolution.credential; + return { + ...recipientHost, + authOverrides: { + ...authOverrides, + ssh: { + credentialId: resolution.credentialId, + required: false, + ownerAuthShared: !!host.shareSshAuth, + }, + }, + authType: + credential.key || credential.privateKey + ? "key" + : credential.password + ? "password" + : "none", + username: credential.username || recipientHost.username, + password: credential.password, + key: credential.privateKey || credential.key, + keyPassword: credential.keyPassword, + keyType: credential.keyType, + }; + } + + if (resolution.source === "owner-shared") { + if (resolution.authType === "agent") { + return { + ...recipientHost, + authOverrides: { + ...authOverrides, + ssh: { + required: false, + ownerAuthShared: true, + }, + }, + authType: "agent", + }; + } + + const sharedAuth = resolution.secret; + if (sharedAuth) { + const resolvedUsername = pickResolvedUsername( + recipientHost.username, + sharedAuth.username, + host.overrideCredentialUsername, + ); + return { + ...recipientHost, + authOverrides: { + ...authOverrides, + ssh: { + required: false, + ownerAuthShared: true, + }, + }, + authType: sharedAuth.key + ? "key" + : sharedAuth.password + ? "password" + : "none", + username: resolvedUsername, + password: sharedAuth.password, + key: sharedAuth.key, + keyPassword: sharedAuth.keyPassword, + keyType: sharedAuth.keyType, + }; + } + } + + if (resolution.source === "secretless") { + return { + ...recipientHost, + authOverrides: { + ...authOverrides, + ssh: { + required: false, + ownerAuthShared: !!host.shareSshAuth, + }, + }, + }; + } + } catch { + // A missing/deleted override or snapshot behaves like unavailable auth. + } + + return recipientHost; + } + + if (host.credentialId && (host.userId || host.ownerId)) { + const credentialId = host.credentialId as number; + const credentialOwnerId = (host.ownerId || host.userId) as string; + + const credential = + preloadedCredentials?.get(credentialId) ?? + (await findUsableCredential(credentialId, credentialOwnerId)); + + if (credential) { + const resolvedHost: Record = { + ...host, + password: pickResolvedPassword(host.password, credential.password), + key: credential.key, + keyPassword: credential.keyPassword, + keyType: credential.keyType, + }; + + const resolvedUsername = pickResolvedUsername( + host.username, + credential.username, + host.overrideCredentialUsername, + ); + if (resolvedUsername !== undefined) { + resolvedHost.username = resolvedUsername; + } + + return resolvedHost; + } + } + + return { ...host }; + } catch (error) { + sshLogger.warn( + `Failed to resolve credentials for host ${host.id}: ${getErrorMessage(error)}`, + ); + return host; + } +} diff --git a/src/backend/hosts/ssh-relay.ts b/src/backend/hosts/ssh-relay.ts new file mode 100644 index 000000000..ad1126068 --- /dev/null +++ b/src/backend/hosts/ssh-relay.ts @@ -0,0 +1,110 @@ +/** + * A relay to a host's SSH port, for another Termix server that manages this + * one. It opens a WebSocket at /plugin-ws/core/ssh-relay with an API key + * ticket, names a host, and the bytes of an SSH connection run through it. + * This server reaches the host the way it always does, jump hosts, proxy and + * port knocking included, so the other server never needs a route to it. The + * SSH session itself stays between that server and the host. + * + * The caller needs connect access to the host, the same as for a session. + */ + +import net from "node:net"; +import type { Duplex } from "node:stream"; +import { createWebSocketStream, type WebSocket } from "ws"; +import type { PluginWebSocketConnection } from "@termix-ssh/plugin-sdk/backend"; +import { resolveHostById } from "./host-resolver.js"; +import { openSshTransport } from "./connect/transport.js"; +import type { MutableConnectConfig, SshConnectHost } from "./connect/types.js"; +import { sshLogger } from "../utils/logger.js"; +import { registerCoreWsRoute } from "../plugins/ws.js"; + +const MAX_RELAYS_PER_USER = 100; +const open = new Map(); + +function close(ws: WebSocket, code: number, reason: string): void { + try { + ws.close(code, reason); + } catch { + // Already closed. + } +} + +async function handleRelay( + connection: PluginWebSocketConnection, +): Promise { + const ws = connection.socket as WebSocket; + const url = String((connection.request as { url?: string }).url ?? ""); + const hostId = Number( + new URL(url, "http://relay").searchParams.get("hostId"), + ); + const userId = connection.userId; + + if (!userId || !Number.isSafeInteger(hostId) || hostId <= 0) { + close(ws, 1008, "A host is required"); + return; + } + if ((open.get(userId) ?? 0) >= MAX_RELAYS_PER_USER) { + close(ws, 1013, "Too many connections"); + return; + } + + const host = await resolveHostById(hostId, userId); + if (!host) { + close(ws, 1008, "Host not found or access denied"); + return; + } + + open.set(userId, (open.get(userId) ?? 0) + 1); + let stream: Duplex | null = null; + let jumpEnd: (() => void) | null = null; + let done = false; + const finish = () => { + if (done) return; + done = true; + open.set(userId, Math.max(0, (open.get(userId) ?? 1) - 1)); + stream?.destroy(); + jumpEnd?.(); + close(ws, 1000, "Closed"); + }; + ws.once("close", finish); + + try { + const sshHost = host as unknown as SshConnectHost; + const config = { + host: sshHost.ip, + port: sshHost.port || 22, + } as MutableConnectConfig; + const { jumpClient } = await openSshTransport(sshHost, config); + jumpEnd = () => jumpClient?.end(); + stream = (config.sock as Duplex | undefined) ?? null; + if (!stream) { + stream = net.connect({ + host: String(config.host), + port: Number(config.port), + }); + } + const pipe = createWebSocketStream(ws); + stream.once("error", finish); + stream.once("close", finish); + pipe.once("error", finish); + stream.pipe(pipe); + pipe.pipe(stream); + } catch (error) { + sshLogger.warn("A relay could not reach its host", { + operation: "ssh_relay_open", + hostId, + error: error instanceof Error ? error.message : String(error), + }); + finish(); + } +} + +let registered = false; + +/** Mounts the relay once, for servers that other Termix servers manage. */ +export function registerSshRelay(): void { + if (registered) return; + registered = true; + registerCoreWsRoute("/ssh-relay", handleRelay); +} diff --git a/src/backend/hosts/status/host-status-service.ts b/src/backend/hosts/status/host-status-service.ts deleted file mode 100644 index 96d47b976..000000000 --- a/src/backend/hosts/status/host-status-service.ts +++ /dev/null @@ -1,491 +0,0 @@ -/** - * The host list's status dot. - * - * Every host with status checks on gets a TCP connection to its port on a - * timer. A port that answers is "online", one that does not (twice in a row) - * is "offline". An open session or a working login also counts as online. - * Probing never logs in to the host itself: on RADIUS or Duo backed devices a - * login fires a real 2FA push, every interval. A host behind jump hosts is - * probed through a chain, which does log in to the hops, so hosts that share - * a hop list share one chain (jump-probe-pool.ts). - * - * Polling is demand driven. A user asking for statuses starts polling their - * own hosts; nothing is probed for a user who never opened the app. The first - * request waits briefly for those first checks so it is not empty. - */ - -import { pluginEvents, TOPICS } from "../../plugins/events.js"; -import { hostSessionStatus } from "../host-session-status.js"; -import { sshLogger } from "../../utils/logger.js"; -import { - createCurrentHostResolutionRepository, - getCurrentSettingValue, -} from "../../database/repositories/factory.js"; -import type { HostStatusTargetRow } from "../../database/repositories/host-resolution-repository.js"; -import type { HostStatus } from "./host-status.js"; -import { ConcurrentLimiter } from "./limiter.js"; -import { tcpPing } from "./tcp-ping.js"; -import { JumpProbePool } from "./jump-probe-pool.js"; - -export const GLOBAL_STATUS_INTERVAL_KEY = "global_status_check_interval"; -export const DEFAULT_STATUS_INTERVAL = 30; -/** How long the first request for a user waits for the first checks. */ -const FIRST_CHECK_WAIT_MS = 6_000; -/** A status younger than this is fresh enough for check(). */ -const FRESH_MS = 30_000; - -export interface HostStatusEntry { - status: HostStatus; - lastChecked: string; -} - -export interface HostStatusPayload { - hostId: number; - ownerUserId: string; - status: HostStatus; - previous: HostStatus | null; - /** Anything but offline, the shape automations' trigger already reads. */ - online: boolean; -} - -export interface StatusTarget { - id: number; - userId: string; - ip: string; - port: number; - connectionType: string; - jumpHosts: Array<{ hostId: number }>; - statusCheckEnabled: boolean; - statusCheckInterval: number | null; -} - -type PortResolver = ( - hostId: number, -) => number | undefined | Promise; - -export interface HostStatusDeps { - loadTargets: (filter: { - userId?: string; - hostIds?: number[]; - }) => Promise; - /** Hosts other users shared with this one, directly or through a role. */ - loadSharedHostIds?: (userId: string) => Promise; - ping: (host: string, port: number) => Promise; - pingThroughJumpHosts: ( - target: StatusTarget, - port: number, - ) => Promise; - hasActiveSession?: (hostId: number) => boolean; - globalInterval: () => number; - emit: (payload: HostStatusPayload) => void; - /** Pause before a failed ping is tried again. */ - retryDelayMs?: number; -} - -function parseJumpHosts(raw: string | null): Array<{ hostId: number }> { - if (!raw) return []; - try { - const parsed = JSON.parse(raw); - return Array.isArray(parsed) - ? parsed.filter( - (hop): hop is { hostId: number } => - !!hop && Number.isInteger((hop as { hostId?: unknown }).hostId), - ) - : []; - } catch { - return []; - } -} - -export function toStatusTarget(row: HostStatusTargetRow): StatusTarget { - return { - id: row.id, - userId: row.userId, - ip: row.ip, - port: row.port, - connectionType: row.connectionType || "ssh", - jumpHosts: parseJumpHosts(row.jumpHosts), - statusCheckEnabled: row.statusCheckEnabled !== false, - statusCheckInterval: row.statusCheckInterval ?? null, - }; -} - -const jumpProbes = new JumpProbePool(async (jumpHosts, userId) => { - const { createJumpHostChain } = await import("../jump-host-chain.js"); - return createJumpHostChain(jumpHosts, userId); -}); - -const defaultDeps: HostStatusDeps = { - loadTargets: async (filter) => - ( - await createCurrentHostResolutionRepository().listStatusTargets(filter) - ).map(toStatusTarget), - loadSharedHostIds: async (userId) => { - const { createCurrentRbacAccessRepository, createCurrentRoleRepository } = - await import("../../database/repositories/factory.js"); - const roleIds = await createCurrentRoleRepository().listUserRoleIds(userId); - const entries = - await createCurrentRbacAccessRepository().listVisibleHostAccessEntries( - userId, - roleIds, - ); - return [...new Set(entries.map((entry) => entry.hostId))]; - }, - hasActiveSession: (hostId) => hostSessionStatus.hasActiveSession(hostId), - ping: (host, port) => tcpPing(host, port, 5000), - pingThroughJumpHosts: (target, port) => - jumpProbes.ping(target.jumpHosts, target.userId, target.ip, port, 5000), - globalInterval: () => { - const value = Number(getCurrentSettingValue(GLOBAL_STATUS_INTERVAL_KEY)); - return Number.isInteger(value) && value >= 5 - ? value - : DEFAULT_STATUS_INTERVAL; - }, - emit: (payload) => pluginEvents.emit(TOPICS.hostStatus, payload), -}; - -interface Polled { - target: StatusTarget; - timer?: NodeJS.Timeout; -} - -export class HostStatusService { - private readonly polled = new Map(); - private readonly store = new Map(); - private readonly owners = new Map(); - private readonly inFlight = new Map>(); - private readonly startedUsers = new Set(); - private readonly portResolvers = new Map>(); - private readonly limiter = new ConcurrentLimiter(20); - private unsubscribers: Array<() => void> = []; - - constructor(private readonly deps: HostStatusDeps = defaultDeps) {} - - /** Subscribes to the core events the status depends on. */ - start(): void { - if (this.unsubscribers.length > 0) return; - this.unsubscribers = [ - hostSessionStatus.subscribe((hostId, online) => { - if (online) this.reportLogin(hostId, { ok: true }); - }), - pluginEvents.on(TOPICS.hostUpdated, (payload) => { - const { hostId } = payload as { hostId?: number }; - if (hostId) void this.reload(hostId); - }), - pluginEvents.on(TOPICS.hostDeleted, (payload) => { - const { hostId } = payload as { hostId?: number }; - if (hostId) this.forget(hostId); - }), - ]; - } - - stop(): void { - for (const unsubscribe of this.unsubscribers) unsubscribe(); - this.unsubscribers = []; - for (const hostId of [...this.polled.keys()]) this.stopPolling(hostId); - this.startedUsers.clear(); - } - - get(hostId: number): HostStatusEntry | null { - return this.store.get(hostId) ?? null; - } - - /** - * Statuses for a user's request. With hostIds, polling is brought in line - * with exactly those of their own hosts (the desktop app sends only the - * hosts that live on this backend). Without, their own hosts are started - * once. - */ - async statusesFor( - userId: string, - requestedHostIds: Set | null, - ): Promise> { - const started: Promise[] = []; - if (requestedHostIds !== null) { - await this.reconcile(userId, requestedHostIds, started); - } else if (!this.startedUsers.has(userId)) { - await this.startUser(userId, started); - } - if (started.length > 0) await this.waitFor(started); - return this.store; - } - - /** Drops every timer for a user's hosts and starts them again. */ - async refresh(userId: string): Promise { - const targets = await this.deps.loadTargets({ userId }); - for (const target of targets) this.stopPolling(target.id); - for (const target of targets) this.startPolling(target); - this.startedUsers.add(userId); - } - - /** Re-times every polled host, after the global interval changed. */ - retimeAll(): void { - for (const { target } of [...this.polled.values()]) { - this.startPolling(target, false); - } - } - - /** Checks now unless the last result is fresh. */ - async check(hostId: number): Promise { - const current = this.store.get(hostId); - if (current && Date.now() - Date.parse(current.lastChecked) < FRESH_MS) { - return current; - } - const target = - this.polled.get(hostId)?.target ?? - (await this.deps.loadTargets({ hostIds: [hostId] }))[0]; - if (!target || !target.statusCheckEnabled) return current ?? null; - await this.probe(target); - return this.store.get(hostId) ?? null; - } - - /** - * A working login proves the host is up. A failed one says nothing about - * reachability (a wrong password, another user's login), so it is ignored. - */ - reportLogin(hostId: number, outcome: { ok: boolean }): void { - if (outcome.ok) this.set(hostId, { status: "online" }); - } - - registerPort(connectionType: string, resolve: PortResolver): () => void { - let set = this.portResolvers.get(connectionType); - if (!set) { - set = new Set(); - this.portResolvers.set(connectionType, set); - } - set.add(resolve); - return () => { - set!.delete(resolve); - if (set!.size === 0) this.portResolvers.delete(connectionType); - }; - } - - private async waitFor(started: Promise[]): Promise { - let timer: NodeJS.Timeout | undefined; - await Promise.race([ - Promise.allSettled(started), - new Promise((resolve) => { - timer = setTimeout(resolve, FIRST_CHECK_WAIT_MS); - timer.unref?.(); - }), - ]); - if (timer) clearTimeout(timer); - } - - private track(started: Promise[], target: StatusTarget): void { - const probe = this.startPolling(target); - if (probe) started.push(probe); - } - - private async startUser( - userId: string, - started: Promise[], - ): Promise { - this.startedUsers.add(userId); - const targets = await this.deps.loadTargets({ userId }); - for (const target of targets) { - if (!this.polled.has(target.id)) this.track(started, target); - } - await this.startShared(userId, null, started); - } - - /** - * Hosts shared with the user are checked as their owners, even when no - * owner has asked for statuses since the server started. - */ - private async startShared( - userId: string, - allowed: Set | null, - started: Promise[], - ): Promise { - if (!this.deps.loadSharedHostIds) return; - try { - const shared = (await this.deps.loadSharedHostIds(userId)).filter( - (hostId) => - !this.polled.has(hostId) && (allowed === null || allowed.has(hostId)), - ); - if (shared.length === 0) return; - const targets = await this.deps.loadTargets({ hostIds: shared }); - for (const target of targets) { - if (!this.polled.has(target.id)) this.track(started, target); - } - } catch (error) { - sshLogger.warn("Could not start status checks for shared hosts", { - operation: "host_status_shared", - userId, - error: error instanceof Error ? error.message : String(error), - }); - } - } - - private async reconcile( - userId: string, - allowed: Set, - started: Promise[], - ): Promise { - const targets = await this.deps.loadTargets({ userId }); - for (const target of targets) { - if (!allowed.has(target.id)) { - this.stopPolling(target.id); - this.store.delete(target.id); - continue; - } - if (!this.polled.has(target.id)) this.track(started, target); - } - await this.startShared(userId, allowed, started); - } - - private async reload(hostId: number): Promise { - try { - const [target] = await this.deps.loadTargets({ hostIds: [hostId] }); - if (!target) { - this.forget(hostId); - return; - } - if (this.polled.has(hostId) || this.startedUsers.has(target.userId)) { - this.startPolling(target); - } - } catch (error) { - sshLogger.warn("Could not reload host for status checks", { - operation: "host_status_reload", - hostId, - error: error instanceof Error ? error.message : String(error), - }); - } - } - - private forget(hostId: number): void { - this.stopPolling(hostId); - this.store.delete(hostId); - this.owners.delete(hostId); - } - - private intervalMs(target: StatusTarget): number { - const seconds = target.statusCheckInterval ?? this.deps.globalInterval(); - const intervalMs = Math.max(5, seconds) * 1000; - // Spread hosts so a fleet does not fire on the same second. - const spread = Math.min(intervalMs * 0.2, 15_000); - return ( - intervalMs + ((target.id * 1103515245) % Math.max(1, Math.floor(spread))) - ); - } - - /** Returns the first probe when one was started. */ - private startPolling( - target: StatusTarget, - probeNow = true, - ): Promise | undefined { - this.stopPolling(target.id); - this.owners.set(target.id, target.userId); - if (!target.statusCheckEnabled) { - this.store.delete(target.id); - return undefined; - } - const polled: Polled = { target }; - this.polled.set(target.id, polled); - const first = probeNow ? this.probe(target) : undefined; - polled.timer = setInterval(() => { - const latest = this.polled.get(target.id); - if (latest) void this.probe(latest.target); - }, this.intervalMs(target)); - polled.timer.unref?.(); - return first; - } - - private stopPolling(hostId: number): void { - const polled = this.polled.get(hostId); - if (polled?.timer) clearInterval(polled.timer); - this.polled.delete(hostId); - } - - private probe(target: StatusTarget): Promise { - const running = this.inFlight.get(target.id); - if (running) return running; - const job = this.limiter - .run(() => this.probeNow(target)) - .catch((error) => { - sshLogger.error("Status check failed", error, { - operation: "host_status_probe", - hostId: target.id, - }); - }) - .finally(() => this.inFlight.delete(target.id)); - this.inFlight.set(target.id, job); - return job; - } - - private async portFor(target: StatusTarget): Promise { - if (target.connectionType !== "ssh") { - for (const resolve of this.portResolvers.get(target.connectionType) ?? - []) { - try { - const port = await resolve(target.id); - if (Number.isInteger(port) && port! > 0) return port!; - } catch { - // fall through to the next resolver - } - } - } - return target.port; - } - - private async pingOnce(target: StatusTarget): Promise { - try { - if (this.deps.hasActiveSession?.(target.id)) return true; - const port = await this.portFor(target); - return target.jumpHosts.length > 0 - ? await this.deps.pingThroughJumpHosts(target, port) - : await this.deps.ping(target.ip, port); - } catch { - return false; - } - } - - private async probeNow(target: StatusTarget): Promise { - this.owners.set(target.id, target.userId); - let reachable = await this.pingOnce(target); - if (!reachable) { - // One lost packet should not flip the dot. - const delay = this.deps.retryDelayMs ?? 1000; - if (delay > 0) await new Promise((resolve) => setTimeout(resolve, delay)); - reachable = await this.pingOnce(target); - } - this.set(target.id, { status: reachable ? "online" : "offline" }); - } - - private set(hostId: number, entry: Omit) { - const previous = this.store.get(hostId)?.status ?? null; - const next: HostStatusEntry = { - ...entry, - lastChecked: new Date().toISOString(), - }; - this.store.set(hostId, next); - if (previous === next.status) return; - void this.emitChange(hostId, next, previous); - } - - private async emitChange( - hostId: number, - entry: HostStatusEntry, - previous: HostStatus | null, - ): Promise { - let ownerUserId = this.owners.get(hostId); - if (!ownerUserId) { - const [target] = await this.deps - .loadTargets({ hostIds: [hostId] }) - .catch(() => []); - if (!target) return; - ownerUserId = target.userId; - this.owners.set(hostId, ownerUserId); - } - this.deps.emit({ - hostId, - ownerUserId, - status: entry.status, - previous, - online: entry.status === "online", - }); - } -} - -export const hostStatusService = new HostStatusService(); diff --git a/src/backend/hosts/status/host-status.ts b/src/backend/hosts/status/host-status.ts deleted file mode 100644 index 5e045910a..000000000 --- a/src/backend/hosts/status/host-status.ts +++ /dev/null @@ -1,9 +0,0 @@ -export type HostStatus = "online" | "offline"; - -export function isHostKeyVerificationError(error: unknown): boolean { - return ( - error instanceof Error && - (error.message.includes("Host denied (verification failed)") || - error.message.includes("Host key changed")) - ); -} diff --git a/src/backend/hosts/status/limiter.ts b/src/backend/hosts/status/limiter.ts deleted file mode 100644 index c1d1321b8..000000000 --- a/src/backend/hosts/status/limiter.ts +++ /dev/null @@ -1,81 +0,0 @@ -/** - * Limits how many async jobs run at once. Extra callers wait in FIFO order. - */ -export class ConcurrentLimiter { - private active = 0; - private readonly waiters: Array<() => void> = []; - - constructor(private maxConcurrent: number) { - if (maxConcurrent < 1) { - throw new Error("maxConcurrent must be >= 1"); - } - } - - get activeCount(): number { - return this.active; - } - - get pendingCount(): number { - return this.waiters.length; - } - - get limit(): number { - return this.maxConcurrent; - } - - /** - * Changes the ceiling at runtime. - * - * Raising it wakes the waiters the new headroom allows, so a queue that built - * up under the old limit drains at once instead of one job at a time as - * running work finishes. Lowering it never interrupts work already running; - * the new limit simply applies from the next release onward. - */ - setLimit(maxConcurrent: number): void { - if (maxConcurrent < 1) { - throw new Error("maxConcurrent must be >= 1"); - } - this.maxConcurrent = maxConcurrent; - this.releaseWaiters(); - } - - /** - * Wakes as many queued callers as there is now room for. - * - * `woken` counts callers that have been resumed but have not yet reached the - * `active += 1` on the far side of their await. Without it the occupancy - * looks lower than it really is for a microtask, and the loop would release - * past the ceiling. - */ - private releaseWaiters(): void { - while ( - this.active + this.woken < this.maxConcurrent && - this.waiters.length > 0 - ) { - this.woken += 1; - this.waiters.shift()!(); - } - } - - private woken = 0; - - async run(fn: () => Promise): Promise { - // Queue behind anything already woken, so a caller arriving mid-handoff - // cannot jump the queue and push occupancy past the ceiling. - if (this.active + this.woken >= this.maxConcurrent) { - await new Promise((resolve) => { - this.waiters.push(resolve); - }); - // Resumed by a slot handoff; that reservation is now consumed. - this.woken = Math.max(0, this.woken - 1); - } - - this.active += 1; - try { - return await fn(); - } finally { - this.active -= 1; - this.releaseWaiters(); - } - } -} diff --git a/src/backend/plugins/activity-provider.ts b/src/backend/plugins/activity-provider.ts new file mode 100644 index 000000000..27ed80084 --- /dev/null +++ b/src/backend/plugins/activity-provider.ts @@ -0,0 +1,105 @@ +/** + * Recent activity is a provider: core owns the callers (the dashboard routes, + * ctx.hosts.recordActivity, the cleanup when a user or host goes away) and one + * provider owns the storage. A plugin registers with + * ctx.hosts.activity.provide. With none, nothing is recorded and the list is + * empty. Core checks that the user may reach the host before it records. + */ + +export interface ActivityEntry { + id: number; + userId: string; + type: string; + hostId: number; + hostName: string | null; + timestamp: string; +} + +export type RecordActivityResult = + | { status: "logged"; id: number } + | { status: "rate_limited" } + | { status: "invalid_type" } + | { status: "denied" } + | { status: "unavailable" }; + +export interface ActivityProvider { + record( + userId: string, + entry: { type: string; hostId: number; hostName: string }, + ): Promise; + list(userId: string, limit: number): Promise; + deleteByUserId(userId: string): Promise; + deleteByHostIds(hostIds: number[]): Promise; +} + +let plugged: { pluginId: string; provider: ActivityProvider } | null = null; + +export function provideActivity( + pluginId: string, + provider: ActivityProvider, +): () => void { + if (plugged) { + throw new Error( + `Recent activity is already provided by plugin "${plugged.pluginId}"`, + ); + } + const entry = { pluginId, provider }; + plugged = entry; + return () => { + if (plugged === entry) plugged = null; + }; +} + +function active(): ActivityProvider | null { + return plugged?.provider ?? null; +} + +async function canReachHost(userId: string, hostId: number): Promise { + const { + createCurrentHostResolutionRepository, + createCurrentRbacAccessRepository, + createCurrentRoleRepository, + } = await import("../database/repositories/factory.js"); + if ( + await createCurrentHostResolutionRepository().isHostOwnedByUser( + hostId, + userId, + ) + ) { + return true; + } + const roleIds = await createCurrentRoleRepository().listUserRoleIds(userId); + const shared = + await createCurrentRbacAccessRepository().listVisibleHostAccessEntries( + userId, + roleIds, + ); + return shared.some((access) => access.hostId === hostId); +} + +/** What the rest of core calls. */ +export const recentActivity = { + async record( + userId: string, + entry: { type: string; hostId: number; hostName: string }, + ): Promise { + const provider = active(); + if (!provider) return { status: "unavailable" }; + if (!(await canReachHost(userId, entry.hostId))) { + return { status: "denied" }; + } + return provider.record(userId, entry); + }, + async list(userId: string, limit: number): Promise { + return (await active()?.list(userId, limit)) ?? []; + }, + async deleteByUserId(userId: string): Promise { + await active()?.deleteByUserId(userId); + }, + async deleteByHostId(hostId: number): Promise { + await active()?.deleteByHostIds([hostId]); + }, + async deleteByHostIds(hostIds: number[]): Promise { + await active()?.deleteByHostIds(hostIds); + }, +}; diff --git a/src/backend/plugins/assets.ts b/src/backend/plugins/assets.ts index 27c978b3f..44b55b683 100644 --- a/src/backend/plugins/assets.ts +++ b/src/backend/plugins/assets.ts @@ -1,4 +1,5 @@ -// Serves a plugin's built frontend: its bundle, stylesheet and locales. +// Serves a plugin's built frontend: its bundle, stylesheet, locales and the +// CSS files its themes declare. // // Unauthenticated on purpose, the same as /assets: import() cannot carry the // bearer header, and nothing here is secret. It only ever serves files from @@ -28,6 +29,22 @@ export interface PluginFrontendInfo { assetVersion: string | null; /** Language codes with a locale file, "en" plus xx_YY names. */ locales: string[]; + /** Changes whenever a theme's CSS does, for its ?v= link. */ + themeVersion: string | null; +} + +/** Where a theme's CSS is served: /plugin-assets//_themes/.css. */ +export const THEME_ASSET_PREFIX = "_themes/"; + +/** The declared CSS file of each of the plugin's themes, by theme id. */ +function themeFiles(plugin: AssetPlugin): Map { + const files = new Map(); + const manifest = plugin.manifest as PluginManifest; + for (const theme of manifest.contributes?.themes ?? []) { + const file = path.resolve(plugin.dir, theme.css); + if (isInside(plugin.dir, file)) files.set(theme.id, file); + } + return files; } function frontendEntry( @@ -72,9 +89,16 @@ export function describePluginFrontend( css: false, assetVersion: null, locales: [], + themeVersion: null, }; if (!plugin) return none; + const themes = [...themeFiles(plugin).values()].filter(fileExists); + const themeVersion = + themes.length > 0 + ? contentVersion(themes.sort(), plugin.manifest.version) + : null; + const locales: string[] = []; const localeFiles: string[] = []; const dir = localesDir(plugin); @@ -96,7 +120,8 @@ export function describePluginFrontend( } const entry = frontendEntry(plugin); - if (!entry || !fileExists(entry.file)) return { ...none, locales }; + if (!entry || !fileExists(entry.file)) + return { ...none, locales, themeVersion }; const cssFile = path.join(entry.dir, "frontend.css"); const css = fileExists(cssFile); @@ -111,6 +136,7 @@ export function describePluginFrontend( css, assetVersion, locales: locales.sort(), + themeVersion, }; } @@ -158,7 +184,14 @@ export function resolvePluginAsset( let root: string | null; let rest: string; - if (clean.startsWith("locales/")) { + if (clean.startsWith(THEME_ASSET_PREFIX)) { + // Only a declared theme file, which the loader checked on load. + const themeId = clean.slice(THEME_ASSET_PREFIX.length, -".css".length); + const file = themeFiles(plugin).get(themeId); + if (!file || !clean.endsWith(".css")) return null; + root = path.dirname(file); + rest = path.basename(file); + } else if (clean.startsWith("locales/")) { root = localesDir(plugin); rest = clean.slice("locales/".length); } else { diff --git a/src/backend/plugins/bundled-index.ts b/src/backend/plugins/bundled-index.ts index 4b5d785a9..46333cf8c 100644 --- a/src/backend/plugins/bundled-index.ts +++ b/src/backend/plugins/bundled-index.ts @@ -1,5 +1,5 @@ /** - * The onboarding defaults of the bundled plugins, from + * The onboarding defaults and the default look of the bundled plugins, from * dist/plugins/bundled-index.json (written by scripts/build-plugins.cjs from * docker/bundled-plugins.json). Core never names plugins, so which ones a * fresh install keeps lives in that file and not here. @@ -27,6 +27,7 @@ interface BundledIndex { consent?: boolean; enabledByEnv?: string[]; }; + defaultLook?: boolean; } >; } @@ -52,6 +53,23 @@ export function readBundledIndex(): BundledIndex | null { return cached; } +/** + * Bundled plugins whose layout and theme a fresh install shows. Core reads + * the default look from here so it never has to name a plugin. + */ +export function defaultLookPluginIds(): string[] { + const index = readBundledIndex(); + if (!index) return []; + return Object.entries(index.plugins) + .filter(([, entry]) => entry?.defaultLook === true) + .map(([id]) => id) + .sort(); +} + +export function isDefaultLookPlugin(pluginId: string): boolean { + return defaultLookPluginIds().includes(pluginId); +} + export function resetBundledIndexCache(): void { cached = undefined; } @@ -96,6 +114,8 @@ export function initialPluginState( ): "enabled" | "disabled" { if (source !== "bundled") return "disabled"; if (!freshInstall) return "enabled"; + // The default look is what the app is drawn with. + if (isDefaultLookPlugin(pluginId)) return "enabled"; const { recommended, consent } = onboardingDefaults(pluginId); return recommended && !consent ? "enabled" : "disabled"; } diff --git a/src/backend/plugins/ctx-audit.ts b/src/backend/plugins/ctx-audit.ts new file mode 100644 index 000000000..f14eee301 --- /dev/null +++ b/src/backend/plugins/ctx-audit.ts @@ -0,0 +1,143 @@ +/** + * ctx.audit.subscribe, list and exportBatches: reading the audit trail, + * behind audit:read. Writing lines (record) stays in ctx.ts. + */ + +import type { + PluginAudit, + PluginAuditEntry, + PluginAuditFilters, +} from "@termix-ssh/plugin-sdk/backend"; +import type { PluginManifest } from "@termix-ssh/plugin-sdk/manifest"; +import { assertCapability, capabilityRefused } from "./permissions.js"; +import type { DisposableBag } from "./disposables.js"; +import { onAuditEntry } from "../utils/audit-hooks.js"; +import type { AuditLogRecord } from "../database/repositories/audit-log-repository.js"; + +type AuditFn = ( + action: string, + details: string, + outcome: { success: boolean; errorMessage?: string }, +) => Promise; + +interface Deps { + manifest: PluginManifest; + bag: DisposableBag; + audit: AuditFn; +} + +const MAX_PAGE = 200; +const MAX_BATCH = 1000; + +function toEntry(row: AuditLogRecord): PluginAuditEntry { + return { + id: row.id, + timestamp: new Date( + row.timestamp as unknown as string | number, + ).toISOString(), + userId: row.userId ?? null, + username: row.username, + action: row.action, + resourceType: row.resourceType, + resourceId: row.resourceId ?? null, + resourceName: row.resourceName ?? null, + details: row.details ?? null, + ipAddress: row.ipAddress ?? null, + userAgent: row.userAgent ?? null, + success: Boolean(row.success), + errorMessage: row.errorMessage ?? null, + }; +} + +export function createPluginAuditReader( + deps: Deps, +): Pick { + const { manifest, bag, audit } = deps; + const require = (action: string) => + assertCapability(manifest.id, "audit:read", manifest.capabilities, action); + + return { + subscribe: (handler) => { + if (!manifest.capabilities.includes("audit:read")) { + throw capabilityRefused(manifest.id, "audit:read", "audit_subscribe"); + } + let remove: (() => void) | null = null; + let forgetBag: (() => void) | null = null; + // Granted state is checked once, then every line is delivered until + // unsubscribed; a revoked grant stops the next subscribe. + void require("audit_subscribe") + .then(async () => { + await audit("audit_subscribe", "listened to the audit log", { + success: true, + }); + remove = onAuditEntry((entry) => { + handler({ + timestamp: entry.timestamp.toISOString(), + userId: entry.userId, + username: entry.username, + action: entry.action, + resourceType: entry.resourceType, + resourceId: entry.resourceId ?? null, + resourceName: entry.resourceName ?? null, + details: entry.details ?? null, + ipAddress: entry.ipAddress ?? null, + userAgent: entry.userAgent ?? null, + success: entry.success, + errorMessage: entry.errorMessage ?? null, + }); + }); + }) + .catch(() => {}); + forgetBag = bag.add(() => remove?.(), "audit subscription"); + return () => { + remove?.(); + remove = null; + forgetBag?.(); + }; + }, + + list: async (options) => { + await require("audit_list"); + const { createCurrentAuditLogRepository } = + await import("../database/repositories/factory.js"); + const limit = Math.min(MAX_PAGE, Math.max(1, options?.limit ?? 50)); + const page = await createCurrentAuditLogRepository().listPage({ + filters: options?.filters ?? {}, + limit, + offset: Math.max(0, options?.offset ?? 0), + }); + await audit("audit_list", "read audit log entries", { success: true }); + return { entries: page.logs.map(toEntry), total: page.total }; + }, + + exportBatches: async function* ( + filters?: PluginAuditFilters, + batchSize?: number, + ) { + await require("audit_export"); + const { createCurrentAuditLogRepository } = + await import("../database/repositories/factory.js"); + const limit = Math.min(MAX_BATCH, Math.max(1, batchSize ?? 500)); + let offset = 0; + let exported = 0; + try { + for (;;) { + const rows = await createCurrentAuditLogRepository().listForExport({ + filters: filters ?? {}, + limit, + offset, + }); + if (rows.length === 0) break; + exported += rows.length; + offset += rows.length; + yield rows.map(toEntry); + if (rows.length < limit) break; + } + } finally { + await audit("audit_export", `exported ${exported} audit log entries`, { + success: true, + }); + } + }, + }; +} diff --git a/src/backend/plugins/ctx-credentials.ts b/src/backend/plugins/ctx-credentials.ts index 8b3519151..ed2fdd7a7 100644 --- a/src/backend/plugins/ctx-credentials.ts +++ b/src/backend/plugins/ctx-credentials.ts @@ -150,6 +150,37 @@ export function createPluginCredentials({ } }, + list: async () => { + try { + await assertCapability(pluginId, "credentials:use", declared); + const userId = actingUser(); + await requireCorePermission(userId, "credentials.view"); + const { createCurrentCredentialRepository } = + await import("../database/repositories/factory.js"); + const rows = + await createCurrentCredentialRepository().listDecryptedByUserId( + userId, + ); + const list = rows.map((row) => ({ + id: row.id, + name: row.name, + username: row.username ?? null, + authType: String(row.authType ?? ""), + folder: row.folder ?? null, + })); + await audit("credentials_list", `${list.length} credentials`, { + success: true, + }); + return list; + } catch (error) { + await audit("credentials_list", "saved credentials", { + success: false, + errorMessage: errorText(error), + }); + throw error; + } + }, + createSshKey: async (input) => { const details = `SSH key "${input?.name ?? ""}"`; try { @@ -194,6 +225,74 @@ export function createPluginCredentials({ } }, + publicKey: async (credentialId) => { + const details = `public key of credential ${credentialId}`; + try { + await assertCapability(pluginId, "credentials:use", declared); + const userId = actingUser(); + await requireCorePermission(userId, "credentials.view"); + if (!Number.isInteger(credentialId) || credentialId <= 0) { + throw new Error("A credential id is required"); + } + const { createCurrentHostResolutionRepository } = + await import("../database/repositories/factory.js"); + const credential = + await createCurrentHostResolutionRepository().findCredentialByIdForUser( + credentialId, + userId, + ); + const row = credential as unknown as Record | null; + if (!row || row.authType !== "key") { + await audit("credentials_public_key", details, { success: true }); + return null; + } + let publicKey = + typeof row.publicKey === "string" && row.publicKey.trim() + ? row.publicKey.trim() + : null; + if (!publicKey) { + const stored = str(row.privateKey) || str(row.key); + if (stored) { + const { resolveKeyReferences } = + await import("../hosts/stored-ssh-key.js"); + const resolved = await resolveKeyReferences( + userId, + stored, + typeof row.keyPassword === "string" ? row.keyPassword : null, + ); + publicKey = derivePublicKey(resolved.key, resolved.passphrase); + } + } + await audit("credentials_public_key", details, { success: true }); + return publicKey; + } catch (error) { + await audit("credentials_public_key", details, { + success: false, + errorMessage: errorText(error), + }); + throw error; + } + }, + + resolveKeyRefs: async (key, passphrase) => { + const details = "resolved key references"; + try { + await assertCapability(pluginId, "credentials:read", declared); + const userId = actingUser(); + const { resolveKeyReferences } = + await import("../hosts/stored-ssh-key.js"); + const resolved = await resolveKeyReferences(userId, key, passphrase); + await audit("credentials_resolve_key", details, { success: true }); + return resolved; + } catch (error) { + await audit("credentials_resolve_key", details, { + success: false, + errorMessage: errorText(error), + }); + throw error; + } + }, + registerSecretResolver: (scheme, resolve) => { if (!declared.includes("auth:provide")) { throw capabilityRefused(pluginId, "auth:provide"); diff --git a/src/backend/plugins/ctx-desktop-local.ts b/src/backend/plugins/ctx-desktop-local.ts new file mode 100644 index 000000000..eed6c3066 --- /dev/null +++ b/src/backend/plugins/ctx-desktop-local.ts @@ -0,0 +1,260 @@ +/** + * ctx.desktop.local: the user's own machine, behind desktop:local. + * + * Only works on the desktop app's embedded backend. On a server every call + * throws, and available() is false. Path rules live in desktop-local/local-fs. + */ + +import type { + PluginDesktopLocal, + PluginLocalPty, +} from "@termix-ssh/plugin-sdk/backend"; +import type { PluginManifest } from "@termix-ssh/plugin-sdk/manifest"; +import { assertCapability } from "./permissions.js"; +import type { DisposableBag } from "./disposables.js"; +import * as localFs from "./desktop-local/local-fs.js"; +import { remoteJson, requestHeaders } from "../sync/client/http.js"; +import { spawnLocalPty } from "./desktop-local/local-pty.js"; + +type AuditFn = ( + action: string, + details: string, + outcome: { success: boolean; errorMessage?: string }, +) => Promise; + +interface Deps { + manifest: PluginManifest; + bag: DisposableBag; + audit: AuditFn; +} + +function isEmbedded(): boolean { + return process.env.ELECTRON_EMBEDDED === "true"; +} + +export function createPluginDesktopLocal(deps: Deps): PluginDesktopLocal { + const { manifest, bag, audit } = deps; + const audited = new Set(); + + const guard = async (action: string, details: string, every = false) => { + await assertCapability( + manifest.id, + "desktop:local", + manifest.capabilities, + action, + ); + if (!isEmbedded()) { + throw new Error( + `Plugin ${manifest.id} used the local machine outside the desktop app`, + ); + } + if (every || !audited.has(action)) { + audited.add(action); + await audit(action, details, { success: true }); + } + }; + + const mainRequest = async (channel: string, path: string) => { + const { requestFromElectronMain } = + await import("../utils/electron-ipc-bridge.js"); + return requestFromElectronMain<{ success: boolean; error?: string }>( + channel, + { path }, + ); + }; + + return { + available: () => + isEmbedded() && manifest.capabilities.includes("desktop:local"), + + pluginFetch: async (origin, subPath, init) => { + await guard("desktop_local_fetch", "called its own routes"); + if ( + typeof subPath !== "string" || + !subPath.startsWith("/") || + subPath.startsWith("//") || + subPath.includes("\\") || + /(^|\/)\.\.?(\/|\?|$)/.test(subPath) + ) { + throw new Error("Invalid plugin route path"); + } + const route = `/plugin-api/${manifest.id}${subPath}`; + const headers: Record = { ...init?.headers }; + for (const name of Object.keys(headers)) { + if (/^(authorization|cookie|host)$/i.test(name)) delete headers[name]; + } + if (origin === "remote") { + const { getLink } = await import("../sync/client/link-store.js"); + const link = await getLink(); + if (!link) throw new Error("This device is not linked to a server"); + const { remoteStreamFetch } = await import("../sync/client/http.js"); + return (await remoteStreamFetch(link, route, { + method: init?.method, + headers, + body: init?.body, + signal: init?.signal, + })) as never; + } + if (origin !== "local") throw new Error("Unknown origin"); + const { getActor } = await import("./actor.js"); + const actor = getActor(); + if (!actor) throw new Error("No acting user"); + const { AuthManager } = await import("../utils/auth-manager.js"); + const session = await AuthManager.getInstance().issuePluginSession( + actor, + { + pluginId: manifest.id, + purpose: "desktop local transfer", + ttlSeconds: 900, + }, + ); + const port = Number(process.env.EMBEDDED_BACKEND_PORT) || 30001; + const { fetch: undiciFetch } = await import("undici"); + return (await undiciFetch(`http://127.0.0.1:${port}${route}`, { + method: init?.method ?? "GET", + headers: { + ...headers, + "X-Electron-App": "true", + Authorization: `Bearer ${session.token}`, + }, + body: init?.body as never, + duplex: "half", + signal: init?.signal, + } as never)) as never; + }, + + info: async () => { + await guard("desktop_local_info", "read local machine info"); + const home = await localFs.homeInfo(); + const { getLink } = await import("../sync/client/link-store.js"); + const link = await getLink(); + return { + ...home, + userDataDir: process.env.ELECTRON_USER_DATA ?? "", + backendPort: Number(process.env.EMBEDDED_BACKEND_PORT) || 30001, + link: link + ? { + serverUrl: link.serverUrl, + token: link.sessionToken, + headers: requestHeaders(link), + allowInvalidCertificate: !!link.allowInvalidCertificate, + } + : null, + }; + }, + + linkJson: async (pathname) => { + await guard("desktop_local_link", "called the linked server"); + const { getLink } = await import("../sync/client/link-store.js"); + const link = await getLink(); + if (!link) + throw new Error("This device is not linked to a Termix server"); + if (!pathname.startsWith("/")) throw new Error("Invalid path"); + return remoteJson( + { + serverUrl: link.serverUrl, + sessionToken: link.sessionToken, + customHeaders: link.customHeaders, + basicAuth: link.basicAuth, + allowInvalidCertificate: link.allowInvalidCertificate, + clientCert: link.clientCert, + }, + pathname, + ); + }, + + fs: { + list: async (dirPath) => { + await guard("desktop_local_fs", "used the local file system"); + return localFs.listDirectory(dirPath); + }, + mkdir: async (parentPath, name) => { + await guard("desktop_local_fs", "used the local file system"); + return localFs.makeDirectory(parentPath, name); + }, + createFile: async (parentPath, name) => { + await guard("desktop_local_fs", "used the local file system"); + return localFs.createEmptyFile(parentPath, name); + }, + rename: async (path, newName) => { + await guard("desktop_local_fs", "used the local file system"); + return localFs.renameEntry(path, newName); + }, + ensureDir: async (dirPath, rootPath) => { + await guard("desktop_local_fs", "used the local file system"); + return localFs.ensureDirectory(dirPath, rootPath); + }, + exists: async (paths) => { + await guard("desktop_local_fs", "used the local file system"); + return localFs.existingPaths(paths); + }, + walk: async (rootPaths) => { + await guard("desktop_local_fs", "used the local file system"); + if (!Array.isArray(rootPaths) || rootPaths.length === 0) { + throw new Error("No local paths provided"); + } + return localFs.walkPaths(rootPaths); + }, + trash: async (paths) => { + await guard( + "desktop_local_trash", + `moved ${Array.isArray(paths) ? paths.length : 0} local items to the trash`, + true, + ); + if (!Array.isArray(paths) || paths.length === 0) { + throw new Error("No local paths provided"); + } + const failed: Array<{ path: string; error: string }> = []; + for (const candidate of paths) { + const target = localFs.normalizeLocalPath(candidate); + try { + const result = await mainRequest("shell-trash-item", target); + if (!result?.success) throw new Error(result?.error ?? "Failed"); + } catch (error) { + failed.push({ + path: target, + error: error instanceof Error ? error.message : String(error), + }); + } + } + return { trashed: paths.length - failed.length, failed }; + }, + reveal: async (path) => { + await guard("desktop_local_fs", "used the local file system"); + await mainRequest("shell-show-item", localFs.normalizeLocalPath(path)); + }, + open: async (path) => { + await guard("desktop_local_open", "opened a local path", true); + const result = await mainRequest( + "shell-open-path", + localFs.normalizeLocalPath(path), + ); + if (!result?.success) throw new Error(result?.error ?? "Failed"); + }, + openRead: async (path) => { + await guard("desktop_local_fs", "used the local file system"); + return localFs.openRead(path); + }, + openDownload: async (options) => { + await guard("desktop_local_fs", "used the local file system"); + return localFs.openDownload(options); + }, + }, + + pty: { + spawn: async (options) => { + await guard("desktop_local_pty", "started a local shell", true); + const child: PluginLocalPty = spawnLocalPty(options); + // A shell never outlives the plugin that started it. + bag.add(() => { + try { + child.kill(); + } catch { + // already gone + } + }); + return child; + }, + }, + }; +} diff --git a/src/backend/plugins/ctx-host-import.ts b/src/backend/plugins/ctx-host-import.ts new file mode 100644 index 000000000..64f5528d2 --- /dev/null +++ b/src/backend/plugins/ctx-host-import.ts @@ -0,0 +1,192 @@ +/** + * ctx.hosts.importMany, importSshConfig, exportMany and registerImportNormalizer. + * + * The import and export logic is the same code the host routes run + * (hosts/import/), so a plugin gets the same validation, defaults, protocol + * logins and plugin settings. What this file adds is the capability checks, + * the core permission checks, one batched save and the audit lines. + */ + +import type { + PluginHostExport, + PluginHostImportNormalizer, + PluginHostImportOptions, + PluginHostImportResult, + PluginHosts, +} from "@termix-ssh/plugin-sdk/backend"; +import type { PluginManifest } from "@termix-ssh/plugin-sdk/manifest"; +import { assertCapability, capabilityRefused } from "./permissions.js"; +import { getActor } from "./actor.js"; +import * as registry from "./registry.js"; +import type { DisposableBag } from "./disposables.js"; +import { DatabaseSaveTrigger } from "../utils/database-save-trigger.js"; +import { PermissionManager } from "../utils/permission-manager.js"; + +type AuditFn = ( + action: string, + details: string, + outcome: { success: boolean; errorMessage?: string }, +) => Promise; + +interface Deps { + manifest: PluginManifest; + bag?: DisposableBag; + audit: AuditFn; +} + +type ImportApi = Pick< + PluginHosts, + "importMany" | "importSshConfig" | "exportMany" | "registerImportNormalizer" +>; + +function actingUser(): string { + const actor = getActor(); + if (!actor) throw new Error("This call needs an acting user"); + return actor; +} + +async function requirePermissions(userId: string, names: string[]) { + const permissions = PermissionManager.getInstance(); + for (const name of names) { + if (!(await permissions.hasPermission(userId, name))) { + throw new Error(`The acting user lacks the ${name} permission`); + } + } +} + +function toResult( + results: Awaited< + ReturnType + >, +): PluginHostImportResult { + return { + success: results.success, + updated: results.updated, + skipped: results.skipped, + failed: results.failed, + errors: results.errors, + createdHostIds: results.createdHostIds, + ...(results.rolledBack ? { rolledBack: true } : {}), + }; +} + +export function createPluginHostImport({ + manifest, + bag, + audit, +}: Deps): ImportApi { + const pluginId = manifest.id; + const declared = manifest.capabilities; + + const runImport = async ( + action: string, + run: (userId: string) => Promise, + needsCredentials: boolean, + ): Promise => { + try { + await assertCapability(pluginId, "hosts:write", declared, action); + if (needsCredentials) { + await assertCapability(pluginId, "credentials:write", declared, action); + } + const userId = actingUser(); + await requirePermissions(userId, ["hosts.create", "hosts.edit"]); + + const result = await DatabaseSaveTrigger.batched(() => run(userId))(); + await audit( + action, + `${result.success} created, ${result.updated} updated, ${result.skipped} skipped, ${result.failed} failed`, + { success: true }, + ); + return result; + } catch (error) { + await audit(action, "host import", { + success: false, + errorMessage: error instanceof Error ? error.message : String(error), + }); + throw error; + } + }; + + return { + importMany: (hosts, options?: PluginHostImportOptions) => + runImport( + "hosts_import", + async (userId) => { + const { runBulkImport } = + await import("../hosts/import/host-import.js"); + return toResult( + await runBulkImport(userId, { + hosts, + overwrite: options?.overwrite, + credentials: options?.credentials, + atomic: options?.atomic, + }), + ); + }, + Array.isArray(options?.credentials) && options.credentials.length > 0, + ), + + importSshConfig: (content, options) => + runImport( + "hosts_import_ssh_config", + async (userId) => { + const { runSshConfigImport } = + await import("../hosts/import/host-import.js"); + return toResult( + await runSshConfigImport(userId, { + content, + overwrite: options?.overwrite, + atomic: options?.atomic, + }), + ); + }, + false, + ), + + exportMany: async (options): Promise => { + const share = options?.share === true; + try { + await assertCapability( + pluginId, + "hosts:export", + declared, + "hosts_export", + ); + const userId = actingUser(); + const { exportAllHosts } = + await import("../hosts/import/host-export.js"); + const exported = await exportAllHosts(userId, share, options?.hostIds); + await audit( + "export_hosts", + `exported ${exported.hosts.length} hosts${share ? " without secrets" : " with secrets"}`, + { success: true }, + ); + return exported; + } catch (error) { + await audit("export_hosts", share ? "share export" : "full export", { + success: false, + errorMessage: error instanceof Error ? error.message : String(error), + }); + throw error; + } + }, + + registerImportNormalizer: (normalizer: PluginHostImportNormalizer) => { + if (!declared.includes("hosts:write")) { + throw capabilityRefused(pluginId, "hosts:write"); + } + // Same key the import path has always read, so the older + // ctx.registry.provide form keeps working. + const key = `${pluginId}.hostImportNormalizer`; + registry.provide(key, normalizer); + const remove = () => { + registry.revoke(key, normalizer); + }; + const drop = bag?.add(remove, "host import normalizer"); + return () => { + drop?.(); + remove(); + }; + }, + }; +} diff --git a/src/backend/plugins/ctx-hosts.ts b/src/backend/plugins/ctx-hosts.ts index c94ec7e71..6cd1095fb 100644 --- a/src/backend/plugins/ctx-hosts.ts +++ b/src/backend/plugins/ctx-hosts.ts @@ -20,18 +20,27 @@ import type { PluginShareableUser, PluginShareableRole, PluginHostJumpHost, + PluginHostShareGrant, } from "@termix-ssh/plugin-sdk/backend"; import { PLUGIN_HOST_INPUT_KEYS } from "@termix-ssh/plugin-sdk/backend"; import type { PluginManifest } from "@termix-ssh/plugin-sdk/manifest"; import { assertCapability, capabilityRefused } from "./permissions.js"; import { hostSessionStatus } from "../hosts/host-session-status.js"; import { getActor } from "./actor.js"; -import { hostStatusService } from "../hosts/status/host-status-service.js"; +import { hostStatus, provideHostStatus } from "./host-status-provider.js"; +import { provideActivity, recentActivity } from "./activity-provider.js"; +import { loadProbeTargets } from "./ctx-probe.js"; +import { createPluginHostImport } from "./ctx-host-import.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"; +import { + assertHostsUnlocked, + assertManualCreateAllowed, + provideHostLocks, +} from "../hosts/locks.js"; type AuditFn = ( action: string, @@ -171,7 +180,7 @@ export function toRecord( connectionOrigin: origin === "local" || origin === "remote" ? origin : null, sshOptions: hostTerminalExport(host).sshOptions, pluginSettings, - status: Number.isInteger(id) ? hostStatusService.get(id) : null, + status: Number.isInteger(id) ? hostStatus.get(id) : null, localOnly: asFlag(host.localOnly, false), createdAt: (host.createdAt as string | null) ?? null, updatedAt: (host.updatedAt as string | null) ?? null, @@ -204,6 +213,8 @@ void inputKeysComplete; export function toHostWrite(input: PluginHostUpdateInput): { row: Record; settings: Record>; + protocolAuth: Record | null; + pluginEnable: Record; } { const unknown = Object.keys(input ?? {}).filter( (key) => !INPUT_KEYS.has(key), @@ -265,6 +276,32 @@ export function toHostWrite(input: PluginHostUpdateInput): { ? null : JSON.stringify(parseSshOptions(input.sshOptions)), ); + for (const key of [ + "password", + "key", + "keyPassword", + "keyType", + "sudoPassword", + ] as const) { + const value = input[key]; + if (value !== undefined && value !== null && typeof value !== "string") { + throw new PluginHostInputError(`${key} must be a string`); + } + set(key, value === undefined ? undefined : value || null); + } + + let protocolAuth: Record | null = null; + if (input.protocolAuth !== undefined && input.protocolAuth !== null) { + if ( + typeof input.protocolAuth !== "object" || + Array.isArray(input.protocolAuth) + ) { + throw new PluginHostInputError( + "protocolAuth must map protocol ids to their logins", + ); + } + protocolAuth = input.protocolAuth as Record; + } const settings: Record> = {}; if (input.pluginSettings !== undefined && input.pluginSettings !== null) { @@ -285,7 +322,136 @@ export function toHostWrite(input: PluginHostUpdateInput): { settings[pluginId] = values; } } - return { row, settings }; + const pluginEnable: Record = {}; + if (input.pluginEnable !== undefined && input.pluginEnable !== null) { + if ( + typeof input.pluginEnable !== "object" || + Array.isArray(input.pluginEnable) + ) { + throw new PluginHostInputError( + "pluginEnable must map plugin ids to true or false", + ); + } + for (const [pluginId, enabled] of Object.entries(input.pluginEnable)) { + if (typeof enabled !== "boolean") { + throw new PluginHostInputError( + `pluginEnable.${pluginId} must be true or false`, + ); + } + pluginEnable[pluginId] = enabled; + } + } + return { row, settings, protocolAuth, pluginEnable }; +} + +/** Folds pluginEnable into each plugin's settings under its enableKey. */ +async function foldPluginEnable( + settings: Record>, + pluginEnable: Record, +): Promise>> { + const ids = Object.keys(pluginEnable); + if (ids.length === 0) return settings; + const { hostSettingsPlugins } = + await import("../database/routes/host-plugin-settings.js"); + const running = hostSettingsPlugins(); + const out = { ...settings }; + for (const pluginId of ids) { + const manifest = running.find((m) => m.id === pluginId); + if (!manifest) { + // checkHostPluginSettingsInput skips and logs a plugin that is not running. + out[pluginId] = { ...out[pluginId] }; + continue; + } + const enableKey = manifest.contributes?.settings?.host?.enableKey; + if (!enableKey) { + throw new PluginHostInputError(`${pluginId} has no host switch`); + } + out[pluginId] = { ...out[pluginId], [enableKey]: pluginEnable[pluginId] }; + } + return out; +} + +/** Writes a host's protocol logins; a credential the owner can't use is refused. */ +async function writeHostProtocolAuth( + ownerId: string, + hostId: number, + protocolAuth: Record | null, +): Promise { + if (!protocolAuth) return; + const { readProtocolAuthPayload, writeProtocolAuth } = + await import("../hosts/protocol-auth/protocol-auth.js"); + const patch = readProtocolAuthPayload({ protocolAuth }); + if (!patch) return; + const { findUsableCredential } = + await import("../hosts/usable-credential.js"); + for (const [protocol, login] of patch) { + const credentialId = Number(login?.credentialId); + if ( + Number.isInteger(credentialId) && + credentialId > 0 && + !(await findUsableCredential(credentialId, ownerId)) + ) { + throw new PluginHostInputError( + `protocolAuth.${protocol}: credential ${credentialId} not found`, + ); + } + } + await writeProtocolAuth(ownerId, hostId, patch, { isOwner: true }); +} + +const SHARE_LEVELS: readonly string[] = ["connect", "view", "edit", "manage"]; + +/** Upserts one grant and snapshots the host's secrets for it. */ +async function grantHostShare( + hostId: number, + ownerId: string, + grantedBy: string, + target: PluginShareTarget, + permissionLevel: PluginHostShareLevel, + expiresAt: string | null, +): Promise { + const { createCurrentRbacAccessRepository } = + await import("../database/repositories/factory.js"); + const grant = await createCurrentRbacAccessRepository().upsertHostAccess({ + hostId, + grantedBy, + permissionLevel, + expiresAt, + ...(target.type === "user" + ? { targetType: "user" as const, targetUserId: target.id as string } + : { targetType: "role" as const, targetRoleId: target.id as number }), + }); + + const { SharedHostSecretsManager } = + await import("../utils/shared-host-secrets-manager.js"); + const secretsManager = SharedHostSecretsManager.getInstance(); + try { + if (target.type === "user") { + await secretsManager.snapshotForUser( + grant.id, + hostId, + target.id as string, + ownerId, + ); + } else { + await secretsManager.snapshotForRole( + grant.id, + hostId, + target.id as number, + ownerId, + ); + } + } catch { + // A snapshot failure never blocks the grant: the recipient can + // still be prompted, or the owner can retry sharing later. + } + return grant.id; +} + +function hoursFromNow(durationHours: number | undefined): string | null { + return durationHours && durationHours > 0 + ? new Date(Date.now() + durationHours * 60 * 60 * 1000).toISOString() + : null; } function actingUser(): string { @@ -389,6 +555,8 @@ export function createPluginHosts({ manifest, bag, audit }: Deps): PluginHosts { }; return { + ...createPluginHostImport({ manifest, bag, audit }), + list: async () => { await requireRead(); const userId = actingUser(); @@ -471,6 +639,7 @@ export function createPluginHosts({ manifest, bag, audit }: Deps): PluginHosts { create: async (host: PluginHostCreateInput): Promise => { try { await requireWrite(); + assertManualCreateAllowed(pluginId); } catch (error) { await audit("hosts_create", host?.name ?? host?.ip ?? "host", { success: false, @@ -479,8 +648,10 @@ export function createPluginHosts({ manifest, bag, audit }: Deps): PluginHosts { throw error; } const userId = actingUser(); - const { row, settings } = toHostWrite(host); - const writes = await checkSettings(settings); + const { row, settings, protocolAuth, pluginEnable } = toHostWrite(host); + const writes = await checkSettings( + await foldPluginEnable(settings, pluginEnable), + ); const { createCurrentHostRepository } = await import("../database/repositories/factory.js"); const { applyHostDefaultsToWrite, applyDefaultsAfterHostWrite } = @@ -499,6 +670,7 @@ export function createPluginHosts({ manifest, bag, audit }: Deps): PluginHosts { userId, columns, ); + await writeHostProtocolAuth(userId, created.id, protocolAuth); await applyDefaultsAfterHostWrite(created.id); await writeSettings(created.id, writes); await markSettingsOwn(created.id, writes); @@ -515,6 +687,7 @@ export function createPluginHosts({ manifest, bag, audit }: Deps): PluginHosts { ): Promise => { try { await requireWrite(); + assertHostsUnlocked([hostId], pluginId); } catch (error) { await audit("hosts_update", `host ${hostId}`, { success: false, @@ -523,8 +696,10 @@ export function createPluginHosts({ manifest, bag, audit }: Deps): PluginHosts { throw error; } const userId = actingUser(); - const { row, settings } = toHostWrite(patch); - const writes = await checkSettings(settings); + const { row, settings, protocolAuth, pluginEnable } = toHostWrite(patch); + const writes = await checkSettings( + await foldPluginEnable(settings, pluginEnable), + ); const { createCurrentHostRepository } = await import("../database/repositories/factory.js"); const { applyHostDefaultsToWrite, applyDefaultsAfterHostWrite } = @@ -555,6 +730,7 @@ export function createPluginHosts({ manifest, bag, audit }: Deps): PluginHosts { columns, ); if (updated) { + await writeHostProtocolAuth(userId, hostId, protocolAuth); await writeSettings(hostId, writes); await markSettingsOwn(hostId, writes); await applyDefaultsAfterHostWrite(hostId, { @@ -579,6 +755,7 @@ export function createPluginHosts({ manifest, bag, audit }: Deps): PluginHosts { delete: async (hostId: number): Promise => { try { await requireWrite(); + assertHostsUnlocked([hostId], pluginId); const userId = actingUser(); const { PermissionManager } = await import("../utils/permission-manager.js"); @@ -626,6 +803,7 @@ export function createPluginHosts({ manifest, bag, audit }: Deps): PluginHosts { ): Promise => { try { await requireWrite(); + assertHostsUnlocked([hostId], pluginId); } catch (error) { await audit("hosts_share", `host ${hostId}`, { success: false, @@ -655,10 +833,8 @@ export function createPluginHosts({ manifest, bag, audit }: Deps): PluginHosts { return result; } - const { - createCurrentHostResolutionRepository, - createCurrentRbacAccessRepository, - } = await import("../database/repositories/factory.js"); + const { createCurrentHostResolutionRepository } = + await import("../database/repositories/factory.js"); const ownerId = (await createCurrentHostResolutionRepository().findHostOwnerId( hostId, @@ -672,56 +848,95 @@ export function createPluginHosts({ manifest, bag, audit }: Deps): PluginHosts { return { hostId, shared: false, reason: "owner" }; } - const expiresAt = - durationHours && durationHours > 0 - ? new Date(Date.now() + durationHours * 60 * 60 * 1000).toISOString() - : null; - - const rbacAccessRepository = createCurrentRbacAccessRepository(); - const { SharedHostSecretsManager } = - await import("../utils/shared-host-secrets-manager.js"); - const secretsManager = SharedHostSecretsManager.getInstance(); - + const expiresAt = hoursFromNow(durationHours); for (const target of targets) { - const grant = await rbacAccessRepository.upsertHostAccess({ + await grantHostShare( hostId, - grantedBy: userId, + ownerId, + userId, + target, permissionLevel, expiresAt, - ...(target.type === "user" - ? { targetType: "user" as const, targetUserId: target.id as string } - : { - targetType: "role" as const, - targetRoleId: target.id as number, - }), - }); - - try { - if (target.type === "user") { - await secretsManager.snapshotForUser( - grant.id, - hostId, - target.id as string, - ownerId, - ); - } else { - await secretsManager.snapshotForRole( - grant.id, - hostId, - target.id as number, - ownerId, - ); - } - } catch { - // A snapshot failure never blocks the grant: the recipient can - // still be prompted, or the owner can retry sharing later. - } + ); } await audit("hosts_share", `host ${hostId}`, { success: true }); return { hostId, shared: true }; }, + setShares: async ( + hostId: number, + grants: PluginHostShareGrant[], + ): Promise => { + const fail = async (reason: string): Promise => { + await audit("hosts_set_shares", `host ${hostId}`, { + success: false, + errorMessage: reason, + }); + return { hostId, shared: false, reason }; + }; + try { + await requireWrite(); + assertHostsUnlocked([hostId], pluginId); + } catch (error) { + await fail(error instanceof Error ? error.message : String(error)); + throw error; + } + if (!Array.isArray(grants)) { + throw new PluginHostInputError("grants must be a list"); + } + for (const grant of grants) { + const type = grant?.target?.type; + if ( + (type !== "user" && type !== "role") || + !SHARE_LEVELS.includes(grant.permissionLevel) + ) { + throw new PluginHostInputError( + "Each grant needs a user or role target and a share level", + ); + } + } + + const userId = actingUser(); + const { + createCurrentHostResolutionRepository, + createCurrentRbacAccessRepository, + } = await import("../database/repositories/factory.js"); + const ownerId = + await createCurrentHostResolutionRepository().findHostOwnerId(hostId); + if (ownerId !== userId) return fail("forbidden"); + if ( + grants.some((g) => g.target.type === "user" && g.target.id === userId) + ) { + return fail("owner"); + } + + const kept = new Set(); + for (const grant of grants) { + kept.add( + await grantHostShare( + hostId, + ownerId, + userId, + grant.target, + grant.permissionLevel, + hoursFromNow(grant.durationHours), + ), + ); + } + const repository = createCurrentRbacAccessRepository(); + for (const existing of await repository.listHostAccess(hostId)) { + if (!kept.has(existing.id)) { + await repository.revokeHostAccess(existing.id, hostId); + } + } + const { pruneHostGrants } = await import("../database/routes/rbac.js"); + await pruneHostGrants(hostId); + + await audit("hosts_set_shares", `host ${hostId}`, { success: true }); + return { hostId, shared: true }; + }, + listUsers: async (): Promise => { try { await requireWrite(); @@ -787,38 +1002,146 @@ export function createPluginHosts({ manifest, bag, audit }: Deps): PluginHosts { await requireRead(); const userId = actingUser(); if (!(await actorMayReach(hostId))) return; - const { recordRecentActivity } = - await import("../services/recent-activity.js"); - await recordRecentActivity(userId, { type, hostId, hostName }); + await recentActivity.record(userId, { type, hostId, hostName }); + }, + + probeTargets: async (filter) => { + await requireRead(); + const actor = getActor(); + if (!actor) { + throw new Error("ctx.hosts.probeTargets needs an acting user"); + } + const ids = filter?.hostIds?.filter((id) => Number.isInteger(id)); + return loadProbeTargets(actor, ids); + }, + + sessionActive: (hostId) => { + requireReadSync(); + return hostSessionStatus.hasActiveSession(hostId); + }, + + locks: { + provide: (provider) => { + if (!declared.includes("hosts:lock")) { + throw capabilityRefused(pluginId, "hosts:lock"); + } + const remove = provideHostLocks( + pluginId, + manifest.name || pluginId, + provider, + ); + void audit("host_locks_provide", `${pluginId} manages hosts`, { + success: true, + }); + const drop = bag?.add(remove, "host lock provider"); + return () => { + drop?.(); + remove(); + }; + }, + }, + + folders: { + ensure: async (name, style) => { + await requireWrite(); + const userId = actingUser(); + const { createCurrentHostFolderRepository } = + await import("../database/repositories/factory.js"); + await createCurrentHostFolderRepository().upsertMetadata( + userId, + name, + style?.color ?? null, + style?.icon ?? null, + ); + }, + removeEmpty: async (name) => { + await requireWrite(); + const userId = actingUser(); + const { createCurrentHostFolderRepository } = + await import("../database/repositories/factory.js"); + return createCurrentHostFolderRepository().deleteEmptyFolderRecords( + userId, + name, + ); + }, + }, + + activity: { + provide: (provider) => { + if (!declared.includes("hosts:activity-provide")) { + throw capabilityRefused(pluginId, "hosts:activity-provide"); + } + const remove = provideActivity( + pluginId, + provider as Parameters[1], + ); + void audit( + "host_activity_provide", + `${pluginId} stores recent activity`, + { + success: true, + }, + ); + const drop = bag?.add(remove, "activity provider"); + return () => { + drop?.(); + remove(); + }; + }, }, // Not audited: pollers call these on every sample. status: { + provide: (provider) => { + requireReadSync(); + if (!declared.includes("hosts:status-provide")) { + throw capabilityRefused(pluginId, "hosts:status-provide"); + } + const remove = provideHostStatus(pluginId, { + start: () => provider.start(), + stop: () => provider.stop(), + get: (hostId) => provider.get(hostId), + statusesFor: (userId, requested) => + provider.statusesFor(userId, requested ? [...requested] : null), + refresh: (userId) => provider.refresh(userId), + check: (hostId) => provider.check(hostId), + reportLogin: (hostId, outcome) => + provider.reportLogin(hostId, outcome), + registerPort: (type, resolve) => provider.registerPort(type, resolve), + getIntervalSeconds: () => provider.getIntervalSeconds(), + setIntervalSeconds: (seconds) => provider.setIntervalSeconds(seconds), + }); + void audit("host_status_provide", `${pluginId} provides host status`, { + success: true, + }); + const drop = bag?.add(remove, "host status provider"); + return () => { + drop?.(); + remove(); + }; + }, get: async (hostId) => { await requireRead(); if (!(await actorMayReach(hostId))) return null; - return hostStatusService.get(hostId); + return hostStatus.get(hostId); }, check: async (hostId) => { await requireRead(); if (!(await actorMayReach(hostId))) return null; - return hostStatusService.check(hostId); + return hostStatus.check(hostId); }, // Synchronous for the same reason as trackSession. reportLogin: (hostId, outcome) => { requireReadSync(); void actorMayReach(hostId) .then((allowed) => { - if (allowed) hostStatusService.reportLogin(hostId, outcome); + if (allowed) hostStatus.reportLogin(hostId, outcome); }) .catch(() => {}); }, registerPort: (connectionType, resolve) => { requireReadSync(); - const unregister = hostStatusService.registerPort( - connectionType, - resolve, - ); + const unregister = hostStatus.registerPort(connectionType, resolve); const drop = bag?.add(unregister, `status port for ${connectionType}`); return () => { drop?.(); diff --git a/src/backend/plugins/ctx-probe.ts b/src/backend/plugins/ctx-probe.ts new file mode 100644 index 000000000..24a73cd48 --- /dev/null +++ b/src/backend/plugins/ctx-probe.ts @@ -0,0 +1,172 @@ +/** + * Reachability for status providers: ctx.network.tcpProbe, ctx.ssh.probe and + * ctx.hosts.probeTargets. Nothing here ever logs in to the probed host. + */ + +import type { + PluginHostProbeTarget, + PluginNetwork, +} 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"; +import { tcpPing } from "./probe/tcp-ping.js"; +import { sharedJumpProbes } from "./probe/jump-probe-pool.js"; +import type { HostStatusTargetRow } from "../database/repositories/host-resolution-repository.js"; + +type AuditFn = ( + action: string, + details: string, + outcome: { success: boolean; errorMessage?: string }, +) => Promise; + +const MIN_TIMEOUT_MS = 500; +const MAX_TIMEOUT_MS = 15_000; +const DEFAULT_TIMEOUT_MS = 5000; + +function clampTimeout(value?: number): number { + const n = Number(value); + if (!Number.isFinite(n)) return DEFAULT_TIMEOUT_MS; + return Math.min(MAX_TIMEOUT_MS, Math.max(MIN_TIMEOUT_MS, n)); +} + +function validPort(port: number): boolean { + return Number.isInteger(port) && port > 0 && port <= 65535; +} + +function parseJumpHosts(raw: string | null): Array<{ hostId: number }> { + if (!raw) return []; + try { + const parsed = JSON.parse(raw); + return Array.isArray(parsed) + ? parsed.filter( + (hop): hop is { hostId: number } => + !!hop && Number.isInteger((hop as { hostId?: unknown }).hostId), + ) + : []; + } catch { + return []; + } +} + +function toTarget(row: HostStatusTargetRow): PluginHostProbeTarget { + return { + id: row.id, + ownerUserId: row.userId, + ip: row.ip, + port: row.port, + connectionType: row.connectionType || "ssh", + jumpHosts: parseJumpHosts(row.jumpHosts), + statusCheckEnabled: row.statusCheckEnabled !== false, + statusCheckInterval: row.statusCheckInterval ?? null, + }; +} + +export function createPluginNetwork(deps: { + manifest: PluginManifest; + audit: AuditFn; +}): PluginNetwork { + const { manifest, audit } = deps; + let audited = false; + return { + tcpProbe: async (host, port, options) => { + await assertCapability( + manifest.id, + "network:probe", + manifest.capabilities, + "network_tcp_probe", + ); + if (typeof host !== "string" || !host.trim() || host.length > 255) { + throw new Error("A host name or address is required"); + } + if (!validPort(port)) + throw new Error("A port from 1 to 65535 is required"); + if (!audited) { + audited = true; + await audit("network_tcp_probe", "checked if TCP ports answer", { + success: true, + }); + } + return tcpPing(host.trim(), port, clampTimeout(options?.timeoutMs)); + }, + }; +} + +/** The hosts the actor owns or was shared, narrowed to ones they may connect to. */ +export async function loadProbeTargets( + actor: string, + hostIds?: number[], +): Promise { + const { + createCurrentHostResolutionRepository, + createCurrentRbacAccessRepository, + createCurrentRoleRepository, + } = await import("../database/repositories/factory.js"); + const { PermissionManager } = await import("../utils/permission-manager.js"); + const hosts = createCurrentHostResolutionRepository(); + + const own = await hosts.listStatusTargets({ + userId: actor, + hostIds, + }); + const ownIds = new Set(own.map((row) => row.id)); + + const roleIds = await createCurrentRoleRepository().listUserRoleIds(actor); + const entries = + await createCurrentRbacAccessRepository().listVisibleHostAccessEntries( + actor, + roleIds, + ); + let sharedIds = [...new Set(entries.map((entry) => entry.hostId))].filter( + (id) => !ownIds.has(id) && (!hostIds || hostIds.includes(id)), + ); + if (sharedIds.length > 0) { + const allowed = + await PermissionManager.getInstance().filterAccessibleHostIds( + actor, + sharedIds, + ); + sharedIds = sharedIds.filter((id) => allowed.has(id)); + } + const shared = + sharedIds.length > 0 + ? await hosts.listStatusTargets({ hostIds: sharedIds }) + : []; + return [...own, ...shared].map(toTarget); +} + +export async function probeHost( + hostId: number, + options?: { port?: number; timeoutMs?: number }, +): Promise { + const actor = getActor(); + if (actor) { + const { PermissionManager } = + await import("../utils/permission-manager.js"); + const access = await PermissionManager.getInstance().canAccessHost( + actor, + hostId, + "connect", + ); + if (!access.hasAccess) return false; + } + const { createCurrentHostResolutionRepository } = + await import("../database/repositories/factory.js"); + const [row] = await createCurrentHostResolutionRepository().listStatusTargets( + { hostIds: [hostId] }, + ); + if (!row) return false; + const target = toTarget(row); + const port = options?.port ?? target.port; + if (!validPort(port)) throw new Error("A port from 1 to 65535 is required"); + const timeout = clampTimeout(options?.timeoutMs); + return target.jumpHosts.length > 0 + ? sharedJumpProbes.ping( + target.jumpHosts, + target.ownerUserId, + target.ip, + port, + timeout, + ) + : tcpPing(target.ip, port, timeout); +} diff --git a/src/backend/plugins/ctx-ssh-auth.ts b/src/backend/plugins/ctx-ssh-auth.ts index 36d64401c..db9d3a1e9 100644 --- a/src/backend/plugins/ctx-ssh-auth.ts +++ b/src/backend/plugins/ctx-ssh-auth.ts @@ -26,6 +26,8 @@ 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 { hostStatus } from "./host-status-provider.js"; +import { probeHost } from "./ctx-probe.js"; import { getSshAuthProvider, listSshAuthProviders, @@ -36,9 +38,12 @@ import { classifyKeyboardInteractive } from "../hosts/connect/keyboard-interacti import { ensureCoreSshAuthProviders } from "../hosts/connect/core-providers.js"; import { getLoginMethod, + listSecondFactors, registerLoginMethod, registerSecondFactor, } from "../auth/registry.js"; +import { provideAuthPolicy } from "../auth/policy.js"; +import { provideSshTransport } from "../hosts/connect/transport-providers.js"; import type { VerifiedIdentity } from "../auth/types.js"; import type { MutableConnectConfig, @@ -224,11 +229,7 @@ export function createPluginSsh({ manifest, bag, audit }: Deps): PluginSsh { const reportLogin = (host: number | PluginSshHost) => { const hostId = typeof host === "number" ? host : lookup(host)?.host.id; if (!Number.isInteger(hostId) || hostId <= 0) return; - void import("../hosts/status/host-status-service.js") - .then(({ hostStatusService }) => - hostStatusService.reportLogin(hostId, { ok: true }), - ) - .catch(() => {}); + hostStatus.reportLogin(hostId, { ok: true }); }; /** Checks, connects and audits one new connection. */ @@ -296,6 +297,31 @@ export function createPluginSsh({ manifest, bag, audit }: Deps): PluginSsh { }`; return { + transports: { + provide: (provider) => { + if (!declared.includes("ssh:transport")) { + throw capabilityRefused(pluginId, "ssh:transport"); + } + const remove = provideSshTransport(pluginId, provider); + void audit( + "ssh_transport_provide", + `${pluginId} carries host connections`, + { + success: true, + }, + ); + const drop = bag.add(remove, "ssh transport"); + return () => { + drop(); + remove(); + }; + }, + }, + probe: async (hostId, probeOptions) => { + await assertCapability(pluginId, "ssh:connect", declared); + if (!Number.isInteger(hostId) || hostId <= 0) return false; + return probeHost(hostId, probeOptions); + }, connect: async (host, options) => { const connection = await connectOnce(host, options); return { @@ -306,6 +332,33 @@ export function createPluginSsh({ manifest, bag, audit }: Deps): PluginSsh { }; }, + exec: async (host, command, execOptions) => { + const connection = await connectOnce(host, { + ...execOptions, + purpose: execOptions?.purpose ?? "exec", + timeoutMs: undefined, + }); + const details = `${describeHost(host)}: command run`; + try { + const { runExec } = await import("./ctx-ssh-exec.js"); + const result = await runExec( + connection.client as never, + command, + execOptions, + ); + await audit("ssh_exec", details, { success: true }); + return result; + } catch (error) { + await audit("ssh_exec", details, { + success: false, + errorMessage: error instanceof Error ? error.message : String(error), + }); + throw error; + } finally { + connection.dispose(); + } + }, + withConnection: async (host, options, fn) => { await checkSsh(true); const { resolveConnectHost } = @@ -826,6 +879,8 @@ export function createPluginAuth({ manifest, bag, audit }: Deps): PluginAuth { getActor() === userId ? getActorSessionId() : undefined, ); await createCurrentTrustedDeviceRepository().deleteByUserId(userId); + // The session that set it up may have been a setup only one. + await createCurrentSessionRepository().clearRestrictionForUser(userId); await audit("auth_factor_enrolled", `${factorId} for ${userId}`, { success: true, }); @@ -904,5 +959,28 @@ export function createPluginAuth({ manifest, bag, audit }: Deps): PluginAuth { }, issueUserToken: (userId, options) => issueUserToken(manifest, userId, options), + + policy: { + provide: (provider) => { + if (!declared.includes("auth:policy")) { + throw capabilityRefused(pluginId, "auth:policy"); + } + const remove = provideAuthPolicy(pluginId, provider); + void audit("auth_policy_provide", `${pluginId} sets login rules`, { + success: true, + }); + const drop = bag.add(remove, "login policy"); + return () => { + drop(); + remove(); + }; + }, + }, + + listSecondFactors: () => + listSecondFactors().map((factor) => ({ + id: factor.id, + pluginId: factor.pluginId, + })), }; } diff --git a/src/backend/plugins/ctx-ssh-exec.ts b/src/backend/plugins/ctx-ssh-exec.ts new file mode 100644 index 000000000..96493c46c --- /dev/null +++ b/src/backend/plugins/ctx-ssh-exec.ts @@ -0,0 +1,123 @@ +/** + * Runs one command on an open ssh2 client for ctx.ssh.exec, with a time limit + * and caps on how much output is kept. + */ + +import type { PluginSshExecOptions } from "@termix-ssh/plugin-sdk/backend"; + +export const EXEC_DEFAULT_TIMEOUT_MS = 30_000; +export const EXEC_MAX_TIMEOUT_MS = 10 * 60_000; +export const EXEC_DEFAULT_MAX_OUTPUT = 256 * 1024; +export const EXEC_HARD_MAX_OUTPUT = 4 * 1024 * 1024; +export const EXEC_MAX_COMMAND_LENGTH = 64 * 1024; + +interface ExecStream { + on: (event: string, listener: (...args: never[]) => void) => unknown; + stderr: { on: (event: string, listener: (chunk: Buffer) => void) => unknown }; + write: (data: string) => unknown; + end: (data?: string) => unknown; + close: () => void; +} + +export interface ExecClient { + exec: ( + command: string, + callback: (error: Error | undefined, stream: ExecStream) => void, + ) => unknown; +} + +export interface ExecResult { + stdout: string; + stderr: string; + /** Null when the command was killed by a signal or hit the time limit. */ + code: number | null; + truncated: boolean; + timedOut: boolean; +} + +export function runExec( + client: ExecClient, + command: string, + options: PluginSshExecOptions = {}, +): Promise { + if (typeof command !== "string" || command.trim() === "") { + return Promise.reject(new Error("A command is required")); + } + if (command.length > EXEC_MAX_COMMAND_LENGTH) { + return Promise.reject(new Error("The command is too long")); + } + const timeoutMs = Math.min( + EXEC_MAX_TIMEOUT_MS, + Math.max(1000, options.timeoutMs ?? EXEC_DEFAULT_TIMEOUT_MS), + ); + const maxOutput = Math.min( + EXEC_HARD_MAX_OUTPUT, + Math.max(1024, options.maxOutputBytes ?? EXEC_DEFAULT_MAX_OUTPUT), + ); + + return new Promise((resolve, reject) => { + let settled = false; + let timedOut = false; + let truncated = false; + let stdout = ""; + let stderr = ""; + let timer: NodeJS.Timeout | null = null; + + const finish = (code: number | null) => { + if (settled) return; + settled = true; + if (timer) clearTimeout(timer); + resolve({ stdout, stderr, code, truncated, timedOut }); + }; + + const append = (current: string, chunk: Buffer): string => { + const room = maxOutput - Buffer.byteLength(current); + if (room <= 0) { + truncated = true; + return current; + } + if (chunk.length > room) { + truncated = true; + return current + chunk.subarray(0, room).toString("utf8"); + } + return current + chunk.toString("utf8"); + }; + + client.exec(command, (error, stream) => { + if (error) { + if (!settled) { + settled = true; + reject(error); + } + return; + } + timer = setTimeout(() => { + timedOut = true; + try { + stream.close(); + } catch { + // already closed + } + finish(null); + }, timeoutMs); + + stream.on("data", ((chunk: Buffer) => { + stdout = append(stdout, chunk); + }) as never); + stream.stderr.on("data", (chunk: Buffer) => { + stderr = append(stderr, chunk); + }); + stream.on("close", ((code: number | null | undefined) => { + finish(typeof code === "number" ? code : null); + }) as never); + stream.on("error", ((streamError: Error) => { + if (!settled) { + settled = true; + if (timer) clearTimeout(timer); + reject(streamError); + } + }) as never); + if (options.stdin !== undefined) stream.end(options.stdin); + }); + }); +} diff --git a/src/backend/plugins/ctx.ts b/src/backend/plugins/ctx.ts index eac8156fc..73c35428a 100644 --- a/src/backend/plugins/ctx.ts +++ b/src/backend/plugins/ctx.ts @@ -22,6 +22,9 @@ import { pluginLogger } from "../utils/logger.js"; import { getRequestBaseUrlWithForceHTTPS } from "../utils/request-origin.js"; import { pluginEvents } from "./events.js"; +import { createPluginDesktopLocal } from "./ctx-desktop-local.js"; +import { createPluginAuditReader } from "./ctx-audit.js"; +import { createPluginNetwork } from "./ctx-probe.js"; import * as registry from "./registry.js"; import * as serviceRegistry from "./service-registry.js"; import type { ServiceRegistration } from "./service-registry.js"; @@ -51,6 +54,12 @@ import { } from "@termix-ssh/plugin-sdk/backend"; import type { PluginTableDefinition } from "@termix-ssh/plugin-sdk/db"; import * as syncRegistry from "./sync-registry.js"; +import { provideSync } from "../sync/provider.js"; +import { createSyncHost } from "../sync/host.js"; +import type { + PluginSync, + PluginSyncHost, +} from "@termix-ssh/plugin-sdk/backend"; import { needsExplicitPersist, resolveDatabaseDialect, @@ -186,6 +195,34 @@ export function createPluginContext( handle: PluginHandle, ): PluginContext { const pluginId = manifest.id; + let syncHost: PluginSyncHost | undefined; + const syncApi: PluginSync = { + registerEntity: (entity) => { + const dispose = syncRegistry.registerEntity(pluginId, entity); + handle.bag.add(dispose, `sync entity ${entity.type}`); + }, + provide: (provider) => { + if (!manifest.capabilities.includes("sync:provide")) { + throw capabilityRefused(pluginId, "sync:provide"); + } + const remove = provideSync(pluginId, provider); + const drop = handle.bag.add(remove, "sync provider"); + return () => { + drop(); + remove(); + }; + }, + } as PluginSync; + // Not enumerable, so tools that walk the context do not trip the check. + Object.defineProperty(syncApi, "host", { + enumerable: false, + get() { + if (!manifest.capabilities.includes("sync:provide")) { + throw capabilityRefused(pluginId, "sync:provide"); + } + return (syncHost ??= createSyncHost()); + }, + }); const auditCall = ( action: string, details: string, @@ -517,12 +554,7 @@ export function createPluginContext( }, }, - sync: { - registerEntity: (entity) => { - const dispose = syncRegistry.registerEntity(pluginId, entity); - handle.bag.add(dispose, `sync entity ${entity.type}`); - }, - }, + sync: syncApi, kv: { get: (key) => kvGet(key), @@ -950,6 +982,7 @@ export function createPluginContext( bag: handle.bag, }), fetch: createPluginFetch({ manifest, audit: auditCall }), + network: createPluginNetwork({ manifest, audit: auditCall }), process: createPluginProcess({ manifest, bag: handle.bag, @@ -964,6 +997,11 @@ export function createPluginContext( openIsolatedWindow: (request) => desktopOpenIsolatedWindow(request), launchExternalClient: (request) => desktopLaunchExternalClient(request), available: () => isElectronIpcAvailable(), + local: createPluginDesktopLocal({ + manifest, + bag: handle.bag, + audit: auditCall, + }), }, credentials: createPluginCredentials({ @@ -995,6 +1033,11 @@ export function createPluginContext( // Auditing must never break the caller. } }, + ...createPluginAuditReader({ + manifest, + bag: handle.bag, + audit: auditCall, + }), }, /** diff --git a/src/backend/plugins/desktop-local/local-fs.ts b/src/backend/plugins/desktop-local/local-fs.ts new file mode 100644 index 000000000..6b622fe7f --- /dev/null +++ b/src/backend/plugins/desktop-local/local-fs.ts @@ -0,0 +1,572 @@ +/** + * Local filesystem helpers for plugins on the desktop app's embedded backend. + * Ported from the old Electron local-files module. The path rules are the + * security part: a download can never leave the folder the user picked, links + * inside it are refused, and a download never replaces a file by accident. + */ + +import fs from "node:fs"; +import fsp from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import type { + PluginLocalDirEntry, + PluginLocalDownload, + PluginLocalWalkResult, +} from "@termix-ssh/plugin-sdk/backend"; + +export class LocalFileError extends Error { + code: string; + constructor(code: string, message: string) { + super(message); + this.name = "LocalFileError"; + this.code = code; + } +} + +type PathImpl = typeof path; + +export function normalizeLocalPath(candidate: unknown): string { + if (typeof candidate !== "string" || !path.isAbsolute(candidate)) { + throw new Error("A local absolute path is required"); + } + return path.normalize(candidate); +} + +// A single path segment: no separators, not "." / "..", no NULs. +export function requireEntryName(name: unknown, what: string): string { + const safeName = String(name || "").trim(); + if ( + !safeName || + safeName === "." || + safeName === ".." || + /[/\\\0]/.test(safeName) + ) { + throw new Error(`Invalid ${what}`); + } + return safeName; +} + +// pathImpl is injectable so the Windows rules can be tested on any OS. +export function assertWithinRoot( + rootPath: string, + candidate: string, + pathImpl: PathImpl = path, +): string { + if (typeof rootPath !== "string" || !rootPath.trim()) { + throw new LocalFileError("EINVAL", "A download folder is required"); + } + const root = pathImpl.normalize(pathImpl.resolve(rootPath)); + const target = pathImpl.normalize(pathImpl.resolve(candidate)); + const rel = pathImpl.relative(root, target); + const escapes = + rel === "" || + rel === ".." || + rel.startsWith(`..${pathImpl.sep}`) || + pathImpl.isAbsolute(rel) || + rel.split(pathImpl.sep).some((seg) => seg === ".." || seg === ""); + if (escapes) { + throw new LocalFileError( + "EINVAL", + `"${pathImpl.basename(candidate)}" would be written outside the selected folder`, + ); + } + return target; +} + +// Resolve a deliberately selected root once, but never follow links supplied +// as descendants of a downloaded tree. +export async function prepareDownloadPath( + rootPath: string, + candidate: string, + directory = false, +): Promise<{ root: string; path: string }> { + const target = assertWithinRoot(rootPath, normalizeLocalPath(candidate)); + const selected = path.resolve(rootPath); + const realRoot = await fsp.realpath(selected); + if (!(await fsp.stat(realRoot)).isDirectory()) { + throw new LocalFileError("EINVAL", "The download root is not a directory"); + } + const parts = path.relative(selected, target).split(path.sep); + let current = realRoot; + for (let i = 0; i < parts.length; i++) { + current = path.join(current, parts[i]); + const needsDirectory = directory || i < parts.length - 1; + if (needsDirectory) { + await fsp.mkdir(current).catch((error: NodeJS.ErrnoException) => { + if (error.code !== "EEXIST") throw error; + }); + } + const stat = await fsp + .lstat(current) + .catch((error: NodeJS.ErrnoException) => { + if (error.code === "ENOENT" && !needsDirectory) return null; + throw error; + }); + if (stat?.isSymbolicLink()) { + throw new LocalFileError( + "EINVAL", + "Downloads cannot follow links inside the selected folder", + ); + } + if (needsDirectory && !stat?.isDirectory()) { + throw new LocalFileError( + "ENOTDIR", + "A download parent is not a directory", + ); + } + } + return { root: realRoot, path: current }; +} + +export async function pathExists(target: string): Promise { + try { + await fsp.lstat(target); + return true; + } catch { + return false; + } +} + +async function describeEntry( + dirPath: string, + dirent: fs.Dirent, +): Promise { + const entryPath = path.join(dirPath, dirent.name); + let type: PluginLocalDirEntry["type"] = "file"; + let size = 0; + let modifiedTimestamp: number | undefined; + let linkTarget: string | undefined; + + try { + if (dirent.isSymbolicLink()) { + type = "link"; + try { + linkTarget = await fsp.readlink(entryPath); + } catch { + // dangling or unreadable link + } + try { + const target = await fsp.stat(entryPath); + if (target.isDirectory()) type = "directory"; + size = target.size; + modifiedTimestamp = target.mtimeMs; + } catch { + const own = await fsp.lstat(entryPath); + modifiedTimestamp = own.mtimeMs; + } + } else if (dirent.isDirectory()) { + type = "directory"; + modifiedTimestamp = (await fsp.stat(entryPath)).mtimeMs; + } else { + const stat = await fsp.stat(entryPath); + size = stat.size; + modifiedTimestamp = stat.mtimeMs; + } + } catch { + // Unreadable entry: still list it so the user sees it exists. + } + + return { + name: dirent.name, + path: entryPath, + type, + size, + modifiedTimestamp, + linkTarget, + hidden: dirent.name.startsWith("."), + }; +} + +export async function listDirectory(dirPath: string): Promise<{ + path: string; + parent: string | null; + entries: PluginLocalDirEntry[]; +}> { + const resolved = normalizeLocalPath(dirPath); + const dirents = await fsp.readdir(resolved, { withFileTypes: true }); + const entries = await Promise.all( + dirents.map((dirent) => describeEntry(resolved, dirent)), + ); + const parent = path.dirname(resolved); + return { + path: resolved, + parent: parent === resolved ? null : parent, + entries, + }; +} + +// Expands dropped local paths into a flat file list with paths relative to the +// drop root, plus empty directories. +export async function walkPaths( + rootPaths: string[], +): Promise { + const files: PluginLocalWalkResult["files"] = []; + const emptyDirs: string[] = []; + let totalBytes = 0; + + async function walkDir(absDir: string, relDir: string): Promise { + const dirents = await fsp.readdir(absDir, { withFileTypes: true }); + if (dirents.length === 0) { + emptyDirs.push(relDir); + return; + } + for (const dirent of dirents) { + const abs = path.join(absDir, dirent.name); + const rel = `${relDir}/${dirent.name}`; + if (dirent.isDirectory()) { + await walkDir(abs, rel); + } else if (dirent.isFile()) { + const stat = await fsp.stat(abs); + files.push({ localPath: abs, relativePath: rel, size: stat.size }); + totalBytes += stat.size; + } else if (dirent.isSymbolicLink()) { + try { + const stat = await fsp.stat(abs); + if (stat.isFile()) { + files.push({ localPath: abs, relativePath: rel, size: stat.size }); + totalBytes += stat.size; + } + } catch { + // dangling link: skip + } + } + } + } + + for (const rootPath of rootPaths) { + const abs = normalizeLocalPath(rootPath); + const stat = await fsp.stat(abs); + const name = path.basename(abs); + if (stat.isDirectory()) { + await walkDir(abs, name); + } else if (stat.isFile()) { + files.push({ localPath: abs, relativePath: name, size: stat.size }); + totalBytes += stat.size; + } + } + + return { files, emptyDirs, totalBytes }; +} + +export async function homeInfo(): Promise<{ + home: string; + separator: string; + platform: NodeJS.Platform; +}> { + return { + home: os.homedir(), + separator: path.sep, + platform: process.platform, + }; +} + +export async function makeDirectory( + parentPath: string, + name: string, +): Promise { + const target = path.join( + normalizeLocalPath(parentPath), + requireEntryName(name, "folder name"), + ); + await fsp.mkdir(target); + return target; +} + +export async function createEmptyFile( + parentPath: string, + name: string, +): Promise { + const target = path.join( + normalizeLocalPath(parentPath), + requireEntryName(name, "file name"), + ); + // "wx" fails if the file already exists rather than truncating it. + await fsp.writeFile(target, "", { flag: "wx" }); + return target; +} + +// Exclusive rename: a destination that appeared meanwhile is never replaced. +export async function renameLocalPath( + source: string, + target: string, +): Promise { + if (source === target) return; + try { + await fsp.lstat(target); + throw new LocalFileError( + "EEXIST", + "A file or folder with that name already exists", + ); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; + } + if ((await fsp.lstat(source)).isDirectory()) { + await fsp.rename(source, target); + return; + } + await fsp.link(source, target); + await fsp.unlink(source); +} + +export async function renameEntry( + oldPath: string, + newName: string, +): Promise { + const source = normalizeLocalPath(oldPath); + const safeName = requireEntryName(newName, "name"); + const target = path.join(path.dirname(source), safeName); + if (target === source) return source; + if (await pathExists(target)) { + throw new LocalFileError("EEXIST", `"${safeName}" already exists`); + } + await renameLocalPath(source, target); + return target; +} + +export async function ensureDirectory( + dirPath: string, + rootPath?: string, +): Promise { + let target = normalizeLocalPath(dirPath); + if (rootPath !== undefined) { + target = (await prepareDownloadPath(rootPath, target, true)).path; + } else { + await fsp.mkdir(target, { recursive: true }); + } + return target; +} + +export async function existingPaths(paths: string[]): Promise { + if (!Array.isArray(paths)) throw new Error("Expected a list of paths"); + const existing: string[] = []; + for (const candidate of paths) { + const target = normalizeLocalPath(candidate); + if (await pathExists(target)) existing.push(target); + } + return existing; +} + +export async function openRead( + localPath: string, +): Promise<{ stream: fs.ReadStream; size: number }> { + const absPath = normalizeLocalPath(localPath); + const stat = await fsp.stat(absPath); + if (!stat.isFile()) throw new Error("Only regular files can be uploaded"); + return { + stream: fs.createReadStream(absPath, { highWaterMark: 1024 * 1024 }), + size: stat.size, + }; +} + +// Destinations with a download in flight, so two transfers never race onto +// the same file. +const activeDestinations = new Set(); + +function transferToken(transferId: string): string { + return ( + String(transferId) + .replace(/[^A-Za-z0-9_-]/g, "") + .slice(0, 48) || "transfer" + ); +} + +export interface PublishFs { + rename: (from: string, to: string) => Promise; + link: (from: string, to: string) => Promise; + copyFileExcl: (from: string, to: string) => Promise; + rm: (target: string) => Promise; + lstat: (target: string) => Promise; +} + +export const defaultPublishFs: PublishFs = Object.freeze({ + rename: (from, to) => fsp.rename(from, to), + link: (from, to) => fsp.link(from, to), + copyFileExcl: (from, to) => + fsp.copyFile(from, to, fs.constants.COPYFILE_EXCL), + rm: (target) => fsp.rm(target, { force: true }), + lstat: (target) => fsp.lstat(target), +}); + +// link() is atomic and fails with EEXIST if the name is taken; filesystems +// without hard links fall back to an exclusive copy. +async function publishExclusive( + io: PublishFs, + sourcePath: string, + absDest: string, +): Promise { + const exists = () => + new LocalFileError("EEXIST", `"${path.basename(absDest)}" already exists`); + try { + await io.link(sourcePath, absDest); + } catch (error) { + if ((error as NodeJS.ErrnoException)?.code === "EEXIST") throw exists(); + try { + await io.copyFileExcl(sourcePath, absDest); + } catch (copyError) { + if ((copyError as NodeJS.ErrnoException)?.code === "EEXIST") { + throw exists(); + } + throw copyError; + } + } + await io.rm(sourcePath); +} + +// rename() onto an existing file is not portable (it commonly fails on +// Windows), so move the old file aside, publish exclusively, then drop it. +async function replaceExisting( + io: PublishFs, + partialPath: string, + absDest: string, + transferId: string, +): Promise { + let current: fs.Stats; + try { + current = await io.lstat(absDest); + } catch (error) { + if ((error as NodeJS.ErrnoException)?.code === "ENOENT") { + await publishExclusive(io, partialPath, absDest); + return; + } + throw error; + } + if (current.isDirectory()) { + throw new LocalFileError( + "EISDIR", + `"${path.basename(absDest)}" is a folder and cannot be replaced by a file`, + ); + } + + const asidePath = `${absDest}.${transferToken(transferId)}.termix-replaced`; + try { + await io.rename(absDest, asidePath); + } catch (error) { + const code = (error as NodeJS.ErrnoException)?.code; + if (code === "EPERM" || code === "EBUSY" || code === "EACCES") { + throw new LocalFileError( + "EBUSY", + `"${path.basename(absDest)}" is in use and could not be replaced`, + ); + } + throw error; + } + + try { + await publishExclusive(io, partialPath, absDest); + } catch (error) { + await io.rm(absDest).catch(() => {}); + await io.rename(asidePath, absDest).catch(() => {}); + throw error; + } + + try { + await io.rm(asidePath); + } catch { + await new Promise((resolve) => setTimeout(resolve, 100)); + await io.rm(asidePath).catch(() => {}); + } +} + +export async function publishDownload( + partialPath: string, + absDest: string, + overwrite: boolean, + transferId: string, + io: PublishFs = defaultPublishFs, +): Promise { + if (overwrite) { + await replaceExisting(io, partialPath, absDest, transferId); + return; + } + await publishExclusive(io, partialPath, absDest); +} + +// Bytes go to a transfer-unique temp sibling first and are published under the +// real name only on success, so a failed transfer never leaves a truncated file. +export async function openDownload( + options: { + rootPath: string; + destPath: string; + transferId: string; + overwrite?: boolean; + }, + io: PublishFs = defaultPublishFs, +): Promise { + const { rootPath, destPath, transferId } = options; + if (!transferId || !destPath) throw new Error("Missing download parameters"); + + const destination = await prepareDownloadPath(rootPath, destPath); + const absDest = destination.path; + if (activeDestinations.has(absDest)) { + throw new LocalFileError( + "EBUSY", + `"${path.basename(absDest)}" is already being downloaded`, + ); + } + activeDestinations.add(absDest); + + const partialPath = `${path.join(destination.root, path.basename(absDest))}.${transferToken( + transferId, + )}.termix-part`; + let released = false; + const release = () => { + if (released) return; + released = true; + activeDestinations.delete(absDest); + }; + + try { + if (options.overwrite !== true && (await pathExists(absDest))) { + throw new LocalFileError( + "EEXIST", + `"${path.basename(absDest)}" already exists`, + ); + } + // "wx": the temp name is ours alone; refuse to reuse a stale one. + const stream = fs.createWriteStream(partialPath, { flags: "wx" }); + // A write error must not crash the backend. publish() reports it. + let streamError: Error | null = null; + stream.on("error", (error) => { + streamError = error; + }); + + return { + path: absDest, + stream, + publish: async (publishOptions) => { + try { + if (streamError) throw streamError; + await new Promise((resolve, reject) => { + if (stream.writableFinished) return resolve(); + stream.once("error", reject); + stream.end(() => resolve()); + }); + if (streamError) throw streamError; + await prepareDownloadPath(destination.root, absDest); + await publishDownload( + partialPath, + absDest, + (publishOptions?.overwrite ?? options.overwrite) === true, + transferId, + io, + ); + return absDest; + } catch (error) { + await fsp.rm(partialPath, { force: true }).catch(() => {}); + throw error; + } finally { + release(); + } + }, + abort: async () => { + stream.destroy(); + await fsp.rm(partialPath, { force: true }).catch(() => {}); + release(); + }, + }; + } catch (error) { + release(); + throw error; + } +} diff --git a/src/backend/plugins/desktop-local/local-pty.ts b/src/backend/plugins/desktop-local/local-pty.ts new file mode 100644 index 000000000..cc42be248 --- /dev/null +++ b/src/backend/plugins/desktop-local/local-pty.ts @@ -0,0 +1,167 @@ +/** + * Local shells for plugins on the desktop app's embedded backend. Ported from + * the old Electron local terminal handlers. + */ + +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { createRequire } from "node:module"; +import type { PluginLocalPty } from "@termix-ssh/plugin-sdk/backend"; + +export function resolveLocalShell( + platform: NodeJS.Platform, + requestedShell?: string, + env: NodeJS.ProcessEnv = process.env, + exists: (file: string) => boolean = fs.existsSync, +): { file: string; args: string[] } { + if (platform === "win32") { + if (requestedShell === "wsl") return { file: "wsl.exe", args: [] }; + return { + file: env.TERMIX_LOCAL_SHELL || "powershell.exe", + args: ["-NoLogo"], + }; + } + + // A SHELL left pointing at an uninstalled shell makes the spawn fail. + const fallbacks = + platform === "darwin" + ? ["/bin/zsh", "/bin/bash", "/bin/sh"] + : ["/bin/bash", "/bin/sh"]; + const usable = (file?: string): file is string => + !!file && (!path.isAbsolute(file) || exists(file)); + const file = + [env.TERMIX_LOCAL_SHELL, env.SHELL, ...fallbacks].find(usable) || + fallbacks[0]; + return { file, args: ["-l"] }; +} + +/** + * node-pty starts every shell through its spawn-helper binary, and a packaged + * copy that lost its execute bit fails with "posix_spawnp failed". Returns how + * many helpers were fixed. + */ +export function repairSpawnHelpers(ptyDir: string, fsImpl = fs): number { + // 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( + dir, + "prebuilds", + `${process.platform}-${process.arch}`, + "spawn-helper", + ), + ]; + let fixed = 0; + for (const helper of candidates) { + try { + const mode = fsImpl.statSync(helper).mode; + if ((mode & 0o111) === 0) { + fsImpl.chmodSync(helper, mode | 0o755); + fixed += 1; + } + } catch { + // Missing or read-only; nothing to fix here. + } + } + return fixed; +} + +interface NodePty { + spawn: ( + file: string, + args: string[], + options: Record, + ) => { + pid: number; + write: (data: string) => void; + resize: (cols: number, rows: number) => void; + kill: () => void; + onData: (cb: (data: string) => void) => { dispose: () => void }; + onExit: (cb: (e: { exitCode: number }) => void) => { dispose: () => void }; + }; +} + +const requireFromHere = createRequire(import.meta.url); + +function loadNodePty(): NodePty { + try { + return requireFromHere("node-pty") as NodePty; + } catch { + throw new Error("The local terminal is not available in this build"); + } +} + +export const MAX_PTY_COLS = 500; +export const MAX_PTY_ROWS = 300; + +export function spawnLocalPty(options?: { + cols?: number; + rows?: number; + shell?: string; +}): PluginLocalPty { + const pty = loadNodePty(); + const cols = Math.min(MAX_PTY_COLS, Math.max(2, Number(options?.cols) || 80)); + const rows = Math.min(MAX_PTY_ROWS, Math.max(1, Number(options?.rows) || 24)); + const shellConfig = resolveLocalShell(process.platform, options?.shell); + + const start = () => + pty.spawn(shellConfig.file, shellConfig.args, { + name: "xterm-256color", + cols, + rows, + cwd: os.homedir(), + env: { ...process.env, TERM: "xterm-256color", COLORTERM: "truecolor" }, + }); + + let child: ReturnType; + try { + child = start(); + } catch (error) { + const ptyDir = path.dirname( + requireFromHere.resolve("node-pty/package.json"), + ); + if ( + !String((error as Error)?.message).includes("posix_spawnp") || + repairSpawnHelpers(ptyDir) === 0 + ) { + throw error; + } + child = start(); + } + + return { + pid: child.pid, + shell: shellConfig.file, + write: (data) => { + if (typeof data === "string" && data.length <= 64 * 1024) { + child.write(data); + } + }, + resize: (c, r) => { + if ( + Number.isInteger(c) && + Number.isInteger(r) && + c >= 2 && + c <= MAX_PTY_COLS && + r >= 1 && + r <= MAX_PTY_ROWS + ) { + child.resize(c, r); + } + }, + kill: () => child.kill(), + onData: (handler) => { + const sub = child.onData(handler); + return () => sub.dispose(); + }, + onExit: (handler) => { + const sub = child.onExit(({ exitCode }) => handler(exitCode)); + return () => sub.dispose(); + }, + }; +} diff --git a/src/backend/plugins/events.ts b/src/backend/plugins/events.ts index 9751be450..263aa60da 100644 --- a/src/backend/plugins/events.ts +++ b/src/backend/plugins/events.ts @@ -22,7 +22,7 @@ export const TOPICS = { hostSessionStatus: "host.session.status", /** A generic named internal event (host_deleted, user_login, ...). */ internalEvent: "internal.event", - /** A host's status dot changed (hosts/status/host-status-service.ts). */ + /** A host's status dot changed (the host status provider). */ hostStatus: "host.status", /** A host's key was accepted again after it changed. */ hostKeyUpdated: "host.key.updated", diff --git a/src/backend/plugins/host-status-provider.ts b/src/backend/plugins/host-status-provider.ts new file mode 100644 index 000000000..2b6b00f2d --- /dev/null +++ b/src/backend/plugins/host-status-provider.ts @@ -0,0 +1,149 @@ +/** + * Host status is a provider: core owns the interface and the callers (the + * status routes, the sync feed, ctx.hosts.status), and one provider fills it. + * A plugin registers with ctx.hosts.status.provide. With none, every status + * is unknown. + */ + +export type ProviderHostStatus = "online" | "reachable" | "offline"; + +export interface HostStatusEntry { + status: ProviderHostStatus; + lastChecked: string; +} + +export type HostPortResolver = ( + hostId: number, +) => number | undefined | Promise; + +export interface HostStatusProvider { + start(): void; + stop(): void; + get(hostId: number): HostStatusEntry | null; + /** All known statuses after bringing polling in line with the request. */ + statusesFor( + userId: string, + requestedHostIds: Set | null, + ): Promise>; + refresh(userId: string): Promise; + check(hostId: number): Promise; + reportLogin(hostId: number, outcome: { ok: boolean }): void; + registerPort(connectionType: string, resolve: HostPortResolver): () => void; + getIntervalSeconds(): Promise; + setIntervalSeconds(seconds: number): Promise; +} + +let builtin: HostStatusProvider | null = null; +let plugged: { pluginId: string; provider: HostStatusProvider } | null = null; +let running = false; +const ports = new Map(); +const portDrops = new Map void>(); + +function active(): HostStatusProvider | null { + return plugged?.provider ?? builtin; +} + +function attachPorts(provider: HostStatusProvider): void { + for (const [key, { type, resolve }] of ports) { + portDrops.set(key, provider.registerPort(type, resolve)); + } +} + +function detachPorts(): void { + for (const drop of portDrops.values()) drop(); + portDrops.clear(); +} + +function swap(next: () => void): void { + const before = active(); + if (running) before?.stop(); + detachPorts(); + next(); + const after = active(); + if (after) attachPorts(after); + if (running) after?.start(); +} + +/** A fallback provider. Core registers none; tests use it. */ +export function registerBuiltinHostStatusProvider( + provider: HostStatusProvider | null, +): void { + swap(() => { + builtin = provider; + }); +} + +/** Returns the remover. A second provider is refused. */ +export function provideHostStatus( + pluginId: string, + provider: HostStatusProvider, +): () => void { + if (plugged) { + throw new Error( + `Host status is already provided by plugin "${plugged.pluginId}"`, + ); + } + const entry = { pluginId, provider }; + swap(() => { + plugged = entry; + }); + return () => { + if (plugged !== entry) return; + swap(() => { + plugged = null; + }); + }; +} + +export function hostStatusProviderId(): string | null { + return plugged?.pluginId ?? null; +} + +/** What the rest of core calls. */ +export const hostStatus = { + start(): void { + running = true; + active()?.start(); + }, + stop(): void { + running = false; + active()?.stop(); + }, + get(hostId: number): HostStatusEntry | null { + return active()?.get(hostId) ?? null; + }, + async statusesFor( + userId: string, + requestedHostIds: Set | null, + ): Promise> { + return (await active()?.statusesFor(userId, requestedHostIds)) ?? new Map(); + }, + async refresh(userId: string): Promise { + await active()?.refresh(userId); + }, + async check(hostId: number): Promise { + return (await active()?.check(hostId)) ?? null; + }, + reportLogin(hostId: number, outcome: { ok: boolean }): void { + active()?.reportLogin(hostId, outcome); + }, + /** Kept across a provider change, so a plugin's port stays registered. */ + registerPort(connectionType: string, resolve: HostPortResolver): () => void { + const key = Symbol(connectionType); + ports.set(key, { type: connectionType, resolve }); + const provider = active(); + if (provider) + portDrops.set(key, provider.registerPort(connectionType, resolve)); + return () => { + ports.delete(key); + portDrops.get(key)?.(); + portDrops.delete(key); + }; + }, + async getIntervalSeconds(): Promise { + return (await active()?.getIntervalSeconds()) ?? 30; + }, + async setIntervalSeconds(seconds: number): Promise { + await active()?.setIntervalSeconds(seconds); + }, +}; diff --git a/src/backend/plugins/loader.ts b/src/backend/plugins/loader.ts index 862ac6537..455b6c902 100644 --- a/src/backend/plugins/loader.ts +++ b/src/backend/plugins/loader.ts @@ -18,7 +18,11 @@ import path from "node:path"; import semver from "semver"; import * as tar from "tar"; import { pluginLogger } from "../utils/logger.js"; -import { isTermixCompatible, parseManifest } from "./manifest.js"; +import { + isTermixCompatible, + parseManifest, + validateThemeCss, +} from "./manifest.js"; import { getLocalVersion } from "../utils/app-version.js"; import type { PluginManifest } from "@termix-ssh/plugin-sdk/manifest"; import { @@ -85,6 +89,19 @@ export interface LoadedPlugin { signedTier?: KeyTier; } +/** + * A plugin folder that did not load. Kept so the layout repair view can say + * why a chosen layout or theme is missing instead of only "not installed". + */ +export interface SkippedPlugin { + /** Manifest id when it could be read, else the folder or file name. */ + id: string; + version: string | null; + layouts: string[]; + themes: string[]; + error: string; +} + export interface PluginLoaderOptions { errorThreshold?: number; errorWindowMs?: number; @@ -92,8 +109,70 @@ export interface PluginLoaderOptions { onFailed?: (plugin: LoadedPlugin) => void; } +/** + * Every theme file a manifest declares must sit inside the plugin and hold + * only what a theme may hold. Refusing the plugin keeps a bad file from ever + * being served. + */ +function validatePluginThemes(dir: string, manifest: PluginManifest): void { + for (const theme of manifest.contributes?.themes ?? []) { + const file = path.join(dir, theme.css); + if (!fs.existsSync(file) || !isRealPathInside(dir, file)) { + throw new Error( + `Plugin ${manifest.id} theme ${theme.id} file ${theme.css} is missing`, + ); + } + const errors = validateThemeCss(fs.readFileSync(file, "utf8"), theme.id); + if (errors.length > 0) { + throw new Error( + `Plugin ${manifest.id} theme ${theme.id} is not allowed: ${errors.join("; ")}`, + ); + } + } +} + +/** What can still be said about a plugin folder that failed to load. */ +function describeSkipped( + dir: string, + name: string, + isArtifact: boolean, + error: unknown, +): SkippedPlugin { + const skipped: SkippedPlugin = { + id: isArtifact ? name.replace(/(-\d[^/]*)?\.tmxplug$/, "") : name, + version: null, + layouts: [], + themes: [], + error: error instanceof Error ? error.message : String(error), + }; + if (isArtifact) return skipped; + try { + const raw = JSON.parse( + fs.readFileSync(getPluginManifestPath(dir), "utf8"), + ) as { + id?: unknown; + version?: unknown; + contributes?: { layouts?: unknown; themes?: unknown }; + }; + const ids = (list: unknown) => + Array.isArray(list) + ? list + .map((entry) => (entry as { id?: unknown })?.id) + .filter((id): id is string => typeof id === "string") + : []; + if (typeof raw.id === "string") skipped.id = raw.id; + if (typeof raw.version === "string") skipped.version = raw.version; + skipped.layouts = ids(raw.contributes?.layouts); + skipped.themes = ids(raw.contributes?.themes); + } catch { + // An unreadable manifest leaves only the folder name. + } + return skipped; +} + export class PluginLoader { private readonly plugins = new Map(); + private skippedPlugins: SkippedPlugin[] = []; /** Version of each bundled plugin as shipped, the floor for any update. */ private readonly bundledVersions = new Map(); /** Activation order, so shutdown can run it backwards. */ @@ -113,6 +192,11 @@ export class PluginLoader { return [...this.plugins.values()]; } + /** Plugin folders the last full load skipped, with the reason. */ + skipped(): SkippedPlugin[] { + return [...this.skippedPlugins]; + } + get(pluginId: string): LoadedPlugin | undefined { return this.plugins.get(pluginId); } @@ -210,6 +294,8 @@ export class PluginLoader { ); } + validatePluginThemes(dir, manifest); + if (this.plugins.has(manifest.id)) { throw new Error(`another plugin already uses the id "${manifest.id}"`); } @@ -258,6 +344,7 @@ export class PluginLoader { async loadAll(): Promise { this.plugins.clear(); this.bundledVersions.clear(); + this.skippedPlugins = []; await removeStaleStaging(); const loaded: LoadedPlugin[] = []; // Every bundled folder name, loaded or not: a bundled plugin that fails @@ -322,6 +409,9 @@ export class PluginLoader { } loaded.push(plugin); } catch (error) { + this.skippedPlugins.push( + describeSkipped(dir, entry.name, isArtifact, error), + ); pluginLogger.error( `Skipping plugin directory ${entry.name} in ${root}`, error instanceof Error ? error : new Error(String(error)), @@ -566,6 +656,15 @@ export class PluginLoader { if (plugin.state === "active" || plugin.state === "activating") return; + if ( + plugin.manifest.runtime === "desktop" && + process.env.ELECTRON_EMBEDDED !== "true" + ) { + plugin.state = "blocked"; + plugin.lastError = "only runs in the desktop app"; + return; + } + plugin.state = "activating"; plugin.lastError = null; @@ -629,8 +728,12 @@ export class PluginLoader { bundled: plugin.source === "bundled" || plugin.signedTier === "official", }); - // A new table may be where core data is waiting to move. - if (applied.length > 0) { + // A new table, or a plugin that keeps its state in settings, may be + // where core data is waiting to move. Every move is idempotent. + if ( + applied.length > 0 || + plugin.manifest.contributes?.settings?.admin?.length + ) { const { runPluginDataMoves } = await import("../upgrade/plugin-data-moves.js"); await runPluginDataMoves(); diff --git a/src/backend/plugins/looks.ts b/src/backend/plugins/looks.ts new file mode 100644 index 000000000..5db3c6b43 --- /dev/null +++ b/src/backend/plugins/looks.ts @@ -0,0 +1,76 @@ +/** + * Layouts and themes on the server side: which look a fresh install shows, + * and which installed looks could not load. Core names no plugin here; the + * default look comes from the bundled index's defaultLook flag. + */ + +import { defaultLookPluginIds } from "./bundled-index.js"; +import type { LoadedPlugin, SkippedPlugin } from "./loader.js"; + +export interface DefaultLook { + layout: string | null; + theme: string | null; +} + +interface LookLoader { + get(pluginId: string): Pick | undefined; + list(): Array>; + skipped(): SkippedPlugin[]; +} + +/** + * The first default-look plugin's default layout (else its first one) and + * that layout's theme. A default-look plugin with themes only fills in the + * theme. Null where none is installed, and the browser uses its built-in. + */ +export function resolveDefaultLook(loader: LookLoader): DefaultLook { + const look: DefaultLook = { layout: null, theme: null }; + for (const id of defaultLookPluginIds()) { + const contributes = loader.get(id)?.manifest.contributes; + const layouts = contributes?.layouts ?? []; + const layout = layouts.find((entry) => entry.default) ?? layouts[0]; + if (layout && !look.layout) { + look.layout = layout.id; + look.theme ??= layout.defaultTheme ?? null; + } + if (!look.theme && contributes?.themes?.length) { + look.theme = contributes.themes[0].id; + } + } + return look; +} + +/** Plugins with a layout or theme that were found but did not load. */ +export function unavailableLooks(loader: LookLoader): SkippedPlugin[] { + return loader + .skipped() + .filter((plugin) => plugin.layouts.length > 0 || plugin.themes.length > 0); +} + +/** + * Layout ids some installed plugin brings, whether it runs, is turned off or + * failed to load. A choice outside this set was uninstalled, so the next + * choice down applies instead of the repair view. + */ +export function installedLayoutIds(loader: LookLoader): Set { + const ids = new Set(); + for (const plugin of loader.list()) { + for (const layout of plugin.manifest.contributes?.layouts ?? []) { + ids.add(layout.id); + } + } + for (const plugin of loader.skipped()) { + for (const id of plugin.layouts) ids.add(id); + } + return ids; +} + +/** TERMIX_SAFE_LAYOUT=1 starts everyone in the layout repair view. */ +export function safeLayoutForced(): boolean { + const value = process.env.TERMIX_SAFE_LAYOUT?.trim().toLowerCase(); + return value === "1" || value === "true"; +} + +export const LOOK_ID_PATTERN = /^[a-z][a-z0-9-]{1,39}$/; +export const LAYOUT_SETTING = "ui.layout"; +export const THEME_SETTING = "ui.theme"; diff --git a/src/backend/plugins/manage.ts b/src/backend/plugins/manage.ts index 6fd33de7e..b87a55cc4 100644 --- a/src/backend/plugins/manage.ts +++ b/src/backend/plugins/manage.ts @@ -7,7 +7,9 @@ * sha256 and signature checks and stays marked unverified. Bundled plugins ship in the * image and can still be uninstalled: their files are deleted and the id is * recorded in the settings table, so the shipped copy is ignored from then - * on and a reinstall downloads the plugin like any other. + * on and a reinstall downloads the plugin like any other. The one exception + * is a default-look plugin (bundled-index.ts): the app is drawn with it, so + * it can be disabled or swapped but never uninstalled. */ import crypto from "node:crypto"; @@ -15,6 +17,7 @@ import fs from "node:fs"; import path from "node:path"; import semver from "semver"; import { pluginLogger } from "../utils/logger.js"; +import { isDefaultLookPlugin } from "./bundled-index.js"; import { activatePlugin, deactivatePlugin, @@ -31,6 +34,7 @@ import { } from "./paths.js"; import { OFFICIAL_REGISTRY_ID, + canRunRelease, downloadRelease, fetchRegistryIndex, channelReleases, @@ -39,6 +43,7 @@ import { getRegistryStatus, isApiCompatible, latestOnChannel, + neededTermix, officialSource, parsePluginChannel, type PluginChannel, @@ -254,10 +259,13 @@ export interface RegistryListing { videoId?: string; features: string[]; docs?: string; + contributes: string[]; versions: Array<{ version: string; prerelease: boolean; compatible: boolean; + /** The Termix range this version needs, when this build is too old. */ + needsTermix: string | null; capabilities: string[]; publishedAt?: string; releaseNotesUrl?: string; @@ -393,7 +401,7 @@ export async function listRegistry( const channel = channelOf(record); const latest = latestOnChannel(plugin, channel); const latestBeta = - plugin.prereleases.find((entry) => isApiCompatible(entry.api)) ?? null; + plugin.prereleases.find((entry) => canRunRelease(entry)) ?? null; const installedVersion = live?.manifest.version ?? record?.version ?? null; const updateAvailable = Boolean( @@ -416,10 +424,12 @@ export async function listRegistry( videoId: plugin.videoId, features: plugin.features, docs: plugin.docs, + contributes: plugin.contributes, versions: channelReleases(plugin, "beta").map((entry) => ({ version: entry.version, prerelease: Boolean(entry.prerelease), - compatible: isApiCompatible(entry.api), + compatible: canRunRelease(entry), + needsTermix: neededTermix(entry), capabilities: entry.capabilities, publishedAt: entry.publishedAt, releaseNotesUrl: entry.releaseNotesUrl, @@ -899,9 +909,12 @@ async function requireRelease( "NOT_IN_REGISTRY", ); } - if (!isApiCompatible(found.release.api)) { + if (!canRunRelease(found.release)) { + const termix = neededTermix(found.release); throw new PluginManageError( - `${pluginId} ${found.release.version} does not run on this version of Termix`, + termix + ? `${pluginId} ${found.release.version} needs Termix ${termix}` + : `${pluginId} ${found.release.version} does not run on this version of Termix`, 409, "INCOMPATIBLE", ); @@ -1154,6 +1167,13 @@ export async function uninstallPluginUnlocked( throw new PluginManageError("Plugin not found", 404, "NOT_FOUND"); } const shipped = loader.bundledIds().has(pluginId); + if (shipped && isDefaultLookPlugin(pluginId)) { + throw new PluginManageError( + "The default look cannot be uninstalled", + 409, + "DEFAULT_LOOK", + ); + } const dependents = dependentChain(pluginId, await enabledIds()); if (dependents.length > 0) { @@ -1492,6 +1512,14 @@ function validateUpload(buffer: Buffer): PluginManifest { "INCOMPATIBLE", ); } + const termix = neededTermix({ engine: manifest.engine }); + if (termix) { + throw new PluginManageError( + `${manifest.id} ${manifest.version} needs Termix ${termix}`, + 409, + "INCOMPATIBLE", + ); + } return manifest; } @@ -1634,41 +1662,94 @@ export function installUpload( }); } +/** + * Loads the copy in the bundled folder again. Running dependents stop first + * and start again after. `prepare` runs once the plugin is stopped, for work + * that needs its files out of use. + */ +async function reloadBundledUnlocked( + pluginId: string, + prepare?: (previous: LoadedPlugin | undefined) => Promise, +): Promise<{ id: string; version: string; state: string }> { + const { loader } = getPluginRuntime(); + const previous = loader.get(pluginId); + if (previous && previous.source !== "bundled" && !prepare) { + throw new PluginManageError( + `${pluginId} is not loaded from the bundled folder`, + 409, + "NOT_BUNDLED", + ); + } + const dependents = previous + ? dependentChain(pluginId, await enabledIds()).filter( + (id) => loader.get(id)?.state === "active", + ) + : []; + for (const id of dependents) await deactivatePlugin(id); + await unloadPlugin(pluginId); + if (prepare) await prepare(previous); + + const plugin = await loader.loadBundled(pluginId); + await registerLoadedPlugins([plugin]); + + let state = "stopped"; + if ((await enabledIds()).has(pluginId)) { + const { missing } = dependencyChain(pluginId); + state = missing.length > 0 ? "blocked" : await startAndRecord(pluginId); + } + for (const id of [...dependents].reverse()) await startAndRecord(id); + return { id: pluginId, version: plugin.manifest.version, state }; +} + /** * Loads the copy in the bundled folder again after it was rebuilt, for the - * dev runner. Running dependents stop first and start again after. + * dev runner. */ export function reloadBundledPlugin( pluginId: string, +): Promise<{ id: string; version: string; state: string }> { + return serialized(() => reloadBundledUnlocked(pluginId)); +} + +/** + * Puts a bundled plugin back to the copy that shipped with this install, + * dropping any update downloaded over it. The shipped version is pinned so + * auto update does not bring a broken release straight back; unpin it in the + * Plugins tab to follow updates again. + */ +export function restoreBundledPlugin( + pluginId: string, ): Promise<{ id: string; version: string; state: string }> { return serialized(async () => { + if (!ID_PATTERN.test(pluginId)) { + throw new PluginManageError("Invalid plugin id", 400, "INVALID_ID"); + } const { loader } = getPluginRuntime(); - const previous = loader.get(pluginId); - if (previous && previous.source !== "bundled") { + if (!loader.bundledIds().has(pluginId) || !isBundledOnDisk(pluginId)) { throw new PluginManageError( - `${pluginId} is not loaded from the bundled folder`, + "This plugin has no bundled copy", 409, - "NOT_BUNDLED", + "NO_BUNDLED_COPY", ); } - const dependents = previous - ? dependentChain(pluginId, await enabledIds()).filter( - (id) => loader.get(id)?.state === "active", - ) - : []; - for (const id of dependents) await deactivatePlugin(id); - await unloadPlugin(pluginId); - - const plugin = await loader.loadBundled(pluginId); - await registerLoadedPlugins([plugin]); - - let state = "stopped"; - if ((await enabledIds()).has(pluginId)) { - const { missing } = dependencyChain(pluginId); - state = missing.length > 0 ? "blocked" : await startAndRecord(pluginId); - } - for (const id of [...dependents].reverse()) await startAndRecord(id); - return { id: pluginId, version: plugin.manifest.version, state }; + const { createCurrentPluginRepository } = await repos(); + const repository = createCurrentPluginRepository(); + const result = await reloadBundledUnlocked(pluginId, async (previous) => { + await removeArtifactFiles(pluginId, previous?.artifact); + await fs.promises.rm(path.join(getUnpackedPluginsDir(), pluginId), { + recursive: true, + force: true, + }); + }); + await repository.update(pluginId, { + autoUpdate: false, + pinnedVersion: result.version, + }); + await applyPin(pluginId, result.version); + pluginLogger.info(`Restored the bundled copy of ${pluginId}`, { + operation: "plugin_restore_bundled", + }); + return result; }); } diff --git a/src/backend/plugins/manifest.ts b/src/backend/plugins/manifest.ts index ee76eb848..7229a7e0e 100644 --- a/src/backend/plugins/manifest.ts +++ b/src/backend/plugins/manifest.ts @@ -21,6 +21,7 @@ export { pluginFeatures, pluginDocsUrl, pluginEnvVars, + validateThemeCss, } from "@termix-ssh/plugin-sdk/manifest"; export type { diff --git a/src/backend/hosts/status/jump-probe-pool.ts b/src/backend/plugins/probe/jump-probe-pool.ts similarity index 90% rename from src/backend/hosts/status/jump-probe-pool.ts rename to src/backend/plugins/probe/jump-probe-pool.ts index 15cd744f1..28e488b55 100644 --- a/src/backend/hosts/status/jump-probe-pool.ts +++ b/src/backend/plugins/probe/jump-probe-pool.ts @@ -97,3 +97,10 @@ export class JumpProbePool { if (this.entries.get(key) === entry) this.entries.delete(key); } } + +/** One pool for core's checks and for plugins probing through ctx. */ +export const sharedJumpProbes = new JumpProbePool(async (jumpHosts, userId) => { + const { createJumpHostChain } = + await import("../../hosts/jump-host-chain.js"); + return createJumpHostChain(jumpHosts, userId); +}); diff --git a/src/backend/hosts/status/tcp-ping.ts b/src/backend/plugins/probe/tcp-ping.ts similarity index 100% rename from src/backend/hosts/status/tcp-ping.ts rename to src/backend/plugins/probe/tcp-ping.ts diff --git a/src/backend/plugins/registry-index.ts b/src/backend/plugins/registry-index.ts index d52d6af18..db9433d9b 100644 --- a/src/backend/plugins/registry-index.ts +++ b/src/backend/plugins/registry-index.ts @@ -15,11 +15,14 @@ import fs from "node:fs"; import path from "node:path"; import semver from "semver"; import { + isApiCompatible as satisfiesPluginApi, + isTermixCompatible, pluginDocsUrl, pluginFeatures, SUPPORTED_PLUGIN_API_VERSION, youtubeVideoId, } from "@termix-ssh/plugin-sdk/manifest"; +import { getLocalVersion } from "../utils/app-version.js"; import { safeOutboundFetch } from "../utils/safe-outbound-fetch.js"; import { pluginLogger } from "../utils/logger.js"; import { @@ -59,7 +62,10 @@ const ID_PATTERN = /^[a-z0-9][a-z0-9-]*$/; export interface RegistryVersion { version: string; + /** The API major, all that builds before engine ranges read. */ api: string; + /** The manifest's engine ranges, when the index lists them. */ + engine?: { api: string; termix: string }; url: string; sha256: string; signature: string; @@ -92,6 +98,8 @@ export interface RegistryPlugin { features: string[]; /** https docs link from the index. */ docs?: string; + /** The manifest's contributes keys, like layouts, themes or tabs. */ + contributes: string[]; /** Stable releases, newest first. */ versions: RegistryVersion[]; /** @@ -149,6 +157,9 @@ function parseVersion(raw: unknown): RegistryVersion | null { return null; } if (!/^[a-f0-9]{64}$/i.test(sha256)) return null; + const engine = entry.engine as Record | undefined; + const engineApi = asString(engine?.api); + const engineTermix = asString(engine?.termix); try { if (new URL(url).protocol !== "https:") return null; } catch { @@ -157,6 +168,9 @@ function parseVersion(raw: unknown): RegistryVersion | null { return { version, api: asString(entry.api) ?? "", + ...(engineApi && engineTermix + ? { engine: { api: engineApi, termix: engineTermix } } + : {}), url, sha256: sha256.toLowerCase(), signature, @@ -191,11 +205,15 @@ export function parseRegistryIndex(raw: unknown): RegistryIndex { .map(parseVersion) .filter((v): v is RegistryVersion => v !== null) .sort((a, b) => semver.rcompare(a.version, b.version)); - const versions = parseList(entry.versions).filter( - (v) => !semver.prerelease(v.version), - ); - const prereleases = parseList(entry.prereleases) + // gated holds releases that need a newer Termix than builds which skip + // the engine.termix check, so only builds that check it read the list. + const gated = parseList(entry.gated); + const versions = [...parseList(entry.versions), ...gated] + .filter((v) => !semver.prerelease(v.version)) + .sort((a, b) => semver.rcompare(a.version, b.version)); + const prereleases = [...parseList(entry.prereleases), ...gated] .filter((v) => semver.prerelease(v.version)) + .sort((a, b) => semver.rcompare(a.version, b.version)) .map((v) => ({ ...v, prerelease: true })); if (versions.length === 0 && prereleases.length === 0) continue; plugins.push({ @@ -209,6 +227,14 @@ export function parseRegistryIndex(raw: unknown): RegistryIndex { videoId: youtubeVideoId(entry.video) ?? undefined, features: pluginFeatures(entry.features), docs: pluginDocsUrl(entry.docs) ?? undefined, + contributes: Array.isArray(entry.contributes) + ? entry.contributes + .filter( + (kind): kind is string => + typeof kind === "string" && /^[A-Za-z]+$/.test(kind), + ) + .slice(0, 50) + : [], versions, prereleases, }); @@ -229,6 +255,29 @@ export function isApiCompatible(api: string): boolean { return major !== undefined && String(major) === SUPPORTED_PLUGIN_API_VERSION; } +/** + * The Termix range a release needs when this build is too old for it, or + * null when this build satisfies it. + */ +export function neededTermix( + release: Pick, +): string | null { + const range = release.engine?.termix; + if (!range) return null; + return isTermixCompatible(range, getLocalVersion()) ? null : range; +} + +/** Whether this build can run a release: its API and its engine ranges. */ +export function canRunRelease( + release: Pick, +): boolean { + if (!isApiCompatible(release.api)) return false; + if (!release.engine) return true; + return ( + satisfiesPluginApi(release.engine.api) && neededTermix(release) === null + ); +} + /** Follows redirects by hand so each hop still gets the outbound checks. */ async function fetchFollowing(url: string): Promise { let current = url; @@ -427,9 +476,8 @@ export function latestOnChannel( channel: PluginChannel, ): RegistryVersion | null { return ( - channelReleases(plugin, channel).find((entry) => - isApiCompatible(entry.api), - ) ?? null + channelReleases(plugin, channel).find((entry) => canRunRelease(entry)) ?? + null ); } @@ -486,9 +534,12 @@ export async function downloadRelease( release: RegistryVersion, keys: readonly TrustedPluginKey[] = TRUSTED_PLUGIN_KEYS, ): Promise<{ file: string; signatureFile: string }> { - if (!isApiCompatible(release.api)) { + if (!canRunRelease(release)) { + const termix = neededTermix(release); throw new Error( - `${pluginId} ${release.version} needs plugin API ${release.api}, this build runs ${SUPPORTED_PLUGIN_API_VERSION}`, + termix + ? `${pluginId} ${release.version} needs Termix ${termix}, this is ${getLocalVersion()}` + : `${pluginId} ${release.version} needs plugin API ${release.engine?.api ?? release.api}, this build runs ${SUPPORTED_PLUGIN_API_VERSION}`, ); } diff --git a/src/backend/plugins/registry.ts b/src/backend/plugins/registry.ts index 050f57511..ed438ffb6 100644 --- a/src/backend/plugins/registry.ts +++ b/src/backend/plugins/registry.ts @@ -41,6 +41,18 @@ export function revoke(key: string, value?: unknown): boolean { return providers.delete(key); } +/** + * The provider under ".", for a well-known name core + * reads without knowing which plugin supplies it. First match wins. + */ +export function consumeByName(name: string): T | undefined { + const suffix = `.${name}`; + for (const [key, value] of providers) { + if (key.endsWith(suffix)) return value as T; + } + return undefined; +} + export function has(key: string): boolean { return providers.has(key); } diff --git a/src/backend/plugins/settings.ts b/src/backend/plugins/settings.ts index f55c523e3..0f9c0c073 100644 --- a/src/backend/plugins/settings.ts +++ b/src/backend/plugins/settings.ts @@ -44,6 +44,10 @@ export type { PluginSettingsScope }; export const CORE_SETTINGS_ALLOWLIST: readonly string[] = [ "app_name", "notification_private_endpoint_allowlist", + // Read once by the branding provider to keep a pre-plugin install's branding. + "branding_app_name", + "branding_tagline", + "branding_logo", ]; type ChangeListener = (value: unknown) => void; diff --git a/src/backend/plugins/user-data.ts b/src/backend/plugins/user-data.ts index 4807deb07..59bb8c4b9 100644 --- a/src/backend/plugins/user-data.ts +++ b/src/backend/plugins/user-data.ts @@ -27,7 +27,7 @@ export interface UserOwnedTable { otherUserColumns: string[]; /** refHost columns. */ hostColumns: string[]; - /** Columns that travel, as SQL names. The primary key and secrets do not. */ + /** Columns that travel, as SQL names. Auto ids and secrets do not. */ columns: string[]; /** The owner column is the primary key: one row per user. */ userIsKey: boolean; @@ -57,9 +57,7 @@ export function listUserOwnedTables(): UserOwnedTable[] { .filter( ([, column]) => column === user[1] || - (column.type !== "id" && - !column.primaryKey && - column.type !== "encryptedText"), + (column.type !== "id" && column.type !== "encryptedText"), ) .map(([property, column]) => columnName(property, column)), userIsKey: !!user[1].primaryKey, diff --git a/src/backend/plugins/ws.ts b/src/backend/plugins/ws.ts index c69eecd85..b9c0cf873 100644 --- a/src/backend/plugins/ws.ts +++ b/src/backend/plugins/ws.ts @@ -54,6 +54,8 @@ interface Route { rawHandler?: RawUpgradeHandler; /** Sockets handed to rawHandler, destroyed when the route goes away. */ rawSockets?: Set; + /** Core's own socket: served under /plugin-ws/core/, no plugin grant. */ + core?: boolean; } /** Largest frame a plugin socket accepts. ws defaults to 100MB. */ @@ -163,7 +165,10 @@ async function handlePluginUpgrade( // checked per upgrade rather than at registration, and a revoke takes effect // on the next connection without a restart. const { hasCapability } = await import("./permissions.js"); - if (!(await hasCapability(route.pluginId, "network:serve", route.declared))) { + if ( + !route.core && + !(await hasCapability(route.pluginId, "network:serve", route.declared)) + ) { reject(socket, 403, "Forbidden"); return true; } @@ -303,6 +308,32 @@ export function registerPluginWsRoute( return () => disposeRoute(routeKey, pluginId, normalized); } +/** + * A socket core itself serves, at /plugin-ws/core/, so it rides the same + * proxy rules and token checks as plugin sockets. Authenticated like them. + */ +export function registerCoreWsRoute( + path: string, + handler: PluginWebSocketHandler, +): () => void { + const routeKey = key("core", path); + const normalized = normalizePath(path); + if (routes.has(routeKey)) return () => {}; + routes.set(routeKey, { + pluginId: "core", + path: normalized, + options: {}, + declared: [], + handler, + core: true, + }); + servers.set( + routeKey, + new WebSocketServer({ noServer: true, maxPayload: MAX_PAYLOAD_BYTES }), + ); + return () => disposeRoute(routeKey, "core", normalized); +} + export function registerPluginWsUpgrade( pluginId: string, path: string, diff --git a/src/backend/services/recent-activity.ts b/src/backend/services/recent-activity.ts deleted file mode 100644 index bdc0728a2..000000000 --- a/src/backend/services/recent-activity.ts +++ /dev/null @@ -1,90 +0,0 @@ -import { - createCurrentHostResolutionRepository, - createCurrentRbacAccessRepository, - createCurrentRecentActivityRepository, - createCurrentRoleRepository, -} from "../database/repositories/factory.js"; -import { dashboardLogger } from "../utils/logger.js"; - -const RATE_LIMIT_MS = 1000; -const activityRateLimiter = new Map(); - -/** - * Activity types are whatever the recording plugin calls them (each tab - * declares the types it reopens), so only the shape is checked here. - */ -function isActivityType(type: unknown): type is string { - return typeof type === "string" && /^[a-z][a-z0-9_]{0,31}$/.test(type); -} - -export type RecordActivityResult = - | { status: "logged"; id: number } - | { status: "rate_limited" } - | { status: "invalid_type" } - | { status: "denied" }; - -/** - * Adds one recent activity entry: the dashboard's /activity/log route and - * ctx.hosts.recordActivity both come through here, so the rate limit and the - * access rule are the same for both. - */ -export async function recordRecentActivity( - userId: string, - entry: { type: string; hostId: number; hostName: string }, -): Promise { - const { type, hostId, hostName } = entry; - if (!isActivityType(type)) { - return { status: "invalid_type" }; - } - - const rateLimitKey = `${userId}:${hostId}:${type}`; - const now = Date.now(); - const lastLogged = activityRateLimiter.get(rateLimitKey); - if (lastLogged && now - lastLogged < RATE_LIMIT_MS) { - return { status: "rate_limited" }; - } - activityRateLimiter.set(rateLimitKey, now); - - if (activityRateLimiter.size > 10000) { - for (const [key, timestamp] of activityRateLimiter.entries()) { - if (now - timestamp > RATE_LIMIT_MS * 2) activityRateLimiter.delete(key); - } - } - - const isOwnedHost = - await createCurrentHostResolutionRepository().isHostOwnedByUser( - hostId, - userId, - ); - if (!isOwnedHost) { - const roleIds = await createCurrentRoleRepository().listUserRoleIds(userId); - const sharedHosts = - await createCurrentRbacAccessRepository().listVisibleHostAccessEntries( - userId, - roleIds, - ); - if (!sharedHosts.some((access) => access.hostId === hostId)) { - return { status: "denied" }; - } - } - - const result = await createCurrentRecentActivityRepository().create({ - userId, - type, - hostId, - hostName, - }); - - // Best-effort trim; a failure here must not fail the log itself. - try { - await createCurrentRecentActivityRepository().trimUserActivity(userId, 100); - } catch (trimErr) { - dashboardLogger.warn("Failed to trim recent_activity (non-fatal)", { - operation: "trim_recent_activity", - userId, - error: trimErr instanceof Error ? trimErr.message : String(trimErr), - }); - } - - return { status: "logged", id: result.id }; -} diff --git a/src/backend/starter.ts b/src/backend/starter.ts index 70ab554aa..10bd20a28 100644 --- a/src/backend/starter.ts +++ b/src/backend/starter.ts @@ -200,9 +200,9 @@ async function provisionLocalDesktopUserIfNeeded(): Promise { const { runCoreBootMigrations } = await import("./boot.js"); await runCoreBootMigrations(); - const { hostStatusService } = - await import("./hosts/status/host-status-service.js"); - hostStatusService.start(); + // Status comes from whichever plugin provides it; none means unknown. + const { hostStatus } = await import("./plugins/host-status-provider.js"); + hostStatus.start(); // Counted before the desktop user is provisioned, so a first desktop // run counts as a fresh install too. @@ -226,7 +226,7 @@ async function provisionLocalDesktopUserIfNeeded(): Promise { // Plugins serve their routes under /plugin-api// and /plugin-ws// // through ctx on activate, and start their own schedulers there, so // disabling one answers 503 instead of leaving a dead import here. The - // dashboard's own uptime and recent-activity routes are core and are + // dashboard's uptime and recent-activity routes are core forwarders and are // mounted on the main server in database.ts. // Initialize log level from database settings @@ -265,17 +265,15 @@ async function provisionLocalDesktopUserIfNeeded(): Promise { }); } - // After plugins, so their sync entities are registered before a pass. + // Sync itself starts with the plugin that provides it. if (process.env.ELECTRON_EMBEDDED === "true") { try { - const { startDesktopSync } = await import("./sync/client/engine.js"); - startDesktopSync(); const { startDesktopUpdateCheck } = await import("./updates/desktop-update-check.js"); startDesktopUpdateCheck(); } catch (error) { - systemLogger.warn("Desktop sync failed to start", { - operation: "sync_start", + systemLogger.warn("Desktop update check failed to start", { + operation: "update_check_start", error: getErrorMessage(error), }); } diff --git a/src/backend/sync/accounts.ts b/src/backend/sync/accounts.ts new file mode 100644 index 000000000..7a76b6463 --- /dev/null +++ b/src/backend/sync/accounts.ts @@ -0,0 +1,94 @@ +/** + * Accounts on a desktop. Each local user is one account: the plain local one, + * or the replica of a linked server. Exactly one is active, and the app signs + * in as it. Switching only changes which user the desktop auto session picks, + * so no data is shared between accounts. + */ + +import crypto from "crypto"; +import { + createCurrentRoleRepository, + createCurrentSettingsRepository, + createCurrentUserRepository, +} from "../database/repositories/factory.js"; +import { resolveDesktopAutoSessionUser } from "../database/routes/desktop-auto-session.js"; +import { AuthManager } from "../utils/auth-manager.js"; + +const ACTIVE_KEY = "desktop_active_user"; + +/** The user the desktop signs in as. Falls back to the usual single-user pick. */ +export async function getActiveUserId(): Promise { + const users = await createCurrentUserRepository().listAll(); + if (users.length === 0) return null; + const saved = await createCurrentSettingsRepository().get(ACTIVE_KEY); + return resolveDesktopAutoSessionUser(users, saved)?.id ?? null; +} + +export async function setActiveUserId(userId: string): Promise { + const user = await createCurrentUserRepository().findById(userId); + if (!user) throw new Error("No such account"); + await createCurrentSettingsRepository().set(ACTIVE_KEY, userId); +} + +/** Saved choice only, for the auto session route. */ +export async function getSavedActiveUserId(): Promise { + return createCurrentSettingsRepository().get(ACTIVE_KEY); +} + +export interface DesktopAccount { + id: string; + username: string; + isAdmin: boolean; + active: boolean; +} + +export async function listAccounts(): Promise { + const users = await createCurrentUserRepository().listAll(); + const active = await getActiveUserId(); + return users.map((user) => ({ + id: user.id, + username: user.username, + isAdmin: !!user.isAdmin, + active: user.id === active, + })); +} + +/** A new empty account for another server to be linked to. */ +export async function createAccount(): Promise { + const users = createCurrentUserRepository(); + const id = crypto.randomUUID(); + await users.create({ + id, + username: `server-${id.slice(0, 8)}`, + passwordHash: "", + isAdmin: true, + }); + try { + await createCurrentRoleRepository().assignRoleNameToUser({ + userId: id, + roleName: "admin", + grantedBy: id, + }); + } catch { + // The account still works; admin is also set on the user row. + } + await AuthManager.getInstance().registerUser( + id, + crypto.randomBytes(32).toString("hex"), + ); + return id; +} + +/** Deletes an account and everything it owns. The last account stays. */ +export async function removeAccount(userId: string): Promise { + const users = await createCurrentUserRepository().listAll(); + if (users.length <= 1) throw new Error("The last account cannot be removed"); + const active = await getActiveUserId(); + const { deleteUserAndRelatedData } = + await import("../database/routes/delete-user-data.js"); + await deleteUserAndRelatedData(userId); + if (active === userId) { + const next = users.find((user) => user.id !== userId); + if (next) await setActiveUserId(next.id); + } +} diff --git a/src/backend/sync/client/device.ts b/src/backend/sync/client/device.ts new file mode 100644 index 000000000..b162a8c05 --- /dev/null +++ b/src/backend/sync/client/device.ts @@ -0,0 +1,5 @@ +import os from "os"; + +export function defaultDeviceName(): string { + return os.hostname() || "Termix Desktop"; +} diff --git a/src/backend/sync/client/electron-proxy.ts b/src/backend/sync/client/electron-proxy.ts index b10b00d37..c7f31f34b 100644 --- a/src/backend/sync/client/electron-proxy.ts +++ b/src/backend/sync/client/electron-proxy.ts @@ -14,7 +14,11 @@ export async function applyElectronProxyConfig( link: | (Pick< SyncLink, - "serverUrl" | "customHeaders" | "basicAuth" | "allowInvalidCertificate" + | "serverUrl" + | "customHeaders" + | "basicAuth" + | "allowInvalidCertificate" + | "clientCert" > & { sessionToken?: string | null; status?: string }) | null, ): Promise { @@ -30,6 +34,7 @@ export async function applyElectronProxyConfig( headers: link.customHeaders ?? [], basicAuth: link.basicAuth ?? null, allowInvalidCertificate: !!link.allowInvalidCertificate, + clientCert: link.clientCert ?? null, } : null, ); diff --git a/src/backend/sync/client/engine.ts b/src/backend/sync/client/engine.ts deleted file mode 100644 index 34b0c46ff..000000000 --- a/src/backend/sync/client/engine.ts +++ /dev/null @@ -1,780 +0,0 @@ -/** - * The linked desktop's side of sync. - * - * A pass pushes local edits, then pulls what changed on the server after the - * saved cursor, then backfills any entity type this device has never pulled - * (first link, a feature switched on, a category turned back on). Local - * edits are found the same way the server finds changes: by comparing each - * row's hash with the one recorded at the last sync. - */ - -import os from "os"; -import type { SyncRow } from "@termix-ssh/plugin-sdk/backend"; -import { - getEntity, - listEntities, - onEntitiesChanged, - type RegisteredSyncEntity, -} from "../../plugins/sync-registry.js"; -import { syncLogger } from "../../utils/logger.js"; -import { sendCoreAlert } from "../../notify/core-notify.js"; -import { registerCoreSyncEntities } from "../entities.js"; -import { - createResolvers, - deleteStoredRow, - loadWireRow, - loadWireRows, - writeWireRow, -} from "../store.js"; -import { - listRecords, - putRecord, - recordKey, - saveConflict, - setRecordError, - type SyncRecord, -} from "../records.js"; -import { orderSelfReferences } from "../wire.js"; -import { onLocalWrite } from "../server/change-watcher.js"; -import { getLink, updateLink, type SyncLink } from "./link-store.js"; -import { RemoteError, remoteFetch, remoteJson } from "./http.js"; -import { mirrorPlugins } from "./plugins.js"; -import { applyElectronProxyConfig } from "./electron-proxy.js"; - -export interface RemoteChange { - seq: number; - entityType: string; - syncId: string; - revision: number; - deleted: boolean; - row: SyncRow | null; -} - -interface ChangesPage { - changes: RemoteChange[]; - nextCursor: number; - hasMore: boolean; - entityTypes: Array<{ - type: string; - owner: string; - order: number; - readOnly: boolean; - }>; -} - -interface PushOp { - entityType: string; - syncId: string; - baseRevision: number; - op: "upsert" | "delete"; - row?: SyncRow; -} - -type PushResult = - | { syncId: string; entityType: string; status: "ok"; revision: number } - | { - syncId: string; - entityType: string; - status: "conflict"; - revision: number; - deleted: boolean; - row: SyncRow | null; - } - | { syncId: string; entityType: string; status: "rejected"; reason: string }; - -const MAX_BATCH_OPS = 200; -const MAX_BATCH_BYTES = 1_000_000; -const MAX_PAGES = 500; -const SAFETY_INTERVAL_MS = 5 * 60_000; -const REFRESH_CHECK_MS = 24 * 60 * 60_000; -const DELETED_HASH = "deleted"; - -export interface EngineState { - running: boolean; - /** Local changes found in the last pass that the server has not taken. */ - pending: number; - /** Entity types the server runs, from the last pull. */ - serverTypes: Array<{ type: string; owner: string; readOnly: boolean }>; - lastPassAt: string | null; -} - -/** An entity this device could sync: registered here, and on the server. */ -function syncable( - entity: RegisteredSyncEntity, - link: SyncLink, - serverTypes: Set | null, -): boolean { - if (link.disabledTypes.includes(entity.type)) return false; - if (serverTypes && !serverTypes.has(entity.type)) return false; - return true; -} - -function sortChanges(changes: RemoteChange[]): RemoteChange[] { - const byType = new Map(); - for (const change of changes) { - const list = byType.get(change.entityType) ?? []; - list.push(change); - byType.set(change.entityType, list); - } - const types = [...byType.keys()].sort( - (a, b) => (getEntity(a)?.order ?? 1e6) - (getEntity(b)?.order ?? 1e6), - ); - const out: RemoteChange[] = []; - for (const type of types) { - const list = byType.get(type)!; - const entity = getEntity(type); - // Only the latest record of each row matters. - const latest = new Map(); - for (const change of list) latest.set(change.syncId, change); - const live = [...latest.values()].filter((change) => !change.deleted); - const gone = [...latest.values()].filter((change) => change.deleted); - if (entity) { - const bySyncId = new Map(live.map((change) => [change.syncId, change])); - const ordered = orderSelfReferences( - entity, - live.map((change) => ({ - ...(change.row ?? {}), - syncId: change.syncId, - })), - ); - out.push(...ordered.map((row) => bySyncId.get(row.syncId as string)!)); - } else { - out.push(...live); - } - out.push(...gone); - } - return out; -} - -export class SyncEngine { - private running = false; - private again = false; - private timer: NodeJS.Timeout | null = null; - private safety: NodeJS.Timeout | null = null; - private backoffMs = 0; - private events: AbortController | null = null; - private eventsFor: string | null = null; - private lastRefreshCheck = 0; - private serverTypes: Set | null = null; - private stopFns: Array<() => void> = []; - private alerted = false; - readonly state: EngineState = { - running: false, - pending: 0, - serverTypes: [], - lastPassAt: null, - }; - - start(): void { - if (this.safety) return; - registerCoreSyncEntities(); - this.safety = setInterval(() => this.request(0), SAFETY_INTERVAL_MS); - this.safety.unref?.(); - this.stopFns.push(onLocalWrite(() => this.request(2000))); - // A plugin that came up may bring entity types to backfill. - this.stopFns.push(onEntitiesChanged(() => this.request(3000))); - this.request(3000); - } - - stop(): void { - if (this.safety) clearInterval(this.safety); - if (this.timer) clearTimeout(this.timer); - this.safety = this.timer = null; - this.closeEvents(); - for (const stop of this.stopFns.splice(0)) stop(); - } - - /** Runs a pass after `delayMs`, folding requests that arrive meanwhile. */ - request(delayMs = 0): void { - if (this.timer) { - if (delayMs > 0) return; - clearTimeout(this.timer); - } - this.timer = setTimeout(() => { - this.timer = null; - void this.run(); - }, delayMs); - this.timer.unref?.(); - } - - /** Runs a pass now and waits for it. */ - async syncNow(): Promise { - if (this.running) { - this.again = true; - while (this.running) await new Promise((r) => setTimeout(r, 100)); - return; - } - await this.run(); - } - - /** Forgets what the server runs, after a relink or unlink. */ - reset(): void { - this.serverTypes = null; - this.state.serverTypes = []; - this.state.pending = 0; - this.alerted = false; - this.backoffMs = 0; - this.closeEvents(); - } - - private async run(): Promise { - if (this.running) { - this.again = true; - return; - } - this.running = this.state.running = true; - try { - await this.pass(); - } finally { - this.running = this.state.running = false; - this.state.lastPassAt = new Date().toISOString(); - if (this.again) { - this.again = false; - this.request(0); - } - } - } - - private async pass(): Promise { - const link = await getLink(); - if (!link?.sessionToken) { - this.closeEvents(); - return; - } - await updateLink({ status: "syncing" }); - - try { - await this.refreshSession(link); - const fresh = (await getLink())!; - await mirrorPlugins(fresh); - - // A first pass pulls before it pushes, so rows both sides already - // have are matched up instead of sent back as edits. - const firstPass = fresh.knownTypes.length === 0; - if (!firstPass) { - await this.push(fresh); - await this.pull(fresh); - } - await this.backfill((await getLink())!); - if (firstPass) await this.push((await getLink())!); - - this.backoffMs = 0; - this.alerted = false; - await updateLink({ - status: "idle", - lastError: null, - lastSyncAt: new Date().toISOString(), - }); - const done = (await getLink())!; - await applyElectronProxyConfig(done).catch(() => {}); - this.openEvents(done); - } catch (error) { - await this.fail(error); - } - } - - private async fail(error: unknown): Promise { - const remote = error instanceof RemoteError ? error : null; - const message = error instanceof Error ? error.message : String(error); - syncLogger.warn("Sync pass failed", { - operation: "sync_pass", - kind: remote?.kind ?? "internal", - error: message, - }); - - if (remote?.kind === "signed_out") { - const link = await updateLink({ - status: "signed_out", - lastError: message, - }); - await applyElectronProxyConfig(link).catch(() => {}); - this.closeEvents(); - if (!this.alerted) { - this.alerted = true; - await sendCoreAlert({ - title: "Sync is paused", - body: "This device was signed out of the server it syncs with. Sign in again from Sync to keep going.", - severity: "warning", - category: "termix.sync.signed_out", - dedupeKey: "sync:signed_out", - audience: "admins", - link: { tab: "sync" }, - }); - } - return; - } - - const offline = remote?.kind === "offline"; - await updateLink({ - status: offline ? "offline" : "error", - lastError: message, - }); - this.backoffMs = Math.min( - this.backoffMs ? this.backoffMs * 2 : 15_000, - 10 * 60_000, - ); - this.request(this.backoffMs); - } - - private async refreshSession(link: SyncLink): Promise { - if (Date.now() - this.lastRefreshCheck < REFRESH_CHECK_MS) return; - const result = await remoteJson<{ token: string | null }>( - link, - "/sync/v2/session/refresh", - { method: "POST", body: {} }, - ); - this.lastRefreshCheck = Date.now(); - if (result.token) await updateLink({ sessionToken: result.token }); - } - - /** Local rows the server does not have in this form yet. */ - async outbox(link: SyncLink): Promise<{ - ops: PushOp[]; - hashes: Map; - }> { - const records = await listRecords(link.userId); - const resolvers = createResolvers(link.userId); - const ops: PushOp[] = []; - const hashes = new Map(); - - for (const entity of listEntities()) { - if (entity.readOnly) continue; - if (!link.knownTypes.includes(entity.type)) continue; - if (!syncable(entity, link, this.serverTypes)) continue; - - const { rows, failed } = await loadWireRows( - entity, - link.userId, - resolvers, - ); - for (const row of rows.values()) { - const key = recordKey(entity.type, row.syncId); - const record = records.get(key); - if (record && !record.deleted && record.hash === 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, - baseRevision: record?.revision ?? 0, - op: "upsert", - row: row.wire, - }); - hashes.set(key, row.hash); - } - - for (const record of records.values()) { - if (record.entityType !== entity.type || record.deleted) continue; - 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 && - record.error !== "unavailable" - ) { - continue; - } - ops.push({ - entityType: entity.type, - syncId: record.syncId, - baseRevision: record.revision, - op: "delete", - }); - } - } - return { ops, hashes }; - } - - private async push(link: SyncLink): Promise { - const { ops, hashes } = await this.outbox(link); - this.state.pending = ops.length; - if (ops.length === 0) return; - - for (const batch of batches(ops)) { - const { results } = await remoteJson<{ results: PushResult[] }>( - link, - "/sync/v2/push", - { method: "POST", body: { ops: batch }, timeoutMs: 120_000 }, - ); - const byKey = new Map( - batch.map((op) => [recordKey(op.entityType, op.syncId), op]), - ); - for (const result of results) { - const key = recordKey(result.entityType, result.syncId); - const op = byKey.get(key); - if (!op) continue; - await this.settle(link, op, result, hashes.get(key) ?? null); - } - this.state.pending = Math.max(0, this.state.pending - batch.length); - } - } - - private async settle( - link: SyncLink, - op: PushOp, - result: PushResult, - hash: string | null, - ): Promise { - const userId = link.userId; - const entity = getEntity(op.entityType); - if (!entity) return; - - if (result.status === "ok") { - await putRecord(userId, op.entityType, op.syncId, { - revision: result.revision, - hash: op.op === "delete" ? null : hash, - deleted: op.op === "delete", - }); - return; - } - - if (result.status === "rejected") { - await setRecordError( - userId, - op.entityType, - op.syncId, - result.reason, - hash ?? DELETED_HASH, - ); - 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) { - await saveConflict( - userId, - op.entityType, - op.syncId, - op.row, - result.revision, - ); - } - const resolvers = createResolvers(userId); - if (result.deleted) { - await deleteStoredRow(entity, userId, op.syncId); - await putRecord(userId, op.entityType, op.syncId, { - revision: result.revision, - hash: null, - deleted: true, - }); - return; - } - await writeWireRow( - entity, - userId, - { ...result.row, syncId: op.syncId }, - resolvers, - ); - const written = await loadWireRow(entity, userId, op.syncId, resolvers); - await putRecord(userId, op.entityType, op.syncId, { - revision: result.revision, - hash: written?.hash ?? null, - deleted: false, - }); - } - - private async fetchChanges( - link: SyncLink, - cursor: number, - types?: string[], - ): Promise<{ changes: RemoteChange[]; cursor: number }> { - const all: RemoteChange[] = []; - let next = cursor; - for (let page = 0; page < MAX_PAGES; page++) { - const query = new URLSearchParams({ - cursor: String(next), - limit: "1000", - }); - if (types?.length) query.set("types", types.join(",")); - const result = await remoteJson( - link, - `/sync/v2/changes?${query.toString()}`, - { timeoutMs: 120_000 }, - ); - all.push(...result.changes); - next = result.nextCursor; - 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); - await this.apply( - link, - changes.filter( - (change) => - known.has(change.entityType) && - !link.disabledTypes.includes(change.entityType), - ), - false, - ); - await updateLink({ cursor }); - } - - /** Pulls every row of types this device has not pulled before. */ - private async backfill(link: SyncLink): Promise { - if (!this.serverTypes) { - // 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) - .filter( - (type) => - !link.knownTypes.includes(type) && - !link.disabledTypes.includes(type) && - this.serverTypes?.has(type), - ); - if (fresh.length === 0) return; - - const { changes, cursor } = await this.fetchChanges(link, 0, fresh); - await this.apply(link, changes, true); - const current = (await getLink())!; - await updateLink({ - knownTypes: [...new Set([...current.knownTypes, ...fresh])], - // On a first pass the backfill is the whole pull, so it sets the cursor. - ...(link.knownTypes.length === 0 ? { cursor } : {}), - }); - } - - /** - * Writes pulled changes. `adopt` is for a first pull of a type: a local row - * with no record yet takes the server's version without a conflict. - */ - private async apply( - link: SyncLink, - changes: RemoteChange[], - adopt: boolean, - ): Promise { - if (changes.length === 0) return; - const userId = link.userId; - const records = await listRecords(userId); - let missed = false; - const resolvers = createResolvers(userId, () => { - missed = true; - }); - const retry: RemoteChange[] = []; - - for (const change of sortChanges(changes)) { - const entity = getEntity(change.entityType); - if (!entity) continue; - const key = recordKey(change.entityType, change.syncId); - const record = records.get(key); - if (record && record.revision >= change.revision) continue; - - try { - if (!entity.readOnly) { - await this.keepLocalEdit( - entity, - userId, - change, - record, - adopt, - resolvers, - ); - } - if (change.deleted) { - await deleteStoredRow(entity, userId, change.syncId); - await putRecord(userId, change.entityType, change.syncId, { - revision: change.revision, - hash: null, - deleted: true, - }); - continue; - } - missed = false; - await writeWireRow( - entity, - userId, - { ...(change.row ?? {}), syncId: change.syncId }, - resolvers, - ); - if (missed) retry.push(change); - await this.recordApplied(entity, userId, change, resolvers); - } catch (error) { - syncLogger.warn("Could not apply a synced change", { - operation: "sync_apply", - entityType: change.entityType, - syncId: change.syncId, - error: error instanceof Error ? error.message : String(error), - }); - } - } - - // Rows that pointed at something written later in the same pull. - for (const change of retry) { - const entity = getEntity(change.entityType)!; - try { - await writeWireRow( - entity, - userId, - { ...(change.row ?? {}), syncId: change.syncId }, - resolvers, - ); - await this.recordApplied(entity, userId, change, resolvers); - } catch { - // The first write stands. - } - } - } - - private async recordApplied( - entity: RegisteredSyncEntity, - userId: string, - change: RemoteChange, - resolvers: ReturnType, - ): Promise { - const written = entity.readOnly - ? null - : await loadWireRow(entity, userId, change.syncId, resolvers); - await putRecord(userId, change.entityType, change.syncId, { - revision: change.revision, - hash: written?.hash ?? null, - deleted: false, - }); - } - - /** Saves a local edit the incoming change is about to replace. */ - private async keepLocalEdit( - entity: RegisteredSyncEntity, - userId: string, - change: RemoteChange, - record: SyncRecord | undefined, - adopt: boolean, - resolvers: ReturnType, - ): Promise { - if (!record && adopt) return; - const local = await loadWireRow( - entity, - userId, - change.syncId, - resolvers, - ).catch(() => null); - if (!local) return; - if (record && !record.deleted && record.hash === local.hash) return; - await saveConflict( - userId, - change.entityType, - change.syncId, - local.wire, - change.revision, - ); - } - - private openEvents(link: SyncLink): void { - const key = `${link.serverUrl}|${link.sessionToken}`; - if (this.events && this.eventsFor === key) return; - this.closeEvents(); - const controller = new AbortController(); - this.events = controller; - this.eventsFor = key; - void this.listen(link, controller); - } - - private closeEvents(): void { - this.events?.abort(); - this.events = null; - this.eventsFor = null; - } - - private async listen( - link: SyncLink, - controller: AbortController, - ): Promise { - try { - const response = await remoteFetch(link, "/sync/v2/events", { - signal: controller.signal, - }); - if (!response.ok || !response.body) - throw new Error(`HTTP ${response.status}`); - const decoder = new TextDecoder(); - let buffer = ""; - for await (const chunk of response.body) { - buffer += decoder.decode(chunk as Uint8Array, { stream: true }); - let index; - while ((index = buffer.indexOf("\n\n")) >= 0) { - const block = buffer.slice(0, index); - buffer = buffer.slice(index + 2); - if (/^event: change$/m.test(block)) this.request(500); - } - } - } catch { - // Dropped or refused: the safety interval still syncs, and the next - // successful pass reopens the stream. - } - if (this.events === controller) { - this.events = null; - this.eventsFor = null; - if (!controller.signal.aborted) { - setTimeout(() => this.request(0), 30_000).unref?.(); - } - } - } - - /** Where the link's proxy settings have to reach outside the backend. */ - async applyProxyConfig(): Promise { - await applyElectronProxyConfig(await getLink()); - } -} - -function* batches(ops: PushOp[]): Generator { - let batch: PushOp[] = []; - let bytes = 0; - for (const op of ops) { - const size = JSON.stringify(op).length; - if ( - batch.length > 0 && - (batch.length >= MAX_BATCH_OPS || bytes + size > MAX_BATCH_BYTES) - ) { - yield batch; - batch = []; - bytes = 0; - } - batch.push(op); - bytes += size; - } - if (batch.length > 0) yield batch; -} - -let engine: SyncEngine | null = null; - -export function getSyncEngine(): SyncEngine { - if (!engine) engine = new SyncEngine(); - return engine; -} - -/** Starts syncing on a desktop. A server install never links. */ -export function startDesktopSync(): void { - if (process.env.ELECTRON_EMBEDDED !== "true") return; - const instance = getSyncEngine(); - instance.start(); - void instance.applyProxyConfig().catch(() => {}); -} - -export function defaultDeviceName(): string { - return os.hostname() || "Termix Desktop"; -} diff --git a/src/backend/sync/client/http.ts b/src/backend/sync/client/http.ts index 2d5448248..640f7ff10 100644 --- a/src/backend/sync/client/http.ts +++ b/src/backend/sync/client/http.ts @@ -9,7 +9,7 @@ import { Agent, ProxyAgent, fetch as undiciFetch } from "undici"; import type { Dispatcher } from "undici"; -import type { BasicAuth, ProxyHeader } from "./link-store.js"; +import type { BasicAuth, ClientCert, ProxyHeader } from "./link-store.js"; export interface RemoteTarget { serverUrl: string; @@ -17,6 +17,7 @@ export interface RemoteTarget { customHeaders?: ProxyHeader[]; basicAuth?: BasicAuth | null; allowInvalidCertificate?: boolean; + clientCert?: ClientCert | null; } export type RemoteErrorKind = @@ -24,6 +25,8 @@ export type RemoteErrorKind = | "offline" /** TLS failed and the certificate is not allowed. */ | "tls" + /** The server wants a client certificate and none was accepted. */ + | "client_cert" /** A proxy answered instead of Termix (its login page, a 404 page). */ | "proxy" /** The proxy wants basic auth. */ @@ -53,6 +56,13 @@ const TLS_CODES = new Set([ "CERT_UNTRUSTED", ]); +const CLIENT_CERT_CODES = new Set([ + "ERR_SSL_TLSV13_ALERT_CERTIFICATE_REQUIRED", + "ERR_SSL_SSLV3_ALERT_BAD_CERTIFICATE", + "ERR_SSL_TLSV1_ALERT_UNKNOWN_CA", + "ERR_SSL_SSLV3_ALERT_HANDSHAKE_FAILURE", +]); + function envProxy(url: string): string | null { const proxy = process.env.https_proxy || @@ -84,10 +94,16 @@ const dispatchers = new Map(); function dispatcherFor(target: RemoteTarget): Dispatcher { const insecure = !!target.allowInvalidCertificate; const proxy = envProxy(target.serverUrl); - const key = `${insecure}|${proxy ?? ""}`; + const cert = target.clientCert; + const key = `${insecure}|${proxy ?? ""}|${cert ? `${cert.passphrase}|${cert.pfx}` : ""}`; let dispatcher = dispatchers.get(key); if (!dispatcher) { - const tls = insecure ? { rejectUnauthorized: false } : {}; + const tls = { + ...(insecure ? { rejectUnauthorized: false } : {}), + ...(cert + ? { pfx: Buffer.from(cert.pfx, "base64"), passphrase: cert.passphrase } + : {}), + }; dispatcher = proxy ? new ProxyAgent({ uri: proxy, requestTls: tls }) : new Agent({ connect: tls }); @@ -141,10 +157,22 @@ export function requestHeaders(target: RemoteTarget): Record { return headers; } -function describeNetworkError(error: unknown): RemoteError { +// A server that requires a client certificate often just drops the +// connection when it gets none, or one it does not accept. +const DROPPED_CODES = new Set(["UND_ERR_SOCKET", "ECONNRESET"]); + +function describeNetworkError( + error: unknown, + target?: RemoteTarget, +): RemoteError { const cause = (error as { cause?: { code?: string; message?: string } }) ?.cause; const code = cause?.code ?? (error as { code?: string })?.code ?? ""; + const droppedOverTls = + DROPPED_CODES.has(code) && target?.serverUrl.startsWith("https:"); + if (CLIENT_CERT_CODES.has(code) || droppedOverTls) { + return new RemoteError("client_cert", cause?.message || code); + } if (TLS_CODES.has(code) || /certificate/i.test(cause?.message ?? "")) { return new RemoteError("tls", cause?.message || code); } @@ -193,13 +221,45 @@ export async function remoteFetch( signal: controller.signal, }); } catch (error) { - throw describeNetworkError(error); + throw describeNetworkError(error, target); } finally { if (timeout) clearTimeout(timeout); } return response; } +export interface RemoteStreamRequest { + method?: string; + headers?: Record; + /** A string, a buffer or a Node stream. */ + body?: unknown; + signal?: AbortSignal; +} + +/** + * Sends a request with the link's proxy headers and certificate rules and a + * body that may be a stream. The response body is left unread for the caller. + */ +export async function remoteStreamFetch( + target: RemoteTarget, + pathname: string, + request: RemoteStreamRequest = {}, +) { + const headers = { ...request.headers, ...requestHeaders(target) }; + try { + return await undiciFetch(remoteUrl(target.serverUrl, pathname), { + method: request.method ?? "GET", + headers, + body: request.body as never, + duplex: "half", + dispatcher: dispatcherFor(target), + signal: request.signal, + } as never); + } catch (error) { + throw describeNetworkError(error, target); + } +} + /** Sends a request and parses a JSON answer from Termix. */ export async function remoteJson( target: RemoteTarget, diff --git a/src/backend/sync/client/link-store.ts b/src/backend/sync/client/link-store.ts index 036c13a0a..7a63d2652 100644 --- a/src/backend/sync/client/link-store.ts +++ b/src/backend/sync/client/link-store.ts @@ -1,9 +1,13 @@ /** - * The desktop's link to a server, one row in sync_link. Secrets (the session - * token, proxy headers, basic auth) are stored encrypted with the system key. + * The desktop's links to servers, one row each in sync_link and at most one + * per local user, since each link has its own replica user. Secrets (the + * session token, proxy headers, basic auth) are stored encrypted with the + * system key. Without a user, a call means the active account's link. */ +import { createSecureContext } from "node:tls"; import { eq } from "drizzle-orm"; +import { getActiveUserId } from "../accounts.js"; import { syncLink } from "../../database/db/schema.js"; import { createCurrentRepositoryContext } from "../../database/repositories/factory.js"; import { DatabaseSaveTrigger } from "../../utils/database-save-trigger.js"; @@ -33,6 +37,14 @@ export interface BasicAuth { password: string; } +/** A PFX/P12 client certificate for servers that require mutual TLS. */ +export interface ClientCert { + /** Base64 of the PFX/P12 file. */ + pfx: string; + passphrase: string; + name: string | null; +} + export interface SyncLink { id: number; userId: string; @@ -43,11 +55,14 @@ export interface SyncLink { customHeaders: ProxyHeader[]; basicAuth: BasicAuth | null; allowInvalidCertificate: boolean; + clientCert: ClientCert | null; remoteUserId: string | null; remoteUsername: string | null; account: LinkAccount | null; /** Entity types switched off on this device. */ disabledTypes: string[]; + /** Extra connection options for this server, kept open for new ones. */ + options: Record; /** Entity types this device has fully pulled at least once. */ knownTypes: string[]; cursor: number; @@ -90,7 +105,11 @@ async function fromRow(row: Row): Promise { sessionToken = null; } } - const scope = parseJson<{ disabled?: string[] }>(row.scope, {}); + const scope = parseJson<{ + disabled?: string[]; + options?: Record; + clientCert?: string; + }>(row.scope, {}); return { id: row.id, userId: row.userId, @@ -101,10 +120,16 @@ async function fromRow(row: Row): Promise { customHeaders: await decryptJson(row.customHeaders, []), basicAuth: await decryptJson(row.basicAuth, null), allowInvalidCertificate: !!row.allowInvalidCertificate, + clientCert: await decryptJson( + scope.clientCert ?? null, + null, + ), remoteUserId: row.remoteUserId, remoteUsername: row.remoteUsername, account: parseJson(row.account, null), disabledTypes: Array.isArray(scope.disabled) ? scope.disabled : [], + options: + scope.options && typeof scope.options === "object" ? scope.options : {}, knownTypes: parseJson(row.knownTypes, []), cursor: Number(row.cursor) || 0, status: (row.status as LinkStatus) || "idle", @@ -114,8 +139,20 @@ async function fromRow(row: Row): Promise { }; } -export async function getLink(): Promise { - const [row] = await db().select().from(syncLink).limit(1); +export async function listLinks(): Promise { + const rows = await db().select().from(syncLink); + return Promise.all(rows.map(fromRow)); +} + +/** The link of one local user, or of the active account when none is given. */ +export async function getLink(userId?: string): Promise { + const owner = userId ?? (await getActiveUserId()); + if (!owner) return null; + const [row] = await db() + .select() + .from(syncLink) + .where(eq(syncLink.userId, owner)) + .limit(1); return row ? fromRow(row) : null; } @@ -128,12 +165,40 @@ export interface NewLink { customHeaders?: ProxyHeader[]; basicAuth?: BasicAuth | null; allowInvalidCertificate?: boolean; + clientCert?: ClientCert | null; remoteUserId?: string | null; remoteUsername?: string | null; } +/** Throws when the PFX is unreadable or the passphrase is wrong. */ +export function assertClientCert(cert: ClientCert): void { + try { + createSecureContext({ + pfx: Buffer.from(cert.pfx, "base64"), + passphrase: cert.passphrase, + }); + } catch { + throw new Error("The client certificate or its passphrase is not valid"); + } +} + +async function sealScope( + disabled: string[], + options: Record, + clientCert: ClientCert | null, +): Promise { + return JSON.stringify({ + disabled, + options, + ...(clientCert + ? { clientCert: await encryptSystemSecret(JSON.stringify(clientCert)) } + : {}), + }); +} + export async function createLink(link: NewLink): Promise { - await db().delete(syncLink); + if (link.clientCert) assertClientCert(link.clientCert); + await db().delete(syncLink).where(eq(syncLink.userId, link.userId)); await db() .insert(syncLink) .values({ @@ -149,6 +214,7 @@ export async function createLink(link: NewLink): Promise { ? await encryptSystemSecret(JSON.stringify(link.basicAuth)) : null, allowInvalidCertificate: !!link.allowInvalidCertificate, + scope: link.clientCert ? await sealScope([], {}, link.clientCert) : null, remoteUserId: link.remoteUserId ?? null, remoteUsername: link.remoteUsername ?? null, cursor: 0, @@ -157,7 +223,7 @@ export async function createLink(link: NewLink): Promise { linkedAt: new Date().toISOString(), }); await DatabaseSaveTrigger.forceSave("sync_link"); - return (await getLink())!; + return (await getLink(link.userId))!; } export interface LinkPatch { @@ -167,10 +233,12 @@ export interface LinkPatch { customHeaders?: ProxyHeader[]; basicAuth?: BasicAuth | null; allowInvalidCertificate?: boolean; + clientCert?: ClientCert | null; remoteUserId?: string | null; remoteUsername?: string | null; account?: LinkAccount | null; disabledTypes?: string[]; + options?: Record; knownTypes?: string[]; cursor?: number; status?: LinkStatus; @@ -178,9 +246,13 @@ export interface LinkPatch { lastSyncAt?: string | null; } -export async function updateLink(patch: LinkPatch): Promise { - const current = await getLink(); +export async function updateLink( + patch: LinkPatch, + userId?: string, +): Promise { + const current = await getLink(userId); if (!current) return null; + if (patch.clientCert) assertClientCert(patch.clientCert); const set: Partial = {}; if (patch.serverName !== undefined) set.serverName = patch.serverName; if (patch.serverVersion !== undefined) @@ -210,8 +282,16 @@ export async function updateLink(patch: LinkPatch): Promise { if (patch.account !== undefined) { set.account = patch.account ? JSON.stringify(patch.account) : null; } - if (patch.disabledTypes !== undefined) { - set.scope = JSON.stringify({ disabled: patch.disabledTypes }); + if ( + patch.disabledTypes !== undefined || + patch.options !== undefined || + patch.clientCert !== undefined + ) { + set.scope = await sealScope( + patch.disabledTypes ?? current.disabledTypes, + patch.options ?? current.options, + patch.clientCert === undefined ? current.clientCert : patch.clientCert, + ); } if (patch.knownTypes !== undefined) { set.knownTypes = JSON.stringify(patch.knownTypes); @@ -224,10 +304,12 @@ export async function updateLink(patch: LinkPatch): Promise { await db().update(syncLink).set(set).where(eq(syncLink.id, current.id)); DatabaseSaveTrigger.triggerSave("sync_link"); } - return getLink(); + return getLink(current.userId); } -export async function deleteLink(): Promise { - await db().delete(syncLink); +export async function deleteLink(userId?: string): Promise { + const owner = userId ?? (await getActiveUserId()); + if (!owner) return; + await db().delete(syncLink).where(eq(syncLink.userId, owner)); await DatabaseSaveTrigger.forceSave("sync_link"); } diff --git a/src/backend/sync/client/plugins.ts b/src/backend/sync/client/plugins.ts index ad841b539..74e9445b5 100644 --- a/src/backend/sync/client/plugins.ts +++ b/src/backend/sync/client/plugins.ts @@ -11,7 +11,10 @@ import fs from "fs"; import path from "path"; import semver from "semver"; -import type { PluginDesktopMode } from "@termix-ssh/plugin-sdk/manifest"; +import { + resolvePluginDesktopMode, + type PluginDesktopMode, +} from "@termix-ssh/plugin-sdk/manifest"; import { syncLogger } from "../../utils/logger.js"; import { sendCoreAlert } from "../../notify/core-notify.js"; import { @@ -227,7 +230,9 @@ async function mirrorOne(link: SyncLink, plugin: RemotePlugin): Promise { } const { loader } = getPluginRuntime(); let local = loader.get(plugin.id); - const mode = local?.manifest.desktop ?? plugin.desktop ?? "mirror"; + const mode = local + ? resolvePluginDesktopMode(local.manifest) + : (plugin.desktop ?? "mirror"); if (mode === "server") { if (local && (await isEnabledLocally(plugin.id))) { @@ -328,7 +333,7 @@ export async function mirrorPlugins(link: SyncLink): Promise { // A plugin the server no longer has is switched off here. Its data stays. for (const local of getPluginRuntime().loader.list()) { if (remoteIds.has(local.id) || local.source !== "user") continue; - if ((local.manifest.desktop ?? "mirror") !== "mirror") continue; + if (resolvePluginDesktopMode(local.manifest) !== "mirror") continue; if (await isEnabledLocally(local.id)) { await setPluginEnabled(local.id, false).catch(() => {}); } @@ -341,7 +346,7 @@ export async function mirrorPlugins(link: SyncLink): Promise { */ export function isServerManaged(pluginId: string): boolean { const local = getPluginRuntime().loader.get(pluginId); - const mode = local?.manifest.desktop ?? "mirror"; + const mode = local ? resolvePluginDesktopMode(local.manifest) : "mirror"; if (mode !== "mirror") return mode === "server"; return local?.source !== "user" || vouchedHere(local); } diff --git a/src/backend/sync/client/routes.ts b/src/backend/sync/client/routes.ts deleted file mode 100644 index e6d53ae58..000000000 --- a/src/backend/sync/client/routes.ts +++ /dev/null @@ -1,761 +0,0 @@ -/** - * The desktop's own sync routes, under /sync/link. They only exist in the - * embedded desktop backend; on a server they answer 404. - */ - -import express, { - type NextFunction, - type Request, - type Response, -} from "express"; -import { and, eq, isNotNull } from "drizzle-orm"; -import type { SyncRow } from "@termix-ssh/plugin-sdk/backend"; -import type { AuthenticatedRequest } from "../../../types/index.js"; -import { AuthManager } from "../../utils/auth-manager.js"; -import { syncLogger } from "../../utils/logger.js"; -import { hosts, sshCredentials } from "../../database/db/schema.js"; -import { createCurrentRepositoryContext } from "../../database/repositories/factory.js"; -import { getPluginRuntime } from "../../plugins/index.js"; -import { getEntity, listEntities } from "../../plugins/sync-registry.js"; -import { registerCoreSyncEntities } from "../entities.js"; -import { - createResolvers, - deleteStoredRow, - listStoredRows, - regenerateSyncIds, - writeWireRow, -} from "../store.js"; -import { - clearRecordErrors, - deleteConflict, - deleteRecordsForUser, - getConflict, - listConflicts, - listRecordErrors, -} from "../records.js"; -import { SYNC_PROTOCOL_VERSION } from "../protocol.js"; -import { - createLink, - deleteLink, - getLink, - updateLink, - type BasicAuth, - type ProxyHeader, - type SyncLink, -} from "./link-store.js"; -import { - normalizeServerUrl, - RemoteError, - remoteJson, - type RemoteTarget, -} from "./http.js"; -import { defaultDeviceName, getSyncEngine } from "./engine.js"; -import { applyElectronProxyConfig } from "./electron-proxy.js"; -import { lastRemotePlugins } from "./plugins.js"; - -const router = express.Router(); -const authManager = AuthManager.getInstance(); -const authenticateJWT = authManager.createAuthMiddleware(); - -function desktopOnly(_req: Request, res: Response, next: NextFunction) { - if (process.env.ELECTRON_EMBEDDED !== "true") { - res.status(404).json({ error: "Not found" }); - return; - } - next(); -} - -const guard = [desktopOnly, authenticateJWT]; - -function userOf(req: Request): string { - return (req as AuthenticatedRequest).userId; -} - -function readHeaders(value: unknown): ProxyHeader[] { - if (!Array.isArray(value)) return []; - return value - .filter( - (header): header is ProxyHeader => - !!header && - typeof header.name === "string" && - typeof header.value === "string" && - /^[A-Za-z0-9-]{1,100}$/.test(header.name.trim()), - ) - .map((header) => ({ name: header.name.trim(), value: header.value })) - .slice(0, 20); -} - -function readBasicAuth(value: unknown): BasicAuth | null { - if (!value || typeof value !== "object") return null; - const { username, password } = value as Record; - if (typeof username !== "string" || !username) return null; - return { - username, - password: typeof password === "string" ? password : "", - }; -} - -function readTarget(body: Record): RemoteTarget | null { - const serverUrl = - typeof body.serverUrl === "string" - ? normalizeServerUrl(body.serverUrl) - : null; - if (!serverUrl) return null; - return { - serverUrl, - customHeaders: readHeaders(body.customHeaders), - basicAuth: readBasicAuth(body.basicAuth), - allowInvalidCertificate: body.allowInvalidCertificate === true, - }; -} - -/** What the sync panel can show about each entity type. */ -function describeEntities(link: SyncLink | null) { - registerCoreSyncEntities(); - const names = new Map(); - for (const plugin of getPluginRuntime().loader.list()) { - names.set(plugin.id, plugin.manifest.name); - } - for (const plugin of lastRemotePlugins()) { - if (!names.has(plugin.id)) names.set(plugin.id, plugin.name); - } - const serverTypes = getSyncEngine().state.serverTypes; - const seen = new Set(); - const entries = [ - ...listEntities().map((entity) => ({ - type: entity.type, - owner: entity.owner, - readOnly: !!entity.readOnly, - })), - ...serverTypes, - ]; - return entries - .filter((entry) => { - if (seen.has(entry.type) || entry.type === "accountProfile") return false; - seen.add(entry.type); - return true; - }) - .map((entry) => ({ - type: entry.type, - owner: entry.owner, - ownerName: - entry.owner === "core" ? null : (names.get(entry.owner) ?? entry.owner), - readOnly: entry.readOnly, - onServer: serverTypes.some((server) => server.type === entry.type), - onDevice: !!getEntity(entry.type), - enabled: !link?.disabledTypes.includes(entry.type), - })); -} - -async function statusBody() { - const link = await getLink(); - const engine = getSyncEngine(); - if (!link) { - return { linked: false, entities: describeEntities(null) }; - } - return { - linked: true, - serverUrl: link.serverUrl, - serverName: link.serverName, - serverVersion: link.serverVersion, - account: link.account ?? { - username: link.remoteUsername ?? undefined, - }, - status: engine.state.running ? "syncing" : link.status, - lastError: link.lastError, - linkedAt: link.linkedAt, - lastSyncAt: link.lastSyncAt, - pending: engine.state.pending, - conflicts: (await listConflicts(link.userId)).length, - errors: (await listRecordErrors(link.userId)).length, - firstSyncDone: link.knownTypes.length > 0, - allowInvalidCertificate: link.allowInvalidCertificate, - customHeaders: link.customHeaders.map((header) => ({ - name: header.name, - value: "", - set: true, - })), - basicAuth: link.basicAuth - ? { username: link.basicAuth.username, password: "", set: true } - : null, - entities: describeEntities(link), - }; -} - -/** Removes read-only copies of what other users shared with the account. */ -async function removeSharedCopies(userId: string): Promise { - const db = createCurrentRepositoryContext().drizzle; - const sharedHosts = await db - .select({ id: hosts.id }) - .from(hosts) - .where(and(eq(hosts.userId, userId), isNotNull(hosts.sharedSource))); - const { deleteOwnedHost } = await import("../../hosts/delete-host.js"); - for (const host of sharedHosts) await deleteOwnedHost(userId, host.id); - - const sharedCredentials = await db - .select({ id: sshCredentials.id }) - .from(sshCredentials) - .where( - and( - eq(sshCredentials.userId, userId), - isNotNull(sshCredentials.sharedSource), - ), - ); - const { deleteOwnedCredential } = - await import("../../hosts/delete-credential.js"); - for (const credential of sharedCredentials) { - await deleteOwnedCredential(userId, credential.id); - } -} - -/** Deletes every row that syncs, keeping local-only ones. */ -async function wipeSyncedData(userId: string): Promise { - const entities = [...listEntities()].reverse(); - for (const entity of entities) { - if (entity.readOnly) continue; - try { - if (entity.load) { - for (const row of await entity.load(userId)) { - await entity.erase?.(userId, String(row.syncId)); - } - continue; - } - if (!entity.table || entity.singleton) continue; - for (const row of await listStoredRows(entity, userId)) { - if (entity.shouldSync && entity.shouldSync(row) === false) continue; - if (typeof row.syncId !== "string") continue; - await deleteStoredRow(entity, userId, row.syncId); - } - } catch (error) { - syncLogger.warn("Could not clear synced data", { - operation: "sync_wipe", - entityType: entity.type, - error: error instanceof Error ? error.message : String(error), - }); - } - } -} - -function problemFor(error: unknown) { - if (!(error instanceof RemoteError)) { - return { problem: "unreachable", message: String(error) }; - } - switch (error.kind) { - case "tls": - return { problem: "tls_untrusted", message: error.message }; - case "basic_auth": - return { problem: "basic_auth_required", message: error.message }; - case "proxy": - return error.status === 404 - ? { problem: "not_termix", message: error.message } - : { problem: "proxy_login", message: error.message }; - case "signed_out": - case "server": - // A Termix older than 2.9 has no /sync/v2 and answers with an error. - return { problem: "version_mismatch", message: error.message }; - default: - return { problem: "unreachable", message: error.message }; - } -} - -/** - * @openapi - * /sync/link/status: - * get: - * summary: This desktop's link to a server - * description: Desktop only. Whether the app is linked, to which server and account, how syncing is going, and which kinds of data sync. - * tags: - * - Sync - * responses: - * 200: - * description: The link status. - */ -router.get("/link/status", ...guard, async (_req: Request, res: Response) => { - try { - res.json(await statusBody()); - } catch (error) { - syncLogger.error("Failed to read sync status", error, { - operation: "sync_status", - }); - res.status(500).json({ error: "Failed to read sync status" }); - } -}); - -/** - * @openapi - * /sync/link/probe: - * post: - * summary: Check a server address before linking - * description: Desktop only. Tries the server with the given proxy settings and says what is in the way, if anything. On success the proxy settings are also applied to the desktop's browser session so the sign-in page loads. - * tags: - * - Sync - * responses: - * 200: - * description: The server's name and version, or the problem found. - */ -router.post("/link/probe", ...guard, async (req: Request, res: Response) => { - const target = readTarget(req.body ?? {}); - if (!target) return res.json({ ok: false, problem: "invalid_url" }); - try { - const info = await remoteJson<{ - termix?: boolean; - name?: string; - version?: string; - protocol?: number; - }>(target, "/sync/v2/info", { timeoutMs: 15_000 }); - if (!info?.termix) return res.json({ ok: false, problem: "not_termix" }); - if (info.protocol !== SYNC_PROTOCOL_VERSION) { - return res.json({ - ok: false, - problem: "version_mismatch", - version: info.version, - }); - } - await applyElectronProxyConfig({ - serverUrl: target.serverUrl, - customHeaders: target.customHeaders ?? [], - basicAuth: target.basicAuth ?? null, - allowInvalidCertificate: !!target.allowInvalidCertificate, - }).catch(() => {}); - res.json({ - ok: true, - serverUrl: target.serverUrl, - name: info.name ?? "Termix", - version: info.version ?? null, - }); - } catch (error) { - res.json({ ok: false, ...problemFor(error) }); - } -}); - -/** - * @openapi - * /sync/link/preview: - * get: - * summary: How much this desktop has that would sync - * description: Desktop only. Row counts per kind of data, shown before choosing whether to merge this desktop's data into the account or replace it. - * tags: - * - Sync - * responses: - * 200: - * description: Counts per entity type. - */ -router.get("/link/preview", ...guard, async (req: Request, res: Response) => { - const userId = userOf(req); - registerCoreSyncEntities(); - const counts: Record = {}; - for (const entity of listEntities()) { - if (entity.readOnly) continue; - try { - if (entity.load) { - counts[entity.type] = (await entity.load(userId)).length; - continue; - } - if (!entity.table) continue; - counts[entity.type] = (await listStoredRows(entity, userId)).filter( - (row) => !entity.shouldSync || entity.shouldSync(row) !== false, - ).length; - } catch { - counts[entity.type] = 0; - } - } - res.json({ counts }); -}); - -/** - * @openapi - * /sync/link/complete: - * post: - * summary: Link this desktop to a server account - * description: Desktop only. Takes the session from signing in to the server, trades it for a long-lived desktop session, and starts syncing. "merge" uploads this desktop's data into the account; "replace" deletes it here first and takes the account's. - * tags: - * - Sync - * responses: - * 200: - * description: Linked; the new status. - * 400: - * description: Missing server or session. - * 502: - * description: The server refused the session. - */ -router.post("/link/complete", ...guard, async (req: Request, res: Response) => { - const userId = userOf(req); - const body = req.body ?? {}; - const target = readTarget(body); - const token = typeof body.token === "string" ? body.token : ""; - const mode = body.mode === "replace" ? "replace" : "merge"; - if (!target || !token) { - return res.status(400).json({ error: "Missing server or session" }); - } - - try { - const deviceName = - typeof body.deviceName === "string" && body.deviceName.trim() - ? body.deviceName.trim() - : defaultDeviceName(); - const linked = await remoteJson<{ - token: string; - user: { id: string; username: string }; - server?: { version?: string | null }; - }>({ ...target, sessionToken: token }, "/sync/v2/link", { - method: "POST", - body: { deviceName }, - }); - - const previous = await getLink(); - if (previous) { - await removeSharedCopies(previous.userId); - await deleteRecordsForUser(previous.userId); - } - if (mode === "replace") { - await wipeSyncedData(userId); - } - await deleteRecordsForUser(userId); - - const link = await createLink({ - userId, - serverUrl: target.serverUrl, - serverName: typeof body.serverName === "string" ? body.serverName : null, - serverVersion: linked.server?.version ?? null, - sessionToken: linked.token, - customHeaders: target.customHeaders, - basicAuth: target.basicAuth, - allowInvalidCertificate: target.allowInvalidCertificate, - remoteUserId: linked.user.id, - remoteUsername: linked.user.username, - }); - const engine = getSyncEngine(); - engine.reset(); - await applyElectronProxyConfig(link).catch(() => {}); - engine.request(0); - res.json(await statusBody()); - } catch (error) { - syncLogger.warn("Linking to a server failed", { - operation: "sync_link_complete", - error: error instanceof Error ? error.message : String(error), - }); - res.status(502).json({ ...problemFor(error) }); - } -}); - -/** - * @openapi - * /sync/link/relogin: - * post: - * summary: Sign this desktop back in after its session ended - * description: Desktop only. Takes a new sign-in session for the same account and resumes syncing where it stopped. - * tags: - * - Sync - * responses: - * 200: - * description: Signed in again; the new status. - * 409: - * description: The sign-in was for a different account. - */ -router.post("/link/relogin", ...guard, async (req: Request, res: Response) => { - const token = typeof req.body?.token === "string" ? req.body.token : ""; - const link = await getLink(); - if (!link || !token) return res.status(400).json({ error: "Not linked" }); - try { - const linked = await remoteJson<{ - token: string; - user: { id: string; username: string }; - }>({ ...link, sessionToken: token }, "/sync/v2/link", { - method: "POST", - body: { deviceName: defaultDeviceName() }, - }); - if (link.remoteUserId && linked.user.id !== link.remoteUserId) { - return res.status(409).json({ error: "different_account" }); - } - await updateLink({ - sessionToken: linked.token, - status: "idle", - lastError: null, - }); - getSyncEngine().request(0); - res.json(await statusBody()); - } catch (error) { - res.status(502).json({ ...problemFor(error) }); - } -}); - -/** - * @openapi - * /sync/link/unlink: - * post: - * summary: Unlink this desktop from its server - * description: Desktop only. Ends the desktop's session on the server when it can be reached. With keepData the synced data stays on this device as its own; otherwise it is deleted. Copies of what others shared with the account are always removed. - * tags: - * - Sync - * responses: - * 200: - * description: Unlinked. - */ -router.post("/link/unlink", ...guard, async (req: Request, res: Response) => { - const keepData = req.body?.keepData !== false; - const link = await getLink(); - if (!link) return res.json(await statusBody()); - const userId = link.userId; - try { - await remoteJson(link, "/sync/v2/unlink", { - method: "POST", - body: {}, - timeoutMs: 10_000, - }).catch(() => {}); - - const engine = getSyncEngine(); - engine.reset(); - await removeSharedCopies(userId); - if (keepData) { - // New syncIds so this data reads as new if it is ever linked again. - for (const entity of listEntities()) { - if (!entity.readOnly) await regenerateSyncIds(entity, userId); - } - } else { - await wipeSyncedData(userId); - } - await deleteRecordsForUser(userId); - await deleteLink(); - await applyElectronProxyConfig(null).catch(() => {}); - res.json(await statusBody()); - } catch (error) { - syncLogger.error("Unlinking failed", error, { operation: "sync_unlink" }); - res.status(500).json({ error: "Failed to unlink" }); - } -}); - -/** - * @openapi - * /sync/link/sync: - * post: - * summary: Sync now - * description: Desktop only. Runs a sync pass and waits for it. - * tags: - * - Sync - * responses: - * 200: - * description: The status after the pass. - */ -router.post("/link/sync", ...guard, async (_req: Request, res: Response) => { - await getSyncEngine().syncNow(); - res.json(await statusBody()); -}); - -/** - * @openapi - * /sync/link/settings: - * put: - * summary: Change what syncs and how the server is reached - * description: Desktop only. disabledTypes lists the kinds of data that stay on this device. Proxy headers and basic auth left blank keep their saved values. - * tags: - * - Sync - * responses: - * 200: - * description: The new status. - * 400: - * description: Not linked. - */ -router.put("/link/settings", ...guard, async (req: Request, res: Response) => { - const link = await getLink(); - if (!link) return res.status(400).json({ error: "Not linked" }); - const body = req.body ?? {}; - const patch: Parameters[0] = {}; - - if (Array.isArray(body.disabledTypes)) { - const disabled = body.disabledTypes.filter( - (type: unknown): type is string => - typeof type === "string" && type !== "accountProfile", - ); - const changed = new Set([ - ...disabled.filter((type: string) => !link.disabledTypes.includes(type)), - ...link.disabledTypes.filter((type) => !disabled.includes(type)), - ]); - patch.disabledTypes = disabled; - // A type switched back on is pulled again in full. - patch.knownTypes = link.knownTypes.filter((type) => !changed.has(type)); - } - if (Array.isArray(body.customHeaders)) { - const saved = new Map(link.customHeaders.map((h) => [h.name, h.value])); - patch.customHeaders = (body.customHeaders as unknown[]) - .filter( - (header): header is { name: string; value?: string } => - !!header && typeof (header as { name?: unknown }).name === "string", - ) - .map((header) => ({ - name: header.name, - value: header.value || saved.get(header.name) || "", - })) - .filter((header) => header.value); - patch.customHeaders = readHeaders(patch.customHeaders); - } - if (body.basicAuth === null) { - patch.basicAuth = null; - } else if (body.basicAuth && typeof body.basicAuth === "object") { - const next = readBasicAuth(body.basicAuth); - if (next && !next.password && link.basicAuth) { - next.password = link.basicAuth.password; - } - patch.basicAuth = next; - } - if (typeof body.allowInvalidCertificate === "boolean") { - patch.allowInvalidCertificate = body.allowInvalidCertificate; - } - - const updated = await updateLink(patch); - await applyElectronProxyConfig(updated).catch(() => {}); - getSyncEngine().request(0); - res.json(await statusBody()); -}); - -/** - * @openapi - * /sync/link/conflicts: - * get: - * summary: Local edits that lost to a newer edit on the server - * description: Desktop only. The server's version was kept; each entry holds this device's version so it can be restored. - * tags: - * - Sync - * responses: - * 200: - * description: The conflicts, oldest first. - */ -router.get("/link/conflicts", ...guard, async (req: Request, res: Response) => { - const conflicts = await listConflicts(userOf(req)); - res.json({ - conflicts: conflicts.map((conflict) => { - let row: SyncRow = {}; - try { - row = JSON.parse(conflict.localRow); - } catch { - row = {}; - } - return { - id: conflict.id, - entityType: conflict.entityType, - syncId: conflict.syncId, - name: row.name ?? row.title ?? row.label ?? row.ip ?? conflict.syncId, - createdAt: conflict.createdAt, - }; - }), - }); -}); - -/** - * @openapi - * /sync/link/conflicts/{id}: - * post: - * summary: Settle a conflict - * description: Desktop only. keep "mine" puts this device's version back, and the next sync sends it to the server. keep "server" drops it. - * tags: - * - Sync - * parameters: - * - in: path - * name: id - * required: true - * schema: - * type: integer - * responses: - * 200: - * description: Settled. - * 404: - * description: No such conflict. - */ -router.post( - "/link/conflicts/:id", - ...guard, - async (req: Request, res: Response) => { - const userId = userOf(req); - const conflict = await getConflict(userId, Number(req.params.id)); - if (!conflict) return res.status(404).json({ error: "Not found" }); - try { - if (req.body?.keep === "mine") { - const entity = getEntity(conflict.entityType); - if (entity && !entity.readOnly) { - const row = JSON.parse(conflict.localRow) as SyncRow; - await writeWireRow( - entity, - userId, - { ...row, syncId: conflict.syncId }, - createResolvers(userId), - ); - } - } - await deleteConflict(userId, conflict.id); - getSyncEngine().request(0); - res.json({ success: true }); - } catch (error) { - syncLogger.warn("Could not settle a sync conflict", { - operation: "sync_conflict", - error: error instanceof Error ? error.message : String(error), - }); - res.status(500).json({ error: "Failed to settle conflict" }); - } - }, -); - -/** - * @openapi - * /sync/link/errors: - * get: - * summary: Changes the server refused - * description: Desktop only. Each entry names the record and why the server refused it. A refused change is not sent again until it changes or is retried. - * tags: - * - Sync - * responses: - * 200: - * description: The refused changes. - */ -router.get("/link/errors", ...guard, async (req: Request, res: Response) => { - const errors = await listRecordErrors(userOf(req)); - res.json({ - errors: errors.map((record) => ({ - entityType: record.entityType, - syncId: record.syncId, - reason: record.error, - })), - }); -}); - -/** - * @openapi - * /sync/link/errors/retry: - * post: - * summary: Send refused changes again - * description: Desktop only. Clears every refusal and syncs. - * tags: - * - Sync - * responses: - * 200: - * description: The status after the pass. - */ -router.post( - "/link/errors/retry", - ...guard, - async (req: Request, res: Response) => { - await clearRecordErrors(userOf(req)); - await getSyncEngine().syncNow(); - res.json(await statusBody()); - }, -); - -/** - * @openapi - * /sync/link/session: - * get: - * summary: The linked server and session, for connecting to it directly - * description: Desktop only. Used when a host is set to connect through the server rather than from this device. - * tags: - * - Sync - * responses: - * 200: - * description: The server address and session token, or nulls when not linked. - */ -router.get("/link/session", ...guard, async (_req: Request, res: Response) => { - const link = await getLink(); - if (!link?.sessionToken || link.status === "signed_out") { - return res.json({ serverUrl: null, token: null }); - } - res.json({ serverUrl: link.serverUrl, token: link.sessionToken }); -}); - -export default router; diff --git a/src/backend/sync/host.ts b/src/backend/sync/host.ts new file mode 100644 index 000000000..cacbdef4c --- /dev/null +++ b/src/backend/sync/host.ts @@ -0,0 +1,544 @@ +/** + * What the sync provider plugin gets from core through ctx.sync.host: the + * replica store, the change feed, the linked server registry, sessions and + * a few helpers. Core stays neutral; the plugin decides what to do with it. + */ + +import fs from "fs"; +import os from "os"; +import path from "path"; +import * as tar from "tar"; +import { and, eq, isNotNull } from "drizzle-orm"; +import type { NextFunction, Request, Response } from "express"; +import type { PluginSyncHost, SyncRow } from "@termix-ssh/plugin-sdk/backend"; +import { resolvePluginDesktopMode } from "@termix-ssh/plugin-sdk/manifest"; +import { AuthManager } from "../utils/auth-manager.js"; +import { getLocalVersion } from "../utils/app-version.js"; +import { syncLogger } from "../utils/logger.js"; +import { sendCoreAlert } from "../notify/core-notify.js"; +import { getPluginRuntime } from "../plugins/index.js"; +import { + getEntity, + listEntities, + onEntitiesChanged, + type RegisteredSyncEntity, +} from "../plugins/sync-registry.js"; +import { + createCurrentHostRepository, + createCurrentHostResolutionRepository, + createCurrentPluginPermissionGrantRepository, + createCurrentPluginRepository, + createCurrentRepositoryContext, + createCurrentSessionRepository, + createCurrentSettingsRepository, + createCurrentUserRepository, +} from "../database/repositories/factory.js"; +import { resolveBrandingSettings } from "../database/routes/branding-settings.js"; +import { hosts, sshCredentials } from "../database/db/schema.js"; +import { + createResolvers, + deleteStoredRow, + listStoredRows, + loadWireRow, + loadWireRows, + regenerateSyncIds, + writeWireRow, + type Resolvers, +} from "./store.js"; +import { + clearRecordErrors, + deleteConflict, + deleteRecordsForUser, + getConflict, + listChanges, + listConflicts, + listRecordErrors, + listRecords, + putRecord, + recordKey, + saveConflict, + setRecordError, +} from "./records.js"; +import { orderSelfReferences } from "./wire.js"; +import { onLocalWrite } from "./server/write-listeners.js"; +import { + createLink, + deleteLink, + getLink, + listLinks, + updateLink, +} from "./client/link-store.js"; +import { + createAccount, + getActiveUserId, + listAccounts, + removeAccount, + setActiveUserId, +} from "./accounts.js"; +import { + normalizeServerUrl, + RemoteError, + remoteFetch, + remoteJson, +} from "./client/http.js"; +import { applyElectronProxyConfig } from "./client/electron-proxy.js"; +import { lastRemotePlugins, mirrorPlugins } from "./client/plugins.js"; +import { defaultDeviceName } from "./client/device.js"; + +function entityOf(type: string): RegisteredSyncEntity { + const entity = getEntity(type); + if (!entity) throw new Error(`Unknown sync entity "${type}"`); + return entity; +} + +function info(entity: RegisteredSyncEntity) { + return { + type: entity.type, + owner: entity.owner, + order: entity.order, + readOnly: !!entity.readOnly, + covers: entity.covers, + }; +} + +/** Deletes every row that syncs, keeping local-only ones. */ +async function wipeSyncedData(userId: string): Promise { + const entities = [...listEntities()].reverse(); + for (const entity of entities) { + if (entity.readOnly) continue; + try { + if (entity.load) { + for (const row of await entity.load(userId)) { + await entity.erase?.(userId, String(row.syncId)); + } + continue; + } + if (!entity.table || entity.singleton) continue; + for (const row of await listStoredRows(entity, userId)) { + if (entity.shouldSync && entity.shouldSync(row) === false) continue; + if (typeof row.syncId !== "string") continue; + await deleteStoredRow(entity, userId, row.syncId); + } + } catch (error) { + syncLogger.warn("Could not clear synced data", { + operation: "sync_wipe", + entityType: entity.type, + error: error instanceof Error ? error.message : String(error), + }); + } + } +} + +/** Removes read-only copies of what other users shared with the account. */ +async function removeSharedCopies(userId: string): Promise { + const db = createCurrentRepositoryContext().drizzle; + const sharedHosts = await db + .select({ id: hosts.id }) + .from(hosts) + .where(and(eq(hosts.userId, userId), isNotNull(hosts.sharedSource))); + const { deleteOwnedHost } = await import("../hosts/delete-host.js"); + for (const host of sharedHosts) await deleteOwnedHost(userId, host.id); + + const sharedCredentials = await db + .select({ id: sshCredentials.id }) + .from(sshCredentials) + .where( + and( + eq(sshCredentials.userId, userId), + isNotNull(sshCredentials.sharedSource), + ), + ); + const { deleteOwnedCredential } = + await import("../hosts/delete-credential.js"); + for (const credential of sharedCredentials) { + await deleteOwnedCredential(userId, credential.id); + } +} + +async function ensureCoreEntities(): Promise { + const { registerCoreSyncEntities } = await import("./entities.js"); + registerCoreSyncEntities(); +} + +async function counts(userId: string): Promise> { + await ensureCoreEntities(); + const result: Record = {}; + for (const entity of listEntities()) { + if (entity.readOnly) continue; + try { + if (entity.load) { + result[entity.type] = (await entity.load(userId)).length; + continue; + } + if (!entity.table) continue; + result[entity.type] = (await listStoredRows(entity, userId)).filter( + (row) => !entity.shouldSync || entity.shouldSync(row) !== false, + ).length; + } catch { + result[entity.type] = 0; + } + } + return result; +} + +async function listPluginsForDesktop() { + const records = new Map( + (await createCurrentPluginRepository().listAll()).map((record) => [ + record.id, + record, + ]), + ); + const grants = createCurrentPluginPermissionGrantRepository(); + const plugins = []; + for (const plugin of getPluginRuntime().loader.list()) { + plugins.push({ + id: plugin.id, + name: plugin.manifest.name, + version: plugin.manifest.version, + source: plugin.source, + // A bundled plugin updated from a signed .tmxplug can be mirrored. + artifact: !!plugin.artifact, + enabled: records.get(plugin.id)?.state === "enabled", + active: plugin.state === "active", + desktop: resolvePluginDesktopMode(plugin.manifest), + syncEntities: plugin.manifest.contributes?.syncEntities ?? [], + granted: (await grants.listByPlugin(plugin.id)).map( + (grant) => grant.capability, + ), + }); + } + const { customRegistryKeys } = await import("../plugins/registries.js"); + return { plugins, registryKeys: await customRegistryKeys() }; +} + +async function sendPackage( + pluginId: string, + target: unknown, +): Promise { + const res = target as Response; + const plugin = getPluginRuntime() + .loader.list() + .find((candidate) => candidate.id === pluginId); + if (!plugin || (plugin.source !== "user" && !plugin.artifact)) return false; + const artifact = plugin.artifact ?? ""; + res.setHeader("Content-Type", "application/gzip"); + if (fs.existsSync(artifact)) { + const sig = `${artifact}.sig`; + if (fs.existsSync(sig)) { + res.setHeader( + "X-Termix-Signature", + Buffer.from(await fs.promises.readFile(sig, "utf8")).toString("base64"), + ); + } + fs.createReadStream(artifact).pipe(res); + return true; + } + const temp = path.join( + os.tmpdir(), + `termix-plugin-${pluginId}-${Date.now()}.tmxplug`, + ); + await tar.c({ gzip: true, cwd: plugin.dir, file: temp }, ["."]); + const stream = fs.createReadStream(temp); + stream.on("close", () => void fs.promises.rm(temp, { force: true })); + stream.pipe(res); + return true; +} + +export function createSyncHost(): PluginSyncHost { + const authManager = AuthManager.getInstance(); + + return { + embedded: process.env.ELECTRON_EMBEDDED === "true", + info: { + appName: async () => { + try { + return ( + await resolveBrandingSettings(createCurrentSettingsRepository()) + ).appName; + } catch { + return "Termix"; + } + }, + version: () => getLocalVersion(), + }, + + entities: { + list: () => listEntities().map(info), + get: (type) => { + const entity = getEntity(type); + return entity ? info(entity) : undefined; + }, + ensureCore: () => ensureCoreEntities(), + onChanged: (listener) => onEntitiesChanged(listener), + onLocalWrite: (listener) => onLocalWrite(listener), + }, + + replica: { + resolvers: (userId, onMissing) => createResolvers(userId, onMissing), + loadRows: async (type, userId, resolvers) => { + const result = await loadWireRows( + entityOf(type), + userId, + resolvers as Resolvers, + ); + return { rows: result.rows, failed: result.failed }; + }, + loadRow: (type, userId, syncId, resolvers) => + loadWireRow(entityOf(type), userId, syncId, resolvers as Resolvers), + writeRow: async (type, userId, wire, resolvers) => { + await writeWireRow( + entityOf(type), + userId, + wire, + resolvers as Resolvers, + ); + }, + deleteRow: async (type, userId, syncId) => { + await deleteStoredRow(entityOf(type), userId, syncId); + }, + orderSelfReferences: (type, rows) => + orderSelfReferences(entityOf(type), rows as SyncRow[]), + counts: (userId) => counts(userId), + wipe: (userId) => wipeSyncedData(userId), + regenerateSyncIds: async (userId) => { + for (const entity of listEntities()) { + if (!entity.readOnly) await regenerateSyncIds(entity, userId); + } + }, + removeSharedCopies: (userId) => removeSharedCopies(userId), + records: { + list: (userId) => listRecords(userId) as never, + put: (userId, type, syncId, values) => + putRecord(userId, type, syncId, values), + setError: (userId, type, syncId, reason, hash) => + setRecordError(userId, type, syncId, reason, hash), + clearErrors: (userId) => clearRecordErrors(userId), + listErrors: (userId) => listRecordErrors(userId) as never, + deleteForUser: (userId) => deleteRecordsForUser(userId), + key: (type, syncId) => recordKey(type, syncId), + }, + conflicts: { + save: (userId, type, syncId, row, revision) => + saveConflict(userId, type, syncId, row, revision), + list: (userId) => listConflicts(userId) as never, + get: (userId, id) => getConflict(userId, id) as never, + delete: (userId, id) => deleteConflict(userId, id), + }, + }, + + feed: { + withUserLock: async (userId, run) => { + const { withUserLock } = await import("./server/feed.js"); + return withUserLock(userId, run); + }, + changes: async (userId, cursor, limit, types) => { + const { withUserLock, reconcileUser } = + await import("./server/feed.js"); + const page = await withUserLock(userId, async () => { + const { wire } = await reconcileUser(userId); + const records = await listChanges(userId, cursor, limit + 1, types); + return { wire, records }; + }); + const hasMore = page.records.length > limit; + const records = []; + for (const record of page.records.slice(0, limit)) { + if (!getEntity(record.entityType)) continue; + const row = record.deleted + ? null + : (page.wire.get(recordKey(record.entityType, record.syncId)) ?? + null); + // A live record with no row failed to serialize; it comes again once + // it changes, rather than arriving as a delete. + if (!record.deleted && !row) continue; + records.push({ + seq: record.seq, + entityType: record.entityType, + syncId: record.syncId, + revision: record.revision, + deleted: !!record.deleted, + row, + }); + } + const slice = page.records.slice(0, limit); + return { + records, + hasMore, + nextCursor: slice.length ? slice[slice.length - 1].seq : cursor, + entityTypes: listEntities().map((entity) => ({ + type: entity.type, + owner: entity.owner, + order: entity.order, + readOnly: !!entity.readOnly, + })), + } as never; + }, + parsePushOps: async (body) => { + const { parsePushOps } = await import("./server/push.js"); + return parsePushOps(body) as never; + }, + applyPush: async (userId, ops) => { + const { withUserLock } = await import("./server/feed.js"); + const { applyPush } = await import("./server/push.js"); + return withUserLock(userId, () => + applyPush(userId, ops as never), + ) as never; + }, + listen: async (userId, res) => { + const { addListener } = await import("./server/feed.js"); + return addListener(userId, res as Response); + }, + }, + + links: { + list: () => listLinks() as never, + get: (userId) => getLink(userId) as never, + create: (link) => createLink(link) as never, + update: (patch, userId) => updateLink(patch, userId) as never, + remove: (userId) => deleteLink(userId), + }, + + accounts: { + list: () => listAccounts(), + create: () => createAccount(), + setActive: (userId) => setActiveUserId(userId), + activeId: () => getActiveUserId(), + remove: (userId) => removeAccount(userId), + }, + + http: { + json: (target, pathname, request) => + remoteJson(target, pathname, request), + fetch: ((target, pathname, request) => + remoteFetch(target, pathname, request)) as never, + normalizeServerUrl, + isRemoteError: ((error: unknown) => + error instanceof RemoteError) as never, + }, + + sessions: { + issueDesktop: (userId, options) => + authManager.generateJWTToken(userId, { + expiresIn: `${options.days}d`, + deviceType: "desktop", + deviceInfo: options.deviceInfo, + }), + revoke: async (sessionId) => { + await authManager.revokeSession(sessionId); + }, + find: async (sessionId) => { + const session = + await createCurrentSessionRepository().findById(sessionId); + return session + ? { expiresAt: session.expiresAt, deviceInfo: session.deviceInfo } + : null; + }, + findUser: async (userId) => { + const user = await createCurrentUserRepository().findById(userId); + return user ? { id: user.id, username: user.username } : null; + }, + verify: async (token) => { + const payload = await authManager.verifyJWTToken(token); + return payload + ? { userId: payload.userId, sessionId: payload.sessionId } + : null; + }, + setCookie: (req, res, value, options) => { + (res as Response).cookie( + options?.name ?? "jwt", + value, + authManager.getSecureCookieOptions( + req as Request, + (options?.days ?? 30) * 24 * 60 * 60 * 1000, + ), + ); + }, + clearCookie: (req, res, name) => { + (res as Response).clearCookie( + name ?? "jwt", + authManager.getClearCookieOptions(req as Request), + ); + }, + middleware: () => authManager.createAuthMiddleware(), + desktopOnly: () => (_req: Request, res: Response, next: NextFunction) => { + if (process.env.ELECTRON_EMBEDDED !== "true") { + res.status(404).json({ error: "Not found" }); + return; + } + next(); + }, + }, + + hosts: { + findForSync: async (userId, syncId) => { + const hostId = + await createCurrentHostResolutionRepository().findHostIdBySyncId( + syncId, + ); + if (hostId === null) return null; + const { PermissionManager } = + await import("../utils/permission-manager.js"); + const access = await PermissionManager.getInstance().canAccessHost( + userId, + hostId, + "connect", + ); + if (!access.hasAccess) return null; + const row = await createCurrentHostRepository().findById(hostId); + return { id: hostId, name: row?.name ?? row?.ip ?? null }; + }, + statusBySyncId: async (userId) => { + const { hostStatus } = + await import("../plugins/host-status-provider.js"); + const { PermissionManager } = + await import("../utils/permission-manager.js"); + const statuses = await hostStatus.statusesFor(userId, null); + const entries = [...statuses.entries()]; + const allowed = + await PermissionManager.getInstance().filterAccessibleHostIds( + userId, + entries.map(([id]) => id), + ); + const hostRepository = createCurrentHostRepository(); + const result: Record = {}; + for (const [id, entry] of entries) { + if (!allowed.has(id)) continue; + const row = await hostRepository.findById(id); + if (row?.syncId) result[row.syncId] = entry; + } + return result; + }, + }, + + plugins: { + listForDesktop: listPluginsForDesktop as never, + sendPackage, + mirror: (link) => mirrorPlugins(link as never), + lastRemote: () => lastRemotePlugins() as never, + localNames: () => { + const names = new Map(); + for (const plugin of getPluginRuntime().loader.list()) { + names.set(plugin.id, plugin.manifest.name); + } + return names; + }, + }, + + desktop: { + applyProxy: (link) => applyElectronProxyConfig(link as never), + deviceName: defaultDeviceName, + }, + + alerts: { + send: (alert) => + sendCoreAlert({ + title: alert.title, + body: alert.body, + severity: alert.severity, + category: alert.category, + dedupeKey: alert.dedupeKey, + audience: "admins", + link: alert.link, + } as never), + }, + }; +} diff --git a/src/backend/sync/protocol.ts b/src/backend/sync/protocol.ts deleted file mode 100644 index 86b3fa486..000000000 --- a/src/backend/sync/protocol.ts +++ /dev/null @@ -1,2 +0,0 @@ -/** Bumped when a desktop and server can no longer understand each other. */ -export const SYNC_PROTOCOL_VERSION = 1; diff --git a/src/backend/sync/provider.ts b/src/backend/sync/provider.ts new file mode 100644 index 000000000..ba24cb920 --- /dev/null +++ b/src/backend/sync/provider.ts @@ -0,0 +1,80 @@ +/** + * The sync provider: a plugin that answers /sync/v2 and /sync/link through + * ctx.sync.provide. Core only forwards. With no provider the paths answer 404, + * so a server without it looks like one that never had sync. + */ + +import type { NextFunction, Request, RequestHandler, Response } from "express"; +import type { PluginSyncProvider } from "@termix-ssh/plugin-sdk/backend"; +import { syncLogger } from "../utils/logger.js"; + +interface Entry { + pluginId: string; + provider: PluginSyncProvider; +} + +const providers = new Map(); + +export function provideSync( + pluginId: string, + provider: PluginSyncProvider, +): () => void { + const key = Symbol(pluginId); + providers.set(key, { pluginId, provider }); + return () => { + providers.delete(key); + }; +} + +/** Test seam. */ +export function clearSyncProviders(): void { + providers.clear(); +} + +export function hasSyncProvider(): boolean { + return providers.size > 0; +} + +/** Sends the request to the first provider that does not pass. */ +export const syncForwarder: RequestHandler = ( + req: Request, + res: Response, + next: NextFunction, +) => { + const entries = [...providers.values()]; + const run = (index: number): void => { + const entry = entries[index]; + if (!entry) { + next(); + return; + } + const pass = (error?: unknown): void => { + if (error) next(error); + else run(index + 1); + }; + try { + const result = entry.provider.handle(req, res, pass); + if (result && typeof result.catch === "function") { + result.catch((error: unknown) => fail(entry, error, res, next)); + } + } catch (error) { + fail(entry, error, res, next); + } + }; + run(0); +}; + +function fail( + entry: Entry, + error: unknown, + res: Response, + next: NextFunction, +): void { + syncLogger.warn("Sync provider failed", { + operation: "sync_provider", + pluginId: entry.pluginId, + error: error instanceof Error ? error.message : String(error), + }); + if (res.headersSent) next(error); + else res.status(500).json({ error: "Sync is not available right now" }); +} diff --git a/src/backend/sync/server/change-watcher.ts b/src/backend/sync/server/change-watcher.ts index c4481c86d..9a4c74e10 100644 --- a/src/backend/sync/server/change-watcher.ts +++ b/src/backend/sync/server/change-watcher.ts @@ -1,14 +1,8 @@ import type { NextFunction, Request, Response } from "express"; import { markChanged } from "./feed.js"; +import { notifyLocalWrite } from "./write-listeners.js"; const READS = new Set(["GET", "HEAD", "OPTIONS"]); -const writeListeners = new Set<() => void>(); - -/** Called after every successful write request, for a linked desktop's engine. */ -export function onLocalWrite(listener: () => void): () => void { - writeListeners.add(listener); - return () => writeListeners.delete(listener); -} /** * Any successful write may change what a linked desktop should have, so it @@ -23,7 +17,7 @@ export function syncChangeWatcher( res.on("finish", () => { if (res.statusCode >= 400) return; markChanged(); - for (const listener of writeListeners) listener(); + notifyLocalWrite(); }); } next(); diff --git a/src/backend/sync/server/push.ts b/src/backend/sync/server/push.ts index d192e3dfc..4cfc19579 100644 --- a/src/backend/sync/server/push.ts +++ b/src/backend/sync/server/push.ts @@ -11,6 +11,12 @@ import type { SyncRow } from "@termix-ssh/plugin-sdk/backend"; import { getEntity } from "../../plugins/sync-registry.js"; import { PermissionManager } from "../../utils/permission-manager.js"; import { syncLogger } from "../../utils/logger.js"; +import { + hasHostLocks, + hostLock, + manualCreateBlockedBy, +} from "../../hosts/locks.js"; +import { createCurrentHostResolutionRepository } from "../../database/repositories/factory.js"; import { createResolvers, deleteStoredRow, @@ -48,6 +54,22 @@ export type PushResult = export const MAX_PUSH_OPS = 500; +/** A host a plugin manages can't be changed from a desktop either. */ +async function hostPushLocked( + userId: string, + syncId: string, + exists: boolean, +): Promise { + if (!hasHostLocks()) return false; + if (!exists) return manualCreateBlockedBy() !== null; + const hostId = + await createCurrentHostResolutionRepository().findHostIdBySyncIdForUser( + syncId, + userId, + ); + return hostId !== null && hostId !== undefined && hostLock(hostId) !== null; +} + function isPushOp(value: unknown): value is PushOp { if (!value || typeof value !== "object") return false; const op = value as Record; @@ -166,6 +188,13 @@ export async function applyPush( results.set(key, { ...base, status: "rejected", reason: "permission" }); continue; } + if ( + op.entityType === "hosts" && + (await hostPushLocked(userId, op.syncId, exists)) + ) { + results.set(key, { ...base, status: "rejected", reason: "locked" }); + continue; + } try { if (op.op === "delete") { diff --git a/src/backend/sync/server/routes.ts b/src/backend/sync/server/routes.ts deleted file mode 100644 index fb1639d54..000000000 --- a/src/backend/sync/server/routes.ts +++ /dev/null @@ -1,604 +0,0 @@ -import express, { type Request, type Response } from "express"; -import fs from "fs"; -import path from "path"; -import os from "os"; -import * as tar from "tar"; -import type { AuthenticatedRequest } from "../../../types/index.js"; -import { AuthManager } from "../../utils/auth-manager.js"; -import { syncLogger } from "../../utils/logger.js"; -import { getLocalVersion } from "../../utils/app-version.js"; -import { getEntity, listEntities } from "../../plugins/sync-registry.js"; -import { getPluginRuntime } from "../../plugins/index.js"; -import { - createCurrentSessionRepository, - createCurrentSettingsRepository, - createCurrentUserRepository, - createCurrentPluginRepository, - createCurrentPluginPermissionGrantRepository, -} from "../../database/repositories/factory.js"; -import { resolveBrandingSettings } from "../../database/routes/branding-settings.js"; -import { registerCoreSyncEntities } from "../entities.js"; -import { listChanges, recordKey } from "../records.js"; -import { addListener, reconcileUser, withUserLock } from "./feed.js"; -import { applyPush, parsePushOps } from "./push.js"; -import { SYNC_PROTOCOL_VERSION } from "../protocol.js"; - -const SESSION_DAYS = 365; -const REFRESH_WITHIN_MS = 60 * 24 * 60 * 60 * 1000; -const MAX_PAGE = 1000; - -const router = express.Router(); -const authManager = AuthManager.getInstance(); -const authenticateJWT = authManager.createAuthMiddleware(); - -registerCoreSyncEntities(); - -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: - * get: - * summary: Describe this server to a desktop that wants to link to it - * description: Public. Lets the desktop app confirm it reached Termix and not a proxy's login page, and that the sync protocol matches. - * tags: - * - Sync - * responses: - * 200: - * description: Server name, version and sync protocol version. - */ -router.get("/v2/info", async (_req: Request, res: Response) => { - let name = "Termix"; - try { - name = (await resolveBrandingSettings(createCurrentSettingsRepository())) - .appName; - } catch { - // The default name is fine. - } - res.setHeader("X-Termix-Sync", String(SYNC_PROTOCOL_VERSION)); - res.json({ - termix: true, - name, - version: getLocalVersion(), - protocol: SYNC_PROTOCOL_VERSION, - }); -}); - -/** - * @openapi - * /sync/v2/link: - * post: - * summary: Link a desktop to this account - * description: Trades the short login session the desktop just signed in with for a long-lived desktop session, which shows in the account's sessions list and is revoked like any other. The login session is ended. - * tags: - * - Sync - * requestBody: - * content: - * application/json: - * schema: - * type: object - * properties: - * deviceName: - * type: string - * responses: - * 200: - * description: The desktop session token and the account it belongs to. - * 401: - * description: Not signed in. - * 403: - * description: API keys cannot link a desktop. - */ -router.post( - "/v2/link", - authenticateJWT, - async (req: Request, res: Response) => { - const userId = userOf(req); - if ((req as AuthenticatedRequest).apiKeyId) { - return res.status(403).json({ error: "API keys cannot link a desktop" }); - } - try { - const user = await createCurrentUserRepository().findById(userId); - if (!user) return res.status(401).json({ error: "User not found" }); - const rawName = - typeof req.body?.deviceName === "string" ? req.body.deviceName : ""; - const deviceName = rawName.trim().slice(0, 100) || "Termix Desktop"; - const token = await authManager.generateJWTToken(userId, { - expiresIn: `${SESSION_DAYS}d`, - deviceType: "desktop", - deviceInfo: `${deviceName} (sync)`, - }); - const loginSession = (req as AuthenticatedRequest).sessionId; - if (loginSession) await authManager.revokeSession(loginSession); - res.json({ - token, - user: { id: user.id, username: user.username }, - server: { version: getLocalVersion() }, - }); - } catch (error) { - syncLogger.error("Failed to link a desktop", error, { - operation: "sync_link", - userId, - }); - res.status(500).json({ error: "Failed to link" }); - } - }, -); - -/** - * @openapi - * /sync/v2/session/refresh: - * post: - * summary: Keep a linked desktop's session from expiring - * description: Returns a fresh desktop session when the current one ends within 60 days, and ends the old one. Otherwise returns null and the desktop keeps its token. - * tags: - * - Sync - * responses: - * 200: - * description: The new token, or null. - * 401: - * description: Session revoked or expired. - */ -router.post( - "/v2/session/refresh", - authenticateJWT, - async (req: Request, res: Response) => { - const userId = userOf(req); - const sessionId = (req as AuthenticatedRequest).sessionId; - try { - if (!sessionId) return res.json({ token: null }); - const session = - await createCurrentSessionRepository().findById(sessionId); - if (!session) return res.status(401).json({ error: "Session not found" }); - const remaining = new Date(session.expiresAt).getTime() - Date.now(); - if (remaining > REFRESH_WITHIN_MS) return res.json({ token: null }); - const token = await authManager.generateJWTToken(userId, { - expiresIn: `${SESSION_DAYS}d`, - deviceType: "desktop", - deviceInfo: session.deviceInfo, - }); - await authManager.revokeSession(sessionId); - res.json({ token }); - } catch (error) { - syncLogger.error("Failed to refresh a desktop session", error, { - operation: "sync_session_refresh", - userId, - }); - res.status(500).json({ error: "Failed to refresh session" }); - } - }, -); - -/** - * @openapi - * /sync/v2/unlink: - * post: - * summary: End a linked desktop's session - * description: Called by a desktop when it unlinks, so its session stops working here right away. - * tags: - * - Sync - * responses: - * 200: - * description: The session was ended. - */ -router.post( - "/v2/unlink", - authenticateJWT, - async (req: Request, res: Response) => { - const sessionId = (req as AuthenticatedRequest).sessionId; - try { - if (sessionId) await authManager.revokeSession(sessionId); - res.json({ success: true }); - } catch (error) { - syncLogger.error("Failed to end a desktop session", error, { - operation: "sync_unlink", - }); - res.status(500).json({ error: "Failed to unlink" }); - } - }, -); - -/** - * @openapi - * /sync/v2/changes: - * get: - * summary: Pull what changed for this account after a cursor - * description: Returns records in change order. Each has the entity type, syncId, revision, whether it is deleted, and the row when it is not. Records of entity types this server does not currently run are skipped but still move the cursor. - * tags: - * - Sync - * parameters: - * - in: query - * name: cursor - * schema: - * type: integer - * - in: query - * name: limit - * schema: - * type: integer - * - in: query - * name: types - * description: Comma-separated entity types, to backfill only those. - * schema: - * type: string - * 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. - */ -router.get( - "/v2/changes", - authenticateJWT, - async (req: Request, res: Response) => { - const userId = userOf(req); - 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) - : undefined; - - try { - const page = await withUserLock(userId, async () => { - const { wire } = await reconcileUser(userId); - const records = await listChanges(userId, cursor, limit + 1, types); - return { wire, records }; - }); - const hasMore = page.records.length > limit; - const records = page.records.slice(0, limit); - const changes = []; - for (const record of records) { - if (!getEntity(record.entityType)) continue; - const row = record.deleted - ? null - : (page.wire.get(recordKey(record.entityType, record.syncId)) ?? - null); - // A live record with no row failed to serialize; it comes again once it - // changes, rather than arriving as a delete. - if (!record.deleted && !row) continue; - changes.push({ - seq: record.seq, - entityType: record.entityType, - syncId: record.syncId, - revision: record.revision, - deleted: !!record.deleted, - row, - }); - } - res.json({ - changes, - nextCursor: records.length ? records[records.length - 1].seq : cursor, - hasMore, - entityTypes: listEntities().map((entity) => ({ - type: entity.type, - owner: entity.owner, - order: entity.order, - readOnly: !!entity.readOnly, - })), - }); - } catch (error) { - syncLogger.error("Failed to read sync changes", error, { - operation: "sync_changes", - userId, - }); - res.status(500).json({ error: "Failed to read changes" }); - } - }, -); - -/** - * @openapi - * /sync/v2/push: - * post: - * summary: Push a desktop's changes - * description: Each op carries the revision the desktop last saw. Ops are applied in dependency order and each gets its own result, ok with the new revision, conflict with the server's version, or rejected with a reason. - * tags: - * - Sync - * requestBody: - * content: - * application/json: - * schema: - * type: object - * properties: - * ops: - * type: array - * items: - * type: object - * responses: - * 200: - * description: One result per op, in the order sent. - * 400: - * description: Malformed ops, or more than 500. - * 500: - * description: Failed to apply changes. - */ -router.post( - "/v2/push", - authenticateJWT, - async (req: Request, res: Response) => { - const userId = userOf(req); - const ops = parsePushOps(req.body); - if (!ops) return res.status(400).json({ error: "Invalid ops" }); - try { - const results = await withUserLock(userId, () => applyPush(userId, ops)); - res.json({ results }); - } catch (error) { - syncLogger.error("Failed to apply pushed changes", error, { - operation: "sync_push", - userId, - }); - res.status(500).json({ error: "Failed to apply changes" }); - } - }, -); - -/** - * @openapi - * /sync/v2/events: - * get: - * summary: Stream a notice whenever this account's data changes - * description: Server-sent events. A "change" event means there is something new to pull. Comments are sent every 25 seconds to keep proxies from closing the stream. - * tags: - * - Sync - * responses: - * 200: - * description: An event stream. - */ -router.get("/v2/events", authenticateJWT, (req: Request, res: Response) => { - const userId = userOf(req); - res.setHeader("Content-Type", "text/event-stream"); - res.setHeader("Cache-Control", "no-cache, no-transform"); - res.setHeader("Connection", "keep-alive"); - res.setHeader("X-Accel-Buffering", "no"); - res.flushHeaders?.(); - res.write(`event: ready\ndata: {}\n\n`); - const remove = addListener(userId, res); - req.on("close", remove); -}); - -/** - * @openapi - * /sync/v2/hosts/{syncId}: - * get: - * summary: This server's id for a host, by its sync id - * description: For a linked desktop that asks the server to act on a host (a tunnel through it), which needs the server's own id. Only for hosts the caller can connect to. - * tags: - * - Sync - * parameters: - * - in: path - * name: syncId - * required: true - * schema: - * type: string - * responses: - * 200: - * description: The host's id and name. - * 404: - * description: No such host the caller can connect to. - */ -router.get( - "/v2/hosts/:syncId", - authenticateJWT, - async (req: Request, res: Response) => { - const userId = userOf(req); - try { - const { createCurrentHostResolutionRepository } = - await import("../../database/repositories/factory.js"); - const hostId = - await createCurrentHostResolutionRepository().findHostIdBySyncId( - String(req.params.syncId), - ); - if (hostId === null) return res.status(404).json({ error: "Not found" }); - const { PermissionManager } = - await import("../../utils/permission-manager.js"); - const access = await PermissionManager.getInstance().canAccessHost( - userId, - hostId, - "connect", - ); - if (!access.hasAccess) - return res.status(404).json({ error: "Not found" }); - const { createCurrentHostRepository } = - await import("../../database/repositories/factory.js"); - const row = await createCurrentHostRepository().findById(hostId); - res.json({ id: hostId, name: row?.name ?? row?.ip ?? null }); - } catch (error) { - syncLogger.error("Failed to look up a host by sync id", error, { - operation: "sync_host_lookup", - }); - res.status(500).json({ error: "Failed to look up host" }); - } - }, -); - -/** - * @openapi - * /sync/v2/host-status: - * get: - * summary: Status of the hosts this account can see, keyed by sync id - * description: For a linked desktop whose hosts connect through this server, so it can show their status against its own copies. - * tags: - * - Sync - * responses: - * 200: - * description: Status entries by host sync id. - */ -router.get( - "/v2/host-status", - authenticateJWT, - async (req: Request, res: Response) => { - const userId = userOf(req); - try { - const { hostStatusService } = - await import("../../hosts/status/host-status-service.js"); - const { PermissionManager } = - await import("../../utils/permission-manager.js"); - const { createCurrentHostRepository } = - await import("../../database/repositories/factory.js"); - const statuses = await hostStatusService.statusesFor(userId, null); - const entries = [...statuses.entries()]; - const allowed = - await PermissionManager.getInstance().filterAccessibleHostIds( - userId, - entries.map(([id]) => id), - ); - const hostRepository = createCurrentHostRepository(); - const result: Record = {}; - for (const [id, entry] of entries) { - if (!allowed.has(id)) continue; - const row = await hostRepository.findById(id); - if (row?.syncId) result[row.syncId] = entry; - } - res.json(result); - } catch (error) { - syncLogger.error("Failed to read host status for sync", error, { - operation: "sync_host_status", - }); - res.status(500).json({ error: "Failed to read host status" }); - } - }, -); - -async function listPluginsForDesktop() { - return getPluginRuntime().loader.list(); -} - -/** - * @openapi - * /sync/v2/plugins: - * get: - * summary: The features this server runs, for a linked desktop to mirror - * description: Every installed plugin with its version, where it came from, whether it is on, how a desktop should treat it and which sync entities it owns. registryKeys lists the signing keys of the custom registries an admin added, so plugins from them load on the desktop. - * tags: - * - Sync - * responses: - * 200: - * description: The plugin list. - */ -router.get( - "/v2/plugins", - authenticateJWT, - async (_req: Request, res: Response) => { - try { - const records = new Map( - (await createCurrentPluginRepository().listAll()).map((record) => [ - record.id, - record, - ]), - ); - const grants = createCurrentPluginPermissionGrantRepository(); - const plugins = []; - for (const plugin of await listPluginsForDesktop()) { - plugins.push({ - id: plugin.id, - name: plugin.manifest.name, - version: plugin.manifest.version, - source: plugin.source, - // A bundled plugin updated from a signed .tmxplug can be mirrored. - artifact: !!plugin.artifact, - enabled: records.get(plugin.id)?.state === "enabled", - active: plugin.state === "active", - desktop: plugin.manifest.desktop ?? "mirror", - syncEntities: plugin.manifest.contributes?.syncEntities ?? [], - granted: (await grants.listByPlugin(plugin.id)).map( - (grant) => grant.capability, - ), - }); - } - const { customRegistryKeys } = - await import("../../plugins/registries.js"); - res.json({ plugins, registryKeys: await customRegistryKeys() }); - } catch (error) { - syncLogger.error("Failed to list plugins for sync", error, { - operation: "sync_plugins", - }); - res.status(500).json({ error: "Failed to list plugins" }); - } - }, -); - -/** - * @openapi - * /sync/v2/plugins/{id}/package: - * get: - * summary: Download an installed plugin so a linked desktop can install it - * description: Only for plugins installed on this server, or bundled ones replaced by a signed update. Sends the original .tmxplug when there is one, with its signature in the X-Termix-Signature header, otherwise a fresh archive of the plugin folder. - * tags: - * - Sync - * parameters: - * - in: path - * name: id - * required: true - * schema: - * type: string - * responses: - * 200: - * description: The plugin archive. - * 404: - * description: No installed plugin with that id. - */ -router.get( - "/v2/plugins/:id/package", - authenticateJWT, - async (req: Request, res: Response) => { - const pluginId = String(req.params.id); - try { - const plugin = (await listPluginsForDesktop()).find( - (candidate) => candidate.id === pluginId, - ); - if (!plugin || (plugin.source !== "user" && !plugin.artifact)) { - return res.status(404).json({ error: "Plugin not found" }); - } - const artifact = plugin.artifact ?? ""; - res.setHeader("Content-Type", "application/gzip"); - if (fs.existsSync(artifact)) { - const sig = `${artifact}.sig`; - if (fs.existsSync(sig)) { - res.setHeader( - "X-Termix-Signature", - Buffer.from(await fs.promises.readFile(sig, "utf8")).toString( - "base64", - ), - ); - } - return fs.createReadStream(artifact).pipe(res); - } - const temp = path.join( - os.tmpdir(), - `termix-plugin-${pluginId}-${Date.now()}.tmxplug`, - ); - await tar.c({ gzip: true, cwd: plugin.dir, file: temp }, ["."]); - const stream = fs.createReadStream(temp); - stream.on("close", () => void fs.promises.rm(temp, { force: true })); - stream.pipe(res); - } catch (error) { - syncLogger.error("Failed to package a plugin for sync", error, { - operation: "sync_plugin_package", - pluginId, - }); - if (!res.headersSent) { - res.status(500).json({ error: "Failed to package plugin" }); - } - } - }, -); - -export default router; diff --git a/src/backend/sync/server/write-listeners.ts b/src/backend/sync/server/write-listeners.ts new file mode 100644 index 000000000..1465a4524 --- /dev/null +++ b/src/backend/sync/server/write-listeners.ts @@ -0,0 +1,11 @@ +const writeListeners = new Set<() => void>(); + +/** Called after every successful write request, for a linked desktop's engine. */ +export function onLocalWrite(listener: () => void): () => void { + writeListeners.add(listener); + return () => writeListeners.delete(listener); +} + +export function notifyLocalWrite(): void { + for (const listener of writeListeners) listener(); +} diff --git a/src/backend/sync/shown-identity.ts b/src/backend/sync/shown-identity.ts new file mode 100644 index 000000000..a841a9671 --- /dev/null +++ b/src/backend/sync/shown-identity.ts @@ -0,0 +1,40 @@ +/** + * On a desktop linked to a server, the account it is signed in to there. + * That account is who the user is; the local profile only mirrors it. + */ +export async function describeDesktopLink(userId: string) { + if (process.env.ELECTRON_EMBEDDED !== "true") return null; + try { + const { getLink } = await import("./client/link-store.js"); + const link = await getLink(userId); + if (!link) return null; + return { + serverUrl: link.serverUrl, + serverName: link.serverName, + username: link.account?.username ?? link.remoteUsername, + isAdmin: !!link.account?.isAdmin, + roles: link.account?.roles ?? [], + permissions: link.account?.permissions ?? [], + status: link.status, + }; + } catch { + return null; + } +} + +/** + * Who the app shows. On a desktop linked to a server that is the account it + * is signed in to there, not the local user that holds the copy of its data. + */ +export async function shownIdentity(user: { + id: string; + username: string; + isAdmin: boolean | null; +}) { + const linked = await describeDesktopLink(user.id); + return { + linked, + username: linked?.username || user.username, + isAdmin: linked ? linked.isAdmin : !!user.isAdmin, + }; +} diff --git a/src/backend/tests/auth/login-pipeline.test.ts b/src/backend/tests/auth/login-pipeline.test.ts index c74588791..40eaa8727 100644 --- a/src/backend/tests/auth/login-pipeline.test.ts +++ b/src/backend/tests/auth/login-pipeline.test.ts @@ -97,6 +97,8 @@ const { getPasswordLoginStatus } = await import("../../auth/core-auth.js"); const { registerLoginMethod, registerSecondFactor } = await import("../../auth/registry.js"); const { loginRateLimiter } = await import("../../utils/login-rate-limiter.js"); +const { provideAuthPolicy, clearAuthPolicies, SECOND_FACTOR_SETUP } = + await import("../../auth/policy.js"); const PASSWORD = "correct-horse"; // Stands in for the totp plugin's factor: core only sees the registration. @@ -137,6 +139,7 @@ beforeEach(() => { delete process.env.ALLOW_PASSWORD_LOGIN; disposeTotp?.(); registerTotpFixture(); + clearAuthPolicies(); }); async function passwordLogin(body: Record, headers = {}) { @@ -891,3 +894,219 @@ describe("lockout guard", () => { }); }); }); + +describe("login policy", () => { + function enrolTotp() { + h.state.factors.push({ userId: "u1", pluginId: "totp", factorId: "totp" }); + } + + function trustedLogin(token: string) { + const req = fakeRequest({ + body: { username: "alice", password: PASSWORD }, + cookies: { [TRUST_DEVICE_COOKIE]: token }, + }); + return runLogin( + req as never, + { kind: "user", userId: "u1" }, + { methodId: "password", rememberMe: true }, + ); + } + + it("hands the provider the user's factors and the device", async () => { + addUser(); + enrolTotp(); + const evaluate = vi.fn(() => ({})); + provideAuthPolicy("policy", { evaluate }); + await passwordLogin({ username: "alice", password: PASSWORD }); + expect(evaluate).toHaveBeenCalledWith({ + userId: "u1", + methodId: "password", + external: false, + deviceType: "web", + trustedDevice: false, + mfaSatisfied: false, + secondFactors: [{ id: "totp", pluginId: "totp", enrolled: true }], + }); + }); + + it("turns remember me off and caps the session", async () => { + addUser(); + provideAuthPolicy("policy", { + evaluate: () => ({ allowRememberDevice: false, maxSessionSeconds: 600 }), + }); + const res = await passwordLogin({ + username: "alice", + password: PASSWORD, + rememberMe: true, + }); + expect(h.manager.generateJWTToken).toHaveBeenCalledWith( + "u1", + expect.objectContaining({ rememberMe: false, maxAgeSeconds: 600 }), + ); + expect((res.cookies[0].options as { maxAge: number }).maxAge).toBe( + 600 * 1000, + ); + }); + + it("stores the idle timeout on the session", async () => { + addUser(); + provideAuthPolicy("policy", { + evaluate: () => ({ idleTimeoutSeconds: 900 }), + }); + provideAuthPolicy("other", { + evaluate: () => ({ idleTimeoutSeconds: 300 }), + }); + await passwordLogin({ username: "alice", password: PASSWORD }); + expect(h.manager.generateJWTToken).toHaveBeenCalledWith( + "u1", + expect.objectContaining({ idleTimeoutSeconds: 300 }), + ); + }); + + it("ignores a remembered device when remember me is off", async () => { + addUser(); + enrolTotp(); + const token = "c".repeat(64); + h.state.trusted.add(`u1:${hashTrustToken(token)}`); + provideAuthPolicy("policy", { + evaluate: () => ({ allowRememberDevice: false }), + }); + const result = await trustedLogin(token); + expect(result.kind).toBe("second-factor"); + }); + + it("asks for the factor on a remembered device with always, not with new-device", async () => { + addUser(); + enrolTotp(); + const token = "d".repeat(64); + h.state.trusted.add(`u1:${hashTrustToken(token)}`); + + const remove = provideAuthPolicy("policy", { + evaluate: () => ({ secondFactor: "always" }), + }); + expect((await trustedLogin(token)).kind).toBe("second-factor"); + remove(); + + provideAuthPolicy("policy", { + evaluate: () => ({ secondFactor: "new-device" }), + }); + expect((await trustedLogin(token)).kind).toBe("session"); + }); + + it("gives a user without a factor a setup only session", async () => { + addUser(); + provideAuthPolicy("policy", { + evaluate: () => ({ secondFactor: "always" }), + }); + const res = await passwordLogin({ username: "alice", password: PASSWORD }); + expect(h.manager.generateJWTToken).toHaveBeenCalledWith( + "u1", + expect.objectContaining({ restriction: SECOND_FACTOR_SETUP }), + ); + expect(res.body).toMatchObject({ restriction: SECOND_FACTOR_SETUP }); + }); + + it("lets a verified passkey satisfy a required factor", async () => { + addUser(); + provideAuthPolicy("policy", { + evaluate: () => ({ secondFactor: "always" }), + }); + const result = await runLogin( + fakeRequest() as never, + { kind: "user", userId: "u1", mfaSatisfied: true }, + { methodId: "passkey", rememberMe: false }, + ); + expect(result.kind).toBe("session"); + expect(h.manager.generateJWTToken).toHaveBeenCalledWith( + "u1", + expect.objectContaining({ restriction: null }), + ); + }); + + it("runs factors for SSO logins when the policy requires one", async () => { + const dispose = registerLoginMethod({ + id: "oidc", + pluginId: "sso", + labelKey: "loginWithSso", + kind: "redirect", + external: true, + }); + try { + addUser({ passwordHash: "" }); + enrolTotp(); + h.state.identities.push({ + id: 1, + userId: "u1", + providerId: "3", + subject: "sub-1", + email: null, + }); + provideAuthPolicy("policy", { + evaluate: (input) => (input.external ? { secondFactor: "always" } : {}), + }); + const result = await runLogin( + fakeRequest() as never, + { + kind: "external", + provider: "3", + subject: "sub-1", + email: null, + name: "Alice", + } as never, + { methodId: "oidc", rememberMe: false }, + ); + expect(result.kind).toBe("second-factor"); + } finally { + dispose(); + } + }); + + it("keeps the policy for the factor step: no trusted device, same cap", async () => { + addUser(); + enrolTotp(); + provideAuthPolicy("policy", { + evaluate: () => ({ allowRememberDevice: false, maxSessionSeconds: 1200 }), + }); + const first = await passwordLogin({ + username: "alice", + password: PASSWORD, + rememberMe: true, + }); + const res = fakeResponse(); + await verifySecondFactorAndRespond( + fakeRequest({ + body: { + temp_token: (first.body as { temp_token: string }).temp_token, + totp_code: TOTP_CODE, + rememberMe: true, + }, + }) as never, + res as never, + "totp", + ); + expect(res.statusCode).toBe(200); + expect(res.cookies.map((c) => c.name)).toEqual(["jwt"]); + expect(h.state.trustedAdded).toEqual([]); + expect(h.manager.generateJWTToken).toHaveBeenLastCalledWith( + "u1", + expect.objectContaining({ rememberMe: false, maxAgeSeconds: 1200 }), + ); + }); + + it("refuses the login when a provider throws", async () => { + addUser(); + provideAuthPolicy("policy", { + evaluate: () => { + throw new Error("boom"); + }, + }); + await expect( + runLogin( + fakeRequest() as never, + { kind: "user", userId: "u1" }, + { methodId: "password", rememberMe: false }, + ), + ).rejects.toMatchObject({ status: 503, code: "auth_policy_unavailable" }); + expect(h.state.audits).toEqual([]); + }); +}); diff --git a/src/backend/tests/auth/policy.test.ts b/src/backend/tests/auth/policy.test.ts new file mode 100644 index 000000000..9cd5b8933 --- /dev/null +++ b/src/backend/tests/auth/policy.test.ts @@ -0,0 +1,100 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("../../utils/logger.js", () => ({ + authLogger: { warn: vi.fn() }, +})); + +const { + clearAuthPolicies, + evaluateAuthPolicy, + hasAuthPolicies, + provideAuthPolicy, + rememberDeviceAllowed, +} = await import("../../auth/policy.js"); + +const input = { + userId: "u1", + methodId: "password", + external: false, + deviceType: "web" as const, + trustedDevice: false, + mfaSatisfied: false, + secondFactors: [], +}; + +beforeEach(() => clearAuthPolicies()); + +describe("auth policy", () => { + it("changes nothing without a provider", async () => { + expect(hasAuthPolicies()).toBe(false); + expect(await evaluateAuthPolicy(input)).toEqual({ + allowRememberDevice: true, + maxSessionSeconds: null, + idleTimeoutSeconds: null, + secondFactor: null, + }); + expect(rememberDeviceAllowed()).toBe(true); + }); + + it("takes the strictest answer from every provider", async () => { + provideAuthPolicy("a", { + evaluate: () => ({ + maxSessionSeconds: 3600, + idleTimeoutSeconds: 900, + secondFactor: "new-device", + }), + }); + provideAuthPolicy("b", { + evaluate: async () => ({ + allowRememberDevice: false, + maxSessionSeconds: 7200, + idleTimeoutSeconds: 300, + secondFactor: "always", + }), + allowRememberDevice: () => false, + }); + expect(await evaluateAuthPolicy(input)).toEqual({ + allowRememberDevice: false, + maxSessionSeconds: 3600, + idleTimeoutSeconds: 300, + secondFactor: "always", + }); + expect(rememberDeviceAllowed()).toBe(false); + }); + + it("ignores zero and negative limits", async () => { + provideAuthPolicy("a", { + evaluate: () => ({ maxSessionSeconds: 0, idleTimeoutSeconds: -5 }), + }); + expect(await evaluateAuthPolicy(input)).toMatchObject({ + maxSessionSeconds: null, + idleTimeoutSeconds: null, + }); + }); + + it("refuses the login when a provider throws, until it is removed", async () => { + const remove = provideAuthPolicy("a", { + evaluate: () => { + throw new Error("boom"); + }, + }); + await expect(evaluateAuthPolicy(input)).rejects.toMatchObject({ + status: 503, + code: "auth_policy_unavailable", + }); + remove(); + await expect(evaluateAuthPolicy(input)).resolves.toMatchObject({ + secondFactor: null, + }); + }); + + it("hides remember me when the hint throws", () => { + provideAuthPolicy("a", { + evaluate: () => ({}), + allowRememberDevice: () => { + throw new Error("boom"); + }, + }); + expect(rememberDeviceAllowed()).toBe(false); + }); +}); diff --git a/src/backend/tests/database/repositories/recent-activity-repository.test.ts b/src/backend/tests/database/repositories/recent-activity-repository.test.ts deleted file mode 100644 index 08087947d..000000000 --- a/src/backend/tests/database/repositories/recent-activity-repository.test.ts +++ /dev/null @@ -1,99 +0,0 @@ -import { sql } from "drizzle-orm"; -import { afterEach, describe, expect, it } from "vitest"; -import { TestSqliteDatabase } from "./test-support.js"; -import { RecentActivityRepository } from "../../../database/repositories/recent-activity-repository.js"; - -describe("RecentActivityRepository", () => { - let adapter: TestSqliteDatabase | null = null; - - afterEach(async () => { - if (adapter) { - await adapter.close(); - adapter = null; - } - }); - - async function createRepository( - onWrite?: () => void | Promise, - ): Promise<{ - repository: RecentActivityRepository; - }> { - adapter = new TestSqliteDatabase(); - const context = await adapter.connect(); - await adapter.exec(` - INSERT INTO users (id, username, password_hash) - VALUES ('user-1', 'alice', 'hash'), ('user-2', 'bob', 'hash'); - INSERT INTO ssh_data (id, user_id, name, ip, port, username, auth_type) - VALUES (1, 'user-1', 'one', '10.0.0.1', 22, 'root', 'password'), (2, 'user-1', 'two', '10.0.0.1', 22, 'root', 'password'), (3, 'user-2', 'other', '10.0.0.1', 22, 'root', 'password'); - INSERT INTO recent_activity (id, user_id, type, host_id, host_name, timestamp) - VALUES - (1, 'user-1', 'connect', 1, 'one', '2026-06-26T00:00:00.000Z'), - (2, 'user-1', 'disconnect', 2, 'two', '2026-06-26T00:01:00.000Z'), - (3, 'user-2', 'connect', 3, 'other', '2026-06-26T00:02:00.000Z'); - `); - - return { - repository: new RecentActivityRepository(context, onWrite), - }; - } - - it("lists, creates, and trims recent activity", async () => { - let writeCount = 0; - const { repository } = await createRepository(() => { - writeCount += 1; - }); - - expect( - (await repository.listByUserId("user-1", 2)).map((row) => row.id), - ).toEqual([2, 1]); - - const created = await repository.create({ - userId: "user-1", - type: "terminal", - hostId: 1, - hostName: "one", - timestamp: "2026-06-26T00:03:00.000Z", - }); - expect(created).toMatchObject({ - userId: "user-1", - type: "terminal", - hostId: 1, - }); - - expect(await repository.trimUserActivity("user-1", 2)).toBe(1); - expect( - await adapter!.query( - sql`SELECT id FROM recent_activity WHERE user_id = 'user-1' ORDER BY timestamp DESC`, - ), - ).toEqual([{ id: created.id }, { id: 2 }]); - expect(writeCount).toBe(2); - }); - - it("deletes activity by user id and only triggers writes for changed rows", async () => { - let writeCount = 0; - const { repository: repo } = await createRepository(() => { - writeCount += 1; - }); - - expect(await repo.deleteByUserId("missing")).toBe(0); - expect(writeCount).toBe(0); - - expect(await repo.deleteByUserId("user-1")).toBe(2); - expect(writeCount).toBe(1); - expect(await repo.deleteByUserId("user-1")).toBe(0); - expect(writeCount).toBe(1); - }); - - it("deletes activity by host id and host id list", async () => { - let writeCount = 0; - const { repository: repo } = await createRepository(() => { - writeCount += 1; - }); - - expect(await repo.deleteByHostId(1)).toBe(1); - expect(await repo.deleteByHostId(1)).toBe(0); - expect(await repo.deleteByHostIds([])).toBe(0); - expect(await repo.deleteByHostIds([2, 3])).toBe(2); - expect(writeCount).toBe(2); - }); -}); diff --git a/src/backend/tests/database/routes/branding-routes.test.ts b/src/backend/tests/database/routes/branding-routes.test.ts index f9f55e95e..b8f47ded2 100644 --- a/src/backend/tests/database/routes/branding-routes.test.ts +++ b/src/backend/tests/database/routes/branding-routes.test.ts @@ -1,5 +1,4 @@ -import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; -import sharp from "sharp"; +import { beforeEach, describe, expect, it, vi } from "vitest"; import express, { type NextFunction, type Request, @@ -19,15 +18,6 @@ vi.mock("../../../utils/logger.js", () => ({ vi.mock("../../../database/repositories/factory.js", () => ({ createCurrentSettingsRepository: () => ({ get: async (key: string) => state.settings[key] ?? null, - set: async (key: string, value: string) => { - state.settings[key] = value; - }, - setMany: async (writes: Array<{ key: string; value: string }>) => { - for (const write of writes) state.settings[write.key] = write.value; - }, - delete: async (key: string) => { - delete state.settings[key]; - }, }), })); @@ -42,25 +32,17 @@ interface RouteLayer { }; } -/** Runs a route's full middleware chain (e.g. requireAdmin, then the handler). */ -function handlersFor( - router: express.Router, - pathName: string, - method: string, -): RequestHandler[] { - const layer = (router as unknown as { stack: RouteLayer[] }).stack.find( - (candidate) => - candidate.route?.path === pathName && candidate.route.methods[method], - ); - if (!layer) throw new Error(`Route not registered: ${method} ${pathName}`); - return layer.route!.stack.map((entry) => entry.handle); +function routesOf(router: express.Router) { + return (router as unknown as { stack: RouteLayer[] }).stack + .filter((layer) => layer.route) + .map((layer) => ({ + path: layer.route!.path, + methods: Object.keys(layer.route!.methods), + handlers: layer.route!.stack.map((entry) => entry.handle), + })); } -async function invokeChain( - handlers: RequestHandler[], - body: unknown = {}, -): Promise<{ statusCode: number; body: unknown }> { - const req = { body, headers: {} } as unknown as Request; +async function call(handler: RequestHandler) { const result = { statusCode: 200, body: null as unknown }; const res = { status(code: number) { @@ -72,67 +54,34 @@ async function invokeChain( return this; }, } as unknown as Response; - - for (const handler of handlers) { - let proceeded = false; - await handler(req, res, ((err?: unknown) => { - if (err) throw err; - proceeded = true; - }) as NextFunction); - if (!proceeded) return result; - } + await handler( + { body: {}, headers: {} } as Request, + res, + (() => {}) as NextFunction, + ); return result; } -/** Mimics the real requireAdmin: 401 with no session, 403 for a non-admin. */ -function fakeRequireAdmin( - outcome: "unauthenticated" | "not-admin" | "admin", -): RequestHandler { - return (_req, res, next) => { - if (outcome === "unauthenticated") { - res.status(401).json({ error: "Missing authentication token" }); - return; - } - if (outcome === "not-admin") { - res.status(403).json({ error: "Not authorized" }); - return; - } - // Must return (not just call) next() so invokeChain's await actually - // waits for the downstream async handler to finish before returning. - return next(); - }; -} - -function buildRouter(requireAdmin: RequestHandler) { - const router = express.Router(); - registerBrandingRoutes(router, requireAdmin); - return router; -} - -let pngDataUrl: string; - -beforeAll(async () => { - const buffer = await sharp({ - create: { width: 4, height: 4, channels: 3, background: "#112233" }, - }) - .png() - .toBuffer(); - pngDataUrl = `data:image/png;base64,${buffer.toString("base64")}`; -}); - beforeEach(() => { state.settings = {}; }); describe("GET /users/branding", () => { - it("succeeds with no auth at all, since the route registers no auth handler", async () => { - // requireAdmin is only ever wired onto PATCH below; GET's own handler chain - // has exactly one entry, proving the public route never runs an admin gate. - const router = buildRouter(fakeRequireAdmin("unauthenticated")); - const handlers = handlersFor(router, "/branding", "get"); - expect(handlers).toHaveLength(1); + it("is the only branding route and has no auth handler", () => { + const router = express.Router(); + registerBrandingRoutes(router); + const routes = routesOf(router); - const response = await invokeChain(handlers); + expect(routes).toHaveLength(1); + expect(routes[0].path).toBe("/branding"); + expect(routes[0].methods).toEqual(["get"]); + expect(routes[0].handlers).toHaveLength(1); + }); + + it("answers defaults when nothing is set", async () => { + const router = express.Router(); + registerBrandingRoutes(router); + const response = await call(routesOf(router)[0].handlers[0]); expect(response.statusCode).toBe(200); expect(response.body).toEqual({ @@ -142,126 +91,19 @@ describe("GET /users/branding", () => { }); }); - it("reflects persisted branding values", async () => { - state.settings.branding_app_name = "Acme Ops"; - state.settings.branding_tagline = "Ship it"; - state.settings.branding_logo = pngDataUrl; - const router = buildRouter(fakeRequireAdmin("admin")); + it("keeps serving the stored branding of an older install", async () => { + state.settings = { + branding_app_name: "Acme", + branding_tagline: "Ops", + }; + const router = express.Router(); + registerBrandingRoutes(router); + const response = await call(routesOf(router)[0].handlers[0]); - const response = await invokeChain(handlersFor(router, "/branding", "get")); - - expect(response.statusCode).toBe(200); expect(response.body).toEqual({ - appName: "Acme Ops", - tagline: "Ship it", - logo: pngDataUrl, + appName: "Acme", + tagline: "Ops", + logo: null, }); }); }); - -describe("PATCH /users/branding", () => { - it("rejects an unauthenticated request and persists nothing", async () => { - const router = buildRouter(fakeRequireAdmin("unauthenticated")); - - const response = await invokeChain( - handlersFor(router, "/branding", "patch"), - { appName: "Hijacked" }, - ); - - expect(response.statusCode).toBe(401); - expect(state.settings).toEqual({}); - }); - - it("rejects a non-admin request and persists nothing", async () => { - const router = buildRouter(fakeRequireAdmin("not-admin")); - - const response = await invokeChain( - handlersFor(router, "/branding", "patch"), - { appName: "Hijacked" }, - ); - - expect(response.statusCode).toBe(403); - expect(state.settings).toEqual({}); - }); - - it("persists only the allowed fields for an admin request", async () => { - const router = buildRouter(fakeRequireAdmin("admin")); - - const response = await invokeChain( - handlersFor(router, "/branding", "patch"), - { appName: "Acme Ops", tagline: "Ship it", logo: pngDataUrl }, - ); - - expect(response.statusCode).toBe(200); - expect(state.settings.branding_app_name).toBe("Acme Ops"); - expect(state.settings.branding_tagline).toBe("Ship it"); - expect(state.settings.branding_logo).toMatch(/^data:image\/png;base64,/); - expect(response.body).toMatchObject({ - appName: "Acme Ops", - tagline: "Ship it", - }); - }); - - it("rejects unknown fields without writing anything", async () => { - const router = buildRouter(fakeRequireAdmin("admin")); - - const response = await invokeChain( - handlersFor(router, "/branding", "patch"), - { theme: "dark" }, - ); - - expect(response.statusCode).toBe(400); - expect(response.body).toMatchObject({ - code: "BRANDING_SETTINGS_UNKNOWN_FIELD", - }); - expect(state.settings).toEqual({}); - }); - - it("rejects an app name over the length limit", async () => { - const router = buildRouter(fakeRequireAdmin("admin")); - - const response = await invokeChain( - handlersFor(router, "/branding", "patch"), - { appName: "x".repeat(61) }, - ); - - expect(response.statusCode).toBe(400); - expect(response.body).toMatchObject({ - code: "BRANDING_SETTINGS_INVALID", - field: "appName", - }); - expect(state.settings).toEqual({}); - }); - - it("rejects malformed/non-raster logo data without persisting it", async () => { - const router = buildRouter(fakeRequireAdmin("admin")); - - const response = await invokeChain( - handlersFor(router, "/branding", "patch"), - { - logo: `data:image/png;base64,${Buffer.from("not an image").toString("base64")}`, - }, - ); - - expect(response.statusCode).toBe(400); - expect(response.body).toMatchObject({ - code: "BRANDING_SETTINGS_INVALID", - field: "logo", - }); - expect(state.settings).toEqual({}); - }); - - it("clears the logo when patched with null", async () => { - state.settings.branding_logo = pngDataUrl; - const router = buildRouter(fakeRequireAdmin("admin")); - - const response = await invokeChain( - handlersFor(router, "/branding", "patch"), - { logo: null }, - ); - - expect(response.statusCode).toBe(200); - expect(state.settings.branding_logo).toBeUndefined(); - expect(response.body).toMatchObject({ logo: null }); - }); -}); diff --git a/src/backend/tests/database/routes/branding-settings.test.ts b/src/backend/tests/database/routes/branding-settings.test.ts index bbed21e7c..2df5bdc92 100644 --- a/src/backend/tests/database/routes/branding-settings.test.ts +++ b/src/backend/tests/database/routes/branding-settings.test.ts @@ -1,149 +1,17 @@ -import { beforeAll, describe, expect, it, vi } from "vitest"; -import sharp from "sharp"; +import { describe, expect, it, vi } from "vitest"; vi.mock("../../../utils/logger.js", () => ({ authLogger: { warn: vi.fn(), error: vi.fn(), info: vi.fn() }, databaseLogger: { warn: vi.fn(), error: vi.fn(), info: vi.fn() }, })); -const { - BRANDING_DEFAULT_APP_NAME, - BRANDING_LOGO_MAX_BYTES, - parseBrandingAppName, - parseBrandingLogoDataUrl, - parseBrandingTagline, - resolveBrandingSettings, -} = await import("../../../database/routes/branding-settings.js"); +const { BRANDING_DEFAULT_APP_NAME, resolveBrandingSettings } = + await import("../../../database/routes/branding-settings.js"); function settingSource(values: Record) { return { get: async (key: string) => values[key] ?? null }; } -async function toDataUrl( - buffer: Buffer, - mime: "image/png" | "image/jpeg" | "image/webp", -): Promise { - return `data:${mime};base64,${buffer.toString("base64")}`; -} - -let pngBuffer: Buffer; -let jpegBuffer: Buffer; -let webpBuffer: Buffer; -let oversizedPngBuffer: Buffer; - -beforeAll(async () => { - const image = sharp({ - create: { width: 4, height: 4, channels: 3, background: "#336699" }, - }); - pngBuffer = await image.clone().png().toBuffer(); - jpegBuffer = await image.clone().jpeg().toBuffer(); - webpBuffer = await image.clone().webp().toBuffer(); - oversizedPngBuffer = await sharp({ - create: { width: 1200, height: 1200, channels: 4, background: "#abcdef" }, - }) - .png({ compressionLevel: 0 }) - .toBuffer(); -}); - -describe("parseBrandingAppName", () => { - it("trims and accepts a normal name", () => { - expect(parseBrandingAppName(" My Company ")).toBe("My Company"); - }); - - it("falls back to the default when cleared", () => { - expect(parseBrandingAppName("")).toBe(BRANDING_DEFAULT_APP_NAME); - expect(parseBrandingAppName(" ")).toBe(BRANDING_DEFAULT_APP_NAME); - }); - - it("rejects names over 60 characters", () => { - expect(parseBrandingAppName("x".repeat(61))).toBeNull(); - }); - - it("rejects non-string values", () => { - expect(parseBrandingAppName(42)).toBeNull(); - expect(parseBrandingAppName(null)).toBeNull(); - }); -}); - -describe("parseBrandingTagline", () => { - it("trims and accepts a normal tagline", () => { - expect(parseBrandingTagline(" Ship faster ")).toBe("Ship faster"); - }); - - it("accepts an empty tagline (means: use the default)", () => { - expect(parseBrandingTagline("")).toBe(""); - expect(parseBrandingTagline(" ")).toBe(""); - }); - - it("rejects taglines over 160 characters", () => { - expect(parseBrandingTagline("x".repeat(161))).toBeNull(); - }); - - it("rejects non-string values", () => { - expect(parseBrandingTagline(42)).toBeNull(); - }); -}); - -describe("parseBrandingLogoDataUrl", () => { - it("accepts a PNG data URL and re-encodes it to PNG", async () => { - const result = await parseBrandingLogoDataUrl( - await toDataUrl(pngBuffer, "image/png"), - ); - expect(result).not.toBeNull(); - expect(result!.startsWith("data:image/png;base64,")).toBe(true); - const decoded = Buffer.from(result!.split(",")[1]!, "base64"); - expect(decoded.subarray(1, 4).toString()).toBe("PNG"); - }); - - it("accepts a JPEG data URL and normalizes it to PNG", async () => { - const result = await parseBrandingLogoDataUrl( - await toDataUrl(jpegBuffer, "image/jpeg"), - ); - expect(result).not.toBeNull(); - expect(result!.startsWith("data:image/png;base64,")).toBe(true); - }); - - it("accepts a WEBP data URL and normalizes it to PNG", async () => { - const result = await parseBrandingLogoDataUrl( - await toDataUrl(webpBuffer, "image/webp"), - ); - expect(result).not.toBeNull(); - expect(result!.startsWith("data:image/png;base64,")).toBe(true); - }); - - it("rejects a non-image/SVG mime prefix outright", async () => { - const svg = Buffer.from('', "utf8").toString( - "base64", - ); - const result = await parseBrandingLogoDataUrl( - `data:image/svg+xml;base64,${svg}`, - ); - expect(result).toBeNull(); - }); - - it("rejects a value that isn't a data URL at all", async () => { - expect(await parseBrandingLogoDataUrl("not-a-data-url")).toBeNull(); - expect(await parseBrandingLogoDataUrl(undefined)).toBeNull(); - }); - - it("rejects a spoofed data URL whose bytes aren't a real image", async () => { - const fake = Buffer.from("definitely not an image").toString("base64"); - const result = await parseBrandingLogoDataUrl( - `data:image/png;base64,${fake}`, - ); - expect(result).toBeNull(); - }); - - it("rejects an image that normalizes over the size cap", async () => { - // Sanity check the fixture is actually large enough to exceed the cap. - expect(oversizedPngBuffer.length).toBeGreaterThan(BRANDING_LOGO_MAX_BYTES); - const result = await parseBrandingLogoDataUrl( - await toDataUrl(oversizedPngBuffer, "image/png"), - ); - expect(result).toBeNull(); - }); -}); - describe("resolveBrandingSettings", () => { it("returns built-in defaults when nothing is persisted", async () => { const settings = await resolveBrandingSettings(settingSource({})); @@ -169,3 +37,34 @@ describe("resolveBrandingSettings", () => { }); }); }); + +describe("branding provider", () => { + it("uses a plugin's branding provider and falls back when it throws", async () => { + const registry = await import("../../../plugins/registry.js"); + const provider = async () => ({ + appName: " Acme ", + tagline: "Hello", + logo: "data:image/png;base64,AAAA", + }); + registry.provide("demo.brandingProvider", provider); + try { + await expect(resolveBrandingSettings(settingSource({}))).resolves.toEqual( + { + appName: "Acme", + tagline: "Hello", + logo: "data:image/png;base64,AAAA", + }, + ); + + registry.revoke("demo.brandingProvider"); + registry.provide("demo.brandingProvider", async () => { + throw new Error("broken"); + }); + await expect( + resolveBrandingSettings(settingSource({ branding_app_name: "Stored" })), + ).resolves.toMatchObject({ appName: "Stored" }); + } finally { + registry.clearRegistry(); + } + }); +}); diff --git a/src/backend/tests/database/routes/credentials-secret-reference.test.ts b/src/backend/tests/database/routes/credentials-secret-reference.test.ts index 4f27a6943..7b7a2e9ee 100644 --- a/src/backend/tests/database/routes/credentials-secret-reference.test.ts +++ b/src/backend/tests/database/routes/credentials-secret-reference.test.ts @@ -52,12 +52,6 @@ vi.mock("../../../utils/logger.js", () => { }; return { authLogger: log, sshLogger: log, databaseLogger: log }; }); -vi.mock("../../../database/routes/credential-key-routes.js", () => ({ - registerCredentialKeyRoutes: () => {}, -})); -vi.mock("../../../database/routes/credential-deploy-routes.js", () => ({ - registerCredentialDeployRoutes: () => {}, -})); vi.mock("../../../database/routes/credential-bulk-routes.js", () => ({ registerCredentialBulkRoutes: () => {}, })); diff --git a/src/backend/tests/database/routes/dashboard-routes.test.ts b/src/backend/tests/database/routes/dashboard-routes.test.ts index 234fabc57..0dce85d2c 100644 --- a/src/backend/tests/database/routes/dashboard-routes.test.ts +++ b/src/backend/tests/database/routes/dashboard-routes.test.ts @@ -34,15 +34,12 @@ vi.mock("../../../utils/data-crypto.js", () => ({ }, })); -vi.mock("../../../database/repositories/factory.js", () => ({ - createCurrentRecentActivityRepository: () => ({ - listByUserId: async () => state.activity, +vi.mock("../../../plugins/activity-provider.js", () => ({ + recentActivity: { + list: async () => state.activity, deleteByUserId: async () => {}, - }), -})); - -vi.mock("../../../services/recent-activity.js", () => ({ - recordRecentActivity: async () => state.recordResult, + record: async () => state.recordResult, + }, })); const { default: dashboardRoutes } = diff --git a/src/backend/tests/database/routes/desktop-auto-session.test.ts b/src/backend/tests/database/routes/desktop-auto-session.test.ts index eeaccb1f9..879f791b0 100644 --- a/src/backend/tests/database/routes/desktop-auto-session.test.ts +++ b/src/backend/tests/database/routes/desktop-auto-session.test.ts @@ -136,6 +136,22 @@ describe("isNativeTokenExportRequest", () => { }); describe("resolveDesktopAutoSessionUser", () => { + it("returns the account the user switched to, while it exists", () => { + const admin = makeUser({ id: "admin", isAdmin: true }); + const chosen = makeUser({ id: "chosen" }); + expect(resolveDesktopAutoSessionUser([admin, chosen], "chosen")).toBe( + chosen, + ); + }); + + it("falls back to the usual pick when the chosen account is gone", () => { + const admin = makeUser({ id: "admin", isAdmin: true }); + const other = makeUser({ id: "other" }); + expect(resolveDesktopAutoSessionUser([admin, other], "deleted")).toBe( + admin, + ); + }); + it("returns the sole local user regardless of having a real password", () => { const user = makeUser({ passwordHash: "$2a$10$realbcryptvaluehere" }); expect(resolveDesktopAutoSessionUser([user])).toBe(user); 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 969120fa1..5912294bb 100644 --- a/src/backend/tests/database/routes/host-bulk-routes.test.ts +++ b/src/backend/tests/database/routes/host-bulk-routes.test.ts @@ -3,7 +3,7 @@ import { importedHostUsername, notifyImportedHosts, parseSSHConfig, -} from "../../../database/routes/host-bulk-routes.js"; +} from "../../../hosts/import/host-import.js"; import { pluginEvents, TOPICS } from "../../../plugins/events.js"; describe("parseSSHConfig", () => { 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 f61ff5649..1e796a7ec 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 @@ -1,5 +1,5 @@ import { beforeEach, expect, it, vi } from "vitest"; -import type { Router, RequestHandler, Request, Response } from "express"; +import type { RequestHandler, Request, Response } from "express"; const mocks = vi.hoisted(() => ({ create: vi.fn(), @@ -35,23 +35,35 @@ vi.mock("../../../database/repositories/factory.js", () => ({ findHostsByUserId: mocks.list, }), })); -import { registerHostBulkRoutes } from "../../../database/routes/host-bulk-routes.js"; -import { buildExportPayload } from "../../../../ui/sidebar/host-export-payload"; +import { + ImportError, + importResponse, + runBulkImport, + runSshConfigImport, +} from "../../../hosts/import/host-import.js"; import { pluginEvents, TOPICS } from "../../../plugins/events.js"; -let handler: RequestHandler; -let sshConfigHandler: RequestHandler; -const router = { - post: (path: string, ...handlers: RequestHandler[]) => { - if (path === "/bulk-import") handler = handlers.at(-1)!; - if (path === "/ssh-config-import") sshConfigHandler = handlers.at(-1)!; - }, - put: () => {}, - patch: () => {}, - delete: () => {}, -} as unknown as Router; -const pass: RequestHandler = (_req, _res, next) => next(); -registerHostBulkRoutes(router, pass, pass, pass, pass); +// The same wrapping the plugin's routes give the shared import code. +const wrap = + (run: typeof runBulkImport): RequestHandler => + async (req, res) => { + try { + const body = (req as unknown as { body: never }).body; + res.json( + importResponse( + await run((req as unknown as { userId: string }).userId, body), + ), + ); + } catch (error) { + if (error instanceof ImportError) { + res.status(400).json({ error: error.message }); + return; + } + throw error; + } + }; +const handler = wrap(runBulkImport); +const sshConfigHandler = wrap(runSshConfigImport as typeof runBulkImport); const host = { ip: "192.0.2.1", port: 22, @@ -76,12 +88,7 @@ it.each([false, true])( "carries plugin host settings through export selection and import (overwrite=%s)", async (overwrite) => { if (overwrite) mocks.list.mockResolvedValue([{ ...host, id: 19 }]); - const payload = buildExportPayload( - { hosts: [host] }, - null, - new Set(["featureFlags", "advanced"]), - false, - ); + const payload = { hosts: [host] }; expect(payload.hosts[0]).toMatchObject(host); const json = vi.fn(); await handler( @@ -130,12 +137,7 @@ it.each([20, "20"])( { ...host, ip: "192.0.2.2", exportId: 20 }, ], }; - const payload = buildExportPayload( - raw, - null, - new Set(["jumpHosts"]), - false, - ); + const payload = raw; const res = await importHosts(payload.hosts); expect(res.json).toHaveBeenCalledWith( expect.objectContaining({ success: 2, failed: 0 }), diff --git a/src/backend/tests/database/routes/host-locked-routes.test.ts b/src/backend/tests/database/routes/host-locked-routes.test.ts new file mode 100644 index 000000000..b1dcbf206 --- /dev/null +++ b/src/backend/tests/database/routes/host-locked-routes.test.ts @@ -0,0 +1,130 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import express, { type RequestHandler } from "express"; +import request from "supertest"; + +const state = vi.hoisted(() => ({ + updates: [] as Array<{ ids: number[]; update: Record }>, + renamed: 0, + folderHosts: [] as Array<{ id: number }>, +})); + +vi.mock("../../../utils/logger.js", () => ({ + databaseLogger: { error: vi.fn(), warn: vi.fn(), info: vi.fn() }, + sshLogger: { error: vi.fn(), warn: vi.fn(), info: vi.fn() }, + pluginLogger: { warn: vi.fn() }, +})); +vi.mock("../../../utils/database-save-trigger.js", () => ({ + DatabaseSaveTrigger: { batched: (fn: RequestHandler) => fn }, +})); +vi.mock("../../../hosts/defaults/overrides.js", () => ({ + changeHostOverrides: async () => {}, +})); +vi.mock("../../../hosts/defaults/recompute.js", () => ({ + recompute: async () => ({ changedHostIds: [] }), +})); +vi.mock("../../../hosts/defaults/service.js", () => ({ + folderCredentialIds: async () => [], + setFolderCredentialDefault: async () => {}, +})); +vi.mock("../../../database/routes/host-plugin-settings.js", () => ({ + setHostPluginEnabled: async () => true, +})); +vi.mock("../../../database/repositories/factory.js", () => ({ + createCurrentHostRepository: () => ({ + listBulkUpdateState: async (_userId: string, ids: number[]) => + ids.map((id) => ({ id })), + updateManyForUser: async ( + _userId: string, + ids: number[], + update: Record, + ) => { + state.updates.push({ ids, update }); + }, + }), + createCurrentHostFolderRepository: () => ({ + listHostsInFolder: async () => state.folderHosts, + renameFolder: async () => { + state.renamed++; + return { updatedHosts: 1, updatedCredentials: 0 }; + }, + }), + createCurrentFolderAccessRepository: () => ({ + renameFolder: async () => {}, + }), +})); + +const { registerHostBulkRoutes } = + await import("../../../database/routes/host-bulk-routes.js"); +const { registerHostFolderRoutes } = + await import("../../../database/routes/host-folder-routes.js"); +const { clearHostLocks, provideHostLocks } = + await import("../../../hosts/locks.js"); + +const pass: RequestHandler = (req, _res, next) => { + (req as unknown as { userId: string }).userId = "owner"; + next(); +}; + +function app() { + const router = express.Router(); + registerHostBulkRoutes(router, pass, pass, pass); + registerHostFolderRoutes(router, { + authenticateJWT: pass, + requireViewPermission: pass, + requireEditPermission: pass, + requireDeletePermission: pass, + requireCredentialEditPermission: pass, + requireDataAccess: pass, + }); + const server = express(); + server.use(express.json()); + server.use(router); + return server; +} + +beforeEach(() => { + state.updates = []; + state.renamed = 0; + state.folderHosts = []; + provideHostLocks("cfg", "Config", { + isLocked: (hostId) => hostId === 2, + note: () => null, + allowManualCreate: () => true, + }); +}); +afterEach(() => clearHostLocks()); + +describe("routes on locked hosts", () => { + it("still lets a user pin a locked host", async () => { + await request(app()) + .patch("/bulk-update") + .send({ hostIds: [1, 2], updates: { pin: true } }) + .expect(200); + expect(state.updates).toEqual([{ ids: [1, 2], update: { pin: true } }]); + }); + + it("refuses a bulk change that touches a locked host", async () => { + const res = await request(app()) + .patch("/bulk-update") + .send({ hostIds: [1, 2], updates: { folder: "Moved" } }) + .expect(403); + expect(res.body).toMatchObject({ code: "HOST_LOCKED" }); + expect(state.updates).toEqual([]); + }); + + it("refuses renaming a folder that holds a locked host", async () => { + state.folderHosts = [{ id: 1 }, { id: 2 }]; + await request(app()) + .put("/folders/rename") + .send({ oldName: "Prod", newName: "Live" }) + .expect(403); + expect(state.renamed).toBe(0); + + state.folderHosts = [{ id: 1 }]; + await request(app()) + .put("/folders/rename") + .send({ oldName: "Prod", newName: "Live" }) + .expect(200); + expect(state.renamed).toBe(1); + }); +}); diff --git a/src/backend/tests/database/routes/host-status-routes.test.ts b/src/backend/tests/database/routes/host-status-routes.test.ts index 4611b210e..7f68c9d95 100644 --- a/src/backend/tests/database/routes/host-status-routes.test.ts +++ b/src/backend/tests/database/routes/host-status-routes.test.ts @@ -15,7 +15,8 @@ const service = vi.hoisted(() => ({ get: vi.fn(), check: vi.fn(), refresh: vi.fn(), - retimeAll: vi.fn(), + getIntervalSeconds: vi.fn(), + setIntervalSeconds: vi.fn(), })); vi.mock("../../../utils/logger.js", () => ({ @@ -35,18 +36,8 @@ vi.mock("../../../utils/permission-manager.js", () => ({ }), }, })); -vi.mock("../../../database/repositories/factory.js", () => ({ - createCurrentSettingsRepository: () => ({ - get: async (key: string) => state.settings[key] ?? null, - set: async (key: string, value: string) => { - state.settings[key] = value; - }, - }), -})); -vi.mock("../../../hosts/status/host-status-service.js", () => ({ - DEFAULT_STATUS_INTERVAL: 60, - GLOBAL_STATUS_INTERVAL_KEY: "global_status_check_interval", - hostStatusService: service, +vi.mock("../../../plugins/host-status-provider.js", () => ({ + hostStatus: service, })); const { registerHostStatusRoutes, parseStatusHostIds } = @@ -83,6 +74,8 @@ async function call(method: string, path: string, body?: unknown) { beforeEach(() => { state.settings = {}; + service.getIntervalSeconds.mockResolvedValue(60); + service.setIntervalSeconds.mockResolvedValue(undefined); state.locked = false; state.accessible = new Set([1, 2]); state.statuses = new Map([ @@ -149,14 +142,13 @@ describe("status settings", () => { }); }); - it("saves a new interval and re-times the checks", async () => { + it("saves a new interval through the provider", async () => { await start(); const { status } = await call("PUT", "/host/status/settings", { statusCheckInterval: 90, }); expect(status).toBe(200); - expect(state.settings.global_status_check_interval).toBe("90"); - expect(service.retimeAll).toHaveBeenCalledOnce(); + expect(service.setIntervalSeconds).toHaveBeenCalledWith(90); }); it("rejects an interval out of range", async () => { diff --git a/src/backend/tests/database/routes/look.test.ts b/src/backend/tests/database/routes/look.test.ts new file mode 100644 index 000000000..bc7432564 --- /dev/null +++ b/src/backend/tests/database/routes/look.test.ts @@ -0,0 +1,228 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import http from "node:http"; +import type { AddressInfo } from "node:net"; +import express from "express"; + +const state = vi.hoisted(() => ({ + userId: "user-1", + admins: new Set(["admin-1"]), + prefs: null as { layoutId: string | null; themeId: string | null } | null, + settings: new Map(), + audits: [] as unknown[], + skipped: [] as unknown[], + defaultLookIds: [] as string[], + installed: [] as string[], +})); + +vi.mock("../../../utils/logger.js", () => ({ + databaseLogger: { warn: vi.fn(), error: vi.fn(), info: vi.fn() }, +})); +vi.mock("../../../utils/audit-logger.js", () => ({ + logAudit: async (entry: unknown) => { + state.audits.push(entry); + }, + getRequestMeta: () => ({ ipAddress: "127.0.0.1", userAgent: "test" }), +})); +vi.mock("../../../utils/auth-manager.js", () => ({ + AuthManager: { + getInstance: () => ({ + createAuthMiddleware: + () => (req: express.Request, _res: unknown, next: () => void) => { + (req as unknown as { userId: string }).userId = state.userId; + next(); + }, + }), + }, +})); +vi.mock("../../../database/repositories/factory.js", () => ({ + createCurrentUserPreferenceRepository: () => ({ + findByUserId: async () => state.prefs, + }), + createCurrentUserRepository: () => ({ + findById: async (id: string) => ({ + id, + username: id, + isAdmin: state.admins.has(id), + }), + }), + createCurrentSettingsRepository: () => ({ + get: async (key: string) => state.settings.get(key) ?? null, + set: async (key: string, value: string) => { + state.settings.set(key, value); + }, + delete: async (key: string) => { + state.settings.delete(key); + }, + }), +})); +vi.mock("../../../plugins/index.js", () => ({ + getPluginRuntime: () => ({ + loader: { + get: (id: string) => + id === "shipped-look" + ? { + manifest: { + contributes: { + layouts: [ + { id: "shipped", label: "Shipped", defaultTheme: "ink" }, + ], + }, + }, + } + : undefined, + list: () => [ + { + manifest: { + contributes: { + layouts: state.installed.map((id) => ({ id, label: id })), + }, + }, + }, + ], + skipped: () => state.skipped, + }, + }), +})); +vi.mock("../../../plugins/bundled-index.js", () => ({ + defaultLookPluginIds: () => state.defaultLookIds, +})); + +const { default: lookRoutes } = + await import("../../../database/routes/look.js"); + +let server: http.Server; +let baseUrl: string; + +beforeEach(async () => { + state.userId = "user-1"; + state.prefs = null; + state.settings.clear(); + state.audits = []; + state.skipped = []; + state.defaultLookIds = []; + state.installed = []; + delete process.env.TERMIX_SAFE_LAYOUT; + + const app = express(); + app.use(express.json()); + app.use("/user-preferences/look", lookRoutes); + server = http.createServer(app); + await new Promise((resolve) => server.listen(0, resolve)); + baseUrl = `http://127.0.0.1:${(server.address() as AddressInfo).port}`; +}); + +afterEach(async () => { + delete process.env.TERMIX_SAFE_LAYOUT; + await new Promise((resolve) => server.close(() => resolve())); +}); + +async function getLook() { + const res = await fetch(`${baseUrl}/user-preferences/look`); + return { status: res.status, body: await res.json() }; +} + +async function putDefaults(body: unknown) { + const res = await fetch(`${baseUrl}/user-preferences/look/defaults`, { + method: "PUT", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }); + return { status: res.status, body: await res.json() }; +} + +describe("GET /user-preferences/look", () => { + it("is all empty on a fresh install", async () => { + expect((await getLook()).body).toEqual({ + layoutId: null, + themeId: null, + defaults: { layout: null, theme: null }, + defaultLook: { layout: null, theme: null }, + safeLayout: false, + unavailable: [], + }); + }); + + it("returns the user's pick, the admin defaults and the default look", async () => { + state.prefs = { layoutId: "mine", themeId: "nord" }; + state.installed = ["mine", "admin-layout"]; + state.settings.set("ui.layout", "admin-layout"); + state.settings.set("ui.theme", "gruvbox"); + state.defaultLookIds = ["shipped-look"]; + state.skipped = [ + { + id: "broken", + version: "1.0.0", + layouts: ["b"], + themes: [], + error: "x", + }, + { id: "other", version: null, layouts: [], themes: [], error: "y" }, + ]; + process.env.TERMIX_SAFE_LAYOUT = "1"; + + const { body } = await getLook(); + expect(body).toMatchObject({ + layoutId: "mine", + themeId: "nord", + defaults: { layout: "admin-layout", theme: "gruvbox" }, + defaultLook: { layout: "shipped", theme: "ink" }, + safeLayout: true, + }); + expect(body.unavailable.map((plugin: { id: string }) => plugin.id)).toEqual( + ["broken"], + ); + }); +}); + +describe("GET /user-preferences/look with an uninstalled layout", () => { + it("drops a choice no installed plugin brings, so the next one applies", async () => { + state.prefs = { layoutId: "deleted", themeId: null }; + state.settings.set("ui.layout", "also-deleted"); + state.installed = ["kept"]; + const { body } = await getLook(); + expect(body.layoutId).toBeNull(); + expect(body.defaults.layout).toBeNull(); + }); + + it("keeps a choice whose plugin failed to load, for the repair view", async () => { + state.prefs = { layoutId: "b", themeId: null }; + state.skipped = [ + { + id: "broken", + version: "1.0.0", + layouts: ["b"], + themes: [], + error: "x", + }, + ]; + expect((await getLook()).body.layoutId).toBe("b"); + }); +}); + +describe("PUT /user-preferences/look/defaults", () => { + it("is for admins only", async () => { + const { status } = await putDefaults({ layout: "x" }); + expect(status).toBe(403); + expect(state.settings.size).toBe(0); + }); + + it("sets and clears the defaults, and audits it", async () => { + state.userId = "admin-1"; + let res = await putDefaults({ layout: "fancy", theme: "nord" }); + expect(res).toEqual({ + status: 200, + body: { layout: "fancy", theme: "nord" }, + }); + res = await putDefaults({ layout: null }); + expect(res.body).toEqual({ layout: null, theme: "nord" }); + expect(state.audits).toHaveLength(2); + expect(state.audits[0]).toMatchObject({ action: "update_default_look" }); + }); + + it("refuses something that is not an id", async () => { + state.userId = "admin-1"; + expect((await putDefaults({ theme: "../evil" })).status).toBe(400); + expect((await putDefaults({ layout: 5 })).status).toBe(400); + expect(state.settings.size).toBe(0); + }); +}); diff --git a/src/backend/tests/database/routes/plugins.test.ts b/src/backend/tests/database/routes/plugins.test.ts index a554d4271..74de8949a 100644 --- a/src/backend/tests/database/routes/plugins.test.ts +++ b/src/backend/tests/database/routes/plugins.test.ts @@ -169,6 +169,7 @@ const manageMock = vi.hoisted(() => ({ updatePlugin: vi.fn(), updateAllPlugins: vi.fn(), uninstallPlugin: vi.fn(), + restoreBundledPlugin: vi.fn(), setPluginOptions: vi.fn(), setAllPluginChannels: vi.fn(), getPluginDataSummary: vi.fn(), @@ -433,6 +434,7 @@ describe("plugins route", () => { "name", "optionalDependencies", "state", + "themeVersion", "version", ].sort(), ); @@ -624,10 +626,12 @@ describe("plugins route", () => { contributes: null, dependencies: {}, optionalDependencies: {}, + defaultLook: false, frontend: false, css: false, assetVersion: null, locales: [], + themeVersion: null, }); }); @@ -1138,6 +1142,35 @@ describe("plugins route", () => { expect(manageMock.uninstallPlugin).toHaveBeenCalledWith("docker"); }); + it("restores the bundled copy", async () => { + manageMock.restoreBundledPlugin.mockResolvedValue({ + id: "docker", + version: "1.0.0", + state: "active", + }); + const res = await post("/plugins/docker/restore-bundled"); + expect(res.status).toBe(200); + expect(await res.json()).toEqual({ + id: "docker", + version: "1.0.0", + state: "active", + }); + expect(manageMock.restoreBundledPlugin).toHaveBeenCalledWith("docker"); + }); + + it("answers 409 when a plugin has no bundled copy", async () => { + manageMock.restoreBundledPlugin.mockRejectedValue( + new PluginManageError( + "This plugin has no bundled copy", + 409, + "NO_BUNDLED_COPY", + ), + ); + const res = await post("/plugins/docker/restore-bundled"); + expect(res.status).toBe(409); + expect((await res.json()).code).toBe("NO_BUNDLED_COPY"); + }); + it("refuses changes on a linked desktop", async () => { manageMock.isLinkedDesktop.mockResolvedValue(true); const res = await post("/plugins/docker/install"); diff --git a/src/backend/tests/database/routes/rbac-route-coverage.test.ts b/src/backend/tests/database/routes/rbac-route-coverage.test.ts index c12df3696..b00825c38 100644 --- a/src/backend/tests/database/routes/rbac-route-coverage.test.ts +++ b/src/backend/tests/database/routes/rbac-route-coverage.test.ts @@ -24,14 +24,12 @@ describe("RBAC coverage for split host routers", () => { it("gates bulk mutations by action and data-access permissions", () => { const router = express.Router(); const authenticate = middleware(); - const requireCreate = middleware(); const requireEdit = middleware(); const requireDataAccess = middleware(); registerHostBulkRoutes( router, authenticate, - requireCreate, requireEdit, requireDataAccess, ); @@ -46,14 +44,6 @@ describe("RBAC coverage for split host routers", () => { requireEdit, requireDataAccess, ]); - for (const path of ["/bulk-import", "/ssh-config-import"]) { - expect(handlers(router, "post", path).slice(0, 4)).toEqual([ - authenticate, - requireCreate, - requireEdit, - requireDataAccess, - ]); - } }); it("gates folder reads and mutations with their matching permissions", () => { diff --git a/src/backend/tests/database/routes/user-password-reset-routes.test.ts b/src/backend/tests/database/routes/user-password-reset-routes.test.ts index dfecc6a19..516415d5f 100644 --- a/src/backend/tests/database/routes/user-password-reset-routes.test.ts +++ b/src/backend/tests/database/routes/user-password-reset-routes.test.ts @@ -28,11 +28,18 @@ vi.mock("../../../database/repositories/factory.js", () => ({ }), createCurrentSettingsRepository: () => ({}), createCurrentSshCredentialUsageRepository: deletingRepo("usage"), - createCurrentRecentActivityRepository: deletingRepo("activity"), createCurrentHostRepository: deletingRepo("hosts"), createCurrentCredentialRepository: deletingRepo("credentials"), })); +vi.mock("../../../plugins/activity-provider.js", () => ({ + recentActivity: { + deleteByUserId: async (userId: string) => { + calls.deletedFor.push(`activity:${userId}`); + }, + }, +})); + vi.mock("../../../plugins/events.js", () => ({ pluginEvents: { emit: (_topic: string, payload: { userId: string }) => { diff --git a/src/backend/tests/database/routes/user-preferences.test.ts b/src/backend/tests/database/routes/user-preferences.test.ts new file mode 100644 index 000000000..cd0a12692 --- /dev/null +++ b/src/backend/tests/database/routes/user-preferences.test.ts @@ -0,0 +1,96 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import http from "node:http"; +import type { AddressInfo } from "node:net"; +import express from "express"; + +const state = vi.hoisted(() => ({ + row: null as Record | null, + upserts: [] as Record[], +})); + +vi.mock("../../../utils/logger.js", () => ({ + databaseLogger: { warn: vi.fn(), error: vi.fn(), info: vi.fn() }, +})); +vi.mock("../../../utils/auth-manager.js", () => ({ + AuthManager: { + getInstance: () => ({ + createAuthMiddleware: + () => (req: express.Request, _res: unknown, next: () => void) => { + (req as unknown as { userId: string }).userId = "user-1"; + next(); + }, + }), + }, +})); +vi.mock("../../../database/repositories/factory.js", () => ({ + createCurrentUserPreferenceRepository: () => ({ + findByUserId: async () => state.row, + upsert: async (_userId: string, updates: Record) => { + state.upserts.push(updates); + state.row = { ...(state.row ?? {}), ...updates }; + }, + }), +})); +vi.mock("../../../database/routes/keybinding-validation.js", () => ({ + isValidKeybinding: () => true, +})); + +const { default: routes } = + await import("../../../database/routes/user-preferences.js"); + +let server: http.Server; +let baseUrl: string; + +beforeEach(async () => { + state.row = null; + state.upserts = []; + const app = express(); + app.use(express.json()); + app.use("/user-preferences", routes); + server = http.createServer(app); + await new Promise((resolve) => server.listen(0, resolve)); + baseUrl = `http://127.0.0.1:${(server.address() as AddressInfo).port}`; +}); + +afterEach(async () => { + await new Promise((resolve) => server.close(() => resolve())); +}); + +async function put(body: unknown) { + const res = await fetch(`${baseUrl}/user-preferences`, { + method: "PUT", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }); + return { status: res.status, body: await res.json() }; +} + +describe("user preferences: layout and theme", () => { + it("reads null for both until chosen", async () => { + const body = await (await fetch(`${baseUrl}/user-preferences`)).json(); + expect(body).toMatchObject({ layoutId: null, themeId: null }); + }); + + it("saves a layout and theme id, and null to follow the default", async () => { + expect((await put({ layoutId: "fancy", themeId: "nord" })).status).toBe( + 200, + ); + expect(state.upserts[0]).toMatchObject({ + layoutId: "fancy", + themeId: "nord", + }); + const body = await (await fetch(`${baseUrl}/user-preferences`)).json(); + expect(body).toMatchObject({ layoutId: "fancy", themeId: "nord" }); + + await put({ layoutId: null }); + expect(state.upserts[1]).toMatchObject({ layoutId: null }); + expect(state.upserts[1]).not.toHaveProperty("themeId"); + }); + + it("refuses anything that is not an id", async () => { + for (const bad of ["Fancy", "../x", "a", 7]) { + expect((await put({ themeId: bad })).status).toBe(400); + } + expect(state.upserts).toHaveLength(0); + }); +}); diff --git a/src/backend/tests/database/routes/user-settings-routes.test.ts b/src/backend/tests/database/routes/user-settings-routes.test.ts index d10da752d..e0954608a 100644 --- a/src/backend/tests/database/routes/user-settings-routes.test.ts +++ b/src/backend/tests/database/routes/user-settings-routes.test.ts @@ -26,6 +26,8 @@ vi.mock("../../../utils/audit-logger.js", () => ({ vi.mock("../../../database/repositories/factory.js", () => ({ createCurrentSettingsRepository: () => ({ get: async (key: string) => state.settings[key] ?? null, + getBoolean: async (key: string, fallback: boolean) => + key in state.settings ? state.settings[key] === "true" : fallback, set: async (key: string, value: string) => { state.settings[key] = value; }, @@ -39,6 +41,12 @@ vi.mock("../../../database/repositories/factory.js", () => ({ }), })); +vi.mock("../../../tls/tls-service.js", () => ({ + getTlsConfig: () => ({ enabled: true, port: 8443 }), +})); + +const { resetHttpsPolicyForTests } = + await import("../../../tls/https-policy.js"); const { registerUserSettingsRoutes } = await import("../../../database/routes/user-settings-routes.js"); @@ -59,6 +67,7 @@ function app() { beforeEach(() => { state.settings = {}; state.admin = true; + resetHttpsPolicyForTests(); }); describe("/users/host-defaults", () => { @@ -77,3 +86,35 @@ describe("/users/host-defaults", () => { expect(JSON.parse(state.settings.host_defaults).useSocks5).toBe(true); }); }); + +describe("/users/https-settings", () => { + it("is off by default and saves a change", async () => { + const read = await request(app()).get("/users/https-settings"); + expect(read.body).toEqual({ forceHttps: false, tlsEnabled: true }); + + const write = await request(app()) + .patch("/users/https-settings") + .send({ forceHttps: true }); + expect(write.status).toBe(200); + expect(state.settings.force_https).toBe("true"); + const after = await request(app()).get("/users/https-settings"); + expect(after.body.forceHttps).toBe(true); + }); + + it("is admin only and checks the value", async () => { + const bad = await request(app()) + .patch("/users/https-settings") + .send({ forceHttps: "yes" }); + expect(bad.status).toBe(400); + + state.admin = false; + expect((await request(app()).get("/users/https-settings")).status).toBe( + 403, + ); + const write = await request(app()) + .patch("/users/https-settings") + .send({ forceHttps: true }); + expect(write.status).toBe(403); + expect(state.settings.force_https).toBeUndefined(); + }); +}); diff --git a/src/backend/tests/database/routes/user-socket-ticket-routes.test.ts b/src/backend/tests/database/routes/user-socket-ticket-routes.test.ts index 0b7312053..d6b9c6d69 100644 --- a/src/backend/tests/database/routes/user-socket-ticket-routes.test.ts +++ b/src/backend/tests/database/routes/user-socket-ticket-routes.test.ts @@ -25,6 +25,10 @@ const issueSocketTicket = vi.fn( async (userId: string, sessionId: string) => `ticket:${userId}:${sessionId}`, ); +const issueApiKeySocketTicket = vi.fn( + async (userId: string, keyId: string) => `key-ticket:${userId}:${keyId}`, +); + const authenticate: RequestHandler = (req, _res, next) => { Object.assign(req, { userId: "u1", @@ -38,7 +42,10 @@ function app() { const router = express.Router(); registerUserSocketTicketRoutes(router, { authenticateJWT: authenticate, - authManager: { issueSocketTicket } as unknown as AuthManager, + authManager: { + issueSocketTicket, + issueApiKeySocketTicket, + } as unknown as AuthManager, }); const server = express(); server.use(cookieParser()); @@ -50,6 +57,7 @@ beforeEach(() => { state.sessionId = "s1"; state.apiKeyId = undefined; issueSocketTicket.mockClear(); + issueApiKeySocketTicket.mockClear(); }); describe("POST /users/ws-ticket", () => { @@ -98,3 +106,23 @@ describe("POST /users/ws-ticket", () => { expect(issueSocketTicket).not.toHaveBeenCalled(); }); }); + +describe("POST /users/ws-ticket/api-key", () => { + it("gives an API key a ticket as its user", async () => { + state.sessionId = undefined; + state.apiKeyId = "key-1"; + + const res = await request(app()).post("/users/ws-ticket/api-key"); + + expect(res.status).toBe(200); + expect(res.body.ticket).toBe("key-ticket:u1:key-1"); + expect(res.headers["cache-control"]).toBe("no-store"); + }); + + it("refuses a login session, which has its own ticket", async () => { + const res = await request(app()).post("/users/ws-ticket/api-key"); + + expect(res.status).toBe(401); + expect(issueApiKeySocketTicket).not.toHaveBeenCalled(); + }); +}); diff --git a/src/backend/tests/electron/app-quit.test.ts b/src/backend/tests/electron/app-quit.test.ts index 0705bb36b..82c22d5d3 100644 --- a/src/backend/tests/electron/app-quit.test.ts +++ b/src/backend/tests/electron/app-quit.test.ts @@ -8,7 +8,7 @@ const main = fs.readFileSync( "utf8", ); const shell = fs.readFileSync( - new URL("../../../ui/AppShell.tsx", import.meta.url), + new URL("../../../ui/shell/controller/use-tabs.ts", import.meta.url), "utf8", ); diff --git a/src/backend/tests/electron/c2s-auth.test.ts b/src/backend/tests/electron/c2s-auth.test.ts deleted file mode 100644 index 429490b5d..000000000 --- a/src/backend/tests/electron/c2s-auth.test.ts +++ /dev/null @@ -1,86 +0,0 @@ -import { createRequire } from "node:module"; -import { EventEmitter } from "node:events"; -import { afterEach, describe, expect, it, vi } from "vitest"; -const require = createRequire(import.meta.url); -const { createC2SAuthBridge } = require("../../../../electron/c2s-auth.cjs"); - -function setup() { - const sender = Object.assign(new EventEmitter(), { - send: vi.fn(), - isDestroyed: () => false, - }); - const ws = Object.assign(new EventEmitter(), { - readyState: 1, - send: vi.fn(), - close: vi.fn(() => { - ws.readyState = 3; - ws.emit("close"); - }), - }); - let answer!: ( - event: { sender: unknown }, - id: string, - value: unknown, - ) => boolean; - const attach = createC2SAuthBridge( - { - handle: (_name: string, fn: typeof answer) => { - answer = fn; - }, - }, - () => ({ isDestroyed: () => false, webContents: sender }), - ); - attach(ws, "dynamic"); - const prompt = () => - ws.emit( - "message", - Buffer.from( - JSON.stringify({ - type: "auth-prompt", - requestId: "server-challenge", - request: { kind: "totp", prompt: "Code:", retry: false }, - }), - ), - false, - ); - return { sender, ws, answer, prompt }; -} -afterEach(() => vi.useRealTimers()); -describe("desktop tunnel prompt bridge", () => { - it("binds answers to the renderer and challenge, and rejects replay", () => { - const { sender, ws, answer, prompt } = setup(); - prompt(); - const id = sender.send.mock.calls[0][1].id; - expect(answer({ sender: {} }, id, "123456")).toBe(false); - expect(answer({ sender }, "unknown", "123456")).toBe(false); - expect(answer({ sender }, id, { secret: "bad" })).toBe(false); - expect(ws.send).not.toHaveBeenCalled(); - expect(answer({ sender }, id, "123456")).toBe(true); - expect(JSON.parse(ws.send.mock.calls[0][0])).toEqual({ - type: "auth-response", - requestId: "server-challenge", - answer: "123456", - }); - expect(answer({ sender }, id, "again")).toBe(false); - expect(sender.listenerCount("destroyed")).toBe(0); - ws.close(); - }); - it.each(["close", "reload", "timeout"])( - "clears a pending dialog on %s", - (reason) => { - vi.useFakeTimers(); - const { sender, ws, answer, prompt } = setup(); - prompt(); - const id = sender.send.mock.calls[0][1].id; - if (reason === "close") ws.close(); - else if (reason === "reload") sender.emit("did-start-loading"); - else vi.advanceTimersByTime(60_000); - expect(sender.send).toHaveBeenLastCalledWith("c2s-auth-prompt", { - id, - closed: true, - }); - expect(answer({ sender }, id, "late")).toBe(false); - expect(ws.close).toHaveBeenCalled(); - }, - ); -}); diff --git a/src/backend/tests/electron/c2s-runtime.test.ts b/src/backend/tests/electron/c2s-runtime.test.ts deleted file mode 100644 index a65c224a7..000000000 --- a/src/backend/tests/electron/c2s-runtime.test.ts +++ /dev/null @@ -1,238 +0,0 @@ -import { EventEmitter } from "node:events"; -import { readFileSync } from "node:fs"; -import vm from "node:vm"; -import { describe, expect, it, vi } from "vitest"; - -const main = readFileSync( - new URL("../../../../electron/main.cjs", import.meta.url), - "utf8", -); -const section = (from: string, to: string) => - main.slice(main.indexOf(from), main.indexOf(to, main.indexOf(from))); -function setup() { - const sockets: Socket[] = []; - class Socket extends EventEmitter { - readyState = 1; - send = vi.fn(); - close = vi.fn(() => { - if (this.readyState === 3) return; - this.readyState = 3; - this.emit("close"); - }); - constructor() { - super(); - sockets.push(this); - queueMicrotask(() => this.emit("open")); - } - ready() { - this.emit( - "message", - Buffer.from(JSON.stringify({ type: "ready" })), - false, - ); - } - } - const runtimes = new Map(); - let incoming!: (socket: EventEmitter) => void; - const server = Object.assign(new EventEmitter(), { - listen: (_options: unknown, ready: () => void) => ready(), - close: vi.fn((done?: () => void) => done?.()), - }); - const headers = vi.fn(async () => ({})); - const dynamic = vi.fn(); - const context = vm.createContext({ - setTimeout, - clearTimeout, - WebSocket: Socket, - crypto: { randomUUID: () => "runtime-token" }, - net: { - createServer: (callback: typeof incoming) => { - incoming = callback; - return server; - }, - }, - c2sTunnelRuntimes: runtimes, - normalizeC2STunnelAddresses: (t: unknown) => t, - resolveC2SRemoteSourceHost: async (t: unknown) => t, - getC2STunnelName: (t: { name: string }) => t.name, - getC2SLocalAddress: () => "127.0.0.1", - getC2SRemoteAddress: () => "127.0.0.1", - getC2SRelayUrl: () => "wss://server/relay", - getC2SRelayHeaders: headers, - getWebSocketOptions: (_url: string, options: unknown) => options, - attachC2SAuth: vi.fn(), - keepC2SSocketAlive: vi.fn(), - logToFile: vi.fn(), - emitC2STunnelStatuses: vi.fn(), - checkLocalPortAvailable: async () => ({ available: true }), - handleC2SDynamicConnection: dynamic, - setC2STunnelStatus: (name: string, status: unknown) => { - runtimes.get(name).status = status; - }, - setC2STunnelError: (name: string, error: string) => { - runtimes.get(name).status = { connected: false, reason: error }; - }, - createC2SFailure: (error: string) => ({ success: false, error }), - normalizeC2SErrorMessage: (message: string) => message, - C2S_REMOTE_SESSION_EXPIRED_ERROR: "expired", - }); - vm.runInContext( - section("async function openC2SRelay(", "async function testC2SRelay(") + - section("async function testC2SRelay(", "async function testC2STunnel(") + - section("async function startC2STunnel(", "function stopAllC2STunnels("), - context, - ); - return { - context, - runtimes, - sockets, - server, - headers, - dynamic, - incoming: () => incoming, - }; -} - -describe("client tunnel runtime authentication", () => { - it("holds preflight open, shares its runtime identity and token, and releases it when stopped", async () => { - const { context, runtimes, sockets, server, headers, dynamic, incoming } = - setup(); - const tunnel = { - name: "socks", - mode: "dynamic", - sourceHostId: 7, - sourcePort: 1080, - }; - await context.startC2STunnel(tunnel, 0, "local-auth"); - await vi.waitFor(() => expect(sockets).toHaveLength(1)); - const premature = Object.assign(new EventEmitter(), { destroy: vi.fn() }); - incoming()(premature); - expect(premature.destroy).toHaveBeenCalledOnce(); - expect(headers).toHaveBeenCalledWith("wss://server/relay", "local-auth"); - expect(JSON.parse(sockets[0].send.mock.calls[0][0])).toMatchObject({ - keepAlive: true, - tunnelConfig: { sessionId: "runtime-token" }, - }); - sockets[0].ready(); - await vi.waitFor(() => - expect(runtimes.get("socks").status.connected).toBe(true), - ); - expect(sockets[0].close).not.toHaveBeenCalled(); - const client = Object.assign(new EventEmitter(), { destroy: vi.fn() }); - incoming()(client); - expect(dynamic).toHaveBeenCalledWith( - expect.objectContaining({ sessionId: "runtime-token" }), - client, - "local-auth", - ); - await context.stopC2STunnel("socks"); - expect(sockets[0].close).toHaveBeenCalledOnce(); - expect(client.destroy).toHaveBeenCalledOnce(); - expect(server.close).toHaveBeenCalledOnce(); - expect(runtimes.size).toBe(0); - }); - - it("does not retain a socket for a standalone test or a stopped runtime", async () => { - const { context, sockets } = setup(); - const test = context.testC2SRelay({ name: "test" }); - await vi.waitFor(() => expect(sockets).toHaveLength(1)); - sockets[0].ready(); - expect(await test).toEqual({ success: true }); - expect(sockets[0].close).toHaveBeenCalledOnce(); - expect( - await context.testC2SRelay({ name: "stopped" }, null, null, null, {}), - ).toEqual({ success: false, error: "Tunnel stopped" }); - 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" } }); - const local = () => - Object.assign(new EventEmitter(), { - destroy: vi.fn(), - write: vi.fn(), - }); - - const first = local(); - await context.openC2SRelay({ name: "web" }, "127.0.0.1", 8006, first); - await vi.waitFor(() => expect(sockets).toHaveLength(1)); - first.emit("error", new Error("ECONNRESET")); - sockets[0].emit( - "message", - Buffer.from(JSON.stringify({ type: "error", error: "refused" })), - false, - ); - expect(runtimes.get("web").status.connected).toBe(true); - - await context.openC2SRelay({ name: "web" }, "127.0.0.1", 8006, local()); - await vi.waitFor(() => expect(sockets).toHaveLength(2)); - sockets[1].emit( - "message", - Buffer.from(JSON.stringify({ type: "error", error: "expired" })), - false, - ); - expect(runtimes.get("web").status).toMatchObject({ - connected: false, - reason: "expired", - }); - }); -}); - -describe("client tunnel socket keepalive", () => { - it("pings an open socket until it closes", () => { - vi.useFakeTimers(); - try { - const context = vm.createContext({ - setInterval, - clearInterval, - WebSocket: { OPEN: 1 }, - }); - vm.runInContext( - section("function keepC2SSocketAlive(", "// C2S (client-to-server)"), - context, - ); - const ws = Object.assign(new EventEmitter(), { - readyState: 1, - ping: vi.fn(), - }); - context.keepC2SSocketAlive(ws); - vi.advanceTimersByTime(50_000); - expect(ws.ping).toHaveBeenCalledTimes(2); - ws.emit("close"); - vi.advanceTimersByTime(50_000); - expect(ws.ping).toHaveBeenCalledTimes(2); - } finally { - vi.useRealTimers(); - } - }); -}); diff --git a/src/backend/tests/electron/embedded-view.test.ts b/src/backend/tests/electron/embedded-view.test.ts new file mode 100644 index 000000000..69bf03210 --- /dev/null +++ b/src/backend/tests/electron/embedded-view.test.ts @@ -0,0 +1,164 @@ +import { createRequire } from "node:module"; +import { EventEmitter } from "node:events"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const { createEmbeddedViews, cleanBounds } = createRequire(import.meta.url)( + "../../../../electron/embedded-view.cjs", +); + +const views: FakeView[] = []; + +class FakeView { + webContents = Object.assign(new EventEmitter(), { + setWindowOpenHandler: vi.fn(), + loadURL: vi.fn().mockResolvedValue(undefined), + isDestroyed: () => false, + close: vi.fn(), + }); + setBounds = vi.fn(); + setVisible = vi.fn(); + constructor(public options: { webPreferences: Record }) { + views.push(this); + } +} + +const bounds = { x: 10.4, y: 20.6, width: 300, height: 200 }; +let contentView: { + addChildView: ReturnType; + removeChildView: ReturnType; +}; +let session: EventEmitter & Record>; +let manager: ReturnType; + +beforeEach(() => { + views.length = 0; + contentView = { addChildView: vi.fn(), removeChildView: vi.fn() }; + session = Object.assign(new EventEmitter(), { + setPermissionRequestHandler: vi.fn(), + setPermissionCheckHandler: vi.fn(), + clearStorageData: vi.fn().mockResolvedValue(undefined), + clearCache: vi.fn().mockResolvedValue(undefined), + closeAllConnections: vi.fn().mockResolvedValue(undefined), + webRequest: { onBeforeRequest: vi.fn() }, + }); + manager = createEmbeddedViews({ + WebContentsView: FakeView, + session: { fromPartition: vi.fn(() => session) }, + getMainWindow: () => ({ contentView, isDestroyed: () => false }), + }); +}); + +describe("embedded views", () => { + it("opens a locked down view at the given bounds", () => { + manager.open({ id: "a", url: "https://pve.example:8006/", bounds }); + const [view] = views; + expect(view.options.webPreferences).toMatchObject({ + sandbox: true, + contextIsolation: true, + nodeIntegration: false, + webviewTag: false, + }); + expect(contentView.addChildView).toHaveBeenCalledWith(view); + expect(view.setBounds).toHaveBeenCalledWith({ + x: 10, + y: 21, + width: 300, + height: 200, + }); + expect(view.webContents.loadURL).toHaveBeenCalledWith( + "https://pve.example:8006/", + ); + const handler = session.setPermissionRequestHandler.mock.calls[0][0]; + const allow = vi.fn(); + handler(null, "media", allow); + expect(allow).toHaveBeenCalledWith(false); + }); + + it("refuses urls that are not plain http or https", () => { + expect(() => + manager.open({ id: "a", url: "file:///etc/passwd", bounds }), + ).toThrow(); + expect(() => + manager.open({ id: "a", url: "https://u:p@host/", bounds }), + ).toThrow(); + }); + + it("moves and hides an open view, and closes it", () => { + manager.open({ id: "a", url: "https://x.example/", bounds }); + manager.setBounds("a", { x: 1, y: 2, width: 3, height: 4 }); + manager.setVisible("a", false); + const [view] = views; + expect(view.setBounds).toHaveBeenLastCalledWith({ + x: 1, + y: 2, + width: 3, + height: 4, + }); + expect(view.setVisible).toHaveBeenLastCalledWith(false); + manager.destroy("a"); + expect(contentView.removeChildView).toHaveBeenCalledWith(view); + expect(view.webContents.close).toHaveBeenCalled(); + expect(session.clearStorageData).toHaveBeenCalled(); + }); + + it("reuses a view for the same url and replaces it for a new one", () => { + manager.open({ id: "a", url: "https://x.example/", bounds }); + manager.open({ id: "a", url: "https://x.example/", bounds }); + expect(views).toHaveLength(1); + manager.open({ id: "a", url: "https://y.example/", bounds }); + expect(views).toHaveLength(2); + expect(contentView.removeChildView).toHaveBeenCalledWith(views[0]); + }); + + it("accepts an invalid certificate only for its own origin and only when asked", () => { + manager.open({ + id: "a", + url: "https://x.example/", + ignoreCert: true, + bounds, + }); + manager.open({ id: "b", url: "https://z.example/", bounds }); + const check = (index: number, url: string) => { + const event = { preventDefault: vi.fn() }; + const callback = vi.fn(); + const handled = manager.handleCertificateError( + event, + views[index].webContents, + url, + callback, + ); + return { handled, allowed: callback.mock.calls[0]?.[0] }; + }; + expect(check(0, "https://x.example/page")).toEqual({ + handled: true, + allowed: true, + }); + expect(check(0, "https://other.example/")).toEqual({ + handled: true, + allowed: false, + }); + expect(check(1, "https://z.example/")).toEqual({ + handled: true, + allowed: false, + }); + expect( + manager.handleCertificateError({}, {}, "https://x.example/", vi.fn()), + ).toBe(false); + }); + + it("closes everything", () => { + manager.open({ id: "a", url: "https://x.example/", bounds }); + manager.open({ id: "b", url: "https://y.example/", bounds }); + manager.destroyAll(); + expect(contentView.removeChildView).toHaveBeenCalledTimes(2); + }); + + it("keeps bounds as whole, non negative numbers", () => { + expect(cleanBounds({ x: -5, y: "x", width: 9.5, height: 1 })).toEqual({ + x: 0, + y: 0, + width: 10, + height: 1, + }); + }); +}); diff --git a/src/backend/tests/electron/linked-certificate.test.ts b/src/backend/tests/electron/linked-certificate.test.ts index 87bab23f6..8fa90aafb 100644 --- a/src/backend/tests/electron/linked-certificate.test.ts +++ b/src/backend/tests/electron/linked-certificate.test.ts @@ -6,6 +6,7 @@ import { describe, expect, it } from "vitest"; const require = createRequire(import.meta.url); const linkedServer = require("../../../../electron/linked-server.cjs") as { setLinkedServer: (config: unknown) => void; + clientCertOptionsFor: (url: string) => Record; }; const source = fs.readFileSync( @@ -14,7 +15,7 @@ const source = fs.readFileSync( ); const tls = source.slice( source.indexOf("const INSECURE_MODE_VALUES"), - source.indexOf("function httpFetch("), + source.indexOf('if (process.platform === "linux") {'), ); const ORIGIN = "https://termix.example"; @@ -30,10 +31,7 @@ function load(link: Record | null) { vm.runInContext(tls, context); return context as unknown as { isInvalidCertificateAllowedForUrl: (url: string) => boolean; - getWebSocketOptions: ( - url: string, - options?: Record, - ) => Record; + getTlsVerificationOptions: (url: string) => Record; }; } @@ -41,8 +39,7 @@ 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: {}, + expect(main.getTlsVerificationOptions(TERMINAL)).toEqual({ rejectUnauthorized: false, }); }); @@ -58,8 +55,31 @@ describe("linked server certificate exception", () => { it("keeps verifying when the link does not allow it", () => { const main = load({ origin: ORIGIN, allowInvalidCertificate: false }); - expect(main.getWebSocketOptions(TERMINAL)).toEqual({ + expect(main.getTlsVerificationOptions(TERMINAL)).toEqual({ rejectUnauthorized: true, }); }); }); + +describe("linked server client certificate", () => { + const clientCert = { + pfx: Buffer.from("pfx-bytes").toString("base64"), + passphrase: "pw", + }; + + it("hands the PFX to TLS made for the linked origin only", () => { + linkedServer.setLinkedServer({ origin: ORIGIN, clientCert }); + expect(linkedServer.clientCertOptionsFor(TERMINAL)).toEqual({ + pfx: Buffer.from("pfx-bytes"), + passphrase: "pw", + }); + expect( + linkedServer.clientCertOptionsFor("https://elsewhere.example/"), + ).toEqual({}); + }); + + it("offers nothing when the link has no certificate", () => { + linkedServer.setLinkedServer({ origin: ORIGIN }); + expect(linkedServer.clientCertOptionsFor(TERMINAL)).toEqual({}); + }); +}); diff --git a/src/backend/tests/electron/local-collection-limit.test.ts b/src/backend/tests/electron/local-collection-limit.test.ts deleted file mode 100644 index 6a310d3f0..000000000 --- a/src/backend/tests/electron/local-collection-limit.test.ts +++ /dev/null @@ -1,54 +0,0 @@ -import fs from "node:fs"; -import path from "node:path"; -import vm from "node:vm"; -import { describe, expect, it } from "vitest"; - -const source = fs.readFileSync( - new URL("../../../../electron/main.cjs", import.meta.url), - "utf8", -); -const collector = source.slice( - source.indexOf("function collectLocalFilesFromPath("), - source.indexOf('ipcMain.handle("get-local-home-directory"'), -); -const handler = source.slice( - source.indexOf('ipcMain.handle("collect-local-files"'), - source.indexOf('ipcMain.handle("create-local-folder"'), -); - -describe("local collection limit", () => { - it.each([9999, 10000, 10001])( - "reports truncation only when %s files exceed the limit", - (count) => { - let collect: ( - _event: unknown, - paths: string[], - ) => { success: boolean; files: unknown[]; truncated: boolean }; - vm.runInNewContext(collector + handler, { - fs: { - lstatSync: (p: string) => ({ - isSymbolicLink: () => false, - isFile: () => p !== "/root", - isDirectory: () => p === "/root", - size: 1, - birthtime: new Date(0), - mtime: new Date(0), - }), - readdirSync: () => - Array.from({ length: count }, (_, i) => `file${i}`), - }, - path: path.posix, - normalizeRelativeLocalPath: (p: string) => p, - ipcMain: { - handle: (_name: string, callback: typeof collect) => { - collect = callback; - }, - }, - }); - const result = collect!(null, ["/root"]); - expect(result.success).toBe(true); - expect(result.files).toHaveLength(Math.min(10000, count)); - expect(result.truncated).toBe(count > 10000); - }, - ); -}); diff --git a/src/backend/tests/electron/local-files.test.ts b/src/backend/tests/electron/local-files.test.ts deleted file mode 100644 index 5999ad547..000000000 --- a/src/backend/tests/electron/local-files.test.ts +++ /dev/null @@ -1,1166 +0,0 @@ -import { createRequire } from "node:module"; -import http from "node:http"; -import fs from "node:fs"; -import fsp from "node:fs/promises"; -import os from "node:os"; -import path from "node:path"; -import crypto from "node:crypto"; -import { EventEmitter } from "node:events"; -import { - afterAll, - afterEach, - beforeAll, - beforeEach, - describe, - expect, - it, -} from "vitest"; - -const require = createRequire(import.meta.url); - -type PublishFs = { - rename: (from: string, to: string) => Promise; - link: (from: string, to: string) => Promise; - copyFileExcl: (from: string, to: string) => Promise; - rm: (target: string) => Promise; - lstat: (target: string) => Promise; -}; - -const localFiles = require("../../../../electron/local-files.cjs") as { - assertWithinRoot: ( - rootPath: string, - candidate: string, - pathImpl?: typeof path, - ) => string; - publishDownload: ( - partialPath: string, - absDest: string, - overwrite: boolean, - transferId: string, - io?: PublishFs, - ) => Promise; - defaultPublishFs: PublishFs; - IPC: Record; - TRANSFER_ROUTES: Record; - createTargetResolver: (deps: { - localBaseUrl?: string; - getLinkedServer?: () => { - serverUrl?: string; - token?: string | null; - } | null; - }) => (req: { origin?: unknown; route?: unknown; deviceId?: unknown }) => { - url: string; - headers: Record; - }; - createLocalFileHandlers: (deps: { - net: unknown; - shell: unknown; - getLinkedServer?: () => { - serverUrl?: string; - token?: string | null; - } | null; - localBaseUrl?: string; - publishFs?: PublishFs; - }) => Record< - string, - ( - event: unknown, - ...args: unknown[] - ) => Promise< - { success: boolean; error?: string; code?: string } & Record< - string, - unknown - > - > - >; -}; - -// Minimal stand-in for Electron's net.request built on Node http, exposing -// the subset of the ClientRequest API local-files.cjs uses. -/** Header names the bridge tried to set on any request, lower-cased. */ -const headersSetByBridge: string[] = []; - -class FakeClientRequest extends EventEmitter { - private req: http.ClientRequest; - chunkedEncoding = false; - constructor(opts: { method: string; url: string }) { - super(); - const u = new URL(opts.url); - this.req = http.request({ - method: opts.method, - hostname: u.hostname, - port: u.port, - path: u.pathname, - }); - this.req.on("response", (res) => this.emit("response", res)); - this.req.on("error", (e) => this.emit("error", e)); - } - setHeader(k: string, v: string) { - headersSetByBridge.push(k.toLowerCase()); - this.req.setHeader(k, v); - } - write(chunk: Buffer | string, cb?: () => void) { - return this.req.write(chunk, cb); - } - end(chunk?: Buffer | string) { - this.req.end(chunk); - } - abort() { - this.req.destroy(); - this.emit("abort"); - } -} - -const fakeNet = { - request: (opts: { method: string; url: string }) => - new FakeClientRequest(opts), -}; - -const fakeEvent = { - sender: { session: null, isDestroyed: () => false, send: () => {} }, -}; - -// A backend stand-in: records what it receives and serves a fixed payload on -// the download route (optionally slowly, to exercise concurrency). -function startBackend( - payload: Buffer, - opts: { delayMs?: number; failAll?: boolean } = {}, -) { - const seen: Array<{ url: string; headers: http.IncomingHttpHeaders }> = []; - const server = http.createServer((req, res) => { - seen.push({ url: req.url || "", headers: req.headers }); - let body = ""; - req.on("data", (d) => (body += d)); - req.on("end", () => { - if (!opts.failAll && req.url?.endsWith("/downloadFileStream")) { - // Headers go out immediately so the client opens its temp file; - // the body may be delayed to keep the transfer in flight. - res.writeHead(200, { "Content-Length": payload.length }); - res.flushHeaders(); - const send = () => res.end(payload); - if (opts.delayMs) setTimeout(send, opts.delayMs); - else send(); - return; - } - res.statusCode = 404; - res.end(JSON.stringify({ error: `no route ${req.url}` })); - }); - }); - return new Promise<{ - url: string; - seen: typeof seen; - close: () => Promise; - }>((resolve) => { - server.listen(0, "127.0.0.1", () => { - const address = server.address() as { port: number }; - resolve({ - url: `http://127.0.0.1:${address.port}/plugin-api/file-manager`, - seen, - close: () => new Promise((r) => server.close(() => r())), - }); - }); - }); -} - -describe("local-files transfer target resolution", () => { - const resolve = localFiles.createTargetResolver({ - localBaseUrl: "http://127.0.0.1:30001/plugin-api/file-manager", - apiPath: "/plugin-api/file-manager", - getLinkedServer: () => ({ - serverUrl: "https://termix.example.com/", - token: "remote-jwt", - }), - }); - - it("only reaches the two file-manager streaming routes on the local backend", () => { - expect(resolve({ origin: "local", route: "uploadFileStream" })).toEqual({ - url: "http://127.0.0.1:30001/plugin-api/file-manager/uploadFileStream", - headers: { "X-Electron-App": "true" }, - }); - expect(resolve({ origin: "local", route: "downloadFileStream" }).url).toBe( - "http://127.0.0.1:30001/plugin-api/file-manager/downloadFileStream", - ); - }); - - it("derives the remote target from the linked server and attaches its session itself", () => { - const target = resolve({ origin: "remote", route: "downloadFileStream" }); - expect(target.url).toBe( - "https://termix.example.com/plugin-api/file-manager/downloadFileStream", - ); - expect(target.headers.Authorization).toBe("Bearer remote-jwt"); - }); - - it("refuses unknown origins, routes, and off-origin URLs smuggled as either", () => { - expect(() => - resolve({ origin: "https://evil.example", route: "uploadFileStream" }), - ).toThrow(/Unknown transfer origin/); - expect(() => resolve({ origin: "local", route: "/../admin" })).toThrow( - /Unknown transfer route/, - ); - expect(() => - resolve({ origin: "local", route: "http://evil.example/x" }), - ).toThrow(/Unknown transfer route/); - expect(() => resolve({})).toThrow(/Unknown transfer route/); - }); - - it("refuses a remote origin when the device is not linked or the server is not http(s)", () => { - const unconfigured = localFiles.createTargetResolver({ - getLinkedServer: () => null, - }); - expect(() => - unconfigured({ origin: "remote", route: "uploadFileStream" }), - ).toThrow(/not linked/); - const bogus = localFiles.createTargetResolver({ - getLinkedServer: () => ({ serverUrl: "file:///etc/passwd" }), - }); - expect(() => - bogus({ origin: "remote", route: "uploadFileStream" }), - ).toThrow(/must use http or https/); - }); - - it("accepts only a well-formed device id and never arbitrary headers", () => { - expect( - resolve({ - origin: "local", - route: "uploadFileStream", - deviceId: "dev_1.2:3-x", - }).headers["X-Termix-Device-ID"], - ).toBe("dev_1.2:3-x"); - expect(() => - resolve({ - origin: "local", - route: "uploadFileStream", - deviceId: "x\r\nHost: evil", - }), - ).toThrow(/Invalid device id/); - const headers = resolve({ - origin: "local", - route: "uploadFileStream", - }).headers; - expect(Object.keys(headers)).toEqual(["X-Electron-App"]); - }); - - it("sends the renderer's local token as a Bearer header so transfers outlive the jwt cookie", () => { - const jwt = "eyJhbGciOiJIUzI1NiJ9.eyJ1c2VySWQiOiJ1MSJ9.c2ln-_X"; - const target = resolve({ - origin: "local", - route: "uploadFileStream", - authToken: jwt, - }); - expect(target.headers.Authorization).toBe(`Bearer ${jwt}`); - expect(target.url).toBe( - "http://127.0.0.1:30001/plugin-api/file-manager/uploadFileStream", - ); - // Absent or empty: no header at all (the session cookie is the fallback). - for (const authToken of [undefined, null, ""]) { - expect( - resolve({ origin: "local", route: "downloadFileStream", authToken }) - .headers.Authorization, - ).toBeUndefined(); - } - }); - - it("refuses anything but a compact JWT as the local token", () => { - for (const authToken of [ - "tmx_apikey", - "a.b", - "x\r\nX-Injected: 1", - "Bearer abc.def.ghi", - { toString: () => "a.b.c" }, - "a.b.".padEnd(9000, "c"), - ]) { - expect(() => - resolve({ origin: "local", route: "uploadFileStream", authToken }), - ).toThrow(/Invalid auth token/); - } - }); - - it("never lets the renderer's token replace the main process's Remote Sync JWT", () => { - const target = resolve({ - origin: "remote", - route: "uploadFileStream", - authToken: "aaa.bbb.ccc", - }); - expect(target.headers.Authorization).toBe("Bearer remote-jwt"); - }); -}); - -describe("local-files download boundary", () => { - let root: string; - const payload = crypto.randomBytes(64 * 1024 + 7); - let backend: Awaited>; - let handlers: ReturnType; - - beforeAll(async () => { - backend = await startBackend(payload); - handlers = localFiles.createLocalFileHandlers({ - net: fakeNet, - shell: {}, - localBaseUrl: backend.url, - getLinkedServer: () => null, - }); - }); - afterAll(() => backend.close()); - beforeEach(async () => { - root = await fsp.mkdtemp(path.join(os.tmpdir(), "termix-local-files-")); - }); - afterEach(() => fsp.rm(root, { recursive: true, force: true })); - - const download = ( - dest: string, - extra: Record = {}, - transferId = `t-${Math.random().toString(36).slice(2)}`, - ) => - handlers[localFiles.IPC.DOWNLOAD](fakeEvent, { - transferId, - origin: "local", - body: { sessionId: "1", path: "/remote/file.bin" }, - destPath: dest, - rootPath: root, - ...extra, - }); - - it("rejects existing linked parents for downloads and directory creation", async () => { - const selected = path.join(root, "selected"); - const outside = path.join(root, "outside"); - await fsp.mkdir(selected); - await fsp.mkdir(outside); - await fsp.symlink(outside, path.join(selected, "linked"), "junction"); - const result = await download(path.join(selected, "linked", "file.bin"), { - rootPath: selected, - }); - expect(result.success).toBe(false); - const mkdir = await handlers[localFiles.IPC.ENSURE_DIR]( - fakeEvent, - path.join(selected, "linked", "new"), - selected, - ); - expect(mkdir.success).toBe(false); - expect(await fsp.readdir(outside)).toEqual([]); - }); - - it("allows an explicitly selected linked root", async () => { - const actual = path.join(root, "actual"); - const selected = path.join(root, "selected"); - await fsp.mkdir(actual); - await fsp.symlink(actual, selected, "junction"); - const result = await download(path.join(selected, "nested", "file.bin"), { - rootPath: selected, - }); - expect(result.success).toBe(true); - expect(await fsp.readFile(path.join(actual, "nested", "file.bin"))).toEqual( - payload, - ); - }); - - it("ignores renderer-supplied url/headers and talks to the resolved backend only", async () => { - const dest = path.join(root, "a.bin"); - const result = await download(dest, { - url: "http://evil.example/steal", - headers: { Authorization: "Bearer leaked", Cookie: "jwt=leaked" }, - }); - expect(result.success).toBe(true); - const last = backend.seen[backend.seen.length - 1]; - expect(last.url).toBe("/plugin-api/file-manager/downloadFileStream"); - expect(last.headers.authorization).toBeUndefined(); - expect(last.headers.cookie).toBeUndefined(); - expect(last.headers["x-electron-app"]).toBe("true"); - expect((await fsp.readFile(dest)).equals(payload)).toBe(true); - }); - - it("never sets headers Electron's net module forbids (the request would fail with ERR_INVALID_ARGUMENT)", async () => { - headersSetByBridge.length = 0; - const result = await download(path.join(root, "b.bin")); - expect(result.success).toBe(true); - // Electron computes Content-Length from the buffered body itself and - // rejects requests that set it (or any other restricted header) by hand. - const restricted = [ - "content-length", - "host", - "trailer", - "te", - "upgrade", - "cookie2", - "keep-alive", - "transfer-encoding", - ]; - expect(headersSetByBridge.filter((h) => restricted.includes(h))).toEqual( - [], - ); - // The JSON body still reached the backend intact. - const last = backend.seen[backend.seen.length - 1]; - expect(last.headers["content-type"]).toBe("application/json"); - }); - - it("refuses to replace an existing file by default and leaves it untouched", async () => { - const dest = path.join(root, "keep.bin"); - await fsp.writeFile(dest, "original"); - const requestsBefore = backend.seen.length; - - const result = await download(dest); - expect(result.success).toBe(false); - expect(result.code).toBe("EEXIST"); - expect(await fsp.readFile(dest, "utf8")).toBe("original"); - // Refused before any network traffic. - expect(backend.seen.length).toBe(requestsBefore); - expect(await fsp.readdir(root)).toEqual(["keep.bin"]); - }); - - it("replaces an existing file only when overwrite is explicitly requested", async () => { - const dest = path.join(root, "replace.bin"); - await fsp.writeFile(dest, "original"); - const result = await download(dest, { overwrite: true }); - expect(result.success).toBe(true); - expect((await fsp.readFile(dest)).equals(payload)).toBe(true); - expect(await fsp.readdir(root)).toEqual(["replace.bin"]); - }); - - it("refuses to publish over a file that appeared while the download was running", async () => { - const slow = await startBackend(payload, { delayMs: 300 }); - const slowHandlers = localFiles.createLocalFileHandlers({ - net: fakeNet, - shell: {}, - localBaseUrl: slow.url, - }); - try { - const dest = path.join(root, "race.bin"); - const pending = slowHandlers[localFiles.IPC.DOWNLOAD](fakeEvent, { - transferId: "race-1", - origin: "local", - body: {}, - destPath: dest, - rootPath: root, - }); - await new Promise((r) => setTimeout(r, 100)); - await fsp.writeFile(dest, "someone else wrote this"); - const result = await pending; - expect(result.success).toBe(false); - expect(result.code).toBe("EEXIST"); - expect(await fsp.readFile(dest, "utf8")).toBe("someone else wrote this"); - expect(await fsp.readdir(root)).toEqual(["race.bin"]); - } finally { - await slow.close(); - } - }); - - it("rejects a destination parent replaced by a link during download", async () => { - const selected = path.join(root, "selected"); - const nested = path.join(selected, "nested"); - const outside = path.join(root, "outside"); - await fsp.mkdir(nested, { recursive: true }); - await fsp.mkdir(outside); - const slow = await startBackend(payload, { delayMs: 300 }); - const slowHandlers = localFiles.createLocalFileHandlers({ - net: fakeNet, - shell: {}, - localBaseUrl: slow.url, - }); - try { - const pending = slowHandlers[localFiles.IPC.DOWNLOAD](fakeEvent, { - transferId: "parent-race", - origin: "local", - body: {}, - rootPath: selected, - destPath: path.join(nested, "file.bin"), - }); - await new Promise((resolve) => setTimeout(resolve, 100)); - await fsp.rename(nested, path.join(selected, "original")); - await fsp.symlink(outside, nested, "junction"); - const result = await pending; - expect(result.success).toBe(false); - expect(await fsp.readdir(outside)).toEqual([]); - expect((await fsp.readdir(selected)).sort()).toEqual([ - "nested", - "original", - ]); - } finally { - await slow.close(); - } - }); - - it("uses a transfer-unique temp file and rejects a concurrent download to the same destination", async () => { - const slow = await startBackend(payload, { delayMs: 300 }); - const slowHandlers = localFiles.createLocalFileHandlers({ - net: fakeNet, - shell: {}, - localBaseUrl: slow.url, - }); - try { - const dest = path.join(root, "same.bin"); - const first = slowHandlers[localFiles.IPC.DOWNLOAD](fakeEvent, { - transferId: "first", - origin: "local", - body: {}, - destPath: dest, - rootPath: root, - }); - await new Promise((r) => setTimeout(r, 50)); - const second = await slowHandlers[localFiles.IPC.DOWNLOAD](fakeEvent, { - transferId: "second", - origin: "local", - body: {}, - destPath: dest, - rootPath: root, - }); - expect(second.success).toBe(false); - expect(second.code).toBe("EBUSY"); - - // While the first is in flight its partial carries the transfer id. - const partials = (await fsp.readdir(root)).filter((n) => - n.endsWith(".termix-part"), - ); - expect(partials).toEqual(["same.bin.first.termix-part"]); - - const result = await first; - expect(result.success).toBe(true); - expect((await fsp.readFile(dest)).equals(payload)).toBe(true); - expect(await fsp.readdir(root)).toEqual(["same.bin"]); - } finally { - await slow.close(); - } - }); - - it("runs many downloads at once, cancels one without disturbing the others, and leaves no partials", async () => { - const slow = await startBackend(payload, { delayMs: 250 }); - const slowHandlers = localFiles.createLocalFileHandlers({ - net: fakeNet, - shell: {}, - localBaseUrl: slow.url, - }); - try { - const dests = Array.from({ length: 5 }, (_, i) => - path.join(root, "many", `part${i}`, `file${i}.bin`), - ); - const pending = dests.map((dest, i) => - slowHandlers[localFiles.IPC.DOWNLOAD](fakeEvent, { - transferId: `many-${i}`, - origin: "local", - body: { sessionId: "1", path: "/remote/file.bin" }, - destPath: dest, - rootPath: root, - }), - ); - await new Promise((r) => setTimeout(r, 60)); - const cancelled = await slowHandlers[localFiles.IPC.CANCEL]( - fakeEvent, - "many-2", - ); - expect(cancelled.success).toBe(true); - const results = await Promise.all(pending); - results.forEach((r, i) => { - if (i === 2) expect(r.success).toBe(false); - else expect(r.success).toBe(true); - }); - // Partials live in the selected root while in flight; none may remain. - expect(await fsp.readdir(root)).toEqual(["many"]); - for (const [i, dest] of dests.entries()) { - if (i === 2) { - expect(await fsp.readdir(path.dirname(dest))).toEqual([]); - } else { - expect((await fsp.readFile(dest)).equals(payload)).toBe(true); - expect(await fsp.readdir(path.dirname(dest))).toEqual([ - `file${i}.bin`, - ]); - } - } - } finally { - slow.close(); - } - }); - - it("allows two downloads of different files to run side by side", async () => { - const [a, b] = await Promise.all([ - download(path.join(root, "one.bin")), - download(path.join(root, "two.bin")), - ]); - expect(a.success && b.success).toBe(true); - expect((await fsp.readdir(root)).sort()).toEqual(["one.bin", "two.bin"]); - }); - - it("cleans up its partial file and never creates the destination on a backend error", async () => { - const failingBackend = await startBackend(payload, { failAll: true }); - const failing = localFiles.createLocalFileHandlers({ - net: fakeNet, - shell: {}, - localBaseUrl: failingBackend.url, - }); - try { - const bad = await failing[localFiles.IPC.DOWNLOAD](fakeEvent, { - transferId: "err", - origin: "local", - body: {}, - destPath: path.join(root, "never.bin"), - rootPath: root, - }); - expect(bad.success).toBe(false); - expect(bad.error).toMatch(/no route/); - expect(fs.existsSync(path.join(root, "never.bin"))).toBe(false); - expect(await fsp.readdir(root)).toEqual([]); - } finally { - await failingBackend.close(); - } - }); - - it("reports which destinations already exist for the collision prompt", async () => { - const present = path.join(root, "present.txt"); - await fsp.writeFile(present, "x"); - const result = await handlers[localFiles.IPC.EXISTS](fakeEvent, [ - present, - path.join(root, "absent.txt"), - ]); - expect(result.success).toBe(true); - expect(result.existing).toEqual([present]); - }); -}); - -// Simulates the Windows rename contract on top of the real filesystem: -// rename() onto a name that already exists fails instead of replacing it. -// Every rename is recorded so a test can prove the swap never relied on -// rename-over-existing. -function windowsLikeFs( - overrides: Partial = {}, -): PublishFs & { renames: Array<[string, string]> } { - const base = localFiles.defaultPublishFs; - const renames: Array<[string, string]> = []; - return { - ...base, - renames, - rename: async (from, to) => { - renames.push([from, to]); - if (fs.existsSync(to)) { - throw Object.assign(new Error(`EEXIST: file already exists, rename`), { - code: "EEXIST", - }); - } - await base.rename(from, to); - }, - ...overrides, - }; -} - -describe("download destination containment", () => { - const win = path.win32; - const root = "C:\\Downloads\\selected"; - - it("accepts ordinary nested destinations on Windows", () => { - expect( - localFiles.assertWithinRoot(root, "C:\\Downloads\\selected\\a.txt", win), - ).toBe("C:\\Downloads\\selected\\a.txt"); - expect( - localFiles.assertWithinRoot( - root, - "C:\\Downloads\\selected\\docs\\2026\\report.pdf", - win, - ), - ).toBe("C:\\Downloads\\selected\\docs\\2026\\report.pdf"); - // case-insensitive drive/dir comparison, like the filesystem - expect( - localFiles.assertWithinRoot(root, "c:\\downloads\\SELECTED\\b.txt", win), - ).toBe("c:\\downloads\\SELECTED\\b.txt"); - }); - - it("rejects backslash traversal that normalises out of the root on Windows", () => { - // The reviewer's reproduction: selected\..\outside.txt -> Downloads\outside.txt - expect(() => - localFiles.assertWithinRoot( - root, - "C:\\Downloads\\selected\\..\\outside.txt", - win, - ), - ).toThrow(/outside the selected folder/); - expect(() => - localFiles.assertWithinRoot( - root, - "C:\\Downloads\\selected\\sub\\..\\..\\..\\x.txt", - win, - ), - ).toThrow(/outside the selected folder/); - // the root itself is not a valid file destination - expect(() => localFiles.assertWithinRoot(root, root, win)).toThrow( - /outside the selected folder/, - ); - }); - - it("rejects absolute, drive-qualified and UNC destinations on Windows", () => { - for (const dest of [ - "C:\\Windows\\evil.dll", - "D:\\Downloads\\selected\\x.txt", - "\\\\server\\share\\x.txt", - "C:\\Downloads\\selected-other\\x.txt", - ]) { - expect(() => localFiles.assertWithinRoot(root, dest, win)).toThrow( - /outside the selected folder/, - ); - } - }); - - it("requires a root and rejects POSIX traversal too", () => { - expect(() => localFiles.assertWithinRoot("", "/tmp/x", path.posix)).toThrow( - /download folder is required/, - ); - expect(() => - localFiles.assertWithinRoot( - "/home/max/dl", - "/home/max/dl/../x", - path.posix, - ), - ).toThrow(/outside the selected folder/); - expect( - localFiles.assertWithinRoot( - "/home/max/dl", - "/home/max/dl/..\\x", - path.posix, - ), - ).toBe("/home/max/dl/..\\x"); - }); - - it("refuses a download whose destination escapes the root, before any network or disk write", async () => { - const payload = crypto.randomBytes(1024); - const backend = await startBackend(payload); - const tmp = await fsp.mkdtemp(path.join(os.tmpdir(), "termix-contain-")); - const selected = path.join(tmp, "selected"); - await fsp.mkdir(selected); - try { - const handlers = localFiles.createLocalFileHandlers({ - net: fakeNet, - shell: {}, - localBaseUrl: backend.url, - }); - const requestsBefore = backend.seen.length; - const result = await handlers[localFiles.IPC.DOWNLOAD](fakeEvent, { - transferId: "escape-1", - origin: "local", - body: { sessionId: "1", path: "/remote/file.bin" }, - destPath: path.join(selected, "..", "outside.txt"), - rootPath: selected, - }); - expect(result.success).toBe(false); - expect(result.code).toBe("EINVAL"); - expect(backend.seen.length).toBe(requestsBefore); - expect(await fsp.readdir(tmp)).toEqual(["selected"]); - - // and the same for the directory skeleton - const dir = await handlers[localFiles.IPC.ENSURE_DIR]( - fakeEvent, - path.join(selected, "..", "escaped-dir"), - selected, - ); - expect(dir.success).toBe(false); - expect(dir.code).toBe("EINVAL"); - expect(await fsp.readdir(tmp)).toEqual(["selected"]); - - // a missing root is refused as well - const noRoot = await handlers[localFiles.IPC.DOWNLOAD](fakeEvent, { - transferId: "escape-2", - origin: "local", - body: { sessionId: "1", path: "/remote/file.bin" }, - destPath: path.join(selected, "ok.bin"), - }); - expect(noRoot.success).toBe(false); - expect(noRoot.code).toBe("EINVAL"); - expect(await fsp.readdir(selected)).toEqual([]); - } finally { - backend.close(); - await fsp.rm(tmp, { recursive: true, force: true }); - } - }); -}); - -describe("local-files replace primitive (Windows-safe overwrite)", () => { - let root: string; - beforeEach(async () => { - root = await fsp.mkdtemp(path.join(os.tmpdir(), "termix-replace-")); - }); - afterEach(() => fsp.rm(root, { recursive: true, force: true })); - - const seed = async (name: string, existing: string, fresh: Buffer) => { - const dest = path.join(root, name); - const partial = `${dest}.t1.termix-part`; - await fsp.writeFile(dest, existing); - await fsp.writeFile(partial, fresh); - return { dest, partial }; - }; - - it("replaces an existing file without renaming onto an occupied name", async () => { - const fresh = crypto.randomBytes(4096); - const { dest, partial } = await seed("swap.bin", "original", fresh); - const io = windowsLikeFs(); - - await localFiles.publishDownload(partial, dest, true, "t1", io); - - expect((await fsp.readFile(dest)).equals(fresh)).toBe(true); - expect(await fsp.readdir(root)).toEqual(["swap.bin"]); - // Every rename targeted a name that was free at the time. - expect(io.renames.length).toBeGreaterThan(0); - for (const [, to] of io.renames) { - expect(to === dest || to.endsWith(".termix-replaced")).toBe(true); - } - }); - - it("restores the original byte-for-byte when publishing the new contents fails", async () => { - const fresh = crypto.randomBytes(1024); - const { dest, partial } = await seed("restore.bin", "original", fresh); - const denied = () => - Promise.reject( - Object.assign(new Error("EACCES: permission denied"), { - code: "EACCES", - }), - ); - const io = windowsLikeFs({ link: denied, copyFileExcl: denied }); - - await expect( - localFiles.publishDownload(partial, dest, true, "t1", io), - ).rejects.toMatchObject({ code: "EACCES" }); - - expect(await fsp.readFile(dest, "utf8")).toBe("original"); - // Only the original and the caller-owned partial remain: no aside copy. - expect((await fsp.readdir(root)).sort()).toEqual( - ["restore.bin", "restore.bin.t1.termix-part"].sort(), - ); - }); - - it("reports EBUSY and touches nothing when the existing file cannot be moved aside (open on Windows)", async () => { - const fresh = crypto.randomBytes(1024); - const { dest, partial } = await seed("locked.bin", "original", fresh); - const io = windowsLikeFs({ - rename: () => - Promise.reject( - Object.assign(new Error("EPERM: operation not permitted"), { - code: "EPERM", - }), - ), - }); - - await expect( - localFiles.publishDownload(partial, dest, true, "t1", io), - ).rejects.toMatchObject({ code: "EBUSY" }); - - expect(await fsp.readFile(dest, "utf8")).toBe("original"); - expect((await fsp.readFile(partial)).equals(fresh)).toBe(true); - }); - - it("refuses to replace a folder with a file", async () => { - const dest = path.join(root, "folder"); - await fsp.mkdir(dest); - await fsp.writeFile(path.join(dest, "inner.txt"), "keep"); - const partial = `${dest}.t1.termix-part`; - await fsp.writeFile(partial, "new"); - - await expect( - localFiles.publishDownload(partial, dest, true, "t1", windowsLikeFs()), - ).rejects.toMatchObject({ code: "EISDIR" }); - expect(await fsp.readFile(path.join(dest, "inner.txt"), "utf8")).toBe( - "keep", - ); - }); - - it("falls back to an exclusive publish when the file to replace has disappeared", async () => { - const dest = path.join(root, "gone.bin"); - const partial = `${dest}.t1.termix-part`; - await fsp.writeFile(partial, "new"); - - await localFiles.publishDownload( - partial, - dest, - true, - "t1", - windowsLikeFs(), - ); - expect(await fsp.readFile(dest, "utf8")).toBe("new"); - expect(await fsp.readdir(root)).toEqual(["gone.bin"]); - }); - - it("works end to end through the download handler on a Windows-like filesystem", async () => { - const payload = crypto.randomBytes(16 * 1024 + 3); - const backend = await startBackend(payload); - try { - const io = windowsLikeFs(); - const handlers = localFiles.createLocalFileHandlers({ - net: fakeNet, - shell: {}, - localBaseUrl: backend.url, - publishFs: io, - }); - const dest = path.join(root, "e2e.bin"); - await fsp.writeFile(dest, "original"); - - const result = await handlers[localFiles.IPC.DOWNLOAD](fakeEvent, { - transferId: "e2e-1", - origin: "local", - body: { sessionId: "1", path: "/remote/file.bin" }, - destPath: dest, - rootPath: root, - overwrite: true, - }); - - expect(result.success).toBe(true); - expect((await fsp.readFile(dest)).equals(payload)).toBe(true); - expect(await fsp.readdir(root)).toEqual(["e2e.bin"]); - for (const [, to] of io.renames) { - expect(to === dest || to.endsWith(".termix-replaced")).toBe(true); - } - } finally { - backend.close(); - } - }); -}); - -describe("local-files upload boundary", () => { - it("streams the file as multipart to the resolved backend and ignores renderer url/headers", async () => { - const Busboy = require("busboy") as (opts: { - headers: http.IncomingHttpHeaders; - }) => NodeJS.EventEmitter & NodeJS.WritableStream; - const received: { - fields: Record; - fileName?: string; - hash?: string; - url?: string; - headers?: http.IncomingHttpHeaders; - } = { fields: {} }; - - const server = http.createServer((req, res) => { - received.url = req.url; - received.headers = req.headers; - const bb = Busboy({ headers: req.headers }); - bb.on("field", (name: string, value: string) => { - received.fields[name] = value; - }); - bb.on( - "file", - ( - _name: string, - stream: NodeJS.ReadableStream, - info: { filename: string }, - ) => { - received.fileName = info.filename; - const hash = crypto.createHash("sha256"); - stream.on("data", (d: Buffer) => hash.update(d)); - stream.on("end", () => { - received.hash = hash.digest("hex"); - }); - }, - ); - bb.on("close", () => { - res.setHeader("Content-Type", "application/json"); - res.end(JSON.stringify({ message: "ok" })); - }); - req.pipe(bb); - }); - await new Promise((r) => server.listen(0, "127.0.0.1", () => r())); - const port = (server.address() as { port: number }).port; - - const root = await fsp.mkdtemp(path.join(os.tmpdir(), "termix-upload-")); - const payload = crypto.randomBytes(3 * 1024 * 1024 + 11); - const localPath = path.join(root, "big.bin"); - await fsp.writeFile(localPath, payload); - - try { - const handlers = localFiles.createLocalFileHandlers({ - net: fakeNet, - shell: {}, - localBaseUrl: `http://127.0.0.1:${port}/plugin-api/file-manager`, - }); - const result = await handlers[localFiles.IPC.UPLOAD](fakeEvent, { - transferId: "up-1", - origin: "local", - fields: { sessionId: "42", path: "/home/ubuntu" }, - localPath, - fileName: "big renamed.bin", - url: "http://evil.example/exfil", - headers: { Authorization: "Bearer leaked" }, - }); - expect(result.success).toBe(true); - expect(received.url).toBe("/plugin-api/file-manager/uploadFileStream"); - expect(received.headers?.authorization).toBeUndefined(); - expect(received.fields).toEqual({ - sessionId: "42", - path: "/home/ubuntu", - }); - expect(received.fileName).toBe("big renamed.bin"); - expect(received.hash).toBe( - crypto.createHash("sha256").update(payload).digest("hex"), - ); - } finally { - await new Promise((r) => server.close(() => r())); - await fsp.rm(root, { recursive: true, force: true }); - } - }); - - it("puts the renderer's local token on the wire as a Bearer header (cookie no longer required)", async () => { - const seen: http.IncomingHttpHeaders[] = []; - const server = http.createServer((req, res) => { - seen.push(req.headers); - req.resume(); - req.on("end", () => { - // The real backend answers 401 "Missing authentication token" when - // neither cookie nor Bearer header is present. - if (!req.headers.authorization) { - res.statusCode = 401; - res.end(JSON.stringify({ error: "Missing authentication token" })); - return; - } - res.end(JSON.stringify({ message: "ok" })); - }); - }); - await new Promise((r) => server.listen(0, "127.0.0.1", () => r())); - const port = (server.address() as { port: number }).port; - const root = await fsp.mkdtemp(path.join(os.tmpdir(), "termix-upload-")); - const localPath = path.join(root, "small.txt"); - await fsp.writeFile(localPath, "hello"); - const jwt = "eyJhbGciOiJIUzI1NiJ9.eyJ1c2VySWQiOiJ1MSJ9.c2ln"; - try { - const handlers = localFiles.createLocalFileHandlers({ - net: fakeNet, - shell: {}, - localBaseUrl: `http://127.0.0.1:${port}/plugin-api/file-manager`, - }); - const withoutToken = await handlers[localFiles.IPC.UPLOAD](fakeEvent, { - transferId: "up-auth-0", - origin: "local", - fields: { sessionId: "42", path: "/home/ubuntu" }, - localPath, - fileName: "small.txt", - }); - expect(withoutToken.success).toBe(false); - expect(withoutToken.error).toMatch(/Missing authentication token/); - - const withToken = await handlers[localFiles.IPC.UPLOAD](fakeEvent, { - transferId: "up-auth-1", - origin: "local", - authToken: jwt, - fields: { sessionId: "42", path: "/home/ubuntu" }, - localPath, - fileName: "small.txt", - headers: { Authorization: "Bearer leaked" }, - }); - expect(withToken.success).toBe(true); - expect(seen[1]?.authorization).toBe(`Bearer ${jwt}`); - expect(seen[1]?.["x-electron-app"]).toBe("true"); - - const malformed = await handlers[localFiles.IPC.UPLOAD](fakeEvent, { - transferId: "up-auth-2", - origin: "local", - authToken: "not a token\r\nX-Injected: 1", - fields: { sessionId: "42", path: "/home/ubuntu" }, - localPath, - fileName: "small.txt", - }); - expect(malformed.success).toBe(false); - expect(malformed.error).toMatch(/Invalid auth token/); - expect(seen.length).toBe(2); // the malformed one never reached the network - } finally { - await new Promise((r) => server.close(() => r())); - await fsp.rm(root, { recursive: true, force: true }); - } - }); - - it("keeps every body intact when several uploads run at once and cancels only the requested one", async () => { - const Busboy = require("busboy") as (opts: { - headers: http.IncomingHttpHeaders; - }) => NodeJS.EventEmitter & NodeJS.WritableStream; - const received = new Map(); // fileName -> sha256 - let inFlight = 0; - let peak = 0; - const server = http.createServer((req, res) => { - inFlight += 1; - peak = Math.max(peak, inFlight); - const bb = Busboy({ headers: req.headers }); - let name = ""; - const hash = crypto.createHash("sha256"); - bb.on( - "file", - ( - _n: string, - stream: NodeJS.ReadableStream, - info: { filename: string }, - ) => { - name = info.filename; - stream.on("data", (d: Buffer) => hash.update(d)); - }, - ); - bb.on("close", () => { - // Hold the response a little so requests genuinely overlap. - setTimeout(() => { - inFlight -= 1; - received.set(name, hash.digest("hex")); - res.setHeader("Content-Type", "application/json"); - res.end(JSON.stringify({ message: "ok" })); - }, 120); - }); - req.on("aborted", () => { - inFlight -= 1; - }); - req.pipe(bb); - }); - await new Promise((r) => server.listen(0, "127.0.0.1", () => r())); - const port = (server.address() as { port: number }).port; - const root = await fsp.mkdtemp(path.join(os.tmpdir(), "termix-par-up-")); - const files = await Promise.all( - Array.from({ length: 6 }, async (_, i) => { - const payload = crypto.randomBytes(256 * 1024 + i); - const localPath = path.join(root, `f${i}.bin`); - await fsp.writeFile(localPath, payload); - return { - i, - localPath, - sha: crypto.createHash("sha256").update(payload).digest("hex"), - }; - }), - ); - try { - const handlers = localFiles.createLocalFileHandlers({ - net: fakeNet, - shell: {}, - localBaseUrl: `http://127.0.0.1:${port}/plugin-api/file-manager`, - }); - const results = await Promise.all( - files.map((f) => - handlers[localFiles.IPC.UPLOAD](fakeEvent, { - transferId: `par-up-${f.i}`, - origin: "local", - fields: { sessionId: "1", path: "/remote" }, - localPath: f.localPath, - fileName: `f${f.i}.bin`, - }), - ), - ); - expect(results.every((r) => r.success)).toBe(true); - expect(peak).toBeGreaterThan(1); - for (const f of files) expect(received.get(`f${f.i}.bin`)).toBe(f.sha); - } finally { - await new Promise((r) => server.close(() => r())); - await fsp.rm(root, { recursive: true, force: true }); - } - }); - - it("refuses an upload whose origin is not one of the two Termix backends", async () => { - const handlers = localFiles.createLocalFileHandlers({ - net: fakeNet, - shell: {}, - localBaseUrl: "http://127.0.0.1:1/plugin-api/file-manager", - }); - const result = await handlers[localFiles.IPC.UPLOAD](fakeEvent, { - transferId: "up-2", - origin: "http://evil.example", - fields: {}, - localPath: __filename, - fileName: "x", - }); - expect(result.success).toBe(false); - expect(result.error).toMatch(/Unknown transfer origin/); - }); -}); - -describe("local-files transfer API path", () => { - it("accepts only a /plugin-api/ path from the plugin", () => { - const set = ( - localFiles as unknown as { - setTransferApiPath: (path: unknown) => void; - } - ).setTransferApiPath; - expect(() => set("/plugin-api/file-manager")).not.toThrow(); - for (const bad of ["/users", "/plugin-api/../users", "https://x", 3]) { - expect(() => set(bad)).toThrow(/Invalid transfer API path/); - } - }); -}); diff --git a/src/backend/tests/electron/native-rdp.test.ts b/src/backend/tests/electron/native-rdp.test.ts deleted file mode 100644 index 1ad2aa9a2..000000000 --- a/src/backend/tests/electron/native-rdp.test.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { createRequire } from "node:module"; -import { describe, expect, it } from "vitest"; - -const require = createRequire(import.meta.url); -const { buildRdpFile, validateNativeRdpOptions } = - require("../../../../electron/native-rdp.cjs") as { - buildRdpFile: (options: Record) => string; - validateNativeRdpOptions: (options: Record) => unknown; - }; - -describe("native Windows RDP", () => { - it("creates a credential-prompting RDP file without a password", () => { - const result = buildRdpFile({ - host: "server.example.com", - port: 3390, - username: "alice", - domain: "EXAMPLE", - password: "must-not-be-written", - }); - - expect(result).toContain("full address:s:server.example.com:3390"); - expect(result).toContain("username:s:EXAMPLE\\alice"); - expect(result).toContain("prompt for credentials:i:1"); - expect(result).not.toContain("must-not-be-written"); - }); - - it("rejects unsafe host and port values", () => { - expect(() => - validateNativeRdpOptions({ host: "bad\r\nvalue" }), - ).not.toThrow(); - expect(() => validateNativeRdpOptions({ host: "server/path" })).toThrow(); - expect(() => - validateNativeRdpOptions({ host: "server", port: 70000 }), - ).toThrow(); - }); -}); diff --git a/src/backend/tests/electron/rename-local-path.test.ts b/src/backend/tests/electron/rename-local-path.test.ts deleted file mode 100644 index ce39727ce..000000000 --- a/src/backend/tests/electron/rename-local-path.test.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { createRequire } from "node:module"; -import fs from "node:fs"; -import os from "node:os"; -import path from "node:path"; -import { afterEach, beforeEach, describe, expect, it } from "vitest"; -const { renameLocalPath } = createRequire(import.meta.url)( - "../../../../electron/rename-local-path.cjs", -); -let root: string; -beforeEach(() => { - root = fs.mkdtempSync(path.join(os.tmpdir(), "termix-rename-")); -}); -afterEach(() => fs.rmSync(root, { recursive: true, force: true })); -describe("local rename", () => { - it("preserves both files on a name collision", () => { - const a = path.join(root, "a"), - b = path.join(root, "b"); - fs.writeFileSync(a, "source"); - fs.writeFileSync(b, "destination"); - expect(() => renameLocalPath(a, b)).toThrow(/already exists/); - expect(fs.readFileSync(a, "utf8")).toBe("source"); - expect(fs.readFileSync(b, "utf8")).toBe("destination"); - }); - it("refuses to replace even an empty destination directory", () => { - const a = path.join(root, "a"), - b = path.join(root, "b"); - fs.mkdirSync(a); - fs.mkdirSync(b); - expect(() => renameLocalPath(a, b)).toThrow(/already exists/); - expect(fs.readdirSync(root).sort()).toEqual(["a", "b"]); - }); - it("renames a file and handles an unchanged name", () => { - const a = path.join(root, "a"), - b = path.join(root, "b"); - fs.writeFileSync(a, "source"); - renameLocalPath(a, a); - renameLocalPath(a, b); - expect(fs.existsSync(a)).toBe(false); - expect(fs.readFileSync(b, "utf8")).toBe("source"); - }); -}); diff --git a/src/backend/tests/hosts/connect/transport-providers.test.ts b/src/backend/tests/hosts/connect/transport-providers.test.ts new file mode 100644 index 000000000..67bc3bca3 --- /dev/null +++ b/src/backend/tests/hosts/connect/transport-providers.test.ts @@ -0,0 +1,103 @@ +import { PassThrough } from "node:stream"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + performPortKnocking: vi.fn(), + createJumpHostChain: vi.fn(), + createSocks5Connection: vi.fn(), +})); + +vi.mock("../../../utils/logger.js", () => { + const log = { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }; + return { logger: log, sshLogger: log }; +}); +vi.mock("../../../hosts/terminal-auth-helpers.js", () => ({ + performPortKnocking: mocks.performPortKnocking, +})); +vi.mock("../../../hosts/jump-host-chain.js", () => ({ + createJumpHostChain: mocks.createJumpHostChain, +})); +vi.mock("../../../utils/socks5-helper.js", () => ({ + createSocks5Connection: mocks.createSocks5Connection, +})); +vi.mock("../../../hosts/ssh-dns.js", () => ({ + resolveSshConnectConfigHost: async (config: unknown) => config, +})); + +const { clearSshTransports, openProvidedTransport, provideSshTransport } = + await import("../../../hosts/connect/transport-providers.js"); +const { openSshTransport } = + await import("../../../hosts/connect/transport.js"); + +const host = { id: 7, ip: "10.0.0.7", port: 22, username: "root" }; + +afterEach(() => { + clearSshTransports(); + vi.clearAllMocks(); +}); + +describe("ssh transport providers", () => { + it("opens nothing when no provider claims the host", async () => { + provideSshTransport("a", { claims: () => false, open: vi.fn() }); + expect(await openProvidedTransport(host)).toBeNull(); + }); + + it("hands back the claiming provider's stream", async () => { + const stream = new PassThrough(); + const open = vi.fn(async () => stream); + provideSshTransport("a", { claims: (id) => id === 7, open }); + expect(await openProvidedTransport(host)).toBe(stream); + expect(open).toHaveBeenCalledWith(host); + expect(await openProvidedTransport({ ...host, id: 8 })).toBeNull(); + }); + + it("treats a provider that throws while claiming as not claiming", async () => { + provideSshTransport("a", { + claims: () => { + throw new Error("boom"); + }, + open: vi.fn(), + }); + expect(await openProvidedTransport(host)).toBeNull(); + }); + + it("stops using a provider once it is removed", async () => { + const remove = provideSshTransport("a", { + claims: () => true, + open: async () => new PassThrough(), + }); + remove(); + expect(await openProvidedTransport(host)).toBeNull(); + }); +}); + +describe("openSshTransport with a provider", () => { + it("uses the provided stream and skips knocking, jump hosts and proxies", async () => { + const stream = new PassThrough(); + provideSshTransport("a", { claims: () => true, open: async () => stream }); + const config: Record = {}; + const result = await openSshTransport( + { + ...host, + portKnockSequence: [{ port: 1 }], + jumpHosts: [{ hostId: 1 }], + useSocks5: true, + socks5Host: "proxy", + userId: "u1", + } as never, + config as never, + ); + expect(result).toEqual({ jumpClient: null, via: "provided" }); + expect(config.sock).toBe(stream); + expect(mocks.performPortKnocking).not.toHaveBeenCalled(); + expect(mocks.createJumpHostChain).not.toHaveBeenCalled(); + expect(mocks.createSocks5Connection).not.toHaveBeenCalled(); + }); + + it("connects directly when nothing claims the host", async () => { + const config: Record = {}; + const result = await openSshTransport(host as never, config as never); + expect(result.via).toBe("direct"); + expect(config.sock).toBeUndefined(); + }); +}); diff --git a/src/backend/tests/hosts/import/host-export.test.ts b/src/backend/tests/hosts/import/host-export.test.ts new file mode 100644 index 000000000..137a427bd --- /dev/null +++ b/src/backend/tests/hosts/import/host-export.test.ts @@ -0,0 +1,147 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const state = vi.hoisted(() => ({ + hosts: [] as Record[], + credentials: [] as Record[], + resolve: vi.fn(), +})); + +vi.mock("../../../database/repositories/factory.js", () => ({ + createCurrentHostResolutionRepository: () => ({ + findHostsByUserId: async () => state.hosts, + findHostByIdForUser: async (id: number) => + state.hosts.find((host) => host.id === id) ?? null, + }), + createCurrentCredentialRepository: () => ({ + listDecryptedByUserId: async () => state.credentials, + }), +})); +vi.mock("../../../database/routes/host-normalizers.js", () => ({ + hostTerminalExport: () => ({ terminalConfig: null, sshOptions: null }), +})); +vi.mock("../../../database/routes/host-plugin-settings.js", () => ({ + loadHostPluginSettings: async () => new Map(), +})); +vi.mock("../../../hosts/protocol-auth/protocol-auth.js", () => ({ + listProtocolLogins: async () => [], + toPortableLogins: () => ({ + rdp: { + authType: "credential", + credentialId: 4, + username: "bob", + password: "rdp-secret", + fields: { domain: "corp", token: "t" }, + }, + }), +})); +vi.mock("../../../hosts/protocol-auth/registry.js", () => ({ + findHostProtocol: () => ({ + credentialFields: [{ key: "token", secret: true }, { key: "domain" }], + }), +})); +vi.mock("../../../hosts/resolve-host-credentials.js", () => ({ + resolveHostCredentials: state.resolve, +})); + +import { exportAllHosts } from "../../../hosts/import/host-export.js"; + +const sshHost = { + id: 1, + connectionType: "ssh", + name: "web", + ip: "192.0.2.1", + port: 22, + username: "alice", + password: "pw", + key: "KEY", + keyPassword: "kp", + sudoPassword: "sp", + socks5Password: "s5", + authType: "credential", + credentialId: 7, + folder: "Prod", + tags: "a,b", + jumpHosts: JSON.stringify([{ hostId: 2 }]), + forceKeyboardInteractive: "true", +}; + +beforeEach(() => { + state.hosts = [{ ...sshHost }]; + state.credentials = [ + { id: 7, name: "deploy", authType: "key", username: "alice", tags: "x" }, + ]; + state.resolve.mockReset().mockImplementation(async (host) => ({ + ...host, + password: "resolved-pw", + key: "RESOLVED-KEY", + })); +}); + +describe("exportAllHosts", () => { + it("includes decrypted secrets without share mode", async () => { + const result = await exportAllHosts("u1", false); + expect(result.credentials).toBeUndefined(); + expect(result.hosts[0]).toMatchObject({ + exportId: 1, + password: "resolved-pw", + key: "RESOLVED-KEY", + sudoPassword: "sp", + credentialId: 7, + tags: ["a", "b"], + jumpHosts: [{ hostId: 2 }], + forceKeyboardInteractive: true, + }); + expect(state.resolve).toHaveBeenCalled(); + }); + + it("leaves out every secret and credential id in share mode", async () => { + const result = await exportAllHosts("u1", true); + const [host] = result.hosts; + expect(state.resolve).not.toHaveBeenCalled(); + expect(host).toMatchObject({ + password: null, + key: null, + keyPassword: null, + sudoPassword: null, + socks5Password: null, + credentialAlias: "deploy", + }); + expect(host).not.toHaveProperty("credentialId"); + expect(result.credentials).toEqual([ + expect.objectContaining({ alias: "deploy", authType: "key" }), + ]); + expect(JSON.stringify(result)).not.toMatch(/rdp-secret|"t"/); + expect(host.protocolAuth).toMatchObject({ + rdp: { + authType: "direct", + credentialId: null, + password: null, + fields: { domain: "corp" }, + }, + }); + expect(result.version).toBe("1"); + }); + + it("exports a remote desktop host without SSH fields", async () => { + state.hosts = [{ ...sshHost, connectionType: "rdp" }]; + const result = await exportAllHosts("u1", false); + expect(result.hosts[0]).not.toHaveProperty("authType"); + expect(result.hosts[0]).toHaveProperty("protocolAuth"); + }); +}); + +describe("exportAllHosts with hostIds", () => { + it("exports only the asked hosts the user owns", async () => { + state.hosts = [ + { ...sshHost, id: 1 }, + { ...sshHost, id: 2 }, + ]; + const result = await exportAllHosts("u1", false, [2, 99]); + expect(result.hosts.map((host) => host.exportId)).toEqual([2]); + }); + + it("returns nothing for an id the user does not own", async () => { + const result = await exportAllHosts("u1", false, [99]); + expect(result.hosts).toEqual([]); + }); +}); diff --git a/src/backend/tests/hosts/import/host-import.test.ts b/src/backend/tests/hosts/import/host-import.test.ts new file mode 100644 index 000000000..bccea9ec5 --- /dev/null +++ b/src/backend/tests/hosts/import/host-import.test.ts @@ -0,0 +1,134 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + create: vi.fn(), + update: vi.fn(), + remove: vi.fn(), + list: vi.fn(), + applyDefaults: vi.fn(async () => {}), +})); +vi.mock("../../../database/routes/host-plugin-settings.js", () => ({ + applyPluginHostImportSettings: vi.fn(), + setHostPluginEnabled: vi.fn(), +})); +vi.mock("../../../hosts/defaults/index.js", () => ({ + applyHostDefaultsToWrite: async () => ({ core: [] }), + applyDefaultsAfterHostWrite: async () => {}, + applyDefaultsAfterHostWrites: mocks.applyDefaults, +})); +vi.mock("../../../database/repositories/factory.js", () => ({ + createCurrentHostDefaultsRepository: () => ({ listHosts: async () => [] }), + createCurrentCredentialRepository: () => ({ + listDecryptedByUserId: async () => [], + }), + createCurrentHostRepository: () => ({ + createEncryptedForUser: mocks.create, + updateEncryptedForUser: mocks.update, + deleteForUser: mocks.remove, + }), + createCurrentHostResolutionRepository: () => ({ + findHostsByUserId: mocks.list, + }), +})); + +import { + ImportError, + runBulkImport, + runSshConfigImport, +} from "../../../hosts/import/host-import.js"; +import { pluginEvents, TOPICS } from "../../../plugins/events.js"; + +const good = { + ip: "192.0.2.1", + port: 22, + username: "alice", + authType: "none", +}; + +beforeEach(() => { + vi.clearAllMocks(); + mocks.list.mockResolvedValue([]); + mocks.remove.mockResolvedValue({ syncId: null }); +}); + +describe("runBulkImport", () => { + it("creates hosts, returns their ids and tells listeners", async () => { + mocks.create.mockResolvedValueOnce({ id: 11 }); + const created: unknown[] = []; + const off = pluginEvents.on(TOPICS.hostCreated, (p) => created.push(p)); + try { + const result = await runBulkImport("u1", { hosts: [good] }); + expect(result).toMatchObject({ success: 1, failed: 0 }); + expect(result.createdHostIds).toEqual([11]); + expect(mocks.applyDefaults).toHaveBeenCalledWith([11]); + } finally { + off(); + } + expect(created).toEqual([{ hostId: 11, userId: "u1" }]); + }); + + it("refuses an empty list and more than 100 hosts", async () => { + await expect(runBulkImport("u1", { hosts: [] })).rejects.toBeInstanceOf( + ImportError, + ); + await expect( + runBulkImport("u1", { + hosts: Array.from({ length: 101 }, () => ({ ...good })), + }), + ).rejects.toThrow(/Maximum 100/); + }); + + it("keeps the rows that worked when atomic is off", async () => { + mocks.create.mockResolvedValueOnce({ id: 11 }); + const result = await runBulkImport("u1", { + hosts: [good, { ...good, ip: "" }], + }); + expect(result).toMatchObject({ success: 1, failed: 1 }); + expect(mocks.remove).not.toHaveBeenCalled(); + }); + + it("deletes what it created and stays quiet when atomic and a row fails", async () => { + mocks.create + .mockResolvedValueOnce({ id: 11 }) + .mockResolvedValueOnce({ id: 12 }); + const events: unknown[] = []; + const off = pluginEvents.on(TOPICS.hostCreated, (p) => events.push(p)); + try { + const result = await runBulkImport("u1", { + hosts: [good, { ...good, ip: "192.0.2.2" }, { ...good, ip: "" }], + atomic: true, + }); + expect(result.rolledBack).toBe(true); + expect(result.success).toBe(0); + expect(result.createdHostIds).toEqual([]); + expect(result.failed).toBe(1); + } finally { + off(); + } + expect(mocks.remove.mock.calls.map((call) => call[1])).toEqual([12, 11]); + expect(mocks.applyDefaults).not.toHaveBeenCalled(); + expect(events).toEqual([]); + }); + + it("refuses overwrite together with atomic", async () => { + await expect( + runBulkImport("u1", { hosts: [good], overwrite: true, atomic: true }), + ).rejects.toThrow(/cannot overwrite/); + }); +}); + +describe("runSshConfigImport", () => { + const config = "Host web\n HostName 192.0.2.9\n User bob\n"; + + it("imports Host blocks", async () => { + mocks.create.mockResolvedValueOnce({ id: 31 }); + const result = await runSshConfigImport("u1", { content: config }); + expect(result).toMatchObject({ success: 1, createdHostIds: [31] }); + }); + + it("rejects empty content", async () => { + await expect( + runSshConfigImport("u1", { content: " " }), + ).rejects.toBeInstanceOf(ImportError); + }); +}); diff --git a/src/backend/tests/hosts/locks.test.ts b/src/backend/tests/hosts/locks.test.ts new file mode 100644 index 000000000..7efe6d1fb --- /dev/null +++ b/src/backend/tests/hosts/locks.test.ts @@ -0,0 +1,113 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +vi.mock("../../utils/logger.js", () => ({ + pluginLogger: { warn: vi.fn() }, +})); + +const { + assertHostsUnlocked, + assertManualCreateAllowed, + clearHostLocks, + hasHostLocks, + hostLock, + HostLockedError, + manualCreateBlockedBy, + provideHostLocks, + requireManualCreate, + requireUnlockedHost, +} = await import("../../hosts/locks.js"); + +const provider = (locked: number[], allowManualCreate = true) => ({ + isLocked: (hostId: number) => locked.includes(hostId), + note: (hostId: number) => `id ${hostId}`, + allowManualCreate: () => allowManualCreate, +}); + +function fakeRes() { + const res = { + code: 0, + body: null as unknown, + status(code: number) { + res.code = code; + return { json: (body: unknown) => (res.body = body) }; + }, + }; + return res; +} + +afterEach(() => clearHostLocks()); + +describe("host locks", () => { + it("reports nothing without providers", () => { + expect(hasHostLocks()).toBe(false); + expect(hostLock(1)).toBeNull(); + expect(() => assertHostsUnlocked([1, 2])).not.toThrow(); + expect(manualCreateBlockedBy()).toBeNull(); + }); + + it("names the plugin and note for a locked host", () => { + provideHostLocks("cfg", "Config", provider([3])); + expect(hostLock(3)).toEqual({ + pluginId: "cfg", + pluginName: "Config", + note: "id 3", + }); + expect(hostLock(4)).toBeNull(); + expect(() => assertHostsUnlocked([4, 3])).toThrow(HostLockedError); + }); + + it("lets the locking plugin through", () => { + provideHostLocks("cfg", "Config", provider([3], false)); + expect(() => assertHostsUnlocked([3], "cfg")).not.toThrow(); + expect(() => assertManualCreateAllowed("cfg")).not.toThrow(); + expect(() => assertManualCreateAllowed()).toThrow(/managed by Config/); + }); + + it("drops a provider when removed", () => { + const remove = provideHostLocks("cfg", "Config", provider([3], false)); + remove(); + expect(hostLock(3)).toBeNull(); + expect(manualCreateBlockedBy()).toBeNull(); + }); + + it("never locks when a provider throws", () => { + provideHostLocks("cfg", "Config", { + isLocked: () => { + throw new Error("broken"); + }, + note: () => null, + allowManualCreate: () => { + throw new Error("broken"); + }, + }); + expect(hostLock(1)).toBeNull(); + expect(manualCreateBlockedBy()).toBeNull(); + }); +}); + +describe("lock route guards", () => { + it("answers 403 HOST_LOCKED for a locked :id", () => { + provideHostLocks("cfg", "Config", provider([7])); + const res = fakeRes(); + const next = vi.fn(); + requireUnlockedHost({ params: { id: "7" } }, res, next); + expect(next).not.toHaveBeenCalled(); + expect(res.code).toBe(403); + expect(res.body).toMatchObject({ code: "HOST_LOCKED", lockedBy: "cfg" }); + + requireUnlockedHost({ params: { id: "8" } }, fakeRes(), next); + expect(next).toHaveBeenCalledTimes(1); + }); + + it("blocks manual creates only while a provider says so", () => { + const next = vi.fn(); + requireManualCreate({}, fakeRes(), next); + expect(next).toHaveBeenCalledTimes(1); + + provideHostLocks("cfg", "Config", provider([], false)); + const res = fakeRes(); + requireManualCreate({}, res, next); + expect(next).toHaveBeenCalledTimes(1); + expect(res.code).toBe(403); + }); +}); diff --git a/src/backend/tests/hosts/ssh-relay.test.ts b/src/backend/tests/hosts/ssh-relay.test.ts new file mode 100644 index 000000000..821e580e6 --- /dev/null +++ b/src/backend/tests/hosts/ssh-relay.test.ts @@ -0,0 +1,138 @@ +import net from "node:net"; +import type { AddressInfo } from "node:net"; +import http from "node:http"; +import { PassThrough } from "node:stream"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { WebSocket, WebSocketServer } from "ws"; + +const mocks = vi.hoisted(() => ({ + resolveHostById: vi.fn(), + openSshTransport: vi.fn(), + handler: null as null | ((connection: unknown) => Promise), +})); + +vi.mock("../../utils/logger.js", () => { + const log = { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }; + return { sshLogger: log, logger: log }; +}); +vi.mock("../../hosts/host-resolver.js", () => ({ + resolveHostById: mocks.resolveHostById, +})); +vi.mock("../../hosts/connect/transport.js", () => ({ + openSshTransport: mocks.openSshTransport, +})); +vi.mock("../../plugins/ws.js", () => ({ + registerCoreWsRoute: (_path: string, handler: never) => { + mocks.handler = handler; + return () => {}; + }, +})); + +const { registerSshRelay } = await import("../../hosts/ssh-relay.js"); + +let echo: net.Server; +let echoPort = 0; +let relayServer: http.Server; +let wss: WebSocketServer; +let relayUrl = ""; + +beforeEach(async () => { + vi.clearAllMocks(); + registerSshRelay(); + + echo = net.createServer((socket) => socket.pipe(socket)); + await new Promise((resolve) => echo.listen(0, "127.0.0.1", resolve)); + echoPort = (echo.address() as AddressInfo).port; + + relayServer = http.createServer(); + wss = new WebSocketServer({ server: relayServer }); + wss.on("connection", (socket, request) => { + void mocks.handler?.({ userId: "user-1", request, socket }); + }); + await new Promise((resolve) => + relayServer.listen(0, "127.0.0.1", resolve), + ); + relayUrl = `ws://127.0.0.1:${(relayServer.address() as AddressInfo).port}`; + + mocks.resolveHostById.mockResolvedValue({ + id: 5, + ip: "127.0.0.1", + port: echoPort, + }); + mocks.openSshTransport.mockResolvedValue({ jumpClient: null, via: "direct" }); +}); + +afterEach(async () => { + wss.close(); + await new Promise((resolve) => relayServer.close(resolve)); + await new Promise((resolve) => echo.close(resolve)); +}); + +function open(query: string): Promise<{ + socket: WebSocket; + closed: Promise<{ code: number; reason: string }>; +}> { + return new Promise((resolve) => { + const socket = new WebSocket(`${relayUrl}/${query}`); + const closed = new Promise<{ code: number; reason: string }>((done) => + socket.once("close", (code, reason) => + done({ code, reason: reason.toString() }), + ), + ); + socket.once("open", () => resolve({ socket, closed })); + }); +} + +describe("ssh relay", () => { + it("carries bytes to the host and back", async () => { + const { socket } = await open("?hostId=5"); + const received = new Promise((resolve) => + socket.once("message", (data) => resolve(data.toString())), + ); + socket.send(Buffer.from("SSH-2.0-test\r\n")); + expect(await received).toBe("SSH-2.0-test\r\n"); + expect(mocks.resolveHostById).toHaveBeenCalledWith(5, "user-1"); + socket.close(); + }); + + it("uses the stream the transport made, such as a jump host channel", async () => { + const channel = new PassThrough(); + const end = vi.fn(); + mocks.openSshTransport.mockImplementation( + async (_host: unknown, config: { sock?: unknown }) => { + config.sock = channel; + return { jumpClient: { end }, via: "jump" }; + }, + ); + const { socket } = await open("?hostId=5"); + const received = new Promise((resolve) => + socket.once("message", (data) => resolve(data.toString())), + ); + socket.send("ping"); + expect(await received).toBe("ping"); + socket.close(); + await vi.waitFor(() => expect(end).toHaveBeenCalled()); + }); + + it("refuses a host the user can't connect to", async () => { + mocks.resolveHostById.mockResolvedValue(null); + const { closed } = await open("?hostId=5"); + expect(await closed).toMatchObject({ + code: 1008, + reason: "Host not found or access denied", + }); + expect(mocks.openSshTransport).not.toHaveBeenCalled(); + }); + + it("needs a host id", async () => { + const { closed } = await open(""); + expect((await closed).code).toBe(1008); + expect(mocks.resolveHostById).not.toHaveBeenCalled(); + }); + + it("closes the socket when the host can't be reached", async () => { + mocks.openSshTransport.mockRejectedValue(new Error("no route")); + const { closed } = await open("?hostId=5"); + expect((await closed).code).toBe(1000); + }); +}); diff --git a/src/backend/tests/hosts/ssh2-sk-patch.test.ts b/src/backend/tests/hosts/ssh2-sk-patch.test.ts new file mode 100644 index 000000000..39f6ad135 --- /dev/null +++ b/src/backend/tests/hosts/ssh2-sk-patch.test.ts @@ -0,0 +1,111 @@ +import { createRequire } from "node:module"; +import { readFileSync } from "node:fs"; +import { generateKeyPairSync } from "node:crypto"; +import { expect, it } from "vitest"; +const require = createRequire(import.meta.url); +const ssh2 = require("ssh2"); +const { + patchKeyParserSource, + patchAgentSource, + patchProtocolSource, +} = require("../../../../scripts/patch-ssh2-sk.cjs"); + +const SK_ED = "sk-ssh-ed25519@openssh.com"; + +function str(value: Buffer | string): Buffer { + const buf = Buffer.isBuffer(value) ? value : Buffer.from(value); + const len = Buffer.alloc(4); + len.writeUInt32BE(buf.length); + return Buffer.concat([len, buf]); +} + +function skPublicBlob(): Buffer { + const { publicKey } = generateKeyPairSync("ed25519"); + const raw = publicKey.export({ format: "der", type: "spki" }).subarray(-32); + return Buffer.concat([str(SK_ED), str(raw), str("ssh:")]); +} + +it("patches are idempotent on the installed dependency", () => { + const files: Array<[string, (s: string) => string]> = [ + ["ssh2/lib/protocol/keyParser.js", patchKeyParserSource], + ["ssh2/lib/agent.js", patchAgentSource], + ["ssh2/lib/protocol/Protocol.js", patchProtocolSource], + ]; + for (const [id, patch] of files) { + const source = readFileSync(require.resolve(id), "utf8"); + const result = patch(source); + expect(patch(result)).toBe(result); + } +}); + +it("fails explicitly when the dependency changes", () => { + expect(() => patchKeyParserSource("changed")).toThrow("review"); + expect(() => patchAgentSource("changed")).toThrow("review"); + expect(() => patchProtocolSource("changed")).toThrow("review"); +}); + +it("parses a security key public blob and keeps it byte for byte", () => { + const blob = skPublicBlob(); + const key = ssh2.utils.parseKey(blob); + expect(key).not.toBeInstanceOf(Error); + expect(key.type).toBe(SK_ED); + expect(Buffer.compare(key.getPublicSSH(), blob)).toBe(0); +}); + +it("sends flags and counter after the signature in publickey auth", async () => { + const blob = skPublicBlob(); + const key = ssh2.utils.parseKey(blob); + const trailer = Buffer.from([0x01, 0, 0, 0, 7]); + const signature = Buffer.alloc(64, 9); + (signature as Buffer & { skTrailer?: Buffer }).skTrailer = trailer; + + class FakeAgent extends ssh2.BaseAgent { + getIdentities(cb: (err: Error | null, keys?: unknown[]) => void) { + cb(null, [key]); + } + sign( + _key: unknown, + _data: Buffer, + _opts: unknown, + cb: (err: Error | null, sig?: Buffer) => void, + ) { + cb(null, signature); + } + } + + const hostKey = ssh2.utils.generateKeyPairSync("ed25519").private; + const received = await new Promise((resolve) => { + const server = new ssh2.Server({ hostKeys: [hostKey] }, (client: any) => { + client.on("authentication", (ctx: any) => { + if (ctx.method !== "publickey") return ctx.reject(["publickey"]); + if (!ctx.signature) return ctx.accept(); + resolve( + Buffer.concat([ + Buffer.from(ctx.key.data), + Buffer.from(ctx.signature), + ]), + ); + ctx.reject(); + }); + client.on("error", () => {}); + }); + server.listen(0, "127.0.0.1", () => { + const conn = new ssh2.Client(); + conn.on("error", () => {}); + conn.on("close", () => server.close()); + conn.connect({ + host: "127.0.0.1", + port: server.address().port, + username: "u", + agent: new FakeAgent(), + readyTimeout: 5000, + }); + }); + }); + expect( + Buffer.compare( + received, + Buffer.concat([blob, Buffer.alloc(64, 9), trailer]), + ), + ).toBe(0); +}); diff --git a/src/backend/tests/hosts/status/host-status-service.test.ts b/src/backend/tests/hosts/status/host-status-service.test.ts deleted file mode 100644 index 0d3eb1d49..000000000 --- a/src/backend/tests/hosts/status/host-status-service.test.ts +++ /dev/null @@ -1,312 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; - -vi.mock("../../../database/repositories/factory.js", () => ({ - createCurrentHostResolutionRepository: vi.fn(), - getCurrentSettingValue: vi.fn(() => null), -})); -vi.mock("../../../utils/logger.js", () => ({ - sshLogger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() }, - pluginLogger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() }, -})); - -const { HostStatusService, toStatusTarget } = - await import("../../../hosts/status/host-status-service.js"); -const { hostSessionStatus } = - await import("../../../hosts/host-session-status.js"); -const { pluginEvents, TOPICS } = await import("../../../plugins/events.js"); - -type Target = ReturnType; - -function target(id: number, extra: Partial = {}): Target { - return { - id, - userId: "owner", - ip: `10.0.0.${id}`, - port: 22, - connectionType: "ssh", - jumpHosts: [], - statusCheckEnabled: true, - statusCheckInterval: null, - ...extra, - }; -} - -function setup( - targets: Target[], - reachable = true, - shared: Record = {}, -) { - const emitted: unknown[] = []; - const ping = vi.fn(async () => reachable); - const pingThroughJumpHosts = vi.fn(async () => reachable); - const loadTargets = vi.fn( - async (filter: { userId?: string; hostIds?: number[] }) => - targets.filter( - (t) => - (!filter.userId || t.userId === filter.userId) && - (!filter.hostIds || filter.hostIds.includes(t.id)), - ), - ); - const service = new HostStatusService({ - loadTargets, - loadSharedHostIds: async (userId) => shared[userId] ?? [], - ping, - pingThroughJumpHosts, - hasActiveSession: (id) => hostSessionStatus.hasActiveSession(id), - globalInterval: () => 60, - emit: (payload) => emitted.push(payload), - retryDelayMs: 0, - }); - return { service, emitted, ping, pingThroughJumpHosts, loadTargets }; -} - -const flush = () => new Promise((resolve) => setTimeout(resolve, 0)); - -let active: InstanceType | null = null; - -beforeEach(() => { - vi.useFakeTimers({ toFake: ["setInterval", "clearInterval"] }); -}); - -afterEach(() => { - active?.stop(); - active = null; - pluginEvents.clear(); - vi.useRealTimers(); -}); - -describe("HostStatusService", () => { - it.each([{ jumpHosts: [] }, { jumpHosts: [{ hostId: 9 }] }])( - "skips probes while a session is open, including jump hosts: %j", - async ({ jumpHosts }) => { - const { service, ping, pingThroughJumpHosts } = setup([ - target(7, { jumpHosts }), - ]); - active = service; - const close = hostSessionStatus.register(7); - try { - await service.statusesFor("owner", null); - await flush(); - await vi.advanceTimersByTimeAsync(60_000); - await flush(); - expect(service.get(7)?.status).toBe("online"); - expect(ping).not.toHaveBeenCalled(); - expect(pingThroughJumpHosts).not.toHaveBeenCalled(); - close(); - await vi.advanceTimersByTimeAsync(60_000); - await flush(); - expect( - jumpHosts.length ? pingThroughJumpHosts : ping, - ).toHaveBeenCalledOnce(); - } finally { - close(); - } - }, - ); - - it("starts a user's own hosts and reports them online", async () => { - const { service, emitted, ping } = setup([target(1), target(2)]); - active = service; - - await service.statusesFor("owner", null); - await flush(); - - expect(ping).toHaveBeenCalledWith("10.0.0.1", 22); - expect(service.get(1)?.status).toBe("online"); - expect(emitted).toContainEqual({ - hostId: 1, - ownerUserId: "owner", - status: "online", - previous: null, - online: true, - }); - }); - - it("checks a host shared with the user before its owner ever asks", async () => { - const { service, ping } = setup([target(5)], true, { recipient: [5] }); - active = service; - - await service.statusesFor("recipient", null); - await flush(); - - expect(ping).toHaveBeenCalledWith("10.0.0.5", 22); - expect(service.get(5)?.status).toBe("online"); - }); - - it("only starts the shared hosts a desktop request names", async () => { - const { service, ping } = setup([target(5), target(6)], true, { - recipient: [5, 6], - }); - active = service; - - await service.statusesFor("recipient", new Set([6])); - await flush(); - - expect(ping).toHaveBeenCalledWith("10.0.0.6", 22); - expect(ping).not.toHaveBeenCalledWith("10.0.0.5", 22); - }); - - it("reports offline for a host that does not answer", async () => { - const { service } = setup([target(1)], false); - active = service; - await service.statusesFor("owner", null); - await flush(); - expect(service.get(1)?.status).toBe("offline"); - }); - - it("never checks a host with status checks off", async () => { - const { service, ping } = setup([target(1, { statusCheckEnabled: false })]); - active = service; - await service.statusesFor("owner", null); - await flush(); - expect(ping).not.toHaveBeenCalled(); - expect(service.get(1)).toBeNull(); - }); - - it("goes through the jump hosts when a host has them", async () => { - const { service, ping, pingThroughJumpHosts } = setup([ - target(1, { jumpHosts: [{ hostId: 9 }] }), - ]); - active = service; - await service.statusesFor("owner", null); - await flush(); - expect(ping).not.toHaveBeenCalled(); - expect(pingThroughJumpHosts).toHaveBeenCalledOnce(); - }); - - it("asks a registered plugin for a protocol's port", async () => { - const { service, ping } = setup([target(1, { connectionType: "rdp" })]); - active = service; - service.registerPort("rdp", async () => 3390); - await service.statusesFor("owner", null); - await flush(); - expect(ping).toHaveBeenCalledWith("10.0.0.1", 3390); - }); - - it("falls back to the host's port when no plugin knows the protocol", async () => { - const { service, ping } = setup([ - target(1, { connectionType: "rdp", port: 3389 }), - ]); - active = service; - await service.statusesFor("owner", null); - await flush(); - expect(ping).toHaveBeenCalledWith("10.0.0.1", 3389); - }); - - it("ignores a failed login and counts a working one as online", async () => { - const { service, emitted } = setup([target(1)]); - active = service; - await service.statusesFor("owner", null); - - const before = emitted.length; - service.reportLogin(1, { ok: false }); - expect(service.get(1)?.status).toBe("online"); - expect(emitted).toHaveLength(before); - }); - - it("marks a host online when a terminal session opens", async () => { - const { service } = setup([target(1)], false); - active = service; - service.start(); - await service.statusesFor("owner", null); - expect(service.get(1)?.status).toBe("offline"); - - const release = hostSessionStatus.register(1); - expect(service.get(1)?.status).toBe("online"); - release(); - }); - - it("tries a failed ping again before reporting offline", async () => { - const { service, ping } = setup([target(1)]); - active = service; - ping.mockResolvedValueOnce(false); - await service.statusesFor("owner", null); - expect(ping).toHaveBeenCalledTimes(2); - expect(service.get(1)?.status).toBe("online"); - }); - - it("answers the first request with the first checks done", async () => { - const { service } = setup([target(1), target(2)]); - active = service; - const statuses = await service.statusesFor("owner", null); - expect(statuses.get(1)?.status).toBe("online"); - expect(statuses.get(2)?.status).toBe("online"); - }); - - it("only checks the requested hosts on the desktop app", async () => { - const { service, ping } = setup([target(1), target(2)]); - active = service; - await service.statusesFor("owner", new Set([2])); - await flush(); - expect(ping).toHaveBeenCalledTimes(1); - expect(ping).toHaveBeenCalledWith("10.0.0.2", 22); - }); - - it("checks again on its interval", async () => { - const { service, ping } = setup([target(1, { statusCheckInterval: 10 })]); - active = service; - await service.statusesFor("owner", null); - await flush(); - expect(ping).toHaveBeenCalledTimes(1); - - vi.advanceTimersByTime(15_000); - await flush(); - expect(ping).toHaveBeenCalledTimes(2); - }); - - it("checks a host on demand unless the last result is fresh", async () => { - const { service, ping } = setup([target(1)]); - active = service; - expect(await service.check(1)).toMatchObject({ status: "online" }); - expect(await service.check(1)).toMatchObject({ status: "online" }); - expect(ping).toHaveBeenCalledTimes(1); - }); - - it("forgets a deleted host", async () => { - const { service } = setup([target(1)]); - active = service; - service.start(); - await service.statusesFor("owner", null); - await flush(); - - pluginEvents.emit(TOPICS.hostDeleted, { hostId: 1 }); - expect(service.get(1)).toBeNull(); - }); - - it("never logs in to check a host", async () => { - // The only transports are the two ping functions: nothing here can - // authenticate, so a RADIUS or Duo backed host gets no 2FA push. - const { service, ping, pingThroughJumpHosts } = setup([target(1)]); - active = service; - await service.statusesFor("owner", null); - await flush(); - expect(ping.mock.calls[0]).toEqual(["10.0.0.1", 22]); - expect(pingThroughJumpHosts).not.toHaveBeenCalled(); - }); -}); - -describe("toStatusTarget", () => { - it("parses the jump hosts and defaults the connection type", () => { - expect( - toStatusTarget({ - id: 1, - userId: "u", - ip: "h", - port: 22, - connectionType: "", - jumpHosts: '[{"hostId":3},{"bad":1}]', - statusCheckEnabled: false, - statusCheckInterval: 30, - }), - ).toEqual({ - id: 1, - userId: "u", - ip: "h", - port: 22, - connectionType: "ssh", - jumpHosts: [{ hostId: 3 }], - statusCheckEnabled: false, - statusCheckInterval: 30, - }); - }); -}); diff --git a/src/backend/tests/hosts/status/host-status.test.ts b/src/backend/tests/hosts/status/host-status.test.ts deleted file mode 100644 index 1d67f7a12..000000000 --- a/src/backend/tests/hosts/status/host-status.test.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { isHostKeyVerificationError } from "../../../hosts/status/host-status.js"; - -describe("isHostKeyVerificationError", () => { - it.each([ - "Host denied (verification failed)", - "Host key changed - please connect via Terminal to verify the new key", - ])("classifies %s", (message) => { - expect(isHostKeyVerificationError(new Error(message))).toBe(true); - }); - - it("does not classify ordinary authentication failures", () => { - expect(isHostKeyVerificationError(new Error("Permission denied"))).toBe( - false, - ); - }); -}); diff --git a/src/backend/tests/hosts/status/limiter.test.ts b/src/backend/tests/hosts/status/limiter.test.ts deleted file mode 100644 index 313226c8c..000000000 --- a/src/backend/tests/hosts/status/limiter.test.ts +++ /dev/null @@ -1,162 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { ConcurrentLimiter } from "../../../hosts/status/limiter.js"; - -describe("ConcurrentLimiter", () => { - it("never exceeds max concurrent runners", async () => { - const limiter = new ConcurrentLimiter(2); - let peak = 0; - let current = 0; - - const job = async () => { - current += 1; - peak = Math.max(peak, current); - await new Promise((r) => setTimeout(r, 30)); - current -= 1; - }; - - await Promise.all([ - limiter.run(job), - limiter.run(job), - limiter.run(job), - limiter.run(job), - ]); - - expect(peak).toBeLessThanOrEqual(2); - expect(limiter.activeCount).toBe(0); - expect(limiter.pendingCount).toBe(0); - }); - - it("runs waiters in FIFO order after a slot frees", async () => { - const limiter = new ConcurrentLimiter(1); - const order: number[] = []; - - const first = limiter.run(async () => { - order.push(1); - await new Promise((r) => setTimeout(r, 20)); - }); - const second = limiter.run(async () => { - order.push(2); - }); - const third = limiter.run(async () => { - order.push(3); - }); - - await Promise.all([first, second, third]); - expect(order).toEqual([1, 2, 3]); - }); - - it("rejects invalid maxConcurrent", () => { - expect(() => new ConcurrentLimiter(0)).toThrow(/maxConcurrent/); - }); - - describe("setLimit", () => { - it("releases queued waiters as soon as the ceiling is raised", async () => { - const limiter = new ConcurrentLimiter(1); - let running = 0; - let peak = 0; - const release: Array<() => void> = []; - - const job = () => - limiter.run(async () => { - running += 1; - peak = Math.max(peak, running); - await new Promise((r) => release.push(r)); - running -= 1; - }); - - const jobs = [job(), job(), job(), job()]; - await new Promise((r) => setTimeout(r, 10)); - expect(peak).toBe(1); - expect(limiter.pendingCount).toBe(3); - - // Widening must drain the backlog without waiting for the running job. - limiter.setLimit(4); - await new Promise((r) => setTimeout(r, 10)); - expect(peak).toBe(4); - expect(limiter.pendingCount).toBe(0); - - release.forEach((fn) => fn()); - await Promise.all(jobs); - expect(limiter.activeCount).toBe(0); - }); - - it("does not over-release beyond the new ceiling", async () => { - const limiter = new ConcurrentLimiter(1); - let running = 0; - let peak = 0; - const release: Array<() => void> = []; - // Later waves of woken jobs enqueue their own resolvers, so draining has - // to keep going until nothing is left rather than flushing a snapshot. - const drain = async () => { - while (release.length > 0) { - release.splice(0).forEach((fn) => fn()); - await new Promise((r) => setTimeout(r, 5)); - } - }; - - const job = () => - limiter.run(async () => { - running += 1; - peak = Math.max(peak, running); - await new Promise((r) => release.push(r)); - running -= 1; - }); - - const jobs = [job(), job(), job(), job(), job()]; - await new Promise((r) => setTimeout(r, 10)); - - limiter.setLimit(3); - await new Promise((r) => setTimeout(r, 10)); - expect(peak).toBe(3); - expect(limiter.pendingCount).toBe(2); - - await drain(); - await Promise.all(jobs); - expect(limiter.activeCount).toBe(0); - expect(limiter.pendingCount).toBe(0); - }); - - it("lets running work finish when the ceiling shrinks", async () => { - const limiter = new ConcurrentLimiter(4); - let running = 0; - let peak = 0; - const release: Array<() => void> = []; - const drain = async () => { - while (release.length > 0) { - release.splice(0).forEach((fn) => fn()); - await new Promise((r) => setTimeout(r, 5)); - } - }; - - const job = () => - limiter.run(async () => { - running += 1; - peak = Math.max(peak, running); - await new Promise((r) => release.push(r)); - running -= 1; - }); - - const jobs = [job(), job(), job(), job(), job(), job()]; - await new Promise((r) => setTimeout(r, 10)); - expect(peak).toBe(4); - - // Shrinking never kills in-flight work; it applies to later releases. - limiter.setLimit(2); - expect(limiter.activeCount).toBe(4); - - await drain(); - await Promise.all(jobs); - expect(limiter.activeCount).toBe(0); - expect(limiter.pendingCount).toBe(0); - // The two queued jobs ran only after the shrink, so they never pushed - // occupancy back up to the old width. - expect(peak).toBe(4); - }); - - it("rejects an invalid new limit", () => { - const limiter = new ConcurrentLimiter(2); - expect(() => limiter.setLimit(0)).toThrow(/maxConcurrent/); - expect(limiter.limit).toBe(2); - }); - }); -}); diff --git a/src/backend/tests/plugins/activity-provider.test.ts b/src/backend/tests/plugins/activity-provider.test.ts new file mode 100644 index 000000000..d7e82f4d3 --- /dev/null +++ b/src/backend/tests/plugins/activity-provider.test.ts @@ -0,0 +1,80 @@ +import { describe, expect, it, vi } from "vitest"; + +const access = vi.hoisted(() => ({ owned: true, shared: [] as number[] })); + +vi.mock("../../database/repositories/factory.js", () => ({ + createCurrentHostResolutionRepository: () => ({ + isHostOwnedByUser: async () => access.owned, + }), + createCurrentRoleRepository: () => ({ listUserRoleIds: async () => [] }), + createCurrentRbacAccessRepository: () => ({ + listVisibleHostAccessEntries: async () => + access.shared.map((hostId) => ({ hostId })), + }), +})); + +import { + provideActivity, + recentActivity, + type ActivityProvider, +} from "../../plugins/activity-provider.js"; + +function fake(): ActivityProvider { + return { + record: vi.fn(async () => ({ status: "logged" as const, id: 1 })), + list: vi.fn(async () => []), + deleteByUserId: vi.fn(async () => {}), + deleteByHostIds: vi.fn(async () => {}), + }; +} + +const entry = { type: "ssh", hostId: 1, hostName: "h" }; + +describe("recent activity provider", () => { + it("is unavailable with no provider", async () => { + expect(await recentActivity.record("u", entry)).toEqual({ + status: "unavailable", + }); + expect(await recentActivity.list("u", 5)).toEqual([]); + await expect(recentActivity.deleteByUserId("u")).resolves.toBeUndefined(); + await expect(recentActivity.deleteByHostId(7)).resolves.toBeUndefined(); + }); + + it("routes to the plugin and back to nothing when it goes", async () => { + const plugin = fake(); + const remove = provideActivity("homepage", plugin); + await recentActivity.record("u", entry); + await recentActivity.deleteByHostId(7); + expect(plugin.record).toHaveBeenCalledOnce(); + expect(plugin.deleteByHostIds).toHaveBeenCalledWith([7]); + + remove(); + expect(await recentActivity.list("u", 5)).toEqual([]); + expect(plugin.list).not.toHaveBeenCalled(); + }); + + it("checks host access before the provider records", async () => { + const plugin = fake(); + const remove = provideActivity("homepage", plugin); + + access.owned = false; + access.shared = []; + expect(await recentActivity.record("u", entry)).toEqual({ + status: "denied", + }); + expect(plugin.record).not.toHaveBeenCalled(); + + access.shared = [1]; + expect((await recentActivity.record("u", entry)).status).toBe("logged"); + remove(); + access.owned = true; + }); + + it("refuses a second provider", () => { + const remove = provideActivity("one", fake()); + expect(() => provideActivity("two", fake())).toThrow( + /already provided by plugin "one"/, + ); + remove(); + }); +}); diff --git a/src/backend/tests/plugins/assets.test.ts b/src/backend/tests/plugins/assets.test.ts index b6b876e7d..ace18db5c 100644 --- a/src/backend/tests/plugins/assets.test.ts +++ b/src/backend/tests/plugins/assets.test.ts @@ -108,6 +108,7 @@ describe("describePluginFrontend", () => { css: false, assetVersion: null, locales: [], + themeVersion: null, }); }); }); @@ -175,3 +176,48 @@ describe("GET /plugin-assets", () => { } }); }); + +describe("theme stylesheets", () => { + let themed: { id: string; dir: string; manifest: never }; + + beforeAll(() => { + const dir = path.join(root, "inks"); + fs.mkdirSync(path.join(dir, "themes"), { recursive: true }); + fs.writeFileSync(path.join(dir, "themes", "paper.css"), ".paper{}"); + fs.writeFileSync(path.join(dir, "themes", "secret.css"), ".secret{}"); + themed = { + id: "inks", + dir, + manifest: { + id: "inks", + version: "1.0.0", + contributes: { + themes: [ + { + id: "paper", + label: "Paper", + mode: "light", + css: "themes/paper.css", + }, + ], + }, + } as never, + }; + }); + + it("serves a declared theme's CSS under _themes/.css only", () => { + expect(resolvePluginAsset(themed, "_themes/paper.css")).toBe( + path.join(themed.dir, "themes", "paper.css"), + ); + expect(resolvePluginAsset(themed, "_themes/secret.css")).toBeNull(); + expect(resolvePluginAsset(themed, "themes/paper.css")).toBeNull(); + expect(resolvePluginAsset(themed, "_themes/paper.js")).toBeNull(); + }); + + it("versions theme files even without a frontend bundle", () => { + const info = describePluginFrontend(themed); + expect(info.frontend).toBe(false); + expect(info.themeVersion).toMatch(/^[0-9a-f]{12}$/); + expect(describePluginFrontend(plugin).themeVersion).toBeNull(); + }); +}); diff --git a/src/backend/tests/plugins/bundled-index.test.ts b/src/backend/tests/plugins/bundled-index.test.ts index eff196e52..bbf30d5f4 100644 --- a/src/backend/tests/plugins/bundled-index.test.ts +++ b/src/backend/tests/plugins/bundled-index.test.ts @@ -3,7 +3,9 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import { + defaultLookPluginIds, initialPluginState, + isDefaultLookPlugin, onboardingDefaults, resetBundledIndexCache, } from "../../plugins/bundled-index.js"; @@ -103,3 +105,28 @@ describe("initialPluginState", () => { expect(initialPluginState("kept", "user", false)).toBe("disabled"); }); }); + +describe("default look", () => { + it("lists the plugins flagged as the default look", () => { + writeIndex({ + "some-layout": { defaultLook: true }, + "some-themes": { defaultLook: true, onboarding: { recommended: false } }, + other: { onboarding: { recommended: true } }, + }); + expect(defaultLookPluginIds()).toEqual(["some-layout", "some-themes"]); + expect(isDefaultLookPlugin("some-themes")).toBe(true); + expect(isDefaultLookPlugin("other")).toBe(false); + }); + + it("has none without an index", () => { + expect(defaultLookPluginIds()).toEqual([]); + }); + + it("starts the default look enabled even on a fresh install", () => { + writeIndex({ + "some-themes": { defaultLook: true, onboarding: { recommended: false } }, + }); + expect(initialPluginState("some-themes", "bundled", true)).toBe("enabled"); + expect(initialPluginState("some-themes", "user", true)).toBe("disabled"); + }); +}); diff --git a/src/backend/tests/plugins/ctx-audit.test.ts b/src/backend/tests/plugins/ctx-audit.test.ts index f28aaedc7..1e9ef3b9c 100644 --- a/src/backend/tests/plugins/ctx-audit.test.ts +++ b/src/backend/tests/plugins/ctx-audit.test.ts @@ -1,112 +1,159 @@ /** - * ctx.audit.record: a plugin writes an audit line under its own action name, - * and the user it names is always the runtime's actor, never one the plugin - * picked. + * ctx.audit.subscribe, list and exportBatches: audit:read gates reading, and + * subscribers see lines after they are accepted without delaying the writer. */ import { beforeEach, describe, expect, it, vi } from "vitest"; -const auditEntries: Array> = []; +const grants = new Map(); +const rows = vi.hoisted(() => ({ list: [] as Array> })); vi.mock("../../database/repositories/factory.js", () => ({ createCurrentPluginPermissionGrantRepository: () => ({ - listByPlugin: async () => [], + listByPlugin: async (pluginId: string) => + (grants.get(pluginId) ?? []).map((capability) => ({ + pluginId, + capability, + })), }), -})); - -vi.mock("../../utils/audit-logger.js", () => ({ - logAudit: async (entry: Record) => { - auditEntries.push(entry); - }, + createCurrentAuditLogRepository: () => ({ + listPage: async ({ limit }: { limit: number }) => ({ + logs: rows.list.slice(0, limit), + total: rows.list.length, + }), + listForExport: async ({ + limit, + offset, + }: { + limit: number; + offset: number; + }) => rows.list.slice(offset, offset + limit), + create: async () => {}, + }), + createCurrentUserRepository: () => ({ findById: async () => null }), })); import { createPluginContext, createPluginHandle } from "../../plugins/ctx.js"; -import { runAsActor } from "../../plugins/actor.js"; +import { invalidatePluginPermissionCache } from "../../plugins/permissions.js"; +import { emitAuditEntry } from "../../utils/audit-hooks.js"; import type { PluginManifest } from "@termix-ssh/plugin-sdk/manifest"; -function contextFor(pluginId: string) { +function contextFor(pluginId: string, capabilities: string[]) { const manifest = { id: pluginId, - name: "Audit Fixture", + name: pluginId, version: "1.0.0", description: "", author: { name: "test" }, license: "MIT", category: "Productivity", - engine: { termix: ">=2.9.0", api: "1" }, - capabilities: [], + engine: { termix: ">=26.10.3", api: "1" }, + capabilities, } as PluginManifest; const handle = createPluginHandle(pluginId, { activate: () => {} }); - return createPluginContext(manifest, handle); + return { ctx: createPluginContext(manifest, handle), handle }; } +const line = (id: number) => ({ + id, + timestamp: new Date(1_700_000_000_000 + id), + userId: null, + username: "root", + action: "login", + resourceType: "user", + resourceId: null, + resourceName: null, + details: null, + ipAddress: null, + userAgent: null, + success: true, + errorMessage: null, +}); + beforeEach(() => { - auditEntries.length = 0; + grants.clear(); + invalidatePluginPermissionCache(); + rows.list = [line(1), line(2), line(3)]; }); -describe("ctx.audit.record", () => { - it("writes the plugin's action for the acting user", async () => { - const ctx = contextFor("audit-fixture"); - await runAsActor("user-3", "request", () => - ctx.audit.record({ - action: "ssh_connect", - resourceType: "host", - resourceId: "12", - resourceName: "root@10.0.0.1:22", - success: true, - }), +describe("ctx.audit reading", () => { + it("refuses list and export without audit:read", async () => { + grants.set("demo", []); + const { ctx } = contextFor("demo", []); + + await expect(ctx.audit.list()).rejects.toThrow(/audit:read/); + await expect(ctx.audit.exportBatches().next()).rejects.toThrow( + /audit:read/, ); - expect(auditEntries).toEqual([ - expect.objectContaining({ - userId: "user-3", - action: "ssh_connect", - resourceType: "host", - resourceId: "12", - resourceName: "root@10.0.0.1:22", - details: "via plugin audit-fixture", - success: true, - }), - ]); + expect(() => ctx.audit.subscribe(() => {})).toThrow(/audit:read/); }); - it("falls back to the plugin as the resource and system as the user", async () => { - const ctx = contextFor("audit-fixture"); - await ctx.audit.record({ action: "cleanup", success: false }); - expect(auditEntries[0]).toMatchObject({ + it("lists stored lines", async () => { + grants.set("demo", ["audit:read"]); + const { ctx } = contextFor("demo", ["audit:read"]); + + const page = await ctx.audit.list({ limit: 2 }); + expect(page.total).toBe(3); + expect(page.entries.map((entry) => entry.id)).toEqual([1, 2]); + expect(page.entries[0].timestamp).toMatch(/^20\d\d-/); + }); + + it("exports in batches", async () => { + grants.set("demo", ["audit:read"]); + const { ctx } = contextFor("demo", ["audit:read"]); + + const sizes: number[] = []; + for await (const batch of ctx.audit.exportBatches({}, 2)) { + sizes.push(batch.length); + } + expect(sizes).toEqual([2, 1]); + }); + + it("delivers new lines to a subscriber until it unsubscribes", async () => { + grants.set("demo", ["audit:read"]); + const { ctx } = contextFor("demo", ["audit:read"]); + const seen: string[] = []; + + const stop = ctx.audit.subscribe((entry) => seen.push(entry.action)); + await new Promise((resolve) => setTimeout(resolve, 20)); + + emitAuditEntry({ userId: null, - username: "system", - resourceType: "plugin", - resourceId: "audit-fixture", - resourceName: "Audit Fixture", - success: false, - }); - }); -}); - -describe("ctx.audit.record with a request", () => { - it("records the request's IP address and user agent", async () => { - const ctx = contextFor("audit-fixture"); - await ctx.audit.record({ - action: "collab_room_end", + username: "root", + action: "one", + resourceType: "x", success: true, - request: { - ip: "203.0.113.4", - headers: { "user-agent": "TestAgent/1.0" }, - }, }); - expect(auditEntries[0]).toMatchObject({ - ipAddress: "203.0.113.4", - userAgent: "TestAgent/1.0", + await new Promise((resolve) => setTimeout(resolve, 5)); + stop(); + emitAuditEntry({ + userId: null, + username: "root", + action: "two", + resourceType: "x", + success: true, }); + await new Promise((resolve) => setTimeout(resolve, 5)); + + expect(seen).toEqual(["one"]); }); - it("falls back to the socket address on an upgrade request", async () => { - const ctx = contextFor("audit-fixture"); - await ctx.audit.record({ - action: "collab_join", + it("stops delivering when the plugin deactivates", async () => { + grants.set("demo", ["audit:read"]); + const { ctx, handle } = contextFor("demo", ["audit:read"]); + const seen: string[] = []; + ctx.audit.subscribe((entry) => seen.push(entry.action)); + await new Promise((resolve) => setTimeout(resolve, 20)); + + await handle.bag.disposeAll(); + emitAuditEntry({ + userId: null, + username: "root", + action: "late", + resourceType: "x", success: true, - request: { headers: {}, socket: { remoteAddress: "198.51.100.9" } }, }); - expect(auditEntries[0]).toMatchObject({ ipAddress: "198.51.100.9" }); + await new Promise((resolve) => setTimeout(resolve, 5)); + expect(seen).toEqual([]); }); }); diff --git a/src/backend/tests/plugins/ctx-credentials-keys.test.ts b/src/backend/tests/plugins/ctx-credentials-keys.test.ts new file mode 100644 index 000000000..42622564c --- /dev/null +++ b/src/backend/tests/plugins/ctx-credentials-keys.test.ts @@ -0,0 +1,179 @@ +/** + * ctx.credentials.publicKey and resolveKeyRefs, and the capability gate on + * ctx.ssh.exec. + */ + +import { beforeEach, describe, expect, it, vi } from "vitest"; +import ssh2 from "ssh2"; + +const grants = new Map(); +const auditEntries: Array> = []; +const state = vi.hoisted(() => ({ + permissions: new Set(["credentials.view"]), + credential: null as Record | null, + resolve: vi.fn(), +})); + +vi.mock("../../database/repositories/factory.js", () => ({ + createCurrentPluginPermissionGrantRepository: () => ({ + listByPlugin: async (pluginId: string) => + (grants.get(pluginId) ?? []).map((capability) => ({ + pluginId, + capability, + })), + }), + createCurrentHostResolutionRepository: () => ({ + findCredentialByIdForUser: async () => state.credential, + }), +})); +vi.mock("../../utils/audit-logger.js", () => ({ + logAudit: async (entry: Record) => { + auditEntries.push(entry); + }, +})); +vi.mock("../../utils/permission-manager.js", () => ({ + PermissionManager: { + getInstance: () => ({ + hasPermission: async (_user: string, name: string) => + state.permissions.has(name), + }), + }, +})); +vi.mock("../../hosts/stored-ssh-key.js", () => ({ + resolveKeyReferences: state.resolve, +})); + +import { createPluginContext, createPluginHandle } from "../../plugins/ctx.js"; +import { invalidatePluginPermissionCache } from "../../plugins/permissions.js"; +import { runAsActor } from "../../plugins/actor.js"; +import type { PluginManifest } from "@termix-ssh/plugin-sdk/manifest"; + +function contextFor(capabilities: string[]) { + const manifest = { + id: "demo", + name: "demo", + version: "1.0.0", + description: "", + author: { name: "test" }, + license: "MIT", + category: "Productivity", + engine: { termix: ">=26.10.3", api: "1" }, + capabilities, + } as PluginManifest; + return createPluginContext( + manifest, + createPluginHandle("demo", { activate: () => {} }), + ); +} + +const asUser = (fn: () => Promise) => runAsActor("user-1", "request", fn); + +const pair = ssh2.utils.generateKeyPairSync("ed25519"); + +beforeEach(() => { + grants.clear(); + auditEntries.length = 0; + invalidatePluginPermissionCache(); + state.permissions = new Set(["credentials.view"]); + state.credential = null; + state.resolve.mockReset(); +}); + +describe("ctx.credentials.publicKey", () => { + it("refuses without credentials:use", async () => { + grants.set("demo", []); + const ctx = contextFor([]); + await expect(asUser(() => ctx.credentials.publicKey(1))).rejects.toThrow( + /credentials:use/, + ); + }); + + it("refuses when the user lacks credentials.view", async () => { + grants.set("demo", ["credentials:use"]); + state.permissions = new Set(); + const ctx = contextFor(["credentials:use"]); + await expect(asUser(() => ctx.credentials.publicKey(1))).rejects.toThrow( + /credentials.view/, + ); + expect(auditEntries.at(-1)).toMatchObject({ success: false }); + }); + + it("returns null for a missing credential or one that is not a key", async () => { + grants.set("demo", ["credentials:use"]); + const ctx = contextFor(["credentials:use"]); + expect(await asUser(() => ctx.credentials.publicKey(1))).toBeNull(); + state.credential = { id: 1, authType: "password" }; + expect(await asUser(() => ctx.credentials.publicKey(1))).toBeNull(); + }); + + it("returns the stored public key", async () => { + grants.set("demo", ["credentials:use"]); + state.credential = { + id: 1, + authType: "key", + publicKey: " ssh-ed25519 AAA ", + }; + const ctx = contextFor(["credentials:use"]); + expect(await asUser(() => ctx.credentials.publicKey(1))).toBe( + "ssh-ed25519 AAA", + ); + }); + + it("derives it from the private key behind a reference", async () => { + grants.set("demo", ["credentials:use"]); + state.credential = { id: 1, authType: "key", key: "op://vault/key" }; + state.resolve.mockResolvedValue({ key: pair.private }); + const ctx = contextFor(["credentials:use"]); + const result = await asUser(() => ctx.credentials.publicKey(1)); + expect(result).toMatch(/^ssh-ed25519 /); + expect(state.resolve).toHaveBeenCalledWith( + "user-1", + "op://vault/key", + null, + ); + }); + + it("rejects a bad id", async () => { + grants.set("demo", ["credentials:use"]); + const ctx = contextFor(["credentials:use"]); + await expect(asUser(() => ctx.credentials.publicKey(0))).rejects.toThrow( + /credential id/, + ); + }); +}); + +describe("ctx.credentials.resolveKeyRefs", () => { + it("refuses with only credentials:use", async () => { + grants.set("demo", ["credentials:use"]); + const ctx = contextFor(["credentials:use"]); + await expect( + asUser(() => ctx.credentials.resolveKeyRefs("op://a")), + ).rejects.toThrow(/credentials:read/); + expect(state.resolve).not.toHaveBeenCalled(); + }); + + it("resolves for the acting user and audits", async () => { + grants.set("demo", ["credentials:read"]); + state.resolve.mockResolvedValue({ key: "KEY", passphrase: "PW" }); + const ctx = contextFor(["credentials:read"]); + const result = await asUser(() => + ctx.credentials.resolveKeyRefs("op://a", "op://b"), + ); + expect(result).toEqual({ key: "KEY", passphrase: "PW" }); + expect(state.resolve).toHaveBeenCalledWith("user-1", "op://a", "op://b"); + expect(auditEntries.at(-1)).toMatchObject({ + action: "plugin_credentials_resolve_key", + success: true, + }); + }); +}); + +describe("ctx.ssh.exec", () => { + it("refuses without ssh:connect and credentials:use", async () => { + grants.set("demo", []); + const ctx = contextFor([]); + await expect(asUser(() => ctx.ssh.exec(1, "id"))).rejects.toThrow( + /ssh:connect/, + ); + }); +}); diff --git a/src/backend/tests/plugins/ctx-credentials.test.ts b/src/backend/tests/plugins/ctx-credentials.test.ts index 3eb8bc505..2f07a00b3 100644 --- a/src/backend/tests/plugins/ctx-credentials.test.ts +++ b/src/backend/tests/plugins/ctx-credentials.test.ts @@ -461,6 +461,40 @@ describe("ctx.credentials.registerSecretResolver", () => { }); }); +describe("ctx.credentials.list", () => { + beforeEach(() => { + state.userCredentials.set("owner", [ + { id: 3, name: "pw", authType: "password", password: "secret" }, + { id: 4, name: "key", authType: "key", privateKey: "PRIVATE KEY" }, + ]); + }); + + it("refuses without credentials:use", async () => { + grants.set("demo", []); + const ctx = contextFor([]); + await expect( + runAsActor("owner", "request", () => ctx.credentials.list()), + ).rejects.toThrow(/credentials:use/); + }); + + it("lists the actor's credentials without secrets", async () => { + grants.set("demo", ["credentials:use"]); + const ctx = contextFor(["credentials:use"]); + const list = await runAsActor("owner", "request", () => + ctx.credentials.list(), + ); + expect(list).toEqual([ + { id: 3, name: "pw", username: null, authType: "password", folder: null }, + { id: 4, name: "key", username: null, authType: "key", folder: null }, + ]); + expect(JSON.stringify(list)).not.toMatch(/secret|PRIVATE/); + expect(auditEntries.at(-1)).toMatchObject({ + action: "plugin_credentials_list", + success: true, + }); + }); +}); + describe("ctx.credentials.listSshKeys", () => { const pair = ssh2.utils.generateKeyPairSync("rsa", { bits: 2048 }); const derived = (() => { diff --git a/src/backend/tests/plugins/ctx-desktop-local.test.ts b/src/backend/tests/plugins/ctx-desktop-local.test.ts new file mode 100644 index 000000000..6e959c524 --- /dev/null +++ b/src/backend/tests/plugins/ctx-desktop-local.test.ts @@ -0,0 +1,248 @@ +/** + * ctx.desktop.local: desktop:local is checked and audited, and nothing + * touches the machine unless the backend runs embedded in the desktop app. + */ + +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const grants = new Map(); +const auditEntries: Array> = []; + +vi.mock("../../database/repositories/factory.js", () => ({ + createCurrentPluginPermissionGrantRepository: () => ({ + listByPlugin: async (pluginId: string) => + (grants.get(pluginId) ?? []).map((capability) => ({ + pluginId, + capability, + })), + }), +})); + +vi.mock("../../utils/audit-logger.js", () => ({ + logAudit: async (entry: Record) => { + auditEntries.push(entry); + }, +})); + +const bridge = vi.hoisted(() => ({ + requestFromElectronMain: vi.fn(async () => ({ success: true })), +})); +vi.mock("../../utils/electron-ipc-bridge.js", () => ({ + isElectronIpcAvailable: () => process.env.ELECTRON_EMBEDDED === "true", + requestFromElectronMain: bridge.requestFromElectronMain, +})); + +vi.mock("../../sync/client/link-store.js", () => ({ + getLink: async () => ({ + serverUrl: "https://termix.example.test", + sessionToken: "token", + }), +})); + +import { createPluginContext, createPluginHandle } from "../../plugins/ctx.js"; +import { invalidatePluginPermissionCache } from "../../plugins/permissions.js"; +import type { PluginManifest } from "@termix-ssh/plugin-sdk/manifest"; + +function contextFor(pluginId: string, capabilities: string[]) { + const manifest = { + id: pluginId, + name: pluginId, + version: "1.0.0", + description: "", + author: { name: "test" }, + license: "MIT", + category: "Productivity", + engine: { termix: ">=26.10.3", api: "1" }, + capabilities, + } as PluginManifest; + const handle = createPluginHandle(pluginId, { activate: () => {} }); + return createPluginContext(manifest, handle); +} + +let dir: string; +const previous = process.env.ELECTRON_EMBEDDED; + +beforeEach(() => { + grants.clear(); + auditEntries.length = 0; + invalidatePluginPermissionCache(); + bridge.requestFromElectronMain.mockClear(); + process.env.ELECTRON_EMBEDDED = "true"; + dir = fs.mkdtempSync(path.join(os.tmpdir(), "termix-local-")); +}); + +afterEach(() => { + fs.rmSync(dir, { recursive: true, force: true }); + if (previous === undefined) delete process.env.ELECTRON_EMBEDDED; + else process.env.ELECTRON_EMBEDDED = previous; +}); + +describe("ctx.desktop.local", () => { + it("refuses without desktop:local and audits the refusal", async () => { + grants.set("demo", []); + const ctx = contextFor("demo", []); + + await expect(ctx.desktop.local.fs.list(dir)).rejects.toThrow( + /desktop:local/, + ); + expect(auditEntries.at(-1)).toMatchObject({ success: false }); + }); + + it("refuses when granted but not declared", async () => { + grants.set("demo", ["desktop:local"]); + const ctx = contextFor("demo", []); + + await expect(ctx.desktop.local.fs.list(dir)).rejects.toThrow( + /desktop:local/, + ); + }); + + it("refuses outside the desktop app even with the capability", async () => { + grants.set("demo", ["desktop:local"]); + process.env.ELECTRON_EMBEDDED = "false"; + const ctx = contextFor("demo", ["desktop:local"]); + + expect(ctx.desktop.local.available()).toBe(false); + await expect(ctx.desktop.local.fs.list(dir)).rejects.toThrow(/desktop app/); + }); + + it("lists and audits once per action", async () => { + grants.set("demo", ["desktop:local"]); + fs.writeFileSync(path.join(dir, "a.txt"), "hi"); + const ctx = contextFor("demo", ["desktop:local"]); + + const first = await ctx.desktop.local.fs.list(dir); + await ctx.desktop.local.fs.list(dir); + + expect(first.entries.map((entry) => entry.name)).toEqual(["a.txt"]); + expect( + auditEntries.filter( + (entry) => entry.action === "plugin_desktop_local_fs", + ), + ).toHaveLength(1); + }); + + it("rejects relative paths and unsafe names", async () => { + grants.set("demo", ["desktop:local"]); + const ctx = contextFor("demo", ["desktop:local"]); + + await expect(ctx.desktop.local.fs.list("relative/path")).rejects.toThrow( + /absolute/, + ); + await expect(ctx.desktop.local.fs.mkdir(dir, "../evil")).rejects.toThrow( + /Invalid/, + ); + }); + + it("never renames onto an existing name", async () => { + grants.set("demo", ["desktop:local"]); + fs.writeFileSync(path.join(dir, "a"), "1"); + fs.writeFileSync(path.join(dir, "b"), "2"); + const ctx = contextFor("demo", ["desktop:local"]); + + await expect( + ctx.desktop.local.fs.rename(path.join(dir, "a"), "b"), + ).rejects.toThrow(/already exists/); + expect(fs.readFileSync(path.join(dir, "b"), "utf8")).toBe("2"); + }); + + it("keeps downloads inside the chosen folder and publishes on success", async () => { + grants.set("demo", ["desktop:local"]); + const ctx = contextFor("demo", ["desktop:local"]); + + await expect( + ctx.desktop.local.fs.openDownload({ + rootPath: dir, + destPath: path.join(dir, "..", "escape.txt"), + transferId: "t1", + }), + ).rejects.toThrow(/outside/); + + const download = await ctx.desktop.local.fs.openDownload({ + rootPath: dir, + destPath: path.join(dir, "ok.txt"), + transferId: "t2", + }); + download.stream.write("data"); + expect(await download.publish()).toBe(path.join(dir, "ok.txt")); + expect(fs.readFileSync(path.join(dir, "ok.txt"), "utf8")).toBe("data"); + expect(fs.readdirSync(dir)).toEqual(["ok.txt"]); + }); + + it("removes the partial file when a download is aborted", async () => { + grants.set("demo", ["desktop:local"]); + const ctx = contextFor("demo", ["desktop:local"]); + + const download = await ctx.desktop.local.fs.openDownload({ + rootPath: dir, + destPath: path.join(dir, "gone.txt"), + transferId: "t3", + }); + download.stream.write("partial"); + await download.abort(); + expect(fs.readdirSync(dir)).toEqual([]); + }); + + it("sends trash and open through Electron main and audits each call", async () => { + grants.set("demo", ["desktop:local"]); + const ctx = contextFor("demo", ["desktop:local"]); + const target = path.join(dir, "x.txt"); + fs.writeFileSync(target, "x"); + + await expect(ctx.desktop.local.fs.trash([target])).resolves.toEqual({ + trashed: 1, + failed: [], + }); + expect(bridge.requestFromElectronMain).toHaveBeenCalledWith( + "shell-trash-item", + { path: target }, + ); + + await ctx.desktop.local.fs.open(target); + await ctx.desktop.local.fs.open(target); + expect( + auditEntries.filter( + (entry) => entry.action === "plugin_desktop_local_open", + ), + ).toHaveLength(2); + }); + + it("only calls the plugin's own routes", async () => { + grants.set("demo", ["desktop:local"]); + const ctx = contextFor("demo", ["desktop:local"]); + + for (const bad of [ + "relative", + "//evil.example/x", + "/../other", + "/a/../../b", + String.raw`/a\b`, + ]) { + await expect(ctx.desktop.local.pluginFetch("local", bad)).rejects.toThrow( + /Invalid plugin route path/, + ); + } + await expect( + ctx.desktop.local.pluginFetch("elsewhere" as never, "/ok"), + ).rejects.toThrow(/Unknown origin|No acting user/); + }); + + it("reports the link and the embedded backend port", async () => { + grants.set("demo", ["desktop:local"]); + process.env.EMBEDDED_BACKEND_PORT = "31234"; + const ctx = contextFor("demo", ["desktop:local"]); + + const info = await ctx.desktop.local.info(); + expect(info.backendPort).toBe(31234); + expect(info.link).toMatchObject({ + serverUrl: "https://termix.example.test", + token: "token", + allowInvalidCertificate: false, + }); + expect(info.link?.headers.Cookie).toBe("jwt=token"); + delete process.env.EMBEDDED_BACKEND_PORT; + }); +}); diff --git a/src/backend/tests/plugins/ctx-host-import.test.ts b/src/backend/tests/plugins/ctx-host-import.test.ts new file mode 100644 index 000000000..fa1952a2c --- /dev/null +++ b/src/backend/tests/plugins/ctx-host-import.test.ts @@ -0,0 +1,264 @@ +/** + * ctx.hosts.importMany, importSshConfig, exportMany, registerImportNormalizer: + * capability checks, core permission checks, audit lines and the registry key. + */ + +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const grants = new Map(); +const auditEntries: Array> = []; +const state = vi.hoisted(() => ({ + permissions: new Set(["hosts.create", "hosts.edit"]), +})); +const importer = vi.hoisted(() => ({ + runBulkImport: vi.fn(), + runSshConfigImport: vi.fn(), + exportAllHosts: vi.fn(), +})); + +vi.mock("../../database/repositories/factory.js", () => ({ + createCurrentPluginPermissionGrantRepository: () => ({ + listByPlugin: async (pluginId: string) => + (grants.get(pluginId) ?? []).map((capability) => ({ + pluginId, + capability, + })), + }), +})); +vi.mock("../../utils/audit-logger.js", () => ({ + logAudit: async (entry: Record) => { + auditEntries.push(entry); + }, +})); +vi.mock("../../utils/permission-manager.js", () => ({ + PermissionManager: { + getInstance: () => ({ + hasPermission: async (_user: string, name: string) => + state.permissions.has(name), + }), + }, +})); +vi.mock("../../hosts/import/host-import.js", () => ({ + runBulkImport: importer.runBulkImport, + runSshConfigImport: importer.runSshConfigImport, +})); +vi.mock("../../hosts/import/host-export.js", () => ({ + exportAllHosts: importer.exportAllHosts, +})); +vi.mock("../../utils/database-save-trigger.js", () => ({ + DatabaseSaveTrigger: { batched: (fn: () => unknown) => fn }, +})); + +import { createPluginContext, createPluginHandle } from "../../plugins/ctx.js"; +import { invalidatePluginPermissionCache } from "../../plugins/permissions.js"; +import { runAsActor } from "../../plugins/actor.js"; +import * as registry from "../../plugins/registry.js"; +import type { PluginManifest } from "@termix-ssh/plugin-sdk/manifest"; + +function contextFor(pluginId: string, capabilities: string[]) { + const manifest = { + id: pluginId, + name: pluginId, + version: "1.0.0", + description: "", + author: { name: "test" }, + license: "MIT", + category: "Productivity", + engine: { termix: ">=26.10.3", api: "1" }, + capabilities, + } as PluginManifest; + const handle = createPluginHandle(pluginId, { activate: () => {} }); + return { ctx: createPluginContext(manifest, handle), handle }; +} + +const asUser = (fn: () => Promise) => runAsActor("user-1", "request", fn); + +const outcome = { + success: 1, + updated: 0, + skipped: 0, + failed: 0, + errors: [], + createdHostIds: [5], +}; + +beforeEach(() => { + grants.clear(); + auditEntries.length = 0; + invalidatePluginPermissionCache(); + state.permissions = new Set(["hosts.create", "hosts.edit"]); + importer.runBulkImport.mockReset().mockResolvedValue(outcome); + importer.runSshConfigImport.mockReset().mockResolvedValue(outcome); + importer.exportAllHosts.mockReset().mockResolvedValue({ hosts: [{ id: 1 }] }); +}); + +describe("ctx.hosts.importMany", () => { + it("refuses without hosts:write", async () => { + grants.set("demo", []); + const { ctx } = contextFor("demo", []); + await expect( + asUser(() => ctx.hosts.importMany([{ ip: "a" }])), + ).rejects.toThrow(/hosts:write/); + expect(importer.runBulkImport).not.toHaveBeenCalled(); + }); + + it("needs credentials:write only when credentials are passed", async () => { + grants.set("demo", ["hosts:write"]); + const { ctx } = contextFor("demo", ["hosts:write"]); + await expect( + asUser(() => ctx.hosts.importMany([{ ip: "a" }])), + ).resolves.toMatchObject({ success: 1 }); + await expect( + asUser(() => + ctx.hosts.importMany([{ ip: "a" }], { credentials: [{ alias: "k" }] }), + ), + ).rejects.toThrow(/credentials:write/); + }); + + it("refuses when the acting user lacks the core permissions", async () => { + grants.set("demo", ["hosts:write"]); + state.permissions = new Set(["hosts.create"]); + const { ctx } = contextFor("demo", ["hosts:write"]); + await expect( + asUser(() => ctx.hosts.importMany([{ ip: "a" }])), + ).rejects.toThrow(/hosts.edit/); + expect(importer.runBulkImport).not.toHaveBeenCalled(); + expect(auditEntries.at(-1)).toMatchObject({ + action: "plugin_hosts_import", + success: false, + }); + }); + + it("needs an acting user", async () => { + grants.set("demo", ["hosts:write"]); + const { ctx } = contextFor("demo", ["hosts:write"]); + await expect(ctx.hosts.importMany([{ ip: "a" }])).rejects.toThrow( + /acting user/, + ); + }); + + it("passes the options through and audits the counts", async () => { + grants.set("demo", ["hosts:write"]); + const { ctx } = contextFor("demo", ["hosts:write"]); + const result = await asUser(() => + ctx.hosts.importMany([{ ip: "a" }], { atomic: true }), + ); + expect(result.createdHostIds).toEqual([5]); + expect(importer.runBulkImport).toHaveBeenCalledWith( + "user-1", + expect.objectContaining({ atomic: true, hosts: [{ ip: "a" }] }), + ); + expect(auditEntries.at(-1)).toMatchObject({ + action: "plugin_hosts_import", + success: true, + }); + }); + + it("surfaces the importer's request errors and audits the failure", async () => { + grants.set("demo", ["hosts:write"]); + importer.runBulkImport.mockRejectedValue(new Error("Maximum 100 hosts")); + const { ctx } = contextFor("demo", ["hosts:write"]); + await expect(asUser(() => ctx.hosts.importMany([]))).rejects.toThrow( + /Maximum 100/, + ); + expect(auditEntries.at(-1)).toMatchObject({ success: false }); + }); +}); + +describe("ctx.hosts.importSshConfig", () => { + it("refuses without hosts:write and runs with it", async () => { + grants.set("demo", []); + const { ctx } = contextFor("demo", []); + await expect( + asUser(() => ctx.hosts.importSshConfig("Host a")), + ).rejects.toThrow(/hosts:write/); + + grants.set("demo", ["hosts:write"]); + invalidatePluginPermissionCache(); + const { ctx: allowed } = contextFor("demo", ["hosts:write"]); + await asUser(() => allowed.hosts.importSshConfig("Host a")); + expect(importer.runSshConfigImport).toHaveBeenCalledWith( + "user-1", + expect.objectContaining({ content: "Host a" }), + ); + }); +}); + +describe("ctx.hosts.exportMany", () => { + it("refuses with only hosts:write and audits the refusal", async () => { + grants.set("demo", ["hosts:write"]); + const { ctx } = contextFor("demo", ["hosts:write"]); + await expect(asUser(() => ctx.hosts.exportMany())).rejects.toThrow( + /hosts:export/, + ); + expect(importer.exportAllHosts).not.toHaveBeenCalled(); + expect(auditEntries.at(-1)).toMatchObject({ success: false }); + }); + + it("exports with secrets by default and audits it", async () => { + grants.set("demo", ["hosts:export"]); + const { ctx } = contextFor("demo", ["hosts:export"]); + await expect(asUser(() => ctx.hosts.exportMany())).resolves.toEqual({ + hosts: [{ id: 1 }], + }); + expect(importer.exportAllHosts).toHaveBeenCalledWith( + "user-1", + false, + undefined, + ); + expect(auditEntries.at(-1)).toMatchObject({ + action: "plugin_export_hosts", + success: true, + }); + expect(String(auditEntries.at(-1)?.details)).toMatch(/with secrets/); + }); + + it("passes share mode through", async () => { + grants.set("demo", ["hosts:export"]); + const { ctx } = contextFor("demo", ["hosts:export"]); + await asUser(() => ctx.hosts.exportMany({ share: true })); + expect(importer.exportAllHosts).toHaveBeenCalledWith( + "user-1", + true, + undefined, + ); + expect(String(auditEntries.at(-1)?.details)).toMatch(/without secrets/); + }); + + it("passes the host ids filter through", async () => { + grants.set("demo", ["hosts:export"]); + const { ctx } = contextFor("demo", ["hosts:export"]); + await asUser(() => ctx.hosts.exportMany({ hostIds: [4, 5] })); + expect(importer.exportAllHosts).toHaveBeenCalledWith( + "user-1", + false, + [4, 5], + ); + }); +}); + +describe("ctx.hosts.registerImportNormalizer", () => { + it("needs hosts:write", () => { + const { ctx } = contextFor("demo", []); + expect(() => ctx.hosts.registerImportNormalizer(() => null)).toThrow( + /hosts:write/, + ); + }); + + it("registers under the key the import path reads and removes it on deactivate", async () => { + const { ctx, handle } = contextFor("demo", ["hosts:write"]); + const normalizer = () => ({ a: 1 }); + ctx.hosts.registerImportNormalizer(normalizer); + expect(registry.consume("demo.hostImportNormalizer")).toBe(normalizer); + + await handle.bag.disposeAll(); + expect(registry.consume("demo.hostImportNormalizer")).toBeUndefined(); + }); + + it("returns a remover", () => { + const { ctx } = contextFor("demo", ["hosts:write"]); + const remove = ctx.hosts.registerImportNormalizer(() => null); + remove(); + expect(registry.consume("demo.hostImportNormalizer")).toBeUndefined(); + }); +}); diff --git a/src/backend/tests/plugins/ctx-hosts.test.ts b/src/backend/tests/plugins/ctx-hosts.test.ts index e9d40c753..3f3c7bff1 100644 --- a/src/backend/tests/plugins/ctx-hosts.test.ts +++ b/src/backend/tests/plugins/ctx-hosts.test.ts @@ -27,6 +27,11 @@ const h = vi.hoisted(() => ({ statusPortsRemoved: [] as string[], userPermissions: new Set(), deleted: [] as Array<{ userId: string; hostId: number }>, + existingAccess: [] as Array<{ id: number }>, + revoked: [] as number[], + protocolWrites: [] as Array<{ hostId: number; protocols: string[] }>, + folderCalls: [] as Array, + folderEmpty: true, roles: [] as Array<{ id: number; name: string; @@ -85,10 +90,23 @@ vi.mock("../../database/repositories/factory.js", () => ({ h.upserts.push(input); return { id: h.upserts.length, created: true }; }, + listHostAccess: async () => h.existingAccess, + revokeHostAccess: async (accessId: number) => { + h.revoked.push(accessId); + }, }), createCurrentUserRepository: () => ({ listAll: async () => h.users, }), + createCurrentHostFolderRepository: () => ({ + upsertMetadata: async (...args: unknown[]) => { + h.folderCalls.push(["ensure", ...args]); + }, + deleteEmptyFolderRecords: async (...args: unknown[]) => { + h.folderCalls.push(["remove", ...args]); + return h.folderEmpty; + }, + }), createCurrentHostRepository: () => ({ createEncryptedForUser: async ( userId: string, @@ -110,14 +128,14 @@ vi.mock("../../database/repositories/factory.js", () => ({ listDecryptedByUserId: async () => h.ownedHosts, }), })); -vi.mock("../../services/recent-activity.js", () => ({ - recordRecentActivity: async ( - userId: string, - entry: Record, - ) => { - h.activities.push({ userId, ...entry }); - return { status: "logged", id: 1 }; +vi.mock("../../plugins/activity-provider.js", () => ({ + recentActivity: { + record: async (userId: string, entry: Record) => { + h.activities.push({ userId, ...entry }); + return { status: "logged", id: 1 }; + }, }, + provideActivity: vi.fn(() => () => {}), })); vi.mock("../../hosts/host-session-status.js", () => ({ hostSessionStatus: { @@ -127,8 +145,9 @@ vi.mock("../../hosts/host-session-status.js", () => ({ }, }, })); -vi.mock("../../hosts/status/host-status-service.js", () => ({ - hostStatusService: { +vi.mock("../../plugins/host-status-provider.js", () => ({ + provideHostStatus: vi.fn(() => () => {}), + hostStatus: { get: (hostId: number) => hostId === 4 ? { status: "online", lastChecked: "t" } : null, check: async (hostId: number) => ({ @@ -144,6 +163,13 @@ vi.mock("../../hosts/status/host-status-service.js", () => ({ }, })); vi.mock("../../database/routes/host-plugin-settings.js", () => ({ + hostSettingsPlugins: () => [ + { + id: "metrics", + contributes: { settings: { host: { enableKey: "enableMetrics" } } }, + }, + { id: "noswitch", contributes: { settings: { host: { fields: [] } } } }, + ], loadHostPluginSettings: async (hostIds: number[]) => new Map( hostIds.map((id) => [ @@ -197,6 +223,25 @@ vi.mock("../../hosts/defaults/overrides.js", () => ({ changeHostOverrides: async () => {}, })); +vi.mock("../../database/routes/rbac.js", () => ({ + pruneHostGrants: async () => 0, +})); +vi.mock("../../hosts/usable-credential.js", () => ({ + findUsableCredential: async (credentialId: number) => + credentialId === 5 ? { id: 5 } : null, +})); +vi.mock("../../hosts/protocol-auth/protocol-auth.js", () => ({ + readProtocolAuthPayload: (data: { protocolAuth: Record }) => + new Map(Object.entries(data.protocolAuth)), + writeProtocolAuth: async ( + _ownerId: string, + hostId: number, + patch: Map, + ) => { + h.protocolWrites.push({ hostId, protocols: [...patch.keys()] }); + }, +})); + vi.mock("../../hosts/delete-host.js", () => ({ deleteOwnedHost: async (userId: string, hostId: number) => { h.deleted.push({ userId, hostId }); @@ -208,6 +253,8 @@ 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"); +const { clearHostLocks, hostLock, provideHostLocks } = + await import("../../hosts/locks.js"); function manifest(capabilities: string[]) { return { @@ -235,6 +282,10 @@ beforeEach(() => { h.roles = []; h.userPermissions = new Set(); h.deleted = []; + h.existingAccess = []; + h.revoked = []; + h.protocolWrites = []; + clearHostLocks(); }); describe("ctx.hosts", () => { @@ -704,3 +755,262 @@ describe("ctx.hosts.status", () => { expect(h.statusPortsRemoved).toEqual(["rdp"]); }); }); + +describe("ctx.hosts secrets and protocol logins", () => { + it("stores secrets on create and writes protocol logins", async () => { + h.granted = new Set(["hosts:write"]); + const hosts = createPluginHosts({ + manifest: manifest(["hosts:write"]), + audit: vi.fn(async () => {}), + }); + const created = await hosts.create({ + name: "rdp", + ip: "10.0.0.9", + port: 3389, + username: "", + authType: "password", + password: "s3cret", + sudoPassword: "", + protocolAuth: { rdp: { authType: "direct", username: "admin" } }, + }); + expect(h.created[0]).toMatchObject({ + password: "s3cret", + sudoPassword: null, + }); + expect(created).not.toHaveProperty("password"); + expect(h.protocolWrites).toEqual([{ hostId: 99, protocols: ["rdp"] }]); + }); + + it("refuses a protocol credential the owner cannot use", async () => { + h.granted = new Set(["hosts:write"]); + const hosts = createPluginHosts({ + manifest: manifest(["hosts:write"]), + audit: vi.fn(async () => {}), + }); + await expect( + hosts.create({ + name: "vnc", + ip: "10.0.0.9", + port: 5900, + username: "", + authType: "password", + protocolAuth: { vnc: { authType: "credential", credentialId: 6 } }, + }), + ).rejects.toThrow(/credential 6/); + }); + + it("refuses a secret that is not a string", async () => { + h.granted = new Set(["hosts:write"]); + const hosts = createPluginHosts({ + manifest: manifest(["hosts:write"]), + audit: vi.fn(async () => {}), + }); + await expect(hosts.update(5, { password: 12 as never })).rejects.toThrow( + /password/, + ); + }); +}); + +describe("ctx.hosts pluginEnable", () => { + it("writes each plugin's switch under its enableKey", async () => { + h.granted = new Set(["hosts:write"]); + h.importRows = []; + const hosts = createPluginHosts({ + manifest: manifest(["hosts:write"]), + audit: vi.fn(async () => {}), + }); + await hosts.create({ + name: "n", + ip: "10.0.0.1", + port: 22, + username: "root", + authType: "password", + pluginEnable: { metrics: false }, + }); + expect(h.importRows).toEqual([ + { hostId: 99, row: { metrics: { enableMetrics: false } } }, + ]); + }); + + it("refuses a plugin with no switch", async () => { + h.granted = new Set(["hosts:write"]); + const hosts = createPluginHosts({ + manifest: manifest(["hosts:write"]), + audit: vi.fn(async () => {}), + }); + await expect( + hosts.update(5, { pluginEnable: { noswitch: true } }), + ).rejects.toThrow(/no host switch/); + expect(h.updates).toHaveLength(0); + }); +}); + +describe("ctx.hosts.setShares", () => { + it("upserts the listed grants and revokes the rest", async () => { + h.granted = new Set(["hosts:write"]); + h.existingAccess = [{ id: 1 }, { id: 7 }]; + const hosts = createPluginHosts({ + manifest: manifest(["hosts:write"]), + audit: vi.fn(async () => {}), + }); + const result = await hosts.setShares(3, [ + { target: { type: "role", id: 2 }, permissionLevel: "connect" }, + ]); + expect(result).toEqual({ hostId: 3, shared: true }); + expect(h.upserts).toEqual([ + expect.objectContaining({ + hostId: 3, + targetType: "role", + targetRoleId: 2, + permissionLevel: "connect", + expiresAt: null, + }), + ]); + expect(h.revoked).toEqual([7]); + }); + + it("only works for the host owner", async () => { + h.granted = new Set(["hosts:write"]); + h.ownerId = "someone-else"; + const hosts = createPluginHosts({ + manifest: manifest(["hosts:write"]), + audit: vi.fn(async () => {}), + }); + expect(await hosts.setShares(3, [])).toEqual({ + hostId: 3, + shared: false, + reason: "forbidden", + }); + expect(h.revoked).toEqual([]); + }); + + it("refuses a bad level", async () => { + h.granted = new Set(["hosts:write"]); + const hosts = createPluginHosts({ + manifest: manifest(["hosts:write"]), + audit: vi.fn(async () => {}), + }); + await expect( + hosts.setShares(3, [ + { target: { type: "user", id: "u2" }, permissionLevel: "own" as never }, + ]), + ).rejects.toThrow(/share level/); + }); +}); + +describe("ctx.hosts.locks", () => { + const provider = (locked: number[], allowManualCreate = true) => ({ + isLocked: (hostId: number) => locked.includes(hostId), + note: () => "From hosts.yaml", + allowManualCreate: () => allowManualCreate, + }); + + it("needs hosts:lock", () => { + const hosts = createPluginHosts({ + manifest: manifest([]), + audit: vi.fn(async () => {}), + }); + expect(() => hosts.locks.provide(provider([1]))).toThrow( + PluginCapabilityError, + ); + }); + + it("locks hosts until the plugin removes its provider", () => { + const hosts = createPluginHosts({ + manifest: manifest(["hosts:lock"]), + audit: vi.fn(async () => {}), + }); + const remove = hosts.locks.provide(provider([4])); + expect(hostLock(4)).toEqual({ + pluginId: "fixture", + pluginName: "Fixture", + note: "From hosts.yaml", + }); + expect(hostLock(5)).toBeNull(); + remove(); + expect(hostLock(4)).toBeNull(); + }); + + it("blocks other plugins on a locked host but not the plugin that locks it", async () => { + h.granted = new Set(["hosts:write"]); + h.updateResult = { id: 4, name: "x" }; + h.userPermissions = new Set(["hosts.delete"]); + provideHostLocks("hosts-file", "Hosts File", provider([4], false)); + const other = createPluginHosts({ + manifest: manifest(["hosts:write"]), + audit: vi.fn(async () => {}), + }); + await expect(other.update(4, { name: "y" })).rejects.toThrow( + /managed by Hosts File/, + ); + await expect(other.delete(4)).rejects.toThrow(/managed by Hosts File/); + await expect( + other.share(4, [{ type: "user", id: "u2" }], "view"), + ).rejects.toThrow(/managed by Hosts File/); + await expect( + other.create({ + name: "n", + ip: "10.0.0.1", + port: 22, + username: "root", + authType: "password", + }), + ).rejects.toThrow(/new hosts can't be added/); + expect(h.updates).toHaveLength(0); + expect(h.deleted).toHaveLength(0); + + const owner = createPluginHosts({ + manifest: { + id: "hosts-file", + name: "Hosts File", + version: "1.0.0", + capabilities: ["hosts:write"], + } as never, + audit: vi.fn(async () => {}), + }); + await owner.update(4, { name: "y" }); + expect(h.updates).toHaveLength(1); + }); +}); + +describe("ctx.hosts.folders", () => { + beforeEach(() => { + h.granted = new Set(["hosts:write"]); + h.actor = "user-1"; + h.folderCalls = []; + h.folderEmpty = true; + }); + + it("needs hosts:write", async () => { + h.granted = new Set(); + const hosts = createPluginHosts({ + manifest: manifest(["hosts:write"]), + audit: vi.fn(async () => {}), + }); + await expect(hosts.folders.ensure("Site")).rejects.toThrow( + PluginCapabilityError, + ); + }); + + it("creates the folder row for the acting user with its style", async () => { + const hosts = createPluginHosts({ + manifest: manifest(["hosts:write"]), + audit: vi.fn(async () => {}), + }); + await hosts.folders.ensure("Site", { icon: "cloud-cog" }); + expect(h.folderCalls).toEqual([ + ["ensure", "user-1", "Site", null, "cloud-cog"], + ]); + }); + + it("removes only an empty folder", async () => { + const hosts = createPluginHosts({ + manifest: manifest(["hosts:write"]), + audit: vi.fn(async () => {}), + }); + expect(await hosts.folders.removeEmpty("Site")).toBe(true); + h.folderEmpty = false; + expect(await hosts.folders.removeEmpty("Site")).toBe(false); + expect(h.folderCalls[0]).toEqual(["remove", "user-1", "Site"]); + }); +}); diff --git a/src/backend/tests/plugins/ctx-probe.test.ts b/src/backend/tests/plugins/ctx-probe.test.ts new file mode 100644 index 000000000..34e46c993 --- /dev/null +++ b/src/backend/tests/plugins/ctx-probe.test.ts @@ -0,0 +1,182 @@ +/** + * ctx.network.tcpProbe, ctx.ssh.probe and ctx.hosts.probeTargets: gated by + * capability, and only ever offered hosts the actor may connect to. + */ + +import net from "node:net"; +import type { AddressInfo } from "node:net"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const grants = new Map(); +const state = vi.hoisted(() => ({ + owned: [] as Array>, + shared: [] as Array>, + sharedIds: [] as number[], + allowed: new Set(), +})); + +vi.mock("../../database/repositories/factory.js", () => ({ + createCurrentPluginPermissionGrantRepository: () => ({ + listByPlugin: async (pluginId: string) => + (grants.get(pluginId) ?? []).map((capability) => ({ + pluginId, + capability, + })), + }), + createCurrentHostResolutionRepository: () => ({ + listStatusTargets: async (filter: { + userId?: string; + hostIds?: number[]; + }) => { + const rows = filter.userId ? state.owned : state.shared; + return rows.filter( + (row) => !filter.hostIds || filter.hostIds.includes(row.id as number), + ); + }, + }), + createCurrentRoleRepository: () => ({ listUserRoleIds: async () => [] }), + createCurrentRbacAccessRepository: () => ({ + listVisibleHostAccessEntries: async () => + state.sharedIds.map((hostId) => ({ hostId })), + }), +})); +vi.mock("../../utils/permission-manager.js", () => ({ + PermissionManager: { + getInstance: () => ({ + filterAccessibleHostIds: async (_user: string, ids: number[]) => + new Set(ids.filter((id) => state.allowed.has(id))), + canAccessHost: async (_user: string, id: number) => ({ + hasAccess: state.allowed.has(id), + }), + }), + }, +})); +vi.mock("../../utils/audit-logger.js", () => ({ logAudit: async () => {} })); + +import { createPluginContext, createPluginHandle } from "../../plugins/ctx.js"; +import { invalidatePluginPermissionCache } from "../../plugins/permissions.js"; +import { runAsActor } from "../../plugins/actor.js"; +import type { PluginManifest } from "@termix-ssh/plugin-sdk/manifest"; + +function contextFor(capabilities: string[]) { + const manifest = { + id: "demo", + name: "demo", + version: "1.0.0", + description: "", + author: { name: "test" }, + license: "MIT", + category: "Productivity", + engine: { termix: ">=26.10.3", api: "1" }, + capabilities, + } as PluginManifest; + return createPluginContext( + manifest, + createPluginHandle("demo", { activate: () => {} }), + ); +} + +const row = (id: number, userId: string) => ({ + id, + userId, + ip: "127.0.0.1", + port: 22, + connectionType: "ssh", + jumpHosts: null, + statusCheckEnabled: true, + statusCheckInterval: null, +}); + +let server: net.Server; +let port = 0; + +beforeEach(async () => { + grants.clear(); + invalidatePluginPermissionCache(); + state.owned = [row(1, "alice")]; + state.shared = [row(2, "bob"), row(3, "bob")]; + state.sharedIds = [2, 3]; + state.allowed = new Set([2]); + server = net.createServer((socket) => socket.destroy()); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + port = (server.address() as AddressInfo).port; +}); + +afterEach(() => { + server.close(); +}); + +describe("ctx.network.tcpProbe", () => { + it("refuses without network:probe", async () => { + grants.set("demo", []); + await expect( + contextFor([]).network.tcpProbe("127.0.0.1", port), + ).rejects.toThrow(/network:probe/); + }); + + it("reports an open port and a closed one", async () => { + grants.set("demo", ["network:probe"]); + const ctx = contextFor(["network:probe"]); + expect(await ctx.network.tcpProbe("127.0.0.1", port)).toBe(true); + await new Promise((resolve) => server.close(() => resolve())); + expect( + await ctx.network.tcpProbe("127.0.0.1", port, { timeoutMs: 500 }), + ).toBe(false); + }); + + it("rejects a bad port", async () => { + grants.set("demo", ["network:probe"]); + await expect( + contextFor(["network:probe"]).network.tcpProbe("127.0.0.1", 0), + ).rejects.toThrow(/port/); + }); +}); + +describe("ctx.hosts.probeTargets", () => { + it("refuses without hosts:read", async () => { + grants.set("demo", []); + await expect(contextFor([]).hosts.probeTargets()).rejects.toThrow( + /hosts:read/, + ); + }); + + it("needs an acting user", async () => { + grants.set("demo", ["hosts:read"]); + await expect( + contextFor(["hosts:read"]).hosts.probeTargets(), + ).rejects.toThrow(/acting user/); + }); + + it("returns own hosts and only shared hosts the actor may connect to", async () => { + grants.set("demo", ["hosts:read"]); + const ctx = contextFor(["hosts:read"]); + const targets = await runAsActor("alice", "request", () => + ctx.hosts.probeTargets(), + ); + expect(targets.map((target) => target.id)).toEqual([1, 2]); + expect(targets[1].ownerUserId).toBe("bob"); + }); +}); + +describe("ctx.ssh.probe", () => { + it("refuses without ssh:connect", async () => { + grants.set("demo", []); + await expect(contextFor([]).ssh.probe(1)).rejects.toThrow(/ssh:connect/); + }); + + it("answers false for a host the actor cannot connect to", async () => { + grants.set("demo", ["ssh:connect"]); + const ctx = contextFor(["ssh:connect"]); + expect(await runAsActor("alice", "request", () => ctx.ssh.probe(3))).toBe( + false, + ); + }); + + it("probes a reachable host directly", async () => { + grants.set("demo", ["ssh:connect"]); + const ctx = contextFor(["ssh:connect"]); + expect( + await runAsActor("alice", "request", () => ctx.ssh.probe(2, { port })), + ).toBe(true); + }); +}); diff --git a/src/backend/tests/plugins/ctx-ssh-auth.test.ts b/src/backend/tests/plugins/ctx-ssh-auth.test.ts index 205a47c62..ab1b9e357 100644 --- a/src/backend/tests/plugins/ctx-ssh-auth.test.ts +++ b/src/backend/tests/plugins/ctx-ssh-auth.test.ts @@ -12,6 +12,7 @@ const h = vi.hoisted(() => ({ sessionId: undefined as string | undefined, revoked: [] as Array<{ userId: string; except?: string }>, trustedCleared: [] as string[], + unrestricted: [] as string[], policyError: null as Error | null, logins: [] as Array<{ hostId: number; outcome: unknown }>, connectError: null as Error | null, @@ -104,8 +105,8 @@ vi.mock("../../hosts/host-resolver.js", () => ({ }), resolveHostBySyncId: async () => null, })); -vi.mock("../../hosts/status/host-status-service.js", () => ({ - hostStatusService: { +vi.mock("../../plugins/host-status-provider.js", () => ({ + hostStatus: { reportLogin: (hostId: number, outcome: unknown) => h.logins.push({ hostId, outcome }), }, @@ -126,6 +127,9 @@ vi.mock("../../database/repositories/factory.js", () => ({ h.revoked.push({ userId, except }); return 0; }, + clearRestrictionForUser: async (userId: string) => { + h.unrestricted.push(userId); + }, }), createCurrentTrustedDeviceRepository: () => ({ deleteByUserId: async (userId: string) => { @@ -145,6 +149,8 @@ const { getLoginMethod, getSecondFactor } = await import("../../auth/registry.js"); const { PluginCapabilityError } = await import("@termix-ssh/plugin-sdk/backend"); +const { hasAuthPolicies, clearAuthPolicies } = + await import("../../auth/policy.js"); function manifest(capabilities: string[], auth: Record = {}) { return { @@ -655,6 +661,7 @@ describe("ctx.auth", () => { h.factors = []; h.revoked = []; h.trustedCleared = []; + h.unrestricted = []; h.policyError = null; h.actor = "user-1"; h.sessionId = "session-a"; @@ -666,10 +673,65 @@ describe("ctx.auth", () => { await auth.recordEnrollment("user-1", "pin"); expect(h.revoked).toEqual([{ userId: "user-1", except: "session-a" }]); expect(h.trustedCleared).toEqual(["user-1"]); + expect(h.unrestricted).toEqual(["user-1"]); h.actor = undefined; h.sessionId = undefined; }); + it("needs auth:policy for a login policy and drops it on deactivate", async () => { + clearAuthPolicies(); + const plain = createPluginAuth({ + manifest: manifest(["auth:provide"]), + bag: new DisposableBag("fixture"), + audit: vi.fn(async () => {}), + }); + expect(() => plain.policy.provide({ evaluate: () => ({}) })).toThrow( + PluginCapabilityError, + ); + + const bag = new DisposableBag("fixture"); + const audit = vi.fn(async () => {}); + const auth = createPluginAuth({ + manifest: manifest(["auth:policy"]), + bag, + audit, + }); + const remove = auth.policy.provide({ evaluate: () => ({}) }); + expect(hasAuthPolicies()).toBe(true); + expect(audit).toHaveBeenCalledWith( + "auth_policy_provide", + "fixture sets login rules", + { success: true }, + ); + remove(); + expect(hasAuthPolicies()).toBe(false); + + auth.policy.provide({ evaluate: () => ({}) }); + await bag.disposeAll(); + expect(hasAuthPolicies()).toBe(false); + }); + + it("lists second factors from every plugin", async () => { + h.granted = new Set(["auth:provide"]); + const bag = new DisposableBag("fixture"); + const auth = createPluginAuth({ + manifest: manifest(["auth:provide"], { secondFactors: ["pin"] }), + bag, + audit: vi.fn(async () => {}), + }); + auth.registerSecondFactor({ + id: "pin", + labelKey: "pin", + isEnrolled: async () => false, + verify: async () => true, + }); + expect(auth.listSecondFactors()).toContainEqual({ + id: "pin", + pluginId: "fixture", + }); + await bag.disposeAll(); + }); + it("refuses enrolment when core policy forbids second factors", async () => { h.granted = new Set(["auth:provide"]); h.factors = []; diff --git a/src/backend/tests/plugins/ctx-ssh-exec.test.ts b/src/backend/tests/plugins/ctx-ssh-exec.test.ts new file mode 100644 index 000000000..ba03753a9 --- /dev/null +++ b/src/backend/tests/plugins/ctx-ssh-exec.test.ts @@ -0,0 +1,104 @@ +import { EventEmitter } from "node:events"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { runExec, type ExecClient } from "../../plugins/ctx-ssh-exec.js"; + +function fakeClient( + script: (stream: FakeStream) => void, + execError?: Error, +): ExecClient & { commands: string[] } { + const commands: string[] = []; + return { + commands, + exec: (command, callback) => { + commands.push(command); + if (execError) return callback(execError, undefined as never); + const stream = new FakeStream(); + callback(undefined, stream as never); + script(stream); + }, + }; +} + +class FakeStream extends EventEmitter { + stderr = new EventEmitter(); + written: string[] = []; + closed = false; + write(data: string) { + this.written.push(data); + } + end(data?: string) { + if (data !== undefined) this.written.push(data); + } + close() { + this.closed = true; + } +} + +afterEach(() => { + vi.useRealTimers(); +}); + +describe("runExec", () => { + it("collects output and the exit code", async () => { + const client = fakeClient((stream) => { + stream.emit("data", Buffer.from("out")); + stream.stderr.emit("data", Buffer.from("err")); + stream.emit("close", 3); + }); + await expect(runExec(client, "echo hi")).resolves.toEqual({ + stdout: "out", + stderr: "err", + code: 3, + truncated: false, + timedOut: false, + }); + expect(client.commands).toEqual(["echo hi"]); + }); + + it("keeps only maxOutputBytes and says so", async () => { + const client = fakeClient((stream) => { + stream.emit("data", Buffer.alloc(3000, "a")); + stream.emit("close", 0); + }); + const result = await runExec(client, "yes", { maxOutputBytes: 1024 }); + expect(result.stdout).toHaveLength(1024); + expect(result.truncated).toBe(true); + }); + + it("writes stdin and closes it", async () => { + let seen: FakeStream | undefined; + const client = fakeClient((stream) => { + seen = stream; + stream.emit("close", 0); + }); + await runExec(client, "cat >> f", { stdin: "key\n" }); + expect(seen?.written).toEqual(["key\n"]); + }); + + it("stops a command that runs past the time limit", async () => { + vi.useFakeTimers(); + let seen: FakeStream | undefined; + const client = fakeClient((stream) => { + seen = stream; + }); + const pending = runExec(client, "sleep 999", { timeoutMs: 2000 }); + await vi.advanceTimersByTimeAsync(2100); + const result = await pending; + expect(result).toMatchObject({ code: null, timedOut: true }); + expect(seen?.closed).toBe(true); + }); + + it("rejects when the exec itself fails", async () => { + const client = fakeClient(() => {}, new Error("channel open failure")); + await expect(runExec(client, "ls")).rejects.toThrow(/channel open/); + }); + + it("rejects an empty or oversized command before running it", async () => { + const client = fakeClient(() => {}); + await expect(runExec(client, " ")).rejects.toThrow(/required/); + await expect(runExec(client, "x".repeat(70_000))).rejects.toThrow( + /too long/, + ); + expect(client.commands).toEqual([]); + }); +}); diff --git a/src/backend/tests/plugins/desktop-local-fs.test.ts b/src/backend/tests/plugins/desktop-local-fs.test.ts new file mode 100644 index 000000000..d04d96087 --- /dev/null +++ b/src/backend/tests/plugins/desktop-local-fs.test.ts @@ -0,0 +1,483 @@ +/** + * The local file helpers behind ctx.desktop.local.fs. Ported from the old + * Electron local-files tests: path containment, link refusal, publish and + * replace semantics, and the exclusive rename. + */ + +import crypto from "node:crypto"; +import fs from "node:fs"; +import fsp from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { + assertWithinRoot, + createEmptyFile, + defaultPublishFs, + ensureDirectory, + existingPaths, + listDirectory, + makeDirectory, + openDownload, + prepareDownloadPath, + publishDownload, + renameEntry, + renameLocalPath, + walkPaths, + type PublishFs, +} from "../../plugins/desktop-local/local-fs.js"; + +let root: string; +beforeEach(async () => { + root = await fsp.mkdtemp(path.join(os.tmpdir(), "termix-desktop-local-")); +}); +afterEach(() => fsp.rm(root, { recursive: true, force: true })); + +function windowsLikeFs( + overrides: Partial = {}, +): PublishFs & { renames: Array<[string, string]> } { + const renames: Array<[string, string]> = []; + return { + ...defaultPublishFs, + renames, + rename: async (from, to) => { + renames.push([from, to]); + if (fs.existsSync(to)) { + throw Object.assign(new Error("EEXIST: file already exists, rename"), { + code: "EEXIST", + }); + } + await defaultPublishFs.rename(from, to); + }, + ...overrides, + }; +} + +describe("download destination containment", () => { + const win = path.win32; + const winRoot = "C:\\Downloads\\selected"; + + it("accepts ordinary nested destinations on Windows", () => { + expect( + assertWithinRoot(winRoot, "C:\\Downloads\\selected\\a.txt", win), + ).toBe("C:\\Downloads\\selected\\a.txt"); + expect( + assertWithinRoot( + winRoot, + "C:\\Downloads\\selected\\docs\\2026\\report.pdf", + win, + ), + ).toBe("C:\\Downloads\\selected\\docs\\2026\\report.pdf"); + expect( + assertWithinRoot(winRoot, "c:\\downloads\\SELECTED\\b.txt", win), + ).toBe("c:\\downloads\\SELECTED\\b.txt"); + }); + + it("rejects backslash traversal that normalises out of the root on Windows", () => { + expect(() => + assertWithinRoot( + winRoot, + "C:\\Downloads\\selected\\..\\outside.txt", + win, + ), + ).toThrow(/outside the selected folder/); + expect(() => + assertWithinRoot( + winRoot, + "C:\\Downloads\\selected\\sub\\..\\..\\..\\x.txt", + win, + ), + ).toThrow(/outside the selected folder/); + expect(() => assertWithinRoot(winRoot, winRoot, win)).toThrow( + /outside the selected folder/, + ); + }); + + it("rejects absolute, drive-qualified and UNC destinations on Windows", () => { + for (const dest of [ + "C:\\Windows\\evil.dll", + "D:\\Downloads\\selected\\x.txt", + "\\\\server\\share\\x.txt", + "C:\\Downloads\\selected-other\\x.txt", + ]) { + expect(() => assertWithinRoot(winRoot, dest, win)).toThrow( + /outside the selected folder/, + ); + } + }); + + it("requires a root and rejects POSIX traversal too", () => { + expect(() => assertWithinRoot("", "/tmp/x", path.posix)).toThrow( + /download folder is required/, + ); + expect(() => + assertWithinRoot("/home/max/dl", "/home/max/dl/../x", path.posix), + ).toThrow(/outside the selected folder/); + expect( + assertWithinRoot("/home/max/dl", "/home/max/dl/..\\x", path.posix), + ).toBe("/home/max/dl/..\\x"); + }); + + it("refuses an escaping download before any disk write", async () => { + const selected = path.join(root, "selected"); + await fsp.mkdir(selected); + await expect( + openDownload({ + rootPath: selected, + destPath: path.join(selected, "..", "outside.txt"), + transferId: "e1", + }), + ).rejects.toMatchObject({ code: "EINVAL" }); + await expect( + ensureDirectory(path.join(selected, "..", "escaped-dir"), selected), + ).rejects.toMatchObject({ code: "EINVAL" }); + await expect( + openDownload({ + rootPath: "", + destPath: path.join(selected, "ok.bin"), + transferId: "e2", + }), + ).rejects.toMatchObject({ code: "EINVAL" }); + expect(await fsp.readdir(root)).toEqual(["selected"]); + expect(await fsp.readdir(selected)).toEqual([]); + }); +}); + +describe("links inside a download tree", () => { + it("rejects existing linked parents for downloads and directory creation", async () => { + const selected = path.join(root, "selected"); + const outside = path.join(root, "outside"); + await fsp.mkdir(selected); + await fsp.mkdir(outside); + await fsp.symlink(outside, path.join(selected, "linked"), "junction"); + + await expect( + openDownload({ + rootPath: selected, + destPath: path.join(selected, "linked", "file.bin"), + transferId: "l1", + }), + ).rejects.toThrow(/cannot follow links/); + await expect( + ensureDirectory(path.join(selected, "linked", "new"), selected), + ).rejects.toThrow(/cannot follow links/); + expect(await fsp.readdir(outside)).toEqual([]); + }); + + it("allows an explicitly selected linked root", async () => { + const actual = path.join(root, "actual"); + const selected = path.join(root, "selected"); + await fsp.mkdir(actual); + await fsp.symlink(actual, selected, "junction"); + + const download = await openDownload({ + rootPath: selected, + destPath: path.join(selected, "nested", "file.bin"), + transferId: "l2", + }); + download.stream.write("data"); + await download.publish(); + expect( + await fsp.readFile(path.join(actual, "nested", "file.bin"), "utf8"), + ).toBe("data"); + }); + + it("rejects a destination parent replaced by a link before publish", async () => { + const selected = path.join(root, "selected"); + const nested = path.join(selected, "nested"); + const outside = path.join(root, "outside"); + await fsp.mkdir(nested, { recursive: true }); + await fsp.mkdir(outside); + + const download = await openDownload({ + rootPath: selected, + destPath: path.join(nested, "file.bin"), + transferId: "l3", + }); + download.stream.write("data"); + await fsp.rename(nested, path.join(selected, "original")); + await fsp.symlink(outside, nested, "junction"); + + await expect(download.publish()).rejects.toThrow(/cannot follow links/); + expect(await fsp.readdir(outside)).toEqual([]); + }); + + it("prepareDownloadPath refuses a root that is not a directory", async () => { + const file = path.join(root, "file"); + await fsp.writeFile(file, "x"); + await expect( + prepareDownloadPath(file, path.join(file, "child")), + ).rejects.toBeTruthy(); + }); +}); + +describe("downloads", () => { + it("refuses to replace an existing file by default", async () => { + const dest = path.join(root, "keep.bin"); + await fsp.writeFile(dest, "original"); + await expect( + openDownload({ rootPath: root, destPath: dest, transferId: "d1" }), + ).rejects.toMatchObject({ code: "EEXIST" }); + expect(await fsp.readFile(dest, "utf8")).toBe("original"); + expect(await fsp.readdir(root)).toEqual(["keep.bin"]); + }); + + it("replaces an existing file only when overwrite is requested", async () => { + const dest = path.join(root, "replace.bin"); + await fsp.writeFile(dest, "original"); + const download = await openDownload({ + rootPath: root, + destPath: dest, + transferId: "d2", + overwrite: true, + }); + download.stream.write("fresh"); + await download.publish(); + expect(await fsp.readFile(dest, "utf8")).toBe("fresh"); + expect(await fsp.readdir(root)).toEqual(["replace.bin"]); + }); + + it("refuses to publish over a file that appeared while it ran", async () => { + const dest = path.join(root, "race.bin"); + const download = await openDownload({ + rootPath: root, + destPath: dest, + transferId: "d3", + }); + download.stream.write("mine"); + await fsp.writeFile(dest, "someone else wrote this"); + await expect(download.publish()).rejects.toMatchObject({ code: "EEXIST" }); + expect(await fsp.readFile(dest, "utf8")).toBe("someone else wrote this"); + expect(await fsp.readdir(root)).toEqual(["race.bin"]); + }); + + it("uses a transfer-unique partial and rejects a concurrent download to the same destination", async () => { + const dest = path.join(root, "same.bin"); + const first = await openDownload({ + rootPath: root, + destPath: dest, + transferId: "first", + }); + await expect( + openDownload({ rootPath: root, destPath: dest, transferId: "second" }), + ).rejects.toMatchObject({ code: "EBUSY" }); + expect( + (await fsp.readdir(root)).filter((n) => n.endsWith(".termix-part")), + ).toEqual(["same.bin.first.termix-part"]); + + first.stream.write("done"); + await first.publish(); + expect(await fsp.readdir(root)).toEqual(["same.bin"]); + + // The destination is free again once the first one finished. + const again = await openDownload({ + rootPath: root, + destPath: path.join(root, "same2.bin"), + transferId: "third", + }); + await again.abort(); + }); + + it("allows two downloads of different files side by side and aborts one cleanly", async () => { + const a = await openDownload({ + rootPath: root, + destPath: path.join(root, "a.bin"), + transferId: "a", + }); + const b = await openDownload({ + rootPath: root, + destPath: path.join(root, "b.bin"), + transferId: "b", + }); + a.stream.write("A"); + b.stream.write("B"); + await a.abort(); + await b.publish(); + expect(await fsp.readdir(root)).toEqual(["b.bin"]); + // Abort after publish is harmless. + await b.abort(); + expect(await fsp.readdir(root)).toEqual(["b.bin"]); + }); +}); + +describe("replace primitive (Windows-safe overwrite)", () => { + const seed = async (name: string, existing: string, fresh: Buffer) => { + const dest = path.join(root, name); + const partial = `${dest}.t1.termix-part`; + await fsp.writeFile(dest, existing); + await fsp.writeFile(partial, fresh); + return { dest, partial }; + }; + + it("replaces an existing file without renaming onto an occupied name", async () => { + const fresh = crypto.randomBytes(4096); + const { dest, partial } = await seed("swap.bin", "original", fresh); + const io = windowsLikeFs(); + + await publishDownload(partial, dest, true, "t1", io); + + expect((await fsp.readFile(dest)).equals(fresh)).toBe(true); + expect(await fsp.readdir(root)).toEqual(["swap.bin"]); + expect(io.renames.length).toBeGreaterThan(0); + for (const [, to] of io.renames) { + expect(to === dest || to.endsWith(".termix-replaced")).toBe(true); + } + }); + + it("restores the original byte for byte when publishing fails", async () => { + const fresh = crypto.randomBytes(1024); + const { dest, partial } = await seed("restore.bin", "original", fresh); + const denied = () => + Promise.reject( + Object.assign(new Error("EACCES: permission denied"), { + code: "EACCES", + }), + ); + const io = windowsLikeFs({ link: denied, copyFileExcl: denied }); + + await expect( + publishDownload(partial, dest, true, "t1", io), + ).rejects.toMatchObject({ code: "EACCES" }); + + expect(await fsp.readFile(dest, "utf8")).toBe("original"); + expect((await fsp.readdir(root)).sort()).toEqual( + ["restore.bin", "restore.bin.t1.termix-part"].sort(), + ); + }); + + it("reports EBUSY and touches nothing when the file cannot be moved aside", async () => { + const fresh = crypto.randomBytes(1024); + const { dest, partial } = await seed("locked.bin", "original", fresh); + const io = windowsLikeFs({ + rename: () => + Promise.reject( + Object.assign(new Error("EPERM: operation not permitted"), { + code: "EPERM", + }), + ), + }); + + await expect( + publishDownload(partial, dest, true, "t1", io), + ).rejects.toMatchObject({ code: "EBUSY" }); + + expect(await fsp.readFile(dest, "utf8")).toBe("original"); + expect((await fsp.readFile(partial)).equals(fresh)).toBe(true); + }); + + it("refuses to replace a folder with a file", async () => { + const dest = path.join(root, "folder"); + await fsp.mkdir(dest); + await fsp.writeFile(path.join(dest, "inner.txt"), "keep"); + const partial = `${dest}.t1.termix-part`; + await fsp.writeFile(partial, "new"); + + await expect( + publishDownload(partial, dest, true, "t1", windowsLikeFs()), + ).rejects.toMatchObject({ code: "EISDIR" }); + expect(await fsp.readFile(path.join(dest, "inner.txt"), "utf8")).toBe( + "keep", + ); + }); + + it("falls back to an exclusive publish when the file to replace is gone", async () => { + const dest = path.join(root, "gone.bin"); + const partial = `${dest}.t1.termix-part`; + await fsp.writeFile(partial, "new"); + + await publishDownload(partial, dest, true, "t1", windowsLikeFs()); + expect(await fsp.readFile(dest, "utf8")).toBe("new"); + expect(await fsp.readdir(root)).toEqual(["gone.bin"]); + }); +}); + +describe("local rename", () => { + it("preserves both files on a name collision", async () => { + const a = path.join(root, "a"); + const b = path.join(root, "b"); + await fsp.writeFile(a, "source"); + await fsp.writeFile(b, "destination"); + await expect(renameLocalPath(a, b)).rejects.toThrow(/already exists/); + expect(await fsp.readFile(a, "utf8")).toBe("source"); + expect(await fsp.readFile(b, "utf8")).toBe("destination"); + }); + + it("refuses to replace even an empty destination directory", async () => { + const a = path.join(root, "a"); + const b = path.join(root, "b"); + await fsp.mkdir(a); + await fsp.mkdir(b); + await expect(renameLocalPath(a, b)).rejects.toThrow(/already exists/); + expect((await fsp.readdir(root)).sort()).toEqual(["a", "b"]); + }); + + it("renames a file and handles an unchanged name", async () => { + const a = path.join(root, "a"); + const b = path.join(root, "b"); + await fsp.writeFile(a, "source"); + await renameLocalPath(a, a); + await renameLocalPath(a, b); + expect(fs.existsSync(a)).toBe(false); + expect(await fsp.readFile(b, "utf8")).toBe("source"); + }); + + it("renameEntry only takes a plain name in the same folder", async () => { + const a = path.join(root, "a"); + await fsp.writeFile(a, "x"); + await expect(renameEntry(a, "../escape")).rejects.toThrow(/Invalid/); + await expect(renameEntry(a, "")).rejects.toThrow(/Invalid/); + expect(await renameEntry(a, "c")).toBe(path.join(root, "c")); + }); +}); + +describe("listing and creating", () => { + it("lists entries with type, size and hidden flag", async () => { + await fsp.mkdir(path.join(root, "dir")); + await fsp.writeFile(path.join(root, "f.txt"), "abc"); + await fsp.writeFile(path.join(root, ".hidden"), ""); + const listing = await listDirectory(root); + const byName = Object.fromEntries(listing.entries.map((e) => [e.name, e])); + expect(byName["dir"].type).toBe("directory"); + expect(byName["f.txt"]).toMatchObject({ type: "file", size: 3 }); + expect(byName[".hidden"].hidden).toBe(true); + expect(listing.parent).toBe(path.dirname(root)); + }); + + it("rejects relative paths", async () => { + await expect(listDirectory("relative")).rejects.toThrow(/absolute/); + }); + + it("creates folders and files without replacing anything", async () => { + expect(await makeDirectory(root, "new")).toBe(path.join(root, "new")); + await expect(makeDirectory(root, "new")).rejects.toBeTruthy(); + expect(await createEmptyFile(root, "a.txt")).toBe(path.join(root, "a.txt")); + await fsp.writeFile(path.join(root, "a.txt"), "keep"); + await expect(createEmptyFile(root, "a.txt")).rejects.toBeTruthy(); + expect(await fsp.readFile(path.join(root, "a.txt"), "utf8")).toBe("keep"); + }); + + it("reports which paths exist", async () => { + await fsp.writeFile(path.join(root, "yes"), ""); + expect( + await existingPaths([path.join(root, "yes"), path.join(root, "no")]), + ).toEqual([path.join(root, "yes")]); + }); + + it("walks dropped paths into a flat list with empty folders", async () => { + await fsp.mkdir(path.join(root, "tree", "empty"), { recursive: true }); + await fsp.mkdir(path.join(root, "tree", "sub")); + await fsp.writeFile(path.join(root, "tree", "sub", "a.txt"), "12345"); + await fsp.writeFile(path.join(root, "single.txt"), "xy"); + + const result = await walkPaths([ + path.join(root, "tree"), + path.join(root, "single.txt"), + ]); + expect(result.files.map((f) => f.relativePath).sort()).toEqual([ + "single.txt", + "tree/sub/a.txt", + ]); + expect(result.emptyDirs).toEqual(["tree/empty"]); + expect(result.totalBytes).toBe(7); + }); +}); diff --git a/src/backend/tests/electron/local-shell.test.ts b/src/backend/tests/plugins/desktop-local-pty.test.ts similarity index 73% rename from src/backend/tests/electron/local-shell.test.ts rename to src/backend/tests/plugins/desktop-local-pty.test.ts index 32a44a6fc..b3f3149c4 100644 --- a/src/backend/tests/electron/local-shell.test.ts +++ b/src/backend/tests/plugins/desktop-local-pty.test.ts @@ -1,23 +1,9 @@ -import { createRequire } from "node:module"; +import path from "node:path"; import { describe, expect, it } from "vitest"; - -const require = createRequire(import.meta.url); -const { resolveLocalShell, repairSpawnHelpers } = - require("../../../../electron/local-shell.cjs") as { - resolveLocalShell: ( - platform: NodeJS.Platform, - requestedShell?: string, - env?: NodeJS.ProcessEnv, - exists?: (file: string) => boolean, - ) => { file: string; args: string[] }; - repairSpawnHelpers: ( - ptyDir: string, - fsImpl: { - statSync: (file: string) => { mode: number }; - chmodSync: (file: string, mode: number) => void; - }, - ) => number; - }; +import { + repairSpawnHelpers, + resolveLocalShell, +} from "../../plugins/desktop-local/local-pty.js"; describe("resolveLocalShell", () => { it("starts the default WSL distribution without PowerShell arguments", () => { @@ -74,9 +60,9 @@ describe("repairSpawnHelpers", () => { "/Applications/Termix.app/Contents/Resources/app.asar/node_modules/node-pty"; const unpacked = dir.replace("app.asar", "app.asar.unpacked"); const release = `${unpacked}/build/Release/spawn-helper`; - modes.set(release.split("/").join(require("node:path").sep), 0o644); + modes.set(release.split("/").join(path.sep), 0o644); modes.set(release, 0o644); - expect(repairSpawnHelpers(dir, fsImpl)).toBe(1); + expect(repairSpawnHelpers(dir, fsImpl as never)).toBe(1); expect(chmods[0][1] & 0o111).not.toBe(0); expect(chmods[0][0]).toContain("app.asar.unpacked"); }); @@ -88,11 +74,11 @@ describe("repairSpawnHelpers", () => { const fsImpl = { statSync: () => ({ mode: 0o644 }), chmodSync: (file: string) => { - chmods.push(file.split(require("node:path").sep).join("/")); + chmods.push(file.split(path.sep).join("/")); }, }; const dir = `/Applications/Termix.app/Contents/Resources/${asar}/node_modules/node-pty`; - repairSpawnHelpers(dir, fsImpl); + repairSpawnHelpers(dir, fsImpl as never); expect(chmods[0]).toContain(`/${asar}.unpacked/node_modules/node-pty/`); }, ); diff --git a/src/backend/tests/plugins/host-status-provider.test.ts b/src/backend/tests/plugins/host-status-provider.test.ts new file mode 100644 index 000000000..e8cc34df7 --- /dev/null +++ b/src/backend/tests/plugins/host-status-provider.test.ts @@ -0,0 +1,99 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { + hostStatus, + provideHostStatus, + registerBuiltinHostStatusProvider, + type HostStatusProvider, +} from "../../plugins/host-status-provider.js"; + +function fakeProvider(label: string): HostStatusProvider & { + ports: string[]; +} { + const ports: string[] = []; + return { + ports, + start: vi.fn(), + stop: vi.fn(), + get: (hostId) => ({ status: "online", lastChecked: `${label}:${hostId}` }), + statusesFor: async () => new Map(), + refresh: vi.fn(async () => {}), + check: async (hostId) => ({ + status: "offline", + lastChecked: `${label}:${hostId}`, + }), + reportLogin: vi.fn(), + registerPort: (type) => { + ports.push(`+${type}`); + return () => ports.push(`-${type}`); + }, + getIntervalSeconds: async () => 45, + setIntervalSeconds: vi.fn(async () => {}), + }; +} + +beforeEach(() => { + hostStatus.stop(); + registerBuiltinHostStatusProvider(null); +}); + +describe("host status provider", () => { + it("answers unknown when nothing provides status", async () => { + expect(hostStatus.get(1)).toBeNull(); + expect((await hostStatus.statusesFor("u", null)).size).toBe(0); + expect(await hostStatus.check(1)).toBeNull(); + expect(await hostStatus.getIntervalSeconds()).toBe(30); + }); + + it("uses the built-in until a plugin provides", () => { + const builtin = fakeProvider("builtin"); + registerBuiltinHostStatusProvider(builtin); + expect(hostStatus.get(1)?.lastChecked).toBe("builtin:1"); + + const plugin = fakeProvider("plugin"); + const remove = provideHostStatus("host-status", plugin); + expect(hostStatus.get(1)?.lastChecked).toBe("plugin:1"); + + remove(); + expect(hostStatus.get(1)?.lastChecked).toBe("builtin:1"); + }); + + it("refuses a second provider", () => { + const remove = provideHostStatus("one", fakeProvider("one")); + expect(() => provideHostStatus("two", fakeProvider("two"))).toThrow( + /already provided by plugin "one"/, + ); + remove(); + }); + + it("hands lifecycle over when the provider changes", () => { + const builtin = fakeProvider("builtin"); + registerBuiltinHostStatusProvider(builtin); + hostStatus.start(); + expect(builtin.start).toHaveBeenCalledTimes(1); + + const plugin = fakeProvider("plugin"); + const remove = provideHostStatus("host-status", plugin); + expect(builtin.stop).toHaveBeenCalledTimes(1); + expect(plugin.start).toHaveBeenCalledTimes(1); + + remove(); + expect(plugin.stop).toHaveBeenCalledTimes(1); + expect(builtin.start).toHaveBeenCalledTimes(2); + }); + + it("keeps registered ports across a provider change", () => { + const builtin = fakeProvider("builtin"); + registerBuiltinHostStatusProvider(builtin); + const drop = hostStatus.registerPort("rdp", () => 3389); + expect(builtin.ports).toEqual(["+rdp"]); + + const plugin = fakeProvider("plugin"); + const remove = provideHostStatus("host-status", plugin); + expect(builtin.ports).toEqual(["+rdp", "-rdp"]); + expect(plugin.ports).toEqual(["+rdp"]); + + drop(); + expect(plugin.ports).toEqual(["+rdp", "-rdp"]); + remove(); + }); +}); diff --git a/src/backend/tests/plugins/loader.test.ts b/src/backend/tests/plugins/loader.test.ts index c3da10399..b670356ce 100644 --- a/src/backend/tests/plugins/loader.test.ts +++ b/src/backend/tests/plugins/loader.test.ts @@ -132,6 +132,85 @@ describe("PluginLoader discovery", () => { }); }); +describe("PluginLoader themes and skipped looks", () => { + const themed = { + capabilities: ["ui:theme", "ui:layout"], + manifestOverrides: { + contributes: { + themes: [ + { + id: "paper", + label: "Paper", + mode: "light", + css: "themes/paper.css", + }, + ], + layouts: [{ id: "fancy", label: "Fancy" }], + }, + }, + }; + + it("loads a plugin whose theme CSS is allowed", async () => { + const fixture = createFixturePlugin(themed); + cleanups.push(fixture.cleanup); + fs.mkdirSync(path.join(fixture.dir, "themes")); + fs.writeFileSync( + path.join(fixture.dir, "themes", "paper.css"), + ".paper { --background: #fff; }", + ); + const plugin = await new PluginLoader().load(fixture.dir, "bundled"); + expect(plugin.manifest.contributes?.themes?.[0].id).toBe("paper"); + }); + + it("refuses a plugin whose theme CSS reaches outside", async () => { + const fixture = createFixturePlugin(themed); + cleanups.push(fixture.cleanup); + fs.mkdirSync(path.join(fixture.dir, "themes")); + fs.writeFileSync( + path.join(fixture.dir, "themes", "paper.css"), + '@import url("https://example.com/x.css");', + ); + await expect( + new PluginLoader().load(fixture.dir, "bundled"), + ).rejects.toThrow(/theme paper is not allowed: @import/); + }); + + it("refuses a plugin whose theme file is missing", async () => { + const fixture = createFixturePlugin(themed); + cleanups.push(fixture.cleanup); + await expect( + new PluginLoader().load(fixture.dir, "bundled"), + ).rejects.toThrow(/theme paper file themes\/paper.css is missing/); + }); + + it("remembers why a layout plugin was skipped", async () => { + const bundled = tempRoot("termix-bundled-"); + const user = tempRoot("termix-data-"); + createFixturePlugin({ + ...themed, + id: "fancy-layout", + root: bundled, + manifestOverrides: { + ...themed.manifestOverrides, + version: "2.0.0", + engine: { termix: ">=99.0.0", api: "1" }, + }, + }); + setRoots(bundled, user); + + const loader = new PluginLoader(); + await loader.loadAll(); + const [skipped] = loader.skipped(); + expect(skipped).toMatchObject({ + id: "fancy-layout", + version: "2.0.0", + layouts: ["fancy"], + themes: ["paper"], + }); + expect(skipped.error).toMatch(/needs Termix >=99.0.0/); + }); +}); + describe("PluginLoader dependency resolution", () => { async function loadAll( specs: Array<{ id: string; manifestOverrides?: Record }>, diff --git a/src/backend/tests/plugins/looks.test.ts b/src/backend/tests/plugins/looks.test.ts new file mode 100644 index 000000000..6e7f7a6dc --- /dev/null +++ b/src/backend/tests/plugins/looks.test.ts @@ -0,0 +1,121 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const index = vi.hoisted(() => ({ ids: [] as string[] })); +vi.mock("../../plugins/bundled-index.js", () => ({ + defaultLookPluginIds: () => index.ids, +})); + +import { + installedLayoutIds, + resolveDefaultLook, + safeLayoutForced, + unavailableLooks, +} from "../../plugins/looks.js"; +import type { SkippedPlugin } from "../../plugins/loader.js"; + +type Contributes = Record; + +function loaderWith( + plugins: Record, + skipped: SkippedPlugin[] = [], +) { + return { + get: (id: string) => + plugins[id] + ? ({ manifest: { contributes: plugins[id] } } as never) + : undefined, + list: () => + Object.values(plugins).map( + (contributes) => ({ manifest: { contributes } }) as never, + ), + skipped: () => skipped, + }; +} + +beforeEach(() => { + index.ids = []; +}); +afterEach(() => { + delete process.env.TERMIX_SAFE_LAYOUT; +}); + +describe("resolveDefaultLook", () => { + it("is empty without a default-look plugin", () => { + expect(resolveDefaultLook(loaderWith({}))).toEqual({ + layout: null, + theme: null, + }); + }); + + it("takes the layout marked default and its theme", () => { + index.ids = ["look-a"]; + const loader = loaderWith({ + "look-a": { + layouts: [ + { id: "first", label: "First", defaultTheme: "one" }, + { id: "main", label: "Main", defaultTheme: "two", default: true }, + ], + }, + }); + expect(resolveDefaultLook(loader)).toEqual({ + layout: "main", + theme: "two", + }); + }); + + it("takes the first layout, and a theme from a themes-only plugin", () => { + index.ids = ["look-a", "look-b"]; + const loader = loaderWith({ + "look-a": { themes: [{ id: "paper", label: "Paper" }] }, + "look-b": { layouts: [{ id: "solo", label: "Solo" }] }, + }); + expect(resolveDefaultLook(loader)).toEqual({ + layout: "solo", + theme: "paper", + }); + }); + + it("skips a default-look plugin that did not load", () => { + index.ids = ["gone"]; + expect(resolveDefaultLook(loaderWith({}))).toEqual({ + layout: null, + theme: null, + }); + }); +}); + +describe("unavailableLooks", () => { + it("lists only skipped plugins that brought a layout or theme", () => { + const skipped: SkippedPlugin[] = [ + { id: "a", version: "1.0.0", layouts: ["x"], themes: [], error: "e" }, + { id: "b", version: null, layouts: [], themes: [], error: "e" }, + { id: "c", version: "2.0.0", layouts: [], themes: ["t"], error: "e" }, + ]; + expect( + unavailableLooks(loaderWith({}, skipped)).map((plugin) => plugin.id), + ).toEqual(["a", "c"]); + }); +}); + +describe("safeLayoutForced", () => { + it("reads TERMIX_SAFE_LAYOUT", () => { + expect(safeLayoutForced()).toBe(false); + process.env.TERMIX_SAFE_LAYOUT = "1"; + expect(safeLayoutForced()).toBe(true); + process.env.TERMIX_SAFE_LAYOUT = "true"; + expect(safeLayoutForced()).toBe(true); + process.env.TERMIX_SAFE_LAYOUT = "0"; + expect(safeLayoutForced()).toBe(false); + }); +}); + +describe("installedLayoutIds", () => { + it("lists layouts of loaded and skipped plugins", () => { + const ids = installedLayoutIds( + loaderWith({ a: { layouts: [{ id: "one" }], themes: [{ id: "t" }] } }, [ + { id: "b", version: null, layouts: ["two"], themes: [], error: "x" }, + ]), + ); + expect([...ids].sort()).toEqual(["one", "two"]); + }); +}); diff --git a/src/backend/tests/plugins/manage.test.ts b/src/backend/tests/plugins/manage.test.ts index 7ea95dba7..8f1fe9a34 100644 --- a/src/backend/tests/plugins/manage.test.ts +++ b/src/backend/tests/plugins/manage.test.ts @@ -289,6 +289,32 @@ describe("uninstall and reinstall of a bundled plugin", () => { expect(loader.get("docker")?.source).toBe("user"); }); + it("refuses to uninstall the default look but lets it be turned off", async () => { + createFixturePlugin({ id: "some-look", root: bundled }); + fs.writeFileSync( + path.join(bundled, "bundled-index.json"), + JSON.stringify({ + version: 1, + plugins: { "some-look": { defaultLook: true } }, + }), + ); + const { resetBundledIndexCache } = + await import("../../plugins/bundled-index.js"); + resetBundledIndexCache(); + try { + const loader = await boot(); + await expect(manage.uninstallPlugin("some-look")).rejects.toMatchObject({ + code: "DEFAULT_LOOK", + status: 409, + }); + expect(loader.get("some-look")?.state).toBe("active"); + expect(fs.existsSync(path.join(bundled, "some-look"))).toBe(true); + } finally { + fs.rmSync(path.join(bundled, "bundled-index.json"), { force: true }); + resetBundledIndexCache(); + } + }); + it("deletes a plugin dropped in as a plain folder", async () => { createFixturePlugin({ id: "notes", root: path.join(data, "plugins") }); const loader = await boot(); @@ -635,6 +661,54 @@ describe("reloading a bundled plugin for the dev runner", () => { }); }); +describe("restoring the bundled copy", () => { + it("drops a downloaded update, loads the shipped copy and pins it", async () => { + createFixturePlugin({ id: "docker", root: bundled }); + const loader = await boot(); + // What a signed update leaves behind next to the shipped copy. + const artifact = path.join(data, "plugins", "docker.tmxplug"); + fs.mkdirSync(path.dirname(artifact), { recursive: true }); + fs.writeFileSync(artifact, "update"); + fs.writeFileSync(`${artifact}.sig`, "sig"); + + const result = await manage.restoreBundledPlugin("docker"); + + expect(result).toEqual({ id: "docker", version: "1.0.0", state: "active" }); + expect(loader.get("docker")?.manifest.version).toBe("1.0.0"); + expect(fs.existsSync(artifact)).toBe(false); + expect(fs.existsSync(`${artifact}.sig`)).toBe(false); + expect(db.rows.get("docker")).toMatchObject({ + pinnedVersion: "1.0.0", + autoUpdate: false, + }); + }); + + it("keeps a plugin that is turned off stopped", async () => { + createFixturePlugin({ id: "docker", root: bundled }); + const loader = await boot(); + await manage.setPluginState("docker", false); + const result = await manage.restoreBundledPlugin("docker"); + expect(result.state).toBe("stopped"); + expect(loader.get("docker")?.state).toBe("loaded"); + }); + + it("refuses a plugin that has no bundled copy", async () => { + createFixturePlugin({ id: "notes", root: path.join(data, "plugins") }); + await boot(); + await expect(manage.restoreBundledPlugin("notes")).rejects.toMatchObject({ + code: "NO_BUNDLED_COPY", + status: 409, + }); + }); + + it("refuses a bad id", async () => { + await expect(manage.restoreBundledPlugin("../x")).rejects.toMatchObject({ + code: "INVALID_ID", + status: 400, + }); + }); +}); + describe("consent", () => { it("refuses an install without the consented list, before any download", async () => { registry.index.plugins = [registryEntry("notes", [{ version: "1.0.0" }])]; diff --git a/src/backend/tests/plugins/manifest.test.ts b/src/backend/tests/plugins/manifest.test.ts index 89d95e2ad..5a3ed608e 100644 --- a/src/backend/tests/plugins/manifest.test.ts +++ b/src/backend/tests/plugins/manifest.test.ts @@ -1002,3 +1002,101 @@ describe("docs and env fields", () => { expect(pluginDocsPage(base, "", "top")).toBe(`${base}#top`); }); }); + +describe("layouts and themes", () => { + const layouts = [ + { + id: "fancy", + label: "layout.fancy", + defaultTheme: "paper", + default: true, + }, + ]; + const themes = [ + { + id: "paper", + label: "theme.paper", + mode: "light", + css: "themes/paper.css", + swatch: { background: "#ffffff", panel: "#eeeeee", text: "#111111" }, + }, + ]; + + it("accepts layouts and themes with their capabilities", () => { + const { manifest, errors } = parseManifest( + base({ + capabilities: ["ui:layout", "ui:theme"], + contributes: { layouts, themes }, + }), + ); + expect(errors).toEqual([]); + expect(manifest?.contributes?.layouts?.[0].id).toBe("fancy"); + expect(manifest?.contributes?.themes?.[0].mode).toBe("light"); + }); + + it("needs ui:layout for layouts and ui:theme for themes", () => { + const { errors } = parseManifest( + base({ capabilities: ["kv:own"], contributes: { layouts, themes } }), + ); + expect(errors.some((e) => e.includes('"ui:layout"'))).toBe(true); + expect(errors.some((e) => e.includes('"ui:theme"'))).toBe(true); + }); + + it("refuses bad ids, modes, paths and swatches", () => { + const errors = validateManifest( + base({ + capabilities: ["ui:layout", "ui:theme"], + contributes: { + layouts: [ + { id: "Fancy", label: "x" }, + { id: "two", label: "x", default: true }, + { id: "three", label: "x", default: true, extra: 1 }, + ], + themes: [ + { id: "paper", label: "x", mode: "sepia", css: "../paper.css" }, + { id: "ink", label: "x", mode: "dark", css: "ink.scss" }, + { + id: "dusk", + label: "x", + mode: "dark", + css: "dusk.css", + swatch: { background: "red", panel: "#000", text: "#fff" }, + }, + { id: "dusk", label: "x", mode: "dark", css: "dusk2.css" }, + ], + }, + }), + ); + const has = (text: string) => errors.some((e) => e.includes(text)); + expect(has("contributes.layouts[0].id must match")).toBe(true); + expect(has("Only one entry")).toBe(true); + expect(has('Unknown field "extra"')).toBe(true); + expect(has('mode must be "light" or "dark"')).toBe(true); + expect(has("relative path inside the plugin")).toBe(true); + expect(has("must be a .css file")).toBe(true); + expect(has("swatch.background must be a hex color")).toBe(true); + expect(has('duplicates "dusk"')).toBe(true); + }); +}); + +describe("manifest runtime", () => { + it("accepts a desktop runtime plugin", () => { + expect( + validateManifest( + base({ runtime: "desktop", capabilities: ["desktop:local"] }), + ), + ).toEqual([]); + }); + + it("rejects an unknown runtime", () => { + expect(validateManifest(base({ runtime: "cloud" })).join()).toMatch( + /runtime/, + ); + }); + + it("rejects a desktop runtime that is mirrored or server only", () => { + expect( + validateManifest(base({ runtime: "desktop", desktop: "mirror" })).join(), + ).toMatch(/local/); + }); +}); diff --git a/src/backend/tests/plugins/migrations.test.ts b/src/backend/tests/plugins/migrations.test.ts index 79afde684..b36c109ab 100644 --- a/src/backend/tests/plugins/migrations.test.ts +++ b/src/backend/tests/plugins/migrations.test.ts @@ -200,6 +200,10 @@ describe("assertOwnedTables", () => { const owners = Object.entries(LEGACY_TABLE_OWNERS); expect(new Set(owners.map(([table]) => table)).size).toBe(owners.length); }); + + it("lets the homepage plugin adopt recent_activity", () => { + expect(LEGACY_TABLE_OWNERS.recent_activity).toBe("homepage"); + }); }); describe("applyPluginMigrations", () => { diff --git a/src/backend/tests/hosts/status/jump-probe-pool.test.ts b/src/backend/tests/plugins/probe/jump-probe-pool.test.ts similarity index 98% rename from src/backend/tests/hosts/status/jump-probe-pool.test.ts rename to src/backend/tests/plugins/probe/jump-probe-pool.test.ts index 8d5164834..77919f10b 100644 --- a/src/backend/tests/hosts/status/jump-probe-pool.test.ts +++ b/src/backend/tests/plugins/probe/jump-probe-pool.test.ts @@ -1,6 +1,6 @@ import { EventEmitter } from "node:events"; import { afterEach, describe, expect, it, vi } from "vitest"; -import { JumpProbePool } from "../../../hosts/status/jump-probe-pool.js"; +import { JumpProbePool } from "../../../plugins/probe/jump-probe-pool.js"; function fakeClient(reachable = true) { const client = Object.assign(new EventEmitter(), { diff --git a/src/backend/tests/hosts/status/tcp-ping.test.ts b/src/backend/tests/plugins/probe/tcp-ping.test.ts similarity index 95% rename from src/backend/tests/hosts/status/tcp-ping.test.ts rename to src/backend/tests/plugins/probe/tcp-ping.test.ts index c9b69f56b..97d3dcf88 100644 --- a/src/backend/tests/hosts/status/tcp-ping.test.ts +++ b/src/backend/tests/plugins/probe/tcp-ping.test.ts @@ -1,6 +1,6 @@ import type { Client } from "ssh2"; import { describe, expect, it, vi } from "vitest"; -import { tcpPingThroughJumpHost } from "../../../hosts/status/tcp-ping.js"; +import { tcpPingThroughJumpHost } from "../../../plugins/probe/tcp-ping.js"; describe("tcpPingThroughJumpHost", () => { it("reports the final destination online when forwarding succeeds", async () => { diff --git a/src/backend/tests/plugins/registry-index.test.ts b/src/backend/tests/plugins/registry-index.test.ts index bba89dfde..d196ec898 100644 --- a/src/backend/tests/plugins/registry-index.test.ts +++ b/src/backend/tests/plugins/registry-index.test.ts @@ -12,6 +12,10 @@ vi.mock("../../utils/safe-outbound-fetch.js", () => ({ safeOutboundFetch: fetchMock, })); +vi.mock("../../utils/app-version.js", () => ({ + getLocalVersion: () => "26.10.3", +})); + vi.mock("../../plugins/trust.js", async (importOriginal) => { const actual = await importOriginal(); @@ -44,7 +48,9 @@ import { findRelease, latestOnChannel, getStatsUrl, + canRunRelease, isApiCompatible, + neededTermix, parseRegistryIndex, parseRegistryStats, resetRegistryCache, @@ -239,6 +245,56 @@ describe("findRelease", () => { }); }); +describe("engine ranges", () => { + const engine = (termix: string, api = "^1.0") => ({ api, termix }); + + it("reads gated releases into versions and prereleases", () => { + const index = parseRegistryIndex({ + plugins: [ + { + id: "classic-layout", + versions: [], + gated: [ + version({ version: "1.1.0-beta.1", engine: engine(">=26.10.3") }), + version({ version: "1.0.0", engine: engine(">=26.10.3") }), + ], + contributes: ["layouts", "tabs", "not valid"], + }, + ], + }); + const [plugin] = index.plugins; + expect(plugin.versions.map((v) => v.version)).toEqual(["1.0.0"]); + expect(plugin.prereleases.map((v) => v.version)).toEqual(["1.1.0-beta.1"]); + expect(plugin.contributes).toEqual(["layouts", "tabs"]); + }); + + it("checks engine.termix and the full engine.api", () => { + expect(canRunRelease(version({ engine: engine(">=26.10.3") }))).toBe(true); + expect(canRunRelease(version({ engine: engine(">=26.11.0") }))).toBe(false); + expect(neededTermix(version({ engine: engine(">=26.11.0") }))).toBe( + ">=26.11.0", + ); + expect(neededTermix(version({ engine: engine(">=26.10.0") }))).toBeNull(); + expect( + canRunRelease(version({ engine: engine(">=26.10.0", "^1.99") })), + ).toBe(false); + expect(canRunRelease(version())).toBe(true); + }); + + it("skips a release this build is too old for", () => { + const index = parseRegistryIndex({ + plugins: [ + { + id: "docker", + versions: [version({ version: "1.0.0" })], + gated: [version({ version: "1.1.0", engine: engine(">=26.11.0") })], + }, + ], + }); + expect(findRelease(index, "docker")?.release.version).toBe("1.0.0"); + }); +}); + describe("prereleases", () => { const index = parseRegistryIndex({ plugins: [ diff --git a/src/backend/tests/plugins/sdk-ssh-keys.test.ts b/src/backend/tests/plugins/sdk-ssh-keys.test.ts new file mode 100644 index 000000000..c49c59113 --- /dev/null +++ b/src/backend/tests/plugins/sdk-ssh-keys.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, it } from "vitest"; +import { + detectSshKeyType, + generateSshKeyPair, + publicKeyFromPrivate, + validateSshKeyPair, +} from "../../../../packages/plugin-sdk/src/ssh-keys.js"; + +describe("plugin SDK ssh-keys", () => { + it("generates a usable ed25519 pair", () => { + const pair = generateSshKeyPair("ssh-ed25519", { comment: "test" }); + expect(pair.publicKey).toMatch(/^ssh-ed25519 /); + expect(detectSshKeyType(pair.privateKey)).toBe("ssh-ed25519"); + expect(detectSshKeyType(pair.publicKey)).toBe("ssh-ed25519"); + expect(validateSshKeyPair(pair.privateKey, pair.publicKey)).toEqual({ + isValid: true, + }); + }); + + it("derives the public key and ignores the comment when matching", () => { + const pair = generateSshKeyPair("ecdsa-sha2-nistp256"); + const derived = publicKeyFromPrivate(pair.privateKey); + expect(derived).toMatch(/^ecdsa-sha2-nistp256 /); + expect( + validateSshKeyPair(pair.privateKey, `${derived} someone@host`).isValid, + ).toBe(true); + }); + + it("generates RSA with a minimum size", () => { + expect(() => generateSshKeyPair("ssh-rsa", { bits: 512 })).toThrow(/2048/); + const pair = generateSshKeyPair("ssh-rsa", { bits: 2048 }); + expect(detectSshKeyType(pair.privateKey)).toBe("ssh-rsa"); + }); + + it("works with a passphrase and fails without it", () => { + const pair = generateSshKeyPair("ssh-ed25519", { passphrase: "secret" }); + expect(publicKeyFromPrivate(pair.privateKey)).toBeNull(); + expect(publicKeyFromPrivate(pair.privateKey, "secret")).toBe( + pair.publicKey.split(" ").slice(0, 2).join(" "), + ); + }); + + it("flags a pair that does not belong together", () => { + const a = generateSshKeyPair("ssh-ed25519"); + const b = generateSshKeyPair("ssh-ed25519"); + const result = validateSshKeyPair(a.privateKey, b.publicKey); + expect(result.isValid).toBe(false); + expect(result.error).toMatch(/match/); + }); + + it("reports garbage as unreadable", () => { + expect(detectSshKeyType("not a key")).toBeNull(); + expect(publicKeyFromPrivate("not a key")).toBeNull(); + expect(validateSshKeyPair("x", "y").isValid).toBe(false); + }); + + it("rejects an unknown key type", () => { + expect(() => generateSshKeyPair("ssh-dss" as never)).toThrow(/Unsupported/); + }); +}); diff --git a/src/backend/tests/plugins/security/ctx-guards.test.ts b/src/backend/tests/plugins/security/ctx-guards.test.ts index b0838c38c..ed5ff31f0 100644 --- a/src/backend/tests/plugins/security/ctx-guards.test.ts +++ b/src/backend/tests/plugins/security/ctx-guards.test.ts @@ -71,6 +71,7 @@ const UNGATED: Record = { "rbac.hasFor": "a yes/no about a role permission, grants nothing", "rbac.require": "route middleware for the plugin's own permissions", "capabilities.has": "a yes/no about the plugin's own grants", + "auth.listSecondFactors": "ids of the second factors, no user data", "disposables.add": "the plugin's own cleanup", "schedule.every": "the plugin's own timers", "schedule.after": "the plugin's own timers", @@ -79,6 +80,7 @@ const UNGATED: Record = { "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", + "desktop.local.available": "a yes/no about the environment", "http.baseUrl": "reads the request's public origin", "ssh.poolKey": "a string built from a host the plugin already holds", "ssh.dropPooled": "closes only this plugin's own pooled connections", @@ -162,7 +164,15 @@ describe("every privileged ctx member is guarded", () => { let caught: unknown; await runAsActor(ACTOR, "request", async () => { try { - await member.fn(...PLACEHOLDER_ARGS); + const result = await member.fn(...PLACEHOLDER_ARGS); + // An async generator only runs its guard on the first next(). + if ( + result && + typeof (result as AsyncGenerator).next === "function" && + Symbol.asyncIterator in (result as object) + ) { + await (result as AsyncGenerator).next(); + } } catch (error) { caught = error; } diff --git a/src/backend/tests/plugins/theme-css.test.ts b/src/backend/tests/plugins/theme-css.test.ts new file mode 100644 index 000000000..3455d1eb1 --- /dev/null +++ b/src/backend/tests/plugins/theme-css.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, it } from "vitest"; +import { validateThemeCss } from "../../plugins/manifest.js"; + +describe("validateThemeCss", () => { + it("accepts token rules scoped under the theme class", () => { + const css = ` + /* Paper */ + .paper { --background: #fff; --foreground: #111; } + :root.paper, html.paper .card { --card: #f8f8f8; } + @media (prefers-reduced-motion: reduce) { + .paper * { transition: none; } + } + @supports (color: oklch(0 0 0)) { .paper { --border: oklch(0.9 0 0); } } + .paper .logo { background: url("logo.svg") no-repeat; } + .paper .dot { background: url(data:image/png;base64,AAAA); } + `; + expect(validateThemeCss(css, "paper")).toEqual([]); + }); + + it("refuses selectors outside the theme", () => { + const errors = validateThemeCss( + ".paper { --a: 1 } body { color: red } .paperish { --b: 2 } .dark { --c: 3 }", + "paper", + ); + expect(errors).toHaveLength(3); + expect(errors[0]).toContain('"body"'); + }); + + it("refuses anything that loads or runs", () => { + const errors = validateThemeCss( + `@import url("https://evil.example/x.css"); + .paper { background: url(https://evil.example/a.png); } + .paper { background: url(//evil.example/a.png); } + .paper { width: expression(alert(1)); } + .paper { background: url(javascript:alert(1)); } + .paper { behavior: url(x.htc); } + @font-face { font-family: x; src: url(x.woff2); } + @keyframes spin { from { opacity: 0 } }`, + "paper", + ); + const has = (text: string) => errors.some((e) => e.includes(text)); + expect(has("@import is not allowed")).toBe(true); + expect(has("url(https://evil.example/a.png) points outside")).toBe(true); + expect(has("url(//evil.example/a.png) points outside")).toBe(true); + expect(has("expression() is not allowed")).toBe(true); + expect(has("javascript: is not allowed")).toBe(true); + expect(has("Script bindings")).toBe(true); + expect(has("@font-face is not allowed")).toBe(true); + expect(has("@keyframes is not allowed")).toBe(true); + }); + + it("refuses nested rules and broken braces", () => { + expect(validateThemeCss(".paper { .x { color: red } }", "paper")).toEqual([ + 'Nested rules are not allowed in ".paper"', + ]); + expect(validateThemeCss(".paper { color: red", "paper")).toEqual([ + "A rule is missing its closing brace", + ]); + }); + + it("does not trip on braces inside strings", () => { + expect( + validateThemeCss('.paper .x::after { content: "{ }"; }', "paper"), + ).toEqual([]); + }); +}); diff --git a/src/backend/tests/plugins/user-data.test.ts b/src/backend/tests/plugins/user-data.test.ts index 66d0b135c..4dc5d3183 100644 --- a/src/backend/tests/plugins/user-data.test.ts +++ b/src/backend/tests/plugins/user-data.test.ts @@ -155,6 +155,30 @@ describe("plugin user data", () => { }); }); + it("exports a table with a text primary key", async () => { + const credentials = defineTable("credential", { + id: varchar(64).primaryKey(), + userId: refUser().notNull(), + label: text(), + }); + for (const statement of createTableSql("sqlite", "fixture", credentials)) { + sqlite.exec(statement); + } + registerTable("fixture", credentials); + sqlite.exec( + "INSERT INTO p_fixture_credential (id, user_id, label) VALUES ('abc', 'u1', 'key')", + ); + + const file = new Database(":memory:"); + file.exec("CREATE TABLE users (id TEXT PRIMARY KEY)"); + file.exec("CREATE TABLE ssh_data (id INTEGER PRIMARY KEY)"); + file.exec("INSERT INTO users (id) VALUES ('u1')"); + await writeUserPluginTables(file, "u1"); + expect(file.prepare("SELECT * FROM p_fixture_credential").all()).toEqual([ + { id: "abc", user_id: "u1", label: "key" }, + ]); + }); + it("writes large tables in chunks and lets the event loop run between them", async () => { const insert = sqlite.prepare( "INSERT INTO p_fixture_bookmark (user_id, path) VALUES ('u1', ?)", diff --git a/src/backend/tests/plugins/ws.test.ts b/src/backend/tests/plugins/ws.test.ts index 13c21522c..766eb4ccf 100644 --- a/src/backend/tests/plugins/ws.test.ts +++ b/src/backend/tests/plugins/ws.test.ts @@ -154,6 +154,26 @@ describe("ctx.ws routing", () => { if (result.outcome === "open") result.socket.close(); }); + it("serves a socket core owns without any plugin grant", async () => { + state.granted = new Set(); + const seen: string[] = []; + ws.registerCoreWsRoute("/relay", ({ userId }) => { + seen.push(userId); + }); + + const base = await startServer(); + const signedIn = await connect( + `${base}/plugin-ws/core/relay`, + authProtocols(state.validToken), + ); + const anonymous = await connect(`${base}/plugin-ws/core/relay`); + + expect(signedIn.outcome).toBe("open"); + expect(anonymous.outcome).toBe(401); + expect(seen).toEqual([state.userId]); + if (signedIn.outcome === "open") signedIn.socket.close(); + }); + it("404s a path nothing registered", async () => { const base = await startServer(); diff --git a/src/backend/tests/sync/accounts.test.ts b/src/backend/tests/sync/accounts.test.ts new file mode 100644 index 000000000..e0676d2cd --- /dev/null +++ b/src/backend/tests/sync/accounts.test.ts @@ -0,0 +1,116 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + users: [] as Array<{ + id: string; + username: string; + isAdmin: boolean; + registeredAt: string; + }>, + saved: null as string | null, + create: vi.fn(), + assignRole: vi.fn(), + registerUser: vi.fn(), + deleteUserAndRelatedData: vi.fn(), + set: vi.fn(), +})); + +vi.mock("../../database/repositories/factory.js", () => ({ + createCurrentUserRepository: () => ({ + listAll: async () => mocks.users, + findById: async (id: string) => + mocks.users.find((u) => u.id === id) ?? null, + create: mocks.create, + }), + createCurrentRoleRepository: () => ({ + assignRoleNameToUser: mocks.assignRole, + }), + createCurrentSettingsRepository: () => ({ + get: async () => mocks.saved, + set: async (key: string, value: string) => { + mocks.set(key, value); + mocks.saved = value; + }, + }), +})); +vi.mock("../../utils/auth-manager.js", () => ({ + AuthManager: { getInstance: () => ({ registerUser: mocks.registerUser }) }, +})); +vi.mock("../../database/routes/delete-user-data.js", () => ({ + deleteUserAndRelatedData: mocks.deleteUserAndRelatedData, +})); + +const { + createAccount, + getActiveUserId, + listAccounts, + removeAccount, + setActiveUserId, +} = await import("../../sync/accounts.js"); + +function user( + id: string, + isAdmin = false, + registeredAt = "2026-01-01T00:00:00Z", +) { + return { id, username: id, isAdmin, registeredAt }; +} + +beforeEach(() => { + vi.clearAllMocks(); + mocks.saved = null; + mocks.users = [ + user("local", true), + user("work", false, "2026-02-01T00:00:00Z"), + ]; +}); + +describe("desktop accounts", () => { + it("signs in as the usual user until one is chosen", async () => { + expect(await getActiveUserId()).toBe("local"); + await setActiveUserId("work"); + expect(await getActiveUserId()).toBe("work"); + }); + + it("will not switch to an account that does not exist", async () => { + await expect(setActiveUserId("nope")).rejects.toThrow("No such account"); + expect(mocks.set).not.toHaveBeenCalled(); + }); + + it("lists the accounts with the active one marked", async () => { + await setActiveUserId("work"); + expect(await listAccounts()).toEqual([ + expect.objectContaining({ id: "local", active: false }), + expect.objectContaining({ id: "work", active: true }), + ]); + }); + + it("makes an empty admin account with its own data key", async () => { + const id = await createAccount(); + expect(mocks.create).toHaveBeenCalledWith( + expect.objectContaining({ id, isAdmin: true, passwordHash: "" }), + ); + expect(mocks.assignRole).toHaveBeenCalledWith( + expect.objectContaining({ userId: id, roleName: "admin" }), + ); + expect(mocks.registerUser).toHaveBeenCalledWith(id, expect.any(String)); + }); + + it("removes an account and its data", async () => { + await removeAccount("work"); + expect(mocks.deleteUserAndRelatedData).toHaveBeenCalledWith("work"); + }); + + it("moves to another account when the active one is removed", async () => { + await setActiveUserId("work"); + mocks.set.mockClear(); + await removeAccount("work"); + expect(mocks.set).toHaveBeenCalledWith("desktop_active_user", "local"); + }); + + it("keeps the last account", async () => { + mocks.users = [user("local", true)]; + await expect(removeAccount("local")).rejects.toThrow("last account"); + expect(mocks.deleteUserAndRelatedData).not.toHaveBeenCalled(); + }); +}); diff --git a/src/backend/tests/sync/client/client-cert.test.ts b/src/backend/tests/sync/client/client-cert.test.ts new file mode 100644 index 000000000..706872a86 --- /dev/null +++ b/src/backend/tests/sync/client/client-cert.test.ts @@ -0,0 +1,204 @@ +import { execFileSync } from "node:child_process"; +import fs from "node:fs"; +import https from "node:https"; +import os from "node:os"; +import path from "node:path"; +import type { AddressInfo } from "node:net"; +import { sql } from "drizzle-orm"; +import { + afterAll, + afterEach, + beforeAll, + beforeEach, + describe, + expect, + it, + vi, +} from "vitest"; + +const state = vi.hoisted(() => ({ drizzle: null as unknown })); + +vi.mock("../../../database/db/index.js", () => ({ + getDb: () => state.drizzle, + getSqlite: () => null, +})); +vi.mock("../../../utils/database-save-trigger.js", () => ({ + DatabaseSaveTrigger: { + triggerSave: vi.fn(), + forceSave: vi.fn(async () => {}), + }, +})); +vi.mock("../../../utils/system-crypto.js", () => ({ + SystemCrypto: { + getInstance: () => ({ getEncryptionKey: async () => Buffer.alloc(32, 5) }), + }, +})); + +const { TestSqliteDatabase } = + await import("../../database/repositories/test-support.js"); +const { createLink, getLink, updateLink } = + await import("../../../sync/client/link-store.js"); +const { RemoteError, remoteJson } = + await import("../../../sync/client/http.js"); + +function hasOpenssl(): boolean { + try { + execFileSync("openssl", ["version"], { stdio: "ignore" }); + return true; + } catch { + return false; + } +} + +const openssl = hasOpenssl(); +const PASSPHRASE = "pfx-pass"; +let dir = ""; +let pfx = ""; +let serverKey = ""; +let serverCert = ""; +let clientCert = ""; + +function run(args: string[]) { + execFileSync("openssl", args, { cwd: dir, stdio: "ignore" }); +} + +function selfSigned(name: string) { + run([ + "req", + "-x509", + "-newkey", + "rsa:2048", + "-nodes", + "-days", + "2", + "-keyout", + `${name}.key`, + "-out", + `${name}.crt`, + "-subj", + `/CN=${name}`, + ]); +} + +beforeAll(() => { + if (!openssl) return; + dir = fs.mkdtempSync(path.join(os.tmpdir(), "termix-mtls-")); + selfSigned("server"); + selfSigned("client"); + run([ + "pkcs12", + "-export", + "-inkey", + "client.key", + "-in", + "client.crt", + "-out", + "client.p12", + "-passout", + `pass:${PASSPHRASE}`, + ]); + pfx = fs.readFileSync(path.join(dir, "client.p12")).toString("base64"); + serverKey = fs.readFileSync(path.join(dir, "server.key"), "utf8"); + serverCert = fs.readFileSync(path.join(dir, "server.crt"), "utf8"); + clientCert = fs.readFileSync(path.join(dir, "client.crt"), "utf8"); +}); + +afterAll(() => { + if (dir) fs.rmSync(dir, { recursive: true, force: true }); +}); + +describe.skipIf(!openssl)("client certificates", () => { + let server: https.Server; + let base: string; + + beforeAll(async () => { + server = https.createServer( + { + key: serverKey, + cert: serverCert, + ca: [clientCert], + requestCert: true, + rejectUnauthorized: true, + }, + (_req, res) => { + res.setHeader("Content-Type", "application/json"); + res.end(JSON.stringify({ ok: true })); + }, + ); + await new Promise((resolve) => + server.listen(0, "127.0.0.1", resolve), + ); + base = `https://127.0.0.1:${(server.address() as AddressInfo).port}`; + }); + + afterAll(() => new Promise((resolve) => server.close(() => resolve()))); + + it("presents the PFX to a server that requires one", async () => { + const result = await remoteJson<{ ok: boolean }>( + { + serverUrl: base, + allowInvalidCertificate: true, + clientCert: { pfx, passphrase: PASSPHRASE, name: "client.p12" }, + }, + "/x", + ); + expect(result.ok).toBe(true); + }); + + it("reports a missing client certificate as its own error", async () => { + const error = await remoteJson( + { serverUrl: base, allowInvalidCertificate: true }, + "/x", + ).catch((e) => e); + expect(error).toBeInstanceOf(RemoteError); + expect((error as InstanceType).kind).toBe( + "client_cert", + ); + }); + + describe("on the link", () => { + let db: InstanceType; + + beforeEach(async () => { + db = new TestSqliteDatabase("sqlite"); + state.drizzle = (await db.connect()).drizzle; + await db.run( + sql`INSERT INTO users (id, username, password_hash) VALUES ('local', 'local', '')`, + ); + }); + + afterEach(async () => { + await db.close(); + }); + + it("stores the PFX encrypted and keeps it through other updates", async () => { + await createLink({ + userId: "local", + serverUrl: "https://termix.example", + sessionToken: "t", + clientCert: { pfx, passphrase: PASSPHRASE, name: "client.p12" }, + }); + const raw = await db.query(sql`SELECT scope FROM sync_link`); + expect(JSON.stringify(raw)).not.toContain(pfx.slice(0, 40)); + + await updateLink({ disabledTypes: ["hosts"] }, "local"); + const link = await getLink("local"); + expect(link?.clientCert?.pfx).toBe(pfx); + expect(link?.disabledTypes).toEqual(["hosts"]); + + await updateLink({ clientCert: null }, "local"); + expect((await getLink("local"))?.clientCert).toBeNull(); + }); + + it("rejects a PFX with the wrong passphrase", async () => { + await expect( + createLink({ + userId: "local", + serverUrl: "https://termix.example", + sessionToken: "t", + clientCert: { pfx, passphrase: "nope", name: null }, + }), + ).rejects.toThrow(/not valid/); + }); + }); +}); diff --git a/src/backend/tests/sync/client/link-store.test.ts b/src/backend/tests/sync/client/link-store.test.ts index b6acd908f..88bb0b1a1 100644 --- a/src/backend/tests/sync/client/link-store.test.ts +++ b/src/backend/tests/sync/client/link-store.test.ts @@ -21,8 +21,9 @@ vi.mock("../../../utils/system-crypto.js", () => ({ const { TestSqliteDatabase } = await import("../../database/repositories/test-support.js"); -const { createLink, deleteLink, getLink, updateLink } = +const { createLink, deleteLink, getLink, listLinks, updateLink } = await import("../../../sync/client/link-store.js"); +const { setActiveUserId } = await import("../../../sync/accounts.js"); let db: InstanceType; @@ -96,3 +97,79 @@ describe("the desktop's link", () => { expect(await updateLink({ cursor: 1 })).toBeNull(); }); }); + +describe("several links", () => { + beforeEach(async () => { + await db.run(sql`UPDATE users SET is_admin = 1 WHERE id = 'local'`); + await db.run( + sql`INSERT INTO users (id, username, password_hash) VALUES ('second', 'second', '')`, + ); + }); + + it("keeps one link per local user", async () => { + await createLink({ + userId: "local", + serverUrl: "https://a", + sessionToken: "a", + }); + await createLink({ + userId: "second", + serverUrl: "https://b", + sessionToken: "b", + }); + expect((await listLinks()).map((link) => link.serverUrl).sort()).toEqual([ + "https://a", + "https://b", + ]); + expect((await getLink("second"))?.serverUrl).toBe("https://b"); + expect((await getLink("local"))?.serverUrl).toBe("https://a"); + }); + + it("answers for the active account when no user is given", async () => { + await createLink({ + userId: "local", + serverUrl: "https://a", + sessionToken: "a", + }); + await createLink({ + userId: "second", + serverUrl: "https://b", + sessionToken: "b", + }); + expect((await getLink())?.serverUrl).toBe("https://a"); + await setActiveUserId("second"); + expect((await getLink())?.serverUrl).toBe("https://b"); + }); + + it("changes and removes only the link it is asked about", async () => { + await createLink({ + userId: "local", + serverUrl: "https://a", + sessionToken: "a", + }); + await createLink({ + userId: "second", + serverUrl: "https://b", + sessionToken: "b", + }); + await updateLink({ cursor: 9, status: "offline" }, "second"); + expect((await getLink("local"))?.cursor).toBe(0); + expect((await getLink("second"))?.cursor).toBe(9); + await deleteLink("second"); + expect(await getLink("second")).toBeNull(); + expect((await getLink("local"))?.serverUrl).toBe("https://a"); + }); + + it("stores connection options without touching the type switches", async () => { + await createLink({ + userId: "local", + serverUrl: "https://a", + sessionToken: "a", + }); + await updateLink({ disabledTypes: ["hosts"] }, "local"); + await updateLink({ options: { clientCertificate: "work" } }, "local"); + const link = await getLink("local"); + expect(link?.options).toEqual({ clientCertificate: "work" }); + expect(link?.disabledTypes).toEqual(["hosts"]); + }); +}); diff --git a/src/backend/tests/sync/entity-labels.test.ts b/src/backend/tests/sync/entity-labels.test.ts deleted file mode 100644 index b59a1f491..000000000 --- a/src/backend/tests/sync/entity-labels.test.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { afterEach, describe, expect, it, vi } from "vitest"; -import fs from "node:fs"; - -vi.mock("../../database/db/index.js", () => ({})); - -import { registerCoreSyncEntities } from "../../sync/entities.js"; -import { - listEntityTypes, - resetSyncRegistry, -} from "../../plugins/sync-registry.js"; - -afterEach(() => resetSyncRegistry()); - -describe("core sync entities", () => { - it("each has a label in the Sync panel", () => { - registerCoreSyncEntities(); - const en = JSON.parse( - fs.readFileSync( - new URL("../../../ui/locales/en.json", import.meta.url), - "utf8", - ), - ); - const missing = listEntityTypes().filter((type) => !en.sync.entities[type]); - expect(missing).toEqual([]); - }); -}); diff --git a/src/backend/tests/sync/host.test.ts b/src/backend/tests/sync/host.test.ts new file mode 100644 index 000000000..e08fcb7bd --- /dev/null +++ b/src/backend/tests/sync/host.test.ts @@ -0,0 +1,217 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + listChanges: vi.fn(), + findHostIdBySyncId: vi.fn(), + canAccessHost: vi.fn(), + findById: vi.fn(), + getLink: vi.fn(), + generateJWTToken: vi.fn(), + revokeSession: vi.fn(), +})); + +vi.mock("../../utils/auth-manager.js", () => ({ + AuthManager: { + getInstance: () => ({ + createAuthMiddleware: () => vi.fn(), + generateJWTToken: mocks.generateJWTToken, + revokeSession: mocks.revokeSession, + }), + }, +})); +vi.mock("../../utils/logger.js", () => ({ + syncLogger: { error: vi.fn(), warn: vi.fn() }, +})); +vi.mock("../../utils/app-version.js", () => ({ + getLocalVersion: () => "26.10.3", +})); +vi.mock("../../notify/core-notify.js", () => ({ sendCoreAlert: 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 }], + onEntitiesChanged: vi.fn(), +})); +vi.mock("../../database/repositories/factory.js", () => ({ + createCurrentHostRepository: () => ({ findById: mocks.findById }), + createCurrentHostResolutionRepository: () => ({ + findHostIdBySyncId: mocks.findHostIdBySyncId, + }), + createCurrentPluginPermissionGrantRepository: vi.fn(), + createCurrentPluginRepository: vi.fn(), + createCurrentRepositoryContext: vi.fn(), + createCurrentSessionRepository: vi.fn(), + createCurrentSettingsRepository: vi.fn(), + createCurrentUserRepository: vi.fn(), +})); +vi.mock("../../database/routes/branding-settings.js", () => ({ + resolveBrandingSettings: async () => ({ appName: "Termix" }), +})); +vi.mock("../../utils/permission-manager.js", () => ({ + PermissionManager: { + getInstance: () => ({ canAccessHost: mocks.canAccessHost }), + }, +})); +vi.mock("../../sync/entities.js", () => ({ + registerCoreSyncEntities: vi.fn(), +})); +vi.mock("../../sync/store.js", () => ({})); +vi.mock("../../sync/wire.js", () => ({})); +vi.mock("../../sync/records.js", () => ({ + listChanges: mocks.listChanges, + recordKey: (type: string, id: string) => `${type}\u0000${id}`, +})); +vi.mock("../../sync/server/write-listeners.js", () => ({ + onLocalWrite: vi.fn(), +})); +vi.mock("../../sync/accounts.js", () => ({ + createAccount: vi.fn(), + getActiveUserId: vi.fn(), + listAccounts: vi.fn(), + removeAccount: vi.fn(), + setActiveUserId: vi.fn(), +})); +vi.mock("../../sync/server/feed.js", () => ({ + withUserLock: (_userId: string, fn: () => unknown) => fn(), + reconcileUser: async () => ({ wire: new Map() }), + addListener: vi.fn(), +})); +vi.mock("../../sync/server/push.js", () => ({})); +vi.mock("../../sync/client/link-store.js", () => ({ + getLink: mocks.getLink, + listLinks: vi.fn(), + createLink: vi.fn(), + updateLink: vi.fn(), + deleteLink: vi.fn(), +})); +vi.mock("../../sync/client/http.js", () => ({ + normalizeServerUrl: vi.fn(), + RemoteError: class RemoteError extends Error {}, + remoteFetch: vi.fn(), + remoteJson: vi.fn(), +})); +vi.mock("../../sync/client/electron-proxy.js", () => ({ + applyElectronProxyConfig: vi.fn(), +})); +vi.mock("../../sync/client/plugins.js", () => ({ + lastRemotePlugins: () => [], + mirrorPlugins: vi.fn(), +})); + +const { createSyncHost } = await import("../../sync/host.js"); + +beforeEach(() => { + vi.clearAllMocks(); + mocks.listChanges.mockResolvedValue([]); +}); + +describe("host.feed.changes", () => { + it("answers an empty page with the entity types past the newest change", async () => { + const cursor = Number.MAX_SAFE_INTEGER; + const page = await createSyncHost().feed.changes("user-1", cursor, 1000); + expect(page).toEqual({ + records: [], + hasMore: false, + nextCursor: cursor, + entityTypes: [ + { type: "hosts", owner: "core", order: 10, readOnly: false }, + ], + }); + }); + + it("asks for one more row than the limit to know if more remain", async () => { + await createSyncHost().feed.changes("user-1", 0, 1000); + expect(mocks.listChanges).toHaveBeenCalledWith( + "user-1", + 0, + 1001, + undefined, + ); + }); + + it("leaves out records of types this server does not run", async () => { + mocks.listChanges.mockResolvedValue([ + { seq: 5, entityType: "gone", syncId: "a", revision: 1, deleted: false }, + ]); + const page = await createSyncHost().feed.changes("user-1", 0, 10); + expect(page.records).toEqual([]); + expect(page.nextCursor).toBe(5); + }); +}); + +describe("host.hosts.findForSync", () => { + beforeEach(() => { + mocks.findHostIdBySyncId.mockResolvedValue(41); + mocks.canAccessHost.mockImplementation(async (_user, _host, action) => ({ + hasAccess: action === "connect", + })); + mocks.findById.mockResolvedValue({ name: "Shared host", ip: "10.0.0.1" }); + }); + + it("resolves the server ID for a connect only recipient", async () => { + const found = await createSyncHost().hosts.findForSync( + "recipient", + "host-sync-id", + ); + expect(mocks.canAccessHost).toHaveBeenCalledWith( + "recipient", + 41, + "connect", + ); + expect(found).toEqual({ id: 41, name: "Shared host" }); + }); + + it("does not disclose a host the caller cannot connect to", async () => { + mocks.canAccessHost.mockResolvedValue({ hasAccess: false }); + expect(await createSyncHost().hosts.findForSync("u", "s")).toBeNull(); + }); + + it("returns null for a missing sync ID", async () => { + mocks.findHostIdBySyncId.mockResolvedValue(null); + expect(await createSyncHost().hosts.findForSync("u", "s")).toBeNull(); + expect(mocks.canAccessHost).not.toHaveBeenCalled(); + }); +}); + +describe("host.sessions", () => { + it("issues a long desktop session that shows in the sessions list", async () => { + mocks.generateJWTToken.mockResolvedValue("token"); + const token = await createSyncHost().sessions.issueDesktop("u1", { + days: 365, + deviceInfo: "Laptop (sync)", + }); + expect(token).toBe("token"); + expect(mocks.generateJWTToken).toHaveBeenCalledWith("u1", { + expiresIn: "365d", + deviceType: "desktop", + deviceInfo: "Laptop (sync)", + }); + }); + + it("answers 404 outside the desktop app", () => { + const previous = process.env.ELECTRON_EMBEDDED; + delete process.env.ELECTRON_EMBEDDED; + const res = { status: vi.fn().mockReturnThis(), json: vi.fn() }; + const next = vi.fn(); + ( + createSyncHost().sessions.desktopOnly() as ( + req: unknown, + res: unknown, + next: () => void, + ) => void + )({}, res, next); + expect(res.status).toHaveBeenCalledWith(404); + expect(next).not.toHaveBeenCalled(); + if (previous !== undefined) process.env.ELECTRON_EMBEDDED = previous; + }); +}); + +describe("host.links", () => { + it("reads the linked server registry", async () => { + mocks.getLink.mockResolvedValue({ id: 1, serverUrl: "https://a.example" }); + expect(await createSyncHost().links.get()).toEqual({ + id: 1, + serverUrl: "https://a.example", + }); + }); +}); diff --git a/src/backend/tests/sync/provider.test.ts b/src/backend/tests/sync/provider.test.ts new file mode 100644 index 000000000..c2c00a06e --- /dev/null +++ b/src/backend/tests/sync/provider.test.ts @@ -0,0 +1,66 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("../../utils/logger.js", () => ({ + syncLogger: { warn: vi.fn() }, +})); + +const { clearSyncProviders, hasSyncProvider, provideSync, syncForwarder } = + await import("../../sync/provider.js"); + +function response() { + const res = { + headersSent: false, + status: vi.fn(() => res), + json: vi.fn(() => res), + }; + return res; +} + +beforeEach(() => clearSyncProviders()); + +describe("sync forwarder", () => { + it("passes through when nothing provides sync", () => { + const next = vi.fn(); + syncForwarder({} as never, response() as never, next); + expect(hasSyncProvider()).toBe(false); + expect(next).toHaveBeenCalledWith(); + }); + + it("hands the request to the provider", () => { + const handle = vi.fn(); + provideSync("a", { handle }); + const next = vi.fn(); + syncForwarder({} as never, response() as never, next); + expect(handle).toHaveBeenCalledOnce(); + expect(next).not.toHaveBeenCalled(); + }); + + it("moves on when the provider calls next", () => { + provideSync("a", { handle: (_req, _res, pass) => pass() }); + const second = vi.fn(); + provideSync("b", { handle: second }); + syncForwarder({} as never, response() as never, vi.fn()); + expect(second).toHaveBeenCalledOnce(); + }); + + it("answers 500 when the provider throws", async () => { + provideSync("a", { + handle: async () => { + throw new Error("boom"); + }, + }); + const res = response(); + syncForwarder({} as never, res as never, vi.fn()); + await Promise.resolve(); + await Promise.resolve(); + expect(res.status).toHaveBeenCalledWith(500); + }); + + it("stops forwarding after the provider is removed", () => { + const remove = provideSync("a", { handle: vi.fn() }); + remove(); + const next = vi.fn(); + syncForwarder({} as never, response() as never, next); + expect(next).toHaveBeenCalledWith(); + }); +}); diff --git a/src/backend/tests/sync/server/changes.test.ts b/src/backend/tests/sync/server/changes.test.ts deleted file mode 100644 index 75199a71a..000000000 --- a/src/backend/tests/sync/server/changes.test.ts +++ /dev/null @@ -1,75 +0,0 @@ -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/server/host-lookup.test.ts b/src/backend/tests/sync/server/host-lookup.test.ts deleted file mode 100644 index f410f6d0f..000000000 --- a/src/backend/tests/sync/server/host-lookup.test.ts +++ /dev/null @@ -1,74 +0,0 @@ -import { beforeEach, expect, it, vi } from "vitest"; - -const { findHostIdBySyncId, canAccessHost } = vi.hoisted(() => ({ - findHostIdBySyncId: vi.fn(), - canAccessHost: 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("../../../database/repositories/factory.js", () => ({ - createCurrentHostResolutionRepository: () => ({ findHostIdBySyncId }), - createCurrentHostRepository: () => ({ - findById: async () => ({ name: "Shared host" }), - }), -})); -vi.mock("../../../utils/permission-manager.js", () => ({ - PermissionManager: { getInstance: () => ({ canAccessHost }) }, -})); -vi.mock("../../../sync/entities.js", () => ({ - registerCoreSyncEntities: vi.fn(), -})); -vi.mock("../../../sync/records.js", () => ({})); -vi.mock("../../../sync/server/feed.js", () => ({})); -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/hosts/:syncId", -)!.route!; -const handler = route.stack[route.stack.length - 1].handle; - -beforeEach(() => { - vi.clearAllMocks(); - findHostIdBySyncId.mockResolvedValue(41); - canAccessHost.mockImplementation(async (_user, _host, action) => ({ - hasAccess: action === "connect", - })); -}); - -async function lookup() { - const res = { status: vi.fn().mockReturnThis(), json: vi.fn() }; - await handler( - { userId: "recipient", params: { syncId: "host-sync-id" } } as never, - res as never, - vi.fn(), - ); - return res; -} - -it("resolves the server ID for a connect-only recipient", async () => { - const res = await lookup(); - expect(findHostIdBySyncId).toHaveBeenCalledWith("host-sync-id"); - expect(canAccessHost).toHaveBeenCalledWith("recipient", 41, "connect"); - expect(res.json).toHaveBeenCalledWith({ id: 41, name: "Shared host" }); -}); - -it("does not disclose a host the caller cannot connect to", async () => { - canAccessHost.mockResolvedValue({ hasAccess: false }); - const res = await lookup(); - expect(res.status).toHaveBeenCalledWith(404); - expect(res.json).toHaveBeenCalledWith({ error: "Not found" }); -}); - -it("returns 404 for a missing sync ID", async () => { - findHostIdBySyncId.mockResolvedValue(null); - const res = await lookup(); - expect(res.status).toHaveBeenCalledWith(404); - expect(canAccessHost).not.toHaveBeenCalled(); -}); diff --git a/src/backend/tests/sync/shown-identity.test.ts b/src/backend/tests/sync/shown-identity.test.ts new file mode 100644 index 000000000..fd2c293e7 --- /dev/null +++ b/src/backend/tests/sync/shown-identity.test.ts @@ -0,0 +1,59 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const getLink = vi.hoisted(() => vi.fn()); +vi.mock("../../sync/client/link-store.js", () => ({ getLink })); + +const { shownIdentity } = await import("../../sync/shown-identity.js"); + +const local = { id: "u1", username: "server-ab12", isAdmin: true }; + +beforeEach(() => { + process.env.ELECTRON_EMBEDDED = "true"; + getLink.mockReset(); +}); + +afterEach(() => { + delete process.env.ELECTRON_EMBEDDED; +}); + +describe("who the app shows", () => { + it("shows the local user when nothing is linked", async () => { + getLink.mockResolvedValue(null); + expect(await shownIdentity(local)).toMatchObject({ + username: "server-ab12", + isAdmin: true, + linked: null, + }); + }); + + it("shows the account on the server once linked, with its own admin flag", async () => { + getLink.mockResolvedValue({ + serverUrl: "https://home.example", + serverName: "Home", + remoteUsername: "luke", + account: { username: "luke", isAdmin: false }, + status: "idle", + }); + expect(await shownIdentity(local)).toMatchObject({ + username: "luke", + isAdmin: false, + }); + expect(getLink).toHaveBeenCalledWith("u1"); + }); + + it("shows the sign in name until the account has synced", async () => { + getLink.mockResolvedValue({ + serverUrl: "https://home.example", + remoteUsername: "luke", + account: null, + status: "syncing", + }); + expect((await shownIdentity(local)).username).toBe("luke"); + }); + + it("is the local user on a server", async () => { + delete process.env.ELECTRON_EMBEDDED; + expect((await shownIdentity(local)).username).toBe("server-ab12"); + expect(getLink).not.toHaveBeenCalled(); + }); +}); diff --git a/src/backend/tests/sync/two-way.test.ts b/src/backend/tests/sync/two-way.test.ts deleted file mode 100644 index 35c7915e6..000000000 --- a/src/backend/tests/sync/two-way.test.ts +++ /dev/null @@ -1,768 +0,0 @@ -/** - * A server and linked desktops, each with its own database, syncing through - * the real engine and the real /sync/v2 routes. Only the network, auth and - * the crypto keys are faked. - */ - -import express from "express"; -import request from "supertest"; -import { and, 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(() => ({ - 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"; - -vi.mock("../../database/db/index.js", () => ({ - getDb: () => state.current!.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), - decryptRecord: (_table: string, record: unknown) => record, - }, -})); -vi.mock("../../utils/permission-manager.js", () => ({ - PermissionManager: { - getInstance: () => ({ - hasPermission: async () => state.allow, - canAccessHost: async (_userId: string, hostId: number) => ({ - hasAccess: state.sharedHostIds.has(hostId), - }), - getUserPermissions: async () => [], - }), - }, -})); -vi.mock("../../utils/shared-host-secrets-manager.js", () => ({ - SharedHostSecretsManager: { - getInstance: () => ({ - resyncHost: async () => {}, - resyncHostsForCredential: async () => {}, - }), - }, -})); -vi.mock("../../utils/shared-credential-secrets-manager.js", () => ({ - SharedCredentialSecretsManager: { - getInstance: () => ({ resyncCredential: async () => {} }), - }, -})); -vi.mock("../../hosts/delete-host.js", async () => { - const { hosts } = await import("../../database/db/schema.js"); - const { eq: equals } = await import("drizzle-orm"); - return { - deleteOwnedHost: async (_userId: string, id: number) => { - await (state.current!.drizzle as DatabaseContext["drizzle"]) - .delete(hosts) - .where(equals(hosts.id, id)); - return { id, name: "" }; - }, - }; -}); -vi.mock("../../hosts/delete-credential.js", async () => { - const { sshCredentials } = await import("../../database/db/schema.js"); - const { eq: equals } = await import("drizzle-orm"); - return { - deleteOwnedCredential: async (_userId: string, id: number) => { - await (state.current!.drizzle as DatabaseContext["drizzle"]) - .delete(sshCredentials) - .where(equals(sshCredentials.id, id)); - return { name: null }; - }, - }; -}); -vi.mock("../../utils/auth-manager.js", () => ({ - AuthManager: { - getInstance: () => ({ - createAuthMiddleware: - () => (req: { userId?: string }, _res: unknown, next: () => void) => { - req.userId = SERVER_USER; - next(); - }, - generateJWTToken: async () => "desktop-session", - revokeSession: async () => true, - }), - }, -})); -vi.mock("../../notify/core-notify.js", () => ({ sendCoreAlert: vi.fn() })); -vi.mock("../../sync/client/plugins.js", () => ({ - mirrorPlugins: async () => {}, - lastRemotePlugins: () => [], - isServerManaged: () => false, -})); -vi.mock("../../sync/client/http.js", async (importOriginal) => { - const actual = - await importOriginal(); - return { - ...actual, - // The event stream is not under test. - remoteFetch: () => new Promise(() => {}), - remoteJson: async ( - _target: unknown, - path: string, - init: { method?: string; body?: unknown } = {}, - ) => { - state.paths.push(path); - const previous = state.current; - state.current = state.server; - try { - const agent = request(app); - const method = (init.method ?? "GET").toLowerCase() as "get" | "post"; - const response = await agent[method](path).send( - init.body as object | undefined, - ); - if (response.status >= 400) { - throw new actual.RemoteError( - "server", - response.body?.error ?? "error", - response.status, - ); - } - return response.body; - } finally { - state.current = previous; - } - }, - }; -}); - -const { TestSqliteDatabase } = - await import("../database/repositories/test-support.js"); -const schema = await import("../../database/db/schema.js"); -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, 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" })); -app.use("/sync", syncRouter); - -type Db = InstanceType; -interface Side { - db: Db; - ctx: DatabaseContext; - userId: string; -} - -let server: Side; -let desktops: Side[] = []; - -async function side(userId: string): Promise { - const db = new TestSqliteDatabase("sqlite"); - const ctx = await db.connect(); - await db.run( - sql`INSERT INTO users (id, username, password_hash, is_admin) VALUES (${userId}, ${userId}, '', 1)`, - ); - return { db, ctx, userId }; -} - -async function on(where: Side, fn: () => Promise): Promise { - const previous = state.current; - state.current = where.ctx; - try { - return await fn(); - } finally { - state.current = previous; - } -} - -function db(where: Side) { - return where.ctx.drizzle; -} - -async function addHost( - where: Side, - values: Partial & { name: string }, -) { - await db(where) - .insert(schema.hosts) - .values({ - userId: where.userId, - ip: "10.0.0.1", - port: 22, - username: "root", - authType: "password", - syncId: `host-${values.name}`, - ...values, - }); - // Stands in for the change watcher a write request would trip. - if (where === server) markChanged(); -} - -async function hostsOf(where: Side) { - return db(where) - .select() - .from(schema.hosts) - .where(eq(schema.hosts.userId, where.userId)); -} - -async function linkDesktop( - desktop: Side, -): Promise> { - await on(desktop, () => - createLink({ - userId: desktop.userId, - serverUrl: "https://termix.example", - sessionToken: "desktop-session", - }), - ); - return new SyncEngine(); -} - -async function pass(desktop: Side, engine: InstanceType) { - await on(desktop, () => engine.syncNow()); - const link = await on(desktop, () => getLink()); - expect(link?.lastError ?? null).toBeNull(); -} - -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")]; -}); - -afterEach(async () => { - for (const s of [server, ...desktops]) await s.db.close(); -}); - -describe("linking a desktop", () => { - it("merges both sides, translating references and keeping secrets", async () => { - const [desk] = desktops; - await on(desk, async () => { - await db(desk).insert(schema.sshCredentials).values({ - userId: desk.userId, - name: "deploy key", - authType: "password", - username: "deploy", - syncId: "cred-1", - }); - const [cred] = await db(desk).select().from(schema.sshCredentials); - await writeWireRow( - getEntity("hosts")!, - desk.userId, - { - syncId: "host-desk", - name: "desk", - ip: "10.0.0.2", - port: 22, - username: "root", - authType: "credential", - password: "s3cret", - credentialSyncId: cred.syncId, - }, - createResolvers(desk.userId), - ); - }); - await on(server, () => addHost(server, { name: "srv" })); - - const engine = await linkDesktop(desk); - await pass(desk, engine); - - const serverHosts = await on(server, () => hostsOf(server)); - expect(serverHosts.map((h) => h.name).sort()).toEqual(["desk", "srv"]); - const pushed = serverHosts.find((h) => h.name === "desk")!; - const [serverCred] = await db(server).select().from(schema.sshCredentials); - expect(serverCred.syncId).toBe("cred-1"); - expect(pushed.credentialId).toBe(serverCred.id); - // Stored encrypted, never as plaintext. - expect(pushed.password).not.toBe("s3cret"); - expect(pushed.password).toContain('"iv"'); - - const deskHosts = await on(desk, () => hostsOf(desk)); - expect(deskHosts.map((h) => h.name).sort()).toEqual(["desk", "srv"]); - }); - - it("adopts the server's version of rows both sides already have", async () => { - const [desk] = desktops; - await on(desk, () => addHost(desk, { name: "same", ip: "old" })); - await on(server, () => addHost(server, { name: "same", ip: "new" })); - - const engine = await linkDesktop(desk); - await pass(desk, engine); - - const [host] = await on(desk, () => hostsOf(desk)); - 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", () => { - it("sends edits and deletes both ways", async () => { - const [desk] = desktops; - await on(server, () => addHost(server, { name: "a" })); - await on(server, () => addHost(server, { name: "b" })); - const engine = await linkDesktop(desk); - await pass(desk, engine); - - await on(desk, async () => { - await db(desk) - .update(schema.hosts) - .set({ ip: "10.9.9.9" }) - .where(eq(schema.hosts.syncId, "host-a")); - }); - await on(server, async () => { - await db(server) - .delete(schema.hosts) - .where(eq(schema.hosts.syncId, "host-b")); - markChanged(); - }); - await pass(desk, engine); - - const serverHosts = await on(server, () => hostsOf(server)); - expect(serverHosts.map((h) => [h.name, h.ip])).toEqual([["a", "10.9.9.9"]]); - const deskHosts = await on(desk, () => hostsOf(desk)); - expect(deskHosts.map((h) => h.name)).toEqual(["a"]); - - await on(desk, async () => { - await db(desk) - .delete(schema.hosts) - .where(eq(schema.hosts.syncId, "host-a")); - }); - await pass(desk, engine); - expect(await on(server, () => hostsOf(server))).toEqual([]); - }); - - it("never sends a host kept on this device only", async () => { - const [desk] = desktops; - const engine = await linkDesktop(desk); - await pass(desk, engine); - await on(desk, () => addHost(desk, { name: "mine", localOnly: true })); - await pass(desk, engine); - - expect(await on(server, () => hostsOf(server))).toEqual([]); - expect((await on(desk, () => hostsOf(desk))).map((h) => h.name)).toEqual([ - "mine", - ]); - }); - - it("keeps a local edit that lost to a newer server edit, and can restore it", async () => { - const [desk] = desktops; - await on(server, () => addHost(server, { name: "a" })); - const engine = await linkDesktop(desk); - await pass(desk, engine); - - await on(desk, () => - 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")); - markChanged(); - }); - await pass(desk, engine); - - const [local] = await on(desk, () => hostsOf(desk)); - expect(local.ip).toBe("server-edit"); - const conflicts = await on(desk, () => listConflicts(desk.userId)); - expect(conflicts).toHaveLength(1); - expect(JSON.parse(conflicts[0].localRow).ip).toBe("desk-edit"); - - // Keep mine: put it back, and the next pass sends it. - await on(desk, async () => { - const conflict = (await getConflict(desk.userId, conflicts[0].id))!; - await writeWireRow( - getEntity("hosts")!, - desk.userId, - { ...JSON.parse(conflict.localRow), syncId: conflict.syncId }, - createResolvers(desk.userId), - ); - await deleteConflict(desk.userId, conflict.id); - }); - await pass(desk, engine); - const [serverHost] = await on(server, () => hostsOf(server)); - expect(serverHost.ip).toBe("desk-edit"); - }); - - it("does not let one refused change hold up the rest", async () => { - const [desk] = desktops; - const engine = await linkDesktop(desk); - await pass(desk, engine); - - await on(desk, async () => { - await addHost(desk, { name: "one" }); - await db(desk).insert(schema.sshCredentials).values({ - userId: desk.userId, - name: "cred", - authType: "password", - syncId: "cred-x", - }); - }); - // The server refuses host changes only. - const hostPermissions = getEntity("hosts")!.permissions; - const credentialPermissions = getEntity("sshCredentials")!.permissions; - getEntity("sshCredentials")!.permissions = undefined; - state.allow = false; - try { - await pass(desk, engine); - } finally { - getEntity("hosts")!.permissions = hostPermissions; - getEntity("sshCredentials")!.permissions = credentialPermissions; - } - - expect(await on(server, () => hostsOf(server))).toEqual([]); - const creds = await db(server).select().from(schema.sshCredentials); - expect(creds.map((c) => c.name)).toEqual(["cred"]); - const [record] = await on(desk, () => - db(desk) - .select() - .from(schema.syncRecords) - .where( - and( - eq(schema.syncRecords.entityType, "hosts"), - eq(schema.syncRecords.syncId, "host-one"), - ), - ), - ); - expect(record.error).toBe("permission"); - }); - - it("leaves a switched-off kind of data alone, and catches up when it is back on", async () => { - const [desk] = desktops; - const engine = await linkDesktop(desk); - await pass(desk, engine); - - const link = (await on(desk, () => getLink()))!; - await on(desk, () => - updateLink({ - disabledTypes: ["hosts"], - knownTypes: link.knownTypes.filter((type) => type !== "hosts"), - }), - ); - await on(server, () => addHost(server, { name: "later" })); - await on(desk, () => addHost(desk, { name: "private" })); - await pass(desk, engine); - - expect((await on(desk, () => hostsOf(desk))).map((h) => h.name)).toEqual([ - "private", - ]); - expect( - (await on(server, () => hostsOf(server))).map((h) => h.name), - ).toEqual(["later"]); - - await on(desk, () => updateLink({ disabledTypes: [] })); - await pass(desk, engine); - await pass(desk, engine); - const names = (await on(desk, () => hostsOf(desk))) - .map((h) => h.name) - .sort(); - expect(names).toEqual(["later", "private"]); - expect( - (await on(server, () => hostsOf(server))).map((h) => h.name).sort(), - ).toEqual(["later", "private"]); - }); -}); - -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; - await on(server, () => addHost(server, { name: "shared-one" })); - await on(server, () => addHost(server, { name: "shared-two" })); - const engineA = await linkDesktop(a); - const engineB = await linkDesktop(b); - await pass(a, engineA); - await pass(b, engineB); - - // Offline on both. - await on(a, () => addHost(a, { name: "from-a" })); - await on(a, () => - db(a) - .update(schema.hosts) - .set({ ip: "10.1.1.1" }) - .where(eq(schema.hosts.syncId, "host-shared-one")), - ); - await on(b, () => - db(b) - .delete(schema.hosts) - .where(eq(schema.hosts.syncId, "host-shared-two")), - ); - await on(b, () => addHost(b, { name: "from-b" })); - - await pass(a, engineA); - await pass(b, engineB); - await pass(a, engineA); - - const view = async (where: Side) => - (await on(where, () => hostsOf(where))) - .map((h) => `${h.name}:${h.ip}`) - .sort(); - const expected = [ - "from-a:10.0.0.1", - "from-b:10.0.0.1", - "shared-one:10.1.1.1", - ]; - expect(await view(server)).toEqual(expected); - expect(await view(a)).toEqual(expected); - expect(await view(b)).toEqual(expected); - }); -}); diff --git a/src/backend/tests/tls/https-policy.test.ts b/src/backend/tests/tls/https-policy.test.ts new file mode 100644 index 000000000..160ad4df4 --- /dev/null +++ b/src/backend/tests/tls/https-policy.test.ts @@ -0,0 +1,108 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const h = vi.hoisted(() => ({ + settings: new Map(), + tlsEnabled: true, +})); + +vi.mock("../../database/repositories/factory.js", () => ({ + createCurrentSettingsRepository: () => ({ + getBoolean: async (key: string, fallback: boolean) => + h.settings.has(key) ? h.settings.get(key) === "true" : fallback, + set: async (key: string, value: string) => { + h.settings.set(key, value); + }, + }), +})); +vi.mock("../../tls/tls-service.js", () => ({ + getTlsConfig: () => ({ enabled: h.tlsEnabled, port: 8443 }), +})); + +const { httpsPolicyMiddleware, setForceHttps, resetHttpsPolicyForTests } = + await import("../../tls/https-policy.js"); + +function run( + path: string, + { secure = false, headers = {} as Record } = {}, +) { + const all: Record = { host: "termix.lan:8080", ...headers }; + const req = { + secure, + path, + originalUrl: path, + url: path, + get: (name: string) => all[name.toLowerCase()], + }; + const res = { + headers: {} as Record, + redirected: null as null | { status: number; url: string }, + setHeader(name: string, value: string) { + res.headers[name] = value; + }, + redirect(status: number, url: string) { + res.redirected = { status, url }; + }, + }; + const next = vi.fn(); + return httpsPolicyMiddleware(req as never, res as never, next).then(() => ({ + res, + next, + })); +} + +beforeEach(() => { + h.settings.clear(); + h.tlsEnabled = true; + resetHttpsPolicyForTests(); +}); + +describe("https policy", () => { + it("is off by default", async () => { + const { res, next } = await run("/users/me"); + expect(next).toHaveBeenCalled(); + expect(res.redirected).toBeNull(); + expect(res.headers).toEqual({}); + }); + + it("does nothing while HTTPS is off, even when turned on", async () => { + await setForceHttps(true); + h.tlsEnabled = false; + const { res, next } = await run("/users/me"); + expect(next).toHaveBeenCalled(); + expect(res.redirected).toBeNull(); + }); + + it("redirects plain HTTP to the HTTPS port", async () => { + await setForceHttps(true); + const { res, next } = await run("/users/me?x=1"); + expect(next).not.toHaveBeenCalled(); + expect(res.redirected).toEqual({ + status: 308, + url: "https://termix.lan:8443/users/me?x=1", + }); + }); + + it("trusts X-Forwarded-Proto and sends HSTS over HTTPS", async () => { + await setForceHttps(true); + const forwarded = await run("/users/me", { + headers: { "x-forwarded-proto": "https, http" }, + }); + expect(forwarded.next).toHaveBeenCalled(); + expect(forwarded.res.headers["Strict-Transport-Security"]).toBe( + "max-age=31536000", + ); + + const direct = await run("/users/me", { secure: true }); + expect(direct.next).toHaveBeenCalled(); + expect(direct.res.headers["Strict-Transport-Security"]).toBeDefined(); + }); + + it("leaves certificate checks and health probes on HTTP", async () => { + await setForceHttps(true); + for (const path of ["/.well-known/acme-challenge/abc", "/health"]) { + const { res, next } = await run(path); + expect(next).toHaveBeenCalled(); + expect(res.redirected).toBeNull(); + } + }); +}); diff --git a/src/backend/tests/upgrade/audit-forwarding-migration.test.ts b/src/backend/tests/upgrade/audit-forwarding-migration.test.ts new file mode 100644 index 000000000..3f57a2671 --- /dev/null +++ b/src/backend/tests/upgrade/audit-forwarding-migration.test.ts @@ -0,0 +1,144 @@ +/** + * The audit forwarding settings migration. + * + * An upgrade must keep forwarding working: the collector URL and token have to + * reach the audit-tools plugin's settings (token encrypted) and be gone from + * the core settings table. Running it twice must not clobber anything. + */ + +import { beforeEach, describe, expect, it, vi } from "vitest"; + +interface Row { + scope: string; + scopeId: string | null; + key: string; + value: string | null; + encrypted: boolean; +} + +const state = vi.hoisted(() => ({ + pluginRows: [] as Row[], + core: new Map(), + pluginInstalled: true, +})); + +vi.mock("../../database/repositories/factory.js", () => ({ + createCurrentPluginRepository: () => ({ + findById: async (id: string) => + state.pluginInstalled && id === "audit-tools" ? { id } : null, + }), + createCurrentPluginSettingsRepository: () => ({ + get: async ( + _pluginId: string, + scope: string, + scopeId: string | null, + key: string, + ) => + state.pluginRows.find( + (row) => + row.scope === scope && row.scopeId === scopeId && row.key === key, + ) ?? null, + set: async ( + _pluginId: string, + scope: string, + scopeId: string | null, + key: string, + value: string | null, + encrypted = false, + ) => { + state.pluginRows = state.pluginRows.filter( + (row) => !(row.scope === scope && row.key === key), + ); + state.pluginRows.push({ scope, scopeId, key, value, encrypted }); + }, + }), + createCurrentSettingsRepository: () => ({ + get: async (key: string) => state.core.get(key) ?? null, + delete: async (key: string) => { + state.core.delete(key); + }, + }), +})); + +vi.mock("../../utils/system-secret-crypto.js", () => ({ + encryptSystemSecret: async (value: string) => `enc(${value})`, +})); + +import { runAuditForwardingMigration } from "../../upgrade/audit-forwarding-migration.js"; + +const row = (key: string) => + state.pluginRows.find((entry) => entry.key === key); + +beforeEach(() => { + state.pluginRows = []; + state.core = new Map(); + state.pluginInstalled = true; +}); + +describe("audit forwarding migration", () => { + it("does nothing until the plugin exists", async () => { + state.pluginInstalled = false; + state.core.set("audit_log_forward_url", "https://siem.test/in"); + + expect(await runAuditForwardingMigration()).toEqual({ + url: false, + token: false, + }); + expect(state.core.get("audit_log_forward_url")).toBe( + "https://siem.test/in", + ); + }); + + it("copies the url and encrypts the token, then clears core", async () => { + state.core.set("audit_log_forward_url", " https://siem.test/in "); + state.core.set("audit_log_forward_token", "secret"); + + expect(await runAuditForwardingMigration()).toEqual({ + url: true, + token: true, + }); + + expect(JSON.parse(row("url")!.value!)).toBe("https://siem.test/in"); + expect(row("url")!.encrypted).toBe(false); + expect(JSON.parse(row("token")!.value!)).toBe("enc(secret)"); + expect(row("token")!.encrypted).toBe(true); + expect(state.core.size).toBe(0); + }); + + it("is safe to run twice", async () => { + state.core.set("audit_log_forward_url", "https://siem.test/in"); + state.core.set("audit_log_forward_token", "secret"); + + await runAuditForwardingMigration(); + const again = await runAuditForwardingMigration(); + + expect(again).toEqual({ url: false, token: false }); + expect(state.pluginRows).toHaveLength(2); + }); + + it("keeps what the plugin already has", async () => { + state.pluginRows.push({ + scope: "admin", + scopeId: null, + key: "url", + value: JSON.stringify("https://new.test/in"), + encrypted: false, + }); + state.core.set("audit_log_forward_url", "https://old.test/in"); + + const result = await runAuditForwardingMigration(); + + expect(result.url).toBe(false); + expect(JSON.parse(row("url")!.value!)).toBe("https://new.test/in"); + expect(state.core.has("audit_log_forward_url")).toBe(false); + }); + + it("drops a stray token when there is no url", async () => { + state.core.set("audit_log_forward_token", "orphan"); + + await runAuditForwardingMigration(); + + expect(state.core.has("audit_log_forward_token")).toBe(false); + expect(state.pluginRows).toHaveLength(0); + }); +}); diff --git a/src/backend/tests/upgrade/host-status-settings-migration.test.ts b/src/backend/tests/upgrade/host-status-settings-migration.test.ts new file mode 100644 index 000000000..bb920cfb8 --- /dev/null +++ b/src/backend/tests/upgrade/host-status-settings-migration.test.ts @@ -0,0 +1,86 @@ +/** + * The status check interval moves into the host-status plugin once. A value + * the plugin already holds is kept, and running it twice changes nothing. + */ + +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const h = vi.hoisted(() => ({ + core: new Map(), + plugin: new Map(), + installed: true, +})); + +vi.mock("../../utils/logger.js", () => { + const log = { info: vi.fn(), warn: vi.fn(), error: vi.fn() }; + return { databaseLogger: log }; +}); +vi.mock("../../database/repositories/factory.js", () => ({ + createCurrentPluginRepository: () => ({ + findById: async (id: string) => (h.installed ? { id } : null), + }), + createCurrentSettingsRepository: () => ({ + get: async (key: string) => h.core.get(key) ?? null, + }), + createCurrentPluginSettingsRepository: () => ({ + get: async (_p: string, _s: string, _id: string | null, key: string) => + h.plugin.has(key) ? { value: h.plugin.get(key) } : null, + set: async ( + _p: string, + _s: string, + _id: string | null, + key: string, + value: string, + ) => { + h.plugin.set(key, value); + }, + }), +})); + +const { runHostStatusSettingsMigration } = + await import("../../upgrade/host-status-settings-migration.js"); + +beforeEach(() => { + h.core.clear(); + h.plugin.clear(); + h.installed = true; +}); + +describe("host status settings migration", () => { + it("copies the interval into the plugin", async () => { + h.core.set("global_status_check_interval", "90"); + expect(await runHostStatusSettingsMigration()).toBe(90); + expect(h.plugin.get("intervalSeconds")).toBe("90"); + }); + + it("keeps a value the plugin already has", async () => { + h.core.set("global_status_check_interval", "90"); + h.plugin.set("intervalSeconds", "45"); + expect(await runHostStatusSettingsMigration()).toBeNull(); + expect(h.plugin.get("intervalSeconds")).toBe("45"); + }); + + it("does nothing twice", async () => { + h.core.set("global_status_check_interval", "120"); + await runHostStatusSettingsMigration(); + expect(await runHostStatusSettingsMigration()).toBeNull(); + expect(h.plugin.get("intervalSeconds")).toBe("120"); + }); + + it.each(["abc", "4", "7200", "1.5", ""])( + "ignores the invalid value %j", + async (value) => { + h.core.set("global_status_check_interval", value); + expect(await runHostStatusSettingsMigration()).toBeNull(); + expect(h.plugin.has("intervalSeconds")).toBe(false); + }, + ); + + it("does nothing without a stored interval or the plugin", async () => { + expect(await runHostStatusSettingsMigration()).toBeNull(); + h.core.set("global_status_check_interval", "90"); + h.installed = false; + expect(await runHostStatusSettingsMigration()).toBeNull(); + expect(h.plugin.size).toBe(0); + }); +}); diff --git a/src/backend/tests/utils/audit-export.test.ts b/src/backend/tests/utils/audit-export.test.ts deleted file mode 100644 index 9e31342e6..000000000 --- a/src/backend/tests/utils/audit-export.test.ts +++ /dev/null @@ -1,125 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { - escapeCsvField, - exportFilename, - toCsv, - toNdjson, -} from "../../utils/audit-export.js"; -import type { AuditLogRecord } from "../../database/repositories/audit-log-repository.js"; - -function entry(overrides: Partial = {}): AuditLogRecord { - return { - id: 1, - userId: "u-1", - username: "alice", - action: "delete_host", - resourceType: "host", - resourceId: "9", - resourceName: "prod-db", - details: null, - ipAddress: "203.0.113.9", - userAgent: "Mozilla/5.0", - success: true, - errorMessage: null, - timestamp: "2026-07-28 10:00:00", - ...overrides, - } as AuditLogRecord; -} - -describe("escapeCsvField", () => { - it("leaves plain values alone", () => { - expect(escapeCsvField("prod-db")).toBe("prod-db"); - expect(escapeCsvField(42)).toBe("42"); - expect(escapeCsvField(true)).toBe("true"); - }); - - it("renders null and undefined as empty", () => { - expect(escapeCsvField(null)).toBe(""); - expect(escapeCsvField(undefined)).toBe(""); - }); - - it("quotes and doubles embedded quotes", () => { - expect(escapeCsvField('say "hi"')).toBe('"say ""hi"""'); - }); - - it("quotes values containing commas or newlines", () => { - expect(escapeCsvField("a,b")).toBe('"a,b"'); - expect(escapeCsvField("line1\nline2")).toBe('"line1\nline2"'); - }); - - it("neutralises spreadsheet formulas", () => { - // An audit entry can carry an attacker-chosen resource name; without this - // the exported file executes it when opened. - expect(escapeCsvField("=1+1")).toBe("'=1+1"); - expect(escapeCsvField("+SUM(A1)")).toBe("'+SUM(A1)"); - expect(escapeCsvField("-2+3")).toBe("'-2+3"); - expect(escapeCsvField("@import")).toBe("'@import"); - }); - - it("still quotes a formula that also contains a comma", () => { - expect(escapeCsvField("=A1,B2")).toBe(`"'=A1,B2"`); - }); -}); - -describe("toCsv", () => { - it("writes a header even with no rows", () => { - expect(toCsv([])).toBe( - "id,timestamp,username,userId,action,resourceType,resourceId,resourceName,success,ipAddress,userAgent,errorMessage,details\n", - ); - }); - - it("writes one line per entry in column order", () => { - const lines = toCsv([entry(), entry({ id: 2, username: "bob" })]) - .trim() - .split("\n"); - - expect(lines).toHaveLength(3); - expect( - lines[1].startsWith("1,2026-07-28 10:00:00,alice,u-1,delete_host"), - ).toBe(true); - expect(lines[2].startsWith("2,")).toBe(true); - }); - - it("keeps a detached entry readable", () => { - const line = toCsv([entry({ userId: null })]) - .trim() - .split("\n")[1]; - - // username survives so the row still names who acted. - expect(line).toContain("alice"); - expect(line.split(",")[3]).toBe(""); - }); -}); - -describe("toNdjson", () => { - it("emits one parseable object per line", () => { - const out = toNdjson([entry(), entry({ id: 2 })]); - const parsed = out - .trim() - .split("\n") - .map((line) => JSON.parse(line)); - - expect(parsed).toHaveLength(2); - expect(parsed[0].action).toBe("delete_host"); - expect(parsed[1].id).toBe(2); - }); - - it("returns nothing for an empty set", () => { - expect(toNdjson([])).toBe(""); - }); -}); - -describe("exportFilename", () => { - it("is filesystem-safe and carries the timestamp", () => { - const name = exportFilename("csv", new Date("2026-07-28T10:11:12.000Z")); - - expect(name).toBe("termix-audit-2026-07-28-10-11-12.csv"); - expect(name).not.toMatch(/[:\s]/); - }); - - it("uses the ndjson extension for the streaming format", () => { - expect(exportFilename("ndjson", new Date("2026-07-28T10:11:12.000Z"))).toBe( - "termix-audit-2026-07-28-10-11-12.ndjson", - ); - }); -}); diff --git a/src/backend/tests/utils/audit-forwarder.test.ts b/src/backend/tests/utils/audit-forwarder.test.ts deleted file mode 100644 index bd15e1d11..000000000 --- a/src/backend/tests/utils/audit-forwarder.test.ts +++ /dev/null @@ -1,169 +0,0 @@ -import { describe, expect, it, vi, beforeEach } from "vitest"; - -const safeFetch = vi.hoisted(() => vi.fn()); -const logs = vi.hoisted(() => ({ info: vi.fn(), warn: vi.fn() })); - -vi.mock("../../database/repositories/factory.js", () => ({ - getCurrentSettingValue: () => null, -})); -vi.mock("../../utils/safe-outbound-fetch.js", () => ({ - safeOutboundFetch: safeFetch, -})); -vi.mock("../../utils/logger.js", () => ({ databaseLogger: logs })); - -const { - auditForwardTarget, - forwardAuditEntry, - forwardPayload, - resetAuditForwarderState, - AUDIT_FORWARD_URL_ENV, - AUDIT_FORWARD_TOKEN_ENV, -} = await import("../../utils/audit-forwarder.js"); - -const ENTRY = { - userId: "u-1", - username: "alice", - action: "delete_host", - resourceType: "host", - resourceId: "9", - success: true, - ipAddress: "203.0.113.9", -}; - -const NOW = new Date("2026-07-28T10:00:00.000Z"); - -beforeEach(() => { - safeFetch.mockReset(); - logs.info.mockReset(); - logs.warn.mockReset(); - resetAuditForwarderState(); -}); - -describe("auditForwardTarget", () => { - it("is off unless a URL is configured", () => { - expect(auditForwardTarget({})).toBeNull(); - expect(auditForwardTarget({ [AUDIT_FORWARD_URL_ENV]: " " })).toBeNull(); - }); - - it("carries an optional bearer token", () => { - expect( - auditForwardTarget({ [AUDIT_FORWARD_URL_ENV]: "https://siem/ingest" }), - ).toEqual({ url: "https://siem/ingest" }); - - expect( - auditForwardTarget({ - [AUDIT_FORWARD_URL_ENV]: "https://siem/ingest", - [AUDIT_FORWARD_TOKEN_ENV]: "secret", - }), - ).toEqual({ url: "https://siem/ingest", token: "secret" }); - }); -}); - -describe("forwardPayload", () => { - it("matches the export shape, with absent fields as null", () => { - expect(forwardPayload(ENTRY, NOW)).toEqual({ - timestamp: "2026-07-28T10:00:00.000Z", - userId: "u-1", - username: "alice", - action: "delete_host", - resourceType: "host", - resourceId: "9", - resourceName: null, - success: true, - ipAddress: "203.0.113.9", - userAgent: null, - errorMessage: null, - details: null, - }); - }); -}); - -describe("forwardAuditEntry", () => { - const env = { [AUDIT_FORWARD_URL_ENV]: "https://siem.example/ingest" }; - - it("does nothing when forwarding is not configured", async () => { - await expect(forwardAuditEntry(ENTRY, NOW, {})).resolves.toBe(false); - expect(safeFetch).not.toHaveBeenCalled(); - }); - - it("posts one NDJSON line through the SSRF-checked fetch", async () => { - safeFetch.mockResolvedValueOnce({ ok: true, status: 200 }); - - await expect(forwardAuditEntry(ENTRY, NOW, env)).resolves.toBe(true); - - const [url, init] = safeFetch.mock.calls[0]; - expect(url).toBe("https://siem.example/ingest"); - expect(init.method).toBe("POST"); - expect(init.headers["Content-Type"]).toBe("application/x-ndjson"); - expect(init.headers.Authorization).toBeUndefined(); - expect(JSON.parse(init.body.trim()).action).toBe("delete_host"); - expect(init.body.endsWith("\n")).toBe(true); - }); - - it("sends the bearer token when one is set", async () => { - safeFetch.mockResolvedValueOnce({ ok: true, status: 200 }); - - await forwardAuditEntry(ENTRY, NOW, { - ...env, - [AUDIT_FORWARD_TOKEN_ENV]: "secret", - }); - - expect(safeFetch.mock.calls[0][1].headers.Authorization).toBe( - "Bearer secret", - ); - }); - - it("reports a rejected delivery without throwing", async () => { - safeFetch.mockResolvedValueOnce({ ok: false, status: 503 }); - - await expect(forwardAuditEntry(ENTRY, NOW, env)).resolves.toBe(false); - expect(logs.warn).toHaveBeenCalledWith( - "Failed to forward audit entry", - expect.objectContaining({ reason: "collector returned 503" }), - ); - }); - - it("swallows transport errors, a dead SIEM must not break auditing", async () => { - safeFetch.mockRejectedValueOnce(new Error("ECONNREFUSED")); - - await expect(forwardAuditEntry(ENTRY, NOW, env)).resolves.toBe(false); - expect(logs.warn).toHaveBeenCalledWith( - "Failed to forward audit entry", - expect.objectContaining({ reason: "ECONNREFUSED" }), - ); - }); - - it("stops repeating itself once the collector is persistently down", async () => { - safeFetch.mockResolvedValue({ ok: false, status: 500 }); - - for (let i = 0; i < 8; i++) { - await forwardAuditEntry(ENTRY, NOW, env); - } - - // 5 per-entry warnings, then one suppression notice, not 8. - const perEntry = logs.warn.mock.calls.filter( - (call) => call[0] === "Failed to forward audit entry", - ); - expect(perEntry).toHaveLength(5); - expect( - logs.warn.mock.calls.some((call) => - String(call[0]).includes("suppressing further messages"), - ), - ).toBe(true); - // It keeps trying regardless. - expect(safeFetch).toHaveBeenCalledTimes(8); - }); - - it("announces recovery after a suppressed outage", async () => { - safeFetch.mockResolvedValue({ ok: false, status: 500 }); - for (let i = 0; i < 6; i++) await forwardAuditEntry(ENTRY, NOW, env); - - safeFetch.mockResolvedValueOnce({ ok: true, status: 200 }); - await forwardAuditEntry(ENTRY, NOW, env); - - expect(logs.info).toHaveBeenCalledWith( - "Audit forwarding recovered", - expect.objectContaining({ operation: "audit_forward_recovered" }), - ); - }); -}); diff --git a/src/backend/tests/utils/auth-manager-token.test.ts b/src/backend/tests/utils/auth-manager-token.test.ts index 132a1a7b9..7758e95ef 100644 --- a/src/backend/tests/utils/auth-manager-token.test.ts +++ b/src/backend/tests/utils/auth-manager-token.test.ts @@ -8,6 +8,8 @@ const encryptionKey = crypto.randomBytes(32); const mocks = vi.hoisted(() => ({ hasUserDEK: vi.fn(() => false), adoptRecoveredDEK: vi.fn(async () => {}), + revoke: vi.fn(async () => true), + created: [] as Array>, })); vi.mock("../../database/db/index.js", () => ({ @@ -19,10 +21,27 @@ vi.mock("../../database/db/index.js", () => ({ vi.mock("../../database/repositories/factory.js", () => ({ createCurrentSettingsRepository: () => ({ get: async () => null }), createCurrentSessionRepository: () => ({ - findById: async (id: string) => - id === "expired" - ? { id, expiresAt: new Date(Date.now() - 1000).toISOString() } - : { id }, + findById: async (id: string) => { + const minutesAgo = (n: number) => + new Date(Date.now() - n * 60_000).toISOString(); + if (id === "expired") { + return { id, expiresAt: new Date(Date.now() - 1000).toISOString() }; + } + if (id === "idle") { + return { id, idleTimeout: 600, lastActiveAt: minutesAgo(11) }; + } + if (id === "active") { + return { id, idleTimeout: 600, lastActiveAt: minutesAgo(5) }; + } + if (id === "setup") return { id, restriction: "second-factor-setup" }; + return { id }; + }, + create: async (row: Record) => { + mocks.created.push(row); + return row; + }, + revoke: mocks.revoke, + touch: async () => true, }), createCurrentUserRepository: () => ({}), createCurrentApiKeyRepository: () => ({}), @@ -54,7 +73,9 @@ vi.mock("../../utils/system-crypto.js", () => ({ }, })); -const { AuthManager } = await import("../../utils/auth-manager.js"); +const { AuthManager, allowsSetupSession } = + await import("../../utils/auth-manager.js"); +const { registerSecondFactor } = await import("../../auth/registry.js"); const authManager = AuthManager.getInstance(); function makeLegacyDataKeyWrap( @@ -74,6 +95,8 @@ function makeLegacyDataKeyWrap( } beforeEach(() => { + mocks.created.length = 0; + mocks.revoke.mockClear(); mocks.hasUserDEK.mockReset().mockReturnValue(false); mocks.adoptRecoveredDEK.mockReset().mockResolvedValue(undefined); }); @@ -191,6 +214,30 @@ describe("AuthManager token handling", () => { expect(payload?.userId).toBe("user-1"); }); + it("gives an API key a ticket on a short session that only a socket takes", async () => { + mocks.created.length = 0; + const ticket = await authManager.issueApiKeySocketTicket( + "user-1", + "key-12345678", + ); + const payload = jwt.decode(ticket) as Record; + + expect(payload.userId).toBe("user-1"); + expect(payload.purpose).toBe("ws"); + expect(Number(payload.exp) - Number(payload.iat)).toBe(60); + + const [session] = mocks.created; + expect(session.userId).toBe("user-1"); + expect(session.id).toBe(payload.sessionId); + expect(session.deviceInfo).toBe("api-key: socket key-1234"); + const lifetime = + new Date(String(session.expiresAt)).getTime() - + new Date(String(session.createdAt)).getTime(); + expect(lifetime).toBe(120_000); + + expect(await authManager.verifyJWTToken(ticket)).toBeNull(); + }); + it("rejects a socket ticket whose session has expired", async () => { const ticket = await authManager.issueSocketTicket("user-1", "expired"); expect( @@ -209,3 +256,97 @@ describe("AuthManager token handling", () => { ).toBeNull(); }); }); + +describe("login policy on sessions", () => { + const sign = (sessionId: string) => + jwt.sign({ userId: "user-1", sessionId }, jwtSecret, { expiresIn: "1h" }); + + it("caps the lifetime and stores the idle timeout and restriction", async () => { + const token = await authManager.generateJWTToken("user-1", { + rememberMe: true, + deviceType: "web", + deviceInfo: "Firefox", + maxAgeSeconds: 600, + idleTimeoutSeconds: 300, + restriction: "second-factor-setup", + }); + const payload = jwt.decode(token) as { iat: number; exp: number }; + expect(payload.exp - payload.iat).toBe(600); + expect(mocks.created[0]).toMatchObject({ + idleTimeout: 300, + restriction: "second-factor-setup", + }); + const lifetime = + new Date(mocks.created[0].expiresAt as string).getTime() - + new Date(mocks.created[0].createdAt as string).getTime(); + expect(lifetime).toBe(600_000); + }); + + it("ends a session idle past its timeout and keeps an active one", async () => { + expect(await authManager.verifyJWTToken(sign("idle"))).toBeNull(); + expect(mocks.revoke).toHaveBeenCalledWith("idle"); + expect(await authManager.verifyJWTToken(sign("active"))).not.toBeNull(); + }); + + it("only lets a setup session reach setup routes", async () => { + const middleware = authManager.createAuthMiddleware(); + const call = async (url: string) => { + const res = { + statusCode: 200, + body: undefined as unknown, + status(code: number) { + res.statusCode = code; + return res; + }, + json(body: unknown) { + res.body = body; + return res; + }, + clearCookie() { + return res; + }, + }; + const next = vi.fn(); + await middleware( + { + cookies: { jwt: sign("setup") }, + headers: {}, + originalUrl: url, + url, + } as never, + res as never, + next, + ); + return { res, next }; + }; + + const blocked = await call("/host/db/host"); + expect(blocked.res.statusCode).toBe(403); + expect(blocked.res.body).toMatchObject({ + code: "SECOND_FACTOR_SETUP_REQUIRED", + }); + expect(blocked.next).not.toHaveBeenCalled(); + + const me = await call("/users/me"); + expect(me.next).toHaveBeenCalled(); + }); + + it("allows second factor plugins' routes and nothing else", () => { + const dispose = registerSecondFactor({ + id: "key", + pluginId: "keys", + labelKey: "key", + isEnrolled: async () => false, + verify: async () => true, + }); + try { + expect(allowsSetupSession("/plugin-api/keys/register")).toBe(true); + expect(allowsSetupSession("/plugin-api/other/thing")).toBe(false); + expect(allowsSetupSession("/users/logout")).toBe(true); + expect(allowsSetupSession("/users/me/token")).toBe(false); + expect(allowsSetupSession("/credentials")).toBe(false); + } finally { + dispose(); + } + }); +}); diff --git a/src/backend/tests/utils/ssh-key-utils.test.ts b/src/backend/tests/utils/ssh-key-utils.test.ts index 22b66e39f..5175e3993 100644 --- a/src/backend/tests/utils/ssh-key-utils.test.ts +++ b/src/backend/tests/utils/ssh-key-utils.test.ts @@ -10,6 +10,7 @@ import { isPrivateKeyPassphraseError, getFriendlyKeyTypeName, validateKeyPair, + isSecurityKeyFile, } from "../../utils/ssh-key-utils.js"; const { parsePrivateKey } = sshpk; @@ -224,3 +225,43 @@ describe("PKCS#8 runtime key normalization", () => { ).toThrow(); }); }); + +describe("security key files", () => { + const str = (v: string | Buffer) => { + const b = Buffer.from(v); + const l = Buffer.alloc(4); + l.writeUInt32BE(b.length); + return Buffer.concat([l, b]); + }; + const skFile = (type: string) => { + const body = Buffer.concat([ + Buffer.from("openssh-key-v1\u0000"), + str("none"), + str("none"), + str(""), + Buffer.from([0, 0, 0, 1]), + str(Buffer.concat([str(type), str(Buffer.alloc(32, 1)), str("ssh:")])), + str(Buffer.alloc(16)), + ]).toString("base64"); + return `-----BEGIN OPENSSH PRIVATE KEY----- +${body} +-----END OPENSSH PRIVATE KEY-----`; + }; + + it("detects sk key files", () => { + expect(isSecurityKeyFile(skFile("sk-ssh-ed25519@openssh.com"))).toBe(true); + expect( + isSecurityKeyFile(skFile("sk-ecdsa-sha2-nistp256@openssh.com")), + ).toBe(true); + }); + + it("ignores normal keys", () => { + expect(isSecurityKeyFile(ED25519_PRIVATE)).toBe(false); + }); + + it("explains how to use a security key instead of failing to decode", () => { + expect(() => + preparePrivateKeyForSSH2(skFile("sk-ssh-ed25519@openssh.com")), + ).toThrow("ssh-add"); + }); +}); diff --git a/src/backend/tls/https-policy.ts b/src/backend/tls/https-policy.ts new file mode 100644 index 000000000..bb14edce7 --- /dev/null +++ b/src/backend/tls/https-policy.ts @@ -0,0 +1,70 @@ +/** + * Admin option force_https: when HTTPS is on, plain HTTP requests are sent to + * the HTTPS port and HTTPS responses carry HSTS. Off by default, so a reverse + * proxy that terminates TLS itself keeps working. A request counts as HTTPS + * when it came in over TLS or a proxy says so in X-Forwarded-Proto. + */ + +import type { NextFunction, Request, Response } from "express"; +import { createCurrentSettingsRepository } from "../database/repositories/factory.js"; +import { getTlsConfig } from "./tls-service.js"; + +export const FORCE_HTTPS_KEY = "force_https"; +const HSTS = "max-age=31536000"; +// Certificate checks and health probes must still work over plain HTTP. +const EXEMPT = [/^\/\.well-known\/acme-challenge\//, /^\/health$/]; + +let forceHttps: boolean | null = null; + +export async function isForceHttpsEnabled(): Promise { + if (forceHttps === null) { + forceHttps = await createCurrentSettingsRepository() + .getBoolean(FORCE_HTTPS_KEY, false) + .catch(() => false); + } + return forceHttps; +} + +export async function setForceHttps(enabled: boolean): Promise { + await createCurrentSettingsRepository().set( + FORCE_HTTPS_KEY, + enabled ? "true" : "false", + ); + forceHttps = enabled; +} + +/** Test seam. */ +export function resetHttpsPolicyForTests(): void { + forceHttps = null; +} + +export function isSecureRequest(req: Request): boolean { + if (req.secure) return true; + const forwarded = req.get("x-forwarded-proto"); + return forwarded?.split(",")[0].trim().toLowerCase() === "https"; +} + +function httpsUrl(req: Request, port: number): string { + const host = (req.get("host") || "localhost").replace(/:\d+$/, ""); + const suffix = port === 443 ? "" : `:${port}`; + return `https://${host}${suffix}${req.originalUrl || req.url}`; +} + +export async function httpsPolicyMiddleware( + req: Request, + res: Response, + next: NextFunction, +): Promise { + try { + const tls = getTlsConfig(); + if (!tls.enabled || !(await isForceHttpsEnabled())) return next(); + if (isSecureRequest(req)) { + res.setHeader("Strict-Transport-Security", HSTS); + return next(); + } + if (EXEMPT.some((pattern) => pattern.test(req.path))) return next(); + res.redirect(308, httpsUrl(req, tls.port)); + } catch (error) { + next(error); + } +} diff --git a/src/backend/upgrade/audit-forwarding-migration.ts b/src/backend/upgrade/audit-forwarding-migration.ts new file mode 100644 index 000000000..2ee36969d --- /dev/null +++ b/src/backend/upgrade/audit-forwarding-migration.ts @@ -0,0 +1,87 @@ +/** + * Moves the audit forwarding collector URL and token out of the core settings + * table into the audit-tools plugin's admin settings. The token is stored + * encrypted with the installation key, like any plugin secret setting, and the + * core copies are removed once the plugin has its own. + * + * Idempotent: a value the plugin already has is kept, and a core value is only + * removed after the plugin holds a copy. Does nothing until the plugin row + * exists (plugin_settings has a foreign key to plugins). + */ + +import { databaseLogger } from "../utils/logger.js"; +import { + createCurrentPluginRepository, + createCurrentPluginSettingsRepository, + createCurrentSettingsRepository, +} from "../database/repositories/factory.js"; +import { encryptSystemSecret } from "../utils/system-secret-crypto.js"; + +const PLUGIN_ID = "audit-tools"; +const URL_SETTING = "audit_log_forward_url"; +const TOKEN_SETTING = "audit_log_forward_token"; + +export interface AuditForwardingMigrationResult { + url: boolean; + token: boolean; +} + +export async function runAuditForwardingMigration(): Promise { + const result: AuditForwardingMigrationResult = { url: false, token: false }; + + try { + if (!(await createCurrentPluginRepository().findById(PLUGIN_ID))) { + return result; + } + } catch { + return result; + } + + const core = createCurrentSettingsRepository(); + const plugin = createCurrentPluginSettingsRepository(); + const legacyUrl = (await core.get(URL_SETTING))?.trim(); + const legacyToken = (await core.get(TOKEN_SETTING))?.trim(); + + const has = async (key: string) => { + const row = await plugin.get(PLUGIN_ID, "admin", null, key); + return !!row && row.value !== null && row.value !== '""'; + }; + + if (legacyUrl) { + if (!(await has("url"))) { + await plugin.set( + PLUGIN_ID, + "admin", + null, + "url", + JSON.stringify(legacyUrl), + ); + result.url = true; + } + if (legacyToken && !(await has("token"))) { + await plugin.set( + PLUGIN_ID, + "admin", + null, + "token", + JSON.stringify(await encryptSystemSecret(legacyToken)), + true, + ); + result.token = true; + } + } + + // Safe to drop now: the plugin holds the values, or never had a collector. + if (legacyUrl ? await has("url") : true) { + if (legacyUrl) await core.delete(URL_SETTING); + if (legacyToken && (await has("token"))) await core.delete(TOKEN_SETTING); + else if (!legacyUrl && legacyToken) await core.delete(TOKEN_SETTING); + } + + if (result.url || result.token) { + databaseLogger.info("Moved audit forwarding settings into audit-tools", { + operation: "audit_forwarding_migration", + }); + } + return result; +} diff --git a/src/backend/upgrade/boot-migrations.ts b/src/backend/upgrade/boot-migrations.ts index 6c56c0368..74bedc47e 100644 --- a/src/backend/upgrade/boot-migrations.ts +++ b/src/backend/upgrade/boot-migrations.ts @@ -48,8 +48,8 @@ export const runPluginDataMigrations = DatabaseSaveTrigger.batched( */ async function applyHostDefaults(): Promise { try { - const { getLink } = await import("../sync/client/link-store.js"); - if (await getLink().catch(() => null)) return; + const { listLinks } = await import("../sync/client/link-store.js"); + if ((await listLinks().catch(() => [])).length > 0) return; const { recomputeEverything } = await import("../hosts/defaults/recompute.js"); recomputeEverything("plugins started"); diff --git a/src/backend/upgrade/host-status-settings-migration.ts b/src/backend/upgrade/host-status-settings-migration.ts new file mode 100644 index 000000000..fdd2028b5 --- /dev/null +++ b/src/backend/upgrade/host-status-settings-migration.ts @@ -0,0 +1,64 @@ +/** + * Moves the global status check interval into the host-status plugin, before + * it activates, so its first checks use the interval an admin chose instead of + * the plugin's 30 second default. + * + * Idempotent: a value the plugin already has is kept. + */ + +import { databaseLogger } from "../utils/logger.js"; +import { + createCurrentPluginRepository, + createCurrentPluginSettingsRepository, + createCurrentSettingsRepository, +} from "../database/repositories/factory.js"; + +const PLUGIN_ID = "host-status"; +const LEGACY_KEY = "global_status_check_interval"; + +function seconds(value: unknown): number | null { + const parsed = Number(value); + return Number.isInteger(parsed) && parsed >= 5 && parsed <= 3600 + ? parsed + : null; +} + +export async function runHostStatusSettingsMigration(): Promise { + try { + const plugin = await createCurrentPluginRepository().findById(PLUGIN_ID); + if (!plugin) return null; + + const settings = createCurrentPluginSettingsRepository(); + const existing = await settings.get( + PLUGIN_ID, + "admin", + null, + "intervalSeconds", + ); + if (existing && existing.value !== null) return null; + + const legacy = seconds( + await createCurrentSettingsRepository().get(LEGACY_KEY), + ); + if (legacy === null) return null; + + await settings.set( + PLUGIN_ID, + "admin", + null, + "intervalSeconds", + JSON.stringify(legacy), + ); + databaseLogger.info("Moved the status check interval into the plugin", { + operation: "host_status_settings_migration", + intervalSeconds: legacy, + }); + return legacy; + } catch (error) { + databaseLogger.warn("Host status settings migration failed", { + operation: "host_status_settings_migration", + error: error instanceof Error ? error.message : String(error), + }); + return null; + } +} diff --git a/src/backend/upgrade/plugin-data-moves.ts b/src/backend/upgrade/plugin-data-moves.ts index 558b8d48f..2b2f80f7a 100644 --- a/src/backend/upgrade/plugin-data-moves.ts +++ b/src/backend/upgrade/plugin-data-moves.ts @@ -10,6 +10,8 @@ import { runOpksshConfigMigration } from "./opkssh-config-migration.js"; import { runTermixIdentityCaMigration } from "./termix-identity-ca-migration.js"; import { runSessionRecordingDataMigration } from "./session-recording-data-migration.js"; import { runNotificationChannelMigration } from "./notification-channel-migration.js"; +import { runHostStatusSettingsMigration } from "./host-status-settings-migration.js"; +import { runAuditForwardingMigration } from "./audit-forwarding-migration.js"; export async function runPluginDataMoves(): Promise { await runLdapProviderMigration(); @@ -19,4 +21,8 @@ export async function runPluginDataMoves(): Promise { await runSessionRecordingDataMigration(); // Automations name channels by id, so they are in place before it starts. await runNotificationChannelMigration(); + // The plugin forwards from its own settings, so they are in place first. + await runAuditForwardingMigration(); + // Sets the check interval, which the plugin reads when it activates. + await runHostStatusSettingsMigration(); } diff --git a/src/backend/utils/audit-export.ts b/src/backend/utils/audit-export.ts deleted file mode 100644 index 5f84891c6..000000000 --- a/src/backend/utils/audit-export.ts +++ /dev/null @@ -1,70 +0,0 @@ -import type { AuditLogRecord } from "../database/repositories/audit-log-repository.js"; - -/** Column order for CSV export; also the header row. */ -const COLUMNS = [ - "id", - "timestamp", - "username", - "userId", - "action", - "resourceType", - "resourceId", - "resourceName", - "success", - "ipAddress", - "userAgent", - "errorMessage", - "details", -] as const; - -/** - * RFC 4180 field escaping. - * - * The leading-character guard is not part of RFC 4180: a field starting with - * `=`, `+`, `-` or `@` is treated as a formula by spreadsheet software, so an - * audit entry containing an attacker-chosen resource name could execute on - * open. Prefixing with a single quote neutralises that while keeping the value - * readable. - */ -export function escapeCsvField(value: unknown): string { - if (value === null || value === undefined) return ""; - - let text = typeof value === "boolean" ? String(value) : String(value); - if (/^[=+\-@\t\r]/.test(text)) { - text = `'${text}`; - } - - if (/[",\n\r]/.test(text)) { - return `"${text.replace(/"/g, '""')}"`; - } - return text; -} - -export function toCsv(rows: AuditLogRecord[]): string { - const lines = [COLUMNS.join(",")]; - for (const row of rows) { - lines.push( - COLUMNS.map((column) => - escapeCsvField((row as Record)[column]), - ).join(","), - ); - } - // Trailing newline so the file ends cleanly when appended to or concatenated. - return `${lines.join("\n")}\n`; -} - -/** - * Newline-delimited JSON: one entry per line, which is what log shippers and - * SIEM bulk endpoints expect, and which streams without holding the whole set. - */ -export function toNdjson(rows: AuditLogRecord[]): string { - return ( - rows.map((row) => JSON.stringify(row)).join("\n") + - (rows.length ? "\n" : "") - ); -} - -export function exportFilename(format: "csv" | "ndjson", now: Date): string { - const stamp = now.toISOString().slice(0, 19).replace(/[:T]/g, "-"); - return `termix-audit-${stamp}.${format === "csv" ? "csv" : "ndjson"}`; -} diff --git a/src/backend/utils/audit-forwarder.ts b/src/backend/utils/audit-forwarder.ts deleted file mode 100644 index a48da2a4b..000000000 --- a/src/backend/utils/audit-forwarder.ts +++ /dev/null @@ -1,152 +0,0 @@ -import { safeOutboundFetch } from "./safe-outbound-fetch.js"; -import { getCurrentSettingValue } from "../database/repositories/factory.js"; -import { databaseLogger } from "./logger.js"; -import type { AuditLogParams } from "./audit-logger.js"; - -export const AUDIT_FORWARD_URL_ENV = "AUDIT_LOG_FORWARD_URL"; -export const AUDIT_FORWARD_TOKEN_ENV = "AUDIT_LOG_FORWARD_TOKEN"; -export const AUDIT_FORWARD_URL_SETTING = "audit_log_forward_url"; -export const AUDIT_FORWARD_TOKEN_SETTING = "audit_log_forward_token"; - -/** - * Settings-table read that tolerates running before the database is up (unit - * tests, early startup): no readable setting behaves like an unset one. - */ -function readForwardSetting(key: string): string | null { - try { - return getCurrentSettingValue(key)?.trim() || null; - } catch { - return null; - } -} - -/** - * How many consecutive failures before the forwarder stops complaining on every - * entry. It keeps trying, this only throttles the log noise, and it reports - * again once delivery recovers. - */ -const QUIET_AFTER_FAILURES = 5; - -let consecutiveFailures = 0; -let quietened = false; - -export interface AuditForwardTarget { - url: string; - token?: string; -} - -export function auditForwardTarget( - env: NodeJS.ProcessEnv = process.env, -): AuditForwardTarget | null { - // An admin-entered collector wins over the deployment env var, so the UI - // can configure forwarding - and its token - without a restart. - const settingUrl = readForwardSetting(AUDIT_FORWARD_URL_SETTING); - const url = settingUrl ?? env[AUDIT_FORWARD_URL_ENV]?.trim(); - if (!url) return null; - const token = settingUrl - ? readForwardSetting(AUDIT_FORWARD_TOKEN_SETTING) - : env[AUDIT_FORWARD_TOKEN_ENV]?.trim(); - return token ? { url, token } : { url }; -} - -/** The wire shape: one JSON object per entry, matching the export's NDJSON. */ -export function forwardPayload( - entry: AuditLogParams, - now: Date, -): Record { - return { - timestamp: now.toISOString(), - userId: entry.userId, - username: entry.username, - action: entry.action, - resourceType: entry.resourceType, - resourceId: entry.resourceId ?? null, - resourceName: entry.resourceName ?? null, - success: entry.success, - ipAddress: entry.ipAddress ?? null, - userAgent: entry.userAgent ?? null, - errorMessage: entry.errorMessage ?? null, - details: entry.details ?? null, - }; -} - -/** Exposed for tests; forwarding state is process-wide otherwise. */ -export function resetAuditForwarderState(): void { - consecutiveFailures = 0; - quietened = false; -} - -/** - * Ships one entry to the configured collector. - * - * Never throws and never blocks the audited operation: a SIEM being unreachable - * must not stop Termix from recording locally, which stays the source of truth. - * Delivery goes through safeOutboundFetch so a misconfigured URL cannot be used - * to probe the internal network. - */ -export async function forwardAuditEntry( - entry: AuditLogParams, - now: Date = new Date(), - env: NodeJS.ProcessEnv = process.env, -): Promise { - const target = auditForwardTarget(env); - if (!target) return false; - - try { - const response = await safeOutboundFetch(target.url, { - method: "POST", - headers: { - "Content-Type": "application/x-ndjson", - ...(target.token ? { Authorization: `Bearer ${target.token}` } : {}), - }, - body: `${JSON.stringify(forwardPayload(entry, now))}\n`, - signal: AbortSignal.timeout(5000), - }); - - if (!response.ok) { - noteFailure(`collector returned ${response.status}`, entry.action); - return false; - } - - noteSuccess(); - return true; - } catch (error) { - noteFailure( - error instanceof Error ? error.message : String(error), - entry.action, - ); - return false; - } -} - -function noteFailure(reason: string, action: string): void { - consecutiveFailures++; - - if (quietened) return; - - databaseLogger.warn("Failed to forward audit entry", { - operation: "audit_forward_failed", - action, - reason, - consecutiveFailures, - }); - - if (consecutiveFailures >= QUIET_AFTER_FAILURES) { - quietened = true; - databaseLogger.warn( - `Audit forwarding has failed ${consecutiveFailures} times; suppressing further messages until it recovers`, - { operation: "audit_forward_suppressed" }, - ); - } -} - -function noteSuccess(): void { - if (quietened) { - databaseLogger.info("Audit forwarding recovered", { - operation: "audit_forward_recovered", - afterFailures: consecutiveFailures, - }); - } - consecutiveFailures = 0; - quietened = false; -} diff --git a/src/backend/utils/audit-hooks.ts b/src/backend/utils/audit-hooks.ts new file mode 100644 index 000000000..00b15ee96 --- /dev/null +++ b/src/backend/utils/audit-hooks.ts @@ -0,0 +1,32 @@ +import type { AuditLogParams } from "./audit-logger.js"; + +export interface AuditHookEntry extends AuditLogParams { + timestamp: Date; +} + +type AuditHook = (entry: AuditHookEntry) => void; + +const hooks = new Set(); + +/** Registers a listener for accepted audit lines. Returns the remover. */ +export function onAuditEntry(hook: AuditHook): () => void { + hooks.add(hook); + return () => { + hooks.delete(hook); + }; +} + +/** Never throws and never waits: a hook cannot delay the audited action. */ +export function emitAuditEntry(params: AuditLogParams): void { + if (hooks.size === 0) return; + const entry: AuditHookEntry = { ...params, timestamp: new Date() }; + for (const hook of hooks) { + queueMicrotask(() => { + try { + hook(entry); + } catch { + // a plugin hook must not break auditing + } + }); + } +} diff --git a/src/backend/utils/audit-logger.ts b/src/backend/utils/audit-logger.ts index 1cc0d1228..eef352fa6 100644 --- a/src/backend/utils/audit-logger.ts +++ b/src/backend/utils/audit-logger.ts @@ -1,5 +1,5 @@ import type { Request } from "express"; -import { forwardAuditEntry } from "./audit-forwarder.js"; +import { emitAuditEntry } from "./audit-hooks.js"; import { createCurrentAuditLogRepository, createCurrentUserRepository, @@ -78,9 +78,9 @@ function isCoalescedRoutine(params: AuditLogParams): boolean { 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(() => {}); + // Hooks (plugins copying the log elsewhere) never delay or fail the + // audited operation. + emitAuditEntry(params); try { await createCurrentAuditLogRepository().create({ diff --git a/src/backend/utils/auth-manager.ts b/src/backend/utils/auth-manager.ts index 0c6eccabc..7516ea26f 100644 --- a/src/backend/utils/auth-manager.ts +++ b/src/backend/utils/auth-manager.ts @@ -16,6 +16,7 @@ import { nanoid } from "nanoid"; import { inArray, isNotNull } from "drizzle-orm"; import type { ExternalSessionRef } from "../auth/types.js"; import { listSecondFactors } from "../auth/registry.js"; +import { SECOND_FACTOR_SETUP } from "../auth/policy.js"; import type { DeviceType } from "./user-agent-parser.js"; import { getDb } from "../database/db/index.js"; import { sessions } from "../database/db/schema.js"; @@ -50,6 +51,8 @@ interface JWTPayload { * from the token, so a refreshed token stays marked. */ pluginSession?: boolean; + /** sessions.restriction, read from the row on verify. */ + restriction?: string | null; dataKeyWrap?: WrappedDataKey; iat?: number; exp?: number; @@ -69,6 +72,7 @@ interface AuthenticatedRequest extends Request { pendingTOTP?: boolean; dataKey?: Buffer; actingAdminUserId?: string; + sessionRestriction?: string | null; } interface RequestWithHeaders extends Request { @@ -129,6 +133,28 @@ export function allowsPluginSession(path: string): boolean { return !listSecondFactors().some((factor) => factor.pluginId === plugin); } +// What a session that must set up a second factor first may call: who am I, +// sign out, the login and factor routes, plugin bundles, and the plugins that +// provide a second factor. +const SETUP_SESSION_ALLOWLIST = [ + /^\/users\/(me|logout)$/, + /^\/users\/auth\//, + /^\/user-preferences\/look(\/|$)/, + /^\/plugins(\/|$)/, + /^\/plugin-assets\//, +]; + +/** Whether a session restricted to second factor setup may call this path. */ +export function allowsSetupSession(path: string): boolean { + if (SETUP_SESSION_ALLOWLIST.some((pattern) => pattern.test(path))) { + return true; + } + const plugin = PLUGIN_API_PATH.exec(path)?.[1]; + return ( + !!plugin && listSecondFactors().some((factor) => factor.pluginId === plugin) + ); +} + let pluginAllowsImpersonation: (pluginId: string) => boolean = () => false; /** Set by the plugin runtime: whether a running plugin opted in. */ @@ -336,6 +362,11 @@ class AuthManager { deviceType?: DeviceType; deviceInfo?: string; externalSession?: ExternalSessionRef | null; + /** Caps the lifetime, from the login policy. */ + maxAgeSeconds?: number | null; + /** Stored on the session, see SECOND_FACTOR_SETUP. */ + restriction?: string | null; + idleTimeoutSeconds?: number | null; } = {}, ): Promise { const jwtSecret = await this.systemCrypto.getJWTSecret(); @@ -355,6 +386,12 @@ class AuthManager { } else if (!expiresIn) { expiresIn = defaultExpiry; } + if ( + options.maxAgeSeconds && + options.maxAgeSeconds * 1000 < this.parseExpiresIn(expiresIn) + ) { + expiresIn = `${Math.floor(options.maxAgeSeconds)}s`; + } const payload: JWTPayload = { userId }; if (options.pendingTOTP) { @@ -385,6 +422,8 @@ class AuthManager { createdAt, expiresAt, lastActiveAt: createdAt, + restriction: options.restriction ?? null, + idleTimeout: options.idleTimeoutSeconds ?? null, }); } catch (error) { databaseLogger.error("Failed to create session", error, { @@ -433,6 +472,24 @@ class AuthManager { } as jwt.SignOptions); } + /** + * A one minute socket ticket for an API key. The key has no login session, + * so a two minute session a plugin could have minted carries the ticket. It + * can open plugin sockets and call nothing else. + */ + async issueApiKeySocketTicket( + userId: string, + keyId: string, + ): Promise { + const { token } = await this.issuePluginSession(userId, { + pluginId: "api-key", + purpose: `socket ${keyId.slice(0, 8)}`, + ttlSeconds: 120, + }); + const sessionId = (jwt.decode(token) as JWTPayload).sessionId!; + return this.issueSocketTicket(userId, sessionId); + } + /** * 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 @@ -500,6 +557,19 @@ class AuthManager { } payload.pluginSession = sessionRecord.deviceType === PLUGIN_SESSION_DEVICE_TYPE; + payload.restriction = sessionRecord.restriction ?? null; + + if (this.idleExpired(sessionRecord)) { + databaseLogger.info("Session ended after idle timeout", { + operation: "session_idle_timeout", + sessionId: payload.sessionId, + userId: payload.userId, + }); + await createCurrentSessionRepository() + .revoke(payload.sessionId) + .catch(() => {}); + return null; + } await this.migrateDataKeyFromPayload(payload); } catch (dbError) { @@ -531,6 +601,15 @@ class AuthManager { } } + private idleExpired(session: { + idleTimeout: number | null; + lastActiveAt: string; + }): boolean { + if (!session.idleTimeout) return false; + const idleMs = Date.now() - new Date(session.lastActiveAt).getTime(); + return idleMs > session.idleTimeout * 1000; + } + async refreshSessionToken( userId: string, sessionId: string, @@ -921,6 +1000,7 @@ class AuthManager { authReq.userId = payload.userId; authReq.sessionId = payload.sessionId; authReq.pendingTOTP = payload.pendingTOTP; + authReq.sessionRestriction = payload.restriction ?? null; if (authReq.headers[ADMIN_TARGET_USER_HEADER]) { const handled = await this.applyAdminImpersonation( @@ -1022,6 +1102,16 @@ class AuthManager { payload: JWTPayload, ): boolean { const path = (req.originalUrl || req.url || "").split("?")[0]; + if ( + payload.restriction === SECOND_FACTOR_SETUP && + !allowsSetupSession(path) + ) { + res.status(403).json({ + error: "Set up a second factor before using Termix", + code: "SECOND_FACTOR_SETUP_REQUIRED", + }); + return true; + } if (!payload.pluginSession || allowsPluginSession(path)) return false; res.status(403).json({ error: "A session a plugin issued cannot use this route", diff --git a/src/backend/utils/ssh-key-utils.ts b/src/backend/utils/ssh-key-utils.ts index 3ee4bb0b2..5bd699fd9 100644 --- a/src/backend/utils/ssh-key-utils.ts +++ b/src/backend/utils/ssh-key-utils.ts @@ -245,10 +245,30 @@ function getUnsupportedPrivateKeyError(privateKeyData: string): string { return "Unsupported private key format. Use an OpenSSH, PEM, or PuTTY PPK v2 RSA/DSA private key."; } +const SECURITY_KEY_ERROR = + "This is a security key (FIDO2) key file. It only holds a handle, so the key itself must sign on your device. Load it with ssh-add and use SSH agent authentication in the desktop app."; + +/** An OpenSSH private key file for a FIDO2 security key (sk-ssh-ed25519, sk-ecdsa). */ +export function isSecurityKeyFile(privateKeyData: string): boolean { + const match = privateKeyData.match( + /-----BEGIN OPENSSH PRIVATE KEY-----([\s\S]*?)-----END OPENSSH PRIVATE KEY-----/, + ); + if (!match) return false; + try { + const body = Buffer.from(match[1].replace(/\s+/g, ""), "base64"); + return /sk-(?:ssh-ed25519|ecdsa-sha2-nistp256)@openssh\.com/.test( + body.toString("latin1"), + ); + } catch { + return false; + } +} + export function preparePrivateKeyForSSH2( privateKeyData: string, passphrase?: string, ): Buffer { + if (isSecurityKeyFile(privateKeyData)) throw new Error(SECURITY_KEY_ERROR); let cleanKey = normalizePrivateKeyText(privateKeyData); if ( /^-----BEGIN (?:ENCRYPTED )?PRIVATE KEY-----/.test(cleanKey) && diff --git a/src/main.tsx b/src/main.tsx index 9e7090dc1..76b2f4f14 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -1,5 +1,10 @@ /* eslint-disable react-refresh/only-export-components */ import { prepareClientCacheVersion } from "@/lib/client-cache-version"; +import { + TitleBar, + TITLE_BAR_OFFSET, + useTitleBarOverlayColors, +} from "@/shell/TitleBar"; import { StrictMode, Suspense, lazy, useState, useRef, useEffect } from "react"; import { createRoot } from "react-dom/client"; import "./ui/index.css"; @@ -14,6 +19,7 @@ import { Auth, getStoredAuth, clearStoredAuth, + storeAuth, markDesktopManualLogout, clearDesktopManualLogout, } from "@/auth/Auth"; @@ -32,6 +38,9 @@ import { } from "@/plugin-host/loader"; import { settledPromise } from "@/plugin-host/plugin-store"; import { resetShellBridge } from "@/plugin-host/shell-bridge"; +import { loadLook, resetLook } from "@/look/look-store"; +import { resetPluginThemes } from "@/look/theme-registry"; +import { resetPluginLayouts } from "@/look/layout-registry"; import { preloadPermissions } from "@/hooks/use-permissions"; import { getUserPreferences } from "@/api/open-tabs-api"; import { standaloneViewFor } from "@/shell/tab-registry"; @@ -156,6 +165,8 @@ function FullscreenAppGate() { } function App() { + // Keeps the window controls the system draws in the colors of the theme. + useTitleBarOverlayColors(); const stored = getStoredAuth(); const [phase, setPhase] = useState( stored?.loggedIn ? "verifying" : "idle-auth", @@ -192,7 +203,12 @@ function App() { if (phase !== "verifying") return; appReadyPromise .then(() => getUserInfo()) - .then(async () => { + .then(async (info) => { + // The stored name can be an earlier account, after a switch or rename. + if (info.username) { + setAuthUsername(info.username); + storeAuth(info.username); + } if (isElectron()) { try { const token = await getCurrentToken(); @@ -209,6 +225,7 @@ function App() { .then(() => settledPromise()), preloadPermissions(), getUserPreferences().catch(() => undefined), + loadLook(), ]); await enterApp(); }) @@ -296,12 +313,10 @@ function App() { .then(() => settledPromise()), preloadPermissions(), getUserPreferences().catch(() => undefined), + loadLook(), ]); await enterApp(); })(); - if (isElectron()) { - window.electronAPI?.startC2SAutoStartTunnels?.().catch(() => {}); - } } function handleLogout(options?: LogoutOptions) { @@ -321,6 +336,9 @@ function App() { // should survive into the next session. void stopPluginRuntime(); resetShellBridge(); + resetLook(); + resetPluginThemes(); + resetPluginLayouts(); clearStoredAuth(); localStorage.removeItem("jwt"); if (isElectron() && options?.manual) { @@ -359,10 +377,17 @@ function App() { const { t } = useTranslation(); const isTransitioning = phase === "fading-in" || phase === "fading-out"; + // The desktop app draws a title bar on top of the window, so everything + // below it starts lower. The bar sets the offset only while it shows. + const titleBarOffset = TITLE_BAR_OFFSET; if (phase === "verifying" || phase === "loading-app") { return ( -
+
+

{t("common.loading")}

@@ -373,8 +398,13 @@ function App() { return ( <> + + {isTransitioning && ( -
+

@@ -405,6 +435,7 @@ function App() { style={{ opacity: appOpacity, pointerEvents: phase === "idle-app" ? "auto" : "none", + paddingTop: titleBarOffset, }} > @@ -421,6 +452,7 @@ function App() { style={{ opacity: authOpacity, pointerEvents: phase === "idle-auth" ? "auto" : "none", + paddingTop: titleBarOffset, }} > diff --git a/src/types/index.ts b/src/types/index.ts index 7606a6992..9df4664c6 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -37,6 +37,13 @@ export interface JumpHost { hostId: number; } +/** The plugin that manages a host, from ctx.hosts.locks. */ +export interface HostLock { + pluginId: string; + pluginName: string; + note: string | null; +} + export type Host = { id: number; name: string; @@ -116,6 +123,8 @@ export type Host = { ownerUsername?: string; /** A read-only copy of a host shared with the linked account. */ sharedCopy?: boolean; + /** Set when a plugin manages this host: read only, pins and order still work. */ + lock?: HostLock; /** Desktop only: kept on this device, never synced to the server. */ localOnly?: boolean; @@ -319,6 +328,8 @@ export interface AuthenticatedRequest extends Request { sessionId?: string; apiKeyId?: string; actingAdminUserId?: string; + /** Set while the session may only set up a second factor. */ + sessionRestriction?: string | null; user?: { id: string; username: string; diff --git a/src/types/onboarding.ts b/src/types/onboarding.ts index fcfdee4f2..b7ed985fc 100644 --- a/src/types/onboarding.ts +++ b/src/types/onboarding.ts @@ -10,10 +10,7 @@ export const CORE_ONBOARDING_STEPS = { plugins: 1, - "desktop-sync": 1, "desktop-updates": 1, - preset: 1, - appearance: 1, security: 1, } as const; @@ -26,7 +23,6 @@ export type CoreOnboardingStepKey = keyof typeof CORE_ONBOARDING_STEPS; */ export const LEGACY_SEEN: Readonly> = Object.freeze({ plugins: 1, - "desktop-sync": 1, preset: 1, appearance: 1, security: 1, diff --git a/src/types/ui-types.ts b/src/types/ui-types.ts index 78a7b09ca..cfb58ad81 100644 --- a/src/types/ui-types.ts +++ b/src/types/ui-types.ts @@ -1,4 +1,5 @@ import type { + HostLock, HostProtocolAuthSummary, HostSshOptions, HostTerminalConfig, @@ -104,6 +105,8 @@ export type Host = { * linked to. It arrives through sync and is managed on the server. */ sharedCopy?: boolean; + /** Set when a plugin manages this host: read only, pins and order still work. */ + lock?: HostLock; /** Desktop only: kept on this device, never synced to the server. */ localOnly?: boolean; }; @@ -223,7 +226,8 @@ export type AdminSection = | "api-keys" | "audit-log" | "ssl"; -export type ThemeId = +export type ThemeId = string; +export type LegacyThemeId = | "dark" | "light" | "system" diff --git a/src/ui/AppShell.tsx b/src/ui/AppShell.tsx index 49db7806f..dfbba2ba1 100644 --- a/src/ui/AppShell.tsx +++ b/src/ui/AppShell.tsx @@ -1,266 +1,10 @@ /* eslint-disable react-refresh/only-export-components */ -/* eslint-disable react-hooks/exhaustive-deps */ -import type { TabHandle } from "@termix-ssh/plugin-sdk/frontend"; -import { toast } from "sonner"; -import { useTranslation } from "react-i18next"; -import { Sheet, SheetContent } from "@/components/sheet"; -import { Columns2, Maximize2, RotateCcw, Rows2, X } from "lucide-react"; -import { - useState, - useRef, - useCallback, - useEffect, - useLayoutEffect, - useMemo, - createRef, - lazy, - Suspense, -} from "react"; -import { createPortal } from "react-dom"; -import { useIsMobile } from "@/hooks/use-mobile"; -import { resetPermissionsCache } from "@/hooks/use-permissions"; -import { MobileBar } from "@/shell/MobileBar"; -import { isElectron } from "@/lib/electron"; -import { AppRail, type RailView } from "@/sidebar/AppRail"; -import { ComponentSlot } from "@/shell/ActionSlot"; -import { - isCoreRailView, - railItemLabel, - promotableIds, - rightDockableIds, - useRailItems, -} from "@/sidebar/rail-items"; -import { - DOCK_DEFAULT_WIDTH, - DockPanel, - DockReopenStrip, -} from "@/shell/DockPanel"; -import { - SurfaceScope, - useConfirm, - type ConfirmOptions, - type EditingWidth, -} from "@/components/surface/surface-scope"; -import { OnboardingHost } from "@/onboarding/OnboardingHost"; -import { DesktopUpdateToasts } from "@/shell/DesktopUpdateToasts"; -import { SplitView, type SplitViewActions } from "@/shell/split/SplitView"; -import { SplitDropOverlay } from "@/shell/split/SplitDropOverlay"; -import { - EmptyPanePicker, - type PickHostTarget, -} from "@/shell/split/EmptyPanePicker"; -import { renderTabContent } from "@/shell/tabUtils"; -import { TabBar } from "@/shell/TabBar"; -import { reconnectDisconnectedTabs } from "@/shell/reconnect-tabs"; -import { canCloseTab } from "@/shell/tab-close-guards"; -import { - MANAGE_REQUEST_EVENT, - type ManageMode, - requestFromLegacyEvent, - requestManage, -} from "@/manage/manage-requests"; -import { - dispatchCtrlW, - createCommandPaletteShortcutMatcher, - isShiftKey, -} from "@/lib/app-keyboard-shortcuts"; -import { parseCustomKeybindings } from "@/api/open-tabs-api"; -import { findMatchingKeybinding } from "@/lib/keybinding-match"; -import type { - CustomKeybinding, - KeybindingActionType, -} from "@/types/keybindings"; -import { - GLOBAL_KEYBINDING_EVENT, - getKeybindingAction, - runKeybindingAction, -} from "@/shell/keybinding-registry"; - -// Shell surfaces that are not needed for first paint. -const CommandPalette = lazy(() => - import("@/shell/CommandPalette").then((m) => ({ - default: m.CommandPalette, - })), -); -const HostsPanel = lazy(() => - import("@/sidebar/HostsPanel").then((m) => ({ default: m.HostsPanel })), -); -const QuickConnectPanel = lazy(() => - import("@/sidebar/QuickConnectPanel").then((m) => ({ - default: m.QuickConnectPanel, - })), -); - -const SyncPanel = lazy(() => - import("@/settings/sync/SyncPanel").then((m) => ({ - default: m.SyncPanel, - })), -); -const CredentialsPanel = lazy(() => - import("@/sidebar/CredentialsPanel").then((m) => ({ - default: m.CredentialsPanel, - })), -); -const ConnectionsPanel = lazy(() => - import("@/sidebar/ConnectionsPanel").then((m) => ({ - default: m.ConnectionsPanel, - })), -); - -function SidebarPanelFallback() { - return ( -

-
-
- ); -} -import type { - Tab, - TabType, - Host, - HostFolder, - ThemeId, - FontSizeId, - WorkspacePayload, -} 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 { - getSSHHosts, - getSSHFolders, - getUserInfo, - getOpenTabs, - addOpenTab, - deleteOpenTab, - patchOpenTab, - createSSHHost, - getActiveSessions, - getUserPreferences, - saveUserPreferences, - type UserPreferences, - type OpenTabRecord, -} from "@/main-axios"; -import { - buildLayoutPayload, - resolveLayoutSplits, - resolveLayoutTabTarget, - snapshotData, -} from "@/shell/shell-layout"; -import { useSyncStatus } from "@/hooks/use-sync-status"; -import { remScale } from "@/lib/rem"; -import { dbHealthMonitor } from "@/lib/db-health-monitor"; -import { ServerStatusProvider } from "@/lib/ServerStatusContext"; -import { sshHostToHost } from "@/sidebar/HostManagerData"; -import { resolveHostTabType } from "@/lib/host-connection-tabs"; -import { changeAppLanguage, consumeLoginLanguage } from "@/i18n/i18n"; -import { quickConnectHostToPayload } from "@/sidebar/quick-connect-host"; -import { buildHostTree } from "@/sidebar/build-host-tree"; -import { - addSplitTab, - canJoinSplit, - closeSplitTab, - isSplitTab, - makeSplitTab, - nextSplitNumber, - placeTabInPane, - removeTab, - restoreSplitTabs, - serializeSplitTabs, - splitTabOf, - summarizeSplits, - updateSplit, - type SplitTab, - type TabSplitAction, -} from "@/shell/split/split-tabs"; -import { - addPaneAtEdge, - assignTab, - canAddPane, - createPane, - createSplitNode, - createSplitState, - equalizeSplit, - findPane, - findPaneByTab, - focusPane, - listPanes, - MAX_PANES, - movePane, - neighborPane, - removePane, - resizeSplit, - shownPanes, - splitPane, - swapPanes, - toggleZoom, - type NavDirection, - type PaneEdge, - type PaneRect, - type SplitState, -} from "@/shell/split/split-tree"; -import { - applyPreset, - buildPreset, - fromLegacyConfig, - type SplitPresetId, -} from "@/shell/split/split-presets"; -import { - setSplitDropHandler, - type SplitDragSource, - type SplitDropHover, -} from "@/shell/split/split-drag"; -import { - publishSplitTargets, - setSplitOpener, - type SplitOpenTarget, -} from "@/shell/split/split-targets"; -import { registerPaletteEntry } from "@/shell/palette-registry"; -import { createId } from "@/lib/create-id"; -import { - canRestoreTabType, - getTabType, - isPersistentTabType, - isSessionTabType, - useTabTypes, - type TabShellCallbacks, -} from "@/shell/tab-registry"; -import { runHostAction } from "@/sidebar/host-contributions"; -import { getPanel, usePanels } from "@/shell/panel-registry"; -import { usePluginStore } from "@/plugin-host/plugin-store"; -import { - notifyShellReady, - notifyTabsChanged, - setShellCallbacks, - setShellHosts, - setShellLayoutProvider, -} from "@/plugin-host/shell-bridge"; -import { PluginViewPlaceholder } from "@/plugin-host/PluginViewPlaceholder"; -import { - OPEN_PLUGINS_EVENT, - OPEN_PLUGIN_SETTINGS_EVENT, -} from "@/plugins/open-plugins"; +import { ShellRoot } from "@/shell/ShellRoot"; export { buildHostTree } from "@/sidebar/build-host-tree"; export { tabIcon, renderTabContent } from "@/shell/tabUtils"; -/** Hands the main area's confirm to code outside it, like closeTab. */ -function ShellConfirmCapture({ - confirmRef, -}: { - confirmRef: React.MutableRefObject< - ((options: ConfirmOptions) => Promise) | null - >; -}) { - const confirm = useConfirm(); - confirmRef.current = confirm; - return null; -} - -// ─── AppShell ──────────────────────────────────────────────────────────────── - +/** The signed-in app. State lives in the shell controller, drawing in the layout. */ export function AppShell({ username, onLogout, @@ -268,2920 +12,5 @@ export function AppShell({ username: string; onLogout: (options?: { manual?: boolean }) => void; }) { - const { t, i18n } = useTranslation(); - const { setTheme } = useTheme(); - // Re-render when plugins add or remove rail items, panels or tabs. - useRailItems(); - const registeredPanels = usePanels(); - const tabTypes = useTabTypes(); - const { settled: pluginsSettled } = usePluginStore(); - const [tabs, setTabs] = useState([ - { - id: "dashboard", - instanceId: "dashboard", - type: "dashboard", - label: t("nav.dashboard"), - openedAt: Date.now(), - }, - ]); - const [activeTabId, setActiveTabId] = useState("dashboard"); - const [userPrefs, setUserPrefs] = useState({ - reopenTabsOnLogin: false, - }); - const [userPrefsLoaded, setUserPrefsLoaded] = useState(false); - const [hostsLoaded, setHostsLoaded] = useState(false); - // Flips to true once the initial DB read (restore or skip) is done. Sync must not fire before this - const [tabsReady, setTabsReady] = useState(false); - const [commandPaletteOpen, setCommandPaletteOpen] = useState(false); - // Split tabs are restored once their child tabs have stable live ids. - const paneLayoutRestoredRef = useRef(false); - const splitTabsRestoredRef = useRef(false); - const [realHostTree, setRealHostTree] = useState(null); - const [hostsLoading, setHostsLoading] = useState(true); - const [allHosts, setAllHosts] = useState([]); - const allHostsRef = useRef(allHosts); - allHostsRef.current = allHosts; - const [isAdmin, setIsAdmin] = useState(false); - // The standalone desktop backend still owns system settings such as the - // Tailscale API key, even though it has only one implicit user. - const showAdminUI = isAdmin; - const [backgroundTabRecords, setBackgroundTabRecords] = useState< - OpenTabRecord[] - >([]); - - const [sidebarOpen, setSidebarOpen] = useState(true); - const [railView, setRailView] = useState("hosts"); - - // Anything that asks to add or edit a host, a credential or host defaults - // ends up in the Manage tab, whichever event it still speaks. - useEffect(() => { - const names = [ - "host-manager:add-host", - "host-manager:edit-host", - "host-manager:add-credential", - "host-manager:show-credentials", - "host-manager:edit-defaults", - "termix:open-host-defaults", - ]; - const handler = (e: Event) => { - const request = requestFromLegacyEvent(e.type, (e as CustomEvent).detail); - if (request) requestManage(request); - }; - const open = () => openSingletonTabRef.current("host-manager"); - for (const name of names) window.addEventListener(name, handler); - window.addEventListener(MANAGE_REQUEST_EVENT, open); - return () => { - for (const name of names) window.removeEventListener(name, handler); - window.removeEventListener(MANAGE_REQUEST_EVENT, open); - }; - }, []); - const [sidebarWidth, setSidebarWidth] = useState(() => { - const saved = localStorage.getItem("termix_sidebarWidth"); - return saved ? parseInt(saved, 10) : 291; - }); - const [sidebarDragging, setSidebarDragging] = useState(false); - // How far each panel wants the sidebar widened: what the panel says itself, - // and what its open inline views say. - const [panelEditing, setPanelEditing] = useState< - Record - >({}); - const [scopeEditing, setScopeEditing] = useState< - Record - >({}); - const setPanelEditingFor = useCallback( - (view: string, editing: EditingWidth) => - setPanelEditing((prev) => - prev[view] === editing ? prev : { ...prev, [view]: editing }, - ), - [], - ); - const setScopeEditingFor = useCallback( - (view: string, editing: EditingWidth) => - setScopeEditing((prev) => - prev[view] === editing ? prev : { ...prev, [view]: editing }, - ), - [], - ); - const editingSetters = useRef( - new Map void>(), - ); - const panelEditingSetter = (view: string) => { - const key = `panel:${view}`; - if (!editingSetters.current.has(key)) - editingSetters.current.set(key, (e) => setPanelEditingFor(view, e)); - return editingSetters.current.get(key)!; - }; - const scopeEditingSetter = (view: string) => { - const key = `scope:${view}`; - if (!editingSetters.current.has(key)) - editingSetters.current.set(key, (e) => setScopeEditingFor(view, e)); - return editingSetters.current.get(key)!; - }; - // The root confirm, for questions the shell itself asks (closing a tab). - const shellConfirmRef = useRef< - ((options: ConfirmOptions) => Promise) | null - >(null); - - // Right dock: a second panel column so reference panels like history can - // stay visible while the left sidebar is used for something else. - const [rightRailView, setRightRailView] = useState(() => { - const saved = localStorage.getItem("termix_rightRailView"); - return saved && rightDockableIds().includes(saved) - ? (saved as RailView) - : null; - }); - const [rightSidebarWidth, setRightSidebarWidth] = useState(() => { - const saved = localStorage.getItem("termix_rightSidebarWidth"); - return saved ? parseInt(saved, 10) : 291; - }); - const [rightSidebarDragging, setRightSidebarDragging] = useState(false); - // Remembers the last panel shown in the dock so the tab bar toggle can bring - // it back instead of always falling back to the same default. - const lastRightRailViewRef = useRef( - localStorage.getItem("termix_lastRightRailView"), - ); - const [isAppFullscreen, setIsAppFullscreen] = useState( - () => !!document.fullscreenElement, - ); - - useEffect(() => { - localStorage.setItem("termix_sidebarWidth", String(sidebarWidth)); - }, [sidebarWidth]); - - useEffect(() => { - localStorage.setItem("termix_rightSidebarWidth", String(rightSidebarWidth)); - }, [rightSidebarWidth]); - - useEffect(() => { - if (rightRailView) { - localStorage.setItem("termix_rightRailView", rightRailView); - lastRightRailViewRef.current = rightRailView; - localStorage.setItem("termix_lastRightRailView", rightRailView); - } else { - localStorage.removeItem("termix_rightRailView"); - } - }, [rightRailView]); - - useEffect(() => { - if (!splitTabsRestoredRef.current) return; - localStorage.setItem( - "termix_splitTabs", - JSON.stringify(serializeSplitTabs(tabs)), - ); - }, [tabs]); - - const isMobile = useIsMobile(); - const sidebarOpenBeforeMobile = useRef(sidebarOpen); - useEffect(() => { - if (isMobile) { - sidebarOpenBeforeMobile.current = sidebarOpen; - setSidebarOpen(false); - } else { - setSidebarOpen(sidebarOpenBeforeMobile.current); - } - }, [isMobile]); - - useEffect(() => { - getUserInfo() - .then((info) => setIsAdmin(info.is_admin)) - .catch(() => setIsAdmin(false)); - }, []); - - const toggleAppFullscreen = useCallback(async () => { - try { - if (document.fullscreenElement) { - await document.exitFullscreen(); - return; - } - - if (!document.fullscreenEnabled) { - toast.error(t("nav.fullscreenUnsupported")); - return; - } - - await document.documentElement.requestFullscreen(); - } catch { - toast.error(t("nav.fullscreenFailed")); - } - }, []); - - useEffect(() => { - const handleFullscreenChange = () => { - setIsAppFullscreen(!!document.fullscreenElement); - }; - - document.addEventListener("fullscreenchange", handleFullscreenChange); - return () => - document.removeEventListener("fullscreenchange", handleFullscreenChange); - }, []); - - const tabsRef = useRef(tabs); - const activeTabIdRef = useRef(activeTabId); - const closeActiveTabRef = useRef<() => void>(() => {}); - const globalKeybindingsRef = useRef([]); - // Split actions for handlers registered once, e.g. the global hotkeys. - const splitActionsRef = useRef<{ - splitActive: (edge: "right" | "bottom") => void; - navigate: (direction: NavDirection) => boolean; - zoomFocused: () => boolean; - closeFocusedPane: () => void; - } | null>(null); - useEffect(() => { - tabsRef.current = tabs; - }, [tabs]); - - useEffect(() => { - let cancelled = false; - const load = () => { - getUserPreferences() - .then((prefs) => { - if (cancelled) return; - globalKeybindingsRef.current = parseCustomKeybindings( - prefs.customKeybindings, - ).filter((binding) => binding.enabled); - }) - .catch(() => {}); - }; - load(); - window.addEventListener("customKeybindingsChanged", load); - return () => { - cancelled = true; - window.removeEventListener("customKeybindingsChanged", load); - }; - }, []); - - useEffect(() => { - const runAction = (type: KeybindingActionType) => { - if (type === "openCommandPalette") { - setCommandPaletteOpen(true); - return; - } - if (type === "reconnectSession") { - const tabId = focusedSessionTabId(); - if (!tabId) return; - const termRef = terminalRefs.current.get(tabId); - (termRef?.current as TabHandle | null)?.reconnect?.(); - return; - } - const currentTabs = tabsRef.current.filter( - (tab) => !tab.parentSplitTabId, - ); - if (currentTabs.length < 2) return; - const index = currentTabs.findIndex( - (tab) => tab.id === activeTabIdRef.current, - ); - const offset = type === "nextTab" ? 1 : -1; - const next = (index + offset + currentTabs.length) % currentTabs.length; - setActiveTabId(currentTabs[next].id); - }; - - const handleKeyDown = (event: KeyboardEvent) => { - if ( - event.target instanceof Element && - event.target.closest("[data-keybinding-recorder]") - ) - return; - // Only actions that run anywhere; a terminal's own are its to handle. - const binding = findMatchingKeybinding( - event, - globalKeybindingsRef.current.filter( - (entry) => getKeybindingAction(entry.action.type)?.scope === "global", - ), - ); - if (!binding) return; - event.preventDefault(); - event.stopPropagation(); - runKeybindingAction(binding.action); - }; - const handleAction = (event: Event) => - runAction( - (event as CustomEvent<{ type: KeybindingActionType }>).detail.type, - ); - - window.addEventListener("keydown", handleKeyDown, true); - window.addEventListener(GLOBAL_KEYBINDING_EVENT, handleAction); - return () => { - window.removeEventListener("keydown", handleKeyDown, true); - window.removeEventListener(GLOBAL_KEYBINDING_EVENT, handleAction); - }; - }, []); - useEffect(() => { - activeTabIdRef.current = activeTabId; - }, [activeTabId]); - useEffect(() => { - return window.electronAPI?.onCloseActiveTab?.(() => { - if (dispatchCtrlW(document.activeElement)) return; - closeActiveTabRef.current(); - }); - }, []); - /** The tab the user is working in: the focused pane's inside a split. */ - function focusedSessionTabId(): string | null { - const id = activeTabIdRef.current; - const active = tabsRef.current.find((tab) => tab.id === id); - if (!isSplitTab(active)) return id || null; - return findPane(active.split, active.split.focusedPaneId)?.tabId ?? null; - } - - // ─── Where opened tabs go ───────────────────────────────────────────────── - - const PENDING_TARGET_MS = 60_000; - /** - * The pane the next opened tab should fill, set when the user picks one - * (the empty pane picker, "Open in split"). `fresh` marks a pane created in - * the same click, which the last committed tabs do not show yet. - */ - const pendingPaneTargetRef = useRef<{ - splitTabId: string; - paneId: string; - at: number; - fresh: boolean; - } | null>(null); - // Set while a saved layout is applied, so its tabs stay where it puts them. - const suppressPaneTargetRef = useRef(false); - - function targetPane(splitTabId: string, paneId: string, fresh = false) { - pendingPaneTargetRef.current = { - splitTabId, - paneId, - at: Date.now(), - fresh, - }; - } - - /** - * Shows a newly opened (or re-focused) tab. It fills the pane the user - * picked, else the focused pane of the active split when that is empty, - * else it simply becomes the active tab. - */ - const placeOpenedTabRef = useRef<(tabId: string) => void>(() => {}); - placeOpenedTabRef.current = (tabId: string) => { - const current = tabsRef.current; - const pending = pendingPaneTargetRef.current; - pendingPaneTargetRef.current = null; - if (suppressPaneTargetRef.current || tabId === "dashboard") { - setActiveTabId(tabId); - return; - } - const existing = current.find((tab) => tab.id === tabId); - - let target: { splitTabId: string; paneId: string } | null = null; - if (pending && Date.now() - pending.at < PENDING_TARGET_MS) { - const split = current.find((tab) => tab.id === pending.splitTabId); - if ( - pending.fresh || - (isSplitTab(split) && findPane(split.split, pending.paneId)) - ) { - target = pending; - } - } - // Opening a session that already sits in a split shows it there. - if (!target && existing?.parentSplitTabId) { - setActiveTabId(tabId); - return; - } - if (!target) { - const active = current.find((tab) => tab.id === activeTabIdRef.current); - if (isSplitTab(active)) { - const focused = findPane(active.split, active.split.focusedPaneId); - if (focused && focused.tabId === null) { - target = { splitTabId: active.id, paneId: focused.id }; - } - } - } - if (!target || (existing && !canJoinSplit(existing))) { - setActiveTabId(tabId); - return; - } - const { splitTabId, paneId } = target; - setTabs((prev) => placeTabInPane(prev, splitTabId, paneId, tabId)); - setActiveTabId(splitTabId); - }; - - // Panels that type into a terminal act on "the terminal you're working in". - // Once those panels can themselves be the active tab, activeTabId points at - // the panel and the lookup misses, so remember the last terminal instead. - // In a split, the tab being worked in is the focused pane's. - const workingTabId = (() => { - const active = tabs.find((t) => t.id === activeTabId); - if (!isSplitTab(active)) return activeTabId; - return findPane(active.split, active.split.focusedPaneId)?.tabId ?? ""; - })(); - const [lastTerminalTabId, setLastTerminalTabId] = useState(activeTabId); - useEffect(() => { - const working = tabs.find((t) => t.id === workingTabId); - if (working && getTabType(working.type)?.commandTarget) { - setLastTerminalTabId(working.id); - } - }, [workingTabId, tabs]); - const [commandPaletteShortcutEnabled, setCommandPaletteShortcutEnabled] = - useState(() => { - const v = localStorage.getItem("commandPaletteShortcutEnabled"); - return v !== null ? v === "true" : true; - }); - const [showTabNumbers, setShowTabNumbers] = useState( - () => localStorage.getItem("showTabNumbers") === "true", - ); - const terminalRefs = useRef>>( - new Map(), - ); - // Each shown pane's content element, by pane id. The version bump - // re-renders so the placement effect moves tab nodes into new panes. - const paneElsRef = useRef>(new Map()); - const [, setPaneElsVersion] = useState(0); - - // Stable per-tab DOM nodes, created once per tab, never destroyed while the tab lives. - // We always portal each tab's content into its own node, then move that node between - // the normal-view container and the pane container via vanilla DOM so React's portal - // target never changes (changing the target causes a remount). - const tabNodesRef = useRef>(new Map()); - const normalViewRef = useRef(null); - // The area below the tab bar, where tabs and panes are dropped to split. - const mainAreaRef = useRef(null); - // Tab id the enter animation has already played for, so a re-render while - // the tab stays active (there can be several right after a switch) doesn't - // replay it, only a genuine switch to a different tab should. - const lastAnimatedTabIdRef = useRef(null); - - const getTabNode = useCallback((tabId: string, isTerminal: boolean) => { - if (!tabNodesRef.current.has(tabId)) { - const el = document.createElement("div"); - el.style.position = "absolute"; - el.style.inset = "0"; - el.style.overflow = "hidden"; - if (!isTerminal) el.classList.add("bg-background"); - tabNodesRef.current.set(tabId, el); - } - return tabNodesRef.current.get(tabId)!; - }, []); - - // Portal render order for tab content, kept independent of the tab bar's - // visual order. Reordering tabs in the bar reorders `tabs`, and mapping - // that array directly to portals reshuffles the Suspense-wrapped portal - // children's sibling order in the fiber tree, React then runs its - // Offscreen disconnect/reconnect pass on the ones that moved, which tears - // down and rebuilds every passive effect underneath (including - // react-xtermjs's terminal-creation effect), dropping the live terminal - // and its WebSocket. Portal position doesn't need to track tab order at - // all, so we only ever append new ids and drop closed ones here. - const portalOrderRef = useRef([]); - { - const liveIds = new Set(tabs.map((t) => t.id)); - portalOrderRef.current = portalOrderRef.current.filter((id) => - liveIds.has(id), - ); - const known = new Set(portalOrderRef.current); - for (const tab of tabs) { - if (!known.has(tab.id)) portalOrderRef.current.push(tab.id); - } - } - const tabsByPortalOrder = portalOrderRef.current - .map((id) => tabs.find((t) => t.id === id)) - .filter((t): t is Tab => t !== undefined); - - const onPaneContentRef = useCallback( - (paneId: string, el: HTMLDivElement | null) => { - const els = paneElsRef.current; - if (el) { - if (els.get(paneId) === el) return; - els.set(paneId, el); - } else { - if (!els.has(paneId)) return; - els.delete(paneId); - } - setPaneElsVersion((version) => version + 1); - }, - [], - ); - - // Titles come from the shared rail definitions so they stay translated and - // in step with the rail itself. - const sidebarTitle = (view: RailView): string => railItemLabel(view, t); - - // Double-shift or Ctrl+K opens the command palette. Double-shift alone was - // hard to discover. - useEffect(() => { - if (!commandPaletteShortcutEnabled) return; - const shortcut = createCommandPaletteShortcutMatcher(); - const handleKeyDown = (e: KeyboardEvent) => { - if (!shortcut.matches(e)) return; - if (!isShiftKey(e)) e.preventDefault(); - setCommandPaletteOpen((prev) => !prev); - }; - window.addEventListener("keydown", handleKeyDown); - window.addEventListener("blur", shortcut.reset); - window.addEventListener("compositionstart", shortcut.reset); - return () => { - window.removeEventListener("keydown", handleKeyDown); - window.removeEventListener("blur", shortcut.reset); - window.removeEventListener("compositionstart", shortcut.reset); - }; - }, [commandPaletteShortcutEnabled]); - - // Ctrl+Shift+E toggles between the two most recent sidebar panels. - const previousRailViewRef = useRef(null); - const currentRailViewRef = useRef(railView); - useEffect(() => { - if (currentRailViewRef.current !== railView) { - previousRailViewRef.current = currentRailViewRef.current; - currentRailViewRef.current = railView; - } - }, [railView]); - useEffect(() => { - const handleKeyDown = (e: KeyboardEvent) => { - if (!e.ctrlKey || !e.shiftKey || e.altKey || e.code !== "KeyE") return; - e.preventDefault(); - const previous = previousRailViewRef.current; - if (!sidebarOpen) { - setSidebarOpen(true); - return; - } - if (previous && previous !== railView) handleRailClick(previous); - }; - window.addEventListener("keydown", handleKeyDown); - return () => window.removeEventListener("keydown", handleKeyDown); - }, [railView, sidebarOpen]); - - // Split-screen and tab navigation hotkeys - // Also registered in globalShortcutHandler so xterm can invoke directly - // without going through synthetic DOM events (which are unreliable). - useEffect(() => { - const handleKeyDown = (e: KeyboardEvent) => { - if (e.ctrlKey && e.shiftKey && !e.altKey && !e.metaKey) { - if (e.code === "KeyF") { - e.preventDefault(); - toggleAppFullscreen(); - return; - } - } - - // Ctrl+Shift+\ splits right, Ctrl+Shift+- splits down, Ctrl+Shift+Enter - // zooms the focused pane. - if (e.ctrlKey && e.shiftKey && !e.altKey && !e.metaKey) { - if (e.code === "Backslash" || e.code === "Minus") { - e.preventDefault(); - splitActionsRef.current?.splitActive( - e.code === "Backslash" ? "right" : "bottom", - ); - return; - } - if (e.code === "Enter" && splitActionsRef.current?.zoomFocused()) { - 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 - const tabDigit = getTabJumpDigit(e); - if (tabDigit !== null) { - const currentTabs = tabsRef.current.filter( - (tab) => !tab.parentSplitTabId, - ); - const index = tabDigit - 1; - if (index < currentTabs.length) { - e.preventDefault(); - setActiveTabId(currentTabs[index].id); - } - return; - } - - // Ctrl+Shift+] / Ctrl+Shift+[: cycle through open tabs (] = next, [ = previous) - if (e.ctrlKey && e.shiftKey && !e.altKey && !e.metaKey) { - if (e.code === "BracketRight" || e.code === "BracketLeft") { - e.preventDefault(); - const currentTabs = tabsRef.current.filter( - (tab) => !tab.parentSplitTabId, - ); - if (currentTabs.length < 2) return; - const currentId = activeTabIdRef.current; - const idx = currentTabs.findIndex((t) => t.id === currentId); - const next = - e.code === "BracketRight" - ? (idx + 1) % currentTabs.length - : (idx - 1 + currentTabs.length) % currentTabs.length; - setActiveTabId(currentTabs[next].id); - return; - } - } - }; - - globalShortcutHandler.current = handleKeyDown; - window.addEventListener("keydown", handleKeyDown); - return () => { - globalShortcutHandler.current = null; - window.removeEventListener("keydown", handleKeyDown); - }; - }, []); - - useEffect(() => { - const handler = () => { - const v = localStorage.getItem("commandPaletteShortcutEnabled"); - setCommandPaletteShortcutEnabled(v !== null ? v === "true" : true); - }; - window.addEventListener("commandPaletteShortcutEnabledChanged", handler); - return () => - window.removeEventListener( - "commandPaletteShortcutEnabledChanged", - handler, - ); - }, []); - - useEffect(() => { - const handler = () => { - setShowTabNumbers(localStorage.getItem("showTabNumbers") === "true"); - }; - window.addEventListener("showTabNumbersChanged", handler); - return () => window.removeEventListener("showTabNumbersChanged", handler); - }, []); - - useEffect(() => { - const handle = (event: Event) => { - const manual = - event instanceof CustomEvent && - (event.detail as { manual?: boolean } | undefined)?.manual === true; - // Drop the cached grants so the next user never inherits them. - resetPermissionsCache(); - onLogout(manual ? { manual: true } : undefined); - }; - window.addEventListener("termix:logout", handle); - return () => window.removeEventListener("termix:logout", handle); - }, [onLogout]); - - useEffect(() => { - const handleSessionExpired = () => onLogout(); - dbHealthMonitor.on("session-expired", handleSessionExpired); - return () => dbHealthMonitor.off("session-expired", handleSessionExpired); - }, [onLogout]); - - useEffect(() => { - const activeTab = tabs.find((t) => t.id === activeTabId); - if (!activeTab?.terminalRef) return; - let innerRafId: number; - const outerRafId = requestAnimationFrame(() => { - innerRafId = requestAnimationFrame(() => { - const ref = activeTab.terminalRef?.current; - ref?.fit?.(); - ref?.notifyResize?.(); - ref?.refresh?.(); - }); - }); - return () => { - cancelAnimationFrame(outerRafId); - cancelAnimationFrame(innerRafId); - }; - }, [activeTabId]); - - useEffect(() => { - const handleDegraded = () => { - toast.loading(t("common.connectionDegraded"), { - id: "db-connection-degraded", - duration: Infinity, - dismissible: false, - action: { - label: t("common.reload"), - onClick: () => window.location.reload(), - }, - }); - }; - - const handleRestored = () => { - toast.dismiss("db-connection-degraded"); - toast.success(t("common.backendReconnected"), { duration: 3000 }); - }; - - dbHealthMonitor.on("database-connection-degraded", handleDegraded); - dbHealthMonitor.on("database-connection-degraded-cleared", handleRestored); - - return () => { - dbHealthMonitor.off("database-connection-degraded", handleDegraded); - dbHealthMonitor.off( - "database-connection-degraded-cleared", - handleRestored, - ); - }; - }, [t]); - - useEffect(() => { - getUserPreferences() - .then((prefs) => { - const loginLanguage = consumeLoginLanguage(); - setUserPrefs(prefs); - if (prefs.storageMode === "cloud") { - // Persist the current browser values before overwriting, so any tab can restore them - if (!localStorage.getItem("termix-local-snapshot")) { - const SNAPSHOT_KEYS = [ - "termix-accent", - "termix-font-size", - "termix-ui-font", - "i18nextLng", - "commandPaletteShortcutEnabled", - "showHostTags", - "hostTrayOnClick", - "pinAppRail", - "expandAppRailOnHover", - "disableUpdateCheck", - "confirmTabClose", - "hiddenRailTabs", - ]; - const snap: Record = { - __theme: localStorage.getItem("termix-theme"), - }; - for (const key of SNAPSHOT_KEYS) - snap[key] = localStorage.getItem(key); - localStorage.setItem("termix-local-snapshot", JSON.stringify(snap)); - } - if (prefs.theme) setTheme(prefs.theme as ThemeId); - if (prefs.fontSize) applyFontSize(prefs.fontSize as FontSizeId); - if (prefs.accentColor) { - localStorage.setItem("termix-accent", prefs.accentColor); - applyAccentColor(prefs.accentColor); - } - const preferredLanguage = loginLanguage ?? prefs.language; - if (preferredLanguage && preferredLanguage !== i18n.language) { - void changeAppLanguage(preferredLanguage); - } - if (loginLanguage && loginLanguage !== prefs.language) { - void saveUserPreferences({ language: loginLanguage }); - } - if ( - prefs.commandPaletteEnabled !== null && - prefs.commandPaletteEnabled !== undefined - ) - localStorage.setItem( - "commandPaletteShortcutEnabled", - String(prefs.commandPaletteEnabled), - ); - if (prefs.showHostTags !== null && prefs.showHostTags !== undefined) { - localStorage.setItem("showHostTags", String(prefs.showHostTags)); - window.dispatchEvent(new CustomEvent("showHostTagsChanged")); - } - if ( - prefs.hostTrayOnClick !== null && - prefs.hostTrayOnClick !== undefined - ) - localStorage.setItem( - "hostTrayOnClick", - String(prefs.hostTrayOnClick), - ); - if (prefs.pinAppRail !== null && prefs.pinAppRail !== undefined) { - localStorage.setItem("pinAppRail", String(prefs.pinAppRail)); - window.dispatchEvent(new Event("pinAppRailChanged")); - } - if ( - prefs.expandAppRailOnHover !== null && - prefs.expandAppRailOnHover !== undefined - ) { - localStorage.setItem( - "expandAppRailOnHover", - String(prefs.expandAppRailOnHover), - ); - window.dispatchEvent(new Event("expandAppRailOnHoverChanged")); - } - if ( - prefs.disableUpdateCheck !== null && - prefs.disableUpdateCheck !== undefined - ) - localStorage.setItem( - "disableUpdateCheck", - String(prefs.disableUpdateCheck), - ); - if ( - prefs.confirmTabClose !== null && - prefs.confirmTabClose !== undefined - ) - localStorage.setItem( - "confirmTabClose", - String(prefs.confirmTabClose), - ); - if ( - prefs.hiddenRailTabs !== null && - prefs.hiddenRailTabs !== undefined - ) { - localStorage.setItem("hiddenRailTabs", prefs.hiddenRailTabs); - window.dispatchEvent(new CustomEvent("hiddenRailTabsChanged")); - } - } - }) - .catch(() => {}) - .finally(() => setUserPrefsLoaded(true)); - }, []); - - // Load real hosts from API - const loadHosts = useCallback(async () => { - try { - const [raw, folders] = await Promise.all([ - getSSHHosts(), - getSSHFolders().catch(() => []), - ]); - const converted = raw.map(sshHostToHost); - setAllHosts(converted); - const folderMeta = new Map< - string, - { - color?: string; - icon?: string; - credentialId?: number | null; - sortOrder?: number | null; - localOnly?: boolean; - } - >(); - for (const f of folders) { - folderMeta.set(f.name, { - color: f.color ?? undefined, - icon: f.icon ?? undefined, - credentialId: f.credentialId ?? null, - sortOrder: f.sortOrder ?? null, - localOnly: !!f.localOnly, - }); - } - setRealHostTree(buildHostTree(raw, folderMeta)); - } catch { - // Keep empty state on error - } finally { - setHostsLoading(false); - setHostsLoaded(true); - } - }, []); - - useEffect(() => { - loadHosts(); - }, [loadHosts]); - - useEffect(() => { - const onHostsChanged = () => { - void loadHosts(); - }; - window.addEventListener("termix:hosts-changed", onHostsChanged); - window.addEventListener("ssh-hosts:changed", onHostsChanged); - window.addEventListener("hosts:refresh", onHostsChanged); - return () => { - window.removeEventListener("termix:hosts-changed", onHostsChanged); - window.removeEventListener("ssh-hosts:changed", onHostsChanged); - window.removeEventListener("hosts:refresh", onHostsChanged); - }; - }, [loadHosts]); - - // Keeps the desktop's sync status polled while the app is open; a pass - // that changed data tells the panels to reload. - useSyncStatus(); - - // Sync tab host data when allHosts updates (e.g. after editing terminal theme in host settings) - useEffect(() => { - if (allHosts.length === 0) return; - const byId = new Map(allHosts.map((h) => [h.id, h])); - setTabs((prev) => { - let changed = false; - const next = prev.map((t) => { - const fresh = t.host ? byId.get(t.host.id) : undefined; - if (!fresh || fresh === t.host) return t; - // A reload hands back new objects; only replace hosts that changed so - // untouched tabs keep their identity and skip a re-render. - if (JSON.stringify(fresh) === JSON.stringify(t.host)) return t; - changed = true; - return { ...t, host: fresh }; - }); - return changed ? next : prev; - }); - }, [allHosts]); - - function buildWorkspacePayload(): WorkspacePayload { - return buildLayoutPayload({ - tabs, - activeTabId, - sidebar: { - left: { view: railView, open: sidebarOpen, width: sidebarWidth }, - right: { - view: rightRailView, - open: rightRailView !== null, - width: rightSidebarWidth, - }, - }, - }); - } - - async function applyLayout( - payload: WorkspacePayload, - name: string, - ): Promise<{ skipped: string[] }> { - // Tear down the current arrangement the same way an individual tab close does. - for (const tab of [...tabsRef.current]) { - doCloseTab(tab.id); - } - // Tabs reopened here must not be pulled into a pane of the old layout. - suppressPaneTargetRef.current = true; - pendingPaneTargetRef.current = null; - try { - const slotIdToNewTabId = new Map(); - const skippedTabs: string[] = []; - - for (const snapshot of payload.tabs) { - const target = resolveLayoutTabTarget(snapshot, allHostsRef.current); - - if (target.kind === "skip") { - skippedTabs.push(snapshot.hostNameSnapshot || snapshot.label); - continue; - } - - if (target.kind === "singleton") { - const newTabId = openSingletonTab( - snapshot.type, - undefined, - target.host, - snapshotData(snapshot), - ); - slotIdToNewTabId.set(snapshot.slotId, newTabId ?? snapshot.type); - continue; - } - - if (target.kind === "host") { - const newTabId = openTab( - target.host, - snapshot.type, - { - instanceId: createId(), - restoredSessionId: null, - savedLabel: snapshot.customLabel ?? snapshot.label, - }, - { data: snapshotData(snapshot) }, - ); - slotIdToNewTabId.set(snapshot.slotId, newTabId); - } - } - - const splitIdBySlotId = new Map(); - const splitIdByTabId = new Map(); - let firstSplitId: string | null = null; - resolveLayoutSplits(payload, slotIdToNewTabId).forEach((saved, index) => { - const splitTab = makeSplitTab( - createId(), - saved.label || name || splitLabel(index + 1), - saved.split, - ); - if (saved.slotId) splitIdBySlotId.set(saved.slotId, splitTab.id); - for (const pane of listPanes(saved.split.root)) { - if (pane.tabId) splitIdByTabId.set(pane.tabId, splitTab.id); - } - firstSplitId ??= splitTab.id; - setTabs((prev) => addSplitTab(prev, splitTab)); - }); - - // A tab that now sits in a split is shown through its split. - const activeSlot = payload.activeSlotId; - const activeTabInLayout = activeSlot - ? slotIdToNewTabId.get(activeSlot) - : undefined; - const activeId = - (activeSlot ? splitIdBySlotId.get(activeSlot) : undefined) ?? - (activeTabInLayout - ? (splitIdByTabId.get(activeTabInLayout) ?? activeTabInLayout) - : undefined) ?? - firstSplitId ?? - "dashboard"; - setActiveTabId(activeId); - - // Older payloads predate the sidebar field, so leave the docks alone then. - const sidebar = payload.sidebar; - if (sidebar) { - if (sidebar.left.view) { - setRailView( - (sidebar.left.view === "split-screen" - ? "hosts" - : sidebar.left.view) as RailView, - ); - } - setSidebarOpen(sidebar.left.open); - if (sidebar.left.width) setSidebarWidth(sidebar.left.width); - - const right = sidebar.right.open ? sidebar.right.view : null; - setRightRailView( - right && rightDockableIds().includes(right) - ? (right as RailView) - : null, - ); - if (sidebar.right.width) setRightSidebarWidth(sidebar.right.width); - } - - return { skipped: skippedTabs }; - } finally { - suppressPaneTargetRef.current = false; - } - } - - // On load: always read saved tabs from DB so background sessions are preserved across refreshes. - // If reopenTabsOnLogin is on, also restore them as open tabs in the tab bar. - const tabRestoreAttemptedRef = useRef(false); - useEffect(() => { - // Waits for plugins too, so a plugin's saved tabs come back as themselves. - if (!hostsLoaded || !userPrefsLoaded || !pluginsSettled) return; - if (tabRestoreAttemptedRef.current) return; - tabRestoreAttemptedRef.current = true; - - async function loadSavedTabs() { - try { - const [savedTabs, activeSessions] = await Promise.all([ - getOpenTabs(), - getActiveSessions(), - ]); - - if (!Array.isArray(savedTabs) || savedTabs.length === 0) return; - - const sessionByInstanceId = new Map( - (Array.isArray(activeSessions) ? activeSessions : []) - .filter((s) => s.tabInstanceId != null) - .map((s) => [s.tabInstanceId, s]), - ); - - if (userPrefs.reopenTabsOnLogin) { - const hasPersistentTabs = tabs.some((t) => - isPersistentTabType(t.type), - ); - if (!hasPersistentTabs) { - const restoredTabs: Tab[] = []; - for (const saved of savedTabs as OpenTabRecord[]) { - const host = saved.hostId - ? allHosts.find((h) => h.id === String(saved.hostId)) - : undefined; - if (!canRestoreTabType(saved.tabType, host)) continue; - - // Singleton tabs use their type as the stable ID; host-bound tabs get a unique ID - const tabId = host - ? `${host.name}-${saved.tabType}-${Date.now()}-${saved.tabOrder}` - : saved.id; - const liveSession = sessionByInstanceId.get(saved.id); - const restoredSessionId = - liveSession?.sessionId ?? saved.backendSessionId ?? null; - - const isCustomLabel = - host && - saved.label !== host.name && - !/^.+ \(\d+\)$/.test(saved.label); - - restoredTabs.push({ - id: tabId, - instanceId: saved.id, - type: saved.tabType as TabType, - label: saved.label, - customLabel: isCustomLabel ? saved.label : undefined, - host, - openedAt: new Date(saved.createdAt).getTime(), - restoredSessionId, - terminalRef: isSessionTabType(saved.tabType) - ? createRef() - : undefined, - }); - } - - if (restoredTabs.length > 0) { - setTabs((prev) => { - const existingIds = new Set(prev.map((t) => t.id)); - const newTabs = restoredTabs.filter( - (t) => !existingIds.has(t.id), - ); - return newTabs.length > 0 ? [...prev, ...newTabs] : prev; - }); - setActiveTabId(restoredTabs[0].id); - } - // Restored tabs are in the tab bar, not in background records - } - } else { - // Not restoring to tab bar, keep as background records for ConnectionsPanel - setBackgroundTabRecords(savedTabs as OpenTabRecord[]); - } - } catch { - // silently fail - } finally { - setTabsReady(true); - } - } - - loadSavedTabs(); - }, [hostsLoaded, userPrefsLoaded, pluginsSettled]); - - // Plugins that act once the session is back (the workspaces plugin applies - // a default workspace) wait for this. - useEffect(() => { - if (tabsReady) notifyShellReady(); - }, [tabsReady]); - - // Restore split tabs once their child sessions have stable live ids. The - // oldest single-split keys are migrated once into a first split. - useEffect(() => { - if (!tabsReady || paneLayoutRestoredRef.current) return; - paneLayoutRestoredRef.current = true; - - try { - const savedSplitTabs = JSON.parse( - localStorage.getItem("termix_splitTabs") ?? "[]", - ) as unknown; - if (Array.isArray(savedSplitTabs) && savedSplitTabs.length > 0) { - setTabs((prev) => restoreSplitTabs(savedSplitTabs, prev)); - return; - } - - const savedInstanceIds = JSON.parse( - localStorage.getItem("termix_paneInstanceIds") ?? "null", - ) as unknown; - const savedMode = localStorage.getItem("termix_splitMode"); - if (!Array.isArray(savedInstanceIds) || !savedMode) return; - const savedSizes = JSON.parse( - localStorage.getItem("termix_paneSizes") ?? "null", - ) as { rowSizes?: unknown; rowColSizes?: unknown } | null; - const tabIdByInstanceId = new Map( - tabs.map((tab) => [tab.instanceId, tab.id]), - ); - const root = fromLegacyConfig( - savedMode, - savedInstanceIds.map((instanceId) => - typeof instanceId === "string" - ? (tabIdByInstanceId.get(instanceId) ?? null) - : null, - ), - savedSizes?.rowSizes, - savedSizes?.rowColSizes, - ); - if (root && listPanes(root).some((pane) => pane.tabId)) { - const splitTab = makeSplitTab( - createId(), - splitLabel(1), - createSplitState(root), - ); - setTabs((prev) => addSplitTab(prev, splitTab)); - setActiveTabId(splitTab.id); - } - } catch { - // silently fail - } finally { - splitTabsRestoredRef.current = true; - localStorage.removeItem("termix_splitMode"); - localStorage.removeItem("termix_paneInstanceIds"); - localStorage.removeItem("termix_paneSizes"); - } - }, [tabsReady, tabs]); - - // Debounced tab-order sync: when tab order changes, patch each persistent tab's tabOrder in DB. - const orderSyncTimeoutRef = useRef | null>( - null, - ); - const prevTabOrderRef = useRef(""); - useEffect(() => { - if (!tabsReady) return; - const persistable = tabs.filter((t) => isPersistentTabType(t.type)); - const orderKey = persistable.map((t) => t.instanceId).join(","); - if (orderKey === prevTabOrderRef.current) return; - prevTabOrderRef.current = orderKey; - - if (orderSyncTimeoutRef.current) clearTimeout(orderSyncTimeoutRef.current); - orderSyncTimeoutRef.current = setTimeout(() => { - persistable.forEach((t, i) => { - patchOpenTab(t.instanceId, { tabOrder: i }).catch(() => {}); - }); - }, 500); - - return () => { - if (orderSyncTimeoutRef.current) - clearTimeout(orderSyncTimeoutRef.current); - }; - }, [tabs, tabsReady]); - - // Tells plugins the arrangement changed, e.g. so the workspaces plugin can - // keep its last-session snapshot current. - useEffect(() => { - if (tabsReady) notifyTabsChanged(); - }, [ - tabs, - tabsReady, - railView, - sidebarOpen, - sidebarWidth, - rightRailView, - rightSidebarWidth, - ]); - - // ─── Tab management ────────────────────────────────────────────────────── - - const openTab = useCallback(function openTab( - host: Host, - type: TabType, - restore?: { - instanceId: string; - restoredSessionId: string | null; - savedLabel?: string; - }, - options?: { - data?: Record; - label?: string; - forceNewTab?: boolean; - }, - ) { - if (!restore && !options?.forceNewTab) { - const dataKey = JSON.stringify(options?.data ?? null); - const existing = tabsRef.current.find( - (t) => - t.type === type && - t.host?.id === host.id && - JSON.stringify(t.data ?? null) === dataKey, - ); - if (existing) { - placeOpenedTabRef.current(existing.id); - return existing.id; - } - } - const instanceId = restore?.instanceId ?? createId(); - // Unique per open; a timestamp collides when a workspace opens several - // tabs in the same millisecond. - const tabId = `${type}-${createId()}`; - const openedAt = Date.now(); - const ref = isSessionTabType(type) ? createRef() : undefined; - if (ref) terminalRefs.current.set(tabId, ref); - - let finalLabel = host.name; - const savedLabel = restore?.savedLabel; - // A saved label that doesn't match the bare host name or the auto-numbered pattern is a custom label - const isCustomLabel = - savedLabel != null && - savedLabel !== host.name && - !/^.+ \(\d+\)$/.test(savedLabel); - - setTabs((prev) => { - if (isCustomLabel && savedLabel) { - finalLabel = savedLabel; - return [ - ...prev, - { - id: tabId, - instanceId, - type, - label: finalLabel, - customLabel: finalLabel, - host, - openedAt, - terminalRef: ref, - restoredSessionId: restore?.restoredSessionId ?? null, - data: options?.data, - }, - ]; - } - - const same = prev.filter( - (t) => - t.type === type && t.label.replace(/ \(\d+\)$/, "") === host.name, - ); - finalLabel = options?.label - ? options.label - : same.length === 0 - ? host.name - : `${host.name} (${same.length + 1})`; - - // Retrofit the first duplicate's label to "(1)" if needed - const next = - same.length === 1 && !/\(\d+\)$/.test(same[0].label) - ? prev.map((t) => - t.id === same[0].id ? { ...t, label: `${host.name} (1)` } : t, - ) - : prev; - - return [ - ...next, - { - id: tabId, - instanceId, - type, - label: finalLabel, - host, - openedAt, - terminalRef: ref, - restoredSessionId: restore?.restoredSessionId ?? null, - data: options?.data, - }, - ]; - }); - placeOpenedTabRef.current(tabId); - - if (isPersistentTabType(type)) { - addOpenTab({ - id: instanceId, - tabType: type, - hostId: host ? parseInt(host.id) : null, - label: finalLabel, - tabOrder: 0, - }).catch(() => {}); - } - - return tabId; - }, []); - - function connectHost( - host: Host, - preferredType?: TabType, - options?: { - data?: Record; - label?: string; - forceNewTab?: boolean; - }, - ) { - const type = resolveHostTabType(host, preferredType); - if (!type) return; - openTab(host, type, undefined, options); - } - - const saveQuickConnectHost = useCallback( - async (tab: Tab, host: Host) => { - try { - const savedHost = await createSSHHost(quickConnectHostToPayload(host)); - await patchOpenTab(tab.instanceId, { hostId: savedHost.id }); - await loadHosts(); - toast.success(t("hosts.hostCreated")); - } catch (error) { - toast.error(t("hosts.failedToSave")); - throw error; - } - }, - [loadHosts, t], - ); - - /** A tab type that opens a fresh tab every time (a local shell). */ - function openMultiInstanceTab( - type: TabType, - options?: { host?: Host; data?: Record; label?: string }, - ): string { - const instanceId = createId(); - const id = `${type}-${instanceId}`; - const titleKey = getTabType(type)?.titleKey; - const title = options?.label ?? (titleKey ? t(titleKey) : type); - setTabs((current) => { - const count = current.filter((tab) => tab.type === type).length; - return [ - ...current, - { - id, - instanceId, - type, - label: - count === 0 || options?.label ? title : `${title} (${count + 1})`, - openedAt: Date.now(), - ...(options?.host ? { host: options.host } : {}), - ...(options?.data !== undefined ? { data: options.data } : {}), - }, - ]; - }); - placeOpenedTabRef.current(id); - return id; - } - - const openSingletonTab = useCallback( - // `host` optionally preselects a host for a singleton plugin tab. - function openSingletonTab( - type: TabType, - pendingEvent?: string, - host?: Host, - data?: Record, - tabLabel?: string, - ): string | undefined { - // A local shell is never a singleton: each open is its own session. - if (getTabType(type)?.multiInstance) { - return openMultiInstanceTab(type, { host, data, label: tabLabel }); - } - if (type === "host-manager" && pendingEvent) { - const request = requestFromLegacyEvent(pendingEvent); - if (request) requestManage(request); - } - // Profile and admin are pages of the one Settings tab now. - if (type === "user-profile" || type === "admin-settings") { - return openSingletonTab( - "settings", - undefined, - undefined, - { - section: - typeof data?.section === "string" - ? data.section - : type === "admin-settings" - ? "admin-general" - : "account", - }, - tabLabel, - ); - } - // Asking for a page again still goes there after the user moved away - if (type === "settings" && data?.section && data.revealAt === undefined) { - data = { ...data, revealAt: Date.now() }; - } - const id = type; - const singletonLabels: Partial> = { - "host-manager": t("nav.manage"), - settings: t("nav.settings"), - plugins: t("nav.plugins"), - }; - // A plugin tab names itself; promoted rail panels reuse the rail's own - // label so the two stay in sync. - const titleKey = getTabType(type)?.titleKey; - const label = - singletonLabels[type] ?? - (titleKey ? t(titleKey) : railItemLabel(type, t)); - setTabs((prev) => { - const existing = prev.find((t) => t.id === id); - if (existing) { - // Refocusing a singleton with a host or data passes it on. - if (!host && data === undefined) return prev; - return prev.map((t) => - t.id === id - ? { - ...t, - ...(host ? { host } : {}), - ...(data !== undefined ? { data } : {}), - } - : t, - ); - } - return [ - ...prev, - { - id, - instanceId: id, - type, - label, - openedAt: Date.now(), - ...(host ? { host } : {}), - ...(data !== undefined ? { data } : {}), - }, - ]; - }); - placeOpenedTabRef.current(id); - if (isPersistentTabType(type)) { - addOpenTab({ - id, - tabType: type, - hostId: null, - label, - tabOrder: 0, - }).catch(() => {}); - } - return id; - }, - [t], - ); - - const openSingletonTabRef = useRef(openSingletonTab); - openSingletonTabRef.current = openSingletonTab; - - useEffect(() => { - const handler = () => openSingletonTabRef.current("plugins"); - window.addEventListener(OPEN_PLUGINS_EVENT, handler); - return () => window.removeEventListener(OPEN_PLUGINS_EVENT, handler); - }, []); - - useEffect(() => { - const handler = (event: Event) => { - const pluginId = (event as CustomEvent).detail; - if (typeof pluginId !== "string") return; - openSingletonTabRef.current("settings", undefined, undefined, { - section: `plugin:${pluginId}`, - revealAt: Date.now(), - }); - }; - window.addEventListener(OPEN_PLUGIN_SETTINGS_EVENT, handler); - return () => - window.removeEventListener(OPEN_PLUGIN_SETTINGS_EVENT, handler); - }, []); - - const getTabCloseLabel = useCallback((tab: Tab) => { - return tab.customLabel || tab.label || tab.host?.name || String(tab.id); - }, []); - - const isActiveConnectionTab = useCallback((tab: Tab) => { - if (!isSessionTabType(tab.type)) return false; - return tab.terminalRef?.current?.isConnected?.() === true; - }, []); - - const hasActiveConnection = useCallback(() => { - return tabsRef.current.some(isActiveConnectionTab); - }, [isActiveConnectionTab]); - - useEffect(() => { - const handleBeforeUnload = (event: BeforeUnloadEvent) => { - if (!hasActiveConnection()) return; - - event.preventDefault(); - event.returnValue = ""; - return ""; - }; - - window.addEventListener("beforeunload", handleBeforeUnload); - return () => { - window.removeEventListener("beforeunload", handleBeforeUnload); - }; - }, [hasActiveConnection]); - - const doCloseTabRef = useRef<(id: string) => void>(() => {}); - doCloseTabRef.current = doCloseTab; - function doCloseTab(id: string) { - const tabToClose = tabs.find((t) => t.id === id); - if (tabToClose?.terminalRef?.current?.disconnect) { - tabToClose.terminalRef.current.disconnect(); - } - if (tabToClose?.instanceId && isPersistentTabType(tabToClose.type)) { - deleteOpenTab(tabToClose.instanceId).catch(() => {}); - } - - terminalRefs.current.delete(id); - if (isSplitTab(tabToClose)) { - unsplit(id); - return; - } - if (id === activeTabId) { - const remaining = tabs.filter( - (tab) => tab.id !== id && !tab.parentSplitTabId, - ); - setActiveTabId( - remaining.length > 0 ? remaining[remaining.length - 1].id : "dashboard", - ); - } - setTabs((prev) => { - // A pane that held the tab stays, empty and focused, ready for the next one. - const next = removeTab(prev, id); - if (next.length === 0) - return [ - { - id: "dashboard", - instanceId: "dashboard", - type: "dashboard", - label: t("nav.dashboard"), - openedAt: Date.now(), - }, - ]; - return next; - }); - } - - const reconnectAllRef = useRef<() => void>(() => {}); - function reconnectAllDisconnected() { - const { reconnected, failed } = reconnectDisconnectedTabs(tabsRef.current); - if (reconnected || !failed) - toast( - t( - reconnected - ? "nav.reconnectingTerminals" - : "nav.noDisconnectedTerminals", - { count: reconnected }, - ), - ); - if (failed) - toast.error(t("nav.reconnectTerminalsFailed", { count: failed })); - } - - reconnectAllRef.current = reconnectAllDisconnected; - - function refreshTab(id: string) { - const tab = tabs.find((t) => t.id === id); - const handle = tab?.terminalRef?.current; - if (!handle) return; - // Session tabs expose one or the other on their handle. - if (handle.reconnect) handle.reconnect(); - else handle.refresh?.(); - } - - async function closeTab(id: string) { - const tab = tabsRef.current.find((t) => t.id === id); - const guard = tab?.terminalRef?.current?.confirmClose; - if (guard && !(await guard())) return; - if (!(await canCloseTab(id))) return; - const confirmEnabled = localStorage.getItem("confirmTabClose") === "true"; - if (tab && confirmEnabled && isActiveConnectionTab(tab)) { - const ask = shellConfirmRef.current; - const ok = ask - ? await ask({ - title: t("nav.confirmCloseHost", { host: getTabCloseLabel(tab) }), - confirmLabel: t("nav.close"), - }) - : true; - if (!ok) return; - } - doCloseTabRef.current(id); - } - - // In a split, closing acts on the focused pane: its session, or the pane - // itself once it is empty. - closeActiveTabRef.current = () => { - const id = activeTabIdRef.current; - const active = tabsRef.current.find((tab) => tab.id === id); - if (isSplitTab(active)) { - const tabId = focusedSessionTabId(); - if (tabId) closeTab(tabId); - else closePaneOf(active.id, active.split.focusedPaneId); - return; - } - if (id !== "dashboard") closeTab(id); - }; - - function renameTab(tabId: string, newLabel: string) { - setTabs((prev) => - prev.map((t) => - t.id === tabId ? { ...t, customLabel: newLabel, label: newLabel } : t, - ), - ); - const tab = tabs.find((t) => t.id === tabId); - if (tab?.instanceId && tab.type !== "split-screen") { - patchOpenTab(tab.instanceId, { label: newLabel }).catch(() => {}); - } - } - - // ─── Split screen ──────────────────────────────────────────────────────── - - const splitLabel = (number: number) => - t("splitScreen.defaultLabel", { number }); - - /** - * Edits a split with a change computed from the last committed state, for - * edits that create panes and so have to know the new pane's id. - */ - function commitSplit( - splitTabId: string, - fn: (state: SplitState) => SplitState | null, - ): SplitState | null { - const current = tabsRef.current.find((tab) => tab.id === splitTabId); - if (!isSplitTab(current)) return null; - const next = fn(current.split); - setTabs((prev) => - updateSplit(prev, splitTabId, (state) => - state === current.split ? next : fn(state), - ), - ); - return next; - } - - function editSplit( - splitTabId: string, - fn: (state: SplitState) => SplitState | null, - ) { - setTabs((prev) => updateSplit(prev, splitTabId, fn)); - } - - function warnSplitFull() { - toast.error(t("splitScreen.maxPanes", { count: MAX_PANES })); - } - - function openSplitTab(state: SplitState): SplitTab { - const number = nextSplitNumber(tabsRef.current, splitLabel); - const splitTab = makeSplitTab(createId(), splitLabel(number), state); - setTabs((prev) => addSplitTab(prev, splitTab)); - setActiveTabId(splitTab.id); - return splitTab; - } - - /** A new split of `tabId` (or of an empty pane) with a fresh pane at `edge`. */ - function startSplit(tabId: string | null, edge: PaneEdge): SplitTab { - const own = createPane(tabId); - const fresh = createPane(); - const leading = edge === "left" || edge === "top"; - const root = createSplitNode( - edge === "left" || edge === "right" ? "row" : "column", - leading ? [fresh, own] : [own, fresh], - ); - return openSplitTab(createSplitState(root, fresh.id)); - } - - function splitPaneOf(splitTabId: string, paneId: string, edge: PaneEdge) { - commitSplit(splitTabId, (state) => { - const result = splitPane(state, paneId, edge); - if (!result) warnSplitFull(); - return result ? result.state : state; - }); - setActiveTabId(splitTabId); - } - - /** - * Splits a tab: a split grows at its focused pane, a tab inside a split - * grows at its own pane, anything else becomes a new split. - */ - function splitTabAt(tabId: string, edge: PaneEdge) { - const current = tabsRef.current; - const tab = current.find((t) => t.id === tabId); - if (!tab) return; - if (isSplitTab(tab)) { - splitPaneOf(tab.id, tab.split.focusedPaneId, edge); - return; - } - const parent = splitTabOf(current, tabId); - const pane = parent ? findPaneByTab(parent.split, tabId) : undefined; - if (parent && pane) { - splitPaneOf(parent.id, pane.id, edge); - return; - } - startSplit(canJoinSplit(tab) ? tab.id : null, edge); - } - - function applyLayoutPreset(presetId: SplitPresetId) { - const active = tabsRef.current.find( - (tab) => tab.id === activeTabIdRef.current, - ); - if (isSplitTab(active)) { - commitSplit(active.id, (state) => applyPreset(state, presetId)); - return; - } - const root = buildPreset( - presetId, - active && canJoinSplit(active) ? [active.id] : [], - ); - openSplitTab(createSplitState(root)); - } - - /** Closes a split tab, sending its tabs back to the tab bar. */ - function unsplit(splitTabId: string) { - const split = tabsRef.current.find((tab) => tab.id === splitTabId); - if (!isSplitTab(split)) return; - const focusedTabId = - findPane(split.split, split.split.focusedPaneId)?.tabId ?? - listPanes(split.split.root).find((pane) => pane.tabId)?.tabId ?? - null; - setTabs((prev) => closeSplitTab(prev, splitTabId)); - if (activeTabIdRef.current === splitTabId) { - const fallback = tabsRef.current.filter( - (tab) => tab.id !== splitTabId && !tab.parentSplitTabId, - ); - setActiveTabId( - focusedTabId ?? fallback[fallback.length - 1]?.id ?? "dashboard", - ); - } - } - - /** Removes a pane; its tab, if any, goes back to the tab bar. */ - function closePaneOf(splitTabId: string, paneId: string) { - const split = tabsRef.current.find((tab) => tab.id === splitTabId); - if (!isSplitTab(split)) return; - if (listPanes(split.split.root).length <= 1) { - unsplit(splitTabId); - return; - } - editSplit(splitTabId, (state) => removePane(state, paneId)); - } - - function handleTabSplitAction(action: TabSplitAction) { - switch (action.kind) { - case "split": - splitTabAt(action.tabId, action.edge); - break; - case "splitActive": - splitTabAt(activeTabIdRef.current, action.edge); - break; - case "preset": - applyLayoutPreset(action.presetId); - break; - case "unsplit": - unsplit(action.splitTabId); - break; - case "addToPane": - setTabs((prev) => - placeTabInPane(prev, action.splitTabId, action.paneId, action.tabId), - ); - setActiveTabId(action.splitTabId); - break; - case "addPane": - commitSplit(action.splitTabId, (state) => { - const result = addPaneAtEdge(state, action.edge, action.tabId); - if (!result) warnSplitFull(); - return result ? result.state : state; - }); - setActiveTabId(action.splitTabId); - break; - } - } - - /** "Open in split" from the host list: aim the next tab, then open it. */ - const splitOpenerRef = useRef< - (target: SplitOpenTarget, open: () => void) => void - >(() => {}); - splitOpenerRef.current = (target, open) => { - if (target.kind === "pane") { - targetPane(target.splitTabId, target.paneId); - } else if (target.kind === "newPane") { - const split = tabsRef.current.find((tab) => tab.id === target.splitTabId); - if (!isSplitTab(split)) return; - const result = addPaneAtEdge(split.split, "right"); - if (!result) { - warnSplitFull(); - return; - } - editSplit(split.id, (state) => - state === split.split ? result.state : state, - ); - setActiveTabId(split.id); - targetPane(split.id, result.paneId, true); - } else { - const active = tabsRef.current.find( - (tab) => tab.id === activeTabIdRef.current, - ); - if (!active || !canJoinSplit(active)) return; - const splitTab = startSplit(active.id, "right"); - targetPane(splitTab.id, splitTab.split.focusedPaneId, true); - } - open(); - }; - - /** A tab or pane dropped on the main area. */ - const splitDropRef = useRef< - (source: SplitDragSource, hover: SplitDropHover) => void - >(() => {}); - splitDropRef.current = (source, hover) => { - if (source.kind === "pane") { - const toPaneId = hover.paneId; - if (!toPaneId) return; - editSplit(source.splitTabId, (state) => - movePane(state, source.paneId, toPaneId, hover.target), - ); - return; - } - const active = tabsRef.current.find( - (tab) => tab.id === activeTabIdRef.current, - ); - if (hover.paneId === null) { - if (hover.target === "center") return; - const other = - active && canJoinSplit(active) && active.id !== source.tabId - ? active.id - : null; - const dragged = createPane(source.tabId); - const rest = createPane(other); - const leading = hover.target === "left" || hover.target === "top"; - const root = createSplitNode( - hover.target === "left" || hover.target === "right" ? "row" : "column", - leading ? [dragged, rest] : [rest, dragged], - ); - openSplitTab(createSplitState(root, dragged.id)); - return; - } - if (!isSplitTab(active)) return; - const paneId = hover.paneId; - if (hover.target === "center") { - setTabs((prev) => placeTabInPane(prev, active.id, paneId, source.tabId)); - return; - } - const edge = hover.target; - commitSplit(active.id, (state) => { - const result = splitPane(state, paneId, edge, source.tabId); - if (!result) warnSplitFull(); - return result ? result.state : state; - }); - }; - - splitActionsRef.current = { - splitActive: (edge) => splitTabAt(activeTabIdRef.current, edge), - navigate: (direction) => { - const active = tabsRef.current.find( - (tab) => tab.id === activeTabIdRef.current, - ); - if (!isSplitTab(active)) return false; - const view = document.querySelector(`[data-split-view="${active.id}"]`); - if (!view) return false; - const rects: Record = {}; - view - .querySelectorAll("[data-split-pane-id]") - .forEach((el) => { - const id = el.dataset.splitPaneId; - if (id) rects[id] = el.getBoundingClientRect(); - }); - const next = neighborPane(active.split.focusedPaneId, direction, rects); - if (next) editSplit(active.id, (state) => focusPane(state, next)); - return true; - }, - zoomFocused: () => { - const active = tabsRef.current.find( - (tab) => tab.id === activeTabIdRef.current, - ); - if (!isSplitTab(active) || listPanes(active.split.root).length < 2) { - return false; - } - editSplit(active.id, (state) => toggleZoom(state, state.focusedPaneId)); - return true; - }, - closeFocusedPane: () => { - const active = tabsRef.current.find( - (tab) => tab.id === activeTabIdRef.current, - ); - if (isSplitTab(active)) { - closePaneOf(active.id, active.split.focusedPaneId); - } - }, - }; - - useEffect(() => { - const disposeOpener = setSplitOpener((target, open) => - splitOpenerRef.current(target, open), - ); - const disposeDrop = setSplitDropHandler((source, hover) => - splitDropRef.current(source, hover), - ); - const activeIsSplit = () => - isSplitTab( - tabsRef.current.find((tab) => tab.id === activeTabIdRef.current), - ); - const entries = [ - registerPaletteEntry({ - id: "core.reconnectDisconnected", - titleKey: "nav.reconnectDisconnectedTerminals", - icon: RotateCcw, - keywords: ["reconnect", "disconnected", "ssh", "all", "network"], - scope: "global", - run: () => reconnectAllRef.current(), - }), - registerPaletteEntry({ - id: "core.split.right", - titleKey: "splitScreen.splitRight", - icon: Columns2, - keywords: ["split", "pane"], - scope: "global", - run: () => splitActionsRef.current?.splitActive("right"), - }), - registerPaletteEntry({ - id: "core.split.down", - titleKey: "splitScreen.splitDown", - icon: Rows2, - keywords: ["split", "pane"], - scope: "global", - run: () => splitActionsRef.current?.splitActive("bottom"), - }), - registerPaletteEntry({ - id: "core.split.zoom", - titleKey: "splitScreen.zoom", - icon: Maximize2, - keywords: ["split", "pane"], - scope: "global", - when: activeIsSplit, - run: () => void splitActionsRef.current?.zoomFocused(), - }), - registerPaletteEntry({ - id: "core.split.closePane", - titleKey: "splitScreen.closePane", - icon: X, - keywords: ["split", "pane"], - scope: "global", - when: activeIsSplit, - run: () => splitActionsRef.current?.closeFocusedPane(), - }), - ]; - return () => { - disposeOpener(); - disposeDrop(); - for (const dispose of entries) dispose(); - }; - }, []); - - // ─── Rail / sidebar ────────────────────────────────────────────────────── - - // Moving a panel to the right dock rather than copying it: two live copies of - // the same panel would fight over the shared editing state. - function openInRightDock(view: RailView) { - setRightRailView(view); - if (railView === view) setSidebarOpen(false); - } - - // Tab bar toggle: reopens whatever was last in the dock, so it behaves like a - // show/hide rather than losing the user's choice each time. - function toggleRightDock() { - if (rightRailView) { - lastRightRailViewRef.current = rightRailView; - setRightRailView(null); - return; - } - const fallback = lastRightRailViewRef.current ?? rightDockableIds()[0]; - if (fallback) setRightRailView(fallback as RailView); - } - - function handleRailClick(view: RailView) { - if (railView === view && sidebarOpen) { - setSidebarOpen(false); - } else { - // A panel lives in one dock at a time, so the left dock reclaims it. - if (rightRailView === view) setRightRailView(null); - setRailView(view); - setSidebarOpen(true); - } - } - - function editHostInManager(host: Host) { - requestManage({ kind: "host", hostId: host.id }); - if (isMobile) setSidebarOpen(false); - } - - const onSidebarMouseDown = useCallback( - (e: React.MouseEvent) => { - e.preventDefault(); - setSidebarDragging(true); - const startX = e.clientX; - const startW = sidebarWidth; - // Widths are kept in Normal-size pixels, so a drag is scaled back. - const scale = remScale(); - // One width update per frame, not per mousemove. - let frame = 0; - let clientX = startX; - function onMove(ev: MouseEvent) { - clientX = ev.clientX; - if (frame) return; - frame = requestAnimationFrame(() => { - frame = 0; - setSidebarWidth( - Math.max(160, Math.min(480, startW + (clientX - startX) / scale)), - ); - }); - } - function onUp() { - cancelAnimationFrame(frame); - setSidebarWidth( - Math.max(160, Math.min(480, startW + (clientX - startX) / scale)), - ); - setSidebarDragging(false); - window.removeEventListener("mousemove", onMove); - window.removeEventListener("mouseup", onUp); - } - window.addEventListener("mousemove", onMove); - window.addEventListener("mouseup", onUp); - }, - [sidebarWidth], - ); - - // Same drag, mirrored: the right dock grows as the pointer moves left. - const onRightSidebarMouseDown = useCallback( - (e: React.MouseEvent) => { - e.preventDefault(); - setRightSidebarDragging(true); - const startX = e.clientX; - const startW = rightSidebarWidth; - const scale = remScale(); - let frame = 0; - let clientX = startX; - function onMove(ev: MouseEvent) { - clientX = ev.clientX; - if (frame) return; - frame = requestAnimationFrame(() => { - frame = 0; - setRightSidebarWidth( - Math.max(160, Math.min(480, startW - (clientX - startX) / scale)), - ); - }); - } - function onUp() { - cancelAnimationFrame(frame); - setRightSidebarWidth( - Math.max(160, Math.min(480, startW - (clientX - startX) / scale)), - ); - setRightSidebarDragging(false); - window.removeEventListener("mousemove", onMove); - window.removeEventListener("mouseup", onUp); - } - window.addEventListener("mousemove", onMove); - window.addEventListener("mouseup", onUp); - }, - [rightSidebarWidth], - ); - - // Resize all terminals in panes + active terminal when split mode or sidebar changes - const resizeAllTerminals = useCallback(() => { - const id = requestAnimationFrame(() => { - tabs.forEach((tab) => { - if (!tab.terminalRef) return; - const ref = tab.terminalRef.current; - ref?.fit?.(); - ref?.notifyResize?.(); - }); - }); - return id; - }, [tabs]); - - const activeTab = tabs.find((tab) => tab.id === activeTabId); - const activeSplit = isSplitTab(activeTab) ? activeTab : null; - // The pane each on-screen tab sits in, for the active split only. - const paneIdByTabId = new Map(); - if (activeSplit) { - for (const pane of shownPanes(activeSplit.split, isMobile)) { - if (pane.tabId) paneIdByTabId.set(pane.tabId, pane.id); - } - } - // Changes when panes appear, move or change tab, but not on a divider drag, - // which fits the terminals itself once it ends. - const splitShapeKey = activeSplit - ? `${activeSplit.id}|${activeSplit.split.zoomedPaneId ?? ""}|${[ - ...paneIdByTabId.entries(), - ].join(",")}|${listPanes(activeSplit.split.root).length}` - : ""; - - useEffect(() => { - // Refitting every terminal on each drag frame is what made sidebar drags - // stutter; fit once when the drag ends instead. - if (sidebarDragging || rightSidebarDragging) return; - const id = resizeAllTerminals(); - return () => cancelAnimationFrame(id); - }, [ - splitShapeKey, - sidebarWidth, - sidebarOpen, - rightSidebarWidth, - rightRailView, - sidebarDragging, - rightSidebarDragging, - ]); - - // A tab inside a split is never shown on its own: activating one (tab jump, - // the connections panel, an existing session) shows its split instead, - // focused on its pane. Also falls back when the active tab is gone. - useLayoutEffect(() => { - if (!activeTab) { - if (!tabsReady) return; - const topLevel = tabs.filter((tab) => !tab.parentSplitTabId); - setActiveTabId(topLevel[topLevel.length - 1]?.id ?? "dashboard"); - return; - } - if (!activeTab.parentSplitTabId) return; - const parent = splitTabOf(tabs, activeTab.id); - if (!parent) return; - const childId = activeTab.id; - setTabs((prev) => - updateSplit(prev, parent.id, (state) => { - const pane = findPaneByTab(state, childId); - return pane ? focusPane(state, pane.id) : state; - }), - ); - setActiveTabId(parent.id); - }, [activeTab, tabs, tabsReady]); - - // Moving focus to a pane moves keyboard focus into its session, unless the - // user is in a menu or already typing in that pane. - const focusedPaneId = activeSplit?.split.focusedPaneId ?? null; - const focusedPaneTabId = focusedPaneId - ? (findPane(activeSplit!.split, focusedPaneId)?.tabId ?? null) - : null; - useEffect(() => { - if (!focusedPaneId || !focusedPaneTabId) return; - let attempts = 0; - let timer: ReturnType | undefined; - const attempt = () => { - const focused = document.activeElement as HTMLElement | null; - const paneEl = paneElsRef.current.get(focusedPaneId); - if (focused && paneEl?.contains(focused)) return; - if ( - focused?.closest?.( - '[data-split-pane-header], [role="menu"], [role="dialog"]', - ) - ) { - return; - } - const tab = tabsRef.current.find((t) => t.id === focusedPaneTabId); - const handle = tab?.terminalRef?.current as TabHandle | null | undefined; - if (handle?.focus) { - handle.focus(); - return; - } - // A session opened into the pane a moment ago may not have mounted yet. - if (attempts++ < 10) timer = setTimeout(attempt, 100); - }; - const frame = requestAnimationFrame(attempt); - return () => { - cancelAnimationFrame(frame); - if (timer) clearTimeout(timer); - }; - }, [focusedPaneId, focusedPaneTabId]); - - useEffect(() => { - publishSplitTargets({ - splits: summarizeSplits(tabs, MAX_PANES), - canStartSplit: canJoinSplit(activeTab) && !activeTab?.parentSplitTabId, - }); - }, [tabs, activeTab]); - - // Move each tab's stable DOM node to the right container (pane or normal-view). - // This is vanilla DOM so React's portal target never changes. Changing the portal - // target causes a remount which is exactly what we're trying to avoid. - // useLayoutEffect (not useEffect) so visibility/display are corrected before - // the browser paints, otherwise the previous tab's node can flash on screen - // for a frame while still visible. - useLayoutEffect(() => { - const normalView = normalViewRef.current; - if (!normalView) return; - - const tabIds = new Set(tabs.map((t) => t.id)); - - // Remove nodes for closed tabs - for (const [id, node] of tabNodesRef.current) { - if (!tabIds.has(id)) { - node.remove(); - tabNodesRef.current.delete(id); - } - } - - for (const tab of tabs) { - const isTerminal = !!getTabType(tab.type)?.ownBackground; - const node = getTabNode(tab.id, isTerminal); - const paneId = paneIdByTabId.get(tab.id); - const paneEl = paneId ? paneElsRef.current.get(paneId) : undefined; - const inPane = !!paneId; - const activeInline = !inPane && tab.id === activeTabId; - - if (inPane && paneEl) { - if (node.parentElement !== paneEl) paneEl.appendChild(node); - node.style.visibility = "visible"; - node.style.pointerEvents = "auto"; - node.style.display = ""; - node.style.zIndex = ""; - node.style.contentVisibility = ""; - } else { - if (node.parentElement !== normalView) normalView.appendChild(node); - if (isTerminal) { - node.style.display = ""; - node.style.visibility = activeInline ? "visible" : "hidden"; - node.style.pointerEvents = activeInline ? "auto" : "none"; - node.style.zIndex = activeInline ? "1" : "0"; - // xterm renders to a ; visibility:hidden alone can still let - // a stale composited frame flash through for a tick when switching - // to/from a non-terminal tab. content-visibility:hidden fully skips - // painting the subtree while keeping its layout box intact, so - // fitAddon.fit() still sees correct dimensions once it's shown again. - node.style.contentVisibility = activeInline ? "" : "hidden"; - } else { - // Plays a quick opacity fade-in on the tab that just became active. - // Only play it on an actual switch into this tab -- gating on the - // class alone replayed the animation on every unrelated re-render - // that happened while the tab was still active (any render after - // animationend had stripped the class), which looked like the - // panel kept growing for up to a second after switching. - if (activeInline && lastAnimatedTabIdRef.current !== tab.id) { - lastAnimatedTabIdRef.current = tab.id; - node.classList.remove("motion-workspace-enter"); - // Force a reflow so re-adding the class restarts the animation - // instead of no-oping because it was already removed this tick. - void node.offsetWidth; - node.classList.add("motion-workspace-enter"); - node.addEventListener( - "animationend", - () => node.classList.remove("motion-workspace-enter"), - { once: true }, - ); - } else if (!activeInline) { - node.classList.remove("motion-workspace-enter"); - if (lastAnimatedTabIdRef.current === tab.id) { - lastAnimatedTabIdRef.current = null; - } - } - node.style.visibility = ""; - node.style.pointerEvents = ""; - node.style.zIndex = activeInline ? "2" : ""; - node.style.display = activeInline ? "" : "none"; - } - } - } - }); - - const terminalTabs = tabs.filter((t) => getTabType(t.type)?.commandTarget); - const topLevelTabs = tabs.filter((tab) => !tab.parentSplitTabId); - - function reorderTopLevelTabs(reordered: Tab[]) { - setTabs((prev) => [ - ...reordered, - ...prev.filter((tab) => tab.parentSplitTabId), - ]); - } - - // What command-target panels act on. Falls back to the remembered - // terminal when the active tab isn't one, and drops it once it's closed. - const targetTerminalTabId = terminalTabs.some((t) => t.id === workingTabId) - ? workingTabId - : terminalTabs.some((t) => t.id === lastTerminalTabId) - ? lastTerminalTabId - : ""; - - /** - * Sidebar panel content, shared between the desktop sidebar, the mobile - * sheet and the right dock. Takes the view rather than reading railView so - * both docks can render from the same code. - * - * `owned` marks the dock responsible for the panels that stay mounted while - * hidden (hosts, credentials, fleets). Only one dock may own them, otherwise - * two live instances fight over the shared editing state. - */ - // The param deliberately shadows the outer railView so the body reads the - // same whichever dock is rendering. - - /** - * What plugins may ask of the shell. Rebuilt every render so it always - * closes over current state, and published to the plugin runtime. - */ - const shellCallbacksImpl: TabShellCallbacks = { - openTab: (host, type, options) => { - if (!host || getTabType(type)?.singleton) { - openSingletonTab( - type, - undefined, - host ?? undefined, - options?.data, - options?.label, - ); - return; - } - openTab(host, type, undefined, options); - }, - openSingletonTab: (type, options) => - openSingletonTab(type, undefined, undefined, options?.data), - connectHost: (host, type) => connectHost(host, type as TabType), - closeTab: (tabId) => closeTab(tabId), - renameTab: (tabId, label) => renameTab(tabId, label), - - openRailView: (id) => { - setRailView(id as RailView); - setSidebarOpen(true); - }, - closeRailView: (id) => { - setRailView((prev) => (prev === id ? "hosts" : prev)); - setRightRailView((prev) => (prev === id ? null : prev)); - }, - openHostEditor: (draft) => - requestManage({ kind: "host", hostId: null, draft }), - saveQuickConnect: saveQuickConnectHost, - confirm: (options) => - shellConfirmRef.current - ? shellConfirmRef.current(options) - : Promise.resolve(false), - }; - - // Tabs get one stable bag that forwards to the latest callbacks, so a shell - // render does not re-render every open tab. - const shellImplRef = useRef(shellCallbacksImpl); - useLayoutEffect(() => { - shellImplRef.current = shellCallbacksImpl; - }); - const shellCallbacks = useMemo( - () => ({ - openTab: (...args) => shellImplRef.current.openTab(...args), - openSingletonTab: (...args) => - shellImplRef.current.openSingletonTab(...args), - connectHost: (...args) => shellImplRef.current.connectHost(...args), - closeTab: (...args) => shellImplRef.current.closeTab(...args), - renameTab: (...args) => shellImplRef.current.renameTab(...args), - openRailView: (...args) => shellImplRef.current.openRailView(...args), - closeRailView: (...args) => shellImplRef.current.closeRailView(...args), - openHostEditor: (...args) => - shellImplRef.current.openHostEditor?.(...args), - saveQuickConnect: (...args) => - shellImplRef.current.saveQuickConnect!(...args), - confirm: (...args) => shellImplRef.current.confirm!(...args), - }), - [], - ); - - // Panels close the sidebar on mobile after opening something, the same as - // the hosts panel does. - const panelShell = useMemo( - () => ({ - ...shellCallbacks, - openTab: (...args) => { - shellCallbacks.openTab(...args); - if (isMobile) setSidebarOpen(false); - }, - }), - [shellCallbacks, isMobile], - ); - - useEffect(() => { - setShellCallbacks(shellCallbacks); - setShellLayoutProvider({ - getLayout: () => (tabsReady ? buildWorkspacePayload() : null), - applyLayout: (layout, options) => - applyLayout(layout as WorkspacePayload, options?.name ?? ""), - }); - }); - - useEffect(() => { - setShellHosts(allHosts, hostsLoaded); - }, [allHosts, hostsLoaded]); - - // Every panel gets its own surface, so its inline views and confirms stay - // inside it. Only the left dock widens for them. - const panelScope = ( - view: string, - owned: boolean, - shown: boolean, - children: React.ReactNode, - className = "", - ) => ( - - {children} - - ); - - const renderSidebarPanels = (railView: RailView, owned = true) => ( - }> -
- {owned && ( - <> - {panelScope( - "hosts", - owned, - railView === "hosts", - { - connectHost(host, type, options); - if (isMobile) setSidebarOpen(false); - }} - onEditHost={editHostInManager} - onCloseSidebar={() => setSidebarOpen(false)} - hostTree={realHostTree ?? undefined} - loading={hostsLoading} - onEditingChange={panelEditingSetter("hosts")} - active={railView === "hosts"} - />, - )} - {panelScope( - "credentials", - owned, - railView === "credentials", - , - )} - - )} - - {railView === "quick-connect" && - panelScope( - "quick-connect", - owned, - true, - { - openTab(host, type); - if (isMobile) setSidebarOpen(false); - }} - />, - )} - - {registeredPanels.map((panel) => { - const shown = railView === panel.id; - // A kept-mounted panel lives in the owning dock only, so two live - // copies never fight over the same state. - if (panel.keepMounted ? !owned : !shown) return null; - const Panel = panel.component; - return panelScope( - panel.id, - owned, - shown, -
- tab.id === targetTerminalTabId, - )} - active={shown} - shell={panelShell} - setEditing={ - owned ? panelEditingSetter(panel.id) : () => undefined - } - activeTabType={ - tabs.find((tab) => tab.id === activeTabId)?.type ?? undefined - } - placement={owned ? "left" : "right"} - /> -
, - ); - })} - - {!isCoreRailView(railView) && !getPanel(railView) && ( - - )} - - {railView === "connections" && - panelScope( - "connections", - owned, - true, -
- { - setActiveTabId(tabId); - if (isMobile) setSidebarOpen(false); - }} - onCloseTab={closeTab} - onReopenTab={(record, restoredSessionId) => { - const host = record.hostId - ? allHosts.find((h) => h.id === String(record.hostId)) - : undefined; - if (!host && !getTabType(record.tabType)?.hostless) return; - setBackgroundTabRecords((prev) => - prev.filter((r) => r.id !== record.id), - ); - if (host) { - const effectiveSessionId = - restoredSessionId ?? record.backendSessionId ?? null; - openTab(host, record.tabType as TabType, { - instanceId: record.id, - restoredSessionId: effectiveSessionId, - savedLabel: record.label, - }); - } else { - openSingletonTab(record.tabType as TabType); - } - if (isMobile) setSidebarOpen(false); - }} - onForgetBackground={(recordId) => { - setBackgroundTabRecords((prev) => - prev.filter((r) => r.id !== recordId), - ); - }} - onRenameTab={renameTab} - onReorderTabs={setTabs} - /> -
, - )} - - {railView === "sync" && - panelScope( - "sync", - owned, - true, -
- -
, - )} -
-
- ); - - function splitViewActions(splitTabId: string): SplitViewActions { - return { - focusPane: (paneId) => - editSplit(splitTabId, (state) => focusPane(state, paneId)), - resize: (nodeId, sizes) => - editSplit(splitTabId, (state) => resizeSplit(state, nodeId, sizes)), - equalize: (nodeId) => - editSplit(splitTabId, (state) => equalizeSplit(state, nodeId)), - splitPane: (paneId, edge) => splitPaneOf(splitTabId, paneId, edge), - closePane: (paneId) => closePaneOf(splitTabId, paneId), - closeSession: (tabId) => closeTab(tabId), - moveToTab: (paneId) => closePaneOf(splitTabId, paneId), - showInPane: (paneId, tabId) => { - if (tabId) { - setTabs((prev) => placeTabInPane(prev, splitTabId, paneId, tabId)); - } else { - editSplit(splitTabId, (state) => assignTab(state, paneId, null)); - } - }, - swapPanes: (firstId, secondId) => - editSplit(splitTabId, (state) => swapPanes(state, firstId, secondId)), - toggleZoom: (paneId) => - editSplit(splitTabId, (state) => toggleZoom(state, paneId)), - resizeEnd: () => resizeAllTerminals(), - }; - } - - // Picking a host for a pane always starts a new session, so it never pulls - // a session out of the tab bar or another split. - const pickerShell: TabShellCallbacks = { - ...shellCallbacks, - openTab: (host, type, options) => - shellCallbacks.openTab(host, type, { ...options, forceNewTab: true }), - }; - - function renderEmptyPane( - splitTabId: string, - paneId: string, - paneIndex: number, - ) { - return ( - !tab.parentSplitTabId && canJoinSplit(tab), - )} - hosts={allHosts} - newTabOptions={tabTypes - .filter((def) => def.multiInstance) - .map((def) => ({ - type: def.id, - label: def.titleKey ? t(def.titleKey) : def.id, - icon: def.icon, - }))} - onPickTab={(tabId) => - setTabs((prev) => placeTabInPane(prev, splitTabId, paneId, tabId)) - } - onPickHost={(host, pick: PickHostTarget) => { - targetPane(splitTabId, paneId); - if ("item" in pick) { - const item = pick.action - .items?.(host) - .find((candidate) => candidate.id === pick.item.id); - item?.run(host, pickerShell); - } else { - runHostAction(pick.action, host, pickerShell); - } - }} - onOpenType={(type) => { - targetPane(splitTabId, paneId); - openMultiInstanceTab(type); - }} - onClosePane={() => closePaneOf(splitTabId, paneId)} - /> - ); - } - - const sidebarPanelContent = renderSidebarPanels(railView); - - const settingsTabProps = { - username, - isAdmin: showAdminUI, - onLogout, - userPrefs, - onPrefsChange: (updates: Partial) => - setUserPrefs((current) => ({ ...current, ...updates })), - onOpenHostTab: (host: Host) => connectHost(host), - }; - - const leftEditing: EditingWidth = (() => { - const values = [panelEditing[railView], scopeEditing[railView]]; - if (values.includes("wide")) return "wide"; - return values.some(Boolean); - })(); - - const dockProps = (view: RailView) => ({ - title: sidebarTitle(view), - onOpenAsTab: isMobile - ? undefined - : view === "hosts" || view === "credentials" - ? () => requestManage({ kind: "browse", mode: view as ManageMode }) - : promotableIds().includes(view) - ? () => openSingletonTab(view as TabType) - : undefined, - }); - - return ( - -
-
- {/* Skinny icon rail, desktop only */} - openSingletonTab("settings")} - onOpenNavigationSettings={() => - openSingletonTab("settings", undefined, undefined, { - section: "appearance", - reveal: "settings-navigation", - revealAt: Date.now(), - }) - } - onOpenPalette={() => setCommandPaletteOpen(true)} - onLogout={onLogout} - /> - - {/* Desktop: inline resizable sidebar */} - {!isMobile && ( - setSidebarOpen(false)} - onMoveToRightDock={ - rightDockableIds().includes(railView) - ? () => openInRightDock(railView) - : undefined - } - onResetWidth={() => setSidebarWidth(DOCK_DEFAULT_WIDTH)} - > - {sidebarPanelContent} - - )} - - {/* Mobile: sidebar as overlay sheet */} - {isMobile && ( - - - setSidebarOpen(false)} - > - {sidebarPanelContent} - - - - )} - - {/* Main content area */} -
- {!isMobile && !sidebarOpen && ( - setSidebarOpen(true)} /> - )} - - -
- -
- {activeSplit && ( -
- - renderEmptyPane(activeSplit.id, paneId, paneIndex) - } - /> -
- )} - - - {/* Normal-view container. Tab nodes are appended here (or to pane elements) - by the DOM-placement effect above. React portals each tab's content - into its stable per-tab node so the component is never remounted. - Hidden while a split is active. */} -
- {tabsByPortalOrder.map((tab) => { - const tabNode = getTabNode( - tab.id, - !!getTabType(tab.type)?.ownBackground, - ); - const paneId = paneIdByTabId.get(tab.id); - const inPane = !!paneId; - const activeInline = !inPane && tab.id === activeTabId; - const isFocusedPane = inPane - ? paneId === focusedPaneId - : activeInline; - return createPortal( - closeTab(tab.id) - } - > - {renderTabContent(tab, { - shell: shellCallbacks, - settings: settingsTabProps, - panelTargetTab: terminalTabs.find( - (t) => t.id === targetTerminalTabId, - ), - isVisible: inPane || activeInline, - isFocusedPane, - inSplit: inPane, - })} - , - tabNode, - tab.id, - ); - })} -
-
-
-
- - {/* Bottom nav bar, mobile only. The desktop app keeps the rail. */} - {!isElectron() && ( - openSingletonTab(type)} - onOpenSettings={() => openSingletonTab("settings")} - onOpenPalette={() => setCommandPaletteOpen(true)} - onLogout={() => onLogout({ manual: true })} - /> - )} -
- - {/* Right dock, desktop only, holds a second reference panel */} - {!isMobile && rightRailView && ( - setRightRailView(null)} - onResetWidth={() => setRightSidebarWidth(DOCK_DEFAULT_WIDTH)} - > - {renderSidebarPanels(rightRailView, false)} - - )} - - {/* Plugin panels pinned to the right edge, sized by the plugin */} - -
-
- - - - {commandPaletteOpen && ( - - handleRailClick(view as RailView)} - onEditHost={editHostInManager} - onOpenSettings={(section) => - openSingletonTab( - "settings", - undefined, - undefined, - section ? { section } : undefined, - ) - } - onOpenTab={(type, label, pendingEvent) => { - if ( - [ - "dashboard", - "host-manager", - "user-profile", - "admin-settings", - "plugins", - ].includes(type) - ) { - openSingletonTab(type, pendingEvent); - } else if (getTabType(type)?.multiInstance) { - openMultiInstanceTab(type); - } else if (getTabType(type)?.singleton) { - // A singleton plugin tab, optionally preselecting a host. - openSingletonTab( - type, - undefined, - label ? allHosts.find((h) => h.name === label) : undefined, - ); - } else if (label) { - const host = allHosts.find((h) => h.name === label); - if (host) openTab(host, type); - } - }} - /> - - )} - - {isElectron() && } -
- ); + return ; } diff --git a/src/ui/api/audit-log-api.ts b/src/ui/api/audit-log-api.ts index 786ddb868..21fca344e 100644 --- a/src/ui/api/audit-log-api.ts +++ b/src/ui/api/audit-log-api.ts @@ -105,33 +105,3 @@ export async function getAuditLogActions(): Promise<{ actions: string[] }> { handleApiError(error, "fetch audit log actions"); } } - -export interface AuditForwardingSettings { - url: string; - hasToken: boolean; - envConfigured: boolean; -} - -export async function getAuditForwarding(): Promise { - try { - const response = await authApi.get("/users/audit-forwarding"); - return response.data; - } catch (error) { - throw handleApiError(error, "get audit forwarding settings"); - } -} - -export async function updateAuditForwarding( - url: string, - token?: string, -): Promise<{ url: string; hasToken: boolean }> { - try { - const response = await authApi.patch("/users/audit-forwarding", { - url, - token, - }); - return response.data; - } catch (error) { - throw handleApiError(error, "update audit forwarding settings"); - } -} diff --git a/src/ui/api/auth-methods-api.ts b/src/ui/api/auth-methods-api.ts index 227de3673..fa58be59b 100644 --- a/src/ui/api/auth-methods-api.ts +++ b/src/ui/api/auth-methods-api.ts @@ -46,6 +46,16 @@ export async function getLoginMethods(): Promise { } } +/** False when the login policy turned remember me off. Public. */ +export async function getRememberDeviceAllowed(): Promise { + try { + const response = await authApi.get("/users/auth/methods"); + return response.data?.rememberDevice !== false; + } catch { + return true; + } +} + export async function submitLoginMethod( methodId: string, body: Record, diff --git a/src/ui/api/credentials-api.ts b/src/ui/api/credentials-api.ts index 9f1d711d7..4942b74da 100644 --- a/src/ui/api/credentials-api.ts +++ b/src/ui/api/credentials-api.ts @@ -336,113 +336,3 @@ export async function reorderCredentials( throw error; } } - -export async function detectKeyType( - privateKey: string, - keyPassword?: string, -): Promise> { - try { - const response = await authApi.post("/credentials/detect-key-type", { - privateKey, - keyPassword, - }); - return response.data; - } catch (error) { - throw handleApiError(error, "detect key type"); - } -} - -export async function detectPublicKeyType( - publicKey: string, -): Promise> { - try { - const response = await authApi.post("/credentials/detect-public-key-type", { - publicKey, - }); - return response.data; - } catch (error) { - throw handleApiError(error, "detect public key type"); - } -} - -export async function validateKeyPair( - privateKey: string, - publicKey: string, - keyPassword?: string, -): Promise> { - try { - const response = await authApi.post("/credentials/validate-key-pair", { - privateKey, - publicKey, - keyPassword, - }); - return response.data; - } catch (error) { - throw handleApiError(error, "validate key pair"); - } -} - -export interface GeneratedPublicKey { - success?: boolean; - publicKey?: string; - error?: string; -} - -export async function generatePublicKeyFromPrivate( - privateKey: string, - keyPassword?: string, -): Promise { - try { - const response = await authApi.post("/credentials/generate-public-key", { - privateKey, - keyPassword, - }); - return response.data; - } catch (error) { - throw handleApiError(error, "generate public key from private key"); - } -} - -export interface GeneratedKeyPair { - success: boolean; - privateKey?: string; - publicKey?: string; - keyType?: string; - format?: string; - algorithm?: string; - keySize?: number; - curve?: string; - error?: string; -} - -export async function generateKeyPair( - keyType: "ssh-ed25519" | "ssh-rsa" | "ecdsa-sha2-nistp256", - keySize?: number, - passphrase?: string, -): Promise { - try { - const response = await authApi.post("/credentials/generate-key-pair", { - keyType, - keySize, - passphrase, - }); - return response.data; - } catch (error) { - throw handleApiError(error, "generate SSH key pair"); - } -} - -export async function deployCredentialToHost( - credentialId: number, - targetHostId: number, -): Promise> { - try { - const response = await authApi.post( - `/credentials/${credentialId}/deploy-to-host`, - { targetHostId }, - ); - return response.data; - } catch (error) { - throw handleApiError(error, "deploy credential to host"); - } -} diff --git a/src/ui/api/look-api.ts b/src/ui/api/look-api.ts new file mode 100644 index 000000000..a2ae0a202 --- /dev/null +++ b/src/ui/api/look-api.ts @@ -0,0 +1,48 @@ +import { authApi } from "@/main-axios"; + +/** A layout or theme plugin the server found but could not load. */ +export interface UnavailableLook { + id: string; + version: string | null; + layouts: string[]; + themes: string[]; + error: string; +} + +/** Everything the browser picks the layout and theme from. */ +export interface LookResponse { + layoutId: string | null; + themeId: string | null; + /** Admin defaults. */ + defaults: { layout: string | null; theme: string | null }; + /** The default-look plugin's layout and theme. */ + defaultLook: { layout: string | null; theme: string | null }; + /** TERMIX_SAFE_LAYOUT is set on the server. */ + safeLayout: boolean; + unavailable: UnavailableLook[]; +} + +export async function getLook(): Promise { + const response = await authApi.get("/user-preferences/look"); + return response.data; +} + +/** The user's own layout and theme; null follows the default. */ +export async function saveUserLook(look: { + layoutId?: string | null; + themeId?: string | null; +}): Promise { + await authApi.put("/user-preferences", look); +} + +/** Admin only. Null clears a default. */ +export async function saveDefaultLook(defaults: { + layout?: string | null; + theme?: string | null; +}): Promise<{ layout: string | null; theme: string | null }> { + const response = await authApi.put( + "/user-preferences/look/defaults", + defaults, + ); + return response.data; +} diff --git a/src/ui/api/open-tabs-api.ts b/src/ui/api/open-tabs-api.ts index e1757bd27..89efaa803 100644 --- a/src/ui/api/open-tabs-api.ts +++ b/src/ui/api/open-tabs-api.ts @@ -116,6 +116,10 @@ export interface UserPreferences { compactHostView?: boolean | null; statusColorScheme?: string | null; customKeybindings?: string | null; + /** Layout this user chose; null follows the admin default. */ + layoutId?: string | null; + /** Theme this user chose; null follows the admin default. */ + themeId?: string | null; } export function parseCustomKeybindings( diff --git a/src/ui/api/plugins-api.ts b/src/ui/api/plugins-api.ts index 4d46e3629..2a102ab07 100644 --- a/src/ui/api/plugins-api.ts +++ b/src/ui/api/plugins-api.ts @@ -78,6 +78,22 @@ export interface PluginViewContribution { icon?: string; } +export interface PluginLayoutContribution { + id: string; + label: string; + description?: string; + defaultTheme?: string; + default?: boolean; +} + +export interface PluginThemeContribution { + id: string; + label: string; + mode: "light" | "dark"; + css: string; + swatch?: { background: string; panel: string; text: string }; +} + export interface PluginContributions { tabs?: PluginTabContribution[]; panels?: PluginViewContribution[]; @@ -87,6 +103,8 @@ export interface PluginContributions { settings?: PluginSettingsContribution; permissions?: { name: string; titleKey: string; descriptionKey: string }[]; uiPresets?: PluginUiPresets; + layouts?: PluginLayoutContribution[]; + themes?: PluginThemeContribution[]; } export { @@ -236,6 +254,10 @@ export interface PluginSummary { assetVersion?: string | null; /** "en" plus the xx_YY names of shipped translations. */ locales?: string[]; + /** Cache key for the theme CSS files; null without themes. */ + themeVersion?: string | null; + /** The app is drawn with this plugin by default; it cannot be uninstalled. */ + defaultLook?: boolean; tier?: string; source?: string; @@ -286,6 +308,8 @@ export interface RegistryPluginVersion { /** A beta, offered only to plugins on the beta channel. */ prerelease?: boolean; compatible: boolean; + /** The Termix range this version needs, when this build is too old. */ + needsTermix?: string | null; capabilities: string[]; publishedAt?: string; releaseNotesUrl?: string; @@ -310,6 +334,8 @@ export interface RegistryPluginEntry { videoId?: string; features?: string[]; docs?: string; + /** The manifest's contributes keys, like layouts or themes. */ + contributes?: string[]; versions: RegistryPluginVersion[]; /** Newest release on the plugin's channel. */ latestVersion: string | null; @@ -497,6 +523,15 @@ export async function uninstallPlugin(pluginId: string): Promise { } } +/** Puts a bundled plugin back to the copy that shipped with this install. */ +export async function restoreBundledPlugin(pluginId: string): Promise { + try { + await rbacApi.post(`${pluginPath(pluginId)}/restore-bundled`); + } finally { + announcePluginsChanged(); + } +} + export async function previewPluginState( pluginId: string, enabled: boolean, diff --git a/src/ui/api/settings-api.ts b/src/ui/api/settings-api.ts index 414273af6..531b94fc8 100644 --- a/src/ui/api/settings-api.ts +++ b/src/ui/api/settings-api.ts @@ -43,6 +43,33 @@ export async function updateSessionTimeout( } } +export interface HttpsSettings { + forceHttps: boolean; + tlsEnabled: boolean; +} + +export async function getHttpsSettings(): Promise { + try { + const response = await authApi.get("/users/https-settings"); + return response.data; + } catch (error) { + handleApiError(error, "fetch HTTPS settings"); + } +} + +export async function updateHttpsSettings( + forceHttps: boolean, +): Promise { + try { + const response = await authApi.patch("/users/https-settings", { + forceHttps, + }); + return response.data; + } catch (error) { + handleApiError(error, "update HTTPS settings"); + } +} + // ============================================================================ // BRANDING SETTINGS // ============================================================================ @@ -54,12 +81,6 @@ export interface BrandingSettings { logo: string | null; } -export interface BrandingSettingsUpdate { - appName?: string; - tagline?: string; - logo?: string | null; -} - /** Public endpoint -- no auth required, so the login screen can read it. */ export async function getBranding(): Promise { try { @@ -69,14 +90,3 @@ export async function getBranding(): Promise { handleApiError(error, "fetch branding settings"); } } - -export async function updateBranding( - update: BrandingSettingsUpdate, -): Promise { - try { - const response = await authApi.patch("/users/branding", update); - return response.data; - } catch (error) { - handleApiError(error, "update branding settings"); - } -} diff --git a/src/ui/api/ssh-host-management-api.ts b/src/ui/api/ssh-host-management-api.ts index 0ebbfb4b3..2ea8fda4f 100644 --- a/src/ui/api/ssh-host-management-api.ts +++ b/src/ui/api/ssh-host-management-api.ts @@ -70,54 +70,6 @@ export async function updateSSHHost( } } -export async function bulkImportSSHHosts( - hosts: SSHHostData[], - overwrite = false, - credentials?: Record[], -): Promise<{ - message: string; - success: number; - updated: number; - skipped: number; - failed: number; - errors: string[]; -}> { - try { - const response = await sshHostApi.post("/bulk-import", { - hosts, - overwrite, - ...(credentials ? { credentials } : {}), - }); - invalidateHostsAndStatusCaches(); - return response.data; - } catch (error) { - handleApiError(error, "bulk import SSH hosts"); - } -} - -export async function importSSHConfigHosts( - content: string, - overwrite = false, -): Promise<{ - message: string; - success: number; - updated: number; - skipped: number; - failed: number; - errors: string[]; -}> { - try { - const response = await sshHostApi.post("/ssh-config-import", { - content, - overwrite, - }); - invalidateHostsAndStatusCaches(); - return response.data; - } catch (error) { - handleApiError(error, "import SSH config hosts"); - } -} - export async function bulkUpdateSSHHosts( hostIds: number[], updates: Record, @@ -134,6 +86,21 @@ export async function bulkUpdateSSHHosts( } } +export interface HostPolicy { + manualCreate: boolean; + /** The plugin that manages hosts, when hosts can't be added by hand. */ + blockedBy?: string; +} + +export async function getHostPolicy(): Promise { + try { + const response = await sshHostApi.get("/policy"); + return response.data; + } catch (error) { + handleApiError(error, "load host policy"); + } +} + export async function reorderSSHHosts( positions: { id: number; sortOrder: number }[], ): Promise<{ updated: number }> { @@ -167,44 +134,6 @@ export async function getSSHHostById(hostId: number): Promise { } } -export async function exportSSHHostWithCredentials( - hostId: number, -): Promise { - try { - const response = await sshHostApi.get(`/db/host/${hostId}/export`); - return response.data; - } catch (error) { - handleApiError(error, "export SSH host with credentials"); - } -} - -export function exportAllSSHHosts(): Promise<{ - hosts: SSHHost[]; -}>; -export function exportAllSSHHosts(options: { share: true }): Promise<{ - version: string; - exportedAt: string; - credentials: Record[]; - hosts: SSHHost[]; -}>; -export async function exportAllSSHHosts(options?: { - share?: boolean; -}): Promise<{ - version?: string; - exportedAt?: string; - credentials?: Record[]; - hosts: SSHHost[]; -}> { - try { - const response = await sshHostApi.get( - options?.share ? "/db/hosts/export?share=1" : "/db/hosts/export", - ); - return response.data; - } catch (error) { - handleApiError(error, "export all SSH hosts"); - } -} - // ============================================================================ // PROXY CONNECTIVITY TEST // ============================================================================ diff --git a/src/ui/api/sync-api.ts b/src/ui/api/sync-api.ts deleted file mode 100644 index 66f331433..000000000 --- a/src/ui/api/sync-api.ts +++ /dev/null @@ -1,155 +0,0 @@ -import { authApi } from "@/main-axios"; - -export type SyncState = "idle" | "syncing" | "offline" | "error" | "signed_out"; - -export interface SyncProxyHeader { - name: string; - value: string; - /** A saved header whose value is not sent back. */ - set?: boolean; -} - -export interface SyncBasicAuth { - username: string; - password: string; - set?: boolean; -} - -export interface SyncEntityInfo { - type: string; - owner: string; - ownerName: string | null; - readOnly: boolean; - onServer: boolean; - onDevice: boolean; - enabled: boolean; -} - -export interface SyncStatus { - linked: boolean; - serverUrl?: string; - serverName?: string | null; - serverVersion?: string | null; - account?: { - username?: string; - isAdmin?: boolean; - roles?: string[]; - permissions?: string[]; - }; - status?: SyncState; - lastError?: string | null; - linkedAt?: string; - lastSyncAt?: string | null; - pending?: number; - conflicts?: number; - errors?: number; - firstSyncDone?: boolean; - allowInvalidCertificate?: boolean; - customHeaders?: SyncProxyHeader[]; - basicAuth?: SyncBasicAuth | null; - entities: SyncEntityInfo[]; -} - -export type ProbeProblem = - | "invalid_url" - | "unreachable" - | "tls_untrusted" - | "basic_auth_required" - | "proxy_login" - | "not_termix" - | "version_mismatch" - | "different_account"; - -export interface ProbeResult { - ok: boolean; - serverUrl?: string; - name?: string; - version?: string | null; - problem?: ProbeProblem; - message?: string; -} - -export interface ServerTarget { - serverUrl: string; - customHeaders?: SyncProxyHeader[]; - basicAuth?: SyncBasicAuth | null; - allowInvalidCertificate?: boolean; -} - -export interface SyncConflictItem { - id: number; - entityType: string; - syncId: string; - name: string; - createdAt: string; -} - -export interface SyncErrorItem { - entityType: string; - syncId: string; - reason: string | null; -} - -export async function getSyncStatus(): Promise { - return (await authApi.get("/sync/link/status")).data; -} - -export async function probeServer(target: ServerTarget): Promise { - return (await authApi.post("/sync/link/probe", target)).data; -} - -export async function getLinkPreview(): Promise> { - return (await authApi.get("/sync/link/preview")).data?.counts ?? {}; -} - -export async function completeLink( - input: ServerTarget & { - token: string; - mode: "merge" | "replace"; - serverName?: string; - }, -): Promise { - return (await authApi.post("/sync/link/complete", input)).data; -} - -export async function reloginLink(token: string): Promise { - return (await authApi.post("/sync/link/relogin", { token })).data; -} - -export async function unlinkServer(keepData: boolean): Promise { - return (await authApi.post("/sync/link/unlink", { keepData })).data; -} - -export async function syncNow(): Promise { - return (await authApi.post("/sync/link/sync", {}, { timeout: 300_000 })).data; -} - -export async function updateSyncSettings(settings: { - disabledTypes?: string[]; - customHeaders?: SyncProxyHeader[]; - basicAuth?: SyncBasicAuth | null; - allowInvalidCertificate?: boolean; -}): Promise { - return (await authApi.put("/sync/link/settings", settings)).data; -} - -export async function getSyncConflicts(): Promise { - return (await authApi.get("/sync/link/conflicts")).data?.conflicts ?? []; -} - -export async function settleSyncConflict( - id: number, - keep: "mine" | "server", -): Promise { - await authApi.post(`/sync/link/conflicts/${id}`, { keep }); -} - -export async function getSyncErrors(): Promise { - return (await authApi.get("/sync/link/errors")).data?.errors ?? []; -} - -export async function retrySyncErrors(): Promise { - return ( - await authApi.post("/sync/link/errors/retry", {}, { timeout: 300_000 }) - ).data; -} diff --git a/src/ui/auth/Auth.tsx b/src/ui/auth/Auth.tsx index a6867a0ed..f5ef9a746 100644 --- a/src/ui/auth/Auth.tsx +++ b/src/ui/auth/Auth.tsx @@ -42,6 +42,7 @@ import { import { challengeSecondFactor, getLoginMethods, + getRememberDeviceAllowed, startLoginRedirect, submitLoginMethod, verifySecondFactor, @@ -137,7 +138,7 @@ function hasDesktopManualLogout() { return localStorage.getItem(DESKTOP_MANUAL_LOGOUT_KEY) === "true"; } -function storeAuth(username: string) { +export function storeAuth(username: string) { localStorage.setItem( STORAGE_KEY, JSON.stringify({ loggedIn: true, username }), @@ -232,6 +233,7 @@ export function Auth({ onLogin }: AuthProps) { const [username, setUsername] = useState(""); const [password, setPassword] = useState(""); const [confirmPassword, setConfirmPassword] = useState(""); + const [rememberAllowed, setRememberAllowed] = useState(true); const [rememberMe, setRememberMe] = useState(() => { try { return localStorage.getItem("rememberMe") === "true"; @@ -395,6 +397,12 @@ export function Auth({ onLogin }: AuthProps) { void startPreLoginPlugins() .catch(() => {}) .finally(() => setAuthPluginsLoading(false)); + getRememberDeviceAllowed() + .then((allowed) => { + setRememberAllowed(allowed); + if (!allowed) setRememberMe(false); + }) + .catch(() => {}); getLoginMethods() .then((methods) => setAuthMethods(methods)) .catch(() => setAuthMethods([])) @@ -1525,19 +1533,21 @@ export function Auth({ onLogin }: AuthProps) { {view === "external" && (
-
- setRememberMe(v === true)} - /> - -
+ {rememberAllowed && ( +
+ setRememberMe(v === true)} + /> + +
+ )}
{externalMethods.map((method) => @@ -1573,20 +1583,24 @@ export function Auth({ onLogin }: AuthProps) { />
-
- setRememberMe(v === true)} - disabled={loading} - /> - -
+ {rememberAllowed ? ( +
+ setRememberMe(v === true)} + disabled={loading} + /> + +
+ ) : ( + + )} {passwordResetAllowed && ( -
- - {displayUrl} - -
- -
- )} - - {error && !isAuthenticating && ( -
- - - {t("common.error")} - {error} - -
- )} - - {loading && !isAuthenticating && ( -
-
- - - {t("auth.loadingServer")} - -
-
- )} - -
-