@remit/calendar-service 0.0.3 → 0.0.4

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@remit/calendar-service",
3
- "version": "0.0.3",
3
+ "version": "0.0.4",
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": {
@@ -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,
@@ -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
- export class MissingRow extends Error {}
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
  }