@particle-academy/fancy-connector-core 0.8.1 → 0.9.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/CHANGELOG.md CHANGED
@@ -6,6 +6,56 @@ All notable changes to `fancy-connector-core` are documented here, in
6
6
  **This package is pre-1.0, so breaking changes land in MINOR releases.** The
7
7
  version number is not a promise it can keep yet; the entries below are.
8
8
 
9
+ ## [0.9.0] - 2026-09-15
10
+
11
+ The verification half of Tynn #11 (Resend inbound email, signed by Svix),
12
+ for the two providers that make it vocabulary: Svix and Stripe, both read
13
+ from their own pages on 2026-09-15. Additive in both runtimes;
14
+ `CONNECTOR_API_VERSION` stays at 1.
15
+
16
+ ### Added
17
+
18
+ - **`HmacScheme.secretEncoding` (`utf8` | `base64`) and `secretPrefix`**, with
19
+ `secretKeyBytes()` / `WebhookVerifier::secretKeyBytes()`. Svix hands out
20
+ `whsec_<base64>`: the HMAC key is the DECODED remainder, raw bytes, half of
21
+ which are not valid UTF-8 — encoding the text signed with the wrong key and
22
+ failed exactly like a wrong secret. `utf8` stays the default, byte for
23
+ byte what 0.8.x did. A secret without its declared prefix, or a base64 one
24
+ that is not base64, is refused BY NAME (`signing secret does not start with
25
+ "whsec_"`, `signing secret is not valid base64`) before anything is signed.
26
+ - **`verifyHmac` accepts a LIST of signatures and the delivery passes when
27
+ ANY matches**; `WebhookVerificationSpec.parse` may return `signatures`
28
+ beside the 0.8.x `signature`. Stripe signs once per active secret while a
29
+ secret is rolled and says to compare against EACH; Svix's header "could be
30
+ any number of signatures" of which yours must match one. An empty list is
31
+ no signature, never a vacuous match.
32
+ - **`fixtures/signed-delivery/cases.json`** — 14 authored rows, every
33
+ signature computed by the provider's own rule with `node:crypto` rather
34
+ than by the code under test: Svix valid, a rotation where only the SECOND
35
+ signature is current, none current, at and outside the replay window, the
36
+ secret as text (never matches), a missing prefix and invalid base64 (both
37
+ named), no signature, no secret; Stripe valid, a roll, a re-serialised
38
+ body, a missing timestamp. Both runtimes read it; the seed of the
39
+ conformance suite of that name.
40
+ - **`HmacVerificationSpec.idHeader`, and the payload's third argument.**
41
+ Svix signs `${svix-id}.${svix-timestamp}.${body}`: the delivery's ID is
42
+ part of the signed content, so `verifyDelivery` reads the declared header
43
+ and hands it to `scheme.payload(raw, timestamp, id)` — `verify(...,
44
+ id:)` in PHP, `verify_hmac(..., id=)` in the Python seed. A declared id
45
+ header the delivery does not carry is refused as `delivery carried no id
46
+ header`, never signed with a hole in it. Every two-argument payload
47
+ written before this release is called exactly as before.
48
+
49
+ ### Fixed
50
+
51
+ - **A first-only signature rule fails a whole secret roll.** `verifyDelivery`
52
+ passed only the FIRST signature a header carried, so during Stripe's
53
+ 24-hour roll (or a Svix rotation) every delivery whose first `v1` came
54
+ from the new secret was refused as `signature did not match` — which reads
55
+ as a wrong secret, on a day the secret was just changed. Found by Fancy's
56
+ review of the #11 design; the emitted parsers in weaver.agi collect every
57
+ signature from the same change on.
58
+
9
59
  ## [0.8.1] - 2026-09-15
10
60
 
11
61
  ### Changed
package/dist/index.cjs CHANGED
@@ -101,6 +101,7 @@ __export(src_exports, {
101
101
  sandboxIsSelectable: () => sandboxIsSelectable,
102
102
  sandboxRefusal: () => sandboxRefusal,
103
103
  satisfiesMinimum: () => satisfiesMinimum,
104
+ secretKeyBytes: () => secretKeyBytes,
104
105
  seedFrom: () => seedFrom,
105
106
  shapeOf: () => shapeOf,
106
107
  shouldRetry: () => shouldRetry,
@@ -1676,12 +1677,35 @@ var PROBLEM_SEVERITIES = ["block", "warn"];
1676
1677
  var CANONICAL_METRICS = ["like", "share", "reply", "quote", "view"];
1677
1678
 
1678
1679
  // src/webhook.ts
1680
+ function secretKeyBytes(secret, options = {}) {
1681
+ let material = secret;
1682
+ if (options.secretPrefix !== void 0) {
1683
+ if (!material.startsWith(options.secretPrefix)) {
1684
+ return { reason: `signing secret does not start with ${JSON.stringify(options.secretPrefix)}` };
1685
+ }
1686
+ material = material.slice(options.secretPrefix.length);
1687
+ }
1688
+ if ((options.secretEncoding ?? "utf8") === "base64") {
1689
+ if (!/^[A-Za-z0-9+/]*={0,2}$/.test(material) || material.length % 4 !== 0) {
1690
+ return { reason: "signing secret is not valid base64" };
1691
+ }
1692
+ try {
1693
+ return { bytes: Uint8Array.from(atob(material), (c) => c.charCodeAt(0)) };
1694
+ } catch {
1695
+ return { reason: "signing secret is not valid base64" };
1696
+ }
1697
+ }
1698
+ return { bytes: new TextEncoder().encode(material) };
1699
+ }
1679
1700
  async function verifyHmac(options) {
1680
- const { raw, signature, secret, scheme, timestamp } = options;
1701
+ const { raw, secret, scheme, timestamp, id } = options;
1702
+ const signatures = (Array.isArray(options.signature) ? options.signature : [options.signature]).filter(
1703
+ (s) => typeof s === "string" && s !== ""
1704
+ );
1681
1705
  if (!secret) {
1682
1706
  return { ok: false, reason: "no signing secret configured for this trigger" };
1683
1707
  }
1684
- if (!signature) return { ok: false, reason: "delivery carried no signature header" };
1708
+ if (signatures.length === 0) return { ok: false, reason: "delivery carried no signature header" };
1685
1709
  if (scheme.tolerance !== void 0) {
1686
1710
  if (!timestamp) return { ok: false, reason: "delivery carried no timestamp header" };
1687
1711
  const sent = Number(timestamp);
@@ -1691,20 +1715,24 @@ async function verifyHmac(options) {
1691
1715
  return { ok: false, reason: `delivery is outside the ${scheme.tolerance}s replay window` };
1692
1716
  }
1693
1717
  }
1694
- const expected = await hmac(secret, scheme.payload(raw, timestamp), scheme.algorithm, scheme.encoding ?? "hex");
1695
- return constantTimeEquals(expected, signature) ? { ok: true } : { ok: false, reason: "signature did not match" };
1718
+ const key = secretKeyBytes(secret, scheme);
1719
+ if ("reason" in key) return { ok: false, reason: key.reason };
1720
+ const expected = await hmac(secret, scheme.payload(raw, timestamp, id), scheme.algorithm, scheme.encoding ?? "hex", scheme);
1721
+ return signatures.some((candidate) => constantTimeEquals(expected, candidate)) ? { ok: true } : { ok: false, reason: "signature did not match" };
1696
1722
  }
1697
- async function hmac(secret, payload, algorithm, encoding = "hex") {
1723
+ async function hmac(secret, payload, algorithm, encoding = "hex", options = {}) {
1698
1724
  const subtle = globalThis.crypto?.subtle;
1699
1725
  if (!subtle) {
1700
1726
  throw new Error(
1701
1727
  "WebCrypto is not available in this runtime, so webhook signatures cannot be verified. Refusing to accept deliveries unverified."
1702
1728
  );
1703
1729
  }
1730
+ const material = secretKeyBytes(secret, options);
1731
+ if ("reason" in material) throw new Error(material.reason);
1704
1732
  const encoder2 = new TextEncoder();
1705
1733
  const key = await subtle.importKey(
1706
1734
  "raw",
1707
- encoder2.encode(secret),
1735
+ material.bytes,
1708
1736
  { name: "HMAC", hash: algorithm },
1709
1737
  false,
1710
1738
  ["sign"]
@@ -1805,12 +1833,15 @@ async function verifyDelivery(trigger, delivery, secret, now) {
1805
1833
  const rawHeader = header2(delivery.headers, spec.signatureHeader);
1806
1834
  const parsed = spec.parse && rawHeader ? spec.parse(rawHeader) : { signature: rawHeader };
1807
1835
  const timestamp = parsed.timestamp ?? (spec.timestampHeader ? header2(delivery.headers, spec.timestampHeader) : void 0);
1836
+ const id = spec.idHeader ? header2(delivery.headers, spec.idHeader) : void 0;
1837
+ if (spec.idHeader && !id) return { ok: false, reason: "delivery carried no id header" };
1808
1838
  return verifyHmac({
1809
1839
  raw: delivery.raw,
1810
- signature: parsed.signature,
1840
+ signature: parsed.signatures ?? parsed.signature,
1811
1841
  secret,
1812
1842
  scheme: spec.scheme,
1813
1843
  timestamp,
1844
+ id,
1814
1845
  ...now === void 0 ? {} : { now }
1815
1846
  });
1816
1847
  }
@@ -1915,6 +1946,7 @@ function hasDelivery(value) {
1915
1946
  sandboxIsSelectable,
1916
1947
  sandboxRefusal,
1917
1948
  satisfiesMinimum,
1949
+ secretKeyBytes,
1918
1950
  seedFrom,
1919
1951
  shapeOf,
1920
1952
  shouldRetry,