@particle-academy/fancy-connector-core 0.6.2 → 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 +91 -0
- package/dist/index.cjs +422 -8
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +225 -2
- package/dist/index.d.ts +225 -2
- package/dist/index.js +411 -8
- package/dist/index.js.map +1 -1
- package/package.json +2 -1
package/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,97 @@ 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
|
+
|
|
72
|
+
## [0.7.0] - 2026-09-15
|
|
73
|
+
|
|
74
|
+
Two additions to the connector vocabulary, both approved by the owner on
|
|
75
|
+
2026-09-14 and both meeting the two-provider bar before shipping: the
|
|
76
|
+
subscription lease is for Google Calendar `events.watch` and Microsoft Graph
|
|
77
|
+
subscriptions; the MIME transform is for inbound email, provider-agnostic,
|
|
78
|
+
with Resend first.
|
|
79
|
+
|
|
80
|
+
### Added
|
|
81
|
+
|
|
82
|
+
- **`SubscriptionLease`** (`src/lease.ts`, `SubscriptionLease.php` with
|
|
83
|
+
`LeaseState` and `LeaseAction`) — the duty a `subscription` trigger carries,
|
|
84
|
+
as one value: the provider's expiry as an RFC 3339 instant, the connector's
|
|
85
|
+
`renewBeforeSeconds`, and the `renewOperation` the host calls when due.
|
|
86
|
+
`leaseState` says where it is (active / due / expired — both boundaries
|
|
87
|
+
inclusive, expired wins) and `leaseAction` what the host does (none / renew /
|
|
88
|
+
resync; a missed lease is resync, never a quiet re-create). Both runtimes
|
|
89
|
+
read `fixtures/subscription-lease/cases.json`. A host runs ONE renewal
|
|
90
|
+
scheduler for every expiring trigger instead of one per connector.
|
|
91
|
+
- **`parseMime` / `Mime::parse`** — raw MIME in, `headers`, `subject`,
|
|
92
|
+
`messageId`, `text`, `html`, `parts` and `attachments` out, identical in both
|
|
93
|
+
runtimes and held to the authored corpus under `fixtures/mime/` (CRLF and
|
|
94
|
+
LF; folded headers; RFC 2047 B and Q words; quoted-printable and base64;
|
|
95
|
+
nested multipart; RFC 2231 `filename*`; utf-8, us-ascii, iso-8859-1,
|
|
96
|
+
iso-8859-15 and windows-1252 — any other charset leaves a part with no
|
|
97
|
+
text). Attachments carry their bytes as base64. **What to do:** nothing
|
|
98
|
+
unless you want them; neither touches an existing surface.
|
|
99
|
+
|
|
9
100
|
## [0.6.2] - 2026-09-14
|
|
10
101
|
|
|
11
102
|
### Fixed
|
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,15 +65,23 @@ __export(src_exports, {
|
|
|
64
65
|
fakeValues: () => fakeValues,
|
|
65
66
|
getConnectionHost: () => getConnectionHost,
|
|
66
67
|
graphemes: () => graphemes,
|
|
67
|
-
|
|
68
|
+
handshakeResponse: () => handshakeResponse,
|
|
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,
|
|
78
|
+
leaseAction: () => leaseAction,
|
|
79
|
+
leaseFromResponse: () => leaseFromResponse,
|
|
80
|
+
leaseRenewAt: () => leaseRenewAt,
|
|
81
|
+
leaseState: () => leaseState,
|
|
74
82
|
linkRanges: () => linkRanges,
|
|
75
83
|
measure: () => measure,
|
|
84
|
+
parseMime: () => parseMime,
|
|
76
85
|
payloadHash: () => payloadHash,
|
|
77
86
|
postChain: () => postChain,
|
|
78
87
|
probe: () => probe,
|
|
@@ -97,11 +106,13 @@ __export(src_exports, {
|
|
|
97
106
|
shouldRetry: () => shouldRetry,
|
|
98
107
|
sliceByteRange: () => sliceByteRange,
|
|
99
108
|
splitToFit: () => splitToFit,
|
|
109
|
+
subscriptionLease: () => subscriptionLease,
|
|
100
110
|
triggerEvent: () => triggerEvent,
|
|
101
111
|
unchecked: () => unchecked,
|
|
102
112
|
utf8Length: () => utf8Length,
|
|
103
113
|
verifyDelivery: () => verifyDelivery,
|
|
104
114
|
verifyHmac: () => verifyHmac,
|
|
115
|
+
verifySharedToken: () => verifySharedToken,
|
|
105
116
|
withResolvedLimit: () => withResolvedLimit
|
|
106
117
|
});
|
|
107
118
|
module.exports = __toCommonJS(src_exports);
|
|
@@ -703,21 +714,21 @@ async function prepare(service, connection, options) {
|
|
|
703
714
|
}
|
|
704
715
|
function encodeForm(form) {
|
|
705
716
|
const pairs = [];
|
|
706
|
-
const
|
|
717
|
+
const walk2 = (prefix, value) => {
|
|
707
718
|
if (value === void 0 || value === null) return;
|
|
708
719
|
if (Array.isArray(value)) {
|
|
709
|
-
value.forEach((item, index) =>
|
|
720
|
+
value.forEach((item, index) => walk2(`${prefix}[${index}]`, item));
|
|
710
721
|
return;
|
|
711
722
|
}
|
|
712
723
|
if (typeof value === "object") {
|
|
713
724
|
for (const [key, nested] of Object.entries(value)) {
|
|
714
|
-
|
|
725
|
+
walk2(prefix === "" ? key : `${prefix}[${key}]`, nested);
|
|
715
726
|
}
|
|
716
727
|
return;
|
|
717
728
|
}
|
|
718
729
|
pairs.push(`${encodeURIComponent(prefix)}=${encodeURIComponent(String(value))}`);
|
|
719
730
|
};
|
|
720
|
-
|
|
731
|
+
walk2("", form);
|
|
721
732
|
return pairs.join("&");
|
|
722
733
|
}
|
|
723
734
|
var fetchTransport = async (request) => {
|
|
@@ -1021,6 +1032,107 @@ function fnv1a(value) {
|
|
|
1021
1032
|
return hash.toString(16).padStart(8, "0");
|
|
1022
1033
|
}
|
|
1023
1034
|
|
|
1035
|
+
// src/lease.ts
|
|
1036
|
+
var EXPIRES_AT_UNITS = ["rfc3339", "epoch-ms"];
|
|
1037
|
+
var INSTANT = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,9})?(?:Z|[+-]\d{2}:\d{2})$/;
|
|
1038
|
+
var DEFAULT_CONTEXT = { service: "", operation: "subscription" };
|
|
1039
|
+
function instantMs(value, field, at) {
|
|
1040
|
+
if (typeof value !== "string" || !INSTANT.test(value)) {
|
|
1041
|
+
throw new ConnectorConfigError(
|
|
1042
|
+
`${field} must be an RFC 3339 instant such as "2026-09-22T00:00:00Z", got ${JSON.stringify(value)}. A provider's own shape \u2014 Google's epoch milliseconds, say \u2014 is converted by the connector, never guessed at here.`,
|
|
1043
|
+
at
|
|
1044
|
+
);
|
|
1045
|
+
}
|
|
1046
|
+
const ms = Date.parse(value.replace(/(\.\d{3})\d+/, "$1"));
|
|
1047
|
+
if (Number.isNaN(ms)) {
|
|
1048
|
+
throw new ConnectorConfigError(`${field} is not a real instant: ${JSON.stringify(value)}`, at);
|
|
1049
|
+
}
|
|
1050
|
+
return ms;
|
|
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
|
+
}
|
|
1099
|
+
function subscriptionLease(input, at = DEFAULT_CONTEXT) {
|
|
1100
|
+
instantMs(input.expiresAt, "expiresAt", at);
|
|
1101
|
+
if (!Number.isInteger(input.renewBeforeSeconds) || input.renewBeforeSeconds <= 0) {
|
|
1102
|
+
throw new ConnectorConfigError(
|
|
1103
|
+
`renewBeforeSeconds must be a positive whole number of seconds, got ${JSON.stringify(input.renewBeforeSeconds)}. Zero would make the lease due only at the instant it expires, which is a lease nobody renews.`,
|
|
1104
|
+
at
|
|
1105
|
+
);
|
|
1106
|
+
}
|
|
1107
|
+
if (typeof input.renewOperation !== "string" || input.renewOperation.trim() === "") {
|
|
1108
|
+
throw new ConnectorConfigError(
|
|
1109
|
+
"renewOperation must name the operation the host calls when the lease is due; a due lease with nothing to call is one nobody renews.",
|
|
1110
|
+
at
|
|
1111
|
+
);
|
|
1112
|
+
}
|
|
1113
|
+
return { expiresAt: input.expiresAt, renewBeforeSeconds: input.renewBeforeSeconds, renewOperation: input.renewOperation };
|
|
1114
|
+
}
|
|
1115
|
+
function leaseRenewAt(lease) {
|
|
1116
|
+
return new Date(instantMs(lease.expiresAt, "expiresAt", DEFAULT_CONTEXT) - lease.renewBeforeSeconds * 1e3).toISOString();
|
|
1117
|
+
}
|
|
1118
|
+
function leaseState(lease, now) {
|
|
1119
|
+
const at = now instanceof Date ? now.getTime() : instantMs(now, "now", DEFAULT_CONTEXT);
|
|
1120
|
+
const expires = instantMs(lease.expiresAt, "expiresAt", DEFAULT_CONTEXT);
|
|
1121
|
+
if (at >= expires) return "expired";
|
|
1122
|
+
if (at >= expires - lease.renewBeforeSeconds * 1e3) return "due";
|
|
1123
|
+
return "active";
|
|
1124
|
+
}
|
|
1125
|
+
function leaseAction(lease, now) {
|
|
1126
|
+
switch (leaseState(lease, now)) {
|
|
1127
|
+
case "active":
|
|
1128
|
+
return "none";
|
|
1129
|
+
case "due":
|
|
1130
|
+
return "renew";
|
|
1131
|
+
case "expired":
|
|
1132
|
+
return "resync";
|
|
1133
|
+
}
|
|
1134
|
+
}
|
|
1135
|
+
|
|
1024
1136
|
// src/metrics.ts
|
|
1025
1137
|
function compareShape(connectorId, declared, produced) {
|
|
1026
1138
|
const declaredKeys = new Set((declared ?? []).map((metric) => metric.key));
|
|
@@ -1115,6 +1227,245 @@ function providerSandboxIsSelectable(provider) {
|
|
|
1115
1227
|
return sandboxIsSelectable(provider.sandbox);
|
|
1116
1228
|
}
|
|
1117
1229
|
|
|
1230
|
+
// src/mime.ts
|
|
1231
|
+
var CR = 13;
|
|
1232
|
+
var LF = 10;
|
|
1233
|
+
function latin1(bytes) {
|
|
1234
|
+
let s = "";
|
|
1235
|
+
for (let i = 0; i < bytes.length; i++) s += String.fromCharCode(bytes[i]);
|
|
1236
|
+
return s;
|
|
1237
|
+
}
|
|
1238
|
+
function bytesOfLatin1(s) {
|
|
1239
|
+
const out = new Uint8Array(s.length);
|
|
1240
|
+
for (let i = 0; i < s.length; i++) out[i] = s.charCodeAt(i) & 255;
|
|
1241
|
+
return out;
|
|
1242
|
+
}
|
|
1243
|
+
function splitEntity(bytes) {
|
|
1244
|
+
for (let i = 0; i < bytes.length; i++) {
|
|
1245
|
+
if (bytes[i] === LF) {
|
|
1246
|
+
if (bytes[i + 1] === LF) return [i, i + 2];
|
|
1247
|
+
if (bytes[i + 1] === CR && bytes[i + 2] === LF) return [i, i + 3];
|
|
1248
|
+
}
|
|
1249
|
+
}
|
|
1250
|
+
return [bytes.length, bytes.length];
|
|
1251
|
+
}
|
|
1252
|
+
function parseHeaders(block) {
|
|
1253
|
+
const lines = block.split(/\r?\n/);
|
|
1254
|
+
const unfolded = [];
|
|
1255
|
+
for (const line of lines) {
|
|
1256
|
+
if (line === "") continue;
|
|
1257
|
+
if ((line.startsWith(" ") || line.startsWith(" ")) && unfolded.length > 0) {
|
|
1258
|
+
unfolded[unfolded.length - 1] += " " + line.trim();
|
|
1259
|
+
} else {
|
|
1260
|
+
unfolded.push(line);
|
|
1261
|
+
}
|
|
1262
|
+
}
|
|
1263
|
+
const headers = [];
|
|
1264
|
+
for (const line of unfolded) {
|
|
1265
|
+
const colon = line.indexOf(":");
|
|
1266
|
+
if (colon <= 0) continue;
|
|
1267
|
+
headers.push({ name: line.slice(0, colon).trim().toLowerCase(), value: decodeWords(line.slice(colon + 1).trim()) });
|
|
1268
|
+
}
|
|
1269
|
+
return headers;
|
|
1270
|
+
}
|
|
1271
|
+
function header(headers, name) {
|
|
1272
|
+
return headers.find((h) => h.name === name)?.value ?? null;
|
|
1273
|
+
}
|
|
1274
|
+
var ENCODED_WORD = /=\?([^?\s]+)\?([BbQq])\?([^?\s]*)\?=/g;
|
|
1275
|
+
function decodeWords(value) {
|
|
1276
|
+
const joined = value.replace(/(\?=)[ \t\r\n]+(=\?)/g, "$1$2");
|
|
1277
|
+
return joined.replace(ENCODED_WORD, (whole, charset, encoding, text) => {
|
|
1278
|
+
const bytes = encoding.toUpperCase() === "B" ? base64Bytes(text) : bytesOfLatin1(text.replace(/_/g, " ").replace(/=([0-9A-Fa-f]{2})/g, (_m, h) => String.fromCharCode(parseInt(h, 16))));
|
|
1279
|
+
const decoded = decodeCharset(bytes, charset);
|
|
1280
|
+
return decoded ?? whole;
|
|
1281
|
+
});
|
|
1282
|
+
}
|
|
1283
|
+
function parseParams(value) {
|
|
1284
|
+
const [head, ...rest] = value.split(";");
|
|
1285
|
+
const params = {};
|
|
1286
|
+
const continued = {};
|
|
1287
|
+
for (const piece of rest) {
|
|
1288
|
+
const eq = piece.indexOf("=");
|
|
1289
|
+
if (eq < 0) continue;
|
|
1290
|
+
let key = piece.slice(0, eq).trim().toLowerCase();
|
|
1291
|
+
let raw = piece.slice(eq + 1).trim();
|
|
1292
|
+
if (raw.startsWith('"') && raw.endsWith('"') && raw.length >= 2) raw = raw.slice(1, -1).replace(/\\(.)/g, "$1");
|
|
1293
|
+
const segment = /^(.+?)\*(\d+)(\*)?$/.exec(key);
|
|
1294
|
+
if (segment) {
|
|
1295
|
+
const [, base, n, star] = segment;
|
|
1296
|
+
(continued[base] ??= [])[Number(n)] = star ? decode2231(raw, Number(n) === 0) : raw;
|
|
1297
|
+
continue;
|
|
1298
|
+
}
|
|
1299
|
+
if (key.endsWith("*")) {
|
|
1300
|
+
key = key.slice(0, -1);
|
|
1301
|
+
params[key] = decode2231(raw, true);
|
|
1302
|
+
continue;
|
|
1303
|
+
}
|
|
1304
|
+
params[key] ??= raw;
|
|
1305
|
+
}
|
|
1306
|
+
for (const [base, parts] of Object.entries(continued)) params[base] = parts.join("");
|
|
1307
|
+
return { main: (head ?? "").trim().toLowerCase(), params };
|
|
1308
|
+
}
|
|
1309
|
+
var pendingCharset2231 = "utf-8";
|
|
1310
|
+
function decode2231(raw, first) {
|
|
1311
|
+
let text = raw;
|
|
1312
|
+
if (first) {
|
|
1313
|
+
const m = /^([^']*)'[^']*'(.*)$/s.exec(raw);
|
|
1314
|
+
if (m) {
|
|
1315
|
+
pendingCharset2231 = m[1] || "utf-8";
|
|
1316
|
+
text = m[2];
|
|
1317
|
+
}
|
|
1318
|
+
}
|
|
1319
|
+
const bytes = bytesOfLatin1(text.replace(/%([0-9A-Fa-f]{2})/g, (_m, h) => String.fromCharCode(parseInt(h, 16))));
|
|
1320
|
+
return decodeCharset(bytes, pendingCharset2231) ?? text;
|
|
1321
|
+
}
|
|
1322
|
+
function base64Bytes(text) {
|
|
1323
|
+
return new Uint8Array(Buffer.from(text.replace(/[^A-Za-z0-9+/=]/g, ""), "base64"));
|
|
1324
|
+
}
|
|
1325
|
+
function quotedPrintableBytes(bytes) {
|
|
1326
|
+
const out = [];
|
|
1327
|
+
for (let i = 0; i < bytes.length; i++) {
|
|
1328
|
+
const b = bytes[i];
|
|
1329
|
+
if (b !== 61) {
|
|
1330
|
+
out.push(b);
|
|
1331
|
+
continue;
|
|
1332
|
+
}
|
|
1333
|
+
const a = bytes[i + 1];
|
|
1334
|
+
const c = bytes[i + 2];
|
|
1335
|
+
if (a === LF) {
|
|
1336
|
+
i += 1;
|
|
1337
|
+
continue;
|
|
1338
|
+
}
|
|
1339
|
+
if (a === CR && c === LF) {
|
|
1340
|
+
i += 2;
|
|
1341
|
+
continue;
|
|
1342
|
+
}
|
|
1343
|
+
if (a !== void 0 && c !== void 0 && isHex(a) && isHex(c)) {
|
|
1344
|
+
out.push(parseInt(String.fromCharCode(a, c), 16));
|
|
1345
|
+
i += 2;
|
|
1346
|
+
continue;
|
|
1347
|
+
}
|
|
1348
|
+
out.push(b);
|
|
1349
|
+
}
|
|
1350
|
+
return new Uint8Array(out);
|
|
1351
|
+
}
|
|
1352
|
+
function isHex(b) {
|
|
1353
|
+
return b >= 48 && b <= 57 || b >= 65 && b <= 70 || b >= 97 && b <= 102;
|
|
1354
|
+
}
|
|
1355
|
+
function transferDecode(body, encoding) {
|
|
1356
|
+
switch ((encoding ?? "7bit").trim().toLowerCase()) {
|
|
1357
|
+
case "base64":
|
|
1358
|
+
return base64Bytes(latin1(body));
|
|
1359
|
+
case "quoted-printable":
|
|
1360
|
+
return quotedPrintableBytes(body);
|
|
1361
|
+
default:
|
|
1362
|
+
return body;
|
|
1363
|
+
}
|
|
1364
|
+
}
|
|
1365
|
+
function decodeCharset(bytes, charset) {
|
|
1366
|
+
switch (charset.trim().toLowerCase()) {
|
|
1367
|
+
case "utf-8":
|
|
1368
|
+
case "utf8":
|
|
1369
|
+
return new TextDecoder("utf-8").decode(bytes);
|
|
1370
|
+
case "us-ascii":
|
|
1371
|
+
case "ascii":
|
|
1372
|
+
case "iso-8859-1":
|
|
1373
|
+
case "latin1":
|
|
1374
|
+
return Buffer.from(bytes).toString("latin1");
|
|
1375
|
+
case "windows-1252":
|
|
1376
|
+
case "cp1252":
|
|
1377
|
+
return new TextDecoder("windows-1252").decode(bytes);
|
|
1378
|
+
case "iso-8859-15":
|
|
1379
|
+
case "latin9":
|
|
1380
|
+
return new TextDecoder("iso-8859-15").decode(bytes);
|
|
1381
|
+
default:
|
|
1382
|
+
return null;
|
|
1383
|
+
}
|
|
1384
|
+
}
|
|
1385
|
+
function walk(bytes, entityHeaders, acc) {
|
|
1386
|
+
const [headerEnd, bodyStart] = splitEntity(bytes);
|
|
1387
|
+
const headers = entityHeaders ?? parseHeaders(latin1(bytes.subarray(0, headerEnd)));
|
|
1388
|
+
const body = bytes.subarray(entityHeaders ? 0 : bodyStart);
|
|
1389
|
+
const type = parseParams(header(headers, "content-type") ?? "text/plain");
|
|
1390
|
+
if (type.main.startsWith("multipart/") && type.params.boundary) {
|
|
1391
|
+
for (const child of splitMultipart(body, type.params.boundary)) walk(child, null, acc);
|
|
1392
|
+
return;
|
|
1393
|
+
}
|
|
1394
|
+
const disposition = parseParams(header(headers, "content-disposition") ?? "");
|
|
1395
|
+
const decoded = transferDecode(body, header(headers, "content-transfer-encoding"));
|
|
1396
|
+
const declaredDisposition = disposition.main === "attachment" || disposition.main === "inline" ? disposition.main : null;
|
|
1397
|
+
const filename = disposition.params.filename ?? type.params.name ?? null;
|
|
1398
|
+
const contentId = header(headers, "content-id")?.replace(/^<|>$/g, "") ?? null;
|
|
1399
|
+
const isText = type.main.startsWith("text/");
|
|
1400
|
+
const isAttachment = declaredDisposition === "attachment" || !isText;
|
|
1401
|
+
const part = {
|
|
1402
|
+
index: acc.parts.length,
|
|
1403
|
+
contentType: type.main,
|
|
1404
|
+
charset: type.params.charset?.toLowerCase() ?? null,
|
|
1405
|
+
disposition: declaredDisposition,
|
|
1406
|
+
filename,
|
|
1407
|
+
contentId,
|
|
1408
|
+
size: decoded.length,
|
|
1409
|
+
headers
|
|
1410
|
+
};
|
|
1411
|
+
acc.parts.push(part);
|
|
1412
|
+
if (isAttachment) {
|
|
1413
|
+
acc.attachments.push({ ...part, content: Buffer.from(decoded).toString("base64") });
|
|
1414
|
+
return;
|
|
1415
|
+
}
|
|
1416
|
+
const text = decodeCharset(decoded, type.params.charset ?? "utf-8");
|
|
1417
|
+
if (text === null) return;
|
|
1418
|
+
if (type.main === "text/plain" && acc.text === null) acc.text = text;
|
|
1419
|
+
if (type.main === "text/html" && acc.html === null) acc.html = text;
|
|
1420
|
+
}
|
|
1421
|
+
function splitMultipart(body, boundary) {
|
|
1422
|
+
const delimiter = bytesOfLatin1(`--${boundary}`);
|
|
1423
|
+
const children = [];
|
|
1424
|
+
let partStart = -1;
|
|
1425
|
+
for (let i = 0; i <= body.length - delimiter.length; i++) {
|
|
1426
|
+
if (i !== 0 && body[i - 1] !== LF) continue;
|
|
1427
|
+
if (!startsWith(body, i, delimiter)) continue;
|
|
1428
|
+
let partEnd = i;
|
|
1429
|
+
if (partEnd > 0 && body[partEnd - 1] === LF) partEnd -= 1;
|
|
1430
|
+
if (partEnd > 0 && body[partEnd - 1] === CR) partEnd -= 1;
|
|
1431
|
+
if (partStart >= 0) children.push(body.subarray(partStart, partEnd));
|
|
1432
|
+
let j = i + delimiter.length;
|
|
1433
|
+
const closing = body[j] === 45 && body[j + 1] === 45;
|
|
1434
|
+
if (closing) return children;
|
|
1435
|
+
while (j < body.length && body[j] !== LF) j++;
|
|
1436
|
+
partStart = j + 1;
|
|
1437
|
+
i = j;
|
|
1438
|
+
}
|
|
1439
|
+
if (partStart >= 0 && partStart <= body.length) children.push(body.subarray(partStart));
|
|
1440
|
+
return children;
|
|
1441
|
+
}
|
|
1442
|
+
function startsWith(haystack, at, needle) {
|
|
1443
|
+
for (let k = 0; k < needle.length; k++) if (haystack[at + k] !== needle[k]) return false;
|
|
1444
|
+
return true;
|
|
1445
|
+
}
|
|
1446
|
+
function parseMime(raw) {
|
|
1447
|
+
const bytes = typeof raw === "string" ? new Uint8Array(Buffer.from(raw, "utf8")) : raw;
|
|
1448
|
+
const [headerEnd, bodyStart] = splitEntity(bytes);
|
|
1449
|
+
const headers = parseHeaders(latin1(bytes.subarray(0, headerEnd)));
|
|
1450
|
+
const body = bytes.subarray(bodyStart);
|
|
1451
|
+
const acc = { parts: [], attachments: [], text: null, html: null };
|
|
1452
|
+
const type = parseParams(header(headers, "content-type") ?? "text/plain");
|
|
1453
|
+
if (type.main.startsWith("multipart/") && type.params.boundary) {
|
|
1454
|
+
for (const child of splitMultipart(body, type.params.boundary)) walk(child, null, acc);
|
|
1455
|
+
} else {
|
|
1456
|
+
walk(body, headers.filter((h) => h.name.startsWith("content-")), acc);
|
|
1457
|
+
}
|
|
1458
|
+
return {
|
|
1459
|
+
headers,
|
|
1460
|
+
subject: header(headers, "subject"),
|
|
1461
|
+
messageId: header(headers, "message-id"),
|
|
1462
|
+
text: acc.text,
|
|
1463
|
+
html: acc.html,
|
|
1464
|
+
parts: acc.parts,
|
|
1465
|
+
attachments: acc.attachments
|
|
1466
|
+
};
|
|
1467
|
+
}
|
|
1468
|
+
|
|
1118
1469
|
// src/probe.ts
|
|
1119
1470
|
async function probe(spec) {
|
|
1120
1471
|
let response;
|
|
@@ -1374,7 +1725,53 @@ function constantTimeEquals(a, b) {
|
|
|
1374
1725
|
for (let i = 0; i < a.length; i++) diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
|
|
1375
1726
|
return diff === 0;
|
|
1376
1727
|
}
|
|
1377
|
-
function
|
|
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
|
+
}
|
|
1774
|
+
function header2(headers, name) {
|
|
1378
1775
|
const wanted = name.toLowerCase();
|
|
1379
1776
|
for (const [key, value] of Object.entries(headers)) {
|
|
1380
1777
|
if (key.toLowerCase() !== wanted) continue;
|
|
@@ -1384,6 +1781,9 @@ function header(headers, name) {
|
|
|
1384
1781
|
}
|
|
1385
1782
|
|
|
1386
1783
|
// src/trigger.ts
|
|
1784
|
+
function isSharedTokenSpec(spec) {
|
|
1785
|
+
return isSharedTokenScheme(spec.scheme);
|
|
1786
|
+
}
|
|
1387
1787
|
async function verifyDelivery(trigger, delivery, secret, now) {
|
|
1388
1788
|
const spec = trigger.verification;
|
|
1389
1789
|
if (!spec) {
|
|
@@ -1392,9 +1792,12 @@ async function verifyDelivery(trigger, delivery, secret, now) {
|
|
|
1392
1792
|
reason: `${trigger.service}.${trigger.operation} declares no signature scheme, so a delivery cannot be trusted.`
|
|
1393
1793
|
};
|
|
1394
1794
|
}
|
|
1395
|
-
|
|
1795
|
+
if (isSharedTokenSpec(spec)) {
|
|
1796
|
+
return verifySharedToken({ raw: delivery.raw, headers: delivery.headers, secret, scheme: spec.scheme });
|
|
1797
|
+
}
|
|
1798
|
+
const rawHeader = header2(delivery.headers, spec.signatureHeader);
|
|
1396
1799
|
const parsed = spec.parse && rawHeader ? spec.parse(rawHeader) : { signature: rawHeader };
|
|
1397
|
-
const timestamp = parsed.timestamp ?? (spec.timestampHeader ?
|
|
1800
|
+
const timestamp = parsed.timestamp ?? (spec.timestampHeader ? header2(delivery.headers, spec.timestampHeader) : void 0);
|
|
1398
1801
|
return verifyHmac({
|
|
1399
1802
|
raw: delivery.raw,
|
|
1400
1803
|
signature: parsed.signature,
|
|
@@ -1442,6 +1845,7 @@ function hasDelivery(value) {
|
|
|
1442
1845
|
ConnectorUnreachable,
|
|
1443
1846
|
DEFAULT_IDEMPOTENCY_WINDOW_SECONDS,
|
|
1444
1847
|
DEFAULT_RETRY,
|
|
1848
|
+
EXPIRES_AT_UNITS,
|
|
1445
1849
|
FAKE_EPOCH,
|
|
1446
1850
|
MAX_IDEMPOTENCY_KEY_LENGTH,
|
|
1447
1851
|
NO_IDEMPOTENCY_KEY_WARNING,
|
|
@@ -1468,15 +1872,23 @@ function hasDelivery(value) {
|
|
|
1468
1872
|
fakeValues,
|
|
1469
1873
|
getConnectionHost,
|
|
1470
1874
|
graphemes,
|
|
1875
|
+
handshakeResponse,
|
|
1471
1876
|
header,
|
|
1472
1877
|
hmac,
|
|
1473
1878
|
httpFailure,
|
|
1474
1879
|
idempotencyKeyFor,
|
|
1475
1880
|
isEmptyPayload,
|
|
1476
1881
|
isRemote,
|
|
1882
|
+
isSharedTokenScheme,
|
|
1883
|
+
isSharedTokenSpec,
|
|
1477
1884
|
isUnconditionallyRetryable,
|
|
1885
|
+
leaseAction,
|
|
1886
|
+
leaseFromResponse,
|
|
1887
|
+
leaseRenewAt,
|
|
1888
|
+
leaseState,
|
|
1478
1889
|
linkRanges,
|
|
1479
1890
|
measure,
|
|
1891
|
+
parseMime,
|
|
1480
1892
|
payloadHash,
|
|
1481
1893
|
postChain,
|
|
1482
1894
|
probe,
|
|
@@ -1501,11 +1913,13 @@ function hasDelivery(value) {
|
|
|
1501
1913
|
shouldRetry,
|
|
1502
1914
|
sliceByteRange,
|
|
1503
1915
|
splitToFit,
|
|
1916
|
+
subscriptionLease,
|
|
1504
1917
|
triggerEvent,
|
|
1505
1918
|
unchecked,
|
|
1506
1919
|
utf8Length,
|
|
1507
1920
|
verifyDelivery,
|
|
1508
1921
|
verifyHmac,
|
|
1922
|
+
verifySharedToken,
|
|
1509
1923
|
withResolvedLimit
|
|
1510
1924
|
});
|
|
1511
1925
|
//# sourceMappingURL=index.cjs.map
|