@end-close/relay 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (134) hide show
  1. package/COMPATIBILITY.md +39 -0
  2. package/LICENSE +21 -0
  3. package/README.md +205 -0
  4. package/dist/config/schema.d.ts +993 -0
  5. package/dist/config/schema.d.ts.map +1 -0
  6. package/dist/config/schema.js +156 -0
  7. package/dist/config/schema.js.map +1 -0
  8. package/dist/crypto/at-rest.d.ts +6 -0
  9. package/dist/crypto/at-rest.d.ts.map +1 -0
  10. package/dist/crypto/at-rest.js +19 -0
  11. package/dist/crypto/at-rest.js.map +1 -0
  12. package/dist/crypto/keys.d.ts +2 -0
  13. package/dist/crypto/keys.d.ts.map +1 -0
  14. package/dist/crypto/keys.js +12 -0
  15. package/dist/crypto/keys.js.map +1 -0
  16. package/dist/engine/codec.d.ts +12 -0
  17. package/dist/engine/codec.d.ts.map +1 -0
  18. package/dist/engine/codec.js +21 -0
  19. package/dist/engine/codec.js.map +1 -0
  20. package/dist/engine/hooks.d.ts +84 -0
  21. package/dist/engine/hooks.d.ts.map +1 -0
  22. package/dist/engine/hooks.js +34 -0
  23. package/dist/engine/hooks.js.map +1 -0
  24. package/dist/engine/ingest.d.ts +39 -0
  25. package/dist/engine/ingest.d.ts.map +1 -0
  26. package/dist/engine/ingest.js +131 -0
  27. package/dist/engine/ingest.js.map +1 -0
  28. package/dist/engine/memory-store.d.ts +35 -0
  29. package/dist/engine/memory-store.d.ts.map +1 -0
  30. package/dist/engine/memory-store.js +205 -0
  31. package/dist/engine/memory-store.js.map +1 -0
  32. package/dist/engine/relay.d.ts +116 -0
  33. package/dist/engine/relay.d.ts.map +1 -0
  34. package/dist/engine/relay.js +180 -0
  35. package/dist/engine/relay.js.map +1 -0
  36. package/dist/engine/secrets.d.ts +18 -0
  37. package/dist/engine/secrets.d.ts.map +1 -0
  38. package/dist/engine/secrets.js +42 -0
  39. package/dist/engine/secrets.js.map +1 -0
  40. package/dist/engine/settings.d.ts +29 -0
  41. package/dist/engine/settings.d.ts.map +1 -0
  42. package/dist/engine/settings.js +13 -0
  43. package/dist/engine/settings.js.map +1 -0
  44. package/dist/engine/store.d.ts +141 -0
  45. package/dist/engine/store.d.ts.map +1 -0
  46. package/dist/engine/store.js +52 -0
  47. package/dist/engine/store.js.map +1 -0
  48. package/dist/forward/backoff.d.ts +3 -0
  49. package/dist/forward/backoff.d.ts.map +1 -0
  50. package/dist/forward/backoff.js +10 -0
  51. package/dist/forward/backoff.js.map +1 -0
  52. package/dist/forward/dispatcher.d.ts +98 -0
  53. package/dist/forward/dispatcher.d.ts.map +1 -0
  54. package/dist/forward/dispatcher.js +444 -0
  55. package/dist/forward/dispatcher.js.map +1 -0
  56. package/dist/forward/endclose-client.d.ts +42 -0
  57. package/dist/forward/endclose-client.d.ts.map +1 -0
  58. package/dist/forward/endclose-client.js +76 -0
  59. package/dist/forward/endclose-client.js.map +1 -0
  60. package/dist/forward/enrich.d.ts +39 -0
  61. package/dist/forward/enrich.d.ts.map +1 -0
  62. package/dist/forward/enrich.js +77 -0
  63. package/dist/forward/enrich.js.map +1 -0
  64. package/dist/forward/mapper.d.ts +50 -0
  65. package/dist/forward/mapper.d.ts.map +1 -0
  66. package/dist/forward/mapper.js +141 -0
  67. package/dist/forward/mapper.js.map +1 -0
  68. package/dist/index.d.ts +36 -0
  69. package/dist/index.d.ts.map +1 -0
  70. package/dist/index.js +24 -0
  71. package/dist/index.js.map +1 -0
  72. package/dist/ingest/adapters/generic-hmac.d.ts +3 -0
  73. package/dist/ingest/adapters/generic-hmac.d.ts.map +1 -0
  74. package/dist/ingest/adapters/generic-hmac.js +60 -0
  75. package/dist/ingest/adapters/generic-hmac.js.map +1 -0
  76. package/dist/ingest/adapters/payabli.d.ts +3 -0
  77. package/dist/ingest/adapters/payabli.d.ts.map +1 -0
  78. package/dist/ingest/adapters/payabli.js +57 -0
  79. package/dist/ingest/adapters/payabli.js.map +1 -0
  80. package/dist/ingest/adapters/registry.d.ts +4 -0
  81. package/dist/ingest/adapters/registry.d.ts.map +1 -0
  82. package/dist/ingest/adapters/registry.js +16 -0
  83. package/dist/ingest/adapters/registry.js.map +1 -0
  84. package/dist/ingest/adapters/types.d.ts +28 -0
  85. package/dist/ingest/adapters/types.d.ts.map +1 -0
  86. package/dist/ingest/adapters/types.js +5 -0
  87. package/dist/ingest/adapters/types.js.map +1 -0
  88. package/dist/logger.d.ts +12 -0
  89. package/dist/logger.d.ts.map +1 -0
  90. package/dist/logger.js +14 -0
  91. package/dist/logger.js.map +1 -0
  92. package/dist/mask/defaults.d.ts +7 -0
  93. package/dist/mask/defaults.d.ts.map +1 -0
  94. package/dist/mask/defaults.js +51 -0
  95. package/dist/mask/defaults.js.map +1 -0
  96. package/dist/mask/paths.d.ts +7 -0
  97. package/dist/mask/paths.d.ts.map +1 -0
  98. package/dist/mask/paths.js +36 -0
  99. package/dist/mask/paths.js.map +1 -0
  100. package/dist/util/payload-shape.d.ts +6 -0
  101. package/dist/util/payload-shape.d.ts.map +1 -0
  102. package/dist/util/payload-shape.js +19 -0
  103. package/dist/util/payload-shape.js.map +1 -0
  104. package/dist/util/strings.d.ts +3 -0
  105. package/dist/util/strings.d.ts.map +1 -0
  106. package/dist/util/strings.js +7 -0
  107. package/dist/util/strings.js.map +1 -0
  108. package/package.json +57 -0
  109. package/src/config/schema.ts +177 -0
  110. package/src/crypto/at-rest.ts +21 -0
  111. package/src/crypto/keys.ts +13 -0
  112. package/src/engine/codec.ts +30 -0
  113. package/src/engine/hooks.ts +76 -0
  114. package/src/engine/ingest.ts +194 -0
  115. package/src/engine/memory-store.ts +232 -0
  116. package/src/engine/relay.ts +294 -0
  117. package/src/engine/secrets.ts +51 -0
  118. package/src/engine/settings.ts +42 -0
  119. package/src/engine/store.ts +188 -0
  120. package/src/forward/backoff.ts +10 -0
  121. package/src/forward/dispatcher.ts +504 -0
  122. package/src/forward/endclose-client.ts +102 -0
  123. package/src/forward/enrich.ts +106 -0
  124. package/src/forward/mapper.ts +191 -0
  125. package/src/index.ts +47 -0
  126. package/src/ingest/adapters/generic-hmac.ts +70 -0
  127. package/src/ingest/adapters/payabli.ts +67 -0
  128. package/src/ingest/adapters/registry.ts +21 -0
  129. package/src/ingest/adapters/types.ts +33 -0
  130. package/src/logger.ts +26 -0
  131. package/src/mask/defaults.ts +56 -0
  132. package/src/mask/paths.ts +36 -0
  133. package/src/util/payload-shape.ts +19 -0
  134. package/src/util/strings.ts +7 -0
package/dist/logger.js ADDED
@@ -0,0 +1,14 @@
1
+ export const noopLogger = {
2
+ debug: () => { },
3
+ info: () => { },
4
+ warn: () => { },
5
+ error: () => { },
6
+ };
7
+ /** console-backed logger at warn level and above. */
8
+ export const consoleLogger = {
9
+ debug: () => { },
10
+ info: () => { },
11
+ warn: (msg, meta) => console.warn(msg, meta ?? ''),
12
+ error: (msg, meta) => console.error(msg, meta ?? ''),
13
+ };
14
+ //# sourceMappingURL=logger.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"logger.js","sourceRoot":"","sources":["../src/logger.ts"],"names":[],"mappings":"AAYA,MAAM,CAAC,MAAM,UAAU,GAAW;IAChC,KAAK,EAAE,GAAG,EAAE,GAAE,CAAC;IACf,IAAI,EAAE,GAAG,EAAE,GAAE,CAAC;IACd,IAAI,EAAE,GAAG,EAAE,GAAE,CAAC;IACd,KAAK,EAAE,GAAG,EAAE,GAAE,CAAC;CAChB,CAAA;AAED,qDAAqD;AACrD,MAAM,CAAC,MAAM,aAAa,GAAW;IACnC,KAAK,EAAE,GAAG,EAAE,GAAE,CAAC;IACf,IAAI,EAAE,GAAG,EAAE,GAAE,CAAC;IACd,IAAI,EAAE,CAAC,GAAG,EAAE,IAAI,EAAE,EAAE,CAAC,OAAO,CAAC,IAAI,CAAC,GAAG,EAAE,IAAI,IAAI,EAAE,CAAC;IAClD,KAAK,EAAE,CAAC,GAAG,EAAE,IAAI,EAAE,EAAE,CAAC,OAAO,CAAC,KAAK,CAAC,GAAG,EAAE,IAAI,IAAI,EAAE,CAAC;CACrD,CAAA"}
@@ -0,0 +1,7 @@
1
+ export declare const REDACTED = "[REDACTED]";
2
+ export declare function keyNameIsSensitive(key: string): boolean;
3
+ /** Redact SSN patterns and Luhn-passing PANs inside string values. Returns the input unchanged when clean. */
4
+ export declare function hardDenyValue(value: string): string;
5
+ /** Recursively apply hardDenyValue to every string inside a mapped JSON value. */
6
+ export declare function hardDenyDeep<T>(value: T): T;
7
+ //# sourceMappingURL=defaults.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"defaults.d.ts","sourceRoot":"","sources":["../../src/mask/defaults.ts"],"names":[],"mappings":"AAIA,eAAO,MAAM,QAAQ,eAAe,CAAA;AAKpC,wBAAgB,kBAAkB,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAIvD;AAqBD,8GAA8G;AAC9G,wBAAgB,aAAa,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,CAQnD;AAED,kFAAkF;AAClF,wBAAgB,YAAY,CAAC,CAAC,EAAE,KAAK,EAAE,CAAC,GAAG,CAAC,CAS3C"}
@@ -0,0 +1,51 @@
1
+ // Hard denylist: applied to every mapped value in every route, not configurable. If one
2
+ // of these patterns fires on data a customer legitimately needs, the answer is a
3
+ // deliberate code change here — never a config override.
4
+ export const REDACTED = '[REDACTED]';
5
+ const SENSITIVE_KEY_RE = /(^|_|\b)(cvv2?|cvc|cid|pin|password|passwd|secret|api[-_]?key|private[-_]?key|access[-_]?token|refresh[-_]?token|ssn|social[-_]?security|routing[-_]?number|account[-_]?number)($|_|\b)/i;
6
+ export function keyNameIsSensitive(key) {
7
+ // Split camelCase so e.g. RoutingNumber / accountNumber match the snake/word patterns.
8
+ const normalized = key.replace(/([a-z0-9])([A-Z])/g, '$1_$2');
9
+ return SENSITIVE_KEY_RE.test(normalized);
10
+ }
11
+ const SSN_RE = /\b\d{3}-\d{2}-\d{4}\b/g;
12
+ // Digit runs (allowing space/dash separators) long enough to be a PAN.
13
+ const PAN_CANDIDATE_RE = /\b\d(?:[ -]?\d){12,18}\b/g;
14
+ function luhnValid(digits) {
15
+ let sum = 0;
16
+ let double = false;
17
+ for (let i = digits.length - 1; i >= 0; i--) {
18
+ let d = digits.charCodeAt(i) - 48;
19
+ if (double) {
20
+ d *= 2;
21
+ if (d > 9)
22
+ d -= 9;
23
+ }
24
+ sum += d;
25
+ double = !double;
26
+ }
27
+ return sum % 10 === 0;
28
+ }
29
+ /** Redact SSN patterns and Luhn-passing PANs inside string values. Returns the input unchanged when clean. */
30
+ export function hardDenyValue(value) {
31
+ let out = value.replace(SSN_RE, REDACTED);
32
+ out = out.replace(PAN_CANDIDATE_RE, (candidate) => {
33
+ const digits = candidate.replace(/[ -]/g, '');
34
+ if (digits.length >= 13 && digits.length <= 19 && luhnValid(digits))
35
+ return REDACTED;
36
+ return candidate;
37
+ });
38
+ return out;
39
+ }
40
+ /** Recursively apply hardDenyValue to every string inside a mapped JSON value. */
41
+ export function hardDenyDeep(value) {
42
+ if (typeof value === 'string')
43
+ return hardDenyValue(value);
44
+ if (Array.isArray(value))
45
+ return value.map(hardDenyDeep);
46
+ if (value !== null && typeof value === 'object') {
47
+ return Object.fromEntries(Object.entries(value).map(([k, v]) => [k, hardDenyDeep(v)]));
48
+ }
49
+ return value;
50
+ }
51
+ //# sourceMappingURL=defaults.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"defaults.js","sourceRoot":"","sources":["../../src/mask/defaults.ts"],"names":[],"mappings":"AAAA,wFAAwF;AACxF,iFAAiF;AACjF,yDAAyD;AAEzD,MAAM,CAAC,MAAM,QAAQ,GAAG,YAAY,CAAA;AAEpC,MAAM,gBAAgB,GACpB,0LAA0L,CAAA;AAE5L,MAAM,UAAU,kBAAkB,CAAC,GAAW;IAC5C,uFAAuF;IACvF,MAAM,UAAU,GAAG,GAAG,CAAC,OAAO,CAAC,oBAAoB,EAAE,OAAO,CAAC,CAAA;IAC7D,OAAO,gBAAgB,CAAC,IAAI,CAAC,UAAU,CAAC,CAAA;AAC1C,CAAC;AAED,MAAM,MAAM,GAAG,wBAAwB,CAAA;AACvC,uEAAuE;AACvE,MAAM,gBAAgB,GAAG,2BAA2B,CAAA;AAEpD,SAAS,SAAS,CAAC,MAAc;IAC/B,IAAI,GAAG,GAAG,CAAC,CAAA;IACX,IAAI,MAAM,GAAG,KAAK,CAAA;IAClB,KAAK,IAAI,CAAC,GAAG,MAAM,CAAC,MAAM,GAAG,CAAC,EAAE,CAAC,IAAI,CAAC,EAAE,CAAC,EAAE,EAAE,CAAC;QAC5C,IAAI,CAAC,GAAG,MAAM,CAAC,UAAU,CAAC,CAAC,CAAC,GAAG,EAAE,CAAA;QACjC,IAAI,MAAM,EAAE,CAAC;YACX,CAAC,IAAI,CAAC,CAAA;YACN,IAAI,CAAC,GAAG,CAAC;gBAAE,CAAC,IAAI,CAAC,CAAA;QACnB,CAAC;QACD,GAAG,IAAI,CAAC,CAAA;QACR,MAAM,GAAG,CAAC,MAAM,CAAA;IAClB,CAAC;IACD,OAAO,GAAG,GAAG,EAAE,KAAK,CAAC,CAAA;AACvB,CAAC;AAED,8GAA8G;AAC9G,MAAM,UAAU,aAAa,CAAC,KAAa;IACzC,IAAI,GAAG,GAAG,KAAK,CAAC,OAAO,CAAC,MAAM,EAAE,QAAQ,CAAC,CAAA;IACzC,GAAG,GAAG,GAAG,CAAC,OAAO,CAAC,gBAAgB,EAAE,CAAC,SAAS,EAAE,EAAE;QAChD,MAAM,MAAM,GAAG,SAAS,CAAC,OAAO,CAAC,OAAO,EAAE,EAAE,CAAC,CAAA;QAC7C,IAAI,MAAM,CAAC,MAAM,IAAI,EAAE,IAAI,MAAM,CAAC,MAAM,IAAI,EAAE,IAAI,SAAS,CAAC,MAAM,CAAC;YAAE,OAAO,QAAQ,CAAA;QACpF,OAAO,SAAS,CAAA;IAClB,CAAC,CAAC,CAAA;IACF,OAAO,GAAG,CAAA;AACZ,CAAC;AAED,kFAAkF;AAClF,MAAM,UAAU,YAAY,CAAI,KAAQ;IACtC,IAAI,OAAO,KAAK,KAAK,QAAQ;QAAE,OAAO,aAAa,CAAC,KAAK,CAAM,CAAA;IAC/D,IAAI,KAAK,CAAC,OAAO,CAAC,KAAK,CAAC;QAAE,OAAO,KAAK,CAAC,GAAG,CAAC,YAAY,CAAM,CAAA;IAC7D,IAAI,KAAK,KAAK,IAAI,IAAI,OAAO,KAAK,KAAK,QAAQ,EAAE,CAAC;QAChD,OAAO,MAAM,CAAC,WAAW,CACvB,MAAM,CAAC,OAAO,CAAC,KAAK,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,EAAE,YAAY,CAAC,CAAC,CAAC,CAAC,CAAC,CACvD,CAAA;IACR,CAAC;IACD,OAAO,KAAK,CAAA;AACd,CAAC"}
@@ -0,0 +1,7 @@
1
+ export type Json = null | boolean | number | string | Json[] | {
2
+ [k: string]: Json;
3
+ };
4
+ export declare function getAtPath(doc: Json, path: string): Json | undefined;
5
+ /** All leaf paths of a document, dot-notation — used for the "not forwarded" report. */
6
+ export declare function leafPaths(doc: Json, prefix?: string): string[];
7
+ //# sourceMappingURL=paths.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"paths.d.ts","sourceRoot":"","sources":["../../src/mask/paths.ts"],"names":[],"mappings":"AAIA,MAAM,MAAM,IAAI,GAAG,IAAI,GAAG,OAAO,GAAG,MAAM,GAAG,MAAM,GAAG,IAAI,EAAE,GAAG;IAAE,CAAC,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;CAAE,CAAA;AAEpF,wBAAgB,SAAS,CAAC,GAAG,EAAE,IAAI,EAAE,IAAI,EAAE,MAAM,GAAG,IAAI,GAAG,SAAS,CAEnE;AAmBD,wFAAwF;AACxF,wBAAgB,SAAS,CAAC,GAAG,EAAE,IAAI,EAAE,MAAM,SAAK,GAAG,MAAM,EAAE,CAO1D"}
@@ -0,0 +1,36 @@
1
+ // Dot-notation paths into JSON documents: "batchId", "batch.id", "transactions.*.id".
2
+ // A `*` segment fans out over every array element (or object value) and the result is
3
+ // the array of matches.
4
+ export function getAtPath(doc, path) {
5
+ return getSegments(doc, path.split('.'));
6
+ }
7
+ function getSegments(node, segments) {
8
+ if (segments.length === 0)
9
+ return node;
10
+ const [head, ...rest] = segments;
11
+ if (node === null || typeof node !== 'object')
12
+ return undefined;
13
+ if (head === '*') {
14
+ const children = Array.isArray(node) ? node : Object.values(node);
15
+ const out = children
16
+ .map((c) => getSegments(c, rest))
17
+ .filter((v) => v !== undefined);
18
+ return out;
19
+ }
20
+ const child = Array.isArray(node)
21
+ ? node[Number(head)]
22
+ : node[head];
23
+ return child === undefined ? undefined : getSegments(child, rest);
24
+ }
25
+ /** All leaf paths of a document, dot-notation — used for the "not forwarded" report. */
26
+ export function leafPaths(doc, prefix = '') {
27
+ if (doc === null || typeof doc !== 'object')
28
+ return prefix ? [prefix] : [];
29
+ const entries = Array.isArray(doc)
30
+ ? doc.map((v, i) => [String(i), v])
31
+ : Object.entries(doc);
32
+ if (entries.length === 0)
33
+ return prefix ? [prefix] : [];
34
+ return entries.flatMap(([k, v]) => leafPaths(v, prefix ? `${prefix}.${k}` : k));
35
+ }
36
+ //# sourceMappingURL=paths.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"paths.js","sourceRoot":"","sources":["../../src/mask/paths.ts"],"names":[],"mappings":"AAAA,sFAAsF;AACtF,sFAAsF;AACtF,wBAAwB;AAIxB,MAAM,UAAU,SAAS,CAAC,GAAS,EAAE,IAAY;IAC/C,OAAO,WAAW,CAAC,GAAG,EAAE,IAAI,CAAC,KAAK,CAAC,GAAG,CAAC,CAAC,CAAA;AAC1C,CAAC;AAED,SAAS,WAAW,CAAC,IAAU,EAAE,QAAkB;IACjD,IAAI,QAAQ,CAAC,MAAM,KAAK,CAAC;QAAE,OAAO,IAAI,CAAA;IACtC,MAAM,CAAC,IAAI,EAAE,GAAG,IAAI,CAAC,GAAG,QAAQ,CAAA;IAChC,IAAI,IAAI,KAAK,IAAI,IAAI,OAAO,IAAI,KAAK,QAAQ;QAAE,OAAO,SAAS,CAAA;IAC/D,IAAI,IAAI,KAAK,GAAG,EAAE,CAAC;QACjB,MAAM,QAAQ,GAAG,KAAK,CAAC,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,MAAM,CAAC,MAAM,CAAC,IAAI,CAAC,CAAA;QACjE,MAAM,GAAG,GAAG,QAAQ;aACjB,GAAG,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,WAAW,CAAC,CAAC,EAAE,IAAI,CAAC,CAAC;aAChC,MAAM,CAAC,CAAC,CAAC,EAAa,EAAE,CAAC,CAAC,KAAK,SAAS,CAAC,CAAA;QAC5C,OAAO,GAAG,CAAA;IACZ,CAAC;IACD,MAAM,KAAK,GAAG,KAAK,CAAC,OAAO,CAAC,IAAI,CAAC;QAC/B,CAAC,CAAC,IAAI,CAAC,MAAM,CAAC,IAAI,CAAC,CAAC;QACpB,CAAC,CAAE,IAA8B,CAAC,IAAK,CAAC,CAAA;IAC1C,OAAO,KAAK,KAAK,SAAS,CAAC,CAAC,CAAC,SAAS,CAAC,CAAC,CAAC,WAAW,CAAC,KAAK,EAAE,IAAI,CAAC,CAAA;AACnE,CAAC;AAED,wFAAwF;AACxF,MAAM,UAAU,SAAS,CAAC,GAAS,EAAE,MAAM,GAAG,EAAE;IAC9C,IAAI,GAAG,KAAK,IAAI,IAAI,OAAO,GAAG,KAAK,QAAQ;QAAE,OAAO,MAAM,CAAC,CAAC,CAAC,CAAC,MAAM,CAAC,CAAC,CAAC,CAAC,EAAE,CAAA;IAC1E,MAAM,OAAO,GAAG,KAAK,CAAC,OAAO,CAAC,GAAG,CAAC;QAChC,CAAC,CAAC,GAAG,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,CAAC,EAAE,EAAE,CAAC,CAAC,MAAM,CAAC,CAAC,CAAC,EAAE,CAAC,CAAU,CAAC;QAC5C,CAAC,CAAC,MAAM,CAAC,OAAO,CAAC,GAAG,CAAC,CAAA;IACvB,IAAI,OAAO,CAAC,MAAM,KAAK,CAAC;QAAE,OAAO,MAAM,CAAC,CAAC,CAAC,CAAC,MAAM,CAAC,CAAC,CAAC,CAAC,EAAE,CAAA;IACvD,OAAO,OAAO,CAAC,OAAO,CAAC,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,EAAE,EAAE,CAAC,SAAS,CAAC,CAAC,EAAE,MAAM,CAAC,CAAC,CAAC,GAAG,MAAM,IAAI,CAAC,EAAE,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAA;AACjF,CAAC"}
@@ -0,0 +1,6 @@
1
+ import type { Json } from '../mask/paths.js';
2
+ /** Comma-sorted top-level keys of a JSON object — safe for logs (no values). */
3
+ export declare function jsonTopLevelKeys(value: Json | unknown): string;
4
+ /** Comma-sorted request header names, excluding auth/secret headers. */
5
+ export declare function requestHeaderNames(headers: Record<string, unknown>): string;
6
+ //# sourceMappingURL=payload-shape.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"payload-shape.d.ts","sourceRoot":"","sources":["../../src/util/payload-shape.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,IAAI,EAAE,MAAM,kBAAkB,CAAA;AAE5C,gFAAgF;AAChF,wBAAgB,gBAAgB,CAAC,KAAK,EAAE,IAAI,GAAG,OAAO,GAAG,MAAM,CAI9D;AAED,wEAAwE;AACxE,wBAAgB,kBAAkB,CAAC,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,MAAM,CAQ3E"}
@@ -0,0 +1,19 @@
1
+ /** Comma-sorted top-level keys of a JSON object — safe for logs (no values). */
2
+ export function jsonTopLevelKeys(value) {
3
+ if (value === null || typeof value !== 'object')
4
+ return typeof value;
5
+ if (Array.isArray(value))
6
+ return '(array)';
7
+ return Object.keys(value).sort().join(',');
8
+ }
9
+ /** Comma-sorted request header names, excluding auth/secret headers. */
10
+ export function requestHeaderNames(headers) {
11
+ return Object.keys(headers)
12
+ .filter((h) => {
13
+ const n = h.toLowerCase();
14
+ return n !== 'authorization' && !n.includes('secret') && !n.includes('api-key');
15
+ })
16
+ .sort()
17
+ .join(',');
18
+ }
19
+ //# sourceMappingURL=payload-shape.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"payload-shape.js","sourceRoot":"","sources":["../../src/util/payload-shape.ts"],"names":[],"mappings":"AAEA,gFAAgF;AAChF,MAAM,UAAU,gBAAgB,CAAC,KAAqB;IACpD,IAAI,KAAK,KAAK,IAAI,IAAI,OAAO,KAAK,KAAK,QAAQ;QAAE,OAAO,OAAO,KAAK,CAAA;IACpE,IAAI,KAAK,CAAC,OAAO,CAAC,KAAK,CAAC;QAAE,OAAO,SAAS,CAAA;IAC1C,OAAO,MAAM,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC,IAAI,EAAE,CAAC,IAAI,CAAC,GAAG,CAAC,CAAA;AAC5C,CAAC;AAED,wEAAwE;AACxE,MAAM,UAAU,kBAAkB,CAAC,OAAgC;IACjE,OAAO,MAAM,CAAC,IAAI,CAAC,OAAO,CAAC;SACxB,MAAM,CAAC,CAAC,CAAC,EAAE,EAAE;QACZ,MAAM,CAAC,GAAG,CAAC,CAAC,WAAW,EAAE,CAAA;QACzB,OAAO,CAAC,KAAK,eAAe,IAAI,CAAC,CAAC,CAAC,QAAQ,CAAC,QAAQ,CAAC,IAAI,CAAC,CAAC,CAAC,QAAQ,CAAC,SAAS,CAAC,CAAA;IACjF,CAAC,CAAC;SACD,IAAI,EAAE;SACN,IAAI,CAAC,GAAG,CAAC,CAAA;AACd,CAAC"}
@@ -0,0 +1,3 @@
1
+ export declare function escapeRe(s: string): string;
2
+ export declare function sleep(ms: number): Promise<void>;
3
+ //# sourceMappingURL=strings.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"strings.d.ts","sourceRoot":"","sources":["../../src/util/strings.ts"],"names":[],"mappings":"AAAA,wBAAgB,QAAQ,CAAC,CAAC,EAAE,MAAM,GAAG,MAAM,CAE1C;AAED,wBAAgB,KAAK,CAAC,EAAE,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAE/C"}
@@ -0,0 +1,7 @@
1
+ export function escapeRe(s) {
2
+ return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
3
+ }
4
+ export function sleep(ms) {
5
+ return new Promise((r) => setTimeout(r, ms));
6
+ }
7
+ //# sourceMappingURL=strings.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"strings.js","sourceRoot":"","sources":["../../src/util/strings.ts"],"names":[],"mappings":"AAAA,MAAM,UAAU,QAAQ,CAAC,CAAS;IAChC,OAAO,CAAC,CAAC,OAAO,CAAC,qBAAqB,EAAE,MAAM,CAAC,CAAA;AACjD,CAAC;AAED,MAAM,UAAU,KAAK,CAAC,EAAU;IAC9B,OAAO,IAAI,OAAO,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,UAAU,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAA;AAC9C,CAAC"}
package/package.json ADDED
@@ -0,0 +1,57 @@
1
+ {
2
+ "name": "@end-close/relay",
3
+ "version": "0.12.0",
4
+ "description": "Embeddable store-and-forward engine for delivering payment-processor webhooks to End Close: verify, buffer durably, map through an explicit allowlist, forward with retries and idempotency.",
5
+ "keywords": [
6
+ "endclose",
7
+ "webhook",
8
+ "relay",
9
+ "store-and-forward",
10
+ "payabli",
11
+ "payments"
12
+ ],
13
+ "license": "MIT",
14
+ "homepage": "https://github.com/End-Close/endclose-relay/tree/main/packages/core#readme",
15
+ "bugs": {
16
+ "url": "https://github.com/End-Close/endclose-relay/issues"
17
+ },
18
+ "repository": {
19
+ "type": "git",
20
+ "url": "git+https://github.com/End-Close/endclose-relay.git",
21
+ "directory": "packages/core"
22
+ },
23
+ "type": "module",
24
+ "sideEffects": false,
25
+ "main": "./dist/index.js",
26
+ "types": "./dist/index.d.ts",
27
+ "exports": {
28
+ ".": {
29
+ "types": "./dist/index.d.ts",
30
+ "default": "./dist/index.js"
31
+ },
32
+ "./package.json": "./package.json"
33
+ },
34
+ "files": [
35
+ "dist",
36
+ "!dist/tsconfig.tsbuildinfo",
37
+ "src",
38
+ "COMPATIBILITY.md"
39
+ ],
40
+ "publishConfig": {
41
+ "access": "public",
42
+ "provenance": true
43
+ },
44
+ "engines": {
45
+ "node": ">=22.12"
46
+ },
47
+ "dependencies": {
48
+ "zod": "^3.24.2"
49
+ },
50
+ "devDependencies": {
51
+ "yaml": "^2.7.0"
52
+ },
53
+ "scripts": {
54
+ "build": "tsc -b tsconfig.json",
55
+ "typecheck": "tsc --noEmit -p tsconfig.json"
56
+ }
57
+ }
@@ -0,0 +1,177 @@
1
+ import { z } from 'zod'
2
+ import { keyNameIsSensitive } from '../mask/defaults.js'
3
+
4
+ // Dot-notation path into the webhook payload, e.g. "batchId", "batch.id",
5
+ // "transactions.*.id" (a `*` segment fans out over an array).
6
+ const dotPath = z
7
+ .string()
8
+ .regex(/^[^\s.]+(\.[^\s.]+)*$/, 'must be a dot-notation path like batchId or batch.id')
9
+
10
+ export const transformNameSchema = z.enum(['trim', 'lowercase', 'hash'])
11
+ export type TransformName = z.infer<typeof transformNameSchema>
12
+
13
+ // A mapped field: either a bare source path, or an object with optional transforms
14
+ // (single or array, applied in order).
15
+ // external_id: transferId
16
+ // customer_email: { source: CustomerEmail, transform: hash }
17
+ // customer_email: { source: CustomerEmail, transform: [trim, lowercase, hash] }
18
+ // Strict, so a stray key (a typo, or `enrich` where it is not allowed) is an error
19
+ // rather than silently ignored.
20
+ export const fieldRefSchema = z.union([
21
+ dotPath,
22
+ z
23
+ .object({
24
+ source: dotPath,
25
+ transform: z.union([transformNameSchema, z.array(transformNameSchema).min(1)]).optional(),
26
+ })
27
+ .strict(),
28
+ ])
29
+ export type FieldRef = z.infer<typeof fieldRefSchema>
30
+
31
+ // Name of an enrichment registered by the host via createRelay({ enrichments }). Only
32
+ // `description` and `metadata` entries may name one: the value at `source` (after any
33
+ // transforms) is handed to the host function and its return value is what is forwarded.
34
+ export const enrichNameSchema = z
35
+ .string()
36
+ .regex(/^[a-z][a-z0-9_]*$/, 'enrichment name must be a lowercase snake_case identifier')
37
+
38
+ // resident_name: { source: PayorId, enrich: resident_name }
39
+ // resident_unit: { source: PayorId, transform: trim, enrich: resident_unit }
40
+ export const enrichableFieldRefSchema = z.union([
41
+ dotPath,
42
+ z
43
+ .object({
44
+ source: dotPath,
45
+ transform: z.union([transformNameSchema, z.array(transformNameSchema).min(1)]).optional(),
46
+ enrich: enrichNameSchema.optional(),
47
+ })
48
+ .strict(),
49
+ ])
50
+ export type EnrichableFieldRef = z.infer<typeof enrichableFieldRefSchema>
51
+
52
+ export function refSource(ref: EnrichableFieldRef): string {
53
+ return typeof ref === 'string' ? ref : ref.source
54
+ }
55
+ export function refTransforms(ref: EnrichableFieldRef): TransformName[] {
56
+ if (typeof ref === 'string' || ref.transform === undefined) return []
57
+ return Array.isArray(ref.transform) ? ref.transform : [ref.transform]
58
+ }
59
+ /** The enrichment a field names, if any. */
60
+ export function refEnrichment(ref: EnrichableFieldRef): string | undefined {
61
+ return typeof ref === 'string' ? undefined : ref.enrich
62
+ }
63
+
64
+ const dateRefSchema = z.union([
65
+ dotPath,
66
+ z.object({
67
+ source: dotPath,
68
+ format: z.enum(['iso8601', 'mdy_hms']).default('iso8601'),
69
+ }),
70
+ ])
71
+ export type DateRef = z.infer<typeof dateRefSchema>
72
+
73
+ // A secret reference: the NAME the host's SecretResolver resolves (an env var in the
74
+ // application). The value itself never appears in config or the database. The field is
75
+ // deliberately named as a reference so a value is never mistaken for it.
76
+ const secretRef = z.string().min(1)
77
+
78
+ export const payabliAuthSchema = z.object({
79
+ mode: z.literal('static_header'),
80
+ header: z.string().default('authorization'),
81
+ secret_env: secretRef,
82
+ allowed_ips: z.array(z.string()).default([]),
83
+ })
84
+
85
+ export const genericHmacAuthSchema = z.object({
86
+ mode: z.literal('hmac'),
87
+ header: z.string(),
88
+ algorithm: z.enum(['sha256', 'sha512']).default('sha256'),
89
+ secret_env: secretRef,
90
+ // What gets signed: '{body}' or '{timestamp}.{body}'
91
+ signed_content: z.enum(['body', 'timestamp.body']).default('body'),
92
+ timestamp_header: z.string().optional(),
93
+ tolerance_seconds: z.number().int().positive().default(300),
94
+ // Where this processor keeps its stable event ID / event type in the payload.
95
+ event_id: dotPath.optional(),
96
+ event_type: dotPath.optional(),
97
+ })
98
+
99
+ // How a webhook event becomes an End Close record. This block is the complete answer to
100
+ // "what leaves our network": only fields named here are forwarded, ever.
101
+ export const recordMapSchema = z
102
+ .object({
103
+ data_stream_key: z.string().min(1),
104
+ external_id: fieldRefSchema,
105
+ // Source of a string amount like "3,762.87"; converted to integer cents.
106
+ amount: fieldRefSchema,
107
+ direction: z.enum(['credit', 'debit']),
108
+ // Optional payload timestamp. Absent -> the record is dated by receive time.
109
+ date: dateRefSchema.optional(),
110
+ description: enrichableFieldRefSchema.optional(),
111
+ currency: z
112
+ .string()
113
+ .regex(/^[A-Za-z]{3}$/)
114
+ .optional(),
115
+ // Extra fields to forward, as output_name: source. Output names are what End Close
116
+ // property definitions see, so choose clean snake_case names.
117
+ metadata: z.record(z.string().regex(/^[a-z][a-z0-9_]*$/), enrichableFieldRefSchema).default({}),
118
+ })
119
+ .superRefine((map, ctx) => {
120
+ for (const [outputKey, ref] of Object.entries(map.metadata)) {
121
+ const sourceLeaf = refSource(ref).split('.').at(-1)!
122
+ const hashed = refTransforms(ref).includes('hash')
123
+ const enriched = refEnrichment(ref) !== undefined
124
+ for (const name of [outputKey, sourceLeaf]) {
125
+ if (!keyNameIsSensitive(name)) continue
126
+ // `hash` protects the source value. An enriched field forwards whatever the host
127
+ // returns, so hashing the input does not make a sensitive output name safe.
128
+ if (hashed && !(enriched && name === outputKey)) continue
129
+ ctx.addIssue({
130
+ code: z.ZodIssueCode.custom,
131
+ path: ['metadata', outputKey],
132
+ message: enriched && name === outputKey
133
+ ? `"${name}" matches the hard denylist (cvv/ssn/account number/...); an enriched field cannot use it as an output name`
134
+ : `"${name}" matches the hard denylist (cvv/ssn/account number/...); it cannot be forwarded in clear — use transform: hash or remove it`,
135
+ })
136
+ }
137
+ }
138
+ })
139
+
140
+ /** Built-in processor adapters. Hosts may register more via createRelay({ adapters }). */
141
+ export const BUILTIN_SOURCES = ['payabli', 'generic_hmac'] as const
142
+
143
+ export const routeSchema = z.object({
144
+ id: z
145
+ .string()
146
+ .regex(/^[a-z0-9][a-z0-9-_]*$/, 'route id must be a lowercase slug'),
147
+ // The processor adapter name: a built-in, or one registered by the host.
148
+ source: z.string().regex(/^[a-z0-9][a-z0-9_-]*$/, 'source must be a lowercase slug'),
149
+ auth: z.discriminatedUnion('mode', [payabliAuthSchema, genericHmacAuthSchema]),
150
+ // Payload event types accepted by this route (adapter-extracted; glob '*' allowed).
151
+ // Non-matching events persist locally as dropped_by_filter and are never forwarded.
152
+ events: z.array(z.string()).min(1).optional(),
153
+ map: recordMapSchema,
154
+ max_body_bytes: z
155
+ .number()
156
+ .int()
157
+ .positive()
158
+ .max(10 * 1024 * 1024)
159
+ .default(1024 * 1024),
160
+ })
161
+
162
+
163
+ // The config document is ROUTES ONLY (strict: unknown top-level keys are rejected).
164
+ // Everything else (End Close endpoint, ports, dispatch/retention tuning) is a boot-time
165
+ // runtime setting from the environment — see src/config/runtime.ts. Consequence: every
166
+ // applied config change takes effect live; there is no "restart pending" state.
167
+ export const relayConfigSchema = z
168
+ .object({
169
+ routes: z.array(routeSchema).min(1),
170
+ })
171
+ .strict()
172
+
173
+ export type RelayConfig = z.infer<typeof relayConfigSchema>
174
+ export type RouteConfig = z.infer<typeof routeSchema>
175
+ export type RecordMap = z.infer<typeof recordMapSchema>
176
+ export type PayabliAuth = z.infer<typeof payabliAuthSchema>
177
+ export type GenericHmacAuth = z.infer<typeof genericHmacAuthSchema>
@@ -0,0 +1,21 @@
1
+ import { createCipheriv, createDecipheriv, randomBytes } from 'node:crypto'
2
+
3
+ // AES-256-GCM with a per-row random IV. Ciphertext layout: <ciphertext><16-byte auth tag>.
4
+ const ALGO = 'aes-256-gcm'
5
+ const IV_BYTES = 12
6
+ const TAG_BYTES = 16
7
+
8
+ export function encrypt(key: Buffer, plaintext: Buffer): { ciphertext: Buffer; iv: Buffer } {
9
+ const iv = randomBytes(IV_BYTES)
10
+ const cipher = createCipheriv(ALGO, key, iv)
11
+ const ciphertext = Buffer.concat([cipher.update(plaintext), cipher.final(), cipher.getAuthTag()])
12
+ return { ciphertext, iv }
13
+ }
14
+
15
+ export function decrypt(key: Buffer, ciphertext: Buffer, iv: Buffer): Buffer {
16
+ const tag = ciphertext.subarray(ciphertext.length - TAG_BYTES)
17
+ const body = ciphertext.subarray(0, ciphertext.length - TAG_BYTES)
18
+ const decipher = createDecipheriv(ALGO, key, iv)
19
+ decipher.setAuthTag(tag)
20
+ return Buffer.concat([decipher.update(body), decipher.final()])
21
+ }
@@ -0,0 +1,13 @@
1
+ import { createHash } from 'node:crypto'
2
+
3
+ // RELAY_DATA_KEY and MASKING_HMAC_KEY are operator-supplied strings (32+ chars of entropy
4
+ // recommended). We derive fixed-length keys by hashing so any string works, but refuse
5
+ // obviously weak values.
6
+ const MIN_KEY_CHARS = 16
7
+
8
+ export function deriveKey(envName: string, raw: string | undefined): Buffer {
9
+ if (!raw || raw.length < MIN_KEY_CHARS) {
10
+ throw new Error(`${envName} must be set to at least ${MIN_KEY_CHARS} characters`)
11
+ }
12
+ return createHash('sha256').update(raw, 'utf8').digest()
13
+ }
@@ -0,0 +1,30 @@
1
+ import { encrypt, decrypt } from '../crypto/at-rest.js'
2
+
3
+ // How buffered payloads are stored. Rows always hold whatever the codec produced; only the
4
+ // dispatcher and an audited operator read path decode. Encryption is chosen explicitly by
5
+ // the host — there is no silent plaintext default.
6
+
7
+ export interface PayloadCodec {
8
+ encode(plain: Buffer): { payload: Buffer; iv: Buffer | null }
9
+ decode(payload: Buffer, iv: Buffer | null): Buffer
10
+ }
11
+
12
+ /** AES-256-GCM under a 32-byte key (see crypto/keys.ts deriveKey). */
13
+ export function aesGcmCodec(key: Buffer): PayloadCodec {
14
+ return {
15
+ encode(plain) {
16
+ const { ciphertext, iv } = encrypt(key, plain)
17
+ return { payload: ciphertext, iv }
18
+ },
19
+ decode(payload, iv) {
20
+ if (!iv) throw new Error('encrypted payload has no iv')
21
+ return decrypt(key, payload, iv)
22
+ },
23
+ }
24
+ }
25
+
26
+ /** Stores payloads as-is. Only appropriate when the store itself is encrypted or trusted. */
27
+ export const plainCodec: PayloadCodec = {
28
+ encode: (plain) => ({ payload: plain, iv: null }),
29
+ decode: (payload) => payload,
30
+ }
@@ -0,0 +1,76 @@
1
+ // Typed, metadata-only events the engine emits. Hosts subscribe to drive metrics,
2
+ // telemetry, or their own observability; the engine itself depends on none of them.
3
+ // Payloads carry route ids, counts, timestamps, field names and error kinds — never
4
+ // webhook data or enriched values.
5
+
6
+ export type IngestOutcome =
7
+ | 'accepted'
8
+ | 'duplicate'
9
+ | 'filtered'
10
+ | 'rejected_auth'
11
+ | 'rejected_size'
12
+ | 'rejected_json'
13
+ | 'panic'
14
+ export type ForwardResult = 'delivered' | 'retried' | 'parked'
15
+ /**
16
+ * applied: the field was filled. omitted: the enrichment returned undefined. failed: it
17
+ * threw or timed out (the event retries). rejected: its result or the reference was
18
+ * invalid (the event parks).
19
+ */
20
+ export type EnrichOutcome = 'applied' | 'omitted' | 'failed' | 'rejected'
21
+
22
+ export type EngineErrorKind = 'dispatch_cycle' | 'ingest_persist' | 'prune' | 'recover_delivering'
23
+
24
+ export interface RelayEvents {
25
+ /** Every ingest request that reached a known route, by outcome. */
26
+ ingest: { routeId: string; outcome: IngestOutcome; eventType: string | null; bodyBytes: number }
27
+ /** A new event row was persisted (not emitted for duplicates). */
28
+ stored: { routeId: string; id: string; filtered: boolean }
29
+ /** Per-event forwarding results. */
30
+ forward: { routeId: string; result: ForwardResult; count: number }
31
+ /** Per-event outcome of a dispatch attempt, keyed by the store id `ingest` returned. */
32
+ settled: { id: string; routeId: string; result: ForwardResult; error?: string }
33
+ /** One event confirmed delivered; carries the timestamps for lag measurement. */
34
+ delivered: { routeId: string; receivedAt: string; deliveredAt: string }
35
+ /** One host enrichment call for one field of one event. Names only, never values. */
36
+ enrich: { routeId: string; id: string; field: string; enrichment: string; result: EnrichOutcome; error?: string }
37
+ 'batch.forwarded': { routeId: string; events: number; bulkRequestId: string }
38
+ /** A whole batch was permanently rejected by End Close. */
39
+ 'batch.parked': { routeId: string; status: number; events: number }
40
+ prune: { wiped: number; deleted: number }
41
+ /** An operational failure inside the engine (already logged). */
42
+ error: { kind: EngineErrorKind; error: unknown; op?: string; routeId?: string }
43
+ }
44
+
45
+ export type RelayEventName = keyof RelayEvents
46
+ export type RelayHandler<E extends RelayEventName> = (payload: RelayEvents[E]) => void
47
+
48
+ export class RelayHooks {
49
+ // Internally untyped; the public on/emit signatures enforce payload types.
50
+ private handlers = new Map<RelayEventName, Set<(payload: unknown) => void>>()
51
+
52
+ /** Subscribe; returns an unsubscribe function. */
53
+ on<E extends RelayEventName>(name: E, handler: RelayHandler<E>): () => void {
54
+ let set = this.handlers.get(name)
55
+ if (!set) {
56
+ set = new Set()
57
+ this.handlers.set(name, set)
58
+ }
59
+ const h = handler as (payload: unknown) => void
60
+ set.add(h)
61
+ return () => set.delete(h)
62
+ }
63
+
64
+ /** Handlers must not throw; a throwing handler is isolated so it cannot break the engine. */
65
+ emit<E extends RelayEventName>(name: E, payload: RelayEvents[E]): void {
66
+ const set = this.handlers.get(name)
67
+ if (!set) return
68
+ for (const h of set) {
69
+ try {
70
+ h(payload)
71
+ } catch {
72
+ // observers never affect ingest or dispatch
73
+ }
74
+ }
75
+ }
76
+ }