mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-29 14:45:24 +00:00
feat: give plugins their own settings and one settings screen
This commit is contained in:
1 parent
cc15559ea1
commit
49f0286a14
66 files changed
+36497
-451
No files matched your search
@@ -751,6 +751,21 @@ async function initializeCompleteDatabase(): Promise<void> {
|
||||
FOREIGN KEY (plugin_id) REFERENCES plugins (id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS plugin_settings (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
plugin_id TEXT NOT NULL,
|
||||
scope TEXT NOT NULL,
|
||||
scope_id TEXT,
|
||||
key TEXT NOT NULL,
|
||||
value TEXT,
|
||||
encrypted INTEGER NOT NULL DEFAULT 0,
|
||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE (plugin_id, scope, scope_id, key),
|
||||
FOREIGN KEY (plugin_id) REFERENCES plugins (id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_plugin_settings_plugin_scope ON plugin_settings (plugin_id, scope);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS rbac_known_permissions (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
permission TEXT NOT NULL,
|
||||
|
||||
@@ -2252,6 +2252,49 @@ export const pluginStorage = mysqlTable(
|
||||
],
|
||||
);
|
||||
|
||||
/**
|
||||
* Values for the settings fields a plugin declares in contributes.settings.
|
||||
*
|
||||
* scope_id is polymorphic: null for admin scope, a user id for user scope, a
|
||||
* host id rendered as text for host scope. That is why it carries no foreign
|
||||
* key - one column cannot point at two tables - so the user and host delete
|
||||
* paths remove these rows explicitly. Cascading with the plugin is a real FK,
|
||||
* because uninstalling should leave nothing behind.
|
||||
*
|
||||
* Secret fields are encrypted with the system key before they land here, and
|
||||
* `encrypted` records which rows that applies to so a read knows to decrypt.
|
||||
*/
|
||||
export const pluginSettings = mysqlTable(
|
||||
"plugin_settings",
|
||||
{
|
||||
id: int("id").autoincrement().primaryKey(),
|
||||
pluginId: varchar("plugin_id", { length: 255 })
|
||||
.notNull()
|
||||
.references(() => plugins.id, { onDelete: "cascade" }),
|
||||
/** admin | user | host */
|
||||
scope: varchar("scope", { length: 255 }).notNull(),
|
||||
scopeId: varchar("scope_id", { length: 255 }),
|
||||
key: varchar("key", { length: 255 }).notNull(),
|
||||
/** JSON-encoded, so a field keeps its declared type across a round trip. */
|
||||
value: text("value"),
|
||||
encrypted: boolean("encrypted")
|
||||
.notNull()
|
||||
.default(false),
|
||||
updatedAt: varchar("updated_at", { length: 255 })
|
||||
.notNull()
|
||||
.default(sql`(CURRENT_TIMESTAMP)`),
|
||||
},
|
||||
(table) => [
|
||||
uniqueIndex("idx_plugin_settings_scope_key").on(
|
||||
table.pluginId,
|
||||
table.scope,
|
||||
table.scopeId,
|
||||
table.key,
|
||||
),
|
||||
index("idx_plugin_settings_plugin_scope").on(table.pluginId, table.scope),
|
||||
],
|
||||
);
|
||||
|
||||
/**
|
||||
* Which of a plugin's migrations have been applied.
|
||||
*
|
||||
|
||||
@@ -2253,6 +2253,49 @@ export const pluginStorage = pgTable(
|
||||
],
|
||||
);
|
||||
|
||||
/**
|
||||
* Values for the settings fields a plugin declares in contributes.settings.
|
||||
*
|
||||
* scope_id is polymorphic: null for admin scope, a user id for user scope, a
|
||||
* host id rendered as text for host scope. That is why it carries no foreign
|
||||
* key - one column cannot point at two tables - so the user and host delete
|
||||
* paths remove these rows explicitly. Cascading with the plugin is a real FK,
|
||||
* because uninstalling should leave nothing behind.
|
||||
*
|
||||
* Secret fields are encrypted with the system key before they land here, and
|
||||
* `encrypted` records which rows that applies to so a read knows to decrypt.
|
||||
*/
|
||||
export const pluginSettings = pgTable(
|
||||
"plugin_settings",
|
||||
{
|
||||
id: serial("id").primaryKey(),
|
||||
pluginId: varchar("plugin_id", { length: 255 })
|
||||
.notNull()
|
||||
.references(() => plugins.id, { onDelete: "cascade" }),
|
||||
/** admin | user | host */
|
||||
scope: varchar("scope", { length: 255 }).notNull(),
|
||||
scopeId: varchar("scope_id", { length: 255 }),
|
||||
key: varchar("key", { length: 255 }).notNull(),
|
||||
/** JSON-encoded, so a field keeps its declared type across a round trip. */
|
||||
value: text("value"),
|
||||
encrypted: boolean("encrypted")
|
||||
.notNull()
|
||||
.default(false),
|
||||
updatedAt: varchar("updated_at", { length: 255 })
|
||||
.notNull()
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
},
|
||||
(table) => [
|
||||
uniqueIndex("idx_plugin_settings_scope_key").on(
|
||||
table.pluginId,
|
||||
table.scope,
|
||||
table.scopeId,
|
||||
table.key,
|
||||
),
|
||||
index("idx_plugin_settings_plugin_scope").on(table.pluginId, table.scope),
|
||||
],
|
||||
);
|
||||
|
||||
/**
|
||||
* Which of a plugin's migrations have been applied.
|
||||
*
|
||||
|
||||
@@ -2251,6 +2251,49 @@ export const pluginStorage = sqliteTable(
|
||||
],
|
||||
);
|
||||
|
||||
/**
|
||||
* Values for the settings fields a plugin declares in contributes.settings.
|
||||
*
|
||||
* scope_id is polymorphic: null for admin scope, a user id for user scope, a
|
||||
* host id rendered as text for host scope. That is why it carries no foreign
|
||||
* key - one column cannot point at two tables - so the user and host delete
|
||||
* paths remove these rows explicitly. Cascading with the plugin is a real FK,
|
||||
* because uninstalling should leave nothing behind.
|
||||
*
|
||||
* Secret fields are encrypted with the system key before they land here, and
|
||||
* `encrypted` records which rows that applies to so a read knows to decrypt.
|
||||
*/
|
||||
export const pluginSettings = sqliteTable(
|
||||
"plugin_settings",
|
||||
{
|
||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||
pluginId: text("plugin_id")
|
||||
.notNull()
|
||||
.references(() => plugins.id, { onDelete: "cascade" }),
|
||||
/** admin | user | host */
|
||||
scope: text("scope").notNull(),
|
||||
scopeId: text("scope_id"),
|
||||
key: text("key").notNull(),
|
||||
/** JSON-encoded, so a field keeps its declared type across a round trip. */
|
||||
value: text("value"),
|
||||
encrypted: integer("encrypted", { mode: "boolean" })
|
||||
.notNull()
|
||||
.default(false),
|
||||
updatedAt: text("updated_at")
|
||||
.notNull()
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
},
|
||||
(table) => [
|
||||
uniqueIndex("idx_plugin_settings_scope_key").on(
|
||||
table.pluginId,
|
||||
table.scope,
|
||||
table.scopeId,
|
||||
table.key,
|
||||
),
|
||||
index("idx_plugin_settings_plugin_scope").on(table.pluginId, table.scope),
|
||||
],
|
||||
);
|
||||
|
||||
/**
|
||||
* Which of a plugin's migrations have been applied.
|
||||
*
|
||||
|
||||
@@ -34,6 +34,7 @@ import { OpenTabRepository } from "./open-tab-repository.js";
|
||||
import { OpksshTokenRepository } from "./opkssh-token-repository.js";
|
||||
import { PluginRepository } from "./plugin-repository.js";
|
||||
import { PluginStorageRepository } from "./plugin-storage-repository.js";
|
||||
import { PluginSettingsRepository } from "./plugin-settings-repository.js";
|
||||
import { PluginMigrationRepository } from "./plugin-migration-repository.js";
|
||||
import { PluginPermissionGrantRepository } from "./plugin-permission-grant-repository.js";
|
||||
import { PluginRegistryRepository } from "./plugin-registry-repository.js";
|
||||
@@ -395,6 +396,13 @@ export function createCurrentPluginStorageRepository(): PluginStorageRepository
|
||||
);
|
||||
}
|
||||
|
||||
export function createCurrentPluginSettingsRepository(): PluginSettingsRepository {
|
||||
return new PluginSettingsRepository(
|
||||
createCurrentRepositoryContext(),
|
||||
createCurrentRepositoryWriteHook("plugin_settings_repository_write"),
|
||||
);
|
||||
}
|
||||
|
||||
export function createCurrentPluginMigrationRepository(): PluginMigrationRepository {
|
||||
return new PluginMigrationRepository(
|
||||
createCurrentRepositoryContext(),
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { and, eq, inArray, sql } from "drizzle-orm";
|
||||
import { randomUUID } from "crypto";
|
||||
import { hostAccess, hosts } from "../db/schema.js";
|
||||
import { hostAccess, hosts, pluginSettings } from "../db/schema.js";
|
||||
import type { DatabaseContext } from "./database-context.js";
|
||||
import { DataCrypto } from "../../utils/data-crypto.js";
|
||||
import { rowsAffected } from "./mutation-result.js";
|
||||
@@ -338,6 +338,25 @@ export class HostRepository {
|
||||
}
|
||||
|
||||
async deleteByUserId(userId: string): Promise<number> {
|
||||
// Bulk path, so it never passes through deleteAccessForHost: clear the
|
||||
// host-scope plugin settings for these hosts before the rows go.
|
||||
const owned = await this.context.drizzle
|
||||
.select({ id: hosts.id })
|
||||
.from(hosts)
|
||||
.where(eq(hosts.userId, userId));
|
||||
|
||||
if (owned.length > 0) {
|
||||
await this.context.drizzle.delete(pluginSettings).where(
|
||||
and(
|
||||
eq(pluginSettings.scope, "host"),
|
||||
inArray(
|
||||
pluginSettings.scopeId,
|
||||
owned.map((row) => String(row.id)),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
const result = await this.context.drizzle
|
||||
.delete(hosts)
|
||||
.where(eq(hosts.userId, userId));
|
||||
@@ -350,6 +369,18 @@ export class HostRepository {
|
||||
}
|
||||
|
||||
async deleteAccessForHost(hostId: number): Promise<number> {
|
||||
// plugin_settings.scope_id is polymorphic, so it has no foreign key to
|
||||
// ssh_data and the engine will not cascade it. Cleared alongside the
|
||||
// access rows, which every host delete path already goes through.
|
||||
await this.context.drizzle
|
||||
.delete(pluginSettings)
|
||||
.where(
|
||||
and(
|
||||
eq(pluginSettings.scope, "host"),
|
||||
eq(pluginSettings.scopeId, String(hostId)),
|
||||
),
|
||||
);
|
||||
|
||||
const result = await this.context.drizzle
|
||||
.delete(hostAccess)
|
||||
.where(eq(hostAccess.hostId, hostId));
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
import { and, eq, inArray, isNull } from "drizzle-orm";
|
||||
import { pluginSettings } from "../db/schema.js";
|
||||
import type { DatabaseContext } from "./database-context.js";
|
||||
import { rowsAffected } from "./mutation-result.js";
|
||||
import { insertReturning, updateReturning } from "./returning.js";
|
||||
|
||||
export type PluginSettingsRecord = typeof pluginSettings.$inferSelect;
|
||||
|
||||
export type PluginSettingsScope = "admin" | "user" | "host";
|
||||
|
||||
/**
|
||||
* Values behind ctx.settings and the /plugins/:id/settings routes.
|
||||
*
|
||||
* scope_id is null for admin scope, so every lookup has to use IS NULL rather
|
||||
* than = NULL: SQL equality against null never matches, which would make an
|
||||
* admin setting look unset no matter how often it was written.
|
||||
*/
|
||||
export class PluginSettingsRepository {
|
||||
constructor(
|
||||
private readonly context: DatabaseContext,
|
||||
private readonly onWrite?: () => void | Promise<void>,
|
||||
) {}
|
||||
|
||||
private scopeMatch(
|
||||
pluginId: string,
|
||||
scope: PluginSettingsScope,
|
||||
scopeId: string | null,
|
||||
) {
|
||||
return and(
|
||||
eq(pluginSettings.pluginId, pluginId),
|
||||
eq(pluginSettings.scope, scope),
|
||||
scopeId === null
|
||||
? isNull(pluginSettings.scopeId)
|
||||
: eq(pluginSettings.scopeId, scopeId),
|
||||
);
|
||||
}
|
||||
|
||||
async get(
|
||||
pluginId: string,
|
||||
scope: PluginSettingsScope,
|
||||
scopeId: string | null,
|
||||
key: string,
|
||||
): Promise<PluginSettingsRecord | null> {
|
||||
const rows = await this.context.drizzle
|
||||
.select()
|
||||
.from(pluginSettings)
|
||||
.where(
|
||||
and(
|
||||
this.scopeMatch(pluginId, scope, scopeId),
|
||||
eq(pluginSettings.key, key),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
return rows[0] ?? null;
|
||||
}
|
||||
|
||||
async getAll(
|
||||
pluginId: string,
|
||||
scope: PluginSettingsScope,
|
||||
scopeId: string | null,
|
||||
): Promise<PluginSettingsRecord[]> {
|
||||
return this.context.drizzle
|
||||
.select()
|
||||
.from(pluginSettings)
|
||||
.where(this.scopeMatch(pluginId, scope, scopeId));
|
||||
}
|
||||
|
||||
/**
|
||||
* Every row for a set of scope ids, in one query.
|
||||
*
|
||||
* The host list would otherwise run one query per host, which is the shape
|
||||
* that made other list endpoints slow at a few hundred hosts.
|
||||
*/
|
||||
async getAllForScopeIds(
|
||||
scope: PluginSettingsScope,
|
||||
scopeIds: string[],
|
||||
): Promise<PluginSettingsRecord[]> {
|
||||
if (scopeIds.length === 0) return [];
|
||||
|
||||
return this.context.drizzle
|
||||
.select()
|
||||
.from(pluginSettings)
|
||||
.where(
|
||||
and(
|
||||
eq(pluginSettings.scope, scope),
|
||||
inArray(pluginSettings.scopeId, scopeIds),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
async set(
|
||||
pluginId: string,
|
||||
scope: PluginSettingsScope,
|
||||
scopeId: string | null,
|
||||
key: string,
|
||||
value: string | null,
|
||||
encrypted = false,
|
||||
now = new Date().toISOString(),
|
||||
): Promise<void> {
|
||||
const where = and(
|
||||
this.scopeMatch(pluginId, scope, scopeId),
|
||||
eq(pluginSettings.key, key),
|
||||
)!;
|
||||
|
||||
const existing = await this.context.drizzle
|
||||
.select()
|
||||
.from(pluginSettings)
|
||||
.where(where)
|
||||
.limit(1);
|
||||
|
||||
if (existing[0]) {
|
||||
await updateReturning(
|
||||
this.context,
|
||||
pluginSettings,
|
||||
{ value, encrypted, updatedAt: now },
|
||||
where,
|
||||
);
|
||||
} else {
|
||||
await insertReturning(this.context, pluginSettings, {
|
||||
pluginId,
|
||||
scope,
|
||||
scopeId,
|
||||
key,
|
||||
value,
|
||||
encrypted,
|
||||
updatedAt: now,
|
||||
});
|
||||
}
|
||||
|
||||
await this.afterWrite();
|
||||
}
|
||||
|
||||
async delete(
|
||||
pluginId: string,
|
||||
scope: PluginSettingsScope,
|
||||
scopeId: string | null,
|
||||
key: string,
|
||||
): Promise<boolean> {
|
||||
const result = await this.context.drizzle
|
||||
.delete(pluginSettings)
|
||||
.where(
|
||||
and(
|
||||
this.scopeMatch(pluginId, scope, scopeId),
|
||||
eq(pluginSettings.key, key),
|
||||
),
|
||||
);
|
||||
|
||||
const affected = rowsAffected(result) > 0;
|
||||
if (affected) await this.afterWrite();
|
||||
return affected;
|
||||
}
|
||||
|
||||
async deleteByPlugin(pluginId: string): Promise<number> {
|
||||
const result = await this.context.drizzle
|
||||
.delete(pluginSettings)
|
||||
.where(eq(pluginSettings.pluginId, pluginId));
|
||||
|
||||
const affected = rowsAffected(result);
|
||||
if (affected > 0) await this.afterWrite();
|
||||
return affected;
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes every plugin's rows for one user or host.
|
||||
*
|
||||
* scope_id carries no foreign key, so the user and host delete paths call
|
||||
* this instead of relying on the engine to cascade.
|
||||
*/
|
||||
async deleteByScope(
|
||||
scope: PluginSettingsScope,
|
||||
scopeId: string,
|
||||
): Promise<number> {
|
||||
const result = await this.context.drizzle
|
||||
.delete(pluginSettings)
|
||||
.where(
|
||||
and(
|
||||
eq(pluginSettings.scope, scope),
|
||||
eq(pluginSettings.scopeId, scopeId),
|
||||
),
|
||||
);
|
||||
|
||||
const affected = rowsAffected(result);
|
||||
if (affected > 0) await this.afterWrite();
|
||||
return affected;
|
||||
}
|
||||
|
||||
private async afterWrite(): Promise<void> {
|
||||
await this.onWrite?.();
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { asc, eq, inArray, like, sql } from "drizzle-orm";
|
||||
import { users } from "../db/schema.js";
|
||||
import { and, asc, eq, inArray, like, sql } from "drizzle-orm";
|
||||
import { pluginSettings, users } from "../db/schema.js";
|
||||
import type { DatabaseContext } from "./database-context.js";
|
||||
import {
|
||||
countValue,
|
||||
@@ -199,6 +199,15 @@ export class UserRepository {
|
||||
}
|
||||
|
||||
async delete(id: string): Promise<boolean> {
|
||||
// plugin_settings.scope_id is polymorphic, so it carries no foreign key to
|
||||
// users and the engine will not cascade it. Removed here rather than in a
|
||||
// route so every caller that deletes a user is covered.
|
||||
await this.context.drizzle
|
||||
.delete(pluginSettings)
|
||||
.where(
|
||||
and(eq(pluginSettings.scope, "user"), eq(pluginSettings.scopeId, id)),
|
||||
);
|
||||
|
||||
const result = await this.context.drizzle
|
||||
.delete(users)
|
||||
.where(eq(users.id, id));
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
/**
|
||||
* The pluginSettings map attached to a host on its way out.
|
||||
*
|
||||
* Without this the host editor would need one request per plugin per host,
|
||||
* which is the shape that makes a list of a few hundred hosts slow. One query
|
||||
* covers the whole list instead.
|
||||
*
|
||||
* Kept out of transformHostResponse because that function is synchronous and
|
||||
* shared by every host read path. Secrets are always redacted here, so nothing
|
||||
* on this path has to decrypt and the async work stays in one place.
|
||||
*/
|
||||
|
||||
import type { PluginManifest } from "@termix/plugin-sdk/manifest";
|
||||
import { createCurrentPluginSettingsRepository } from "../repositories/factory.js";
|
||||
import type { PluginSettingsRecord } from "../repositories/plugin-settings-repository.js";
|
||||
import { declaredFields, resolveFieldValue } from "../../plugins/settings.js";
|
||||
import { getPluginRuntime } from "../../plugins/index.js";
|
||||
import { sshLogger } from "../../utils/logger.js";
|
||||
|
||||
export type HostPluginSettings = Record<string, Record<string, unknown>>;
|
||||
|
||||
/** Enabled plugins that declare host-scope settings, with their manifests. */
|
||||
function hostSettingsPlugins(): PluginManifest[] {
|
||||
try {
|
||||
const { loader } = getPluginRuntime();
|
||||
return loader
|
||||
.list()
|
||||
.filter((plugin) => plugin.state === "active")
|
||||
.map((plugin) => plugin.manifest)
|
||||
.filter(
|
||||
(manifest): manifest is PluginManifest =>
|
||||
!!manifest && !!manifest.contributes?.settings?.host,
|
||||
);
|
||||
} catch {
|
||||
// The runtime is not up in every context that renders a host.
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the map for a set of hosts in one query.
|
||||
*
|
||||
* Returns an empty map when no enabled plugin declares host settings, so the
|
||||
* common case costs nothing.
|
||||
*/
|
||||
export async function loadHostPluginSettings(
|
||||
hostIds: number[],
|
||||
): Promise<Map<number, HostPluginSettings>> {
|
||||
const result = new Map<number, HostPluginSettings>();
|
||||
const manifests = hostSettingsPlugins();
|
||||
if (manifests.length === 0 || hostIds.length === 0) return result;
|
||||
|
||||
let rows: PluginSettingsRecord[] = [];
|
||||
try {
|
||||
rows = await createCurrentPluginSettingsRepository().getAllForScopeIds(
|
||||
"host",
|
||||
hostIds.map(String),
|
||||
);
|
||||
} catch (error) {
|
||||
// A host list must not fail because a settings read did.
|
||||
sshLogger.warn("Failed to load host plugin settings", {
|
||||
operation: "host_plugin_settings",
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
const byHostAndPlugin = new Map<string, PluginSettingsRecord[]>();
|
||||
for (const row of rows) {
|
||||
const key = `${row.scopeId}:${row.pluginId}`;
|
||||
const list = byHostAndPlugin.get(key);
|
||||
if (list) list.push(row);
|
||||
else byHostAndPlugin.set(key, [row]);
|
||||
}
|
||||
|
||||
for (const hostId of hostIds) {
|
||||
const perPlugin: HostPluginSettings = {};
|
||||
|
||||
for (const manifest of manifests) {
|
||||
const fields = declaredFields(manifest, "host");
|
||||
if (fields.length === 0) continue;
|
||||
|
||||
const stored = new Map(
|
||||
(byHostAndPlugin.get(`${hostId}:${manifest.id}`) ?? []).map((row) => [
|
||||
row.key,
|
||||
row,
|
||||
]),
|
||||
);
|
||||
|
||||
const values: Record<string, unknown> = {};
|
||||
for (const field of fields) {
|
||||
values[field.key] = await resolveFieldValue(
|
||||
field,
|
||||
stored.get(field.key),
|
||||
{ redactSecrets: true },
|
||||
);
|
||||
}
|
||||
perPlugin[manifest.id] = values;
|
||||
}
|
||||
|
||||
if (Object.keys(perPlugin).length > 0) result.set(hostId, perPlugin);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/** Attaches the map to already-transformed host objects, in place. */
|
||||
export function attachHostPluginSettings(
|
||||
hosts: Record<string, unknown>[],
|
||||
settings: Map<number, HostPluginSettings>,
|
||||
): void {
|
||||
if (settings.size === 0) return;
|
||||
for (const host of hosts) {
|
||||
const hostId = Number(host.id);
|
||||
const values = settings.get(hostId);
|
||||
if (values) host.pluginSettings = values;
|
||||
}
|
||||
}
|
||||
|
||||
/** The single-host convenience wrapper. */
|
||||
export async function withHostPluginSettings(
|
||||
host: Record<string, unknown>,
|
||||
): Promise<Record<string, unknown>> {
|
||||
const hostId = Number(host.id);
|
||||
if (!Number.isInteger(hostId)) return host;
|
||||
|
||||
const settings = await loadHostPluginSettings([hostId]);
|
||||
const values = settings.get(hostId);
|
||||
return values ? { ...host, pluginSettings: values } : host;
|
||||
}
|
||||
@@ -45,6 +45,11 @@ import {
|
||||
stripSensitiveFields,
|
||||
transformHostResponse,
|
||||
} from "./host-normalizers.js";
|
||||
import {
|
||||
attachHostPluginSettings,
|
||||
loadHostPluginSettings,
|
||||
withHostPluginSettings,
|
||||
} from "./host-plugin-settings.js";
|
||||
import { validateParentHostId } from "./host-parent-validation.js";
|
||||
import { registerHostOpksshRoutes } from "./host-opkssh-routes.js";
|
||||
import { registerHostStepCaRoutes } from "./host-step-ca-routes.js";
|
||||
@@ -1701,6 +1706,18 @@ router.get(
|
||||
)
|
||||
: stripSensitiveFields(host),
|
||||
);
|
||||
|
||||
// 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)),
|
||||
),
|
||||
);
|
||||
|
||||
res.json(sanitized);
|
||||
} catch (err) {
|
||||
sshLogger.error("Failed to fetch SSH hosts from database", err, {
|
||||
@@ -1767,7 +1784,9 @@ router.get(
|
||||
const resolved =
|
||||
(await resolveHostCredentials(result, userId)) || result;
|
||||
|
||||
return res.json(stripSensitiveFields(resolved));
|
||||
return res.json(
|
||||
await withHostPluginSettings(stripSensitiveFields(resolved)),
|
||||
);
|
||||
}
|
||||
|
||||
// Not the owner: shared recipients get a sanitized view of the host.
|
||||
@@ -1818,9 +1837,11 @@ router.get(
|
||||
(await resolveHostCredentials(sharedResult, userId)) || sharedResult;
|
||||
|
||||
res.json(
|
||||
sanitizeHostForRecipient(
|
||||
resolvedSharedResult,
|
||||
accessInfo.permissionLevel,
|
||||
await withHostPluginSettings(
|
||||
sanitizeHostForRecipient(
|
||||
resolvedSharedResult,
|
||||
accessInfo.permissionLevel,
|
||||
),
|
||||
),
|
||||
);
|
||||
} catch (err) {
|
||||
|
||||
@@ -14,9 +14,29 @@ import {
|
||||
} from "../repositories/factory.js";
|
||||
import { getPluginRuntime } from "../../plugins/index.js";
|
||||
import { invalidatePluginPermissionCache } from "../../plugins/permissions.js";
|
||||
import {
|
||||
findField,
|
||||
getAllSettings,
|
||||
setSetting,
|
||||
type PluginSettingsScope,
|
||||
} from "../../plugins/settings.js";
|
||||
import {
|
||||
getAuditUsername,
|
||||
getRequestMeta,
|
||||
logAudit,
|
||||
} from "../../utils/audit-logger.js";
|
||||
import type {
|
||||
PluginManifest,
|
||||
PluginSettingsField,
|
||||
} from "@termix/plugin-sdk/manifest";
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
/** A settings body is a flat object; an array or null is a client bug. */
|
||||
function isPlainBody(body: unknown): body is Record<string, unknown> {
|
||||
return typeof body === "object" && body !== null && !Array.isArray(body);
|
||||
}
|
||||
|
||||
const authManager = AuthManager.getInstance();
|
||||
const authenticateJWT = authManager.createAuthMiddleware();
|
||||
const permissionManager = PermissionManager.getInstance();
|
||||
@@ -79,15 +99,18 @@ router.get("/", authenticateJWT, async (req: Request, res: Response) => {
|
||||
|
||||
let contributes: unknown = null;
|
||||
let capabilities: string[] = [];
|
||||
let icon: string | undefined;
|
||||
try {
|
||||
const manifest = JSON.parse(record.manifestJson) as {
|
||||
contributes?: unknown;
|
||||
capabilities?: unknown;
|
||||
icon?: unknown;
|
||||
};
|
||||
contributes = manifest?.contributes ?? null;
|
||||
capabilities = Array.isArray(manifest?.capabilities)
|
||||
? (manifest.capabilities as string[])
|
||||
: [];
|
||||
icon = typeof manifest?.icon === "string" ? manifest.icon : undefined;
|
||||
} catch {
|
||||
contributes = null;
|
||||
}
|
||||
@@ -99,6 +122,7 @@ router.get("/", authenticateJWT, async (req: Request, res: Response) => {
|
||||
enabled: record.state === "enabled",
|
||||
state: loaded?.state ?? record.state,
|
||||
contributes,
|
||||
icon,
|
||||
};
|
||||
|
||||
if (!canManage) return summary;
|
||||
@@ -528,4 +552,535 @@ router.delete(
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* Loads a plugin's manifest for the settings routes.
|
||||
*
|
||||
* Read from the loader rather than the database row so the fields a request is
|
||||
* validated against are the ones the running build declares.
|
||||
*/
|
||||
async function loadManifestForSettings(
|
||||
pluginId: string,
|
||||
): Promise<PluginManifest | null> {
|
||||
const { loader } = getPluginRuntime();
|
||||
const live = loader.get(pluginId);
|
||||
if (live?.manifest) return live.manifest;
|
||||
|
||||
const record = await createCurrentPluginRepository().findById(pluginId);
|
||||
if (!record) return null;
|
||||
try {
|
||||
return JSON.parse(record.manifestJson) as PluginManifest;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the caller may write a given field.
|
||||
*
|
||||
* Admin fields fall back to admin.plugins.manage, so a plugin that declares no
|
||||
* permission of its own still cannot have its install-wide settings changed by
|
||||
* an ordinary user. A field naming its own permission uses that instead.
|
||||
*/
|
||||
async function canWriteField(
|
||||
userId: string,
|
||||
manifest: PluginManifest,
|
||||
scope: PluginSettingsScope,
|
||||
field: PluginSettingsField,
|
||||
): Promise<boolean> {
|
||||
// Admin scope always needs admin.plugins.manage. A field-level permission
|
||||
// narrows who may write it; it never widens the scope's own gate, so a
|
||||
// plugin cannot hand an ordinary user install-wide configuration by naming
|
||||
// a permission that user happens to hold.
|
||||
if (
|
||||
scope === "admin" &&
|
||||
!(await permissionManager.hasPermission(userId, "admin.plugins.manage"))
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (field.permission) {
|
||||
const { resolvePermission } = await import("../../plugins/rbac.js");
|
||||
return permissionManager.hasPermission(
|
||||
userId,
|
||||
resolvePermission(manifest, field.permission),
|
||||
);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Applies a body of values to one scope, collecting per-field errors. */
|
||||
async function applySettings(
|
||||
userId: string,
|
||||
manifest: PluginManifest,
|
||||
scope: PluginSettingsScope,
|
||||
scopeId: string | null,
|
||||
body: Record<string, unknown>,
|
||||
): Promise<Record<string, string>> {
|
||||
const errors: Record<string, string> = {};
|
||||
|
||||
for (const [key, value] of Object.entries(body)) {
|
||||
const field = findField(manifest, scope, key);
|
||||
if (!field) {
|
||||
errors[key] = "Not a settings field this plugin declares";
|
||||
continue;
|
||||
}
|
||||
if (!(await canWriteField(userId, manifest, scope, field))) {
|
||||
errors[key] = "You do not have permission to change this setting";
|
||||
continue;
|
||||
}
|
||||
|
||||
const error = await setSetting(manifest, scope, scopeId, key, value);
|
||||
if (error) errors[key] = error;
|
||||
}
|
||||
|
||||
return errors;
|
||||
}
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /plugins/{id}/settings/admin:
|
||||
* get:
|
||||
* summary: Read a plugin's install-wide settings
|
||||
* description: >
|
||||
* Returns every admin-scope field the plugin declares, with stored values
|
||||
* over declared defaults. Secret fields come back as { set: boolean } and
|
||||
* never carry their value, so a browser can render "configured" without
|
||||
* ever holding the secret.
|
||||
* tags:
|
||||
* - Plugins
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: id
|
||||
* required: true
|
||||
* schema:
|
||||
* type: string
|
||||
* responses:
|
||||
* 200:
|
||||
* description: The plugin's admin settings.
|
||||
* 403:
|
||||
* description: The caller lacks admin.plugins.manage.
|
||||
* 404:
|
||||
* description: No such plugin.
|
||||
*/
|
||||
router.get(
|
||||
"/:id/settings/admin",
|
||||
authenticateJWT,
|
||||
requireManagePlugins,
|
||||
async (req: Request, res: Response) => {
|
||||
const pluginId = String(req.params.id);
|
||||
try {
|
||||
const manifest = await loadManifestForSettings(pluginId);
|
||||
if (!manifest) {
|
||||
res.status(404).json({ error: "No such plugin" });
|
||||
return;
|
||||
}
|
||||
const values = await getAllSettings(manifest, "admin", null, {
|
||||
redactSecrets: true,
|
||||
});
|
||||
res.json({ id: pluginId, values });
|
||||
} catch (error) {
|
||||
databaseLogger.error(
|
||||
`Failed to read admin settings for plugin ${pluginId}`,
|
||||
error instanceof Error ? error : new Error(String(error)),
|
||||
{ operation: "plugin_settings_read" },
|
||||
);
|
||||
res.status(500).json({ error: "Failed to read the plugin's settings" });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /plugins/{id}/settings/admin:
|
||||
* put:
|
||||
* summary: Update a plugin's install-wide settings
|
||||
* description: >
|
||||
* Writes the given keys. Every value is validated against the field the
|
||||
* manifest declares, and a key the manifest never declared is rejected
|
||||
* rather than stored. Validation failures come back per field so a form
|
||||
* can show every problem at once. Sending a secret field back as
|
||||
* { set: true } leaves the stored value alone, so saving a form does not
|
||||
* clear a key it was never shown.
|
||||
* tags:
|
||||
* - Plugins
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: id
|
||||
* required: true
|
||||
* schema:
|
||||
* type: string
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* responses:
|
||||
* 200:
|
||||
* description: The updated settings.
|
||||
* 400:
|
||||
* description: One or more fields were rejected.
|
||||
* 403:
|
||||
* description: The caller lacks admin.plugins.manage.
|
||||
* 404:
|
||||
* description: No such plugin.
|
||||
*/
|
||||
router.put(
|
||||
"/:id/settings/admin",
|
||||
authenticateJWT,
|
||||
requireManagePlugins,
|
||||
async (req: Request, res: Response) => {
|
||||
const pluginId = String(req.params.id);
|
||||
const userId = (req as AuthenticatedRequest).userId as string;
|
||||
|
||||
if (!isPlainBody(req.body)) {
|
||||
res.status(400).json({ error: "Body must be an object of settings" });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const manifest = await loadManifestForSettings(pluginId);
|
||||
if (!manifest) {
|
||||
res.status(404).json({ error: "No such plugin" });
|
||||
return;
|
||||
}
|
||||
|
||||
const errors = await applySettings(
|
||||
userId,
|
||||
manifest,
|
||||
"admin",
|
||||
null,
|
||||
req.body,
|
||||
);
|
||||
if (Object.keys(errors).length > 0) {
|
||||
res.status(400).json({ error: "Some settings were rejected", errors });
|
||||
return;
|
||||
}
|
||||
|
||||
const { ipAddress, userAgent } = getRequestMeta(req);
|
||||
await logAudit({
|
||||
userId,
|
||||
username: await getAuditUsername(userId),
|
||||
action: "update_plugin_settings",
|
||||
resourceType: "setting",
|
||||
resourceId: pluginId,
|
||||
resourceName: manifest.name,
|
||||
// Keys only: a value here could be the secret we just encrypted.
|
||||
details: JSON.stringify({
|
||||
scope: "admin",
|
||||
keys: Object.keys(req.body),
|
||||
}),
|
||||
ipAddress,
|
||||
userAgent,
|
||||
success: true,
|
||||
});
|
||||
|
||||
const values = await getAllSettings(manifest, "admin", null, {
|
||||
redactSecrets: true,
|
||||
});
|
||||
res.json({ id: pluginId, values });
|
||||
} catch (error) {
|
||||
databaseLogger.error(
|
||||
`Failed to update admin settings for plugin ${pluginId}`,
|
||||
error instanceof Error ? error : new Error(String(error)),
|
||||
{ operation: "plugin_settings_write" },
|
||||
);
|
||||
res.status(500).json({ error: "Failed to update the plugin's settings" });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /plugins/{id}/settings/user:
|
||||
* get:
|
||||
* summary: Read the caller's own settings for a plugin
|
||||
* description: Returns every user-scope field the plugin declares for the authenticated user. The scope is always the caller, never a user id from the request.
|
||||
* tags:
|
||||
* - Plugins
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: id
|
||||
* required: true
|
||||
* schema:
|
||||
* type: string
|
||||
* responses:
|
||||
* 200:
|
||||
* description: The caller's settings for this plugin.
|
||||
* 404:
|
||||
* description: No such plugin.
|
||||
*/
|
||||
router.get(
|
||||
"/:id/settings/user",
|
||||
authenticateJWT,
|
||||
async (req: Request, res: Response) => {
|
||||
const pluginId = String(req.params.id);
|
||||
const userId = (req as AuthenticatedRequest).userId as string;
|
||||
|
||||
try {
|
||||
const manifest = await loadManifestForSettings(pluginId);
|
||||
if (!manifest) {
|
||||
res.status(404).json({ error: "No such plugin" });
|
||||
return;
|
||||
}
|
||||
const values = await getAllSettings(manifest, "user", userId, {
|
||||
redactSecrets: true,
|
||||
});
|
||||
res.json({ id: pluginId, values });
|
||||
} catch (error) {
|
||||
databaseLogger.error(
|
||||
`Failed to read user settings for plugin ${pluginId}`,
|
||||
error instanceof Error ? error : new Error(String(error)),
|
||||
{ operation: "plugin_settings_read" },
|
||||
);
|
||||
res.status(500).json({ error: "Failed to read the plugin's settings" });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /plugins/{id}/settings/user:
|
||||
* put:
|
||||
* summary: Update the caller's own settings for a plugin
|
||||
* description: Writes user-scope values for the authenticated user. The scope is always the caller, so one user can never write another's settings.
|
||||
* tags:
|
||||
* - Plugins
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: id
|
||||
* required: true
|
||||
* schema:
|
||||
* type: string
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* responses:
|
||||
* 200:
|
||||
* description: The updated settings.
|
||||
* 400:
|
||||
* description: One or more fields were rejected.
|
||||
* 404:
|
||||
* description: No such plugin.
|
||||
*/
|
||||
router.put(
|
||||
"/:id/settings/user",
|
||||
authenticateJWT,
|
||||
async (req: Request, res: Response) => {
|
||||
const pluginId = String(req.params.id);
|
||||
const userId = (req as AuthenticatedRequest).userId as string;
|
||||
|
||||
if (!isPlainBody(req.body)) {
|
||||
res.status(400).json({ error: "Body must be an object of settings" });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const manifest = await loadManifestForSettings(pluginId);
|
||||
if (!manifest) {
|
||||
res.status(404).json({ error: "No such plugin" });
|
||||
return;
|
||||
}
|
||||
|
||||
const errors = await applySettings(
|
||||
userId,
|
||||
manifest,
|
||||
"user",
|
||||
userId,
|
||||
req.body,
|
||||
);
|
||||
if (Object.keys(errors).length > 0) {
|
||||
res.status(400).json({ error: "Some settings were rejected", errors });
|
||||
return;
|
||||
}
|
||||
|
||||
const values = await getAllSettings(manifest, "user", userId, {
|
||||
redactSecrets: true,
|
||||
});
|
||||
res.json({ id: pluginId, values });
|
||||
} catch (error) {
|
||||
databaseLogger.error(
|
||||
`Failed to update user settings for plugin ${pluginId}`,
|
||||
error instanceof Error ? error : new Error(String(error)),
|
||||
{ operation: "plugin_settings_write" },
|
||||
);
|
||||
res.status(500).json({ error: "Failed to update the plugin's settings" });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /plugins/{id}/settings/host/{hostId}:
|
||||
* get:
|
||||
* summary: Read a plugin's settings for one host
|
||||
* description: Returns every host-scope field the plugin declares for that host. Requires edit access to the host, the same check the host editor itself uses.
|
||||
* tags:
|
||||
* - Plugins
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: id
|
||||
* required: true
|
||||
* schema:
|
||||
* type: string
|
||||
* - in: path
|
||||
* name: hostId
|
||||
* required: true
|
||||
* schema:
|
||||
* type: integer
|
||||
* responses:
|
||||
* 200:
|
||||
* description: The plugin's settings for that host.
|
||||
* 400:
|
||||
* description: Invalid host id.
|
||||
* 403:
|
||||
* description: No edit access to that host.
|
||||
* 404:
|
||||
* description: No such plugin.
|
||||
*/
|
||||
router.get(
|
||||
"/:id/settings/host/:hostId",
|
||||
authenticateJWT,
|
||||
async (req: Request, res: Response) => {
|
||||
const pluginId = String(req.params.id);
|
||||
const userId = (req as AuthenticatedRequest).userId as string;
|
||||
const hostId = Number(req.params.hostId);
|
||||
|
||||
if (!Number.isInteger(hostId) || hostId <= 0) {
|
||||
res.status(400).json({ error: "Invalid host ID" });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const manifest = await loadManifestForSettings(pluginId);
|
||||
if (!manifest) {
|
||||
res.status(404).json({ error: "No such plugin" });
|
||||
return;
|
||||
}
|
||||
|
||||
const access = await permissionManager.canAccessHost(
|
||||
userId,
|
||||
hostId,
|
||||
"edit",
|
||||
);
|
||||
if (!access.hasAccess) {
|
||||
res.status(403).json({ error: "Access denied to host" });
|
||||
return;
|
||||
}
|
||||
|
||||
const values = await getAllSettings(manifest, "host", hostId, {
|
||||
redactSecrets: true,
|
||||
});
|
||||
res.json({ id: pluginId, hostId, values });
|
||||
} catch (error) {
|
||||
databaseLogger.error(
|
||||
`Failed to read host settings for plugin ${pluginId}`,
|
||||
error instanceof Error ? error : new Error(String(error)),
|
||||
{ operation: "plugin_settings_read" },
|
||||
);
|
||||
res.status(500).json({ error: "Failed to read the plugin's settings" });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /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.
|
||||
* tags:
|
||||
* - Plugins
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: id
|
||||
* required: true
|
||||
* schema:
|
||||
* type: string
|
||||
* - in: path
|
||||
* name: hostId
|
||||
* required: true
|
||||
* schema:
|
||||
* type: integer
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* responses:
|
||||
* 200:
|
||||
* description: The updated settings.
|
||||
* 400:
|
||||
* description: Invalid host id, or one or more fields were rejected.
|
||||
* 403:
|
||||
* description: No edit access to that host.
|
||||
* 404:
|
||||
* description: No such plugin.
|
||||
*/
|
||||
router.put(
|
||||
"/:id/settings/host/:hostId",
|
||||
authenticateJWT,
|
||||
async (req: Request, res: Response) => {
|
||||
const pluginId = String(req.params.id);
|
||||
const userId = (req as AuthenticatedRequest).userId as string;
|
||||
const hostId = Number(req.params.hostId);
|
||||
|
||||
if (!Number.isInteger(hostId) || hostId <= 0) {
|
||||
res.status(400).json({ error: "Invalid host ID" });
|
||||
return;
|
||||
}
|
||||
if (!isPlainBody(req.body)) {
|
||||
res.status(400).json({ error: "Body must be an object of settings" });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const manifest = await loadManifestForSettings(pluginId);
|
||||
if (!manifest) {
|
||||
res.status(404).json({ error: "No such plugin" });
|
||||
return;
|
||||
}
|
||||
|
||||
const access = await permissionManager.canAccessHost(
|
||||
userId,
|
||||
hostId,
|
||||
"edit",
|
||||
);
|
||||
if (!access.hasAccess) {
|
||||
res.status(403).json({ error: "Access denied to host" });
|
||||
return;
|
||||
}
|
||||
|
||||
const errors = await applySettings(
|
||||
userId,
|
||||
manifest,
|
||||
"host",
|
||||
String(hostId),
|
||||
req.body,
|
||||
);
|
||||
if (Object.keys(errors).length > 0) {
|
||||
res.status(400).json({ error: "Some settings were rejected", errors });
|
||||
return;
|
||||
}
|
||||
|
||||
const values = await getAllSettings(manifest, "host", hostId, {
|
||||
redactSecrets: true,
|
||||
});
|
||||
res.json({ id: pluginId, hostId, values });
|
||||
} catch (error) {
|
||||
databaseLogger.error(
|
||||
`Failed to update host settings for plugin ${pluginId}`,
|
||||
error instanceof Error ? error : new Error(String(error)),
|
||||
{ operation: "plugin_settings_write" },
|
||||
);
|
||||
res.status(500).json({ error: "Failed to update the plugin's settings" });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
export default router;
|
||||
@@ -338,120 +338,6 @@ export function registerUserSettingsRoutes(
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /users/tailscale-settings:
|
||||
* get:
|
||||
* summary: Get Tailscale settings
|
||||
* description: Returns whether a Tailscale API key is configured (value is masked).
|
||||
* tags:
|
||||
* - Users
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Tailscale settings.
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* apiKey:
|
||||
* type: string
|
||||
* description: Masked API key or empty string if not set.
|
||||
* hasApiKey:
|
||||
* type: boolean
|
||||
* apiBaseUrl:
|
||||
* type: string
|
||||
* description: Custom control-plane API base URL (e.g. a Headscale instance), or empty string for the Tailscale default.
|
||||
*/
|
||||
router.get("/tailscale-settings", authenticateJWT, async (_req, res) => {
|
||||
try {
|
||||
const settingsRepo = createCurrentSettingsRepository();
|
||||
const apiKey = (await settingsRepo.get("tailscale_api_key")) ?? "";
|
||||
const apiBaseUrl =
|
||||
(await settingsRepo.get("tailscale_api_base_url")) ?? "";
|
||||
res.json({
|
||||
apiKey: apiKey
|
||||
? `${apiKey.slice(0, 6)}${"*".repeat(Math.max(0, apiKey.length - 6))}`
|
||||
: "",
|
||||
hasApiKey: !!apiKey,
|
||||
apiBaseUrl,
|
||||
});
|
||||
} catch (err) {
|
||||
authLogger.error("Failed to get Tailscale settings", err);
|
||||
res.status(500).json({ error: "Failed to get Tailscale settings" });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /users/tailscale-settings:
|
||||
* patch:
|
||||
* summary: Update Tailscale settings (admin only)
|
||||
* description: Saves or clears the Tailscale API key used for device discovery.
|
||||
* tags:
|
||||
* - Users
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* apiKey:
|
||||
* type: string
|
||||
* apiBaseUrl:
|
||||
* type: string
|
||||
* description: Optional custom control-plane API base URL (e.g. a Headscale instance). Leave empty to use the Tailscale default.
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Tailscale settings updated.
|
||||
* 403:
|
||||
* description: Not authorized.
|
||||
* 500:
|
||||
* description: Failed to update Tailscale settings.
|
||||
*/
|
||||
router.patch("/tailscale-settings", 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 { apiKey, apiBaseUrl } = req.body;
|
||||
if (typeof apiKey !== "string") {
|
||||
return res.status(400).json({ error: "apiKey must be a string" });
|
||||
}
|
||||
if (apiBaseUrl !== undefined && typeof apiBaseUrl !== "string") {
|
||||
return res.status(400).json({ error: "apiBaseUrl must be a string" });
|
||||
}
|
||||
const settingsRepo = createCurrentSettingsRepository();
|
||||
await settingsRepo.set("tailscale_api_key", apiKey);
|
||||
if (apiBaseUrl !== undefined) {
|
||||
await settingsRepo.set(
|
||||
"tailscale_api_base_url",
|
||||
apiBaseUrl.trim().replace(/\/+$/, ""),
|
||||
);
|
||||
}
|
||||
|
||||
const { ipAddress, userAgent } = getRequestMeta(req);
|
||||
await logAudit({
|
||||
userId,
|
||||
username: actor.username ?? userId,
|
||||
action: "update_tailscale_settings",
|
||||
resourceType: "setting",
|
||||
details: JSON.stringify({ hasApiKey: !!apiKey }),
|
||||
ipAddress,
|
||||
userAgent,
|
||||
success: true,
|
||||
});
|
||||
|
||||
res.json({ hasApiKey: !!apiKey });
|
||||
} catch (err) {
|
||||
authLogger.error("Failed to update Tailscale settings", err);
|
||||
res.status(500).json({ error: "Failed to update Tailscale settings" });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /users/command-history-enabled:
|
||||
|
||||
@@ -36,6 +36,7 @@ import {
|
||||
} from "./http.js";
|
||||
import { registerPluginWsRoute, registerPluginWsUpgrade } from "./ws.js";
|
||||
import { resolvePermission } from "./rbac.js";
|
||||
import * as pluginSettings from "./settings.js";
|
||||
import type { PluginManifest } from "@termix/plugin-sdk/manifest";
|
||||
import {
|
||||
PluginCapabilityError,
|
||||
@@ -257,6 +258,18 @@ export function createPluginContext(
|
||||
{ action: "db_client" },
|
||||
);
|
||||
|
||||
// The one settings call that leaves the plugin's own namespace, so the one
|
||||
// that needs a capability and an audit line.
|
||||
const settingsReadCore = guarded(
|
||||
manifest,
|
||||
"settings:read-core",
|
||||
async (key: string) => pluginSettings.readCoreSetting(key),
|
||||
{
|
||||
action: "settings_read_core",
|
||||
details: () => "read a core server setting",
|
||||
},
|
||||
);
|
||||
|
||||
const dbRefs = guarded(
|
||||
manifest,
|
||||
"db:own",
|
||||
@@ -474,6 +487,66 @@ export function createPluginContext(
|
||||
},
|
||||
},
|
||||
|
||||
settings: {
|
||||
// Reading and writing a plugin's OWN settings is ungated. The manifest
|
||||
// already declares every field, and a plugin that had to ask permission
|
||||
// to read its own configuration would be useless. Only readCore, which
|
||||
// reaches outside the plugin's namespace, is a capability.
|
||||
get: (key) =>
|
||||
pluginSettings.getSetting(manifest, "admin", null, key) as never,
|
||||
set: async (key, value) => {
|
||||
const error = await pluginSettings.setSetting(
|
||||
manifest,
|
||||
"admin",
|
||||
null,
|
||||
key,
|
||||
value,
|
||||
);
|
||||
if (error) throw new Error(error);
|
||||
},
|
||||
|
||||
getUser: (userId, key) =>
|
||||
pluginSettings.getSetting(manifest, "user", userId, key) as never,
|
||||
setUser: async (userId, key, value) => {
|
||||
const error = await pluginSettings.setSetting(
|
||||
manifest,
|
||||
"user",
|
||||
userId,
|
||||
key,
|
||||
value,
|
||||
);
|
||||
if (error) throw new Error(error);
|
||||
},
|
||||
|
||||
getHost: (hostId, key) =>
|
||||
pluginSettings.getSetting(manifest, "host", hostId, key) as never,
|
||||
setHost: async (hostId, key, value) => {
|
||||
const error = await pluginSettings.setSetting(
|
||||
manifest,
|
||||
"host",
|
||||
hostId,
|
||||
key,
|
||||
value,
|
||||
);
|
||||
if (error) throw new Error(error);
|
||||
},
|
||||
|
||||
getAll: (scope, scopeId) =>
|
||||
pluginSettings.getAllSettings(manifest, scope, scopeId ?? null),
|
||||
|
||||
onChange: (key, listener) => {
|
||||
const unsubscribe = pluginSettings.onSettingsChange(
|
||||
pluginId,
|
||||
key,
|
||||
listener,
|
||||
);
|
||||
handle.bag.add(unsubscribe, `settings listener for "${key}"`);
|
||||
return unsubscribe;
|
||||
},
|
||||
|
||||
readCore: (key) => settingsReadCore(key),
|
||||
},
|
||||
|
||||
rbac: {
|
||||
// A short name takes this plugin's prefix; another plugin's id or a core
|
||||
// group is used as given, which is what makes a cross-plugin check
|
||||
@@ -623,6 +696,7 @@ export async function disposePluginHandle(
|
||||
// Belt and braces: an offer made outside ctx, or one whose registration was
|
||||
// lost to a throw mid-activate, would otherwise outlive the plugin.
|
||||
secretRegistry.withdrawAllForPlugin(pluginId);
|
||||
pluginSettings.clearSettingsListeners(pluginId);
|
||||
|
||||
// Skipped when activate() never finished: a plugin's deactivate expects the
|
||||
// state activate builds, and calling it on a half-built plugin tends to
|
||||
|
||||
@@ -0,0 +1,319 @@
|
||||
/**
|
||||
* Plugin settings: validation, storage, encryption and change notification.
|
||||
*
|
||||
* One module behind both ctx.settings and the /plugins/:id/settings routes, so
|
||||
* "what is a valid value", "which fields are secret" and "what a read hands
|
||||
* back" are decided once. Two implementations would drift, and the one that
|
||||
* drifted would be the one writing to the database.
|
||||
*
|
||||
* A field the manifest never declared is rejected rather than stored. That is
|
||||
* what stops a PUT from filling a plugin's namespace with arbitrary keys, and
|
||||
* it is why every entry point resolves the field first and the value second.
|
||||
*/
|
||||
|
||||
import type {
|
||||
PluginManifest,
|
||||
PluginSettingsField,
|
||||
} from "@termix/plugin-sdk/manifest";
|
||||
import {
|
||||
coerceSettingValue,
|
||||
isRedactedSecret,
|
||||
validateSettingValue,
|
||||
} from "@termix/plugin-sdk/settings";
|
||||
import { createCurrentPluginSettingsRepository } from "../database/repositories/factory.js";
|
||||
import type { PluginSettingsScope } from "../database/repositories/plugin-settings-repository.js";
|
||||
import {
|
||||
decryptSystemSecret,
|
||||
encryptSystemSecret,
|
||||
} from "../utils/system-secret-crypto.js";
|
||||
import { pluginLogger } from "../utils/logger.js";
|
||||
|
||||
export type { PluginSettingsScope };
|
||||
|
||||
/**
|
||||
* Core settings a plugin may read through ctx.settings.readCore.
|
||||
*
|
||||
* Deliberately short. Everything here is either already visible in the UI to
|
||||
* every user, or a switch a plugin has to honour to behave correctly. Adding
|
||||
* to it means deciding that every plugin holding settings:read-core may see
|
||||
* that value, so it is a review decision rather than a convenience.
|
||||
*/
|
||||
export const CORE_SETTINGS_ALLOWLIST: readonly string[] = [
|
||||
"app_name",
|
||||
"guac_enabled",
|
||||
"global_metrics_interval",
|
||||
"global_status_check_interval",
|
||||
];
|
||||
|
||||
type ChangeListener = (value: unknown) => void;
|
||||
|
||||
/** pluginId -> key -> listeners. In-process only; settings are not clustered. */
|
||||
const listeners = new Map<string, Map<string, Set<ChangeListener>>>();
|
||||
|
||||
export function onSettingsChange(
|
||||
pluginId: string,
|
||||
key: string,
|
||||
listener: ChangeListener,
|
||||
): () => void {
|
||||
let byKey = listeners.get(pluginId);
|
||||
if (!byKey) {
|
||||
byKey = new Map();
|
||||
listeners.set(pluginId, byKey);
|
||||
}
|
||||
let set = byKey.get(key);
|
||||
if (!set) {
|
||||
set = new Set();
|
||||
byKey.set(key, set);
|
||||
}
|
||||
set.add(listener);
|
||||
|
||||
return () => {
|
||||
set!.delete(listener);
|
||||
if (set!.size === 0) byKey!.delete(key);
|
||||
if (byKey!.size === 0) listeners.delete(pluginId);
|
||||
};
|
||||
}
|
||||
|
||||
function notify(pluginId: string, key: string, value: unknown): void {
|
||||
for (const listener of listeners.get(pluginId)?.get(key) ?? []) {
|
||||
try {
|
||||
listener(value);
|
||||
} catch (error) {
|
||||
// A listener throwing must not fail the write that already happened.
|
||||
pluginLogger.error(
|
||||
`Plugin ${pluginId} settings listener for "${key}" failed`,
|
||||
error instanceof Error ? error : new Error(String(error)),
|
||||
{ operation: "plugin_settings_change" },
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Drops every listener a plugin registered. Called when it deactivates. */
|
||||
export function clearSettingsListeners(pluginId: string): void {
|
||||
listeners.delete(pluginId);
|
||||
}
|
||||
|
||||
/** The fields a manifest declares for one scope. */
|
||||
export function declaredFields(
|
||||
manifest: PluginManifest,
|
||||
scope: PluginSettingsScope,
|
||||
): PluginSettingsField[] {
|
||||
const settings = manifest.contributes?.settings;
|
||||
if (!settings) return [];
|
||||
|
||||
if (scope === "admin") return settings.admin ?? [];
|
||||
if (scope === "user") return settings.user ?? [];
|
||||
|
||||
const host = settings.host;
|
||||
if (!host) return [];
|
||||
|
||||
// The enable switch is a real stored key, so it has to behave like a field
|
||||
// everywhere: validated on write, defaulted on read, rendered in the editor.
|
||||
const enableField: PluginSettingsField[] = host.enableKey
|
||||
? [
|
||||
{
|
||||
key: host.enableKey,
|
||||
type: "boolean",
|
||||
labelKey: host.enableLabelKey,
|
||||
default: false,
|
||||
},
|
||||
]
|
||||
: [];
|
||||
|
||||
return [...enableField, ...host.fields];
|
||||
}
|
||||
|
||||
export function findField(
|
||||
manifest: PluginManifest,
|
||||
scope: PluginSettingsScope,
|
||||
key: string,
|
||||
): PluginSettingsField | undefined {
|
||||
return declaredFields(manifest, scope).find((field) => field.key === key);
|
||||
}
|
||||
|
||||
function normalizeScopeId(
|
||||
scope: PluginSettingsScope,
|
||||
scopeId: string | number | undefined | null,
|
||||
): string | null {
|
||||
if (scope === "admin") return null;
|
||||
if (scopeId === undefined || scopeId === null) {
|
||||
throw new Error(`Plugin settings scope "${scope}" requires a scope id`);
|
||||
}
|
||||
return String(scopeId);
|
||||
}
|
||||
|
||||
/** Stored values are JSON so a field keeps its declared type on the way back. */
|
||||
function encode(value: unknown): string {
|
||||
return JSON.stringify(value ?? null);
|
||||
}
|
||||
|
||||
function decode(raw: string | null): unknown {
|
||||
if (raw === null) return undefined;
|
||||
try {
|
||||
return JSON.parse(raw);
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export async function getSetting(
|
||||
manifest: PluginManifest,
|
||||
scope: PluginSettingsScope,
|
||||
scopeId: string | number | null | undefined,
|
||||
key: string,
|
||||
): Promise<unknown> {
|
||||
const field = findField(manifest, scope, key);
|
||||
if (!field) return undefined;
|
||||
|
||||
const row = await createCurrentPluginSettingsRepository().get(
|
||||
manifest.id,
|
||||
scope,
|
||||
normalizeScopeId(scope, scopeId),
|
||||
key,
|
||||
);
|
||||
|
||||
if (!row || row.value === null) return field.default;
|
||||
|
||||
const decoded = decode(row.value);
|
||||
if (decoded === undefined || decoded === null) return field.default;
|
||||
|
||||
if (row.encrypted && typeof decoded === "string") {
|
||||
return decryptSystemSecret(decoded);
|
||||
}
|
||||
return decoded;
|
||||
}
|
||||
|
||||
export interface SetSettingOptions {
|
||||
/** Skips validation for a value core itself produced, such as a migration. */
|
||||
trusted?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes one value, after validating it against the declared field.
|
||||
*
|
||||
* Returns the error message when the value is rejected, or null on success,
|
||||
* rather than throwing: a PUT reports every bad field at once and a throw per
|
||||
* field would only ever surface the first.
|
||||
*/
|
||||
export async function setSetting(
|
||||
manifest: PluginManifest,
|
||||
scope: PluginSettingsScope,
|
||||
scopeId: string | number | null | undefined,
|
||||
key: string,
|
||||
value: unknown,
|
||||
options: SetSettingOptions = {},
|
||||
): Promise<string | null> {
|
||||
const field = findField(manifest, scope, key);
|
||||
if (!field) {
|
||||
return `"${key}" is not a settings field this plugin declares`;
|
||||
}
|
||||
|
||||
// Echoing a redacted secret back means "leave it alone", so a form can save
|
||||
// without clearing a key it was never shown.
|
||||
if (field.type === "secret" && isRedactedSecret(value)) return null;
|
||||
|
||||
const coerced = options.trusted ? value : coerceSettingValue(field, value);
|
||||
if (!options.trusted) {
|
||||
const error = validateSettingValue(field, coerced);
|
||||
if (error) return error;
|
||||
}
|
||||
|
||||
// Clearing a secret writes null rather than an encrypted empty string, so a
|
||||
// read reports it as unset instead of "set to nothing".
|
||||
const clearingSecret = field.type === "secret" && coerced === "";
|
||||
const shouldEncrypt =
|
||||
field.type === "secret" && typeof coerced === "string" && !clearingSecret;
|
||||
const stored = clearingSecret
|
||||
? null
|
||||
: shouldEncrypt
|
||||
? encode(await encryptSystemSecret(coerced as string))
|
||||
: encode(coerced);
|
||||
|
||||
await createCurrentPluginSettingsRepository().set(
|
||||
manifest.id,
|
||||
scope,
|
||||
normalizeScopeId(scope, scopeId),
|
||||
key,
|
||||
stored,
|
||||
shouldEncrypt,
|
||||
);
|
||||
|
||||
notify(manifest.id, key, coerced);
|
||||
return null;
|
||||
}
|
||||
|
||||
export interface GetAllOptions {
|
||||
/** Replaces every secret with { set } so the value never leaves the server. */
|
||||
redactSecrets?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Every declared field for one scope, with stored values over defaults.
|
||||
*
|
||||
* Driven by the manifest rather than by the rows, so a field that was never
|
||||
* written still comes back with its default and a stale row for a field the
|
||||
* plugin has since dropped is ignored instead of being handed to the UI.
|
||||
*/
|
||||
export async function getAllSettings(
|
||||
manifest: PluginManifest,
|
||||
scope: PluginSettingsScope,
|
||||
scopeId: string | number | null | undefined,
|
||||
options: GetAllOptions = {},
|
||||
): Promise<Record<string, unknown>> {
|
||||
const fields = declaredFields(manifest, scope);
|
||||
if (fields.length === 0) return {};
|
||||
|
||||
const rows = await createCurrentPluginSettingsRepository().getAll(
|
||||
manifest.id,
|
||||
scope,
|
||||
normalizeScopeId(scope, scopeId),
|
||||
);
|
||||
const byKey = new Map(rows.map((row) => [row.key, row]));
|
||||
|
||||
const values: Record<string, unknown> = {};
|
||||
for (const field of fields) {
|
||||
values[field.key] = await resolveFieldValue(
|
||||
field,
|
||||
byKey.get(field.key),
|
||||
options,
|
||||
);
|
||||
}
|
||||
return values;
|
||||
}
|
||||
|
||||
/** Shared by getAllSettings and the host-list projection. */
|
||||
export async function resolveFieldValue(
|
||||
field: PluginSettingsField,
|
||||
row: { value: string | null; encrypted: boolean } | undefined,
|
||||
options: GetAllOptions = {},
|
||||
): Promise<unknown> {
|
||||
const decoded = row && row.value !== null ? decode(row.value) : undefined;
|
||||
const hasValue = decoded !== undefined && decoded !== null && decoded !== "";
|
||||
|
||||
if (field.type === "secret") {
|
||||
if (options.redactSecrets) return { set: hasValue };
|
||||
if (!hasValue) return field.default;
|
||||
return row!.encrypted && typeof decoded === "string"
|
||||
? decryptSystemSecret(decoded)
|
||||
: decoded;
|
||||
}
|
||||
|
||||
return hasValue ? decoded : field.default;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads a core server setting. The caller has already checked
|
||||
* settings:read-core; this only enforces the allowlist.
|
||||
*/
|
||||
export async function readCoreSetting(key: string): Promise<string | null> {
|
||||
if (!CORE_SETTINGS_ALLOWLIST.includes(key)) {
|
||||
throw new Error(
|
||||
`Core setting "${key}" is not readable by plugins. Readable keys: ${CORE_SETTINGS_ALLOWLIST.join(", ")}`,
|
||||
);
|
||||
}
|
||||
|
||||
const { createCurrentSettingsRepository } =
|
||||
await import("../database/repositories/factory.js");
|
||||
return createCurrentSettingsRepository().get(key);
|
||||
}
|
||||
@@ -344,6 +344,12 @@ async function provisionLocalDesktopUserIfNeeded(): Promise<void> {
|
||||
operation: "plugin_init",
|
||||
});
|
||||
}
|
||||
|
||||
// After seeding: plugin_settings has a foreign key to plugins, so there
|
||||
// is nothing to migrate into until the plugin row exists.
|
||||
const { runTailscaleSettingsMigration } =
|
||||
await import("./utils/crypto-migration/tailscale-settings-migration.js");
|
||||
await runTailscaleSettingsMigration();
|
||||
} catch (error) {
|
||||
systemLogger.warn("Plugin runtime failed to initialize", {
|
||||
operation: "plugin_init",
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
/**
|
||||
* plugin_settings cleanup when a user or host goes.
|
||||
*
|
||||
* scope_id is polymorphic, so it carries no foreign key and the engine will
|
||||
* not cascade it. The repositories delete these rows explicitly, and these
|
||||
* tests exist so a later refactor that drops those calls fails here rather
|
||||
* than leaving orphaned settings behind forever.
|
||||
*/
|
||||
|
||||
import { asc } from "drizzle-orm";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { TestSqliteDatabase } from "./repositories/test-support.js";
|
||||
import { HostRepository } from "../../database/repositories/host-repository.js";
|
||||
import { UserRepository } from "../../database/repositories/user-repository.js";
|
||||
import type { DatabaseContext } from "../../database/repositories/database-context.js";
|
||||
import { pluginSettings } from "../../database/db/schema.js";
|
||||
|
||||
let adapter: TestSqliteDatabase | null = null;
|
||||
let ctx: DatabaseContext | null = null;
|
||||
|
||||
afterEach(async () => {
|
||||
if (adapter) {
|
||||
await adapter.close();
|
||||
adapter = null;
|
||||
ctx = null;
|
||||
}
|
||||
});
|
||||
|
||||
async function seed(): Promise<DatabaseContext> {
|
||||
adapter = new TestSqliteDatabase();
|
||||
const context = await adapter.connect();
|
||||
ctx = context;
|
||||
|
||||
await adapter.exec(`
|
||||
INSERT INTO users (id, username, password_hash)
|
||||
VALUES ('user-1', 'alice', 'hash'), ('user-2', 'bob', 'hash');
|
||||
|
||||
INSERT INTO ssh_data (id, user_id, name, ip, port, username, auth_type)
|
||||
VALUES
|
||||
(7, 'user-1', 'seven', '10.0.0.7', 22, 'root', 'password'),
|
||||
(8, 'user-1', 'eight', '10.0.0.8', 22, 'root', 'password'),
|
||||
(9, 'user-2', 'nine', '10.0.0.9', 22, 'root', 'password');
|
||||
|
||||
INSERT INTO plugins (id, name, version, manifest_json)
|
||||
VALUES ('sample', 'Sample', '1.0.0', '{}');
|
||||
`);
|
||||
|
||||
return context;
|
||||
}
|
||||
|
||||
async function addSetting(
|
||||
scope: string,
|
||||
scopeId: string | null,
|
||||
key = "k",
|
||||
): Promise<void> {
|
||||
await adapter!.exec(
|
||||
`INSERT INTO plugin_settings (plugin_id, scope, scope_id, key, value)
|
||||
VALUES ('sample', '${scope}', ${scopeId === null ? "NULL" : `'${scopeId}'`}, '${key}', '"v"')`,
|
||||
);
|
||||
}
|
||||
|
||||
async function remaining(): Promise<
|
||||
{ scope: string; scopeId: string | null }[]
|
||||
> {
|
||||
const rows = await ctx!.drizzle
|
||||
.select({
|
||||
scope: pluginSettings.scope,
|
||||
scopeId: pluginSettings.scopeId,
|
||||
})
|
||||
.from(pluginSettings)
|
||||
.orderBy(asc(pluginSettings.scope), asc(pluginSettings.scopeId));
|
||||
return rows;
|
||||
}
|
||||
|
||||
describe("user deletion", () => {
|
||||
it("removes that user's plugin settings", async () => {
|
||||
const context = await seed();
|
||||
await addSetting("user", "user-1");
|
||||
|
||||
await new UserRepository(context).delete("user-1");
|
||||
|
||||
expect(await remaining()).toEqual([]);
|
||||
});
|
||||
|
||||
it("leaves another user's settings alone", async () => {
|
||||
const context = await seed();
|
||||
await addSetting("user", "user-1");
|
||||
await addSetting("user", "user-2");
|
||||
|
||||
await new UserRepository(context).delete("user-1");
|
||||
|
||||
expect(await remaining()).toEqual([{ scope: "user", scopeId: "user-2" }]);
|
||||
});
|
||||
|
||||
it("leaves admin-scope settings alone", async () => {
|
||||
const context = await seed();
|
||||
await addSetting("admin", null);
|
||||
await addSetting("user", "user-1");
|
||||
|
||||
await new UserRepository(context).delete("user-1");
|
||||
|
||||
expect(await remaining()).toEqual([{ scope: "admin", scopeId: null }]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("host deletion", () => {
|
||||
it("removes that host's plugin settings", async () => {
|
||||
const context = await seed();
|
||||
await addSetting("host", "7");
|
||||
|
||||
await new HostRepository(context).deleteForUser("user-1", 7);
|
||||
|
||||
expect(await remaining()).toEqual([]);
|
||||
});
|
||||
|
||||
it("leaves another host's settings alone", async () => {
|
||||
const context = await seed();
|
||||
await addSetting("host", "7");
|
||||
await addSetting("host", "8");
|
||||
|
||||
await new HostRepository(context).deleteForUser("user-1", 7);
|
||||
|
||||
expect(await remaining()).toEqual([{ scope: "host", scopeId: "8" }]);
|
||||
});
|
||||
|
||||
it("clears settings for every host in the bulk delete path", async () => {
|
||||
const context = await seed();
|
||||
await addSetting("host", "7");
|
||||
await addSetting("host", "8");
|
||||
await addSetting("host", "9");
|
||||
|
||||
await new HostRepository(context).deleteByUserId("user-1");
|
||||
|
||||
// Host 9 belongs to another user and keeps its settings.
|
||||
expect(await remaining()).toEqual([{ scope: "host", scopeId: "9" }]);
|
||||
});
|
||||
|
||||
it("does not confuse a host id with a user id", async () => {
|
||||
const context = await seed();
|
||||
await addSetting("host", "7");
|
||||
await addSetting("user", "7");
|
||||
|
||||
await new HostRepository(context).deleteForUser("user-1", 7);
|
||||
|
||||
expect(await remaining()).toEqual([{ scope: "user", scopeId: "7" }]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,244 @@
|
||||
/**
|
||||
* The pluginSettings map attached to host responses.
|
||||
*
|
||||
* The host editor needs these with the host, not one request per plugin per
|
||||
* host, so the shape and the query count both matter.
|
||||
*/
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { PluginManifest } from "@termix/plugin-sdk/manifest";
|
||||
|
||||
interface Row {
|
||||
pluginId: string;
|
||||
scope: string;
|
||||
scopeId: string | null;
|
||||
key: string;
|
||||
value: string | null;
|
||||
encrypted: boolean;
|
||||
}
|
||||
|
||||
const rows: Row[] = [];
|
||||
const loaded: { id: string; manifest: PluginManifest; state: string }[] = [];
|
||||
const getAllForScopeIds = vi.fn(async (scope: string, scopeIds: string[]) =>
|
||||
rows.filter(
|
||||
(row) => row.scope === scope && scopeIds.includes(row.scopeId ?? ""),
|
||||
),
|
||||
);
|
||||
|
||||
vi.mock("../../../utils/logger.js", () => {
|
||||
const logger = {
|
||||
debug: vi.fn(),
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
success: vi.fn(),
|
||||
};
|
||||
return { sshLogger: logger, pluginLogger: logger, databaseLogger: logger };
|
||||
});
|
||||
|
||||
vi.mock("../../../database/repositories/factory.js", () => ({
|
||||
createCurrentPluginSettingsRepository: () => ({ getAllForScopeIds }),
|
||||
}));
|
||||
|
||||
vi.mock("../../../plugins/index.js", () => ({
|
||||
getPluginRuntime: () => ({ loader: { list: () => loaded } }),
|
||||
}));
|
||||
|
||||
vi.mock("../../../utils/system-secret-crypto.js", () => ({
|
||||
isSystemEncrypted: (value: string) => value.startsWith("sysenc:v1:"),
|
||||
encryptSystemSecret: async (plaintext: string) => plaintext,
|
||||
decryptSystemSecret: async (stored: string) => stored,
|
||||
}));
|
||||
|
||||
const {
|
||||
attachHostPluginSettings,
|
||||
loadHostPluginSettings,
|
||||
withHostPluginSettings,
|
||||
} = await import("../../../database/routes/host-plugin-settings.js");
|
||||
|
||||
function manifest(id: string, host: unknown): PluginManifest {
|
||||
return {
|
||||
id,
|
||||
name: id,
|
||||
version: "1.0.0",
|
||||
description: "",
|
||||
author: { name: "t" },
|
||||
license: "MIT",
|
||||
category: "Productivity",
|
||||
engine: { termix: ">=2.9.0", api: "1" },
|
||||
capabilities: [],
|
||||
contributes: { settings: { host } },
|
||||
} as PluginManifest;
|
||||
}
|
||||
|
||||
const DOCKER = manifest("docker", {
|
||||
enableKey: "enableDocker",
|
||||
enableLabelKey: "k",
|
||||
fields: [{ key: "socketPath", type: "string", labelKey: "k" }],
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
rows.length = 0;
|
||||
loaded.length = 0;
|
||||
getAllForScopeIds.mockClear();
|
||||
});
|
||||
|
||||
describe("loadHostPluginSettings", () => {
|
||||
it("returns nothing when no plugin declares host settings", async () => {
|
||||
loaded.push({
|
||||
id: "other",
|
||||
manifest: manifest("other", undefined),
|
||||
state: "active",
|
||||
});
|
||||
|
||||
const result = await loadHostPluginSettings([1, 2]);
|
||||
|
||||
expect(result.size).toBe(0);
|
||||
expect(getAllForScopeIds).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("ignores a plugin that is not running", async () => {
|
||||
loaded.push({ id: "docker", manifest: DOCKER, state: "disabled" });
|
||||
|
||||
const result = await loadHostPluginSettings([1]);
|
||||
|
||||
expect(result.size).toBe(0);
|
||||
});
|
||||
|
||||
it("uses one query for the whole list", async () => {
|
||||
loaded.push({ id: "docker", manifest: DOCKER, state: "active" });
|
||||
|
||||
await loadHostPluginSettings([1, 2, 3, 4, 5]);
|
||||
|
||||
expect(getAllForScopeIds).toHaveBeenCalledTimes(1);
|
||||
expect(getAllForScopeIds).toHaveBeenCalledWith("host", [
|
||||
"1",
|
||||
"2",
|
||||
"3",
|
||||
"4",
|
||||
"5",
|
||||
]);
|
||||
});
|
||||
|
||||
it("merges stored values over declared defaults", async () => {
|
||||
loaded.push({ id: "docker", manifest: DOCKER, state: "active" });
|
||||
rows.push({
|
||||
pluginId: "docker",
|
||||
scope: "host",
|
||||
scopeId: "1",
|
||||
key: "enableDocker",
|
||||
value: JSON.stringify(true),
|
||||
encrypted: false,
|
||||
});
|
||||
|
||||
const result = await loadHostPluginSettings([1, 2]);
|
||||
|
||||
expect(result.get(1)).toEqual({
|
||||
docker: { enableDocker: true, socketPath: undefined },
|
||||
});
|
||||
// Host 2 has nothing stored, so it falls back to the declared default.
|
||||
expect(result.get(2)).toEqual({
|
||||
docker: { enableDocker: false, socketPath: undefined },
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps one host's values out of another's", async () => {
|
||||
loaded.push({ id: "docker", manifest: DOCKER, state: "active" });
|
||||
rows.push({
|
||||
pluginId: "docker",
|
||||
scope: "host",
|
||||
scopeId: "1",
|
||||
key: "socketPath",
|
||||
value: JSON.stringify("/var/run/docker.sock"),
|
||||
encrypted: false,
|
||||
});
|
||||
|
||||
const result = await loadHostPluginSettings([1, 2]);
|
||||
|
||||
expect(result.get(1)!.docker.socketPath).toBe("/var/run/docker.sock");
|
||||
expect(result.get(2)!.docker.socketPath).toBeUndefined();
|
||||
});
|
||||
|
||||
it("redacts a host-scope secret", async () => {
|
||||
loaded.push({
|
||||
id: "vault",
|
||||
manifest: manifest("vault", {
|
||||
fields: [{ key: "token", type: "secret", labelKey: "k" }],
|
||||
}),
|
||||
state: "active",
|
||||
});
|
||||
rows.push({
|
||||
pluginId: "vault",
|
||||
scope: "host",
|
||||
scopeId: "1",
|
||||
key: "token",
|
||||
value: JSON.stringify("s.verysecret"),
|
||||
encrypted: true,
|
||||
});
|
||||
|
||||
const result = await loadHostPluginSettings([1]);
|
||||
|
||||
expect(result.get(1)!.vault.token).toEqual({ set: true });
|
||||
expect(JSON.stringify(result.get(1))).not.toContain("verysecret");
|
||||
});
|
||||
|
||||
it("survives a failed settings read rather than failing the host list", async () => {
|
||||
loaded.push({ id: "docker", manifest: DOCKER, state: "active" });
|
||||
getAllForScopeIds.mockRejectedValueOnce(new Error("database is away"));
|
||||
|
||||
await expect(loadHostPluginSettings([1])).resolves.toEqual(new Map());
|
||||
});
|
||||
|
||||
it("does nothing for an empty host list", async () => {
|
||||
loaded.push({ id: "docker", manifest: DOCKER, state: "active" });
|
||||
|
||||
expect((await loadHostPluginSettings([])).size).toBe(0);
|
||||
expect(getAllForScopeIds).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("attachHostPluginSettings", () => {
|
||||
it("attaches values to the matching host", () => {
|
||||
const hosts = [{ id: 1 }, { id: 2 }] as Record<string, unknown>[];
|
||||
const settings = new Map([[1, { docker: { enableDocker: true } }]]);
|
||||
|
||||
attachHostPluginSettings(hosts, settings);
|
||||
|
||||
expect(hosts[0].pluginSettings).toEqual({ docker: { enableDocker: true } });
|
||||
expect(hosts[1].pluginSettings).toBeUndefined();
|
||||
});
|
||||
|
||||
it("leaves hosts alone when there is nothing to attach", () => {
|
||||
const hosts = [{ id: 1 }] as Record<string, unknown>[];
|
||||
|
||||
attachHostPluginSettings(hosts, new Map());
|
||||
|
||||
expect(hosts[0]).toEqual({ id: 1 });
|
||||
});
|
||||
});
|
||||
|
||||
describe("withHostPluginSettings", () => {
|
||||
it("adds the map to a single host", async () => {
|
||||
loaded.push({ id: "docker", manifest: DOCKER, state: "active" });
|
||||
|
||||
const result = await withHostPluginSettings({ id: 1, name: "web" });
|
||||
|
||||
expect(result.name).toBe("web");
|
||||
expect(result.pluginSettings).toEqual({
|
||||
docker: { enableDocker: false, socketPath: undefined },
|
||||
});
|
||||
});
|
||||
|
||||
it("returns the host unchanged when nothing contributes", async () => {
|
||||
const host = { id: 1, name: "web" };
|
||||
|
||||
expect(await withHostPluginSettings(host)).toBe(host);
|
||||
});
|
||||
|
||||
it("returns the host unchanged when its id is not a number", async () => {
|
||||
loaded.push({ id: "docker", manifest: DOCKER, state: "active" });
|
||||
const host = { name: "web" };
|
||||
|
||||
expect(await withHostPluginSettings(host)).toBe(host);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,356 @@
|
||||
/**
|
||||
* contributes.settings validation.
|
||||
*
|
||||
* A settings field is the one thing in a manifest that core writes to the
|
||||
* database on a plugin's behalf, so the schema has to reject anything it would
|
||||
* not know how to store or render.
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
parseManifest,
|
||||
validateManifest,
|
||||
SUPPORTED_PLUGIN_API_VERSION,
|
||||
} from "../../plugins/manifest.js";
|
||||
|
||||
function base(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
id: "sample-plugin",
|
||||
name: "Sample Plugin",
|
||||
version: "1.0.0",
|
||||
description: "A fixture.",
|
||||
author: { name: "Termix Tests" },
|
||||
license: "MIT",
|
||||
category: "Productivity",
|
||||
engine: { termix: ">=2.9.0", api: SUPPORTED_PLUGIN_API_VERSION },
|
||||
capabilities: ["kv:own"],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function withSettings(settings: unknown) {
|
||||
return base({ contributes: { settings } });
|
||||
}
|
||||
|
||||
describe("contributes.settings", () => {
|
||||
it("accepts a manifest with every field type", () => {
|
||||
const errors = validateManifest(
|
||||
withSettings({
|
||||
admin: [
|
||||
{ key: "apiKey", type: "secret", labelKey: "k" },
|
||||
{ key: "baseUrl", type: "string", labelKey: "k", default: "" },
|
||||
{ key: "retries", type: "number", labelKey: "k", min: 0, max: 5 },
|
||||
{ key: "enabled", type: "boolean", labelKey: "k" },
|
||||
{
|
||||
key: "mode",
|
||||
type: "select",
|
||||
labelKey: "k",
|
||||
options: [{ value: "a", labelKey: "k" }],
|
||||
},
|
||||
{
|
||||
key: "tags",
|
||||
type: "multiselect",
|
||||
labelKey: "k",
|
||||
options: [{ value: "a", labelKey: "k" }],
|
||||
},
|
||||
{ key: "notes", type: "textarea", labelKey: "k" },
|
||||
{ key: "raw", type: "json", labelKey: "k" },
|
||||
{ key: "browser", type: "custom", component: "devices" },
|
||||
],
|
||||
user: [{ key: "theme", type: "string", labelKey: "k" }],
|
||||
host: {
|
||||
enableKey: "enableThing",
|
||||
enableLabelKey: "k",
|
||||
fields: [{ key: "port", type: "number", labelKey: "k" }],
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
expect(errors).toEqual([]);
|
||||
});
|
||||
|
||||
it("accepts a manifest with no settings at all", () => {
|
||||
expect(validateManifest(base())).toEqual([]);
|
||||
});
|
||||
|
||||
it("rejects an unknown field property", () => {
|
||||
const errors = validateManifest(
|
||||
withSettings({
|
||||
admin: [{ key: "a", type: "string", labelKey: "k", wat: 1 }],
|
||||
}),
|
||||
);
|
||||
|
||||
expect(errors.join(" ")).toContain('Unknown field "wat"');
|
||||
});
|
||||
|
||||
it("rejects an unknown scope", () => {
|
||||
const errors = validateManifest(
|
||||
withSettings({ global: [{ key: "a", type: "string", labelKey: "k" }] }),
|
||||
);
|
||||
|
||||
expect(errors.join(" ")).toContain('Unknown field "global"');
|
||||
});
|
||||
|
||||
it("rejects an unknown field type", () => {
|
||||
const errors = validateManifest(
|
||||
withSettings({ admin: [{ key: "a", type: "colour", labelKey: "k" }] }),
|
||||
);
|
||||
|
||||
expect(errors.join(" ")).toContain("must be one of");
|
||||
});
|
||||
|
||||
it("rejects a duplicate key within one scope", () => {
|
||||
const errors = validateManifest(
|
||||
withSettings({
|
||||
admin: [
|
||||
{ key: "a", type: "string", labelKey: "k" },
|
||||
{ key: "a", type: "number", labelKey: "k" },
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
expect(errors.join(" ")).toContain('duplicates "a"');
|
||||
});
|
||||
|
||||
it("allows the same key in two different scopes", () => {
|
||||
const errors = validateManifest(
|
||||
withSettings({
|
||||
admin: [{ key: "shared", type: "string", labelKey: "k" }],
|
||||
user: [{ key: "shared", type: "string", labelKey: "k" }],
|
||||
}),
|
||||
);
|
||||
|
||||
expect(errors).toEqual([]);
|
||||
});
|
||||
|
||||
it("requires labelKey for everything but a custom field", () => {
|
||||
expect(
|
||||
validateManifest(
|
||||
withSettings({ admin: [{ key: "a", type: "string" }] }),
|
||||
).join(" "),
|
||||
).toContain("labelKey");
|
||||
|
||||
expect(
|
||||
validateManifest(
|
||||
withSettings({ admin: [{ key: "a", type: "custom", component: "x" }] }),
|
||||
),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it("requires component on a custom field and forbids it elsewhere", () => {
|
||||
expect(
|
||||
validateManifest(
|
||||
withSettings({ admin: [{ key: "a", type: "custom" }] }),
|
||||
).join(" "),
|
||||
).toContain("component");
|
||||
|
||||
expect(
|
||||
validateManifest(
|
||||
withSettings({
|
||||
admin: [{ key: "a", type: "string", labelKey: "k", component: "x" }],
|
||||
}),
|
||||
).join(" "),
|
||||
).toContain('only valid when type is "custom"');
|
||||
});
|
||||
|
||||
it("requires non-empty options on select and multiselect", () => {
|
||||
for (const type of ["select", "multiselect"]) {
|
||||
expect(
|
||||
validateManifest(
|
||||
withSettings({ admin: [{ key: "a", type, labelKey: "k" }] }),
|
||||
).join(" "),
|
||||
).toContain("options must be a non-empty array");
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects options on a field that has no options", () => {
|
||||
const errors = validateManifest(
|
||||
withSettings({
|
||||
admin: [
|
||||
{
|
||||
key: "a",
|
||||
type: "string",
|
||||
labelKey: "k",
|
||||
options: [{ value: "x", labelKey: "k" }],
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
expect(errors.join(" ")).toContain("only valid for select and multiselect");
|
||||
});
|
||||
|
||||
it("rejects min greater than max", () => {
|
||||
const errors = validateManifest(
|
||||
withSettings({
|
||||
admin: [{ key: "a", type: "number", labelKey: "k", min: 10, max: 1 }],
|
||||
}),
|
||||
);
|
||||
|
||||
expect(errors.join(" ")).toContain("must not be greater than");
|
||||
});
|
||||
|
||||
it("rejects min or max on a non-number field", () => {
|
||||
const errors = validateManifest(
|
||||
withSettings({
|
||||
admin: [{ key: "a", type: "string", labelKey: "k", min: 1 }],
|
||||
}),
|
||||
);
|
||||
|
||||
expect(errors.join(" ")).toContain("only valid for number fields");
|
||||
});
|
||||
|
||||
it("rejects requires that names no boolean in the same scope", () => {
|
||||
const errors = validateManifest(
|
||||
withSettings({
|
||||
admin: [
|
||||
{ key: "a", type: "string", labelKey: "k", requires: "missing" },
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
expect(errors.join(" ")).toContain("must name a boolean field");
|
||||
});
|
||||
|
||||
it("rejects requires that names a non-boolean field", () => {
|
||||
const errors = validateManifest(
|
||||
withSettings({
|
||||
admin: [
|
||||
{ key: "flag", type: "string", labelKey: "k" },
|
||||
{ key: "a", type: "string", labelKey: "k", requires: "flag" },
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
expect(errors.join(" ")).toContain("must name a boolean field");
|
||||
});
|
||||
|
||||
it("accepts requires pointing at a boolean declared later", () => {
|
||||
const errors = validateManifest(
|
||||
withSettings({
|
||||
admin: [
|
||||
{ key: "a", type: "string", labelKey: "k", requires: "flag" },
|
||||
{ key: "flag", type: "boolean", labelKey: "k" },
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
expect(errors).toEqual([]);
|
||||
});
|
||||
|
||||
it("lets a host field require the section's enable key", () => {
|
||||
const errors = validateManifest(
|
||||
withSettings({
|
||||
host: {
|
||||
enableKey: "enableThing",
|
||||
enableLabelKey: "k",
|
||||
fields: [
|
||||
{
|
||||
key: "port",
|
||||
type: "number",
|
||||
labelKey: "k",
|
||||
requires: "enableThing",
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
expect(errors).toEqual([]);
|
||||
});
|
||||
|
||||
it("rejects a host field colliding with the enable key", () => {
|
||||
const errors = validateManifest(
|
||||
withSettings({
|
||||
host: {
|
||||
enableKey: "enableThing",
|
||||
enableLabelKey: "k",
|
||||
fields: [{ key: "enableThing", type: "boolean", labelKey: "k" }],
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
expect(errors.join(" ")).toContain("already the section's enableKey");
|
||||
});
|
||||
|
||||
it("requires enableLabelKey alongside enableKey", () => {
|
||||
const errors = validateManifest(
|
||||
withSettings({
|
||||
host: { enableKey: "enableThing", fields: [] },
|
||||
}),
|
||||
);
|
||||
|
||||
expect(errors.join(" ")).toContain("enableLabelKey");
|
||||
});
|
||||
|
||||
it("rejects a key that would not be index-safe", () => {
|
||||
const errors = validateManifest(
|
||||
withSettings({
|
||||
admin: [{ key: "a".repeat(80), type: "string", labelKey: "k" }],
|
||||
}),
|
||||
);
|
||||
|
||||
expect(errors.join(" ")).toContain("short alphanumeric key");
|
||||
});
|
||||
});
|
||||
|
||||
describe("settings field permissions", () => {
|
||||
it("rejects a short permission the plugin does not declare", () => {
|
||||
const { errors } = parseManifest(
|
||||
base({
|
||||
contributes: {
|
||||
settings: {
|
||||
user: [
|
||||
{ key: "a", type: "string", labelKey: "k", permission: "use" },
|
||||
],
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
expect(errors.join(" ")).toContain(
|
||||
"not declared in contributes.permissions",
|
||||
);
|
||||
});
|
||||
|
||||
it("accepts a short permission the plugin declares", () => {
|
||||
const { errors, manifest } = parseManifest(
|
||||
base({
|
||||
contributes: {
|
||||
permissions: [{ name: "use", titleKey: "k", descriptionKey: "k" }],
|
||||
settings: {
|
||||
user: [
|
||||
{ key: "a", type: "string", labelKey: "k", permission: "use" },
|
||||
],
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
expect(errors).toEqual([]);
|
||||
expect(manifest?.contributes?.settings?.user?.[0].permission).toBe("use");
|
||||
});
|
||||
|
||||
it("accepts a fully qualified permission from another namespace", () => {
|
||||
// Cross-namespace ids cannot be checked here: the validator has no idea
|
||||
// which other plugins exist. Taken as given, exactly like ctx.rbac does.
|
||||
const { errors } = parseManifest(
|
||||
base({
|
||||
contributes: {
|
||||
settings: {
|
||||
user: [
|
||||
{
|
||||
key: "a",
|
||||
type: "string",
|
||||
labelKey: "k",
|
||||
permission: "hosts.view",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
expect(errors).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,498 @@
|
||||
/**
|
||||
* The /plugins/:id/settings routes.
|
||||
*
|
||||
* These decide who may change what, so the tests are mostly about refusal: a
|
||||
* non-admin must not write install-wide configuration, a user must not write
|
||||
* another user's settings or a host they cannot edit, and a secret must never
|
||||
* come back over the wire.
|
||||
*/
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import express from "express";
|
||||
import type { AddressInfo } from "node:net";
|
||||
import type { Server } from "node:http";
|
||||
import type { PluginManifest } from "@termix/plugin-sdk/manifest";
|
||||
|
||||
const state = vi.hoisted(() => ({
|
||||
userId: "user-1",
|
||||
permissions: new Set<string>(),
|
||||
hostEditAccess: true,
|
||||
}));
|
||||
|
||||
vi.mock("../../utils/logger.js", () => {
|
||||
const logger = {
|
||||
debug: vi.fn(),
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
success: vi.fn(),
|
||||
};
|
||||
return { pluginLogger: logger, databaseLogger: logger, apiLogger: logger };
|
||||
});
|
||||
|
||||
vi.mock("../../utils/auth-manager.js", () => ({
|
||||
AuthManager: {
|
||||
getInstance: () => ({
|
||||
createAuthMiddleware:
|
||||
() =>
|
||||
(
|
||||
req: express.Request,
|
||||
res: express.Response,
|
||||
next: express.NextFunction,
|
||||
) => {
|
||||
(req as express.Request & { userId?: string }).userId = state.userId;
|
||||
next();
|
||||
},
|
||||
}),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("../../utils/permission-manager.js", () => ({
|
||||
PermissionManager: {
|
||||
getInstance: () => ({
|
||||
hasPermission: async (_userId: string, permission: string) =>
|
||||
state.permissions.has(permission),
|
||||
requirePermission:
|
||||
(permission: string) =>
|
||||
(
|
||||
_req: express.Request,
|
||||
res: express.Response,
|
||||
next: express.NextFunction,
|
||||
) => {
|
||||
if (!state.permissions.has(permission)) {
|
||||
res.status(403).json({ error: "Insufficient permissions" });
|
||||
return;
|
||||
}
|
||||
next();
|
||||
},
|
||||
canAccessHost: async () => ({
|
||||
hasAccess: state.hostEditAccess,
|
||||
isOwner: state.hostEditAccess,
|
||||
isShared: false,
|
||||
}),
|
||||
}),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("../../utils/audit-logger.js", () => ({
|
||||
logAudit: vi.fn(async () => {}),
|
||||
getAuditUsername: vi.fn(async (id: string) => id),
|
||||
getRequestMeta: () => ({ ipAddress: "127.0.0.1", userAgent: "test" }),
|
||||
}));
|
||||
|
||||
const MANIFEST: PluginManifest = {
|
||||
id: "sample",
|
||||
name: "Sample",
|
||||
version: "1.0.0",
|
||||
description: "",
|
||||
author: { name: "t" },
|
||||
license: "MIT",
|
||||
category: "Productivity",
|
||||
engine: { termix: ">=2.9.0", api: "1" },
|
||||
capabilities: [],
|
||||
contributes: {
|
||||
permissions: [{ name: "tweak", titleKey: "k", descriptionKey: "k" }],
|
||||
settings: {
|
||||
admin: [
|
||||
{ key: "apiKey", type: "secret", labelKey: "k" },
|
||||
{ key: "retries", type: "number", labelKey: "k", min: 0, max: 5 },
|
||||
{
|
||||
key: "narrowed",
|
||||
type: "string",
|
||||
labelKey: "k",
|
||||
permission: "tweak",
|
||||
},
|
||||
],
|
||||
user: [
|
||||
{ key: "theme", type: "string", labelKey: "k", default: "dark" },
|
||||
{
|
||||
key: "advanced",
|
||||
type: "string",
|
||||
labelKey: "k",
|
||||
permission: "tweak",
|
||||
},
|
||||
],
|
||||
host: {
|
||||
enableKey: "enableThing",
|
||||
enableLabelKey: "k",
|
||||
fields: [{ key: "port", type: "number", labelKey: "k" }],
|
||||
},
|
||||
},
|
||||
},
|
||||
} as PluginManifest;
|
||||
|
||||
vi.mock("../../plugins/index.js", () => ({
|
||||
getPluginRuntime: () => ({
|
||||
loader: {
|
||||
get: (id: string) =>
|
||||
id === "sample"
|
||||
? { id, manifest: MANIFEST, state: "active" }
|
||||
: undefined,
|
||||
list: () => [{ id: "sample", manifest: MANIFEST, state: "active" }],
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
interface Row {
|
||||
pluginId: string;
|
||||
scope: string;
|
||||
scopeId: string | null;
|
||||
key: string;
|
||||
value: string | null;
|
||||
encrypted: boolean;
|
||||
}
|
||||
|
||||
const rows: Row[] = [];
|
||||
|
||||
function find(scope: string, scopeId: string | null, key: string) {
|
||||
return rows.find(
|
||||
(row) => row.scope === scope && row.scopeId === scopeId && row.key === key,
|
||||
);
|
||||
}
|
||||
|
||||
vi.mock("../../database/repositories/factory.js", () => ({
|
||||
createCurrentPluginRepository: () => ({
|
||||
findById: async (id: string) =>
|
||||
id === "sample"
|
||||
? { id, manifestJson: JSON.stringify(MANIFEST), state: "enabled" }
|
||||
: null,
|
||||
listAll: async () => [],
|
||||
}),
|
||||
createCurrentPluginPermissionGrantRepository: () => ({
|
||||
listByPlugin: async () => [],
|
||||
}),
|
||||
createCurrentPluginSettingsRepository: () => ({
|
||||
get: async (
|
||||
_pluginId: string,
|
||||
scope: string,
|
||||
scopeId: string | null,
|
||||
key: string,
|
||||
) => find(scope, scopeId, key) ?? null,
|
||||
getAll: async (_pluginId: string, scope: string, scopeId: string | null) =>
|
||||
rows.filter((row) => row.scope === scope && row.scopeId === scopeId),
|
||||
set: async (
|
||||
pluginId: string,
|
||||
scope: string,
|
||||
scopeId: string | null,
|
||||
key: string,
|
||||
value: string | null,
|
||||
encrypted = false,
|
||||
) => {
|
||||
const existing = find(scope, scopeId, key);
|
||||
if (existing) {
|
||||
existing.value = value;
|
||||
existing.encrypted = encrypted;
|
||||
return;
|
||||
}
|
||||
rows.push({ pluginId, scope, scopeId, key, value, encrypted });
|
||||
},
|
||||
}),
|
||||
createCurrentSettingsRepository: () => ({ get: async () => null }),
|
||||
}));
|
||||
|
||||
vi.mock("../../utils/system-secret-crypto.js", () => ({
|
||||
isSystemEncrypted: (value: string) => value.startsWith("sysenc:v1:"),
|
||||
encryptSystemSecret: async (plaintext: string) =>
|
||||
!plaintext || plaintext.startsWith("sysenc:v1:")
|
||||
? plaintext
|
||||
: `sysenc:v1:${Buffer.from(plaintext, "utf8").toString("base64")}`,
|
||||
decryptSystemSecret: async (stored: string) =>
|
||||
stored.startsWith("sysenc:v1:")
|
||||
? Buffer.from(stored.slice("sysenc:v1:".length), "base64").toString(
|
||||
"utf8",
|
||||
)
|
||||
: stored,
|
||||
}));
|
||||
|
||||
const pluginRoutes = (await import("../../database/routes/plugins.js")).default;
|
||||
|
||||
let server: Server;
|
||||
let baseUrl: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
rows.length = 0;
|
||||
state.userId = "user-1";
|
||||
state.permissions = new Set<string>();
|
||||
state.hostEditAccess = true;
|
||||
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use("/plugins", pluginRoutes);
|
||||
|
||||
server = await new Promise<Server>((resolve) => {
|
||||
const created = app.listen(0, "127.0.0.1", () => resolve(created));
|
||||
});
|
||||
baseUrl = `http://127.0.0.1:${(server.address() as AddressInfo).port}`;
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await new Promise<void>((resolve) => server.close(() => resolve()));
|
||||
});
|
||||
|
||||
function call(path: string, init?: RequestInit) {
|
||||
return fetch(`${baseUrl}${path}`, {
|
||||
...init,
|
||||
headers: { "content-type": "application/json", ...(init?.headers ?? {}) },
|
||||
});
|
||||
}
|
||||
|
||||
describe("admin scope", () => {
|
||||
it("refuses a reader without admin.plugins.manage", async () => {
|
||||
const response = await call("/plugins/sample/settings/admin");
|
||||
|
||||
expect(response.status).toBe(403);
|
||||
});
|
||||
|
||||
it("refuses a writer without admin.plugins.manage", async () => {
|
||||
const response = await call("/plugins/sample/settings/admin", {
|
||||
method: "PUT",
|
||||
body: JSON.stringify({ retries: 2 }),
|
||||
});
|
||||
|
||||
expect(response.status).toBe(403);
|
||||
expect(rows).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("lets an admin read and write", async () => {
|
||||
state.permissions.add("admin.plugins.manage");
|
||||
|
||||
const write = await call("/plugins/sample/settings/admin", {
|
||||
method: "PUT",
|
||||
body: JSON.stringify({ retries: 3 }),
|
||||
});
|
||||
expect(write.status).toBe(200);
|
||||
|
||||
const read = await call("/plugins/sample/settings/admin");
|
||||
expect((await read.json()).values.retries).toBe(3);
|
||||
});
|
||||
|
||||
it("reports a rejected value per field", async () => {
|
||||
state.permissions.add("admin.plugins.manage");
|
||||
|
||||
const response = await call("/plugins/sample/settings/admin", {
|
||||
method: "PUT",
|
||||
body: JSON.stringify({ retries: 99 }),
|
||||
});
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect((await response.json()).errors.retries).toContain("at most 5");
|
||||
expect(rows).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("refuses a key the manifest never declared", async () => {
|
||||
state.permissions.add("admin.plugins.manage");
|
||||
|
||||
const response = await call("/plugins/sample/settings/admin", {
|
||||
method: "PUT",
|
||||
body: JSON.stringify({ somethingElse: "x" }),
|
||||
});
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect((await response.json()).errors.somethingElse).toBeDefined();
|
||||
expect(rows).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("never returns a secret's value", async () => {
|
||||
state.permissions.add("admin.plugins.manage");
|
||||
|
||||
await call("/plugins/sample/settings/admin", {
|
||||
method: "PUT",
|
||||
body: JSON.stringify({ apiKey: "tskey-very-secret" }),
|
||||
});
|
||||
|
||||
const response = await call("/plugins/sample/settings/admin");
|
||||
const body = await response.text();
|
||||
|
||||
expect(body).not.toContain("tskey-very-secret");
|
||||
expect(JSON.parse(body).values.apiKey).toEqual({ set: true });
|
||||
});
|
||||
|
||||
it("treats a redacted echo as no change", async () => {
|
||||
state.permissions.add("admin.plugins.manage");
|
||||
|
||||
await call("/plugins/sample/settings/admin", {
|
||||
method: "PUT",
|
||||
body: JSON.stringify({ apiKey: "tskey-original" }),
|
||||
});
|
||||
await call("/plugins/sample/settings/admin", {
|
||||
method: "PUT",
|
||||
body: JSON.stringify({ apiKey: { set: true }, retries: 1 }),
|
||||
});
|
||||
|
||||
const row = rows.find((entry) => entry.key === "apiKey")!;
|
||||
expect(
|
||||
Buffer.from(
|
||||
JSON.parse(row.value!).slice("sysenc:v1:".length),
|
||||
"base64",
|
||||
).toString("utf8"),
|
||||
).toBe("tskey-original");
|
||||
});
|
||||
|
||||
it("keeps the admin gate even for a field naming its own permission", async () => {
|
||||
// Holding the field's permission is not enough: admin scope is admin
|
||||
// scope, so a plugin cannot hand install-wide config to an ordinary user.
|
||||
state.permissions.add("sample.tweak");
|
||||
|
||||
const response = await call("/plugins/sample/settings/admin", {
|
||||
method: "PUT",
|
||||
body: JSON.stringify({ narrowed: "x" }),
|
||||
});
|
||||
|
||||
expect(response.status).toBe(403);
|
||||
expect(rows).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("narrows an admin field to holders of its permission", async () => {
|
||||
state.permissions.add("admin.plugins.manage");
|
||||
|
||||
const denied = await call("/plugins/sample/settings/admin", {
|
||||
method: "PUT",
|
||||
body: JSON.stringify({ narrowed: "x" }),
|
||||
});
|
||||
expect(denied.status).toBe(400);
|
||||
expect((await denied.json()).errors.narrowed).toContain("permission");
|
||||
|
||||
state.permissions.add("sample.tweak");
|
||||
const allowed = await call("/plugins/sample/settings/admin", {
|
||||
method: "PUT",
|
||||
body: JSON.stringify({ narrowed: "x" }),
|
||||
});
|
||||
expect(allowed.status).toBe(200);
|
||||
});
|
||||
|
||||
it("404s for a plugin that does not exist", async () => {
|
||||
state.permissions.add("admin.plugins.manage");
|
||||
|
||||
const response = await call("/plugins/nope/settings/admin");
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
});
|
||||
|
||||
it("rejects a body that is not an object", async () => {
|
||||
state.permissions.add("admin.plugins.manage");
|
||||
|
||||
const response = await call("/plugins/sample/settings/admin", {
|
||||
method: "PUT",
|
||||
body: JSON.stringify([1, 2, 3]),
|
||||
});
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
});
|
||||
});
|
||||
|
||||
describe("user scope", () => {
|
||||
it("lets any authenticated user read and write their own", async () => {
|
||||
const write = await call("/plugins/sample/settings/user", {
|
||||
method: "PUT",
|
||||
body: JSON.stringify({ theme: "light" }),
|
||||
});
|
||||
expect(write.status).toBe(200);
|
||||
|
||||
const read = await call("/plugins/sample/settings/user");
|
||||
expect((await read.json()).values.theme).toBe("light");
|
||||
});
|
||||
|
||||
it("scopes writes to the caller, never to a user id in the body", async () => {
|
||||
await call("/plugins/sample/settings/user", {
|
||||
method: "PUT",
|
||||
body: JSON.stringify({ theme: "light", userId: "someone-else" }),
|
||||
});
|
||||
|
||||
// The bogus key is rejected and the row belongs to the caller.
|
||||
expect(rows.every((row) => row.scopeId === "user-1")).toBe(true);
|
||||
|
||||
state.userId = "user-2";
|
||||
const other = await call("/plugins/sample/settings/user");
|
||||
expect((await other.json()).values.theme).toBe("dark");
|
||||
});
|
||||
|
||||
it("refuses a user field gated on a permission the caller lacks", async () => {
|
||||
const response = await call("/plugins/sample/settings/user", {
|
||||
method: "PUT",
|
||||
body: JSON.stringify({ advanced: "x" }),
|
||||
});
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect((await response.json()).errors.advanced).toContain("permission");
|
||||
expect(rows).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("allows it once the caller holds that permission", async () => {
|
||||
state.permissions.add("sample.tweak");
|
||||
|
||||
const response = await call("/plugins/sample/settings/user", {
|
||||
method: "PUT",
|
||||
body: JSON.stringify({ advanced: "x" }),
|
||||
});
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
});
|
||||
|
||||
it("refuses an admin field through the user route", async () => {
|
||||
const response = await call("/plugins/sample/settings/user", {
|
||||
method: "PUT",
|
||||
body: JSON.stringify({ apiKey: "nope" }),
|
||||
});
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(rows).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("host scope", () => {
|
||||
it("lets a user with edit access read and write", async () => {
|
||||
const write = await call("/plugins/sample/settings/host/7", {
|
||||
method: "PUT",
|
||||
body: JSON.stringify({ enableThing: true, port: 22 }),
|
||||
});
|
||||
expect(write.status).toBe(200);
|
||||
|
||||
const read = await call("/plugins/sample/settings/host/7");
|
||||
const values = (await read.json()).values;
|
||||
expect(values.enableThing).toBe(true);
|
||||
expect(values.port).toBe(22);
|
||||
});
|
||||
|
||||
it("refuses a host the caller cannot edit", async () => {
|
||||
state.hostEditAccess = false;
|
||||
|
||||
const read = await call("/plugins/sample/settings/host/7");
|
||||
expect(read.status).toBe(403);
|
||||
|
||||
const write = await call("/plugins/sample/settings/host/7", {
|
||||
method: "PUT",
|
||||
body: JSON.stringify({ port: 22 }),
|
||||
});
|
||||
expect(write.status).toBe(403);
|
||||
expect(rows).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("rejects an invalid host id", async () => {
|
||||
const response = await call("/plugins/sample/settings/host/not-a-number");
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
});
|
||||
|
||||
it("keeps each host's values apart", async () => {
|
||||
await call("/plugins/sample/settings/host/7", {
|
||||
method: "PUT",
|
||||
body: JSON.stringify({ port: 22 }),
|
||||
});
|
||||
await call("/plugins/sample/settings/host/8", {
|
||||
method: "PUT",
|
||||
body: JSON.stringify({ port: 2222 }),
|
||||
});
|
||||
|
||||
const seven = await (await call("/plugins/sample/settings/host/7")).json();
|
||||
const eight = await (await call("/plugins/sample/settings/host/8")).json();
|
||||
|
||||
expect(seven.values.port).toBe(22);
|
||||
expect(eight.values.port).toBe(2222);
|
||||
});
|
||||
|
||||
it("returns the declared default for an untouched host", async () => {
|
||||
const response = await call("/plugins/sample/settings/host/9");
|
||||
|
||||
expect((await response.json()).values.enableThing).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,442 @@
|
||||
/**
|
||||
* The plugin settings service.
|
||||
*
|
||||
* Storage is faked at the repository boundary; everything above it - schema
|
||||
* lookup, validation, encryption, redaction and change notification - is the
|
||||
* real implementation, because that is where the behaviour worth protecting
|
||||
* lives.
|
||||
*/
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { PluginManifest } from "@termix/plugin-sdk/manifest";
|
||||
|
||||
interface Row {
|
||||
pluginId: string;
|
||||
scope: string;
|
||||
scopeId: string | null;
|
||||
key: string;
|
||||
value: string | null;
|
||||
encrypted: boolean;
|
||||
}
|
||||
|
||||
const rows: Row[] = [];
|
||||
|
||||
function find(
|
||||
pluginId: string,
|
||||
scope: string,
|
||||
scopeId: string | null,
|
||||
key: string,
|
||||
) {
|
||||
return rows.find(
|
||||
(row) =>
|
||||
row.pluginId === pluginId &&
|
||||
row.scope === scope &&
|
||||
row.scopeId === scopeId &&
|
||||
row.key === key,
|
||||
);
|
||||
}
|
||||
|
||||
const settingsRepository = {
|
||||
get: vi.fn(
|
||||
async (
|
||||
pluginId: string,
|
||||
scope: string,
|
||||
scopeId: string | null,
|
||||
key: string,
|
||||
) => find(pluginId, scope, scopeId, key) ?? null,
|
||||
),
|
||||
getAll: vi.fn(
|
||||
async (pluginId: string, scope: string, scopeId: string | null) =>
|
||||
rows.filter(
|
||||
(row) =>
|
||||
row.pluginId === pluginId &&
|
||||
row.scope === scope &&
|
||||
row.scopeId === scopeId,
|
||||
),
|
||||
),
|
||||
set: vi.fn(
|
||||
async (
|
||||
pluginId: string,
|
||||
scope: string,
|
||||
scopeId: string | null,
|
||||
key: string,
|
||||
value: string | null,
|
||||
encrypted = false,
|
||||
) => {
|
||||
const existing = find(pluginId, scope, scopeId, key);
|
||||
if (existing) {
|
||||
existing.value = value;
|
||||
existing.encrypted = encrypted;
|
||||
return;
|
||||
}
|
||||
rows.push({ pluginId, scope, scopeId, key, value, encrypted });
|
||||
},
|
||||
),
|
||||
};
|
||||
|
||||
const coreSettings = new Map<string, string>();
|
||||
|
||||
vi.mock("../../database/repositories/factory.js", () => ({
|
||||
createCurrentPluginSettingsRepository: () => settingsRepository,
|
||||
createCurrentSettingsRepository: () => ({
|
||||
get: async (key: string) => coreSettings.get(key) ?? null,
|
||||
}),
|
||||
}));
|
||||
|
||||
// Real AES would need the system key on disk. The prefix and the idempotence
|
||||
// are what the service depends on, so the double keeps both.
|
||||
vi.mock("../../utils/system-secret-crypto.js", () => ({
|
||||
isSystemEncrypted: (value: string) => value.startsWith("sysenc:v1:"),
|
||||
encryptSystemSecret: async (plaintext: string) =>
|
||||
!plaintext || plaintext.startsWith("sysenc:v1:")
|
||||
? plaintext
|
||||
: `sysenc:v1:${Buffer.from(plaintext, "utf8").toString("base64")}`,
|
||||
decryptSystemSecret: async (stored: string) =>
|
||||
stored.startsWith("sysenc:v1:")
|
||||
? Buffer.from(stored.slice("sysenc:v1:".length), "base64").toString(
|
||||
"utf8",
|
||||
)
|
||||
: stored,
|
||||
}));
|
||||
|
||||
const {
|
||||
CORE_SETTINGS_ALLOWLIST,
|
||||
clearSettingsListeners,
|
||||
declaredFields,
|
||||
findField,
|
||||
getAllSettings,
|
||||
getSetting,
|
||||
onSettingsChange,
|
||||
readCoreSetting,
|
||||
setSetting,
|
||||
} = await import("../../plugins/settings.js");
|
||||
|
||||
function manifest(settings: unknown): PluginManifest {
|
||||
return {
|
||||
id: "sample",
|
||||
name: "Sample",
|
||||
version: "1.0.0",
|
||||
description: "",
|
||||
author: { name: "t" },
|
||||
license: "MIT",
|
||||
category: "Productivity",
|
||||
engine: { termix: ">=2.9.0", api: "1" },
|
||||
capabilities: [],
|
||||
contributes: { settings },
|
||||
} as PluginManifest;
|
||||
}
|
||||
|
||||
const ADMIN = manifest({
|
||||
admin: [
|
||||
{ key: "apiKey", type: "secret", labelKey: "k" },
|
||||
{ key: "baseUrl", type: "string", labelKey: "k", default: "https://x" },
|
||||
{ key: "retries", type: "number", labelKey: "k", min: 0, max: 5 },
|
||||
{ key: "enabled", type: "boolean", labelKey: "k", default: false },
|
||||
{
|
||||
key: "mode",
|
||||
type: "select",
|
||||
labelKey: "k",
|
||||
options: [
|
||||
{ value: "fast", labelKey: "k" },
|
||||
{ value: "slow", labelKey: "k" },
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
rows.length = 0;
|
||||
coreSettings.clear();
|
||||
clearSettingsListeners("sample");
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("declaredFields", () => {
|
||||
it("returns the fields for each scope", () => {
|
||||
expect(declaredFields(ADMIN, "admin").map((f) => f.key)).toEqual([
|
||||
"apiKey",
|
||||
"baseUrl",
|
||||
"retries",
|
||||
"enabled",
|
||||
"mode",
|
||||
]);
|
||||
expect(declaredFields(ADMIN, "user")).toEqual([]);
|
||||
});
|
||||
|
||||
it("treats the host enable key as a real boolean field", () => {
|
||||
const withHost = manifest({
|
||||
host: {
|
||||
enableKey: "enableThing",
|
||||
enableLabelKey: "k",
|
||||
fields: [{ key: "port", type: "number", labelKey: "k" }],
|
||||
},
|
||||
});
|
||||
|
||||
const fields = declaredFields(withHost, "host");
|
||||
expect(fields.map((f) => f.key)).toEqual(["enableThing", "port"]);
|
||||
expect(fields[0]).toMatchObject({ type: "boolean", default: false });
|
||||
});
|
||||
|
||||
it("finds a field by key", () => {
|
||||
expect(findField(ADMIN, "admin", "baseUrl")?.type).toBe("string");
|
||||
expect(findField(ADMIN, "admin", "nope")).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("setSetting", () => {
|
||||
it("writes a valid value and reads it back", async () => {
|
||||
expect(
|
||||
await setSetting(ADMIN, "admin", null, "baseUrl", "https://y"),
|
||||
).toBeNull();
|
||||
expect(await getSetting(ADMIN, "admin", null, "baseUrl")).toBe("https://y");
|
||||
});
|
||||
|
||||
it("refuses a key the manifest never declared", async () => {
|
||||
const error = await setSetting(ADMIN, "admin", null, "wat", "x");
|
||||
|
||||
expect(error).toContain("not a settings field this plugin declares");
|
||||
expect(rows).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("refuses a value that fails the declared constraint", async () => {
|
||||
expect(await setSetting(ADMIN, "admin", null, "retries", 99)).toContain(
|
||||
"at most 5",
|
||||
);
|
||||
expect(
|
||||
await setSetting(ADMIN, "admin", null, "mode", "sideways"),
|
||||
).toContain("must be one of");
|
||||
expect(rows).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("coerces the strings a form actually sends", async () => {
|
||||
expect(await setSetting(ADMIN, "admin", null, "retries", "3")).toBeNull();
|
||||
expect(await getSetting(ADMIN, "admin", null, "retries")).toBe(3);
|
||||
|
||||
expect(
|
||||
await setSetting(ADMIN, "admin", null, "enabled", "true"),
|
||||
).toBeNull();
|
||||
expect(await getSetting(ADMIN, "admin", null, "enabled")).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps each scope separate", async () => {
|
||||
const scoped = manifest({
|
||||
admin: [{ key: "v", type: "string", labelKey: "k" }],
|
||||
user: [{ key: "v", type: "string", labelKey: "k" }],
|
||||
host: { fields: [{ key: "v", type: "string", labelKey: "k" }] },
|
||||
});
|
||||
|
||||
await setSetting(scoped, "admin", null, "v", "a");
|
||||
await setSetting(scoped, "user", "user-1", "v", "u");
|
||||
await setSetting(scoped, "host", 7, "v", "h");
|
||||
|
||||
expect(await getSetting(scoped, "admin", null, "v")).toBe("a");
|
||||
expect(await getSetting(scoped, "user", "user-1", "v")).toBe("u");
|
||||
expect(await getSetting(scoped, "host", 7, "v")).toBe("h");
|
||||
// A different user sees nothing of the first user's value.
|
||||
expect(await getSetting(scoped, "user", "user-2", "v")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("stores a host id as text, matching the polymorphic scope column", async () => {
|
||||
const scoped = manifest({
|
||||
host: { fields: [{ key: "v", type: "string", labelKey: "k" }] },
|
||||
});
|
||||
|
||||
await setSetting(scoped, "host", 12, "v", "x");
|
||||
|
||||
expect(rows[0].scopeId).toBe("12");
|
||||
// Either spelling resolves to the same row.
|
||||
expect(await getSetting(scoped, "host", "12", "v")).toBe("x");
|
||||
});
|
||||
});
|
||||
|
||||
describe("defaults", () => {
|
||||
it("falls back to the declared default when nothing is stored", async () => {
|
||||
expect(await getSetting(ADMIN, "admin", null, "baseUrl")).toBe("https://x");
|
||||
expect(await getSetting(ADMIN, "admin", null, "enabled")).toBe(false);
|
||||
});
|
||||
|
||||
it("returns undefined for a field with no default and no value", async () => {
|
||||
expect(await getSetting(ADMIN, "admin", null, "retries")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("returns undefined for a key the manifest does not declare", async () => {
|
||||
expect(await getSetting(ADMIN, "admin", null, "wat")).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("secrets", () => {
|
||||
it("encrypts on the way in and decrypts on the way out", async () => {
|
||||
await setSetting(ADMIN, "admin", null, "apiKey", "tskey-secret");
|
||||
|
||||
const row = rows.find((entry) => entry.key === "apiKey")!;
|
||||
expect(row.encrypted).toBe(true);
|
||||
expect(row.value).not.toContain("tskey-secret");
|
||||
expect(JSON.parse(row.value!)).toMatch(/^sysenc:v1:/);
|
||||
|
||||
expect(await getSetting(ADMIN, "admin", null, "apiKey")).toBe(
|
||||
"tskey-secret",
|
||||
);
|
||||
});
|
||||
|
||||
it("redacts a secret rather than returning it", async () => {
|
||||
await setSetting(ADMIN, "admin", null, "apiKey", "tskey-secret");
|
||||
|
||||
const values = await getAllSettings(ADMIN, "admin", null, {
|
||||
redactSecrets: true,
|
||||
});
|
||||
|
||||
expect(values.apiKey).toEqual({ set: true });
|
||||
expect(JSON.stringify(values)).not.toContain("tskey-secret");
|
||||
});
|
||||
|
||||
it("reports an unset secret as set:false", async () => {
|
||||
const values = await getAllSettings(ADMIN, "admin", null, {
|
||||
redactSecrets: true,
|
||||
});
|
||||
|
||||
expect(values.apiKey).toEqual({ set: false });
|
||||
});
|
||||
|
||||
it("treats a redacted echo as leave-it-alone", async () => {
|
||||
await setSetting(ADMIN, "admin", null, "apiKey", "original");
|
||||
|
||||
expect(
|
||||
await setSetting(ADMIN, "admin", null, "apiKey", { set: true }),
|
||||
).toBeNull();
|
||||
|
||||
expect(await getSetting(ADMIN, "admin", null, "apiKey")).toBe("original");
|
||||
});
|
||||
|
||||
it("lets a secret be replaced and cleared", async () => {
|
||||
await setSetting(ADMIN, "admin", null, "apiKey", "first");
|
||||
await setSetting(ADMIN, "admin", null, "apiKey", "second");
|
||||
expect(await getSetting(ADMIN, "admin", null, "apiKey")).toBe("second");
|
||||
|
||||
await setSetting(ADMIN, "admin", null, "apiKey", "");
|
||||
const values = await getAllSettings(ADMIN, "admin", null, {
|
||||
redactSecrets: true,
|
||||
});
|
||||
expect(values.apiKey).toEqual({ set: false });
|
||||
});
|
||||
|
||||
it("hands a plugin its own secret in the clear", async () => {
|
||||
await setSetting(ADMIN, "admin", null, "apiKey", "tskey-secret");
|
||||
|
||||
const values = await getAllSettings(ADMIN, "admin", null);
|
||||
|
||||
expect(values.apiKey).toBe("tskey-secret");
|
||||
});
|
||||
});
|
||||
|
||||
describe("getAllSettings", () => {
|
||||
it("is driven by the manifest, not by the stored rows", async () => {
|
||||
await setSetting(ADMIN, "admin", null, "baseUrl", "https://y");
|
||||
// A row for a field the plugin has since dropped.
|
||||
rows.push({
|
||||
pluginId: "sample",
|
||||
scope: "admin",
|
||||
scopeId: null,
|
||||
key: "removedField",
|
||||
value: JSON.stringify("stale"),
|
||||
encrypted: false,
|
||||
});
|
||||
|
||||
const values = await getAllSettings(ADMIN, "admin", null);
|
||||
|
||||
expect(values.baseUrl).toBe("https://y");
|
||||
expect(values.enabled).toBe(false);
|
||||
expect(values).not.toHaveProperty("removedField");
|
||||
});
|
||||
|
||||
it("returns nothing for a scope the plugin declares no fields in", async () => {
|
||||
expect(await getAllSettings(ADMIN, "user", "user-1")).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
describe("onSettingsChange", () => {
|
||||
it("fires on a successful write", async () => {
|
||||
const listener = vi.fn();
|
||||
onSettingsChange("sample", "baseUrl", listener);
|
||||
|
||||
await setSetting(ADMIN, "admin", null, "baseUrl", "https://z");
|
||||
|
||||
expect(listener).toHaveBeenCalledWith("https://z");
|
||||
});
|
||||
|
||||
it("does not fire for a rejected write", async () => {
|
||||
const listener = vi.fn();
|
||||
onSettingsChange("sample", "retries", listener);
|
||||
|
||||
await setSetting(ADMIN, "admin", null, "retries", 99);
|
||||
|
||||
expect(listener).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("stops firing once unsubscribed", async () => {
|
||||
const listener = vi.fn();
|
||||
const unsubscribe = onSettingsChange("sample", "baseUrl", listener);
|
||||
|
||||
unsubscribe();
|
||||
await setSetting(ADMIN, "admin", null, "baseUrl", "https://z");
|
||||
|
||||
expect(listener).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("only notifies listeners for that key and plugin", async () => {
|
||||
const other = vi.fn();
|
||||
const another = vi.fn();
|
||||
onSettingsChange("sample", "enabled", other);
|
||||
onSettingsChange("different-plugin", "baseUrl", another);
|
||||
|
||||
await setSetting(ADMIN, "admin", null, "baseUrl", "https://z");
|
||||
|
||||
expect(other).not.toHaveBeenCalled();
|
||||
expect(another).not.toHaveBeenCalled();
|
||||
clearSettingsListeners("different-plugin");
|
||||
});
|
||||
|
||||
it("survives a listener that throws", async () => {
|
||||
const good = vi.fn();
|
||||
onSettingsChange("sample", "baseUrl", () => {
|
||||
throw new Error("listener blew up");
|
||||
});
|
||||
onSettingsChange("sample", "baseUrl", good);
|
||||
|
||||
await expect(
|
||||
setSetting(ADMIN, "admin", null, "baseUrl", "https://z"),
|
||||
).resolves.toBeNull();
|
||||
expect(good).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("drops every listener when the plugin deactivates", async () => {
|
||||
const listener = vi.fn();
|
||||
onSettingsChange("sample", "baseUrl", listener);
|
||||
|
||||
clearSettingsListeners("sample");
|
||||
await setSetting(ADMIN, "admin", null, "baseUrl", "https://z");
|
||||
|
||||
expect(listener).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("readCoreSetting", () => {
|
||||
it("returns an allowlisted key", async () => {
|
||||
coreSettings.set("app_name", "Termix");
|
||||
|
||||
expect(await readCoreSetting("app_name")).toBe("Termix");
|
||||
});
|
||||
|
||||
it("refuses a key that is not on the allowlist", async () => {
|
||||
coreSettings.set("jwt_secret", "nope");
|
||||
|
||||
await expect(readCoreSetting("jwt_secret")).rejects.toThrow(
|
||||
/not readable by plugins/,
|
||||
);
|
||||
});
|
||||
|
||||
it("does not expose anything sensitive through the allowlist", () => {
|
||||
for (const key of CORE_SETTINGS_ALLOWLIST) {
|
||||
expect(key).not.toMatch(/secret|password|token|key$/i);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,228 @@
|
||||
/**
|
||||
* The Tailscale settings migration.
|
||||
*
|
||||
* An upgrade must be lossless: an install that had a working API key must keep
|
||||
* working without anyone re-entering it. That is the whole point of the test,
|
||||
* and running it twice proves a restart cannot corrupt what it moved.
|
||||
*/
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
interface Row {
|
||||
pluginId: string;
|
||||
scope: string;
|
||||
scopeId: string | null;
|
||||
key: string;
|
||||
value: string | null;
|
||||
encrypted: boolean;
|
||||
}
|
||||
|
||||
const coreSettings = new Map<string, string>();
|
||||
const pluginRows: Row[] = [];
|
||||
let pluginInstalled = true;
|
||||
|
||||
const pluginSettingsRepository = {
|
||||
get: async (
|
||||
pluginId: string,
|
||||
scope: string,
|
||||
scopeId: string | null,
|
||||
key: string,
|
||||
) =>
|
||||
pluginRows.find(
|
||||
(row) =>
|
||||
row.pluginId === pluginId &&
|
||||
row.scope === scope &&
|
||||
row.scopeId === scopeId &&
|
||||
row.key === key,
|
||||
) ?? null,
|
||||
set: async (
|
||||
pluginId: string,
|
||||
scope: string,
|
||||
scopeId: string | null,
|
||||
key: string,
|
||||
value: string | null,
|
||||
encrypted = false,
|
||||
) => {
|
||||
const existing = pluginRows.find(
|
||||
(row) =>
|
||||
row.pluginId === pluginId &&
|
||||
row.scope === scope &&
|
||||
row.scopeId === scopeId &&
|
||||
row.key === key,
|
||||
);
|
||||
if (existing) {
|
||||
existing.value = value;
|
||||
existing.encrypted = encrypted;
|
||||
return;
|
||||
}
|
||||
pluginRows.push({ pluginId, scope, scopeId, key, value, encrypted });
|
||||
},
|
||||
};
|
||||
|
||||
vi.mock("../../database/repositories/factory.js", () => ({
|
||||
createCurrentPluginRepository: () => ({
|
||||
findById: async (id: string) =>
|
||||
pluginInstalled && id === "tailscale" ? { id } : null,
|
||||
}),
|
||||
createCurrentPluginSettingsRepository: () => pluginSettingsRepository,
|
||||
createCurrentSettingsRepository: () => ({
|
||||
get: async (key: string) => coreSettings.get(key) ?? null,
|
||||
delete: async (key: string) => coreSettings.delete(key),
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("../../utils/system-secret-crypto.js", () => ({
|
||||
isSystemEncrypted: (value: string) => value.startsWith("sysenc:v1:"),
|
||||
encryptSystemSecret: async (plaintext: string) =>
|
||||
!plaintext || plaintext.startsWith("sysenc:v1:")
|
||||
? plaintext
|
||||
: `sysenc:v1:${Buffer.from(plaintext, "utf8").toString("base64")}`,
|
||||
decryptSystemSecret: async (stored: string) =>
|
||||
stored.startsWith("sysenc:v1:")
|
||||
? Buffer.from(stored.slice("sysenc:v1:".length), "base64").toString(
|
||||
"utf8",
|
||||
)
|
||||
: stored,
|
||||
}));
|
||||
|
||||
const { runTailscaleSettingsMigration } =
|
||||
await import("../../utils/crypto-migration/tailscale-settings-migration.js");
|
||||
|
||||
function storedValue(key: string): unknown {
|
||||
const row = pluginRows.find((entry) => entry.key === key);
|
||||
return row?.value === null || row?.value === undefined
|
||||
? undefined
|
||||
: JSON.parse(row.value);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
coreSettings.clear();
|
||||
pluginRows.length = 0;
|
||||
pluginInstalled = true;
|
||||
});
|
||||
|
||||
describe("runTailscaleSettingsMigration", () => {
|
||||
it("moves an existing key and base URL into plugin settings", async () => {
|
||||
coreSettings.set("tailscale_api_key", "tskey-api-legacy");
|
||||
coreSettings.set("tailscale_api_base_url", "https://headscale.example");
|
||||
|
||||
const result = await runTailscaleSettingsMigration();
|
||||
|
||||
expect(result.moved).toEqual([
|
||||
"tailscale_api_key",
|
||||
"tailscale_api_base_url",
|
||||
]);
|
||||
expect(storedValue("apiKey")).toMatch(/^sysenc:v1:/);
|
||||
expect(storedValue("apiBaseUrl")).toBe("https://headscale.example");
|
||||
});
|
||||
|
||||
it("encrypts the key, which was stored in plaintext before", async () => {
|
||||
coreSettings.set("tailscale_api_key", "tskey-api-legacy");
|
||||
|
||||
await runTailscaleSettingsMigration();
|
||||
|
||||
const row = pluginRows.find((entry) => entry.key === "apiKey")!;
|
||||
expect(row.encrypted).toBe(true);
|
||||
expect(row.value).not.toContain("tskey-api-legacy");
|
||||
});
|
||||
|
||||
it("leaves the base URL readable, since it is not a secret", async () => {
|
||||
coreSettings.set("tailscale_api_base_url", "https://headscale.example");
|
||||
|
||||
await runTailscaleSettingsMigration();
|
||||
|
||||
const row = pluginRows.find((entry) => entry.key === "apiBaseUrl")!;
|
||||
expect(row.encrypted).toBe(false);
|
||||
});
|
||||
|
||||
it("removes the legacy rows so no plaintext key is left behind", async () => {
|
||||
coreSettings.set("tailscale_api_key", "tskey-api-legacy");
|
||||
coreSettings.set("tailscale_api_base_url", "https://headscale.example");
|
||||
|
||||
await runTailscaleSettingsMigration();
|
||||
|
||||
expect(coreSettings.has("tailscale_api_key")).toBe(false);
|
||||
expect(coreSettings.has("tailscale_api_base_url")).toBe(false);
|
||||
});
|
||||
|
||||
it("is idempotent: a second run changes nothing", async () => {
|
||||
coreSettings.set("tailscale_api_key", "tskey-api-legacy");
|
||||
coreSettings.set("tailscale_api_base_url", "https://headscale.example");
|
||||
|
||||
await runTailscaleSettingsMigration();
|
||||
const afterFirst = JSON.parse(JSON.stringify(pluginRows));
|
||||
|
||||
const second = await runTailscaleSettingsMigration();
|
||||
|
||||
expect(second.moved).toEqual([]);
|
||||
expect(pluginRows).toEqual(afterFirst);
|
||||
});
|
||||
|
||||
it("cannot double-encrypt the key across runs", async () => {
|
||||
coreSettings.set("tailscale_api_key", "tskey-api-legacy");
|
||||
|
||||
await runTailscaleSettingsMigration();
|
||||
const first = storedValue("apiKey");
|
||||
|
||||
await runTailscaleSettingsMigration();
|
||||
|
||||
expect(storedValue("apiKey")).toBe(first);
|
||||
expect(String(storedValue("apiKey")).match(/sysenc:v1:/g)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("drops a stale legacy row when the value was already moved", async () => {
|
||||
// An interrupted run, or a downgrade that rewrote the old key.
|
||||
await pluginSettingsRepository.set(
|
||||
"tailscale",
|
||||
"admin",
|
||||
null,
|
||||
"apiKey",
|
||||
JSON.stringify("sysenc:v1:already"),
|
||||
true,
|
||||
);
|
||||
coreSettings.set("tailscale_api_key", "tskey-plaintext-leftover");
|
||||
|
||||
const result = await runTailscaleSettingsMigration();
|
||||
|
||||
expect(result.skipped).toContain("tailscale_api_key");
|
||||
expect(storedValue("apiKey")).toBe("sysenc:v1:already");
|
||||
expect(coreSettings.has("tailscale_api_key")).toBe(false);
|
||||
});
|
||||
|
||||
it("does nothing on a fresh install with no legacy rows", async () => {
|
||||
const result = await runTailscaleSettingsMigration();
|
||||
|
||||
expect(result.moved).toEqual([]);
|
||||
expect(pluginRows).toEqual([]);
|
||||
});
|
||||
|
||||
it("skips an empty legacy value rather than storing one", async () => {
|
||||
coreSettings.set("tailscale_api_key", "");
|
||||
|
||||
const result = await runTailscaleSettingsMigration();
|
||||
|
||||
expect(result.moved).toEqual([]);
|
||||
expect(pluginRows).toEqual([]);
|
||||
});
|
||||
|
||||
it("waits for the plugin row, because of the foreign key", async () => {
|
||||
pluginInstalled = false;
|
||||
coreSettings.set("tailscale_api_key", "tskey-api-legacy");
|
||||
|
||||
const result = await runTailscaleSettingsMigration();
|
||||
|
||||
expect(result.moved).toEqual([]);
|
||||
expect(pluginRows).toEqual([]);
|
||||
// Still there for the next boot, once the plugin is seeded.
|
||||
expect(coreSettings.get("tailscale_api_key")).toBe("tskey-api-legacy");
|
||||
});
|
||||
|
||||
it("moves each key independently", async () => {
|
||||
coreSettings.set("tailscale_api_key", "tskey-api-legacy");
|
||||
|
||||
const result = await runTailscaleSettingsMigration();
|
||||
|
||||
expect(result.moved).toEqual(["tailscale_api_key"]);
|
||||
expect(storedValue("apiBaseUrl")).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,105 @@
|
||||
/**
|
||||
* Moves the Tailscale API key and base URL out of core settings.
|
||||
*
|
||||
* Both lived in the core `settings` table, written by a core route that existed
|
||||
* only to serve one plugin. A6 gives plugins their own settings, so they belong
|
||||
* in plugin_settings under the tailscale plugin.
|
||||
*
|
||||
* The key was stored in plaintext, and the route that read it back masked only
|
||||
* its tail while being open to any authenticated user. Landing it as a secret
|
||||
* field encrypts it at rest, so this migration is a fix as much as a move.
|
||||
*
|
||||
* Idempotent in two ways: it stops when a value is already present in the new
|
||||
* home, and encryptSystemSecret returns an already-encrypted value unchanged,
|
||||
* so a second pass can neither duplicate nor double-encrypt.
|
||||
*/
|
||||
|
||||
import { databaseLogger } from "../logger.js";
|
||||
import {
|
||||
createCurrentPluginRepository,
|
||||
createCurrentPluginSettingsRepository,
|
||||
createCurrentSettingsRepository,
|
||||
} from "../../database/repositories/factory.js";
|
||||
import { encryptSystemSecret } from "../system-secret-crypto.js";
|
||||
|
||||
const PLUGIN_ID = "tailscale";
|
||||
|
||||
/** Legacy core key -> the field the plugin now declares. */
|
||||
const MOVES: { legacyKey: string; field: string; secret: boolean }[] = [
|
||||
{ legacyKey: "tailscale_api_key", field: "apiKey", secret: true },
|
||||
{ legacyKey: "tailscale_api_base_url", field: "apiBaseUrl", secret: false },
|
||||
];
|
||||
|
||||
export interface TailscaleSettingsMigrationResult {
|
||||
moved: string[];
|
||||
skipped: string[];
|
||||
}
|
||||
|
||||
export async function runTailscaleSettingsMigration(): Promise<TailscaleSettingsMigrationResult> {
|
||||
const moved: string[] = [];
|
||||
const skipped: string[] = [];
|
||||
|
||||
try {
|
||||
// The FK to plugins means there is nothing to migrate into until the
|
||||
// plugin has been seeded. Not an error: a build without it just has no
|
||||
// tailscale rows.
|
||||
const plugin = await createCurrentPluginRepository().findById(PLUGIN_ID);
|
||||
if (!plugin) return { moved, skipped };
|
||||
|
||||
const settingsRepository = createCurrentSettingsRepository();
|
||||
const pluginSettingsRepository = createCurrentPluginSettingsRepository();
|
||||
|
||||
for (const move of MOVES) {
|
||||
const legacyValue = await settingsRepository.get(move.legacyKey);
|
||||
if (legacyValue === null || legacyValue === "") {
|
||||
continue;
|
||||
}
|
||||
|
||||
const existing = await pluginSettingsRepository.get(
|
||||
PLUGIN_ID,
|
||||
"admin",
|
||||
null,
|
||||
move.field,
|
||||
);
|
||||
if (existing && existing.value !== null) {
|
||||
// Already moved. Drop the stale copy rather than leaving a plaintext
|
||||
// key behind for a reader that predates this change.
|
||||
await settingsRepository.delete(move.legacyKey);
|
||||
skipped.push(move.legacyKey);
|
||||
continue;
|
||||
}
|
||||
|
||||
const stored = move.secret
|
||||
? await encryptSystemSecret(legacyValue)
|
||||
: legacyValue;
|
||||
|
||||
await pluginSettingsRepository.set(
|
||||
PLUGIN_ID,
|
||||
"admin",
|
||||
null,
|
||||
move.field,
|
||||
JSON.stringify(stored),
|
||||
move.secret,
|
||||
);
|
||||
|
||||
await settingsRepository.delete(move.legacyKey);
|
||||
moved.push(move.legacyKey);
|
||||
}
|
||||
|
||||
if (moved.length > 0) {
|
||||
databaseLogger.info(
|
||||
`Moved ${moved.length} Tailscale setting(s) into plugin settings`,
|
||||
{ operation: "tailscale_settings_migration", moved: moved.join(", ") },
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
// A failed migration must not stop the backend. The plugin falls back to
|
||||
// "no key configured", which is a visible, recoverable state.
|
||||
databaseLogger.warn("Tailscale settings migration failed", {
|
||||
operation: "tailscale_settings_migration",
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
}
|
||||
|
||||
return { moved, skipped };
|
||||
}
|
||||
Reference in new issue
Block a user