@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 +94 -0
- package/dist/index.cjs +364 -7
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +140 -6
- package/dist/index.d.ts +140 -6
- package/dist/index.js +360 -7
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
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,
|
|
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 (
|
|
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
|
|
1695
|
-
|
|
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
|
-
|
|
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,
|