@xenon-device-management/xenon 1.7.2 → 1.7.4

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 (103) hide show
  1. package/lib/package.json +2 -1
  2. package/lib/public/assets/{ApiKeyGate-BousCIQZ.js → ApiKeyGate-DX4HKDBA.js} +1 -1
  3. package/lib/public/assets/{BugReportButton-Gw4lnGnV.js → BugReportButton-N-NBBb3l.js} +1 -1
  4. package/lib/public/assets/{DeviceMosaicView-BX2hfhBf.js → DeviceMosaicView-BEDnJqk5.js} +1 -1
  5. package/lib/public/assets/{EmptyState-CPOfuV3O.js → EmptyState-X7DYPoPj.js} +1 -1
  6. package/lib/public/assets/{FieldGroup-C5UkCHbe.js → FieldGroup-a9gzUh4W.js} +1 -1
  7. package/lib/public/assets/{Modal-MMGhL8eW.js → Modal-Cuva-zxU.js} +1 -1
  8. package/lib/public/assets/{SegmentedControl-CZfAicws.js → SegmentedControl-Bdc4A0UR.js} +1 -1
  9. package/lib/public/assets/{SettingCard-Dp7XIdcl.js → SettingCard-DVZUpQJR.js} +1 -1
  10. package/lib/public/assets/{Table-DsN_IX6J.js → Table-Casq-_Hh.js} +1 -1
  11. package/lib/public/assets/{activity-DCBAvxuo.js → activity-KiLkzk_U.js} +1 -1
  12. package/lib/public/assets/{ai-settings-yNS_bU1m.js → ai-settings-0dEPftlc.js} +1 -1
  13. package/lib/public/assets/{api-keys-CQ9g1g1b.js → api-keys-Cg1cbSUt.js} +1 -1
  14. package/lib/public/assets/{apps-Bqh1eav9.js → apps-CyetRRfI.js} +1 -1
  15. package/lib/public/assets/{arrow-left-GX0vzYtn.js → arrow-left-C1v7ukDM.js} +1 -1
  16. package/lib/public/assets/{arrow-up-right-DePHHOby.js → arrow-up-right-B-YmrLSY.js} +1 -1
  17. package/lib/public/assets/{auth-shell-BBGlC6i3.js → auth-shell-BXwxuIPK.js} +1 -1
  18. package/lib/public/assets/{builds-page-Czf7RsmD.js → builds-page-wquO6Xb2.js} +1 -1
  19. package/lib/public/assets/{button-ybNY3GYx.js → button-xZrEx8cE.js} +1 -1
  20. package/lib/public/assets/{check-BZMx8AhR.js → check-CDOZKQih.js} +1 -1
  21. package/lib/public/assets/{chevron-right-WL_bcB02.js → chevron-right-CnpP0W_l.js} +1 -1
  22. package/lib/public/assets/{circle-check-CI2xUlDo.js → circle-check-DDqpikKW.js} +1 -1
  23. package/lib/public/assets/{circle-x-De3thbcr.js → circle-x-DcBe5J67.js} +1 -1
  24. package/lib/public/assets/{clock-BFVLE7wW.js → clock-CfNmWqai.js} +1 -1
  25. package/lib/public/assets/{copy-ve3lNhva.js → copy-DkXT5Ggc.js} +1 -1
  26. package/lib/public/assets/{device-explorer-vzZxT4-Y.js → device-explorer-B3PK2XW9.js} +1 -1
  27. package/lib/public/assets/{download-BDZj44ke.js → download-B0tbRUtY.js} +1 -1
  28. package/lib/public/assets/{forgot-password-BSYQX2oR.js → forgot-password-DvHMwK0-.js} +1 -1
  29. package/lib/public/assets/{funnel-CptlpFZ9.js → funnel-DfsfiM0o.js} +1 -1
  30. package/lib/public/assets/{index-PtnKslHU.js → index-wbxPcwMM.js} +2 -2
  31. package/lib/public/assets/{layers-DRpwoKhI.js → layers-D-4LYQG3.js} +1 -1
  32. package/lib/public/assets/{lock-CrQhC34I.js → lock-Cf_J2NEl.js} +1 -1
  33. package/lib/public/assets/{login-DaJOt8Qz.js → login-DCIOMQb8.js} +1 -1
  34. package/lib/public/assets/{maintenance-settings-D629C2IC.js → maintenance-settings-CDDl4Q8B.js} +1 -1
  35. package/lib/public/assets/{mouse-pointer-2-Dpcc76zN.js → mouse-pointer-2-DoCqhosb.js} +1 -1
  36. package/lib/public/assets/{overview-CGEKYX62.js → overview-DNeH4FfG.js} +1 -1
  37. package/lib/public/assets/{page-header-CSvuGbSO.js → page-header-DZpQDeCw.js} +1 -1
  38. package/lib/public/assets/{plus-vJJF9g79.js → plus-CTjPrkU5.js} +1 -1
  39. package/lib/public/assets/{profile-page-CF7ZQNQz.js → profile-page-DKiZOb_Y.js} +1 -1
  40. package/lib/public/assets/{reset-password-BgpjbCac.js → reset-password-6rl6YA7S.js} +1 -1
  41. package/lib/public/assets/{runbook-page-Dxuzftyv.js → runbook-page-DTTw_cb3.js} +1 -1
  42. package/lib/public/assets/{selector-detail-page-B_puCd9g.js → selector-detail-page-BVPMp3ZK.js} +1 -1
  43. package/lib/public/assets/{selector-health-BjMHf106.js → selector-health-BVJ8fGyw.js} +1 -1
  44. package/lib/public/assets/{selector-health-page-Du_bGD2p.js → selector-health-page-CZ_AAjtV.js} +1 -1
  45. package/lib/public/assets/{session-detail-page-lQTe5_B1.js → session-detail-page-BApz2wZr.js} +1 -1
  46. package/lib/public/assets/{settings-DhxWGUzy.js → settings-CuYjTdtb.js} +1 -1
  47. package/lib/public/assets/{shield-JGzamJo5.js → shield-DVYhrtS6.js} +1 -1
  48. package/lib/public/assets/{shield-alert-DzE36jcb.js → shield-alert-BhxlmXys.js} +1 -1
  49. package/lib/public/assets/{status-pill-outline-50aN0wRU.js → status-pill-outline-DjKuXDLG.js} +1 -1
  50. package/lib/public/assets/{tablet-DHY88mxw.js → tablet-CxgLRRaU.js} +1 -1
  51. package/lib/public/assets/{teams-EOAIPbZC.js → teams-EuX6i7H2.js} +1 -1
  52. package/lib/public/assets/{trash-2-BrTSfFh5.js → trash-2-8zA1h40t.js} +1 -1
  53. package/lib/public/assets/useSocket-qyqK9PWr.js +1 -0
  54. package/lib/public/assets/{users-Dd44jLAB.js → users-CPafGHcL.js} +1 -1
  55. package/lib/public/assets/{webhook-settings-DBYmqim_.js → webhook-settings-y8EAM79b.js} +1 -1
  56. package/lib/public/assets/{zap-JUbOoK3o.js → zap-C-b2-XlO.js} +1 -1
  57. package/lib/public/index.html +1 -1
  58. package/lib/src/app/index.js +6 -0
  59. package/lib/src/app/routers/auth.js +30 -0
  60. package/lib/src/app/routers/capabilities.js +25 -0
  61. package/lib/src/app/routers/control.js +14 -0
  62. package/lib/src/app/routers/projects.js +55 -0
  63. package/lib/src/device-managers/IOSDeviceManager.js +24 -3
  64. package/lib/src/device-managers/ios/IOSStreamService.js +39 -1
  65. package/lib/src/device-managers/ios/iosBattery.js +24 -0
  66. package/lib/src/device-managers/ios/iosStreamDiagnostics.js +61 -0
  67. package/lib/src/generated/client/edge.js +22 -4
  68. package/lib/src/generated/client/index-browser.js +19 -1
  69. package/lib/src/generated/client/index.d.ts +2376 -158
  70. package/lib/src/generated/client/index.js +22 -4
  71. package/lib/src/generated/client/package.json +1 -1
  72. package/lib/src/generated/client/schema.prisma +21 -0
  73. package/lib/src/generated/client/wasm.js +19 -1
  74. package/lib/src/middleware/authMiddleware.js +64 -3
  75. package/lib/src/prisma.js +1 -1
  76. package/lib/src/services/EventLogService.js +81 -0
  77. package/lib/src/services/ServerManager.js +36 -0
  78. package/lib/src/services/SocketServer.js +2 -0
  79. package/lib/src/services/VideoPipelineService.js +51 -43
  80. package/lib/src/services/artifacts/ArtifactStore.js +60 -0
  81. package/lib/src/services/recording/RecordingOrchestrator.js +4 -5
  82. package/lib/src/services/token/JwtKeyService.js +137 -0
  83. package/lib/src/services/token/StreamTicketService.js +61 -0
  84. package/lib/test/unit/ArtifactStore.spec.js +59 -0
  85. package/lib/test/unit/CapabilitiesRouter.spec.js +18 -0
  86. package/lib/test/unit/EventLogService.spec.js +67 -0
  87. package/lib/test/unit/JwtKeyService.spec.js +86 -0
  88. package/lib/test/unit/ProjectsRouter.spec.js +45 -0
  89. package/lib/test/unit/StreamTicketService.spec.js +95 -0
  90. package/lib/test/unit/TokenEndpoint.spec.js +68 -0
  91. package/lib/test/unit/authMiddleware.bearer.spec.js +102 -0
  92. package/lib/test/unit/authMiddleware.streamTicket.spec.js +217 -0
  93. package/lib/test/unit/iosBattery.spec.js +29 -0
  94. package/lib/test/unit/iosStreamDiagnostics.spec.js +61 -0
  95. package/lib/test/unit/recording-instrumentation.spec.js +8 -0
  96. package/lib/test/unit/recording-orchestrator.spec.js +14 -0
  97. package/lib/test/unit/video-pipeline-args.spec.js +46 -0
  98. package/lib/tsconfig.tsbuildinfo +1 -1
  99. package/package.json +2 -1
  100. package/prisma/migrations/20260717063103_event_log/migration.sql +15 -0
  101. package/prisma/migrations/20260717070124_project_entity/migration.sql +10 -0
  102. package/prisma/schema.prisma +21 -0
  103. package/lib/public/assets/useSocket-BRto9a5m.js +0 -1
@@ -1,5 +1,5 @@
1
1
  {
2
- "name": "prisma-client-f9160d8df2e4d6c96a662ee5e57fac84fdeae4219fe48275125ca22e998fe5fa",
2
+ "name": "prisma-client-6dbaaf845a8b748475630cfe8b405b0da9018b7231b8b50f3d7c7a681a0566d8",
3
3
  "main": "index.js",
4
4
  "types": "index.d.ts",
5
5
  "browser": "index-browser.js",
@@ -437,3 +437,24 @@ model TeamMember {
437
437
  @@id([teamId, userId])
438
438
  @@index([userId])
439
439
  }
440
+
441
+ model EventLog {
442
+ id String @id @default(cuid())
443
+ type String
444
+ payload String // JSON string — portable, no SQLite-isms
445
+ correlationId String?
446
+ teamId String?
447
+ occurredAt DateTime @default(now())
448
+
449
+ @@index([type, occurredAt])
450
+ @@index([occurredAt])
451
+ }
452
+
453
+ model Project {
454
+ id String @id @default(cuid())
455
+ name String
456
+ teamId String?
457
+ createdAt DateTime @default(now())
458
+
459
+ @@index([teamId])
460
+ }
@@ -465,6 +465,22 @@ exports.Prisma.TeamMemberScalarFieldEnum = {
465
465
  createdAt: 'createdAt'
466
466
  };
467
467
 
468
+ exports.Prisma.EventLogScalarFieldEnum = {
469
+ id: 'id',
470
+ type: 'type',
471
+ payload: 'payload',
472
+ correlationId: 'correlationId',
473
+ teamId: 'teamId',
474
+ occurredAt: 'occurredAt'
475
+ };
476
+
477
+ exports.Prisma.ProjectScalarFieldEnum = {
478
+ id: 'id',
479
+ name: 'name',
480
+ teamId: 'teamId',
481
+ createdAt: 'createdAt'
482
+ };
483
+
468
484
  exports.Prisma.SortOrder = {
469
485
  asc: 'asc',
470
486
  desc: 'desc'
@@ -500,7 +516,9 @@ exports.Prisma.ModelName = {
500
516
  User: 'User',
501
517
  UserSession: 'UserSession',
502
518
  PasswordResetToken: 'PasswordResetToken',
503
- TeamMember: 'TeamMember'
519
+ TeamMember: 'TeamMember',
520
+ EventLog: 'EventLog',
521
+ Project: 'Project'
504
522
  };
505
523
 
506
524
  /**
@@ -15,6 +15,8 @@ const typedi_1 = require("typedi");
15
15
  const ApiKeyService_1 = require("../services/ApiKeyService");
16
16
  const UserSessionService_1 = require("../services/UserSessionService");
17
17
  const UserService_1 = require("../services/UserService");
18
+ const JwtKeyService_1 = require("../services/token/JwtKeyService");
19
+ const StreamTicketService_1 = require("../services/token/StreamTicketService");
18
20
  const config_1 = require("../config");
19
21
  const prisma_1 = require("../prisma");
20
22
  const SESSION_COOKIE = 'xenon_dashboard_session';
@@ -70,7 +72,7 @@ function computeTeamIds(opts) {
70
72
  }
71
73
  function authMiddleware(req, res, next) {
72
74
  return __awaiter(this, void 0, void 0, function* () {
73
- var _a, _b, _c, _d;
75
+ var _a, _b, _c, _d, _e, _f, _g, _h;
74
76
  if (config_1.config.authDisabled === true) {
75
77
  req.auth = {
76
78
  kind: 'api-key',
@@ -114,6 +116,39 @@ function authMiddleware(req, res, next) {
114
116
  req.apiKey = { id: row.id, scopes: row.scopes, rateLimit: row.rateLimit, teamId: (_b = row.teamId) !== null && _b !== void 0 ? _b : null };
115
117
  return next();
116
118
  }
119
+ // Path 1.5: Authorization: Bearer <hub-issued JWT> (audience xenon-rest).
120
+ // Live user lookup on every request → revocation is instant on the REST
121
+ // surface even though the token itself is stateless (spec §7.1).
122
+ const authHeader = req.headers['authorization'];
123
+ if (typeof authHeader === 'string' && authHeader.startsWith('Bearer ')) {
124
+ try {
125
+ const payload = yield typedi_1.Container.get(JwtKeyService_1.JwtKeyService).verify(authHeader.slice(7), {
126
+ audience: 'xenon-rest',
127
+ });
128
+ const user = yield userSvc.findById(String(payload.sub));
129
+ if (!user || user.status !== 'ACTIVE') {
130
+ return res.status(401).json({ error: 'invalid token' });
131
+ }
132
+ const teamIds = yield computeTeamIds({
133
+ role: user.role,
134
+ userId: user.id,
135
+ apiKeyTeamId: (_c = payload.teamId) !== null && _c !== void 0 ? _c : null,
136
+ });
137
+ req.auth = {
138
+ kind: 'bearer',
139
+ userId: user.id,
140
+ role: user.role,
141
+ scopes: String((_d = payload.scopes) !== null && _d !== void 0 ? _d : ''),
142
+ teamId: (_e = payload.teamId) !== null && _e !== void 0 ? _e : null,
143
+ rateLimit: 300,
144
+ teamIds,
145
+ };
146
+ return next();
147
+ }
148
+ catch (_j) {
149
+ return res.status(401).json({ error: 'invalid token' });
150
+ }
151
+ }
117
152
  // Path 2: cookie — UserSession first, then ApiKey (legacy issuance path).
118
153
  // Both ids are UUIDv4; collision is astronomically unlikely (<1 in 2^128).
119
154
  // On collision, UserSession wins and we never look at the ApiKey branch.
@@ -174,15 +209,41 @@ function authMiddleware(req, res, next) {
174
209
  userId: user.id,
175
210
  role: user.role,
176
211
  scopes: row.scopes,
177
- teamId: (_c = row.teamId) !== null && _c !== void 0 ? _c : null,
212
+ teamId: (_f = row.teamId) !== null && _f !== void 0 ? _f : null,
178
213
  apiKeyId: row.id,
179
214
  rateLimit: row.rateLimit,
180
215
  teamIds,
181
216
  };
182
- req.apiKey = { id: row.id, scopes: row.scopes, rateLimit: row.rateLimit, teamId: (_d = row.teamId) !== null && _d !== void 0 ? _d : null };
217
+ req.apiKey = { id: row.id, scopes: row.scopes, rateLimit: row.rateLimit, teamId: (_g = row.teamId) !== null && _g !== void 0 ? _g : null };
183
218
  return next();
184
219
  }
185
220
  }
221
+ // Path 3: single-use stream ticket — ONLY for GET <control>/:udid/stream.
222
+ // req.path here is relative to apiRouter's own mount (authMiddleware is
223
+ // registered directly on apiRouter, before ControlRouter mounts '/control'
224
+ // onto it), so it is '/control/<udid>/stream' — not the full
225
+ // '/xenon/api/control/...' external URL. Verified against
226
+ // src/app/index.ts (apiRouter.use(authMiddleware) at line 223, then
227
+ // ControlRouter.register(apiRouter) -> parentRouter.use('/control', router)).
228
+ const ticket = (_h = req.query) === null || _h === void 0 ? void 0 : _h.ticket;
229
+ const streamMatch = req.method === 'GET' && /^\/control\/([^/]+)\/stream$/.exec(req.path);
230
+ if (typeof ticket === 'string' && streamMatch) {
231
+ try {
232
+ const { actorId } = yield typedi_1.Container.get(StreamTicketService_1.StreamTicketService).redeem(ticket, streamMatch[1]);
233
+ req.auth = {
234
+ kind: 'stream-ticket',
235
+ userId: actorId,
236
+ role: 'MEMBER',
237
+ scopes: 'read',
238
+ rateLimit: 300,
239
+ teamIds: undefined,
240
+ };
241
+ return next();
242
+ }
243
+ catch (_k) {
244
+ return res.status(401).json({ error: 'invalid ticket' });
245
+ }
246
+ }
186
247
  return res.status(401).json({ error: 'unauthenticated' });
187
248
  });
188
249
  }
package/lib/src/prisma.js CHANGED
@@ -36,7 +36,7 @@ const MODEL_DELEGATES = new Set([
36
36
  'build', 'session', 'sessionLog', 'log', 'profiling', 'app', 'device',
37
37
  'pendingSession', 'cLIArgs', 'webhookConfig', 'webConfig', 'locatorEtalon',
38
38
  'lease', 'portLease', 'apiKey', 'selectorState', 'user', 'userSession',
39
- 'passwordResetToken', 'team', 'teamMember',
39
+ 'passwordResetToken', 'team', 'teamMember', 'eventLog', 'project',
40
40
  ]);
41
41
  /** Cache of plain-object wrappers, keyed by model name. */
42
42
  const _modelWrappers = new Map();
@@ -0,0 +1,81 @@
1
+ "use strict";
2
+ var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
3
+ var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
4
+ if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
5
+ 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
+ return c > 3 && r && Object.defineProperty(target, key, r), r;
7
+ };
8
+ var __metadata = (this && this.__metadata) || function (k, v) {
9
+ if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v);
10
+ };
11
+ var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
12
+ function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
13
+ return new (P || (P = Promise))(function (resolve, reject) {
14
+ function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
15
+ function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
16
+ function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
17
+ step((generator = generator.apply(thisArg, _arguments || [])).next());
18
+ });
19
+ };
20
+ var __importDefault = (this && this.__importDefault) || function (mod) {
21
+ return (mod && mod.__esModule) ? mod : { "default": mod };
22
+ };
23
+ Object.defineProperty(exports, "__esModule", { value: true });
24
+ exports.EventLogService = void 0;
25
+ const typedi_1 = require("typedi");
26
+ const prisma_service_1 = require("../data-service/prisma-service");
27
+ const logger_1 = __importDefault(require("../logger"));
28
+ /**
29
+ * Transactional-outbox seed (ARB foreclosure guard #1). Append-only log of
30
+ * every dashboard-visible domain event. Fire-and-forget by design: the
31
+ * broadcast hot path must gain zero latency and never fail because of the log.
32
+ */
33
+ let EventLogService = class EventLogService {
34
+ constructor(prismaService) {
35
+ this.prismaService = prismaService;
36
+ }
37
+ appendSafe(entry) {
38
+ if (process.env.XENON_EVENT_LOG === 'off')
39
+ return;
40
+ setImmediate(() => {
41
+ var _a, _b, _c;
42
+ // Serialize defensively: a circular/unserializable payload must degrade
43
+ // to a placeholder, never throw. The throw would be uncaught here (it
44
+ // happens while building the .create() args, before the promise/.catch
45
+ // exists), and the global uncaughtException handler in src/index.ts would
46
+ // process.exit(1) — a single bad dashboard-event payload cannot be allowed
47
+ // to crash the whole plugin process.
48
+ let payloadStr;
49
+ try {
50
+ payloadStr = JSON.stringify((_a = entry.payload) !== null && _a !== void 0 ? _a : null);
51
+ }
52
+ catch (_d) {
53
+ payloadStr = JSON.stringify({ _unserializable: true, type: entry.type });
54
+ }
55
+ this.prismaService.client.eventLog
56
+ .create({
57
+ data: {
58
+ type: entry.type,
59
+ payload: payloadStr,
60
+ correlationId: (_b = entry.correlationId) !== null && _b !== void 0 ? _b : null,
61
+ teamId: (_c = entry.teamId) !== null && _c !== void 0 ? _c : null,
62
+ },
63
+ })
64
+ .catch((err) => logger_1.default.debug(`EventLog append failed (non-fatal): ${err === null || err === void 0 ? void 0 : err.message}`));
65
+ });
66
+ }
67
+ prune(retentionDays) {
68
+ return __awaiter(this, void 0, void 0, function* () {
69
+ const cutoff = new Date(Date.now() - retentionDays * 24 * 60 * 60 * 1000);
70
+ const res = yield this.prismaService.client.eventLog.deleteMany({
71
+ where: { occurredAt: { lt: cutoff } },
72
+ });
73
+ return res.count;
74
+ });
75
+ }
76
+ };
77
+ exports.EventLogService = EventLogService;
78
+ exports.EventLogService = EventLogService = __decorate([
79
+ (0, typedi_1.Service)(),
80
+ __metadata("design:paramtypes", [prisma_service_1.PrismaService])
81
+ ], EventLogService);
@@ -55,6 +55,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
55
55
  exports.ServerManager = void 0;
56
56
  const typedi_1 = require("typedi");
57
57
  const os = __importStar(require("os"));
58
+ const path = __importStar(require("path"));
58
59
  const OrphanSweeper_1 = require("./OrphanSweeper");
59
60
  const uuid_1 = require("uuid");
60
61
  const helpers_1 = require("../helpers");
@@ -79,6 +80,7 @@ const NodeDevices_1 = __importDefault(require("../device-managers/NodeDevices"))
79
80
  const config_1 = require("../config");
80
81
  const SocketServer_1 = require("./SocketServer");
81
82
  const SocketClient_1 = require("./SocketClient");
83
+ const EventLogService_1 = require("./EventLogService");
82
84
  const TracingService_1 = require("./TracingService");
83
85
  const plugin_1 = require("../plugin");
84
86
  let ServerManager = ServerManager_1 = class ServerManager {
@@ -133,6 +135,17 @@ let ServerManager = ServerManager_1 = class ServerManager {
133
135
  logger_1.default.warn(`LeaseOrphanSweeper tick failed: ${(_a = err === null || err === void 0 ? void 0 : err.message) !== null && _a !== void 0 ? _a : err}`);
134
136
  });
135
137
  }, 30000);
138
+ // Daily EventLog prune (transactional-outbox seed): keeps the append-only
139
+ // log from growing unbounded. Fire-and-forget interval, unref'd so it
140
+ // never keeps the process alive on its own.
141
+ const eventLog = typedi_1.Container.get(EventLogService_1.EventLogService);
142
+ setInterval(() => {
143
+ const rawRet = Number(process.env.XENON_EVENT_LOG_RETENTION_DAYS);
144
+ const retentionDays = Number.isFinite(rawRet) && rawRet > 0 ? rawRet : 30;
145
+ eventLog
146
+ .prune(retentionDays)
147
+ .catch((err) => { var _a; return logger_1.default.warn(`EventLog prune failed: ${(_a = err === null || err === void 0 ? void 0 : err.message) !== null && _a !== void 0 ? _a : err}`); });
148
+ }, 24 * 60 * 60 * 1000).unref();
136
149
  // Cleanup any remaining zombie sessions (cross-node fallback)
137
150
  const { cleanupZombieSessions } = yield Promise.resolve().then(() => __importStar(require('../dashboard/services/session-service')));
138
151
  yield cleanupZombieSessions(recoveredSessionIds);
@@ -215,6 +228,29 @@ let ServerManager = ServerManager_1 = class ServerManager {
215
228
  yield bootstrapIdentity();
216
229
  const { startUserSessionCleanupCron } = yield Promise.resolve().then(() => __importStar(require('./identity/sessionCleanupCron')));
217
230
  startUserSessionCleanupCron();
231
+ // Hub-issued JWT signing key (REST/MCP tokens, stream tickets) — key
232
+ // material lives next to the SQLite db file so it survives restarts
233
+ // without a new DB migration. Must finish before /auth/token or
234
+ // /auth/jwks.json can be mounted (registerRoutes runs right after this).
235
+ // Non-disruptive by design: a key-material failure (permissions, disk
236
+ // full, corrupt PEM) only degrades the two token routes — sign()/verify()
237
+ // throw "not initialized" and jwks() serves an empty set — it must never
238
+ // abort server boot.
239
+ try {
240
+ const { JwtKeyService } = yield Promise.resolve().then(() => __importStar(require('./token/JwtKeyService')));
241
+ const jwtKeyDir = process.env.XENON_JWT_KEY_DIR || path.dirname(config_1.config.databasePath);
242
+ yield typedi_1.Container.get(JwtKeyService).init(jwtKeyDir);
243
+ }
244
+ catch (err) {
245
+ logger_1.default.error(`JWT key init failed — /auth/token and /auth/jwks.json will be unavailable: ${err === null || err === void 0 ? void 0 : err.message}`);
246
+ }
247
+ // ARB foreclosure guard #2: every recording/proof-bundle artifact path
248
+ // flows through ArtifactStore so an S3/object-store backend later is an
249
+ // implementation swap, not a call-site migration. FsArtifactStore is
250
+ // byte-identical to the legacy path.join(recordingsAssetsPath, ...)
251
+ // concatenation it replaces.
252
+ const { FsArtifactStore, ARTIFACT_STORE } = yield Promise.resolve().then(() => __importStar(require('./artifacts/ArtifactStore')));
253
+ typedi_1.Container.set(ARTIFACT_STORE, new FsArtifactStore(config_1.config.recordingsAssetsPath));
218
254
  });
219
255
  }
220
256
  registerRoutes(expressApp, cliArgs, pluginArgs) {
@@ -26,6 +26,7 @@ const logger_1 = __importDefault(require("../logger"));
26
26
  const config_1 = require("../config");
27
27
  const ApiKeyService_1 = require("./ApiKeyService");
28
28
  const prisma_1 = require("../prisma");
29
+ const EventLogService_1 = require("./EventLogService");
29
30
  const SocketEvents_1 = require("../enums/SocketEvents");
30
31
  const SESSION_COOKIE = 'xenon_dashboard_session';
31
32
  function readCookie(cookieHeader, name) {
@@ -169,6 +170,7 @@ let SocketServer = SocketServer_1 = class SocketServer {
169
170
  if (this.io) {
170
171
  this.io.to('dashboard').emit(event, data);
171
172
  }
173
+ typedi_1.Container.get(EventLogService_1.EventLogService).appendSafe({ type: event, payload: data });
172
174
  }
173
175
  emitToNodes(event, data) {
174
176
  if (this.io) {
@@ -56,6 +56,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
56
56
  Object.defineProperty(exports, "__esModule", { value: true });
57
57
  exports.VideoPipelineService = void 0;
58
58
  exports.resolveOutputPath = resolveOutputPath;
59
+ exports.buildRecordArgs = buildRecordArgs;
59
60
  const typedi_1 = require("typedi");
60
61
  const ProcessRegistry_1 = require("./ProcessRegistry");
61
62
  const child_process_1 = require("child_process");
@@ -73,6 +74,48 @@ const ResourceIsolationService_1 = require("./ResourceIsolationService");
73
74
  function resolveOutputPath(sessionId, override) {
74
75
  return (override !== null && override !== void 0 ? override : path_1.default.join(config_1.config.sessionAssetsPath, sessionId, 'video', `${sessionId}.mp4`));
75
76
  }
77
+ /**
78
+ * Build the ffmpeg argv for a single-device MJPEG → mp4 recording.
79
+ *
80
+ * Extracted as a pure function so it can be unit-tested without spawning ffmpeg.
81
+ *
82
+ * Timing correctness: an HTTP MJPEG stream carries no real timestamps, so the
83
+ * mjpeg demuxer assumes a fixed 25 fps and stamps frames 1/25 s apart. The
84
+ * recorded duration then becomes (frames received)/25, which drifts from real
85
+ * time whenever the source's actual rate differs from 25 fps — a ~12.5 fps
86
+ * stream records at 2× speed, a >25 fps stream at slow motion. Stamping each
87
+ * frame with its wall-clock arrival time (`-use_wallclock_as_timestamps`) and
88
+ * keeping variable frame timing on output (`-vsync vfr`) makes the mp4 duration
89
+ * track real elapsed time regardless of the source frame rate.
90
+ */
91
+ function buildRecordArgs(opts) {
92
+ const args = [
93
+ '-y',
94
+ '-loglevel', 'error',
95
+ '-reconnect', '1',
96
+ '-reconnect_at_eof', '1',
97
+ '-reconnect_streamed', '1',
98
+ '-reconnect_delay_max', '5',
99
+ '-probesize', '32',
100
+ '-analyzeduration', '0',
101
+ // Stamp frames with wall-clock arrival time (must precede -i).
102
+ '-use_wallclock_as_timestamps', '1',
103
+ '-f', 'mjpeg',
104
+ '-i', opts.mjpegUrl,
105
+ '-pix_fmt', 'yuv420p',
106
+ ];
107
+ if (opts.isMac) {
108
+ args.push('-c:v', 'h264_videotoolbox', '-realtime', '1', '-q:v', '50');
109
+ }
110
+ else {
111
+ args.push('-c:v', 'libx264', '-preset', 'veryfast', '-crf', '25');
112
+ }
113
+ // Preserve the (variable) wall-clock timing rather than forcing constant fps.
114
+ args.push('-vsync', 'vfr');
115
+ args.push('-movflags', 'frag_keyframe+empty_moov+default_base_moof');
116
+ args.push(opts.outputPath);
117
+ return args;
118
+ }
76
119
  /**
77
120
  * Intelligent Video Pipeline Service
78
121
  *
@@ -121,47 +164,8 @@ let VideoPipelineService = class VideoPipelineService {
121
164
  logger_1.default.info(`[VideoPipeline] Starting HW-accelerated recording for ${sessionId} from ${mjpegUrl}`);
122
165
  // Small settlement delay to allow the source stream to prime
123
166
  yield new Promise((r) => setTimeout(r, 500));
124
- // 2. Construct FFMPEG Args
125
- // -f mjpeg: Input format
126
- // -i: Input source
127
- // -c:v: Hardware accelerated encoder based on platform
128
- // -movflags: fMP4 for instant playback and crash resiliency
129
- const args = [
130
- '-y',
131
- '-loglevel',
132
- 'error', // Only log errors to keep console clean
133
- '-reconnect',
134
- '1',
135
- '-reconnect_at_eof',
136
- '1',
137
- '-reconnect_streamed',
138
- '1',
139
- '-reconnect_delay_max',
140
- '5',
141
- '-probesize',
142
- '32', // Fast startup for MJPEG
143
- '-analyzeduration',
144
- '0',
145
- '-f',
146
- 'mjpeg',
147
- '-i',
148
- mjpegUrl,
149
- '-pix_fmt',
150
- 'yuv420p',
151
- ];
152
- if (this.isMac) {
153
- args.push('-c:v', 'h264_videotoolbox');
154
- args.push('-realtime', '1'); // VideoToolbox optimization
155
- args.push('-q:v', '50'); // High quality/efficiency balance
156
- }
157
- else {
158
- args.push('-c:v', 'libx264');
159
- args.push('-preset', 'veryfast');
160
- args.push('-crf', '25');
161
- }
162
- // Instant Playback Flags (fMP4)
163
- args.push('-movflags', 'frag_keyframe+empty_moov+default_base_moof');
164
- args.push(outputPath);
167
+ // 2. Construct FFMPEG Args (see buildRecordArgs for the timing rationale).
168
+ const args = buildRecordArgs({ mjpegUrl, outputPath, isMac: this.isMac });
165
169
  // 3. Wrap with Resource Isolation (Economy)
166
170
  const isolationService = typedi_1.Container.get(ResourceIsolationService_1.ResourceIsolationService);
167
171
  const { command, args: wrappedArgs } = isolationService.wrapSpawn('ffmpeg', args, 'Economy');
@@ -248,9 +252,11 @@ let VideoPipelineService = class VideoPipelineService {
248
252
  '-loglevel',
249
253
  'error',
250
254
  ];
251
- // Per-input args: mjpeg + reconnect.
255
+ // Per-input args: mjpeg + reconnect. `-use_wallclock_as_timestamps` stamps
256
+ // each input's frames with real arrival time so the composite duration
257
+ // tracks wall-clock (see buildRecordArgs for the full rationale).
252
258
  for (const inp of inputs) {
253
- args.push('-reconnect', '1', '-reconnect_at_eof', '1', '-reconnect_streamed', '1', '-reconnect_delay_max', '5', '-probesize', '32', '-analyzeduration', '0', '-f', 'mjpeg', '-i', `http://127.0.0.1:${inp.mjpegPort}`);
259
+ args.push('-reconnect', '1', '-reconnect_at_eof', '1', '-reconnect_streamed', '1', '-reconnect_delay_max', '5', '-probesize', '32', '-analyzeduration', '0', '-use_wallclock_as_timestamps', '1', '-f', 'mjpeg', '-i', `http://127.0.0.1:${inp.mjpegPort}`);
254
260
  }
255
261
  // Build the filtergraph: scale+pad each input to a uniform cell, then stack.
256
262
  const filterParts = [];
@@ -267,6 +273,8 @@ let VideoPipelineService = class VideoPipelineService {
267
273
  else {
268
274
  args.push('-c:v', 'libx264', '-preset', 'veryfast', '-crf', '25');
269
275
  }
276
+ // Preserve wall-clock frame timing rather than forcing constant fps.
277
+ args.push('-vsync', 'vfr');
270
278
  args.push('-movflags', 'frag_keyframe+empty_moov+default_base_moof');
271
279
  args.push(outputPath);
272
280
  logger_1.default.info(`[VideoPipeline] Composite [${groupId}] starting: ${inputs.length} inputs, layout=${layout}, ${cellW}×${cellH}/cell → ${outputPath}`);
@@ -0,0 +1,60 @@
1
+ "use strict";
2
+ var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
3
+ function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
4
+ return new (P || (P = Promise))(function (resolve, reject) {
5
+ function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
6
+ function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
7
+ function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
8
+ step((generator = generator.apply(thisArg, _arguments || [])).next());
9
+ });
10
+ };
11
+ var __importDefault = (this && this.__importDefault) || function (mod) {
12
+ return (mod && mod.__esModule) ? mod : { "default": mod };
13
+ };
14
+ Object.defineProperty(exports, "__esModule", { value: true });
15
+ exports.ARTIFACT_STORE = exports.FsArtifactStore = void 0;
16
+ const fs_1 = __importDefault(require("fs"));
17
+ const path_1 = __importDefault(require("path"));
18
+ class FsArtifactStore {
19
+ constructor(rootDir) {
20
+ this.rootDir = rootDir;
21
+ }
22
+ resolve(...segments) {
23
+ // Byte-identical to the legacy `path.join(config.recordingsAssetsPath, ...)`
24
+ // this replaced — including under a RELATIVE root (e.g. a relative
25
+ // XENON_RECORDINGS_ASSETS_PATH override): path.join stays relative,
26
+ // path.resolve would silently absolutize it. path.resolve is used ONLY
27
+ // to compute an absolute form for the traversal guard, never for the
28
+ // returned value.
29
+ const joined = path_1.default.join(this.rootDir, ...segments);
30
+ const absRoot = path_1.default.resolve(this.rootDir);
31
+ const abs = path_1.default.resolve(joined);
32
+ if (abs !== absRoot && !abs.startsWith(absRoot + path_1.default.sep)) {
33
+ throw new Error(`artifact path escapes store root: ${joined}`);
34
+ }
35
+ return joined;
36
+ }
37
+ ensureDir(...segments) {
38
+ return __awaiter(this, void 0, void 0, function* () {
39
+ const dir = this.resolve(...segments);
40
+ yield fs_1.default.promises.mkdir(dir, { recursive: true });
41
+ return dir;
42
+ });
43
+ }
44
+ createReadStream(relPath) {
45
+ return fs_1.default.createReadStream(this.resolve(relPath));
46
+ }
47
+ exists(relPath) {
48
+ return __awaiter(this, void 0, void 0, function* () {
49
+ try {
50
+ yield fs_1.default.promises.access(this.resolve(relPath));
51
+ return true;
52
+ }
53
+ catch (_a) {
54
+ return false;
55
+ }
56
+ });
57
+ }
58
+ }
59
+ exports.FsArtifactStore = FsArtifactStore;
60
+ exports.ARTIFACT_STORE = 'artifact-store'; // TypeDI token
@@ -57,11 +57,9 @@ Object.defineProperty(exports, "__esModule", { value: true });
57
57
  exports.RecordingOrchestrator = exports.RecordingError = void 0;
58
58
  exports.compositeOutputPath = compositeOutputPath;
59
59
  const typedi_1 = require("typedi");
60
- const path = __importStar(require("path"));
61
60
  const fs = __importStar(require("fs"));
62
61
  const uuid_1 = require("uuid");
63
62
  const api_1 = require("@opentelemetry/api");
64
- const config_1 = require("../../config");
65
63
  const VideoPipelineService_1 = require("../VideoPipelineService");
66
64
  const event_manager_1 = require("../../dashboard/event-manager");
67
65
  const busy_precheck_1 = require("./busy-precheck");
@@ -72,6 +70,7 @@ const device_store_1 = require("../../data-service/device-store");
72
70
  const manualLock_1 = require("./manualLock");
73
71
  const attributes_1 = require("../telemetry/attributes");
74
72
  const logger_1 = __importDefault(require("../../logger"));
73
+ const ArtifactStore_1 = require("../artifacts/ArtifactStore");
75
74
  const recLog = logger_1.default.scope('RecordingOrchestrator');
76
75
  // Lazy-init OTel instruments. Module-load order vs. TracingService init does
77
76
  // not matter — the OTel API returns Noop instruments until a real
@@ -111,7 +110,7 @@ function ensureOtelInstruments() {
111
110
  * stay clear of per-recording-id folders.
112
111
  */
113
112
  function compositeOutputPath(groupId) {
114
- return path.join(config_1.config.recordingsAssetsPath, '_groups', groupId, 'composite.mp4');
113
+ return typedi_1.Container.get(ArtifactStore_1.ARTIFACT_STORE).resolve('_groups', groupId, 'composite.mp4');
115
114
  }
116
115
  class RecordingError extends Error {
117
116
  constructor(code, busyDevices, limit, active) {
@@ -242,7 +241,7 @@ let RecordingOrchestrator = class RecordingOrchestrator {
242
241
  const id = recordingIds[i];
243
242
  const udid = udids[i];
244
243
  const host = deviceHosts[udid];
245
- const filePath = path.join(config_1.config.recordingsAssetsPath, id, 'video', `${id}.mp4`);
244
+ const filePath = typedi_1.Container.get(ArtifactStore_1.ARTIFACT_STORE).resolve(id, 'video', `${id}.mp4`);
246
245
  try {
247
246
  yield this.store.create({
248
247
  groupId,
@@ -524,7 +523,7 @@ let RecordingOrchestrator = class RecordingOrchestrator {
524
523
  this.gate.release(recordingId);
525
524
  throw new RecordingError('device_busy', [{ udid, reason: 'unknown' }]);
526
525
  }
527
- const filePath = path.join(config_1.config.recordingsAssetsPath, recordingId, 'video', `${recordingId}.mp4`);
526
+ const filePath = typedi_1.Container.get(ArtifactStore_1.ARTIFACT_STORE).resolve(recordingId, 'video', `${recordingId}.mp4`);
528
527
  try {
529
528
  yield this.store.create({
530
529
  groupId,