@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 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
- if (Math.abs(now - sent) > scheme.tolerance) {
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
- const expected = await hmac(secret, scheme.payload(raw, timestamp, id), scheme.algorithm, scheme.encoding ?? "hex", scheme);
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,