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

+167 -26
View File
@@ -32,6 +32,7 @@ dependencies. Entry points:
| `@termix/plugin-sdk/frontend` | The `app` object types (stubs until A7) |
| `@termix/plugin-sdk/manifest` | Manifest types, validation, the JSON schema |
| `@termix/plugin-sdk/capabilities` | The capability catalog |
| `@termix/plugin-sdk/settings` | Settings field types and the shared value validator |
| `@termix/plugin-sdk/testing` | Test helpers, incl. `createFakeContext()` |
Plugins never import from `src/` or `@/`, and core never imports from
@@ -205,10 +206,68 @@ by an authenticated route).
### 7. Settings
Plugins declare settings fields in the manifest with scope `admin`, `user` or
`host`. Core renders them from the schema (with an optional custom component),
stores values in `plugin_settings`, and encrypts secret fields. One Settings
screen has a Plugins section with a page per plugin; host settings render in
the host editor. **A6.**
`host`. Core renders them from the schema, stores values in `plugin_settings`,
and encrypts secret fields.
**A6 delivered this.** A field is `{ key, type, labelKey, ... }`, with types
`boolean`, `string`, `number`, `select`, `multiselect`, `secret`, `textarea`,
`json` and `custom`. `requires` names a boolean in the same scope that gates
it, `group` is a section heading, and `permission` is who may write it: admin
fields fall back to `admin.plugins.manage`, and a short name resolves against
the plugin's own permissions exactly as `ctx.rbac` does.
`type: "custom"` names a component the frontend registered, for the few things
a schema cannot express (a device browser, a provider list). It is deliberately
narrow: a plugin supplies data everywhere else, so it cannot ship its own form
styling and drift from the rest of the app, and a page disappears cleanly when
its plugin does.
**One service, two callers.** `src/backend/plugins/settings.ts` is behind both
`ctx.settings` and the HTTP routes, so validation, encryption and redaction are
decided once. Two implementations would drift, and the one that drifted would
be the one writing to the database.
**The manifest is the schema.** A read is driven by the declared fields, not by
the stored rows: an unwritten field comes back with its `default`, and a row
for a field the plugin has since dropped is ignored rather than handed to the
UI. A write to a key the manifest never declared is refused, which is what
stops a PUT from filling a plugin's namespace with arbitrary keys.
**Secrets never leave the server.** A `secret` field is encrypted with
`encryptSystemSecret` (the installation key, not a user DEK: these have to be
readable before any user is unlocked) and every HTTP read replaces it with
`{ set: boolean }`. Sending that marker back is a no-op, so saving a form
cannot clear a key it was never shown. Clearing one writes null rather than an
encrypted empty string, so it reads back as unset.
**Storage.** `plugin_settings(plugin_id, scope, scope_id, key, value,
encrypted, updated_at)`, unique on the first four. `scope_id` is null for
admin, a user id for user and a host id as text for host. It is polymorphic, so
it carries no foreign key and the engine cannot cascade it: `UserRepository`
and `HostRepository` delete these rows explicitly, with tests that fail if
those calls ever go. The foreign key to `plugins` is real, so uninstalling
leaves nothing.
**Routes**, all with core auth: `GET|PUT /plugins/:id/settings/admin`
(`admin.plugins.manage`, or the field's own permission), `/settings/user`
(always the caller, never a user id from the body) and
`/settings/host/:hostId` (needs edit access to that host). A rejected PUT
returns `400 { errors: { <key>: message } }` per field, so a form shows every
problem at once.
**Host payloads carry them.** Host list and get responses include a
`pluginSettings` map, enabled plugins only and secrets redacted, built in one
query for the whole list rather than one request per plugin per host. It is
attached after the sanitizers, because the connect-level projection of a shared
host reduces it to an allowlist.
`ctx.settings` is `get/set`, `getUser/setUser`, `getHost/setHost`, `getAll`,
`onChange` (disposed with the plugin) and `readCore`. **Reading and writing a
plugin's own settings needs no capability**: the manifest already declares
every field, and a plugin that had to ask permission to read its own
configuration would be useless. Only `readCore`, which reaches outside the
plugin's namespace, is gated on `settings:read-core`, and it serves a short
allowlist (`CORE_SETTINGS_ALLOWLIST`) rather than the whole settings table.
### 8. UI
@@ -296,6 +355,32 @@ plugins/<id>/
Every move of data, settings or host columns into a plugin comes with a
migration for all three dialects and a test proving existing data survives.
#### Moving a host column into a plugin
A6 built the host-scope settings path but moved no columns; each Phase B step
moves its own. `ssh_data` still holds `enable_docker`, `stats_config`,
`web_ui_config`, `enable_proxmox`, `enable_rdp`, `mac_address` and the rest,
and the hardcoded host editor tabs still read them. The order matters, because
dropping a column before its data has moved loses it:
1. Declare the fields in the plugin's `contributes.settings.host`, keeping the
key names the form already uses so the editor tab can be deleted rather than
rewritten.
2. Write a boot migration that copies each row's column into host-scope
`plugin_settings` for that plugin, keyed by the host id as text. Make it
idempotent by checking for an existing row first, the way
`tailscale-settings-migration.ts` does, and cover it with a test that runs
it twice.
3. Switch the plugin's backend to `ctx.settings.getHost` and delete the
hardcoded editor tab; the schema-driven Plugins group replaces it.
4. Only then drop the column, in lockstep across all four artifacts: the
`schema.ts` declaration, the `db/index.ts` bootstrap DDL, a drizzle
migration per dialect, and `npm run schema:generate` for the pg and mysql
variants. The two bootstrap guard tests fail if these disagree.
A column that is still read anywhere in core is not ready to be dropped. Ship
the copy and the switch first, and drop it in a later step if that is safer.
---
## Manifest v2 reference
@@ -370,6 +455,52 @@ not do.
"defaultRoles": ["user"], // system roles only, applied once
},
],
"settings": {
"admin": [
{
"key": "apiKey",
"type": "secret", // encrypted at rest, never sent to a browser
"labelKey": "settings.apiKey.label",
"descriptionKey": "settings.apiKey.description",
"placeholderKey": "settings.apiKey.placeholder",
"group": "settings.group.api", // section heading
},
{
"key": "retries",
"type": "number",
"labelKey": "k",
"min": 0,
"max": 5,
"default": 3,
},
{
"key": "browser",
"type": "custom", // a registered component, for what a schema cannot express
"component": "devices",
},
],
"user": [
{
"key": "advanced",
"type": "boolean",
"labelKey": "k",
"permission": "tweak", // one of this plugin's own permissions
},
],
"host": {
"enableKey": "enableExample", // boolean rendered first, gating the rest
"enableLabelKey": "k",
"fields": [
{
"key": "port",
"type": "number",
"labelKey": "k",
"requires": "enableExample",
},
],
},
},
"hostCapability": {
"key": "enableExample",
"labelKey": "k",
@@ -389,6 +520,13 @@ Networking, Access & Security, Productivity.
`openFrom` is one of: rail, host-context-menu, palette.
Field types are `boolean`, `string`, `number`, `select`, `multiselect`,
`secret`, `textarea`, `json` and `custom`. `labelKey` is required for every
type but `custom`, which needs `component` instead; `options` is required and
non-empty for `select` and `multiselect` and rejected elsewhere; `min`/`max`
belong to `number` alone; and `requires` must name a boolean field in the same
scope, which includes the host section's `enableKey`.
Cross-field rules the JSON schema cannot express, checked by `parseManifest`:
- `engine.api` must match the SDK major version this build implements.
@@ -403,6 +541,9 @@ Cross-field rules the JSON schema cannot express, checked by `parseManifest`:
- Every `contributes.permissions[].name` must be unique, must not start with a
core group (`hosts`, `snippets`, `credentials`, `admin`) or with the plugin's
own id, and every `defaultRoles` entry must be `admin` or `user`.
- A `contributes.settings` field `permission` written as a bare short name must
appear in the plugin's own `contributes.permissions`. A dotted id is taken as
given, because the validator cannot know which other plugins exist.
`contributes.hostCapability`, `provides`, `requires`, `providesSecret` and
`requiresSecret` are carried forward from v1 unchanged because the service and
@@ -469,28 +610,28 @@ deciding, not the mechanism.
Built per plugin in `src/backend/plugins/ctx.ts` and passed to `activate`.
| Member | Capability | Status |
| ------------------------------------------------ | -------------------------------- | ------ |
| `ctx.pluginId`, `ctx.manifest` | none | **A1** |
| `ctx.log.*` | none | **A1** |
| `ctx.events.emit` / `.on` | `events:core` for core topics | **A1** |
| `ctx.kv.get/set/delete/list` | `kv:own` | **A1** |
| `ctx.registry.*` | none | **A1** |
| `ctx.services.provide` / `.get` | per-service RBAC | **A1** |
| `ctx.secrets.offer` / `.withdraw` / `.getShared` | per-secret RBAC | **A1** |
| `ctx.disposables.add` | none | **A1** |
| `ctx.asUser(userId, fn)` | none, always audited | **A1** |
| `ctx.currentActor()` | none | **A1** |
| `ctx.db.define` / `.client` / `.refs` | `db:own` | **A3** |
| `ctx.sync.registerEntity` | none | **A3** |
| `ctx.http.router` / `ctx.ws.route` / `.upgrade` | `network:serve` | **A4** |
| `ctx.rbac.has` / `.hasFor` / `.require` | own permissions only | **A5** |
| `ctx.hosts.*` | `hosts:read` / `hosts:write` | B |
| `ctx.ssh.*` | `ssh:connect`, `credentials:use` | B |
| `ctx.settings.*` | `settings:read-core` | A6 |
| `ctx.notify.*` | `notify:send` | A6 |
| `ctx.auth.*` | `auth:provide` | A8 |
| `ctx.fetch` | `network:outbound` | B |
| Member | Capability | Status |
| ------------------------------------------------ | ------------------------------------ | ------ |
| `ctx.pluginId`, `ctx.manifest` | none | **A1** |
| `ctx.log.*` | none | **A1** |
| `ctx.events.emit` / `.on` | `events:core` for core topics | **A1** |
| `ctx.kv.get/set/delete/list` | `kv:own` | **A1** |
| `ctx.registry.*` | none | **A1** |
| `ctx.services.provide` / `.get` | per-service RBAC | **A1** |
| `ctx.secrets.offer` / `.withdraw` / `.getShared` | per-secret RBAC | **A1** |
| `ctx.disposables.add` | none | **A1** |
| `ctx.asUser(userId, fn)` | none, always audited | **A1** |
| `ctx.currentActor()` | none | **A1** |
| `ctx.db.define` / `.client` / `.refs` | `db:own` | **A3** |
| `ctx.sync.registerEntity` | none | **A3** |
| `ctx.http.router` / `ctx.ws.route` / `.upgrade` | `network:serve` | **A4** |
| `ctx.rbac.has` / `.hasFor` / `.require` | own permissions only | **A5** |
| `ctx.hosts.*` | `hosts:read` / `hosts:write` | B |
| `ctx.ssh.*` | `ssh:connect`, `credentials:use` | B |
| `ctx.settings.*` | `settings:read-core` (readCore only) | **A6** |
| `ctx.notify.*` | `notify:send` | A6 |
| `ctx.auth.*` | `auth:provide` | A8 |
| `ctx.fetch` | `network:outbound` | B |
### The actor
+4
View File
@@ -32,6 +32,10 @@
"types": "./dist/capabilities.d.ts",
"default": "./dist/capabilities.js"
},
"./settings": {
"types": "./dist/settings.d.ts",
"default": "./dist/settings.js"
},
"./testing": {
"types": "./dist/testing.d.ts",
"default": "./dist/testing.js"
+445 -38
View File
@@ -21,24 +21,42 @@
"pattern": "^[a-z][a-z0-9-]{1,39}$",
"description": "Unique plugin id. Lowercase, starts with a letter, 2-40 characters. Must equal the plugin's directory name."
},
"name": { "type": "string", "minLength": 1 },
"name": {
"type": "string",
"minLength": 1
},
"version": {
"type": "string",
"pattern": "^\\d+\\.\\d+\\.\\d+(-[0-9A-Za-z-.]+)?(\\+[0-9A-Za-z-.]+)?$",
"description": "Semver version string."
},
"description": { "type": "string", "minLength": 1 },
"description": {
"type": "string",
"minLength": 1
},
"author": {
"type": "object",
"additionalProperties": false,
"required": ["name"],
"properties": {
"name": { "type": "string", "minLength": 1 },
"url": { "type": "string", "minLength": 1 }
"name": {
"type": "string",
"minLength": 1
},
"url": {
"type": "string",
"minLength": 1
}
}
},
"license": { "type": "string", "minLength": 1 },
"repository": { "type": "string", "minLength": 1 },
"license": {
"type": "string",
"minLength": 1
},
"repository": {
"type": "string",
"minLength": 1
},
"category": {
"type": "string",
"enum": [
@@ -106,14 +124,24 @@
"dependencies": {
"type": "object",
"description": "Plugin ids this plugin cannot run without, mapped to a semver range.",
"additionalProperties": { "type": "string", "minLength": 1 },
"propertyNames": { "pattern": "^[a-z][a-z0-9-]{1,39}$" }
"additionalProperties": {
"type": "string",
"minLength": 1
},
"propertyNames": {
"pattern": "^[a-z][a-z0-9-]{1,39}$"
}
},
"optionalDependencies": {
"type": "object",
"description": "Plugin ids this plugin uses when present. It must keep working without them.",
"additionalProperties": { "type": "string", "minLength": 1 },
"propertyNames": { "pattern": "^[a-z][a-z0-9-]{1,39}$" }
"additionalProperties": {
"type": "string",
"minLength": 1
},
"propertyNames": {
"pattern": "^[a-z][a-z0-9-]{1,39}$"
}
},
"provides": {
"type": "array",
@@ -130,7 +158,10 @@
"type": "string",
"pattern": "^\\d+\\.\\d+\\.\\d+(-[0-9A-Za-z-.]+)?(\\+[0-9A-Za-z-.]+)?$"
},
"permission": { "type": "string", "minLength": 1 }
"permission": {
"type": "string",
"minLength": 1
}
}
}
},
@@ -145,8 +176,13 @@
"type": "string",
"pattern": "^[a-z0-9-]+(\\.[a-z0-9-]+)+$"
},
"versionRange": { "type": "string", "minLength": 1 },
"optional": { "type": "boolean" }
"versionRange": {
"type": "string",
"minLength": 1
},
"optional": {
"type": "boolean"
}
}
}
},
@@ -157,9 +193,18 @@
"additionalProperties": false,
"required": ["key", "permission"],
"properties": {
"key": { "type": "string", "pattern": "^[a-z0-9-]+$" },
"permission": { "type": "string", "minLength": 1 },
"descriptionKey": { "type": "string", "minLength": 1 }
"key": {
"type": "string",
"pattern": "^[a-z0-9-]+$"
},
"permission": {
"type": "string",
"minLength": 1
},
"descriptionKey": {
"type": "string",
"minLength": 1
}
}
}
},
@@ -170,9 +215,17 @@
"additionalProperties": false,
"required": ["plugin", "key"],
"properties": {
"plugin": { "type": "string", "pattern": "^[a-z][a-z0-9-]{1,39}$" },
"key": { "type": "string", "pattern": "^[a-z0-9-]+$" },
"optional": { "type": "boolean" }
"plugin": {
"type": "string",
"pattern": "^[a-z][a-z0-9-]{1,39}$"
},
"key": {
"type": "string",
"pattern": "^[a-z0-9-]+$"
},
"optional": {
"type": "boolean"
}
}
}
},
@@ -187,9 +240,18 @@
"additionalProperties": false,
"required": ["id", "titleKey", "icon", "openFrom"],
"properties": {
"id": { "type": "string", "minLength": 1 },
"titleKey": { "type": "string", "minLength": 1 },
"icon": { "type": "string", "minLength": 1 },
"id": {
"type": "string",
"minLength": 1
},
"titleKey": {
"type": "string",
"minLength": 1
},
"icon": {
"type": "string",
"minLength": 1
},
"openFrom": {
"type": "array",
"minItems": 1,
@@ -212,15 +274,30 @@
"type": "string",
"pattern": "^[a-z0-9-]+(\\.[a-zA-Z0-9-]+)+$"
},
"titleKey": { "type": "string", "minLength": 1 },
"titleKey": {
"type": "string",
"minLength": 1
},
"handler": {
"type": "string",
"pattern": "^[A-Za-z_$][A-Za-z0-9_$]*$"
},
"icon": { "type": "string", "minLength": 1 },
"permission": { "type": "string", "minLength": 1 },
"slot": { "type": "string", "minLength": 1 },
"kind": { "type": "string", "enum": ["button"] }
"icon": {
"type": "string",
"minLength": 1
},
"permission": {
"type": "string",
"minLength": 1
},
"slot": {
"type": "string",
"minLength": 1
},
"kind": {
"type": "string",
"enum": ["button"]
}
}
}
},
@@ -238,9 +315,15 @@
"accepts": {
"type": "array",
"minItems": 1,
"items": { "type": "string", "enum": ["button"] }
"items": {
"type": "string",
"enum": ["button"]
}
},
"descriptionKey": { "type": "string", "minLength": 1 }
"descriptionKey": {
"type": "string",
"minLength": 1
}
}
}
},
@@ -257,12 +340,320 @@
"description": "Plugin-relative name. May contain dots. Must not start with a core group or this plugin's id.",
"pattern": "^[a-z0-9_-]+(\\.[a-z0-9_-]+)*$"
},
"titleKey": { "type": "string", "minLength": 1 },
"descriptionKey": { "type": "string", "minLength": 1 },
"titleKey": {
"type": "string",
"minLength": 1
},
"descriptionKey": {
"type": "string",
"minLength": 1
},
"defaultRoles": {
"type": "array",
"description": "System roles that hold this the first time it is seen.",
"items": { "type": "string", "enum": ["admin", "user"] }
"items": {
"type": "string",
"enum": ["admin", "user"]
}
}
}
}
},
"settings": {
"type": "object",
"description": "Settings fields core renders on the plugin's settings page and in the host editor.",
"additionalProperties": false,
"properties": {
"admin": {
"type": "array",
"items": {
"type": "object",
"additionalProperties": false,
"required": ["key", "type"],
"properties": {
"key": {
"type": "string",
"description": "Storage key, unique within its scope.",
"pattern": "^[a-zA-Z][a-zA-Z0-9_-]{0,63}$"
},
"type": {
"type": "string",
"enum": [
"boolean",
"string",
"number",
"select",
"multiselect",
"secret",
"textarea",
"json",
"custom"
]
},
"labelKey": {
"type": "string",
"minLength": 1,
"description": "Required for every type except \"custom\"."
},
"descriptionKey": {
"type": "string",
"minLength": 1
},
"placeholderKey": {
"type": "string",
"minLength": 1
},
"default": {
"description": "Value used until one is written."
},
"options": {
"type": "array",
"description": "select and multiselect only.",
"minItems": 1,
"items": {
"type": "object",
"additionalProperties": false,
"required": ["value", "labelKey"],
"properties": {
"value": {
"type": "string",
"minLength": 1
},
"labelKey": {
"type": "string",
"minLength": 1
}
}
}
},
"min": {
"type": "number",
"description": "number fields only."
},
"max": {
"type": "number",
"description": "number fields only."
},
"requires": {
"type": "string",
"description": "Key of a boolean field in the same scope that must be on."
},
"permission": {
"type": "string",
"minLength": 1,
"description": "Permission needed to write it. Admin fields default to admin.plugins.manage."
},
"group": {
"type": "string",
"minLength": 1,
"description": "Section heading this field sits under."
},
"component": {
"type": "string",
"minLength": 1,
"description": "Registered component id. Required when type is \"custom\"."
}
}
}
},
"user": {
"type": "array",
"items": {
"type": "object",
"additionalProperties": false,
"required": ["key", "type"],
"properties": {
"key": {
"type": "string",
"description": "Storage key, unique within its scope.",
"pattern": "^[a-zA-Z][a-zA-Z0-9_-]{0,63}$"
},
"type": {
"type": "string",
"enum": [
"boolean",
"string",
"number",
"select",
"multiselect",
"secret",
"textarea",
"json",
"custom"
]
},
"labelKey": {
"type": "string",
"minLength": 1,
"description": "Required for every type except \"custom\"."
},
"descriptionKey": {
"type": "string",
"minLength": 1
},
"placeholderKey": {
"type": "string",
"minLength": 1
},
"default": {
"description": "Value used until one is written."
},
"options": {
"type": "array",
"description": "select and multiselect only.",
"minItems": 1,
"items": {
"type": "object",
"additionalProperties": false,
"required": ["value", "labelKey"],
"properties": {
"value": {
"type": "string",
"minLength": 1
},
"labelKey": {
"type": "string",
"minLength": 1
}
}
}
},
"min": {
"type": "number",
"description": "number fields only."
},
"max": {
"type": "number",
"description": "number fields only."
},
"requires": {
"type": "string",
"description": "Key of a boolean field in the same scope that must be on."
},
"permission": {
"type": "string",
"minLength": 1,
"description": "Permission needed to write it. Admin fields default to admin.plugins.manage."
},
"group": {
"type": "string",
"minLength": 1,
"description": "Section heading this field sits under."
},
"component": {
"type": "string",
"minLength": 1,
"description": "Registered component id. Required when type is \"custom\"."
}
}
}
},
"host": {
"type": "object",
"additionalProperties": false,
"required": ["fields"],
"properties": {
"enableKey": {
"type": "string",
"pattern": "^[a-zA-Z][a-zA-Z0-9_-]{0,63}$",
"description": "Boolean rendered first, gating the rest of the section."
},
"enableLabelKey": {
"type": "string",
"minLength": 1
},
"fields": {
"type": "array",
"items": {
"type": "object",
"additionalProperties": false,
"required": ["key", "type"],
"properties": {
"key": {
"type": "string",
"description": "Storage key, unique within its scope.",
"pattern": "^[a-zA-Z][a-zA-Z0-9_-]{0,63}$"
},
"type": {
"type": "string",
"enum": [
"boolean",
"string",
"number",
"select",
"multiselect",
"secret",
"textarea",
"json",
"custom"
]
},
"labelKey": {
"type": "string",
"minLength": 1,
"description": "Required for every type except \"custom\"."
},
"descriptionKey": {
"type": "string",
"minLength": 1
},
"placeholderKey": {
"type": "string",
"minLength": 1
},
"default": {
"description": "Value used until one is written."
},
"options": {
"type": "array",
"description": "select and multiselect only.",
"minItems": 1,
"items": {
"type": "object",
"additionalProperties": false,
"required": ["value", "labelKey"],
"properties": {
"value": {
"type": "string",
"minLength": 1
},
"labelKey": {
"type": "string",
"minLength": 1
}
}
}
},
"min": {
"type": "number",
"description": "number fields only."
},
"max": {
"type": "number",
"description": "number fields only."
},
"requires": {
"type": "string",
"description": "Key of a boolean field in the same scope that must be on."
},
"permission": {
"type": "string",
"minLength": 1,
"description": "Permission needed to write it. Admin fields default to admin.plugins.manage."
},
"group": {
"type": "string",
"minLength": 1,
"description": "Section heading this field sits under."
},
"component": {
"type": "string",
"minLength": 1,
"description": "Registered component id. Required when type is \"custom\"."
}
}
}
}
}
}
}
@@ -270,11 +661,15 @@
"hostCapability": {
"description": "A host-editor checkbox backed by a boolean column on the host record. Reshaped by a later step.",
"oneOf": [
{ "$ref": "#/$defs/hostCapability" },
{
"$ref": "#/$defs/hostCapability"
},
{
"type": "array",
"minItems": 1,
"items": { "$ref": "#/$defs/hostCapability" }
"items": {
"$ref": "#/$defs/hostCapability"
}
}
]
}
@@ -298,7 +693,10 @@
"platforms": {
"type": "array",
"minItems": 1,
"items": { "type": "string", "enum": ["linux", "win32", "darwin"] }
"items": {
"type": "string",
"enum": ["linux", "win32", "darwin"]
}
}
},
"$defs": {
@@ -307,9 +705,18 @@
"additionalProperties": false,
"required": ["key", "labelKey", "editorTab"],
"properties": {
"key": { "type": "string", "minLength": 1 },
"labelKey": { "type": "string", "minLength": 1 },
"editorTab": { "type": "string", "minLength": 1 }
"key": {
"type": "string",
"minLength": 1
},
"labelKey": {
"type": "string",
"minLength": 1
},
"editorTab": {
"type": "string",
"minLength": 1
}
}
}
}
+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 };
}
/**