From 4fb91850a0901fea3d2d7506a1f1514031e67f5f Mon Sep 17 00:00:00 2001 From: LukeGus Date: Mon, 2 Nov 2026 16:48:15 -0600 Subject: [PATCH 001/106] fix: keep the default look out of the first run plugin picker --- CHANGELOG.md | 6 +++++ src/backend/plugins/onboarding.ts | 15 +++++++++++-- src/backend/tests/plugins/onboarding.test.ts | 23 ++++++++++++++++++++ 3 files changed, 42 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 247d6a88ca..46a79c5b2e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## 26.11.1 + +### Fixed + +- The first run plugin picker no longer sets the default layout and themes to Remove. They always stay on + ## 26.11.0 ### Fixed diff --git a/src/backend/plugins/onboarding.ts b/src/backend/plugins/onboarding.ts index 5859d5b219..c277b895c6 100644 --- a/src/backend/plugins/onboarding.ts +++ b/src/backend/plugins/onboarding.ts @@ -5,7 +5,7 @@ import { pluginLogger } from "../utils/logger.js"; import { getPluginRuntime } from "./index.js"; -import { onboardingDefaults } from "./bundled-index.js"; +import { isDefaultLookPlugin, onboardingDefaults } from "./bundled-index.js"; import { isLinkedDesktop, serialized, @@ -99,6 +99,11 @@ export async function offerPluginSetup(fresh: boolean): Promise { await settings.set(PLUGIN_SETUP_OFFERED_KEY, "1"); } +// The shipped default look always stays on, so the picker never offers it. +function inPicker(plugin: { id: string; source: string }): boolean { + return !(plugin.source === "bundled" && isDefaultLookPlugin(plugin.id)); +} + function choicePlugins(): ChoicePlugin[] { return getPluginRuntime() .loader.list() @@ -118,6 +123,7 @@ export async function describeOnboardingPlugins(): Promise ); const plugins = getPluginRuntime() .loader.list() + .filter(inPicker) .map((plugin): OnboardingPluginInfo => { const defaults = plugin.source === "bundled" @@ -158,7 +164,12 @@ export function applyOnboardingChoices( ): Promise { return serialized(async () => { const plugins = choicePlugins(); - const installed = new Set(plugins.map((plugin) => plugin.id)); + const installed = new Set( + getPluginRuntime() + .loader.list() + .filter(inPicker) + .map((plugin) => plugin.id), + ); const wanted: Record = {}; for (const [id, choice] of Object.entries(choices)) { if (installed.has(id)) wanted[id] = choice; diff --git a/src/backend/tests/plugins/onboarding.test.ts b/src/backend/tests/plugins/onboarding.test.ts index ca8eea2aff..8f00b44380 100644 --- a/src/backend/tests/plugins/onboarding.test.ts +++ b/src/backend/tests/plugins/onboarding.test.ts @@ -197,6 +197,13 @@ describe("describeOnboardingPlugins", () => { }); expect(byId.extra).toMatchObject({ recommended: true, consent: true }); }); + + it("leaves out the default look", async () => { + writeIndex({ extra: { defaultLook: true } }); + await chain(); + const list = await onboarding.describeOnboardingPlugins(); + expect(list.plugins.map((p) => p.id).sort()).toEqual(["addon", "base"]); + }); }); describe("applyOnboardingChoices", () => { @@ -263,6 +270,22 @@ describe("applyOnboardingChoices", () => { expect(db.settings.get(onboarding.PLUGIN_SETUP_PENDING_KEY)).toBe("1"); }); + it("never turns off or removes the default look", async () => { + writeIndex({ extra: { defaultLook: true } }); + const loader = await chain(); + const result = await onboarding.applyOnboardingChoices({ + extra: "remove", + base: "remove", + addon: "remove", + }); + expect(result.failed).toEqual([]); + expect(result.removed).toEqual(["addon", "base"]); + expect(loader.get("extra")?.state).toBe("active"); + + await onboarding.applyOnboardingChoices({ extra: "disabled" }); + expect(loader.get("extra")?.state).toBe("active"); + }); + it("ignores ids that are not installed", async () => { await chain(); const result = await onboarding.applyOnboardingChoices({ From f5895f15f2077bf5517cefbaf5287bc43b44ed37 Mon Sep 17 00:00:00 2001 From: LukeGus Date: Tue, 3 Nov 2026 15:09:54 +0000 Subject: [PATCH 002/106] chore: update translations and plugin pins --- docker/bundled-plugins.json | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/docker/bundled-plugins.json b/docker/bundled-plugins.json index ff7336f429..e966cd5a1d 100644 --- a/docker/bundled-plugins.json +++ b/docker/bundled-plugins.json @@ -200,8 +200,8 @@ { "id": "reverse-proxy", "source": "tmxplug", - "url": "https://github.com/Termix-SSH/Plugin-Reverse-Proxy/releases/download/v1.1.0/reverse-proxy-1.1.0.tmxplug", - "sha256": "c8a9851da7ca6291c1f47cf6ab235da5057455a86b4ed0dc1c1a3026afb30aa7", + "url": "https://github.com/Termix-SSH/Plugin-Reverse-Proxy/releases/download/v1.1.1/reverse-proxy-1.1.1.tmxplug", + "sha256": "f5e0089ca5eae1254f21ce18b222e70eb2beeae3bd874c2abc4faa1a68d3e47a", "tier": "full" }, { @@ -288,7 +288,9 @@ "onboarding": { "recommended": false, "consent": false, - "enabledByEnv": ["OIDC_CLIENT_ID"] + "enabledByEnv": [ + "OIDC_CLIENT_ID" + ] } }, { @@ -351,8 +353,8 @@ { "id": "tunnels", "source": "tmxplug", - "url": "https://github.com/Termix-SSH/Plugin-Tunnels/releases/download/v1.2.2/tunnels-1.2.2.tmxplug", - "sha256": "92d89c5bf7fa43fff790cc5240ae75eaf05c3c9089c6a0dfb68bbc37c517bf04", + "url": "https://github.com/Termix-SSH/Plugin-Tunnels/releases/download/v1.2.3/tunnels-1.2.3.tmxplug", + "sha256": "5a23953ee19b2951bb895e033d2099b8f6d5abc0121253ef3da25328134984ad", "tier": "standard", "onboarding": { "recommended": true, From 78a1e90d930661c08d8ac4b87a26a2471ed55d09 Mon Sep 17 00:00:00 2001 From: LukeGus Date: Tue, 3 Nov 2026 20:37:33 -0600 Subject: [PATCH 003/106] chore: update wake-on-lan readme description --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index b4af7d3c19..96b8ad5b4b 100644 --- a/README.md +++ b/README.md @@ -71,6 +71,7 @@ Plugins add everything else. The official ones ship with Termix, and admins can | [Databases](https://github.com/Termix-SSH/Plugin-Databases) | Browse and edit MySQL, MariaDB, PostgreSQL and SQLite databases on your hosts from one tab. | | [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. | +| [Firewall](https://github.com/Termix-SSH/Plugin-Firewall) | Manage UFW, firewalld, nftables and iptables rules on your hosts over SSH, with lockout protection. | | [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. | From 037b0329bd7580f4cacd9bbda7628ac13e1c0003 Mon Sep 17 00:00:00 2001 From: LukeGus Date: Tue, 3 Nov 2026 20:38:02 -0600 Subject: [PATCH 004/106] chore: use the new wake-on-lan description in the readme --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 96b8ad5b4b..7404b23cc1 100644 --- a/README.md +++ b/README.md @@ -106,7 +106,7 @@ Plugins add everything else. The official ones ship with Termix, and admins can | [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. | +| [Wake-on-LAN](https://github.com/Termix-SSH/Plugin-Wake-On-LAN) | Wake, shut down and track your machines from one power dashboard. | | [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. | From 3ab3022451974dac4ad1429037fff45b1e42bcb9 Mon Sep 17 00:00:00 2001 From: LukeGus Date: Tue, 3 Nov 2026 20:48:27 -0600 Subject: [PATCH 005/106] fix: stop alerts with no icon squeezing text to one word per line --- CHANGELOG.md | 1 + src/ui/components/alert.tsx | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 46a79c5b2e..45ce1f3000 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ ### Fixed - The first run plugin picker no longer sets the default layout and themes to Remove. They always stay on +- Alerts with no icon no longer squeeze their text to one word per line ## 26.11.0 diff --git a/src/ui/components/alert.tsx b/src/ui/components/alert.tsx index b054f5b48f..98d4d79f4b 100644 --- a/src/ui/components/alert.tsx +++ b/src/ui/components/alert.tsx @@ -4,7 +4,7 @@ import { cva, type VariantProps } from "class-variance-authority"; import { cn } from "@/lib/utils"; const alertVariants = cva( - "relative w-full border border-border px-4 py-3 text-sm grid has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] grid-cols-[0_1fr] has-[>svg]:gap-x-3 gap-y-0.5 items-start [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current", + "relative w-full border border-border px-4 py-3 text-sm not-has-[>svg]:block grid has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] grid-cols-[0_1fr] has-[>svg]:gap-x-3 gap-y-0.5 items-start [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current", { variants: { variant: { From a2aafc11ec93e28388d54ce957ee47b7dd5adeab Mon Sep 17 00:00:00 2001 From: LukeGus Date: Tue, 3 Nov 2026 20:55:27 -0600 Subject: [PATCH 006/106] chore: add wireguard to the readme plugin table --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index 7404b23cc1..b65b0a38b9 100644 --- a/README.md +++ b/README.md @@ -109,6 +109,7 @@ Plugins add everything else. The official ones ship with Termix, and admins can | [Wake-on-LAN](https://github.com/Termix-SSH/Plugin-Wake-On-LAN) | Wake, shut down and track your machines from one power dashboard. | | [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. | +| [WireGuard](https://github.com/Termix-SSH/Plugin-WireGuard) | Install WireGuard on your hosts and manage interfaces, clients and configs from one tab. | | [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). From 80612c304b54262be53c66b925bce71021401a04 Mon Sep 17 00:00:00 2001 From: LukeGus Date: Tue, 3 Nov 2026 21:26:49 -0600 Subject: [PATCH 007/106] fix: match password field height to other inputs --- CHANGELOG.md | 8 ++++++++ src/ui/components/password-input.tsx | 8 ++++---- 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 45ce1f3000..ce38086e99 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,10 +2,18 @@ ## 26.11.1 +### Changed + +- Host status, import and export, and credential tools now start on for new installs +- Service Manager is now in the standard image and starts on for new installs +- Sync now starts on in a new desktop app, so it can link to a server +- The first run plugin picker leaves plugins you did not choose off instead of removing them + ### Fixed - The first run plugin picker no longer sets the default layout and themes to Remove. They always stay on - Alerts with no icon no longer squeeze their text to one word per line +- Password fields with the show button are the same height as the other fields next to them ## 26.11.0 diff --git a/src/ui/components/password-input.tsx b/src/ui/components/password-input.tsx index d8a57ff0ea..a5286f0be6 100644 --- a/src/ui/components/password-input.tsx +++ b/src/ui/components/password-input.tsx @@ -18,19 +18,19 @@ export const PasswordInput = React.forwardRef< From 31df4b12e3cdea83f7533b20474a7ad587dba38f Mon Sep 17 00:00:00 2001 From: LukeGus Date: Tue, 3 Nov 2026 21:27:29 -0600 Subject: [PATCH 008/106] fix: drop the empty security step when no sign in plugin offers setup --- CHANGELOG.md | 1 + src/ui/onboarding/OnboardingStage.tsx | 8 +++-- .../tests/onboarding/OnboardingStage.test.tsx | 31 +++++++++++++++++++ 3 files changed, 38 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ce38086e99..184a735c2a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ - The first run plugin picker no longer sets the default layout and themes to Remove. They always stay on - Alerts with no icon no longer squeeze their text to one word per line - Password fields with the show button are the same height as the other fields next to them +- The Secure your account setup step no longer shows up empty when no passkey or authenticator plugin is on ## 26.11.0 diff --git a/src/ui/onboarding/OnboardingStage.tsx b/src/ui/onboarding/OnboardingStage.tsx index e80c9182d4..bb0ffd40b3 100644 --- a/src/ui/onboarding/OnboardingStage.tsx +++ b/src/ui/onboarding/OnboardingStage.tsx @@ -19,6 +19,7 @@ import { } from "./onboarding-registry"; import { computeFlow, + isStepRelevant, ONBOARDING_ADMIN_PERMISSION, seenFor, } from "./onboarding-flow"; @@ -109,10 +110,13 @@ export function OnboardingStage({ const live = computeFlow(candidates, seenAtStart.current, ctx); for (const step of live) sticky.current.add(step.id); // A step that was in the run stays in it (the picker stops being - // pending once applied), unless its plugin went away. + // pending once applied), unless its plugin went away or it no longer + // applies, like Security once no sign in plugin offers anything. const kept = allSteps.filter( (step) => - sticky.current.has(step.id) && !live.some((s) => s.id === step.id), + sticky.current.has(step.id) && + !live.some((s) => s.id === step.id) && + (!!step.forcePending || isStepRelevant(step, ctx)), ); return sortSteps([...live, ...kept]); }, [allSteps, ctx]); diff --git a/src/ui/tests/onboarding/OnboardingStage.test.tsx b/src/ui/tests/onboarding/OnboardingStage.test.tsx index 977947bdaf..feea5286d2 100644 --- a/src/ui/tests/onboarding/OnboardingStage.test.tsx +++ b/src/ui/tests/onboarding/OnboardingStage.test.tsx @@ -80,6 +80,7 @@ import { OnboardingStage } from "@/onboarding/OnboardingStage"; import { UiPreferencesProvider } from "@/contexts/UiPreferencesContext"; import { setPermissionsForTesting } from "@/hooks/use-permissions"; import { onboardingSteps } from "@/onboarding/onboarding-registry"; +import { registerSecondFactor } from "@/plugin-host/auth-registry"; import { defaultUiPreferences, type UiPreferences, @@ -216,4 +217,34 @@ describe("OnboardingStage", () => { ); await waitFor(() => expect(onDone).toHaveBeenCalled()); }); + it("drops a step that stops applying after it joined the run", async () => { + const offFactor = registerSecondFactor({ + id: "totp", + pluginId: "totp", + titleKey: "totp", + component: () => null, + enrollment: () =>

enroll body

, + }); + const offStep = onboardingSteps.register({ + id: "x:factor", + pluginId: "x", + version: 1, + titleKey: "factor", + Component: () => , + audience: "all", + position: 50, + isRelevant: (ctx) => ctx.canEnrollSecondFactor === true, + }); + renderStage("full"); + await screen.findByText("preset body"); + act(() => offFactor()); + next(); + await screen.findByText("picker body"); + await act(async () => next()); + await screen.findByText("appearance body"); + next(); + await screen.findByText("security body"); + expect(screen.queryByText("factor body")).toBeNull(); + offStep(); + }); }); From b9df36b09ace1c30c700ccae269aef17e234aeb3 Mon Sep 17 00:00:00 2001 From: LukeGus Date: Tue, 3 Nov 2026 21:29:39 -0600 Subject: [PATCH 009/106] feat: rework default plugins and keep unchosen plugins off in the first run picker --- CHANGELOG.md | 1 - docker/bundled-plugins.json | 28 ++++++++++++---- scripts/bundled-plugins.test.ts | 32 +++++++++++++++++++ scripts/lib/bundled-plugins.cjs | 11 +++++-- scripts/pin-bundled-plugins.cjs | 2 +- src/ui/onboarding/plugin-choices.ts | 9 +++--- .../onboarding/steps/PluginsStep.test.tsx | 14 ++++---- 7 files changed, 74 insertions(+), 23 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 184a735c2a..b6f7c405c9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,6 @@ ### Changed - Host status, import and export, and credential tools now start on for new installs -- Service Manager is now in the standard image and starts on for new installs - Sync now starts on in a new desktop app, so it can link to a server - The first run plugin picker leaves plugins you did not choose off instead of removing them diff --git a/docker/bundled-plugins.json b/docker/bundled-plugins.json index e966cd5a1d..007f1ea5cb 100644 --- a/docker/bundled-plugins.json +++ b/docker/bundled-plugins.json @@ -67,7 +67,11 @@ "source": "tmxplug", "url": "https://github.com/Termix-SSH/Plugin-Credential-Tools/releases/download/v1.0.0/credential-tools-1.0.0.tmxplug", "sha256": "413eff710b5c832a2575a890bbab1c90d28ea55f10cd7a7b94b851d51dd77dce", - "tier": "standard" + "tier": "standard", + "onboarding": { + "recommended": true, + "consent": false + } }, { "id": "databases", @@ -128,7 +132,11 @@ "source": "tmxplug", "url": "https://github.com/Termix-SSH/Plugin-Host-Status/releases/download/v1.0.0/host-status-1.0.0.tmxplug", "sha256": "1ba62577dc7373a2a2787bab7efdb89b544a30a8003b1de91ce51f372fc4f170", - "tier": "standard" + "tier": "standard", + "onboarding": { + "recommended": true, + "consent": false + } }, { "id": "hosts-config", @@ -142,7 +150,11 @@ "source": "tmxplug", "url": "https://github.com/Termix-SSH/Plugin-Import-Export/releases/download/v1.0.1/import-export-1.0.1.tmxplug", "sha256": "d8f2f865e1e6b226f4fe38501bf69b752f47a643bed1d9d127181083e27da2f0", - "tier": "standard" + "tier": "standard", + "onboarding": { + "recommended": true, + "consent": false + } }, { "id": "kubernetes", @@ -288,9 +300,7 @@ "onboarding": { "recommended": false, "consent": false, - "enabledByEnv": [ - "OIDC_CLIENT_ID" - ] + "enabledByEnv": ["OIDC_CLIENT_ID"] } }, { @@ -305,7 +315,11 @@ "source": "tmxplug", "url": "https://github.com/Termix-SSH/Plugin-Sync/releases/download/v1.1.0/sync-1.1.0.tmxplug", "sha256": "9023d547f0e0bcef582c6ba7b7a8ab2ef68d8d0b973dde17d9e8c84d92b6429e", - "tier": "standard" + "tier": "standard", + "onboarding": { + "recommended": "desktop", + "consent": false + } }, { "id": "tailscale", diff --git a/scripts/bundled-plugins.test.ts b/scripts/bundled-plugins.test.ts index e84c129822..541d95b5ef 100644 --- a/scripts/bundled-plugins.test.ts +++ b/scripts/bundled-plugins.test.ts @@ -537,6 +537,38 @@ describe("default look", () => { ).toMatchObject({ id: "look", defaultLook: true }); }); + it("leaves out new plugins with no usable release when pinning all", () => { + const stable = { + version: "1.0.0", + url: "https://x/a.tmxplug", + sha256: SHA, + }; + const index = { + plugins: [ + { id: "ready", versions: [stable] }, + { + id: "beta-only", + versions: [], + prereleases: [{ ...stable, version: "1.0.0-beta.1" }], + }, + { + id: "too-new", + versions: [], + gated: [{ ...stable, engine: { termix: ">=99.0.0" } }], + }, + ], + }; + expect( + pinsFromIndex(index, null, [], "26.11.1").map( + (pin: { id: string }) => pin.id, + ), + ).toEqual(["ready"]); + expect(() => + pinsFromIndex(index, null, [{ id: "beta-only" }], "26.11.1"), + ).toThrow(/beta-only/); + expect(() => pinsFromIndex(index, ["beta-only"])).toThrow(/beta-only/); + }); + it("copies a local build's theme files", () => { const repo = tempDir(); fs.mkdirSync(path.join(repo, "dist"), { recursive: true }); diff --git a/scripts/lib/bundled-plugins.cjs b/scripts/lib/bundled-plugins.cjs index deeb173ce2..00262c7363 100644 --- a/scripts/lib/bundled-plugins.cjs +++ b/scripts/lib/bundled-plugins.cjs @@ -278,14 +278,15 @@ function pluginsForProfile(plugins, profile) { * pin list, so fields the registry does not know (onboarding, tier) carry over; * a plugin new to the list starts in the full tier. coreVersion, when given, * skips releases whose engine.termix range does not include it, so the image - * never ships a plugin its own core cannot run. + * never ships a plugin its own core cannot run. With ids null, a plugin new to + * the list that has no usable release (only betas, say) is left out. */ function pinsFromIndex(index, ids, current = [], coreVersion = null) { const plugins = Array.isArray(index?.plugins) ? index.plugins : []; const byId = new Map(plugins.map((plugin) => [plugin.id, plugin])); const currentById = new Map(current.map((entry) => [entry.id, entry])); const wanted = ids ?? plugins.map((plugin) => plugin.id); - return [...wanted].sort().map((id) => { + const pins = [...wanted].sort().map((id) => { const listed = byId.get(id); // gated holds releases that need a newer Termix than old builds check for. const latest = [...(listed?.versions ?? []), ...(listed?.gated ?? [])] @@ -300,7 +301,10 @@ function pinsFromIndex(index, ids, current = [], coreVersion = null) { }), ) .sort((a, b) => semver.rcompare(a.version, b.version))[0]; - if (!latest) throw new Error(`${id} is not in the registry index`); + if (!latest) { + if (ids === null && !currentById.has(id)) return null; + throw new Error(`${id} is not in the registry index`); + } const onboarding = currentById.get(id)?.onboarding; const defaultLook = currentById.get(id)?.defaultLook === true; const tier = currentById.get(id)?.tier ?? "full"; @@ -314,6 +318,7 @@ function pinsFromIndex(index, ids, current = [], coreVersion = null) { ...(defaultLook ? { defaultLook } : {}), }; }); + return pins.filter(Boolean); } const LOCAL_FILES = [ diff --git a/scripts/pin-bundled-plugins.cjs b/scripts/pin-bundled-plugins.cjs index 5db6dfdb94..e01dd85074 100644 --- a/scripts/pin-bundled-plugins.cjs +++ b/scripts/pin-bundled-plugins.cjs @@ -4,7 +4,7 @@ * re-released. * * node scripts/pin-bundled-plugins.cjs refresh the plugins already listed - * node scripts/pin-bundled-plugins.cjs --all also add plugins new to the index (tier full) + * node scripts/pin-bundled-plugins.cjs --all also add plugins new to the index that have a stable release (tier full) * --core-version X skip releases whose engine.termix excludes X * * The release workflow runs it with --all and the version being released. diff --git a/src/ui/onboarding/plugin-choices.ts b/src/ui/onboarding/plugin-choices.ts index 8d576e44cc..844d16973b 100644 --- a/src/ui/onboarding/plugin-choices.ts +++ b/src/ui/onboarding/plugin-choices.ts @@ -10,8 +10,9 @@ export function currentChoice(plugin: OnboardingPluginInfo): PluginChoice { /** * What the picker starts on. On a fresh install recommended plugins are on - * and the rest are set to be removed; consent ones start on. Later runs - * start from how things are now. + * and the rest stay installed but off, so a full image keeps every plugin + * unless the admin picks Remove; consent ones start on. Later runs start from + * how things are now. */ export function defaultChoices( list: OnboardingPluginList, @@ -26,7 +27,7 @@ export function defaultChoices( return [plugin.id, currentChoice(plugin)]; } if (plugin.consent) return [plugin.id, "enabled"]; - return [plugin.id, plugin.recommended ? "enabled" : "remove"]; + return [plugin.id, plugin.recommended ? "enabled" : "disabled"]; }), ); } @@ -37,7 +38,7 @@ export function recommendedChoices( return Object.fromEntries( list.plugins.map((plugin) => [ plugin.id, - plugin.recommended || plugin.consent ? "enabled" : "remove", + plugin.recommended || plugin.consent ? "enabled" : "disabled", ]), ); } diff --git a/src/ui/tests/onboarding/steps/PluginsStep.test.tsx b/src/ui/tests/onboarding/steps/PluginsStep.test.tsx index 18a782e339..335a2a85e9 100644 --- a/src/ui/tests/onboarding/steps/PluginsStep.test.tsx +++ b/src/ui/tests/onboarding/steps/PluginsStep.test.tsx @@ -49,11 +49,11 @@ const LIST: OnboardingPluginList = { }; describe("defaultChoices", () => { - it("keeps recommended plugins and removes the rest on a fresh install", () => { + it("keeps recommended plugins on and the rest off on a fresh install", () => { expect(defaultChoices(LIST)).toEqual({ term: "enabled", - base: "remove", - addon: "remove", + base: "disabled", + addon: "disabled", stats: "enabled", }); }); @@ -139,8 +139,8 @@ describe("PluginsStep", () => { resolved: {}, adjustments: [], enabled: [], - disabled: [], - removed: ["base", "addon"], + disabled: ["base", "addon"], + removed: [], failed: [], }); api.getOnboardingPlugins.mockResolvedValueOnce(LIST).mockResolvedValue({ @@ -155,8 +155,8 @@ describe("PluginsStep", () => { expect(ok).toBe(true); expect(api.applyOnboardingPlugins).toHaveBeenCalledWith({ term: "enabled", - base: "remove", - addon: "remove", + base: "disabled", + addon: "disabled", stats: "enabled", }); expect(loader.syncPlugins).toHaveBeenCalled(); From b87fe3c6a5bbbacf16357ccc33f5aad316f9276d Mon Sep 17 00:00:00 2001 From: LukeGus Date: Tue, 3 Nov 2026 21:31:19 -0600 Subject: [PATCH 010/106] chore: rename vault to ssh certificate signer in the readme --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index b65b0a38b9..a2915896dc 100644 --- a/README.md +++ b/README.md @@ -73,7 +73,6 @@ Plugins add everything else. The official ones ship with Termix, and admins can | [File Manager](https://github.com/Termix-SSH/Plugin-File-Manager) | Browse, edit and move files over SFTP, including straight from one server to another. | | [Firewall](https://github.com/Termix-SSH/Plugin-Firewall) | Manage UFW, firewalld, nftables and iptables rules on your hosts over SSH, with lockout protection. | | [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. | @@ -97,6 +96,7 @@ Plugins add everything else. The official ones ship with Termix, and admins can | [Simple Layout](https://github.com/Termix-SSH/Plugin-Simple-Layout) | A simple Termix layout for small setups with a top bar, a Home screen and only the basics. | | [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 Certificate Signer](https://github.com/Termix-SSH/Plugin-Vault) | Connect to hosts with short-lived SSH certificates signed by HashiCorp Vault or OpenBao. | | [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. | From cc725c7889d25264ef80884b9713554ba592e68b Mon Sep 17 00:00:00 2001 From: LukeGus Date: Tue, 3 Nov 2026 22:28:54 -0600 Subject: [PATCH 011/106] feat: add the operations layer for declared plugin actions --- CHANGELOG.md | 5 + docker/nginx-https.conf | 9 + docker/nginx.conf | 9 + drizzle/mysql/0078_sweet_king_cobra.sql | 70 + drizzle/mysql/meta/0078_snapshot.json | 5278 ++++++++++++++++ drizzle/mysql/meta/_journal.json | 7 + drizzle/postgres/0078_clean_living_mummy.sql | 67 + drizzle/postgres/meta/0078_snapshot.json | 5343 +++++++++++++++++ drizzle/postgres/meta/_journal.json | 7 + drizzle/sqlite/0073_dazzling_amazoness.sql | 67 + drizzle/sqlite/meta/0073_snapshot.json | 4978 +++++++++++++++ drizzle/sqlite/meta/_journal.json | 7 + packages/plugin-sdk/CHANGELOG.md | 11 + packages/plugin-sdk/PLUGIN-GUIDE.md | 70 +- packages/plugin-sdk/package.json | 6 +- .../plugin-sdk/schema/manifest.schema.json | 25 + packages/plugin-sdk/src/backend.ts | 137 + packages/plugin-sdk/src/capabilities.ts | 3 + packages/plugin-sdk/src/manifest.ts | 21 +- packages/plugin-sdk/src/operations.ts | 951 +++ packages/plugin-sdk/src/testing.ts | 54 +- src/backend/database/database.ts | 2 + src/backend/database/db/index.ts | 65 + src/backend/database/db/schema.mysql.ts | 92 + src/backend/database/db/schema.pg.ts | 92 + src/backend/database/db/schema.ts | 92 + src/backend/database/repositories/factory.ts | 9 + .../repositories/operation-repository.ts | 255 + .../database/routes/delete-user-data.ts | 2 + .../database/routes/operations-routes.ts | 760 +++ src/backend/operations/approvals.ts | 129 + src/backend/operations/catalog.ts | 251 + src/backend/operations/constants.ts | 8 + src/backend/operations/events.ts | 155 + src/backend/operations/gate.ts | 891 +++ src/backend/operations/hosts.ts | 68 + src/backend/operations/key-scopes.ts | 44 + src/backend/operations/limits.ts | 90 + src/backend/operations/maintenance.ts | 47 + src/backend/operations/registry.ts | 84 + src/backend/operations/resources.ts | 231 + src/backend/plugins/actor.ts | 51 +- src/backend/plugins/ctx-operations.ts | 195 + src/backend/plugins/ctx.ts | 3 + src/backend/plugins/http.ts | 10 +- src/backend/starter.ts | 3 + .../database/routes/operations-routes.test.ts | 277 + src/backend/tests/operations/gate.test.ts | 694 +++ .../tests/operations/key-scopes.test.ts | 70 + .../tests/plugins/ctx-operations.test.ts | 210 + .../tests/plugins/operations-manifest.test.ts | 297 + src/backend/utils/auth-manager.ts | 12 + src/backend/utils/permission-catalog.ts | 1 + src/ui/locales/en.json | 12 + 54 files changed, 22319 insertions(+), 8 deletions(-) create mode 100644 drizzle/mysql/0078_sweet_king_cobra.sql create mode 100644 drizzle/mysql/meta/0078_snapshot.json create mode 100644 drizzle/postgres/0078_clean_living_mummy.sql create mode 100644 drizzle/postgres/meta/0078_snapshot.json create mode 100644 drizzle/sqlite/0073_dazzling_amazoness.sql create mode 100644 drizzle/sqlite/meta/0073_snapshot.json create mode 100644 packages/plugin-sdk/src/operations.ts create mode 100644 src/backend/database/repositories/operation-repository.ts create mode 100644 src/backend/database/routes/operations-routes.ts create mode 100644 src/backend/operations/approvals.ts create mode 100644 src/backend/operations/catalog.ts create mode 100644 src/backend/operations/constants.ts create mode 100644 src/backend/operations/events.ts create mode 100644 src/backend/operations/gate.ts create mode 100644 src/backend/operations/hosts.ts create mode 100644 src/backend/operations/key-scopes.ts create mode 100644 src/backend/operations/limits.ts create mode 100644 src/backend/operations/maintenance.ts create mode 100644 src/backend/operations/registry.ts create mode 100644 src/backend/operations/resources.ts create mode 100644 src/backend/plugins/ctx-operations.ts create mode 100644 src/backend/tests/database/routes/operations-routes.test.ts create mode 100644 src/backend/tests/operations/gate.test.ts create mode 100644 src/backend/tests/operations/key-scopes.test.ts create mode 100644 src/backend/tests/plugins/ctx-operations.test.ts create mode 100644 src/backend/tests/plugins/operations-manifest.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index b6f7c405c9..a897fa7ba5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,11 @@ ## 26.11.1 +### Added + +- Termix actions: plugins can declare what they do once, and it works from the API with permission checks, dry runs, run history and approvals +- API keys can be limited to reading or running actions + ### Changed - Host status, import and export, and credential tools now start on for new installs diff --git a/docker/nginx-https.conf b/docker/nginx-https.conf index fbe592f9c6..1276923371 100644 --- a/docker/nginx-https.conf +++ b/docker/nginx-https.conf @@ -276,6 +276,15 @@ http { proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto; } + location ~ ^/operations(/.*)?$ { + proxy_pass http://127.0.0.1:30001; + proxy_http_version 1.1; + proxy_set_header Host $http_host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto; + } + location ~ ^/plugins(/.*)?$ { proxy_pass http://127.0.0.1:30001; proxy_http_version 1.1; diff --git a/docker/nginx.conf b/docker/nginx.conf index e529ce06c0..d52d06f76e 100644 --- a/docker/nginx.conf +++ b/docker/nginx.conf @@ -245,6 +245,15 @@ http { proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto; } + location ~ ^/operations(/.*)?$ { + proxy_pass http://127.0.0.1:30001; + proxy_http_version 1.1; + proxy_set_header Host $http_host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto; + } + location ~ ^/plugins(/.*)?$ { proxy_pass http://127.0.0.1:30001; proxy_http_version 1.1; diff --git a/drizzle/mysql/0078_sweet_king_cobra.sql b/drizzle/mysql/0078_sweet_king_cobra.sql new file mode 100644 index 0000000000..5987a9673f --- /dev/null +++ b/drizzle/mysql/0078_sweet_king_cobra.sql @@ -0,0 +1,70 @@ +CREATE TABLE `operation_approvals` ( + `id` varchar(255) NOT NULL, + `run_id` varchar(255) NOT NULL, + `requested_by` text, + `reason` text, + `allow_self` boolean NOT NULL DEFAULT false, + `status` varchar(255) NOT NULL, + `decided_by` text, + `comment` text, + `created_at` text NOT NULL, + `expires_at` varchar(255) NOT NULL, + `decided_at` text, + CONSTRAINT `operation_approvals_id` PRIMARY KEY(`id`) +); +--> statement-breakpoint +CREATE TABLE `operation_events` ( + `id` varchar(255) NOT NULL, + `event_id` text NOT NULL, + `plugin_id` text NOT NULL, + `host_id` int, + `severity` text, + `payload` text, + `run_id` text, + `created_at` varchar(255) NOT NULL, + CONSTRAINT `operation_events_id` PRIMARY KEY(`id`) +); +--> statement-breakpoint +CREATE TABLE `operation_runs` ( + `id` varchar(255) NOT NULL, + `action_id` text NOT NULL, + `action_version` int NOT NULL DEFAULT 1, + `plugin_id` text NOT NULL, + `plugin_version` text NOT NULL, + `user_id` varchar(255), + `username` text NOT NULL, + `origin` text NOT NULL, + `host_id` int, + `host_name` text, + `batch_id` varchar(255), + `dry_run` boolean NOT NULL DEFAULT false, + `risk` text NOT NULL, + `status` varchar(255) NOT NULL, + `summary` text, + `changed` boolean, + `input` text, + `sealed_input` text, + `output` text, + `undo_input` text, + `error` text, + `error_code` text, + `undo_of` text, + `idempotency_key` text, + `created_at` varchar(255) NOT NULL, + `started_at` text, + `finished_at` text, + CONSTRAINT `operation_runs_id` PRIMARY KEY(`id`) +); +--> statement-breakpoint +ALTER TABLE `api_keys` ADD `scopes` text;--> statement-breakpoint +ALTER TABLE `operation_approvals` ADD CONSTRAINT `operation_approvals_run_id_operation_runs_id_fk` FOREIGN KEY (`run_id`) REFERENCES `operation_runs`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE `operation_runs` ADD CONSTRAINT `operation_runs_user_id_users_id_fk` FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE set null ON UPDATE no action;--> statement-breakpoint +CREATE INDEX `idx_operation_approvals_status` ON `operation_approvals` (`status`,`expires_at`);--> statement-breakpoint +CREATE INDEX `idx_operation_approvals_run` ON `operation_approvals` (`run_id`);--> statement-breakpoint +CREATE INDEX `idx_operation_events_created` ON `operation_events` (`created_at`);--> statement-breakpoint +CREATE INDEX `idx_operation_events_host_created` ON `operation_events` (`host_id`,`created_at`);--> statement-breakpoint +CREATE INDEX `idx_operation_runs_created` ON `operation_runs` (`created_at`);--> statement-breakpoint +CREATE INDEX `idx_operation_runs_user_created` ON `operation_runs` (`user_id`,`created_at`);--> statement-breakpoint +CREATE INDEX `idx_operation_runs_host_created` ON `operation_runs` (`host_id`,`created_at`);--> statement-breakpoint +CREATE INDEX `idx_operation_runs_status` ON `operation_runs` (`status`);--> statement-breakpoint +CREATE INDEX `idx_operation_runs_batch` ON `operation_runs` (`batch_id`); \ No newline at end of file diff --git a/drizzle/mysql/meta/0078_snapshot.json b/drizzle/mysql/meta/0078_snapshot.json new file mode 100644 index 0000000000..489cce7c7b --- /dev/null +++ b/drizzle/mysql/meta/0078_snapshot.json @@ -0,0 +1,5278 @@ +{ + "version": "5", + "dialect": "mysql", + "id": "d516ec4d-26b1-460f-8020-63ef7d960512", + "prevId": "7ed8bfd3-eeba-4c4a-a7d2-a0ebcfb89d66", + "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 + }, + "scopes": { + "name": "scopes", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "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 + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "icon": { + "name": "icon", + "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": {} + }, + "operation_approvals": { + "name": "operation_approvals", + "columns": { + "id": { + "name": "id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "run_id": { + "name": "run_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "requested_by": { + "name": "requested_by", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "allow_self": { + "name": "allow_self", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "status": { + "name": "status", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "decided_by": { + "name": "decided_by", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "comment": { + "name": "comment", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "decided_at": { + "name": "decided_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "idx_operation_approvals_status": { + "name": "idx_operation_approvals_status", + "columns": [ + "status", + "expires_at" + ], + "isUnique": false + }, + "idx_operation_approvals_run": { + "name": "idx_operation_approvals_run", + "columns": [ + "run_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "operation_approvals_run_id_operation_runs_id_fk": { + "name": "operation_approvals_run_id_operation_runs_id_fk", + "tableFrom": "operation_approvals", + "tableTo": "operation_runs", + "columnsFrom": [ + "run_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "operation_approvals_id": { + "name": "operation_approvals_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "operation_events": { + "name": "operation_events", + "columns": { + "id": { + "name": "id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "event_id": { + "name": "event_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "plugin_id": { + "name": "plugin_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "host_id": { + "name": "host_id", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "severity": { + "name": "severity", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "payload": { + "name": "payload", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "run_id": { + "name": "run_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_operation_events_created": { + "name": "idx_operation_events_created", + "columns": [ + "created_at" + ], + "isUnique": false + }, + "idx_operation_events_host_created": { + "name": "idx_operation_events_host_created", + "columns": [ + "host_id", + "created_at" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "operation_events_id": { + "name": "operation_events_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "operation_runs": { + "name": "operation_runs", + "columns": { + "id": { + "name": "id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "action_id": { + "name": "action_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "action_version": { + "name": "action_version", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 1 + }, + "plugin_id": { + "name": "plugin_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "plugin_version": { + "name": "plugin_version", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "origin": { + "name": "origin", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "host_id": { + "name": "host_id", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "host_name": { + "name": "host_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "batch_id": { + "name": "batch_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "dry_run": { + "name": "dry_run", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "risk": { + "name": "risk", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "summary": { + "name": "summary", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "changed": { + "name": "changed", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "input": { + "name": "input", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sealed_input": { + "name": "sealed_input", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "output": { + "name": "output", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "undo_input": { + "name": "undo_input", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "error_code": { + "name": "error_code", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "undo_of": { + "name": "undo_of", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "idempotency_key": { + "name": "idempotency_key", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "started_at": { + "name": "started_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "finished_at": { + "name": "finished_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "idx_operation_runs_created": { + "name": "idx_operation_runs_created", + "columns": [ + "created_at" + ], + "isUnique": false + }, + "idx_operation_runs_user_created": { + "name": "idx_operation_runs_user_created", + "columns": [ + "user_id", + "created_at" + ], + "isUnique": false + }, + "idx_operation_runs_host_created": { + "name": "idx_operation_runs_host_created", + "columns": [ + "host_id", + "created_at" + ], + "isUnique": false + }, + "idx_operation_runs_status": { + "name": "idx_operation_runs_status", + "columns": [ + "status" + ], + "isUnique": false + }, + "idx_operation_runs_batch": { + "name": "idx_operation_runs_batch", + "columns": [ + "batch_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "operation_runs_user_id_users_id_fk": { + "name": "operation_runs_user_id_users_id_fk", + "tableFrom": "operation_runs", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "operation_runs_id": { + "name": "operation_runs_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "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_folder_auth_overrides": { + "name": "shared_folder_auth_overrides", + "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 + }, + "folder": { + "name": "folder", + "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_folder_auth_overrides_user_folder_protocol_unique": { + "name": "shared_folder_auth_overrides_user_folder_protocol_unique", + "columns": [ + "user_id", + "folder", + "protocol" + ], + "isUnique": true + } + }, + "foreignKeys": { + "shared_folder_auth_overrides_user_id_users_id_fk": { + "name": "shared_folder_auth_overrides_user_id_users_id_fk", + "tableFrom": "shared_folder_auth_overrides", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "shared_folder_auth_overrides_credential_id_ssh_credentials_id_fk": { + "name": "shared_folder_auth_overrides_credential_id_ssh_credentials_id_fk", + "tableFrom": "shared_folder_auth_overrides", + "tableTo": "ssh_credentials", + "columnsFrom": [ + "credential_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "shared_folder_auth_overrides_id": { + "name": "shared_folder_auth_overrides_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 b4f852fc9e..d6b0bec6bd 100644 --- a/drizzle/mysql/meta/_journal.json +++ b/drizzle/mysql/meta/_journal.json @@ -547,6 +547,13 @@ "when": 1793304929772, "tag": "0077_shared_folder_auth_overrides", "breakpoints": true + }, + { + "idx": 78, + "version": "5", + "when": 1793765383557, + "tag": "0078_sweet_king_cobra", + "breakpoints": true } ] } \ No newline at end of file diff --git a/drizzle/postgres/0078_clean_living_mummy.sql b/drizzle/postgres/0078_clean_living_mummy.sql new file mode 100644 index 0000000000..59000a2fdc --- /dev/null +++ b/drizzle/postgres/0078_clean_living_mummy.sql @@ -0,0 +1,67 @@ +CREATE TABLE "operation_approvals" ( + "id" varchar(255) PRIMARY KEY NOT NULL, + "run_id" varchar(255) NOT NULL, + "requested_by" text, + "reason" text, + "allow_self" boolean DEFAULT false NOT NULL, + "status" varchar(255) NOT NULL, + "decided_by" text, + "comment" text, + "created_at" text NOT NULL, + "expires_at" varchar(255) NOT NULL, + "decided_at" text +); +--> statement-breakpoint +CREATE TABLE "operation_events" ( + "id" varchar(255) PRIMARY KEY NOT NULL, + "event_id" text NOT NULL, + "plugin_id" text NOT NULL, + "host_id" integer, + "severity" text, + "payload" text, + "run_id" text, + "created_at" varchar(255) NOT NULL +); +--> statement-breakpoint +CREATE TABLE "operation_runs" ( + "id" varchar(255) PRIMARY KEY NOT NULL, + "action_id" text NOT NULL, + "action_version" integer DEFAULT 1 NOT NULL, + "plugin_id" text NOT NULL, + "plugin_version" text NOT NULL, + "user_id" varchar(255), + "username" text NOT NULL, + "origin" text NOT NULL, + "host_id" integer, + "host_name" text, + "batch_id" varchar(255), + "dry_run" boolean DEFAULT false NOT NULL, + "risk" text NOT NULL, + "status" varchar(255) NOT NULL, + "summary" text, + "changed" boolean, + "input" text, + "sealed_input" text, + "output" text, + "undo_input" text, + "error" text, + "error_code" text, + "undo_of" text, + "idempotency_key" text, + "created_at" varchar(255) NOT NULL, + "started_at" text, + "finished_at" text +); +--> statement-breakpoint +ALTER TABLE "api_keys" ADD COLUMN "scopes" text;--> statement-breakpoint +ALTER TABLE "operation_approvals" ADD CONSTRAINT "operation_approvals_run_id_operation_runs_id_fk" FOREIGN KEY ("run_id") REFERENCES "public"."operation_runs"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "operation_runs" ADD CONSTRAINT "operation_runs_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint +CREATE INDEX "idx_operation_approvals_status" ON "operation_approvals" USING btree ("status","expires_at");--> statement-breakpoint +CREATE INDEX "idx_operation_approvals_run" ON "operation_approvals" USING btree ("run_id");--> statement-breakpoint +CREATE INDEX "idx_operation_events_created" ON "operation_events" USING btree ("created_at");--> statement-breakpoint +CREATE INDEX "idx_operation_events_host_created" ON "operation_events" USING btree ("host_id","created_at");--> statement-breakpoint +CREATE INDEX "idx_operation_runs_created" ON "operation_runs" USING btree ("created_at");--> statement-breakpoint +CREATE INDEX "idx_operation_runs_user_created" ON "operation_runs" USING btree ("user_id","created_at");--> statement-breakpoint +CREATE INDEX "idx_operation_runs_host_created" ON "operation_runs" USING btree ("host_id","created_at");--> statement-breakpoint +CREATE INDEX "idx_operation_runs_status" ON "operation_runs" USING btree ("status");--> statement-breakpoint +CREATE INDEX "idx_operation_runs_batch" ON "operation_runs" USING btree ("batch_id"); \ No newline at end of file diff --git a/drizzle/postgres/meta/0078_snapshot.json b/drizzle/postgres/meta/0078_snapshot.json new file mode 100644 index 0000000000..3caccb3c7c --- /dev/null +++ b/drizzle/postgres/meta/0078_snapshot.json @@ -0,0 +1,5343 @@ +{ + "id": "9ef48da0-f63a-4f05-9337-3aeb762e18d3", + "prevId": "4e428aad-48b3-42b7-a27c-aa136b6b4452", + "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 + }, + "scopes": { + "name": "scopes", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "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 + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "icon": { + "name": "icon", + "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.operation_approvals": { + "name": "operation_approvals", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "run_id": { + "name": "run_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "requested_by": { + "name": "requested_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "allow_self": { + "name": "allow_self", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "status": { + "name": "status", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "decided_by": { + "name": "decided_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "comment": { + "name": "comment", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "decided_at": { + "name": "decided_at", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_operation_approvals_status": { + "name": "idx_operation_approvals_status", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_operation_approvals_run": { + "name": "idx_operation_approvals_run", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "operation_approvals_run_id_operation_runs_id_fk": { + "name": "operation_approvals_run_id_operation_runs_id_fk", + "tableFrom": "operation_approvals", + "tableTo": "operation_runs", + "columnsFrom": [ + "run_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.operation_events": { + "name": "operation_events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "event_id": { + "name": "event_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "plugin_id": { + "name": "plugin_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "host_id": { + "name": "host_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "severity": { + "name": "severity", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "payload": { + "name": "payload", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "run_id": { + "name": "run_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "idx_operation_events_created": { + "name": "idx_operation_events_created", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_operation_events_host_created": { + "name": "idx_operation_events_host_created", + "columns": [ + { + "expression": "host_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.operation_runs": { + "name": "operation_runs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "action_id": { + "name": "action_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "action_version": { + "name": "action_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "plugin_id": { + "name": "plugin_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "plugin_version": { + "name": "plugin_version", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "origin": { + "name": "origin", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "host_id": { + "name": "host_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "host_name": { + "name": "host_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "batch_id": { + "name": "batch_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "dry_run": { + "name": "dry_run", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "risk": { + "name": "risk", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "summary": { + "name": "summary", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "changed": { + "name": "changed", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "input": { + "name": "input", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sealed_input": { + "name": "sealed_input", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "output": { + "name": "output", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "undo_input": { + "name": "undo_input", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error_code": { + "name": "error_code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "undo_of": { + "name": "undo_of", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "idempotency_key": { + "name": "idempotency_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "started_at": { + "name": "started_at", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "finished_at": { + "name": "finished_at", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_operation_runs_created": { + "name": "idx_operation_runs_created", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_operation_runs_user_created": { + "name": "idx_operation_runs_user_created", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_operation_runs_host_created": { + "name": "idx_operation_runs_host_created", + "columns": [ + { + "expression": "host_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_operation_runs_status": { + "name": "idx_operation_runs_status", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_operation_runs_batch": { + "name": "idx_operation_runs_batch", + "columns": [ + { + "expression": "batch_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "operation_runs_user_id_users_id_fk": { + "name": "operation_runs_user_id_users_id_fk", + "tableFrom": "operation_runs", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "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_folder_auth_overrides": { + "name": "shared_folder_auth_overrides", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "folder": { + "name": "folder", + "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_folder_auth_overrides_user_folder_protocol_unique": { + "name": "shared_folder_auth_overrides_user_folder_protocol_unique", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "folder", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "protocol", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "shared_folder_auth_overrides_user_id_users_id_fk": { + "name": "shared_folder_auth_overrides_user_id_users_id_fk", + "tableFrom": "shared_folder_auth_overrides", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "shared_folder_auth_overrides_credential_id_ssh_credentials_id_fk": { + "name": "shared_folder_auth_overrides_credential_id_ssh_credentials_id_fk", + "tableFrom": "shared_folder_auth_overrides", + "tableTo": "ssh_credentials", + "columnsFrom": [ + "credential_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 cd320dad6c..95a703242f 100644 --- a/drizzle/postgres/meta/_journal.json +++ b/drizzle/postgres/meta/_journal.json @@ -547,6 +547,13 @@ "when": 1793304928399, "tag": "0077_shared_folder_auth_overrides", "breakpoints": true + }, + { + "idx": 78, + "version": "7", + "when": 1793765382955, + "tag": "0078_clean_living_mummy", + "breakpoints": true } ] } \ No newline at end of file diff --git a/drizzle/sqlite/0073_dazzling_amazoness.sql b/drizzle/sqlite/0073_dazzling_amazoness.sql new file mode 100644 index 0000000000..535d2c31c5 --- /dev/null +++ b/drizzle/sqlite/0073_dazzling_amazoness.sql @@ -0,0 +1,67 @@ +CREATE TABLE `operation_approvals` ( + `id` text PRIMARY KEY NOT NULL, + `run_id` text NOT NULL, + `requested_by` text, + `reason` text, + `allow_self` integer DEFAULT false NOT NULL, + `status` text NOT NULL, + `decided_by` text, + `comment` text, + `created_at` text NOT NULL, + `expires_at` text NOT NULL, + `decided_at` text, + FOREIGN KEY (`run_id`) REFERENCES `operation_runs`(`id`) ON UPDATE no action ON DELETE cascade +); +--> statement-breakpoint +CREATE INDEX `idx_operation_approvals_status` ON `operation_approvals` (`status`,`expires_at`);--> statement-breakpoint +CREATE INDEX `idx_operation_approvals_run` ON `operation_approvals` (`run_id`);--> statement-breakpoint +CREATE TABLE `operation_events` ( + `id` text PRIMARY KEY NOT NULL, + `event_id` text NOT NULL, + `plugin_id` text NOT NULL, + `host_id` integer, + `severity` text, + `payload` text, + `run_id` text, + `created_at` text NOT NULL +); +--> statement-breakpoint +CREATE INDEX `idx_operation_events_created` ON `operation_events` (`created_at`);--> statement-breakpoint +CREATE INDEX `idx_operation_events_host_created` ON `operation_events` (`host_id`,`created_at`);--> statement-breakpoint +CREATE TABLE `operation_runs` ( + `id` text PRIMARY KEY NOT NULL, + `action_id` text NOT NULL, + `action_version` integer DEFAULT 1 NOT NULL, + `plugin_id` text NOT NULL, + `plugin_version` text NOT NULL, + `user_id` text, + `username` text NOT NULL, + `origin` text NOT NULL, + `host_id` integer, + `host_name` text, + `batch_id` text, + `dry_run` integer DEFAULT false NOT NULL, + `risk` text NOT NULL, + `status` text NOT NULL, + `summary` text, + `changed` integer, + `input` text, + `sealed_input` text, + `output` text, + `undo_input` text, + `error` text, + `error_code` text, + `undo_of` text, + `idempotency_key` text, + `created_at` text NOT NULL, + `started_at` text, + `finished_at` text, + FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE set null +); +--> statement-breakpoint +CREATE INDEX `idx_operation_runs_created` ON `operation_runs` (`created_at`);--> statement-breakpoint +CREATE INDEX `idx_operation_runs_user_created` ON `operation_runs` (`user_id`,`created_at`);--> statement-breakpoint +CREATE INDEX `idx_operation_runs_host_created` ON `operation_runs` (`host_id`,`created_at`);--> statement-breakpoint +CREATE INDEX `idx_operation_runs_status` ON `operation_runs` (`status`);--> statement-breakpoint +CREATE INDEX `idx_operation_runs_batch` ON `operation_runs` (`batch_id`);--> statement-breakpoint +ALTER TABLE `api_keys` ADD `scopes` text; \ No newline at end of file diff --git a/drizzle/sqlite/meta/0073_snapshot.json b/drizzle/sqlite/meta/0073_snapshot.json new file mode 100644 index 0000000000..2cd3490108 --- /dev/null +++ b/drizzle/sqlite/meta/0073_snapshot.json @@ -0,0 +1,4978 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "5ad2dc73-99c2-4514-ac7b-0fb1dc421890", + "prevId": "53940e39-d429-4ebe-acfd-496bc4144fa1", + "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 + }, + "scopes": { + "name": "scopes", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "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 + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "icon": { + "name": "icon", + "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": {} + }, + "operation_approvals": { + "name": "operation_approvals", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "run_id": { + "name": "run_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "requested_by": { + "name": "requested_by", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "allow_self": { + "name": "allow_self", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "decided_by": { + "name": "decided_by", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "comment": { + "name": "comment", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "decided_at": { + "name": "decided_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "idx_operation_approvals_status": { + "name": "idx_operation_approvals_status", + "columns": [ + "status", + "expires_at" + ], + "isUnique": false + }, + "idx_operation_approvals_run": { + "name": "idx_operation_approvals_run", + "columns": [ + "run_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "operation_approvals_run_id_operation_runs_id_fk": { + "name": "operation_approvals_run_id_operation_runs_id_fk", + "tableFrom": "operation_approvals", + "tableTo": "operation_runs", + "columnsFrom": [ + "run_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "operation_events": { + "name": "operation_events", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "event_id": { + "name": "event_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "plugin_id": { + "name": "plugin_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "host_id": { + "name": "host_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "severity": { + "name": "severity", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "payload": { + "name": "payload", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "run_id": { + "name": "run_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_operation_events_created": { + "name": "idx_operation_events_created", + "columns": [ + "created_at" + ], + "isUnique": false + }, + "idx_operation_events_host_created": { + "name": "idx_operation_events_host_created", + "columns": [ + "host_id", + "created_at" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "operation_runs": { + "name": "operation_runs", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "action_id": { + "name": "action_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "action_version": { + "name": "action_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 1 + }, + "plugin_id": { + "name": "plugin_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "plugin_version": { + "name": "plugin_version", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "origin": { + "name": "origin", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "host_id": { + "name": "host_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "host_name": { + "name": "host_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "batch_id": { + "name": "batch_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "dry_run": { + "name": "dry_run", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "risk": { + "name": "risk", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "summary": { + "name": "summary", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "changed": { + "name": "changed", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "input": { + "name": "input", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sealed_input": { + "name": "sealed_input", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "output": { + "name": "output", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "undo_input": { + "name": "undo_input", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "error_code": { + "name": "error_code", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "undo_of": { + "name": "undo_of", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "idempotency_key": { + "name": "idempotency_key", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "started_at": { + "name": "started_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "finished_at": { + "name": "finished_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "idx_operation_runs_created": { + "name": "idx_operation_runs_created", + "columns": [ + "created_at" + ], + "isUnique": false + }, + "idx_operation_runs_user_created": { + "name": "idx_operation_runs_user_created", + "columns": [ + "user_id", + "created_at" + ], + "isUnique": false + }, + "idx_operation_runs_host_created": { + "name": "idx_operation_runs_host_created", + "columns": [ + "host_id", + "created_at" + ], + "isUnique": false + }, + "idx_operation_runs_status": { + "name": "idx_operation_runs_status", + "columns": [ + "status" + ], + "isUnique": false + }, + "idx_operation_runs_batch": { + "name": "idx_operation_runs_batch", + "columns": [ + "batch_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "operation_runs_user_id_users_id_fk": { + "name": "operation_runs_user_id_users_id_fk", + "tableFrom": "operation_runs", + "tableTo": "users", + "columnsFrom": [ + "user_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_folder_auth_overrides": { + "name": "shared_folder_auth_overrides", + "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 + }, + "folder": { + "name": "folder", + "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_folder_auth_overrides_user_folder_protocol_unique": { + "name": "shared_folder_auth_overrides_user_folder_protocol_unique", + "columns": [ + "user_id", + "folder", + "protocol" + ], + "isUnique": true + } + }, + "foreignKeys": { + "shared_folder_auth_overrides_user_id_users_id_fk": { + "name": "shared_folder_auth_overrides_user_id_users_id_fk", + "tableFrom": "shared_folder_auth_overrides", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "shared_folder_auth_overrides_credential_id_ssh_credentials_id_fk": { + "name": "shared_folder_auth_overrides_credential_id_ssh_credentials_id_fk", + "tableFrom": "shared_folder_auth_overrides", + "tableTo": "ssh_credentials", + "columnsFrom": [ + "credential_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 d001624cd6..256052e97f 100644 --- a/drizzle/sqlite/meta/_journal.json +++ b/drizzle/sqlite/meta/_journal.json @@ -512,6 +512,13 @@ "when": 1793304926942, "tag": "0072_shared_folder_auth_overrides", "breakpoints": true + }, + { + "idx": 73, + "version": "6", + "when": 1793765382283, + "tag": "0073_dazzling_amazoness", + "breakpoints": true } ] } \ No newline at end of file diff --git a/packages/plugin-sdk/CHANGELOG.md b/packages/plugin-sdk/CHANGELOG.md index 68659a958a..e37af6b2cb 100644 --- a/packages/plugin-sdk/CHANGELOG.md +++ b/packages/plugin-sdk/CHANGELOG.md @@ -1,5 +1,16 @@ # Changelog +## 1.0.23 + +Plugin API 1.14.0. + +### Added + +- `contributes.operations` declares a plugin's actions, events, resources and signals, so Automations, Fleets, the assistant, the CLI, the phone app and the command palette can find them with no wiring +- `ctx.operations` registers handlers, runs other plugins' declared actions, emits events and provides approval rules, with the new `operations:provide`, `operations:invoke` and `operations:approve` capabilities +- `@termix-ssh/plugin-sdk/operations` has the types, input checks, redaction and JSON Schema for declared actions +- `contributes.hostSections` declares cards for the host page + ## 1.0.22 Plugin API 1.13.0. diff --git a/packages/plugin-sdk/PLUGIN-GUIDE.md b/packages/plugin-sdk/PLUGIN-GUIDE.md index 7ac4df0569..d1e1789d91 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.22 | Plugin API 1.13.0 +SDK 1.0.23 | Plugin API 1.14.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. @@ -414,6 +414,70 @@ ctx.schedule.every(60_000, () => refresh(), { jitterMs: 5_000 }); - Use a service: declare it in `requires`, then `ctx.services.get("snippets.access")`. It returns nothing while no provider runs, so always handle that. +### Operations + +Declare what your plugin can do in `contributes.operations`, and it shows up in Automations, Fleets, the +assistant, the CLI, the phone app and the command palette with no other wiring. It also gets per host results, +dry runs, run history, approvals and drift checks. + +```json +"capabilities": ["operations:provide"], +"engine": { "termix": ">=26.11.1-0", "api": "^1.13" }, +"contributes": { + "operations": { + "actions": [{ + "id": "demo.restart", + "titleKey": "ops.restart.title", + "scope": "host", + "inputs": [{ "key": "name", "type": "string", "labelKey": "ops.restart.name", "required": true }], + "risk": "change", + "permission": "use", + "hostAccess": "edit", + "dryRun": "describe" + }] + } +} +``` + +```ts +ctx.operations.register("demo.restart", { + describe: (input) => `Would restart ${input.name}`, + run: async (input, { hostId, signal, log }) => { + return { summary: `Restarted ${input.name}`, changed: true }; + }, +}); +``` + +- Actions: `scope` is `host`, `resource` or `server`. `risk` is `read`, `change` or `destructive`. Host actions declare + `hostAccess`. Use `edit` for changes unless people with `connect` already do this today. A destructive action + must use `edit` or `manage` and needs a confirm. +- Inputs use the settings field types, plus `required`, `pattern`, `optionsFrom` (a read action fills the choices) + and type `resource`. Keys you did not declare are refused before your handler runs. `secret` values reach the handler + but are never stored or shown. Mark private outputs `sensitive`. +- `dryRun`: `native` calls `run` with `dryRun: true` and nothing may change. `describe` calls `describe` instead. +- `check` names a read action that proves the result, used for drift. `undo` names the reverse action, and `run` returns + `undoInput`. `idempotent` lets a rollout retry it. `exclusive` allows one at a time per host. `when` limits it to + hosts by setting or OS. +- `surfaces`: the assistant and MCP are off until you set them true. Only do that for actions safe for a model to + ask for. The rest are on unless you set them false. +- Resources describe a kind of thing, such as a container: a read action on one host returns `{ rows }` as `data`, and + core draws the All hosts table and host page section. Attach actions with `scope: "resource"`. +- Signals are status tiles: a read action returns `{ value, status, label }` as `data`, with status `ok`, `warn`, `fail` or + `unknown`. `fix` names the action offered when it is not ok. Keep these reads cheap, they run for every host. +- Events: declare them, then call `ctx.operations.emit(id, payload, { hostId })`. +- Handlers must watch `signal` and stop when it fires. Throw on failure; a failed run never counts against your + plugin's error budget. Throw an error with `code = "NEEDS_INTERACTION"` when a host needs a person to sign in first. +- Handlers have no request and no session. Take what you need from the input and `hostId`. +- Keep your REST routes. Inside one, call `ctx.operations.run(id, { hostId, input })` so a button press goes through + the same checks and lands in the same history. +- To run another plugin's action, add `operations:invoke`, list it in `operations.invokes` and call + `ctx.operations.invoke(id, { hostId, input, via: "automation" })`. It acts as the current user and rules still apply. +- `risk` is your plugin's claim. Plugins run in-process, so this is not a sandbox. Actions from plugins not signed as + official count as `change` when rules decide what needs a person, and stay off the assistant and MCP until an admin + allows them. +- Set `engine.termix` to at least `>=26.11.1-0`. Keep `engine.api` as it was unless you import something new from + `/ui`, or the phone app will ask people to update to see your plugin. + ### Shared packages These come from Termix and are never bundled: `@termix-ssh/plugin-sdk`, `express`, `ssh2`, `ws`, `multer`, @@ -962,6 +1026,7 @@ To run your own registry instead, see https://docs.termix.site/develop/registrie | `@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/operations` | Types, input checks and JSON Schema for declared actions | | `@termix-ssh/plugin-sdk/settings` | Settings field validation | | `@termix-ssh/plugin-sdk/testing` | `createMockCtx`, `createTestDb`, `renderWithApp`, `createFakeContext` | | `@termix-ssh/plugin-sdk/vitest-preset` | `pluginVitestConfig` | @@ -993,6 +1058,8 @@ To run your own registry instead, see https://docs.termix.site/develop/registrie | `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) | +| `operations:invoke` | high | Run other plugins' declared actions, listed in `invokes` | +| `operations:approve` | high | Decide which actions need a person | | `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 | @@ -1008,6 +1075,7 @@ To run your own registry instead, see https://docs.termix.site/develop/registrie | `users:read` | medium | Usernames, roles and UI language | | `events:core` | medium | Listen to core events | | `notify:send` | medium | Send alerts | +| `operations:provide` | medium | Offer declared actions and events | | `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 | diff --git a/packages/plugin-sdk/package.json b/packages/plugin-sdk/package.json index b85d7da8a7..8f43f72ed2 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.22", + "version": "1.0.23", "description": "The contract between Termix core and a plugin: types, the capability catalog and the manifest schema.", "license": "Apache-2.0", "repository": { @@ -61,6 +61,10 @@ "types": "./dist/openapi.d.ts", "default": "./dist/openapi.js" }, + "./operations": { + "types": "./dist/operations.d.ts", + "default": "./dist/operations.js" + }, "./settings": { "types": "./dist/settings.d.ts", "default": "./dist/settings.js" diff --git a/packages/plugin-sdk/schema/manifest.schema.json b/packages/plugin-sdk/schema/manifest.schema.json index f1b8d76aa3..d4ca15aa0c 100644 --- a/packages/plugin-sdk/schema/manifest.schema.json +++ b/packages/plugin-sdk/schema/manifest.schema.json @@ -536,6 +536,31 @@ } } }, + "hostSections": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "required": ["id", "titleKey"], + "properties": { + "id": { "type": "string", "minLength": 1 }, + "titleKey": { "type": "string", "minLength": 1 }, + "icon": { "type": "string", "minLength": 1 } + } + } + }, + "operations": { + "type": "object", + "description": "What this plugin can do. Every surface finds these on its own. See the operations section of PLUGIN-GUIDE.md.", + "additionalProperties": false, + "properties": { + "actions": { "type": "array", "items": { "type": "object" } }, + "events": { "type": "array", "items": { "type": "object" } }, + "resources": { "type": "array", "items": { "type": "object" } }, + "signals": { "type": "array", "items": { "type": "object" } }, + "invokes": { "type": "array", "items": { "type": "string" } } + } + }, "dashboardCards": { "type": "array", "items": { diff --git a/packages/plugin-sdk/src/backend.ts b/packages/plugin-sdk/src/backend.ts index bef15d30ce..d6e685a144 100644 --- a/packages/plugin-sdk/src/backend.ts +++ b/packages/plugin-sdk/src/backend.ts @@ -14,6 +14,11 @@ import type { HostSshOptions } from "./ssh-options.js"; export type { HostSshOptions } from "./ssh-options.js"; import type { PluginManifest, PluginSettingsField } from "./manifest.js"; import type { PluginTableDefinition } from "./db.js"; +import type { + OperationResult, + OperationRisk, + OperationSurfaces, +} from "./operations.js"; /** * Thrown when a plugin calls a guarded member without the capability. @@ -3104,6 +3109,136 @@ export interface PluginPlugins { list: () => Promise; } +/** What a handler gets besides its input. */ +export interface PluginOperationContext { + hostId?: number; + /** The row a resource action targets. */ + resourceKey?: string; + /** Null for a background read. */ + runId: string | null; + /** Nothing may change when this is set. */ + dryRun: boolean; + /** Fires when the run is cancelled, times out or the plugin stops. */ + signal: AbortSignal; + log: (line: string) => void; +} + +export interface PluginOperationHandler { + run: ( + input: Record, + context: PluginOperationContext, + ) => Promise | OperationResult; + /** The dry run text for an action declared with dryRun "describe". */ + describe?: ( + input: Record, + context: PluginOperationContext, + ) => string | Promise; +} + +export interface PluginOperationInvokeOptions { + hostId?: number; + resourceKey?: string; + input?: Record; + dryRun?: boolean; + /** A person agreed to a destructive action. */ + confirmed?: boolean; + idempotencyKey?: string; + batchId?: string; + /** What this plugin is doing: automation, assistant, mcp, rollout and so on. */ + via?: string; +} + +export interface PluginOperationOutcome { + runId: string | null; + status: string; + summary?: string; + changed?: boolean; + data?: unknown; + error?: string; + errorCode?: string; + approvalId?: string; +} + +export interface PluginOperationApprovalInput { + actionId: string; + pluginId: string; + risk: OperationRisk; + /** What rules should use. Plugins not signed as official count as change. */ + effectiveRisk: OperationRisk; + origin: { kind: string; pluginId?: string; via?: string }; + userId: string; + hostId?: number; + hostTags?: string[]; + batchId?: string; +} + +export interface PluginOperationApprovalDecision { + decision: "allow" | "approve" | "deny"; + reason?: string; + allowSelf?: boolean; + expiresInMinutes?: number; +} + +export interface PluginOperationEvent { + id: string; + eventId: string; + pluginId: string; + hostId?: number; + severity?: string; + payload?: unknown; + runId?: string; + depth: number; + at: string; +} + +/** + * Declared actions and events. See contributes.operations in the manifest. + * register and emit need operations:provide. invoke, list and onEvent need + * operations:invoke, and invoke only reaches ids in the manifest's invokes list. + */ +export interface PluginOperations { + register: (id: string, handler: PluginOperationHandler) => void; + /** + * Runs one of this plugin's own actions from its route, so a button press + * goes through the same checks and lands in the same history. + */ + run: ( + actionId: string, + options?: PluginOperationInvokeOptions, + ) => Promise; + invoke: ( + actionId: string, + options?: PluginOperationInvokeOptions, + ) => Promise; + list: (options?: { + lang?: string; + surface?: keyof OperationSurfaces; + }) => Promise; + emit: ( + eventId: string, + payload?: unknown, + options?: { hostId?: number }, + ) => Promise; + onEvent: (subscription: { + pattern: string; + handler: (event: PluginOperationEvent) => void | Promise; + userId?: string; + cooldownMs?: number; + }) => () => void; + /** Rules for what needs a person. Needs operations:approve. */ + approvals: { + provide: ( + provider: ( + input: PluginOperationApprovalInput, + ) => + | PluginOperationApprovalDecision + | null + | undefined + | Promise, + ) => () => void; + }; +} + export interface PluginContext { readonly pluginId: string; readonly manifest: PluginManifest; @@ -3144,6 +3279,8 @@ export interface PluginContext { readonly schedule: PluginSchedule; /** Core's notification channels. Needs notify:send. */ readonly notify: PluginNotify; + /** Declared actions and events, reachable from every surface. */ + readonly operations: PluginOperations; /** Outbound HTTP through core's SSRF guard. Needs network:outbound. */ readonly fetch: PluginFetch; /** Plain reachability checks. Needs network:probe. */ diff --git a/packages/plugin-sdk/src/capabilities.ts b/packages/plugin-sdk/src/capabilities.ts index e03def44c3..3855fe5893 100644 --- a/packages/plugin-sdk/src/capabilities.ts +++ b/packages/plugin-sdk/src/capabilities.ts @@ -52,6 +52,8 @@ export const CAPABILITY_CATALOG: readonly CapabilityInfo[] = [ entry("ssh:transport", "high"), entry("device:serial", "high"), entry("notify:hub", "high"), + entry("operations:invoke", "high"), + entry("operations:approve", "high"), entry("ui:layout", "high"), entry("hosts:write", "medium"), @@ -68,6 +70,7 @@ export const CAPABILITY_CATALOG: readonly CapabilityInfo[] = [ entry("users:read", "medium"), entry("events:core", "medium"), entry("notify:send", "medium"), + entry("operations:provide", "medium"), entry("audit:read", "medium"), entry("desktop:window", "medium"), entry("desktop:local", "high"), diff --git a/packages/plugin-sdk/src/manifest.ts b/packages/plugin-sdk/src/manifest.ts index 9ae79dcc27..023a61adbd 100644 --- a/packages/plugin-sdk/src/manifest.ts +++ b/packages/plugin-sdk/src/manifest.ts @@ -12,6 +12,11 @@ import semver from "semver"; import { isKnownCapability } from "./capabilities.js"; +import { + checkOperationReferences, + validateOperations, + type PluginOperationsContribution, +} from "./operations.js"; import { ENV_NAME_PATTERN, MAX_ENV_DESCRIPTION_LENGTH, @@ -37,7 +42,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.13.0"; +export const PLUGIN_API_VERSION = "1.14.0"; /** The API major, the default engine.api for a new plugin. */ export const SUPPORTED_PLUGIN_API_VERSION = String( @@ -465,6 +470,10 @@ export interface PluginContributions { tabs?: PluginTabContribution[]; panels?: PluginViewContribution[]; dashboardCards?: PluginViewContribution[]; + /** Cards on the host page, filled with app.registerHostSection. */ + hostSections?: PluginViewContribution[]; + /** What this plugin can do, for every surface to find on its own. */ + operations?: PluginOperationsContribution; /** * The frontend also runs on anonymous guest pages (shared-session and * collab links), where it is activated with app.guest set. @@ -779,6 +788,8 @@ const ALLOWED_CONTRIBUTES = new Set([ "tabs", "panels", "dashboardCards", + "hostSections", + "operations", "guest", "guestViews", "actions", @@ -1500,6 +1511,8 @@ function validateContributes( validateTabs(contributes.tabs, errors); validateViews(contributes.panels, "panels", errors); validateViews(contributes.dashboardCards, "dashboardCards", errors); + validateViews(contributes.hostSections, "hostSections", errors); + validateOperations(contributes.operations, pluginId, errors); if ( contributes.guest !== undefined && typeof contributes.guest !== "boolean" @@ -2622,6 +2635,12 @@ export function parseManifest(raw: unknown): ParsedManifest { ); } } + checkOperationReferences( + manifest.contributes?.operations, + manifest.capabilities, + (name) => declared.has(qualifyPermission(manifest.id, name)), + errors, + ); // A settings field may gate on one of this plugin's own permissions, or on // a core/other-plugin id used as given. Only the first form is checkable // here, and an undeclared one would hide the field from every admin. diff --git a/packages/plugin-sdk/src/operations.ts b/packages/plugin-sdk/src/operations.ts new file mode 100644 index 0000000000..149ebd85d8 --- /dev/null +++ b/packages/plugin-sdk/src/operations.ts @@ -0,0 +1,951 @@ +/** + * The operations contract: what a plugin declares it can do, in one shape every + * surface reads (Automations, Fleets, AI, CLI, Mobile, palette, API, MCP). + * + * Declarations are data in the manifest, so a surface can list and describe an + * action without loading the plugin's code. Handlers are registered at + * activation with ctx.operations.register and must match a declaration. + * + * No Node imports: the frontend, the CLI and Mobile read this file too. + */ + +import { coerceSettingValue, validateSettingValue } from "./settings.js"; +import type { PluginSettingsField, PluginSettingsOption } from "./manifest.js"; + +export const OPERATION_RISKS = ["read", "change", "destructive"] as const; +export type OperationRisk = (typeof OPERATION_RISKS)[number]; + +export const OPERATION_SCOPES = ["host", "resource", "server"] as const; +export type OperationScope = (typeof OPERATION_SCOPES)[number]; + +export const OPERATION_HOST_ACCESS = [ + "connect", + "view", + "edit", + "manage", +] as const; +export type OperationHostAccess = (typeof OPERATION_HOST_ACCESS)[number]; + +export const OPERATION_DRY_RUN = ["native", "describe"] as const; +/** native: the handler checks ctx.dryRun. describe: core reports its text. */ +export type OperationDryRun = (typeof OPERATION_DRY_RUN)[number]; + +export const OPERATION_FIELD_TYPES = [ + "boolean", + "string", + "number", + "select", + "multiselect", + "secret", + "textarea", + "json", + "resource", +] as const; +export type OperationFieldType = (typeof OPERATION_FIELD_TYPES)[number]; + +export const OPERATION_SEVERITIES = ["info", "warning", "critical"] as const; +export type OperationSeverity = (typeof OPERATION_SEVERITIES)[number]; + +export const SIGNAL_KINDS = ["count", "gauge", "status", "list"] as const; +export type SignalKind = (typeof SIGNAL_KINDS)[number]; + +export const SIGNAL_STATUSES = ["ok", "warn", "fail", "unknown"] as const; +export type SignalStatus = (typeof SIGNAL_STATUSES)[number]; + +export const RESOURCE_COLUMN_TYPES = [ + "text", + "status", + "bytes", + "percent", + "time", + "tags", +] as const; +export type ResourceColumnType = (typeof RESOURCE_COLUMN_TYPES)[number]; + +/** One input or output of an action. A subset of a settings field, plus rules. */ +export interface OperationField { + key: string; + type: OperationFieldType; + labelKey?: string; + descriptionKey?: string; + placeholderKey?: string; + default?: unknown; + options?: PluginSettingsOption[]; + min?: number; + max?: number; + /** Key of a boolean field in the same list that must be on. */ + requires?: string; + group?: string; + required?: boolean; + /** string and textarea only. A regular expression the whole value matches. */ + pattern?: string; + /** Read action whose rows fill the choices, for a list known only at run time. */ + optionsFrom?: string; + /** type "resource" only: the resource id whose list fills the picker. */ + resource?: string; + /** Outputs only: dropped before a run is stored or shown. */ + sensitive?: boolean; +} + +export interface OperationWhen { + /** Host setting values that must match, such as { enableDocker: true }. */ + settings?: Record; + /** Lowercase OS names. A host with no known OS passes. */ + os?: string[]; +} + +export interface OperationSurfaces { + /** Offered to the AI assistant. Off unless set. */ + assistant?: boolean; + /** Offered over MCP. Off unless set. */ + mcp?: boolean; + /** Set false to keep out of the command palette. */ + palette?: boolean; + /** Set false to keep out of Automations. */ + automations?: boolean; + /** Set false to keep out of the phone app. */ + mobile?: boolean; + /** Set false to keep out of the CLI. */ + cli?: boolean; +} + +export interface OperationAction { + /** `.`. */ + id: string; + /** Bump when inputs change in a way saved references cannot follow. */ + version?: number; + titleKey: string; + descriptionKey?: string; + icon?: string; + scope: OperationScope; + /** Required for scope "resource": the resource this acts on. */ + resource?: string; + inputs?: OperationField[]; + outputs?: OperationField[]; + risk: OperationRisk; + /** One of this plugin's own permissions, short or full. */ + permission: string; + /** Lowest share level. Required for host and resource scopes. */ + hostAccess?: OperationHostAccess; + dryRun?: OperationDryRun; + /** A read action of this plugin that proves the result. */ + check?: string; + /** An action of this plugin that reverses this one. */ + undo?: string; + /** Safe to run twice, so a rollout may retry it. */ + idempotent?: boolean; + /** Only one such action per host at a time. */ + exclusive?: boolean; + timeoutMs?: number; + when?: OperationWhen; + surfaces?: OperationSurfaces; +} + +export interface OperationEvent { + id: string; + titleKey: string; + descriptionKey?: string; + severity?: OperationSeverity; + scope: "host" | "server"; + payload?: OperationField[]; +} + +export interface ResourceColumn { + key: string; + titleKey: string; + type?: ResourceColumnType; +} + +export interface OperationResource { + id: string; + titleKey: string; + icon?: string; + /** Read action that returns { rows: [...] } for one host. */ + list: string; + /** Row field that is unique on a host. */ + key: string; + /** Row field shown as the name. */ + name: string; + /** Row field holding a status word. */ + status?: string; + /** Row field holding a link, for auto links on the homepage. */ + url?: string; + /** Tab type that opens this on a host. */ + tab?: string; + columns?: ResourceColumn[]; + filters?: string[]; +} + +export interface OperationSignal { + id: string; + titleKey: string; + /** Read action that returns a SignalResult. */ + read: string; + scope: "host" | "server"; + kind: SignalKind; + refreshSec?: number; + /** Action offered as the fix when this signal is not ok. */ + fix?: string; +} + +export interface PluginOperationsContribution { + actions?: OperationAction[]; + events?: OperationEvent[]; + resources?: OperationResource[]; + signals?: OperationSignal[]; + /** + * Actions this plugin may run through ctx.operations.invoke: exact ids, + * `.*`, or `*`. + */ + invokes?: string[]; +} + +/** What a signal's read action returns. */ +export interface SignalResult { + value?: number | string | null; + status: SignalStatus; + label?: string; + items?: { label: string; status?: SignalStatus }[]; + link?: { tab?: string; data?: Record }; +} + +/** What a resource's list action returns. */ +export interface ResourceListResult { + rows: Record[]; +} + +/** What a handler returns. */ +export interface OperationResult { + /** One plain line for history and per host results. */ + summary: string; + /** Whether anything actually changed. */ + changed?: boolean; + data?: unknown; + /** Input for the `undo` action that reverses this exact run. */ + undoInput?: Record; +} + +export const OPERATION_ID_PATTERN = /^[a-z0-9][a-z0-9-]*\.[a-z0-9][a-z0-9-]*$/; + +const FIELD_KEY_PATTERN = /^[a-zA-Z][a-zA-Z0-9_-]{0,63}$/; +const INVOKE_PATTERN = /^(\*|[a-z0-9][a-z0-9-]*\.(\*|[a-z0-9][a-z0-9-]*))$/; + +export const MAX_OPERATION_TIMEOUT_MS = 3_600_000; +export const REDACTED_VALUE = "[redacted]"; + +const ALLOWED_OPERATIONS = [ + "actions", + "events", + "resources", + "signals", + "invokes", +]; +const ALLOWED_ACTION = [ + "id", + "version", + "titleKey", + "descriptionKey", + "icon", + "scope", + "resource", + "inputs", + "outputs", + "risk", + "permission", + "hostAccess", + "dryRun", + "check", + "undo", + "idempotent", + "exclusive", + "timeoutMs", + "when", + "surfaces", +]; +const ALLOWED_FIELD = [ + "key", + "type", + "labelKey", + "descriptionKey", + "placeholderKey", + "default", + "options", + "min", + "max", + "requires", + "group", + "required", + "pattern", + "optionsFrom", + "resource", + "sensitive", +]; +const ALLOWED_EVENT = [ + "id", + "titleKey", + "descriptionKey", + "severity", + "scope", + "payload", +]; +const ALLOWED_RESOURCE = [ + "id", + "titleKey", + "icon", + "list", + "key", + "name", + "status", + "url", + "tab", + "columns", + "filters", +]; +const ALLOWED_SIGNAL = [ + "id", + "titleKey", + "read", + "scope", + "kind", + "refreshSec", + "fix", +]; +const ALLOWED_SURFACES = [ + "assistant", + "mcp", + "palette", + "automations", + "mobile", + "cli", +]; + +function isObject(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function rejectUnknown( + raw: Record, + allowed: string[], + where: string, + errors: string[], +): void { + for (const key of Object.keys(raw)) { + if (!allowed.includes(key)) + errors.push(`Unknown field "${key}" in ${where}`); + } +} + +function requireEnum( + value: unknown, + allowed: readonly string[], + where: string, + errors: string[], +): void { + if (typeof value !== "string" || !allowed.includes(value)) { + errors.push(`${where} must be one of: ${allowed.join(", ")}`); + } +} + +function requireText(value: unknown, where: string, errors: string[]): void { + if (typeof value !== "string" || value.trim().length === 0) { + errors.push(`${where} must be a non-empty string`); + } +} + +function optionalText(value: unknown, where: string, errors: string[]): void { + if (value !== undefined) requireText(value, where, errors); +} + +function optionalBoolean( + value: unknown, + where: string, + errors: string[], +): void { + if (value !== undefined && typeof value !== "boolean") { + errors.push(`${where} must be a boolean`); + } +} + +function requireId( + value: unknown, + pluginId: string | undefined, + where: string, + seen: Set, + errors: string[], +): void { + if (typeof value !== "string" || !OPERATION_ID_PATTERN.test(value)) { + errors.push(`${where}.id must look like "." in lowercase`); + return; + } + if (pluginId && !value.startsWith(`${pluginId}.`)) { + errors.push(`${where}.id must start with "${pluginId}."`); + } + if (seen.has(value)) errors.push(`${where}.id "${value}" is declared twice`); + seen.add(value); +} + +function eachObject( + value: unknown, + where: string, + errors: string[], + each: (raw: Record, at: string) => void, +): void { + if (value === undefined) return; + if (!Array.isArray(value)) { + errors.push(`${where} must be an array`); + return; + } + value.forEach((raw, index) => { + const at = `${where}[${index}]`; + if (!isObject(raw)) { + errors.push(`${at} must be an object`); + return; + } + each(raw, at); + }); +} + +function validateFields(value: unknown, where: string, errors: string[]): void { + const keys = new Set(); + eachObject(value, where, errors, (raw, at) => { + rejectUnknown(raw, ALLOWED_FIELD, at, errors); + if (typeof raw.key !== "string" || !FIELD_KEY_PATTERN.test(raw.key)) { + errors.push(`${at}.key must be a short name starting with a letter`); + } else if (keys.has(raw.key)) { + errors.push(`${at}.key "${raw.key}" is declared twice`); + } else { + keys.add(raw.key); + } + requireEnum(raw.type, OPERATION_FIELD_TYPES, `${at}.type`, errors); + optionalText(raw.labelKey, `${at}.labelKey`, errors); + optionalText(raw.descriptionKey, `${at}.descriptionKey`, errors); + optionalText(raw.placeholderKey, `${at}.placeholderKey`, errors); + optionalText(raw.group, `${at}.group`, errors); + optionalText(raw.requires, `${at}.requires`, errors); + optionalText(raw.optionsFrom, `${at}.optionsFrom`, errors); + optionalBoolean(raw.required, `${at}.required`, errors); + optionalBoolean(raw.sensitive, `${at}.sensitive`, errors); + if (raw.type === "resource" && typeof raw.resource !== "string") { + errors.push(`${at}.resource is required for type "resource"`); + } + if (raw.resource !== undefined && raw.type !== "resource") { + errors.push(`${at}.resource needs type "resource"`); + } + if (raw.type === "secret" && raw.default !== undefined) { + errors.push(`${at}.default is not allowed on a secret`); + } + if ( + (raw.type === "select" || raw.type === "multiselect") && + !raw.optionsFrom && + (!Array.isArray(raw.options) || raw.options.length === 0) + ) { + errors.push(`${at} needs options or optionsFrom`); + } + for (const bound of ["min", "max"] as const) { + if (raw[bound] !== undefined && typeof raw[bound] !== "number") { + errors.push(`${at}.${bound} must be a number`); + } + } + if (raw.pattern !== undefined) { + if (typeof raw.pattern !== "string") { + errors.push(`${at}.pattern must be a string`); + } else { + try { + new RegExp(raw.pattern); + } catch { + errors.push(`${at}.pattern is not a valid regular expression`); + } + } + } + }); +} + +function validateWhen(value: unknown, at: string, errors: string[]): void { + if (value === undefined) return; + if (!isObject(value)) { + errors.push(`${at} must be an object`); + return; + } + rejectUnknown(value, ["settings", "os"], at, errors); + if (value.settings !== undefined && !isObject(value.settings)) { + errors.push(`${at}.settings must be an object`); + } + if ( + value.os !== undefined && + (!Array.isArray(value.os) || + value.os.some((entry) => typeof entry !== "string")) + ) { + errors.push(`${at}.os must be an array of strings`); + } +} + +function validateAction( + raw: Record, + at: string, + errors: string[], +): void { + rejectUnknown(raw, ALLOWED_ACTION, at, errors); + requireText(raw.titleKey, `${at}.titleKey`, errors); + optionalText(raw.descriptionKey, `${at}.descriptionKey`, errors); + optionalText(raw.icon, `${at}.icon`, errors); + requireEnum(raw.scope, OPERATION_SCOPES, `${at}.scope`, errors); + requireEnum(raw.risk, OPERATION_RISKS, `${at}.risk`, errors); + requireText(raw.permission, `${at}.permission`, errors); + validateFields(raw.inputs, `${at}.inputs`, errors); + validateFields(raw.outputs, `${at}.outputs`, errors); + validateWhen(raw.when, `${at}.when`, errors); + optionalBoolean(raw.idempotent, `${at}.idempotent`, errors); + optionalBoolean(raw.exclusive, `${at}.exclusive`, errors); + + if ( + raw.version !== undefined && + (!Number.isInteger(raw.version) || (raw.version as number) < 1) + ) { + errors.push(`${at}.version must be a whole number from 1`); + } + if (raw.dryRun !== undefined) { + requireEnum(raw.dryRun, OPERATION_DRY_RUN, `${at}.dryRun`, errors); + } + if ( + raw.timeoutMs !== undefined && + (typeof raw.timeoutMs !== "number" || + raw.timeoutMs <= 0 || + raw.timeoutMs > MAX_OPERATION_TIMEOUT_MS) + ) { + errors.push(`${at}.timeoutMs must be above 0 and at most 1 hour`); + } + if (raw.scope === "resource" && typeof raw.resource !== "string") { + errors.push(`${at}.resource is required for scope "resource"`); + } + if (raw.scope !== "resource" && raw.resource !== undefined) { + errors.push(`${at}.resource needs scope "resource"`); + } + if (raw.scope === "server" && raw.hostAccess !== undefined) { + errors.push(`${at}.hostAccess does not apply to scope "server"`); + } + if (raw.scope === "host" || raw.scope === "resource") { + requireEnum( + raw.hostAccess, + OPERATION_HOST_ACCESS, + `${at}.hostAccess`, + errors, + ); + if ( + raw.risk === "destructive" && + (raw.hostAccess === "connect" || raw.hostAccess === "view") + ) { + errors.push(`${at}.hostAccess must be edit or manage when destructive`); + } + } + if (raw.surfaces !== undefined) { + if (!isObject(raw.surfaces)) { + errors.push(`${at}.surfaces must be an object`); + } else { + rejectUnknown(raw.surfaces, ALLOWED_SURFACES, `${at}.surfaces`, errors); + for (const [key, value] of Object.entries(raw.surfaces)) { + optionalBoolean(value, `${at}.surfaces.${key}`, errors); + } + } + } +} + +function validateResource( + raw: Record, + at: string, + errors: string[], +): void { + rejectUnknown(raw, ALLOWED_RESOURCE, at, errors); + requireText(raw.titleKey, `${at}.titleKey`, errors); + requireText(raw.list, `${at}.list`, errors); + requireText(raw.key, `${at}.key`, errors); + requireText(raw.name, `${at}.name`, errors); + optionalText(raw.icon, `${at}.icon`, errors); + optionalText(raw.status, `${at}.status`, errors); + optionalText(raw.url, `${at}.url`, errors); + optionalText(raw.tab, `${at}.tab`, errors); + eachObject(raw.columns, `${at}.columns`, errors, (column, where) => { + rejectUnknown(column, ["key", "titleKey", "type"], where, errors); + requireText(column.key, `${where}.key`, errors); + requireText(column.titleKey, `${where}.titleKey`, errors); + if (column.type !== undefined) { + requireEnum(column.type, RESOURCE_COLUMN_TYPES, `${where}.type`, errors); + } + }); + if ( + raw.filters !== undefined && + (!Array.isArray(raw.filters) || + raw.filters.some((entry) => typeof entry !== "string")) + ) { + errors.push(`${at}.filters must be an array of strings`); + } +} + +/** Shape checks for contributes.operations, run by validateManifest. */ +export function validateOperations( + value: unknown, + pluginId: string | undefined, + errors: string[], +): void { + if (value === undefined) return; + const root = "contributes.operations"; + if (!isObject(value)) { + errors.push(`Field "${root}" must be an object`); + return; + } + rejectUnknown(value, ALLOWED_OPERATIONS, `"${root}"`, errors); + + const ids = new Set(); + eachObject(value.actions, `${root}.actions`, errors, (raw, at) => { + requireId(raw.id, pluginId, at, ids, errors); + validateAction(raw, at, errors); + }); + + const eventIds = new Set(); + eachObject(value.events, `${root}.events`, errors, (raw, at) => { + requireId(raw.id, pluginId, at, eventIds, errors); + rejectUnknown(raw, ALLOWED_EVENT, at, errors); + requireText(raw.titleKey, `${at}.titleKey`, errors); + optionalText(raw.descriptionKey, `${at}.descriptionKey`, errors); + requireEnum(raw.scope, ["host", "server"], `${at}.scope`, errors); + if (raw.severity !== undefined) { + requireEnum(raw.severity, OPERATION_SEVERITIES, `${at}.severity`, errors); + } + validateFields(raw.payload, `${at}.payload`, errors); + }); + + const resourceIds = new Set(); + eachObject(value.resources, `${root}.resources`, errors, (raw, at) => { + requireId(raw.id, pluginId, at, resourceIds, errors); + validateResource(raw, at, errors); + }); + + const signalIds = new Set(); + eachObject(value.signals, `${root}.signals`, errors, (raw, at) => { + requireId(raw.id, pluginId, at, signalIds, errors); + rejectUnknown(raw, ALLOWED_SIGNAL, at, errors); + requireText(raw.titleKey, `${at}.titleKey`, errors); + requireText(raw.read, `${at}.read`, errors); + requireEnum(raw.scope, ["host", "server"], `${at}.scope`, errors); + requireEnum(raw.kind, SIGNAL_KINDS, `${at}.kind`, errors); + optionalText(raw.fix, `${at}.fix`, errors); + if ( + raw.refreshSec !== undefined && + (typeof raw.refreshSec !== "number" || raw.refreshSec < 10) + ) { + errors.push(`${at}.refreshSec must be at least 10`); + } + }); + + if (value.invokes !== undefined) { + if ( + !Array.isArray(value.invokes) || + value.invokes.some( + (entry) => typeof entry !== "string" || !INVOKE_PATTERN.test(entry), + ) + ) { + errors.push( + `${root}.invokes must list action ids, ".*" or "*"`, + ); + } + } +} + +/** + * The cross-field rules: every id one declaration names must exist in this + * plugin, or a surface would offer a button that goes nowhere. + */ +export function checkOperationReferences( + operations: PluginOperationsContribution | undefined, + capabilities: readonly string[], + hasPermission: (name: string) => boolean, + errors: string[], +): void { + if (!operations) return; + const root = "contributes.operations"; + const actions = new Map( + (operations.actions ?? []).map((action) => [action.id, action]), + ); + const resources = new Set((operations.resources ?? []).map((r) => r.id)); + + const declaresAnything = + actions.size > 0 || + (operations.events?.length ?? 0) > 0 || + resources.size > 0 || + (operations.signals?.length ?? 0) > 0; + if (declaresAnything && !capabilities.includes("operations:provide")) { + errors.push(`${root} needs the "operations:provide" capability`); + } + if ( + operations.invokes?.length && + !capabilities.includes("operations:invoke") + ) { + errors.push(`${root}.invokes needs the "operations:invoke" capability`); + } + + const readAction = (id: string, where: string) => { + const target = actions.get(id); + if (!target) errors.push(`${where} "${id}" is not a declared action`); + else if (target.risk !== "read") { + errors.push(`${where} "${id}" must be a read action`); + } + }; + + for (const action of actions.values()) { + const at = `${root}.actions["${action.id}"]`; + if (!hasPermission(action.permission)) { + errors.push( + `${at}.permission "${action.permission}" is not declared in contributes.permissions`, + ); + } + if (action.resource && !resources.has(action.resource)) { + errors.push(`${at}.resource "${action.resource}" is not declared`); + } + if (action.check) readAction(action.check, `${at}.check`); + if (action.undo && !actions.has(action.undo)) { + errors.push(`${at}.undo "${action.undo}" is not a declared action`); + } + for (const field of action.inputs ?? []) { + if (field.optionsFrom) { + readAction(field.optionsFrom, `${at} input "${field.key}" optionsFrom`); + } + if (field.resource && !resources.has(field.resource)) { + errors.push(`${at} input "${field.key}" names an unknown resource`); + } + } + } + for (const resource of operations.resources ?? []) { + readAction(resource.list, `${root}.resources["${resource.id}"].list`); + } + for (const signal of operations.signals ?? []) { + const at = `${root}.signals["${signal.id}"]`; + readAction(signal.read, `${at}.read`); + if (signal.fix && !actions.has(signal.fix)) { + errors.push(`${at}.fix "${signal.fix}" is not a declared action`); + } + } +} + +/** Whether a plugin's invokes list lets it run an action. */ +export function invokeAllowed( + invokes: readonly string[] | undefined, + actionId: string, +): boolean { + for (const entry of invokes ?? []) { + if (entry === "*" || entry === actionId) return true; + if (entry.endsWith(".*") && actionId.startsWith(entry.slice(0, -1))) { + return true; + } + } + return false; +} + +const RISK_RANK: Record = { + read: 0, + change: 1, + destructive: 2, +}; + +export function riskRank(risk: OperationRisk): number { + return RISK_RANK[risk]; +} + +/** A surface's own switch. The assistant and MCP are opt in, the rest opt out. */ +export function onSurface( + action: Pick, + surface: keyof OperationSurfaces, +): boolean { + const value = action.surfaces?.[surface]; + if (surface === "assistant" || surface === "mcp") return value === true; + return value !== false; +} + +/** Whether an action applies to a host, from fields any client can read. */ +export function matchesWhen( + when: OperationWhen | undefined, + host: { settings?: Record; os?: string | null }, +): boolean { + if (!when) return true; + for (const [key, expected] of Object.entries(when.settings ?? {})) { + if (host.settings?.[key] !== expected) return false; + } + if (when.os?.length && host.os) { + if (!when.os.includes(host.os.toLowerCase())) return false; + } + return true; +} + +function asSettingsField(field: OperationField): PluginSettingsField { + const type = field.type === "resource" ? "string" : field.type; + return { ...field, type } as PluginSettingsField; +} + +function isEmpty(value: unknown): boolean { + return value === undefined || value === null || value === ""; +} + +export interface OperationInputResult { + values: Record; + errors: string[]; +} + +/** + * Turns raw input into validated values. Keys no field declares are refused, + * so nothing a handler did not ask for ever reaches it. + */ +export function parseOperationInput( + fields: readonly OperationField[] | undefined, + raw: unknown, +): OperationInputResult { + const errors: string[] = []; + const values: Record = {}; + const list = fields ?? []; + + if (raw !== undefined && raw !== null && !isObject(raw)) { + return { values, errors: ["Input must be an object"] }; + } + const input = (raw ?? {}) as Record; + for (const key of Object.keys(input)) { + if (!list.some((field) => field.key === key)) { + errors.push(`"${key}" is not an input of this action`); + } + } + + for (const field of list) { + if (field.requires && input[field.requires] !== true) continue; + + let value = input[field.key]; + if (isEmpty(value)) { + if (field.default !== undefined) { + values[field.key] = field.default; + } else if (field.required) { + errors.push(`"${field.key}" is required`); + } + continue; + } + + const settingsField = asSettingsField(field); + value = coerceSettingValue(settingsField, value); + + if (field.type === "secret" && typeof value !== "string") { + errors.push(`"${field.key}" must be a string`); + continue; + } + if (field.type === "json" && typeof value === "string") { + try { + value = JSON.parse(value); + } catch { + errors.push(`"${field.key}" must be valid JSON`); + continue; + } + } + + // A list known only at run time is checked by the handler. + const skipChoices = + field.optionsFrom && + (field.type === "select" || field.type === "multiselect"); + const problem = skipChoices + ? field.type === "select" + ? typeof value === "string" + ? null + : `"${field.key}" must be a string` + : Array.isArray(value) + ? null + : `"${field.key}" must be an array` + : validateSettingValue(settingsField, value); + if (problem) { + errors.push(problem); + continue; + } + + if ( + field.pattern && + typeof value === "string" && + !new RegExp(`^(?:${field.pattern})$`).test(value) + ) { + errors.push(`"${field.key}" has the wrong format`); + continue; + } + values[field.key] = value; + } + + return { values, errors }; +} + +/** A copy safe to store or show: secret and sensitive values are replaced. */ +export function redactFields( + fields: readonly OperationField[] | undefined, + values: unknown, +): unknown { + if (!isObject(values)) return values; + const copy: Record = { ...values }; + for (const field of fields ?? []) { + if (field.type !== "secret" && !field.sensitive) continue; + if (field.key in copy) copy[field.key] = REDACTED_VALUE; + } + return copy; +} + +/** Trims text to a byte-friendly length with a plain marker. */ +export function capText(text: string, maxLength: number): string { + return text.length <= maxLength + ? text + : `${text.slice(0, maxLength)}\n[cut at ${maxLength} characters]`; +} + +function fieldSchema(field: OperationField): Record { + const schema: Record = {}; + if (field.default !== undefined) schema.default = field.default; + const values = field.optionsFrom + ? undefined + : field.options?.map((option) => option.value); + + switch (field.type) { + case "boolean": + schema.type = "boolean"; + break; + case "number": + schema.type = "number"; + if (field.min !== undefined) schema.minimum = field.min; + if (field.max !== undefined) schema.maximum = field.max; + break; + case "select": + schema.type = "string"; + if (values) schema.enum = values; + break; + case "multiselect": + schema.type = "array"; + schema.items = values + ? { type: "string", enum: values } + : { type: "string" }; + break; + case "json": + break; + default: + schema.type = "string"; + if (field.pattern) schema.pattern = `^(?:${field.pattern})$`; + } + if (field.type === "resource" && field.resource) { + schema["x-termix-resource"] = field.resource; + } + if (field.type === "secret") schema.writeOnly = true; + return schema; +} + +/** JSON Schema for a field list, for AI tools, MCP and OpenAPI. */ +export function toJsonSchema( + fields: readonly OperationField[] | undefined, + describe?: (field: OperationField) => string | undefined, +): Record { + const properties: Record = {}; + const required: string[] = []; + for (const field of fields ?? []) { + const schema = fieldSchema(field); + const description = describe?.(field); + if (description) schema.description = description; + properties[field.key] = schema; + if (field.required && field.default === undefined && !field.requires) { + required.push(field.key); + } + } + return { type: "object", properties, required, additionalProperties: false }; +} diff --git a/packages/plugin-sdk/src/testing.ts b/packages/plugin-sdk/src/testing.ts index 7b0d3f0533..d56ca10e55 100644 --- a/packages/plugin-sdk/src/testing.ts +++ b/packages/plugin-sdk/src/testing.ts @@ -68,7 +68,11 @@ import type { PluginSshKeyCredential, PluginSshKeyCredentialInput, } from "./backend.js"; -import type { PluginSyncHost, SyncEntityRegistration } from "./backend.js"; +import type { + PluginOperationHandler, + PluginSyncHost, + SyncEntityRegistration, +} from "./backend.js"; import type { PluginDatabase } from "./backend.js"; import type { PluginExternalClientRequest, @@ -355,6 +359,10 @@ export interface FakePluginContext { }>; /** The hub the plugin registered with ctx.notify.serve, if any. */ notifyHub: { current: PluginNotifyHub | null }; + /** Handlers from ctx.operations.register, by action id. */ + operationHandlers: Map; + /** Every ctx.operations.emit call. */ + operationEvents: { eventId: string; payload: unknown; hostId?: number }[]; /** Backing store behind ctx.secrets.get/set, keyed ":". */ secretStore: Map; /** Every ctx.fetch call, in order. */ @@ -566,6 +574,8 @@ export function createFakeContext( const createdSshKeys: FakePluginContext["createdSshKeys"] = []; const notifications: FakePluginContext["notifications"] = []; const notifyHub: FakePluginContext["notifyHub"] = { current: null }; + const operationHandlers: FakePluginContext["operationHandlers"] = new Map(); + const operationEvents: FakePluginContext["operationEvents"] = []; const fetches: FakePluginContext["fetches"] = []; const processRuns: FakePluginContext["processRuns"] = []; const binaries: FakePluginContext["binaries"] = []; @@ -1391,6 +1401,46 @@ export function createFakeContext( }, }, + operations: { + register: (id, handler) => { + operationHandlers.set(id, handler); + }, + run: async (actionId, options = {}) => { + const handler = operationHandlers.get(actionId); + if (!handler) throw new Error(`No action "${actionId}"`); + const result = await handler.run(options.input ?? {}, { + hostId: options.hostId, + resourceKey: options.resourceKey, + runId: null, + dryRun: options.dryRun === true, + signal: new AbortController().signal, + log: () => {}, + }); + return { runId: null, status: "success", ...result }; + }, + invoke: async (actionId, options = {}) => { + const handler = operationHandlers.get(actionId); + if (!handler) throw new Error(`No action "${actionId}"`); + const result = await handler.run(options.input ?? {}, { + hostId: options.hostId, + resourceKey: options.resourceKey, + runId: null, + dryRun: options.dryRun === true, + signal: new AbortController().signal, + log: () => {}, + }); + return { runId: null, status: "success", ...result }; + }, + list: async () => ({ + actions: manifest.contributes?.operations?.actions ?? [], + }), + emit: async (eventId, payload, options) => { + operationEvents.push({ eventId, payload, hostId: options?.hostId }); + }, + onEvent: () => () => {}, + approvals: { provide: () => () => {} }, + }, + notify: { channels: async () => { if (!actor) throw new Error("ctx.notify needs an acting user"); @@ -1554,6 +1604,8 @@ export function createFakeContext( createdSshKeys, notifications, notifyHub, + operationHandlers, + operationEvents, fetches, processRuns, binaries, diff --git a/src/backend/database/database.ts b/src/backend/database/database.ts index 94b3b76e03..0ec0e5142e 100644 --- a/src/backend/database/database.ts +++ b/src/backend/database/database.ts @@ -30,6 +30,7 @@ import { registerAuditLogRoutes } from "./routes/audit-log-routes.js"; import { syncForwarder } from "../sync/provider.js"; import { syncChangeWatcher } from "../sync/server/change-watcher.js"; import dashboardRoutes from "./routes/dashboard-routes.js"; +import operationsRoutes from "./routes/operations-routes.js"; import { mountPluginApi, mountPluginLegacyPaths, @@ -1627,6 +1628,7 @@ app.use("/ui-preferences", uiPreferencesRoutes); registerAuditLogRoutes(app, authenticateJWT); app.use("/sync", syncForwarder); app.use("/dashboard", dashboardRoutes); +app.use("/operations", operationsRoutes); app.use("/plugins", pluginRoutes); app.use( "/plugin-assets", diff --git a/src/backend/database/db/index.ts b/src/backend/database/db/index.ts index c70cef2ac9..c4bc338924 100644 --- a/src/backend/database/db/index.ts +++ b/src/backend/database/db/index.ts @@ -599,6 +599,70 @@ async function initializeCompleteDatabase(): Promise { UNIQUE (role_name, permission) ); + CREATE TABLE IF NOT EXISTS operation_runs ( + id TEXT PRIMARY KEY, + action_id TEXT NOT NULL, + action_version INTEGER NOT NULL DEFAULT 1, + plugin_id TEXT NOT NULL, + plugin_version TEXT NOT NULL, + user_id TEXT REFERENCES users (id) ON DELETE SET NULL, + username TEXT NOT NULL, + origin TEXT NOT NULL, + host_id INTEGER, + host_name TEXT, + batch_id TEXT, + dry_run INTEGER NOT NULL DEFAULT 0, + risk TEXT NOT NULL, + status TEXT NOT NULL, + summary TEXT, + changed INTEGER, + input TEXT, + sealed_input TEXT, + output TEXT, + undo_input TEXT, + error TEXT, + error_code TEXT, + undo_of TEXT, + idempotency_key TEXT, + created_at TEXT NOT NULL, + started_at TEXT, + finished_at TEXT + ); + CREATE INDEX IF NOT EXISTS idx_operation_runs_created ON operation_runs (created_at); + CREATE INDEX IF NOT EXISTS idx_operation_runs_user_created ON operation_runs (user_id, created_at); + CREATE INDEX IF NOT EXISTS idx_operation_runs_host_created ON operation_runs (host_id, created_at); + CREATE INDEX IF NOT EXISTS idx_operation_runs_status ON operation_runs (status); + CREATE INDEX IF NOT EXISTS idx_operation_runs_batch ON operation_runs (batch_id); + + CREATE TABLE IF NOT EXISTS operation_approvals ( + id TEXT PRIMARY KEY, + run_id TEXT NOT NULL REFERENCES operation_runs (id) ON DELETE CASCADE, + requested_by TEXT, + reason TEXT, + allow_self INTEGER NOT NULL DEFAULT 0, + status TEXT NOT NULL, + decided_by TEXT, + comment TEXT, + created_at TEXT NOT NULL, + expires_at TEXT NOT NULL, + decided_at TEXT + ); + CREATE INDEX IF NOT EXISTS idx_operation_approvals_status ON operation_approvals (status, expires_at); + CREATE INDEX IF NOT EXISTS idx_operation_approvals_run ON operation_approvals (run_id); + + CREATE TABLE IF NOT EXISTS operation_events ( + id TEXT PRIMARY KEY, + event_id TEXT NOT NULL, + plugin_id TEXT NOT NULL, + host_id INTEGER, + severity TEXT, + payload TEXT, + run_id TEXT, + created_at TEXT NOT NULL + ); + CREATE INDEX IF NOT EXISTS idx_operation_events_created ON operation_events (created_at); + CREATE INDEX IF NOT EXISTS idx_operation_events_host_created ON operation_events (host_id, created_at); + CREATE TABLE IF NOT EXISTS api_keys ( id TEXT PRIMARY KEY, user_id TEXT NOT NULL, @@ -961,6 +1025,7 @@ const enforceUniqueUsernames = () => { const migrateSchema = () => { repairSnippetsColumns(sqlite); + addColumnIfNotExists("api_keys", "scopes", "TEXT"); addColumnIfNotExists("user_preferences", "theme", "TEXT"); addColumnIfNotExists("user_preferences", "font_size", "TEXT"); addColumnIfNotExists("user_preferences", "accent_color", "TEXT"); diff --git a/src/backend/database/db/schema.mysql.ts b/src/backend/database/db/schema.mysql.ts index dbc45ef18d..95e5caca98 100644 --- a/src/backend/database/db/schema.mysql.ts +++ b/src/backend/database/db/schema.mysql.ts @@ -673,10 +673,102 @@ export const apiKeys = mysqlTable( expiresAt: text("expires_at"), lastUsedAt: text("last_used_at"), isActive: boolean("is_active").notNull().default(true), + // JSON list. Null means full access, which every older key has. + scopes: text("scopes"), }, (table) => [index("idx_api_keys_user_id").on(table.userId)], ); +export const operationRuns = mysqlTable( + "operation_runs", + { + id: varchar("id", { length: 255 }).primaryKey(), + actionId: text("action_id").notNull(), + actionVersion: int("action_version").notNull().default(1), + pluginId: text("plugin_id").notNull(), + pluginVersion: text("plugin_version").notNull(), + + // Nullable on purpose: history outlives the account, like audit_logs. + userId: varchar("user_id", { length: 255 }).references(() => users.id, { onDelete: "set null" }), + username: text("username").notNull(), + + origin: text("origin").notNull(), + hostId: int("host_id"), + hostName: text("host_name"), + batchId: varchar("batch_id", { length: 255 }), + dryRun: boolean("dry_run").notNull().default(false), + risk: text("risk").notNull(), + status: varchar("status", { length: 255 }).notNull(), + summary: text("summary"), + changed: boolean("changed"), + input: text("input"), + // Only until the run ends, then wiped. + sealedInput: text("sealed_input"), + output: text("output"), + undoInput: text("undo_input"), + error: text("error"), + errorCode: text("error_code"), + undoOf: text("undo_of"), + idempotencyKey: text("idempotency_key"), + createdAt: varchar("created_at", { length: 255 }).notNull(), + startedAt: text("started_at"), + finishedAt: text("finished_at"), + }, + (table) => [ + index("idx_operation_runs_created").on(table.createdAt), + index("idx_operation_runs_user_created").on(table.userId, table.createdAt), + index("idx_operation_runs_host_created").on(table.hostId, table.createdAt), + index("idx_operation_runs_status").on(table.status), + index("idx_operation_runs_batch").on(table.batchId), + ], +); + +export const operationApprovals = mysqlTable( + "operation_approvals", + { + id: varchar("id", { length: 255 }).primaryKey(), + runId: varchar("run_id", { length: 255 }) + .notNull() + .references(() => operationRuns.id, { onDelete: "cascade" }), + requestedBy: text("requested_by"), + reason: text("reason"), + allowSelf: boolean("allow_self") + .notNull() + .default(false), + status: varchar("status", { length: 255 }).notNull(), + decidedBy: text("decided_by"), + comment: text("comment"), + createdAt: text("created_at").notNull(), + expiresAt: varchar("expires_at", { length: 255 }).notNull(), + decidedAt: text("decided_at"), + }, + (table) => [ + index("idx_operation_approvals_status").on(table.status, table.expiresAt), + index("idx_operation_approvals_run").on(table.runId), + ], +); + +export const operationEvents = mysqlTable( + "operation_events", + { + id: varchar("id", { length: 255 }).primaryKey(), + eventId: text("event_id").notNull(), + pluginId: text("plugin_id").notNull(), + hostId: int("host_id"), + severity: text("severity"), + payload: text("payload"), + runId: text("run_id"), + createdAt: varchar("created_at", { length: 255 }).notNull(), + }, + (table) => [ + index("idx_operation_events_created").on(table.createdAt), + index("idx_operation_events_host_created").on( + table.hostId, + table.createdAt, + ), + ], +); + export const userOpenTabs = mysqlTable( "user_open_tabs", { diff --git a/src/backend/database/db/schema.pg.ts b/src/backend/database/db/schema.pg.ts index 521bac8631..8a866bdd03 100644 --- a/src/backend/database/db/schema.pg.ts +++ b/src/backend/database/db/schema.pg.ts @@ -673,10 +673,102 @@ export const apiKeys = pgTable( expiresAt: text("expires_at"), lastUsedAt: text("last_used_at"), isActive: boolean("is_active").notNull().default(true), + // JSON list. Null means full access, which every older key has. + scopes: text("scopes"), }, (table) => [index("idx_api_keys_user_id").on(table.userId)], ); +export const operationRuns = pgTable( + "operation_runs", + { + id: varchar("id", { length: 255 }).primaryKey(), + actionId: text("action_id").notNull(), + actionVersion: integer("action_version").notNull().default(1), + pluginId: text("plugin_id").notNull(), + pluginVersion: text("plugin_version").notNull(), + + // Nullable on purpose: history outlives the account, like audit_logs. + userId: varchar("user_id", { length: 255 }).references(() => users.id, { onDelete: "set null" }), + username: text("username").notNull(), + + origin: text("origin").notNull(), + hostId: integer("host_id"), + hostName: text("host_name"), + batchId: varchar("batch_id", { length: 255 }), + dryRun: boolean("dry_run").notNull().default(false), + risk: text("risk").notNull(), + status: varchar("status", { length: 255 }).notNull(), + summary: text("summary"), + changed: boolean("changed"), + input: text("input"), + // Only until the run ends, then wiped. + sealedInput: text("sealed_input"), + output: text("output"), + undoInput: text("undo_input"), + error: text("error"), + errorCode: text("error_code"), + undoOf: text("undo_of"), + idempotencyKey: text("idempotency_key"), + createdAt: varchar("created_at", { length: 255 }).notNull(), + startedAt: text("started_at"), + finishedAt: text("finished_at"), + }, + (table) => [ + index("idx_operation_runs_created").on(table.createdAt), + index("idx_operation_runs_user_created").on(table.userId, table.createdAt), + index("idx_operation_runs_host_created").on(table.hostId, table.createdAt), + index("idx_operation_runs_status").on(table.status), + index("idx_operation_runs_batch").on(table.batchId), + ], +); + +export const operationApprovals = pgTable( + "operation_approvals", + { + id: varchar("id", { length: 255 }).primaryKey(), + runId: varchar("run_id", { length: 255 }) + .notNull() + .references(() => operationRuns.id, { onDelete: "cascade" }), + requestedBy: text("requested_by"), + reason: text("reason"), + allowSelf: boolean("allow_self") + .notNull() + .default(false), + status: varchar("status", { length: 255 }).notNull(), + decidedBy: text("decided_by"), + comment: text("comment"), + createdAt: text("created_at").notNull(), + expiresAt: varchar("expires_at", { length: 255 }).notNull(), + decidedAt: text("decided_at"), + }, + (table) => [ + index("idx_operation_approvals_status").on(table.status, table.expiresAt), + index("idx_operation_approvals_run").on(table.runId), + ], +); + +export const operationEvents = pgTable( + "operation_events", + { + id: varchar("id", { length: 255 }).primaryKey(), + eventId: text("event_id").notNull(), + pluginId: text("plugin_id").notNull(), + hostId: integer("host_id"), + severity: text("severity"), + payload: text("payload"), + runId: text("run_id"), + createdAt: varchar("created_at", { length: 255 }).notNull(), + }, + (table) => [ + index("idx_operation_events_created").on(table.createdAt), + index("idx_operation_events_host_created").on( + table.hostId, + table.createdAt, + ), + ], +); + export const userOpenTabs = pgTable( "user_open_tabs", { diff --git a/src/backend/database/db/schema.ts b/src/backend/database/db/schema.ts index be3018a42c..47bf843fd4 100644 --- a/src/backend/database/db/schema.ts +++ b/src/backend/database/db/schema.ts @@ -663,10 +663,102 @@ export const apiKeys = sqliteTable( expiresAt: text("expires_at"), lastUsedAt: text("last_used_at"), isActive: integer("is_active", { mode: "boolean" }).notNull().default(true), + // JSON list. Null means full access, which every older key has. + scopes: text("scopes"), }, (table) => [index("idx_api_keys_user_id").on(table.userId)], ); +export const operationRuns = sqliteTable( + "operation_runs", + { + id: text("id").primaryKey(), + actionId: text("action_id").notNull(), + actionVersion: integer("action_version").notNull().default(1), + pluginId: text("plugin_id").notNull(), + pluginVersion: text("plugin_version").notNull(), + + // Nullable on purpose: history outlives the account, like audit_logs. + userId: text("user_id").references(() => users.id, { onDelete: "set null" }), + username: text("username").notNull(), + + origin: text("origin").notNull(), + hostId: integer("host_id"), + hostName: text("host_name"), + batchId: text("batch_id"), + dryRun: integer("dry_run", { mode: "boolean" }).notNull().default(false), + risk: text("risk").notNull(), + status: text("status").notNull(), + summary: text("summary"), + changed: integer("changed", { mode: "boolean" }), + input: text("input"), + // Only until the run ends, then wiped. + sealedInput: text("sealed_input"), + output: text("output"), + undoInput: text("undo_input"), + error: text("error"), + errorCode: text("error_code"), + undoOf: text("undo_of"), + idempotencyKey: text("idempotency_key"), + createdAt: text("created_at").notNull(), + startedAt: text("started_at"), + finishedAt: text("finished_at"), + }, + (table) => [ + index("idx_operation_runs_created").on(table.createdAt), + index("idx_operation_runs_user_created").on(table.userId, table.createdAt), + index("idx_operation_runs_host_created").on(table.hostId, table.createdAt), + index("idx_operation_runs_status").on(table.status), + index("idx_operation_runs_batch").on(table.batchId), + ], +); + +export const operationApprovals = sqliteTable( + "operation_approvals", + { + id: text("id").primaryKey(), + runId: text("run_id") + .notNull() + .references(() => operationRuns.id, { onDelete: "cascade" }), + requestedBy: text("requested_by"), + reason: text("reason"), + allowSelf: integer("allow_self", { mode: "boolean" }) + .notNull() + .default(false), + status: text("status").notNull(), + decidedBy: text("decided_by"), + comment: text("comment"), + createdAt: text("created_at").notNull(), + expiresAt: text("expires_at").notNull(), + decidedAt: text("decided_at"), + }, + (table) => [ + index("idx_operation_approvals_status").on(table.status, table.expiresAt), + index("idx_operation_approvals_run").on(table.runId), + ], +); + +export const operationEvents = sqliteTable( + "operation_events", + { + id: text("id").primaryKey(), + eventId: text("event_id").notNull(), + pluginId: text("plugin_id").notNull(), + hostId: integer("host_id"), + severity: text("severity"), + payload: text("payload"), + runId: text("run_id"), + createdAt: text("created_at").notNull(), + }, + (table) => [ + index("idx_operation_events_created").on(table.createdAt), + index("idx_operation_events_host_created").on( + table.hostId, + table.createdAt, + ), + ], +); + export const userOpenTabs = sqliteTable( "user_open_tabs", { diff --git a/src/backend/database/repositories/factory.ts b/src/backend/database/repositories/factory.ts index b34811df37..795657b13b 100644 --- a/src/backend/database/repositories/factory.ts +++ b/src/backend/database/repositories/factory.ts @@ -13,6 +13,7 @@ import { HostSidebarPreferenceRepository } from "./host-sidebar-preference-repos import { CredentialSidebarPreferenceRepository } from "./credential-sidebar-preference-repository.js"; import { UiPreferenceRepository } from "./ui-preference-repository.js"; import { OpenTabRepository } from "./open-tab-repository.js"; +import { OperationRepository } from "./operation-repository.js"; import { PluginRepository } from "./plugin-repository.js"; import { PluginStorageRepository } from "./plugin-storage-repository.js"; import { PluginSettingsRepository } from "./plugin-settings-repository.js"; @@ -134,6 +135,14 @@ export function createCurrentAuditLogRepository(): AuditLogRepository { ); } +export function createCurrentOperationRepository(): OperationRepository { + return new OperationRepository( + createCurrentRepositoryContext(), + // Rows land on every run. A rollout saves once on its own when it starts. + createCurrentRepositoryLazyWriteHook("operation_repository_write"), + ); +} + export function createCurrentCredentialRepository(): CredentialRepository { return new CredentialRepository( createCurrentRepositoryContext(), diff --git a/src/backend/database/repositories/operation-repository.ts b/src/backend/database/repositories/operation-repository.ts new file mode 100644 index 0000000000..7d449ca1bc --- /dev/null +++ b/src/backend/database/repositories/operation-repository.ts @@ -0,0 +1,255 @@ +import { and, desc, eq, inArray, lt, or, sql } from "drizzle-orm"; +import { + operationApprovals, + operationEvents, + operationRuns, +} from "../db/schema.js"; +import type { DatabaseContext } from "./database-context.js"; +import { countValue, rowsAffected } from "./mutation-result.js"; + +export type OperationRunRecord = typeof operationRuns.$inferSelect; +export type NewOperationRunRecord = typeof operationRuns.$inferInsert; +export type OperationApprovalRecord = typeof operationApprovals.$inferSelect; +export type NewOperationApprovalRecord = typeof operationApprovals.$inferInsert; +export type OperationEventRecord = typeof operationEvents.$inferSelect; +export type NewOperationEventRecord = typeof operationEvents.$inferInsert; + +export interface OperationRunFilters { + userId?: string; + /** Rows also visible because the viewer owns the host. */ + orHostIds?: number[]; + hostId?: number; + actionId?: string; + status?: string; + batchId?: string; + before?: string; +} + +const MAX_PAGE = 200; + +export class OperationRepository { + constructor( + private readonly context: DatabaseContext, + private readonly onWrite?: () => void | Promise, + ) {} + + async insertRun(run: NewOperationRunRecord): Promise { + await this.context.drizzle.insert(operationRuns).values(run); + await this.onWrite?.(); + } + + async findRun(id: string): Promise { + const rows = await this.context.drizzle + .select() + .from(operationRuns) + .where(eq(operationRuns.id, id)) + .limit(1); + return rows[0] ?? null; + } + + async findByIdempotencyKey( + userId: string, + key: string, + ): Promise { + const rows = await this.context.drizzle + .select() + .from(operationRuns) + .where( + and( + eq(operationRuns.userId, userId), + eq(operationRuns.idempotencyKey, key), + ), + ) + .limit(1); + return rows[0] ?? null; + } + + async listRuns( + filters: OperationRunFilters, + limit = 50, + ): Promise { + const owner = filters.userId + ? filters.orHostIds?.length + ? or( + eq(operationRuns.userId, filters.userId), + inArray(operationRuns.hostId, filters.orHostIds), + ) + : eq(operationRuns.userId, filters.userId) + : undefined; + const conditions = [ + owner, + filters.hostId !== undefined + ? eq(operationRuns.hostId, filters.hostId) + : undefined, + filters.actionId + ? eq(operationRuns.actionId, filters.actionId) + : undefined, + filters.status ? eq(operationRuns.status, filters.status) : undefined, + filters.batchId ? eq(operationRuns.batchId, filters.batchId) : undefined, + filters.before ? lt(operationRuns.createdAt, filters.before) : undefined, + ].filter((condition) => condition !== undefined); + return this.context.drizzle + .select() + .from(operationRuns) + .where(conditions.length > 0 ? and(...conditions) : undefined) + .orderBy(desc(operationRuns.createdAt)) + .limit(Math.min(Math.max(limit, 1), MAX_PAGE)); + } + + /** Moves a run on only from the status given, so two writers cannot race. */ + async updateRunFrom( + id: string, + fromStatus: string, + patch: Partial, + ): Promise { + const result = await this.context.drizzle + .update(operationRuns) + .set(patch) + .where( + and(eq(operationRuns.id, id), eq(operationRuns.status, fromStatus)), + ); + await this.onWrite?.(); + return rowsAffected(result) > 0; + } + + /** Anything still running when the server stopped has an unknown outcome. */ + async markRunningInterrupted(finishedAt: string): Promise { + const result = await this.context.drizzle + .update(operationRuns) + .set({ + status: "interrupted", + finishedAt, + sealedInput: null, + error: "The server stopped during this run. Check the host.", + }) + .where(eq(operationRuns.status, "running")); + await this.onWrite?.(); + return rowsAffected(result); + } + + async insertApproval(approval: NewOperationApprovalRecord): Promise { + await this.context.drizzle.insert(operationApprovals).values(approval); + await this.onWrite?.(); + } + + async findApproval(id: string): Promise { + const rows = await this.context.drizzle + .select() + .from(operationApprovals) + .where(eq(operationApprovals.id, id)) + .limit(1); + return rows[0] ?? null; + } + + async findApprovalForRun( + runId: string, + ): Promise { + const rows = await this.context.drizzle + .select() + .from(operationApprovals) + .where(eq(operationApprovals.runId, runId)) + .limit(1); + return rows[0] ?? null; + } + + async listPendingApprovals(limit = 100): Promise { + return this.context.drizzle + .select() + .from(operationApprovals) + .where(eq(operationApprovals.status, "pending")) + .orderBy(desc(operationApprovals.createdAt)) + .limit(Math.min(Math.max(limit, 1), MAX_PAGE)); + } + + /** One shot: only the first decision on a pending row wins. */ + async decideApproval( + id: string, + status: "approved" | "rejected" | "expired", + decidedBy: string | null, + comment: string | null, + decidedAt: string, + ): Promise { + const result = await this.context.drizzle + .update(operationApprovals) + .set({ status, decidedBy, comment, decidedAt }) + .where( + and( + eq(operationApprovals.id, id), + eq(operationApprovals.status, "pending"), + ), + ); + await this.onWrite?.(); + return rowsAffected(result) > 0; + } + + async listExpiredApprovals(now: string): Promise { + return this.context.drizzle + .select() + .from(operationApprovals) + .where( + and( + eq(operationApprovals.status, "pending"), + lt(operationApprovals.expiresAt, now), + ), + ) + .limit(MAX_PAGE); + } + + async insertEvent(event: NewOperationEventRecord): Promise { + await this.context.drizzle.insert(operationEvents).values(event); + await this.onWrite?.(); + } + + async listEvents( + filters: { hostId?: number; before?: string }, + limit = 50, + ): Promise { + const conditions = [ + filters.hostId !== undefined + ? eq(operationEvents.hostId, filters.hostId) + : undefined, + filters.before + ? lt(operationEvents.createdAt, filters.before) + : undefined, + ].filter((condition) => condition !== undefined); + return this.context.drizzle + .select() + .from(operationEvents) + .where(conditions.length > 0 ? and(...conditions) : undefined) + .orderBy(desc(operationEvents.createdAt)) + .limit(Math.min(Math.max(limit, 1), MAX_PAGE)); + } + + /** Drops rows older than the cutoff, finished runs only. */ + async prune(before: string): Promise { + const runs = await this.context.drizzle + .delete(operationRuns) + .where( + and( + lt(operationRuns.createdAt, before), + sql`${operationRuns.status} NOT IN ('running', 'pending_approval')`, + ), + ); + const events = await this.context.drizzle + .delete(operationEvents) + .where(lt(operationEvents.createdAt, before)); + await this.onWrite?.(); + return rowsAffected(runs) + rowsAffected(events); + } + + async countRuns(): Promise { + const rows = await this.context.drizzle + .select({ value: sql`count(*)` }) + .from(operationRuns); + return countValue(rows[0]?.value); + } + + /** History outlives the account, but the name goes with it. */ + async anonymizeUser(userId: string): Promise { + await this.context.drizzle + .update(operationRuns) + .set({ username: "deleted user" }) + .where(eq(operationRuns.userId, userId)); + await this.onWrite?.(); + } +} diff --git a/src/backend/database/routes/delete-user-data.ts b/src/backend/database/routes/delete-user-data.ts index 1457579a6e..e86d1a9f8e 100644 --- a/src/backend/database/routes/delete-user-data.ts +++ b/src/backend/database/routes/delete-user-data.ts @@ -3,6 +3,7 @@ import { recentActivity } from "../../plugins/activity-provider.js"; import { createCurrentApiKeyRepository, createCurrentAuditLogRepository, + createCurrentOperationRepository, createCurrentCredentialRepository, createCurrentHostFolderRepository, createCurrentHostRepository, @@ -61,6 +62,7 @@ export async function deleteUserAndRelatedData( await createCurrentRoleRepository().removeAllRolesFromUser(userId); await createCurrentAuditLogRepository().anonymizeByUserId(userId); + await createCurrentOperationRepository().anonymizeUser(userId); await createCurrentSshCredentialUsageRepository().deleteByUserId(userId); diff --git a/src/backend/database/routes/operations-routes.ts b/src/backend/database/routes/operations-routes.ts new file mode 100644 index 0000000000..09ca3f5531 --- /dev/null +++ b/src/backend/database/routes/operations-routes.ts @@ -0,0 +1,760 @@ +import express, { type Request, type Response } from "express"; +import type { + ResourceListResult, + SignalResult, +} from "@termix-ssh/plugin-sdk/operations"; +import { AuthManager } from "../../utils/auth-manager.js"; +import { PermissionManager } from "../../utils/permission-manager.js"; +import { databaseLogger } from "../../utils/logger.js"; +import { getErrorMessage } from "../../utils/error-message.js"; +import { createCurrentOperationRepository } from "../repositories/factory.js"; +import type { OperationRunRecord } from "../repositories/operation-repository.js"; +import { buildCatalog } from "../../operations/catalog.js"; +import { + cancelOperationRun, + decideOperationApproval, + OperationError, + runOperation, +} from "../../operations/gate.js"; +import { getAction } from "../../operations/registry.js"; +import { + flattenResourceRows, + readAcrossHosts, +} from "../../operations/resources.js"; +import type { ActorOrigin } from "../../plugins/actor.js"; + +const router = express.Router(); +const authenticateJWT = AuthManager.getInstance().createAuthMiddleware(); +const permissions = PermissionManager.getInstance(); + +/** Sees every run on the server, not only their own. */ +export const VIEW_ALL_RUNS = "admin.operations.view"; + +type OperationsRequest = Request & { + userId: string; + apiKeyId?: string; + apiKeyScopes?: string[] | null; + actingAdminUserId?: string; +}; + +function originOf(req: OperationsRequest): ActorOrigin { + return { kind: req.apiKeyId ? "api" : "ui" }; +} + +function sendError(res: Response, error: unknown, operation: string): void { + if (error instanceof OperationError) { + res.status(error.status).json({ + error: error.message, + code: error.code, + details: error.details, + }); + return; + } + databaseLogger.error("Operations route failed", error, { operation }); + res.status(500).json({ error: getErrorMessage(error, "Request failed") }); +} + +function parseJson(text: string | null): unknown { + if (!text) return undefined; + try { + return JSON.parse(text); + } catch { + return undefined; + } +} + +/** Never includes the sealed input. */ +function publicRun(run: OperationRunRecord) { + return { + id: run.id, + actionId: run.actionId, + pluginId: run.pluginId, + pluginVersion: run.pluginVersion, + userId: run.userId, + username: run.username, + origin: parseJson(run.origin), + hostId: run.hostId, + hostName: run.hostName, + batchId: run.batchId, + dryRun: run.dryRun, + risk: run.risk, + status: run.status, + summary: run.summary, + changed: run.changed, + input: parseJson(run.input), + output: parseJson(run.output), + undoAvailable: run.undoInput !== null, + error: run.error, + errorCode: run.errorCode, + undoOf: run.undoOf, + createdAt: run.createdAt, + startedAt: run.startedAt, + finishedAt: run.finishedAt, + }; +} + +async function canSeeRun( + run: OperationRunRecord, + userId: string, +): Promise { + if (run.userId === userId) return true; + if (await permissions.hasPermission(userId, VIEW_ALL_RUNS)) return true; + if (run.hostId === null) return false; + const access = await permissions.canAccessHost(userId, run.hostId, "manage"); + return access.hasAccess; +} + +function hostIdsFrom(value: unknown): number[] | undefined { + if (typeof value !== "string" || value === "") return undefined; + const ids = value + .split(",") + .map(Number) + .filter((id) => Number.isInteger(id)); + return ids.length > 0 ? ids : undefined; +} + +function limitFrom(value: unknown): number | undefined { + const limit = Number(value); + return Number.isInteger(limit) && limit > 0 ? limit : undefined; +} + +/** + * @openapi + * /operations/catalog: + * get: + * summary: List what this user can do + * description: Every action, event, resource and signal from running plugins that the caller has permission for, with titles in the requested language. + * tags: + * - Operations + * parameters: + * - in: query + * name: lang + * schema: + * type: string + * - in: query + * name: surface + * schema: + * type: string + * enum: [assistant, mcp, palette, automations, mobile, cli] + * responses: + * 200: + * description: The catalog. + */ +router.get("/catalog", authenticateJWT, async (req, res) => { + const { userId } = req as OperationsRequest; + try { + res.json( + await buildCatalog({ + userId, + lang: typeof req.query.lang === "string" ? req.query.lang : undefined, + surface: + typeof req.query.surface === "string" + ? (req.query.surface as never) + : undefined, + }), + ); + } catch (error) { + sendError(res, error, "operations_catalog"); + } +}); + +/** + * @openapi + * /operations/catalog/{id}: + * get: + * summary: Describe one action + * tags: + * - Operations + * parameters: + * - in: path + * name: id + * required: true + * schema: + * type: string + * responses: + * 200: + * description: The action. + * 404: + * description: No such action, or the caller may not use it. + */ +router.get("/catalog/:id", authenticateJWT, async (req, res) => { + const { userId } = req as OperationsRequest; + try { + const catalog = await buildCatalog({ + userId, + lang: typeof req.query.lang === "string" ? req.query.lang : undefined, + }); + const action = catalog.actions.find((entry) => entry.id === req.params.id); + if (!action) { + res.status(404).json({ error: "Action not found" }); + return; + } + res.json(action); + } catch (error) { + sendError(res, error, "operations_catalog_action"); + } +}); + +/** + * @openapi + * /operations/runs: + * get: + * summary: List runs + * description: The caller's own runs, runs on hosts they manage, or every run with the view permission. + * tags: + * - Operations + * parameters: + * - in: query + * name: hostId + * schema: + * type: integer + * - in: query + * name: actionId + * schema: + * type: string + * - in: query + * name: status + * schema: + * type: string + * - in: query + * name: batchId + * schema: + * type: string + * - in: query + * name: before + * schema: + * type: string + * - in: query + * name: limit + * schema: + * type: integer + * responses: + * 200: + * description: Runs, newest first. + */ +router.get("/runs", authenticateJWT, async (req, res) => { + const { userId } = req as OperationsRequest; + try { + const viewAll = await permissions.hasPermission(userId, VIEW_ALL_RUNS); + const hostId = limitFrom(req.query.hostId); + let managed = false; + if (!viewAll && hostId !== undefined) { + managed = (await permissions.canAccessHost(userId, hostId, "manage")) + .hasAccess; + } + const runs = await createCurrentOperationRepository().listRuns( + { + userId: viewAll || managed ? undefined : userId, + hostId, + actionId: + typeof req.query.actionId === "string" + ? req.query.actionId + : undefined, + status: + typeof req.query.status === "string" ? req.query.status : undefined, + batchId: + typeof req.query.batchId === "string" ? req.query.batchId : undefined, + before: + typeof req.query.before === "string" ? req.query.before : undefined, + }, + limitFrom(req.query.limit), + ); + res.json({ runs: runs.map(publicRun) }); + } catch (error) { + sendError(res, error, "operations_runs"); + } +}); + +/** + * @openapi + * /operations/runs/{id}: + * get: + * summary: Get a run + * tags: + * - Operations + * parameters: + * - in: path + * name: id + * required: true + * schema: + * type: string + * responses: + * 200: + * description: The run. + * 404: + * description: Not found, or not visible to the caller. + */ +router.get("/runs/:id", authenticateJWT, async (req, res) => { + const { userId } = req as OperationsRequest; + try { + const run = await createCurrentOperationRepository().findRun( + String(req.params.id), + ); + if (!run || !(await canSeeRun(run, userId))) { + res.status(404).json({ error: "Run not found" }); + return; + } + res.json(publicRun(run)); + } catch (error) { + sendError(res, error, "operations_run"); + } +}); + +/** + * @openapi + * /operations/runs/{id}/cancel: + * post: + * summary: Cancel a run + * description: Stops a running action, or withdraws one waiting for approval. + * tags: + * - Operations + * parameters: + * - in: path + * name: id + * required: true + * schema: + * type: string + * responses: + * 200: + * description: Whether anything was cancelled. + * 404: + * description: Not found, or not visible to the caller. + */ +router.post("/runs/:id/cancel", authenticateJWT, async (req, res) => { + const { userId } = req as OperationsRequest; + try { + const run = await createCurrentOperationRepository().findRun( + String(req.params.id), + ); + if (!run || !(await canSeeRun(run, userId))) { + res.status(404).json({ error: "Run not found" }); + return; + } + res.json({ cancelled: await cancelOperationRun(run) }); + } catch (error) { + sendError(res, error, "operations_cancel"); + } +}); + +/** + * @openapi + * /operations/runs/{id}/undo: + * post: + * summary: Undo a run + * description: Runs the action's undo with the input the run recorded. It goes through the same checks as any run. + * tags: + * - Operations + * parameters: + * - in: path + * name: id + * required: true + * schema: + * type: string + * responses: + * 200: + * description: The new run. + * 404: + * description: Not found, or nothing to undo. + */ +router.post("/runs/:id/undo", authenticateJWT, async (req, res) => { + const authReq = req as OperationsRequest; + try { + const run = await createCurrentOperationRepository().findRun( + String(req.params.id), + ); + const undo = run ? getAction(run.actionId)?.action.undo : undefined; + if ( + !run || + !undo || + !run.undoInput || + !(await canSeeRun(run, authReq.userId)) + ) { + res.status(404).json({ error: "Nothing to undo" }); + return; + } + res.json( + await runOperation({ + actionId: undo, + hostId: run.hostId ?? undefined, + input: parseJson(run.undoInput), + confirmed: req.body?.confirmed === true, + undoOf: run.id, + userId: authReq.userId, + origin: originOf(authReq), + apiKeyScopes: authReq.apiKeyScopes, + }), + ); + } catch (error) { + sendError(res, error, "operations_undo"); + } +}); + +/** + * @openapi + * /operations/approvals: + * get: + * summary: List waiting approvals + * description: Requests the caller could decide, with the exact input that would run. + * tags: + * - Operations + * responses: + * 200: + * description: Waiting approvals. + */ +router.get("/approvals", authenticateJWT, async (req, res) => { + const { userId } = req as OperationsRequest; + try { + const repository = createCurrentOperationRepository(); + const approvals = []; + for (const approval of await repository.listPendingApprovals()) { + const run = await repository.findRun(approval.runId); + if (run && (await canSeeRun(run, userId))) { + approvals.push({ ...approval, run: publicRun(run) }); + } + } + res.json({ approvals }); + } catch (error) { + sendError(res, error, "operations_approvals"); + } +}); + +/** + * @openapi + * /operations/approvals/{id}: + * post: + * summary: Approve or reject a request + * description: One shot. What runs is the sealed request, never anything sent here. + * tags: + * - Operations + * parameters: + * - in: path + * name: id + * required: true + * schema: + * type: string + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * required: [approve] + * properties: + * approve: + * type: boolean + * comment: + * type: string + * responses: + * 200: + * description: The run's outcome. + * 403: + * description: The caller may not decide this. + * 409: + * description: Already decided, expired, or the host changed. + */ +router.post("/approvals/:id", authenticateJWT, async (req, res) => { + const authReq = req as OperationsRequest; + if (typeof req.body?.approve !== "boolean") { + res.status(400).json({ error: "approve must be true or false" }); + return; + } + // A key could be handed to a script, so a person decides from a session. + if (authReq.apiKeyScopes) { + res.status(403).json({ error: "This API key may not approve" }); + return; + } + try { + res.json( + await decideOperationApproval({ + approvalId: String(req.params.id), + approverId: authReq.userId, + approve: req.body.approve, + comment: + typeof req.body.comment === "string" ? req.body.comment : undefined, + impersonated: !!authReq.actingAdminUserId, + }), + ); + } catch (error) { + sendError(res, error, "operations_approve"); + } +}); + +/** + * @openapi + * /operations/events: + * get: + * summary: List declared events + * tags: + * - Operations + * responses: + * 200: + * description: Declared events from running plugins, plus core's run events. + */ +router.get("/events", authenticateJWT, async (req, res) => { + const { userId } = req as OperationsRequest; + try { + const catalog = await buildCatalog({ + userId, + lang: typeof req.query.lang === "string" ? req.query.lang : undefined, + }); + res.json({ events: catalog.events }); + } catch (error) { + sendError(res, error, "operations_events"); + } +}); + +/** + * @openapi + * /operations/timeline: + * get: + * summary: Recent events + * description: Events the caller may see, newest first. Events tied to a host need access to it. + * tags: + * - Operations + * parameters: + * - in: query + * name: hostId + * schema: + * type: integer + * - in: query + * name: before + * schema: + * type: string + * - in: query + * name: limit + * schema: + * type: integer + * responses: + * 200: + * description: Events. + */ +router.get("/timeline", authenticateJWT, async (req, res) => { + const { userId } = req as OperationsRequest; + try { + const rows = await createCurrentOperationRepository().listEvents( + { + hostId: limitFrom(req.query.hostId), + before: + typeof req.query.before === "string" ? req.query.before : undefined, + }, + limitFrom(req.query.limit), + ); + const viewAll = await permissions.hasPermission(userId, VIEW_ALL_RUNS); + const access = new Map(); + const events = []; + for (const row of rows) { + if (row.hostId === null) { + if (viewAll) events.push(row); + continue; + } + let allowed = access.get(row.hostId); + if (allowed === undefined) { + allowed = ( + await permissions.canAccessHost(userId, row.hostId, "connect") + ).hasAccess; + access.set(row.hostId, allowed); + } + if (allowed) events.push(row); + } + res.json({ + events: events.map((event) => ({ + ...event, + payload: parseJson(event.payload), + })), + }); + } catch (error) { + sendError(res, error, "operations_timeline"); + } +}); + +/** + * @openapi + * /operations/signals: + * get: + * summary: Read status tiles + * description: Answers from memory right away and refreshes stale hosts behind it, so a page should poll. Reads hosts the caller can reach. + * tags: + * - Operations + * parameters: + * - in: query + * name: hostIds + * description: Comma separated. Leave out for every host. + * schema: + * type: string + * - in: query + * name: ids + * description: Comma separated signal ids. Leave out for all. + * schema: + * type: string + * responses: + * 200: + * description: One entry per signal with per host results. + */ +router.get("/signals", authenticateJWT, async (req, res) => { + const authReq = req as OperationsRequest; + try { + const catalog = await buildCatalog({ userId: authReq.userId }); + const wanted = + typeof req.query.ids === "string" && req.query.ids + ? new Set(req.query.ids.split(",")) + : null; + const hostIds = hostIdsFrom(req.query.hostIds); + const signals = []; + for (const signal of catalog.signals) { + if (wanted && !wanted.has(signal.id)) continue; + if (signal.scope === "server") { + const outcome = await runOperation({ + actionId: signal.read, + internal: true, + userId: authReq.userId, + origin: originOf(authReq), + }).catch(() => null); + signals.push({ + id: signal.id, + hosts: [], + result: outcome?.data as SignalResult | undefined, + }); + continue; + } + signals.push({ + id: signal.id, + hosts: await readAcrossHosts({ + userId: authReq.userId, + actionId: signal.read, + hostIds, + origin: originOf(authReq), + }), + }); + } + res.json({ signals }); + } catch (error) { + sendError(res, error, "operations_signals"); + } +}); + +/** + * @openapi + * /operations/resources/{id}: + * get: + * summary: Read one kind of thing across hosts + * description: Rows from cache plus each host's state (ok, loading, offline, error, no-access). Answers right away and refreshes stale hosts behind it, so a page should poll until no host is loading. + * tags: + * - Operations + * parameters: + * - in: path + * name: id + * required: true + * schema: + * type: string + * - in: query + * name: hostIds + * description: Comma separated. Leave out for every host. + * schema: + * type: string + * - in: query + * name: refresh + * schema: + * type: boolean + * responses: + * 200: + * description: Rows and per host state. + * 404: + * description: No such resource, or the caller may not read it. + */ +router.get("/resources/:id", authenticateJWT, async (req, res) => { + const authReq = req as OperationsRequest; + try { + const catalog = await buildCatalog({ userId: authReq.userId }); + const resource = catalog.resources.find( + (entry) => entry.id === req.params.id, + ); + if (!resource) { + res.status(404).json({ error: "Resource not found" }); + return; + } + const reads = await readAcrossHosts({ + userId: authReq.userId, + actionId: resource.list, + hostIds: hostIdsFrom(req.query.hostIds), + refresh: req.query.refresh === "true", + origin: originOf(authReq), + }); + res.json(flattenResourceRows(reads, resource.key)); + } catch (error) { + sendError(res, error, "operations_resources"); + } +}); + +/** + * @openapi + * /operations/{id}/run: + * post: + * summary: Run an action + * description: Goes through permission, host access, input and approval checks. A change returns as soon as it starts unless wait is true, then poll the run. A read always answers inline. + * tags: + * - Operations + * parameters: + * - in: path + * name: id + * required: true + * schema: + * type: string + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * properties: + * hostId: + * type: integer + * resourceKey: + * type: string + * input: + * type: object + * dryRun: + * type: boolean + * confirmed: + * type: boolean + * idempotencyKey: + * type: string + * wait: + * type: boolean + * responses: + * 200: + * description: The outcome, or a run waiting for approval. + * 400: + * description: Invalid input. + * 403: + * description: Not allowed. + * 409: + * description: Needs a confirm, or safe mode is on. + */ +router.post("/:id/run", authenticateJWT, async (req, res) => { + const authReq = req as OperationsRequest; + const body = (req.body ?? {}) as Record; + try { + res.json( + await runOperation({ + actionId: String(req.params.id), + hostId: typeof body.hostId === "number" ? body.hostId : undefined, + resourceKey: + typeof body.resourceKey === "string" ? body.resourceKey : undefined, + input: body.input, + dryRun: body.dryRun === true, + confirmed: body.confirmed === true, + idempotencyKey: + typeof body.idempotencyKey === "string" + ? body.idempotencyKey.slice(0, 128) + : undefined, + userId: authReq.userId, + origin: originOf(authReq), + apiKeyScopes: authReq.apiKeyScopes, + }), + ); + } catch (error) { + sendError(res, error, "operations_run_action"); + } +}); + +export default router; diff --git a/src/backend/operations/approvals.ts b/src/backend/operations/approvals.ts new file mode 100644 index 0000000000..8e9c10fcd0 --- /dev/null +++ b/src/backend/operations/approvals.ts @@ -0,0 +1,129 @@ +/** + * Who may run what without a person saying yes. + * + * A plugin fills this through ctx.operations.approvals.provide. Core asks every + * provider and the strictest answer wins. A provider that throws denies the + * run, since skipping a safety rule is worse than a refused action. + */ + +import type { OperationRisk } from "@termix-ssh/plugin-sdk/operations"; +import { riskRank } from "@termix-ssh/plugin-sdk/operations"; +import { hasCapability } from "../plugins/permissions.js"; +import { pluginLogger } from "../utils/logger.js"; +import type { ActorOrigin } from "../plugins/actor.js"; +import type { PluginManifest } from "../plugins/manifest.js"; + +export interface ApprovalPolicyInput { + actionId: string; + pluginId: string; + /** What the plugin declared. */ + risk: OperationRisk; + /** What rules should use. Plugins not signed as official count as change. */ + effectiveRisk: OperationRisk; + origin: ActorOrigin; + userId: string; + hostId?: number; + hostTags?: string[]; + batchId?: string; +} + +export interface ApprovalDecision { + decision: "allow" | "approve" | "deny"; + reason?: string; + /** The requester may approve their own run. */ + allowSelf?: boolean; + expiresInMinutes?: number; +} + +export type ApprovalPolicyProvider = ( + input: ApprovalPolicyInput, +) => + | ApprovalDecision + | null + | undefined + | Promise; + +interface Entry { + pluginId: string; + manifest: PluginManifest; + provider: ApprovalPolicyProvider; +} + +const providers = new Map(); + +export function provideApprovalPolicy( + pluginId: string, + manifest: PluginManifest, + provider: ApprovalPolicyProvider, +): () => void { + const key = Symbol(pluginId); + providers.set(key, { pluginId, manifest, provider }); + return () => { + providers.delete(key); + }; +} + +/** Test seam. */ +export function clearApprovalPolicies(): void { + providers.clear(); +} + +const RANK = { allow: 0, approve: 1, deny: 2 } as const; +const TRUSTED_VIA = new Set(["automation", "rollout", "fleet"]); +const SELF_APPROVE_VIA = new Set(["assistant", "mcp", "automation"]); + +/** A person is the check when the request came from something else. */ +export function canSelfApprove(origin: ActorOrigin): boolean { + return origin.kind === "plugin" && SELF_APPROVE_VIA.has(origin.via ?? ""); +} + +/** + * Used when no provider has an opinion. People and their own keys go by RBAC, + * and so do automations they set up. Anything else needs a rule that allows it. + */ +function builtIn(input: ApprovalPolicyInput): ApprovalDecision { + if (riskRank(input.effectiveRisk) === 0) return { decision: "allow" }; + if (input.origin.kind !== "plugin") return { decision: "allow" }; + if (TRUSTED_VIA.has(input.origin.via ?? "")) return { decision: "allow" }; + return { + decision: "deny", + reason: "This needs an approval rule. Turn on approvals to allow it.", + }; +} + +export async function evaluateApproval( + input: ApprovalPolicyInput, +): Promise { + let strictest: ApprovalDecision | null = null; + for (const entry of providers.values()) { + let decision: ApprovalDecision | null | undefined; + try { + // Rechecked at use, so a revoked grant stops the provider now. + if ( + !(await hasCapability( + entry.pluginId, + "operations:approve", + entry.manifest.capabilities, + )) + ) { + continue; + } + decision = await entry.provider(input); + } catch (error) { + pluginLogger.warn("Approval rule failed, refusing the run", { + operation: "operation_approval", + pluginId: entry.pluginId, + error: error instanceof Error ? error.message : String(error), + }); + return { + decision: "deny", + reason: "Approval rules are not available right now.", + }; + } + if (!decision) continue; + if (!strictest || RANK[decision.decision] > RANK[strictest.decision]) { + strictest = decision; + } + } + return strictest ?? builtIn(input); +} diff --git a/src/backend/operations/catalog.ts b/src/backend/operations/catalog.ts new file mode 100644 index 0000000000..86c11dd21e --- /dev/null +++ b/src/backend/operations/catalog.ts @@ -0,0 +1,251 @@ +/** + * What a user can do right now, in their language. + * + * Built from the manifests of running plugins, never from GET /plugins, which + * hands every signed-in user the raw manifest. A plugin's locale file loads + * here on the server, because its frontend may not be loaded on the client: + * the CLI has no frontend, and a linked desktop may not have the plugin at all. + */ + +import fs from "node:fs"; +import path from "node:path"; +import { + onSurface, + toJsonSchema, + type OperationAction, + type OperationEvent, + type OperationField, + type OperationResource, + type OperationSignal, + type OperationSurfaces, +} from "@termix-ssh/plugin-sdk/operations"; +import { getAction } from "./registry.js"; +import { qualifyPermission, type PluginManifest } from "../plugins/manifest.js"; +import { PermissionManager } from "../utils/permission-manager.js"; + +interface CatalogPlugin { + id: string; + dir: string; + manifest: PluginManifest; + state: string; + official: boolean; +} + +export interface CatalogField extends OperationField { + label: string; + description?: string; + placeholder?: string; + optionLabels?: Record; +} + +export interface CatalogAction extends Omit< + OperationAction, + "inputs" | "outputs" +> { + pluginId: string; + pluginName: string; + official: boolean; + title: string; + description?: string; + inputs: CatalogField[]; + outputs: CatalogField[]; + inputSchema: Record; + available: boolean; +} + +export interface CatalogEvent extends OperationEvent { + pluginId: string; + title: string; +} + +export interface CatalogResource extends OperationResource { + pluginId: string; + title: string; + columns: (NonNullable[number] & { + title: string; + })[]; +} + +export interface CatalogSignal extends OperationSignal { + pluginId: string; + title: string; +} + +export interface OperationCatalog { + actions: CatalogAction[]; + events: CatalogEvent[]; + resources: CatalogResource[]; + signals: CatalogSignal[]; +} + +type Strings = Record; + +const localeCache = new Map(); + +function flatten(value: unknown, prefix: string, out: Strings): void { + if (typeof value === "string") { + out[prefix] = value; + return; + } + if (typeof value !== "object" || value === null) return; + for (const [key, child] of Object.entries(value)) { + flatten(child, prefix ? `${prefix}.${key}` : key, out); + } +} + +function readLocale(file: string): Strings { + const out: Strings = {}; + try { + flatten(JSON.parse(fs.readFileSync(file, "utf8")), "", out); + } catch { + // A missing or broken file falls back to the key. + } + return out; +} + +/** Locale names reach a path, so only a plain language tag is used. */ +function safeLanguage(lang: string | undefined): string { + return lang && /^[a-zA-Z]{2,3}(-[a-zA-Z0-9]{2,8})?$/.test(lang) ? lang : "en"; +} + +function stringsFor(plugin: CatalogPlugin, lang: string): Strings { + const cacheKey = `${plugin.id}@${plugin.manifest.version}:${lang}`; + const cached = localeCache.get(cacheKey); + if (cached) return cached; + + const dir = path.resolve(plugin.dir, plugin.manifest.locales ?? "locales"); + const strings = readLocale(path.join(dir, "en.json")); + if (lang !== "en") { + Object.assign( + strings, + readLocale(path.join(dir, "translated", `${lang}.json`)), + readLocale(path.join(dir, `${lang}.json`)), + ); + } + localeCache.set(cacheKey, strings); + return strings; +} + +function localizedField(field: OperationField, strings: Strings): CatalogField { + const text = (key?: string) => (key ? (strings[key] ?? key) : undefined); + const optionLabels: Record = {}; + for (const option of field.options ?? []) { + optionLabels[option.value] = text(option.labelKey) ?? option.value; + } + return { + ...field, + label: text(field.labelKey) ?? field.key, + description: text(field.descriptionKey), + placeholder: text(field.placeholderKey), + optionLabels: field.options ? optionLabels : undefined, + }; +} + +async function runningPlugins(): Promise { + const { getPluginRuntime } = await import("../plugins/index.js"); + return getPluginRuntime() + .loader.list() + .filter((plugin) => plugin.state === "active") + .map((plugin) => ({ + id: plugin.id, + dir: plugin.dir, + manifest: plugin.manifest, + state: plugin.state, + official: plugin.source === "bundled" || plugin.signedTier === "official", + })); +} + +export async function buildCatalog(options: { + userId: string; + lang?: string; + surface?: keyof OperationSurfaces; +}): Promise { + const lang = safeLanguage(options.lang); + const permissions = PermissionManager.getInstance(); + const catalog: OperationCatalog = { + actions: [], + events: [], + resources: [], + signals: [], + }; + + for (const plugin of await runningPlugins()) { + const operations = plugin.manifest.contributes?.operations; + if (!operations) continue; + const strings = stringsFor(plugin, lang); + const text = (key: string) => strings[key] ?? key; + + for (const action of operations.actions ?? []) { + if (options.surface && !onSurface(action, options.surface)) continue; + // Community plugins stay away from the assistant and MCP until an admin allows them. + if ( + !plugin.official && + (options.surface === "assistant" || options.surface === "mcp") + ) { + continue; + } + const permission = qualifyPermission(plugin.id, action.permission); + if (!(await permissions.hasPermission(options.userId, permission))) { + continue; + } + const inputs = (action.inputs ?? []).map((field) => + localizedField(field, strings), + ); + catalog.actions.push({ + ...action, + pluginId: plugin.id, + pluginName: plugin.manifest.name, + official: plugin.official, + title: text(action.titleKey), + description: action.descriptionKey + ? text(action.descriptionKey) + : undefined, + inputs, + outputs: (action.outputs ?? []) + .filter((field) => !field.sensitive) + .map((field) => localizedField(field, strings)), + inputSchema: toJsonSchema( + action.inputs, + (field) => + inputs.find((input) => input.key === field.key)?.description, + ), + available: getAction(action.id) !== undefined, + }); + } + + const visible = new Set(catalog.actions.map((action) => action.id)); + for (const event of operations.events ?? []) { + catalog.events.push({ + ...event, + pluginId: plugin.id, + title: text(event.titleKey), + }); + } + for (const resource of operations.resources ?? []) { + if (!visible.has(resource.list)) continue; + catalog.resources.push({ + ...resource, + pluginId: plugin.id, + title: text(resource.titleKey), + columns: (resource.columns ?? []).map((column) => ({ + ...column, + title: text(column.titleKey), + })), + }); + } + for (const signal of operations.signals ?? []) { + if (!visible.has(signal.read)) continue; + catalog.signals.push({ + ...signal, + pluginId: plugin.id, + title: text(signal.titleKey), + }); + } + } + return catalog; +} + +/** Test seam. */ +export function clearCatalogCache(): void { + localeCache.clear(); +} diff --git a/src/backend/operations/constants.ts b/src/backend/operations/constants.ts new file mode 100644 index 0000000000..d8a99e90a8 --- /dev/null +++ b/src/backend/operations/constants.ts @@ -0,0 +1,8 @@ +/** How many events or actions may lead to one another before the chain stops. */ +export const MAX_EVENT_DEPTH = 3; + +/** How long finished runs and events stay in history. */ +export const RETENTION_DAYS = 90; + +/** How long a cached table row or tile counts as fresh. */ +export const RESOURCE_FRESH_MS = 60_000; diff --git a/src/backend/operations/events.ts b/src/backend/operations/events.ts new file mode 100644 index 0000000000..0c7f3e4d4b --- /dev/null +++ b/src/backend/operations/events.ts @@ -0,0 +1,155 @@ +/** + * Declared events, separate from the plugin.* bus any plugin can read. + * + * Each event is kept in a capped timeline. A subscriber bound to a user only + * hears about hosts that user can reach, checked at delivery. Events carry a + * depth, so a rule that fires an action that fires the same rule stops. + */ + +import { randomUUID } from "node:crypto"; +import { MAX_EVENT_DEPTH } from "./constants.js"; +import { getActorTrail, runAsActor } from "../plugins/actor.js"; +import { PermissionManager } from "../utils/permission-manager.js"; +import { pluginLogger } from "../utils/logger.js"; +import { createCurrentOperationRepository } from "../database/repositories/factory.js"; + +export interface OperationEventInput { + eventId: string; + pluginId: string; + hostId?: number; + severity?: string; + payload?: unknown; + runId?: string; + depth?: number; +} + +export interface DeliveredEvent { + id: string; + eventId: string; + pluginId: string; + hostId?: number; + severity?: string; + payload?: unknown; + runId?: string; + depth: number; + at: string; +} + +export interface OperationEventSubscription { + /** Exact id, `.*` or `*`. */ + pattern: string; + handler: (event: DeliveredEvent) => void | Promise; + /** Only events for hosts this user can reach, and runs the handler as them. */ + userId?: string; + /** Skips repeats of one event and host inside this window. */ + cooldownMs?: number; +} + +interface Subscriber extends OperationEventSubscription { + pluginId: string; + lastFired: Map; +} + +const subscribers = new Set(); + +function matches(pattern: string, eventId: string): boolean { + if (pattern === "*" || pattern === eventId) return true; + return pattern.endsWith(".*") && eventId.startsWith(pattern.slice(0, -1)); +} + +export function subscribeOperationEvents( + pluginId: string, + subscription: OperationEventSubscription, +): () => void { + const entry = { ...subscription, pluginId, lastFired: new Map() }; + subscribers.add(entry); + return () => { + subscribers.delete(entry); + }; +} + +async function deliver(subscriber: Subscriber, event: DeliveredEvent) { + const key = `${event.eventId}|${event.hostId ?? ""}`; + if (subscriber.cooldownMs) { + const last = subscriber.lastFired.get(key) ?? 0; + if (Date.now() - last < subscriber.cooldownMs) return; + subscriber.lastFired.set(key, Date.now()); + } + + const { userId } = subscriber; + if (userId && event.hostId !== undefined) { + const access = await PermissionManager.getInstance().canAccessHost( + userId, + event.hostId, + "connect", + ); + if (!access.hasAccess) return; + } + + const fire = () => Promise.resolve(subscriber.handler(event)); + await (userId + ? runAsActor(userId, "service", fire, undefined, { depth: event.depth }) + : fire()); +} + +/** Never throws. An event must not be able to fail what caused it. */ +export async function emitOperationEvent( + input: OperationEventInput, +): Promise { + try { + const trail = getActorTrail(); + const depth = input.depth ?? trail.depth ?? 0; + const event: DeliveredEvent = { + id: randomUUID(), + eventId: input.eventId, + pluginId: input.pluginId, + hostId: input.hostId, + severity: input.severity, + payload: input.payload, + runId: input.runId ?? trail.runId, + depth, + at: new Date().toISOString(), + }; + + await createCurrentOperationRepository().insertEvent({ + id: event.id, + eventId: event.eventId, + pluginId: event.pluginId, + hostId: event.hostId ?? null, + severity: event.severity ?? null, + payload: + event.payload === undefined ? null : JSON.stringify(event.payload), + runId: event.runId ?? null, + createdAt: event.at, + }); + + if (depth >= MAX_EVENT_DEPTH) { + pluginLogger.warn("Event chain stopped at the depth limit", { + operation: "operation_event_depth", + eventId: event.eventId, + }); + return; + } + + for (const subscriber of subscribers) { + if (!matches(subscriber.pattern, event.eventId)) continue; + void deliver(subscriber, event).catch((error) => { + pluginLogger.warn("Event subscriber failed", { + operation: "operation_event_delivery", + pluginId: subscriber.pluginId, + error: error instanceof Error ? error.message : String(error), + }); + }); + } + } catch (error) { + pluginLogger.warn("Could not record an event", { + operation: "operation_event", + error: error instanceof Error ? error.message : String(error), + }); + } +} + +/** Test seam. */ +export function clearOperationEvents(): void { + subscribers.clear(); +} diff --git a/src/backend/operations/gate.ts b/src/backend/operations/gate.ts new file mode 100644 index 0000000000..46b2b5152c --- /dev/null +++ b/src/backend/operations/gate.ts @@ -0,0 +1,891 @@ +/** + * The one path every action takes, from any surface. + * + * In order: plugin up, grant still held, input valid, role permission, host + * access, key scope, destructive confirm, approval, limits, run row, audit. + * Handlers run in-process, so this is an access check for callers, not a + * sandbox around plugins. + * + * Handler failures stay here. They never reach the plugin error budget, or five + * failed hosts in a rollout would switch the plugin off. + */ + +import { randomUUID } from "node:crypto"; +import { + capText, + matchesWhen, + parseOperationInput, + redactFields, + riskRank, + type OperationAction, + type OperationRisk, +} from "@termix-ssh/plugin-sdk/operations"; +import { MAX_EVENT_DEPTH } from "./constants.js"; +import { getAction, type OperationRegistration } from "./registry.js"; +import { + canSelfApprove, + evaluateApproval, + type ApprovalDecision, +} from "./approvals.js"; +import { withLimits } from "./limits.js"; +import { emitOperationEvent } from "./events.js"; +import { dropResourceCache } from "./resources.js"; +import { loadOperationHost, type OperationHost } from "./hosts.js"; +import { runAsActor, type ActorOrigin } from "../plugins/actor.js"; +import { hasCapability } from "../plugins/permissions.js"; +import { qualifyPermission } from "../plugins/manifest.js"; +import { safeModeOn } from "../plugins/safe-mode.js"; +import { PermissionManager } from "../utils/permission-manager.js"; +import { logAudit } from "../utils/audit-logger.js"; +import { + decryptSystemSecret, + encryptSystemSecret, + isSystemEncrypted, +} from "../utils/system-secret-crypto.js"; +import { pluginLogger } from "../utils/logger.js"; +import { + createCurrentOperationRepository, + createCurrentUserRepository, +} from "../database/repositories/factory.js"; +import type { + OperationApprovalRecord, + OperationRunRecord, +} from "../database/repositories/operation-repository.js"; + +const MAX_OUTPUT = 32_000; +const DEFAULT_TIMEOUT_MS = 300_000; +const READ_TIMEOUT_MS = 60_000; +const DEFAULT_APPROVAL_MINUTES = 24 * 60; +const DRAIN_WAIT_MS = 10_000; + +export type OperationErrorCode = + | "NOT_FOUND" + | "UNAVAILABLE" + | "INVALID_INPUT" + | "FORBIDDEN" + | "SCOPE" + | "NEEDS_CONFIRM" + | "DENIED" + | "TOO_DEEP" + | "STALE" + | "CONFLICT" + | "SAFE_MODE"; + +const STATUS: Record = { + NOT_FOUND: 404, + UNAVAILABLE: 503, + INVALID_INPUT: 400, + FORBIDDEN: 403, + SCOPE: 403, + NEEDS_CONFIRM: 409, + DENIED: 403, + TOO_DEEP: 409, + STALE: 409, + CONFLICT: 409, + SAFE_MODE: 409, +}; + +/** Refused before anything ran. Carries a status so routes can pass it on. */ +export class OperationError extends Error { + readonly status: number; + + constructor( + readonly code: OperationErrorCode, + message: string, + readonly details?: unknown, + ) { + super(message); + this.name = "OperationError"; + this.status = STATUS[code]; + } +} + +/** A handler throws this when a host needs a person to sign in first. */ +export class OperationNeedsInteraction extends Error { + readonly code = "NEEDS_INTERACTION"; +} + +export interface OperationRequest { + actionId: string; + hostId?: number; + resourceKey?: string; + input?: unknown; + dryRun?: boolean; + /** A person agreed to a destructive action. */ + confirmed?: boolean; + idempotencyKey?: string; + batchId?: string; + undoOf?: string; + /** Background reads for tables and tiles. Writes no run row. */ + internal?: boolean; + userId: string; + origin: ActorOrigin; + apiKeyScopes?: string[] | null; + depth?: number; +} + +export interface OperationOutcome { + runId: string | null; + status: string; + summary?: string; + changed?: boolean; + data?: unknown; + error?: string; + errorCode?: string; + approvalId?: string; +} + +interface Prepared { + registration: OperationRegistration; + action: OperationAction; + pluginVersion: string; + input: Record; + host: OperationHost | null; + effectiveRisk: OperationRisk; +} + +/** The sealed copy an approval is bound to. */ +interface ApprovalSnapshot { + runId: string; + actionId: string; + actionVersion: number; + pluginVersion: string; + hostId: number | null; + hostUpdatedAt: string | null; + resourceKey: string | null; + input: Record; +} + +const active = new Map< + string, + { pluginId: string; controller: AbortController; done: Promise } +>(); +const draining = new Set(); + +function now(): string { + return new Date().toISOString(); +} + +function errorText(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +async function loadedPlugin(pluginId: string) { + const { getPluginRuntime } = await import("../plugins/index.js"); + return getPluginRuntime().loader.get(pluginId); +} + +async function usernameOf(userId: string): Promise { + try { + const user = await createCurrentUserRepository().findById(userId); + return user?.username ?? userId; + } catch { + return userId; + } +} + +/** Every check that does not depend on who approved it. */ +async function prepare(request: OperationRequest): Promise { + if (safeModeOn()) { + throw new OperationError("SAFE_MODE", "Actions are off in safe mode"); + } + if ((request.depth ?? 0) > MAX_EVENT_DEPTH) { + throw new OperationError("TOO_DEEP", "Too many actions led to this one"); + } + + const registration = getAction(request.actionId); + if (!registration) { + throw new OperationError( + "NOT_FOUND", + `No action "${request.actionId}" is available`, + ); + } + const { action, pluginId } = registration; + const plugin = await loadedPlugin(pluginId); + if (plugin?.state !== "active" || draining.has(pluginId)) { + throw new OperationError("UNAVAILABLE", `${pluginId} is not running`); + } + // Rechecked at use, so revoking the grant stops it on the next call. + if ( + !(await hasCapability( + pluginId, + "operations:provide", + plugin.manifest.capabilities, + )) + ) { + throw new OperationError("UNAVAILABLE", `${pluginId} may not run actions`); + } + + const parsed = parseOperationInput(action.inputs, request.input); + if (parsed.errors.length > 0) { + throw new OperationError( + "INVALID_INPUT", + parsed.errors.join("; "), + parsed.errors, + ); + } + + const permissions = PermissionManager.getInstance(); + const permission = qualifyPermission(pluginId, action.permission); + if (!(await permissions.hasPermission(request.userId, permission))) { + throw new OperationError( + "FORBIDDEN", + `This needs the ${permission} permission`, + ); + } + + let host: OperationHost | null = null; + if (action.scope !== "server") { + if (request.hostId === undefined) { + throw new OperationError("INVALID_INPUT", "This action needs a host"); + } + if (action.scope === "resource" && !request.resourceKey) { + throw new OperationError("INVALID_INPUT", "This action needs a target"); + } + const access = await permissions.canAccessHost( + request.userId, + request.hostId, + action.hostAccess ?? "edit", + ); + if (!access.hasAccess) { + throw new OperationError( + "FORBIDDEN", + `You need ${action.hostAccess ?? "edit"} access to this host`, + ); + } + host = await loadOperationHost(request.hostId, plugin.manifest); + if (!host) throw new OperationError("NOT_FOUND", "Host not found"); + if (!matchesWhen(action.when, host)) { + throw new OperationError( + "UNAVAILABLE", + "This action does not apply to this host", + ); + } + } + + // A claim by the plugin, so only signed official plugins are taken at + // their word when rules decide what needs a person. + const official = + plugin.source === "bundled" || plugin.signedTier === "official"; + const effectiveRisk: OperationRisk = + !official && action.risk === "read" ? "change" : action.risk; + + return { + registration, + action, + pluginVersion: plugin.manifest.version, + input: parsed.values, + host, + effectiveRisk, + }; +} + +function checkKeyScope(request: OperationRequest, action: OperationAction) { + const scopes = request.apiKeyScopes; + if (scopes === undefined || scopes === null) return; + if (scopes.includes("operations:run")) return; + if (scopes.includes("operations:read") && action.risk === "read") return; + throw new OperationError("SCOPE", "This API key may not run this action"); +} + +function originText(origin: ActorOrigin): string { + return JSON.stringify(origin); +} + +function auditRun( + request: OperationRequest, + username: string, + action: OperationAction, + host: OperationHost | null, + success: boolean, + errorMessage?: string, + verb = "operation_run", +): void { + void logAudit({ + userId: request.userId, + username, + action: verb, + resourceType: "operation", + resourceId: action.id, + resourceName: host?.name ?? undefined, + details: originText(request.origin), + success, + errorMessage, + }).catch(() => {}); +} + +async function execute( + prepared: Prepared, + request: OperationRequest, + runId: string | null, + username: string, +): Promise { + const { action, registration, input, host } = prepared; + const repository = createCurrentOperationRepository(); + const controller = new AbortController(); + const dryRun = request.dryRun === true; + const timeoutMs = + action.timeoutMs ?? + (action.risk === "read" ? READ_TIMEOUT_MS : DEFAULT_TIMEOUT_MS); + const lines: string[] = []; + const context = { + hostId: request.hostId, + resourceKey: request.resourceKey, + runId, + dryRun, + signal: controller.signal, + log: (line: string) => { + if (lines.length < 200) lines.push(line); + }, + }; + + const work = async () => { + if (dryRun && action.dryRun !== "native") { + const summary = registration.handler.describe + ? await registration.handler.describe(input, context) + : `Would run ${action.id}`; + return { summary, changed: false }; + } + return registration.handler.run(input, context); + }; + + const timer = setTimeout(() => controller.abort(), timeoutMs); + timer.unref?.(); + const aborted = new Promise((_, reject) => { + controller.signal.addEventListener("abort", () => + reject(new Error("The action was stopped or timed out")), + ); + }); + aborted.catch(() => {}); + + // Settles when the handler really stops, which can be after the run gives + // up on it. A plugin going down waits on this, not on the run. + let handlerStopped: () => void = () => {}; + const stopped = new Promise((resolve) => { + handlerStopped = resolve; + }); + + const startWork = () => { + // Cancelled while waiting for a slot, so it never starts. + if (controller.signal.aborted) { + handlerStopped(); + return aborted; + } + const handling = Promise.resolve().then(work); + void handling.finally(handlerStopped).catch(() => {}); + return Promise.race([handling, aborted]); + }; + + const running = withLimits( + { hostId: request.hostId, exclusive: action.exclusive }, + () => + runAsActor(request.userId, "service", startWork, undefined, { + origin: request.origin, + apiKeyScopes: request.apiKeyScopes, + runId: runId ?? undefined, + depth: (request.depth ?? 0) + 1, + }), + ); + if (runId) { + active.set(runId, { + pluginId: registration.pluginId, + controller, + done: stopped, + }); + } + + let outcome: OperationOutcome; + try { + const result = await running; + const data = redactFields(action.outputs, result.data); + outcome = { + runId, + status: "success", + summary: result.summary, + changed: result.changed, + data, + }; + if (runId) { + await repository.updateRunFrom(runId, "running", { + status: "success", + summary: capText(result.summary ?? "", 2000), + changed: result.changed ?? null, + output: + data === undefined ? null : capText(JSON.stringify(data), MAX_OUTPUT), + undoInput: result.undoInput ? JSON.stringify(result.undoInput) : null, + sealedInput: null, + finishedAt: now(), + }); + } + } catch (error) { + const needsPerson = + (error as { code?: unknown } | null)?.code === "NEEDS_INTERACTION"; + const stopping = draining.has(registration.pluginId); + const status = needsPerson + ? "needs_interaction" + : stopping + ? "interrupted" + : "failed"; + const message = stopping + ? "The plugin stopped during this run. Check the host." + : capText(errorText(error), 2000); + outcome = { runId, status, error: message }; + if (needsPerson) outcome.errorCode = "NEEDS_INTERACTION"; + if (runId) { + await repository.updateRunFrom(runId, "running", { + status, + error: message, + errorCode: outcome.errorCode ?? null, + sealedInput: null, + finishedAt: now(), + }); + } + } finally { + clearTimeout(timer); + if (runId && !draining.has(registration.pluginId)) active.delete(runId); + } + + if (action.risk !== "read" && !dryRun) { + if (request.hostId !== undefined && outcome.status === "success") { + dropResourceCache(request.hostId); + } + auditRun( + request, + username, + action, + host, + outcome.status === "success", + outcome.error, + ); + void emitOperationEvent({ + eventId: + outcome.status === "success" + ? "operation.succeeded" + : "operation.failed", + pluginId: registration.pluginId, + hostId: request.hostId, + severity: outcome.status === "success" ? "info" : "warning", + payload: { + actionId: action.id, + summary: outcome.summary, + error: outcome.error, + origin: request.origin, + }, + runId: runId ?? undefined, + depth: (request.depth ?? 0) + 1, + }); + } + return outcome; +} + +function outcomeOf( + run: OperationRunRecord, + approvalId?: string, +): OperationOutcome { + return { + runId: run.id, + status: run.status, + summary: run.summary ?? undefined, + changed: run.changed ?? undefined, + error: run.error ?? undefined, + errorCode: run.errorCode ?? undefined, + approvalId, + }; +} + +async function requestApproval( + prepared: Prepared, + request: OperationRequest, + decision: ApprovalDecision, + run: { id: string; username: string; createdAt: string }, +): Promise { + const { action, host, input } = prepared; + const repository = createCurrentOperationRepository(); + const snapshot: ApprovalSnapshot = { + runId: run.id, + actionId: action.id, + actionVersion: action.version ?? 1, + pluginVersion: prepared.pluginVersion, + hostId: request.hostId ?? null, + hostUpdatedAt: host?.updatedAt ?? null, + resourceKey: request.resourceKey ?? null, + input, + }; + await repository.updateRunFrom(run.id, "created", { + status: "pending_approval", + summary: decision.reason ?? null, + sealedInput: await encryptSystemSecret(JSON.stringify(snapshot)), + }); + const minutes = decision.expiresInMinutes ?? DEFAULT_APPROVAL_MINUTES; + const approvalId = randomUUID(); + await repository.insertApproval({ + id: approvalId, + runId: run.id, + requestedBy: request.userId, + reason: decision.reason ?? null, + allowSelf: decision.allowSelf === true || canSelfApprove(request.origin), + status: "pending", + createdAt: run.createdAt, + expiresAt: new Date(Date.now() + minutes * 60_000).toISOString(), + }); + void emitOperationEvent({ + eventId: "operation.approval-requested", + pluginId: prepared.registration.pluginId, + hostId: request.hostId, + severity: "warning", + payload: { actionId: action.id, approvalId }, + runId: run.id, + }); + return { runId: run.id, status: "pending_approval", approvalId }; +} + +/** Runs an action, or parks it for approval. Throws OperationError if refused. */ +export async function runOperation( + request: OperationRequest, +): Promise { + const registration = getAction(request.actionId); + if (registration) checkKeyScope(request, registration.action); + + const repository = createCurrentOperationRepository(); + if (request.idempotencyKey) { + const earlier = await repository.findByIdempotencyKey( + request.userId, + request.idempotencyKey, + ); + if (earlier) return outcomeOf(earlier); + } + + const prepared = await prepare(request); + const { action } = prepared; + const dryRun = request.dryRun === true; + const isRead = action.risk === "read"; + + if (action.risk === "destructive" && !dryRun && !request.confirmed) { + throw new OperationError("NEEDS_CONFIRM", "This action needs a confirm"); + } + + let decision: ApprovalDecision = { decision: "allow" }; + if (!dryRun && !request.internal) { + decision = await evaluateApproval({ + actionId: action.id, + pluginId: prepared.registration.pluginId, + risk: action.risk, + effectiveRisk: prepared.effectiveRisk, + origin: request.origin, + userId: request.userId, + hostId: request.hostId, + hostTags: prepared.host?.tags, + batchId: request.batchId, + }); + } + const username = await usernameOf(request.userId); + if (decision.decision === "deny") { + auditRun( + request, + username, + action, + prepared.host, + false, + decision.reason, + "operation_denied", + ); + throw new OperationError( + "DENIED", + decision.reason ?? "A rule refused this action", + ); + } + + // Internal reads poll constantly and would flood history and the save loop. + if (request.internal && isRead) { + return execute(prepared, request, null, username); + } + + const run = { + id: randomUUID(), + actionId: action.id, + actionVersion: action.version ?? 1, + pluginId: prepared.registration.pluginId, + pluginVersion: prepared.pluginVersion, + userId: request.userId, + username, + origin: originText(request.origin), + hostId: request.hostId ?? null, + hostName: prepared.host?.name ?? null, + batchId: request.batchId ?? null, + dryRun, + risk: action.risk, + status: decision.decision === "approve" ? "created" : "running", + input: JSON.stringify(redactFields(action.inputs, prepared.input)), + undoOf: request.undoOf ?? null, + idempotencyKey: request.idempotencyKey ?? null, + createdAt: now(), + startedAt: decision.decision === "approve" ? null : now(), + }; + await repository.insertRun(run); + + if (decision.decision === "approve") { + return requestApproval(prepared, request, decision, run); + } + return execute(prepared, request, run.id, username); +} + +function originOf(run: OperationRunRecord): ActorOrigin { + try { + return JSON.parse(run.origin) as ActorOrigin; + } catch { + return { kind: "api" }; + } +} + +async function failStale(run: OperationRunRecord, why: string): Promise { + await createCurrentOperationRepository().updateRunFrom( + run.id, + "pending_approval", + { + status: "rejected", + error: why, + sealedInput: null, + finishedAt: now(), + }, + ); +} + +/** + * Records a person's decision. One shot: the row moves from pending once, so a + * second click or a replay does nothing. What runs is the sealed copy, never + * what the caller sends now. + */ +export async function decideOperationApproval(options: { + approvalId: string; + approverId: string; + approve: boolean; + comment?: string; + impersonated?: boolean; +}): Promise { + const { approvalId, approverId, approve, comment } = options; + const repository = createCurrentOperationRepository(); + const approval = await repository.findApproval(approvalId); + if (!approval) throw new OperationError("NOT_FOUND", "Approval not found"); + if (approval.status !== "pending") { + throw new OperationError("CONFLICT", "This was already decided"); + } + const run = await repository.findRun(approval.runId); + if (!run) throw new OperationError("NOT_FOUND", "Run not found"); + + if (options.impersonated) { + throw new OperationError("FORBIDDEN", "You cannot approve as another user"); + } + if (approval.requestedBy === approverId && !approval.allowSelf) { + throw new OperationError( + "FORBIDDEN", + "Someone else has to approve your request", + ); + } + + if (new Date(approval.expiresAt).getTime() < Date.now()) { + await repository.decideApproval(approvalId, "expired", null, null, now()); + await failStale(run, "The approval expired"); + throw new OperationError("STALE", "This approval expired"); + } + + // Approving needs the same rights as running it. + const registration = getAction(run.actionId); + if (registration) { + const permissions = PermissionManager.getInstance(); + const needed = qualifyPermission( + registration.pluginId, + registration.action.permission, + ); + const allowed = + (await permissions.hasPermission(approverId, needed)) && + (run.hostId === null || + ( + await permissions.canAccessHost( + approverId, + run.hostId, + registration.action.hostAccess ?? "edit", + ) + ).hasAccess); + if (!allowed) { + throw new OperationError( + "FORBIDDEN", + "You need the same access as this action to approve it", + ); + } + } + + const won = await repository.decideApproval( + approvalId, + approve ? "approved" : "rejected", + approverId, + comment?.slice(0, 500) ?? null, + now(), + ); + if (!won) throw new OperationError("CONFLICT", "This was already decided"); + + const username = await usernameOf(approverId); + void logAudit({ + userId: approverId, + username, + action: approve ? "operation_approved" : "operation_rejected", + resourceType: "operation", + resourceId: run.actionId, + details: run.id, + success: true, + }).catch(() => {}); + + if (!approve) { + await repository.updateRunFrom(run.id, "pending_approval", { + status: "rejected", + sealedInput: null, + finishedAt: now(), + }); + return { runId: run.id, status: "rejected" }; + } + return runApproved(run, approval); +} + +async function readSnapshot( + run: OperationRunRecord, +): Promise { + if (!run.sealedInput || !isSystemEncrypted(run.sealedInput)) return null; + try { + const snapshot = JSON.parse( + await decryptSystemSecret(run.sealedInput), + ) as ApprovalSnapshot; + return snapshot.runId === run.id ? snapshot : null; + } catch { + return null; + } +} + +async function runApproved( + run: OperationRunRecord, + approval: OperationApprovalRecord, +): Promise { + const snapshot = await readSnapshot(run); + if (!snapshot || !run.userId) { + await failStale(run, "The approved request could not be read"); + throw new OperationError("STALE", "The approved request could not be read"); + } + + const request: OperationRequest = { + actionId: snapshot.actionId, + hostId: snapshot.hostId ?? undefined, + resourceKey: snapshot.resourceKey ?? undefined, + input: snapshot.input, + confirmed: true, + batchId: run.batchId ?? undefined, + userId: run.userId, + origin: originOf(run), + }; + + // The requester's own rights, as of now. They may have changed. + let prepared: Prepared; + try { + prepared = await prepare(request); + } catch (error) { + await failStale(run, errorText(error)); + throw error; + } + + const changedSince = + prepared.pluginVersion !== snapshot.pluginVersion || + (prepared.action.version ?? 1) !== snapshot.actionVersion || + (prepared.host?.updatedAt ?? null) !== snapshot.hostUpdatedAt; + if (changedSince) { + await failStale(run, "The host or plugin changed after this was requested"); + throw new OperationError( + "STALE", + "The host or plugin changed after this was requested", + ); + } + + const repository = createCurrentOperationRepository(); + const claimed = await repository.updateRunFrom(run.id, "pending_approval", { + status: "running", + startedAt: now(), + }); + if (!claimed) throw new OperationError("CONFLICT", "This already ran"); + + const outcome = await execute(prepared, request, run.id, run.username); + return { ...outcome, approvalId: approval.id }; +} + +export async function cancelOperationRun( + run: OperationRunRecord, +): Promise { + const repository = createCurrentOperationRepository(); + if (run.status === "pending_approval") { + const approval = await repository.findApprovalForRun(run.id); + if (approval) { + await repository.decideApproval( + approval.id, + "rejected", + null, + "cancelled", + now(), + ); + } + return repository.updateRunFrom(run.id, "pending_approval", { + status: "cancelled", + sealedInput: null, + finishedAt: now(), + }); + } + const running = active.get(run.id); + if (!running) return false; + running.controller.abort(); + return true; +} + +/** + * Called when a plugin stops, before its SSH connections are torn down. New + * runs are refused and running ones told to stop. Anything still going after + * the wait has an unknown outcome and is never retried. + */ +export async function drainPlugin(pluginId: string): Promise { + draining.add(pluginId); + try { + const runs = [...active.entries()].filter( + ([, entry]) => entry.pluginId === pluginId, + ); + if (runs.length === 0) return; + for (const [, entry] of runs) entry.controller.abort(); + await Promise.race([ + Promise.all(runs.map(([, entry]) => entry.done)), + new Promise((resolve) => setTimeout(resolve, DRAIN_WAIT_MS)), + ]); + const repository = createCurrentOperationRepository(); + for (const [runId] of runs) { + await repository.updateRunFrom(runId, "running", { + status: "interrupted", + error: "The plugin stopped during this run. Check the host.", + sealedInput: null, + finishedAt: now(), + }); + active.delete(runId); + } + } catch (error) { + pluginLogger.warn("Could not finish stopping runs", { + operation: "operation_drain", + pluginId, + error: errorText(error), + }); + } finally { + draining.delete(pluginId); + } +} + +export function riskAtLeast( + risk: OperationRisk, + floor: OperationRisk, +): boolean { + return riskRank(risk) >= riskRank(floor); +} + +/** Test seam. */ +export function clearOperationGate(): void { + active.clear(); + draining.clear(); +} diff --git a/src/backend/operations/hosts.ts b/src/backend/operations/hosts.ts new file mode 100644 index 0000000000..6f4de03454 --- /dev/null +++ b/src/backend/operations/hosts.ts @@ -0,0 +1,68 @@ +/** + * The small view of a host the operations layer needs: enough to name it, test + * an action's `when`, and notice it changed. No secret field is ever read here. + */ + +import type { OperationWhen } from "@termix-ssh/plugin-sdk/operations"; +import type { PluginManifest } from "../plugins/manifest.js"; +import { getSetting } from "../plugins/settings.js"; +import { createCurrentHostResolutionRepository } from "../database/repositories/factory.js"; + +export interface OperationHost { + id: number; + name: string; + updatedAt: string | null; + tags: string[]; + os: string | null; + /** Only the setting keys some `when` asks about. */ + settings: Record; +} + +export function splitTags(tags: string | null | undefined): string[] { + return (tags ?? "") + .split(",") + .map((tag) => tag.trim()) + .filter(Boolean); +} + +async function settingsFor( + manifest: PluginManifest, + hostId: number, +): Promise> { + const keys = new Set(); + for (const action of manifest.contributes?.operations?.actions ?? []) { + for (const key of Object.keys(action.when?.settings ?? {})) keys.add(key); + } + const settings: Record = {}; + for (const key of keys) { + settings[key] = await getSetting(manifest, "host", hostId, key); + } + return settings; +} + +/** A host with the settings its plugin's `when` rules read. */ +export async function loadOperationHost( + hostId: number, + manifest: PluginManifest, +): Promise { + const repository = createCurrentHostResolutionRepository(); + const ownerId = await repository.findHostOwnerId(hostId); + if (!ownerId) return null; + const host = (await repository.findHostById(hostId, ownerId)) as { + name: string | null; + ip: string; + tags: string | null; + updatedAt?: string | null; + } | null; + if (!host) return null; + return { + id: hostId, + name: host.name || host.ip, + updatedAt: host.updatedAt ?? null, + tags: splitTags(host.tags), + os: null, + settings: await settingsFor(manifest, hostId), + }; +} + +export type { OperationWhen }; diff --git a/src/backend/operations/key-scopes.ts b/src/backend/operations/key-scopes.ts new file mode 100644 index 0000000000..4db923588d --- /dev/null +++ b/src/backend/operations/key-scopes.ts @@ -0,0 +1,44 @@ +/** + * What a scoped API key may reach. Enforced where keys are authenticated, so a + * limited key cannot step around the gate through /plugin-api or a ws ticket. A + * key with no scopes has full access, which is every key made before this. + */ + +export const API_KEY_SCOPES = ["operations:read", "operations:run"] as const; + +const MCP_PATH = /^\/plugin-api\/[a-z0-9-]+\/mcp(\/|$)/; +const OPERATIONS_PATH = /^\/operations(\/|$)/; + +export function parseKeyScopes( + raw: string | null | undefined, +): string[] | null { + if (!raw) return null; + try { + const value = JSON.parse(raw); + return Array.isArray(value) + ? value.filter((scope): scope is string => typeof scope === "string") + : []; + } catch { + // Unreadable means no access, not full access. + return []; + } +} + +export function keyScopeAllows( + scopes: string[] | null, + method: string, + path: string, +): boolean { + if (scopes === null) return true; + const clean = path.split("?")[0]; + if (MCP_PATH.test(clean)) { + return ( + scopes.includes("operations:read") || scopes.includes("operations:run") + ); + } + if (!OPERATIONS_PATH.test(clean)) return false; + if (scopes.includes("operations:run")) return true; + if (!scopes.includes("operations:read")) return false; + // The gate still checks each action's risk on a run. + return method === "GET" || /^\/operations\/[^/]+\/run$/.test(clean); +} diff --git a/src/backend/operations/limits.ts b/src/backend/operations/limits.ts new file mode 100644 index 0000000000..11ecb1eb50 --- /dev/null +++ b/src/backend/operations/limits.ts @@ -0,0 +1,90 @@ +/** + * Caps on how much runs at once. The SSH pool caps per key, not per host, so a + * large rollout or an all hosts table needs its own limit here. + */ + +export const MAX_RUNNING = 32; +export const MAX_PER_HOST = 3; + +class Slots { + private used = 0; + private readonly waiting: (() => void)[] = []; + + constructor(private readonly size: number) {} + + async take(): Promise { + if (this.used < this.size) { + this.used++; + return; + } + await new Promise((resolve) => this.waiting.push(resolve)); + } + + give(): void { + const next = this.waiting.shift(); + if (next) next(); + else this.used--; + } + + get idle(): boolean { + return this.used === 0 && this.waiting.length === 0; + } +} + +const everything = new Slots(MAX_RUNNING); +const perHost = new Map(); +const exclusive = new Map(); + +function slotsFor( + map: Map, + hostId: number, + size: number, +): Slots { + let slots = map.get(hostId); + if (!slots) { + slots = new Slots(size); + map.set(hostId, slots); + } + return slots; +} + +/** Runs `work` once there is room. Exclusive work on a host goes one at a time. */ +export async function withLimits( + options: { hostId?: number; exclusive?: boolean }, + work: () => Promise, +): Promise { + const { hostId } = options; + const held: { map?: Map; slots: Slots }[] = []; + const hold = async (slots: Slots, map?: Map) => { + await slots.take(); + held.push({ slots, map }); + }; + + try { + // Same order everywhere, so two runs cannot wait on each other. + if (hostId !== undefined && options.exclusive) { + await hold(slotsFor(exclusive, hostId, 1), exclusive); + } + if (hostId !== undefined) { + await hold(slotsFor(perHost, hostId, MAX_PER_HOST), perHost); + } + await hold(everything); + return await work(); + } finally { + for (const { slots, map } of held.reverse()) { + slots.give(); + if (map && hostId !== undefined && slots.idle) map.delete(hostId); + } + } +} + +/** Shares one in-flight promise per key, so one read serves every waiter. */ +const inflight = new Map>(); + +export function coalesce(key: string, work: () => Promise): Promise { + const running = inflight.get(key); + if (running) return running as Promise; + const started = work().finally(() => inflight.delete(key)); + inflight.set(key, started); + return started; +} diff --git a/src/backend/operations/maintenance.ts b/src/backend/operations/maintenance.ts new file mode 100644 index 0000000000..88400ff05b --- /dev/null +++ b/src/backend/operations/maintenance.ts @@ -0,0 +1,47 @@ +/** Startup sweep and periodic cleanup for runs, approvals and events. */ + +import { RETENTION_DAYS } from "./constants.js"; +import { pluginLogger } from "../utils/logger.js"; +import { createCurrentOperationRepository } from "../database/repositories/factory.js"; + +const SWEEP_MS = 10 * 60_000; + +let timer: ReturnType | null = null; + +/** Parks expired approvals and trims history. Returns rows removed. */ +export async function sweepOperations(at = new Date()): Promise { + const repository = createCurrentOperationRepository(); + const iso = at.toISOString(); + for (const approval of await repository.listExpiredApprovals(iso)) { + await repository.decideApproval(approval.id, "expired", null, null, iso); + await repository.updateRunFrom(approval.runId, "pending_approval", { + status: "expired", + sealedInput: null, + finishedAt: iso, + }); + } + const cutoff = new Date(at.getTime() - RETENTION_DAYS * 86_400_000); + return repository.prune(cutoff.toISOString()); +} + +/** Anything still running at boot lost its process, so its outcome is unknown. */ +export async function startOperationsMaintenance(): Promise { + await createCurrentOperationRepository().markRunningInterrupted( + new Date().toISOString(), + ); + await sweepOperations(); + timer ??= setInterval(() => { + sweepOperations().catch((error) => { + pluginLogger.warn("Operations cleanup failed", { + operation: "operations_sweep", + error: error instanceof Error ? error.message : String(error), + }); + }); + }, SWEEP_MS); + timer.unref?.(); +} + +export function stopOperationsMaintenance(): void { + if (timer) clearInterval(timer); + timer = null; +} diff --git a/src/backend/operations/registry.ts b/src/backend/operations/registry.ts new file mode 100644 index 0000000000..ce4d59021f --- /dev/null +++ b/src/backend/operations/registry.ts @@ -0,0 +1,84 @@ +/** + * Handlers plugins registered for the actions their manifest declares. + * + * Same generation rule as service-registry.ts: a plugin that crashed and + * restarted must not have its old disposer remove the new registration, and a + * run that resolved a handler before a reload must not call the stale one. + */ + +import type { + OperationAction, + OperationResult, +} from "@termix-ssh/plugin-sdk/operations"; + +export interface OperationRunContext { + hostId?: number; + resourceKey?: string; + runId: string | null; + dryRun: boolean; + signal: AbortSignal; + log: (line: string) => void; +} + +export interface OperationHandler { + run: ( + input: Record, + context: OperationRunContext, + ) => Promise | OperationResult; + /** What a dry run reports when the action has no native dry run. */ + describe?: ( + input: Record, + context: OperationRunContext, + ) => string | Promise; +} + +export interface OperationRegistration { + pluginId: string; + action: OperationAction; + handler: OperationHandler; + generation: number; +} + +const registrations = new Map(); +let generationCounter = 0; + +export function registerAction( + pluginId: string, + action: OperationAction, + handler: OperationHandler, +): OperationRegistration { + const existing = registrations.get(action.id); + if (existing && existing.pluginId !== pluginId) { + throw new Error( + `Action "${action.id}" is already registered by ${existing.pluginId}`, + ); + } + const entry = { + pluginId, + action, + handler, + generation: ++generationCounter, + }; + registrations.set(action.id, entry); + return entry; +} + +/** Removes only the exact registration given. */ +export function revokeAction(registration: OperationRegistration): boolean { + const current = registrations.get(registration.action.id); + if (!current || current.generation !== registration.generation) return false; + return registrations.delete(registration.action.id); +} + +export function getAction(id: string): OperationRegistration | undefined { + return registrations.get(id); +} + +export function listActions(): OperationRegistration[] { + return [...registrations.values()]; +} + +/** Test seam. */ +export function clearOperationRegistry(): void { + registrations.clear(); +} diff --git a/src/backend/operations/resources.ts b/src/backend/operations/resources.ts new file mode 100644 index 0000000000..0e5340f2ca --- /dev/null +++ b/src/backend/operations/resources.ts @@ -0,0 +1,231 @@ +/** + * Reads for All hosts tables and status tiles. + * + * Asking every host over SSH on each page load cannot hold up, so a request + * answers right away from memory and refreshes stale hosts behind it. The page + * polls and the table fills in as hosts answer. Results stay in memory, never + * SQLite, since a save rewrites the whole database. + * + * Keys include the user: someone a host is shared with can have their own + * login there and see different rows. + */ + +import type { + ResourceListResult, + SignalResult, + SignalStatus, +} from "@termix-ssh/plugin-sdk/operations"; +import { RESOURCE_FRESH_MS } from "./constants.js"; +import { coalesce } from "./limits.js"; +import { getAction } from "./registry.js"; +import { matchesWhen } from "@termix-ssh/plugin-sdk/operations"; +import { loadOperationHost } from "./hosts.js"; +import type { ActorOrigin } from "../plugins/actor.js"; +import { hostStatus } from "../plugins/host-status-provider.js"; +import { PermissionManager } from "../utils/permission-manager.js"; +import { loadProbeTargets } from "../plugins/ctx-probe.js"; + +export type HostReadState = + "ok" | "loading" | "offline" | "error" | "no-access" | "not-applicable"; + +interface CacheEntry { + value: unknown; + error?: string; + fetchedAt: number; +} + +export interface HostRead { + hostId: number; + state: HostReadState; + value?: T; + error?: string; + fetchedAt?: string; +} + +const cache = new Map(); +const MAX_ENTRIES = 20_000; + +function cacheKey(userId: string, actionId: string, hostId: number): string { + return `${userId}|${actionId}|${hostId}`; +} + +/** After a change, the next read of this host goes back to the host. */ +export function dropResourceCache(hostId: number): void { + const suffix = `|${hostId}`; + for (const key of cache.keys()) { + if (key.endsWith(suffix)) cache.delete(key); + } +} + +function remember(key: string, entry: CacheEntry): void { + if (cache.size >= MAX_ENTRIES) { + const oldest = cache.keys().next().value; + if (oldest) cache.delete(oldest); + } + cache.delete(key); + cache.set(key, entry); +} + +function isOffline(hostId: number): boolean { + const status = hostStatus.get(hostId) as + string | { status?: string } | null | undefined; + const value = typeof status === "string" ? status : status?.status; + return value === "offline"; +} + +interface ReadOptions { + userId: string; + actionId: string; + hostIds?: number[]; + refresh?: boolean; + input?: Record; + origin: ActorOrigin; +} + +function refreshHost( + options: ReadOptions, + hostId: number, + key: string, +): Promise { + return coalesce(key, async () => { + const { runOperation, OperationError } = await import("./gate.js"); + try { + const outcome = await runOperation({ + actionId: options.actionId, + hostId, + input: options.input, + internal: true, + userId: options.userId, + origin: options.origin, + }); + if (outcome.status !== "success") { + remember(key, { + value: undefined, + error: outcome.error ?? outcome.status, + fetchedAt: Date.now(), + }); + return; + } + remember(key, { value: outcome.data, fetchedAt: Date.now() }); + } catch (error) { + remember(key, { + value: undefined, + error: + error instanceof OperationError || error instanceof Error + ? error.message + : String(error), + fetchedAt: Date.now(), + }); + } + }); +} + +/** One entry per host the user can reach. Never waits on a host. */ +export async function readAcrossHosts( + options: ReadOptions, +): Promise[]> { + const registration = getAction(options.actionId); + if (!registration) return []; + const { action } = registration; + const { getPluginRuntime } = await import("../plugins/index.js"); + const manifest = getPluginRuntime().loader.get( + registration.pluginId, + )?.manifest; + if (!manifest) return []; + + const permissions = PermissionManager.getInstance(); + const targets = await loadProbeTargets(options.userId, options.hostIds); + const reads: HostRead[] = []; + + for (const target of targets) { + const hostId = target.id; + const access = await permissions.canAccessHost( + options.userId, + hostId, + action.hostAccess ?? "connect", + ); + if (!access.hasAccess) { + reads.push({ hostId, state: "no-access" }); + continue; + } + if (action.when) { + const host = await loadOperationHost(hostId, manifest); + if (!host || !matchesWhen(action.when, host)) continue; + } + + const key = cacheKey(options.userId, options.actionId, hostId); + const cached = cache.get(key); + const fresh = cached && Date.now() - cached.fetchedAt < RESOURCE_FRESH_MS; + + if (isOffline(hostId)) { + reads.push({ + hostId, + state: "offline", + value: cached?.value as T | undefined, + fetchedAt: cached + ? new Date(cached.fetchedAt).toISOString() + : undefined, + }); + continue; + } + if (!fresh || options.refresh) void refreshHost(options, hostId, key); + if (!cached) { + reads.push({ hostId, state: "loading" }); + } else if (cached.error) { + reads.push({ + hostId, + state: "error", + error: cached.error, + fetchedAt: new Date(cached.fetchedAt).toISOString(), + }); + } else { + reads.push({ + hostId, + state: "ok", + value: cached.value as T, + fetchedAt: new Date(cached.fetchedAt).toISOString(), + }); + } + } + return reads; +} + +export interface ResourceRow { + hostId: number; + key: string; + row: Record; +} + +/** Flattens per host reads into table rows, keeping each host's state. */ +export function flattenResourceRows( + reads: HostRead[], + keyField: string, +): { rows: ResourceRow[]; hosts: Omit, "value">[] } { + const rows: ResourceRow[] = []; + const hosts: Omit, "value">[] = []; + for (const read of reads) { + const { value, ...state } = read; + hosts.push(state); + for (const row of value?.rows ?? []) { + rows.push({ hostId: read.hostId, key: String(row[keyField]), row }); + } + } + return { rows, hosts }; +} + +const WORST: SignalStatus[] = ["fail", "warn", "unknown", "ok"]; + +/** The worst status across hosts, for a server level tile. */ +export function worstStatus( + results: (SignalResult | undefined)[], +): SignalStatus { + for (const status of WORST) { + if (results.some((result) => result?.status === status)) return status; + } + return "unknown"; +} + +/** Test seam. */ +export function clearResourceCache(): void { + cache.clear(); +} diff --git a/src/backend/plugins/actor.ts b/src/backend/plugins/actor.ts index f0a49b57bb..d29c3cf1ca 100644 --- a/src/backend/plugins/actor.ts +++ b/src/backend/plugins/actor.ts @@ -20,7 +20,28 @@ import { AsyncLocalStorage } from "node:async_hooks"; -interface ActorStore { +/** How a call reached core, for run history and approval rules. */ +export interface ActorOrigin { + kind: "ui" | "api" | "plugin"; + /** Which plugin asked, set by core from the ctx that made the call. */ + pluginId?: string; + /** What the plugin was doing: automation, assistant, mcp, rollout and so on. */ + via?: string; +} + +/** Follows a call through plugin work, so nothing loses who started it. */ +export interface ActorTrail { + origin?: ActorOrigin; + apiKeyId?: string; + /** Null means a key with full access. */ + apiKeyScopes?: string[] | null; + /** The operation run this work belongs to. */ + runId?: string; + /** How many operations or events led here. */ + depth?: number; +} + +interface ActorStore extends ActorTrail { userId: string; /** Where the identity came from, for the audit line. */ source: "request" | "asUser" | "service"; @@ -35,8 +56,34 @@ export function runAsActor( source: ActorStore["source"], fn: () => T, sessionId?: string, + trail?: ActorTrail, ): T { - return storage.run({ userId, source, sessionId }, fn); + const parent = storage.getStore(); + return storage.run( + { + origin: parent?.origin, + apiKeyId: parent?.apiKeyId, + apiKeyScopes: parent?.apiKeyScopes, + runId: parent?.runId, + depth: parent?.depth, + ...trail, + userId, + source, + sessionId, + }, + fn, + ); +} + +export function getActorTrail(): ActorTrail { + const store = storage.getStore(); + return { + origin: store?.origin, + apiKeyId: store?.apiKeyId, + apiKeyScopes: store?.apiKeyScopes, + runId: store?.runId, + depth: store?.depth, + }; } export function getActorSessionId(): string | undefined { diff --git a/src/backend/plugins/ctx-operations.ts b/src/backend/plugins/ctx-operations.ts new file mode 100644 index 0000000000..5c895a1ea3 --- /dev/null +++ b/src/backend/plugins/ctx-operations.ts @@ -0,0 +1,195 @@ +/** + * ctx.operations: declaring what a plugin can do, and asking for another + * plugin's action to run. + * + * invoke acts for the current user, so on its own it would let any plugin spend + * that user's full authority. It needs the capability, the action named in the + * manifest's invokes list, and the origin always names the calling plugin, set + * here from the closure rather than from anything the plugin passes. + */ + +import type { + PluginOperations, + PluginOperationInvokeOptions, +} from "@termix-ssh/plugin-sdk/backend"; +import { + invokeAllowed, + type OperationAction, +} from "@termix-ssh/plugin-sdk/operations"; +import type { PluginManifest } from "./manifest.js"; +import { assertCapability, capabilityRefused } from "./permissions.js"; +import { getActor, getActorTrail, type ActorOrigin } from "./actor.js"; +import type { DisposableBag } from "./disposables.js"; +import { + registerAction, + revokeAction, + type OperationHandler, +} from "../operations/registry.js"; +import { provideApprovalPolicy } from "../operations/approvals.js"; +import { + emitOperationEvent, + subscribeOperationEvents, +} from "../operations/events.js"; +import { + drainPlugin, + OperationError, + runOperation, +} from "../operations/gate.js"; +import { buildCatalog } from "../operations/catalog.js"; + +interface Deps { + manifest: PluginManifest; + bag: DisposableBag; +} + +export function createPluginOperations({ + manifest, + bag, +}: Deps): PluginOperations { + const pluginId = manifest.id; + const declared = manifest.capabilities; + const contribution = manifest.contributes?.operations; + let drainRegistered = false; + + const declaredAction = (id: string): OperationAction => { + const action = contribution?.actions?.find((entry) => entry.id === id); + if (!action) { + throw new Error( + `Plugin ${pluginId} cannot register action "${id}": it is not declared in contributes.operations`, + ); + } + return action; + }; + + const actingUser = (): string => { + const userId = getActor(); + if (!userId) { + throw new Error( + "ctx.operations needs an acting user: call it inside a request or ctx.asUser", + ); + } + return userId; + }; + + return { + register: (id, handler) => { + if (!declared.includes("operations:provide")) { + throw capabilityRefused(pluginId, "operations:provide"); + } + const registration = registerAction( + pluginId, + declaredAction(id), + handler as OperationHandler, + ); + bag.add(() => void revokeAction(registration), `action "${id}"`); + // Added once and after the actions, so disposal, which runs newest + // first, drains before anything the handlers use is torn down. + if (!drainRegistered) { + drainRegistered = true; + bag.add(() => drainPlugin(pluginId), "operations drain"); + } + }, + + // A plugin's own route running its own action, so a button press lands in + // the same history. The caller stays the origin, not this plugin. + run: async (actionId, options = {}) => { + if (!declared.includes("operations:provide")) { + throw capabilityRefused(pluginId, "operations:provide"); + } + declaredAction(actionId); + const trail = getActorTrail(); + return runOperation({ + actionId, + hostId: options.hostId, + resourceKey: options.resourceKey, + input: options.input, + dryRun: options.dryRun, + confirmed: options.confirmed, + idempotencyKey: options.idempotencyKey, + batchId: options.batchId, + userId: actingUser(), + origin: trail.origin ?? { kind: "plugin", pluginId }, + apiKeyScopes: trail.apiKeyScopes, + depth: trail.depth, + }); + }, + + invoke: async (actionId, options = {}) => { + await assertCapability(pluginId, "operations:invoke", declared); + if (!invokeAllowed(contribution?.invokes, actionId)) { + throw new OperationError( + "FORBIDDEN", + `${pluginId} did not declare that it runs ${actionId}`, + ); + } + const trail = getActorTrail(); + const origin: ActorOrigin = { + kind: "plugin", + pluginId, + via: (options as PluginOperationInvokeOptions).via, + }; + return runOperation({ + actionId, + hostId: options.hostId, + resourceKey: options.resourceKey, + input: options.input, + dryRun: options.dryRun, + confirmed: options.confirmed, + idempotencyKey: options.idempotencyKey, + batchId: options.batchId, + userId: actingUser(), + origin, + apiKeyScopes: trail.apiKeyScopes, + depth: trail.depth, + }); + }, + + list: async (options) => { + await assertCapability(pluginId, "operations:invoke", declared); + return buildCatalog({ + userId: actingUser(), + lang: options?.lang, + surface: options?.surface, + }); + }, + + emit: async (eventId, payload, options) => { + if (!declared.includes("operations:provide")) { + throw capabilityRefused(pluginId, "operations:provide"); + } + const event = contribution?.events?.find((entry) => entry.id === eventId); + if (!event) { + throw new Error( + `Plugin ${pluginId} cannot emit "${eventId}": it is not declared in contributes.operations`, + ); + } + await emitOperationEvent({ + eventId, + pluginId, + hostId: options?.hostId, + severity: event.severity, + payload, + }); + }, + + onEvent: (subscription) => { + if (!declared.includes("operations:invoke")) { + throw capabilityRefused(pluginId, "operations:invoke"); + } + const remove = subscribeOperationEvents(pluginId, subscription); + bag.add(remove, `events "${subscription.pattern}"`); + return remove; + }, + + approvals: { + provide: (provider) => { + if (!declared.includes("operations:approve")) { + throw capabilityRefused(pluginId, "operations:approve"); + } + const remove = provideApprovalPolicy(pluginId, manifest, provider); + bag.add(remove, "approval rules"); + return remove; + }, + }, + }; +} diff --git a/src/backend/plugins/ctx.ts b/src/backend/plugins/ctx.ts index 73c35428a2..4935658de2 100644 --- a/src/backend/plugins/ctx.ts +++ b/src/backend/plugins/ctx.ts @@ -68,6 +68,7 @@ import { createPluginAuth, createPluginSsh } from "./ctx-ssh-auth.js"; import { createPluginHosts } from "./ctx-hosts.js"; import { createPluginSchedule } from "./schedule.js"; import { createPluginFetch, createPluginNotify } from "./ctx-notify.js"; +import { createPluginOperations } from "./ctx-operations.js"; import { createPluginProcess } from "./ctx-process.js"; import { createPluginSystem } from "./ctx-system.js"; import { createPluginPlugins } from "./ctx-plugins.js"; @@ -976,6 +977,8 @@ export function createPluginContext( void reportRuntimeError(pluginId, error); }), ssh: createPluginSsh({ manifest, bag: handle.bag, audit: auditCall }), + operations: createPluginOperations({ manifest, bag: handle.bag }), + notify: createPluginNotify({ manifest, audit: auditCall, diff --git a/src/backend/plugins/http.ts b/src/backend/plugins/http.ts index 7ebab8745c..e8cd765f26 100644 --- a/src/backend/plugins/http.ts +++ b/src/backend/plugins/http.ts @@ -354,15 +354,21 @@ export function createPluginRouter({ // After auth, so the actor is the user core authenticated rather than // whatever the request claimed. outer.use((req: Request, _res: Response, next: NextFunction) => { - const { userId, sessionId } = req as Request & { + const { userId, sessionId, apiKeyId, apiKeyScopes } = req as Request & { userId?: string; sessionId?: string; + apiKeyId?: string; + apiKeyScopes?: string[] | null; }; if (!userId) { next(); return; } - runAsActor(userId, "request", () => next(), sessionId); + runAsActor(userId, "request", () => next(), sessionId, { + origin: { kind: apiKeyId ? "api" : "ui" }, + apiKeyId, + apiKeyScopes, + }); }); outer.use(inner); diff --git a/src/backend/starter.ts b/src/backend/starter.ts index 10bd20a281..d8b09c8a65 100644 --- a/src/backend/starter.ts +++ b/src/backend/starter.ts @@ -243,6 +243,9 @@ async function provisionLocalDesktopUserIfNeeded(): Promise { // Last, so a plugin's activate() sees a fully wired server. A plugin that // fails to load must not stop the backend, so this never rejects. try { + const { startOperationsMaintenance } = + await import("./operations/maintenance.js"); + await startOperationsMaintenance(); const { initializePlugins } = await import("./plugins/index.js"); const loaded = await initializePlugins({ freshInstall }); if (loaded.length > 0) { diff --git a/src/backend/tests/database/routes/operations-routes.test.ts b/src/backend/tests/database/routes/operations-routes.test.ts new file mode 100644 index 0000000000..158302370c --- /dev/null +++ b/src/backend/tests/database/routes/operations-routes.test.ts @@ -0,0 +1,277 @@ +import express from "express"; +import request from "supertest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const state = vi.hoisted(() => ({ + userId: "user-1", + apiKeyId: undefined as string | undefined, + apiKeyScopes: undefined as string[] | null | undefined, + actingAdminUserId: undefined as string | undefined, + viewAll: false, + hostAccess: false, + runs: new Map>(), + runOperation: vi.fn(), + decide: vi.fn(), + cancel: vi.fn(async () => true), +})); + +vi.mock("../../../utils/logger.js", () => ({ + databaseLogger: { error: vi.fn(), warn: vi.fn(), info: vi.fn() }, + pluginLogger: { warn: vi.fn(), info: vi.fn(), error: vi.fn() }, +})); +vi.mock("../../../utils/auth-manager.js", () => ({ + AuthManager: { + getInstance: () => ({ + createAuthMiddleware: + () => + ( + req: Record, + _res: unknown, + next: () => void, + ): void => { + req.userId = state.userId; + req.apiKeyId = state.apiKeyId; + req.apiKeyScopes = state.apiKeyScopes; + req.actingAdminUserId = state.actingAdminUserId; + next(); + }, + }), + }, +})); +vi.mock("../../../utils/permission-manager.js", () => ({ + PermissionManager: { + getInstance: () => ({ + hasPermission: async () => state.viewAll, + canAccessHost: async () => ({ hasAccess: state.hostAccess }), + }), + }, +})); +vi.mock("../../../database/repositories/factory.js", () => ({ + createCurrentOperationRepository: () => ({ + findRun: async (id: string) => state.runs.get(id) ?? null, + listRuns: async (filters: { userId?: string }) => + [...state.runs.values()].filter( + (run) => !filters.userId || run.userId === filters.userId, + ), + listPendingApprovals: async () => [], + listEvents: async () => [], + }), +})); +vi.mock("../../../operations/catalog.js", () => ({ + buildCatalog: vi.fn(async () => ({ + actions: [{ id: "demo.restart" }], + events: [], + resources: [], + signals: [], + })), +})); +vi.mock("../../../operations/resources.js", () => ({ + flattenResourceRows: vi.fn(), + readAcrossHosts: vi.fn(async () => []), +})); +vi.mock("../../../operations/gate.js", () => { + class OperationError extends Error { + status: number; + constructor( + readonly code: string, + message: string, + status = 403, + ) { + super(message); + this.status = status; + } + } + return { + OperationError, + runOperation: state.runOperation, + decideOperationApproval: state.decide, + cancelOperationRun: state.cancel, + }; +}); + +const { default: router } = + await import("../../../database/routes/operations-routes.js"); +const { OperationError } = await import("../../../operations/gate.js"); + +const app = express(); +app.use(express.json()); +app.use("/operations", router); + +const run = (overrides: Record = {}) => ({ + id: "run-1", + actionId: "demo.restart", + pluginId: "demo", + pluginVersion: "1.0.0", + userId: "user-1", + username: "name", + origin: '{"kind":"ui"}', + hostId: 1, + hostName: "web", + batchId: null, + dryRun: false, + risk: "change", + status: "success", + summary: "done", + changed: true, + input: '{"token":"[redacted]"}', + sealedInput: "sealed:secret", + output: null, + undoInput: null, + error: null, + errorCode: null, + undoOf: null, + createdAt: "2026-01-01", + startedAt: null, + finishedAt: null, + ...overrides, +}); + +beforeEach(() => { + state.userId = "user-1"; + state.apiKeyId = undefined; + state.apiKeyScopes = undefined; + state.actingAdminUserId = undefined; + state.viewAll = false; + state.hostAccess = false; + state.runs.clear(); + state.runOperation.mockReset(); + state.runOperation.mockResolvedValue({ runId: "r", status: "success" }); + state.decide.mockReset(); + state.decide.mockResolvedValue({ runId: "r", status: "success" }); +}); + +describe("POST /operations/:id/run", () => { + it("passes a session as a ui origin", async () => { + await request(app) + .post("/operations/demo.restart/run") + .send({ hostId: 1, input: { name: "x" } }) + .expect(200); + expect(state.runOperation).toHaveBeenCalledWith( + expect.objectContaining({ + actionId: "demo.restart", + hostId: 1, + userId: "user-1", + origin: { kind: "ui" }, + }), + ); + }); + + it("passes a key as an api origin with its scopes", async () => { + state.apiKeyId = "key"; + state.apiKeyScopes = ["operations:read"]; + await request(app) + .post("/operations/demo.restart/run") + .send({}) + .expect(200); + expect(state.runOperation).toHaveBeenCalledWith( + expect.objectContaining({ + origin: { kind: "api" }, + apiKeyScopes: ["operations:read"], + }), + ); + }); + + it("does not take the user or origin from the body", async () => { + await request(app) + .post("/operations/demo.restart/run") + .send({ userId: "admin", origin: { kind: "plugin", via: "automation" } }) + .expect(200); + expect(state.runOperation).toHaveBeenCalledWith( + expect.objectContaining({ userId: "user-1", origin: { kind: "ui" } }), + ); + }); + + it("sends a refusal's status and code", async () => { + state.runOperation.mockRejectedValue( + new ( + OperationError as never as new (code: string, message: string) => Error + )("FORBIDDEN", "No"), + ); + const response = await request(app) + .post("/operations/demo.restart/run") + .send({}) + .expect(403); + expect(response.body).toMatchObject({ code: "FORBIDDEN", error: "No" }); + }); +}); + +describe("runs", () => { + it("never returns the sealed input", async () => { + state.runs.set("run-1", run()); + const response = await request(app) + .get("/operations/runs/run-1") + .expect(200); + expect(JSON.stringify(response.body)).not.toContain("secret"); + }); + + it("hides another user's run", async () => { + state.runs.set("run-1", run({ userId: "someone" })); + await request(app).get("/operations/runs/run-1").expect(404); + }); + + it("shows a run to someone who manages its host", async () => { + state.runs.set("run-1", run({ userId: "someone" })); + state.hostAccess = true; + await request(app).get("/operations/runs/run-1").expect(200); + }); + + it("lists only a user's own runs", async () => { + state.runs.set("a", run({ id: "a" })); + state.runs.set("b", run({ id: "b", userId: "someone" })); + const response = await request(app).get("/operations/runs").expect(200); + expect(response.body.runs.map((row: { id: string }) => row.id)).toEqual([ + "a", + ]); + }); + + it("lists everything with the view permission", async () => { + state.viewAll = true; + state.runs.set("a", run({ id: "a" })); + state.runs.set("b", run({ id: "b", userId: "someone" })); + const response = await request(app).get("/operations/runs").expect(200); + expect(response.body.runs).toHaveLength(2); + }); + + it("does not cancel another user's run", async () => { + state.runs.set("run-1", run({ userId: "someone" })); + await request(app).post("/operations/runs/run-1/cancel").expect(404); + expect(state.cancel).not.toHaveBeenCalled(); + }); +}); + +describe("POST /operations/approvals/:id", () => { + it("needs a clear answer", async () => { + await request(app) + .post("/operations/approvals/a") + .send({ approve: "yes" }) + .expect(400); + expect(state.decide).not.toHaveBeenCalled(); + }); + + it("refuses a scoped key", async () => { + state.apiKeyScopes = ["operations:run"]; + await request(app) + .post("/operations/approvals/a") + .send({ approve: true }) + .expect(403); + expect(state.decide).not.toHaveBeenCalled(); + }); + + it("marks an impersonated request", async () => { + state.actingAdminUserId = "admin"; + await request(app) + .post("/operations/approvals/a") + .send({ approve: true }) + .expect(200); + expect(state.decide).toHaveBeenCalledWith( + expect.objectContaining({ impersonated: true }), + ); + }); +}); + +describe("GET /operations/catalog", () => { + it("answers", async () => { + const response = await request(app).get("/operations/catalog").expect(200); + expect(response.body.actions).toEqual([{ id: "demo.restart" }]); + }); +}); diff --git a/src/backend/tests/operations/gate.test.ts b/src/backend/tests/operations/gate.test.ts new file mode 100644 index 0000000000..cb862cfdc4 --- /dev/null +++ b/src/backend/tests/operations/gate.test.ts @@ -0,0 +1,694 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { OperationAction } from "@termix-ssh/plugin-sdk/operations"; + +const state = vi.hoisted(() => ({ + permissions: new Set(), + hostLevels: new Map(), + plugin: { + state: "active", + source: "bundled", + signedTier: undefined as string | undefined, + manifest: { + version: "1.0.0", + capabilities: ["operations:provide"], + }, + }, + capability: true, + safeMode: false, + host: { name: "web", updatedAt: "2026-01-01", tags: ["prod"] } as { + name: string; + updatedAt: string; + tags: string[]; + }, + runs: new Map>(), + approvals: new Map>(), + audits: [] as Record[], + events: [] as Record[], +})); + +vi.mock("../../utils/logger.js", () => ({ + pluginLogger: { + warn: vi.fn(), + info: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + }, +})); +vi.mock("../../utils/audit-logger.js", () => ({ + logAudit: async (entry: Record) => { + state.audits.push(entry); + }, +})); +vi.mock("../../utils/system-secret-crypto.js", () => ({ + encryptSystemSecret: async (text: string) => `sealed:${text}`, + decryptSystemSecret: async (text: string) => text.slice("sealed:".length), + isSystemEncrypted: (text: string) => text.startsWith("sealed:"), +})); +vi.mock("../../utils/permission-manager.js", () => ({ + PermissionManager: { + getInstance: () => ({ + hasPermission: async (userId: string, permission: string) => + state.permissions.has(`${userId}:${permission}`), + canAccessHost: async (userId: string, hostId: number, level: string) => { + const held = state.hostLevels.get(hostId); + const rank = ["connect", "view", "edit", "manage"]; + return { + hasAccess: + held !== undefined && rank.indexOf(held) >= rank.indexOf(level), + }; + }, + }), + }, +})); +vi.mock("../../plugins/permissions.js", () => ({ + hasCapability: async () => state.capability, +})); +vi.mock("../../plugins/safe-mode.js", () => ({ + safeModeOn: () => state.safeMode, +})); +vi.mock("../../plugins/manifest.js", () => ({ + qualifyPermission: (pluginId: string, name: string) => + name.startsWith(`${pluginId}.`) ? name : `${pluginId}.${name}`, +})); +vi.mock("../../plugins/index.js", () => ({ + getPluginRuntime: () => ({ loader: { get: () => state.plugin } }), +})); +vi.mock("../../plugins/host-status-provider.js", () => ({ + hostStatus: { get: () => null }, +})); +vi.mock("../../plugins/ctx-probe.js", () => ({ + loadProbeTargets: async () => [], +})); +vi.mock("../../operations/hosts.js", () => ({ + loadOperationHost: async (id: number) => ({ + id, + ...state.host, + os: null, + settings: {}, + }), +})); +vi.mock("../../database/repositories/factory.js", () => ({ + createCurrentUserRepository: () => ({ + findById: async (id: string) => ({ username: `name-${id}` }), + }), + createCurrentOperationRepository: () => ({ + insertRun: async (run: Record) => { + state.runs.set(run.id as string, { ...run }); + }, + findRun: async (id: string) => state.runs.get(id) ?? null, + findByIdempotencyKey: async (userId: string, key: string) => + [...state.runs.values()].find( + (run) => run.userId === userId && run.idempotencyKey === key, + ) ?? null, + updateRunFrom: async ( + id: string, + from: string, + patch: Record, + ) => { + const run = state.runs.get(id); + if (!run || run.status !== from) return false; + Object.assign(run, patch); + return true; + }, + insertApproval: async (row: Record) => { + state.approvals.set(row.id as string, { ...row }); + }, + findApproval: async (id: string) => state.approvals.get(id) ?? null, + findApprovalForRun: async (runId: string) => + [...state.approvals.values()].find((row) => row.runId === runId) ?? null, + decideApproval: async (id: string, status: string) => { + const row = state.approvals.get(id); + if (!row || row.status !== "pending") return false; + row.status = status; + return true; + }, + insertEvent: async (event: Record) => { + state.events.push(event); + }, + }), +})); + +const { registerAction, clearOperationRegistry } = + await import("../../operations/registry.js"); +const { provideApprovalPolicy, clearApprovalPolicies } = + await import("../../operations/approvals.js"); +const { + cancelOperationRun, + clearOperationGate, + decideOperationApproval, + drainPlugin, + OperationError, + OperationNeedsInteraction, + runOperation, +} = await import("../../operations/gate.js"); + +const USER = "user-1"; +const APPROVER = "user-2"; + +const action = (overrides: Partial = {}): OperationAction => ({ + id: "demo.restart", + titleKey: "t", + scope: "host", + risk: "change", + permission: "use", + hostAccess: "edit", + inputs: [ + { key: "name", type: "string", required: true }, + { key: "token", type: "secret" }, + ], + dryRun: "native", + ...overrides, +}); + +function register( + overrides: Partial = {}, + run = vi.fn(async () => ({ summary: "done", changed: true })), +) { + registerAction("demo", action(overrides), { run }); + return run; +} + +const manifestForPolicy = { + capabilities: ["operations:approve"], +} as never; + +const base = { + actionId: "demo.restart", + hostId: 1, + input: { name: "nginx" }, + userId: USER, + origin: { kind: "ui" } as const, +}; + +async function refused(request: Parameters[0]) { + try { + await runOperation(request); + } catch (error) { + expect(error).toBeInstanceOf(OperationError); + return (error as InstanceType).code; + } + throw new Error("Expected a refusal"); +} + +beforeEach(() => { + state.permissions = new Set([`${USER}:demo.use`, `${APPROVER}:demo.use`]); + state.hostLevels = new Map([[1, "edit"]]); + state.plugin.state = "active"; + state.plugin.source = "bundled"; + state.plugin.manifest.version = "1.0.0"; + state.capability = true; + state.safeMode = false; + state.host = { name: "web", updatedAt: "2026-01-01", tags: ["prod"] }; + state.runs.clear(); + state.approvals.clear(); + state.audits.length = 0; + state.events.length = 0; +}); + +afterEach(() => { + clearOperationRegistry(); + clearApprovalPolicies(); + clearOperationGate(); +}); + +describe("refusals", () => { + it("does not run an unknown action", async () => { + expect(await refused(base)).toBe("NOT_FOUND"); + }); + + it("does not run in safe mode", async () => { + register(); + state.safeMode = true; + expect(await refused(base)).toBe("SAFE_MODE"); + }); + + it("does not run for a plugin that stopped", async () => { + register(); + state.plugin.state = "disabled"; + expect(await refused(base)).toBe("UNAVAILABLE"); + }); + + it("rechecks the grant at use", async () => { + const run = register(); + state.capability = false; + expect(await refused(base)).toBe("UNAVAILABLE"); + expect(run).not.toHaveBeenCalled(); + }); + + it("needs the role permission", async () => { + const run = register(); + state.permissions.clear(); + expect(await refused(base)).toBe("FORBIDDEN"); + expect(run).not.toHaveBeenCalled(); + }); + + it("needs the declared host level", async () => { + const run = register(); + state.hostLevels.set(1, "connect"); + expect(await refused(base)).toBe("FORBIDDEN"); + expect(run).not.toHaveBeenCalled(); + }); + + it("refuses bad input before anything runs", async () => { + const run = register(); + expect(await refused({ ...base, input: { nope: 1 } })).toBe( + "INVALID_INPUT", + ); + expect(run).not.toHaveBeenCalled(); + }); + + it("needs a host for a host action", async () => { + register(); + expect(await refused({ ...base, hostId: undefined })).toBe("INVALID_INPUT"); + }); + + it("needs a confirm for destructive actions", async () => { + register({ risk: "destructive" }); + expect(await refused(base)).toBe("NEEDS_CONFIRM"); + expect((await runOperation({ ...base, confirmed: true })).status).toBe( + "success", + ); + }); + + it("stops long chains", async () => { + register(); + expect(await refused({ ...base, depth: 4 })).toBe("TOO_DEEP"); + }); +}); + +describe("api key scopes", () => { + it("lets a read key run reads only", async () => { + register(); + register({ id: "demo.read", risk: "read", hostAccess: "connect" }); + expect(await refused({ ...base, apiKeyScopes: ["operations:read"] })).toBe( + "SCOPE", + ); + const read = await runOperation({ + ...base, + actionId: "demo.read", + apiKeyScopes: ["operations:read"], + }); + expect(read.status).toBe("success"); + }); + + it("treats no scopes as full access", async () => { + register(); + const outcome = await runOperation({ ...base, apiKeyScopes: null }); + expect(outcome.status).toBe("success"); + }); +}); + +describe("runs", () => { + it("records a run and never stores a secret", async () => { + const run = register(); + const outcome = await runOperation({ + ...base, + input: { name: "nginx", token: "hunter2" }, + }); + + expect(outcome.status).toBe("success"); + expect(run.mock.calls[0]?.[0]).toMatchObject({ token: "hunter2" }); + const row = state.runs.get(outcome.runId!)!; + expect(row.status).toBe("success"); + expect(JSON.stringify(row)).not.toContain("hunter2"); + expect(JSON.stringify(state.audits)).not.toContain("hunter2"); + expect(JSON.stringify(state.events)).not.toContain("hunter2"); + }); + + it("keeps failures in the run, not thrown", async () => { + register( + {}, + vi.fn(async () => { + throw new Error("no such unit"); + }), + ); + const outcome = await runOperation(base); + expect(outcome).toMatchObject({ status: "failed", error: "no such unit" }); + expect(state.runs.get(outcome.runId!)!.status).toBe("failed"); + }); + + it("reports hosts that need a person", async () => { + register( + {}, + vi.fn(async () => { + throw new OperationNeedsInteraction("Sign in first"); + }), + ); + const outcome = await runOperation(base); + expect(outcome.status).toBe("needs_interaction"); + }); + + it("passes dry run to a native handler", async () => { + const run = register(); + await runOperation({ ...base, dryRun: true }); + expect(run.mock.calls[0]).toBeDefined(); + expect( + (run.mock.calls[0] as unknown as [unknown, { dryRun: boolean }])[1] + .dryRun, + ).toBe(true); + }); + + it("never calls run for a describe dry run", async () => { + const run = vi.fn(async () => ({ summary: "x" })); + registerAction("demo", action({ dryRun: "describe" }), { + run, + describe: () => "Would restart nginx", + }); + const outcome = await runOperation({ ...base, dryRun: true }); + expect(outcome.summary).toBe("Would restart nginx"); + expect(run).not.toHaveBeenCalled(); + }); + + it("drops sensitive outputs", async () => { + register( + { outputs: [{ key: "conf", type: "textarea", sensitive: true }] }, + vi.fn(async () => ({ + summary: "ok", + data: { conf: "private key" }, + })), + ); + const outcome = await runOperation(base); + expect(JSON.stringify(outcome)).not.toContain("private key"); + expect(JSON.stringify(state.runs.get(outcome.runId!))).not.toContain( + "private key", + ); + }); + + it("writes no row for a background read", async () => { + register({ risk: "read", hostAccess: "connect" }); + const outcome = await runOperation({ ...base, internal: true }); + expect(outcome.runId).toBeNull(); + expect(state.runs.size).toBe(0); + }); + + it("does not run twice for one idempotency key", async () => { + const run = register(); + const first = await runOperation({ ...base, idempotencyKey: "abc" }); + const second = await runOperation({ ...base, idempotencyKey: "abc" }); + expect(second.runId).toBe(first.runId); + expect(run).toHaveBeenCalledTimes(1); + }); + + it("emits a run event for a change", async () => { + register(); + await runOperation(base); + await vi.waitFor(() => + expect(state.events[0]).toMatchObject({ + eventId: "operation.succeeded", + }), + ); + }); + + it("stops a run on cancel", async () => { + let started = () => {}; + const ready = new Promise((resolve) => (started = resolve)); + register( + {}, + vi.fn( + (_input, context: { signal: AbortSignal }) => + new Promise((_, reject) => { + started(); + context.signal.addEventListener("abort", () => + reject(new Error("stopped")), + ); + }), + ) as never, + ); + const running = runOperation(base); + await ready; + const row = [...state.runs.values()][0]!; + expect(await cancelOperationRun(row as never)).toBe(true); + expect((await running).status).toBe("failed"); + }); +}); + +describe("policy", () => { + it("keeps AI and MCP away from changes with no rule", async () => { + register(); + for (const via of ["assistant", "mcp"]) { + expect( + await refused({ + ...base, + origin: { kind: "plugin", pluginId: "x", via }, + }), + ).toBe("DENIED"); + } + }); + + it("refuses a plugin with no stated purpose", async () => { + register(); + expect( + await refused({ ...base, origin: { kind: "plugin", pluginId: "x" } }), + ).toBe("DENIED"); + }); + + it("lets a person's automation go by RBAC", async () => { + register(); + const outcome = await runOperation({ + ...base, + origin: { kind: "plugin", pluginId: "x", via: "automation" }, + }); + expect(outcome.status).toBe("success"); + }); + + it("treats a community read as a change", async () => { + state.plugin.source = "user"; + state.plugin.signedTier = "community"; + register({ risk: "read", hostAccess: "connect" }); + expect( + await refused({ + ...base, + origin: { kind: "plugin", pluginId: "x", via: "assistant" }, + }), + ).toBe("DENIED"); + state.plugin.signedTier = undefined; + }); + + it("denies when a provider throws", async () => { + register(); + provideApprovalPolicy("rules", manifestForPolicy, () => { + throw new Error("boom"); + }); + expect(await refused(base)).toBe("DENIED"); + }); + + it("takes the strictest answer", async () => { + register(); + provideApprovalPolicy("a", manifestForPolicy, () => ({ + decision: "allow", + })); + provideApprovalPolicy("b", manifestForPolicy, () => ({ + decision: "deny", + reason: "No", + })); + expect(await refused(base)).toBe("DENIED"); + }); + + it("audits a denial", async () => { + register(); + provideApprovalPolicy("a", manifestForPolicy, () => ({ decision: "deny" })); + await refused(base); + expect(state.audits[0]).toMatchObject({ + action: "operation_denied", + success: false, + }); + }); +}); + +describe("approvals", () => { + const needApproval = () => + provideApprovalPolicy("rules", manifestForPolicy, () => ({ + decision: "approve", + })); + + async function pending(run = register()) { + needApproval(); + const outcome = await runOperation({ + ...base, + input: { name: "nginx", token: "hunter2" }, + }); + expect(outcome.status).toBe("pending_approval"); + expect(run).not.toHaveBeenCalled(); + return outcome; + } + + const decide = (approvalId: string, approverId = APPROVER) => + decideOperationApproval({ approvalId, approverId, approve: true }); + + it("runs the sealed input once approved, then wipes it", async () => { + const run = register(); + const { approvalId, runId } = await pending(run); + + const row = state.runs.get(runId!)!; + expect(row.input).not.toContain("hunter2"); + expect(row.sealedInput).toBeTruthy(); + + const outcome = await decide(approvalId!); + expect(outcome.status).toBe("success"); + expect(run.mock.calls[0]?.[0]).toMatchObject({ token: "hunter2" }); + expect(state.runs.get(runId!)!.sealedInput).toBeNull(); + }); + + it("is one shot", async () => { + const run = register(); + const { approvalId } = await pending(run); + await decide(approvalId!); + await expect(decide(approvalId!)).rejects.toMatchObject({ + code: "CONFLICT", + }); + expect(run).toHaveBeenCalledTimes(1); + }); + + it("does not let a person approve their own request", async () => { + const { approvalId } = await pending(); + await expect(decide(approvalId!, USER)).rejects.toMatchObject({ + code: "FORBIDDEN", + }); + }); + + it("lets a person approve what an AI asked for", async () => { + register(); + needApproval(); + const outcome = await runOperation({ + ...base, + origin: { kind: "plugin", pluginId: "x", via: "assistant" }, + }); + const done = await decide(outcome.approvalId!, USER); + expect(done.status).toBe("success"); + }); + + it("does not let someone impersonating approve", async () => { + const { approvalId } = await pending(); + await expect( + decideOperationApproval({ + approvalId: approvalId!, + approverId: APPROVER, + approve: true, + impersonated: true, + }), + ).rejects.toMatchObject({ code: "FORBIDDEN" }); + }); + + it("needs the approver to hold the action's rights", async () => { + const { approvalId } = await pending(); + state.permissions.delete(`${APPROVER}:demo.use`); + await expect(decide(approvalId!)).rejects.toMatchObject({ + code: "FORBIDDEN", + }); + }); + + it("rechecks the requester when it runs", async () => { + const run = register(); + const { approvalId, runId } = await pending(run); + state.permissions.delete(`${USER}:demo.use`); + await expect(decide(approvalId!)).rejects.toMatchObject({ + code: "FORBIDDEN", + }); + expect(run).not.toHaveBeenCalled(); + expect(state.runs.get(runId!)!.status).toBe("rejected"); + }); + + it("goes stale when the host changed", async () => { + const run = register(); + const { approvalId } = await pending(run); + state.host.updatedAt = "2026-02-02"; + await expect(decide(approvalId!)).rejects.toMatchObject({ code: "STALE" }); + expect(run).not.toHaveBeenCalled(); + }); + + it("goes stale when the plugin changed", async () => { + const run = register(); + const { approvalId } = await pending(run); + state.plugin.manifest.version = "2.0.0"; + await expect(decide(approvalId!)).rejects.toMatchObject({ code: "STALE" }); + expect(run).not.toHaveBeenCalled(); + }); + + it("refuses a sealed copy from another run", async () => { + const run = register(); + const first = await pending(run); + const second = await runOperation(base); + state.runs.get(second.runId!)!.sealedInput = state.runs.get( + first.runId!, + )!.sealedInput; + await expect(decide(second.approvalId!)).rejects.toMatchObject({ + code: "STALE", + }); + expect(run).not.toHaveBeenCalled(); + }); + + it("refuses an expired approval", async () => { + const run = register(); + const { approvalId } = await pending(run); + state.approvals.get(approvalId!)!.expiresAt = "2020-01-01T00:00:00.000Z"; + await expect(decide(approvalId!)).rejects.toMatchObject({ code: "STALE" }); + expect(run).not.toHaveBeenCalled(); + }); + + it("wipes the sealed input on reject", async () => { + const { approvalId, runId } = await pending(); + const outcome = await decideOperationApproval({ + approvalId: approvalId!, + approverId: APPROVER, + approve: false, + }); + expect(outcome.status).toBe("rejected"); + expect(state.runs.get(runId!)!.sealedInput).toBeNull(); + }); +}); + +describe("drain", () => { + it("waits for a handler that is still stopping", async () => { + let finish = () => {}; + register( + {}, + vi.fn( + () => + new Promise((_, reject) => { + finish = () => reject(new Error("closed")); + }), + ) as never, + ); + vi.useFakeTimers(); + void runOperation(base).catch(() => {}); + await vi.advanceTimersByTimeAsync(0); + + let drained = false; + const draining = drainPlugin("demo").then(() => (drained = true)); + await vi.advanceTimersByTimeAsync(5_000); + expect(drained).toBe(false); + + finish(); + await vi.advanceTimersByTimeAsync(0); + await draining; + vi.useRealTimers(); + expect(drained).toBe(true); + }); + + it("stops running work and marks it interrupted", async () => { + register({}, vi.fn(() => new Promise(() => {})) as never); + vi.useFakeTimers(); + const running = runOperation(base); + await vi.advanceTimersByTimeAsync(0); + const draining = drainPlugin("demo"); + await vi.advanceTimersByTimeAsync(11_000); + await draining; + await running; + vi.useRealTimers(); + expect([...state.runs.values()][0]!.status).toBe("interrupted"); + }); + + it("refuses new runs while draining", async () => { + register({}, vi.fn(() => new Promise(() => {})) as never); + vi.useFakeTimers(); + void runOperation(base); + await vi.advanceTimersByTimeAsync(0); + const draining = drainPlugin("demo"); + await expect(runOperation(base)).rejects.toMatchObject({ + code: "UNAVAILABLE", + }); + await vi.advanceTimersByTimeAsync(11_000); + await draining; + vi.useRealTimers(); + }); +}); diff --git a/src/backend/tests/operations/key-scopes.test.ts b/src/backend/tests/operations/key-scopes.test.ts new file mode 100644 index 0000000000..75595fdaf4 --- /dev/null +++ b/src/backend/tests/operations/key-scopes.test.ts @@ -0,0 +1,70 @@ +import { describe, expect, it } from "vitest"; +import { keyScopeAllows, parseKeyScopes } from "../../operations/key-scopes.js"; + +const READ = ["operations:read"]; +const RUN = ["operations:run"]; + +describe("keyScopeAllows", () => { + it("leaves a key with no scopes alone", () => { + expect(keyScopeAllows(null, "POST", "/users/ws-ticket/api-key")).toBe(true); + expect(keyScopeAllows(null, "DELETE", "/host/1")).toBe(true); + }); + + it("keeps a scoped key off every other route", () => { + for (const path of [ + "/users/ws-ticket/api-key", + "/host/1", + "/plugin-api/docker/1/containers", + "/credentials", + ]) { + expect(keyScopeAllows(RUN, "GET", path)).toBe(false); + } + }); + + it("lets a read key read and start runs the gate then checks", () => { + expect(keyScopeAllows(READ, "GET", "/operations/catalog?lang=en")).toBe( + true, + ); + expect(keyScopeAllows(READ, "POST", "/operations/docker.list/run")).toBe( + true, + ); + expect(keyScopeAllows(READ, "POST", "/operations/approvals/abc")).toBe( + false, + ); + expect(keyScopeAllows(READ, "POST", "/operations/runs/abc/cancel")).toBe( + false, + ); + }); + + it("reaches a plugin's MCP route only", () => { + expect(keyScopeAllows(READ, "POST", "/plugin-api/some-plugin/mcp")).toBe( + true, + ); + expect( + keyScopeAllows(READ, "POST", "/plugin-api/some-plugin/mcp-admin"), + ).toBe(false); + }); + + it("does not match lookalike paths", () => { + expect(keyScopeAllows(RUN, "GET", "/operationsx/catalog")).toBe(false); + }); + + it("grants nothing with an empty list", () => { + expect(keyScopeAllows([], "GET", "/operations/catalog")).toBe(false); + }); +}); + +describe("parseKeyScopes", () => { + it("reads a list", () => { + expect(parseKeyScopes('["operations:read"]')).toEqual(READ); + }); + + it("keeps old keys at full access", () => { + expect(parseKeyScopes(null)).toBeNull(); + }); + + it("fails closed on junk", () => { + expect(parseKeyScopes("{nope")).toEqual([]); + expect(parseKeyScopes('"x"')).toEqual([]); + }); +}); diff --git a/src/backend/tests/plugins/ctx-operations.test.ts b/src/backend/tests/plugins/ctx-operations.test.ts new file mode 100644 index 0000000000..3dd62afc0e --- /dev/null +++ b/src/backend/tests/plugins/ctx-operations.test.ts @@ -0,0 +1,210 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { PluginManifest } from "../../plugins/manifest.js"; + +const gate = vi.hoisted(() => ({ + runOperation: vi.fn(async () => ({ runId: "r", status: "success" })), + drainPlugin: vi.fn(async () => {}), +})); + +vi.mock("../../utils/logger.js", () => ({ + pluginLogger: { + warn: vi.fn(), + info: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + }, +})); +vi.mock("../../operations/gate.js", () => ({ + ...gate, + OperationError: class extends Error { + constructor(_code: string, message: string) { + super(message); + } + }, +})); +vi.mock("../../operations/catalog.js", () => ({ + buildCatalog: vi.fn(async () => ({ actions: [] })), +})); +vi.mock("../../operations/events.js", () => ({ + emitOperationEvent: vi.fn(async () => {}), + subscribeOperationEvents: vi.fn(() => () => {}), +})); +vi.mock("../../plugins/permissions.js", () => ({ + assertCapability: vi.fn( + async (_id: string, capability: string, declared: string[]) => { + if (!declared.includes(capability)) throw new Error("refused"); + }, + ), + capabilityRefused: (pluginId: string, capability: string) => + new Error(`${pluginId} lacks ${capability}`), +})); + +const { createPluginOperations } = + await import("../../plugins/ctx-operations.js"); +const { DisposableBag } = await import("../../plugins/disposables.js"); +const { clearOperationRegistry, getAction } = + await import("../../operations/registry.js"); +const { runAsActor } = await import("../../plugins/actor.js"); + +const action = { + id: "demo.restart", + titleKey: "t", + scope: "host", + risk: "change", + permission: "use", + hostAccess: "edit", +}; + +function build(capabilities: string[], invokes?: string[]) { + const bag = new DisposableBag("demo"); + const manifest = { + id: "demo", + capabilities, + contributes: { operations: { actions: [action], invokes } }, + } as unknown as PluginManifest; + return { bag, operations: createPluginOperations({ manifest, bag }) }; +} + +const handler = { run: async () => ({ summary: "ok" }) }; + +beforeEach(() => { + clearOperationRegistry(); + gate.runOperation.mockClear(); + gate.drainPlugin.mockClear(); +}); + +describe("register", () => { + it("needs the capability", () => { + const { operations } = build([]); + expect(() => operations.register("demo.restart", handler)).toThrow( + "operations:provide", + ); + }); + + it("refuses an undeclared action", () => { + const { operations } = build(["operations:provide"]); + expect(() => operations.register("demo.other", handler)).toThrow( + "not declared", + ); + }); + + it("drains, then revokes, when the plugin stops", async () => { + const { bag, operations } = build(["operations:provide"]); + operations.register("demo.restart", handler); + expect(getAction("demo.restart")).toBeDefined(); + + await bag.disposeAll(); + expect(gate.drainPlugin).toHaveBeenCalledWith("demo"); + expect(getAction("demo.restart")).toBeUndefined(); + }); + + it("keeps the new handler when an old disposer runs after a restart", async () => { + const first = build(["operations:provide"]); + first.operations.register("demo.restart", handler); + const second = build(["operations:provide"]); + const fresh = { run: async () => ({ summary: "new" }) }; + second.operations.register("demo.restart", fresh); + + await first.bag.disposeAll(); + expect(getAction("demo.restart")?.handler).toBe(fresh); + }); +}); + +describe("invoke", () => { + const asUser = (fn: () => Promise) => + runAsActor("user", "request", fn, undefined, { + origin: { kind: "ui" }, + apiKeyScopes: ["operations:read"], + }); + + it("needs the capability", async () => { + const { operations } = build([], ["*"]); + await expect( + asUser(() => operations.invoke("docker.restart")), + ).rejects.toThrow("refused"); + }); + + it("only reaches what the manifest lists", async () => { + const { operations } = build(["operations:invoke"], ["docker.*"]); + await expect( + asUser(() => operations.invoke("firewall.allow")), + ).rejects.toThrow("did not declare"); + expect(gate.runOperation).not.toHaveBeenCalled(); + }); + + it("names the plugin as origin and keeps the key's limits", async () => { + const { operations } = build(["operations:invoke"], ["docker.*"]); + await asUser(() => + operations.invoke("docker.restart", { via: "automation" }), + ); + expect(gate.runOperation).toHaveBeenCalledWith( + expect.objectContaining({ + userId: "user", + origin: { kind: "plugin", pluginId: "demo", via: "automation" }, + apiKeyScopes: ["operations:read"], + }), + ); + }); + + it("does not let a plugin pick its own origin", async () => { + const { operations } = build(["operations:invoke"], ["*"]); + await asUser(() => + operations.invoke("docker.restart", { + origin: { kind: "ui" }, + } as never), + ); + expect(gate.runOperation).toHaveBeenCalledWith( + expect.objectContaining({ + origin: expect.objectContaining({ kind: "plugin", pluginId: "demo" }), + }), + ); + }); + + it("needs an acting user", async () => { + const { operations } = build(["operations:invoke"], ["*"]); + await expect(operations.invoke("docker.restart")).rejects.toThrow( + "acting user", + ); + }); +}); + +describe("run", () => { + it("keeps the caller as origin for a plugin's own action", async () => { + const { operations } = build(["operations:provide"]); + await runAsActor( + "user", + "request", + () => operations.run("demo.restart", { hostId: 1 }), + undefined, + { origin: { kind: "ui" } }, + ); + expect(gate.runOperation).toHaveBeenCalledWith( + expect.objectContaining({ origin: { kind: "ui" }, hostId: 1 }), + ); + }); + + it("refuses another plugin's action", async () => { + const { operations } = build(["operations:provide"]); + await expect( + runAsActor("user", "request", () => operations.run("docker.restart")), + ).rejects.toThrow("not declared"); + }); +}); + +describe("emit", () => { + it("refuses an undeclared event", async () => { + const { operations } = build(["operations:provide"]); + await expect(operations.emit("demo.nothing")).rejects.toThrow( + "not declared", + ); + }); +}); + +describe("approvals", () => { + it("needs the capability", () => { + const { operations } = build([]); + expect(() => operations.approvals.provide(() => null)).toThrow( + "operations:approve", + ); + }); +}); diff --git a/src/backend/tests/plugins/operations-manifest.test.ts b/src/backend/tests/plugins/operations-manifest.test.ts new file mode 100644 index 0000000000..0e9fa60eac --- /dev/null +++ b/src/backend/tests/plugins/operations-manifest.test.ts @@ -0,0 +1,297 @@ +import { describe, expect, it } from "vitest"; +import { parseManifest } from "@termix-ssh/plugin-sdk/manifest"; +import { + invokeAllowed, + matchesWhen, + onSurface, + parseOperationInput, + redactFields, + toJsonSchema, + type OperationAction, + type OperationField, +} from "@termix-ssh/plugin-sdk/operations"; + +const action = (overrides: Partial = {}): OperationAction => ({ + id: "demo.allow-port", + titleKey: "ops.allow", + scope: "host", + risk: "change", + permission: "use", + hostAccess: "edit", + ...overrides, +}); + +function manifest(operations: unknown, capabilities = ["operations:provide"]) { + return { + id: "demo", + name: "Demo", + version: "1.0.0", + description: "A demo.", + author: { name: "test" }, + license: "MIT", + category: "Productivity", + engine: { termix: ">=26.11.1-0", api: "^1.13" }, + capabilities, + contributes: { + permissions: [ + { name: "use", titleKey: "perm.use", descriptionKey: "perm.useDesc" }, + ], + operations, + }, + }; +} + +function errorsFor(operations: unknown, capabilities?: string[]): string[] { + return parseManifest(manifest(operations, capabilities)).errors ?? []; +} + +describe("contributes.operations", () => { + it("accepts a full declaration", () => { + const errors = errorsFor({ + actions: [ + action({ + check: "demo.port-allowed", + inputs: [ + { key: "port", type: "number", required: true, min: 1, max: 65535 }, + { key: "name", type: "resource", resource: "demo.rule" }, + ], + }), + action({ + id: "demo.port-allowed", + risk: "read", + hostAccess: "connect", + }), + action({ + id: "demo.list-rules", + risk: "read", + hostAccess: "connect", + }), + ], + events: [{ id: "demo.turned-off", titleKey: "e", scope: "host" }], + resources: [ + { + id: "demo.rule", + titleKey: "r", + list: "demo.list-rules", + key: "id", + name: "name", + }, + ], + signals: [ + { + id: "demo.rules", + titleKey: "s", + read: "demo.port-allowed", + scope: "host", + kind: "count", + fix: "demo.allow-port", + }, + ], + }); + expect(errors).toEqual([]); + }); + + it("needs the capability", () => { + expect(errorsFor({ actions: [action()] }, [])).toContainEqual( + expect.stringContaining("operations:provide"), + ); + }); + + it("refuses an id outside the plugin", () => { + expect( + errorsFor({ actions: [action({ id: "docker.stop" })] }), + ).toContainEqual(expect.stringContaining('must start with "demo."')); + }); + + it("refuses a destructive action a connect level user could run", () => { + expect( + errorsFor({ + actions: [action({ risk: "destructive", hostAccess: "connect" })], + }), + ).toContainEqual(expect.stringContaining("edit or manage")); + }); + + it("lets a change declare its own level", () => { + expect(errorsFor({ actions: [action({ hostAccess: "connect" })] })).toEqual( + [], + ); + }); + + it("refuses an undeclared permission", () => { + expect( + errorsFor({ actions: [action({ permission: "nope" })] }), + ).toContainEqual(expect.stringContaining("is not declared")); + }); + + it("refuses a check that is not a read", () => { + expect( + errorsFor({ + actions: [ + action({ check: "demo.other" }), + action({ id: "demo.other" }), + ], + }), + ).toContainEqual(expect.stringContaining("must be a read action")); + }); + + it("refuses references that go nowhere", () => { + const errors = errorsFor({ + actions: [action({ undo: "demo.gone" })], + resources: [ + { + id: "demo.rule", + titleKey: "r", + list: "demo.gone", + key: "id", + name: "n", + }, + ], + }); + expect(errors.length).toBeGreaterThanOrEqual(2); + }); + + it("refuses unknown fields", () => { + expect( + errorsFor({ actions: [{ ...action(), extra: true }] }), + ).toContainEqual(expect.stringContaining('Unknown field "extra"')); + }); + + it("refuses a default on a secret", () => { + expect( + errorsFor({ + actions: [ + action({ + inputs: [{ key: "token", type: "secret", default: "abc" }], + }), + ], + }), + ).toContainEqual(expect.stringContaining("not allowed on a secret")); + }); + + it("needs operations:invoke for invokes", () => { + expect(errorsFor({ invokes: ["docker.*"] }, [])).toContainEqual( + expect.stringContaining("operations:invoke"), + ); + }); +}); + +describe("parseOperationInput", () => { + const fields: OperationField[] = [ + { key: "port", type: "number", required: true, min: 1, max: 65535 }, + { + key: "proto", + type: "select", + default: "tcp", + options: [ + { value: "tcp", labelKey: "t" }, + { value: "udp", labelKey: "u" }, + ], + }, + { key: "name", type: "string", pattern: "[a-z]+" }, + { key: "data", type: "json" }, + ]; + + it("coerces strings and fills defaults", () => { + expect(parseOperationInput(fields, { port: "443" })).toEqual({ + values: { port: 443, proto: "tcp" }, + errors: [], + }); + }); + + it("refuses keys no field declares", () => { + expect( + parseOperationInput(fields, { port: 1, sudo: true }).errors, + ).toContainEqual(expect.stringContaining('"sudo"')); + }); + + it("needs required fields", () => { + expect(parseOperationInput(fields, {}).errors).toEqual([ + '"port" is required', + ]); + }); + + it("checks bounds, options and the whole pattern", () => { + expect(parseOperationInput(fields, { port: 0 }).errors).toHaveLength(1); + expect( + parseOperationInput(fields, { port: 1, proto: "icmp" }).errors, + ).toHaveLength(1); + expect( + parseOperationInput(fields, { port: 1, name: "abc; rm -rf" }).errors, + ).toEqual(['"name" has the wrong format']); + }); + + it("parses json strings", () => { + expect( + parseOperationInput(fields, { port: 1, data: '{"a":1}' }).values.data, + ).toEqual({ a: 1 }); + }); + + it("refuses a redacted echo for a secret", () => { + const secret: OperationField[] = [{ key: "token", type: "secret" }]; + expect( + parseOperationInput(secret, { token: { set: true } }).errors, + ).toHaveLength(1); + }); + + it("refuses input that is not an object", () => { + expect(parseOperationInput(fields, "nope").errors).toHaveLength(1); + }); +}); + +describe("helpers", () => { + it("redacts secrets and sensitive outputs", () => { + expect( + redactFields( + [ + { key: "token", type: "secret" }, + { key: "conf", type: "textarea", sensitive: true }, + { key: "port", type: "number" }, + ], + { token: "abc", conf: "key", port: 1 }, + ), + ).toEqual({ token: "[redacted]", conf: "[redacted]", port: 1 }); + }); + + it("builds JSON Schema", () => { + const schema = toJsonSchema([ + { key: "port", type: "number", required: true, min: 1 }, + { key: "token", type: "secret" }, + ]) as { properties: Record; required: string[] }; + expect(schema.required).toEqual(["port"]); + expect(schema.properties.port).toEqual({ type: "number", minimum: 1 }); + expect(schema.properties.token).toEqual({ + type: "string", + writeOnly: true, + }); + }); + + it("keeps AI and MCP opt in, the rest opt out", () => { + expect(onSurface({}, "assistant")).toBe(false); + expect(onSurface({}, "mcp")).toBe(false); + expect(onSurface({}, "cli")).toBe(true); + expect(onSurface({ surfaces: { assistant: true } }, "assistant")).toBe( + true, + ); + expect(onSurface({ surfaces: { cli: false } }, "cli")).toBe(false); + }); + + it("matches invokes patterns", () => { + expect(invokeAllowed(["docker.*"], "docker.stop")).toBe(true); + expect(invokeAllowed(["docker.*"], "dockerx.stop")).toBe(false); + expect(invokeAllowed(["docker.stop"], "docker.start")).toBe(false); + expect(invokeAllowed(["*"], "anything.at-all")).toBe(true); + expect(invokeAllowed(undefined, "docker.stop")).toBe(false); + }); + + it("tests when against a host", () => { + const when = { settings: { enableDocker: true }, os: ["linux"] }; + expect( + matchesWhen(when, { settings: { enableDocker: true }, os: "Linux" }), + ).toBe(true); + expect(matchesWhen(when, { settings: {} })).toBe(false); + expect( + matchesWhen(when, { settings: { enableDocker: true }, os: "windows" }), + ).toBe(false); + expect(matchesWhen(when, { settings: { enableDocker: true } })).toBe(true); + }); +}); diff --git a/src/backend/utils/auth-manager.ts b/src/backend/utils/auth-manager.ts index 7516ea26f2..0bd2033703 100644 --- a/src/backend/utils/auth-manager.ts +++ b/src/backend/utils/auth-manager.ts @@ -17,6 +17,7 @@ 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 { keyScopeAllows, parseKeyScopes } from "../operations/key-scopes.js"; import type { DeviceType } from "./user-agent-parser.js"; import { getDb } from "../database/db/index.js"; import { sessions } from "../database/db/schema.js"; @@ -69,6 +70,7 @@ interface AuthenticatedRequest extends Request { userId?: string; sessionId?: string; apiKeyId?: string; + apiKeyScopes?: string[] | null; pendingTOTP?: boolean; dataKey?: Buffer; actingAdminUserId?: string; @@ -879,8 +881,18 @@ class AuthManager { }); }); + const scopes = parseKeyScopes(matchedKey.scopes); + if (!keyScopeAllows(scopes, req.method, req.originalUrl ?? "")) { + res.status(403).json({ + error: "This API key may not use this route", + code: "API_KEY_SCOPE", + }); + return; + } + req.userId = matchedKey.userId; req.apiKeyId = matchedKey.id; + req.apiKeyScopes = scopes; next(); } catch (error) { databaseLogger.error("API key authentication failed", error, { diff --git a/src/backend/utils/permission-catalog.ts b/src/backend/utils/permission-catalog.ts index 51b4926bab..c45fbb39bf 100644 --- a/src/backend/utils/permission-catalog.ts +++ b/src/backend/utils/permission-catalog.ts @@ -54,6 +54,7 @@ export const PERMISSION_CATALOG: PermissionCatalogEntry[] = [ "admin.roles.manage", "admin.settings.manage", "admin.sessions.manage", + "admin.operations.view", "admin.plugins.manage", ], }, diff --git a/src/ui/locales/en.json b/src/ui/locales/en.json index 1a6c275a03..a0d109edc1 100644 --- a/src/ui/locales/en.json +++ b/src/ui/locales/en.json @@ -1214,6 +1214,18 @@ "title": "Send alerts", "consequence": "To you or other users, and through the channels they set up." }, + "operations:provide": { + "title": "Offer actions", + "consequence": "Its actions can be run from Automations, the CLI, the phone app and other places, by people allowed to use them." + }, + "operations:invoke": { + "title": "Run other plugins' actions", + "consequence": "Acts for the signed-in user on the actions its manifest lists. Approval rules still apply." + }, + "operations:approve": { + "title": "Decide what needs approval", + "consequence": "Can let actions run with no approval, or block them, for every user." + }, "notify:hub": { "title": "Receive every alert", "consequence": "Stores and delivers the alerts all plugins send, including their contents." From 631f6ac9f77494a1677234260acc330e6ee0b22f Mon Sep 17 00:00:00 2001 From: LukeGus Date: Tue, 3 Nov 2026 22:34:29 -0600 Subject: [PATCH 012/106] feat: add host sections, tab embeds and the operations client to the plugin ui --- package-lock.json | 4 +-- package.json | 2 +- packages/plugin-sdk/CHANGELOG.md | 2 ++ packages/plugin-sdk/src/frontend.ts | 21 ++++++++++++++ packages/plugin-sdk/ui.api.txt | 6 ++++ scripts/check-sdk-ui-exports.cjs | 6 ++++ src/ui/lib/plugin-transport.ts | 28 ++++++++++++++++++ src/ui/plugin-host/app.ts | 19 ++++++++++++ src/ui/plugin-host/sdk-ui.ts | 10 +++++++ src/ui/plugin-host/testing-host.tsx | 1 + src/ui/shell/HostTabEmbed.tsx | 45 +++++++++++++++++++++++++++++ src/ui/shell/host-sections.ts | 43 +++++++++++++++++++++++++++ 12 files changed, 184 insertions(+), 3 deletions(-) create mode 100644 src/ui/shell/HostTabEmbed.tsx create mode 100644 src/ui/shell/host-sections.ts diff --git a/package-lock.json b/package-lock.json index 78e0ee0a27..0e5d4e3109 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "termix", - "version": "26.11.0", + "version": "26.11.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "termix", - "version": "26.11.0", + "version": "26.11.1", "hasInstallScript": true, "workspaces": [ "packages/*" diff --git a/package.json b/package.json index e313cca489..9e0231904d 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,7 @@ "workspaces": [ "packages/*" ], - "version": "26.11.0", + "version": "26.11.1", "description": "Self-hosted, plugin-based server management.", "author": "Karmaa", "main": "electron/main.cjs", diff --git a/packages/plugin-sdk/CHANGELOG.md b/packages/plugin-sdk/CHANGELOG.md index e37af6b2cb..d4f14ce81a 100644 --- a/packages/plugin-sdk/CHANGELOG.md +++ b/packages/plugin-sdk/CHANGELOG.md @@ -10,6 +10,8 @@ Plugin API 1.14.0. - `ctx.operations` registers handlers, runs other plugins' declared actions, emits events and provides approval rules, with the new `operations:provide`, `operations:invoke` and `operations:approve` capabilities - `@termix-ssh/plugin-sdk/operations` has the types, input checks, redaction and JSON Schema for declared actions - `contributes.hostSections` declares cards for the host page +- `app.registerHostSection` adds a card to the host page +- `/ui` exports `HostTabEmbed`, which draws any tab type inside another view, the host section registry, and `operationsApi` for core's `/operations` routes ## 1.0.22 diff --git a/packages/plugin-sdk/src/frontend.ts b/packages/plugin-sdk/src/frontend.ts index a263b19744..b604057fe7 100644 --- a/packages/plugin-sdk/src/frontend.ts +++ b/packages/plugin-sdk/src/frontend.ts @@ -670,6 +670,22 @@ export type HostStatusIndicatorContribution = HostBadgeContribution; /** A control in the host editor's toolbar. */ export type HostEditorToolbarItemContribution = HostBadgeContribution; +export interface HostSectionProps { + host: PluginHostRecord; + isVisible: boolean; + shell: ShellApi; +} + +export interface HostSectionContribution { + /** Declare it in manifest contributes.hostSections too. */ + id: string; + titleKey: string; + /** Lower sorts first. */ + order?: number; + when?: (host: PluginHostRecord) => boolean; + component: ComponentType; +} + export interface HostContextMenuItemContribution { id: string; titleKey: string; @@ -1172,6 +1188,11 @@ export interface TermixApp extends TermixAppInfo { registerHostContextMenuItem: ( item: HostContextMenuItemContribution, ) => Disposer; + /** + * A card on the host page. Core 26.11.1 and newer; call it as + * app.registerHostSection?.(...) if your plugin allows older cores. + */ + registerHostSection: (section: HostSectionContribution) => Disposer; registerPaletteEntry: (entry: PaletteEntryContribution) => Disposer; registerPaletteGroup: (group: PaletteGroupContribution) => Disposer; registerKeybindingAction: (action: KeybindingActionContribution) => Disposer; diff --git a/packages/plugin-sdk/ui.api.txt b/packages/plugin-sdk/ui.api.txt index ef0635403f..344c67b5c9 100644 --- a/packages/plugin-sdk/ui.api.txt +++ b/packages/plugin-sdk/ui.api.txt @@ -159,8 +159,10 @@ HostProtocolAuthInput (type): HostProtocolAuthInput HostProtocolDef (type): HostProtocolDef HostProtocols (type): HostProtocols HostRowFields (type): HostRowFields +HostSectionDef (type): HostSectionDef HostSidebarFilterState (type): HostSidebarFilterState HostSidebarPreferences (type): HostSidebarPreferences +HostTabEmbed (value): ({ host, type, label, data, isVisible, shell, }: { host: Host; type: string; label?: string; data?: Record; isVisible?: boolean; shell: TabShellCallbacks; }) => Element HostTrayTrigger (type): HostTrayTrigger HttpsSettings (type): HttpsSettings IconPicker (value): ({ value, color, onChange, }: { value: string; color?: string; onChange: (icon: string) => void; }) => Element @@ -457,6 +459,7 @@ 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 +hostSectionsFor (value): (all: HostSectionDef[], host: Host) => HostSectionDef[] hostStatusIndicatorsFor (value): (all: HostBadgeDef[], host: Host) => HostBadgeDef[] hydrateLocalSharedHostAuth (value): (host: T) => Promise invalidateHostsAndStatusCaches (value): () => void @@ -475,6 +478,7 @@ linkExternalToPasswordAccount (value): (externalUserId: string, targetUsername: linkedServerUrl (value): () => Promise listHostActions (value): () => HostActionDef[] listHostProtocols (value): () => HostProtocolDef[] +listHostSections (value): () => HostSectionDef[] 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[] @@ -488,6 +492,7 @@ moveSplitDrag (value): (x: number, y: number) => void normalizeLanguageCode (value): (language?: string) => string notifySyncChanged (value): () => void openInSplit (value): (target: SplitOpenTarget, open: () => void) => void +operationsApi (value): AxiosInstance paletteEntriesFor (value): (all: PaletteEntryDef[], scope: "global" | "host", host?: Host) => PaletteEntryDef[] parseCustomKeybindings (value): (raw?: string) => CustomKeybinding[] parseServerTime (value): (ts: string) => Date @@ -598,6 +603,7 @@ useHostContextMenuItems (value): () => HostContextMenuItemDef[] useHostEditorSections (value): () => HostEditorSectionDef[] useHostEditorToolbarItems (value): () => HostBadgeDef[] useHostProtocols (value): () => HostProtocolDef[] +useHostSections (value): () => HostSectionDef[] useHostStatus (value): (hostId: number, statusCheckEnabled?: boolean) => StatusValue useHostStatusIndicators (value): () => HostBadgeDef[] useInstalledPlugins (value): () => PluginSummary[] diff --git a/scripts/check-sdk-ui-exports.cjs b/scripts/check-sdk-ui-exports.cjs index bd6b4b1158..170c112ce5 100644 --- a/scripts/check-sdk-ui-exports.cjs +++ b/scripts/check-sdk-ui-exports.cjs @@ -19,6 +19,12 @@ const SDK_UI = path.join(ROOT, "src", "ui", "plugin-host", "sdk-ui.ts"); const TSCONFIG = path.join(ROOT, "tsconfig.app.json"); const ALLOWED = [ + "useHostSections", + "operationsApi", + "listHostSections", + "hostSectionsFor", + "HostTabEmbed", + "HostSectionDef", "ACCENT_PRESET_COLORS", "APP_VERSION", "AccessRecord", diff --git a/src/ui/lib/plugin-transport.ts b/src/ui/lib/plugin-transport.ts index 73fabd822a..7da0df8652 100644 --- a/src/ui/lib/plugin-transport.ts +++ b/src/ui/lib/plugin-transport.ts @@ -79,6 +79,34 @@ export function createPluginApi(pluginId: string): AxiosInstance { }) as AxiosInstance; } +/** + * An axios client rooted at core's /operations routes: the action catalog, + * runs, approvals, events, signals and cross host resources. + */ +export function createOperationsApi(): AxiosInstance { + return new Proxy(authApi, { + get(target, property, receiver) { + const value = Reflect.get(target, property, receiver); + if (typeof value !== "function") return value; + if ( + property === "get" || + property === "delete" || + property === "post" || + property === "put" || + property === "patch" + ) { + return (url: string, ...rest: unknown[]) => + (value as (...args: unknown[]) => unknown).call( + target, + `/operations${url.startsWith("/") ? url : `/${url}`}`, + ...rest, + ); + } + return value.bind(target); + }, + }) as AxiosInstance; +} + /** * A raw fetch on this plugin's /plugin-api mount point, for a response the * caller reads as a stream (server-sent events). axios buffers the whole diff --git a/src/ui/plugin-host/app.ts b/src/ui/plugin-host/app.ts index 3ce15fdfc8..f84c8fe704 100644 --- a/src/ui/plugin-host/app.ts +++ b/src/ui/plugin-host/app.ts @@ -41,6 +41,10 @@ import { type KeybindingEditorProps, } from "@/shell/keybinding-registry"; import { registerHostProtocol } from "@/sidebar/host-protocols"; +import { + registerHostSection, + type HostSectionRenderProps, +} from "@/shell/host-sections"; import { registerDashboardCard, type DashboardCardRenderProps, @@ -424,6 +428,21 @@ export function createPluginApp( ); }, + registerHostSection(section) { + return track( + registerHostSection({ + id: section.id, + pluginId, + titleKey: key(section.titleKey), + order: section.order, + when: hostCallback(section.when ?? (() => true)) as never, + component: scoped( + section.component as unknown as ComponentType, + ), + }), + ); + }, + registerHostContextMenuItem(item) { return track( registerHostContextMenuItem({ diff --git a/src/ui/plugin-host/sdk-ui.ts b/src/ui/plugin-host/sdk-ui.ts index 28271a6599..5f8d6f7ed9 100644 --- a/src/ui/plugin-host/sdk-ui.ts +++ b/src/ui/plugin-host/sdk-ui.ts @@ -823,3 +823,13 @@ export { rightDockableIds, useRailItems, } from "@/sidebar/rail-items"; +export { + hostSectionsFor, + type HostSectionDef, + listHostSections, + useHostSections, +} from "@/shell/host-sections"; +export { HostTabEmbed } from "@/shell/HostTabEmbed"; +import { createOperationsApi } from "@/lib/plugin-transport"; +/** Core's /operations routes, with the user's own session. */ +export const operationsApi = createOperationsApi(); diff --git a/src/ui/plugin-host/testing-host.tsx b/src/ui/plugin-host/testing-host.tsx index 14793cb8e6..c2c1762150 100644 --- a/src/ui/plugin-host/testing-host.tsx +++ b/src/ui/plugin-host/testing-host.tsx @@ -471,6 +471,7 @@ export { resetHostContributions, } from "@/sidebar/host-contributions"; export { registerHostProtocol } from "@/sidebar/host-protocols"; +export { registerHostSection, resetHostSections } from "@/shell/host-sections"; export { RAIL_ITEMS, registerRailItem, diff --git a/src/ui/shell/HostTabEmbed.tsx b/src/ui/shell/HostTabEmbed.tsx new file mode 100644 index 0000000000..b722ee8c83 --- /dev/null +++ b/src/ui/shell/HostTabEmbed.tsx @@ -0,0 +1,45 @@ +import { useMemo } from "react"; +import type { Host, Tab } from "@/types/ui-types"; +import type { TabShellCallbacks } from "@/shell/tab-registry"; +import { renderTabContent } from "@/shell/tabUtils"; + +/** + * Draws a registered tab type inside another view, such as a host page, with + * the same props the tab stage gives it. The embed has its own instance id, so + * it never collides with the same tool opened as a normal tab. + */ +export function HostTabEmbed({ + host, + type, + label, + data, + isVisible = true, + shell, +}: { + host: Host; + type: string; + label?: string; + data?: Record; + isVisible?: boolean; + shell: TabShellCallbacks; +}) { + const tab = useMemo( + () => ({ + id: `embed-${type}-${host.id}`, + instanceId: `embed-${type}-${host.id}`, + type, + label: label ?? host.name ?? host.ip, + host, + openedAt: Date.now(), + data, + }), + // A new host or tool is a new tab; label and data changes keep it. + // eslint-disable-next-line react-hooks/exhaustive-deps + [type, host.id], + ); + return ( +
+ {renderTabContent(tab, { shell, isVisible, isFocusedPane: isVisible })} +
+ ); +} diff --git a/src/ui/shell/host-sections.ts b/src/ui/shell/host-sections.ts new file mode 100644 index 0000000000..5c511cde46 --- /dev/null +++ b/src/ui/shell/host-sections.ts @@ -0,0 +1,43 @@ +import type { ComponentType } from "react"; +import type { Host } from "@/types/ui-types"; +import { byOrderThenId, createRegistry } from "@/lib/registry"; +import type { TabShellCallbacks } from "@/shell/tab-registry"; + +export interface HostSectionRenderProps { + host: Host; + isVisible: boolean; + shell: TabShellCallbacks; +} + +/** + * A card a plugin adds to a host page. The page itself belongs to a plugin; + * core only keeps the list so any plugin can fill it. + */ +export interface HostSectionDef { + id: string; + pluginId?: string; + titleKey: string; + order?: number; + when: (host: Host) => boolean; + component: ComponentType; +} + +const sections = createRegistry(byOrderThenId); + +export const registerHostSection = sections.register; +export const useHostSections = sections.useList; +export const listHostSections = sections.list; +export const resetHostSections = sections.reset; + +export function hostSectionsFor( + all: HostSectionDef[], + host: Host, +): HostSectionDef[] { + return all.filter((section) => { + try { + return section.when(host); + } catch { + return false; + } + }); +} From 3ae56a96d0e665c75b46a15230816b40cdfc1bf4 Mon Sep 17 00:00:00 2001 From: LukeGus Date: Tue, 3 Nov 2026 22:34:51 -0600 Subject: [PATCH 013/106] chore: bump the plugin sdk to 1.0.24 --- packages/plugin-sdk/CHANGELOG.md | 11 +++++++++-- packages/plugin-sdk/PLUGIN-GUIDE.md | 2 +- packages/plugin-sdk/package.json | 2 +- 3 files changed, 11 insertions(+), 4 deletions(-) diff --git a/packages/plugin-sdk/CHANGELOG.md b/packages/plugin-sdk/CHANGELOG.md index d4f14ce81a..e622d9ffe1 100644 --- a/packages/plugin-sdk/CHANGELOG.md +++ b/packages/plugin-sdk/CHANGELOG.md @@ -1,5 +1,14 @@ # Changelog +## 1.0.24 + +Plugin API 1.14.0. + +### Added + +- `app.registerHostSection` adds a card to the host page +- `/ui` exports `HostTabEmbed`, which draws any tab type inside another view, the host section registry, and `operationsApi` for core's `/operations` routes + ## 1.0.23 Plugin API 1.14.0. @@ -10,8 +19,6 @@ Plugin API 1.14.0. - `ctx.operations` registers handlers, runs other plugins' declared actions, emits events and provides approval rules, with the new `operations:provide`, `operations:invoke` and `operations:approve` capabilities - `@termix-ssh/plugin-sdk/operations` has the types, input checks, redaction and JSON Schema for declared actions - `contributes.hostSections` declares cards for the host page -- `app.registerHostSection` adds a card to the host page -- `/ui` exports `HostTabEmbed`, which draws any tab type inside another view, the host section registry, and `operationsApi` for core's `/operations` routes ## 1.0.22 diff --git a/packages/plugin-sdk/PLUGIN-GUIDE.md b/packages/plugin-sdk/PLUGIN-GUIDE.md index d1e1789d91..7ceecc279d 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.23 | Plugin API 1.14.0 +SDK 1.0.24 | Plugin API 1.14.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. diff --git a/packages/plugin-sdk/package.json b/packages/plugin-sdk/package.json index 8f43f72ed2..dfed5bfd51 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.23", + "version": "1.0.24", "description": "The contract between Termix core and a plugin: types, the capability catalog and the manifest schema.", "license": "Apache-2.0", "repository": { From b7f749cc9d0c26178af37e95dfc2acab8b2280d3 Mon Sep 17 00:00:00 2001 From: LukeGus Date: Tue, 3 Nov 2026 22:52:30 -0600 Subject: [PATCH 014/106] feat: let a host click open the host page --- src/types/host-sidebar-preferences.ts | 3 ++- src/ui/locales/en.json | 1 + 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/src/types/host-sidebar-preferences.ts b/src/types/host-sidebar-preferences.ts index 3040d054cf..2ea47b3f98 100644 --- a/src/types/host-sidebar-preferences.ts +++ b/src/types/host-sidebar-preferences.ts @@ -34,7 +34,7 @@ export type HostTrayTrigger = "always" | "hover" | "click" | "actionsOnly"; /** What clicking a host does when it already has an open tab. */ export type HostClickBehavior = - "newTab" | "focusExisting" | "focusExistingDoubleClickNew"; + "newTab" | "focusExisting" | "focusExistingDoubleClickNew" | "hostPage"; export interface HostSidebarFilterState { status: ("online" | "offline" | "pinned")[]; @@ -136,6 +136,7 @@ const HOST_CLICK_BEHAVIORS: HostClickBehavior[] = [ "newTab", "focusExisting", "focusExistingDoubleClickNew", + "hostPage", ]; const STATUS_COLOR_SCHEMES: StatusColorScheme[] = ["accent", "status"]; const FILTER_STATUS: HostSidebarFilterState["status"] = [ diff --git a/src/ui/locales/en.json b/src/ui/locales/en.json index a0d109edc1..10c2eca63e 100644 --- a/src/ui/locales/en.json +++ b/src/ui/locales/en.json @@ -609,6 +609,7 @@ "hostClickBehavior": "Clicking a host", "hostClickBehaviorDesc": "What happens when you click a host that already has an open tab. Ctrl/Cmd click, middle click, or \"Open in new tab\" always start a new one.", "hostClickNewTab": "Always open a new tab", + "hostClickHostPage": "Open the host page, when a plugin offers one", "hostClickFocusExisting": "Switch to the open tab", "hostClickFocusExistingDoubleNew": "Click to switch, double click for a new tab", "openInNewTab": "Open in new tab", From 446596989ea471ed41b1eecdc625f0c6a017fe34 Mon Sep 17 00:00:00 2001 From: LukeGus Date: Tue, 3 Nov 2026 22:55:45 -0600 Subject: [PATCH 015/106] chore: add operations center to the plugin table --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index a2915896dc..963d26fba2 100644 --- a/README.md +++ b/README.md @@ -82,6 +82,7 @@ Plugins add everything else. The official ones ship with Termix, and admins can | [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. | +| [Operations Center](https://github.com/Termix-SSH/Plugin-Operations-Center) | Run, roll out, approve and track every plugin action across your hosts, with a page per host and tables across all of them. | | [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. | From 03029cd37266acbd053cf000af989ef11b5db008 Mon Sep 17 00:00:00 2001 From: LukeGus Date: Tue, 3 Nov 2026 23:03:20 -0600 Subject: [PATCH 016/106] feat: let a request approved in a plugin run by permissions --- src/backend/operations/approvals.ts | 3 ++- src/backend/tests/operations/gate.test.ts | 9 +++++++++ 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/src/backend/operations/approvals.ts b/src/backend/operations/approvals.ts index 8e9c10fcd0..2ad8ad9d89 100644 --- a/src/backend/operations/approvals.ts +++ b/src/backend/operations/approvals.ts @@ -69,7 +69,8 @@ export function clearApprovalPolicies(): void { } const RANK = { allow: 0, approve: 1, deny: 2 } as const; -const TRUSTED_VIA = new Set(["automation", "rollout", "fleet"]); +// approved: a person approved this exact request in the plugin's own UI. +const TRUSTED_VIA = new Set(["automation", "rollout", "fleet", "approved"]); const SELF_APPROVE_VIA = new Set(["assistant", "mcp", "automation"]); /** A person is the check when the request came from something else. */ diff --git a/src/backend/tests/operations/gate.test.ts b/src/backend/tests/operations/gate.test.ts index cb862cfdc4..47accad2ec 100644 --- a/src/backend/tests/operations/gate.test.ts +++ b/src/backend/tests/operations/gate.test.ts @@ -451,6 +451,15 @@ describe("policy", () => { expect(outcome.status).toBe("success"); }); + it("lets a request a person approved in a plugin go by RBAC", async () => { + register(); + const outcome = await runOperation({ + ...base, + origin: { kind: "plugin", pluginId: "x", via: "approved" }, + }); + expect(outcome.status).toBe("success"); + }); + it("treats a community read as a change", async () => { state.plugin.source = "user"; state.plugin.signedTier = "community"; From 2e79bf9d0d5ffefd983128da2256ccf2a53b920c Mon Sep 17 00:00:00 2001 From: LukeGus Date: Wed, 4 Nov 2026 06:29:49 -0600 Subject: [PATCH 017/106] feat: limit api keys to actions when they are made --- CHANGELOG.md | 3 ++- packages/plugin-sdk/CHANGELOG.md | 9 +++++++ packages/plugin-sdk/PLUGIN-GUIDE.md | 2 +- packages/plugin-sdk/package.json | 2 +- packages/plugin-sdk/ui.api.txt | 2 +- .../repositories/api-key-repository.ts | 3 +++ .../database/routes/user-api-key-routes.ts | 27 ++++++++++++++++++- src/ui/api/user-management-api.ts | 4 +++ 8 files changed, 47 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a897fa7ba5..2cd43a1de0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,8 @@ ### Added - Termix actions: plugins can declare what they do once, and it works from the API with permission checks, dry runs, run history and approvals -- API keys can be limited to reading or running actions +- API keys can be limited to reading or running actions, when an admin makes them +- A click on a host can open its host page, when a plugin offers one ### Changed diff --git a/packages/plugin-sdk/CHANGELOG.md b/packages/plugin-sdk/CHANGELOG.md index e622d9ffe1..6b64200e26 100644 --- a/packages/plugin-sdk/CHANGELOG.md +++ b/packages/plugin-sdk/CHANGELOG.md @@ -1,5 +1,14 @@ # Changelog +## 1.0.25 + +Plugin API 1.14.0. + +### Added + +- Host click behavior `hostPage` opens a host page when a plugin offers one +- `createApiKey` takes scopes, to limit a key to actions + ## 1.0.24 Plugin API 1.14.0. diff --git a/packages/plugin-sdk/PLUGIN-GUIDE.md b/packages/plugin-sdk/PLUGIN-GUIDE.md index 7ceecc279d..67877a860e 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.24 | Plugin API 1.14.0 +SDK 1.0.25 | Plugin API 1.14.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. diff --git a/packages/plugin-sdk/package.json b/packages/plugin-sdk/package.json index dfed5bfd51..330cbfea69 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.24", + "version": "1.0.25", "description": "The contract between Termix core and a plugin: types, the capability catalog and the manifest schema.", "license": "Apache-2.0", "repository": { diff --git a/packages/plugin-sdk/ui.api.txt b/packages/plugin-sdk/ui.api.txt index 344c67b5c9..954533256a 100644 --- a/packages/plugin-sdk/ui.api.txt +++ b/packages/plugin-sdk/ui.api.txt @@ -356,7 +356,7 @@ changePassword (value): (oldPassword: string, newPassword: string) => Promise void cn (value): (...inputs: ClassValue[]) => string copyToClipboard (value): (text: string) => Promise -createApiKey (value): (name: string, userId: string, expiresAt?: string) => Promise +createApiKey (value): (name: string, userId: string, expiresAt?: string, scopes?: string[]) => Promise createCredential (value): (credentialData: Record) => Promise> createFrontendLogger (value): (serviceName: string, serviceIcon?: string, serviceColor?: string) => FrontendLogger createQuickConnectHost (value): (input: QuickConnectInput) => Host diff --git a/src/backend/database/repositories/api-key-repository.ts b/src/backend/database/repositories/api-key-repository.ts index c037f78006..fbc456df9c 100644 --- a/src/backend/database/repositories/api-key-repository.ts +++ b/src/backend/database/repositories/api-key-repository.ts @@ -17,6 +17,8 @@ export interface ApiKeyListRecord { expiresAt: string | null; lastUsedAt: string | null; isActive: boolean; + /** JSON list, or null for full access. */ + scopes: string | null; } export class ApiKeyRepository { @@ -43,6 +45,7 @@ export class ApiKeyRepository { expiresAt: apiKeys.expiresAt, lastUsedAt: apiKeys.lastUsedAt, isActive: apiKeys.isActive, + scopes: apiKeys.scopes, }) .from(apiKeys) .leftJoin(users, eq(apiKeys.userId, users.id)) diff --git a/src/backend/database/routes/user-api-key-routes.ts b/src/backend/database/routes/user-api-key-routes.ts index f09b3a2a80..7134b5d760 100644 --- a/src/backend/database/routes/user-api-key-routes.ts +++ b/src/backend/database/routes/user-api-key-routes.ts @@ -1,5 +1,6 @@ import type { RequestHandler, Router } from "express"; import type { AuthenticatedRequest } from "../../../types/index.js"; +import { API_KEY_SCOPES } from "../../operations/key-scopes.js"; import crypto from "crypto"; import bcrypt from "bcryptjs"; import { nanoid } from "nanoid"; @@ -42,6 +43,12 @@ export function registerUserApiKeyRoutes( * type: string * format: date-time * description: Optional expiration date. Null means the key never expires. + * scopes: + * type: array + * items: + * type: string + * enum: [operations:read, operations:run] + * description: Limits the key to actions only. Leave out for full access. * responses: * 201: * description: API key created. Contains the full token (shown only once). @@ -56,7 +63,7 @@ export function registerUserApiKeyRoutes( */ router.post("/api-keys", requireAdmin, async (req, res) => { try { - const { name, userId: targetUserId, expiresAt } = req.body; + const { name, userId: targetUserId, expiresAt, scopes } = req.body; const apiKeyRepository = createCurrentApiKeyRepository(); const userRepository = createCurrentUserRepository(); @@ -86,6 +93,21 @@ export function registerUserApiKeyRoutes( expiresAtValue = parsed.toISOString(); } + let scopesValue: string[] | null = null; + if (scopes !== undefined && scopes !== null) { + if ( + !Array.isArray(scopes) || + scopes.some( + (scope) => + !(API_KEY_SCOPES as readonly string[]).includes(String(scope)), + ) + ) { + return res.status(400).json({ error: "Unknown API key scope" }); + } + scopesValue = + scopes.length > 0 ? [...new Set(scopes as string[])] : null; + } + const rawToken = "tmx_" + crypto.randomBytes(32).toString("hex"); const tokenPrefix = rawToken.substring(0, 12); const tokenHash = await bcrypt.hash(rawToken, 10); @@ -102,6 +124,7 @@ export function registerUserApiKeyRoutes( expiresAt: expiresAtValue, lastUsedAt: null, isActive: true, + scopes: scopesValue ? JSON.stringify(scopesValue) : null, }); const actorId = (req as AuthenticatedRequest).userId; @@ -119,6 +142,7 @@ export function registerUserApiKeyRoutes( details: JSON.stringify({ targetUserId, targetUsername: targetUser.username, + scopes: scopesValue, }), ipAddress, userAgent, @@ -133,6 +157,7 @@ export function registerUserApiKeyRoutes( tokenPrefix, createdAt: now, expiresAt: expiresAtValue, + scopes: scopesValue, token: rawToken, }); } catch (err) { diff --git a/src/ui/api/user-management-api.ts b/src/ui/api/user-management-api.ts index b0f97cadba..81f9a1142b 100644 --- a/src/ui/api/user-management-api.ts +++ b/src/ui/api/user-management-api.ts @@ -87,6 +87,8 @@ export interface ApiKey { expiresAt: string | null; lastUsedAt: string | null; isActive: boolean; + /** Null for full access. */ + scopes?: string[] | null; } export interface CreatedApiKey extends ApiKey { @@ -97,12 +99,14 @@ export async function createApiKey( name: string, userId: string, expiresAt?: string, + scopes?: string[] | null, ): Promise { try { const response = await authApi.post("/users/api-keys", { name, userId, expiresAt: expiresAt ?? null, + scopes: scopes?.length ? scopes : null, }); return response.data; } catch (error) { From 1c4fa52de3b307d97f114a74417c843f9bdf3270 Mon Sep 17 00:00:00 2001 From: LukeGus Date: Wed, 4 Nov 2026 08:11:34 -0600 Subject: [PATCH 018/106] fix: remove plugin updates the bundled copy caught up with --- CHANGELOG.md | 1 + src/backend/plugins/loader.ts | 23 ++++++++++++++++--- .../tests/plugins/signed-plugins-gate.test.ts | 10 ++++++++ 3 files changed, 31 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2cd43a1de0..a7e8e324ff 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,7 @@ - Alerts with no icon no longer squeeze their text to one word per line - Password fields with the show button are the same height as the other fields next to them - The Secure your account setup step no longer shows up empty when no passkey or authenticator plugin is on +- An installed plugin update older than the copy in a newer image is removed at startup instead of logging an error every boot ## 26.11.0 diff --git a/src/backend/plugins/loader.ts b/src/backend/plugins/loader.ts index 455b6c9027..5b3220c840 100644 --- a/src/backend/plugins/loader.ts +++ b/src/backend/plugins/loader.ts @@ -409,6 +409,15 @@ export class PluginLoader { } loaded.push(plugin); } catch (error) { + // A newer image ships a newer copy, so the old update can go. + if (isArtifact && error instanceof SupersededUpdateError) { + await fs.promises.rm(dir, { force: true }).catch(() => {}); + await fs.promises.rm(`${dir}.sig`, { force: true }).catch(() => {}); + pluginLogger.info(`Removed ${entry.name}: ${error.message}`, { + operation: "plugin_load", + }); + continue; + } this.skippedPlugins.push( describeSkipped(dir, entry.name, isArtifact, error), ); @@ -948,6 +957,16 @@ function withTimeout( const PLUGIN_ID_PATTERN = /^[a-z0-9][a-z0-9-]*$/; +/** An installed update that the bundled copy has caught up with. */ +export class SupersededUpdateError extends Error { + constructor(id: string, version: string, bundledVersion: string) { + super( + `update for "${id}" is ${version}, which is not newer than the bundled ${bundledVersion}`, + ); + this.name = "SupersededUpdateError"; + } +} + /** * Throws unless an artifact may replace the bundled plugin with the same id: * a trusted key signed it and its version is strictly higher, so an old @@ -971,9 +990,7 @@ export function assertBundledUpdate( // An admin pinning an exact older release is the one allowed downgrade. if (pinnedVersion && pinnedVersion === version) return; if (bundledVersion && !semver.gt(version, bundledVersion)) { - throw new Error( - `update for "${id}" is ${version}, which is not newer than the bundled ${bundledVersion}`, - ); + throw new SupersededUpdateError(id, version, bundledVersion); } } diff --git a/src/backend/tests/plugins/signed-plugins-gate.test.ts b/src/backend/tests/plugins/signed-plugins-gate.test.ts index 8916366cef..1bd20e49fa 100644 --- a/src/backend/tests/plugins/signed-plugins-gate.test.ts +++ b/src/backend/tests/plugins/signed-plugins-gate.test.ts @@ -231,6 +231,16 @@ describe("updating a bundled plugin", () => { expect(loader.get("bundled-one")?.manifest.version).toBe("1.0.0"); }); + it("removes an update the bundled copy caught up with", async () => { + const { userDir } = setup(); + const file = await writeArtifact(userDir, "bundled-one", "valid", "0.9.0"); + const loader = new PluginLoader(); + await loader.loadAll(); + expect(fs.existsSync(file)).toBe(false); + expect(fs.existsSync(`${file}.sig`)).toBe(false); + expect(loader.skipped()).toEqual([]); + }); + it("refuses a downgrade after the bundled copy was unloaded", async () => { const { userDir } = setup(); const loader = new PluginLoader(); From 9613b787a78ee67b8f985d68df4b784a5cc53990 Mon Sep 17 00:00:00 2001 From: LukeGus Date: Wed, 4 Nov 2026 15:29:46 -0600 Subject: [PATCH 019/106] feat: shared tile grid for plugin pages and many-tile dashboard cards (SDK 1.0.26) --- CHANGELOG.md | 7 + packages/plugin-sdk/CHANGELOG.md | 10 + packages/plugin-sdk/PLUGIN-GUIDE.md | 8 +- packages/plugin-sdk/package.json | 2 +- packages/plugin-sdk/src/frontend.ts | 27 + packages/plugin-sdk/src/manifest.ts | 2 +- packages/plugin-sdk/ui.api.txt | 12 + scripts/check-sdk-ui-exports.cjs | 12 + src/ui/components/charts/LineChart.tsx | 8 + src/ui/components/metric-card.tsx | 34 +- src/ui/components/tile-grid/TileFrame.tsx | 72 ++ src/ui/components/tile-grid/TileGrid.tsx | 827 ++++++++++++++++++ src/ui/components/tile-grid/TilePicker.tsx | 221 +++++ src/ui/components/tile-grid/context.ts | 8 + src/ui/components/tile-grid/index.ts | 4 + src/ui/components/tile-grid/layout.ts | 234 +++++ src/ui/dashboard/dashboard-cards-registry.ts | 22 + src/ui/locales/en.json | 22 + src/ui/plugin-host/app.ts | 2 + src/ui/plugin-host/sdk-ui.ts | 14 + .../components/tile-grid/TileGrid.test.tsx | 299 +++++++ .../tests/components/tile-grid/layout.test.ts | 157 ++++ 22 files changed, 1997 insertions(+), 7 deletions(-) create mode 100644 src/ui/components/tile-grid/TileFrame.tsx create mode 100644 src/ui/components/tile-grid/TileGrid.tsx create mode 100644 src/ui/components/tile-grid/TilePicker.tsx create mode 100644 src/ui/components/tile-grid/context.ts create mode 100644 src/ui/components/tile-grid/index.ts create mode 100644 src/ui/components/tile-grid/layout.ts create mode 100644 src/ui/tests/components/tile-grid/TileGrid.test.tsx create mode 100644 src/ui/tests/components/tile-grid/layout.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index a7e8e324ff..79e041ac67 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,9 +7,15 @@ - Termix actions: plugins can declare what they do once, and it works from the API with permission checks, dry runs, run history and approvals - API keys can be limited to reading or running actions, when an admin makes them - A click on a host can open its host page, when a plugin offers one +- Plugins can build pages of tiles you drag and resize, and add many copies of one dashboard card, each with its own settings +- A plugin a newer release ships is downloaded on its own when your image does not have it, and tried again on the next start when offline ### Changed +- Plugin cards placed on a tile grid take the dashboard look, with shared lines and no gaps +- Line charts that cover a few minutes show seconds in their times +- Plugins a new release adds to the image start on only when they are recommended. The rest are installed and left off for an admin to turn on + - Host status, import and export, and credential tools now start on for new installs - Sync now starts on in a new desktop app, so it can link to a server - The first run plugin picker leaves plugins you did not choose off instead of removing them @@ -21,6 +27,7 @@ - Password fields with the show button are the same height as the other fields next to them - The Secure your account setup step no longer shows up empty when no passkey or authenticator plugin is on - An installed plugin update older than the copy in a newer image is removed at startup instead of logging an error every boot +- Canceling a download to the desktop app right as it starts no longer leaves a partial file behind ## 26.11.0 diff --git a/packages/plugin-sdk/CHANGELOG.md b/packages/plugin-sdk/CHANGELOG.md index 6b64200e26..edb69328f1 100644 --- a/packages/plugin-sdk/CHANGELOG.md +++ b/packages/plugin-sdk/CHANGELOG.md @@ -1,5 +1,15 @@ # Changelog +## 1.0.26 + +Plugin API 1.15.0. + +### Added + +- `/ui` exports `TileGrid`, `TileFrame` and `TilePicker`, the 12 column tile grid the dashboard, the host page and Host Metrics share, plus `appendTile`, `flowTiles`, `removeTile`, `sanitizeTiles` and the `TileSlot` type +- `registerDashboardCard` takes `entries`, so one card can be placed many times with its own `config`, and `defaultSize` for the grid +- Dashboard cards get `config`, `setConfig` and `editMode` + ## 1.0.25 Plugin API 1.14.0. diff --git a/packages/plugin-sdk/PLUGIN-GUIDE.md b/packages/plugin-sdk/PLUGIN-GUIDE.md index 67877a860e..621bd94f8b 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.25 | Plugin API 1.14.0 +SDK 1.0.26 | Plugin API 1.15.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. @@ -768,6 +768,12 @@ exports with props is `ui.api.txt` in the Termix repo `PanePrompt` for small prompts. - Every tab or panel sits in `PanelShell`. Lists use `PanelList` and `ListRow`, add with `AddButton`, forms end with `FormFooter`, empty lists show `EmptyState`. +- Tile pages use `TileGrid` with `TileFrame` around each tile and `TilePicker` beside it while editing. Slots are + `{ id, x, y, w, h }` on 12 columns (`h` null for the natural height); save them where your plugin keeps + preferences and run saved ones through `sanitizeTiles`. Add with `appendTile`. Needs Termix 26.11.1. +- A dashboard card with `entries` can be placed many times: each entry has a title, a group and a `config` the + card gets back as `config` (save changes with `setConfig`, keep it small). Operations Center uses this for + status and action tiles. - Card grids use `DataView` with `ViewToggle`. Pass `columns` and `onColumns` to `ViewToggle` and the same value as `size` to `DataView`, so users can pick Auto (as many as fit) or a fixed number of cards per row. `usePluginPanelView` keeps the choice as `columns`. Older Termix versions ignore both props. diff --git a/packages/plugin-sdk/package.json b/packages/plugin-sdk/package.json index 330cbfea69..d72578dec9 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.25", + "version": "1.0.26", "description": "The contract between Termix core and a plugin: types, the capability catalog and the manifest schema.", "license": "Apache-2.0", "repository": { diff --git a/packages/plugin-sdk/src/frontend.ts b/packages/plugin-sdk/src/frontend.ts index b604057fe7..1fe6f52dbf 100644 --- a/packages/plugin-sdk/src/frontend.ts +++ b/packages/plugin-sdk/src/frontend.ts @@ -799,6 +799,26 @@ export interface KeybindingDefaultContribution { export interface DashboardCardProps { isVisible: boolean; shell: ShellApi; + /** This tile's settings, for cards with `entries`. Empty otherwise. Core 26.11.1+. */ + config?: Record; + /** Saves this tile's settings. Keep it small, it is stored in one line. */ + setConfig?: (config: Record) => void; + /** True while the user is arranging the dashboard. */ + editMode?: boolean; +} + +/** One tile a card with `entries` offers in the dashboard's add list. */ +export interface DashboardCardEntry { + /** Stable within the card, used to mark entries already placed. */ + key: string; + title: string; + description?: string; + /** Group heading in the add list. Defaults to the card title. */ + group?: string; + kind?: "card" | "chart" | "signal" | "action" | "resource" | "session"; + /** Passed back as `config`. */ + config: Record; + defaultSize?: { w: number; h?: number | null }; } export interface DashboardCardContribution { @@ -813,6 +833,13 @@ export interface DashboardCardContribution { * edge, for a strip of numbers. Defaults to "framed". */ frame?: "framed" | "bare"; + /** Size on the 12 column dashboard grid when added. Core 26.11.1+. */ + defaultSize?: { w: number; h?: number | null }; + /** + * Lets the card be placed many times, once per entry, each with its own + * `config`. Titles come back already translated. Core 26.11.1+. + */ + entries?: (language: string) => Promise; component: ComponentType; } diff --git a/packages/plugin-sdk/src/manifest.ts b/packages/plugin-sdk/src/manifest.ts index 023a61adbd..281fdb6c50 100644 --- a/packages/plugin-sdk/src/manifest.ts +++ b/packages/plugin-sdk/src/manifest.ts @@ -42,7 +42,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.14.0"; +export const PLUGIN_API_VERSION = "1.15.0"; /** The API major, the default engine.api for a new plugin. */ export const SUPPORTED_PLUGIN_API_VERSION = String( diff --git a/packages/plugin-sdk/ui.api.txt b/packages/plugin-sdk/ui.api.txt index 954533256a..60021fcf0e 100644 --- a/packages/plugin-sdk/ui.api.txt +++ b/packages/plugin-sdk/ui.api.txt @@ -291,6 +291,7 @@ 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 +TILE_COLUMNS (value): 12 TOTPDialog (value): ({ isOpen, prompt, mode, waiting, onSubmit, onCancel, backgroundColor, }: TOTPDialogProps) => Element Tab (type): Tab TabShellCallbacks (type): TabShellCallbacks @@ -305,6 +306,13 @@ TextField (value): ({ label, value, onChange, placeholder, hint, error, aside, d Textarea (value): ForwardRefExoticComponent> TextareaProps (type): TextareaProps ThemeId (type): string +TileFrame (value): ({ title, icon, actions, bare, onTitleClick, className, bodyClassName, children, }: { title?: ReactNode; icon?: ReactNode; actions?: ReactNode; bare?: boolean; onTitleClick?: () => void; className?: string; bodyClassName?: string; children: ReactNode; }) => Element +TileGrid (value): ({ slots, editMode, renderTile, tileLabel, onChange, onSettings, gap, stackBelow, minColumns, minHeight, empty, className, }: TileGridProps) => Element +TileGridProps (type): TileGridProps +TileKind (type): TileKind +TilePicker (value): ({ entries, onAdd, onClose, className, }: { entries: TilePickerEntry[]; onAdd: (entry: TilePickerEntry) => void; onClose?: () => void; className?: string; }) => Element +TilePickerEntry (type): TilePickerEntry +TileSlot (type): TileSlot TlsStatus (type): TlsStatus ToolsTab (type): ToolsTab Tooltip (value): ({ ...props }: TooltipProps) => Element @@ -341,6 +349,7 @@ 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 +appendTile (value): (slots: TileSlot[], tile: { id: string; w?: number; h?: number; }) => TileSlot[] applyAccentColor (value): (colorValue: string) => void applyFontSize (value): (id: FontSizeId) => void applyPresetSideEffects (value): (preset: "simple" | "balanced" | "advanced") => Promise @@ -384,6 +393,7 @@ 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 +flowTiles (value): (tiles: { id: string; w: number; h?: number; }[], rowHeight?: number) => TileSlot[] getActiveSessions (value): () => Promise getAdaptiveResourceBudget (value): (kind: AdaptiveTaskKind) => AdaptiveResourceBudget getApiKeys (value): () => Promise<{ apiKeys: ApiKey[]; }> @@ -514,6 +524,7 @@ releaseUrlFrom (value): (info: VersionInfo) => string rem (value): (px: number) => string removeAdminStatus (value): (userId: string) => Promise> removeRoleFromUser (value): (userId: string, roleId: number) => Promise<{ success: boolean; }> +removeTile (value): (slots: TileSlot[], id: string) => TileSlot[] renameCredentialFolder (value): (oldName: string, newName: string) => Promise> renameFolder (value): (oldName: string, newName: string) => Promise> reorderCredentials (value): (positions: { id: number; sortOrder: number; }[]) => Promise<{ updated: number; }> @@ -543,6 +554,7 @@ runHostAction (value): (action: HostActionDef, host: Host, shell: TabShellCallba runVisibleInterval (value): (tick: () => void, intervalMs: number) => () => void sanitizeCredentialSidebarPreferences (value): (input: unknown) => CredentialSidebarPreferences sanitizeHostSidebarPreferences (value): (input: unknown) => HostSidebarPreferences +sanitizeTiles (value): (value: unknown, min?: number) => TileSlot[] saveCredentialSidebarPreferences (value): (preferences: CredentialSidebarPreferences) => Promise saveHostDefaultsLevel (value): (target: HostDefaultsTarget, change: HostDefaultsChange) => Promise<{ changedHosts?: number; jobId?: string; }> saveHostSidebarPreferences (value): (preferences: HostSidebarPreferences) => Promise diff --git a/scripts/check-sdk-ui-exports.cjs b/scripts/check-sdk-ui-exports.cjs index 170c112ce5..0b78339cd6 100644 --- a/scripts/check-sdk-ui-exports.cjs +++ b/scripts/check-sdk-ui-exports.cjs @@ -19,6 +19,18 @@ const SDK_UI = path.join(ROOT, "src", "ui", "plugin-host", "sdk-ui.ts"); const TSCONFIG = path.join(ROOT, "tsconfig.app.json"); const ALLOWED = [ + "TileFrame", + "TileGrid", + "TilePicker", + "appendTile", + "flowTiles", + "removeTile", + "sanitizeTiles", + "TILE_COLUMNS", + "TileGridProps", + "TileKind", + "TilePickerEntry", + "TileSlot", "useHostSections", "operationsApi", "listHostSections", diff --git a/src/ui/components/charts/LineChart.tsx b/src/ui/components/charts/LineChart.tsx index 9870e9b559..1079c5a539 100644 --- a/src/ui/components/charts/LineChart.tsx +++ b/src/ui/components/charts/LineChart.tsx @@ -26,6 +26,14 @@ const TICK_COUNT = 5; function formatTimestamp(ts: string, rangeMs: number): string { const d = parseServerTime(ts); + // A live chart covers a minute or two, so minutes alone would repeat. + if (rangeMs <= 10 * 60 * 1000) { + return d.toLocaleTimeString([], { + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + }); + } if (rangeMs <= 25 * 60 * 60 * 1000) { return d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }); } diff --git a/src/ui/components/metric-card.tsx b/src/ui/components/metric-card.tsx index 26ba3bd877..72431c7f17 100644 --- a/src/ui/components/metric-card.tsx +++ b/src/ui/components/metric-card.tsx @@ -1,4 +1,5 @@ import type { ReactNode } from "react"; +import { useInSeamlessTile } from "@/components/tile-grid/context"; import { cn } from "@/lib/utils"; /** Default cap (px) for a scrollable card body before it scrolls. */ @@ -29,11 +30,36 @@ export function MetricCard({ /** Max body height in px for scrollable cards. */ scrollMax?: number; }) { + // In an edge to edge tile grid the grid draws the lines, so the card takes + // the dashboard tile look: no border or fill, the small title row. + const tiled = useInSeamlessTile(); return ( -
-
- {icon} - +
+
+ + {icon} + + {title} {action &&
{action}
} diff --git a/src/ui/components/tile-grid/TileFrame.tsx b/src/ui/components/tile-grid/TileFrame.tsx new file mode 100644 index 0000000000..24d5efc519 --- /dev/null +++ b/src/ui/components/tile-grid/TileFrame.tsx @@ -0,0 +1,72 @@ +import type { ReactNode } from "react"; +import { cn } from "@/lib/utils"; + +/** + * The look every tile shares with the dashboard: a small uppercase title row + * over a body that scrolls when the tile has a fixed height. The grid draws + * the lines between tiles. `bare` drops the title row. + */ +export function TileFrame({ + title, + icon, + actions, + bare = false, + onTitleClick, + className, + bodyClassName, + children, +}: { + title?: ReactNode; + icon?: ReactNode; + actions?: ReactNode; + bare?: boolean; + onTitleClick?: () => void; + className?: string; + bodyClassName?: string; + children: ReactNode; +}) { + return ( +
+ {!bare && (title || icon || actions) && ( +
+ {icon && ( + + {icon} + + )} + {onTitleClick ? ( + + ) : ( + + {title} + + )} + {actions && ( + + {actions} + + )} +
+ )} +
+ {children} +
+
+ ); +} diff --git a/src/ui/components/tile-grid/TileGrid.tsx b/src/ui/components/tile-grid/TileGrid.tsx new file mode 100644 index 0000000000..c459ed5d7a --- /dev/null +++ b/src/ui/components/tile-grid/TileGrid.tsx @@ -0,0 +1,827 @@ +import { + useCallback, + useEffect, + useLayoutEffect, + useMemo, + useRef, + useState, + type KeyboardEvent as ReactKeyboardEvent, + type PointerEvent as ReactPointerEvent, + type ReactNode, +} from "react"; +import { useTranslation } from "react-i18next"; +import { GripVertical, Settings2, X } from "lucide-react"; +import { remScale } from "@/lib/rem"; +import { cn } from "@/lib/utils"; +import { + MAX_TILE_HEIGHT, + MIN_TILE_COLUMNS, + MIN_TILE_HEIGHT, + TILE_COLUMNS, + clampTileColumns, + settleTiles, + tileBottom, + tileBoxes, + tilesFromBoxes, + type TileBox, + type TileSlot, +} from "./layout"; +import { SeamlessTileContext } from "./context"; +import { gridLines } from "./layout"; + +type Edge = "left" | "right" | "bottom" | "corner"; +interface Proposal { + x?: number; + y?: number; + w?: number; + h?: number | null; +} +interface Interaction { + id: string; + kind: "move" | "resize"; + base: TileSlot[]; + proposal: Proposal; + frame: { left: number; top: number; width: number; height?: number }; +} + +const DRAG_THRESHOLD = 4; +const SNAP = 8; +const SCROLL_EDGE = 48; + +const styles = ` +.tg-grid { position: relative; } +.tg-tile { position: absolute; left: 0; top: 0; min-width: 0; display: flex; flex-direction: column; } +.tg-grid[data-animate="true"] .tg-tile:not([data-active]) { transition: transform 160ms ease, width 160ms ease, height 160ms ease; } +.tg-tile[data-active] { z-index: 40; } +.tg-tile[data-active="move"] { opacity: 0.85; cursor: grabbing; } +.tg-grid[data-edit-mode="true"] .tg-tile { user-select: none; } +.tg-grid[data-edit-mode="true"] .tg-tile:not([data-active]) { cursor: grab; } +[data-tg-drop-preview] { + position: absolute; left: 0; top: 0; z-index: 5; pointer-events: none; box-sizing: border-box; + background: color-mix(in srgb, var(--accent-brand, #60a5fa) 12%, transparent); + border: 2px dashed color-mix(in srgb, var(--accent-brand, #60a5fa) 70%, transparent); +} +.tg-handle { position: absolute; z-index: 30; display: flex; touch-action: none; align-items: center; justify-content: center; } +.tg-handle > span { background: var(--border); } +.tg-handle:hover > span { background: var(--accent-brand, #60a5fa); } +.tg-edge-left, .tg-edge-right { top: 2rem; bottom: 1rem; width: 0.75rem; cursor: ew-resize; } +.tg-edge-left { left: 0; } +.tg-edge-right { right: 0; } +.tg-edge-bottom { bottom: 0; left: 1rem; right: 1rem; height: 0.75rem; cursor: ns-resize; } +.tg-edge-corner { bottom: 0; right: 0; width: 1rem; height: 1rem; cursor: nwse-resize; } +.tg-edge-corner::after { content: ""; width: 0.5rem; height: 0.5rem; border-right: 2px solid var(--border); border-bottom: 2px solid var(--border); } +.tg-handle:focus-visible { outline: 2px solid var(--accent-brand, #60a5fa); } +.tg-card[data-fixed-height="true"] { height: 100%; } +.tg-card[data-fixed-height="true"] > .tg-content { overflow: hidden; } +.tg-card[data-fixed-height="true"] > .tg-content > * { height: 100%; } +`; + +const clamp = (value: number, min: number, max: number) => + Math.max(min, Math.min(max, value)); + +function propose(base: TileSlot[], id: string, proposal: Proposal) { + return base.map((slot) => (slot.id === id ? { ...slot, ...proposal } : slot)); +} + +function scrollParent(element: HTMLElement | null) { + for (let node = element?.parentElement; node; node = node.parentElement) { + const { overflowY } = getComputedStyle(node); + if (overflowY === "auto" || overflowY === "scroll") return node; + } + return null; +} + +const NO_DRAG = + "button, a, input, select, textarea, label, [role=separator], [contenteditable=true], .tg-handle"; + +export interface TileGridProps { + slots: TileSlot[]; + editMode: boolean; + renderTile: (id: string) => ReactNode; + /** Name of a tile, for the edit controls. */ + tileLabel: (id: string) => string; + onChange: (slots: TileSlot[]) => void; + /** Shows a settings button on each tile while editing. */ + onSettings?: (id: string) => void; + /** Space between tiles in px. 0 draws tiles edge to edge with shared borders, like the dashboard. */ + gap?: number; + /** Below this width tiles stack in one column and editing is off. */ + stackBelow?: number; + minColumns?: number; + minHeight?: number; + /** Shown while editing an empty grid. */ + empty?: ReactNode; + className?: string; +} + +/** + * A 12 column grid of tiles the user can drag and resize. Tiles keep their + * column and settle upward, so a layout never has holes it did not ask for. + */ +export function TileGrid({ + slots, + editMode, + renderTile, + tileLabel, + onChange, + onSettings, + gap = 0, + stackBelow = 640, + minColumns = MIN_TILE_COLUMNS, + minHeight = MIN_TILE_HEIGHT, + empty, + className, +}: TileGridProps) { + const { t } = useTranslation(); + const root = useRef(null); + const gridEl = useRef(null); + const [width, setWidth] = useState(0); + // Whether the grid reaches the bottom of the view it scrolls in. Then its + // last row meets the bottom of the screen and needs no line under it. + const [overflows, setOverflows] = useState(false); + const overflowsRef = useRef(overflows); + overflowsRef.current = overflows; + const [heights, setHeights] = useState>({}); + const [active, setActive] = useState(null); + const cancel = useRef<(() => void) | null>(null); + const heightsRef = useRef(heights); + heightsRef.current = heights; + + const scale = remScale(); + const seamless = gap <= 0; + // Edge to edge tiles draw no lines of their own. The grid works out every + // line between them and draws each once, so a line is never doubled at any + // display scaling and a taller neighbour still gets its edge. + const inset = seamless ? 0 : gap * scale; + const settleGap = seamless ? 0 : gap; + const pitch = (width + inset) / TILE_COLUMNS; + const stacked = width > 0 && width < stackBelow; + const editing = editMode && !stacked; + + const settled = useMemo( + () => settleTiles(tileBoxes(slots, heights), settleGap), + [slots, heights, settleGap], + ); + const preview = useMemo( + () => + active + ? settleTiles( + tileBoxes( + propose(active.base, active.id, active.proposal), + heights, + ), + settleGap, + ) + : settled, + [active, settled, heights, settleGap], + ); + const boxById = useMemo( + () => new Map(preview.map((box) => [box.id, box])), + [preview], + ); + // Same elements across pointer moves, so tiles only re-render on new data. + const content = useMemo( + () => + new Map( + slots.map((slot) => [slot.id, renderTile(slot.id)]), + ), + [slots, renderTile], + ); + + useLayoutEffect(() => { + const element = root.current; + if (!element) return; + const update = () => + setWidth(element.getBoundingClientRect().width || element.clientWidth); + update(); + const observer = new ResizeObserver(update); + observer.observe(element); + return () => observer.disconnect(); + }, []); + + useLayoutEffect(() => { + const element = root.current; + const scroller = seamless ? scrollParent(element) : null; + if (!element || !scroller) { + setOverflows(false); + return; + } + // Where the grid's last row ends inside the scrolled content, without the + // kept bottom line's px, so adding or dropping that line never flips it. + const update = () => { + const end = + element.getBoundingClientRect().bottom - + scroller.getBoundingClientRect().top + + scroller.scrollTop - + (overflowsRef.current ? 0 : 1); + setOverflows(end >= scroller.clientHeight - 0.5); + }; + update(); + const observer = new ResizeObserver(update); + observer.observe(scroller); + observer.observe(element); + return () => observer.disconnect(); + }, [seamless, settled, width]); + + // An outside change (undo, reset, host switch) ends any drag in progress. + useEffect(() => () => cancel.current?.(), [slots, editing]); + + const reportHeight = useCallback( + (id: string, height: number) => + setHeights((previous) => + Math.abs((previous[id] ?? -1) - height) < 0.5 + ? previous + : { ...previous, [id]: height }, + ), + [], + ); + + const commit = (base: TileSlot[], id: string | null, proposal: Proposal) => { + const next = id ? propose(base, id, proposal) : base; + const boxes = settleTiles(tileBoxes(next, heightsRef.current), settleGap); + onChange(tilesFromBoxes(next, boxes)); + }; + const settledBase = (): TileSlot[] => + slots.map((slot) => ({ + ...slot, + y: settled.find((box) => box.id === slot.id)?.y ?? slot.y, + })); + + const begin = ( + event: ReactPointerEvent, + id: string, + edge?: Edge, + ) => { + if (event.button !== 0 || !editing || cancel.current) return; + const element = gridEl.current; + const box = settled.find((b) => b.id === id); + const slot = slots.find((s) => s.id === id); + if (!element || !box || !slot) return; + if (!edge) { + const target = event.target as HTMLElement; + if (target.closest(NO_DRAG) && !target.closest("[data-tg-grip]")) return; + const native = event.nativeEvent; + const scrolls = + target.scrollHeight > target.clientHeight || + target.scrollWidth > target.clientWidth; + if ( + scrolls && + (native.offsetX > target.clientWidth || + native.offsetY > target.clientHeight) + ) + return; + } else { + event.preventDefault(); + event.stopPropagation(); + } + const kind = edge ? "resize" : "move"; + const base = settledBase(); + const origin = element.getBoundingClientRect(); + const startLeft = box.x * pitch; + const startTop = box.y * scale; + const startWidth = box.w * pitch - inset; + const startHeight = box.h * scale; + const fixed = slot.h != null; + const startX = event.clientX; + const startY = event.clientY; + const grabX = startX - origin.left - startLeft; + const grabY = startY - origin.top - startTop; + const scroller = scrollParent(element); + const others = settled.filter((b) => b.id !== id); + let pointer = { x: startX, y: startY }; + let started = kind === "resize"; + let current: Interaction | null = null; + let timer: ReturnType | undefined; + + const compute = (): Interaction => { + const rect = element.getBoundingClientRect(); + if (kind === "move") { + const left = pointer.x - rect.left - grabX; + const top = pointer.y - rect.top - grabY; + return { + id, + kind, + base, + proposal: { + x: clamp(Math.round(left / pitch), 0, TILE_COLUMNS - box.w), + y: Math.max(0, top / scale), + }, + frame: { + left, + top, + width: startWidth, + height: fixed ? startHeight : undefined, + }, + }; + } + const dx = pointer.x - startX; + const dy = pointer.y - rect.top - (startY - origin.top); + const proposal: Proposal = {}; + const frame: Interaction["frame"] = { + left: startLeft, + top: startTop, + width: startWidth, + height: fixed ? startHeight : undefined, + }; + const minWidth = minColumns * pitch - inset; + if (edge === "right" || edge === "corner") { + const raw = clamp( + startWidth + dx, + minWidth, + (TILE_COLUMNS - box.x) * pitch - inset, + ); + proposal.w = Math.min( + clampTileColumns((raw + inset) / pitch, minColumns), + TILE_COLUMNS - box.x, + ); + frame.width = raw; + } + if (edge === "left") { + const right = box.x + box.w; + const raw = clamp(startWidth - dx, minWidth, right * pitch - inset); + proposal.w = Math.min( + clampTileColumns((raw + inset) / pitch, minColumns), + right, + ); + proposal.x = right - proposal.w; + frame.width = raw; + frame.left = startLeft + startWidth - raw; + } + if (edge === "bottom" || edge === "corner") { + let height = + clamp(startHeight + dy, minHeight * scale, MAX_TILE_HEIGHT * scale) / + scale; + // Line up with the bottom of a nearby tile. + for (const other of others) { + const bottom = other.y + other.h - box.y; + if (bottom >= minHeight && Math.abs(bottom - height) <= SNAP) { + height = bottom; + break; + } + } + proposal.h = Math.round(height); + frame.height = proposal.h * scale; + } + return { id, kind, base, proposal, frame }; + }; + const update = () => { + current = compute(); + setActive(current); + }; + const autoScroll = () => { + if (!scroller) return; + const rect = scroller.getBoundingClientRect(); + const step = + pointer.y < rect.top + SCROLL_EDGE + ? -12 + : pointer.y > rect.bottom - SCROLL_EDGE + ? 12 + : 0; + if (!step) return; + const before = scroller.scrollTop; + scroller.scrollTop += step; + if (scroller.scrollTop !== before) update(); + }; + const move = (e: PointerEvent) => { + if (e.pointerId !== event.pointerId) return; + pointer = { x: e.clientX, y: e.clientY }; + if ( + !started && + Math.hypot(pointer.x - startX, pointer.y - startY) < DRAG_THRESHOLD + ) + return; + if (!started) { + started = true; + window.getSelection?.()?.removeAllRanges(); + if (scroller) timer = setInterval(autoScroll, 16); + } + update(); + }; + const finish = (save: boolean) => { + window.removeEventListener("pointermove", move); + window.removeEventListener("pointerup", up); + window.removeEventListener("pointercancel", cancelled); + window.removeEventListener("keydown", key); + window.removeEventListener("blur", blur); + clearInterval(timer); + cancel.current = null; + setActive(null); + if (save && started && current) commit(base, id, current.proposal); + }; + const up = (e: PointerEvent) => { + if (e.pointerId === event.pointerId) finish(true); + }; + const cancelled = (e: PointerEvent) => { + if (e.pointerId === event.pointerId) finish(false); + }; + const key = (e: KeyboardEvent) => { + if (e.key !== "Escape") return; + e.preventDefault(); + finish(false); + }; + const blur = () => finish(false); + cancel.current = () => finish(false); + window.addEventListener("pointermove", move); + window.addEventListener("pointerup", up); + window.addEventListener("pointercancel", cancelled); + window.addEventListener("keydown", key); + window.addEventListener("blur", blur); + if (started) update(); + }; + + const moveByKey = (event: ReactKeyboardEvent, id: string) => { + const box = settled.find((b) => b.id === id); + if (!box || !event.key.startsWith("Arrow")) return; + const near = settled.filter( + (b) => b.id !== id && b.x < box.x + box.w && box.x < b.x + b.w, + ); + let proposal: Proposal | null = null; + if (event.key === "ArrowLeft" && box.x > 0) proposal = { x: box.x - 1 }; + if (event.key === "ArrowRight" && box.x + box.w < TILE_COLUMNS) + proposal = { x: box.x + 1 }; + if (event.key === "ArrowUp") { + const above = near + .filter((b) => b.y < box.y) + .sort((a, b) => b.y - a.y)[0]; + if (above) proposal = { y: Math.max(0, above.y - 1) }; + } + if (event.key === "ArrowDown") { + const below = near + .filter((b) => b.y > box.y) + .sort((a, b) => a.y - b.y)[0]; + if (below) proposal = { y: below.y + 1 }; + } + event.preventDefault(); + if (proposal) commit(settledBase(), id, proposal); + }; + + const resizeByKey = ( + event: ReactKeyboardEvent, + id: string, + axis: "width" | "height", + ) => { + const box = settled.find((b) => b.id === id); + if (!box) return; + const keys = + axis === "width" ? ["ArrowLeft", "ArrowRight"] : ["ArrowUp", "ArrowDown"]; + if (![...keys, "Home", "End"].includes(event.key)) return; + event.preventDefault(); + event.stopPropagation(); + const sign = event.key === keys[1] ? 1 : -1; + if (axis === "width") { + const max = TILE_COLUMNS - box.x; + const w = + event.key === "Home" + ? minColumns + : event.key === "End" + ? max + : clamp(box.w + sign, minColumns, max); + if (w !== box.w) commit(settledBase(), id, { w }); + } else { + const current = Math.round(box.h); + const h = + event.key === "Home" + ? minHeight + : event.key === "End" + ? MAX_TILE_HEIGHT + : clamp( + current + sign * (event.shiftKey ? 50 : 10), + minHeight, + MAX_TILE_HEIGHT, + ); + if (h !== current) commit(settledBase(), id, { h }); + } + }; + + const remove = (id: string) => + commit( + settledBase().filter((slot) => slot.id !== id), + null, + {}, + ); + + if (stacked) { + return ( + +
+ {[...settled] + .sort((a, b) => a.y - b.y || a.x - b.x) + .map((box) => ( +
+ {content.get(box.id)} +
+ ))} +
+
+ ); + } + + const activeBox = active ? boxById.get(active.id) : undefined; + // Edge to edge tiles sit on whole px so the lines between them are crisp. + const snap = (value: number) => (seamless ? Math.round(value) : value); + const leftOf = (box: TileBox) => snap(box.x * pitch); + const widthOf = (box: TileBox) => + seamless + ? snap((box.x + box.w) * pitch) - snap(box.x * pitch) + : box.w * pitch - inset; + const bottom = tileBottom(preview, settleGap) * scale; + // A kept bottom line sits one px under the last row. + const height = Math.max( + 0, + seamless ? snap(bottom) + (overflows ? 0 : 1) : bottom - inset, + ); + const lines = seamless + ? gridLines( + preview.map((box) => ({ + l: leftOf(box), + t: snap(box.y * scale), + r: leftOf(box) + widthOf(box), + b: snap((box.y + box.h) * scale), + })), + snap(width), + overflows, + ) + : []; + + return ( + +
+
+ + {lines.map((line) => ( + + + ); +} + +function Tile({ + id, + box, + label, + editMode, + active, + left, + top, + width, + height, + measure, + scale, + seamless, + minColumns, + minHeight, + onHeight, + onPointerDown, + onMoveKey, + onResizeKey, + onSettings, + onRemove, + children, +}: { + id: string; + box: TileBox; + label: string; + editMode: boolean; + active?: Interaction["kind"]; + left: number; + top: number; + width: number; + height?: number; + /** The tile has no height of its own and reports its content's. */ + measure: boolean; + scale: number; + seamless: boolean; + minColumns: number; + minHeight: number; + onHeight: (id: string, height: number) => void; + onPointerDown: (event: ReactPointerEvent, edge?: Edge) => void; + onMoveKey: (event: ReactKeyboardEvent) => void; + onResizeKey: (event: ReactKeyboardEvent, axis: "width" | "height") => void; + onSettings?: () => void; + onRemove: () => void; + children: ReactNode; +}) { + const { t } = useTranslation(); + const card = useRef(null); + const body = useRef(null); + const fixed = !measure; + + // Tiles with no height of their own report their content's so the grid can + // place them. Edge to edge, that is rounded up to a whole px. + useLayoutEffect(() => { + const element = seamless ? body.current : card.current; + if (!element || fixed) return; + const report = () => { + const own = element.getBoundingClientRect().height; + onHeight(id, (seamless ? Math.ceil(own) : own) / scale); + }; + report(); + const observer = new ResizeObserver(report); + observer.observe(element); + return () => observer.disconnect(); + }, [id, fixed, scale, seamless, onHeight]); + + const button = + "flex size-6 items-center justify-center border border-border bg-card text-muted-foreground hover:text-foreground"; + + return ( +
onPointerDown(event) : undefined} + > +
+
+ {children} +
+ {editMode && ( + <> +
+
+ + {onSettings && ( + + )} + +
+ + ); +} diff --git a/src/ui/components/tile-grid/TilePicker.tsx b/src/ui/components/tile-grid/TilePicker.tsx new file mode 100644 index 0000000000..1a27b82da3 --- /dev/null +++ b/src/ui/components/tile-grid/TilePicker.tsx @@ -0,0 +1,221 @@ +import { useMemo, useState, type ReactNode } from "react"; +import { useTranslation } from "react-i18next"; +import { + Check, + ChevronDown, + ChevronRight, + Plus, + Search, + X, +} from "lucide-react"; +import { cn } from "@/lib/utils"; + +export type TileKind = + "card" | "signal" | "action" | "session" | "resource" | "chart"; + +export interface TilePickerEntry { + /** Tile id the grid gets when this entry is added. */ + id: string; + title: string; + description?: string; + /** Group heading, usually the plugin name. */ + group: string; + kind: TileKind; + icon?: ReactNode; + /** Already on the grid; shown checked and not added twice. */ + placed?: boolean; +} + +const KINDS: TileKind[] = [ + "card", + "chart", + "signal", + "action", + "resource", + "session", +]; +const KIND_LABELS: Record = { + all: "tileGrid.kind.all", + card: "tileGrid.kind.card", + chart: "tileGrid.kind.chart", + signal: "tileGrid.kind.signal", + action: "tileGrid.kind.action", + resource: "tileGrid.kind.resource", + session: "tileGrid.kind.session", +}; + +/** + * Everything that can go on a grid, in a narrow column beside it while + * editing. Search, filter by kind and fold groups, so long lists from many + * plugins stay usable. + */ +export function TilePicker({ + entries, + onAdd, + onClose, + className, +}: { + entries: TilePickerEntry[]; + onAdd: (entry: TilePickerEntry) => void; + onClose?: () => void; + className?: string; +}) { + const { t } = useTranslation(); + const [query, setQuery] = useState(""); + const [kind, setKind] = useState(null); + const [folded, setFolded] = useState>(() => new Set()); + + const kinds = useMemo( + () => KINDS.filter((k) => entries.some((entry) => entry.kind === k)), + [entries], + ); + const groups = useMemo(() => { + const needle = query.trim().toLowerCase(); + const shown = entries.filter( + (entry) => + (!kind || entry.kind === kind) && + (!needle || + entry.title.toLowerCase().includes(needle) || + entry.group.toLowerCase().includes(needle) || + entry.description?.toLowerCase().includes(needle)), + ); + const map = new Map(); + for (const entry of shown) { + const list = map.get(entry.group) ?? []; + list.push(entry); + map.set(entry.group, list); + } + return [...map.entries()].sort(([a], [b]) => a.localeCompare(b)); + }, [entries, query, kind]); + const searching = query.trim().length > 0; + + const toggle = (group: string) => + setFolded((current) => { + const next = new Set(current); + if (next.has(group)) next.delete(group); + else next.add(group); + return next; + }); + + return ( +
+
+ + + {t("tileGrid.addTiles")} + + {onClose && ( + + )} +
+
+ + {kinds.length > 1 && ( +
+ {[null, ...kinds].map((k) => ( + + ))} +
+ )} +
+
+ {groups.length === 0 && ( +

+ {t("tileGrid.noMatches")} +

+ )} + {groups.map(([group, list]) => { + const open = searching || !folded.has(group); + return ( +
+ + {open && ( +
    + {list.map((entry) => ( +
  • + +
  • + ))} +
+ )} +
+ ); + })} +
+
+ ); +} diff --git a/src/ui/components/tile-grid/context.ts b/src/ui/components/tile-grid/context.ts new file mode 100644 index 0000000000..e1cfb57691 --- /dev/null +++ b/src/ui/components/tile-grid/context.ts @@ -0,0 +1,8 @@ +import { createContext, useContext } from "react"; + +/** True inside an edge to edge TileGrid, where the grid draws the lines. */ +export const SeamlessTileContext = createContext(false); + +export function useInSeamlessTile(): boolean { + return useContext(SeamlessTileContext); +} diff --git a/src/ui/components/tile-grid/index.ts b/src/ui/components/tile-grid/index.ts new file mode 100644 index 0000000000..4f1db2c21f --- /dev/null +++ b/src/ui/components/tile-grid/index.ts @@ -0,0 +1,4 @@ +export { TileGrid, type TileGridProps } from "./TileGrid"; +export { TileFrame } from "./TileFrame"; +export { TilePicker, type TileKind, type TilePickerEntry } from "./TilePicker"; +export * from "./layout"; diff --git a/src/ui/components/tile-grid/layout.ts b/src/ui/components/tile-grid/layout.ts new file mode 100644 index 0000000000..126a86b2a6 --- /dev/null +++ b/src/ui/components/tile-grid/layout.ts @@ -0,0 +1,234 @@ +/** + * A tile on a 12 column grid. x and w are columns, y is unscaled px from the + * top (the grid settles it), h is a fixed height in unscaled px or null for + * the tile's natural height. + */ +export interface TileSlot { + id: string; + x: number; + y: number; + w: number; + h?: number | null; +} + +/** A tile with a resolved height, as the grid draws it. */ +export interface TileBox { + id: string; + x: number; + y: number; + w: number; + h: number; +} + +export const TILE_COLUMNS = 12; +export const MIN_TILE_COLUMNS = 2; +export const MIN_TILE_HEIGHT = 48; +export const MAX_TILE_HEIGHT = 10000; +/** y given to a new tile so it settles under everything else. */ +export const BOTTOM = 1_000_000; + +export const clampTileColumns = (w: number, min = MIN_TILE_COLUMNS) => + Math.max(min, Math.min(TILE_COLUMNS, Math.round(w) || min)); + +const overlapsX = (a: TileBox, b: TileBox) => + a.x < b.x + b.w && b.x < a.x + a.w; + +/** + * Settles tiles top to bottom in (y, x) order. Each tile sits right under the + * lowest tile placed before it in the columns it covers. Tiles never move + * sideways and never jump above a tile that came before them. + */ +export function settleTiles(boxes: TileBox[], gap = 0): TileBox[] { + const order = [...boxes].sort( + (a, b) => a.y - b.y || a.x - b.x || (a.id < b.id ? -1 : 1), + ); + const placed: TileBox[] = []; + for (const box of order) { + const y = placed.reduce( + (top, p) => (overlapsX(p, box) ? Math.max(top, p.y + p.h + gap) : top), + 0, + ); + placed.push({ ...box, y }); + } + return placed; +} + +export function tileBoxes( + slots: TileSlot[], + heights: Record, + fallback = 160, +): TileBox[] { + return slots.map((slot) => ({ + id: slot.id, + x: slot.x, + y: slot.y, + w: slot.w, + h: slot.h ?? heights[slot.id] ?? fallback, + })); +} + +export function tileBottom(boxes: TileBox[], gap = 0) { + return boxes.reduce((max, box) => Math.max(max, box.y + box.h + gap), 0); +} + +/** Drops broken entries and duplicates and keeps every tile inside the grid. */ +export function sanitizeTiles( + value: unknown, + min = MIN_TILE_COLUMNS, +): TileSlot[] { + if (!Array.isArray(value)) return []; + const seen = new Set(); + const out: TileSlot[] = []; + for (const raw of value) { + if (!raw || typeof raw !== "object") continue; + const slot = raw as Record; + const id = typeof slot.id === "string" ? slot.id : ""; + if (!id || id.length > 300 || seen.has(id)) continue; + seen.add(id); + const w = clampTileColumns(Number(slot.w), min); + const x = Math.max( + 0, + Math.min(TILE_COLUMNS - w, Math.round(Number(slot.x)) || 0), + ); + const y = Math.max(0, Number(slot.y) || 0); + const h = + slot.h == null || !Number.isFinite(Number(slot.h)) + ? null + : Math.max( + MIN_TILE_HEIGHT, + Math.min(MAX_TILE_HEIGHT, Math.round(Number(slot.h))), + ); + out.push({ id, x, y, w, h }); + } + return out.slice(0, 200); +} + +/** Adds a tile at the bottom, at the left. */ +export function appendTile( + slots: TileSlot[], + tile: { id: string; w?: number; h?: number | null }, +): TileSlot[] { + if (slots.some((slot) => slot.id === tile.id)) return slots; + return [ + ...slots, + { + id: tile.id, + x: 0, + y: BOTTOM, + w: clampTileColumns(tile.w ?? 4), + h: tile.h ?? null, + }, + ]; +} + +export function removeTile(slots: TileSlot[], id: string): TileSlot[] { + return slots.filter((slot) => slot.id !== id); +} + +/** Writes settled positions back, in reading order. */ +export function tilesFromBoxes( + slots: TileSlot[], + boxes: TileBox[], +): TileSlot[] { + const byId = new Map(boxes.map((box) => [box.id, box])); + return slots + .map((slot) => { + const box = byId.get(slot.id); + return box ? { ...slot, x: box.x, y: Math.round(box.y), w: box.w } : slot; + }) + .sort((a, b) => a.y - b.y || a.x - b.x); +} + +/** + * Lays tiles out left to right in rows, for layouts that only know an order + * and a width (old dashboards, generated defaults). + */ +export function flowTiles( + tiles: { id: string; w: number; h?: number | null }[], + rowHeight = 160, +): TileSlot[] { + const out: TileSlot[] = []; + let x = 0; + let row = 0; + for (const tile of tiles) { + const w = clampTileColumns(tile.w); + if (x + w > TILE_COLUMNS) { + x = 0; + row++; + } + out.push({ id: tile.id, x, y: row * rowHeight, w, h: tile.h ?? null }); + x += w; + } + return out; +} + +/** A tile's box in whole screen px. */ +export interface PixelRect { + l: number; + t: number; + r: number; + b: number; +} + +/** A 1 px line the grid draws, as a box. */ +export interface GridLine { + x: number; + y: number; + w: number; + h: number; +} + +function mergeSpans(spans: [number, number][]): [number, number][] { + const sorted = [...spans].sort((a, b) => a[0] - b[0]); + const out: [number, number][] = []; + for (const [start, end] of sorted) { + const last = out[out.length - 1]; + if (last && start <= last[1]) last[1] = Math.max(last[1], end); + else out.push([start, end]); + } + return out; +} + +/** + * The lines between edge to edge tiles, each drawn once. A line sits on the + * first px column or row past a tile, so it covers the edge of whatever is + * next to it. The outer left, top and right edges are left out. With + * `openBottom` (the grid runs to the bottom of the screen) so is the line + * along the very bottom; a column that ends higher keeps its bottom line. + * Lines reach one px further so corners join. + */ +export function gridLines( + rects: PixelRect[], + width: number, + openBottom = false, +): GridLine[] { + const lowest = Math.max(0, ...rects.map((rect) => rect.b)); + const end = openBottom ? lowest : Infinity; + const vertical = new Map(); + const horizontal = new Map(); + const add = ( + map: Map, + at: number, + span: [number, number], + ) => { + if (span[1] <= span[0]) return; + map.set(at, [...(map.get(at) ?? []), span]); + }; + for (const rect of rects) { + if (rect.r <= rect.l || rect.b <= rect.t) continue; + const down: [number, number] = [rect.t, Math.min(end, rect.b + 1)]; + if (rect.l > 0) add(vertical, rect.l, down); + if (rect.r < width) add(vertical, rect.r, down); + const across: [number, number] = [rect.l, Math.min(width, rect.r + 1)]; + if (rect.t > 0) add(horizontal, rect.t, across); + if (rect.b < end) add(horizontal, rect.b, across); + } + const lines: GridLine[] = []; + for (const [x, spans] of vertical) + for (const [start, end] of mergeSpans(spans)) + lines.push({ x, y: start, w: 1, h: end - start }); + for (const [y, spans] of horizontal) + for (const [start, end] of mergeSpans(spans)) + lines.push({ x: start, y, w: end - start, h: 1 }); + return lines; +} diff --git a/src/ui/dashboard/dashboard-cards-registry.ts b/src/ui/dashboard/dashboard-cards-registry.ts index 09c560e809..effcea93a7 100644 --- a/src/ui/dashboard/dashboard-cards-registry.ts +++ b/src/ui/dashboard/dashboard-cards-registry.ts @@ -6,6 +6,24 @@ export interface DashboardCardRenderProps { isVisible: boolean; /** The shell, e.g. to open the full-page version of the card. */ shell: TabShellCallbacks; + /** This copy's settings, for cards with entries. Empty otherwise. */ + config?: Record; + setConfig?: (config: Record) => void; + /** True while the user is arranging the dashboard. */ + editMode?: boolean; +} + +/** One thing the add list offers from a card that can be placed many times. */ +export interface DashboardCardEntry { + /** Stable within the card, used to mark entries already placed. */ + key: string; + title: string; + description?: string; + /** Group heading in the add list. Defaults to the card title. */ + group?: string; + kind?: "card" | "chart" | "signal" | "action" | "resource" | "session"; + config: Record; + defaultSize?: { w: number; h?: number | null }; } /** @@ -23,6 +41,10 @@ export interface RegisteredDashboardCard { defaultPanel?: "main" | "side"; /** "framed" gets a titled header from the dashboard; "bare" draws its own. */ frame?: "framed" | "bare"; + /** Size on the 12 column dashboard grid when added. */ + defaultSize?: { w: number; h?: number | null }; + /** Lists what this card can show; each entry becomes its own tile. */ + entries?: (language: string) => Promise; component: ComponentType; } diff --git a/src/ui/locales/en.json b/src/ui/locales/en.json index 10c2eca63e..1d22c8e275 100644 --- a/src/ui/locales/en.json +++ b/src/ui/locales/en.json @@ -1694,5 +1694,27 @@ "admin": "Admin", "user": "User" } + }, + "tileGrid": { + "empty": "No tiles yet. Add some from the list.", + "move": "Drag to move", + "moveTile": "Move {{tile}}", + "settings": "{{tile}} settings", + "remove": "Remove {{tile}}", + "width": "Width", + "height": "Height", + "addTiles": "Add tiles", + "closePicker": "Close", + "search": "Search tiles", + "noMatches": "Nothing matches.", + "kind": { + "all": "All", + "card": "Cards", + "chart": "Charts", + "signal": "Status", + "action": "Actions", + "resource": "Lists", + "session": "Sessions" + } } } diff --git a/src/ui/plugin-host/app.ts b/src/ui/plugin-host/app.ts index f84c8fe704..8147a13012 100644 --- a/src/ui/plugin-host/app.ts +++ b/src/ui/plugin-host/app.ts @@ -534,6 +534,8 @@ export function createPluginApp( defaultHeight: card.defaultHeight, defaultPanel: card.defaultPanel, frame: card.frame, + defaultSize: card.defaultSize, + entries: card.entries, component: scoped( card.component as unknown as ComponentType, ), diff --git a/src/ui/plugin-host/sdk-ui.ts b/src/ui/plugin-host/sdk-ui.ts index 5f8d6f7ed9..8437073210 100644 --- a/src/ui/plugin-host/sdk-ui.ts +++ b/src/ui/plugin-host/sdk-ui.ts @@ -127,6 +127,20 @@ export { type GridLayout, type GridSlot, } from "@/components/card-grid/types"; +export { + TileFrame, + TileGrid, + TilePicker, + appendTile, + flowTiles, + removeTile, + sanitizeTiles, + TILE_COLUMNS, + type TileGridProps, + type TileKind, + type TilePickerEntry, + type TileSlot, +} from "@/components/tile-grid"; export { ConnectionScreen } from "@/components/connection/ConnectionScreen"; export { type ConnectionStatus } from "@/components/connection/connection-status"; export { diff --git a/src/ui/tests/components/tile-grid/TileGrid.test.tsx b/src/ui/tests/components/tile-grid/TileGrid.test.tsx new file mode 100644 index 0000000000..8335910971 --- /dev/null +++ b/src/ui/tests/components/tile-grid/TileGrid.test.tsx @@ -0,0 +1,299 @@ +import { + afterAll, + afterEach, + beforeAll, + beforeEach, + describe, + expect, + it, + vi, +} from "vitest"; +import { cleanup, fireEvent, render, screen } from "@testing-library/react"; +import { useState } from "react"; +import "@testing-library/jest-dom/vitest"; +import { TileGrid } from "../../../components/tile-grid/TileGrid"; +import { MetricCard } from "../../../components/metric-card"; +import type { TileSlot } from "../../../components/tile-grid/layout"; + +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ t: (key: string) => key }), + initReactI18next: { type: "3rdParty", init: () => {} }, +})); + +// 1212px wide grid: one column is (1212 + 12) / 12 = 102px. +let gridWidth = 1212; +const PITCH = 102; +const original = HTMLElement.prototype.getBoundingClientRect; + +beforeAll(() => { + document.documentElement.style.fontSize = "14px"; + vi.stubGlobal( + "PointerEvent", + class extends MouseEvent { + pointerId: number; + constructor(type: string, init: PointerEventInit = {}) { + super(type, init); + this.pointerId = init.pointerId ?? 1; + } + }, + ); + globalThis.ResizeObserver ??= class { + observe() {} + unobserve() {} + disconnect() {} + } as unknown as typeof ResizeObserver; + HTMLElement.prototype.getBoundingClientRect = function (this: HTMLElement) { + if (this.dataset.tgCard) return { height: 200, width: 0 } as DOMRect; + return { + left: 0, + top: 0, + width: gridWidth, + height: 0, + right: gridWidth, + bottom: 0, + } as DOMRect; + }; +}); +afterAll(() => { + HTMLElement.prototype.getBoundingClientRect = original; + document.documentElement.style.removeProperty("font-size"); + vi.unstubAllGlobals(); +}); +beforeEach(() => { + gridWidth = 1212; +}); +afterEach(cleanup); + +const three: TileSlot[] = [ + { id: "cpu", x: 0, y: 0, w: 4, h: null }, + { id: "memory", x: 4, y: 0, w: 4, h: null }, + { id: "disk", x: 8, y: 0, w: 4, h: null }, +]; + +function mount(layout = three, editMode = true) { + const changed = vi.fn(); + function Harness() { + const [value, setValue] = useState(layout); + return ( + id} + onChange={(next) => { + changed(next); + setValue(next); + }} + renderTile={(id) =>
{id}
} + /> + ); + } + return { ...render(), changed }; +} + +const tile = (id: string) => + document.querySelector(`[data-tg-tile="${id}"]`)!; +const position = (element: HTMLElement) => { + const match = /translate\((-?[\d.]+)px, (-?[\d.]+)px\)/.exec( + element.style.transform, + )!; + return { x: Number(match[1]), y: Number(match[2]) }; +}; +const pointer = ( + target: Element | Window, + type: "pointerDown" | "pointerMove" | "pointerUp", + x: number, + y: number, +) => + fireEvent[type](target, { clientX: x, clientY: y, button: 0, pointerId: 1 }); +const slot = (slots: TileSlot[], id: string) => slots.find((s) => s.id === id)!; + +describe("TileGrid", () => { + it("places tiles on the 12 column grid", () => { + mount(); + expect(position(tile("cpu"))).toEqual({ x: 0, y: 0 }); + expect(position(tile("memory"))).toEqual({ x: 4 * PITCH, y: 0 }); + expect(position(tile("disk"))).toEqual({ x: 8 * PITCH, y: 0 }); + expect(tile("cpu").style.width).toBe(`${4 * PITCH - 12}px`); + }); + + it("drops a dragged card exactly where the preview showed it", () => { + const { changed } = mount(); + pointer(screen.getByTestId("body-cpu"), "pointerDown", 10, 10); + pointer(window, "pointerMove", 10 + 4 * PITCH, 300); + const ghost = document.querySelector( + "[data-tg-drop-preview]", + )!; + const previewed = position(ghost); + expect(previewed).toEqual({ x: 4 * PITCH, y: 212 }); + expect(position(tile("memory"))).toEqual({ x: 4 * PITCH, y: 0 }); + expect(position(tile("disk"))).toEqual({ x: 8 * PITCH, y: 0 }); + pointer(window, "pointerUp", 10 + 4 * PITCH, 300); + const next = changed.mock.calls[0][0] as TileSlot[]; + expect(slot(next, "cpu")).toMatchObject({ x: 4, y: 212 }); + expect(position(tile("cpu"))).toEqual(previewed); + expect(slot(next, "memory")).toMatchObject({ x: 4, y: 0 }); + expect(slot(next, "disk")).toMatchObject({ x: 8, y: 0 }); + }); + + it("moves a card into another column without touching the rest", () => { + const layout: TileSlot[] = [ + { id: "cpu", x: 0, y: 0, w: 4, h: null }, + { id: "memory", x: 4, y: 0, w: 4, h: null }, + ]; + const { changed } = mount(layout); + pointer(screen.getByTestId("body-cpu"), "pointerDown", 10, 10); + pointer(window, "pointerMove", 10 + 8 * PITCH, 10); + pointer(window, "pointerUp", 10 + 8 * PITCH, 10); + const next = changed.mock.calls[0][0] as TileSlot[]; + expect(slot(next, "cpu")).toMatchObject({ x: 8, y: 0 }); + expect(slot(next, "memory")).toMatchObject({ x: 4, y: 0 }); + }); + + it("does nothing on a click without movement", () => { + const { changed } = mount(); + pointer(screen.getByTestId("body-cpu"), "pointerDown", 10, 10); + pointer(window, "pointerMove", 12, 11); + pointer(window, "pointerUp", 12, 11); + expect(changed).not.toHaveBeenCalled(); + }); + + it("only pushes the overlapping card when a card gets wider", () => { + const { changed } = mount(); + const edge = tile("cpu").querySelector(".tg-edge-right")!; + pointer(edge, "pointerDown", 400, 100); + pointer(window, "pointerMove", 400 + 2 * PITCH, 100); + expect(tile("cpu").dataset.active).toBe("resize"); + pointer(window, "pointerUp", 400 + 2 * PITCH, 100); + const next = changed.mock.calls[0][0] as TileSlot[]; + expect(slot(next, "cpu")).toMatchObject({ x: 0, y: 0, w: 6 }); + expect(slot(next, "memory")).toMatchObject({ x: 4, y: 212 }); + expect(slot(next, "disk")).toMatchObject({ x: 8, y: 0, w: 4 }); + }); + + it("resizes from the left edge keeping the right side in place", () => { + const { changed } = mount(); + const edge = tile("disk").querySelector(".tg-edge-left")!; + pointer(edge, "pointerDown", 8 * PITCH, 100); + pointer(window, "pointerMove", 8 * PITCH + 2 * PITCH, 100); + pointer(window, "pointerUp", 8 * PITCH + 2 * PITCH, 100); + const next = changed.mock.calls[0][0] as TileSlot[]; + expect(slot(next, "disk")).toMatchObject({ x: 10, w: 2 }); + }); + + it("pins the height and snaps to a neighbour's bottom", () => { + const layout: TileSlot[] = [ + { id: "cpu", x: 0, y: 0, w: 4, h: null }, + { id: "memory", x: 4, y: 0, w: 4, h: 300 }, + ]; + const { changed } = mount(layout); + const edge = tile("memory").querySelector(".tg-edge-bottom")!; + pointer(edge, "pointerDown", 500, 300); + pointer(window, "pointerMove", 500, 196); + pointer(window, "pointerUp", 500, 196); + const next = changed.mock.calls[0][0] as TileSlot[]; + expect(slot(next, "memory").h).toBe(200); + }); + + it("cancels on Escape without saving", () => { + const { changed } = mount(); + pointer(screen.getByTestId("body-cpu"), "pointerDown", 10, 10); + pointer(window, "pointerMove", 10, 300); + fireEvent.keyDown(window, { key: "Escape" }); + pointer(window, "pointerUp", 10, 300); + expect(changed).not.toHaveBeenCalled(); + expect(document.querySelector("[data-tg-drop-preview]")).toBeNull(); + expect(position(tile("cpu"))).toEqual({ x: 0, y: 0 }); + }); + + it("resizes and moves with the keyboard", () => { + const { changed } = mount(); + const width = tile("disk").querySelector('[aria-orientation="vertical"]')!; + fireEvent.keyDown(width, { key: "ArrowLeft" }); + expect(slot(changed.mock.calls[0][0], "disk").w).toBe(3); + const grip = tile("cpu").querySelector("[data-tg-grip]")!; + fireEvent.keyDown(grip, { key: "ArrowRight" }); + expect(slot(changed.mock.calls[1][0], "cpu").x).toBe(1); + expect(slot(changed.mock.calls[1][0], "memory")).toMatchObject({ y: 212 }); + }); + + it("removes tiles", () => { + const { changed } = mount(); + fireEvent.click(screen.getAllByLabelText("tileGrid.remove")[0]); + expect(changed.mock.calls[0][0]).toHaveLength(2); + }); + + it("draws each line between edge to edge tiles once", () => { + render( + id} + onChange={() => {}} + renderTile={(id) =>
{id}
} + />, + ); + // Whole px tiles that touch, with no lines of their own. + expect(position(tile("memory")).x).toBe(404); + expect(parseFloat(tile("cpu").style.width)).toBe(404); + expect(parseFloat(tile("cpu").style.height)).toBe(100); + expect(tile("cpu").querySelector(".tg-card")!.className).not.toMatch( + /(^| )border/, + ); + const vertical = [ + ...document.querySelectorAll("[data-tg-line].border-l"), + ].map((el) => [ + parseFloat(el.style.left), + parseFloat(el.style.top), + parseFloat(el.style.height), + ]); + // One line down the middle, as tall as the taller side, and one on the + // open right edge of the middle tile. Drawn as borders so they are as + // thin as other lines at any display scaling. + expect(vertical).toEqual([ + [404, 0, 301], + [808, 0, 301], + ]); + }); + + it("gives metric cards the tile look only in an edge to edge grid", () => { + const card = (gap?: number) => + render( + id} + onChange={() => {}} + renderTile={() => ( + + x + + )} + />, + ).container.querySelector(".tg-content > div")!.className; + expect(card()).not.toContain("bg-card"); + cleanup(); + expect(card(12)).toContain("border border-border bg-card"); + }); + + it("stacks cards with no edit controls on narrow screens", () => { + gridWidth = 500; + mount(); + expect(document.querySelector("[data-tg-tile]")).toBeNull(); + expect(screen.getByTestId("body-disk")).toBeInTheDocument(); + expect(screen.queryAllByRole("separator")).toHaveLength(0); + }); + + it("shows no edit controls outside edit mode", () => { + mount(three, false); + expect(screen.queryAllByRole("separator")).toHaveLength(0); + pointer(screen.getByTestId("body-cpu"), "pointerDown", 10, 10); + expect(document.querySelector("[data-active]")).toBeNull(); + }); +}); diff --git a/src/ui/tests/components/tile-grid/layout.test.ts b/src/ui/tests/components/tile-grid/layout.test.ts new file mode 100644 index 0000000000..cc958b763f --- /dev/null +++ b/src/ui/tests/components/tile-grid/layout.test.ts @@ -0,0 +1,157 @@ +import { describe, expect, it } from "vitest"; +import { + BOTTOM, + appendTile, + flowTiles, + gridLines, + removeTile, + sanitizeTiles, + settleTiles, + tileBottom, + tileBoxes, + tilesFromBoxes, +} from "../../../components/tile-grid/layout"; + +describe("tile grid layout", () => { + it("settles tiles under the ones above them in the same columns", () => { + const boxes = settleTiles([ + { id: "a", x: 0, y: 0, w: 6, h: 100 }, + { id: "b", x: 6, y: 0, w: 6, h: 50 }, + { id: "c", x: 6, y: 500, w: 6, h: 50 }, + { id: "d", x: 0, y: 900, w: 12, h: 20 }, + ]); + const y = Object.fromEntries(boxes.map((box) => [box.id, box.y])); + expect(y).toEqual({ a: 0, b: 0, c: 50, d: 100 }); + expect(tileBottom(boxes)).toBe(120); + }); + + it("overlaps by the gap when it is negative, for shared borders", () => { + const boxes = settleTiles( + [ + { id: "a", x: 0, y: 0, w: 12, h: 100 }, + { id: "b", x: 0, y: 1, w: 12, h: 100 }, + ], + -1, + ); + expect(boxes[1].y).toBe(99); + }); + + it("uses measured heights for tiles without a fixed one", () => { + const boxes = tileBoxes( + [ + { id: "a", x: 0, y: 0, w: 4, h: null }, + { id: "b", x: 0, y: 0, w: 4, h: 30 }, + { id: "c", x: 0, y: 0, w: 4 }, + ], + { a: 77 }, + 10, + ); + expect(boxes.map((box) => box.h)).toEqual([77, 30, 10]); + }); + + it("appends at the bottom once and removes by id", () => { + const start = [{ id: "a", x: 0, y: 0, w: 4, h: null }]; + const added = appendTile(start, { id: "b", w: 30 }); + expect(added[1]).toEqual({ id: "b", x: 0, y: BOTTOM, w: 12, h: null }); + expect(appendTile(added, { id: "b" })).toBe(added); + expect(removeTile(added, "a").map((slot) => slot.id)).toEqual(["b"]); + }); + + it("writes settled positions back in reading order", () => { + const slots = [ + { id: "b", x: 6, y: 300, w: 6, h: 10 }, + { id: "a", x: 0, y: 0, w: 6, h: 10 }, + ]; + const out = tilesFromBoxes(slots, settleTiles(tileBoxes(slots, {}))); + expect(out.map((slot) => [slot.id, slot.y])).toEqual([ + ["a", 0], + ["b", 0], + ]); + }); + + it("cleans saved layouts", () => { + expect(sanitizeTiles("nope")).toEqual([]); + expect( + sanitizeTiles([ + { id: "a", x: 11, y: -5, w: 6, h: 3 }, + { id: "a", x: 0, y: 0, w: 4 }, + { x: 0 }, + null, + { id: "b", x: "x", y: 10, w: 1, h: "big" }, + ]), + ).toEqual([ + { id: "a", x: 6, y: 0, w: 6, h: 48 }, + { id: "b", x: 0, y: 10, w: 2, h: null }, + ]); + }); + + it("flows tiles into rows by width", () => { + const out = flowTiles([ + { id: "a", w: 8 }, + { id: "b", w: 4 }, + { id: "c", w: 6 }, + ]); + expect(out.map((slot) => [slot.id, slot.x, slot.y])).toEqual([ + ["a", 0, 0], + ["b", 8, 0], + ["c", 0, 160], + ]); + }); +}); + +describe("gridLines", () => { + it("draws a shared edge once, as long as the longer side", () => { + const lines = gridLines( + [ + { l: 0, t: 0, r: 100, b: 50 }, + { l: 100, t: 0, r: 200, b: 120 }, + ], + 200, + ); + expect(lines.filter((line) => line.w === 1)).toEqual([ + { x: 100, y: 0, w: 1, h: 121 }, + ]); + expect(lines.filter((line) => line.h === 1)).toEqual([ + { x: 0, y: 50, w: 101, h: 1 }, + { x: 100, y: 120, w: 100, h: 1 }, + ]); + }); + + it("leaves out the outer left, top and right edges and keeps bottoms", () => { + expect(gridLines([{ l: 0, t: 0, r: 200, b: 80 }], 200)).toEqual([ + { x: 0, y: 80, w: 200, h: 1 }, + ]); + }); + + it("drops the very bottom line when the grid runs to the bottom of the screen", () => { + const lines = gridLines( + [ + { l: 0, t: 0, r: 100, b: 50 }, + { l: 100, t: 0, r: 200, b: 120 }, + ], + 200, + true, + ); + // The shorter column still gets its bottom line. + expect(lines.filter((line) => line.h === 1)).toEqual([ + { x: 0, y: 50, w: 101, h: 1 }, + ]); + expect(lines.filter((line) => line.w === 1)).toEqual([ + { x: 100, y: 0, w: 1, h: 120 }, + ]); + }); + + it("joins rows into one line across tiles", () => { + const lines = gridLines( + [ + { l: 0, t: 0, r: 100, b: 50 }, + { l: 100, t: 0, r: 200, b: 50 }, + { l: 0, t: 50, r: 200, b: 90 }, + ], + 200, + ); + expect(lines.filter((line) => line.h === 1 && line.y === 50)).toEqual([ + { x: 0, y: 50, w: 200, h: 1 }, + ]); + }); +}); From c7aa1c64b56ee24cadfce91ae620e24bfb1e1291 Mon Sep 17 00:00:00 2001 From: LukeGus Date: Wed, 4 Nov 2026 15:30:14 -0600 Subject: [PATCH 020/106] feat: new bundled plugins start on only if recommended, fetch missing shipped ones --- scripts/build-plugins.cjs | 2 +- scripts/bundled-plugins.test.ts | 29 +++++++- scripts/lib/bundled-plugins.cjs | 16 ++++- src/backend/plugins/boot-state.ts | 17 +++++ src/backend/plugins/bundled-index.ts | 60 +++++++++++++--- src/backend/plugins/index.ts | 4 +- src/backend/plugins/manage.ts | 69 ++++++++++++++++++- src/backend/tests/plugins/boot-state.test.ts | 18 +++++ .../tests/plugins/bundled-index.test.ts | 49 +++++++++++-- 9 files changed, 240 insertions(+), 24 deletions(-) diff --git a/scripts/build-plugins.cjs b/scripts/build-plugins.cjs index c6109dfac2..109ae34c11 100644 --- a/scripts/build-plugins.cjs +++ b/scripts/build-plugins.cjs @@ -74,7 +74,7 @@ async function main() { const staged = [...pinned, ...extra]; fs.writeFileSync( path.join(destination, "bundled-index.json"), - `${JSON.stringify(buildBundledIndex(plugins, staged), null, 2)} + `${JSON.stringify(buildBundledIndex(plugins, staged, profile), null, 2)} `, ); diff --git a/scripts/bundled-plugins.test.ts b/scripts/bundled-plugins.test.ts index 541d95b5ef..3a9aa02a16 100644 --- a/scripts/bundled-plugins.test.ts +++ b/scripts/bundled-plugins.test.ts @@ -221,7 +221,34 @@ describe("buildBundledIndex", () => { ]; expect(buildBundledIndex(plugins, ["a", "c", "local"])).toEqual({ version: 1, + profile: "standard", plugins: { a: { onboarding: { recommended: true, consent: false } } }, + catalog: { + a: { tier: "full" }, + b: { tier: "full" }, + c: { tier: "full" }, + }, + }); + }); + + it("lists every pin with its tier and version", () => { + const plugins = [ + { + id: "a", + tier: "standard", + url: "https://example.com/v1.2.0/a-1.2.0.tmxplug", + onboarding: null, + }, + { + id: "b", + tier: "minimal", + path: "x/b-2.0.0-beta.1.tmxplug", + onboarding: null, + }, + ]; + expect(buildBundledIndex(plugins, [], "minimal").catalog).toEqual({ + a: { tier: "standard", version: "1.2.0" }, + b: { tier: "minimal", version: "2.0.0-beta.1" }, }); }); }); @@ -520,7 +547,7 @@ describe("default look", () => { ], ["look", "plain"], ), - ).toEqual({ version: 1, plugins: { look: { defaultLook: true } } }); + ).toMatchObject({ version: 1, plugins: { look: { defaultLook: true } } }); const index = { plugins: [ diff --git a/scripts/lib/bundled-plugins.cjs b/scripts/lib/bundled-plugins.cjs index 00262c7363..16eaf71632 100644 --- a/scripts/lib/bundled-plugins.cjs +++ b/scripts/lib/bundled-plugins.cjs @@ -236,12 +236,22 @@ async function extractArtifact(buffer, outDir, id) { /** * What dist/plugins/bundled-index.json holds: the onboarding defaults of each - * staged plugin, read by the backend when it seeds a fresh install. + * staged plugin, read by the backend when it seeds a fresh install, plus the + * profile and every pin it ships, so an existing install can fetch a shipped + * plugin it finds missing. */ -function buildBundledIndex(plugins, stagedIds) { +function buildBundledIndex(plugins, stagedIds, profile = DEFAULT_PROFILE) { const staged = new Set(stagedIds); const out = {}; + const catalog = {}; for (const plugin of plugins) { + const version = /-(\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?)\.tmxplug$/.exec( + plugin.url ?? plugin.path ?? "", + )?.[1]; + catalog[plugin.id] = { + tier: plugin.tier ?? "full", + ...(version ? { version } : {}), + }; if (!staged.has(plugin.id)) continue; if (!plugin.onboarding && !plugin.defaultLook) continue; out[plugin.id] = { @@ -249,7 +259,7 @@ function buildBundledIndex(plugins, stagedIds) { ...(plugin.defaultLook ? { defaultLook: true } : {}), }; } - return { version: 1, plugins: out }; + return { version: 1, profile, plugins: out, catalog }; } /** The profile to build: --profile, then TERMIX_PLUGIN_PROFILE, then standard. */ diff --git a/src/backend/plugins/boot-state.ts b/src/backend/plugins/boot-state.ts index 5b1030d51e..a8227c5a16 100644 --- a/src/backend/plugins/boot-state.ts +++ b/src/backend/plugins/boot-state.ts @@ -35,3 +35,20 @@ export function missingShippedPluginIds( .map((record) => record.id) .sort(); } + +/** + * Plugins this image's profile ships that the install has never seen and + * that are not on disk. They get fetched from the registry once, the first + * boot that can reach it. + */ +export function newShippedPluginIds( + shipped: Iterable, + records: { id: string }[], + presentIds: ReadonlySet, + ignored: ReadonlySet, +): string[] { + const known = new Set(records.map((record) => record.id)); + return [...shipped] + .filter((id) => !known.has(id) && !presentIds.has(id) && !ignored.has(id)) + .sort(); +} diff --git a/src/backend/plugins/bundled-index.ts b/src/backend/plugins/bundled-index.ts index 46333cf8c8..055462cb95 100644 --- a/src/backend/plugins/bundled-index.ts +++ b/src/backend/plugins/bundled-index.ts @@ -18,7 +18,14 @@ export interface OnboardingDefaults { type Recommended = boolean | "desktop"; +export interface BundledCatalogEntry { + tier: string; + version?: string; +} + interface BundledIndex { + profile?: string; + catalog?: Record; plugins: Record< string, { @@ -45,7 +52,14 @@ export function readBundledIndex(): BundledIndex | null { ); cached = raw && typeof raw.plugins === "object" && raw.plugins - ? { plugins: raw.plugins } + ? { + plugins: raw.plugins, + profile: typeof raw.profile === "string" ? raw.profile : undefined, + catalog: + raw.catalog && typeof raw.catalog === "object" + ? raw.catalog + : undefined, + } : null; } catch { cached = null; @@ -101,21 +115,45 @@ export function onboardingDefaults(pluginId: string): OnboardingDefaults { }; } +const PROFILES = ["minimal", "standard", "full"]; + /** - * The state a newly seen plugin's row starts in. Bundled plugins start - * enabled, except on a fresh install, where only recommended ones do and - * consent plugins wait for the admin. A plugin dropped into the data - * directory always starts disabled. + * Plugins the image's profile ships (its tier and every smaller one), with + * the version it pins. Read from the bundled index, so core never names them. + */ +export function shippedCatalog(): Map { + const index = readBundledIndex(); + const out = new Map(); + if (!index?.catalog) return out; + const rank = PROFILES.indexOf(index.profile ?? "standard"); + for (const [id, entry] of Object.entries(index.catalog)) { + if (!entry || typeof entry !== "object") continue; + const tier = PROFILES.indexOf(entry.tier); + if (tier === -1 || rank === -1 || tier > rank) continue; + out.set(id, entry); + } + return out; +} + +/** Whether a shipped plugin should be on when an install first sees it. */ +export function startsEnabled(pluginId: string): boolean { + // The default look is what the app is drawn with. + if (isDefaultLookPlugin(pluginId)) return true; + const { recommended, consent } = onboardingDefaults(pluginId); + return recommended && !consent; +} + +/** + * The state a newly seen plugin's row starts in. A bundled plugin starts on + * only when it is recommended, so a plugin a later release adds to the image + * does not switch itself on everywhere. Consent plugins wait for the admin. + * A plugin dropped into the data directory always starts disabled. */ export function initialPluginState( pluginId: string, source: "bundled" | "user", - freshInstall: boolean, + _freshInstall: boolean, ): "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"; + return startsEnabled(pluginId) ? "enabled" : "disabled"; } diff --git a/src/backend/plugins/index.ts b/src/backend/plugins/index.ts index 4d8c170e5b..1717c35b17 100644 --- a/src/backend/plugins/index.ts +++ b/src/backend/plugins/index.ts @@ -243,8 +243,10 @@ export async function initializePlugins( const fresh = await seedPlugins(loaded, options.freshInstall === true); if (!fresh) { - const { restoreMissingPlugins } = await import("./manage.js"); + const { installNewShippedPlugins, restoreMissingPlugins } = + await import("./manage.js"); loaded.push(...(await restoreMissingPlugins())); + loaded.push(...(await installNewShippedPlugins())); } await syncCapabilityGrants(loaded); if (loaded.some((plugin) => plugin.source === "bundled")) { diff --git a/src/backend/plugins/manage.ts b/src/backend/plugins/manage.ts index 674bd74a65..4c8d03ee81 100644 --- a/src/backend/plugins/manage.ts +++ b/src/backend/plugins/manage.ts @@ -17,7 +17,11 @@ 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 { + isDefaultLookPlugin, + shippedCatalog, + startsEnabled, +} from "./bundled-index.js"; import { mayRunInSafeMode } from "./safe-mode.js"; import { activatePlugin, @@ -26,7 +30,7 @@ import { registerLoadedPlugins, unloadPlugin, } from "./index.js"; -import { missingShippedPluginIds } from "./boot-state.js"; +import { missingShippedPluginIds, newShippedPluginIds } from "./boot-state.js"; import type { LoadedPlugin, PluginLoader } from "./loader.js"; import { getBundledPluginsDir, @@ -1099,6 +1103,67 @@ export function restoreMissingPlugins(): Promise { }); } +/** + * Called at boot on an existing install. A plugin this image's profile ships + * that the install never had and that is not on disk is fetched from the + * official registry and starts on only if it is recommended. Offline, nothing + * is recorded, so the next start tries again. + */ +export function installNewShippedPlugins(): Promise { + return serialized(async () => { + if (await isLinkedDesktop()) return []; + const catalog = shippedCatalog(); + if (catalog.size === 0) return []; + const { createCurrentPluginRepository } = await repos(); + const repository = createCurrentPluginRepository(); + const { loader } = getPluginRuntime(); + const ids = newShippedPluginIds( + catalog.keys(), + await repository.listAll(), + new Set(loader.list().map((plugin) => plugin.id)), + await readUninstalled(), + ); + const added: LoadedPlugin[] = []; + for (const id of ids) { + try { + const { source, release } = await requireRelease( + id, + catalog.get(id)?.version, + "stable", + OFFICIAL_REGISTRY_ID, + ); + const plugin = await swapIn(id, { release, source }); + const enabled = startsEnabled(id); + await repository.update(id, { + registryId: source.id, + tier: "bundled", + state: enabled ? "enabled" : "disabled", + lastError: null, + }); + await grantDeclared(plugin, null); + added.push(plugin); + pluginLogger.info( + `Added ${id}@${release.version} from the registry (${enabled ? "on" : "off"})`, + { operation: "plugin_install_shipped" }, + ); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + pluginLogger.warn(`Could not add ${id}: ${message}`, { + operation: "plugin_install_shipped", + }); + // A half made row would hide the plugin from the next try. + if (!loader.get(id)) await repository.delete(id).catch(() => false); + if ( + error instanceof PluginManageError && + error.code === "REGISTRY_UNREACHABLE" + ) + break; + } + } + return added; + }); +} + export function updatePlugin( pluginId: string, options: InstallOptions, diff --git a/src/backend/tests/plugins/boot-state.test.ts b/src/backend/tests/plugins/boot-state.test.ts index 43343a157d..d7bb933230 100644 --- a/src/backend/tests/plugins/boot-state.test.ts +++ b/src/backend/tests/plugins/boot-state.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "vitest"; import { missingShippedPluginIds, + newShippedPluginIds, startablePluginIds, } from "../../plugins/boot-state.js"; @@ -38,3 +39,20 @@ describe("missingShippedPluginIds", () => { expect(missingShippedPluginIds(records, present, new Set())).toEqual([]); }); }); + +describe("newShippedPluginIds", () => { + it("lists shipped plugins the install never had and that are not on disk", () => { + expect( + newShippedPluginIds( + ["new", "known", "here", "removed", "another"], + [{ id: "known" }], + new Set(["here"]), + new Set(["removed"]), + ), + ).toEqual(["another", "new"]); + }); + + it("finds nothing for an empty catalog", () => { + expect(newShippedPluginIds([], [], new Set(), new Set())).toEqual([]); + }); +}); diff --git a/src/backend/tests/plugins/bundled-index.test.ts b/src/backend/tests/plugins/bundled-index.test.ts index bbf30d5f46..79861d002b 100644 --- a/src/backend/tests/plugins/bundled-index.test.ts +++ b/src/backend/tests/plugins/bundled-index.test.ts @@ -8,14 +8,18 @@ import { isDefaultLookPlugin, onboardingDefaults, resetBundledIndexCache, + shippedCatalog, } from "../../plugins/bundled-index.js"; let dir: string; -function writeIndex(plugins: Record) { +function writeIndex( + plugins: Record, + extra: Record = {}, +) { fs.writeFileSync( path.join(dir, "bundled-index.json"), - JSON.stringify({ version: 1, plugins }), + JSON.stringify({ version: 1, plugins, ...extra }), ); resetBundledIndexCache(); } @@ -89,9 +93,10 @@ describe("initialPluginState", () => { }); }); - it("enables every bundled plugin on an existing install", () => { - expect(initialPluginState("other", "bundled", false)).toBe("enabled"); - expect(initialPluginState("ask", "bundled", false)).toBe("enabled"); + it("enables only recommended plugins an existing install sees for the first time", () => { + expect(initialPluginState("kept", "bundled", false)).toBe("enabled"); + expect(initialPluginState("other", "bundled", false)).toBe("disabled"); + expect(initialPluginState("ask", "bundled", false)).toBe("disabled"); }); it("enables only recommended, non-consent plugins on a fresh install", () => { @@ -106,6 +111,40 @@ describe("initialPluginState", () => { }); }); +describe("shippedCatalog", () => { + it("is empty for an index without a catalog", () => { + writeIndex({}); + expect(shippedCatalog().size).toBe(0); + }); + + it("keeps the pins of the image's tier and smaller ones", () => { + writeIndex( + {}, + { + profile: "standard", + catalog: { + look: { tier: "minimal", version: "1.0.0" }, + ops: { tier: "standard", version: "1.0.0" }, + big: { tier: "full", version: "2.0.0" }, + odd: { tier: "huge" }, + }, + }, + ); + expect([...shippedCatalog().keys()].sort()).toEqual(["look", "ops"]); + }); + + it("follows a minimal image", () => { + writeIndex( + {}, + { + profile: "minimal", + catalog: { look: { tier: "minimal" }, ops: { tier: "standard" } }, + }, + ); + expect([...shippedCatalog().keys()]).toEqual(["look"]); + }); +}); + describe("default look", () => { it("lists the plugins flagged as the default look", () => { writeIndex({ From ea4e2d212485f0ad8341742aab7ceb39b5395438 Mon Sep 17 00:00:00 2001 From: LukeGus Date: Wed, 4 Nov 2026 15:30:17 -0600 Subject: [PATCH 021/106] fix: remove the partial file when a desktop download is aborted early --- src/backend/plugins/desktop-local/local-fs.ts | 8 +++++++- .../tests/plugins/ctx-desktop-local.test.ts | 15 +++++++++++++++ 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/src/backend/plugins/desktop-local/local-fs.ts b/src/backend/plugins/desktop-local/local-fs.ts index 6b622fe7f4..440eff224b 100644 --- a/src/backend/plugins/desktop-local/local-fs.ts +++ b/src/backend/plugins/desktop-local/local-fs.ts @@ -560,7 +560,13 @@ export async function openDownload( } }, abort: async () => { - stream.destroy(); + // The file opens in the background, so wait for the stream to close + // or a late open would leave the partial file behind. + await new Promise((resolve) => { + if (stream.closed) return resolve(); + stream.once("close", () => resolve()); + stream.destroy(); + }); await fsp.rm(partialPath, { force: true }).catch(() => {}); release(); }, diff --git a/src/backend/tests/plugins/ctx-desktop-local.test.ts b/src/backend/tests/plugins/ctx-desktop-local.test.ts index 6e959c5248..5b54b4f0c6 100644 --- a/src/backend/tests/plugins/ctx-desktop-local.test.ts +++ b/src/backend/tests/plugins/ctx-desktop-local.test.ts @@ -186,6 +186,21 @@ describe("ctx.desktop.local", () => { expect(fs.readdirSync(dir)).toEqual([]); }); + it("leaves no partial file when aborted before the file has opened", async () => { + grants.set("demo", ["desktop:local"]); + const ctx = contextFor("demo", ["desktop:local"]); + for (let i = 0; i < 20; i++) { + const download = await ctx.desktop.local.fs.openDownload({ + rootPath: dir, + destPath: path.join(dir, `early-${i}.txt`), + transferId: `early-${i}`, + }); + await download.abort(); + } + await new Promise((resolve) => setTimeout(resolve, 20)); + 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"]); From 09e04d076af9512ffa27ee23b6f8f9a037241b2a Mon Sep 17 00:00:00 2001 From: LukeGus Date: Wed, 4 Nov 2026 15:30:21 -0600 Subject: [PATCH 022/106] fix: stop the jump host pipeline test failing under load --- .../tests/hosts/connect/pipeline.test.ts | 33 +++++++++++++------ 1 file changed, 23 insertions(+), 10 deletions(-) diff --git a/src/backend/tests/hosts/connect/pipeline.test.ts b/src/backend/tests/hosts/connect/pipeline.test.ts index 8353f14912..c1000d4e0d 100644 --- a/src/backend/tests/hosts/connect/pipeline.test.ts +++ b/src/backend/tests/hosts/connect/pipeline.test.ts @@ -148,8 +148,23 @@ async function build( }); } +// Building a hop's config parses keys, which is slow while the whole suite +// runs, so give the multi hop steps more than vi.waitFor's 1 s. +const settle = (check: () => void) => vi.waitFor(check, { timeout: 10_000 }); +vi.setConfig({ testTimeout: 30_000 }); + beforeEach(() => { vi.clearAllMocks(); + // clearAllMocks keeps queued Once values, so a test that stopped early + // would hand its leftovers to the next one. + for (const mock of [ + mocks.resolveHostById, + mocks.createJumpHostChain, + mocks.createSocks5Connection, + mocks.applyCertificateAuth, + mocks.applyAgentAuth, + ]) + mock.mockReset(); FakeSshClient.nextBehaviour = []; FakeSshClient.instances = []; mocks.createHostVerifier.mockResolvedValue(mocks.verifier); @@ -532,7 +547,7 @@ describe("connectHost", () => { "user-1", prompt, ); - await vi.waitFor(() => + await settle(() => expect(FakeSshClient.instances[0]?.connectConfig).toBeTruthy(), ); const first = FakeSshClient.instances[0]; @@ -546,11 +561,9 @@ describe("connectHost", () => { [{ prompt: "Verification code:", echo: false }], firstAnswer, ); - await vi.waitFor(() => - expect(firstAnswer).toHaveBeenCalledWith(["111111"]), - ); + await settle(() => expect(firstAnswer).toHaveBeenCalledWith(["111111"])); first.emit("ready"); - await vi.waitFor(() => + await settle(() => expect(FakeSshClient.instances[1]?.connectConfig).toBeTruthy(), ); const second = FakeSshClient.instances[1]; @@ -566,7 +579,7 @@ describe("connectHost", () => { ], secondAnswer, ); - await vi.waitFor(() => + await settle(() => expect(secondAnswer).toHaveBeenCalledWith(["hop-password", "222222"]), ); second.emit("ready"); @@ -674,7 +687,7 @@ describe("connectHost", () => { it("auto-fills the stored password in keyboard-interactive with nobody to ask", async () => { FakeSshClient.nextBehaviour = ["hang"]; const attempt = connectHost(host(), { userId: "user-1", purpose: "fleet" }); - await vi.waitFor(() => { + await settle(() => { const last = FakeSshClient.instances.at(-1)!; expect(last.connectConfig).not.toBeNull(); }); @@ -704,7 +717,7 @@ describe("connectHost", () => { purpose: "file-manager", prompt: { ask }, }); - await vi.waitFor(() => + await settle(() => expect(FakeSshClient.instances.at(-1)!.connectConfig).not.toBeNull(), ); const client = FakeSshClient.instances.at(-1)!; @@ -720,7 +733,7 @@ describe("connectHost", () => { ], finish, ); - await vi.waitFor(() => expect(finish).toHaveBeenCalled()); + await settle(() => expect(finish).toHaveBeenCalled()); expect(ask).toHaveBeenCalledWith({ kind: "totp", prompt: "Verification code: ", @@ -751,7 +764,7 @@ describe("connectHost", () => { purpose: "fleet", }), ).rejects.toThrow(/All configured authentication methods failed/); - await vi.waitFor(() => + await settle(() => expect(mocks.forgetCert).toHaveBeenCalledWith("user-1", 7), ); dispose(); From 1867be3f582dc0dd2330fae635433c8b294bcb6b Mon Sep 17 00:00:00 2001 From: LukeGus Date: Wed, 4 Nov 2026 15:52:42 -0600 Subject: [PATCH 023/106] fix: keep line chart time labels from overlapping --- src/ui/components/charts/LineChart.tsx | 34 +-------- src/ui/components/charts/x-ticks.ts | 74 +++++++++++++++++++ .../charts/line-chart-ticks.test.ts | 46 ++++++++++++ 3 files changed, 122 insertions(+), 32 deletions(-) create mode 100644 src/ui/components/charts/x-ticks.ts create mode 100644 src/ui/tests/components/charts/line-chart-ticks.test.ts diff --git a/src/ui/components/charts/LineChart.tsx b/src/ui/components/charts/LineChart.tsx index 1079c5a539..f618e91594 100644 --- a/src/ui/components/charts/LineChart.tsx +++ b/src/ui/components/charts/LineChart.tsx @@ -1,6 +1,7 @@ import { useRef, useState, useEffect, useCallback } from "react"; import { cn } from "@/lib/utils"; import { parseServerTime } from "@/lib/server-time"; +import { formatTimestamp, pickXTicks } from "./x-ticks"; export interface LineChartSeries { key: string; @@ -24,26 +25,6 @@ const X_LABEL_H = 20; const TOP_PAD = 6; const TICK_COUNT = 5; -function formatTimestamp(ts: string, rangeMs: number): string { - const d = parseServerTime(ts); - // A live chart covers a minute or two, so minutes alone would repeat. - if (rangeMs <= 10 * 60 * 1000) { - return d.toLocaleTimeString([], { - hour: "2-digit", - minute: "2-digit", - second: "2-digit", - }); - } - if (rangeMs <= 25 * 60 * 60 * 1000) { - return d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }); - } - return ( - d.toLocaleDateString([], { month: "short", day: "numeric" }) + - " " + - d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }) - ); -} - function valToY(v: number, drawH: number, domain: [number, number]): number { const [lo, hi] = domain; const range = hi === lo ? 1 : hi - lo; @@ -119,18 +100,7 @@ export function LineChart({ return Array.from({ length: TICK_COUNT }, (_, i) => lo + i * step); })(); - const xTickIndices: number[] = []; - if (timestamps.length > 1 && chartW > 0) { - const n = timestamps.length; - // Long labels ("Jun 28 14:30") are ~80px; short ("14:30") are ~40px. - // Use at most 3 ticks for long labels, 5 for short, so they never crowd. - const longLabel = rangeMs > 25 * 60 * 60 * 1000; - const maxTicks = longLabel ? 3 : 5; - const count = Math.min(maxTicks, n); - for (let k = 0; k < count; k++) { - xTickIndices.push(Math.round((k / (count - 1)) * (n - 1))); - } - } + const xTickIndices = pickXTicks(timestamps, rangeMs, chartW); const handleMouseMove = useCallback( (e: React.MouseEvent) => { diff --git a/src/ui/components/charts/x-ticks.ts b/src/ui/components/charts/x-ticks.ts new file mode 100644 index 0000000000..a0b59b65d5 --- /dev/null +++ b/src/ui/components/charts/x-ticks.ts @@ -0,0 +1,74 @@ +import { parseServerTime } from "@/lib/server-time"; + +export function formatTimestamp(ts: string, rangeMs: number): string { + const d = parseServerTime(ts); + // A live chart covers a minute or two, so minutes alone would repeat. + if (rangeMs <= 10 * 60 * 1000) { + return d.toLocaleTimeString([], { + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + }); + } + if (rangeMs <= 25 * 60 * 60 * 1000) { + return d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }); + } + return ( + d.toLocaleDateString([], { month: "short", day: "numeric" }) + + " " + + d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }) + ); +} + +// Rough width of a 10px label, used to keep x labels apart. +const CHAR_W = 6.2; +const LABEL_GAP = 12; + +/** + * Picks which timestamps get an x label. The count follows the chart width and + * label length, and a label that would touch its neighbour is dropped. + */ +export function pickXTicks( + timestamps: string[], + rangeMs: number, + chartW: number, +): number[] { + const n = timestamps.length; + if (n < 2 || chartW <= 0) return []; + const labelW = (i: number) => + formatTimestamp(timestamps[i], rangeMs).length * CHAR_W; + const widest = Math.max(labelW(0), labelW(n - 1)); + const fit = Math.floor((chartW + LABEL_GAP) / (widest + LABEL_GAP)); + const count = Math.max(1, Math.min(5, n, fit)); + const candidates = + count === 1 + ? [n - 1] + : Array.from({ length: count }, (_, k) => + Math.round((k / (count - 1)) * (n - 1)), + ); + + // Box of a label as drawn: first is start anchored, last end, others middle. + const box = (i: number): [number, number] => { + const x = (i / (n - 1)) * chartW; + const w = labelW(i); + if (i === 0) return [x, x + w]; + if (i === n - 1) return [x - w, x]; + return [x - w / 2, x + w / 2]; + }; + + const clashes = (a: number | undefined, b: number) => + a !== undefined && box(a)[1] + LABEL_GAP > box(b)[0]; + const placed: number[] = []; + for (const i of [...new Set(candidates)]) { + // The newest time stays visible on a live chart, older labels make room. + if (i === n - 1) { + while (placed.length && clashes(placed[placed.length - 1], i)) { + placed.pop(); + } + } else if (clashes(placed[placed.length - 1], i)) { + continue; + } + placed.push(i); + } + return placed; +} diff --git a/src/ui/tests/components/charts/line-chart-ticks.test.ts b/src/ui/tests/components/charts/line-chart-ticks.test.ts new file mode 100644 index 0000000000..8b2483d3ca --- /dev/null +++ b/src/ui/tests/components/charts/line-chart-ticks.test.ts @@ -0,0 +1,46 @@ +import { describe, it, expect } from "vitest"; +import { pickXTicks } from "../../../components/charts/x-ticks"; + +const live = (n: number) => + Array.from({ length: n }, (_, i) => + new Date(Date.UTC(2026, 10, 4, 15, 37, i * 2)).toISOString(), + ); + +describe("pickXTicks", () => { + it("returns nothing without two points or width", () => { + expect(pickXTicks(live(1), 0, 300)).toEqual([]); + expect(pickXTicks(live(5), 8000, 0)).toEqual([]); + }); + + it("uses up to five labels on a wide chart", () => { + const ts = live(60); + expect(pickXTicks(ts, 118_000, 900)).toHaveLength(5); + }); + + it("fits fewer labels on a narrow chart and keeps the newest", () => { + const ts = live(60); + const ticks = pickXTicks(ts, 118_000, 220); + expect(ticks.length).toBeLessThan(5); + expect(ticks[ticks.length - 1]).toBe(59); + }); + + it("never lets labels touch", () => { + for (const w of [120, 180, 220, 260, 300, 400, 600]) { + for (const n of [2, 3, 4, 5, 8, 60]) { + const ts = live(n); + const range = (n - 1) * 2000; + const ticks = pickXTicks(ts, range, w); + const labelW = 11 * 6.2; + const boxes = ticks.map((i) => { + const x = (i / (n - 1)) * w; + if (i === 0) return [x, x + labelW]; + if (i === n - 1) return [x - labelW, x]; + return [x - labelW / 2, x + labelW / 2]; + }); + for (let k = 1; k < boxes.length; k++) { + expect(boxes[k][0]).toBeGreaterThanOrEqual(boxes[k - 1][1]); + } + } + } + }); +}); From 19bdd589f3304baaef468a887d21bf1ddf9f04c8 Mon Sep 17 00:00:00 2001 From: LukeGus Date: Wed, 4 Nov 2026 18:57:18 -0600 Subject: [PATCH 024/106] feat: shared tile look for cards and pages, plus new page pieces in SDK 1.1.0 --- CHANGELOG.md | 3 +- packages/plugin-sdk/CHANGELOG.md | 10 +- packages/plugin-sdk/PLUGIN-GUIDE.md | 23 ++- packages/plugin-sdk/package.json | 2 +- packages/plugin-sdk/ui.api.txt | 31 +++- scripts/check-sdk-ui-exports.cjs | 25 +++ src/ui/components/card.tsx | 7 +- src/ui/components/data-table.tsx | 57 +++++++ src/ui/components/data-view.tsx | 140 ++++++++--------- src/ui/components/info-grid.tsx | 41 +++++ src/ui/components/loading-state.tsx | 44 ++++++ src/ui/components/metric-card.tsx | 3 +- src/ui/components/notice-panel.tsx | 52 +++++++ src/ui/components/pager.tsx | 73 +++++++++ src/ui/components/panel-layout.tsx | 66 +++++++- src/ui/components/section-card.tsx | 19 ++- src/ui/components/side-nav.tsx | 127 ++++++++++++++++ src/ui/components/stat-tile.tsx | 87 +++++++++++ src/ui/components/status.tsx | 125 +++++++++++++++ src/ui/components/surface/surface-scope.tsx | 11 ++ src/ui/components/tab-strip.tsx | 2 +- src/ui/components/tile-grid/TileColumn.tsx | 27 ++++ src/ui/components/tile-grid/TileEditBar.tsx | 56 +++++++ src/ui/components/tile-grid/TileGrid.tsx | 9 +- src/ui/components/tile-grid/TileStack.tsx | 37 +++++ src/ui/components/tile-grid/TileWall.tsx | 53 +++++++ src/ui/components/tile-grid/edge.tsx | 86 +++++++++++ src/ui/components/tile-grid/index.ts | 5 + src/ui/lib/widget-title.tsx | 10 +- src/ui/locales/en.json | 10 +- src/ui/plugin-host/sdk-ui.ts | 21 +++ src/ui/plugins/PluginDetail.tsx | 68 ++++----- src/ui/plugins/PluginReleaseNotes.tsx | 2 +- src/ui/plugins/PluginsTab.tsx | 120 +++++++-------- src/ui/settings/HostPluginSections.tsx | 2 +- src/ui/shell/tabUtils.tsx | 4 +- src/ui/tests/components/data-view.test.tsx | 3 +- src/ui/tests/components/pager.test.tsx | 56 +++++++ src/ui/tests/components/status.test.tsx | 43 ++++++ .../components/tile-grid/TileGrid.test.tsx | 2 +- .../components/tile-grid/TileWall.test.tsx | 142 ++++++++++++++++++ 41 files changed, 1497 insertions(+), 207 deletions(-) create mode 100644 src/ui/components/data-table.tsx create mode 100644 src/ui/components/info-grid.tsx create mode 100644 src/ui/components/loading-state.tsx create mode 100644 src/ui/components/notice-panel.tsx create mode 100644 src/ui/components/pager.tsx create mode 100644 src/ui/components/side-nav.tsx create mode 100644 src/ui/components/stat-tile.tsx create mode 100644 src/ui/components/status.tsx create mode 100644 src/ui/components/tile-grid/TileColumn.tsx create mode 100644 src/ui/components/tile-grid/TileEditBar.tsx create mode 100644 src/ui/components/tile-grid/TileStack.tsx create mode 100644 src/ui/components/tile-grid/TileWall.tsx create mode 100644 src/ui/components/tile-grid/edge.tsx create mode 100644 src/ui/tests/components/pager.test.tsx create mode 100644 src/ui/tests/components/status.test.tsx create mode 100644 src/ui/tests/components/tile-grid/TileWall.test.tsx diff --git a/CHANGELOG.md b/CHANGELOG.md index 79e041ac67..c22935d9fa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,7 +12,8 @@ ### Changed -- Plugin cards placed on a tile grid take the dashboard look, with shared lines and no gaps +- Plugin cards and pages use the dashboard look everywhere, with shared lines between sections instead of separate boxes with gaps +- The Plugins tab and plugin pages use the same look - Line charts that cover a few minutes show seconds in their times - Plugins a new release adds to the image start on only when they are recommended. The rest are installed and left off for an admin to turn on diff --git a/packages/plugin-sdk/CHANGELOG.md b/packages/plugin-sdk/CHANGELOG.md index edb69328f1..29149b1296 100644 --- a/packages/plugin-sdk/CHANGELOG.md +++ b/packages/plugin-sdk/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -## 1.0.26 +## 1.1.0 Plugin API 1.15.0. @@ -9,6 +9,14 @@ Plugin API 1.15.0. - `/ui` exports `TileGrid`, `TileFrame` and `TilePicker`, the 12 column tile grid the dashboard, the host page and Host Metrics share, plus `appendTile`, `flowTiles`, `removeTile`, `sanitizeTiles` and the `TileSlot` type - `registerDashboardCard` takes `entries`, so one card can be placed many times with its own `config`, and `defaultSize` for the grid - Dashboard cards get `config`, `setConfig` and `editMode` +- `/ui` exports `TileWall` (a fixed grid of tiles with shared lines), `TileStack` and `TileColumn` (sections stacked with a line between), `TileEditBar`, and `SeamlessTileContext` with `useInSeamlessTile` +- `/ui` exports `StatTile`, `InfoGrid`, `StatusDot`, `StatusBadge` and `statusTone`, `LoadingState`, `NoticePanel`, `Pager` with `usePaged` and `pageOf`, `DataTable` with `DataTableRow`, `SideNav` with `SideNavItem`, `PanelFrame` and `RefreshButton` +- `InlineView` takes `stacked`, which lays SectionCards out in one column with lines between them +- `GroupHeading` takes `plain`, which drops the rule after the title. Inside a tile layout that is the default + +### Changed + +- `Card`, `SectionCard`, `MetricCard` and `DataView` use the tile look: no fill, and inside a tile layout no box either. `DataView` grids sit edge to edge with shared lines ## 1.0.25 diff --git a/packages/plugin-sdk/PLUGIN-GUIDE.md b/packages/plugin-sdk/PLUGIN-GUIDE.md index 621bd94f8b..c334d0103a 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.26 | Plugin API 1.15.0 +SDK 1.1.0 | Plugin API 1.15.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. @@ -766,15 +766,28 @@ exports with props is `ui.api.txt` in the Termix repo - Small type: most text 11 to 13px. - No new modal dialogs. Ask with `useConfirm()`, open views in place with `InlineView`, and use `PanePrompt` for small prompts. -- Every tab or panel sits in `PanelShell`. Lists use `PanelList` and `ListRow`, add with `AddButton`, - forms end with `FormFooter`, empty lists show `EmptyState`. +- Every tab or panel sits in `PanelShell` (inside `PanelFrame` when it is a tab). Lists use `PanelList` and + `ListRow`, add with `AddButton`, forms end with `FormFooter`, empty lists show `EmptyState`, loading shows + `LoadingState`, and "not installed" or "no access" shows `NoticePanel`. +- The tile look: page content sits edge to edge on the page background, with one line between sections and + no gaps, padding or card fill around them. The body of a `PanelShell` has no padding. Stack sections with + `TileStack` (or `TileColumn` when cards sit deeper), put numbers and small cards in a `TileWall` of + `StatTile` or `MetricCard`, and use `TileFrame` for a titled section. Cards drop their own box inside + these. Forms keep `PANEL.body` padding inside their section. An `InlineView` form made of `SectionCard`s + takes `stacked`. +- Page views go in `PanelShell`'s `tabs` band as a `TabStrip`. `Segmented` is for filters and view modes. + A page with many sections uses `SideNav` on the left. Put the refresh button in the header with + `RefreshButton`, and the docs link with the `docs` prop. +- States and data: `StatusBadge` and `StatusDot` (pass a state like "running" and the color follows), + `InfoGrid` for label and value pairs, `DataTable` for a plain table, `Pager` and `usePaged` under long lists. - Tile pages use `TileGrid` with `TileFrame` around each tile and `TilePicker` beside it while editing. Slots are `{ id, x, y, w, h }` on 12 columns (`h` null for the natural height); save them where your plugin keeps - preferences and run saved ones through `sanitizeTiles`. Add with `appendTile`. Needs Termix 26.11.1. + preferences and run saved ones through `sanitizeTiles`. Add with `appendTile`. While editing, show + `TileEditBar` over the grid. Needs Termix 26.11.1. - A dashboard card with `entries` can be placed many times: each entry has a title, a group and a `config` the card gets back as `config` (save changes with `setConfig`, keep it small). Operations Center uses this for status and action tiles. -- Card grids use `DataView` with `ViewToggle`. Pass `columns` and `onColumns` to `ViewToggle` and the same +- Card grids use `DataView` with `ViewToggle` (the cards share lines like a `TileWall`). Pass `columns` and `onColumns` to `ViewToggle` and the same value as `size` to `DataView`, so users can pick Auto (as many as fit) or a fixed number of cards per row. `usePluginPanelView` keeps the choice as `columns`. Older Termix versions ignore both props. - Forms: `TextField`, `NumberField`, `SelectField`, `SwitchRow`, `SecretField`, `SettingRow` and friends. diff --git a/packages/plugin-sdk/package.json b/packages/plugin-sdk/package.json index d72578dec9..31698dcab4 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.26", + "version": "1.1.0", "description": "The contract between Termix core and a plugin: types, the capability catalog and the manifest schema.", "license": "Apache-2.0", "repository": { diff --git a/packages/plugin-sdk/ui.api.txt b/packages/plugin-sdk/ui.api.txt index 60021fcf0e..2d483d8e3b 100644 --- a/packages/plugin-sdk/ui.api.txt +++ b/packages/plugin-sdk/ui.api.txt @@ -80,6 +80,8 @@ DEFAULT_FOLDER_COLOR (value): "#f59145" DEFAULT_FOLDER_ICON (value): "folder" DashboardCardId (type): DashboardCardId DataColumn (type): DataColumn +DataTable (value): ({ className, containerClassName, children, ...props }: ClassAttributes & TableHTMLAttributes & { containerClassName?: string; }) => Element +DataTableRow (value): ({ active, className, onClick, ...props }: ClassAttributes & HTMLAttributes & { active?: boolean; }) => Element DataView (value): ({ items, view, density, getKey, renderCard, columns, size, minCardWidth, listColumns, onRowClick, isRowActive, empty, className, }: { items: T[]; view: PanelViewMode; density?: PanelDensity; getKey: (item: T) => string; renderCard: (item: T) => ReactNode; columns: GridColumns; size?: GridSize; minCardWidth?: number; 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 @@ -133,7 +135,7 @@ GridColumns (type): GridColumns GridLayout (type): GridLayout GridSize (type): GridSize GridSlot (type): GridSlot -GroupHeading (value): ({ title, count, action, className, }: { title: string; count?: number; action?: ReactNode; className?: string; }) => Element +GroupHeading (value): ({ title, count, action, plain, className, }: { title: string; count?: number; action?: ReactNode; plain?: boolean; className?: string; }) => Element HexColorPicker (value): ({ value, onChange, }: { value: string; onChange: (hex: string) => void; }) => Element Host (type): Host HostActionDef (type): HostActionDef @@ -166,7 +168,9 @@ HostTabEmbed (value): ({ host, type, label, data, isVisible, shell, }: { host: H 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 +InfoGrid (value): ({ items, className, }: { items: InfoItem[]; className?: string; }) => Element +InfoItem (type): InfoItem +InlineView (value): ({ open, onOpenChange, title, icon, status, actions, toolbar, footer, width, onBeforeClose, scroll, bare, stacked, 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; stacked?: boolean; className?: string; children: ReactNode; }) => ReactPortal Input (value): ({ className, type, ...props }: DetailedHTMLProps, HTMLInputElement>) => Element Kbd (value): ForwardRefExoticComponent> KbdKey (value): { ({ className, ...props }: HTMLAttributes): Element; displayName: string; } @@ -180,11 +184,12 @@ Label (value): ({ className, ...props }: LabelProps & RefAttributes Element LineChartSeries (type): LineChartSeries LinkedAccountInfo (type): LinkedAccountInfo -ListBadge (value): ({ tone, className, children, }: { tone?: "destructive" | "muted" | "brand" | "warning" | "success"; className?: string; children: ReactNode; }) => Element +ListBadge (value): ({ tone, className, children, }: { tone?: "destructive" | "warning" | "muted" | "brand" | "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 ListRowFolder (value): ({ name, icon, count, open, onToggle, stripe, actions, dropActive, onDropItem, emptyText, className, children, }: { name: ReactNode; icon?: ReactNode; count?: number; open: boolean; onToggle: () => void; stripe?: number; actions?: ReactNode; dropActive?: boolean; onDropItem?: () => void; emptyText?: ReactNode; className?: string; children?: ReactNode; }) => Element ListRowTone (type): ListRowTone +LoadingState (value): ({ label, compact, className, }: { label?: string; compact?: boolean; className?: string; }) => Element LogContext (type): LogContext LogEntry (type): LogEntry LookSettings (value): ({ isAdmin }: { isAdmin: boolean; }) => Element @@ -201,6 +206,7 @@ MetricCard (value): ({ title, icon, action, children, bodyClassName, scroll, scr 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 +NoticePanel (value): ({ icon: Icon, title, children, action, tone, className, }: { icon?: LucideIcon; title: ReactNode; children?: ReactNode; action?: ReactNode; tone?: "default" | "destructive" | "warning"; className?: string; }) => Element 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 @@ -209,9 +215,11 @@ 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" +Pager (value): ({ page, pages, hasNext, onPage, className, }: { page: number; pages?: number; hasNext?: boolean; onPage: (next: number) => void; className?: string; }) => Element 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 +PanelFrame (value): ({ className, children, }: { className?: string; children: ReactNode; }) => Element PanelList (value): ({ empty, className, children, }: { empty?: ReactNode; className?: string; children?: ReactNode; }) => Element PanelSearch (value): ({ value, onChange, placeholder, fill, autoFocus, disabled, title, onEnter, inputRef, className, }: { value: string; onChange: (next: string) => void; placeholder?: string; fill?: boolean; autoFocus?: boolean; disabled?: boolean; title?: string; onEnter?: () => void; inputRef?: Ref; className?: string; }) => Element PanelShell (value): ({ chrome, icon, title, status, leading, actions, toolbar, tabs, footer, scroll, docs, className, children, }: { chrome?: boolean; icon?: ReactNode; title?: ReactNode; status?: ReactNode; leading?: ReactNode; actions?: ReactNode; toolbar?: ReactNode; tabs?: ReactNode; footer?: ReactNode; scroll?: boolean; docs?: string; className?: string; children: ReactNode; }) => Element @@ -236,6 +244,7 @@ RadialGaugeProps (type): RadialGaugeProps RailGroup (type): RailGroup RailItemDef (type): RailItemDef RecentActivityItem (type): RecentActivityItem +RefreshButton (value): ({ onClick, loading, disabled, label, className, }: { onClick: () => void; loading?: boolean; disabled?: boolean; label?: string; className?: string; }) => Element 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 @@ -248,6 +257,7 @@ SSHHostWithStatus (type): SSHHostWithStatus SSH_AUTH_PROTOCOL (value): "ssh" ScrollArea (value): ({ className, children, ...props }: ScrollAreaProps & RefAttributes) => Element ScrollBar (value): ({ className, orientation, ...props }: ScrollAreaScrollbarProps & RefAttributes) => Element +SeamlessTileContext (value): Context 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 SectionCard (value): ({ title, icon, action, children, className, rowId, }: { title: string; icon: ReactNode; action?: ReactNode; children: ReactNode; className?: string; rowId?: string; }) => Element Segmented (value): ({ value, onChange, options, className, }: { value: T; onChange: (next: T) => void; options: readonly { value: NoInfer; label: string; icon?: ReactNode; count?: number; title?: string; }[]; className?: string; }) => Element @@ -277,6 +287,9 @@ SheetHeader (value): ({ className, ...props }: DetailedHTMLProps) => Element SheetTrigger (value): ({ ...props }: DialogTriggerProps & RefAttributes) => Element ShellConfirmCapture (value): () => null +SideNav (value): ({ entries, active, onSelect, footer, label, className, }: { entries: SideNavEntry[]; label?: string; active: string; onSelect: (id: string) => void; footer?: ReactNode; className?: string; }) => Element +SideNavEntry (type): SideNavEntry +SideNavItem (value): ({ entry, active, onClick, }: { entry: SideNavEntry; active: boolean; onClick: () => void; }) => Element Skeleton (value): ({ className, ...props }: DetailedHTMLProps, HTMLDivElement>) => Element Slider (value): ({ className, defaultValue, value, min, max, ...props }: SliderProps & RefAttributes) => Element SortKey (type): SortKey @@ -286,6 +299,10 @@ SplitLayoutMenu (value): ({ activeIsSplit, canAddPane, onSplit, onPreset, }: { a SplitOpenTarget (type): SplitOpenTarget SplitSummary (type): SplitSummary StatRow (value): ({ label, value, mono, className, }: { label: ReactNode; value: ReactNode; mono?: boolean; className?: string; }) => Element +StatTile (value): ({ label, icon, value, unit, percent, hint, tone, actions, onClick, children, className, }: { label: ReactNode; icon?: ReactNode; value: ReactNode; unit?: ReactNode; percent?: number; hint?: ReactNode; tone?: "default" | "ok" | "warn" | "bad"; actions?: ReactNode; onClick?: () => void; children?: ReactNode; className?: string; }) => Element +StatusBadge (value): ({ tone, state, className, children, }: { tone?: StatusTone; state?: string; className?: string; children?: ReactNode; }) => Element +StatusDot (value): ({ tone, state, pulse, className, title, }: { tone?: StatusTone; state?: string; pulse?: boolean; className?: string; title?: string; }) => Element +StatusTone (type): StatusTone 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 @@ -306,6 +323,8 @@ TextField (value): ({ label, value, onChange, placeholder, hint, error, aside, d Textarea (value): ForwardRefExoticComponent> TextareaProps (type): TextareaProps ThemeId (type): string +TileColumn (value): ({ className, children, }: { className?: string; children: ReactNode; }) => Element +TileEditBar (value): ({ children, onReset, onAdd, onDone, className, }: { children?: ReactNode; onReset?: () => void; onAdd?: () => void; onDone: () => void; className?: string; }) => Element TileFrame (value): ({ title, icon, actions, bare, onTitleClick, className, bodyClassName, children, }: { title?: ReactNode; icon?: ReactNode; actions?: ReactNode; bare?: boolean; onTitleClick?: () => void; className?: string; bodyClassName?: string; children: ReactNode; }) => Element TileGrid (value): ({ slots, editMode, renderTile, tileLabel, onChange, onSettings, gap, stackBelow, minColumns, minHeight, empty, className, }: TileGridProps) => Element TileGridProps (type): TileGridProps @@ -313,6 +332,8 @@ TileKind (type): TileKind TilePicker (value): ({ entries, onAdd, onClose, className, }: { entries: TilePickerEntry[]; onAdd: (entry: TilePickerEntry) => void; onClose?: () => void; className?: string; }) => Element TilePickerEntry (type): TilePickerEntry TileSlot (type): TileSlot +TileStack (value): ({ className, children, }: { className?: string; children: ReactNode; }) => Element +TileWall (value): ({ size, minWidth, gridClassName, className, children, }: { size?: GridSize; minWidth?: number; gridClassName?: string; className?: string; children: ReactNode; }) => Element TlsStatus (type): TlsStatus ToolsTab (type): ToolsTab Tooltip (value): ({ ...props }: TooltipProps) => Element @@ -503,6 +524,7 @@ normalizeLanguageCode (value): (language?: string) => string notifySyncChanged (value): () => void openInSplit (value): (target: SplitOpenTarget, open: () => void) => void operationsApi (value): AxiosInstance +pageOf (value): (items: T[], page: number, size: number) => { rows: T[]; page: number; pages: number; } paletteEntriesFor (value): (all: PaletteEntryDef[], scope: "global" | "host", host?: Host) => PaletteEntryDef[] parseCustomKeybindings (value): (raw?: string) => CustomKeybinding[] parseServerTime (value): (ts: string) => Date @@ -573,6 +595,7 @@ shareHost (value): (hostId: number, shareData: { targets: ShareTarget[]; permiss shell (value): TabShellCallbacks splitDefaultKey (value): (fullKey: string) => [string, string] sshHostToHost (value): (h: SSHHostWithStatus) => Host +statusTone (value): (state: string) => StatusTone tabIcon (value): (type: TabType) => Element tabJumpHotkeyKeys (value): () => string[] takePendingManageRequest (value): () => ManageRequest @@ -618,6 +641,7 @@ useHostProtocols (value): () => HostProtocolDef[] useHostSections (value): () => HostSectionDef[] useHostStatus (value): (hostId: number, statusCheckEnabled?: boolean) => StatusValue useHostStatusIndicators (value): () => HostBadgeDef[] +useInSeamlessTile (value): () => boolean useInstalledPlugins (value): () => PluginSummary[] useIsDefaultsEditor (value): () => boolean useIsMobile (value): () => boolean @@ -625,6 +649,7 @@ useKeybindingActions (value): () => KeybindingActionDef[] useKeybindingDefaults (value): () => KeybindingDefaultDef[] useOptionalConnectionLog (value): () => ConnectionLogContextType usePageVisibleInterval (value): (callback: () => void, intervalMs: number, enabled?: boolean, options?: Options) => void +usePaged (value): (items: T[], size: number, reset?: unknown) => { setPage: Dispatch>; rows: T[]; page: number; pages: number; } usePaletteEntries (value): () => PaletteEntryDef[] usePaletteGroups (value): () => PaletteGroupDef[] usePanels (value): () => PanelDef[] diff --git a/scripts/check-sdk-ui-exports.cjs b/scripts/check-sdk-ui-exports.cjs index 0b78339cd6..ffb4de9e67 100644 --- a/scripts/check-sdk-ui-exports.cjs +++ b/scripts/check-sdk-ui-exports.cjs @@ -19,6 +19,31 @@ const SDK_UI = path.join(ROOT, "src", "ui", "plugin-host", "sdk-ui.ts"); const TSCONFIG = path.join(ROOT, "tsconfig.app.json"); const ALLOWED = [ + "SeamlessTileContext", + "TileEditBar", + "TileStack", + "TileColumn", + "TileWall", + "useInSeamlessTile", + "NoticePanel", + "LoadingState", + "StatusBadge", + "StatusDot", + "statusTone", + "StatusTone", + "StatTile", + "InfoGrid", + "InfoItem", + "Pager", + "pageOf", + "usePaged", + "DataTable", + "DataTableRow", + "SideNav", + "SideNavItem", + "SideNavEntry", + "PanelFrame", + "RefreshButton", "TileFrame", "TileGrid", "TilePicker", diff --git a/src/ui/components/card.tsx b/src/ui/components/card.tsx index 2d30a89a9d..4a873fe730 100644 --- a/src/ui/components/card.tsx +++ b/src/ui/components/card.tsx @@ -1,18 +1,23 @@ import * as React from "react"; import { cn } from "@/lib/utils"; +import { useInSeamlessTile } from "@/components/tile-grid/context"; function Card({ className, size = "default", ...props }: React.ComponentProps<"div"> & { size?: "default" | "sm" }) { + // Inside an edge to edge tile layout the layout draws the lines. + const tiled = useInSeamlessTile(); return (
img:first-child]:pt-0 data-[size=sm]:gap-2 data-[size=sm]:py-3 data-[size=sm]:has-data-[slot=card-footer]:pb-0 *:[img:first-child]:rounded-none *:[img:last-child]:rounded-none", + "group/card flex flex-col gap-4 overflow-hidden rounded-none py-4 text-xs/relaxed text-card-foreground has-data-[slot=card-footer]:pb-0 has-[>img:first-child]:pt-0 data-[size=sm]:gap-2 data-[size=sm]:py-3 data-[size=sm]:has-data-[slot=card-footer]:pb-0 *:[img:first-child]:rounded-none *:[img:last-child]:rounded-none", + !tiled && "border border-border", className, )} {...props} diff --git a/src/ui/components/data-table.tsx b/src/ui/components/data-table.tsx new file mode 100644 index 0000000000..8ae33841d6 --- /dev/null +++ b/src/ui/components/data-table.tsx @@ -0,0 +1,57 @@ +import type React from "react"; +import { cn } from "@/lib/utils"; + +/** + * A plain table in the tile look: sticky header on the page background, a + * line under each row, no outer box. For wide data with real columns; use + * DataView when the same list also has a card view. + */ +export function DataTable({ + className, + containerClassName, + children, + ...props +}: React.ComponentProps<"table"> & { + /** Classes for the scroll box around the table, like "flex-1". */ + containerClassName?: string; +}) { + return ( +
+ + {children} +
+
+ ); +} + +/** A body row that highlights on hover, and when active. */ +export function DataTableRow({ + active = false, + className, + onClick, + ...props +}: React.ComponentProps<"tr"> & { active?: boolean }) { + return ( + + ); +} diff --git a/src/ui/components/data-view.tsx b/src/ui/components/data-view.tsx index 4d317a196a..21e19abb35 100644 --- a/src/ui/components/data-view.tsx +++ b/src/ui/components/data-view.tsx @@ -1,8 +1,8 @@ import type React from "react"; import { cn } from "@/lib/utils"; +import { TileWall } from "@/components/tile-grid/TileWall"; +import { BottomEdge } from "@/components/tile-grid/edge"; import { - PANEL, - gridSizeStyle, type GridSize, type PanelDensity, type PanelViewMode, @@ -91,7 +91,10 @@ function columnVisibility(hideBelow?: "md" | "lg"): string { return ""; } -/** One collection as a card grid or a dense row table. */ +/** + * One collection as a card grid or a dense row table, edge to edge with + * shared lines like the rest of the tile look. + */ export function DataView({ items, view, @@ -132,21 +135,18 @@ export function DataView({ if (view === "grid") { return ( -
{items.map((item) => (
{renderCard(item)}
))} -
+ ); } @@ -154,68 +154,70 @@ export function DataView({ const rowHeight = density === "compact" ? "min-h-7" : "min-h-9"; return ( -
+
- {listColumns.map((column) => ( - - {column.header} - - ))} -
- -
- {items.map((item) => { - const active = isRowActive?.(item) ?? false; - return ( -
onRowClick(item) : undefined} +
+ {listColumns.map((column) => ( + - {listColumns.map((column) => ( -
- {column.cell(item)} -
- ))} -
- ); - })} + {column.header} + + ))} +
+ +
+ {items.map((item) => { + const active = isRowActive?.(item) ?? false; + return ( +
onRowClick(item) : undefined} + className={cn( + LIST_GRID_CLASS, + "items-center border-b border-border/50 text-xs transition-colors last:border-border", + rowHeight, + onRowClick && "cursor-pointer", + active ? "bg-accent-brand/10" : "hover:bg-muted/40", + )} + > + {listColumns.map((column) => ( +
+ {column.cell(item)} +
+ ))} +
+ ); + })} +
-
+ ); } diff --git a/src/ui/components/info-grid.tsx b/src/ui/components/info-grid.tsx new file mode 100644 index 0000000000..52fbffcd0b --- /dev/null +++ b/src/ui/components/info-grid.tsx @@ -0,0 +1,41 @@ +import type { ReactNode } from "react"; +import { cn } from "@/lib/utils"; + +export interface InfoItem { + label: ReactNode; + value: ReactNode; + /** Monospace value, for ids, paths and addresses. */ + mono?: boolean; +} + +/** Label and value pairs in two aligned columns. Empty values are skipped. */ +export function InfoGrid({ + items, + className, +}: { + items: InfoItem[]; + className?: string; +}) { + const shown = items.filter( + (item) => + item.value !== null && item.value !== undefined && item.value !== "", + ); + if (shown.length === 0) return null; + return ( +
+ {shown.map((item, index) => ( +
+
{item.label}
+
+ {item.value} +
+
+ ))} +
+ ); +} diff --git a/src/ui/components/loading-state.tsx b/src/ui/components/loading-state.tsx new file mode 100644 index 0000000000..25914e449b --- /dev/null +++ b/src/ui/components/loading-state.tsx @@ -0,0 +1,44 @@ +import { Loader2 } from "lucide-react"; +import { useTranslation } from "react-i18next"; +import { cn } from "@/lib/utils"; + +/** A spinner with a short line, for a view waiting on its first load. */ +export function LoadingState({ + label, + compact = false, + className, +}: { + label?: string; + /** One inline line instead of the centered block. */ + compact?: boolean; + className?: string; +}) { + const { t } = useTranslation(); + const text = label ?? t("common.loading"); + if (compact) { + return ( +
+ + {text} +
+ ); + } + return ( +
+ + {text} +
+ ); +} diff --git a/src/ui/components/metric-card.tsx b/src/ui/components/metric-card.tsx index 72431c7f17..47d9916bc1 100644 --- a/src/ui/components/metric-card.tsx +++ b/src/ui/components/metric-card.tsx @@ -35,9 +35,10 @@ export function MetricCard({ const tiled = useInSeamlessTile(); return (
+ {Icon && ( + + )} +

{title}

+ {children && ( +
+ {children} +
+ )} + {action &&
{action}
} +
+ ); +} diff --git a/src/ui/components/pager.tsx b/src/ui/components/pager.tsx new file mode 100644 index 0000000000..810b1a77f5 --- /dev/null +++ b/src/ui/components/pager.tsx @@ -0,0 +1,73 @@ +/* eslint-disable react-refresh/only-export-components */ +import { useEffect, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { Button } from "@/components/button"; +import { cn } from "@/lib/utils"; + +/** One page of a list, with the page clamped to what exists. */ +export function pageOf( + items: T[], + page: number, + size: number, +): { rows: T[]; page: number; pages: number } { + const pages = Math.max(1, Math.ceil(items.length / size)); + const at = Math.min(Math.max(0, page), pages - 1); + return { rows: items.slice(at * size, at * size + size), page: at, pages }; +} + +/** Pages a list in memory. Goes back to the first page when `reset` changes. */ +export function usePaged(items: T[], size: number, reset?: unknown) { + const [page, setPage] = useState(0); + useEffect(() => setPage(0), [reset]); + return { ...pageOf(items, page, size), setPage }; +} + +/** Previous and Next under a list. Hidden when there is only one page. */ +export function Pager({ + page, + pages, + hasNext, + onPage, + className, +}: { + page: number; + /** Total pages when known; leave out for a list read a page at a time. */ + pages?: number; + hasNext?: boolean; + onPage: (next: number) => void; + className?: string; +}) { + const { t } = useTranslation(); + const next = hasNext ?? (pages !== undefined && page < pages - 1); + if (page === 0 && !next) return null; + return ( +
+ + + {pages !== undefined + ? t("panel.pager.pageOf", { page: page + 1, pages }) + : t("panel.pager.page", { page: page + 1 })} + + +
+ ); +} diff --git a/src/ui/components/panel-layout.tsx b/src/ui/components/panel-layout.tsx index de28a19050..9a09ea04ae 100644 --- a/src/ui/components/panel-layout.tsx +++ b/src/ui/components/panel-layout.tsx @@ -6,6 +6,7 @@ import { Columns3, Grid3X3, List, + RefreshCw, Rows3, Search, X, @@ -14,6 +15,7 @@ import { useTranslation } from "react-i18next"; import { cn } from "@/lib/utils"; import { Separator } from "@/components/separator"; import { DocsLink } from "@/components/docs-link"; +import { useInSeamlessTile } from "@/components/tile-grid/context"; import { Select, SelectContent, @@ -215,6 +217,59 @@ export function BackButton({ } /** Search box sized to match toolbar buttons. Esc clears it. */ +/** The refresh button panel headers show, spinning while it loads. */ +export function RefreshButton({ + onClick, + loading = false, + disabled, + label, + className, +}: { + onClick: () => void; + loading?: boolean; + disabled?: boolean; + label?: string; + className?: string; +}) { + const { t } = useTranslation(); + const text = label ?? t("common.refresh"); + return ( + + ); +} + +/** + * The outer box of a plugin tab: fills the tab and clips, so the PanelShell + * inside can scroll its own body. + */ +export function PanelFrame({ + className, + children, +}: { + className?: string; + children: React.ReactNode; +}) { + return ( +
+
+ {children} +
+
+ ); +} + export function PanelSearch({ value, onChange, @@ -513,13 +568,18 @@ export function GroupHeading({ title, count, action, + plain, className, }: { title: string; count?: number; action?: React.ReactNode; + /** No rule after the title. The default inside a tile layout. */ + plain?: boolean; className?: string; }) { + const tiled = useInSeamlessTile(); + const noRule = plain ?? tiled; return (
@@ -530,7 +590,11 @@ export function GroupHeading({ {count} )} -
+ {noRule ? ( +
+ ) : ( +
+ )} {action}
); diff --git a/src/ui/components/section-card.tsx b/src/ui/components/section-card.tsx index 8fa7877f83..ae442b3095 100644 --- a/src/ui/components/section-card.tsx +++ b/src/ui/components/section-card.tsx @@ -1,5 +1,7 @@ import { DefaultsOnly, HostDefaultBadge } from "@/lib/host-defaults-context"; import { useState } from "react"; +import { useInSeamlessTile } from "@/components/tile-grid/context"; +import { cn } from "@/lib/utils"; import type React from "react"; export function SectionCard({ @@ -18,19 +20,28 @@ export function SectionCard({ /** Lets settings search jump to this card. */ rowId?: string; }) { + // Inside an edge to edge tile layout the layout draws the lines. + const tiled = useInSeamlessTile(); return (
-
- {icon} +
+ + {icon} + {title} {action &&
{action}
}
-
{children}
+
{children}
); } diff --git a/src/ui/components/side-nav.tsx b/src/ui/components/side-nav.tsx new file mode 100644 index 0000000000..d45057325b --- /dev/null +++ b/src/ui/components/side-nav.tsx @@ -0,0 +1,127 @@ +import type { ComponentType, ReactNode } from "react"; +import { ExternalLink } from "lucide-react"; +import { cn } from "@/lib/utils"; +import { Select2 } from "@/components/select2"; + +export interface SideNavEntry { + id: string; + label: string; + icon?: ComponentType<{ className?: string }>; + /** A count or short badge after the label. */ + badge?: ReactNode; + /** Opens it in a normal tab instead, for split view. */ + onPopOut?: () => void; + popOutLabel?: string; + /** Draws a line above this entry. */ + divider?: boolean; +} + +/** + * The left list of sections a page switches between, like the host page and + * the Operations tab. On a phone it turns into a select over the content. + */ +export function SideNav({ + entries, + active, + onSelect, + footer, + label, + className, +}: { + entries: SideNavEntry[]; + /** Names the list for screen readers and the phone select. */ + label?: string; + active: string; + onSelect: (id: string) => void; + footer?: ReactNode; + className?: string; +}) { + return ( + <> + +
+ onSelect(event.target.value)} + className="h-8 w-full px-2.5 text-xs" + > + {entries.map((entry) => ( + + ))} + +
+ + ); +} + +export function SideNavItem({ + entry, + active, + onClick, +}: { + entry: SideNavEntry; + active: boolean; + onClick: () => void; +}) { + const Icon = entry.icon; + return ( +
+ + {entry.onPopOut && ( + + )} +
+ ); +} diff --git a/src/ui/components/stat-tile.tsx b/src/ui/components/stat-tile.tsx new file mode 100644 index 0000000000..a463d1c3bd --- /dev/null +++ b/src/ui/components/stat-tile.tsx @@ -0,0 +1,87 @@ +import type { ReactNode } from "react"; +import { cn } from "@/lib/utils"; +import { Meter } from "@/components/meter"; +import { TileFrame } from "@/components/tile-grid/TileFrame"; + +const VALUE_TONE = { + default: "", + ok: "text-green-500", + warn: "text-warning", + bad: "text-destructive", +} as const; + +/** + * One number in the tile look: the small title row, a big value, then an + * optional usage bar and a hint line. Put several in a TileWall for a strip. + */ +export function StatTile({ + label, + icon, + value, + unit, + percent, + hint, + tone = "default", + actions, + onClick, + children, + className, +}: { + label: ReactNode; + icon?: ReactNode; + value: ReactNode; + unit?: ReactNode; + /** Draws a usage bar under the value. */ + percent?: number | null; + hint?: ReactNode; + tone?: keyof typeof VALUE_TONE; + actions?: ReactNode; + onClick?: () => void; + /** Extra lines under the hint. */ + children?: ReactNode; + className?: string; +}) { + const body = ( +
+
+ + {value} + + {unit && {unit}} +
+ {percent !== undefined && } + {hint && ( + + {hint} + + )} + {children} +
+ ); + return ( + + {onClick ? ( + + ) : ( + body + )} + + ); +} diff --git a/src/ui/components/status.tsx b/src/ui/components/status.tsx new file mode 100644 index 0000000000..41257cd6f3 --- /dev/null +++ b/src/ui/components/status.tsx @@ -0,0 +1,125 @@ +/* eslint-disable react-refresh/only-export-components */ +import type { ReactNode } from "react"; +import { cn } from "@/lib/utils"; +import { ListBadge } from "@/components/list-kit"; + +export type StatusTone = "ok" | "warn" | "bad" | "info" | "idle"; + +const DOT: Record = { + ok: "bg-green-500", + warn: "bg-warning", + bad: "bg-destructive", + info: "bg-accent-brand", + idle: "bg-muted-foreground/50", +}; + +const BADGE = { + ok: "success", + warn: "warning", + bad: "destructive", + info: "brand", + idle: "muted", +} as const; + +// Common state words, so most callers only pass the state. +const WORDS: Record = { + running: "ok", + up: "ok", + online: "ok", + active: "ok", + healthy: "ok", + ready: "ok", + ok: "ok", + success: "ok", + succeeded: "ok", + done: "ok", + completed: "ok", + enabled: "ok", + connected: "ok", + bound: "ok", + compliant: "ok", + pending: "warn", + paused: "warn", + restarting: "warn", + starting: "warn", + stopping: "warn", + waiting: "warn", + degraded: "warn", + warn: "warn", + warning: "warn", + unhealthy: "warn", + drifted: "warn", + pending_approval: "warn", + needs_interaction: "warn", + interrupted: "warn", + update: "info", + created: "idle", + exited: "idle", + stopped: "idle", + inactive: "idle", + disabled: "idle", + offline: "bad", + down: "bad", + dead: "bad", + failed: "bad", + fail: "bad", + error: "bad", + crashloopbackoff: "bad", +}; + +/** The tone for a state word like "running" or "failed". Unknown words are idle. */ +export function statusTone(state: string | null | undefined): StatusTone { + if (!state) return "idle"; + const key = state.toLowerCase().replace(/[\s-]+/g, "_"); + return WORDS[key] ?? WORDS[key.replace(/_/g, "")] ?? "idle"; +} + +/** A small round status light. */ +export function StatusDot({ + tone, + state, + pulse = false, + className, + title, +}: { + tone?: StatusTone; + /** A state word, used when no tone is given. */ + state?: string | null; + pulse?: boolean; + className?: string; + title?: string; +}) { + const resolved = tone ?? statusTone(state); + return ( + + ); +} + +/** A state as a small colored badge. The text is the state unless children are given. */ +export function StatusBadge({ + tone, + state, + className, + children, +}: { + tone?: StatusTone; + state?: string | null; + className?: string; + children?: ReactNode; +}) { + const resolved = tone ?? statusTone(state); + return ( + + {children ?? state} + + ); +} diff --git a/src/ui/components/surface/surface-scope.tsx b/src/ui/components/surface/surface-scope.tsx index 8aced0893b..2398594aa6 100644 --- a/src/ui/components/surface/surface-scope.tsx +++ b/src/ui/components/surface/surface-scope.tsx @@ -15,6 +15,7 @@ import { createPortal } from "react-dom"; import { useTranslation } from "react-i18next"; import { cn } from "@/lib/utils"; import { BackButton, PanelShell } from "@/components/panel-layout"; +import { TileColumn } from "@/components/tile-grid/TileColumn"; import { PROMPT_BUTTON, PROMPT_DESTRUCTIVE_BUTTON, @@ -397,6 +398,7 @@ export function InlineView({ onBeforeClose, scroll = true, bare = false, + stacked = false, className, children, }: { @@ -416,6 +418,11 @@ export function InlineView({ scroll?: boolean; /** Draw children edge to edge, for views with their own bands. */ bare?: boolean; + /** + * Cards stacked in one column with a line between them and no padding, + * like a settings page. For forms built from SectionCards. + */ + stacked?: boolean; className?: string; children: React.ReactNode; }) { @@ -462,6 +469,10 @@ export function InlineView({ > {bare ? ( children + ) : stacked ? ( + + {children} + ) : (
diff --git a/src/ui/components/tile-grid/TileColumn.tsx b/src/ui/components/tile-grid/TileColumn.tsx new file mode 100644 index 0000000000..6f413f6640 --- /dev/null +++ b/src/ui/components/tile-grid/TileColumn.tsx @@ -0,0 +1,27 @@ +import type { ReactNode } from "react"; +import { SeamlessTileContext } from "./context"; +import { BottomEdge, EdgeOwned } from "./edge"; + +/** + * A column of cards that can sit at any depth, like a settings page built + * from several panels. Cards drop their box and the outermost ones draw a + * line under themselves. Use TileStack when the sections are direct children. + */ +export function TileColumn({ + className, + children, +}: { + className?: string; + children: ReactNode; +}) { + return ( + + + {children} + + + ); +} diff --git a/src/ui/components/tile-grid/TileEditBar.tsx b/src/ui/components/tile-grid/TileEditBar.tsx new file mode 100644 index 0000000000..596efca1e9 --- /dev/null +++ b/src/ui/components/tile-grid/TileEditBar.tsx @@ -0,0 +1,56 @@ +import type { ReactNode } from "react"; +import { RotateCcw } from "lucide-react"; +import { useTranslation } from "react-i18next"; +import { Button } from "@/components/button"; +import { cn } from "@/lib/utils"; + +/** + * The bar over a tile grid while it is being edited: a hint or extra controls + * on the left, then Reset, Add tiles (while the picker is closed) and Done. + */ +export function TileEditBar({ + children, + onReset, + onAdd, + onDone, + className, +}: { + children?: ReactNode; + onReset?: () => void; + /** Leave out while the tile picker is open. */ + onAdd?: () => void; + onDone: () => void; + className?: string; +}) { + const { t } = useTranslation(); + return ( +
+ {children && ( +
+ {children} +
+ )} +
+ {onReset && ( + + )} + {onAdd && ( + + )} + +
+
+ ); +} diff --git a/src/ui/components/tile-grid/TileGrid.tsx b/src/ui/components/tile-grid/TileGrid.tsx index c459ed5d7a..0875dc98e0 100644 --- a/src/ui/components/tile-grid/TileGrid.tsx +++ b/src/ui/components/tile-grid/TileGrid.tsx @@ -28,6 +28,7 @@ import { } from "./layout"; import { SeamlessTileContext } from "./context"; import { gridLines } from "./layout"; +import { scrollParent } from "./edge"; type Edge = "left" | "right" | "bottom" | "corner"; interface Proposal { @@ -83,14 +84,6 @@ function propose(base: TileSlot[], id: string, proposal: Proposal) { return base.map((slot) => (slot.id === id ? { ...slot, ...proposal } : slot)); } -function scrollParent(element: HTMLElement | null) { - for (let node = element?.parentElement; node; node = node.parentElement) { - const { overflowY } = getComputedStyle(node); - if (overflowY === "auto" || overflowY === "scroll") return node; - } - return null; -} - const NO_DRAG = "button, a, input, select, textarea, label, [role=separator], [contenteditable=true], .tg-handle"; diff --git a/src/ui/components/tile-grid/TileStack.tsx b/src/ui/components/tile-grid/TileStack.tsx new file mode 100644 index 0000000000..2a2c45e7cb --- /dev/null +++ b/src/ui/components/tile-grid/TileStack.tsx @@ -0,0 +1,37 @@ +import { Children, isValidElement, type ReactNode } from "react"; +import { SeamlessTileContext } from "./context"; +import { BottomEdge, EdgeOwned } from "./edge"; + +/** + * Sections stacked edge to edge with a line under each, the column form of + * the tile look. Cards inside drop their own border. The last line goes away + * when the stack reaches the bottom of the screen. + */ +export function TileStack({ + className, + children, +}: { + className?: string; + children: ReactNode; +}) { + return ( + + + + {Children.toArray(children).map((section, index) => ( +
+ {section} +
+ ))} +
+
+
+ ); +} + +function keyOf(node: ReactNode, index: number): string | number { + return isValidElement(node) && node.key !== null ? node.key : index; +} diff --git a/src/ui/components/tile-grid/TileWall.tsx b/src/ui/components/tile-grid/TileWall.tsx new file mode 100644 index 0000000000..eb6fc0c81a --- /dev/null +++ b/src/ui/components/tile-grid/TileWall.tsx @@ -0,0 +1,53 @@ +import { Children, isValidElement, type ReactNode } from "react"; +import { cn } from "@/lib/utils"; +import { gridSizeStyle, type GridSize } from "@/components/panel-layout"; +import { SeamlessTileContext } from "./context"; +import { BottomEdge } from "./edge"; + +/** + * A fixed grid of tiles edge to edge with one line between neighbours, for + * stat strips and card lists. `size` is tiles per row ("auto" fits as many as + * `minWidth` allows). Use TileGrid when people should move and resize tiles. + */ +export function TileWall({ + size = "auto", + minWidth = 280, + gridClassName, + className, + children, +}: { + size?: GridSize; + minWidth?: number; + /** Column classes (like "grid-cols-1 md:grid-cols-3") in place of size. */ + gridClassName?: string; + className?: string; + children: ReactNode; +}) { + const tiles = Children.toArray(children); + return ( + + {/* The grid sits a pixel wider so the right edge line is cut off. */} + +
+ {tiles.map((tile, index) => ( +
+ {tile} +
+ ))} +
+
+
+ ); +} + +function keyOf(node: ReactNode, index: number): string | number { + return isValidElement(node) && node.key !== null ? node.key : index; +} diff --git a/src/ui/components/tile-grid/edge.tsx b/src/ui/components/tile-grid/edge.tsx new file mode 100644 index 0000000000..9c35c3a43b --- /dev/null +++ b/src/ui/components/tile-grid/edge.tsx @@ -0,0 +1,86 @@ +/* eslint-disable react-refresh/only-export-components */ +import { + createContext, + useContext, + useLayoutEffect, + useRef, + useState, + type ReactNode, +} from "react"; +import { cn } from "@/lib/utils"; + +export function scrollParent(element: HTMLElement | null) { + for (let node = element?.parentElement; node; node = node.parentElement) { + const { overflowY } = getComputedStyle(node); + if (overflowY === "auto" || overflowY === "scroll") return node; + } + return null; +} + +/** True inside a TileStack or TileColumn section, which draws its own line. */ +const EdgeOwnedContext = createContext(false); + +export function EdgeOwned({ children }: { children: ReactNode }) { + return ( + + {children} + + ); +} + +/** + * Whether the element's end reaches the bottom of the visible scroll area, + * where a closing line would sit on the screen edge and only add noise. + */ +function useReachesBottom(ref: React.RefObject) { + const [reaches, setReaches] = useState(false); + const reachesRef = useRef(reaches); + reachesRef.current = reaches; + useLayoutEffect(() => { + const element = ref.current; + const scroller = scrollParent(element); + if (!element || !scroller) return; + // Measured without the line itself, so showing it never flips the answer. + const update = () => { + const end = + element.getBoundingClientRect().bottom - + scroller.getBoundingClientRect().top + + scroller.scrollTop - + (reachesRef.current ? 0 : 1); + setReaches(end >= scroller.clientHeight - 0.5); + }; + update(); + const observer = new ResizeObserver(update); + observer.observe(scroller); + observer.observe(element); + return () => observer.disconnect(); + }, [ref]); + return reaches; +} + +/** + * Wraps tiles whose last row draws a line under itself. The line stays when + * there is room below and is clipped when the tiles reach the bottom of the + * screen, the same rule the tile grid uses. Inside a stack section the + * section owns the line, so it is always clipped. + */ +export function BottomEdge({ + className, + innerClassName, + children, +}: { + className?: string; + innerClassName?: string; + children: ReactNode; +}) { + const owned = useContext(EdgeOwnedContext); + const ref = useRef(null); + const reaches = useReachesBottom(ref); + return ( +
+
+ {children} +
+
+ ); +} diff --git a/src/ui/components/tile-grid/index.ts b/src/ui/components/tile-grid/index.ts index 4f1db2c21f..165c920fa7 100644 --- a/src/ui/components/tile-grid/index.ts +++ b/src/ui/components/tile-grid/index.ts @@ -1,4 +1,9 @@ export { TileGrid, type TileGridProps } from "./TileGrid"; export { TileFrame } from "./TileFrame"; export { TilePicker, type TileKind, type TilePickerEntry } from "./TilePicker"; +export { TileWall } from "./TileWall"; +export { TileStack } from "./TileStack"; +export { TileColumn } from "./TileColumn"; +export { TileEditBar } from "./TileEditBar"; +export { SeamlessTileContext, useInSeamlessTile } from "./context"; export * from "./layout"; diff --git a/src/ui/lib/widget-title.tsx b/src/ui/lib/widget-title.tsx index 514c8349ee..067f2c4529 100644 --- a/src/ui/lib/widget-title.tsx +++ b/src/ui/lib/widget-title.tsx @@ -10,9 +10,13 @@ export function WidgetTitle({ title, icon, fallback }: WidgetTitleProps) { const label = title || fallback; if (!label) return null; return ( -
- {icon && {icon}} - +
+ {icon && ( + + {icon} + + )} + {label}
diff --git a/src/ui/locales/en.json b/src/ui/locales/en.json index 1d22c8e275..ae3a744ccd 100644 --- a/src/ui/locales/en.json +++ b/src/ui/locales/en.json @@ -1676,6 +1676,12 @@ "title": "Columns", "auto": "Auto", "count": "{{count}} wide" + }, + "pager": { + "previous": "Previous", + "next": "Next", + "page": "Page {{page}}", + "pageOf": "Page {{page}} of {{pages}}" } }, "fields": { @@ -1715,6 +1721,8 @@ "action": "Actions", "resource": "Lists", "session": "Sessions" - } + }, + "reset": "Reset", + "done": "Done" } } diff --git a/src/ui/plugin-host/sdk-ui.ts b/src/ui/plugin-host/sdk-ui.ts index 8437073210..6cee5ccd36 100644 --- a/src/ui/plugin-host/sdk-ui.ts +++ b/src/ui/plugin-host/sdk-ui.ts @@ -128,9 +128,15 @@ export { type GridSlot, } from "@/components/card-grid/types"; export { + SeamlessTileContext, + TileEditBar, TileFrame, TileGrid, TilePicker, + TileColumn, + TileStack, + TileWall, + useInSeamlessTile, appendTile, flowTiles, removeTile, @@ -217,6 +223,19 @@ export { WidgetTitle } from "@/lib/widget-title"; export { runVisibleInterval } from "@/lib/visible-interval"; export { useIsMobile } from "@/hooks/use-mobile"; export { EmptyState } from "@/components/empty-state"; +export { NoticePanel } from "@/components/notice-panel"; +export { LoadingState } from "@/components/loading-state"; +export { + StatusBadge, + StatusDot, + statusTone, + type StatusTone, +} from "@/components/status"; +export { StatTile } from "@/components/stat-tile"; +export { InfoGrid, type InfoItem } from "@/components/info-grid"; +export { Pager, pageOf, usePaged } from "@/components/pager"; +export { DataTable, DataTableRow } from "@/components/data-table"; +export { SideNav, SideNavItem, type SideNavEntry } from "@/components/side-nav"; export { PassphraseDialog } from "@/ssh/dialogs/PassphraseDialog"; export { HostKeyVerificationDialog } from "@/ssh/dialogs/HostKeyVerificationDialog"; @@ -299,8 +318,10 @@ export { Facts, GroupHeading, PANEL, + PanelFrame, PanelSearch, PanelShell, + RefreshButton, Segmented, ViewToggle, type GridSize, diff --git a/src/ui/plugins/PluginDetail.tsx b/src/ui/plugins/PluginDetail.tsx index 0bf16932f0..2bb1776262 100644 --- a/src/ui/plugins/PluginDetail.tsx +++ b/src/ui/plugins/PluginDetail.tsx @@ -17,8 +17,9 @@ import { TriangleAlert, } from "lucide-react"; import { Button } from "@/components/button"; -import { Card } from "@/components/card"; -import { Facts, PANEL } from "@/components/panel-layout"; +import { Facts } from "@/components/panel-layout"; +import { TileStack } from "@/components/tile-grid/TileStack"; +import { TileWall } from "@/components/tile-grid/TileWall"; import { FakeSwitch, SectionCard } from "@/components/section-card"; import { Select, @@ -52,7 +53,7 @@ import { PluginReleaseNotes, PluginVideo } from "./PluginReleaseNotes"; import type { PluginsManager } from "./use-plugins-manager"; const LINK_ROW = - "flex items-center gap-2 border border-border bg-card px-3 py-2.5 text-left text-[11px] text-muted-foreground transition-colors hover:border-accent-brand/40 hover:text-foreground"; + "flex w-full items-center gap-2 px-3 py-2.5 text-left text-[11px] text-muted-foreground transition-colors hover:bg-muted/40 hover:text-foreground"; export function PluginDetail({ plugin, @@ -93,10 +94,8 @@ export function PluginDetail({ const tryableBeta = betaToTry(plugin); return ( -
- + +
)}
-
+
{plugin.installed && failed && plugin.lastError && ( -
+
@@ -242,7 +241,7 @@ export function PluginDetail({ )} {plugin.status === "server" && ( -
+
{t("plugins.manager.runsOnServerHint")} @@ -250,13 +249,13 @@ export function PluginDetail({
)} -
-
+
+ {plugin.features.length > 0 && ( -
    +
      {plugin.features.map((feature) => (
    • ))} {plugin.capabilities.length === 0 && ( -
      +
      {t("plugins.manager.consent.none")}
      )} @@ -292,10 +291,7 @@ export function PluginDetail({
      {plugin.env.map((v) => ( -
      +
      {v.name} @@ -331,12 +327,12 @@ export function PluginDetail({ onDelete={() => manager.removeData(plugin)} /> )} -
      + -
      + {contributions.length > 0 && ( -
      +
      {contributions.map((c) => ( 0 && ( -
      +
      {plugin.installed ? t("plugins.manager.versionPickHintInstalled") @@ -414,7 +410,7 @@ export function PluginDetail({ {plugin.installed && (
      -
      +
      {t("plugins.manager.autoUpdate")} @@ -431,7 +427,7 @@ export function PluginDetail({ } />
      -
      +
      @@ -455,7 +451,7 @@ export function PluginDetail({ } />
      -
      +
      @@ -565,9 +561,9 @@ export function PluginDetail({ )} -
      +
      -
      + ); } @@ -625,16 +621,16 @@ function DataCard({ } > {error ? ( -
      +
      {t("plugins.manager.data.loadFailed")}
      ) : !data ? ( -
      +
      {t("plugins.manager.loading")}
      ) : ( -
      -
      +
      + -
      + {data.tables.length > 0 && (
      {data.tables.map((table) => (
      {table.name} @@ -675,7 +671,7 @@ function DataCard({ ))}
      )} -
      +
      {t("plugins.manager.data.migrations", { count: data.migrations.length, @@ -701,7 +697,7 @@ function Stat({ hint?: string; }) { return ( -
      +
      {label} diff --git a/src/ui/plugins/PluginReleaseNotes.tsx b/src/ui/plugins/PluginReleaseNotes.tsx index 621285462b..f23c9fdc0d 100644 --- a/src/ui/plugins/PluginReleaseNotes.tsx +++ b/src/ui/plugins/PluginReleaseNotes.tsx @@ -62,7 +62,7 @@ function Release({ .filter((group) => group.items.length > 0); return ( -
      +
      {row.version} {row.version === installedVersion && ( diff --git a/src/ui/plugins/PluginsTab.tsx b/src/ui/plugins/PluginsTab.tsx index 519843dac4..7823510f13 100644 --- a/src/ui/plugins/PluginsTab.tsx +++ b/src/ui/plugins/PluginsTab.tsx @@ -42,6 +42,8 @@ import { type PanelViewMode, } from "@/components/panel-layout"; import { DataView, type DataColumn } from "@/components/data-view"; +import { StatusDot } from "@/components/status"; +import { TileStack } from "@/components/tile-grid/TileStack"; import { InlineView, SurfaceScope } from "@/components/surface/surface-scope"; import { cn } from "@/lib/utils"; import { @@ -321,7 +323,7 @@ function Toolbar({ children?: React.ReactNode; }) { return ( -
      +
      - +
      + {children}
      ); @@ -653,7 +660,7 @@ function ActiveFilters({ }); } return ( -
      +
      {t("plugins.manager.filters.results", { count: shown })} @@ -703,7 +710,7 @@ function Pager({ const per = Number(size); if (total <= Number(PAGE_SIZES[0])) return null; return ( -
      +
      {t("plugins.manager.pager.range", { from: (page - 1) * per + 1, @@ -895,7 +902,7 @@ function InstalledSection({ rail={} > {(filtersButton) => ( -
      +
      - {failed ? ( -
      - +
      + {failed ? ( - {plugin.status === "blocked" - ? t("plugins.manager.status.blocked") - : t("plugins.manager.status.failed")} - -
      - ) : !running ? ( -
      - - - {plugin.status === "server" - ? t("plugins.manager.status.server") - : plugin.status === "safeMode" - ? t("plugins.manager.status.safeMode") - : t("plugins.manager.status.disabled")} + + + {plugin.status === "blocked" + ? t("plugins.manager.status.blocked") + : t("plugins.manager.status.failed")} + -
      - ) : ( -
      - - - {plugin.pinnedVersion - ? t("plugins.manager.pinnedTo", { - version: plugin.pinnedVersion, - }) - : t("plugins.manager.status.running")} + ) : ( + + + + {!running + ? plugin.status === "server" + ? t("plugins.manager.status.server") + : plugin.status === "safeMode" + ? t("plugins.manager.status.safeMode") + : t("plugins.manager.status.disabled") + : plugin.pinnedVersion + ? t("plugins.manager.pinnedTo", { + version: plugin.pinnedVersion, + }) + : t("plugins.manager.status.running")} + -
      - )} - -
      + )} +
      {plugin.updateAvailable && !plugin.pinnedVersion && !failed && ( - +
      )} - -
      - {[...gated, ...plain, ...pinned].map((p) => ( - onOpen(p.id)} - /> - ))} -
      -
      + {[...gated, ...plain, ...pinned].map((p) => ( + onOpen(p.id)} + /> + ))} + ); } diff --git a/src/ui/settings/HostPluginSections.tsx b/src/ui/settings/HostPluginSections.tsx index d4fb2057bf..7b702d1599 100644 --- a/src/ui/settings/HostPluginSections.tsx +++ b/src/ui/settings/HostPluginSections.tsx @@ -66,7 +66,7 @@ export function HostPluginSections({ if (plugins.length === 0) return null; return ( -
      +
      {plugins.map((plugin) => ( -
      - {children} -
      +
      {children}
      ); } diff --git a/src/ui/tests/components/data-view.test.tsx b/src/ui/tests/components/data-view.test.tsx index f7a41fe809..dc6287c896 100644 --- a/src/ui/tests/components/data-view.test.tsx +++ b/src/ui/tests/components/data-view.test.tsx @@ -47,8 +47,9 @@ describe("DataView", () => { empty={null} />, ); - const grid = container.firstElementChild as HTMLElement; + const grid = container.querySelector(".grid") as HTMLElement; expect(grid.className).not.toContain("md:grid-cols-2"); + expect(grid.className).not.toContain("gap-"); expect(grid.style.gridTemplateColumns).toContain("/ 3"); }); diff --git a/src/ui/tests/components/pager.test.tsx b/src/ui/tests/components/pager.test.tsx new file mode 100644 index 0000000000..d8a3f5b9d9 --- /dev/null +++ b/src/ui/tests/components/pager.test.tsx @@ -0,0 +1,56 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + act, + cleanup, + fireEvent, + render, + renderHook, + screen, +} from "@testing-library/react"; +import "@testing-library/jest-dom/vitest"; +import { Pager, pageOf, usePaged } from "../../components/pager"; + +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ t: (key: string) => key }), + initReactI18next: { type: "3rdParty", init: () => {} }, +})); + +afterEach(cleanup); + +describe("pageOf", () => { + it("clamps the page to what exists", () => { + const items = [1, 2, 3, 4, 5]; + expect(pageOf(items, 9, 2)).toEqual({ rows: [5], page: 2, pages: 3 }); + expect(pageOf([], 0, 2)).toEqual({ rows: [], page: 0, pages: 1 }); + }); +}); + +describe("usePaged", () => { + it("goes back to the first page when reset changes", () => { + const { result, rerender } = renderHook( + ({ reset }) => usePaged([1, 2, 3], 1, reset), + { initialProps: { reset: "a" } }, + ); + act(() => result.current.setPage(2)); + expect(result.current.rows).toEqual([3]); + rerender({ reset: "b" }); + expect(result.current.page).toBe(0); + }); +}); + +describe("Pager", () => { + it("hides with one page", () => { + const { container } = render( + {}} />, + ); + expect(container).toBeEmptyDOMElement(); + }); + + it("moves between pages", () => { + const onPage = vi.fn(); + render(); + expect(screen.getByText("panel.pager.previous")).toBeDisabled(); + fireEvent.click(screen.getByText("panel.pager.next")); + expect(onPage).toHaveBeenCalledWith(1); + }); +}); diff --git a/src/ui/tests/components/status.test.tsx b/src/ui/tests/components/status.test.tsx new file mode 100644 index 0000000000..42113c4d24 --- /dev/null +++ b/src/ui/tests/components/status.test.tsx @@ -0,0 +1,43 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { cleanup, render, screen } from "@testing-library/react"; +import "@testing-library/jest-dom/vitest"; +import { StatusBadge, StatusDot, statusTone } from "../../components/status"; + +afterEach(cleanup); + +describe("statusTone", () => { + it("maps common state words", () => { + expect(statusTone("Running")).toBe("ok"); + expect(statusTone("CrashLoopBackOff")).toBe("bad"); + expect(statusTone("pending approval")).toBe("warn"); + expect(statusTone("exited")).toBe("idle"); + }); + + it("treats unknown and empty states as idle", () => { + expect(statusTone("banana")).toBe("idle"); + expect(statusTone(null)).toBe("idle"); + }); +}); + +describe("StatusBadge", () => { + it("shows the state with its tone", () => { + render(); + expect(screen.getByText("failed")).toHaveClass("text-destructive"); + }); + + it("lets a tone win over the state", () => { + render( + + Fine + , + ); + expect(screen.getByText("Fine")).toHaveClass("text-green-500"); + }); +}); + +describe("StatusDot", () => { + it("colors by state", () => { + render(); + expect(screen.getByTitle("up")).toHaveClass("bg-green-500"); + }); +}); diff --git a/src/ui/tests/components/tile-grid/TileGrid.test.tsx b/src/ui/tests/components/tile-grid/TileGrid.test.tsx index 8335910971..a71bcdd3f2 100644 --- a/src/ui/tests/components/tile-grid/TileGrid.test.tsx +++ b/src/ui/tests/components/tile-grid/TileGrid.test.tsx @@ -279,7 +279,7 @@ describe("TileGrid", () => { ).container.querySelector(".tg-content > div")!.className; expect(card()).not.toContain("bg-card"); cleanup(); - expect(card(12)).toContain("border border-border bg-card"); + expect(card(12)).toContain("border border-border"); }); it("stacks cards with no edit controls on narrow screens", () => { diff --git a/src/ui/tests/components/tile-grid/TileWall.test.tsx b/src/ui/tests/components/tile-grid/TileWall.test.tsx new file mode 100644 index 0000000000..44af98eea0 --- /dev/null +++ b/src/ui/tests/components/tile-grid/TileWall.test.tsx @@ -0,0 +1,142 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { cleanup, render, screen } from "@testing-library/react"; +import "@testing-library/jest-dom/vitest"; +import { TileWall } from "../../../components/tile-grid/TileWall"; +import { TileStack } from "../../../components/tile-grid/TileStack"; +import { TileColumn } from "../../../components/tile-grid/TileColumn"; +import { MetricCard } from "../../../components/metric-card"; +import { SectionCard } from "../../../components/section-card"; + +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ t: (key: string) => key }), + initReactI18next: { type: "3rdParty", init: () => {} }, +})); + +afterEach(cleanup); + +const cardOf = (text: string) => + screen.getByText(text).closest("[data-tile-card]") as HTMLElement; + +describe("TileWall", () => { + it("wraps each tile in a cell with the shared lines", () => { + render( + + one + {null} + two + , + ); + const cell = screen.getByText("one").parentElement!; + expect(cell).toHaveClass("border-b", "border-r"); + expect(cell.parentElement?.children).toHaveLength(2); + }); + + it("drops the box of cards inside it", () => { + render( + + + x + + , + ); + expect(cardOf("CPU")).not.toHaveClass("border"); + }); + + it("uses column classes in place of size", () => { + render( + + a + , + ); + const grid = screen.getByText("a").parentElement!.parentElement!; + expect(grid).toHaveClass("grid-cols-3"); + expect(grid.style.gridTemplateColumns).toBe(""); + }); +}); + +describe("bottom line", () => { + const edgeOf = (text: string) => + screen.getByText(text).closest(".overflow-clip")!.firstElementChild; + + it("stays when the tiles do not reach the bottom of the screen", () => { + render( + + alone + , + ); + expect(edgeOf("alone")).not.toHaveClass("-mb-px"); + }); + + it("is left to the section inside a stack", () => { + render( + + + nested + + , + ); + expect(edgeOf("nested")).toHaveClass("-mb-px"); + }); + + it("is cut off once the tiles reach the bottom of the scroll area", () => { + const rect = vi + .spyOn(HTMLElement.prototype, "getBoundingClientRect") + .mockReturnValue({ top: 0, bottom: 500 } as DOMRect); + vi.stubGlobal( + "ResizeObserver", + class { + observe() {} + disconnect() {} + }, + ); + render( +
      + + full + +
      , + ); + expect(edgeOf("full")).toHaveClass("-mb-px"); + rect.mockRestore(); + vi.unstubAllGlobals(); + }); +}); + +describe("TileStack", () => { + it("draws a line under each section", () => { + render( + + first + second + , + ); + expect(screen.getByText("first").parentElement).toHaveClass("border-b"); + expect(screen.getByText("second").parentElement).toHaveClass("border-b"); + }); +}); + +describe("cards outside a tile layout", () => { + it("keep their border but no fill", () => { + render( + + body + , + ); + const card = cardOf("Shell"); + expect(card).toHaveClass("border"); + expect(card).not.toHaveClass("bg-card"); + }); + + it("drop the border inside a TileColumn", () => { + render( + +
      + + body + +
      +
      , + ); + expect(cardOf("Nested")).not.toHaveClass("border"); + }); +}); From 4aead47049cc6f413d94afd30266da8c82740df4 Mon Sep 17 00:00:00 2001 From: LukeGus Date: Wed, 4 Nov 2026 19:07:02 -0600 Subject: [PATCH 025/106] fix: save every ui pref key changed in one burst so dashboard reset sticks --- CHANGELOG.md | 1 + src/ui/contexts/UiPreferencesContext.tsx | 18 ++++++++++++-- .../contexts/UiPreferencesContext.test.tsx | 24 +++++++++++++++++++ 3 files changed, 41 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c22935d9fa..181facccf4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -29,6 +29,7 @@ - The Secure your account setup step no longer shows up empty when no passkey or authenticator plugin is on - An installed plugin update older than the copy in a newer image is removed at startup instead of logging an error every boot - Canceling a download to the desktop app right as it starts no longer leaves a partial file behind +- Changing two interface settings at once, like resetting the dashboard, now saves both instead of only the last one ## 26.11.0 diff --git a/src/ui/contexts/UiPreferencesContext.tsx b/src/ui/contexts/UiPreferencesContext.tsx index 613a9e3555..8b4f3f9fea 100644 --- a/src/ui/contexts/UiPreferencesContext.tsx +++ b/src/ui/contexts/UiPreferencesContext.tsx @@ -179,10 +179,24 @@ export function UiPreferencesProvider({ const merged = { ...pendingPatch.current }; for (const [key, value] of Object.entries(patch)) { if (key === "overrides" && value && typeof value === "object") { - merged.overrides = { + // Two levels deep like the server, so two keys set in one area + // in the same burst both get sent. + const areas = { ...((merged.overrides as Record) ?? {}), - ...(value as Record), }; + for (const [area, keys] of Object.entries( + value as Record, + )) { + const prev = areas[area]; + areas[area] = + keys && + typeof keys === "object" && + prev && + typeof prev === "object" + ? { ...prev, ...keys } + : keys; + } + merged.overrides = areas; } else if (key === "onboarding" && value && typeof value === "object") { const prev = (merged.onboarding ?? {}) as Record; const next = value as Record; diff --git a/src/ui/tests/contexts/UiPreferencesContext.test.tsx b/src/ui/tests/contexts/UiPreferencesContext.test.tsx index 31f806cc36..3f657f3689 100644 --- a/src/ui/tests/contexts/UiPreferencesContext.test.tsx +++ b/src/ui/tests/contexts/UiPreferencesContext.test.tsx @@ -185,6 +185,30 @@ describe("UiPreferencesProvider", () => { expect(result.current?.preferences.overrides).toEqual({}); }); + it("sends every key set in one area within a burst", async () => { + vi.useFakeTimers(); + api.getUserPreferences.mockResolvedValue({ storageMode: "cloud" }); + api.getUiPreferences.mockResolvedValue({ + ...defaultUiPreferences(), + overrides: { "plugin:x": { grid: ["a"], slots: ["b"] } }, + }); + + const { result } = renderHook(() => useUiPreferencesContext(), { wrapper }); + await vi.waitFor(() => expect(result.current?.loaded).toBe(true)); + + act(() => { + result.current?.setPluginOverride("x", "grid", null); + result.current?.setPluginOverride("x", "slots", null); + }); + await act(async () => { + await vi.advanceTimersByTimeAsync(1000); + }); + + expect(api.saveUiPreferences).toHaveBeenCalledWith({ + overrides: { "plugin:x": { grid: null, slots: null } }, + }); + }); + it("prunes an area once its last override is cleared", async () => { api.getUiPreferences.mockResolvedValue({ ...defaultUiPreferences(), From 2ee31ac9ccd82e74122ebca83f34cf23b0db6dbf Mon Sep 17 00:00:00 2001 From: LukeGus Date: Wed, 4 Nov 2026 19:30:12 -0600 Subject: [PATCH 026/106] fix: honor signal refreshSec when caching signal reads --- .../database/routes/operations-routes.ts | 1 + src/backend/operations/resources.ts | 6 ++++- .../database/routes/operations-routes.test.ts | 24 +++++++++++++++++++ 3 files changed, 30 insertions(+), 1 deletion(-) diff --git a/src/backend/database/routes/operations-routes.ts b/src/backend/database/routes/operations-routes.ts index 09ca3f5531..56034d08cf 100644 --- a/src/backend/database/routes/operations-routes.ts +++ b/src/backend/database/routes/operations-routes.ts @@ -622,6 +622,7 @@ router.get("/signals", authenticateJWT, async (req, res) => { userId: authReq.userId, actionId: signal.read, hostIds, + freshMs: signal.refreshSec ? signal.refreshSec * 1000 : undefined, origin: originOf(authReq), }), }); diff --git a/src/backend/operations/resources.ts b/src/backend/operations/resources.ts index 0e5340f2ca..52a390d553 100644 --- a/src/backend/operations/resources.ts +++ b/src/backend/operations/resources.ts @@ -78,6 +78,8 @@ interface ReadOptions { actionId: string; hostIds?: number[]; refresh?: boolean; + /** How long a cached answer stays fresh. Defaults to RESOURCE_FRESH_MS. */ + freshMs?: number; input?: Record; origin: ActorOrigin; } @@ -155,7 +157,9 @@ export async function readAcrossHosts( const key = cacheKey(options.userId, options.actionId, hostId); const cached = cache.get(key); - const fresh = cached && Date.now() - cached.fetchedAt < RESOURCE_FRESH_MS; + const fresh = + cached && + Date.now() - cached.fetchedAt < (options.freshMs ?? RESOURCE_FRESH_MS); if (isOffline(hostId)) { reads.push({ diff --git a/src/backend/tests/database/routes/operations-routes.test.ts b/src/backend/tests/database/routes/operations-routes.test.ts index 158302370c..a56511803c 100644 --- a/src/backend/tests/database/routes/operations-routes.test.ts +++ b/src/backend/tests/database/routes/operations-routes.test.ts @@ -275,3 +275,27 @@ describe("GET /operations/catalog", () => { expect(response.body.actions).toEqual([{ id: "demo.restart" }]); }); }); + +describe("GET /operations/signals", () => { + it("keeps a signal's answer for its refreshSec", async () => { + const { buildCatalog } = await import("../../../operations/catalog.js"); + const { readAcrossHosts } = + await import("../../../operations/resources.js"); + vi.mocked(buildCatalog).mockResolvedValueOnce({ + actions: [], + events: [], + resources: [], + signals: [ + { id: "a", read: "demo.a", scope: "host", refreshSec: 600 }, + { id: "b", read: "demo.b", scope: "host" }, + ], + } as never); + await request(app).get("/operations/signals").expect(200); + expect(vi.mocked(readAcrossHosts)).toHaveBeenCalledWith( + expect.objectContaining({ actionId: "demo.a", freshMs: 600_000 }), + ); + expect(vi.mocked(readAcrossHosts)).toHaveBeenCalledWith( + expect.objectContaining({ actionId: "demo.b", freshMs: undefined }), + ); + }); +}); From a2308c9d712f9346eb8219865fc544ee1a75a420 Mon Sep 17 00:00:00 2001 From: LukeGus Date: Wed, 4 Nov 2026 20:03:15 -0600 Subject: [PATCH 027/106] fix: plugin page clipping and badges, match select and textarea sizes --- CHANGELOG.md | 9 ++++++++ src/ui/components/panel-layout.tsx | 6 +++--- src/ui/components/select.tsx | 4 ++-- src/ui/components/select2.tsx | 8 +++---- src/ui/components/textarea.tsx | 2 +- src/ui/look/LookSettings.tsx | 2 +- src/ui/plugins/PluginDetail.tsx | 26 ++++++++++++++--------- src/ui/plugins/PluginReleaseNotes.tsx | 6 +++--- src/ui/plugins/PluginsTab.tsx | 23 ++++++++++++-------- src/ui/plugins/plugin-bits.tsx | 15 ++++++++----- src/ui/plugins/plugin-model.ts | 5 +++++ src/ui/sidebar/AuthEnrollmentSections.tsx | 4 ++-- 12 files changed, 70 insertions(+), 40 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 181facccf4..dd551b0d06 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,8 @@ - Host status, import and export, and credential tools now start on for new installs - Sync now starts on in a new desktop app, so it can link to a server - The first run plugin picker leaves plugins you did not choose off instead of removing them +- Dropdowns and text areas are the same size as the text fields next to them +- A page's own tabs sit above its search bar ### Fixed @@ -30,6 +32,13 @@ - An installed plugin update older than the copy in a newer image is removed at startup instead of logging an error every boot - Canceling a download to the desktop app right as it starts no longer leaves a partial file behind - Changing two interface settings at once, like resetting the dashboard, now saves both instead of only the last one +- Release notes on a plugin's page are no longer cut off on the left +- The Community badge in Browse is no longer missing its top border +- Official plugin pages no longer show a stray divider before the author +- The version picker on a plugin's page is no longer empty for a plugin installed from a local build +- The plugin video no longer has a second border around it +- The hint at the bottom of Browse and the stored data footer on plugin pages have the same padding as the rest of the page +- Sign in methods on the Security page have a line between them ## 26.11.0 diff --git a/src/ui/components/panel-layout.tsx b/src/ui/components/panel-layout.tsx index 9a09ea04ae..c90d384acc 100644 --- a/src/ui/components/panel-layout.tsx +++ b/src/ui/components/panel-layout.tsx @@ -118,6 +118,9 @@ export function PanelShell({ const bands = ( <> + {tabs && ( +
      {tabs}
      + )} {toolbar && (
      )} - {tabs && ( -
      {tabs}
      - )} ); diff --git a/src/ui/components/select.tsx b/src/ui/components/select.tsx index 9207fe81c1..1ac0f2ab34 100644 --- a/src/ui/components/select.tsx +++ b/src/ui/components/select.tsx @@ -35,7 +35,7 @@ function SelectTrigger({ data-slot="select-trigger" data-size={size} className={cn( - "border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-fit items-center justify-between gap-2 rounded-none border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", + "border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-fit items-center justify-between gap-2 rounded-none border bg-transparent px-2.5 py-1 text-xs whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-8 data-[size=sm]:h-7 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", className, )} {...props} @@ -105,7 +105,7 @@ function SelectItem({ { @@ -189,7 +189,7 @@ function Select2({ value={query} onChange={(event) => setQuery(event.target.value)} placeholder={t("common.search")} - className="h-9 w-full bg-transparent text-sm outline-none placeholder:text-muted-foreground" + className="h-8 w-full bg-transparent text-xs outline-none placeholder:text-muted-foreground" autoFocus />
      @@ -198,7 +198,7 @@ function Select2({ className="max-h-64 overflow-y-auto p-1 thin-scrollbar" > {filteredOptions.length === 0 ? ( -
      +
      {t("common.noResults")}
      ) : ( @@ -222,7 +222,7 @@ function Select2({ aria-selected={isSelected} disabled={option.disabled} className={cn( - "flex w-full items-center gap-2 rounded-none px-2 py-1.5 text-left text-sm outline-none transition-colors hover:bg-muted disabled:pointer-events-none disabled:opacity-50", + "flex w-full items-center gap-2 rounded-none px-2 py-1.5 text-left text-xs outline-none transition-colors hover:bg-muted disabled:pointer-events-none disabled:opacity-50", isSelected && "bg-muted text-accent-foreground", )} onClick={() => selectValue(option.value)} diff --git a/src/ui/components/textarea.tsx b/src/ui/components/textarea.tsx index 0293c3af83..3c5f1d457d 100644 --- a/src/ui/components/textarea.tsx +++ b/src/ui/components/textarea.tsx @@ -9,7 +9,7 @@ const Textarea = React.forwardRef( return (