Spaces:
Runtime error
Runtime error
| 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<string, unknown> => | |
| 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<string, unknown>, | |
| properties: Record<string, PluginConfigField>, | |
| ): Record<string, unknown> { | |
| 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<string, unknown> | undefined, | |
| schema?: PluginConfigSchema, | |
| ): Record<string, unknown> { | |
| 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<string, number>(); | |
| const sigFirst = new Map<string, unknown>(); | |
| 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<string, unknown>, | |
| existing: Record<string, unknown> | undefined, | |
| properties: Record<string, PluginConfigField>, | |
| ): Record<string, unknown> { | |
| 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<string, unknown>, | |
| existing: Record<string, unknown> | undefined, | |
| schema?: PluginConfigSchema, | |
| ): Record<string, unknown> { | |
| // 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); | |
| } | |