@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 +50 -0
- package/dist/index.cjs +39 -7
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +54 -6
- package/dist/index.d.ts +54 -6
- package/dist/index.js +38 -7
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
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,
|
|
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 (
|
|
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
|
|
1695
|
-
|
|
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
|
-
|
|
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,
|