@oneuptime/common 12.0.20 → 12.0.22

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 (119) hide show
  1. package/Models/AnalyticsModels/ChangeEvent.ts +331 -0
  2. package/Models/AnalyticsModels/Index.ts +2 -0
  3. package/Models/DatabaseModels/Index.ts +2 -0
  4. package/Models/DatabaseModels/UserTwoFactorBackupCode.ts +262 -0
  5. package/Server/API/UserTwoFactorBackupCodeAPI.ts +258 -0
  6. package/Server/EnvironmentConfig.ts +1 -8
  7. package/Server/Infrastructure/Postgres/SchemaMigrations/1789000000000-AddUserSlackAndMicrosoftTeams.ts +42 -0
  8. package/Server/Infrastructure/Postgres/SchemaMigrations/1789100000000-AddUserTwoFactorBackupCode.ts +63 -0
  9. package/Server/Infrastructure/Postgres/SchemaMigrations/Index.ts +2 -2
  10. package/Server/Services/ChangeEventService.ts +11 -0
  11. package/Server/Services/EnterpriseLicenseService.ts +3 -3
  12. package/Server/Services/Index.ts +4 -0
  13. package/Server/Services/UserService.ts +42 -0
  14. package/Server/Services/UserTwoFactorBackupCodeService.ts +355 -0
  15. package/Server/Utils/Attribution.ts +11 -11
  16. package/Server/Utils/Marketing/MarketingEventWebhook.ts +4 -5
  17. package/Server/Utils/Telemetry/ChangeEventRow.ts +228 -0
  18. package/Server/Utils/TwoFactorBackupCode.ts +266 -0
  19. package/Server/Views/Partials/AnalyticsConsent.ejs +3 -95
  20. package/Tests/App/Dashboard/DashboardChartWidgetZoom.test.tsx +64 -0
  21. package/Tests/App/Dashboard/EventOverlayHook.test.tsx +249 -0
  22. package/Tests/App/Dashboard/InvestigationArtifacts.test.tsx +315 -0
  23. package/Tests/App/Dashboard/InvestigationDrawer.test.tsx +258 -0
  24. package/Tests/App/Dashboard/MetricChartsCompare.test.tsx +171 -0
  25. package/Tests/App/Dashboard/MetricViewCompare.test.tsx +233 -0
  26. package/Tests/Server/API/UserAuthenticationAPI.test.ts +16 -3
  27. package/Tests/Server/API/UserTwoFactorAuthAdminAPI.test.ts +18 -2
  28. package/Tests/Server/API/UserTwoFactorBackupCodeAPI.test.ts +1390 -0
  29. package/Tests/Server/Infrastructure/Postgres/AddUserSlackAndMicrosoftTeamsMigration.test.ts +236 -0
  30. package/Tests/Server/Services/UserAuthenticationService.test.ts +23 -1
  31. package/Tests/Server/Services/UserTwoFactorAuthAdmin.test.ts +21 -0
  32. package/Tests/Server/Services/UserTwoFactorBackupCodeAdminSurface.test.ts +919 -0
  33. package/Tests/Server/Services/UserTwoFactorBackupCodeService.test.ts +862 -0
  34. package/Tests/Server/Utils/AI/SRE/Insights/Detectors/ExceptionIdentity.test.ts +224 -0
  35. package/Tests/Server/Utils/Attribution.test.ts +4 -5
  36. package/Tests/Server/Utils/Marketing/MarketingEventUtil.test.ts +2 -2
  37. package/Tests/Server/Utils/Monitor/MonitorCriteriaExpectationBuilder.test.ts +300 -0
  38. package/Tests/Server/Utils/TwoFactorBackupCode.test.ts +475 -0
  39. package/Tests/UI/Components/Charts/ChartGhostSeries.test.tsx +103 -0
  40. package/Tests/UI/Components/Charts/TooltipEntries.test.ts +44 -8
  41. package/Tests/Utils/Telemetry/CrossSignalScope.test.ts +13 -2
  42. package/Types/AnalyticsDatabase/AnalyticsTableName.ts +7 -0
  43. package/Types/Email/EmailTemplateType.ts +2 -0
  44. package/Types/Marketing/Attribution.ts +9 -15
  45. package/Types/Marketing/MarketingEvent.ts +10 -46
  46. package/Types/UserAuthenticationStatus.ts +16 -0
  47. package/UI/Components/Charts/Area/AreaChart.tsx +15 -0
  48. package/UI/Components/Charts/ChartGroup/ChartGroup.tsx +16 -1
  49. package/UI/Components/Charts/ChartLibrary/AreaChart/AreaChart.tsx +189 -93
  50. package/UI/Components/Charts/ChartLibrary/BarChart/BarChart.tsx +6 -1
  51. package/UI/Components/Charts/ChartLibrary/LineChart/LineChart.tsx +213 -114
  52. package/UI/Components/Charts/ChartLibrary/Utils/TooltipEntries.ts +42 -27
  53. package/UI/Components/Charts/Line/LineChart.tsx +15 -0
  54. package/Utils/Metrics/MetricExplorerUrl.ts +119 -5
  55. package/Utils/Telemetry/CrossSignalScope.ts +19 -10
  56. package/build/dist/Models/AnalyticsModels/ChangeEvent.js +298 -0
  57. package/build/dist/Models/AnalyticsModels/ChangeEvent.js.map +1 -0
  58. package/build/dist/Models/AnalyticsModels/Index.js +2 -0
  59. package/build/dist/Models/AnalyticsModels/Index.js.map +1 -1
  60. package/build/dist/Models/DatabaseModels/Index.js +2 -0
  61. package/build/dist/Models/DatabaseModels/Index.js.map +1 -1
  62. package/build/dist/Models/DatabaseModels/UserTwoFactorBackupCode.js +277 -0
  63. package/build/dist/Models/DatabaseModels/UserTwoFactorBackupCode.js.map +1 -0
  64. package/build/dist/Server/API/UserTwoFactorBackupCodeAPI.js +201 -0
  65. package/build/dist/Server/API/UserTwoFactorBackupCodeAPI.js.map +1 -0
  66. package/build/dist/Server/EnvironmentConfig.js +1 -7
  67. package/build/dist/Server/EnvironmentConfig.js.map +1 -1
  68. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1789000000000-AddUserSlackAndMicrosoftTeams.js +39 -0
  69. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1789000000000-AddUserSlackAndMicrosoftTeams.js.map +1 -1
  70. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1789100000000-AddUserTwoFactorBackupCode.js +46 -0
  71. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1789100000000-AddUserTwoFactorBackupCode.js.map +1 -0
  72. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js +2 -2
  73. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js.map +1 -1
  74. package/build/dist/Server/Services/ChangeEventService.js +9 -0
  75. package/build/dist/Server/Services/ChangeEventService.js.map +1 -0
  76. package/build/dist/Server/Services/EnterpriseLicenseService.js +3 -3
  77. package/build/dist/Server/Services/Index.js +4 -0
  78. package/build/dist/Server/Services/Index.js.map +1 -1
  79. package/build/dist/Server/Services/UserService.js +39 -0
  80. package/build/dist/Server/Services/UserService.js.map +1 -1
  81. package/build/dist/Server/Services/UserTwoFactorBackupCodeService.js +327 -0
  82. package/build/dist/Server/Services/UserTwoFactorBackupCodeService.js.map +1 -0
  83. package/build/dist/Server/Utils/Attribution.js +6 -6
  84. package/build/dist/Server/Utils/Marketing/MarketingEventWebhook.js +4 -5
  85. package/build/dist/Server/Utils/Marketing/MarketingEventWebhook.js.map +1 -1
  86. package/build/dist/Server/Utils/Telemetry/ChangeEventRow.js +150 -0
  87. package/build/dist/Server/Utils/Telemetry/ChangeEventRow.js.map +1 -0
  88. package/build/dist/Server/Utils/TwoFactorBackupCode.js +269 -0
  89. package/build/dist/Server/Utils/TwoFactorBackupCode.js.map +1 -0
  90. package/build/dist/Types/AnalyticsDatabase/AnalyticsTableName.js +7 -0
  91. package/build/dist/Types/AnalyticsDatabase/AnalyticsTableName.js.map +1 -1
  92. package/build/dist/Types/Email/EmailTemplateType.js +2 -0
  93. package/build/dist/Types/Email/EmailTemplateType.js.map +1 -1
  94. package/build/dist/Types/Marketing/Attribution.js +9 -15
  95. package/build/dist/Types/Marketing/Attribution.js.map +1 -1
  96. package/build/dist/Types/Marketing/MarketingEvent.js +2 -33
  97. package/build/dist/Types/Marketing/MarketingEvent.js.map +1 -1
  98. package/build/dist/UI/Components/Charts/Area/AreaChart.js +1 -1
  99. package/build/dist/UI/Components/Charts/Area/AreaChart.js.map +1 -1
  100. package/build/dist/UI/Components/Charts/ChartGroup/ChartGroup.js +9 -1
  101. package/build/dist/UI/Components/Charts/ChartGroup/ChartGroup.js.map +1 -1
  102. package/build/dist/UI/Components/Charts/ChartLibrary/AreaChart/AreaChart.js +92 -38
  103. package/build/dist/UI/Components/Charts/ChartLibrary/AreaChart/AreaChart.js.map +1 -1
  104. package/build/dist/UI/Components/Charts/ChartLibrary/BarChart/BarChart.js +8 -8
  105. package/build/dist/UI/Components/Charts/ChartLibrary/BarChart/BarChart.js.map +1 -1
  106. package/build/dist/UI/Components/Charts/ChartLibrary/LineChart/LineChart.js +95 -37
  107. package/build/dist/UI/Components/Charts/ChartLibrary/LineChart/LineChart.js.map +1 -1
  108. package/build/dist/UI/Components/Charts/ChartLibrary/Utils/TooltipEntries.js +39 -24
  109. package/build/dist/UI/Components/Charts/ChartLibrary/Utils/TooltipEntries.js.map +1 -1
  110. package/build/dist/UI/Components/Charts/Line/LineChart.js +1 -1
  111. package/build/dist/UI/Components/Charts/Line/LineChart.js.map +1 -1
  112. package/build/dist/Utils/Metrics/MetricExplorerUrl.js +81 -0
  113. package/build/dist/Utils/Metrics/MetricExplorerUrl.js.map +1 -1
  114. package/build/dist/Utils/Telemetry/CrossSignalScope.js +14 -8
  115. package/build/dist/Utils/Telemetry/CrossSignalScope.js.map +1 -1
  116. package/package.json +1 -1
  117. package/Server/Infrastructure/Postgres/SchemaMigrations/1788900000000-RedactStoredMonitorIngestSecrets.ts +0 -117
  118. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1788900000000-RedactStoredMonitorIngestSecrets.js +0 -106
  119. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1788900000000-RedactStoredMonitorIngestSecrets.js.map +0 -1
@@ -0,0 +1,266 @@
1
+ import { EncryptionSecret } from "../EnvironmentConfig";
2
+ import ObjectID from "../../Types/ObjectID";
3
+ import CaptureSpan from "./Telemetry/CaptureSpan";
4
+ import crypto from "crypto";
5
+
6
+ /*
7
+ * Single-use recovery codes for an account whose second factor is gone --
8
+ * the phone that held the authenticator app, or the security key that is now
9
+ * in a taxi somewhere.
10
+ *
11
+ * WHY THIS IS SERVER-ONLY
12
+ *
13
+ * Nothing in this file falls back to Math.random. Common/Utils/UUID does, on
14
+ * purpose, because it is bundled into the dashboard and has to keep producing
15
+ * well-formed ids in a browser with no Web Crypto -- which is exactly why
16
+ * ObjectID.generate() must never mint a code here. A backup code is a
17
+ * password-equivalent credential; if the platform cannot produce secure
18
+ * randomness, generation must throw rather than quietly emit something
19
+ * predictable. Common/Server/Utils/VerificationCode.ts makes the same call for
20
+ * the same reason.
21
+ *
22
+ * THE CODE SPACE
23
+ *
24
+ * Ten characters drawn uniformly from a 32 symbol alphabet is 2^50 codes, and
25
+ * a user holds ten of them at once -- so a blind guess lands with probability
26
+ * ~10/2^50, about one in 10^14. That is far beyond anything the rate limiter
27
+ * needs to defend, which matters because it settles the design question a TOTP
28
+ * code cannot settle: the six digit space is small enough that the limiter IS
29
+ * the control, whereas here the code itself is.
30
+ *
31
+ * THE HASH
32
+ *
33
+ * Codes are stored as HMAC-SHA256 keyed by the instance's EncryptionSecret,
34
+ * never in the clear, and the fast keyed-digest lane is the RIGHT one here
35
+ * rather than scrypt. scrypt exists to make guessing a low-entropy,
36
+ * human-chosen secret expensive; a code minted above has no low-entropy
37
+ * structure to guess, so the cost would buy nothing and would be paid on every
38
+ * verification. Keying with the EncryptionSecret is what a bare SHA-256 would
39
+ * miss: it lives in configuration rather than in Postgres, so a database dump
40
+ * on its own cannot be run through a dictionary of every possible code.
41
+ *
42
+ * The digest is deterministic given (userId, code), which is deliberate and is
43
+ * what makes single-use consumption a single conditional UPDATE rather than a
44
+ * read of every one of the user's rows followed by a comparison of each. A
45
+ * per-row salt would forfeit that for no gain -- see the note on domain
46
+ * separation below for the property it would have been bought for, which the
47
+ * userId already provides.
48
+ */
49
+
50
+ /**
51
+ * Crockford's Base32 alphabet: the digits and the uppercase letters, minus
52
+ * I, L, O and U.
53
+ *
54
+ * These codes get read off a screen and typed back in months later, possibly
55
+ * from a piece of paper in a drawer, so the alphabet is chosen for the eye
56
+ * rather than for density. I/1, L/1 and O/0 are the pairs people transcribe
57
+ * wrongly; U is dropped by Crockford so that no code can spell an obscenity
58
+ * at a user who did nothing to deserve one.
59
+ *
60
+ * Exactly 32 symbols, so each character carries a clean five bits and the
61
+ * entropy arithmetic in the header comment is exact rather than approximate.
62
+ */
63
+ export const BackupCodeAlphabet: string = "0123456789ABCDEFGHJKMNPQRSTVWXYZ";
64
+
65
+ /** Characters per code. Ten symbols over a 32 symbol alphabet is 2^50. */
66
+ export const BackupCodeLength: number = 10;
67
+
68
+ /**
69
+ * How many codes a user is issued at once.
70
+ *
71
+ * Ten is enough that losing a phone does not become an emergency after the
72
+ * second sign-in, and small enough that the printed list is one short block a
73
+ * person will actually keep.
74
+ */
75
+ export const BackupCodeSetSize: number = 10;
76
+
77
+ /** Characters per group in the displayed form, e.g. `AB3D5-9XZQ2`. */
78
+ const DISPLAY_GROUP_LENGTH: number = 5;
79
+
80
+ /*
81
+ * Prefixed into every digest so a stored hash is bound to the scheme that
82
+ * produced it. If the construction below ever has to change, old rows keep
83
+ * verifying under the version they were written with instead of silently
84
+ * failing to match and locking a user out of their own recovery codes.
85
+ */
86
+ const HASH_SCHEME_VERSION: string = "v1";
87
+
88
+ /*
89
+ * Characters a person plausibly types in place of a symbol that is not in the
90
+ * alphabet. Applied before the strip below, so `O` becomes `0` rather than
91
+ * being deleted -- deleting it would shorten the code and guarantee a
92
+ * mismatch, which is the confusing failure this map exists to avoid.
93
+ */
94
+ const AMBIGUOUS_CHARACTER_MAP: Record<string, string> = {
95
+ I: "1",
96
+ L: "1",
97
+ O: "0",
98
+ };
99
+
100
+ export default class TwoFactorBackupCode {
101
+ /**
102
+ * One code, drawn uniformly from the alphabet above.
103
+ *
104
+ * `crypto.randomInt` per character rather than `randomBytes(n) % 32`. The
105
+ * modulo version happens to be uniform for this alphabet only because 32
106
+ * divides 256, and it stops being uniform the moment somebody edits the
107
+ * alphabet -- silently, with no test that would notice. `randomInt` rejects
108
+ * out-of-range draws internally for whatever bound it is given, so the
109
+ * uniformity does not depend on a coincidence nobody wrote down.
110
+ */
111
+ @CaptureSpan()
112
+ public static generateCode(): string {
113
+ let code: string = "";
114
+
115
+ for (let index: number = 0; index < BackupCodeLength; index++) {
116
+ code += BackupCodeAlphabet.charAt(
117
+ crypto.randomInt(0, BackupCodeAlphabet.length),
118
+ );
119
+ }
120
+
121
+ return code;
122
+ }
123
+
124
+ /**
125
+ * A full set of distinct codes.
126
+ *
127
+ * Duplicates are not a security problem -- at 2^50 the birthday odds across
128
+ * ten draws are around 4e-14 -- but a duplicate WOULD be a correctness
129
+ * problem downstream: two rows sharing a digest means consuming one leaves a
130
+ * second, identical, still-valid code behind, so a "single-use" code would
131
+ * work twice. Cheaper to rule out here than to reason about there.
132
+ */
133
+ @CaptureSpan()
134
+ public static generateCodeSet(
135
+ count: number = BackupCodeSetSize,
136
+ ): Array<string> {
137
+ const codes: Set<string> = new Set<string>();
138
+
139
+ while (codes.size < count) {
140
+ codes.add(TwoFactorBackupCode.generateCode());
141
+ }
142
+
143
+ return Array.from(codes);
144
+ }
145
+
146
+ /**
147
+ * The form shown to the user: one hyphen in the middle, for the same reason
148
+ * the alphabet drops ambiguous letters -- a ten character run is hard to
149
+ * read back without losing your place.
150
+ *
151
+ * Purely cosmetic. `normalizeCode` strips the hyphen straight back out, so
152
+ * a user may type the code with it, without it, or with the spaces their
153
+ * password manager pasted in.
154
+ */
155
+ @CaptureSpan()
156
+ public static formatForDisplay(code: string): string {
157
+ const groups: Array<string> = [];
158
+
159
+ for (
160
+ let index: number = 0;
161
+ index < code.length;
162
+ index += DISPLAY_GROUP_LENGTH
163
+ ) {
164
+ groups.push(code.substring(index, index + DISPLAY_GROUP_LENGTH));
165
+ }
166
+
167
+ return groups.join("-");
168
+ }
169
+
170
+ /**
171
+ * Reduce whatever the user typed to the canonical form the digest is
172
+ * computed over.
173
+ *
174
+ * Everything here is about not rejecting somebody who supplied exactly the
175
+ * right secret material: the display hyphen, the spaces a clipboard adds,
176
+ * lowercase from a phone keyboard, and the three transcription confusions
177
+ * the alphabet was chosen to make survivable. Anything still outside the
178
+ * alphabet after that is dropped rather than rejected -- this is a
179
+ * canonicaliser, not a validator; the digest comparison is what decides
180
+ * whether a code is real.
181
+ *
182
+ * @param rawCode - The code exactly as submitted.
183
+ * @returns The code reduced to alphabet symbols, uppercase.
184
+ */
185
+ @CaptureSpan()
186
+ public static normalizeCode(rawCode: string): string {
187
+ /*
188
+ * The code arrives straight off a JSON body, so it is only a string
189
+ * because the client chose to send one. A number or an object here must
190
+ * fail verification, not throw out of `.toUpperCase` and surface as a 500.
191
+ */
192
+ if (typeof rawCode !== "string" || !rawCode) {
193
+ return "";
194
+ }
195
+
196
+ let normalized: string = "";
197
+
198
+ for (const character of rawCode.toUpperCase()) {
199
+ const mapped: string = AMBIGUOUS_CHARACTER_MAP[character] || character;
200
+
201
+ if (BackupCodeAlphabet.includes(mapped)) {
202
+ normalized += mapped;
203
+ }
204
+ }
205
+
206
+ return normalized;
207
+ }
208
+
209
+ /**
210
+ * The digest stored for one of `userId`'s codes.
211
+ *
212
+ * Domain separated by the owning user, which is what stops one precomputed
213
+ * table from inverting every account's codes at once and stops two users who
214
+ * happen to be issued the same code from being visibly linked by a matching
215
+ * row. The user id is known at verification time -- the password has already
216
+ * been accepted by then -- so binding to it costs nothing.
217
+ *
218
+ * Both parts are LENGTH-PREFIXED into the message rather than merely
219
+ * concatenated, so no pair of (userId, code) values can be rearranged into
220
+ * the same byte string as another pair. Concatenation alone is a real
221
+ * ambiguity, not a theoretical one: without prefixes, ("ab", "cd") and
222
+ * ("abc", "d") hash identically.
223
+ */
224
+ @CaptureSpan()
225
+ public static hashCode(data: { code: string; userId: ObjectID }): string {
226
+ const message: string = [
227
+ HASH_SCHEME_VERSION,
228
+ data.userId.toString(),
229
+ TwoFactorBackupCode.normalizeCode(data.code),
230
+ ]
231
+ .map((part: string) => {
232
+ return `${part.length}:${part}`;
233
+ })
234
+ .join("");
235
+
236
+ return crypto
237
+ .createHmac("sha256", EncryptionSecret.toString())
238
+ .update(message)
239
+ .digest("hex");
240
+ }
241
+
242
+ /**
243
+ * Compare two digests without leaking, through timing, how many leading
244
+ * characters matched.
245
+ *
246
+ * The consume path compares digests in Postgres rather than here, so this is
247
+ * for callers that already hold both -- but it exists so that nobody writing
248
+ * one reaches for `===` and hands an attacker a way to recover a stored hash
249
+ * one character at a time.
250
+ *
251
+ * Lengths are compared first because `crypto.timingSafeEqual` THROWS on a
252
+ * length mismatch. Digests here are always 64 hex characters, so an early
253
+ * exit only happens on malformed input, where the length is not the secret.
254
+ */
255
+ @CaptureSpan()
256
+ public static isHashEqual(a: string, b: string): boolean {
257
+ if (!a || !b || a.length !== b.length) {
258
+ return false;
259
+ }
260
+
261
+ return crypto.timingSafeEqual(
262
+ Buffer.from(a, "utf8"),
263
+ Buffer.from(b, "utf8"),
264
+ );
265
+ }
266
+ }
@@ -28,9 +28,7 @@
28
28
  the home/-prefixed page view and CTA listeners. Those are Home's alone and
29
29
  stay in head-basic.ejs. Reporting on a captured touch is handed back to
30
30
  the page through window.oneUptimeOnAttributionCaptured for the same
31
- reason. The Cal booking helper does live here: it is a pure reader over
32
- the shared attribution store, and splitting it from the store it reads
33
- would be worse than sharing an unused function definition.
31
+ reason.
34
32
  -%>
35
33
  <!--
36
34
  Consent.
@@ -168,9 +166,8 @@
168
166
 
169
167
  <!--
170
168
  Attribution capture (UTM parameters + ad platform click IDs), and the
171
- readers that hand it on to the two places it has to reach: the signup form
172
- (through localStorage, same origin) and a Cal.com booking (through the
173
- embed's metadata).
169
+ reader that hands it on to the signup form (through localStorage, same
170
+ origin).
174
171
 
175
172
  STORAGE MODEL
176
173
 
@@ -211,9 +208,6 @@
211
208
  // Mirrors MaxAttributionValueLength on the server.
212
209
  var MAX_VALUE_LENGTH = 500;
213
210
 
214
- // Mirrors MAX_FIRST_TOUCH_LENGTH in App/API/CalWebhook.ts.
215
- var MAX_FIRST_TOUCH_LENGTH = 4000;
216
-
217
211
  // Attribution seen on this page but not yet allowed to be stored.
218
212
  var pending = null;
219
213
 
@@ -384,92 +378,6 @@
384
378
  };
385
379
  };
386
380
 
387
- /*
388
- * The same attribution, flattened into the scalar string map Cal.com
389
- * accepts as booking metadata.
390
- *
391
- * Keys are the snake_case wire spelling the server parses in
392
- * App/API/CalWebhook.ts. The first touch is a nested object, and Cal
393
- * metadata values are scalars, so it travels as one JSON string under
394
- * ou_first_touch — which is exactly what the webhook expects to parse.
395
- *
396
- * Returns {} when there is nothing to attribute, so a caller can spread
397
- * it unconditionally without sending an empty metadata bag.
398
- */
399
- /*
400
- * `bookingKind` says which conversation this embed books. All three
401
- * embeds use the same Cal event type, so without it the webhook cannot
402
- * tell a support call from an enterprise demo. It travels in the same
403
- * bracketed metadata bag as the attribution and is read back by
404
- * App/API/CalWebhook.ts, which allowlists the value.
405
- */
406
- window.oneUptimeCalAttributionMetadata = function (bookingKind) {
407
- var attribution = window.oneUptimeGetAttribution();
408
- var config = {};
409
-
410
- /*
411
- * Cal takes booking metadata as FLAT, BRACKETED keys on the embed
412
- * config — `metadata[utm_source]` — and hands them back on the
413
- * webhook as payload.metadata.utm_source.
414
- *
415
- * Not as a nested `metadata: { utm_source: ... }` object. Cal
416
- * serialises each config value into a query parameter, so a nested
417
- * object becomes the string "[object Object]" and every key inside
418
- * it is lost. Nothing errors; bookings keep being recorded, they
419
- * just arrive attributable to nothing — which is precisely the bug
420
- * this whole path exists to fix, so getting the shape wrong here
421
- * would have been indistinguishable from not having done the work.
422
- *
423
- * https://cal.com/help/embedding/prefill-booking-form-embed
424
- */
425
- function put(key, value, maxLength) {
426
- if (value) {
427
- config['metadata[' + key + ']'] =
428
- String(value).slice(0, maxLength || MAX_VALUE_LENGTH);
429
- }
430
- }
431
-
432
- Object.keys(UTM_KEY_MAP).forEach(function (param) {
433
- put(param, attribution.utm[UTM_KEY_MAP[param]]);
434
- });
435
-
436
- put('utm_url', attribution.utm.utmUrl);
437
-
438
- Object.keys(attribution.clickIds).forEach(function (param) {
439
- put(param, attribution.clickIds[param]);
440
- });
441
-
442
- if (attribution.firstTouch) {
443
- var serialized = JSON.stringify(attribution.firstTouch);
444
- /*
445
- * The first touch is JSON, so it gets the JSON bound, NOT the
446
- * 500-char per-value one. A realistic first touch — five UTM
447
- * values, a click id, and a landing URL that repeats them all
448
- * as query parameters — runs past 500 characters easily, and
449
- * truncating JSON produces a string the webhook's JSON.parse
450
- * rejects. It would drop the whole first touch, silently.
451
- *
452
- * Mirrors MAX_FIRST_TOUCH_LENGTH in App/API/CalWebhook.ts.
453
- */
454
- if (serialized.length <= MAX_FIRST_TOUCH_LENGTH) {
455
- put('ou_first_touch', serialized, MAX_FIRST_TOUCH_LENGTH);
456
- }
457
- }
458
-
459
- /*
460
- * Unconditional, unlike everything above it. The attribution keys
461
- * describe the visitor and are absent when there is nothing to say
462
- * about them; the booking kind describes the embed, which always
463
- * knows what it is. A booking from a visitor who refused
464
- * attribution storage still arrives knowing it was a demo.
465
- */
466
- if (bookingKind) {
467
- config['metadata[ou_booking_kind]'] = String(bookingKind).slice(0, MAX_VALUE_LENGTH);
468
- }
469
-
470
- return config;
471
- };
472
-
473
381
  var touch = readTouchFromUrl();
474
382
 
475
383
  if (touch) {
@@ -184,6 +184,7 @@ function renderWidget(
184
184
 
185
185
  interface CapturedChartProps {
186
186
  metricViewData: MetricViewData;
187
+ metricResults?: Array<Record<string, unknown>> | undefined;
187
188
  onTimeRangeSelect?: ((startTime: Date, endTime: Date) => void) | undefined;
188
189
  enableSeriesActions?: boolean | undefined;
189
190
  }
@@ -288,6 +289,69 @@ describe("dashboard chart widget drag-to-zoom", () => {
288
289
  expect(lastChartProps().metricViewData.rangeToken).toBe(TimeRange.CUSTOM);
289
290
  });
290
291
 
292
+ test("a stale fetch response can never overwrite a newer window's data", async () => {
293
+ /*
294
+ * Zoom then Reset puts two aggregate calls in flight; if the SLOWER
295
+ * (zoom) response lands last, its data must be discarded — otherwise
296
+ * the chart paints the zoomed window's series under the dashboard
297
+ * window's axis.
298
+ */
299
+ const pendingFetches: Array<(value: unknown) => void> = [];
300
+ fetchResultsMock.mockImplementation(() => {
301
+ return new Promise((resolve: (value: unknown) => void) => {
302
+ pendingFetches.push(resolve);
303
+ });
304
+ });
305
+
306
+ renderWidget();
307
+
308
+ await waitFor(() => {
309
+ expect(pendingFetches.length).toBe(1);
310
+ });
311
+ act(() => {
312
+ pendingFetches[0]?.([{ data: [], truncated: false }]);
313
+ });
314
+ await waitFor(() => {
315
+ expect(screen.getByTestId("metric-charts")).toBeInTheDocument();
316
+ });
317
+
318
+ act(() => {
319
+ lastChartProps().onTimeRangeSelect?.(ZOOM_START, ZOOM_END);
320
+ });
321
+ await waitFor(() => {
322
+ expect(pendingFetches.length).toBe(2);
323
+ });
324
+
325
+ fireEvent.click(screen.getByText("Reset"));
326
+ await waitFor(() => {
327
+ expect(pendingFetches.length).toBe(3);
328
+ });
329
+
330
+ const staleZoomResults: Array<Record<string, unknown>> = [
331
+ { data: [{ marker: "stale-zoom" }], truncated: false },
332
+ ];
333
+ const freshResetResults: Array<Record<string, unknown>> = [
334
+ { data: [], truncated: false },
335
+ ];
336
+
337
+ // The newer (reset) fetch answers first…
338
+ act(() => {
339
+ pendingFetches[2]?.(freshResetResults);
340
+ });
341
+ await waitFor(() => {
342
+ expect(lastChartProps().metricResults).toBe(freshResetResults);
343
+ });
344
+
345
+ // …then the stale zoom fetch answers last and must be ignored.
346
+ act(() => {
347
+ pendingFetches[1]?.(staleZoomResults);
348
+ });
349
+ await act(async () => {
350
+ await Promise.resolve();
351
+ });
352
+ expect(lastChartProps().metricResults).toBe(freshResetResults);
353
+ });
354
+
291
355
  test("edit mode disables zoom and series navigation", async () => {
292
356
  renderWidget({ isEditMode: true });
293
357