From ea4777c099550fdf910ba48bb9c2189543779bbd Mon Sep 17 00:00:00 2001 From: lilleman Date: Sun, 23 Aug 2026 13:16:29 +0200 Subject: [PATCH] Declare plugin settings in the manifest, resolve them from the environment --- AGENTS.md | 10 + README.md | 63 ++++- compose.override.yml | 6 +- e2e-tests/full-flow.spec.ts | 11 + examples/README.md | 2 +- .../admin/admin-plugin-settings.test.ts | 55 ++++ .../plugins/admin/admin-plugin-settings.ts | 75 +++++ examples/plugins/admin/admin-shared.test.ts | 14 +- examples/plugins/admin/admin-shared.ts | 4 +- examples/plugins/admin/i18n/en-US.ts | 20 ++ examples/plugins/admin/i18n/sv-SE.ts | 20 ++ examples/plugins/admin/plugin.test.ts | 11 +- examples/plugins/admin/plugin.ts | 7 +- .../plugins/admin/views/plugin-settings.ejs | 24 ++ examples/plugins/scheduling/README.md | 2 +- examples/plugins/scheduling/plugin.ts | 28 +- examples/plugins/scheduling/shifts.test.ts | 44 ++- examples/plugins/scheduling/shifts.ts | 22 +- examples/shifts-upstream/server.ts | 2 +- release-tooling/contract-version.test.ts | 2 +- release-tooling/dockerhub-overview.md.tmpl | 2 +- src/config.ts | 2 + src/http/app.ts | 7 +- src/http/context.ts | 6 + src/plugin-host/discovery.ts | 5 + src/plugin-host/plugin-api.ts | 1 + src/plugin-host/plugin.ts | 38 ++- src/plugin-host/settings.test.ts | 177 ++++++++++++ src/plugin-host/settings.ts | 259 ++++++++++++++++++ src/server.ts | 26 +- todo.md | 2 +- views/index.ejs | 2 +- 32 files changed, 858 insertions(+), 91 deletions(-) create mode 100644 examples/plugins/admin/admin-plugin-settings.test.ts create mode 100644 examples/plugins/admin/admin-plugin-settings.ts create mode 100644 examples/plugins/admin/views/plugin-settings.ejs create mode 100644 src/plugin-host/settings.test.ts create mode 100644 src/plugin-host/settings.ts diff --git a/AGENTS.md b/AGENTS.md index ea2c00a..d87c6b8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -93,6 +93,16 @@ Revisit only if the stated reason stops holding. plugin cannot destroy data — boot logs the orphans instead. Because the host's copy sits in the ambient `/node_modules`, a plugin can `import "postgres"` without declaring it — incidental, not a packaging promise, and a plugin must still depend on its own driver. + - **Plugin settings are declared, not discovered** (README → Plugin settings). `settings.ts` is pure and + takes the env as an argument, so the whole matrix unit-tests without a stack. Four rules carry the + design: the prefix is `PLUGIN_SETTING_`, never bare `PLUGIN_`, because a plugin id `db` with key + `url` would otherwise name the host's own `PLUGIN_DB_URL`; keys are camelCase so the + `camelCase → SNAKE_CASE` mapping is total and no two keys collide, with the residual cross-plugin + collision caught by `findConflicts`; `required` and `default` are mutually exclusive, which is what + lets `SettingsOf` type a declared key as present rather than `T | undefined`, so no plugin author + casts; and a secret's value reaches the plugin but never a log, an error or `ctx.declaredSettings` + — not even as a mask or a length. An author mistake is refused at discovery, a bad operator value + refuses the boot, and a stray `PLUGIN_SETTING_` variable only warns (the orphan-database precedent). - **The trust boundary is the `web` process, not the plugin.** Per-plugin databases and roles bound *accidents*, not hostile plugins: `PLUGIN_DB_SECRET` is in `web`'s environment during `onBoot`, and a plugin already holds `ctx.system`'s Ory admin clients — so cross-plugin DB isolation is diff --git a/README.md b/README.md index 8ecec03..f12903a 100644 --- a/README.md +++ b/README.md @@ -47,7 +47,7 @@ folder under `plugins/` goes live after a restart. Create `plugins/hello/plugin. import { definePlugin } from "@plainpages/plugin-api"; export default definePlugin({ - apiVersion: "0.1.0", + apiVersion: "0.2.0", nav: [{ href: "/hello", id: "hello", label: "Hello", public: true }], routes: [ { method: "GET", path: "/", public: true, handler: () => ({ html: "

Hello from my plugin

" }) }, @@ -89,6 +89,7 @@ From here, render real pages against the app shell and fetch upstream data — s - [hooks](#hooks) - [where they live & mounting](#where-plugins-live-and-how-to-mount-them) - [dependencies](#plugin-dependencies) + - [settings](#plugin-settings) - [storage](#plugin-storage) - [local dev & test](#local-dev--test-story) - [The menu system](#the-menu-system) @@ -348,7 +349,7 @@ import { definePlugin } from "@plainpages/plugin-api"; import { listThings, createThings } from "./handlers.ts"; export default definePlugin({ - apiVersion: "0.1.0", // semver string of the host contract this plugin was built against (see Versioning) + apiVersion: "0.2.0", // semver string of the host contract this plugin was built against (see Versioning) // Nav fragment, merged into the global menu and permission-filtered per user. // `icon` is a Lucide icon by its sprite id (src/ui/icons.ts). @@ -381,6 +382,7 @@ folder-derived `id` to produce the loaded `Plugin`. | `permissions` | no | Permissions this plugin gates on. See [Nav & permission gates](#nav--permission-gates). | | `routes` | no | See [Routes & handlers](#routes--handlers). | | `hooks` | no | See [Hooks](#hooks). | +| `settings` | no | Configuration this plugin accepts, one `PLUGIN_SETTING__` variable per key, resolved and validated at boot and handed to `onBoot`. See [Plugin settings](#plugin-settings). | | `storage` | no | `true` ⇒ the host provisions a Postgres database and login role for this plugin and hands the credentials to `onBoot`. See [Plugin storage](#plugin-storage). | A plugin may be routes-only, nav-only, or hooks-only — every collection field is optional. @@ -468,7 +470,7 @@ import { definePlugin } from "@plainpages/plugin-api"; import { landing, board } from "./pages.ts"; export default definePlugin({ - apiVersion: "0.1.0", + apiVersion: "0.2.0", home: landing, // owns "/" — the public front page dashboard: board, // owns "/dashboard" — the post-login app home }); @@ -736,6 +738,56 @@ barrel's types on disk: typecheck it mounted under the host tree, or vendor a ty `node_modules`** and point tsconfig `paths` at it — a stub inside is the shadowing copy discovery refuses, and it would travel with the folder you mount. +### Plugin settings + +A plugin declares the configuration it accepts, and the host resolves it from the environment at +boot. Each key becomes one variable — `PLUGIN_SETTING__`, the id's dashes and the key's +camel humps both becoming underscores — so `upstream` on the `scheduling` plugin is set by +`PLUGIN_SETTING_SCHEDULING_UPSTREAM`. + +```ts +export default definePlugin({ + apiVersion: "0.2.0", + settings: [ + { key: "upstream", type: "url", required: true, description: "Base URL of the backend" }, + { key: "pageSize", type: "number", default: 25 }, + { key: "mode", type: "enum", values: ["strict", "lenient"], default: "strict" }, + { key: "apiKey", type: "string", secret: true, default: "dev-insecure-key" }, + ], + hooks: { + onBoot: ({ settings }) => { + settings.upstream; // string — required, so the boot already refused without it + settings.pageSize; // number — defaulted, so always present + start(settings); + }, + }, +}); +``` + +`type` is one of `string`, `number`, `boolean`, `enum` (with `values`) or `url`. A declared type is +coerced and checked at boot, so a mistyped value names the plugin, the key and the variable instead +of surfacing later as a broken page. + +**`required` and `default` are mutually exclusive** — a default means the setting can never fail, so +declaring both is refused at discovery. That leaves three cases, and the type `onBoot` receives +follows them exactly: `required: true` is always present, a `default` is always present, and a +setting with neither is `T | undefined`, so the plugin has to handle its absence. + +**Secrets.** `secret: true` marks a value the host reads but never renders — not in a boot log, not +in an error, not on the admin screen, which shows only whether it resolved and from where. With +`REQUIRE_SECURE_SECRETS=true` a secret that is unset, or still equal to its declared default, refuses +the boot — the same rule the host applies to its own secrets. + +**Where it fails, and where it warns.** A malformed declaration is refused at discovery; a missing +`required` value or a value that will not coerce refuses the boot. A `PLUGIN_SETTING_` variable no +installed plugin declares is only *reported* — it is usually a typo in the one the operator meant to +set, and naming it turns two unrelated-looking errors into one. Declaring settings without an +`onBoot` warns too: they resolve, but nothing receives them. + +**Reading what a deployment is configured with.** The admin plugin's **Plugin settings** screen +(`plugin-settings:read`) lists every installed plugin, its declared keys, the variable that sets +each, and whether the value came from the environment or the declared default. + ### Plugin storage A plugin that needs to keep data sets `storage: true`. The host then provisions a Postgres @@ -749,7 +801,7 @@ import { definePlugin } from "@plainpages/plugin-api"; let sql: ReturnType; export default definePlugin({ - apiVersion: "0.1.0", + apiVersion: "0.2.0", storage: true, hooks: { onBoot: async (boot) => { @@ -1030,7 +1082,7 @@ The app is **environment-agnostic**: no `NODE_ENV`, every behaviour its own expl | `PORT` | `3000` | web listen port | | `CACHE_TEMPLATES` | `false` | cache compiled EJS templates (`true` in prod) | | `SECURE_COOKIES` | `false` | mark our session/CSRF cookies `Secure` (`true` in prod https; off in dev http) | -| `REQUIRE_SECURE_SECRETS` | `false` | when `true`, `CSRF_SECRET` — and `PLUGIN_DB_SECRET` once storage is configured — must be supplied and differ from the dev throwaway | +| `REQUIRE_SECURE_SECRETS` | `false` | when `true`, `CSRF_SECRET` — and `PLUGIN_DB_SECRET` once storage is configured, and every plugin setting declared `secret` — must be supplied and differ from the dev throwaway | | `LOG_LEVEL` | `info` | min severity logged: `error`/`warn`/`info`/`verbose`/`debug`/`silly`/`none` | | `LOG_FORMAT` | `text` | log line format: `text` (human-readable, dev) or `json` (structured, prod) | | `SERVICE_NAME` | `plainpages` | OTLP `service.name` on every log + span — brand it as your own deployment | @@ -1047,6 +1099,7 @@ The app is **environment-agnostic**: no `NODE_ENV`, every behaviour its own expl | `REVOCATION_TTL_SEC` | `900` | how long a revoke entry lives; keep ≥ tokenizer TTL (10m) + clock skew | | `CSRF_SECRET` | dev throwaway | signs our double-submit CSRF token; enforced by `REQUIRE_SECURE_SECRETS` | | `PLUGIN_DB_URL` | _unset_ (dev: `postgres://postgres:5432`) | credential-free Postgres base URL for [plugin storage](#plugin-storage); unset ⇒ storage off, and a plugin declaring it aborts boot | +| `PLUGIN_SETTING__` | per declaration | one variable per key a plugin declares in `settings`; see [Plugin settings](#plugin-settings) | | `PLUGIN_DB_ADMIN_URL` | _unset_ (dev: the bundled superuser) | the DSN that provisions each plugin's database and role — read by the one-shot `bootstrap` service **only**, never by `web` | | `PLUGIN_DB_SECRET` | dev throwaway | derives each plugin's database password; `REQUIRE_SECURE_SECRETS` enforces it in `web` once `PLUGIN_DB_URL` is set, and in `bootstrap` whenever a plugin declares storage | | `PLUGIN_DB_CONNECTION_LIMIT` | `10` | per-role Postgres connection ceiling, so one plugin's pools cannot exhaust the server Ory shares; read by `bootstrap` when provisioning | diff --git a/compose.override.yml b/compose.override.yml index dc31986..f2fe007 100644 --- a/compose.override.yml +++ b/compose.override.yml @@ -22,7 +22,7 @@ services: PLUGIN_DB_URL: *plugin-db-url REQUIRE_SECURE_SECRETS: "false" SECURE_COOKIES: "false" # dev serves http — Secure cookies wouldn't be sent - SCHEDULING_UPSTREAM: "http://shifts-upstream:4000" # backs the reference plugin once you copy it into plugins/ + PLUGIN_SETTING_SCHEDULING_UPSTREAM: "http://shifts-upstream:4000" # backs the reference plugin once you copy it into plugins/ volumes: - .:/app # Mount your own menu/branding override into the empty config/ dir (defaults apply otherwise): @@ -46,8 +46,8 @@ services: # Mock backend ready for the reference plugin (examples/plugins/scheduling): plugins/ ships empty, so # the plugin is opt-in — `cp -r examples/plugins/scheduling plugins/scheduling`, restart, and this - # backs it (SCHEDULING_UPSTREAM above points here). Stand-in for the customer's real service — - # stdlib-only, in-memory, no auth. Prod points SCHEDULING_UPSTREAM at the real backend instead. + # backs it (PLUGIN_SETTING_SCHEDULING_UPSTREAM above points here). Stand-in for the customer's real service — + # stdlib-only, in-memory, no auth. Prod points PLUGIN_SETTING_SCHEDULING_UPSTREAM at the real backend instead. shifts-upstream: image: node:24.19.0-alpine3.24 command: node /srv/server.ts diff --git a/e2e-tests/full-flow.spec.ts b/e2e-tests/full-flow.spec.ts index 39db0c7..a884569 100644 --- a/e2e-tests/full-flow.spec.ts +++ b/e2e-tests/full-flow.spec.ts @@ -195,6 +195,17 @@ test.describe.serial("authenticated admin journey", () => { await expect(page.locator("table")).toContainText("Morning — Front desk"); // seeded by the mock upstream }); + test("plugin settings: the screen names the variable that sets each declared key", async () => { + await page.goto("/admin/plugin-settings"); + await expect(page.locator("h1")).toHaveText("Plugin settings"); + // The reference plugin's one declared setting, and the variable an operator would set for it. + const scheduling = page.locator("table").filter({ hasText: "PLUGIN_SETTING_SCHEDULING_UPSTREAM" }); + await expect(scheduling).toContainText("upstream"); + await expect(scheduling).toContainText("http://shifts-upstream:4000"); // resolved, and its source shown + // Every installed plugin gets a section, so "declares none" is distinguishable from "not installed". + await expect(page.locator("h2", { hasText: "admin" })).toHaveCount(1); + }); + test("logout: signing out ends the session and returns to the login page", async () => { await page.goto("/dashboard"); await page.locator("button.profile").click(); // open the profile dropdown diff --git a/examples/README.md b/examples/README.md index 2bee729..911572c 100644 --- a/examples/README.md +++ b/examples/README.md @@ -8,4 +8,4 @@ across (or bind-mount your own) and restart. | [`plugins/scheduling/`](plugins/scheduling/) | `plugins/scheduling/` | The reference plugin: a list page over an upstream REST service, a CSRF-guarded form that forwards a write, and permission-gated nav — built from the core building blocks, holding no state. Imports the host surface as `@plainpages/plugin-api`. See its [README](plugins/scheduling/README.md) and the [plugin contract](../README.md#building-plugins). | | [`plugins/admin/`](plugins/admin/) | `plugins/admin/` | The system-admin plugin: the Users / Groups / Permissions / OAuth2-clients screens for running Plainpages itself. A *system* plugin — it administers the Ory identity stack via the privileged [`ctx.system`](../README.md#system-capabilities-the-ctxsystem-surface) surface instead of its own upstream. Copy it in to get a GUI for user & group admin. See its [README](plugins/admin/README.md). | | [`config/menu.ts`](config/menu.ts) | `config/menu.ts` | The central menu override + branding template (rename/group/order/hide nav, set app name/logo/theme). Imports its typed builder as `#menu-config`; `config/` ships empty, so defaults apply until you copy this in. See [The menu system](../README.md#the-menu-system). | -| [`shifts-upstream/`](shifts-upstream/) | — (dev service) | A throwaway mock backend the reference plugin reads/writes — stdlib-only, in-memory, no auth. Stands in for your real service so `docker compose up` shows the plugin working out of the box; in production you point `SCHEDULING_UPSTREAM` at the real thing instead. | +| [`shifts-upstream/`](shifts-upstream/) | — (dev service) | A throwaway mock backend the reference plugin reads/writes — stdlib-only, in-memory, no auth. Stands in for your real service so `docker compose up` shows the plugin working out of the box; in production you point `PLUGIN_SETTING_SCHEDULING_UPSTREAM` at the real thing instead. | diff --git a/examples/plugins/admin/admin-plugin-settings.test.ts b/examples/plugins/admin/admin-plugin-settings.test.ts new file mode 100644 index 0000000..cfbb014 --- /dev/null +++ b/examples/plugins/admin/admin-plugin-settings.test.ts @@ -0,0 +1,55 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import type { PageChrome, PluginSettings } from "@plainpages/plugin-api"; +import { buildPluginSettingsModel } from "./admin-plugin-settings.ts"; + +const CHROME: PageChrome = { brand: { name: "Test" }, csrfToken: "tok", nav: [], signInHref: "/login", user: { email: "", initials: "T", name: "Tester" } }; + +const CATALOG: readonly PluginSettings[] = [ + { + pluginId: "scheduling", + settings: [ + { description: "Where shifts come from", envName: "PLUGIN_SETTING_SCHEDULING_UPSTREAM", key: "upstream", required: true, secret: false, source: "env", type: "url", value: "https://shifts.test" }, + { envName: "PLUGIN_SETTING_SCHEDULING_MODE", key: "mode", required: false, secret: false, source: "default", type: "enum", value: "strict", values: ["strict", "lenient"] }, + { envName: "PLUGIN_SETTING_SCHEDULING_NOTE", key: "note", required: false, secret: false, source: "unset", type: "string" }, + ], + }, + { pluginId: "quiet", settings: [] }, +]; + +test("a row carries the variable to set and where the value came from", () => { + const model = buildPluginSettingsModel({ chrome: CHROME, settings: CATALOG }); + const rows = model.groups[0]?.table.rows ?? []; + assert.deepEqual(rows.map((r) => r.name), ["upstream", "mode", "note"]); + assert.deepEqual(rows[0]?.cells, [ + { rowHeader: { text: "upstream" } }, "Where shifts come from", "url", "Yes", "PLUGIN_SETTING_SCHEDULING_UPSTREAM", "Environment", "https://shifts.test", + ]); + assert.equal(rows[1]?.cells[2], "enum (strict, lenient)"); // the choices are the useful half of the type + assert.equal(rows[2]?.cells[5], "Not set"); +}); + +test("a plugin declaring nothing still gets a section, so it is visibly installed", () => { + const model = buildPluginSettingsModel({ chrome: CHROME, settings: CATALOG }); + assert.deepEqual(model.groups.map((g) => g.pluginId), ["scheduling", "quiet"]); + assert.deepEqual(model.groups[1]?.table.rows, []); + assert.match(model.groups[1]?.emptyText ?? "", /no settings/i); +}); + +test("a secret renders as set-or-not, never as a value, a mask or a length", () => { + const settings: readonly PluginSettings[] = [{ + pluginId: "billing", + settings: [ + { envName: "PLUGIN_SETTING_BILLING_API_KEY", key: "apiKey", required: false, secret: true, source: "env", type: "string" }, + { envName: "PLUGIN_SETTING_BILLING_WEBHOOK_KEY", key: "webhookKey", required: false, secret: true, source: "unset", type: "string" }, + ], + }]; + const rows = buildPluginSettingsModel({ chrome: CHROME, settings }).groups[0]?.table.rows ?? []; + assert.equal(rows[0]?.cells[6], "Secret — set"); + assert.equal(rows[1]?.cells[6], "Secret — not set"); +}); + +test("two tables on one page need distinct row-action id stems", () => { + const model = buildPluginSettingsModel({ chrome: CHROME, settings: CATALOG }); + const stems = model.groups.map((g) => g.table.actionsId); + assert.equal(new Set(stems).size, stems.length); +}); diff --git a/examples/plugins/admin/admin-plugin-settings.ts b/examples/plugins/admin/admin-plugin-settings.ts new file mode 100644 index 0000000..fbdb7ec --- /dev/null +++ b/examples/plugins/admin/admin-plugin-settings.ts @@ -0,0 +1,75 @@ +// Plugin settings admin screen: what each installed plugin declares it can be configured with, the +// variable that sets it, and how each key resolved. Read-only — the host reads settings from the +// environment at boot, so changing one is a deploy, not a form. + +import { type PageChrome, type PluginSettings, type RouteHandler, type SettingSummary, type Translate } from "@plainpages/plugin-api"; +import { ADMIN_EN, requirePermission } from "./admin-shared.ts"; + +interface SettingsGroup { + emptyText: string; + pluginId: string; + table: { + actionsId: string; + caption: string; + columns: { label: string }[]; + rows: { cells: (string | { rowHeader: { text: string } })[]; name: string }[]; + }; +} + +// One group per installed plugin, including those declaring nothing — an operator who cannot find +// their plugin here has not installed it, which is the other half of what this screen answers. +export function buildPluginSettingsModel(opts: { chrome: PageChrome; settings: readonly PluginSettings[]; t?: Translate }) { + const t = opts.t ?? ADMIN_EN; + return { + breadcrumbs: [{ label: t("admin.pluginSettings.title") }], + chrome: opts.chrome, + groups: opts.settings.map((plugin): SettingsGroup => ({ + emptyText: t("admin.pluginSettings.none"), + pluginId: plugin.pluginId, + table: { + actionsId: `settings-${plugin.pluginId}`, // two tables share this page, so the stem must differ + caption: t("admin.pluginSettings.caption", { plugin: plugin.pluginId }), + columns: [ + { label: t("admin.pluginSettings.column.key") }, + { label: t("admin.pluginSettings.column.description") }, + { label: t("admin.pluginSettings.column.type") }, + { label: t("admin.pluginSettings.column.required") }, + { label: t("admin.pluginSettings.column.variable") }, + { label: t("admin.pluginSettings.column.source") }, + { label: t("admin.pluginSettings.column.value") }, + ], + rows: plugin.settings.map((setting) => ({ + cells: [ + { rowHeader: { text: setting.key } }, + setting.description ?? "", + typeLabel(setting), + t(setting.required ? "admin.pluginSettings.yes" : "admin.pluginSettings.no"), + setting.envName, + t(`admin.pluginSettings.source.${setting.source}`), + valueLabel(setting, t), + ], + name: setting.key, + })), + }, + })), + title: t("admin.pluginSettings.title"), + }; +} + +// An enum's choices are the useful half of its type — they are what the operator must pick from. +function typeLabel(setting: SettingSummary): string { + return setting.type === "enum" && setting.values ? `${setting.type} (${setting.values.join(", ")})` : setting.type; +} + +// A secret never renders its value — not the value, not a mask of it, not its length. Whether it +// resolved and from where is what an operator needs, and the source column already says the rest. +function valueLabel(setting: SettingSummary, t: Translate): string { + if (setting.secret) return t(setting.source === "unset" ? "admin.pluginSettings.secretUnset" : "admin.pluginSettings.secretSet"); + return setting.value ?? t("admin.pluginSettings.unset"); +} + +// GET /admin/plugin-settings +export const pluginSettingsList: RouteHandler = (ctx) => { + requirePermission(ctx, "plugin-settings"); + return { data: { chrome: ctx.chrome, model: buildPluginSettingsModel({ chrome: ctx.chrome, settings: ctx.declaredSettings, t: ctx.t }) }, view: "plugin-settings" }; +}; diff --git a/examples/plugins/admin/admin-shared.test.ts b/examples/plugins/admin/admin-shared.test.ts index 283e0fd..37de987 100644 --- a/examples/plugins/admin/admin-shared.test.ts +++ b/examples/plugins/admin/admin-shared.test.ts @@ -19,7 +19,7 @@ function fakeCtx(opts: { body?: string; method?: string; user?: User | null; ver const req = Readable.from(opts.body != null ? [Buffer.from(opts.body)] : []) as unknown as IncomingMessage; req.method = opts.method ?? "GET"; return { - chrome: CHROME, declaredPermissions: [], user: opts.user ?? null, locale: "en-US", localeHref: (href) => href, locales: ["en-US"], log: {} as Log, params: {}, + chrome: CHROME, declaredPermissions: [], declaredSettings: [], user: opts.user ?? null, locale: "en-US", localeHref: (href) => href, locales: ["en-US"], log: {} as Log, params: {}, query: url.searchParams, req, res: {} as ServerResponse, permissions: opts.user?.permissions ?? [], t: ADMIN_EN, url, verifyCsrf: opts.verifyCsrf ?? (() => true), }; @@ -27,21 +27,21 @@ function fakeCtx(opts: { body?: string; method?: string; user?: User | null; ver // ---- nav fragment ---- -test("ADMIN_NAV: an ungated Admin header whose three screens each gate on their own read permission", () => { +test("ADMIN_NAV: an ungated Admin header whose screens each gate on their own read permission", () => { assert.equal(ADMIN_NAV.id, "admin"); // No gate on the header: a user may hold one screen's permission and not another's. composeNav - // drops a header left with no visible children, so holding none of the three hides the section. + // drops a header left with no visible children, so holding none of them hides the section. // Both halves matter — give the header an `href` and it survives the filter as a visible leaf, // ungated, for anonymous visitors included. assert.equal(ADMIN_NAV.permission, undefined); assert.equal(ADMIN_NAV.href, undefined); assert.equal(ADMIN_NAV.open, undefined); // the host current-marks + opens; the fragment stays static - assert.deepEqual(ADMIN_NAV.children?.map((c) => c.href), ["/admin/users", "/admin/groups", "/admin/clients"]); - assert.deepEqual(ADMIN_NAV.children?.map((c) => c.permission), ["users:read", "groups:read", "oauth2-clients:read"]); + assert.deepEqual(ADMIN_NAV.children?.map((c) => c.href), ["/admin/users", "/admin/groups", "/admin/clients", "/admin/plugin-settings"]); + assert.deepEqual(ADMIN_NAV.children?.map((c) => c.permission), ["users:read", "groups:read", "oauth2-clients:read", "plugin-settings:read"]); // Labels are catalog keys; the host translates them with this plugin's catalog when it composes // the menu, so what a visitor sees is the en-US (or sv-SE …) wording behind these keys. - assert.deepEqual(ADMIN_NAV.children?.map((c) => c.label), ["admin.nav.users", "admin.nav.groups", "admin.nav.clients"]); - assert.deepEqual(ADMIN_NAV.children?.map((c) => ADMIN_EN(c.label)), ["Users", "Groups", "OAuth2 clients"]); + assert.deepEqual(ADMIN_NAV.children?.map((c) => c.label), ["admin.nav.users", "admin.nav.groups", "admin.nav.clients", "admin.nav.pluginSettings"]); + assert.deepEqual(ADMIN_NAV.children?.map((c) => ADMIN_EN(c.label)), ["Users", "Groups", "OAuth2 clients", "Plugin settings"]); assert.ok(ADMIN_NAV.children?.every((c) => c.current === undefined)); }); diff --git a/examples/plugins/admin/admin-shared.ts b/examples/plugins/admin/admin-shared.ts index ccb4517..97b28fd 100644 --- a/examples/plugins/admin/admin-shared.ts +++ b/examples/plugins/admin/admin-shared.ts @@ -12,12 +12,13 @@ export const ADMIN_EN: Translate = englishTranslator(enUS); export const ADMIN_USERS_BASE = "/admin/users"; export const ADMIN_GROUPS_BASE = "/admin/groups"; export const ADMIN_CLIENTS_BASE = "/admin/clients"; +export const ADMIN_PLUGIN_SETTINGS_BASE = "/admin/plugin-settings"; // One resource per screen — the `` half of every permission this plugin gates on. // `oauth2-clients` rather than `clients` because permission names are one global namespace. // There is no `permissions` resource: permissions are declared in plugin code, not created here, so // holding a grant is a property of a user or a group and is edited on those two screens. -export type AdminResource = "groups" | "oauth2-clients" | "users"; +export type AdminResource = "groups" | "oauth2-clients" | "plugin-settings" | "users"; export type AdminAction = "read" | "write"; @@ -43,6 +44,7 @@ export const ADMIN_NAV: NavNode = { { href: ADMIN_USERS_BASE, icon: "i-users", id: "users", label: "admin.nav.users", permission: permissionName("users", "read") }, { href: ADMIN_GROUPS_BASE, icon: "i-layers", id: "groups", label: "admin.nav.groups", permission: permissionName("groups", "read") }, { href: ADMIN_CLIENTS_BASE, icon: "i-globe", id: "clients", label: "admin.nav.clients", permission: permissionName("oauth2-clients", "read") }, + { href: ADMIN_PLUGIN_SETTINGS_BASE, icon: "i-sliders", id: "plugin-settings", label: "admin.nav.pluginSettings", permission: permissionName("plugin-settings", "read") }, ], icon: "i-shield", id: "admin", diff --git a/examples/plugins/admin/i18n/en-US.ts b/examples/plugins/admin/i18n/en-US.ts index 666f6ff..c885ce6 100644 --- a/examples/plugins/admin/i18n/en-US.ts +++ b/examples/plugins/admin/i18n/en-US.ts @@ -83,9 +83,29 @@ const messages = { "admin.nav.clients": "OAuth2 clients", "admin.nav.groups": "Groups", + "admin.nav.pluginSettings": "Plugin settings", "admin.nav.section": "Admin", "admin.nav.users": "Users", + "admin.pluginSettings.caption": "Settings declared by {{plugin}}", + "admin.pluginSettings.column.description": "Description", + "admin.pluginSettings.column.key": "Key", + "admin.pluginSettings.column.required": "Required", + "admin.pluginSettings.column.source": "Source", + "admin.pluginSettings.column.type": "Type", + "admin.pluginSettings.column.value": "Value", + "admin.pluginSettings.column.variable": "Variable", + "admin.pluginSettings.no": "No", + "admin.pluginSettings.none": "This plugin declares no settings.", + "admin.pluginSettings.secretSet": "Secret — set", + "admin.pluginSettings.secretUnset": "Secret — not set", + "admin.pluginSettings.source.default": "Default", + "admin.pluginSettings.source.env": "Environment", + "admin.pluginSettings.source.unset": "Not set", + "admin.pluginSettings.title": "Plugin settings", + "admin.pluginSettings.unset": "—", + "admin.pluginSettings.yes": "Yes", + "admin.notFound.message": "That item doesn't exist.", "admin.notFound.title": "Not found", diff --git a/examples/plugins/admin/i18n/sv-SE.ts b/examples/plugins/admin/i18n/sv-SE.ts index f0224af..03b2348 100644 --- a/examples/plugins/admin/i18n/sv-SE.ts +++ b/examples/plugins/admin/i18n/sv-SE.ts @@ -83,9 +83,29 @@ const messages: AdminMessages = { "admin.nav.clients": "OAuth2-klienter", "admin.nav.groups": "Grupper", + "admin.nav.pluginSettings": "Tilläggsinställningar", "admin.nav.section": "Administration", "admin.nav.users": "Användare", + "admin.pluginSettings.caption": "Inställningar som {{plugin}} deklarerar", + "admin.pluginSettings.column.description": "Beskrivning", + "admin.pluginSettings.column.key": "Nyckel", + "admin.pluginSettings.column.required": "Obligatorisk", + "admin.pluginSettings.column.source": "Källa", + "admin.pluginSettings.column.type": "Typ", + "admin.pluginSettings.column.value": "Värde", + "admin.pluginSettings.column.variable": "Variabel", + "admin.pluginSettings.no": "Nej", + "admin.pluginSettings.none": "Det här tillägget deklarerar inga inställningar.", + "admin.pluginSettings.secretSet": "Hemlighet — satt", + "admin.pluginSettings.secretUnset": "Hemlighet — inte satt", + "admin.pluginSettings.source.default": "Standardvärde", + "admin.pluginSettings.source.env": "Miljövariabel", + "admin.pluginSettings.source.unset": "Inte satt", + "admin.pluginSettings.title": "Tilläggsinställningar", + "admin.pluginSettings.unset": "—", + "admin.pluginSettings.yes": "Ja", + "admin.notFound.message": "Objektet finns inte.", "admin.notFound.title": "Hittades inte", diff --git a/examples/plugins/admin/plugin.test.ts b/examples/plugins/admin/plugin.test.ts index 955f33d..4ad6c38 100644 --- a/examples/plugins/admin/plugin.test.ts +++ b/examples/plugins/admin/plugin.test.ts @@ -35,17 +35,20 @@ test("every nav permission is one the manifest declares", () => { } }; walk(manifest.nav); - assert.equal(navPermissions.length, 3); + assert.equal(navPermissions.length, 4); for (const name of navPermissions) assert.ok(declared.includes(name), `nav gates on undeclared ${name}`); }); test("every declared permission is :, and reads and writes are split per resource", () => { for (const name of declared) assert.ok(isValidPermissionName(name), name); // the host's rule, not a copy of it - // Three screens × read/write. There is deliberately no `permissions:` pair: permissions are - // declared in plugin code, so holding one is edited on the user or group that holds it. + // Three CRUD screens × read/write, plus read-only plugin settings — a screen that never writes + // declares no `:write`, since a permission nothing gates on is one an operator can only mis-grant. + // There is deliberately no `permissions:` pair either: permissions are declared in plugin code, so + // holding one is edited on the user or group that holds it. assert.deepEqual([...declared].sort(), [ "groups:read", "groups:write", "oauth2-clients:read", "oauth2-clients:write", + "plugin-settings:read", "users:read", "users:write", ]); }); @@ -58,5 +61,5 @@ test("GET routes gate on read and mutations on write, so a reader can open a scr const action = route.method === "GET" && !writeIntent(route.path) ? "read" : "write"; assert.ok(route.permission?.endsWith(`:${action}`), `${route.method} ${route.path} → ${route.permission}`); } - assert.equal(routes.filter((r) => r.method === "GET" && writeIntent(r.path)).length, 6); // 2 per screen + assert.equal(routes.filter((r) => r.method === "GET" && writeIntent(r.path)).length, 6); // 2 per CRUD screen; plugin settings has none }); diff --git a/examples/plugins/admin/plugin.ts b/examples/plugins/admin/plugin.ts index ece09d0..baf3f4a 100644 --- a/examples/plugins/admin/plugin.ts +++ b/examples/plugins/admin/plugin.ts @@ -7,6 +7,7 @@ import { definePlugin, type HttpMethod, type Route, type RouteHandler } from "@plainpages/plugin-api"; import { clientsCreate, clientsDeleteConfirm, clientsDelete, clientsDetail, clientsList, clientsNewForm } from "./admin-clients.ts"; import { groupsAddMember, groupsCreate, groupsDelete, groupsDeleteConfirm, groupsDetail, groupsList, groupsNewForm, groupsPermissions, groupsRemoveMember } from "./admin-groups.ts"; +import { pluginSettingsList } from "./admin-plugin-settings.ts"; import { usersCreate, usersDeleteConfirm, usersDelete, usersEditForm, usersList, usersNewForm, usersPermissions, usersRecovery, usersState, usersUpdate } from "./admin-users.ts"; import { ADMIN_NAV, actionForMethod, type AdminAction, type AdminResource, permissionName } from "./admin-shared.ts"; @@ -24,9 +25,10 @@ const on = (resource: AdminResource) => (method: HttpMethod, path: string, handl const users = on("users"); const groups = on("groups"); const clients = on("oauth2-clients"); +const pluginSettings = on("plugin-settings"); export default definePlugin({ - apiVersion: "0.1.0", // the host contract this was built against — a literal, never HOST_API_VERSION + apiVersion: "0.2.0", // the host contract this was built against — a literal, never HOST_API_VERSION nav: [ADMIN_NAV], @@ -37,6 +39,7 @@ export default definePlugin({ { description: "Create and delete groups, and change their members and permissions", name: "groups:write" }, { description: "View OAuth2 clients", name: "oauth2-clients:read" }, { description: "Register and delete OAuth2 clients", name: "oauth2-clients:write" }, + { description: "View the settings each installed plugin declares, and how they resolved", name: "plugin-settings:read" }, ], routes: [ @@ -68,5 +71,7 @@ export default definePlugin({ clients("GET", "/clients/:id", clientsDetail), clients("GET", "/clients/:id/delete", clientsDeleteConfirm, "write"), clients("POST", "/clients/:id/delete", clientsDelete), + // Plugin settings — read-only, so no :write route and no write-intent GET. + pluginSettings("GET", "/plugin-settings", pluginSettingsList), ], }); diff --git a/examples/plugins/admin/views/plugin-settings.ejs b/examples/plugins/admin/views/plugin-settings.ejs new file mode 100644 index 0000000..9bff5e8 --- /dev/null +++ b/examples/plugins/admin/views/plugin-settings.ejs @@ -0,0 +1,24 @@ +<%# + Plugin settings admin list: one section per installed plugin, each a table of what it declares + and how each key resolved (admin-plugin-settings.ts). Read-only — no actions, no forms. +%><% + const nav = include("partials/nav-tree", { nodes: chrome.nav }); + let body = ""; + for (const group of model.groups) { + // A plugin id is the folder name, which discovery constrains to [a-z0-9-] — no escaping needed. + body += '

' + group.pluginId + "

"; + body += group.table.rows.length === 0 + ? '

' + group.emptyText + "

" + : include("partials/data-table", group.table); + } +-%> +<%- include("partials/shell", { + body, + brand: chrome.brand, + breadcrumbs: model.breadcrumbs, + csrfToken: chrome.csrfToken, + nav, + theme: chrome.theme, + title: model.title, + user: chrome.user, +}) %> diff --git a/examples/plugins/scheduling/README.md b/examples/plugins/scheduling/README.md index 44894db..50c623d 100644 --- a/examples/plugins/scheduling/README.md +++ b/examples/plugins/scheduling/README.md @@ -27,7 +27,7 @@ The plugin holds **no state** — data lives upstream (README → *Stateless*). ## Upstream -Set `SCHEDULING_UPSTREAM` to your backend's base URL. The dev compose points it at a tiny in-memory +Set `PLUGIN_SETTING_SCHEDULING_UPSTREAM` to your backend's base URL. The dev compose points it at a tiny in-memory mock (`examples/shifts-upstream/`) so `docker compose up` shows the plugin working out of the box. A malformed/non-http URL fails the boot loudly (the plugin's `onBoot` hook). diff --git a/examples/plugins/scheduling/plugin.ts b/examples/plugins/scheduling/plugin.ts index 9b3cb1a..76f3972 100644 --- a/examples/plugins/scheduling/plugin.ts +++ b/examples/plugins/scheduling/plugin.ts @@ -3,19 +3,20 @@ // folder, rename it, point it at your own backend. Full contract: README.md → Building plugins. import { definePlugin } from "@plainpages/plugin-api"; -import { assertHttpUrl, createShift, createUpstream, listShifts, newShiftForm, overview, READ, SCHEDULING_PATH, SHIFTS_PATH, WRITE } from "./shifts.ts"; +import { createShift, createUpstream, listShifts, newShiftForm, overview, READ, SCHEDULING_PATH, SHIFTS_PATH, WRITE } from "./shifts.ts"; // The upstream this plugin reads/writes — a stand-in for your real backend (the plugin is -// stateless). Configure via env; the dev compose points it at a tiny mock (examples/shifts-upstream). -const upstreamUrl = process.env["SCHEDULING_UPSTREAM"] ?? "http://shifts-upstream:4000"; -const upstream = createUpstream(upstreamUrl); +// stateless). Its URL is a declared setting, so it is resolved and validated before onBoot hands it +// over — which is after this manifest is built, hence the getter. +let upstreamUrl = ""; +const upstream = createUpstream(() => upstreamUrl); export default definePlugin({ - apiVersion: "0.1.0", // the host contract this was built against — a literal, never HOST_API_VERSION + apiVersion: "0.2.0", // the host contract this was built against — a literal, never HOST_API_VERSION - // onBoot runs after discovery, before the server listens: validate the plugin's own config so a - // typo'd SCHEDULING_UPSTREAM fails the boot loudly instead of degrading every request later. - hooks: { onBoot: () => assertHttpUrl(upstreamUrl, "SCHEDULING_UPSTREAM") }, + // onBoot runs after discovery, before the server listens — where a plugin receives its resolved + // settings. A malformed URL already failed the boot by then; the host validated the declared type. + hooks: { onBoot: ({ settings }) => { upstreamUrl = settings.upstream; } }, // Merged into the global menu + filtered per user. Labels are keys in this plugin's own catalog // (i18n/.ts) — a plain string works too, it just isn't translated. "Overview" is `public`, so the "Scheduling" @@ -45,4 +46,15 @@ export default definePlugin({ { handler: newShiftForm(), method: "GET", path: "/shifts/new", permission: WRITE }, { handler: createShift(upstream), method: "POST", path: "/shifts", permission: WRITE }, ], + + // Operator-supplied config: one PLUGIN_SETTING_SCHEDULING_UPSTREAM variable, validated as a URL at + // boot. The default points at the mock backend the dev compose runs (examples/shifts-upstream). + settings: [ + { + default: "http://shifts-upstream:4000", + description: "Base URL of the backend this plugin reads shifts from and writes them to", + key: "upstream", + type: "url", + }, + ], }); diff --git a/examples/plugins/scheduling/shifts.test.ts b/examples/plugins/scheduling/shifts.test.ts index e641b66..9e43918 100644 --- a/examples/plugins/scheduling/shifts.test.ts +++ b/examples/plugins/scheduling/shifts.test.ts @@ -7,7 +7,7 @@ import test from "node:test"; import { englishTranslator, GuardError, Log, type PageChrome, type RequestContext, type RouteResult } from "@plainpages/plugin-api"; import enUS from "./i18n/en-US.ts"; import { - assertHttpUrl, buildFormModel, createShift, createUpstream, listShifts, newShiftForm, overview, readInput, + buildFormModel, createShift, createUpstream, listShifts, newShiftForm, overview, readInput, SHIFTS_PATH, type Shift, type ShiftInput, type ShiftsUpstream, UpstreamError, validate, } from "./shifts.ts"; @@ -18,7 +18,7 @@ function fakeCtx(opts: { body?: string; permissions?: string[]; url?: string; ve const url = new URL(opts.url ?? "http://localhost/scheduling/shifts"); const req = Readable.from(opts.body != null ? [Buffer.from(opts.body)] : []) as unknown as IncomingMessage; return { - chrome: CHROME, declaredPermissions: [], user: null, locale: "en-US", localeHref: (href) => href, locales: ["en-US"], log: new Log("none"), params: {}, + chrome: CHROME, declaredPermissions: [], declaredSettings: [], user: null, locale: "en-US", localeHref: (href) => href, locales: ["en-US"], log: new Log("none"), params: {}, query: url.searchParams, req, res: {} as ServerResponse, permissions: opts.permissions ?? [], t, url, verifyCsrf: opts.verifyCsrf ?? (() => true), }; @@ -35,27 +35,25 @@ const asView = (r: RouteResult | void) => { return r as { data: Record; status?: number; view: string }; }; -// ---- upstream config validation (the onBoot hook) ---- +// ---- the upstream URL as a declared setting ---- -test("assertHttpUrl accepts http(s) and fails loud on a malformed or non-http upstream URL", () => { - assert.doesNotThrow(() => assertHttpUrl("http://shifts-upstream:4000", "SCHEDULING_UPSTREAM")); - assert.doesNotThrow(() => assertHttpUrl("https://api.example.com/v1", "SCHEDULING_UPSTREAM")); - assert.throws(() => assertHttpUrl("not a url", "SCHEDULING_UPSTREAM"), /SCHEDULING_UPSTREAM.*valid URL/); // unparseable - assert.throws(() => assertHttpUrl("shifts-upstream:4000", "SCHEDULING_UPSTREAM"), /SCHEDULING_UPSTREAM.*http/); // missing // → parsed as a bogus scheme - assert.throws(() => assertHttpUrl("ftp://host/x", "SCHEDULING_UPSTREAM"), /SCHEDULING_UPSTREAM.*http/); // wrong scheme +test("the manifest declares its upstream as a URL setting the host validates", async () => { + const manifest = (await import("./plugin.ts")).default; + assert.deepEqual(manifest.settings?.map((s) => s.key), ["upstream"]); + assert.equal(manifest.settings?.[0]?.type, "url"); // so a typo'd URL fails the boot, not every request + assert.equal(manifest.settings?.[0]?.default, "http://shifts-upstream:4000"); // the dev compose's mock + assert.equal(typeof manifest.hooks?.onBoot, "function"); // without it the resolved value never arrives }); -test("the manifest's onBoot hook validates SCHEDULING_UPSTREAM (the binding, not just the helper)", async () => { - const prev = process.env["SCHEDULING_UPSTREAM"]; - process.env["SCHEDULING_UPSTREAM"] = "nope://bad"; // read at import time below - try { - const manifest = (await import("./plugin.ts")).default; - assert.equal(typeof manifest.hooks?.onBoot, "function"); - assert.throws(() => manifest.hooks!.onBoot!({}), /SCHEDULING_UPSTREAM/); // bad upstream → boot fails loud - } finally { - if (prev === undefined) delete process.env["SCHEDULING_UPSTREAM"]; - else process.env["SCHEDULING_UPSTREAM"] = prev; - } +test("the client re-reads its base URL, so onBoot can bind it after the manifest is built", async () => { + let baseUrl = "http://first:4000"; + const seen: string[] = []; + const http = (async (url) => { seen.push(String(url)); return new Response("[]", { status: 200 }); }) as typeof fetch; + const upstream = createUpstream(() => baseUrl, http); + await upstream.list(); + baseUrl = "http://second:4000"; + await upstream.list(); + assert.deepEqual(seen, ["http://first:4000/shifts", "http://second:4000/shifts"]); }); // ---- upstream client (fetch injected) ---- @@ -67,21 +65,21 @@ test("createUpstream.list fetches /shifts, asks for JSON, and maps the rows", as assert.equal((init?.headers as Record).accept, "application/json"); return new Response(JSON.stringify([{ assignee: "A", end: "2", id: "x", start: "1", title: "T", extra: "ignored" }]), { status: 200 }); }) as typeof fetch; - const shifts = await createUpstream("http://up:4000/", http).list(); // trailing slash trimmed + const shifts = await createUpstream(() => "http://up:4000/", http).list(); // trailing slash trimmed assert.equal(seen, "http://up:4000/shifts"); assert.deepEqual(shifts, [{ assignee: "A", end: "2", id: "x", start: "1", title: "T" }]); }); test("createUpstream throws UpstreamError carrying the status on a non-2xx", async () => { const http = (async () => new Response("nope", { status: 503 })) as typeof fetch; - await assert.rejects(createUpstream("http://up:4000", http).list(), (e: unknown) => e instanceof UpstreamError && e.status === 503); + await assert.rejects(createUpstream(() => "http://up:4000", http).list(), (e: unknown) => e instanceof UpstreamError && e.status === 503); }); test("createUpstream.create POSTs the input as JSON", async () => { let body: unknown, method = ""; const http = (async (_url, init) => { method = init?.method ?? ""; body = JSON.parse(String(init?.body)); return new Response(null, { status: 201 }); }) as typeof fetch; const input: ShiftInput = { assignee: "A", end: "2", start: "1", title: "T" }; - await createUpstream("http://up:4000", http).create(input); + await createUpstream(() => "http://up:4000", http).create(input); assert.equal(method, "POST"); assert.deepEqual(body, input); }); diff --git a/examples/plugins/scheduling/shifts.ts b/examples/plugins/scheduling/shifts.ts index 7df69a0..2ba44f9 100644 --- a/examples/plugins/scheduling/shifts.ts +++ b/examples/plugins/scheduling/shifts.ts @@ -49,26 +49,16 @@ export interface ShiftsUpstream { list(): Promise; } -// Fail loud at boot (the plugin's onBoot hook) on a malformed/non-http upstream URL — a config -// typo surfaces at startup, not as a degraded page later. Reachability stays a runtime concern. -export function assertHttpUrl(value: string, name: string): void { - let url: URL; - try { - url = new URL(value); - } catch { - throw new Error(`${name} is not a valid URL: ${JSON.stringify(value)}`); - } - if (url.protocol !== "http:" && url.protocol !== "https:") throw new Error(`${name} must be an http(s) URL: ${JSON.stringify(value)}`); -} - // REST client over the upstream service (a stand-in for the customer's real backend). `fetch` // defaults to the host's tracedFetch, so each upstream call joins the request's trace (a client // span + a propagated traceparent); it's injectable so handlers unit-test against a mock, no network. -export function createUpstream(baseUrl: string, fetchImpl: typeof fetch = tracedFetch): ShiftsUpstream { - const base = baseUrl.replace(/\/+$/, ""); +// `baseUrl` is read per call: the plugin's settings arrive on onBoot, after the manifest that binds +// these handlers has already been built. +export function createUpstream(baseUrl: () => string, fetchImpl: typeof fetch = tracedFetch): ShiftsUpstream { + const base = (): string => baseUrl().replace(/\/+$/, ""); return { async create(input) { - const res = await fetchImpl(`${base}/shifts`, { + const res = await fetchImpl(`${base()}/shifts`, { body: JSON.stringify(input), headers: { "content-type": "application/json" }, method: "POST", @@ -76,7 +66,7 @@ export function createUpstream(baseUrl: string, fetchImpl: typeof fetch = traced if (!res.ok) throw new UpstreamError(`create shift failed (${res.status})`, res.status); }, async list() { - const res = await fetchImpl(`${base}/shifts`, { headers: { accept: "application/json" } }); + const res = await fetchImpl(`${base()}/shifts`, { headers: { accept: "application/json" } }); if (!res.ok) throw new UpstreamError(`list shifts failed (${res.status})`, res.status); const data: unknown = await res.json(); return Array.isArray(data) ? data.map(toShift) : []; diff --git a/examples/shifts-upstream/server.ts b/examples/shifts-upstream/server.ts index 62d3f0d..c769d4f 100644 --- a/examples/shifts-upstream/server.ts +++ b/examples/shifts-upstream/server.ts @@ -1,6 +1,6 @@ // Dev-only mock upstream for the reference plugin (examples/plugins/scheduling) — a stand-in for the // customer's real backend, ready for when you copy the reference plugin into plugins/. NOT part -// of the app: stdlib only, in-memory (state resets on restart), no auth. Point SCHEDULING_UPSTREAM +// of the app: stdlib only, in-memory (state resets on restart), no auth. Point PLUGIN_SETTING_SCHEDULING_UPSTREAM // at your real service in production. // // GET /shifts → 200 [ { id, title, assignee, start, end }, … ] diff --git a/release-tooling/contract-version.test.ts b/release-tooling/contract-version.test.ts index 8f3d780..356b4e9 100644 --- a/release-tooling/contract-version.test.ts +++ b/release-tooling/contract-version.test.ts @@ -12,7 +12,7 @@ test("readHostApiVersion pulls the constant out of the real source, and returns test("bumping HOST_API_VERSION is a deliberate act, so pin the shipped value", () => { // Not a substitute for the release gate — this test cannot see a tag. It is the tripwire that // makes an accidental edit fail here rather than at release time. - assert.equal(readHostApiVersion(readFileSync("src/plugin-host/plugin.ts", "utf8")), "0.1.0"); + assert.equal(readHostApiVersion(readFileSync("src/plugin-host/plugin.ts", "utf8")), "0.2.0"); }); test("every author-facing apiVersion sample matches the shipped contract", () => { diff --git a/release-tooling/dockerhub-overview.md.tmpl b/release-tooling/dockerhub-overview.md.tmpl index c38c5e7..816113c 100644 --- a/release-tooling/dockerhub-overview.md.tmpl +++ b/release-tooling/dockerhub-overview.md.tmpl @@ -182,7 +182,7 @@ into the app. Create `plugins/hello/plugin.ts`: import { definePlugin } from "@plainpages/plugin-api"; export default definePlugin({ - apiVersion: "0.1.0", + apiVersion: "0.2.0", nav: [{ href: "/hello", id: "hello", label: "Hello", public: true }], routes: [ { method: "GET", path: "/", public: true, handler: () => ({ html: "

Hello from my plugin

" }) }, diff --git a/src/config.ts b/src/config.ts index c46f622..dcceb5d 100644 --- a/src/config.ts +++ b/src/config.ts @@ -56,6 +56,7 @@ export interface Config { pluginDbSecret: string; // derives each plugin's database password (src/plugin-host/storage.ts) pluginDbUrl: string | undefined; // credential-free Postgres base URL; unset ⇒ plugin storage is off port: number; + requireSecureSecrets: boolean; // enforce real secrets — the host's own, and every plugin's declared `secret` revocationDenylist: boolean; // enable the optional instant permission/session revoke denylist revocationTtlSec: number; // how long a revoke entry lives; keep ≥ tokenizer TTL + clock skew secureCookies: boolean; @@ -188,6 +189,7 @@ export function loadConfig(env: Env = process.env): Config { pluginDbSecret: resolvePluginDbSecret(env, requireSecure && Boolean(env["PLUGIN_DB_URL"])), pluginDbUrl: readCredentiallessUrl(env, "PLUGIN_DB_URL"), port: readPort(env), + requireSecureSecrets: requireSecure, // Optional instant-revoke, off by default. When on, an admin deactivate/delete or permission // change revokes the subject's live tokens at once; the entry lives ttl seconds (≥ the 10m // tokenizer TTL + skew, so it outlasts any pre-revoke token). diff --git a/src/http/app.ts b/src/http/app.ts index 0314f04..39d57f1 100644 --- a/src/http/app.ts +++ b/src/http/app.ts @@ -26,6 +26,7 @@ import { createLogger, type Log, requestLogger, runWithLog } from "../logger.ts" import { remintSession } from "../auth/login.ts"; import { DEFAULT_MENU, type MenuConfig } from "../ui/menu-config.ts"; import { declaredPermissions, type Plugin, type RouteHandler, type RouteResult } from "../plugin-host/plugin.ts"; +import type { PluginSettings } from "../plugin-host/settings.ts"; import type { SystemCapabilities } from "../plugin-host/system.ts"; import { allowedMethods, isAuthorized, matchRoute } from "../plugin-host/router.ts"; import { buildAuthRoutes } from "../auth/routes.ts"; @@ -54,6 +55,7 @@ export interface AppOptions { pluginsDir?: string; // where plugin views/static live; defaults to the scanned plugins/ publicDir?: string; secureCookies?: boolean; // set Secure on our session/CSRF cookies (config.secureCookies; off in dev http) + settingsCatalog?: readonly PluginSettings[]; // resolved at boot (server.ts, needs the env); → ctx.declaredSettings viewsDir?: string; } @@ -88,6 +90,7 @@ export function createApp(options: AppOptions = {}): Server { const homePlugin = plugins.find((p): p is Plugin & { home: RouteHandler } => typeof p.home === "function"); const dashboardPlugin = plugins.find((p): p is Plugin & { dashboard: RouteHandler } => typeof p.dashboard === "function"); const permissionCatalog = declaredPermissions(plugins); + const settingsCatalog = options.settingsCatalog ?? []; // Skip the hook pipeline entirely unless a plugin declares the hook (keeps the hot path free). const anyRequestHooks = plugins.some((p) => p.hooks?.onRequest); const anyResponseHooks = plugins.some((p) => p.hooks?.onResponse); @@ -259,9 +262,9 @@ export function createApp(options: AppOptions = {}): Server { // Base context (no route params), for the built-in routes. Every plugin-owned render — a // landing slot, a hook short-circuit, a plugin route — gets `contextFor(id)` instead. - const ctx = buildContext(req, res, { chrome, declaredPermissions: permissionCatalog, user, ...i18nFor(), log: reqLog, verifyCsrf, ...(system ? { system } : {}) }); + const ctx = buildContext(req, res, { chrome, declaredPermissions: permissionCatalog, declaredSettings: settingsCatalog, user, ...i18nFor(), log: reqLog, verifyCsrf, ...(system ? { system } : {}) }); const contextFor = (pluginId: string, params?: Record): RequestContext => - buildContext(req, res, { chrome, declaredPermissions: permissionCatalog, user, ...i18nFor(pluginId), log: reqLog, ...(params ? { params } : {}), verifyCsrf, ...(system ? { system } : {}) }); + buildContext(req, res, { chrome, declaredPermissions: permissionCatalog, declaredSettings: settingsCatalog, user, ...i18nFor(pluginId), log: reqLog, ...(params ? { params } : {}), verifyCsrf, ...(system ? { system } : {}) }); renderPage = viewsFor(ctx); // Plugin onRequest hooks run before routing and may short-circuit the request. diff --git a/src/http/context.ts b/src/http/context.ts index befd0bf..62c988a 100644 --- a/src/http/context.ts +++ b/src/http/context.ts @@ -1,6 +1,7 @@ import type { IncomingMessage, ServerResponse } from "node:http"; import type { PageChrome } from "../ui/chrome.ts"; // type-only: no runtime import, so no cycle import type { PermissionDecl } from "../plugin-host/plugin.ts"; // type-only +import type { PluginSettings } from "../plugin-host/settings.ts"; // type-only import type { SystemCapabilities } from "../plugin-host/system.ts"; // type-only import { DEFAULT_LOCALE } from "../i18n/catalog.ts"; import { ENGLISH } from "../i18n/english.ts"; @@ -42,6 +43,9 @@ export interface RequestContext { // screen offers when granting one. Pairs with `permissions` below: this is what *exists*, that is // what *this user holds*. Empty when no installed plugin declares any. declaredPermissions: readonly PermissionDecl[]; + // What each installed plugin declares it can be configured with, and how each key resolved — one + // entry per plugin, including those declaring nothing. A secret's value is never carried here. + declaredSettings: readonly PluginSettings[]; params: Record; // path params from the route match, e.g. /users/:id → { id } permissions: string[]; // user?.permissions ?? [] — coarse gate without a null-check query: URLSearchParams; // alias of url.searchParams, for ctx.query.get("q") @@ -67,6 +71,7 @@ export interface BuildContextOptions { // The host's factory is memoised, so the menu composes at most once per request across contexts. chrome?: () => PageChrome; declaredPermissions?: readonly PermissionDecl[]; + declaredSettings?: readonly PluginSettings[]; user?: User | null; locale?: string; localeHref?: (href: string) => string; @@ -96,6 +101,7 @@ export function buildContext( return { get chrome(): PageChrome { return (chromeMemo ??= buildChrome ? buildChrome() : ANON_CHROME); }, declaredPermissions: options.declaredPermissions ?? [], + declaredSettings: options.declaredSettings ?? [], user, locale: options.locale ?? DEFAULT_LOCALE, localeHref: options.localeHref ?? ((href) => href), diff --git a/src/plugin-host/discovery.ts b/src/plugin-host/discovery.ts index d4e19c9..660d603 100644 --- a/src/plugin-host/discovery.ts +++ b/src/plugin-host/discovery.ts @@ -8,6 +8,7 @@ import { existsSync, readdirSync, readFileSync } from "node:fs"; import { dirname, join } from "node:path"; import { fileURLToPath, pathToFileURL } from "node:url"; import { checkApiVersion, findConflicts, isValidPermissionName, isValidPluginId, RESERVED_PLUGIN_IDS, type Plugin, type PluginManifest } from "./plugin.ts"; +import { settingsDeclError } from "./settings.ts"; import { isValidStoragePluginId, MAX_STORAGE_PLUGIN_ID_LENGTH } from "./storage.ts"; const rootDir = join(dirname(fileURLToPath(import.meta.url)), "..", ".."); @@ -141,6 +142,10 @@ function shapeError(manifest: PluginManifest): string | null { } // A truthy non-boolean (a DSN, say) must not quietly read as "provision me one". if (manifest.storage !== undefined && typeof manifest.storage !== "boolean") return `"storage" must be a boolean`; + if (manifest.settings !== undefined) { + const settings = settingsDeclError(manifest.settings); + if (settings) return settings; + } // `public` and `permission` are contradictory on the same route/nav node — "open to all" vs // "needs this permission". Refuse rather than silently pick one, so the author's intent is unambiguous. for (const route of Array.isArray(manifest.routes) ? manifest.routes : []) { diff --git a/src/plugin-host/plugin-api.ts b/src/plugin-host/plugin-api.ts index 1777480..03d1ec7 100644 --- a/src/plugin-host/plugin-api.ts +++ b/src/plugin-host/plugin-api.ts @@ -8,6 +8,7 @@ export { definePlugin, isValidPermissionName } from "./plugin.ts"; export type { BootContext, HttpMethod, Plugin, PluginHooks, PluginManifest, PermissionDecl, Route, RouteHandler, RouteResult } from "./plugin.ts"; // A plugin's own database, handed to onBoot when the manifest sets `storage`. Credentials, not a // client — the plugin depends on whichever driver it prefers (README → Plugin storage). +export type { PluginSettings, SettingDecl, SettingSummary, SettingType, SettingValue } from "./settings.ts"; export type { StorageCredentials } from "./storage.ts"; export type { RequestContext, User } from "../http/context.ts"; export type { PageChrome } from "../ui/chrome.ts"; diff --git a/src/plugin-host/plugin.ts b/src/plugin-host/plugin.ts index 2fed523..7d45f13 100644 --- a/src/plugin-host/plugin.ts +++ b/src/plugin-host/plugin.ts @@ -6,10 +6,11 @@ import type { RequestContext } from "../http/context.ts"; import type { NavNode } from "../ui/nav.ts"; +import { envName, type SettingDecl, type SettingsOf } from "./settings.ts"; import type { StorageCredentials } from "./storage.ts"; // The Plainpages release this contract ships in — see README → Contract versioning. -export const HOST_API_VERSION = "0.1.0"; +export const HOST_API_VERSION = "0.2.0"; export type HttpMethod = "DELETE" | "GET" | "HEAD" | "PATCH" | "POST" | "PUT"; @@ -62,30 +63,39 @@ export function declaredPermissions(plugins: Plugin[]): PermissionDecl[] { } // What onBoot receives. A hook declaring no parameter stays valid, so this may grow additively. -export interface BootContext { +export type BootContext = { storage?: StorageCredentials; // this plugin's own database; present iff the manifest declared `storage` -} +} & SettingsSlot; + +// Required once the manifest declares settings, so that plugin reads `settings.key` without a guard +// for the case it just ruled out; optional for a manifest that declared none. +type SettingsSlot = readonly [] extends S + ? { settings?: SettingsOf } + : { settings: SettingsOf }; // Optional hooks on system actions. Crash-isolation is a non-goal — a throwing hook fails loud. -export interface PluginHooks { - onBoot?: (host: BootContext) => Promise | void; // after discovery, before the server listens +export interface PluginHooks { + onBoot?: (host: BootContext) => Promise | void; // after discovery, before the server listens onRequest?: (ctx: RequestContext) => Promise | RouteResult | void; // may short-circuit onResponse?: (ctx: RequestContext, result: RouteResult | null) => Promise | void; } // The authored manifest — a plugin's `plugin.ts` default-exports this. No `id`/mount path: the // host derives them from the folder name at discovery (see Plugin). -export interface PluginManifest { +export interface PluginManifest { apiVersion: string; // semver of the host contract this targets — write a literal, NOT HOST_API_VERSION (see docs) // Take over "/dashboard"; the host gates it to a signed-in session first. At most one plugin may // declare it (findConflicts → error, never last-write-wins). dashboard?: RouteHandler; // Take over the ungated public landing "/". At most one plugin may declare it. home?: RouteHandler; - hooks?: PluginHooks; + hooks?: PluginHooks; nav?: NavNode[]; // fragment merged into the menu (composeNav); node `icon` is a Lucide sprite id (src/ui/icons.ts), node ids must be globally unique permissions?: PermissionDecl[]; routes?: Route[]; + // Operator-supplied configuration, one PLUGIN_SETTING__ variable per key; the resolved + // values arrive on onBoot's BootContext, typed from these declarations (settings.ts). + settings?: S; // Ask for a Postgres database of this plugin's own; its credentials arrive on onBoot's BootContext. // The host provisions and locks it down but owns no schema inside it, and never drops it. storage?: boolean; @@ -99,7 +109,9 @@ export interface Plugin extends PluginManifest { // Types the manifest and returns it unchanged; validation happens at discovery, so a plugin may // equally be a plain typed object. -export function definePlugin(manifest: PluginManifest): PluginManifest { +// The `const` parameter captures the literal `settings`, so onBoot receives each key at its declared +// type instead of a union every plugin author would have to narrow with a cast. +export function definePlugin(manifest: PluginManifest): PluginManifest { return manifest; } @@ -168,7 +180,7 @@ export function checkApiVersion(pluginVersion: unknown, hostVersion: string = HO } export interface PluginConflict { - kind: "dashboard" | "home" | "id" | "nav-id" | "permission" | "route"; + kind: "dashboard" | "home" | "id" | "nav-id" | "permission" | "route" | "setting"; level: "error" | "warn"; message: string; plugins: string[]; // unique ids involved @@ -209,6 +221,14 @@ export function findConflicts(plugins: Plugin[]): PluginConflict[] { if (owners.length > 1) out.push({ kind: "permission", level: "warn", message: `permission "${name}" declared by ${uniq(owners).length} plugins; pick a more specific "" unless shared on purpose`, plugins: uniq(owners) }); }); + // Both the id's dashes and the key's camel humps become underscores, so plugin "a-b" key "c" and + // plugin "a" key "bC" name one variable — one plugin would silently read the other's value. + collect(plugins, (plugin, push) => { + for (const decl of plugin.settings ?? []) push(envName(plugin.id, decl.key)); + }).forEach((owners, name) => { + if (owners.length > 1) out.push({ kind: "setting", level: "error", message: `${owners.length} settings resolve to "${name}"; rename a key or a plugin folder`, plugins: uniq(owners) }); + }); + return out; } diff --git a/src/plugin-host/settings.test.ts b/src/plugin-host/settings.test.ts new file mode 100644 index 0000000..ee77e65 --- /dev/null +++ b/src/plugin-host/settings.test.ts @@ -0,0 +1,177 @@ +// Guards the plugin-settings rules: the env name a declaration maps to, per-type coercion, the +// required/default/secret resolution, and what the admin catalog is allowed to carry. +import { test } from "node:test"; +import assert from "node:assert/strict"; +import type { Plugin } from "./plugin.ts"; +import { + ENV_PREFIX, + envName, + isValidSettingKey, + resolveSettings, + settingsDeclError, + settingsEnvNames, + strayNames, + type SettingDecl, +} from "./settings.ts"; + +function pluginWith(id: string, settings: SettingDecl[]): Plugin { + return { apiVersion: "0.2.0", id, settings }; +} + +test("a key becomes one SCREAMING_SNAKE segment under the plugin's own", () => { + assert.equal(envName("scheduling", "timezone"), "PLUGIN_SETTING_SCHEDULING_TIMEZONE"); + assert.equal(envName("scheduling", "maxShiftHours"), "PLUGIN_SETTING_SCHEDULING_MAX_SHIFT_HOURS"); + assert.equal(envName("my-plugin", "apiBase"), "PLUGIN_SETTING_MY_PLUGIN_API_BASE"); + assert.equal(ENV_PREFIX, "PLUGIN_SETTING_"); // never bare PLUGIN_ — the host owns PLUGIN_DB_* +}); + +test("the host's own PLUGIN_DB_* variables are unreachable from a declaration", () => { + // A plugin id "db" with key "url" is exactly the collision the longer prefix rules out. + assert.notEqual(envName("db", "url"), "PLUGIN_DB_URL"); + assert.equal(envName("db", "url"), "PLUGIN_SETTING_DB_URL"); +}); + +test("a key is camelCase, so the env name is derivable and no two keys collide", () => { + assert.ok(isValidSettingKey("timezone")); + assert.ok(isValidSettingKey("maxShiftHours")); + assert.ok(!isValidSettingKey("max_shift_hours")); // would collide with maxShiftHours + assert.ok(!isValidSettingKey("MaxShiftHours")); + assert.ok(!isValidSettingKey("2fa")); + assert.ok(!isValidSettingKey("")); +}); + +test("a declaration is refused when it cannot mean what it says", () => { + const why = (settings: unknown): string => settingsDeclError(settings) ?? ""; + + assert.equal(settingsDeclError([{ key: "a", type: "string" }]), null); + assert.match(why("nope"), /must be an array/); + assert.match(why([{ key: "max_hours", type: "number" }]), /max_hours.*camelCase/); + assert.match(why([{ key: "a", type: "date" }]), /"date".*string, number, boolean, enum, url/); + assert.match(why([{ key: "a", type: "string" }, { key: "a", type: "number" }]), /declared twice/); + // required means "boot fails without it", so a default would make the flag a lie. + assert.match(why([{ default: "x", key: "a", required: true, type: "string" }]), /required.*default.*mutually exclusive/); + assert.match(why([{ default: 8, key: "a", type: "string" }]), /default.*string/); + assert.match(why([{ key: "a", type: "enum" }]), /enum.*values/); + assert.match(why([{ key: "a", type: "enum", values: [] }]), /enum.*values/); + assert.match(why([{ default: "c", key: "a", type: "enum", values: ["a", "b"] }]), /default "c".*a, b/); + assert.match(why([{ key: "a", type: "string", values: ["a"] }]), /values.*only.*enum/); +}); + +test("an unset optional setting resolves to undefined, not to a stand-in", () => { + const result = resolveSettings([pluginWith("p", [{ key: "a", type: "string" }])], {}); + assert.deepEqual(result.errors, []); + assert.equal(result.values.get("p")?.["a"], undefined); +}); + +test("a default fills in, and an env value overrides it", () => { + const plugins = [pluginWith("p", [{ default: 8, key: "maxHours", type: "number" }])]; + assert.equal(resolveSettings(plugins, {}).values.get("p")?.["maxHours"], 8); + assert.equal(resolveSettings(plugins, { PLUGIN_SETTING_P_MAX_HOURS: "12" }).values.get("p")?.["maxHours"], 12); +}); + +test("an empty variable reads as unset — compose passes an unset one through as \"\"", () => { + const plugins = [pluginWith("p", [{ default: "fallback", key: "a", type: "string" }])]; + assert.equal(resolveSettings(plugins, { PLUGIN_SETTING_P_A: "" }).values.get("p")?.["a"], "fallback"); + const required = [pluginWith("p", [{ key: "a", required: true, type: "string" }])]; + assert.match(resolveSettings(required, { PLUGIN_SETTING_P_A: "" }).errors.join("\n"), /must be set/); +}); + +test("a missing required setting is an error naming the plugin, the key and the variable", () => { + const result = resolveSettings([pluginWith("scheduling", [{ key: "timezone", required: true, type: "string" }])], {}); + assert.equal(result.errors.length, 1); + assert.match(result.errors[0] ?? "", /scheduling/); + assert.match(result.errors[0] ?? "", /timezone/); + assert.match(result.errors[0] ?? "", /PLUGIN_SETTING_SCHEDULING_TIMEZONE/); +}); + +test("each type coerces from the environment, and a bad value fails loud", () => { + const decls: SettingDecl[] = [ + { key: "text", type: "string" }, + { key: "count", type: "number" }, + { key: "flag", type: "boolean" }, + { key: "mode", type: "enum", values: ["strict", "lenient"] }, + { key: "base", type: "url" }, + ]; + const ok = resolveSettings([pluginWith("p", decls)], { + PLUGIN_SETTING_P_BASE: "https://example.com/v1", + PLUGIN_SETTING_P_COUNT: "42", + PLUGIN_SETTING_P_FLAG: "true", + PLUGIN_SETTING_P_MODE: "strict", + PLUGIN_SETTING_P_TEXT: "hello", + }); + assert.deepEqual(ok.errors, []); + assert.deepEqual(ok.values.get("p"), { base: "https://example.com/v1", count: 42, flag: true, mode: "strict", text: "hello" }); + + const bad = resolveSettings([pluginWith("p", decls)], { + PLUGIN_SETTING_P_BASE: "not a url", + PLUGIN_SETTING_P_COUNT: "twelve", + PLUGIN_SETTING_P_FLAG: "yes", + PLUGIN_SETTING_P_MODE: "loose", + }); + assert.equal(bad.errors.length, 4); + assert.match(bad.errors.join("\n"), /PLUGIN_SETTING_P_COUNT.*number/); + assert.match(bad.errors.join("\n"), /PLUGIN_SETTING_P_FLAG.*"true".*"false"/); + assert.match(bad.errors.join("\n"), /PLUGIN_SETTING_P_MODE.*strict, lenient/); + assert.match(bad.errors.join("\n"), /PLUGIN_SETTING_P_BASE.*URL/); +}); + +test("a boolean is only \"true\"/\"false\" — a typo never degrades to false", () => { + const plugins = [pluginWith("p", [{ default: true, key: "flag", type: "boolean" }])]; + assert.equal(resolveSettings(plugins, { PLUGIN_SETTING_P_FLAG: "false" }).values.get("p")?.["flag"], false); + assert.equal(resolveSettings(plugins, { PLUGIN_SETTING_P_FLAG: "0" }).errors.length, 1); +}); + +test("REQUIRE_SECURE_SECRETS refuses an unset secret and one still on its dev default", () => { + const decls: SettingDecl[] = [{ default: "dev-insecure", key: "apiKey", secret: true, type: "string" }]; + const plugins = [pluginWith("p", decls)]; + assert.deepEqual(resolveSettings(plugins, {}).errors, []); // off: the dev default boots a clean clone + + assert.match(resolveSettings(plugins, {}, { requireSecureSecrets: true }).errors.join("\n"), /apiKey.*must be set/); + assert.match( + resolveSettings(plugins, { PLUGIN_SETTING_P_API_KEY: "dev-insecure" }, { requireSecureSecrets: true }).errors.join("\n"), + /apiKey.*dev/, + ); + assert.deepEqual(resolveSettings(plugins, { PLUGIN_SETTING_P_API_KEY: "real" }, { requireSecureSecrets: true }).errors, []); +}); + +test("a secret's value reaches the plugin but never the catalog", () => { + const plugins = [pluginWith("p", [{ key: "apiKey", secret: true, type: "string" }])]; + const result = resolveSettings(plugins, { PLUGIN_SETTING_P_API_KEY: "s3cr3t" }); + assert.equal(result.values.get("p")?.["apiKey"], "s3cr3t"); + + const entry = result.catalog[0]?.settings[0]; + assert.equal(entry?.secret, true); + assert.equal(entry?.source, "env"); + assert.equal(entry?.value, undefined); // not the value, not its length, not a mask of it + assert.ok(!JSON.stringify(result.catalog).includes("s3cr3t")); +}); + +test("the catalog carries every installed plugin, so \"declares none\" is not \"not installed\"", () => { + const plugins = [pluginWith("with", [{ default: "x", key: "a", type: "string" }]), { apiVersion: "0.2.0", id: "without" }]; + const catalog = resolveSettings(plugins, {}).catalog; + assert.deepEqual(catalog.map((entry) => entry.pluginId), ["with", "without"]); + assert.deepEqual(catalog[1]?.settings, []); +}); + +test("a catalog entry carries the variable to set and where the value came from", () => { + const decls: SettingDecl[] = [ + { description: "Where shifts come from", key: "upstream", required: true, type: "url" }, + { default: 8, key: "maxHours", type: "number" }, + { key: "note", type: "string" }, + ]; + const catalog = resolveSettings([pluginWith("scheduling", decls)], { PLUGIN_SETTING_SCHEDULING_UPSTREAM: "https://x.test" }).catalog; + assert.deepEqual(catalog[0]?.settings, [ + { description: "Where shifts come from", envName: "PLUGIN_SETTING_SCHEDULING_UPSTREAM", key: "upstream", required: true, secret: false, source: "env", type: "url", value: "https://x.test" }, + { envName: "PLUGIN_SETTING_SCHEDULING_MAX_HOURS", key: "maxHours", required: false, secret: false, source: "default", type: "number", value: "8" }, + { envName: "PLUGIN_SETTING_SCHEDULING_NOTE", key: "note", required: false, secret: false, source: "unset", type: "string" }, + ]); +}); + +test("a variable no plugin declares is reported, never acted on", () => { + const declared = settingsEnvNames([pluginWith("scheduling", [{ key: "timezone", type: "string" }])]); + const strays = strayNames( + { PATH: "/usr/bin", PLUGIN_DB_URL: "postgres://x", PLUGIN_SETTING_GONE_KEY: "x", PLUGIN_SETTING_SCHEDULING_TIMEZOME: "UTC", PLUGIN_SETTING_SCHEDULING_TIMEZONE: "UTC" }, + declared, + ); + assert.deepEqual(strays, ["PLUGIN_SETTING_GONE_KEY", "PLUGIN_SETTING_SCHEDULING_TIMEZOME"]); // sorted; the host's own untouched +}); diff --git a/src/plugin-host/settings.ts b/src/plugin-host/settings.ts new file mode 100644 index 0000000..282ac73 --- /dev/null +++ b/src/plugin-host/settings.ts @@ -0,0 +1,259 @@ +// Per-plugin settings: the declaration shape, the env name it maps to, and the resolution rules +// (README → Plugin settings). Pure — server.ts passes `process.env` in, so the whole matrix is +// unit-testable without a stack. + +import type { Plugin } from "./plugin.ts"; + +// `PLUGIN_` alone would let a plugin id "db" with key "url" produce the host's own PLUGIN_DB_URL. +export const ENV_PREFIX = "PLUGIN_SETTING_"; + +export const SETTING_TYPES = ["string", "number", "boolean", "enum", "url"] as const; +export type SettingType = (typeof SETTING_TYPES)[number]; + +export type SettingValue = boolean | number | string; + +// What a manifest declares. `required` and `default` are mutually exclusive: a default means the +// setting can never fail resolution, which is the opposite of what required asserts. +export interface SettingDecl { + default?: SettingValue; + description?: string; + key: string; + required?: boolean; + secret?: boolean; // value reaches the plugin, never a log, an error or the catalog + type: SettingType; + values?: readonly string[]; // enum only — the accepted choices +} + +interface SettingTypeMap { + boolean: boolean; + enum: string; + number: number; + string: string; + url: string; +} + +type ValueOfDecl = D extends { type: "enum"; values: readonly (infer V extends string)[] } + ? V + : D extends { type: infer T extends keyof SettingTypeMap } + ? SettingTypeMap[T] + : never; + +// The resolved shape a plugin's onBoot receives, inferred from its own declarations so no caller +// narrows with a cast. Only a required or defaulted setting is guaranteed present. +export type SettingsOf = { + [K in D[number] as K["key"]]: K extends { required: true } + ? ValueOfDecl + : K extends { default: SettingValue } + ? ValueOfDecl + : ValueOfDecl | undefined; +}; + +export type SettingsValues = Record; + +// One row of the admin catalog. `value` is a display string and is absent for a secret and for an +// unset setting — a secret's length is a disclosure too, so nothing stands in for it. +export interface SettingSummary { + description?: string; + envName: string; + key: string; + required: boolean; + secret: boolean; + source: "default" | "env" | "unset"; + type: SettingType; + value?: string; + values?: readonly string[]; +} + +export interface PluginSettings { + pluginId: string; + settings: SettingSummary[]; +} + +export interface ResolveResult { + catalog: PluginSettings[]; + errors: string[]; + values: Map; +} + +export interface ResolveOptions { + requireSecureSecrets?: boolean; +} + +type Env = Record; + +const SETTING_KEY = /^[a-z][a-zA-Z0-9]*$/; + +export function isValidSettingKey(key: unknown): boolean { + return typeof key === "string" && SETTING_KEY.test(key); +} + +export function envName(pluginId: string, key: string): string { + const plugin = pluginId.replaceAll("-", "_").toUpperCase(); + return `${ENV_PREFIX}${plugin}_${camelToSnake(key)}`; +} + +function camelToSnake(key: string): string { + return key.replaceAll(/(?<=[a-z0-9])(?=[A-Z])/g, "_").toUpperCase(); +} + +// Discovery-time shape check: the author's mistakes, refused before any value is read. +export function settingsDeclError(settings: unknown): string | null { + if (!Array.isArray(settings)) return `"settings" must be an array`; + + const seen = new Set(); + for (const decl of settings as SettingDecl[]) { + const key = decl?.key; + if (!isValidSettingKey(key)) { + return `setting "${String(key)}" — a key must be camelCase (${SETTING_KEY.source}) so its variable name is derivable`; + } + if (seen.has(key)) return `setting "${key}" is declared twice`; + seen.add(key); + + if (!(SETTING_TYPES as readonly string[]).includes(decl.type)) { + return `setting "${key}" has type "${String(decl.type)}"; one of ${SETTING_TYPES.join(", ")}`; + } + if (decl.required === true && decl.default !== undefined) { + return `setting "${key}" sets both required and default — they are mutually exclusive, a default means it can never fail`; + } + if (decl.type === "enum") { + if (!Array.isArray(decl.values) || decl.values.length === 0 || decl.values.some((v) => typeof v !== "string")) { + return `setting "${key}" has type enum, so it must declare a non-empty values array of strings`; + } + } else if (decl.values !== undefined) { + return `setting "${key}" declares values, which only an enum type may do`; + } + + const typeError = defaultTypeError(decl); + if (typeError) return typeError; + } + return null; +} + +function defaultTypeError(decl: SettingDecl): string | null { + if (decl.default === undefined) return null; + if (decl.type === "enum") { + const values = decl.values ?? []; + return values.includes(String(decl.default)) + ? null + : `setting "${decl.key}" has default "${String(decl.default)}", which is not one of ${values.join(", ")}`; + } + const expected = decl.type === "number" ? "number" : decl.type === "boolean" ? "boolean" : "string"; + return typeof decl.default === expected + ? null + : `setting "${decl.key}": default must be a ${expected} (type ${decl.type}), got ${typeof decl.default}`; +} + +// Every variable the installed plugins answer to — the set a stray is measured against. +export function settingsEnvNames(plugins: Plugin[]): Set { + const names = new Set(); + for (const plugin of plugins) { + for (const decl of plugin.settings ?? []) names.add(envName(plugin.id, decl.key)); + } + return names; +} + +// A PLUGIN_SETTING_ variable no installed plugin declares — usually a typo in the one the operator +// meant to set, or a plugin they removed. Reported, never acted on (the orphan-database precedent). +export function strayNames(env: Env, declared: ReadonlySet): string[] { + return Object.keys(env) + .filter((name) => name.startsWith(ENV_PREFIX) && !declared.has(name)) + .sort(); +} + +export function resolveSettings(plugins: Plugin[], env: Env, options: ResolveOptions = {}): ResolveResult { + const catalog: PluginSettings[] = []; + const errors: string[] = []; + const values = new Map(); + + for (const plugin of plugins) { + const decls = plugin.settings ?? []; + const resolved: SettingsValues = {}; + const summaries: SettingSummary[] = []; + + for (const decl of decls) { + const name = envName(plugin.id, decl.key); + const raw = env[name] || undefined; // compose passes an unset variable through as "" + const fail = (message: string): void => void errors.push(`plugin "${plugin.id}": ${message}`); + + let value: SettingValue | undefined; + let source: SettingSummary["source"] = "unset"; + if (raw !== undefined) { + const coerced = coerce(decl, raw, name); + if (typeof coerced === "string") fail(coerced); + else { + value = coerced.value; + source = "env"; + } + } else if (decl.default !== undefined) { + value = decl.default; + source = "default"; + } else if (decl.required === true) { + fail(`setting "${decl.key}" must be set — ${name} (type ${decl.type}, no default)`); + } + + const secretError = secretPolicyError(decl, raw, options.requireSecureSecrets === true, name); + if (secretError) fail(secretError); + + resolved[decl.key] = value; + summaries.push(summarize(decl, name, source, value)); + } + + if (decls.length > 0) values.set(plugin.id, resolved); + catalog.push({ pluginId: plugin.id, settings: summaries }); + } + + return { catalog, errors, values }; +} + +// The host's own rule for a secret (readSecret), reaching plugins: enforced, neither unset nor the +// declared dev throwaway is accepted. +function secretPolicyError(decl: SettingDecl, raw: string | undefined, enforce: boolean, name: string): string | null { + if (!enforce || decl.secret !== true) return null; + if (raw === undefined) return `setting "${decl.key}" must be set when REQUIRE_SECURE_SECRETS=true — ${name}`; + if (decl.default !== undefined && raw === String(decl.default)) { + return `setting "${decl.key}" must not be its dev default when REQUIRE_SECURE_SECRETS=true — ${name}`; + } + return null; +} + +function summarize(decl: SettingDecl, name: string, source: SettingSummary["source"], value: SettingValue | undefined): SettingSummary { + const showValue = decl.secret !== true && value !== undefined; + return { + ...(decl.description !== undefined ? { description: decl.description } : {}), + envName: name, + key: decl.key, + required: decl.required === true, + secret: decl.secret === true, + source, + type: decl.type, + ...(showValue ? { value: String(value) } : {}), + ...(decl.values !== undefined ? { values: decl.values } : {}), + }; +} + +// A coerced value, or the boot error naming the variable and what it accepts. +function coerce(decl: SettingDecl, raw: string, name: string): { value: SettingValue } | string { + switch (decl.type) { + case "boolean": + if (raw === "true") return { value: true }; + if (raw === "false") return { value: false }; + return `${name} must be "true" or "false", got "${raw}"`; + case "enum": + return (decl.values ?? []).includes(raw) + ? { value: raw } + : `${name} must be one of ${(decl.values ?? []).join(", ")}, got "${raw}"`; + case "number": { + const value = Number(raw); + return Number.isFinite(value) ? { value } : `${name} must be a number, got "${raw}"`; + } + case "url": + try { + new URL(raw); + } catch { + return `${name} is not a valid URL: ${raw}`; + } + return { value: raw }; + case "string": + return { value: raw }; + } +} diff --git a/src/server.ts b/src/server.ts index 1443f46..c950b64 100644 --- a/src/server.ts +++ b/src/server.ts @@ -14,6 +14,7 @@ import { createKratosAdmin } from "./auth/kratos-admin.ts"; import { createKratosPublic } from "./auth/kratos-public.ts"; import { createLogger, tracedFetch } from "./logger.ts"; import { loadMenuConfig } from "./ui/menu-config.ts"; +import { resolveSettings, settingsEnvNames, strayNames } from "./plugin-host/settings.ts"; import { buildCredentials, storagePluginIds, type StorageCredentials } from "./plugin-host/storage.ts"; const config = loadConfig(); // validates the env (incl. enforced secrets) — fails loud at boot @@ -62,15 +63,29 @@ const storageCredentials = new Map(); if (pluginDbUrl !== undefined) { for (const id of declaresStorage) storageCredentials.set(id, buildCredentials(pluginDbUrl, id, config.pluginDbSecret)); } -// onBoot is the only way credentials are handed over, so without one the database is provisioned -// and unreachable. A warning, not a refusal — the plugin still works, it just cannot store anything. -const unreachable = plugins.filter((plugin) => plugin.storage && !plugin.hooks?.onBoot).map((plugin) => plugin.id); -if (unreachable.length > 0) log.warn("plugins declare storage but have no onBoot to receive it", { plugins: unreachable.join(", ") }); +// Operator-supplied plugin settings, resolved against the environment the manifests declared. A bad +// or missing value is refused here rather than at that plugin's first use, hours later. +const settings = resolveSettings(plugins, process.env, { requireSecureSecrets: config.requireSecureSecrets }); +if (settings.errors.length > 0) throw new Error(`Plugin settings:\n${settings.errors.map((e) => ` - ${e}`).join("\n")}`); +// A stray is usually a typo in the very variable the operator meant to set — naming it turns two +// unrelated-looking errors into one. Reported, never acted on. +const strays = strayNames(process.env, settingsEnvNames(plugins)); +if (strays.length > 0) log.warn("settings variables no installed plugin declares", { variables: strays.join(", ") }); + +// onBoot is the only way storage credentials and settings are handed over, so without one they are +// resolved and undeliverable. A warning, not a refusal — the plugin still works, it just gets neither. +for (const [what, ids] of [ + ["settings", plugins.filter((plugin) => plugin.settings?.length && !plugin.hooks?.onBoot)], + ["storage", plugins.filter((plugin) => plugin.storage && !plugin.hooks?.onBoot)], +] as const) { + if (ids.length > 0) log.warn(`plugins declare ${what} but have no onBoot to receive it`, { plugins: ids.map((plugin) => plugin.id).join(", ") }); +} // plugin onBoot — after discovery, before listen; a throw aborts boot. await runBootHooks(plugins, (plugin) => { const storage = storageCredentials.get(plugin.id); - return storage ? { storage } : {}; + const values = settings.values.get(plugin.id); + return { ...(values ? { settings: values } : {}), ...(storage ? { storage } : {}) }; }); const server = createApp({ @@ -91,6 +106,7 @@ const server = createApp({ menu, plugins, secureCookies: config.secureCookies, + settingsCatalog: settings.catalog, }).listen(config.port, () => { log.info("listening", { apiVersion: HOST_API_VERSION, port: config.port, url: config.appUrl ?? `http://localhost:${config.port}` }); }); diff --git a/todo.md b/todo.md index 2723c0a..b4250c1 100644 --- a/todo.md +++ b/todo.md @@ -2,7 +2,6 @@ ## Unfinnished work -- [ ] Add a way to configure plugins directly when installing. **Decided: the manifest declares it, not an `.env`** — a declared schema is validatable at boot, so a missing or mistyped setting fails loud and named the way a stray `package.json` now does, and the picker/docs can be generated from the declaration. Open: where the operator *supplies* the values (env var per key, a `config/` file, or both), and whether a secret may be declared at all. - [ ] Rename the plugin "admin" to something less generic, like "auth-admin" or "users-groups-admin". - [ ] Guard the group paths against self-lockout, or accept them explicitly. The self-revoke guard covers only your own *direct* grants on the Users screen; unticking a permission on a group you belong to, removing yourself from that group, or deleting it can all still strip your own effective access with no warning. Recorded in AGENTS.md as a known gap — the robust fix is a "last effective holder" check, which needs a reverse Keto query. - [ ] The permission picker has no concurrency baseline, so two operators editing the same user/group silently discard each other's change. Sketch: post the rendered set as a hidden baseline; if it no longer matches Keto, re-render with "this changed while you had the page open" rather than applying. @@ -36,6 +35,7 @@ Prioritized. Overall verdict: architecture is sound; these are refinements. ## Finnished work +- [x] Configure a plugin at install time: the manifest declares `settings`, the operator sets one `PLUGIN_SETTING__` variable per key, and the resolved values arrive on `onBoot` typed from the declaration. Read-only admin screen at `/admin/plugin-settings`. - [x] Give a plugin persistent storage: `storage: true` provisions a Postgres database + login role named `plugin_`, credentials arrive on `onBoot`, passwords are derived from `PLUGIN_DB_SECRET` rather than stored. - [x] Refuse a stray `package.json`/`node_modules` in `config/` by name, as plugin folders already are. - [x] Let Renovate reach the example plugins' manifests (`ignorePaths` overrides `config:recommended`). diff --git a/views/index.ejs b/views/index.ejs index 118c8e2..12ed83a 100644 --- a/views/index.ejs +++ b/views/index.ejs @@ -14,7 +14,7 @@

${t("dashboard.starter.intro")}

${t("dashboard.starter.replace")}

export default definePlugin({
-  apiVersion: "0.1.0",
+  apiVersion: "0.2.0",
   // view names plugins/<id>/views/<view>.ejs, rendered in this same shell
   dashboard: (ctx) => ({ view: "dashboard", data: { /* … */ } }),
 });
-- 2.52.0