@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
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { hardDenyDeep, keyNameIsSensitive } from '../mask/defaults.js'
|
|
2
|
+
import type { Json } from '../mask/paths.js'
|
|
3
|
+
|
|
4
|
+
// Host-supplied enrichments: named functions registered via createRelay({ enrichments })
|
|
5
|
+
// and referenced from a route's map with `enrich: <name>`. They run in the host process
|
|
6
|
+
// at dispatch time, after mapping and before the bulk POST. The engine never calls out
|
|
7
|
+
// anywhere on their behalf; whatever a function does with its input is the host's code.
|
|
8
|
+
|
|
9
|
+
export interface EnrichContext {
|
|
10
|
+
routeId: string
|
|
11
|
+
/** The route's processor adapter name, e.g. "payabli". */
|
|
12
|
+
source: string
|
|
13
|
+
/** Adapter-derived id, e.g. "ApprovedPayment:txn_1" (or "sha256:…" when unknown). */
|
|
14
|
+
eventId: string
|
|
15
|
+
eventType: string | null
|
|
16
|
+
receivedAt: string
|
|
17
|
+
/** Output field being computed: "metadata.<key>" or "description". */
|
|
18
|
+
field: string
|
|
19
|
+
/** The decrypted webhook payload, for inputs beyond the `source` value. */
|
|
20
|
+
payload: Json
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Compute the value for one enriched field from the value at its `source` (after any
|
|
25
|
+
* transforms). Return `undefined` to omit the field and still send the record. Throw to
|
|
26
|
+
* retry the whole event later with backoff (the host's database was unavailable); throw
|
|
27
|
+
* `EnrichmentError` to park it instead (the input is bad and a retry cannot help).
|
|
28
|
+
*/
|
|
29
|
+
export type Enrichment = (input: Json, ctx: EnrichContext) => Json | undefined | Promise<Json | undefined>
|
|
30
|
+
|
|
31
|
+
/** Enrichment cannot be applied to this event. Parks the event; never retried. */
|
|
32
|
+
export class EnrichmentError extends Error {}
|
|
33
|
+
|
|
34
|
+
export class EnrichTimeoutError extends Error {}
|
|
35
|
+
|
|
36
|
+
function describe(value: unknown): string {
|
|
37
|
+
if (value === null) return 'null'
|
|
38
|
+
if (Array.isArray(value)) return 'an array'
|
|
39
|
+
if (typeof value === 'number') return `number ${String(value)}`
|
|
40
|
+
if (typeof value === 'object') return `a ${value.constructor?.name ?? 'non-plain object'}`
|
|
41
|
+
return `a ${typeof value}`
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
|
45
|
+
if (value === null || typeof value !== 'object') return false
|
|
46
|
+
const proto = Object.getPrototypeOf(value)
|
|
47
|
+
return proto === Object.prototype || proto === null
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function assertJson(value: unknown, path: string): void {
|
|
51
|
+
if (value === null || typeof value === 'boolean' || typeof value === 'string') return
|
|
52
|
+
if (typeof value === 'number') {
|
|
53
|
+
if (Number.isFinite(value)) return
|
|
54
|
+
throw new EnrichmentError(`enriched value at "${path}" is not JSON (${describe(value)})`)
|
|
55
|
+
}
|
|
56
|
+
if (Array.isArray(value)) {
|
|
57
|
+
value.forEach((v, i) => assertJson(v, `${path}[${i}]`))
|
|
58
|
+
return
|
|
59
|
+
}
|
|
60
|
+
if (isPlainObject(value)) {
|
|
61
|
+
for (const [k, v] of Object.entries(value)) {
|
|
62
|
+
if (keyNameIsSensitive(k)) {
|
|
63
|
+
throw new EnrichmentError(
|
|
64
|
+
`enriched value at "${path}.${k}" matches the hard denylist (cvv/ssn/account number/...) and cannot be forwarded`,
|
|
65
|
+
)
|
|
66
|
+
}
|
|
67
|
+
assertJson(v, `${path}.${k}`)
|
|
68
|
+
}
|
|
69
|
+
return
|
|
70
|
+
}
|
|
71
|
+
throw new EnrichmentError(`enriched value at "${path}" is not JSON (${describe(value)})`)
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Check what an enrichment returned before it may enter a record: JSON only (no
|
|
76
|
+
* undefined-in-objects, functions, NaN, class instances), no sensitive key names at any
|
|
77
|
+
* depth, and the hard denylist applied to every string. `undefined` means "omit".
|
|
78
|
+
*/
|
|
79
|
+
export function validateEnrichedValue(value: unknown, field: string): Json | undefined {
|
|
80
|
+
if (value === undefined) return undefined
|
|
81
|
+
assertJson(value, field)
|
|
82
|
+
return hardDenyDeep(value as Json)
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Run a host callback under a deadline. A synchronous throw becomes a rejection; the timer
|
|
87
|
+
* is cleared on settle so it never keeps the process alive. A timed-out call is abandoned,
|
|
88
|
+
* not cancelled: hosts must treat enrichments as reads that may run more than once.
|
|
89
|
+
*/
|
|
90
|
+
export function withTimeout<T>(run: () => T | Promise<T>, ms: number): Promise<T> {
|
|
91
|
+
return new Promise<T>((resolve, reject) => {
|
|
92
|
+
const timer = setTimeout(() => reject(new EnrichTimeoutError(`timed out after ${ms} ms`)), ms)
|
|
93
|
+
Promise.resolve()
|
|
94
|
+
.then(run)
|
|
95
|
+
.then(
|
|
96
|
+
(v) => {
|
|
97
|
+
clearTimeout(timer)
|
|
98
|
+
resolve(v)
|
|
99
|
+
},
|
|
100
|
+
(e: unknown) => {
|
|
101
|
+
clearTimeout(timer)
|
|
102
|
+
reject(e)
|
|
103
|
+
},
|
|
104
|
+
)
|
|
105
|
+
})
|
|
106
|
+
}
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
import { createHmac } from 'node:crypto'
|
|
2
|
+
import {
|
|
3
|
+
refEnrichment,
|
|
4
|
+
refSource,
|
|
5
|
+
refTransforms,
|
|
6
|
+
type DateRef,
|
|
7
|
+
type EnrichableFieldRef,
|
|
8
|
+
type RouteConfig,
|
|
9
|
+
type TransformName,
|
|
10
|
+
} from '../config/schema.js'
|
|
11
|
+
import { hardDenyDeep } from '../mask/defaults.js'
|
|
12
|
+
import { getAtPath, leafPaths, type Json } from '../mask/paths.js'
|
|
13
|
+
import { escapeRe } from '../util/strings.js'
|
|
14
|
+
|
|
15
|
+
export interface EndCloseRecord {
|
|
16
|
+
date: string
|
|
17
|
+
data_stream_key: string
|
|
18
|
+
amount: number
|
|
19
|
+
currency?: string
|
|
20
|
+
direction: 'credit' | 'debit'
|
|
21
|
+
description?: string
|
|
22
|
+
external_id: string
|
|
23
|
+
metadata: Record<string, Json>
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface MapReport {
|
|
27
|
+
/** output field -> source path (values marked when transformed) */
|
|
28
|
+
mapped: Record<string, string>
|
|
29
|
+
hashed: string[]
|
|
30
|
+
/** output fields whose value comes from a host enrichment, filled at dispatch time */
|
|
31
|
+
enriched: string[]
|
|
32
|
+
/** payload leaf paths that do NOT leave the network */
|
|
33
|
+
not_forwarded: string[]
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** An enriched field mapEvent could not fill: the dispatcher runs the host function. */
|
|
37
|
+
export interface PendingEnrichment {
|
|
38
|
+
/** "metadata.<key>" or "description" */
|
|
39
|
+
field: string
|
|
40
|
+
enrichment: string
|
|
41
|
+
/** The source value after transforms; what the enrichment receives. */
|
|
42
|
+
input: Json
|
|
43
|
+
source: string
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export class MappingError extends Error {}
|
|
47
|
+
|
|
48
|
+
/** "3,762.87" | "3762.87" | "$38.00" | 3762.87 → integer cents. */
|
|
49
|
+
export function toCents(value: Json): number {
|
|
50
|
+
let s: string
|
|
51
|
+
if (typeof value === 'number') s = String(value)
|
|
52
|
+
else if (typeof value === 'string') s = value.replace(/[$,\s]/g, '')
|
|
53
|
+
else throw new MappingError(`amount is not a string or number: ${JSON.stringify(value)}`)
|
|
54
|
+
if (!/^-?\d+(\.\d{1,2})?$/.test(s)) throw new MappingError(`unparseable amount: ${s}`)
|
|
55
|
+
const negative = s.startsWith('-')
|
|
56
|
+
if (negative) s = s.slice(1)
|
|
57
|
+
const [whole = '0', frac = ''] = s.split('.')
|
|
58
|
+
const cents = Number(whole) * 100 + Number(frac.padEnd(2, '0') || '0')
|
|
59
|
+
return negative ? -cents : cents
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Payabli transferTime arrives as "M/D/YYYY H:mm:ss"; End Close wants an ISO date. */
|
|
63
|
+
export function parseDate(value: Json, format: 'iso8601' | 'mdy_hms'): string {
|
|
64
|
+
if (typeof value !== 'string' || value.length === 0) {
|
|
65
|
+
throw new MappingError(`date is not a string: ${JSON.stringify(value)}`)
|
|
66
|
+
}
|
|
67
|
+
if (format === 'mdy_hms') {
|
|
68
|
+
const m = value.match(/^(\d{1,2})\/(\d{1,2})\/(\d{4})(?:\s|$)/)
|
|
69
|
+
if (!m) throw new MappingError(`unparseable M/D/YYYY date: ${value}`)
|
|
70
|
+
return `${m[3]}-${m[1]!.padStart(2, '0')}-${m[2]!.padStart(2, '0')}`
|
|
71
|
+
}
|
|
72
|
+
const parsed = new Date(value)
|
|
73
|
+
if (Number.isNaN(parsed.getTime())) throw new MappingError(`unparseable date: ${value}`)
|
|
74
|
+
return parsed.toISOString().slice(0, 10)
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function hashValue(maskingKey: Buffer, value: Json): string {
|
|
78
|
+
const raw = typeof value === 'string' ? value : JSON.stringify(value)
|
|
79
|
+
return 'hmac256:' + createHmac('sha256', maskingKey).update(raw, 'utf8').digest('hex')
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** trim/lowercase act on strings (elementwise over arrays from wildcard paths); hash consumes anything. */
|
|
83
|
+
function applyTransform(name: TransformName, value: Json, maskingKey: Buffer): Json {
|
|
84
|
+
if (name === 'hash') return hashValue(maskingKey, value)
|
|
85
|
+
if (Array.isArray(value)) return value.map((v) => applyTransform(name, v, maskingKey))
|
|
86
|
+
if (typeof value !== 'string') return value
|
|
87
|
+
return name === 'trim' ? value.trim() : value.toLowerCase()
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function resolve(ref: EnrichableFieldRef, payload: Json, maskingKey: Buffer): Json | undefined {
|
|
91
|
+
let value = getAtPath(payload, refSource(ref))
|
|
92
|
+
if (value === undefined) return undefined
|
|
93
|
+
for (const t of refTransforms(ref)) value = applyTransform(t, value, maskingKey)
|
|
94
|
+
return value
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function dateSource(ref: DateRef): { source: string; format: 'iso8601' | 'mdy_hms' } {
|
|
98
|
+
return typeof ref === 'string' ? { source: ref, format: 'iso8601' } : ref
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export interface MappedEvent {
|
|
102
|
+
/** Enriched fields are absent here until the dispatcher fills them. */
|
|
103
|
+
record: EndCloseRecord
|
|
104
|
+
report: MapReport
|
|
105
|
+
pending: PendingEnrichment[]
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Map a webhook payload to an End Close record. Only fields named in the route's `map`
|
|
110
|
+
* block are forwarded — the map IS the allowlist. Every mapped value additionally passes
|
|
111
|
+
* through the hard denylist (PAN/SSN redaction), so no configuration can forward those
|
|
112
|
+
* in clear. This is the only place payload data crosses toward the network path.
|
|
113
|
+
*/
|
|
114
|
+
export function mapEvent(
|
|
115
|
+
route: RouteConfig,
|
|
116
|
+
payload: Json,
|
|
117
|
+
receivedAt: string,
|
|
118
|
+
maskingKey: Buffer,
|
|
119
|
+
): MappedEvent {
|
|
120
|
+
const { map: m } = route
|
|
121
|
+
const report: MapReport = { mapped: {}, hashed: [], enriched: [], not_forwarded: [] }
|
|
122
|
+
const pending: PendingEnrichment[] = []
|
|
123
|
+
const usedPaths = new Set<string>()
|
|
124
|
+
|
|
125
|
+
const use = (field: string, ref: EnrichableFieldRef): Json | undefined => {
|
|
126
|
+
const value = resolve(ref, payload, maskingKey)
|
|
127
|
+
if (value === undefined) return undefined
|
|
128
|
+
const source = refSource(ref)
|
|
129
|
+
usedPaths.add(source)
|
|
130
|
+
const enrichment = refEnrichment(ref)
|
|
131
|
+
if (enrichment !== undefined) {
|
|
132
|
+
// The host fills this field later; the record leaves here without it.
|
|
133
|
+
report.mapped[field] = `${source} → enrich:${enrichment}`
|
|
134
|
+
report.enriched.push(field)
|
|
135
|
+
pending.push({ field, enrichment, input: value, source })
|
|
136
|
+
return undefined
|
|
137
|
+
}
|
|
138
|
+
report.mapped[field] = source
|
|
139
|
+
if (refTransforms(ref).includes('hash')) report.hashed.push(field)
|
|
140
|
+
return value
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const externalId = use('external_id', m.external_id)
|
|
144
|
+
if (typeof externalId !== 'string' && typeof externalId !== 'number') {
|
|
145
|
+
throw new MappingError(`missing external id at ${refSource(m.external_id)}`)
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const amount = toCents(use('amount', m.amount) ?? null)
|
|
149
|
+
|
|
150
|
+
let date: string
|
|
151
|
+
if (m.date) {
|
|
152
|
+
const { source, format } = dateSource(m.date)
|
|
153
|
+
date = parseDate(getAtPath(payload, source) ?? null, format)
|
|
154
|
+
report.mapped['date'] = source
|
|
155
|
+
usedPaths.add(source)
|
|
156
|
+
} else {
|
|
157
|
+
date = receivedAt.slice(0, 10)
|
|
158
|
+
report.mapped['date'] = '(receive time)'
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const metadata: Record<string, Json> = {}
|
|
162
|
+
for (const [outputKey, ref] of Object.entries(m.metadata)) {
|
|
163
|
+
const value = use(`metadata.${outputKey}`, ref)
|
|
164
|
+
if (value !== undefined) metadata[outputKey] = hardDenyDeep(value)
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const record: EndCloseRecord = {
|
|
168
|
+
date,
|
|
169
|
+
data_stream_key: m.data_stream_key,
|
|
170
|
+
amount,
|
|
171
|
+
direction: m.direction,
|
|
172
|
+
external_id: String(externalId),
|
|
173
|
+
metadata,
|
|
174
|
+
}
|
|
175
|
+
if (m.currency) record.currency = m.currency
|
|
176
|
+
if (m.description) {
|
|
177
|
+
const desc = use('description', m.description)
|
|
178
|
+
if (typeof desc === 'string') record.description = hardDenyDeep(desc)
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// Everything in the payload that was not explicitly mapped stays local.
|
|
182
|
+
const wildcardPrefixes = [...usedPaths]
|
|
183
|
+
.filter((p) => p.includes('*'))
|
|
184
|
+
.map((p) => new RegExp('^' + p.split('.').map((s) => (s === '*' ? '[^.]+' : escapeRe(s))).join('\\.') + '$'))
|
|
185
|
+
report.not_forwarded = leafPaths(payload).filter(
|
|
186
|
+
(leaf) => !usedPaths.has(leaf) && !wildcardPrefixes.some((re) => re.test(leaf)),
|
|
187
|
+
)
|
|
188
|
+
|
|
189
|
+
return { record, report, pending }
|
|
190
|
+
}
|
|
191
|
+
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
// @end-close/relay — the embeddable store-and-forward engine behind the End Close relay.
|
|
2
|
+
|
|
3
|
+
export { createRelay, parseRoutes, assertKnownSources, assertKnownEnrichments, routeEnrichments } from './engine/relay.js'
|
|
4
|
+
export type { Relay, RelayOptions, DispatchOnceResult, FlushResult } from './engine/relay.js'
|
|
5
|
+
export type { DispatchCounts } from './forward/dispatcher.js'
|
|
6
|
+
export { DEFAULT_DISPATCH, DEFAULT_RETENTION } from './engine/settings.js'
|
|
7
|
+
export type { DispatchSettings, RetentionSettings } from './engine/settings.js'
|
|
8
|
+
export type { IngestResult, IngestResultOutcome } from './engine/ingest.js'
|
|
9
|
+
export { eventIdempotencyKey } from './engine/ingest.js'
|
|
10
|
+
|
|
11
|
+
export * from './engine/store.js'
|
|
12
|
+
export { MemoryEventStore, memoryStore } from './engine/memory-store.js'
|
|
13
|
+
export { aesGcmCodec, plainCodec } from './engine/codec.js'
|
|
14
|
+
export type { PayloadCodec } from './engine/codec.js'
|
|
15
|
+
export { RelayHooks } from './engine/hooks.js'
|
|
16
|
+
export type {
|
|
17
|
+
RelayEvents,
|
|
18
|
+
RelayEventName,
|
|
19
|
+
RelayHandler,
|
|
20
|
+
IngestOutcome,
|
|
21
|
+
ForwardResult,
|
|
22
|
+
EnrichOutcome,
|
|
23
|
+
EngineErrorKind,
|
|
24
|
+
} from './engine/hooks.js'
|
|
25
|
+
export { envSecrets, staticSecrets, hasSecret, requireSecret, SecretUnavailableError } from './engine/secrets.js'
|
|
26
|
+
export type { SecretResolver } from './engine/secrets.js'
|
|
27
|
+
export { noopLogger, consoleLogger } from './logger.js'
|
|
28
|
+
export type { Logger, LogMeta } from './logger.js'
|
|
29
|
+
|
|
30
|
+
export * from './config/schema.js'
|
|
31
|
+
export { mapEvent, toCents, parseDate, MappingError } from './forward/mapper.js'
|
|
32
|
+
export type { EndCloseRecord, MapReport, MappedEvent, PendingEnrichment } from './forward/mapper.js'
|
|
33
|
+
export { EnrichmentError, EnrichTimeoutError, validateEnrichedValue, withTimeout } from './forward/enrich.js'
|
|
34
|
+
export type { Enrichment, EnrichContext } from './forward/enrich.js'
|
|
35
|
+
export { EndCloseClient, TransientHttpError, PermanentHttpError } from './forward/endclose-client.js'
|
|
36
|
+
export type { BulkRequestSummary, BulkResultItem } from './forward/endclose-client.js'
|
|
37
|
+
export { adapterFor, hasAdapter } from './ingest/adapters/registry.js'
|
|
38
|
+
export { payabliAdapter } from './ingest/adapters/payabli.js'
|
|
39
|
+
export { genericHmacAdapter } from './ingest/adapters/generic-hmac.js'
|
|
40
|
+
export { headerValue } from './ingest/adapters/types.js'
|
|
41
|
+
export type { ProcessorAdapter, RawRequest, VerifyContext, VerifyResult } from './ingest/adapters/types.js'
|
|
42
|
+
export { hardDenyValue, hardDenyDeep, keyNameIsSensitive, REDACTED } from './mask/defaults.js'
|
|
43
|
+
export { getAtPath, leafPaths } from './mask/paths.js'
|
|
44
|
+
export type { Json } from './mask/paths.js'
|
|
45
|
+
export { encrypt, decrypt } from './crypto/at-rest.js'
|
|
46
|
+
export { deriveKey } from './crypto/keys.js'
|
|
47
|
+
export { jsonTopLevelKeys, requestHeaderNames } from './util/payload-shape.js'
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { createHash, createHmac, timingSafeEqual } from 'node:crypto'
|
|
2
|
+
import type { RouteConfig } from '../../config/schema.js'
|
|
3
|
+
import { getAtPath, type Json } from '../../mask/paths.js'
|
|
4
|
+
import {
|
|
5
|
+
headerValue,
|
|
6
|
+
type ProcessorAdapter,
|
|
7
|
+
type RawRequest,
|
|
8
|
+
type VerifyContext,
|
|
9
|
+
type VerifyResult,
|
|
10
|
+
} from './types.js'
|
|
11
|
+
|
|
12
|
+
// Generic HMAC-signature adapter: covers processors that sign `body` or `timestamp.body`
|
|
13
|
+
// with a shared secret. Configuration lives entirely in the route's auth block, so most
|
|
14
|
+
// future processors need no code.
|
|
15
|
+
|
|
16
|
+
export const genericHmacAdapter: ProcessorAdapter = {
|
|
17
|
+
name: 'generic_hmac',
|
|
18
|
+
|
|
19
|
+
verify(req: RawRequest, route: RouteConfig, ctx: VerifyContext): VerifyResult {
|
|
20
|
+
if (route.auth.mode !== 'hmac') return { ok: false, reason: 'route auth mode mismatch' }
|
|
21
|
+
const auth = route.auth
|
|
22
|
+
|
|
23
|
+
let signedContent: Buffer = req.rawBody
|
|
24
|
+
if (auth.signed_content === 'timestamp.body') {
|
|
25
|
+
if (!auth.timestamp_header) return { ok: false, reason: 'timestamp_header not configured' }
|
|
26
|
+
const ts = headerValue(req.headers, auth.timestamp_header)
|
|
27
|
+
if (!ts) return { ok: false, reason: `missing ${auth.timestamp_header} header` }
|
|
28
|
+
const skew = Math.abs(Date.now() / 1000 - Number(ts))
|
|
29
|
+
if (!Number.isFinite(skew) || skew > auth.tolerance_seconds) {
|
|
30
|
+
return { ok: false, reason: 'stale timestamp' }
|
|
31
|
+
}
|
|
32
|
+
signedContent = Buffer.concat([Buffer.from(`${ts}.`, 'utf8'), req.rawBody])
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const presented = headerValue(req.headers, auth.header)
|
|
36
|
+
if (!presented) return { ok: false, reason: `missing ${auth.header} header` }
|
|
37
|
+
|
|
38
|
+
const expectedHex = createHmac(auth.algorithm, ctx.secret).update(signedContent).digest('hex')
|
|
39
|
+
// Accept optional "sha256=" style prefixes.
|
|
40
|
+
const presentedHex = (presented.includes('=') && !/^[0-9a-f]+$/i.test(presented)
|
|
41
|
+
? presented.slice(presented.indexOf('=') + 1)
|
|
42
|
+
: presented
|
|
43
|
+
).toLowerCase()
|
|
44
|
+
|
|
45
|
+
const a = Buffer.from(expectedHex, 'utf8')
|
|
46
|
+
const b = Buffer.from(presentedHex, 'utf8')
|
|
47
|
+
if (a.length !== b.length) {
|
|
48
|
+
timingSafeEqual(a, a)
|
|
49
|
+
return { ok: false, reason: 'bad signature' }
|
|
50
|
+
}
|
|
51
|
+
if (!timingSafeEqual(a, b)) return { ok: false, reason: 'bad signature' }
|
|
52
|
+
return { ok: true }
|
|
53
|
+
},
|
|
54
|
+
|
|
55
|
+
extractEventId(body: Json, req: RawRequest, route: RouteConfig): string {
|
|
56
|
+
if (route.auth.mode === 'hmac' && route.auth.event_id) {
|
|
57
|
+
const id = getAtPath(body, route.auth.event_id)
|
|
58
|
+
if ((typeof id === 'string' && id.length > 0) || typeof id === 'number') return String(id)
|
|
59
|
+
}
|
|
60
|
+
return 'sha256:' + createHash('sha256').update(req.rawBody).digest('hex')
|
|
61
|
+
},
|
|
62
|
+
|
|
63
|
+
extractEventType(body: Json, _req: RawRequest, route: RouteConfig): string | null {
|
|
64
|
+
if (route.auth.mode === 'hmac' && route.auth.event_type) {
|
|
65
|
+
const t = getAtPath(body, route.auth.event_type)
|
|
66
|
+
return typeof t === 'string' ? t : null
|
|
67
|
+
}
|
|
68
|
+
return null
|
|
69
|
+
},
|
|
70
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { createHash, timingSafeEqual } from 'node:crypto'
|
|
2
|
+
import type { RouteConfig } from '../../config/schema.js'
|
|
3
|
+
import { getAtPath, type Json } from '../../mask/paths.js'
|
|
4
|
+
import {
|
|
5
|
+
headerValue,
|
|
6
|
+
type ProcessorAdapter,
|
|
7
|
+
type RawRequest,
|
|
8
|
+
type VerifyContext,
|
|
9
|
+
type VerifyResult,
|
|
10
|
+
} from './types.js'
|
|
11
|
+
|
|
12
|
+
// Payabli does not sign webhooks. Trust boundary: a customer-defined static header
|
|
13
|
+
// (configured on the Payabli notification via webHeaderParameters) compared in constant
|
|
14
|
+
// time, plus an optional source-IP allowlist (Payabli publishes one static IP per env:
|
|
15
|
+
// sandbox 52.3.204.115, production 54.166.54.170).
|
|
16
|
+
|
|
17
|
+
function constantTimeEquals(a: string, b: string): boolean {
|
|
18
|
+
const ab = Buffer.from(a, 'utf8')
|
|
19
|
+
const bb = Buffer.from(b, 'utf8')
|
|
20
|
+
if (ab.length !== bb.length) {
|
|
21
|
+
// Compare against self to keep timing independent of the mismatch position.
|
|
22
|
+
timingSafeEqual(ab, ab)
|
|
23
|
+
return false
|
|
24
|
+
}
|
|
25
|
+
return timingSafeEqual(ab, bb)
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// Stable-ID field per payload Event type, per Payabli's OpenAPI webhook schemas.
|
|
29
|
+
const EVENT_ID_PATHS: Record<string, string> = {
|
|
30
|
+
TransferFunded: 'transferId', // payout_batch_settlement_funded
|
|
31
|
+
PayOutBatchPaid: 'BatchId', // payout_batch_paid
|
|
32
|
+
ApprovedPayment: 'TransactionId', // transaction-level; field names pending the customer's sample
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export const payabliAdapter: ProcessorAdapter = {
|
|
36
|
+
name: 'payabli',
|
|
37
|
+
|
|
38
|
+
verify(req: RawRequest, route: RouteConfig, ctx: VerifyContext): VerifyResult {
|
|
39
|
+
if (route.auth.mode !== 'static_header') {
|
|
40
|
+
return { ok: false, reason: 'route auth mode mismatch' }
|
|
41
|
+
}
|
|
42
|
+
if (route.auth.allowed_ips.length > 0 && !route.auth.allowed_ips.includes(req.remoteIp)) {
|
|
43
|
+
return { ok: false, reason: 'source ip not allowed' }
|
|
44
|
+
}
|
|
45
|
+
const presented = headerValue(req.headers, route.auth.header)
|
|
46
|
+
if (!presented) return { ok: false, reason: `missing ${route.auth.header} header` }
|
|
47
|
+
if (!constantTimeEquals(presented, ctx.secret)) return { ok: false, reason: 'bad auth header' }
|
|
48
|
+
return { ok: true }
|
|
49
|
+
},
|
|
50
|
+
|
|
51
|
+
extractEventId(body: Json, req: RawRequest): string {
|
|
52
|
+
const eventType = getAtPath(body, 'Event')
|
|
53
|
+
if (typeof eventType === 'string') {
|
|
54
|
+
const path = EVENT_ID_PATHS[eventType]
|
|
55
|
+
if (path) {
|
|
56
|
+
const id = getAtPath(body, path)
|
|
57
|
+
if (typeof id === 'string' && id.length > 0) return `${eventType}:${id}`
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
return 'sha256:' + createHash('sha256').update(req.rawBody).digest('hex')
|
|
61
|
+
},
|
|
62
|
+
|
|
63
|
+
extractEventType(body: Json): string | null {
|
|
64
|
+
const t = getAtPath(body, 'Event')
|
|
65
|
+
return typeof t === 'string' ? t : null
|
|
66
|
+
},
|
|
67
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { ProcessorAdapter } from './types.js'
|
|
2
|
+
import { payabliAdapter } from './payabli.js'
|
|
3
|
+
import { genericHmacAdapter } from './generic-hmac.js'
|
|
4
|
+
|
|
5
|
+
const adapters: Record<string, ProcessorAdapter> = {
|
|
6
|
+
payabli: payabliAdapter,
|
|
7
|
+
generic_hmac: genericHmacAdapter,
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function adapterFor(
|
|
11
|
+
source: string,
|
|
12
|
+
extra?: Record<string, ProcessorAdapter>,
|
|
13
|
+
): ProcessorAdapter {
|
|
14
|
+
const adapter = extra?.[source] ?? adapters[source]
|
|
15
|
+
if (!adapter) throw new Error(`no adapter for source: ${source}`)
|
|
16
|
+
return adapter
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function hasAdapter(source: string, extra?: Record<string, ProcessorAdapter>): boolean {
|
|
20
|
+
return Boolean(extra?.[source] ?? adapters[source])
|
|
21
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import type { RouteConfig } from '../../config/schema.js'
|
|
2
|
+
import type { Json } from '../../mask/paths.js'
|
|
3
|
+
|
|
4
|
+
export interface RawRequest {
|
|
5
|
+
rawBody: Buffer
|
|
6
|
+
headers: Record<string, string | string[] | undefined>
|
|
7
|
+
remoteIp: string
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export type VerifyResult = { ok: true } | { ok: false; reason: string }
|
|
11
|
+
|
|
12
|
+
/** Resolved by the engine before verification so adapters stay pure and synchronous. */
|
|
13
|
+
export interface VerifyContext {
|
|
14
|
+
/** The value of the route's `auth.secret_env` reference. */
|
|
15
|
+
secret: string
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface ProcessorAdapter {
|
|
19
|
+
name: string
|
|
20
|
+
/** Verify authenticity over the raw bytes. Must be constant-time on secret comparisons. */
|
|
21
|
+
verify(req: RawRequest, route: RouteConfig, ctx: VerifyContext): VerifyResult
|
|
22
|
+
/** Stable per-event ID used for idempotency. Falls back to a raw-body hash. */
|
|
23
|
+
extractEventId(body: Json, req: RawRequest, route: RouteConfig): string
|
|
24
|
+
extractEventType(body: Json, req: RawRequest, route: RouteConfig): string | null
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function headerValue(
|
|
28
|
+
headers: Record<string, string | string[] | undefined>,
|
|
29
|
+
name: string,
|
|
30
|
+
): string | undefined {
|
|
31
|
+
const v = headers[name.toLowerCase()]
|
|
32
|
+
return Array.isArray(v) ? v[0] : v
|
|
33
|
+
}
|
package/src/logger.ts
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
// Log metadata is restricted to scalars: there is deliberately no way to pass an object
|
|
2
|
+
// (and therefore a payload) into a log line.
|
|
3
|
+
export type LogMeta = Record<string, string | number | boolean | null | undefined>
|
|
4
|
+
|
|
5
|
+
/** The logging contract the engine depends on. Any host can supply its own implementation. */
|
|
6
|
+
export interface Logger {
|
|
7
|
+
debug(msg: string, meta?: LogMeta): void
|
|
8
|
+
info(msg: string, meta?: LogMeta): void
|
|
9
|
+
warn(msg: string, meta?: LogMeta): void
|
|
10
|
+
error(msg: string, meta?: LogMeta): void
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export const noopLogger: Logger = {
|
|
14
|
+
debug: () => {},
|
|
15
|
+
info: () => {},
|
|
16
|
+
warn: () => {},
|
|
17
|
+
error: () => {},
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** console-backed logger at warn level and above. */
|
|
21
|
+
export const consoleLogger: Logger = {
|
|
22
|
+
debug: () => {},
|
|
23
|
+
info: () => {},
|
|
24
|
+
warn: (msg, meta) => console.warn(msg, meta ?? ''),
|
|
25
|
+
error: (msg, meta) => console.error(msg, meta ?? ''),
|
|
26
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
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
|
+
|
|
5
|
+
export const REDACTED = '[REDACTED]'
|
|
6
|
+
|
|
7
|
+
const SENSITIVE_KEY_RE =
|
|
8
|
+
/(^|_|\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
|
|
9
|
+
|
|
10
|
+
export function keyNameIsSensitive(key: string): boolean {
|
|
11
|
+
// Split camelCase so e.g. RoutingNumber / accountNumber match the snake/word patterns.
|
|
12
|
+
const normalized = key.replace(/([a-z0-9])([A-Z])/g, '$1_$2')
|
|
13
|
+
return SENSITIVE_KEY_RE.test(normalized)
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const SSN_RE = /\b\d{3}-\d{2}-\d{4}\b/g
|
|
17
|
+
// Digit runs (allowing space/dash separators) long enough to be a PAN.
|
|
18
|
+
const PAN_CANDIDATE_RE = /\b\d(?:[ -]?\d){12,18}\b/g
|
|
19
|
+
|
|
20
|
+
function luhnValid(digits: string): boolean {
|
|
21
|
+
let sum = 0
|
|
22
|
+
let double = false
|
|
23
|
+
for (let i = digits.length - 1; i >= 0; i--) {
|
|
24
|
+
let d = digits.charCodeAt(i) - 48
|
|
25
|
+
if (double) {
|
|
26
|
+
d *= 2
|
|
27
|
+
if (d > 9) d -= 9
|
|
28
|
+
}
|
|
29
|
+
sum += d
|
|
30
|
+
double = !double
|
|
31
|
+
}
|
|
32
|
+
return sum % 10 === 0
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Redact SSN patterns and Luhn-passing PANs inside string values. Returns the input unchanged when clean. */
|
|
36
|
+
export function hardDenyValue(value: string): string {
|
|
37
|
+
let out = value.replace(SSN_RE, REDACTED)
|
|
38
|
+
out = out.replace(PAN_CANDIDATE_RE, (candidate) => {
|
|
39
|
+
const digits = candidate.replace(/[ -]/g, '')
|
|
40
|
+
if (digits.length >= 13 && digits.length <= 19 && luhnValid(digits)) return REDACTED
|
|
41
|
+
return candidate
|
|
42
|
+
})
|
|
43
|
+
return out
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Recursively apply hardDenyValue to every string inside a mapped JSON value. */
|
|
47
|
+
export function hardDenyDeep<T>(value: T): T {
|
|
48
|
+
if (typeof value === 'string') return hardDenyValue(value) as T
|
|
49
|
+
if (Array.isArray(value)) return value.map(hardDenyDeep) as T
|
|
50
|
+
if (value !== null && typeof value === 'object') {
|
|
51
|
+
return Object.fromEntries(
|
|
52
|
+
Object.entries(value).map(([k, v]) => [k, hardDenyDeep(v)]),
|
|
53
|
+
) as T
|
|
54
|
+
}
|
|
55
|
+
return value
|
|
56
|
+
}
|
|
@@ -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
|
+
|
|
5
|
+
export type Json = null | boolean | number | string | Json[] | { [k: string]: Json }
|
|
6
|
+
|
|
7
|
+
export function getAtPath(doc: Json, path: string): Json | undefined {
|
|
8
|
+
return getSegments(doc, path.split('.'))
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function getSegments(node: Json, segments: string[]): Json | undefined {
|
|
12
|
+
if (segments.length === 0) return node
|
|
13
|
+
const [head, ...rest] = segments
|
|
14
|
+
if (node === null || typeof node !== 'object') return undefined
|
|
15
|
+
if (head === '*') {
|
|
16
|
+
const children = Array.isArray(node) ? node : Object.values(node)
|
|
17
|
+
const out = children
|
|
18
|
+
.map((c) => getSegments(c, rest))
|
|
19
|
+
.filter((v): v is Json => v !== undefined)
|
|
20
|
+
return out
|
|
21
|
+
}
|
|
22
|
+
const child = Array.isArray(node)
|
|
23
|
+
? node[Number(head)]
|
|
24
|
+
: (node as { [k: string]: Json })[head!]
|
|
25
|
+
return child === undefined ? undefined : getSegments(child, rest)
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** All leaf paths of a document, dot-notation — used for the "not forwarded" report. */
|
|
29
|
+
export function leafPaths(doc: Json, prefix = ''): string[] {
|
|
30
|
+
if (doc === null || typeof doc !== 'object') return prefix ? [prefix] : []
|
|
31
|
+
const entries = Array.isArray(doc)
|
|
32
|
+
? doc.map((v, i) => [String(i), v] as const)
|
|
33
|
+
: Object.entries(doc)
|
|
34
|
+
if (entries.length === 0) return prefix ? [prefix] : []
|
|
35
|
+
return entries.flatMap(([k, v]) => leafPaths(v, prefix ? `${prefix}.${k}` : k))
|
|
36
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { Json } from '../mask/paths.js'
|
|
2
|
+
|
|
3
|
+
/** Comma-sorted top-level keys of a JSON object — safe for logs (no values). */
|
|
4
|
+
export function jsonTopLevelKeys(value: Json | unknown): string {
|
|
5
|
+
if (value === null || typeof value !== 'object') return typeof value
|
|
6
|
+
if (Array.isArray(value)) return '(array)'
|
|
7
|
+
return Object.keys(value).sort().join(',')
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/** Comma-sorted request header names, excluding auth/secret headers. */
|
|
11
|
+
export function requestHeaderNames(headers: Record<string, unknown>): string {
|
|
12
|
+
return Object.keys(headers)
|
|
13
|
+
.filter((h) => {
|
|
14
|
+
const n = h.toLowerCase()
|
|
15
|
+
return n !== 'authorization' && !n.includes('secret') && !n.includes('api-key')
|
|
16
|
+
})
|
|
17
|
+
.sort()
|
|
18
|
+
.join(',')
|
|
19
|
+
}
|