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

@@ -0,0 +1,15 @@
CREATE TABLE `plugin_settings` (
`id` int AUTO_INCREMENT NOT NULL,
`plugin_id` varchar(255) NOT NULL,
`scope` varchar(255) NOT NULL,
`scope_id` varchar(255),
`key` varchar(255) NOT NULL,
`value` text,
`encrypted` boolean NOT NULL DEFAULT false,
`updated_at` varchar(255) NOT NULL DEFAULT (CURRENT_TIMESTAMP),
CONSTRAINT `plugin_settings_id` PRIMARY KEY(`id`),
CONSTRAINT `idx_plugin_settings_scope_key` UNIQUE(`plugin_id`,`scope`,`scope_id`,`key`)
);
--> statement-breakpoint
ALTER TABLE `plugin_settings` ADD CONSTRAINT `plugin_settings_plugin_id_plugins_id_fk` FOREIGN KEY (`plugin_id`) REFERENCES `plugins`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
CREATE INDEX `idx_plugin_settings_plugin_scope` ON `plugin_settings` (`plugin_id`,`scope`);
File diff suppressed because it is too large. Load diff
+7
View File
@@ -204,6 +204,13 @@
"when": 1790114765594,
"tag": "0028_fair_tusk",
"breakpoints": true
},
{
"idx": 29,
"version": "5",
"when": 1790118361160,
"tag": "0029_open_captain_america",
"breakpoints": true
}
]
}
@@ -0,0 +1,14 @@
CREATE TABLE "plugin_settings" (
"id" serial PRIMARY KEY NOT NULL,
"plugin_id" varchar(255) NOT NULL,
"scope" varchar(255) NOT NULL,
"scope_id" varchar(255),
"key" varchar(255) NOT NULL,
"value" text,
"encrypted" boolean DEFAULT false NOT NULL,
"updated_at" varchar(255) DEFAULT CURRENT_TIMESTAMP NOT NULL
);
--> statement-breakpoint
ALTER TABLE "plugin_settings" ADD CONSTRAINT "plugin_settings_plugin_id_plugins_id_fk" FOREIGN KEY ("plugin_id") REFERENCES "public"."plugins"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
CREATE UNIQUE INDEX "idx_plugin_settings_scope_key" ON "plugin_settings" USING btree ("plugin_id","scope","scope_id","key");--> statement-breakpoint
CREATE INDEX "idx_plugin_settings_plugin_scope" ON "plugin_settings" USING btree ("plugin_id","scope");
File diff suppressed because it is too large. Load diff
+7
View File
@@ -211,6 +211,13 @@
"when": 1790114765114,
"tag": "0029_nosy_roulette",
"breakpoints": true
},
{
"idx": 30,
"version": "7",
"when": 1790118360674,
"tag": "0030_cynical_callisto",
"breakpoints": true
}
]
}
+14
View File
@@ -0,0 +1,14 @@
CREATE TABLE `plugin_settings` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`plugin_id` text NOT NULL,
`scope` text NOT NULL,
`scope_id` text,
`key` text NOT NULL,
`value` text,
`encrypted` integer DEFAULT false NOT NULL,
`updated_at` text DEFAULT CURRENT_TIMESTAMP NOT NULL,
FOREIGN KEY (`plugin_id`) REFERENCES `plugins`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE UNIQUE INDEX `idx_plugin_settings_scope_key` ON `plugin_settings` (`plugin_id`,`scope`,`scope_id`,`key`);--> statement-breakpoint
CREATE INDEX `idx_plugin_settings_plugin_scope` ON `plugin_settings` (`plugin_id`,`scope`);
File diff suppressed because it is too large. Load diff
+7
View File
@@ -183,6 +183,13 @@
"when": 1790114764617,
"tag": "0025_overrated_cable",
"breakpoints": true
},
{
"idx": 26,
"version": "6",
"when": 1790118360159,
"tag": "0026_fair_sauron",
"breakpoints": true
}
]
}
+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 };
}
/**
+28 -1
View File
@@ -27,7 +27,34 @@
"titleKey": "permissions.devices.view.title",
"descriptionKey": "permissions.devices.view.description"
}
]
],
"settings": {
"admin": [
{
"key": "apiKey",
"type": "secret",
"labelKey": "settings.apiKey.label",
"descriptionKey": "settings.apiKey.description",
"placeholderKey": "settings.apiKey.placeholder",
"group": "settings.group.api"
},
{
"key": "apiBaseUrl",
"type": "string",
"labelKey": "settings.apiBaseUrl.label",
"descriptionKey": "settings.apiBaseUrl.description",
"placeholderKey": "settings.apiBaseUrl.placeholder",
"default": "",
"group": "settings.group.api"
},
{
"key": "devices",
"type": "custom",
"component": "devices",
"group": "settings.group.devices"
}
]
}
},
"locales": "locales",
"platforms": ["linux", "win32", "darwin"]
+1 -1
View File
@@ -2,7 +2,7 @@ import type { PluginContext } from "@termix/plugin-sdk/backend";
import { startTailscaleService, stopTailscaleService } from "./routes.js";
export async function activate(ctx: PluginContext) {
startTailscaleService(ctx.http.router());
startTailscaleService(ctx.http.router(), ctx);
ctx.log.info("Tailscale routes mounted at /plugin-api/tailscale");
}
+22 -6
View File
@@ -8,7 +8,7 @@ import { AuthManager } from "../../../../src/backend/utils/auth-manager.js";
import { PermissionManager } from "../../../../src/backend/utils/permission-manager.js";
import { apiLogger } from "../../../../src/backend/utils/logger.js";
import { fetchWithProxy } from "../../../../src/backend/utils/proxy-agent.js";
import { createCurrentSettingsRepository } from "../../../../src/backend/database/repositories/factory.js";
import type { PluginContext } from "@termix/plugin-sdk/backend";
interface TailscaleDevice {
id: string;
@@ -33,6 +33,12 @@ const DEFAULT_TAILSCALE_API_BASE = "https://api.tailscale.com/api/v2";
export const router = Router();
/**
* Set by activate(). The API key and base URL are this plugin's own settings
* now, read through ctx rather than out of the core settings table.
*/
let context: PluginContext | null = null;
const authManager = AuthManager.getInstance();
const authenticateJWT = authManager.createAuthMiddleware();
@@ -85,13 +91,17 @@ router.get(
requireDeviceAccess,
async (_req, res) => {
try {
const settingsRepo = createCurrentSettingsRepository();
const apiKey = (await settingsRepo.get("tailscale_api_key")) ?? "";
if (!context) {
return res
.status(503)
.json({ error: "Plugin is not running", devices: [] });
}
const apiKey = (await context.settings.get<string>("apiKey")) ?? "";
if (!apiKey) {
return res.json({ devices: [], hasApiKey: false });
}
const apiBase = normalizeApiBase(
await settingsRepo.get("tailscale_api_base_url"),
(await context.settings.get<string>("apiBaseUrl")) ?? null,
);
const url = `${apiBase}/tailnet/-/devices?fields=all`;
@@ -148,12 +158,18 @@ router.get(
/** Called from activate(). Mounts this router at /tailscale via the shared
* dispatcher. */
export function startTailscaleService(mountOn: Router): void {
export function startTailscaleService(
mountOn: Router,
ctx: PluginContext,
): void {
context = ctx;
mountOn.use(router);
}
/** Called from deactivate(). /tailscale/* falls back to 404 until
* reactivated. */
export function stopTailscaleService(): void {
// Unmounted by the runtime when the plugin deactivates.
// Dropped so a request arriving mid-teardown cannot read settings through a
// context the runtime has already torn down.
context = null;
}
@@ -1,93 +0,0 @@
import type { Dispatch, SetStateAction } from "react";
import { useTranslation } from "react-i18next";
import { Button } from "@/components/button";
import { Input } from "@/components/input";
import { Network } from "lucide-react";
import { AccordionSection } from "@/sidebar/AdminSettingsShared";
// Was inline in AdminGeneralSettingsSection (src/ui/sidebar/AdminSettingsSections.tsx),
// moved here into its own accordion section as part of the Tailscale plugin.
// The backing routes (GET/PATCH /users/tailscale-settings) stay in core -
// they are general user-settings infrastructure this plugin calls, not
// something it owns.
type AdminTailscaleSectionProps = {
open: boolean;
onToggle: () => void;
tailscaleApiKey: string;
setTailscaleApiKey: Dispatch<SetStateAction<string>>;
tailscaleApiBaseUrl: string;
setTailscaleApiBaseUrl: Dispatch<SetStateAction<string>>;
handleSaveTailscaleApiKey: () => void;
};
export function AdminTailscaleSection({
open,
onToggle,
tailscaleApiKey,
setTailscaleApiKey,
tailscaleApiBaseUrl,
setTailscaleApiBaseUrl,
handleSaveTailscaleApiKey,
}: AdminTailscaleSectionProps) {
const { t } = useTranslation();
return (
<AccordionSection
label={t("admin.sectionTailscale")}
icon={<Network className="size-3.5" />}
open={open}
onToggle={onToggle}
>
<div className="flex flex-col gap-2 pt-2">
<div className="flex flex-col gap-0.5">
<span className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
{t("admin.tailscaleApiKey")}
</span>
<span className="text-[10px] text-muted-foreground">
{t("admin.tailscaleApiKeyDescription")}{" "}
<a
href="https://docs.termix.site/features/networking/tailscale"
target="_blank"
rel="noreferrer"
className="text-accent-brand hover:underline"
>
{t("admin.tailscaleApiKeyDocsLink")}
</a>
</span>
</div>
<div className="flex items-center gap-2">
<Input
type="password"
value={tailscaleApiKey}
onChange={(e) => setTailscaleApiKey(e.target.value)}
placeholder="tskey-api-... / hskey-api-..."
className="text-sm"
/>
<Button
variant="outline"
size="sm"
className="text-xs border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand h-7 shrink-0"
onClick={handleSaveTailscaleApiKey}
>
{t("common.save")}
</Button>
</div>
<div className="flex flex-col gap-1.5 mt-1">
<label className="text-[10px] font-semibold text-muted-foreground uppercase tracking-widest">
{t("admin.tailscaleApiBaseUrl")}
</label>
<span className="text-[10px] text-muted-foreground">
{t("admin.tailscaleApiBaseUrlDescription")}
</span>
<Input
value={tailscaleApiBaseUrl}
onChange={(e) => setTailscaleApiBaseUrl(e.target.value)}
placeholder="https://api.tailscale.com/api/v2"
className="text-sm"
/>
</div>
</div>
</AccordionSection>
);
}
@@ -1,39 +1,11 @@
// Was part of src/ui/api/settings-api.ts, moved here with the rest of the
// Tailscale plugin. The backing routes stay in place: /users/tailscale-settings
// is general user-settings infrastructure in core, and /tailscale/devices is
// this plugin's own route (plugins/tailscale/backend/routes.ts).
// The API key and base URL are this plugin's own settings now, read and
// written through the generic /plugins/tailscale/settings/admin routes that
// core renders from the manifest. Only the device list is left here, because
// it is this plugin's own route.
import axios from "axios";
import { authApi, handleApiError } from "@/main-axios";
export async function getTailscaleSettings(): Promise<{
apiKey: string;
hasApiKey: boolean;
apiBaseUrl: string;
}> {
try {
const response = await authApi.get("/users/tailscale-settings");
return response.data;
} catch (error) {
handleApiError(error, "fetch Tailscale settings");
}
}
export async function updateTailscaleSettings(
apiKey: string,
apiBaseUrl?: string,
): Promise<{ hasApiKey: boolean }> {
try {
const response = await authApi.patch("/users/tailscale-settings", {
apiKey,
apiBaseUrl,
});
return response.data;
} catch (error) {
handleApiError(error, "update Tailscale settings");
}
}
export async function getTailscaleDevices(): Promise<{
devices: Array<{
id: string;
@@ -0,0 +1,88 @@
import { describe, it, expect } from "vitest";
import fs from "node:fs";
import path from "node:path";
import { parseManifest } from "@termix/plugin-sdk/manifest";
import { createMockCtx } from "@termix/plugin-sdk/testing";
/**
* The API key and base URL are this plugin's own settings now.
*
* They used to live in the core `settings` table behind a core route that
* existed only for this plugin, with the key stored in plaintext. These tests
* pin the shape core renders the page from, and that reading a plugin's own
* settings needs no capability.
*/
const manifest = parseManifest(
JSON.parse(
fs.readFileSync(
path.resolve(import.meta.dirname, "../../manifest.json"),
"utf8",
),
),
);
describe("tailscale settings contribution", () => {
it("parses cleanly", () => {
expect(manifest.errors).toEqual([]);
});
it("declares the API key as a secret, so it is encrypted and redacted", () => {
const apiKey = manifest.manifest?.contributes?.settings?.admin?.find(
(field) => field.key === "apiKey",
);
expect(apiKey?.type).toBe("secret");
});
it("declares the base URL as plain text, because it is not a secret", () => {
const baseUrl = manifest.manifest?.contributes?.settings?.admin?.find(
(field) => field.key === "apiBaseUrl",
);
expect(baseUrl?.type).toBe("string");
expect(baseUrl?.default).toBe("");
});
it("puts both fields on the admin scope, not the user one", () => {
const settings = manifest.manifest?.contributes?.settings;
expect(settings?.admin?.map((field) => field.key)).toContain("apiKey");
expect(settings?.user).toBeUndefined();
});
it("keeps settings:read-core, which it still holds for core keys", () => {
expect(manifest.manifest?.capabilities).toContain("settings:read-core");
});
});
describe("reading its own settings", () => {
it("needs no capability", async () => {
// Nothing granted at all: a plugin reading its own configuration must not
// have to declare anything for it.
const { ctx } = createMockCtx({
pluginId: "tailscale",
capabilities: [],
settings: { apiKey: "tskey-api-test", apiBaseUrl: "" },
});
await expect(ctx.settings.get("apiKey")).resolves.toBe("tskey-api-test");
});
it("can write and read a value back", async () => {
const { ctx } = createMockCtx({ pluginId: "tailscale", capabilities: [] });
await ctx.settings.set("apiBaseUrl", "https://headscale.example");
await expect(ctx.settings.get("apiBaseUrl")).resolves.toBe(
"https://headscale.example",
);
});
it("still needs settings:read-core to reach a core key", async () => {
const { ctx } = createMockCtx({ pluginId: "tailscale", capabilities: [] });
await expect(ctx.settings.readCore("app_name")).rejects.toThrow(
/settings:read-core/,
);
});
});
@@ -1,62 +0,0 @@
import { describe, expect, it, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { AdminTailscaleSection } from "../../src/frontend/AdminTailscaleSection";
vi.mock("react-i18next", () => ({
useTranslation: () => ({ t: (key: string) => key }),
}));
describe("AdminTailscaleSection", () => {
it("renders the API key and base URL fields and saves on click", async () => {
const user = userEvent.setup();
const handleSave = vi.fn();
render(
<AdminTailscaleSection
open
onToggle={vi.fn()}
tailscaleApiKey="tskey-api-abc"
setTailscaleApiKey={vi.fn()}
tailscaleApiBaseUrl="https://api.tailscale.com/api/v2"
setTailscaleApiBaseUrl={vi.fn()}
handleSaveTailscaleApiKey={handleSave}
/>,
);
expect(
(
screen.getByPlaceholderText(
"tskey-api-... / hskey-api-...",
) as HTMLInputElement
).value,
).toBe("tskey-api-abc");
expect(
(
screen.getByPlaceholderText(
"https://api.tailscale.com/api/v2",
) as HTMLInputElement
).value,
).toBe("https://api.tailscale.com/api/v2");
await user.click(screen.getByText("common.save"));
expect(handleSave).toHaveBeenCalledOnce();
});
it("does not render its content when closed", () => {
render(
<AdminTailscaleSection
open={false}
onToggle={vi.fn()}
tailscaleApiKey=""
setTailscaleApiKey={vi.fn()}
tailscaleApiBaseUrl=""
setTailscaleApiBaseUrl={vi.fn()}
handleSaveTailscaleApiKey={vi.fn()}
/>,
);
expect(
screen.queryByPlaceholderText("tskey-api-... / hskey-api-..."),
).toBeNull();
});
});
+1 -1
View File
@@ -9,8 +9,8 @@
"src/ui/features/session-sharing/SharedSessionView.tsx",
"src/ui/features/terminal/Terminal.tsx",
"src/ui/main-axios.ts",
"src/ui/settings/components/TailscaleDevicesStatus.tsx",
"src/ui/shell/tabUtils.tsx",
"src/ui/sidebar/AdminSettingsPanel.tsx",
"src/ui/sidebar/HostEditor.tsx",
"src/ui/sidebar/HostEditorWebUiSection.tsx",
"src/ui/sidebar/HostsPanel.tsx",
+15
View File
@@ -751,6 +751,21 @@ async function initializeCompleteDatabase(): Promise<void> {
FOREIGN KEY (plugin_id) REFERENCES plugins (id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS plugin_settings (
id INTEGER PRIMARY KEY AUTOINCREMENT,
plugin_id TEXT NOT NULL,
scope TEXT NOT NULL,
scope_id TEXT,
key TEXT NOT NULL,
value TEXT,
encrypted INTEGER NOT NULL DEFAULT 0,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE (plugin_id, scope, scope_id, key),
FOREIGN KEY (plugin_id) REFERENCES plugins (id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_plugin_settings_plugin_scope ON plugin_settings (plugin_id, scope);
CREATE TABLE IF NOT EXISTS rbac_known_permissions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
permission TEXT NOT NULL,
+43
View File
@@ -2252,6 +2252,49 @@ export const pluginStorage = mysqlTable(
],
);
/**
* Values for the settings fields a plugin declares in contributes.settings.
*
* scope_id is polymorphic: null for admin scope, a user id for user scope, a
* host id rendered as text for host scope. That is why it carries no foreign
* key - one column cannot point at two tables - so the user and host delete
* paths remove these rows explicitly. Cascading with the plugin is a real FK,
* because uninstalling should leave nothing behind.
*
* Secret fields are encrypted with the system key before they land here, and
* `encrypted` records which rows that applies to so a read knows to decrypt.
*/
export const pluginSettings = mysqlTable(
"plugin_settings",
{
id: int("id").autoincrement().primaryKey(),
pluginId: varchar("plugin_id", { length: 255 })
.notNull()
.references(() => plugins.id, { onDelete: "cascade" }),
/** admin | user | host */
scope: varchar("scope", { length: 255 }).notNull(),
scopeId: varchar("scope_id", { length: 255 }),
key: varchar("key", { length: 255 }).notNull(),
/** JSON-encoded, so a field keeps its declared type across a round trip. */
value: text("value"),
encrypted: boolean("encrypted")
.notNull()
.default(false),
updatedAt: varchar("updated_at", { length: 255 })
.notNull()
.default(sql`(CURRENT_TIMESTAMP)`),
},
(table) => [
uniqueIndex("idx_plugin_settings_scope_key").on(
table.pluginId,
table.scope,
table.scopeId,
table.key,
),
index("idx_plugin_settings_plugin_scope").on(table.pluginId, table.scope),
],
);
/**
* Which of a plugin's migrations have been applied.
*
+43
View File
@@ -2253,6 +2253,49 @@ export const pluginStorage = pgTable(
],
);
/**
* Values for the settings fields a plugin declares in contributes.settings.
*
* scope_id is polymorphic: null for admin scope, a user id for user scope, a
* host id rendered as text for host scope. That is why it carries no foreign
* key - one column cannot point at two tables - so the user and host delete
* paths remove these rows explicitly. Cascading with the plugin is a real FK,
* because uninstalling should leave nothing behind.
*
* Secret fields are encrypted with the system key before they land here, and
* `encrypted` records which rows that applies to so a read knows to decrypt.
*/
export const pluginSettings = pgTable(
"plugin_settings",
{
id: serial("id").primaryKey(),
pluginId: varchar("plugin_id", { length: 255 })
.notNull()
.references(() => plugins.id, { onDelete: "cascade" }),
/** admin | user | host */
scope: varchar("scope", { length: 255 }).notNull(),
scopeId: varchar("scope_id", { length: 255 }),
key: varchar("key", { length: 255 }).notNull(),
/** JSON-encoded, so a field keeps its declared type across a round trip. */
value: text("value"),
encrypted: boolean("encrypted")
.notNull()
.default(false),
updatedAt: varchar("updated_at", { length: 255 })
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
},
(table) => [
uniqueIndex("idx_plugin_settings_scope_key").on(
table.pluginId,
table.scope,
table.scopeId,
table.key,
),
index("idx_plugin_settings_plugin_scope").on(table.pluginId, table.scope),
],
);
/**
* Which of a plugin's migrations have been applied.
*
+43
View File
@@ -2251,6 +2251,49 @@ export const pluginStorage = sqliteTable(
],
);
/**
* Values for the settings fields a plugin declares in contributes.settings.
*
* scope_id is polymorphic: null for admin scope, a user id for user scope, a
* host id rendered as text for host scope. That is why it carries no foreign
* key - one column cannot point at two tables - so the user and host delete
* paths remove these rows explicitly. Cascading with the plugin is a real FK,
* because uninstalling should leave nothing behind.
*
* Secret fields are encrypted with the system key before they land here, and
* `encrypted` records which rows that applies to so a read knows to decrypt.
*/
export const pluginSettings = sqliteTable(
"plugin_settings",
{
id: integer("id").primaryKey({ autoIncrement: true }),
pluginId: text("plugin_id")
.notNull()
.references(() => plugins.id, { onDelete: "cascade" }),
/** admin | user | host */
scope: text("scope").notNull(),
scopeId: text("scope_id"),
key: text("key").notNull(),
/** JSON-encoded, so a field keeps its declared type across a round trip. */
value: text("value"),
encrypted: integer("encrypted", { mode: "boolean" })
.notNull()
.default(false),
updatedAt: text("updated_at")
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
},
(table) => [
uniqueIndex("idx_plugin_settings_scope_key").on(
table.pluginId,
table.scope,
table.scopeId,
table.key,
),
index("idx_plugin_settings_plugin_scope").on(table.pluginId, table.scope),
],
);
/**
* Which of a plugin's migrations have been applied.
*
@@ -34,6 +34,7 @@ import { OpenTabRepository } from "./open-tab-repository.js";
import { OpksshTokenRepository } from "./opkssh-token-repository.js";
import { PluginRepository } from "./plugin-repository.js";
import { PluginStorageRepository } from "./plugin-storage-repository.js";
import { PluginSettingsRepository } from "./plugin-settings-repository.js";
import { PluginMigrationRepository } from "./plugin-migration-repository.js";
import { PluginPermissionGrantRepository } from "./plugin-permission-grant-repository.js";
import { PluginRegistryRepository } from "./plugin-registry-repository.js";
@@ -395,6 +396,13 @@ export function createCurrentPluginStorageRepository(): PluginStorageRepository
);
}
export function createCurrentPluginSettingsRepository(): PluginSettingsRepository {
return new PluginSettingsRepository(
createCurrentRepositoryContext(),
createCurrentRepositoryWriteHook("plugin_settings_repository_write"),
);
}
export function createCurrentPluginMigrationRepository(): PluginMigrationRepository {
return new PluginMigrationRepository(
createCurrentRepositoryContext(),
@@ -1,6 +1,6 @@
import { and, eq, inArray, sql } from "drizzle-orm";
import { randomUUID } from "crypto";
import { hostAccess, hosts } from "../db/schema.js";
import { hostAccess, hosts, pluginSettings } from "../db/schema.js";
import type { DatabaseContext } from "./database-context.js";
import { DataCrypto } from "../../utils/data-crypto.js";
import { rowsAffected } from "./mutation-result.js";
@@ -338,6 +338,25 @@ export class HostRepository {
}
async deleteByUserId(userId: string): Promise<number> {
// Bulk path, so it never passes through deleteAccessForHost: clear the
// host-scope plugin settings for these hosts before the rows go.
const owned = await this.context.drizzle
.select({ id: hosts.id })
.from(hosts)
.where(eq(hosts.userId, userId));
if (owned.length > 0) {
await this.context.drizzle.delete(pluginSettings).where(
and(
eq(pluginSettings.scope, "host"),
inArray(
pluginSettings.scopeId,
owned.map((row) => String(row.id)),
),
),
);
}
const result = await this.context.drizzle
.delete(hosts)
.where(eq(hosts.userId, userId));
@@ -350,6 +369,18 @@ export class HostRepository {
}
async deleteAccessForHost(hostId: number): Promise<number> {
// plugin_settings.scope_id is polymorphic, so it has no foreign key to
// ssh_data and the engine will not cascade it. Cleared alongside the
// access rows, which every host delete path already goes through.
await this.context.drizzle
.delete(pluginSettings)
.where(
and(
eq(pluginSettings.scope, "host"),
eq(pluginSettings.scopeId, String(hostId)),
),
);
const result = await this.context.drizzle
.delete(hostAccess)
.where(eq(hostAccess.hostId, hostId));
@@ -0,0 +1,191 @@
import { and, eq, inArray, isNull } from "drizzle-orm";
import { pluginSettings } from "../db/schema.js";
import type { DatabaseContext } from "./database-context.js";
import { rowsAffected } from "./mutation-result.js";
import { insertReturning, updateReturning } from "./returning.js";
export type PluginSettingsRecord = typeof pluginSettings.$inferSelect;
export type PluginSettingsScope = "admin" | "user" | "host";
/**
* Values behind ctx.settings and the /plugins/:id/settings routes.
*
* scope_id is null for admin scope, so every lookup has to use IS NULL rather
* than = NULL: SQL equality against null never matches, which would make an
* admin setting look unset no matter how often it was written.
*/
export class PluginSettingsRepository {
constructor(
private readonly context: DatabaseContext,
private readonly onWrite?: () => void | Promise<void>,
) {}
private scopeMatch(
pluginId: string,
scope: PluginSettingsScope,
scopeId: string | null,
) {
return and(
eq(pluginSettings.pluginId, pluginId),
eq(pluginSettings.scope, scope),
scopeId === null
? isNull(pluginSettings.scopeId)
: eq(pluginSettings.scopeId, scopeId),
);
}
async get(
pluginId: string,
scope: PluginSettingsScope,
scopeId: string | null,
key: string,
): Promise<PluginSettingsRecord | null> {
const rows = await this.context.drizzle
.select()
.from(pluginSettings)
.where(
and(
this.scopeMatch(pluginId, scope, scopeId),
eq(pluginSettings.key, key),
),
)
.limit(1);
return rows[0] ?? null;
}
async getAll(
pluginId: string,
scope: PluginSettingsScope,
scopeId: string | null,
): Promise<PluginSettingsRecord[]> {
return this.context.drizzle
.select()
.from(pluginSettings)
.where(this.scopeMatch(pluginId, scope, scopeId));
}
/**
* Every row for a set of scope ids, in one query.
*
* The host list would otherwise run one query per host, which is the shape
* that made other list endpoints slow at a few hundred hosts.
*/
async getAllForScopeIds(
scope: PluginSettingsScope,
scopeIds: string[],
): Promise<PluginSettingsRecord[]> {
if (scopeIds.length === 0) return [];
return this.context.drizzle
.select()
.from(pluginSettings)
.where(
and(
eq(pluginSettings.scope, scope),
inArray(pluginSettings.scopeId, scopeIds),
),
);
}
async set(
pluginId: string,
scope: PluginSettingsScope,
scopeId: string | null,
key: string,
value: string | null,
encrypted = false,
now = new Date().toISOString(),
): Promise<void> {
const where = and(
this.scopeMatch(pluginId, scope, scopeId),
eq(pluginSettings.key, key),
)!;
const existing = await this.context.drizzle
.select()
.from(pluginSettings)
.where(where)
.limit(1);
if (existing[0]) {
await updateReturning(
this.context,
pluginSettings,
{ value, encrypted, updatedAt: now },
where,
);
} else {
await insertReturning(this.context, pluginSettings, {
pluginId,
scope,
scopeId,
key,
value,
encrypted,
updatedAt: now,
});
}
await this.afterWrite();
}
async delete(
pluginId: string,
scope: PluginSettingsScope,
scopeId: string | null,
key: string,
): Promise<boolean> {
const result = await this.context.drizzle
.delete(pluginSettings)
.where(
and(
this.scopeMatch(pluginId, scope, scopeId),
eq(pluginSettings.key, key),
),
);
const affected = rowsAffected(result) > 0;
if (affected) await this.afterWrite();
return affected;
}
async deleteByPlugin(pluginId: string): Promise<number> {
const result = await this.context.drizzle
.delete(pluginSettings)
.where(eq(pluginSettings.pluginId, pluginId));
const affected = rowsAffected(result);
if (affected > 0) await this.afterWrite();
return affected;
}
/**
* Removes every plugin's rows for one user or host.
*
* scope_id carries no foreign key, so the user and host delete paths call
* this instead of relying on the engine to cascade.
*/
async deleteByScope(
scope: PluginSettingsScope,
scopeId: string,
): Promise<number> {
const result = await this.context.drizzle
.delete(pluginSettings)
.where(
and(
eq(pluginSettings.scope, scope),
eq(pluginSettings.scopeId, scopeId),
),
);
const affected = rowsAffected(result);
if (affected > 0) await this.afterWrite();
return affected;
}
private async afterWrite(): Promise<void> {
await this.onWrite?.();
}
}
@@ -1,5 +1,5 @@
import { asc, eq, inArray, like, sql } from "drizzle-orm";
import { users } from "../db/schema.js";
import { and, asc, eq, inArray, like, sql } from "drizzle-orm";
import { pluginSettings, users } from "../db/schema.js";
import type { DatabaseContext } from "./database-context.js";
import {
countValue,
@@ -199,6 +199,15 @@ export class UserRepository {
}
async delete(id: string): Promise<boolean> {
// plugin_settings.scope_id is polymorphic, so it carries no foreign key to
// users and the engine will not cascade it. Removed here rather than in a
// route so every caller that deletes a user is covered.
await this.context.drizzle
.delete(pluginSettings)
.where(
and(eq(pluginSettings.scope, "user"), eq(pluginSettings.scopeId, id)),
);
const result = await this.context.drizzle
.delete(users)
.where(eq(users.id, id));
@@ -0,0 +1,130 @@
/**
* The pluginSettings map attached to a host on its way out.
*
* Without this the host editor would need one request per plugin per host,
* which is the shape that makes a list of a few hundred hosts slow. One query
* covers the whole list instead.
*
* Kept out of transformHostResponse because that function is synchronous and
* shared by every host read path. Secrets are always redacted here, so nothing
* on this path has to decrypt and the async work stays in one place.
*/
import type { PluginManifest } from "@termix/plugin-sdk/manifest";
import { createCurrentPluginSettingsRepository } from "../repositories/factory.js";
import type { PluginSettingsRecord } from "../repositories/plugin-settings-repository.js";
import { declaredFields, resolveFieldValue } from "../../plugins/settings.js";
import { getPluginRuntime } from "../../plugins/index.js";
import { sshLogger } from "../../utils/logger.js";
export type HostPluginSettings = Record<string, Record<string, unknown>>;
/** Enabled plugins that declare host-scope settings, with their manifests. */
function hostSettingsPlugins(): PluginManifest[] {
try {
const { loader } = getPluginRuntime();
return loader
.list()
.filter((plugin) => plugin.state === "active")
.map((plugin) => plugin.manifest)
.filter(
(manifest): manifest is PluginManifest =>
!!manifest && !!manifest.contributes?.settings?.host,
);
} catch {
// The runtime is not up in every context that renders a host.
return [];
}
}
/**
* Builds the map for a set of hosts in one query.
*
* Returns an empty map when no enabled plugin declares host settings, so the
* common case costs nothing.
*/
export async function loadHostPluginSettings(
hostIds: number[],
): Promise<Map<number, HostPluginSettings>> {
const result = new Map<number, HostPluginSettings>();
const manifests = hostSettingsPlugins();
if (manifests.length === 0 || hostIds.length === 0) return result;
let rows: PluginSettingsRecord[] = [];
try {
rows = await createCurrentPluginSettingsRepository().getAllForScopeIds(
"host",
hostIds.map(String),
);
} catch (error) {
// A host list must not fail because a settings read did.
sshLogger.warn("Failed to load host plugin settings", {
operation: "host_plugin_settings",
error: error instanceof Error ? error.message : String(error),
});
return result;
}
const byHostAndPlugin = new Map<string, PluginSettingsRecord[]>();
for (const row of rows) {
const key = `${row.scopeId}:${row.pluginId}`;
const list = byHostAndPlugin.get(key);
if (list) list.push(row);
else byHostAndPlugin.set(key, [row]);
}
for (const hostId of hostIds) {
const perPlugin: HostPluginSettings = {};
for (const manifest of manifests) {
const fields = declaredFields(manifest, "host");
if (fields.length === 0) continue;
const stored = new Map(
(byHostAndPlugin.get(`${hostId}:${manifest.id}`) ?? []).map((row) => [
row.key,
row,
]),
);
const values: Record<string, unknown> = {};
for (const field of fields) {
values[field.key] = await resolveFieldValue(
field,
stored.get(field.key),
{ redactSecrets: true },
);
}
perPlugin[manifest.id] = values;
}
if (Object.keys(perPlugin).length > 0) result.set(hostId, perPlugin);
}
return result;
}
/** Attaches the map to already-transformed host objects, in place. */
export function attachHostPluginSettings(
hosts: Record<string, unknown>[],
settings: Map<number, HostPluginSettings>,
): void {
if (settings.size === 0) return;
for (const host of hosts) {
const hostId = Number(host.id);
const values = settings.get(hostId);
if (values) host.pluginSettings = values;
}
}
/** The single-host convenience wrapper. */
export async function withHostPluginSettings(
host: Record<string, unknown>,
): Promise<Record<string, unknown>> {
const hostId = Number(host.id);
if (!Number.isInteger(hostId)) return host;
const settings = await loadHostPluginSettings([hostId]);
const values = settings.get(hostId);
return values ? { ...host, pluginSettings: values } : host;
}
+25 -4
View File
@@ -45,6 +45,11 @@ import {
stripSensitiveFields,
transformHostResponse,
} from "./host-normalizers.js";
import {
attachHostPluginSettings,
loadHostPluginSettings,
withHostPluginSettings,
} from "./host-plugin-settings.js";
import { validateParentHostId } from "./host-parent-validation.js";
import { registerHostOpksshRoutes } from "./host-opkssh-routes.js";
import { registerHostStepCaRoutes } from "./host-step-ca-routes.js";
@@ -1701,6 +1706,18 @@ router.get(
)
: stripSensitiveFields(host),
);
// After sanitizing: the connect-level projection reduces a shared host
// to an allowlist, which would drop this again. One query for the list.
attachHostPluginSettings(
sanitized,
await loadHostPluginSettings(
sanitized
.map((host) => Number(host.id))
.filter((id) => Number.isInteger(id)),
),
);
res.json(sanitized);
} catch (err) {
sshLogger.error("Failed to fetch SSH hosts from database", err, {
@@ -1767,7 +1784,9 @@ router.get(
const resolved =
(await resolveHostCredentials(result, userId)) || result;
return res.json(stripSensitiveFields(resolved));
return res.json(
await withHostPluginSettings(stripSensitiveFields(resolved)),
);
}
// Not the owner: shared recipients get a sanitized view of the host.
@@ -1818,9 +1837,11 @@ router.get(
(await resolveHostCredentials(sharedResult, userId)) || sharedResult;
res.json(
sanitizeHostForRecipient(
resolvedSharedResult,
accessInfo.permissionLevel,
await withHostPluginSettings(
sanitizeHostForRecipient(
resolvedSharedResult,
accessInfo.permissionLevel,
),
),
);
} catch (err) {
+555
View File
@@ -14,9 +14,29 @@ import {
} from "../repositories/factory.js";
import { getPluginRuntime } from "../../plugins/index.js";
import { invalidatePluginPermissionCache } from "../../plugins/permissions.js";
import {
findField,
getAllSettings,
setSetting,
type PluginSettingsScope,
} from "../../plugins/settings.js";
import {
getAuditUsername,
getRequestMeta,
logAudit,
} from "../../utils/audit-logger.js";
import type {
PluginManifest,
PluginSettingsField,
} from "@termix/plugin-sdk/manifest";
const router = express.Router();
/** A settings body is a flat object; an array or null is a client bug. */
function isPlainBody(body: unknown): body is Record<string, unknown> {
return typeof body === "object" && body !== null && !Array.isArray(body);
}
const authManager = AuthManager.getInstance();
const authenticateJWT = authManager.createAuthMiddleware();
const permissionManager = PermissionManager.getInstance();
@@ -79,15 +99,18 @@ router.get("/", authenticateJWT, async (req: Request, res: Response) => {
let contributes: unknown = null;
let capabilities: string[] = [];
let icon: string | undefined;
try {
const manifest = JSON.parse(record.manifestJson) as {
contributes?: unknown;
capabilities?: unknown;
icon?: unknown;
};
contributes = manifest?.contributes ?? null;
capabilities = Array.isArray(manifest?.capabilities)
? (manifest.capabilities as string[])
: [];
icon = typeof manifest?.icon === "string" ? manifest.icon : undefined;
} catch {
contributes = null;
}
@@ -99,6 +122,7 @@ router.get("/", authenticateJWT, async (req: Request, res: Response) => {
enabled: record.state === "enabled",
state: loaded?.state ?? record.state,
contributes,
icon,
};
if (!canManage) return summary;
@@ -528,4 +552,535 @@ router.delete(
},
);
/**
* Loads a plugin's manifest for the settings routes.
*
* Read from the loader rather than the database row so the fields a request is
* validated against are the ones the running build declares.
*/
async function loadManifestForSettings(
pluginId: string,
): Promise<PluginManifest | null> {
const { loader } = getPluginRuntime();
const live = loader.get(pluginId);
if (live?.manifest) return live.manifest;
const record = await createCurrentPluginRepository().findById(pluginId);
if (!record) return null;
try {
return JSON.parse(record.manifestJson) as PluginManifest;
} catch {
return null;
}
}
/**
* Whether the caller may write a given field.
*
* Admin fields fall back to admin.plugins.manage, so a plugin that declares no
* permission of its own still cannot have its install-wide settings changed by
* an ordinary user. A field naming its own permission uses that instead.
*/
async function canWriteField(
userId: string,
manifest: PluginManifest,
scope: PluginSettingsScope,
field: PluginSettingsField,
): Promise<boolean> {
// Admin scope always needs admin.plugins.manage. A field-level permission
// narrows who may write it; it never widens the scope's own gate, so a
// plugin cannot hand an ordinary user install-wide configuration by naming
// a permission that user happens to hold.
if (
scope === "admin" &&
!(await permissionManager.hasPermission(userId, "admin.plugins.manage"))
) {
return false;
}
if (field.permission) {
const { resolvePermission } = await import("../../plugins/rbac.js");
return permissionManager.hasPermission(
userId,
resolvePermission(manifest, field.permission),
);
}
return true;
}
/** Applies a body of values to one scope, collecting per-field errors. */
async function applySettings(
userId: string,
manifest: PluginManifest,
scope: PluginSettingsScope,
scopeId: string | null,
body: Record<string, unknown>,
): Promise<Record<string, string>> {
const errors: Record<string, string> = {};
for (const [key, value] of Object.entries(body)) {
const field = findField(manifest, scope, key);
if (!field) {
errors[key] = "Not a settings field this plugin declares";
continue;
}
if (!(await canWriteField(userId, manifest, scope, field))) {
errors[key] = "You do not have permission to change this setting";
continue;
}
const error = await setSetting(manifest, scope, scopeId, key, value);
if (error) errors[key] = error;
}
return errors;
}
/**
* @openapi
* /plugins/{id}/settings/admin:
* get:
* summary: Read a plugin's install-wide settings
* description: >
* Returns every admin-scope field the plugin declares, with stored values
* over declared defaults. Secret fields come back as { set: boolean } and
* never carry their value, so a browser can render "configured" without
* ever holding the secret.
* tags:
* - Plugins
* parameters:
* - in: path
* name: id
* required: true
* schema:
* type: string
* responses:
* 200:
* description: The plugin's admin settings.
* 403:
* description: The caller lacks admin.plugins.manage.
* 404:
* description: No such plugin.
*/
router.get(
"/:id/settings/admin",
authenticateJWT,
requireManagePlugins,
async (req: Request, res: Response) => {
const pluginId = String(req.params.id);
try {
const manifest = await loadManifestForSettings(pluginId);
if (!manifest) {
res.status(404).json({ error: "No such plugin" });
return;
}
const values = await getAllSettings(manifest, "admin", null, {
redactSecrets: true,
});
res.json({ id: pluginId, values });
} catch (error) {
databaseLogger.error(
`Failed to read admin settings for plugin ${pluginId}`,
error instanceof Error ? error : new Error(String(error)),
{ operation: "plugin_settings_read" },
);
res.status(500).json({ error: "Failed to read the plugin's settings" });
}
},
);
/**
* @openapi
* /plugins/{id}/settings/admin:
* put:
* summary: Update a plugin's install-wide settings
* description: >
* Writes the given keys. Every value is validated against the field the
* manifest declares, and a key the manifest never declared is rejected
* rather than stored. Validation failures come back per field so a form
* can show every problem at once. Sending a secret field back as
* { set: true } leaves the stored value alone, so saving a form does not
* clear a key it was never shown.
* tags:
* - Plugins
* parameters:
* - in: path
* name: id
* required: true
* schema:
* type: string
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* responses:
* 200:
* description: The updated settings.
* 400:
* description: One or more fields were rejected.
* 403:
* description: The caller lacks admin.plugins.manage.
* 404:
* description: No such plugin.
*/
router.put(
"/:id/settings/admin",
authenticateJWT,
requireManagePlugins,
async (req: Request, res: Response) => {
const pluginId = String(req.params.id);
const userId = (req as AuthenticatedRequest).userId as string;
if (!isPlainBody(req.body)) {
res.status(400).json({ error: "Body must be an object of settings" });
return;
}
try {
const manifest = await loadManifestForSettings(pluginId);
if (!manifest) {
res.status(404).json({ error: "No such plugin" });
return;
}
const errors = await applySettings(
userId,
manifest,
"admin",
null,
req.body,
);
if (Object.keys(errors).length > 0) {
res.status(400).json({ error: "Some settings were rejected", errors });
return;
}
const { ipAddress, userAgent } = getRequestMeta(req);
await logAudit({
userId,
username: await getAuditUsername(userId),
action: "update_plugin_settings",
resourceType: "setting",
resourceId: pluginId,
resourceName: manifest.name,
// Keys only: a value here could be the secret we just encrypted.
details: JSON.stringify({
scope: "admin",
keys: Object.keys(req.body),
}),
ipAddress,
userAgent,
success: true,
});
const values = await getAllSettings(manifest, "admin", null, {
redactSecrets: true,
});
res.json({ id: pluginId, values });
} catch (error) {
databaseLogger.error(
`Failed to update admin settings for plugin ${pluginId}`,
error instanceof Error ? error : new Error(String(error)),
{ operation: "plugin_settings_write" },
);
res.status(500).json({ error: "Failed to update the plugin's settings" });
}
},
);
/**
* @openapi
* /plugins/{id}/settings/user:
* get:
* summary: Read the caller's own settings for a plugin
* description: Returns every user-scope field the plugin declares for the authenticated user. The scope is always the caller, never a user id from the request.
* tags:
* - Plugins
* parameters:
* - in: path
* name: id
* required: true
* schema:
* type: string
* responses:
* 200:
* description: The caller's settings for this plugin.
* 404:
* description: No such plugin.
*/
router.get(
"/:id/settings/user",
authenticateJWT,
async (req: Request, res: Response) => {
const pluginId = String(req.params.id);
const userId = (req as AuthenticatedRequest).userId as string;
try {
const manifest = await loadManifestForSettings(pluginId);
if (!manifest) {
res.status(404).json({ error: "No such plugin" });
return;
}
const values = await getAllSettings(manifest, "user", userId, {
redactSecrets: true,
});
res.json({ id: pluginId, values });
} catch (error) {
databaseLogger.error(
`Failed to read user settings for plugin ${pluginId}`,
error instanceof Error ? error : new Error(String(error)),
{ operation: "plugin_settings_read" },
);
res.status(500).json({ error: "Failed to read the plugin's settings" });
}
},
);
/**
* @openapi
* /plugins/{id}/settings/user:
* put:
* summary: Update the caller's own settings for a plugin
* description: Writes user-scope values for the authenticated user. The scope is always the caller, so one user can never write another's settings.
* tags:
* - Plugins
* parameters:
* - in: path
* name: id
* required: true
* schema:
* type: string
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* responses:
* 200:
* description: The updated settings.
* 400:
* description: One or more fields were rejected.
* 404:
* description: No such plugin.
*/
router.put(
"/:id/settings/user",
authenticateJWT,
async (req: Request, res: Response) => {
const pluginId = String(req.params.id);
const userId = (req as AuthenticatedRequest).userId as string;
if (!isPlainBody(req.body)) {
res.status(400).json({ error: "Body must be an object of settings" });
return;
}
try {
const manifest = await loadManifestForSettings(pluginId);
if (!manifest) {
res.status(404).json({ error: "No such plugin" });
return;
}
const errors = await applySettings(
userId,
manifest,
"user",
userId,
req.body,
);
if (Object.keys(errors).length > 0) {
res.status(400).json({ error: "Some settings were rejected", errors });
return;
}
const values = await getAllSettings(manifest, "user", userId, {
redactSecrets: true,
});
res.json({ id: pluginId, values });
} catch (error) {
databaseLogger.error(
`Failed to update user settings for plugin ${pluginId}`,
error instanceof Error ? error : new Error(String(error)),
{ operation: "plugin_settings_write" },
);
res.status(500).json({ error: "Failed to update the plugin's settings" });
}
},
);
/**
* @openapi
* /plugins/{id}/settings/host/{hostId}:
* get:
* summary: Read a plugin's settings for one host
* description: Returns every host-scope field the plugin declares for that host. Requires edit access to the host, the same check the host editor itself uses.
* tags:
* - Plugins
* parameters:
* - in: path
* name: id
* required: true
* schema:
* type: string
* - in: path
* name: hostId
* required: true
* schema:
* type: integer
* responses:
* 200:
* description: The plugin's settings for that host.
* 400:
* description: Invalid host id.
* 403:
* description: No edit access to that host.
* 404:
* description: No such plugin.
*/
router.get(
"/:id/settings/host/:hostId",
authenticateJWT,
async (req: Request, res: Response) => {
const pluginId = String(req.params.id);
const userId = (req as AuthenticatedRequest).userId as string;
const hostId = Number(req.params.hostId);
if (!Number.isInteger(hostId) || hostId <= 0) {
res.status(400).json({ error: "Invalid host ID" });
return;
}
try {
const manifest = await loadManifestForSettings(pluginId);
if (!manifest) {
res.status(404).json({ error: "No such plugin" });
return;
}
const access = await permissionManager.canAccessHost(
userId,
hostId,
"edit",
);
if (!access.hasAccess) {
res.status(403).json({ error: "Access denied to host" });
return;
}
const values = await getAllSettings(manifest, "host", hostId, {
redactSecrets: true,
});
res.json({ id: pluginId, hostId, values });
} catch (error) {
databaseLogger.error(
`Failed to read host settings for plugin ${pluginId}`,
error instanceof Error ? error : new Error(String(error)),
{ operation: "plugin_settings_read" },
);
res.status(500).json({ error: "Failed to read the plugin's settings" });
}
},
);
/**
* @openapi
* /plugins/{id}/settings/host/{hostId}:
* put:
* summary: Update a plugin's settings for one host
* description: Writes host-scope values. Requires edit access to the host, so a user who may only connect to a shared host cannot change how a plugin treats it.
* tags:
* - Plugins
* parameters:
* - in: path
* name: id
* required: true
* schema:
* type: string
* - in: path
* name: hostId
* required: true
* schema:
* type: integer
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* responses:
* 200:
* description: The updated settings.
* 400:
* description: Invalid host id, or one or more fields were rejected.
* 403:
* description: No edit access to that host.
* 404:
* description: No such plugin.
*/
router.put(
"/:id/settings/host/:hostId",
authenticateJWT,
async (req: Request, res: Response) => {
const pluginId = String(req.params.id);
const userId = (req as AuthenticatedRequest).userId as string;
const hostId = Number(req.params.hostId);
if (!Number.isInteger(hostId) || hostId <= 0) {
res.status(400).json({ error: "Invalid host ID" });
return;
}
if (!isPlainBody(req.body)) {
res.status(400).json({ error: "Body must be an object of settings" });
return;
}
try {
const manifest = await loadManifestForSettings(pluginId);
if (!manifest) {
res.status(404).json({ error: "No such plugin" });
return;
}
const access = await permissionManager.canAccessHost(
userId,
hostId,
"edit",
);
if (!access.hasAccess) {
res.status(403).json({ error: "Access denied to host" });
return;
}
const errors = await applySettings(
userId,
manifest,
"host",
String(hostId),
req.body,
);
if (Object.keys(errors).length > 0) {
res.status(400).json({ error: "Some settings were rejected", errors });
return;
}
const values = await getAllSettings(manifest, "host", hostId, {
redactSecrets: true,
});
res.json({ id: pluginId, hostId, values });
} catch (error) {
databaseLogger.error(
`Failed to update host settings for plugin ${pluginId}`,
error instanceof Error ? error : new Error(String(error)),
{ operation: "plugin_settings_write" },
);
res.status(500).json({ error: "Failed to update the plugin's settings" });
}
},
);
export default router;
@@ -338,120 +338,6 @@ export function registerUserSettingsRoutes(
}
});
/**
* @openapi
* /users/tailscale-settings:
* get:
* summary: Get Tailscale settings
* description: Returns whether a Tailscale API key is configured (value is masked).
* tags:
* - Users
* responses:
* 200:
* description: Tailscale settings.
* content:
* application/json:
* schema:
* type: object
* properties:
* apiKey:
* type: string
* description: Masked API key or empty string if not set.
* hasApiKey:
* type: boolean
* apiBaseUrl:
* type: string
* description: Custom control-plane API base URL (e.g. a Headscale instance), or empty string for the Tailscale default.
*/
router.get("/tailscale-settings", authenticateJWT, async (_req, res) => {
try {
const settingsRepo = createCurrentSettingsRepository();
const apiKey = (await settingsRepo.get("tailscale_api_key")) ?? "";
const apiBaseUrl =
(await settingsRepo.get("tailscale_api_base_url")) ?? "";
res.json({
apiKey: apiKey
? `${apiKey.slice(0, 6)}${"*".repeat(Math.max(0, apiKey.length - 6))}`
: "",
hasApiKey: !!apiKey,
apiBaseUrl,
});
} catch (err) {
authLogger.error("Failed to get Tailscale settings", err);
res.status(500).json({ error: "Failed to get Tailscale settings" });
}
});
/**
* @openapi
* /users/tailscale-settings:
* patch:
* summary: Update Tailscale settings (admin only)
* description: Saves or clears the Tailscale API key used for device discovery.
* tags:
* - Users
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* apiKey:
* type: string
* apiBaseUrl:
* type: string
* description: Optional custom control-plane API base URL (e.g. a Headscale instance). Leave empty to use the Tailscale default.
* responses:
* 200:
* description: Tailscale settings updated.
* 403:
* description: Not authorized.
* 500:
* description: Failed to update Tailscale settings.
*/
router.patch("/tailscale-settings", authenticateJWT, async (req, res) => {
const userId = (req as AuthenticatedRequest).userId;
try {
const actor = await getAdminActor(userId);
if (!actor) {
return res.status(403).json({ error: "Not authorized" });
}
const { apiKey, apiBaseUrl } = req.body;
if (typeof apiKey !== "string") {
return res.status(400).json({ error: "apiKey must be a string" });
}
if (apiBaseUrl !== undefined && typeof apiBaseUrl !== "string") {
return res.status(400).json({ error: "apiBaseUrl must be a string" });
}
const settingsRepo = createCurrentSettingsRepository();
await settingsRepo.set("tailscale_api_key", apiKey);
if (apiBaseUrl !== undefined) {
await settingsRepo.set(
"tailscale_api_base_url",
apiBaseUrl.trim().replace(/\/+$/, ""),
);
}
const { ipAddress, userAgent } = getRequestMeta(req);
await logAudit({
userId,
username: actor.username ?? userId,
action: "update_tailscale_settings",
resourceType: "setting",
details: JSON.stringify({ hasApiKey: !!apiKey }),
ipAddress,
userAgent,
success: true,
});
res.json({ hasApiKey: !!apiKey });
} catch (err) {
authLogger.error("Failed to update Tailscale settings", err);
res.status(500).json({ error: "Failed to update Tailscale settings" });
}
});
/**
* @openapi
* /users/command-history-enabled:
+74
View File
@@ -36,6 +36,7 @@ import {
} from "./http.js";
import { registerPluginWsRoute, registerPluginWsUpgrade } from "./ws.js";
import { resolvePermission } from "./rbac.js";
import * as pluginSettings from "./settings.js";
import type { PluginManifest } from "@termix/plugin-sdk/manifest";
import {
PluginCapabilityError,
@@ -257,6 +258,18 @@ export function createPluginContext(
{ action: "db_client" },
);
// The one settings call that leaves the plugin's own namespace, so the one
// that needs a capability and an audit line.
const settingsReadCore = guarded(
manifest,
"settings:read-core",
async (key: string) => pluginSettings.readCoreSetting(key),
{
action: "settings_read_core",
details: () => "read a core server setting",
},
);
const dbRefs = guarded(
manifest,
"db:own",
@@ -474,6 +487,66 @@ export function createPluginContext(
},
},
settings: {
// Reading and writing a plugin's OWN settings is ungated. 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 a capability.
get: (key) =>
pluginSettings.getSetting(manifest, "admin", null, key) as never,
set: async (key, value) => {
const error = await pluginSettings.setSetting(
manifest,
"admin",
null,
key,
value,
);
if (error) throw new Error(error);
},
getUser: (userId, key) =>
pluginSettings.getSetting(manifest, "user", userId, key) as never,
setUser: async (userId, key, value) => {
const error = await pluginSettings.setSetting(
manifest,
"user",
userId,
key,
value,
);
if (error) throw new Error(error);
},
getHost: (hostId, key) =>
pluginSettings.getSetting(manifest, "host", hostId, key) as never,
setHost: async (hostId, key, value) => {
const error = await pluginSettings.setSetting(
manifest,
"host",
hostId,
key,
value,
);
if (error) throw new Error(error);
},
getAll: (scope, scopeId) =>
pluginSettings.getAllSettings(manifest, scope, scopeId ?? null),
onChange: (key, listener) => {
const unsubscribe = pluginSettings.onSettingsChange(
pluginId,
key,
listener,
);
handle.bag.add(unsubscribe, `settings listener for "${key}"`);
return unsubscribe;
},
readCore: (key) => settingsReadCore(key),
},
rbac: {
// A short name takes this plugin's prefix; another plugin's id or a core
// group is used as given, which is what makes a cross-plugin check
@@ -623,6 +696,7 @@ export async function disposePluginHandle(
// Belt and braces: an offer made outside ctx, or one whose registration was
// lost to a throw mid-activate, would otherwise outlive the plugin.
secretRegistry.withdrawAllForPlugin(pluginId);
pluginSettings.clearSettingsListeners(pluginId);
// Skipped when activate() never finished: a plugin's deactivate expects the
// state activate builds, and calling it on a half-built plugin tends to
+319
View File
@@ -0,0 +1,319 @@
/**
* Plugin settings: validation, storage, encryption and change notification.
*
* One module behind both ctx.settings and the /plugins/:id/settings routes, so
* "what is a valid value", "which fields are secret" and "what a read hands
* back" are decided once. Two implementations would drift, and the one that
* drifted would be the one writing to the database.
*
* A field the manifest never declared is rejected rather than stored. That is
* what stops a PUT from filling a plugin's namespace with arbitrary keys, and
* it is why every entry point resolves the field first and the value second.
*/
import type {
PluginManifest,
PluginSettingsField,
} from "@termix/plugin-sdk/manifest";
import {
coerceSettingValue,
isRedactedSecret,
validateSettingValue,
} from "@termix/plugin-sdk/settings";
import { createCurrentPluginSettingsRepository } from "../database/repositories/factory.js";
import type { PluginSettingsScope } from "../database/repositories/plugin-settings-repository.js";
import {
decryptSystemSecret,
encryptSystemSecret,
} from "../utils/system-secret-crypto.js";
import { pluginLogger } from "../utils/logger.js";
export type { PluginSettingsScope };
/**
* Core settings a plugin may read through ctx.settings.readCore.
*
* Deliberately short. Everything here is either already visible in the UI to
* every user, or a switch a plugin has to honour to behave correctly. Adding
* to it means deciding that every plugin holding settings:read-core may see
* that value, so it is a review decision rather than a convenience.
*/
export const CORE_SETTINGS_ALLOWLIST: readonly string[] = [
"app_name",
"guac_enabled",
"global_metrics_interval",
"global_status_check_interval",
];
type ChangeListener = (value: unknown) => void;
/** pluginId -> key -> listeners. In-process only; settings are not clustered. */
const listeners = new Map<string, Map<string, Set<ChangeListener>>>();
export function onSettingsChange(
pluginId: string,
key: string,
listener: ChangeListener,
): () => void {
let byKey = listeners.get(pluginId);
if (!byKey) {
byKey = new Map();
listeners.set(pluginId, byKey);
}
let set = byKey.get(key);
if (!set) {
set = new Set();
byKey.set(key, set);
}
set.add(listener);
return () => {
set!.delete(listener);
if (set!.size === 0) byKey!.delete(key);
if (byKey!.size === 0) listeners.delete(pluginId);
};
}
function notify(pluginId: string, key: string, value: unknown): void {
for (const listener of listeners.get(pluginId)?.get(key) ?? []) {
try {
listener(value);
} catch (error) {
// A listener throwing must not fail the write that already happened.
pluginLogger.error(
`Plugin ${pluginId} settings listener for "${key}" failed`,
error instanceof Error ? error : new Error(String(error)),
{ operation: "plugin_settings_change" },
);
}
}
}
/** Drops every listener a plugin registered. Called when it deactivates. */
export function clearSettingsListeners(pluginId: string): void {
listeners.delete(pluginId);
}
/** The fields a manifest declares for one scope. */
export function declaredFields(
manifest: PluginManifest,
scope: PluginSettingsScope,
): PluginSettingsField[] {
const settings = manifest.contributes?.settings;
if (!settings) return [];
if (scope === "admin") return settings.admin ?? [];
if (scope === "user") return settings.user ?? [];
const host = settings.host;
if (!host) return [];
// The enable switch is a real stored key, so it has to behave like a field
// everywhere: validated on write, defaulted on read, rendered in the editor.
const enableField: PluginSettingsField[] = host.enableKey
? [
{
key: host.enableKey,
type: "boolean",
labelKey: host.enableLabelKey,
default: false,
},
]
: [];
return [...enableField, ...host.fields];
}
export function findField(
manifest: PluginManifest,
scope: PluginSettingsScope,
key: string,
): PluginSettingsField | undefined {
return declaredFields(manifest, scope).find((field) => field.key === key);
}
function normalizeScopeId(
scope: PluginSettingsScope,
scopeId: string | number | undefined | null,
): string | null {
if (scope === "admin") return null;
if (scopeId === undefined || scopeId === null) {
throw new Error(`Plugin settings scope "${scope}" requires a scope id`);
}
return String(scopeId);
}
/** Stored values are JSON so a field keeps its declared type on the way back. */
function encode(value: unknown): string {
return JSON.stringify(value ?? null);
}
function decode(raw: string | null): unknown {
if (raw === null) return undefined;
try {
return JSON.parse(raw);
} catch {
return undefined;
}
}
export async function getSetting(
manifest: PluginManifest,
scope: PluginSettingsScope,
scopeId: string | number | null | undefined,
key: string,
): Promise<unknown> {
const field = findField(manifest, scope, key);
if (!field) return undefined;
const row = await createCurrentPluginSettingsRepository().get(
manifest.id,
scope,
normalizeScopeId(scope, scopeId),
key,
);
if (!row || row.value === null) return field.default;
const decoded = decode(row.value);
if (decoded === undefined || decoded === null) return field.default;
if (row.encrypted && typeof decoded === "string") {
return decryptSystemSecret(decoded);
}
return decoded;
}
export interface SetSettingOptions {
/** Skips validation for a value core itself produced, such as a migration. */
trusted?: boolean;
}
/**
* Writes one value, after validating it against the declared field.
*
* Returns the error message when the value is rejected, or null on success,
* rather than throwing: a PUT reports every bad field at once and a throw per
* field would only ever surface the first.
*/
export async function setSetting(
manifest: PluginManifest,
scope: PluginSettingsScope,
scopeId: string | number | null | undefined,
key: string,
value: unknown,
options: SetSettingOptions = {},
): Promise<string | null> {
const field = findField(manifest, scope, key);
if (!field) {
return `"${key}" is not a settings field this plugin declares`;
}
// Echoing a redacted secret back means "leave it alone", so a form can save
// without clearing a key it was never shown.
if (field.type === "secret" && isRedactedSecret(value)) return null;
const coerced = options.trusted ? value : coerceSettingValue(field, value);
if (!options.trusted) {
const error = validateSettingValue(field, coerced);
if (error) return error;
}
// Clearing a secret writes null rather than an encrypted empty string, so a
// read reports it as unset instead of "set to nothing".
const clearingSecret = field.type === "secret" && coerced === "";
const shouldEncrypt =
field.type === "secret" && typeof coerced === "string" && !clearingSecret;
const stored = clearingSecret
? null
: shouldEncrypt
? encode(await encryptSystemSecret(coerced as string))
: encode(coerced);
await createCurrentPluginSettingsRepository().set(
manifest.id,
scope,
normalizeScopeId(scope, scopeId),
key,
stored,
shouldEncrypt,
);
notify(manifest.id, key, coerced);
return null;
}
export interface GetAllOptions {
/** Replaces every secret with { set } so the value never leaves the server. */
redactSecrets?: boolean;
}
/**
* Every declared field for one scope, with stored values over defaults.
*
* Driven by the manifest rather than by the rows, so a field that was never
* written still comes back with its default and a stale row for a field the
* plugin has since dropped is ignored instead of being handed to the UI.
*/
export async function getAllSettings(
manifest: PluginManifest,
scope: PluginSettingsScope,
scopeId: string | number | null | undefined,
options: GetAllOptions = {},
): Promise<Record<string, unknown>> {
const fields = declaredFields(manifest, scope);
if (fields.length === 0) return {};
const rows = await createCurrentPluginSettingsRepository().getAll(
manifest.id,
scope,
normalizeScopeId(scope, scopeId),
);
const byKey = new Map(rows.map((row) => [row.key, row]));
const values: Record<string, unknown> = {};
for (const field of fields) {
values[field.key] = await resolveFieldValue(
field,
byKey.get(field.key),
options,
);
}
return values;
}
/** Shared by getAllSettings and the host-list projection. */
export async function resolveFieldValue(
field: PluginSettingsField,
row: { value: string | null; encrypted: boolean } | undefined,
options: GetAllOptions = {},
): Promise<unknown> {
const decoded = row && row.value !== null ? decode(row.value) : undefined;
const hasValue = decoded !== undefined && decoded !== null && decoded !== "";
if (field.type === "secret") {
if (options.redactSecrets) return { set: hasValue };
if (!hasValue) return field.default;
return row!.encrypted && typeof decoded === "string"
? decryptSystemSecret(decoded)
: decoded;
}
return hasValue ? decoded : field.default;
}
/**
* Reads a core server setting. The caller has already checked
* settings:read-core; this only enforces the allowlist.
*/
export async function readCoreSetting(key: string): Promise<string | null> {
if (!CORE_SETTINGS_ALLOWLIST.includes(key)) {
throw new Error(
`Core setting "${key}" is not readable by plugins. Readable keys: ${CORE_SETTINGS_ALLOWLIST.join(", ")}`,
);
}
const { createCurrentSettingsRepository } =
await import("../database/repositories/factory.js");
return createCurrentSettingsRepository().get(key);
}
+6
View File
@@ -344,6 +344,12 @@ async function provisionLocalDesktopUserIfNeeded(): Promise<void> {
operation: "plugin_init",
});
}
// After seeding: plugin_settings has a foreign key to plugins, so there
// is nothing to migrate into until the plugin row exists.
const { runTailscaleSettingsMigration } =
await import("./utils/crypto-migration/tailscale-settings-migration.js");
await runTailscaleSettingsMigration();
} catch (error) {
systemLogger.warn("Plugin runtime failed to initialize", {
operation: "plugin_init",
@@ -0,0 +1,147 @@
/**
* plugin_settings cleanup when a user or host goes.
*
* scope_id is polymorphic, so it carries no foreign key and the engine will
* not cascade it. The repositories delete these rows explicitly, and these
* tests exist so a later refactor that drops those calls fails here rather
* than leaving orphaned settings behind forever.
*/
import { asc } from "drizzle-orm";
import { afterEach, describe, expect, it } from "vitest";
import { TestSqliteDatabase } from "./repositories/test-support.js";
import { HostRepository } from "../../database/repositories/host-repository.js";
import { UserRepository } from "../../database/repositories/user-repository.js";
import type { DatabaseContext } from "../../database/repositories/database-context.js";
import { pluginSettings } from "../../database/db/schema.js";
let adapter: TestSqliteDatabase | null = null;
let ctx: DatabaseContext | null = null;
afterEach(async () => {
if (adapter) {
await adapter.close();
adapter = null;
ctx = null;
}
});
async function seed(): Promise<DatabaseContext> {
adapter = new TestSqliteDatabase();
const context = await adapter.connect();
ctx = context;
await adapter.exec(`
INSERT INTO users (id, username, password_hash)
VALUES ('user-1', 'alice', 'hash'), ('user-2', 'bob', 'hash');
INSERT INTO ssh_data (id, user_id, name, ip, port, username, auth_type)
VALUES
(7, 'user-1', 'seven', '10.0.0.7', 22, 'root', 'password'),
(8, 'user-1', 'eight', '10.0.0.8', 22, 'root', 'password'),
(9, 'user-2', 'nine', '10.0.0.9', 22, 'root', 'password');
INSERT INTO plugins (id, name, version, manifest_json)
VALUES ('sample', 'Sample', '1.0.0', '{}');
`);
return context;
}
async function addSetting(
scope: string,
scopeId: string | null,
key = "k",
): Promise<void> {
await adapter!.exec(
`INSERT INTO plugin_settings (plugin_id, scope, scope_id, key, value)
VALUES ('sample', '${scope}', ${scopeId === null ? "NULL" : `'${scopeId}'`}, '${key}', '"v"')`,
);
}
async function remaining(): Promise<
{ scope: string; scopeId: string | null }[]
> {
const rows = await ctx!.drizzle
.select({
scope: pluginSettings.scope,
scopeId: pluginSettings.scopeId,
})
.from(pluginSettings)
.orderBy(asc(pluginSettings.scope), asc(pluginSettings.scopeId));
return rows;
}
describe("user deletion", () => {
it("removes that user's plugin settings", async () => {
const context = await seed();
await addSetting("user", "user-1");
await new UserRepository(context).delete("user-1");
expect(await remaining()).toEqual([]);
});
it("leaves another user's settings alone", async () => {
const context = await seed();
await addSetting("user", "user-1");
await addSetting("user", "user-2");
await new UserRepository(context).delete("user-1");
expect(await remaining()).toEqual([{ scope: "user", scopeId: "user-2" }]);
});
it("leaves admin-scope settings alone", async () => {
const context = await seed();
await addSetting("admin", null);
await addSetting("user", "user-1");
await new UserRepository(context).delete("user-1");
expect(await remaining()).toEqual([{ scope: "admin", scopeId: null }]);
});
});
describe("host deletion", () => {
it("removes that host's plugin settings", async () => {
const context = await seed();
await addSetting("host", "7");
await new HostRepository(context).deleteForUser("user-1", 7);
expect(await remaining()).toEqual([]);
});
it("leaves another host's settings alone", async () => {
const context = await seed();
await addSetting("host", "7");
await addSetting("host", "8");
await new HostRepository(context).deleteForUser("user-1", 7);
expect(await remaining()).toEqual([{ scope: "host", scopeId: "8" }]);
});
it("clears settings for every host in the bulk delete path", async () => {
const context = await seed();
await addSetting("host", "7");
await addSetting("host", "8");
await addSetting("host", "9");
await new HostRepository(context).deleteByUserId("user-1");
// Host 9 belongs to another user and keeps its settings.
expect(await remaining()).toEqual([{ scope: "host", scopeId: "9" }]);
});
it("does not confuse a host id with a user id", async () => {
const context = await seed();
await addSetting("host", "7");
await addSetting("user", "7");
await new HostRepository(context).deleteForUser("user-1", 7);
expect(await remaining()).toEqual([{ scope: "user", scopeId: "7" }]);
});
});
@@ -0,0 +1,244 @@
/**
* The pluginSettings map attached to host responses.
*
* The host editor needs these with the host, not one request per plugin per
* host, so the shape and the query count both matter.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { PluginManifest } from "@termix/plugin-sdk/manifest";
interface Row {
pluginId: string;
scope: string;
scopeId: string | null;
key: string;
value: string | null;
encrypted: boolean;
}
const rows: Row[] = [];
const loaded: { id: string; manifest: PluginManifest; state: string }[] = [];
const getAllForScopeIds = vi.fn(async (scope: string, scopeIds: string[]) =>
rows.filter(
(row) => row.scope === scope && scopeIds.includes(row.scopeId ?? ""),
),
);
vi.mock("../../../utils/logger.js", () => {
const logger = {
debug: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
success: vi.fn(),
};
return { sshLogger: logger, pluginLogger: logger, databaseLogger: logger };
});
vi.mock("../../../database/repositories/factory.js", () => ({
createCurrentPluginSettingsRepository: () => ({ getAllForScopeIds }),
}));
vi.mock("../../../plugins/index.js", () => ({
getPluginRuntime: () => ({ loader: { list: () => loaded } }),
}));
vi.mock("../../../utils/system-secret-crypto.js", () => ({
isSystemEncrypted: (value: string) => value.startsWith("sysenc:v1:"),
encryptSystemSecret: async (plaintext: string) => plaintext,
decryptSystemSecret: async (stored: string) => stored,
}));
const {
attachHostPluginSettings,
loadHostPluginSettings,
withHostPluginSettings,
} = await import("../../../database/routes/host-plugin-settings.js");
function manifest(id: string, host: unknown): PluginManifest {
return {
id,
name: id,
version: "1.0.0",
description: "",
author: { name: "t" },
license: "MIT",
category: "Productivity",
engine: { termix: ">=2.9.0", api: "1" },
capabilities: [],
contributes: { settings: { host } },
} as PluginManifest;
}
const DOCKER = manifest("docker", {
enableKey: "enableDocker",
enableLabelKey: "k",
fields: [{ key: "socketPath", type: "string", labelKey: "k" }],
});
beforeEach(() => {
rows.length = 0;
loaded.length = 0;
getAllForScopeIds.mockClear();
});
describe("loadHostPluginSettings", () => {
it("returns nothing when no plugin declares host settings", async () => {
loaded.push({
id: "other",
manifest: manifest("other", undefined),
state: "active",
});
const result = await loadHostPluginSettings([1, 2]);
expect(result.size).toBe(0);
expect(getAllForScopeIds).not.toHaveBeenCalled();
});
it("ignores a plugin that is not running", async () => {
loaded.push({ id: "docker", manifest: DOCKER, state: "disabled" });
const result = await loadHostPluginSettings([1]);
expect(result.size).toBe(0);
});
it("uses one query for the whole list", async () => {
loaded.push({ id: "docker", manifest: DOCKER, state: "active" });
await loadHostPluginSettings([1, 2, 3, 4, 5]);
expect(getAllForScopeIds).toHaveBeenCalledTimes(1);
expect(getAllForScopeIds).toHaveBeenCalledWith("host", [
"1",
"2",
"3",
"4",
"5",
]);
});
it("merges stored values over declared defaults", async () => {
loaded.push({ id: "docker", manifest: DOCKER, state: "active" });
rows.push({
pluginId: "docker",
scope: "host",
scopeId: "1",
key: "enableDocker",
value: JSON.stringify(true),
encrypted: false,
});
const result = await loadHostPluginSettings([1, 2]);
expect(result.get(1)).toEqual({
docker: { enableDocker: true, socketPath: undefined },
});
// Host 2 has nothing stored, so it falls back to the declared default.
expect(result.get(2)).toEqual({
docker: { enableDocker: false, socketPath: undefined },
});
});
it("keeps one host's values out of another's", async () => {
loaded.push({ id: "docker", manifest: DOCKER, state: "active" });
rows.push({
pluginId: "docker",
scope: "host",
scopeId: "1",
key: "socketPath",
value: JSON.stringify("/var/run/docker.sock"),
encrypted: false,
});
const result = await loadHostPluginSettings([1, 2]);
expect(result.get(1)!.docker.socketPath).toBe("/var/run/docker.sock");
expect(result.get(2)!.docker.socketPath).toBeUndefined();
});
it("redacts a host-scope secret", async () => {
loaded.push({
id: "vault",
manifest: manifest("vault", {
fields: [{ key: "token", type: "secret", labelKey: "k" }],
}),
state: "active",
});
rows.push({
pluginId: "vault",
scope: "host",
scopeId: "1",
key: "token",
value: JSON.stringify("s.verysecret"),
encrypted: true,
});
const result = await loadHostPluginSettings([1]);
expect(result.get(1)!.vault.token).toEqual({ set: true });
expect(JSON.stringify(result.get(1))).not.toContain("verysecret");
});
it("survives a failed settings read rather than failing the host list", async () => {
loaded.push({ id: "docker", manifest: DOCKER, state: "active" });
getAllForScopeIds.mockRejectedValueOnce(new Error("database is away"));
await expect(loadHostPluginSettings([1])).resolves.toEqual(new Map());
});
it("does nothing for an empty host list", async () => {
loaded.push({ id: "docker", manifest: DOCKER, state: "active" });
expect((await loadHostPluginSettings([])).size).toBe(0);
expect(getAllForScopeIds).not.toHaveBeenCalled();
});
});
describe("attachHostPluginSettings", () => {
it("attaches values to the matching host", () => {
const hosts = [{ id: 1 }, { id: 2 }] as Record<string, unknown>[];
const settings = new Map([[1, { docker: { enableDocker: true } }]]);
attachHostPluginSettings(hosts, settings);
expect(hosts[0].pluginSettings).toEqual({ docker: { enableDocker: true } });
expect(hosts[1].pluginSettings).toBeUndefined();
});
it("leaves hosts alone when there is nothing to attach", () => {
const hosts = [{ id: 1 }] as Record<string, unknown>[];
attachHostPluginSettings(hosts, new Map());
expect(hosts[0]).toEqual({ id: 1 });
});
});
describe("withHostPluginSettings", () => {
it("adds the map to a single host", async () => {
loaded.push({ id: "docker", manifest: DOCKER, state: "active" });
const result = await withHostPluginSettings({ id: 1, name: "web" });
expect(result.name).toBe("web");
expect(result.pluginSettings).toEqual({
docker: { enableDocker: false, socketPath: undefined },
});
});
it("returns the host unchanged when nothing contributes", async () => {
const host = { id: 1, name: "web" };
expect(await withHostPluginSettings(host)).toBe(host);
});
it("returns the host unchanged when its id is not a number", async () => {
loaded.push({ id: "docker", manifest: DOCKER, state: "active" });
const host = { name: "web" };
expect(await withHostPluginSettings(host)).toBe(host);
});
});
@@ -0,0 +1,356 @@
/**
* contributes.settings validation.
*
* A settings field is the one thing in a manifest that core writes to the
* database on a plugin's behalf, so the schema has to reject anything it would
* not know how to store or render.
*/
import { describe, expect, it } from "vitest";
import {
parseManifest,
validateManifest,
SUPPORTED_PLUGIN_API_VERSION,
} from "../../plugins/manifest.js";
function base(overrides: Record<string, unknown> = {}) {
return {
id: "sample-plugin",
name: "Sample Plugin",
version: "1.0.0",
description: "A fixture.",
author: { name: "Termix Tests" },
license: "MIT",
category: "Productivity",
engine: { termix: ">=2.9.0", api: SUPPORTED_PLUGIN_API_VERSION },
capabilities: ["kv:own"],
...overrides,
};
}
function withSettings(settings: unknown) {
return base({ contributes: { settings } });
}
describe("contributes.settings", () => {
it("accepts a manifest with every field type", () => {
const errors = validateManifest(
withSettings({
admin: [
{ key: "apiKey", type: "secret", labelKey: "k" },
{ key: "baseUrl", type: "string", labelKey: "k", default: "" },
{ key: "retries", type: "number", labelKey: "k", min: 0, max: 5 },
{ key: "enabled", type: "boolean", labelKey: "k" },
{
key: "mode",
type: "select",
labelKey: "k",
options: [{ value: "a", labelKey: "k" }],
},
{
key: "tags",
type: "multiselect",
labelKey: "k",
options: [{ value: "a", labelKey: "k" }],
},
{ key: "notes", type: "textarea", labelKey: "k" },
{ key: "raw", type: "json", labelKey: "k" },
{ key: "browser", type: "custom", component: "devices" },
],
user: [{ key: "theme", type: "string", labelKey: "k" }],
host: {
enableKey: "enableThing",
enableLabelKey: "k",
fields: [{ key: "port", type: "number", labelKey: "k" }],
},
}),
);
expect(errors).toEqual([]);
});
it("accepts a manifest with no settings at all", () => {
expect(validateManifest(base())).toEqual([]);
});
it("rejects an unknown field property", () => {
const errors = validateManifest(
withSettings({
admin: [{ key: "a", type: "string", labelKey: "k", wat: 1 }],
}),
);
expect(errors.join(" ")).toContain('Unknown field "wat"');
});
it("rejects an unknown scope", () => {
const errors = validateManifest(
withSettings({ global: [{ key: "a", type: "string", labelKey: "k" }] }),
);
expect(errors.join(" ")).toContain('Unknown field "global"');
});
it("rejects an unknown field type", () => {
const errors = validateManifest(
withSettings({ admin: [{ key: "a", type: "colour", labelKey: "k" }] }),
);
expect(errors.join(" ")).toContain("must be one of");
});
it("rejects a duplicate key within one scope", () => {
const errors = validateManifest(
withSettings({
admin: [
{ key: "a", type: "string", labelKey: "k" },
{ key: "a", type: "number", labelKey: "k" },
],
}),
);
expect(errors.join(" ")).toContain('duplicates "a"');
});
it("allows the same key in two different scopes", () => {
const errors = validateManifest(
withSettings({
admin: [{ key: "shared", type: "string", labelKey: "k" }],
user: [{ key: "shared", type: "string", labelKey: "k" }],
}),
);
expect(errors).toEqual([]);
});
it("requires labelKey for everything but a custom field", () => {
expect(
validateManifest(
withSettings({ admin: [{ key: "a", type: "string" }] }),
).join(" "),
).toContain("labelKey");
expect(
validateManifest(
withSettings({ admin: [{ key: "a", type: "custom", component: "x" }] }),
),
).toEqual([]);
});
it("requires component on a custom field and forbids it elsewhere", () => {
expect(
validateManifest(
withSettings({ admin: [{ key: "a", type: "custom" }] }),
).join(" "),
).toContain("component");
expect(
validateManifest(
withSettings({
admin: [{ key: "a", type: "string", labelKey: "k", component: "x" }],
}),
).join(" "),
).toContain('only valid when type is "custom"');
});
it("requires non-empty options on select and multiselect", () => {
for (const type of ["select", "multiselect"]) {
expect(
validateManifest(
withSettings({ admin: [{ key: "a", type, labelKey: "k" }] }),
).join(" "),
).toContain("options must be a non-empty array");
}
});
it("rejects options on a field that has no options", () => {
const errors = validateManifest(
withSettings({
admin: [
{
key: "a",
type: "string",
labelKey: "k",
options: [{ value: "x", labelKey: "k" }],
},
],
}),
);
expect(errors.join(" ")).toContain("only valid for select and multiselect");
});
it("rejects min greater than max", () => {
const errors = validateManifest(
withSettings({
admin: [{ key: "a", type: "number", labelKey: "k", min: 10, max: 1 }],
}),
);
expect(errors.join(" ")).toContain("must not be greater than");
});
it("rejects min or max on a non-number field", () => {
const errors = validateManifest(
withSettings({
admin: [{ key: "a", type: "string", labelKey: "k", min: 1 }],
}),
);
expect(errors.join(" ")).toContain("only valid for number fields");
});
it("rejects requires that names no boolean in the same scope", () => {
const errors = validateManifest(
withSettings({
admin: [
{ key: "a", type: "string", labelKey: "k", requires: "missing" },
],
}),
);
expect(errors.join(" ")).toContain("must name a boolean field");
});
it("rejects requires that names a non-boolean field", () => {
const errors = validateManifest(
withSettings({
admin: [
{ key: "flag", type: "string", labelKey: "k" },
{ key: "a", type: "string", labelKey: "k", requires: "flag" },
],
}),
);
expect(errors.join(" ")).toContain("must name a boolean field");
});
it("accepts requires pointing at a boolean declared later", () => {
const errors = validateManifest(
withSettings({
admin: [
{ key: "a", type: "string", labelKey: "k", requires: "flag" },
{ key: "flag", type: "boolean", labelKey: "k" },
],
}),
);
expect(errors).toEqual([]);
});
it("lets a host field require the section's enable key", () => {
const errors = validateManifest(
withSettings({
host: {
enableKey: "enableThing",
enableLabelKey: "k",
fields: [
{
key: "port",
type: "number",
labelKey: "k",
requires: "enableThing",
},
],
},
}),
);
expect(errors).toEqual([]);
});
it("rejects a host field colliding with the enable key", () => {
const errors = validateManifest(
withSettings({
host: {
enableKey: "enableThing",
enableLabelKey: "k",
fields: [{ key: "enableThing", type: "boolean", labelKey: "k" }],
},
}),
);
expect(errors.join(" ")).toContain("already the section's enableKey");
});
it("requires enableLabelKey alongside enableKey", () => {
const errors = validateManifest(
withSettings({
host: { enableKey: "enableThing", fields: [] },
}),
);
expect(errors.join(" ")).toContain("enableLabelKey");
});
it("rejects a key that would not be index-safe", () => {
const errors = validateManifest(
withSettings({
admin: [{ key: "a".repeat(80), type: "string", labelKey: "k" }],
}),
);
expect(errors.join(" ")).toContain("short alphanumeric key");
});
});
describe("settings field permissions", () => {
it("rejects a short permission the plugin does not declare", () => {
const { errors } = parseManifest(
base({
contributes: {
settings: {
user: [
{ key: "a", type: "string", labelKey: "k", permission: "use" },
],
},
},
}),
);
expect(errors.join(" ")).toContain(
"not declared in contributes.permissions",
);
});
it("accepts a short permission the plugin declares", () => {
const { errors, manifest } = parseManifest(
base({
contributes: {
permissions: [{ name: "use", titleKey: "k", descriptionKey: "k" }],
settings: {
user: [
{ key: "a", type: "string", labelKey: "k", permission: "use" },
],
},
},
}),
);
expect(errors).toEqual([]);
expect(manifest?.contributes?.settings?.user?.[0].permission).toBe("use");
});
it("accepts a fully qualified permission from another namespace", () => {
// Cross-namespace ids cannot be checked here: the validator has no idea
// which other plugins exist. Taken as given, exactly like ctx.rbac does.
const { errors } = parseManifest(
base({
contributes: {
settings: {
user: [
{
key: "a",
type: "string",
labelKey: "k",
permission: "hosts.view",
},
],
},
},
}),
);
expect(errors).toEqual([]);
});
});
@@ -0,0 +1,498 @@
/**
* The /plugins/:id/settings routes.
*
* These decide who may change what, so the tests are mostly about refusal: a
* non-admin must not write install-wide configuration, a user must not write
* another user's settings or a host they cannot edit, and a secret must never
* come back over the wire.
*/
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import express from "express";
import type { AddressInfo } from "node:net";
import type { Server } from "node:http";
import type { PluginManifest } from "@termix/plugin-sdk/manifest";
const state = vi.hoisted(() => ({
userId: "user-1",
permissions: new Set<string>(),
hostEditAccess: true,
}));
vi.mock("../../utils/logger.js", () => {
const logger = {
debug: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
success: vi.fn(),
};
return { pluginLogger: logger, databaseLogger: logger, apiLogger: logger };
});
vi.mock("../../utils/auth-manager.js", () => ({
AuthManager: {
getInstance: () => ({
createAuthMiddleware:
() =>
(
req: express.Request,
res: express.Response,
next: express.NextFunction,
) => {
(req as express.Request & { userId?: string }).userId = state.userId;
next();
},
}),
},
}));
vi.mock("../../utils/permission-manager.js", () => ({
PermissionManager: {
getInstance: () => ({
hasPermission: async (_userId: string, permission: string) =>
state.permissions.has(permission),
requirePermission:
(permission: string) =>
(
_req: express.Request,
res: express.Response,
next: express.NextFunction,
) => {
if (!state.permissions.has(permission)) {
res.status(403).json({ error: "Insufficient permissions" });
return;
}
next();
},
canAccessHost: async () => ({
hasAccess: state.hostEditAccess,
isOwner: state.hostEditAccess,
isShared: false,
}),
}),
},
}));
vi.mock("../../utils/audit-logger.js", () => ({
logAudit: vi.fn(async () => {}),
getAuditUsername: vi.fn(async (id: string) => id),
getRequestMeta: () => ({ ipAddress: "127.0.0.1", userAgent: "test" }),
}));
const MANIFEST: PluginManifest = {
id: "sample",
name: "Sample",
version: "1.0.0",
description: "",
author: { name: "t" },
license: "MIT",
category: "Productivity",
engine: { termix: ">=2.9.0", api: "1" },
capabilities: [],
contributes: {
permissions: [{ name: "tweak", titleKey: "k", descriptionKey: "k" }],
settings: {
admin: [
{ key: "apiKey", type: "secret", labelKey: "k" },
{ key: "retries", type: "number", labelKey: "k", min: 0, max: 5 },
{
key: "narrowed",
type: "string",
labelKey: "k",
permission: "tweak",
},
],
user: [
{ key: "theme", type: "string", labelKey: "k", default: "dark" },
{
key: "advanced",
type: "string",
labelKey: "k",
permission: "tweak",
},
],
host: {
enableKey: "enableThing",
enableLabelKey: "k",
fields: [{ key: "port", type: "number", labelKey: "k" }],
},
},
},
} as PluginManifest;
vi.mock("../../plugins/index.js", () => ({
getPluginRuntime: () => ({
loader: {
get: (id: string) =>
id === "sample"
? { id, manifest: MANIFEST, state: "active" }
: undefined,
list: () => [{ id: "sample", manifest: MANIFEST, state: "active" }],
},
}),
}));
interface Row {
pluginId: string;
scope: string;
scopeId: string | null;
key: string;
value: string | null;
encrypted: boolean;
}
const rows: Row[] = [];
function find(scope: string, scopeId: string | null, key: string) {
return rows.find(
(row) => row.scope === scope && row.scopeId === scopeId && row.key === key,
);
}
vi.mock("../../database/repositories/factory.js", () => ({
createCurrentPluginRepository: () => ({
findById: async (id: string) =>
id === "sample"
? { id, manifestJson: JSON.stringify(MANIFEST), state: "enabled" }
: null,
listAll: async () => [],
}),
createCurrentPluginPermissionGrantRepository: () => ({
listByPlugin: async () => [],
}),
createCurrentPluginSettingsRepository: () => ({
get: async (
_pluginId: string,
scope: string,
scopeId: string | null,
key: string,
) => find(scope, scopeId, key) ?? null,
getAll: async (_pluginId: string, scope: string, scopeId: string | null) =>
rows.filter((row) => row.scope === scope && row.scopeId === scopeId),
set: async (
pluginId: string,
scope: string,
scopeId: string | null,
key: string,
value: string | null,
encrypted = false,
) => {
const existing = find(scope, scopeId, key);
if (existing) {
existing.value = value;
existing.encrypted = encrypted;
return;
}
rows.push({ pluginId, scope, scopeId, key, value, encrypted });
},
}),
createCurrentSettingsRepository: () => ({ get: async () => null }),
}));
vi.mock("../../utils/system-secret-crypto.js", () => ({
isSystemEncrypted: (value: string) => value.startsWith("sysenc:v1:"),
encryptSystemSecret: async (plaintext: string) =>
!plaintext || plaintext.startsWith("sysenc:v1:")
? plaintext
: `sysenc:v1:${Buffer.from(plaintext, "utf8").toString("base64")}`,
decryptSystemSecret: async (stored: string) =>
stored.startsWith("sysenc:v1:")
? Buffer.from(stored.slice("sysenc:v1:".length), "base64").toString(
"utf8",
)
: stored,
}));
const pluginRoutes = (await import("../../database/routes/plugins.js")).default;
let server: Server;
let baseUrl: string;
beforeEach(async () => {
rows.length = 0;
state.userId = "user-1";
state.permissions = new Set<string>();
state.hostEditAccess = true;
const app = express();
app.use(express.json());
app.use("/plugins", pluginRoutes);
server = await new Promise<Server>((resolve) => {
const created = app.listen(0, "127.0.0.1", () => resolve(created));
});
baseUrl = `http://127.0.0.1:${(server.address() as AddressInfo).port}`;
});
afterEach(async () => {
await new Promise<void>((resolve) => server.close(() => resolve()));
});
function call(path: string, init?: RequestInit) {
return fetch(`${baseUrl}${path}`, {
...init,
headers: { "content-type": "application/json", ...(init?.headers ?? {}) },
});
}
describe("admin scope", () => {
it("refuses a reader without admin.plugins.manage", async () => {
const response = await call("/plugins/sample/settings/admin");
expect(response.status).toBe(403);
});
it("refuses a writer without admin.plugins.manage", async () => {
const response = await call("/plugins/sample/settings/admin", {
method: "PUT",
body: JSON.stringify({ retries: 2 }),
});
expect(response.status).toBe(403);
expect(rows).toHaveLength(0);
});
it("lets an admin read and write", async () => {
state.permissions.add("admin.plugins.manage");
const write = await call("/plugins/sample/settings/admin", {
method: "PUT",
body: JSON.stringify({ retries: 3 }),
});
expect(write.status).toBe(200);
const read = await call("/plugins/sample/settings/admin");
expect((await read.json()).values.retries).toBe(3);
});
it("reports a rejected value per field", async () => {
state.permissions.add("admin.plugins.manage");
const response = await call("/plugins/sample/settings/admin", {
method: "PUT",
body: JSON.stringify({ retries: 99 }),
});
expect(response.status).toBe(400);
expect((await response.json()).errors.retries).toContain("at most 5");
expect(rows).toHaveLength(0);
});
it("refuses a key the manifest never declared", async () => {
state.permissions.add("admin.plugins.manage");
const response = await call("/plugins/sample/settings/admin", {
method: "PUT",
body: JSON.stringify({ somethingElse: "x" }),
});
expect(response.status).toBe(400);
expect((await response.json()).errors.somethingElse).toBeDefined();
expect(rows).toHaveLength(0);
});
it("never returns a secret's value", async () => {
state.permissions.add("admin.plugins.manage");
await call("/plugins/sample/settings/admin", {
method: "PUT",
body: JSON.stringify({ apiKey: "tskey-very-secret" }),
});
const response = await call("/plugins/sample/settings/admin");
const body = await response.text();
expect(body).not.toContain("tskey-very-secret");
expect(JSON.parse(body).values.apiKey).toEqual({ set: true });
});
it("treats a redacted echo as no change", async () => {
state.permissions.add("admin.plugins.manage");
await call("/plugins/sample/settings/admin", {
method: "PUT",
body: JSON.stringify({ apiKey: "tskey-original" }),
});
await call("/plugins/sample/settings/admin", {
method: "PUT",
body: JSON.stringify({ apiKey: { set: true }, retries: 1 }),
});
const row = rows.find((entry) => entry.key === "apiKey")!;
expect(
Buffer.from(
JSON.parse(row.value!).slice("sysenc:v1:".length),
"base64",
).toString("utf8"),
).toBe("tskey-original");
});
it("keeps the admin gate even for a field naming its own permission", async () => {
// Holding the field's permission is not enough: admin scope is admin
// scope, so a plugin cannot hand install-wide config to an ordinary user.
state.permissions.add("sample.tweak");
const response = await call("/plugins/sample/settings/admin", {
method: "PUT",
body: JSON.stringify({ narrowed: "x" }),
});
expect(response.status).toBe(403);
expect(rows).toHaveLength(0);
});
it("narrows an admin field to holders of its permission", async () => {
state.permissions.add("admin.plugins.manage");
const denied = await call("/plugins/sample/settings/admin", {
method: "PUT",
body: JSON.stringify({ narrowed: "x" }),
});
expect(denied.status).toBe(400);
expect((await denied.json()).errors.narrowed).toContain("permission");
state.permissions.add("sample.tweak");
const allowed = await call("/plugins/sample/settings/admin", {
method: "PUT",
body: JSON.stringify({ narrowed: "x" }),
});
expect(allowed.status).toBe(200);
});
it("404s for a plugin that does not exist", async () => {
state.permissions.add("admin.plugins.manage");
const response = await call("/plugins/nope/settings/admin");
expect(response.status).toBe(404);
});
it("rejects a body that is not an object", async () => {
state.permissions.add("admin.plugins.manage");
const response = await call("/plugins/sample/settings/admin", {
method: "PUT",
body: JSON.stringify([1, 2, 3]),
});
expect(response.status).toBe(400);
});
});
describe("user scope", () => {
it("lets any authenticated user read and write their own", async () => {
const write = await call("/plugins/sample/settings/user", {
method: "PUT",
body: JSON.stringify({ theme: "light" }),
});
expect(write.status).toBe(200);
const read = await call("/plugins/sample/settings/user");
expect((await read.json()).values.theme).toBe("light");
});
it("scopes writes to the caller, never to a user id in the body", async () => {
await call("/plugins/sample/settings/user", {
method: "PUT",
body: JSON.stringify({ theme: "light", userId: "someone-else" }),
});
// The bogus key is rejected and the row belongs to the caller.
expect(rows.every((row) => row.scopeId === "user-1")).toBe(true);
state.userId = "user-2";
const other = await call("/plugins/sample/settings/user");
expect((await other.json()).values.theme).toBe("dark");
});
it("refuses a user field gated on a permission the caller lacks", async () => {
const response = await call("/plugins/sample/settings/user", {
method: "PUT",
body: JSON.stringify({ advanced: "x" }),
});
expect(response.status).toBe(400);
expect((await response.json()).errors.advanced).toContain("permission");
expect(rows).toHaveLength(0);
});
it("allows it once the caller holds that permission", async () => {
state.permissions.add("sample.tweak");
const response = await call("/plugins/sample/settings/user", {
method: "PUT",
body: JSON.stringify({ advanced: "x" }),
});
expect(response.status).toBe(200);
});
it("refuses an admin field through the user route", async () => {
const response = await call("/plugins/sample/settings/user", {
method: "PUT",
body: JSON.stringify({ apiKey: "nope" }),
});
expect(response.status).toBe(400);
expect(rows).toHaveLength(0);
});
});
describe("host scope", () => {
it("lets a user with edit access read and write", async () => {
const write = await call("/plugins/sample/settings/host/7", {
method: "PUT",
body: JSON.stringify({ enableThing: true, port: 22 }),
});
expect(write.status).toBe(200);
const read = await call("/plugins/sample/settings/host/7");
const values = (await read.json()).values;
expect(values.enableThing).toBe(true);
expect(values.port).toBe(22);
});
it("refuses a host the caller cannot edit", async () => {
state.hostEditAccess = false;
const read = await call("/plugins/sample/settings/host/7");
expect(read.status).toBe(403);
const write = await call("/plugins/sample/settings/host/7", {
method: "PUT",
body: JSON.stringify({ port: 22 }),
});
expect(write.status).toBe(403);
expect(rows).toHaveLength(0);
});
it("rejects an invalid host id", async () => {
const response = await call("/plugins/sample/settings/host/not-a-number");
expect(response.status).toBe(400);
});
it("keeps each host's values apart", async () => {
await call("/plugins/sample/settings/host/7", {
method: "PUT",
body: JSON.stringify({ port: 22 }),
});
await call("/plugins/sample/settings/host/8", {
method: "PUT",
body: JSON.stringify({ port: 2222 }),
});
const seven = await (await call("/plugins/sample/settings/host/7")).json();
const eight = await (await call("/plugins/sample/settings/host/8")).json();
expect(seven.values.port).toBe(22);
expect(eight.values.port).toBe(2222);
});
it("returns the declared default for an untouched host", async () => {
const response = await call("/plugins/sample/settings/host/9");
expect((await response.json()).values.enableThing).toBe(false);
});
});
+442
View File
@@ -0,0 +1,442 @@
/**
* The plugin settings service.
*
* Storage is faked at the repository boundary; everything above it - schema
* lookup, validation, encryption, redaction and change notification - is the
* real implementation, because that is where the behaviour worth protecting
* lives.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { PluginManifest } from "@termix/plugin-sdk/manifest";
interface Row {
pluginId: string;
scope: string;
scopeId: string | null;
key: string;
value: string | null;
encrypted: boolean;
}
const rows: Row[] = [];
function find(
pluginId: string,
scope: string,
scopeId: string | null,
key: string,
) {
return rows.find(
(row) =>
row.pluginId === pluginId &&
row.scope === scope &&
row.scopeId === scopeId &&
row.key === key,
);
}
const settingsRepository = {
get: vi.fn(
async (
pluginId: string,
scope: string,
scopeId: string | null,
key: string,
) => find(pluginId, scope, scopeId, key) ?? null,
),
getAll: vi.fn(
async (pluginId: string, scope: string, scopeId: string | null) =>
rows.filter(
(row) =>
row.pluginId === pluginId &&
row.scope === scope &&
row.scopeId === scopeId,
),
),
set: vi.fn(
async (
pluginId: string,
scope: string,
scopeId: string | null,
key: string,
value: string | null,
encrypted = false,
) => {
const existing = find(pluginId, scope, scopeId, key);
if (existing) {
existing.value = value;
existing.encrypted = encrypted;
return;
}
rows.push({ pluginId, scope, scopeId, key, value, encrypted });
},
),
};
const coreSettings = new Map<string, string>();
vi.mock("../../database/repositories/factory.js", () => ({
createCurrentPluginSettingsRepository: () => settingsRepository,
createCurrentSettingsRepository: () => ({
get: async (key: string) => coreSettings.get(key) ?? null,
}),
}));
// Real AES would need the system key on disk. The prefix and the idempotence
// are what the service depends on, so the double keeps both.
vi.mock("../../utils/system-secret-crypto.js", () => ({
isSystemEncrypted: (value: string) => value.startsWith("sysenc:v1:"),
encryptSystemSecret: async (plaintext: string) =>
!plaintext || plaintext.startsWith("sysenc:v1:")
? plaintext
: `sysenc:v1:${Buffer.from(plaintext, "utf8").toString("base64")}`,
decryptSystemSecret: async (stored: string) =>
stored.startsWith("sysenc:v1:")
? Buffer.from(stored.slice("sysenc:v1:".length), "base64").toString(
"utf8",
)
: stored,
}));
const {
CORE_SETTINGS_ALLOWLIST,
clearSettingsListeners,
declaredFields,
findField,
getAllSettings,
getSetting,
onSettingsChange,
readCoreSetting,
setSetting,
} = await import("../../plugins/settings.js");
function manifest(settings: unknown): PluginManifest {
return {
id: "sample",
name: "Sample",
version: "1.0.0",
description: "",
author: { name: "t" },
license: "MIT",
category: "Productivity",
engine: { termix: ">=2.9.0", api: "1" },
capabilities: [],
contributes: { settings },
} as PluginManifest;
}
const ADMIN = manifest({
admin: [
{ key: "apiKey", type: "secret", labelKey: "k" },
{ key: "baseUrl", type: "string", labelKey: "k", default: "https://x" },
{ key: "retries", type: "number", labelKey: "k", min: 0, max: 5 },
{ key: "enabled", type: "boolean", labelKey: "k", default: false },
{
key: "mode",
type: "select",
labelKey: "k",
options: [
{ value: "fast", labelKey: "k" },
{ value: "slow", labelKey: "k" },
],
},
],
});
beforeEach(() => {
rows.length = 0;
coreSettings.clear();
clearSettingsListeners("sample");
vi.clearAllMocks();
});
describe("declaredFields", () => {
it("returns the fields for each scope", () => {
expect(declaredFields(ADMIN, "admin").map((f) => f.key)).toEqual([
"apiKey",
"baseUrl",
"retries",
"enabled",
"mode",
]);
expect(declaredFields(ADMIN, "user")).toEqual([]);
});
it("treats the host enable key as a real boolean field", () => {
const withHost = manifest({
host: {
enableKey: "enableThing",
enableLabelKey: "k",
fields: [{ key: "port", type: "number", labelKey: "k" }],
},
});
const fields = declaredFields(withHost, "host");
expect(fields.map((f) => f.key)).toEqual(["enableThing", "port"]);
expect(fields[0]).toMatchObject({ type: "boolean", default: false });
});
it("finds a field by key", () => {
expect(findField(ADMIN, "admin", "baseUrl")?.type).toBe("string");
expect(findField(ADMIN, "admin", "nope")).toBeUndefined();
});
});
describe("setSetting", () => {
it("writes a valid value and reads it back", async () => {
expect(
await setSetting(ADMIN, "admin", null, "baseUrl", "https://y"),
).toBeNull();
expect(await getSetting(ADMIN, "admin", null, "baseUrl")).toBe("https://y");
});
it("refuses a key the manifest never declared", async () => {
const error = await setSetting(ADMIN, "admin", null, "wat", "x");
expect(error).toContain("not a settings field this plugin declares");
expect(rows).toHaveLength(0);
});
it("refuses a value that fails the declared constraint", async () => {
expect(await setSetting(ADMIN, "admin", null, "retries", 99)).toContain(
"at most 5",
);
expect(
await setSetting(ADMIN, "admin", null, "mode", "sideways"),
).toContain("must be one of");
expect(rows).toHaveLength(0);
});
it("coerces the strings a form actually sends", async () => {
expect(await setSetting(ADMIN, "admin", null, "retries", "3")).toBeNull();
expect(await getSetting(ADMIN, "admin", null, "retries")).toBe(3);
expect(
await setSetting(ADMIN, "admin", null, "enabled", "true"),
).toBeNull();
expect(await getSetting(ADMIN, "admin", null, "enabled")).toBe(true);
});
it("keeps each scope separate", async () => {
const scoped = manifest({
admin: [{ key: "v", type: "string", labelKey: "k" }],
user: [{ key: "v", type: "string", labelKey: "k" }],
host: { fields: [{ key: "v", type: "string", labelKey: "k" }] },
});
await setSetting(scoped, "admin", null, "v", "a");
await setSetting(scoped, "user", "user-1", "v", "u");
await setSetting(scoped, "host", 7, "v", "h");
expect(await getSetting(scoped, "admin", null, "v")).toBe("a");
expect(await getSetting(scoped, "user", "user-1", "v")).toBe("u");
expect(await getSetting(scoped, "host", 7, "v")).toBe("h");
// A different user sees nothing of the first user's value.
expect(await getSetting(scoped, "user", "user-2", "v")).toBeUndefined();
});
it("stores a host id as text, matching the polymorphic scope column", async () => {
const scoped = manifest({
host: { fields: [{ key: "v", type: "string", labelKey: "k" }] },
});
await setSetting(scoped, "host", 12, "v", "x");
expect(rows[0].scopeId).toBe("12");
// Either spelling resolves to the same row.
expect(await getSetting(scoped, "host", "12", "v")).toBe("x");
});
});
describe("defaults", () => {
it("falls back to the declared default when nothing is stored", async () => {
expect(await getSetting(ADMIN, "admin", null, "baseUrl")).toBe("https://x");
expect(await getSetting(ADMIN, "admin", null, "enabled")).toBe(false);
});
it("returns undefined for a field with no default and no value", async () => {
expect(await getSetting(ADMIN, "admin", null, "retries")).toBeUndefined();
});
it("returns undefined for a key the manifest does not declare", async () => {
expect(await getSetting(ADMIN, "admin", null, "wat")).toBeUndefined();
});
});
describe("secrets", () => {
it("encrypts on the way in and decrypts on the way out", async () => {
await setSetting(ADMIN, "admin", null, "apiKey", "tskey-secret");
const row = rows.find((entry) => entry.key === "apiKey")!;
expect(row.encrypted).toBe(true);
expect(row.value).not.toContain("tskey-secret");
expect(JSON.parse(row.value!)).toMatch(/^sysenc:v1:/);
expect(await getSetting(ADMIN, "admin", null, "apiKey")).toBe(
"tskey-secret",
);
});
it("redacts a secret rather than returning it", async () => {
await setSetting(ADMIN, "admin", null, "apiKey", "tskey-secret");
const values = await getAllSettings(ADMIN, "admin", null, {
redactSecrets: true,
});
expect(values.apiKey).toEqual({ set: true });
expect(JSON.stringify(values)).not.toContain("tskey-secret");
});
it("reports an unset secret as set:false", async () => {
const values = await getAllSettings(ADMIN, "admin", null, {
redactSecrets: true,
});
expect(values.apiKey).toEqual({ set: false });
});
it("treats a redacted echo as leave-it-alone", async () => {
await setSetting(ADMIN, "admin", null, "apiKey", "original");
expect(
await setSetting(ADMIN, "admin", null, "apiKey", { set: true }),
).toBeNull();
expect(await getSetting(ADMIN, "admin", null, "apiKey")).toBe("original");
});
it("lets a secret be replaced and cleared", async () => {
await setSetting(ADMIN, "admin", null, "apiKey", "first");
await setSetting(ADMIN, "admin", null, "apiKey", "second");
expect(await getSetting(ADMIN, "admin", null, "apiKey")).toBe("second");
await setSetting(ADMIN, "admin", null, "apiKey", "");
const values = await getAllSettings(ADMIN, "admin", null, {
redactSecrets: true,
});
expect(values.apiKey).toEqual({ set: false });
});
it("hands a plugin its own secret in the clear", async () => {
await setSetting(ADMIN, "admin", null, "apiKey", "tskey-secret");
const values = await getAllSettings(ADMIN, "admin", null);
expect(values.apiKey).toBe("tskey-secret");
});
});
describe("getAllSettings", () => {
it("is driven by the manifest, not by the stored rows", async () => {
await setSetting(ADMIN, "admin", null, "baseUrl", "https://y");
// A row for a field the plugin has since dropped.
rows.push({
pluginId: "sample",
scope: "admin",
scopeId: null,
key: "removedField",
value: JSON.stringify("stale"),
encrypted: false,
});
const values = await getAllSettings(ADMIN, "admin", null);
expect(values.baseUrl).toBe("https://y");
expect(values.enabled).toBe(false);
expect(values).not.toHaveProperty("removedField");
});
it("returns nothing for a scope the plugin declares no fields in", async () => {
expect(await getAllSettings(ADMIN, "user", "user-1")).toEqual({});
});
});
describe("onSettingsChange", () => {
it("fires on a successful write", async () => {
const listener = vi.fn();
onSettingsChange("sample", "baseUrl", listener);
await setSetting(ADMIN, "admin", null, "baseUrl", "https://z");
expect(listener).toHaveBeenCalledWith("https://z");
});
it("does not fire for a rejected write", async () => {
const listener = vi.fn();
onSettingsChange("sample", "retries", listener);
await setSetting(ADMIN, "admin", null, "retries", 99);
expect(listener).not.toHaveBeenCalled();
});
it("stops firing once unsubscribed", async () => {
const listener = vi.fn();
const unsubscribe = onSettingsChange("sample", "baseUrl", listener);
unsubscribe();
await setSetting(ADMIN, "admin", null, "baseUrl", "https://z");
expect(listener).not.toHaveBeenCalled();
});
it("only notifies listeners for that key and plugin", async () => {
const other = vi.fn();
const another = vi.fn();
onSettingsChange("sample", "enabled", other);
onSettingsChange("different-plugin", "baseUrl", another);
await setSetting(ADMIN, "admin", null, "baseUrl", "https://z");
expect(other).not.toHaveBeenCalled();
expect(another).not.toHaveBeenCalled();
clearSettingsListeners("different-plugin");
});
it("survives a listener that throws", async () => {
const good = vi.fn();
onSettingsChange("sample", "baseUrl", () => {
throw new Error("listener blew up");
});
onSettingsChange("sample", "baseUrl", good);
await expect(
setSetting(ADMIN, "admin", null, "baseUrl", "https://z"),
).resolves.toBeNull();
expect(good).toHaveBeenCalled();
});
it("drops every listener when the plugin deactivates", async () => {
const listener = vi.fn();
onSettingsChange("sample", "baseUrl", listener);
clearSettingsListeners("sample");
await setSetting(ADMIN, "admin", null, "baseUrl", "https://z");
expect(listener).not.toHaveBeenCalled();
});
});
describe("readCoreSetting", () => {
it("returns an allowlisted key", async () => {
coreSettings.set("app_name", "Termix");
expect(await readCoreSetting("app_name")).toBe("Termix");
});
it("refuses a key that is not on the allowlist", async () => {
coreSettings.set("jwt_secret", "nope");
await expect(readCoreSetting("jwt_secret")).rejects.toThrow(
/not readable by plugins/,
);
});
it("does not expose anything sensitive through the allowlist", () => {
for (const key of CORE_SETTINGS_ALLOWLIST) {
expect(key).not.toMatch(/secret|password|token|key$/i);
}
});
});
@@ -0,0 +1,228 @@
/**
* The Tailscale settings migration.
*
* An upgrade must be lossless: an install that had a working API key must keep
* working without anyone re-entering it. That is the whole point of the test,
* and running it twice proves a restart cannot corrupt what it moved.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
interface Row {
pluginId: string;
scope: string;
scopeId: string | null;
key: string;
value: string | null;
encrypted: boolean;
}
const coreSettings = new Map<string, string>();
const pluginRows: Row[] = [];
let pluginInstalled = true;
const pluginSettingsRepository = {
get: async (
pluginId: string,
scope: string,
scopeId: string | null,
key: string,
) =>
pluginRows.find(
(row) =>
row.pluginId === pluginId &&
row.scope === scope &&
row.scopeId === scopeId &&
row.key === key,
) ?? null,
set: async (
pluginId: string,
scope: string,
scopeId: string | null,
key: string,
value: string | null,
encrypted = false,
) => {
const existing = pluginRows.find(
(row) =>
row.pluginId === pluginId &&
row.scope === scope &&
row.scopeId === scopeId &&
row.key === key,
);
if (existing) {
existing.value = value;
existing.encrypted = encrypted;
return;
}
pluginRows.push({ pluginId, scope, scopeId, key, value, encrypted });
},
};
vi.mock("../../database/repositories/factory.js", () => ({
createCurrentPluginRepository: () => ({
findById: async (id: string) =>
pluginInstalled && id === "tailscale" ? { id } : null,
}),
createCurrentPluginSettingsRepository: () => pluginSettingsRepository,
createCurrentSettingsRepository: () => ({
get: async (key: string) => coreSettings.get(key) ?? null,
delete: async (key: string) => coreSettings.delete(key),
}),
}));
vi.mock("../../utils/system-secret-crypto.js", () => ({
isSystemEncrypted: (value: string) => value.startsWith("sysenc:v1:"),
encryptSystemSecret: async (plaintext: string) =>
!plaintext || plaintext.startsWith("sysenc:v1:")
? plaintext
: `sysenc:v1:${Buffer.from(plaintext, "utf8").toString("base64")}`,
decryptSystemSecret: async (stored: string) =>
stored.startsWith("sysenc:v1:")
? Buffer.from(stored.slice("sysenc:v1:".length), "base64").toString(
"utf8",
)
: stored,
}));
const { runTailscaleSettingsMigration } =
await import("../../utils/crypto-migration/tailscale-settings-migration.js");
function storedValue(key: string): unknown {
const row = pluginRows.find((entry) => entry.key === key);
return row?.value === null || row?.value === undefined
? undefined
: JSON.parse(row.value);
}
beforeEach(() => {
coreSettings.clear();
pluginRows.length = 0;
pluginInstalled = true;
});
describe("runTailscaleSettingsMigration", () => {
it("moves an existing key and base URL into plugin settings", async () => {
coreSettings.set("tailscale_api_key", "tskey-api-legacy");
coreSettings.set("tailscale_api_base_url", "https://headscale.example");
const result = await runTailscaleSettingsMigration();
expect(result.moved).toEqual([
"tailscale_api_key",
"tailscale_api_base_url",
]);
expect(storedValue("apiKey")).toMatch(/^sysenc:v1:/);
expect(storedValue("apiBaseUrl")).toBe("https://headscale.example");
});
it("encrypts the key, which was stored in plaintext before", async () => {
coreSettings.set("tailscale_api_key", "tskey-api-legacy");
await runTailscaleSettingsMigration();
const row = pluginRows.find((entry) => entry.key === "apiKey")!;
expect(row.encrypted).toBe(true);
expect(row.value).not.toContain("tskey-api-legacy");
});
it("leaves the base URL readable, since it is not a secret", async () => {
coreSettings.set("tailscale_api_base_url", "https://headscale.example");
await runTailscaleSettingsMigration();
const row = pluginRows.find((entry) => entry.key === "apiBaseUrl")!;
expect(row.encrypted).toBe(false);
});
it("removes the legacy rows so no plaintext key is left behind", async () => {
coreSettings.set("tailscale_api_key", "tskey-api-legacy");
coreSettings.set("tailscale_api_base_url", "https://headscale.example");
await runTailscaleSettingsMigration();
expect(coreSettings.has("tailscale_api_key")).toBe(false);
expect(coreSettings.has("tailscale_api_base_url")).toBe(false);
});
it("is idempotent: a second run changes nothing", async () => {
coreSettings.set("tailscale_api_key", "tskey-api-legacy");
coreSettings.set("tailscale_api_base_url", "https://headscale.example");
await runTailscaleSettingsMigration();
const afterFirst = JSON.parse(JSON.stringify(pluginRows));
const second = await runTailscaleSettingsMigration();
expect(second.moved).toEqual([]);
expect(pluginRows).toEqual(afterFirst);
});
it("cannot double-encrypt the key across runs", async () => {
coreSettings.set("tailscale_api_key", "tskey-api-legacy");
await runTailscaleSettingsMigration();
const first = storedValue("apiKey");
await runTailscaleSettingsMigration();
expect(storedValue("apiKey")).toBe(first);
expect(String(storedValue("apiKey")).match(/sysenc:v1:/g)).toHaveLength(1);
});
it("drops a stale legacy row when the value was already moved", async () => {
// An interrupted run, or a downgrade that rewrote the old key.
await pluginSettingsRepository.set(
"tailscale",
"admin",
null,
"apiKey",
JSON.stringify("sysenc:v1:already"),
true,
);
coreSettings.set("tailscale_api_key", "tskey-plaintext-leftover");
const result = await runTailscaleSettingsMigration();
expect(result.skipped).toContain("tailscale_api_key");
expect(storedValue("apiKey")).toBe("sysenc:v1:already");
expect(coreSettings.has("tailscale_api_key")).toBe(false);
});
it("does nothing on a fresh install with no legacy rows", async () => {
const result = await runTailscaleSettingsMigration();
expect(result.moved).toEqual([]);
expect(pluginRows).toEqual([]);
});
it("skips an empty legacy value rather than storing one", async () => {
coreSettings.set("tailscale_api_key", "");
const result = await runTailscaleSettingsMigration();
expect(result.moved).toEqual([]);
expect(pluginRows).toEqual([]);
});
it("waits for the plugin row, because of the foreign key", async () => {
pluginInstalled = false;
coreSettings.set("tailscale_api_key", "tskey-api-legacy");
const result = await runTailscaleSettingsMigration();
expect(result.moved).toEqual([]);
expect(pluginRows).toEqual([]);
// Still there for the next boot, once the plugin is seeded.
expect(coreSettings.get("tailscale_api_key")).toBe("tskey-api-legacy");
});
it("moves each key independently", async () => {
coreSettings.set("tailscale_api_key", "tskey-api-legacy");
const result = await runTailscaleSettingsMigration();
expect(result.moved).toEqual(["tailscale_api_key"]);
expect(storedValue("apiBaseUrl")).toBeUndefined();
});
});
@@ -0,0 +1,105 @@
/**
* Moves the Tailscale API key and base URL out of core settings.
*
* Both lived in the core `settings` table, written by a core route that existed
* only to serve one plugin. A6 gives plugins their own settings, so they belong
* in plugin_settings under the tailscale plugin.
*
* The key was stored in plaintext, and the route that read it back masked only
* its tail while being open to any authenticated user. Landing it as a secret
* field encrypts it at rest, so this migration is a fix as much as a move.
*
* Idempotent in two ways: it stops when a value is already present in the new
* home, and encryptSystemSecret returns an already-encrypted value unchanged,
* so a second pass can neither duplicate nor double-encrypt.
*/
import { databaseLogger } from "../logger.js";
import {
createCurrentPluginRepository,
createCurrentPluginSettingsRepository,
createCurrentSettingsRepository,
} from "../../database/repositories/factory.js";
import { encryptSystemSecret } from "../system-secret-crypto.js";
const PLUGIN_ID = "tailscale";
/** Legacy core key -> the field the plugin now declares. */
const MOVES: { legacyKey: string; field: string; secret: boolean }[] = [
{ legacyKey: "tailscale_api_key", field: "apiKey", secret: true },
{ legacyKey: "tailscale_api_base_url", field: "apiBaseUrl", secret: false },
];
export interface TailscaleSettingsMigrationResult {
moved: string[];
skipped: string[];
}
export async function runTailscaleSettingsMigration(): Promise<TailscaleSettingsMigrationResult> {
const moved: string[] = [];
const skipped: string[] = [];
try {
// The FK to plugins means there is nothing to migrate into until the
// plugin has been seeded. Not an error: a build without it just has no
// tailscale rows.
const plugin = await createCurrentPluginRepository().findById(PLUGIN_ID);
if (!plugin) return { moved, skipped };
const settingsRepository = createCurrentSettingsRepository();
const pluginSettingsRepository = createCurrentPluginSettingsRepository();
for (const move of MOVES) {
const legacyValue = await settingsRepository.get(move.legacyKey);
if (legacyValue === null || legacyValue === "") {
continue;
}
const existing = await pluginSettingsRepository.get(
PLUGIN_ID,
"admin",
null,
move.field,
);
if (existing && existing.value !== null) {
// Already moved. Drop the stale copy rather than leaving a plaintext
// key behind for a reader that predates this change.
await settingsRepository.delete(move.legacyKey);
skipped.push(move.legacyKey);
continue;
}
const stored = move.secret
? await encryptSystemSecret(legacyValue)
: legacyValue;
await pluginSettingsRepository.set(
PLUGIN_ID,
"admin",
null,
move.field,
JSON.stringify(stored),
move.secret,
);
await settingsRepository.delete(move.legacyKey);
moved.push(move.legacyKey);
}
if (moved.length > 0) {
databaseLogger.info(
`Moved ${moved.length} Tailscale setting(s) into plugin settings`,
{ operation: "tailscale_settings_migration", moved: moved.join(", ") },
);
}
} catch (error) {
// A failed migration must not stop the backend. The plugin falls back to
// "no key configured", which is a visible, recoverable state.
databaseLogger.warn("Tailscale settings migration failed", {
operation: "tailscale_settings_migration",
error: error instanceof Error ? error.message : String(error),
});
}
return { moved, skipped };
}
+3 -2
View File
@@ -185,6 +185,8 @@ export type Host = {
hasTelnetPassword?: boolean;
guacamoleConfig?: GuacamoleConfig;
/** Host-scope plugin settings, keyed by plugin id. Secrets are redacted. */
pluginSettings?: Record<string, Record<string, unknown>>;
forceKeyboardInteractive?: boolean;
isShared?: boolean;
@@ -372,8 +374,7 @@ export type AdminSection =
| "audit-log"
| "ssl"
| "plugins"
| "touch-input"
| "tailscale";
| "touch-input";
export type ThemeId =
| "dark"
| "light"
+13 -18
View File
@@ -126,14 +126,11 @@ const SessionLogsPanel = lazy(() =>
default: m.SessionLogsPanel,
})),
);
const UserProfilePanel = lazy(() =>
import("@/sidebar/UserProfilePanel").then((m) => ({
default: m.UserProfilePanel,
})),
);
const AdminSettingsPanel = lazy(() =>
import("@/sidebar/AdminSettingsPanel").then((m) => ({
default: m.AdminSettingsPanel,
// One screen behind both rail entries. It lazy-loads the profile and admin
// panels itself, so the shell no longer imports either directly.
const SettingsScreen = lazy(() =>
import("@/settings/SettingsScreen").then((m) => ({
default: m.SettingsScreen,
})),
);
const CredentialsPanel = lazy(() =>
@@ -2882,9 +2879,14 @@ export function AppShell({
</div>
)}
{railView === "user-profile" && (
<div className="flex-1 min-h-0 overflow-y-auto">
<UserProfilePanel
{(railView === "user-profile" ||
(railView === "admin-settings" && showAdminUI)) && (
<div className="flex flex-col flex-1 min-h-0">
<SettingsScreen
initialSection={
railView === "admin-settings" ? "admin" : "profile"
}
isAdmin={showAdminUI}
username={username}
onLogout={onLogout}
userPrefs={userPrefs}
@@ -2896,13 +2898,6 @@ export function AppShell({
onRemoteSyncReconnectHandled={() =>
setRemoteSyncReconnectRequested(false)
}
/>
</div>
)}
{railView === "admin-settings" && showAdminUI && (
<div className="flex flex-col flex-1 min-h-0 overflow-y-auto">
<AdminSettingsPanel
onEditingChange={setSidebarEditing}
onOpenHostTab={(host) => {
connectHost(host);
+147
View File
@@ -7,8 +7,153 @@ export interface PluginTabContribution {
openFrom: string[];
}
export type PluginSettingsFieldType =
| "boolean"
| "string"
| "number"
| "select"
| "multiselect"
| "secret"
| "textarea"
| "json"
| "custom";
export interface PluginSettingsField {
key: string;
type: PluginSettingsFieldType;
labelKey?: string;
descriptionKey?: string;
placeholderKey?: string;
default?: unknown;
options?: { value: string; labelKey: string }[];
min?: number;
max?: number;
requires?: string;
permission?: string;
group?: string;
component?: string;
}
export interface PluginHostSettingsContribution {
enableKey?: string;
enableLabelKey?: string;
fields: PluginSettingsField[];
}
export interface PluginSettingsContribution {
admin?: PluginSettingsField[];
user?: PluginSettingsField[];
host?: PluginHostSettingsContribution;
}
export interface PluginContributions {
tabs?: PluginTabContribution[];
settings?: PluginSettingsContribution;
}
/** A secret as it arrives from the server. 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) &&
typeof (value as RedactedSecret).set === "boolean"
);
}
export type PluginSettingsValues = Record<string, unknown>;
/** Per-field messages from a rejected PUT, keyed by field key. */
export interface PluginSettingsErrors {
[key: string]: string;
}
export class PluginSettingsValidationError extends Error {
readonly errors: PluginSettingsErrors;
constructor(errors: PluginSettingsErrors) {
super("Some settings were rejected");
this.name = "PluginSettingsValidationError";
this.errors = errors;
}
}
function settingsPath(pluginId: string, suffix: string): string {
return `/plugins/${encodeURIComponent(pluginId)}/settings/${suffix}`;
}
/** Turns a 400 carrying per-field errors into something a form can render. */
async function putSettings(
path: string,
values: PluginSettingsValues,
): Promise<PluginSettingsValues> {
try {
const response = await rbacApi.put(path, values);
return response.data?.values ?? {};
} catch (error) {
const data = (
error as { response?: { status?: number; data?: { errors?: unknown } } }
).response;
if (data?.status === 400 && data.data?.errors) {
throw new PluginSettingsValidationError(
data.data.errors as PluginSettingsErrors,
);
}
throw error;
}
}
export async function getPluginAdminSettings(
pluginId: string,
): Promise<PluginSettingsValues> {
const response = await rbacApi.get(settingsPath(pluginId, "admin"));
return response.data?.values ?? {};
}
export async function updatePluginAdminSettings(
pluginId: string,
values: PluginSettingsValues,
): Promise<PluginSettingsValues> {
return putSettings(settingsPath(pluginId, "admin"), values);
}
export async function getPluginUserSettings(
pluginId: string,
): Promise<PluginSettingsValues> {
const response = await rbacApi.get(settingsPath(pluginId, "user"));
return response.data?.values ?? {};
}
export async function updatePluginUserSettings(
pluginId: string,
values: PluginSettingsValues,
): Promise<PluginSettingsValues> {
return putSettings(settingsPath(pluginId, "user"), values);
}
export async function getPluginHostSettings(
pluginId: string,
hostId: number,
): Promise<PluginSettingsValues> {
const response = await rbacApi.get(
settingsPath(pluginId, `host/${encodeURIComponent(String(hostId))}`),
);
return response.data?.values ?? {};
}
export async function updatePluginHostSettings(
pluginId: string,
hostId: number,
values: PluginSettingsValues,
): Promise<PluginSettingsValues> {
return putSettings(
settingsPath(pluginId, `host/${encodeURIComponent(String(hostId))}`),
values,
);
}
/**
@@ -26,6 +171,8 @@ export interface PluginSummary {
/** enabled | disabled | blocked | failed, or the loader's live state. */
state: string;
contributes: PluginContributions | null;
/** Lucide icon name from the manifest. */
icon?: string;
tier?: string;
source?: string;
+45 -8
View File
@@ -3508,7 +3508,6 @@
"sectionApiKeys": "API Keys",
"sectionAuditLog": "Audit Log",
"sectionSsl": "SSL / Let's Encrypt",
"sectionTailscale": "Tailscale",
"sectionTouchInput": "Touch Input",
"touchEnabled": "Enable Touch Input",
"touchEnabledDesc": "Allow one-finger touch scrolling in terminals",
@@ -3667,11 +3666,6 @@
"enableRemoteDesktopDesc": "RDP/VNC remote desktop",
"enableRemoteDesktopDocsLink": "View docs",
"guacdUrl": "guacd URL",
"tailscaleApiKey": "Tailscale API Key",
"tailscaleApiKeyDescription": "Used for device discovery in the host editor. Supports Tailscale (tskey-) and Headscale (hskey-) keys. Generate a key at tailscale.com/admin/settings/keys or from your Headscale instance.",
"tailscaleApiKeyDocsLink": "View docs",
"tailscaleApiBaseUrl": "API Base URL",
"tailscaleApiBaseUrlDescription": "Optional. Point to a self-hosted Headscale instance instead of api.tailscale.com. Leave empty to use the Tailscale default.",
"oidcDescription": "Configure OpenID Connect for SSO. Fields marked * are required.",
"oidcDocsLink": "View docs",
"oidcClientId": "Client ID",
@@ -3822,8 +3816,6 @@
"remoteDesktopSaved": "Remote Desktop settings saved",
"remoteDesktopSaveFailed": "Failed to save Remote Desktop settings",
"remoteDesktopUpdateFailed": "Failed to update Remote Desktop setting",
"tailscaleSettingsSaved": "Tailscale settings saved",
"tailscaleSettingsSaveFailed": "Failed to save Tailscale settings",
"logLevelUpdateFailed": "Failed to update log level",
"oidcSaved": "OIDC configuration saved",
"oidcSaveFailed": "Failed to save OIDC config",
@@ -5261,7 +5253,52 @@
"title": "View tailnet devices",
"description": "See the devices on the connected tailnet."
}
},
"settings": {
"group": {
"api": "Tailscale API",
"devices": "Tailnet"
},
"apiKey": {
"label": "API key",
"description": "Used to list the devices on your tailnet. Works with a Tailscale or Headscale key.",
"placeholder": "tskey-api-... / hskey-api-..."
},
"apiBaseUrl": {
"label": "API base URL",
"description": "Leave empty for Tailscale. Set this to point at a Headscale instance.",
"placeholder": "https://api.tailscale.com/api/v2"
},
"devices": {
"label": "Device list",
"checking": "Checking the tailnet...",
"noKey": "No API key set yet.",
"error": "Could not reach the tailnet. Check the API key.",
"count": "{{count}} device reachable",
"count_other": "{{count}} devices reachable",
"refresh": "Check"
}
}
}
},
"settings": {
"title": "Settings",
"bandYou": "You",
"bandPlugins": "Plugins",
"bandInstance": "Instance",
"account": "Account",
"secretSet": "Set",
"secretNotSet": "Not set",
"secretReplace": "Replace",
"pluginGroupAdmin": "Server settings",
"pluginGroupUser": "Your settings",
"pluginNoSettings": "{{name}} has no settings you can change.",
"pluginStoppedNotice": "{{name}} is installed but not running. These settings are saved but will not take effect.",
"pluginSettingsSaved": "Settings saved",
"pluginSettingsInvalid": "Some settings could not be saved. Check the highlighted fields.",
"pluginSettingsSaveFailed": "Failed to save settings",
"pluginSettingsLoadFailed": "Failed to load settings",
"pluginsGroupLabel": "Plugins",
"hostPluginNotRunning": "{{name}} is installed but not running. These settings are saved but will not take effect."
}
}
+140
View File
@@ -0,0 +1,140 @@
/**
* Host editor sections contributed by plugins.
*
* A plugin declares host-scope fields in its manifest and core draws them, so
* a plugin cannot ship its own form styling and a section disappears when its
* plugin does. The enable switch comes first and gates the rest, which is the
* shape the hardcoded feature tabs already have.
*
* Nothing is rendered when no enabled plugin declares host settings, so the
* group does not appear as an empty tab. Phase B fills it as each feature
* moves its columns out of ssh_data.
*/
import { useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { SectionCard } from "@/components/section-card";
import { PluginIcon } from "@/lib/plugin-icon";
import { getPlugins, type PluginSummary } from "@/api/plugins-api";
import { SettingsFieldRow } from "./SettingsFields";
import { isFieldActive } from "./settings-fields-util";
/** Values for every plugin on one host: { [pluginId]: { [key]: value } }. */
export type HostPluginSettings = Record<string, Record<string, unknown>>;
/** Enabled plugins declaring host-scope settings. */
export function usePluginHostSections(): PluginSummary[] {
const [plugins, setPlugins] = useState<PluginSummary[]>([]);
useEffect(() => {
let cancelled = false;
void getPlugins()
.then((loaded) => {
if (!cancelled) setPlugins(loaded);
})
.catch(() => {
// Additive: the built-in host tabs do not depend on this.
});
return () => {
cancelled = true;
};
}, []);
return useMemo(
() =>
plugins.filter((plugin) => {
if (!plugin.enabled) return false;
const host = plugin.contributes?.settings?.host;
return !!host && (host.fields.length > 0 || !!host.enableKey);
}),
[plugins],
);
}
export interface HostPluginSectionsProps {
plugins: PluginSummary[];
values: HostPluginSettings;
setValue: (pluginId: string, key: string, value: unknown) => void;
}
export function HostPluginSections({
plugins,
values,
setValue,
}: HostPluginSectionsProps) {
if (plugins.length === 0) return null;
return (
<div className="flex flex-col gap-2">
{plugins.map((plugin) => (
<HostPluginSection
key={plugin.id}
plugin={plugin}
values={values[plugin.id] ?? {}}
setValue={(key, value) => setValue(plugin.id, key, value)}
/>
))}
</div>
);
}
function HostPluginSection({
plugin,
values,
setValue,
}: {
plugin: PluginSummary;
values: Record<string, unknown>;
setValue: (key: string, value: unknown) => void;
}) {
const { t } = useTranslation();
const host = plugin.contributes?.settings?.host;
if (!host) return null;
const running = plugin.enabled && plugin.state !== "failed";
const enableKey = host.enableKey;
// With no enable switch the section is always on, which is what a plugin
// declaring only plain fields means.
const enabled = enableKey ? values[enableKey] === true : true;
return (
<SectionCard
title={plugin.name}
icon={<PluginIcon name={plugin.icon} className="size-3.5" />}
>
{!running && (
<div className="my-3 border border-yellow-500/40 bg-yellow-500/10 px-3 py-2 text-xs text-yellow-500">
{t("settings.hostPluginNotRunning", { name: plugin.name })}
</div>
)}
{enableKey && (
<SettingsFieldRow
pluginId={plugin.id}
field={{
key: enableKey,
type: "boolean",
labelKey: host.enableLabelKey,
}}
values={values}
setValue={setValue}
running={running}
/>
)}
{enabled &&
host.fields
.filter((field) => isFieldActive(field, values))
.map((field) => (
<SettingsFieldRow
key={field.key}
pluginId={plugin.id}
field={field}
values={values}
setValue={setValue}
running={running}
/>
))}
</SectionCard>
);
}
+238
View File
@@ -0,0 +1,238 @@
/**
* One plugin's settings page, drawn from its manifest.
*
* The plugin declares groups and fields; Termix renders them with the same
* SectionCard and SettingRow everything else uses. A plugin cannot ship its
* own form styling and drift from the rest of the app, and a page disappears
* cleanly when the plugin is uninstalled.
*/
import { useCallback, useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { Loader2 } from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/button";
import { SectionCard } from "@/components/section-card";
import { pluginKey } from "@/lib/plugin-i18n";
import { PluginIcon } from "@/lib/plugin-icon";
import {
getPluginAdminSettings,
getPluginUserSettings,
updatePluginAdminSettings,
updatePluginUserSettings,
PluginSettingsValidationError,
type PluginSettingsErrors,
type PluginSettingsField,
type PluginSummary,
} from "@/api/plugins-api";
import { SettingsFieldRow } from "./SettingsFields";
import { isFieldActive } from "./settings-fields-util";
interface PluginSettingsPageProps {
plugin: PluginSummary;
isAdmin: boolean;
}
type Scope = "admin" | "user";
export function PluginSettingsPage({
plugin,
isAdmin,
}: PluginSettingsPageProps) {
const { t } = useTranslation();
const settings = plugin.contributes?.settings;
const running = plugin.enabled && plugin.state !== "failed";
// Admin fields are not merely disabled for a non-admin: the values behind
// them are install-wide configuration they have no business reading.
const scopes = useMemo(() => {
const entries: { scope: Scope; fields: PluginSettingsField[] }[] = [];
if (isAdmin && (settings?.admin?.length ?? 0) > 0) {
entries.push({ scope: "admin", fields: settings!.admin! });
}
if ((settings?.user?.length ?? 0) > 0) {
entries.push({ scope: "user", fields: settings!.user! });
}
return entries;
}, [settings, isAdmin]);
if (scopes.length === 0) {
return (
<div className="flex flex-col gap-2 p-2.5">
<p className="text-xs text-muted-foreground px-1">
{t("settings.pluginNoSettings", { name: plugin.name })}
</p>
</div>
);
}
return (
<div className="flex flex-col gap-2 p-2.5">
{!running && (
<div className="border border-yellow-500/40 bg-yellow-500/10 px-3 py-2 text-xs text-yellow-500">
{t("settings.pluginStoppedNotice", { name: plugin.name })}
</div>
)}
{scopes.map(({ scope, fields }) => (
<PluginScopeForm
key={scope}
plugin={plugin}
scope={scope}
fields={fields}
running={running}
/>
))}
</div>
);
}
/**
* One scope's form.
*
* Admin and user settings are separate requests with separate permissions, so
* they save separately rather than sharing one dirty state.
*/
function PluginScopeForm({
plugin,
scope,
fields,
running,
}: {
plugin: PluginSummary;
scope: Scope;
fields: PluginSettingsField[];
running: boolean;
}) {
const { t } = useTranslation();
const [values, setValues] = useState<Record<string, unknown>>({});
const [errors, setErrors] = useState<PluginSettingsErrors>({});
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [dirty, setDirty] = useState(false);
const load = useCallback(async () => {
setLoading(true);
try {
const loaded =
scope === "admin"
? await getPluginAdminSettings(plugin.id)
: await getPluginUserSettings(plugin.id);
setValues(loaded);
setErrors({});
setDirty(false);
} catch {
toast.error(t("settings.pluginSettingsLoadFailed"));
} finally {
setLoading(false);
}
}, [plugin.id, scope, t]);
useEffect(() => {
void load();
}, [load]);
const setValue = (key: string, value: unknown) => {
setValues((current) => ({ ...current, [key]: value }));
setErrors((current) => {
if (!current[key]) return current;
const next = { ...current };
delete next[key];
return next;
});
setDirty(true);
};
const save = async () => {
setSaving(true);
try {
const saved =
scope === "admin"
? await updatePluginAdminSettings(plugin.id, values)
: await updatePluginUserSettings(plugin.id, values);
setValues(saved);
setErrors({});
setDirty(false);
toast.success(t("settings.pluginSettingsSaved"));
} catch (error) {
if (error instanceof PluginSettingsValidationError) {
setErrors(error.errors);
toast.error(t("settings.pluginSettingsInvalid"));
} else {
toast.error(t("settings.pluginSettingsSaveFailed"));
}
} finally {
setSaving(false);
}
};
// Fields keep their declared order within a group, and groups appear in the
// order their first field does, so a manifest controls the whole layout.
const groups = useMemo(() => {
const ordered: {
key: string | undefined;
fields: PluginSettingsField[];
}[] = [];
for (const field of fields) {
const existing = ordered.find((group) => group.key === field.group);
if (existing) existing.fields.push(field);
else ordered.push({ key: field.group, fields: [field] });
}
return ordered;
}, [fields]);
if (loading) {
return (
<div className="flex items-center gap-2 px-1 py-4 text-xs text-muted-foreground">
<Loader2 className="size-3.5 animate-spin" />
{t("common.loading")}
</div>
);
}
const icon = <PluginIcon name={plugin.icon} className="size-3.5" />;
return (
<>
{groups.map((group, index) => (
<SectionCard
key={group.key ?? `group-${index}`}
title={
group.key
? t(pluginKey(plugin.id, group.key))
: scope === "admin"
? t("settings.pluginGroupAdmin")
: t("settings.pluginGroupUser")
}
icon={icon}
>
{group.fields
.filter((field) => isFieldActive(field, values))
.map((field) => (
<SettingsFieldRow
key={field.key}
pluginId={plugin.id}
field={field}
values={values}
setValue={setValue}
running={running}
error={errors[field.key]}
/>
))}
</SectionCard>
))}
<div className="flex justify-end px-1">
<Button
size="sm"
className="h-7 text-xs"
disabled={!dirty || saving}
onClick={() => void save()}
>
{saving && <Loader2 className="size-3 animate-spin mr-1.5" />}
{t("common.save")}
</Button>
</div>
</>
);
}
+294
View File
@@ -0,0 +1,294 @@
/**
* One control per declared settings field type.
*
* A plugin supplies data; core draws the form. That is what keeps every
* plugin's settings page looking like the rest of the app, and what makes a
* page disappear cleanly when its plugin does.
*/
import { useTranslation } from "react-i18next";
import { Input } from "@/components/input";
import { Textarea } from "@/components/textarea";
import { Checkbox } from "@/components/checkbox";
import { PasswordInput } from "@/components/password-input";
import { Button } from "@/components/button";
import { FakeSwitch, SettingRow } from "@/components/section-card";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/select";
import { pluginKey } from "@/lib/plugin-i18n";
import { isRedactedSecret, type PluginSettingsField } from "@/api/plugins-api";
import { getSettingsComponent } from "./settings-components";
export interface SettingsFieldProps {
pluginId: string;
field: PluginSettingsField;
values: Record<string, unknown>;
setValue: (key: string, value: unknown) => void;
/** Whether the owning plugin is running. */
running: boolean;
/** Message from a rejected save, shown under the control. */
error?: string;
disabled?: boolean;
}
export function SettingsFieldRow({
pluginId,
field,
values,
setValue,
running,
error,
disabled = false,
}: SettingsFieldProps) {
const { t } = useTranslation();
const label = (key?: string) => (key ? t(pluginKey(pluginId, key)) : "");
// A custom field draws its own row: it has no single control to put in the
// right-hand slot, and often no label of its own either.
if (field.type === "custom") {
const Component = getSettingsComponent(pluginId, field.component);
if (!Component) return null;
return (
<Component
pluginId={pluginId}
values={values}
setValue={setValue}
running={running}
/>
);
}
const value = values[field.key];
const readOnly = disabled || !running;
const description = field.descriptionKey ? (
<>
{label(field.descriptionKey)}
{error && <span className="block text-destructive mt-0.5">{error}</span>}
</>
) : error ? (
<span className="text-destructive">{error}</span>
) : undefined;
return (
<SettingRow
label={label(field.labelKey)}
description={description}
badge={error ? t("common.error") : undefined}
>
<FieldControl
pluginId={pluginId}
field={field}
value={value}
setValue={setValue}
readOnly={readOnly}
/>
</SettingRow>
);
}
function FieldControl({
pluginId,
field,
value,
setValue,
readOnly,
}: {
pluginId: string;
field: PluginSettingsField;
value: unknown;
setValue: (key: string, value: unknown) => void;
readOnly: boolean;
}) {
const { t } = useTranslation();
const label = (key?: string) => (key ? t(pluginKey(pluginId, key)) : "");
const placeholder = field.placeholderKey
? label(field.placeholderKey)
: undefined;
switch (field.type) {
case "boolean":
return (
<FakeSwitch
checked={value === true}
disabled={readOnly}
onChange={(next) => setValue(field.key, next)}
/>
);
case "number":
return (
<Input
type="number"
className="h-7 w-40 text-sm"
disabled={readOnly}
min={field.min}
max={field.max}
placeholder={placeholder}
value={value === undefined || value === null ? "" : String(value)}
onChange={(event) =>
setValue(
field.key,
event.target.value === "" ? null : Number(event.target.value),
)
}
/>
);
case "select":
return (
<Select
disabled={readOnly}
value={typeof value === "string" ? value : ""}
onValueChange={(next) => setValue(field.key, next)}
>
<SelectTrigger className="h-7 w-52 text-sm">
<SelectValue placeholder={placeholder} />
</SelectTrigger>
<SelectContent>
{(field.options ?? []).map((option) => (
<SelectItem key={option.value} value={option.value}>
{label(option.labelKey)}
</SelectItem>
))}
</SelectContent>
</Select>
);
case "multiselect": {
const selected = Array.isArray(value) ? (value as string[]) : [];
return (
<div className="flex flex-col gap-1.5 items-end">
{(field.options ?? []).map((option) => (
<label
key={option.value}
className="flex items-center gap-2 text-xs text-muted-foreground"
>
<span>{label(option.labelKey)}</span>
<Checkbox
disabled={readOnly}
checked={selected.includes(option.value)}
onCheckedChange={(checked) =>
setValue(
field.key,
checked
? [...selected, option.value]
: selected.filter((entry) => entry !== option.value),
)
}
/>
</label>
))}
</div>
);
}
case "secret":
return (
<SecretControl
field={field}
value={value}
setValue={setValue}
readOnly={readOnly}
placeholder={placeholder}
/>
);
case "textarea":
case "json":
return (
<Textarea
className="w-64 text-sm font-mono min-h-16"
disabled={readOnly}
placeholder={placeholder}
value={
typeof value === "string"
? value
: value === undefined || value === null
? ""
: JSON.stringify(value, null, 2)
}
onChange={(event) => setValue(field.key, event.target.value)}
/>
);
default:
return (
<Input
className="h-7 w-52 text-sm"
disabled={readOnly}
placeholder={placeholder}
value={typeof value === "string" ? value : ""}
onChange={(event) => setValue(field.key, event.target.value)}
/>
);
}
}
/**
* A secret is never sent to the browser, so the control shows whether one is
* stored and offers to replace or clear it. Leaving it untouched sends the
* redacted marker straight back, which the server reads as "no change".
*/
function SecretControl({
field,
value,
setValue,
readOnly,
placeholder,
}: {
field: PluginSettingsField;
value: unknown;
setValue: (key: string, value: unknown) => void;
readOnly: boolean;
placeholder?: string;
}) {
const { t } = useTranslation();
const redacted = isRedactedSecret(value);
const stored = redacted && value.set;
if (redacted) {
return (
<div className="flex items-center gap-2">
<span className="text-xs text-muted-foreground">
{stored ? t("settings.secretSet") : t("settings.secretNotSet")}
</span>
<Button
variant="outline"
size="sm"
className="h-7 text-xs"
disabled={readOnly}
onClick={() => setValue(field.key, "")}
>
{stored ? t("settings.secretReplace") : t("common.edit")}
</Button>
</div>
);
}
return (
<div className="flex items-center gap-2">
<PasswordInput
className="h-7 w-52 text-sm"
disabled={readOnly}
placeholder={placeholder}
value={typeof value === "string" ? value : ""}
onChange={(event) => setValue(field.key, event.target.value)}
/>
<Button
variant="ghost"
size="sm"
className="h-7 text-xs"
disabled={readOnly}
onClick={() => setValue(field.key, { set: stored })}
>
{t("common.cancel")}
</Button>
</div>
);
}
+280
View File
@@ -0,0 +1,280 @@
/**
* Everything that configures the app rather than operating a server.
*
* Profile and admin were two separate rail panels, each a fixed accordion of
* hardcoded sections. That is the wrong shape for the number of sections they
* hold, and it left no place at all for a plugin to contribute one. This is a
* single screen with a banded left nav, and a plugin's page is an entry in it
* that appears and disappears with the plugin.
*
* Both rail buttons still open this, each on its own section, so the Navigation
* visibility toggles and the habit of reaching for either one keep working.
*/
import { lazy, Suspense, useEffect, useMemo, useState } from "react";
import type { ComponentProps } from "react";
import { useTranslation } from "react-i18next";
import {
ChevronDown,
Puzzle,
Settings as SettingsIcon,
User,
} from "lucide-react";
import { Separator } from "@/components/separator";
import { PluginIcon } from "@/lib/plugin-icon";
import { getPlugins, type PluginSummary } from "@/api/plugins-api";
import { PluginSettingsPage } from "./PluginSettingsPage";
import { registerLegacySettingsComponents } from "./legacy-settings-components";
const UserProfilePanel = lazy(() =>
import("@/sidebar/UserProfilePanel").then((m) => ({
default: m.UserProfilePanel,
})),
);
const AdminSettingsPanel = lazy(() =>
import("@/sidebar/AdminSettingsPanel").then((m) => ({
default: m.AdminSettingsPanel,
})),
);
// Taken from the panels themselves: this screen only forwards them, so
// restating their shapes here would be one more thing to keep in step.
type ProfileProps = ComponentProps<typeof UserProfilePanel>;
type AdminProps = ComponentProps<typeof AdminSettingsPanel>;
/** Built-in sections, plus "plugin:<id>" for a plugin's own settings page. */
export type SettingsSectionId = "profile" | "admin" | `plugin:${string}`;
export interface SettingsScreenProps {
/** Which page to land on. Both rail entries come in here. */
initialSection?: SettingsSectionId;
isAdmin?: boolean;
/** Forwarded to the profile panel. */
username?: ProfileProps["username"];
onLogout?: ProfileProps["onLogout"];
userPrefs?: ProfileProps["userPrefs"];
onPrefsChange?: ProfileProps["onPrefsChange"];
remoteSyncInitialServerUrl?: ProfileProps["remoteSyncInitialServerUrl"];
remoteSyncReconnectRequested?: ProfileProps["remoteSyncReconnectRequested"];
onRemoteSyncReconnectHandled?: ProfileProps["onRemoteSyncReconnectHandled"];
/** Forwarded to the admin panel. */
onEditingChange?: AdminProps["onEditingChange"];
onOpenHostTab?: AdminProps["onOpenHostTab"];
}
interface NavItem {
id: SettingsSectionId;
label: string;
icon: React.ElementType;
iconName?: string;
}
export function SettingsScreen({
initialSection = "profile",
isAdmin = false,
username,
onLogout,
userPrefs,
onPrefsChange,
remoteSyncInitialServerUrl,
remoteSyncReconnectRequested,
onRemoteSyncReconnectHandled,
onEditingChange,
onOpenHostTab,
}: SettingsScreenProps) {
const { t } = useTranslation();
const [section, setSection] = useState<SettingsSectionId>(initialSection);
const [navOpen, setNavOpen] = useState(false);
const [plugins, setPlugins] = useState<PluginSummary[]>([]);
// Registered here rather than at module scope so a custom field's component
// exists before any page that renders one. A7 moves this into the loader.
useEffect(() => {
registerLegacySettingsComponents();
}, []);
useEffect(() => {
let cancelled = false;
void getPlugins()
.then((loaded) => {
if (!cancelled) setPlugins(loaded);
})
.catch(() => {
// The plugin band is additive: the core sections still work without it.
});
return () => {
cancelled = true;
};
}, []);
// The rail can switch which entry opened the screen while it is mounted.
useEffect(() => setSection(initialSection), [initialSection]);
/**
* A plugin gets a page when it is enabled and declares settings this user
* could actually change. A non-admin seeing a page of admin-only fields
* would be a page they can read and never act on.
*/
const pluginPages = useMemo(
() =>
plugins.filter((plugin) => {
if (!plugin.enabled) return false;
const settings = plugin.contributes?.settings;
if (!settings) return false;
if ((settings.user?.length ?? 0) > 0) return true;
return isAdmin && (settings.admin?.length ?? 0) > 0;
}),
[plugins, isAdmin],
);
const bands = useMemo(() => {
const result: { heading: string; items: NavItem[] }[] = [
{
heading: t("settings.bandYou"),
items: [{ id: "profile", label: t("settings.account"), icon: User }],
},
];
if (pluginPages.length > 0) {
result.push({
heading: t("settings.bandPlugins"),
items: pluginPages.map((plugin) => ({
id: `plugin:${plugin.id}` as SettingsSectionId,
label: plugin.name,
icon: Puzzle,
iconName: plugin.icon,
})),
});
}
if (isAdmin) {
result.push({
heading: t("settings.bandInstance"),
items: [{ id: "admin", label: t("nav.admin"), icon: SettingsIcon }],
});
}
return result;
}, [t, pluginPages, isAdmin]);
const sections = bands.flatMap((band) => band.items);
const active = sections.find((item) => item.id === section) ?? sections[0];
// A plugin page can vanish under you: the plugin is disabled from the admin
// section in the same screen. Fall back rather than render an empty pane.
useEffect(() => {
if (
section.startsWith("plugin:") &&
!sections.some((s) => s.id === section)
) {
setSection("profile");
}
}, [section, sections]);
return (
<div className="flex h-full min-h-0 flex-col overflow-hidden bg-background">
<header className="flex h-12.5 shrink-0 flex-row items-center border-b border-border">
<div className="flex min-w-0 flex-1 items-center gap-2 px-3">
<SettingsIcon className="size-4 shrink-0 text-accent-brand" />
<span className="truncate text-base font-bold tracking-tight">
{t("settings.title")}
</span>
<Separator orientation="vertical" className="h-4" />
<span className="hidden truncate text-[10px] font-semibold uppercase tracking-widest text-muted-foreground md:inline">
{active?.label}
</span>
{/* The nav column does not fit beside the content on a phone, so
there it collapses behind the section name. */}
<button
onClick={() => setNavOpen((open) => !open)}
aria-expanded={navOpen}
className="flex min-w-0 items-center gap-1 text-[10px] font-semibold uppercase tracking-widest text-muted-foreground md:hidden"
>
<span className="truncate">{active?.label}</span>
<ChevronDown
className={`size-3 shrink-0 transition-transform ${navOpen ? "rotate-180" : ""}`}
/>
</button>
</div>
</header>
<div className="flex min-h-0 flex-1">
<nav
className={`${navOpen ? "flex" : "hidden"} w-full shrink-0 flex-col gap-3 overflow-y-auto border-r border-border py-2.5 md:flex md:w-60`}
>
{bands.map((band) => (
<div key={band.heading} className="flex flex-col">
<span className="px-2.5 pb-1.5 text-[10px] font-semibold uppercase tracking-widest text-muted-foreground">
{band.heading}
</span>
{band.items.map((item) => {
const Icon = item.icon;
const on = item.id === section;
return (
<button
key={item.id}
onClick={() => {
setSection(item.id);
setNavOpen(false);
}}
className={`flex w-full items-center gap-2 border-l-2 py-2.5 pl-2 pr-2 text-left transition-colors md:py-1.5 ${
on
? "border-accent-brand bg-accent-brand/10 text-accent-brand"
: "border-transparent text-muted-foreground hover:bg-muted hover:text-foreground"
}`}
>
{item.iconName ? (
<PluginIcon
name={item.iconName}
className="size-3.5 shrink-0"
/>
) : (
<Icon className="size-3.5 shrink-0" />
)}
<span className="truncate text-xs font-medium">
{item.label}
</span>
</button>
);
})}
</div>
))}
</nav>
<div
className={`${navOpen ? "hidden" : ""} min-h-0 flex-1 overflow-y-auto md:block`}
>
<Suspense fallback={null}>
{section === "profile" && (
<UserProfilePanel
username={username}
onLogout={onLogout}
userPrefs={userPrefs}
onPrefsChange={onPrefsChange}
remoteSyncInitialServerUrl={remoteSyncInitialServerUrl}
remoteSyncReconnectRequested={remoteSyncReconnectRequested}
onRemoteSyncReconnectHandled={onRemoteSyncReconnectHandled}
/>
)}
{section === "admin" && isAdmin && (
<AdminSettingsPanel
onEditingChange={onEditingChange}
onOpenHostTab={onOpenHostTab}
/>
)}
{section.startsWith("plugin:") &&
(() => {
const plugin = pluginPages.find(
(candidate) => `plugin:${candidate.id}` === section,
);
if (!plugin) return null;
return <PluginSettingsPage plugin={plugin} isAdmin={isAdmin} />;
})()}
</Suspense>
</div>
</div>
</div>
);
}
@@ -0,0 +1,90 @@
/**
* The tailnet status shown on the Tailscale settings page.
*
* Confirms the saved key actually works, which is the question anyone has just
* after entering one. Connecting to a device stays in the Tailscale rail panel;
* a settings page is for configuring, not for operating.
*
* Registered by legacy-settings-components.ts until A7 lets the plugin's own
* bundle register it.
*/
import { useCallback, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { Loader2, RefreshCw } from "lucide-react";
import { Button } from "@/components/button";
import { pluginKey } from "@/lib/plugin-i18n";
import type { SettingsComponentProps } from "../settings-components";
import { getTailscaleDevices } from "../../../../plugins/tailscale/src/frontend/tailscale-api";
type Status =
| { kind: "loading" }
| { kind: "no-key" }
| { kind: "error" }
| { kind: "ok"; count: number };
export function TailscaleDevicesStatus({ running }: SettingsComponentProps) {
const { t } = useTranslation();
const [status, setStatus] = useState<Status>({ kind: "loading" });
const load = useCallback(async () => {
if (!running) {
setStatus({ kind: "no-key" });
return;
}
setStatus({ kind: "loading" });
try {
const result = await getTailscaleDevices();
if (!result.hasApiKey) {
setStatus({ kind: "no-key" });
return;
}
if (result.error) {
setStatus({ kind: "error" });
return;
}
setStatus({ kind: "ok", count: result.devices?.length ?? 0 });
} catch {
setStatus({ kind: "error" });
}
}, [running]);
useEffect(() => {
void load();
}, [load]);
const label = (key: string) => t(pluginKey("tailscale", key));
return (
<div className="flex items-center justify-between gap-3 py-3">
<div className="flex flex-col gap-0.5 min-w-0 flex-1">
<span className="text-sm font-medium leading-snug">
{label("settings.devices.label")}
</span>
<span className="text-xs text-muted-foreground leading-snug">
{status.kind === "loading" && label("settings.devices.checking")}
{status.kind === "no-key" && label("settings.devices.noKey")}
{status.kind === "error" && label("settings.devices.error")}
{status.kind === "ok" &&
t(pluginKey("tailscale", "settings.devices.count"), {
count: status.count,
})}
</span>
</div>
<Button
variant="outline"
size="sm"
className="h-7 text-xs shrink-0"
disabled={!running || status.kind === "loading"}
onClick={() => void load()}
>
{status.kind === "loading" ? (
<Loader2 className="size-3 animate-spin" />
) : (
<RefreshCw className="size-3" />
)}
<span className="ml-1.5">{label("settings.devices.refresh")}</span>
</Button>
</div>
);
}
+69
View File
@@ -0,0 +1,69 @@
/**
* Registers the host editor's "Plugins" tab.
*
* Goes through the existing registerHostEditorTab seam rather than adding a
* case to HostTabId, so the tab genuinely appears and disappears with the
* plugins that fill it. Nothing is registered when no enabled plugin declares
* host settings, which is what stops an empty tab from showing up.
*
* Re-run whenever the plugin list changes: enabling a plugin from the settings
* screen has to put the tab there without a reload.
*/
import { Puzzle } from "lucide-react";
import {
registerHostEditorTab,
unregisterHostEditorTab,
} from "@/sidebar/HostManagerTabs";
import type { PluginSummary } from "@/api/plugins-api";
import {
HostPluginSections,
type HostPluginSettings,
} from "./HostPluginSections";
export const HOST_PLUGINS_TAB_ID = "plugins";
/** Only these two fields are read, so a test can pass a plain object. */
interface HostFormLike {
pluginSettings?: HostPluginSettings;
}
/**
* Adds or removes the tab to match the current plugin list.
*
* Returns whether the tab is now present, for a caller that needs to redirect
* away from it when the last contributor goes.
*/
export function syncHostPluginsTab(plugins: PluginSummary[]): boolean {
const contributors = plugins.filter((plugin) => {
if (!plugin.enabled) return false;
const host = plugin.contributes?.settings?.host;
return !!host && (host.fields.length > 0 || !!host.enableKey);
});
if (contributors.length === 0) {
unregisterHostEditorTab(HOST_PLUGINS_TAB_ID);
return false;
}
registerHostEditorTab({
id: HOST_PLUGINS_TAB_ID,
labelKey: "settings.pluginsGroupLabel",
icon: <Puzzle className="size-3" />,
component: ({ form, setField }) => (
<HostPluginSections
plugins={contributors}
values={(form as HostFormLike)?.pluginSettings ?? {}}
setValue={(pluginId, key, value) => {
const current = (form as HostFormLike)?.pluginSettings ?? {};
setField("pluginSettings", {
...current,
[pluginId]: { ...(current[pluginId] ?? {}), [key]: value },
});
}}
/>
),
});
return true;
}
@@ -0,0 +1,27 @@
/**
* Core-side registration of plugin settings components.
*
* TEMPORARY. A `type: "custom"` settings field names a component the plugin's
* own frontend bundle should register through `app.registerSettingsComponent`.
* There is no frontend plugin loader yet, so core registers the handful that
* exist, the same way pluginLoader.ts mirrors the first-party action
* contributions.
*
* A7 deletes this file. Each plugin registers its own component from its
* bundle, and nothing in core names a plugin id.
*
* Only register what is needed now. Everything else waits for its own step:
* the AI providers list stays where it is until B18.
*/
import { registerSettingsComponent } from "./settings-components.js";
import { TailscaleDevicesStatus } from "./components/TailscaleDevicesStatus.js";
let registered = false;
export function registerLegacySettingsComponents(): void {
if (registered) return;
registered = true;
registerSettingsComponent("tailscale", "devices", TailscaleDevicesStatus);
}
+65
View File
@@ -0,0 +1,65 @@
/**
* Components behind a `type: "custom"` settings field.
*
* Most settings are expressible as a schema, and those are rendered from the
* manifest so a plugin cannot ship its own form styling. A few are not: a
* device browser, a provider list. Those declare a component id here instead,
* which keeps the escape hatch narrow and named rather than letting a plugin
* hand core arbitrary markup.
*
* A7 lets a plugin's own frontend bundle register these through
* `app.registerSettingsComponent`. Until then core registers them, in
* legacy-settings-components.ts.
*/
import type { ComponentType } from "react";
export interface SettingsComponentProps {
pluginId: string;
/** Current values for the scope this field sits in. */
values: Record<string, unknown>;
/** Writes one key in the same scope. */
setValue: (key: string, value: unknown) => void;
/** Whether the owning plugin is running. */
running: boolean;
}
export type SettingsComponent = ComponentType<SettingsComponentProps>;
/** Keyed "<pluginId>:<componentId>" so two plugins may use the same name. */
const registry = new Map<string, SettingsComponent>();
function registryKey(pluginId: string, componentId: string): string {
return `${pluginId}:${componentId}`;
}
export function registerSettingsComponent(
pluginId: string,
componentId: string,
component: SettingsComponent,
): () => void {
const key = registryKey(pluginId, componentId);
registry.set(key, component);
return () => {
if (registry.get(key) === component) registry.delete(key);
};
}
export function getSettingsComponent(
pluginId: string,
componentId: string | undefined,
): SettingsComponent | undefined {
if (!componentId) return undefined;
return registry.get(registryKey(pluginId, componentId));
}
export function unregisterSettingsComponents(pluginId: string): void {
for (const key of [...registry.keys()]) {
if (key.startsWith(`${pluginId}:`)) registry.delete(key);
}
}
/** Test helper. */
export function resetSettingsComponents(): void {
registry.clear();
}
+15
View File
@@ -0,0 +1,15 @@
import type { PluginSettingsField } from "@/api/plugins-api";
/**
* Whether a field's `requires` gate is satisfied.
*
* Kept out of the component file so both the settings page and the host editor
* section can import it without dragging a component along.
*/
export function isFieldActive(
field: PluginSettingsField,
values: Record<string, unknown>,
): boolean {
if (!field.requires) return true;
return values[field.requires] === true;
}
+5
View File
@@ -19,6 +19,7 @@ import { Bot } from "lucide-react";
import { unregisterRailItem } from "@/sidebar/rail-items";
import { unregisterTabComponent } from "@/shell/tabUtils";
import { getPlugins, type PluginSummary } from "@/api/plugins-api";
import { syncHostPluginsTab } from "@/settings/host-plugins-tab";
import {
declareActionSlot,
registerAction,
@@ -122,6 +123,10 @@ export function applyPluginState(plugins: PluginSummary[]): void {
pluginEnabled = nextEnabled;
disabledTabTypes = nextDisabledTabs;
applyFirstPartyActions(nextEnabled);
// The host editor's Plugins tab exists only while something fills it, so it
// is re-synced whenever the plugin list changes rather than declared once.
syncHostPluginsTab(plugins);
}
export async function refreshPluginState(): Promise<PluginSummary[]> {
-32
View File
@@ -64,11 +64,6 @@ import {
type TerminalImageStorageTestResult,
type BrandingSettings,
} from "@/api/settings-api";
import {
getTailscaleSettings,
updateTailscaleSettings,
} from "../../../plugins/tailscale/src/frontend/tailscale-api";
import { AdminTailscaleSection } from "../../../plugins/tailscale/src/frontend/AdminTailscaleSection";
import {
getSessionSharingGloballyEnabled,
updateSessionSharingGloballyEnabled,
@@ -185,8 +180,6 @@ export function AdminSettingsPanel({
const [guacEnabled, setGuacEnabled] = useState(false);
const [guacUrl, setGuacUrl] = useState("guacd:4822");
const [logLevel, setLogLevel] = useState("info");
const [tailscaleApiKey, setTailscaleApiKey] = useState("");
const [tailscaleApiBaseUrl, setTailscaleApiBaseUrl] = useState("");
const [commandHistoryEnabled, setCommandHistoryEnabled] = useState(true);
const [analyticsEnabled, setAnalyticsEnabled] = useState(true);
const [analyticsLocked, setAnalyticsLocked] = useState(false);
@@ -413,7 +406,6 @@ export function AdminSettingsPanel({
guac,
oidcProv,
oidcSilent,
tailscale,
cmdHistory,
analytics,
sessionSharingEnabled,
@@ -435,7 +427,6 @@ export function AdminSettingsPanel({
getGuacamoleSettings(),
getOidcAutoProvision(),
getOidcSilentLoginDefault(),
getTailscaleSettings(),
getCommandHistoryEnabled(),
getAnalyticsEnabled(),
getSessionSharingGloballyEnabled(),
@@ -474,10 +465,6 @@ export function AdminSettingsPanel({
setGuacEnabled(guac.value.enabled);
setGuacUrl(guac.value.url || "guacd:4822");
}
if (tailscale.status === "fulfilled") {
setTailscaleApiKey(tailscale.value.apiKey ?? "");
setTailscaleApiBaseUrl(tailscale.value.apiBaseUrl ?? "");
}
if (cmdHistory.status === "fulfilled") {
setCommandHistoryEnabled(cmdHistory.value.enabled);
}
@@ -886,15 +873,6 @@ export function AdminSettingsPanel({
}
}
async function handleSaveTailscaleApiKey() {
try {
await updateTailscaleSettings(tailscaleApiKey, tailscaleApiBaseUrl);
toast.success(t("admin.tailscaleSettingsSaved"));
} catch {
toast.error(t("admin.tailscaleSettingsSaveFailed"));
}
}
async function handleSaveLogLevel(level: string) {
setLogLevel(level);
try {
@@ -1341,16 +1319,6 @@ export function AdminSettingsPanel({
handleSaveLogLevel={handleSaveLogLevel}
/>
<AdminTailscaleSection
open={openSections.has("tailscale")}
onToggle={() => toggle("tailscale")}
tailscaleApiKey={tailscaleApiKey}
setTailscaleApiKey={setTailscaleApiKey}
tailscaleApiBaseUrl={tailscaleApiBaseUrl}
setTailscaleApiBaseUrl={setTailscaleApiBaseUrl}
handleSaveTailscaleApiKey={handleSaveTailscaleApiKey}
/>
<AdminSSOSection
open={openSections.has("sso")}
onToggle={() => toggle("sso")}
+28
View File
@@ -67,6 +67,7 @@ import {
import type { Host, VaultProfile } from "@/types/ui-types";
import type { SSHHost, TunnelStatus } from "@/types";
import { useTabsSafe } from "@/shell/TabContext";
import { updatePluginHostSettings } from "@/api/plugins-api";
import {
buildHostEditorPayload,
createHostEditorForm,
@@ -333,6 +334,29 @@ export function HostEditor({
.finally(() => setTailscaleLoading(false));
}, [form.authType]);
/**
* Writes each plugin's host-scope values through its own route.
*
* One request per plugin rather than one per field, and a plugin that
* rejects a value is reported without failing the host save that already
* succeeded.
*/
const savePluginHostSettings = async (
hostId: number,
values: Record<string, Record<string, unknown>> | undefined,
) => {
if (!values || !Number.isInteger(hostId)) return;
for (const [pluginId, fields] of Object.entries(values)) {
if (!fields || Object.keys(fields).length === 0) continue;
try {
await updatePluginHostSettings(pluginId, hostId, fields);
} catch {
toast.error(t("settings.pluginSettingsSaveFailed"));
}
}
};
const handleSave = async () => {
setSaving(true);
try {
@@ -350,6 +374,10 @@ export function HostEditor({
? await updateSSHHost(Number(host.id), data)
: await createSSHHost(data);
}
// After the host: a new one has no id to scope settings to until it
// exists. A failure here must not claim the host itself failed to save.
await savePluginHostSettings(Number(saved.id), form.pluginSettings);
toast.success(host ? t("hosts.hostUpdated") : t("hosts.hostCreated"));
setPreviewTerminalTheme(null);
onSave(saved);
+7
View File
@@ -342,6 +342,13 @@ export function createHostEditorForm(
excludedMounts: [] as string[],
monitoredMounts: [] as Array<{ path: string; label?: string }>,
},
// Host-scope plugin settings, keyed by plugin id. Loaded with the host and
// saved through the plugin's own scoped route, not in the host payload.
pluginSettings: (host?.pluginSettings ?? {}) as Record<
string,
Record<string, unknown>
>,
};
}
@@ -0,0 +1,297 @@
/**
* The schema-driven settings form.
*
* A plugin declares fields and core draws them, so the thing worth proving is
* that every declared type renders a usable control, reports what the user
* typed, and that a secret's value never appears in the DOM.
*/
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
import type { PluginSettingsField } from "@/api/plugins-api";
import { SettingsFieldRow } from "@/settings/SettingsFields";
import { isFieldActive } from "@/settings/settings-fields-util";
import {
registerSettingsComponent,
resetSettingsComponents,
} from "@/settings/settings-components";
vi.mock("react-i18next", () => ({
useTranslation: () => ({
t: (key: string) => key,
}),
}));
function renderField(
field: PluginSettingsField,
values: Record<string, unknown> = {},
options: { running?: boolean } = {},
) {
const setValue = vi.fn();
render(
<SettingsFieldRow
pluginId="sample"
field={field}
values={values}
setValue={setValue}
running={options.running ?? true}
/>,
);
return { setValue };
}
beforeEach(() => {
resetSettingsComponents();
});
afterEach(() => {
cleanup();
});
describe("field types", () => {
it("renders a boolean as a switch and reports the toggle", () => {
const { setValue } = renderField(
{ key: "enabled", type: "boolean", labelKey: "enabled.label" },
{ enabled: false },
);
const toggle = screen.getByRole("button");
fireEvent.click(toggle);
expect(setValue).toHaveBeenCalledWith("enabled", true);
});
it("renders a string as a text box and reports what was typed", () => {
const { setValue } = renderField(
{ key: "baseUrl", type: "string", labelKey: "baseUrl.label" },
{ baseUrl: "https://a" },
);
const input = screen.getByDisplayValue("https://a");
fireEvent.change(input, { target: { value: "https://b" } });
expect(setValue).toHaveBeenCalledWith("baseUrl", "https://b");
});
it("renders a number with its declared bounds", () => {
const { setValue } = renderField(
{ key: "retries", type: "number", labelKey: "k", min: 0, max: 5 },
{ retries: 2 },
);
const input = screen.getByDisplayValue("2") as HTMLInputElement;
expect(input.getAttribute("min")).toBe("0");
expect(input.getAttribute("max")).toBe("5");
fireEvent.change(input, { target: { value: "4" } });
expect(setValue).toHaveBeenCalledWith("retries", 4);
});
it("clears a number to null rather than to zero", () => {
const { setValue } = renderField(
{ key: "retries", type: "number", labelKey: "k" },
{ retries: 2 },
);
fireEvent.change(screen.getByDisplayValue("2"), { target: { value: "" } });
expect(setValue).toHaveBeenCalledWith("retries", null);
});
it("renders a textarea", () => {
const { setValue } = renderField(
{ key: "notes", type: "textarea", labelKey: "k" },
{ notes: "hello" },
);
fireEvent.change(screen.getByDisplayValue("hello"), {
target: { value: "goodbye" },
});
expect(setValue).toHaveBeenCalledWith("notes", "goodbye");
});
it("renders json as editable text", () => {
renderField({ key: "raw", type: "json", labelKey: "k" }, { raw: { a: 1 } });
expect(screen.getByDisplayValue(/"a": 1/)).toBeTruthy();
});
it("renders a multiselect option per choice and reports a selection", () => {
const { setValue } = renderField(
{
key: "tags",
type: "multiselect",
labelKey: "k",
options: [
{ value: "a", labelKey: "opt.a" },
{ value: "b", labelKey: "opt.b" },
],
},
{ tags: ["a"] },
);
const boxes = screen.getAllByRole("checkbox");
expect(boxes).toHaveLength(2);
fireEvent.click(boxes[1]);
expect(setValue).toHaveBeenCalledWith("tags", ["a", "b"]);
});
it("removes a multiselect value when unticked", () => {
const { setValue } = renderField(
{
key: "tags",
type: "multiselect",
labelKey: "k",
options: [
{ value: "a", labelKey: "opt.a" },
{ value: "b", labelKey: "opt.b" },
],
},
{ tags: ["a", "b"] },
);
fireEvent.click(screen.getAllByRole("checkbox")[0]);
expect(setValue).toHaveBeenCalledWith("tags", ["b"]);
});
it("disables every control while the plugin is not running", () => {
renderField(
{ key: "baseUrl", type: "string", labelKey: "k" },
{ baseUrl: "https://a" },
{ running: false },
);
expect(
(screen.getByDisplayValue("https://a") as HTMLInputElement).disabled,
).toBe(true);
});
});
describe("secrets", () => {
const field: PluginSettingsField = {
key: "apiKey",
type: "secret",
labelKey: "apiKey.label",
};
it("shows a stored secret as set, without its value", () => {
renderField(field, { apiKey: { set: true } });
expect(screen.getByText("settings.secretSet")).toBeTruthy();
expect(document.body.textContent).not.toContain("tskey");
});
it("shows an unset secret as not set", () => {
renderField(field, { apiKey: { set: false } });
expect(screen.getByText("settings.secretNotSet")).toBeTruthy();
});
it("switches to an input when replacing", () => {
const { setValue } = renderField(field, { apiKey: { set: true } });
fireEvent.click(screen.getByText("settings.secretReplace"));
expect(setValue).toHaveBeenCalledWith("apiKey", "");
});
it("takes a new value once editing", () => {
const { setValue } = renderField(field, { apiKey: "" });
// A password input has no textbox role, so it is found by its type.
const input = document.querySelector(
'input[type="password"]',
) as HTMLInputElement;
fireEvent.change(input, { target: { value: "tskey-new" } });
expect(setValue).toHaveBeenCalledWith("apiKey", "tskey-new");
});
it("restores the redacted marker on cancel, so saving changes nothing", () => {
const { setValue } = renderField(field, { apiKey: "half-typed" });
fireEvent.click(screen.getByText("common.cancel"));
expect(setValue).toHaveBeenCalledWith("apiKey", { set: false });
});
});
describe("custom fields", () => {
it("renders a registered component", () => {
registerSettingsComponent("sample", "devices", () => (
<div>custom-component-here</div>
));
renderField({ key: "devices", type: "custom", component: "devices" });
expect(screen.getByText("custom-component-here")).toBeTruthy();
});
it("renders nothing when no component is registered", () => {
const { container } = render(
<SettingsFieldRow
pluginId="sample"
field={{ key: "devices", type: "custom", component: "missing" }}
values={{}}
setValue={vi.fn()}
running
/>,
);
expect(container.innerHTML).toBe("");
});
it("keeps two plugins' components apart", () => {
registerSettingsComponent("sample", "shared", () => <div>mine</div>);
registerSettingsComponent("other", "shared", () => <div>theirs</div>);
renderField({ key: "shared", type: "custom", component: "shared" });
expect(screen.getByText("mine")).toBeTruthy();
expect(screen.queryByText("theirs")).toBeNull();
});
});
describe("errors", () => {
it("shows a per-field message from a rejected save", () => {
render(
<SettingsFieldRow
pluginId="sample"
field={{ key: "retries", type: "number", labelKey: "k" }}
values={{ retries: 99 }}
setValue={vi.fn()}
running
error='"retries" must be at most 5'
/>,
);
expect(screen.getByText('"retries" must be at most 5')).toBeTruthy();
});
});
describe("isFieldActive", () => {
const gated: PluginSettingsField = {
key: "socketPath",
type: "string",
labelKey: "k",
requires: "enableDocker",
};
it("is inactive while the named boolean is off", () => {
expect(isFieldActive(gated, { enableDocker: false })).toBe(false);
expect(isFieldActive(gated, {})).toBe(false);
});
it("is active once the named boolean is on", () => {
expect(isFieldActive(gated, { enableDocker: true })).toBe(true);
});
it("is always active without a requires", () => {
expect(isFieldActive({ key: "a", type: "string", labelKey: "k" }, {})).toBe(
true,
);
});
});
@@ -0,0 +1,185 @@
/**
* The one Settings screen.
*
* What matters here is the navigation: which bands appear for whom, that a
* plugin's page shows up only when it has settings that user could change, and
* that the nav collapses rather than disappearing on a phone.
*/
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { cleanup, render, screen, waitFor } from "@testing-library/react";
import type { PluginSummary } from "@/api/plugins-api";
const { getPluginsMock } = vi.hoisted(() => ({ getPluginsMock: vi.fn() }));
vi.mock("@/api/plugins-api", async (importOriginal) => ({
...(await importOriginal<typeof import("@/api/plugins-api")>()),
getPlugins: getPluginsMock,
}));
vi.mock("react-i18next", () => ({
useTranslation: () => ({ t: (key: string) => key }),
}));
// The two core panels are large and pull in most of the app; this screen only
// has to decide which one to mount.
vi.mock("@/sidebar/UserProfilePanel", () => ({
UserProfilePanel: () => <div>profile-panel</div>,
}));
vi.mock("@/sidebar/AdminSettingsPanel", () => ({
AdminSettingsPanel: () => <div>admin-panel</div>,
}));
vi.mock("@/settings/legacy-settings-components", () => ({
registerLegacySettingsComponents: vi.fn(),
}));
const { SettingsScreen } = await import("@/settings/SettingsScreen");
function plugin(overrides: Partial<PluginSummary> = {}): PluginSummary {
return {
id: "tailscale",
name: "Tailscale",
version: "1.0.0",
enabled: true,
state: "active",
contributes: {
settings: { admin: [{ key: "apiKey", type: "secret", labelKey: "k" }] },
},
...overrides,
} as PluginSummary;
}
beforeEach(() => {
getPluginsMock.mockReset();
getPluginsMock.mockResolvedValue([]);
});
afterEach(() => {
cleanup();
});
describe("navigation bands", () => {
it("always shows the You band", async () => {
render(<SettingsScreen />);
expect(await screen.findByText("settings.bandYou")).toBeTruthy();
});
it("shows the Instance band only to an admin", async () => {
const { unmount } = render(<SettingsScreen isAdmin={false} />);
await waitFor(() => expect(getPluginsMock).toHaveBeenCalled());
expect(screen.queryByText("settings.bandInstance")).toBeNull();
unmount();
render(<SettingsScreen isAdmin />);
expect(await screen.findByText("settings.bandInstance")).toBeTruthy();
});
it("shows no Plugins band when nothing declares settings", async () => {
render(<SettingsScreen isAdmin />);
await waitFor(() => expect(getPluginsMock).toHaveBeenCalled());
expect(screen.queryByText("settings.bandPlugins")).toBeNull();
});
it("lists a plugin with admin settings for an admin", async () => {
getPluginsMock.mockResolvedValue([plugin()]);
render(<SettingsScreen isAdmin />);
expect(await screen.findByText("settings.bandPlugins")).toBeTruthy();
expect(screen.getByText("Tailscale")).toBeTruthy();
});
it("hides an admin-only plugin page from a non-admin", async () => {
getPluginsMock.mockResolvedValue([plugin()]);
render(<SettingsScreen isAdmin={false} />);
await waitFor(() => expect(getPluginsMock).toHaveBeenCalled());
expect(screen.queryByText("Tailscale")).toBeNull();
});
it("shows a plugin with user settings to everyone", async () => {
getPluginsMock.mockResolvedValue([
plugin({
contributes: {
settings: { user: [{ key: "theme", type: "string", labelKey: "k" }] },
},
}),
]);
render(<SettingsScreen isAdmin={false} />);
expect(await screen.findByText("Tailscale")).toBeTruthy();
});
it("hides a disabled plugin's page", async () => {
getPluginsMock.mockResolvedValue([plugin({ enabled: false })]);
render(<SettingsScreen isAdmin />);
await waitFor(() => expect(getPluginsMock).toHaveBeenCalled());
expect(screen.queryByText("Tailscale")).toBeNull();
});
it("keeps working when the plugin list cannot be loaded", async () => {
getPluginsMock.mockRejectedValue(new Error("offline"));
render(<SettingsScreen isAdmin />);
expect(await screen.findByText("settings.bandYou")).toBeTruthy();
});
});
describe("initial section", () => {
it("lands on the profile panel by default", async () => {
render(<SettingsScreen />);
expect(await screen.findByText("profile-panel")).toBeTruthy();
});
it("lands on the admin panel when opened from the admin rail entry", async () => {
render(<SettingsScreen isAdmin initialSection="admin" />);
expect(await screen.findByText("admin-panel")).toBeTruthy();
});
it("does not mount the admin panel for a non-admin", async () => {
render(<SettingsScreen isAdmin={false} initialSection="admin" />);
await waitFor(() => expect(getPluginsMock).toHaveBeenCalled());
expect(screen.queryByText("admin-panel")).toBeNull();
});
it("switches section when the rail entry changes", async () => {
const { rerender } = render(
<SettingsScreen isAdmin initialSection="profile" />,
);
expect(await screen.findByText("profile-panel")).toBeTruthy();
rerender(<SettingsScreen isAdmin initialSection="admin" />);
expect(await screen.findByText("admin-panel")).toBeTruthy();
});
});
describe("phone layout", () => {
it("offers a disclosure button for the collapsed nav", async () => {
render(<SettingsScreen />);
await screen.findByText("settings.bandYou");
const toggle = document.querySelector("button[aria-expanded]");
expect(toggle).toBeTruthy();
expect(toggle?.getAttribute("aria-expanded")).toBe("false");
});
it("hides the nav below md and shows it from md up", async () => {
render(<SettingsScreen />);
await screen.findByText("settings.bandYou");
const nav = document.querySelector("nav")!;
expect(nav.className).toContain("hidden");
expect(nav.className).toContain("md:flex");
});
});
@@ -0,0 +1,193 @@
/**
* The host editor's Plugins group.
*
* No bundled plugin declares host settings yet, so a fixture stands in for one.
* The rule worth protecting is the empty case: with nothing to show, the tab
* must not be registered at all rather than appearing blank.
*/
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
import type { PluginSummary } from "@/api/plugins-api";
import {
HOST_PLUGINS_TAB_ID,
syncHostPluginsTab,
} from "@/settings/host-plugins-tab";
import {
getRegisteredHostEditorTab,
unregisterHostEditorTab,
} from "@/sidebar/HostManagerTabs";
import { HostPluginSections } from "@/settings/HostPluginSections";
vi.mock("react-i18next", () => ({
useTranslation: () => ({ t: (key: string) => key }),
}));
function plugin(overrides: Partial<PluginSummary> = {}): PluginSummary {
return {
id: "docker",
name: "Docker",
version: "1.0.0",
enabled: true,
state: "active",
icon: "Box",
contributes: {
settings: {
host: {
enableKey: "enableDocker",
enableLabelKey: "enableDocker.label",
fields: [
{ key: "socketPath", type: "string", labelKey: "socket.label" },
],
},
},
},
...overrides,
} as PluginSummary;
}
beforeEach(() => {
unregisterHostEditorTab(HOST_PLUGINS_TAB_ID);
});
afterEach(() => {
cleanup();
unregisterHostEditorTab(HOST_PLUGINS_TAB_ID);
});
describe("syncHostPluginsTab", () => {
it("registers the tab when a plugin contributes host settings", () => {
expect(syncHostPluginsTab([plugin()])).toBe(true);
expect(getRegisteredHostEditorTab(HOST_PLUGINS_TAB_ID)).toBeDefined();
});
it("registers nothing when no plugin contributes", () => {
expect(syncHostPluginsTab([])).toBe(false);
expect(getRegisteredHostEditorTab(HOST_PLUGINS_TAB_ID)).toBeUndefined();
});
it("ignores a plugin with no host settings", () => {
const other = plugin({
id: "ai",
contributes: { settings: { admin: [] } },
});
expect(syncHostPluginsTab([other])).toBe(false);
});
it("ignores a disabled plugin", () => {
expect(syncHostPluginsTab([plugin({ enabled: false })])).toBe(false);
});
it("removes the tab once the last contributor goes", () => {
syncHostPluginsTab([plugin()]);
expect(getRegisteredHostEditorTab(HOST_PLUGINS_TAB_ID)).toBeDefined();
syncHostPluginsTab([plugin({ enabled: false })]);
expect(getRegisteredHostEditorTab(HOST_PLUGINS_TAB_ID)).toBeUndefined();
});
it("registers for a plugin declaring only an enable switch", () => {
const bare = plugin({
contributes: {
settings: {
host: { enableKey: "enableThing", enableLabelKey: "k", fields: [] },
},
},
});
expect(syncHostPluginsTab([bare])).toBe(true);
});
});
describe("HostPluginSections", () => {
it("shows the enable switch and hides the fields until it is on", () => {
const setValue = vi.fn();
render(
<HostPluginSections
plugins={[plugin()]}
values={{ docker: { enableDocker: false } }}
setValue={setValue}
/>,
);
expect(screen.getByText("plugins.docker.enableDocker.label")).toBeTruthy();
expect(screen.queryByText("plugins.docker.socket.label")).toBeNull();
});
it("shows the fields once the enable switch is on", () => {
render(
<HostPluginSections
plugins={[plugin()]}
values={{ docker: { enableDocker: true } }}
setValue={vi.fn()}
/>,
);
expect(screen.getByText("plugins.docker.socket.label")).toBeTruthy();
});
it("reports a change against the right plugin", () => {
const setValue = vi.fn();
render(
<HostPluginSections
plugins={[plugin()]}
values={{ docker: { enableDocker: false } }}
setValue={setValue}
/>,
);
fireEvent.click(screen.getAllByRole("button")[0]);
expect(setValue).toHaveBeenCalledWith("docker", "enableDocker", true);
});
it("warns when the plugin is installed but not running", () => {
render(
<HostPluginSections
plugins={[plugin({ state: "failed" })]}
values={{ docker: {} }}
setValue={vi.fn()}
/>,
);
expect(screen.getByText("settings.hostPluginNotRunning")).toBeTruthy();
});
it("renders nothing at all with no contributing plugins", () => {
const { container } = render(
<HostPluginSections plugins={[]} values={{}} setValue={vi.fn()} />,
);
expect(container.innerHTML).toBe("");
});
it("keeps two plugins' values apart", () => {
const vault = plugin({
id: "vault",
name: "Vault",
contributes: {
settings: {
host: {
fields: [{ key: "role", type: "string", labelKey: "role.label" }],
},
},
},
});
render(
<HostPluginSections
plugins={[plugin(), vault]}
values={{
docker: { enableDocker: true, socketPath: "/var/run/docker.sock" },
vault: { role: "web" },
}}
setValue={vi.fn()}
/>,
);
expect(screen.getByDisplayValue("/var/run/docker.sock")).toBeTruthy();
expect(screen.getByDisplayValue("web")).toBeTruthy();
});
});