@particle-academy/fancy-connector-core 0.11.0 → 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,38 @@ 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
+
9
41
  ## [0.11.0] - 2026-10-28
10
42
 
11
43
  ### Added
package/dist/index.cjs CHANGED
@@ -49,6 +49,7 @@ __export(src_exports, {
49
49
  RENDERER_VERSION: () => RENDERER_VERSION,
50
50
  REVIEW_STALE_DAYS: () => REVIEW_STALE_DAYS,
51
51
  SANDBOX_KINDS: () => SANDBOX_KINDS,
52
+ SIGNED_URI_RULES: () => SIGNED_URI_RULES,
52
53
  SUPPORTED_CONNECTOR_API: () => SUPPORTED_CONNECTOR_API,
53
54
  assertConnectorApi: () => assertConnectorApi,
54
55
  byteRangeOf: () => byteRangeOf,
@@ -88,6 +89,7 @@ __export(src_exports, {
88
89
  linkRanges: () => linkRanges,
89
90
  measure: () => measure,
90
91
  nextOccurrence: () => nextOccurrence,
92
+ normaliseSignedUri: () => normaliseSignedUri,
91
93
  parseInvite: () => parseInvite,
92
94
  parseMime: () => parseMime,
93
95
  payloadHash: () => payloadHash,
@@ -2081,6 +2083,29 @@ var PROBLEM_SEVERITIES = ["block", "warn"];
2081
2083
  var CANONICAL_METRICS = ["like", "share", "reply", "quote", "view"];
2082
2084
 
2083
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
+ }
2084
2109
  function secretKeyBytes(secret, options = {}) {
2085
2110
  let material = secret;
2086
2111
  if (options.secretPrefix !== void 0) {
@@ -2115,13 +2140,26 @@ async function verifyHmac(options) {
2115
2140
  const sent = Number(timestamp);
2116
2141
  if (!Number.isFinite(sent)) return { ok: false, reason: "timestamp header is not a number" };
2117
2142
  const now = options.now ?? Math.floor(Date.now() / 1e3);
2118
- if (Math.abs(now - sent) > scheme.tolerance) {
2143
+ const sentSeconds = scheme.timestampUnit === "ms" ? sent / 1e3 : sent;
2144
+ if (Math.abs(now - sentSeconds) > scheme.tolerance) {
2119
2145
  return { ok: false, reason: `delivery is outside the ${scheme.tolerance}s replay window` };
2120
2146
  }
2121
2147
  }
2122
2148
  const key = secretKeyBytes(secret, scheme);
2123
2149
  if ("reason" in key) return { ok: false, reason: key.reason };
2124
- 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
+ );
2125
2163
  return signatures.some((candidate) => constantTimeEquals(expected, candidate)) ? { ok: true } : { ok: false, reason: "signature did not match" };
2126
2164
  }
2127
2165
  async function hmac(secret, payload, algorithm, encoding = "hex", options = {}) {
@@ -2246,6 +2284,7 @@ async function verifyDelivery(trigger, delivery, secret, now) {
2246
2284
  scheme: spec.scheme,
2247
2285
  timestamp,
2248
2286
  id,
2287
+ ...delivery.method === void 0 && delivery.url === void 0 ? {} : { request: { method: delivery.method ?? "", url: delivery.url ?? "" } },
2249
2288
  ...now === void 0 ? {} : { now }
2250
2289
  });
2251
2290
  }
@@ -2298,6 +2337,7 @@ function hasDelivery(value) {
2298
2337
  RENDERER_VERSION,
2299
2338
  REVIEW_STALE_DAYS,
2300
2339
  SANDBOX_KINDS,
2340
+ SIGNED_URI_RULES,
2301
2341
  SUPPORTED_CONNECTOR_API,
2302
2342
  assertConnectorApi,
2303
2343
  byteRangeOf,
@@ -2337,6 +2377,7 @@ function hasDelivery(value) {
2337
2377
  linkRanges,
2338
2378
  measure,
2339
2379
  nextOccurrence,
2380
+ normaliseSignedUri,
2340
2381
  parseInvite,
2341
2382
  parseMime,
2342
2383
  payloadHash,