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

+58
View File
@@ -297,6 +297,58 @@ export interface PluginDisposables {
add: (dispose: () => void | Promise<void>) => void;
}
/**
* The plugin's own settings, as declared in contributes.settings.
*
* Reading and writing a plugin's own settings needs no capability: the
* manifest already says which fields exist, and a plugin that could not read
* its own configuration would be useless. Only readCore, which reaches outside
* the plugin's namespace, is gated on settings:read-core.
*
* Values are validated against the declared field on every write, so a key the
* manifest never declared is rejected rather than stored. Secret fields are
* encrypted at rest and returned decrypted here; the HTTP surface never sends
* them to a browser.
*/
export interface PluginSettings {
/** Install-wide admin settings. */
get: <T = unknown>(key: string) => Promise<T | undefined>;
set: (key: string, value: unknown) => Promise<void>;
/** Per-user settings. */
getUser: <T = unknown>(userId: string, key: string) => Promise<T | undefined>;
setUser: (userId: string, key: string, value: unknown) => Promise<void>;
/** Per-host settings. */
getHost: <T = unknown>(
hostId: number | string,
key: string,
) => Promise<T | undefined>;
setHost: (
hostId: number | string,
key: string,
value: unknown,
) => Promise<void>;
/** Every value in one scope, with declared defaults merged in. */
getAll: (
scope: "admin" | "user" | "host",
scopeId?: string | number,
) => Promise<Record<string, unknown>>;
/**
* Fires when a key in this plugin's settings changes, from either the ctx
* API or the HTTP routes. Disposed automatically on deactivate.
*/
onChange: (key: string, listener: (value: unknown) => void) => () => void;
/**
* Reads a core server setting from a small documented allowlist.
* Requires the settings:read-core capability.
*/
readCore: (key: string) => Promise<string | null>;
}
export interface PluginContext {
readonly pluginId: string;
readonly manifest: PluginManifest;
@@ -311,6 +363,7 @@ export interface PluginContext {
readonly http: PluginHttp;
readonly ws: PluginWebSockets;
readonly rbac: PluginRbac;
readonly settings: PluginSettings;
readonly disposables: PluginDisposables;
/**
@@ -347,3 +400,8 @@ export function definePlugin(plugin: PluginModule): PluginModule {
export type { PluginManifest } from "./manifest.js";
export type { PluginTableDefinition } from "./db.js";
export type {
PluginSettingsField,
PluginSettingsContribution,
PluginSettingsScope,
} from "./manifest.js";
+302
View File
@@ -154,11 +154,80 @@ export interface PluginSecretRequire {
optional?: boolean;
}
export const SETTINGS_FIELD_TYPES = [
"boolean",
"string",
"number",
"select",
"multiselect",
"secret",
"textarea",
"json",
"custom",
] as const;
export type PluginSettingsFieldType = (typeof SETTINGS_FIELD_TYPES)[number];
export const SETTINGS_SCOPES = ["admin", "user", "host"] as const;
export type PluginSettingsScope = (typeof SETTINGS_SCOPES)[number];
export interface PluginSettingsOption {
value: string;
labelKey: string;
}
/**
* One field on a plugin's settings page.
*
* Core renders these; a plugin supplies data only, so it cannot ship its own
* form styling and drift from the rest of the app. `type: "custom"` is the
* escape hatch for UI a schema cannot express, and names a component the
* frontend registered rather than carrying markup.
*/
export interface PluginSettingsField {
key: string;
type: PluginSettingsFieldType;
/** Required except for "custom", which draws its own label. */
labelKey?: string;
descriptionKey?: string;
placeholderKey?: string;
default?: unknown;
/** select and multiselect only. */
options?: PluginSettingsOption[];
/** number only. */
min?: number;
max?: number;
/** Key of a boolean field in the same scope that must be on. */
requires?: string;
/**
* Who may write it. Admin fields default to admin.plugins.manage; a short
* name resolves against this plugin's own permissions.
*/
permission?: string;
/** Section heading this field sits under. */
group?: string;
/** Registered component id. Required when type is "custom". */
component?: string;
}
export interface PluginHostSettingsContribution {
/** Boolean field rendered first, gating the rest of the section. */
enableKey?: string;
enableLabelKey?: string;
fields: PluginSettingsField[];
}
export interface PluginSettingsContribution {
admin?: PluginSettingsField[];
user?: PluginSettingsField[];
host?: PluginHostSettingsContribution;
}
export interface PluginContributions {
tabs?: PluginTabContribution[];
actions?: PluginActionContribution[];
actionSlots?: PluginActionSlot[];
permissions?: PluginPermissionContribution[];
settings?: PluginSettingsContribution;
/**
* A host-editor checkbox backed by a boolean column on the host record.
* A5/A6/A7 reshape this; it stays in v2 because remote-desktop, docker,
@@ -236,9 +305,29 @@ const ALLOWED_CONTRIBUTES = new Set([
"actions",
"actionSlots",
"permissions",
"settings",
"hostCapability",
]);
const ALLOWED_SETTINGS_FIELD = [
"key",
"type",
"labelKey",
"descriptionKey",
"placeholderKey",
"default",
"options",
"min",
"max",
"requires",
"permission",
"group",
"component",
];
/** Settings keys are stored as-is, so they stay short and index-safe. */
const SETTINGS_KEY_PATTERN = /^[a-zA-Z][a-zA-Z0-9_-]{0,63}$/;
function isPlainObject(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
@@ -564,9 +653,201 @@ function validateContributes(
validatePermissions(contributes.permissions, pluginId, errors);
validateActions(contributes.actions, errors);
validateActionSlots(contributes.actionSlots, errors);
validateSettings(contributes.settings, errors);
validateHostCapability(contributes.hostCapability, errors);
}
function validateSettings(settings: unknown, errors: string[]): void {
if (settings === undefined) return;
const where = "contributes.settings";
if (!isPlainObject(settings)) {
errors.push(`${where} must be an object`);
return;
}
rejectUnknown(settings, ["admin", "user", "host"], `"${where}"`, errors);
for (const scope of ["admin", "user"] as const) {
const value = settings[scope];
if (value === undefined) continue;
if (!Array.isArray(value)) {
errors.push(`${where}.${scope} must be an array of fields`);
continue;
}
validateSettingsFields(value, `${where}.${scope}`, errors);
}
if (settings.host !== undefined) {
const host = settings.host;
const at = `${where}.host`;
if (!isPlainObject(host)) {
errors.push(`${at} must be an object`);
return;
}
rejectUnknown(
host,
["enableKey", "enableLabelKey", "fields"],
`"${at}"`,
errors,
);
if ("enableKey" in host) {
if (
typeof host.enableKey !== "string" ||
!SETTINGS_KEY_PATTERN.test(host.enableKey)
) {
errors.push(`${at}.enableKey must be a short alphanumeric key`);
}
// An enable switch with no label is a blank row in the host editor.
requireString(host.enableLabelKey, `${at}.enableLabelKey`, errors);
}
if (!Array.isArray(host.fields)) {
errors.push(`${at}.fields must be an array`);
return;
}
validateSettingsFields(
host.fields,
`${at}.fields`,
errors,
typeof host.enableKey === "string" ? host.enableKey : undefined,
);
}
}
/**
* Validates one scope's fields.
*
* `requires` is checked against the keys declared in the same scope, plus the
* host scope's enableKey, because a field pointing at a key that does not
* exist would simply never render.
*/
function validateSettingsFields(
fields: unknown[],
where: string,
errors: string[],
enableKey?: string,
): void {
const seen = new Set<string>();
const booleanKeys = new Set<string>();
if (enableKey) booleanKeys.add(enableKey);
fields.forEach((raw, index) => {
const at = `${where}[${index}]`;
if (!isPlainObject(raw)) {
errors.push(`${at} must be an object`);
return;
}
rejectUnknown(raw, ALLOWED_SETTINGS_FIELD, at, errors);
if (typeof raw.key !== "string" || !SETTINGS_KEY_PATTERN.test(raw.key)) {
errors.push(`${at}.key must be a short alphanumeric key`);
} else {
if (seen.has(raw.key)) {
errors.push(`${at}.key duplicates "${raw.key}"`);
}
seen.add(raw.key);
if (raw.type === "boolean") booleanKeys.add(raw.key);
if (enableKey && raw.key === enableKey) {
errors.push(
`${at}.key "${raw.key}" is already the section's enableKey`,
);
}
}
if (
typeof raw.type !== "string" ||
!(SETTINGS_FIELD_TYPES as readonly string[]).includes(raw.type)
) {
errors.push(
`${at}.type must be one of: ${SETTINGS_FIELD_TYPES.join(", ")}`,
);
return;
}
// A custom field draws its own label, so it needs a component instead.
if (raw.type === "custom") {
requireString(raw.component, `${at}.component`, errors);
} else {
requireString(raw.labelKey, `${at}.labelKey`, errors);
if ("component" in raw) {
errors.push(`${at}.component is only valid when type is "custom"`);
}
}
for (const key of ["descriptionKey", "placeholderKey", "group"] as const) {
if (key in raw) requireString(raw[key], `${at}.${key}`, errors);
}
if ("permission" in raw) {
requireString(raw.permission, `${at}.permission`, errors);
}
if (raw.type === "select" || raw.type === "multiselect") {
validateSettingsOptions(raw.options, at, errors);
} else if ("options" in raw) {
errors.push(`${at}.options is only valid for select and multiselect`);
}
if (raw.type === "number") {
for (const bound of ["min", "max"] as const) {
if (bound in raw && typeof raw[bound] !== "number") {
errors.push(`${at}.${bound} must be a number`);
}
}
if (
typeof raw.min === "number" &&
typeof raw.max === "number" &&
raw.min > raw.max
) {
errors.push(`${at}.min must not be greater than ${at}.max`);
}
} else if ("min" in raw || "max" in raw) {
errors.push(`${at}.min and ${at}.max are only valid for number fields`);
}
});
// Second pass: every key is known by now, so forward references are fine.
fields.forEach((raw, index) => {
if (!isPlainObject(raw) || !("requires" in raw)) return;
const at = `${where}[${index}]`;
if (typeof raw.requires !== "string") {
errors.push(`${at}.requires must be a string`);
return;
}
if (!booleanKeys.has(raw.requires)) {
errors.push(
`${at}.requires "${raw.requires}" must name a boolean field in the same scope`,
);
}
});
}
function validateSettingsOptions(
options: unknown,
where: string,
errors: string[],
): void {
if (!Array.isArray(options) || options.length === 0) {
errors.push(`${where}.options must be a non-empty array`);
return;
}
const seen = new Set<string>();
options.forEach((raw, index) => {
const at = `${where}.options[${index}]`;
if (!isPlainObject(raw)) {
errors.push(`${at} must be an object`);
return;
}
rejectUnknown(raw, ["value", "labelKey"], at, errors);
if (requireString(raw.value, `${at}.value`, errors)) {
if (seen.has(raw.value)) {
errors.push(`${at}.value duplicates "${raw.value}"`);
}
seen.add(raw.value);
}
requireString(raw.labelKey, `${at}.labelKey`, errors);
});
}
function validateTabs(tabs: unknown, errors: string[]): void {
if (tabs === undefined) return;
if (!Array.isArray(tabs)) {
@@ -850,6 +1131,27 @@ export function parseManifest(raw: unknown): ParsedManifest {
);
}
}
// A settings field may gate on one of this plugin's own permissions, or on
// a core/other-plugin id used as given. Only the first form is checkable
// here, and an undeclared one would hide the field from every admin.
const settings = manifest.contributes?.settings;
const settingsScopes: [string, PluginSettingsField[]][] = [
["admin", settings?.admin ?? []],
["user", settings?.user ?? []],
["host", settings?.host?.fields ?? []],
];
for (const [scope, fields] of settingsScopes) {
for (const field of fields) {
if (!field.permission) continue;
if (field.permission.includes(".")) continue;
if (!declared.has(qualifyPermission(manifest.id, field.permission))) {
errors.push(
`contributes.settings.${scope} field "${field.key}" requires permission "${field.permission}", which is not declared in contributes.permissions`,
);
}
}
}
for (const secret of manifest.requiresSecret ?? []) {
if (secret.plugin === manifest.id) {
errors.push(
+203
View File
@@ -0,0 +1,203 @@
/**
* Settings field validation, shared by core and the frontend.
*
* Both the HTTP routes and the form renderer call these, so "what counts as a
* valid value" is decided once. A field the manifest never declared has no
* entry here and is rejected rather than stored, which is what stops a PUT
* from writing arbitrary keys into a plugin's namespace.
*/
import type { PluginSettingsField } from "./manifest.js";
/** The permission an admin-scope field requires when it names none itself. */
export const ADMIN_SETTINGS_PERMISSION = "admin.plugins.manage";
/** What a secret looks like on the wire. 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) &&
Object.keys(value).length === 1 &&
typeof (value as RedactedSecret).set === "boolean"
);
}
/**
* Turns whatever arrived on the wire into the type the field declares.
*
* Forms send everything as strings, so a number field that refused "30" would
* reject every value the UI can produce. Anything genuinely unconvertible is
* left alone for validateSettingValue to reject with a readable message.
*/
export function coerceSettingValue(
field: PluginSettingsField,
value: unknown,
): unknown {
if (value === null || value === undefined) return value;
switch (field.type) {
case "boolean":
if (typeof value === "boolean") return value;
if (value === "true") return true;
if (value === "false") return false;
return value;
case "number": {
if (typeof value === "number") return value;
if (typeof value === "string" && value.trim() !== "") {
const parsed = Number(value);
return Number.isNaN(parsed) ? value : parsed;
}
return value;
}
case "multiselect":
return Array.isArray(value) ? value : value === "" ? [] : value;
case "string":
case "secret":
case "textarea":
case "select":
return typeof value === "number" || typeof value === "boolean"
? String(value)
: value;
default:
return value;
}
}
/**
* Returns an error message, or null when the value is acceptable.
*
* Messages are plain English rather than i18n keys: they name the offending
* field and the rule it broke, and the UI shows them beside the input.
*/
export function validateSettingValue(
field: PluginSettingsField,
value: unknown,
): string | null {
// Clearing a field is always allowed; the default takes over on read.
if (value === null || value === undefined) return null;
switch (field.type) {
case "boolean":
return typeof value === "boolean"
? null
: `"${field.key}" must be true or false`;
case "number": {
if (typeof value !== "number" || !Number.isFinite(value)) {
return `"${field.key}" must be a number`;
}
if (field.min !== undefined && value < field.min) {
return `"${field.key}" must be at least ${field.min}`;
}
if (field.max !== undefined && value > field.max) {
return `"${field.key}" must be at most ${field.max}`;
}
return null;
}
case "select": {
if (typeof value !== "string") {
return `"${field.key}" must be a string`;
}
const allowed = (field.options ?? []).map((option) => option.value);
return allowed.includes(value)
? null
: `"${field.key}" must be one of: ${allowed.join(", ")}`;
}
case "multiselect": {
if (!Array.isArray(value)) {
return `"${field.key}" must be an array`;
}
const allowed = (field.options ?? []).map((option) => option.value);
for (const entry of value) {
if (typeof entry !== "string" || !allowed.includes(entry)) {
return `"${field.key}" may only contain: ${allowed.join(", ")}`;
}
}
return null;
}
case "json": {
if (typeof value === "string") {
try {
JSON.parse(value);
return null;
} catch {
return `"${field.key}" must be valid JSON`;
}
}
// Already-parsed JSON is fine as long as it survives a round trip.
try {
JSON.stringify(value);
return null;
} catch {
return `"${field.key}" must be valid JSON`;
}
}
case "secret":
// A redacted echo is a no-op rather than a value, handled by the caller.
if (isRedactedSecret(value)) return null;
return typeof value === "string"
? null
: `"${field.key}" must be a string`;
case "string":
case "textarea":
return typeof value === "string"
? null
: `"${field.key}" must be a string`;
case "custom":
// The component owns its own shape; core only stores what it sends.
return null;
default:
return `"${field.key}" has an unknown type`;
}
}
/** The declared defaults for a scope, used when no row has been written. */
export function defaultsFor(
fields: readonly PluginSettingsField[] = [],
): Record<string, unknown> {
const defaults: Record<string, unknown> = {};
for (const field of fields) {
if (field.default !== undefined) defaults[field.key] = field.default;
}
return defaults;
}
/** Looks a field up by key, for a caller holding only the declared list. */
export function findSettingsField(
fields: readonly PluginSettingsField[] = [],
key: string,
): PluginSettingsField | undefined {
return fields.find((field) => field.key === key);
}
/**
* Whether a field should be shown or accepted, given the rest of the scope.
*
* A field declaring `requires` is inert while its named boolean is off, so the
* UI hides it and a write to it is pointless rather than wrong.
*/
export function isFieldActive(
field: PluginSettingsField,
values: Record<string, unknown>,
): boolean {
if (!field.requires) return true;
return values[field.requires] === true;
}
export type { PluginSettingsField } from "./manifest.js";
+102 -10
View File
@@ -25,6 +25,10 @@ export interface FakeContextOptions {
manifest?: Partial<PluginManifest>;
/** Acting user returned by currentActor and used by asUser. */
actor?: string;
/** Seed for admin-scope ctx.settings. */
settings?: Record<string, unknown>;
/** Seed for the core settings ctx.settings.readCore can reach. */
coreSettings?: Record<string, string>;
}
export interface FakePluginContext {
@@ -43,6 +47,19 @@ export interface FakePluginContext {
wsRoutes: Array<{ path: string; raw: boolean }>;
/** Router options passed to ctx.http.router, in order. */
httpRouters: Array<PluginRouterOptions | undefined>;
/** Backing store behind ctx.settings, keyed "<scope>:<scopeId>:<key>". */
settings: Map<string, unknown>;
/** Core settings readable through ctx.settings.readCore. */
coreSettings: Map<string, string>;
}
/** How a settings row is keyed in the doubles. Mirrors the real unique index. */
function settingsKey(
scope: string,
scopeId: string | number | undefined,
key: string,
): string {
return `${scope}:${scopeId ?? ""}:${key}`;
}
function noopLogger(): PluginLogger {
@@ -71,8 +88,27 @@ export function createFakeContext(
const wsRoutes: Array<{ path: string; raw: boolean }> = [];
const httpRouters: Array<PluginRouterOptions | undefined> = [];
const listeners = new Map<string, Set<(payload: unknown) => void>>();
const settings = new Map<string, unknown>();
const coreSettings = new Map<string, string>(
Object.entries(options.coreSettings ?? {}),
);
const settingsListeners = new Map<string, Set<(value: unknown) => void>>();
let actor = options.actor;
for (const [key, value] of Object.entries(options.settings ?? {})) {
settings.set(settingsKey("admin", undefined, key), value);
}
const writeSetting = (
scope: string,
scopeId: string | number | undefined,
key: string,
value: unknown,
) => {
settings.set(settingsKey(scope, scopeId, key), value);
for (const listener of settingsListeners.get(key) ?? []) listener(value);
};
const manifest = {
id: pluginId,
name: pluginId,
@@ -186,6 +222,49 @@ export function createFakeContext(
require: () => ((_req, _res, next) => next()) as PluginMiddleware,
},
settings: {
get: async (key) =>
settings.get(settingsKey("admin", undefined, key)) as never,
set: async (key, value) => writeSetting("admin", undefined, key, value),
getUser: async (userId, key) =>
settings.get(settingsKey("user", userId, key)) as never,
setUser: async (userId, key, value) =>
writeSetting("user", userId, key, value),
getHost: async (hostId, key) =>
settings.get(settingsKey("host", String(hostId), key)) as never,
setHost: async (hostId, key, value) =>
writeSetting("host", String(hostId), key, value),
getAll: async (scope, scopeId) => {
const prefix = `${scope}:${scopeId ?? ""}:`;
const all: Record<string, unknown> = {};
for (const [storedKey, value] of settings) {
if (storedKey.startsWith(prefix)) {
all[storedKey.slice(prefix.length)] = value;
}
}
return all;
},
onChange: (key, listener) => {
let set = settingsListeners.get(key);
if (!set) {
set = new Set();
settingsListeners.set(key, set);
}
set.add(listener);
const unsubscribe = () => {
set!.delete(listener);
};
disposals.push(unsubscribe);
return unsubscribe;
},
readCore: async (key) => coreSettings.get(key) ?? null,
},
disposables,
asUser: async (userId, fn) => {
@@ -210,6 +289,8 @@ export function createFakeContext(
syncEntities,
wsRoutes,
httpRouters,
settings,
coreSettings,
};
}
@@ -220,15 +301,15 @@ export interface MockContextOptions {
capabilities?: string[];
/** Seed for ctx.kv. */
kv?: Record<string, unknown>;
/** Plugin settings, readable through settings.get. A5 wires the real one. */
/** Seed for this plugin's own admin-scope settings. */
settings?: Record<string, unknown>;
/** Seed for the core settings readCore can reach. */
coreSettings?: Record<string, string>;
/** Acting user returned by currentActor and used by asUser. */
actor?: string;
}
export interface MockPluginContext extends FakePluginContext {
/** Settings backing store, so a test can assert what a plugin wrote. */
settings: Map<string, unknown>;
/** Capability ids checked during the test, in order, denied ones included. */
checked: string[];
}
@@ -237,22 +318,23 @@ export interface MockPluginContext extends FakePluginContext {
* A context double that gates on capabilities the way src/backend/plugins/ctx.ts
* does, so a test can prove a plugin fails closed without a running server.
*
* Two gates exist on today's ctx surface: kv:own on every ctx.kv call, and
* events:core on emitting a topic outside the plugin's own namespace. As the
* SDK grows a member, add its gate here in the same shape.
* The gates on today's ctx surface: kv:own on every ctx.kv call, db:own on
* ctx.db, network:serve on ctx.http and ctx.ws, events:core on emitting a
* topic outside the plugin's own namespace, and settings:read-core on
* ctx.settings.readCore. Reading a plugin's own settings is deliberately
* ungated. As the SDK grows a member, add its gate here in the same shape.
*/
export function createMockCtx(
options: MockContextOptions = {},
): MockPluginContext {
const granted = new Set(options.capabilities ?? []);
const checked: string[] = [];
const settings = new Map<string, unknown>(
Object.entries(options.settings ?? {}),
);
const base = createFakeContext({
pluginId: options.pluginId,
actor: options.actor,
settings: options.settings,
coreSettings: options.coreSettings,
manifest: {
capabilities: options.capabilities ?? [],
...options.manifest,
@@ -345,9 +427,19 @@ export function createMockCtx(
ctx.ws.upgrade(path, handler, wsOptions);
},
},
settings: {
// Reading and writing a plugin's OWN settings is ungated, exactly as in
// the real runtime. Only readCore reaches outside the plugin.
...ctx.settings,
readCore: async (key) => {
require("settings:read-core");
return ctx.settings.readCore(key);
},
},
};
return { ...base, ctx: gatedCtx, settings, checked };
return { ...base, ctx: gatedCtx, checked };
}
/**