@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.
- package/package.json +3 -3
- package/src/auth/workspace-access.js +176 -0
- package/src/console-direct.js +469 -11
- package/src/console-direct.test.mjs +446 -5
- package/src/http/hub-ui-session.js +155 -32
- package/src/server.js +1141 -328
|
@@ -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
|
-
|
|
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
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
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
|
|
55
|
-
const normalizedUserId = String(userId || '').trim();
|
|
56
|
-
if (!normalizedUserId) throw new Error('hub-ui-session-user-required');
|
|
57
|
-
|
|
58
|
-
const
|
|
59
|
-
const
|
|
60
|
-
const
|
|
61
|
-
|
|
62
|
-
|
|
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
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
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 {
|
|
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
|
-
|
|
91
|
-
|
|
92
|
-
|
|
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
|
}
|