@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,39 @@
1
+ # Compatibility
2
+
3
+ Within a major version the following are stable and change only additively:
4
+
5
+ - **`createRelay()` and its options** (`RelayOptions` including `enrichments`,
6
+ `DispatchSettings` including `enrichTimeoutMs`, `RetentionSettings` and their defaults),
7
+ the `Relay` methods (`ingest`, `start`, `stop`, `dispatchOnce`, `flush`, `prune`, `preview`,
8
+ `readPayload`, `on`) and their result shapes (`IngestResult`, `DispatchOnceResult`,
9
+ `FlushResult` including its `reason` values).
10
+ - **The enrichment contract**: `Enrichment` (`(input, ctx) => value | undefined`, sync or
11
+ async), `EnrichContext` fields (may be added, never removed or retyped), the meaning of
12
+ `undefined` (omit), a throw (retry) and `EnrichmentError` (park). Validation of returned
13
+ values may tighten in a minor release, like denylist patterns.
14
+ - **The routes document** (`routes:` in relay.yaml / `parseRoutes`): field names, transforms
15
+ (`trim`, `lowercase`, `hash`), the `enrich:` key on `description` and `metadata` field refs,
16
+ `auth` modes, the hard denylist's *existence*. Field-ref objects are strict: unknown keys
17
+ are errors. Secrets are referenced by name (`auth.secret_env`), never by value. Denylist
18
+ *patterns* may tighten in a minor release.
19
+ - **`mapEvent` output**: the record shape (`date`, `data_stream_key`, `amount` in integer
20
+ cents, `direction`, `external_id`, `currency?`, `description?`, `metadata`), plus
21
+ `pending` (enriched fields the dispatcher fills) and `report.enriched`.
22
+ - **Idempotency derivations**: per event `sha256(source + ":" + eventId)`; per bulk request
23
+ `"relay-" + sha256("<data_stream_key>:<external_id>\n" per record)[0:40]`; bulk POSTs use
24
+ `on_conflict: "skip"`.
25
+ - **`EventStore` / `EventStoreAdmin` / `ControlStore` / `RouteProvider`** interfaces and the
26
+ behavioural contract in `@end-close/relay-store-contract`. New optional methods may be added;
27
+ required methods are not. A store signals lock contention or loss of connection by throwing
28
+ `StoreUnavailableError` (ingest answers 503); any other `StoreError` answers 500.
29
+ - **`IngestResult`** status codes and outcomes.
30
+ - **Hook event names and payload fields** (fields may be added).
31
+ - **Adapter interface** `ProcessorAdapter` (`verify`, `extractEventId`, `extractEventType`).
32
+
33
+ Not covered: the internal `Dispatcher` class, package file layout, log message text.
34
+
35
+ `@end-close/relay`, `@end-close/relay-sqlite` and `@end-close/relay-store-contract` are published
36
+ to npm in lockstep with the relay application: every release tags one version, and all three
37
+ packages carry it. Install them at the same version. The release PR stamps the product
38
+ version into each package manifest (`0.0.0` only ever means "not yet released"). While the
39
+ major is `0`, a minor bump may carry breaking changes; patch releases are additive.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 End Close
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,205 @@
1
+ # @end-close/relay
2
+
3
+ The store-and-forward engine behind the End Close relay application, as a library you embed in
4
+ your own Node backend. Same code, same guarantees:
5
+
6
+ 1. **Verify** each webhook over the raw bytes (Payabli static header + source IP; HMAC for
7
+ processors that sign).
8
+ 2. **Persist it durably before acknowledging** in a store you choose.
9
+ 3. **Map** it to an End Close record through an explicit field map: the map *is* the
10
+ allowlist, and a non-configurable hard denylist (PANs, SSNs, secret-named fields) applies
11
+ on top. A mapped field may name an **enrichment**: a function of yours that turns the
12
+ payload value into what is forwarded (a resident's name from a payer id, say). The map
13
+ still names every field; your code only fills the ones it points at.
14
+ 4. **Forward** in batches to End Close's public API with exponential backoff, idempotency
15
+ at both ends, and parking (never silent dropping) of events that cannot be delivered.
16
+
17
+ Node `>=22.12`, ESM. The only runtime dependency is `zod`.
18
+
19
+ ## Install
20
+
21
+ ```sh
22
+ npm install @end-close/relay @end-close/relay-sqlite
23
+ ```
24
+
25
+ Install both at the same version: `@end-close/relay-sqlite` peers on `@end-close/relay`, and the
26
+ packages are released together in lockstep with the relay application (the version on npm is the
27
+ product version). Skip `@end-close/relay-sqlite` if you bring your own `EventStore`. MIT licensed.
28
+
29
+ ## Quick start
30
+
31
+ ```ts
32
+ import { createRelay, parseRoutes, envSecrets } from '@end-close/relay'
33
+ import { SqliteEventStore, SqliteControlStore, openDb, migrate } from '@end-close/relay-sqlite'
34
+ import { parse } from 'yaml'
35
+ import { readFileSync } from 'node:fs'
36
+
37
+ const db = openDb('/var/lib/myapp/relay.db')
38
+ migrate(db)
39
+
40
+ const relay = createRelay({
41
+ // The `routes` block of relay.yaml, validated. Only fields named in each `map` are forwarded
42
+ // (pass `{ enrichments }` as the second argument if any route uses `enrich:`).
43
+ routes: parseRoutes(parse(readFileSync('relay.yaml', 'utf8'))),
44
+ store: new SqliteEventStore(db),
45
+ control: new SqliteControlStore(db), // killswitch + per-route pause; omit for in-memory
46
+ secrets: envSecrets(process.env), // resolves `auth.secret_env` names
47
+ endclose: { apiKey: process.env.ENDCLOSE_API_KEY! },
48
+ encryption: { dataKey: process.env.RELAY_DATA_KEY! }, // or 'none' (be explicit)
49
+ maskingKey: process.env.MASKING_HMAC_KEY!, // keys the `hash` transform
50
+ logger: myLogger, // { debug, info, warn, error }(msg, scalarMeta)
51
+ instanceId: 'api-1', // stable per replica: a restart reclaims its own batch at once
52
+ })
53
+
54
+ // 1. Mount the ingest path in your HTTP framework. Give it the RAW body bytes.
55
+ app.post('/webhooks/:route', async (req, res) => {
56
+ const result = await relay.ingest(req.params.route, {
57
+ rawBody: req.rawBody, // Buffer, exactly as sent
58
+ headers: req.headers,
59
+ remoteIp: req.ip, // the true client IP (see below)
60
+ })
61
+ res.status(result.status).json(result.body)
62
+ })
63
+
64
+ // 2a. Long-lived process: run the dispatch loop.
65
+ relay.start()
66
+ process.on('SIGTERM', () => relay.stop().then(() => process.exit(0)))
67
+
68
+ // 2b. Serverless / cron: run one cycle per invocation instead.
69
+ // await relay.dispatchOnce({ prune: true })
70
+ // 2c. Deliver before this process exits (shutdown, a serverless function):
71
+ // const out = await relay.flush({ timeoutMs: 5_000 }) // { delivered, retried, parked, drained, reason? }
72
+ ```
73
+
74
+ ## Knowing what happened to an event
75
+
76
+ `ingest()` resolves as soon as the event is durably in the store; a 2xx means **buffered**,
77
+ never **sent**. The accepted result carries the store `id`. From there:
78
+
79
+ - `dispatchOnce()` runs exactly one cycle (at most `batchMax` per route) and returns counts.
80
+ Use it from a scheduler; overlapping runs are safe because claims are leased.
81
+ - `flush()` loops cycles until nothing deliverable remains or the deadline passes, retrying
82
+ as backoff timers expire. It returns immediately with `reason: 'paused'` if forwarding is
83
+ paused, `reason: 'unroutable'` if due events belong to routes the provider no longer
84
+ returns, and `reason: 'timeout'` with `retried > 0` if End Close stayed down. Flushing
85
+ cannot make an unavailable End Close accept records: with `memoryStore()` those events
86
+ are lost when the process exits, with a durable store the next cycle picks them up.
87
+ - `relay.on('settled', e => …)` fires per event with `{ id, routeId, result, error? }` where
88
+ `result` is `delivered`, `retried` or `parked` — correlate with the `id` from `ingest()`.
89
+ - `relay.store.getById(id)` (stores with the admin capability) gives the current `status`,
90
+ `attempts`, `next_attempt_at` and `last_error`.
91
+
92
+ **Serverless recipe:** a durable store shared across invocations, `dispatchOnce()` on a
93
+ schedule as the guarantee, and optionally `await relay.flush()` after `ingest()` for low
94
+ latency when End Close is healthy.
95
+
96
+ ## Enriching fields from your own data
97
+
98
+ A webhook rarely carries everything End Close should see. When the missing value lives in
99
+ your own systems, register an **enrichment** and name it from the map:
100
+
101
+ ```ts
102
+ const enrichments = {
103
+ // (value at `source` after transforms, ctx) → the value to forward. undefined → omit the field.
104
+ resident_name: async (payorId, ctx) => {
105
+ const r = await residents.findByPayerId(String(payorId)) // your database, your code
106
+ return r?.fullName
107
+ },
108
+ }
109
+ const relay = createRelay({
110
+ routes: parseRoutes(parse(yaml), { enrichments }), // validation knows the registered names
111
+ enrichments,
112
+ dispatch: { enrichTimeoutMs: 3_000 }, // per call; default 5 s
113
+ ...
114
+ })
115
+ ```
116
+
117
+ ```yaml
118
+ map:
119
+ external_id: TransactionId
120
+ amount: NetAmount
121
+ direction: credit
122
+ metadata:
123
+ paypoint: Paypoint
124
+ resident_name: { source: PayorId, enrich: resident_name }
125
+ resident_unit: { source: PayorId, transform: trim, enrich: resident_unit }
126
+ ```
127
+
128
+ How it behaves:
129
+
130
+ - **The map stays the allowlist.** `enrich:` is allowed on `metadata` entries and
131
+ `description` only; `external_id` and `amount` are never host-computed. A route that names
132
+ an enrichment you have not registered fails `parseRoutes` / `createRelay` (or parks its
133
+ events, for routes from a `RouteProvider`). The shipped application registers none, so it
134
+ rejects `enrich:` outright.
135
+ - **Order per field:** source value → `transform`s → your function → validation → hard
136
+ denylist → record. If the source is absent the function is not called and the field is
137
+ omitted. `ctx` carries `routeId`, `source`, `eventId`, `eventType`, `receivedAt`, `field`
138
+ and the decrypted `payload` for inputs beyond the one value.
139
+ - **Return `undefined`** to omit the field and still send the record (unknown payer).
140
+ **Throw** to retry the whole event with backoff — the rest of its batch still ships, and it
141
+ parks after `parkAfterMs` like any retrying event. **Throw `EnrichmentError`** to park it
142
+ now. A call slower than `enrichTimeoutMs` counts as a throw.
143
+ - **What you return is checked like any mapped value:** JSON only, no sensitive key names
144
+ at any depth, PANs and SSNs inside strings redacted, and a sensitive output name (`ssn`,
145
+ `account_number`, …) is refused by the schema even with `transform: hash`, since the
146
+ hash protects the input, not your output. A bad value parks the event with the reason.
147
+ - **Enrichments are reads that may run more than once per event** (retry, lease recovery,
148
+ replay). Keep them idempotent and side-effect free, and keep thrown messages free of
149
+ personal data: they are stored as `last_error` and emitted on `settled`.
150
+ - Calls run one at a time per event, so a batch can take up to `batchMax × enrichments per
151
+ event × enrichTimeoutMs`; keep that under `leaseMs` (defaults: 100 × 1 × 5 s vs 600 s).
152
+ - `relay.on('enrich', e => …)` reports each call as `{ routeId, id, field, enrichment,
153
+ result: applied | omitted | failed | rejected, error? }` — names only, never values.
154
+ - `relay.preview(route, sample)` does not run enrichments; it lists their fields in
155
+ `report.enriched` and `pending`.
156
+
157
+ What your framework must do because the engine cannot:
158
+
159
+ - **Hand over the raw body.** Signature verification and the stored payload operate on the
160
+ exact bytes the processor sent. Disable JSON pre-parsing for the webhook route.
161
+ - **Cap request size** before it reaches `ingest` (the application uses 10 MB); per-route
162
+ `max_body_bytes` is enforced inside.
163
+ - **Pass the real client IP** if you use `allowed_ips` (configure your proxy trust).
164
+
165
+ ## Storage
166
+
167
+ `EventStore` is an async interface (`packages/core/src/engine/store.ts`). Ships with:
168
+
169
+ - `@end-close/relay-sqlite` — the application's store. Rollback journal + `synchronous=FULL`,
170
+ safe on network filesystems. Add `SqliteControlStore` for killswitch state.
171
+ - `memoryStore()` — in-process, **not durable**. For development and tests.
172
+
173
+ Any implementation that passes `describeEventStoreContract()` from
174
+ `@end-close/relay-store-contract` works. Claiming is lease-based, so several instances can
175
+ share one store (a SQL store would use `FOR UPDATE SKIP LOCKED` in `claimDue`). Give each
176
+ long-lived replica a stable, distinct `instanceId`: on boot an instance reclaims batches it
177
+ left `delivering` (a crash), and every `recoverIntervalMs` it sweeps leases other instances
178
+ let expire. A random id works too; a crashed replica's batch then waits out `leaseMs`.
179
+
180
+ ## Observability
181
+
182
+ `relay.on(event, handler)` delivers metadata-only events: `ingest`, `stored`, `settled`,
183
+ `forward`, `delivered`, `enrich`, `batch.forwarded`, `batch.parked`, `prune`, `error`. Payloads
184
+ and enriched values are never included.
185
+ The application drives its Prometheus metrics and call-home from these; the engine itself
186
+ never phones home.
187
+
188
+ ## Operating
189
+
190
+ - `relay.control.setKillswitch('pause' | 'panic' | 'none')`, `setRoutePaused(id, bool)`.
191
+ - `relay.store` with `EventStoreAdmin` (SQLite and memory stores have it): `list`, `getById`,
192
+ `replay`, `replayAllParked`, `countByStatus`, `perRouteStats`.
193
+ - `relay.preview(route, samplePayload)` shows exactly what would leave your network, with
194
+ enriched fields listed as pending rather than computed.
195
+ - `relay.readPayload(id)` decrypts a buffered payload. Audit it yourself.
196
+ - Retention (`retention: { deliveredDays, ledgerDays }`, or `false`) runs hourly under
197
+ `start()` or on `dispatchOnce({ prune: true })` / `relay.prune()`.
198
+
199
+ ## Lower-level pieces
200
+
201
+ For a backend with its own queue: `verify` via `adapterFor(source).verify(raw, route, { secret })`,
202
+ `mapEvent(route, payload, receivedAt, maskingKey)`, `EndCloseClient`, `hardDenyDeep`,
203
+ `keyNameIsSensitive`, `parseRoutes`.
204
+
205
+ See [`COMPATIBILITY.md`](./COMPATIBILITY.md) for what is a stable contract.