@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.
- package/COMPATIBILITY.md +39 -0
- package/LICENSE +21 -0
- package/README.md +205 -0
- package/dist/config/schema.d.ts +993 -0
- package/dist/config/schema.d.ts.map +1 -0
- package/dist/config/schema.js +156 -0
- package/dist/config/schema.js.map +1 -0
- package/dist/crypto/at-rest.d.ts +6 -0
- package/dist/crypto/at-rest.d.ts.map +1 -0
- package/dist/crypto/at-rest.js +19 -0
- package/dist/crypto/at-rest.js.map +1 -0
- package/dist/crypto/keys.d.ts +2 -0
- package/dist/crypto/keys.d.ts.map +1 -0
- package/dist/crypto/keys.js +12 -0
- package/dist/crypto/keys.js.map +1 -0
- package/dist/engine/codec.d.ts +12 -0
- package/dist/engine/codec.d.ts.map +1 -0
- package/dist/engine/codec.js +21 -0
- package/dist/engine/codec.js.map +1 -0
- package/dist/engine/hooks.d.ts +84 -0
- package/dist/engine/hooks.d.ts.map +1 -0
- package/dist/engine/hooks.js +34 -0
- package/dist/engine/hooks.js.map +1 -0
- package/dist/engine/ingest.d.ts +39 -0
- package/dist/engine/ingest.d.ts.map +1 -0
- package/dist/engine/ingest.js +131 -0
- package/dist/engine/ingest.js.map +1 -0
- package/dist/engine/memory-store.d.ts +35 -0
- package/dist/engine/memory-store.d.ts.map +1 -0
- package/dist/engine/memory-store.js +205 -0
- package/dist/engine/memory-store.js.map +1 -0
- package/dist/engine/relay.d.ts +116 -0
- package/dist/engine/relay.d.ts.map +1 -0
- package/dist/engine/relay.js +180 -0
- package/dist/engine/relay.js.map +1 -0
- package/dist/engine/secrets.d.ts +18 -0
- package/dist/engine/secrets.d.ts.map +1 -0
- package/dist/engine/secrets.js +42 -0
- package/dist/engine/secrets.js.map +1 -0
- package/dist/engine/settings.d.ts +29 -0
- package/dist/engine/settings.d.ts.map +1 -0
- package/dist/engine/settings.js +13 -0
- package/dist/engine/settings.js.map +1 -0
- package/dist/engine/store.d.ts +141 -0
- package/dist/engine/store.d.ts.map +1 -0
- package/dist/engine/store.js +52 -0
- package/dist/engine/store.js.map +1 -0
- package/dist/forward/backoff.d.ts +3 -0
- package/dist/forward/backoff.d.ts.map +1 -0
- package/dist/forward/backoff.js +10 -0
- package/dist/forward/backoff.js.map +1 -0
- package/dist/forward/dispatcher.d.ts +98 -0
- package/dist/forward/dispatcher.d.ts.map +1 -0
- package/dist/forward/dispatcher.js +444 -0
- package/dist/forward/dispatcher.js.map +1 -0
- package/dist/forward/endclose-client.d.ts +42 -0
- package/dist/forward/endclose-client.d.ts.map +1 -0
- package/dist/forward/endclose-client.js +76 -0
- package/dist/forward/endclose-client.js.map +1 -0
- package/dist/forward/enrich.d.ts +39 -0
- package/dist/forward/enrich.d.ts.map +1 -0
- package/dist/forward/enrich.js +77 -0
- package/dist/forward/enrich.js.map +1 -0
- package/dist/forward/mapper.d.ts +50 -0
- package/dist/forward/mapper.d.ts.map +1 -0
- package/dist/forward/mapper.js +141 -0
- package/dist/forward/mapper.js.map +1 -0
- package/dist/index.d.ts +36 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +24 -0
- package/dist/index.js.map +1 -0
- package/dist/ingest/adapters/generic-hmac.d.ts +3 -0
- package/dist/ingest/adapters/generic-hmac.d.ts.map +1 -0
- package/dist/ingest/adapters/generic-hmac.js +60 -0
- package/dist/ingest/adapters/generic-hmac.js.map +1 -0
- package/dist/ingest/adapters/payabli.d.ts +3 -0
- package/dist/ingest/adapters/payabli.d.ts.map +1 -0
- package/dist/ingest/adapters/payabli.js +57 -0
- package/dist/ingest/adapters/payabli.js.map +1 -0
- package/dist/ingest/adapters/registry.d.ts +4 -0
- package/dist/ingest/adapters/registry.d.ts.map +1 -0
- package/dist/ingest/adapters/registry.js +16 -0
- package/dist/ingest/adapters/registry.js.map +1 -0
- package/dist/ingest/adapters/types.d.ts +28 -0
- package/dist/ingest/adapters/types.d.ts.map +1 -0
- package/dist/ingest/adapters/types.js +5 -0
- package/dist/ingest/adapters/types.js.map +1 -0
- package/dist/logger.d.ts +12 -0
- package/dist/logger.d.ts.map +1 -0
- package/dist/logger.js +14 -0
- package/dist/logger.js.map +1 -0
- package/dist/mask/defaults.d.ts +7 -0
- package/dist/mask/defaults.d.ts.map +1 -0
- package/dist/mask/defaults.js +51 -0
- package/dist/mask/defaults.js.map +1 -0
- package/dist/mask/paths.d.ts +7 -0
- package/dist/mask/paths.d.ts.map +1 -0
- package/dist/mask/paths.js +36 -0
- package/dist/mask/paths.js.map +1 -0
- package/dist/util/payload-shape.d.ts +6 -0
- package/dist/util/payload-shape.d.ts.map +1 -0
- package/dist/util/payload-shape.js +19 -0
- package/dist/util/payload-shape.js.map +1 -0
- package/dist/util/strings.d.ts +3 -0
- package/dist/util/strings.d.ts.map +1 -0
- package/dist/util/strings.js +7 -0
- package/dist/util/strings.js.map +1 -0
- package/package.json +57 -0
- package/src/config/schema.ts +177 -0
- package/src/crypto/at-rest.ts +21 -0
- package/src/crypto/keys.ts +13 -0
- package/src/engine/codec.ts +30 -0
- package/src/engine/hooks.ts +76 -0
- package/src/engine/ingest.ts +194 -0
- package/src/engine/memory-store.ts +232 -0
- package/src/engine/relay.ts +294 -0
- package/src/engine/secrets.ts +51 -0
- package/src/engine/settings.ts +42 -0
- package/src/engine/store.ts +188 -0
- package/src/forward/backoff.ts +10 -0
- package/src/forward/dispatcher.ts +504 -0
- package/src/forward/endclose-client.ts +102 -0
- package/src/forward/enrich.ts +106 -0
- package/src/forward/mapper.ts +191 -0
- package/src/index.ts +47 -0
- package/src/ingest/adapters/generic-hmac.ts +70 -0
- package/src/ingest/adapters/payabli.ts +67 -0
- package/src/ingest/adapters/registry.ts +21 -0
- package/src/ingest/adapters/types.ts +33 -0
- package/src/logger.ts +26 -0
- package/src/mask/defaults.ts +56 -0
- package/src/mask/paths.ts +36 -0
- package/src/util/payload-shape.ts +19 -0
- 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 @@
|
|
|
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 @@
|
|
|
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
|
+
}
|