feat: redesign user host defaults system

This commit is contained in:
LukeGus committed 2026-09-30 06:19:56 -05:00
1 parent 3028ab1176
commit a4a4d4f27b
144 files changed
+24609 -2555

No files matched your search

@@ -0,0 +1,20 @@
CREATE TABLE `host_defaults` (
`id` int AUTO_INCREMENT NOT NULL,
`level` enum('admin','user','folder') NOT NULL,
`scope_key` varchar(255) NOT NULL,
`user_id` varchar(255),
`folder_id` int,
`namespace` varchar(255) NOT NULL,
`key` varchar(255) NOT NULL,
`value` text,
`updated_by` text,
`updated_at` text NOT NULL DEFAULT (CURRENT_TIMESTAMP),
CONSTRAINT `host_defaults_id` PRIMARY KEY(`id`),
CONSTRAINT `idx_host_defaults_scope_key` UNIQUE(`scope_key`,`namespace`,`key`)
);
--> statement-breakpoint
ALTER TABLE `ssh_data` ADD `default_overrides` text;--> statement-breakpoint
ALTER TABLE `host_defaults` ADD CONSTRAINT `host_defaults_user_id_users_id_fk` FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE `host_defaults` ADD CONSTRAINT `host_defaults_folder_id_ssh_folders_id_fk` FOREIGN KEY (`folder_id`) REFERENCES `ssh_folders`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
CREATE INDEX `idx_host_defaults_user` ON `host_defaults` (`user_id`);--> statement-breakpoint
CREATE INDEX `idx_host_defaults_folder` ON `host_defaults` (`folder_id`);
File diff suppressed because it is too large. Load diff
+7
View File
@@ -456,6 +456,13 @@
"when": 1790704531888,
"tag": "0064_flippant_killer_shrike",
"breakpoints": true
},
{
"idx": 65,
"version": "5",
"when": 1790742086934,
"tag": "0065_ambiguous_agent_zero",
"breakpoints": true
}
]
}
@@ -0,0 +1,19 @@
CREATE TABLE "host_defaults" (
"id" serial PRIMARY KEY NOT NULL,
"level" text NOT NULL,
"scope_key" varchar(255) NOT NULL,
"user_id" varchar(255),
"folder_id" integer,
"namespace" varchar(255) NOT NULL,
"key" varchar(255) NOT NULL,
"value" text,
"updated_by" text,
"updated_at" text DEFAULT CURRENT_TIMESTAMP NOT NULL
);
--> statement-breakpoint
ALTER TABLE "ssh_data" ADD COLUMN "default_overrides" text;--> statement-breakpoint
ALTER TABLE "host_defaults" ADD CONSTRAINT "host_defaults_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "host_defaults" ADD CONSTRAINT "host_defaults_folder_id_ssh_folders_id_fk" FOREIGN KEY ("folder_id") REFERENCES "public"."ssh_folders"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
CREATE UNIQUE INDEX "idx_host_defaults_scope_key" ON "host_defaults" USING btree ("scope_key","namespace","key");--> statement-breakpoint
CREATE INDEX "idx_host_defaults_user" ON "host_defaults" USING btree ("user_id");--> statement-breakpoint
CREATE INDEX "idx_host_defaults_folder" ON "host_defaults" USING btree ("folder_id");
File diff suppressed because it is too large. Load diff
+7
View File
@@ -456,6 +456,13 @@
"when": 1790704531255,
"tag": "0064_sticky_pestilence",
"breakpoints": true
},
{
"idx": 65,
"version": "7",
"when": 1790742086298,
"tag": "0065_regular_joystick",
"breakpoints": true
}
]
}
+19
View File
@@ -0,0 +1,19 @@
CREATE TABLE `host_defaults` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`level` text NOT NULL,
`scope_key` text NOT NULL,
`user_id` text,
`folder_id` integer,
`namespace` text NOT NULL,
`key` text NOT NULL,
`value` text,
`updated_by` text,
`updated_at` text DEFAULT CURRENT_TIMESTAMP NOT NULL,
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE cascade,
FOREIGN KEY (`folder_id`) REFERENCES `ssh_folders`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE UNIQUE INDEX `idx_host_defaults_scope_key` ON `host_defaults` (`scope_key`,`namespace`,`key`);--> statement-breakpoint
CREATE INDEX `idx_host_defaults_user` ON `host_defaults` (`user_id`);--> statement-breakpoint
CREATE INDEX `idx_host_defaults_folder` ON `host_defaults` (`folder_id`);--> statement-breakpoint
ALTER TABLE `ssh_data` ADD `default_overrides` text;
File diff suppressed because it is too large. Load diff
+7
View File
@@ -421,6 +421,13 @@
"when": 1790704530590,
"tag": "0059_adorable_wrecking_crew",
"breakpoints": true
},
{
"idx": 60,
"version": "6",
"when": 1790742085632,
"tag": "0060_fine_cerise",
"breakpoints": true
}
]
}
@@ -903,7 +903,21 @@
"defaultFrom": {
"type": "string",
"minLength": 1,
"description": "A non-secret admin field whose value a new host starts with."
"description": "Deprecated and ignored. Host defaults cover every host field."
},
"defaultable": {
"type": "boolean",
"description": "Whether the field can have a host default. On by default for non-secret fields."
},
"defaultLevels": {
"type": "array",
"minItems": 1,
"items": { "enum": ["admin", "user", "folder"] },
"description": "Levels that may set a default for the field. All three by default."
},
"personal": {
"type": "boolean",
"description": "A look-and-feel value: a user a host is shared with gets their own default for it."
},
"shareRead": {
"type": "string",
+38 -2
View File
@@ -500,15 +500,44 @@ export interface PluginSettings {
getUser: <T = unknown>(userId: string, key: string) => Promise<T | undefined>;
setUser: (userId: string, key: string, value: unknown) => Promise<void>;
/** Per-host settings. */
/**
* Per-host settings. A host follows its host defaults (server, user,
* folder) for every key it has not set itself, and the value read here is
* already the resolved one.
*/
getHost: <T = unknown>(
hostId: number | string,
key: string,
) => Promise<T | undefined>;
/**
* The value as one user sees it. Only a `personal` field differs from
* getHost: on a host shared with that user and following its defaults, it
* resolves against the user's own defaults rather than the owner's.
*/
getHostFor: <T = unknown>(
hostId: number | string,
userId: string,
key: string,
) => Promise<T | undefined>;
/**
* What a user's hosts get for a host field when nothing more specific
* sets it: their own defaults, then the server's, then the manifest
* default. For something with no host, such as a quick connect.
*/
getHostDefault: <T = unknown>(
userId: string,
key: string,
) => Promise<T | undefined>;
/**
* Writes a host value, which makes it the host's own rather than
* following its defaults. `{ inherit: true }` hands the key back to the
* defaults instead and ignores `value`.
*/
setHost: (
hostId: number | string,
key: string,
value: unknown,
options?: { inherit?: boolean },
) => Promise<void>;
/**
@@ -542,7 +571,14 @@ export interface PluginSettings {
scope: "admin" | "user" | "host",
validator: (
values: Record<string, unknown>,
context: { hostId?: number },
/**
* `defaults` is set when the values are a host defaults level being
* saved rather than one host, so there is no hostId.
*/
context: {
hostId?: number;
defaults?: { level: "admin" | "user" | "folder" };
},
) => Record<string, string> | void | Promise<Record<string, string> | void>,
) => () => void;
+14
View File
@@ -403,6 +403,13 @@ export interface HostEditorSectionProps {
adminTargetUserId?: string;
/** Which connection protocols are switched on for this host. */
protocols: Record<string, boolean>;
/**
* "defaults" while the editor sets a level of host defaults (server, user
* or folder) rather than one host. There is no `host` then, and the form
* holds the defaults. Only a section registered with `defaults: true` is
* shown in that mode.
*/
mode?: "host" | "defaults";
}
export interface HostEditorSectionContribution {
@@ -418,6 +425,13 @@ export interface HostEditorSectionContribution {
order?: number;
/** Whether to offer the tab, from the host's enabled protocols. */
visible?: (protocols: Record<string, boolean>) => boolean;
/**
* Also shown in the host defaults editor. Only for a section whose fields
* are this plugin's host settings kept on the form, and that makes no
* calls about one particular host. Off by default. "only" shows it in the
* defaults editor and never for a host.
*/
defaults?: boolean | "only";
component: ComponentType<HostEditorSectionProps>;
}
+57 -15
View File
@@ -276,11 +276,30 @@ export interface PluginSettingsField {
/** Registered component id. Required when type is "custom". */
component?: string;
/**
* Host scope only: the admin field whose value a new host starts with,
* instead of `default`. Hosts that never saved the field still read
* `default`.
* Deprecated and ignored: host defaults cover every host field now. Still
* accepted so an older manifest loads. Removed in 3.0.0.
*/
defaultFrom?: string;
/**
* Host scope only: whether the field can have a host default (server,
* user or folder) that hosts follow until they set their own. On by
* default for every field except secrets and json fields with
* `secretKeys`. Turn it off for a value that only makes sense per host,
* such as a MAC address or an API endpoint.
*/
defaultable?: boolean;
/**
* Host scope only: the levels that may set a default for it. Defaults to
* all three. Leave "admin" out for a value that points at one user's own
* rows (a snippet id, a profile id).
*/
defaultLevels?: Array<"admin" | "user" | "folder">;
/**
* Host scope only: a look-and-feel value. When the host follows its
* defaults, a user it is shared with gets their own defaults for it
* rather than the owner's.
*/
personal?: boolean;
/**
* Host scope only: the lowest share level whose recipients see this value
* in the host payload. Defaults to "connect", everyone who sees the host.
@@ -626,6 +645,9 @@ const ALLOWED_SETTINGS_FIELD = [
"component",
"hidden",
"defaultFrom",
"defaultable",
"defaultLevels",
"personal",
"shareRead",
"ownerOnly",
"secretKeys",
@@ -1589,20 +1611,33 @@ function validateSettings(settings: unknown, errors: string[]): void {
typeof host.enableKey === "string" ? host.enableKey : undefined,
);
const adminKeys = new Set(
(Array.isArray(settings.admin) ? settings.admin : [])
.filter(isPlainObject)
// A secret never becomes a default the host editor can read.
.filter((field) => field.type !== "secret")
.map((field) => field.key),
);
host.fields.forEach((raw, index) => {
if (!isPlainObject(raw)) return;
const fieldAt = `${at}.fields[${index}]`;
if ("defaultFrom" in raw && !adminKeys.has(raw.defaultFrom as string)) {
errors.push(
`${fieldAt}.defaultFrom must name one of this plugin's non-secret admin fields`,
);
if ("defaultFrom" in raw && typeof raw.defaultFrom !== "string") {
errors.push(`${fieldAt}.defaultFrom must be a string`);
}
for (const flag of ["defaultable", "personal"]) {
if (flag in raw && typeof raw[flag] !== "boolean") {
errors.push(`${fieldAt}.${flag} must be a boolean`);
}
}
if ("defaultLevels" in raw) {
const levels = raw.defaultLevels;
if (
!Array.isArray(levels) ||
levels.length === 0 ||
levels.some(
(level) => !["admin", "user", "folder"].includes(level as string),
)
) {
errors.push(
`${fieldAt}.defaultLevels must be a non-empty array of "admin", "user" or "folder"`,
);
}
}
if (raw.type === "secret" && raw.defaultable === true) {
errors.push(`${fieldAt} is a secret and cannot be defaultable`);
}
if (
"shareRead" in raw &&
@@ -1634,7 +1669,14 @@ function validateSettings(settings: unknown, errors: string[]): void {
if (!Array.isArray(fields)) continue;
fields.forEach((raw, index) => {
if (!isPlainObject(raw)) return;
for (const key of ["defaultFrom", "shareRead", "ownerOnly"]) {
for (const key of [
"defaultFrom",
"defaultable",
"defaultLevels",
"personal",
"shareRead",
"ownerOnly",
]) {
if (key in raw) {
errors.push(
`${where}.${scope}[${index}].${key} is only valid on host fields`,
+10 -2
View File
@@ -826,8 +826,16 @@ export function createFakeContext(
getHost: async (hostId, key) =>
settings.get(settingsKey("host", String(hostId), key)) as never,
setHost: async (hostId, key, value) =>
writeSetting("host", String(hostId), key, value),
getHostFor: async (hostId, _userId, key) =>
settings.get(settingsKey("host", String(hostId), key)) as never,
// Host defaults live in core; the mock stands in the user's own
// setting of the same key, so a test can set one with setUser.
getHostDefault: async (userId, key) =>
settings.get(settingsKey("user", userId, key)) as never,
setHost: async (hostId, key, value, options) =>
options?.inherit
? void settings.delete(settingsKey("host", String(hostId), key))
: writeSetting("host", String(hostId), key, value),
listHostValues: async (key) => {
const found: Array<{ hostId: number; userId: string; value: never }> =
+4 -1
View File
@@ -72,6 +72,8 @@ GridCardCatalogEntry (type): GridCardCatalogEntry
GridColSpan (type): GridColSpan
GridLayout (type): GridLayout
GridSlot (type): GridSlot
HostDefaultBadge (value): ({ settingKey }: { settingKey: string; }) => Element
HostDefaultField (value): ({ settingKey, children, }: { settingKey: string; children: ReactNode; }) => Element
HostFeatureFields (value): ({ form, updateForm, }: HostFeatureFieldsProps) => Element
HostFeatureFieldsProps (type): HostFeatureFieldsProps
HostKeyVerificationDialog (value): ({ isOpen, scenario, ip, port, hostname, fingerprint, oldFingerprint, algorithm, onAccept, onReject, backgroundColor, }: HostKeyVerificationDialogProps) => Element
@@ -121,7 +123,7 @@ SelectSeparator (value): ({ className, ...props }: SelectSeparatorProps & RefAtt
SelectTrigger (value): ({ className, size, children, ...props }: SelectTriggerProps & RefAttributes<HTMLButtonElement> & { size?: "default" | "sm"; }) => Element
SelectValue (value): ({ ...props }: SelectValueProps & RefAttributes<HTMLSpanElement>) => Element
Separator (value): ({ className, orientation, decorative, ...props }: SeparatorProps & RefAttributes<HTMLDivElement>) => Element
SettingRow (value): ({ label, badge, description, children, }: { label: string; badge?: string; description?: ReactNode; children: ReactNode; }) => Element
SettingRow (value): ({ label, badge, description, defaultKey, children, }: { label: string; badge?: string; description?: ReactNode; defaultKey?: string; children: ReactNode; }) => Element
Sheet (value): ({ ...props }: DialogProps) => Element
SheetClose (value): ({ ...props }: DialogCloseProps & RefAttributes<HTMLButtonElement>) => Element
SheetContent (value): ({ className, children, side, showCloseButton, ...props }: DialogContentProps & RefAttributes<HTMLDivElement> & { side?: "top" | "right" | "bottom" | "left"; showCloseButton?: boolean; }) => Element
@@ -179,6 +181,7 @@ useAdaptivePolling (value): (poll: () => AdaptivePollResult | Promise<AdaptivePo
useAppTheme (value): () => ThemeProviderState
useConfirmation (value): () => { isOpen: boolean; options: ConfirmationOptions; confirm: (opts: ConfirmationOptions, callback: () => void) => void; handleConfirm: () => void; handleCancel: () => void; confirmWithToast: (opts: string | ConfirmationOptions, callback?: () => void, variantOrConfirmLabel?: string, cancelLabel?: string, toastOptions?: ToastConfirmOptions) => Promise<boolean>; }
useConnectionLog (value): () => ConnectionLogContextType
useIsDefaultsEditor (value): () => boolean
useIsMobile (value): () => boolean
useOptionalConnectionLog (value): () => ConnectionLogContextType
useTabs (value): () => TabContextType
@@ -102,25 +102,6 @@ export function registerRoutes(router: Router, deps: RouteDeps): void {
poller.registerViewer(hostId, sessionId, userId),
};
/**
* @openapi
* /plugin-api/host-metrics/defaults:
* get:
* summary: Get defaults for new hosts
* description: Whether a new host starts with metrics on, an admin setting any user creating a host needs.
* tags:
* - Host Metrics
* responses:
* 200:
* description: The defaults.
*/
router.get("/defaults", async (_req, res) => {
res.json({
enabledForNewHosts:
(await ctx.settings.get<boolean>("enabledForNewHosts")) !== false,
});
});
/**
* @openapi
* /plugin-api/host-metrics/metrics/{id}:
@@ -1,13 +1,14 @@
import {
Button,
HostDefaultBadge,
Input,
SectionCard,
SettingRow,
FakeSwitch,
useIsDefaultsEditor,
} from "@termix/plugin-sdk/ui";
import { useEffect, useState } from "react";
import { useState } from "react";
import {
usePluginApi,
useTranslation,
type HostEditorSectionProps,
} from "@termix/plugin-sdk/frontend";
@@ -24,11 +25,7 @@ type PluginSettingsBag = Record<string, Record<string, unknown>>;
* host settings, carried on form.pluginSettings and saved by the editor after
* the host itself.
*/
export function HostStatsTab({
form,
updateForm,
host,
}: HostEditorSectionProps) {
export function HostStatsTab({ form, updateForm }: HostEditorSectionProps) {
const { t } = useTranslation();
const [newMount, setNewMount] = useState("");
const [newMonitoredPath, setNewMonitoredPath] = useState("");
@@ -38,7 +35,7 @@ export function HostStatsTab({
];
const settings = readHostMetricsSettings(bag);
const { excludedMounts, monitoredMounts } = settings;
const api = usePluginApi();
const defaultsEditor = useIsDefaultsEditor();
const patch = (values: Record<string, unknown>) =>
updateForm((current) => {
@@ -52,23 +49,6 @@ export function HostStatsTab({
};
});
// A new host starts with the admin's default for metrics.
useEffect(() => {
if (host || bag?.metricsEnabled !== undefined) return;
let cancelled = false;
api
.get<{ enabledForNewHosts: boolean }>("/defaults")
.then(({ data }) => {
if (!cancelled && data.enabledForNewHosts === false) {
patch({ metricsEnabled: false });
}
})
.catch(() => {});
return () => {
cancelled = true;
};
}, [host]);
const addExcludedMount = () => {
const value = newMount.trim();
if (!value || excludedMounts.includes(value)) {
@@ -102,6 +82,7 @@ export function HostStatsTab({
<SettingRow
label={t("hosts.enableMetricsLabel")}
description={t("hosts.enableMetricsDesc")}
defaultKey="metricsEnabled"
>
<FakeSwitch
checked={settings.metricsEnabled}
@@ -112,6 +93,7 @@ export function HostStatsTab({
<SettingRow
label={t("hosts.useGlobalMetrics")}
description={t("hosts.useGlobalMetricsDesc")}
defaultKey="metricsInterval"
>
<FakeSwitch
checked={settings.metricsInterval === null}
@@ -141,8 +123,9 @@ export function HostStatsTab({
icon={<HardDrive className="size-3.5" />}
>
<div className="flex flex-col gap-3 py-3">
<p className="text-xs text-muted-foreground">
<p className="flex items-center gap-1.5 text-xs text-muted-foreground">
{t("hosts.monitoredMountsDesc")}
<HostDefaultBadge settingKey="monitoredMounts" />
</p>
<div className="grid grid-cols-[1fr_0.7fr_auto] gap-2">
<Input
@@ -212,8 +195,9 @@ export function HostStatsTab({
icon={<HardDrive className="size-3.5" />}
>
<div className="flex flex-col gap-3 py-3">
<p className="text-xs text-muted-foreground">
<p className="flex items-center gap-1.5 text-xs text-muted-foreground">
{t("hosts.excludedMountsDesc")}
<HostDefaultBadge settingKey="excludedMounts" />
</p>
<div className="flex items-center gap-2">
<Input
@@ -265,16 +249,18 @@ export function HostStatsTab({
))}
</div>
</SectionCard>
<SectionCard
title={t("hosts.visibleWidgets")}
icon={<LayoutDashboard className="size-3.5" />}
>
<div className="flex flex-col gap-2 py-3">
<p className="text-xs text-muted-foreground">
{t("hosts.widgetsMovedToHostMetrics")}
</p>
</div>
</SectionCard>
{!defaultsEditor && (
<SectionCard
title={t("hosts.visibleWidgets")}
icon={<LayoutDashboard className="size-3.5" />}
>
<div className="flex flex-col gap-2 py-3">
<p className="text-xs text-muted-foreground">
{t("hosts.widgetsMovedToHostMetrics")}
</p>
</div>
</SectionCard>
)}
</>
);
}
@@ -96,6 +96,7 @@ export async function activate(app: TermixApp): Promise<void> {
titleKey: "hosts.tabHostMetrics",
icon: Activity,
order: 70,
defaults: true,
component: MetricsHostSection,
});
@@ -35,19 +35,6 @@ describe("permissions", () => {
);
});
describe("defaults", () => {
it("tells any user whether new hosts start with metrics on", async () => {
server = await startServer();
expect((await server.request("GET", "/defaults")).body).toEqual({
enabledForNewHosts: true,
});
await server.mock.ctx.settings.set("enabledForNewHosts", false);
expect((await server.request("GET", "/defaults")).body).toEqual({
enabledForNewHosts: false,
});
});
});
describe("metrics", () => {
it("answers 404 until a sample exists", async () => {
server = await startServer();
+8 -4
View File
@@ -39,22 +39,26 @@
{
"key": "enableProxmox",
"type": "custom",
"component": "proxmoxHostSettings"
"component": "proxmoxHostSettings",
"defaultable": false
},
{
"key": "proxmoxConfig",
"type": "custom",
"component": "proxmoxHostSettings"
"component": "proxmoxHostSettings",
"defaultable": false
},
{
"key": "enableProxmoxStats",
"type": "custom",
"component": "proxmoxHostSettings"
"component": "proxmoxHostSettings",
"defaultable": false
},
{
"key": "proxmoxStatsConfig",
"type": "custom",
"component": "proxmoxHostSettings"
"component": "proxmoxHostSettings",
"defaultable": false
}
]
}
+3 -1
View File
@@ -323,7 +323,9 @@
"enableToolbar": {
"label": "Remote desktop toolbar",
"description": "Show the toolbar in RDP, VNC and Telnet sessions."
}
},
"displayGroup": "Display",
"displayDefaultsTab": "Remote Desktop"
}
},
"palette": {
+293 -18
View File
@@ -150,7 +150,8 @@
"labelKey": "settings.user.colorDepth32"
}
],
"default": "inherit"
"default": "inherit",
"hidden": true
},
{
"key": "resizeMethod",
@@ -172,7 +173,8 @@
"labelKey": "settings.user.resizeReconnect"
}
],
"default": "inherit"
"default": "inherit",
"hidden": true
},
{
"key": "forceLossless",
@@ -194,7 +196,8 @@
"labelKey": "settings.user.off"
}
],
"default": "inherit"
"default": "inherit",
"hidden": true
},
{
"key": "enableWallpaper",
@@ -216,7 +219,8 @@
"labelKey": "settings.user.off"
}
],
"default": "inherit"
"default": "inherit",
"hidden": true
},
{
"key": "enableFontSmoothing",
@@ -238,7 +242,8 @@
"labelKey": "settings.user.off"
}
],
"default": "inherit"
"default": "inherit",
"hidden": true
},
{
"key": "enableDesktopComposition",
@@ -260,7 +265,8 @@
"labelKey": "settings.user.off"
}
],
"default": "inherit"
"default": "inherit",
"hidden": true
},
{
"key": "disableAudio",
@@ -282,7 +288,8 @@
"labelKey": "settings.user.off"
}
],
"default": "inherit"
"default": "inherit",
"hidden": true
},
{
"key": "enablePrinting",
@@ -304,7 +311,8 @@
"labelKey": "settings.user.off"
}
],
"default": "inherit"
"default": "inherit",
"hidden": true
},
{
"key": "enableDrive",
@@ -326,7 +334,8 @@
"labelKey": "settings.user.off"
}
],
"default": "inherit"
"default": "inherit",
"hidden": true
},
{
"key": "disableCopy",
@@ -348,7 +357,8 @@
"labelKey": "settings.user.off"
}
],
"default": "inherit"
"default": "inherit",
"hidden": true
},
{
"key": "disablePaste",
@@ -370,7 +380,8 @@
"labelKey": "settings.user.off"
}
],
"default": "inherit"
"default": "inherit",
"hidden": true
}
],
"host": {
@@ -380,21 +391,24 @@
"type": "boolean",
"labelKey": "settings.host.enableRdp.label",
"default": false,
"hidden": true
"hidden": true,
"defaultable": false
},
{
"key": "enableVnc",
"type": "boolean",
"labelKey": "settings.host.enableVnc.label",
"default": false,
"hidden": true
"hidden": true,
"defaultable": false
},
{
"key": "enableTelnet",
"type": "boolean",
"labelKey": "settings.host.enableTelnet.label",
"default": false,
"hidden": true
"hidden": true,
"defaultable": false
},
{
"key": "rdpPort",
@@ -403,7 +417,8 @@
"min": 1,
"max": 65535,
"default": 3389,
"hidden": true
"hidden": true,
"defaultable": false
},
{
"key": "vncPort",
@@ -412,7 +427,8 @@
"min": 1,
"max": 65535,
"default": 5900,
"hidden": true
"hidden": true,
"defaultable": false
},
{
"key": "telnetPort",
@@ -421,7 +437,8 @@
"min": 1,
"max": 65535,
"default": 23,
"hidden": true
"hidden": true,
"defaultable": false
},
{
"key": "rdpSecurity",
@@ -444,7 +461,8 @@
"default": {},
"hidden": true,
"shareRead": "edit",
"secretKeys": ["gateway-password"]
"secretKeys": ["gateway-password"],
"defaultable": false
},
{
"key": "enableToolbar",
@@ -453,6 +471,263 @@
"descriptionKey": "settings.host.enableToolbar.description",
"default": true,
"hidden": true
},
{
"key": "colorDepth",
"type": "select",
"labelKey": "settings.user.colorDepth.label",
"descriptionKey": "settings.user.colorDepth.description",
"group": "settings.host.displayGroup",
"options": [
{
"value": "inherit",
"labelKey": "settings.user.inherit"
},
{
"value": "16",
"labelKey": "settings.user.colorDepth16"
},
{
"value": "24",
"labelKey": "settings.user.colorDepth24"
},
{
"value": "32",
"labelKey": "settings.user.colorDepth32"
}
],
"default": "inherit",
"personal": true
},
{
"key": "resizeMethod",
"type": "select",
"labelKey": "settings.user.resizeMethod.label",
"descriptionKey": "settings.user.resizeMethod.description",
"group": "settings.host.displayGroup",
"options": [
{
"value": "inherit",
"labelKey": "settings.user.inherit"
},
{
"value": "display-update",
"labelKey": "settings.user.resizeDisplayUpdate"
},
{
"value": "reconnect",
"labelKey": "settings.user.resizeReconnect"
}
],
"default": "inherit",
"personal": true
},
{
"key": "forceLossless",
"type": "select",
"labelKey": "settings.user.forceLossless.label",
"descriptionKey": "settings.user.forceLossless.description",
"group": "settings.host.displayGroup",
"options": [
{
"value": "inherit",
"labelKey": "settings.user.inherit"
},
{
"value": "on",
"labelKey": "settings.user.on"
},
{
"value": "off",
"labelKey": "settings.user.off"
}
],
"default": "inherit",
"personal": true
},
{
"key": "enableWallpaper",
"type": "select",
"labelKey": "settings.user.enableWallpaper.label",
"descriptionKey": "settings.user.enableWallpaper.description",
"group": "settings.host.displayGroup",
"options": [
{
"value": "inherit",
"labelKey": "settings.user.inherit"
},
{
"value": "on",
"labelKey": "settings.user.on"
},
{
"value": "off",
"labelKey": "settings.user.off"
}
],
"default": "inherit",
"personal": true
},
{
"key": "enableFontSmoothing",
"type": "select",
"labelKey": "settings.user.enableFontSmoothing.label",
"descriptionKey": "settings.user.enableFontSmoothing.description",
"group": "settings.host.displayGroup",
"options": [
{
"value": "inherit",
"labelKey": "settings.user.inherit"
},
{
"value": "on",
"labelKey": "settings.user.on"
},
{
"value": "off",
"labelKey": "settings.user.off"
}
],
"default": "inherit",
"personal": true
},
{
"key": "enableDesktopComposition",
"type": "select",
"labelKey": "settings.user.enableDesktopComposition.label",
"descriptionKey": "settings.user.enableDesktopComposition.description",
"group": "settings.host.displayGroup",
"options": [
{
"value": "inherit",
"labelKey": "settings.user.inherit"
},
{
"value": "on",
"labelKey": "settings.user.on"
},
{
"value": "off",
"labelKey": "settings.user.off"
}
],
"default": "inherit",
"personal": true
},
{
"key": "disableAudio",
"type": "select",
"labelKey": "settings.user.disableAudio.label",
"descriptionKey": "settings.user.disableAudio.description",
"group": "settings.host.displayGroup",
"options": [
{
"value": "inherit",
"labelKey": "settings.user.inherit"
},
{
"value": "on",
"labelKey": "settings.user.on"
},
{
"value": "off",
"labelKey": "settings.user.off"
}
],
"default": "inherit",
"personal": true
},
{
"key": "enablePrinting",
"type": "select",
"labelKey": "settings.user.enablePrinting.label",
"descriptionKey": "settings.user.enablePrinting.description",
"group": "settings.host.displayGroup",
"options": [
{
"value": "inherit",
"labelKey": "settings.user.inherit"
},
{
"value": "on",
"labelKey": "settings.user.on"
},
{
"value": "off",
"labelKey": "settings.user.off"
}
],
"default": "inherit",
"personal": true
},
{
"key": "enableDrive",
"type": "select",
"labelKey": "settings.user.enableDrive.label",
"descriptionKey": "settings.user.enableDrive.description",
"group": "settings.host.displayGroup",
"options": [
{
"value": "inherit",
"labelKey": "settings.user.inherit"
},
{
"value": "on",
"labelKey": "settings.user.on"
},
{
"value": "off",
"labelKey": "settings.user.off"
}
],
"default": "inherit",
"personal": true
},
{
"key": "disableCopy",
"type": "select",
"labelKey": "settings.user.disableCopy.label",
"descriptionKey": "settings.user.disableCopy.description",
"group": "settings.host.displayGroup",
"options": [
{
"value": "inherit",
"labelKey": "settings.user.inherit"
},
{
"value": "on",
"labelKey": "settings.user.on"
},
{
"value": "off",
"labelKey": "settings.user.off"
}
],
"default": "inherit",
"personal": true
},
{
"key": "disablePaste",
"type": "select",
"labelKey": "settings.user.disablePaste.label",
"descriptionKey": "settings.user.disablePaste.description",
"group": "settings.host.displayGroup",
"options": [
{
"value": "inherit",
"labelKey": "settings.user.inherit"
},
{
"value": "on",
"labelKey": "settings.user.on"
},
{
"value": "off",
"labelKey": "settings.user.off"
}
],
"default": "inherit",
"personal": true
}
]
}
@@ -177,7 +177,7 @@ export function hostPayloadLegacy(
return Object.keys(out).length > 0 ? out : null;
}
/** User setting key to the guacd parameter it sets. */
/** Display field key to the guacd parameter it sets. */
const USER_DEFAULT_PARAMS: Record<string, string> = {
colorDepth: "color-depth",
resizeMethod: "resize-method",
@@ -193,8 +193,8 @@ const USER_DEFAULT_PARAMS: Record<string, string> = {
};
/**
* The user's RDP defaults as guacd parameters. "inherit" leaves a parameter
* to the host and guacd; on and off become booleans.
* Display settings as guacd parameters. "inherit" leaves a parameter to the
* host and guacd; on and off become booleans.
*/
export function userDefaultParams(
values: Record<string, unknown>,
@@ -215,9 +215,22 @@ export function userDefaultParams(
return params;
}
export async function readUserDefaults(
/**
* The display settings a session runs with, as guacd parameters: the host's
* as this user sees them (their own defaults while the host follows its
* defaults), or with no host, this user's defaults.
*/
export async function readDisplayDefaults(
ctx: PluginContext,
userId: string,
hostId?: number,
): Promise<Record<string, unknown>> {
return userDefaultParams(await ctx.settings.getAll("user", userId));
const values: Record<string, unknown> = {};
for (const key of Object.keys(USER_DEFAULT_PARAMS)) {
values[key] =
hostId === undefined
? await ctx.settings.getHostDefault(userId, key)
: await ctx.settings.getHostFor(hostId, userId, key);
}
return userDefaultParams(values);
}
+9 -5
View File
@@ -23,7 +23,7 @@ import {
PORT_KEY,
isRemoteProtocol,
readHostSettings,
readUserDefaults,
readDisplayDefaults,
type RemoteProtocol,
} from "./host-settings.js";
import { errorMessage, type RemoteDesktopLogger } from "./log.js";
@@ -203,7 +203,7 @@ export function registerRoutes(router: Router, deps: RouteDeps): void {
const userId = ctx.currentActor();
if (type === "rdp" && userId) {
for (const [key, value] of Object.entries(
await readUserDefaults(ctx, userId),
await readDisplayDefaults(ctx, userId),
)) {
if (isServerOwnedSetting(key)) continue;
if (options[key] === undefined) options[key] = value;
@@ -319,9 +319,11 @@ export function registerRoutes(router: Router, deps: RouteDeps): void {
});
}
// The user's RDP defaults sit under whatever the host sets itself.
// The display settings sit under whatever the host's guacd settings say.
const { config, guacdOverrides } = cleanGuacConfig({
...(protocol === "rdp" ? await readUserDefaults(ctx, userId) : {}),
...(protocol === "rdp"
? await readDisplayDefaults(ctx, userId, hostId)
: {}),
...settings.guacamoleConfig,
});
let guacConfig = config;
@@ -545,7 +547,9 @@ export function registerRoutes(router: Router, deps: RouteDeps): void {
.json({ error: "Protocol is not enabled for this host" });
}
const { guacdOverrides } = cleanGuacConfig({
...(protocol === "rdp" ? await readUserDefaults(ctx, userId) : {}),
...(protocol === "rdp"
? await readDisplayDefaults(ctx, userId, hostId)
: {}),
...settings.guacamoleConfig,
});
guacd = {
@@ -17,6 +17,7 @@ import {
} from "@termix/plugin-sdk/frontend";
import {
FakeSwitch,
HostFeatureFields,
SectionCard,
SettingRow,
isElectron,
@@ -166,6 +167,7 @@ function ToolbarCard({
<SettingRow
label={t("settings.host.enableToolbar.label")}
description={t("settings.host.enableToolbar.description")}
defaultKey="enableToolbar"
>
<FakeSwitch
checked={form.enableToolbar}
@@ -254,6 +256,27 @@ function TelnetSection(props: HostEditorSectionProps) {
);
}
/**
* The defaults editor's remote desktop section: the display settings and the
* toolbar, which every host follows unless its own guacd settings say
* otherwise. A host edits these in its protocol tabs instead.
*/
function DisplayDefaultsSection(props: HostEditorSectionProps) {
const { t } = useTranslation();
const { form, setField } = remoteDesktopForm(props);
return (
<>
<SectionCard
title={t("settings.host.displayGroup")}
icon={<Monitor className="size-3.5" />}
>
<HostFeatureFields form={props.form} updateForm={props.updateForm} />
</SectionCard>
<ToolbarCard form={form} setField={setField} />
</>
);
}
const SECTIONS: Record<Protocol, ComponentType<HostEditorSectionProps>> = {
rdp: RdpSection,
vnc: VncSection,
@@ -343,6 +366,16 @@ function registerHostSurfaces(app: TermixApp): void {
});
}
app.registerHostEditorSection({
id: "remote-desktop-display",
group: "top",
titleKey: "settings.host.displayDefaultsTab",
icon: Monitor,
order: 40,
defaults: "only",
component: DisplayDefaultsSection,
});
if (isElectron()) registerNativeRdp(app);
}
@@ -149,14 +149,15 @@ describe("POST /connect-host/:hostId", () => {
});
});
it("puts the user's RDP defaults under the host's own values", async () => {
it("puts the host's display settings under its own guacd settings", async () => {
server = await start({ protocolTargets: { "7:rdp": target() } });
await server.mock.ctx.settings.setHost(7, "enableRdp", true);
await server.mock.ctx.settings.setHost(7, "guacamoleConfig", {
"color-depth": 32,
});
await server.mock.ctx.settings.setUser("user-1", "colorDepth", "16");
await server.mock.ctx.settings.setUser("user-1", "disableCopy", "on");
// What the host follows from its defaults.
await server.mock.ctx.settings.setHost(7, "colorDepth", "16");
await server.mock.ctx.settings.setHost(7, "disableCopy", "on");
const response = await server.request("POST", "/connect-host/7", {
protocol: "rdp",
+4 -2
View File
@@ -108,7 +108,8 @@
"descriptionKey": "host.startupDescription",
"hidden": true,
"min": 1,
"shareRead": "view"
"shareRead": "view",
"defaultLevels": ["user", "folder"]
},
{
"key": "quickActions",
@@ -117,7 +118,8 @@
"descriptionKey": "host.quickActionsDescription",
"hidden": true,
"default": [],
"shareRead": "view"
"shareRead": "view",
"defaultLevels": ["user", "folder"]
}
]
}
@@ -3,7 +3,14 @@ import {
useTranslation,
type HostEditorSectionProps,
} from "@termix/plugin-sdk/frontend";
import { Button, Input, SectionCard, Select2 } from "@termix/plugin-sdk/ui";
import {
Button,
HostDefaultBadge,
HostDefaultField,
Input,
SectionCard,
Select2,
} from "@termix/plugin-sdk/ui";
import { PLUGIN_ID, readStartupSnippetId } from "../shared/host-settings.js";
import { useSnippetOptions } from "./use-snippet-options";
@@ -62,110 +69,119 @@ export function HostSnippetsSection({
return (
<>
<SectionCard
title={t("host.startupTitle")}
icon={<Play className="size-3.5" />}
>
<div className="flex flex-col gap-1.5 py-3">
<p className="text-xs text-muted-foreground">
{t("host.startupDescription")}
</p>
<Select2
aria-label={t("host.startupTitle")}
value={startupSnippetId ?? ""}
onChange={(e) =>
write({
startupSnippetId: e.target.value
? Number(e.target.value)
: null,
})
}
className="flex h-9 w-full border border-border bg-background px-3 py-1 text-xs outline-none focus:ring-1 focus:ring-ring"
>
<option value="">{t("host.none")}</option>
{options.map((snippet) => (
<option key={snippet.id} value={snippet.id}>
{snippet.name}
</option>
))}
</Select2>
</div>
</SectionCard>
<SectionCard
title={t("host.quickActionsTitle")}
icon={<Zap className="size-3.5" />}
action={
<Button
variant="outline"
size="sm"
className="h-6 text-[10px] px-2 border-accent-brand/40 text-accent-brand"
onClick={() =>
setQuickActions([...quickActions, { name: "", snippetId: null }])
}
>
<Plus className="size-3 mr-1" /> {t("host.addQuickAction")}
</Button>
}
>
<div className="flex flex-col gap-3 py-3">
<p className="text-xs text-muted-foreground">
{t("host.quickActionsDescription")}
</p>
{quickActions.length === 0 && (
<div className="flex flex-col items-center justify-center py-4 text-muted-foreground/40 gap-1.5">
<Zap className="size-6" />
<span className="text-xs">{t("host.noQuickActions")}</span>
</div>
)}
{quickActions.map((row, index) => (
<div
key={index}
className="flex items-center gap-2 p-2 bg-muted/20 border border-border group"
<HostDefaultField settingKey="startupSnippetId">
<SectionCard
title={t("host.startupTitle")}
icon={<Play className="size-3.5" />}
>
<div className="flex flex-col gap-1.5 py-3">
<p className="flex items-center gap-1.5 text-xs text-muted-foreground">
{t("host.startupDescription")}
<HostDefaultBadge settingKey="startupSnippetId" />
</p>
<Select2
aria-label={t("host.startupTitle")}
value={startupSnippetId ?? ""}
onChange={(e) =>
write({
startupSnippetId: e.target.value
? Number(e.target.value)
: null,
})
}
className="flex h-9 w-full border border-border bg-background px-3 py-1 text-xs outline-none focus:ring-1 focus:ring-ring"
>
<Input
className="h-7 text-xs flex-1"
placeholder={t("host.buttonLabel")}
value={row.name}
onChange={(e) => {
const next = [...quickActions];
next[index] = { ...row, name: e.target.value };
setQuickActions(next);
}}
/>
<Select2
aria-label={t("host.selectSnippet")}
className="h-7 text-xs flex-1 border border-border bg-background px-2 outline-none focus:ring-1 focus:ring-ring"
value={row.snippetId ?? ""}
onChange={(e) => {
const next = [...quickActions];
next[index] = {
...row,
snippetId: e.target.value ? Number(e.target.value) : null,
};
setQuickActions(next);
}}
<option value="">{t("host.none")}</option>
{options.map((snippet) => (
<option key={snippet.id} value={snippet.id}>
{snippet.name}
</option>
))}
</Select2>
</div>
</SectionCard>
</HostDefaultField>
<HostDefaultField settingKey="quickActions">
<SectionCard
title={t("host.quickActionsTitle")}
icon={<Zap className="size-3.5" />}
action={
<Button
variant="outline"
size="sm"
className="h-6 text-[10px] px-2 border-accent-brand/40 text-accent-brand"
onClick={() =>
setQuickActions([
...quickActions,
{ name: "", snippetId: null },
])
}
>
<Plus className="size-3 mr-1" /> {t("host.addQuickAction")}
</Button>
}
>
<div className="flex flex-col gap-3 py-3">
<p className="flex items-center gap-1.5 text-xs text-muted-foreground">
{t("host.quickActionsDescription")}
<HostDefaultBadge settingKey="quickActions" />
</p>
{quickActions.length === 0 && (
<div className="flex flex-col items-center justify-center py-4 text-muted-foreground/40 gap-1.5">
<Zap className="size-6" />
<span className="text-xs">{t("host.noQuickActions")}</span>
</div>
)}
{quickActions.map((row, index) => (
<div
key={index}
className="flex items-center gap-2 p-2 bg-muted/20 border border-border group"
>
<option value="">{t("host.selectSnippet")}</option>
{options.map((snippet) => (
<option key={snippet.id} value={snippet.id}>
{snippet.name}
</option>
))}
</Select2>
<button
type="button"
aria-label={t("host.removeQuickAction")}
className="text-muted-foreground hover:text-destructive opacity-0 group-hover:opacity-100 transition-opacity"
onClick={() =>
setQuickActions(quickActions.filter((_, i) => i !== index))
}
>
<Trash2 className="size-3.5" />
</button>
</div>
))}
</div>
</SectionCard>
<Input
className="h-7 text-xs flex-1"
placeholder={t("host.buttonLabel")}
value={row.name}
onChange={(e) => {
const next = [...quickActions];
next[index] = { ...row, name: e.target.value };
setQuickActions(next);
}}
/>
<Select2
aria-label={t("host.selectSnippet")}
className="h-7 text-xs flex-1 border border-border bg-background px-2 outline-none focus:ring-1 focus:ring-ring"
value={row.snippetId ?? ""}
onChange={(e) => {
const next = [...quickActions];
next[index] = {
...row,
snippetId: e.target.value ? Number(e.target.value) : null,
};
setQuickActions(next);
}}
>
<option value="">{t("host.selectSnippet")}</option>
{options.map((snippet) => (
<option key={snippet.id} value={snippet.id}>
{snippet.name}
</option>
))}
</Select2>
<button
type="button"
aria-label={t("host.removeQuickAction")}
className="text-muted-foreground hover:text-destructive opacity-0 group-hover:opacity-100 transition-opacity"
onClick={() =>
setQuickActions(quickActions.filter((_, i) => i !== index))
}
>
<Trash2 className="size-3.5" />
</button>
</div>
))}
</div>
</SectionCard>
</HostDefaultField>
</>
);
}
+1
View File
@@ -127,6 +127,7 @@ export function activate(app: TermixApp): void {
titleKey: "host.tabTitle",
icon: Play,
order: 15,
defaults: true,
component: HostSnippetsSection,
});
-17
View File
@@ -461,13 +461,11 @@
"lineHeight": "Line Height",
"lineHeightLabel": "Line Height",
"linkClickBehaviorConfirm": "Show popup to open or copy",
"linkClickBehaviorDefault": "Default (follow app setting)",
"linkClickBehaviorDesc": "Controls what happens when you click a link in the terminal. Use 'Default' to follow the app-level setting.",
"linkClickBehaviorDirect": "Open directly",
"linkClickBehaviorLabel": "Link Click Behavior",
"loadingHost": "Loading host...",
"localEchoAuto": "Automatic",
"localEchoDefault": "Use global setting",
"localEchoDesc": "Predict safe printable keystrokes locally on slow connections. Password prompts and control input are never echoed.",
"localEchoLabel": "Local Echo",
"localEchoOff": "Off",
@@ -512,8 +510,6 @@
"themePreview": "Theme Preview",
"useSSHTitleDesc": "Update the tab title from the shell's window title instead of the host name",
"useSSHTitleLabel": "Use SSH Window Title",
"useUserDefaults": "Use user defaults",
"useUserTerminalDefaultsDesc": "Keep this host in step with the terminal defaults in your profile.",
"enableTunnel": "Enable Tunnel",
"enableFileManager": "Enable File Manager",
"enableDocker": "Enable Docker",
@@ -571,19 +567,6 @@
"startRecording": "Start Recording",
"stopRecording": "Stop Recording"
},
"terminalDefaults": {
"clearAll": "Clear All",
"description": "Set values that every host inherits unless it overrides them.",
"dialogDescription": "These apply to hosts that leave a setting unset. Anything set on a host wins.",
"inherit": "Inherit",
"manage": "Manage",
"off": "Off",
"on": "On",
"savedThemeLabel": "Apply Saved Theme",
"savedThemePlaceholder": "Choose a saved theme",
"title": "Connection Defaults",
"apply": "Apply"
},
"keybindings": {
"copy": "Copy selection",
"paste": "Paste",
+47 -38
View File
@@ -151,21 +151,24 @@
"type": "boolean",
"labelKey": "settings.admin.commandHistoryForNewHosts.label",
"descriptionKey": "settings.admin.commandHistoryForNewHosts.description",
"default": true
"default": true,
"hidden": true
},
{
"key": "newHostTheme",
"type": "custom",
"component": "newHostTheme",
"group": "settings.admin.group.newHosts",
"default": "termix"
"default": "termix",
"hidden": true
},
{
"key": "newHostFontFamily",
"type": "custom",
"component": "newHostFontFamily",
"group": "settings.admin.group.newHosts",
"default": "Caskaydia Cove Nerd Font Mono"
"default": "Caskaydia Cove Nerd Font Mono",
"hidden": true
},
{
"key": "newHostFontSize",
@@ -175,7 +178,8 @@
"group": "settings.admin.group.newHosts",
"min": 8,
"max": 36,
"default": 14
"default": 14,
"hidden": true
},
{
"key": "newHostCursorStyle",
@@ -196,14 +200,16 @@
"value": "bar",
"labelKey": "hosts.cursorStyleBar"
}
]
],
"hidden": true
},
{
"key": "newHostCursorBlink",
"type": "boolean",
"labelKey": "settings.admin.newHostCursorBlink.label",
"group": "settings.admin.group.newHosts",
"default": true
"default": true,
"hidden": true
},
{
"key": "newHostAutoTmux",
@@ -211,7 +217,8 @@
"labelKey": "settings.admin.newHostAutoTmux.label",
"descriptionKey": "settings.admin.newHostAutoTmux.description",
"group": "settings.admin.group.newHosts",
"default": false
"default": false,
"hidden": true
},
{
"key": "touchInput",
@@ -399,15 +406,15 @@
"type": "boolean",
"labelKey": "settings.host.enableCommandHistory.label",
"descriptionKey": "settings.host.enableCommandHistory.description",
"default": true,
"defaultFrom": "commandHistoryForNewHosts"
"default": true
},
{
"key": "inheritAppearance",
"type": "boolean",
"labelKey": "settings.host.inheritAppearance.label",
"hidden": true,
"default": true
"default": true,
"defaultable": false
},
{
"key": "theme",
@@ -415,7 +422,7 @@
"labelKey": "settings.host.theme.label",
"hidden": true,
"default": "termix",
"defaultFrom": "newHostTheme"
"personal": true
},
{
"key": "cursorBlink",
@@ -423,7 +430,7 @@
"labelKey": "settings.host.cursorBlink.label",
"hidden": true,
"default": true,
"defaultFrom": "newHostCursorBlink"
"personal": true
},
{
"key": "cursorStyle",
@@ -445,7 +452,7 @@
"labelKey": "hosts.cursorStyleBar"
}
],
"defaultFrom": "newHostCursorStyle"
"personal": true
},
{
"key": "fontSize",
@@ -453,7 +460,7 @@
"labelKey": "settings.host.fontSize.label",
"hidden": true,
"default": 14,
"defaultFrom": "newHostFontSize"
"personal": true
},
{
"key": "fontFamily",
@@ -461,28 +468,31 @@
"labelKey": "settings.host.fontFamily.label",
"hidden": true,
"default": "Caskaydia Cove Nerd Font Mono",
"defaultFrom": "newHostFontFamily"
"personal": true
},
{
"key": "scrollback",
"type": "number",
"labelKey": "settings.host.scrollback.label",
"hidden": true,
"default": 10000
"default": 10000,
"personal": true
},
{
"key": "letterSpacing",
"type": "number",
"labelKey": "settings.host.letterSpacing.label",
"hidden": true,
"default": 0
"default": 0,
"personal": true
},
{
"key": "lineHeight",
"type": "number",
"labelKey": "settings.host.lineHeight.label",
"hidden": true,
"default": 1
"default": 1,
"personal": true
},
{
"key": "bellStyle",
@@ -507,34 +517,39 @@
"value": "both",
"labelKey": "hosts.bellStyleBoth"
}
]
],
"personal": true
},
{
"key": "minimumContrastRatio",
"type": "number",
"labelKey": "settings.host.minimumContrastRatio.label",
"hidden": true,
"default": 1
"default": 1,
"personal": true
},
{
"key": "backgroundImage",
"type": "string",
"labelKey": "settings.host.backgroundImage.label",
"hidden": true,
"default": ""
"default": "",
"personal": true
},
{
"key": "backgroundImageOpacity",
"type": "number",
"labelKey": "settings.host.backgroundImageOpacity.label",
"hidden": true,
"default": 0.15
"default": 0.15,
"personal": true
},
{
"key": "customThemeColors",
"type": "json",
"labelKey": "settings.host.customThemeColors.label",
"hidden": true
"hidden": true,
"personal": true
},
{
"key": "rightClickSelectsWord",
@@ -614,8 +629,7 @@
"type": "boolean",
"labelKey": "settings.host.autoTmux.label",
"hidden": true,
"default": false,
"defaultFrom": "newHostAutoTmux"
"default": false
},
{
"key": "useSSHTitle",
@@ -642,12 +656,8 @@
"type": "select",
"labelKey": "settings.host.linkClickBehavior.label",
"hidden": true,
"default": "default",
"default": "confirm",
"options": [
{
"value": "default",
"labelKey": "hosts.linkClickBehaviorDefault"
},
{
"value": "confirm",
"labelKey": "hosts.linkClickBehaviorConfirm"
@@ -663,12 +673,8 @@
"type": "select",
"labelKey": "settings.host.localEcho.label",
"hidden": true,
"default": "default",
"default": "auto",
"options": [
{
"value": "default",
"labelKey": "hosts.localEchoDefault"
},
{
"value": "off",
"labelKey": "hosts.localEchoOff"
@@ -704,7 +710,8 @@
"key": "terminalDefaults",
"type": "custom",
"component": "terminalDefaults",
"default": {}
"default": {},
"hidden": true
},
{
"key": "customThemes",
@@ -739,7 +746,8 @@
"value": "on",
"labelKey": "hosts.localEchoOn"
}
]
],
"hidden": true
},
{
"key": "linkClickBehavior",
@@ -756,7 +764,8 @@
"value": "direct",
"labelKey": "hosts.linkClickBehaviorDirect"
}
]
],
"hidden": true
},
{
"key": "macros",
@@ -10,11 +10,7 @@ import {
} from "./images/image-storage-settings.js";
import { DEFAULT_TIMEOUT_MINUTES } from "./session-manager.js";
import {
DEFAULT_APPEARANCE,
NEW_HOST_ADMIN_KEYS,
pickTerminalValues,
readUserSettings,
type NewHostTerminalDefaults,
type TerminalUserSettings,
} from "../shared/terminal-settings.js";
@@ -84,8 +80,6 @@ export interface ClientSettings {
sessionPersistence: boolean;
commandHistoryEnabled: boolean;
touchInput: TouchInputSettings;
/** What a new host's terminal starts with. */
newHostDefaults: NewHostTerminalDefaults;
/** The caller's own terminal settings. */
user: TerminalUserSettings;
}
@@ -114,26 +108,6 @@ export async function readClientSettings(
sessionPersistence: all[ADMIN_KEYS.sessionPersistence] !== false,
commandHistoryEnabled: all[ADMIN_KEYS.commandHistoryEnabled] !== false,
touchInput: normalizeTouchInputSettings(touch),
newHostDefaults: readNewHostDefaults(all),
user: readUserSettings(user),
};
}
/** The admin's new-host terminal defaults, typed, with the built-in ones under them. */
export function readNewHostDefaults(
admin: Record<string, unknown>,
): NewHostTerminalDefaults {
const byHostKey: Record<string, unknown> = {};
for (const [adminKey, hostKey] of Object.entries(NEW_HOST_ADMIN_KEYS)) {
byHostKey[hostKey] = admin[adminKey];
}
return {
fontSize: DEFAULT_APPEARANCE.fontSize,
fontFamily: DEFAULT_APPEARANCE.fontFamily,
theme: DEFAULT_APPEARANCE.theme,
cursorStyle: DEFAULT_APPEARANCE.cursorStyle,
cursorBlink: DEFAULT_APPEARANCE.cursorBlink,
autoTmux: false,
...pickTerminalValues(byHostKey, Object.values(NEW_HOST_ADMIN_KEYS)),
} as NewHostTerminalDefaults;
}
+2 -13
View File
@@ -46,11 +46,6 @@ import {
SendControlCodeEditor,
SendTextEditor,
} from "./lib/keybinding-editors";
import { TerminalDefaultsSettings } from "./settings/TerminalDefaultsSettings";
import {
NewHostFontSetting,
NewHostThemeSetting,
} from "./settings/NewHostLookSettings";
import { TerminalPreview } from "./look/TerminalPreview";
import { installTerminalGlobalStyles } from "./look/terminal-global-styles";
import {
@@ -60,7 +55,6 @@ import {
} from "./look/look-actions";
import {
invalidateTerminalClientSettings,
loadTerminalClientSettings,
resetTerminalClientSettings,
} from "./terminal-settings";
import { moveLocalTerminalPreferences } from "./settings/local-preferences-migration";
@@ -142,6 +136,7 @@ export function activate(app: TermixApp): void {
titleKey: "hosts.tabTerminal",
icon: SquareTerminal,
order: 10,
defaults: true,
component: HostTerminalSection,
});
@@ -305,9 +300,6 @@ export function activate(app: TermixApp): void {
app.registerAction("terminal.resolveTheme", (async (
request: ResolveLookRequest & { hostId?: number | string } = {},
) => {
const settings = await loadTerminalClientSettings(app.api).catch(
() => null,
);
let host: PluginHostRecord | null | undefined = request.host;
if (!host && request.hostId !== undefined) {
host =
@@ -316,15 +308,12 @@ export function activate(app: TermixApp): void {
(entry) => String(entry.id) === String(request.hostId),
);
}
return resolveTerminalLook({ ...request, host }, settings?.user);
return resolveTerminalLook({ ...request, host });
}) as never);
app.registerAction("terminal.themes", () => listTerminalThemes());
app.registerSettingsComponent("touchInput", TouchInputSettings);
app.registerSettingsComponent("imageStorageTest", ImageStorageTest);
app.registerSettingsComponent("terminalDefaults", TerminalDefaultsSettings);
app.registerSettingsComponent("newHostTheme", NewHostThemeSetting);
app.registerSettingsComponent("newHostFontFamily", NewHostFontSetting);
app.onSettingsChanged(() => invalidateTerminalClientSettings());
if (!app.guest) void moveLocalTerminalPreferences(app);
@@ -10,7 +10,6 @@ import { resolveForHost } from "../terminal-settings";
import type {
CursorStyle,
TerminalThemeColors,
TerminalUserSettings,
} from "../../shared/terminal-settings";
/**
@@ -45,9 +44,8 @@ export interface ResolveLookRequest {
export function resolveTerminalLook(
request: ResolveLookRequest,
user: TerminalUserSettings | undefined,
): ResolvedTerminalLook {
const config = resolveForHost(request.host ?? null, user);
const config = resolveForHost(request.host ?? null);
const themeId = request.theme || config.theme;
ensureTerminalFontsLoaded(config.fontFamily);
return {
@@ -5,6 +5,7 @@ import { toast } from "sonner";
import {
Button,
FakeSwitch,
HostDefaultBadge,
Input,
SectionCard,
Select2,
@@ -33,9 +34,6 @@ import {
TERMINAL_FONT_ZOOM_MAX,
} from "../look/terminal-font-zoom";
import {
APPEARANCE_KEYS,
INHERIT_APPEARANCE_KEY,
pickTerminalValues,
readHostTerminalSettings,
readUserSettings,
type BackspaceMode,
@@ -45,14 +43,10 @@ import {
type HostTerminalSettings as HostTerminalValues,
type SavedCustomTheme,
} from "../../shared/terminal-settings";
import {
invalidateTerminalClientSettings,
useTerminalClientSettings,
} from "../terminal-settings";
import { invalidateTerminalClientSettings } from "../terminal-settings";
const PLUGIN_ID = "ssh-terminal";
const CUSTOM_FONT_OPTION = "__custom__";
const APPEARANCE = new Set<string>(APPEARANCE_KEYS);
type HostPluginSettings = Record<string, Record<string, unknown>>;
@@ -64,10 +58,8 @@ type HostPluginSettings = Record<string, Record<string, unknown>>;
export function HostTerminalSettings({
form: editorForm,
updateForm,
host,
}: Pick<HostEditorSectionProps, "form" | "setField" | "updateForm" | "host">) {
const { t } = useTranslation();
const client = useTerminalClientSettings();
const userSettings = useSettings("user");
// The preview ends when the editor section goes away.
useEffect(() => () => setThemePreview(null), []);
@@ -75,18 +67,8 @@ export function HostTerminalSettings({
const stored = ((editorForm?.pluginSettings as HostPluginSettings)?.[
PLUGIN_ID
] ?? {}) as Record<string, unknown>;
// A new host starts from the admin's new-host defaults.
const seed = host ? {} : (client?.newHostDefaults ?? {});
const own = readHostTerminalSettings({ ...seed, ...stored });
const userDefaults = pickTerminalValues(
client?.user.terminalDefaults ?? {},
APPEARANCE_KEYS,
);
// While the host follows the user, show the look it will actually get.
const form = {
...own,
...(own.inheritAppearance ? userDefaults : {}),
} as HostTerminalValues;
// The form holds what the host follows, so this is what it will look like.
const form = readHostTerminalSettings(stored) as HostTerminalValues;
const writeValues = (values: Record<string, unknown>) =>
updateForm((current) => {
@@ -103,23 +85,7 @@ export function HostTerminalSettings({
const setField = <K extends keyof HostTerminalValues>(
key: K,
value: HostTerminalValues[K],
) =>
writeValues({
[key]: value,
// Touching the look takes the host off the user's defaults.
...(APPEARANCE.has(key) ? { [INHERIT_APPEARANCE_KEY]: false } : {}),
});
const setInheritAppearance = (inherit: boolean) => {
if (inherit) {
writeValues({ [INHERIT_APPEARANCE_KEY]: true });
return;
}
// Keep what was shown, so switching off does not change the look.
const shown: Record<string, unknown> = {};
for (const key of APPEARANCE_KEYS) shown[key] = form[key];
writeValues({ ...shown, [INHERIT_APPEARANCE_KEY]: false });
};
) => writeValues({ [key]: value });
const [isCustomFont, setIsCustomFont] = useState(
() => !TERMINAL_FONTS.some((f) => f.value === form.fontFamily),
@@ -183,21 +149,7 @@ export function HostTerminalSettings({
icon={<Palette className="size-3.5" />}
>
<div className="flex flex-col gap-4 py-3">
<SettingRow
label={t("hosts.useUserDefaults")}
description={t("hosts.useUserTerminalDefaultsDesc")}
>
<FakeSwitch
checked={form.inheritAppearance}
onChange={setInheritAppearance}
/>
</SettingRow>
<fieldset
disabled={form.inheritAppearance}
className={
form.inheritAppearance ? "contents opacity-60" : "contents"
}
>
<>
<div className="space-y-2">
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
{t("hosts.themePreview")}
@@ -215,8 +167,9 @@ export function HostTerminalSettings({
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="flex flex-col gap-1.5">
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
<label className="flex items-center gap-1.5 text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
{t("hosts.colorTheme")}
<HostDefaultBadge settingKey="theme" />
</label>
<Select2
value={form.theme}
@@ -245,8 +198,9 @@ export function HostTerminalSettings({
</div>
<div className="flex flex-col gap-1.5">
<div className="flex items-center gap-1">
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
<label className="flex items-center gap-1.5 text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
{t("hosts.fontFamilyLabel")}
<HostDefaultBadge settingKey="fontFamily" />
</label>
<TooltipProvider delayDuration={200}>
<Tooltip>
@@ -292,8 +246,9 @@ export function HostTerminalSettings({
</div>
<div className="flex flex-col gap-1.5">
<div className="flex items-center justify-between">
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
<label className="flex items-center gap-1.5 text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
{t("hosts.fontSizeLabel")}
<HostDefaultBadge settingKey="fontSize" />
</label>
<span className="text-[10px] text-muted-foreground tabular-nums">
{form.fontSize}px
@@ -308,8 +263,9 @@ export function HostTerminalSettings({
/>
</div>
<div className="flex flex-col gap-1.5">
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
<label className="flex items-center gap-1.5 text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
{t("hosts.cursorStyleLabel")}
<HostDefaultBadge settingKey="cursorStyle" />
</label>
<Select2
value={form.cursorStyle}
@@ -327,8 +283,9 @@ export function HostTerminalSettings({
</div>
<div className="flex flex-col gap-1.5">
<div className="flex items-center justify-between">
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
<label className="flex items-center gap-1.5 text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
{t("hosts.letterSpacingPx")}
<HostDefaultBadge settingKey="letterSpacing" />
</label>
<span className="text-[10px] text-muted-foreground tabular-nums">
{form.letterSpacing}px
@@ -344,8 +301,9 @@ export function HostTerminalSettings({
</div>
<div className="flex flex-col gap-1.5">
<div className="flex items-center justify-between">
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
<label className="flex items-center gap-1.5 text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
{t("hosts.lineHeightLabel")}
<HostDefaultBadge settingKey="lineHeight" />
</label>
<span className="text-[10px] text-muted-foreground tabular-nums">
{form.lineHeight.toFixed(1)}
@@ -360,8 +318,9 @@ export function HostTerminalSettings({
/>
</div>
<div className="flex flex-col gap-1.5">
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
<label className="flex items-center gap-1.5 text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
{t("hosts.bellStyleLabel")}
<HostDefaultBadge settingKey="bellStyle" />
</label>
<Select2
value={form.bellStyle}
@@ -378,8 +337,9 @@ export function HostTerminalSettings({
</Select2>
</div>
<div className="flex flex-col gap-1.5">
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
<label className="flex items-center gap-1.5 text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
{t("hosts.backspaceModeLabel")}
<HostDefaultBadge settingKey="backspaceMode" />
</label>
<Select2
value={form.backspaceMode}
@@ -397,7 +357,7 @@ export function HostTerminalSettings({
</Select2>
</div>
</div>
</fieldset>
</>
{form.theme === "custom" && (
<div className="flex flex-col gap-3">
<div className="flex flex-col gap-1.5">
@@ -455,8 +415,9 @@ export function HostTerminalSettings({
)}
</div>
<div className="flex items-center justify-between">
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
<label className="flex items-center gap-1.5 text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
{t("hosts.customThemeColors")}
<HostDefaultBadge settingKey="customThemeColors" />
</label>
<button
type="button"
@@ -569,6 +530,7 @@ export function HostTerminalSettings({
)}
<SettingRow
label={t("hosts.cursorBlinking")}
defaultKey="cursorBlink"
description={t("hosts.cursorBlinkingDesc")}
>
<FakeSwitch
@@ -578,6 +540,7 @@ export function HostTerminalSettings({
</SettingRow>
<SettingRow
label={t("hosts.rightClickSelectsWordLabel")}
defaultKey="rightClickSelectsWord"
description={t("hosts.rightClickSelectsWordShortDesc")}
>
<FakeSwitch
@@ -587,6 +550,7 @@ export function HostTerminalSettings({
</SettingRow>
<SettingRow
label={t("hosts.macOptionIsMetaLabel")}
defaultKey="macOptionIsMeta"
description={t("hosts.macOptionIsMetaShortDesc")}
>
<FakeSwitch
@@ -596,6 +560,7 @@ export function HostTerminalSettings({
</SettingRow>
<SettingRow
label={t("hosts.syntaxHighlightingLabel")}
defaultKey="syntaxHighlighting"
description={t("hosts.syntaxHighlightingDesc")}
>
<FakeSwitch
@@ -633,6 +598,7 @@ export function HostTerminalSettings({
).map(([key, labelKey, descKey]) => (
<SettingRow
key={key}
defaultKey="syntaxHighlightingOptions"
label={t(`hosts.${labelKey}`)}
description={t(`hosts.${descKey}`)}
>
@@ -650,8 +616,9 @@ export function HostTerminalSettings({
</div>
)}
<div className="flex flex-col gap-1.5">
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
<label className="flex items-center gap-1.5 text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
{t("hosts.backgroundImageLabel")}
<HostDefaultBadge settingKey="backgroundImage" />
</label>
<p className="text-[10px] text-muted-foreground">
{t("hosts.backgroundImageDesc")}
@@ -667,8 +634,9 @@ export function HostTerminalSettings({
{form.backgroundImage && (
<div className="flex flex-col gap-1.5">
<div className="flex items-center justify-between">
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
<label className="flex items-center gap-1.5 text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
{t("hosts.backgroundImageOpacityLabel")}
<HostDefaultBadge settingKey="backgroundImageOpacity" />
</label>
<span className="text-[10px] text-muted-foreground tabular-nums">
{Math.round(form.backgroundImageOpacity * 100)}%
@@ -693,8 +661,9 @@ export function HostTerminalSettings({
<div className="flex flex-col gap-4 py-3">
<div className="flex flex-col gap-1.5">
<div className="flex items-center justify-between">
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
<label className="flex items-center gap-1.5 text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
{t("hosts.scrollbackBufferLabel")}
<HostDefaultBadge settingKey="scrollback" />
</label>
<span className="text-[10px] text-muted-foreground tabular-nums">
{form.scrollback.toLocaleString()}{" "}
@@ -711,6 +680,7 @@ export function HostTerminalSettings({
</div>
<SettingRow
label={t("hosts.enableAutoTmux")}
defaultKey="autoTmux"
description={
<>
{t("hosts.enableAutoTmuxDesc")}{" "}
@@ -732,6 +702,7 @@ export function HostTerminalSettings({
</SettingRow>
<SettingRow
label={t("hosts.useSSHTitleLabel")}
defaultKey="useSSHTitle"
description={t("hosts.useSSHTitleDesc")}
>
<FakeSwitch
@@ -741,6 +712,7 @@ export function HostTerminalSettings({
</SettingRow>
<SettingRow
label={t("hosts.enableAutoMosh")}
defaultKey="autoMosh"
description={t("hosts.enableAutoMoshDesc")}
>
<FakeSwitch
@@ -750,6 +722,7 @@ export function HostTerminalSettings({
</SettingRow>
<SettingRow
label={t("hosts.passwordPromptAutoFillLabel")}
defaultKey="passwordPromptAutoFill"
description={t("hosts.passwordPromptAutoFillDesc")}
>
<FakeSwitch
@@ -759,6 +732,7 @@ export function HostTerminalSettings({
</SettingRow>
<SettingRow
label={t("hosts.sudoPasswordAutoFillLabel")}
defaultKey="sudoPasswordAutoFill"
description={t("hosts.sudoPasswordAutoFillDesc")}
>
<FakeSwitch
@@ -767,20 +741,17 @@ export function HostTerminalSettings({
/>
</SettingRow>
<div className="flex flex-col gap-1.5">
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
<label className="flex items-center gap-1.5 text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
{t("hosts.localEchoLabel")}
<HostDefaultBadge settingKey="localEcho" />
</label>
<Select2
value={form.localEcho}
onChange={(e) =>
setField(
"localEcho",
e.target.value as "default" | "off" | "auto" | "on",
)
setField("localEcho", e.target.value as "off" | "auto" | "on")
}
className="flex h-9 w-full border border-border bg-background px-3 py-1 text-xs outline-none focus:ring-1 focus:ring-ring"
>
<option value="default">{t("hosts.localEchoDefault")}</option>
<option value="off">{t("hosts.localEchoOff")}</option>
<option value="auto">{t("hosts.localEchoAuto")}</option>
<option value="on">{t("hosts.localEchoOn")}</option>
@@ -798,22 +769,20 @@ export function HostTerminalSettings({
</p>
</div>
<div className="flex flex-col gap-1.5">
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
<label className="flex items-center gap-1.5 text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
{t("hosts.linkClickBehaviorLabel")}
<HostDefaultBadge settingKey="linkClickBehavior" />
</label>
<Select2
value={form.linkClickBehavior}
onChange={(e) =>
setField(
"linkClickBehavior",
e.target.value as "default" | "confirm" | "direct",
e.target.value as "confirm" | "direct",
)
}
className="flex h-9 w-full border border-border bg-background px-3 py-1 text-xs outline-none focus:ring-1 focus:ring-ring"
>
<option value="default">
{t("hosts.linkClickBehaviorDefault")}
</option>
<option value="confirm">
{t("hosts.linkClickBehaviorConfirm")}
</option>
@@ -827,8 +796,9 @@ export function HostTerminalSettings({
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 border-t border-border pt-4">
<div className="flex flex-col gap-1.5">
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
<label className="flex items-center gap-1.5 text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
{t("hosts.fastScrollModifierLabel")}
<HostDefaultBadge settingKey="fastScrollModifier" />
</label>
<Select2
value={form.fastScrollModifier}
@@ -849,8 +819,9 @@ export function HostTerminalSettings({
</div>
<div className="flex flex-col gap-1.5">
<div className="flex items-center justify-between">
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
<label className="flex items-center gap-1.5 text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
{t("hosts.fastScrollSensitivityLabel")}
<HostDefaultBadge settingKey="fastScrollSensitivity" />
</label>
<span className="text-[10px] text-muted-foreground tabular-nums">
{form.fastScrollSensitivity}
@@ -867,8 +838,9 @@ export function HostTerminalSettings({
</div>
{form.autoMosh && (
<div className="flex flex-col gap-1.5">
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
<label className="flex items-center gap-1.5 text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
{t("hosts.moshCommandLabel")}
<HostDefaultBadge settingKey="moshCommand" />
</label>
<Input
placeholder="mosh"
@@ -1,78 +0,0 @@
import { SettingRow, Select2 } from "@termix/plugin-sdk/ui";
import {
useTranslation,
type SettingsComponentProps,
} from "@termix/plugin-sdk/frontend";
import { TERMINAL_FONTS, TERMINAL_THEMES } from "../look/terminal-themes";
import { DEFAULT_APPEARANCE } from "../../shared/terminal-settings";
const selectClass =
"flex h-8 w-48 border border-border bg-background px-2 text-xs outline-none focus:ring-1 focus:ring-ring";
/** Admin setting `newHostTheme`: the theme a new host starts with. */
export function NewHostThemeSetting({
values,
setValue,
running,
}: SettingsComponentProps) {
const { t } = useTranslation();
const value =
typeof values.newHostTheme === "string"
? values.newHostTheme
: DEFAULT_APPEARANCE.theme;
return (
<SettingRow
label={t("settings.admin.newHostTheme.label")}
description={t("settings.admin.newHostTheme.description")}
>
<Select2
value={value}
disabled={!running}
onChange={(e) => setValue("newHostTheme", e.target.value)}
className={selectClass}
>
{Object.entries(TERMINAL_THEMES)
.filter(([key]) => key !== "termixDark" && key !== "termixLight")
.map(([key, theme]) => (
<option key={key} value={key}>
{theme.name}
</option>
))}
</Select2>
</SettingRow>
);
}
/** Admin setting `newHostFontFamily`: the font a new host starts with. */
export function NewHostFontSetting({
values,
setValue,
running,
}: SettingsComponentProps) {
const { t } = useTranslation();
const value =
typeof values.newHostFontFamily === "string"
? values.newHostFontFamily
: DEFAULT_APPEARANCE.fontFamily;
const known = TERMINAL_FONTS.some((font) => font.value === value);
return (
<SettingRow
label={t("settings.admin.newHostFontFamily.label")}
description={t("settings.admin.newHostFontFamily.description")}
>
<Select2
value={value}
disabled={!running}
onChange={(e) => setValue("newHostFontFamily", e.target.value)}
className={`${selectClass} font-mono`}
>
{!known && <option value={value}>{value}</option>}
{TERMINAL_FONTS.map((font) => (
<option key={font.value} value={font.value}>
{font.label}
</option>
))}
</Select2>
</SettingRow>
);
}
@@ -1,317 +0,0 @@
import { useMemo, useState } from "react";
import type React from "react";
import {
Button,
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
SettingRow,
} from "@termix/plugin-sdk/ui";
import {
useTranslation,
type SettingsComponentProps,
} from "@termix/plugin-sdk/frontend";
import {
CURSOR_STYLES,
TERMINAL_FONTS,
TERMINAL_THEMES,
} from "../look/terminal-themes";
import { fromTriState, toTriState, type TriState } from "./tri-state";
import {
readUserSettings,
type TerminalDefaults,
} from "../../shared/terminal-settings";
const inputClass =
"h-8 w-full border border-border bg-background px-2 text-xs outline-none focus:ring-1 focus:ring-ring";
const labelClass =
"text-[10px] font-bold uppercase tracking-widest text-muted-foreground";
function Field({
label,
children,
}: {
label: string;
children: React.ReactNode;
}) {
return (
<div className="flex flex-col gap-1.5">
<span className={labelClass}>{label}</span>
{children}
</div>
);
}
function TriStateSelect({
value,
onChange,
}: {
value?: boolean;
onChange: (value: boolean | undefined) => void;
}) {
const { t } = useTranslation();
return (
<select
className="h-7 w-28 border border-border bg-background px-2 text-xs outline-none focus:ring-1 focus:ring-ring"
value={toTriState(value)}
onChange={(e) => onChange(fromTriState(e.target.value as TriState))}
>
<option value="inherit">{t("terminalDefaults.inherit")}</option>
<option value="on">{t("terminalDefaults.on")}</option>
<option value="off">{t("terminalDefaults.off")}</option>
</select>
);
}
/**
* The user's terminal defaults (the `terminalDefaults` user setting): the
* look every host that follows the user starts from. Applied to the form,
* saved with the rest of the section.
*/
export function TerminalDefaultsSettings({
values,
setValue,
running,
}: SettingsComponentProps) {
const { t } = useTranslation();
const saved = useMemo(() => readUserSettings(values), [values]);
const [open, setOpen] = useState(false);
const [terminal, setTerminal] = useState<TerminalDefaults>({});
const configuredCount = Object.values(saved.terminalDefaults).filter(
(value) => value !== undefined,
).length;
const openDialog = () => {
setTerminal(saved.terminalDefaults);
setOpen(true);
};
const updateTerminal = <K extends keyof TerminalDefaults>(
key: K,
value: TerminalDefaults[K],
) => setTerminal((current) => ({ ...current, [key]: value }));
const applySavedTheme = (id: string) => {
const theme = saved.customThemes.find((entry) => entry.id === id);
if (!theme) return;
setTerminal((current) => ({
...current,
theme: "custom",
customThemeColors: { ...theme.colors },
}));
};
const apply = () => {
const cleaned = Object.fromEntries(
Object.entries(terminal).filter(([, value]) => value !== undefined),
);
setValue("terminalDefaults", cleaned);
setOpen(false);
};
const inheritLabel = t("terminalDefaults.inherit");
return (
<>
<SettingRow
label={t("terminalDefaults.title")}
description={t("terminalDefaults.description")}
badge={configuredCount > 0 ? String(configuredCount) : undefined}
>
<Button
variant="outline"
size="sm"
disabled={!running}
onClick={openDialog}
>
{t("terminalDefaults.manage")}
</Button>
</SettingRow>
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="sm:max-w-xl">
<DialogHeader>
<DialogTitle className="text-base font-bold">
{t("terminalDefaults.title")}
</DialogTitle>
<DialogDescription className="text-xs text-muted-foreground">
{t("terminalDefaults.dialogDescription")}{" "}
<a
href="https://docs.termix.site/features/files-and-hosts/connection-defaults"
target="_blank"
rel="noreferrer"
className="text-accent-brand hover:underline"
>
{t("hosts.docsLink")}
</a>
</DialogDescription>
</DialogHeader>
<div className="max-h-[55vh] overflow-y-auto pr-1">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<Field label={t("hosts.fontFamilyLabel")}>
<select
className={inputClass}
value={terminal.fontFamily ?? ""}
onChange={(e) =>
updateTerminal("fontFamily", e.target.value || undefined)
}
>
<option value="">{inheritLabel}</option>
{TERMINAL_FONTS.map((font) => (
<option key={font.value} value={font.value}>
{font.label}
</option>
))}
</select>
</Field>
<Field label={t("hosts.colorTheme")}>
<select
className={inputClass}
value={terminal.theme ?? ""}
onChange={(e) =>
updateTerminal("theme", e.target.value || undefined)
}
>
<option value="">{inheritLabel}</option>
{Object.entries(TERMINAL_THEMES)
.filter(
([key]) => key !== "termixDark" && key !== "termixLight",
)
.map(([key, theme]) => (
<option key={key} value={key}>
{theme.name}
</option>
))}
</select>
</Field>
{saved.customThemes.length > 0 && (
<Field label={t("terminalDefaults.savedThemeLabel")}>
<select
className={inputClass}
value=""
onChange={(e) => applySavedTheme(e.target.value)}
>
<option value="">
{t("terminalDefaults.savedThemePlaceholder")}
</option>
{saved.customThemes.map((theme) => (
<option key={theme.id} value={theme.id}>
{theme.name}
</option>
))}
</select>
</Field>
)}
<Field label={t("hosts.fontSizeLabel")}>
<input
className={inputClass}
type="number"
min={8}
max={32}
value={terminal.fontSize ?? ""}
placeholder={inheritLabel}
onChange={(e) =>
updateTerminal(
"fontSize",
e.target.value ? Number(e.target.value) : undefined,
)
}
/>
</Field>
<Field label={t("hosts.lineHeightLabel")}>
<input
className={inputClass}
type="number"
min={0.8}
max={2}
step={0.05}
value={terminal.lineHeight ?? ""}
placeholder={inheritLabel}
onChange={(e) =>
updateTerminal(
"lineHeight",
e.target.value ? Number(e.target.value) : undefined,
)
}
/>
</Field>
<Field label={t("hosts.cursorStyleLabel")}>
<select
className={inputClass}
value={terminal.cursorStyle ?? ""}
onChange={(e) =>
updateTerminal(
"cursorStyle",
(e.target.value || undefined) as
TerminalDefaults["cursorStyle"] | undefined,
)
}
>
<option value="">{inheritLabel}</option>
{CURSOR_STYLES.map((style) => (
<option key={style.value} value={style.value}>
{t(style.labelKey)}
</option>
))}
</select>
</Field>
<Field label={t("hosts.scrollbackBufferLabel")}>
<input
className={inputClass}
type="number"
min={1000}
max={100000}
step={1000}
value={terminal.scrollback ?? ""}
placeholder={inheritLabel}
onChange={(e) =>
updateTerminal(
"scrollback",
e.target.value ? Number(e.target.value) : undefined,
)
}
/>
</Field>
<div className="sm:col-span-2">
<SettingRow
label={t("hosts.cursorBlinking")}
description={t("hosts.cursorBlinkingDesc")}
>
<TriStateSelect
value={terminal.cursorBlink}
onChange={(value) => updateTerminal("cursorBlink", value)}
/>
</SettingRow>
</div>
</div>
</div>
<div className="flex items-center justify-between gap-2 pt-3 border-t border-border">
<Button variant="ghost" size="sm" onClick={() => setTerminal({})}>
{t("terminalDefaults.clearAll")}
</Button>
<div className="flex items-center gap-2">
<Button variant="ghost" size="sm" onClick={() => setOpen(false)}>
{t("common.cancel")}
</Button>
<Button size="sm" onClick={apply}>
{t("terminalDefaults.apply")}
</Button>
</div>
</div>
</DialogContent>
</Dialog>
</>
);
}
@@ -1,12 +0,0 @@
// Booleans have three states in the defaults UI: unset means the host decides.
export type TriState = "inherit" | "on" | "off";
export function toTriState(value?: boolean): TriState {
if (value === undefined) return "inherit";
return value ? "on" : "off";
}
export function fromTriState(value: TriState): boolean | undefined {
if (value === "inherit") return undefined;
return value === "on";
}
@@ -5,7 +5,6 @@ import {
} from "../shared/touch-input-settings";
import {
readUserSettings,
type NewHostTerminalDefaults,
type TerminalUserSettings,
} from "../shared/terminal-settings";
@@ -16,7 +15,6 @@ export interface TerminalClientSettings {
sessionPersistence: boolean;
commandHistoryEnabled: boolean;
touchInput: TouchInputSettings;
newHostDefaults?: NewHostTerminalDefaults;
user: TerminalUserSettings;
}
@@ -105,11 +105,9 @@ export function hostTerminalValues(
export function resolveForHost(
host: object | null | undefined,
user: TerminalUserSettings = DEFAULT_USER_SETTINGS,
): ResolvedTerminalConfig {
const resolved = resolveTerminalSettings(
readHostTerminalSettings(hostTerminalValues(host)),
user.terminalDefaults,
);
return {
...resolved,
@@ -126,7 +124,7 @@ export function useTerminalSettings(
const ownKey = own ? JSON.stringify(own) : "";
return useMemo(() => {
const user = client?.user ?? DEFAULT_USER_SETTINGS;
return { config: resolveForHost(host, user), user };
return { config: resolveForHost(host), user };
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [ownKey, client]);
}
@@ -47,7 +47,7 @@ export type BackspaceMode = "normal" | "control-h";
export type HostLocalEcho = "default" | "off" | "auto" | "on";
export type HostLinkClickBehavior = "default" | "confirm" | "direct";
/** How a terminal looks. A host follows the user's defaults unless it opts out. */
/** How a terminal looks. Each value follows the host defaults unless the host sets it. */
export interface TerminalAppearance {
theme: string;
cursorBlink: boolean;
@@ -64,7 +64,7 @@ export interface TerminalAppearance {
customThemeColors: TerminalThemeColors | null;
}
/** How a terminal behaves on one host. Always the host's own. */
/** How a terminal behaves on one host. */
export interface TerminalBehavior {
rightClickSelectsWord: boolean;
macOptionIsMeta: boolean;
@@ -165,8 +165,8 @@ export const DEFAULT_BEHAVIOR: TerminalBehavior = {
useSSHTitle: false,
syntaxHighlighting: true,
syntaxHighlightingOptions: DEFAULT_SYNTAX_HIGHLIGHTING,
linkClickBehavior: "default",
localEcho: "default",
linkClickBehavior: "confirm",
localEcho: "auto",
passwordPromptAutoFill: true,
sudoPasswordAutoFill: false,
};
@@ -177,9 +177,6 @@ export const DEFAULT_HOST_TERMINAL_SETTINGS: HostTerminalSettings = {
inheritAppearance: true,
};
/** The user's terminal defaults: any appearance key, unset ones inherit. */
export type TerminalDefaults = Partial<TerminalAppearance>;
export interface SavedCustomTheme {
id: string;
name: string;
@@ -188,7 +185,6 @@ export interface SavedCustomTheme {
/** The ssh-terminal user settings the terminal reads. */
export interface TerminalUserSettings {
terminalDefaults: TerminalDefaults;
customThemes: SavedCustomTheme[];
commandAutocomplete: boolean;
localEcho: "off" | "auto" | "on";
@@ -196,33 +192,12 @@ export interface TerminalUserSettings {
}
export const DEFAULT_USER_SETTINGS: TerminalUserSettings = {
terminalDefaults: {},
customThemes: [],
commandAutocomplete: false,
localEcho: "auto",
linkClickBehavior: "confirm",
};
/** The admin's defaults for a new host's terminal. */
export interface NewHostTerminalDefaults {
fontSize: number;
fontFamily: string;
theme: string;
cursorStyle: CursorStyle;
cursorBlink: boolean;
autoTmux: boolean;
}
/** Admin field -> the host field it seeds (manifest defaultFrom). */
export const NEW_HOST_ADMIN_KEYS = {
newHostFontSize: "fontSize",
newHostFontFamily: "fontFamily",
newHostTheme: "theme",
newHostCursorStyle: "cursorStyle",
newHostCursorBlink: "cursorBlink",
newHostAutoTmux: "autoTmux",
} as const satisfies Record<string, keyof NewHostTerminalDefaults>;
const ENUMS: Record<string, readonly string[]> = {
cursorStyle: ["block", "underline", "bar"],
bellStyle: ["none", "sound", "visual", "both"],
@@ -357,7 +332,6 @@ export function readUserSettings(
values: Record<string, unknown> | null | undefined,
): TerminalUserSettings {
const source = values ?? {};
const defaults = asObject(source.terminalDefaults);
const themes = Array.isArray(source.customThemes)
? source.customThemes
: typeof source.customThemes === "string"
@@ -371,10 +345,6 @@ export function readUserSettings(
})()
: [];
return {
terminalDefaults: pickTerminalValues(
defaults,
APPEARANCE_KEYS,
) as TerminalDefaults,
customThemes: themes.filter(
(theme): theme is SavedCustomTheme =>
isObject(theme) &&
@@ -393,35 +363,25 @@ export function readUserSettings(
}
/**
* What a terminal on a host runs with: the defaults, then the user's
* terminal defaults, then the host's own look when it does not follow the
* user, then the host's behavior.
* What a terminal on a host runs with: the host's values over the built-in
* ones. The host's values already follow its host defaults, and for a host
* shared with this user its look follows theirs.
*/
export function resolveTerminalSettings(
host: HostTerminalSettings | null | undefined,
userDefaults: TerminalDefaults = {},
): TerminalAppearance & TerminalBehavior {
const own = host ?? DEFAULT_HOST_TERMINAL_SETTINGS;
const appearance: TerminalAppearance = {
...DEFAULT_APPEARANCE,
...pickTerminalValues(userDefaults, APPEARANCE_KEYS),
};
if (!own.inheritAppearance) {
for (const key of APPEARANCE_KEYS) {
(appearance as unknown as Record<string, unknown>)[key] = own[key];
}
const resolved = {} as Record<string, unknown>;
for (const key of APPEARANCE_KEYS) {
resolved[key] = own[key] ?? DEFAULT_APPEARANCE[key];
}
const behavior = {} as TerminalBehavior;
for (const key of BEHAVIOR_KEYS) {
(behavior as unknown as Record<string, unknown>)[key] = own[key];
}
return { ...appearance, ...behavior };
for (const key of BEHAVIOR_KEYS) resolved[key] = own[key];
return resolved as unknown as TerminalAppearance & TerminalBehavior;
}
/**
* The host's settings in the 2.8 `terminalConfig` shape, for clients that
* still read it (Termix-Mobile). The look is left out while the host follows
* the user, the way 2.8 stored it.
* still read it (Termix-Mobile).
*/
export function terminalConfigFromHostSettings(
values: Record<string, unknown>,
@@ -429,9 +389,7 @@ export function terminalConfigFromHostSettings(
const settings = readHostTerminalSettings(values);
const config: Record<string, unknown> = {};
for (const key of BEHAVIOR_KEYS) config[key] = settings[key];
if (!settings.inheritAppearance) {
for (const key of APPEARANCE_KEYS) config[key] = settings[key];
}
for (const key of APPEARANCE_KEYS) config[key] = settings[key];
if (config.linkClickBehavior === "default") delete config.linkClickBehavior;
if (config.localEcho === "default") delete config.localEcho;
return config;
@@ -54,8 +54,9 @@ describe("hostPayloadLegacy", () => {
fontSize: 20,
}) as { terminalConfig: Record<string, unknown> };
expect(terminalConfig.autoTmux).toBe(true);
expect(terminalConfig).not.toHaveProperty("fontSize");
expect(terminalConfig).not.toHaveProperty("localEcho");
// The values already follow the host defaults, so the look is included.
expect(terminalConfig.fontSize).toBe(20);
expect(terminalConfig.localEcho).toBe("auto");
});
it("includes the look of a host that has its own", () => {
@@ -146,26 +146,20 @@ describe("terminal settings routes", () => {
authType: "password",
});
it("hands every user their own terminal settings and the new-host defaults", async () => {
server = await startServer({
settings: { newHostFontSize: 20, newHostTheme: "nord" },
});
it("hands every user their own terminal settings", async () => {
server = await startServer();
await server.mock.ctx.settings.setUser(
"user-1",
"commandAutocomplete",
true,
);
await server.mock.ctx.settings.setUser("user-1", "terminalDefaults", {
fontSize: 18,
});
const { body } = await server.request("GET", "/client-settings");
expect(body.user).toMatchObject({
commandAutocomplete: true,
terminalDefaults: { fontSize: 18 },
localEcho: "auto",
linkClickBehavior: "confirm",
});
expect(body.newHostDefaults).toMatchObject({ fontSize: 20, theme: "nord" });
expect(body).not.toHaveProperty("newHostDefaults");
const other = await server.request("GET", "/client-settings", {
user: "user-2",
});
@@ -30,19 +30,25 @@ describe("hostSettingsFromTerminalConfig", () => {
});
describe("resolveTerminalSettings", () => {
it("uses the user's defaults while the host follows them", () => {
it("runs with the host's values, which already follow its defaults", () => {
const host = readHostTerminalSettings({ fontSize: 30, autoTmux: true });
const resolved = resolveTerminalSettings(host, { fontSize: 18 });
expect(resolved.fontSize).toBe(18);
const resolved = resolveTerminalSettings(host);
expect(resolved.fontSize).toBe(30);
expect(resolved.autoTmux).toBe(true);
});
it("uses the host's own look once it opts out", () => {
it("ignores the old follow-the-user switch", () => {
const host = readHostTerminalSettings({
inheritAppearance: false,
fontSize: 30,
inheritAppearance: true,
fontSize: 22,
});
expect(resolveTerminalSettings(host, { fontSize: 18 }).fontSize).toBe(30);
expect(resolveTerminalSettings(host).fontSize).toBe(22);
});
it("defaults echo and link clicks to concrete modes", () => {
const resolved = resolveTerminalSettings(readHostTerminalSettings({}));
expect(resolved.localEcho).toBe("auto");
expect(resolved.linkClickBehavior).toBe("confirm");
});
it("falls back to the built-in defaults", () => {
@@ -62,7 +68,6 @@ describe("readUserSettings", () => {
linkClickBehavior: "direct",
}),
).toEqual({
terminalDefaults: { fontSize: 16 },
customThemes: [{ id: "a", name: "A", colors: {} }],
commandAutocomplete: false,
localEcho: "auto",
@@ -116,11 +116,11 @@ describe(`${manifest.id} activate`, () => {
it("registers its settings components", async () => {
rendered = await renderWithApp(plugin, { manifest, locales });
expect(rendered.registered.settingsComponents()).toEqual(
expect.arrayContaining([
"terminalDefaults",
"newHostTheme",
"newHostFontFamily",
]),
expect.arrayContaining(["touchInput", "imageStorageTest"]),
);
// Terminal defaults are host defaults now, set in the host editor.
expect(rendered.registered.settingsComponents()).not.toContain(
"terminalDefaults",
);
});
@@ -1,37 +0,0 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
import { TerminalDefaultsSettings } from "../../../src/frontend/settings/TerminalDefaultsSettings";
afterEach(cleanup);
describe("TerminalDefaultsSettings", () => {
it("counts the defaults the user set", () => {
render(
<TerminalDefaultsSettings
pluginId="ssh-terminal"
values={{ terminalDefaults: { fontSize: 18, cursorBlink: false } }}
setValue={vi.fn()}
running
/>,
);
expect(screen.getByText("2")).toBeTruthy();
});
it("applies the dialog's values to the terminalDefaults setting", () => {
const setValue = vi.fn();
render(
<TerminalDefaultsSettings
pluginId="ssh-terminal"
values={{ terminalDefaults: { fontSize: 18 } }}
setValue={setValue}
running
/>,
);
fireEvent.click(screen.getByText("terminalDefaults.manage"));
fireEvent.change(screen.getByDisplayValue("18"), {
target: { value: "20" },
});
fireEvent.click(screen.getByText("terminalDefaults.apply"));
expect(setValue).toHaveBeenCalledWith("terminalDefaults", { fontSize: 20 });
});
});
@@ -1,17 +0,0 @@
import { describe, expect, it } from "vitest";
import {
fromTriState,
toTriState,
} from "../../../src/frontend/settings/tri-state";
describe("tri-state", () => {
it("keeps unset booleans distinct from false", () => {
expect(toTriState(undefined)).toBe("inherit");
expect(toTriState(true)).toBe("on");
expect(toTriState(false)).toBe("off");
expect(fromTriState("inherit")).toBeUndefined();
expect(fromTriState("on")).toBe(true);
expect(fromTriState("off")).toBe(false);
});
});
@@ -43,6 +43,7 @@ export function HostTmuxMonitorSection({
<div className="flex flex-col gap-4 py-3">
<SettingRow
label={t("hosts.enableTmuxMonitor")}
defaultKey="enableTmuxMonitor"
description={
<>
{t("hosts.enableTmuxMonitorDesc")}{" "}
@@ -55,6 +55,7 @@ export function activate(app: TermixApp): void {
titleKey: "hosts.tabTmuxMonitor",
icon: Layers,
order: 45,
defaults: true,
component: HostTmuxMonitorSection,
});
}
+2 -1
View File
@@ -79,7 +79,8 @@
"key": "tunnelConnections",
"type": "custom",
"component": "tunnelConnections",
"requires": "enableTunnel"
"requires": "enableTunnel",
"defaultable": false
}
]
}
+2 -1
View File
@@ -62,7 +62,8 @@
"type": "number",
"labelKey": "settings.host.profileId.label",
"hidden": true,
"ownerOnly": true
"ownerOnly": true,
"defaultLevels": ["user", "folder"]
}
]
}
+2 -1
View File
@@ -46,7 +46,8 @@
"type": "string",
"labelKey": "settings.host.macAddress.label",
"descriptionKey": "settings.host.macAddress.description",
"placeholderKey": "settings.host.macAddress.placeholder"
"placeholderKey": "settings.host.macAddress.placeholder",
"defaultable": false
},
{
"key": "broadcastAddress",
+2 -1
View File
@@ -47,7 +47,8 @@
"key": "webUiConfig",
"type": "json",
"labelKey": "hosts.webUiEndpointsSection",
"requires": "enableWebUi"
"requires": "enableWebUi",
"defaultable": false
}
]
}
+3
View File
@@ -93,6 +93,8 @@ const ALLOWED = [
"GridColSpan",
"GridLayout",
"GridSlot",
"HostDefaultBadge",
"HostDefaultField",
"HostFeatureFields",
"HostFeatureFieldsProps",
"HostKeyVerificationDialog",
@@ -200,6 +202,7 @@ const ALLOWED = [
"useAppTheme",
"useConfirmation",
"useConnectionLog",
"useIsDefaultsEditor",
"useIsMobile",
"useOptionalConnectionLog",
"useTabs",
+26
View File
@@ -1,5 +1,14 @@
import { getErrorMessage } from "../utils/error-message.js";
import { sshOptionsForWrite } from "../hosts/ssh-options.js";
import {
applyDefaultsAfterHostWrite,
applyHostDefaultsToWrite,
} from "../hosts/defaults/index.js";
import { recompute } from "../hosts/defaults/recompute.js";
import {
importHostDefaults,
writeHostDefaultsToExport,
} from "../hosts/defaults/user-export.js";
import express from "express";
import http from "http";
import https from "https";
@@ -951,6 +960,7 @@ app.post("/database/export", authenticateJWT, async (req, res) => {
}
writeSettingsToExportDatabase(exportDb, await getExportableSettings());
await writeHostDefaultsToExport(exportDb, userId);
} finally {
exportDb.close();
}
@@ -1197,6 +1207,15 @@ app.post(
updatedAt: new Date().toISOString(),
};
(hostData as Record<string, unknown>).defaultOverrides =
JSON.stringify(
await applyHostDefaultsToWrite({
ownerId: userId,
hostId: null,
columns: hostData as Record<string, unknown>,
body: hostData as Record<string, unknown>,
}),
);
const created = await hostRepository.createEncryptedForUser(
userId,
hostData,
@@ -1205,6 +1224,7 @@ app.post(
Number(created.id),
importedHostPluginSettings(importDb, host),
);
await applyDefaultsAfterHostWrite(Number(created.id));
await importHostProtocolLogins(
importDb,
host,
@@ -1285,6 +1305,12 @@ app.post(
result.summary.skippedItems += pluginRows.skipped;
result.summary.errors.push(...pluginRows.errors);
const hostDefaults = await importHostDefaults(importDb, userId);
result.summary.skippedItems += hostDefaults.skipped;
if (hostDefaults.imported > 0) {
await recompute({ userIds: [userId] });
}
const targetUser = await userRepository.findById(userId);
if (targetUser?.isAdmin) {
try {
+28
View File
@@ -1306,6 +1306,34 @@ const migrateSchema = () => {
});
}
addColumnIfNotExists("ssh_data", "default_overrides", "TEXT");
try {
sqlite.exec(`
CREATE TABLE IF NOT EXISTS host_defaults (
id INTEGER PRIMARY KEY AUTOINCREMENT,
level TEXT NOT NULL,
scope_key TEXT NOT NULL,
user_id TEXT,
folder_id INTEGER,
namespace TEXT NOT NULL,
key TEXT NOT NULL,
value TEXT,
updated_by TEXT,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE,
FOREIGN KEY (folder_id) REFERENCES ssh_folders (id) ON DELETE CASCADE
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_host_defaults_scope_key ON host_defaults (scope_key, namespace, key);
CREATE INDEX IF NOT EXISTS idx_host_defaults_user ON host_defaults (user_id);
CREATE INDEX IF NOT EXISTS idx_host_defaults_folder ON host_defaults (folder_id);
`);
} catch (createError) {
databaseLogger.warn("Failed to create host_defaults table", {
operation: "schema_migration",
error: createError,
});
}
try {
if (getRawSettingValue("rbac_permission_levels_v2") === null) {
sqlite.exec(
+40
View File
@@ -237,6 +237,11 @@ export const hosts = mysqlTable(
// Desktop only: set on the read-only copy of a host someone shared with
// the linked account. JSON with the share's owner and permission level.
sharedSource: text("shared_source"),
// Which host default keys this host sets itself, per namespace, as JSON:
// {"core":["sshPort"],"<pluginId>":["key"]}. Null, or a missing
// namespace, means not classified yet. Every other key follows the
// defaults and its column holds the resolved value.
defaultOverrides: text("default_overrides"),
createdAt: text("created_at")
.notNull()
@@ -390,6 +395,41 @@ export const sshFolders = mysqlTable(
(table) => [index("idx_ssh_folders_user_id").on(table.userId)],
);
/**
* Host defaults at one level. A host follows the deepest level that sets a
* key: its folders (deepest first), then its owner, then the server, then the
* built-in value. `scope_key` is "admin", "u:<userId>" or "f:<folderId>", so
* the unique index holds for the admin level too.
*/
export const hostDefaults = mysqlTable(
"host_defaults",
{
id: int("id").autoincrement().primaryKey(),
level: mysqlEnum("level", ["admin", "user", "folder"]).notNull(),
scopeKey: varchar("scope_key", { length: 255 }).notNull(),
userId: varchar("user_id", { length: 255 }).references(() => users.id, { onDelete: "cascade" }),
folderId: int("folder_id").references(() => sshFolders.id, {
onDelete: "cascade",
}),
namespace: varchar("namespace", { length: 255 }).notNull(),
key: varchar("key", { length: 255 }).notNull(),
value: text("value"),
updatedBy: text("updated_by"),
updatedAt: text("updated_at")
.notNull()
.default(sql`(CURRENT_TIMESTAMP)`),
},
(table) => [
uniqueIndex("idx_host_defaults_scope_key").on(
table.scopeKey,
table.namespace,
table.key,
),
index("idx_host_defaults_user").on(table.userId),
index("idx_host_defaults_folder").on(table.folderId),
],
);
export const recentActivity = mysqlTable(
"recent_activity",
{
+40
View File
@@ -237,6 +237,11 @@ export const hosts = pgTable(
// Desktop only: set on the read-only copy of a host someone shared with
// the linked account. JSON with the share's owner and permission level.
sharedSource: text("shared_source"),
// Which host default keys this host sets itself, per namespace, as JSON:
// {"core":["sshPort"],"<pluginId>":["key"]}. Null, or a missing
// namespace, means not classified yet. Every other key follows the
// defaults and its column holds the resolved value.
defaultOverrides: text("default_overrides"),
createdAt: text("created_at")
.notNull()
@@ -390,6 +395,41 @@ export const sshFolders = pgTable(
(table) => [index("idx_ssh_folders_user_id").on(table.userId)],
);
/**
* Host defaults at one level. A host follows the deepest level that sets a
* key: its folders (deepest first), then its owner, then the server, then the
* built-in value. `scope_key` is "admin", "u:<userId>" or "f:<folderId>", so
* the unique index holds for the admin level too.
*/
export const hostDefaults = pgTable(
"host_defaults",
{
id: serial("id").primaryKey(),
level: text("level", { enum: ["admin", "user", "folder"] }).notNull(),
scopeKey: varchar("scope_key", { length: 255 }).notNull(),
userId: varchar("user_id", { length: 255 }).references(() => users.id, { onDelete: "cascade" }),
folderId: integer("folder_id").references(() => sshFolders.id, {
onDelete: "cascade",
}),
namespace: varchar("namespace", { length: 255 }).notNull(),
key: varchar("key", { length: 255 }).notNull(),
value: text("value"),
updatedBy: text("updated_by"),
updatedAt: text("updated_at")
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
},
(table) => [
uniqueIndex("idx_host_defaults_scope_key").on(
table.scopeKey,
table.namespace,
table.key,
),
index("idx_host_defaults_user").on(table.userId),
index("idx_host_defaults_folder").on(table.folderId),
],
);
export const recentActivity = pgTable(
"recent_activity",
{
+40
View File
@@ -229,6 +229,11 @@ export const hosts = sqliteTable(
// Desktop only: set on the read-only copy of a host someone shared with
// the linked account. JSON with the share's owner and permission level.
sharedSource: text("shared_source"),
// Which host default keys this host sets itself, per namespace, as JSON:
// {"core":["sshPort"],"<pluginId>":["key"]}. Null, or a missing
// namespace, means not classified yet. Every other key follows the
// defaults and its column holds the resolved value.
defaultOverrides: text("default_overrides"),
createdAt: text("created_at")
.notNull()
@@ -382,6 +387,41 @@ export const sshFolders = sqliteTable(
(table) => [index("idx_ssh_folders_user_id").on(table.userId)],
);
/**
* Host defaults at one level. A host follows the deepest level that sets a
* key: its folders (deepest first), then its owner, then the server, then the
* built-in value. `scope_key` is "admin", "u:<userId>" or "f:<folderId>", so
* the unique index holds for the admin level too.
*/
export const hostDefaults = sqliteTable(
"host_defaults",
{
id: integer("id").primaryKey({ autoIncrement: true }),
level: text("level", { enum: ["admin", "user", "folder"] }).notNull(),
scopeKey: text("scope_key").notNull(),
userId: text("user_id").references(() => users.id, { onDelete: "cascade" }),
folderId: integer("folder_id").references(() => sshFolders.id, {
onDelete: "cascade",
}),
namespace: text("namespace").notNull(),
key: text("key").notNull(),
value: text("value"),
updatedBy: text("updated_by"),
updatedAt: text("updated_at")
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
},
(table) => [
uniqueIndex("idx_host_defaults_scope_key").on(
table.scopeKey,
table.namespace,
table.key,
),
index("idx_host_defaults_user").on(table.userId),
index("idx_host_defaults_folder").on(table.folderId),
],
);
export const recentActivity = sqliteTable(
"recent_activity",
{
@@ -16,6 +16,7 @@ import { OpenTabRepository } from "./open-tab-repository.js";
import { PluginRepository } from "./plugin-repository.js";
import { PluginStorageRepository } from "./plugin-storage-repository.js";
import { PluginSettingsRepository } from "./plugin-settings-repository.js";
import { HostDefaultsRepository } from "./host-defaults-repository.js";
import { PluginMigrationRepository } from "./plugin-migration-repository.js";
import { PluginPermissionGrantRepository } from "./plugin-permission-grant-repository.js";
import { UserAuthRepository } from "./user-auth-repository.js";
@@ -224,6 +225,13 @@ export function createCurrentPluginStorageRepository(): PluginStorageRepository
);
}
export function createCurrentHostDefaultsRepository(): HostDefaultsRepository {
return new HostDefaultsRepository(
createCurrentRepositoryContext(),
createCurrentRepositoryWriteHook("host_defaults_repository_write"),
);
}
export function createCurrentPluginSettingsRepository(): PluginSettingsRepository {
return new PluginSettingsRepository(
createCurrentRepositoryContext(),
@@ -0,0 +1,354 @@
import { and, eq, inArray, type SQL } from "drizzle-orm";
import { hostDefaults, hosts, sshFolders } from "../db/schema.js";
import type { DatabaseContext } from "./database-context.js";
import { rowsAffected } from "./mutation-result.js";
import type { HostDefaultsLevel } from "../../../types/host-defaults.js";
export type HostDefaultsRecord = typeof hostDefaults.$inferSelect;
export type HostRow = typeof hosts.$inferSelect;
export interface HostDefaultsScope {
level: HostDefaultsLevel;
userId?: string | null;
folderId?: number | null;
}
export interface HostDefaultsEntry {
namespace: string;
key: string;
/** JSON-encoded. */
value: string;
}
export function scopeKeyFor(scope: HostDefaultsScope): string {
if (scope.level === "admin") return "admin";
if (scope.level === "user") return `u:${scope.userId}`;
return `f:${scope.folderId}`;
}
const CHUNK = 500;
function chunks<T>(items: T[], size = CHUNK): T[][] {
const result: T[][] = [];
for (let i = 0; i < items.length; i += size) {
result.push(items.slice(i, i + size));
}
return result;
}
/**
* Host defaults at every level, plus the reads and writes that apply them to
* hosts in bulk. The resolution itself lives in hosts/defaults.
*/
export class HostDefaultsRepository {
constructor(
private readonly context: DatabaseContext,
private readonly onWrite?: () => void | Promise<void>,
) {}
async listScope(scope: HostDefaultsScope): Promise<HostDefaultsRecord[]> {
return this.context.drizzle
.select()
.from(hostDefaults)
.where(eq(hostDefaults.scopeKey, scopeKeyFor(scope)));
}
/** The admin rows, and every user and folder row the given users own. */
async listForUsers(userIds: string[]): Promise<HostDefaultsRecord[]> {
const admin = await this.context.drizzle
.select()
.from(hostDefaults)
.where(eq(hostDefaults.level, "admin"));
const own: HostDefaultsRecord[] = [];
for (const part of chunks([...new Set(userIds)])) {
own.push(
...(await this.context.drizzle
.select()
.from(hostDefaults)
.where(inArray(hostDefaults.userId, part))),
);
}
return [...admin, ...own];
}
/** The user's own and folder rows. */
async listOwnedBy(userId: string): Promise<HostDefaultsRecord[]> {
return this.context.drizzle
.select()
.from(hostDefaults)
.where(eq(hostDefaults.userId, userId));
}
/**
* Sets and clears keys at one level in one transaction. `set` values are
* already JSON-encoded.
*/
async apply(
scope: HostDefaultsScope,
set: HostDefaultsEntry[],
unset: Array<{ namespace: string; key: string }>,
updatedBy: string | null,
): Promise<void> {
const scopeKey = scopeKeyFor(scope);
const now = new Date().toISOString();
const match = (namespace: string, key: string) =>
and(
eq(hostDefaults.scopeKey, scopeKey),
eq(hostDefaults.namespace, namespace),
eq(hostDefaults.key, key),
)!;
const touched = [...set, ...unset];
if (touched.length === 0) return;
if (this.context.dialect === "sqlite") {
// better-sqlite3 runs a transaction synchronously.
const db = this.context.drizzle;
db.transaction((tx) => {
for (const entry of unset) {
tx.delete(hostDefaults)
.where(match(entry.namespace, entry.key))
.run();
}
for (const entry of set) {
const existing = tx
.select({ id: hostDefaults.id })
.from(hostDefaults)
.where(match(entry.namespace, entry.key))
.limit(1)
.all();
if (existing[0]) {
tx.update(hostDefaults)
.set({ value: entry.value, updatedBy, updatedAt: now })
.where(eq(hostDefaults.id, existing[0].id))
.run();
} else {
tx.insert(hostDefaults)
.values({
level: scope.level,
scopeKey,
userId: scope.level === "admin" ? null : (scope.userId ?? null),
folderId:
scope.level === "folder" ? (scope.folderId ?? null) : null,
namespace: entry.namespace,
key: entry.key,
value: entry.value,
updatedBy,
updatedAt: now,
})
.run();
}
}
});
} else {
await this.context.drizzle.transaction(async (tx) => {
for (const entry of unset) {
await tx
.delete(hostDefaults)
.where(match(entry.namespace, entry.key));
}
for (const entry of set) {
const existing = await tx
.select({ id: hostDefaults.id })
.from(hostDefaults)
.where(match(entry.namespace, entry.key))
.limit(1);
if (existing[0]) {
await tx
.update(hostDefaults)
.set({ value: entry.value, updatedBy, updatedAt: now })
.where(eq(hostDefaults.id, existing[0].id));
} else {
await tx.insert(hostDefaults).values({
level: scope.level,
scopeKey,
userId: scope.level === "admin" ? null : (scope.userId ?? null),
folderId:
scope.level === "folder" ? (scope.folderId ?? null) : null,
namespace: entry.namespace,
key: entry.key,
value: entry.value,
updatedBy,
updatedAt: now,
});
}
}
});
}
await this.onWrite?.();
}
async deleteScope(scope: HostDefaultsScope): Promise<number> {
const result = await this.context.drizzle
.delete(hostDefaults)
.where(eq(hostDefaults.scopeKey, scopeKeyFor(scope)));
const affected = rowsAffected(result);
if (affected > 0) await this.onWrite?.();
return affected;
}
/** Folder ids and paths for a set of users. */
async listFolders(
userIds: string[],
): Promise<Array<{ id: number; userId: string; name: string }>> {
const rows: Array<{ id: number; userId: string; name: string }> = [];
for (const part of chunks([...new Set(userIds)])) {
rows.push(
...(await this.context.drizzle
.select({
id: sshFolders.id,
userId: sshFolders.userId,
name: sshFolders.name,
})
.from(sshFolders)
.where(inArray(sshFolders.userId, part))),
);
}
return rows;
}
async findFolder(
folderId: number,
): Promise<{ id: number; userId: string; name: string } | null> {
const [row] = await this.context.drizzle
.select({
id: sshFolders.id,
userId: sshFolders.userId,
name: sshFolders.name,
})
.from(sshFolders)
.where(eq(sshFolders.id, folderId))
.limit(1);
return row ?? null;
}
/** The folder row for a path, created when the folder only exists on hosts. */
async ensureFolder(userId: string, name: string): Promise<number> {
const [existing] = await this.context.drizzle
.select({ id: sshFolders.id })
.from(sshFolders)
.where(and(eq(sshFolders.userId, userId), eq(sshFolders.name, name)))
.limit(1);
if (existing) return existing.id;
await this.context.drizzle.insert(sshFolders).values({ userId, name });
const [created] = await this.context.drizzle
.select({ id: sshFolders.id })
.from(sshFolders)
.where(and(eq(sshFolders.userId, userId), eq(sshFolders.name, name)))
.limit(1);
await this.onWrite?.();
return created.id;
}
/** Host rows by id or by owner. Secrets are read as stored, never used. */
async listHosts(filter: {
hostIds?: number[];
userIds?: string[];
all?: boolean;
}): Promise<HostRow[]> {
const db = this.context.drizzle;
if (filter.all) return db.select().from(hosts);
const rows: HostRow[] = [];
const conditions: SQL[] = [];
for (const part of chunks(filter.hostIds ?? [])) {
conditions.push(inArray(hosts.id, part));
}
for (const part of chunks(filter.userIds ?? [])) {
conditions.push(inArray(hosts.userId, part));
}
for (const condition of conditions) {
rows.push(...(await db.select().from(hosts).where(condition)));
}
const seen = new Set<number>();
return rows.filter((row) => {
if (seen.has(row.id)) return false;
seen.add(row.id);
return true;
});
}
/** Every host of the given owners, for walking sub-host chains. */
async listHostPlacement(userIds: string[]): Promise<
Array<{
id: number;
userId: string;
folder: string | null;
parentHostId: number | null;
}>
> {
const rows: Array<{
id: number;
userId: string;
folder: string | null;
parentHostId: number | null;
}> = [];
for (const part of chunks([...new Set(userIds)])) {
rows.push(
...(await this.context.drizzle
.select({
id: hosts.id,
userId: hosts.userId,
folder: hosts.folder,
parentHostId: hosts.parentHostId,
})
.from(hosts)
.where(inArray(hosts.userId, part))),
);
}
return rows;
}
/**
* Writes column patches, one statement per host, in one transaction.
* `updatedAt` is set on each, which is what sync looks for.
*/
async updateHosts(
patches: Array<{ id: number; values: Partial<HostRow> }>,
): Promise<void> {
if (patches.length === 0) return;
const now = new Date().toISOString();
for (const part of chunks(patches)) {
if (this.context.dialect === "sqlite") {
this.context.drizzle.transaction((tx) => {
for (const patch of part) {
tx.update(hosts)
.set({ ...patch.values, updatedAt: now })
.where(eq(hosts.id, patch.id))
.run();
}
});
} else {
await this.context.drizzle.transaction(async (tx) => {
for (const patch of part) {
await tx
.update(hosts)
.set({ ...patch.values, updatedAt: now })
.where(eq(hosts.id, patch.id));
}
});
}
}
await this.onWrite?.();
}
/** Rows that name a folder, credential or host a user no longer has. */
async deleteWhere(condition: SQL): Promise<number> {
const result = await this.context.drizzle
.delete(hostDefaults)
.where(condition);
const affected = rowsAffected(result);
if (affected > 0) await this.onWrite?.();
return affected;
}
async listByKey(
namespace: string,
key: string,
): Promise<HostDefaultsRecord[]> {
return this.context.drizzle
.select()
.from(hostDefaults)
.where(
and(eq(hostDefaults.namespace, namespace), eq(hostDefaults.key, key)),
);
}
}
+101 -1
View File
@@ -3,6 +3,16 @@ import {
remapImportedJumpHosts,
} from "./host-import-order.js";
import { sshOptionsForWrite } from "../../hosts/ssh-options.js";
import {
applyDefaultsAfterHostWrite,
applyHostDefaultsToWrite,
} from "../../hosts/defaults/index.js";
import {
changeHostOverrides,
type OverrideChange,
} from "../../hosts/defaults/overrides.js";
import { recompute } from "../../hosts/defaults/recompute.js";
import { splitDefaultKey } from "../../../types/host-defaults.js";
import {
keepUsableProtocolCredentials,
readProtocolAuthPayload,
@@ -16,6 +26,7 @@ import {
createCurrentCredentialRepository,
createCurrentHostRepository,
createCurrentHostResolutionRepository,
createCurrentHostDefaultsRepository,
} from "../repositories/factory.js";
import { validateParentHostId } from "./host-parent-validation.js";
import {
@@ -203,6 +214,20 @@ export function registerHostBulkRoutes(
* description: Plugin id to on or off, for each plugin that declares a host enable switch.
* additionalProperties:
* type: boolean
* resetDefaults:
* type: object
* description: Hands keys back to the host defaults. `all`, or `namespaces` ("core" or a plugin id), or `keys` as "namespace.key".
* properties:
* all:
* type: boolean
* namespaces:
* type: array
* items:
* type: string
* keys:
* type: array
* items:
* type: string
* responses:
* 200:
* description: Bulk update completed.
@@ -309,6 +334,14 @@ export function registerHostBulkRoutes(
simpleUpdates,
);
}
if (typeof updates.statusCheckEnabled === "boolean") {
await changeHostOverrides(ownedIds, {
own: [["core", "statusCheckEnabled"]],
});
}
if ("folder" in simpleUpdates || "parentHostId" in simpleUpdates) {
void recompute({ userIds: [userId] }).catch(() => {});
}
// Each plugin's own host switch, by plugin id: the field its manifest
// names in contributes.settings.host.enableKey.
@@ -323,6 +356,9 @@ export function registerHostBulkRoutes(
}
}
const reset = readDefaultsReset(updates.resetDefaults);
if (reset) await changeHostOverrides(ownedIds, reset);
return res.json({
updated: ownedIds.length,
failed: unauthorizedIds.length,
@@ -727,6 +763,12 @@ export function registerHostBulkRoutes(
const lookupKey = `${hostData.ip}:${hostData.port}:${hostData.username}`;
const existing = existingHostMap?.get(lookupKey);
await withHostDefaults(
userId,
existing?.id ?? null,
sshDataObj,
hostData as Record<string, unknown>,
);
let savedHostId: number;
if (existing) {
@@ -770,6 +812,7 @@ export function registerHostBulkRoutes(
savedHostId,
hostData as Record<string, unknown>,
);
await applyDefaultsAfterHostWrite(savedHostId);
} catch (error) {
results.failed++;
results.errors.push(`Host ${i + 1}: ${getErrorMessage(error)}`);
@@ -947,6 +990,12 @@ export function registerHostBulkRoutes(
const lookupKey = `${hostData.ip}:${hostData.port}:${hostData.username}`;
const existing = existingHostMap?.get(lookupKey);
await withHostDefaults(
userId,
existing?.id ?? null,
sshDataObj,
hostData as unknown as Record<string, unknown>,
);
if (existing) {
await hostRepository.updateEncryptedForUser(
@@ -954,10 +1003,15 @@ export function registerHostBulkRoutes(
existing.id,
sshDataObj,
);
await applyDefaultsAfterHostWrite(existing.id);
results.updated++;
} else {
sshDataObj.createdAt = new Date().toISOString();
await hostRepository.createEncryptedForUser(userId, sshDataObj);
const saved = await hostRepository.createEncryptedForUser(
userId,
sshDataObj,
);
await applyDefaultsAfterHostWrite(saved.id);
results.success++;
}
} catch (error) {
@@ -1022,3 +1076,49 @@ function listKnownAuthTypes(): Set<string> {
...listSshAuthTypeOwners().map((owner) => owner.type),
]);
}
/** Fills an imported host's inherited keys and records what it sets itself. */
async function withHostDefaults(
userId: string,
hostId: number | null,
columns: Record<string, unknown>,
body: Record<string, unknown>,
): Promise<void> {
const stored =
hostId === null
? null
: ((
await createCurrentHostDefaultsRepository().listHosts({
hostIds: [hostId],
})
)[0] ?? null);
columns.defaultOverrides = JSON.stringify(
await applyHostDefaultsToWrite({
ownerId: userId,
hostId,
columns,
body,
stored,
}),
);
}
/** A resetDefaults body as an override change, or null when there is none. */
export function readDefaultsReset(raw: unknown): OverrideChange | null {
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return null;
const source = raw as Record<string, unknown>;
const strings = (value: unknown) =>
Array.isArray(value)
? value.filter((item): item is string => typeof item === "string")
: [];
const change: OverrideChange = {
inheritAll: source.all === true,
inheritNamespaces: strings(source.namespaces),
inherit: strings(source.keys).map(splitDefaultKey),
};
return change.inheritAll ||
change.inheritNamespaces!.length > 0 ||
change.inherit!.length > 0
? change
: null;
}
@@ -0,0 +1,708 @@
import type { Request, RequestHandler, Response, Router } from "express";
import type { AuthenticatedRequest } from "../../../types/index.js";
import { databaseLogger } from "../../utils/logger.js";
import {
getRequestMeta,
logAudit,
getAuditUsername,
} from "../../utils/audit-logger.js";
import { PermissionManager } from "../../utils/permission-manager.js";
import { createCurrentHostDefaultsRepository } from "../repositories/factory.js";
import type { HostDefaultsScope } from "../repositories/host-defaults-repository.js";
import { findUsableCredential } from "../../hosts/usable-credential.js";
import {
currentCatalog,
materializeHosts,
} from "../../hosts/defaults/materialize.js";
import {
getRecomputeJob,
recompute,
startRecomputeJob,
targetForScope,
} from "../../hosts/defaults/recompute.js";
import {
levelsAbove,
readLevel,
resolveAll,
resolveForEditor,
saveLevel,
validateLevelChange,
type CheckContext,
type LevelChangeInput,
} from "../../hosts/defaults/service.js";
import { changeHostOverrides } from "../../hosts/defaults/overrides.js";
import { readDefaultsReset } from "./host-bulk-routes.js";
type HostDefaultsRoutesDeps = {
authenticateJWT: RequestHandler;
requireEditPermission: RequestHandler;
requireDataAccess: RequestHandler;
requireAdminSettings: RequestHandler;
};
function checkContext(actorId: string, ownerId: string | null): CheckContext {
const permissions = PermissionManager.getInstance();
return {
actorId,
ownerId,
canUseCredential: async (credentialId, userId) =>
(await findUsableCredential(credentialId, userId)) !== null,
canUseHost: async (hostId, userId) =>
(await permissions.canAccessHost(userId, hostId, "connect")).hasAccess,
};
}
function readChange(body: unknown): LevelChangeInput {
const source =
body && typeof body === "object" ? (body as Record<string, unknown>) : {};
return {
set:
source.set && typeof source.set === "object" && !Array.isArray(source.set)
? (source.set as Record<string, unknown>)
: {},
unset: Array.isArray(source.unset)
? source.unset.filter((key): key is string => typeof key === "string")
: [],
};
}
async function describeLevel(
scope: HostDefaultsScope,
folderOwner?: { userId: string; name: string },
) {
const { catalog } = currentCatalog();
return {
values: await readLevel(scope),
inherited: resolveAll(catalog, await levelsAbove(scope, folderOwner)),
};
}
/** The folder a user may edit defaults for, by id. */
async function ownedFolder(userId: string, folderId: number) {
const folder =
await createCurrentHostDefaultsRepository().findFolder(folderId);
return folder && folder.userId === userId ? folder : null;
}
export function registerHostDefaultsRoutes(
router: Router,
{
authenticateJWT,
requireEditPermission,
requireDataAccess,
requireAdminSettings,
}: HostDefaultsRoutesDeps,
): void {
const save = async (
req: Request,
res: Response,
scope: HostDefaultsScope,
ownerId: string | null,
folderOwnerId?: string,
) => {
const actorId = (req as AuthenticatedRequest).userId;
const change = await validateLevelChange(
scope,
readChange(req.body),
checkContext(actorId, ownerId),
);
if (Object.keys(change.errors).length > 0) {
return res
.status(400)
.json({ error: "Some defaults were rejected", errors: change.errors });
}
await saveLevel(scope, change, actorId);
const keys = new Set([...change.set.keys(), ...change.unset]);
const { ipAddress, userAgent } = getRequestMeta(req);
await logAudit({
userId: actorId,
username: await getAuditUsername(actorId),
action: "update_host_defaults",
resourceType: "host_defaults",
resourceId:
scope.level === "admin"
? "admin"
: scope.level === "user"
? String(scope.userId)
: String(scope.folderId),
resourceName: scope.level,
details: JSON.stringify({ keys: [...keys] }),
ipAddress,
userAgent,
success: true,
});
const target = targetForScope(scope, folderOwnerId);
if (scope.level === "admin") {
const job = startRecomputeJob(target, keys);
return res.json({ jobId: job.id });
}
const result = await recompute(target, keys);
return res.json({ changedHosts: result.changedHostIds.length });
};
/**
* @openapi
* /host/defaults/admin:
* get:
* summary: Get the server's host defaults
* description: The host defaults every user's hosts follow unless the user, a folder or the host sets its own. Returns the values set at this level and what each key reads without them.
* tags:
* - Host Defaults
* responses:
* 200:
* description: Values and inherited values, keyed by "namespace.key".
* 403:
* description: Missing admin.settings.manage.
*/
router.get(
"/defaults/admin",
authenticateJWT,
requireAdminSettings,
async (_req: Request, res: Response) => {
try {
res.json(await describeLevel({ level: "admin" }));
} catch (error) {
databaseLogger.error("Failed to read admin host defaults", error);
res.status(500).json({ error: "Failed to read host defaults" });
}
},
);
/**
* @openapi
* /host/defaults/admin:
* put:
* summary: Update the server's host defaults
* description: Sets and clears server-wide host defaults. Every inheriting host is updated in the background; poll the returned job.
* tags:
* - Host Defaults
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* set:
* type: object
* description: Values by "namespace.key".
* unset:
* type: array
* items:
* type: string
* responses:
* 200:
* description: Saved. Returns the recompute job id.
* 400:
* description: One or more values were rejected.
* 403:
* description: Missing admin.settings.manage.
*/
router.put(
"/defaults/admin",
authenticateJWT,
requireAdminSettings,
async (req: Request, res: Response) => {
try {
await save(req, res, { level: "admin" }, null);
} catch (error) {
databaseLogger.error("Failed to update admin host defaults", error);
res.status(500).json({ error: "Failed to update host defaults" });
}
},
);
/**
* @openapi
* /host/defaults/user:
* get:
* summary: Get your host defaults
* description: The defaults your hosts follow, over the server's. Returns the values you set and what each key reads without them.
* tags:
* - Host Defaults
* responses:
* 200:
* description: Values and inherited values, keyed by "namespace.key".
*/
router.get(
"/defaults/user",
authenticateJWT,
requireDataAccess,
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
try {
res.json(await describeLevel({ level: "user", userId }));
} catch (error) {
databaseLogger.error("Failed to read user host defaults", error);
res.status(500).json({ error: "Failed to read host defaults" });
}
},
);
/**
* @openapi
* /host/defaults/user:
* put:
* summary: Update your host defaults
* description: Sets and clears your own host defaults and updates every host of yours that follows them.
* tags:
* - Host Defaults
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* set:
* type: object
* unset:
* type: array
* items:
* type: string
* responses:
* 200:
* description: Saved. Returns how many hosts changed.
* 400:
* description: One or more values were rejected.
*/
router.put(
"/defaults/user",
authenticateJWT,
requireEditPermission,
requireDataAccess,
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
try {
await save(req, res, { level: "user", userId }, userId);
} catch (error) {
databaseLogger.error("Failed to update user host defaults", error);
res.status(500).json({ error: "Failed to update host defaults" });
}
},
);
/**
* @openapi
* /host/defaults/folders/{folderId}:
* get:
* summary: Get a folder's host defaults
* description: The defaults hosts in this folder and its subfolders follow, over yours and the server's.
* tags:
* - Host Defaults
* parameters:
* - in: path
* name: folderId
* required: true
* schema:
* type: integer
* responses:
* 200:
* description: Values and inherited values, keyed by "namespace.key".
* 404:
* description: No such folder of yours.
*/
router.get(
"/defaults/folders/:folderId",
authenticateJWT,
requireDataAccess,
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
const folder = await ownedFolder(userId, Number(req.params.folderId));
if (!folder) return res.status(404).json({ error: "Folder not found" });
try {
res.json(
await describeLevel(
{ level: "folder", folderId: folder.id, userId },
folder,
),
);
} catch (error) {
databaseLogger.error("Failed to read folder host defaults", error);
res.status(500).json({ error: "Failed to read host defaults" });
}
},
);
/**
* @openapi
* /host/defaults/folders/{folderId}:
* put:
* summary: Update a folder's host defaults
* description: Sets and clears a folder's host defaults and updates every host in it (and its subfolders) that follows them.
* tags:
* - Host Defaults
* parameters:
* - in: path
* name: folderId
* required: true
* schema:
* type: integer
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* responses:
* 200:
* description: Saved. Returns how many hosts changed.
* 400:
* description: One or more values were rejected.
* 404:
* description: No such folder of yours.
*/
router.put(
"/defaults/folders/:folderId",
authenticateJWT,
requireEditPermission,
requireDataAccess,
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
const folder = await ownedFolder(userId, Number(req.params.folderId));
if (!folder) return res.status(404).json({ error: "Folder not found" });
try {
await save(
req,
res,
{ level: "folder", folderId: folder.id, userId },
userId,
userId,
);
} catch (error) {
databaseLogger.error("Failed to update folder host defaults", error);
res.status(500).json({ error: "Failed to update host defaults" });
}
},
);
/**
* @openapi
* /host/defaults/folders:
* post:
* summary: Get or create a folder's defaults id
* description: A folder can exist only as a path on its hosts. This returns its id, creating the folder row when needed, so its defaults can be edited.
* tags:
* - Host Defaults
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* name:
* type: string
* responses:
* 200:
* description: The folder's id.
* 400:
* description: Missing folder name.
*/
router.post(
"/defaults/folders",
authenticateJWT,
requireEditPermission,
requireDataAccess,
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
const name = typeof req.body?.name === "string" ? req.body.name : "";
if (!name.trim()) {
return res.status(400).json({ error: "Folder name is required" });
}
try {
const id = await createCurrentHostDefaultsRepository().ensureFolder(
userId,
name,
);
res.json({ id });
} catch (error) {
databaseLogger.error("Failed to find folder for host defaults", error);
res.status(500).json({ error: "Failed to find the folder" });
}
},
);
/**
* @openapi
* /host/defaults/preview:
* post:
* summary: Preview a host defaults change
* description: How many hosts would change if a level were saved with these values. Nothing is written.
* tags:
* - Host Defaults
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* level:
* type: string
* enum: [admin, user, folder]
* folderId:
* type: integer
* set:
* type: object
* unset:
* type: array
* items:
* type: string
* responses:
* 200:
* description: The number of hosts that would change.
* 403:
* description: Not allowed to change that level.
*/
router.post(
"/defaults/preview",
authenticateJWT,
requireDataAccess,
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
const level = req.body?.level;
try {
let target;
let overlayUserId: string | undefined;
let folderId: number | undefined;
if (level === "admin") {
const permissions = PermissionManager.getInstance();
if (
!(await permissions.hasPermission(userId, "admin.settings.manage"))
) {
return res.status(403).json({ error: "Access denied" });
}
target = { all: true };
} else if (level === "user") {
target = { userIds: [userId] };
overlayUserId = userId;
} else if (level === "folder") {
const folder = await ownedFolder(userId, Number(req.body?.folderId));
if (!folder)
return res.status(404).json({ error: "Folder not found" });
target = { userIds: [userId] };
folderId = folder.id;
} else {
return res.status(400).json({ error: "Unknown level" });
}
const change = readChange(req.body);
const { catalog } = currentCatalog();
const set = new Map<string, unknown>();
for (const [key, value] of Object.entries(change.set ?? {})) {
const info = catalog.get(key);
if (info) set.set(key, info.normalize(value));
}
const result = await materializeHosts(target, {
dryRun: true,
overlay: {
level,
userId: overlayUserId,
folderId,
set,
unset: new Set(change.unset ?? []),
},
});
res.json({ changedHosts: result.changedHostIds.length });
} catch (error) {
databaseLogger.error("Failed to preview host defaults", error);
res.status(500).json({ error: "Failed to preview host defaults" });
}
},
);
/**
* @openapi
* /host/defaults/resolve:
* get:
* summary: Resolve host defaults for a host
* description: What every key resolves to for a host, or for a new host in a folder or under a parent host, and where each value comes from.
* tags:
* - Host Defaults
* parameters:
* - in: query
* name: hostId
* schema:
* type: integer
* - in: query
* name: folder
* schema:
* type: string
* - in: query
* name: parentHostId
* schema:
* type: integer
* responses:
* 200:
* description: Resolved values with their source, keyed by "namespace.key".
* 404:
* description: No such host you can edit.
*/
router.get(
"/defaults/resolve",
authenticateJWT,
requireDataAccess,
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
try {
let ownerId = userId;
const hostId =
req.query.hostId !== undefined ? Number(req.query.hostId) : null;
if (hostId !== null) {
if (!Number.isInteger(hostId)) {
return res.status(400).json({ error: "Invalid host ID" });
}
const access = await PermissionManager.getInstance().canAccessHost(
userId,
hostId,
"edit",
);
if (!access.hasAccess) {
return res.status(404).json({ error: "Host not found" });
}
const [row] = await createCurrentHostDefaultsRepository().listHosts({
hostIds: [hostId],
});
if (!row) return res.status(404).json({ error: "Host not found" });
ownerId = row.userId;
}
const folder =
typeof req.query.folder === "string" ? req.query.folder : undefined;
const parentHostId =
req.query.parentHostId !== undefined && req.query.parentHostId !== ""
? Number(req.query.parentHostId)
: undefined;
res.json({
values: await resolveForEditor({
ownerId,
hostId,
folder: folder === undefined ? undefined : folder || null,
parentHostId:
parentHostId === undefined || !Number.isInteger(parentHostId)
? folder !== undefined
? null
: undefined
: parentHostId,
}),
});
} catch (error) {
databaseLogger.error("Failed to resolve host defaults", error);
res.status(500).json({ error: "Failed to resolve host defaults" });
}
},
);
/**
* @openapi
* /host/defaults/jobs/{id}:
* get:
* summary: Host defaults job status
* description: Progress of a server-wide host defaults update.
* tags:
* - Host Defaults
* parameters:
* - in: path
* name: id
* required: true
* schema:
* type: string
* responses:
* 200:
* description: The job's status and how many hosts changed.
* 404:
* description: No such job.
*/
router.get(
"/defaults/jobs/:id",
authenticateJWT,
requireAdminSettings,
(req: Request, res: Response) => {
const job = getRecomputeJob(String(req.params.id));
if (!job) return res.status(404).json({ error: "Job not found" });
res.json(job);
},
);
/**
* @openapi
* /host/db/host/{id}/reset-defaults:
* post:
* summary: Reset a host to its defaults
* description: Hands keys the host set itself back to its host defaults. `all`, or `namespaces` ("core" or a plugin id), or `keys` as "namespace.key".
* tags:
* - Host Defaults
* parameters:
* - in: path
* name: id
* required: true
* schema:
* type: integer
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* all:
* type: boolean
* namespaces:
* type: array
* items:
* type: string
* keys:
* type: array
* items:
* type: string
* responses:
* 200:
* description: Reset.
* 400:
* description: Nothing to reset.
* 403:
* description: No edit access to the host.
*/
router.post(
"/db/host/:id/reset-defaults",
authenticateJWT,
requireEditPermission,
requireDataAccess,
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
const hostId = Number(req.params.id);
if (!Number.isInteger(hostId)) {
return res.status(400).json({ error: "Invalid host ID" });
}
const reset = readDefaultsReset(req.body);
if (!reset) return res.status(400).json({ error: "Nothing to reset" });
try {
const access = await PermissionManager.getInstance().canAccessHost(
userId,
hostId,
"edit",
);
if (!access.hasAccess) {
return res.status(403).json({ error: "Access denied" });
}
if (!access.isOwner) {
// A shared editor never takes over the owner's SSH login.
reset.inherit = (reset.inherit ?? []).filter(
([namespace, key]) => !(namespace === "core" && key === "auth"),
);
if (reset.inheritAll || reset.inheritNamespaces?.includes("core")) {
return res.status(403).json({
error: "Only the host owner can reset the host's SSH login",
});
}
}
await changeHostOverrides([hostId], reset);
res.json({ message: "Host reset to its defaults" });
} catch (error) {
databaseLogger.error("Failed to reset host defaults", error);
res.status(500).json({ error: "Failed to reset host defaults" });
}
},
);
}
@@ -11,6 +11,8 @@ import {
createCurrentSshCredentialUsageRepository,
} from "../repositories/factory.js";
import { isNonEmptyString } from "./host-normalizers.js";
import { recompute } from "../../hosts/defaults/recompute.js";
import { setFolderCredentialDefault } from "../../hosts/defaults/service.js";
type HostFolderRoutesDeps = {
authenticateJWT: RequestHandler;
@@ -92,6 +94,8 @@ export function registerHostFolderRoutes(
oldName,
newName,
);
// Subfolders change parents with a rename, so their chains do too.
void recompute({ userIds: [userId] }).catch(() => {});
res.json({
message: "Folder renamed successfully",
@@ -243,6 +247,14 @@ export function registerHostFolderRoutes(
localOnly,
);
}
if (normalizedCredentialId !== undefined) {
await setFolderCredentialDefault(
userId,
folder.id,
normalizedCredentialId,
);
void recompute({ userIds: [userId] }).catch(() => {});
}
if (!created) {
databaseLogger.info("Updating SSH folder", {
@@ -1,3 +1,4 @@
import { parseDefaultOverrides } from "../../../types/host-defaults.js";
import {
parseSshOptions,
type HostSshOptions,
@@ -481,5 +482,6 @@ export function transformHostResponse(
portKnockSequence: host.portKnockSequence
? JSON.parse(host.portKnockSequence as string)
: [],
defaultOverrides: parseDefaultOverrides(host.defaultOverrides),
};
}
@@ -224,14 +224,23 @@ export function attachHostPluginSettings(
}
}
/** The single-host convenience wrapper. */
/**
* The single-host convenience wrapper. With a viewer, a shared host's
* personal fields come from that viewer's own defaults.
*/
export async function withHostPluginSettings(
host: Record<string, unknown>,
viewerId?: string,
): Promise<Record<string, unknown>> {
const hostId = Number(host.id);
if (!Number.isInteger(hostId)) return host;
const settings = await loadHostPluginSettings([hostId]);
if (viewerId) {
const { applyPersonalHostValues } =
await import("../../hosts/defaults/personal.js");
await applyPersonalHostValues(settings, viewerId).catch(() => {});
}
const values = settings.get(hostId);
if (!values) return host;
const result: Record<string, unknown> = {
@@ -391,5 +400,8 @@ export async function setHostPluginEnabled(
await setSetting(manifest, "host", hostId, enableKey, enabled);
await touchHost(hostId);
}
const { changeHostOverrides } =
await import("../../hosts/defaults/overrides.js");
await changeHostOverrides(hostIds, { own: [[pluginId, enableKey]] });
return true;
}
+60 -7
View File
@@ -26,6 +26,7 @@ import {
createCurrentHostRepository,
createCurrentUserRepository,
createCurrentSharedHostAuthOverrideRepository,
createCurrentHostDefaultsRepository,
} from "../repositories/factory.js";
import {
applyHostKeyTypeUpdate,
@@ -51,6 +52,7 @@ import { validateParentHostId } from "./host-parent-validation.js";
import { registerHostFolderRoutes } from "./host-folder-routes.js";
import { registerHostNetworkRoutes } from "./host-network-routes.js";
import { registerHostBulkRoutes } from "./host-bulk-routes.js";
import { registerHostDefaultsRoutes } from "./host-defaults-routes.js";
import { registerHostStatusRoutes } from "./host-status-routes.js";
import {
applyHostEnrollmentDefaults,
@@ -71,6 +73,11 @@ import {
} from "../../utils/shared-host-auth-resolver.js";
import { rejectSharedCopyWrites } from "../../sync/shared-copy-guard.js";
import { sshOptionsForWrite } from "../../hosts/ssh-options.js";
import {
applyDefaultsAfterHostWrite,
applyHostDefaultsToWrite,
} from "../../hosts/defaults/index.js";
import { applyPersonalHostValues } from "../../hosts/defaults/personal.js";
import {
applyProtocolAuthPlan,
attachProtocolAuth,
@@ -385,6 +392,14 @@ router.post(
}
try {
sshDataObj.defaultOverrides = JSON.stringify(
await applyHostDefaultsToWrite({
ownerId: userId,
hostId: null,
columns: sshDataObj,
body: hostData,
}),
);
const result = await createCurrentHostRepository().createEncryptedForUser(
userId,
sshDataObj,
@@ -402,6 +417,7 @@ router.post(
}
const createdHost = result;
await applyDefaultsAfterHostWrite(createdHost.id as number);
if (protocolAuthPatch) {
await writeProtocolAuth(
userId,
@@ -1072,11 +1088,37 @@ router.put(
return res.status(400).json({ error: terminalFieldsError });
}
const storedRow = (
await createCurrentHostDefaultsRepository().listHosts({
hostIds: [Number(hostId)],
})
)[0];
sshDataObj.defaultOverrides = JSON.stringify(
await applyHostDefaultsToWrite({
ownerId,
hostId: Number(hostId),
columns: sshDataObj,
body: hostData,
stored: storedRow,
lockedKeys: accessInfo.isOwner ? [] : ["auth"],
}),
);
await createCurrentHostRepository().updateEncryptedForUser(
ownerId,
Number(hostId),
sshDataObj,
);
await applyDefaultsAfterHostWrite(Number(hostId), {
moved:
!!storedRow &&
((sshDataObj.folder !== undefined &&
(sshDataObj.folder ?? null) !== (storedRow.folder ?? null)) ||
(sshDataObj.parentHostId !== undefined &&
(sshDataObj.parentHostId ?? null) !==
(storedRow.parentHostId ?? null))),
ownerId,
});
if (protocolAuthPlan) {
await applyProtocolAuthPlan(ownerId, Number(hostId), protocolAuthPlan);
}
@@ -1296,14 +1338,15 @@ router.get(
// After sanitizing: the connect-level projection reduces a shared host
// to an allowlist, which would drop this again. One query for the list.
attachHostPluginSettings(
sanitized,
await loadHostPluginSettings(
sanitized
.map((host) => Number(host.id))
.filter((id) => Number.isInteger(id)),
),
const pluginSettingsByHost = await loadHostPluginSettings(
sanitized
.map((host) => Number(host.id))
.filter((id) => Number.isInteger(id)),
);
await applyPersonalHostValues(pluginSettingsByHost, userId).catch(
() => {},
);
attachHostPluginSettings(sanitized, pluginSettingsByHost);
res.json(sanitized);
} catch (err) {
@@ -1432,6 +1475,7 @@ router.get(
resolvedSharedResult,
accessInfo.permissionLevel,
),
userId,
),
);
} catch (err) {
@@ -2301,6 +2345,15 @@ registerHostNetworkRoutes(router, {
requireDataAccess,
});
registerHostDefaultsRoutes(router, {
authenticateJWT,
requireEditPermission: permissionManager.requirePermission("hosts.edit"),
requireDataAccess,
requireAdminSettings: permissionManager.requirePermission(
"admin.settings.manage",
),
});
export default router;
/** Seconds between status checks, or null to follow the global setting. */
+18 -38
View File
@@ -14,7 +14,6 @@ import {
} from "../repositories/factory.js";
import { getPluginRuntime } from "../../plugins/index.js";
import { describePluginFrontend } from "../../plugins/assets.js";
import { getSetting } from "../../plugins/settings.js";
import { invalidatePluginPermissionCache } from "../../plugins/permissions.js";
import { getPluginPublicHttpRoutes } from "../../plugins/http.js";
import { getPluginPublicWsRoutes } from "../../plugins/ws.js";
@@ -254,7 +253,7 @@ router.get("/", authenticateJWT, async (req: Request, res: Response) => {
dependencies?: Record<string, string>;
optionalDependencies?: Record<string, string>;
};
contributes = await withHostFieldDefaults(manifest as PluginManifest);
contributes = manifest?.contributes ?? null;
capabilities = Array.isArray(manifest?.capabilities)
? (manifest.capabilities as string[])
: [];
@@ -1189,7 +1188,7 @@ router.get(
* /plugins/{id}/settings/host/{hostId}:
* put:
* summary: Update a plugin's settings for one host
* description: Writes host-scope values. Requires edit access to the host, so a user who may only connect to a shared host cannot change how a plugin treats it.
* description: Writes host-scope values. Requires edit access to the host, so a user who may only connect to a shared host cannot change how a plugin treats it. A value written here becomes the host's own rather than following the host defaults; list keys in `_inherit` to hand them back to the defaults.
* tags:
* - Plugins
* parameters:
@@ -1253,12 +1252,19 @@ router.put(
return;
}
const { _inherit: inheritRaw, ...written } = req.body as Record<
string,
unknown
>;
const inherit = Array.isArray(inheritRaw)
? inheritRaw.filter((key): key is string => typeof key === "string")
: [];
const errors = await applySettings(
userId,
manifest,
"host",
String(hostId),
req.body,
written,
{ isHostOwner: access.isOwner === true },
);
if (Object.keys(errors).length > 0) {
@@ -1267,6 +1273,14 @@ router.put(
}
const { touchHost } = await import("./host-plugin-settings.js");
await touchHost(hostId);
// A value set here is the host's own; a key in _inherit follows the
// host defaults again.
const { changeHostOverrides } =
await import("../../hosts/defaults/overrides.js");
await changeHostOverrides([hostId], {
own: Object.keys(written).map((key) => [pluginId, key]),
inherit: inherit.map((key) => [pluginId, key]),
});
const values = await getAllSettings(manifest, "host", hostId, {
redactSecrets: true,
@@ -1284,37 +1298,3 @@ router.put(
);
export default router;
/**
* A host field with defaultFrom starts new hosts at an admin setting's value,
* so the host editor gets that value as the field's default.
*/
async function withHostFieldDefaults(
manifest: PluginManifest,
): Promise<unknown> {
const contributes = manifest?.contributes;
const host = contributes?.settings?.host;
if (!host?.fields.some((field) => field.defaultFrom)) {
return contributes ?? null;
}
const fields: PluginSettingsField[] = await Promise.all(
host.fields.map(async (field) => {
if (!field.defaultFrom) return field;
try {
const value = await getSetting(
manifest,
"admin",
null,
field.defaultFrom,
);
return value === undefined ? field : { ...field, default: value };
} catch {
return field;
}
}),
);
return {
...contributes,
settings: { ...contributes.settings, host: { ...host, fields } },
};
}
@@ -22,43 +22,6 @@ import {
} from "../repositories/factory.js";
import type { UserRecord } from "../repositories/user-repository.js";
export type HostDefaults = {
useSocks5?: boolean;
socks5Host?: string;
socks5Port?: number;
socks5Username?: string;
socks5Password?: string;
credentialId?: number | null;
statusCheckEnabled?: boolean;
};
/**
* Keys host_defaults held before the terminal's new-host defaults became the
* ssh-terminal plugin's admin settings. Kept in the stored row for the boot
* copy and a downgrade, never served or overwritten here.
*/
const LEGACY_TERMINAL_HOST_DEFAULTS = [
"fontSize",
"fontFamily",
"theme",
"cursorStyle",
"cursorBlink",
"autoTmux",
"enableCommandHistory",
];
function parseHostDefaults(value: string | null): Record<string, unknown> {
if (!value) return {};
try {
const parsed = JSON.parse(value);
return parsed && typeof parsed === "object" && !Array.isArray(parsed)
? parsed
: {};
} catch {
return {};
}
}
async function getAdminActor(
userId: string | undefined,
): Promise<UserRecord | null> {
@@ -559,95 +522,4 @@ export function registerUserSettingsRoutes(
"update_notification_private_endpoints",
"notification endpoint",
);
/**
* @openapi
* /users/host-defaults:
* get:
* summary: Get host creation defaults
* description: Returns the global default settings applied when creating a new host.
* tags:
* - Users
* responses:
* 200:
* description: Host defaults object.
* 500:
* description: Failed to get host defaults.
*/
router.get("/host-defaults", authenticateJWT, async (_req, res) => {
try {
const stored = parseHostDefaults(
await createCurrentSettingsRepository().get("host_defaults"),
);
for (const key of LEGACY_TERMINAL_HOST_DEFAULTS) delete stored[key];
res.json(stored as HostDefaults);
} catch (err) {
authLogger.error("Failed to get host defaults", err);
res.status(500).json({ error: "Failed to get host defaults" });
}
});
/**
* @openapi
* /users/host-defaults:
* patch:
* summary: Update host creation defaults (admin only)
* description: Sets global default settings applied when a new host is created.
* tags:
* - Users
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* responses:
* 200:
* description: Host defaults updated.
* 403:
* description: Not authorized.
* 500:
* description: Failed to update host defaults.
*/
router.patch("/host-defaults", authenticateJWT, async (req, res) => {
const userId = (req as AuthenticatedRequest).userId;
try {
const actor = await getAdminActor(userId);
if (!actor) {
return res.status(403).json({ error: "Not authorized" });
}
const defaults: HostDefaults = { ...(req.body ?? {}) };
for (const key of LEGACY_TERMINAL_HOST_DEFAULTS) {
delete (defaults as Record<string, unknown>)[key];
}
const settings = createCurrentSettingsRepository();
const stored = parseHostDefaults(await settings.get("host_defaults"));
const legacy = Object.fromEntries(
LEGACY_TERMINAL_HOST_DEFAULTS.filter((key) => key in stored).map(
(key) => [key, stored[key]],
),
);
await settings.set(
"host_defaults",
JSON.stringify({ ...legacy, ...defaults }),
);
const { ipAddress, userAgent } = getRequestMeta(req);
await logAudit({
userId,
username: actor.username ?? userId,
action: "update_host_defaults",
resourceType: "setting",
details: JSON.stringify(defaults),
ipAddress,
userAgent,
success: true,
});
res.json(defaults);
} catch (err) {
authLogger.error("Failed to update host defaults", err);
res.status(500).json({ error: "Failed to update host defaults" });
}
});
}
+110
View File
@@ -0,0 +1,110 @@
/**
* Every key a host can inherit: core's own catalog plus each running
* plugin's host fields, in one shape the resolver and materializer share.
*/
import type {
PluginManifest,
PluginSettingsField,
} from "@termix/plugin-sdk/manifest";
import { coerceSettingValue } from "@termix/plugin-sdk/settings";
import {
CORE_HOST_DEFAULTS,
CORE_NAMESPACE,
HOST_DEFAULTS_LEVELS,
joinDefaultKey,
type HostDefaultsLevel,
} from "../../../types/host-defaults.js";
import { declaredFields } from "../../plugins/settings.js";
export interface DefaultKeyInfo {
namespace: string;
key: string;
fullKey: string;
levels: readonly HostDefaultsLevel[];
/** Undefined when nothing is inherited unless some level sets it. */
builtin: unknown;
normalize: (value: unknown) => unknown;
personal: boolean;
/** Plugin keys only. */
field?: PluginSettingsField;
}
export type DefaultsCatalog = Map<string, DefaultKeyInfo>;
/** Whether a plugin's host field can have a default. */
export function isDefaultableField(field: PluginSettingsField): boolean {
if (field.type === "secret") return false;
if (field.type === "json" && field.secretKeys?.length) return false;
return field.defaultable !== false;
}
/** A plugin's host fields that can have a default, enable switch included. */
export function defaultableHostFields(
manifest: PluginManifest,
): PluginSettingsField[] {
return declaredFields(manifest, "host").filter(isDefaultableField);
}
function isUnset(value: unknown): boolean {
return value === undefined || value === null || value === "";
}
export function normalizePluginValue(
field: PluginSettingsField,
value: unknown,
): unknown {
// A row with no value reads as the manifest default, so compare that way.
if (isUnset(value)) return field.default ?? null;
try {
const coerced = coerceSettingValue(field, value);
return isUnset(coerced) ? (field.default ?? null) : coerced;
} catch {
return value;
}
}
export function coreCatalogEntries(): DefaultKeyInfo[] {
return CORE_HOST_DEFAULTS.map((entry) => ({
namespace: CORE_NAMESPACE,
key: entry.key,
fullKey: joinDefaultKey(CORE_NAMESPACE, entry.key),
levels: entry.levels,
builtin: entry.builtin,
normalize: entry.normalize,
personal: false,
}));
}
export function pluginCatalogEntries(
manifest: PluginManifest,
): DefaultKeyInfo[] {
return defaultableHostFields(manifest).map((field) => ({
namespace: manifest.id,
key: field.key,
fullKey: joinDefaultKey(manifest.id, field.key),
levels: field.defaultLevels?.length
? field.defaultLevels
: HOST_DEFAULTS_LEVELS,
builtin: normalizePluginValue(field, field.default),
normalize: (value: unknown) => normalizePluginValue(field, value),
personal: field.personal === true,
field,
}));
}
export function buildCatalog(manifests: PluginManifest[]): DefaultsCatalog {
const catalog: DefaultsCatalog = new Map();
for (const entry of coreCatalogEntries()) catalog.set(entry.fullKey, entry);
for (const manifest of manifests) {
for (const entry of pluginCatalogEntries(manifest)) {
catalog.set(entry.fullKey, entry);
}
}
return catalog;
}
/** Namespaces the catalog covers: core plus each plugin with a defaultable field. */
export function catalogNamespaces(catalog: DefaultsCatalog): string[] {
return [...new Set([...catalog.values()].map((entry) => entry.namespace))];
}
+184
View File
@@ -0,0 +1,184 @@
/**
* Core host defaults in column form.
*
* A host row (or the column object a host route builds before its write) is
* read into a default's value and written back from one. Both directions go
* through the catalog's normalize, so a value compares the same however the
* row spelled it.
*/
import {
findCoreHostDefault,
normalizeAuthDefault,
normalizeSocks5Default,
} from "../../../types/host-defaults.js";
import { parseSshOptions } from "../ssh-options.js";
type Columns = Record<string, unknown>;
const SSH_OPTION_DEFAULTS = [
"keepaliveInterval",
"keepaliveCountMax",
"allowLegacyAlgorithms",
"agentForwarding",
"environmentVariables",
] as const;
function isSshOptionDefault(key: string): boolean {
return (SSH_OPTION_DEFAULTS as readonly string[]).includes(key);
}
function asBoolean(value: unknown): boolean {
return value === true || value === 1 || value === "1" || value === "true";
}
/** The value a core key has on a row, normalized. */
export function readCoreDefault(
key: string,
row: Columns,
storedSshOptions?: unknown,
): unknown {
const entry = findCoreHostDefault(key);
if (!entry) return undefined;
const sshOptions = parseSshOptions(
row.sshOptions !== undefined ? row.sshOptions : storedSshOptions,
) as Record<string, unknown>;
switch (key) {
case "username":
return entry.normalize(row.username);
case "sshPort":
return entry.normalize(row.sshPort ?? row.port);
case "auth":
return normalizeAuthDefault({
authType: row.authType,
credentialId: row.credentialId,
overrideCredentialUsername: asBoolean(row.overrideCredentialUsername),
agentSocketPath: sshOptions.agentSocketPath,
agentIdentity: sshOptions.agentIdentity,
});
case "forceKeyboardInteractive":
return entry.normalize(row.forceKeyboardInteractive);
case "socks5":
return normalizeSocks5Default({
useSocks5: asBoolean(row.useSocks5),
socks5Host: row.socks5Host,
socks5Port: row.socks5Port,
socks5Username: row.socks5Username,
socks5ProxyChain: row.socks5ProxyChain,
});
case "jumpHosts":
case "portKnockSequence":
case "statusCheckEnabled":
case "statusCheckInterval":
return entry.normalize(row[key]);
default:
if (isSshOptionDefault(key)) return entry.normalize(sshOptions[key]);
return undefined;
}
}
/**
* The column patch that gives a row this value. `row` is read for what the
* patch depends on: the connection type (an SSH host's port follows its SSH
* port) and the SSH options the others share a column with.
*/
export function writeCoreDefault(
key: string,
value: unknown,
row: Columns,
storedSshOptions?: unknown,
): Columns {
const entry = findCoreHostDefault(key);
if (!entry) return {};
const normalized = entry.normalize(value);
if (isSshOptionDefault(key) || key === "auth") {
const current = parseSshOptions(
row.sshOptions !== undefined ? row.sshOptions : storedSshOptions,
) as Record<string, unknown>;
if (key === "auth") {
const auth = normalizeAuthDefault(value);
if (!auth) return {};
const next = { ...current };
if (auth.authType === "agent") {
next.agentSocketPath = auth.agentSocketPath;
next.agentIdentity = auth.agentIdentity;
} else {
delete next.agentSocketPath;
delete next.agentIdentity;
}
// A credential host may keep its own password next to the credential;
// every other secretless type has no use for one.
return {
authType: auth.authType,
credentialId: auth.credentialId,
overrideCredentialUsername: auth.overrideCredentialUsername,
sshOptions: JSON.stringify(next),
...(auth.authType === "credential" ? {} : { password: null }),
key: null,
keyPassword: null,
keyType: null,
};
}
return { sshOptions: JSON.stringify({ ...current, [key]: normalized }) };
}
switch (key) {
case "username":
return normalized === null ? {} : { username: normalized };
case "sshPort": {
const patch: Columns = { sshPort: normalized };
if ((row.connectionType ?? "ssh") === "ssh") patch.port = normalized;
return patch;
}
case "forceKeyboardInteractive":
return { forceKeyboardInteractive: normalized ? "true" : "false" };
case "socks5": {
const socks = normalizeSocks5Default(value);
return {
useSocks5: socks.useSocks5,
socks5Host: socks.socks5Host,
socks5Port: socks.socks5Port,
socks5Username: socks.socks5Username,
socks5ProxyChain: socks.socks5ProxyChain
? JSON.stringify(mergeChainPasswords(socks.socks5ProxyChain, row))
: null,
};
}
case "jumpHosts":
case "portKnockSequence":
return {
[key]:
Array.isArray(normalized) && normalized.length > 0
? JSON.stringify(normalized)
: null,
};
case "statusCheckEnabled":
return { statusCheckEnabled: !!normalized };
case "statusCheckInterval":
return { statusCheckInterval: normalized };
default:
return {};
}
}
/** A chain that follows a default keeps the passwords the host saved for it. */
function mergeChainPasswords(chain: unknown[], row: Columns): unknown[] {
let current: unknown = row.socks5ProxyChain;
if (typeof current === "string") {
try {
current = JSON.parse(current);
} catch {
current = [];
}
}
const saved = Array.isArray(current) ? current : [];
return chain.map((node, index) => {
const own = saved[index] as Record<string, unknown> | undefined;
const password = own?.password;
return typeof password === "string" && password
? { ...(node as Record<string, unknown>), password }
: node;
});
}
+27
View File
@@ -0,0 +1,27 @@
import { databaseLogger } from "../../utils/logger.js";
import { recompute } from "./recompute.js";
export { applyHostDefaultsToWrite } from "./write-rules.js";
/**
* Brings one host's plugin settings in line with its defaults after a write,
* and, when it moved folder or parent, every host of its owner (a sub-host
* follows its parent's folder). A failure here never fails the write.
*/
export async function applyDefaultsAfterHostWrite(
hostId: number,
options: { moved?: boolean; ownerId?: string } = {},
): Promise<void> {
try {
await recompute({ hostIds: [hostId] });
if (options.moved && options.ownerId) {
void recompute({ userIds: [options.ownerId] }).catch(() => {});
}
} catch (error) {
databaseLogger.warn("Could not apply host defaults after a write", {
operation: "host_defaults_after_write",
hostId,
error: error instanceof Error ? error.message : String(error),
});
}
}
+139
View File
@@ -0,0 +1,139 @@
/**
* Loads host default levels from the database into the maps the resolver
* reads. One pass covers any number of users.
*/
import {
createCurrentHostDefaultsRepository,
createCurrentSettingsRepository,
} from "../../database/repositories/factory.js";
import type { HostDefaultsRecord } from "../../database/repositories/host-defaults-repository.js";
import { joinDefaultKey } from "../../../types/host-defaults.js";
import {
folderChainPaths,
type FolderLevel,
type HostLevels,
type LevelMap,
} from "./resolve.js";
/**
* On a desktop linked to a server, the server's admin defaults arrive over
* sync and are kept here. They stand in for the desktop's own admin level so
* both sides resolve a host the same way.
*/
export const REMOTE_ADMIN_DEFAULTS_KEY = "host_defaults_remote_admin";
export interface DefaultsIndex {
admin: LevelMap;
users: Map<string, LevelMap>;
/** userId -> folder path -> the folder's level. */
folders: Map<string, Map<string, FolderLevel>>;
}
export function decodeDefaultValue(raw: string | null): unknown {
if (raw === null) return null;
try {
return JSON.parse(raw);
} catch {
return null;
}
}
export function encodeDefaultValue(value: unknown): string {
return JSON.stringify(value ?? null);
}
async function remoteAdminLevel(): Promise<LevelMap | null> {
try {
const raw = await createCurrentSettingsRepository().get(
REMOTE_ADMIN_DEFAULTS_KEY,
);
if (!raw) return null;
const parsed = JSON.parse(raw);
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
return null;
}
return new Map(Object.entries(parsed as Record<string, unknown>));
} catch {
return null;
}
}
export function buildIndex(
rows: HostDefaultsRecord[],
folders: Array<{ id: number; userId: string; name: string }>,
): DefaultsIndex {
const index: DefaultsIndex = {
admin: new Map(),
users: new Map(),
folders: new Map(),
};
const folderById = new Map(folders.map((folder) => [folder.id, folder]));
for (const folder of folders) {
let own = index.folders.get(folder.userId);
if (!own) {
own = new Map();
index.folders.set(folder.userId, own);
}
own.set(folder.name, {
folderId: folder.id,
name: folder.name,
values: new Map(),
});
}
for (const row of rows) {
const fullKey = joinDefaultKey(row.namespace, row.key);
const value = decodeDefaultValue(row.value);
if (row.level === "admin") {
index.admin.set(fullKey, value);
} else if (row.level === "user" && row.userId) {
let own = index.users.get(row.userId);
if (!own) {
own = new Map();
index.users.set(row.userId, own);
}
own.set(fullKey, value);
} else if (row.level === "folder" && row.folderId !== null) {
const folder = folderById.get(row.folderId);
if (!folder) continue;
index.folders
.get(folder.userId)
?.get(folder.name)
?.values.set(fullKey, value);
}
}
return index;
}
export async function loadDefaultsIndex(
userIds: string[],
): Promise<DefaultsIndex> {
const repository = createCurrentHostDefaultsRepository();
const [rows, folders, remoteAdmin] = await Promise.all([
repository.listForUsers(userIds),
repository.listFolders(userIds),
remoteAdminLevel(),
]);
const index = buildIndex(rows, folders);
if (remoteAdmin) index.admin = remoteAdmin;
return index;
}
export function levelsForHost(
index: DefaultsIndex,
userId: string,
folderPath: string | null,
): HostLevels {
const own = index.folders.get(userId);
const folders: FolderLevel[] = [];
for (const path of folderChainPaths(folderPath)) {
const folder = own?.get(path);
if (folder) folders.push(folder);
}
return {
admin: index.admin,
user: index.users.get(userId) ?? new Map(),
folders,
};
}
+424
View File
@@ -0,0 +1,424 @@
/**
* Writes resolved defaults into the hosts that follow them.
*
* A host keeps its effective values in its own columns and plugin setting
* rows, so every reader (connect, export, sync, a 2.8 downgrade) sees plain
* values. What changes when a default does is this pass: every host that
* inherits the key gets the new value written, and nothing else is touched.
*
* A namespace a host has never been classified for (a host from before host
* defaults, from an older sync peer, or a plugin enabled later) is classified
* first: a value equal to what resolves inherits, anything else is the
* host's own.
*/
import type { PluginManifest } from "@termix/plugin-sdk/manifest";
import {
createCurrentHostDefaultsRepository,
createCurrentPluginSettingsRepository,
} from "../../database/repositories/factory.js";
import type { HostRow } from "../../database/repositories/host-defaults-repository.js";
import type { PluginSettingsRecord } from "../../database/repositories/plugin-settings-repository.js";
import {
CORE_NAMESPACE,
defaultValuesEqual,
parseDefaultOverrides,
type DefaultOverrides,
type HostDefaultsLevel,
type ResolvedHostDefault,
} from "../../../types/host-defaults.js";
import { hostSettingsPlugins } from "../../database/routes/host-plugin-settings.js";
import { notifySettingChange } from "../../plugins/settings.js";
import { databaseLogger } from "../../utils/logger.js";
import {
buildCatalog,
catalogNamespaces,
type DefaultKeyInfo,
type DefaultsCatalog,
} from "./catalog.js";
import { readCoreDefault, writeCoreDefault } from "./core-columns.js";
import {
loadDefaultsIndex,
levelsForHost,
type DefaultsIndex,
} from "./levels.js";
import {
effectiveFolderPath,
isOwnValue,
resolveKey,
withoutSelfJump,
type HostLevels,
type HostPlacement,
} from "./resolve.js";
export interface HostDefaultsTarget {
hostIds?: number[];
userIds?: string[];
all?: boolean;
}
/** A level change that has not been saved yet, for a preview. */
export interface LevelOverlay {
level: HostDefaultsLevel;
userId?: string;
folderId?: number;
set: Map<string, unknown>;
unset: Set<string>;
}
export interface MaterializeOptions {
/** Only these keys are rewritten. Classification still covers every key. */
keys?: Set<string>;
dryRun?: boolean;
overlay?: LevelOverlay;
}
export interface MaterializeResult {
changedHostIds: number[];
}
export interface PluginWrite {
hostId: number;
pluginId: string;
key: string;
/** undefined deletes the row, so the host reads the manifest default. */
value: unknown;
}
export interface HostPlan {
columns: Record<string, unknown>;
pluginWrites: PluginWrite[];
overrides: DefaultOverrides;
overridesChanged: boolean;
coreKeysChanged: string[];
}
type PluginRows = Map<string, Map<string, PluginSettingsRecord>>;
function decode(raw: string | null | undefined): unknown {
if (raw === null || raw === undefined) return undefined;
try {
return JSON.parse(raw);
} catch {
return undefined;
}
}
export function currentPluginValue(
info: DefaultKeyInfo,
rows: PluginRows | undefined,
): unknown {
const row = rows?.get(info.namespace)?.get(info.key);
return info.normalize(row ? decode(row.value) : undefined);
}
export function applyOverlay(
index: DefaultsIndex,
overlay: LevelOverlay | undefined,
): DefaultsIndex {
if (!overlay) return index;
const patch = (values: Map<string, unknown>) => {
const next = new Map(values);
for (const key of overlay.unset) next.delete(key);
for (const [key, value] of overlay.set) next.set(key, value);
return next;
};
if (overlay.level === "admin") {
return { ...index, admin: patch(index.admin) };
}
if (overlay.level === "user" && overlay.userId) {
const users = new Map(index.users);
users.set(overlay.userId, patch(users.get(overlay.userId) ?? new Map()));
return { ...index, users };
}
if (overlay.level === "folder" && overlay.folderId !== undefined) {
const folders = new Map(index.folders);
for (const [userId, byPath] of folders) {
for (const [path, folder] of byPath) {
if (folder.folderId !== overlay.folderId) continue;
const copy = new Map(byPath);
copy.set(path, { ...folder, values: patch(folder.values) });
folders.set(userId, copy);
}
}
return { ...index, folders };
}
return index;
}
/** What one key resolves to for one host, with the jump-host guard applied. */
export function resolveForHost(
info: DefaultKeyInfo,
levels: HostLevels,
hostId: number | null,
): ResolvedHostDefault | undefined {
const resolved = resolveKey(info, levels);
if (!resolved || hostId === null || info.fullKey !== "core.jumpHosts") {
return resolved;
}
return { ...resolved, value: withoutSelfJump(resolved.value, hostId) };
}
/**
* Plans one host: which namespaces get classified, and what each inheriting
* key has to be rewritten to. Pure, apart from the catalog it is handed.
*/
export function planHost(
row: HostRow,
levels: HostLevels,
catalog: DefaultsCatalog,
pluginRows: PluginRows | undefined,
options: { keys?: Set<string> } = {},
): HostPlan {
const stored = parseDefaultOverrides(row.defaultOverrides);
const overrides: DefaultOverrides = { ...(stored ?? {}) };
let overridesChanged = stored === null;
const byNamespace = new Map<string, DefaultKeyInfo[]>();
for (const info of catalog.values()) {
const list = byNamespace.get(info.namespace);
if (list) list.push(info);
else byNamespace.set(info.namespace, [info]);
}
const current = (info: DefaultKeyInfo) =>
info.namespace === CORE_NAMESPACE
? readCoreDefault(info.key, row as unknown as Record<string, unknown>)
: currentPluginValue(info, pluginRows);
for (const namespace of catalogNamespaces(catalog)) {
if (overrides[namespace]) continue;
overrides[namespace] = (byNamespace.get(namespace) ?? [])
.filter((info) =>
isOwnValue(info, current(info), resolveForHost(info, levels, row.id)),
)
.map((info) => info.key)
.sort();
overridesChanged = true;
}
let columns: Record<string, unknown> = {};
const pluginWrites: PluginWrite[] = [];
const coreKeysChanged: string[] = [];
for (const info of catalog.values()) {
if (options.keys && !options.keys.has(info.fullKey)) continue;
if (overrides[info.namespace]?.includes(info.key)) continue;
const resolved = resolveForHost(info, levels, row.id);
if (!resolved) continue;
const desired = info.normalize(resolved.value);
if (defaultValuesEqual(current(info), desired)) continue;
if (info.namespace === CORE_NAMESPACE) {
const base = { ...row, ...columns } as Record<string, unknown>;
columns = { ...columns, ...writeCoreDefault(info.key, desired, base) };
coreKeysChanged.push(info.key);
} else {
const manifestDefault = info.normalize(undefined);
pluginWrites.push({
hostId: row.id,
pluginId: info.namespace,
key: info.key,
value: defaultValuesEqual(desired, manifestDefault)
? undefined
: desired,
});
}
}
if (overridesChanged) columns.defaultOverrides = JSON.stringify(overrides);
return {
columns,
pluginWrites,
overrides,
overridesChanged,
coreKeysChanged,
};
}
/** The running plugins' manifests, and the catalog they make with core's. */
export function currentCatalog(): {
manifests: PluginManifest[];
catalog: DefaultsCatalog;
} {
const manifests = hostSettingsPlugins();
return { manifests, catalog: buildCatalog(manifests) };
}
export async function loadPluginRows(
hostIds: number[],
): Promise<Map<number, PluginRows>> {
const result = new Map<number, PluginRows>();
const repository = createCurrentPluginSettingsRepository();
for (let i = 0; i < hostIds.length; i += 500) {
const part = hostIds.slice(i, i + 500).map(String);
for (const row of await repository.getAllForScopeIds("host", part)) {
const hostId = Number(row.scopeId);
let byPlugin = result.get(hostId);
if (!byPlugin) {
byPlugin = new Map();
result.set(hostId, byPlugin);
}
let byKey = byPlugin.get(row.pluginId);
if (!byKey) {
byKey = new Map();
byPlugin.set(row.pluginId, byKey);
}
byKey.set(row.key, row);
}
}
return result;
}
export async function loadPlacement(
userIds: string[],
): Promise<Map<number, HostPlacement>> {
const rows =
await createCurrentHostDefaultsRepository().listHostPlacement(userIds);
return new Map(
rows.map((row) => [
row.id,
{ folder: row.folder, parentHostId: row.parentHostId },
]),
);
}
/** Runs the side effects every changed host needs, after its write. */
async function afterHostsChanged(
changed: Array<{ row: HostRow; plan: HostPlan }>,
): Promise<void> {
if (changed.length === 0) return;
try {
const { markChanged } = await import("../../sync/server/feed.js");
markChanged();
} catch {
// Sync is not loaded in every context.
}
const { pluginEvents, TOPICS } = await import("../../plugins/events.js");
const notified = new Map<string, unknown>();
let secrets: {
resyncHost: (hostId: number) => Promise<unknown>;
} | null = null;
for (const { row, plan } of changed) {
try {
pluginEvents.emit(TOPICS.hostUpdated, {
hostId: row.id,
userId: row.userId,
});
} catch {
// A subscriber must not fail the write that already happened.
}
for (const write of plan.pluginWrites) {
notified.set(`${write.pluginId}\u0000${write.key}`, write.value);
}
if (
plan.coreKeysChanged.includes("auth") ||
plan.coreKeysChanged.includes("username")
) {
try {
if (!secrets) {
const { SharedHostSecretsManager } =
await import("../../utils/shared-host-secrets-manager.js");
secrets = SharedHostSecretsManager.getInstance();
}
await secrets.resyncHost(row.id);
} catch (error) {
databaseLogger.warn("Failed to resync shared host after defaults", {
operation: "host_defaults_resync",
hostId: row.id,
error: error instanceof Error ? error.message : String(error),
});
}
}
}
for (const [pair, value] of notified) {
const [pluginId, key] = pair.split("\u0000");
notifySettingChange(pluginId, key, value);
}
}
/**
* Classifies and rewrites a set of hosts. Returns the hosts whose values or
* overrides changed; with `dryRun` nothing is written.
*/
export async function materializeHosts(
target: HostDefaultsTarget,
options: MaterializeOptions = {},
): Promise<MaterializeResult> {
const repository = createCurrentHostDefaultsRepository();
const rows = (await repository.listHosts(target)).filter(
// A desktop's read-only copy of a shared host follows its owner's server.
(row) => !row.sharedSource,
);
if (rows.length === 0) return { changedHostIds: [] };
const { catalog } = currentCatalog();
const userIds = [...new Set(rows.map((row) => row.userId))];
const [index, placement, pluginRows] = await Promise.all([
loadDefaultsIndex(userIds),
loadPlacement(userIds),
loadPluginRows(rows.map((row) => row.id)),
]);
const withOverlay = applyOverlay(index, options.overlay);
const changed: Array<{ row: HostRow; plan: HostPlan }> = [];
for (const row of rows) {
const levels = levelsForHost(
withOverlay,
row.userId,
effectiveFolderPath(row.id, placement),
);
const plan = planHost(row, levels, catalog, pluginRows.get(row.id), {
keys: options.keys,
});
const valuesChanged =
Object.keys(plan.columns).some((key) => key !== "defaultOverrides") ||
plan.pluginWrites.length > 0;
if (options.dryRun) {
if (valuesChanged) changed.push({ row, plan });
continue;
}
if (valuesChanged || plan.overridesChanged) changed.push({ row, plan });
}
if (options.dryRun) {
return { changedHostIds: changed.map(({ row }) => row.id) };
}
await repository.updateHosts(
changed.map(({ row, plan }) => ({
id: row.id,
values: plan.columns as Partial<HostRow>,
})),
);
const settings = createCurrentPluginSettingsRepository();
for (const { plan } of changed) {
for (const write of plan.pluginWrites) {
if (write.value === undefined) {
await settings.delete(
write.pluginId,
"host",
String(write.hostId),
write.key,
);
} else {
await settings.set(
write.pluginId,
"host",
String(write.hostId),
write.key,
JSON.stringify(write.value),
);
}
}
}
const valueChanges = changed.filter(
({ plan }) =>
plan.pluginWrites.length > 0 ||
Object.keys(plan.columns).some((key) => key !== "defaultOverrides"),
);
await afterHostsChanged(valueChanges);
return { changedHostIds: valueChanges.map(({ row }) => row.id) };
}
+88
View File
@@ -0,0 +1,88 @@
/**
* Changes which keys a host sets itself, without going through a full host
* write: a patch that sets a value makes it the host's own, and a reset hands
* it back to the defaults.
*/
import { createCurrentHostDefaultsRepository } from "../../database/repositories/factory.js";
import {
parseDefaultOverrides,
type DefaultOverrides,
} from "../../../types/host-defaults.js";
import { catalogNamespaces } from "./catalog.js";
import { currentCatalog } from "./materialize.js";
import { recompute } from "./recompute.js";
export interface OverrideChange {
/** Keys that become the host's own, as [namespace, key]. */
own?: Array<[string, string]>;
/** Keys that follow the defaults again. */
inherit?: Array<[string, string]>;
/** Namespaces whose every key follows the defaults again. */
inheritNamespaces?: string[];
/** Every key follows the defaults again. */
inheritAll?: boolean;
}
export function applyOverrideChange(
current: DefaultOverrides | null,
change: OverrideChange,
allNamespaces: string[] = [],
): DefaultOverrides {
const next: DefaultOverrides = {};
for (const [namespace, keys] of Object.entries(current ?? {})) {
next[namespace] = [...keys];
}
if (change.inheritAll) {
for (const namespace of [...Object.keys(next), ...allNamespaces]) {
next[namespace] = [];
}
}
for (const namespace of change.inheritNamespaces ?? []) {
next[namespace] = [];
}
for (const [namespace, key] of change.inherit ?? []) {
if (next[namespace]) {
next[namespace] = next[namespace].filter((own) => own !== key);
}
}
for (const [namespace, key] of change.own ?? []) {
// A namespace nobody classified yet stays that way: its first pass
// compares every value, this one included, and would keep it.
if (!next[namespace]) continue;
const keys = new Set(next[namespace] ?? []);
keys.add(key);
next[namespace] = [...keys].sort();
}
return next;
}
/**
* Applies a change to a set of hosts. A change that hands keys back to the
* defaults is followed by a pass that writes what they now resolve to.
*/
export async function changeHostOverrides(
hostIds: number[],
change: OverrideChange,
): Promise<void> {
if (hostIds.length === 0) return;
const repository = createCurrentHostDefaultsRepository();
const rows = await repository.listHosts({ hostIds });
const namespaces = catalogNamespaces(currentCatalog().catalog);
const patches: Array<{ id: number; values: { defaultOverrides: string } }> =
[];
for (const row of rows) {
const current = parseDefaultOverrides(row.defaultOverrides);
const next = applyOverrideChange(current, change, namespaces);
const encoded = JSON.stringify(next);
if (encoded !== row.defaultOverrides) {
patches.push({ id: row.id, values: { defaultOverrides: encoded } });
}
}
await repository.updateHosts(patches);
const resets =
change.inheritAll ||
(change.inheritNamespaces?.length ?? 0) > 0 ||
(change.inherit?.length ?? 0) > 0;
if (resets) await recompute({ hostIds });
}
+101
View File
@@ -0,0 +1,101 @@
/**
* Personal fields: look-and-feel values (a terminal's font, a remote
* desktop's color depth) that a user a host is shared with sees through
* their own defaults while the host follows its defaults. The stored value is
* the owner's; this swaps it for the viewer's at read time.
*/
import type { PluginManifest } from "@termix/plugin-sdk/manifest";
import { createCurrentHostDefaultsRepository } from "../../database/repositories/factory.js";
import type { HostRow } from "../../database/repositories/host-defaults-repository.js";
import {
hostSettingsPlugins,
type HostPluginSettings,
} from "../../database/routes/host-plugin-settings.js";
import { parseDefaultOverrides } from "../../../types/host-defaults.js";
import { pluginCatalogEntries, type DefaultKeyInfo } from "./catalog.js";
import { loadDefaultsIndex } from "./levels.js";
import { resolveKey, type HostLevels } from "./resolve.js";
function personalEntries(manifests: PluginManifest[]): DefaultKeyInfo[] {
return manifests.flatMap((manifest) =>
pluginCatalogEntries(manifest).filter((entry) => entry.personal),
);
}
async function viewerLevels(viewerId: string): Promise<HostLevels> {
const index = await loadDefaultsIndex([viewerId]);
return {
admin: index.admin,
user: index.users.get(viewerId) ?? new Map(),
folders: [],
};
}
function follows(row: HostRow, entry: DefaultKeyInfo): boolean {
const overrides = parseDefaultOverrides(row.defaultOverrides);
const own = overrides?.[entry.namespace];
return !!own && !own.includes(entry.key);
}
/** Replaces personal values in a host list's plugin settings, in place. */
export async function applyPersonalHostValues(
settings: Map<number, HostPluginSettings>,
viewerId: string,
): Promise<void> {
const entries = personalEntries(hostSettingsPlugins());
if (entries.length === 0 || settings.size === 0) return;
const rows = (
await createCurrentHostDefaultsRepository().listHosts({
hostIds: [...settings.keys()],
})
).filter((row) => row.userId !== viewerId);
if (rows.length === 0) return;
const levels = await viewerLevels(viewerId);
for (const row of rows) {
const values = settings.get(row.id);
if (!values) continue;
for (const entry of entries) {
if (!follows(row, entry) || !values[entry.namespace]) continue;
const resolved = resolveKey(entry, levels);
if (resolved) values[entry.namespace][entry.key] = resolved.value;
}
}
}
/** One personal value as a user sees it, when that differs from the stored one. */
export async function personalHostValue(
manifest: PluginManifest,
hostId: number,
viewerId: string,
key: string,
): Promise<{ applies: boolean; value?: unknown }> {
const entry = pluginCatalogEntries(manifest).find(
(candidate) => candidate.key === key && candidate.personal,
);
if (!entry || !Number.isInteger(hostId)) return { applies: false };
const [row] = await createCurrentHostDefaultsRepository().listHosts({
hostIds: [hostId],
});
if (!row || row.userId === viewerId || !follows(row, entry)) {
return { applies: false };
}
const resolved = resolveKey(entry, await viewerLevels(viewerId));
return resolved
? { applies: true, value: resolved.value }
: { applies: false };
}
/** A host field as a user's hosts get it with nothing more specific set. */
export async function userHostDefault(
manifest: PluginManifest,
userId: string,
key: string,
): Promise<unknown> {
const entry = pluginCatalogEntries(manifest).find(
(candidate) => candidate.key === key,
);
if (!entry) return undefined;
return resolveKey(entry, await viewerLevels(userId))?.value;
}
+115
View File
@@ -0,0 +1,115 @@
/**
* Runs materialize passes one at a time, so two default changes can never
* write the same host at once. A server-wide change can touch every host, so
* it runs as a job the admin panel polls instead of holding the request open.
*/
import { randomUUID } from "crypto";
import { databaseLogger } from "../../utils/logger.js";
import type { HostDefaultsScope } from "../../database/repositories/host-defaults-repository.js";
import {
materializeHosts,
type HostDefaultsTarget,
type MaterializeResult,
} from "./materialize.js";
let queue: Promise<unknown> = Promise.resolve();
function enqueue<T>(task: () => Promise<T>): Promise<T> {
const run = queue.catch(() => {}).then(task);
queue = run;
return run;
}
export interface RecomputeJob {
id: string;
status: "running" | "done" | "failed";
changedHosts: number;
error?: string;
finishedAt?: number;
}
const jobs = new Map<string, RecomputeJob>();
const JOB_TTL_MS = 60 * 60 * 1000;
function pruneJobs(): void {
const cutoff = Date.now() - JOB_TTL_MS;
for (const [id, job] of jobs) {
if (job.finishedAt && job.finishedAt < cutoff) jobs.delete(id);
}
}
/** Which hosts a level's defaults reach. A folder's owner is resolved by the caller. */
export function targetForScope(
scope: HostDefaultsScope,
folderOwnerId?: string,
): HostDefaultsTarget {
if (scope.level === "admin") return { all: true };
if (scope.level === "user") return { userIds: [String(scope.userId)] };
return { userIds: folderOwnerId ? [folderOwnerId] : [] };
}
export function recompute(
target: HostDefaultsTarget,
keys?: Set<string>,
): Promise<MaterializeResult> {
return enqueue(() => materializeHosts(target, { keys }));
}
/** Starts a pass in the background and returns its job. */
export function startRecomputeJob(
target: HostDefaultsTarget,
keys?: Set<string>,
): RecomputeJob {
pruneJobs();
const job: RecomputeJob = {
id: randomUUID(),
status: "running",
changedHosts: 0,
};
jobs.set(job.id, job);
recompute(target, keys)
.then((result) => {
job.status = "done";
job.changedHosts = result.changedHostIds.length;
})
.catch((error) => {
job.status = "failed";
job.error = error instanceof Error ? error.message : String(error);
databaseLogger.error("Host defaults recompute failed", error, {
operation: "host_defaults_recompute",
});
})
.finally(() => {
job.finishedAt = Date.now();
});
return job;
}
export function getRecomputeJob(id: string): RecomputeJob | undefined {
return jobs.get(id);
}
/**
* Classifies and brings every host up to date. Run after boot and when a
* plugin starts, so a host or namespace nobody has classified yet catches up.
*/
export function recomputeEverything(reason: string): void {
recompute({ all: true })
.then((result) => {
if (result.changedHostIds.length > 0) {
databaseLogger.info("Applied host defaults", {
operation: "host_defaults_recompute",
reason,
changedHosts: result.changedHostIds.length,
});
}
})
.catch((error) => {
databaseLogger.warn("Could not apply host defaults", {
operation: "host_defaults_recompute",
reason,
error: error instanceof Error ? error.message : String(error),
});
});
}
+131
View File
@@ -0,0 +1,131 @@
/**
* Host default resolution. No database access: the levels come in as maps,
* so every rule here is testable on its own.
*
* A key resolves from the deepest folder a host sits in up to its root
* folder, then the owner's own defaults, then the server's, then the key's
* built-in value. A level the key does not allow is skipped.
*/
import {
defaultValuesEqual,
type HostDefaultSource,
type ResolvedHostDefault,
} from "../../../types/host-defaults.js";
import type { DefaultKeyInfo } from "./catalog.js";
export type LevelMap = Map<string, unknown>;
export interface FolderLevel {
folderId: number;
name: string;
values: LevelMap;
}
export interface HostLevels {
admin: LevelMap;
user: LevelMap;
/** Root first, deepest last. */
folders: FolderLevel[];
}
export const FOLDER_SEPARATOR = " / ";
/** "A / B / C" -> ["A", "A / B", "A / B / C"]. */
export function folderChainPaths(path: string | null | undefined): string[] {
if (!path) return [];
const segments = path.split(FOLDER_SEPARATOR).filter(Boolean);
return segments.map((_, index) =>
segments.slice(0, index + 1).join(FOLDER_SEPARATOR),
);
}
export interface HostPlacement {
folder: string | null;
parentHostId: number | null;
}
/**
* The folder whose defaults a host follows. A sub-host has no folder of its
* own, so it takes the nearest ancestor's.
*/
export function effectiveFolderPath(
hostId: number,
placement: Map<number, HostPlacement>,
): string | null {
const seen = new Set<number>();
let current: number | null = hostId;
while (current !== null && !seen.has(current)) {
seen.add(current);
const own = placement.get(current);
if (!own) return null;
if (own.folder) return own.folder;
current = own.parentHostId;
}
return null;
}
export function resolveKey(
info: DefaultKeyInfo,
levels: HostLevels,
): ResolvedHostDefault | undefined {
const allowed = new Set(info.levels);
if (allowed.has("folder")) {
for (let i = levels.folders.length - 1; i >= 0; i--) {
const folder = levels.folders[i];
if (folder.values.has(info.fullKey)) {
return {
value: info.normalize(folder.values.get(info.fullKey)),
source: {
level: "folder",
folderId: folder.folderId,
folderName: folder.name,
},
};
}
}
}
const order: Array<["user" | "admin", LevelMap]> = [
["user", levels.user],
["admin", levels.admin],
];
for (const [level, values] of order) {
if (allowed.has(level) && values.has(info.fullKey)) {
return {
value: info.normalize(values.get(info.fullKey)),
source: { level },
};
}
}
if (info.builtin === undefined) return undefined;
const source: HostDefaultSource = { level: "builtin" };
return { value: info.normalize(info.builtin), source };
}
function isEmpty(value: unknown): boolean {
if (value === undefined || value === null || value === "") return true;
if (Array.isArray(value)) return value.length === 0;
return false;
}
/**
* Whether a host's value is its own rather than the default: it differs from
* what resolves, or nothing resolves and the host has a value at all.
*/
export function isOwnValue(
info: DefaultKeyInfo,
hostValue: unknown,
resolved: ResolvedHostDefault | undefined,
): boolean {
const own = info.normalize(hostValue);
if (!resolved) return !isEmpty(own);
return !defaultValuesEqual(own, resolved.value);
}
/** A host never jumps through itself, even when a folder default says to. */
export function withoutSelfJump(value: unknown, hostId: number): unknown {
if (!Array.isArray(value)) return value;
return value.filter(
(entry) => Number((entry as { hostId?: unknown })?.hostId) !== hostId,
);
}
+286
View File
@@ -0,0 +1,286 @@
/**
* Reading, checking and saving one level of host defaults, and resolving a
* host's defaults for the editor. The routes are a thin layer over this.
*/
import {
coerceSettingValue,
validateSettingValue,
} from "@termix/plugin-sdk/settings";
import { createCurrentHostDefaultsRepository } from "../../database/repositories/factory.js";
import type { HostDefaultsScope } from "../../database/repositories/host-defaults-repository.js";
import { validateSettingsSave } from "../../plugins/settings.js";
import {
CORE_NAMESPACE,
SECRET_AUTH_TYPES,
normalizeAuthDefault,
splitDefaultKey,
type ResolvedHostDefault,
} from "../../../types/host-defaults.js";
import type { DefaultKeyInfo, DefaultsCatalog } from "./catalog.js";
import {
decodeDefaultValue,
encodeDefaultValue,
loadDefaultsIndex,
levelsForHost,
} from "./levels.js";
import {
currentCatalog,
loadPlacement,
resolveForHost,
} from "./materialize.js";
import {
effectiveFolderPath,
folderChainPaths,
type HostLevels,
} from "./resolve.js";
export interface LevelChangeInput {
set?: Record<string, unknown>;
unset?: string[];
}
export interface ValidatedLevelChange {
set: Map<string, unknown>;
unset: Set<string>;
errors: Record<string, string>;
}
/** What a level holds, by "namespace.key". */
export async function readLevel(
scope: HostDefaultsScope,
): Promise<Record<string, unknown>> {
const rows = await createCurrentHostDefaultsRepository().listScope(scope);
const values: Record<string, unknown> = {};
for (const row of rows) {
values[`${row.namespace}.${row.key}`] = decodeDefaultValue(row.value);
}
return values;
}
/** The levels above one, which is what a key reads when this level leaves it unset. */
export async function levelsAbove(
scope: HostDefaultsScope,
folderOwner?: { userId: string; name: string },
): Promise<HostLevels> {
const empty: HostLevels = { admin: new Map(), user: new Map(), folders: [] };
if (scope.level === "admin") return empty;
const ownerId =
scope.level === "user" ? String(scope.userId) : folderOwner?.userId;
if (!ownerId) return empty;
const index = await loadDefaultsIndex([ownerId]);
if (scope.level === "user") {
return { ...empty, admin: index.admin };
}
const parents = folderChainPaths(folderOwner?.name).slice(0, -1);
const own = index.folders.get(ownerId);
return {
admin: index.admin,
user: index.users.get(ownerId) ?? new Map(),
folders: parents
.map((path) => own?.get(path))
.filter((folder): folder is NonNullable<typeof folder> => !!folder),
};
}
export function resolveAll(
catalog: DefaultsCatalog,
levels: HostLevels,
hostId: number | null = null,
): Record<string, ResolvedHostDefault> {
const result: Record<string, ResolvedHostDefault> = {};
for (const info of catalog.values()) {
const resolved = resolveForHost(info, levels, hostId);
if (resolved) result[info.fullKey] = resolved;
}
return result;
}
export interface CheckContext {
actorId: string;
/** Whose rows a user or folder level may point at. */
ownerId: string | null;
canUseCredential: (credentialId: number, userId: string) => Promise<boolean>;
canUseHost: (hostId: number, userId: string) => Promise<boolean>;
}
async function checkCoreValue(
info: DefaultKeyInfo,
value: unknown,
scope: HostDefaultsScope,
context: CheckContext,
): Promise<string | null> {
if (info.key === "auth") {
const auth = normalizeAuthDefault(value);
if (!auth) return "An auth default needs an auth type";
if (SECRET_AUTH_TYPES.includes(auth.authType)) {
return "Password and key logins belong to one host and cannot be a default";
}
if (auth.authType === "credential") {
if (scope.level === "admin") {
return "A credential belongs to one user, so it cannot be a server default";
}
if (
auth.credentialId === null ||
!context.ownerId ||
!(await context.canUseCredential(auth.credentialId, context.ownerId))
) {
return "That credential is not available";
}
}
}
if (info.key === "jumpHosts" && Array.isArray(value) && context.ownerId) {
for (const entry of value as Array<{ hostId?: unknown }>) {
const hostId = Number(entry?.hostId);
if (
!Number.isInteger(hostId) ||
!(await context.canUseHost(hostId, context.ownerId))
) {
return "A jump host in the chain is not available";
}
}
}
return null;
}
/** Checks a level change against the catalog, each key's levels and its field. */
export async function validateLevelChange(
scope: HostDefaultsScope,
input: LevelChangeInput,
context: CheckContext,
catalog: DefaultsCatalog = currentCatalog().catalog,
): Promise<ValidatedLevelChange> {
const set = new Map<string, unknown>();
const unset = new Set<string>();
const errors: Record<string, string> = {};
for (const fullKey of input.unset ?? []) {
if (typeof fullKey === "string") unset.add(fullKey);
}
const pluginValues = new Map<string, Record<string, unknown>>();
for (const [fullKey, raw] of Object.entries(input.set ?? {})) {
const info = catalog.get(fullKey);
if (!info) {
errors[fullKey] = "Not a host setting that can have a default";
continue;
}
if (!info.levels.includes(scope.level)) {
errors[fullKey] = `Cannot be set as a ${scope.level} default`;
continue;
}
if (info.namespace === CORE_NAMESPACE) {
const error = await checkCoreValue(info, raw, scope, context);
if (error) {
errors[fullKey] = error;
continue;
}
set.set(fullKey, info.normalize(raw));
continue;
}
const field = info.field!;
const coerced = coerceSettingValue(field, raw);
const error = validateSettingValue(field, coerced);
if (error) {
errors[fullKey] = error;
continue;
}
set.set(fullKey, info.normalize(coerced));
const own = pluginValues.get(info.namespace) ?? {};
own[info.key] = coerced;
pluginValues.set(info.namespace, own);
}
for (const [pluginId, values] of pluginValues) {
const rejected = await validateSettingsSave(
pluginId,
"host",
null,
values,
{ level: scope.level },
);
for (const [key, message] of Object.entries(rejected)) {
errors[key === "_" ? pluginId : `${pluginId}.${key}`] = message;
}
}
for (const key of set.keys()) unset.delete(key);
return { set, unset, errors };
}
export async function saveLevel(
scope: HostDefaultsScope,
change: ValidatedLevelChange,
actorId: string,
): Promise<void> {
await createCurrentHostDefaultsRepository().apply(
scope,
[...change.set].map(([fullKey, value]) => {
const [namespace, key] = splitDefaultKey(fullKey);
return { namespace, key, value: encodeDefaultValue(value) };
}),
[...change.unset].map((fullKey) => {
const [namespace, key] = splitDefaultKey(fullKey);
return { namespace, key };
}),
actorId,
);
}
/**
* What a host (or a new one in a folder or under a parent) resolves every
* key to, with where each value comes from.
*/
export async function resolveForEditor(input: {
ownerId: string;
hostId?: number | null;
folder?: string | null;
parentHostId?: number | null;
}): Promise<Record<string, ResolvedHostDefault>> {
const { catalog } = currentCatalog();
const placement = await loadPlacement([input.ownerId]);
const self = input.hostId ?? -1;
const stored = placement.get(self);
placement.set(self, {
folder:
input.folder !== undefined ? input.folder : (stored?.folder ?? null),
parentHostId:
input.parentHostId !== undefined
? input.parentHostId
: (stored?.parentHostId ?? null),
});
const index = await loadDefaultsIndex([input.ownerId]);
const levels = levelsForHost(
index,
input.ownerId,
effectiveFolderPath(self, placement),
);
return resolveAll(catalog, levels, input.hostId ?? null);
}
/**
* A folder's credential is its auth default: hosts in the folder that follow
* their defaults sign in with it. Null clears it.
*/
export async function setFolderCredentialDefault(
userId: string,
folderId: number,
credentialId: number | null,
): Promise<void> {
await createCurrentHostDefaultsRepository().apply(
{ level: "folder", folderId, userId },
credentialId === null
? []
: [
{
namespace: CORE_NAMESPACE,
key: "auth",
value: encodeDefaultValue(
normalizeAuthDefault({ authType: "credential", credentialId }),
),
},
],
credentialId === null ? [{ namespace: CORE_NAMESPACE, key: "auth" }] : [],
userId,
);
}
+115
View File
@@ -0,0 +1,115 @@
/**
* A user's own and folder host defaults in the per-user export database.
* Folders travel by path, since their ids mean nothing on another server.
* An import is checked like any other save, so a credential or jump host
* the importing user cannot use is skipped, and a key the level already
* holds is left as it is.
*/
import type Database from "better-sqlite3";
import { createCurrentHostDefaultsRepository } from "../../database/repositories/factory.js";
import type { HostDefaultsScope } from "../../database/repositories/host-defaults-repository.js";
import { PermissionManager } from "../../utils/permission-manager.js";
import { findUsableCredential } from "../usable-credential.js";
import { decodeDefaultValue } from "./levels.js";
import { readLevel, saveLevel, validateLevelChange } from "./service.js";
export async function writeHostDefaultsToExport(
exportDb: Database.Database,
userId: string,
): Promise<void> {
exportDb.exec(`
CREATE TABLE host_defaults (
level TEXT NOT NULL,
folder_name TEXT,
namespace TEXT NOT NULL,
key TEXT NOT NULL,
value TEXT
);
`);
const repository = createCurrentHostDefaultsRepository();
const folders = new Map(
(await repository.listFolders([userId])).map((folder) => [
folder.id,
folder.name,
]),
);
const insert = exportDb.prepare(
"INSERT INTO host_defaults (level, folder_name, namespace, key, value) VALUES (?, ?, ?, ?, ?)",
);
for (const row of await repository.listOwnedBy(userId)) {
const folderName =
row.level === "folder" && row.folderId !== null
? folders.get(row.folderId)
: null;
if (row.level === "folder" && !folderName) continue;
insert.run(row.level, folderName, row.namespace, row.key, row.value);
}
}
export async function importHostDefaults(
importDb: Database.Database,
userId: string,
): Promise<{ imported: number; skipped: number }> {
let rows: Array<{
level: string;
folder_name: string | null;
namespace: string;
key: string;
value: string | null;
}>;
try {
rows = importDb.prepare("SELECT * FROM host_defaults").all() as never;
} catch {
return { imported: 0, skipped: 0 };
}
const groups = new Map<string, Record<string, unknown>>();
for (const row of rows) {
const group =
row.level === "folder" && row.folder_name
? `f:${row.folder_name}`
: row.level === "user"
? "u"
: null;
if (!group) continue;
const values = groups.get(group) ?? {};
values[`${row.namespace}.${row.key}`] = decodeDefaultValue(row.value);
groups.set(group, values);
}
const permissions = PermissionManager.getInstance();
const context = {
actorId: userId,
ownerId: userId,
canUseCredential: async (credentialId: number, owner: string) =>
(await findUsableCredential(credentialId, owner)) !== null,
canUseHost: async (hostId: number, owner: string) =>
(await permissions.canAccessHost(owner, hostId, "connect")).hasAccess,
};
const repository = createCurrentHostDefaultsRepository();
let imported = 0;
let skipped = 0;
for (const [group, values] of groups) {
const scope: HostDefaultsScope =
group === "u"
? { level: "user", userId }
: {
level: "folder",
userId,
folderId: await repository.ensureFolder(userId, group.slice(2)),
};
const existing = await readLevel(scope);
const fresh = Object.fromEntries(
Object.entries(values).filter(([key]) => !(key in existing)),
);
skipped += Object.keys(values).length - Object.keys(fresh).length;
const change = await validateLevelChange(scope, { set: fresh }, context);
skipped += Object.keys(change.errors).length;
if (change.set.size === 0) continue;
await saveLevel(scope, { ...change, errors: {} }, userId);
imported += change.set.size;
}
return { imported, skipped };
}
+149
View File
@@ -0,0 +1,149 @@
/**
* How a host write decides which core keys the host sets itself.
*
* - A write that carries `defaultOverrides` says so outright (the host
* editor). Values it sends for inherited keys are replaced by what
* resolves.
* - An update without one (an API client, Termix-Mobile) keeps what was
* overridden, and a key whose value changed becomes overridden.
* - A create without one (import, quick connect, enroll) inherits every key
* whose value matches what resolves, or that it left out.
*
* Plugin namespaces are not in the host body. They keep their state here and
* are classified or rewritten by the materialize pass that follows the write.
*/
import type { HostRow } from "../../database/repositories/host-defaults-repository.js";
import {
CORE_NAMESPACE,
defaultValuesEqual,
parseDefaultOverrides,
type DefaultOverrides,
} from "../../../types/host-defaults.js";
import { coreCatalogEntries } from "./catalog.js";
import { readCoreDefault, writeCoreDefault } from "./core-columns.js";
import { loadDefaultsIndex, levelsForHost } from "./levels.js";
import { loadPlacement, resolveForHost } from "./materialize.js";
import { effectiveFolderPath, isOwnValue } from "./resolve.js";
export interface HostDefaultsWrite {
/** The host's owner, whose defaults apply. */
ownerId: string;
/** Null on create. */
hostId: number | null;
/** The column object the route is about to write. Changed in place. */
columns: Record<string, unknown>;
/** The request body as sent. */
body: Record<string, unknown>;
/** The row before an update. */
stored?: HostRow | null;
/** Core keys this caller may not change, which keep their state. */
lockedKeys?: string[];
}
/** Body fields each core key is read from, to tell a left-out key on create. */
const BODY_FIELDS: Record<string, string[]> = {
username: ["username"],
sshPort: ["sshPort", "port"],
auth: ["authType", "authMethod", "credentialId"],
forceKeyboardInteractive: ["forceKeyboardInteractive"],
keepaliveInterval: ["sshOptions", "terminalConfig"],
keepaliveCountMax: ["sshOptions", "terminalConfig"],
allowLegacyAlgorithms: ["sshOptions", "terminalConfig"],
agentForwarding: ["sshOptions", "terminalConfig"],
environmentVariables: ["sshOptions", "terminalConfig"],
socks5: ["useSocks5"],
jumpHosts: ["jumpHosts"],
portKnockSequence: ["portKnockSequence"],
statusCheckEnabled: ["statusCheckEnabled"],
statusCheckInterval: ["statusCheckInterval"],
};
function leftOut(body: Record<string, unknown>, key: string): boolean {
const fields = BODY_FIELDS[key] ?? [key];
return fields.every((field) => body[field] === undefined);
}
/**
* Fills inherited core keys into `columns` and returns the host's overrides.
* The caller stores them as `defaultOverrides`.
*/
export async function applyHostDefaultsToWrite(
write: HostDefaultsWrite,
): Promise<DefaultOverrides> {
const { ownerId, hostId, columns, body, stored } = write;
const sent = parseDefaultOverrides(body.defaultOverrides);
const before = parseDefaultOverrides(stored?.defaultOverrides);
const locked = new Set(write.lockedKeys ?? []);
const placement = await loadPlacement([ownerId]);
const self = hostId ?? -1;
placement.set(self, {
folder: (columns.folder !== undefined
? columns.folder
: (stored?.folder ?? null)) as string | null,
parentHostId: (columns.parentHostId !== undefined
? columns.parentHostId
: (stored?.parentHostId ?? null)) as number | null,
});
const index = await loadDefaultsIndex([ownerId]);
const levels = levelsForHost(
index,
ownerId,
effectiveFolderPath(self, placement),
);
const storedColumns = (stored ?? {}) as Record<string, unknown>;
// A partial write (a plugin's ctx.hosts.update) leaves the rest as stored.
const incoming = (key: string) =>
readCoreDefault(key, { ...storedColumns, ...columns });
const previous = (key: string) => readCoreDefault(key, storedColumns);
const overridden: string[] = [];
for (const info of coreCatalogEntries()) {
const resolved = resolveForHost(info, levels, hostId);
let own: boolean;
if (locked.has(info.key)) {
own = before?.[CORE_NAMESPACE]?.includes(info.key) ?? true;
if (own) overridden.push(info.key);
continue;
}
if (sent?.[CORE_NAMESPACE]) {
own = sent[CORE_NAMESPACE].includes(info.key);
} else if (stored && before?.[CORE_NAMESPACE]) {
own =
before[CORE_NAMESPACE].includes(info.key) ||
!defaultValuesEqual(
info.normalize(incoming(info.key)),
info.normalize(previous(info.key)),
);
} else if (!stored && leftOut(body, info.key)) {
own = false;
} else {
own = isOwnValue(info, incoming(info.key), resolved);
}
if (own || !resolved) {
if (own) overridden.push(info.key);
continue;
}
Object.assign(
columns,
writeCoreDefault(
info.key,
resolved.value,
{ ...storedColumns, ...columns },
storedColumns.sshOptions,
),
);
}
const overrides: DefaultOverrides = { ...(before ?? {}) };
if (sent) {
for (const [namespace, keys] of Object.entries(sent)) {
if (namespace !== CORE_NAMESPACE) overrides[namespace] = keys;
}
}
overrides[CORE_NAMESPACE] = overridden.sort();
return overrides;
}
+67 -5
View File
@@ -372,6 +372,21 @@ export function createPluginHosts({ manifest, bag, audit }: Deps): PluginHosts {
}
};
/** Host settings a plugin writes are the host's own, not its defaults. */
const markSettingsOwn = async (
hostId: number,
writes: Awaited<ReturnType<typeof checkSettings>>,
) => {
if (writes.length === 0) return;
const { changeHostOverrides } =
await import("../hosts/defaults/overrides.js");
await changeHostOverrides([hostId], {
own: writes.flatMap(({ manifest: owner, values }) =>
Object.keys(values).map((key): [string, string] => [owner.id, key]),
),
});
};
return {
list: async () => {
await requireRead();
@@ -467,12 +482,25 @@ export function createPluginHosts({ manifest, bag, audit }: Deps): PluginHosts {
const writes = await checkSettings(settings);
const { createCurrentHostRepository } =
await import("../database/repositories/factory.js");
const { applyHostDefaultsToWrite, applyDefaultsAfterHostWrite } =
await import("../hosts/defaults/index.js");
const columns: Record<string, unknown> = { ...row, userId };
columns.defaultOverrides = JSON.stringify(
await applyHostDefaultsToWrite({
ownerId: userId,
hostId: null,
columns,
body: host as unknown as Record<string, unknown>,
}),
);
const created =
await createCurrentHostRepository().createEncryptedForUser(userId, {
...row,
await createCurrentHostRepository().createEncryptedForUser(
userId,
});
columns,
);
await applyDefaultsAfterHostWrite(created.id);
await writeSettings(created.id, writes);
await markSettingsOwn(created.id, writes);
await audit("hosts_create", `host ${created.id}`, { success: true });
return (
await toRecords([created as unknown as Record<string, unknown>])
@@ -497,13 +525,47 @@ export function createPluginHosts({ manifest, bag, audit }: Deps): PluginHosts {
const writes = await checkSettings(settings);
const { createCurrentHostRepository } =
await import("../database/repositories/factory.js");
const { applyHostDefaultsToWrite, applyDefaultsAfterHostWrite } =
await import("../hosts/defaults/index.js");
const { createCurrentHostDefaultsRepository } =
await import("../database/repositories/factory.js");
const stored = (
await createCurrentHostDefaultsRepository().listHosts({
hostIds: [hostId],
})
)[0];
const columns: Record<string, unknown> = { ...row };
if (stored && stored.userId === userId) {
columns.defaultOverrides = JSON.stringify(
await applyHostDefaultsToWrite({
ownerId: userId,
hostId,
columns,
body: patch as unknown as Record<string, unknown>,
stored,
}),
);
}
const updated =
await createCurrentHostRepository().updateEncryptedForUser(
userId,
hostId,
row,
columns,
);
if (updated) await writeSettings(hostId, writes);
if (updated) {
await writeSettings(hostId, writes);
await markSettingsOwn(hostId, writes);
await applyDefaultsAfterHostWrite(hostId, {
moved:
!!stored &&
((columns.folder !== undefined &&
(columns.folder ?? null) !== (stored.folder ?? null)) ||
(columns.parentHostId !== undefined &&
(columns.parentHostId ?? null) !==
(stored.parentHostId ?? null))),
ownerId: userId,
});
}
await audit("hosts_update", `host ${hostId}`, {
success: updated !== null,
});
+35 -1
View File
@@ -816,7 +816,40 @@ export function createPluginContext(
getHost: (hostId, key) =>
pluginSettings.getSetting(manifest, "host", hostId, key) as never,
setHost: async (hostId, key, value) => {
getHostFor: async (hostId, userId, key) => {
const { personalHostValue } =
await import("../hosts/defaults/personal.js");
const personal = await personalHostValue(
manifest,
Number(hostId),
userId,
key,
);
return (
personal.applies
? personal.value
: await pluginSettings.getSetting(manifest, "host", hostId, key)
) as never;
},
getHostDefault: async (userId, key) => {
const { userHostDefault } =
await import("../hosts/defaults/personal.js");
return (await userHostDefault(manifest, userId, key)) as never;
},
setHost: async (hostId, key, value, options) => {
const { changeHostOverrides } =
await import("../hosts/defaults/overrides.js");
if (options?.inherit) {
if (!pluginSettings.findField(manifest, "host", key)) {
throw new Error(
`"${key}" is not a host setting this plugin declares`,
);
}
await changeHostOverrides([Number(hostId)], {
inherit: [[pluginId, key]],
});
return;
}
const error = await pluginSettings.setSetting(
manifest,
"host",
@@ -825,6 +858,7 @@ export function createPluginContext(
value,
);
if (error) throw new Error(error);
await changeHostOverrides([Number(hostId)], { own: [[pluginId, key]] });
},
listHostValues: async (key) => {
+12 -3
View File
@@ -75,7 +75,11 @@ export function onSettingsChange(
};
}
function notify(pluginId: string, key: string, value: unknown): void {
export function notifySettingChange(
pluginId: string,
key: string,
value: unknown,
): void {
for (const listener of listeners.get(pluginId)?.get(key) ?? []) {
try {
listener(value);
@@ -92,7 +96,10 @@ function notify(pluginId: string, key: string, value: unknown): void {
export type SettingsValidator = (
values: Record<string, unknown>,
context: { hostId?: number },
context: {
hostId?: number;
defaults?: { level: "admin" | "user" | "folder" };
},
) =>
| Record<string, string>
| void
@@ -131,6 +138,7 @@ export async function validateSettingsSave(
scope: PluginSettingsScope,
scopeId: string | null,
values: Record<string, unknown>,
defaults?: { level: "admin" | "user" | "folder" },
): Promise<Record<string, string>> {
const errors: Record<string, string> = {};
for (const entry of validators.get(pluginId) ?? []) {
@@ -138,6 +146,7 @@ export async function validateSettingsSave(
try {
const result = await entry.run(values, {
hostId: scope === "host" && scopeId ? Number(scopeId) : undefined,
...(defaults ? { defaults } : {}),
});
Object.assign(errors, result ?? {});
} catch (error) {
@@ -299,7 +308,7 @@ export async function setSetting(
shouldEncrypt,
);
notify(manifest.id, key, coerced);
notifySettingChange(manifest.id, key, coerced);
return null;
}
+46 -1
View File
@@ -23,6 +23,7 @@ import { FieldCrypto } from "../utils/field-crypto.js";
import { syncLogger } from "../utils/logger.js";
import { getPluginRuntime } from "../plugins/index.js";
import { singletonSyncId } from "./wire.js";
import { parseDefaultOverrides } from "../../types/host-defaults.js";
import {
exportHostPluginSettings,
importHostPluginSettings,
@@ -31,6 +32,14 @@ import {
exportProtocolLogins,
importProtocolLogins,
} from "./host-protocol-auth.js";
import {
coversHostDefault,
eraseHostDefault,
loadAdminDefaults,
loadHostDefaults,
writeAdminDefaults,
writeHostDefault,
} from "./host-defaults.js";
const CREDENTIAL_REFERENCE = {
field: "credentialId",
@@ -167,9 +176,22 @@ export function registerCoreSyncEntities(): void {
}),
afterWrite: async ({ id, userId, wire, resolveId }) => {
if (id === null) return;
await importHostPluginSettings(id, wire.pluginSettings);
// The server owns host defaults: it keeps what the host set itself
// and resolves the rest. A desktop takes the server's values as sent.
const { getLink } = await import("./client/link-store.js");
const onServer = !(await getLink().catch(() => null));
await importHostPluginSettings(
id,
wire.pluginSettings,
onServer ? parseDefaultOverrides(wire.defaultOverrides) : null,
);
await importProtocolLogins(id, userId, wire.protocolAuth, resolveId);
await dropInvalidParent(userId, id);
if (onServer) {
const { applyDefaultsAfterHostWrite } =
await import("../hosts/defaults/index.js");
await applyDefaultsAfterHostWrite(id);
}
const { SharedHostSecretsManager } =
await import("../utils/shared-host-secrets-manager.js");
await SharedHostSecretsManager.getInstance().resyncHost(id);
@@ -193,6 +215,29 @@ export function registerCoreSyncEntities(): void {
},
);
// After hosts: a folder's jump chain names hosts by syncId.
registerEntity(
CORE_OWNER,
{ type: "hostDefaults", table: null, order: 60 },
{
load: loadHostDefaults,
write: writeHostDefault,
erase: eraseHostDefault,
covers: (syncId) => coversHostDefault(syncId, activeManifest),
},
);
registerEntity(
CORE_OWNER,
{ type: "hostDefaultsAdmin", table: null, order: 7, singleton: true },
{
readOnly: true,
load: loadAdminDefaults,
write: writeAdminDefaults,
erase: async () => {},
},
);
// dashboardServiceLinks (70) and homepageItems (80) belong to homepage.
registerEntity(CORE_OWNER, {
+335
View File
@@ -0,0 +1,335 @@
/**
* Host defaults over sync.
*
* A user's own and folder defaults travel as one row per key. A value that
* names a row by local id (the credential in an auth default, the hosts in a
* jump chain, a plugin's own ids) travels by syncId and is translated back on
* the other side. The server's admin defaults reach a linked desktop as one
* read-only snapshot, so the desktop resolves a host the way its server does.
*/
import { and, eq } from "drizzle-orm";
import type { SyncRow } from "@termix/plugin-sdk/backend";
import type { PluginManifest } from "@termix/plugin-sdk/manifest";
import { hosts, sshCredentials, sshFolders } from "../database/db/schema.js";
import {
createCurrentHostDefaultsRepository,
createCurrentRepositoryContext,
createCurrentSettingsRepository,
} from "../database/repositories/factory.js";
import { consume } from "../plugins/registry.js";
import {
CORE_NAMESPACE,
joinDefaultKey,
splitDefaultKey,
} from "../../types/host-defaults.js";
import {
decodeDefaultValue,
encodeDefaultValue,
REMOTE_ADMIN_DEFAULTS_KEY,
} from "../hosts/defaults/levels.js";
import type { PluginHostSettingsSync } from "./host-plugin-settings.js";
import { singletonSyncId } from "./wire.js";
type IdMapper = (id: number) => Promise<string | null>;
type SyncIdMapper = (syncId: string) => Promise<number | null>;
function db() {
return createCurrentRepositoryContext().drizzle;
}
async function credentialSyncId(id: number): Promise<string | null> {
const [row] = await db()
.select({ syncId: sshCredentials.syncId })
.from(sshCredentials)
.where(eq(sshCredentials.id, id))
.limit(1);
return row?.syncId ?? null;
}
async function hostSyncId(id: number): Promise<string | null> {
const [row] = await db()
.select({ syncId: hosts.syncId })
.from(hosts)
.where(eq(hosts.id, id))
.limit(1);
return row?.syncId ?? null;
}
function localCredential(userId: string): SyncIdMapper {
return async (syncId) => {
const [row] = await db()
.select({ id: sshCredentials.id })
.from(sshCredentials)
.where(
and(
eq(sshCredentials.syncId, syncId),
eq(sshCredentials.userId, userId),
),
)
.limit(1);
return row?.id ?? null;
};
}
function localHost(userId: string): SyncIdMapper {
return async (syncId) => {
const [row] = await db()
.select({ id: hosts.id })
.from(hosts)
.where(and(eq(hosts.syncId, syncId), eq(hosts.userId, userId)))
.limit(1);
return row?.id ?? null;
};
}
function hostSettingsHook(
pluginId: string,
): PluginHostSettingsSync | undefined {
return consume<PluginHostSettingsSync>(`${pluginId}.hostSettingsSync`);
}
/** A value on its way out: local ids become syncIds. */
export async function exportDefaultValue(
namespace: string,
key: string,
value: unknown,
maps: { credential: IdMapper; host: IdMapper } = {
credential: credentialSyncId,
host: hostSyncId,
},
): Promise<unknown> {
if (namespace !== CORE_NAMESPACE) {
const hook = hostSettingsHook(namespace);
return hook?.exportValue ? hook.exportValue(key, value) : value;
}
if (key === "auth" && value && typeof value === "object") {
const auth = value as Record<string, unknown>;
if (typeof auth.credentialId !== "number") return value;
const { credentialId, ...rest } = auth;
return { ...rest, credentialSyncId: await maps.credential(credentialId) };
}
if (key === "jumpHosts" && Array.isArray(value)) {
const mapped = [];
for (const entry of value) {
const hostId = Number((entry as { hostId?: unknown })?.hostId);
const syncId = Number.isInteger(hostId) ? await maps.host(hostId) : null;
if (syncId) mapped.push({ hostSyncId: syncId });
}
return mapped;
}
return value;
}
/** A value on its way in: syncIds become this side's ids. */
export async function importDefaultValue(
namespace: string,
key: string,
value: unknown,
maps: { credential: SyncIdMapper; host: SyncIdMapper },
): Promise<unknown> {
if (namespace !== CORE_NAMESPACE) {
const hook = hostSettingsHook(namespace);
return hook?.importValue ? hook.importValue(key, value) : value;
}
if (key === "auth" && value && typeof value === "object") {
const auth = value as Record<string, unknown>;
if (typeof auth.credentialSyncId !== "string") return value;
const { credentialSyncId, ...rest } = auth;
return { ...rest, credentialId: await maps.credential(credentialSyncId) };
}
if (key === "jumpHosts" && Array.isArray(value)) {
const mapped = [];
for (const entry of value) {
const syncId = (entry as { hostSyncId?: unknown })?.hostSyncId;
const hostId =
typeof syncId === "string" ? await maps.host(syncId) : null;
if (hostId !== null) mapped.push({ hostId });
}
return mapped;
}
return value;
}
export async function loadHostDefaults(userId: string): Promise<SyncRow[]> {
const repository = createCurrentHostDefaultsRepository();
const rows = await repository.listOwnedBy(userId);
const folderSyncIds = new Map<number, string>();
const folders = await db()
.select({
id: sshFolders.id,
syncId: sshFolders.syncId,
localOnly: sshFolders.localOnly,
})
.from(sshFolders)
.where(eq(sshFolders.userId, userId));
for (const folder of folders) {
if (folder.syncId && !folder.localOnly) {
folderSyncIds.set(folder.id, folder.syncId);
}
}
const result: SyncRow[] = [];
for (const row of rows) {
const fullKey = joinDefaultKey(row.namespace, row.key);
let syncId: string;
let folderSyncId: string | null = null;
if (row.level === "user") {
syncId = `u:${fullKey}`;
} else if (row.level === "folder" && row.folderId !== null) {
const folder = folderSyncIds.get(row.folderId);
if (!folder) continue;
folderSyncId = folder;
syncId = `f:${folder}:${fullKey}`;
} else {
continue;
}
result.push({
syncId,
level: row.level,
folderSyncId,
namespace: row.namespace,
key: row.key,
value: await exportDefaultValue(
row.namespace,
row.key,
decodeDefaultValue(row.value),
),
});
}
return result;
}
/** Splits "u:<ns.key>" or "f:<folderSyncId>:<ns.key>". */
export function parseHostDefaultsSyncId(syncId: string): {
level: "user" | "folder";
folderSyncId: string | null;
fullKey: string;
} | null {
if (syncId.startsWith("u:")) {
return { level: "user", folderSyncId: null, fullKey: syncId.slice(2) };
}
if (syncId.startsWith("f:")) {
const rest = syncId.slice(2);
const colon = rest.indexOf(":");
if (colon <= 0) return null;
return {
level: "folder",
folderSyncId: rest.slice(0, colon),
fullKey: rest.slice(colon + 1),
};
}
return null;
}
async function folderIdFor(
userId: string,
folderSyncId: string,
): Promise<number | null> {
const [row] = await db()
.select({ id: sshFolders.id })
.from(sshFolders)
.where(
and(eq(sshFolders.syncId, folderSyncId), eq(sshFolders.userId, userId)),
)
.limit(1);
return row?.id ?? null;
}
async function scopeFor(
userId: string,
parsed: NonNullable<ReturnType<typeof parseHostDefaultsSyncId>>,
) {
if (parsed.level === "user") return { level: "user" as const, userId };
const folderId = parsed.folderSyncId
? await folderIdFor(userId, parsed.folderSyncId)
: null;
if (folderId === null) return null;
return { level: "folder" as const, userId, folderId };
}
export async function writeHostDefault(
userId: string,
wire: SyncRow,
): Promise<void> {
const parsed = parseHostDefaultsSyncId(String(wire.syncId));
if (!parsed) return;
const scope = await scopeFor(userId, parsed);
if (!scope) return;
const [namespace, key] = splitDefaultKey(parsed.fullKey);
const value = await importDefaultValue(namespace, key, wire.value, {
credential: localCredential(userId),
host: localHost(userId),
});
await createCurrentHostDefaultsRepository().apply(
scope,
[{ namespace, key, value: encodeDefaultValue(value) }],
[],
null,
);
await afterSyncedLevelChange(userId);
}
export async function eraseHostDefault(
userId: string,
syncId: string,
): Promise<void> {
const parsed = parseHostDefaultsSyncId(syncId);
if (!parsed) return;
const scope = await scopeFor(userId, parsed);
if (!scope) return;
const [namespace, key] = splitDefaultKey(parsed.fullKey);
await createCurrentHostDefaultsRepository().apply(
scope,
[],
[{ namespace, key }],
null,
);
await afterSyncedLevelChange(userId);
}
/**
* A level a desktop pushed changes what the server's hosts resolve to. On a
* desktop the hosts arrive already resolved, so nothing runs there.
*/
async function afterSyncedLevelChange(userId: string): Promise<void> {
const { getLink } = await import("./client/link-store.js");
if (await getLink().catch(() => null)) return;
const { recompute } = await import("../hosts/defaults/recompute.js");
void recompute({ userIds: [userId] }).catch(() => {});
}
export function coversHostDefault(
syncId: string,
activeManifest: (pluginId: string) => PluginManifest | undefined,
): boolean {
const parsed = parseHostDefaultsSyncId(syncId);
if (!parsed) return false;
const [namespace] = splitDefaultKey(parsed.fullKey);
return namespace === CORE_NAMESPACE || !!activeManifest(namespace);
}
export async function loadAdminDefaults(): Promise<SyncRow[]> {
const rows = await createCurrentHostDefaultsRepository().listScope({
level: "admin",
});
const values: Record<string, unknown> = {};
for (const row of rows) {
values[joinDefaultKey(row.namespace, row.key)] = decodeDefaultValue(
row.value,
);
}
return [{ syncId: singletonSyncId("hostDefaultsAdmin"), values }];
}
export async function writeAdminDefaults(
_userId: string,
wire: SyncRow,
): Promise<void> {
const values =
wire.values && typeof wire.values === "object" ? wire.values : {};
await createCurrentSettingsRepository().set(
REMOTE_ADMIN_DEFAULTS_KEY,
JSON.stringify(values),
);
}
+10 -1
View File
@@ -12,6 +12,7 @@ import {
setSetting,
} from "../plugins/settings.js";
import { databaseLogger } from "../utils/logger.js";
import type { DefaultOverrides } from "../../types/host-defaults.js";
import {
hostSettingsPlugins,
type HostPluginSettings,
@@ -50,10 +51,16 @@ export async function exportHostPluginSettings(
return result;
}
/** Writes what a synced host carried, translated back to local ids. */
/**
* Writes what a synced host carried, translated back to local ids. With
* `only`, a classified namespace takes just the keys the host sets itself;
* the rest follow this side's defaults and are written by the materialize
* pass that follows.
*/
export async function importHostPluginSettings(
hostId: number,
carried: unknown,
only?: DefaultOverrides | null,
): Promise<void> {
if (!carried || typeof carried !== "object") return;
const byPlugin = carried as HostPluginSettings;
@@ -63,8 +70,10 @@ export async function importHostPluginSettings(
const hook = consume<PluginHostSettingsSync>(
`${manifest.id}.hostSettingsSync`,
);
const ownKeys = only?.[manifest.id];
for (const field of declaredFields(manifest, "host")) {
if (field.type === "secret" || !(field.key in own)) continue;
if (ownKeys && !ownKeys.includes(field.key)) continue;
try {
const value = hook?.importValue
? await hook.importValue(field.key, own[field.key])
@@ -0,0 +1,116 @@
import { afterEach, describe, expect, it } from "vitest";
import { TestSqliteDatabase } from "./test-support.js";
import { HostDefaultsRepository } from "../../../database/repositories/host-defaults-repository.js";
describe("HostDefaultsRepository", () => {
let adapter: TestSqliteDatabase | null = null;
afterEach(async () => {
if (adapter) {
await adapter.close();
adapter = null;
}
});
async function createRepository(): Promise<HostDefaultsRepository> {
adapter = new TestSqliteDatabase();
const context = await adapter.connect();
await adapter.exec(`
INSERT INTO users (id, username, password_hash)
VALUES ('user-1', 'alice', 'hash'), ('user-2', 'bob', 'hash');
INSERT INTO ssh_folders (id, user_id, name)
VALUES (1, 'user-1', 'prod'), (2, 'user-2', 'prod');
INSERT INTO ssh_data (id, user_id, name, ip, port, username, folder, auth_type)
VALUES
(1, 'user-1', 'one', '10.0.0.1', 22, 'root', 'prod', 'password'),
(2, 'user-2', 'two', '10.0.0.2', 22, 'root', null, 'password');
`);
return new HostDefaultsRepository(context);
}
it("keeps each level apart, the server level included", async () => {
const repository = await createRepository();
await repository.apply(
{ level: "admin" },
[{ namespace: "core", key: "sshPort", value: "2022" }],
[],
"user-1",
);
await repository.apply(
{ level: "user", userId: "user-1" },
[{ namespace: "core", key: "sshPort", value: "2023" }],
[],
"user-1",
);
await repository.apply(
{ level: "folder", userId: "user-1", folderId: 1 },
[{ namespace: "term", key: "fontSize", value: "16" }],
[],
"user-1",
);
expect(await repository.listScope({ level: "admin" })).toHaveLength(1);
const forUser = await repository.listForUsers(["user-1"]);
expect(forUser.map((row) => `${row.level}:${row.value}`).sort()).toEqual([
"admin:2022",
"folder:16",
"user:2023",
]);
expect(await repository.listForUsers(["user-2"])).toHaveLength(1);
});
it("updates a key in place and clears one", async () => {
const repository = await createRepository();
const scope = { level: "user" as const, userId: "user-1" };
await repository.apply(
scope,
[{ namespace: "core", key: "sshPort", value: "1" }],
[],
null,
);
await repository.apply(
scope,
[{ namespace: "core", key: "sshPort", value: "2" }],
[],
null,
);
expect((await repository.listScope(scope)).map((row) => row.value)).toEqual(
["2"],
);
await repository.apply(
scope,
[],
[{ namespace: "core", key: "sshPort" }],
null,
);
expect(await repository.listScope(scope)).toEqual([]);
});
it("finds or creates a folder row for a path", async () => {
const repository = await createRepository();
expect(await repository.ensureFolder("user-1", "prod")).toBe(1);
const created = await repository.ensureFolder("user-1", "prod / web");
expect(await repository.findFolder(created)).toMatchObject({
userId: "user-1",
name: "prod / web",
});
});
it("reads and writes hosts in bulk", async () => {
const repository = await createRepository();
expect(
(await repository.listHosts({ userIds: ["user-1"] })).map(
(row) => row.id,
),
).toEqual([1]);
await repository.updateHosts([
{ id: 1, values: { sshPort: 2022, defaultOverrides: '{"core":[]}' } },
]);
const [row] = await repository.listHosts({ hostIds: [1] });
expect(row.sshPort).toBe(2022);
expect(row.defaultOverrides).toBe('{"core":[]}');
expect(await repository.listHostPlacement(["user-1"])).toEqual([
{ id: 1, userId: "user-1", folder: "prod", parentHostId: null },
]);
});
});
@@ -0,0 +1,206 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import express, { type RequestHandler } from "express";
import request from "supertest";
const state = vi.hoisted(() => ({
isAdmin: true,
access: { hasAccess: true, isOwner: true },
saved: [] as unknown[],
resets: [] as unknown[],
audits: [] as unknown[],
folders: new Map<number, { id: number; userId: string; name: string }>(),
}));
vi.mock("../../../utils/logger.js", () => ({
databaseLogger: { error: vi.fn(), warn: vi.fn(), info: vi.fn() },
}));
vi.mock("../../../utils/audit-logger.js", () => ({
logAudit: async (entry: unknown) => {
state.audits.push(entry);
},
getAuditUsername: async () => "admin",
getRequestMeta: () => ({ ipAddress: "127.0.0.1", userAgent: "test" }),
}));
vi.mock("../../../utils/permission-manager.js", () => ({
PermissionManager: {
getInstance: () => ({
canAccessHost: async () => state.access,
hasPermission: async () => state.isAdmin,
}),
},
}));
vi.mock("../../../hosts/usable-credential.js", () => ({
findUsableCredential: async () => ({}),
}));
vi.mock("../../../database/repositories/factory.js", () => ({
createCurrentHostDefaultsRepository: () => ({
findFolder: async (id: number) => state.folders.get(id) ?? null,
ensureFolder: async () => 42,
listHosts: async () => [{ id: 1, userId: "u1" }],
}),
}));
vi.mock("../../../hosts/defaults/materialize.js", () => ({
currentCatalog: () => ({ catalog: new Map() }),
materializeHosts: async () => ({ changedHostIds: [1, 2] }),
}));
vi.mock("../../../hosts/defaults/recompute.js", () => ({
getRecomputeJob: (id: string) =>
id === "job-1" ? { id, status: "done", changedHosts: 3 } : undefined,
recompute: async () => ({ changedHostIds: [5] }),
startRecomputeJob: () => ({ id: "job-1" }),
targetForScope: () => ({ all: true }),
}));
vi.mock("../../../hosts/defaults/service.js", () => ({
levelsAbove: async () => ({ admin: new Map(), user: new Map(), folders: [] }),
readLevel: async () => ({ "core.sshPort": 2022 }),
resolveAll: () => ({}),
resolveForEditor: async () => ({
"core.sshPort": { value: 2022, source: { level: "user" } },
}),
saveLevel: async (scope: unknown, change: unknown) => {
state.saved.push({ scope, change });
},
validateLevelChange: async (
_scope: unknown,
input: { set?: Record<string, unknown> },
) =>
input.set && "bad.key" in input.set
? { set: new Map(), unset: new Set(), errors: { "bad.key": "no" } }
: {
set: new Map(Object.entries(input.set ?? {})),
unset: new Set(),
errors: {},
},
}));
vi.mock("../../../hosts/defaults/overrides.js", () => ({
changeHostOverrides: async (hostIds: number[], change: unknown) => {
state.resets.push({ hostIds, change });
},
}));
vi.mock("../../../database/routes/host-bulk-routes.js", async () => {
const actual = await vi.importActual<
typeof import("../../../database/routes/host-bulk-routes.js")
>("../../../database/routes/host-bulk-routes.js");
return { readDefaultsReset: actual.readDefaultsReset };
});
const { registerHostDefaultsRoutes } =
await import("../../../database/routes/host-defaults-routes.js");
const pass: RequestHandler = (_req, _res, next) => next();
const adminOnly: RequestHandler = (_req, res, next) =>
state.isAdmin ? next() : void res.status(403).json({ error: "no" });
function app() {
const router = express.Router();
router.use((req, _res, next) => {
(req as unknown as { userId: string }).userId = "u1";
next();
});
registerHostDefaultsRoutes(router, {
authenticateJWT: pass,
requireEditPermission: pass,
requireDataAccess: pass,
requireAdminSettings: adminOnly,
});
const server = express();
server.use(express.json());
server.use("/host", router);
return server;
}
beforeEach(() => {
state.isAdmin = true;
state.access = { hasAccess: true, isOwner: true };
state.saved = [];
state.resets = [];
state.audits = [];
state.folders = new Map([
[1, { id: 1, userId: "u1", name: "Prod" }],
[2, { id: 2, userId: "u2", name: "Theirs" }],
]);
});
describe("host defaults routes", () => {
it("keeps the server level to admins", async () => {
state.isAdmin = false;
expect((await request(app()).get("/host/defaults/admin")).status).toBe(403);
expect(
(await request(app()).put("/host/defaults/admin").send({ set: {} }))
.status,
).toBe(403);
});
it("saves the server level as a background job, audited", async () => {
const response = await request(app())
.put("/host/defaults/admin")
.send({ set: { "core.sshPort": 2022 } });
expect(response.body).toEqual({ jobId: "job-1" });
expect(state.audits).toHaveLength(1);
const job = await request(app()).get("/host/defaults/jobs/job-1");
expect(job.body.changedHosts).toBe(3);
});
it("saves a user's own level and says how many hosts changed", async () => {
const response = await request(app())
.put("/host/defaults/user")
.send({ set: { "core.sshPort": 2022 } });
expect(response.body).toEqual({ changedHosts: 1 });
expect(state.saved[0]).toMatchObject({
scope: { level: "user", userId: "u1" },
});
});
it("reports rejected values", async () => {
const response = await request(app())
.put("/host/defaults/user")
.send({ set: { "bad.key": 1 } });
expect(response.status).toBe(400);
expect(response.body.errors).toEqual({ "bad.key": "no" });
});
it("only opens a folder the user owns", async () => {
expect((await request(app()).get("/host/defaults/folders/1")).status).toBe(
200,
);
expect((await request(app()).get("/host/defaults/folders/2")).status).toBe(
404,
);
const created = await request(app())
.post("/host/defaults/folders")
.send({ name: "New" });
expect(created.body).toEqual({ id: 42 });
});
it("previews a change without saving it", async () => {
const response = await request(app())
.post("/host/defaults/preview")
.send({ level: "user", set: { "core.sshPort": 1 } });
expect(response.body).toEqual({ changedHosts: 2 });
expect(state.saved).toEqual([]);
});
it("resolves a host's defaults for the editor", async () => {
const response = await request(app()).get(
"/host/defaults/resolve?hostId=1",
);
expect(response.body.values["core.sshPort"].value).toBe(2022);
});
it("resets a host, but never a shared editor over the owner's login", async () => {
await request(app())
.post("/host/db/host/7/reset-defaults")
.send({ keys: ["core.sshPort"] });
expect(state.resets).toHaveLength(1);
state.access = { hasAccess: true, isOwner: false };
const all = await request(app())
.post("/host/db/host/7/reset-defaults")
.send({ all: true });
expect(all.status).toBe(403);
expect(
(await request(app()).post("/host/db/host/7/reset-defaults").send({}))
.status,
).toBe(400);
});
});
@@ -11,7 +11,17 @@ vi.mock("../../../database/routes/host-plugin-settings.js", () => ({
applyPluginHostImportSettings: mocks.applyPluginSettings,
setHostPluginEnabled: vi.fn(),
}));
vi.mock("../../../hosts/defaults/index.js", () => ({
applyHostDefaultsToWrite: async () => ({ core: [] }),
applyDefaultsAfterHostWrite: async () => {},
}));
vi.mock("../../../hosts/defaults/overrides.js", () => ({
changeHostOverrides: async () => {},
}));
vi.mock("../../../database/repositories/factory.js", () => ({
createCurrentHostDefaultsRepository: () => ({
listHosts: async () => [],
}),
createCurrentCredentialRepository: () => ({
listDecryptedByUserId: async () => [],
}),
@@ -52,6 +52,9 @@ vi.mock("../../../database/repositories/factory.js", () => ({
createCurrentPluginSettingsRepository: () => ({ getAllForScopeIds, set }),
}));
vi.mock("../../../hosts/defaults/overrides.js", () => ({
changeHostOverrides: async () => {},
}));
vi.mock("../../../plugins/index.js", () => ({
getPluginRuntime: () => ({ loader: { list: () => loaded } }),
}));
@@ -1,7 +1,7 @@
/**
* The admin host defaults. The terminal half moved to the ssh-terminal
* plugin's new-host settings; its keys stay in the stored row for the boot
* copy and a downgrade, but are never served or overwritten here.
* The old admin host defaults routes are gone: host defaults live under
* /host/defaults now. The GET used to hand the admin's proxy password to any
* signed-in user.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
@@ -65,40 +65,18 @@ beforeEach(() => {
});
describe("/users/host-defaults", () => {
it("serves core's defaults without the terminal keys the plugin took over", async () => {
it("is no longer served, so the stored row never leaves the server", async () => {
state.settings.host_defaults = JSON.stringify({
useSocks5: true,
fontSize: 18,
autoTmux: true,
socks5Password: "secret",
});
const response = await request(app()).get("/users/host-defaults");
expect(response.status).toBe(200);
expect(response.body).toEqual({ useSocks5: true });
});
it("keeps the stored terminal keys when an admin saves core's defaults", async () => {
state.settings.host_defaults = JSON.stringify({
useSocks5: true,
fontSize: 18,
enableCommandHistory: false,
});
const response = await request(app())
const read = await request(app()).get("/users/host-defaults");
expect(read.status).toBe(404);
expect(JSON.stringify(read.body)).not.toContain("secret");
const write = await request(app())
.patch("/users/host-defaults")
.send({ useSocks5: false, statusCheckEnabled: false, autoTmux: true });
expect(response.status).toBe(200);
expect(JSON.parse(state.settings.host_defaults)).toEqual({
fontSize: 18,
enableCommandHistory: false,
useSocks5: false,
statusCheckEnabled: false,
});
});
it("refuses a user who is not an admin", async () => {
state.admin = false;
const response = await request(app())
.patch("/users/host-defaults")
.send({ useSocks5: true });
expect(response.status).toBe(403);
.send({ useSocks5: false });
expect(write.status).toBe(404);
expect(JSON.parse(state.settings.host_defaults).useSocks5).toBe(true);
});
});
+22
View File
@@ -450,6 +450,28 @@ CREATE INDEX `idx_host_protocol_auth_credential` ON `host_protocol_auth` (`crede
-- install loses what it stored.
SELECT 1;
-- ==== core 0065_ambiguous_agent_zero ====
CREATE TABLE `host_defaults` (
`id` int AUTO_INCREMENT NOT NULL,
`level` enum('admin','user','folder') NOT NULL,
`scope_key` varchar(255) NOT NULL,
`user_id` varchar(255),
`folder_id` int,
`namespace` varchar(255) NOT NULL,
`key` varchar(255) NOT NULL,
`value` text,
`updated_by` text,
`updated_at` text NOT NULL DEFAULT (CURRENT_TIMESTAMP),
CONSTRAINT `host_defaults_id` PRIMARY KEY(`id`),
CONSTRAINT `idx_host_defaults_scope_key` UNIQUE(`scope_key`,`namespace`,`key`)
);
--> statement-breakpoint
ALTER TABLE `ssh_data` ADD `default_overrides` text;--> statement-breakpoint
ALTER TABLE `host_defaults` ADD CONSTRAINT `host_defaults_user_id_users_id_fk` FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE `host_defaults` ADD CONSTRAINT `host_defaults_folder_id_ssh_folders_id_fk` FOREIGN KEY (`folder_id`) REFERENCES `ssh_folders`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
CREATE INDEX `idx_host_defaults_user` ON `host_defaults` (`user_id`);--> statement-breakpoint
CREATE INDEX `idx_host_defaults_folder` ON `host_defaults` (`folder_id`);
-- ==== plugin workspaces 0001_adopt_user_workspaces.sql ====
-- workspaces 0001: adopt_user_workspaces
-- Generated by termix-plugin migrations. Review before committing.
+21
View File
@@ -469,6 +469,27 @@ CREATE INDEX "idx_host_protocol_auth_credential" ON "host_protocol_auth" USING b
-- install loses what it stored.
SELECT 1;
-- ==== core 0065_regular_joystick ====
CREATE TABLE "host_defaults" (
"id" serial PRIMARY KEY NOT NULL,
"level" text NOT NULL,
"scope_key" varchar(255) NOT NULL,
"user_id" varchar(255),
"folder_id" integer,
"namespace" varchar(255) NOT NULL,
"key" varchar(255) NOT NULL,
"value" text,
"updated_by" text,
"updated_at" text DEFAULT CURRENT_TIMESTAMP NOT NULL
);
--> statement-breakpoint
ALTER TABLE "ssh_data" ADD COLUMN "default_overrides" text;--> statement-breakpoint
ALTER TABLE "host_defaults" ADD CONSTRAINT "host_defaults_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "host_defaults" ADD CONSTRAINT "host_defaults_folder_id_ssh_folders_id_fk" FOREIGN KEY ("folder_id") REFERENCES "public"."ssh_folders"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
CREATE UNIQUE INDEX "idx_host_defaults_scope_key" ON "host_defaults" USING btree ("scope_key","namespace","key");--> statement-breakpoint
CREATE INDEX "idx_host_defaults_user" ON "host_defaults" USING btree ("user_id");--> statement-breakpoint
CREATE INDEX "idx_host_defaults_folder" ON "host_defaults" USING btree ("folder_id");
-- ==== plugin workspaces 0001_adopt_user_workspaces.sql ====
-- workspaces 0001: adopt_user_workspaces
-- Generated by termix-plugin migrations. Review before committing.
@@ -0,0 +1,121 @@
import { describe, expect, it, vi } from "vitest";
vi.mock("../../../database/repositories/factory.js", () => ({}));
import type { PluginManifest } from "@termix/plugin-sdk/manifest";
import {
buildCatalog,
catalogNamespaces,
isDefaultableField,
} from "../../../hosts/defaults/catalog.js";
import {
defaultValuesEqual,
normalizeAuthDefault,
normalizeSocks5Default,
parseDefaultOverrides,
splitDefaultKey,
stableStringify,
} from "../../../../types/host-defaults.js";
const manifest = {
id: "fixture",
contributes: {
settings: {
host: {
enableKey: "enabled",
enableDefault: true,
fields: [
{ key: "mode", type: "select", default: "a", options: [] },
{ key: "token", type: "secret" },
{ key: "config", type: "json", secretKeys: ["password"] },
{ key: "mac", type: "string", defaultable: false },
{
key: "snippet",
type: "number",
defaultLevels: ["user", "folder"],
},
{ key: "font", type: "number", default: 14, personal: true },
],
},
},
},
} as unknown as PluginManifest;
describe("the catalog", () => {
it("holds core's keys and each defaultable plugin field", () => {
const catalog = buildCatalog([manifest]);
expect(catalog.has("core.sshPort")).toBe(true);
expect(catalog.has("core.auth")).toBe(true);
expect(catalog.has("fixture.enabled")).toBe(true);
expect(catalog.has("fixture.mode")).toBe(true);
expect(catalog.has("fixture.token")).toBe(false);
expect(catalog.has("fixture.config")).toBe(false);
expect(catalog.has("fixture.mac")).toBe(false);
expect(catalog.get("fixture.snippet")?.levels).toEqual(["user", "folder"]);
expect(catalog.get("fixture.font")?.personal).toBe(true);
expect(catalog.get("fixture.enabled")?.builtin).toBe(true);
expect(catalogNamespaces(catalog)).toEqual(["core", "fixture"]);
});
it("reads an unset plugin value as its manifest default", () => {
const mode = buildCatalog([manifest]).get("fixture.mode")!;
expect(mode.normalize(null)).toBe("a");
expect(mode.normalize("")).toBe("a");
expect(mode.normalize("b")).toBe("b");
});
it("never lets a secret be a default", () => {
expect(isDefaultableField({ key: "s", type: "secret" })).toBe(false);
expect(
isDefaultableField({ key: "s", type: "secret", defaultable: true }),
).toBe(false);
});
});
describe("value shapes", () => {
it("keeps only the auth fields its type uses", () => {
expect(
normalizeAuthDefault({
authType: "agent",
credentialId: 4,
agentSocketPath: "/tmp/agent",
}),
).toEqual({
authType: "agent",
credentialId: null,
overrideCredentialUsername: false,
agentSocketPath: "/tmp/agent",
agentIdentity: null,
});
expect(normalizeAuthDefault(null)).toBeNull();
});
it("drops a proxy's settings while it is off, and chain passwords always", () => {
expect(
normalizeSocks5Default({ useSocks5: false, socks5Host: "x" }).socks5Host,
).toBeNull();
expect(
normalizeSocks5Default({
useSocks5: true,
socks5ProxyChain: [{ host: "a", password: "p" }],
}).socks5ProxyChain,
).toEqual([{ host: "a" }]);
});
it("compares objects regardless of key order", () => {
expect(stableStringify({ b: 1, a: 2 })).toBe(
stableStringify({ a: 2, b: 1 }),
);
expect(defaultValuesEqual([{ a: 1 }], [{ a: 1 }])).toBe(true);
});
it("parses overrides and splits keys", () => {
expect(parseDefaultOverrides('{"core":["b","a","a"],"x":"no"}')).toEqual({
core: ["a", "b"],
});
expect(parseDefaultOverrides(null)).toBeNull();
expect(splitDefaultKey("ssh-terminal.fontSize")).toEqual([
"ssh-terminal",
"fontSize",
]);
});
});
@@ -0,0 +1,87 @@
import { describe, expect, it } from "vitest";
import {
readCoreDefault,
writeCoreDefault,
} from "../../../hosts/defaults/core-columns.js";
describe("core defaults in column form", () => {
it("reads the SSH options a row keeps as JSON", () => {
const row = {
sshOptions: JSON.stringify({ keepaliveInterval: 30 }),
};
expect(readCoreDefault("keepaliveInterval", row)).toBe(30);
expect(readCoreDefault("keepaliveCountMax", row)).toBe(5);
expect(readCoreDefault("allowLegacyAlgorithms", row)).toBe(true);
});
it("writes one SSH option and keeps the others", () => {
const row = {
sshOptions: JSON.stringify({
keepaliveInterval: 30,
agentForwarding: true,
}),
};
const patch = writeCoreDefault("keepaliveInterval", 90, row);
expect(JSON.parse(patch.sshOptions as string)).toEqual({
keepaliveInterval: 90,
agentForwarding: true,
});
});
it("moves an SSH host's port with its SSH port, but not a plugin protocol's", () => {
expect(
writeCoreDefault("sshPort", 2222, { connectionType: "ssh" }),
).toEqual({ sshPort: 2222, port: 2222 });
expect(
writeCoreDefault("sshPort", 2222, { connectionType: "rdp" }),
).toEqual({ sshPort: 2222 });
});
it("writes a credential login and clears any key the host held", () => {
const patch = writeCoreDefault(
"auth",
{ authType: "credential", credentialId: 7 },
{ sshOptions: null, password: "kept" },
);
expect(patch).toMatchObject({
authType: "credential",
credentialId: 7,
key: null,
keyPassword: null,
});
expect(patch).not.toHaveProperty("password");
expect(
writeCoreDefault("auth", { authType: "agent" }, {}).password,
).toBeNull();
});
it("round-trips a proxy and keeps the host's own chain passwords", () => {
const row = {
socks5ProxyChain: JSON.stringify([{ host: "a", password: "mine" }]),
};
const patch = writeCoreDefault(
"socks5",
{ useSocks5: true, socks5ProxyChain: [{ host: "a" }] },
row,
);
expect(JSON.parse(patch.socks5ProxyChain as string)).toEqual([
{ host: "a", password: "mine" },
]);
expect(readCoreDefault("socks5", { ...row, ...patch })).toMatchObject({
useSocks5: true,
socks5ProxyChain: [{ host: "a" }],
});
});
it("stores empty lists as null and booleans as the column expects", () => {
expect(writeCoreDefault("jumpHosts", [], {})).toEqual({ jumpHosts: null });
expect(writeCoreDefault("forceKeyboardInteractive", true, {})).toEqual({
forceKeyboardInteractive: "true",
});
expect(
readCoreDefault("forceKeyboardInteractive", {
forceKeyboardInteractive: "false",
}),
).toBe(false);
});
});
@@ -0,0 +1,324 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
/**
* The materialize pass against in-memory tables: hosts, plugin host rows and
* the defaults levels.
*/
type Row = Record<string, unknown> & { id: number; userId: string };
const db = vi.hoisted(() => ({
hosts: [] as Array<Record<string, unknown> & { id: number; userId: string }>,
pluginRows: [] as Array<{
pluginId: string;
scope: string;
scopeId: string;
key: string;
value: string | null;
encrypted: boolean;
}>,
defaults: [] as Array<Record<string, unknown>>,
folders: [] as Array<{ id: number; userId: string; name: string }>,
events: [] as unknown[],
notified: [] as unknown[],
}));
const manifest = vi.hoisted(() => ({
id: "term",
contributes: {
settings: {
host: {
enableKey: "enabled",
enableDefault: true,
fields: [
{ key: "fontSize", type: "number", default: 14 },
{ key: "mac", type: "string", defaultable: false },
],
},
},
},
}));
vi.mock("../../../database/routes/host-plugin-settings.js", () => ({
hostSettingsPlugins: () => [manifest],
}));
vi.mock("../../../plugins/events.js", () => ({
TOPICS: { hostUpdated: "host.updated" },
pluginEvents: { emit: (...args: unknown[]) => db.events.push(args) },
}));
vi.mock("../../../sync/server/feed.js", () => ({ markChanged: () => {} }));
vi.mock("../../../utils/shared-host-secrets-manager.js", () => ({
SharedHostSecretsManager: {
getInstance: () => ({ resyncHost: async () => {} }),
},
}));
vi.mock("../../../plugins/settings.js", async (importOriginal) => ({
...(await importOriginal<typeof import("../../../plugins/settings.js")>()),
notifySettingChange: (...args: unknown[]) => db.notified.push(args),
}));
vi.mock("../../../database/repositories/factory.js", () => ({
createCurrentSettingsRepository: () => ({ get: async () => null }),
createCurrentHostDefaultsRepository: () => ({
listHosts: async (filter: {
hostIds?: number[];
userIds?: string[];
all?: boolean;
}) =>
db.hosts.filter(
(host) =>
filter.all ||
filter.hostIds?.includes(host.id) ||
filter.userIds?.includes(host.userId),
),
listForUsers: async (userIds: string[]) =>
db.defaults.filter(
(row) =>
row.level === "admin" || userIds.includes(row.userId as string),
),
listFolders: async (userIds: string[]) =>
db.folders.filter((folder) => userIds.includes(folder.userId)),
listHostPlacement: async (userIds: string[]) =>
db.hosts
.filter((host) => userIds.includes(host.userId))
.map((host) => ({
id: host.id,
userId: host.userId,
folder: (host.folder as string | null) ?? null,
parentHostId: (host.parentHostId as number | null) ?? null,
})),
updateHosts: async (patches: Array<{ id: number; values: object }>) => {
for (const patch of patches) {
const host = db.hosts.find((row) => row.id === patch.id)!;
Object.assign(host, patch.values);
}
},
}),
createCurrentPluginSettingsRepository: () => ({
getAllForScopeIds: async (_scope: string, ids: string[]) =>
db.pluginRows.filter((row) => ids.includes(row.scopeId)),
set: async (
pluginId: string,
scope: string,
scopeId: string,
key: string,
value: string,
) => {
const existing = db.pluginRows.find(
(row) =>
row.pluginId === pluginId &&
row.scopeId === scopeId &&
row.key === key,
);
if (existing) existing.value = value;
else
db.pluginRows.push({
pluginId,
scope,
scopeId,
key,
value,
encrypted: false,
});
},
delete: async (
pluginId: string,
_scope: string,
scopeId: string,
key: string,
) => {
db.pluginRows = db.pluginRows.filter(
(row) =>
!(
row.pluginId === pluginId &&
row.scopeId === scopeId &&
row.key === key
),
);
return true;
},
}),
}));
const { materializeHosts } =
await import("../../../hosts/defaults/materialize.js");
function host(partial: Partial<Row> & { id: number }): Row {
return {
userId: "u1",
connectionType: "ssh",
port: 22,
sshPort: 22,
username: "root",
authType: "password",
folder: null,
parentHostId: null,
statusCheckEnabled: true,
defaultOverrides: null,
sharedSource: null,
...partial,
};
}
function setDefault(
level: "admin" | "user" | "folder",
key: string,
value: unknown,
extra: Record<string, unknown> = {},
) {
const [namespace, name] = key.split(".");
db.defaults.push({
level,
namespace,
key: name,
value: JSON.stringify(value),
userId: level === "admin" ? null : "u1",
folderId: null,
...extra,
});
}
function pluginValue(hostId: number, key: string): unknown {
const row = db.pluginRows.find(
(entry) => entry.scopeId === String(hostId) && entry.key === key,
);
return row ? JSON.parse(row.value as string) : undefined;
}
beforeEach(() => {
db.hosts = [];
db.pluginRows = [];
db.defaults = [];
db.folders = [];
db.events = [];
db.notified = [];
});
describe("materializeHosts", () => {
it("classifies a host from before host defaults: matching values follow them", async () => {
db.hosts = [host({ id: 1, sshPort: 22 }), host({ id: 2, sshPort: 2222 })];
db.pluginRows.push({
pluginId: "term",
scope: "host",
scopeId: "2",
key: "fontSize",
value: "18",
encrypted: false,
});
const result = await materializeHosts({ all: true });
expect(result.changedHostIds).toEqual([]);
const first = JSON.parse(db.hosts[0].defaultOverrides as string);
const second = JSON.parse(db.hosts[1].defaultOverrides as string);
expect(first.core).not.toContain("sshPort");
expect(second.core).toContain("sshPort");
expect(first.term).toEqual([]);
expect(second.term).toEqual(["fontSize"]);
// Nothing a user sees changed.
expect(db.hosts[1].sshPort).toBe(2222);
});
it("writes a new default into every host that follows it, and no other", async () => {
db.hosts = [
host({ id: 1, defaultOverrides: JSON.stringify({ core: [], term: [] }) }),
host({
id: 2,
sshPort: 2200,
defaultOverrides: JSON.stringify({ core: ["sshPort"], term: [] }),
}),
];
setDefault("user", "core.sshPort", 2022);
setDefault("admin", "term.fontSize", 16);
const result = await materializeHosts({ userIds: ["u1"] });
expect(result.changedHostIds).toEqual([1, 2]);
expect(db.hosts[0].sshPort).toBe(2022);
expect(db.hosts[0].port).toBe(2022);
expect(db.hosts[1].sshPort).toBe(2200);
expect(pluginValue(1, "fontSize")).toBe(16);
expect(pluginValue(2, "fontSize")).toBe(16);
expect(db.events).toHaveLength(2);
expect(db.notified).toEqual([["term", "fontSize", 16]]);
});
it("removes the row when a key goes back to the manifest default", async () => {
db.hosts = [
host({ id: 1, defaultOverrides: JSON.stringify({ core: [], term: [] }) }),
];
db.pluginRows.push({
pluginId: "term",
scope: "host",
scopeId: "1",
key: "fontSize",
value: "16",
encrypted: false,
});
await materializeHosts({ all: true });
expect(pluginValue(1, "fontSize")).toBeUndefined();
});
it("follows the deepest folder, and a sub-host follows its parent's", async () => {
db.folders = [
{ id: 10, userId: "u1", name: "Prod" },
{ id: 11, userId: "u1", name: "Prod / Web" },
];
const classified = JSON.stringify({ core: [], term: [] });
db.hosts = [
host({ id: 1, folder: "Prod / Web", defaultOverrides: classified }),
host({ id: 2, parentHostId: 1, defaultOverrides: classified }),
host({ id: 3, folder: "Prod", defaultOverrides: classified }),
];
setDefault("folder", "core.sshPort", 2100, { folderId: 10 });
setDefault("folder", "core.sshPort", 2200, { folderId: 11 });
await materializeHosts({ all: true });
expect(db.hosts.map((row) => row.sshPort)).toEqual([2200, 2200, 2100]);
});
it("never writes a jump chain through the host itself", async () => {
db.folders = [{ id: 10, userId: "u1", name: "Lab" }];
db.hosts = [
host({
id: 1,
folder: "Lab",
defaultOverrides: JSON.stringify({ core: [], term: [] }),
}),
];
setDefault("folder", "core.jumpHosts", [{ hostId: 1 }, { hostId: 9 }], {
folderId: 10,
});
await materializeHosts({ all: true });
expect(JSON.parse(db.hosts[0].jumpHosts as string)).toEqual([
{ hostId: 9 },
]);
});
it("previews without writing", async () => {
db.hosts = [
host({ id: 1, defaultOverrides: JSON.stringify({ core: [], term: [] }) }),
];
const result = await materializeHosts(
{ all: true },
{
dryRun: true,
overlay: {
level: "admin",
set: new Map([["core.sshPort", 2022]]),
unset: new Set(),
},
},
);
expect(result.changedHostIds).toEqual([1]);
expect(db.hosts[0].sshPort).toBe(22);
});
it("leaves a desktop's copy of a shared host alone", async () => {
db.hosts = [host({ id: 1, sharedSource: "{}" })];
setDefault("admin", "core.sshPort", 2022);
await materializeHosts({ all: true });
expect(db.hosts[0].sshPort).toBe(22);
expect(db.hosts[0].defaultOverrides).toBeNull();
});
});
@@ -0,0 +1,132 @@
import { describe, expect, it, vi } from "vitest";
vi.mock("../../../database/repositories/factory.js", () => ({}));
import {
effectiveFolderPath,
folderChainPaths,
isOwnValue,
resolveKey,
withoutSelfJump,
type HostLevels,
} from "../../../hosts/defaults/resolve.js";
import { coreCatalogEntries } from "../../../hosts/defaults/catalog.js";
const entry = (key: string) =>
coreCatalogEntries().find((info) => info.key === key)!;
function levels(partial: Partial<HostLevels> = {}): HostLevels {
return {
admin: new Map(),
user: new Map(),
folders: [],
...partial,
};
}
describe("folderChainPaths", () => {
it("lists every ancestor, root first", () => {
expect(folderChainPaths("A / B / C")).toEqual(["A", "A / B", "A / B / C"]);
expect(folderChainPaths(null)).toEqual([]);
expect(folderChainPaths("")).toEqual([]);
});
});
describe("effectiveFolderPath", () => {
it("takes a sub-host's folder from its nearest placed ancestor", () => {
const placement = new Map([
[1, { folder: "Prod", parentHostId: null }],
[2, { folder: null, parentHostId: 1 }],
[3, { folder: null, parentHostId: 2 }],
]);
expect(effectiveFolderPath(3, placement)).toBe("Prod");
expect(effectiveFolderPath(1, placement)).toBe("Prod");
});
it("stops on a parent cycle", () => {
const placement = new Map([
[1, { folder: null, parentHostId: 2 }],
[2, { folder: null, parentHostId: 1 }],
]);
expect(effectiveFolderPath(1, placement)).toBeNull();
});
});
describe("resolveKey", () => {
it("prefers the deepest folder, then the user, then the server, then built-in", () => {
const port = entry("sshPort");
expect(resolveKey(port, levels())).toEqual({
value: 22,
source: { level: "builtin" },
});
expect(
resolveKey(port, levels({ admin: new Map([["core.sshPort", 2200]]) }))
?.source.level,
).toBe("admin");
const all = levels({
admin: new Map([["core.sshPort", 2200]]),
user: new Map([["core.sshPort", 2201]]),
folders: [
{ folderId: 1, name: "A", values: new Map([["core.sshPort", 2202]]) },
{
folderId: 2,
name: "A / B",
values: new Map([["core.sshPort", 2203]]),
},
],
});
expect(resolveKey(port, all)).toEqual({
value: 2203,
source: { level: "folder", folderId: 2, folderName: "A / B" },
});
all.folders[1].values.clear();
expect(resolveKey(port, all)?.value).toBe(2202);
});
it("skips a level the key cannot be set at", () => {
const jumps = entry("jumpHosts");
const result = resolveKey(
jumps,
levels({ admin: new Map([["core.jumpHosts", [{ hostId: 5 }]]]) }),
);
expect(result?.source.level).toBe("builtin");
expect(result?.value).toEqual([]);
});
it("resolves nothing for a key with no built-in value and no level", () => {
expect(resolveKey(entry("username"), levels())).toBeUndefined();
});
});
describe("isOwnValue", () => {
it("compares normalized values", () => {
const port = entry("sshPort");
const resolved = { value: 22, source: { level: "builtin" as const } };
expect(isOwnValue(port, "22", resolved)).toBe(false);
expect(isOwnValue(port, null, resolved)).toBe(false);
expect(isOwnValue(port, 2222, resolved)).toBe(true);
});
it("treats any value as the host's own when nothing resolves", () => {
const username = entry("username");
expect(isOwnValue(username, "root", undefined)).toBe(true);
expect(isOwnValue(username, "", undefined)).toBe(false);
});
it("treats keepalive left unset as the built-in value", () => {
const keepalive = entry("keepaliveInterval");
expect(
isOwnValue(keepalive, undefined, {
value: 60,
source: { level: "builtin" },
}),
).toBe(false);
});
});
describe("withoutSelfJump", () => {
it("drops the host itself from an inherited jump chain", () => {
expect(withoutSelfJump([{ hostId: 1 }, { hostId: 2 }], 2)).toEqual([
{ hostId: 1 },
]);
});
});
@@ -0,0 +1,115 @@
import { describe, expect, it, vi } from "vitest";
import type { PluginManifest } from "@termix/plugin-sdk/manifest";
vi.mock("../../../database/repositories/factory.js", () => ({}));
vi.mock("../../../database/routes/host-plugin-settings.js", () => ({
hostSettingsPlugins: () => [],
}));
const { validateLevelChange } =
await import("../../../hosts/defaults/service.js");
const { buildCatalog } = await import("../../../hosts/defaults/catalog.js");
const catalog = buildCatalog([
{
id: "fx",
contributes: {
settings: {
host: {
fields: [
{ key: "size", type: "number", min: 8, max: 36, default: 14 },
{ key: "snippet", type: "number", defaultLevels: ["user"] },
],
},
},
},
} as unknown as PluginManifest,
]);
const context = {
actorId: "u1",
ownerId: "u1",
canUseCredential: async (id: number) => id === 7,
canUseHost: async (id: number) => id === 3,
};
describe("validateLevelChange", () => {
it("accepts known keys and normalizes their values", async () => {
const change = await validateLevelChange(
{ level: "user", userId: "u1" },
{ set: { "core.sshPort": "2022", "fx.size": "16" }, unset: ["fx.x"] },
context,
catalog,
);
expect(change.errors).toEqual({});
expect(change.set.get("core.sshPort")).toBe(2022);
expect(change.set.get("fx.size")).toBe(16);
expect([...change.unset]).toEqual(["fx.x"]);
});
it("refuses unknown keys, out of range values and levels a key cannot use", async () => {
const change = await validateLevelChange(
{ level: "admin" },
{
set: {
"core.name": "x",
"fx.size": 99,
"fx.snippet": 1,
"core.jumpHosts": [{ hostId: 3 }],
},
},
context,
catalog,
);
expect(Object.keys(change.errors).sort()).toEqual([
"core.jumpHosts",
"core.name",
"fx.size",
"fx.snippet",
]);
});
it("never takes a password or key login, or a credential at the server level", async () => {
const password = await validateLevelChange(
{ level: "user", userId: "u1" },
{ set: { "core.auth": { authType: "password" } } },
context,
catalog,
);
expect(password.errors["core.auth"]).toMatch(/cannot be a default/);
const serverCredential = await validateLevelChange(
{ level: "admin" },
{ set: { "core.auth": { authType: "credential", credentialId: 7 } } },
context,
catalog,
);
expect(serverCredential.errors["core.auth"]).toMatch(/server default/);
const ownCredential = await validateLevelChange(
{ level: "folder", folderId: 1, userId: "u1" },
{ set: { "core.auth": { authType: "credential", credentialId: 7 } } },
context,
catalog,
);
expect(ownCredential.errors).toEqual({});
const otherCredential = await validateLevelChange(
{ level: "user", userId: "u1" },
{ set: { "core.auth": { authType: "credential", credentialId: 8 } } },
context,
catalog,
);
expect(otherCredential.errors["core.auth"]).toMatch(/not available/);
});
it("checks every jump host in a chain", async () => {
const change = await validateLevelChange(
{ level: "user", userId: "u1" },
{ set: { "core.jumpHosts": [{ hostId: 3 }, { hostId: 4 }] } },
context,
catalog,
);
expect(change.errors["core.jumpHosts"]).toBeDefined();
});
});
@@ -0,0 +1,150 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const db = vi.hoisted(() => ({
defaults: [] as Array<Record<string, unknown>>,
}));
vi.mock("../../../database/routes/host-plugin-settings.js", () => ({
hostSettingsPlugins: () => [],
}));
vi.mock("../../../database/repositories/factory.js", () => ({
createCurrentSettingsRepository: () => ({ get: async () => null }),
createCurrentHostDefaultsRepository: () => ({
listForUsers: async () => db.defaults,
listFolders: async () => [],
listHostPlacement: async () => [],
}),
}));
const { applyHostDefaultsToWrite } =
await import("../../../hosts/defaults/write-rules.js");
const { applyOverrideChange } =
await import("../../../hosts/defaults/overrides.js");
function userDefault(key: string, value: unknown) {
db.defaults.push({
level: "user",
userId: "u1",
folderId: null,
namespace: "core",
key,
value: JSON.stringify(value),
});
}
beforeEach(() => {
db.defaults = [];
});
describe("applyHostDefaultsToWrite", () => {
it("takes the editor's list as the answer and fills what it inherits", async () => {
userDefault("sshPort", 2022);
const columns: Record<string, unknown> = {
sshPort: 9999,
port: 9999,
connectionType: "ssh",
};
const overrides = await applyHostDefaultsToWrite({
ownerId: "u1",
hostId: null,
columns,
body: { defaultOverrides: { core: [], term: ["fontSize"] } },
});
expect(columns.sshPort).toBe(2022);
expect(columns.port).toBe(2022);
expect(overrides.core).toEqual([]);
expect(overrides.term).toEqual(["fontSize"]);
});
it("inherits what a create leaves out, and keeps what it sets", async () => {
userDefault("sshPort", 2022);
userDefault("statusCheckEnabled", false);
const columns: Record<string, unknown> = {
sshPort: 22,
port: 22,
statusCheckEnabled: 1,
username: "deploy",
connectionType: "ssh",
};
const overrides = await applyHostDefaultsToWrite({
ownerId: "u1",
hostId: null,
columns,
body: { ip: "10.0.0.1", port: 22, username: "deploy" },
});
expect(columns.statusCheckEnabled).toBe(false);
// The port was sent and differs from the default, so it is the host's.
expect(overrides.core).toContain("sshPort");
expect(columns.sshPort).toBe(22);
expect(overrides.core).toContain("username");
});
it("marks a changed key on an update without a list, and keeps the rest", async () => {
userDefault("sshPort", 2022);
const stored = {
id: 5,
userId: "u1",
sshPort: 2022,
port: 2022,
statusCheckEnabled: true,
username: "root",
defaultOverrides: JSON.stringify({ core: ["username"] }),
};
const columns: Record<string, unknown> = {
sshPort: 2022,
port: 2022,
statusCheckEnabled: 0,
username: "root",
};
const overrides = await applyHostDefaultsToWrite({
ownerId: "u1",
hostId: 5,
columns,
body: {},
stored: stored as never,
});
expect(overrides.core).toEqual(["statusCheckEnabled", "username"]);
});
it("keeps a locked key's state for a shared editor", async () => {
const overrides = await applyHostDefaultsToWrite({
ownerId: "u1",
hostId: 5,
columns: {},
body: { defaultOverrides: { core: [] } },
stored: {
id: 5,
userId: "u1",
defaultOverrides: JSON.stringify({ core: ["auth"] }),
} as never,
lockedKeys: ["auth"],
});
expect(overrides.core).toContain("auth");
});
});
describe("applyOverrideChange", () => {
it("adds and removes keys, per namespace", () => {
const next = applyOverrideChange(
{ core: ["sshPort"], term: ["fontSize", "theme"] },
{ own: [["core", "username"]], inherit: [["term", "theme"]] },
);
expect(next).toEqual({ core: ["sshPort", "username"], term: ["fontSize"] });
});
it("leaves a namespace nobody classified yet to its first pass", () => {
expect(applyOverrideChange({ core: [] }, { own: [["term", "x"]] })).toEqual(
{ core: [] },
);
expect(applyOverrideChange(null, { own: [["core", "x"]] })).toEqual({});
});
it("resets everything, known namespaces included", () => {
expect(
applyOverrideChange({ core: ["a"] }, { inheritAll: true }, [
"core",
"term",
]),
).toEqual({ core: [], term: [] });
});
});
Loaded 100 of 144 files, more files were not shown because too many files have changed in this diff. Show more