@particle-academy/fancy-connector-core 0.8.0 → 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,69 @@ 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
+
59
+ ## [0.8.1] - 2026-09-15
60
+
61
+ ### Changed
62
+
63
+ - **`verifyDelivery` dispatches on a NAMED scheme kind and refuses one it does
64
+ not know.** `HmacScheme` gains an optional `kind?: "hmac"`; absent still
65
+ means HMAC, because every scheme written before 0.8.0 is one, and
66
+ `"shared-token"` still goes to the token check — but any other value is now
67
+ refused by name (`declares a verification scheme "jwt" this runtime does not
68
+ know`) rather than verified as HMAC. From Fancy's alignment review of
69
+ 0.8.0: `"kind" in scheme` had made refusal-by-default depend on every future
70
+ scheme remembering to declare a kind. Connectors compile unchanged.
71
+
9
72
  ## [0.8.0] - 2026-09-15
10
73
 
11
74
  The declaration half of the subscription vocabulary (Tynn #15), so a
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"]
@@ -1795,15 +1823,25 @@ async function verifyDelivery(trigger, delivery, secret, now) {
1795
1823
  if (isSharedTokenSpec(spec)) {
1796
1824
  return verifySharedToken({ raw: delivery.raw, headers: delivery.headers, secret, scheme: spec.scheme });
1797
1825
  }
1826
+ const kind = spec.scheme.kind ?? "hmac";
1827
+ if (kind !== "hmac") {
1828
+ return {
1829
+ ok: false,
1830
+ reason: `${trigger.service}.${trigger.operation} declares a verification scheme "${kind}" this runtime does not know \u2014 refusing rather than guessing.`
1831
+ };
1832
+ }
1798
1833
  const rawHeader = header2(delivery.headers, spec.signatureHeader);
1799
1834
  const parsed = spec.parse && rawHeader ? spec.parse(rawHeader) : { signature: rawHeader };
1800
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" };
1801
1838
  return verifyHmac({
1802
1839
  raw: delivery.raw,
1803
- signature: parsed.signature,
1840
+ signature: parsed.signatures ?? parsed.signature,
1804
1841
  secret,
1805
1842
  scheme: spec.scheme,
1806
1843
  timestamp,
1844
+ id,
1807
1845
  ...now === void 0 ? {} : { now }
1808
1846
  });
1809
1847
  }
@@ -1908,6 +1946,7 @@ function hasDelivery(value) {
1908
1946
  sandboxIsSelectable,
1909
1947
  sandboxRefusal,
1910
1948
  satisfiesMinimum,
1949
+ secretKeyBytes,
1911
1950
  seedFrom,
1912
1951
  shapeOf,
1913
1952
  shouldRetry,