@xenon-device-management/xenon 1.7.2 → 1.7.3

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 (38) hide show
  1. package/lib/package.json +2 -1
  2. package/lib/src/app/index.js +6 -0
  3. package/lib/src/app/routers/auth.js +30 -0
  4. package/lib/src/app/routers/capabilities.js +25 -0
  5. package/lib/src/app/routers/control.js +14 -0
  6. package/lib/src/app/routers/projects.js +55 -0
  7. package/lib/src/generated/client/edge.js +22 -4
  8. package/lib/src/generated/client/index-browser.js +19 -1
  9. package/lib/src/generated/client/index.d.ts +2376 -158
  10. package/lib/src/generated/client/index.js +22 -4
  11. package/lib/src/generated/client/package.json +1 -1
  12. package/lib/src/generated/client/schema.prisma +21 -0
  13. package/lib/src/generated/client/wasm.js +19 -1
  14. package/lib/src/middleware/authMiddleware.js +64 -3
  15. package/lib/src/prisma.js +1 -1
  16. package/lib/src/services/EventLogService.js +81 -0
  17. package/lib/src/services/ServerManager.js +36 -0
  18. package/lib/src/services/SocketServer.js +2 -0
  19. package/lib/src/services/artifacts/ArtifactStore.js +60 -0
  20. package/lib/src/services/recording/RecordingOrchestrator.js +4 -5
  21. package/lib/src/services/token/JwtKeyService.js +137 -0
  22. package/lib/src/services/token/StreamTicketService.js +61 -0
  23. package/lib/test/unit/ArtifactStore.spec.js +59 -0
  24. package/lib/test/unit/CapabilitiesRouter.spec.js +18 -0
  25. package/lib/test/unit/EventLogService.spec.js +67 -0
  26. package/lib/test/unit/JwtKeyService.spec.js +86 -0
  27. package/lib/test/unit/ProjectsRouter.spec.js +45 -0
  28. package/lib/test/unit/StreamTicketService.spec.js +95 -0
  29. package/lib/test/unit/TokenEndpoint.spec.js +68 -0
  30. package/lib/test/unit/authMiddleware.bearer.spec.js +102 -0
  31. package/lib/test/unit/authMiddleware.streamTicket.spec.js +217 -0
  32. package/lib/test/unit/recording-instrumentation.spec.js +8 -0
  33. package/lib/test/unit/recording-orchestrator.spec.js +14 -0
  34. package/lib/tsconfig.tsbuildinfo +1 -1
  35. package/package.json +2 -1
  36. package/prisma/migrations/20260717063103_event_log/migration.sql +15 -0
  37. package/prisma/migrations/20260717070124_project_entity/migration.sql +10 -0
  38. package/prisma/schema.prisma +21 -0
@@ -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) {
@@ -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,
@@ -0,0 +1,137 @@
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
+ });
18
+ var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
19
+ var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
20
+ if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
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;
22
+ return c > 3 && r && Object.defineProperty(target, key, r), r;
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
+ })();
41
+ var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
42
+ function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
43
+ return new (P || (P = Promise))(function (resolve, reject) {
44
+ function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
45
+ function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
46
+ function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
47
+ step((generator = generator.apply(thisArg, _arguments || [])).next());
48
+ });
49
+ };
50
+ var __importDefault = (this && this.__importDefault) || function (mod) {
51
+ return (mod && mod.__esModule) ? mod : { "default": mod };
52
+ };
53
+ Object.defineProperty(exports, "__esModule", { value: true });
54
+ exports.JwtKeyService = void 0;
55
+ const typedi_1 = require("typedi");
56
+ const jose = __importStar(require("jose"));
57
+ const crypto_1 = require("crypto");
58
+ const fs_1 = __importDefault(require("fs"));
59
+ const path_1 = __importDefault(require("path"));
60
+ const KEY_FILE = 'xenon-jwt-private.pem';
61
+ const ISSUER = process.env.XENON_JWT_ISSUER || 'xenon-hub';
62
+ /**
63
+ * RS256 signing key for hub-issued JWTs (REST/MCP tokens, stream tickets).
64
+ * Key material lives on disk (0600), never in the database. `kid` is derived
65
+ * from the public key so it is stable across restarts; rotation = drop a new
66
+ * PEM in place, old tokens fail verification (acceptable: TTLs are short).
67
+ */
68
+ let JwtKeyService = class JwtKeyService {
69
+ constructor() {
70
+ // Boot degrades gracefully if key material is unavailable (bad perms, full
71
+ // disk, corrupt PEM): the server still comes up, and only the token routes
72
+ // fail. sign()/verify() throw a clear error; jwks() serves an empty set.
73
+ this.initialized = false;
74
+ }
75
+ init(keyDir) {
76
+ return __awaiter(this, void 0, void 0, function* () {
77
+ fs_1.default.mkdirSync(keyDir, { recursive: true });
78
+ const keyPath = path_1.default.join(keyDir, KEY_FILE);
79
+ let pkcs8;
80
+ if (fs_1.default.existsSync(keyPath)) {
81
+ pkcs8 = fs_1.default.readFileSync(keyPath, 'utf8');
82
+ }
83
+ else {
84
+ const { privateKey } = yield jose.generateKeyPair('RS256', { extractable: true });
85
+ pkcs8 = yield jose.exportPKCS8(privateKey);
86
+ fs_1.default.writeFileSync(keyPath, pkcs8, { mode: 0o600 });
87
+ }
88
+ this.privateKey = yield jose.importPKCS8(pkcs8, 'RS256');
89
+ // Public JWK derived from the private key; strip private fields.
90
+ const fullJwk = yield jose.exportJWK(this.privateKey);
91
+ this.publicJwk = { kty: fullJwk.kty, n: fullJwk.n, e: fullJwk.e };
92
+ this.publicKey = yield jose.importJWK(Object.assign(Object.assign({}, this.publicJwk), { alg: 'RS256' }), 'RS256');
93
+ this.kid = (0, crypto_1.createHash)('sha256')
94
+ .update(`${fullJwk.n}.${fullJwk.e}`)
95
+ .digest('base64url')
96
+ .slice(0, 16);
97
+ this.initialized = true;
98
+ });
99
+ }
100
+ sign(claims, opts) {
101
+ return __awaiter(this, void 0, void 0, function* () {
102
+ if (!this.initialized)
103
+ throw new Error('JWT key service not initialized');
104
+ const jwt = new jose.SignJWT(claims)
105
+ .setProtectedHeader({ alg: 'RS256', kid: this.kid })
106
+ .setIssuer(ISSUER)
107
+ .setAudience(opts.audience)
108
+ .setIssuedAt()
109
+ .setExpirationTime(Math.floor(Date.now() / 1000) + opts.ttlSeconds);
110
+ if (opts.jti)
111
+ jwt.setJti(opts.jti);
112
+ return jwt.sign(this.privateKey);
113
+ });
114
+ }
115
+ verify(token, opts) {
116
+ return __awaiter(this, void 0, void 0, function* () {
117
+ if (!this.initialized)
118
+ throw new Error('JWT key service not initialized');
119
+ const { payload } = yield jose.jwtVerify(token, this.publicKey, {
120
+ issuer: ISSUER,
121
+ audience: opts.audience,
122
+ clockTolerance: 60, // spec §7.1: ±60 s skew, mirrors appium-mcp-auth
123
+ });
124
+ return payload;
125
+ });
126
+ }
127
+ jwks() {
128
+ // Empty JWKS when uninitialized — valid JSON, honest about having no keys.
129
+ if (!this.initialized)
130
+ return { keys: [] };
131
+ return { keys: [Object.assign(Object.assign({}, this.publicJwk), { kid: this.kid, use: 'sig', alg: 'RS256' })] };
132
+ }
133
+ };
134
+ exports.JwtKeyService = JwtKeyService;
135
+ exports.JwtKeyService = JwtKeyService = __decorate([
136
+ (0, typedi_1.Service)()
137
+ ], JwtKeyService);
@@ -0,0 +1,61 @@
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 __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
9
+ function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
10
+ return new (P || (P = Promise))(function (resolve, reject) {
11
+ function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
12
+ function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
13
+ function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
14
+ step((generator = generator.apply(thisArg, _arguments || [])).next());
15
+ });
16
+ };
17
+ Object.defineProperty(exports, "__esModule", { value: true });
18
+ exports.StreamTicketService = void 0;
19
+ const typedi_1 = require("typedi");
20
+ const crypto_1 = require("crypto");
21
+ const JwtKeyService_1 = require("./JwtKeyService");
22
+ const TICKET_TTL_SEC = 60;
23
+ /** Single-use, udid-bound, 60 s tokens for the webview <img> MJPEG path. */
24
+ let StreamTicketService = class StreamTicketService {
25
+ constructor() {
26
+ // jti -> expiry epoch-ms. Pruned on each redeem; bounded by 60 s TTL.
27
+ this.used = new Map();
28
+ }
29
+ mint(udid, actorId) {
30
+ return __awaiter(this, void 0, void 0, function* () {
31
+ return typedi_1.Container.get(JwtKeyService_1.JwtKeyService).sign({ udid, actorId }, { audience: 'xenon-stream', ttlSeconds: TICKET_TTL_SEC, jti: (0, crypto_1.randomUUID)() });
32
+ });
33
+ }
34
+ redeem(ticket, udid) {
35
+ return __awaiter(this, void 0, void 0, function* () {
36
+ const payload = yield typedi_1.Container.get(JwtKeyService_1.JwtKeyService).verify(ticket, { audience: 'xenon-stream' });
37
+ if (payload.udid !== udid)
38
+ throw new Error('ticket udid mismatch');
39
+ const jti = String(payload.jti);
40
+ const now = Date.now();
41
+ for (const [k, exp] of this.used)
42
+ if (exp < now)
43
+ this.used.delete(k);
44
+ if (this.used.has(jti))
45
+ throw new Error('ticket already used');
46
+ // Evict the anti-replay entry only once the JWT itself is genuinely
47
+ // unverifiable. JwtKeyService.verify() honors clockTolerance:60, so the
48
+ // token stays acceptable until exp + 60s. Keying eviction off redeem-time
49
+ // + TTL (mint..mint+60) would drop the jti at mint+60 while verify() still
50
+ // accepts a replay up to mint+120 — a ~60s single-use bypass. Key off the
51
+ // token's own exp claim plus the same tolerance so memory outlives it.
52
+ const evictAt = (typeof payload.exp === 'number' ? payload.exp * 1000 : now + TICKET_TTL_SEC * 1000) + 60000; // token exp + clockTolerance (mirrors JwtKeyService.verify)
53
+ this.used.set(jti, evictAt);
54
+ return { actorId: String(payload.actorId) };
55
+ });
56
+ }
57
+ };
58
+ exports.StreamTicketService = StreamTicketService;
59
+ exports.StreamTicketService = StreamTicketService = __decorate([
60
+ (0, typedi_1.Service)()
61
+ ], StreamTicketService);