@livedesk/hub 0.1.57 → 0.1.59

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
  }