mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-29 06:35:24 +00:00
423 lines
12 KiB
TypeScript
423 lines
12 KiB
TypeScript
/**
|
|
* 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 {
|
|
createCurrentHostRepository,
|
|
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",
|
|
"notification_private_endpoint_allowlist",
|
|
];
|
|
|
|
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);
|
|
};
|
|
}
|
|
|
|
export function notifySettingChange(
|
|
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" },
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
export type SettingsValidator = (
|
|
values: Record<string, unknown>,
|
|
context: {
|
|
hostId?: number;
|
|
defaults?: { level: "admin" | "user" | "folder" };
|
|
},
|
|
) =>
|
|
| Record<string, string>
|
|
| void
|
|
| undefined
|
|
| Promise<Record<string, string> | void | undefined>;
|
|
|
|
const validators = new Map<
|
|
string,
|
|
Set<{ scope: PluginSettingsScope; run: SettingsValidator }>
|
|
>();
|
|
|
|
/** A plugin's own check on a settings save, before anything is written. */
|
|
export function onSettingsValidate(
|
|
pluginId: string,
|
|
scope: PluginSettingsScope,
|
|
run: SettingsValidator,
|
|
): () => void {
|
|
let set = validators.get(pluginId);
|
|
if (!set) {
|
|
set = new Set();
|
|
validators.set(pluginId, set);
|
|
}
|
|
const entry = { scope, run };
|
|
set.add(entry);
|
|
return () => {
|
|
set!.delete(entry);
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Runs the plugin's validators over a save. Returns field key to message;
|
|
* a validator that throws rejects the whole save under "_".
|
|
*/
|
|
export async function validateSettingsSave(
|
|
pluginId: string,
|
|
scope: PluginSettingsScope,
|
|
scopeId: string | null,
|
|
values: Record<string, unknown>,
|
|
defaults?: { level: "admin" | "user" | "folder" },
|
|
): Promise<Record<string, string>> {
|
|
const errors: Record<string, string> = {};
|
|
for (const entry of validators.get(pluginId) ?? []) {
|
|
if (entry.scope !== scope) continue;
|
|
try {
|
|
const result = await entry.run(values, {
|
|
hostId: scope === "host" && scopeId ? Number(scopeId) : undefined,
|
|
...(defaults ? { defaults } : {}),
|
|
});
|
|
Object.assign(errors, result ?? {});
|
|
} catch (error) {
|
|
errors._ = error instanceof Error ? error.message : String(error);
|
|
}
|
|
}
|
|
return errors;
|
|
}
|
|
|
|
/** Drops every listener a plugin registered. Called when it deactivates. */
|
|
export function clearSettingsListeners(pluginId: string): void {
|
|
listeners.delete(pluginId);
|
|
validators.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,
|
|
descriptionKey: host.enableDescriptionKey,
|
|
default: host.enableDefault ?? 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,
|
|
);
|
|
|
|
notifySettingChange(manifest.id, key, coerced);
|
|
return null;
|
|
}
|
|
|
|
/** Every stored value of one host field, with each host's owner. */
|
|
export async function listHostValues(
|
|
manifest: PluginManifest,
|
|
key: string,
|
|
): Promise<Array<{ hostId: number; userId: string; value: unknown }>> {
|
|
const field = findField(manifest, "host", key);
|
|
if (!field) {
|
|
throw new Error(
|
|
`"${key}" is not a host settings field this plugin declares`,
|
|
);
|
|
}
|
|
if (field.type === "secret") {
|
|
throw new Error(`"${key}" is a secret and cannot be listed`);
|
|
}
|
|
|
|
const rows = await createCurrentPluginSettingsRepository().listByKey(
|
|
manifest.id,
|
|
"host",
|
|
key,
|
|
);
|
|
const hosts = createCurrentHostRepository();
|
|
const result: Array<{ hostId: number; userId: string; value: unknown }> = [];
|
|
for (const row of rows) {
|
|
const hostId = Number(row.scopeId);
|
|
if (!Number.isInteger(hostId) || row.value === null) continue;
|
|
const host = await hosts.findById(hostId);
|
|
if (!host?.userId) continue;
|
|
const value = decode(row.value);
|
|
if (value === undefined) continue;
|
|
result.push({ hostId, userId: host.userId as string, value });
|
|
}
|
|
return result;
|
|
}
|
|
|
|
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);
|
|
}
|