feat: give plugins their own settings and one settings screen

This commit is contained in:
LukeGus committed 2026-09-22 18:31:19 -05:00
1 parent cc15559ea1
commit 49f0286a14
66 files changed
+36497 -451

No files matched your search

+15
View File
@@ -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,
+43
View File
@@ -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.
*
+43
View File
@@ -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.
*
+43
View File
@@ -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;
}
+25 -4
View File
@@ -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) {
+555
View File
@@ -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:
+74
View File
@@ -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
+319
View File
@@ -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);
}
+6
View File
@@ -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);
});
});
+442
View File
@@ -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 };
}
+3 -2
View File
@@ -185,6 +185,8 @@ export type Host = {
hasTelnetPassword?: boolean;
guacamoleConfig?: GuacamoleConfig;
/** Host-scope plugin settings, keyed by plugin id. Secrets are redacted. */
pluginSettings?: Record<string, Record<string, unknown>>;
forceKeyboardInteractive?: boolean;
isShared?: boolean;
@@ -372,8 +374,7 @@ export type AdminSection =
| "audit-log"
| "ssl"
| "plugins"
| "touch-input"
| "tailscale";
| "touch-input";
export type ThemeId =
| "dark"
| "light"
+13 -18
View File
@@ -126,14 +126,11 @@ const SessionLogsPanel = lazy(() =>
default: m.SessionLogsPanel,
})),
);
const UserProfilePanel = lazy(() =>
import("@/sidebar/UserProfilePanel").then((m) => ({
default: m.UserProfilePanel,
})),
);
const AdminSettingsPanel = lazy(() =>
import("@/sidebar/AdminSettingsPanel").then((m) => ({
default: m.AdminSettingsPanel,
// One screen behind both rail entries. It lazy-loads the profile and admin
// panels itself, so the shell no longer imports either directly.
const SettingsScreen = lazy(() =>
import("@/settings/SettingsScreen").then((m) => ({
default: m.SettingsScreen,
})),
);
const CredentialsPanel = lazy(() =>
@@ -2882,9 +2879,14 @@ export function AppShell({
</div>
)}
{railView === "user-profile" && (
<div className="flex-1 min-h-0 overflow-y-auto">
<UserProfilePanel
{(railView === "user-profile" ||
(railView === "admin-settings" && showAdminUI)) && (
<div className="flex flex-col flex-1 min-h-0">
<SettingsScreen
initialSection={
railView === "admin-settings" ? "admin" : "profile"
}
isAdmin={showAdminUI}
username={username}
onLogout={onLogout}
userPrefs={userPrefs}
@@ -2896,13 +2898,6 @@ export function AppShell({
onRemoteSyncReconnectHandled={() =>
setRemoteSyncReconnectRequested(false)
}
/>
</div>
)}
{railView === "admin-settings" && showAdminUI && (
<div className="flex flex-col flex-1 min-h-0 overflow-y-auto">
<AdminSettingsPanel
onEditingChange={setSidebarEditing}
onOpenHostTab={(host) => {
connectHost(host);
+147
View File
@@ -7,8 +7,153 @@ export interface PluginTabContribution {
openFrom: string[];
}
export type PluginSettingsFieldType =
| "boolean"
| "string"
| "number"
| "select"
| "multiselect"
| "secret"
| "textarea"
| "json"
| "custom";
export interface PluginSettingsField {
key: string;
type: PluginSettingsFieldType;
labelKey?: string;
descriptionKey?: string;
placeholderKey?: string;
default?: unknown;
options?: { value: string; labelKey: string }[];
min?: number;
max?: number;
requires?: string;
permission?: string;
group?: string;
component?: string;
}
export interface PluginHostSettingsContribution {
enableKey?: string;
enableLabelKey?: string;
fields: PluginSettingsField[];
}
export interface PluginSettingsContribution {
admin?: PluginSettingsField[];
user?: PluginSettingsField[];
host?: PluginHostSettingsContribution;
}
export interface PluginContributions {
tabs?: PluginTabContribution[];
settings?: PluginSettingsContribution;
}
/** A secret as it arrives from the server. The value never leaves the server. */
export interface RedactedSecret {
set: boolean;
}
export function isRedactedSecret(value: unknown): value is RedactedSecret {
return (
typeof value === "object" &&
value !== null &&
!Array.isArray(value) &&
typeof (value as RedactedSecret).set === "boolean"
);
}
export type PluginSettingsValues = Record<string, unknown>;
/** Per-field messages from a rejected PUT, keyed by field key. */
export interface PluginSettingsErrors {
[key: string]: string;
}
export class PluginSettingsValidationError extends Error {
readonly errors: PluginSettingsErrors;
constructor(errors: PluginSettingsErrors) {
super("Some settings were rejected");
this.name = "PluginSettingsValidationError";
this.errors = errors;
}
}
function settingsPath(pluginId: string, suffix: string): string {
return `/plugins/${encodeURIComponent(pluginId)}/settings/${suffix}`;
}
/** Turns a 400 carrying per-field errors into something a form can render. */
async function putSettings(
path: string,
values: PluginSettingsValues,
): Promise<PluginSettingsValues> {
try {
const response = await rbacApi.put(path, values);
return response.data?.values ?? {};
} catch (error) {
const data = (
error as { response?: { status?: number; data?: { errors?: unknown } } }
).response;
if (data?.status === 400 && data.data?.errors) {
throw new PluginSettingsValidationError(
data.data.errors as PluginSettingsErrors,
);
}
throw error;
}
}
export async function getPluginAdminSettings(
pluginId: string,
): Promise<PluginSettingsValues> {
const response = await rbacApi.get(settingsPath(pluginId, "admin"));
return response.data?.values ?? {};
}
export async function updatePluginAdminSettings(
pluginId: string,
values: PluginSettingsValues,
): Promise<PluginSettingsValues> {
return putSettings(settingsPath(pluginId, "admin"), values);
}
export async function getPluginUserSettings(
pluginId: string,
): Promise<PluginSettingsValues> {
const response = await rbacApi.get(settingsPath(pluginId, "user"));
return response.data?.values ?? {};
}
export async function updatePluginUserSettings(
pluginId: string,
values: PluginSettingsValues,
): Promise<PluginSettingsValues> {
return putSettings(settingsPath(pluginId, "user"), values);
}
export async function getPluginHostSettings(
pluginId: string,
hostId: number,
): Promise<PluginSettingsValues> {
const response = await rbacApi.get(
settingsPath(pluginId, `host/${encodeURIComponent(String(hostId))}`),
);
return response.data?.values ?? {};
}
export async function updatePluginHostSettings(
pluginId: string,
hostId: number,
values: PluginSettingsValues,
): Promise<PluginSettingsValues> {
return putSettings(
settingsPath(pluginId, `host/${encodeURIComponent(String(hostId))}`),
values,
);
}
/**
@@ -26,6 +171,8 @@ export interface PluginSummary {
/** enabled | disabled | blocked | failed, or the loader's live state. */
state: string;
contributes: PluginContributions | null;
/** Lucide icon name from the manifest. */
icon?: string;
tier?: string;
source?: string;
+45 -8
View File
@@ -3508,7 +3508,6 @@
"sectionApiKeys": "API Keys",
"sectionAuditLog": "Audit Log",
"sectionSsl": "SSL / Let's Encrypt",
"sectionTailscale": "Tailscale",
"sectionTouchInput": "Touch Input",
"touchEnabled": "Enable Touch Input",
"touchEnabledDesc": "Allow one-finger touch scrolling in terminals",
@@ -3667,11 +3666,6 @@
"enableRemoteDesktopDesc": "RDP/VNC remote desktop",
"enableRemoteDesktopDocsLink": "View docs",
"guacdUrl": "guacd URL",
"tailscaleApiKey": "Tailscale API Key",
"tailscaleApiKeyDescription": "Used for device discovery in the host editor. Supports Tailscale (tskey-) and Headscale (hskey-) keys. Generate a key at tailscale.com/admin/settings/keys or from your Headscale instance.",
"tailscaleApiKeyDocsLink": "View docs",
"tailscaleApiBaseUrl": "API Base URL",
"tailscaleApiBaseUrlDescription": "Optional. Point to a self-hosted Headscale instance instead of api.tailscale.com. Leave empty to use the Tailscale default.",
"oidcDescription": "Configure OpenID Connect for SSO. Fields marked * are required.",
"oidcDocsLink": "View docs",
"oidcClientId": "Client ID",
@@ -3822,8 +3816,6 @@
"remoteDesktopSaved": "Remote Desktop settings saved",
"remoteDesktopSaveFailed": "Failed to save Remote Desktop settings",
"remoteDesktopUpdateFailed": "Failed to update Remote Desktop setting",
"tailscaleSettingsSaved": "Tailscale settings saved",
"tailscaleSettingsSaveFailed": "Failed to save Tailscale settings",
"logLevelUpdateFailed": "Failed to update log level",
"oidcSaved": "OIDC configuration saved",
"oidcSaveFailed": "Failed to save OIDC config",
@@ -5261,7 +5253,52 @@
"title": "View tailnet devices",
"description": "See the devices on the connected tailnet."
}
},
"settings": {
"group": {
"api": "Tailscale API",
"devices": "Tailnet"
},
"apiKey": {
"label": "API key",
"description": "Used to list the devices on your tailnet. Works with a Tailscale or Headscale key.",
"placeholder": "tskey-api-... / hskey-api-..."
},
"apiBaseUrl": {
"label": "API base URL",
"description": "Leave empty for Tailscale. Set this to point at a Headscale instance.",
"placeholder": "https://api.tailscale.com/api/v2"
},
"devices": {
"label": "Device list",
"checking": "Checking the tailnet...",
"noKey": "No API key set yet.",
"error": "Could not reach the tailnet. Check the API key.",
"count": "{{count}} device reachable",
"count_other": "{{count}} devices reachable",
"refresh": "Check"
}
}
}
},
"settings": {
"title": "Settings",
"bandYou": "You",
"bandPlugins": "Plugins",
"bandInstance": "Instance",
"account": "Account",
"secretSet": "Set",
"secretNotSet": "Not set",
"secretReplace": "Replace",
"pluginGroupAdmin": "Server settings",
"pluginGroupUser": "Your settings",
"pluginNoSettings": "{{name}} has no settings you can change.",
"pluginStoppedNotice": "{{name}} is installed but not running. These settings are saved but will not take effect.",
"pluginSettingsSaved": "Settings saved",
"pluginSettingsInvalid": "Some settings could not be saved. Check the highlighted fields.",
"pluginSettingsSaveFailed": "Failed to save settings",
"pluginSettingsLoadFailed": "Failed to load settings",
"pluginsGroupLabel": "Plugins",
"hostPluginNotRunning": "{{name}} is installed but not running. These settings are saved but will not take effect."
}
}
+140
View File
@@ -0,0 +1,140 @@
/**
* Host editor sections contributed by plugins.
*
* A plugin declares host-scope fields in its manifest and core draws them, so
* a plugin cannot ship its own form styling and a section disappears when its
* plugin does. The enable switch comes first and gates the rest, which is the
* shape the hardcoded feature tabs already have.
*
* Nothing is rendered when no enabled plugin declares host settings, so the
* group does not appear as an empty tab. Phase B fills it as each feature
* moves its columns out of ssh_data.
*/
import { useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { SectionCard } from "@/components/section-card";
import { PluginIcon } from "@/lib/plugin-icon";
import { getPlugins, type PluginSummary } from "@/api/plugins-api";
import { SettingsFieldRow } from "./SettingsFields";
import { isFieldActive } from "./settings-fields-util";
/** Values for every plugin on one host: { [pluginId]: { [key]: value } }. */
export type HostPluginSettings = Record<string, Record<string, unknown>>;
/** Enabled plugins declaring host-scope settings. */
export function usePluginHostSections(): PluginSummary[] {
const [plugins, setPlugins] = useState<PluginSummary[]>([]);
useEffect(() => {
let cancelled = false;
void getPlugins()
.then((loaded) => {
if (!cancelled) setPlugins(loaded);
})
.catch(() => {
// Additive: the built-in host tabs do not depend on this.
});
return () => {
cancelled = true;
};
}, []);
return useMemo(
() =>
plugins.filter((plugin) => {
if (!plugin.enabled) return false;
const host = plugin.contributes?.settings?.host;
return !!host && (host.fields.length > 0 || !!host.enableKey);
}),
[plugins],
);
}
export interface HostPluginSectionsProps {
plugins: PluginSummary[];
values: HostPluginSettings;
setValue: (pluginId: string, key: string, value: unknown) => void;
}
export function HostPluginSections({
plugins,
values,
setValue,
}: HostPluginSectionsProps) {
if (plugins.length === 0) return null;
return (
<div className="flex flex-col gap-2">
{plugins.map((plugin) => (
<HostPluginSection
key={plugin.id}
plugin={plugin}
values={values[plugin.id] ?? {}}
setValue={(key, value) => setValue(plugin.id, key, value)}
/>
))}
</div>
);
}
function HostPluginSection({
plugin,
values,
setValue,
}: {
plugin: PluginSummary;
values: Record<string, unknown>;
setValue: (key: string, value: unknown) => void;
}) {
const { t } = useTranslation();
const host = plugin.contributes?.settings?.host;
if (!host) return null;
const running = plugin.enabled && plugin.state !== "failed";
const enableKey = host.enableKey;
// With no enable switch the section is always on, which is what a plugin
// declaring only plain fields means.
const enabled = enableKey ? values[enableKey] === true : true;
return (
<SectionCard
title={plugin.name}
icon={<PluginIcon name={plugin.icon} className="size-3.5" />}
>
{!running && (
<div className="my-3 border border-yellow-500/40 bg-yellow-500/10 px-3 py-2 text-xs text-yellow-500">
{t("settings.hostPluginNotRunning", { name: plugin.name })}
</div>
)}
{enableKey && (
<SettingsFieldRow
pluginId={plugin.id}
field={{
key: enableKey,
type: "boolean",
labelKey: host.enableLabelKey,
}}
values={values}
setValue={setValue}
running={running}
/>
)}
{enabled &&
host.fields
.filter((field) => isFieldActive(field, values))
.map((field) => (
<SettingsFieldRow
key={field.key}
pluginId={plugin.id}
field={field}
values={values}
setValue={setValue}
running={running}
/>
))}
</SectionCard>
);
}
+238
View File
@@ -0,0 +1,238 @@
/**
* One plugin's settings page, drawn from its manifest.
*
* The plugin declares groups and fields; Termix renders them with the same
* SectionCard and SettingRow everything else uses. A plugin cannot ship its
* own form styling and drift from the rest of the app, and a page disappears
* cleanly when the plugin is uninstalled.
*/
import { useCallback, useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { Loader2 } from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/button";
import { SectionCard } from "@/components/section-card";
import { pluginKey } from "@/lib/plugin-i18n";
import { PluginIcon } from "@/lib/plugin-icon";
import {
getPluginAdminSettings,
getPluginUserSettings,
updatePluginAdminSettings,
updatePluginUserSettings,
PluginSettingsValidationError,
type PluginSettingsErrors,
type PluginSettingsField,
type PluginSummary,
} from "@/api/plugins-api";
import { SettingsFieldRow } from "./SettingsFields";
import { isFieldActive } from "./settings-fields-util";
interface PluginSettingsPageProps {
plugin: PluginSummary;
isAdmin: boolean;
}
type Scope = "admin" | "user";
export function PluginSettingsPage({
plugin,
isAdmin,
}: PluginSettingsPageProps) {
const { t } = useTranslation();
const settings = plugin.contributes?.settings;
const running = plugin.enabled && plugin.state !== "failed";
// Admin fields are not merely disabled for a non-admin: the values behind
// them are install-wide configuration they have no business reading.
const scopes = useMemo(() => {
const entries: { scope: Scope; fields: PluginSettingsField[] }[] = [];
if (isAdmin && (settings?.admin?.length ?? 0) > 0) {
entries.push({ scope: "admin", fields: settings!.admin! });
}
if ((settings?.user?.length ?? 0) > 0) {
entries.push({ scope: "user", fields: settings!.user! });
}
return entries;
}, [settings, isAdmin]);
if (scopes.length === 0) {
return (
<div className="flex flex-col gap-2 p-2.5">
<p className="text-xs text-muted-foreground px-1">
{t("settings.pluginNoSettings", { name: plugin.name })}
</p>
</div>
);
}
return (
<div className="flex flex-col gap-2 p-2.5">
{!running && (
<div className="border border-yellow-500/40 bg-yellow-500/10 px-3 py-2 text-xs text-yellow-500">
{t("settings.pluginStoppedNotice", { name: plugin.name })}
</div>
)}
{scopes.map(({ scope, fields }) => (
<PluginScopeForm
key={scope}
plugin={plugin}
scope={scope}
fields={fields}
running={running}
/>
))}
</div>
);
}
/**
* One scope's form.
*
* Admin and user settings are separate requests with separate permissions, so
* they save separately rather than sharing one dirty state.
*/
function PluginScopeForm({
plugin,
scope,
fields,
running,
}: {
plugin: PluginSummary;
scope: Scope;
fields: PluginSettingsField[];
running: boolean;
}) {
const { t } = useTranslation();
const [values, setValues] = useState<Record<string, unknown>>({});
const [errors, setErrors] = useState<PluginSettingsErrors>({});
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [dirty, setDirty] = useState(false);
const load = useCallback(async () => {
setLoading(true);
try {
const loaded =
scope === "admin"
? await getPluginAdminSettings(plugin.id)
: await getPluginUserSettings(plugin.id);
setValues(loaded);
setErrors({});
setDirty(false);
} catch {
toast.error(t("settings.pluginSettingsLoadFailed"));
} finally {
setLoading(false);
}
}, [plugin.id, scope, t]);
useEffect(() => {
void load();
}, [load]);
const setValue = (key: string, value: unknown) => {
setValues((current) => ({ ...current, [key]: value }));
setErrors((current) => {
if (!current[key]) return current;
const next = { ...current };
delete next[key];
return next;
});
setDirty(true);
};
const save = async () => {
setSaving(true);
try {
const saved =
scope === "admin"
? await updatePluginAdminSettings(plugin.id, values)
: await updatePluginUserSettings(plugin.id, values);
setValues(saved);
setErrors({});
setDirty(false);
toast.success(t("settings.pluginSettingsSaved"));
} catch (error) {
if (error instanceof PluginSettingsValidationError) {
setErrors(error.errors);
toast.error(t("settings.pluginSettingsInvalid"));
} else {
toast.error(t("settings.pluginSettingsSaveFailed"));
}
} finally {
setSaving(false);
}
};
// Fields keep their declared order within a group, and groups appear in the
// order their first field does, so a manifest controls the whole layout.
const groups = useMemo(() => {
const ordered: {
key: string | undefined;
fields: PluginSettingsField[];
}[] = [];
for (const field of fields) {
const existing = ordered.find((group) => group.key === field.group);
if (existing) existing.fields.push(field);
else ordered.push({ key: field.group, fields: [field] });
}
return ordered;
}, [fields]);
if (loading) {
return (
<div className="flex items-center gap-2 px-1 py-4 text-xs text-muted-foreground">
<Loader2 className="size-3.5 animate-spin" />
{t("common.loading")}
</div>
);
}
const icon = <PluginIcon name={plugin.icon} className="size-3.5" />;
return (
<>
{groups.map((group, index) => (
<SectionCard
key={group.key ?? `group-${index}`}
title={
group.key
? t(pluginKey(plugin.id, group.key))
: scope === "admin"
? t("settings.pluginGroupAdmin")
: t("settings.pluginGroupUser")
}
icon={icon}
>
{group.fields
.filter((field) => isFieldActive(field, values))
.map((field) => (
<SettingsFieldRow
key={field.key}
pluginId={plugin.id}
field={field}
values={values}
setValue={setValue}
running={running}
error={errors[field.key]}
/>
))}
</SectionCard>
))}
<div className="flex justify-end px-1">
<Button
size="sm"
className="h-7 text-xs"
disabled={!dirty || saving}
onClick={() => void save()}
>
{saving && <Loader2 className="size-3 animate-spin mr-1.5" />}
{t("common.save")}
</Button>
</div>
</>
);
}
+294
View File
@@ -0,0 +1,294 @@
/**
* One control per declared settings field type.
*
* A plugin supplies data; core draws the form. That is what keeps every
* plugin's settings page looking like the rest of the app, and what makes a
* page disappear cleanly when its plugin does.
*/
import { useTranslation } from "react-i18next";
import { Input } from "@/components/input";
import { Textarea } from "@/components/textarea";
import { Checkbox } from "@/components/checkbox";
import { PasswordInput } from "@/components/password-input";
import { Button } from "@/components/button";
import { FakeSwitch, SettingRow } from "@/components/section-card";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/select";
import { pluginKey } from "@/lib/plugin-i18n";
import { isRedactedSecret, type PluginSettingsField } from "@/api/plugins-api";
import { getSettingsComponent } from "./settings-components";
export interface SettingsFieldProps {
pluginId: string;
field: PluginSettingsField;
values: Record<string, unknown>;
setValue: (key: string, value: unknown) => void;
/** Whether the owning plugin is running. */
running: boolean;
/** Message from a rejected save, shown under the control. */
error?: string;
disabled?: boolean;
}
export function SettingsFieldRow({
pluginId,
field,
values,
setValue,
running,
error,
disabled = false,
}: SettingsFieldProps) {
const { t } = useTranslation();
const label = (key?: string) => (key ? t(pluginKey(pluginId, key)) : "");
// A custom field draws its own row: it has no single control to put in the
// right-hand slot, and often no label of its own either.
if (field.type === "custom") {
const Component = getSettingsComponent(pluginId, field.component);
if (!Component) return null;
return (
<Component
pluginId={pluginId}
values={values}
setValue={setValue}
running={running}
/>
);
}
const value = values[field.key];
const readOnly = disabled || !running;
const description = field.descriptionKey ? (
<>
{label(field.descriptionKey)}
{error && <span className="block text-destructive mt-0.5">{error}</span>}
</>
) : error ? (
<span className="text-destructive">{error}</span>
) : undefined;
return (
<SettingRow
label={label(field.labelKey)}
description={description}
badge={error ? t("common.error") : undefined}
>
<FieldControl
pluginId={pluginId}
field={field}
value={value}
setValue={setValue}
readOnly={readOnly}
/>
</SettingRow>
);
}
function FieldControl({
pluginId,
field,
value,
setValue,
readOnly,
}: {
pluginId: string;
field: PluginSettingsField;
value: unknown;
setValue: (key: string, value: unknown) => void;
readOnly: boolean;
}) {
const { t } = useTranslation();
const label = (key?: string) => (key ? t(pluginKey(pluginId, key)) : "");
const placeholder = field.placeholderKey
? label(field.placeholderKey)
: undefined;
switch (field.type) {
case "boolean":
return (
<FakeSwitch
checked={value === true}
disabled={readOnly}
onChange={(next) => setValue(field.key, next)}
/>
);
case "number":
return (
<Input
type="number"
className="h-7 w-40 text-sm"
disabled={readOnly}
min={field.min}
max={field.max}
placeholder={placeholder}
value={value === undefined || value === null ? "" : String(value)}
onChange={(event) =>
setValue(
field.key,
event.target.value === "" ? null : Number(event.target.value),
)
}
/>
);
case "select":
return (
<Select
disabled={readOnly}
value={typeof value === "string" ? value : ""}
onValueChange={(next) => setValue(field.key, next)}
>
<SelectTrigger className="h-7 w-52 text-sm">
<SelectValue placeholder={placeholder} />
</SelectTrigger>
<SelectContent>
{(field.options ?? []).map((option) => (
<SelectItem key={option.value} value={option.value}>
{label(option.labelKey)}
</SelectItem>
))}
</SelectContent>
</Select>
);
case "multiselect": {
const selected = Array.isArray(value) ? (value as string[]) : [];
return (
<div className="flex flex-col gap-1.5 items-end">
{(field.options ?? []).map((option) => (
<label
key={option.value}
className="flex items-center gap-2 text-xs text-muted-foreground"
>
<span>{label(option.labelKey)}</span>
<Checkbox
disabled={readOnly}
checked={selected.includes(option.value)}
onCheckedChange={(checked) =>
setValue(
field.key,
checked
? [...selected, option.value]
: selected.filter((entry) => entry !== option.value),
)
}
/>
</label>
))}
</div>
);
}
case "secret":
return (
<SecretControl
field={field}
value={value}
setValue={setValue}
readOnly={readOnly}
placeholder={placeholder}
/>
);
case "textarea":
case "json":
return (
<Textarea
className="w-64 text-sm font-mono min-h-16"
disabled={readOnly}
placeholder={placeholder}
value={
typeof value === "string"
? value
: value === undefined || value === null
? ""
: JSON.stringify(value, null, 2)
}
onChange={(event) => setValue(field.key, event.target.value)}
/>
);
default:
return (
<Input
className="h-7 w-52 text-sm"
disabled={readOnly}
placeholder={placeholder}
value={typeof value === "string" ? value : ""}
onChange={(event) => setValue(field.key, event.target.value)}
/>
);
}
}
/**
* A secret is never sent to the browser, so the control shows whether one is
* stored and offers to replace or clear it. Leaving it untouched sends the
* redacted marker straight back, which the server reads as "no change".
*/
function SecretControl({
field,
value,
setValue,
readOnly,
placeholder,
}: {
field: PluginSettingsField;
value: unknown;
setValue: (key: string, value: unknown) => void;
readOnly: boolean;
placeholder?: string;
}) {
const { t } = useTranslation();
const redacted = isRedactedSecret(value);
const stored = redacted && value.set;
if (redacted) {
return (
<div className="flex items-center gap-2">
<span className="text-xs text-muted-foreground">
{stored ? t("settings.secretSet") : t("settings.secretNotSet")}
</span>
<Button
variant="outline"
size="sm"
className="h-7 text-xs"
disabled={readOnly}
onClick={() => setValue(field.key, "")}
>
{stored ? t("settings.secretReplace") : t("common.edit")}
</Button>
</div>
);
}
return (
<div className="flex items-center gap-2">
<PasswordInput
className="h-7 w-52 text-sm"
disabled={readOnly}
placeholder={placeholder}
value={typeof value === "string" ? value : ""}
onChange={(event) => setValue(field.key, event.target.value)}
/>
<Button
variant="ghost"
size="sm"
className="h-7 text-xs"
disabled={readOnly}
onClick={() => setValue(field.key, { set: stored })}
>
{t("common.cancel")}
</Button>
</div>
);
}
+280
View File
@@ -0,0 +1,280 @@
/**
* Everything that configures the app rather than operating a server.
*
* Profile and admin were two separate rail panels, each a fixed accordion of
* hardcoded sections. That is the wrong shape for the number of sections they
* hold, and it left no place at all for a plugin to contribute one. This is a
* single screen with a banded left nav, and a plugin's page is an entry in it
* that appears and disappears with the plugin.
*
* Both rail buttons still open this, each on its own section, so the Navigation
* visibility toggles and the habit of reaching for either one keep working.
*/
import { lazy, Suspense, useEffect, useMemo, useState } from "react";
import type { ComponentProps } from "react";
import { useTranslation } from "react-i18next";
import {
ChevronDown,
Puzzle,
Settings as SettingsIcon,
User,
} from "lucide-react";
import { Separator } from "@/components/separator";
import { PluginIcon } from "@/lib/plugin-icon";
import { getPlugins, type PluginSummary } from "@/api/plugins-api";
import { PluginSettingsPage } from "./PluginSettingsPage";
import { registerLegacySettingsComponents } from "./legacy-settings-components";
const UserProfilePanel = lazy(() =>
import("@/sidebar/UserProfilePanel").then((m) => ({
default: m.UserProfilePanel,
})),
);
const AdminSettingsPanel = lazy(() =>
import("@/sidebar/AdminSettingsPanel").then((m) => ({
default: m.AdminSettingsPanel,
})),
);
// Taken from the panels themselves: this screen only forwards them, so
// restating their shapes here would be one more thing to keep in step.
type ProfileProps = ComponentProps<typeof UserProfilePanel>;
type AdminProps = ComponentProps<typeof AdminSettingsPanel>;
/** Built-in sections, plus "plugin:<id>" for a plugin's own settings page. */
export type SettingsSectionId = "profile" | "admin" | `plugin:${string}`;
export interface SettingsScreenProps {
/** Which page to land on. Both rail entries come in here. */
initialSection?: SettingsSectionId;
isAdmin?: boolean;
/** Forwarded to the profile panel. */
username?: ProfileProps["username"];
onLogout?: ProfileProps["onLogout"];
userPrefs?: ProfileProps["userPrefs"];
onPrefsChange?: ProfileProps["onPrefsChange"];
remoteSyncInitialServerUrl?: ProfileProps["remoteSyncInitialServerUrl"];
remoteSyncReconnectRequested?: ProfileProps["remoteSyncReconnectRequested"];
onRemoteSyncReconnectHandled?: ProfileProps["onRemoteSyncReconnectHandled"];
/** Forwarded to the admin panel. */
onEditingChange?: AdminProps["onEditingChange"];
onOpenHostTab?: AdminProps["onOpenHostTab"];
}
interface NavItem {
id: SettingsSectionId;
label: string;
icon: React.ElementType;
iconName?: string;
}
export function SettingsScreen({
initialSection = "profile",
isAdmin = false,
username,
onLogout,
userPrefs,
onPrefsChange,
remoteSyncInitialServerUrl,
remoteSyncReconnectRequested,
onRemoteSyncReconnectHandled,
onEditingChange,
onOpenHostTab,
}: SettingsScreenProps) {
const { t } = useTranslation();
const [section, setSection] = useState<SettingsSectionId>(initialSection);
const [navOpen, setNavOpen] = useState(false);
const [plugins, setPlugins] = useState<PluginSummary[]>([]);
// Registered here rather than at module scope so a custom field's component
// exists before any page that renders one. A7 moves this into the loader.
useEffect(() => {
registerLegacySettingsComponents();
}, []);
useEffect(() => {
let cancelled = false;
void getPlugins()
.then((loaded) => {
if (!cancelled) setPlugins(loaded);
})
.catch(() => {
// The plugin band is additive: the core sections still work without it.
});
return () => {
cancelled = true;
};
}, []);
// The rail can switch which entry opened the screen while it is mounted.
useEffect(() => setSection(initialSection), [initialSection]);
/**
* A plugin gets a page when it is enabled and declares settings this user
* could actually change. A non-admin seeing a page of admin-only fields
* would be a page they can read and never act on.
*/
const pluginPages = useMemo(
() =>
plugins.filter((plugin) => {
if (!plugin.enabled) return false;
const settings = plugin.contributes?.settings;
if (!settings) return false;
if ((settings.user?.length ?? 0) > 0) return true;
return isAdmin && (settings.admin?.length ?? 0) > 0;
}),
[plugins, isAdmin],
);
const bands = useMemo(() => {
const result: { heading: string; items: NavItem[] }[] = [
{
heading: t("settings.bandYou"),
items: [{ id: "profile", label: t("settings.account"), icon: User }],
},
];
if (pluginPages.length > 0) {
result.push({
heading: t("settings.bandPlugins"),
items: pluginPages.map((plugin) => ({
id: `plugin:${plugin.id}` as SettingsSectionId,
label: plugin.name,
icon: Puzzle,
iconName: plugin.icon,
})),
});
}
if (isAdmin) {
result.push({
heading: t("settings.bandInstance"),
items: [{ id: "admin", label: t("nav.admin"), icon: SettingsIcon }],
});
}
return result;
}, [t, pluginPages, isAdmin]);
const sections = bands.flatMap((band) => band.items);
const active = sections.find((item) => item.id === section) ?? sections[0];
// A plugin page can vanish under you: the plugin is disabled from the admin
// section in the same screen. Fall back rather than render an empty pane.
useEffect(() => {
if (
section.startsWith("plugin:") &&
!sections.some((s) => s.id === section)
) {
setSection("profile");
}
}, [section, sections]);
return (
<div className="flex h-full min-h-0 flex-col overflow-hidden bg-background">
<header className="flex h-12.5 shrink-0 flex-row items-center border-b border-border">
<div className="flex min-w-0 flex-1 items-center gap-2 px-3">
<SettingsIcon className="size-4 shrink-0 text-accent-brand" />
<span className="truncate text-base font-bold tracking-tight">
{t("settings.title")}
</span>
<Separator orientation="vertical" className="h-4" />
<span className="hidden truncate text-[10px] font-semibold uppercase tracking-widest text-muted-foreground md:inline">
{active?.label}
</span>
{/* The nav column does not fit beside the content on a phone, so
there it collapses behind the section name. */}
<button
onClick={() => setNavOpen((open) => !open)}
aria-expanded={navOpen}
className="flex min-w-0 items-center gap-1 text-[10px] font-semibold uppercase tracking-widest text-muted-foreground md:hidden"
>
<span className="truncate">{active?.label}</span>
<ChevronDown
className={`size-3 shrink-0 transition-transform ${navOpen ? "rotate-180" : ""}`}
/>
</button>
</div>
</header>
<div className="flex min-h-0 flex-1">
<nav
className={`${navOpen ? "flex" : "hidden"} w-full shrink-0 flex-col gap-3 overflow-y-auto border-r border-border py-2.5 md:flex md:w-60`}
>
{bands.map((band) => (
<div key={band.heading} className="flex flex-col">
<span className="px-2.5 pb-1.5 text-[10px] font-semibold uppercase tracking-widest text-muted-foreground">
{band.heading}
</span>
{band.items.map((item) => {
const Icon = item.icon;
const on = item.id === section;
return (
<button
key={item.id}
onClick={() => {
setSection(item.id);
setNavOpen(false);
}}
className={`flex w-full items-center gap-2 border-l-2 py-2.5 pl-2 pr-2 text-left transition-colors md:py-1.5 ${
on
? "border-accent-brand bg-accent-brand/10 text-accent-brand"
: "border-transparent text-muted-foreground hover:bg-muted hover:text-foreground"
}`}
>
{item.iconName ? (
<PluginIcon
name={item.iconName}
className="size-3.5 shrink-0"
/>
) : (
<Icon className="size-3.5 shrink-0" />
)}
<span className="truncate text-xs font-medium">
{item.label}
</span>
</button>
);
})}
</div>
))}
</nav>
<div
className={`${navOpen ? "hidden" : ""} min-h-0 flex-1 overflow-y-auto md:block`}
>
<Suspense fallback={null}>
{section === "profile" && (
<UserProfilePanel
username={username}
onLogout={onLogout}
userPrefs={userPrefs}
onPrefsChange={onPrefsChange}
remoteSyncInitialServerUrl={remoteSyncInitialServerUrl}
remoteSyncReconnectRequested={remoteSyncReconnectRequested}
onRemoteSyncReconnectHandled={onRemoteSyncReconnectHandled}
/>
)}
{section === "admin" && isAdmin && (
<AdminSettingsPanel
onEditingChange={onEditingChange}
onOpenHostTab={onOpenHostTab}
/>
)}
{section.startsWith("plugin:") &&
(() => {
const plugin = pluginPages.find(
(candidate) => `plugin:${candidate.id}` === section,
);
if (!plugin) return null;
return <PluginSettingsPage plugin={plugin} isAdmin={isAdmin} />;
})()}
</Suspense>
</div>
</div>
</div>
);
}
@@ -0,0 +1,90 @@
/**
* The tailnet status shown on the Tailscale settings page.
*
* Confirms the saved key actually works, which is the question anyone has just
* after entering one. Connecting to a device stays in the Tailscale rail panel;
* a settings page is for configuring, not for operating.
*
* Registered by legacy-settings-components.ts until A7 lets the plugin's own
* bundle register it.
*/
import { useCallback, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { Loader2, RefreshCw } from "lucide-react";
import { Button } from "@/components/button";
import { pluginKey } from "@/lib/plugin-i18n";
import type { SettingsComponentProps } from "../settings-components";
import { getTailscaleDevices } from "../../../../plugins/tailscale/src/frontend/tailscale-api";
type Status =
| { kind: "loading" }
| { kind: "no-key" }
| { kind: "error" }
| { kind: "ok"; count: number };
export function TailscaleDevicesStatus({ running }: SettingsComponentProps) {
const { t } = useTranslation();
const [status, setStatus] = useState<Status>({ kind: "loading" });
const load = useCallback(async () => {
if (!running) {
setStatus({ kind: "no-key" });
return;
}
setStatus({ kind: "loading" });
try {
const result = await getTailscaleDevices();
if (!result.hasApiKey) {
setStatus({ kind: "no-key" });
return;
}
if (result.error) {
setStatus({ kind: "error" });
return;
}
setStatus({ kind: "ok", count: result.devices?.length ?? 0 });
} catch {
setStatus({ kind: "error" });
}
}, [running]);
useEffect(() => {
void load();
}, [load]);
const label = (key: string) => t(pluginKey("tailscale", key));
return (
<div className="flex items-center justify-between gap-3 py-3">
<div className="flex flex-col gap-0.5 min-w-0 flex-1">
<span className="text-sm font-medium leading-snug">
{label("settings.devices.label")}
</span>
<span className="text-xs text-muted-foreground leading-snug">
{status.kind === "loading" && label("settings.devices.checking")}
{status.kind === "no-key" && label("settings.devices.noKey")}
{status.kind === "error" && label("settings.devices.error")}
{status.kind === "ok" &&
t(pluginKey("tailscale", "settings.devices.count"), {
count: status.count,
})}
</span>
</div>
<Button
variant="outline"
size="sm"
className="h-7 text-xs shrink-0"
disabled={!running || status.kind === "loading"}
onClick={() => void load()}
>
{status.kind === "loading" ? (
<Loader2 className="size-3 animate-spin" />
) : (
<RefreshCw className="size-3" />
)}
<span className="ml-1.5">{label("settings.devices.refresh")}</span>
</Button>
</div>
);
}
+69
View File
@@ -0,0 +1,69 @@
/**
* Registers the host editor's "Plugins" tab.
*
* Goes through the existing registerHostEditorTab seam rather than adding a
* case to HostTabId, so the tab genuinely appears and disappears with the
* plugins that fill it. Nothing is registered when no enabled plugin declares
* host settings, which is what stops an empty tab from showing up.
*
* Re-run whenever the plugin list changes: enabling a plugin from the settings
* screen has to put the tab there without a reload.
*/
import { Puzzle } from "lucide-react";
import {
registerHostEditorTab,
unregisterHostEditorTab,
} from "@/sidebar/HostManagerTabs";
import type { PluginSummary } from "@/api/plugins-api";
import {
HostPluginSections,
type HostPluginSettings,
} from "./HostPluginSections";
export const HOST_PLUGINS_TAB_ID = "plugins";
/** Only these two fields are read, so a test can pass a plain object. */
interface HostFormLike {
pluginSettings?: HostPluginSettings;
}
/**
* Adds or removes the tab to match the current plugin list.
*
* Returns whether the tab is now present, for a caller that needs to redirect
* away from it when the last contributor goes.
*/
export function syncHostPluginsTab(plugins: PluginSummary[]): boolean {
const contributors = plugins.filter((plugin) => {
if (!plugin.enabled) return false;
const host = plugin.contributes?.settings?.host;
return !!host && (host.fields.length > 0 || !!host.enableKey);
});
if (contributors.length === 0) {
unregisterHostEditorTab(HOST_PLUGINS_TAB_ID);
return false;
}
registerHostEditorTab({
id: HOST_PLUGINS_TAB_ID,
labelKey: "settings.pluginsGroupLabel",
icon: <Puzzle className="size-3" />,
component: ({ form, setField }) => (
<HostPluginSections
plugins={contributors}
values={(form as HostFormLike)?.pluginSettings ?? {}}
setValue={(pluginId, key, value) => {
const current = (form as HostFormLike)?.pluginSettings ?? {};
setField("pluginSettings", {
...current,
[pluginId]: { ...(current[pluginId] ?? {}), [key]: value },
});
}}
/>
),
});
return true;
}
@@ -0,0 +1,27 @@
/**
* Core-side registration of plugin settings components.
*
* TEMPORARY. A `type: "custom"` settings field names a component the plugin's
* own frontend bundle should register through `app.registerSettingsComponent`.
* There is no frontend plugin loader yet, so core registers the handful that
* exist, the same way pluginLoader.ts mirrors the first-party action
* contributions.
*
* A7 deletes this file. Each plugin registers its own component from its
* bundle, and nothing in core names a plugin id.
*
* Only register what is needed now. Everything else waits for its own step:
* the AI providers list stays where it is until B18.
*/
import { registerSettingsComponent } from "./settings-components.js";
import { TailscaleDevicesStatus } from "./components/TailscaleDevicesStatus.js";
let registered = false;
export function registerLegacySettingsComponents(): void {
if (registered) return;
registered = true;
registerSettingsComponent("tailscale", "devices", TailscaleDevicesStatus);
}
+65
View File
@@ -0,0 +1,65 @@
/**
* Components behind a `type: "custom"` settings field.
*
* Most settings are expressible as a schema, and those are rendered from the
* manifest so a plugin cannot ship its own form styling. A few are not: a
* device browser, a provider list. Those declare a component id here instead,
* which keeps the escape hatch narrow and named rather than letting a plugin
* hand core arbitrary markup.
*
* A7 lets a plugin's own frontend bundle register these through
* `app.registerSettingsComponent`. Until then core registers them, in
* legacy-settings-components.ts.
*/
import type { ComponentType } from "react";
export interface SettingsComponentProps {
pluginId: string;
/** Current values for the scope this field sits in. */
values: Record<string, unknown>;
/** Writes one key in the same scope. */
setValue: (key: string, value: unknown) => void;
/** Whether the owning plugin is running. */
running: boolean;
}
export type SettingsComponent = ComponentType<SettingsComponentProps>;
/** Keyed "<pluginId>:<componentId>" so two plugins may use the same name. */
const registry = new Map<string, SettingsComponent>();
function registryKey(pluginId: string, componentId: string): string {
return `${pluginId}:${componentId}`;
}
export function registerSettingsComponent(
pluginId: string,
componentId: string,
component: SettingsComponent,
): () => void {
const key = registryKey(pluginId, componentId);
registry.set(key, component);
return () => {
if (registry.get(key) === component) registry.delete(key);
};
}
export function getSettingsComponent(
pluginId: string,
componentId: string | undefined,
): SettingsComponent | undefined {
if (!componentId) return undefined;
return registry.get(registryKey(pluginId, componentId));
}
export function unregisterSettingsComponents(pluginId: string): void {
for (const key of [...registry.keys()]) {
if (key.startsWith(`${pluginId}:`)) registry.delete(key);
}
}
/** Test helper. */
export function resetSettingsComponents(): void {
registry.clear();
}
+15
View File
@@ -0,0 +1,15 @@
import type { PluginSettingsField } from "@/api/plugins-api";
/**
* Whether a field's `requires` gate is satisfied.
*
* Kept out of the component file so both the settings page and the host editor
* section can import it without dragging a component along.
*/
export function isFieldActive(
field: PluginSettingsField,
values: Record<string, unknown>,
): boolean {
if (!field.requires) return true;
return values[field.requires] === true;
}
+5
View File
@@ -19,6 +19,7 @@ import { Bot } from "lucide-react";
import { unregisterRailItem } from "@/sidebar/rail-items";
import { unregisterTabComponent } from "@/shell/tabUtils";
import { getPlugins, type PluginSummary } from "@/api/plugins-api";
import { syncHostPluginsTab } from "@/settings/host-plugins-tab";
import {
declareActionSlot,
registerAction,
@@ -122,6 +123,10 @@ export function applyPluginState(plugins: PluginSummary[]): void {
pluginEnabled = nextEnabled;
disabledTabTypes = nextDisabledTabs;
applyFirstPartyActions(nextEnabled);
// The host editor's Plugins tab exists only while something fills it, so it
// is re-synced whenever the plugin list changes rather than declared once.
syncHostPluginsTab(plugins);
}
export async function refreshPluginState(): Promise<PluginSummary[]> {
-32
View File
@@ -64,11 +64,6 @@ import {
type TerminalImageStorageTestResult,
type BrandingSettings,
} from "@/api/settings-api";
import {
getTailscaleSettings,
updateTailscaleSettings,
} from "../../../plugins/tailscale/src/frontend/tailscale-api";
import { AdminTailscaleSection } from "../../../plugins/tailscale/src/frontend/AdminTailscaleSection";
import {
getSessionSharingGloballyEnabled,
updateSessionSharingGloballyEnabled,
@@ -185,8 +180,6 @@ export function AdminSettingsPanel({
const [guacEnabled, setGuacEnabled] = useState(false);
const [guacUrl, setGuacUrl] = useState("guacd:4822");
const [logLevel, setLogLevel] = useState("info");
const [tailscaleApiKey, setTailscaleApiKey] = useState("");
const [tailscaleApiBaseUrl, setTailscaleApiBaseUrl] = useState("");
const [commandHistoryEnabled, setCommandHistoryEnabled] = useState(true);
const [analyticsEnabled, setAnalyticsEnabled] = useState(true);
const [analyticsLocked, setAnalyticsLocked] = useState(false);
@@ -413,7 +406,6 @@ export function AdminSettingsPanel({
guac,
oidcProv,
oidcSilent,
tailscale,
cmdHistory,
analytics,
sessionSharingEnabled,
@@ -435,7 +427,6 @@ export function AdminSettingsPanel({
getGuacamoleSettings(),
getOidcAutoProvision(),
getOidcSilentLoginDefault(),
getTailscaleSettings(),
getCommandHistoryEnabled(),
getAnalyticsEnabled(),
getSessionSharingGloballyEnabled(),
@@ -474,10 +465,6 @@ export function AdminSettingsPanel({
setGuacEnabled(guac.value.enabled);
setGuacUrl(guac.value.url || "guacd:4822");
}
if (tailscale.status === "fulfilled") {
setTailscaleApiKey(tailscale.value.apiKey ?? "");
setTailscaleApiBaseUrl(tailscale.value.apiBaseUrl ?? "");
}
if (cmdHistory.status === "fulfilled") {
setCommandHistoryEnabled(cmdHistory.value.enabled);
}
@@ -886,15 +873,6 @@ export function AdminSettingsPanel({
}
}
async function handleSaveTailscaleApiKey() {
try {
await updateTailscaleSettings(tailscaleApiKey, tailscaleApiBaseUrl);
toast.success(t("admin.tailscaleSettingsSaved"));
} catch {
toast.error(t("admin.tailscaleSettingsSaveFailed"));
}
}
async function handleSaveLogLevel(level: string) {
setLogLevel(level);
try {
@@ -1341,16 +1319,6 @@ export function AdminSettingsPanel({
handleSaveLogLevel={handleSaveLogLevel}
/>
<AdminTailscaleSection
open={openSections.has("tailscale")}
onToggle={() => toggle("tailscale")}
tailscaleApiKey={tailscaleApiKey}
setTailscaleApiKey={setTailscaleApiKey}
tailscaleApiBaseUrl={tailscaleApiBaseUrl}
setTailscaleApiBaseUrl={setTailscaleApiBaseUrl}
handleSaveTailscaleApiKey={handleSaveTailscaleApiKey}
/>
<AdminSSOSection
open={openSections.has("sso")}
onToggle={() => toggle("sso")}
+28
View File
@@ -67,6 +67,7 @@ import {
import type { Host, VaultProfile } from "@/types/ui-types";
import type { SSHHost, TunnelStatus } from "@/types";
import { useTabsSafe } from "@/shell/TabContext";
import { updatePluginHostSettings } from "@/api/plugins-api";
import {
buildHostEditorPayload,
createHostEditorForm,
@@ -333,6 +334,29 @@ export function HostEditor({
.finally(() => setTailscaleLoading(false));
}, [form.authType]);
/**
* Writes each plugin's host-scope values through its own route.
*
* One request per plugin rather than one per field, and a plugin that
* rejects a value is reported without failing the host save that already
* succeeded.
*/
const savePluginHostSettings = async (
hostId: number,
values: Record<string, Record<string, unknown>> | undefined,
) => {
if (!values || !Number.isInteger(hostId)) return;
for (const [pluginId, fields] of Object.entries(values)) {
if (!fields || Object.keys(fields).length === 0) continue;
try {
await updatePluginHostSettings(pluginId, hostId, fields);
} catch {
toast.error(t("settings.pluginSettingsSaveFailed"));
}
}
};
const handleSave = async () => {
setSaving(true);
try {
@@ -350,6 +374,10 @@ export function HostEditor({
? await updateSSHHost(Number(host.id), data)
: await createSSHHost(data);
}
// After the host: a new one has no id to scope settings to until it
// exists. A failure here must not claim the host itself failed to save.
await savePluginHostSettings(Number(saved.id), form.pluginSettings);
toast.success(host ? t("hosts.hostUpdated") : t("hosts.hostCreated"));
setPreviewTerminalTheme(null);
onSave(saved);
+7
View File
@@ -342,6 +342,13 @@ export function createHostEditorForm(
excludedMounts: [] as string[],
monitoredMounts: [] as Array<{ path: string; label?: string }>,
},
// Host-scope plugin settings, keyed by plugin id. Loaded with the host and
// saved through the plugin's own scoped route, not in the host payload.
pluginSettings: (host?.pluginSettings ?? {}) as Record<
string,
Record<string, unknown>
>,
};
}
@@ -0,0 +1,297 @@
/**
* The schema-driven settings form.
*
* A plugin declares fields and core draws them, so the thing worth proving is
* that every declared type renders a usable control, reports what the user
* typed, and that a secret's value never appears in the DOM.
*/
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
import type { PluginSettingsField } from "@/api/plugins-api";
import { SettingsFieldRow } from "@/settings/SettingsFields";
import { isFieldActive } from "@/settings/settings-fields-util";
import {
registerSettingsComponent,
resetSettingsComponents,
} from "@/settings/settings-components";
vi.mock("react-i18next", () => ({
useTranslation: () => ({
t: (key: string) => key,
}),
}));
function renderField(
field: PluginSettingsField,
values: Record<string, unknown> = {},
options: { running?: boolean } = {},
) {
const setValue = vi.fn();
render(
<SettingsFieldRow
pluginId="sample"
field={field}
values={values}
setValue={setValue}
running={options.running ?? true}
/>,
);
return { setValue };
}
beforeEach(() => {
resetSettingsComponents();
});
afterEach(() => {
cleanup();
});
describe("field types", () => {
it("renders a boolean as a switch and reports the toggle", () => {
const { setValue } = renderField(
{ key: "enabled", type: "boolean", labelKey: "enabled.label" },
{ enabled: false },
);
const toggle = screen.getByRole("button");
fireEvent.click(toggle);
expect(setValue).toHaveBeenCalledWith("enabled", true);
});
it("renders a string as a text box and reports what was typed", () => {
const { setValue } = renderField(
{ key: "baseUrl", type: "string", labelKey: "baseUrl.label" },
{ baseUrl: "https://a" },
);
const input = screen.getByDisplayValue("https://a");
fireEvent.change(input, { target: { value: "https://b" } });
expect(setValue).toHaveBeenCalledWith("baseUrl", "https://b");
});
it("renders a number with its declared bounds", () => {
const { setValue } = renderField(
{ key: "retries", type: "number", labelKey: "k", min: 0, max: 5 },
{ retries: 2 },
);
const input = screen.getByDisplayValue("2") as HTMLInputElement;
expect(input.getAttribute("min")).toBe("0");
expect(input.getAttribute("max")).toBe("5");
fireEvent.change(input, { target: { value: "4" } });
expect(setValue).toHaveBeenCalledWith("retries", 4);
});
it("clears a number to null rather than to zero", () => {
const { setValue } = renderField(
{ key: "retries", type: "number", labelKey: "k" },
{ retries: 2 },
);
fireEvent.change(screen.getByDisplayValue("2"), { target: { value: "" } });
expect(setValue).toHaveBeenCalledWith("retries", null);
});
it("renders a textarea", () => {
const { setValue } = renderField(
{ key: "notes", type: "textarea", labelKey: "k" },
{ notes: "hello" },
);
fireEvent.change(screen.getByDisplayValue("hello"), {
target: { value: "goodbye" },
});
expect(setValue).toHaveBeenCalledWith("notes", "goodbye");
});
it("renders json as editable text", () => {
renderField({ key: "raw", type: "json", labelKey: "k" }, { raw: { a: 1 } });
expect(screen.getByDisplayValue(/"a": 1/)).toBeTruthy();
});
it("renders a multiselect option per choice and reports a selection", () => {
const { setValue } = renderField(
{
key: "tags",
type: "multiselect",
labelKey: "k",
options: [
{ value: "a", labelKey: "opt.a" },
{ value: "b", labelKey: "opt.b" },
],
},
{ tags: ["a"] },
);
const boxes = screen.getAllByRole("checkbox");
expect(boxes).toHaveLength(2);
fireEvent.click(boxes[1]);
expect(setValue).toHaveBeenCalledWith("tags", ["a", "b"]);
});
it("removes a multiselect value when unticked", () => {
const { setValue } = renderField(
{
key: "tags",
type: "multiselect",
labelKey: "k",
options: [
{ value: "a", labelKey: "opt.a" },
{ value: "b", labelKey: "opt.b" },
],
},
{ tags: ["a", "b"] },
);
fireEvent.click(screen.getAllByRole("checkbox")[0]);
expect(setValue).toHaveBeenCalledWith("tags", ["b"]);
});
it("disables every control while the plugin is not running", () => {
renderField(
{ key: "baseUrl", type: "string", labelKey: "k" },
{ baseUrl: "https://a" },
{ running: false },
);
expect(
(screen.getByDisplayValue("https://a") as HTMLInputElement).disabled,
).toBe(true);
});
});
describe("secrets", () => {
const field: PluginSettingsField = {
key: "apiKey",
type: "secret",
labelKey: "apiKey.label",
};
it("shows a stored secret as set, without its value", () => {
renderField(field, { apiKey: { set: true } });
expect(screen.getByText("settings.secretSet")).toBeTruthy();
expect(document.body.textContent).not.toContain("tskey");
});
it("shows an unset secret as not set", () => {
renderField(field, { apiKey: { set: false } });
expect(screen.getByText("settings.secretNotSet")).toBeTruthy();
});
it("switches to an input when replacing", () => {
const { setValue } = renderField(field, { apiKey: { set: true } });
fireEvent.click(screen.getByText("settings.secretReplace"));
expect(setValue).toHaveBeenCalledWith("apiKey", "");
});
it("takes a new value once editing", () => {
const { setValue } = renderField(field, { apiKey: "" });
// A password input has no textbox role, so it is found by its type.
const input = document.querySelector(
'input[type="password"]',
) as HTMLInputElement;
fireEvent.change(input, { target: { value: "tskey-new" } });
expect(setValue).toHaveBeenCalledWith("apiKey", "tskey-new");
});
it("restores the redacted marker on cancel, so saving changes nothing", () => {
const { setValue } = renderField(field, { apiKey: "half-typed" });
fireEvent.click(screen.getByText("common.cancel"));
expect(setValue).toHaveBeenCalledWith("apiKey", { set: false });
});
});
describe("custom fields", () => {
it("renders a registered component", () => {
registerSettingsComponent("sample", "devices", () => (
<div>custom-component-here</div>
));
renderField({ key: "devices", type: "custom", component: "devices" });
expect(screen.getByText("custom-component-here")).toBeTruthy();
});
it("renders nothing when no component is registered", () => {
const { container } = render(
<SettingsFieldRow
pluginId="sample"
field={{ key: "devices", type: "custom", component: "missing" }}
values={{}}
setValue={vi.fn()}
running
/>,
);
expect(container.innerHTML).toBe("");
});
it("keeps two plugins' components apart", () => {
registerSettingsComponent("sample", "shared", () => <div>mine</div>);
registerSettingsComponent("other", "shared", () => <div>theirs</div>);
renderField({ key: "shared", type: "custom", component: "shared" });
expect(screen.getByText("mine")).toBeTruthy();
expect(screen.queryByText("theirs")).toBeNull();
});
});
describe("errors", () => {
it("shows a per-field message from a rejected save", () => {
render(
<SettingsFieldRow
pluginId="sample"
field={{ key: "retries", type: "number", labelKey: "k" }}
values={{ retries: 99 }}
setValue={vi.fn()}
running
error='"retries" must be at most 5'
/>,
);
expect(screen.getByText('"retries" must be at most 5')).toBeTruthy();
});
});
describe("isFieldActive", () => {
const gated: PluginSettingsField = {
key: "socketPath",
type: "string",
labelKey: "k",
requires: "enableDocker",
};
it("is inactive while the named boolean is off", () => {
expect(isFieldActive(gated, { enableDocker: false })).toBe(false);
expect(isFieldActive(gated, {})).toBe(false);
});
it("is active once the named boolean is on", () => {
expect(isFieldActive(gated, { enableDocker: true })).toBe(true);
});
it("is always active without a requires", () => {
expect(isFieldActive({ key: "a", type: "string", labelKey: "k" }, {})).toBe(
true,
);
});
});
@@ -0,0 +1,185 @@
/**
* The one Settings screen.
*
* What matters here is the navigation: which bands appear for whom, that a
* plugin's page shows up only when it has settings that user could change, and
* that the nav collapses rather than disappearing on a phone.
*/
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { cleanup, render, screen, waitFor } from "@testing-library/react";
import type { PluginSummary } from "@/api/plugins-api";
const { getPluginsMock } = vi.hoisted(() => ({ getPluginsMock: vi.fn() }));
vi.mock("@/api/plugins-api", async (importOriginal) => ({
...(await importOriginal<typeof import("@/api/plugins-api")>()),
getPlugins: getPluginsMock,
}));
vi.mock("react-i18next", () => ({
useTranslation: () => ({ t: (key: string) => key }),
}));
// The two core panels are large and pull in most of the app; this screen only
// has to decide which one to mount.
vi.mock("@/sidebar/UserProfilePanel", () => ({
UserProfilePanel: () => <div>profile-panel</div>,
}));
vi.mock("@/sidebar/AdminSettingsPanel", () => ({
AdminSettingsPanel: () => <div>admin-panel</div>,
}));
vi.mock("@/settings/legacy-settings-components", () => ({
registerLegacySettingsComponents: vi.fn(),
}));
const { SettingsScreen } = await import("@/settings/SettingsScreen");
function plugin(overrides: Partial<PluginSummary> = {}): PluginSummary {
return {
id: "tailscale",
name: "Tailscale",
version: "1.0.0",
enabled: true,
state: "active",
contributes: {
settings: { admin: [{ key: "apiKey", type: "secret", labelKey: "k" }] },
},
...overrides,
} as PluginSummary;
}
beforeEach(() => {
getPluginsMock.mockReset();
getPluginsMock.mockResolvedValue([]);
});
afterEach(() => {
cleanup();
});
describe("navigation bands", () => {
it("always shows the You band", async () => {
render(<SettingsScreen />);
expect(await screen.findByText("settings.bandYou")).toBeTruthy();
});
it("shows the Instance band only to an admin", async () => {
const { unmount } = render(<SettingsScreen isAdmin={false} />);
await waitFor(() => expect(getPluginsMock).toHaveBeenCalled());
expect(screen.queryByText("settings.bandInstance")).toBeNull();
unmount();
render(<SettingsScreen isAdmin />);
expect(await screen.findByText("settings.bandInstance")).toBeTruthy();
});
it("shows no Plugins band when nothing declares settings", async () => {
render(<SettingsScreen isAdmin />);
await waitFor(() => expect(getPluginsMock).toHaveBeenCalled());
expect(screen.queryByText("settings.bandPlugins")).toBeNull();
});
it("lists a plugin with admin settings for an admin", async () => {
getPluginsMock.mockResolvedValue([plugin()]);
render(<SettingsScreen isAdmin />);
expect(await screen.findByText("settings.bandPlugins")).toBeTruthy();
expect(screen.getByText("Tailscale")).toBeTruthy();
});
it("hides an admin-only plugin page from a non-admin", async () => {
getPluginsMock.mockResolvedValue([plugin()]);
render(<SettingsScreen isAdmin={false} />);
await waitFor(() => expect(getPluginsMock).toHaveBeenCalled());
expect(screen.queryByText("Tailscale")).toBeNull();
});
it("shows a plugin with user settings to everyone", async () => {
getPluginsMock.mockResolvedValue([
plugin({
contributes: {
settings: { user: [{ key: "theme", type: "string", labelKey: "k" }] },
},
}),
]);
render(<SettingsScreen isAdmin={false} />);
expect(await screen.findByText("Tailscale")).toBeTruthy();
});
it("hides a disabled plugin's page", async () => {
getPluginsMock.mockResolvedValue([plugin({ enabled: false })]);
render(<SettingsScreen isAdmin />);
await waitFor(() => expect(getPluginsMock).toHaveBeenCalled());
expect(screen.queryByText("Tailscale")).toBeNull();
});
it("keeps working when the plugin list cannot be loaded", async () => {
getPluginsMock.mockRejectedValue(new Error("offline"));
render(<SettingsScreen isAdmin />);
expect(await screen.findByText("settings.bandYou")).toBeTruthy();
});
});
describe("initial section", () => {
it("lands on the profile panel by default", async () => {
render(<SettingsScreen />);
expect(await screen.findByText("profile-panel")).toBeTruthy();
});
it("lands on the admin panel when opened from the admin rail entry", async () => {
render(<SettingsScreen isAdmin initialSection="admin" />);
expect(await screen.findByText("admin-panel")).toBeTruthy();
});
it("does not mount the admin panel for a non-admin", async () => {
render(<SettingsScreen isAdmin={false} initialSection="admin" />);
await waitFor(() => expect(getPluginsMock).toHaveBeenCalled());
expect(screen.queryByText("admin-panel")).toBeNull();
});
it("switches section when the rail entry changes", async () => {
const { rerender } = render(
<SettingsScreen isAdmin initialSection="profile" />,
);
expect(await screen.findByText("profile-panel")).toBeTruthy();
rerender(<SettingsScreen isAdmin initialSection="admin" />);
expect(await screen.findByText("admin-panel")).toBeTruthy();
});
});
describe("phone layout", () => {
it("offers a disclosure button for the collapsed nav", async () => {
render(<SettingsScreen />);
await screen.findByText("settings.bandYou");
const toggle = document.querySelector("button[aria-expanded]");
expect(toggle).toBeTruthy();
expect(toggle?.getAttribute("aria-expanded")).toBe("false");
});
it("hides the nav below md and shows it from md up", async () => {
render(<SettingsScreen />);
await screen.findByText("settings.bandYou");
const nav = document.querySelector("nav")!;
expect(nav.className).toContain("hidden");
expect(nav.className).toContain("md:flex");
});
});
@@ -0,0 +1,193 @@
/**
* The host editor's Plugins group.
*
* No bundled plugin declares host settings yet, so a fixture stands in for one.
* The rule worth protecting is the empty case: with nothing to show, the tab
* must not be registered at all rather than appearing blank.
*/
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
import type { PluginSummary } from "@/api/plugins-api";
import {
HOST_PLUGINS_TAB_ID,
syncHostPluginsTab,
} from "@/settings/host-plugins-tab";
import {
getRegisteredHostEditorTab,
unregisterHostEditorTab,
} from "@/sidebar/HostManagerTabs";
import { HostPluginSections } from "@/settings/HostPluginSections";
vi.mock("react-i18next", () => ({
useTranslation: () => ({ t: (key: string) => key }),
}));
function plugin(overrides: Partial<PluginSummary> = {}): PluginSummary {
return {
id: "docker",
name: "Docker",
version: "1.0.0",
enabled: true,
state: "active",
icon: "Box",
contributes: {
settings: {
host: {
enableKey: "enableDocker",
enableLabelKey: "enableDocker.label",
fields: [
{ key: "socketPath", type: "string", labelKey: "socket.label" },
],
},
},
},
...overrides,
} as PluginSummary;
}
beforeEach(() => {
unregisterHostEditorTab(HOST_PLUGINS_TAB_ID);
});
afterEach(() => {
cleanup();
unregisterHostEditorTab(HOST_PLUGINS_TAB_ID);
});
describe("syncHostPluginsTab", () => {
it("registers the tab when a plugin contributes host settings", () => {
expect(syncHostPluginsTab([plugin()])).toBe(true);
expect(getRegisteredHostEditorTab(HOST_PLUGINS_TAB_ID)).toBeDefined();
});
it("registers nothing when no plugin contributes", () => {
expect(syncHostPluginsTab([])).toBe(false);
expect(getRegisteredHostEditorTab(HOST_PLUGINS_TAB_ID)).toBeUndefined();
});
it("ignores a plugin with no host settings", () => {
const other = plugin({
id: "ai",
contributes: { settings: { admin: [] } },
});
expect(syncHostPluginsTab([other])).toBe(false);
});
it("ignores a disabled plugin", () => {
expect(syncHostPluginsTab([plugin({ enabled: false })])).toBe(false);
});
it("removes the tab once the last contributor goes", () => {
syncHostPluginsTab([plugin()]);
expect(getRegisteredHostEditorTab(HOST_PLUGINS_TAB_ID)).toBeDefined();
syncHostPluginsTab([plugin({ enabled: false })]);
expect(getRegisteredHostEditorTab(HOST_PLUGINS_TAB_ID)).toBeUndefined();
});
it("registers for a plugin declaring only an enable switch", () => {
const bare = plugin({
contributes: {
settings: {
host: { enableKey: "enableThing", enableLabelKey: "k", fields: [] },
},
},
});
expect(syncHostPluginsTab([bare])).toBe(true);
});
});
describe("HostPluginSections", () => {
it("shows the enable switch and hides the fields until it is on", () => {
const setValue = vi.fn();
render(
<HostPluginSections
plugins={[plugin()]}
values={{ docker: { enableDocker: false } }}
setValue={setValue}
/>,
);
expect(screen.getByText("plugins.docker.enableDocker.label")).toBeTruthy();
expect(screen.queryByText("plugins.docker.socket.label")).toBeNull();
});
it("shows the fields once the enable switch is on", () => {
render(
<HostPluginSections
plugins={[plugin()]}
values={{ docker: { enableDocker: true } }}
setValue={vi.fn()}
/>,
);
expect(screen.getByText("plugins.docker.socket.label")).toBeTruthy();
});
it("reports a change against the right plugin", () => {
const setValue = vi.fn();
render(
<HostPluginSections
plugins={[plugin()]}
values={{ docker: { enableDocker: false } }}
setValue={setValue}
/>,
);
fireEvent.click(screen.getAllByRole("button")[0]);
expect(setValue).toHaveBeenCalledWith("docker", "enableDocker", true);
});
it("warns when the plugin is installed but not running", () => {
render(
<HostPluginSections
plugins={[plugin({ state: "failed" })]}
values={{ docker: {} }}
setValue={vi.fn()}
/>,
);
expect(screen.getByText("settings.hostPluginNotRunning")).toBeTruthy();
});
it("renders nothing at all with no contributing plugins", () => {
const { container } = render(
<HostPluginSections plugins={[]} values={{}} setValue={vi.fn()} />,
);
expect(container.innerHTML).toBe("");
});
it("keeps two plugins' values apart", () => {
const vault = plugin({
id: "vault",
name: "Vault",
contributes: {
settings: {
host: {
fields: [{ key: "role", type: "string", labelKey: "role.label" }],
},
},
},
});
render(
<HostPluginSections
plugins={[plugin(), vault]}
values={{
docker: { enableDocker: true, socketPath: "/var/run/docker.sock" },
vault: { role: "web" },
}}
setValue={vi.fn()}
/>,
);
expect(screen.getByDisplayValue("/var/run/docker.sock")).toBeTruthy();
expect(screen.getByDisplayValue("web")).toBeTruthy();
});
});