@oneuptime/common 12.0.29 → 12.0.30

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.
Files changed (189) hide show
  1. package/Models/DatabaseModels/Index.ts +11 -0
  2. package/Models/DatabaseModels/OnCallDutyPolicySchedule.ts +43 -0
  3. package/Models/DatabaseModels/OnCallDutyPolicyScheduleCalendarFeed.ts +662 -0
  4. package/Models/DatabaseModels/ProjectOnCallCalendarFeed.ts +584 -0
  5. package/Models/DatabaseModels/UserOnCallCalendarFeed.ts +546 -0
  6. package/Models/DatabaseModels/UserOnCallShiftReminder.ts +231 -0
  7. package/Models/DatabaseModels/UserOnCallShiftReminderLog.ts +331 -0
  8. package/Server/API/OnCallCalendarAPI.ts +2194 -0
  9. package/Server/EnvironmentConfig.ts +138 -0
  10. package/Server/Infrastructure/OnCallCalendarFeedCache.ts +1050 -0
  11. package/Server/Infrastructure/Postgres/SchemaMigrations/1790400000000-AddOnCallCalendarFeeds.ts +253 -0
  12. package/Server/Infrastructure/Postgres/SchemaMigrations/Index.ts +2 -0
  13. package/Server/Middleware/OnCallCalendarFeedRateLimit.ts +487 -0
  14. package/Server/Services/Index.ts +12 -0
  15. package/Server/Services/OnCallDutyPolicyEscalationRuleScheduleService.ts +32 -0
  16. package/Server/Services/OnCallDutyPolicyScheduleCalendarFeedService.ts +260 -0
  17. package/Server/Services/OnCallDutyPolicyScheduleLayerService.ts +70 -3
  18. package/Server/Services/OnCallDutyPolicyScheduleLayerUserService.ts +138 -3
  19. package/Server/Services/OnCallDutyPolicyScheduleService.ts +1465 -134
  20. package/Server/Services/OnCallDutyPolicyUserOverrideService.ts +167 -31
  21. package/Server/Services/ProjectOnCallCalendarFeedService.ts +192 -0
  22. package/Server/Services/TeamMemberService.ts +489 -1
  23. package/Server/Services/TeamService.ts +50 -0
  24. package/Server/Services/UserNotificationSettingService.ts +44 -1
  25. package/Server/Services/UserOnCallCalendarFeedService.ts +159 -0
  26. package/Server/Services/UserOnCallShiftReminderLogService.ts +151 -0
  27. package/Server/Services/UserOnCallShiftReminderService.ts +112 -0
  28. package/Server/Utils/OnCall/CalendarFeedToken.ts +241 -0
  29. package/Server/Utils/OnCall/OnCallCalendarFeedRenderer.ts +1488 -0
  30. package/Server/Utils/OnCall/OnCallCalendarFeedUrls.ts +230 -0
  31. package/Server/Utils/OnCall/OnCallShiftChangeListeners.ts +227 -0
  32. package/Server/Utils/OnCall/OnCallShiftMaterializer.ts +1127 -0
  33. package/Server/Utils/OnCall/OnCallShiftReminderListener.ts +174 -0
  34. package/Server/Utils/OnCall/OnCallShiftReminderRunner.ts +2584 -0
  35. package/Server/Utils/Response.ts +134 -0
  36. package/Server/Utils/StartServer.ts +10 -0
  37. package/Server/Utils/Telemetry/AppMetrics.ts +51 -0
  38. package/Tests/App/Dashboard/OnCallCalendarFeedLinks.test.tsx +229 -0
  39. package/Tests/App/Dashboard/OnCallCalendarFeedPlanGate.test.tsx +489 -0
  40. package/Tests/App/Dashboard/OnCallCalendarFeedSideMenus.test.tsx +149 -0
  41. package/Tests/App/Dashboard/OnCallCalendarFeedUtil.test.ts +855 -0
  42. package/Tests/App/Dashboard/OnCallPersonalCalendarFeed.test.tsx +1209 -0
  43. package/Tests/App/Dashboard/OnCallSharedCalendarFeedCard.test.tsx +1019 -0
  44. package/Tests/App/Dashboard/OnCallShiftRemindersAndUpcomingShifts.test.tsx +655 -0
  45. package/Tests/App/Dashboard/UserSettingsSetupChecklistModel.test.ts +73 -0
  46. package/Tests/Models/OnCallCalendarFeedModels.test.ts +642 -0
  47. package/Tests/Models/OnCallDutyPolicyScheduleCalendarFeed.test.ts +288 -0
  48. package/Tests/Models/OnCallDutyPolicyScheduleShiftConfigVersion.test.ts +88 -0
  49. package/Tests/Models/UserOnCallShiftReminderModels.test.ts +368 -0
  50. package/Tests/Server/API/Helpers.ts +8 -0
  51. package/Tests/Server/API/OnCallCalendarAPI.test.ts +4461 -0
  52. package/Tests/Server/EnvironmentConfigOnCallCalendarFeed.test.ts +357 -0
  53. package/Tests/Server/Infrastructure/OnCallCalendarFeedCache.test.ts +1589 -0
  54. package/Tests/Server/Infrastructure/Postgres/AddOnCallCalendarFeedsMigration.test.ts +369 -0
  55. package/Tests/Server/Middleware/OnCallCalendarFeedRateLimit.test.ts +1365 -0
  56. package/Tests/Server/Services/FeedRetentionConsistency.test.ts +70 -0
  57. package/Tests/Server/Services/OnCallCalendarFeedServices.test.ts +1103 -0
  58. package/Tests/Server/Services/OnCallDutyPolicyScheduleResolver.test.ts +1037 -0
  59. package/Tests/Server/Services/OnCallShiftConfigPropagation.test.ts +1270 -0
  60. package/Tests/Server/Services/TeamDeleteMemberCleanup.test.ts +156 -0
  61. package/Tests/Server/Services/TeamMemberOnCallCleanup.test.ts +755 -0
  62. package/Tests/Server/Services/UserNotificationSettingShiftReminderDefaults.test.ts +267 -0
  63. package/Tests/Server/Services/UserOnCallShiftReminderServices.test.ts +583 -0
  64. package/Tests/Server/Types/Database/Permissions/OnCallCalendarFeedPermissions.test.ts +254 -0
  65. package/Tests/Server/Utils/OnCall/CalendarFeedToken.test.ts +467 -0
  66. package/Tests/Server/Utils/OnCall/OnCallCalendarFeedRenderer.test.ts +2142 -0
  67. package/Tests/Server/Utils/OnCall/OnCallCalendarFeedUrls.test.ts +278 -0
  68. package/Tests/Server/Utils/OnCall/OnCallResolverTestHarness.ts +440 -0
  69. package/Tests/Server/Utils/OnCall/OnCallShiftChangeListeners.test.ts +314 -0
  70. package/Tests/Server/Utils/OnCall/OnCallShiftMaterializer.test.ts +1502 -0
  71. package/Tests/Server/Utils/OnCall/OnCallShiftReminderListener.test.ts +491 -0
  72. package/Tests/Server/Utils/OnCall/OnCallShiftReminderRunner.test.ts +3088 -0
  73. package/Tests/Server/Utils/OnCall/OnCallShiftReminderTestHarness.ts +910 -0
  74. package/Tests/Server/Utils/ResponseSendCalendarResponse.test.ts +561 -0
  75. package/Tests/Server/Utils/StartServerEncryptionSecretWarning.test.ts +64 -0
  76. package/Tests/Server/Utils/Telemetry/AppMetrics.test.ts +29 -0
  77. package/Tests/Types/Calendar/ICalendar.test.ts +488 -0
  78. package/Tests/Types/NotificationSetting/NotificationSettingEventType.test.ts +53 -0
  79. package/Tests/Types/OnCallDutyPolicy/CalendarFeedTestFixtures.ts +310 -0
  80. package/Tests/Types/OnCallDutyPolicy/CalendarFeedWindow.test.ts +205 -0
  81. package/Tests/Types/OnCallDutyPolicy/LayerUtilLayerMetaAndIterationCap.test.ts +458 -0
  82. package/Tests/Types/OnCallDutyPolicy/MaterializedShift.test.ts +197 -0
  83. package/Tests/Types/OnCallDutyPolicy/OnCallCalendarFeedUtil.test.ts +1986 -0
  84. package/Tests/Types/OnCallDutyPolicy/ScheduleShiftUtilGroupKey.test.ts +678 -0
  85. package/Tests/Types/OnCallDutyPolicy/ShiftSeamUtil.test.ts +417 -0
  86. package/Tests/UI/Utils/Breadcrumb/fixtures/RealBreadcrumbTrails.ts +10 -0
  87. package/Tests/UI/Utils/Breadcrumb/fixtures/RealRoutePatterns.ts +2 -0
  88. package/Types/Calendar/ICalendar.ts +442 -0
  89. package/Types/Email/EmailTemplateType.ts +2 -0
  90. package/Types/NotificationSetting/NotificationSettingEventType.ts +4 -0
  91. package/Types/OnCallDutyPolicy/CalendarFeedWindow.ts +140 -0
  92. package/Types/OnCallDutyPolicy/Layer.ts +190 -35
  93. package/Types/OnCallDutyPolicy/MaterializedShift.ts +274 -0
  94. package/Types/OnCallDutyPolicy/OnCallCalendarFeedUtil.ts +1415 -0
  95. package/Types/OnCallDutyPolicy/ScheduleShiftUtil.ts +159 -4
  96. package/Types/OnCallDutyPolicy/ShiftSeamUtil.ts +106 -0
  97. package/build/dist/Models/DatabaseModels/Index.js +11 -0
  98. package/build/dist/Models/DatabaseModels/Index.js.map +1 -1
  99. package/build/dist/Models/DatabaseModels/OnCallDutyPolicySchedule.js +44 -0
  100. package/build/dist/Models/DatabaseModels/OnCallDutyPolicySchedule.js.map +1 -1
  101. package/build/dist/Models/DatabaseModels/OnCallDutyPolicyScheduleCalendarFeed.js +699 -0
  102. package/build/dist/Models/DatabaseModels/OnCallDutyPolicyScheduleCalendarFeed.js.map +1 -0
  103. package/build/dist/Models/DatabaseModels/ProjectOnCallCalendarFeed.js +620 -0
  104. package/build/dist/Models/DatabaseModels/ProjectOnCallCalendarFeed.js.map +1 -0
  105. package/build/dist/Models/DatabaseModels/UserOnCallCalendarFeed.js +585 -0
  106. package/build/dist/Models/DatabaseModels/UserOnCallCalendarFeed.js.map +1 -0
  107. package/build/dist/Models/DatabaseModels/UserOnCallShiftReminder.js +247 -0
  108. package/build/dist/Models/DatabaseModels/UserOnCallShiftReminder.js.map +1 -0
  109. package/build/dist/Models/DatabaseModels/UserOnCallShiftReminderLog.js +355 -0
  110. package/build/dist/Models/DatabaseModels/UserOnCallShiftReminderLog.js.map +1 -0
  111. package/build/dist/Server/API/OnCallCalendarAPI.js +1371 -0
  112. package/build/dist/Server/API/OnCallCalendarAPI.js.map +1 -0
  113. package/build/dist/Server/EnvironmentConfig.js +102 -0
  114. package/build/dist/Server/EnvironmentConfig.js.map +1 -1
  115. package/build/dist/Server/Infrastructure/OnCallCalendarFeedCache.js +663 -0
  116. package/build/dist/Server/Infrastructure/OnCallCalendarFeedCache.js.map +1 -0
  117. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1790400000000-AddOnCallCalendarFeeds.js +103 -0
  118. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1790400000000-AddOnCallCalendarFeeds.js.map +1 -0
  119. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js +2 -0
  120. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js.map +1 -1
  121. package/build/dist/Server/Middleware/OnCallCalendarFeedRateLimit.js +342 -0
  122. package/build/dist/Server/Middleware/OnCallCalendarFeedRateLimit.js.map +1 -0
  123. package/build/dist/Server/Services/Index.js +12 -0
  124. package/build/dist/Server/Services/Index.js.map +1 -1
  125. package/build/dist/Server/Services/OnCallDutyPolicyEscalationRuleScheduleService.js +20 -0
  126. package/build/dist/Server/Services/OnCallDutyPolicyEscalationRuleScheduleService.js.map +1 -1
  127. package/build/dist/Server/Services/OnCallDutyPolicyScheduleCalendarFeedService.js +219 -0
  128. package/build/dist/Server/Services/OnCallDutyPolicyScheduleCalendarFeedService.js.map +1 -0
  129. package/build/dist/Server/Services/OnCallDutyPolicyScheduleLayerService.js +47 -3
  130. package/build/dist/Server/Services/OnCallDutyPolicyScheduleLayerService.js.map +1 -1
  131. package/build/dist/Server/Services/OnCallDutyPolicyScheduleLayerUserService.js +112 -3
  132. package/build/dist/Server/Services/OnCallDutyPolicyScheduleLayerUserService.js.map +1 -1
  133. package/build/dist/Server/Services/OnCallDutyPolicyScheduleService.js +955 -9
  134. package/build/dist/Server/Services/OnCallDutyPolicyScheduleService.js.map +1 -1
  135. package/build/dist/Server/Services/OnCallDutyPolicyUserOverrideService.js +93 -6
  136. package/build/dist/Server/Services/OnCallDutyPolicyUserOverrideService.js.map +1 -1
  137. package/build/dist/Server/Services/ProjectOnCallCalendarFeedService.js +157 -0
  138. package/build/dist/Server/Services/ProjectOnCallCalendarFeedService.js.map +1 -0
  139. package/build/dist/Server/Services/TeamMemberService.js +396 -1
  140. package/build/dist/Server/Services/TeamMemberService.js.map +1 -1
  141. package/build/dist/Server/Services/TeamService.js +43 -0
  142. package/build/dist/Server/Services/TeamService.js.map +1 -1
  143. package/build/dist/Server/Services/UserNotificationSettingService.js +26 -1
  144. package/build/dist/Server/Services/UserNotificationSettingService.js.map +1 -1
  145. package/build/dist/Server/Services/UserOnCallCalendarFeedService.js +140 -0
  146. package/build/dist/Server/Services/UserOnCallCalendarFeedService.js.map +1 -0
  147. package/build/dist/Server/Services/UserOnCallShiftReminderLogService.js +116 -0
  148. package/build/dist/Server/Services/UserOnCallShiftReminderLogService.js.map +1 -0
  149. package/build/dist/Server/Services/UserOnCallShiftReminderService.js +92 -0
  150. package/build/dist/Server/Services/UserOnCallShiftReminderService.js.map +1 -0
  151. package/build/dist/Server/Utils/OnCall/CalendarFeedToken.js +161 -0
  152. package/build/dist/Server/Utils/OnCall/CalendarFeedToken.js.map +1 -0
  153. package/build/dist/Server/Utils/OnCall/OnCallCalendarFeedRenderer.js +983 -0
  154. package/build/dist/Server/Utils/OnCall/OnCallCalendarFeedRenderer.js.map +1 -0
  155. package/build/dist/Server/Utils/OnCall/OnCallCalendarFeedUrls.js +155 -0
  156. package/build/dist/Server/Utils/OnCall/OnCallCalendarFeedUrls.js.map +1 -0
  157. package/build/dist/Server/Utils/OnCall/OnCallShiftChangeListeners.js +145 -0
  158. package/build/dist/Server/Utils/OnCall/OnCallShiftChangeListeners.js.map +1 -0
  159. package/build/dist/Server/Utils/OnCall/OnCallShiftMaterializer.js +752 -0
  160. package/build/dist/Server/Utils/OnCall/OnCallShiftMaterializer.js.map +1 -0
  161. package/build/dist/Server/Utils/OnCall/OnCallShiftReminderListener.js +92 -0
  162. package/build/dist/Server/Utils/OnCall/OnCallShiftReminderListener.js.map +1 -0
  163. package/build/dist/Server/Utils/OnCall/OnCallShiftReminderRunner.js +1786 -0
  164. package/build/dist/Server/Utils/OnCall/OnCallShiftReminderRunner.js.map +1 -0
  165. package/build/dist/Server/Utils/Response.js +97 -0
  166. package/build/dist/Server/Utils/Response.js.map +1 -1
  167. package/build/dist/Server/Utils/StartServer.js +9 -1
  168. package/build/dist/Server/Utils/StartServer.js.map +1 -1
  169. package/build/dist/Server/Utils/Telemetry/AppMetrics.js +41 -0
  170. package/build/dist/Server/Utils/Telemetry/AppMetrics.js.map +1 -1
  171. package/build/dist/Types/Calendar/ICalendar.js +290 -0
  172. package/build/dist/Types/Calendar/ICalendar.js.map +1 -0
  173. package/build/dist/Types/Email/EmailTemplateType.js +2 -0
  174. package/build/dist/Types/Email/EmailTemplateType.js.map +1 -1
  175. package/build/dist/Types/NotificationSetting/NotificationSettingEventType.js +3 -0
  176. package/build/dist/Types/NotificationSetting/NotificationSettingEventType.js.map +1 -1
  177. package/build/dist/Types/OnCallDutyPolicy/CalendarFeedWindow.js +97 -0
  178. package/build/dist/Types/OnCallDutyPolicy/CalendarFeedWindow.js.map +1 -0
  179. package/build/dist/Types/OnCallDutyPolicy/Layer.js +113 -19
  180. package/build/dist/Types/OnCallDutyPolicy/Layer.js.map +1 -1
  181. package/build/dist/Types/OnCallDutyPolicy/MaterializedShift.js +134 -0
  182. package/build/dist/Types/OnCallDutyPolicy/MaterializedShift.js.map +1 -0
  183. package/build/dist/Types/OnCallDutyPolicy/OnCallCalendarFeedUtil.js +800 -0
  184. package/build/dist/Types/OnCallDutyPolicy/OnCallCalendarFeedUtil.js.map +1 -0
  185. package/build/dist/Types/OnCallDutyPolicy/ScheduleShiftUtil.js +97 -3
  186. package/build/dist/Types/OnCallDutyPolicy/ScheduleShiftUtil.js.map +1 -1
  187. package/build/dist/Types/OnCallDutyPolicy/ShiftSeamUtil.js +77 -0
  188. package/build/dist/Types/OnCallDutyPolicy/ShiftSeamUtil.js.map +1 -0
  189. package/package.json +1 -1
@@ -0,0 +1,487 @@
1
+ import Redis, { ClientType } from "../Infrastructure/Redis";
2
+ import {
3
+ ExpressRequest,
4
+ ExpressResponse,
5
+ NextFunction,
6
+ OneUptimeRequest,
7
+ } from "../Utils/Express";
8
+ import logger, { getLogAttributesFromRequest } from "../Utils/Logger";
9
+ import resolveTrustedClientIp, { ClientIpRequestLike } from "../Utils/ClientIp";
10
+ import Response from "../Utils/Response";
11
+ import {
12
+ OnCallCalendarFeedRateLimitPerIpPerWindow,
13
+ OnCallCalendarFeedRateLimitPerTokenPerWindow,
14
+ OnCallCalendarFeedRateLimitWindowSeconds,
15
+ } from "../EnvironmentConfig";
16
+ import TooManyRequestsException from "../../Types/Exception/TooManyRequestsException";
17
+ import { createHash } from "crypto";
18
+
19
+ /*
20
+ * Rate limiting for the public on-call calendar feed routes:
21
+ *
22
+ * GET /api/on-call-calendar/user/:token/shifts.ics
23
+ * GET /api/on-call-calendar/schedule/:token/schedule.ics
24
+ * GET /api/on-call-calendar/project/:token/project.ics
25
+ *
26
+ * These are polled by calendar clients (Google, Outlook, Apple) with no session
27
+ * and no API key -- the token in the path is the whole credential -- so there
28
+ * is nothing else in front of them that bounds request volume. Rendering a
29
+ * feed expands every layer of every candidate schedule across up to 180 days,
30
+ * which is exactly the kind of work an unbounded anonymous route must not be
31
+ * allowed to drive in a loop.
32
+ *
33
+ * A sibling of PublicDashboardRateLimit rather than a reuse of it: that class
34
+ * derives its key from dashboard ids and domains, and the one thing that must
35
+ * be different here is that the key is derived from a SECRET. It is never used
36
+ * raw -- not in a Redis key, not in a log line -- see resolveTokenKey.
37
+ */
38
+
39
+ /*
40
+ * Every request consumes TWO counters, and either one can reject it:
41
+ *
42
+ * - the token counter, keyed on a hash of the token + client address. This is
43
+ * the budget one subscribed calendar gets. Default 60 per minute: Apple
44
+ * Calendar's most eager setting is one poll every five minutes, so a real
45
+ * client never comes near it, and a whole office of clients behind one NAT
46
+ * all polling the same shared team link still has headroom.
47
+ *
48
+ * - the address counter, keyed on client address alone. Without it the token
49
+ * counter is trivially bypassed by rotating tokens: every guess lands in a
50
+ * fresh bucket and still costs a Postgres lookup even though it is a 404.
51
+ * Default 3000 per minute is the ceiling that survives rotation.
52
+ *
53
+ * Both are incremented on every request including a rejected one, so a client
54
+ * that keeps hammering keeps its window pinned rather than being handed a
55
+ * fresh allowance for free.
56
+ */
57
+ export enum OnCallCalendarFeedRateLimitScope {
58
+ Token = "token",
59
+ Ip = "ip",
60
+ }
61
+
62
+ export enum OnCallCalendarFeedRateLimitOutcome {
63
+ Allowed = "allowed",
64
+ RateLimited = "rate-limited",
65
+
66
+ /* Redis is unreachable, so no limit can be honoured either way. */
67
+ CounterUnavailable = "counter-unavailable",
68
+ }
69
+
70
+ export interface OnCallCalendarFeedRateLimitDecision {
71
+ outcome: OnCallCalendarFeedRateLimitOutcome;
72
+ retryAfterSeconds?: number | undefined;
73
+
74
+ /* Which of the two counters rejected, for logs. Unset unless rejected. */
75
+ scope?: OnCallCalendarFeedRateLimitScope | undefined;
76
+
77
+ /*
78
+ * True only on the request that first crossed the line in this window, so
79
+ * the middleware logs one line per key per window rather than turning a
80
+ * flood into a second flood in the log pipeline.
81
+ */
82
+ isFirstRejectionInWindow?: boolean | undefined;
83
+ }
84
+
85
+ export interface OnCallCalendarFeedRateLimitConfig {
86
+ windowSeconds: number;
87
+ perTokenLimit: number;
88
+ perIpLimit: number;
89
+ }
90
+
91
+ /*
92
+ * The token shape the feed routes accept: 32 random bytes as base64url, which
93
+ * is always exactly 43 characters. Anything else is not a token that could
94
+ * exist and shares one small "invalid" bucket (see resolveTokenKey).
95
+ */
96
+ export const ON_CALL_CALENDAR_FEED_TOKEN_PATTERN: RegExp =
97
+ /^[A-Za-z0-9_-]{43}$/;
98
+
99
+ const KEY_PREFIX: string = "oncal:rl:";
100
+
101
+ /*
102
+ * Counter keys outlive their window by one full window, so a request landing
103
+ * on a boundary cannot read a key that was reclaimed mid-window.
104
+ */
105
+ const TTL_MULTIPLIER: number = 2;
106
+
107
+ /* Bounds a Redis key built from attacker-supplied path segments. */
108
+ const MAX_KEY_SEGMENT_LENGTH: number = 64;
109
+
110
+ /*
111
+ * How much of the token hash goes into the Redis key. 16 hex characters is 64
112
+ * bits -- far more than enough to keep distinct tokens in distinct buckets --
113
+ * and short enough that a KEYS/SCAN listing stays readable.
114
+ */
115
+ const TOKEN_KEY_HASH_LENGTH: number = 16;
116
+
117
+ /* How often the "counter unavailable" condition may be logged. */
118
+ const COUNTER_UNAVAILABLE_LOG_INTERVAL_MS: number = 60 * 1000;
119
+
120
+ export default class OnCallCalendarFeedRateLimit {
121
+ private static readonly config: OnCallCalendarFeedRateLimitConfig = {
122
+ windowSeconds: OnCallCalendarFeedRateLimitWindowSeconds,
123
+ perTokenLimit: OnCallCalendarFeedRateLimitPerTokenPerWindow,
124
+ perIpLimit: OnCallCalendarFeedRateLimitPerIpPerWindow,
125
+ };
126
+
127
+ private static counterUnavailableLastLoggedAt: number = 0;
128
+
129
+ /* The limits in force, for tests and for operators' diagnostics. */
130
+ public static getConfig(): OnCallCalendarFeedRateLimitConfig {
131
+ return { ...OnCallCalendarFeedRateLimit.config };
132
+ }
133
+
134
+ /*
135
+ * The client address to bill this request to.
136
+ *
137
+ * Delegates to the shared ClientIp helper, which reads X-Forwarded-For from
138
+ * the trusted (right-hand) end under the instance-wide TRUSTED_PROXY_HOPS
139
+ * setting. Deliberately NOT Express.getClientIp: that takes the LEFTMOST
140
+ * entry, which any caller can set by sending its own X-Forwarded-For header,
141
+ * and for a rate limiter that is fatal -- a fresh spoofed value per request
142
+ * means a fresh bucket per request and no limit at all.
143
+ */
144
+ public static resolveClientIp(req: ExpressRequest): string {
145
+ const clientIp: string | undefined = resolveTrustedClientIp(
146
+ req as unknown as ClientIpRequestLike,
147
+ );
148
+
149
+ if (clientIp) {
150
+ return OnCallCalendarFeedRateLimit.sanitizeKeySegment(clientIp);
151
+ }
152
+
153
+ /*
154
+ * No address at all. Everything in this state shares one bucket, which is
155
+ * the conservative direction -- an unidentifiable caller should not get its
156
+ * own private allowance.
157
+ */
158
+ return "unknown";
159
+ }
160
+
161
+ /*
162
+ * The token this request presents, as a key segment that is NOT the token.
163
+ *
164
+ * The token is a bearer credential. Redis keys show up in KEYS/SCAN output,
165
+ * in monitoring dashboards and in Redis's own slow log, so a raw token in a
166
+ * key is a token written down somewhere it was never meant to be. The key
167
+ * therefore carries a truncated SHA-256 of it: enough bits to keep distinct
168
+ * tokens apart, no way back to the token.
169
+ *
170
+ * The hash is unkeyed on purpose. This is a rate-limit bucket name, not the
171
+ * stored lookup hash the feed row is found by (that one is a full SHA-256 and
172
+ * lives in Postgres); 64 bits of a preimage-resistant hash of 256 bits of
173
+ * randomness is not a useful oracle for anything.
174
+ *
175
+ * Anything that is not shaped like a token collapses into one shared
176
+ * "invalid" bucket. It cannot correspond to a real feed, so a small shared
177
+ * allowance between all such requests is the right grouping, and it bounds
178
+ * Redis memory against a caller feeding junk path segments.
179
+ */
180
+ public static resolveTokenKey(req: ExpressRequest): string {
181
+ const raw: unknown = req.params?.["token"];
182
+
183
+ if (typeof raw !== "string") {
184
+ return "none";
185
+ }
186
+
187
+ const value: string = raw.trim();
188
+
189
+ if (!value) {
190
+ return "none";
191
+ }
192
+
193
+ if (!ON_CALL_CALENDAR_FEED_TOKEN_PATTERN.test(value)) {
194
+ return "invalid";
195
+ }
196
+
197
+ return OnCallCalendarFeedRateLimit.hashToken(value);
198
+ }
199
+
200
+ /*
201
+ * Bucket segment for a token the caller already validated. Exposed so a
202
+ * route that has the token in hand (or a test) can build the same key the
203
+ * middleware does.
204
+ */
205
+ public static hashToken(token: string): string {
206
+ return `t:${createHash("sha256")
207
+ .update(token, "utf8")
208
+ .digest("hex")
209
+ .slice(0, TOKEN_KEY_HASH_LENGTH)}`;
210
+ }
211
+
212
+ private static sanitizeKeySegment(value: string): string {
213
+ return (
214
+ value
215
+ .slice(0, MAX_KEY_SEGMENT_LENGTH)
216
+ /*
217
+ * Redis keys are binary safe, but a predictable charset keeps
218
+ * operational tooling (KEYS/SCAN patterns, dashboards) sane.
219
+ */
220
+ .replace(/[^a-zA-Z0-9._:%\-[\]]/g, "_")
221
+ );
222
+ }
223
+
224
+ /*
225
+ * Increment both counters for this request and decide.
226
+ *
227
+ * Both INCRs go out in one pipeline so the common path costs a single round
228
+ * trip; the EXPIRE follow-ups only happen on the request that created a key.
229
+ */
230
+ public static async consume(data: {
231
+ tokenKey: string;
232
+ clientIp: string;
233
+ }): Promise<OnCallCalendarFeedRateLimitDecision> {
234
+ const client: ClientType | null = Redis.getClient();
235
+
236
+ if (!client || !Redis.isConnected()) {
237
+ return {
238
+ outcome: OnCallCalendarFeedRateLimitOutcome.CounterUnavailable,
239
+ };
240
+ }
241
+
242
+ const config: OnCallCalendarFeedRateLimitConfig =
243
+ OnCallCalendarFeedRateLimit.config;
244
+
245
+ const windowMs: number = config.windowSeconds * 1000;
246
+ const windowIndex: number = Math.floor(Date.now() / windowMs);
247
+
248
+ const tokenCounterKey: string = `${KEY_PREFIX}t:${data.tokenKey}:${data.clientIp}:${windowIndex}`;
249
+ const ipCounterKey: string = `${KEY_PREFIX}i:${data.clientIp}:${windowIndex}`;
250
+
251
+ try {
252
+ const pipelineResults: Array<[Error | null, unknown]> | null =
253
+ (await client
254
+ .pipeline()
255
+ .incr(tokenCounterKey)
256
+ .incr(ipCounterKey)
257
+ .exec()) as Array<[Error | null, unknown]> | null;
258
+
259
+ if (!pipelineResults || pipelineResults.length < 2) {
260
+ throw new Error("Rate limit pipeline returned no result");
261
+ }
262
+
263
+ const tokenCount: number = OnCallCalendarFeedRateLimit.readCounterResult(
264
+ pipelineResults[0],
265
+ );
266
+ const ipCount: number = OnCallCalendarFeedRateLimit.readCounterResult(
267
+ pipelineResults[1],
268
+ );
269
+
270
+ /*
271
+ * Set the expiry only on the write that created the key. Re-issuing
272
+ * EXPIRE on every increment would slide the window forward for as long
273
+ * as the load continues, so the counter would never reset and a client
274
+ * that tripped the limit once could never recover.
275
+ */
276
+ const ttlSeconds: number = config.windowSeconds * TTL_MULTIPLIER;
277
+ const keysToExpire: Array<string> = [];
278
+
279
+ if (tokenCount === 1) {
280
+ keysToExpire.push(tokenCounterKey);
281
+ }
282
+
283
+ if (ipCount === 1) {
284
+ keysToExpire.push(ipCounterKey);
285
+ }
286
+
287
+ if (keysToExpire.length > 0) {
288
+ const expirePipeline: ReturnType<ClientType["pipeline"]> =
289
+ client.pipeline();
290
+
291
+ for (const key of keysToExpire) {
292
+ expirePipeline.expire(key, ttlSeconds);
293
+ }
294
+
295
+ await expirePipeline.exec();
296
+ }
297
+
298
+ const retryAfterSeconds: number =
299
+ OnCallCalendarFeedRateLimit.getSecondsUntilWindowEnd(
300
+ config.windowSeconds,
301
+ );
302
+
303
+ /*
304
+ * Token counter is reported first when both are over: it is the more
305
+ * specific of the two and the more useful thing to see in a log line.
306
+ */
307
+ if (tokenCount > config.perTokenLimit) {
308
+ return {
309
+ outcome: OnCallCalendarFeedRateLimitOutcome.RateLimited,
310
+ retryAfterSeconds,
311
+ scope: OnCallCalendarFeedRateLimitScope.Token,
312
+ isFirstRejectionInWindow: tokenCount === config.perTokenLimit + 1,
313
+ };
314
+ }
315
+
316
+ if (ipCount > config.perIpLimit) {
317
+ return {
318
+ outcome: OnCallCalendarFeedRateLimitOutcome.RateLimited,
319
+ retryAfterSeconds,
320
+ scope: OnCallCalendarFeedRateLimitScope.Ip,
321
+ isFirstRejectionInWindow: ipCount === config.perIpLimit + 1,
322
+ };
323
+ }
324
+
325
+ return { outcome: OnCallCalendarFeedRateLimitOutcome.Allowed };
326
+ } catch (err) {
327
+ /*
328
+ * Throttled: whatever broke Redis is unlikely to break it for one
329
+ * request only, and a per-request log line buries the incident it is
330
+ * reporting. The log names neither the token nor its hash.
331
+ */
332
+ if (OnCallCalendarFeedRateLimit.shouldLogCounterUnavailable()) {
333
+ logger.warn(
334
+ `OnCallCalendarFeedRateLimit: counter failed for a request from ${data.clientIp}`,
335
+ );
336
+ logger.warn(err);
337
+ }
338
+
339
+ return {
340
+ outcome: OnCallCalendarFeedRateLimitOutcome.CounterUnavailable,
341
+ };
342
+ }
343
+ }
344
+
345
+ private static readCounterResult(
346
+ result: [Error | null, unknown] | undefined,
347
+ ): number {
348
+ if (!result) {
349
+ throw new Error("Rate limit counter returned no result");
350
+ }
351
+
352
+ const [error, value] = result;
353
+
354
+ if (error) {
355
+ throw error;
356
+ }
357
+
358
+ if (typeof value !== "number") {
359
+ throw new Error("Rate limit counter returned a non-numeric value");
360
+ }
361
+
362
+ return value;
363
+ }
364
+
365
+ /*
366
+ * Remaining seconds in the current fixed window, so every rejected caller
367
+ * is told to come back when the window actually rolls rather than all
368
+ * backing off by the same constant and re-colliding.
369
+ */
370
+ private static getSecondsUntilWindowEnd(windowSeconds: number): number {
371
+ const windowMs: number = windowSeconds * 1000;
372
+ const msIntoWindow: number = Date.now() % windowMs;
373
+
374
+ return Math.max(1, Math.ceil((windowMs - msIntoWindow) / 1000));
375
+ }
376
+
377
+ /*
378
+ * Express middleware. Mounted on each feed route (it reads `:token` from
379
+ * req.params, so it must sit on the route, not on the router) ahead of the
380
+ * token lookup, so a flood is refused before it costs a Postgres read, let
381
+ * alone a render.
382
+ *
383
+ * FAILS OPEN. When Redis is unreachable every request is let through and
384
+ * the condition is logged (throttled). The limiter is load control: the
385
+ * token is still checked by the route behind it, and blacking out every
386
+ * subscribed calendar over a Redis blip -- clients that get a 503 keep the
387
+ * copy they have, but one that gets it repeatedly may drop the calendar --
388
+ * is a worse outcome than an unbounded window for the duration of the blip.
389
+ */
390
+ public static getMiddleware(): (
391
+ req: ExpressRequest,
392
+ res: ExpressResponse,
393
+ next: NextFunction,
394
+ ) => Promise<void> {
395
+ return async (
396
+ req: ExpressRequest,
397
+ res: ExpressResponse,
398
+ next: NextFunction,
399
+ ): Promise<void> => {
400
+ const tokenKey: string = OnCallCalendarFeedRateLimit.resolveTokenKey(req);
401
+ const clientIp: string = OnCallCalendarFeedRateLimit.resolveClientIp(req);
402
+
403
+ const decision: OnCallCalendarFeedRateLimitDecision =
404
+ await OnCallCalendarFeedRateLimit.consume({ tokenKey, clientIp });
405
+
406
+ if (decision.outcome === OnCallCalendarFeedRateLimitOutcome.RateLimited) {
407
+ if (decision.retryAfterSeconds) {
408
+ OnCallCalendarFeedRateLimit.setRetryAfterHeader(
409
+ res,
410
+ decision.retryAfterSeconds,
411
+ );
412
+ }
413
+
414
+ /*
415
+ * The crossing request only, and never the token or its hash: an
416
+ * operator needs to know an address is hammering the feeds, not which
417
+ * feed. The route's own attributes (requestId, no token) are enough to
418
+ * correlate.
419
+ */
420
+ if (decision.isFirstRejectionInWindow) {
421
+ logger.warn(
422
+ `OnCallCalendarFeedRateLimit: rejected calendar feed request from ${clientIp} (${decision.scope} limit)`,
423
+ );
424
+ }
425
+
426
+ return Response.sendErrorResponse(
427
+ req,
428
+ res,
429
+ new TooManyRequestsException(
430
+ "Too many requests. Please try again later.",
431
+ ),
432
+ );
433
+ }
434
+
435
+ if (
436
+ decision.outcome ===
437
+ OnCallCalendarFeedRateLimitOutcome.CounterUnavailable
438
+ ) {
439
+ if (OnCallCalendarFeedRateLimit.shouldLogCounterUnavailable()) {
440
+ logger.warn(
441
+ "OnCallCalendarFeedRateLimit: rate limit counter unavailable, allowing calendar feed requests unthrottled",
442
+ );
443
+ logger.warn(getLogAttributesFromRequest(req as OneUptimeRequest));
444
+ }
445
+ }
446
+
447
+ return next();
448
+ };
449
+ }
450
+
451
+ /*
452
+ * A Redis outage means EVERY request takes the unavailable path, so an
453
+ * unguarded log line there is one per request for as long as the outage
454
+ * lasts. One line per interval is enough to make the condition visible.
455
+ */
456
+ private static shouldLogCounterUnavailable(): boolean {
457
+ const now: number = Date.now();
458
+
459
+ if (
460
+ now - OnCallCalendarFeedRateLimit.counterUnavailableLastLoggedAt <
461
+ COUNTER_UNAVAILABLE_LOG_INTERVAL_MS
462
+ ) {
463
+ return false;
464
+ }
465
+
466
+ OnCallCalendarFeedRateLimit.counterUnavailableLastLoggedAt = now;
467
+
468
+ return true;
469
+ }
470
+
471
+ private static setRetryAfterHeader(
472
+ res: ExpressResponse,
473
+ retryAfterSeconds: number,
474
+ ): void {
475
+ const setHeader: unknown = (res as unknown as Record<string, unknown>)[
476
+ "setHeader"
477
+ ];
478
+
479
+ if (typeof setHeader === "function") {
480
+ (setHeader as (name: string, value: string) => void).call(
481
+ res,
482
+ "Retry-After",
483
+ String(retryAfterSeconds),
484
+ );
485
+ }
486
+ }
487
+ }
@@ -166,6 +166,12 @@ import OnCallDutyPolicyScheduleOwnerTeamService from "./OnCallDutyPolicySchedule
166
166
  import OnCallDutyPolicyScheduleLabelRuleService from "./OnCallDutyPolicyScheduleLabelRuleService";
167
167
  import OnCallDutyPolicyScheduleLayerUserService from "./OnCallDutyPolicyScheduleLayerUserService";
168
168
  import OnCallDutyPolicyScheduleService from "./OnCallDutyPolicyScheduleService";
169
+ // On-call calendar feeds and shift reminders
170
+ import UserOnCallCalendarFeedService from "./UserOnCallCalendarFeedService";
171
+ import OnCallDutyPolicyScheduleCalendarFeedService from "./OnCallDutyPolicyScheduleCalendarFeedService";
172
+ import ProjectOnCallCalendarFeedService from "./ProjectOnCallCalendarFeedService";
173
+ import UserOnCallShiftReminderService from "./UserOnCallShiftReminderService";
174
+ import UserOnCallShiftReminderLogService from "./UserOnCallShiftReminderLogService";
169
175
  // On-Call Duty
170
176
  import OnCallDutyPolicyService from "./OnCallDutyPolicyService";
171
177
  import ProbeService from "./ProbeService";
@@ -688,6 +694,12 @@ const services: Array<BaseService> = [
688
694
  OnCallDutyPolicyScheduleOwnerTeamService,
689
695
  OnCallDutyPolicyScheduleLabelRuleService,
690
696
  OnCallDutyPolicyScheduleLayerUserService,
697
+ // On-call calendar feeds and shift reminders
698
+ UserOnCallCalendarFeedService,
699
+ OnCallDutyPolicyScheduleCalendarFeedService,
700
+ ProjectOnCallCalendarFeedService,
701
+ UserOnCallShiftReminderService,
702
+ UserOnCallShiftReminderLogService,
691
703
  OnCallDutyPolicyScheduleLayerService,
692
704
  OnCallDutyPolicyEscalationRuleScheduleService,
693
705
 
@@ -25,12 +25,35 @@ import { WhatsAppMessagePayload } from "../../Types/WhatsApp/WhatsAppMessage";
25
25
  import OnCallDutyPolicyTimeLogService from "./OnCallDutyPolicyTimeLogService";
26
26
  import OneUptimeDate from "../../Types/Date";
27
27
  import logger, { LogAttributes } from "../Utils/Logger";
28
+ import { OnCallShiftChangeReason } from "../Utils/OnCall/OnCallShiftChangeListeners";
28
29
 
29
30
  export class Service extends DatabaseService<Model> {
30
31
  public constructor() {
31
32
  super(Model);
32
33
  }
33
34
 
35
+ /**
36
+ * Attaching to / detaching from a policy changes the schedule's policy
37
+ * context (which overrides apply, and which policies its calendar events
38
+ * list), so it is a configuration change for the calendar feeds and the
39
+ * reminders: bump shiftConfigVersion, purge the feed caches, notify the
40
+ * listeners. Runs after the roster refresh; never throws.
41
+ */
42
+ private async propagateAttachmentChange(
43
+ onCallDutyPolicyScheduleId: ObjectID | null | undefined,
44
+ projectId: ObjectID | null | undefined,
45
+ ): Promise<void> {
46
+ if (!onCallDutyPolicyScheduleId) {
47
+ return;
48
+ }
49
+
50
+ await OnCallDutyPolicyScheduleService.propagateShiftConfigChange({
51
+ scheduleIds: [onCallDutyPolicyScheduleId],
52
+ projectId: projectId || null,
53
+ reason: OnCallShiftChangeReason.PolicyAttachmentChanged,
54
+ });
55
+ }
56
+
34
57
  /**
35
58
  * Re-resolve and persist the schedule's roster after it is attached to, or
36
59
  * detached from, an on-call policy.
@@ -123,6 +146,11 @@ export class Service extends DatabaseService<Model> {
123
146
  */
124
147
  await this.refreshScheduleRoster(createdModel.onCallDutyPolicyScheduleId);
125
148
 
149
+ await this.propagateAttachmentChange(
150
+ createdModel.onCallDutyPolicyScheduleId,
151
+ createdModel.projectId,
152
+ );
153
+
126
154
  // send notification to the new current user.
127
155
 
128
156
  const userOnSchedule: ObjectID | null =
@@ -387,6 +415,10 @@ export class Service extends DatabaseService<Model> {
387
415
  }
388
416
  refreshedScheduleIds.add(scheduleId);
389
417
  await this.refreshScheduleRoster(deletedItem.onCallDutyPolicyScheduleId);
418
+ await this.propagateAttachmentChange(
419
+ deletedItem.onCallDutyPolicyScheduleId,
420
+ deletedItem.projectId,
421
+ );
390
422
  }
391
423
 
392
424
  for (const deletedItem of deletedItems) {