livedesk 0.1.631 → 0.1.633

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.
Files changed (43) hide show
  1. package/client/bin/livedesk-client.js +469 -116
  2. package/client/package.json +1 -1
  3. package/client/src/runtime/hub-wake-listener.js +316 -91
  4. package/electron/main.mjs +213 -103
  5. package/hub/package.json +3 -3
  6. package/hub/src/auth/workspace-access.js +176 -0
  7. package/hub/src/console-direct.js +469 -11
  8. package/hub/src/console-direct.test.mjs +446 -5
  9. package/hub/src/http/hub-ui-session.js +155 -32
  10. package/hub/src/server.js +1141 -328
  11. package/package.json +2 -2
  12. package/web/dist/app.html +7 -7
  13. package/web/dist/app.webmanifest +1 -1
  14. package/web/dist/assets/{AgentSettingsTab-VwiC7JUN.js → AgentSettingsTab-DyPu-H5o.js} +1 -1
  15. package/web/dist/assets/{AgentsPage-DhvGz3nK.js → AgentsPage-DY3Cg-DH.js} +1 -1
  16. package/web/dist/assets/App-DFLBNvIi.css +1 -0
  17. package/web/dist/assets/App-DwVqfExL.js +181 -0
  18. package/web/dist/assets/{CaptureGallery-aelE6Zdk.js → CaptureGallery-D9ArARgM.js} +1 -1
  19. package/web/dist/assets/{DesktopUpdatePanel-_K61p838.js → DesktopUpdatePanel-B3aUfd1G.js} +1 -1
  20. package/web/dist/assets/LiveDeskApp-DPIrXfaj.js +1 -0
  21. package/web/dist/assets/{SettingsPage-pt2aO7OB.js → SettingsPage-BcPHDfSH.js} +1 -1
  22. package/web/dist/assets/SettingsTabs-DFoU16NX.js +1 -0
  23. package/web/dist/assets/{ShareFilesPage-DHSxFlMx.js → ShareFilesPage-Cdu46CVM.js} +1 -1
  24. package/web/dist/assets/{SupportPage-D4TV6Vpp.js → SupportPage-C8rh3fLm.js} +2 -2
  25. package/web/dist/assets/app-Colp5UPd.js +1 -0
  26. package/web/dist/assets/icons-CwWL39Up.js +1 -0
  27. package/web/dist/assets/{main-BzVdJN3b.css → main-C1HTJiPX.css} +1 -1
  28. package/web/dist/assets/main-hfSMYb-x.js +2 -0
  29. package/web/dist/assets/{react-CzpJ4iNd.js → react-ITwfzPyo.js} +1 -1
  30. package/web/dist/assets/styles-Dft03_BF.js +16 -0
  31. package/web/dist/assets/styles-PqoeFg2_.css +1 -0
  32. package/web/dist/index.html +6 -6
  33. package/web/dist/livedesk-build-evidence.json +58 -58
  34. package/web/dist/sw.js +1 -1
  35. package/web/dist/assets/App-B6lW0dqi.js +0 -171
  36. package/web/dist/assets/App-CD73TEql.css +0 -1
  37. package/web/dist/assets/LiveDeskApp-DtvEkJkj.js +0 -1
  38. package/web/dist/assets/SettingsTabs-Crjk_9s3.js +0 -1
  39. package/web/dist/assets/app-Dz24Poiu.js +0 -1
  40. package/web/dist/assets/icons-C9gCrfh8.js +0 -1
  41. package/web/dist/assets/main-DvaMzUeB.js +0 -2
  42. package/web/dist/assets/styles-BNaoQWzp.css +0 -1
  43. package/web/dist/assets/styles-BQJexgrQ.js +0 -12
@@ -53,8 +53,9 @@ const EXIT_INVALID_PAIR_TOKEN = 23;
53
53
  const EXIT_CLIENT_UPDATE = 42;
54
54
  const SESSION_REFRESH_SKEW_SECONDS = 60;
55
55
  const SUPABASE_REQUEST_TIMEOUT_MS = 8_000;
56
- const HUB_TARGET_CACHE_MAX_AGE_MS = 30 * 24 * 60 * 60 * 1000;
57
- const HUB_TARGET_CACHE_FUTURE_SKEW_MS = 5 * 60 * 1000;
56
+ const HUB_TARGET_CACHE_MAX_AGE_MS = 30 * 24 * 60 * 60 * 1000;
57
+ const HUB_TARGET_CACHE_FUTURE_SKEW_MS = 5 * 60 * 1000;
58
+ const WORKSPACE_ID_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;
58
59
  const WINDOWS_STARTUP_SCRIPT_NAME = 'VuvoDesk Desktop.vbs';
59
60
  const WINDOWS_STARTUP_LEGACY_DESKTOP_SCRIPT_NAME = 'LiveDesk Desktop.vbs';
60
61
  const WINDOWS_STARTUP_LEGACY_SCRIPT_NAME = 'LiveDesk Client.vbs';
@@ -1006,10 +1007,23 @@ function writeClientUpdateRuntimeStage(stage, details = {}) {
1006
1007
  }
1007
1008
  }
1008
1009
 
1009
- function hubCacheOwnerForSession(session) {
1010
- const userId = String(session?.user?.id || '').trim();
1011
- return userId ? `user:${userId}` : '';
1012
- }
1010
+ function normalizeWorkspaceId(value) {
1011
+ const workspaceId = String(value || '').trim();
1012
+ return WORKSPACE_ID_PATTERN.test(workspaceId) ? workspaceId : '';
1013
+ }
1014
+
1015
+ export function hubCacheOwnerForSession(session, workspaceId = '') {
1016
+ const userId = String(session?.user?.id || '').trim();
1017
+ if (!userId) return '';
1018
+ const normalizedWorkspaceId = normalizeWorkspaceId(workspaceId);
1019
+ // Personal workspace ids intentionally equal auth.uid. Preserve the old
1020
+ // cache owner key so every existing Personal installation keeps its
1021
+ // verified Hub cache after this upgrade. Team caches carry the workspace
1022
+ // id so switching between Personal and Team can never reuse a pair token.
1023
+ return normalizedWorkspaceId && normalizedWorkspaceId !== userId
1024
+ ? `user:${userId}:workspace:${normalizedWorkspaceId}`
1025
+ : `user:${userId}`;
1026
+ }
1013
1027
 
1014
1028
  function hubCacheOwnerForPin(pin) {
1015
1029
  const normalizedPin = normalizePairingPin(pin);
@@ -1049,7 +1063,7 @@ export function normalizeCachedHubTarget(value, expectedOwnerKey, now = Date.now
1049
1063
  };
1050
1064
  }
1051
1065
 
1052
- export function readCachedHubTarget(ownerKey) {
1066
+ export function readCachedHubTarget(ownerKey) {
1053
1067
  const paths = [...new Set([preferredClientAuthPath(), CLIENT_AUTH_PATH, UNIFIED_CLIENT_AUTH_PATH])];
1054
1068
  for (const path of paths) {
1055
1069
  try {
@@ -1062,10 +1076,43 @@ export function readCachedHubTarget(ownerKey) {
1062
1076
  // Try the next compatible state location.
1063
1077
  }
1064
1078
  }
1065
- return null;
1066
- }
1067
-
1068
- export function writeCachedHubTarget(ownerKey, target) {
1079
+ return null;
1080
+ }
1081
+
1082
+ export function readCachedHubWorkspaceBinding(session, now = Date.now()) {
1083
+ const userId = normalizeWorkspaceId(session?.user?.id);
1084
+ if (!userId) return null;
1085
+ const personalOwnerKey = `user:${userId}`;
1086
+ const teamOwnerPrefix = `${personalOwnerKey}:workspace:`;
1087
+ const paths = [...new Set([preferredClientAuthPath(), CLIENT_AUTH_PATH, UNIFIED_CLIENT_AUTH_PATH])];
1088
+ for (const path of paths) {
1089
+ try {
1090
+ const state = JSON.parse(readFileSync(path, 'utf8'));
1091
+ const raw = state?.[CLIENT_HUB_TARGET_CACHE_STORAGE_KEY];
1092
+ const parsed = typeof raw === 'string' ? JSON.parse(raw) : raw;
1093
+ const ownerKey = String(parsed?.ownerKey || '').trim();
1094
+ let workspaceId = '';
1095
+ if (ownerKey === personalOwnerKey) {
1096
+ workspaceId = userId;
1097
+ } else if (ownerKey.startsWith(teamOwnerPrefix)) {
1098
+ workspaceId = normalizeWorkspaceId(ownerKey.slice(teamOwnerPrefix.length));
1099
+ }
1100
+ if (!workspaceId || !normalizeCachedHubTarget(parsed, ownerKey, now)) continue;
1101
+ return { ownerKey, workspaceId };
1102
+ } catch {
1103
+ // Try the next compatible state location.
1104
+ }
1105
+ }
1106
+ return null;
1107
+ }
1108
+
1109
+ export function hasClientWorkspaceCacheOwnerChanged(cachedBinding, session, freshWorkspaceId) {
1110
+ const previousOwnerKey = String(cachedBinding?.ownerKey || '').trim();
1111
+ if (!previousOwnerKey) return false;
1112
+ return hubCacheOwnerForSession(session, freshWorkspaceId) !== previousOwnerKey;
1113
+ }
1114
+
1115
+ export function writeCachedHubTarget(ownerKey, target) {
1069
1116
  const normalizedOwner = String(ownerKey || '').trim();
1070
1117
  const pair = String(target?.pair || '').trim();
1071
1118
  const endpointCandidates = normalizeEndpointCandidates({
@@ -4048,33 +4095,142 @@ function waitForDiscoveryTrigger(delayMs, wakePromise = null, shouldStop = () =>
4048
4095
  });
4049
4096
  }
4050
4097
 
4051
- function normalizeEndpointCandidates(target) {
4098
+ function normalizeEndpointCandidates(target) {
4052
4099
  const rawCandidates = [
4053
4100
  target?.endpoint,
4054
4101
  ...(Array.isArray(target?.endpoint_candidates) ? target.endpoint_candidates : [])
4055
4102
  ];
4056
- return [...new Set(rawCandidates.map(value => String(value || '').trim()).filter(Boolean))];
4057
- }
4058
-
4059
- export async function resolveManagerFromSupabase(supabase, options = {}) {
4060
- if (options.signal?.aborted) {
4061
- throw createHubDiscoveryError('hub-registry-aborted', 'VuvoDesk Hub registry lookup was cancelled.');
4062
- }
4063
- await refreshSessionIfNeeded(supabase);
4103
+ return [...new Set(rawCandidates.map(value => String(value || '').trim()).filter(Boolean))];
4104
+ }
4105
+
4106
+ function isLegacyWorkspaceSchemaError(error) {
4107
+ const code = String(error?.code || '').trim().toUpperCase();
4108
+ const message = String(error?.message || error || '');
4109
+ return code === '42703'
4110
+ || code === 'PGRST202'
4111
+ || /workspace_id.*does not exist|current_livedesk_owner_workspace.*not found/i.test(message);
4112
+ }
4113
+
4114
+ export async function resolveClientWorkspaceFromSupabase(supabase, options = {}) {
4115
+ const deviceId = String(options.deviceId || '').trim();
4116
+ const session = options.session?.access_token
4117
+ ? options.session
4118
+ : await refreshSessionIfNeeded(supabase);
4119
+ const userId = normalizeWorkspaceId(session?.user?.id);
4120
+ if (!userId) {
4121
+ throw createHubDiscoveryError(
4122
+ 'workspace-session-required',
4123
+ 'A signed-in VuvoDesk account is required to resolve this Client workspace.'
4124
+ );
4125
+ }
4126
+ if (!deviceId) {
4127
+ throw createHubDiscoveryError(
4128
+ 'workspace-device-id-required',
4129
+ 'The VuvoDesk Client device identity is required to resolve its workspace.'
4130
+ );
4131
+ }
4132
+
4133
+ let query = supabase
4134
+ .from('livedesk_devices')
4135
+ .select('workspace_id, device_id, enabled')
4136
+ .eq('account_id', userId)
4137
+ .eq('device_id', deviceId)
4138
+ .limit(2);
4139
+ if (options.signal && typeof query.abortSignal === 'function') {
4140
+ query = query.abortSignal(options.signal);
4141
+ }
4142
+ const { data, error } = await query;
4143
+ if (error) {
4144
+ // A pre-Team Supabase schema has only one possible Personal namespace,
4145
+ // whose id is auth.uid. This fallback preserves deployed Personal
4146
+ // Clients while never guessing after the workspace schema exists.
4147
+ if (isLegacyWorkspaceSchemaError(error)) return userId;
4148
+ throw error;
4149
+ }
4150
+
4151
+ const rows = Array.isArray(data) ? data : [];
4152
+ const workspaceIds = [...new Set(rows
4153
+ .map(row => normalizeWorkspaceId(row?.workspace_id))
4154
+ .filter(Boolean))];
4155
+ if (workspaceIds.length > 1) {
4156
+ throw createHubDiscoveryError(
4157
+ 'workspace-binding-conflict',
4158
+ 'This VuvoDesk Client device is bound to more than one workspace.'
4159
+ );
4160
+ }
4161
+ if (rows.length > 0 && workspaceIds.length !== 1) {
4162
+ throw createHubDiscoveryError(
4163
+ 'workspace-binding-invalid',
4164
+ 'This VuvoDesk Client device has an invalid workspace binding.'
4165
+ );
4166
+ }
4167
+ if (rows.some(row => row?.enabled === false)) {
4168
+ throw createHubDiscoveryError(
4169
+ 'workspace-device-disabled',
4170
+ 'This VuvoDesk Client device is disabled.'
4171
+ );
4172
+ }
4173
+ if (workspaceIds.length === 1) {
4174
+ return workspaceIds[0];
4175
+ }
4176
+
4177
+ const { data: currentWorkspace, error: currentWorkspaceError } = await supabase.rpc(
4178
+ 'current_livedesk_owner_workspace'
4179
+ );
4180
+ if (currentWorkspaceError) {
4181
+ if (isLegacyWorkspaceSchemaError(currentWorkspaceError)) return userId;
4182
+ throw currentWorkspaceError;
4183
+ }
4184
+ const currentValue = Array.isArray(currentWorkspace)
4185
+ ? currentWorkspace[0]?.current_livedesk_owner_workspace ?? currentWorkspace[0]
4186
+ : currentWorkspace?.current_livedesk_owner_workspace ?? currentWorkspace;
4187
+ const workspaceId = normalizeWorkspaceId(currentValue);
4188
+ if (!workspaceId) {
4189
+ throw createHubDiscoveryError(
4190
+ 'workspace-current-missing',
4191
+ 'No current VuvoDesk workspace is available for this new Client installation.'
4192
+ );
4193
+ }
4194
+ return workspaceId;
4195
+ }
4196
+
4197
+ export async function resolveManagerFromSupabase(supabase, options = {}) {
4064
4198
  if (options.signal?.aborted) {
4065
4199
  throw createHubDiscoveryError('hub-registry-aborted', 'VuvoDesk Hub registry lookup was cancelled.');
4066
4200
  }
4067
- let query = supabase
4068
- .from('livedesk_remote_host_targets')
4069
- .select('node_id, endpoint, endpoint_candidates, pair_token, active, expires_at, updated_at, manager_version')
4070
- .eq('product_key', 'livedesk');
4071
- if (options.signal && typeof query.abortSignal === 'function') {
4072
- query = query.abortSignal(options.signal);
4073
- }
4074
- const { data, error } = await query.maybeSingle();
4075
- if (error) {
4076
- throw error;
4077
- }
4201
+ const session = await refreshSessionIfNeeded(supabase);
4202
+ if (options.signal?.aborted) {
4203
+ throw createHubDiscoveryError('hub-registry-aborted', 'VuvoDesk Hub registry lookup was cancelled.');
4204
+ }
4205
+ const workspaceId = normalizeWorkspaceId(options.workspaceId)
4206
+ || await resolveClientWorkspaceFromSupabase(supabase, {
4207
+ deviceId: options.deviceId,
4208
+ session,
4209
+ signal: options.signal
4210
+ });
4211
+ let query = supabase.rpc('get_livedesk_workspace_remote_host_target', {
4212
+ p_workspace_id: workspaceId
4213
+ });
4214
+ if (options.signal && typeof query.abortSignal === 'function') {
4215
+ query = query.abortSignal(options.signal);
4216
+ }
4217
+ const { data: responseData, error } = await query;
4218
+ if (error) {
4219
+ throw error;
4220
+ }
4221
+ const data = Array.isArray(responseData) ? responseData[0] : responseData;
4222
+ if (data?.ok === false) {
4223
+ throw createHubDiscoveryError(
4224
+ String(data.reason || 'workspace-host-target-access-denied'),
4225
+ 'The VuvoDesk Client could not read this workspace Hub target.'
4226
+ );
4227
+ }
4228
+ if (data?.workspace_id && normalizeWorkspaceId(data.workspace_id) !== workspaceId) {
4229
+ throw createHubDiscoveryError(
4230
+ 'workspace-target-mismatch',
4231
+ 'The VuvoDesk Hub registry returned a different workspace.'
4232
+ );
4233
+ }
4078
4234
  if (!data?.active) {
4079
4235
  throw createHubDiscoveryError(
4080
4236
  'hub-registry-missing',
@@ -4110,8 +4266,9 @@ export async function resolveManagerFromSupabase(supabase, options = {}) {
4110
4266
  `No reachable VuvoDesk Hub endpoint yet. Checked ${endpointCandidates.join(', ')}.`
4111
4267
  );
4112
4268
  }
4113
- return {
4114
- manager: target.manager,
4269
+ return {
4270
+ workspaceId,
4271
+ manager: target.manager,
4115
4272
  pair: data.pair_token,
4116
4273
  hubDeviceId: String(data.node_id || '').trim(),
4117
4274
  endpointCandidates,
@@ -4119,23 +4276,61 @@ export async function resolveManagerFromSupabase(supabase, options = {}) {
4119
4276
  connectionTransport: target.connectionTransport,
4120
4277
  directProbeSkipped: target.directProbeSkipped === true,
4121
4278
  directProbeSkipReason: String(target.directProbeSkipReason || '')
4122
- };
4123
- }
4124
-
4125
- async function registerClientDeviceWithSupabase(supabase, options = {}) {
4279
+ };
4280
+ }
4281
+
4282
+ function readClientInstallationId(deviceId) {
4283
+ const expectedDeviceId = String(deviceId || '').trim();
4284
+ if (!expectedDeviceId) return '';
4285
+ for (const path of [
4286
+ join(UNIFIED_CLIENT_STATE_DIR, 'device.json'),
4287
+ join(CLIENT_STATE_DIR, 'device.json')
4288
+ ]) {
4289
+ try {
4290
+ const identity = JSON.parse(readFileSync(path, 'utf8'));
4291
+ if (String(identity?.deviceId || '').trim() !== expectedDeviceId) continue;
4292
+ const installationId = String(identity?.installationId || '').trim();
4293
+ if (installationId) return installationId.slice(0, 128);
4294
+ } catch {
4295
+ // Try the compatible legacy identity path next.
4296
+ }
4297
+ }
4298
+ return '';
4299
+ }
4300
+
4301
+ async function registerClientDeviceWithSupabase(supabase, options = {}) {
4126
4302
  if (!options.deviceId || !options.session?.access_token) {
4127
4303
  return null;
4128
- }
4129
- try {
4130
- const { data, error } = await supabase.rpc('register_livedesk_device', {
4131
- p_device_id: options.deviceId,
4132
- p_device_name: options.deviceName || os.hostname(),
4133
- p_role: 'client',
4134
- p_assigned_hub_id: options.assignedHubId || null,
4135
- p_platform: os.platform(),
4136
- p_os_version: os.release(),
4137
- p_app_version: readPackageVersion()
4138
- });
4304
+ }
4305
+ try {
4306
+ const workspaceId = normalizeWorkspaceId(options.workspaceId);
4307
+ const installationId = readClientInstallationId(options.deviceId);
4308
+ const exactWorkspaceRegistration = Boolean(workspaceId && installationId);
4309
+ if (workspaceId
4310
+ && workspaceId !== normalizeWorkspaceId(options.session?.user?.id)
4311
+ && !installationId) {
4312
+ console.warn('[VuvoDesk Client] Team device registration was skipped because the exact installation identity is unavailable.');
4313
+ return null;
4314
+ }
4315
+ const { data, error } = exactWorkspaceRegistration
4316
+ ? await supabase.rpc('resolve_or_register_livedesk_workspace_device', {
4317
+ p_workspace_id: workspaceId,
4318
+ p_device_id: options.deviceId,
4319
+ p_device_name: options.deviceName || os.hostname(),
4320
+ p_installation_id: installationId,
4321
+ p_platform: os.platform(),
4322
+ p_os_version: os.release(),
4323
+ p_app_version: readPackageVersion()
4324
+ })
4325
+ : await supabase.rpc('register_livedesk_device', {
4326
+ p_device_id: options.deviceId,
4327
+ p_device_name: options.deviceName || os.hostname(),
4328
+ p_role: 'client',
4329
+ p_assigned_hub_id: options.assignedHubId || null,
4330
+ p_platform: os.platform(),
4331
+ p_os_version: os.release(),
4332
+ p_app_version: readPackageVersion()
4333
+ });
4139
4334
  const result = Array.isArray(data) ? data[0] : data;
4140
4335
  if (error || result?.ok === false) {
4141
4336
  console.warn(`[VuvoDesk Client] Device role registration unavailable: ${error?.message || result?.reason || 'unknown-error'}`);
@@ -4147,27 +4342,148 @@ async function registerClientDeviceWithSupabase(supabase, options = {}) {
4147
4342
  }
4148
4343
  }
4149
4344
 
4150
- export async function waitForManagerFromSupabase(supabase, options = {}) {
4345
+ export async function waitForManagerFromSupabase(supabase, options = {}) {
4151
4346
  const configuredIntervalMs = Number(options.intervalMs || 0);
4152
4347
  const shouldStop = typeof options.shouldStop === 'function' ? options.shouldStop : () => false;
4153
4348
  const createWakeListener = typeof options.createWakeListener === 'function'
4154
4349
  ? options.createWakeListener
4155
4350
  : createHubWakeListener;
4156
4351
  let attempts = 0;
4157
- let lastMessage = '';
4158
- let wakeListener = null;
4159
- let initialError = options.initialError || null;
4160
- console.log('Waiting for a VuvoDesk Hub. VuvoDesk is listening for Hub-online events with adaptive registry retries as a fallback.');
4161
- try {
4162
- while (true) {
4163
- if (shouldStop()) {
4164
- return null;
4165
- }
4166
- if (!wakeListener) {
4167
- try {
4168
- wakeListener = await createWakeListener({
4169
- getAccessToken: async () => (await refreshSessionIfNeeded(supabase))?.access_token || ''
4170
- });
4352
+ let lastMessage = '';
4353
+ let wakeListener = null;
4354
+ let initialError = options.initialError || null;
4355
+ let workspaceId = normalizeWorkspaceId(options.workspaceId);
4356
+ const memberUserId = String(options.memberUserId || '').trim();
4357
+ let workspaceOwnerGeneration = workspaceId ? 1 : 0;
4358
+ const resolveWorkspace = typeof options.resolveWorkspace === 'function'
4359
+ ? options.resolveWorkspace
4360
+ : refreshOptions => resolveClientWorkspaceFromSupabase(supabase, refreshOptions);
4361
+ const requestedWorkspaceRefreshIntervalMs = Number(options.workspaceRefreshIntervalMs || 15_000);
4362
+ const workspaceRefreshIntervalMs = Number.isFinite(requestedWorkspaceRefreshIntervalMs)
4363
+ ? Math.max(25, requestedWorkspaceRefreshIntervalMs)
4364
+ : 15_000;
4365
+ let workspaceRefreshPromise = null;
4366
+ let lastWorkspaceRefreshAt = 0;
4367
+
4368
+ const refreshWorkspaceBinding = async ({ force = false } = {}) => {
4369
+ if (!String(options.deviceId || '').trim()) return false;
4370
+ const now = Date.now();
4371
+ // Even a forced registry-missing refresh is rate bounded. This keeps a
4372
+ // deleted/expired target from turning into an unbounded device lookup.
4373
+ if (lastWorkspaceRefreshAt > 0
4374
+ && now - lastWorkspaceRefreshAt < workspaceRefreshIntervalMs) {
4375
+ return false;
4376
+ }
4377
+ if (!force && attempts === 0) return false;
4378
+ if (workspaceRefreshPromise) return workspaceRefreshPromise;
4379
+
4380
+ lastWorkspaceRefreshAt = now;
4381
+ workspaceRefreshPromise = (async () => {
4382
+ try {
4383
+ const nextWorkspaceId = normalizeWorkspaceId(await resolveWorkspace({
4384
+ deviceId: options.deviceId
4385
+ }));
4386
+ if (!nextWorkspaceId || nextWorkspaceId === workspaceId) return false;
4387
+
4388
+ const previousWorkspaceId = workspaceId;
4389
+ workspaceOwnerGeneration += 1;
4390
+ wakeListener?.close();
4391
+ wakeListener = null;
4392
+ workspaceId = nextWorkspaceId;
4393
+ initialError = null;
4394
+ lastMessage = '';
4395
+ console.log(`[VuvoDesk Client] Workspace binding moved from ${previousWorkspaceId || 'unbound'} to ${workspaceId}. Rebinding Hub discovery without restarting.`);
4396
+ return true;
4397
+ } catch (error) {
4398
+ if (force && attempts === 0) {
4399
+ console.warn(`[VuvoDesk Client] Workspace move check unavailable; continuing the exact current workspace: ${error?.message || error}`);
4400
+ }
4401
+ return false;
4402
+ } finally {
4403
+ workspaceRefreshPromise = null;
4404
+ }
4405
+ })();
4406
+ return workspaceRefreshPromise;
4407
+ };
4408
+
4409
+ const shouldRefreshWorkspaceAfterRegistryError = error => {
4410
+ const code = String(error?.code || '').trim().toLowerCase();
4411
+ return code === 'hub-registry-missing'
4412
+ || code === 'workspace-target-mismatch';
4413
+ };
4414
+ console.log('Waiting for a VuvoDesk Hub. VuvoDesk is listening for Hub-online events with adaptive registry retries as a fallback.');
4415
+ try {
4416
+ while (true) {
4417
+ if (shouldStop()) {
4418
+ return null;
4419
+ }
4420
+ if (!workspaceId) {
4421
+ try {
4422
+ const resolvedWorkspaceId = normalizeWorkspaceId(await resolveWorkspace({
4423
+ deviceId: options.deviceId
4424
+ }));
4425
+ if (!resolvedWorkspaceId) {
4426
+ throw createHubDiscoveryError(
4427
+ 'workspace-current-missing',
4428
+ 'No current VuvoDesk workspace is available for this Client.'
4429
+ );
4430
+ }
4431
+ workspaceId = resolvedWorkspaceId;
4432
+ workspaceOwnerGeneration += 1;
4433
+ } catch (error) {
4434
+ attempts += 1;
4435
+ const message = formatDiscoveryError(error);
4436
+ if (message !== lastMessage || attempts === 1 || attempts % 6 === 0) {
4437
+ const suffix = attempts === 1 ? '' : ` attempt ${attempts}`;
4438
+ console.log(`Still waiting for VuvoDesk workspace${suffix}: ${message}`);
4439
+ lastMessage = message;
4440
+ }
4441
+ await waitForDiscoveryTrigger(
4442
+ getDiscoveryRetryDelay(attempts, configuredIntervalMs),
4443
+ null,
4444
+ shouldStop
4445
+ );
4446
+ continue;
4447
+ }
4448
+ }
4449
+ if (await refreshWorkspaceBinding()) {
4450
+ continue;
4451
+ }
4452
+ if (!wakeListener) {
4453
+ try {
4454
+ const listenerWorkspaceId = workspaceId;
4455
+ const listenerOwnerGeneration = workspaceOwnerGeneration;
4456
+ wakeListener = await createWakeListener({
4457
+ workspaceId: listenerWorkspaceId,
4458
+ memberUserId,
4459
+ ownerGeneration: listenerOwnerGeneration,
4460
+ getAccessContext: async expected => {
4461
+ if (workspaceId !== listenerWorkspaceId
4462
+ || workspaceOwnerGeneration !== listenerOwnerGeneration
4463
+ || expected?.workspaceId !== listenerWorkspaceId
4464
+ || String(expected?.memberUserId || '') !== memberUserId
4465
+ || String(expected?.ownerGeneration ?? '') !== String(listenerOwnerGeneration)) {
4466
+ return null;
4467
+ }
4468
+ const session = await refreshSessionIfNeeded(supabase);
4469
+ const refreshedMemberUserId = String(session?.user?.id || '').trim();
4470
+ const accessToken = String(session?.access_token || '').trim();
4471
+ if (!accessToken
4472
+ || !refreshedMemberUserId
4473
+ || refreshedMemberUserId !== memberUserId
4474
+ || workspaceId !== listenerWorkspaceId
4475
+ || workspaceOwnerGeneration !== listenerOwnerGeneration) {
4476
+ return null;
4477
+ }
4478
+ return {
4479
+ accessToken,
4480
+ workspaceId: listenerWorkspaceId,
4481
+ memberUserId,
4482
+ ownerGeneration: listenerOwnerGeneration,
4483
+ connectionGeneration: expected.connectionGeneration
4484
+ };
4485
+ }
4486
+ });
4171
4487
  } catch (error) {
4172
4488
  if (attempts === 0) {
4173
4489
  console.warn(`[VuvoDesk Client] Hub-online event channel unavailable; adaptive registry retries remain active: ${error?.message || error}`);
@@ -4182,11 +4498,12 @@ export async function waitForManagerFromSupabase(supabase, options = {}) {
4182
4498
  throw error;
4183
4499
  }
4184
4500
  const outcome = await runFreshHubRegistryLookup(
4185
- signal => resolveManagerFromSupabase(supabase, {
4186
- allowRelayFallback: options.allowRelayFallback === true,
4187
- probeEndpoint: options.probeEndpoint,
4188
- signal
4189
- }),
4501
+ signal => resolveManagerFromSupabase(supabase, {
4502
+ allowRelayFallback: options.allowRelayFallback === true,
4503
+ probeEndpoint: options.probeEndpoint,
4504
+ workspaceId,
4505
+ signal
4506
+ }),
4190
4507
  {
4191
4508
  timeoutMs: options.freshTimeoutMs,
4192
4509
  wakePromise: wakeListener?.promise
@@ -4199,8 +4516,12 @@ export async function waitForManagerFromSupabase(supabase, options = {}) {
4199
4516
  continue;
4200
4517
  }
4201
4518
  return outcome.target;
4202
- } catch (err) {
4203
- const message = formatDiscoveryError(err);
4519
+ } catch (err) {
4520
+ if (shouldRefreshWorkspaceAfterRegistryError(err)
4521
+ && await refreshWorkspaceBinding({ force: true })) {
4522
+ continue;
4523
+ }
4524
+ const message = formatDiscoveryError(err);
4204
4525
  if (message !== lastMessage || attempts === 1 || attempts % 6 === 0) {
4205
4526
  const suffix = attempts === 1 ? '' : ` attempt ${attempts}`;
4206
4527
  console.log(`Still waiting for VuvoDesk Hub${suffix}: ${message}`);
@@ -4327,54 +4648,83 @@ async function prepareLoginConnection(parsed, existingConnectionPage = null, get
4327
4648
  console.log(connectionTransport === 'relay-fallback'
4328
4649
  ? 'VuvoDesk PIN accepted. Direct TCP is unavailable; starting encrypted relay fallback.'
4329
4650
  : 'Connected by VuvoDesk PIN.');
4330
- } else {
4331
- const cacheOwnerKey = hubCacheOwnerForSession(choice.session);
4332
- let session = choice.session;
4333
- let initialDiscoveryError = null;
4334
- let resolved = null;
4335
- try {
4336
- resolved = await resolveManagerWithCachedRecovery(cacheOwnerKey, {
4337
- allowRelayFallback,
4338
- relayEndpoint: parsed.relay,
4339
- resolveFreshTarget: async signal => {
4340
- // The signed registry is authoritative for Hub replacement
4341
- // and pair-token rotation. Cache direct/relay recovery is
4342
- // considered only after this one current lookup reports
4343
- // an explicitly recoverable availability failure.
4344
- try {
4345
- session = await activateSupabaseSession(supabase, choice.session);
4346
- } catch (error) {
4347
- if (!isRefreshTokenAlreadyUsedError(error)) throw error;
4348
- session = readSavedSessionFromFile() || choice.session;
4349
- console.warn('[VuvoDesk Client] Another auth owner rotated the refresh token. Keeping the runtime alive while the saved session catches up.');
4350
- }
4351
- return await resolveManagerFromSupabase(supabase, {
4352
- allowRelayFallback,
4353
- signal
4354
- });
4355
- }
4356
- });
4651
+ } else {
4652
+ let session = choice.session;
4653
+ const cachedWorkspaceBinding = readCachedHubWorkspaceBinding(session);
4654
+ let workspaceId = cachedWorkspaceBinding?.workspaceId || '';
4655
+ let cacheOwnerKey = cachedWorkspaceBinding?.ownerKey
4656
+ || hubCacheOwnerForSession(session, session?.user?.id);
4657
+ let initialDiscoveryError = null;
4658
+ let resolved = null;
4659
+ try {
4660
+ resolved = await resolveManagerWithCachedRecovery(cacheOwnerKey, {
4661
+ allowRelayFallback,
4662
+ relayEndpoint: parsed.relay,
4663
+ resolveFreshTarget: async signal => {
4664
+ // The signed registry is authoritative for Hub replacement
4665
+ // and pair-token rotation. Cache direct/relay recovery is
4666
+ // considered only after this one current lookup reports
4667
+ // an explicitly recoverable availability failure.
4668
+ try {
4669
+ session = await activateSupabaseSession(supabase, choice.session);
4670
+ } catch (error) {
4671
+ if (!isRefreshTokenAlreadyUsedError(error)) throw error;
4672
+ session = readSavedSessionFromFile() || choice.session;
4673
+ console.warn('[VuvoDesk Client] Another auth owner rotated the refresh token. Keeping the runtime alive while the saved session catches up.');
4674
+ }
4675
+ const freshWorkspaceId = await resolveClientWorkspaceFromSupabase(supabase, {
4676
+ deviceId: parsed.deviceId,
4677
+ session,
4678
+ signal
4679
+ });
4680
+ const freshCacheOwnerKey = hubCacheOwnerForSession(session, freshWorkspaceId);
4681
+ const cacheWorkspaceChanged = hasClientWorkspaceCacheOwnerChanged(
4682
+ cachedWorkspaceBinding,
4683
+ session,
4684
+ freshWorkspaceId
4685
+ );
4686
+ workspaceId = freshWorkspaceId;
4687
+ cacheOwnerKey = freshCacheOwnerKey;
4688
+ try {
4689
+ return await resolveManagerFromSupabase(supabase, {
4690
+ allowRelayFallback,
4691
+ workspaceId,
4692
+ signal
4693
+ });
4694
+ } catch (error) {
4695
+ if (!cacheWorkspaceChanged) throw error;
4696
+ throw createHubDiscoveryError(
4697
+ 'workspace-cache-owner-changed',
4698
+ 'The Client workspace changed, so the previous workspace Hub cache was rejected.',
4699
+ error
4700
+ );
4701
+ }
4702
+ }
4703
+ });
4357
4704
  } catch (error) {
4358
4705
  initialDiscoveryError = error;
4359
4706
  }
4360
4707
  discoverySource = resolved?.discoverySource || 'supabase';
4361
4708
  if (resolved?.discoverySource === 'cache') {
4362
4709
  writeSavedSessionToFile(session);
4363
- console.log(resolved.connectionTransport === 'relay-fallback'
4364
- ? `Fresh Hub registry lookup was unavailable; using saved account-bound Hub identity ${resolved.hubDeviceId} for the bounded encrypted relay path.`
4365
- : `Fresh Hub registry lookup was unavailable; using the responding saved direct Hub endpoint ${resolved.manager}.`);
4710
+ console.log(resolved.connectionTransport === 'relay-fallback'
4711
+ ? `Fresh Hub registry lookup was unavailable; using saved workspace-bound Hub identity ${resolved.hubDeviceId} for the bounded encrypted relay path.`
4712
+ : `Fresh Hub registry lookup was unavailable; using the responding saved direct Hub endpoint ${resolved.manager}.`);
4366
4713
  }
4367
4714
  choice.session = session;
4368
4715
  const email = session?.user?.email ? ` as ${session.user.email}` : '';
4369
4716
  console.log(`Signed in to VuvoDesk${email}.`);
4370
4717
  if (!resolved) {
4371
- resolved = await waitForManagerFromSupabase(supabase, {
4372
- allowRelayFallback,
4373
- initialError: initialDiscoveryError,
4374
- shouldStop: () => Boolean(roleRestartRequest?.role)
4375
- });
4376
- discoverySource = 'supabase';
4377
- }
4718
+ resolved = await waitForManagerFromSupabase(supabase, {
4719
+ allowRelayFallback,
4720
+ initialError: initialDiscoveryError,
4721
+ workspaceId,
4722
+ deviceId: parsed.deviceId,
4723
+ memberUserId: session?.user?.id,
4724
+ shouldStop: () => Boolean(roleRestartRequest?.role)
4725
+ });
4726
+ discoverySource = 'supabase';
4727
+ }
4378
4728
  if (!resolved && roleRestartRequest?.role) {
4379
4729
  return {
4380
4730
  ...parsed,
@@ -4386,15 +4736,18 @@ async function prepareLoginConnection(parsed, existingConnectionPage = null, get
4386
4736
  rediscoverOnDisconnect: shouldLogin,
4387
4737
  rediscoverOnInvalidPair: shouldLogin
4388
4738
  };
4389
- }
4390
- if (discoverySource === 'supabase') {
4391
- writeCachedHubTarget(cacheOwnerKey, resolved);
4392
- const registration = await registerClientDeviceWithSupabase(supabase, {
4393
- deviceId: parsed.deviceId,
4394
- deviceName: os.hostname(),
4395
- assignedHubId: resolved.hubDeviceId,
4396
- session
4397
- });
4739
+ }
4740
+ workspaceId = normalizeWorkspaceId(resolved.workspaceId) || workspaceId;
4741
+ cacheOwnerKey = hubCacheOwnerForSession(session, workspaceId) || cacheOwnerKey;
4742
+ if (discoverySource === 'supabase') {
4743
+ writeCachedHubTarget(cacheOwnerKey, resolved);
4744
+ const registration = await registerClientDeviceWithSupabase(supabase, {
4745
+ deviceId: parsed.deviceId,
4746
+ deviceName: os.hostname(),
4747
+ assignedHubId: resolved.hubDeviceId,
4748
+ workspaceId,
4749
+ session
4750
+ });
4398
4751
  if (Number.isInteger(registration?.role_version)) {
4399
4752
  connectionPage?.update({ roleVersion: registration.role_version });
4400
4753
  }