@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
@@ -0,0 +1,59 @@
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
+ const chai_1 = require("chai");
16
+ const fs_1 = __importDefault(require("fs"));
17
+ const os_1 = __importDefault(require("os"));
18
+ const path_1 = __importDefault(require("path"));
19
+ const ArtifactStore_1 = require("../../src/services/artifacts/ArtifactStore");
20
+ describe('FsArtifactStore', () => {
21
+ let root;
22
+ beforeEach(() => {
23
+ root = fs_1.default.mkdtempSync(path_1.default.join(os_1.default.tmpdir(), 'xenon-art-'));
24
+ });
25
+ afterEach(() => fs_1.default.rmSync(root, { recursive: true, force: true }));
26
+ it('resolve() is byte-identical to legacy path concatenation', () => {
27
+ const store = new ArtifactStore_1.FsArtifactStore(root);
28
+ const legacy = path_1.default.join(root, '_groups', 'g1', 'composite.mp4');
29
+ (0, chai_1.expect)(store.resolve('_groups', 'g1', 'composite.mp4')).to.equal(legacy);
30
+ });
31
+ it('resolve() stays byte-identical (relative, NOT absolutized) under a relative root', () => {
32
+ // config.recordingsAssetsPath is overridable via XENON_RECORDINGS_ASSETS_PATH
33
+ // with no absolute-path validation. Legacy code was path.join, which keeps a
34
+ // relative root relative; path.resolve would silently absolutize it and flip
35
+ // every persisted filePath relative→absolute. Byte-identical means path.join.
36
+ const store = new ArtifactStore_1.FsArtifactStore('rel/root');
37
+ const legacy = path_1.default.join('rel/root', '_groups', 'g1', 'composite.mp4');
38
+ (0, chai_1.expect)(store.resolve('_groups', 'g1', 'composite.mp4')).to.equal(legacy);
39
+ });
40
+ it('resolve() with no segments returns the root and does not throw', () => {
41
+ const store = new ArtifactStore_1.FsArtifactStore(root);
42
+ (0, chai_1.expect)(store.resolve()).to.equal(root);
43
+ });
44
+ it('refuses path traversal outside the root', () => {
45
+ const store = new ArtifactStore_1.FsArtifactStore(root);
46
+ (0, chai_1.expect)(() => store.resolve('..', 'etc', 'passwd')).to.throw(/escapes store root/);
47
+ });
48
+ it('ensureDir creates nested directories and returns the absolute path', () => __awaiter(void 0, void 0, void 0, function* () {
49
+ const store = new ArtifactStore_1.FsArtifactStore(root);
50
+ const dir = yield store.ensureDir('_groups', 'g2');
51
+ (0, chai_1.expect)(fs_1.default.statSync(dir).isDirectory()).to.equal(true);
52
+ }));
53
+ it('exists() reflects the filesystem', () => __awaiter(void 0, void 0, void 0, function* () {
54
+ const store = new ArtifactStore_1.FsArtifactStore(root);
55
+ (0, chai_1.expect)(yield store.exists('nope.mp4')).to.equal(false);
56
+ fs_1.default.writeFileSync(path_1.default.join(root, 'yes.mp4'), 'x');
57
+ (0, chai_1.expect)(yield store.exists('yes.mp4')).to.equal(true);
58
+ }));
59
+ });
@@ -0,0 +1,18 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ const chai_1 = require("chai");
4
+ const capabilities_1 = require("../../src/app/routers/capabilities");
5
+ describe('capabilities payload', () => {
6
+ it('reports version and the hub feature set', () => {
7
+ const caps = (0, capabilities_1.buildCapabilities)();
8
+ (0, chai_1.expect)(caps.version).to.be.a('string').and.not.equal('');
9
+ (0, chai_1.expect)(caps.features).to.deep.equal({
10
+ bearerAuth: true,
11
+ tokenIssuance: true,
12
+ streamTickets: true,
13
+ leases: true,
14
+ eventLog: true,
15
+ projects: true,
16
+ });
17
+ });
18
+ });
@@ -0,0 +1,67 @@
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
+ const chai_1 = require("chai");
16
+ const sinon_1 = __importDefault(require("sinon"));
17
+ const EventLogService_1 = require("../../src/services/EventLogService");
18
+ describe('EventLogService', () => {
19
+ function fakePrisma() {
20
+ return { eventLog: { create: sinon_1.default.stub().resolves({}), deleteMany: sinon_1.default.stub().resolves({ count: 3 }) } };
21
+ }
22
+ it('appendSafe writes type + JSON payload asynchronously', () => __awaiter(void 0, void 0, void 0, function* () {
23
+ const db = fakePrisma();
24
+ const svc = new EventLogService_1.EventLogService({ client: db });
25
+ svc.appendSafe({ type: 'device_blocked', payload: { udid: 'U1' }, teamId: 't1' });
26
+ yield new Promise((r) => setImmediate(r)); // let the fire-and-forget tick run
27
+ (0, chai_1.expect)(db.eventLog.create.calledOnce).to.equal(true);
28
+ const arg = db.eventLog.create.firstCall.args[0].data;
29
+ (0, chai_1.expect)(arg.type).to.equal('device_blocked');
30
+ (0, chai_1.expect)(JSON.parse(arg.payload)).to.deep.equal({ udid: 'U1' });
31
+ }));
32
+ it('appendSafe never throws even when the DB write fails', () => __awaiter(void 0, void 0, void 0, function* () {
33
+ const db = fakePrisma();
34
+ db.eventLog.create.rejects(new Error('disk full'));
35
+ const svc = new EventLogService_1.EventLogService({ client: db });
36
+ (0, chai_1.expect)(() => svc.appendSafe({ type: 'x', payload: {} })).to.not.throw();
37
+ yield new Promise((r) => setImmediate(r));
38
+ }));
39
+ it('appendSafe degrades a circular payload to a placeholder instead of crashing', () => __awaiter(void 0, void 0, void 0, function* () {
40
+ const db = fakePrisma();
41
+ const svc = new EventLogService_1.EventLogService({ client: db });
42
+ const c = {};
43
+ c.self = c;
44
+ (0, chai_1.expect)(() => svc.appendSafe({ type: 'device_blocked', payload: c })).to.not.throw();
45
+ yield new Promise((r) => setImmediate(r));
46
+ (0, chai_1.expect)(db.eventLog.create.calledOnce).to.equal(true);
47
+ const arg = db.eventLog.create.firstCall.args[0].data;
48
+ (0, chai_1.expect)(JSON.parse(arg.payload)).to.deep.equal({ _unserializable: true, type: 'device_blocked' });
49
+ }));
50
+ it('is a no-op when XENON_EVENT_LOG=off', () => __awaiter(void 0, void 0, void 0, function* () {
51
+ process.env.XENON_EVENT_LOG = 'off';
52
+ const db = fakePrisma();
53
+ const svc = new EventLogService_1.EventLogService({ client: db });
54
+ svc.appendSafe({ type: 'x', payload: {} });
55
+ yield new Promise((r) => setImmediate(r));
56
+ (0, chai_1.expect)(db.eventLog.create.called).to.equal(false);
57
+ delete process.env.XENON_EVENT_LOG;
58
+ }));
59
+ it('prune deletes rows older than retention', () => __awaiter(void 0, void 0, void 0, function* () {
60
+ const db = fakePrisma();
61
+ const svc = new EventLogService_1.EventLogService({ client: db });
62
+ const n = yield svc.prune(30);
63
+ (0, chai_1.expect)(n).to.equal(3);
64
+ const where = db.eventLog.deleteMany.firstCall.args[0].where;
65
+ (0, chai_1.expect)(where.occurredAt.lt).to.be.a('date');
66
+ }));
67
+ });
@@ -0,0 +1,86 @@
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
+ const chai_1 = require("chai");
16
+ const fs_1 = __importDefault(require("fs"));
17
+ const os_1 = __importDefault(require("os"));
18
+ const path_1 = __importDefault(require("path"));
19
+ const JwtKeyService_1 = require("../../src/services/token/JwtKeyService");
20
+ describe('JwtKeyService', () => {
21
+ let dir;
22
+ let svc;
23
+ beforeEach(() => __awaiter(void 0, void 0, void 0, function* () {
24
+ dir = fs_1.default.mkdtempSync(path_1.default.join(os_1.default.tmpdir(), 'xenon-jwt-'));
25
+ svc = new JwtKeyService_1.JwtKeyService();
26
+ yield svc.init(dir);
27
+ }));
28
+ afterEach(() => fs_1.default.rmSync(dir, { recursive: true, force: true }));
29
+ it('generates a private key file with 0600 on first init', () => {
30
+ const keyPath = path_1.default.join(dir, 'xenon-jwt-private.pem');
31
+ (0, chai_1.expect)(fs_1.default.existsSync(keyPath)).to.equal(true);
32
+ const mode = fs_1.default.statSync(keyPath).mode & 0o777;
33
+ (0, chai_1.expect)(mode).to.equal(0o600);
34
+ });
35
+ it('reloads the same key on second init (stable kid)', () => __awaiter(void 0, void 0, void 0, function* () {
36
+ const kid1 = svc.jwks().keys[0].kid;
37
+ const svc2 = new JwtKeyService_1.JwtKeyService();
38
+ yield svc2.init(dir);
39
+ (0, chai_1.expect)(svc2.jwks().keys[0].kid).to.equal(kid1);
40
+ }));
41
+ it('signs and verifies a token round-trip with audience check', () => __awaiter(void 0, void 0, void 0, function* () {
42
+ const token = yield svc.sign({ sub: 'user-1', scopes: 'devices,read' }, { audience: 'xenon-rest', ttlSeconds: 60 });
43
+ const payload = yield svc.verify(token, { audience: 'xenon-rest' });
44
+ (0, chai_1.expect)(payload.sub).to.equal('user-1');
45
+ (0, chai_1.expect)(payload.scopes).to.equal('devices,read');
46
+ }));
47
+ it('rejects wrong audience', () => __awaiter(void 0, void 0, void 0, function* () {
48
+ const token = yield svc.sign({ sub: 'u' }, { audience: 'xenon-mcp', ttlSeconds: 60 });
49
+ try {
50
+ yield svc.verify(token, { audience: 'xenon-rest' });
51
+ chai_1.expect.fail('should have thrown');
52
+ }
53
+ catch (e) {
54
+ (0, chai_1.expect)(String(e.message)).to.match(/aud/i);
55
+ }
56
+ }));
57
+ it('rejects tokens expired beyond the 60s clock tolerance', () => __awaiter(void 0, void 0, void 0, function* () {
58
+ var _a;
59
+ const token = yield svc.sign({ sub: 'u' }, { audience: 'xenon-rest', ttlSeconds: -120 });
60
+ try {
61
+ yield svc.verify(token, { audience: 'xenon-rest' });
62
+ chai_1.expect.fail('should have thrown');
63
+ }
64
+ catch (e) {
65
+ (0, chai_1.expect)(String((_a = e.code) !== null && _a !== void 0 ? _a : e.message)).to.match(/expired|ERR_JWT_EXPIRED/i);
66
+ }
67
+ }));
68
+ it('degrades gracefully when uninitialized: sign() rejects, jwks() is empty', () => __awaiter(void 0, void 0, void 0, function* () {
69
+ const fresh = new JwtKeyService_1.JwtKeyService(); // init() never called
70
+ try {
71
+ yield fresh.sign({ sub: 'u' }, { audience: 'xenon-rest', ttlSeconds: 60 });
72
+ chai_1.expect.fail('should have thrown');
73
+ }
74
+ catch (e) {
75
+ (0, chai_1.expect)(String(e.message)).to.match(/not initialized/);
76
+ }
77
+ (0, chai_1.expect)(fresh.jwks()).to.deep.equal({ keys: [] });
78
+ }));
79
+ it('exposes a JWKS with kid, use=sig, alg=RS256 and no private material', () => {
80
+ const jwk = svc.jwks().keys[0];
81
+ (0, chai_1.expect)(jwk.kid).to.be.a('string');
82
+ (0, chai_1.expect)(jwk.use).to.equal('sig');
83
+ (0, chai_1.expect)(jwk.alg).to.equal('RS256');
84
+ (0, chai_1.expect)(jwk.d).to.equal(undefined); // private exponent must never appear
85
+ });
86
+ });
@@ -0,0 +1,45 @@
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
+ const chai_1 = require("chai");
16
+ const sinon_1 = __importDefault(require("sinon"));
17
+ const projects_1 = require("../../src/app/routers/projects");
18
+ describe('projects handlers', () => {
19
+ function db() {
20
+ return { project: { findMany: sinon_1.default.stub().resolves([{ id: 'p1', name: 'App A', teamId: 't1' }]), create: sinon_1.default.stub().resolves({ id: 'p2', name: 'New', teamId: null }) } };
21
+ }
22
+ it('lists projects scoped to the caller teamIds when narrowed', () => __awaiter(void 0, void 0, void 0, function* () {
23
+ const client = db();
24
+ const out = yield (0, projects_1.listProjects)(client, ['t1']);
25
+ (0, chai_1.expect)(out).to.have.length(1);
26
+ (0, chai_1.expect)(client.project.findMany.firstCall.args[0].where).to.deep.equal({ OR: [{ teamId: { in: ['t1'] } }, { teamId: null }] });
27
+ }));
28
+ it('lists all projects for unscoped (admin) callers', () => __awaiter(void 0, void 0, void 0, function* () {
29
+ const client = db();
30
+ yield (0, projects_1.listProjects)(client, undefined);
31
+ (0, chai_1.expect)(client.project.findMany.firstCall.args[0]).to.deep.equal({});
32
+ }));
33
+ it('creates a project with a required name', () => __awaiter(void 0, void 0, void 0, function* () {
34
+ const client = db();
35
+ const p = yield (0, projects_1.createProject)(client, { name: 'New' });
36
+ (0, chai_1.expect)(p.id).to.equal('p2');
37
+ try {
38
+ yield (0, projects_1.createProject)(client, { name: '' });
39
+ chai_1.expect.fail('should throw');
40
+ }
41
+ catch (e) {
42
+ (0, chai_1.expect)(e.message).to.match(/name/);
43
+ }
44
+ }));
45
+ });
@@ -0,0 +1,95 @@
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
+ require("reflect-metadata");
16
+ const chai_1 = require("chai");
17
+ const sinon_1 = __importDefault(require("sinon"));
18
+ const fs_1 = __importDefault(require("fs"));
19
+ const os_1 = __importDefault(require("os"));
20
+ const path_1 = __importDefault(require("path"));
21
+ const typedi_1 = require("typedi");
22
+ const JwtKeyService_1 = require("../../src/services/token/JwtKeyService");
23
+ const StreamTicketService_1 = require("../../src/services/token/StreamTicketService");
24
+ describe('StreamTicketService', () => {
25
+ let dir;
26
+ let svc;
27
+ beforeEach(() => __awaiter(void 0, void 0, void 0, function* () {
28
+ dir = fs_1.default.mkdtempSync(path_1.default.join(os_1.default.tmpdir(), 'xenon-ticket-'));
29
+ const keys = new JwtKeyService_1.JwtKeyService();
30
+ yield keys.init(dir);
31
+ typedi_1.Container.set(JwtKeyService_1.JwtKeyService, keys);
32
+ svc = new StreamTicketService_1.StreamTicketService();
33
+ }));
34
+ afterEach(() => {
35
+ typedi_1.Container.reset();
36
+ fs_1.default.rmSync(dir, { recursive: true, force: true });
37
+ });
38
+ it('mints and redeems a ticket once for the bound udid', () => __awaiter(void 0, void 0, void 0, function* () {
39
+ const t = yield svc.mint('UDID-1', 'actor-1');
40
+ const out = yield svc.redeem(t, 'UDID-1');
41
+ (0, chai_1.expect)(out.actorId).to.equal('actor-1');
42
+ }));
43
+ it('rejects redemption for a different udid', () => __awaiter(void 0, void 0, void 0, function* () {
44
+ const t = yield svc.mint('UDID-1', 'actor-1');
45
+ try {
46
+ yield svc.redeem(t, 'UDID-2');
47
+ chai_1.expect.fail('should throw');
48
+ }
49
+ catch (e) {
50
+ (0, chai_1.expect)(e.message).to.match(/udid/);
51
+ }
52
+ }));
53
+ it('rejects second redemption (single-use)', () => __awaiter(void 0, void 0, void 0, function* () {
54
+ const t = yield svc.mint('UDID-1', 'actor-1');
55
+ yield svc.redeem(t, 'UDID-1');
56
+ try {
57
+ yield svc.redeem(t, 'UDID-1');
58
+ chai_1.expect.fail('should throw');
59
+ }
60
+ catch (e) {
61
+ (0, chai_1.expect)(e.message).to.match(/used/);
62
+ }
63
+ }));
64
+ // Regression for the replay window: JwtKeyService.verify honors
65
+ // clockTolerance:60, so a 60s ticket stays *verifiable* until mint+120s.
66
+ // If the anti-replay entry evicts at redeem-time+TTL (mint+60s) instead of
67
+ // token-exp+tolerance (mint+120s), a replay at ~mint+90s slips through:
68
+ // verify() still accepts it, the prune loop has already dropped the jti, and
69
+ // `used.has(jti)` is false → REPLAY ACCEPTED. This test advances the clock
70
+ // ~90s (past TTL, still within exp+tolerance) and asserts single-use holds.
71
+ // It fails under redeem-time+TTL eviction and passes under exp+tolerance.
72
+ it('rejects a replay after TTL but within verify clock tolerance (advanced clock)', () => __awaiter(void 0, void 0, void 0, function* () {
73
+ const clock = sinon_1.default.useFakeTimers({ now: 0, toFake: ['Date'] });
74
+ let err;
75
+ try {
76
+ const t = yield svc.mint('UDID-1', 'actor-1');
77
+ yield svc.redeem(t, 'UDID-1'); // legitimate first use at ~t=0
78
+ clock.tick(90000); // t≈90s: past 60s TTL, still < mint+120s verify window
79
+ try {
80
+ yield svc.redeem(t, 'UDID-1');
81
+ }
82
+ catch (e) {
83
+ err = e;
84
+ }
85
+ }
86
+ finally {
87
+ clock.restore();
88
+ }
89
+ // Assert OUTSIDE the try so a wrongly-accepted replay (err === undefined)
90
+ // fails loudly instead of being swallowed. Note: the failure message here
91
+ // must NOT contain the word matched below, or a vacuous pass sneaks back in.
92
+ (0, chai_1.expect)(err, 'replay was accepted — single-use bypassed').to.be.an('error');
93
+ (0, chai_1.expect)(String(err && err.message)).to.match(/already used/);
94
+ }));
95
+ });
@@ -0,0 +1,68 @@
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
+ require("reflect-metadata");
16
+ const chai_1 = require("chai");
17
+ const typedi_1 = require("typedi");
18
+ const fs_1 = __importDefault(require("fs"));
19
+ const os_1 = __importDefault(require("os"));
20
+ const path_1 = __importDefault(require("path"));
21
+ const JwtKeyService_1 = require("../../src/services/token/JwtKeyService");
22
+ const auth_1 = require("../../src/app/routers/auth");
23
+ describe('POST /auth/token handler (issueToken)', () => {
24
+ let dir;
25
+ beforeEach(() => __awaiter(void 0, void 0, void 0, function* () {
26
+ dir = fs_1.default.mkdtempSync(path_1.default.join(os_1.default.tmpdir(), 'xenon-tok-'));
27
+ const svc = new JwtKeyService_1.JwtKeyService();
28
+ yield svc.init(dir);
29
+ typedi_1.Container.set(JwtKeyService_1.JwtKeyService, svc);
30
+ }));
31
+ afterEach(() => {
32
+ fs_1.default.rmSync(dir, { recursive: true, force: true });
33
+ typedi_1.Container.reset();
34
+ });
35
+ const auth = {
36
+ kind: 'api-key',
37
+ userId: 'u1',
38
+ role: 'MEMBER',
39
+ scopes: 'devices,read',
40
+ teamId: 't1',
41
+ rateLimit: 300,
42
+ };
43
+ it('mints a xenon-rest token by default with 3600s TTL', () => __awaiter(void 0, void 0, void 0, function* () {
44
+ const out = yield (0, auth_1.issueToken)(auth, {});
45
+ (0, chai_1.expect)(out.audience).to.equal('xenon-rest');
46
+ (0, chai_1.expect)(out.expiresIn).to.equal(3600);
47
+ const payload = yield typedi_1.Container.get(JwtKeyService_1.JwtKeyService).verify(out.token, {
48
+ audience: 'xenon-rest',
49
+ });
50
+ (0, chai_1.expect)(payload.sub).to.equal('u1');
51
+ (0, chai_1.expect)(payload.scopes).to.equal('devices,read');
52
+ (0, chai_1.expect)(payload.teamId).to.equal('t1');
53
+ }));
54
+ it('mints a xenon-mcp token with 86400s TTL', () => __awaiter(void 0, void 0, void 0, function* () {
55
+ const out = yield (0, auth_1.issueToken)(auth, { audience: 'xenon-mcp' });
56
+ (0, chai_1.expect)(out.expiresIn).to.equal(86400);
57
+ yield typedi_1.Container.get(JwtKeyService_1.JwtKeyService).verify(out.token, { audience: 'xenon-mcp' });
58
+ }));
59
+ it('rejects unknown audiences', () => __awaiter(void 0, void 0, void 0, function* () {
60
+ try {
61
+ yield (0, auth_1.issueToken)(auth, { audience: 'xenon-stream' }); // tickets are not minted here
62
+ chai_1.expect.fail('should have thrown');
63
+ }
64
+ catch (e) {
65
+ (0, chai_1.expect)(e.message).to.match(/audience/);
66
+ }
67
+ }));
68
+ });
@@ -0,0 +1,102 @@
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
+ require("reflect-metadata");
16
+ const chai_1 = require("chai");
17
+ const sinon_1 = __importDefault(require("sinon"));
18
+ const typedi_1 = require("typedi");
19
+ const fs_1 = __importDefault(require("fs"));
20
+ const os_1 = __importDefault(require("os"));
21
+ const path_1 = __importDefault(require("path"));
22
+ const JwtKeyService_1 = require("../../src/services/token/JwtKeyService");
23
+ const authMiddleware_1 = require("../../src/middleware/authMiddleware");
24
+ const UserService_1 = require("../../src/services/UserService");
25
+ const ApiKeyService_1 = require("../../src/services/ApiKeyService");
26
+ const UserSessionService_1 = require("../../src/services/UserSessionService");
27
+ const prisma_1 = require("../../src/prisma");
28
+ function fakeRes() {
29
+ const res = { statusCode: 200 };
30
+ res.status = (c) => { res.statusCode = c; return res; };
31
+ res.json = (b) => { res.body = b; return res; };
32
+ res.cookie = () => res;
33
+ return res;
34
+ }
35
+ describe('authMiddleware — Bearer branch', () => {
36
+ let dir;
37
+ let keySvc;
38
+ const user = { id: 'u1', role: 'MEMBER', status: 'ACTIVE' };
39
+ beforeEach(() => __awaiter(void 0, void 0, void 0, function* () {
40
+ dir = fs_1.default.mkdtempSync(path_1.default.join(os_1.default.tmpdir(), 'xenon-bearer-'));
41
+ keySvc = new JwtKeyService_1.JwtKeyService();
42
+ yield keySvc.init(dir);
43
+ typedi_1.Container.set(JwtKeyService_1.JwtKeyService, keySvc);
44
+ typedi_1.Container.set(UserService_1.UserService, { findById: sinon_1.default.stub().resolves(user) });
45
+ typedi_1.Container.set(ApiKeyService_1.ApiKeyService, { verifyPair: sinon_1.default.stub().resolves(null), verify: sinon_1.default.stub().resolves(null) });
46
+ typedi_1.Container.set(UserSessionService_1.UserSessionService, { resolve: sinon_1.default.stub().resolves(null) });
47
+ sinon_1.default.stub(prisma_1.prisma.teamMember, 'findMany').resolves([{ teamId: 't1' }]);
48
+ }));
49
+ afterEach(() => {
50
+ sinon_1.default.restore();
51
+ typedi_1.Container.reset();
52
+ fs_1.default.rmSync(dir, { recursive: true, force: true });
53
+ });
54
+ it('accepts a valid xenon-rest Bearer token and sets req.auth', () => __awaiter(void 0, void 0, void 0, function* () {
55
+ const token = yield keySvc.sign({ sub: 'u1', role: 'MEMBER', scopes: 'devices,read', teamId: 't1' }, { audience: 'xenon-rest', ttlSeconds: 60 });
56
+ const req = { headers: { authorization: `Bearer ${token}` }, query: {} };
57
+ const res = fakeRes();
58
+ const next = sinon_1.default.spy();
59
+ yield (0, authMiddleware_1.authMiddleware)(req, res, next);
60
+ (0, chai_1.expect)(next.calledOnce).to.equal(true);
61
+ (0, chai_1.expect)(req.auth.kind).to.equal('bearer');
62
+ (0, chai_1.expect)(req.auth.userId).to.equal('u1');
63
+ (0, chai_1.expect)(req.auth.scopes).to.equal('devices,read');
64
+ }));
65
+ it('rejects an xenon-mcp-audience token on the REST surface', () => __awaiter(void 0, void 0, void 0, function* () {
66
+ const token = yield keySvc.sign({ sub: 'u1' }, { audience: 'xenon-mcp', ttlSeconds: 60 });
67
+ const req = { headers: { authorization: `Bearer ${token}` }, query: {} };
68
+ const res = fakeRes();
69
+ yield (0, authMiddleware_1.authMiddleware)(req, res, sinon_1.default.spy());
70
+ (0, chai_1.expect)(res.statusCode).to.equal(401);
71
+ }));
72
+ it('rejects a token for a disabled user (live revocation)', () => __awaiter(void 0, void 0, void 0, function* () {
73
+ typedi_1.Container.get(UserService_1.UserService).findById.resolves(Object.assign(Object.assign({}, user), { status: 'DISABLED' }));
74
+ const token = yield keySvc.sign({ sub: 'u1' }, { audience: 'xenon-rest', ttlSeconds: 60 });
75
+ const req = { headers: { authorization: `Bearer ${token}` }, query: {} };
76
+ const res = fakeRes();
77
+ yield (0, authMiddleware_1.authMiddleware)(req, res, sinon_1.default.spy());
78
+ (0, chai_1.expect)(res.statusCode).to.equal(401);
79
+ }));
80
+ // ---- regression pins: old inputs behave exactly as before ----
81
+ it('REGRESSION: no credentials → 401 unauthenticated (unchanged)', () => __awaiter(void 0, void 0, void 0, function* () {
82
+ const req = { headers: {}, query: {} };
83
+ const res = fakeRes();
84
+ yield (0, authMiddleware_1.authMiddleware)(req, res, sinon_1.default.spy());
85
+ (0, chai_1.expect)(res.statusCode).to.equal(401);
86
+ (0, chai_1.expect)(res.body).to.deep.equal({ error: 'unauthenticated' });
87
+ }));
88
+ it('REGRESSION: header pair still takes priority over a Bearer header', () => __awaiter(void 0, void 0, void 0, function* () {
89
+ const row = { id: 'k1', userId: 'u1', scopes: 'admin', rateLimit: 100, teamId: null };
90
+ typedi_1.Container.get(ApiKeyService_1.ApiKeyService).verifyPair.resolves(row);
91
+ const token = yield keySvc.sign({ sub: 'other' }, { audience: 'xenon-rest', ttlSeconds: 60 });
92
+ const req = {
93
+ headers: { 'x-xenon-access-key': 'ak', 'x-xenon-token': 'tk', authorization: `Bearer ${token}` },
94
+ query: {},
95
+ };
96
+ const res = fakeRes();
97
+ const next = sinon_1.default.spy();
98
+ yield (0, authMiddleware_1.authMiddleware)(req, res, next);
99
+ (0, chai_1.expect)(next.calledOnce).to.equal(true);
100
+ (0, chai_1.expect)(req.auth.kind).to.equal('api-key'); // pair wins, Bearer never evaluated
101
+ }));
102
+ });