@centralping/ergo
Version [0.7.0]
Changed
-
IdempotencyStoreeviction lookup is now O(1) via internal side index. (#239) Added a_completeKeysSet as a secondary index tracking keys withcompletestatus. Eviction inset()now uses_completeKeys.values().next().valueinstead of scanning the entire_entriesMap. 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=Minimalnow produces{return: 'minimal'}instead of{Return: 'Minimal'}. Non-breaking for well-behaved clients: standard RFC 7240 inputs already use lowercase. -
IdempotencyStoreeviction is now status-aware with generation token validation. (#225)set()now prunes expired entries before checking capacity, preferentially evictscompleteentries overprocessingentries, and returns a generation token (string).complete(key, response, generation)accepts the generation token and returnsboolean(trueon success,falsewhen the entry is absent, evicted, or the generation token mismatches). Passingundefinedornullasresponsereturnsfalsewithout mutating the entry. When all entries areprocessingand a new entry must be stored, the oldestprocessingentry is evicted and a one-timeprocess.emitWarningis emitted with{type: 'ErgoWarning', code: 'ERGO_IDEMPOTENCY_PROCESSING_EVICTED'}. Custom store implementations must updateset()to return astringandcomplete()to accept a thirdgenerationparameter and returnboolean. -
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
tokenand §5.6.4quoted-stringgrammars. Preference names now accept the fulltcharset (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. -
formatLinkHeaderhref 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%GGor%2are rejected. Callers passing href values with spaces,<,{,},\,^,`, or|will now receive aTypeError. Callers producing IRIs must percent-encode non-ASCII characters before callingformatLinkHeader.
Added
-
keyGeneratoroption onidempotency()for store key scoping. (#227) Transforms the parsedIdempotency-Keyheader 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 therateLimit()keyGeneratorpattern. -
redactHeadersoption onhandler()for onResponse hook header redaction. (#181) Controls which response headers are replaced with'[REDACTED]'in theresponseInfosnapshot passed toonResponsehooks. Defaults toauthorization,proxy-authorization,cookie,set-cookie— the same set used bylogger(). Pass an emptySetto disable redaction. -
lib/redact-headers.jsshared redaction primitive. (#181) ExportsDEFAULT_REDACTED_HEADERSandredactHeaders(headers, redactSet). Extracted fromhttp/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-timeprocess.emitWarningwith{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
-
MemoryStoreconstructor validatesmaxKeysandnowparameters. (#230)maxKeysmust be a positive integer;nowmust be a function. Invalid values that previously caused silent misconfiguration (e.g.maxKeys: nullcoercing to0in the eviction guard, making the store behave as a single-entry cache) now throwTypeErrorat construction time. Default construction is unaffected. -
IdempotencyStoredefensive coding improvements. (#226) Three hardening fixes for the publicIdempotencyStoreprimitive: (1) constructor validatesmaxKeys(positive integer) andttlMs(positive finite number), throwingTypeErrorfor 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 viastructuredClone, 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)
domainvalidates against RFC 1034/1123 subdomain grammar (alphanumeric labels with hyphens, dot-separated, optional leading dot).pathvalidates against RFC 6265 §4.1.2.4path-value(any CHAR except CTLs or semicolons).sameSitevalidates against the RFC 6265bis{Strict, Lax, None}enum (case-insensitive). Previously all three attributes incorrectly used thecookie-octetgrammar 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. -
validateLocationuses 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 informatLinkHeader(#207),sanitizeQuotedString(#208), and cookie-octet validation (#206). -
parseIdempotencyKeyuses RFC 8941 sf-string allowlist instead of permissive denylist. (#220) Replaced theSF_STRING_REregex (permissive negated character class[^"\\]) with a positive allowlist derived from RFC 8941 §3.3.3’sunescaped(%x20-21 / %x23-5B / %x5D-7E) andescaped("\" ( 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.parsednow returns objects created viaObject.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 inlib/idempotency.js(1) andhttp/body.spec.unit.js(1) used bareBufferwithout theimport('node:...')form required by JSDoc conventions. -
sanitizeQuotedStringuses qdtext allowlist instead of CTL denylist. (#208) Replaced the control-character denylist regex with a positive allowlist derived from the RFC 7230 §3.2.6qdtextandquoted-pairproductions. 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.1cookie-octetgrammar. Now correctly rejects non-ASCII bytes (\x80-\xFF) that the denylist missed. Aligns with the allowlist patterns already used byassertSafeName(TOKEN_RE) and the parser (valueRFC6265). -
Location header rejects dangerous URI schemes. (#188)
send()validatesresponseAcc.locationagainstjavascript:,data:, andvbscript:schemes before setting the Location header. ThrowsTypeErrorfor blocked schemes, providing defense-in-depth against CWE-601 XSS via open redirect. -
JSDoc bare
{Array}annotations replaced with{*[]}shorthand. (#187) Five annotations inlib/paginate.js(4) andutils/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) WhenredactErrorsistrue(default), the error callback logs generic HTTP status text instead oferr.message, and suppresseserr.stackanderr.originalError. Prevents sensitive error details (database connection strings, file paths, token validation messages) from leaking into structured log output. Mirrorshandler()’sredactErrorsbehavior for HTTP response bodies, applied to the log output boundary. -
ajv-formatsdefault 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 fordate,time,date-time,duration,uri,uri-reference,email, andidn-emailare vulnerable to ReDoS with crafted payloads. Selective format arrays (e.g.formats: ['email']) continue to use full-mode regexes —ajv-formatsdoes 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.jsrequest-ID resolution chain now uses??instead of||. An upstream proxy sendingx-request-id: ""is treated as a present value rather than falling through to UUID generation. -
Validation error
pathuses RFC 6901 empty string for root-level errors. (#186)lib/validate.jsformatErrorno longer maps AJV’s empty-stringinstancePathto'/'. Per RFC 6901, the empty string is the correct JSON Pointer representation of the root document. Consumers matchingdetails[].path === '/'for root-level errors should update to=== ''. -
buildResponseInfonow redacts sensitive response headers. (#181) Previously,buildResponseInfopassedres.getHeaders()directly into the response info snapshot without redaction. TheonResponsehook could leakset-cookie,authorization,proxy-authorization, andcookieheaders even whenhttp/logger.jscorrectly redacted them. The function now accepts an optionalredactSetparameter, andhandler()forwards itsredactHeadersoption (defaulting to the same 4-header set as the logger). -
handler()send catch block now emits errors for observability. (#179) Thesend()catch block previously used a barecatchwithout capturing the error, silently swallowing serialization failures. The error is now emitted onresvia the guardedlistenerCount('error') > 0pattern (enablinghttp/logger.jserror callbacks) and recorded on the OTEL span viaspan.recordException()for distributed tracing visibility. Matches the pipeline catch block’s established observability convention. Additionally,responseAcc.statusCodeis now set to500in the send catch so the OTEL span finalization reads the correct status instead of the pipeline’s stale value. -
Pagination
prevlink clamped to last page whenpageexceeds total. (#180)paginationLinksnow generatesprevpointing tolastPageinstead ofpage - 1when 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) Theredact()helper inhttp/logger.jsnow usesObject.create(null)instead of{}for the header copy, aligning with the null-prototype policy for user-input-derived objects. -
BodyResult.parsedtype narrowed from optional to required. (#174) Theparsedfield was incorrectly declared asparsed?: Tdespite thebody()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 accessacc.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.