mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-29 14:45:24 +00:00
feat: redesign user host defaults system
This commit is contained in:
1 parent
3028ab1176
commit
a4a4d4f27b
144 files changed
+24609
-2555
No files matched your search
@@ -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))];
|
||||
}
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
@@ -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),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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) };
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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),
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
Reference in new issue
Block a user