@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 +63 -0
- package/dist/index.cjs +113 -1
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +113 -3
- package/dist/index.d.ts +113 -3
- package/dist/index.js +107 -1
- package/dist/index.js.map +1 -1
- package/package.json +2 -1
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
|