@remit/backend 0.0.143 → 0.0.145
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/dev-server/content-handler.d.ts +38 -0
- package/dist/dev-server/content-handler.js +46 -0
- package/dist/src/api.d.ts +5 -0
- package/dist/src/api.js +73 -0
- package/dist/src/auth.d.ts +24 -0
- package/dist/src/auth.js +81 -0
- package/dist/src/config/api-config.d.ts +20 -0
- package/dist/src/config/api-config.js +1 -0
- package/dist/src/config/from-env.d.ts +2 -0
- package/dist/src/config/from-env.js +65 -0
- package/dist/src/config/msoauth-secrets-manager.d.ts +2 -0
- package/dist/src/config/msoauth-secrets-manager.js +28 -0
- package/dist/src/config/msoauth.d.ts +19 -0
- package/dist/src/config/msoauth.js +6 -0
- package/{src/derive/autoMoved.ts → dist/src/derive/autoMoved.d.ts} +1 -29
- package/dist/src/derive/autoMoved.js +49 -0
- package/dist/src/derive/contentSignature.d.ts +40 -0
- package/{src/derive/contentSignature.ts → dist/src/derive/contentSignature.js} +23 -77
- package/dist/src/derive/contentUrl.d.ts +33 -0
- package/dist/src/derive/contentUrl.js +27 -0
- package/{src/derive/deriveMuted.ts → dist/src/derive/deriveMuted.d.ts} +1 -3
- package/dist/src/derive/deriveMuted.js +7 -0
- package/dist/src/derive/enrichThreadRows.d.ts +118 -0
- package/dist/src/derive/enrichThreadRows.js +151 -0
- package/dist/src/derive/filterThreadCriteria.d.ts +23 -0
- package/dist/src/derive/filterThreadCriteria.js +24 -0
- package/{src/derive/pendingMoveCounts.ts → dist/src/derive/pendingMoveCounts.d.ts} +5 -53
- package/dist/src/derive/pendingMoveCounts.js +53 -0
- package/{src/derive/senderTrust.ts → dist/src/derive/senderTrust.d.ts} +1 -9
- package/dist/src/derive/senderTrust.js +19 -0
- package/dist/src/error.d.ts +21 -0
- package/dist/src/error.js +85 -0
- package/dist/src/handlers/account-guards.d.ts +36 -0
- package/dist/src/handlers/account-guards.js +107 -0
- package/dist/src/handlers/account-oauth.d.ts +15 -0
- package/dist/src/handlers/account-oauth.js +334 -0
- package/dist/src/handlers/account-overrides.d.ts +53 -0
- package/dist/src/handlers/account-overrides.js +233 -0
- package/dist/src/handlers/account-signature.d.ts +28 -0
- package/dist/src/handlers/account-signature.js +85 -0
- package/dist/src/handlers/account.d.ts +7 -0
- package/dist/src/handlers/account.js +337 -0
- package/dist/src/handlers/address.d.ts +35 -0
- package/dist/src/handlers/address.js +133 -0
- package/dist/src/handlers/calendar-event.d.ts +70 -0
- package/dist/src/handlers/calendar-event.js +417 -0
- package/dist/src/handlers/calendar-feed.d.ts +47 -0
- package/dist/src/handlers/calendar-feed.js +184 -0
- package/dist/src/handlers/calendar-suggestion.d.ts +47 -0
- package/dist/src/handlers/calendar-suggestion.js +204 -0
- package/dist/src/handlers/calendar.d.ts +116 -0
- package/dist/src/handlers/calendar.js +323 -0
- package/dist/src/handlers/config.d.ts +43 -0
- package/dist/src/handlers/config.js +164 -0
- package/dist/src/handlers/ensure-account-config.d.ts +9 -0
- package/dist/src/handlers/ensure-account-config.js +27 -0
- package/dist/src/handlers/filter.d.ts +3 -0
- package/dist/src/handlers/filter.js +88 -0
- package/dist/src/handlers/folder-role-appointments.d.ts +68 -0
- package/dist/src/handlers/folder-role-appointments.js +168 -0
- package/dist/src/handlers/folder-role-labels.d.ts +26 -0
- package/dist/src/handlers/folder-role-labels.js +40 -0
- package/dist/src/handlers/folder-role.d.ts +9 -0
- package/dist/src/handlers/folder-role.js +37 -0
- package/dist/src/handlers/index.d.ts +88 -0
- package/dist/src/handlers/index.js +62 -0
- package/dist/src/handlers/label.d.ts +43 -0
- package/dist/src/handlers/label.js +107 -0
- package/dist/src/handlers/mailbox-bindings.d.ts +41 -0
- package/dist/src/handlers/mailbox-bindings.js +61 -0
- package/dist/src/handlers/mailbox.d.ts +23 -0
- package/dist/src/handlers/mailbox.js +211 -0
- package/dist/src/handlers/me.d.ts +2 -0
- package/dist/src/handlers/me.js +117 -0
- package/dist/src/handlers/message.d.ts +82 -0
- package/dist/src/handlers/message.js +595 -0
- package/dist/src/handlers/organize.d.ts +10 -0
- package/dist/src/handlers/organize.js +103 -0
- package/dist/src/handlers/outbox-attachment.d.ts +25 -0
- package/dist/src/handlers/outbox-attachment.js +77 -0
- package/dist/src/handlers/outbox.d.ts +4 -0
- package/dist/src/handlers/outbox.js +136 -0
- package/dist/src/handlers/sync-progress.d.ts +46 -0
- package/dist/src/handlers/sync-progress.js +63 -0
- package/dist/src/handlers/sync.d.ts +28 -0
- package/dist/src/handlers/sync.js +73 -0
- package/dist/src/handlers/system-update.d.ts +2 -0
- package/dist/src/handlers/system-update.js +159 -0
- package/dist/src/handlers/thread.d.ts +148 -0
- package/dist/src/handlers/thread.js +228 -0
- package/dist/src/handlers/unified-threads.d.ts +247 -0
- package/dist/src/handlers/unified-threads.js +406 -0
- package/{src/handlers/vip-suggestions.ts → dist/src/handlers/vip-suggestions.d.ts} +1 -13
- package/dist/src/handlers/vip-suggestions.js +16 -0
- package/dist/src/index.d.ts +5 -0
- package/dist/src/index.js +53 -0
- package/{src/json.ts → dist/src/json.d.ts} +1 -4
- package/dist/src/json.js +10 -0
- package/dist/src/jwt-auth.d.ts +15 -0
- package/dist/src/jwt-auth.js +45 -0
- package/dist/src/lambda.d.ts +13 -0
- package/dist/src/lambda.js +33 -0
- package/dist/src/request.d.ts +9 -0
- package/dist/src/request.js +17 -0
- package/dist/src/response.d.ts +44 -0
- package/dist/src/response.js +80 -0
- package/dist/src/serve.d.ts +31 -0
- package/dist/src/serve.js +117 -0
- package/dist/src/service/account-deletion.d.ts +20 -0
- package/dist/src/service/account-deletion.js +33 -0
- package/dist/src/service/compose-sqlite.d.ts +3 -0
- package/dist/src/service/compose-sqlite.js +13 -0
- package/dist/src/service/create-remit-client.d.ts +109 -0
- package/dist/src/service/create-remit-client.js +188 -0
- package/dist/src/service/data-client.d.ts +12 -0
- package/dist/src/service/data-client.js +42 -0
- package/dist/src/service/export-identity.d.ts +4 -0
- package/dist/src/service/export-identity.js +33 -0
- package/dist/src/service/fire-and-forget.d.ts +54 -0
- package/dist/src/service/fire-and-forget.js +37 -0
- package/dist/src/service/organize.d.ts +181 -0
- package/dist/src/service/organize.js +367 -0
- package/dist/src/service/queues.d.ts +12 -0
- package/dist/src/service/queues.js +1 -0
- package/dist/src/service/refusal.d.ts +4 -0
- package/dist/src/service/refusal.js +20 -0
- package/dist/src/service/sender-category.d.ts +51 -0
- package/{src/service/sender-category.ts → dist/src/service/sender-category.js} +40 -81
- package/dist/src/service/shared-deps.d.ts +6 -0
- package/dist/src/service/shared-deps.js +24 -0
- package/dist/src/service/trigger-sync.d.ts +57 -0
- package/dist/src/service/trigger-sync.js +31 -0
- package/dist/src/types.d.ts +72 -0
- package/dist/src/types.js +1 -0
- package/package.json +125 -98
- package/.env.test +0 -4
- package/dev-server/content-auth.test.ts +0 -113
- package/dev-server/content-auth.ts +0 -54
- package/dev-server/content-handler.test.ts +0 -105
- package/dev-server/content-handler.ts +0 -79
- package/dev-server/content-path.test.ts +0 -37
- package/dev-server/content-path.ts +0 -27
- package/dev-server/cors.test.ts +0 -48
- package/dev-server/cors.ts +0 -25
- package/dev-server/keep-alive.test.ts +0 -24
- package/dev-server/lambda-helpers.ts +0 -69
- package/dev-server/log-output.test.ts +0 -173
- package/dev-server/oauth-callback-route.test.ts +0 -66
- package/dev-server/relational-health.test.ts +0 -83
- package/dev-server/relational-health.ts +0 -39
- package/dev-server/server.ts +0 -384
- package/dev-server/sync-age.test.ts +0 -91
- package/dev-server/sync-age.ts +0 -55
- package/dev-server/upload-handler.ts +0 -132
- package/dev-server/upload-route.test.ts +0 -196
- package/scripts/backfill-classification.ts +0 -91
- package/scripts/backfill-list-id.ts +0 -83
- package/scripts/config-save.ts +0 -128
- package/src/auth.test.ts +0 -12
- package/src/auth.ts +0 -92
- package/src/config/msoauth.test.ts +0 -48
- package/src/config/msoauth.ts +0 -48
- package/src/data-backend.test.ts +0 -23
- package/src/data-backend.ts +0 -14
- package/src/derive/autoMoved.test.ts +0 -104
- package/src/derive/contentSignature.test.ts +0 -161
- package/src/derive/contentUrl.test.ts +0 -172
- package/src/derive/contentUrl.ts +0 -86
- package/src/derive/deriveMuted.test.ts +0 -34
- package/src/derive/enrichThreadRows.test.ts +0 -360
- package/src/derive/enrichThreadRows.ts +0 -234
- package/src/derive/filterThreadCriteria.test.ts +0 -126
- package/src/derive/filterThreadCriteria.ts +0 -51
- package/src/derive/senderTrust.test.ts +0 -67
- package/src/error.test.ts +0 -201
- package/src/error.ts +0 -123
- package/src/handlers/account-guards.test.ts +0 -67
- package/src/handlers/account-guards.ts +0 -169
- package/src/handlers/account-oauth-callback.test.ts +0 -155
- package/src/handlers/account-oauth-consent.test.ts +0 -336
- package/src/handlers/account-oauth-start.test.ts +0 -191
- package/src/handlers/account-oauth.test.ts +0 -439
- package/src/handlers/account-oauth.ts +0 -635
- package/src/handlers/account-overrides.test.ts +0 -88
- package/src/handlers/account-overrides.ts +0 -323
- package/src/handlers/account-ownership.ts +0 -25
- package/src/handlers/account-signature.ts +0 -117
- package/src/handlers/account-synced-services.test.ts +0 -245
- package/src/handlers/account.ts +0 -552
- package/src/handlers/address.test.ts +0 -381
- package/src/handlers/address.ts +0 -200
- package/src/handlers/calendar-event.test.ts +0 -685
- package/src/handlers/calendar-event.ts +0 -738
- package/src/handlers/calendar-feed-validators.test.ts +0 -228
- package/src/handlers/calendar-feed.test.ts +0 -439
- package/src/handlers/calendar-feed.ts +0 -291
- package/src/handlers/calendar-sqlite-fixture.ts +0 -36
- package/src/handlers/calendar-suggestion.test.ts +0 -1269
- package/src/handlers/calendar-suggestion.ts +0 -425
- package/src/handlers/calendar.test.ts +0 -1080
- package/src/handlers/calendar.ts +0 -517
- package/src/handlers/config.test.ts +0 -245
- package/src/handlers/config.ts +0 -332
- package/src/handlers/ensure-account-config.ts +0 -30
- package/src/handlers/filter.test.ts +0 -644
- package/src/handlers/filter.ts +0 -518
- package/src/handlers/folder-role-appointments.test.ts +0 -438
- package/src/handlers/folder-role-appointments.ts +0 -249
- package/src/handlers/folder-role-labels.ts +0 -63
- package/src/handlers/folder-role.test.ts +0 -209
- package/src/handlers/folder-role.ts +0 -80
- package/src/handlers/index.ts +0 -83
- package/src/handlers/label.test.ts +0 -112
- package/src/handlers/label.ts +0 -210
- package/src/handlers/mailbox-bindings.ts +0 -111
- package/src/handlers/mailbox-rename-refusal.test.ts +0 -247
- package/src/handlers/mailbox-settled-target.test.ts +0 -251
- package/src/handlers/mailbox.test.ts +0 -280
- package/src/handlers/mailbox.ts +0 -581
- package/src/handlers/me.test.ts +0 -81
- package/src/handlers/me.ts +0 -203
- package/src/handlers/message-read-backfill.test.ts +0 -103
- package/src/handlers/message.test.ts +0 -131
- package/src/handlers/message.ts +0 -1131
- package/src/handlers/organize.test.ts +0 -277
- package/src/handlers/organize.ts +0 -197
- package/src/handlers/outbox-attachment.test.ts +0 -765
- package/src/handlers/outbox-attachment.ts +0 -126
- package/src/handlers/outbox-list.test.ts +0 -179
- package/src/handlers/outbox.test.ts +0 -772
- package/src/handlers/outbox.ts +0 -258
- package/src/handlers/search.test.ts +0 -161
- package/src/handlers/search.ts +0 -105
- package/src/handlers/sync-progress.test.ts +0 -205
- package/src/handlers/sync-progress.ts +0 -99
- package/src/handlers/sync.test.ts +0 -168
- package/src/handlers/sync.ts +0 -118
- package/src/handlers/system-update.test.ts +0 -497
- package/src/handlers/system-update.ts +0 -230
- package/src/handlers/thread.test.ts +0 -396
- package/src/handlers/thread.ts +0 -427
- package/src/handlers/unified-threads-mail-sources.sqlite.test.ts +0 -197
- package/src/handlers/unified-threads.test.ts +0 -785
- package/src/handlers/unified-threads.ts +0 -669
- package/src/index.test.ts +0 -83
- package/src/index.ts +0 -228
- package/src/jwt-auth.test.ts +0 -152
- package/src/jwt-auth.ts +0 -132
- package/src/log-redaction.test.ts +0 -144
- package/src/openapi-spec.test.ts +0 -118
- package/src/request-context.test.ts +0 -61
- package/src/request-context.ts +0 -33
- package/src/request-pipeline.test.ts +0 -88
- package/src/request.ts +0 -10
- package/src/response.test.ts +0 -130
- package/src/response.ts +0 -144
- package/src/service/compose-sqlite.ts +0 -98
- package/src/service/config-import.ts +0 -39
- package/src/service/create-remit-client.ts +0 -522
- package/src/service/data-client.test.ts +0 -97
- package/src/service/data-client.ts +0 -58
- package/src/service/export-identity.ts +0 -39
- package/src/service/filter.ts +0 -40
- package/src/service/fire-and-forget.test.ts +0 -126
- package/src/service/fire-and-forget.ts +0 -79
- package/src/service/localhost-env-config.test.ts +0 -36
- package/src/service/organize-queue.ts +0 -98
- package/src/service/organize.test.ts +0 -1451
- package/src/service/organize.ts +0 -1025
- package/src/service/read-path-filter-config.test.ts +0 -243
- package/src/service/semantic-capability.test.ts +0 -78
- package/src/service/semantic-capability.ts +0 -95
- package/src/service/sender-category.test.ts +0 -235
- package/src/service/sqs.ts +0 -4
- package/src/service/trigger-sync.test.ts +0 -205
- package/src/service/trigger-sync.ts +0 -100
- package/src/stdout-capture-fixture.ts +0 -40
- package/src/types.ts +0 -271
- package/tsconfig.json +0 -7
|
@@ -0,0 +1,417 @@
|
|
|
1
|
+
import { applyScopedDelete, applyScopedUpdate, buildEventCalendar, deleteCalendarObject, followingUidOf, listBusySpans, listCalendarInstances, listObjectInstances, OFFSET_BEARING, parseCalendar, prepareCalendarObject, putCalendarObject, readWrittenObject, toOffsetIso, toUtcIso, } from "@remit/calendar-service";
|
|
2
|
+
import { base36uuid } from "@remit/data-ports/id";
|
|
3
|
+
import { RecurrenceScope } from "@remit/domain-enums";
|
|
4
|
+
import { badRequest, calendarDepsOf, findCalendarFor, findWritableCalendarFor, listCalendarsFor, notFound, preconditionFailed, refuseCalendar, } from "./calendar.js";
|
|
5
|
+
/**
|
|
6
|
+
* The widest window a single read may ask for. A year covers every view the
|
|
7
|
+
* client has — a year grid is the coarsest — and keeps one request from asking
|
|
8
|
+
* the server to expand a decade of a daily series.
|
|
9
|
+
*/
|
|
10
|
+
export const CALENDAR_MAX_WINDOW_DAYS = 366;
|
|
11
|
+
const MAX_WINDOW_MS = CALENDAR_MAX_WINDOW_DAYS * 24 * 60 * 60 * 1000;
|
|
12
|
+
export const calendarEventDepsOf = (deps) => ({
|
|
13
|
+
...deps,
|
|
14
|
+
newId: base36uuid,
|
|
15
|
+
now: () => new Date(),
|
|
16
|
+
});
|
|
17
|
+
/**
|
|
18
|
+
* Reads the window a listing asked for.
|
|
19
|
+
*
|
|
20
|
+
* Both ends must carry an offset, because a window with no offset names no
|
|
21
|
+
* span of time — and a client whose `from` was read an hour out gets an
|
|
22
|
+
* apparently empty morning rather than an error.
|
|
23
|
+
*/
|
|
24
|
+
export const readWindow = (from, to) => {
|
|
25
|
+
if (typeof from !== "string" || typeof to !== "string") {
|
|
26
|
+
return refuseCalendar("InvalidWindow", "a listing needs both `from` and `to`");
|
|
27
|
+
}
|
|
28
|
+
const fromMs = Date.parse(from);
|
|
29
|
+
const toMs = Date.parse(to);
|
|
30
|
+
if (Number.isNaN(fromMs) ||
|
|
31
|
+
Number.isNaN(toMs) ||
|
|
32
|
+
!OFFSET_BEARING.test(from) ||
|
|
33
|
+
!OFFSET_BEARING.test(to)) {
|
|
34
|
+
return refuseCalendar("InvalidWindow", "`from` and `to` must be ISO 8601 date-times with a zone offset");
|
|
35
|
+
}
|
|
36
|
+
if (toMs <= fromMs) {
|
|
37
|
+
return refuseCalendar("InvalidWindow", "`to` must come after `from`");
|
|
38
|
+
}
|
|
39
|
+
if (toMs - fromMs > MAX_WINDOW_MS) {
|
|
40
|
+
return refuseCalendar("InvalidWindow", `a window may cover at most ${CALENDAR_MAX_WINDOW_DAYS} days`);
|
|
41
|
+
}
|
|
42
|
+
return { ok: true, value: { from: toUtcIso(fromMs), to: toUtcIso(toMs) } };
|
|
43
|
+
};
|
|
44
|
+
/** The collections a listing covers, defaulting to every one the caller holds. */
|
|
45
|
+
export const collectionsForListing = async (deps, accountConfigId, calendarIds) => {
|
|
46
|
+
const held = await listCalendarsFor(deps, accountConfigId);
|
|
47
|
+
if (calendarIds.length === 0)
|
|
48
|
+
return { ok: true, value: held };
|
|
49
|
+
const selected = held.filter((collection) => calendarIds.includes(collection.calendarId));
|
|
50
|
+
const missing = calendarIds.find((calendarId) => !held.some((collection) => collection.calendarId === calendarId));
|
|
51
|
+
if (missing) {
|
|
52
|
+
return refuseCalendar("NotFound", `no calendar ${missing} on this account`);
|
|
53
|
+
}
|
|
54
|
+
return { ok: true, value: selected };
|
|
55
|
+
};
|
|
56
|
+
const toInstanceResponse = (instance) => ({
|
|
57
|
+
calendarId: instance.calendarId,
|
|
58
|
+
calendarObjectId: instance.calendarObjectId,
|
|
59
|
+
recurrenceId: instance.recurrenceId,
|
|
60
|
+
icalUid: instance.icalUid,
|
|
61
|
+
summary: instance.summary,
|
|
62
|
+
start: instance.start,
|
|
63
|
+
end: instance.end,
|
|
64
|
+
allDay: instance.allDay,
|
|
65
|
+
status: instance.status,
|
|
66
|
+
transparency: instance.transparency,
|
|
67
|
+
zoneCertainty: instance.zoneCertainty,
|
|
68
|
+
etag: instance.etag,
|
|
69
|
+
hasRecurrence: instance.hasRecurrence,
|
|
70
|
+
});
|
|
71
|
+
const toEventResponse = (object) => ({
|
|
72
|
+
calendarObjectId: object.calendarObjectId,
|
|
73
|
+
calendarId: object.calendarId,
|
|
74
|
+
resourceName: object.resourceName,
|
|
75
|
+
icalUid: object.icalUid,
|
|
76
|
+
icalData: object.icalData,
|
|
77
|
+
etag: object.etag,
|
|
78
|
+
sequence: object.sequence,
|
|
79
|
+
syncSequence: object.syncSequence,
|
|
80
|
+
summary: object.summary,
|
|
81
|
+
dtStart: object.dtStart,
|
|
82
|
+
dtEnd: object.dtEnd,
|
|
83
|
+
allDay: object.allDay,
|
|
84
|
+
zoneCertainty: object.zoneCertainty,
|
|
85
|
+
status: object.status,
|
|
86
|
+
transparency: object.transparency,
|
|
87
|
+
hasRecurrence: object.hasRecurrence,
|
|
88
|
+
expandedThrough: object.expandedThrough,
|
|
89
|
+
createdAt: object.createdAt,
|
|
90
|
+
updatedAt: object.updatedAt,
|
|
91
|
+
});
|
|
92
|
+
const eventFieldsOf = (input) => ({
|
|
93
|
+
summary: input.summary,
|
|
94
|
+
description: input.description ?? "",
|
|
95
|
+
location: input.location ?? "",
|
|
96
|
+
start: input.start,
|
|
97
|
+
end: input.end,
|
|
98
|
+
allDay: input.allDay ?? false,
|
|
99
|
+
timeZone: input.timeZone ?? "",
|
|
100
|
+
status: input.status ?? "Confirmed",
|
|
101
|
+
transparency: input.transparency ?? "Opaque",
|
|
102
|
+
recurrenceRule: input.recurrenceRule ?? "",
|
|
103
|
+
});
|
|
104
|
+
/**
|
|
105
|
+
* Reduce a PATCH body to the event fields an update may set, preserving
|
|
106
|
+
* absence: a body carrying only `summary` yields only `summary`, and the
|
|
107
|
+
* resource's times are then left exactly as they were.
|
|
108
|
+
*/
|
|
109
|
+
export const pickEventUpdate = (body) => {
|
|
110
|
+
const patch = {};
|
|
111
|
+
const fields = [
|
|
112
|
+
"summary",
|
|
113
|
+
"description",
|
|
114
|
+
"location",
|
|
115
|
+
"start",
|
|
116
|
+
"end",
|
|
117
|
+
"allDay",
|
|
118
|
+
"timeZone",
|
|
119
|
+
"status",
|
|
120
|
+
"transparency",
|
|
121
|
+
"recurrenceRule",
|
|
122
|
+
];
|
|
123
|
+
for (const field of fields) {
|
|
124
|
+
if (!Object.hasOwn(body, field))
|
|
125
|
+
continue;
|
|
126
|
+
Object.assign(patch, { [field]: body[field] });
|
|
127
|
+
}
|
|
128
|
+
return patch;
|
|
129
|
+
};
|
|
130
|
+
/**
|
|
131
|
+
* Whether an `If-Match` header lets the write through.
|
|
132
|
+
*
|
|
133
|
+
* No header is no precondition, which is what HTTP says and what a first-party
|
|
134
|
+
* client that just read the resource wants. `*` matches any existing resource.
|
|
135
|
+
* Quotes and a weak-validator prefix are transport spelling and are stripped
|
|
136
|
+
* before comparing, because the stored tag carries neither.
|
|
137
|
+
*/
|
|
138
|
+
export const etagMatches = (ifMatch, etag) => {
|
|
139
|
+
if (ifMatch === undefined || ifMatch === "")
|
|
140
|
+
return true;
|
|
141
|
+
return ifMatch
|
|
142
|
+
.split(",")
|
|
143
|
+
.map((candidate) => candidate.trim().replace(/^W\//, "").replace(/"/g, ""))
|
|
144
|
+
.some((candidate) => candidate === "*" || candidate === etag);
|
|
145
|
+
};
|
|
146
|
+
/**
|
|
147
|
+
* An absent scope means the whole series, which is what a client editing an
|
|
148
|
+
* event that does not recur sends. A scope that is present but not one of the
|
|
149
|
+
* three is refused rather than read as `All`: the widest, most destructive
|
|
150
|
+
* reading is the worst possible answer to a typo.
|
|
151
|
+
*/
|
|
152
|
+
export const readScope = (value) => {
|
|
153
|
+
if (value === undefined || value === "") {
|
|
154
|
+
return { ok: true, value: RecurrenceScope.All };
|
|
155
|
+
}
|
|
156
|
+
if (value === RecurrenceScope.This ||
|
|
157
|
+
value === RecurrenceScope.Following ||
|
|
158
|
+
value === RecurrenceScope.All) {
|
|
159
|
+
return { ok: true, value };
|
|
160
|
+
}
|
|
161
|
+
return refuseCalendar("InvalidScope", `scope must be one of This, Following or All, and this request sent "${String(value)}"`);
|
|
162
|
+
};
|
|
163
|
+
/**
|
|
164
|
+
* Writes what a scoped edit resolved to.
|
|
165
|
+
*
|
|
166
|
+
* A split is two resources and one write set: the truncated original and the
|
|
167
|
+
* remainder land together or not at all, because half a split is a series the
|
|
168
|
+
* user sees twice or not at all.
|
|
169
|
+
*/
|
|
170
|
+
export const commitScopedWrite = async (deps, accountConfigId, object, collectionTimezone, write) => {
|
|
171
|
+
if (write.kind === "Delete") {
|
|
172
|
+
await deleteCalendarObject(deps.calendarStore, {
|
|
173
|
+
accountConfigId,
|
|
174
|
+
calendarId: object.calendarId,
|
|
175
|
+
calendarObjectId: object.calendarObjectId,
|
|
176
|
+
});
|
|
177
|
+
return { ok: true, value: null };
|
|
178
|
+
}
|
|
179
|
+
// Both resources are prepared before either is written, so a refusal of
|
|
180
|
+
// either one commits nothing: half a split is never stored.
|
|
181
|
+
const zone = { timezone: collectionTimezone };
|
|
182
|
+
const head = await prepareCalendarObject(zone, {
|
|
183
|
+
accountConfigId,
|
|
184
|
+
calendarId: object.calendarId,
|
|
185
|
+
resourceName: object.resourceName,
|
|
186
|
+
icalData: write.icalData,
|
|
187
|
+
});
|
|
188
|
+
if (!head.ok)
|
|
189
|
+
return head;
|
|
190
|
+
if (write.kind === "Replace") {
|
|
191
|
+
await deps.calendarStore.commit([head.value]);
|
|
192
|
+
return {
|
|
193
|
+
ok: true,
|
|
194
|
+
value: await readWrittenObject(deps.calendarStore, head.value),
|
|
195
|
+
};
|
|
196
|
+
}
|
|
197
|
+
const following = await prepareCalendarObject(zone, {
|
|
198
|
+
accountConfigId,
|
|
199
|
+
calendarId: object.calendarId,
|
|
200
|
+
resourceName: `${deps.newId()}.ics`,
|
|
201
|
+
icalData: write.following,
|
|
202
|
+
});
|
|
203
|
+
if (!following.ok)
|
|
204
|
+
return following;
|
|
205
|
+
await deps.calendarStore.commit([head.value, following.value]);
|
|
206
|
+
// The remainder is what the patch applied to, and it lives under an id the
|
|
207
|
+
// caller has never seen — returning the truncated head instead would leave
|
|
208
|
+
// them holding the resource their edit is not in.
|
|
209
|
+
return {
|
|
210
|
+
ok: true,
|
|
211
|
+
value: await readWrittenObject(deps.calendarStore, following.value),
|
|
212
|
+
};
|
|
213
|
+
};
|
|
214
|
+
export const createCalendarEventFor = async (deps, accountConfigId, input) => {
|
|
215
|
+
const collection = await findWritableCalendarFor(deps, accountConfigId, input.calendarId);
|
|
216
|
+
if (!collection.ok)
|
|
217
|
+
return collection;
|
|
218
|
+
const id = deps.newId();
|
|
219
|
+
const built = await buildEventCalendar(eventFieldsOf(input), `${id}@reader.remit`, deps.now());
|
|
220
|
+
if (!built.ok)
|
|
221
|
+
return built;
|
|
222
|
+
return putCalendarObject(deps.calendarStore, {
|
|
223
|
+
accountConfigId,
|
|
224
|
+
calendarId: collection.value.calendarId,
|
|
225
|
+
resourceName: `${id}.ics`,
|
|
226
|
+
icalData: built.value,
|
|
227
|
+
});
|
|
228
|
+
};
|
|
229
|
+
const resolveResource = async (deps, accountConfigId, request, intent) => {
|
|
230
|
+
const collection = intent === "write"
|
|
231
|
+
? await findWritableCalendarFor(deps, accountConfigId, request.calendarId)
|
|
232
|
+
: await findCalendarFor(deps, accountConfigId, request.calendarId);
|
|
233
|
+
if (!collection.ok)
|
|
234
|
+
return collection;
|
|
235
|
+
const object = await deps.calendarObject.find(request.calendarId, request.calendarObjectId);
|
|
236
|
+
if (!object) {
|
|
237
|
+
return refuseCalendar("NotFound", `no event ${request.calendarObjectId} in this calendar`);
|
|
238
|
+
}
|
|
239
|
+
return { ok: true, value: { collection: collection.value, object } };
|
|
240
|
+
};
|
|
241
|
+
export const updateCalendarEventFor = async (deps, accountConfigId, request, patch) => {
|
|
242
|
+
const resolved = await resolveResource(deps, accountConfigId, request, "write");
|
|
243
|
+
if (!resolved.ok)
|
|
244
|
+
return resolved;
|
|
245
|
+
const { collection, object } = resolved.value;
|
|
246
|
+
if (!etagMatches(request.ifMatch, object.etag)) {
|
|
247
|
+
return refuseCalendar("EtagMismatch", "this event has been written since you read it — read it again and reapply the change");
|
|
248
|
+
}
|
|
249
|
+
const parsed = await parseCalendar(object.icalData);
|
|
250
|
+
if (!parsed.ok) {
|
|
251
|
+
throw new Error(`stored calendar object ${object.calendarObjectId} no longer parses: ${parsed.error.message}`);
|
|
252
|
+
}
|
|
253
|
+
const write = await applyScopedUpdate(parsed.value, collection.timezone, {
|
|
254
|
+
scope: request.scope,
|
|
255
|
+
recurrenceId: request.recurrenceId,
|
|
256
|
+
followingUid: followingUidOf(object.icalUid, `${deps.newId()}@reader.remit`),
|
|
257
|
+
}, patch);
|
|
258
|
+
if (!write.ok)
|
|
259
|
+
return write;
|
|
260
|
+
return commitScopedWrite(deps, accountConfigId, object, collection.timezone, write.value);
|
|
261
|
+
};
|
|
262
|
+
export const deleteCalendarEventFor = async (deps, accountConfigId, request) => {
|
|
263
|
+
const resolved = await resolveResource(deps, accountConfigId, request, "write");
|
|
264
|
+
if (!resolved.ok)
|
|
265
|
+
return resolved;
|
|
266
|
+
const { collection, object } = resolved.value;
|
|
267
|
+
if (!etagMatches(request.ifMatch, object.etag)) {
|
|
268
|
+
return refuseCalendar("EtagMismatch", "this event has been written since you read it — read it again and reapply the change");
|
|
269
|
+
}
|
|
270
|
+
const parsed = await parseCalendar(object.icalData);
|
|
271
|
+
if (!parsed.ok) {
|
|
272
|
+
throw new Error(`stored calendar object ${object.calendarObjectId} no longer parses: ${parsed.error.message}`);
|
|
273
|
+
}
|
|
274
|
+
const write = await applyScopedDelete(parsed.value, collection.timezone, {
|
|
275
|
+
scope: request.scope,
|
|
276
|
+
recurrenceId: request.recurrenceId,
|
|
277
|
+
followingUid: "",
|
|
278
|
+
});
|
|
279
|
+
if (!write.ok)
|
|
280
|
+
return write;
|
|
281
|
+
return commitScopedWrite(deps, accountConfigId, object, collection.timezone, write.value);
|
|
282
|
+
};
|
|
283
|
+
/** The collection holding a resource, found among the ones given. */
|
|
284
|
+
const locateResource = async (deps, collections, calendarObjectId) => {
|
|
285
|
+
for (const collection of collections) {
|
|
286
|
+
const object = await deps.calendarObject.find(collection.calendarId, calendarObjectId);
|
|
287
|
+
if (object)
|
|
288
|
+
return { collection, object };
|
|
289
|
+
}
|
|
290
|
+
return undefined;
|
|
291
|
+
};
|
|
292
|
+
const readCalendarIds = (value) => {
|
|
293
|
+
if (typeof value === "string")
|
|
294
|
+
return value === "" ? [] : [value];
|
|
295
|
+
if (Array.isArray(value))
|
|
296
|
+
return value.filter((id) => typeof id === "string");
|
|
297
|
+
return [];
|
|
298
|
+
};
|
|
299
|
+
const answerRefusal = (error) => error.code === "NotFound"
|
|
300
|
+
? notFound(error.message)
|
|
301
|
+
: error.code === "EtagMismatch"
|
|
302
|
+
? preconditionFailed(error.message)
|
|
303
|
+
: badRequest(error);
|
|
304
|
+
const scopedRequestOf = (context) => {
|
|
305
|
+
const params = context.request.params;
|
|
306
|
+
const query = context.request.query;
|
|
307
|
+
const scope = readScope(query.scope);
|
|
308
|
+
if (!scope.ok)
|
|
309
|
+
return scope;
|
|
310
|
+
const headers = (context.request.headers ?? {});
|
|
311
|
+
const ifMatch = Object.entries(headers).find(([name]) => name.toLowerCase() === "if-match")?.[1];
|
|
312
|
+
return {
|
|
313
|
+
ok: true,
|
|
314
|
+
value: {
|
|
315
|
+
calendarId: query.calendarId ?? "",
|
|
316
|
+
calendarObjectId: params.calendarObjectId,
|
|
317
|
+
scope: scope.value,
|
|
318
|
+
recurrenceId: query.recurrenceId ?? "",
|
|
319
|
+
ifMatch,
|
|
320
|
+
},
|
|
321
|
+
};
|
|
322
|
+
};
|
|
323
|
+
export const CalendarEventOperations = {
|
|
324
|
+
CalendarEventOperations_listCalendarEvents: async (context, ctx) => {
|
|
325
|
+
const accountConfigId = ctx.identity.accountConfigId;
|
|
326
|
+
const query = context.request.query;
|
|
327
|
+
const window = readWindow(query.from, query.to);
|
|
328
|
+
if (!window.ok)
|
|
329
|
+
return badRequest(window.error);
|
|
330
|
+
const deps = calendarDepsOf(ctx.client);
|
|
331
|
+
const collections = await collectionsForListing(deps, accountConfigId, readCalendarIds(query.calendarId));
|
|
332
|
+
if (!collections.ok)
|
|
333
|
+
return answerRefusal(collections.error);
|
|
334
|
+
if (query.calendarObjectId) {
|
|
335
|
+
const located = await locateResource(deps, collections.value, query.calendarObjectId);
|
|
336
|
+
if (!located)
|
|
337
|
+
return { items: [] };
|
|
338
|
+
const own = await listObjectInstances(deps, located.collection, located.object, window.value);
|
|
339
|
+
return { items: own.map(toInstanceResponse) };
|
|
340
|
+
}
|
|
341
|
+
const instances = await listCalendarInstances(deps, collections.value, window.value);
|
|
342
|
+
return { items: instances.map(toInstanceResponse) };
|
|
343
|
+
},
|
|
344
|
+
CalendarEventOperations_createCalendarEvent: async (context, ctx) => {
|
|
345
|
+
const accountConfigId = ctx.identity.accountConfigId;
|
|
346
|
+
const input = context.request.requestBody;
|
|
347
|
+
const deps = calendarEventDepsOf(calendarDepsOf(ctx.client));
|
|
348
|
+
const created = await createCalendarEventFor(deps, accountConfigId, input);
|
|
349
|
+
if (!created.ok)
|
|
350
|
+
return answerRefusal(created.error);
|
|
351
|
+
return toEventResponse(created.value);
|
|
352
|
+
},
|
|
353
|
+
};
|
|
354
|
+
export const CalendarEventDetailOperations = {
|
|
355
|
+
CalendarEventDetailOperations_getCalendarEvent: async (context, ctx) => {
|
|
356
|
+
const accountConfigId = ctx.identity.accountConfigId;
|
|
357
|
+
const request = scopedRequestOf(context);
|
|
358
|
+
if (!request.ok)
|
|
359
|
+
return badRequest(request.error);
|
|
360
|
+
const deps = calendarEventDepsOf(calendarDepsOf(ctx.client));
|
|
361
|
+
if (request.value.calendarId === "") {
|
|
362
|
+
const located = await locateResource(deps, await listCalendarsFor(deps, accountConfigId), request.value.calendarObjectId);
|
|
363
|
+
if (!located) {
|
|
364
|
+
return notFound(`no event ${request.value.calendarObjectId} on this account`);
|
|
365
|
+
}
|
|
366
|
+
return toEventResponse(located.object);
|
|
367
|
+
}
|
|
368
|
+
const resolved = await resolveResource(deps, accountConfigId, request.value, "read");
|
|
369
|
+
if (!resolved.ok)
|
|
370
|
+
return answerRefusal(resolved.error);
|
|
371
|
+
return toEventResponse(resolved.value.object);
|
|
372
|
+
},
|
|
373
|
+
CalendarEventDetailOperations_updateCalendarEvent: async (context, ctx) => {
|
|
374
|
+
const accountConfigId = ctx.identity.accountConfigId;
|
|
375
|
+
const body = context.request
|
|
376
|
+
.requestBody;
|
|
377
|
+
const request = scopedRequestOf(context);
|
|
378
|
+
if (!request.ok)
|
|
379
|
+
return badRequest(request.error);
|
|
380
|
+
const deps = calendarEventDepsOf(calendarDepsOf(ctx.client));
|
|
381
|
+
const updated = await updateCalendarEventFor(deps, accountConfigId, request.value, pickEventUpdate(body));
|
|
382
|
+
if (!updated.ok)
|
|
383
|
+
return answerRefusal(updated.error);
|
|
384
|
+
if (!updated.value) {
|
|
385
|
+
throw new Error("a scoped update resolved to a delete");
|
|
386
|
+
}
|
|
387
|
+
return toEventResponse(updated.value);
|
|
388
|
+
},
|
|
389
|
+
CalendarEventDetailOperations_deleteCalendarEvent: async (context, ctx) => {
|
|
390
|
+
const accountConfigId = ctx.identity.accountConfigId;
|
|
391
|
+
const request = scopedRequestOf(context);
|
|
392
|
+
if (!request.ok)
|
|
393
|
+
return badRequest(request.error);
|
|
394
|
+
const deps = calendarEventDepsOf(calendarDepsOf(ctx.client));
|
|
395
|
+
const removed = await deleteCalendarEventFor(deps, accountConfigId, request.value);
|
|
396
|
+
if (!removed.ok)
|
|
397
|
+
return answerRefusal(removed.error);
|
|
398
|
+
return { statusCode: 204 };
|
|
399
|
+
},
|
|
400
|
+
};
|
|
401
|
+
export const CalendarFreeBusyOperations = {
|
|
402
|
+
CalendarFreeBusyOperations_listCalendarFreeBusy: async (context, ctx) => {
|
|
403
|
+
const accountConfigId = ctx.identity.accountConfigId;
|
|
404
|
+
const query = context.request.query;
|
|
405
|
+
const window = readWindow(query.from, query.to);
|
|
406
|
+
if (!window.ok)
|
|
407
|
+
return badRequest(window.error);
|
|
408
|
+
const deps = calendarDepsOf(ctx.client);
|
|
409
|
+
const collections = await listCalendarsFor(deps, accountConfigId);
|
|
410
|
+
const spans = await listBusySpans(deps, collections, window.value);
|
|
411
|
+
const items = spans.map((span) => ({
|
|
412
|
+
start: toOffsetIso(span.startMs, "UTC"),
|
|
413
|
+
end: toOffsetIso(span.endMs, "UTC"),
|
|
414
|
+
}));
|
|
415
|
+
return { items };
|
|
416
|
+
},
|
|
417
|
+
};
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import type { CalendarFeedResponse, CalendarFeedStateResponse } from "@remit/api-openapi-types";
|
|
2
|
+
import { type RawApiResponse } from "../response.js";
|
|
3
|
+
import type { CalendarDetailOperationIds, CalendarFeedDetailOperationIds, OperationHandler, PublicOperationHandler } from "../types.js";
|
|
4
|
+
import { type CalendarDeps, type CalendarOutcome } from "./calendar.js";
|
|
5
|
+
/**
|
|
6
|
+
* Mints the calendar's feed address, or replaces the one it has.
|
|
7
|
+
*
|
|
8
|
+
* The plaintext exists here and nowhere else: the store keeps a digest, so this
|
|
9
|
+
* return value is the only chance anybody has to read the token. Ownership is
|
|
10
|
+
* settled before the mint — a calendar the caller does not hold is absent, not
|
|
11
|
+
* forbidden, which is the same answer `getCalendar` gives it.
|
|
12
|
+
*/
|
|
13
|
+
export declare const putCalendarFeedFor: (deps: CalendarDeps, accountConfigId: string, calendarId: string) => Promise<CalendarOutcome<CalendarFeedResponse>>;
|
|
14
|
+
/** Whether the calendar has a feed address, and how old it is. */
|
|
15
|
+
export declare const findCalendarFeedFor: (deps: CalendarDeps, accountConfigId: string, calendarId: string) => Promise<CalendarOutcome<CalendarFeedStateResponse>>;
|
|
16
|
+
/**
|
|
17
|
+
* Revokes the calendar's feed address.
|
|
18
|
+
*
|
|
19
|
+
* A calendar with no feed is refused rather than answered 204: the caller asked
|
|
20
|
+
* for a subscription to stop working, and reporting success for one that was
|
|
21
|
+
* never there reads as confirmation that it has.
|
|
22
|
+
*/
|
|
23
|
+
export declare const revokeCalendarFeedFor: (deps: CalendarDeps, accountConfigId: string, calendarId: string) => Promise<CalendarOutcome<null>>;
|
|
24
|
+
/**
|
|
25
|
+
* Serves a calendar to whoever holds its secret address.
|
|
26
|
+
*
|
|
27
|
+
* The token is the whole credential, so a value that cannot be one is refused
|
|
28
|
+
* before anything is hashed, and the digest of what arrived is what the store is
|
|
29
|
+
* asked about — the plaintext is never compared against a stored one because
|
|
30
|
+
* none is stored.
|
|
31
|
+
*
|
|
32
|
+
* `Last-Modified` is the collection's own timestamp, not the newest event in
|
|
33
|
+
* it: an event deleted from a calendar leaves every survivor older than the
|
|
34
|
+
* change, and a validator that moves backwards tells a subscriber its cached
|
|
35
|
+
* copy is still good. The collection is stamped by the same sequence bump every
|
|
36
|
+
* write and every delete goes through, so it only ever moves forward.
|
|
37
|
+
*
|
|
38
|
+
* A row whose collection is gone is the same 404. It should not exist — a
|
|
39
|
+
* calendar's delete takes its token with it, in one batch — and serving a
|
|
40
|
+
* blank calendar for one would be worse than saying nothing.
|
|
41
|
+
*/
|
|
42
|
+
export declare const serveCalendarFeed: (deps: CalendarDeps, feedToken: string, conditions: {
|
|
43
|
+
ifNoneMatch: string | undefined;
|
|
44
|
+
ifModifiedSince: string | undefined;
|
|
45
|
+
}) => Promise<RawApiResponse>;
|
|
46
|
+
export declare const CalendarFeedDetailOperations: Record<CalendarFeedDetailOperationIds, OperationHandler<CalendarDetailOperationIds>>;
|
|
47
|
+
export declare const getCalendarFeedIcal: PublicOperationHandler;
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
import { buildCalendarFeed, calendarFeedIsUnchanged, calendarFeedIsUnmodifiedSince, hashCalendarFeedToken, isCalendarFeedToken, mintCalendarFeedToken, readCalendarFeedToken, } from "@remit/calendar-service";
|
|
2
|
+
import { NotFoundError } from "@remit/data-ports/errors";
|
|
3
|
+
import { rawApiResponse } from "../response.js";
|
|
4
|
+
import { calendarDepsOf, findCalendarFor, notFound, refuseCalendar, } from "./calendar.js";
|
|
5
|
+
const toFeedState = (token) => ({
|
|
6
|
+
calendarId: token.calendarId,
|
|
7
|
+
createdAt: token.createdAt,
|
|
8
|
+
rotatedAt: token.rotatedAt,
|
|
9
|
+
});
|
|
10
|
+
/**
|
|
11
|
+
* Mints the calendar's feed address, or replaces the one it has.
|
|
12
|
+
*
|
|
13
|
+
* The plaintext exists here and nowhere else: the store keeps a digest, so this
|
|
14
|
+
* return value is the only chance anybody has to read the token. Ownership is
|
|
15
|
+
* settled before the mint — a calendar the caller does not hold is absent, not
|
|
16
|
+
* forbidden, which is the same answer `getCalendar` gives it.
|
|
17
|
+
*/
|
|
18
|
+
export const putCalendarFeedFor = async (deps, accountConfigId, calendarId) => {
|
|
19
|
+
const calendar = await findCalendarFor(deps, accountConfigId, calendarId);
|
|
20
|
+
if (!calendar.ok)
|
|
21
|
+
return calendar;
|
|
22
|
+
const secret = mintCalendarFeedToken();
|
|
23
|
+
const stored = await deps.calendarFeedToken.put({
|
|
24
|
+
accountConfigId,
|
|
25
|
+
calendarId,
|
|
26
|
+
tokenHash: secret.tokenHash,
|
|
27
|
+
});
|
|
28
|
+
return {
|
|
29
|
+
ok: true,
|
|
30
|
+
value: {
|
|
31
|
+
calendarId: stored.calendarId,
|
|
32
|
+
feedToken: secret.token,
|
|
33
|
+
createdAt: stored.createdAt,
|
|
34
|
+
rotatedAt: stored.rotatedAt,
|
|
35
|
+
},
|
|
36
|
+
};
|
|
37
|
+
};
|
|
38
|
+
/** Whether the calendar has a feed address, and how old it is. */
|
|
39
|
+
export const findCalendarFeedFor = async (deps, accountConfigId, calendarId) => {
|
|
40
|
+
const calendar = await findCalendarFor(deps, accountConfigId, calendarId);
|
|
41
|
+
if (!calendar.ok)
|
|
42
|
+
return calendar;
|
|
43
|
+
const stored = await deps.calendarFeedToken.findByCalendar(accountConfigId, calendarId);
|
|
44
|
+
if (!stored) {
|
|
45
|
+
return refuseCalendar("NotFound", `no feed on calendar ${calendarId}`);
|
|
46
|
+
}
|
|
47
|
+
return { ok: true, value: toFeedState(stored) };
|
|
48
|
+
};
|
|
49
|
+
/**
|
|
50
|
+
* Revokes the calendar's feed address.
|
|
51
|
+
*
|
|
52
|
+
* A calendar with no feed is refused rather than answered 204: the caller asked
|
|
53
|
+
* for a subscription to stop working, and reporting success for one that was
|
|
54
|
+
* never there reads as confirmation that it has.
|
|
55
|
+
*/
|
|
56
|
+
export const revokeCalendarFeedFor = async (deps, accountConfigId, calendarId) => {
|
|
57
|
+
const calendar = await findCalendarFor(deps, accountConfigId, calendarId);
|
|
58
|
+
if (!calendar.ok)
|
|
59
|
+
return calendar;
|
|
60
|
+
const stored = await deps.calendarFeedToken.findByCalendar(accountConfigId, calendarId);
|
|
61
|
+
if (!stored) {
|
|
62
|
+
return refuseCalendar("NotFound", `no feed on calendar ${calendarId}`);
|
|
63
|
+
}
|
|
64
|
+
await deps.calendarFeedToken.delete(accountConfigId, calendarId);
|
|
65
|
+
return { ok: true, value: null };
|
|
66
|
+
};
|
|
67
|
+
/**
|
|
68
|
+
* No feed answers to this address.
|
|
69
|
+
*
|
|
70
|
+
* One answer for an unknown token, a revoked one and a malformed one, with a
|
|
71
|
+
* body that says the same thing to all three: a caller probing addresses learns
|
|
72
|
+
* only whether the one it sent works, which is what it was going to find out
|
|
73
|
+
* anyway.
|
|
74
|
+
*/
|
|
75
|
+
const feedNotFound = () => rawApiResponse({
|
|
76
|
+
statusCode: 404,
|
|
77
|
+
headers: { "Content-Type": "text/plain; charset=utf-8" },
|
|
78
|
+
body: "no calendar feed at this address\n",
|
|
79
|
+
});
|
|
80
|
+
/**
|
|
81
|
+
* Serves a calendar to whoever holds its secret address.
|
|
82
|
+
*
|
|
83
|
+
* The token is the whole credential, so a value that cannot be one is refused
|
|
84
|
+
* before anything is hashed, and the digest of what arrived is what the store is
|
|
85
|
+
* asked about — the plaintext is never compared against a stored one because
|
|
86
|
+
* none is stored.
|
|
87
|
+
*
|
|
88
|
+
* `Last-Modified` is the collection's own timestamp, not the newest event in
|
|
89
|
+
* it: an event deleted from a calendar leaves every survivor older than the
|
|
90
|
+
* change, and a validator that moves backwards tells a subscriber its cached
|
|
91
|
+
* copy is still good. The collection is stamped by the same sequence bump every
|
|
92
|
+
* write and every delete goes through, so it only ever moves forward.
|
|
93
|
+
*
|
|
94
|
+
* A row whose collection is gone is the same 404. It should not exist — a
|
|
95
|
+
* calendar's delete takes its token with it, in one batch — and serving a
|
|
96
|
+
* blank calendar for one would be worse than saying nothing.
|
|
97
|
+
*/
|
|
98
|
+
export const serveCalendarFeed = async (deps, feedToken, conditions) => {
|
|
99
|
+
if (!isCalendarFeedToken(feedToken))
|
|
100
|
+
return feedNotFound();
|
|
101
|
+
const stored = await deps.calendarFeedToken.findByTokenHash(hashCalendarFeedToken(feedToken));
|
|
102
|
+
if (!stored)
|
|
103
|
+
return feedNotFound();
|
|
104
|
+
const collection = await deps.calendarCollection
|
|
105
|
+
.get(stored.accountConfigId, stored.calendarId)
|
|
106
|
+
.catch((error) => {
|
|
107
|
+
if (error instanceof NotFoundError)
|
|
108
|
+
return null;
|
|
109
|
+
throw error;
|
|
110
|
+
});
|
|
111
|
+
if (!collection)
|
|
112
|
+
return feedNotFound();
|
|
113
|
+
const objects = await deps.calendarObject.listByCalendar(stored.calendarId);
|
|
114
|
+
const feed = buildCalendarFeed(collection, objects);
|
|
115
|
+
const etag = `"${feed.etag}"`;
|
|
116
|
+
const lastModified = new Date(collection.updatedAt).toUTCString();
|
|
117
|
+
// `If-None-Match` wins outright when both arrive (RFC 9110 13.2.2): the tag
|
|
118
|
+
// is exact where the date is truncated to the second, so honouring the date
|
|
119
|
+
// as well could only turn a 200 the tag asked for into a 304.
|
|
120
|
+
const unchanged = conditions.ifNoneMatch === undefined
|
|
121
|
+
? calendarFeedIsUnmodifiedSince(conditions.ifModifiedSince, collection.updatedAt)
|
|
122
|
+
: calendarFeedIsUnchanged(conditions.ifNoneMatch, feed.etag);
|
|
123
|
+
if (unchanged) {
|
|
124
|
+
return rawApiResponse({
|
|
125
|
+
statusCode: 304,
|
|
126
|
+
headers: { ETag: etag, "Last-Modified": lastModified },
|
|
127
|
+
body: "",
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
return rawApiResponse({
|
|
131
|
+
statusCode: 200,
|
|
132
|
+
headers: {
|
|
133
|
+
"Content-Type": "text/calendar; charset=utf-8",
|
|
134
|
+
ETag: etag,
|
|
135
|
+
"Last-Modified": lastModified,
|
|
136
|
+
},
|
|
137
|
+
body: feed.icalData,
|
|
138
|
+
});
|
|
139
|
+
};
|
|
140
|
+
export const CalendarFeedDetailOperations = {
|
|
141
|
+
CalendarDetailOperations_getCalendarFeed: async (context, ctx) => {
|
|
142
|
+
const accountConfigId = ctx.identity.accountConfigId;
|
|
143
|
+
const { calendarId } = context.request.params;
|
|
144
|
+
const deps = calendarDepsOf(ctx.client);
|
|
145
|
+
const found = await findCalendarFeedFor(deps, accountConfigId, calendarId);
|
|
146
|
+
if (!found.ok)
|
|
147
|
+
return notFound(found.error.message);
|
|
148
|
+
return found.value;
|
|
149
|
+
},
|
|
150
|
+
CalendarDetailOperations_putCalendarFeed: async (context, ctx) => {
|
|
151
|
+
const accountConfigId = ctx.identity.accountConfigId;
|
|
152
|
+
const { calendarId } = context.request.params;
|
|
153
|
+
const deps = calendarDepsOf(ctx.client);
|
|
154
|
+
const minted = await putCalendarFeedFor(deps, accountConfigId, calendarId);
|
|
155
|
+
if (!minted.ok)
|
|
156
|
+
return notFound(minted.error.message);
|
|
157
|
+
return minted.value;
|
|
158
|
+
},
|
|
159
|
+
CalendarDetailOperations_revokeCalendarFeed: async (context, ctx) => {
|
|
160
|
+
const accountConfigId = ctx.identity.accountConfigId;
|
|
161
|
+
const { calendarId } = context.request.params;
|
|
162
|
+
const deps = calendarDepsOf(ctx.client);
|
|
163
|
+
const revoked = await revokeCalendarFeedFor(deps, accountConfigId, calendarId);
|
|
164
|
+
if (!revoked.ok)
|
|
165
|
+
return notFound(revoked.error.message);
|
|
166
|
+
return { statusCode: 204 };
|
|
167
|
+
},
|
|
168
|
+
};
|
|
169
|
+
export const getCalendarFeedIcal = async (context, ctx) => {
|
|
170
|
+
// From the raw path, not from the router's captured parameter: the matcher
|
|
171
|
+
// substitutes the parameter into the literal path and leaves the dot of
|
|
172
|
+
// ".ics" as a regex dot, so /feeds/calendar/<token>Xics routes here too —
|
|
173
|
+
// and answering it would serve the feed at an address nobody was handed.
|
|
174
|
+
const feedToken = readCalendarFeedToken(context.request.path);
|
|
175
|
+
if (feedToken === null)
|
|
176
|
+
return feedNotFound();
|
|
177
|
+
const ifNoneMatch = context.request.headers["if-none-match"];
|
|
178
|
+
const ifModifiedSince = context.request.headers["if-modified-since"];
|
|
179
|
+
const deps = calendarDepsOf(ctx.client);
|
|
180
|
+
return serveCalendarFeed(deps, feedToken, {
|
|
181
|
+
ifNoneMatch: typeof ifNoneMatch === "string" ? ifNoneMatch : undefined,
|
|
182
|
+
ifModifiedSince: typeof ifModifiedSince === "string" ? ifModifiedSince : undefined,
|
|
183
|
+
});
|
|
184
|
+
};
|