vairified 0.7.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -255,6 +255,148 @@ const result = await client.matches.tournamentImport({
255
255
  console.log(`Imported ${result.matchesImported} matches, ${result.ghostPlayersCreated} ghosts`);
256
256
  ```
257
257
 
258
+ ## Receiving Webhooks
259
+
260
+ `verifyWebhook()` checks a delivery's signature and hands it back typed. It takes no client
261
+ and no API key — a webhook receiver is an inbound HTTP handler, and often never calls the
262
+ Partner API at all.
263
+
264
+ **Verify on your server, never in a browser or a mobile app.** The signing secret is what
265
+ proves a delivery came from us; anything that ships to a user's device can be read out of it.
266
+
267
+ ```ts
268
+ import { verifyWebhook, isMemberStatusEvent, WebhookSignatureError } from 'vairified';
269
+
270
+ const event = await verifyWebhook(
271
+ rawBody, // the exact bytes — see below
272
+ request.headers['x-vairified-signature'],
273
+ process.env.VAIR_WEBHOOK_SECRET,
274
+ );
275
+
276
+ if (isMemberStatusEvent(event)) {
277
+ await entitlements.set(event.data.memberId, {
278
+ vairPlus: event.data.isVairPlus,
279
+ ambassador: event.data.isAmbassador,
280
+ });
281
+ }
282
+ ```
283
+
284
+ It is `async` — **`await` it.** Verification uses Web Crypto, which has no synchronous HMAC.
285
+ The function never returns a boolean, precisely so that a forgotten `await` cannot be read as
286
+ "the signature was fine".
287
+
288
+ ### Capture the raw body
289
+
290
+ The signature covers **the bytes we sent**. A body that has been parsed and re-serialised is
291
+ not those bytes — key order, whitespace and number formatting all move — so every signature
292
+ fails. In Express, that means reaching for `express.raw()` on the webhook route specifically,
293
+ even when the rest of the app uses `express.json()`:
294
+
295
+ ```ts
296
+ import express from 'express';
297
+ import { verifyWebhook, WebhookSignatureError, dedupeKey } from 'vairified';
298
+
299
+ const app = express();
300
+
301
+ app.post('/webhooks/vairified', express.raw({ type: 'application/json' }), async (req, res) => {
302
+ let event;
303
+ try {
304
+ event = await verifyWebhook(req.body, req.get('X-Vairified-Signature'), [
305
+ process.env.VAIR_WEBHOOK_SECRET,
306
+ process.env.VAIR_WEBHOOK_SECRET_PREVIOUS, // see "Rotating the secret"
307
+ ]);
308
+ } catch (err) {
309
+ if (err instanceof WebhookSignatureError) return res.sendStatus(400);
310
+ throw err;
311
+ }
312
+
313
+ // Respond first, work afterwards — a slow handler is retried as a failure.
314
+ res.sendStatus(200);
315
+ await queue.add(dedupeKey(event), event);
316
+ });
317
+
318
+ app.use(express.json()); // everything else
319
+ ```
320
+
321
+ In a Fetch-based runtime (Workers, Deno, Bun, Next.js route handlers) `await request.text()`
322
+ already gives you the raw body; pass it straight in. `rawBody` accepts a `string` or a
323
+ `Uint8Array`.
324
+
325
+ ### Handling what arrives
326
+
327
+ ```ts
328
+ const event = await verifyWebhook(rawBody, signatureHeader, process.env.VAIR_WEBHOOK_SECRET);
329
+
330
+ if (isMemberStatusEvent(event)) {
331
+ // membership or ambassador standing changed
332
+ } else if (isRatingUpdatedEvent(event)) {
333
+ // a new rating, as a full per-sport snapshot
334
+ } else if (isConnectionRevokedEvent(event)) {
335
+ // the member disconnected your app — stop reading their data
336
+ } else if (isEventCreatedEvent(event)) {
337
+ // a new event was published
338
+ }
339
+ // Anything else is an event type this version does not know about. It still
340
+ // verified, and arrives as the plain envelope — ignore it, or log it.
341
+ ```
342
+
343
+ An event type the package does not recognise **verifies successfully and is handed over
344
+ as-is**, and so does an unfamiliar value inside one it does recognise. Both grow on the API's
345
+ schedule rather than this package's, and refusing one would break a working receiver over a
346
+ change that is not a break.
347
+
348
+ ### Ordering and duplicates
349
+
350
+ Delivery is at-least-once, and deliveries can arrive out of order.
351
+
352
+ ```ts
353
+ if (isRatingUpdatedEvent(event)) {
354
+ const last = await store.get(event.data.memberId);
355
+ if (last && !isNewerSequence(event.data.sequence, last)) return; // stale — skip it
356
+ await store.put(event.data.memberId, event.data.sequence);
357
+ }
358
+ ```
359
+
360
+ - **`sequence` is an unpadded decimal string, so do not compare it as one.** `'10000000' >
361
+ '9999999'` is `false`, which would discard every later delivery for that member from the
362
+ first power-of-ten crossing onward. `isNewerSequence()` and `compareSequence()` compare as
363
+ integers.
364
+ - **Deduplicate with `dedupeKey(event)`**, which returns the id from the *signed body*. The
365
+ `X-Vairified-Event-Id` header carries the same value but is outside the signature, so a
366
+ replayed delivery can present a fresh one and header-based deduplication admits it every
367
+ time.
368
+
369
+ ### Rotating the secret
370
+
371
+ `secret` accepts an array, and any one match verifies:
372
+
373
+ ```ts
374
+ const event = await verifyWebhook(rawBody, signatureHeader, [
375
+ process.env.VAIR_WEBHOOK_SECRET,
376
+ process.env.VAIR_WEBHOOK_SECRET_PREVIOUS,
377
+ ]);
378
+ ```
379
+
380
+ Deliveries queued before you rotated were signed with the **old** secret and keep arriving for
381
+ hours afterwards. A verifier that knows only the new one discards them silently, so keep the
382
+ previous secret configured until the retry tail has drained.
383
+
384
+ ### Why a delivery was refused
385
+
386
+ Every refusal is a `WebhookSignatureError` with a `reason` you can branch on:
387
+
388
+ | `reason` | Means |
389
+ |---|---|
390
+ | `no_secret_configured` | Your configuration, not an attack — almost always an unset environment variable. |
391
+ | `invalid_option` | A tolerance or clock you passed in was not usable. |
392
+ | `missing_signature` | No `X-Vairified-Signature` header. |
393
+ | `malformed_signature` | The header was present but unparseable, or had no `t` / `v1`. |
394
+ | `timestamp_out_of_tolerance` | The delivery's clock and yours disagree by more than the window (5 minutes by default, applied in both directions). |
395
+ | `signature_mismatch` | The body does not match the signature under any secret supplied. |
396
+ | `malformed_body` | The body was not JSON, or a field a partner gates access on was missing or the wrong type. |
397
+
398
+ The message never contains the secret or either digest.
399
+
258
400
  ## Webhook Deliveries
259
401
 
260
402
  Inspect recent webhook delivery attempts for your app:
package/dist/index.cjs CHANGED
@@ -22,6 +22,7 @@ var index_exports = {};
22
22
  __export(index_exports, {
23
23
  AuthenticationError: () => AuthenticationError,
24
24
  DEFAULT_SCOPES: () => DEFAULT_SCOPES,
25
+ DEFAULT_TOLERANCE_SECONDS: () => DEFAULT_TOLERANCE_SECONDS,
25
26
  ENVIRONMENTS: () => ENVIRONMENTS,
26
27
  LeaderboardResource: () => LeaderboardResource,
27
28
  MatchBatchResult: () => MatchBatchResult,
@@ -44,12 +45,21 @@ __export(index_exports, {
44
45
  ValidationError: () => ValidationError,
45
46
  WebhookDeliveriesResult: () => WebhookDeliveriesResult,
46
47
  WebhookDelivery: () => WebhookDelivery,
48
+ WebhookSignatureError: () => WebhookSignatureError,
47
49
  WebhooksResource: () => WebhooksResource,
50
+ compareSequence: () => compareSequence,
51
+ dedupeKey: () => dedupeKey,
48
52
  describeScope: () => describeScope,
49
53
  describeScopes: () => describeScopes,
50
54
  generateState: () => generateState,
51
55
  getAuthorizationUrl: () => getAuthorizationUrl,
52
- validateScope: () => validateScope
56
+ isConnectionRevokedEvent: () => isConnectionRevokedEvent,
57
+ isEventCreatedEvent: () => isEventCreatedEvent,
58
+ isMemberStatusEvent: () => isMemberStatusEvent,
59
+ isNewerSequence: () => isNewerSequence,
60
+ isRatingUpdatedEvent: () => isRatingUpdatedEvent,
61
+ validateScope: () => validateScope,
62
+ verifyWebhook: () => verifyWebhook
53
63
  });
54
64
  module.exports = __toCommonJS(index_exports);
55
65
 
@@ -105,6 +115,14 @@ var OAuthError = class extends VairifiedError {
105
115
  this.errorCode = errorCode;
106
116
  }
107
117
  };
118
+ var WebhookSignatureError = class extends VairifiedError {
119
+ reason;
120
+ constructor(reason, message) {
121
+ super(message);
122
+ this.name = "WebhookSignatureError";
123
+ this.reason = reason;
124
+ }
125
+ };
108
126
 
109
127
  // src/http.ts
110
128
  var HttpTransport = class {
@@ -1575,10 +1593,208 @@ var Vairified = class {
1575
1593
  return `Vairified { env: '${this.env}', baseUrl: '${this.baseUrl}' }`;
1576
1594
  }
1577
1595
  };
1596
+
1597
+ // src/webhooks/verify.ts
1598
+ var DEFAULT_TOLERANCE_SECONDS = 300;
1599
+ var SIGNATURE_HEADER = "X-Vairified-Signature";
1600
+ var encoder = new TextEncoder();
1601
+ function toBytes(body) {
1602
+ return typeof body === "string" ? encoder.encode(body) : body;
1603
+ }
1604
+ function parseSignatureHeader(header) {
1605
+ let timestamp = null;
1606
+ let rawTimestamp = null;
1607
+ let signature = null;
1608
+ for (const part of header.split(",")) {
1609
+ const eq = part.indexOf("=");
1610
+ if (eq === -1) continue;
1611
+ const key = part.slice(0, eq).trim();
1612
+ const value = part.slice(eq + 1).trim();
1613
+ if (key === "t" && timestamp === null) {
1614
+ if (!/^\d+$/.test(value)) {
1615
+ throw new WebhookSignatureError(
1616
+ "malformed_signature",
1617
+ `${SIGNATURE_HEADER} carried a timestamp that is not an integer`
1618
+ );
1619
+ }
1620
+ timestamp = Number(value);
1621
+ rawTimestamp = value;
1622
+ } else if (key === "v1" && signature === null) {
1623
+ signature = value;
1624
+ }
1625
+ }
1626
+ if (timestamp === null || rawTimestamp === null || signature === null) {
1627
+ throw new WebhookSignatureError(
1628
+ "malformed_signature",
1629
+ `${SIGNATURE_HEADER} must carry both a 't' and a 'v1' part`
1630
+ );
1631
+ }
1632
+ return { timestamp, rawTimestamp, signature };
1633
+ }
1634
+ var MAX_SIGNATURE_HEX = 64;
1635
+ function hexToBytes(hex) {
1636
+ if (hex.length > MAX_SIGNATURE_HEX) {
1637
+ throw new WebhookSignatureError(
1638
+ "malformed_signature",
1639
+ `${SIGNATURE_HEADER} carried a 'v1' value longer than a SHA-256 digest`
1640
+ );
1641
+ }
1642
+ if (hex.length === 0 || hex.length % 2 !== 0 || !/^[0-9a-fA-F]+$/.test(hex)) {
1643
+ throw new WebhookSignatureError(
1644
+ "malformed_signature",
1645
+ `${SIGNATURE_HEADER} carried a 'v1' value that is not a hex digest`
1646
+ );
1647
+ }
1648
+ const out = new Uint8Array(hex.length / 2);
1649
+ for (let i = 0; i < out.length; i++) {
1650
+ out[i] = Number.parseInt(hex.slice(i * 2, i * 2 + 2), 16);
1651
+ }
1652
+ return out;
1653
+ }
1654
+ function assertEnvelope(value) {
1655
+ if (typeof value !== "object" || value === null) {
1656
+ throw new WebhookSignatureError("malformed_body", "The webhook body is not a JSON object");
1657
+ }
1658
+ const o = value;
1659
+ for (const field of ["event", "eventId", "timestamp"]) {
1660
+ if (typeof o[field] !== "string") {
1661
+ throw new WebhookSignatureError(
1662
+ "malformed_body",
1663
+ `The webhook body is missing a string '${field}'`
1664
+ );
1665
+ }
1666
+ }
1667
+ }
1668
+ function assertKnownEventShape(event) {
1669
+ const bad = (field) => {
1670
+ throw new WebhookSignatureError(
1671
+ "malformed_body",
1672
+ `A '${event.event}' event is missing a valid '${field}'`
1673
+ );
1674
+ };
1675
+ const data = event.data;
1676
+ if (typeof data !== "object" || data === null) {
1677
+ if (event.event === "member.status" || event.event === "rating.updated") bad("data");
1678
+ return;
1679
+ }
1680
+ if (event.event === "member.status") {
1681
+ if (typeof data.memberId !== "number") bad("data.memberId");
1682
+ if (typeof data.isVairPlus !== "boolean") bad("data.isVairPlus");
1683
+ if (typeof data.isAmbassador !== "boolean") bad("data.isAmbassador");
1684
+ } else if (event.event === "rating.updated") {
1685
+ if (typeof data.memberId !== "number") bad("data.memberId");
1686
+ if (typeof data.sequence !== "string") bad("data.sequence");
1687
+ }
1688
+ }
1689
+ async function verifyWebhook(rawBody, signatureHeader, secret, options = {}) {
1690
+ const candidates = typeof secret === "string" ? [secret] : Array.isArray(secret) ? secret : [];
1691
+ const secrets = candidates.filter(
1692
+ (s) => typeof s === "string" && s.trim().length > 0
1693
+ );
1694
+ if (secrets.length === 0) {
1695
+ throw new WebhookSignatureError(
1696
+ "no_secret_configured",
1697
+ "No usable signing secret was supplied"
1698
+ );
1699
+ }
1700
+ if (!signatureHeader) {
1701
+ throw new WebhookSignatureError(
1702
+ "missing_signature",
1703
+ `No ${SIGNATURE_HEADER} header was supplied`
1704
+ );
1705
+ }
1706
+ const tolerance = options.toleranceSeconds ?? DEFAULT_TOLERANCE_SECONDS;
1707
+ const now = options.nowSeconds ?? Math.floor(Date.now() / 1e3);
1708
+ if (!Number.isFinite(tolerance) || tolerance < 0) {
1709
+ throw new WebhookSignatureError(
1710
+ "invalid_option",
1711
+ "toleranceSeconds must be a finite, non-negative number"
1712
+ );
1713
+ }
1714
+ if (!Number.isFinite(now)) {
1715
+ throw new WebhookSignatureError("invalid_option", "nowSeconds must be a finite number");
1716
+ }
1717
+ const { timestamp, rawTimestamp, signature } = parseSignatureHeader(signatureHeader);
1718
+ if (Math.abs(now - timestamp) > tolerance) {
1719
+ throw new WebhookSignatureError(
1720
+ "timestamp_out_of_tolerance",
1721
+ `The delivery timestamp is outside the ${tolerance}s tolerance`
1722
+ );
1723
+ }
1724
+ const signatureBytes = hexToBytes(signature);
1725
+ const bodyBytes = toBytes(rawBody);
1726
+ const prefix = encoder.encode(`${rawTimestamp}.`);
1727
+ const signed = new Uint8Array(prefix.length + bodyBytes.length);
1728
+ signed.set(prefix, 0);
1729
+ signed.set(bodyBytes, prefix.length);
1730
+ let matched = false;
1731
+ for (const candidate of secrets) {
1732
+ const key = await crypto.subtle.importKey(
1733
+ "raw",
1734
+ encoder.encode(candidate),
1735
+ { name: "HMAC", hash: "SHA-256" },
1736
+ false,
1737
+ ["verify"]
1738
+ );
1739
+ matched = await crypto.subtle.verify("HMAC", key, signatureBytes, signed) || matched;
1740
+ }
1741
+ if (!matched) {
1742
+ throw new WebhookSignatureError(
1743
+ "signature_mismatch",
1744
+ "The webhook signature did not match any supplied secret"
1745
+ );
1746
+ }
1747
+ let parsed;
1748
+ try {
1749
+ parsed = JSON.parse(
1750
+ new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(
1751
+ signed.subarray(prefix.length)
1752
+ )
1753
+ );
1754
+ } catch {
1755
+ throw new WebhookSignatureError("malformed_body", "The webhook body is not valid JSON");
1756
+ }
1757
+ assertEnvelope(parsed);
1758
+ assertKnownEventShape(parsed);
1759
+ return deepFreeze(parsed);
1760
+ }
1761
+ function deepFreeze(value) {
1762
+ if (value === null || typeof value !== "object" || Object.isFrozen(value)) return value;
1763
+ Object.freeze(value);
1764
+ for (const key of Object.getOwnPropertyNames(value)) {
1765
+ deepFreeze(value[key]);
1766
+ }
1767
+ return value;
1768
+ }
1769
+ function isMemberStatusEvent(event) {
1770
+ return event.event === "member.status";
1771
+ }
1772
+ function isConnectionRevokedEvent(event) {
1773
+ return event.event === "connection.revoked";
1774
+ }
1775
+ function isRatingUpdatedEvent(event) {
1776
+ return event.event === "rating.updated";
1777
+ }
1778
+ function isEventCreatedEvent(event) {
1779
+ return event.event === "event.created";
1780
+ }
1781
+ function compareSequence(a, b) {
1782
+ const x = BigInt(a);
1783
+ const y = BigInt(b);
1784
+ return x < y ? -1 : x > y ? 1 : 0;
1785
+ }
1786
+ function isNewerSequence(incoming, lastApplied) {
1787
+ if (lastApplied === void 0 || lastApplied === null || lastApplied === "") return true;
1788
+ return compareSequence(incoming, lastApplied) > 0;
1789
+ }
1790
+ function dedupeKey(event) {
1791
+ return event.eventId;
1792
+ }
1578
1793
  // Annotate the CommonJS export names for ESM import in node:
1579
1794
  0 && (module.exports = {
1580
1795
  AuthenticationError,
1581
1796
  DEFAULT_SCOPES,
1797
+ DEFAULT_TOLERANCE_SECONDS,
1582
1798
  ENVIRONMENTS,
1583
1799
  LeaderboardResource,
1584
1800
  MatchBatchResult,
@@ -1601,11 +1817,20 @@ var Vairified = class {
1601
1817
  ValidationError,
1602
1818
  WebhookDeliveriesResult,
1603
1819
  WebhookDelivery,
1820
+ WebhookSignatureError,
1604
1821
  WebhooksResource,
1822
+ compareSequence,
1823
+ dedupeKey,
1605
1824
  describeScope,
1606
1825
  describeScopes,
1607
1826
  generateState,
1608
1827
  getAuthorizationUrl,
1609
- validateScope
1828
+ isConnectionRevokedEvent,
1829
+ isEventCreatedEvent,
1830
+ isMemberStatusEvent,
1831
+ isNewerSequence,
1832
+ isRatingUpdatedEvent,
1833
+ validateScope,
1834
+ verifyWebhook
1610
1835
  });
1611
1836
  //# sourceMappingURL=index.cjs.map