@xenon-device-management/xenon 2.15.0 → 2.16.0

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 (183) hide show
  1. package/README.md +38 -26
  2. package/lib/package.json +2 -1
  3. package/lib/public/assets/{AnnotationOverlay-Btq3dZRI.js → AnnotationOverlay-2W4JvEyZ.js} +1 -1
  4. package/lib/public/assets/{ApiKeyGate-C9OoAgg_.js → ApiKeyGate-GmckX_7r.js} +1 -1
  5. package/lib/public/assets/{DeviceMosaicView-DBC_xczB.js → DeviceMosaicView-DqODzkyo.js} +1 -1
  6. package/lib/public/assets/{EmptyState-DqWU2-vM.js → EmptyState-7hETKEWb.js} +1 -1
  7. package/lib/public/assets/{FieldGroup-CveQ3xrA.js → FieldGroup-GOgssFEP.js} +1 -1
  8. package/lib/public/assets/{FilterMenu-BfKDSfHz.js → FilterMenu-Yvf6RGMd.js} +1 -1
  9. package/lib/public/assets/{Menu-CtdCybzA.js → Menu-KrPnfdsf.js} +1 -1
  10. package/lib/public/assets/{Modal-Bsvj1tm2.js → Modal-C6WjZFeI.js} +1 -1
  11. package/lib/public/assets/{RecordingPage-CRofOiyx.js → RecordingPage-CSRIhZEh.js} +1 -1
  12. package/lib/public/assets/{RecordingsPage-jNQHb2IR.js → RecordingsPage-CB8ST3nE.js} +1 -1
  13. package/lib/public/assets/{SegmentedControl-C8HtTha-.js → SegmentedControl-C9o90KId.js} +1 -1
  14. package/lib/public/assets/{SettingCard-D8K1LZNo.js → SettingCard-CPm3w9l2.js} +1 -1
  15. package/lib/public/assets/{Table-zNTX92Pn.js → Table-DwIgERRt.js} +1 -1
  16. package/lib/public/assets/{activity-CJ8LwZq9.js → activity-DrLbHifn.js} +1 -1
  17. package/lib/public/assets/{ai-settings-CgxQZqrc.js → ai-settings-DU_Ls06P.js} +1 -1
  18. package/lib/public/assets/{api-keys-ECpo-zTo.js → api-keys-CWYdEzJ_.js} +1 -1
  19. package/lib/public/assets/{apps-BjGymyf3.js → apps-BqNVdln7.js} +1 -1
  20. package/lib/public/assets/{arrow-left-BWfCbeHP.js → arrow-left-BtB0_Jg_.js} +1 -1
  21. package/lib/public/assets/{arrow-right-DC7UGf_n.js → arrow-right-C8NhdtyN.js} +1 -1
  22. package/lib/public/assets/{arrow-up-right-BOjwybDk.js → arrow-up-right-gMg0ffvv.js} +1 -1
  23. package/lib/public/assets/{auth-shell-CxMTo9aH.js → auth-shell-CiYZ3N4M.js} +2 -2
  24. package/lib/public/assets/{builds-page-DOCm4HkA.js → builds-page-EaaqDmLN.js} +1 -1
  25. package/lib/public/assets/{button-Cg08-EMT.js → button-DDau4Axv.js} +1 -1
  26. package/lib/public/assets/{calendar-CA1ikLJs.js → calendar-DQ83B1ed.js} +1 -1
  27. package/lib/public/assets/{check-BMTaH72W.js → check-K1ovTQam.js} +1 -1
  28. package/lib/public/assets/{chevron-right-B2QOv9Nx.js → chevron-right-Biq00pMt.js} +1 -1
  29. package/lib/public/assets/{circle-check-CYXBoUqT.js → circle-check-CBv88cH7.js} +1 -1
  30. package/lib/public/assets/{circle-x-DTwnSAnl.js → circle-x-Bmltailn.js} +1 -1
  31. package/lib/public/assets/{clock-C8_HViQP.js → clock-BPrld-3h.js} +1 -1
  32. package/lib/public/assets/{copy-Be7LNMXV.js → copy-t0QB14JI.js} +1 -1
  33. package/lib/public/assets/{device-explorer-BOqtfGXz.js → device-explorer-BgGZ8AvV.js} +1 -1
  34. package/lib/public/assets/{download-B56lwxWP.js → download-BroHxNOh.js} +1 -1
  35. package/lib/public/assets/{forgot-password-CCU6bzLn.js → forgot-password-Bjmf49i6.js} +1 -1
  36. package/lib/public/assets/{h264Stream-C-OtmIuk.js → h264Stream-DeD__6wa.js} +1 -1
  37. package/lib/public/assets/{index-CfxsUslp.js → index-72oF89LJ.js} +1 -1
  38. package/lib/public/assets/{index-B9KQ6LsY.js → index-B0JeuZbg.js} +1 -1
  39. package/lib/public/assets/index-B0gQkEg0.js +219 -0
  40. package/lib/public/assets/{input-CZHFjbSg.js → input-DIc4MSC7.js} +1 -1
  41. package/lib/public/assets/{line-chart-Gl_Dz3X_.js → line-chart-BcWm2vW8.js} +1 -1
  42. package/lib/public/assets/{list-checks-CCgjeyGY.js → list-checks-B06IJJad.js} +1 -1
  43. package/lib/public/assets/{lock-CkOwgDcx.js → lock-pVLvnN30.js} +1 -1
  44. package/lib/public/assets/{login-D5cc4w7M.js → login-CN77vy--.js} +1 -1
  45. package/lib/public/assets/{maintenance-settings-t7qr3fvX.js → maintenance-settings-CSdzQSkY.js} +1 -1
  46. package/lib/public/assets/{monitor-DEJixKQO.js → monitor-BtrN5IkI.js} +1 -1
  47. package/lib/public/assets/{mouse-pointer-2-CtOrk4M8.js → mouse-pointer-2-CNsQfo6w.js} +1 -1
  48. package/lib/public/assets/{network-Dii9qdvk.js → network-BSfA8ZxG.js} +1 -1
  49. package/lib/public/assets/{overview-rolAACrO.js → overview-BfTebCK5.js} +1 -1
  50. package/lib/public/assets/{page-header-COQZxJfR.js → page-header-D8KUX6Qg.js} +1 -1
  51. package/lib/public/assets/{play-DrN1Se_R.js → play-Cvc8xzic.js} +1 -1
  52. package/lib/public/assets/{plus-5aq-9uRx.js → plus-C9Zj84UF.js} +1 -1
  53. package/lib/public/assets/{profile-page-6i5kY6kF.js → profile-page-Bi27STgs.js} +1 -1
  54. package/lib/public/assets/{reset-password-sp2IBrc7.js → reset-password-CPsOuwSu.js} +1 -1
  55. package/lib/public/assets/{runbook-page-DJDjeopU.js → runbook-page-DEQ-DBOX.js} +153 -102
  56. package/lib/public/assets/{select-Bpyn7rWO.js → select-BWpnhWXj.js} +1 -1
  57. package/lib/public/assets/{selector-detail-redirect-BH74vmKv.js → selector-detail-redirect-BefSCSpe.js} +1 -1
  58. package/lib/public/assets/{selector-health-page-Cw_g218H.js → selector-health-page-MSk96Zz7.js} +1 -1
  59. package/lib/public/assets/session-detail-page-DPyBNef_.js +4 -0
  60. package/lib/public/assets/{settings-C3uyGhyc.js → settings-V6x-cXwy.js} +1 -1
  61. package/lib/public/assets/{stat-tile-xVqAKIlI.js → stat-tile-Duex-GZD.js} +1 -1
  62. package/lib/public/assets/{tablet-BxUScAeO.js → tablet-BBcoqBmL.js} +1 -1
  63. package/lib/public/assets/{teams-B5ljGkY2.js → teams-ts8p5rEh.js} +1 -1
  64. package/lib/public/assets/{trash-2-B701i8sy.js → trash-2-CZ2ew6AZ.js} +1 -1
  65. package/lib/public/assets/{upload-C2gr44RS.js → upload-Dv99y_le.js} +1 -1
  66. package/lib/public/assets/{use-builds-data-NeQOFEoy.js → use-builds-data-DrYC8JCF.js} +1 -1
  67. package/lib/public/assets/{use-password-reset-mode-DEzJq5ef.js → use-password-reset-mode-C7QkBNkP.js} +1 -1
  68. package/lib/public/assets/{users-D_vv_yz4.js → users-D7tKMESH.js} +1 -1
  69. package/lib/public/assets/{users-MY6eNe6s.js → users-gJ5Y01yo.js} +1 -1
  70. package/lib/public/assets/{video-off-OK1lNZQn.js → video-off-CqZVuZGs.js} +1 -1
  71. package/lib/public/assets/{webhook-settings-BU-C9lBf.js → webhook-settings-DI5DSW99.js} +1 -1
  72. package/lib/public/assets/{zap-DO9ry1iz.js → zap-ClcHFxfo.js} +1 -1
  73. package/lib/public/index.html +1 -1
  74. package/lib/schema.json +11 -7
  75. package/lib/src/InternalHttpClient.js +27 -5
  76. package/lib/src/XenonCapabilityManager.js +10 -3
  77. package/lib/src/app/openapi/sessions.yaml +7 -5
  78. package/lib/src/app/routers/auth.js +2 -1
  79. package/lib/src/app/routers/users.js +2 -1
  80. package/lib/src/app/ws/nodeSocketRelay.js +77 -11
  81. package/lib/src/dashboard/commandLogFields.js +42 -0
  82. package/lib/src/dashboard/event-manager.js +26 -154
  83. package/lib/src/dashboard/services/failure-analysis-service.js +8 -86
  84. package/lib/src/dashboard/services/failureCategories.js +264 -0
  85. package/lib/src/device-managers/AndroidDeviceManager.js +61 -23
  86. package/lib/src/device-managers/IOSDeviceManager.js +10 -0
  87. package/lib/src/device-utils.js +14 -6
  88. package/lib/src/gateway/forwardToNode.js +4 -11
  89. package/lib/src/gateway/hubSessionToken.js +27 -4
  90. package/lib/src/gateway/nodeWebDriverUrl.js +3 -5
  91. package/lib/src/helpers/outboundProxy.js +219 -0
  92. package/lib/src/index.js +21 -0
  93. package/lib/src/interceptors/CommandInterceptor.js +13 -15
  94. package/lib/src/interceptors/xenonScripts.js +28 -7
  95. package/lib/src/logger.js +3 -1
  96. package/lib/src/plugin.js +8 -3
  97. package/lib/src/scripts/database-check.js +3 -3
  98. package/lib/src/scripts/initialize-database.js +35 -33
  99. package/lib/src/scripts/run-migrations.js +1079 -27
  100. package/lib/src/services/ApiKeyService.js +16 -5
  101. package/lib/src/services/InterceptorService.js +6 -2
  102. package/lib/src/services/OrphanSweeper.js +6 -0
  103. package/lib/src/services/SelectorStateService.js +11 -7
  104. package/lib/src/services/SelectorVerificationJob.js +6 -2
  105. package/lib/src/services/SessionLifecycleService.js +32 -18
  106. package/lib/src/services/SocketClient.js +11 -0
  107. package/lib/src/services/SocketServer.js +361 -42
  108. package/lib/src/services/TeamService.js +5 -1
  109. package/lib/src/services/TracingService.js +171 -60
  110. package/lib/src/services/UserService.js +14 -3
  111. package/lib/src/services/UserSessionService.js +16 -4
  112. package/lib/src/services/healing/HealingOrchestrator.js +3 -1
  113. package/lib/src/services/identity/identityChanges.js +41 -0
  114. package/lib/src/services/logcat/SessionDeviceLogs.js +97 -10
  115. package/lib/src/services/logcat/deviceLogBook.js +5 -4
  116. package/lib/src/services/logcat/iosDriverLog.js +178 -0
  117. package/lib/src/services/selector-health/SelectorVisibilityResolver.js +121 -0
  118. package/lib/src/services/token/JwtKeyService.js +8 -2
  119. package/lib/src/services/token/mcpTokenTtl.js +27 -0
  120. package/lib/src/sessions/LocalSession.js +29 -1
  121. package/lib/src/sessions/RemoteSession.js +12 -2
  122. package/lib/src/sessions/SessionManager.js +5 -8
  123. package/lib/test/helpers/driver-log.js +38 -0
  124. package/lib/test/helpers/fake-http-proxy.js +106 -0
  125. package/lib/test/helpers/otlp-probe.js +107 -0
  126. package/lib/test/integration/androidDevices.spec.js +1 -1
  127. package/lib/test/integration/ios/01iOSSimulator.spec.js +1 -1
  128. package/lib/test/integration/ios/02iOSDevices.spec.js +1 -1
  129. package/lib/test/integration/selector-state-flow.spec.js +5 -5
  130. package/lib/test/unit/XenonCapabilityManager.spec.js +23 -0
  131. package/lib/test/unit/android-adb-retry.spec.js +112 -0
  132. package/lib/test/unit/command-log-network-capture.spec.js +191 -0
  133. package/lib/test/unit/dashboard-events-device-logs.spec.js +59 -11
  134. package/lib/test/unit/dashboard-events-team-scope.spec.js +6 -3
  135. package/lib/test/unit/database-check.spec.js +1 -0
  136. package/lib/test/unit/device-utils.spec.js +13 -4
  137. package/lib/test/unit/event-log-session-data.spec.js +11 -4
  138. package/lib/test/unit/failure-analysis.spec.js +22 -8
  139. package/lib/test/unit/failure-categories.spec.js +24 -22
  140. package/lib/test/unit/failure-categorization.spec.js +393 -0
  141. package/lib/test/unit/healing-tier-gate.spec.js +3 -2
  142. package/lib/test/unit/hub-node-gateway-appium-server.spec.js +72 -1
  143. package/lib/test/unit/ios-driver-log.spec.js +168 -0
  144. package/lib/test/unit/ios-simulator-session-readiness.spec.js +187 -0
  145. package/lib/test/unit/ios-simulator-session-teardown.spec.js +158 -0
  146. package/lib/test/unit/logcat-ws.spec.js +78 -37
  147. package/lib/test/unit/logger-json-levels.spec.js +44 -0
  148. package/lib/test/unit/mcp-token-ttl.spec.js +38 -0
  149. package/lib/test/unit/observability-example-loki.spec.js +25 -0
  150. package/lib/test/unit/openapi-for-site.spec.js +80 -0
  151. package/lib/test/unit/outbound-proxy.spec.js +320 -0
  152. package/lib/test/unit/port-exhaustion.spec.js +2 -1
  153. package/lib/test/unit/proxy-option.spec.js +358 -0
  154. package/lib/test/unit/run-migrations-database.spec.js +807 -0
  155. package/lib/test/unit/run-migrations-fail-fast.test.js +41 -27
  156. package/lib/test/unit/run-migrations.test.js +864 -0
  157. package/lib/test/unit/selector-health-actions.spec.js +3 -1
  158. package/lib/test/unit/selector-state-service.spec.js +72 -23
  159. package/lib/test/unit/selector-verification-clean-build.spec.js +1 -1
  160. package/lib/test/unit/selector-verification-job.spec.js +15 -6
  161. package/lib/test/unit/selector-verification-missed-regression.spec.js +2 -2
  162. package/lib/test/unit/selector-visibility-resolver.spec.js +209 -0
  163. package/lib/test/unit/session-device-logs-ios.spec.js +180 -0
  164. package/lib/test/unit/session-device-logs.spec.js +11 -2
  165. package/lib/test/unit/session-end-failure-analysis.spec.js +5 -5
  166. package/lib/test/unit/session-log-indexes.spec.js +1 -1
  167. package/lib/test/unit/socket-team-scope.integration.spec.js +749 -0
  168. package/lib/test/unit/socketServer.scopedEmit.spec.js +237 -5
  169. package/lib/test/unit/tracing-export-routing.spec.js +137 -0
  170. package/lib/test/unit/tracing-session-spans.spec.js +337 -0
  171. package/lib/test/unit/unexpected-shutdown-reason.spec.js +2 -2
  172. package/lib/tsconfig.tsbuildinfo +1 -1
  173. package/package.json +2 -1
  174. package/schema.json +11 -7
  175. package/scripts/dev/readme-screenshots.js +276 -23
  176. package/scripts/dev/screenshot-fixtures/logcat.js +157 -0
  177. package/scripts/dev/screenshot-fixtures/phone-screen.html +469 -0
  178. package/scripts/dev/screenshot-fixtures/session.js +242 -0
  179. package/scripts/export-openapi.js +32 -0
  180. package/scripts/lib/openapi-for-site.js +79 -0
  181. package/lib/public/assets/index-BOxe43p1.js +0 -219
  182. package/lib/public/assets/session-detail-page-AZJE6XXE.js +0 -4
  183. package/lib/src/scripts/prepare-prisma.js +0 -89
@@ -1,10 +1,43 @@
1
1
  "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
2
18
  var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
3
19
  var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
4
20
  if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
5
21
  else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
6
22
  return c > 3 && r && Object.defineProperty(target, key, r), r;
7
23
  };
24
+ var __importStar = (this && this.__importStar) || (function () {
25
+ var ownKeys = function(o) {
26
+ ownKeys = Object.getOwnPropertyNames || function (o) {
27
+ var ar = [];
28
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
29
+ return ar;
30
+ };
31
+ return ownKeys(o);
32
+ };
33
+ return function (mod) {
34
+ if (mod && mod.__esModule) return mod;
35
+ var result = {};
36
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
37
+ __setModuleDefault(result, mod);
38
+ return result;
39
+ };
40
+ })();
8
41
  var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
9
42
  function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
10
43
  return new (P || (P = Promise))(function (resolve, reject) {
@@ -19,9 +52,10 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
19
52
  };
20
53
  var SocketServer_1;
21
54
  Object.defineProperty(exports, "__esModule", { value: true });
22
- exports.SocketServer = void 0;
55
+ exports.SocketServer = exports.SOCKET_RECHECK_INTERVAL_MS = void 0;
23
56
  const socket_io_1 = require("socket.io");
24
57
  const typedi_1 = require("typedi");
58
+ const jose = __importStar(require("jose"));
25
59
  const logger_1 = __importDefault(require("../logger"));
26
60
  const config_1 = require("../config");
27
61
  const ApiKeyService_1 = require("./ApiKeyService");
@@ -34,13 +68,39 @@ const callerTeamIds_1 = require("./device-access/callerTeamIds");
34
68
  const DeviceTeamResolver_1 = require("./device-access/DeviceTeamResolver");
35
69
  const upgradeRouter_1 = require("../app/ws/upgradeRouter");
36
70
  const sessionCommandSummary_1 = require("../dashboard/sessionCommandSummary");
71
+ const SelectorVisibilityResolver_1 = require("./selector-health/SelectorVisibilityResolver");
72
+ const identityChanges_1 = require("./identity/identityChanges");
37
73
  /** socket.io's default path, which the dashboard and nodes connect to. */
38
74
  const SOCKET_IO_PATH = '/socket.io';
75
+ /**
76
+ * How often every socket is checked again, for a change this server didn't
77
+ * make itself (another server sharing the database, a direct edit). Changes
78
+ * made through Xenon reach a socket at once (onIdentityChanged).
79
+ */
80
+ exports.SOCKET_RECHECK_INTERVAL_MS = 60000;
81
+ /** setTimeout fires at once for a longer delay. */
82
+ const MAX_TIMER_MS = 2147483647;
83
+ /** How many sockets a sweep checks at once, so a large lab's sweep is no burst of queries. */
84
+ const SWEEP_BATCH = 10;
85
+ /**
86
+ * The credential no longer admits the socket: wrong, revoked, expired, or its
87
+ * user inactive or gone. Anything else thrown while checking means the check
88
+ * could not run (the database or signing key unavailable).
89
+ */
90
+ class SocketRefused extends Error {
91
+ }
39
92
  const SESSION_COOKIE = 'xenon_dashboard_session';
40
93
  const EVENT_LOG_KEEPS = new Map([
41
94
  [SocketEvents_1.SocketEvents.INTERCEPTOR_REQUEST, null],
42
95
  [SocketEvents_1.SocketEvents.SESSION_COMMAND, sessionCommandSummary_1.sessionCommandSummary],
43
96
  ]);
97
+ /** A row's expiresAt as epoch ms, or undefined for none. */
98
+ function msOf(at) {
99
+ if (!at)
100
+ return undefined;
101
+ const ms = new Date(at).getTime();
102
+ return Number.isNaN(ms) ? undefined : ms;
103
+ }
44
104
  function readCookie(cookieHeader, name) {
45
105
  if (!cookieHeader)
46
106
  return undefined;
@@ -60,8 +120,23 @@ let SocketServer = SocketServer_1 = class SocketServer {
60
120
  this.nodes = new Map(); // socketId -> nodeHost
61
121
  /** The tail of each phone's pending event deliveries (see emitToDashboardForDevices). */
62
122
  this.deliveries = new Map();
123
+ /** The tail of each selector's pending event deliveries (see emitToDashboardForSelector). */
124
+ this.selectorDeliveries = new Map();
125
+ /** The timer that checks a socket again when its credential ends, by socket id. */
126
+ this.expiryTimers = new Map();
127
+ /**
128
+ * Each socket's checks, numbered as they start. A check's outcome is
129
+ * applied unless one that started after it has already applied its own.
130
+ */
131
+ this.checks = new WeakMap();
132
+ /** The check of a socket whose handshake overlapped a change; it joins the dashboard room after it. */
133
+ this.connectChecks = new WeakMap();
134
+ /** Counts the identity changes, so a handshake that overlapped one checks again. */
135
+ this.identityEpoch = 0;
136
+ this.sweeping = false;
63
137
  }
64
- initialize(server) {
138
+ initialize(server, options = {}) {
139
+ var _a;
65
140
  // socket.io (engine.io) takes its websocket transport by adding its own
66
141
  // `upgrade` listener, which would also see every other upgrade and end
67
142
  // the ones it doesn't own after 1 s. The upgrade router moves that
@@ -79,9 +154,12 @@ let SocketServer = SocketServer_1 = class SocketServer {
79
154
  }));
80
155
  this.io.use((socket, next) => __awaiter(this, void 0, void 0, function* () {
81
156
  try {
82
- const identity = yield this.authenticate(socket);
157
+ const epoch = this.identityEpoch;
158
+ const { identity, endsAt } = yield this.check(socket);
83
159
  socket.data.identity = identity;
84
160
  socket.data.principal = identity.principal;
161
+ socket.data.endsAt = endsAt;
162
+ socket.data.checkedEpoch = epoch;
85
163
  next();
86
164
  }
87
165
  catch (err) {
@@ -93,6 +171,13 @@ let SocketServer = SocketServer_1 = class SocketServer {
93
171
  const socketId = socket.id;
94
172
  const principal = socket.data.principal;
95
173
  logger_1.default.info(`[SocketServer] New connection: ${socketId} (principal=${principal})`);
174
+ this.armExpiry(socket);
175
+ // A user changed while this handshake was being checked. The change
176
+ // found no socket of theirs to check yet, so this one may hold what was
177
+ // true before it.
178
+ if (socket.data.checkedEpoch !== this.identityEpoch) {
179
+ this.connectChecks.set(socket, this.recheck(socket));
180
+ }
96
181
  // Protocol version handshake — still runs after auth so a logged-in
97
182
  // client on the wrong protocol version still gets a clean disconnect.
98
183
  socket.on(SocketEvents_1.SocketEvents.HANDSHAKE, (data) => {
@@ -117,16 +202,22 @@ let SocketServer = SocketServer_1 = class SocketServer {
117
202
  // Notify dashboard about new node
118
203
  this.emitToDashboard(SocketEvents_1.SocketEvents.NODE_CONNECTED, { host });
119
204
  });
120
- socket.on(SocketEvents_1.SocketEvents.REGISTER_DASHBOARD, () => {
205
+ socket.on(SocketEvents_1.SocketEvents.REGISTER_DASHBOARD, () => __awaiter(this, void 0, void 0, function* () {
121
206
  if (!SocketServer_1.canRegisterAs(principal, 'dashboard')) {
122
207
  logger_1.default.warn(`[SocketServer] Rejected REGISTER_DASHBOARD from principal=${principal} on ${socketId}`);
123
208
  socket.disconnect();
124
209
  return;
125
210
  }
211
+ // Its events wait for the identity the overlapping change gave it.
212
+ yield this.connectChecks.get(socket);
213
+ if (!socket.connected)
214
+ return;
126
215
  logger_1.default.info(`[SocketServer] Dashboard client registered (Socket: ${socketId})`);
127
216
  socket.join('dashboard');
128
- });
217
+ }));
129
218
  socket.on('disconnect', () => {
219
+ clearTimeout(this.expiryTimers.get(socketId));
220
+ this.expiryTimers.delete(socketId);
130
221
  if (this.nodes.has(socketId)) {
131
222
  const host = this.nodes.get(socketId);
132
223
  logger_1.default.info(`[SocketServer] Node disconnected: ${host} (Socket: ${socketId})`);
@@ -138,8 +229,128 @@ let SocketServer = SocketServer_1 = class SocketServer {
138
229
  }
139
230
  });
140
231
  });
232
+ const stopListening = (0, identityChanges_1.onIdentityChanged)((userId) => this.recheckUser(userId));
233
+ const sweep = setInterval(() => void this.sweep(), (_a = options.recheckIntervalMs) !== null && _a !== void 0 ? _a : exports.SOCKET_RECHECK_INTERVAL_MS);
234
+ sweep.unref();
235
+ server.once('close', () => {
236
+ stopListening();
237
+ clearInterval(sweep);
238
+ });
141
239
  logger_1.default.info('[SocketServer] WebSocket server initialized (auth enabled)');
142
240
  }
241
+ /**
242
+ * Checks every socket of `userId` again, on its own credential (see
243
+ * recheck). Settles once each has its new identity or is closed (or, if it
244
+ * could not be checked, is as it was).
245
+ */
246
+ recheckUser(userId) {
247
+ return __awaiter(this, void 0, void 0, function* () {
248
+ this.identityEpoch += 1;
249
+ if (!this.io || config_1.config.authDisabled === true)
250
+ return;
251
+ const sockets = Array.from(this.io.sockets.sockets.values()).filter((socket) => { var _a, _b; return ((_b = (_a = socket.data) === null || _a === void 0 ? void 0 : _a.identity) === null || _b === void 0 ? void 0 : _b.userId) === userId; });
252
+ yield Promise.all(sockets.map((socket) => this.recheck(socket)));
253
+ });
254
+ }
255
+ /**
256
+ * Every socket again, for a change made where this server doesn't hear of
257
+ * it, SWEEP_BATCH at a time. One sweep at a time.
258
+ */
259
+ sweep() {
260
+ return __awaiter(this, void 0, void 0, function* () {
261
+ if (this.sweeping || !this.io || config_1.config.authDisabled === true)
262
+ return;
263
+ this.sweeping = true;
264
+ try {
265
+ const sockets = Array.from(this.io.sockets.sockets.values());
266
+ for (let i = 0; i < sockets.length; i += SWEEP_BATCH) {
267
+ yield Promise.all(sockets.slice(i, i + SWEEP_BATCH).map((s) => this.recheck(s)));
268
+ }
269
+ }
270
+ finally {
271
+ this.sweeping = false;
272
+ }
273
+ });
274
+ }
275
+ /**
276
+ * The handshake's check, again, on the socket's own credential, renewing
277
+ * nothing: a sign-in's TTL doesn't slide and a key's `lastUsedAt` doesn't
278
+ * move. Its outcome is applied unless a check that started after it has
279
+ * already applied its own. So once a check settles, the socket is at least
280
+ * as current as what it read: a change's check lands before REST answers,
281
+ * though a sweep's started meanwhile, and a newer check that could not run
282
+ * doesn't cancel an older refusal.
283
+ *
284
+ * - Admitted: the socket gets its new identity where it is, still
285
+ * connected, so a demoted admin's tab keeps its member's events.
286
+ * - Refused: its connection is closed (the transport, not
287
+ * `disconnect()`). The client reconnects as it does after any drop, and
288
+ * the handshake decides on the credential it has now: a fresh bearer
289
+ * token from its `auth` function (Xenon Studio, the documented client),
290
+ * or the browser's new cookie, is admitted; the refused one is refused,
291
+ * and socket.io-client doesn't try again after a refused handshake.
292
+ * `disconnect()` would have the client never reconnect at all
293
+ * (`io server disconnect`), so every token's `exp` would end a
294
+ * long-running client's events for good. Once closing, the socket is
295
+ * sent nothing more.
296
+ * - Could not check: the socket is kept as it was. Closing every
297
+ * dashboard over a database hiccup would have their reconnects refused
298
+ * too, leaving them all without live events.
299
+ */
300
+ recheck(socket) {
301
+ return __awaiter(this, void 0, void 0, function* () {
302
+ var _a;
303
+ let state = this.checks.get(socket);
304
+ if (!state) {
305
+ state = { started: 0, applied: 0 };
306
+ this.checks.set(socket, state);
307
+ }
308
+ const checks = state;
309
+ const n = ++checks.started;
310
+ const current = () => n > checks.applied && socket.connected;
311
+ const before = socket.data.identity;
312
+ let checked;
313
+ try {
314
+ checked = yield this.check(socket, true);
315
+ }
316
+ catch (err) {
317
+ if (!current())
318
+ return;
319
+ if (err instanceof SocketRefused) {
320
+ checks.applied = n;
321
+ logger_1.default.info(`[SocketServer] Closing ${socket.id} (user ${before === null || before === void 0 ? void 0 : before.userId}): ${err.message}`);
322
+ socket.conn.close();
323
+ }
324
+ else {
325
+ logger_1.default.warn(`[SocketServer] Could not check ${socket.id} (user ${before === null || before === void 0 ? void 0 : before.userId}) again, so it is kept as it was: ${(_a = err === null || err === void 0 ? void 0 : err.message) !== null && _a !== void 0 ? _a : err}`);
326
+ }
327
+ return;
328
+ }
329
+ if (!current())
330
+ return;
331
+ checks.applied = n;
332
+ const { identity, endsAt } = checked;
333
+ socket.data.identity = identity;
334
+ socket.data.endsAt = endsAt;
335
+ this.armExpiry(socket);
336
+ if ((before === null || before === void 0 ? void 0 : before.role) !== identity.role ||
337
+ JSON.stringify(before === null || before === void 0 ? void 0 : before.teamIds) !== JSON.stringify(identity.teamIds)) {
338
+ logger_1.default.info(`[SocketServer] ${socket.id} (user ${identity.userId}) is now ${identity.role}, teams ${identity.teamIds ? `[${identity.teamIds.join(', ')}]` : 'all'}`);
339
+ }
340
+ });
341
+ }
342
+ /** Checks the socket again when its credential ends by itself (`socket.data.endsAt`). */
343
+ armExpiry(socket) {
344
+ clearTimeout(this.expiryTimers.get(socket.id));
345
+ this.expiryTimers.delete(socket.id);
346
+ const endsAt = socket.data.endsAt;
347
+ if (endsAt === undefined || !socket.connected)
348
+ return;
349
+ const delay = Math.min(Math.max(endsAt - Date.now(), 0), MAX_TIMER_MS);
350
+ const timer = setTimeout(() => void this.recheck(socket), delay);
351
+ timer.unref();
352
+ this.expiryTimers.set(socket.id, timer);
353
+ }
143
354
  // Role-based registration guard. A dashboard-authed socket can't join the
144
355
  // 'nodes' broadcast room and a node-authed socket can't subscribe to
145
356
  // dashboard events — keeps a stolen credential of one class from acting
@@ -151,13 +362,27 @@ let SocketServer = SocketServer_1 = class SocketServer {
151
362
  }
152
363
  authenticate(socket) {
153
364
  return __awaiter(this, void 0, void 0, function* () {
154
- var _a, _b, _c, _d;
365
+ return (yield this.check(socket)).identity;
366
+ });
367
+ }
368
+ /**
369
+ * Who the socket's handshake credential says it is now, and when that
370
+ * credential ends by itself (`endsAt`, epoch ms; none for one that doesn't).
371
+ * Throws SocketRefused when the credential no longer admits it, anything
372
+ * else when it could not be checked. A `recheck` renews nothing (see
373
+ * recheck).
374
+ */
375
+ check(socket_1) {
376
+ return __awaiter(this, arguments, void 0, function* (socket, recheck = false) {
377
+ var _a, _b, _c, _d, _e;
155
378
  if (config_1.config.authDisabled === true) {
156
379
  return {
157
- principal: 'auth-disabled',
158
- userId: 'auth-disabled',
159
- role: 'SUPER_ADMIN',
160
- teamIds: undefined,
380
+ identity: {
381
+ principal: 'auth-disabled',
382
+ userId: 'auth-disabled',
383
+ role: 'SUPER_ADMIN',
384
+ teamIds: undefined,
385
+ },
161
386
  };
162
387
  }
163
388
  const auth = (socket.handshake.auth || {});
@@ -171,58 +396,74 @@ let SocketServer = SocketServer_1 = class SocketServer {
171
396
  try {
172
397
  payload = yield typedi_1.Container.get(JwtKeyService_1.JwtKeyService).verify(bearer, { audience: 'xenon-rest' });
173
398
  }
174
- catch (_e) {
175
- throw new Error('invalid bearer token');
399
+ catch (err) {
400
+ if (err instanceof jose.errors.JOSEError)
401
+ throw new SocketRefused('invalid bearer token');
402
+ throw new Error(`bearer token could not be checked: ${(_a = err === null || err === void 0 ? void 0 : err.message) !== null && _a !== void 0 ? _a : err}`);
176
403
  }
404
+ // The socket closes when REST stops taking the token: verify() allows
405
+ // JWT_CLOCK_TOLERANCE_SEC past exp, and refuses it from then on.
406
+ const endsAt = typeof payload.exp === 'number'
407
+ ? (payload.exp + JwtKeyService_1.JWT_CLOCK_TOLERANCE_SEC) * 1000
408
+ : undefined;
177
409
  const owner = yield prisma_1.prisma.user.findUnique({
178
410
  where: { id: String(payload.sub) },
179
411
  select: { status: true, role: true },
180
412
  });
181
413
  if (!owner || owner.status !== 'ACTIVE')
182
- throw new Error('inactive user');
414
+ throw new SocketRefused('inactive user');
183
415
  // The team claim is read exactly as REST's bearer path reads it.
184
- return this.identify('dashboard', String(payload.sub), owner.role, (_a = payload.teamId) !== null && _a !== void 0 ? _a : null);
416
+ const identity = yield this.identify('dashboard', String(payload.sub), owner.role, (_b = payload.teamId) !== null && _b !== void 0 ? _b : null);
417
+ return { identity, endsAt };
185
418
  }
186
419
  // Node path: per-node (accessKey, token) pair. Resolves to a real
187
420
  // ApiKey row owned by a real User; we additionally enforce that the
188
421
  // User is ACTIVE so a disabled account can't keep a stolen key alive.
189
422
  const pairKey = (typeof auth.accessKey === 'string' && auth.accessKey) ||
190
- ((_b = headers['x-xenon-access-key']) !== null && _b !== void 0 ? _b : '');
423
+ ((_c = headers['x-xenon-access-key']) !== null && _c !== void 0 ? _c : '');
191
424
  const pairTok = (typeof auth.token === 'string' && auth.token) ||
192
- ((_c = headers['x-xenon-token']) !== null && _c !== void 0 ? _c : '');
425
+ ((_d = headers['x-xenon-token']) !== null && _d !== void 0 ? _d : '');
193
426
  if (pairKey && pairTok) {
194
- const row = yield typedi_1.Container.get(ApiKeyService_1.ApiKeyService).verifyPair(pairKey, pairTok);
427
+ const row = yield typedi_1.Container.get(ApiKeyService_1.ApiKeyService).verifyPair(pairKey, pairTok, {
428
+ touch: !recheck,
429
+ });
195
430
  if (!row)
196
- throw new Error('invalid (accessKey, token) pair');
431
+ throw new SocketRefused('invalid (accessKey, token) pair');
197
432
  const owner = yield prisma_1.prisma.user.findUnique({
198
433
  where: { id: row.userId },
199
434
  select: { status: true, role: true },
200
435
  });
201
436
  if (!owner || owner.status !== 'ACTIVE')
202
- throw new Error('inactive user');
203
- return this.identify('node', row.userId, owner.role, row.teamId);
437
+ throw new SocketRefused('inactive user');
438
+ const identity = yield this.identify('node', row.userId, owner.role, row.teamId);
439
+ return { identity, endsAt: msOf(row.expiresAt) };
204
440
  }
205
441
  // Dashboard path: the cookie. As in REST's authMiddleware, it is first a
206
442
  // UserSession id (set by /auth/login), then a raw API key (the older
207
443
  // /api-key-gate cookie). A session whose user is disabled is refused, not
208
444
  // retried as a key.
209
- const cookieValue = (_d = readCookie(headers.cookie, SESSION_COOKIE)) !== null && _d !== void 0 ? _d : '';
445
+ const cookieValue = (_e = readCookie(headers.cookie, SESSION_COOKIE)) !== null && _e !== void 0 ? _e : '';
210
446
  if (!cookieValue) {
211
- throw new Error('missing credentials (need (accessKey, token) pair or dashboard cookie)');
447
+ throw new SocketRefused('missing credentials (need (accessKey, token) pair or dashboard cookie)');
212
448
  }
213
- const session = yield typedi_1.Container.get(UserSessionService_1.UserSessionService).resolve(cookieValue);
449
+ // A sign-in's expiresAt slides as REST uses it: checked again then, it
450
+ // has moved on and the timer is set again.
451
+ const session = yield typedi_1.Container.get(UserSessionService_1.UserSessionService).resolve(cookieValue, {
452
+ renew: !recheck,
453
+ });
214
454
  if (session) {
215
455
  const user = yield prisma_1.prisma.user.findUnique({
216
456
  where: { id: session.userId },
217
457
  select: { status: true, role: true },
218
458
  });
219
459
  if (!user || user.status !== 'ACTIVE')
220
- throw new Error('inactive user');
221
- return this.identify('dashboard', session.userId, user.role, null);
460
+ throw new SocketRefused('inactive user');
461
+ const identity = yield this.identify('dashboard', session.userId, user.role, null);
462
+ return { identity, endsAt: msOf(session.expiresAt) };
222
463
  }
223
- const row = yield typedi_1.Container.get(ApiKeyService_1.ApiKeyService).verify(cookieValue);
464
+ const row = yield typedi_1.Container.get(ApiKeyService_1.ApiKeyService).verify(cookieValue, { touch: !recheck });
224
465
  if (!row) {
225
- throw new Error('invalid or revoked dashboard session');
466
+ throw new SocketRefused('invalid or revoked dashboard session');
226
467
  }
227
468
  // The key owner's role decides the team scope, and an inactive owner is
228
469
  // refused here as REST refuses them.
@@ -231,8 +472,9 @@ let SocketServer = SocketServer_1 = class SocketServer {
231
472
  select: { status: true, role: true },
232
473
  });
233
474
  if (!owner || owner.status !== 'ACTIVE')
234
- throw new Error('inactive user');
235
- return this.identify('dashboard', row.userId, owner.role, row.teamId);
475
+ throw new SocketRefused('inactive user');
476
+ const identity = yield this.identify('dashboard', row.userId, owner.role, row.teamId);
477
+ return { identity, endsAt: msOf(row.expiresAt) };
236
478
  });
237
479
  }
238
480
  identify(principal, userId, role, apiKeyTeamId) {
@@ -242,7 +484,10 @@ let SocketServer = SocketServer_1 = class SocketServer {
242
484
  return { principal, userId, role: r, teamIds };
243
485
  });
244
486
  }
245
- /** Unscoped: every dashboard socket. For events that aren't one phone's data (selectors, nodes). */
487
+ /**
488
+ * Unscoped: every dashboard socket. Only for the hub's node events, which
489
+ * carry a node's address and nothing of a phone, a session or a selector.
490
+ */
246
491
  emitToDashboard(event, data) {
247
492
  if (this.io) {
248
493
  this.io.to('dashboard').emit(event, data);
@@ -259,6 +504,55 @@ let SocketServer = SocketServer_1 = class SocketServer {
259
504
  payload: keep ? keep(data) : data,
260
505
  });
261
506
  }
507
+ /**
508
+ * A Selector Health event (`selector_*`): each dashboard socket gets it only
509
+ * if its caller may see the selector, by REST's rule (a selector healed in a
510
+ * session the caller may see; SelectorVisibilityResolver). Admins get every
511
+ * one. The event log records it once, unscoped.
512
+ *
513
+ * Each selector's events are delivered through one chain, in the order they
514
+ * were emitted. With no scoped socket connected and nothing pending for the
515
+ * selector, it is the synchronous room broadcast, with no lookup. The
516
+ * returned promise settles once the event is delivered and never rejects.
517
+ */
518
+ emitToDashboardForSelector(event, data, selector) {
519
+ var _a;
520
+ this.logEvent(event, data);
521
+ const key = JSON.stringify([selector.strategy, selector.selector]);
522
+ if (!this.hasScopedDashboard() && !this.selectorDeliveries.has(key)) {
523
+ (_a = this.io) === null || _a === void 0 ? void 0 : _a.to('dashboard').emit(event, data);
524
+ return Promise.resolve();
525
+ }
526
+ return this.enqueue(event, [key], () => this.deliverForSelector(event, data, selector), this.selectorDeliveries);
527
+ }
528
+ deliverForSelector(event, data, selector) {
529
+ return __awaiter(this, void 0, void 0, function* () {
530
+ var _a;
531
+ // The scoped sockets may have left while this waited its turn.
532
+ if (!this.hasScopedDashboard()) {
533
+ (_a = this.io) === null || _a === void 0 ? void 0 : _a.to('dashboard').emit(event, data);
534
+ return;
535
+ }
536
+ const resolver = typedi_1.Container.get(SelectorVisibilityResolver_1.SelectorVisibilityResolver);
537
+ const sockets = this.dashboardSockets();
538
+ const asked = sockets.map((socket) => { var _a; return (_a = socket.data) === null || _a === void 0 ? void 0 : _a.identity; });
539
+ const allowed = yield Promise.all(sockets.map((socket) => {
540
+ var _a, _b;
541
+ const teamIds = teamIdsOf(socket);
542
+ if (teamIds === undefined)
543
+ return true;
544
+ const userId = (_b = (_a = socket.data) === null || _a === void 0 ? void 0 : _a.identity) === null || _b === void 0 ? void 0 : _b.userId;
545
+ return resolver.canSee(selector, { userId, teamIds });
546
+ }));
547
+ // A socket whose identity was replaced meanwhile (a role or team
548
+ // change) was not the one asked about: it gets nothing.
549
+ sockets.forEach((socket, i) => {
550
+ var _a;
551
+ if (allowed[i] && ((_a = socket.data) === null || _a === void 0 ? void 0 : _a.identity) === asked[i])
552
+ socket.emit(event, data);
553
+ });
554
+ });
555
+ }
262
556
  /**
263
557
  * A dashboard event about one or more phones: each dashboard socket gets it
264
558
  * only if its caller can see the phone ({@link DeviceEventScope}), by the
@@ -280,7 +574,7 @@ let SocketServer = SocketServer_1 = class SocketServer {
280
574
  var _a, _b;
281
575
  this.logEvent(event, data);
282
576
  const udids = 'udids' in scope ? scope.udids : [(_a = scope.udid) !== null && _a !== void 0 ? _a : ''];
283
- if (!this.hasScopedDashboard() && udids.every((udid) => !this.deliveries.has(udid))) {
577
+ if (this.reachesEverySocket(scope) && udids.every((udid) => !this.deliveries.has(udid))) {
284
578
  this.noteRow(scope);
285
579
  (_b = this.io) === null || _b === void 0 ? void 0 : _b.to('dashboard').emit(event, data);
286
580
  return Promise.resolve();
@@ -305,34 +599,53 @@ let SocketServer = SocketServer_1 = class SocketServer {
305
599
  hasScopedDashboard() {
306
600
  return this.dashboardSockets().some((socket) => teamIdsOf(socket) !== undefined);
307
601
  }
308
- /** Runs `step` after every pending delivery for `udids`, and makes it their new tail. */
309
- enqueue(event, udids, step) {
310
- const before = udids.map((udid) => { var _a; return (_a = this.deliveries.get(udid)) !== null && _a !== void 0 ? _a : Promise.resolve(); });
602
+ /** Runs `step` after every pending delivery for `keys` in `chains`, and makes it their new tail. */
603
+ enqueue(event, keys, step, chains = this.deliveries) {
604
+ const before = keys.map((key) => { var _a; return (_a = chains.get(key)) !== null && _a !== void 0 ? _a : Promise.resolve(); });
311
605
  const done = Promise.all(before)
312
606
  .then(step)
313
607
  .catch((err) => { var _a; return logger_1.default.warn(`[SocketServer] ${event} not delivered: ${(_a = err === null || err === void 0 ? void 0 : err.message) !== null && _a !== void 0 ? _a : err}`); });
314
- for (const udid of udids)
315
- this.deliveries.set(udid, done);
608
+ for (const key of keys)
609
+ chains.set(key, done);
316
610
  void done.then(() => {
317
- for (const udid of udids) {
318
- if (this.deliveries.get(udid) === done)
319
- this.deliveries.delete(udid);
611
+ for (const key of keys) {
612
+ if (chains.get(key) === done)
613
+ chains.delete(key);
320
614
  }
321
615
  });
322
616
  return done;
323
617
  }
618
+ /**
619
+ * Whether every dashboard socket gets an event of this scope whatever its
620
+ * phones are, so it can be the room broadcast with no lookup.
621
+ */
622
+ reachesEverySocket(scope) {
623
+ return this.dashboardSockets().every((socket) => teamIdsOf(socket) === undefined && (!scope.adminOnly || isAdmin(socket)));
624
+ }
324
625
  deliver(event, data, scope) {
325
626
  return __awaiter(this, void 0, void 0, function* () {
326
627
  var _a;
327
628
  // The scoped sockets may have left while this waited its turn.
328
- if (!this.hasScopedDashboard()) {
629
+ if (this.reachesEverySocket(scope)) {
329
630
  (_a = this.io) === null || _a === void 0 ? void 0 : _a.to('dashboard').emit(event, data);
330
631
  return;
331
632
  }
332
633
  const resolver = typedi_1.Container.get(DeviceTeamResolver_1.DeviceTeamResolver);
634
+ // Read once the team is known, so a socket that joined meanwhile is included.
635
+ const recipients = () => this.dashboardSockets().filter((socket) => !scope.adminOnly || isAdmin(socket));
636
+ // An admin-only event goes to admins, who see every phone: no lookup, so
637
+ // a slow store holds no captured request back.
638
+ if (scope.adminOnly) {
639
+ const admins = recipients();
640
+ if (admins.every((socket) => teamIdsOf(socket) === undefined)) {
641
+ for (const socket of admins)
642
+ socket.emit(event, data);
643
+ return;
644
+ }
645
+ }
333
646
  if ('udids' in scope) {
334
647
  const teams = yield Promise.all(scope.udids.map((udid) => resolver.resolve(udid)));
335
- for (const socket of this.dashboardSockets()) {
648
+ for (const socket of recipients()) {
336
649
  const teamIds = teamIdsOf(socket);
337
650
  if (teamIds === undefined) {
338
651
  socket.emit(event, data);
@@ -348,7 +661,7 @@ let SocketServer = SocketServer_1 = class SocketServer {
348
661
  const team = scope.teamId !== undefined
349
662
  ? { known: true, teamId: scope.teamId }
350
663
  : yield resolver.resolve(scope.udid);
351
- for (const socket of this.dashboardSockets()) {
664
+ for (const socket of recipients()) {
352
665
  if ((0, DeviceTeamResolver_1.canSeeDeviceTeam)(team, teamIdsOf(socket)))
353
666
  socket.emit(event, data);
354
667
  }
@@ -384,3 +697,9 @@ function teamIdsOf(socket) {
384
697
  const identity = (_a = socket.data) === null || _a === void 0 ? void 0 : _a.identity;
385
698
  return identity ? identity.teamIds : [];
386
699
  }
700
+ /** An admin by role, as `roleGuard('ADMIN')` decides. A socket with no identity is not one. */
701
+ function isAdmin(socket) {
702
+ var _a, _b;
703
+ const role = (_b = (_a = socket.data) === null || _a === void 0 ? void 0 : _a.identity) === null || _b === void 0 ? void 0 : _b.role;
704
+ return role === 'ADMIN' || role === 'SUPER_ADMIN';
705
+ }
@@ -22,6 +22,7 @@ exports.TeamService = void 0;
22
22
  const typedi_1 = require("typedi");
23
23
  const prisma_1 = require("../prisma");
24
24
  const logger_1 = __importDefault(require("../logger"));
25
+ const identityChanges_1 = require("./identity/identityChanges");
25
26
  let TeamService = class TeamService {
26
27
  constructor() {
27
28
  this.log = logger_1.default.scope('Team');
@@ -101,7 +102,9 @@ let TeamService = class TeamService {
101
102
  }
102
103
  addMember(teamId, userId) {
103
104
  return __awaiter(this, void 0, void 0, function* () {
104
- return prisma_1.prisma.teamMember.create({ data: { teamId, userId } });
105
+ const member = yield prisma_1.prisma.teamMember.create({ data: { teamId, userId } });
106
+ yield (0, identityChanges_1.identityChanged)(userId);
107
+ return member;
105
108
  });
106
109
  }
107
110
  removeMember(teamId, userId) {
@@ -109,6 +112,7 @@ let TeamService = class TeamService {
109
112
  yield prisma_1.prisma.teamMember.delete({
110
113
  where: { teamId_userId: { teamId, userId } },
111
114
  });
115
+ yield (0, identityChanges_1.identityChanged)(userId);
112
116
  });
113
117
  }
114
118
  };