@livedesk/hub 0.1.58 → 0.1.61

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.
@@ -34,33 +34,97 @@ function randomToken() {
34
34
  return crypto.randomBytes(32).toString('base64url');
35
35
  }
36
36
 
37
- export function createHubUiSessionAuthority({
38
- ttlMs = HUB_UI_SESSION_TTL_MS,
39
- maxSessions = 32,
40
- now = () => Date.now()
41
- } = {}) {
42
- const sessions = new Map();
37
+ export function createHubUiSessionAuthority({
38
+ ttlMs = HUB_UI_SESSION_TTL_MS,
39
+ maxSessions = 32,
40
+ now = () => Date.now(),
41
+ onRevoke = () => {}
42
+ } = {}) {
43
+ const sessions = new Map();
44
+
45
+ function dropSession(sessionId, reason) {
46
+ const session = sessions.get(sessionId);
47
+ if (!session) return null;
48
+ sessions.delete(sessionId);
49
+ try {
50
+ onRevoke({ sessionId, ...session }, String(reason || 'hub-ui-session-revoked'));
51
+ } catch {
52
+ // Revocation remains authoritative even if a best-effort socket cleanup
53
+ // listener fails. The next protected request still fails closed.
54
+ }
55
+ return session;
56
+ }
43
57
 
44
58
  function purgeExpired() {
45
- const current = now();
46
- for (const [sessionId, session] of sessions) {
47
- if (session.expiresAt <= current) sessions.delete(sessionId);
48
- }
49
- while (sessions.size >= maxSessions) {
50
- sessions.delete(sessions.keys().next().value);
59
+ const current = now();
60
+ for (const [sessionId, session] of sessions) {
61
+ if (session.expiresAt <= current
62
+ || (session.accessExpiresAt > 0 && session.accessExpiresAt <= current)) {
63
+ dropSession(sessionId, 'hub-ui-session-expired');
64
+ }
65
+ }
66
+ while (sessions.size >= maxSessions) {
67
+ dropSession(sessions.keys().next().value, 'hub-ui-session-capacity-evicted');
51
68
  }
52
69
  }
53
70
 
54
- function issue(userId) {
55
- const normalizedUserId = String(userId || '').trim();
56
- if (!normalizedUserId) throw new Error('hub-ui-session-user-required');
57
- purgeExpired();
58
- const sessionId = randomToken();
59
- const csrfToken = randomToken();
60
- const expiresAt = now() + ttlMs;
61
- sessions.set(sessionId, { userId: normalizedUserId, csrfToken, expiresAt });
62
- return { sessionId, csrfToken, expiresAt };
63
- }
71
+ function normalizedSessionAccess(userId, access = {}) {
72
+ const normalizedUserId = String(userId || '').trim();
73
+ if (!normalizedUserId) throw new Error('hub-ui-session-user-required');
74
+ const workspaceId = String(access.workspaceId || '').trim();
75
+ const workspaceKind = String(access.workspaceKind || 'personal').trim().toLowerCase();
76
+ const role = String(access.role || 'owner').trim().toLowerCase();
77
+ const entitlementStatus = String(access.entitlementStatus || 'active').trim().toLowerCase();
78
+ const membershipRevision = Math.max(0, Number(access.membershipRevision || 0));
79
+ const accessExpiresAt = Math.max(0, Number(access.accessExpiresAt || 0));
80
+ if (workspaceId && !['owner', 'operator'].includes(role)) {
81
+ throw new Error('hub-ui-session-workspace-role-invalid');
82
+ }
83
+ return {
84
+ userId: normalizedUserId,
85
+ workspaceId,
86
+ workspaceKind,
87
+ role,
88
+ plan: String(access.plan || 'free').trim().toLowerCase(),
89
+ entitlementStatus,
90
+ membershipRevision,
91
+ accessExpiresAt
92
+ };
93
+ }
94
+
95
+ function issue(userId, access = {}) {
96
+ const normalized = normalizedSessionAccess(userId, access);
97
+ purgeExpired();
98
+ const sessionId = randomToken();
99
+ const csrfToken = randomToken();
100
+ const expiresAt = now() + ttlMs;
101
+ sessions.set(sessionId, {
102
+ ...normalized,
103
+ csrfToken,
104
+ expiresAt
105
+ });
106
+ return { sessionId, csrfToken, expiresAt };
107
+ }
108
+
109
+ function renew(req, userId, access = {}) {
110
+ purgeExpired();
111
+ const sessionId = parseCookies(req?.headers?.cookie).get(HUB_UI_SESSION_COOKIE) || '';
112
+ const session = sessions.get(sessionId);
113
+ const normalized = normalizedSessionAccess(userId, access);
114
+ if (!session
115
+ || session.userId !== normalized.userId
116
+ || session.workspaceId !== normalized.workspaceId
117
+ || session.role !== normalized.role) {
118
+ return issue(userId, access);
119
+ }
120
+ const expiresAt = now() + ttlMs;
121
+ sessions.set(sessionId, {
122
+ ...session,
123
+ ...normalized,
124
+ expiresAt
125
+ });
126
+ return { sessionId, csrfToken: session.csrfToken, expiresAt, renewed: true };
127
+ }
64
128
 
65
129
  function authorize(req, currentUserId, {
66
130
  requireCsrf = !SAFE_HTTP_METHODS.has(String(req.method || 'GET').toUpperCase())
@@ -74,23 +138,82 @@ export function createHubUiSessionAuthority({
74
138
  if (!session) {
75
139
  return { ok: false, status: 401, error: 'hub-ui-session-expired' };
76
140
  }
77
- const normalizedUserId = String(currentUserId || '').trim();
78
- if (!normalizedUserId || session.userId !== normalizedUserId) {
79
- sessions.delete(sessionId);
80
- return { ok: false, status: 403, error: 'hub-ui-session-account-mismatch' };
141
+ const expected = currentUserId && typeof currentUserId === 'object'
142
+ ? currentUserId
143
+ : { userId: currentUserId };
144
+ const normalizedUserId = String(expected.userId || '').trim();
145
+ const expectedWorkspaceId = String(expected.workspaceId || '').trim();
146
+ const allowedRoles = new Set(Array.isArray(expected.allowedRoles)
147
+ ? expected.allowedRoles.map(role => String(role || '').trim().toLowerCase())
148
+ : ['owner', 'operator']);
149
+ const identityMismatch = expectedWorkspaceId
150
+ ? session.workspaceId !== expectedWorkspaceId || !allowedRoles.has(session.role)
151
+ : !normalizedUserId || session.userId !== normalizedUserId;
152
+ if (identityMismatch || (session.accessExpiresAt > 0 && session.accessExpiresAt <= now())) {
153
+ dropSession(sessionId, 'hub-ui-session-account-mismatch');
154
+ return { ok: false, status: 403, error: 'hub-ui-session-account-mismatch' };
81
155
  }
82
156
  if (requireCsrf && !secureEqual(req.headers?.['x-livedesk-csrf'], session.csrfToken)) {
83
157
  return { ok: false, status: 403, error: 'hub-ui-csrf-invalid' };
84
158
  }
85
- return { ok: true, sessionId, userId: session.userId, expiresAt: session.expiresAt };
159
+ return {
160
+ ok: true,
161
+ sessionId,
162
+ userId: session.userId,
163
+ workspaceId: session.workspaceId,
164
+ workspaceKind: session.workspaceKind,
165
+ role: session.role,
166
+ plan: session.plan,
167
+ entitlementStatus: session.entitlementStatus,
168
+ membershipRevision: session.membershipRevision,
169
+ accessExpiresAt: session.accessExpiresAt,
170
+ expiresAt: session.expiresAt
171
+ };
86
172
  }
87
173
 
88
174
  return {
89
- issue,
90
- authorize,
91
- revokeAll() {
92
- sessions.clear();
93
- },
175
+ issue,
176
+ renew,
177
+ authorize,
178
+ sessionDeadline(sessionId) {
179
+ purgeExpired();
180
+ const session = sessions.get(String(sessionId || ''));
181
+ if (!session) return 0;
182
+ return session.accessExpiresAt > 0
183
+ ? Math.min(session.expiresAt, session.accessExpiresAt)
184
+ : session.expiresAt;
185
+ },
186
+ revokeAll(reason = 'hub-ui-session-revoked-all') {
187
+ let revoked = 0;
188
+ for (const sessionId of [...sessions.keys()]) {
189
+ if (dropSession(sessionId, reason)) revoked += 1;
190
+ }
191
+ return revoked;
192
+ },
193
+ revokeWorkspaceMember(workspaceId, userId) {
194
+ const normalizedWorkspaceId = String(workspaceId || '').trim();
195
+ const normalizedUserId = String(userId || '').trim();
196
+ let revoked = 0;
197
+ for (const [sessionId, session] of sessions) {
198
+ if (session.workspaceId !== normalizedWorkspaceId || session.userId !== normalizedUserId) continue;
199
+ if (dropSession(sessionId, 'workspace-member-revoked')) revoked += 1;
200
+ }
201
+ return revoked;
202
+ },
203
+ revoke(req) {
204
+ const sessionId = parseCookies(req?.headers?.cookie).get(HUB_UI_SESSION_COOKIE) || '';
205
+ if (!sessionId) return null;
206
+ const session = dropSession(sessionId, 'hub-ui-session-signed-out');
207
+ return session ? {
208
+ sessionId,
209
+ userId: session.userId,
210
+ workspaceId: session.workspaceId,
211
+ workspaceKind: session.workspaceKind,
212
+ role: session.role,
213
+ plan: session.plan,
214
+ entitlementStatus: session.entitlementStatus
215
+ } : null;
216
+ },
94
217
  sessionCount: () => sessions.size
95
218
  };
96
219
  }
@@ -10,7 +10,7 @@ export function parseExactLiveStreamMonitorIndex(value) {
10
10
  : null;
11
11
  }
12
12
 
13
- export function isReusedLiveStreamFrameReady(result, activeStream, expectedBinding) {
13
+ export function isReusedLiveStreamFrameReady(result, activeStream, expectedBinding) {
14
14
  const expectedMonitorIndex = parseExactLiveStreamMonitorIndex(expectedBinding?.monitorIndex);
15
15
  const activeMonitorIndex = parseExactLiveStreamMonitorIndex(activeStream?.monitorIndex);
16
16
  const latestFrame = activeStream?.latestFrame;
@@ -34,5 +34,197 @@ export function isReusedLiveStreamFrameReady(result, activeStream, expectedBindi
34
34
  && Number(latestFrame?.captureGeneration || 0) === expectedCaptureGeneration
35
35
  && expectedMonitorIndex !== null
36
36
  && activeMonitorIndex === expectedMonitorIndex
37
- && frameMonitorIndex === expectedMonitorIndex;
38
- }
37
+ && frameMonitorIndex === expectedMonitorIndex;
38
+ }
39
+
40
+ export const READ_ONLY_CONTROL_PRESENTATION_MAX_IDLE_MS = 3_000;
41
+ export const READ_ONLY_CONTROL_STOP_RECONCILE_GRACE_MS = 250;
42
+
43
+ function normalizedPurpose(value) {
44
+ return String(value || '').trim().toLowerCase();
45
+ }
46
+
47
+ function positiveInteger(value) {
48
+ const parsed = Number(value);
49
+ return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : 0;
50
+ }
51
+
52
+ /**
53
+ * Own exactly one short stop-reconcile timer per device. A replacement Control
54
+ * transition cancels that timer before a PWA Wall can claim the released
55
+ * native capture; its Ready event then rebinds observers immediately.
56
+ */
57
+ export function createReadOnlyControlPresentationReconcileCoordinator({
58
+ onReconcile,
59
+ graceMs = READ_ONLY_CONTROL_STOP_RECONCILE_GRACE_MS,
60
+ setTimeoutFn = setTimeout,
61
+ clearTimeoutFn = clearTimeout
62
+ } = {}) {
63
+ if (typeof onReconcile !== 'function') {
64
+ throw new TypeError('onReconcile must be a function');
65
+ }
66
+ if (typeof setTimeoutFn !== 'function' || typeof clearTimeoutFn !== 'function') {
67
+ throw new TypeError('timer functions must be callable');
68
+ }
69
+ const normalizedGraceMs = Number(graceMs);
70
+ if (!Number.isFinite(normalizedGraceMs)
71
+ || normalizedGraceMs < 1
72
+ || normalizedGraceMs > 5_000) {
73
+ throw new RangeError('graceMs must be between 1 and 5000 milliseconds');
74
+ }
75
+
76
+ const pendingByDeviceId = new Map();
77
+ let closed = false;
78
+
79
+ function normalizeDeviceId(deviceId) {
80
+ return String(deviceId || '').trim();
81
+ }
82
+
83
+ function cancelForControlTransition(deviceId) {
84
+ const normalizedDeviceId = normalizeDeviceId(deviceId);
85
+ const owner = normalizedDeviceId ? pendingByDeviceId.get(normalizedDeviceId) : null;
86
+ if (!owner) return false;
87
+ pendingByDeviceId.delete(normalizedDeviceId);
88
+ clearTimeoutFn(owner.timer);
89
+ return true;
90
+ }
91
+
92
+ function scheduleAfterConfirmedStop(deviceId) {
93
+ const normalizedDeviceId = normalizeDeviceId(deviceId);
94
+ if (closed || !normalizedDeviceId || pendingByDeviceId.has(normalizedDeviceId)) {
95
+ return false;
96
+ }
97
+ const owner = { timer: null };
98
+ pendingByDeviceId.set(normalizedDeviceId, owner);
99
+ try {
100
+ owner.timer = setTimeoutFn(() => {
101
+ if (closed || pendingByDeviceId.get(normalizedDeviceId) !== owner) {
102
+ return;
103
+ }
104
+ pendingByDeviceId.delete(normalizedDeviceId);
105
+ onReconcile(normalizedDeviceId);
106
+ }, normalizedGraceMs);
107
+ owner.timer?.unref?.();
108
+ } catch (error) {
109
+ if (pendingByDeviceId.get(normalizedDeviceId) === owner) {
110
+ pendingByDeviceId.delete(normalizedDeviceId);
111
+ }
112
+ throw error;
113
+ }
114
+ return true;
115
+ }
116
+
117
+ function reconcileReadyControl(deviceId) {
118
+ const normalizedDeviceId = normalizeDeviceId(deviceId);
119
+ if (closed || !normalizedDeviceId) return false;
120
+ cancelForControlTransition(normalizedDeviceId);
121
+ onReconcile(normalizedDeviceId);
122
+ return true;
123
+ }
124
+
125
+ function close() {
126
+ if (closed) return;
127
+ closed = true;
128
+ for (const owner of pendingByDeviceId.values()) {
129
+ clearTimeoutFn(owner.timer);
130
+ }
131
+ pendingByDeviceId.clear();
132
+ }
133
+
134
+ return {
135
+ scheduleAfterConfirmedStop,
136
+ cancelForControlTransition,
137
+ reconcileReadyControl,
138
+ close,
139
+ getPendingCount: () => pendingByDeviceId.size
140
+ };
141
+ }
142
+
143
+ /**
144
+ * Bind a Wall presentation observer to an already healthy Control encoder.
145
+ * This returns metadata only: it never starts, stops, or replaces native work.
146
+ */
147
+ export function resolveReadOnlyControlPresentationBorrow({
148
+ device,
149
+ liveOptions,
150
+ monitorIndex,
151
+ nowEpochMs = Date.now()
152
+ } = {}) {
153
+ if (liveOptions?.allowReadOnlyControlBorrow !== true
154
+ || normalizedPurpose(liveOptions?.streamPurpose) !== 'wall') {
155
+ return null;
156
+ }
157
+
158
+ const active = device?.activeLiveStream;
159
+ const latest = active?.latestFrame;
160
+ const sessionId = String(device?.sessionId || '').trim();
161
+ const streamId = String(active?.streamId || '').trim();
162
+ const commandId = String(active?.commandId || '').trim();
163
+ const captureGeneration = positiveInteger(active?.captureGeneration);
164
+ const activeMonitorIndex = parseExactLiveStreamMonitorIndex(active?.monitorIndex);
165
+ const requestedMonitorIndex = parseExactLiveStreamMonitorIndex(monitorIndex);
166
+ const frameMode = String(active?.frameMode || active?.mode || '').trim().toLowerCase();
167
+ const requestedFrameMode = String(liveOptions?.frameMode || liveOptions?.mode || '').trim().toLowerCase();
168
+ const lastFrameAtEpochMs = Date.parse(String(active?.lastFrameAt || ''));
169
+ const currentEpochMs = Number(nowEpochMs);
170
+ const frameIdleMs = Number.isFinite(lastFrameAtEpochMs) && Number.isFinite(currentEpochMs)
171
+ ? Math.max(0, currentEpochMs - lastFrameAtEpochMs)
172
+ : Number.POSITIVE_INFINITY;
173
+ const latestMonitorIndex = parseExactLiveStreamMonitorIndex(latest?.monitorIndex);
174
+
175
+ if (active?.active !== true
176
+ || active?.open !== true
177
+ || active?.stopPending === true
178
+ || !!String(active?.stopCommandId || '').trim()
179
+ || !!String(active?.pendingDescriptor?.commandId || '').trim()
180
+ || normalizedPurpose(active?.streamPurpose) !== 'control'
181
+ || frameMode !== 'mode3-h264-hw'
182
+ || requestedFrameMode !== 'mode3-h264-hw'
183
+ || !sessionId
184
+ || !streamId
185
+ || !commandId
186
+ || captureGeneration <= 0
187
+ || requestedMonitorIndex === null
188
+ || activeMonitorIndex !== requestedMonitorIndex
189
+ || active?.readyFrameReceived !== true
190
+ || Number(active?.framesReceived || 0) <= 0
191
+ || frameIdleMs > READ_ONLY_CONTROL_PRESENTATION_MAX_IDLE_MS
192
+ || latest?.currentGenerationVerified !== true
193
+ || String(latest?.sessionId || '') !== sessionId
194
+ || String(latest?.streamId || '') !== streamId
195
+ || String(latest?.commandId || '') !== commandId
196
+ || Number(latest?.captureGeneration || 0) !== captureGeneration
197
+ || latestMonitorIndex !== activeMonitorIndex
198
+ || normalizedPurpose(latest?.streamPurpose) !== 'control') {
199
+ return null;
200
+ }
201
+
202
+ return {
203
+ ok: true,
204
+ commandId,
205
+ sessionId,
206
+ streamId,
207
+ streamPurpose: 'control',
208
+ fps: positiveInteger(active?.fps) || 1,
209
+ mode: String(active?.mode || active?.frameMode || 'mode3-h264-hw'),
210
+ frameMode: 'mode3-h264-hw',
211
+ monitorIndex: activeMonitorIndex,
212
+ captureGeneration,
213
+ ready: true,
214
+ reused: true,
215
+ readOnlyControlBorrow: true,
216
+ presentationPurpose: 'wall',
217
+ requestedProfile: {
218
+ fps: Number(liveOptions?.fps || 0),
219
+ maxWidth: Number(liveOptions?.maxWidth || 0),
220
+ maxHeight: Number(liveOptions?.maxHeight || 0),
221
+ quality: Number(liveOptions?.quality || 0)
222
+ },
223
+ effectiveProfile: {
224
+ fps: Number(active?.fps || 0),
225
+ maxWidth: Number(active?.maxWidth || 0),
226
+ maxHeight: Number(active?.maxHeight || 0),
227
+ quality: Number(active?.quality || 0)
228
+ }
229
+ };
230
+ }