mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-29 06:35: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
@@ -1,5 +1,14 @@
|
||||
import { getErrorMessage } from "../utils/error-message.js";
|
||||
import { sshOptionsForWrite } from "../hosts/ssh-options.js";
|
||||
import {
|
||||
applyDefaultsAfterHostWrite,
|
||||
applyHostDefaultsToWrite,
|
||||
} from "../hosts/defaults/index.js";
|
||||
import { recompute } from "../hosts/defaults/recompute.js";
|
||||
import {
|
||||
importHostDefaults,
|
||||
writeHostDefaultsToExport,
|
||||
} from "../hosts/defaults/user-export.js";
|
||||
import express from "express";
|
||||
import http from "http";
|
||||
import https from "https";
|
||||
@@ -951,6 +960,7 @@ app.post("/database/export", authenticateJWT, async (req, res) => {
|
||||
}
|
||||
|
||||
writeSettingsToExportDatabase(exportDb, await getExportableSettings());
|
||||
await writeHostDefaultsToExport(exportDb, userId);
|
||||
} finally {
|
||||
exportDb.close();
|
||||
}
|
||||
@@ -1197,6 +1207,15 @@ app.post(
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
(hostData as Record<string, unknown>).defaultOverrides =
|
||||
JSON.stringify(
|
||||
await applyHostDefaultsToWrite({
|
||||
ownerId: userId,
|
||||
hostId: null,
|
||||
columns: hostData as Record<string, unknown>,
|
||||
body: hostData as Record<string, unknown>,
|
||||
}),
|
||||
);
|
||||
const created = await hostRepository.createEncryptedForUser(
|
||||
userId,
|
||||
hostData,
|
||||
@@ -1205,6 +1224,7 @@ app.post(
|
||||
Number(created.id),
|
||||
importedHostPluginSettings(importDb, host),
|
||||
);
|
||||
await applyDefaultsAfterHostWrite(Number(created.id));
|
||||
await importHostProtocolLogins(
|
||||
importDb,
|
||||
host,
|
||||
@@ -1285,6 +1305,12 @@ app.post(
|
||||
result.summary.skippedItems += pluginRows.skipped;
|
||||
result.summary.errors.push(...pluginRows.errors);
|
||||
|
||||
const hostDefaults = await importHostDefaults(importDb, userId);
|
||||
result.summary.skippedItems += hostDefaults.skipped;
|
||||
if (hostDefaults.imported > 0) {
|
||||
await recompute({ userIds: [userId] });
|
||||
}
|
||||
|
||||
const targetUser = await userRepository.findById(userId);
|
||||
if (targetUser?.isAdmin) {
|
||||
try {
|
||||
|
||||
@@ -1306,6 +1306,34 @@ const migrateSchema = () => {
|
||||
});
|
||||
}
|
||||
|
||||
addColumnIfNotExists("ssh_data", "default_overrides", "TEXT");
|
||||
try {
|
||||
sqlite.exec(`
|
||||
CREATE TABLE IF NOT EXISTS host_defaults (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
level TEXT NOT NULL,
|
||||
scope_key TEXT NOT NULL,
|
||||
user_id TEXT,
|
||||
folder_id INTEGER,
|
||||
namespace TEXT NOT NULL,
|
||||
key TEXT NOT NULL,
|
||||
value TEXT,
|
||||
updated_by TEXT,
|
||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (folder_id) REFERENCES ssh_folders (id) ON DELETE CASCADE
|
||||
);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_host_defaults_scope_key ON host_defaults (scope_key, namespace, key);
|
||||
CREATE INDEX IF NOT EXISTS idx_host_defaults_user ON host_defaults (user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_host_defaults_folder ON host_defaults (folder_id);
|
||||
`);
|
||||
} catch (createError) {
|
||||
databaseLogger.warn("Failed to create host_defaults table", {
|
||||
operation: "schema_migration",
|
||||
error: createError,
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
if (getRawSettingValue("rbac_permission_levels_v2") === null) {
|
||||
sqlite.exec(
|
||||
|
||||
@@ -237,6 +237,11 @@ export const hosts = mysqlTable(
|
||||
// Desktop only: set on the read-only copy of a host someone shared with
|
||||
// the linked account. JSON with the share's owner and permission level.
|
||||
sharedSource: text("shared_source"),
|
||||
// Which host default keys this host sets itself, per namespace, as JSON:
|
||||
// {"core":["sshPort"],"<pluginId>":["key"]}. Null, or a missing
|
||||
// namespace, means not classified yet. Every other key follows the
|
||||
// defaults and its column holds the resolved value.
|
||||
defaultOverrides: text("default_overrides"),
|
||||
|
||||
createdAt: text("created_at")
|
||||
.notNull()
|
||||
@@ -390,6 +395,41 @@ export const sshFolders = mysqlTable(
|
||||
(table) => [index("idx_ssh_folders_user_id").on(table.userId)],
|
||||
);
|
||||
|
||||
/**
|
||||
* Host defaults at one level. A host follows the deepest level that sets a
|
||||
* key: its folders (deepest first), then its owner, then the server, then the
|
||||
* built-in value. `scope_key` is "admin", "u:<userId>" or "f:<folderId>", so
|
||||
* the unique index holds for the admin level too.
|
||||
*/
|
||||
export const hostDefaults = mysqlTable(
|
||||
"host_defaults",
|
||||
{
|
||||
id: int("id").autoincrement().primaryKey(),
|
||||
level: mysqlEnum("level", ["admin", "user", "folder"]).notNull(),
|
||||
scopeKey: varchar("scope_key", { length: 255 }).notNull(),
|
||||
userId: varchar("user_id", { length: 255 }).references(() => users.id, { onDelete: "cascade" }),
|
||||
folderId: int("folder_id").references(() => sshFolders.id, {
|
||||
onDelete: "cascade",
|
||||
}),
|
||||
namespace: varchar("namespace", { length: 255 }).notNull(),
|
||||
key: varchar("key", { length: 255 }).notNull(),
|
||||
value: text("value"),
|
||||
updatedBy: text("updated_by"),
|
||||
updatedAt: text("updated_at")
|
||||
.notNull()
|
||||
.default(sql`(CURRENT_TIMESTAMP)`),
|
||||
},
|
||||
(table) => [
|
||||
uniqueIndex("idx_host_defaults_scope_key").on(
|
||||
table.scopeKey,
|
||||
table.namespace,
|
||||
table.key,
|
||||
),
|
||||
index("idx_host_defaults_user").on(table.userId),
|
||||
index("idx_host_defaults_folder").on(table.folderId),
|
||||
],
|
||||
);
|
||||
|
||||
export const recentActivity = mysqlTable(
|
||||
"recent_activity",
|
||||
{
|
||||
|
||||
@@ -237,6 +237,11 @@ export const hosts = pgTable(
|
||||
// Desktop only: set on the read-only copy of a host someone shared with
|
||||
// the linked account. JSON with the share's owner and permission level.
|
||||
sharedSource: text("shared_source"),
|
||||
// Which host default keys this host sets itself, per namespace, as JSON:
|
||||
// {"core":["sshPort"],"<pluginId>":["key"]}. Null, or a missing
|
||||
// namespace, means not classified yet. Every other key follows the
|
||||
// defaults and its column holds the resolved value.
|
||||
defaultOverrides: text("default_overrides"),
|
||||
|
||||
createdAt: text("created_at")
|
||||
.notNull()
|
||||
@@ -390,6 +395,41 @@ export const sshFolders = pgTable(
|
||||
(table) => [index("idx_ssh_folders_user_id").on(table.userId)],
|
||||
);
|
||||
|
||||
/**
|
||||
* Host defaults at one level. A host follows the deepest level that sets a
|
||||
* key: its folders (deepest first), then its owner, then the server, then the
|
||||
* built-in value. `scope_key` is "admin", "u:<userId>" or "f:<folderId>", so
|
||||
* the unique index holds for the admin level too.
|
||||
*/
|
||||
export const hostDefaults = pgTable(
|
||||
"host_defaults",
|
||||
{
|
||||
id: serial("id").primaryKey(),
|
||||
level: text("level", { enum: ["admin", "user", "folder"] }).notNull(),
|
||||
scopeKey: varchar("scope_key", { length: 255 }).notNull(),
|
||||
userId: varchar("user_id", { length: 255 }).references(() => users.id, { onDelete: "cascade" }),
|
||||
folderId: integer("folder_id").references(() => sshFolders.id, {
|
||||
onDelete: "cascade",
|
||||
}),
|
||||
namespace: varchar("namespace", { length: 255 }).notNull(),
|
||||
key: varchar("key", { length: 255 }).notNull(),
|
||||
value: text("value"),
|
||||
updatedBy: text("updated_by"),
|
||||
updatedAt: text("updated_at")
|
||||
.notNull()
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
},
|
||||
(table) => [
|
||||
uniqueIndex("idx_host_defaults_scope_key").on(
|
||||
table.scopeKey,
|
||||
table.namespace,
|
||||
table.key,
|
||||
),
|
||||
index("idx_host_defaults_user").on(table.userId),
|
||||
index("idx_host_defaults_folder").on(table.folderId),
|
||||
],
|
||||
);
|
||||
|
||||
export const recentActivity = pgTable(
|
||||
"recent_activity",
|
||||
{
|
||||
|
||||
@@ -229,6 +229,11 @@ export const hosts = sqliteTable(
|
||||
// Desktop only: set on the read-only copy of a host someone shared with
|
||||
// the linked account. JSON with the share's owner and permission level.
|
||||
sharedSource: text("shared_source"),
|
||||
// Which host default keys this host sets itself, per namespace, as JSON:
|
||||
// {"core":["sshPort"],"<pluginId>":["key"]}. Null, or a missing
|
||||
// namespace, means not classified yet. Every other key follows the
|
||||
// defaults and its column holds the resolved value.
|
||||
defaultOverrides: text("default_overrides"),
|
||||
|
||||
createdAt: text("created_at")
|
||||
.notNull()
|
||||
@@ -382,6 +387,41 @@ export const sshFolders = sqliteTable(
|
||||
(table) => [index("idx_ssh_folders_user_id").on(table.userId)],
|
||||
);
|
||||
|
||||
/**
|
||||
* Host defaults at one level. A host follows the deepest level that sets a
|
||||
* key: its folders (deepest first), then its owner, then the server, then the
|
||||
* built-in value. `scope_key` is "admin", "u:<userId>" or "f:<folderId>", so
|
||||
* the unique index holds for the admin level too.
|
||||
*/
|
||||
export const hostDefaults = sqliteTable(
|
||||
"host_defaults",
|
||||
{
|
||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||
level: text("level", { enum: ["admin", "user", "folder"] }).notNull(),
|
||||
scopeKey: text("scope_key").notNull(),
|
||||
userId: text("user_id").references(() => users.id, { onDelete: "cascade" }),
|
||||
folderId: integer("folder_id").references(() => sshFolders.id, {
|
||||
onDelete: "cascade",
|
||||
}),
|
||||
namespace: text("namespace").notNull(),
|
||||
key: text("key").notNull(),
|
||||
value: text("value"),
|
||||
updatedBy: text("updated_by"),
|
||||
updatedAt: text("updated_at")
|
||||
.notNull()
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
},
|
||||
(table) => [
|
||||
uniqueIndex("idx_host_defaults_scope_key").on(
|
||||
table.scopeKey,
|
||||
table.namespace,
|
||||
table.key,
|
||||
),
|
||||
index("idx_host_defaults_user").on(table.userId),
|
||||
index("idx_host_defaults_folder").on(table.folderId),
|
||||
],
|
||||
);
|
||||
|
||||
export const recentActivity = sqliteTable(
|
||||
"recent_activity",
|
||||
{
|
||||
|
||||
@@ -16,6 +16,7 @@ import { OpenTabRepository } from "./open-tab-repository.js";
|
||||
import { PluginRepository } from "./plugin-repository.js";
|
||||
import { PluginStorageRepository } from "./plugin-storage-repository.js";
|
||||
import { PluginSettingsRepository } from "./plugin-settings-repository.js";
|
||||
import { HostDefaultsRepository } from "./host-defaults-repository.js";
|
||||
import { PluginMigrationRepository } from "./plugin-migration-repository.js";
|
||||
import { PluginPermissionGrantRepository } from "./plugin-permission-grant-repository.js";
|
||||
import { UserAuthRepository } from "./user-auth-repository.js";
|
||||
@@ -224,6 +225,13 @@ export function createCurrentPluginStorageRepository(): PluginStorageRepository
|
||||
);
|
||||
}
|
||||
|
||||
export function createCurrentHostDefaultsRepository(): HostDefaultsRepository {
|
||||
return new HostDefaultsRepository(
|
||||
createCurrentRepositoryContext(),
|
||||
createCurrentRepositoryWriteHook("host_defaults_repository_write"),
|
||||
);
|
||||
}
|
||||
|
||||
export function createCurrentPluginSettingsRepository(): PluginSettingsRepository {
|
||||
return new PluginSettingsRepository(
|
||||
createCurrentRepositoryContext(),
|
||||
|
||||
@@ -0,0 +1,354 @@
|
||||
import { and, eq, inArray, type SQL } from "drizzle-orm";
|
||||
import { hostDefaults, hosts, sshFolders } from "../db/schema.js";
|
||||
import type { DatabaseContext } from "./database-context.js";
|
||||
import { rowsAffected } from "./mutation-result.js";
|
||||
import type { HostDefaultsLevel } from "../../../types/host-defaults.js";
|
||||
|
||||
export type HostDefaultsRecord = typeof hostDefaults.$inferSelect;
|
||||
export type HostRow = typeof hosts.$inferSelect;
|
||||
|
||||
export interface HostDefaultsScope {
|
||||
level: HostDefaultsLevel;
|
||||
userId?: string | null;
|
||||
folderId?: number | null;
|
||||
}
|
||||
|
||||
export interface HostDefaultsEntry {
|
||||
namespace: string;
|
||||
key: string;
|
||||
/** JSON-encoded. */
|
||||
value: string;
|
||||
}
|
||||
|
||||
export function scopeKeyFor(scope: HostDefaultsScope): string {
|
||||
if (scope.level === "admin") return "admin";
|
||||
if (scope.level === "user") return `u:${scope.userId}`;
|
||||
return `f:${scope.folderId}`;
|
||||
}
|
||||
|
||||
const CHUNK = 500;
|
||||
|
||||
function chunks<T>(items: T[], size = CHUNK): T[][] {
|
||||
const result: T[][] = [];
|
||||
for (let i = 0; i < items.length; i += size) {
|
||||
result.push(items.slice(i, i + size));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Host defaults at every level, plus the reads and writes that apply them to
|
||||
* hosts in bulk. The resolution itself lives in hosts/defaults.
|
||||
*/
|
||||
export class HostDefaultsRepository {
|
||||
constructor(
|
||||
private readonly context: DatabaseContext,
|
||||
private readonly onWrite?: () => void | Promise<void>,
|
||||
) {}
|
||||
|
||||
async listScope(scope: HostDefaultsScope): Promise<HostDefaultsRecord[]> {
|
||||
return this.context.drizzle
|
||||
.select()
|
||||
.from(hostDefaults)
|
||||
.where(eq(hostDefaults.scopeKey, scopeKeyFor(scope)));
|
||||
}
|
||||
|
||||
/** The admin rows, and every user and folder row the given users own. */
|
||||
async listForUsers(userIds: string[]): Promise<HostDefaultsRecord[]> {
|
||||
const admin = await this.context.drizzle
|
||||
.select()
|
||||
.from(hostDefaults)
|
||||
.where(eq(hostDefaults.level, "admin"));
|
||||
const own: HostDefaultsRecord[] = [];
|
||||
for (const part of chunks([...new Set(userIds)])) {
|
||||
own.push(
|
||||
...(await this.context.drizzle
|
||||
.select()
|
||||
.from(hostDefaults)
|
||||
.where(inArray(hostDefaults.userId, part))),
|
||||
);
|
||||
}
|
||||
return [...admin, ...own];
|
||||
}
|
||||
|
||||
/** The user's own and folder rows. */
|
||||
async listOwnedBy(userId: string): Promise<HostDefaultsRecord[]> {
|
||||
return this.context.drizzle
|
||||
.select()
|
||||
.from(hostDefaults)
|
||||
.where(eq(hostDefaults.userId, userId));
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets and clears keys at one level in one transaction. `set` values are
|
||||
* already JSON-encoded.
|
||||
*/
|
||||
async apply(
|
||||
scope: HostDefaultsScope,
|
||||
set: HostDefaultsEntry[],
|
||||
unset: Array<{ namespace: string; key: string }>,
|
||||
updatedBy: string | null,
|
||||
): Promise<void> {
|
||||
const scopeKey = scopeKeyFor(scope);
|
||||
const now = new Date().toISOString();
|
||||
const match = (namespace: string, key: string) =>
|
||||
and(
|
||||
eq(hostDefaults.scopeKey, scopeKey),
|
||||
eq(hostDefaults.namespace, namespace),
|
||||
eq(hostDefaults.key, key),
|
||||
)!;
|
||||
const touched = [...set, ...unset];
|
||||
if (touched.length === 0) return;
|
||||
|
||||
if (this.context.dialect === "sqlite") {
|
||||
// better-sqlite3 runs a transaction synchronously.
|
||||
const db = this.context.drizzle;
|
||||
db.transaction((tx) => {
|
||||
for (const entry of unset) {
|
||||
tx.delete(hostDefaults)
|
||||
.where(match(entry.namespace, entry.key))
|
||||
.run();
|
||||
}
|
||||
for (const entry of set) {
|
||||
const existing = tx
|
||||
.select({ id: hostDefaults.id })
|
||||
.from(hostDefaults)
|
||||
.where(match(entry.namespace, entry.key))
|
||||
.limit(1)
|
||||
.all();
|
||||
if (existing[0]) {
|
||||
tx.update(hostDefaults)
|
||||
.set({ value: entry.value, updatedBy, updatedAt: now })
|
||||
.where(eq(hostDefaults.id, existing[0].id))
|
||||
.run();
|
||||
} else {
|
||||
tx.insert(hostDefaults)
|
||||
.values({
|
||||
level: scope.level,
|
||||
scopeKey,
|
||||
userId: scope.level === "admin" ? null : (scope.userId ?? null),
|
||||
folderId:
|
||||
scope.level === "folder" ? (scope.folderId ?? null) : null,
|
||||
namespace: entry.namespace,
|
||||
key: entry.key,
|
||||
value: entry.value,
|
||||
updatedBy,
|
||||
updatedAt: now,
|
||||
})
|
||||
.run();
|
||||
}
|
||||
}
|
||||
});
|
||||
} else {
|
||||
await this.context.drizzle.transaction(async (tx) => {
|
||||
for (const entry of unset) {
|
||||
await tx
|
||||
.delete(hostDefaults)
|
||||
.where(match(entry.namespace, entry.key));
|
||||
}
|
||||
for (const entry of set) {
|
||||
const existing = await tx
|
||||
.select({ id: hostDefaults.id })
|
||||
.from(hostDefaults)
|
||||
.where(match(entry.namespace, entry.key))
|
||||
.limit(1);
|
||||
if (existing[0]) {
|
||||
await tx
|
||||
.update(hostDefaults)
|
||||
.set({ value: entry.value, updatedBy, updatedAt: now })
|
||||
.where(eq(hostDefaults.id, existing[0].id));
|
||||
} else {
|
||||
await tx.insert(hostDefaults).values({
|
||||
level: scope.level,
|
||||
scopeKey,
|
||||
userId: scope.level === "admin" ? null : (scope.userId ?? null),
|
||||
folderId:
|
||||
scope.level === "folder" ? (scope.folderId ?? null) : null,
|
||||
namespace: entry.namespace,
|
||||
key: entry.key,
|
||||
value: entry.value,
|
||||
updatedBy,
|
||||
updatedAt: now,
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
await this.onWrite?.();
|
||||
}
|
||||
|
||||
async deleteScope(scope: HostDefaultsScope): Promise<number> {
|
||||
const result = await this.context.drizzle
|
||||
.delete(hostDefaults)
|
||||
.where(eq(hostDefaults.scopeKey, scopeKeyFor(scope)));
|
||||
const affected = rowsAffected(result);
|
||||
if (affected > 0) await this.onWrite?.();
|
||||
return affected;
|
||||
}
|
||||
|
||||
/** Folder ids and paths for a set of users. */
|
||||
async listFolders(
|
||||
userIds: string[],
|
||||
): Promise<Array<{ id: number; userId: string; name: string }>> {
|
||||
const rows: Array<{ id: number; userId: string; name: string }> = [];
|
||||
for (const part of chunks([...new Set(userIds)])) {
|
||||
rows.push(
|
||||
...(await this.context.drizzle
|
||||
.select({
|
||||
id: sshFolders.id,
|
||||
userId: sshFolders.userId,
|
||||
name: sshFolders.name,
|
||||
})
|
||||
.from(sshFolders)
|
||||
.where(inArray(sshFolders.userId, part))),
|
||||
);
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
async findFolder(
|
||||
folderId: number,
|
||||
): Promise<{ id: number; userId: string; name: string } | null> {
|
||||
const [row] = await this.context.drizzle
|
||||
.select({
|
||||
id: sshFolders.id,
|
||||
userId: sshFolders.userId,
|
||||
name: sshFolders.name,
|
||||
})
|
||||
.from(sshFolders)
|
||||
.where(eq(sshFolders.id, folderId))
|
||||
.limit(1);
|
||||
return row ?? null;
|
||||
}
|
||||
|
||||
/** The folder row for a path, created when the folder only exists on hosts. */
|
||||
async ensureFolder(userId: string, name: string): Promise<number> {
|
||||
const [existing] = await this.context.drizzle
|
||||
.select({ id: sshFolders.id })
|
||||
.from(sshFolders)
|
||||
.where(and(eq(sshFolders.userId, userId), eq(sshFolders.name, name)))
|
||||
.limit(1);
|
||||
if (existing) return existing.id;
|
||||
await this.context.drizzle.insert(sshFolders).values({ userId, name });
|
||||
const [created] = await this.context.drizzle
|
||||
.select({ id: sshFolders.id })
|
||||
.from(sshFolders)
|
||||
.where(and(eq(sshFolders.userId, userId), eq(sshFolders.name, name)))
|
||||
.limit(1);
|
||||
await this.onWrite?.();
|
||||
return created.id;
|
||||
}
|
||||
|
||||
/** Host rows by id or by owner. Secrets are read as stored, never used. */
|
||||
async listHosts(filter: {
|
||||
hostIds?: number[];
|
||||
userIds?: string[];
|
||||
all?: boolean;
|
||||
}): Promise<HostRow[]> {
|
||||
const db = this.context.drizzle;
|
||||
if (filter.all) return db.select().from(hosts);
|
||||
const rows: HostRow[] = [];
|
||||
const conditions: SQL[] = [];
|
||||
for (const part of chunks(filter.hostIds ?? [])) {
|
||||
conditions.push(inArray(hosts.id, part));
|
||||
}
|
||||
for (const part of chunks(filter.userIds ?? [])) {
|
||||
conditions.push(inArray(hosts.userId, part));
|
||||
}
|
||||
for (const condition of conditions) {
|
||||
rows.push(...(await db.select().from(hosts).where(condition)));
|
||||
}
|
||||
const seen = new Set<number>();
|
||||
return rows.filter((row) => {
|
||||
if (seen.has(row.id)) return false;
|
||||
seen.add(row.id);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
/** Every host of the given owners, for walking sub-host chains. */
|
||||
async listHostPlacement(userIds: string[]): Promise<
|
||||
Array<{
|
||||
id: number;
|
||||
userId: string;
|
||||
folder: string | null;
|
||||
parentHostId: number | null;
|
||||
}>
|
||||
> {
|
||||
const rows: Array<{
|
||||
id: number;
|
||||
userId: string;
|
||||
folder: string | null;
|
||||
parentHostId: number | null;
|
||||
}> = [];
|
||||
for (const part of chunks([...new Set(userIds)])) {
|
||||
rows.push(
|
||||
...(await this.context.drizzle
|
||||
.select({
|
||||
id: hosts.id,
|
||||
userId: hosts.userId,
|
||||
folder: hosts.folder,
|
||||
parentHostId: hosts.parentHostId,
|
||||
})
|
||||
.from(hosts)
|
||||
.where(inArray(hosts.userId, part))),
|
||||
);
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes column patches, one statement per host, in one transaction.
|
||||
* `updatedAt` is set on each, which is what sync looks for.
|
||||
*/
|
||||
async updateHosts(
|
||||
patches: Array<{ id: number; values: Partial<HostRow> }>,
|
||||
): Promise<void> {
|
||||
if (patches.length === 0) return;
|
||||
const now = new Date().toISOString();
|
||||
for (const part of chunks(patches)) {
|
||||
if (this.context.dialect === "sqlite") {
|
||||
this.context.drizzle.transaction((tx) => {
|
||||
for (const patch of part) {
|
||||
tx.update(hosts)
|
||||
.set({ ...patch.values, updatedAt: now })
|
||||
.where(eq(hosts.id, patch.id))
|
||||
.run();
|
||||
}
|
||||
});
|
||||
} else {
|
||||
await this.context.drizzle.transaction(async (tx) => {
|
||||
for (const patch of part) {
|
||||
await tx
|
||||
.update(hosts)
|
||||
.set({ ...patch.values, updatedAt: now })
|
||||
.where(eq(hosts.id, patch.id));
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
await this.onWrite?.();
|
||||
}
|
||||
|
||||
/** Rows that name a folder, credential or host a user no longer has. */
|
||||
async deleteWhere(condition: SQL): Promise<number> {
|
||||
const result = await this.context.drizzle
|
||||
.delete(hostDefaults)
|
||||
.where(condition);
|
||||
const affected = rowsAffected(result);
|
||||
if (affected > 0) await this.onWrite?.();
|
||||
return affected;
|
||||
}
|
||||
|
||||
async listByKey(
|
||||
namespace: string,
|
||||
key: string,
|
||||
): Promise<HostDefaultsRecord[]> {
|
||||
return this.context.drizzle
|
||||
.select()
|
||||
.from(hostDefaults)
|
||||
.where(
|
||||
and(eq(hostDefaults.namespace, namespace), eq(hostDefaults.key, key)),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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. */
|
||||
|
||||
@@ -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" });
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -372,6 +372,21 @@ export function createPluginHosts({ manifest, bag, audit }: Deps): PluginHosts {
|
||||
}
|
||||
};
|
||||
|
||||
/** Host settings a plugin writes are the host's own, not its defaults. */
|
||||
const markSettingsOwn = async (
|
||||
hostId: number,
|
||||
writes: Awaited<ReturnType<typeof checkSettings>>,
|
||||
) => {
|
||||
if (writes.length === 0) return;
|
||||
const { changeHostOverrides } =
|
||||
await import("../hosts/defaults/overrides.js");
|
||||
await changeHostOverrides([hostId], {
|
||||
own: writes.flatMap(({ manifest: owner, values }) =>
|
||||
Object.keys(values).map((key): [string, string] => [owner.id, key]),
|
||||
),
|
||||
});
|
||||
};
|
||||
|
||||
return {
|
||||
list: async () => {
|
||||
await requireRead();
|
||||
@@ -467,12 +482,25 @@ export function createPluginHosts({ manifest, bag, audit }: Deps): PluginHosts {
|
||||
const writes = await checkSettings(settings);
|
||||
const { createCurrentHostRepository } =
|
||||
await import("../database/repositories/factory.js");
|
||||
const { applyHostDefaultsToWrite, applyDefaultsAfterHostWrite } =
|
||||
await import("../hosts/defaults/index.js");
|
||||
const columns: Record<string, unknown> = { ...row, userId };
|
||||
columns.defaultOverrides = JSON.stringify(
|
||||
await applyHostDefaultsToWrite({
|
||||
ownerId: userId,
|
||||
hostId: null,
|
||||
columns,
|
||||
body: host as unknown as Record<string, unknown>,
|
||||
}),
|
||||
);
|
||||
const created =
|
||||
await createCurrentHostRepository().createEncryptedForUser(userId, {
|
||||
...row,
|
||||
await createCurrentHostRepository().createEncryptedForUser(
|
||||
userId,
|
||||
});
|
||||
columns,
|
||||
);
|
||||
await applyDefaultsAfterHostWrite(created.id);
|
||||
await writeSettings(created.id, writes);
|
||||
await markSettingsOwn(created.id, writes);
|
||||
await audit("hosts_create", `host ${created.id}`, { success: true });
|
||||
return (
|
||||
await toRecords([created as unknown as Record<string, unknown>])
|
||||
@@ -497,13 +525,47 @@ export function createPluginHosts({ manifest, bag, audit }: Deps): PluginHosts {
|
||||
const writes = await checkSettings(settings);
|
||||
const { createCurrentHostRepository } =
|
||||
await import("../database/repositories/factory.js");
|
||||
const { applyHostDefaultsToWrite, applyDefaultsAfterHostWrite } =
|
||||
await import("../hosts/defaults/index.js");
|
||||
const { createCurrentHostDefaultsRepository } =
|
||||
await import("../database/repositories/factory.js");
|
||||
const stored = (
|
||||
await createCurrentHostDefaultsRepository().listHosts({
|
||||
hostIds: [hostId],
|
||||
})
|
||||
)[0];
|
||||
const columns: Record<string, unknown> = { ...row };
|
||||
if (stored && stored.userId === userId) {
|
||||
columns.defaultOverrides = JSON.stringify(
|
||||
await applyHostDefaultsToWrite({
|
||||
ownerId: userId,
|
||||
hostId,
|
||||
columns,
|
||||
body: patch as unknown as Record<string, unknown>,
|
||||
stored,
|
||||
}),
|
||||
);
|
||||
}
|
||||
const updated =
|
||||
await createCurrentHostRepository().updateEncryptedForUser(
|
||||
userId,
|
||||
hostId,
|
||||
row,
|
||||
columns,
|
||||
);
|
||||
if (updated) await writeSettings(hostId, writes);
|
||||
if (updated) {
|
||||
await writeSettings(hostId, writes);
|
||||
await markSettingsOwn(hostId, writes);
|
||||
await applyDefaultsAfterHostWrite(hostId, {
|
||||
moved:
|
||||
!!stored &&
|
||||
((columns.folder !== undefined &&
|
||||
(columns.folder ?? null) !== (stored.folder ?? null)) ||
|
||||
(columns.parentHostId !== undefined &&
|
||||
(columns.parentHostId ?? null) !==
|
||||
(stored.parentHostId ?? null))),
|
||||
ownerId: userId,
|
||||
});
|
||||
}
|
||||
await audit("hosts_update", `host ${hostId}`, {
|
||||
success: updated !== null,
|
||||
});
|
||||
|
||||
@@ -816,7 +816,40 @@ export function createPluginContext(
|
||||
|
||||
getHost: (hostId, key) =>
|
||||
pluginSettings.getSetting(manifest, "host", hostId, key) as never,
|
||||
setHost: async (hostId, key, value) => {
|
||||
getHostFor: async (hostId, userId, key) => {
|
||||
const { personalHostValue } =
|
||||
await import("../hosts/defaults/personal.js");
|
||||
const personal = await personalHostValue(
|
||||
manifest,
|
||||
Number(hostId),
|
||||
userId,
|
||||
key,
|
||||
);
|
||||
return (
|
||||
personal.applies
|
||||
? personal.value
|
||||
: await pluginSettings.getSetting(manifest, "host", hostId, key)
|
||||
) as never;
|
||||
},
|
||||
getHostDefault: async (userId, key) => {
|
||||
const { userHostDefault } =
|
||||
await import("../hosts/defaults/personal.js");
|
||||
return (await userHostDefault(manifest, userId, key)) as never;
|
||||
},
|
||||
setHost: async (hostId, key, value, options) => {
|
||||
const { changeHostOverrides } =
|
||||
await import("../hosts/defaults/overrides.js");
|
||||
if (options?.inherit) {
|
||||
if (!pluginSettings.findField(manifest, "host", key)) {
|
||||
throw new Error(
|
||||
`"${key}" is not a host setting this plugin declares`,
|
||||
);
|
||||
}
|
||||
await changeHostOverrides([Number(hostId)], {
|
||||
inherit: [[pluginId, key]],
|
||||
});
|
||||
return;
|
||||
}
|
||||
const error = await pluginSettings.setSetting(
|
||||
manifest,
|
||||
"host",
|
||||
@@ -825,6 +858,7 @@ export function createPluginContext(
|
||||
value,
|
||||
);
|
||||
if (error) throw new Error(error);
|
||||
await changeHostOverrides([Number(hostId)], { own: [[pluginId, key]] });
|
||||
},
|
||||
|
||||
listHostValues: async (key) => {
|
||||
|
||||
@@ -75,7 +75,11 @@ export function onSettingsChange(
|
||||
};
|
||||
}
|
||||
|
||||
function notify(pluginId: string, key: string, value: unknown): void {
|
||||
export function notifySettingChange(
|
||||
pluginId: string,
|
||||
key: string,
|
||||
value: unknown,
|
||||
): void {
|
||||
for (const listener of listeners.get(pluginId)?.get(key) ?? []) {
|
||||
try {
|
||||
listener(value);
|
||||
@@ -92,7 +96,10 @@ function notify(pluginId: string, key: string, value: unknown): void {
|
||||
|
||||
export type SettingsValidator = (
|
||||
values: Record<string, unknown>,
|
||||
context: { hostId?: number },
|
||||
context: {
|
||||
hostId?: number;
|
||||
defaults?: { level: "admin" | "user" | "folder" };
|
||||
},
|
||||
) =>
|
||||
| Record<string, string>
|
||||
| void
|
||||
@@ -131,6 +138,7 @@ export async function validateSettingsSave(
|
||||
scope: PluginSettingsScope,
|
||||
scopeId: string | null,
|
||||
values: Record<string, unknown>,
|
||||
defaults?: { level: "admin" | "user" | "folder" },
|
||||
): Promise<Record<string, string>> {
|
||||
const errors: Record<string, string> = {};
|
||||
for (const entry of validators.get(pluginId) ?? []) {
|
||||
@@ -138,6 +146,7 @@ export async function validateSettingsSave(
|
||||
try {
|
||||
const result = await entry.run(values, {
|
||||
hostId: scope === "host" && scopeId ? Number(scopeId) : undefined,
|
||||
...(defaults ? { defaults } : {}),
|
||||
});
|
||||
Object.assign(errors, result ?? {});
|
||||
} catch (error) {
|
||||
@@ -299,7 +308,7 @@ export async function setSetting(
|
||||
shouldEncrypt,
|
||||
);
|
||||
|
||||
notify(manifest.id, key, coerced);
|
||||
notifySettingChange(manifest.id, key, coerced);
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
@@ -23,6 +23,7 @@ import { FieldCrypto } from "../utils/field-crypto.js";
|
||||
import { syncLogger } from "../utils/logger.js";
|
||||
import { getPluginRuntime } from "../plugins/index.js";
|
||||
import { singletonSyncId } from "./wire.js";
|
||||
import { parseDefaultOverrides } from "../../types/host-defaults.js";
|
||||
import {
|
||||
exportHostPluginSettings,
|
||||
importHostPluginSettings,
|
||||
@@ -31,6 +32,14 @@ import {
|
||||
exportProtocolLogins,
|
||||
importProtocolLogins,
|
||||
} from "./host-protocol-auth.js";
|
||||
import {
|
||||
coversHostDefault,
|
||||
eraseHostDefault,
|
||||
loadAdminDefaults,
|
||||
loadHostDefaults,
|
||||
writeAdminDefaults,
|
||||
writeHostDefault,
|
||||
} from "./host-defaults.js";
|
||||
|
||||
const CREDENTIAL_REFERENCE = {
|
||||
field: "credentialId",
|
||||
@@ -167,9 +176,22 @@ export function registerCoreSyncEntities(): void {
|
||||
}),
|
||||
afterWrite: async ({ id, userId, wire, resolveId }) => {
|
||||
if (id === null) return;
|
||||
await importHostPluginSettings(id, wire.pluginSettings);
|
||||
// The server owns host defaults: it keeps what the host set itself
|
||||
// and resolves the rest. A desktop takes the server's values as sent.
|
||||
const { getLink } = await import("./client/link-store.js");
|
||||
const onServer = !(await getLink().catch(() => null));
|
||||
await importHostPluginSettings(
|
||||
id,
|
||||
wire.pluginSettings,
|
||||
onServer ? parseDefaultOverrides(wire.defaultOverrides) : null,
|
||||
);
|
||||
await importProtocolLogins(id, userId, wire.protocolAuth, resolveId);
|
||||
await dropInvalidParent(userId, id);
|
||||
if (onServer) {
|
||||
const { applyDefaultsAfterHostWrite } =
|
||||
await import("../hosts/defaults/index.js");
|
||||
await applyDefaultsAfterHostWrite(id);
|
||||
}
|
||||
const { SharedHostSecretsManager } =
|
||||
await import("../utils/shared-host-secrets-manager.js");
|
||||
await SharedHostSecretsManager.getInstance().resyncHost(id);
|
||||
@@ -193,6 +215,29 @@ export function registerCoreSyncEntities(): void {
|
||||
},
|
||||
);
|
||||
|
||||
// After hosts: a folder's jump chain names hosts by syncId.
|
||||
registerEntity(
|
||||
CORE_OWNER,
|
||||
{ type: "hostDefaults", table: null, order: 60 },
|
||||
{
|
||||
load: loadHostDefaults,
|
||||
write: writeHostDefault,
|
||||
erase: eraseHostDefault,
|
||||
covers: (syncId) => coversHostDefault(syncId, activeManifest),
|
||||
},
|
||||
);
|
||||
|
||||
registerEntity(
|
||||
CORE_OWNER,
|
||||
{ type: "hostDefaultsAdmin", table: null, order: 7, singleton: true },
|
||||
{
|
||||
readOnly: true,
|
||||
load: loadAdminDefaults,
|
||||
write: writeAdminDefaults,
|
||||
erase: async () => {},
|
||||
},
|
||||
);
|
||||
|
||||
// dashboardServiceLinks (70) and homepageItems (80) belong to homepage.
|
||||
|
||||
registerEntity(CORE_OWNER, {
|
||||
|
||||
@@ -0,0 +1,335 @@
|
||||
/**
|
||||
* Host defaults over sync.
|
||||
*
|
||||
* A user's own and folder defaults travel as one row per key. A value that
|
||||
* names a row by local id (the credential in an auth default, the hosts in a
|
||||
* jump chain, a plugin's own ids) travels by syncId and is translated back on
|
||||
* the other side. The server's admin defaults reach a linked desktop as one
|
||||
* read-only snapshot, so the desktop resolves a host the way its server does.
|
||||
*/
|
||||
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import type { SyncRow } from "@termix/plugin-sdk/backend";
|
||||
import type { PluginManifest } from "@termix/plugin-sdk/manifest";
|
||||
import { hosts, sshCredentials, sshFolders } from "../database/db/schema.js";
|
||||
import {
|
||||
createCurrentHostDefaultsRepository,
|
||||
createCurrentRepositoryContext,
|
||||
createCurrentSettingsRepository,
|
||||
} from "../database/repositories/factory.js";
|
||||
import { consume } from "../plugins/registry.js";
|
||||
import {
|
||||
CORE_NAMESPACE,
|
||||
joinDefaultKey,
|
||||
splitDefaultKey,
|
||||
} from "../../types/host-defaults.js";
|
||||
import {
|
||||
decodeDefaultValue,
|
||||
encodeDefaultValue,
|
||||
REMOTE_ADMIN_DEFAULTS_KEY,
|
||||
} from "../hosts/defaults/levels.js";
|
||||
import type { PluginHostSettingsSync } from "./host-plugin-settings.js";
|
||||
import { singletonSyncId } from "./wire.js";
|
||||
|
||||
type IdMapper = (id: number) => Promise<string | null>;
|
||||
type SyncIdMapper = (syncId: string) => Promise<number | null>;
|
||||
|
||||
function db() {
|
||||
return createCurrentRepositoryContext().drizzle;
|
||||
}
|
||||
|
||||
async function credentialSyncId(id: number): Promise<string | null> {
|
||||
const [row] = await db()
|
||||
.select({ syncId: sshCredentials.syncId })
|
||||
.from(sshCredentials)
|
||||
.where(eq(sshCredentials.id, id))
|
||||
.limit(1);
|
||||
return row?.syncId ?? null;
|
||||
}
|
||||
|
||||
async function hostSyncId(id: number): Promise<string | null> {
|
||||
const [row] = await db()
|
||||
.select({ syncId: hosts.syncId })
|
||||
.from(hosts)
|
||||
.where(eq(hosts.id, id))
|
||||
.limit(1);
|
||||
return row?.syncId ?? null;
|
||||
}
|
||||
|
||||
function localCredential(userId: string): SyncIdMapper {
|
||||
return async (syncId) => {
|
||||
const [row] = await db()
|
||||
.select({ id: sshCredentials.id })
|
||||
.from(sshCredentials)
|
||||
.where(
|
||||
and(
|
||||
eq(sshCredentials.syncId, syncId),
|
||||
eq(sshCredentials.userId, userId),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
return row?.id ?? null;
|
||||
};
|
||||
}
|
||||
|
||||
function localHost(userId: string): SyncIdMapper {
|
||||
return async (syncId) => {
|
||||
const [row] = await db()
|
||||
.select({ id: hosts.id })
|
||||
.from(hosts)
|
||||
.where(and(eq(hosts.syncId, syncId), eq(hosts.userId, userId)))
|
||||
.limit(1);
|
||||
return row?.id ?? null;
|
||||
};
|
||||
}
|
||||
|
||||
function hostSettingsHook(
|
||||
pluginId: string,
|
||||
): PluginHostSettingsSync | undefined {
|
||||
return consume<PluginHostSettingsSync>(`${pluginId}.hostSettingsSync`);
|
||||
}
|
||||
|
||||
/** A value on its way out: local ids become syncIds. */
|
||||
export async function exportDefaultValue(
|
||||
namespace: string,
|
||||
key: string,
|
||||
value: unknown,
|
||||
maps: { credential: IdMapper; host: IdMapper } = {
|
||||
credential: credentialSyncId,
|
||||
host: hostSyncId,
|
||||
},
|
||||
): Promise<unknown> {
|
||||
if (namespace !== CORE_NAMESPACE) {
|
||||
const hook = hostSettingsHook(namespace);
|
||||
return hook?.exportValue ? hook.exportValue(key, value) : value;
|
||||
}
|
||||
if (key === "auth" && value && typeof value === "object") {
|
||||
const auth = value as Record<string, unknown>;
|
||||
if (typeof auth.credentialId !== "number") return value;
|
||||
const { credentialId, ...rest } = auth;
|
||||
return { ...rest, credentialSyncId: await maps.credential(credentialId) };
|
||||
}
|
||||
if (key === "jumpHosts" && Array.isArray(value)) {
|
||||
const mapped = [];
|
||||
for (const entry of value) {
|
||||
const hostId = Number((entry as { hostId?: unknown })?.hostId);
|
||||
const syncId = Number.isInteger(hostId) ? await maps.host(hostId) : null;
|
||||
if (syncId) mapped.push({ hostSyncId: syncId });
|
||||
}
|
||||
return mapped;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
/** A value on its way in: syncIds become this side's ids. */
|
||||
export async function importDefaultValue(
|
||||
namespace: string,
|
||||
key: string,
|
||||
value: unknown,
|
||||
maps: { credential: SyncIdMapper; host: SyncIdMapper },
|
||||
): Promise<unknown> {
|
||||
if (namespace !== CORE_NAMESPACE) {
|
||||
const hook = hostSettingsHook(namespace);
|
||||
return hook?.importValue ? hook.importValue(key, value) : value;
|
||||
}
|
||||
if (key === "auth" && value && typeof value === "object") {
|
||||
const auth = value as Record<string, unknown>;
|
||||
if (typeof auth.credentialSyncId !== "string") return value;
|
||||
const { credentialSyncId, ...rest } = auth;
|
||||
return { ...rest, credentialId: await maps.credential(credentialSyncId) };
|
||||
}
|
||||
if (key === "jumpHosts" && Array.isArray(value)) {
|
||||
const mapped = [];
|
||||
for (const entry of value) {
|
||||
const syncId = (entry as { hostSyncId?: unknown })?.hostSyncId;
|
||||
const hostId =
|
||||
typeof syncId === "string" ? await maps.host(syncId) : null;
|
||||
if (hostId !== null) mapped.push({ hostId });
|
||||
}
|
||||
return mapped;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
export async function loadHostDefaults(userId: string): Promise<SyncRow[]> {
|
||||
const repository = createCurrentHostDefaultsRepository();
|
||||
const rows = await repository.listOwnedBy(userId);
|
||||
const folderSyncIds = new Map<number, string>();
|
||||
const folders = await db()
|
||||
.select({
|
||||
id: sshFolders.id,
|
||||
syncId: sshFolders.syncId,
|
||||
localOnly: sshFolders.localOnly,
|
||||
})
|
||||
.from(sshFolders)
|
||||
.where(eq(sshFolders.userId, userId));
|
||||
for (const folder of folders) {
|
||||
if (folder.syncId && !folder.localOnly) {
|
||||
folderSyncIds.set(folder.id, folder.syncId);
|
||||
}
|
||||
}
|
||||
|
||||
const result: SyncRow[] = [];
|
||||
for (const row of rows) {
|
||||
const fullKey = joinDefaultKey(row.namespace, row.key);
|
||||
let syncId: string;
|
||||
let folderSyncId: string | null = null;
|
||||
if (row.level === "user") {
|
||||
syncId = `u:${fullKey}`;
|
||||
} else if (row.level === "folder" && row.folderId !== null) {
|
||||
const folder = folderSyncIds.get(row.folderId);
|
||||
if (!folder) continue;
|
||||
folderSyncId = folder;
|
||||
syncId = `f:${folder}:${fullKey}`;
|
||||
} else {
|
||||
continue;
|
||||
}
|
||||
result.push({
|
||||
syncId,
|
||||
level: row.level,
|
||||
folderSyncId,
|
||||
namespace: row.namespace,
|
||||
key: row.key,
|
||||
value: await exportDefaultValue(
|
||||
row.namespace,
|
||||
row.key,
|
||||
decodeDefaultValue(row.value),
|
||||
),
|
||||
});
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/** Splits "u:<ns.key>" or "f:<folderSyncId>:<ns.key>". */
|
||||
export function parseHostDefaultsSyncId(syncId: string): {
|
||||
level: "user" | "folder";
|
||||
folderSyncId: string | null;
|
||||
fullKey: string;
|
||||
} | null {
|
||||
if (syncId.startsWith("u:")) {
|
||||
return { level: "user", folderSyncId: null, fullKey: syncId.slice(2) };
|
||||
}
|
||||
if (syncId.startsWith("f:")) {
|
||||
const rest = syncId.slice(2);
|
||||
const colon = rest.indexOf(":");
|
||||
if (colon <= 0) return null;
|
||||
return {
|
||||
level: "folder",
|
||||
folderSyncId: rest.slice(0, colon),
|
||||
fullKey: rest.slice(colon + 1),
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function folderIdFor(
|
||||
userId: string,
|
||||
folderSyncId: string,
|
||||
): Promise<number | null> {
|
||||
const [row] = await db()
|
||||
.select({ id: sshFolders.id })
|
||||
.from(sshFolders)
|
||||
.where(
|
||||
and(eq(sshFolders.syncId, folderSyncId), eq(sshFolders.userId, userId)),
|
||||
)
|
||||
.limit(1);
|
||||
return row?.id ?? null;
|
||||
}
|
||||
|
||||
async function scopeFor(
|
||||
userId: string,
|
||||
parsed: NonNullable<ReturnType<typeof parseHostDefaultsSyncId>>,
|
||||
) {
|
||||
if (parsed.level === "user") return { level: "user" as const, userId };
|
||||
const folderId = parsed.folderSyncId
|
||||
? await folderIdFor(userId, parsed.folderSyncId)
|
||||
: null;
|
||||
if (folderId === null) return null;
|
||||
return { level: "folder" as const, userId, folderId };
|
||||
}
|
||||
|
||||
export async function writeHostDefault(
|
||||
userId: string,
|
||||
wire: SyncRow,
|
||||
): Promise<void> {
|
||||
const parsed = parseHostDefaultsSyncId(String(wire.syncId));
|
||||
if (!parsed) return;
|
||||
const scope = await scopeFor(userId, parsed);
|
||||
if (!scope) return;
|
||||
const [namespace, key] = splitDefaultKey(parsed.fullKey);
|
||||
const value = await importDefaultValue(namespace, key, wire.value, {
|
||||
credential: localCredential(userId),
|
||||
host: localHost(userId),
|
||||
});
|
||||
await createCurrentHostDefaultsRepository().apply(
|
||||
scope,
|
||||
[{ namespace, key, value: encodeDefaultValue(value) }],
|
||||
[],
|
||||
null,
|
||||
);
|
||||
await afterSyncedLevelChange(userId);
|
||||
}
|
||||
|
||||
export async function eraseHostDefault(
|
||||
userId: string,
|
||||
syncId: string,
|
||||
): Promise<void> {
|
||||
const parsed = parseHostDefaultsSyncId(syncId);
|
||||
if (!parsed) return;
|
||||
const scope = await scopeFor(userId, parsed);
|
||||
if (!scope) return;
|
||||
const [namespace, key] = splitDefaultKey(parsed.fullKey);
|
||||
await createCurrentHostDefaultsRepository().apply(
|
||||
scope,
|
||||
[],
|
||||
[{ namespace, key }],
|
||||
null,
|
||||
);
|
||||
await afterSyncedLevelChange(userId);
|
||||
}
|
||||
|
||||
/**
|
||||
* A level a desktop pushed changes what the server's hosts resolve to. On a
|
||||
* desktop the hosts arrive already resolved, so nothing runs there.
|
||||
*/
|
||||
async function afterSyncedLevelChange(userId: string): Promise<void> {
|
||||
const { getLink } = await import("./client/link-store.js");
|
||||
if (await getLink().catch(() => null)) return;
|
||||
const { recompute } = await import("../hosts/defaults/recompute.js");
|
||||
void recompute({ userIds: [userId] }).catch(() => {});
|
||||
}
|
||||
|
||||
export function coversHostDefault(
|
||||
syncId: string,
|
||||
activeManifest: (pluginId: string) => PluginManifest | undefined,
|
||||
): boolean {
|
||||
const parsed = parseHostDefaultsSyncId(syncId);
|
||||
if (!parsed) return false;
|
||||
const [namespace] = splitDefaultKey(parsed.fullKey);
|
||||
return namespace === CORE_NAMESPACE || !!activeManifest(namespace);
|
||||
}
|
||||
|
||||
export async function loadAdminDefaults(): Promise<SyncRow[]> {
|
||||
const rows = await createCurrentHostDefaultsRepository().listScope({
|
||||
level: "admin",
|
||||
});
|
||||
const values: Record<string, unknown> = {};
|
||||
for (const row of rows) {
|
||||
values[joinDefaultKey(row.namespace, row.key)] = decodeDefaultValue(
|
||||
row.value,
|
||||
);
|
||||
}
|
||||
return [{ syncId: singletonSyncId("hostDefaultsAdmin"), values }];
|
||||
}
|
||||
|
||||
export async function writeAdminDefaults(
|
||||
_userId: string,
|
||||
wire: SyncRow,
|
||||
): Promise<void> {
|
||||
const values =
|
||||
wire.values && typeof wire.values === "object" ? wire.values : {};
|
||||
await createCurrentSettingsRepository().set(
|
||||
REMOTE_ADMIN_DEFAULTS_KEY,
|
||||
JSON.stringify(values),
|
||||
);
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
setSetting,
|
||||
} from "../plugins/settings.js";
|
||||
import { databaseLogger } from "../utils/logger.js";
|
||||
import type { DefaultOverrides } from "../../types/host-defaults.js";
|
||||
import {
|
||||
hostSettingsPlugins,
|
||||
type HostPluginSettings,
|
||||
@@ -50,10 +51,16 @@ export async function exportHostPluginSettings(
|
||||
return result;
|
||||
}
|
||||
|
||||
/** Writes what a synced host carried, translated back to local ids. */
|
||||
/**
|
||||
* Writes what a synced host carried, translated back to local ids. With
|
||||
* `only`, a classified namespace takes just the keys the host sets itself;
|
||||
* the rest follow this side's defaults and are written by the materialize
|
||||
* pass that follows.
|
||||
*/
|
||||
export async function importHostPluginSettings(
|
||||
hostId: number,
|
||||
carried: unknown,
|
||||
only?: DefaultOverrides | null,
|
||||
): Promise<void> {
|
||||
if (!carried || typeof carried !== "object") return;
|
||||
const byPlugin = carried as HostPluginSettings;
|
||||
@@ -63,8 +70,10 @@ export async function importHostPluginSettings(
|
||||
const hook = consume<PluginHostSettingsSync>(
|
||||
`${manifest.id}.hostSettingsSync`,
|
||||
);
|
||||
const ownKeys = only?.[manifest.id];
|
||||
for (const field of declaredFields(manifest, "host")) {
|
||||
if (field.type === "secret" || !(field.key in own)) continue;
|
||||
if (ownKeys && !ownKeys.includes(field.key)) continue;
|
||||
try {
|
||||
const value = hook?.importValue
|
||||
? await hook.importValue(field.key, own[field.key])
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { TestSqliteDatabase } from "./test-support.js";
|
||||
import { HostDefaultsRepository } from "../../../database/repositories/host-defaults-repository.js";
|
||||
|
||||
describe("HostDefaultsRepository", () => {
|
||||
let adapter: TestSqliteDatabase | null = null;
|
||||
|
||||
afterEach(async () => {
|
||||
if (adapter) {
|
||||
await adapter.close();
|
||||
adapter = null;
|
||||
}
|
||||
});
|
||||
|
||||
async function createRepository(): Promise<HostDefaultsRepository> {
|
||||
adapter = new TestSqliteDatabase();
|
||||
const context = await adapter.connect();
|
||||
await adapter.exec(`
|
||||
INSERT INTO users (id, username, password_hash)
|
||||
VALUES ('user-1', 'alice', 'hash'), ('user-2', 'bob', 'hash');
|
||||
INSERT INTO ssh_folders (id, user_id, name)
|
||||
VALUES (1, 'user-1', 'prod'), (2, 'user-2', 'prod');
|
||||
INSERT INTO ssh_data (id, user_id, name, ip, port, username, folder, auth_type)
|
||||
VALUES
|
||||
(1, 'user-1', 'one', '10.0.0.1', 22, 'root', 'prod', 'password'),
|
||||
(2, 'user-2', 'two', '10.0.0.2', 22, 'root', null, 'password');
|
||||
`);
|
||||
return new HostDefaultsRepository(context);
|
||||
}
|
||||
|
||||
it("keeps each level apart, the server level included", async () => {
|
||||
const repository = await createRepository();
|
||||
await repository.apply(
|
||||
{ level: "admin" },
|
||||
[{ namespace: "core", key: "sshPort", value: "2022" }],
|
||||
[],
|
||||
"user-1",
|
||||
);
|
||||
await repository.apply(
|
||||
{ level: "user", userId: "user-1" },
|
||||
[{ namespace: "core", key: "sshPort", value: "2023" }],
|
||||
[],
|
||||
"user-1",
|
||||
);
|
||||
await repository.apply(
|
||||
{ level: "folder", userId: "user-1", folderId: 1 },
|
||||
[{ namespace: "term", key: "fontSize", value: "16" }],
|
||||
[],
|
||||
"user-1",
|
||||
);
|
||||
|
||||
expect(await repository.listScope({ level: "admin" })).toHaveLength(1);
|
||||
const forUser = await repository.listForUsers(["user-1"]);
|
||||
expect(forUser.map((row) => `${row.level}:${row.value}`).sort()).toEqual([
|
||||
"admin:2022",
|
||||
"folder:16",
|
||||
"user:2023",
|
||||
]);
|
||||
expect(await repository.listForUsers(["user-2"])).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("updates a key in place and clears one", async () => {
|
||||
const repository = await createRepository();
|
||||
const scope = { level: "user" as const, userId: "user-1" };
|
||||
await repository.apply(
|
||||
scope,
|
||||
[{ namespace: "core", key: "sshPort", value: "1" }],
|
||||
[],
|
||||
null,
|
||||
);
|
||||
await repository.apply(
|
||||
scope,
|
||||
[{ namespace: "core", key: "sshPort", value: "2" }],
|
||||
[],
|
||||
null,
|
||||
);
|
||||
expect((await repository.listScope(scope)).map((row) => row.value)).toEqual(
|
||||
["2"],
|
||||
);
|
||||
await repository.apply(
|
||||
scope,
|
||||
[],
|
||||
[{ namespace: "core", key: "sshPort" }],
|
||||
null,
|
||||
);
|
||||
expect(await repository.listScope(scope)).toEqual([]);
|
||||
});
|
||||
|
||||
it("finds or creates a folder row for a path", async () => {
|
||||
const repository = await createRepository();
|
||||
expect(await repository.ensureFolder("user-1", "prod")).toBe(1);
|
||||
const created = await repository.ensureFolder("user-1", "prod / web");
|
||||
expect(await repository.findFolder(created)).toMatchObject({
|
||||
userId: "user-1",
|
||||
name: "prod / web",
|
||||
});
|
||||
});
|
||||
|
||||
it("reads and writes hosts in bulk", async () => {
|
||||
const repository = await createRepository();
|
||||
expect(
|
||||
(await repository.listHosts({ userIds: ["user-1"] })).map(
|
||||
(row) => row.id,
|
||||
),
|
||||
).toEqual([1]);
|
||||
await repository.updateHosts([
|
||||
{ id: 1, values: { sshPort: 2022, defaultOverrides: '{"core":[]}' } },
|
||||
]);
|
||||
const [row] = await repository.listHosts({ hostIds: [1] });
|
||||
expect(row.sshPort).toBe(2022);
|
||||
expect(row.defaultOverrides).toBe('{"core":[]}');
|
||||
expect(await repository.listHostPlacement(["user-1"])).toEqual([
|
||||
{ id: 1, userId: "user-1", folder: "prod", parentHostId: null },
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,206 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import express, { type RequestHandler } from "express";
|
||||
import request from "supertest";
|
||||
|
||||
const state = vi.hoisted(() => ({
|
||||
isAdmin: true,
|
||||
access: { hasAccess: true, isOwner: true },
|
||||
saved: [] as unknown[],
|
||||
resets: [] as unknown[],
|
||||
audits: [] as unknown[],
|
||||
folders: new Map<number, { id: number; userId: string; name: string }>(),
|
||||
}));
|
||||
|
||||
vi.mock("../../../utils/logger.js", () => ({
|
||||
databaseLogger: { error: vi.fn(), warn: vi.fn(), info: vi.fn() },
|
||||
}));
|
||||
vi.mock("../../../utils/audit-logger.js", () => ({
|
||||
logAudit: async (entry: unknown) => {
|
||||
state.audits.push(entry);
|
||||
},
|
||||
getAuditUsername: async () => "admin",
|
||||
getRequestMeta: () => ({ ipAddress: "127.0.0.1", userAgent: "test" }),
|
||||
}));
|
||||
vi.mock("../../../utils/permission-manager.js", () => ({
|
||||
PermissionManager: {
|
||||
getInstance: () => ({
|
||||
canAccessHost: async () => state.access,
|
||||
hasPermission: async () => state.isAdmin,
|
||||
}),
|
||||
},
|
||||
}));
|
||||
vi.mock("../../../hosts/usable-credential.js", () => ({
|
||||
findUsableCredential: async () => ({}),
|
||||
}));
|
||||
vi.mock("../../../database/repositories/factory.js", () => ({
|
||||
createCurrentHostDefaultsRepository: () => ({
|
||||
findFolder: async (id: number) => state.folders.get(id) ?? null,
|
||||
ensureFolder: async () => 42,
|
||||
listHosts: async () => [{ id: 1, userId: "u1" }],
|
||||
}),
|
||||
}));
|
||||
vi.mock("../../../hosts/defaults/materialize.js", () => ({
|
||||
currentCatalog: () => ({ catalog: new Map() }),
|
||||
materializeHosts: async () => ({ changedHostIds: [1, 2] }),
|
||||
}));
|
||||
vi.mock("../../../hosts/defaults/recompute.js", () => ({
|
||||
getRecomputeJob: (id: string) =>
|
||||
id === "job-1" ? { id, status: "done", changedHosts: 3 } : undefined,
|
||||
recompute: async () => ({ changedHostIds: [5] }),
|
||||
startRecomputeJob: () => ({ id: "job-1" }),
|
||||
targetForScope: () => ({ all: true }),
|
||||
}));
|
||||
vi.mock("../../../hosts/defaults/service.js", () => ({
|
||||
levelsAbove: async () => ({ admin: new Map(), user: new Map(), folders: [] }),
|
||||
readLevel: async () => ({ "core.sshPort": 2022 }),
|
||||
resolveAll: () => ({}),
|
||||
resolveForEditor: async () => ({
|
||||
"core.sshPort": { value: 2022, source: { level: "user" } },
|
||||
}),
|
||||
saveLevel: async (scope: unknown, change: unknown) => {
|
||||
state.saved.push({ scope, change });
|
||||
},
|
||||
validateLevelChange: async (
|
||||
_scope: unknown,
|
||||
input: { set?: Record<string, unknown> },
|
||||
) =>
|
||||
input.set && "bad.key" in input.set
|
||||
? { set: new Map(), unset: new Set(), errors: { "bad.key": "no" } }
|
||||
: {
|
||||
set: new Map(Object.entries(input.set ?? {})),
|
||||
unset: new Set(),
|
||||
errors: {},
|
||||
},
|
||||
}));
|
||||
vi.mock("../../../hosts/defaults/overrides.js", () => ({
|
||||
changeHostOverrides: async (hostIds: number[], change: unknown) => {
|
||||
state.resets.push({ hostIds, change });
|
||||
},
|
||||
}));
|
||||
vi.mock("../../../database/routes/host-bulk-routes.js", async () => {
|
||||
const actual = await vi.importActual<
|
||||
typeof import("../../../database/routes/host-bulk-routes.js")
|
||||
>("../../../database/routes/host-bulk-routes.js");
|
||||
return { readDefaultsReset: actual.readDefaultsReset };
|
||||
});
|
||||
|
||||
const { registerHostDefaultsRoutes } =
|
||||
await import("../../../database/routes/host-defaults-routes.js");
|
||||
|
||||
const pass: RequestHandler = (_req, _res, next) => next();
|
||||
const adminOnly: RequestHandler = (_req, res, next) =>
|
||||
state.isAdmin ? next() : void res.status(403).json({ error: "no" });
|
||||
|
||||
function app() {
|
||||
const router = express.Router();
|
||||
router.use((req, _res, next) => {
|
||||
(req as unknown as { userId: string }).userId = "u1";
|
||||
next();
|
||||
});
|
||||
registerHostDefaultsRoutes(router, {
|
||||
authenticateJWT: pass,
|
||||
requireEditPermission: pass,
|
||||
requireDataAccess: pass,
|
||||
requireAdminSettings: adminOnly,
|
||||
});
|
||||
const server = express();
|
||||
server.use(express.json());
|
||||
server.use("/host", router);
|
||||
return server;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
state.isAdmin = true;
|
||||
state.access = { hasAccess: true, isOwner: true };
|
||||
state.saved = [];
|
||||
state.resets = [];
|
||||
state.audits = [];
|
||||
state.folders = new Map([
|
||||
[1, { id: 1, userId: "u1", name: "Prod" }],
|
||||
[2, { id: 2, userId: "u2", name: "Theirs" }],
|
||||
]);
|
||||
});
|
||||
|
||||
describe("host defaults routes", () => {
|
||||
it("keeps the server level to admins", async () => {
|
||||
state.isAdmin = false;
|
||||
expect((await request(app()).get("/host/defaults/admin")).status).toBe(403);
|
||||
expect(
|
||||
(await request(app()).put("/host/defaults/admin").send({ set: {} }))
|
||||
.status,
|
||||
).toBe(403);
|
||||
});
|
||||
|
||||
it("saves the server level as a background job, audited", async () => {
|
||||
const response = await request(app())
|
||||
.put("/host/defaults/admin")
|
||||
.send({ set: { "core.sshPort": 2022 } });
|
||||
expect(response.body).toEqual({ jobId: "job-1" });
|
||||
expect(state.audits).toHaveLength(1);
|
||||
const job = await request(app()).get("/host/defaults/jobs/job-1");
|
||||
expect(job.body.changedHosts).toBe(3);
|
||||
});
|
||||
|
||||
it("saves a user's own level and says how many hosts changed", async () => {
|
||||
const response = await request(app())
|
||||
.put("/host/defaults/user")
|
||||
.send({ set: { "core.sshPort": 2022 } });
|
||||
expect(response.body).toEqual({ changedHosts: 1 });
|
||||
expect(state.saved[0]).toMatchObject({
|
||||
scope: { level: "user", userId: "u1" },
|
||||
});
|
||||
});
|
||||
|
||||
it("reports rejected values", async () => {
|
||||
const response = await request(app())
|
||||
.put("/host/defaults/user")
|
||||
.send({ set: { "bad.key": 1 } });
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body.errors).toEqual({ "bad.key": "no" });
|
||||
});
|
||||
|
||||
it("only opens a folder the user owns", async () => {
|
||||
expect((await request(app()).get("/host/defaults/folders/1")).status).toBe(
|
||||
200,
|
||||
);
|
||||
expect((await request(app()).get("/host/defaults/folders/2")).status).toBe(
|
||||
404,
|
||||
);
|
||||
const created = await request(app())
|
||||
.post("/host/defaults/folders")
|
||||
.send({ name: "New" });
|
||||
expect(created.body).toEqual({ id: 42 });
|
||||
});
|
||||
|
||||
it("previews a change without saving it", async () => {
|
||||
const response = await request(app())
|
||||
.post("/host/defaults/preview")
|
||||
.send({ level: "user", set: { "core.sshPort": 1 } });
|
||||
expect(response.body).toEqual({ changedHosts: 2 });
|
||||
expect(state.saved).toEqual([]);
|
||||
});
|
||||
|
||||
it("resolves a host's defaults for the editor", async () => {
|
||||
const response = await request(app()).get(
|
||||
"/host/defaults/resolve?hostId=1",
|
||||
);
|
||||
expect(response.body.values["core.sshPort"].value).toBe(2022);
|
||||
});
|
||||
|
||||
it("resets a host, but never a shared editor over the owner's login", async () => {
|
||||
await request(app())
|
||||
.post("/host/db/host/7/reset-defaults")
|
||||
.send({ keys: ["core.sshPort"] });
|
||||
expect(state.resets).toHaveLength(1);
|
||||
|
||||
state.access = { hasAccess: true, isOwner: false };
|
||||
const all = await request(app())
|
||||
.post("/host/db/host/7/reset-defaults")
|
||||
.send({ all: true });
|
||||
expect(all.status).toBe(403);
|
||||
expect(
|
||||
(await request(app()).post("/host/db/host/7/reset-defaults").send({}))
|
||||
.status,
|
||||
).toBe(400);
|
||||
});
|
||||
});
|
||||
@@ -11,7 +11,17 @@ vi.mock("../../../database/routes/host-plugin-settings.js", () => ({
|
||||
applyPluginHostImportSettings: mocks.applyPluginSettings,
|
||||
setHostPluginEnabled: vi.fn(),
|
||||
}));
|
||||
vi.mock("../../../hosts/defaults/index.js", () => ({
|
||||
applyHostDefaultsToWrite: async () => ({ core: [] }),
|
||||
applyDefaultsAfterHostWrite: async () => {},
|
||||
}));
|
||||
vi.mock("../../../hosts/defaults/overrides.js", () => ({
|
||||
changeHostOverrides: async () => {},
|
||||
}));
|
||||
vi.mock("../../../database/repositories/factory.js", () => ({
|
||||
createCurrentHostDefaultsRepository: () => ({
|
||||
listHosts: async () => [],
|
||||
}),
|
||||
createCurrentCredentialRepository: () => ({
|
||||
listDecryptedByUserId: async () => [],
|
||||
}),
|
||||
|
||||
@@ -52,6 +52,9 @@ vi.mock("../../../database/repositories/factory.js", () => ({
|
||||
createCurrentPluginSettingsRepository: () => ({ getAllForScopeIds, set }),
|
||||
}));
|
||||
|
||||
vi.mock("../../../hosts/defaults/overrides.js", () => ({
|
||||
changeHostOverrides: async () => {},
|
||||
}));
|
||||
vi.mock("../../../plugins/index.js", () => ({
|
||||
getPluginRuntime: () => ({ loader: { list: () => loaded } }),
|
||||
}));
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* The admin host defaults. The terminal half moved to the ssh-terminal
|
||||
* plugin's new-host settings; its keys stay in the stored row for the boot
|
||||
* copy and a downgrade, but are never served or overwritten here.
|
||||
* The old admin host defaults routes are gone: host defaults live under
|
||||
* /host/defaults now. The GET used to hand the admin's proxy password to any
|
||||
* signed-in user.
|
||||
*/
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
@@ -65,40 +65,18 @@ beforeEach(() => {
|
||||
});
|
||||
|
||||
describe("/users/host-defaults", () => {
|
||||
it("serves core's defaults without the terminal keys the plugin took over", async () => {
|
||||
it("is no longer served, so the stored row never leaves the server", async () => {
|
||||
state.settings.host_defaults = JSON.stringify({
|
||||
useSocks5: true,
|
||||
fontSize: 18,
|
||||
autoTmux: true,
|
||||
socks5Password: "secret",
|
||||
});
|
||||
const response = await request(app()).get("/users/host-defaults");
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toEqual({ useSocks5: true });
|
||||
});
|
||||
|
||||
it("keeps the stored terminal keys when an admin saves core's defaults", async () => {
|
||||
state.settings.host_defaults = JSON.stringify({
|
||||
useSocks5: true,
|
||||
fontSize: 18,
|
||||
enableCommandHistory: false,
|
||||
});
|
||||
const response = await request(app())
|
||||
const read = await request(app()).get("/users/host-defaults");
|
||||
expect(read.status).toBe(404);
|
||||
expect(JSON.stringify(read.body)).not.toContain("secret");
|
||||
const write = await request(app())
|
||||
.patch("/users/host-defaults")
|
||||
.send({ useSocks5: false, statusCheckEnabled: false, autoTmux: true });
|
||||
expect(response.status).toBe(200);
|
||||
expect(JSON.parse(state.settings.host_defaults)).toEqual({
|
||||
fontSize: 18,
|
||||
enableCommandHistory: false,
|
||||
useSocks5: false,
|
||||
statusCheckEnabled: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("refuses a user who is not an admin", async () => {
|
||||
state.admin = false;
|
||||
const response = await request(app())
|
||||
.patch("/users/host-defaults")
|
||||
.send({ useSocks5: true });
|
||||
expect(response.status).toBe(403);
|
||||
.send({ useSocks5: false });
|
||||
expect(write.status).toBe(404);
|
||||
expect(JSON.parse(state.settings.host_defaults).useSocks5).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -450,6 +450,28 @@ CREATE INDEX `idx_host_protocol_auth_credential` ON `host_protocol_auth` (`crede
|
||||
-- install loses what it stored.
|
||||
SELECT 1;
|
||||
|
||||
-- ==== core 0065_ambiguous_agent_zero ====
|
||||
CREATE TABLE `host_defaults` (
|
||||
`id` int AUTO_INCREMENT NOT NULL,
|
||||
`level` enum('admin','user','folder') NOT NULL,
|
||||
`scope_key` varchar(255) NOT NULL,
|
||||
`user_id` varchar(255),
|
||||
`folder_id` int,
|
||||
`namespace` varchar(255) NOT NULL,
|
||||
`key` varchar(255) NOT NULL,
|
||||
`value` text,
|
||||
`updated_by` text,
|
||||
`updated_at` text NOT NULL DEFAULT (CURRENT_TIMESTAMP),
|
||||
CONSTRAINT `host_defaults_id` PRIMARY KEY(`id`),
|
||||
CONSTRAINT `idx_host_defaults_scope_key` UNIQUE(`scope_key`,`namespace`,`key`)
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE `ssh_data` ADD `default_overrides` text;--> statement-breakpoint
|
||||
ALTER TABLE `host_defaults` ADD CONSTRAINT `host_defaults_user_id_users_id_fk` FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE `host_defaults` ADD CONSTRAINT `host_defaults_folder_id_ssh_folders_id_fk` FOREIGN KEY (`folder_id`) REFERENCES `ssh_folders`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
CREATE INDEX `idx_host_defaults_user` ON `host_defaults` (`user_id`);--> statement-breakpoint
|
||||
CREATE INDEX `idx_host_defaults_folder` ON `host_defaults` (`folder_id`);
|
||||
|
||||
-- ==== plugin workspaces 0001_adopt_user_workspaces.sql ====
|
||||
-- workspaces 0001: adopt_user_workspaces
|
||||
-- Generated by termix-plugin migrations. Review before committing.
|
||||
|
||||
@@ -469,6 +469,27 @@ CREATE INDEX "idx_host_protocol_auth_credential" ON "host_protocol_auth" USING b
|
||||
-- install loses what it stored.
|
||||
SELECT 1;
|
||||
|
||||
-- ==== core 0065_regular_joystick ====
|
||||
CREATE TABLE "host_defaults" (
|
||||
"id" serial PRIMARY KEY NOT NULL,
|
||||
"level" text NOT NULL,
|
||||
"scope_key" varchar(255) NOT NULL,
|
||||
"user_id" varchar(255),
|
||||
"folder_id" integer,
|
||||
"namespace" varchar(255) NOT NULL,
|
||||
"key" varchar(255) NOT NULL,
|
||||
"value" text,
|
||||
"updated_by" text,
|
||||
"updated_at" text DEFAULT CURRENT_TIMESTAMP NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "ssh_data" ADD COLUMN "default_overrides" text;--> statement-breakpoint
|
||||
ALTER TABLE "host_defaults" ADD CONSTRAINT "host_defaults_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "host_defaults" ADD CONSTRAINT "host_defaults_folder_id_ssh_folders_id_fk" FOREIGN KEY ("folder_id") REFERENCES "public"."ssh_folders"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "idx_host_defaults_scope_key" ON "host_defaults" USING btree ("scope_key","namespace","key");--> statement-breakpoint
|
||||
CREATE INDEX "idx_host_defaults_user" ON "host_defaults" USING btree ("user_id");--> statement-breakpoint
|
||||
CREATE INDEX "idx_host_defaults_folder" ON "host_defaults" USING btree ("folder_id");
|
||||
|
||||
-- ==== plugin workspaces 0001_adopt_user_workspaces.sql ====
|
||||
-- workspaces 0001: adopt_user_workspaces
|
||||
-- Generated by termix-plugin migrations. Review before committing.
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("../../../database/repositories/factory.js", () => ({}));
|
||||
import type { PluginManifest } from "@termix/plugin-sdk/manifest";
|
||||
import {
|
||||
buildCatalog,
|
||||
catalogNamespaces,
|
||||
isDefaultableField,
|
||||
} from "../../../hosts/defaults/catalog.js";
|
||||
import {
|
||||
defaultValuesEqual,
|
||||
normalizeAuthDefault,
|
||||
normalizeSocks5Default,
|
||||
parseDefaultOverrides,
|
||||
splitDefaultKey,
|
||||
stableStringify,
|
||||
} from "../../../../types/host-defaults.js";
|
||||
|
||||
const manifest = {
|
||||
id: "fixture",
|
||||
contributes: {
|
||||
settings: {
|
||||
host: {
|
||||
enableKey: "enabled",
|
||||
enableDefault: true,
|
||||
fields: [
|
||||
{ key: "mode", type: "select", default: "a", options: [] },
|
||||
{ key: "token", type: "secret" },
|
||||
{ key: "config", type: "json", secretKeys: ["password"] },
|
||||
{ key: "mac", type: "string", defaultable: false },
|
||||
{
|
||||
key: "snippet",
|
||||
type: "number",
|
||||
defaultLevels: ["user", "folder"],
|
||||
},
|
||||
{ key: "font", type: "number", default: 14, personal: true },
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
} as unknown as PluginManifest;
|
||||
|
||||
describe("the catalog", () => {
|
||||
it("holds core's keys and each defaultable plugin field", () => {
|
||||
const catalog = buildCatalog([manifest]);
|
||||
expect(catalog.has("core.sshPort")).toBe(true);
|
||||
expect(catalog.has("core.auth")).toBe(true);
|
||||
expect(catalog.has("fixture.enabled")).toBe(true);
|
||||
expect(catalog.has("fixture.mode")).toBe(true);
|
||||
expect(catalog.has("fixture.token")).toBe(false);
|
||||
expect(catalog.has("fixture.config")).toBe(false);
|
||||
expect(catalog.has("fixture.mac")).toBe(false);
|
||||
expect(catalog.get("fixture.snippet")?.levels).toEqual(["user", "folder"]);
|
||||
expect(catalog.get("fixture.font")?.personal).toBe(true);
|
||||
expect(catalog.get("fixture.enabled")?.builtin).toBe(true);
|
||||
expect(catalogNamespaces(catalog)).toEqual(["core", "fixture"]);
|
||||
});
|
||||
|
||||
it("reads an unset plugin value as its manifest default", () => {
|
||||
const mode = buildCatalog([manifest]).get("fixture.mode")!;
|
||||
expect(mode.normalize(null)).toBe("a");
|
||||
expect(mode.normalize("")).toBe("a");
|
||||
expect(mode.normalize("b")).toBe("b");
|
||||
});
|
||||
|
||||
it("never lets a secret be a default", () => {
|
||||
expect(isDefaultableField({ key: "s", type: "secret" })).toBe(false);
|
||||
expect(
|
||||
isDefaultableField({ key: "s", type: "secret", defaultable: true }),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("value shapes", () => {
|
||||
it("keeps only the auth fields its type uses", () => {
|
||||
expect(
|
||||
normalizeAuthDefault({
|
||||
authType: "agent",
|
||||
credentialId: 4,
|
||||
agentSocketPath: "/tmp/agent",
|
||||
}),
|
||||
).toEqual({
|
||||
authType: "agent",
|
||||
credentialId: null,
|
||||
overrideCredentialUsername: false,
|
||||
agentSocketPath: "/tmp/agent",
|
||||
agentIdentity: null,
|
||||
});
|
||||
expect(normalizeAuthDefault(null)).toBeNull();
|
||||
});
|
||||
|
||||
it("drops a proxy's settings while it is off, and chain passwords always", () => {
|
||||
expect(
|
||||
normalizeSocks5Default({ useSocks5: false, socks5Host: "x" }).socks5Host,
|
||||
).toBeNull();
|
||||
expect(
|
||||
normalizeSocks5Default({
|
||||
useSocks5: true,
|
||||
socks5ProxyChain: [{ host: "a", password: "p" }],
|
||||
}).socks5ProxyChain,
|
||||
).toEqual([{ host: "a" }]);
|
||||
});
|
||||
|
||||
it("compares objects regardless of key order", () => {
|
||||
expect(stableStringify({ b: 1, a: 2 })).toBe(
|
||||
stableStringify({ a: 2, b: 1 }),
|
||||
);
|
||||
expect(defaultValuesEqual([{ a: 1 }], [{ a: 1 }])).toBe(true);
|
||||
});
|
||||
|
||||
it("parses overrides and splits keys", () => {
|
||||
expect(parseDefaultOverrides('{"core":["b","a","a"],"x":"no"}')).toEqual({
|
||||
core: ["a", "b"],
|
||||
});
|
||||
expect(parseDefaultOverrides(null)).toBeNull();
|
||||
expect(splitDefaultKey("ssh-terminal.fontSize")).toEqual([
|
||||
"ssh-terminal",
|
||||
"fontSize",
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,87 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
readCoreDefault,
|
||||
writeCoreDefault,
|
||||
} from "../../../hosts/defaults/core-columns.js";
|
||||
|
||||
describe("core defaults in column form", () => {
|
||||
it("reads the SSH options a row keeps as JSON", () => {
|
||||
const row = {
|
||||
sshOptions: JSON.stringify({ keepaliveInterval: 30 }),
|
||||
};
|
||||
expect(readCoreDefault("keepaliveInterval", row)).toBe(30);
|
||||
expect(readCoreDefault("keepaliveCountMax", row)).toBe(5);
|
||||
expect(readCoreDefault("allowLegacyAlgorithms", row)).toBe(true);
|
||||
});
|
||||
|
||||
it("writes one SSH option and keeps the others", () => {
|
||||
const row = {
|
||||
sshOptions: JSON.stringify({
|
||||
keepaliveInterval: 30,
|
||||
agentForwarding: true,
|
||||
}),
|
||||
};
|
||||
const patch = writeCoreDefault("keepaliveInterval", 90, row);
|
||||
expect(JSON.parse(patch.sshOptions as string)).toEqual({
|
||||
keepaliveInterval: 90,
|
||||
agentForwarding: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("moves an SSH host's port with its SSH port, but not a plugin protocol's", () => {
|
||||
expect(
|
||||
writeCoreDefault("sshPort", 2222, { connectionType: "ssh" }),
|
||||
).toEqual({ sshPort: 2222, port: 2222 });
|
||||
expect(
|
||||
writeCoreDefault("sshPort", 2222, { connectionType: "rdp" }),
|
||||
).toEqual({ sshPort: 2222 });
|
||||
});
|
||||
|
||||
it("writes a credential login and clears any key the host held", () => {
|
||||
const patch = writeCoreDefault(
|
||||
"auth",
|
||||
{ authType: "credential", credentialId: 7 },
|
||||
{ sshOptions: null, password: "kept" },
|
||||
);
|
||||
expect(patch).toMatchObject({
|
||||
authType: "credential",
|
||||
credentialId: 7,
|
||||
key: null,
|
||||
keyPassword: null,
|
||||
});
|
||||
expect(patch).not.toHaveProperty("password");
|
||||
expect(
|
||||
writeCoreDefault("auth", { authType: "agent" }, {}).password,
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("round-trips a proxy and keeps the host's own chain passwords", () => {
|
||||
const row = {
|
||||
socks5ProxyChain: JSON.stringify([{ host: "a", password: "mine" }]),
|
||||
};
|
||||
const patch = writeCoreDefault(
|
||||
"socks5",
|
||||
{ useSocks5: true, socks5ProxyChain: [{ host: "a" }] },
|
||||
row,
|
||||
);
|
||||
expect(JSON.parse(patch.socks5ProxyChain as string)).toEqual([
|
||||
{ host: "a", password: "mine" },
|
||||
]);
|
||||
expect(readCoreDefault("socks5", { ...row, ...patch })).toMatchObject({
|
||||
useSocks5: true,
|
||||
socks5ProxyChain: [{ host: "a" }],
|
||||
});
|
||||
});
|
||||
|
||||
it("stores empty lists as null and booleans as the column expects", () => {
|
||||
expect(writeCoreDefault("jumpHosts", [], {})).toEqual({ jumpHosts: null });
|
||||
expect(writeCoreDefault("forceKeyboardInteractive", true, {})).toEqual({
|
||||
forceKeyboardInteractive: "true",
|
||||
});
|
||||
expect(
|
||||
readCoreDefault("forceKeyboardInteractive", {
|
||||
forceKeyboardInteractive: "false",
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,324 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
/**
|
||||
* The materialize pass against in-memory tables: hosts, plugin host rows and
|
||||
* the defaults levels.
|
||||
*/
|
||||
|
||||
type Row = Record<string, unknown> & { id: number; userId: string };
|
||||
|
||||
const db = vi.hoisted(() => ({
|
||||
hosts: [] as Array<Record<string, unknown> & { id: number; userId: string }>,
|
||||
pluginRows: [] as Array<{
|
||||
pluginId: string;
|
||||
scope: string;
|
||||
scopeId: string;
|
||||
key: string;
|
||||
value: string | null;
|
||||
encrypted: boolean;
|
||||
}>,
|
||||
defaults: [] as Array<Record<string, unknown>>,
|
||||
folders: [] as Array<{ id: number; userId: string; name: string }>,
|
||||
events: [] as unknown[],
|
||||
notified: [] as unknown[],
|
||||
}));
|
||||
|
||||
const manifest = vi.hoisted(() => ({
|
||||
id: "term",
|
||||
contributes: {
|
||||
settings: {
|
||||
host: {
|
||||
enableKey: "enabled",
|
||||
enableDefault: true,
|
||||
fields: [
|
||||
{ key: "fontSize", type: "number", default: 14 },
|
||||
{ key: "mac", type: "string", defaultable: false },
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("../../../database/routes/host-plugin-settings.js", () => ({
|
||||
hostSettingsPlugins: () => [manifest],
|
||||
}));
|
||||
vi.mock("../../../plugins/events.js", () => ({
|
||||
TOPICS: { hostUpdated: "host.updated" },
|
||||
pluginEvents: { emit: (...args: unknown[]) => db.events.push(args) },
|
||||
}));
|
||||
vi.mock("../../../sync/server/feed.js", () => ({ markChanged: () => {} }));
|
||||
vi.mock("../../../utils/shared-host-secrets-manager.js", () => ({
|
||||
SharedHostSecretsManager: {
|
||||
getInstance: () => ({ resyncHost: async () => {} }),
|
||||
},
|
||||
}));
|
||||
vi.mock("../../../plugins/settings.js", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("../../../plugins/settings.js")>()),
|
||||
notifySettingChange: (...args: unknown[]) => db.notified.push(args),
|
||||
}));
|
||||
vi.mock("../../../database/repositories/factory.js", () => ({
|
||||
createCurrentSettingsRepository: () => ({ get: async () => null }),
|
||||
createCurrentHostDefaultsRepository: () => ({
|
||||
listHosts: async (filter: {
|
||||
hostIds?: number[];
|
||||
userIds?: string[];
|
||||
all?: boolean;
|
||||
}) =>
|
||||
db.hosts.filter(
|
||||
(host) =>
|
||||
filter.all ||
|
||||
filter.hostIds?.includes(host.id) ||
|
||||
filter.userIds?.includes(host.userId),
|
||||
),
|
||||
listForUsers: async (userIds: string[]) =>
|
||||
db.defaults.filter(
|
||||
(row) =>
|
||||
row.level === "admin" || userIds.includes(row.userId as string),
|
||||
),
|
||||
listFolders: async (userIds: string[]) =>
|
||||
db.folders.filter((folder) => userIds.includes(folder.userId)),
|
||||
listHostPlacement: async (userIds: string[]) =>
|
||||
db.hosts
|
||||
.filter((host) => userIds.includes(host.userId))
|
||||
.map((host) => ({
|
||||
id: host.id,
|
||||
userId: host.userId,
|
||||
folder: (host.folder as string | null) ?? null,
|
||||
parentHostId: (host.parentHostId as number | null) ?? null,
|
||||
})),
|
||||
updateHosts: async (patches: Array<{ id: number; values: object }>) => {
|
||||
for (const patch of patches) {
|
||||
const host = db.hosts.find((row) => row.id === patch.id)!;
|
||||
Object.assign(host, patch.values);
|
||||
}
|
||||
},
|
||||
}),
|
||||
createCurrentPluginSettingsRepository: () => ({
|
||||
getAllForScopeIds: async (_scope: string, ids: string[]) =>
|
||||
db.pluginRows.filter((row) => ids.includes(row.scopeId)),
|
||||
set: async (
|
||||
pluginId: string,
|
||||
scope: string,
|
||||
scopeId: string,
|
||||
key: string,
|
||||
value: string,
|
||||
) => {
|
||||
const existing = db.pluginRows.find(
|
||||
(row) =>
|
||||
row.pluginId === pluginId &&
|
||||
row.scopeId === scopeId &&
|
||||
row.key === key,
|
||||
);
|
||||
if (existing) existing.value = value;
|
||||
else
|
||||
db.pluginRows.push({
|
||||
pluginId,
|
||||
scope,
|
||||
scopeId,
|
||||
key,
|
||||
value,
|
||||
encrypted: false,
|
||||
});
|
||||
},
|
||||
delete: async (
|
||||
pluginId: string,
|
||||
_scope: string,
|
||||
scopeId: string,
|
||||
key: string,
|
||||
) => {
|
||||
db.pluginRows = db.pluginRows.filter(
|
||||
(row) =>
|
||||
!(
|
||||
row.pluginId === pluginId &&
|
||||
row.scopeId === scopeId &&
|
||||
row.key === key
|
||||
),
|
||||
);
|
||||
return true;
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
const { materializeHosts } =
|
||||
await import("../../../hosts/defaults/materialize.js");
|
||||
|
||||
function host(partial: Partial<Row> & { id: number }): Row {
|
||||
return {
|
||||
userId: "u1",
|
||||
connectionType: "ssh",
|
||||
port: 22,
|
||||
sshPort: 22,
|
||||
username: "root",
|
||||
authType: "password",
|
||||
folder: null,
|
||||
parentHostId: null,
|
||||
statusCheckEnabled: true,
|
||||
defaultOverrides: null,
|
||||
sharedSource: null,
|
||||
...partial,
|
||||
};
|
||||
}
|
||||
|
||||
function setDefault(
|
||||
level: "admin" | "user" | "folder",
|
||||
key: string,
|
||||
value: unknown,
|
||||
extra: Record<string, unknown> = {},
|
||||
) {
|
||||
const [namespace, name] = key.split(".");
|
||||
db.defaults.push({
|
||||
level,
|
||||
namespace,
|
||||
key: name,
|
||||
value: JSON.stringify(value),
|
||||
userId: level === "admin" ? null : "u1",
|
||||
folderId: null,
|
||||
...extra,
|
||||
});
|
||||
}
|
||||
|
||||
function pluginValue(hostId: number, key: string): unknown {
|
||||
const row = db.pluginRows.find(
|
||||
(entry) => entry.scopeId === String(hostId) && entry.key === key,
|
||||
);
|
||||
return row ? JSON.parse(row.value as string) : undefined;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
db.hosts = [];
|
||||
db.pluginRows = [];
|
||||
db.defaults = [];
|
||||
db.folders = [];
|
||||
db.events = [];
|
||||
db.notified = [];
|
||||
});
|
||||
|
||||
describe("materializeHosts", () => {
|
||||
it("classifies a host from before host defaults: matching values follow them", async () => {
|
||||
db.hosts = [host({ id: 1, sshPort: 22 }), host({ id: 2, sshPort: 2222 })];
|
||||
db.pluginRows.push({
|
||||
pluginId: "term",
|
||||
scope: "host",
|
||||
scopeId: "2",
|
||||
key: "fontSize",
|
||||
value: "18",
|
||||
encrypted: false,
|
||||
});
|
||||
|
||||
const result = await materializeHosts({ all: true });
|
||||
|
||||
expect(result.changedHostIds).toEqual([]);
|
||||
const first = JSON.parse(db.hosts[0].defaultOverrides as string);
|
||||
const second = JSON.parse(db.hosts[1].defaultOverrides as string);
|
||||
expect(first.core).not.toContain("sshPort");
|
||||
expect(second.core).toContain("sshPort");
|
||||
expect(first.term).toEqual([]);
|
||||
expect(second.term).toEqual(["fontSize"]);
|
||||
// Nothing a user sees changed.
|
||||
expect(db.hosts[1].sshPort).toBe(2222);
|
||||
});
|
||||
|
||||
it("writes a new default into every host that follows it, and no other", async () => {
|
||||
db.hosts = [
|
||||
host({ id: 1, defaultOverrides: JSON.stringify({ core: [], term: [] }) }),
|
||||
host({
|
||||
id: 2,
|
||||
sshPort: 2200,
|
||||
defaultOverrides: JSON.stringify({ core: ["sshPort"], term: [] }),
|
||||
}),
|
||||
];
|
||||
setDefault("user", "core.sshPort", 2022);
|
||||
setDefault("admin", "term.fontSize", 16);
|
||||
|
||||
const result = await materializeHosts({ userIds: ["u1"] });
|
||||
|
||||
expect(result.changedHostIds).toEqual([1, 2]);
|
||||
expect(db.hosts[0].sshPort).toBe(2022);
|
||||
expect(db.hosts[0].port).toBe(2022);
|
||||
expect(db.hosts[1].sshPort).toBe(2200);
|
||||
expect(pluginValue(1, "fontSize")).toBe(16);
|
||||
expect(pluginValue(2, "fontSize")).toBe(16);
|
||||
expect(db.events).toHaveLength(2);
|
||||
expect(db.notified).toEqual([["term", "fontSize", 16]]);
|
||||
});
|
||||
|
||||
it("removes the row when a key goes back to the manifest default", async () => {
|
||||
db.hosts = [
|
||||
host({ id: 1, defaultOverrides: JSON.stringify({ core: [], term: [] }) }),
|
||||
];
|
||||
db.pluginRows.push({
|
||||
pluginId: "term",
|
||||
scope: "host",
|
||||
scopeId: "1",
|
||||
key: "fontSize",
|
||||
value: "16",
|
||||
encrypted: false,
|
||||
});
|
||||
await materializeHosts({ all: true });
|
||||
expect(pluginValue(1, "fontSize")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("follows the deepest folder, and a sub-host follows its parent's", async () => {
|
||||
db.folders = [
|
||||
{ id: 10, userId: "u1", name: "Prod" },
|
||||
{ id: 11, userId: "u1", name: "Prod / Web" },
|
||||
];
|
||||
const classified = JSON.stringify({ core: [], term: [] });
|
||||
db.hosts = [
|
||||
host({ id: 1, folder: "Prod / Web", defaultOverrides: classified }),
|
||||
host({ id: 2, parentHostId: 1, defaultOverrides: classified }),
|
||||
host({ id: 3, folder: "Prod", defaultOverrides: classified }),
|
||||
];
|
||||
setDefault("folder", "core.sshPort", 2100, { folderId: 10 });
|
||||
setDefault("folder", "core.sshPort", 2200, { folderId: 11 });
|
||||
|
||||
await materializeHosts({ all: true });
|
||||
|
||||
expect(db.hosts.map((row) => row.sshPort)).toEqual([2200, 2200, 2100]);
|
||||
});
|
||||
|
||||
it("never writes a jump chain through the host itself", async () => {
|
||||
db.folders = [{ id: 10, userId: "u1", name: "Lab" }];
|
||||
db.hosts = [
|
||||
host({
|
||||
id: 1,
|
||||
folder: "Lab",
|
||||
defaultOverrides: JSON.stringify({ core: [], term: [] }),
|
||||
}),
|
||||
];
|
||||
setDefault("folder", "core.jumpHosts", [{ hostId: 1 }, { hostId: 9 }], {
|
||||
folderId: 10,
|
||||
});
|
||||
await materializeHosts({ all: true });
|
||||
expect(JSON.parse(db.hosts[0].jumpHosts as string)).toEqual([
|
||||
{ hostId: 9 },
|
||||
]);
|
||||
});
|
||||
|
||||
it("previews without writing", async () => {
|
||||
db.hosts = [
|
||||
host({ id: 1, defaultOverrides: JSON.stringify({ core: [], term: [] }) }),
|
||||
];
|
||||
const result = await materializeHosts(
|
||||
{ all: true },
|
||||
{
|
||||
dryRun: true,
|
||||
overlay: {
|
||||
level: "admin",
|
||||
set: new Map([["core.sshPort", 2022]]),
|
||||
unset: new Set(),
|
||||
},
|
||||
},
|
||||
);
|
||||
expect(result.changedHostIds).toEqual([1]);
|
||||
expect(db.hosts[0].sshPort).toBe(22);
|
||||
});
|
||||
|
||||
it("leaves a desktop's copy of a shared host alone", async () => {
|
||||
db.hosts = [host({ id: 1, sharedSource: "{}" })];
|
||||
setDefault("admin", "core.sshPort", 2022);
|
||||
await materializeHosts({ all: true });
|
||||
expect(db.hosts[0].sshPort).toBe(22);
|
||||
expect(db.hosts[0].defaultOverrides).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,132 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("../../../database/repositories/factory.js", () => ({}));
|
||||
import {
|
||||
effectiveFolderPath,
|
||||
folderChainPaths,
|
||||
isOwnValue,
|
||||
resolveKey,
|
||||
withoutSelfJump,
|
||||
type HostLevels,
|
||||
} from "../../../hosts/defaults/resolve.js";
|
||||
import { coreCatalogEntries } from "../../../hosts/defaults/catalog.js";
|
||||
|
||||
const entry = (key: string) =>
|
||||
coreCatalogEntries().find((info) => info.key === key)!;
|
||||
|
||||
function levels(partial: Partial<HostLevels> = {}): HostLevels {
|
||||
return {
|
||||
admin: new Map(),
|
||||
user: new Map(),
|
||||
folders: [],
|
||||
...partial,
|
||||
};
|
||||
}
|
||||
|
||||
describe("folderChainPaths", () => {
|
||||
it("lists every ancestor, root first", () => {
|
||||
expect(folderChainPaths("A / B / C")).toEqual(["A", "A / B", "A / B / C"]);
|
||||
expect(folderChainPaths(null)).toEqual([]);
|
||||
expect(folderChainPaths("")).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("effectiveFolderPath", () => {
|
||||
it("takes a sub-host's folder from its nearest placed ancestor", () => {
|
||||
const placement = new Map([
|
||||
[1, { folder: "Prod", parentHostId: null }],
|
||||
[2, { folder: null, parentHostId: 1 }],
|
||||
[3, { folder: null, parentHostId: 2 }],
|
||||
]);
|
||||
expect(effectiveFolderPath(3, placement)).toBe("Prod");
|
||||
expect(effectiveFolderPath(1, placement)).toBe("Prod");
|
||||
});
|
||||
|
||||
it("stops on a parent cycle", () => {
|
||||
const placement = new Map([
|
||||
[1, { folder: null, parentHostId: 2 }],
|
||||
[2, { folder: null, parentHostId: 1 }],
|
||||
]);
|
||||
expect(effectiveFolderPath(1, placement)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveKey", () => {
|
||||
it("prefers the deepest folder, then the user, then the server, then built-in", () => {
|
||||
const port = entry("sshPort");
|
||||
expect(resolveKey(port, levels())).toEqual({
|
||||
value: 22,
|
||||
source: { level: "builtin" },
|
||||
});
|
||||
expect(
|
||||
resolveKey(port, levels({ admin: new Map([["core.sshPort", 2200]]) }))
|
||||
?.source.level,
|
||||
).toBe("admin");
|
||||
const all = levels({
|
||||
admin: new Map([["core.sshPort", 2200]]),
|
||||
user: new Map([["core.sshPort", 2201]]),
|
||||
folders: [
|
||||
{ folderId: 1, name: "A", values: new Map([["core.sshPort", 2202]]) },
|
||||
{
|
||||
folderId: 2,
|
||||
name: "A / B",
|
||||
values: new Map([["core.sshPort", 2203]]),
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(resolveKey(port, all)).toEqual({
|
||||
value: 2203,
|
||||
source: { level: "folder", folderId: 2, folderName: "A / B" },
|
||||
});
|
||||
all.folders[1].values.clear();
|
||||
expect(resolveKey(port, all)?.value).toBe(2202);
|
||||
});
|
||||
|
||||
it("skips a level the key cannot be set at", () => {
|
||||
const jumps = entry("jumpHosts");
|
||||
const result = resolveKey(
|
||||
jumps,
|
||||
levels({ admin: new Map([["core.jumpHosts", [{ hostId: 5 }]]]) }),
|
||||
);
|
||||
expect(result?.source.level).toBe("builtin");
|
||||
expect(result?.value).toEqual([]);
|
||||
});
|
||||
|
||||
it("resolves nothing for a key with no built-in value and no level", () => {
|
||||
expect(resolveKey(entry("username"), levels())).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("isOwnValue", () => {
|
||||
it("compares normalized values", () => {
|
||||
const port = entry("sshPort");
|
||||
const resolved = { value: 22, source: { level: "builtin" as const } };
|
||||
expect(isOwnValue(port, "22", resolved)).toBe(false);
|
||||
expect(isOwnValue(port, null, resolved)).toBe(false);
|
||||
expect(isOwnValue(port, 2222, resolved)).toBe(true);
|
||||
});
|
||||
|
||||
it("treats any value as the host's own when nothing resolves", () => {
|
||||
const username = entry("username");
|
||||
expect(isOwnValue(username, "root", undefined)).toBe(true);
|
||||
expect(isOwnValue(username, "", undefined)).toBe(false);
|
||||
});
|
||||
|
||||
it("treats keepalive left unset as the built-in value", () => {
|
||||
const keepalive = entry("keepaliveInterval");
|
||||
expect(
|
||||
isOwnValue(keepalive, undefined, {
|
||||
value: 60,
|
||||
source: { level: "builtin" },
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("withoutSelfJump", () => {
|
||||
it("drops the host itself from an inherited jump chain", () => {
|
||||
expect(withoutSelfJump([{ hostId: 1 }, { hostId: 2 }], 2)).toEqual([
|
||||
{ hostId: 1 },
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,115 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { PluginManifest } from "@termix/plugin-sdk/manifest";
|
||||
|
||||
vi.mock("../../../database/repositories/factory.js", () => ({}));
|
||||
vi.mock("../../../database/routes/host-plugin-settings.js", () => ({
|
||||
hostSettingsPlugins: () => [],
|
||||
}));
|
||||
|
||||
const { validateLevelChange } =
|
||||
await import("../../../hosts/defaults/service.js");
|
||||
const { buildCatalog } = await import("../../../hosts/defaults/catalog.js");
|
||||
|
||||
const catalog = buildCatalog([
|
||||
{
|
||||
id: "fx",
|
||||
contributes: {
|
||||
settings: {
|
||||
host: {
|
||||
fields: [
|
||||
{ key: "size", type: "number", min: 8, max: 36, default: 14 },
|
||||
{ key: "snippet", type: "number", defaultLevels: ["user"] },
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
} as unknown as PluginManifest,
|
||||
]);
|
||||
|
||||
const context = {
|
||||
actorId: "u1",
|
||||
ownerId: "u1",
|
||||
canUseCredential: async (id: number) => id === 7,
|
||||
canUseHost: async (id: number) => id === 3,
|
||||
};
|
||||
|
||||
describe("validateLevelChange", () => {
|
||||
it("accepts known keys and normalizes their values", async () => {
|
||||
const change = await validateLevelChange(
|
||||
{ level: "user", userId: "u1" },
|
||||
{ set: { "core.sshPort": "2022", "fx.size": "16" }, unset: ["fx.x"] },
|
||||
context,
|
||||
catalog,
|
||||
);
|
||||
expect(change.errors).toEqual({});
|
||||
expect(change.set.get("core.sshPort")).toBe(2022);
|
||||
expect(change.set.get("fx.size")).toBe(16);
|
||||
expect([...change.unset]).toEqual(["fx.x"]);
|
||||
});
|
||||
|
||||
it("refuses unknown keys, out of range values and levels a key cannot use", async () => {
|
||||
const change = await validateLevelChange(
|
||||
{ level: "admin" },
|
||||
{
|
||||
set: {
|
||||
"core.name": "x",
|
||||
"fx.size": 99,
|
||||
"fx.snippet": 1,
|
||||
"core.jumpHosts": [{ hostId: 3 }],
|
||||
},
|
||||
},
|
||||
context,
|
||||
catalog,
|
||||
);
|
||||
expect(Object.keys(change.errors).sort()).toEqual([
|
||||
"core.jumpHosts",
|
||||
"core.name",
|
||||
"fx.size",
|
||||
"fx.snippet",
|
||||
]);
|
||||
});
|
||||
|
||||
it("never takes a password or key login, or a credential at the server level", async () => {
|
||||
const password = await validateLevelChange(
|
||||
{ level: "user", userId: "u1" },
|
||||
{ set: { "core.auth": { authType: "password" } } },
|
||||
context,
|
||||
catalog,
|
||||
);
|
||||
expect(password.errors["core.auth"]).toMatch(/cannot be a default/);
|
||||
|
||||
const serverCredential = await validateLevelChange(
|
||||
{ level: "admin" },
|
||||
{ set: { "core.auth": { authType: "credential", credentialId: 7 } } },
|
||||
context,
|
||||
catalog,
|
||||
);
|
||||
expect(serverCredential.errors["core.auth"]).toMatch(/server default/);
|
||||
|
||||
const ownCredential = await validateLevelChange(
|
||||
{ level: "folder", folderId: 1, userId: "u1" },
|
||||
{ set: { "core.auth": { authType: "credential", credentialId: 7 } } },
|
||||
context,
|
||||
catalog,
|
||||
);
|
||||
expect(ownCredential.errors).toEqual({});
|
||||
|
||||
const otherCredential = await validateLevelChange(
|
||||
{ level: "user", userId: "u1" },
|
||||
{ set: { "core.auth": { authType: "credential", credentialId: 8 } } },
|
||||
context,
|
||||
catalog,
|
||||
);
|
||||
expect(otherCredential.errors["core.auth"]).toMatch(/not available/);
|
||||
});
|
||||
|
||||
it("checks every jump host in a chain", async () => {
|
||||
const change = await validateLevelChange(
|
||||
{ level: "user", userId: "u1" },
|
||||
{ set: { "core.jumpHosts": [{ hostId: 3 }, { hostId: 4 }] } },
|
||||
context,
|
||||
catalog,
|
||||
);
|
||||
expect(change.errors["core.jumpHosts"]).toBeDefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,150 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const db = vi.hoisted(() => ({
|
||||
defaults: [] as Array<Record<string, unknown>>,
|
||||
}));
|
||||
|
||||
vi.mock("../../../database/routes/host-plugin-settings.js", () => ({
|
||||
hostSettingsPlugins: () => [],
|
||||
}));
|
||||
vi.mock("../../../database/repositories/factory.js", () => ({
|
||||
createCurrentSettingsRepository: () => ({ get: async () => null }),
|
||||
createCurrentHostDefaultsRepository: () => ({
|
||||
listForUsers: async () => db.defaults,
|
||||
listFolders: async () => [],
|
||||
listHostPlacement: async () => [],
|
||||
}),
|
||||
}));
|
||||
|
||||
const { applyHostDefaultsToWrite } =
|
||||
await import("../../../hosts/defaults/write-rules.js");
|
||||
const { applyOverrideChange } =
|
||||
await import("../../../hosts/defaults/overrides.js");
|
||||
|
||||
function userDefault(key: string, value: unknown) {
|
||||
db.defaults.push({
|
||||
level: "user",
|
||||
userId: "u1",
|
||||
folderId: null,
|
||||
namespace: "core",
|
||||
key,
|
||||
value: JSON.stringify(value),
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
db.defaults = [];
|
||||
});
|
||||
|
||||
describe("applyHostDefaultsToWrite", () => {
|
||||
it("takes the editor's list as the answer and fills what it inherits", async () => {
|
||||
userDefault("sshPort", 2022);
|
||||
const columns: Record<string, unknown> = {
|
||||
sshPort: 9999,
|
||||
port: 9999,
|
||||
connectionType: "ssh",
|
||||
};
|
||||
const overrides = await applyHostDefaultsToWrite({
|
||||
ownerId: "u1",
|
||||
hostId: null,
|
||||
columns,
|
||||
body: { defaultOverrides: { core: [], term: ["fontSize"] } },
|
||||
});
|
||||
expect(columns.sshPort).toBe(2022);
|
||||
expect(columns.port).toBe(2022);
|
||||
expect(overrides.core).toEqual([]);
|
||||
expect(overrides.term).toEqual(["fontSize"]);
|
||||
});
|
||||
|
||||
it("inherits what a create leaves out, and keeps what it sets", async () => {
|
||||
userDefault("sshPort", 2022);
|
||||
userDefault("statusCheckEnabled", false);
|
||||
const columns: Record<string, unknown> = {
|
||||
sshPort: 22,
|
||||
port: 22,
|
||||
statusCheckEnabled: 1,
|
||||
username: "deploy",
|
||||
connectionType: "ssh",
|
||||
};
|
||||
const overrides = await applyHostDefaultsToWrite({
|
||||
ownerId: "u1",
|
||||
hostId: null,
|
||||
columns,
|
||||
body: { ip: "10.0.0.1", port: 22, username: "deploy" },
|
||||
});
|
||||
expect(columns.statusCheckEnabled).toBe(false);
|
||||
// The port was sent and differs from the default, so it is the host's.
|
||||
expect(overrides.core).toContain("sshPort");
|
||||
expect(columns.sshPort).toBe(22);
|
||||
expect(overrides.core).toContain("username");
|
||||
});
|
||||
|
||||
it("marks a changed key on an update without a list, and keeps the rest", async () => {
|
||||
userDefault("sshPort", 2022);
|
||||
const stored = {
|
||||
id: 5,
|
||||
userId: "u1",
|
||||
sshPort: 2022,
|
||||
port: 2022,
|
||||
statusCheckEnabled: true,
|
||||
username: "root",
|
||||
defaultOverrides: JSON.stringify({ core: ["username"] }),
|
||||
};
|
||||
const columns: Record<string, unknown> = {
|
||||
sshPort: 2022,
|
||||
port: 2022,
|
||||
statusCheckEnabled: 0,
|
||||
username: "root",
|
||||
};
|
||||
const overrides = await applyHostDefaultsToWrite({
|
||||
ownerId: "u1",
|
||||
hostId: 5,
|
||||
columns,
|
||||
body: {},
|
||||
stored: stored as never,
|
||||
});
|
||||
expect(overrides.core).toEqual(["statusCheckEnabled", "username"]);
|
||||
});
|
||||
|
||||
it("keeps a locked key's state for a shared editor", async () => {
|
||||
const overrides = await applyHostDefaultsToWrite({
|
||||
ownerId: "u1",
|
||||
hostId: 5,
|
||||
columns: {},
|
||||
body: { defaultOverrides: { core: [] } },
|
||||
stored: {
|
||||
id: 5,
|
||||
userId: "u1",
|
||||
defaultOverrides: JSON.stringify({ core: ["auth"] }),
|
||||
} as never,
|
||||
lockedKeys: ["auth"],
|
||||
});
|
||||
expect(overrides.core).toContain("auth");
|
||||
});
|
||||
});
|
||||
|
||||
describe("applyOverrideChange", () => {
|
||||
it("adds and removes keys, per namespace", () => {
|
||||
const next = applyOverrideChange(
|
||||
{ core: ["sshPort"], term: ["fontSize", "theme"] },
|
||||
{ own: [["core", "username"]], inherit: [["term", "theme"]] },
|
||||
);
|
||||
expect(next).toEqual({ core: ["sshPort", "username"], term: ["fontSize"] });
|
||||
});
|
||||
|
||||
it("leaves a namespace nobody classified yet to its first pass", () => {
|
||||
expect(applyOverrideChange({ core: [] }, { own: [["term", "x"]] })).toEqual(
|
||||
{ core: [] },
|
||||
);
|
||||
expect(applyOverrideChange(null, { own: [["core", "x"]] })).toEqual({});
|
||||
});
|
||||
|
||||
it("resets everything, known namespaces included", () => {
|
||||
expect(
|
||||
applyOverrideChange({ core: ["a"] }, { inheritAll: true }, [
|
||||
"core",
|
||||
"term",
|
||||
]),
|
||||
).toEqual({ core: [], term: [] });
|
||||
});
|
||||
});
|
||||
@@ -65,6 +65,9 @@ vi.mock("../../utils/permission-manager.js", () => ({
|
||||
},
|
||||
}));
|
||||
vi.mock("../../database/repositories/factory.js", () => ({
|
||||
createCurrentHostDefaultsRepository: () => ({
|
||||
listHosts: async () => [],
|
||||
}),
|
||||
createCurrentHostResolutionRepository: () => ({
|
||||
findHostsByUserId: async () => h.ownedHosts,
|
||||
listHostRowsForAccessList: async () => h.sharedRows,
|
||||
@@ -185,6 +188,14 @@ vi.mock("../../utils/shared-host-secrets-manager.js", () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("../../hosts/defaults/index.js", () => ({
|
||||
applyHostDefaultsToWrite: async () => ({ core: [] }),
|
||||
applyDefaultsAfterHostWrite: async () => {},
|
||||
}));
|
||||
vi.mock("../../hosts/defaults/overrides.js", () => ({
|
||||
changeHostOverrides: async () => {},
|
||||
}));
|
||||
|
||||
vi.mock("../../hosts/delete-host.js", () => ({
|
||||
deleteOwnedHost: async (userId: string, hostId: number) => {
|
||||
h.deleted.push({ userId, hostId });
|
||||
|
||||
@@ -77,7 +77,14 @@ vi.mock("../../../utils/permission-manager.js", () => ({
|
||||
}),
|
||||
},
|
||||
}));
|
||||
vi.mock("../../../hosts/defaults/index.js", () => ({
|
||||
applyHostDefaultsToWrite: async () => ({ core: [] }),
|
||||
applyDefaultsAfterHostWrite: async () => {},
|
||||
}));
|
||||
vi.mock("../../../database/repositories/factory.js", () => ({
|
||||
createCurrentHostDefaultsRepository: () => ({
|
||||
listHosts: async () => [],
|
||||
}),
|
||||
createCurrentHostRepository: () => ({
|
||||
createEncryptedForUser: async () => secretHost(1),
|
||||
updateEncryptedForUser: async () => secretHost(1),
|
||||
|
||||
@@ -58,6 +58,10 @@ const UNGATED: Record<string, string> = {
|
||||
"settings.getUser": "the plugin's own user settings",
|
||||
"settings.setUser": "the plugin's own user settings",
|
||||
"settings.getHost": "the plugin's own host settings",
|
||||
"settings.getHostFor":
|
||||
"the plugin's own host settings, as one user sees them",
|
||||
"settings.getHostDefault":
|
||||
"the plugin's own host settings, as a user's defaults",
|
||||
"settings.setHost": "the plugin's own host settings",
|
||||
"settings.getAll": "the plugin's own settings",
|
||||
"settings.onChange": "the plugin's own settings",
|
||||
|
||||
@@ -418,14 +418,62 @@ describe("host field options", () => {
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it("rejects a defaultFrom that names a secret or unknown admin field", () => {
|
||||
for (const defaultFrom of ["token", "missing"]) {
|
||||
expect(
|
||||
validateManifest(
|
||||
host([{ key: "mode", type: "boolean", labelKey: "k", defaultFrom }]),
|
||||
).join(),
|
||||
).toMatch(/defaultFrom/);
|
||||
}
|
||||
it("still accepts an old defaultFrom, but only as a string", () => {
|
||||
expect(
|
||||
validateManifest(
|
||||
host([
|
||||
{
|
||||
key: "mode",
|
||||
type: "boolean",
|
||||
labelKey: "k",
|
||||
defaultFrom: "missing",
|
||||
},
|
||||
]),
|
||||
),
|
||||
).toEqual([]);
|
||||
expect(
|
||||
validateManifest(
|
||||
host([{ key: "mode", type: "boolean", labelKey: "k", defaultFrom: 1 }]),
|
||||
).join(),
|
||||
).toMatch(/defaultFrom/);
|
||||
});
|
||||
|
||||
it("accepts defaultable, defaultLevels and personal on host fields", () => {
|
||||
expect(
|
||||
validateManifest(
|
||||
host([
|
||||
{
|
||||
key: "mode",
|
||||
type: "boolean",
|
||||
labelKey: "k",
|
||||
defaultable: false,
|
||||
defaultLevels: ["user", "folder"],
|
||||
personal: true,
|
||||
},
|
||||
]),
|
||||
),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it("rejects bad default options and a defaultable secret", () => {
|
||||
const errors = (field: Record<string, unknown>) =>
|
||||
validateManifest(
|
||||
host([{ key: "mode", type: "boolean", labelKey: "k", ...field }]),
|
||||
).join();
|
||||
expect(errors({ defaultable: "yes" })).toMatch(/defaultable/);
|
||||
expect(errors({ personal: 1 })).toMatch(/personal/);
|
||||
expect(errors({ defaultLevels: [] })).toMatch(/defaultLevels/);
|
||||
expect(errors({ defaultLevels: ["host"] })).toMatch(/defaultLevels/);
|
||||
expect(errors({ type: "secret", defaultable: true })).toMatch(
|
||||
/cannot be defaultable/,
|
||||
);
|
||||
expect(
|
||||
validateManifest(
|
||||
withSettings({
|
||||
user: [{ key: "a", type: "boolean", labelKey: "k", personal: true }],
|
||||
}),
|
||||
).join(),
|
||||
).toMatch(/only valid on host fields/);
|
||||
});
|
||||
|
||||
it("rejects an unknown shareRead level and host options on admin fields", () => {
|
||||
|
||||
@@ -24,6 +24,9 @@ vi.mock("../../utils/crypto-migration/raw-rows.js", () => ({
|
||||
runStatement: vi.fn(async () => {}),
|
||||
}));
|
||||
|
||||
vi.mock("../../hosts/defaults/overrides.js", () => ({
|
||||
changeHostOverrides: async () => {},
|
||||
}));
|
||||
vi.mock("../../utils/logger.js", () => {
|
||||
const logger = {
|
||||
debug: vi.fn(),
|
||||
|
||||
@@ -127,11 +127,13 @@ describe("core sync entities", () => {
|
||||
it("registers what core syncs, under the wire names 2.8 used", () => {
|
||||
expect(listEntityTypes()).toEqual([
|
||||
"accountProfile",
|
||||
"hostDefaultsAdmin",
|
||||
"sshCredentials",
|
||||
"sharedCredentials",
|
||||
"sshFolders",
|
||||
"hosts",
|
||||
"sharedHosts",
|
||||
"hostDefaults",
|
||||
"userPreferences",
|
||||
"pluginUserSettings",
|
||||
]);
|
||||
@@ -168,7 +170,11 @@ describe("core sync entities", () => {
|
||||
.filter((entity) => entity.singleton)
|
||||
.map((entity) => entity.type);
|
||||
|
||||
expect(singletons.sort()).toEqual(["accountProfile", "userPreferences"]);
|
||||
expect(singletons.sort()).toEqual([
|
||||
"accountProfile",
|
||||
"hostDefaultsAdmin",
|
||||
"userPreferences",
|
||||
]);
|
||||
});
|
||||
|
||||
it("only sends shared copies and the account one way", () => {
|
||||
@@ -178,6 +184,7 @@ describe("core sync entities", () => {
|
||||
|
||||
expect(readOnly.sort()).toEqual([
|
||||
"accountProfile",
|
||||
"hostDefaultsAdmin",
|
||||
"sharedCredentials",
|
||||
"sharedHosts",
|
||||
]);
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("../../database/repositories/factory.js", () => ({}));
|
||||
vi.mock("../../plugins/registry.js", () => ({ consume: () => undefined }));
|
||||
|
||||
const { exportDefaultValue, importDefaultValue, parseHostDefaultsSyncId } =
|
||||
await import("../../sync/host-defaults.js");
|
||||
|
||||
describe("host defaults over sync", () => {
|
||||
it("names a row by level, folder and key", () => {
|
||||
expect(parseHostDefaultsSyncId("u:core.sshPort")).toEqual({
|
||||
level: "user",
|
||||
folderSyncId: null,
|
||||
fullKey: "core.sshPort",
|
||||
});
|
||||
expect(parseHostDefaultsSyncId("f:abc-1:term.fontSize")).toEqual({
|
||||
level: "folder",
|
||||
folderSyncId: "abc-1",
|
||||
fullKey: "term.fontSize",
|
||||
});
|
||||
expect(parseHostDefaultsSyncId("x:core.sshPort")).toBeNull();
|
||||
expect(parseHostDefaultsSyncId("f:core.sshPort")).toBeNull();
|
||||
});
|
||||
|
||||
it("sends a credential and jump hosts by syncId and maps them back", async () => {
|
||||
const out = {
|
||||
credential: async (id: number) => `cred-${id}`,
|
||||
host: async (id: number) => (id === 9 ? null : `host-${id}`),
|
||||
};
|
||||
const auth = await exportDefaultValue(
|
||||
"core",
|
||||
"auth",
|
||||
{ authType: "credential", credentialId: 4 },
|
||||
out,
|
||||
);
|
||||
expect(auth).toEqual({
|
||||
authType: "credential",
|
||||
credentialSyncId: "cred-4",
|
||||
});
|
||||
const jumps = await exportDefaultValue(
|
||||
"core",
|
||||
"jumpHosts",
|
||||
[{ hostId: 1 }, { hostId: 9 }],
|
||||
out,
|
||||
);
|
||||
expect(jumps).toEqual([{ hostSyncId: "host-1" }]);
|
||||
|
||||
const back = {
|
||||
credential: async (syncId: string) => Number(syncId.split("-")[1]) + 100,
|
||||
host: async (syncId: string) => Number(syncId.split("-")[1]) + 100,
|
||||
};
|
||||
expect(await importDefaultValue("core", "auth", auth, back)).toEqual({
|
||||
authType: "credential",
|
||||
credentialId: 104,
|
||||
});
|
||||
expect(await importDefaultValue("core", "jumpHosts", jumps, back)).toEqual([
|
||||
{ hostId: 101 },
|
||||
]);
|
||||
});
|
||||
|
||||
it("passes other values through", async () => {
|
||||
expect(await exportDefaultValue("core", "sshPort", 22)).toBe(22);
|
||||
expect(
|
||||
await importDefaultValue("term", "fontSize", 16, {
|
||||
credential: async () => null,
|
||||
host: async () => null,
|
||||
}),
|
||||
).toBe(16);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,311 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
type PluginRow = {
|
||||
pluginId: string;
|
||||
scope: string;
|
||||
scopeId: string | null;
|
||||
key: string;
|
||||
value: string | null;
|
||||
};
|
||||
|
||||
const db = vi.hoisted(() => ({
|
||||
settings: {} as Record<string, string>,
|
||||
pluginRows: [] as PluginRow[],
|
||||
defaults: [] as Array<{
|
||||
scope: Record<string, unknown>;
|
||||
namespace: string;
|
||||
key: string;
|
||||
value: string;
|
||||
}>,
|
||||
hosts: [] as Array<Record<string, unknown>>,
|
||||
folders: [] as Array<{ id: number; userId: string; credentialId: number }>,
|
||||
folderCredential: new Map<string, number>(),
|
||||
}));
|
||||
|
||||
const manifests = vi.hoisted(() => [
|
||||
{
|
||||
id: "ssh-terminal",
|
||||
contributes: {
|
||||
settings: {
|
||||
host: {
|
||||
fields: [
|
||||
{ key: "fontSize", type: "number", default: 14 },
|
||||
{ key: "theme", type: "string", default: "termix" },
|
||||
{ key: "localEcho", type: "select", default: "auto" },
|
||||
{ key: "linkClickBehavior", type: "select", default: "confirm" },
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "remote-desktop",
|
||||
contributes: {
|
||||
settings: {
|
||||
host: {
|
||||
fields: [{ key: "colorDepth", type: "select", default: "inherit" }],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{ id: "host-metrics", contributes: { settings: { host: { fields: [] } } } },
|
||||
]);
|
||||
|
||||
vi.mock("../../utils/logger.js", () => ({
|
||||
databaseLogger: { info: vi.fn(), warn: vi.fn() },
|
||||
}));
|
||||
vi.mock("../../database/routes/host-plugin-settings.js", () => ({
|
||||
hostSettingsPlugins: () => manifests,
|
||||
}));
|
||||
vi.mock("../../database/db/schema.js", () => ({
|
||||
sshFolders: { id: "id", userId: "userId", credentialId: "credentialId" },
|
||||
}));
|
||||
vi.mock("../../database/repositories/factory.js", () => {
|
||||
const scopeKey = (scope: Record<string, unknown>) =>
|
||||
scope.level === "admin"
|
||||
? "admin"
|
||||
: scope.level === "user"
|
||||
? `u:${scope.userId}`
|
||||
: `f:${scope.folderId}`;
|
||||
return {
|
||||
createCurrentSettingsRepository: () => ({
|
||||
get: async (key: string) => db.settings[key] ?? null,
|
||||
set: async (key: string, value: string) => {
|
||||
db.settings[key] = value;
|
||||
},
|
||||
}),
|
||||
createCurrentPluginSettingsRepository: () => ({
|
||||
listByKey: async (pluginId: string, scope: string, key: string) =>
|
||||
db.pluginRows.filter(
|
||||
(row) =>
|
||||
row.pluginId === pluginId && row.scope === scope && row.key === key,
|
||||
),
|
||||
getAll: async (pluginId: string, scope: string, scopeId: string) =>
|
||||
db.pluginRows.filter(
|
||||
(row) =>
|
||||
row.pluginId === pluginId &&
|
||||
row.scope === scope &&
|
||||
row.scopeId === scopeId,
|
||||
),
|
||||
set: async (
|
||||
pluginId: string,
|
||||
scope: string,
|
||||
scopeId: string,
|
||||
key: string,
|
||||
value: string,
|
||||
) => {
|
||||
db.pluginRows = db.pluginRows.filter(
|
||||
(row) =>
|
||||
!(
|
||||
row.pluginId === pluginId &&
|
||||
row.scope === scope &&
|
||||
row.scopeId === scopeId &&
|
||||
row.key === key
|
||||
),
|
||||
);
|
||||
db.pluginRows.push({ pluginId, scope, scopeId, key, value });
|
||||
},
|
||||
delete: async (
|
||||
pluginId: string,
|
||||
scope: string,
|
||||
scopeId: string,
|
||||
key: string,
|
||||
) => {
|
||||
db.pluginRows = db.pluginRows.filter(
|
||||
(row) =>
|
||||
!(
|
||||
row.pluginId === pluginId &&
|
||||
row.scope === scope &&
|
||||
row.scopeId === scopeId &&
|
||||
row.key === key
|
||||
),
|
||||
);
|
||||
},
|
||||
}),
|
||||
createCurrentHostDefaultsRepository: () => ({
|
||||
listScope: async (scope: Record<string, unknown>) =>
|
||||
db.defaults.filter((row) => scopeKey(row.scope) === scopeKey(scope)),
|
||||
apply: async (
|
||||
scope: Record<string, unknown>,
|
||||
set: Array<{ namespace: string; key: string; value: string }>,
|
||||
) => {
|
||||
for (const entry of set) db.defaults.push({ scope, ...entry });
|
||||
},
|
||||
listHosts: async () => db.hosts,
|
||||
updateHosts: async (
|
||||
patches: Array<{ id: number; values: Record<string, unknown> }>,
|
||||
) => {
|
||||
for (const patch of patches) {
|
||||
Object.assign(
|
||||
db.hosts.find((host) => host.id === patch.id)!,
|
||||
patch.values,
|
||||
);
|
||||
}
|
||||
},
|
||||
}),
|
||||
createCurrentHostResolutionRepository: () => ({
|
||||
findFolderCredentialId: async (_userId: string, folder: string) =>
|
||||
db.folderCredential.get(folder) ?? null,
|
||||
}),
|
||||
createCurrentRepositoryContext: () => ({
|
||||
drizzle: {
|
||||
select: () => ({
|
||||
from: () => ({ where: async () => db.folders }),
|
||||
}),
|
||||
},
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
const { runHostDefaultsMigration } =
|
||||
await import("../../upgrade/host-defaults-migration.js");
|
||||
|
||||
function level(namespace: string, key: string, scopeLevel: string) {
|
||||
const row = db.defaults.find(
|
||||
(entry) =>
|
||||
entry.namespace === namespace &&
|
||||
entry.key === key &&
|
||||
entry.scope.level === scopeLevel,
|
||||
);
|
||||
return row ? JSON.parse(row.value) : undefined;
|
||||
}
|
||||
|
||||
function hostValue(hostId: number, pluginId: string, key: string) {
|
||||
const row = db.pluginRows.find(
|
||||
(entry) =>
|
||||
entry.pluginId === pluginId &&
|
||||
entry.scope === "host" &&
|
||||
entry.scopeId === String(hostId) &&
|
||||
entry.key === key,
|
||||
);
|
||||
return row?.value ? JSON.parse(row.value) : undefined;
|
||||
}
|
||||
|
||||
const plugin = (
|
||||
pluginId: string,
|
||||
scope: string,
|
||||
scopeId: string | null,
|
||||
key: string,
|
||||
value: unknown,
|
||||
): PluginRow => ({
|
||||
pluginId,
|
||||
scope,
|
||||
scopeId,
|
||||
key,
|
||||
value: JSON.stringify(value),
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
db.settings = {};
|
||||
db.pluginRows = [];
|
||||
db.defaults = [];
|
||||
db.hosts = [];
|
||||
db.folders = [];
|
||||
db.folderCredential = new Map();
|
||||
});
|
||||
|
||||
describe("runHostDefaultsMigration", () => {
|
||||
it("moves the old server defaults, dropping the credential and password", async () => {
|
||||
db.settings.host_defaults = JSON.stringify({
|
||||
useSocks5: true,
|
||||
socks5Host: "proxy",
|
||||
socks5Port: 1081,
|
||||
socks5Password: "secret",
|
||||
credentialId: 3,
|
||||
statusCheckEnabled: false,
|
||||
});
|
||||
db.pluginRows.push(
|
||||
plugin("ssh-terminal", "admin", null, "newHostFontSize", 18),
|
||||
plugin("host-metrics", "admin", null, "enabledForNewHosts", false),
|
||||
);
|
||||
db.folders = [{ id: 4, userId: "u1", credentialId: 9 }];
|
||||
|
||||
await runHostDefaultsMigration();
|
||||
|
||||
expect(level("core", "socks5", "admin")).toMatchObject({
|
||||
useSocks5: true,
|
||||
socks5Host: "proxy",
|
||||
socks5Port: 1081,
|
||||
});
|
||||
expect(JSON.stringify(db.defaults)).not.toContain("secret");
|
||||
expect(level("core", "statusCheckEnabled", "admin")).toBe(false);
|
||||
expect(level("ssh-terminal", "fontSize", "admin")).toBe(18);
|
||||
expect(level("host-metrics", "metricsEnabled", "admin")).toBe(false);
|
||||
expect(level("core", "auth", "folder")).toMatchObject({
|
||||
authType: "credential",
|
||||
credentialId: 9,
|
||||
});
|
||||
});
|
||||
|
||||
it("moves each user's terminal and remote desktop defaults to their own level", async () => {
|
||||
db.pluginRows.push(
|
||||
plugin("ssh-terminal", "user", "u1", "terminalDefaults", {
|
||||
fontSize: 20,
|
||||
}),
|
||||
plugin("ssh-terminal", "user", "u1", "localEcho", "on"),
|
||||
plugin("remote-desktop", "user", "u1", "colorDepth", "16"),
|
||||
plugin("remote-desktop", "user", "u1", "disableAudio", "inherit"),
|
||||
);
|
||||
await runHostDefaultsMigration();
|
||||
expect(level("ssh-terminal", "fontSize", "user")).toBe(20);
|
||||
expect(level("ssh-terminal", "localEcho", "user")).toBe("on");
|
||||
expect(level("remote-desktop", "colorDepth", "user")).toBe("16");
|
||||
expect(level("remote-desktop", "disableAudio", "user")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("writes in the look a host really had while it followed its user", async () => {
|
||||
db.hosts = [
|
||||
{ id: 1, userId: "u1", defaultOverrides: null },
|
||||
{ id: 2, userId: "u1", defaultOverrides: null },
|
||||
{ id: 3, userId: "u1", defaultOverrides: '{"core":[]}' },
|
||||
];
|
||||
db.pluginRows.push(
|
||||
plugin("ssh-terminal", "user", "u1", "terminalDefaults", {
|
||||
fontSize: 20,
|
||||
}),
|
||||
plugin("ssh-terminal", "user", "u1", "localEcho", "on"),
|
||||
plugin("ssh-terminal", "host", "1", "fontSize", 30),
|
||||
plugin("ssh-terminal", "host", "1", "localEcho", "default"),
|
||||
plugin("ssh-terminal", "host", "2", "inheritAppearance", false),
|
||||
plugin("ssh-terminal", "host", "2", "fontSize", 30),
|
||||
plugin("ssh-terminal", "host", "2", "localEcho", "off"),
|
||||
plugin("remote-desktop", "user", "u1", "colorDepth", "24"),
|
||||
);
|
||||
|
||||
await runHostDefaultsMigration();
|
||||
|
||||
// Followed the user: the user's look, and their echo mode.
|
||||
expect(hostValue(1, "ssh-terminal", "fontSize")).toBe(20);
|
||||
expect(hostValue(1, "ssh-terminal", "localEcho")).toBe("on");
|
||||
expect(hostValue(1, "remote-desktop", "colorDepth")).toBe("24");
|
||||
// Had its own look: kept.
|
||||
expect(hostValue(2, "ssh-terminal", "fontSize")).toBe(30);
|
||||
expect(hostValue(2, "ssh-terminal", "localEcho")).toBe("off");
|
||||
// Saved since host defaults: left alone.
|
||||
expect(hostValue(3, "ssh-terminal", "fontSize")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("gives a credential host its folder's credential", async () => {
|
||||
db.hosts = [
|
||||
{
|
||||
id: 1,
|
||||
userId: "u1",
|
||||
authType: "credential",
|
||||
credentialId: null,
|
||||
folder: "Prod",
|
||||
defaultOverrides: null,
|
||||
},
|
||||
];
|
||||
db.folderCredential.set("Prod", 12);
|
||||
await runHostDefaultsMigration();
|
||||
expect(db.hosts[0].credentialId).toBe(12);
|
||||
});
|
||||
|
||||
it("moves each level once", async () => {
|
||||
db.settings.host_defaults = JSON.stringify({ statusCheckEnabled: false });
|
||||
await runHostDefaultsMigration();
|
||||
db.defaults = [];
|
||||
await runHostDefaultsMigration();
|
||||
expect(level("core", "statusCheckEnabled", "admin")).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -35,6 +35,7 @@ import { runTotpMigration } from "./totp-migration.js";
|
||||
import { runNotificationChannelMigration } from "./notification-channel-migration.js";
|
||||
import { runTermixIdentityCaMigration } from "./termix-identity-ca-migration.js";
|
||||
import { runSsoSettingsMigration } from "./sso-settings-migration.js";
|
||||
import { runHostDefaultsMigration } from "./host-defaults-migration.js";
|
||||
|
||||
const MIGRATIONS: Array<[string, () => Promise<unknown>]> = [
|
||||
["runTailscaleSettingsMigration", runTailscaleSettingsMigration],
|
||||
@@ -66,6 +67,8 @@ const MIGRATIONS: Array<[string, () => Promise<unknown>]> = [
|
||||
["runNotificationChannelMigration", runNotificationChannelMigration],
|
||||
["runTermixIdentityCaMigration", runTermixIdentityCaMigration],
|
||||
["runSsoSettingsMigration", runSsoSettingsMigration],
|
||||
// Last: it reads what the moves above wrote.
|
||||
["runHostDefaultsMigration", runHostDefaultsMigration],
|
||||
];
|
||||
|
||||
export async function runPluginDataMigrations(): Promise<void> {
|
||||
@@ -79,4 +82,25 @@ export async function runPluginDataMigrations(): Promise<void> {
|
||||
});
|
||||
}
|
||||
}
|
||||
await applyHostDefaults();
|
||||
}
|
||||
|
||||
/**
|
||||
* Classifies hosts nobody classified yet (from an older release, or for a
|
||||
* plugin just started) and brings every host up to date with its defaults.
|
||||
* A linked desktop gets its hosts already resolved from its server.
|
||||
*/
|
||||
async function applyHostDefaults(): Promise<void> {
|
||||
try {
|
||||
const { getLink } = await import("../sync/client/link-store.js");
|
||||
if (await getLink().catch(() => null)) return;
|
||||
const { recomputeEverything } =
|
||||
await import("../hosts/defaults/recompute.js");
|
||||
recomputeEverything("plugins started");
|
||||
} catch (error) {
|
||||
databaseLogger.warn("Could not apply host defaults", {
|
||||
operation: "plugin_data_migration",
|
||||
error: getErrorMessage(error),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,447 @@
|
||||
/**
|
||||
* Moves 2.8 and early 2.9 default settings into host defaults, and gets hosts
|
||||
* ready to be classified against them.
|
||||
*
|
||||
* Levels, each moved once:
|
||||
* - core `settings.host_defaults` (SOCKS5 and the status check) becomes the
|
||||
* server level. Its credential and SOCKS5 password are dropped: a
|
||||
* credential belongs to one user and a password is never a default.
|
||||
* - a folder's credential becomes that folder's auth default.
|
||||
* - ssh-terminal's admin "new host" look, tmux and command history become the
|
||||
* server level; each user's terminal defaults, local echo and link click
|
||||
* setting become their user level.
|
||||
* - host-metrics' "enabled for new hosts" becomes the server level.
|
||||
* - remote-desktop's per-user display settings become the user level.
|
||||
*
|
||||
* Hosts: a host saved before host defaults gets the value it really used
|
||||
* written in, where that was not its stored value. A terminal that
|
||||
* followed the user's look used the user's look; an echo mode of "default"
|
||||
* used the user's setting; a credential host with no credential used its
|
||||
* folder's; a remote desktop used the connecting user's display settings.
|
||||
* The classify pass that follows compares those against the new levels, so
|
||||
* nothing a user sees changes.
|
||||
*
|
||||
* The old rows stay in place for a downgrade until 3.0.0.
|
||||
*/
|
||||
|
||||
import type { PluginManifest } from "@termix/plugin-sdk/manifest";
|
||||
import {
|
||||
createCurrentHostDefaultsRepository,
|
||||
createCurrentPluginSettingsRepository,
|
||||
createCurrentSettingsRepository,
|
||||
} from "../database/repositories/factory.js";
|
||||
import type { HostRow } from "../database/repositories/host-defaults-repository.js";
|
||||
import { hostSettingsPlugins } from "../database/routes/host-plugin-settings.js";
|
||||
import {
|
||||
CORE_NAMESPACE,
|
||||
defaultValuesEqual,
|
||||
normalizeSocks5Default,
|
||||
parseDefaultOverrides,
|
||||
} from "../../types/host-defaults.js";
|
||||
import { encodeDefaultValue } from "../hosts/defaults/levels.js";
|
||||
import { folderChainPaths } from "../hosts/defaults/resolve.js";
|
||||
import { databaseLogger } from "../utils/logger.js";
|
||||
|
||||
const TERMINAL = "ssh-terminal";
|
||||
const METRICS = "host-metrics";
|
||||
const DESKTOP = "remote-desktop";
|
||||
|
||||
const TERMINAL_APPEARANCE_KEYS = [
|
||||
"theme",
|
||||
"cursorBlink",
|
||||
"cursorStyle",
|
||||
"fontSize",
|
||||
"fontFamily",
|
||||
"scrollback",
|
||||
"letterSpacing",
|
||||
"lineHeight",
|
||||
"bellStyle",
|
||||
"minimumContrastRatio",
|
||||
"backgroundImage",
|
||||
"backgroundImageOpacity",
|
||||
"customThemeColors",
|
||||
];
|
||||
|
||||
/** ssh-terminal admin key -> the host key it seeded. */
|
||||
const TERMINAL_NEW_HOST_KEYS: Record<string, string> = {
|
||||
commandHistoryForNewHosts: "enableCommandHistory",
|
||||
newHostTheme: "theme",
|
||||
newHostFontFamily: "fontFamily",
|
||||
newHostFontSize: "fontSize",
|
||||
newHostCursorStyle: "cursorStyle",
|
||||
newHostCursorBlink: "cursorBlink",
|
||||
newHostAutoTmux: "autoTmux",
|
||||
};
|
||||
|
||||
/** ssh-terminal keys whose "default" meant the user's own setting. */
|
||||
const TERMINAL_USER_MODES: Record<string, string> = {
|
||||
localEcho: "auto",
|
||||
linkClickBehavior: "confirm",
|
||||
};
|
||||
|
||||
const DESKTOP_DISPLAY_KEYS = [
|
||||
"colorDepth",
|
||||
"resizeMethod",
|
||||
"forceLossless",
|
||||
"enableWallpaper",
|
||||
"enableFontSmoothing",
|
||||
"enableDesktopComposition",
|
||||
"disableAudio",
|
||||
"enablePrinting",
|
||||
"enableDrive",
|
||||
"disableCopy",
|
||||
"disablePaste",
|
||||
];
|
||||
|
||||
const MARKER_PREFIX = "host_defaults_moved_";
|
||||
|
||||
function decode(raw: string | null | undefined): unknown {
|
||||
if (raw === null || raw === undefined) return undefined;
|
||||
try {
|
||||
let parsed = JSON.parse(raw);
|
||||
if (typeof parsed === "string" && /^[[{]/.test(parsed)) {
|
||||
parsed = JSON.parse(parsed);
|
||||
}
|
||||
return parsed;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function asObject(value: unknown): Record<string, unknown> | null {
|
||||
return value && typeof value === "object" && !Array.isArray(value)
|
||||
? (value as Record<string, unknown>)
|
||||
: null;
|
||||
}
|
||||
|
||||
async function once(name: string, run: () => Promise<void>): Promise<void> {
|
||||
const settings = createCurrentSettingsRepository();
|
||||
const marker = `${MARKER_PREFIX}${name}`;
|
||||
if (await settings.get(marker)) return;
|
||||
await run();
|
||||
await settings.set(marker, new Date().toISOString());
|
||||
}
|
||||
|
||||
type Level =
|
||||
| { level: "admin" }
|
||||
| { level: "user"; userId: string }
|
||||
| { level: "folder"; userId: string; folderId: number };
|
||||
|
||||
async function setDefault(
|
||||
scope: Level,
|
||||
namespace: string,
|
||||
key: string,
|
||||
value: unknown,
|
||||
): Promise<void> {
|
||||
const repository = createCurrentHostDefaultsRepository();
|
||||
const existing = await repository.listScope(scope);
|
||||
if (existing.some((row) => row.namespace === namespace && row.key === key)) {
|
||||
return;
|
||||
}
|
||||
await repository.apply(
|
||||
scope,
|
||||
[{ namespace, key, value: encodeDefaultValue(value) }],
|
||||
[],
|
||||
null,
|
||||
);
|
||||
}
|
||||
|
||||
async function moveCoreLevels(): Promise<void> {
|
||||
const raw = await createCurrentSettingsRepository().get("host_defaults");
|
||||
const stored = asObject(decode(raw)) ?? {};
|
||||
if (stored.useSocks5 === true) {
|
||||
await setDefault(
|
||||
{ level: "admin" },
|
||||
CORE_NAMESPACE,
|
||||
"socks5",
|
||||
normalizeSocks5Default({ ...stored, socks5ProxyChain: null }),
|
||||
);
|
||||
}
|
||||
if (stored.statusCheckEnabled === false) {
|
||||
await setDefault(
|
||||
{ level: "admin" },
|
||||
CORE_NAMESPACE,
|
||||
"statusCheckEnabled",
|
||||
false,
|
||||
);
|
||||
}
|
||||
if (stored.credentialId !== undefined || stored.socks5Password) {
|
||||
databaseLogger.info(
|
||||
"Host defaults no longer hold a credential or proxy password; set a credential per user or folder instead",
|
||||
{ operation: "host_defaults_migration" },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function moveFolderCredentials(): Promise<void> {
|
||||
const { createCurrentRepositoryContext } =
|
||||
await import("../database/repositories/factory.js");
|
||||
const { sshFolders } = await import("../database/db/schema.js");
|
||||
const { isNotNull } = await import("drizzle-orm");
|
||||
const rows = await createCurrentRepositoryContext()
|
||||
.drizzle.select({
|
||||
id: sshFolders.id,
|
||||
userId: sshFolders.userId,
|
||||
credentialId: sshFolders.credentialId,
|
||||
})
|
||||
.from(sshFolders)
|
||||
.where(isNotNull(sshFolders.credentialId));
|
||||
for (const row of rows) {
|
||||
await setDefault(
|
||||
{ level: "folder", userId: row.userId, folderId: row.id },
|
||||
CORE_NAMESPACE,
|
||||
"auth",
|
||||
{
|
||||
authType: "credential",
|
||||
credentialId: row.credentialId,
|
||||
overrideCredentialUsername: false,
|
||||
agentSocketPath: null,
|
||||
agentIdentity: null,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function storedRows(
|
||||
pluginId: string,
|
||||
scope: "admin" | "user",
|
||||
key: string,
|
||||
) {
|
||||
return (
|
||||
await createCurrentPluginSettingsRepository().listByKey(
|
||||
pluginId,
|
||||
scope,
|
||||
key,
|
||||
)
|
||||
).filter((row) => row.value !== null);
|
||||
}
|
||||
|
||||
async function moveTerminalLevels(): Promise<void> {
|
||||
for (const [adminKey, hostKey] of Object.entries(TERMINAL_NEW_HOST_KEYS)) {
|
||||
for (const row of await storedRows(TERMINAL, "admin", adminKey)) {
|
||||
const value = decode(row.value);
|
||||
if (value === undefined || value === null) continue;
|
||||
await setDefault({ level: "admin" }, TERMINAL, hostKey, value);
|
||||
}
|
||||
}
|
||||
for (const row of await storedRows(TERMINAL, "user", "terminalDefaults")) {
|
||||
const values = asObject(decode(row.value));
|
||||
if (!values || !row.scopeId) continue;
|
||||
for (const key of TERMINAL_APPEARANCE_KEYS) {
|
||||
if (values[key] === undefined) continue;
|
||||
await setDefault(
|
||||
{ level: "user", userId: row.scopeId },
|
||||
TERMINAL,
|
||||
key,
|
||||
values[key],
|
||||
);
|
||||
}
|
||||
}
|
||||
for (const key of Object.keys(TERMINAL_USER_MODES)) {
|
||||
for (const row of await storedRows(TERMINAL, "user", key)) {
|
||||
const value = decode(row.value);
|
||||
if (typeof value !== "string" || !row.scopeId) continue;
|
||||
await setDefault(
|
||||
{ level: "user", userId: row.scopeId },
|
||||
TERMINAL,
|
||||
key,
|
||||
value,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function moveMetricsLevels(): Promise<void> {
|
||||
for (const row of await storedRows(METRICS, "admin", "enabledForNewHosts")) {
|
||||
if (decode(row.value) === false) {
|
||||
await setDefault({ level: "admin" }, METRICS, "metricsEnabled", false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function moveDesktopLevels(): Promise<void> {
|
||||
for (const key of DESKTOP_DISPLAY_KEYS) {
|
||||
for (const row of await storedRows(DESKTOP, "user", key)) {
|
||||
const value = decode(row.value);
|
||||
if (typeof value !== "string" || value === "inherit" || !row.scopeId) {
|
||||
continue;
|
||||
}
|
||||
await setDefault(
|
||||
{ level: "user", userId: row.scopeId },
|
||||
DESKTOP,
|
||||
key,
|
||||
value,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Hosts saved before host defaults (by an older release, or 2.8 after a
|
||||
* downgrade), which still carry the old meaning. A host saved since has its
|
||||
* overrides set, and a namespace it lacks is classified as it stands.
|
||||
*/
|
||||
function unclassified(rows: HostRow[]): HostRow[] {
|
||||
return rows.filter(
|
||||
(row) =>
|
||||
!row.sharedSource && parseDefaultOverrides(row.defaultOverrides) === null,
|
||||
);
|
||||
}
|
||||
|
||||
async function userValues(
|
||||
pluginId: string,
|
||||
key: string,
|
||||
): Promise<Map<string, unknown>> {
|
||||
const result = new Map<string, unknown>();
|
||||
for (const row of await storedRows(pluginId, "user", key)) {
|
||||
if (row.scopeId) result.set(row.scopeId, decode(row.value));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function manifestDefault(manifest: PluginManifest, key: string): unknown {
|
||||
return manifest.contributes?.settings?.host?.fields.find(
|
||||
(field) => field.key === key,
|
||||
)?.default;
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes one host value, or removes the row when it is the manifest default
|
||||
* so the host reads that default the usual way.
|
||||
*/
|
||||
async function writeHostValue(
|
||||
manifest: PluginManifest,
|
||||
hostId: number,
|
||||
key: string,
|
||||
value: unknown,
|
||||
): Promise<void> {
|
||||
const settings = createCurrentPluginSettingsRepository();
|
||||
if (defaultValuesEqual(value, manifestDefault(manifest, key))) {
|
||||
await settings.delete(manifest.id, "host", String(hostId), key);
|
||||
} else {
|
||||
await settings.set(
|
||||
manifest.id,
|
||||
"host",
|
||||
String(hostId),
|
||||
key,
|
||||
JSON.stringify(value),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function bakeTerminalHosts(
|
||||
manifest: PluginManifest,
|
||||
rows: HostRow[],
|
||||
): Promise<void> {
|
||||
const hosts = unclassified(rows);
|
||||
if (hosts.length === 0) return;
|
||||
const settings = createCurrentPluginSettingsRepository();
|
||||
const lookByUser = await userValues(TERMINAL, "terminalDefaults");
|
||||
const modesByUser = new Map(
|
||||
await Promise.all(
|
||||
Object.keys(TERMINAL_USER_MODES).map(
|
||||
async (key) => [key, await userValues(TERMINAL, key)] as const,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
for (const host of hosts) {
|
||||
const stored = new Map(
|
||||
(await settings.getAll(TERMINAL, "host", String(host.id))).map((row) => [
|
||||
row.key,
|
||||
decode(row.value),
|
||||
]),
|
||||
);
|
||||
if (stored.get("inheritAppearance") !== false) {
|
||||
const look = asObject(lookByUser.get(host.userId)) ?? {};
|
||||
for (const key of TERMINAL_APPEARANCE_KEYS) {
|
||||
const value = look[key] ?? manifestDefault(manifest, key);
|
||||
await writeHostValue(manifest, host.id, key, value);
|
||||
}
|
||||
}
|
||||
for (const [key, fallback] of Object.entries(TERMINAL_USER_MODES)) {
|
||||
const own = stored.get(key);
|
||||
if (own !== undefined && own !== "default") continue;
|
||||
const user = modesByUser.get(key)?.get(host.userId);
|
||||
await writeHostValue(
|
||||
manifest,
|
||||
host.id,
|
||||
key,
|
||||
typeof user === "string" && user !== "default" ? user : fallback,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function bakeDesktopHosts(
|
||||
manifest: PluginManifest,
|
||||
rows: HostRow[],
|
||||
): Promise<void> {
|
||||
const hosts = unclassified(rows);
|
||||
if (hosts.length === 0) return;
|
||||
const byKey = new Map(
|
||||
await Promise.all(
|
||||
DESKTOP_DISPLAY_KEYS.map(
|
||||
async (key) => [key, await userValues(DESKTOP, key)] as const,
|
||||
),
|
||||
),
|
||||
);
|
||||
for (const host of hosts) {
|
||||
for (const key of DESKTOP_DISPLAY_KEYS) {
|
||||
const value = byKey.get(key)?.get(host.userId);
|
||||
if (typeof value !== "string" || value === "inherit") continue;
|
||||
await writeHostValue(manifest, host.id, key, value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** A credential host with no credential of its own used its folder's. */
|
||||
async function bakeFolderCredentials(rows: HostRow[]): Promise<void> {
|
||||
const hosts = unclassified(rows).filter(
|
||||
(row) => row.authType === "credential" && !row.credentialId && row.folder,
|
||||
);
|
||||
if (hosts.length === 0) return;
|
||||
const repository = createCurrentHostDefaultsRepository();
|
||||
const { createCurrentHostResolutionRepository } =
|
||||
await import("../database/repositories/factory.js");
|
||||
const resolution = createCurrentHostResolutionRepository();
|
||||
const patches: Array<{ id: number; values: Partial<HostRow> }> = [];
|
||||
for (const host of hosts) {
|
||||
if (folderChainPaths(host.folder).length === 0) continue;
|
||||
const credentialId = await resolution.findFolderCredentialId(
|
||||
host.userId,
|
||||
host.folder!,
|
||||
);
|
||||
if (credentialId) patches.push({ id: host.id, values: { credentialId } });
|
||||
}
|
||||
await repository.updateHosts(patches);
|
||||
}
|
||||
|
||||
export async function runHostDefaultsMigration(): Promise<void> {
|
||||
const manifests = new Map(
|
||||
hostSettingsPlugins().map((manifest) => [manifest.id, manifest]),
|
||||
);
|
||||
try {
|
||||
await once("core", async () => {
|
||||
await moveCoreLevels();
|
||||
await moveFolderCredentials();
|
||||
});
|
||||
if (manifests.has(TERMINAL)) await once(TERMINAL, moveTerminalLevels);
|
||||
if (manifests.has(METRICS)) await once(METRICS, moveMetricsLevels);
|
||||
if (manifests.has(DESKTOP)) await once(DESKTOP, moveDesktopLevels);
|
||||
|
||||
const rows = await createCurrentHostDefaultsRepository().listHosts({
|
||||
all: true,
|
||||
});
|
||||
await bakeFolderCredentials(rows);
|
||||
const terminal = manifests.get(TERMINAL);
|
||||
if (terminal) await bakeTerminalHosts(terminal, rows);
|
||||
const desktop = manifests.get(DESKTOP);
|
||||
if (desktop) await bakeDesktopHosts(desktop, rows);
|
||||
} catch (error) {
|
||||
databaseLogger.warn("Host defaults migration failed", {
|
||||
operation: "host_defaults_migration",
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user