Skip to content

@centralping/ergo

Version [0.7.0]

Changed

  • IdempotencyStore eviction lookup is now O(1) via internal side index. (#239) Added a _completeKeys Set as a secondary index tracking keys with complete status. Eviction in set() now uses _completeKeys.values().next().value instead of scanning the entire _entries Map. Under sustained load where all entries are in-flight (processing), worst-case eviction was previously O(maxKeys); it is now O(1) for the common case (complete entries available) with unchanged fallback behavior (oldest processing entry evicted with warning). Internal optimization only — no public API changes; custom store implementations are unaffected.

  • Prefer header parser normalizes preference names and values to lowercase. (#235) Preference names are lowercased per RFC 7240 §2 (case-insensitive comparison). Preference values are also lowercased as a Postel’s Law leniency for practical interoperability — all IANA-registered preference values are lowercase tokens. A client sending Prefer: Return=Minimal now produces {return: 'minimal'} instead of {Return: 'Minimal'}. Non-breaking for well-behaved clients: standard RFC 7240 inputs already use lowercase.

  • IdempotencyStore eviction is now status-aware with generation token validation. (#225) set() now prunes expired entries before checking capacity, preferentially evicts complete entries over processing entries, and returns a generation token (string). complete(key, response, generation) accepts the generation token and returns boolean (true on success, false when the entry is absent, evicted, or the generation token mismatches). Passing undefined or null as response returns false without mutating the entry. When all entries are processing and a new entry must be stored, the oldest processing entry is evicted and a one-time process.emitWarning is emitted with {type: 'ErgoWarning', code: 'ERGO_IDEMPOTENCY_PROCESSING_EVICTED'}. Custom store implementations must update set() to return a string and complete() to accept a third generation parameter and return boolean.

  • Prefer header parser enforces RFC 7240 token/quoted-string grammar. (#219) Replaced the loose regex with a character-by-character scanner that enforces RFC 9110 §5.6.2 token and §5.6.4 quoted-string grammars. Preference names now accept the full tchar set (digits, !, #, etc. at any position). Quoted values handle backslash escapes (quoted-pair) and reject bare control characters. Commas inside quoted values no longer break parsing. Malformed preferences are silently skipped (graceful degradation). Non-breaking: valid RFC 7240 inputs produce identical output; previously-accepted invalid inputs may now be skipped.

  • formatLinkHeader href validation replaced with RFC 3986 URI-reference character allowlist. (#207) Previously rejected only CR, LF, NUL, and > via a four-character denylist. Now validates against the full RFC 3986 §2 URI-reference character repertoire — only unreserved, reserved, and well-formed percent-encoded triplets (%XX) are accepted. Bare % signs and malformed sequences like %GG or %2 are rejected. Callers passing href values with spaces, <, {, }, \, ^, `, or | will now receive a TypeError. Callers producing IRIs must percent-encode non-ASCII characters before calling formatLinkHeader.

Added

  • keyGenerator option on idempotency() for store key scoping. (#227) Transforms the parsed Idempotency-Key header value into a scoped store key via (parsedKey, req, domainAcc) => string. Enables multi-tenant isolation by binding keys to auth principal, route, or HTTP method — per IETF draft-ietf-httpapi-idempotency-key-header-07 §5 composite key recommendation. Defaults to identity (unscoped), preserving existing behavior. Follows the rateLimit() keyGenerator pattern.

  • redactHeaders option on handler() for onResponse hook header redaction. (#181) Controls which response headers are replaced with '[REDACTED]' in the responseInfo snapshot passed to onResponse hooks. Defaults to authorization, proxy-authorization, cookie, set-cookie — the same set used by logger(). Pass an empty Set to disable redaction.

  • lib/redact-headers.js shared redaction primitive. (#181) Exports DEFAULT_REDACTED_HEADERS and redactHeaders(headers, redactSet). Extracted from http/logger.js’s private implementation. Available via deep import @centralping/ergo/lib/redact-headers.

  • Factory-time warning for CORS wildcard + credentials misconfiguration. (#177) cors({origins: '*', allowCredentials: true}) now emits a one-time process.emitWarning with {type: 'ErgoWarning', code: 'ERGO_CORS_WILDCARD_CREDENTIALS'}. Behavior is unchanged — origin reflection still works. The warning surfaces the OWASP-documented misconfiguration footgun at startup.

  • MemoryStore.reset() method for test isolation. (#165) Clears all tracked keys, restoring the store to its initial state. Enables integration tests that share a single store instance to reset rate-limit counters between test cases without reconstructing the middleware or router.

Fixed

  • MemoryStore constructor validates maxKeys and now parameters. (#230) maxKeys must be a positive integer; now must be a function. Invalid values that previously caused silent misconfiguration (e.g. maxKeys: null coercing to 0 in the eviction guard, making the store behave as a single-entry cache) now throw TypeError at construction time. Default construction is unaffected.

  • IdempotencyStore defensive coding improvements. (#226) Three hardening fixes for the public IdempotencyStore primitive: (1) constructor validates maxKeys (positive integer) and ttlMs (positive finite number), throwing TypeError for invalid types that previously caused silent misconfiguration; (2) get() returns a frozen deep clone instead of the live internal entry, preventing callers from corrupting store state via mutation of any nested field; (3) complete() deep-clones the response object via structuredClone, preventing post-call mutation of the original (including nested objects) from affecting stored replay data. Existing valid usage is unaffected — only previously-invalid constructor arguments now throw.

  • Cookie attribute validation uses per-attribute RFC grammars. (#218) domain validates against RFC 1034/1123 subdomain grammar (alphanumeric labels with hyphens, dot-separated, optional leading dot). path validates against RFC 6265 §4.1.2.4 path-value (any CHAR except CTLs or semicolons). sameSite validates against the RFC 6265bis {Strict, Lax, None} enum (case-insensitive). Previously all three attributes incorrectly used the cookie-octet grammar intended only for cookie values. Domain now rejects injection characters (!, #, $, *) that are invalid in DNS names. Path now accepts spaces and commas that are valid per RFC 6265. SameSite now rejects arbitrary strings that do not match the enum.

  • validateLocation uses RFC 3986 URI-reference character allowlist. (#217) Replaces the three-character denylist (CONTROL_STRIP_RE) that only stripped tab, CR, and LF. Now rejects all characters not permitted in a URI-reference — NUL, C0 controls, DEL, non-ASCII, bare percent signs, and malformed percent-encoding are caught. Follows the same allowlist pattern applied in formatLinkHeader (#207), sanitizeQuotedString (#208), and cookie-octet validation (#206).

  • parseIdempotencyKey uses RFC 8941 sf-string allowlist instead of permissive denylist. (#220) Replaced the SF_STRING_RE regex (permissive negated character class [^"\\]) with a positive allowlist derived from RFC 8941 §3.3.3’s unescaped (%x20-21 / %x23-5B / %x5D-7E) and escaped ("\" ( DQUOTE / "\" )) productions. CTL characters (\x00\x1F, \x7F), non-ASCII bytes (\x80+), and HTAB (\x09) in the inner string are now correctly rejected. The denylist escape check is eliminated — the regex itself rejects invalid escape sequences at the match stage. Previously-accepted malformed keys will now produce 400 responses (only invalid inputs are affected; all valid RFC 8941 sf-strings continue to parse correctly).

  • Parsed JSON bodies use null-prototype objects at all nesting levels. (#214) acc.body.parsed now returns objects created via Object.create(null) at every depth, aligning with the null-prototype policy enforced by query, cookie, and Prefer parsers. Applies to both the identity-encoded fast path and the compressed-body lazy getter path.

  • JSDoc bare {Buffer} replaced with {import('node:buffer').Buffer}. (#213) Two annotations in lib/idempotency.js (1) and http/body.spec.unit.js (1) used bare Buffer without the import('node:...') form required by JSDoc conventions.

  • sanitizeQuotedString uses qdtext allowlist instead of CTL denylist. (#208) Replaced the control-character denylist regex with a positive allowlist derived from the RFC 7230 §3.2.6 qdtext and quoted-pair productions. Behavior is preserved for the previous CTL cases (NUL–BS, LF–US, DEL), and the sanitizer now also strips characters outside the quoted-string latin1 allowlist. Defense-in-depth improvement: any character not explicitly allowed is now stripped rather than relying on enumerating forbidden characters.

  • Cookie value validation uses RFC 6265 cookie-octet allowlist. (#206) Replaces the denylist regex (COOKIE_VALUE_UNSAFE_RE) with an anchored allowlist (COOKIE_VALUE_RE) matching RFC 6265 §4.1.1 cookie-octet grammar. Now correctly rejects non-ASCII bytes (\x80-\xFF) that the denylist missed. Aligns with the allowlist patterns already used by assertSafeName (TOKEN_RE) and the parser (valueRFC6265).

  • Location header rejects dangerous URI schemes. (#188) send() validates responseAcc.location against javascript:, data:, and vbscript: schemes before setting the Location header. Throws TypeError for blocked schemes, providing defense-in-depth against CWE-601 XSS via open redirect.

  • JSDoc bare {Array} annotations replaced with {*[]} shorthand. (#187) Five annotations in lib/paginate.js (4) and utils/flat-array.js (1) used imprecise {Array} without type parameters. Replaced with the {*[]} shorthand for consistency with the codebase’s parameterized {Array<T>} convention.

  • Logger error() callback now redacts sensitive error details by default. (#183) When redactErrors is true (default), the error callback logs generic HTTP status text instead of err.message, and suppresses err.stack and err.originalError. Prevents sensitive error details (database connection strings, file paths, token validation messages) from leaking into structured log output. Mirrors handler()’s redactErrors behavior for HTTP response bodies, applied to the log output boundary.

  • ajv-formats default mode changed from full to fast (ReDoS mitigation). (#182) The default format validation mode now uses simplified regexes that are safe for untrusted input. Full-mode regexes for date, time, date-time, duration, uri, uri-reference, email, and idn-email are vulnerable to ReDoS with crafted payloads. Selective format arrays (e.g. formats: ['email']) continue to use full-mode regexes — ajv-formats does not support per-format mode selection. Opt in to full mode via {mode: 'full'} when strict RFC compliance is required and input sources are trusted.

  • Logger preserves empty-string request IDs (nullish coalescing). (#186) http/logger.js request-ID resolution chain now uses ?? instead of ||. An upstream proxy sending x-request-id: "" is treated as a present value rather than falling through to UUID generation.

  • Validation error path uses RFC 6901 empty string for root-level errors. (#186) lib/validate.js formatError no longer maps AJV’s empty-string instancePath to '/'. Per RFC 6901, the empty string is the correct JSON Pointer representation of the root document. Consumers matching details[].path === '/' for root-level errors should update to === ''.

  • buildResponseInfo now redacts sensitive response headers. (#181) Previously, buildResponseInfo passed res.getHeaders() directly into the response info snapshot without redaction. The onResponse hook could leak set-cookie, authorization, proxy-authorization, and cookie headers even when http/logger.js correctly redacted them. The function now accepts an optional redactSet parameter, and handler() forwards its redactHeaders option (defaulting to the same 4-header set as the logger).

  • handler() send catch block now emits errors for observability. (#179) The send() catch block previously used a bare catch without capturing the error, silently swallowing serialization failures. The error is now emitted on res via the guarded listenerCount('error') > 0 pattern (enabling http/logger.js error callbacks) and recorded on the OTEL span via span.recordException() for distributed tracing visibility. Matches the pipeline catch block’s established observability convention. Additionally, responseAcc.statusCode is now set to 500 in the send catch so the OTEL span finalization reads the correct status instead of the pipeline’s stale value.

  • Pagination prev link clamped to last page when page exceeds total. (#180) paginationLinks now generates prev pointing to lastPage instead of page - 1 when the requested page is beyond the last page, preventing clients from navigating through a chain of non-existent pages.

  • Logger redact() uses null-prototype object. (#178) The redact() helper in http/logger.js now uses Object.create(null) instead of {} for the header copy, aligning with the null-prototype policy for user-input-derived objects.

  • BodyResult.parsed type narrowed from optional to required. (#174) The parsed field was incorrectly declared as parsed?: T despite the body() middleware guaranteeing its presence on every success path (both the fast JSON path and the lazy-getter path). Consumers no longer need non-null assertions or optional chaining to access acc.body.parsed.

Documentation

  • README: elevate benchmark discoverability. (#166) Adds a “Benchmarked” bullet to the “Why ergo?” section, surfacing the published benchmarks page link within the first screenful of the README.