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

+101 -1
View File
@@ -3,6 +3,16 @@ import {
remapImportedJumpHosts,
} from "./host-import-order.js";
import { sshOptionsForWrite } from "../../hosts/ssh-options.js";
import {
applyDefaultsAfterHostWrite,
applyHostDefaultsToWrite,
} from "../../hosts/defaults/index.js";
import {
changeHostOverrides,
type OverrideChange,
} from "../../hosts/defaults/overrides.js";
import { recompute } from "../../hosts/defaults/recompute.js";
import { splitDefaultKey } from "../../../types/host-defaults.js";
import {
keepUsableProtocolCredentials,
readProtocolAuthPayload,
@@ -16,6 +26,7 @@ import {
createCurrentCredentialRepository,
createCurrentHostRepository,
createCurrentHostResolutionRepository,
createCurrentHostDefaultsRepository,
} from "../repositories/factory.js";
import { validateParentHostId } from "./host-parent-validation.js";
import {
@@ -203,6 +214,20 @@ export function registerHostBulkRoutes(
* description: Plugin id to on or off, for each plugin that declares a host enable switch.
* additionalProperties:
* type: boolean
* resetDefaults:
* type: object
* description: Hands keys back to the host defaults. `all`, or `namespaces` ("core" or a plugin id), or `keys` as "namespace.key".
* properties:
* all:
* type: boolean
* namespaces:
* type: array
* items:
* type: string
* keys:
* type: array
* items:
* type: string
* responses:
* 200:
* description: Bulk update completed.
@@ -309,6 +334,14 @@ export function registerHostBulkRoutes(
simpleUpdates,
);
}
if (typeof updates.statusCheckEnabled === "boolean") {
await changeHostOverrides(ownedIds, {
own: [["core", "statusCheckEnabled"]],
});
}
if ("folder" in simpleUpdates || "parentHostId" in simpleUpdates) {
void recompute({ userIds: [userId] }).catch(() => {});
}
// Each plugin's own host switch, by plugin id: the field its manifest
// names in contributes.settings.host.enableKey.
@@ -323,6 +356,9 @@ export function registerHostBulkRoutes(
}
}
const reset = readDefaultsReset(updates.resetDefaults);
if (reset) await changeHostOverrides(ownedIds, reset);
return res.json({
updated: ownedIds.length,
failed: unauthorizedIds.length,
@@ -727,6 +763,12 @@ export function registerHostBulkRoutes(
const lookupKey = `${hostData.ip}:${hostData.port}:${hostData.username}`;
const existing = existingHostMap?.get(lookupKey);
await withHostDefaults(
userId,
existing?.id ?? null,
sshDataObj,
hostData as Record<string, unknown>,
);
let savedHostId: number;
if (existing) {
@@ -770,6 +812,7 @@ export function registerHostBulkRoutes(
savedHostId,
hostData as Record<string, unknown>,
);
await applyDefaultsAfterHostWrite(savedHostId);
} catch (error) {
results.failed++;
results.errors.push(`Host ${i + 1}: ${getErrorMessage(error)}`);
@@ -947,6 +990,12 @@ export function registerHostBulkRoutes(
const lookupKey = `${hostData.ip}:${hostData.port}:${hostData.username}`;
const existing = existingHostMap?.get(lookupKey);
await withHostDefaults(
userId,
existing?.id ?? null,
sshDataObj,
hostData as unknown as Record<string, unknown>,
);
if (existing) {
await hostRepository.updateEncryptedForUser(
@@ -954,10 +1003,15 @@ export function registerHostBulkRoutes(
existing.id,
sshDataObj,
);
await applyDefaultsAfterHostWrite(existing.id);
results.updated++;
} else {
sshDataObj.createdAt = new Date().toISOString();
await hostRepository.createEncryptedForUser(userId, sshDataObj);
const saved = await hostRepository.createEncryptedForUser(
userId,
sshDataObj,
);
await applyDefaultsAfterHostWrite(saved.id);
results.success++;
}
} catch (error) {
@@ -1022,3 +1076,49 @@ function listKnownAuthTypes(): Set<string> {
...listSshAuthTypeOwners().map((owner) => owner.type),
]);
}
/** Fills an imported host's inherited keys and records what it sets itself. */
async function withHostDefaults(
userId: string,
hostId: number | null,
columns: Record<string, unknown>,
body: Record<string, unknown>,
): Promise<void> {
const stored =
hostId === null
? null
: ((
await createCurrentHostDefaultsRepository().listHosts({
hostIds: [hostId],
})
)[0] ?? null);
columns.defaultOverrides = JSON.stringify(
await applyHostDefaultsToWrite({
ownerId: userId,
hostId,
columns,
body,
stored,
}),
);
}
/** A resetDefaults body as an override change, or null when there is none. */
export function readDefaultsReset(raw: unknown): OverrideChange | null {
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return null;
const source = raw as Record<string, unknown>;
const strings = (value: unknown) =>
Array.isArray(value)
? value.filter((item): item is string => typeof item === "string")
: [];
const change: OverrideChange = {
inheritAll: source.all === true,
inheritNamespaces: strings(source.namespaces),
inherit: strings(source.keys).map(splitDefaultKey),
};
return change.inheritAll ||
change.inheritNamespaces!.length > 0 ||
change.inherit!.length > 0
? change
: null;
}
@@ -0,0 +1,708 @@
import type { Request, RequestHandler, Response, Router } from "express";
import type { AuthenticatedRequest } from "../../../types/index.js";
import { databaseLogger } from "../../utils/logger.js";
import {
getRequestMeta,
logAudit,
getAuditUsername,
} from "../../utils/audit-logger.js";
import { PermissionManager } from "../../utils/permission-manager.js";
import { createCurrentHostDefaultsRepository } from "../repositories/factory.js";
import type { HostDefaultsScope } from "../repositories/host-defaults-repository.js";
import { findUsableCredential } from "../../hosts/usable-credential.js";
import {
currentCatalog,
materializeHosts,
} from "../../hosts/defaults/materialize.js";
import {
getRecomputeJob,
recompute,
startRecomputeJob,
targetForScope,
} from "../../hosts/defaults/recompute.js";
import {
levelsAbove,
readLevel,
resolveAll,
resolveForEditor,
saveLevel,
validateLevelChange,
type CheckContext,
type LevelChangeInput,
} from "../../hosts/defaults/service.js";
import { changeHostOverrides } from "../../hosts/defaults/overrides.js";
import { readDefaultsReset } from "./host-bulk-routes.js";
type HostDefaultsRoutesDeps = {
authenticateJWT: RequestHandler;
requireEditPermission: RequestHandler;
requireDataAccess: RequestHandler;
requireAdminSettings: RequestHandler;
};
function checkContext(actorId: string, ownerId: string | null): CheckContext {
const permissions = PermissionManager.getInstance();
return {
actorId,
ownerId,
canUseCredential: async (credentialId, userId) =>
(await findUsableCredential(credentialId, userId)) !== null,
canUseHost: async (hostId, userId) =>
(await permissions.canAccessHost(userId, hostId, "connect")).hasAccess,
};
}
function readChange(body: unknown): LevelChangeInput {
const source =
body && typeof body === "object" ? (body as Record<string, unknown>) : {};
return {
set:
source.set && typeof source.set === "object" && !Array.isArray(source.set)
? (source.set as Record<string, unknown>)
: {},
unset: Array.isArray(source.unset)
? source.unset.filter((key): key is string => typeof key === "string")
: [],
};
}
async function describeLevel(
scope: HostDefaultsScope,
folderOwner?: { userId: string; name: string },
) {
const { catalog } = currentCatalog();
return {
values: await readLevel(scope),
inherited: resolveAll(catalog, await levelsAbove(scope, folderOwner)),
};
}
/** The folder a user may edit defaults for, by id. */
async function ownedFolder(userId: string, folderId: number) {
const folder =
await createCurrentHostDefaultsRepository().findFolder(folderId);
return folder && folder.userId === userId ? folder : null;
}
export function registerHostDefaultsRoutes(
router: Router,
{
authenticateJWT,
requireEditPermission,
requireDataAccess,
requireAdminSettings,
}: HostDefaultsRoutesDeps,
): void {
const save = async (
req: Request,
res: Response,
scope: HostDefaultsScope,
ownerId: string | null,
folderOwnerId?: string,
) => {
const actorId = (req as AuthenticatedRequest).userId;
const change = await validateLevelChange(
scope,
readChange(req.body),
checkContext(actorId, ownerId),
);
if (Object.keys(change.errors).length > 0) {
return res
.status(400)
.json({ error: "Some defaults were rejected", errors: change.errors });
}
await saveLevel(scope, change, actorId);
const keys = new Set([...change.set.keys(), ...change.unset]);
const { ipAddress, userAgent } = getRequestMeta(req);
await logAudit({
userId: actorId,
username: await getAuditUsername(actorId),
action: "update_host_defaults",
resourceType: "host_defaults",
resourceId:
scope.level === "admin"
? "admin"
: scope.level === "user"
? String(scope.userId)
: String(scope.folderId),
resourceName: scope.level,
details: JSON.stringify({ keys: [...keys] }),
ipAddress,
userAgent,
success: true,
});
const target = targetForScope(scope, folderOwnerId);
if (scope.level === "admin") {
const job = startRecomputeJob(target, keys);
return res.json({ jobId: job.id });
}
const result = await recompute(target, keys);
return res.json({ changedHosts: result.changedHostIds.length });
};
/**
* @openapi
* /host/defaults/admin:
* get:
* summary: Get the server's host defaults
* description: The host defaults every user's hosts follow unless the user, a folder or the host sets its own. Returns the values set at this level and what each key reads without them.
* tags:
* - Host Defaults
* responses:
* 200:
* description: Values and inherited values, keyed by "namespace.key".
* 403:
* description: Missing admin.settings.manage.
*/
router.get(
"/defaults/admin",
authenticateJWT,
requireAdminSettings,
async (_req: Request, res: Response) => {
try {
res.json(await describeLevel({ level: "admin" }));
} catch (error) {
databaseLogger.error("Failed to read admin host defaults", error);
res.status(500).json({ error: "Failed to read host defaults" });
}
},
);
/**
* @openapi
* /host/defaults/admin:
* put:
* summary: Update the server's host defaults
* description: Sets and clears server-wide host defaults. Every inheriting host is updated in the background; poll the returned job.
* tags:
* - Host Defaults
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* set:
* type: object
* description: Values by "namespace.key".
* unset:
* type: array
* items:
* type: string
* responses:
* 200:
* description: Saved. Returns the recompute job id.
* 400:
* description: One or more values were rejected.
* 403:
* description: Missing admin.settings.manage.
*/
router.put(
"/defaults/admin",
authenticateJWT,
requireAdminSettings,
async (req: Request, res: Response) => {
try {
await save(req, res, { level: "admin" }, null);
} catch (error) {
databaseLogger.error("Failed to update admin host defaults", error);
res.status(500).json({ error: "Failed to update host defaults" });
}
},
);
/**
* @openapi
* /host/defaults/user:
* get:
* summary: Get your host defaults
* description: The defaults your hosts follow, over the server's. Returns the values you set and what each key reads without them.
* tags:
* - Host Defaults
* responses:
* 200:
* description: Values and inherited values, keyed by "namespace.key".
*/
router.get(
"/defaults/user",
authenticateJWT,
requireDataAccess,
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
try {
res.json(await describeLevel({ level: "user", userId }));
} catch (error) {
databaseLogger.error("Failed to read user host defaults", error);
res.status(500).json({ error: "Failed to read host defaults" });
}
},
);
/**
* @openapi
* /host/defaults/user:
* put:
* summary: Update your host defaults
* description: Sets and clears your own host defaults and updates every host of yours that follows them.
* tags:
* - Host Defaults
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* set:
* type: object
* unset:
* type: array
* items:
* type: string
* responses:
* 200:
* description: Saved. Returns how many hosts changed.
* 400:
* description: One or more values were rejected.
*/
router.put(
"/defaults/user",
authenticateJWT,
requireEditPermission,
requireDataAccess,
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
try {
await save(req, res, { level: "user", userId }, userId);
} catch (error) {
databaseLogger.error("Failed to update user host defaults", error);
res.status(500).json({ error: "Failed to update host defaults" });
}
},
);
/**
* @openapi
* /host/defaults/folders/{folderId}:
* get:
* summary: Get a folder's host defaults
* description: The defaults hosts in this folder and its subfolders follow, over yours and the server's.
* tags:
* - Host Defaults
* parameters:
* - in: path
* name: folderId
* required: true
* schema:
* type: integer
* responses:
* 200:
* description: Values and inherited values, keyed by "namespace.key".
* 404:
* description: No such folder of yours.
*/
router.get(
"/defaults/folders/:folderId",
authenticateJWT,
requireDataAccess,
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
const folder = await ownedFolder(userId, Number(req.params.folderId));
if (!folder) return res.status(404).json({ error: "Folder not found" });
try {
res.json(
await describeLevel(
{ level: "folder", folderId: folder.id, userId },
folder,
),
);
} catch (error) {
databaseLogger.error("Failed to read folder host defaults", error);
res.status(500).json({ error: "Failed to read host defaults" });
}
},
);
/**
* @openapi
* /host/defaults/folders/{folderId}:
* put:
* summary: Update a folder's host defaults
* description: Sets and clears a folder's host defaults and updates every host in it (and its subfolders) that follows them.
* tags:
* - Host Defaults
* parameters:
* - in: path
* name: folderId
* required: true
* schema:
* type: integer
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* responses:
* 200:
* description: Saved. Returns how many hosts changed.
* 400:
* description: One or more values were rejected.
* 404:
* description: No such folder of yours.
*/
router.put(
"/defaults/folders/:folderId",
authenticateJWT,
requireEditPermission,
requireDataAccess,
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
const folder = await ownedFolder(userId, Number(req.params.folderId));
if (!folder) return res.status(404).json({ error: "Folder not found" });
try {
await save(
req,
res,
{ level: "folder", folderId: folder.id, userId },
userId,
userId,
);
} catch (error) {
databaseLogger.error("Failed to update folder host defaults", error);
res.status(500).json({ error: "Failed to update host defaults" });
}
},
);
/**
* @openapi
* /host/defaults/folders:
* post:
* summary: Get or create a folder's defaults id
* description: A folder can exist only as a path on its hosts. This returns its id, creating the folder row when needed, so its defaults can be edited.
* tags:
* - Host Defaults
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* name:
* type: string
* responses:
* 200:
* description: The folder's id.
* 400:
* description: Missing folder name.
*/
router.post(
"/defaults/folders",
authenticateJWT,
requireEditPermission,
requireDataAccess,
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
const name = typeof req.body?.name === "string" ? req.body.name : "";
if (!name.trim()) {
return res.status(400).json({ error: "Folder name is required" });
}
try {
const id = await createCurrentHostDefaultsRepository().ensureFolder(
userId,
name,
);
res.json({ id });
} catch (error) {
databaseLogger.error("Failed to find folder for host defaults", error);
res.status(500).json({ error: "Failed to find the folder" });
}
},
);
/**
* @openapi
* /host/defaults/preview:
* post:
* summary: Preview a host defaults change
* description: How many hosts would change if a level were saved with these values. Nothing is written.
* tags:
* - Host Defaults
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* level:
* type: string
* enum: [admin, user, folder]
* folderId:
* type: integer
* set:
* type: object
* unset:
* type: array
* items:
* type: string
* responses:
* 200:
* description: The number of hosts that would change.
* 403:
* description: Not allowed to change that level.
*/
router.post(
"/defaults/preview",
authenticateJWT,
requireDataAccess,
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
const level = req.body?.level;
try {
let target;
let overlayUserId: string | undefined;
let folderId: number | undefined;
if (level === "admin") {
const permissions = PermissionManager.getInstance();
if (
!(await permissions.hasPermission(userId, "admin.settings.manage"))
) {
return res.status(403).json({ error: "Access denied" });
}
target = { all: true };
} else if (level === "user") {
target = { userIds: [userId] };
overlayUserId = userId;
} else if (level === "folder") {
const folder = await ownedFolder(userId, Number(req.body?.folderId));
if (!folder)
return res.status(404).json({ error: "Folder not found" });
target = { userIds: [userId] };
folderId = folder.id;
} else {
return res.status(400).json({ error: "Unknown level" });
}
const change = readChange(req.body);
const { catalog } = currentCatalog();
const set = new Map<string, unknown>();
for (const [key, value] of Object.entries(change.set ?? {})) {
const info = catalog.get(key);
if (info) set.set(key, info.normalize(value));
}
const result = await materializeHosts(target, {
dryRun: true,
overlay: {
level,
userId: overlayUserId,
folderId,
set,
unset: new Set(change.unset ?? []),
},
});
res.json({ changedHosts: result.changedHostIds.length });
} catch (error) {
databaseLogger.error("Failed to preview host defaults", error);
res.status(500).json({ error: "Failed to preview host defaults" });
}
},
);
/**
* @openapi
* /host/defaults/resolve:
* get:
* summary: Resolve host defaults for a host
* description: What every key resolves to for a host, or for a new host in a folder or under a parent host, and where each value comes from.
* tags:
* - Host Defaults
* parameters:
* - in: query
* name: hostId
* schema:
* type: integer
* - in: query
* name: folder
* schema:
* type: string
* - in: query
* name: parentHostId
* schema:
* type: integer
* responses:
* 200:
* description: Resolved values with their source, keyed by "namespace.key".
* 404:
* description: No such host you can edit.
*/
router.get(
"/defaults/resolve",
authenticateJWT,
requireDataAccess,
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
try {
let ownerId = userId;
const hostId =
req.query.hostId !== undefined ? Number(req.query.hostId) : null;
if (hostId !== null) {
if (!Number.isInteger(hostId)) {
return res.status(400).json({ error: "Invalid host ID" });
}
const access = await PermissionManager.getInstance().canAccessHost(
userId,
hostId,
"edit",
);
if (!access.hasAccess) {
return res.status(404).json({ error: "Host not found" });
}
const [row] = await createCurrentHostDefaultsRepository().listHosts({
hostIds: [hostId],
});
if (!row) return res.status(404).json({ error: "Host not found" });
ownerId = row.userId;
}
const folder =
typeof req.query.folder === "string" ? req.query.folder : undefined;
const parentHostId =
req.query.parentHostId !== undefined && req.query.parentHostId !== ""
? Number(req.query.parentHostId)
: undefined;
res.json({
values: await resolveForEditor({
ownerId,
hostId,
folder: folder === undefined ? undefined : folder || null,
parentHostId:
parentHostId === undefined || !Number.isInteger(parentHostId)
? folder !== undefined
? null
: undefined
: parentHostId,
}),
});
} catch (error) {
databaseLogger.error("Failed to resolve host defaults", error);
res.status(500).json({ error: "Failed to resolve host defaults" });
}
},
);
/**
* @openapi
* /host/defaults/jobs/{id}:
* get:
* summary: Host defaults job status
* description: Progress of a server-wide host defaults update.
* tags:
* - Host Defaults
* parameters:
* - in: path
* name: id
* required: true
* schema:
* type: string
* responses:
* 200:
* description: The job's status and how many hosts changed.
* 404:
* description: No such job.
*/
router.get(
"/defaults/jobs/:id",
authenticateJWT,
requireAdminSettings,
(req: Request, res: Response) => {
const job = getRecomputeJob(String(req.params.id));
if (!job) return res.status(404).json({ error: "Job not found" });
res.json(job);
},
);
/**
* @openapi
* /host/db/host/{id}/reset-defaults:
* post:
* summary: Reset a host to its defaults
* description: Hands keys the host set itself back to its host defaults. `all`, or `namespaces` ("core" or a plugin id), or `keys` as "namespace.key".
* tags:
* - Host Defaults
* parameters:
* - in: path
* name: id
* required: true
* schema:
* type: integer
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* all:
* type: boolean
* namespaces:
* type: array
* items:
* type: string
* keys:
* type: array
* items:
* type: string
* responses:
* 200:
* description: Reset.
* 400:
* description: Nothing to reset.
* 403:
* description: No edit access to the host.
*/
router.post(
"/db/host/:id/reset-defaults",
authenticateJWT,
requireEditPermission,
requireDataAccess,
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
const hostId = Number(req.params.id);
if (!Number.isInteger(hostId)) {
return res.status(400).json({ error: "Invalid host ID" });
}
const reset = readDefaultsReset(req.body);
if (!reset) return res.status(400).json({ error: "Nothing to reset" });
try {
const access = await PermissionManager.getInstance().canAccessHost(
userId,
hostId,
"edit",
);
if (!access.hasAccess) {
return res.status(403).json({ error: "Access denied" });
}
if (!access.isOwner) {
// A shared editor never takes over the owner's SSH login.
reset.inherit = (reset.inherit ?? []).filter(
([namespace, key]) => !(namespace === "core" && key === "auth"),
);
if (reset.inheritAll || reset.inheritNamespaces?.includes("core")) {
return res.status(403).json({
error: "Only the host owner can reset the host's SSH login",
});
}
}
await changeHostOverrides([hostId], reset);
res.json({ message: "Host reset to its defaults" });
} catch (error) {
databaseLogger.error("Failed to reset host defaults", error);
res.status(500).json({ error: "Failed to reset host defaults" });
}
},
);
}
@@ -11,6 +11,8 @@ import {
createCurrentSshCredentialUsageRepository,
} from "../repositories/factory.js";
import { isNonEmptyString } from "./host-normalizers.js";
import { recompute } from "../../hosts/defaults/recompute.js";
import { setFolderCredentialDefault } from "../../hosts/defaults/service.js";
type HostFolderRoutesDeps = {
authenticateJWT: RequestHandler;
@@ -92,6 +94,8 @@ export function registerHostFolderRoutes(
oldName,
newName,
);
// Subfolders change parents with a rename, so their chains do too.
void recompute({ userIds: [userId] }).catch(() => {});
res.json({
message: "Folder renamed successfully",
@@ -243,6 +247,14 @@ export function registerHostFolderRoutes(
localOnly,
);
}
if (normalizedCredentialId !== undefined) {
await setFolderCredentialDefault(
userId,
folder.id,
normalizedCredentialId,
);
void recompute({ userIds: [userId] }).catch(() => {});
}
if (!created) {
databaseLogger.info("Updating SSH folder", {
@@ -1,3 +1,4 @@
import { parseDefaultOverrides } from "../../../types/host-defaults.js";
import {
parseSshOptions,
type HostSshOptions,
@@ -481,5 +482,6 @@ export function transformHostResponse(
portKnockSequence: host.portKnockSequence
? JSON.parse(host.portKnockSequence as string)
: [],
defaultOverrides: parseDefaultOverrides(host.defaultOverrides),
};
}
@@ -224,14 +224,23 @@ export function attachHostPluginSettings(
}
}
/** The single-host convenience wrapper. */
/**
* The single-host convenience wrapper. With a viewer, a shared host's
* personal fields come from that viewer's own defaults.
*/
export async function withHostPluginSettings(
host: Record<string, unknown>,
viewerId?: string,
): Promise<Record<string, unknown>> {
const hostId = Number(host.id);
if (!Number.isInteger(hostId)) return host;
const settings = await loadHostPluginSettings([hostId]);
if (viewerId) {
const { applyPersonalHostValues } =
await import("../../hosts/defaults/personal.js");
await applyPersonalHostValues(settings, viewerId).catch(() => {});
}
const values = settings.get(hostId);
if (!values) return host;
const result: Record<string, unknown> = {
@@ -391,5 +400,8 @@ export async function setHostPluginEnabled(
await setSetting(manifest, "host", hostId, enableKey, enabled);
await touchHost(hostId);
}
const { changeHostOverrides } =
await import("../../hosts/defaults/overrides.js");
await changeHostOverrides(hostIds, { own: [[pluginId, enableKey]] });
return true;
}
+60 -7
View File
@@ -26,6 +26,7 @@ import {
createCurrentHostRepository,
createCurrentUserRepository,
createCurrentSharedHostAuthOverrideRepository,
createCurrentHostDefaultsRepository,
} from "../repositories/factory.js";
import {
applyHostKeyTypeUpdate,
@@ -51,6 +52,7 @@ import { validateParentHostId } from "./host-parent-validation.js";
import { registerHostFolderRoutes } from "./host-folder-routes.js";
import { registerHostNetworkRoutes } from "./host-network-routes.js";
import { registerHostBulkRoutes } from "./host-bulk-routes.js";
import { registerHostDefaultsRoutes } from "./host-defaults-routes.js";
import { registerHostStatusRoutes } from "./host-status-routes.js";
import {
applyHostEnrollmentDefaults,
@@ -71,6 +73,11 @@ import {
} from "../../utils/shared-host-auth-resolver.js";
import { rejectSharedCopyWrites } from "../../sync/shared-copy-guard.js";
import { sshOptionsForWrite } from "../../hosts/ssh-options.js";
import {
applyDefaultsAfterHostWrite,
applyHostDefaultsToWrite,
} from "../../hosts/defaults/index.js";
import { applyPersonalHostValues } from "../../hosts/defaults/personal.js";
import {
applyProtocolAuthPlan,
attachProtocolAuth,
@@ -385,6 +392,14 @@ router.post(
}
try {
sshDataObj.defaultOverrides = JSON.stringify(
await applyHostDefaultsToWrite({
ownerId: userId,
hostId: null,
columns: sshDataObj,
body: hostData,
}),
);
const result = await createCurrentHostRepository().createEncryptedForUser(
userId,
sshDataObj,
@@ -402,6 +417,7 @@ router.post(
}
const createdHost = result;
await applyDefaultsAfterHostWrite(createdHost.id as number);
if (protocolAuthPatch) {
await writeProtocolAuth(
userId,
@@ -1072,11 +1088,37 @@ router.put(
return res.status(400).json({ error: terminalFieldsError });
}
const storedRow = (
await createCurrentHostDefaultsRepository().listHosts({
hostIds: [Number(hostId)],
})
)[0];
sshDataObj.defaultOverrides = JSON.stringify(
await applyHostDefaultsToWrite({
ownerId,
hostId: Number(hostId),
columns: sshDataObj,
body: hostData,
stored: storedRow,
lockedKeys: accessInfo.isOwner ? [] : ["auth"],
}),
);
await createCurrentHostRepository().updateEncryptedForUser(
ownerId,
Number(hostId),
sshDataObj,
);
await applyDefaultsAfterHostWrite(Number(hostId), {
moved:
!!storedRow &&
((sshDataObj.folder !== undefined &&
(sshDataObj.folder ?? null) !== (storedRow.folder ?? null)) ||
(sshDataObj.parentHostId !== undefined &&
(sshDataObj.parentHostId ?? null) !==
(storedRow.parentHostId ?? null))),
ownerId,
});
if (protocolAuthPlan) {
await applyProtocolAuthPlan(ownerId, Number(hostId), protocolAuthPlan);
}
@@ -1296,14 +1338,15 @@ router.get(
// After sanitizing: the connect-level projection reduces a shared host
// to an allowlist, which would drop this again. One query for the list.
attachHostPluginSettings(
sanitized,
await loadHostPluginSettings(
sanitized
.map((host) => Number(host.id))
.filter((id) => Number.isInteger(id)),
),
const pluginSettingsByHost = await loadHostPluginSettings(
sanitized
.map((host) => Number(host.id))
.filter((id) => Number.isInteger(id)),
);
await applyPersonalHostValues(pluginSettingsByHost, userId).catch(
() => {},
);
attachHostPluginSettings(sanitized, pluginSettingsByHost);
res.json(sanitized);
} catch (err) {
@@ -1432,6 +1475,7 @@ router.get(
resolvedSharedResult,
accessInfo.permissionLevel,
),
userId,
),
);
} catch (err) {
@@ -2301,6 +2345,15 @@ registerHostNetworkRoutes(router, {
requireDataAccess,
});
registerHostDefaultsRoutes(router, {
authenticateJWT,
requireEditPermission: permissionManager.requirePermission("hosts.edit"),
requireDataAccess,
requireAdminSettings: permissionManager.requirePermission(
"admin.settings.manage",
),
});
export default router;
/** Seconds between status checks, or null to follow the global setting. */
+18 -38
View File
@@ -14,7 +14,6 @@ import {
} from "../repositories/factory.js";
import { getPluginRuntime } from "../../plugins/index.js";
import { describePluginFrontend } from "../../plugins/assets.js";
import { getSetting } from "../../plugins/settings.js";
import { invalidatePluginPermissionCache } from "../../plugins/permissions.js";
import { getPluginPublicHttpRoutes } from "../../plugins/http.js";
import { getPluginPublicWsRoutes } from "../../plugins/ws.js";
@@ -254,7 +253,7 @@ router.get("/", authenticateJWT, async (req: Request, res: Response) => {
dependencies?: Record<string, string>;
optionalDependencies?: Record<string, string>;
};
contributes = await withHostFieldDefaults(manifest as PluginManifest);
contributes = manifest?.contributes ?? null;
capabilities = Array.isArray(manifest?.capabilities)
? (manifest.capabilities as string[])
: [];
@@ -1189,7 +1188,7 @@ router.get(
* /plugins/{id}/settings/host/{hostId}:
* put:
* summary: Update a plugin's settings for one host
* description: Writes host-scope values. Requires edit access to the host, so a user who may only connect to a shared host cannot change how a plugin treats it.
* description: Writes host-scope values. Requires edit access to the host, so a user who may only connect to a shared host cannot change how a plugin treats it. A value written here becomes the host's own rather than following the host defaults; list keys in `_inherit` to hand them back to the defaults.
* tags:
* - Plugins
* parameters:
@@ -1253,12 +1252,19 @@ router.put(
return;
}
const { _inherit: inheritRaw, ...written } = req.body as Record<
string,
unknown
>;
const inherit = Array.isArray(inheritRaw)
? inheritRaw.filter((key): key is string => typeof key === "string")
: [];
const errors = await applySettings(
userId,
manifest,
"host",
String(hostId),
req.body,
written,
{ isHostOwner: access.isOwner === true },
);
if (Object.keys(errors).length > 0) {
@@ -1267,6 +1273,14 @@ router.put(
}
const { touchHost } = await import("./host-plugin-settings.js");
await touchHost(hostId);
// A value set here is the host's own; a key in _inherit follows the
// host defaults again.
const { changeHostOverrides } =
await import("../../hosts/defaults/overrides.js");
await changeHostOverrides([hostId], {
own: Object.keys(written).map((key) => [pluginId, key]),
inherit: inherit.map((key) => [pluginId, key]),
});
const values = await getAllSettings(manifest, "host", hostId, {
redactSecrets: true,
@@ -1284,37 +1298,3 @@ router.put(
);
export default router;
/**
* A host field with defaultFrom starts new hosts at an admin setting's value,
* so the host editor gets that value as the field's default.
*/
async function withHostFieldDefaults(
manifest: PluginManifest,
): Promise<unknown> {
const contributes = manifest?.contributes;
const host = contributes?.settings?.host;
if (!host?.fields.some((field) => field.defaultFrom)) {
return contributes ?? null;
}
const fields: PluginSettingsField[] = await Promise.all(
host.fields.map(async (field) => {
if (!field.defaultFrom) return field;
try {
const value = await getSetting(
manifest,
"admin",
null,
field.defaultFrom,
);
return value === undefined ? field : { ...field, default: value };
} catch {
return field;
}
}),
);
return {
...contributes,
settings: { ...contributes.settings, host: { ...host, fields } },
};
}
@@ -22,43 +22,6 @@ import {
} from "../repositories/factory.js";
import type { UserRecord } from "../repositories/user-repository.js";
export type HostDefaults = {
useSocks5?: boolean;
socks5Host?: string;
socks5Port?: number;
socks5Username?: string;
socks5Password?: string;
credentialId?: number | null;
statusCheckEnabled?: boolean;
};
/**
* Keys host_defaults held before the terminal's new-host defaults became the
* ssh-terminal plugin's admin settings. Kept in the stored row for the boot
* copy and a downgrade, never served or overwritten here.
*/
const LEGACY_TERMINAL_HOST_DEFAULTS = [
"fontSize",
"fontFamily",
"theme",
"cursorStyle",
"cursorBlink",
"autoTmux",
"enableCommandHistory",
];
function parseHostDefaults(value: string | null): Record<string, unknown> {
if (!value) return {};
try {
const parsed = JSON.parse(value);
return parsed && typeof parsed === "object" && !Array.isArray(parsed)
? parsed
: {};
} catch {
return {};
}
}
async function getAdminActor(
userId: string | undefined,
): Promise<UserRecord | null> {
@@ -559,95 +522,4 @@ export function registerUserSettingsRoutes(
"update_notification_private_endpoints",
"notification endpoint",
);
/**
* @openapi
* /users/host-defaults:
* get:
* summary: Get host creation defaults
* description: Returns the global default settings applied when creating a new host.
* tags:
* - Users
* responses:
* 200:
* description: Host defaults object.
* 500:
* description: Failed to get host defaults.
*/
router.get("/host-defaults", authenticateJWT, async (_req, res) => {
try {
const stored = parseHostDefaults(
await createCurrentSettingsRepository().get("host_defaults"),
);
for (const key of LEGACY_TERMINAL_HOST_DEFAULTS) delete stored[key];
res.json(stored as HostDefaults);
} catch (err) {
authLogger.error("Failed to get host defaults", err);
res.status(500).json({ error: "Failed to get host defaults" });
}
});
/**
* @openapi
* /users/host-defaults:
* patch:
* summary: Update host creation defaults (admin only)
* description: Sets global default settings applied when a new host is created.
* tags:
* - Users
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* responses:
* 200:
* description: Host defaults updated.
* 403:
* description: Not authorized.
* 500:
* description: Failed to update host defaults.
*/
router.patch("/host-defaults", authenticateJWT, async (req, res) => {
const userId = (req as AuthenticatedRequest).userId;
try {
const actor = await getAdminActor(userId);
if (!actor) {
return res.status(403).json({ error: "Not authorized" });
}
const defaults: HostDefaults = { ...(req.body ?? {}) };
for (const key of LEGACY_TERMINAL_HOST_DEFAULTS) {
delete (defaults as Record<string, unknown>)[key];
}
const settings = createCurrentSettingsRepository();
const stored = parseHostDefaults(await settings.get("host_defaults"));
const legacy = Object.fromEntries(
LEGACY_TERMINAL_HOST_DEFAULTS.filter((key) => key in stored).map(
(key) => [key, stored[key]],
),
);
await settings.set(
"host_defaults",
JSON.stringify({ ...legacy, ...defaults }),
);
const { ipAddress, userAgent } = getRequestMeta(req);
await logAudit({
userId,
username: actor.username ?? userId,
action: "update_host_defaults",
resourceType: "setting",
details: JSON.stringify(defaults),
ipAddress,
userAgent,
success: true,
});
res.json(defaults);
} catch (err) {
authLogger.error("Failed to update host defaults", err);
res.status(500).json({ error: "Failed to update host defaults" });
}
});
}