@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.
- package/lib/package.json +2 -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/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/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/recording-instrumentation.spec.js +8 -0
- package/lib/test/unit/recording-orchestrator.spec.js +14 -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
|
@@ -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) {
|
|
@@ -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,
|
|
@@ -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);
|