Declare plugin settings in the manifest, resolve them from the environment
CI / full-gate (push) Successful in 2m52s
CI / full-gate (push) Successful in 2m52s
This commit is contained in:
@@ -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).
|
||||
|
||||
+5
-2
@@ -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<string, string>): 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.
|
||||
|
||||
@@ -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<string, string>; // 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),
|
||||
|
||||
@@ -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 : []) {
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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<S extends readonly SettingDecl[] = readonly SettingDecl[]> = {
|
||||
storage?: StorageCredentials; // this plugin's own database; present iff the manifest declared `storage`
|
||||
}
|
||||
} & SettingsSlot<S>;
|
||||
|
||||
// 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<S extends readonly SettingDecl[]> = readonly [] extends S
|
||||
? { settings?: SettingsOf<S> }
|
||||
: { settings: SettingsOf<S> };
|
||||
|
||||
// Optional hooks on system actions. Crash-isolation is a non-goal — a throwing hook fails loud.
|
||||
export interface PluginHooks {
|
||||
onBoot?: (host: BootContext) => Promise<void> | void; // after discovery, before the server listens
|
||||
export interface PluginHooks<S extends readonly SettingDecl[] = readonly SettingDecl[]> {
|
||||
onBoot?: (host: BootContext<S>) => Promise<void> | void; // after discovery, before the server listens
|
||||
onRequest?: (ctx: RequestContext) => Promise<RouteResult | void> | RouteResult | void; // may short-circuit
|
||||
onResponse?: (ctx: RequestContext, result: RouteResult | null) => Promise<void> | 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<S extends readonly SettingDecl[] = readonly SettingDecl[]> {
|
||||
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<S>;
|
||||
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_<ID>_<KEY> 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<const S extends readonly SettingDecl[]>(manifest: PluginManifest<S>): PluginManifest<S> {
|
||||
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 "<resource>" 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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
});
|
||||
@@ -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> = 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<D extends readonly SettingDecl[]> = {
|
||||
[K in D[number] as K["key"]]: K extends { required: true }
|
||||
? ValueOfDecl<K>
|
||||
: K extends { default: SettingValue }
|
||||
? ValueOfDecl<K>
|
||||
: ValueOfDecl<K> | undefined;
|
||||
};
|
||||
|
||||
export type SettingsValues = Record<string, SettingValue | undefined>;
|
||||
|
||||
// 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<string, SettingsValues>;
|
||||
}
|
||||
|
||||
export interface ResolveOptions {
|
||||
requireSecureSecrets?: boolean;
|
||||
}
|
||||
|
||||
type Env = Record<string, string | undefined>;
|
||||
|
||||
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<string>();
|
||||
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<string> {
|
||||
const names = new Set<string>();
|
||||
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>): 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<string, SettingsValues>();
|
||||
|
||||
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 };
|
||||
}
|
||||
}
|
||||
+21
-5
@@ -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<string, StorageCredentials>();
|
||||
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}` });
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user