@oneuptime/common 11.0.4 → 11.0.6

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 (72) hide show
  1. package/Server/Infrastructure/GlobalCache.ts +11 -0
  2. package/Server/Middleware/UserAuthorization.ts +94 -20
  3. package/Server/Services/CephClusterService.ts +22 -10
  4. package/Server/Services/CloudResourceService.ts +22 -10
  5. package/Server/Services/DockerHostService.ts +22 -10
  6. package/Server/Services/DockerSwarmClusterService.ts +22 -10
  7. package/Server/Services/HostService.ts +22 -10
  8. package/Server/Services/KubernetesClusterService.ts +22 -10
  9. package/Server/Services/PodmanHostService.ts +22 -10
  10. package/Server/Services/ProxmoxClusterService.ts +22 -10
  11. package/Server/Services/RumApplicationService.ts +22 -10
  12. package/Server/Services/ServerlessFunctionService.ts +22 -10
  13. package/Server/Utils/Cookie.ts +61 -0
  14. package/Tests/Server/Infrastructure/GlobalCache.test.ts +43 -0
  15. package/Tests/Server/Middleware/UserAuthorizationSSOProvider.test.ts +151 -0
  16. package/Types/CookieName.ts +1 -0
  17. package/UI/Components/Charts/Area/AreaChart.tsx +4 -8
  18. package/UI/Components/Charts/Bar/BarChart.tsx +4 -8
  19. package/UI/Components/Charts/ChartGroup/ChartGroup.tsx +4 -8
  20. package/UI/Components/Charts/ChartLibrary/AreaChart/AreaChart.tsx +1 -1
  21. package/UI/Components/Charts/ChartLibrary/BarChart/BarChart.tsx +1 -1
  22. package/UI/Components/Charts/ChartLibrary/LineChart/LineChart.tsx +1 -1
  23. package/UI/Components/Charts/Line/LineChart.tsx +4 -8
  24. package/UI/Components/EditionLabel/EditionLabel.tsx +68 -16
  25. package/UI/Components/Modal/Modal.tsx +4 -1
  26. package/build/dist/Server/Infrastructure/GlobalCache.js +13 -0
  27. package/build/dist/Server/Infrastructure/GlobalCache.js.map +1 -1
  28. package/build/dist/Server/Middleware/UserAuthorization.js +72 -16
  29. package/build/dist/Server/Middleware/UserAuthorization.js.map +1 -1
  30. package/build/dist/Server/Services/CephClusterService.js +19 -2
  31. package/build/dist/Server/Services/CephClusterService.js.map +1 -1
  32. package/build/dist/Server/Services/CloudResourceService.js +19 -2
  33. package/build/dist/Server/Services/CloudResourceService.js.map +1 -1
  34. package/build/dist/Server/Services/DockerHostService.js +19 -2
  35. package/build/dist/Server/Services/DockerHostService.js.map +1 -1
  36. package/build/dist/Server/Services/DockerSwarmClusterService.js +19 -2
  37. package/build/dist/Server/Services/DockerSwarmClusterService.js.map +1 -1
  38. package/build/dist/Server/Services/HostService.js +19 -2
  39. package/build/dist/Server/Services/HostService.js.map +1 -1
  40. package/build/dist/Server/Services/KubernetesClusterService.js +19 -2
  41. package/build/dist/Server/Services/KubernetesClusterService.js.map +1 -1
  42. package/build/dist/Server/Services/PodmanHostService.js +19 -2
  43. package/build/dist/Server/Services/PodmanHostService.js.map +1 -1
  44. package/build/dist/Server/Services/ProxmoxClusterService.js +19 -2
  45. package/build/dist/Server/Services/ProxmoxClusterService.js.map +1 -1
  46. package/build/dist/Server/Services/RumApplicationService.js +19 -2
  47. package/build/dist/Server/Services/RumApplicationService.js.map +1 -1
  48. package/build/dist/Server/Services/ServerlessFunctionService.js +19 -2
  49. package/build/dist/Server/Services/ServerlessFunctionService.js.map +1 -1
  50. package/build/dist/Server/Utils/Cookie.js +61 -0
  51. package/build/dist/Server/Utils/Cookie.js.map +1 -1
  52. package/build/dist/Types/CookieName.js +1 -0
  53. package/build/dist/Types/CookieName.js.map +1 -1
  54. package/build/dist/UI/Components/Charts/Area/AreaChart.js +2 -6
  55. package/build/dist/UI/Components/Charts/Area/AreaChart.js.map +1 -1
  56. package/build/dist/UI/Components/Charts/Bar/BarChart.js +2 -6
  57. package/build/dist/UI/Components/Charts/Bar/BarChart.js.map +1 -1
  58. package/build/dist/UI/Components/Charts/ChartGroup/ChartGroup.js +8 -8
  59. package/build/dist/UI/Components/Charts/ChartGroup/ChartGroup.js.map +1 -1
  60. package/build/dist/UI/Components/Charts/ChartLibrary/AreaChart/AreaChart.js +1 -1
  61. package/build/dist/UI/Components/Charts/ChartLibrary/AreaChart/AreaChart.js.map +1 -1
  62. package/build/dist/UI/Components/Charts/ChartLibrary/BarChart/BarChart.js +1 -1
  63. package/build/dist/UI/Components/Charts/ChartLibrary/BarChart/BarChart.js.map +1 -1
  64. package/build/dist/UI/Components/Charts/ChartLibrary/LineChart/LineChart.js +1 -1
  65. package/build/dist/UI/Components/Charts/ChartLibrary/LineChart/LineChart.js.map +1 -1
  66. package/build/dist/UI/Components/Charts/Line/LineChart.js +2 -6
  67. package/build/dist/UI/Components/Charts/Line/LineChart.js.map +1 -1
  68. package/build/dist/UI/Components/EditionLabel/EditionLabel.js +31 -5
  69. package/build/dist/UI/Components/EditionLabel/EditionLabel.js.map +1 -1
  70. package/build/dist/UI/Components/Modal/Modal.js +1 -1
  71. package/build/dist/UI/Components/Modal/Modal.js.map +1 -1
  72. package/package.json +1 -1
@@ -175,4 +175,15 @@ export default abstract class GlobalCache {
175
175
  */
176
176
  await client.set(`${namespace}-${key}`, value, "EX", expiresInSeconds);
177
177
  }
178
+
179
+ @CaptureSpan()
180
+ public static async deleteKey(namespace: string, key: string): Promise<void> {
181
+ const client: ClientType | null = Redis.getClient();
182
+
183
+ if (!client || !Redis.isConnected()) {
184
+ throw new DatabaseNotConnectedException("Cache is not connected");
185
+ }
186
+
187
+ await client.del(`${namespace}-${key}`);
188
+ }
178
189
  }
@@ -26,6 +26,7 @@ import { JSONObject } from "../../Types/JSON";
26
26
  import JSONFunctions from "../../Types/JSONFunctions";
27
27
  import JSONWebTokenData from "../../Types/JsonWebTokenData";
28
28
  import ObjectID from "../../Types/ObjectID";
29
+ import SsoProviderType from "../../Types/SSO/SsoProviderType";
29
30
  import NotAuthorizedException from "../../Types/Exception/NotAuthorizedException";
30
31
  import Permission, {
31
32
  PermissionHelper,
@@ -163,6 +164,76 @@ export default class UserMiddleware {
163
164
  return ssoTokens;
164
165
  }
165
166
 
167
+ /*
168
+ * Specific-IdP enforcement: when the project requires a specific SSO provider,
169
+ * the token must carry a matching `ssoProviderId` discriminator. Tokens issued
170
+ * before this field existed (no discriminator) do not satisfy a
171
+ * specific-provider requirement. When no specific provider is required, any
172
+ * trusted SSO token satisfies enforcement.
173
+ */
174
+ private static isSsoProviderSatisfied(
175
+ decodedData: JSONWebTokenData,
176
+ requiredSsoProviderId?: ObjectID | undefined,
177
+ ): boolean {
178
+ if (!requiredSsoProviderId) {
179
+ return true;
180
+ }
181
+
182
+ const tokenProviderId: string | undefined = decodedData.ssoProviderId
183
+ ? decodedData.ssoProviderId.toString()
184
+ : undefined;
185
+
186
+ return Boolean(
187
+ tokenProviderId && tokenProviderId === requiredSsoProviderId.toString(),
188
+ );
189
+ }
190
+
191
+ /*
192
+ * Reads the single Global SSO token, if present. It is minted by a Global
193
+ * SSO/OIDC login and is NOT bound to a project, so it satisfies SSO
194
+ * enforcement for any project the user belongs to. Sourced from the
195
+ * `global-sso-token` cookie (web) or the `x-global-sso-token` header (mobile).
196
+ * Only tokens carrying a Global provider type are accepted here.
197
+ */
198
+ @CaptureSpan()
199
+ public static getGlobalSsoTokenData(
200
+ req: ExpressRequest,
201
+ ): JSONWebTokenData | null {
202
+ let rawToken: string | undefined = CookieUtil.getCookieFromExpressRequest(
203
+ req,
204
+ CookieUtil.getGlobalSSOKey(),
205
+ );
206
+
207
+ if (!rawToken) {
208
+ const headerToken: string | undefined = req.headers[
209
+ "x-global-sso-token"
210
+ ] as string | undefined;
211
+ if (headerToken && typeof headerToken === "string") {
212
+ rawToken = headerToken;
213
+ }
214
+ }
215
+
216
+ if (!rawToken) {
217
+ return null;
218
+ }
219
+
220
+ try {
221
+ const decodedData: JSONWebTokenData = JSONWebToken.decode(rawToken);
222
+
223
+ if (
224
+ decodedData.ssoProviderType === SsoProviderType.GlobalSSO ||
225
+ decodedData.ssoProviderType === SsoProviderType.GlobalOIDC
226
+ ) {
227
+ return decodedData;
228
+ }
229
+
230
+ return null;
231
+ } catch (err) {
232
+ logger.error(err, getLogAttributesFromRequest(req as OneUptimeRequest));
233
+ return null;
234
+ }
235
+ }
236
+
166
237
  @CaptureSpan()
167
238
  public static doesSsoTokenForProjectExist(
168
239
  req: ExpressRequest,
@@ -172,37 +243,40 @@ export default class UserMiddleware {
172
243
  ): boolean {
173
244
  const ssoTokens: Dictionary<string> = this.getSsoTokens(req);
174
245
 
246
+ /*
247
+ * 1) Per-project SSO token (Project SSO/OIDC login). Bound to one project.
248
+ */
175
249
  if (ssoTokens && ssoTokens[projectId.toString()]) {
176
250
  const decodedData: JSONWebTokenData = JSONWebToken.decode(
177
251
  ssoTokens[projectId.toString()] as string,
178
252
  );
179
253
  if (
180
254
  decodedData.projectId?.toString() === projectId.toString() &&
181
- decodedData.userId.toString() === userId.toString()
255
+ decodedData.userId.toString() === userId.toString() &&
256
+ this.isSsoProviderSatisfied(decodedData, requiredSsoProviderId)
182
257
  ) {
183
- /*
184
- * Specific-IdP enforcement: when the project requires a specific SSO
185
- * provider, the token must carry a matching `ssoProviderId`
186
- * discriminator. Tokens issued before this field existed (no
187
- * discriminator) do not satisfy a specific-provider requirement.
188
- */
189
- if (requiredSsoProviderId) {
190
- const tokenProviderId: string | undefined = decodedData.ssoProviderId
191
- ? decodedData.ssoProviderId.toString()
192
- : undefined;
193
-
194
- if (
195
- !tokenProviderId ||
196
- tokenProviderId !== requiredSsoProviderId.toString()
197
- ) {
198
- return false;
199
- }
200
- }
201
-
202
258
  return true;
203
259
  }
204
260
  }
205
261
 
262
+ /*
263
+ * 2) Global SSO token (Global SSO/OIDC login). Not bound to a project, so a
264
+ * single token satisfies enforcement for every project this user belongs to
265
+ * — including projects created after the login. The specific-provider
266
+ * discriminator still applies (a project pinned to a different provider is
267
+ * not satisfied by this token).
268
+ */
269
+ const globalSsoTokenData: JSONWebTokenData | null =
270
+ this.getGlobalSsoTokenData(req);
271
+
272
+ if (
273
+ globalSsoTokenData &&
274
+ globalSsoTokenData.userId.toString() === userId.toString() &&
275
+ this.isSsoProviderSatisfied(globalSsoTokenData, requiredSsoProviderId)
276
+ ) {
277
+ return true;
278
+ }
279
+
206
280
  return false;
207
281
  }
208
282
 
@@ -200,21 +200,33 @@ export class Service extends DatabaseService<Model> {
200
200
  )
201
201
  .digest("hex");
202
202
 
203
- const cached: string | null = await GlobalCache.getString(
204
- LAST_SEEN_CACHE_NAMESPACE,
205
- cacheKey,
206
- );
203
+ let cached: string | null = null;
204
+ try {
205
+ cached = await GlobalCache.getString(LAST_SEEN_CACHE_NAMESPACE, cacheKey);
206
+ } catch {
207
+ /*
208
+ * Cache unavailable — fail open and refresh lastSeenAt anyway. A
209
+ * cache error must never skip the DB write below, otherwise the
210
+ * resource is wrongly marked "disconnected" while telemetry is
211
+ * still flowing. Mirrors shouldRunMaintenance's fail-open stance.
212
+ */
213
+ cached = null;
214
+ }
207
215
 
208
216
  if (cached === extrasFingerprint) {
209
217
  return; // same data was written recently
210
218
  }
211
219
 
212
- await GlobalCache.setString(
213
- LAST_SEEN_CACHE_NAMESPACE,
214
- cacheKey,
215
- extrasFingerprint,
216
- { expiresInSeconds: LAST_SEEN_THROTTLE_SECONDS },
217
- );
220
+ try {
221
+ await GlobalCache.setString(
222
+ LAST_SEEN_CACHE_NAMESPACE,
223
+ cacheKey,
224
+ extrasFingerprint,
225
+ { expiresInSeconds: LAST_SEEN_THROTTLE_SECONDS },
226
+ );
227
+ } catch {
228
+ // Best-effort throttle write; proceed with the DB update regardless.
229
+ }
218
230
 
219
231
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
220
232
  const data: any = {
@@ -170,21 +170,33 @@ export class Service extends DatabaseService<Model> {
170
170
  )
171
171
  .digest("hex");
172
172
 
173
- const cached: string | null = await GlobalCache.getString(
174
- LAST_SEEN_CACHE_NAMESPACE,
175
- cacheKey,
176
- );
173
+ let cached: string | null = null;
174
+ try {
175
+ cached = await GlobalCache.getString(LAST_SEEN_CACHE_NAMESPACE, cacheKey);
176
+ } catch {
177
+ /*
178
+ * Cache unavailable — fail open and refresh lastSeenAt anyway. A
179
+ * cache error must never skip the DB write below, otherwise the
180
+ * resource is wrongly marked "disconnected" while telemetry is
181
+ * still flowing. Mirrors shouldRunMaintenance's fail-open stance.
182
+ */
183
+ cached = null;
184
+ }
177
185
 
178
186
  if (cached === extrasFingerprint) {
179
187
  return; // same data was written recently
180
188
  }
181
189
 
182
- await GlobalCache.setString(
183
- LAST_SEEN_CACHE_NAMESPACE,
184
- cacheKey,
185
- extrasFingerprint,
186
- { expiresInSeconds: LAST_SEEN_THROTTLE_SECONDS },
187
- );
190
+ try {
191
+ await GlobalCache.setString(
192
+ LAST_SEEN_CACHE_NAMESPACE,
193
+ cacheKey,
194
+ extrasFingerprint,
195
+ { expiresInSeconds: LAST_SEEN_THROTTLE_SECONDS },
196
+ );
197
+ } catch {
198
+ // Best-effort throttle write; proceed with the DB update regardless.
199
+ }
188
200
 
189
201
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
190
202
  const data: any = {
@@ -192,21 +192,33 @@ export class Service extends DatabaseService<Model> {
192
192
  )
193
193
  .digest("hex");
194
194
 
195
- const cached: string | null = await GlobalCache.getString(
196
- LAST_SEEN_CACHE_NAMESPACE,
197
- cacheKey,
198
- );
195
+ let cached: string | null = null;
196
+ try {
197
+ cached = await GlobalCache.getString(LAST_SEEN_CACHE_NAMESPACE, cacheKey);
198
+ } catch {
199
+ /*
200
+ * Cache unavailable — fail open and refresh lastSeenAt anyway. A
201
+ * cache error must never skip the DB write below, otherwise the
202
+ * resource is wrongly marked "disconnected" while telemetry is
203
+ * still flowing. Mirrors shouldRunMaintenance's fail-open stance.
204
+ */
205
+ cached = null;
206
+ }
199
207
 
200
208
  if (cached === extrasFingerprint) {
201
209
  return; // same data was written recently
202
210
  }
203
211
 
204
- await GlobalCache.setString(
205
- LAST_SEEN_CACHE_NAMESPACE,
206
- cacheKey,
207
- extrasFingerprint,
208
- { expiresInSeconds: LAST_SEEN_THROTTLE_SECONDS },
209
- );
212
+ try {
213
+ await GlobalCache.setString(
214
+ LAST_SEEN_CACHE_NAMESPACE,
215
+ cacheKey,
216
+ extrasFingerprint,
217
+ { expiresInSeconds: LAST_SEEN_THROTTLE_SECONDS },
218
+ );
219
+ } catch {
220
+ // Best-effort throttle write; proceed with the DB update regardless.
221
+ }
210
222
 
211
223
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
212
224
  const data: any = {
@@ -202,21 +202,33 @@ export class Service extends DatabaseService<Model> {
202
202
  )
203
203
  .digest("hex");
204
204
 
205
- const cached: string | null = await GlobalCache.getString(
206
- LAST_SEEN_CACHE_NAMESPACE,
207
- cacheKey,
208
- );
205
+ let cached: string | null = null;
206
+ try {
207
+ cached = await GlobalCache.getString(LAST_SEEN_CACHE_NAMESPACE, cacheKey);
208
+ } catch {
209
+ /*
210
+ * Cache unavailable — fail open and refresh lastSeenAt anyway. A
211
+ * cache error must never skip the DB write below, otherwise the
212
+ * resource is wrongly marked "disconnected" while telemetry is
213
+ * still flowing. Mirrors shouldRunMaintenance's fail-open stance.
214
+ */
215
+ cached = null;
216
+ }
209
217
 
210
218
  if (cached === extrasFingerprint) {
211
219
  return; // same data was written recently
212
220
  }
213
221
 
214
- await GlobalCache.setString(
215
- LAST_SEEN_CACHE_NAMESPACE,
216
- cacheKey,
217
- extrasFingerprint,
218
- { expiresInSeconds: LAST_SEEN_THROTTLE_SECONDS },
219
- );
222
+ try {
223
+ await GlobalCache.setString(
224
+ LAST_SEEN_CACHE_NAMESPACE,
225
+ cacheKey,
226
+ extrasFingerprint,
227
+ { expiresInSeconds: LAST_SEEN_THROTTLE_SECONDS },
228
+ );
229
+ } catch {
230
+ // Best-effort throttle write; proceed with the DB update regardless.
231
+ }
220
232
 
221
233
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
222
234
  const data: any = {
@@ -214,21 +214,33 @@ export class Service extends DatabaseService<Model> {
214
214
  */
215
215
  const cacheKey: string = hostId.toString();
216
216
  const extrasFingerprint: string = this.fingerprintExtras(extra);
217
- const cached: string | null = await GlobalCache.getString(
218
- LAST_SEEN_CACHE_NAMESPACE,
219
- cacheKey,
220
- );
217
+ let cached: string | null = null;
218
+ try {
219
+ cached = await GlobalCache.getString(LAST_SEEN_CACHE_NAMESPACE, cacheKey);
220
+ } catch {
221
+ /*
222
+ * Cache unavailable — fail open and refresh lastSeenAt anyway. A
223
+ * cache error must never skip the DB write below, otherwise the
224
+ * resource is wrongly marked "disconnected" while telemetry is
225
+ * still flowing. Mirrors shouldRunMaintenance's fail-open stance.
226
+ */
227
+ cached = null;
228
+ }
221
229
 
222
230
  if (cached === extrasFingerprint) {
223
231
  return; // same data was written recently
224
232
  }
225
233
 
226
- await GlobalCache.setString(
227
- LAST_SEEN_CACHE_NAMESPACE,
228
- cacheKey,
229
- extrasFingerprint,
230
- { expiresInSeconds: LAST_SEEN_THROTTLE_SECONDS },
231
- );
234
+ try {
235
+ await GlobalCache.setString(
236
+ LAST_SEEN_CACHE_NAMESPACE,
237
+ cacheKey,
238
+ extrasFingerprint,
239
+ { expiresInSeconds: LAST_SEEN_THROTTLE_SECONDS },
240
+ );
241
+ } catch {
242
+ // Best-effort throttle write; proceed with the DB update regardless.
243
+ }
232
244
 
233
245
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
234
246
  const data: any = {
@@ -155,21 +155,33 @@ export class Service extends DatabaseService<Model> {
155
155
  )
156
156
  .digest("hex");
157
157
 
158
- const cached: string | null = await GlobalCache.getString(
159
- LAST_SEEN_CACHE_NAMESPACE,
160
- cacheKey,
161
- );
158
+ let cached: string | null = null;
159
+ try {
160
+ cached = await GlobalCache.getString(LAST_SEEN_CACHE_NAMESPACE, cacheKey);
161
+ } catch {
162
+ /*
163
+ * Cache unavailable — fail open and refresh lastSeenAt anyway. A
164
+ * cache error must never skip the DB write below, otherwise the
165
+ * resource is wrongly marked "disconnected" while telemetry is
166
+ * still flowing. Mirrors shouldRunMaintenance's fail-open stance.
167
+ */
168
+ cached = null;
169
+ }
162
170
 
163
171
  if (cached === extrasFingerprint) {
164
172
  return; // same data was written recently
165
173
  }
166
174
 
167
- await GlobalCache.setString(
168
- LAST_SEEN_CACHE_NAMESPACE,
169
- cacheKey,
170
- extrasFingerprint,
171
- { expiresInSeconds: LAST_SEEN_THROTTLE_SECONDS },
172
- );
175
+ try {
176
+ await GlobalCache.setString(
177
+ LAST_SEEN_CACHE_NAMESPACE,
178
+ cacheKey,
179
+ extrasFingerprint,
180
+ { expiresInSeconds: LAST_SEEN_THROTTLE_SECONDS },
181
+ );
182
+ } catch {
183
+ // Best-effort throttle write; proceed with the DB update regardless.
184
+ }
173
185
 
174
186
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
175
187
  const data: any = {
@@ -192,21 +192,33 @@ export class Service extends DatabaseService<Model> {
192
192
  )
193
193
  .digest("hex");
194
194
 
195
- const cached: string | null = await GlobalCache.getString(
196
- LAST_SEEN_CACHE_NAMESPACE,
197
- cacheKey,
198
- );
195
+ let cached: string | null = null;
196
+ try {
197
+ cached = await GlobalCache.getString(LAST_SEEN_CACHE_NAMESPACE, cacheKey);
198
+ } catch {
199
+ /*
200
+ * Cache unavailable — fail open and refresh lastSeenAt anyway. A
201
+ * cache error must never skip the DB write below, otherwise the
202
+ * resource is wrongly marked "disconnected" while telemetry is
203
+ * still flowing. Mirrors shouldRunMaintenance's fail-open stance.
204
+ */
205
+ cached = null;
206
+ }
199
207
 
200
208
  if (cached === extrasFingerprint) {
201
209
  return; // same data was written recently
202
210
  }
203
211
 
204
- await GlobalCache.setString(
205
- LAST_SEEN_CACHE_NAMESPACE,
206
- cacheKey,
207
- extrasFingerprint,
208
- { expiresInSeconds: LAST_SEEN_THROTTLE_SECONDS },
209
- );
212
+ try {
213
+ await GlobalCache.setString(
214
+ LAST_SEEN_CACHE_NAMESPACE,
215
+ cacheKey,
216
+ extrasFingerprint,
217
+ { expiresInSeconds: LAST_SEEN_THROTTLE_SECONDS },
218
+ );
219
+ } catch {
220
+ // Best-effort throttle write; proceed with the DB update regardless.
221
+ }
210
222
 
211
223
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
212
224
  const data: any = {
@@ -193,21 +193,33 @@ export class Service extends DatabaseService<Model> {
193
193
  )
194
194
  .digest("hex");
195
195
 
196
- const cached: string | null = await GlobalCache.getString(
197
- LAST_SEEN_CACHE_NAMESPACE,
198
- cacheKey,
199
- );
196
+ let cached: string | null = null;
197
+ try {
198
+ cached = await GlobalCache.getString(LAST_SEEN_CACHE_NAMESPACE, cacheKey);
199
+ } catch {
200
+ /*
201
+ * Cache unavailable — fail open and refresh lastSeenAt anyway. A
202
+ * cache error must never skip the DB write below, otherwise the
203
+ * resource is wrongly marked "disconnected" while telemetry is
204
+ * still flowing. Mirrors shouldRunMaintenance's fail-open stance.
205
+ */
206
+ cached = null;
207
+ }
200
208
 
201
209
  if (cached === extrasFingerprint) {
202
210
  return; // same data was written recently
203
211
  }
204
212
 
205
- await GlobalCache.setString(
206
- LAST_SEEN_CACHE_NAMESPACE,
207
- cacheKey,
208
- extrasFingerprint,
209
- { expiresInSeconds: LAST_SEEN_THROTTLE_SECONDS },
210
- );
213
+ try {
214
+ await GlobalCache.setString(
215
+ LAST_SEEN_CACHE_NAMESPACE,
216
+ cacheKey,
217
+ extrasFingerprint,
218
+ { expiresInSeconds: LAST_SEEN_THROTTLE_SECONDS },
219
+ );
220
+ } catch {
221
+ // Best-effort throttle write; proceed with the DB update regardless.
222
+ }
211
223
 
212
224
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
213
225
  const data: any = {
@@ -141,21 +141,33 @@ export class Service extends DatabaseService<Model> {
141
141
  )
142
142
  .digest("hex");
143
143
 
144
- const cached: string | null = await GlobalCache.getString(
145
- LAST_SEEN_CACHE_NAMESPACE,
146
- cacheKey,
147
- );
144
+ let cached: string | null = null;
145
+ try {
146
+ cached = await GlobalCache.getString(LAST_SEEN_CACHE_NAMESPACE, cacheKey);
147
+ } catch {
148
+ /*
149
+ * Cache unavailable — fail open and refresh lastSeenAt anyway. A
150
+ * cache error must never skip the DB write below, otherwise the
151
+ * resource is wrongly marked "disconnected" while telemetry is
152
+ * still flowing. Mirrors shouldRunMaintenance's fail-open stance.
153
+ */
154
+ cached = null;
155
+ }
148
156
 
149
157
  if (cached === extrasFingerprint) {
150
158
  return; // same data was written recently
151
159
  }
152
160
 
153
- await GlobalCache.setString(
154
- LAST_SEEN_CACHE_NAMESPACE,
155
- cacheKey,
156
- extrasFingerprint,
157
- { expiresInSeconds: LAST_SEEN_THROTTLE_SECONDS },
158
- );
161
+ try {
162
+ await GlobalCache.setString(
163
+ LAST_SEEN_CACHE_NAMESPACE,
164
+ cacheKey,
165
+ extrasFingerprint,
166
+ { expiresInSeconds: LAST_SEEN_THROTTLE_SECONDS },
167
+ );
168
+ } catch {
169
+ // Best-effort throttle write; proceed with the DB update regardless.
170
+ }
159
171
 
160
172
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
161
173
  const data: any = {
@@ -170,21 +170,33 @@ export class Service extends DatabaseService<Model> {
170
170
  )
171
171
  .digest("hex");
172
172
 
173
- const cached: string | null = await GlobalCache.getString(
174
- LAST_SEEN_CACHE_NAMESPACE,
175
- cacheKey,
176
- );
173
+ let cached: string | null = null;
174
+ try {
175
+ cached = await GlobalCache.getString(LAST_SEEN_CACHE_NAMESPACE, cacheKey);
176
+ } catch {
177
+ /*
178
+ * Cache unavailable — fail open and refresh lastSeenAt anyway. A
179
+ * cache error must never skip the DB write below, otherwise the
180
+ * resource is wrongly marked "disconnected" while telemetry is
181
+ * still flowing. Mirrors shouldRunMaintenance's fail-open stance.
182
+ */
183
+ cached = null;
184
+ }
177
185
 
178
186
  if (cached === extrasFingerprint) {
179
187
  return; // same data was written recently
180
188
  }
181
189
 
182
- await GlobalCache.setString(
183
- LAST_SEEN_CACHE_NAMESPACE,
184
- cacheKey,
185
- extrasFingerprint,
186
- { expiresInSeconds: LAST_SEEN_THROTTLE_SECONDS },
187
- );
190
+ try {
191
+ await GlobalCache.setString(
192
+ LAST_SEEN_CACHE_NAMESPACE,
193
+ cacheKey,
194
+ extrasFingerprint,
195
+ { expiresInSeconds: LAST_SEEN_THROTTLE_SECONDS },
196
+ );
197
+ } catch {
198
+ // Best-effort throttle write; proceed with the DB update regardless.
199
+ }
188
200
 
189
201
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
190
202
  const data: any = {