@particle-academy/fancy-connector-core 0.7.0 → 0.8.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,69 @@ 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.0] - 2026-09-15
10
+
11
+ The declaration half of the subscription vocabulary (Tynn #15), so a
12
+ generated `subscription` trigger can say how its lease is built and how its
13
+ deliveries are verified, for the two providers that make it vocabulary:
14
+ Google Calendar `events.watch` and Microsoft Graph subscriptions. Additive
15
+ in both runtimes; `CONNECTOR_API_VERSION` stays at 1, because nothing a
16
+ connector must implement changed.
17
+
18
+ ### Added
19
+
20
+ - **`LeaseDeclaration`** on `TriggerDescriptor.lease` (`LeaseDeclaration.php`
21
+ with `ExpiresAtUnit`) — `expiresAtFrom` (a dotted path into the create or
22
+ renew response), `expiresAtUnit` (`rfc3339` | `epoch-ms`; `EXPIRES_AT_UNITS`
23
+ ships as data), `renewBeforeSeconds`, `renewOperation`.
24
+ - **`leaseFromResponse` / `SubscriptionLease::fromResponse`** — the one
25
+ conversion from a provider's response to a `SubscriptionLease`. Refuses,
26
+ naming the path, rather than guess: a missing or null path, digits under
27
+ `rfc3339`, an instant under `epoch-ms`, a fractional epoch, an unknown unit.
28
+ Stores the expiry in UTC at millisecond precision, fraction truncated. Both
29
+ runtimes read `fixtures/lease-from-response/cases.json` (14 rows).
30
+ - **`SharedTokenScheme` and `verifySharedToken`** (`WebhookVerifier::verifySharedToken`)
31
+ — verification by a token the provider echoes: in a header (Google's
32
+ `X-Goog-Channel-Token`) or at a dotted body path where `[]` fans out over a
33
+ batch and every item must match (Graph's `value[].clientState`). Fixed
34
+ reasons a host can log on: `no shared token configured for this trigger`,
35
+ `delivery carried no token`, `token did not match`, `delivery body is not
36
+ JSON`. Both runtimes read `fixtures/shared-token/cases.json` (16 rows).
37
+ - **`ChallengeHandshake` and `handshakeResponse`**
38
+ (`WebhookVerifier::handshakeResponse`) — Graph's `validationToken` echo on
39
+ subscription creation, declared as data on the verification spec and
40
+ answered by a pure function: `{status: 200, contentType: "text/plain",
41
+ body}` or nothing when the request is not a challenge.
42
+ - **`WebhookVerificationSpec` is a union** of `HmacVerificationSpec` (the
43
+ 0.7.0 shape, unchanged, now with an optional `handshake`) and
44
+ `SharedTokenVerificationSpec`; `verifyDelivery` dispatches on it and
45
+ `isSharedTokenSpec` / `isSharedTokenScheme` narrow it. **What to do:** a
46
+ connector that only CONSTRUCTS a spec compiles unchanged. A host that READS
47
+ `verification.signatureHeader` off the spec must narrow first
48
+ (`!isSharedTokenSpec(spec)`); `tsc` will say so.
49
+
50
+ ### Fixed
51
+
52
+ - **PHP refused a Graph-shaped instant.** `SubscriptionLease::of` parsed the
53
+ fraction with PHP's six-digit formats, so `2026-09-22T18:23:45.9356913Z`
54
+ was `not a real instant` in PHP and a lease in TypeScript. Both runtimes now
55
+ accept one to nine fractional digits and TRUNCATE to milliseconds before
56
+ comparing, so a `state` boundary cannot fall between a microsecond PHP saw
57
+ and a millisecond Node saw. (Fancy: the conformance suite gets a row for
58
+ this.)
59
+ - `MimeMessage.php` had failed `pint --test` since 0.7.0, which is why the
60
+ core's CI has been red on `main` since that release while the tag's publish
61
+ run, which does not run pint, went green.
62
+
63
+ ### Changed
64
+
65
+ - **The subscription-lease table is read from fancy-conformance 0.26.0**
66
+ (`shared/subscription-lease`), through the package's own `runTable` in both
67
+ runtimes, and `fixtures/subscription-lease/cases.json` is gone — it was the
68
+ seed of that suite and landed there unchanged, so keeping it would have been
69
+ a second table that agrees with the first until somebody edits one. Tests
70
+ and dev-dependencies only; nothing a consumer installs moved.
71
+
9
72
  ## [0.7.0] - 2026-09-15
10
73
 
11
74
  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,9 @@ 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
+ }
1692
1798
  const rawHeader = header2(delivery.headers, spec.signatureHeader);
1693
1799
  const parsed = spec.parse && rawHeader ? spec.parse(rawHeader) : { signature: rawHeader };
1694
1800
  const timestamp = parsed.timestamp ?? (spec.timestampHeader ? header2(delivery.headers, spec.timestampHeader) : void 0);
@@ -1739,6 +1845,7 @@ function hasDelivery(value) {
1739
1845
  ConnectorUnreachable,
1740
1846
  DEFAULT_IDEMPOTENCY_WINDOW_SECONDS,
1741
1847
  DEFAULT_RETRY,
1848
+ EXPIRES_AT_UNITS,
1742
1849
  FAKE_EPOCH,
1743
1850
  MAX_IDEMPOTENCY_KEY_LENGTH,
1744
1851
  NO_IDEMPOTENCY_KEY_WARNING,
@@ -1765,14 +1872,18 @@ function hasDelivery(value) {
1765
1872
  fakeValues,
1766
1873
  getConnectionHost,
1767
1874
  graphemes,
1875
+ handshakeResponse,
1768
1876
  header,
1769
1877
  hmac,
1770
1878
  httpFailure,
1771
1879
  idempotencyKeyFor,
1772
1880
  isEmptyPayload,
1773
1881
  isRemote,
1882
+ isSharedTokenScheme,
1883
+ isSharedTokenSpec,
1774
1884
  isUnconditionallyRetryable,
1775
1885
  leaseAction,
1886
+ leaseFromResponse,
1776
1887
  leaseRenewAt,
1777
1888
  leaseState,
1778
1889
  linkRanges,
@@ -1808,6 +1919,7 @@ function hasDelivery(value) {
1808
1919
  utf8Length,
1809
1920
  verifyDelivery,
1810
1921
  verifyHmac,
1922
+ verifySharedToken,
1811
1923
  withResolvedLimit
1812
1924
  });
1813
1925
  //# sourceMappingURL=index.cjs.map