@oneuptime/common 12.0.19 → 12.0.20

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 (66) hide show
  1. package/Server/API/EnterpriseLicenseAPI.ts +94 -19
  2. package/Server/EnvironmentConfig.ts +17 -0
  3. package/Server/Services/ProjectService.ts +104 -0
  4. package/Server/Utils/Response.ts +2 -2
  5. package/Server/Utils/StartServer.ts +2 -2
  6. package/Server/Views/Partials/AnalyticsConsent.ejs +533 -0
  7. package/Tests/App/Dashboard/AlertEpisodeViewFields.test.tsx +465 -0
  8. package/Tests/App/Dashboard/DashboardChartWidgetZoom.test.tsx +302 -0
  9. package/Tests/App/Dashboard/DiscoveryScanWizardValidation.test.tsx +408 -0
  10. package/Tests/App/Dashboard/IncidentEpisodeViewFields.test.tsx +474 -0
  11. package/Tests/App/Dashboard/MetricSeriesInvestigateMenu.test.tsx +368 -0
  12. package/Tests/Server/API/EnterpriseLicenseReportUserCount.test.ts +763 -0
  13. package/Tests/Server/Services/ProjectServiceChangePlan.test.ts +138 -0
  14. package/Tests/Server/Services/ProjectServiceCreateSubscriptionStarted.test.ts +516 -0
  15. package/Tests/Server/Utils/ResponseRenderAnalytics.test.ts +53 -0
  16. package/Tests/Server/Views/AnalyticsConsentPartial.test.ts +241 -0
  17. package/Tests/UI/Components/Charts/ChartTooltipSorted.test.tsx +129 -0
  18. package/Tests/UI/Components/Charts/TooltipEntries.test.ts +155 -0
  19. package/Tests/UI/Components/Detail/DetailFieldErrors.test.tsx +347 -0
  20. package/Tests/UI/Components/Forms/BasicFormStepValidation.test.tsx +392 -0
  21. package/Tests/UI/Components/Forms/ValidationFormSteps.test.ts +549 -0
  22. package/Tests/Utils/EnterpriseLicenseSync.test.ts +562 -0
  23. package/Types/Analytics/RevenueEvent.ts +1 -0
  24. package/Types/Marketing/MarketingEvent.ts +49 -6
  25. package/UI/Components/Charts/Area/AreaChart.tsx +8 -1
  26. package/UI/Components/Charts/Bar/BarChart.tsx +8 -1
  27. package/UI/Components/Charts/ChartLibrary/AreaChart/AreaChart.tsx +19 -4
  28. package/UI/Components/Charts/ChartLibrary/BarChart/BarChart.tsx +20 -2
  29. package/UI/Components/Charts/ChartLibrary/LineChart/LineChart.tsx +19 -4
  30. package/UI/Components/Charts/ChartLibrary/Utils/TooltipEntries.ts +105 -0
  31. package/UI/Components/Charts/Line/LineChart.tsx +8 -1
  32. package/UI/Components/Detail/Detail.tsx +18 -1
  33. package/Utils/EnterpriseLicense/EnterpriseLicenseSync.ts +244 -0
  34. package/build/dist/Server/API/EnterpriseLicenseAPI.js +66 -12
  35. package/build/dist/Server/API/EnterpriseLicenseAPI.js.map +1 -1
  36. package/build/dist/Server/EnvironmentConfig.js +15 -0
  37. package/build/dist/Server/EnvironmentConfig.js.map +1 -1
  38. package/build/dist/Server/Services/ProjectService.js +88 -0
  39. package/build/dist/Server/Services/ProjectService.js.map +1 -1
  40. package/build/dist/Server/Utils/Response.js +2 -2
  41. package/build/dist/Server/Utils/Response.js.map +1 -1
  42. package/build/dist/Server/Utils/StartServer.js +2 -2
  43. package/build/dist/Server/Utils/StartServer.js.map +1 -1
  44. package/build/dist/Types/Analytics/RevenueEvent.js +1 -0
  45. package/build/dist/Types/Analytics/RevenueEvent.js.map +1 -1
  46. package/build/dist/Types/Marketing/MarketingEvent.js +40 -1
  47. package/build/dist/Types/Marketing/MarketingEvent.js.map +1 -1
  48. package/build/dist/UI/Components/Charts/Area/AreaChart.js +9 -1
  49. package/build/dist/UI/Components/Charts/Area/AreaChart.js.map +1 -1
  50. package/build/dist/UI/Components/Charts/Bar/BarChart.js +9 -1
  51. package/build/dist/UI/Components/Charts/Bar/BarChart.js.map +1 -1
  52. package/build/dist/UI/Components/Charts/ChartLibrary/AreaChart/AreaChart.js +22 -13
  53. package/build/dist/UI/Components/Charts/ChartLibrary/AreaChart/AreaChart.js.map +1 -1
  54. package/build/dist/UI/Components/Charts/ChartLibrary/BarChart/BarChart.js +27 -15
  55. package/build/dist/UI/Components/Charts/ChartLibrary/BarChart/BarChart.js.map +1 -1
  56. package/build/dist/UI/Components/Charts/ChartLibrary/LineChart/LineChart.js +27 -18
  57. package/build/dist/UI/Components/Charts/ChartLibrary/LineChart/LineChart.js.map +1 -1
  58. package/build/dist/UI/Components/Charts/ChartLibrary/Utils/TooltipEntries.js +71 -0
  59. package/build/dist/UI/Components/Charts/ChartLibrary/Utils/TooltipEntries.js.map +1 -0
  60. package/build/dist/UI/Components/Charts/Line/LineChart.js +9 -1
  61. package/build/dist/UI/Components/Charts/Line/LineChart.js.map +1 -1
  62. package/build/dist/UI/Components/Detail/Detail.js +17 -1
  63. package/build/dist/UI/Components/Detail/Detail.js.map +1 -1
  64. package/build/dist/Utils/EnterpriseLicense/EnterpriseLicenseSync.js +174 -0
  65. package/build/dist/Utils/EnterpriseLicense/EnterpriseLicenseSync.js.map +1 -0
  66. package/package.json +1 -1
@@ -39,6 +39,10 @@ import {
39
39
  import { cx } from "../Utils/Cx";
40
40
  import { getYAxisDomain } from "../Utils/GetYAxisDomain";
41
41
  import { hasOnlyOneValueForKey } from "../Utils/HasOnlyOneValueForKey";
42
+ import {
43
+ PreparedTooltipEntries,
44
+ prepareTooltipEntries,
45
+ } from "../Utils/TooltipEntries";
42
46
  import ChartCurve from "../../Types/ChartCurve";
43
47
 
44
48
  /*
@@ -475,9 +479,15 @@ const ChartTooltip: ({
475
479
  valueFormatter,
476
480
  }: ChartTooltipProps): React.JSX.Element | null => {
477
481
  if (active && payload && payload.length) {
478
- const legendPayload: PayloadItem[] = payload.filter((item: PayloadItem) => {
479
- return item.type !== "none";
480
- });
482
+ /*
483
+ * Highest value at the hovered timestamp first, capped — on a grouped
484
+ * chart (one series per host/pod), the spiking series must be the
485
+ * first line of the tooltip, not buried at its alphabetical position.
486
+ */
487
+ const {
488
+ entries: legendPayload,
489
+ overflowCount,
490
+ }: PreparedTooltipEntries<PayloadItem> = prepareTooltipEntries(payload);
481
491
  return (
482
492
  <div
483
493
  className={cx(
@@ -531,6 +541,11 @@ const ChartTooltip: ({
531
541
  );
532
542
  },
533
543
  )}
544
+ {overflowCount > 0 ? (
545
+ <p className={cx("pt-1 text-xs", "text-gray-400")}>
546
+ +{overflowCount} more series — highest values shown
547
+ </p>
548
+ ) : null}
534
549
  </div>
535
550
  </div>
536
551
  );
@@ -1426,4 +1441,4 @@ const AreaChart: React.ForwardRefExoticComponent<
1426
1441
 
1427
1442
  AreaChart.displayName = "AreaChart";
1428
1443
 
1429
- export { AreaChart, type AreaChartEventProps, type TooltipProps };
1444
+ export { AreaChart, ChartTooltip, type AreaChartEventProps, type TooltipProps };
@@ -33,6 +33,10 @@ import {
33
33
  } from "../Utils/ChartColors";
34
34
  import { cx } from "../Utils/Cx";
35
35
  import { getYAxisDomain } from "../Utils/GetYAxisDomain";
36
+ import {
37
+ PreparedTooltipEntries,
38
+ prepareTooltipEntries,
39
+ } from "../Utils/TooltipEntries";
36
40
  import { useOnWindowResize } from "../Utils/UseWindowOnResize";
37
41
 
38
42
  /*
@@ -562,6 +566,15 @@ const ChartTooltip: React.FunctionComponent<ChartTooltipProps> = ({
562
566
  valueFormatter,
563
567
  }: ChartTooltipProps): React.ReactElement | null => {
564
568
  if (active && payload && payload.length) {
569
+ /*
570
+ * Highest value at the hovered bucket first, capped — on a grouped
571
+ * chart (one series per host/pod), the spiking series must be the
572
+ * first line of the tooltip, not buried at its alphabetical position.
573
+ */
574
+ const {
575
+ entries: sortedPayload,
576
+ overflowCount,
577
+ }: PreparedTooltipEntries<PayloadItem> = prepareTooltipEntries(payload);
565
578
  return (
566
579
  <div
567
580
  className={cx(
@@ -586,7 +599,7 @@ const ChartTooltip: React.FunctionComponent<ChartTooltipProps> = ({
586
599
  </p>
587
600
  </div>
588
601
  <div className={cx("space-y-1 px-4 py-2")}>
589
- {payload.map(
602
+ {sortedPayload.map(
590
603
  ({ value, category, color }: PayloadItem, index: number) => {
591
604
  return (
592
605
  <div
@@ -631,6 +644,11 @@ const ChartTooltip: React.FunctionComponent<ChartTooltipProps> = ({
631
644
  );
632
645
  },
633
646
  )}
647
+ {overflowCount > 0 ? (
648
+ <p className={cx("pt-1 text-xs", "text-gray-400")}>
649
+ +{overflowCount} more series — highest values shown
650
+ </p>
651
+ ) : null}
634
652
  </div>
635
653
  </div>
636
654
  );
@@ -1194,4 +1212,4 @@ const BarChart: React.ForwardRefExoticComponent<
1194
1212
 
1195
1213
  BarChart.displayName = "BarChart";
1196
1214
 
1197
- export { BarChart, type BarChartEventProps, type TooltipProps };
1215
+ export { BarChart, ChartTooltip, type BarChartEventProps, type TooltipProps };
@@ -40,6 +40,10 @@ import {
40
40
  import { cx } from "../Utils/Cx";
41
41
  import { getYAxisDomain } from "../Utils/GetYAxisDomain";
42
42
  import { hasOnlyOneValueForKey } from "../Utils/HasOnlyOneValueForKey";
43
+ import {
44
+ PreparedTooltipEntries,
45
+ prepareTooltipEntries,
46
+ } from "../Utils/TooltipEntries";
43
47
  import ChartCurve from "../../Types/ChartCurve";
44
48
 
45
49
  /*
@@ -478,9 +482,15 @@ const ChartTooltip: ({
478
482
  valueFormatter,
479
483
  }: ChartTooltipProps): React.JSX.Element | null => {
480
484
  if (active && payload && payload.length) {
481
- const legendPayload: PayloadItem[] = payload.filter((item: PayloadItem) => {
482
- return item.type !== "none";
483
- });
485
+ /*
486
+ * Highest value at the hovered timestamp first, capped — on a grouped
487
+ * chart (one series per host/pod), the spiking series must be the
488
+ * first line of the tooltip, not buried at its alphabetical position.
489
+ */
490
+ const {
491
+ entries: legendPayload,
492
+ overflowCount,
493
+ }: PreparedTooltipEntries<PayloadItem> = prepareTooltipEntries(payload);
484
494
  return (
485
495
  <div
486
496
  className={cx(
@@ -550,6 +560,11 @@ const ChartTooltip: ({
550
560
  );
551
561
  },
552
562
  )}
563
+ {overflowCount > 0 ? (
564
+ <p className={cx("pt-1 text-xs", "text-gray-400")}>
565
+ +{overflowCount} more series — highest values shown
566
+ </p>
567
+ ) : null}
553
568
  </div>
554
569
  </div>
555
570
  );
@@ -1419,4 +1434,4 @@ const LineChart: React.ForwardRefExoticComponent<
1419
1434
 
1420
1435
  LineChart.displayName = "LineChart";
1421
1436
 
1422
- export { LineChart, type LineChartEventProps, type TooltipProps };
1437
+ export { ChartTooltip, LineChart, type LineChartEventProps, type TooltipProps };
@@ -0,0 +1,105 @@
1
+ /*
2
+ * Shared ordering/capping for the hover tooltip of the Area / Line / Bar
3
+ * charts. With a grouped metric (say 20 hosts on one chart) the recharts
4
+ * payload arrives in series-render order — effectively alphabetical — so
5
+ * the series that is actually spiking at the hovered timestamp is buried
6
+ * mid-list. Sorting by the value AT THE HOVERED POINT puts the top series
7
+ * first, and capping the list keeps a high-cardinality chart's tooltip
8
+ * readable (the "+N more" line reports what was elided).
9
+ */
10
+
11
+ /*
12
+ * The slice of a recharts tooltip payload item the ordering needs. The
13
+ * three chart implementations each declare their own structurally-equal
14
+ * PayloadItem, so this stays generic instead of importing any of them.
15
+ */
16
+ export interface SortableTooltipItem {
17
+ category: string;
18
+ value: number;
19
+ type?: string | undefined;
20
+ }
21
+
22
+ export const DEFAULT_TOOLTIP_MAX_ENTRIES: number = 10;
23
+
24
+ export interface PreparedTooltipEntries<T extends SortableTooltipItem> {
25
+ /** Entries to render, highest value first, capped at maxEntries. */
26
+ entries: Array<T>;
27
+ /** How many entries the cap hid (0 when everything fits). */
28
+ overflowCount: number;
29
+ /** Entry count after filtering, before capping. */
30
+ totalCount: number;
31
+ }
32
+
33
+ /*
34
+ * Natural compare so host names order like cpu0 < cpu2 < cpu10 instead of
35
+ * lexicographically — mirrors the series chips' name ordering.
36
+ */
37
+ function compareCategoryNames(a: string, b: string): number {
38
+ return String(a).localeCompare(String(b), undefined, {
39
+ numeric: true,
40
+ sensitivity: "base",
41
+ });
42
+ }
43
+
44
+ export function prepareTooltipEntries<T extends SortableTooltipItem>(
45
+ payload: Array<T> | undefined | null,
46
+ maxEntries: number = DEFAULT_TOOLTIP_MAX_ENTRIES,
47
+ ): PreparedTooltipEntries<T> {
48
+ /*
49
+ * `type === "none"` entries are series excluded from legends/tooltips
50
+ * (recharts tooltipType) — every chart filtered them before this
51
+ * module existed, so the filter lives here now to stay uniform.
52
+ */
53
+ const visibleEntries: Array<T> = (payload || []).filter((item: T) => {
54
+ return item.type !== "none";
55
+ });
56
+
57
+ const sortedEntries: Array<T> = visibleEntries
58
+ .slice()
59
+ .sort((a: T, b: T): number => {
60
+ /*
61
+ * Non-finite values (missing points, the anomaly band's [low, high]
62
+ * array) sort last — they carry no "how high is this series right
63
+ * now" signal, so they must never displace a real reading.
64
+ */
65
+ const aValue: number | null =
66
+ typeof a.value === "number" && Number.isFinite(a.value)
67
+ ? a.value
68
+ : null;
69
+ const bValue: number | null =
70
+ typeof b.value === "number" && Number.isFinite(b.value)
71
+ ? b.value
72
+ : null;
73
+
74
+ if (aValue === null && bValue === null) {
75
+ return compareCategoryNames(a.category, b.category);
76
+ }
77
+ if (aValue === null) {
78
+ return 1;
79
+ }
80
+ if (bValue === null) {
81
+ return -1;
82
+ }
83
+ if (bValue !== aValue) {
84
+ return bValue - aValue;
85
+ }
86
+ return compareCategoryNames(a.category, b.category);
87
+ });
88
+
89
+ const totalCount: number = sortedEntries.length;
90
+
91
+ const cap: number =
92
+ Number.isInteger(maxEntries) && maxEntries > 0
93
+ ? maxEntries
94
+ : DEFAULT_TOOLTIP_MAX_ENTRIES;
95
+
96
+ if (totalCount <= cap) {
97
+ return { entries: sortedEntries, overflowCount: 0, totalCount };
98
+ }
99
+
100
+ return {
101
+ entries: sortedEntries.slice(0, cap),
102
+ overflowCount: totalCount - cap,
103
+ totalCount,
104
+ };
105
+ }
@@ -161,7 +161,14 @@ const LineChartElement: FunctionComponent<LineInternalProps> = (
161
161
 
162
162
  return (
163
163
  <div
164
- className="relative flex flex-1"
164
+ /*
165
+ * `isolate` opens a stacking context so the recharts tooltip's
166
+ * z-index competes only inside this chart — without it the tooltip
167
+ * (zIndex 10, pinned to the chart's top edge) ties with the app
168
+ * header's sticky z-10 in the root context and paints over the
169
+ * navbar on half-scrolled charts.
170
+ */
171
+ className="relative isolate flex flex-1"
165
172
  style={props.heightInPx ? { height: `${props.heightInPx}px` } : undefined}
166
173
  >
167
174
  <LineChart
@@ -715,7 +715,24 @@ const Detail: DetailFunction = <T extends GenericObject>(
715
715
  }
716
716
 
717
717
  if (field.getElement) {
718
- data = field.getElement(props.item);
718
+ /*
719
+ * getField runs inside Detail's own render, so a throw out of a field
720
+ * renderer unwinds the whole route to the app level ErrorBoundary and
721
+ * replaces the page with "Something went wrong". One misbehaving field
722
+ * must never cost the entire page: log the misconfiguration and keep
723
+ * whatever this field type already rendered - the entity badge, or ""
724
+ * so the placeholder below can stand in.
725
+ */
726
+ try {
727
+ data = field.getElement(props.item);
728
+ } catch (err) {
729
+ Logger.error(
730
+ "Detail: getElement for '" +
731
+ fieldKeyStr +
732
+ "' threw and was ignored. " +
733
+ (err instanceof Error ? err.message : String(err)),
734
+ );
735
+ }
719
736
  }
720
737
 
721
738
  let className: string = "sm:col-span-1";
@@ -0,0 +1,244 @@
1
+ import GlobalConfig from "../../Models/DatabaseModels/GlobalConfig";
2
+ import PartialEntity from "../../Types/Database/PartialEntity";
3
+ import EnterpriseLicenseInstanceSummary from "../../Types/EnterpriseLicense/EnterpriseLicenseInstanceSummary";
4
+ import { JSONObject } from "../../Types/JSON";
5
+
6
+ export interface EnterpriseLicenseSyncResult {
7
+ /*
8
+ * Only the columns the license server actually spoke about. A column the
9
+ * response did not mention is absent from this object, so updateOneById
10
+ * leaves whatever is stored alone.
11
+ */
12
+ updateData: PartialEntity<GlobalConfig>;
13
+
14
+ /*
15
+ * Fields the server sent in a shape this build cannot use. Returned rather
16
+ * than logged so this stays a pure function the tests can drive directly;
17
+ * the caller decides how loud to be about them.
18
+ */
19
+ warnings: Array<string>;
20
+ }
21
+
22
+ /*
23
+ * Maps a license-server response (from /enterprise-license/validate or
24
+ * /enterprise-license/report-user-count) onto the GlobalConfig columns a
25
+ * self-hosted installation mirrors locally.
26
+ *
27
+ * Every field is three-state, and the distinction is the whole contract:
28
+ *
29
+ * key absent - the license server did not speak about this field. That is
30
+ * what an oneuptime.com older than this build looks like, so
31
+ * the stored value is left exactly as it is. Collapsing this
32
+ * to null is how a daily report would wipe a perfectly good
33
+ * license expiry and take the installation down.
34
+ * null - the server explicitly says "there is no value". Written as
35
+ * null, so clearing a seat limit on oneuptime.com actually
36
+ * clears it on the customer's installation.
37
+ * a value - validated, then written.
38
+ *
39
+ * Anything that is neither absent, null, nor a usable value is treated as
40
+ * absent and reported as a warning: a malformed response must never be able
41
+ * to corrupt license state that was correct a moment ago.
42
+ */
43
+ export default class EnterpriseLicenseSyncUtil {
44
+ /*
45
+ * The license key is deliberately NOT synced. The installation authenticates
46
+ * with the key it already holds, and a response must never be able to swap
47
+ * it for another one.
48
+ */
49
+ public static getGlobalConfigUpdateFromLicenseResponse(data: {
50
+ payload: JSONObject;
51
+ /*
52
+ * Used as the "user count as of" stamp when the server reports a count but
53
+ * no timestamp for it. Passed in rather than read from the clock so the
54
+ * function stays pure.
55
+ */
56
+ reportedAt: Date;
57
+ }): EnterpriseLicenseSyncResult {
58
+ const payload: JSONObject = data.payload || {};
59
+ const updateData: PartialEntity<GlobalConfig> = {};
60
+ const warnings: Array<string> = [];
61
+
62
+ /*
63
+ * The seat limit. This is the field customers change on oneuptime.com and
64
+ * then wait for: without it here, buying seats never reaches the
65
+ * installation that is enforcing the old number.
66
+ */
67
+ if (this.isPresent(payload, "userLimit")) {
68
+ const rawUserLimit: unknown = payload["userLimit"];
69
+
70
+ if (rawUserLimit === null) {
71
+ updateData.enterpriseLicenseUserLimit = null;
72
+ } else if (this.isNonNegativeInteger(rawUserLimit)) {
73
+ updateData.enterpriseLicenseUserLimit = rawUserLimit as number;
74
+ } else {
75
+ warnings.push(
76
+ `Ignored userLimit "${String(
77
+ rawUserLimit,
78
+ )}" from the license server: it is not a non-negative whole number.`,
79
+ );
80
+ }
81
+ }
82
+
83
+ /*
84
+ * The count and its timestamp move together: a count with a stamp from a
85
+ * previous report would read as "confirmed today", which is the exact
86
+ * lie this whole sync exists to stop telling.
87
+ */
88
+ if (this.isPresent(payload, "currentUserCount")) {
89
+ const rawUserCount: unknown = payload["currentUserCount"];
90
+
91
+ if (this.isNonNegativeInteger(rawUserCount)) {
92
+ updateData.enterpriseLicenseCurrentUserCount = rawUserCount as number;
93
+ updateData.enterpriseLicenseUserCountUpdatedAt =
94
+ this.parseDate(payload["userCountUpdatedAt"]) || data.reportedAt;
95
+ } else if (rawUserCount === null) {
96
+ /*
97
+ * The server has never had a count for this license. Nothing useful to
98
+ * store, and blanking the last known count would be worse than keeping
99
+ * it, so leave both columns alone.
100
+ */
101
+ warnings.push(
102
+ "The license server reported no user count for this license. Keeping the previously stored usage.",
103
+ );
104
+ } else {
105
+ warnings.push(
106
+ `Ignored currentUserCount "${String(
107
+ rawUserCount,
108
+ )}" from the license server: it is not a non-negative whole number. Keeping the previously stored usage.`,
109
+ );
110
+ }
111
+ }
112
+
113
+ /*
114
+ * Expiry drives licenseValid on the installation, so a renewal that never
115
+ * arrives here shows up as a license that expires on its original date
116
+ * however much the customer paid. Null is treated as absent on purpose: a
117
+ * report is not the place to strip an installation of its expiry.
118
+ */
119
+ if (this.isPresent(payload, "expiresAt") && payload["expiresAt"] !== null) {
120
+ const expiresAt: Date | null = this.parseDate(payload["expiresAt"]);
121
+
122
+ if (expiresAt) {
123
+ updateData.enterpriseLicenseExpiresAt = expiresAt;
124
+ } else {
125
+ warnings.push(
126
+ `Ignored expiresAt "${String(
127
+ payload["expiresAt"],
128
+ )}" from the license server: it is not a date.`,
129
+ );
130
+ }
131
+ }
132
+
133
+ if (this.isPresent(payload, "isEvaluationLicense")) {
134
+ const rawIsEvaluation: unknown = payload["isEvaluationLicense"];
135
+
136
+ if (typeof rawIsEvaluation === "boolean") {
137
+ updateData.enterpriseLicenseIsEvaluation = rawIsEvaluation;
138
+ } else {
139
+ warnings.push(
140
+ `Ignored isEvaluationLicense "${String(
141
+ rawIsEvaluation,
142
+ )}" from the license server: it is not a boolean.`,
143
+ );
144
+ }
145
+ }
146
+
147
+ if (this.isPresent(payload, "companyName")) {
148
+ const companyName: string = this.parseNonEmptyString(
149
+ payload["companyName"],
150
+ );
151
+
152
+ /*
153
+ * An empty company name is what the server sends for a license with none
154
+ * set, and overwriting a good name with "" would be a visible regression
155
+ * in the modal. Only a real name is worth writing.
156
+ */
157
+ if (companyName) {
158
+ updateData.enterpriseCompanyName = companyName;
159
+ }
160
+ }
161
+
162
+ /*
163
+ * The token is opaque here - it is signed with the license server's secret
164
+ * and this side only checks that it exists. It is never cleared: a license
165
+ * that has expired is legitimately issued no token, and clearing it on the
166
+ * way past would compound an expiry the customer may be about to renew.
167
+ */
168
+ if (this.isPresent(payload, "token")) {
169
+ const token: string = this.parseNonEmptyString(payload["token"]);
170
+
171
+ if (token) {
172
+ updateData.enterpriseLicenseToken = token;
173
+ }
174
+ }
175
+
176
+ if (this.isPresent(payload, "instances")) {
177
+ const rawInstances: unknown = payload["instances"];
178
+
179
+ if (Array.isArray(rawInstances)) {
180
+ updateData.enterpriseLicenseInstances =
181
+ rawInstances as Array<EnterpriseLicenseInstanceSummary>;
182
+ } else {
183
+ warnings.push(
184
+ "Ignored the instance list from the license server: it is not an array.",
185
+ );
186
+ }
187
+ }
188
+
189
+ return {
190
+ updateData: updateData,
191
+ warnings: warnings,
192
+ };
193
+ }
194
+
195
+ /*
196
+ * Present means the key is on the object, even when it carries null or
197
+ * undefined. `"x" in payload` rather than a truthiness or undefined check,
198
+ * because "the server said null" and "the server said nothing" have to stay
199
+ * distinguishable all the way down.
200
+ */
201
+ private static isPresent(payload: JSONObject, key: string): boolean {
202
+ if (!Object.prototype.hasOwnProperty.call(payload, key)) {
203
+ return false;
204
+ }
205
+
206
+ // An explicit undefined is indistinguishable from silence. Treat it as one.
207
+ return payload[key] !== undefined;
208
+ }
209
+
210
+ private static isNonNegativeInteger(value: unknown): boolean {
211
+ return (
212
+ typeof value === "number" &&
213
+ Number.isFinite(value) &&
214
+ Number.isInteger(value) &&
215
+ value >= 0
216
+ );
217
+ }
218
+
219
+ private static parseNonEmptyString(value: unknown): string {
220
+ if (typeof value !== "string") {
221
+ return "";
222
+ }
223
+
224
+ return value.trim();
225
+ }
226
+
227
+ private static parseDate(value: unknown): Date | null {
228
+ if (value instanceof Date) {
229
+ return Number.isNaN(value.getTime()) ? null : value;
230
+ }
231
+
232
+ if (typeof value !== "string" || !value.trim()) {
233
+ return null;
234
+ }
235
+
236
+ const parsed: Date = new Date(value.trim());
237
+
238
+ if (Number.isNaN(parsed.getTime())) {
239
+ return null;
240
+ }
241
+
242
+ return parsed;
243
+ }
244
+ }
@@ -83,18 +83,13 @@ export default class EnterpriseLicenseAPI extends BaseAPI {
83
83
  });
84
84
  }
85
85
  const instances = await this.findLicenseInstances(license.id);
86
- const payload = {
87
- companyName: license.companyName || "",
88
- expiresAt: license.expiresAt.toISOString(),
89
- licenseKey: license.licenseKey || "",
90
- userLimit: typeof license.userLimit === "number" ? license.userLimit : null,
91
- };
92
- const token = JSONWebToken.signJsonPayload(payload, Math.max(secondsUntilExpiry, 1));
86
+ const terms = this.getLicenseTerms(license);
87
+ const token = this.signLicenseToken(license);
93
88
  return Response.sendJsonObjectResponse(req, res, {
94
- companyName: payload["companyName"],
95
- expiresAt: payload["expiresAt"],
96
- licenseKey: payload["licenseKey"],
97
- userLimit: payload["userLimit"],
89
+ companyName: terms.companyName,
90
+ expiresAt: terms.expiresAt,
91
+ licenseKey: terms.licenseKey,
92
+ userLimit: terms.userLimit,
98
93
  currentUserCount: typeof license.currentUserCount === "number"
99
94
  ? license.currentUserCount
100
95
  : null,
@@ -128,8 +123,19 @@ export default class EnterpriseLicenseAPI extends BaseAPI {
128
123
  query: {
129
124
  licenseKey: licenseKey,
130
125
  },
126
+ /*
127
+ * The full set of terms, not just the usage columns: this
128
+ * response is what a self-hosted installation mirrors into its
129
+ * own GlobalConfig every day, so anything it is missing here is
130
+ * a field the customer keeps the stale value of until somebody
131
+ * re-types the license key by hand. Same indexed lookup either
132
+ * way.
133
+ */
131
134
  select: {
132
135
  _id: true,
136
+ companyName: true,
137
+ expiresAt: true,
138
+ licenseKey: true,
133
139
  userLimit: true,
134
140
  isEvaluationLicense: true,
135
141
  },
@@ -185,12 +191,24 @@ export default class EnterpriseLicenseAPI extends BaseAPI {
185
191
  },
186
192
  });
187
193
  }
194
+ const terms = this.getLicenseTerms(license);
188
195
  return Response.sendJsonObjectResponse(req, res, {
196
+ companyName: terms.companyName,
197
+ expiresAt: terms.expiresAt,
198
+ licenseKey: terms.licenseKey,
199
+ userLimit: terms.userLimit,
189
200
  currentUserCount: currentUserCount,
190
201
  userCountUpdatedAt: reportedAt.toISOString(),
191
- userLimit: typeof license.userLimit === "number" ? license.userLimit : null,
192
202
  isEvaluationLicense: Boolean(license.isEvaluationLicense),
193
203
  instances: this.getInstanceSummaries(instances),
204
+ /*
205
+ * Null once the license has expired. Unlike /validate this route
206
+ * does not reject an expired license - the instance is still
207
+ * expected to report, and the expiry notification emails are built
208
+ * from what it reports - but it must not keep being handed a fresh
209
+ * token, and the expiresAt above is what tells it the truth.
210
+ */
211
+ token: this.signLicenseToken(license),
194
212
  });
195
213
  }
196
214
  catch (err) {
@@ -198,6 +216,42 @@ export default class EnterpriseLicenseAPI extends BaseAPI {
198
216
  }
199
217
  });
200
218
  }
219
+ /*
220
+ * The license terms a self-hosted installation mirrors locally. Built in one
221
+ * place so /validate and /report-user-count cannot drift: the seat limit was
222
+ * missing from what an instance could refresh for exactly as long as the two
223
+ * responses were written out by hand, separately.
224
+ */
225
+ getLicenseTerms(license) {
226
+ return {
227
+ companyName: license.companyName || "",
228
+ expiresAt: license.expiresAt ? license.expiresAt.toISOString() : null,
229
+ licenseKey: license.licenseKey || "",
230
+ userLimit: typeof license.userLimit === "number" ? license.userLimit : null,
231
+ };
232
+ }
233
+ /*
234
+ * Signed with the license server's own secret, so it is opaque to the
235
+ * installation holding it - it is the proof that oneuptime.com recognized
236
+ * the key, and the instance only checks that it exists. An expired license
237
+ * gets none, which is why this returns null rather than a short-lived token.
238
+ */
239
+ signLicenseToken(license) {
240
+ if (!license.expiresAt) {
241
+ return null;
242
+ }
243
+ const secondsUntilExpiry = Math.floor((license.expiresAt.getTime() - Date.now()) / 1000);
244
+ if (secondsUntilExpiry <= 0) {
245
+ return null;
246
+ }
247
+ const terms = this.getLicenseTerms(license);
248
+ return JSONWebToken.signJsonPayload({
249
+ companyName: terms.companyName,
250
+ expiresAt: terms.expiresAt,
251
+ licenseKey: terms.licenseKey,
252
+ userLimit: terms.userLimit,
253
+ }, Math.max(secondsUntilExpiry, 1));
254
+ }
201
255
  parseShortText(value) {
202
256
  if (typeof value !== "string") {
203
257
  return "";