@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 +76 -0
- package/dist/index.cjs +120 -1
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +122 -3
- package/dist/index.d.ts +122 -3
- package/dist/index.js +114 -1
- package/dist/index.js.map +1 -1
- package/package.json +2 -1
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
|