@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 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
- header: () => header,
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 walk = (prefix, value) => {
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) => walk(`${prefix}[${index}]`, item));
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
- walk(prefix === "" ? key : `${prefix}[${key}]`, nested);
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
- walk("", form);
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 header(headers, name) {
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
- const rawHeader = header(delivery.headers, spec.signatureHeader);
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 ? header(delivery.headers, spec.timestampHeader) : void 0);
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