import type { PluginConfigField, PluginConfigSchema } from '../../core/plugins/plugin.interfaces'; /** Mask shown for a stored secret on read. Treated as "unchanged" on write. */ export const SECRET_SENTINEL = '***'; const isMeaningful = (v: unknown): boolean => v !== undefined && v !== null && v !== ''; const isPlainObject = (v: unknown): v is Record => typeof v === 'object' && v !== null && !Array.isArray(v); /** * Redact one value against its field. Recurses into nested objects and into each row of an * array-of-rows so a `secret`-flagged field at any depth is masked. An empty/absent secret is left * as-is so the mask never implies a secret that isn't set. */ function redactValue(value: unknown, field: PluginConfigField): unknown { // Mask a `secret` field BEFORE descending into its structure: a whole object/array marked secret // (e.g. a credentials object or a list of keys) must be masked as one unit, not recursed into — // otherwise its non-secret children leak verbatim. Scalar secrets are masked here too. if (field.secret && isMeaningful(value)) return SECRET_SENTINEL; if (field.type === 'object' && field.properties && isPlainObject(value)) { return redactObject(value, field.properties); } if (field.type === 'array' && field.items && Array.isArray(value)) { return value.map(item => redactValue(item, field.items as PluginConfigField)); } return value; } function redactObject( config: Record, properties: Record, ): Record { const out = { ...config }; for (const [key, field] of Object.entries(properties)) { if (key in out) out[key] = redactValue(out[key], field); } return out; } /** * Replace secret-flagged, non-empty config values with {@link SECRET_SENTINEL} so a read (GET * /plugins) never leaks them — at any depth. Returns a copy; non-secret fields, unknown keys, and * shape are untouched (bare payload). */ export function redactSecretConfig( config: Record | undefined, schema?: PluginConfigSchema, ): Record { const out = { ...(config ?? {}) }; // Fail CLOSED when the schema is unavailable (plugin unloaded / failed to load): we can't tell which // fields are secret, so mask every meaningful value rather than leak a credential. Returning the // config unchanged here would expose runtime-stored secrets on GET /plugins. if (!schema?.properties) { for (const key of Object.keys(out)) { if (isMeaningful(out[key])) out[key] = SECRET_SENTINEL; } return out; } return redactObject(out, schema.properties); } /** Deterministic JSON (object keys sorted) so two equal shapes compare equal regardless of key order. */ function stableStringify(v: unknown): string { if (Array.isArray(v)) return '[' + v.map(stableStringify).join(',') + ']'; if (isPlainObject(v)) { return ( '{' + Object.keys(v) .sort() .map(k => JSON.stringify(k) + ':' + stableStringify(v[k])) .join(',') + '}' ); } return JSON.stringify(v) ?? 'null'; } /** * An array element's identity for matching on restore: the element with its secrets masked, so a * stored row (real secret) and the dashboard's round-tripped row (sentinel secret) share a signature * iff their NON-secret content is equal. Scalar-secret elements collapse to the same signature and so * are deliberately ambiguous (never auto-restored to the wrong one). */ function elementSignature(value: unknown, field: PluginConfigField): string { return stableStringify(redactValue(value, field)); } /** Restore one value against its field. `keep:false` means drop the key (sentinel with nothing stored). */ function restoreValue( incoming: unknown, existing: unknown, field: PluginConfigField, ): { keep: boolean; value?: unknown } { // Mirror redactValue: a `secret` field is handled as ONE unit before any structural recursion. A // sentinel/empty incoming keeps the stored value (or drops it); a genuinely-new value is stored // as-is. Without this, a composite secret (object/array) would recurse and its masked children // would clobber the stored secret instead of restoring it. if (field.secret) { if (incoming === SECRET_SENTINEL || !isMeaningful(incoming)) { return isMeaningful(existing) ? { keep: true, value: existing } : { keep: false }; } return { keep: true, value: incoming }; } if (field.type === 'object' && field.properties && isPlainObject(incoming)) { return { keep: true, value: restoreObject(incoming, isPlainObject(existing) ? existing : undefined, field.properties), }; } if (field.type === 'array' && field.items && Array.isArray(incoming)) { const itemField = field.items; const existingArr: unknown[] = Array.isArray(existing) ? existing : []; // Match each incoming element to its stored counterpart by NON-SECRET content (a row's signature // is the row with its secrets masked), not by array index. Adding / removing / reordering rows // then can't bind a sentinel to a different row's secret. Only an unambiguous match (exactly one // stored element with that signature) restores; otherwise the sentinel is treated as "nothing // stored" (dropped), so a secret may be lost on an ambiguous edit but is never mis-targeted. const sigCount = new Map(); const sigFirst = new Map(); for (const ex of existingArr) { const s = elementSignature(ex, itemField); sigCount.set(s, (sigCount.get(s) ?? 0) + 1); if (!sigFirst.has(s)) sigFirst.set(s, ex); } const sameLength = incoming.length === existingArr.length; return { keep: true, value: incoming .map((item, i) => { const s = elementSignature(item, itemField); // Resolution order: (1) an unambiguous content match keeps a row's secret across // reorder/insert/removal; (2) on an unchanged length, fall back to the positional twin (an // in-place edit — position i is the same logical row, incl. a non-secret-field rename); (3) on a // length change, bind the positional twin ONLY when its masked signature still equals this // element's, so an append/removal keeps each surviving sentinel's stored secret while a // genuinely-new or signature-changed row at that position is never grafted with a stored secret. const twin = existingArr[i]; const match = sigCount.get(s) === 1 ? sigFirst.get(s) : sameLength ? twin : twin !== undefined && elementSignature(twin, itemField) === s ? twin : undefined; return restoreValue(item, match, itemField); }) // Honor keep:false like restoreObject does (drop the element) rather than emitting `.value` // (undefined), which would serialize to a null hole in the persisted array. .filter(r => r.keep) .map(r => r.value), }; } return { keep: true, value: incoming }; } function restoreObject( incoming: Record, existing: Record | undefined, properties: Record, ): Record { const out = { ...incoming }; for (const [key, field] of Object.entries(properties)) { if (!(key in out)) continue; const r = restoreValue(out[key], existing?.[key], field); if (r.keep) out[key] = r.value; else delete out[key]; } return out; } /** * On write (PUT /plugins/:id/config), the dashboard sends the whole config back — including masked * secrets at any depth. Treat a sentinel/empty secret as "keep existing": restore the stored value, * or drop the key when there's nothing stored. A genuinely-new value is stored as provided. */ export function restoreSecretConfig( incoming: Record, existing: Record | undefined, schema?: PluginConfigSchema, ): Record { // Symmetric fail-closed restore for the schemaless case: redactSecretConfig masks EVERY value to the // sentinel, so on write treat any sentinel value as "keep existing" (restore the stored value, or drop // the key when nothing is stored). Returning `incoming` as-is would persist the sentinel and corrupt // the stored config. Non-sentinel values (genuinely edited) are kept verbatim. if (!schema?.properties) { const out = { ...incoming }; const ex = existing ?? {}; for (const key of Object.keys(out)) { if (out[key] !== SECRET_SENTINEL) continue; if (isMeaningful(ex[key])) out[key] = ex[key]; else delete out[key]; } return out; } return restoreObject(incoming, existing, schema.properties); }