qwen_2.5_model / src /modules /plugins /redact-config.ts
Muhammad Noman
Deploy OpenWA to Hugging Face Spaces
46252cd
Raw
History Blame Contribute Delete
8.86 kB
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);
}