mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-29 06:35:24 +00:00
feat: give plugins their own settings and one settings screen
This commit is contained in:
1 parent
cc15559ea1
commit
49f0286a14
66 files changed
+36497
-451
No files matched your search
@@ -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";
|
||||
@@ -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(
|
||||
|
||||
@@ -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";
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in new issue
Block a user