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: