Declare plugin settings in the manifest, resolve them from the environment

This commit is contained in:
2026-08-23 13:16:29 +02:00
parent aff47c8b90
commit fa7cad1d65
32 changed files with 858 additions and 91 deletions
+5
View File
@@ -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 : []) {
+1
View File
@@ -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";
+29 -9
View File
@@ -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;
}
+177
View File
@@ -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
});
+259
View File
@@ -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 };
}
}