Initial import of verae-middleware from zapier monorepo

This commit is contained in:
George Lambert 2026-09-11 14:36:30 -04:00
commit a11a20128a
67 changed files with 6630 additions and 0 deletions

106
src/debug/config.js Normal file
View file

@ -0,0 +1,106 @@
/**
* @fileoverview Runtime debug configuration.
*
* Reads process environment at call time of {@link loadDebugConfig} so tests can
* mutate `process.env` between cases. Production servers typically load once at boot.
*
* @module debug/config
*/
/**
* @typedef {Object} DebugConfig
* @property {boolean} enabled - True when any debug output should be produced.
* @property {Set<string>|null} namespaces - Allowed namespaces; `null` means all.
* @property {'debug'|'info'|'warn'|'error'} level - Minimum severity to emit.
* @property {string|null} filePath - Optional secondary log file path.
*/
const LEVEL_ORDER = { debug: 10, info: 20, warn: 30, error: 40 };
/**
* Parse `DEBUG_VERAE` into an enabled flag and optional namespace allow-list.
*
* @param {string|undefined} raw - Raw env value (e.g. `"1"`, `"*"`, `"auth,nats"`).
* @returns {{ enabled: boolean, namespaces: Set<string>|null }}
*
* @example
* parseDebugVeraeEnv('auth,jobs'); // { enabled: true, namespaces: Set{'auth','jobs'} }
* parseDebugVeraeEnv(undefined); // { enabled: false, namespaces: null }
*/
export function parseDebugVeraeEnv(raw) {
if (raw === undefined || raw === null) {
return { enabled: false, namespaces: null };
}
const trimmed = String(raw).trim();
if (!trimmed || trimmed === '0' || trimmed.toLowerCase() === 'false' || trimmed.toLowerCase() === 'off') {
return { enabled: false, namespaces: null };
}
if (trimmed === '1' || trimmed === '*' || trimmed.toLowerCase() === 'true' || trimmed.toLowerCase() === 'all') {
return { enabled: true, namespaces: null };
}
const parts = trimmed
.split(',')
.map((p) => p.trim().toLowerCase())
.filter(Boolean);
if (parts.length === 0) {
return { enabled: false, namespaces: null };
}
return { enabled: true, namespaces: new Set(parts) };
}
/**
* Normalize a level string to a known level.
*
* @param {string|undefined} raw - Env level value.
* @param {'debug'|'info'|'warn'|'error'} [fallback='debug'] - Default level.
* @returns {'debug'|'info'|'warn'|'error'}
*/
export function parseLevel(raw, fallback = 'debug') {
const value = String(raw ?? fallback).toLowerCase();
if (value in LEVEL_ORDER) return /** @type {'debug'|'info'|'warn'|'error'} */ (value);
return fallback;
}
/**
* Load full debug configuration from `process.env`.
*
* Environment variables:
* - `DEBUG_VERAE` enable + namespaces (see {@link parseDebugVeraeEnv})
* - `DEBUG_VERAE_LEVEL` minimum level
* - `DEBUG_VERAE_FILE` optional log file path
*
* @param {NodeJS.ProcessEnv} [env=process.env] - Environment map (injectable for tests).
* @returns {DebugConfig}
*/
export function loadDebugConfig(env = process.env) {
const { enabled, namespaces } = parseDebugVeraeEnv(env.DEBUG_VERAE);
return {
enabled,
namespaces,
level: parseLevel(env.DEBUG_VERAE_LEVEL, 'debug'),
filePath: env.DEBUG_VERAE_FILE ? String(env.DEBUG_VERAE_FILE) : null,
};
}
/**
* Whether a message at `messageLevel` in `namespace` should be emitted.
*
* @param {DebugConfig} config - Active config.
* @param {string} namespace - Logger namespace (e.g. `auth`).
* @param {'debug'|'info'|'warn'|'error'} messageLevel - Message severity.
* @returns {boolean}
*/
export function shouldLog(config, namespace, messageLevel) {
if (!config.enabled) return false;
if (config.namespaces && !config.namespaces.has(String(namespace).toLowerCase())) {
return false;
}
return LEVEL_ORDER[messageLevel] >= LEVEL_ORDER[config.level];
}
export { LEVEL_ORDER };

24
src/debug/index.js Normal file
View file

@ -0,0 +1,24 @@
/**
* @fileoverview Public exports for the runtime debug facility.
* @module debug
*/
export {
loadDebugConfig,
parseDebugVeraeEnv,
parseLevel,
shouldLog,
LEVEL_ORDER,
} from './config.js';
export { createDebugger, formatLogLine, setDebugTestSink } from './logger.js';
export { redact, redactString } from './redact.js';
export {
generateTraceId,
getTraceId,
getTraceContext,
withTrace,
traceMiddleware,
} from './trace.js';

121
src/debug/logger.js Normal file
View file

@ -0,0 +1,121 @@
/**
* @fileoverview Namespaced debug logger with runtime enable/disable.
* @module debug/logger
*/
import { appendFileSync } from 'node:fs';
import { loadDebugConfig, shouldLog } from './config.js';
import { redact } from './redact.js';
import { getTraceId } from './trace-context.js';
/**
* @typedef {Object} Debugger
* @property {(msg: string, meta?: Record<string, unknown>) => void} debug
* @property {(msg: string, meta?: Record<string, unknown>) => void} info
* @property {(msg: string, meta?: Record<string, unknown>) => void} warn
* @property {(msg: string, meta?: Record<string, unknown>) => void} error
* @property {string} namespace
*/
/**
* Optional sink for tests when set, lines go here instead of/in addition to stderr.
* @type {null|((line: string) => void)}
*/
let testSink = null;
/**
* Install a test sink that captures formatted log lines.
* Used only by unit tests; do not use in production code.
*
* @param {null|((line: string) => void)} sink - Callback or null to clear.
* @returns {void}
*/
export function setDebugTestSink(sink) {
testSink = sink;
}
/**
* Format a single log line.
*
* @param {object} parts
* @param {string} parts.level
* @param {string} parts.namespace
* @param {string} parts.message
* @param {Record<string, unknown>|undefined} parts.meta
* @param {string|null} parts.traceId
* @returns {string}
*/
export function formatLogLine({ level, namespace, message, meta, traceId }) {
const ts = new Date().toISOString();
const tracePart = traceId ? ` [trace=${traceId}]` : '';
const metaPart =
meta && Object.keys(meta).length > 0 ? ` ${JSON.stringify(redact(meta))}` : '';
return `${ts} ${level.toUpperCase()} ${namespace}${tracePart} ${message}${metaPart}`;
}
/**
* Emit a log line to stderr, optional file, and optional test sink.
*
* @param {string} line - Full formatted line.
* @param {string|null} filePath - Optional file from config.
* @returns {void}
*/
function emit(line, filePath) {
if (testSink) {
testSink(line);
} else {
// eslint-disable-next-line no-console
console.error(line);
}
if (filePath) {
try {
appendFileSync(filePath, `${line}\n`, 'utf8');
} catch {
// ignore file errors in debug path
}
}
}
/**
* Create a namespaced debugger bound to current env configuration.
*
* Configuration is re-read on every log call so tests can toggle `DEBUG_VERAE`
* without reloading modules. Slight cost is acceptable for debug-only paths.
*
* @param {string} namespace - Namespace such as `auth`, `nats`, `jobs`.
* @returns {Debugger} Logger with debug/info/warn/error methods.
*
* @example
* const log = createDebugger('jobs');
* log.debug('poll start', { jobId: 'abc' });
*/
export function createDebugger(namespace) {
const ns = String(namespace || 'app').toLowerCase();
/**
* @param {'debug'|'info'|'warn'|'error'} level
* @param {string} message
* @param {Record<string, unknown>} [meta]
*/
function write(level, message, meta) {
const config = loadDebugConfig();
if (!shouldLog(config, ns, level)) return;
const line = formatLogLine({
level,
namespace: ns,
message: String(message),
meta,
traceId: getTraceId(),
});
emit(line, config.filePath);
}
return {
namespace: ns,
debug: (message, meta) => write('debug', message, meta),
info: (message, meta) => write('info', message, meta),
warn: (message, meta) => write('warn', message, meta),
error: (message, meta) => write('error', message, meta),
};
}

93
src/debug/redact.js Normal file
View file

@ -0,0 +1,93 @@
/**
* @fileoverview Secret redaction for debug logs.
* @module debug/redact
*/
const SENSITIVE_KEYS = new Set([
'password',
'veraepassword',
'token',
'veraetoken',
'accesstoken',
'apikey',
'api_key',
'secret',
'authorization',
'x-api-key',
'cookie',
'sessionkey',
]);
const BEARER_RE = /^Bearer\s+.+/i;
const API_KEY_RE = /^zmw_[A-Za-z0-9_-]+/;
const SESSION_RE = /^zmt_[A-Za-z0-9_.-]+/;
const JWT_RE = /^eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+/;
/**
* Redact a single string value if it looks like a secret.
*
* @param {string} value - Raw string.
* @returns {string} Original or `[REDACTED]`.
*/
export function redactString(value) {
if (typeof value !== 'string') return value;
if (
BEARER_RE.test(value) ||
API_KEY_RE.test(value) ||
SESSION_RE.test(value) ||
JWT_RE.test(value)
) {
return '[REDACTED]';
}
return value;
}
/**
* Deep-clone plain data structures while redacting sensitive keys and values.
*
* @param {unknown} input - Any JSON-like value.
* @param {number} [depth=0] - Recursion depth guard.
* @returns {unknown} Redacted structure safe for logging.
*
* @example
* redact({ password: 'x', jobId: '1' }); // { password: '[REDACTED]', jobId: '1' }
*/
export function redact(input, depth = 0) {
if (depth > 8) return '[MaxDepth]';
if (input === null || input === undefined) return input;
if (typeof input === 'string') return redactString(input);
if (typeof input === 'number' || typeof input === 'boolean') return input;
if (input instanceof Error) {
return { name: input.name, message: redactString(input.message), stack: undefined };
}
if (Array.isArray(input)) {
return input.map((item) => redact(item, depth + 1));
}
if (typeof input === 'object') {
/** @type {Record<string, unknown>} */
const out = {};
for (const [key, value] of Object.entries(input)) {
if (SENSITIVE_KEYS.has(key.toLowerCase())) {
out[key] = '[REDACTED]';
} else if (key.toLowerCase() === 'targeturl' && typeof value === 'string') {
try {
const u = new URL(value);
out[key] = `${u.origin}${u.pathname}`;
} catch {
out[key] = '[REDACTED_URL]';
}
} else {
out[key] = redact(value, depth + 1);
}
}
return out;
}
return String(input);
}

View file

@ -0,0 +1,39 @@
/**
* @fileoverview AsyncLocalStorage-backed trace context (no logger dependency).
* Split from trace.js to avoid circular imports with logger.js.
* @module debug/trace-context
*/
import { AsyncLocalStorage } from 'node:async_hooks';
import { randomBytes } from 'node:crypto';
/**
* @typedef {Object} TraceContext
* @property {string} traceId
* @property {string} [span]
*/
/** @type {AsyncLocalStorage<TraceContext>} */
export const traceStorage = new AsyncLocalStorage();
/**
* Generate a short opaque trace id (16 hex chars).
* @returns {string}
*/
export function generateTraceId() {
return randomBytes(8).toString('hex');
}
/**
* @returns {string|null}
*/
export function getTraceId() {
return traceStorage.getStore()?.traceId ?? null;
}
/**
* @returns {TraceContext|null}
*/
export function getTraceContext() {
return traceStorage.getStore() ?? null;
}

95
src/debug/trace.js Normal file
View file

@ -0,0 +1,95 @@
/**
* @fileoverview Correlation / trace IDs for failure tracing across HTTP and NATS.
* @module debug/trace
*/
import { createDebugger } from './logger.js';
import {
traceStorage,
generateTraceId,
getTraceId,
getTraceContext,
} from './trace-context.js';
export { generateTraceId, getTraceId, getTraceContext };
const log = createDebugger('trace');
/**
* Run `fn` within a trace context. Nested calls inherit the same `traceId`
* unless `traceId` is explicitly overridden.
*
* @template T
* @param {object|(() => T|Promise<T>)} options - Options or the callback itself.
* @param {string} [options.traceId] - Existing id (e.g. from `X-Trace-Id` header).
* @param {string} [options.span] - Human label for this span (logged when debug on).
* @param {() => T|Promise<T>} [fn] - Work to execute inside the context when options is an object.
* @returns {T|Promise<T>} Return value of `fn`.
*
* @example
* await withTrace({ span: 'timestamp.wait', traceId: req.headers['x-trace-id'] }, async () => {
* // getTraceId() is stable here and in awaited children
* });
*/
export function withTrace(options, fn) {
const opts = typeof options === 'function' ? {} : options ?? {};
const callback = typeof options === 'function' ? options : fn;
if (typeof callback !== 'function') {
throw new TypeError('withTrace requires a function to execute');
}
const parent = traceStorage.getStore();
const traceId = opts.traceId || parent?.traceId || generateTraceId();
const span = opts.span || parent?.span || 'root';
const ctx = { traceId, span };
return traceStorage.run(ctx, () => {
log.debug('span enter', { span });
try {
const result = callback();
if (result && typeof result.then === 'function') {
return result.then(
(value) => {
log.debug('span exit', { span, ok: true });
return value;
},
(err) => {
log.debug('span exit', { span, ok: false, error: err?.message });
throw err;
},
);
}
log.debug('span exit', { span, ok: true });
return result;
} catch (err) {
log.debug('span exit', { span, ok: false, error: err?.message });
throw err;
}
});
}
/**
* Express middleware that establishes a trace context per request.
*
* Reads `X-Trace-Id` when present; otherwise generates a new id.
* Sets `req.traceId` and response header `X-Trace-Id`.
*
* @param {import('express').Request} req
* @param {import('express').Response} res
* @param {import('express').NextFunction} next
* @returns {void}
*/
export function traceMiddleware(req, res, next) {
const incoming = req.headers['x-trace-id'];
const traceId =
typeof incoming === 'string' && incoming.trim() ? incoming.trim() : generateTraceId();
req.traceId = traceId;
res.setHeader('X-Trace-Id', traceId);
withTrace({ traceId, span: `${req.method} ${req.path}` }, () => {
next();
});
}