@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/CHANGELOG.md CHANGED
@@ -6,6 +6,100 @@ 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.10.0] - 2026-09-16
10
+
11
+ Tynn #12 (MOIC's meeting bot): an RFC 5545 invite parser, written from
12
+ scratch in both runtimes (the owner: no third-party code) — no provider
13
+ generates it; it lives beside the other pure helpers. Additive;
14
+ `CONNECTOR_API_VERSION` stays at 1.
15
+
16
+ ### Added
17
+
18
+ - **`parseInvite` / `Ical::parse`** — raw invite text to a structured
19
+ `Invite`: METHOD (REQUEST/CANCEL), UID, SEQUENCE, SUMMARY/DESCRIPTION/
20
+ LOCATION (RFC 5545-unescaped, folded lines joined first), STATUS,
21
+ ORGANIZER and every ATTENDEE (name, email, role, partstat), and an RRULE
22
+ when the event recurs. `DTSTART`/`DTEND` always come back as UTC
23
+ instants: a `Z` suffix passes through, a `TZID` resolves through the
24
+ calendar's own `VTIMEZONE` (its DAYLIGHT/STANDARD sub-components, each
25
+ with a yearly BYMONTH+BYDAY transition rule — an unknown TZID is refused
26
+ BY NAME, never guessed), and a value with neither (RFC 5545's FLOATING
27
+ time) is treated as UTC — a decision named rather than left to look like
28
+ an oversight, because resolving one correctly needs the READER's
29
+ timezone, which nothing in the invite carries.
30
+ - **`nextOccurrence` / `Ical::nextOccurrence`** — the next occurrence at or
31
+ after a given instant, for a recurring or a one-off invite alike; `null`
32
+ once the series (or the single occurrence) has nothing left to offer.
33
+ RRULE support is intentionally narrow: `FREQ` daily/weekly/monthly/yearly
34
+ with `INTERVAL`, `COUNT` and `UNTIL`, and `BYDAY` for WEEKLY only — no
35
+ `BYMONTHDAY`, `BYSETPOS`, `BYWEEKNO`, `BYYEARDAY`, `WKST`, `EXDATE` or
36
+ `RDATE`. A recurring invite using one of those still parses (its RRULE
37
+ fields are read, never dropped); `nextOccurrence` is what does not
38
+ attempt to walk it.
39
+ - **`joinTargets` / `Ical::joinTargets`** — the ALLOW-LISTED places a bot
40
+ can actually join, in the order they appear in the text: Google Meet,
41
+ Zoom (with the meeting id and passcode read out of the URL itself),
42
+ Microsoft Teams, and a dial-in phone number with its own passcode line.
43
+ Anything else — Webex, a generic link, an unrecognised dial-in shape —
44
+ is not returned: a shape this package does not know is a shape a bot
45
+ should not guess at.
46
+ - **`fixtures/ical/`** — six authored cases (the floor, a VTIMEZONE
47
+ resolution, a weekly recurrence with four `nextOccurrence` boundary
48
+ queries, a CANCEL sharing its REQUEST's uid at a higher sequence, an
49
+ allow-listed join-target extraction from a folded/escaped DESCRIPTION,
50
+ and the negative case: a non-allow-listed link ignored). Both runtimes
51
+ read it; `README.md` names the decision each case pins.
52
+
53
+ ## [0.9.0] - 2026-09-15
54
+
55
+ The verification half of Tynn #11 (Resend inbound email, signed by Svix),
56
+ for the two providers that make it vocabulary: Svix and Stripe, both read
57
+ from their own pages on 2026-09-15. Additive in both runtimes;
58
+ `CONNECTOR_API_VERSION` stays at 1.
59
+
60
+ ### Added
61
+
62
+ - **`HmacScheme.secretEncoding` (`utf8` | `base64`) and `secretPrefix`**, with
63
+ `secretKeyBytes()` / `WebhookVerifier::secretKeyBytes()`. Svix hands out
64
+ `whsec_<base64>`: the HMAC key is the DECODED remainder, raw bytes, half of
65
+ which are not valid UTF-8 — encoding the text signed with the wrong key and
66
+ failed exactly like a wrong secret. `utf8` stays the default, byte for
67
+ byte what 0.8.x did. A secret without its declared prefix, or a base64 one
68
+ that is not base64, is refused BY NAME (`signing secret does not start with
69
+ "whsec_"`, `signing secret is not valid base64`) before anything is signed.
70
+ - **`verifyHmac` accepts a LIST of signatures and the delivery passes when
71
+ ANY matches**; `WebhookVerificationSpec.parse` may return `signatures`
72
+ beside the 0.8.x `signature`. Stripe signs once per active secret while a
73
+ secret is rolled and says to compare against EACH; Svix's header "could be
74
+ any number of signatures" of which yours must match one. An empty list is
75
+ no signature, never a vacuous match.
76
+ - **`fixtures/signed-delivery/cases.json`** — 14 authored rows, every
77
+ signature computed by the provider's own rule with `node:crypto` rather
78
+ than by the code under test: Svix valid, a rotation where only the SECOND
79
+ signature is current, none current, at and outside the replay window, the
80
+ secret as text (never matches), a missing prefix and invalid base64 (both
81
+ named), no signature, no secret; Stripe valid, a roll, a re-serialised
82
+ body, a missing timestamp. Both runtimes read it; the seed of the
83
+ conformance suite of that name.
84
+ - **`HmacVerificationSpec.idHeader`, and the payload's third argument.**
85
+ Svix signs `${svix-id}.${svix-timestamp}.${body}`: the delivery's ID is
86
+ part of the signed content, so `verifyDelivery` reads the declared header
87
+ and hands it to `scheme.payload(raw, timestamp, id)` — `verify(...,
88
+ id:)` in PHP, `verify_hmac(..., id=)` in the Python seed. A declared id
89
+ header the delivery does not carry is refused as `delivery carried no id
90
+ header`, never signed with a hole in it. Every two-argument payload
91
+ written before this release is called exactly as before.
92
+
93
+ ### Fixed
94
+
95
+ - **A first-only signature rule fails a whole secret roll.** `verifyDelivery`
96
+ passed only the FIRST signature a header carried, so during Stripe's
97
+ 24-hour roll (or a Svix rotation) every delivery whose first `v1` came
98
+ from the new secret was refused as `signature did not match` — which reads
99
+ as a wrong secret, on a day the secret was just changed. Found by Fancy's
100
+ review of the #11 design; the emitted parsers in weaver.agi collect every
101
+ signature from the same change on.
102
+
9
103
  ## [0.8.1] - 2026-09-15
10
104
 
11
105
  ### Changed
package/dist/index.cjs CHANGED
@@ -75,12 +75,15 @@ __export(src_exports, {
75
75
  isSharedTokenScheme: () => isSharedTokenScheme,
76
76
  isSharedTokenSpec: () => isSharedTokenSpec,
77
77
  isUnconditionallyRetryable: () => isUnconditionallyRetryable,
78
+ joinTargets: () => joinTargets,
78
79
  leaseAction: () => leaseAction,
79
80
  leaseFromResponse: () => leaseFromResponse,
80
81
  leaseRenewAt: () => leaseRenewAt,
81
82
  leaseState: () => leaseState,
82
83
  linkRanges: () => linkRanges,
83
84
  measure: () => measure,
85
+ nextOccurrence: () => nextOccurrence,
86
+ parseInvite: () => parseInvite,
84
87
  parseMime: () => parseMime,
85
88
  payloadHash: () => payloadHash,
86
89
  postChain: () => postChain,
@@ -101,6 +104,7 @@ __export(src_exports, {
101
104
  sandboxIsSelectable: () => sandboxIsSelectable,
102
105
  sandboxRefusal: () => sandboxRefusal,
103
106
  satisfiesMinimum: () => satisfiesMinimum,
107
+ secretKeyBytes: () => secretKeyBytes,
104
108
  seedFrom: () => seedFrom,
105
109
  shapeOf: () => shapeOf,
106
110
  shouldRetry: () => shouldRetry,
@@ -979,6 +983,325 @@ function unchecked(contract) {
979
983
  };
980
984
  }
981
985
 
986
+ // src/ical.ts
987
+ function unfold(text) {
988
+ return text.replace(/\r\n|\r|\n/g, "\n").replace(/\n[ \t]/g, "");
989
+ }
990
+ function unescapeText(value) {
991
+ return value.replace(/\\[nN]/g, "\n").replace(/\\,/g, ",").replace(/\\;/g, ";").replace(/\\\\/g, "\\");
992
+ }
993
+ function parseLine(line) {
994
+ const colon = findUnquoted(line, ":");
995
+ const head = colon === -1 ? line : line.slice(0, colon);
996
+ const value = colon === -1 ? "" : line.slice(colon + 1);
997
+ const [name = "", ...paramParts] = head.split(";");
998
+ const params = {};
999
+ for (const part of paramParts) {
1000
+ const eq = part.indexOf("=");
1001
+ if (eq === -1) continue;
1002
+ const key = part.slice(0, eq).toUpperCase();
1003
+ let raw = part.slice(eq + 1);
1004
+ if (raw.startsWith('"') && raw.endsWith('"')) raw = raw.slice(1, -1);
1005
+ params[key] = raw;
1006
+ }
1007
+ return { name: name.toUpperCase(), params, value };
1008
+ }
1009
+ function findUnquoted(line, char) {
1010
+ let quoted = false;
1011
+ for (let i = 0; i < line.length; i++) {
1012
+ if (line[i] === '"') quoted = !quoted;
1013
+ else if (line[i] === char && !quoted) return i;
1014
+ }
1015
+ return -1;
1016
+ }
1017
+ function blockLines(lines, name) {
1018
+ const blocks = [];
1019
+ let depth = 0;
1020
+ let current = null;
1021
+ for (const raw of lines) {
1022
+ const upper = raw.toUpperCase();
1023
+ if (upper === `BEGIN:${name}`) {
1024
+ if (depth === 0) current = [];
1025
+ depth++;
1026
+ continue;
1027
+ }
1028
+ if (upper === `END:${name}`) {
1029
+ depth--;
1030
+ if (depth === 0 && current !== null) {
1031
+ blocks.push(current);
1032
+ current = null;
1033
+ }
1034
+ continue;
1035
+ }
1036
+ if (current !== null) current.push(raw);
1037
+ }
1038
+ return blocks;
1039
+ }
1040
+ var WEEKDAY_CODES = ["SU", "MO", "TU", "WE", "TH", "FR", "SA"];
1041
+ function parseByDayOrdinal(token) {
1042
+ const match = /^(-?\d+)([A-Z]{2})$/.exec(token);
1043
+ if (!match) throw new Error(`ical: VTIMEZONE rule has an unreadable BYDAY "${token}"`);
1044
+ const [, ordinalText, weekdayText] = match;
1045
+ const weekday = WEEKDAY_CODES.indexOf(weekdayText ?? "");
1046
+ if (weekday === -1) throw new Error(`ical: VTIMEZONE rule names an unknown weekday "${weekdayText}"`);
1047
+ return { ordinal: Number(ordinalText), weekday };
1048
+ }
1049
+ function parseTzTransition(lines) {
1050
+ const props = lines.map(parseLine);
1051
+ const get = (name) => props.find((p) => p.name === name)?.value;
1052
+ const offsetFrom = get("TZOFFSETFROM");
1053
+ const offsetTo = get("TZOFFSETTO");
1054
+ const rrule = get("RRULE");
1055
+ if (!offsetFrom || !offsetTo || !rrule) {
1056
+ throw new Error("ical: a VTIMEZONE sub-component needs TZOFFSETFROM, TZOFFSETTO and an RRULE");
1057
+ }
1058
+ const parts = Object.fromEntries(rrule.split(";").map((p) => p.split("=")));
1059
+ const month = Number(parts.BYMONTH);
1060
+ const { ordinal, weekday } = parseByDayOrdinal(parts.BYDAY ?? "");
1061
+ if (!month || Number.isNaN(ordinal)) {
1062
+ throw new Error(`ical: this package only reads a yearly BYMONTH+BYDAY transition rule, got "${rrule}"`);
1063
+ }
1064
+ return { offsetFrom, offsetTo, month, ordinal, weekday };
1065
+ }
1066
+ function offsetMinutes(offset) {
1067
+ const match = /^([+-])(\d{2}):?(\d{2})/.exec(offset);
1068
+ if (!match) throw new Error(`ical: unreadable UTC offset "${offset}"`);
1069
+ const sign = match[1] === "-" ? -1 : 1;
1070
+ return sign * (Number(match[2]) * 60 + Number(match[3]));
1071
+ }
1072
+ function nthWeekdayOfMonth(year, month1, weekday, ordinal) {
1073
+ if (ordinal > 0) {
1074
+ const first = Date.UTC(year, month1 - 1, 1);
1075
+ const firstWeekday = new Date(first).getUTCDay();
1076
+ const offset2 = (weekday - firstWeekday + 7) % 7;
1077
+ return 1 + offset2 + (ordinal - 1) * 7;
1078
+ }
1079
+ const lastDay = new Date(Date.UTC(year, month1, 0)).getUTCDate();
1080
+ const last = Date.UTC(year, month1 - 1, lastDay);
1081
+ const lastWeekday = new Date(last).getUTCDay();
1082
+ const offset = (lastWeekday - weekday + 7) % 7;
1083
+ return lastDay - offset - (-ordinal - 1) * 7;
1084
+ }
1085
+ function parseTimezones(lines) {
1086
+ const zones = /* @__PURE__ */ new Map();
1087
+ for (const block of blockLines(lines, "VTIMEZONE")) {
1088
+ const tzid = block.map(parseLine).find((p) => p.name === "TZID")?.value;
1089
+ if (!tzid) continue;
1090
+ const standardBlocks = blockLines(block, "STANDARD");
1091
+ const daylightBlocks = blockLines(block, "DAYLIGHT");
1092
+ const standardBlock = standardBlocks[0];
1093
+ if (!standardBlock) {
1094
+ throw new Error(`ical: VTIMEZONE "${tzid}" has no STANDARD component`);
1095
+ }
1096
+ zones.set(tzid, {
1097
+ standard: parseTzTransition(standardBlock),
1098
+ daylight: daylightBlocks[0] ? parseTzTransition(daylightBlocks[0]) : null
1099
+ });
1100
+ }
1101
+ return zones;
1102
+ }
1103
+ function resolveTz(zone, y, mo, d, h, mi, s) {
1104
+ const naiveUtcMs = Date.UTC(y, mo - 1, d, h, mi, s);
1105
+ if (!zone.daylight) {
1106
+ return new Date(naiveUtcMs - offsetMinutes(zone.standard.offsetTo) * 6e4).toISOString();
1107
+ }
1108
+ const springDay = nthWeekdayOfMonth(y, zone.daylight.month, zone.daylight.weekday, zone.daylight.ordinal);
1109
+ const fallDay = nthWeekdayOfMonth(y, zone.standard.month, zone.standard.weekday, zone.standard.ordinal);
1110
+ const spring = Date.UTC(y, zone.daylight.month - 1, springDay);
1111
+ const fall = Date.UTC(y, zone.standard.month - 1, fallDay);
1112
+ const inDaylight = naiveUtcMs >= spring && naiveUtcMs < fall;
1113
+ const offset = inDaylight ? zone.daylight.offsetTo : zone.standard.offsetTo;
1114
+ return new Date(naiveUtcMs - offsetMinutes(offset) * 6e4).toISOString();
1115
+ }
1116
+ function parseDateTimeParts(value) {
1117
+ const match = /^(\d{4})(\d{2})(\d{2})T(\d{2})(\d{2})(\d{2})(Z)?$/.exec(value);
1118
+ if (!match) throw new Error(`ical: unreadable date-time "${value}"`);
1119
+ return {
1120
+ y: Number(match[1]),
1121
+ mo: Number(match[2]),
1122
+ d: Number(match[3]),
1123
+ h: Number(match[4]),
1124
+ mi: Number(match[5]),
1125
+ s: Number(match[6]),
1126
+ utc: Boolean(match[7])
1127
+ };
1128
+ }
1129
+ function resolveInstant(prop, zones) {
1130
+ const parts = parseDateTimeParts(prop.value);
1131
+ if (parts.utc) return new Date(Date.UTC(parts.y, parts.mo - 1, parts.d, parts.h, parts.mi, parts.s)).toISOString();
1132
+ const tzid = prop.params.TZID;
1133
+ if (!tzid) {
1134
+ return new Date(Date.UTC(parts.y, parts.mo - 1, parts.d, parts.h, parts.mi, parts.s)).toISOString();
1135
+ }
1136
+ const zone = zones.get(tzid);
1137
+ if (!zone) {
1138
+ throw new Error(`ical: DTSTART/DTEND names TZID "${tzid}", which this calendar's VTIMEZONE does not define`);
1139
+ }
1140
+ return resolveTz(zone, parts.y, parts.mo, parts.d, parts.h, parts.mi, parts.s);
1141
+ }
1142
+ function emailOf(value) {
1143
+ return value.replace(/^mailto:/i, "");
1144
+ }
1145
+ function parseAttendee(prop) {
1146
+ return {
1147
+ name: prop.params.CN ?? null,
1148
+ email: emailOf(prop.value),
1149
+ role: prop.params.ROLE ?? null,
1150
+ partstat: prop.params.PARTSTAT ?? null
1151
+ };
1152
+ }
1153
+ var FREQS = ["DAILY", "WEEKLY", "MONTHLY", "YEARLY"];
1154
+ function parseRRule(value) {
1155
+ const parts = Object.fromEntries(
1156
+ value.split(";").map((p) => {
1157
+ const eq = p.indexOf("=");
1158
+ return [p.slice(0, eq).toUpperCase(), p.slice(eq + 1)];
1159
+ })
1160
+ );
1161
+ const freq = parts.FREQ;
1162
+ if (!FREQS.includes(freq)) {
1163
+ throw new Error(`ical: RRULE FREQ must be one of ${FREQS.join(", ")}, got "${parts.FREQ}"`);
1164
+ }
1165
+ return {
1166
+ freq,
1167
+ interval: parts.INTERVAL ? Number(parts.INTERVAL) : 1,
1168
+ count: parts.COUNT ? Number(parts.COUNT) : null,
1169
+ until: parts.UNTIL ? resolveInstant({ name: "UNTIL", params: {}, value: parts.UNTIL }, /* @__PURE__ */ new Map()) : null,
1170
+ byDay: parts.BYDAY ? parts.BYDAY.split(",") : null
1171
+ };
1172
+ }
1173
+ function parseInvite(raw) {
1174
+ const lines = unfold(raw).split("\n").filter((l) => l.length > 0);
1175
+ const zones = parseTimezones(lines);
1176
+ const eventBlocks = blockLines(lines, "VEVENT");
1177
+ const firstEvent = eventBlocks[0];
1178
+ if (!firstEvent) throw new Error("ical: no VEVENT in this calendar");
1179
+ const props = firstEvent.map(parseLine);
1180
+ const methodValue = (lines.find((l) => l.toUpperCase().startsWith("METHOD:")) ?? "").split(":")[1];
1181
+ const get = (name) => props.find((p) => p.name === name);
1182
+ const getAll = (name) => props.filter((p) => p.name === name);
1183
+ const text = (name) => {
1184
+ const p = get(name);
1185
+ return p ? unescapeText(p.value) : null;
1186
+ };
1187
+ const method = (methodValue ?? "REQUEST").trim().toUpperCase();
1188
+ if (method !== "REQUEST" && method !== "CANCEL") {
1189
+ throw new Error(`ical: METHOD must be REQUEST or CANCEL, got "${methodValue}"`);
1190
+ }
1191
+ const uidProp = get("UID");
1192
+ if (!uidProp) throw new Error("ical: VEVENT has no UID");
1193
+ const dtstartProp = get("DTSTART");
1194
+ if (!dtstartProp) throw new Error("ical: VEVENT has no DTSTART");
1195
+ const dtendProp = get("DTEND");
1196
+ const organizerProp = get("ORGANIZER");
1197
+ const rruleProp = get("RRULE");
1198
+ return {
1199
+ method,
1200
+ uid: uidProp.value,
1201
+ sequence: get("SEQUENCE") ? Number(get("SEQUENCE").value) : 0,
1202
+ summary: text("SUMMARY"),
1203
+ description: text("DESCRIPTION"),
1204
+ location: text("LOCATION"),
1205
+ status: get("STATUS")?.value ?? null,
1206
+ dtstart: resolveInstant(dtstartProp, zones),
1207
+ dtend: dtendProp ? resolveInstant(dtendProp, zones) : null,
1208
+ organizer: organizerProp ? { name: organizerProp.params.CN ?? null, email: emailOf(organizerProp.value) } : null,
1209
+ attendees: getAll("ATTENDEE").map(parseAttendee),
1210
+ rrule: rruleProp ? parseRRule(rruleProp.value) : null
1211
+ };
1212
+ }
1213
+ var DAY_MS = 864e5;
1214
+ function addCalendarUnit(ms, freq, interval) {
1215
+ const d = new Date(ms);
1216
+ if (freq === "DAILY") return ms + interval * DAY_MS;
1217
+ if (freq === "WEEKLY") return ms + interval * 7 * DAY_MS;
1218
+ const months = freq === "YEARLY" ? interval * 12 : interval;
1219
+ const day = d.getUTCDate();
1220
+ const next = new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth() + months, 1, d.getUTCHours(), d.getUTCMinutes(), d.getUTCSeconds()));
1221
+ const daysInNext = new Date(Date.UTC(next.getUTCFullYear(), next.getUTCMonth() + 1, 0)).getUTCDate();
1222
+ next.setUTCDate(Math.min(day, daysInNext));
1223
+ return next.getTime();
1224
+ }
1225
+ function* occurrences(rrule, dtstartMs) {
1226
+ const untilMs = rrule.until ? Date.parse(rrule.until) : null;
1227
+ let n = 0;
1228
+ const emit = (ms) => {
1229
+ if (untilMs !== null && ms > untilMs) return false;
1230
+ n++;
1231
+ return true;
1232
+ };
1233
+ if (rrule.freq === "WEEKLY" && rrule.byDay?.length) {
1234
+ const wanted = [...new Set(rrule.byDay.map((d) => WEEKDAY_CODES.indexOf(d)))].sort((a, b) => a - b);
1235
+ const dtstart = new Date(dtstartMs);
1236
+ const timeOfDay = dtstartMs - Date.UTC(dtstart.getUTCFullYear(), dtstart.getUTCMonth(), dtstart.getUTCDate());
1237
+ const startOfWeek = dtstartMs - timeOfDay - dtstart.getUTCDay() * DAY_MS;
1238
+ let weekIndex = 0;
1239
+ while (true) {
1240
+ if (weekIndex % rrule.interval === 0) {
1241
+ for (const weekday of wanted) {
1242
+ const candidate = startOfWeek + weekIndex * 7 * DAY_MS + weekday * DAY_MS + timeOfDay;
1243
+ if (candidate < dtstartMs) continue;
1244
+ if (!emit(candidate)) return;
1245
+ yield candidate;
1246
+ if (rrule.count && n >= rrule.count) return;
1247
+ }
1248
+ }
1249
+ weekIndex++;
1250
+ }
1251
+ }
1252
+ let current = dtstartMs;
1253
+ while (true) {
1254
+ if (!emit(current)) return;
1255
+ yield current;
1256
+ if (rrule.count && n >= rrule.count) return;
1257
+ current = addCalendarUnit(current, rrule.freq, rrule.interval);
1258
+ }
1259
+ }
1260
+ function nextOccurrence(invite, afterIso) {
1261
+ const afterMs = Date.parse(afterIso);
1262
+ const dtstartMs = Date.parse(invite.dtstart);
1263
+ if (!invite.rrule) {
1264
+ return dtstartMs >= afterMs ? invite.dtstart : null;
1265
+ }
1266
+ for (const ms of occurrences(invite.rrule, dtstartMs)) {
1267
+ if (ms >= afterMs) return new Date(ms).toISOString();
1268
+ }
1269
+ return null;
1270
+ }
1271
+ var ZOOM_LINK = /https:\/\/[\w.-]*zoom\.us\/j\/(\d+)(\?[^\s)]*)?/g;
1272
+ var MEET_LINK = /https:\/\/meet\.google\.com\/[a-z0-9-]+/gi;
1273
+ var TEAMS_LINK = /https:\/\/teams\.(?:microsoft|live)\.com\/l\/meetup-join\/[^\s)]+/gi;
1274
+ var DIAL_LINE = /^Dial(?:-in)?[:\s]+([+0-9][0-9\s().-]{6,}\d)/im;
1275
+ var PASSCODE_LINE = /Passcode[:\s]+([A-Za-z0-9]+)/i;
1276
+ function joinTargets(invite) {
1277
+ const text = invite.description ?? "";
1278
+ const targets = [];
1279
+ for (const match of text.matchAll(ZOOM_LINK)) {
1280
+ const query = match[2] ?? "";
1281
+ const passcode = /[?&]pwd=([^&\s]+)/.exec(query)?.[1] ?? null;
1282
+ targets.push({
1283
+ index: match.index ?? 0,
1284
+ target: { kind: "zoom", url: match[0], meetingId: match[1] ?? null, passcode, phone: null }
1285
+ });
1286
+ }
1287
+ for (const match of text.matchAll(MEET_LINK)) {
1288
+ targets.push({ index: match.index ?? 0, target: { kind: "google-meet", url: match[0], meetingId: null, passcode: null, phone: null } });
1289
+ }
1290
+ for (const match of text.matchAll(TEAMS_LINK)) {
1291
+ targets.push({ index: match.index ?? 0, target: { kind: "teams", url: match[0], meetingId: null, passcode: null, phone: null } });
1292
+ }
1293
+ const dial = DIAL_LINE.exec(text);
1294
+ const dialedPhone = dial?.[1];
1295
+ if (dial && dialedPhone !== void 0) {
1296
+ const passcode = PASSCODE_LINE.exec(text)?.[1] ?? null;
1297
+ targets.push({
1298
+ index: dial.index,
1299
+ target: { kind: "dial-in", url: null, meetingId: null, passcode, phone: dialedPhone.trim() }
1300
+ });
1301
+ }
1302
+ return targets.sort((a, b) => a.index - b.index).map((t) => t.target);
1303
+ }
1304
+
982
1305
  // src/idempotency.ts
983
1306
  var DEFAULT_IDEMPOTENCY_WINDOW_SECONDS = 24 * 60 * 60;
984
1307
  var MAX_IDEMPOTENCY_KEY_LENGTH = 255;
@@ -1676,12 +1999,35 @@ var PROBLEM_SEVERITIES = ["block", "warn"];
1676
1999
  var CANONICAL_METRICS = ["like", "share", "reply", "quote", "view"];
1677
2000
 
1678
2001
  // src/webhook.ts
2002
+ function secretKeyBytes(secret, options = {}) {
2003
+ let material = secret;
2004
+ if (options.secretPrefix !== void 0) {
2005
+ if (!material.startsWith(options.secretPrefix)) {
2006
+ return { reason: `signing secret does not start with ${JSON.stringify(options.secretPrefix)}` };
2007
+ }
2008
+ material = material.slice(options.secretPrefix.length);
2009
+ }
2010
+ if ((options.secretEncoding ?? "utf8") === "base64") {
2011
+ if (!/^[A-Za-z0-9+/]*={0,2}$/.test(material) || material.length % 4 !== 0) {
2012
+ return { reason: "signing secret is not valid base64" };
2013
+ }
2014
+ try {
2015
+ return { bytes: Uint8Array.from(atob(material), (c) => c.charCodeAt(0)) };
2016
+ } catch {
2017
+ return { reason: "signing secret is not valid base64" };
2018
+ }
2019
+ }
2020
+ return { bytes: new TextEncoder().encode(material) };
2021
+ }
1679
2022
  async function verifyHmac(options) {
1680
- const { raw, signature, secret, scheme, timestamp } = options;
2023
+ const { raw, secret, scheme, timestamp, id } = options;
2024
+ const signatures = (Array.isArray(options.signature) ? options.signature : [options.signature]).filter(
2025
+ (s) => typeof s === "string" && s !== ""
2026
+ );
1681
2027
  if (!secret) {
1682
2028
  return { ok: false, reason: "no signing secret configured for this trigger" };
1683
2029
  }
1684
- if (!signature) return { ok: false, reason: "delivery carried no signature header" };
2030
+ if (signatures.length === 0) return { ok: false, reason: "delivery carried no signature header" };
1685
2031
  if (scheme.tolerance !== void 0) {
1686
2032
  if (!timestamp) return { ok: false, reason: "delivery carried no timestamp header" };
1687
2033
  const sent = Number(timestamp);
@@ -1691,20 +2037,24 @@ async function verifyHmac(options) {
1691
2037
  return { ok: false, reason: `delivery is outside the ${scheme.tolerance}s replay window` };
1692
2038
  }
1693
2039
  }
1694
- const expected = await hmac(secret, scheme.payload(raw, timestamp), scheme.algorithm, scheme.encoding ?? "hex");
1695
- return constantTimeEquals(expected, signature) ? { ok: true } : { ok: false, reason: "signature did not match" };
2040
+ const key = secretKeyBytes(secret, scheme);
2041
+ if ("reason" in key) return { ok: false, reason: key.reason };
2042
+ const expected = await hmac(secret, scheme.payload(raw, timestamp, id), scheme.algorithm, scheme.encoding ?? "hex", scheme);
2043
+ return signatures.some((candidate) => constantTimeEquals(expected, candidate)) ? { ok: true } : { ok: false, reason: "signature did not match" };
1696
2044
  }
1697
- async function hmac(secret, payload, algorithm, encoding = "hex") {
2045
+ async function hmac(secret, payload, algorithm, encoding = "hex", options = {}) {
1698
2046
  const subtle = globalThis.crypto?.subtle;
1699
2047
  if (!subtle) {
1700
2048
  throw new Error(
1701
2049
  "WebCrypto is not available in this runtime, so webhook signatures cannot be verified. Refusing to accept deliveries unverified."
1702
2050
  );
1703
2051
  }
2052
+ const material = secretKeyBytes(secret, options);
2053
+ if ("reason" in material) throw new Error(material.reason);
1704
2054
  const encoder2 = new TextEncoder();
1705
2055
  const key = await subtle.importKey(
1706
2056
  "raw",
1707
- encoder2.encode(secret),
2057
+ material.bytes,
1708
2058
  { name: "HMAC", hash: algorithm },
1709
2059
  false,
1710
2060
  ["sign"]
@@ -1805,12 +2155,15 @@ async function verifyDelivery(trigger, delivery, secret, now) {
1805
2155
  const rawHeader = header2(delivery.headers, spec.signatureHeader);
1806
2156
  const parsed = spec.parse && rawHeader ? spec.parse(rawHeader) : { signature: rawHeader };
1807
2157
  const timestamp = parsed.timestamp ?? (spec.timestampHeader ? header2(delivery.headers, spec.timestampHeader) : void 0);
2158
+ const id = spec.idHeader ? header2(delivery.headers, spec.idHeader) : void 0;
2159
+ if (spec.idHeader && !id) return { ok: false, reason: "delivery carried no id header" };
1808
2160
  return verifyHmac({
1809
2161
  raw: delivery.raw,
1810
- signature: parsed.signature,
2162
+ signature: parsed.signatures ?? parsed.signature,
1811
2163
  secret,
1812
2164
  scheme: spec.scheme,
1813
2165
  timestamp,
2166
+ id,
1814
2167
  ...now === void 0 ? {} : { now }
1815
2168
  });
1816
2169
  }
@@ -1889,12 +2242,15 @@ function hasDelivery(value) {
1889
2242
  isSharedTokenScheme,
1890
2243
  isSharedTokenSpec,
1891
2244
  isUnconditionallyRetryable,
2245
+ joinTargets,
1892
2246
  leaseAction,
1893
2247
  leaseFromResponse,
1894
2248
  leaseRenewAt,
1895
2249
  leaseState,
1896
2250
  linkRanges,
1897
2251
  measure,
2252
+ nextOccurrence,
2253
+ parseInvite,
1898
2254
  parseMime,
1899
2255
  payloadHash,
1900
2256
  postChain,
@@ -1915,6 +2271,7 @@ function hasDelivery(value) {
1915
2271
  sandboxIsSelectable,
1916
2272
  sandboxRefusal,
1917
2273
  satisfiesMinimum,
2274
+ secretKeyBytes,
1918
2275
  seedFrom,
1919
2276
  shapeOf,
1920
2277
  shouldRetry,