@particle-academy/fancy-connector-core 0.10.1 → 0.12.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 +61 -0
- package/dist/index.cjs +124 -2
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +109 -2
- package/dist/index.d.ts +109 -2
- package/dist/index.js +117 -2
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,67 @@ 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.12.0] - 2026-11-01
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
|
|
13
|
+
- **A signed REQUEST LINE and millisecond timestamps (HubSpot v3).** HubSpot
|
|
14
|
+
signs `base64(HMAC-SHA256(client secret, method + uri + body + timestamp))`,
|
|
15
|
+
sends `X-HubSpot-Request-Timestamp` in MILLISECONDS, and signs the URI with
|
|
16
|
+
only its QUERY portion percent-decoded for twelve escapes (never the leading
|
|
17
|
+
`?`, any `#fragment` dropped). The pieces:
|
|
18
|
+
- `InboundDelivery` gains optional `method` and `url`; pass the request
|
|
19
|
+
line exactly as it arrived.
|
|
20
|
+
- `HmacScheme` gains `timestampUnit` (`"s"` | `"ms"`), `signsRequestLine`
|
|
21
|
+
(a delivery without its method or URL is refused by name: `delivery
|
|
22
|
+
carried no request method or url`), and `uri: "hubspot-v3"`.
|
|
23
|
+
- The CORE normalises the URI (`normaliseSignedUri` /
|
|
24
|
+
`WebhookVerifier::normaliseSignedUri`; the rules ship as data, in
|
|
25
|
+
`SIGNED_URI_RULES`), and a scheme's `payload` receives the request line
|
|
26
|
+
as a fourth argument.
|
|
27
|
+
- PHP's new `verify()` parameters are appended, so existing calls are
|
|
28
|
+
unchanged.
|
|
29
|
+
|
|
30
|
+
The signed-delivery corpus gains four HubSpot rows, led by HubSpot's
|
|
31
|
+
PUBLISHED worked example, which reproduces from its inputs.
|
|
32
|
+
- **`TriggerDescriptor.route` `{by, path}`.** It names the one key a delivery
|
|
33
|
+
that reaches ONE endpoint for many tenants may be routed by (HubSpot's
|
|
34
|
+
`portalId`; Gmail's `emailAddress`). The rule is fixed: route only after
|
|
35
|
+
verification, only to a connection or watch the host minted, and drop an
|
|
36
|
+
unmapped key with a 2xx and a log line, creating nothing. The package
|
|
37
|
+
declares; the host routes.
|
|
38
|
+
|
|
39
|
+
`CONNECTOR_API_VERSION` is unchanged: everything here is optional.
|
|
40
|
+
|
|
41
|
+
## [0.11.0] - 2026-10-28
|
|
42
|
+
|
|
43
|
+
### Added
|
|
44
|
+
|
|
45
|
+
- **`decodeFormFieldJson` / `FormFieldJson::decode`: the form-field-json
|
|
46
|
+
payload transform.** It reads a form-encoded body whose one named field
|
|
47
|
+
holds JSON, which is how Slack posts every interactivity callback
|
|
48
|
+
(`payload=<json>`). It runs AFTER signature verification, over the same raw
|
|
49
|
+
bytes. `+` and `%20` are both spaces. Every `%` must be `%XX` across the
|
|
50
|
+
WHOLE body, and the bytes must be UTF-8. A missing, doubled or malformed
|
|
51
|
+
field, or one that is not JSON, is refused BY NAME (`missing-field`,
|
|
52
|
+
`duplicate-field`, `bad-encoding`, `not-json`; `FormFieldJsonError.reason` /
|
|
53
|
+
`FormFieldJsonException->reason`). Neither runtime uses its built-in
|
|
54
|
+
decoder, because `decodeURIComponent` throws on `%zz` where `urldecode`
|
|
55
|
+
silently keeps it, and a field sent twice is refused rather than resolved
|
|
56
|
+
first-wins or last-wins. Both runtimes reproduce the hand-authored corpus
|
|
57
|
+
under `fixtures/form-field-json/`. In PHP, JSON objects come back as
|
|
58
|
+
arrays, so an empty `{}` is `[]`, as in `Mime::toArray()`.
|
|
59
|
+
- **`TriggerDescriptor.decode` + `decodeDelivery` / `DeliveryDecode::apply`.**
|
|
60
|
+
A webhook trigger may declare how its verified body is read:
|
|
61
|
+
`{kind: "form-field-json", field: "payload"}`. A host verifies the raw
|
|
62
|
+
bytes, then calls the dispatcher with the declaration, then starts the run.
|
|
63
|
+
An unknown kind is refused by name, never passed through as raw bytes. The
|
|
64
|
+
kinds ship as data (`DELIVERY_DECODE_KINDS` / `DeliveryDecode::KINDS`).
|
|
65
|
+
Absent `decode` means a host reads the body as it always has.
|
|
66
|
+
|
|
67
|
+
`CONNECTOR_API_VERSION` is unchanged: an optional descriptor field changes
|
|
68
|
+
nothing a connector must implement.
|
|
69
|
+
|
|
9
70
|
## [0.10.1] - 2026-10-10
|
|
10
71
|
|
|
11
72
|
### Fixed
|
package/dist/index.cjs
CHANGED
|
@@ -38,14 +38,18 @@ __export(src_exports, {
|
|
|
38
38
|
ConnectorUnreachable: () => ConnectorUnreachable,
|
|
39
39
|
DEFAULT_IDEMPOTENCY_WINDOW_SECONDS: () => DEFAULT_IDEMPOTENCY_WINDOW_SECONDS,
|
|
40
40
|
DEFAULT_RETRY: () => DEFAULT_RETRY,
|
|
41
|
+
DELIVERY_DECODE_KINDS: () => DELIVERY_DECODE_KINDS,
|
|
41
42
|
EXPIRES_AT_UNITS: () => EXPIRES_AT_UNITS,
|
|
42
43
|
FAKE_EPOCH: () => FAKE_EPOCH,
|
|
44
|
+
FORM_FIELD_JSON_REASONS: () => FORM_FIELD_JSON_REASONS,
|
|
45
|
+
FormFieldJsonError: () => FormFieldJsonError,
|
|
43
46
|
MAX_IDEMPOTENCY_KEY_LENGTH: () => MAX_IDEMPOTENCY_KEY_LENGTH,
|
|
44
47
|
NO_IDEMPOTENCY_KEY_WARNING: () => NO_IDEMPOTENCY_KEY_WARNING,
|
|
45
48
|
PROBLEM_SEVERITIES: () => PROBLEM_SEVERITIES,
|
|
46
49
|
RENDERER_VERSION: () => RENDERER_VERSION,
|
|
47
50
|
REVIEW_STALE_DAYS: () => REVIEW_STALE_DAYS,
|
|
48
51
|
SANDBOX_KINDS: () => SANDBOX_KINDS,
|
|
52
|
+
SIGNED_URI_RULES: () => SIGNED_URI_RULES,
|
|
49
53
|
SUPPORTED_CONNECTOR_API: () => SUPPORTED_CONNECTOR_API,
|
|
50
54
|
assertConnectorApi: () => assertConnectorApi,
|
|
51
55
|
byteRangeOf: () => byteRangeOf,
|
|
@@ -60,6 +64,8 @@ __export(src_exports, {
|
|
|
60
64
|
compareShape: () => compareShape,
|
|
61
65
|
connectionsFor: () => connectionsFor,
|
|
62
66
|
constantTimeEquals: () => constantTimeEquals,
|
|
67
|
+
decodeDelivery: () => decodeDelivery,
|
|
68
|
+
decodeFormFieldJson: () => decodeFormFieldJson,
|
|
63
69
|
deliver: () => deliver,
|
|
64
70
|
fakeRequest: () => fakeRequest,
|
|
65
71
|
fakeValues: () => fakeValues,
|
|
@@ -83,6 +89,7 @@ __export(src_exports, {
|
|
|
83
89
|
linkRanges: () => linkRanges,
|
|
84
90
|
measure: () => measure,
|
|
85
91
|
nextOccurrence: () => nextOccurrence,
|
|
92
|
+
normaliseSignedUri: () => normaliseSignedUri,
|
|
86
93
|
parseInvite: () => parseInvite,
|
|
87
94
|
parseMime: () => parseMime,
|
|
88
95
|
payloadHash: () => payloadHash,
|
|
@@ -1789,6 +1796,77 @@ function parseMime(raw) {
|
|
|
1789
1796
|
};
|
|
1790
1797
|
}
|
|
1791
1798
|
|
|
1799
|
+
// src/form-field-json.ts
|
|
1800
|
+
var FORM_FIELD_JSON_REASONS = ["missing-field", "duplicate-field", "bad-encoding", "not-json"];
|
|
1801
|
+
var FormFieldJsonError = class extends Error {
|
|
1802
|
+
constructor(reason, field, message) {
|
|
1803
|
+
super(message);
|
|
1804
|
+
this.reason = reason;
|
|
1805
|
+
this.field = field;
|
|
1806
|
+
this.name = "FormFieldJsonError";
|
|
1807
|
+
}
|
|
1808
|
+
reason;
|
|
1809
|
+
field;
|
|
1810
|
+
};
|
|
1811
|
+
var UTF8 = new TextDecoder("utf-8", { fatal: true });
|
|
1812
|
+
var HEX = /^[0-9A-Fa-f]{2}$/;
|
|
1813
|
+
function decodeFormFieldJson(raw, field) {
|
|
1814
|
+
const text = typeof raw === "string" ? raw : latin12(raw);
|
|
1815
|
+
const found = [];
|
|
1816
|
+
for (const pair of text.split("&")) {
|
|
1817
|
+
if (pair === "") continue;
|
|
1818
|
+
const eq = pair.indexOf("=");
|
|
1819
|
+
const name = component(eq === -1 ? pair : pair.slice(0, eq), field);
|
|
1820
|
+
const value2 = component(eq === -1 ? "" : pair.slice(eq + 1), field);
|
|
1821
|
+
if (name === field) found.push(value2);
|
|
1822
|
+
}
|
|
1823
|
+
if (found.length === 0) {
|
|
1824
|
+
throw new FormFieldJsonError("missing-field", field, `the body has no "${field}" field`);
|
|
1825
|
+
}
|
|
1826
|
+
if (found.length > 1) {
|
|
1827
|
+
throw new FormFieldJsonError("duplicate-field", field, `the body has "${field}" ${found.length} times`);
|
|
1828
|
+
}
|
|
1829
|
+
const [value] = found;
|
|
1830
|
+
try {
|
|
1831
|
+
return JSON.parse(value);
|
|
1832
|
+
} catch (error) {
|
|
1833
|
+
throw new FormFieldJsonError("not-json", field, `"${field}" is not JSON: ${error.message}`);
|
|
1834
|
+
}
|
|
1835
|
+
}
|
|
1836
|
+
function component(encoded, field) {
|
|
1837
|
+
const bytes = [];
|
|
1838
|
+
for (let i = 0; i < encoded.length; i++) {
|
|
1839
|
+
const char = encoded[i];
|
|
1840
|
+
if (char === "+") {
|
|
1841
|
+
bytes.push(32);
|
|
1842
|
+
} else if (char === "%") {
|
|
1843
|
+
const hex = encoded.slice(i + 1, i + 3);
|
|
1844
|
+
if (!HEX.test(hex)) {
|
|
1845
|
+
throw new FormFieldJsonError("bad-encoding", field, `a "%" at byte ${i} is not followed by two hex digits`);
|
|
1846
|
+
}
|
|
1847
|
+
bytes.push(parseInt(hex, 16));
|
|
1848
|
+
i += 2;
|
|
1849
|
+
} else {
|
|
1850
|
+
bytes.push(encoded.charCodeAt(i) & 255);
|
|
1851
|
+
}
|
|
1852
|
+
}
|
|
1853
|
+
try {
|
|
1854
|
+
return UTF8.decode(new Uint8Array(bytes));
|
|
1855
|
+
} catch {
|
|
1856
|
+
throw new FormFieldJsonError("bad-encoding", field, "the decoded bytes are not valid UTF-8");
|
|
1857
|
+
}
|
|
1858
|
+
}
|
|
1859
|
+
var DELIVERY_DECODE_KINDS = ["form-field-json"];
|
|
1860
|
+
function decodeDelivery(decode, raw) {
|
|
1861
|
+
if (decode.kind === "form-field-json") return decodeFormFieldJson(raw, decode.field);
|
|
1862
|
+
throw new Error(`unknown decode kind ${JSON.stringify(decode.kind)}`);
|
|
1863
|
+
}
|
|
1864
|
+
function latin12(bytes) {
|
|
1865
|
+
let out = "";
|
|
1866
|
+
for (const byte of bytes) out += String.fromCharCode(byte);
|
|
1867
|
+
return out;
|
|
1868
|
+
}
|
|
1869
|
+
|
|
1792
1870
|
// src/probe.ts
|
|
1793
1871
|
async function probe(spec) {
|
|
1794
1872
|
let response;
|
|
@@ -2005,6 +2083,29 @@ var PROBLEM_SEVERITIES = ["block", "warn"];
|
|
|
2005
2083
|
var CANONICAL_METRICS = ["like", "share", "reply", "quote", "view"];
|
|
2006
2084
|
|
|
2007
2085
|
// src/webhook.ts
|
|
2086
|
+
var SIGNED_URI_RULES = ["hubspot-v3"];
|
|
2087
|
+
var HUBSPOT_V3_DECODE = {
|
|
2088
|
+
"%3A": ":",
|
|
2089
|
+
"%2F": "/",
|
|
2090
|
+
"%3F": "?",
|
|
2091
|
+
"%40": "@",
|
|
2092
|
+
"%21": "!",
|
|
2093
|
+
"%24": "$",
|
|
2094
|
+
"%27": "'",
|
|
2095
|
+
"%28": "(",
|
|
2096
|
+
"%29": ")",
|
|
2097
|
+
"%2A": "*",
|
|
2098
|
+
"%2C": ",",
|
|
2099
|
+
"%3B": ";"
|
|
2100
|
+
};
|
|
2101
|
+
function normaliseSignedUri(url, rule) {
|
|
2102
|
+
if (rule !== "hubspot-v3") throw new Error(`unknown signed-uri rule ${JSON.stringify(rule)}`);
|
|
2103
|
+
const withoutFragment = url.split("#", 1)[0] ?? "";
|
|
2104
|
+
const q = withoutFragment.indexOf("?");
|
|
2105
|
+
if (q === -1) return withoutFragment;
|
|
2106
|
+
const query = withoutFragment.slice(q + 1).replace(/%[0-9A-Fa-f]{2}/g, (e) => HUBSPOT_V3_DECODE[e.toUpperCase()] ?? e);
|
|
2107
|
+
return `${withoutFragment.slice(0, q)}?${query}`;
|
|
2108
|
+
}
|
|
2008
2109
|
function secretKeyBytes(secret, options = {}) {
|
|
2009
2110
|
let material = secret;
|
|
2010
2111
|
if (options.secretPrefix !== void 0) {
|
|
@@ -2039,13 +2140,26 @@ async function verifyHmac(options) {
|
|
|
2039
2140
|
const sent = Number(timestamp);
|
|
2040
2141
|
if (!Number.isFinite(sent)) return { ok: false, reason: "timestamp header is not a number" };
|
|
2041
2142
|
const now = options.now ?? Math.floor(Date.now() / 1e3);
|
|
2042
|
-
|
|
2143
|
+
const sentSeconds = scheme.timestampUnit === "ms" ? sent / 1e3 : sent;
|
|
2144
|
+
if (Math.abs(now - sentSeconds) > scheme.tolerance) {
|
|
2043
2145
|
return { ok: false, reason: `delivery is outside the ${scheme.tolerance}s replay window` };
|
|
2044
2146
|
}
|
|
2045
2147
|
}
|
|
2046
2148
|
const key = secretKeyBytes(secret, scheme);
|
|
2047
2149
|
if ("reason" in key) return { ok: false, reason: key.reason };
|
|
2048
|
-
|
|
2150
|
+
let line;
|
|
2151
|
+
if (scheme.signsRequestLine) {
|
|
2152
|
+
const { method, url } = options.request ?? {};
|
|
2153
|
+
if (!method || !url) return { ok: false, reason: "delivery carried no request method or url" };
|
|
2154
|
+
line = { method, uri: scheme.uri ? normaliseSignedUri(url, scheme.uri) : url };
|
|
2155
|
+
}
|
|
2156
|
+
const expected = await hmac(
|
|
2157
|
+
secret,
|
|
2158
|
+
scheme.payload(raw, timestamp, id, line),
|
|
2159
|
+
scheme.algorithm,
|
|
2160
|
+
scheme.encoding ?? "hex",
|
|
2161
|
+
scheme
|
|
2162
|
+
);
|
|
2049
2163
|
return signatures.some((candidate) => constantTimeEquals(expected, candidate)) ? { ok: true } : { ok: false, reason: "signature did not match" };
|
|
2050
2164
|
}
|
|
2051
2165
|
async function hmac(secret, payload, algorithm, encoding = "hex", options = {}) {
|
|
@@ -2170,6 +2284,7 @@ async function verifyDelivery(trigger, delivery, secret, now) {
|
|
|
2170
2284
|
scheme: spec.scheme,
|
|
2171
2285
|
timestamp,
|
|
2172
2286
|
id,
|
|
2287
|
+
...delivery.method === void 0 && delivery.url === void 0 ? {} : { request: { method: delivery.method ?? "", url: delivery.url ?? "" } },
|
|
2173
2288
|
...now === void 0 ? {} : { now }
|
|
2174
2289
|
});
|
|
2175
2290
|
}
|
|
@@ -2211,14 +2326,18 @@ function hasDelivery(value) {
|
|
|
2211
2326
|
ConnectorUnreachable,
|
|
2212
2327
|
DEFAULT_IDEMPOTENCY_WINDOW_SECONDS,
|
|
2213
2328
|
DEFAULT_RETRY,
|
|
2329
|
+
DELIVERY_DECODE_KINDS,
|
|
2214
2330
|
EXPIRES_AT_UNITS,
|
|
2215
2331
|
FAKE_EPOCH,
|
|
2332
|
+
FORM_FIELD_JSON_REASONS,
|
|
2333
|
+
FormFieldJsonError,
|
|
2216
2334
|
MAX_IDEMPOTENCY_KEY_LENGTH,
|
|
2217
2335
|
NO_IDEMPOTENCY_KEY_WARNING,
|
|
2218
2336
|
PROBLEM_SEVERITIES,
|
|
2219
2337
|
RENDERER_VERSION,
|
|
2220
2338
|
REVIEW_STALE_DAYS,
|
|
2221
2339
|
SANDBOX_KINDS,
|
|
2340
|
+
SIGNED_URI_RULES,
|
|
2222
2341
|
SUPPORTED_CONNECTOR_API,
|
|
2223
2342
|
assertConnectorApi,
|
|
2224
2343
|
byteRangeOf,
|
|
@@ -2233,6 +2352,8 @@ function hasDelivery(value) {
|
|
|
2233
2352
|
compareShape,
|
|
2234
2353
|
connectionsFor,
|
|
2235
2354
|
constantTimeEquals,
|
|
2355
|
+
decodeDelivery,
|
|
2356
|
+
decodeFormFieldJson,
|
|
2236
2357
|
deliver,
|
|
2237
2358
|
fakeRequest,
|
|
2238
2359
|
fakeValues,
|
|
@@ -2256,6 +2377,7 @@ function hasDelivery(value) {
|
|
|
2256
2377
|
linkRanges,
|
|
2257
2378
|
measure,
|
|
2258
2379
|
nextOccurrence,
|
|
2380
|
+
normaliseSignedUri,
|
|
2259
2381
|
parseInvite,
|
|
2260
2382
|
parseMime,
|
|
2261
2383
|
payloadHash,
|