mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-28 06:05: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
@@ -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
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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