@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.
- package/lib/package.json +2 -1
- package/lib/public/assets/{ApiKeyGate-BousCIQZ.js → ApiKeyGate-DX4HKDBA.js} +1 -1
- package/lib/public/assets/{BugReportButton-Gw4lnGnV.js → BugReportButton-N-NBBb3l.js} +1 -1
- package/lib/public/assets/{DeviceMosaicView-BX2hfhBf.js → DeviceMosaicView-BEDnJqk5.js} +1 -1
- package/lib/public/assets/{EmptyState-CPOfuV3O.js → EmptyState-X7DYPoPj.js} +1 -1
- package/lib/public/assets/{FieldGroup-C5UkCHbe.js → FieldGroup-a9gzUh4W.js} +1 -1
- package/lib/public/assets/{Modal-MMGhL8eW.js → Modal-Cuva-zxU.js} +1 -1
- package/lib/public/assets/{SegmentedControl-CZfAicws.js → SegmentedControl-Bdc4A0UR.js} +1 -1
- package/lib/public/assets/{SettingCard-Dp7XIdcl.js → SettingCard-DVZUpQJR.js} +1 -1
- package/lib/public/assets/{Table-DsN_IX6J.js → Table-Casq-_Hh.js} +1 -1
- package/lib/public/assets/{activity-DCBAvxuo.js → activity-KiLkzk_U.js} +1 -1
- package/lib/public/assets/{ai-settings-yNS_bU1m.js → ai-settings-0dEPftlc.js} +1 -1
- package/lib/public/assets/{api-keys-CQ9g1g1b.js → api-keys-Cg1cbSUt.js} +1 -1
- package/lib/public/assets/{apps-Bqh1eav9.js → apps-CyetRRfI.js} +1 -1
- package/lib/public/assets/{arrow-left-GX0vzYtn.js → arrow-left-C1v7ukDM.js} +1 -1
- package/lib/public/assets/{arrow-up-right-DePHHOby.js → arrow-up-right-B-YmrLSY.js} +1 -1
- package/lib/public/assets/{auth-shell-BBGlC6i3.js → auth-shell-BXwxuIPK.js} +1 -1
- package/lib/public/assets/{builds-page-Czf7RsmD.js → builds-page-wquO6Xb2.js} +1 -1
- package/lib/public/assets/{button-ybNY3GYx.js → button-xZrEx8cE.js} +1 -1
- package/lib/public/assets/{check-BZMx8AhR.js → check-CDOZKQih.js} +1 -1
- package/lib/public/assets/{chevron-right-WL_bcB02.js → chevron-right-CnpP0W_l.js} +1 -1
- package/lib/public/assets/{circle-check-CI2xUlDo.js → circle-check-DDqpikKW.js} +1 -1
- package/lib/public/assets/{circle-x-De3thbcr.js → circle-x-DcBe5J67.js} +1 -1
- package/lib/public/assets/{clock-BFVLE7wW.js → clock-CfNmWqai.js} +1 -1
- package/lib/public/assets/{copy-ve3lNhva.js → copy-DkXT5Ggc.js} +1 -1
- package/lib/public/assets/{device-explorer-vzZxT4-Y.js → device-explorer-B3PK2XW9.js} +1 -1
- package/lib/public/assets/{download-BDZj44ke.js → download-B0tbRUtY.js} +1 -1
- package/lib/public/assets/{forgot-password-BSYQX2oR.js → forgot-password-DvHMwK0-.js} +1 -1
- package/lib/public/assets/{funnel-CptlpFZ9.js → funnel-DfsfiM0o.js} +1 -1
- package/lib/public/assets/{index-PtnKslHU.js → index-wbxPcwMM.js} +2 -2
- package/lib/public/assets/{layers-DRpwoKhI.js → layers-D-4LYQG3.js} +1 -1
- package/lib/public/assets/{lock-CrQhC34I.js → lock-Cf_J2NEl.js} +1 -1
- package/lib/public/assets/{login-DaJOt8Qz.js → login-DCIOMQb8.js} +1 -1
- package/lib/public/assets/{maintenance-settings-D629C2IC.js → maintenance-settings-CDDl4Q8B.js} +1 -1
- package/lib/public/assets/{mouse-pointer-2-Dpcc76zN.js → mouse-pointer-2-DoCqhosb.js} +1 -1
- package/lib/public/assets/{overview-CGEKYX62.js → overview-DNeH4FfG.js} +1 -1
- package/lib/public/assets/{page-header-CSvuGbSO.js → page-header-DZpQDeCw.js} +1 -1
- package/lib/public/assets/{plus-vJJF9g79.js → plus-CTjPrkU5.js} +1 -1
- package/lib/public/assets/{profile-page-CF7ZQNQz.js → profile-page-DKiZOb_Y.js} +1 -1
- package/lib/public/assets/{reset-password-BgpjbCac.js → reset-password-6rl6YA7S.js} +1 -1
- package/lib/public/assets/{runbook-page-Dxuzftyv.js → runbook-page-DTTw_cb3.js} +1 -1
- package/lib/public/assets/{selector-detail-page-B_puCd9g.js → selector-detail-page-BVPMp3ZK.js} +1 -1
- package/lib/public/assets/{selector-health-BjMHf106.js → selector-health-BVJ8fGyw.js} +1 -1
- package/lib/public/assets/{selector-health-page-Du_bGD2p.js → selector-health-page-CZ_AAjtV.js} +1 -1
- package/lib/public/assets/{session-detail-page-lQTe5_B1.js → session-detail-page-BApz2wZr.js} +1 -1
- package/lib/public/assets/{settings-DhxWGUzy.js → settings-CuYjTdtb.js} +1 -1
- package/lib/public/assets/{shield-JGzamJo5.js → shield-DVYhrtS6.js} +1 -1
- package/lib/public/assets/{shield-alert-DzE36jcb.js → shield-alert-BhxlmXys.js} +1 -1
- package/lib/public/assets/{status-pill-outline-50aN0wRU.js → status-pill-outline-DjKuXDLG.js} +1 -1
- package/lib/public/assets/{tablet-DHY88mxw.js → tablet-CxgLRRaU.js} +1 -1
- package/lib/public/assets/{teams-EOAIPbZC.js → teams-EuX6i7H2.js} +1 -1
- package/lib/public/assets/{trash-2-BrTSfFh5.js → trash-2-8zA1h40t.js} +1 -1
- package/lib/public/assets/useSocket-qyqK9PWr.js +1 -0
- package/lib/public/assets/{users-Dd44jLAB.js → users-CPafGHcL.js} +1 -1
- package/lib/public/assets/{webhook-settings-DBYmqim_.js → webhook-settings-y8EAM79b.js} +1 -1
- package/lib/public/assets/{zap-JUbOoK3o.js → zap-C-b2-XlO.js} +1 -1
- package/lib/public/index.html +1 -1
- package/lib/src/app/index.js +6 -0
- package/lib/src/app/routers/auth.js +30 -0
- package/lib/src/app/routers/capabilities.js +25 -0
- package/lib/src/app/routers/control.js +14 -0
- package/lib/src/app/routers/projects.js +55 -0
- package/lib/src/device-managers/IOSDeviceManager.js +24 -3
- package/lib/src/device-managers/ios/IOSStreamService.js +39 -1
- package/lib/src/device-managers/ios/iosBattery.js +24 -0
- package/lib/src/device-managers/ios/iosStreamDiagnostics.js +61 -0
- package/lib/src/generated/client/edge.js +22 -4
- package/lib/src/generated/client/index-browser.js +19 -1
- package/lib/src/generated/client/index.d.ts +2376 -158
- package/lib/src/generated/client/index.js +22 -4
- package/lib/src/generated/client/package.json +1 -1
- package/lib/src/generated/client/schema.prisma +21 -0
- package/lib/src/generated/client/wasm.js +19 -1
- package/lib/src/middleware/authMiddleware.js +64 -3
- package/lib/src/prisma.js +1 -1
- package/lib/src/services/EventLogService.js +81 -0
- package/lib/src/services/ServerManager.js +36 -0
- package/lib/src/services/SocketServer.js +2 -0
- package/lib/src/services/VideoPipelineService.js +51 -43
- package/lib/src/services/artifacts/ArtifactStore.js +60 -0
- package/lib/src/services/recording/RecordingOrchestrator.js +4 -5
- package/lib/src/services/token/JwtKeyService.js +137 -0
- package/lib/src/services/token/StreamTicketService.js +61 -0
- package/lib/test/unit/ArtifactStore.spec.js +59 -0
- package/lib/test/unit/CapabilitiesRouter.spec.js +18 -0
- package/lib/test/unit/EventLogService.spec.js +67 -0
- package/lib/test/unit/JwtKeyService.spec.js +86 -0
- package/lib/test/unit/ProjectsRouter.spec.js +45 -0
- package/lib/test/unit/StreamTicketService.spec.js +95 -0
- package/lib/test/unit/TokenEndpoint.spec.js +68 -0
- package/lib/test/unit/authMiddleware.bearer.spec.js +102 -0
- package/lib/test/unit/authMiddleware.streamTicket.spec.js +217 -0
- package/lib/test/unit/iosBattery.spec.js +29 -0
- package/lib/test/unit/iosStreamDiagnostics.spec.js +61 -0
- package/lib/test/unit/recording-instrumentation.spec.js +8 -0
- package/lib/test/unit/recording-orchestrator.spec.js +14 -0
- package/lib/test/unit/video-pipeline-args.spec.js +46 -0
- package/lib/tsconfig.tsbuildinfo +1 -1
- package/package.json +2 -1
- package/prisma/migrations/20260717063103_event_log/migration.sql +15 -0
- package/prisma/migrations/20260717070124_project_entity/migration.sql +10 -0
- package/prisma/schema.prisma +21 -0
- package/lib/public/assets/useSocket-BRto9a5m.js +0 -1
|
@@ -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: (
|
|
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: (
|
|
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
|
-
|
|
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
|
|
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 =
|
|
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 =
|
|
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,
|