@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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@livedesk/hub",
3
- "version": "0.1.58",
3
+ "version": "0.1.61",
4
4
  "description": "VuvoDesk local Hub API and browser frame bridge",
5
5
  "type": "module",
6
6
  "main": "src/server.js",
@@ -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
+ });