@pulsebyshiga/node 0.2.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,446 @@
1
+ /**
2
+ * Wire types for the Pulse API. Two sections:
3
+ *
4
+ * 1. ENGINE API — reconciled 1:1 against the live public contract
5
+ * (https://engine-api.shiga.io/swagger/public/index.html). These are real.
6
+ * 2. PROPOSED product extension — the collection-session/client-session/webhook
7
+ * surface the embedded product needs; not yet in the engine (see
8
+ * docs/engine-gap.md).
9
+ *
10
+ * Wire convention (both): snake_case JSON, decimal amounts as strings.
11
+ */
12
+ /** Networks the engine supports today (uppercase on the wire). Non-EVM support is on the gap list. */
13
+ export type EngineNetwork = 'BASE' | 'POLYGON' | 'ARBITRUM' | 'OPTIMISM' | 'ETHEREUM' | 'BSC';
14
+ export type CreateQuoteParams = {
15
+ /** Decimal string in the source currency, e.g. "100.50". */
16
+ amount: string;
17
+ source_currency: string;
18
+ destination_currency: string;
19
+ network?: EngineNetwork;
20
+ };
21
+ /** A locked price. Orders are created FROM a quote (`quote_id`) before it expires. */
22
+ export type Quote = {
23
+ id: string;
24
+ rate: string;
25
+ source_amount: string;
26
+ destination_amount: string;
27
+ expires_at: string;
28
+ };
29
+ export type CreateOfframpOrderParams = {
30
+ quote_id: string;
31
+ /** Partner's stable reference for the end user. */
32
+ customer_id: string;
33
+ customer_name: string;
34
+ customer_email: string;
35
+ /** Payout target — resolve via banks.resolve() (name enquiry) first. */
36
+ account_number: string;
37
+ account_name: string;
38
+ bank_code: string;
39
+ bank_name: string;
40
+ };
41
+ /** Crypto → fiat. The user sends stablecoin to `deposit_address`; fiat lands in the bank account. */
42
+ export type OfframpOrder = {
43
+ id: string;
44
+ customer_id: string;
45
+ deposit_address: string;
46
+ /**
47
+ * Proposed: one deposit address per chain family so a single order serves every
48
+ * supported chain (one EVM address for all EVM chains, plus Solana, TON, …).
49
+ * Keyed by family or network name. Absent on today's single-address engine —
50
+ * `deposit_address` is the fallback. See docs/engine-gap.md.
51
+ */
52
+ deposit_addresses?: Record<string, string>;
53
+ source_amount: string;
54
+ source_currency: string;
55
+ destination_amount: string;
56
+ destination_currency: string;
57
+ quoted_rate: string;
58
+ /** Lifecycle value — the engine does not document the enum yet (gap list). */
59
+ status: string;
60
+ reference: string;
61
+ flow_type: string;
62
+ execution_mode: string;
63
+ metadata: Record<string, unknown>;
64
+ created_at: string;
65
+ updated_at: string;
66
+ };
67
+ export type CreateOnrampOrderParams = {
68
+ quote_id: string;
69
+ customer_id: string;
70
+ customer_name: string;
71
+ customer_email: string;
72
+ /** Wallet that receives the crypto once the fiat lands. */
73
+ destination_address: string;
74
+ destination_tag?: string;
75
+ };
76
+ /** Virtual bank account the end user pays into for an onramp order. */
77
+ export type OnrampVirtualAccount = {
78
+ id: string;
79
+ account_name: string;
80
+ account_number: string;
81
+ bank_code: string;
82
+ bank_name: string;
83
+ amount: string;
84
+ provider: string;
85
+ provider_reference: string;
86
+ reference: string;
87
+ status: string;
88
+ type: string;
89
+ expires_at: string;
90
+ created_at: string;
91
+ updated_at: string;
92
+ };
93
+ /** Fiat → crypto. The user pays `account`; crypto is sent to `destination_address`. */
94
+ export type OnrampOrder = {
95
+ id: string;
96
+ customer_id: string;
97
+ account: OnrampVirtualAccount;
98
+ destination_address: string;
99
+ source_amount: string;
100
+ source_currency: string;
101
+ destination_amount: string;
102
+ destination_currency: string;
103
+ quoted_rate: string;
104
+ /** Lifecycle value — the engine does not document the enum yet (gap list). */
105
+ status: string;
106
+ reference: string;
107
+ flow_type: string;
108
+ execution_mode: string;
109
+ metadata: Record<string, unknown>;
110
+ created_at: string;
111
+ updated_at: string;
112
+ };
113
+ export type Bank = {
114
+ /** NIP bank code. */
115
+ code: string;
116
+ name: string;
117
+ };
118
+ export type ResolveAccountParams = {
119
+ account_number: string;
120
+ bank_code: string;
121
+ };
122
+ export type VerifiedAccount = {
123
+ account_name: string;
124
+ account_number: string;
125
+ bank_code: string;
126
+ bank_name: string;
127
+ };
128
+ export type Gate = 'naira' | 'usd';
129
+ /**
130
+ * Which way value flows. `offramp` = crypto in → fiat out (the collection
131
+ * product: user sends stablecoin, fiat settles to a bank account). `onramp` =
132
+ * fiat in → crypto out (user pays a virtual bank account, crypto is sent to a
133
+ * wallet). Defaults to `offramp` when a session omits it.
134
+ */
135
+ export type CollectionDirection = 'offramp' | 'onramp';
136
+ export type Asset = 'USDC' | 'USDT';
137
+ /**
138
+ * Full network set per the approved design (business requirement). The
139
+ * per-partner ENABLED subset comes from Pulse config (R5.1) once it exists.
140
+ */
141
+ export type Network = 'ethereum' | 'base' | 'polygon' | 'arbitrum' | 'optimism' | 'celo' | 'plasma' | 'solana' | 'ton';
142
+ export type FiatCurrency = 'NGN' | 'USD';
143
+ export type TargetAmount = {
144
+ currency: FiatCurrency;
145
+ amount: string;
146
+ };
147
+ /**
148
+ * Crypto side of a session, as an alternative to `target`: the amount of `asset`
149
+ * the user SENDS (offramp). The fiat target is derived from it. Exactly one of
150
+ * `target` / `source` is provided.
151
+ */
152
+ export type SourceAmount = {
153
+ asset: Asset;
154
+ amount: string;
155
+ };
156
+ export type NairaAccountProvision = {
157
+ provision: true;
158
+ /** Sensitive — server-to-server only; never reaches the hosted flow. */
159
+ bvn: string;
160
+ /** Sensitive — server-to-server only; never reaches the hosted flow. */
161
+ nin: string;
162
+ };
163
+ export type NairaAccountReference = {
164
+ provision: false;
165
+ virtual_account_ref: string;
166
+ };
167
+ export type NairaAccount = NairaAccountProvision | NairaAccountReference;
168
+ export type CreateNairaSessionParams = {
169
+ gate: 'naira';
170
+ /** Value direction; defaults to `offramp` (crypto in → fiat out). */
171
+ direction?: CollectionDirection;
172
+ /** Fiat side — provide this OR `source` (the crypto side). */
173
+ target?: TargetAmount;
174
+ /** Crypto side (offramp) — the amount of `asset` to send; fiat is derived. */
175
+ source?: SourceAmount;
176
+ asset: Asset;
177
+ network: Network;
178
+ /** Partner's stable reference for the end user. */
179
+ user_ref: string;
180
+ account: NairaAccount;
181
+ partner_ref?: string;
182
+ };
183
+ export type CreateUsdSessionParams = {
184
+ gate: 'usd';
185
+ /** Value direction; defaults to `offramp` (crypto in → fiat out). */
186
+ direction?: CollectionDirection;
187
+ /** Fiat side — provide this OR `source` (the crypto side). */
188
+ target?: TargetAmount;
189
+ /** Crypto side (offramp) — the amount of `asset` to send; fiat is derived. */
190
+ source?: SourceAmount;
191
+ asset: Asset;
192
+ network: Network;
193
+ partner_ref?: string;
194
+ };
195
+ export type CreateCollectionSessionParams = CreateNairaSessionParams | CreateUsdSessionParams;
196
+ export type SessionQuote = {
197
+ /** Fiat per 1 asset unit, decimal string. */
198
+ rate: string;
199
+ asset: Asset;
200
+ /** Exact stablecoin amount the user must send. */
201
+ asset_amount: string;
202
+ locked_until: string;
203
+ };
204
+ export type OrderStatus = 'awaiting_payment' | 'deposit_detected' | 'deposit_confirmed' | 'converting' | 'converted_unsettled' | 'completed' | 'expired' | 'failed';
205
+ export type AmountStatus = 'pending' | 'exact' | 'underpaid' | 'overpaid';
206
+ export type Deposit = {
207
+ tx_hash: string;
208
+ /** On-chain detected amount — always the credited figure, never the quoted one. */
209
+ amount: string;
210
+ asset: Asset;
211
+ network: Network;
212
+ detected_at: string;
213
+ confirmed_at: string | null;
214
+ };
215
+ /**
216
+ * Funds detected on a network other than the order's. PROPOSED contract shape
217
+ * (PRD §10 wrong-chain row) — the real detection signal comes from Pulse;
218
+ * recovery policy is a [DECIDE] item.
219
+ */
220
+ export type WrongChainDeposit = {
221
+ tx_hash: string;
222
+ amount: string;
223
+ asset: Asset;
224
+ /** Network the funds actually arrived on (may be outside the supported set). */
225
+ network: string;
226
+ detected_at: string;
227
+ };
228
+ export type Disbursement = {
229
+ settlement_ref: string;
230
+ currency: FiatCurrency;
231
+ amount: string;
232
+ /** Naira gate only; null for USD (partner omnibus). */
233
+ virtual_account_ref: string | null;
234
+ completed_at: string;
235
+ };
236
+ /**
237
+ * Onramp only: the virtual bank account the end user transfers fiat into.
238
+ * Mirrors the engine's onramp `account` (see docs/engine-gap.md). Null on
239
+ * offramp orders, where the user sends crypto to `deposit_address` instead.
240
+ */
241
+ export type PayAccount = {
242
+ bank_name: string;
243
+ bank_code: string;
244
+ /** Optional bank logo (progressive enhancement; hidden if it fails to load). */
245
+ bank_logo_url?: string;
246
+ account_number: string;
247
+ account_name: string;
248
+ /** Fiat to transfer (equals target.amount), decimal string. */
249
+ amount: string;
250
+ expires_at: string;
251
+ };
252
+ export type CollectionSession = {
253
+ order_id: string;
254
+ /** Short-lived single-order token for the hosted flow. Returned once. */
255
+ session_token: string;
256
+ /** Unique per order — the attribution key. */
257
+ deposit_address: string;
258
+ /** Solana Pay URI (solana) or EIP-681 URI (EVM). */
259
+ qr_payload: string;
260
+ quote: SessionQuote;
261
+ status: OrderStatus;
262
+ created_at: string;
263
+ };
264
+ export type CollectionOrder = {
265
+ id: string;
266
+ gate: Gate;
267
+ direction: CollectionDirection;
268
+ status: OrderStatus;
269
+ amount_status: AmountStatus;
270
+ target: TargetAmount;
271
+ asset: Asset;
272
+ network: Network;
273
+ user_ref: string | null;
274
+ partner_ref: string | null;
275
+ /** Offramp: where the user sends crypto. Onramp: unused (see pay_account). */
276
+ deposit_address: string;
277
+ /**
278
+ * Offramp: deposit address per chain family/network when the engine returns
279
+ * them, so switching chains reuses the same order instead of creating a new
280
+ * one. Falls back to `deposit_address`. See `depositAddressFor`.
281
+ */
282
+ deposit_addresses?: Record<string, string>;
283
+ qr_payload: string;
284
+ /** Onramp: the virtual bank account to pay into. Null on offramp. */
285
+ pay_account: PayAccount | null;
286
+ quote: SessionQuote;
287
+ deposit: Deposit | null;
288
+ wrong_chain_deposit: WrongChainDeposit | null;
289
+ disbursement: Disbursement | null;
290
+ created_at: string;
291
+ };
292
+ /** Theme tokens as stored against the partner record (wire snake_case). */
293
+ export type PartnerConfigTheme = {
294
+ primary_color?: string;
295
+ border_radius?: string;
296
+ mode?: 'light' | 'dark';
297
+ font_family?: string;
298
+ ink_color?: string;
299
+ muted_color?: string;
300
+ background_color?: string;
301
+ button_text_color?: string;
302
+ line_color?: string;
303
+ success_color?: string;
304
+ danger_color?: string;
305
+ page_background_color?: string;
306
+ density?: 'comfortable' | 'compact';
307
+ };
308
+ /** Copy overrides as stored against the partner record (wire snake_case). */
309
+ export type PartnerConfigStrings = {
310
+ title?: string;
311
+ subtitle?: string;
312
+ hero_label?: string;
313
+ success_title?: string;
314
+ success_subtitle?: string;
315
+ expired_title?: string;
316
+ expired_subtitle?: string;
317
+ refresh_button?: string;
318
+ copy_button?: string;
319
+ copied_button?: string;
320
+ };
321
+ /**
322
+ * PROPOSED contract shape (R5 per-partner config). Set on the partner's
323
+ * dashboard, stored against the partner record in Pulse, resolved via the API
324
+ * key at session creation, and carried on client-session responses so the
325
+ * hosted flow reflects it with zero partner front-end code.
326
+ *
327
+ * Semantics: absent/null config = full defaults — every network and asset is
328
+ * available. `enabled_networks`/`enabled_assets` only ever NARROW the full
329
+ * built-in set, never widen it; mount options may narrow further but cannot
330
+ * re-enable what the config removed. Pulse enforces the lists server-side
331
+ * (session creation and re-selection reject disabled values) — the embed
332
+ * merely mirrors them in the UI.
333
+ */
334
+ export type PartnerConfig = {
335
+ /** Subset of the full network set this partner accepts. Absent = all. */
336
+ enabled_networks?: Network[];
337
+ /** Subset of the asset set this partner accepts. Absent = all. */
338
+ enabled_assets?: Asset[];
339
+ theme?: PartnerConfigTheme;
340
+ strings?: PartnerConfigStrings;
341
+ /** "none" removes the attribution footer (R3.4 — never mandatory). */
342
+ attribution?: 'subtle' | 'none';
343
+ /** Default flow when the mount doesn't pin one: selection UI or direct pay. */
344
+ flow?: 'select' | 'direct';
345
+ };
346
+ /**
347
+ * GET /v1/client/session response — the order plus the resolved partner
348
+ * config (also echoed on refresh_quote/select for symmetry). `partner_config`
349
+ * missing or null means the partner has no dashboard config.
350
+ */
351
+ export type ClientSessionState = CollectionOrder & {
352
+ partner_config?: PartnerConfig | null;
353
+ };
354
+ /**
355
+ * PROPOSED contract: `POST /v1/client/telemetry` (session-token auth, body
356
+ * `{ events: ClientTelemetryEvent[] }`, responds 202). Batched, pseudonymous
357
+ * funnel/error events emitted by the hosted embed — session/order ids are the
358
+ * only correlation keys; no PII, and delivery is fire-and-forget (the payment
359
+ * flow never depends on it). See docs/observability.md.
360
+ */
361
+ export type ClientTelemetryEvent = {
362
+ /** e.g. "session_loaded", "step_viewed", "status_change", "client_error". */
363
+ name: string;
364
+ /** ISO timestamp at emission. */
365
+ at: string;
366
+ /** Embed build that emitted the event, e.g. "embed/0.1.0". */
367
+ source: string;
368
+ /** Small scalar payload (step, status, error code…) — never free-form PII. */
369
+ data?: Record<string, string | number | boolean | null>;
370
+ };
371
+ export type ListOrdersParams = {
372
+ gate?: Gate;
373
+ status?: OrderStatus;
374
+ user_ref?: string;
375
+ created_after?: string;
376
+ created_before?: string;
377
+ limit?: number;
378
+ cursor?: string;
379
+ };
380
+ export type Page<T> = {
381
+ data: T[];
382
+ next_cursor: string | null;
383
+ };
384
+ export type AmountMismatchData = {
385
+ order_id: string;
386
+ expected: string;
387
+ received: string;
388
+ asset: Asset;
389
+ network: Network;
390
+ tx_hash: string;
391
+ };
392
+ type WebhookEventBase = {
393
+ /** evt_* — the idempotency key for consumers. */
394
+ id: string;
395
+ created_at: string;
396
+ };
397
+ export type WebhookEvent = (WebhookEventBase & {
398
+ type: 'collection.order.created';
399
+ data: {
400
+ order: CollectionOrder;
401
+ };
402
+ }) | (WebhookEventBase & {
403
+ type: 'collection.deposit.detected';
404
+ data: {
405
+ order_id: string;
406
+ deposit: Deposit;
407
+ };
408
+ }) | (WebhookEventBase & {
409
+ type: 'collection.deposit.confirmed';
410
+ data: {
411
+ order_id: string;
412
+ deposit: Deposit;
413
+ };
414
+ }) | (WebhookEventBase & {
415
+ type: 'collection.deposit.wrong_chain';
416
+ data: {
417
+ order_id: string;
418
+ deposit: WrongChainDeposit;
419
+ expected_network: Network;
420
+ };
421
+ }) | (WebhookEventBase & {
422
+ type: 'collection.amount.underpaid';
423
+ data: AmountMismatchData;
424
+ }) | (WebhookEventBase & {
425
+ type: 'collection.amount.overpaid';
426
+ data: AmountMismatchData;
427
+ }) | (WebhookEventBase & {
428
+ type: 'disbursement.completed';
429
+ data: {
430
+ order_id: string;
431
+ disbursement: Disbursement;
432
+ };
433
+ }) | (WebhookEventBase & {
434
+ type: 'collection.order.expired';
435
+ data: {
436
+ order_id: string;
437
+ };
438
+ }) | (WebhookEventBase & {
439
+ type: 'collection.order.failed';
440
+ data: {
441
+ order_id: string;
442
+ reason: string;
443
+ };
444
+ });
445
+ export type WebhookEventType = WebhookEvent['type'];
446
+ export {};
package/dist/types.js ADDED
@@ -0,0 +1,12 @@
1
+ /**
2
+ * Wire types for the Pulse API. Two sections:
3
+ *
4
+ * 1. ENGINE API — reconciled 1:1 against the live public contract
5
+ * (https://engine-api.shiga.io/swagger/public/index.html). These are real.
6
+ * 2. PROPOSED product extension — the collection-session/client-session/webhook
7
+ * surface the embedded product needs; not yet in the engine (see
8
+ * docs/engine-gap.md).
9
+ *
10
+ * Wire convention (both): snake_case JSON, decimal amounts as strings.
11
+ */
12
+ export {};
@@ -0,0 +1,6 @@
1
+ /**
2
+ * Kept in sync with package.json by a unit test (version drift fails CI) —
3
+ * a source constant avoids JSON-import interop differences across Node
4
+ * versions this SDK supports.
5
+ */
6
+ export declare const SDK_VERSION = "0.2.0";
@@ -0,0 +1,6 @@
1
+ /**
2
+ * Kept in sync with package.json by a unit test (version drift fails CI) —
3
+ * a source constant avoids JSON-import interop differences across Node
4
+ * versions this SDK supports.
5
+ */
6
+ export const SDK_VERSION = '0.2.0';
@@ -0,0 +1,49 @@
1
+ import type { WebhookEvent } from './types.js';
2
+ export type VerifyOptions = {
3
+ /** Max allowed clock skew between the signature timestamp and now. Default 300s. */
4
+ toleranceSeconds?: number;
5
+ /** Unix-seconds clock, injectable for tests. */
6
+ now?: () => number;
7
+ };
8
+ /**
9
+ * Produce a "Pulse-Signature" header value for a payload:
10
+ * `t=<unix>,v1=<hex hmac-sha256 of "<unix>.<payload>">`.
11
+ * Exported for test fixtures and the local dev mock.
12
+ */
13
+ export declare function signPayload(payload: string, secret: string, timestamp: number): string;
14
+ export type ProcessedEventStore = {
15
+ /** True if this event id has already been processed. */
16
+ has(eventId: string): boolean | Promise<boolean>;
17
+ /** Record an event id as processed. */
18
+ add(eventId: string): void | Promise<void>;
19
+ };
20
+ /**
21
+ * Single-process default. In multi-instance deployments back verifyOnce with
22
+ * a shared store (Redis, DB) via the ProcessedEventStore contract instead.
23
+ */
24
+ export declare class InMemoryProcessedEventStore {
25
+ private readonly ttlMs;
26
+ private readonly seen;
27
+ constructor(ttlMs?: number);
28
+ has(eventId: string): boolean;
29
+ add(eventId: string): void;
30
+ }
31
+ export declare class Webhooks {
32
+ private readonly secret;
33
+ private readonly store;
34
+ constructor(secret?: string, store?: ProcessedEventStore);
35
+ /**
36
+ * Verify a webhook delivery against its "Pulse-Signature" header and return
37
+ * the typed event. `payload` MUST be the raw request body — a re-serialized
38
+ * JSON object will not match the signature.
39
+ * Throws SignatureVerificationError on any mismatch.
40
+ */
41
+ verify(payload: string | Buffer, signatureHeader: string, secret?: string, options?: VerifyOptions): WebhookEvent;
42
+ /**
43
+ * Verify a delivery AND de-duplicate on event id in one call. Delivery is
44
+ * at-least-once, so redeliveries WILL arrive: the first sight of an id
45
+ * returns the typed event, a duplicate returns null (skip it, respond 200).
46
+ * Signature failures still throw before the store is touched.
47
+ */
48
+ verifyOnce(payload: string | Buffer, signatureHeader: string, secret?: string, options?: VerifyOptions): Promise<WebhookEvent | null>;
49
+ }
@@ -0,0 +1,135 @@
1
+ import { createHmac, timingSafeEqual } from 'node:crypto';
2
+ import { SignatureVerificationError } from './errors.js';
3
+ const DEFAULT_TOLERANCE_SECONDS = 300;
4
+ /**
5
+ * Produce a "Pulse-Signature" header value for a payload:
6
+ * `t=<unix>,v1=<hex hmac-sha256 of "<unix>.<payload>">`.
7
+ * Exported for test fixtures and the local dev mock.
8
+ */
9
+ export function signPayload(payload, secret, timestamp) {
10
+ const mac = createHmac('sha256', secret).update(`${timestamp}.${payload}`).digest('hex');
11
+ return `t=${timestamp},v1=${mac}`;
12
+ }
13
+ const DEFAULT_DEDUPE_TTL_MS = 24 * 60 * 60 * 1000;
14
+ /**
15
+ * Single-process default. In multi-instance deployments back verifyOnce with
16
+ * a shared store (Redis, DB) via the ProcessedEventStore contract instead.
17
+ */
18
+ export class InMemoryProcessedEventStore {
19
+ ttlMs;
20
+ seen = new Map();
21
+ constructor(ttlMs = DEFAULT_DEDUPE_TTL_MS) {
22
+ this.ttlMs = ttlMs;
23
+ }
24
+ has(eventId) {
25
+ const expiresAt = this.seen.get(eventId);
26
+ if (expiresAt === undefined)
27
+ return false;
28
+ if (expiresAt <= Date.now()) {
29
+ this.seen.delete(eventId);
30
+ return false;
31
+ }
32
+ return true;
33
+ }
34
+ add(eventId) {
35
+ const now = Date.now();
36
+ for (const [id, expiresAt] of this.seen) {
37
+ if (expiresAt <= now)
38
+ this.seen.delete(id);
39
+ }
40
+ this.seen.set(eventId, now + this.ttlMs);
41
+ }
42
+ }
43
+ export class Webhooks {
44
+ secret;
45
+ store;
46
+ constructor(secret, store) {
47
+ this.secret = secret ?? null;
48
+ this.store = store ?? new InMemoryProcessedEventStore();
49
+ }
50
+ /**
51
+ * Verify a webhook delivery against its "Pulse-Signature" header and return
52
+ * the typed event. `payload` MUST be the raw request body — a re-serialized
53
+ * JSON object will not match the signature.
54
+ * Throws SignatureVerificationError on any mismatch.
55
+ */
56
+ verify(payload, signatureHeader, secret, options = {}) {
57
+ const resolvedSecret = secret ?? this.secret;
58
+ if (resolvedSecret === null || resolvedSecret === '') {
59
+ throw new SignatureVerificationError('No webhook secret provided — pass one to verify() or the Pulse constructor');
60
+ }
61
+ const raw = typeof payload === 'string' ? payload : payload.toString('utf8');
62
+ const { timestamp, signatures } = parseSignatureHeader(signatureHeader);
63
+ const expected = createHmac('sha256', resolvedSecret)
64
+ .update(`${timestamp}.${raw}`)
65
+ .digest('hex');
66
+ const expectedBuf = Buffer.from(expected, 'utf8');
67
+ const matches = signatures.some((candidate) => {
68
+ const candidateBuf = Buffer.from(candidate, 'utf8');
69
+ return (candidateBuf.length === expectedBuf.length && timingSafeEqual(candidateBuf, expectedBuf));
70
+ });
71
+ if (!matches) {
72
+ throw new SignatureVerificationError('Webhook signature does not match the payload');
73
+ }
74
+ const tolerance = options.toleranceSeconds ?? DEFAULT_TOLERANCE_SECONDS;
75
+ const nowSeconds = options.now?.() ?? Math.floor(Date.now() / 1000);
76
+ if (Math.abs(nowSeconds - timestamp) > tolerance) {
77
+ throw new SignatureVerificationError(`Webhook timestamp outside tolerance (${tolerance}s) — possible replay`);
78
+ }
79
+ return parseEvent(raw);
80
+ }
81
+ /**
82
+ * Verify a delivery AND de-duplicate on event id in one call. Delivery is
83
+ * at-least-once, so redeliveries WILL arrive: the first sight of an id
84
+ * returns the typed event, a duplicate returns null (skip it, respond 200).
85
+ * Signature failures still throw before the store is touched.
86
+ */
87
+ async verifyOnce(payload, signatureHeader, secret, options = {}) {
88
+ const event = this.verify(payload, signatureHeader, secret, options);
89
+ if (await this.store.has(event.id)) {
90
+ return null;
91
+ }
92
+ await this.store.add(event.id);
93
+ return event;
94
+ }
95
+ }
96
+ function parseSignatureHeader(header) {
97
+ let timestamp = null;
98
+ const signatures = [];
99
+ for (const part of header.split(',')) {
100
+ const eq = part.indexOf('=');
101
+ if (eq === -1)
102
+ continue;
103
+ const key = part.slice(0, eq).trim();
104
+ const value = part.slice(eq + 1).trim();
105
+ if (key === 't') {
106
+ const parsed = Number(value);
107
+ if (Number.isInteger(parsed) && parsed > 0)
108
+ timestamp = parsed;
109
+ }
110
+ else if (key === 'v1' && value !== '') {
111
+ signatures.push(value);
112
+ }
113
+ }
114
+ if (timestamp === null || signatures.length === 0) {
115
+ throw new SignatureVerificationError('Malformed Pulse-Signature header');
116
+ }
117
+ return { timestamp, signatures };
118
+ }
119
+ function parseEvent(raw) {
120
+ let parsed;
121
+ try {
122
+ parsed = JSON.parse(raw);
123
+ }
124
+ catch {
125
+ throw new SignatureVerificationError('Webhook payload is not valid JSON');
126
+ }
127
+ if (typeof parsed !== 'object' ||
128
+ parsed === null ||
129
+ !('id' in parsed) ||
130
+ !('type' in parsed) ||
131
+ !('data' in parsed)) {
132
+ throw new SignatureVerificationError('Webhook payload is not an event envelope');
133
+ }
134
+ return parsed;
135
+ }
package/package.json ADDED
@@ -0,0 +1,38 @@
1
+ {
2
+ "name": "@pulsebyshiga/node",
3
+ "version": "0.2.0",
4
+ "description": "Pulse Collect backend SDK — collection sessions, orders, and webhook signature verification",
5
+ "license": "MIT",
6
+ "author": "Shiga Digital",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/Pulse-Digital/pulse-collect.git",
10
+ "directory": "packages/node-sdk"
11
+ },
12
+ "sideEffects": false,
13
+ "type": "module",
14
+ "main": "./dist/index.js",
15
+ "types": "./dist/index.d.ts",
16
+ "exports": {
17
+ ".": {
18
+ "types": "./dist/index.d.ts",
19
+ "default": "./dist/index.js"
20
+ }
21
+ },
22
+ "files": [
23
+ "dist"
24
+ ],
25
+ "engines": {
26
+ "node": ">=18.17"
27
+ },
28
+ "devDependencies": {
29
+ "@types/node": "^22.10.0",
30
+ "typescript": "^5.6.3",
31
+ "vitest": "^3.0.0"
32
+ },
33
+ "scripts": {
34
+ "build": "tsc -p tsconfig.build.json",
35
+ "typecheck": "tsc -p tsconfig.json --noEmit",
36
+ "test": "vitest run"
37
+ }
38
+ }