Files
OmniRoute/src/lib/logExport/secrets.ts
Damian Pozimski 385e90f444 feat(dashboard): continuous call-log export to pluggable destinations (BigQuery first) (#11945)
Feature grande e bem construída: exportação contínua de call logs para destinos plugáveis (BigQuery primeiro). Revisei especificamente o tratamento de segredos (`src/lib/logExport/secrets.ts`) e a migração — encryption gate real (`requiresEncryptionKey` recusa gravação em texto plano quando `STORAGE_ENCRYPTION_KEY` não está setada), redação antes de qualquer resposta de API, e a migração cria a tabela com `enabled=0`/`include_bodies=0` por padrão (opt-in, sem exportar nada até o operador configurar). 62/62 testes focados verdes, typecheck limpo.

Resolvido o conflito com o barrel `src/lib/localDb.ts` (removido nesta mesma sessão, #11795 fase 5 — todo consumidor já migrado para `src/lib/db/*`); a PR só adicionava um re-export nele, que não é mais necessário. Obrigado pela contribuição!
2026-08-30 09:16:52 -03:00

102 lines
3.6 KiB
TypeScript

/**
* Secret handling for destination configs.
*
* Which keys are secret is declared by the destination type (`secretFields`), so this
* module stays generic: encrypt on write, decrypt only when a client is constructed,
* and redact before anything reaches an API response.
*/
import { decrypt, encrypt, isEncryptionEnabled } from "@/lib/db/encryption";
import { getLogExportDestinationType } from "./registry";
/** Placeholder returned by the API in place of a stored secret. */
export const SECRET_PLACEHOLDER = "__stored__";
function secretKeysFor(type: string): readonly string[] {
return getLogExportDestinationType(type)?.secretFields ?? [];
}
/**
* True when this destination type stores a credential AND field encryption is off.
*
* `encrypt()` is a silent passthrough without STORAGE_ENCRYPTION_KEY, which is the
* default for a fresh install — so writing a service-account key would land it in
* SQLite as plaintext. Callers refuse the write instead (the same guard the Telegram
* webhook uses).
*/
export function requiresEncryptionKey(type: string, config: Record<string, unknown>): boolean {
if (isEncryptionEnabled()) return false;
return secretKeysFor(type).some((key) => {
const value = config[key];
return typeof value === "string" && value.length > 0;
});
}
/** Encrypt every declared secret key. Non-secret keys pass through untouched. */
export function encryptDestinationConfig(
type: string,
config: Record<string, unknown>
): Record<string, unknown> {
const secretKeys = secretKeysFor(type);
if (secretKeys.length === 0) return { ...config };
const out: Record<string, unknown> = { ...config };
for (const key of secretKeys) {
const value = out[key];
if (typeof value === "string" && value.length > 0) out[key] = encrypt(value);
}
return out;
}
/** Decrypt declared secret keys for runtime use. Never feed the result to a response. */
export function decryptDestinationConfig(
type: string,
config: Record<string, unknown>
): Record<string, unknown> {
const secretKeys = secretKeysFor(type);
if (secretKeys.length === 0) return { ...config };
const out: Record<string, unknown> = { ...config };
for (const key of secretKeys) {
const value = out[key];
if (typeof value === "string" && value.length > 0) out[key] = decrypt(value) ?? "";
}
return out;
}
/**
* Replace declared secrets with a placeholder for API responses. A stored secret
* becomes SECRET_PLACEHOLDER; an absent one stays absent, so the UI can tell
* "configured" from "never set".
*/
export function redactDestinationConfig(
type: string,
config: Record<string, unknown>
): Record<string, unknown> {
const secretKeys = secretKeysFor(type);
const out: Record<string, unknown> = { ...config };
for (const key of secretKeys) {
const value = out[key];
if (typeof value === "string" && value.length > 0) out[key] = SECRET_PLACEHOLDER;
else delete out[key];
}
return out;
}
/**
* Merge an incoming config over the stored one, keeping the stored ciphertext wherever
* the caller sent back the placeholder (an edit that did not retype the secret).
*/
export function mergeDestinationConfig(
type: string,
storedConfig: Record<string, unknown>,
incomingConfig: Record<string, unknown>
): Record<string, unknown> {
const merged: Record<string, unknown> = { ...incomingConfig };
for (const key of secretKeysFor(type)) {
if (merged[key] === SECRET_PLACEHOLDER || merged[key] === undefined) {
if (storedConfig[key] !== undefined) merged[key] = storedConfig[key];
else delete merged[key];
}
}
return merged;
}