@remit/calendar-service 0.0.3 → 0.0.5
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/package.json +5 -1
- package/src/expand.test.ts +18 -0
- package/src/expand.ts +12 -5
- package/src/feed.test.ts +286 -0
- package/src/feed.ts +182 -0
- package/src/index.ts +16 -0
- package/src/memory-store.ts +48 -1
- package/src/window.test.ts +46 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@remit/calendar-service",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.5",
|
|
4
4
|
"description": "iCalendar (RFC 5545) parsing, projection and recurrence expansion over the calendar store",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/index.ts",
|
|
@@ -9,6 +9,10 @@
|
|
|
9
9
|
".": {
|
|
10
10
|
"types": "./src/index.ts",
|
|
11
11
|
"default": "./src/index.ts"
|
|
12
|
+
},
|
|
13
|
+
"./memory-store": {
|
|
14
|
+
"types": "./src/memory-store.ts",
|
|
15
|
+
"default": "./src/memory-store.ts"
|
|
12
16
|
}
|
|
13
17
|
},
|
|
14
18
|
"scripts": {
|
package/src/expand.test.ts
CHANGED
|
@@ -328,6 +328,24 @@ describe("expandCalendar", () => {
|
|
|
328
328
|
assert.notEqual(expansion.expandedThrough, "");
|
|
329
329
|
});
|
|
330
330
|
|
|
331
|
+
it("marks a series whose first occurrence falls past the horizon", async () => {
|
|
332
|
+
// The rule's own first instance is excluded, so the iterator's first slot
|
|
333
|
+
// is 2029 and the horizon closes in 2027. Nothing is written, but the
|
|
334
|
+
// series is not complete: `""` here would hide it from the live expansion
|
|
335
|
+
// and the event would render nowhere, ever.
|
|
336
|
+
const expansion = await expand(
|
|
337
|
+
singleEvent(
|
|
338
|
+
"DTSTART:20250301T090000Z",
|
|
339
|
+
"DTEND:20250301T100000Z",
|
|
340
|
+
"RRULE:FREQ=YEARLY;INTERVAL=4",
|
|
341
|
+
"EXDATE:20250301T090000Z",
|
|
342
|
+
),
|
|
343
|
+
);
|
|
344
|
+
|
|
345
|
+
assert.deepEqual(expansion.occurrences, []);
|
|
346
|
+
assert.equal(expansion.expandedThrough, "2025-03-01T09:00:00Z");
|
|
347
|
+
});
|
|
348
|
+
|
|
331
349
|
it("says nothing about a horizon for a series that ends inside it", async () => {
|
|
332
350
|
const expansion = await expand(
|
|
333
351
|
singleEvent(
|
package/src/expand.ts
CHANGED
|
@@ -107,7 +107,7 @@ interface Walk {
|
|
|
107
107
|
occurrences: CalendarOccurrenceInput[];
|
|
108
108
|
/** Whether the walk stopped on a bound rather than on the series ending. */
|
|
109
109
|
truncated: boolean;
|
|
110
|
-
/** Start of the last slot the
|
|
110
|
+
/** Start of the last slot the walk collected, or `""` when it collected none. */
|
|
111
111
|
lastIteratedStart: string;
|
|
112
112
|
}
|
|
113
113
|
|
|
@@ -258,12 +258,19 @@ export const expandCalendar = (
|
|
|
258
258
|
maxSteps: Number.POSITIVE_INFINITY,
|
|
259
259
|
});
|
|
260
260
|
|
|
261
|
+
// Every truncated walk is marked, including one that collected nothing — a
|
|
262
|
+
// series whose first occurrence lands past the horizon. Its index is empty
|
|
263
|
+
// from the series start, which is the honest floor to write; `""` would
|
|
264
|
+
// claim the whole series is held and the live expansion would never run.
|
|
261
265
|
return {
|
|
262
266
|
occurrences: walk.occurrences,
|
|
263
|
-
expandedThrough:
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
+
expandedThrough: walk.truncated
|
|
268
|
+
? toUtcIso(
|
|
269
|
+
walk.lastIteratedStart
|
|
270
|
+
? Date.parse(walk.lastIteratedStart)
|
|
271
|
+
: seriesStart,
|
|
272
|
+
)
|
|
273
|
+
: "",
|
|
267
274
|
};
|
|
268
275
|
};
|
|
269
276
|
|
package/src/feed.test.ts
ADDED
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { describe, it } from "node:test";
|
|
3
|
+
import type {
|
|
4
|
+
CalendarCollectionItem,
|
|
5
|
+
CalendarObjectItem,
|
|
6
|
+
} from "@remit/data-ports";
|
|
7
|
+
import {
|
|
8
|
+
buildCalendarFeed,
|
|
9
|
+
CALENDAR_FEED_TOKEN_BYTES,
|
|
10
|
+
calendarFeedIsUnchanged,
|
|
11
|
+
calendarFeedIsUnmodifiedSince,
|
|
12
|
+
calendarFeedPath,
|
|
13
|
+
hashCalendarFeedToken,
|
|
14
|
+
isCalendarFeedToken,
|
|
15
|
+
mintCalendarFeedToken,
|
|
16
|
+
readCalendarFeedToken,
|
|
17
|
+
redactCalendarFeedPath,
|
|
18
|
+
} from "./feed.js";
|
|
19
|
+
import { AMSTERDAM_VTIMEZONE, ical, singleEvent } from "./fixtures.js";
|
|
20
|
+
|
|
21
|
+
const collection = (
|
|
22
|
+
overrides: Partial<CalendarCollectionItem> = {},
|
|
23
|
+
): CalendarCollectionItem =>
|
|
24
|
+
({
|
|
25
|
+
calendarId: "cal-1",
|
|
26
|
+
accountConfigId: "acc-1",
|
|
27
|
+
urlSegment: "work",
|
|
28
|
+
displayName: "Work",
|
|
29
|
+
color: "Cal1",
|
|
30
|
+
componentSet: "VeventOnly",
|
|
31
|
+
source: "UserCreated",
|
|
32
|
+
timezone: "",
|
|
33
|
+
syncSequence: 3,
|
|
34
|
+
createdAt: 1_700_000_000_000,
|
|
35
|
+
updatedAt: 1_700_000_000_000,
|
|
36
|
+
...overrides,
|
|
37
|
+
}) as CalendarCollectionItem;
|
|
38
|
+
|
|
39
|
+
const object = (
|
|
40
|
+
resourceName: string,
|
|
41
|
+
icalData: string,
|
|
42
|
+
updatedAt = 1_700_000_100_000,
|
|
43
|
+
): CalendarObjectItem =>
|
|
44
|
+
({
|
|
45
|
+
calendarObjectId: `obj-${resourceName}`,
|
|
46
|
+
calendarId: "cal-1",
|
|
47
|
+
resourceName,
|
|
48
|
+
icalData,
|
|
49
|
+
updatedAt,
|
|
50
|
+
}) as CalendarObjectItem;
|
|
51
|
+
|
|
52
|
+
describe("a feed token", () => {
|
|
53
|
+
it("is base64url over the declared number of random bytes", () => {
|
|
54
|
+
const minted = mintCalendarFeedToken();
|
|
55
|
+
|
|
56
|
+
assert.equal(
|
|
57
|
+
Buffer.from(minted.token, "base64url").length,
|
|
58
|
+
CALENDAR_FEED_TOKEN_BYTES,
|
|
59
|
+
);
|
|
60
|
+
assert.ok(isCalendarFeedToken(minted.token));
|
|
61
|
+
assert.equal(minted.tokenHash, hashCalendarFeedToken(minted.token));
|
|
62
|
+
assert.equal(minted.tokenHash.length, 64);
|
|
63
|
+
assert.equal(
|
|
64
|
+
minted.tokenHash.includes(minted.token),
|
|
65
|
+
false,
|
|
66
|
+
"the stored value is a digest, not the secret",
|
|
67
|
+
);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it("is never the same twice", () => {
|
|
71
|
+
const minted = new Set(
|
|
72
|
+
Array.from({ length: 64 }, () => mintCalendarFeedToken().token),
|
|
73
|
+
);
|
|
74
|
+
|
|
75
|
+
assert.equal(minted.size, 64);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it("refuses anything that is not the shape of one", () => {
|
|
79
|
+
for (const candidate of [
|
|
80
|
+
"",
|
|
81
|
+
"short",
|
|
82
|
+
"a".repeat(42),
|
|
83
|
+
"a".repeat(44),
|
|
84
|
+
`${"a".repeat(42)}/`,
|
|
85
|
+
`${"a".repeat(42)}.`,
|
|
86
|
+
"../../etc/passwd",
|
|
87
|
+
]) {
|
|
88
|
+
assert.equal(isCalendarFeedToken(candidate), false, candidate);
|
|
89
|
+
}
|
|
90
|
+
});
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
describe("a feed address", () => {
|
|
94
|
+
it("reads back the token it was written from", () => {
|
|
95
|
+
const minted = mintCalendarFeedToken();
|
|
96
|
+
|
|
97
|
+
assert.equal(
|
|
98
|
+
readCalendarFeedToken(calendarFeedPath(minted.token)),
|
|
99
|
+
minted.token,
|
|
100
|
+
);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it("is not a feed address without the path and the suffix", () => {
|
|
104
|
+
assert.equal(readCalendarFeedToken("/feeds/calendar/abc"), null);
|
|
105
|
+
assert.equal(readCalendarFeedToken("/calendars/abc.ics"), null);
|
|
106
|
+
assert.equal(readCalendarFeedToken("/feeds/calendar/a/b.ics"), null);
|
|
107
|
+
});
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
describe("the calendar a feed serves", () => {
|
|
111
|
+
it("names itself from the collection and terminates its last line", () => {
|
|
112
|
+
const feed = buildCalendarFeed(collection({ displayName: "Team" }), [
|
|
113
|
+
object(
|
|
114
|
+
"a.ics",
|
|
115
|
+
singleEvent("SUMMARY:Stand-up", "DTSTART:20260907T090000Z"),
|
|
116
|
+
),
|
|
117
|
+
]);
|
|
118
|
+
|
|
119
|
+
assert.match(feed.icalData, /^BEGIN:VCALENDAR\r\n/);
|
|
120
|
+
assert.match(feed.icalData, /X-WR-CALNAME:Team\r\n/);
|
|
121
|
+
assert.match(feed.icalData, /END:VCALENDAR\r\n$/);
|
|
122
|
+
assert.match(feed.icalData, /SUMMARY:Stand-up/);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it("carries the collection's zone so a client reads floating times the same way", () => {
|
|
126
|
+
const feed = buildCalendarFeed(
|
|
127
|
+
collection({ timezone: "Europe/Amsterdam" }),
|
|
128
|
+
[],
|
|
129
|
+
);
|
|
130
|
+
|
|
131
|
+
assert.match(feed.icalData, /X-WR-TIMEZONE:Europe\/Amsterdam\r\n/);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it("keeps the rule rather than the occurrences it would produce", () => {
|
|
135
|
+
const feed = buildCalendarFeed(collection(), [
|
|
136
|
+
object(
|
|
137
|
+
"a.ics",
|
|
138
|
+
singleEvent(
|
|
139
|
+
"SUMMARY:Weekly",
|
|
140
|
+
"DTSTART:20260907T090000Z",
|
|
141
|
+
"RRULE:FREQ=WEEKLY;COUNT=5",
|
|
142
|
+
),
|
|
143
|
+
),
|
|
144
|
+
]);
|
|
145
|
+
|
|
146
|
+
assert.equal(feed.icalData.match(/BEGIN:VEVENT/g)?.length, 1);
|
|
147
|
+
assert.match(feed.icalData, /RRULE:FREQ=WEEKLY;COUNT=5/);
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it("carries one copy of a VTIMEZONE two resources both declare", () => {
|
|
151
|
+
const withZone = (uid: string) =>
|
|
152
|
+
ical(
|
|
153
|
+
"BEGIN:VCALENDAR",
|
|
154
|
+
"VERSION:2.0",
|
|
155
|
+
"PRODID:-//Remit//Calendar Tests//EN",
|
|
156
|
+
...AMSTERDAM_VTIMEZONE,
|
|
157
|
+
"BEGIN:VEVENT",
|
|
158
|
+
`UID:${uid}`,
|
|
159
|
+
"DTSTAMP:20260801T090000Z",
|
|
160
|
+
"DTSTART;TZID=Europe/Amsterdam:20260907T090000",
|
|
161
|
+
"END:VEVENT",
|
|
162
|
+
"END:VCALENDAR",
|
|
163
|
+
);
|
|
164
|
+
|
|
165
|
+
const feed = buildCalendarFeed(collection(), [
|
|
166
|
+
object("a.ics", withZone("a@example.com")),
|
|
167
|
+
object("b.ics", withZone("b@example.com")),
|
|
168
|
+
]);
|
|
169
|
+
|
|
170
|
+
assert.equal(feed.icalData.match(/BEGIN:VTIMEZONE/g)?.length, 1);
|
|
171
|
+
assert.equal(feed.icalData.match(/BEGIN:VEVENT/g)?.length, 2);
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
it("declares one VERSION however many resources it gathered", () => {
|
|
175
|
+
const feed = buildCalendarFeed(collection(), [
|
|
176
|
+
object("a.ics", singleEvent("DTSTART:20260907T090000Z")),
|
|
177
|
+
object("b.ics", singleEvent("DTSTART:20260908T090000Z")),
|
|
178
|
+
]);
|
|
179
|
+
|
|
180
|
+
assert.equal(feed.icalData.match(/^VERSION:/gm)?.length, 1);
|
|
181
|
+
assert.equal(feed.icalData.match(/^PRODID:/gm)?.length, 1);
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
it("moves its tag when the bytes change and holds it when they do not", () => {
|
|
185
|
+
const one = object("a.ics", singleEvent("DTSTART:20260907T090000Z"));
|
|
186
|
+
const two = object("b.ics", singleEvent("DTSTART:20260908T090000Z"));
|
|
187
|
+
|
|
188
|
+
const first = buildCalendarFeed(collection(), [one]);
|
|
189
|
+
const again = buildCalendarFeed(collection(), [one]);
|
|
190
|
+
const grown = buildCalendarFeed(collection(), [one, two]);
|
|
191
|
+
const renamed = buildCalendarFeed(collection({ displayName: "Team" }), [
|
|
192
|
+
one,
|
|
193
|
+
]);
|
|
194
|
+
|
|
195
|
+
assert.equal(again.etag, first.etag);
|
|
196
|
+
assert.notEqual(grown.etag, first.etag);
|
|
197
|
+
assert.notEqual(renamed.etag, first.etag);
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
it("carries no modification time of its own", () => {
|
|
201
|
+
// The newest surviving event is not when the calendar last changed: a
|
|
202
|
+
// delete removes the newest one and leaves every survivor older than the
|
|
203
|
+
// change. The collection's own timestamp is what the feed serves, and it
|
|
204
|
+
// belongs to the store rather than to these bytes (issue #1067).
|
|
205
|
+
const feed = buildCalendarFeed(collection({ updatedAt: 500 }), [
|
|
206
|
+
object("a.ics", singleEvent("DTSTART:20260907T090000Z"), 900),
|
|
207
|
+
]);
|
|
208
|
+
|
|
209
|
+
assert.deepEqual(Object.keys(feed).sort(), ["etag", "icalData"]);
|
|
210
|
+
});
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
describe("a conditional poll", () => {
|
|
214
|
+
it("is unchanged for the tag it holds, quoted, weak or in a list", () => {
|
|
215
|
+
for (const header of ['"abc"', "abc", 'W/"abc"', '"other", "abc"', "*"]) {
|
|
216
|
+
assert.ok(calendarFeedIsUnchanged(header, "abc"), String(header));
|
|
217
|
+
}
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
it("is changed with no header, an empty one, or somebody else's tag", () => {
|
|
221
|
+
for (const header of [undefined, "", '"other"', 'W/"other"']) {
|
|
222
|
+
assert.equal(
|
|
223
|
+
calendarFeedIsUnchanged(header, "abc"),
|
|
224
|
+
false,
|
|
225
|
+
String(header),
|
|
226
|
+
);
|
|
227
|
+
}
|
|
228
|
+
});
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
describe("a feed path in a log line", () => {
|
|
232
|
+
it("keeps the route and drops the token", () => {
|
|
233
|
+
const minted = mintCalendarFeedToken();
|
|
234
|
+
|
|
235
|
+
const redacted = redactCalendarFeedPath(calendarFeedPath(minted.token));
|
|
236
|
+
|
|
237
|
+
assert.equal(redacted.includes(minted.token), false);
|
|
238
|
+
assert.equal(redacted, "/feeds/calendar/<redacted>.ics");
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
it("leaves a path that carries no token alone", () => {
|
|
242
|
+
for (const path of [
|
|
243
|
+
"/calendars/abc",
|
|
244
|
+
"/feeds/calendar/no-suffix",
|
|
245
|
+
"/health",
|
|
246
|
+
]) {
|
|
247
|
+
assert.equal(redactCalendarFeedPath(path), path, path);
|
|
248
|
+
}
|
|
249
|
+
});
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
describe("a poll carrying a date", () => {
|
|
253
|
+
const lastModified = Date.UTC(2026, 8, 7, 9, 0, 0);
|
|
254
|
+
|
|
255
|
+
it("is unmodified for the second it was last written in", () => {
|
|
256
|
+
// Last-Modified went out truncated to the second, so the value that comes
|
|
257
|
+
// back is 750ms behind the stored timestamp and still means "the copy I
|
|
258
|
+
// have is the one you served".
|
|
259
|
+
assert.ok(
|
|
260
|
+
calendarFeedIsUnmodifiedSince(
|
|
261
|
+
new Date(lastModified).toUTCString(),
|
|
262
|
+
lastModified + 750,
|
|
263
|
+
),
|
|
264
|
+
);
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
it("is modified once the calendar moves into a later second", () => {
|
|
268
|
+
assert.equal(
|
|
269
|
+
calendarFeedIsUnmodifiedSince(
|
|
270
|
+
new Date(lastModified).toUTCString(),
|
|
271
|
+
lastModified + 1000,
|
|
272
|
+
),
|
|
273
|
+
false,
|
|
274
|
+
);
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
it("is not a condition when the date is absent or unreadable", () => {
|
|
278
|
+
for (const header of [undefined, "", "whenever"]) {
|
|
279
|
+
assert.equal(
|
|
280
|
+
calendarFeedIsUnmodifiedSince(header, lastModified),
|
|
281
|
+
false,
|
|
282
|
+
String(header),
|
|
283
|
+
);
|
|
284
|
+
}
|
|
285
|
+
});
|
|
286
|
+
});
|
package/src/feed.ts
ADDED
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
2
|
+
import type {
|
|
3
|
+
CalendarCollectionItem,
|
|
4
|
+
CalendarObjectItem,
|
|
5
|
+
} from "@remit/data-ports";
|
|
6
|
+
import ICAL from "ical.js";
|
|
7
|
+
import { CALENDAR_PRODID } from "./build.js";
|
|
8
|
+
import { computeEtag } from "./etag.js";
|
|
9
|
+
import { serializeCalendar } from "./parse.js";
|
|
10
|
+
|
|
11
|
+
/** Random bytes behind a feed token (issue #1067). */
|
|
12
|
+
export const CALENDAR_FEED_TOKEN_BYTES = 32;
|
|
13
|
+
|
|
14
|
+
/** Where a feed lives, as the one place the shape of that path is written. */
|
|
15
|
+
export const CALENDAR_FEED_PATH_PREFIX = "/feeds/calendar/";
|
|
16
|
+
export const CALENDAR_FEED_PATH_SUFFIX = ".ics";
|
|
17
|
+
|
|
18
|
+
/** base64url over `CALENDAR_FEED_TOKEN_BYTES`, so the length is fixed. */
|
|
19
|
+
const TOKEN_LENGTH = Math.ceil((CALENDAR_FEED_TOKEN_BYTES * 4) / 3);
|
|
20
|
+
const TOKEN_SHAPE = new RegExp(`^[A-Za-z0-9_-]{${TOKEN_LENGTH}}$`);
|
|
21
|
+
|
|
22
|
+
/** A minted feed address: the secret, handed out once, and what is stored. */
|
|
23
|
+
export interface CalendarFeedSecret {
|
|
24
|
+
token: string;
|
|
25
|
+
tokenHash: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export const hashCalendarFeedToken = (token: string): string =>
|
|
29
|
+
createHash("sha256").update(token, "utf8").digest("hex");
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* A fresh feed token and the hash that will stand in for it.
|
|
33
|
+
*
|
|
34
|
+
* 32 random bytes rather than a stretched password: nothing about this value is
|
|
35
|
+
* user-chosen, so there is no dictionary to search and a slow hash would only
|
|
36
|
+
* tax every poll of every subscribed client.
|
|
37
|
+
*/
|
|
38
|
+
export const mintCalendarFeedToken = (): CalendarFeedSecret => {
|
|
39
|
+
const token = randomBytes(CALENDAR_FEED_TOKEN_BYTES).toString("base64url");
|
|
40
|
+
return { token, tokenHash: hashCalendarFeedToken(token) };
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Whether a path segment can be a feed token at all.
|
|
45
|
+
*
|
|
46
|
+
* Checked before anything is hashed or read, so a request carrying a megabyte
|
|
47
|
+
* of path or a `../` never reaches the store. A refusal here is the same 404 a
|
|
48
|
+
* revoked token gets — the caller learns nothing from the difference.
|
|
49
|
+
*/
|
|
50
|
+
export const isCalendarFeedToken = (token: string): boolean =>
|
|
51
|
+
TOKEN_SHAPE.test(token);
|
|
52
|
+
|
|
53
|
+
export const calendarFeedPath = (token: string): string =>
|
|
54
|
+
`${CALENDAR_FEED_PATH_PREFIX}${token}${CALENDAR_FEED_PATH_SUFFIX}`;
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* The token a request path carries, or `null` when the path is not a feed
|
|
58
|
+
* address. One segment only: a slash inside it would name a different route.
|
|
59
|
+
*/
|
|
60
|
+
export const readCalendarFeedToken = (path: string): string | null => {
|
|
61
|
+
if (!path.startsWith(CALENDAR_FEED_PATH_PREFIX)) return null;
|
|
62
|
+
if (!path.endsWith(CALENDAR_FEED_PATH_SUFFIX)) return null;
|
|
63
|
+
const token = path.slice(
|
|
64
|
+
CALENDAR_FEED_PATH_PREFIX.length,
|
|
65
|
+
-CALENDAR_FEED_PATH_SUFFIX.length,
|
|
66
|
+
);
|
|
67
|
+
if (token.includes("/")) return null;
|
|
68
|
+
return token;
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* The feed path with its token taken out, for a log line or a metric label.
|
|
73
|
+
*
|
|
74
|
+
* The token is the whole credential, so a path that carries one is not a field
|
|
75
|
+
* that can be recorded. Redacted rather than dropped: which route was served is
|
|
76
|
+
* what an operator reads a log for, and a blank path loses that too.
|
|
77
|
+
*/
|
|
78
|
+
export const redactCalendarFeedPath = (path: string): string =>
|
|
79
|
+
readCalendarFeedToken(path) === null
|
|
80
|
+
? path
|
|
81
|
+
: `${CALENDAR_FEED_PATH_PREFIX}<redacted>${CALENDAR_FEED_PATH_SUFFIX}`;
|
|
82
|
+
|
|
83
|
+
/** The bytes a feed serves, and what a conditional request needs to skip them. */
|
|
84
|
+
export interface CalendarFeed {
|
|
85
|
+
icalData: string;
|
|
86
|
+
etag: string;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* One VCALENDAR carrying every event in a collection.
|
|
91
|
+
*
|
|
92
|
+
* The stored components are copied across as they are: a recurring event
|
|
93
|
+
* travels as its master plus its RRULE, never as an expansion, because a
|
|
94
|
+
* subscriber renders occurrences itself and expects to keep doing so past any
|
|
95
|
+
* horizon this server would have chosen. VTIMEZONE definitions come along for
|
|
96
|
+
* the same reason — without them a client reads a TZID it cannot resolve.
|
|
97
|
+
*
|
|
98
|
+
* `X-WR-CALNAME` is a property of the calendar, not of the response: Apple
|
|
99
|
+
* Calendar, Google Calendar and Thunderbird all name a subscription from it.
|
|
100
|
+
*/
|
|
101
|
+
export const buildCalendarFeed = (
|
|
102
|
+
collection: CalendarCollectionItem,
|
|
103
|
+
objects: readonly CalendarObjectItem[],
|
|
104
|
+
): CalendarFeed => {
|
|
105
|
+
const feed = new ICAL.Component("vcalendar");
|
|
106
|
+
feed.updatePropertyWithValue("prodid", CALENDAR_PRODID);
|
|
107
|
+
feed.updatePropertyWithValue("version", "2.0");
|
|
108
|
+
feed.updatePropertyWithValue("calscale", "GREGORIAN");
|
|
109
|
+
feed.updatePropertyWithValue("x-wr-calname", collection.displayName);
|
|
110
|
+
if (collection.timezone !== "") {
|
|
111
|
+
feed.updatePropertyWithValue("x-wr-timezone", collection.timezone);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const zonesSeen = new Set<string>();
|
|
115
|
+
for (const object of objects) {
|
|
116
|
+
const stored = new ICAL.Component(ICAL.parse(object.icalData));
|
|
117
|
+
// A copy of the list, not the list: adding a component reparents it,
|
|
118
|
+
// which splices it out of the array `getAllSubcomponents` handed back and
|
|
119
|
+
// would drop whatever followed it.
|
|
120
|
+
for (const child of [...stored.getAllSubcomponents()]) {
|
|
121
|
+
if (child.name === "vtimezone") {
|
|
122
|
+
const tzid = child.getFirstPropertyValue("tzid");
|
|
123
|
+
const key = typeof tzid === "string" ? tzid : "";
|
|
124
|
+
if (zonesSeen.has(key)) continue;
|
|
125
|
+
zonesSeen.add(key);
|
|
126
|
+
}
|
|
127
|
+
feed.addSubcomponent(child);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// ical.js leaves the last content line unterminated. Every line in an
|
|
132
|
+
// iCalendar stream ends with CRLF (RFC 5545 3.1), END:VCALENDAR included.
|
|
133
|
+
const serialized = serializeCalendar(feed);
|
|
134
|
+
const icalData = serialized.endsWith("\r\n")
|
|
135
|
+
? serialized
|
|
136
|
+
: `${serialized}\r\n`;
|
|
137
|
+
return { icalData, etag: computeEtag(icalData) };
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Whether an `If-None-Match` covers the tag the feed would serve.
|
|
142
|
+
*
|
|
143
|
+
* Accepts the weak form and `*`, and reads the header as the list RFC 9110
|
|
144
|
+
* 13.1.2 says it is — a subscriber that has polled through a proxy may get its
|
|
145
|
+
* own tag back with a `W/` on it, and answering 200 to that resends the whole
|
|
146
|
+
* calendar for nothing.
|
|
147
|
+
*/
|
|
148
|
+
export const calendarFeedIsUnchanged = (
|
|
149
|
+
ifNoneMatch: string | undefined,
|
|
150
|
+
etag: string,
|
|
151
|
+
): boolean => {
|
|
152
|
+
if (ifNoneMatch === undefined || ifNoneMatch === "") return false;
|
|
153
|
+
if (ifNoneMatch.trim() === "*") return true;
|
|
154
|
+
return ifNoneMatch
|
|
155
|
+
.split(",")
|
|
156
|
+
.map((candidate) =>
|
|
157
|
+
candidate.trim().replace(/^W\//, "").replace(/^"|"$/g, ""),
|
|
158
|
+
)
|
|
159
|
+
.includes(etag);
|
|
160
|
+
};
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Whether the calendar has stood still since the date an `If-Modified-Since`
|
|
164
|
+
* carries.
|
|
165
|
+
*
|
|
166
|
+
* Compared at whole seconds, which is the resolution an HTTP-date has: a
|
|
167
|
+
* `Last-Modified` served from a millisecond timestamp is rounded down on the
|
|
168
|
+
* way out, and comparing the unrounded value against what comes back declares
|
|
169
|
+
* every calendar modified within the second it was last written.
|
|
170
|
+
*
|
|
171
|
+
* An unreadable date is not a condition (RFC 9110 13.1.3) — the full calendar
|
|
172
|
+
* is served rather than a 304 nobody asked for.
|
|
173
|
+
*/
|
|
174
|
+
export const calendarFeedIsUnmodifiedSince = (
|
|
175
|
+
ifModifiedSince: string | undefined,
|
|
176
|
+
lastModifiedAt: number,
|
|
177
|
+
): boolean => {
|
|
178
|
+
if (ifModifiedSince === undefined || ifModifiedSince === "") return false;
|
|
179
|
+
const since = Date.parse(ifModifiedSince);
|
|
180
|
+
if (Number.isNaN(since)) return false;
|
|
181
|
+
return Math.floor(lastModifiedAt / 1000) * 1000 <= since;
|
|
182
|
+
};
|
package/src/index.ts
CHANGED
|
@@ -27,6 +27,22 @@ export {
|
|
|
27
27
|
expandCalendar,
|
|
28
28
|
expandCalendarWindow,
|
|
29
29
|
} from "./expand.js";
|
|
30
|
+
export {
|
|
31
|
+
buildCalendarFeed,
|
|
32
|
+
CALENDAR_FEED_PATH_PREFIX,
|
|
33
|
+
CALENDAR_FEED_PATH_SUFFIX,
|
|
34
|
+
CALENDAR_FEED_TOKEN_BYTES,
|
|
35
|
+
type CalendarFeed,
|
|
36
|
+
type CalendarFeedSecret,
|
|
37
|
+
calendarFeedIsUnchanged,
|
|
38
|
+
calendarFeedIsUnmodifiedSince,
|
|
39
|
+
calendarFeedPath,
|
|
40
|
+
hashCalendarFeedToken,
|
|
41
|
+
isCalendarFeedToken,
|
|
42
|
+
mintCalendarFeedToken,
|
|
43
|
+
readCalendarFeedToken,
|
|
44
|
+
redactCalendarFeedPath,
|
|
45
|
+
} from "./feed.js";
|
|
30
46
|
export {
|
|
31
47
|
type ParsedCalendar,
|
|
32
48
|
parseCalendar,
|
package/src/memory-store.ts
CHANGED
|
@@ -1,21 +1,26 @@
|
|
|
1
1
|
import type {
|
|
2
2
|
CalendarCollectionItem,
|
|
3
3
|
CalendarEventIndexItem,
|
|
4
|
+
CalendarFeedTokenItem,
|
|
4
5
|
CalendarObjectItem,
|
|
5
6
|
CalendarOccurrenceInput,
|
|
6
7
|
CalendarSuggestionItem,
|
|
7
8
|
CreateCalendarCollectionInput,
|
|
8
9
|
ICalendarCollectionRepository,
|
|
9
10
|
ICalendarEventIndexRepository,
|
|
11
|
+
ICalendarFeedTokenRepository,
|
|
10
12
|
ICalendarObjectRepository,
|
|
11
13
|
ICalendarSuggestionRepository,
|
|
12
14
|
ICalendarUnitOfWork,
|
|
15
|
+
PutCalendarFeedTokenInput,
|
|
13
16
|
PutCalendarObjectInput as PutCalendarObjectRow,
|
|
14
17
|
PutCalendarSuggestionInput,
|
|
15
18
|
SettleCalendarSuggestionInput,
|
|
16
19
|
UpdateCalendarCollectionInput,
|
|
17
20
|
} from "@remit/data-ports";
|
|
21
|
+
import { NotFoundError } from "@remit/data-ports/errors";
|
|
18
22
|
import {
|
|
23
|
+
deriveCalendarFeedTokenId,
|
|
19
24
|
deriveCalendarId,
|
|
20
25
|
deriveCalendarObjectId,
|
|
21
26
|
deriveCalendarSuggestionId,
|
|
@@ -23,7 +28,11 @@ import {
|
|
|
23
28
|
} from "@remit/data-ports/id";
|
|
24
29
|
import { CalendarSuggestionState } from "@remit/domain-enums";
|
|
25
30
|
|
|
26
|
-
|
|
31
|
+
// The class the port's conformance suite requires a repository to throw for an
|
|
32
|
+
// absent row, so a caller that branches on it — the calendar feed answers 404 to
|
|
33
|
+
// a token whose collection is gone — behaves the same against this double as
|
|
34
|
+
// against a real one.
|
|
35
|
+
export class MissingRow extends NotFoundError {}
|
|
27
36
|
|
|
28
37
|
/**
|
|
29
38
|
* A pass-through unit of work over plain maps — the shape the port documents
|
|
@@ -37,6 +46,7 @@ export class MemoryCalendarStore implements ICalendarUnitOfWork {
|
|
|
37
46
|
readonly objects = new Map<string, CalendarObjectItem>();
|
|
38
47
|
readonly occurrences = new Map<string, CalendarEventIndexItem[]>();
|
|
39
48
|
readonly suggestions = new Map<string, CalendarSuggestionItem>();
|
|
49
|
+
readonly feedTokens = new Map<string, CalendarFeedTokenItem>();
|
|
40
50
|
|
|
41
51
|
private readonly collectionRepo: ICalendarCollectionRepository = {
|
|
42
52
|
create: async (input: CreateCalendarCollectionInput) => {
|
|
@@ -101,6 +111,7 @@ export class MemoryCalendarStore implements ICalendarUnitOfWork {
|
|
|
101
111
|
const bumped = {
|
|
102
112
|
...collection,
|
|
103
113
|
syncSequence: collection.syncSequence + 1,
|
|
114
|
+
updatedAt: Date.now(),
|
|
104
115
|
};
|
|
105
116
|
this.collections.set(calendarId, bumped);
|
|
106
117
|
return bumped.syncSequence;
|
|
@@ -281,6 +292,40 @@ export class MemoryCalendarStore implements ICalendarUnitOfWork {
|
|
|
281
292
|
},
|
|
282
293
|
};
|
|
283
294
|
|
|
295
|
+
private readonly feedTokenRepo: ICalendarFeedTokenRepository = {
|
|
296
|
+
put: async (input: PutCalendarFeedTokenInput) => {
|
|
297
|
+
const feedTokenId = deriveCalendarFeedTokenId(input.calendarId);
|
|
298
|
+
const now = Date.now();
|
|
299
|
+
const existing = this.feedTokens.get(feedTokenId);
|
|
300
|
+
const token: CalendarFeedTokenItem = {
|
|
301
|
+
feedTokenId,
|
|
302
|
+
accountConfigId: input.accountConfigId,
|
|
303
|
+
calendarId: input.calendarId,
|
|
304
|
+
tokenHash: input.tokenHash,
|
|
305
|
+
createdAt: existing?.createdAt ?? now,
|
|
306
|
+
rotatedAt: existing ? now : 0,
|
|
307
|
+
updatedAt: now,
|
|
308
|
+
};
|
|
309
|
+
this.feedTokens.set(feedTokenId, token);
|
|
310
|
+
return token;
|
|
311
|
+
},
|
|
312
|
+
findByCalendar: async (accountConfigId: string, calendarId: string) => {
|
|
313
|
+
const token = this.feedTokens.get(deriveCalendarFeedTokenId(calendarId));
|
|
314
|
+
if (!token || token.accountConfigId !== accountConfigId) return null;
|
|
315
|
+
return token;
|
|
316
|
+
},
|
|
317
|
+
findByTokenHash: async (tokenHash: string) =>
|
|
318
|
+
[...this.feedTokens.values()].find(
|
|
319
|
+
(token) => token.tokenHash === tokenHash,
|
|
320
|
+
) ?? null,
|
|
321
|
+
delete: async (accountConfigId: string, calendarId: string) => {
|
|
322
|
+
const feedTokenId = deriveCalendarFeedTokenId(calendarId);
|
|
323
|
+
const token = this.feedTokens.get(feedTokenId);
|
|
324
|
+
if (!token || token.accountConfigId !== accountConfigId) return;
|
|
325
|
+
this.feedTokens.delete(feedTokenId);
|
|
326
|
+
},
|
|
327
|
+
};
|
|
328
|
+
|
|
284
329
|
get calendarSuggestion(): ICalendarSuggestionRepository {
|
|
285
330
|
return this.suggestionRepo;
|
|
286
331
|
}
|
|
@@ -291,6 +336,7 @@ export class MemoryCalendarStore implements ICalendarUnitOfWork {
|
|
|
291
336
|
calendarObject: ICalendarObjectRepository;
|
|
292
337
|
calendarEventIndex: ICalendarEventIndexRepository;
|
|
293
338
|
calendarSuggestion: ICalendarSuggestionRepository;
|
|
339
|
+
calendarFeedToken: ICalendarFeedTokenRepository;
|
|
294
340
|
}) => Promise<T>,
|
|
295
341
|
): Promise<T> {
|
|
296
342
|
return fn({
|
|
@@ -298,6 +344,7 @@ export class MemoryCalendarStore implements ICalendarUnitOfWork {
|
|
|
298
344
|
calendarObject: this.objectRepo,
|
|
299
345
|
calendarEventIndex: this.eventIndexRepo,
|
|
300
346
|
calendarSuggestion: this.suggestionRepo,
|
|
347
|
+
calendarFeedToken: this.feedTokenRepo,
|
|
301
348
|
});
|
|
302
349
|
}
|
|
303
350
|
}
|
package/src/window.test.ts
CHANGED
|
@@ -245,6 +245,52 @@ describe("listCalendarInstances", () => {
|
|
|
245
245
|
assert.equal(instances[0]?.summary, "Weekly one-to-one");
|
|
246
246
|
});
|
|
247
247
|
|
|
248
|
+
it("expands a series whose index is empty because its first occurrence is past the horizon", async () => {
|
|
249
|
+
const calendar = collection();
|
|
250
|
+
// The rule's own first instance is excluded, so the first occurrence is in
|
|
251
|
+
// 2029 and the horizon closed in 2027. The index holds no row at all, and
|
|
252
|
+
// the only thing that can serve the event is the live expansion.
|
|
253
|
+
const distant = await store(
|
|
254
|
+
calendar,
|
|
255
|
+
"distant.ics",
|
|
256
|
+
ical(
|
|
257
|
+
"BEGIN:VCALENDAR",
|
|
258
|
+
"VERSION:2.0",
|
|
259
|
+
"BEGIN:VEVENT",
|
|
260
|
+
"UID:distant@example.com",
|
|
261
|
+
"DTSTART:20250301T090000Z",
|
|
262
|
+
"DTEND:20250301T100000Z",
|
|
263
|
+
"SUMMARY:Leap day review",
|
|
264
|
+
"RRULE:FREQ=YEARLY;INTERVAL=4",
|
|
265
|
+
"EXDATE:20250301T090000Z",
|
|
266
|
+
"END:VEVENT",
|
|
267
|
+
"END:VCALENDAR",
|
|
268
|
+
),
|
|
269
|
+
);
|
|
270
|
+
|
|
271
|
+
assert.deepEqual(distant.rows, [], "the index really is empty");
|
|
272
|
+
assert.deepEqual(
|
|
273
|
+
await repositories([distant]).calendarObject.listIncompleteExpansions(
|
|
274
|
+
calendar.calendarId,
|
|
275
|
+
"2029-04-01T00:00:00Z",
|
|
276
|
+
),
|
|
277
|
+
[distant.object],
|
|
278
|
+
"and the resource is still offered to the live expansion",
|
|
279
|
+
);
|
|
280
|
+
|
|
281
|
+
const instances = await listCalendarInstances(
|
|
282
|
+
repositories([distant]),
|
|
283
|
+
[calendar],
|
|
284
|
+
{ from: "2029-03-01T00:00:00Z", to: "2029-04-01T00:00:00Z" },
|
|
285
|
+
);
|
|
286
|
+
|
|
287
|
+
assert.deepEqual(
|
|
288
|
+
instances.map((instance) => instance.startAt),
|
|
289
|
+
["2029-03-01T09:00:00Z"],
|
|
290
|
+
);
|
|
291
|
+
assert.equal(instances[0]?.summary, "Leap day review");
|
|
292
|
+
});
|
|
293
|
+
|
|
248
294
|
it("serves a live-expanded series from one source, never twice", async () => {
|
|
249
295
|
const calendar = collection();
|
|
250
296
|
const openEnded = await store(
|