@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
@@ -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
+ }