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/dist/index.js CHANGED
@@ -50,6 +50,14 @@ var OAuthError = class extends VairifiedError {
50
50
  this.errorCode = errorCode;
51
51
  }
52
52
  };
53
+ var WebhookSignatureError = class extends VairifiedError {
54
+ reason;
55
+ constructor(reason, message) {
56
+ super(message);
57
+ this.name = "WebhookSignatureError";
58
+ this.reason = reason;
59
+ }
60
+ };
53
61
 
54
62
  // src/http.ts
55
63
  var HttpTransport = class {
@@ -1520,9 +1528,207 @@ var Vairified = class {
1520
1528
  return `Vairified { env: '${this.env}', baseUrl: '${this.baseUrl}' }`;
1521
1529
  }
1522
1530
  };
1531
+
1532
+ // src/webhooks/verify.ts
1533
+ var DEFAULT_TOLERANCE_SECONDS = 300;
1534
+ var SIGNATURE_HEADER = "X-Vairified-Signature";
1535
+ var encoder = new TextEncoder();
1536
+ function toBytes(body) {
1537
+ return typeof body === "string" ? encoder.encode(body) : body;
1538
+ }
1539
+ function parseSignatureHeader(header) {
1540
+ let timestamp = null;
1541
+ let rawTimestamp = null;
1542
+ let signature = null;
1543
+ for (const part of header.split(",")) {
1544
+ const eq = part.indexOf("=");
1545
+ if (eq === -1) continue;
1546
+ const key = part.slice(0, eq).trim();
1547
+ const value = part.slice(eq + 1).trim();
1548
+ if (key === "t" && timestamp === null) {
1549
+ if (!/^\d+$/.test(value)) {
1550
+ throw new WebhookSignatureError(
1551
+ "malformed_signature",
1552
+ `${SIGNATURE_HEADER} carried a timestamp that is not an integer`
1553
+ );
1554
+ }
1555
+ timestamp = Number(value);
1556
+ rawTimestamp = value;
1557
+ } else if (key === "v1" && signature === null) {
1558
+ signature = value;
1559
+ }
1560
+ }
1561
+ if (timestamp === null || rawTimestamp === null || signature === null) {
1562
+ throw new WebhookSignatureError(
1563
+ "malformed_signature",
1564
+ `${SIGNATURE_HEADER} must carry both a 't' and a 'v1' part`
1565
+ );
1566
+ }
1567
+ return { timestamp, rawTimestamp, signature };
1568
+ }
1569
+ var MAX_SIGNATURE_HEX = 64;
1570
+ function hexToBytes(hex) {
1571
+ if (hex.length > MAX_SIGNATURE_HEX) {
1572
+ throw new WebhookSignatureError(
1573
+ "malformed_signature",
1574
+ `${SIGNATURE_HEADER} carried a 'v1' value longer than a SHA-256 digest`
1575
+ );
1576
+ }
1577
+ if (hex.length === 0 || hex.length % 2 !== 0 || !/^[0-9a-fA-F]+$/.test(hex)) {
1578
+ throw new WebhookSignatureError(
1579
+ "malformed_signature",
1580
+ `${SIGNATURE_HEADER} carried a 'v1' value that is not a hex digest`
1581
+ );
1582
+ }
1583
+ const out = new Uint8Array(hex.length / 2);
1584
+ for (let i = 0; i < out.length; i++) {
1585
+ out[i] = Number.parseInt(hex.slice(i * 2, i * 2 + 2), 16);
1586
+ }
1587
+ return out;
1588
+ }
1589
+ function assertEnvelope(value) {
1590
+ if (typeof value !== "object" || value === null) {
1591
+ throw new WebhookSignatureError("malformed_body", "The webhook body is not a JSON object");
1592
+ }
1593
+ const o = value;
1594
+ for (const field of ["event", "eventId", "timestamp"]) {
1595
+ if (typeof o[field] !== "string") {
1596
+ throw new WebhookSignatureError(
1597
+ "malformed_body",
1598
+ `The webhook body is missing a string '${field}'`
1599
+ );
1600
+ }
1601
+ }
1602
+ }
1603
+ function assertKnownEventShape(event) {
1604
+ const bad = (field) => {
1605
+ throw new WebhookSignatureError(
1606
+ "malformed_body",
1607
+ `A '${event.event}' event is missing a valid '${field}'`
1608
+ );
1609
+ };
1610
+ const data = event.data;
1611
+ if (typeof data !== "object" || data === null) {
1612
+ if (event.event === "member.status" || event.event === "rating.updated") bad("data");
1613
+ return;
1614
+ }
1615
+ if (event.event === "member.status") {
1616
+ if (typeof data.memberId !== "number") bad("data.memberId");
1617
+ if (typeof data.isVairPlus !== "boolean") bad("data.isVairPlus");
1618
+ if (typeof data.isAmbassador !== "boolean") bad("data.isAmbassador");
1619
+ } else if (event.event === "rating.updated") {
1620
+ if (typeof data.memberId !== "number") bad("data.memberId");
1621
+ if (typeof data.sequence !== "string") bad("data.sequence");
1622
+ }
1623
+ }
1624
+ async function verifyWebhook(rawBody, signatureHeader, secret, options = {}) {
1625
+ const candidates = typeof secret === "string" ? [secret] : Array.isArray(secret) ? secret : [];
1626
+ const secrets = candidates.filter(
1627
+ (s) => typeof s === "string" && s.trim().length > 0
1628
+ );
1629
+ if (secrets.length === 0) {
1630
+ throw new WebhookSignatureError(
1631
+ "no_secret_configured",
1632
+ "No usable signing secret was supplied"
1633
+ );
1634
+ }
1635
+ if (!signatureHeader) {
1636
+ throw new WebhookSignatureError(
1637
+ "missing_signature",
1638
+ `No ${SIGNATURE_HEADER} header was supplied`
1639
+ );
1640
+ }
1641
+ const tolerance = options.toleranceSeconds ?? DEFAULT_TOLERANCE_SECONDS;
1642
+ const now = options.nowSeconds ?? Math.floor(Date.now() / 1e3);
1643
+ if (!Number.isFinite(tolerance) || tolerance < 0) {
1644
+ throw new WebhookSignatureError(
1645
+ "invalid_option",
1646
+ "toleranceSeconds must be a finite, non-negative number"
1647
+ );
1648
+ }
1649
+ if (!Number.isFinite(now)) {
1650
+ throw new WebhookSignatureError("invalid_option", "nowSeconds must be a finite number");
1651
+ }
1652
+ const { timestamp, rawTimestamp, signature } = parseSignatureHeader(signatureHeader);
1653
+ if (Math.abs(now - timestamp) > tolerance) {
1654
+ throw new WebhookSignatureError(
1655
+ "timestamp_out_of_tolerance",
1656
+ `The delivery timestamp is outside the ${tolerance}s tolerance`
1657
+ );
1658
+ }
1659
+ const signatureBytes = hexToBytes(signature);
1660
+ const bodyBytes = toBytes(rawBody);
1661
+ const prefix = encoder.encode(`${rawTimestamp}.`);
1662
+ const signed = new Uint8Array(prefix.length + bodyBytes.length);
1663
+ signed.set(prefix, 0);
1664
+ signed.set(bodyBytes, prefix.length);
1665
+ let matched = false;
1666
+ for (const candidate of secrets) {
1667
+ const key = await crypto.subtle.importKey(
1668
+ "raw",
1669
+ encoder.encode(candidate),
1670
+ { name: "HMAC", hash: "SHA-256" },
1671
+ false,
1672
+ ["verify"]
1673
+ );
1674
+ matched = await crypto.subtle.verify("HMAC", key, signatureBytes, signed) || matched;
1675
+ }
1676
+ if (!matched) {
1677
+ throw new WebhookSignatureError(
1678
+ "signature_mismatch",
1679
+ "The webhook signature did not match any supplied secret"
1680
+ );
1681
+ }
1682
+ let parsed;
1683
+ try {
1684
+ parsed = JSON.parse(
1685
+ new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(
1686
+ signed.subarray(prefix.length)
1687
+ )
1688
+ );
1689
+ } catch {
1690
+ throw new WebhookSignatureError("malformed_body", "The webhook body is not valid JSON");
1691
+ }
1692
+ assertEnvelope(parsed);
1693
+ assertKnownEventShape(parsed);
1694
+ return deepFreeze(parsed);
1695
+ }
1696
+ function deepFreeze(value) {
1697
+ if (value === null || typeof value !== "object" || Object.isFrozen(value)) return value;
1698
+ Object.freeze(value);
1699
+ for (const key of Object.getOwnPropertyNames(value)) {
1700
+ deepFreeze(value[key]);
1701
+ }
1702
+ return value;
1703
+ }
1704
+ function isMemberStatusEvent(event) {
1705
+ return event.event === "member.status";
1706
+ }
1707
+ function isConnectionRevokedEvent(event) {
1708
+ return event.event === "connection.revoked";
1709
+ }
1710
+ function isRatingUpdatedEvent(event) {
1711
+ return event.event === "rating.updated";
1712
+ }
1713
+ function isEventCreatedEvent(event) {
1714
+ return event.event === "event.created";
1715
+ }
1716
+ function compareSequence(a, b) {
1717
+ const x = BigInt(a);
1718
+ const y = BigInt(b);
1719
+ return x < y ? -1 : x > y ? 1 : 0;
1720
+ }
1721
+ function isNewerSequence(incoming, lastApplied) {
1722
+ if (lastApplied === void 0 || lastApplied === null || lastApplied === "") return true;
1723
+ return compareSequence(incoming, lastApplied) > 0;
1724
+ }
1725
+ function dedupeKey(event) {
1726
+ return event.eventId;
1727
+ }
1523
1728
  export {
1524
1729
  AuthenticationError,
1525
1730
  DEFAULT_SCOPES,
1731
+ DEFAULT_TOLERANCE_SECONDS,
1526
1732
  ENVIRONMENTS,
1527
1733
  LeaderboardResource,
1528
1734
  MatchBatchResult,
@@ -1545,11 +1751,20 @@ export {
1545
1751
  ValidationError,
1546
1752
  WebhookDeliveriesResult,
1547
1753
  WebhookDelivery,
1754
+ WebhookSignatureError,
1548
1755
  WebhooksResource,
1756
+ compareSequence,
1757
+ dedupeKey,
1549
1758
  describeScope,
1550
1759
  describeScopes,
1551
1760
  generateState,
1552
1761
  getAuthorizationUrl,
1553
- validateScope
1762
+ isConnectionRevokedEvent,
1763
+ isEventCreatedEvent,
1764
+ isMemberStatusEvent,
1765
+ isNewerSequence,
1766
+ isRatingUpdatedEvent,
1767
+ validateScope,
1768
+ verifyWebhook
1554
1769
  };
1555
1770
  //# sourceMappingURL=index.js.map