@livedesk/hub 0.1.58 → 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 +1 -1
- package/src/auth/workspace-access.js +176 -0
- package/src/console-direct.js +329 -10
- package/src/console-direct.test.mjs +332 -3
- package/src/http/hub-ui-session.js +155 -32
- package/src/server.js +1141 -328
package/package.json
CHANGED
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
const WORKSPACE_ROLES = new Set(['owner', 'operator']);
|
|
2
|
+
const WORKSPACE_KINDS = new Set(['personal', 'team']);
|
|
3
|
+
const WORKSPACE_PLANS = new Set(['free', 'ltd', 'pro', 'team']);
|
|
4
|
+
const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
|
5
|
+
|
|
6
|
+
function boundedText(value, maxLength = 160) {
|
|
7
|
+
return String(value ?? '').replace(/[\r\n\t]/g, ' ').trim().slice(0, maxLength);
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export class WorkspaceAccessError extends Error {
|
|
11
|
+
constructor(code, { status = 403, cause = null } = {}) {
|
|
12
|
+
super(String(code || 'workspace-access-denied'), cause ? { cause } : undefined);
|
|
13
|
+
this.name = 'WorkspaceAccessError';
|
|
14
|
+
this.code = String(code || 'workspace-access-denied');
|
|
15
|
+
this.status = Number(status) || 403;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function normalizeRequestedWorkspaceId(value) {
|
|
20
|
+
const workspaceId = boundedText(value);
|
|
21
|
+
if (!workspaceId) return '';
|
|
22
|
+
if (!UUID_PATTERN.test(workspaceId)) {
|
|
23
|
+
throw new WorkspaceAccessError('workspace-id-invalid', { status: 400 });
|
|
24
|
+
}
|
|
25
|
+
return workspaceId;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function normalizeWorkspaceAccess(value, { userId = '', verifiedAt = Date.now() } = {}) {
|
|
29
|
+
const source = Array.isArray(value) ? value[0] : value;
|
|
30
|
+
const workspaceId = boundedText(source?.workspace_id ?? source?.workspaceId);
|
|
31
|
+
const role = boundedText(source?.role, 32).toLowerCase();
|
|
32
|
+
const kind = boundedText(source?.workspace_kind ?? source?.workspaceKind ?? source?.kind, 32).toLowerCase();
|
|
33
|
+
const planValue = boundedText(source?.plan, 32).toLowerCase();
|
|
34
|
+
const reason = boundedText(source?.reason, 160);
|
|
35
|
+
const ok = source?.ok !== false
|
|
36
|
+
&& Boolean(workspaceId)
|
|
37
|
+
&& WORKSPACE_ROLES.has(role)
|
|
38
|
+
&& WORKSPACE_KINDS.has(kind);
|
|
39
|
+
if (!ok) {
|
|
40
|
+
throw new WorkspaceAccessError(reason || 'workspace-access-denied');
|
|
41
|
+
}
|
|
42
|
+
const normalizedUserId = boundedText(userId);
|
|
43
|
+
if (kind === 'personal' && normalizedUserId && workspaceId !== normalizedUserId) {
|
|
44
|
+
throw new WorkspaceAccessError('personal-workspace-owner-mismatch');
|
|
45
|
+
}
|
|
46
|
+
const membershipRevision = Number(source?.membership_revision ?? source?.membershipRevision ?? 0);
|
|
47
|
+
const deviceLimit = Number(source?.device_limit ?? source?.deviceLimit);
|
|
48
|
+
const memberLimit = Number(source?.member_limit ?? source?.memberLimit);
|
|
49
|
+
return Object.freeze({
|
|
50
|
+
ok: true,
|
|
51
|
+
userId: normalizedUserId,
|
|
52
|
+
workspaceId,
|
|
53
|
+
workspaceName: boundedText(source?.workspace_name ?? source?.workspaceName ?? source?.name, 120),
|
|
54
|
+
workspaceKind: kind,
|
|
55
|
+
role,
|
|
56
|
+
membershipRevision: Number.isSafeInteger(membershipRevision) && membershipRevision >= 0
|
|
57
|
+
? membershipRevision
|
|
58
|
+
: 0,
|
|
59
|
+
plan: WORKSPACE_PLANS.has(planValue) ? planValue : 'free',
|
|
60
|
+
entitlementStatus: boundedText(source?.entitlement_status ?? source?.entitlementStatus ?? source?.status, 32).toLowerCase() || 'inactive',
|
|
61
|
+
deviceLimit: Number.isSafeInteger(deviceLimit) && deviceLimit >= 0 ? deviceLimit : null,
|
|
62
|
+
memberLimit: Number.isSafeInteger(memberLimit) && memberLimit >= 0 ? memberLimit : 1,
|
|
63
|
+
commercialUse: source?.commercial_use === true || source?.commercialUse === true,
|
|
64
|
+
verifiedAt: Math.max(0, Number(verifiedAt) || Date.now())
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export async function resolveWorkspaceAccess({
|
|
69
|
+
accessToken,
|
|
70
|
+
requestedWorkspaceId = '',
|
|
71
|
+
userId = '',
|
|
72
|
+
supabaseUrl,
|
|
73
|
+
supabasePublishableKey,
|
|
74
|
+
fetchResponse,
|
|
75
|
+
timeoutMs
|
|
76
|
+
}) {
|
|
77
|
+
const token = boundedText(accessToken, 8192);
|
|
78
|
+
if (!token) throw new WorkspaceAccessError('supabase-access-token-required', { status: 401 });
|
|
79
|
+
const workspaceId = normalizeRequestedWorkspaceId(requestedWorkspaceId);
|
|
80
|
+
if (typeof fetchResponse !== 'function') {
|
|
81
|
+
throw new WorkspaceAccessError('workspace-access-verifier-unavailable', { status: 500 });
|
|
82
|
+
}
|
|
83
|
+
let response;
|
|
84
|
+
try {
|
|
85
|
+
response = await fetchResponse(
|
|
86
|
+
`${String(supabaseUrl || '').replace(/\/+$/, '')}/rest/v1/rpc/get_livedesk_workspace_access`,
|
|
87
|
+
{
|
|
88
|
+
method: 'POST',
|
|
89
|
+
headers: {
|
|
90
|
+
apikey: String(supabasePublishableKey || ''),
|
|
91
|
+
Authorization: `Bearer ${token}`,
|
|
92
|
+
'Content-Type': 'application/json',
|
|
93
|
+
Accept: 'application/json'
|
|
94
|
+
},
|
|
95
|
+
body: JSON.stringify({ p_workspace_id: workspaceId || null })
|
|
96
|
+
},
|
|
97
|
+
timeoutMs
|
|
98
|
+
);
|
|
99
|
+
} catch (cause) {
|
|
100
|
+
throw new WorkspaceAccessError('workspace-access-provider-unavailable', { status: 502, cause });
|
|
101
|
+
}
|
|
102
|
+
if (response.status === 401 || response.status === 403) {
|
|
103
|
+
throw new WorkspaceAccessError('workspace-access-not-authenticated', { status: 401 });
|
|
104
|
+
}
|
|
105
|
+
if (!response.ok) {
|
|
106
|
+
throw new WorkspaceAccessError(`workspace-access-query-failed:${response.status}`, { status: 502 });
|
|
107
|
+
}
|
|
108
|
+
const payload = await response.json().catch(() => null);
|
|
109
|
+
return normalizeWorkspaceAccess(payload, { userId });
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export function workspaceRoleCanControl(role) {
|
|
113
|
+
return WORKSPACE_ROLES.has(boundedText(role, 32).toLowerCase());
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export function operatorRequestAllowed(method, pathname) {
|
|
117
|
+
const normalizedMethod = boundedText(method, 16).toUpperCase() || 'GET';
|
|
118
|
+
const path = boundedText(pathname, 512).split('?')[0];
|
|
119
|
+
if (normalizedMethod === 'GET') {
|
|
120
|
+
return path === '/api/health'
|
|
121
|
+
|| path === '/api/runtime/status'
|
|
122
|
+
|| path === '/api/auth/status'
|
|
123
|
+
|| path === '/api/remote/status'
|
|
124
|
+
|| path === '/api/hub/status'
|
|
125
|
+
|| path === '/api/hub/devices'
|
|
126
|
+
|| path === '/api/remote/devices'
|
|
127
|
+
|| path === '/api/remote/license'
|
|
128
|
+
|| path === '/api/remote/wall-preferences'
|
|
129
|
+
|| /^\/api\/remote\/(?:frames|atlas|input|audio)\/ws$/.test(path)
|
|
130
|
+
|| /^\/api\/remote\/devices\/[^/]+(?:\/(?:thumbnail|live\/frame))?$/.test(path);
|
|
131
|
+
}
|
|
132
|
+
if (normalizedMethod === 'DELETE' && path === '/api/auth/session') return true;
|
|
133
|
+
if (normalizedMethod === 'PATCH' && path === '/api/remote/wall-preferences') return true;
|
|
134
|
+
if (normalizedMethod !== 'POST') return false;
|
|
135
|
+
return path === '/api/auth/session'
|
|
136
|
+
|| path === '/api/remote/frames'
|
|
137
|
+
|| /^\/api\/remote\/devices\/[^/]+\/(?:input|clipboard)$/.test(path)
|
|
138
|
+
|| /^\/api\/remote\/devices\/[^/]+\/thumbnail\/request$/.test(path)
|
|
139
|
+
|| /^\/api\/remote\/devices\/[^/]+\/live\/(?:start|stop|pause|resume)$/.test(path)
|
|
140
|
+
|| /^\/api\/remote\/devices\/[^/]+\/audio\/(?:start|stop)$/.test(path);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export function workspaceRoleCanRequest(role, method, pathname) {
|
|
144
|
+
const normalizedRole = boundedText(role, 32).toLowerCase();
|
|
145
|
+
return normalizedRole === 'owner'
|
|
146
|
+
|| (normalizedRole === 'operator' && operatorRequestAllowed(method, pathname));
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export function workspaceEntitlementCanRequest(access, method, pathname) {
|
|
150
|
+
const workspaceKind = boundedText(
|
|
151
|
+
access?.workspaceKind ?? access?.workspace_kind ?? access?.kind,
|
|
152
|
+
32
|
|
153
|
+
).toLowerCase();
|
|
154
|
+
const entitlementStatus = boundedText(
|
|
155
|
+
access?.entitlementStatus ?? access?.entitlement_status ?? access?.status,
|
|
156
|
+
32
|
|
157
|
+
).toLowerCase();
|
|
158
|
+
const plan = boundedText(access?.plan, 32).toLowerCase();
|
|
159
|
+
if (workspaceKind !== 'team' || (plan === 'team' && entitlementStatus === 'active')) return true;
|
|
160
|
+
const normalizedMethod = boundedText(method, 16).toUpperCase() || 'GET';
|
|
161
|
+
const path = boundedText(pathname, 512).split('?')[0];
|
|
162
|
+
return (normalizedMethod === 'GET' && (
|
|
163
|
+
path === '/api/health'
|
|
164
|
+
|| path === '/api/runtime/status'
|
|
165
|
+
|| path === '/api/auth/status'
|
|
166
|
+
|| path === '/api/remote/license'
|
|
167
|
+
))
|
|
168
|
+
|| (normalizedMethod === 'POST' && path === '/api/remote/license/sync')
|
|
169
|
+
|| (normalizedMethod === 'DELETE' && path === '/api/auth/session');
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
export const workspaceAccessContract = Object.freeze({
|
|
173
|
+
roles: Object.freeze([...WORKSPACE_ROLES]),
|
|
174
|
+
kinds: Object.freeze([...WORKSPACE_KINDS]),
|
|
175
|
+
plans: Object.freeze([...WORKSPACE_PLANS])
|
|
176
|
+
});
|