@particle-academy/fancy-connector-core 0.7.0 → 0.8.1

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,82 @@ 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.8.1] - 2026-09-15
10
+
11
+ ### Changed
12
+
13
+ - **`verifyDelivery` dispatches on a NAMED scheme kind and refuses one it does
14
+ not know.** `HmacScheme` gains an optional `kind?: "hmac"`; absent still
15
+ means HMAC, because every scheme written before 0.8.0 is one, and
16
+ `"shared-token"` still goes to the token check — but any other value is now
17
+ refused by name (`declares a verification scheme "jwt" this runtime does not
18
+ know`) rather than verified as HMAC. From Fancy's alignment review of
19
+ 0.8.0: `"kind" in scheme` had made refusal-by-default depend on every future
20
+ scheme remembering to declare a kind. Connectors compile unchanged.
21
+
22
+ ## [0.8.0] - 2026-09-15
23
+
24
+ The declaration half of the subscription vocabulary (Tynn #15), so a
25
+ generated `subscription` trigger can say how its lease is built and how its
26
+ deliveries are verified, for the two providers that make it vocabulary:
27
+ Google Calendar `events.watch` and Microsoft Graph subscriptions. Additive
28
+ in both runtimes; `CONNECTOR_API_VERSION` stays at 1, because nothing a
29
+ connector must implement changed.
30
+
31
+ ### Added
32
+
33
+ - **`LeaseDeclaration`** on `TriggerDescriptor.lease` (`LeaseDeclaration.php`
34
+ with `ExpiresAtUnit`) — `expiresAtFrom` (a dotted path into the create or
35
+ renew response), `expiresAtUnit` (`rfc3339` | `epoch-ms`; `EXPIRES_AT_UNITS`
36
+ ships as data), `renewBeforeSeconds`, `renewOperation`.
37
+ - **`leaseFromResponse` / `SubscriptionLease::fromResponse`** — the one
38
+ conversion from a provider's response to a `SubscriptionLease`. Refuses,
39
+ naming the path, rather than guess: a missing or null path, digits under
40
+ `rfc3339`, an instant under `epoch-ms`, a fractional epoch, an unknown unit.
41
+ Stores the expiry in UTC at millisecond precision, fraction truncated. Both
42
+ runtimes read `fixtures/lease-from-response/cases.json` (14 rows).
43
+ - **`SharedTokenScheme` and `verifySharedToken`** (`WebhookVerifier::verifySharedToken`)
44
+ — verification by a token the provider echoes: in a header (Google's
45
+ `X-Goog-Channel-Token`) or at a dotted body path where `[]` fans out over a
46
+ batch and every item must match (Graph's `value[].clientState`). Fixed
47
+ reasons a host can log on: `no shared token configured for this trigger`,
48
+ `delivery carried no token`, `token did not match`, `delivery body is not
49
+ JSON`. Both runtimes read `fixtures/shared-token/cases.json` (16 rows).
50
+ - **`ChallengeHandshake` and `handshakeResponse`**
51
+ (`WebhookVerifier::handshakeResponse`) — Graph's `validationToken` echo on
52
+ subscription creation, declared as data on the verification spec and
53
+ answered by a pure function: `{status: 200, contentType: "text/plain",
54
+ body}` or nothing when the request is not a challenge.
55
+ - **`WebhookVerificationSpec` is a union** of `HmacVerificationSpec` (the
56
+ 0.7.0 shape, unchanged, now with an optional `handshake`) and
57
+ `SharedTokenVerificationSpec`; `verifyDelivery` dispatches on it and
58
+ `isSharedTokenSpec` / `isSharedTokenScheme` narrow it. **What to do:** a
59
+ connector that only CONSTRUCTS a spec compiles unchanged. A host that READS
60
+ `verification.signatureHeader` off the spec must narrow first
61
+ (`!isSharedTokenSpec(spec)`); `tsc` will say so.
62
+
63
+ ### Fixed
64
+
65
+ - **PHP refused a Graph-shaped instant.** `SubscriptionLease::of` parsed the
66
+ fraction with PHP's six-digit formats, so `2026-09-22T18:23:45.9356913Z`
67
+ was `not a real instant` in PHP and a lease in TypeScript. Both runtimes now
68
+ accept one to nine fractional digits and TRUNCATE to milliseconds before
69
+ comparing, so a `state` boundary cannot fall between a microsecond PHP saw
70
+ and a millisecond Node saw. (Fancy: the conformance suite gets a row for
71
+ this.)
72
+ - `MimeMessage.php` had failed `pint --test` since 0.7.0, which is why the
73
+ core's CI has been red on `main` since that release while the tag's publish
74
+ run, which does not run pint, went green.
75
+
76
+ ### Changed
77
+
78
+ - **The subscription-lease table is read from fancy-conformance 0.26.0**
79
+ (`shared/subscription-lease`), through the package's own `runTable` in both
80
+ runtimes, and `fixtures/subscription-lease/cases.json` is gone — it was the
81
+ seed of that suite and landed there unchanged, so keeping it would have been
82
+ a second table that agrees with the first until somebody edits one. Tests
83
+ and dev-dependencies only; nothing a consumer installs moved.
84
+
9
85
  ## [0.7.0] - 2026-09-15
10
86
 
11
87
  Two additions to the connector vocabulary, both approved by the owner on
package/dist/index.cjs CHANGED
@@ -38,6 +38,7 @@ __export(src_exports, {
38
38
  ConnectorUnreachable: () => ConnectorUnreachable,
39
39
  DEFAULT_IDEMPOTENCY_WINDOW_SECONDS: () => DEFAULT_IDEMPOTENCY_WINDOW_SECONDS,
40
40
  DEFAULT_RETRY: () => DEFAULT_RETRY,
41
+ EXPIRES_AT_UNITS: () => EXPIRES_AT_UNITS,
41
42
  FAKE_EPOCH: () => FAKE_EPOCH,
42
43
  MAX_IDEMPOTENCY_KEY_LENGTH: () => MAX_IDEMPOTENCY_KEY_LENGTH,
43
44
  NO_IDEMPOTENCY_KEY_WARNING: () => NO_IDEMPOTENCY_KEY_WARNING,
@@ -64,14 +65,18 @@ __export(src_exports, {
64
65
  fakeValues: () => fakeValues,
65
66
  getConnectionHost: () => getConnectionHost,
66
67
  graphemes: () => graphemes,
68
+ handshakeResponse: () => handshakeResponse,
67
69
  header: () => header2,
68
70
  hmac: () => hmac,
69
71
  httpFailure: () => httpFailure,
70
72
  idempotencyKeyFor: () => idempotencyKeyFor,
71
73
  isEmptyPayload: () => isEmptyPayload,
72
74
  isRemote: () => isRemote,
75
+ isSharedTokenScheme: () => isSharedTokenScheme,
76
+ isSharedTokenSpec: () => isSharedTokenSpec,
73
77
  isUnconditionallyRetryable: () => isUnconditionallyRetryable,
74
78
  leaseAction: () => leaseAction,
79
+ leaseFromResponse: () => leaseFromResponse,
75
80
  leaseRenewAt: () => leaseRenewAt,
76
81
  leaseState: () => leaseState,
77
82
  linkRanges: () => linkRanges,
@@ -107,6 +112,7 @@ __export(src_exports, {
107
112
  utf8Length: () => utf8Length,
108
113
  verifyDelivery: () => verifyDelivery,
109
114
  verifyHmac: () => verifyHmac,
115
+ verifySharedToken: () => verifySharedToken,
110
116
  withResolvedLimit: () => withResolvedLimit
111
117
  });
112
118
  module.exports = __toCommonJS(src_exports);
@@ -1027,6 +1033,7 @@ function fnv1a(value) {
1027
1033
  }
1028
1034
 
1029
1035
  // src/lease.ts
1036
+ var EXPIRES_AT_UNITS = ["rfc3339", "epoch-ms"];
1030
1037
  var INSTANT = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,9})?(?:Z|[+-]\d{2}:\d{2})$/;
1031
1038
  var DEFAULT_CONTEXT = { service: "", operation: "subscription" };
1032
1039
  function instantMs(value, field, at) {
@@ -1036,12 +1043,59 @@ function instantMs(value, field, at) {
1036
1043
  at
1037
1044
  );
1038
1045
  }
1039
- const ms = Date.parse(value);
1046
+ const ms = Date.parse(value.replace(/(\.\d{3})\d+/, "$1"));
1040
1047
  if (Number.isNaN(ms)) {
1041
1048
  throw new ConnectorConfigError(`${field} is not a real instant: ${JSON.stringify(value)}`, at);
1042
1049
  }
1043
1050
  return ms;
1044
1051
  }
1052
+ function leaseFromResponse(declaration, response, at = DEFAULT_CONTEXT) {
1053
+ const { expiresAtFrom: path, expiresAtUnit: unit } = declaration;
1054
+ if (!EXPIRES_AT_UNITS.includes(unit)) {
1055
+ throw new ConnectorConfigError(
1056
+ `expiresAtUnit must be one of ${EXPIRES_AT_UNITS.join(" | ")}, got ${JSON.stringify(unit)}.`,
1057
+ at
1058
+ );
1059
+ }
1060
+ const value = readPath(response, path);
1061
+ if (value === void 0 || value === null) {
1062
+ throw new ConnectorConfigError(
1063
+ `${path} is not present in the response, so no lease can be built \u2014 a subscription with no known expiry is one nobody renews.`,
1064
+ at
1065
+ );
1066
+ }
1067
+ let expiresAt;
1068
+ if (unit === "epoch-ms") {
1069
+ const ms = typeof value === "number" ? value : typeof value === "string" && /^\d+$/.test(value) ? Number(value) : Number.NaN;
1070
+ if (!Number.isSafeInteger(ms)) {
1071
+ throw new ConnectorConfigError(
1072
+ `${path} must be epoch milliseconds as digits under "epoch-ms", got ${JSON.stringify(value)}.`,
1073
+ at
1074
+ );
1075
+ }
1076
+ expiresAt = new Date(ms).toISOString();
1077
+ } else {
1078
+ if (typeof value !== "string" || !INSTANT.test(value)) {
1079
+ throw new ConnectorConfigError(
1080
+ `${path} must be an RFC 3339 instant under "rfc3339", got ${JSON.stringify(value)}.`,
1081
+ at
1082
+ );
1083
+ }
1084
+ expiresAt = new Date(instantMs(value, path, at)).toISOString();
1085
+ }
1086
+ return subscriptionLease(
1087
+ { expiresAt, renewBeforeSeconds: declaration.renewBeforeSeconds, renewOperation: declaration.renewOperation },
1088
+ at
1089
+ );
1090
+ }
1091
+ function readPath(value, path) {
1092
+ let current = value;
1093
+ for (const segment of path.split(".")) {
1094
+ if (current === null || typeof current !== "object") return void 0;
1095
+ current = current[segment];
1096
+ }
1097
+ return current;
1098
+ }
1045
1099
  function subscriptionLease(input, at = DEFAULT_CONTEXT) {
1046
1100
  instantMs(input.expiresAt, "expiresAt", at);
1047
1101
  if (!Number.isInteger(input.renewBeforeSeconds) || input.renewBeforeSeconds <= 0) {
@@ -1671,6 +1725,52 @@ function constantTimeEquals(a, b) {
1671
1725
  for (let i = 0; i < a.length; i++) diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
1672
1726
  return diff === 0;
1673
1727
  }
1728
+ function isSharedTokenScheme(scheme) {
1729
+ return "kind" in scheme && scheme.kind === "shared-token";
1730
+ }
1731
+ function verifySharedToken(options) {
1732
+ const { raw, headers, secret, scheme } = options;
1733
+ if (!secret) {
1734
+ return { ok: false, reason: "no shared token configured for this trigger" };
1735
+ }
1736
+ let tokens;
1737
+ if (scheme.in === "header") {
1738
+ tokens = [header2(headers, scheme.name)];
1739
+ } else {
1740
+ let parsed;
1741
+ try {
1742
+ parsed = JSON.parse(raw);
1743
+ } catch {
1744
+ return { ok: false, reason: "delivery body is not JSON" };
1745
+ }
1746
+ tokens = readTokens(parsed, scheme.path.split("."));
1747
+ }
1748
+ const present = tokens.filter((t) => t !== void 0 && t !== null && t !== "");
1749
+ if (present.length === 0) return { ok: false, reason: "delivery carried no token" };
1750
+ let matched = present.length === tokens.length;
1751
+ for (const token of present) {
1752
+ matched = typeof token === "string" && constantTimeEquals(secret, token) && matched;
1753
+ }
1754
+ return matched ? { ok: true } : { ok: false, reason: "token did not match" };
1755
+ }
1756
+ function readTokens(value, segments) {
1757
+ if (segments.length === 0) return [value];
1758
+ const [head, ...rest] = segments;
1759
+ const eachElement = head.endsWith("[]");
1760
+ const key = eachElement ? head.slice(0, -2) : head;
1761
+ if (value === null || typeof value !== "object") return [void 0];
1762
+ const next = value[key];
1763
+ if (!eachElement) return readTokens(next, rest);
1764
+ if (!Array.isArray(next)) return [void 0];
1765
+ return next.flatMap((element) => readTokens(element, rest));
1766
+ }
1767
+ function handshakeResponse(handshake, query) {
1768
+ if (!handshake) return void 0;
1769
+ const raw = query[handshake.param];
1770
+ const token = Array.isArray(raw) ? raw[0] : raw;
1771
+ if (!token) return void 0;
1772
+ return { status: 200, contentType: handshake.contentType ?? "text/plain", body: token };
1773
+ }
1674
1774
  function header2(headers, name) {
1675
1775
  const wanted = name.toLowerCase();
1676
1776
  for (const [key, value] of Object.entries(headers)) {
@@ -1681,6 +1781,9 @@ function header2(headers, name) {
1681
1781
  }
1682
1782
 
1683
1783
  // src/trigger.ts
1784
+ function isSharedTokenSpec(spec) {
1785
+ return isSharedTokenScheme(spec.scheme);
1786
+ }
1684
1787
  async function verifyDelivery(trigger, delivery, secret, now) {
1685
1788
  const spec = trigger.verification;
1686
1789
  if (!spec) {
@@ -1689,6 +1792,16 @@ async function verifyDelivery(trigger, delivery, secret, now) {
1689
1792
  reason: `${trigger.service}.${trigger.operation} declares no signature scheme, so a delivery cannot be trusted.`
1690
1793
  };
1691
1794
  }
1795
+ if (isSharedTokenSpec(spec)) {
1796
+ return verifySharedToken({ raw: delivery.raw, headers: delivery.headers, secret, scheme: spec.scheme });
1797
+ }
1798
+ const kind = spec.scheme.kind ?? "hmac";
1799
+ if (kind !== "hmac") {
1800
+ return {
1801
+ ok: false,
1802
+ reason: `${trigger.service}.${trigger.operation} declares a verification scheme "${kind}" this runtime does not know \u2014 refusing rather than guessing.`
1803
+ };
1804
+ }
1692
1805
  const rawHeader = header2(delivery.headers, spec.signatureHeader);
1693
1806
  const parsed = spec.parse && rawHeader ? spec.parse(rawHeader) : { signature: rawHeader };
1694
1807
  const timestamp = parsed.timestamp ?? (spec.timestampHeader ? header2(delivery.headers, spec.timestampHeader) : void 0);
@@ -1739,6 +1852,7 @@ function hasDelivery(value) {
1739
1852
  ConnectorUnreachable,
1740
1853
  DEFAULT_IDEMPOTENCY_WINDOW_SECONDS,
1741
1854
  DEFAULT_RETRY,
1855
+ EXPIRES_AT_UNITS,
1742
1856
  FAKE_EPOCH,
1743
1857
  MAX_IDEMPOTENCY_KEY_LENGTH,
1744
1858
  NO_IDEMPOTENCY_KEY_WARNING,
@@ -1765,14 +1879,18 @@ function hasDelivery(value) {
1765
1879
  fakeValues,
1766
1880
  getConnectionHost,
1767
1881
  graphemes,
1882
+ handshakeResponse,
1768
1883
  header,
1769
1884
  hmac,
1770
1885
  httpFailure,
1771
1886
  idempotencyKeyFor,
1772
1887
  isEmptyPayload,
1773
1888
  isRemote,
1889
+ isSharedTokenScheme,
1890
+ isSharedTokenSpec,
1774
1891
  isUnconditionallyRetryable,
1775
1892
  leaseAction,
1893
+ leaseFromResponse,
1776
1894
  leaseRenewAt,
1777
1895
  leaseState,
1778
1896
  linkRanges,
@@ -1808,6 +1926,7 @@ function hasDelivery(value) {
1808
1926
  utf8Length,
1809
1927
  verifyDelivery,
1810
1928
  verifyHmac,
1929
+ verifySharedToken,
1811
1930
  withResolvedLimit
1812
1931
  });
1813
1932
  //# sourceMappingURL=index.cjs.map