@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.
- package/Models/DatabaseModels/Index.ts +11 -0
- package/Models/DatabaseModels/OnCallDutyPolicySchedule.ts +43 -0
- package/Models/DatabaseModels/OnCallDutyPolicyScheduleCalendarFeed.ts +662 -0
- package/Models/DatabaseModels/ProjectOnCallCalendarFeed.ts +584 -0
- package/Models/DatabaseModels/UserOnCallCalendarFeed.ts +546 -0
- package/Models/DatabaseModels/UserOnCallShiftReminder.ts +231 -0
- package/Models/DatabaseModels/UserOnCallShiftReminderLog.ts +331 -0
- package/Server/API/OnCallCalendarAPI.ts +2194 -0
- package/Server/EnvironmentConfig.ts +138 -0
- package/Server/Infrastructure/OnCallCalendarFeedCache.ts +1050 -0
- package/Server/Infrastructure/Postgres/SchemaMigrations/1790400000000-AddOnCallCalendarFeeds.ts +253 -0
- package/Server/Infrastructure/Postgres/SchemaMigrations/Index.ts +2 -0
- package/Server/Middleware/OnCallCalendarFeedRateLimit.ts +487 -0
- package/Server/Services/Index.ts +12 -0
- package/Server/Services/OnCallDutyPolicyEscalationRuleScheduleService.ts +32 -0
- package/Server/Services/OnCallDutyPolicyScheduleCalendarFeedService.ts +260 -0
- package/Server/Services/OnCallDutyPolicyScheduleLayerService.ts +70 -3
- package/Server/Services/OnCallDutyPolicyScheduleLayerUserService.ts +138 -3
- package/Server/Services/OnCallDutyPolicyScheduleService.ts +1465 -134
- package/Server/Services/OnCallDutyPolicyUserOverrideService.ts +167 -31
- package/Server/Services/ProjectOnCallCalendarFeedService.ts +192 -0
- package/Server/Services/TeamMemberService.ts +489 -1
- package/Server/Services/TeamService.ts +50 -0
- package/Server/Services/UserNotificationSettingService.ts +44 -1
- package/Server/Services/UserOnCallCalendarFeedService.ts +159 -0
- package/Server/Services/UserOnCallShiftReminderLogService.ts +151 -0
- package/Server/Services/UserOnCallShiftReminderService.ts +112 -0
- package/Server/Utils/OnCall/CalendarFeedToken.ts +241 -0
- package/Server/Utils/OnCall/OnCallCalendarFeedRenderer.ts +1488 -0
- package/Server/Utils/OnCall/OnCallCalendarFeedUrls.ts +230 -0
- package/Server/Utils/OnCall/OnCallShiftChangeListeners.ts +227 -0
- package/Server/Utils/OnCall/OnCallShiftMaterializer.ts +1127 -0
- package/Server/Utils/OnCall/OnCallShiftReminderListener.ts +174 -0
- package/Server/Utils/OnCall/OnCallShiftReminderRunner.ts +2584 -0
- package/Server/Utils/Response.ts +134 -0
- package/Server/Utils/StartServer.ts +10 -0
- package/Server/Utils/Telemetry/AppMetrics.ts +51 -0
- package/Tests/App/Dashboard/OnCallCalendarFeedLinks.test.tsx +229 -0
- package/Tests/App/Dashboard/OnCallCalendarFeedPlanGate.test.tsx +489 -0
- package/Tests/App/Dashboard/OnCallCalendarFeedSideMenus.test.tsx +149 -0
- package/Tests/App/Dashboard/OnCallCalendarFeedUtil.test.ts +855 -0
- package/Tests/App/Dashboard/OnCallPersonalCalendarFeed.test.tsx +1209 -0
- package/Tests/App/Dashboard/OnCallSharedCalendarFeedCard.test.tsx +1019 -0
- package/Tests/App/Dashboard/OnCallShiftRemindersAndUpcomingShifts.test.tsx +655 -0
- package/Tests/App/Dashboard/UserSettingsSetupChecklistModel.test.ts +73 -0
- package/Tests/Models/OnCallCalendarFeedModels.test.ts +642 -0
- package/Tests/Models/OnCallDutyPolicyScheduleCalendarFeed.test.ts +288 -0
- package/Tests/Models/OnCallDutyPolicyScheduleShiftConfigVersion.test.ts +88 -0
- package/Tests/Models/UserOnCallShiftReminderModels.test.ts +368 -0
- package/Tests/Server/API/Helpers.ts +8 -0
- package/Tests/Server/API/OnCallCalendarAPI.test.ts +4461 -0
- package/Tests/Server/EnvironmentConfigOnCallCalendarFeed.test.ts +357 -0
- package/Tests/Server/Infrastructure/OnCallCalendarFeedCache.test.ts +1589 -0
- package/Tests/Server/Infrastructure/Postgres/AddOnCallCalendarFeedsMigration.test.ts +369 -0
- package/Tests/Server/Middleware/OnCallCalendarFeedRateLimit.test.ts +1365 -0
- package/Tests/Server/Services/FeedRetentionConsistency.test.ts +70 -0
- package/Tests/Server/Services/OnCallCalendarFeedServices.test.ts +1103 -0
- package/Tests/Server/Services/OnCallDutyPolicyScheduleResolver.test.ts +1037 -0
- package/Tests/Server/Services/OnCallShiftConfigPropagation.test.ts +1270 -0
- package/Tests/Server/Services/TeamDeleteMemberCleanup.test.ts +156 -0
- package/Tests/Server/Services/TeamMemberOnCallCleanup.test.ts +755 -0
- package/Tests/Server/Services/UserNotificationSettingShiftReminderDefaults.test.ts +267 -0
- package/Tests/Server/Services/UserOnCallShiftReminderServices.test.ts +583 -0
- package/Tests/Server/Types/Database/Permissions/OnCallCalendarFeedPermissions.test.ts +254 -0
- package/Tests/Server/Utils/OnCall/CalendarFeedToken.test.ts +467 -0
- package/Tests/Server/Utils/OnCall/OnCallCalendarFeedRenderer.test.ts +2142 -0
- package/Tests/Server/Utils/OnCall/OnCallCalendarFeedUrls.test.ts +278 -0
- package/Tests/Server/Utils/OnCall/OnCallResolverTestHarness.ts +440 -0
- package/Tests/Server/Utils/OnCall/OnCallShiftChangeListeners.test.ts +314 -0
- package/Tests/Server/Utils/OnCall/OnCallShiftMaterializer.test.ts +1502 -0
- package/Tests/Server/Utils/OnCall/OnCallShiftReminderListener.test.ts +491 -0
- package/Tests/Server/Utils/OnCall/OnCallShiftReminderRunner.test.ts +3088 -0
- package/Tests/Server/Utils/OnCall/OnCallShiftReminderTestHarness.ts +910 -0
- package/Tests/Server/Utils/ResponseSendCalendarResponse.test.ts +561 -0
- package/Tests/Server/Utils/StartServerEncryptionSecretWarning.test.ts +64 -0
- package/Tests/Server/Utils/Telemetry/AppMetrics.test.ts +29 -0
- package/Tests/Types/Calendar/ICalendar.test.ts +488 -0
- package/Tests/Types/NotificationSetting/NotificationSettingEventType.test.ts +53 -0
- package/Tests/Types/OnCallDutyPolicy/CalendarFeedTestFixtures.ts +310 -0
- package/Tests/Types/OnCallDutyPolicy/CalendarFeedWindow.test.ts +205 -0
- package/Tests/Types/OnCallDutyPolicy/LayerUtilLayerMetaAndIterationCap.test.ts +458 -0
- package/Tests/Types/OnCallDutyPolicy/MaterializedShift.test.ts +197 -0
- package/Tests/Types/OnCallDutyPolicy/OnCallCalendarFeedUtil.test.ts +1986 -0
- package/Tests/Types/OnCallDutyPolicy/ScheduleShiftUtilGroupKey.test.ts +678 -0
- package/Tests/Types/OnCallDutyPolicy/ShiftSeamUtil.test.ts +417 -0
- package/Tests/UI/Utils/Breadcrumb/fixtures/RealBreadcrumbTrails.ts +10 -0
- package/Tests/UI/Utils/Breadcrumb/fixtures/RealRoutePatterns.ts +2 -0
- package/Types/Calendar/ICalendar.ts +442 -0
- package/Types/Email/EmailTemplateType.ts +2 -0
- package/Types/NotificationSetting/NotificationSettingEventType.ts +4 -0
- package/Types/OnCallDutyPolicy/CalendarFeedWindow.ts +140 -0
- package/Types/OnCallDutyPolicy/Layer.ts +190 -35
- package/Types/OnCallDutyPolicy/MaterializedShift.ts +274 -0
- package/Types/OnCallDutyPolicy/OnCallCalendarFeedUtil.ts +1415 -0
- package/Types/OnCallDutyPolicy/ScheduleShiftUtil.ts +159 -4
- package/Types/OnCallDutyPolicy/ShiftSeamUtil.ts +106 -0
- package/build/dist/Models/DatabaseModels/Index.js +11 -0
- package/build/dist/Models/DatabaseModels/Index.js.map +1 -1
- package/build/dist/Models/DatabaseModels/OnCallDutyPolicySchedule.js +44 -0
- package/build/dist/Models/DatabaseModels/OnCallDutyPolicySchedule.js.map +1 -1
- package/build/dist/Models/DatabaseModels/OnCallDutyPolicyScheduleCalendarFeed.js +699 -0
- package/build/dist/Models/DatabaseModels/OnCallDutyPolicyScheduleCalendarFeed.js.map +1 -0
- package/build/dist/Models/DatabaseModels/ProjectOnCallCalendarFeed.js +620 -0
- package/build/dist/Models/DatabaseModels/ProjectOnCallCalendarFeed.js.map +1 -0
- package/build/dist/Models/DatabaseModels/UserOnCallCalendarFeed.js +585 -0
- package/build/dist/Models/DatabaseModels/UserOnCallCalendarFeed.js.map +1 -0
- package/build/dist/Models/DatabaseModels/UserOnCallShiftReminder.js +247 -0
- package/build/dist/Models/DatabaseModels/UserOnCallShiftReminder.js.map +1 -0
- package/build/dist/Models/DatabaseModels/UserOnCallShiftReminderLog.js +355 -0
- package/build/dist/Models/DatabaseModels/UserOnCallShiftReminderLog.js.map +1 -0
- package/build/dist/Server/API/OnCallCalendarAPI.js +1371 -0
- package/build/dist/Server/API/OnCallCalendarAPI.js.map +1 -0
- package/build/dist/Server/EnvironmentConfig.js +102 -0
- package/build/dist/Server/EnvironmentConfig.js.map +1 -1
- package/build/dist/Server/Infrastructure/OnCallCalendarFeedCache.js +663 -0
- package/build/dist/Server/Infrastructure/OnCallCalendarFeedCache.js.map +1 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1790400000000-AddOnCallCalendarFeeds.js +103 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1790400000000-AddOnCallCalendarFeeds.js.map +1 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js +2 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js.map +1 -1
- package/build/dist/Server/Middleware/OnCallCalendarFeedRateLimit.js +342 -0
- package/build/dist/Server/Middleware/OnCallCalendarFeedRateLimit.js.map +1 -0
- package/build/dist/Server/Services/Index.js +12 -0
- package/build/dist/Server/Services/Index.js.map +1 -1
- package/build/dist/Server/Services/OnCallDutyPolicyEscalationRuleScheduleService.js +20 -0
- package/build/dist/Server/Services/OnCallDutyPolicyEscalationRuleScheduleService.js.map +1 -1
- package/build/dist/Server/Services/OnCallDutyPolicyScheduleCalendarFeedService.js +219 -0
- package/build/dist/Server/Services/OnCallDutyPolicyScheduleCalendarFeedService.js.map +1 -0
- package/build/dist/Server/Services/OnCallDutyPolicyScheduleLayerService.js +47 -3
- package/build/dist/Server/Services/OnCallDutyPolicyScheduleLayerService.js.map +1 -1
- package/build/dist/Server/Services/OnCallDutyPolicyScheduleLayerUserService.js +112 -3
- package/build/dist/Server/Services/OnCallDutyPolicyScheduleLayerUserService.js.map +1 -1
- package/build/dist/Server/Services/OnCallDutyPolicyScheduleService.js +955 -9
- package/build/dist/Server/Services/OnCallDutyPolicyScheduleService.js.map +1 -1
- package/build/dist/Server/Services/OnCallDutyPolicyUserOverrideService.js +93 -6
- package/build/dist/Server/Services/OnCallDutyPolicyUserOverrideService.js.map +1 -1
- package/build/dist/Server/Services/ProjectOnCallCalendarFeedService.js +157 -0
- package/build/dist/Server/Services/ProjectOnCallCalendarFeedService.js.map +1 -0
- package/build/dist/Server/Services/TeamMemberService.js +396 -1
- package/build/dist/Server/Services/TeamMemberService.js.map +1 -1
- package/build/dist/Server/Services/TeamService.js +43 -0
- package/build/dist/Server/Services/TeamService.js.map +1 -1
- package/build/dist/Server/Services/UserNotificationSettingService.js +26 -1
- package/build/dist/Server/Services/UserNotificationSettingService.js.map +1 -1
- package/build/dist/Server/Services/UserOnCallCalendarFeedService.js +140 -0
- package/build/dist/Server/Services/UserOnCallCalendarFeedService.js.map +1 -0
- package/build/dist/Server/Services/UserOnCallShiftReminderLogService.js +116 -0
- package/build/dist/Server/Services/UserOnCallShiftReminderLogService.js.map +1 -0
- package/build/dist/Server/Services/UserOnCallShiftReminderService.js +92 -0
- package/build/dist/Server/Services/UserOnCallShiftReminderService.js.map +1 -0
- package/build/dist/Server/Utils/OnCall/CalendarFeedToken.js +161 -0
- package/build/dist/Server/Utils/OnCall/CalendarFeedToken.js.map +1 -0
- package/build/dist/Server/Utils/OnCall/OnCallCalendarFeedRenderer.js +983 -0
- package/build/dist/Server/Utils/OnCall/OnCallCalendarFeedRenderer.js.map +1 -0
- package/build/dist/Server/Utils/OnCall/OnCallCalendarFeedUrls.js +155 -0
- package/build/dist/Server/Utils/OnCall/OnCallCalendarFeedUrls.js.map +1 -0
- package/build/dist/Server/Utils/OnCall/OnCallShiftChangeListeners.js +145 -0
- package/build/dist/Server/Utils/OnCall/OnCallShiftChangeListeners.js.map +1 -0
- package/build/dist/Server/Utils/OnCall/OnCallShiftMaterializer.js +752 -0
- package/build/dist/Server/Utils/OnCall/OnCallShiftMaterializer.js.map +1 -0
- package/build/dist/Server/Utils/OnCall/OnCallShiftReminderListener.js +92 -0
- package/build/dist/Server/Utils/OnCall/OnCallShiftReminderListener.js.map +1 -0
- package/build/dist/Server/Utils/OnCall/OnCallShiftReminderRunner.js +1786 -0
- package/build/dist/Server/Utils/OnCall/OnCallShiftReminderRunner.js.map +1 -0
- package/build/dist/Server/Utils/Response.js +97 -0
- package/build/dist/Server/Utils/Response.js.map +1 -1
- package/build/dist/Server/Utils/StartServer.js +9 -1
- package/build/dist/Server/Utils/StartServer.js.map +1 -1
- package/build/dist/Server/Utils/Telemetry/AppMetrics.js +41 -0
- package/build/dist/Server/Utils/Telemetry/AppMetrics.js.map +1 -1
- package/build/dist/Types/Calendar/ICalendar.js +290 -0
- package/build/dist/Types/Calendar/ICalendar.js.map +1 -0
- package/build/dist/Types/Email/EmailTemplateType.js +2 -0
- package/build/dist/Types/Email/EmailTemplateType.js.map +1 -1
- package/build/dist/Types/NotificationSetting/NotificationSettingEventType.js +3 -0
- package/build/dist/Types/NotificationSetting/NotificationSettingEventType.js.map +1 -1
- package/build/dist/Types/OnCallDutyPolicy/CalendarFeedWindow.js +97 -0
- package/build/dist/Types/OnCallDutyPolicy/CalendarFeedWindow.js.map +1 -0
- package/build/dist/Types/OnCallDutyPolicy/Layer.js +113 -19
- package/build/dist/Types/OnCallDutyPolicy/Layer.js.map +1 -1
- package/build/dist/Types/OnCallDutyPolicy/MaterializedShift.js +134 -0
- package/build/dist/Types/OnCallDutyPolicy/MaterializedShift.js.map +1 -0
- package/build/dist/Types/OnCallDutyPolicy/OnCallCalendarFeedUtil.js +800 -0
- package/build/dist/Types/OnCallDutyPolicy/OnCallCalendarFeedUtil.js.map +1 -0
- package/build/dist/Types/OnCallDutyPolicy/ScheduleShiftUtil.js +97 -3
- package/build/dist/Types/OnCallDutyPolicy/ScheduleShiftUtil.js.map +1 -1
- package/build/dist/Types/OnCallDutyPolicy/ShiftSeamUtil.js +77 -0
- package/build/dist/Types/OnCallDutyPolicy/ShiftSeamUtil.js.map +1 -0
- package/package.json +1 -1
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
import CreateBy from "../Types/Database/CreateBy";
|
|
2
|
+
import UpdateBy from "../Types/Database/UpdateBy";
|
|
3
|
+
import { OnCreate, OnUpdate } from "../Types/Database/Hooks";
|
|
4
|
+
import DatabaseService from "./DatabaseService";
|
|
5
|
+
import CalendarFeedToken, {
|
|
6
|
+
CalendarFeedRotation,
|
|
7
|
+
MintedCalendarFeedToken,
|
|
8
|
+
} from "../Utils/OnCall/CalendarFeedToken";
|
|
9
|
+
import CaptureSpan from "../Utils/Telemetry/CaptureSpan";
|
|
10
|
+
import Model from "../../Models/DatabaseModels/UserOnCallCalendarFeed";
|
|
11
|
+
import BadDataException from "../../Types/Exception/BadDataException";
|
|
12
|
+
import NotAuthorizedException from "../../Types/Exception/NotAuthorizedException";
|
|
13
|
+
import NotFoundException from "../../Types/Exception/NotFoundException";
|
|
14
|
+
import CalendarFeedWindow from "../../Types/OnCallDutyPolicy/CalendarFeedWindow";
|
|
15
|
+
import ObjectID from "../../Types/ObjectID";
|
|
16
|
+
import OneUptimeDate from "../../Types/Date";
|
|
17
|
+
|
|
18
|
+
/*
|
|
19
|
+
* The personal calendar feed row: one per (project, user).
|
|
20
|
+
*
|
|
21
|
+
* WHAT THE HOOKS GUARANTEE
|
|
22
|
+
*
|
|
23
|
+
* - Only root creates one. The table create list is already empty, but the
|
|
24
|
+
* hook says it out loud: the plaintext token is minted here or by the
|
|
25
|
+
* calendar API, never by a request body.
|
|
26
|
+
* - A created row always carries a consistent token set (see
|
|
27
|
+
* CalendarFeedToken.applyTokenColumnsOnCreate): there is no path to a row
|
|
28
|
+
* whose hash does not match its token.
|
|
29
|
+
* - pastDays / futureDays are clamped to the CalendarFeedWindow bounds on
|
|
30
|
+
* every write, so a feed can never be asked to render a year.
|
|
31
|
+
*
|
|
32
|
+
* Rotation lives here too (rotateTokenById) so the grace-hash bookkeeping is
|
|
33
|
+
* written once and the API, the member-leave cleanup and the tests share it.
|
|
34
|
+
* Cache purging is the caller's job - this service knows nothing about the
|
|
35
|
+
* feed cache.
|
|
36
|
+
*/
|
|
37
|
+
export class Service extends DatabaseService<Model> {
|
|
38
|
+
public constructor() {
|
|
39
|
+
super(Model);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
@CaptureSpan()
|
|
43
|
+
protected override async onBeforeCreate(
|
|
44
|
+
createBy: CreateBy<Model>,
|
|
45
|
+
): Promise<OnCreate<Model>> {
|
|
46
|
+
if (!createBy.props.isRoot) {
|
|
47
|
+
throw new NotAuthorizedException(
|
|
48
|
+
"Calendar feeds are generated by the calendar API, not created directly.",
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const projectId: ObjectID | undefined =
|
|
53
|
+
createBy.data.projectId || createBy.props.tenantId;
|
|
54
|
+
|
|
55
|
+
if (!projectId) {
|
|
56
|
+
throw new BadDataException("projectId is required");
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
if (!createBy.data.userId) {
|
|
60
|
+
throw new BadDataException("userId is required");
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
createBy.data.projectId = projectId;
|
|
64
|
+
|
|
65
|
+
// Root only reaches this line, so a pre-minted token may be trusted.
|
|
66
|
+
CalendarFeedToken.applyTokenColumnsOnCreate(createBy.data, {
|
|
67
|
+
trustSuppliedToken: true,
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
createBy.data.pastDays = CalendarFeedWindow.clampPastDays(
|
|
71
|
+
createBy.data.pastDays,
|
|
72
|
+
);
|
|
73
|
+
createBy.data.futureDays = CalendarFeedWindow.clampFutureDays(
|
|
74
|
+
createBy.data.futureDays,
|
|
75
|
+
);
|
|
76
|
+
|
|
77
|
+
return { createBy, carryForward: null };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
@CaptureSpan()
|
|
81
|
+
protected override async onBeforeUpdate(
|
|
82
|
+
updateBy: UpdateBy<Model>,
|
|
83
|
+
): Promise<OnUpdate<Model>> {
|
|
84
|
+
if (updateBy.data.pastDays !== undefined) {
|
|
85
|
+
updateBy.data.pastDays = CalendarFeedWindow.clampPastDays(
|
|
86
|
+
updateBy.data.pastDays,
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
if (updateBy.data.futureDays !== undefined) {
|
|
91
|
+
updateBy.data.futureDays = CalendarFeedWindow.clampFutureDays(
|
|
92
|
+
updateBy.data.futureDays,
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
return { updateBy, carryForward: null };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Create the feed row for a user in a project and hand the plaintext token
|
|
101
|
+
* back ONCE, so the caller can build the subscription URL without a
|
|
102
|
+
* decrypt round-trip. Root only; the caller is responsible for holding the
|
|
103
|
+
* per-(project, user) lock the API uses around mint-or-rotate.
|
|
104
|
+
*/
|
|
105
|
+
@CaptureSpan()
|
|
106
|
+
public async createForUser(data: {
|
|
107
|
+
projectId: ObjectID;
|
|
108
|
+
userId: ObjectID;
|
|
109
|
+
}): Promise<{ feed: Model; minted: MintedCalendarFeedToken }> {
|
|
110
|
+
const minted: MintedCalendarFeedToken = CalendarFeedToken.mintSet();
|
|
111
|
+
|
|
112
|
+
const feed: Model = new Model();
|
|
113
|
+
feed.projectId = data.projectId;
|
|
114
|
+
feed.userId = data.userId;
|
|
115
|
+
feed.token = minted.token;
|
|
116
|
+
feed.rotatedAt = OneUptimeDate.getCurrentDate();
|
|
117
|
+
|
|
118
|
+
const created: Model = await this.create({
|
|
119
|
+
data: feed,
|
|
120
|
+
props: { isRoot: true },
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
return { feed: created, minted };
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Rotate the token of an existing feed: mint a new one, move the current
|
|
128
|
+
* hash into the 30-day grace slot, stamp rotatedAt. Returns the new
|
|
129
|
+
* plaintext token ONCE. Root write; the caller purges caches.
|
|
130
|
+
*/
|
|
131
|
+
@CaptureSpan()
|
|
132
|
+
public async rotateTokenById(data: {
|
|
133
|
+
id: ObjectID;
|
|
134
|
+
}): Promise<CalendarFeedRotation> {
|
|
135
|
+
const existing: Model | null = await this.findOneBy({
|
|
136
|
+
query: { _id: data.id },
|
|
137
|
+
select: { _id: true, tokenHash: true },
|
|
138
|
+
props: { isRoot: true, ignoreHooks: true },
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
if (!existing) {
|
|
142
|
+
throw new NotFoundException("Calendar feed not found.");
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const rotation: CalendarFeedRotation = CalendarFeedToken.buildRotation({
|
|
146
|
+
currentTokenHash: existing.tokenHash,
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
await this.updateOneById({
|
|
150
|
+
id: data.id,
|
|
151
|
+
data: CalendarFeedToken.toRotationUpdateData(rotation),
|
|
152
|
+
props: { isRoot: true },
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
return rotation;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
export default new Service();
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import CreateBy from "../Types/Database/CreateBy";
|
|
2
|
+
import DeleteBy from "../Types/Database/DeleteBy";
|
|
3
|
+
import UpdateBy from "../Types/Database/UpdateBy";
|
|
4
|
+
import { OnCreate, OnDelete, OnUpdate } from "../Types/Database/Hooks";
|
|
5
|
+
import DatabaseService from "./DatabaseService";
|
|
6
|
+
import CaptureSpan from "../Utils/Telemetry/CaptureSpan";
|
|
7
|
+
import Model, {
|
|
8
|
+
UserOnCallShiftReminderLogKind,
|
|
9
|
+
} from "../../Models/DatabaseModels/UserOnCallShiftReminderLog";
|
|
10
|
+
import BadDataException from "../../Types/Exception/BadDataException";
|
|
11
|
+
import NotAuthorizedException from "../../Types/Exception/NotAuthorizedException";
|
|
12
|
+
import ObjectID from "../../Types/ObjectID";
|
|
13
|
+
import OneUptimeDate from "../../Types/Date";
|
|
14
|
+
|
|
15
|
+
const MILLISECONDS_PER_MINUTE: number = 60 * 1000;
|
|
16
|
+
|
|
17
|
+
const ALLOWED_KINDS: Set<string> = new Set<string>(
|
|
18
|
+
Object.values(UserOnCallShiftReminderLogKind),
|
|
19
|
+
);
|
|
20
|
+
|
|
21
|
+
/*
|
|
22
|
+
* The shift-reminder ledger. Root only, in every direction: a claim row is
|
|
23
|
+
* the thing that makes "send exactly once" true, so nothing outside the
|
|
24
|
+
* worker may insert, edit or remove one.
|
|
25
|
+
*
|
|
26
|
+
* The create hook normalises the idempotency key so two workers that compute
|
|
27
|
+
* the same shift a few milliseconds apart still collide on the unique index:
|
|
28
|
+
* shiftStartsAt is truncated to the minute here as well as by the worker
|
|
29
|
+
* (belt and braces - the seam normalisation upstream already makes starts
|
|
30
|
+
* minute-granular, this makes it a guarantee of the table rather than of one
|
|
31
|
+
* caller), and minutesBeforeShift defaults to 0 for change notices.
|
|
32
|
+
*/
|
|
33
|
+
export class Service extends DatabaseService<Model> {
|
|
34
|
+
public constructor() {
|
|
35
|
+
super(Model);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
@CaptureSpan()
|
|
39
|
+
protected override async onBeforeCreate(
|
|
40
|
+
createBy: CreateBy<Model>,
|
|
41
|
+
): Promise<OnCreate<Model>> {
|
|
42
|
+
if (!createBy.props.isRoot) {
|
|
43
|
+
throw new NotAuthorizedException(
|
|
44
|
+
"Shift reminder logs are written by the reminder worker only.",
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const projectId: ObjectID | undefined =
|
|
49
|
+
createBy.data.projectId || createBy.props.tenantId;
|
|
50
|
+
|
|
51
|
+
if (!projectId) {
|
|
52
|
+
throw new BadDataException("projectId is required");
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
if (!createBy.data.userId) {
|
|
56
|
+
throw new BadDataException("userId is required");
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
if (!createBy.data.onCallDutyPolicyScheduleId) {
|
|
60
|
+
throw new BadDataException("onCallDutyPolicyScheduleId is required");
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
if (!createBy.data.shiftStartsAt) {
|
|
64
|
+
throw new BadDataException("shiftStartsAt is required");
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
if (
|
|
68
|
+
!createBy.data.kind ||
|
|
69
|
+
!ALLOWED_KINDS.has(createBy.data.kind as string)
|
|
70
|
+
) {
|
|
71
|
+
throw new BadDataException(
|
|
72
|
+
`kind must be one of: ${Object.values(UserOnCallShiftReminderLogKind).join(", ")}`,
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
createBy.data.projectId = projectId;
|
|
77
|
+
|
|
78
|
+
createBy.data.shiftStartsAt = Service.truncateToMinute(
|
|
79
|
+
OneUptimeDate.fromString(createBy.data.shiftStartsAt),
|
|
80
|
+
);
|
|
81
|
+
|
|
82
|
+
createBy.data.minutesBeforeShift = Service.validateMinutesBeforeShift(
|
|
83
|
+
createBy.data.minutesBeforeShift,
|
|
84
|
+
);
|
|
85
|
+
|
|
86
|
+
if (!createBy.data.claimedAt) {
|
|
87
|
+
createBy.data.claimedAt = OneUptimeDate.getCurrentDate();
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
return { createBy, carryForward: null };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
@CaptureSpan()
|
|
94
|
+
protected override async onBeforeUpdate(
|
|
95
|
+
updateBy: UpdateBy<Model>,
|
|
96
|
+
): Promise<OnUpdate<Model>> {
|
|
97
|
+
if (!updateBy.props.isRoot) {
|
|
98
|
+
throw new NotAuthorizedException(
|
|
99
|
+
"Shift reminder logs are written by the reminder worker only.",
|
|
100
|
+
);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
return { updateBy, carryForward: null };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
@CaptureSpan()
|
|
107
|
+
protected override async onBeforeDelete(
|
|
108
|
+
deleteBy: DeleteBy<Model>,
|
|
109
|
+
): Promise<OnDelete<Model>> {
|
|
110
|
+
if (!deleteBy.props.isRoot) {
|
|
111
|
+
throw new NotAuthorizedException(
|
|
112
|
+
"Shift reminder logs are written by the reminder worker only.",
|
|
113
|
+
);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
return { deleteBy, carryForward: null };
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// Zero the seconds and milliseconds. Part of the idempotency key.
|
|
120
|
+
public static truncateToMinute(date: Date): Date {
|
|
121
|
+
return new Date(
|
|
122
|
+
Math.floor(date.getTime() / MILLISECONDS_PER_MINUTE) *
|
|
123
|
+
MILLISECONDS_PER_MINUTE,
|
|
124
|
+
);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/*
|
|
128
|
+
* 0 (a change notice) or a positive whole number of minutes. Missing means
|
|
129
|
+
* 0. Anything else is a caller bug, not a value to store.
|
|
130
|
+
*/
|
|
131
|
+
public static validateMinutesBeforeShift(value: unknown): number {
|
|
132
|
+
if (value === undefined || value === null) {
|
|
133
|
+
return 0;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const parsed: number =
|
|
137
|
+
typeof value === "string" && value.trim() !== ""
|
|
138
|
+
? Number(value)
|
|
139
|
+
: (value as number);
|
|
140
|
+
|
|
141
|
+
if (typeof parsed !== "number" || !Number.isInteger(parsed) || parsed < 0) {
|
|
142
|
+
throw new BadDataException(
|
|
143
|
+
"minutesBeforeShift must be a whole number of minutes, 0 or more.",
|
|
144
|
+
);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
return parsed;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export default new Service();
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import CreateBy from "../Types/Database/CreateBy";
|
|
2
|
+
import UpdateBy from "../Types/Database/UpdateBy";
|
|
3
|
+
import { OnCreate, OnUpdate } from "../Types/Database/Hooks";
|
|
4
|
+
import DatabaseService from "./DatabaseService";
|
|
5
|
+
import CaptureSpan from "../Utils/Telemetry/CaptureSpan";
|
|
6
|
+
import Model, {
|
|
7
|
+
MAX_MINUTES_BEFORE_SHIFT,
|
|
8
|
+
MIN_MINUTES_BEFORE_SHIFT,
|
|
9
|
+
} from "../../Models/DatabaseModels/UserOnCallShiftReminder";
|
|
10
|
+
import BadDataException from "../../Types/Exception/BadDataException";
|
|
11
|
+
import ObjectID from "../../Types/ObjectID";
|
|
12
|
+
import PositiveNumber from "../../Types/PositiveNumber";
|
|
13
|
+
|
|
14
|
+
/*
|
|
15
|
+
* "Remind me N minutes before my shift" rows.
|
|
16
|
+
*
|
|
17
|
+
* The hooks keep the table honest for the reminder worker: every row has an
|
|
18
|
+
* owner (defaulted to the session user when the client did not send one, so
|
|
19
|
+
* the settings page can post `{ minutesBeforeShift }` and nothing else), a
|
|
20
|
+
* project, and a lead time inside 15 minutes ... 2 weeks. A duplicate lead
|
|
21
|
+
* is answered with a message rather than a unique-violation 500.
|
|
22
|
+
*/
|
|
23
|
+
export class Service extends DatabaseService<Model> {
|
|
24
|
+
public constructor() {
|
|
25
|
+
super(Model);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
@CaptureSpan()
|
|
29
|
+
protected override async onBeforeCreate(
|
|
30
|
+
createBy: CreateBy<Model>,
|
|
31
|
+
): Promise<OnCreate<Model>> {
|
|
32
|
+
const projectId: ObjectID | undefined =
|
|
33
|
+
createBy.data.projectId || createBy.props.tenantId;
|
|
34
|
+
|
|
35
|
+
if (!projectId) {
|
|
36
|
+
throw new BadDataException("projectId is required");
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
if (!createBy.data.userId && createBy.props.userId) {
|
|
40
|
+
createBy.data.userId = createBy.props.userId;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
if (!createBy.data.userId) {
|
|
44
|
+
throw new BadDataException("userId is required");
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
createBy.data.projectId = projectId;
|
|
48
|
+
|
|
49
|
+
createBy.data.minutesBeforeShift = Service.validateMinutesBeforeShift(
|
|
50
|
+
createBy.data.minutesBeforeShift,
|
|
51
|
+
);
|
|
52
|
+
|
|
53
|
+
const duplicates: PositiveNumber = await this.countBy({
|
|
54
|
+
query: {
|
|
55
|
+
projectId: projectId,
|
|
56
|
+
userId: createBy.data.userId,
|
|
57
|
+
minutesBeforeShift: createBy.data.minutesBeforeShift,
|
|
58
|
+
},
|
|
59
|
+
props: {
|
|
60
|
+
isRoot: true,
|
|
61
|
+
},
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
if (duplicates.toNumber() > 0) {
|
|
65
|
+
throw new BadDataException(
|
|
66
|
+
`You already have a reminder ${createBy.data.minutesBeforeShift} minutes before your shifts.`,
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
return { createBy, carryForward: null };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
@CaptureSpan()
|
|
74
|
+
protected override async onBeforeUpdate(
|
|
75
|
+
updateBy: UpdateBy<Model>,
|
|
76
|
+
): Promise<OnUpdate<Model>> {
|
|
77
|
+
if (updateBy.data.minutesBeforeShift !== undefined) {
|
|
78
|
+
updateBy.data.minutesBeforeShift = Service.validateMinutesBeforeShift(
|
|
79
|
+
updateBy.data.minutesBeforeShift,
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
return { updateBy, carryForward: null };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/*
|
|
87
|
+
* A whole number of minutes between MIN_MINUTES_BEFORE_SHIFT and
|
|
88
|
+
* MAX_MINUTES_BEFORE_SHIFT. Numeric strings are accepted (a form may post
|
|
89
|
+
* one); missing, fractional, negative or out-of-range values are a 400.
|
|
90
|
+
*/
|
|
91
|
+
public static validateMinutesBeforeShift(value: unknown): number {
|
|
92
|
+
const parsed: number =
|
|
93
|
+
typeof value === "string" && value.trim() !== ""
|
|
94
|
+
? Number(value)
|
|
95
|
+
: (value as number);
|
|
96
|
+
|
|
97
|
+
if (
|
|
98
|
+
typeof parsed !== "number" ||
|
|
99
|
+
!Number.isInteger(parsed) ||
|
|
100
|
+
parsed < MIN_MINUTES_BEFORE_SHIFT ||
|
|
101
|
+
parsed > MAX_MINUTES_BEFORE_SHIFT
|
|
102
|
+
) {
|
|
103
|
+
throw new BadDataException(
|
|
104
|
+
`Reminder lead time must be a whole number of minutes between ${MIN_MINUTES_BEFORE_SHIFT} and ${MAX_MINUTES_BEFORE_SHIFT} (two weeks).`,
|
|
105
|
+
);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
return parsed;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export default new Service();
|
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
import crypto from "crypto";
|
|
2
|
+
import OneUptimeDate from "../../../Types/Date";
|
|
3
|
+
import { PREVIOUS_TOKEN_GRACE_DAYS } from "../../../Types/OnCallDutyPolicy/CalendarFeedWindow";
|
|
4
|
+
|
|
5
|
+
/*
|
|
6
|
+
* The capability token that addresses an on-call calendar feed.
|
|
7
|
+
*
|
|
8
|
+
* A calendar client keeps the feed URL forever and re-fetches it unattended,
|
|
9
|
+
* so the token has to be both unguessable and cheap to look up: 256 bits from
|
|
10
|
+
* the CSPRNG, carried in the URL as base64url (43 characters, no padding), and
|
|
11
|
+
* stored ONLY as its unkeyed SHA-256 hex digest. The digest is what the public
|
|
12
|
+
* route queries by, which keeps the plaintext out of the indexed column; a
|
|
13
|
+
* database dump therefore yields hashes, not working feed URLs.
|
|
14
|
+
*
|
|
15
|
+
* Unkeyed on purpose: a keyed hash (HMAC over EncryptionSecret, the backup-code
|
|
16
|
+
* recipe) would tie every feed to the instance secret, and rotating that secret
|
|
17
|
+
* would silently break every subscribed calendar in the world with no way to
|
|
18
|
+
* tell the user. The token already carries 256 bits of entropy, so a rainbow
|
|
19
|
+
* table buys nothing and the key adds no strength worth that failure mode.
|
|
20
|
+
*
|
|
21
|
+
* The hint is the last four characters - enough for the settings page to say
|
|
22
|
+
* "link ending in …k3Qx" so a user can tell two links apart, far too little to
|
|
23
|
+
* recover anything.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
// crypto.randomBytes(32) -> 32 bytes -> ceil(32 * 8 / 6) = 43 base64url chars.
|
|
27
|
+
const TOKEN_BYTE_LENGTH: number = 32;
|
|
28
|
+
|
|
29
|
+
export const CALENDAR_FEED_TOKEN_LENGTH: number = 43;
|
|
30
|
+
|
|
31
|
+
export const CALENDAR_FEED_TOKEN_HINT_LENGTH: number = 4;
|
|
32
|
+
|
|
33
|
+
/*
|
|
34
|
+
* Shape guard for the public route. Anything that does not match is answered
|
|
35
|
+
* 404 before any database work, so a scanner probing the path costs one regex.
|
|
36
|
+
*/
|
|
37
|
+
export const CALENDAR_FEED_TOKEN_REGEX: RegExp = /^[A-Za-z0-9_-]{43}$/;
|
|
38
|
+
|
|
39
|
+
export interface MintedCalendarFeedToken {
|
|
40
|
+
/** The plaintext token. Goes into the URL and, encrypted, into `token`. */
|
|
41
|
+
token: string;
|
|
42
|
+
/** Unkeyed SHA-256 hex digest of the token. The indexed lookup column. */
|
|
43
|
+
tokenHash: string;
|
|
44
|
+
/** Last four characters of the token, for display only. */
|
|
45
|
+
tokenHint: string;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/*
|
|
49
|
+
* Everything a feed row's token columns hold after a rotation: the new
|
|
50
|
+
* token set, when it was minted, and the rotated-out hash with the instant
|
|
51
|
+
* it stops being honoured. `previousTokenHash` is null when there was no
|
|
52
|
+
* token before (a first mint), so a fresh feed never carries a grace entry.
|
|
53
|
+
*/
|
|
54
|
+
export interface CalendarFeedRotation extends MintedCalendarFeedToken {
|
|
55
|
+
rotatedAt: Date;
|
|
56
|
+
previousTokenHash: string | null;
|
|
57
|
+
previousTokenExpiresAt: Date | null;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/*
|
|
61
|
+
* The token columns every feed model carries (UserOnCallCalendarFeed,
|
|
62
|
+
* OnCallDutyPolicyScheduleCalendarFeed, ProjectOnCallCalendarFeed). Typed
|
|
63
|
+
* structurally so the three services share one rule without a common base.
|
|
64
|
+
*/
|
|
65
|
+
export interface CalendarFeedTokenColumns {
|
|
66
|
+
token?: string | undefined;
|
|
67
|
+
tokenHash?: string | undefined;
|
|
68
|
+
tokenHint?: string | undefined;
|
|
69
|
+
rotatedAt?: Date | undefined;
|
|
70
|
+
previousTokenHash?: string | undefined;
|
|
71
|
+
previousTokenExpiresAt?: Date | undefined;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export interface CalendarFeedRotationUpdateData {
|
|
75
|
+
token: string;
|
|
76
|
+
tokenHash: string;
|
|
77
|
+
tokenHint: string;
|
|
78
|
+
rotatedAt: Date;
|
|
79
|
+
previousTokenHash: string;
|
|
80
|
+
previousTokenExpiresAt: Date;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export default class CalendarFeedToken {
|
|
84
|
+
/**
|
|
85
|
+
* A fresh 43-character base64url token from 32 CSPRNG bytes.
|
|
86
|
+
*/
|
|
87
|
+
public static mint(): string {
|
|
88
|
+
return crypto.randomBytes(TOKEN_BYTE_LENGTH).toString("base64url");
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Unkeyed SHA-256 of the token, lowercase hex (64 characters). This is the
|
|
93
|
+
* value stored in `tokenHash` / `previousTokenHash` and the value the public
|
|
94
|
+
* route looks up by. Deterministic: the same token always hashes the same.
|
|
95
|
+
*/
|
|
96
|
+
public static hash(token: string): string {
|
|
97
|
+
return crypto.createHash("sha256").update(token, "utf8").digest("hex");
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* The display hint - the last four characters of the token. Never enough to
|
|
102
|
+
* reconstruct the token; enough to tell "…k3Qx" from "…Q9zA" in the UI.
|
|
103
|
+
*/
|
|
104
|
+
public static hint(token: string): string {
|
|
105
|
+
return token.slice(-CALENDAR_FEED_TOKEN_HINT_LENGTH);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* True when the string has exactly the shape a minted token has. Used as
|
|
110
|
+
* the shape guard on the public route so a malformed path never reaches the
|
|
111
|
+
* database.
|
|
112
|
+
*/
|
|
113
|
+
public static isValidShape(token: unknown): token is string {
|
|
114
|
+
return typeof token === "string" && CALENDAR_FEED_TOKEN_REGEX.test(token);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Everything a feed row needs when a token is minted or rotated.
|
|
119
|
+
*/
|
|
120
|
+
public static mintSet(): MintedCalendarFeedToken {
|
|
121
|
+
const token: string = CalendarFeedToken.mint();
|
|
122
|
+
|
|
123
|
+
return {
|
|
124
|
+
token: token,
|
|
125
|
+
tokenHash: CalendarFeedToken.hash(token),
|
|
126
|
+
tokenHint: CalendarFeedToken.hint(token),
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* The token columns a feed row should hold after minting or rotating.
|
|
132
|
+
*
|
|
133
|
+
* The hash the row held before (if any) moves to `previousTokenHash` and
|
|
134
|
+
* keeps serving an EMPTY calendar until `previousTokenExpiresAt`
|
|
135
|
+
* (PREVIOUS_TOKEN_GRACE_DAYS from now), so a still-subscribed client clears
|
|
136
|
+
* its copy instead of showing "could not fetch" until the user notices.
|
|
137
|
+
*/
|
|
138
|
+
public static buildRotation(data: {
|
|
139
|
+
currentTokenHash: string | null | undefined;
|
|
140
|
+
now?: Date | undefined;
|
|
141
|
+
}): CalendarFeedRotation {
|
|
142
|
+
const now: Date = data.now || OneUptimeDate.getCurrentDate();
|
|
143
|
+
const minted: MintedCalendarFeedToken = CalendarFeedToken.mintSet();
|
|
144
|
+
|
|
145
|
+
const previousTokenHash: string | null = data.currentTokenHash || null;
|
|
146
|
+
|
|
147
|
+
return {
|
|
148
|
+
...minted,
|
|
149
|
+
rotatedAt: now,
|
|
150
|
+
previousTokenHash: previousTokenHash,
|
|
151
|
+
previousTokenExpiresAt: previousTokenHash
|
|
152
|
+
? OneUptimeDate.addRemoveDays(now, PREVIOUS_TOKEN_GRACE_DAYS)
|
|
153
|
+
: null,
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Populate the token columns of a row about to be INSERTED.
|
|
159
|
+
*
|
|
160
|
+
* With `trustSuppliedToken`, a well-formed plaintext token already on the
|
|
161
|
+
* row (the calendar API mints one up front so it can build the URL in the
|
|
162
|
+
* same response) is kept and the hash and hint are DERIVED from it. Without
|
|
163
|
+
* it - every non-root create - whatever the request carried is discarded
|
|
164
|
+
* and a fresh token is minted. Either way a malformed token or a
|
|
165
|
+
* caller-chosen hash is replaced: there is no path to a row whose hash
|
|
166
|
+
* disagrees with its token, and no path for a request body to choose the
|
|
167
|
+
* secret. A brand-new row never carries a grace-period hash.
|
|
168
|
+
*/
|
|
169
|
+
public static applyTokenColumnsOnCreate(
|
|
170
|
+
data: CalendarFeedTokenColumns,
|
|
171
|
+
options: { trustSuppliedToken: boolean },
|
|
172
|
+
): MintedCalendarFeedToken {
|
|
173
|
+
let minted: MintedCalendarFeedToken;
|
|
174
|
+
|
|
175
|
+
if (
|
|
176
|
+
options.trustSuppliedToken &&
|
|
177
|
+
CalendarFeedToken.isValidShape(data.token)
|
|
178
|
+
) {
|
|
179
|
+
minted = {
|
|
180
|
+
token: data.token,
|
|
181
|
+
tokenHash: CalendarFeedToken.hash(data.token),
|
|
182
|
+
tokenHint: CalendarFeedToken.hint(data.token),
|
|
183
|
+
};
|
|
184
|
+
} else {
|
|
185
|
+
minted = CalendarFeedToken.mintSet();
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
data.token = minted.token;
|
|
189
|
+
data.tokenHash = minted.tokenHash;
|
|
190
|
+
data.tokenHint = minted.tokenHint;
|
|
191
|
+
data.rotatedAt = OneUptimeDate.getCurrentDate();
|
|
192
|
+
data.previousTokenHash = undefined;
|
|
193
|
+
data.previousTokenExpiresAt = undefined;
|
|
194
|
+
|
|
195
|
+
return minted;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* The partial row a rotation writes. `previousTokenHash` /
|
|
200
|
+
* `previousTokenExpiresAt` are null on a first mint, which the update path
|
|
201
|
+
* has to be able to write; the model types them as optional strings/dates,
|
|
202
|
+
* hence the cast at this one boundary.
|
|
203
|
+
*/
|
|
204
|
+
public static toRotationUpdateData(
|
|
205
|
+
rotation: CalendarFeedRotation,
|
|
206
|
+
): CalendarFeedRotationUpdateData {
|
|
207
|
+
return {
|
|
208
|
+
token: rotation.token,
|
|
209
|
+
tokenHash: rotation.tokenHash,
|
|
210
|
+
tokenHint: rotation.tokenHint,
|
|
211
|
+
rotatedAt: rotation.rotatedAt,
|
|
212
|
+
previousTokenHash: rotation.previousTokenHash as unknown as string,
|
|
213
|
+
previousTokenExpiresAt:
|
|
214
|
+
rotation.previousTokenExpiresAt as unknown as Date,
|
|
215
|
+
};
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* Constant-time comparison of two hashes, for callers that compare a
|
|
220
|
+
* freshly computed digest against a stored one in memory rather than in a
|
|
221
|
+
* WHERE clause. Length mismatch is answered false without leaking timing on
|
|
222
|
+
* the content.
|
|
223
|
+
*/
|
|
224
|
+
public static isHashEqual(a: string, b: string): boolean {
|
|
225
|
+
/*
|
|
226
|
+
* Plain Uint8Arrays rather than Buffers: the two are interchangeable at
|
|
227
|
+
* runtime, but the App build's @types/node rejects Buffer where an
|
|
228
|
+
* ArrayBufferView is expected (the same mismatch PasswordHash and
|
|
229
|
+
* TwoFactorBackupCode trip over), and TextEncoder is global on every
|
|
230
|
+
* supported Node.
|
|
231
|
+
*/
|
|
232
|
+
const bytesA: Uint8Array = new TextEncoder().encode(a);
|
|
233
|
+
const bytesB: Uint8Array = new TextEncoder().encode(b);
|
|
234
|
+
|
|
235
|
+
if (bytesA.length !== bytesB.length) {
|
|
236
|
+
return false;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
return crypto.timingSafeEqual(bytesA, bytesB);
|
|
240
|
+
}
|
|
241
|
+
}
|