@rewloy/node 0.1.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.
@@ -0,0 +1,3 @@
1
+ // Generated by scripts/generate.ts from the Rewloy OpenAPI document
2
+ // (openapi/openapi.json, API 1.0.0). Do not edit: run `npm run generate`.
3
+ export {};
@@ -0,0 +1,22 @@
1
+ /**
2
+ * @rewloy/node — the official Node.js and TypeScript library for the Rewloy API.
3
+ *
4
+ * ```ts
5
+ * import { Rewloy } from '@rewloy/node';
6
+ * const rewloy = new Rewloy({ apiKey: process.env.REWLOY_API_KEY! });
7
+ * ```
8
+ */
9
+ export { Rewloy, DEFAULT_BASE_URL } from './client.js';
10
+ export type { RewloyOptions } from './client.js';
11
+ export { RewloyError, RateLimitError, RewloyConnectionError, RewloyTimeoutError } from './errors.js';
12
+ export type { RewloyErrorInit } from './errors.js';
13
+ export { EventStream, SseParser } from './sse.js';
14
+ export type { ServerSentEvent } from './sse.js';
15
+ export { verifyWebhook, signWebhook, WebhookSignatureError } from './webhooks.js';
16
+ export type { WebhookEvent, PassEventData, VerifyWebhookOptions, SignWebhookOptions, WebhookSignatureReason } from './webhooks.js';
17
+ export { OPERATIONS, ERROR_TITLES, API_VERSION } from './generated/operations.js';
18
+ export type { ApiResponse, AuthKind, HttpMethod, OperationMeta, Page, RequestOptions, ResponseKind, StreamOptions } from './types.js';
19
+ export type * from './generated/types.js';
20
+ export { VERSION } from './version.js';
21
+ import { Rewloy } from './client.js';
22
+ export default Rewloy;
package/dist/index.js ADDED
@@ -0,0 +1,16 @@
1
+ /**
2
+ * @rewloy/node — the official Node.js and TypeScript library for the Rewloy API.
3
+ *
4
+ * ```ts
5
+ * import { Rewloy } from '@rewloy/node';
6
+ * const rewloy = new Rewloy({ apiKey: process.env.REWLOY_API_KEY! });
7
+ * ```
8
+ */
9
+ export { Rewloy, DEFAULT_BASE_URL } from "./client.js";
10
+ export { RewloyError, RateLimitError, RewloyConnectionError, RewloyTimeoutError } from "./errors.js";
11
+ export { EventStream, SseParser } from "./sse.js";
12
+ export { verifyWebhook, signWebhook, WebhookSignatureError } from "./webhooks.js";
13
+ export { OPERATIONS, ERROR_TITLES, API_VERSION } from "./generated/operations.js";
14
+ export { VERSION } from "./version.js";
15
+ import { Rewloy } from "./client.js";
16
+ export default Rewloy;
package/dist/sse.d.ts ADDED
@@ -0,0 +1,68 @@
1
+ /**
2
+ * Server-sent events (`text/event-stream`), as the HTML standard parses them
3
+ * (https://html.spec.whatwg.org/multipage/server-sent-events.html), and the
4
+ * stream the client opens on `liveFeed` and `holderCardEvents`.
5
+ *
6
+ * The API's streams start with `retry: 5000`, send `: hb` every 25 seconds
7
+ * and events as `event: <type>` + `data: <text>`; they carry no `id:` today.
8
+ */
9
+ /** One event of a stream. */
10
+ export interface ServerSentEvent {
11
+ /** The type: the `event:` field, `message` when the event had none. */
12
+ event: string;
13
+ /** The `data:` lines, joined with "\n". */
14
+ data: string;
15
+ /** The last event ID: the latest `id:` field the stream has sent ("" if none). */
16
+ id: string;
17
+ }
18
+ /**
19
+ * The standard's parser, fed text in pieces of any size: a line, an event or
20
+ * a CRLF may be split anywhere between two pieces.
21
+ */
22
+ export declare class SseParser {
23
+ #private;
24
+ /** The reconnection time the stream asked for (`retry:`), in milliseconds. */
25
+ retry: number | undefined;
26
+ /**
27
+ * The last event ID: set from the `id:` buffer at every blank line, kept from
28
+ * one event to the next, as the standard says.
29
+ */
30
+ lastEventId: string;
31
+ constructor(lastEventId?: string);
32
+ /** Feeds decoded text; returns the events it completed. */
33
+ push(text: string): ServerSentEvent[];
34
+ /** The stream ended: an event without its blank line is dropped, as the standard says. */
35
+ end(): void;
36
+ }
37
+ /** @internal How a stream asks the client for a connection. */
38
+ export interface StreamSource {
39
+ operation: string;
40
+ /** Opens one connection: resolves with the answer once its headers are in. */
41
+ connect(lastEventId: string, controller: AbortController): Promise<Response>;
42
+ signal: AbortSignal | undefined;
43
+ reconnect: boolean;
44
+ idleTimeoutMs: number;
45
+ sleep(ms: number, signal?: AbortSignal): Promise<void>;
46
+ }
47
+ /**
48
+ * A live stream of server-sent events: iterate it with `for await`. It ends
49
+ * when the signal aborts, on `break`, on `close()`, or with a
50
+ * {@link RewloyError} that a reconnection cannot fix; with `reconnect: false`
51
+ * also when the connection ends.
52
+ */
53
+ export declare class EventStream implements AsyncIterable<ServerSentEvent> {
54
+ #private;
55
+ /** The last event ID seen; sent as `Last-Event-ID` when reconnecting. */
56
+ lastEventId: string;
57
+ /** The wait before reconnecting, in milliseconds: the server's `retry:` once it sent one. */
58
+ retryMs: number;
59
+ /** `x-request-id` of the current connection. */
60
+ requestId: string | null;
61
+ /** `Rewloy-Mode` of the current connection (see `ApiResponse.mode`). */
62
+ mode: string | null;
63
+ /** @internal Use `client.stream()` or the operation's method. */
64
+ constructor(source: StreamSource);
65
+ [Symbol.asyncIterator](): AsyncGenerator<ServerSentEvent, void, undefined>;
66
+ /** Closes the connection and ends the iteration. */
67
+ close(): void;
68
+ }
package/dist/sse.js ADDED
@@ -0,0 +1,251 @@
1
+ /**
2
+ * Server-sent events (`text/event-stream`), as the HTML standard parses them
3
+ * (https://html.spec.whatwg.org/multipage/server-sent-events.html), and the
4
+ * stream the client opens on `liveFeed` and `holderCardEvents`.
5
+ *
6
+ * The API's streams start with `retry: 5000`, send `: hb` every 25 seconds
7
+ * and events as `event: <type>` + `data: <text>`; they carry no `id:` today.
8
+ */
9
+ import { RewloyConnectionError, RewloyError, RewloyTimeoutError } from "./errors.js";
10
+ /**
11
+ * The standard's parser, fed text in pieces of any size: a line, an event or
12
+ * a CRLF may be split anywhere between two pieces.
13
+ */
14
+ export class SseParser {
15
+ /** The reconnection time the stream asked for (`retry:`), in milliseconds. */
16
+ retry = undefined;
17
+ /**
18
+ * The last event ID: set from the `id:` buffer at every blank line, kept from
19
+ * one event to the next, as the standard says.
20
+ */
21
+ lastEventId;
22
+ #id;
23
+ #line = '';
24
+ #data = '';
25
+ #event = '';
26
+ #afterCR = false;
27
+ #started = false;
28
+ constructor(lastEventId = '') {
29
+ this.lastEventId = lastEventId;
30
+ this.#id = lastEventId;
31
+ }
32
+ /** Feeds decoded text; returns the events it completed. */
33
+ push(text) {
34
+ const out = [];
35
+ let i = 0;
36
+ if (!this.#started && text.length) {
37
+ this.#started = true;
38
+ if (text.charCodeAt(0) === 0xfeff)
39
+ i = 1;
40
+ }
41
+ // A CR ended the previous piece: an LF right after it belongs to the same line ending.
42
+ if (this.#afterCR && i < text.length) {
43
+ if (text[i] === '\n')
44
+ i++;
45
+ this.#afterCR = false;
46
+ }
47
+ while (i < text.length) {
48
+ let j = i;
49
+ while (j < text.length && text[j] !== '\n' && text[j] !== '\r')
50
+ j++;
51
+ if (j === text.length) {
52
+ this.#line += text.slice(i);
53
+ break;
54
+ }
55
+ const line = this.#line + text.slice(i, j);
56
+ this.#line = '';
57
+ if (text[j] === '\r') {
58
+ if (j + 1 < text.length) {
59
+ if (text[j + 1] === '\n')
60
+ j++;
61
+ }
62
+ else
63
+ this.#afterCR = true;
64
+ }
65
+ i = j + 1;
66
+ this.#take(line, out);
67
+ }
68
+ return out;
69
+ }
70
+ /** The stream ended: an event without its blank line is dropped, as the standard says. */
71
+ end() {
72
+ this.#line = '';
73
+ this.#data = '';
74
+ this.#event = '';
75
+ this.#afterCR = false;
76
+ }
77
+ #take(line, out) {
78
+ if (line === '') {
79
+ this.lastEventId = this.#id;
80
+ if (this.#data === '') {
81
+ this.#event = '';
82
+ return;
83
+ }
84
+ out.push({ event: this.#event || 'message', data: this.#data.endsWith('\n') ? this.#data.slice(0, -1) : this.#data, id: this.lastEventId });
85
+ this.#data = '';
86
+ this.#event = '';
87
+ return;
88
+ }
89
+ if (line.startsWith(':'))
90
+ return;
91
+ const colon = line.indexOf(':');
92
+ const field = colon === -1 ? line : line.slice(0, colon);
93
+ let value = colon === -1 ? '' : line.slice(colon + 1);
94
+ if (value.startsWith(' '))
95
+ value = value.slice(1);
96
+ switch (field) {
97
+ case 'event':
98
+ this.#event = value;
99
+ break;
100
+ case 'data':
101
+ this.#data += `${value}\n`;
102
+ break;
103
+ case 'id':
104
+ if (!value.includes('\0'))
105
+ this.#id = value;
106
+ break;
107
+ case 'retry':
108
+ if (/^[0-9]+$/.test(value))
109
+ this.retry = Number.parseInt(value, 10);
110
+ break;
111
+ default: break;
112
+ }
113
+ }
114
+ }
115
+ const DEFAULT_RETRY_MS = 3000;
116
+ const MAX_RECONNECT_MS = 30_000;
117
+ /** Errors a new connection may fix. */
118
+ function transient(err) {
119
+ if (err instanceof RewloyConnectionError)
120
+ return true;
121
+ return err instanceof RewloyError && (err.status === 429 || err.status === 502 || err.status === 503 || err.status === 504 || (err.status >= 520 && err.status <= 524));
122
+ }
123
+ /**
124
+ * A live stream of server-sent events: iterate it with `for await`. It ends
125
+ * when the signal aborts, on `break`, on `close()`, or with a
126
+ * {@link RewloyError} that a reconnection cannot fix; with `reconnect: false`
127
+ * also when the connection ends.
128
+ */
129
+ export class EventStream {
130
+ /** The last event ID seen; sent as `Last-Event-ID` when reconnecting. */
131
+ lastEventId = '';
132
+ /** The wait before reconnecting, in milliseconds: the server's `retry:` once it sent one. */
133
+ retryMs = DEFAULT_RETRY_MS;
134
+ /** `x-request-id` of the current connection. */
135
+ requestId = null;
136
+ /** `Rewloy-Mode` of the current connection (see `ApiResponse.mode`). */
137
+ mode = null;
138
+ #source;
139
+ #close = new AbortController();
140
+ /** Aborts on `close()` and on the caller's signal. */
141
+ #stop;
142
+ #iterator = null;
143
+ /** @internal Use `client.stream()` or the operation's method. */
144
+ constructor(source) {
145
+ this.#source = source;
146
+ this.#stop = source.signal ? AbortSignal.any([source.signal, this.#close.signal]) : this.#close.signal;
147
+ }
148
+ [Symbol.asyncIterator]() {
149
+ this.#iterator ??= this.#run();
150
+ return this.#iterator;
151
+ }
152
+ /** Closes the connection and ends the iteration. */
153
+ close() {
154
+ this.#close.abort();
155
+ }
156
+ #stopped() {
157
+ return this.#stop.aborted;
158
+ }
159
+ /** Waits before a reconnection; false when the stream was stopped meanwhile. */
160
+ async #wait(ms) {
161
+ try {
162
+ await this.#source.sleep(ms, this.#stop);
163
+ }
164
+ catch {
165
+ return false;
166
+ }
167
+ return !this.#stopped();
168
+ }
169
+ async *#run() {
170
+ const s = this.#source;
171
+ let failures = 0;
172
+ while (!this.#stopped()) {
173
+ const controller = new AbortController();
174
+ const onStop = () => controller.abort();
175
+ this.#stop.addEventListener('abort', onStop, { once: true });
176
+ let res;
177
+ try {
178
+ res = await s.connect(this.lastEventId, controller);
179
+ }
180
+ catch (err) {
181
+ this.#stop.removeEventListener('abort', onStop);
182
+ if (this.#stopped())
183
+ return;
184
+ if (!s.reconnect || !transient(err))
185
+ throw err;
186
+ failures++;
187
+ if (!(await this.#wait(Math.max(this.retryMs, Math.min(MAX_RECONNECT_MS, 1000 * 2 ** failures)))))
188
+ return;
189
+ continue;
190
+ }
191
+ this.requestId = res.headers.get('x-request-id');
192
+ this.mode = res.headers.get('rewloy-mode');
193
+ const parser = new SseParser(this.lastEventId);
194
+ const decoder = new TextDecoder();
195
+ let idle;
196
+ let silent = false;
197
+ const arm = () => {
198
+ if (!(s.idleTimeoutMs > 0 && s.idleTimeoutMs < 2 ** 31))
199
+ return;
200
+ clearTimeout(idle);
201
+ idle = setTimeout(() => { silent = true; controller.abort(); }, s.idleTimeoutMs);
202
+ };
203
+ let dropped = null;
204
+ try {
205
+ if (!res.body)
206
+ throw new TypeError('the answer has no body');
207
+ const reader = res.body.getReader();
208
+ arm();
209
+ for (;;) {
210
+ const { done, value } = await reader.read();
211
+ const text = done ? decoder.decode() : decoder.decode(value, { stream: true });
212
+ if (!done)
213
+ arm();
214
+ for (const event of parser.push(text)) {
215
+ this.lastEventId = event.id;
216
+ failures = 0;
217
+ yield event;
218
+ }
219
+ this.lastEventId = parser.lastEventId;
220
+ if (parser.retry !== undefined)
221
+ this.retryMs = parser.retry;
222
+ if (done)
223
+ break;
224
+ }
225
+ parser.end();
226
+ }
227
+ catch (err) {
228
+ if (this.#stopped())
229
+ return;
230
+ dropped = silent
231
+ ? new RewloyTimeoutError({ detail: `no data for ${String(s.idleTimeoutMs)} ms`, operation: s.operation, requestId: this.requestId })
232
+ : new RewloyConnectionError({ detail: err instanceof Error ? err.message : String(err), operation: s.operation, requestId: this.requestId, cause: err });
233
+ }
234
+ finally {
235
+ clearTimeout(idle);
236
+ controller.abort();
237
+ this.#stop.removeEventListener('abort', onStop);
238
+ }
239
+ if (!s.reconnect) {
240
+ if (dropped)
241
+ throw dropped;
242
+ return;
243
+ }
244
+ if (dropped)
245
+ failures++;
246
+ const delay = dropped ? Math.max(this.retryMs, Math.min(MAX_RECONNECT_MS, 1000 * 2 ** failures)) : this.retryMs;
247
+ if (!(await this.#wait(delay)))
248
+ return;
249
+ }
250
+ }
251
+ }
@@ -0,0 +1,104 @@
1
+ /**
2
+ * Hand-written types shared by the client and the generated code
3
+ * (src/generated/), which imports them.
4
+ */
5
+ import type { PageMeta } from './generated/types.js';
6
+ /**
7
+ * A credential kind of the Rewloy API, as the OpenAPI document's
8
+ * `x-credentials` names them:
9
+ * - `key`: an API key (`rwk_…`);
10
+ * - `staff`: a staff session (`rws_…`);
11
+ * - `holder`: a card holder's session (`rwh_…`);
12
+ * - `public`: no credential.
13
+ */
14
+ export type AuthKind = 'key' | 'staff' | 'holder' | 'public';
15
+ export type HttpMethod = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE';
16
+ /**
17
+ * How a successful answer is read:
18
+ * - `json`: the `{ data }` envelope (with `meta` on paged lists);
19
+ * - `none`: 204, no body;
20
+ * - `blob`: a file (image, CSV, pass), returned as a `Blob`;
21
+ * - `raw-json`: JSON without the envelope (the OpenAPI document itself);
22
+ * - `stream`: server-sent events.
23
+ */
24
+ export type ResponseKind = 'json' | 'none' | 'blob' | 'raw-json' | 'stream';
25
+ /** One row of the metadata table (`OPERATIONS`): what the client needs to call an operation. */
26
+ export interface OperationMeta {
27
+ readonly method: HttpMethod;
28
+ /** The path with `{name}` placeholders, `/v1` included. */
29
+ readonly path: string;
30
+ /** The credential kinds the operation accepts. */
31
+ readonly auth: readonly AuthKind[];
32
+ /** Takes the `Rewloy-Merchant` header (staff sessions with seats in several businesses). */
33
+ readonly merchant: boolean;
34
+ /** Takes an `Idempotency-Key` header: `required`, `optional`, or not at all. */
35
+ readonly idempotency: 'required' | 'optional' | null;
36
+ /** Has a JSON request body. */
37
+ readonly body: boolean;
38
+ readonly response: ResponseKind;
39
+ /** A paged list: `page`/`limit` in, `meta` out. */
40
+ readonly paged: boolean;
41
+ /** Answers with server-sent events. */
42
+ readonly stream: boolean;
43
+ /**
44
+ * Marked for removal. `sunset` is the last day it works (YYYY-MM-DD) and `use`
45
+ * the operation that replaces it, when the document says so.
46
+ */
47
+ readonly deprecated: {
48
+ readonly sunset: string | null;
49
+ readonly use: string | null;
50
+ } | null;
51
+ }
52
+ /** Options every call takes. */
53
+ export interface RequestOptions {
54
+ /** Cancels the call (and any retry still waiting). The promise rejects with the signal's reason. */
55
+ signal?: AbortSignal | undefined;
56
+ /**
57
+ * Time allowed for one attempt, in milliseconds: until the whole answer has
58
+ * arrived (for a stream: until its headers have). Overrides the client's.
59
+ */
60
+ timeoutMs?: number | undefined;
61
+ /** Retries after the first attempt. Overrides the client's. */
62
+ maxRetries?: number | undefined;
63
+ }
64
+ /** Options of a server-sent event stream (`Rewloy.stream`). */
65
+ export interface StreamOptions {
66
+ /**
67
+ * Reconnect when the connection drops or the server ends the stream, as a
68
+ * browser's `EventSource` does: after the server's `retry:` delay, with
69
+ * `Last-Event-ID` once an event carried an id. Errors that a reconnection
70
+ * cannot fix (401, 403, 404…) end the stream with a `RewloyError`.
71
+ * Default `true`.
72
+ */
73
+ reconnect?: boolean | undefined;
74
+ /**
75
+ * Treat the connection as dead after this many milliseconds without a byte
76
+ * (the API sends a heartbeat every 25 seconds). `0` turns the check off.
77
+ * Default 60000.
78
+ */
79
+ idleTimeoutMs?: number | undefined;
80
+ }
81
+ /** One page of a paged list, as the API answers it. */
82
+ export interface Page<T> {
83
+ data: T[];
84
+ meta: PageMeta;
85
+ }
86
+ /** The whole answer to a call (`Rewloy.request`). */
87
+ export interface ApiResponse<T> {
88
+ /** What `data` held (a `Blob` for files, `undefined` for 204). */
89
+ data: T;
90
+ /** Paging, on paged lists. */
91
+ meta: PageMeta | undefined;
92
+ /** The HTTP status: 200, 201, 202 or 204. Some operations answer 200 when they found what they would have created. */
93
+ status: number;
94
+ headers: Headers;
95
+ /** `x-request-id`: quote it to Rewloy support. */
96
+ requestId: string | null;
97
+ /**
98
+ * `Rewloy-Mode`: which mode answered (`test` for test keys, once the platform
99
+ * has test mode); `null` when the answer does not say.
100
+ */
101
+ mode: string | null;
102
+ /** `Idempotent-Replayed: true`: the API replayed the first answer to this `Idempotency-Key`. */
103
+ replayed: boolean;
104
+ }
package/dist/types.js ADDED
@@ -0,0 +1,5 @@
1
+ /**
2
+ * Hand-written types shared by the client and the generated code
3
+ * (src/generated/), which imports them.
4
+ */
5
+ export {};
@@ -0,0 +1,2 @@
1
+ /** This library's version (package.json's; test/client.test.ts keeps them equal). */
2
+ export declare const VERSION = "0.1.0";
@@ -0,0 +1,2 @@
1
+ /** This library's version (package.json's; test/client.test.ts keeps them equal). */
2
+ export const VERSION = '0.1.0';
@@ -0,0 +1,91 @@
1
+ /**
2
+ * Webhook signatures, exactly as the platform signs a delivery:
3
+ *
4
+ * Rewloy-Signature: t=<unix seconds>,v1=<hex HMAC-SHA256(secret, "<t>.<raw body>")>
5
+ *
6
+ * The key is the whole secret as shown once when the webhook was created
7
+ * (`whsec_…`, prefix included); the message is the timestamp, a dot and the
8
+ * body's bytes as they arrived. Each delivery attempt is signed anew, so a
9
+ * retry carries a fresh `t`.
10
+ *
11
+ * Every delivery also carries `Rewloy-Event` (the event type, as `type` in the
12
+ * body) and `Rewloy-Delivery` (the delivery's id: the same on every retry of
13
+ * one delivery; deliveries are at least once, so skip an id already handled).
14
+ */
15
+ /** What a webhook's `data` holds for the `pass.*` events (only business facts, never contact details). */
16
+ export interface PassEventData {
17
+ /** What happened on the card: `join`, `earn`, `redeem`, `spend`, `visit_credit`, `load`, `void`… */
18
+ kind: string;
19
+ /** The card's serial number, XXXX-XXXX-XXXX. */
20
+ card: string | null;
21
+ program_id: string | null;
22
+ location_id: string | null;
23
+ customer_id: string | null;
24
+ /** What `delta` counts: `stamp`, `point`, `try_minor` (kuruş)… */
25
+ unit?: string;
26
+ delta?: number;
27
+ reward?: unknown;
28
+ use?: unknown;
29
+ reason?: string;
30
+ [key: string]: unknown;
31
+ }
32
+ /**
33
+ * A webhook delivery's body. Read the person behind `customer_id` from the
34
+ * API; new event types may appear, so keep a default branch.
35
+ */
36
+ export type WebhookEvent = {
37
+ /** The event's id. */
38
+ id: string;
39
+ type: 'pass.issued' | 'pass.activity' | 'pass.voided';
40
+ created_at: string;
41
+ data: PassEventData;
42
+ } | {
43
+ /** The panel's or `testWebhook`'s test delivery: no event behind it. */
44
+ id?: undefined;
45
+ type: 'webhook.test';
46
+ created_at: string;
47
+ data: {
48
+ message: string;
49
+ };
50
+ };
51
+ /** Why a delivery was refused. */
52
+ export type WebhookSignatureReason = 'missing' | 'malformed' | 'expired' | 'mismatch' | 'payload';
53
+ /** The delivery is not a genuine one: answer it with 400 and do not act on it. */
54
+ export declare class WebhookSignatureError extends Error {
55
+ readonly reason: WebhookSignatureReason;
56
+ constructor(reason: WebhookSignatureReason, message: string);
57
+ }
58
+ export interface VerifyWebhookOptions {
59
+ /**
60
+ * The body exactly as it arrived: a string or the raw bytes. Not a parsed
61
+ * object: JSON parsing and re-serializing changes the bytes the signature
62
+ * covers (in Express use `express.raw({ type: 'application/json' })`).
63
+ */
64
+ payload: string | Uint8Array | ArrayBuffer;
65
+ /** The `Rewloy-Signature` header. */
66
+ header: string | readonly string[] | null | undefined;
67
+ /** The webhook's secret (`whsec_…`); several while you move from one webhook to another. */
68
+ secret: string | readonly string[];
69
+ /** How far `t` may be from now, in seconds. Default 300. */
70
+ toleranceSeconds?: number | undefined;
71
+ /** The current time: a `Date`, or Unix time in seconds (the unit of `t`). For tests. */
72
+ now?: Date | number | undefined;
73
+ }
74
+ /**
75
+ * Checks a delivery's `Rewloy-Signature` and returns its parsed body.
76
+ * Throws {@link WebhookSignatureError} when the header is missing or
77
+ * malformed, `t` is further than `toleranceSeconds` from now, or no `v1`
78
+ * matches; the comparison takes constant time.
79
+ */
80
+ export declare function verifyWebhook<T = WebhookEvent>(options: VerifyWebhookOptions): T;
81
+ export interface SignWebhookOptions {
82
+ payload: string | Uint8Array | ArrayBuffer;
83
+ secret: string;
84
+ /** Unix time in seconds; default now. */
85
+ timestamp?: number | undefined;
86
+ }
87
+ /**
88
+ * The `Rewloy-Signature` header the platform would send for this body: for
89
+ * testing your own webhook handler.
90
+ */
91
+ export declare function signWebhook(options: SignWebhookOptions): string;
@@ -0,0 +1,98 @@
1
+ /**
2
+ * Webhook signatures, exactly as the platform signs a delivery:
3
+ *
4
+ * Rewloy-Signature: t=<unix seconds>,v1=<hex HMAC-SHA256(secret, "<t>.<raw body>")>
5
+ *
6
+ * The key is the whole secret as shown once when the webhook was created
7
+ * (`whsec_…`, prefix included); the message is the timestamp, a dot and the
8
+ * body's bytes as they arrived. Each delivery attempt is signed anew, so a
9
+ * retry carries a fresh `t`.
10
+ *
11
+ * Every delivery also carries `Rewloy-Event` (the event type, as `type` in the
12
+ * body) and `Rewloy-Delivery` (the delivery's id: the same on every retry of
13
+ * one delivery; deliveries are at least once, so skip an id already handled).
14
+ */
15
+ import { createHmac, timingSafeEqual } from 'node:crypto';
16
+ /** The delivery is not a genuine one: answer it with 400 and do not act on it. */
17
+ export class WebhookSignatureError extends Error {
18
+ reason;
19
+ constructor(reason, message) {
20
+ super(message);
21
+ this.reason = reason;
22
+ }
23
+ }
24
+ Object.defineProperty(WebhookSignatureError.prototype, 'name', { value: 'WebhookSignatureError', writable: true, configurable: true });
25
+ const V1 = /^[0-9a-f]{64}$/i;
26
+ function bytesOf(payload) {
27
+ if (typeof payload === 'string')
28
+ return Buffer.from(payload, 'utf8');
29
+ if (payload instanceof ArrayBuffer)
30
+ return Buffer.from(payload);
31
+ if (ArrayBuffer.isView(payload))
32
+ return Buffer.from(payload.buffer, payload.byteOffset, payload.byteLength);
33
+ throw new TypeError('verifyWebhook: `payload` must be the raw body (a string or bytes), not a parsed object');
34
+ }
35
+ function signature(secret, t, body) {
36
+ return createHmac('sha256', secret).update(`${t}.`).update(body).digest();
37
+ }
38
+ /**
39
+ * Checks a delivery's `Rewloy-Signature` and returns its parsed body.
40
+ * Throws {@link WebhookSignatureError} when the header is missing or
41
+ * malformed, `t` is further than `toleranceSeconds` from now, or no `v1`
42
+ * matches; the comparison takes constant time.
43
+ */
44
+ export function verifyWebhook(options) {
45
+ const { header, toleranceSeconds = 300 } = options;
46
+ const body = bytesOf(options.payload);
47
+ const secrets = (typeof options.secret === 'string' ? [options.secret] : [...options.secret]).filter((s) => s !== '');
48
+ if (!secrets.length)
49
+ throw new TypeError('verifyWebhook: `secret` is empty');
50
+ const value = typeof header === 'string' ? header : Array.isArray(header) ? header.join(',') : '';
51
+ if (!value.trim())
52
+ throw new WebhookSignatureError('missing', 'No Rewloy-Signature header');
53
+ let t;
54
+ const candidates = [];
55
+ for (const part of value.split(',')) {
56
+ const eq = part.indexOf('=');
57
+ if (eq === -1)
58
+ continue;
59
+ const k = part.slice(0, eq).trim();
60
+ const v = part.slice(eq + 1).trim();
61
+ if (k === 't' && t === undefined)
62
+ t = v;
63
+ else if (k === 'v1' && V1.test(v))
64
+ candidates.push(Buffer.from(v, 'hex'));
65
+ }
66
+ if (t === undefined || !/^\d+$/.test(t) || !candidates.length) {
67
+ throw new WebhookSignatureError('malformed', 'Rewloy-Signature is not "t=<unix seconds>,v1=<hex>"');
68
+ }
69
+ const now = options.now === undefined ? Date.now() / 1000 : options.now instanceof Date ? options.now.getTime() / 1000 : options.now;
70
+ if (Math.abs(now - Number(t)) > toleranceSeconds) {
71
+ throw new WebhookSignatureError('expired', `The signature's time (t=${t}) is more than ${String(toleranceSeconds)} seconds from now`);
72
+ }
73
+ let match = false;
74
+ for (const secret of secrets) {
75
+ const expected = signature(secret, t, body);
76
+ for (const candidate of candidates) {
77
+ // Every pair is compared, in constant time, whether or not one matched already.
78
+ if (timingSafeEqual(candidate, expected))
79
+ match = true;
80
+ }
81
+ }
82
+ if (!match)
83
+ throw new WebhookSignatureError('mismatch', 'No v1 signature matches the body and the secret');
84
+ try {
85
+ return JSON.parse(body.toString('utf8'));
86
+ }
87
+ catch {
88
+ throw new WebhookSignatureError('payload', 'The signed body is not JSON');
89
+ }
90
+ }
91
+ /**
92
+ * The `Rewloy-Signature` header the platform would send for this body: for
93
+ * testing your own webhook handler.
94
+ */
95
+ export function signWebhook(options) {
96
+ const t = String(Math.floor(options.timestamp ?? Date.now() / 1000));
97
+ return `t=${t},v1=${signature(options.secret, t, bytesOf(options.payload)).toString('hex')}`;
98
+ }