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

+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;
}