@particle-academy/fancy-connector-core 0.8.1 → 0.10.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/dist/index.js CHANGED
@@ -860,6 +860,325 @@ function unchecked(contract) {
860
860
  };
861
861
  }
862
862
 
863
+ // src/ical.ts
864
+ function unfold(text) {
865
+ return text.replace(/\r\n|\r|\n/g, "\n").replace(/\n[ \t]/g, "");
866
+ }
867
+ function unescapeText(value) {
868
+ return value.replace(/\\[nN]/g, "\n").replace(/\\,/g, ",").replace(/\\;/g, ";").replace(/\\\\/g, "\\");
869
+ }
870
+ function parseLine(line) {
871
+ const colon = findUnquoted(line, ":");
872
+ const head = colon === -1 ? line : line.slice(0, colon);
873
+ const value = colon === -1 ? "" : line.slice(colon + 1);
874
+ const [name = "", ...paramParts] = head.split(";");
875
+ const params = {};
876
+ for (const part of paramParts) {
877
+ const eq = part.indexOf("=");
878
+ if (eq === -1) continue;
879
+ const key = part.slice(0, eq).toUpperCase();
880
+ let raw = part.slice(eq + 1);
881
+ if (raw.startsWith('"') && raw.endsWith('"')) raw = raw.slice(1, -1);
882
+ params[key] = raw;
883
+ }
884
+ return { name: name.toUpperCase(), params, value };
885
+ }
886
+ function findUnquoted(line, char) {
887
+ let quoted = false;
888
+ for (let i = 0; i < line.length; i++) {
889
+ if (line[i] === '"') quoted = !quoted;
890
+ else if (line[i] === char && !quoted) return i;
891
+ }
892
+ return -1;
893
+ }
894
+ function blockLines(lines, name) {
895
+ const blocks = [];
896
+ let depth = 0;
897
+ let current = null;
898
+ for (const raw of lines) {
899
+ const upper = raw.toUpperCase();
900
+ if (upper === `BEGIN:${name}`) {
901
+ if (depth === 0) current = [];
902
+ depth++;
903
+ continue;
904
+ }
905
+ if (upper === `END:${name}`) {
906
+ depth--;
907
+ if (depth === 0 && current !== null) {
908
+ blocks.push(current);
909
+ current = null;
910
+ }
911
+ continue;
912
+ }
913
+ if (current !== null) current.push(raw);
914
+ }
915
+ return blocks;
916
+ }
917
+ var WEEKDAY_CODES = ["SU", "MO", "TU", "WE", "TH", "FR", "SA"];
918
+ function parseByDayOrdinal(token) {
919
+ const match = /^(-?\d+)([A-Z]{2})$/.exec(token);
920
+ if (!match) throw new Error(`ical: VTIMEZONE rule has an unreadable BYDAY "${token}"`);
921
+ const [, ordinalText, weekdayText] = match;
922
+ const weekday = WEEKDAY_CODES.indexOf(weekdayText ?? "");
923
+ if (weekday === -1) throw new Error(`ical: VTIMEZONE rule names an unknown weekday "${weekdayText}"`);
924
+ return { ordinal: Number(ordinalText), weekday };
925
+ }
926
+ function parseTzTransition(lines) {
927
+ const props = lines.map(parseLine);
928
+ const get = (name) => props.find((p) => p.name === name)?.value;
929
+ const offsetFrom = get("TZOFFSETFROM");
930
+ const offsetTo = get("TZOFFSETTO");
931
+ const rrule = get("RRULE");
932
+ if (!offsetFrom || !offsetTo || !rrule) {
933
+ throw new Error("ical: a VTIMEZONE sub-component needs TZOFFSETFROM, TZOFFSETTO and an RRULE");
934
+ }
935
+ const parts = Object.fromEntries(rrule.split(";").map((p) => p.split("=")));
936
+ const month = Number(parts.BYMONTH);
937
+ const { ordinal, weekday } = parseByDayOrdinal(parts.BYDAY ?? "");
938
+ if (!month || Number.isNaN(ordinal)) {
939
+ throw new Error(`ical: this package only reads a yearly BYMONTH+BYDAY transition rule, got "${rrule}"`);
940
+ }
941
+ return { offsetFrom, offsetTo, month, ordinal, weekday };
942
+ }
943
+ function offsetMinutes(offset) {
944
+ const match = /^([+-])(\d{2}):?(\d{2})/.exec(offset);
945
+ if (!match) throw new Error(`ical: unreadable UTC offset "${offset}"`);
946
+ const sign = match[1] === "-" ? -1 : 1;
947
+ return sign * (Number(match[2]) * 60 + Number(match[3]));
948
+ }
949
+ function nthWeekdayOfMonth(year, month1, weekday, ordinal) {
950
+ if (ordinal > 0) {
951
+ const first = Date.UTC(year, month1 - 1, 1);
952
+ const firstWeekday = new Date(first).getUTCDay();
953
+ const offset2 = (weekday - firstWeekday + 7) % 7;
954
+ return 1 + offset2 + (ordinal - 1) * 7;
955
+ }
956
+ const lastDay = new Date(Date.UTC(year, month1, 0)).getUTCDate();
957
+ const last = Date.UTC(year, month1 - 1, lastDay);
958
+ const lastWeekday = new Date(last).getUTCDay();
959
+ const offset = (lastWeekday - weekday + 7) % 7;
960
+ return lastDay - offset - (-ordinal - 1) * 7;
961
+ }
962
+ function parseTimezones(lines) {
963
+ const zones = /* @__PURE__ */ new Map();
964
+ for (const block of blockLines(lines, "VTIMEZONE")) {
965
+ const tzid = block.map(parseLine).find((p) => p.name === "TZID")?.value;
966
+ if (!tzid) continue;
967
+ const standardBlocks = blockLines(block, "STANDARD");
968
+ const daylightBlocks = blockLines(block, "DAYLIGHT");
969
+ const standardBlock = standardBlocks[0];
970
+ if (!standardBlock) {
971
+ throw new Error(`ical: VTIMEZONE "${tzid}" has no STANDARD component`);
972
+ }
973
+ zones.set(tzid, {
974
+ standard: parseTzTransition(standardBlock),
975
+ daylight: daylightBlocks[0] ? parseTzTransition(daylightBlocks[0]) : null
976
+ });
977
+ }
978
+ return zones;
979
+ }
980
+ function resolveTz(zone, y, mo, d, h, mi, s) {
981
+ const naiveUtcMs = Date.UTC(y, mo - 1, d, h, mi, s);
982
+ if (!zone.daylight) {
983
+ return new Date(naiveUtcMs - offsetMinutes(zone.standard.offsetTo) * 6e4).toISOString();
984
+ }
985
+ const springDay = nthWeekdayOfMonth(y, zone.daylight.month, zone.daylight.weekday, zone.daylight.ordinal);
986
+ const fallDay = nthWeekdayOfMonth(y, zone.standard.month, zone.standard.weekday, zone.standard.ordinal);
987
+ const spring = Date.UTC(y, zone.daylight.month - 1, springDay);
988
+ const fall = Date.UTC(y, zone.standard.month - 1, fallDay);
989
+ const inDaylight = naiveUtcMs >= spring && naiveUtcMs < fall;
990
+ const offset = inDaylight ? zone.daylight.offsetTo : zone.standard.offsetTo;
991
+ return new Date(naiveUtcMs - offsetMinutes(offset) * 6e4).toISOString();
992
+ }
993
+ function parseDateTimeParts(value) {
994
+ const match = /^(\d{4})(\d{2})(\d{2})T(\d{2})(\d{2})(\d{2})(Z)?$/.exec(value);
995
+ if (!match) throw new Error(`ical: unreadable date-time "${value}"`);
996
+ return {
997
+ y: Number(match[1]),
998
+ mo: Number(match[2]),
999
+ d: Number(match[3]),
1000
+ h: Number(match[4]),
1001
+ mi: Number(match[5]),
1002
+ s: Number(match[6]),
1003
+ utc: Boolean(match[7])
1004
+ };
1005
+ }
1006
+ function resolveInstant(prop, zones) {
1007
+ const parts = parseDateTimeParts(prop.value);
1008
+ if (parts.utc) return new Date(Date.UTC(parts.y, parts.mo - 1, parts.d, parts.h, parts.mi, parts.s)).toISOString();
1009
+ const tzid = prop.params.TZID;
1010
+ if (!tzid) {
1011
+ return new Date(Date.UTC(parts.y, parts.mo - 1, parts.d, parts.h, parts.mi, parts.s)).toISOString();
1012
+ }
1013
+ const zone = zones.get(tzid);
1014
+ if (!zone) {
1015
+ throw new Error(`ical: DTSTART/DTEND names TZID "${tzid}", which this calendar's VTIMEZONE does not define`);
1016
+ }
1017
+ return resolveTz(zone, parts.y, parts.mo, parts.d, parts.h, parts.mi, parts.s);
1018
+ }
1019
+ function emailOf(value) {
1020
+ return value.replace(/^mailto:/i, "");
1021
+ }
1022
+ function parseAttendee(prop) {
1023
+ return {
1024
+ name: prop.params.CN ?? null,
1025
+ email: emailOf(prop.value),
1026
+ role: prop.params.ROLE ?? null,
1027
+ partstat: prop.params.PARTSTAT ?? null
1028
+ };
1029
+ }
1030
+ var FREQS = ["DAILY", "WEEKLY", "MONTHLY", "YEARLY"];
1031
+ function parseRRule(value) {
1032
+ const parts = Object.fromEntries(
1033
+ value.split(";").map((p) => {
1034
+ const eq = p.indexOf("=");
1035
+ return [p.slice(0, eq).toUpperCase(), p.slice(eq + 1)];
1036
+ })
1037
+ );
1038
+ const freq = parts.FREQ;
1039
+ if (!FREQS.includes(freq)) {
1040
+ throw new Error(`ical: RRULE FREQ must be one of ${FREQS.join(", ")}, got "${parts.FREQ}"`);
1041
+ }
1042
+ return {
1043
+ freq,
1044
+ interval: parts.INTERVAL ? Number(parts.INTERVAL) : 1,
1045
+ count: parts.COUNT ? Number(parts.COUNT) : null,
1046
+ until: parts.UNTIL ? resolveInstant({ name: "UNTIL", params: {}, value: parts.UNTIL }, /* @__PURE__ */ new Map()) : null,
1047
+ byDay: parts.BYDAY ? parts.BYDAY.split(",") : null
1048
+ };
1049
+ }
1050
+ function parseInvite(raw) {
1051
+ const lines = unfold(raw).split("\n").filter((l) => l.length > 0);
1052
+ const zones = parseTimezones(lines);
1053
+ const eventBlocks = blockLines(lines, "VEVENT");
1054
+ const firstEvent = eventBlocks[0];
1055
+ if (!firstEvent) throw new Error("ical: no VEVENT in this calendar");
1056
+ const props = firstEvent.map(parseLine);
1057
+ const methodValue = (lines.find((l) => l.toUpperCase().startsWith("METHOD:")) ?? "").split(":")[1];
1058
+ const get = (name) => props.find((p) => p.name === name);
1059
+ const getAll = (name) => props.filter((p) => p.name === name);
1060
+ const text = (name) => {
1061
+ const p = get(name);
1062
+ return p ? unescapeText(p.value) : null;
1063
+ };
1064
+ const method = (methodValue ?? "REQUEST").trim().toUpperCase();
1065
+ if (method !== "REQUEST" && method !== "CANCEL") {
1066
+ throw new Error(`ical: METHOD must be REQUEST or CANCEL, got "${methodValue}"`);
1067
+ }
1068
+ const uidProp = get("UID");
1069
+ if (!uidProp) throw new Error("ical: VEVENT has no UID");
1070
+ const dtstartProp = get("DTSTART");
1071
+ if (!dtstartProp) throw new Error("ical: VEVENT has no DTSTART");
1072
+ const dtendProp = get("DTEND");
1073
+ const organizerProp = get("ORGANIZER");
1074
+ const rruleProp = get("RRULE");
1075
+ return {
1076
+ method,
1077
+ uid: uidProp.value,
1078
+ sequence: get("SEQUENCE") ? Number(get("SEQUENCE").value) : 0,
1079
+ summary: text("SUMMARY"),
1080
+ description: text("DESCRIPTION"),
1081
+ location: text("LOCATION"),
1082
+ status: get("STATUS")?.value ?? null,
1083
+ dtstart: resolveInstant(dtstartProp, zones),
1084
+ dtend: dtendProp ? resolveInstant(dtendProp, zones) : null,
1085
+ organizer: organizerProp ? { name: organizerProp.params.CN ?? null, email: emailOf(organizerProp.value) } : null,
1086
+ attendees: getAll("ATTENDEE").map(parseAttendee),
1087
+ rrule: rruleProp ? parseRRule(rruleProp.value) : null
1088
+ };
1089
+ }
1090
+ var DAY_MS = 864e5;
1091
+ function addCalendarUnit(ms, freq, interval) {
1092
+ const d = new Date(ms);
1093
+ if (freq === "DAILY") return ms + interval * DAY_MS;
1094
+ if (freq === "WEEKLY") return ms + interval * 7 * DAY_MS;
1095
+ const months = freq === "YEARLY" ? interval * 12 : interval;
1096
+ const day = d.getUTCDate();
1097
+ const next = new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth() + months, 1, d.getUTCHours(), d.getUTCMinutes(), d.getUTCSeconds()));
1098
+ const daysInNext = new Date(Date.UTC(next.getUTCFullYear(), next.getUTCMonth() + 1, 0)).getUTCDate();
1099
+ next.setUTCDate(Math.min(day, daysInNext));
1100
+ return next.getTime();
1101
+ }
1102
+ function* occurrences(rrule, dtstartMs) {
1103
+ const untilMs = rrule.until ? Date.parse(rrule.until) : null;
1104
+ let n = 0;
1105
+ const emit = (ms) => {
1106
+ if (untilMs !== null && ms > untilMs) return false;
1107
+ n++;
1108
+ return true;
1109
+ };
1110
+ if (rrule.freq === "WEEKLY" && rrule.byDay?.length) {
1111
+ const wanted = [...new Set(rrule.byDay.map((d) => WEEKDAY_CODES.indexOf(d)))].sort((a, b) => a - b);
1112
+ const dtstart = new Date(dtstartMs);
1113
+ const timeOfDay = dtstartMs - Date.UTC(dtstart.getUTCFullYear(), dtstart.getUTCMonth(), dtstart.getUTCDate());
1114
+ const startOfWeek = dtstartMs - timeOfDay - dtstart.getUTCDay() * DAY_MS;
1115
+ let weekIndex = 0;
1116
+ while (true) {
1117
+ if (weekIndex % rrule.interval === 0) {
1118
+ for (const weekday of wanted) {
1119
+ const candidate = startOfWeek + weekIndex * 7 * DAY_MS + weekday * DAY_MS + timeOfDay;
1120
+ if (candidate < dtstartMs) continue;
1121
+ if (!emit(candidate)) return;
1122
+ yield candidate;
1123
+ if (rrule.count && n >= rrule.count) return;
1124
+ }
1125
+ }
1126
+ weekIndex++;
1127
+ }
1128
+ }
1129
+ let current = dtstartMs;
1130
+ while (true) {
1131
+ if (!emit(current)) return;
1132
+ yield current;
1133
+ if (rrule.count && n >= rrule.count) return;
1134
+ current = addCalendarUnit(current, rrule.freq, rrule.interval);
1135
+ }
1136
+ }
1137
+ function nextOccurrence(invite, afterIso) {
1138
+ const afterMs = Date.parse(afterIso);
1139
+ const dtstartMs = Date.parse(invite.dtstart);
1140
+ if (!invite.rrule) {
1141
+ return dtstartMs >= afterMs ? invite.dtstart : null;
1142
+ }
1143
+ for (const ms of occurrences(invite.rrule, dtstartMs)) {
1144
+ if (ms >= afterMs) return new Date(ms).toISOString();
1145
+ }
1146
+ return null;
1147
+ }
1148
+ var ZOOM_LINK = /https:\/\/[\w.-]*zoom\.us\/j\/(\d+)(\?[^\s)]*)?/g;
1149
+ var MEET_LINK = /https:\/\/meet\.google\.com\/[a-z0-9-]+/gi;
1150
+ var TEAMS_LINK = /https:\/\/teams\.(?:microsoft|live)\.com\/l\/meetup-join\/[^\s)]+/gi;
1151
+ var DIAL_LINE = /^Dial(?:-in)?[:\s]+([+0-9][0-9\s().-]{6,}\d)/im;
1152
+ var PASSCODE_LINE = /Passcode[:\s]+([A-Za-z0-9]+)/i;
1153
+ function joinTargets(invite) {
1154
+ const text = invite.description ?? "";
1155
+ const targets = [];
1156
+ for (const match of text.matchAll(ZOOM_LINK)) {
1157
+ const query = match[2] ?? "";
1158
+ const passcode = /[?&]pwd=([^&\s]+)/.exec(query)?.[1] ?? null;
1159
+ targets.push({
1160
+ index: match.index ?? 0,
1161
+ target: { kind: "zoom", url: match[0], meetingId: match[1] ?? null, passcode, phone: null }
1162
+ });
1163
+ }
1164
+ for (const match of text.matchAll(MEET_LINK)) {
1165
+ targets.push({ index: match.index ?? 0, target: { kind: "google-meet", url: match[0], meetingId: null, passcode: null, phone: null } });
1166
+ }
1167
+ for (const match of text.matchAll(TEAMS_LINK)) {
1168
+ targets.push({ index: match.index ?? 0, target: { kind: "teams", url: match[0], meetingId: null, passcode: null, phone: null } });
1169
+ }
1170
+ const dial = DIAL_LINE.exec(text);
1171
+ const dialedPhone = dial?.[1];
1172
+ if (dial && dialedPhone !== void 0) {
1173
+ const passcode = PASSCODE_LINE.exec(text)?.[1] ?? null;
1174
+ targets.push({
1175
+ index: dial.index,
1176
+ target: { kind: "dial-in", url: null, meetingId: null, passcode, phone: dialedPhone.trim() }
1177
+ });
1178
+ }
1179
+ return targets.sort((a, b) => a.index - b.index).map((t) => t.target);
1180
+ }
1181
+
863
1182
  // src/idempotency.ts
864
1183
  var DEFAULT_IDEMPOTENCY_WINDOW_SECONDS = 24 * 60 * 60;
865
1184
  var MAX_IDEMPOTENCY_KEY_LENGTH = 255;
@@ -1557,12 +1876,35 @@ var PROBLEM_SEVERITIES = ["block", "warn"];
1557
1876
  var CANONICAL_METRICS = ["like", "share", "reply", "quote", "view"];
1558
1877
 
1559
1878
  // src/webhook.ts
1879
+ function secretKeyBytes(secret, options = {}) {
1880
+ let material = secret;
1881
+ if (options.secretPrefix !== void 0) {
1882
+ if (!material.startsWith(options.secretPrefix)) {
1883
+ return { reason: `signing secret does not start with ${JSON.stringify(options.secretPrefix)}` };
1884
+ }
1885
+ material = material.slice(options.secretPrefix.length);
1886
+ }
1887
+ if ((options.secretEncoding ?? "utf8") === "base64") {
1888
+ if (!/^[A-Za-z0-9+/]*={0,2}$/.test(material) || material.length % 4 !== 0) {
1889
+ return { reason: "signing secret is not valid base64" };
1890
+ }
1891
+ try {
1892
+ return { bytes: Uint8Array.from(atob(material), (c) => c.charCodeAt(0)) };
1893
+ } catch {
1894
+ return { reason: "signing secret is not valid base64" };
1895
+ }
1896
+ }
1897
+ return { bytes: new TextEncoder().encode(material) };
1898
+ }
1560
1899
  async function verifyHmac(options) {
1561
- const { raw, signature, secret, scheme, timestamp } = options;
1900
+ const { raw, secret, scheme, timestamp, id } = options;
1901
+ const signatures = (Array.isArray(options.signature) ? options.signature : [options.signature]).filter(
1902
+ (s) => typeof s === "string" && s !== ""
1903
+ );
1562
1904
  if (!secret) {
1563
1905
  return { ok: false, reason: "no signing secret configured for this trigger" };
1564
1906
  }
1565
- if (!signature) return { ok: false, reason: "delivery carried no signature header" };
1907
+ if (signatures.length === 0) return { ok: false, reason: "delivery carried no signature header" };
1566
1908
  if (scheme.tolerance !== void 0) {
1567
1909
  if (!timestamp) return { ok: false, reason: "delivery carried no timestamp header" };
1568
1910
  const sent = Number(timestamp);
@@ -1572,20 +1914,24 @@ async function verifyHmac(options) {
1572
1914
  return { ok: false, reason: `delivery is outside the ${scheme.tolerance}s replay window` };
1573
1915
  }
1574
1916
  }
1575
- const expected = await hmac(secret, scheme.payload(raw, timestamp), scheme.algorithm, scheme.encoding ?? "hex");
1576
- return constantTimeEquals(expected, signature) ? { ok: true } : { ok: false, reason: "signature did not match" };
1917
+ const key = secretKeyBytes(secret, scheme);
1918
+ if ("reason" in key) return { ok: false, reason: key.reason };
1919
+ const expected = await hmac(secret, scheme.payload(raw, timestamp, id), scheme.algorithm, scheme.encoding ?? "hex", scheme);
1920
+ return signatures.some((candidate) => constantTimeEquals(expected, candidate)) ? { ok: true } : { ok: false, reason: "signature did not match" };
1577
1921
  }
1578
- async function hmac(secret, payload, algorithm, encoding = "hex") {
1922
+ async function hmac(secret, payload, algorithm, encoding = "hex", options = {}) {
1579
1923
  const subtle = globalThis.crypto?.subtle;
1580
1924
  if (!subtle) {
1581
1925
  throw new Error(
1582
1926
  "WebCrypto is not available in this runtime, so webhook signatures cannot be verified. Refusing to accept deliveries unverified."
1583
1927
  );
1584
1928
  }
1929
+ const material = secretKeyBytes(secret, options);
1930
+ if ("reason" in material) throw new Error(material.reason);
1585
1931
  const encoder2 = new TextEncoder();
1586
1932
  const key = await subtle.importKey(
1587
1933
  "raw",
1588
- encoder2.encode(secret),
1934
+ material.bytes,
1589
1935
  { name: "HMAC", hash: algorithm },
1590
1936
  false,
1591
1937
  ["sign"]
@@ -1686,12 +2032,15 @@ async function verifyDelivery(trigger, delivery, secret, now) {
1686
2032
  const rawHeader = header2(delivery.headers, spec.signatureHeader);
1687
2033
  const parsed = spec.parse && rawHeader ? spec.parse(rawHeader) : { signature: rawHeader };
1688
2034
  const timestamp = parsed.timestamp ?? (spec.timestampHeader ? header2(delivery.headers, spec.timestampHeader) : void 0);
2035
+ const id = spec.idHeader ? header2(delivery.headers, spec.idHeader) : void 0;
2036
+ if (spec.idHeader && !id) return { ok: false, reason: "delivery carried no id header" };
1689
2037
  return verifyHmac({
1690
2038
  raw: delivery.raw,
1691
- signature: parsed.signature,
2039
+ signature: parsed.signatures ?? parsed.signature,
1692
2040
  secret,
1693
2041
  scheme: spec.scheme,
1694
2042
  timestamp,
2043
+ id,
1695
2044
  ...now === void 0 ? {} : { now }
1696
2045
  });
1697
2046
  }
@@ -1769,12 +2118,15 @@ export {
1769
2118
  isSharedTokenScheme,
1770
2119
  isSharedTokenSpec,
1771
2120
  isUnconditionallyRetryable,
2121
+ joinTargets,
1772
2122
  leaseAction,
1773
2123
  leaseFromResponse,
1774
2124
  leaseRenewAt,
1775
2125
  leaseState,
1776
2126
  linkRanges,
1777
2127
  measure,
2128
+ nextOccurrence,
2129
+ parseInvite,
1778
2130
  parseMime,
1779
2131
  payloadHash,
1780
2132
  postChain,
@@ -1795,6 +2147,7 @@ export {
1795
2147
  sandboxIsSelectable,
1796
2148
  sandboxRefusal,
1797
2149
  satisfiesMinimum,
2150
+ secretKeyBytes,
1798
2151
  seedFrom,
1799
2152
  shapeOf,
1800
2153
  shouldRetry,