@buoy-gg/sync-broker 7.0.36 → 7.0.38

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/README.md CHANGED
@@ -1,57 +1,50 @@
1
1
  # @buoy-gg/sync-broker
2
2
 
3
- Headless Socket.IO **broker** for the Buoy devtool sync protocol (v1).
3
+ Host the Socket.IO message router used by Buoy Desktop and MCP. It routes device capabilities, watched snapshots, and remote actions between device and dashboard clients. Most users should run Desktop or MCP instead of hosting this package directly.
4
4
 
5
- This is the message router that sits between **devices** (React Native apps running a Buoy devtool, connected as Socket.IO clients) and **dashboard clients** (the Buoy desktop app, and the Buoy MCP server). It was extracted from the desktop app's `apps/desktop/src/server` so the same implementation can be hosted by either:
5
+ ## Install
6
6
 
7
- - the **desktop app** (which owns its own express/http server), or
8
- - a **standalone process** such as the Buoy MCP server, when no desktop app is running.
7
+ ```bash
8
+ npm install @buoy-gg/sync-broker
9
+ ```
10
+
11
+ This is a Node host package. Use the exported `createBroker`, `socketHandle`, and protocol types when implementing a custom host.
9
12
 
10
- ## Usage
13
+ ## Host a broker
11
14
 
12
- ### Host a complete broker (standalone / MCP)
15
+ The host must independently validate its Buoy account and supply admission through `canUseDesktop`. The default denies access. This integration sketch expects your host's existing `account` object; `hasAccess()` must reflect current verified admission, not merely the presence of a key:
13
16
 
14
17
  ```ts
15
18
  import { createBroker, DEFAULT_BROKER_PORT } from "@buoy-gg/sync-broker";
16
19
 
17
20
  const broker = createBroker({
18
- port: DEFAULT_BROKER_PORT, // 42831
19
- onListening: (port) => console.log(`broker listening on ${port}`),
20
- onError: (err) => {
21
- if (err.code === "EADDRINUSE") {
22
- // Another broker (e.g. the desktop app) already owns the port —
23
- // connect to it as a client instead of hosting your own.
24
- }
25
- },
21
+ port: DEFAULT_BROKER_PORT,
22
+ canUseDesktop: () => account.hasAccess(),
23
+ onListening: (port) => console.log(`Broker listening on ${port}`),
24
+ onError: (error) => console.error(error),
26
25
  });
27
26
 
28
- // later
27
+ // Call when your account lifecycle changes admission:
28
+ broker.refreshAccess();
29
+
30
+ // Call during host shutdown:
29
31
  await broker.close();
30
32
  ```
31
33
 
32
- ### Attach to an existing Socket.IO server (desktop app)
34
+ Keep account revalidation and expiry handling active for the host's lifetime. Refresh routing when access changes. Device and client admission is separate from the host callback. A listening HTTP endpoint does not prove that a device has been admitted.
33
35
 
34
- ```ts
35
- import { Server } from "socket.io";
36
- import { socketHandle } from "@buoy-gg/sync-broker";
36
+ Try connecting to an existing broker before starting another. If startup reports EADDRINUSE, connect to the broker already using that port or resolve the conflict. The default port is `42831`.
37
37
 
38
- const io = new Server(httpServer, socketOptions);
39
- socketHandle({ io });
40
- ```
38
+ ## Existing HTTP server
39
+
40
+ For an existing Socket.IO server, use `socketHandle({ io, canUseDesktop })` and retain its returned `refreshAccess` function. Your host owns the HTTP server, Socket.IO options, account lifecycle, and shutdown. Do not attach routing twice to the same server.
41
41
 
42
- ## Protocol
42
+ ## Message flow
43
43
 
44
- The wire contract (events, message shapes, watch/backpressure model) is **v1**, canonically defined in [`@buoy-gg/external-sync`](../external-sync). The broker reproduces the server-relevant types in `src/protocol.ts` to stay a dependency-light Node package (no React Native / `socket.io-client`).
44
+ Devices announce capabilities. Dashboard watch requests control adapter subscriptions; the broker consolidates those requests. Device snapshots flow to watching clients, while named actions and their results travel in the opposite directions. Broker logs help diagnose admission, connection, and protocol problems. Offline device history is in memory and can be forgotten or age out.
45
45
 
46
- Topology and message flow:
46
+ ## Network and verification
47
47
 
48
- - Devices connect with a handshake carrying `deviceId`, `platform`, etc.
49
- - Dashboard clients connect with `deviceName: "Dashboard"` (exact, no suffix).
50
- - Devices announce `devtool-capabilities`; the broker replays them to dashboards.
51
- - Dashboards `devtool-watch` tools; the broker **consolidates** watch counts so a device receives a single `watching` true/false per `(device, tool)` regardless of how many dashboards are watching.
52
- - Dashboards invoke `devtool-action`; devices reply `devtool-action-result`.
53
- - Devices push `devtool-sync` snapshots; the broker forwards them to dashboards.
54
- - The broker emits `broker-log` entries to dashboards (handshakes, disconnect reasons, duplicate-name renames, protocol-version mismatches), buffering the last 200 so a dashboard that connects late still sees earlier failures.
55
- - Dashboards emit `forget-device` to remove an **offline** device from history; offline devices also age out automatically after 24h (`User.lastSeenAt`).
48
+ The standalone server listens without a loopback-only host restriction. Default CORS allows any origin; CORS is not client authentication. Use a trusted development network and review access controls before exposing a custom host beyond it. Account admission does not establish app-specific authorization for every remote action.
56
49
 
57
- > **Security:** dev-only. No authentication, CORS is open (`*`), intended for loopback / trusted LAN.
50
+ Test with a verified host and device account: connect, watch a known tool, and confirm its snapshot. Then revoke host admission and verify that access closes. Reconnect after restoring valid access and check that watch state resumes. See the [Desktop guide](https://buoy.gg/buoy/latest/docs/desktop) and [MCP guide](https://buoy.gg/buoy/latest/docs/mcp) for supported end-user hosts.
package/dist/index.d.mts CHANGED
@@ -3,6 +3,7 @@ import http from 'http';
3
3
 
4
4
  type DefaultEventsMap = Record<string, (...args: any[]) => void>;
5
5
  interface Props {
6
+ canUseDesktop?: () => boolean;
6
7
  io: Server<DefaultEventsMap, DefaultEventsMap, DefaultEventsMap, any>;
7
8
  }
8
9
  /**
@@ -12,7 +13,9 @@ interface Props {
12
13
  * - Query state synchronization
13
14
  * - Action forwarding
14
15
  */
15
- declare function socketHandle({ io }: Props): void;
16
+ declare function socketHandle({ io, canUseDesktop }: Props): {
17
+ refreshAccess(): void;
18
+ };
16
19
 
17
20
  /** Default port the Buoy sync broker listens on. */
18
21
  declare const DEFAULT_BROKER_PORT = 42831;
@@ -24,6 +27,8 @@ declare const SOCKET_CONFIG: Partial<ServerOptions>;
24
27
  interface CreateBrokerOptions {
25
28
  /** Port to listen on. @default DEFAULT_BROKER_PORT (42831) */
26
29
  port?: number;
30
+ /** The host must supply its independently verified account admission. */
31
+ canUseDesktop?: () => boolean;
27
32
  /**
28
33
  * Extra Socket.IO server options merged over {@link SOCKET_CONFIG}. Use to
29
34
  * override CORS, transports, etc. for a specific host.
@@ -48,6 +53,7 @@ interface BrokerHandle {
48
53
  port: number;
49
54
  /** Stop the broker and release the port. */
50
55
  close: () => Promise<void>;
56
+ refreshAccess: () => void;
51
57
  }
52
58
  /**
53
59
  * Create and start a headless Buoy sync broker: an HTTP + Socket.IO server with
@@ -153,10 +159,17 @@ interface DevToolActionMessage {
153
159
  params?: unknown;
154
160
  }
155
161
  /**
156
- * A client's access tier. Mirrors `BuoyTier` in @buoy-gg/license; redeclared
157
- * here because the broker is a standalone node process with no client deps.
162
+ * A client's tier. Mirrors `BuoyTier` in @buoy-gg/license; redeclared here
163
+ * because the broker is a standalone node process with no client deps.
158
164
  */
159
- type BuoyTier = "anonymous" | "free" | "pro";
165
+ type BuoyTier = "free" | "pro";
166
+ /**
167
+ * What the broker knows about a device's account: its tier once the device
168
+ * has announced an admitted account, or "locked" until then. "locked" is not
169
+ * a tier — it is the broker's default for every socket, and what a device
170
+ * announces when its own account access ends.
171
+ */
172
+ type DeviceTier = BuoyTier | "locked";
160
173
  /**
161
174
  * A connected client (device or dashboard) as tracked by the broker.
162
175
  * Mirrors the desktop dashboard's `User` type; the dashboard UI carries a few
@@ -194,12 +207,12 @@ interface User {
194
207
  * The device's access tier. Absent from clients older than the tier split —
195
208
  * derive it from `isPro` in that case (see `tierFromLicensePayload`).
196
209
  */
197
- tier?: BuoyTier;
210
+ tier?: DeviceTier;
198
211
  /**
199
212
  * The device's Buoy license key, if any. Local dev connection only.
200
213
  *
201
214
  * Forwarded for free keys too, not just Pro ones, so the desktop dashboard
202
- * can mirror the device's tier instead of sitting at anonymous while the
215
+ * can mirror the device's tier instead of showing it locked while the
203
216
  * phone it is attached to is signed in.
204
217
  */
205
218
  licenseKey?: string;
package/dist/index.d.ts CHANGED
@@ -3,6 +3,7 @@ import http from 'http';
3
3
 
4
4
  type DefaultEventsMap = Record<string, (...args: any[]) => void>;
5
5
  interface Props {
6
+ canUseDesktop?: () => boolean;
6
7
  io: Server<DefaultEventsMap, DefaultEventsMap, DefaultEventsMap, any>;
7
8
  }
8
9
  /**
@@ -12,7 +13,9 @@ interface Props {
12
13
  * - Query state synchronization
13
14
  * - Action forwarding
14
15
  */
15
- declare function socketHandle({ io }: Props): void;
16
+ declare function socketHandle({ io, canUseDesktop }: Props): {
17
+ refreshAccess(): void;
18
+ };
16
19
 
17
20
  /** Default port the Buoy sync broker listens on. */
18
21
  declare const DEFAULT_BROKER_PORT = 42831;
@@ -24,6 +27,8 @@ declare const SOCKET_CONFIG: Partial<ServerOptions>;
24
27
  interface CreateBrokerOptions {
25
28
  /** Port to listen on. @default DEFAULT_BROKER_PORT (42831) */
26
29
  port?: number;
30
+ /** The host must supply its independently verified account admission. */
31
+ canUseDesktop?: () => boolean;
27
32
  /**
28
33
  * Extra Socket.IO server options merged over {@link SOCKET_CONFIG}. Use to
29
34
  * override CORS, transports, etc. for a specific host.
@@ -48,6 +53,7 @@ interface BrokerHandle {
48
53
  port: number;
49
54
  /** Stop the broker and release the port. */
50
55
  close: () => Promise<void>;
56
+ refreshAccess: () => void;
51
57
  }
52
58
  /**
53
59
  * Create and start a headless Buoy sync broker: an HTTP + Socket.IO server with
@@ -153,10 +159,17 @@ interface DevToolActionMessage {
153
159
  params?: unknown;
154
160
  }
155
161
  /**
156
- * A client's access tier. Mirrors `BuoyTier` in @buoy-gg/license; redeclared
157
- * here because the broker is a standalone node process with no client deps.
162
+ * A client's tier. Mirrors `BuoyTier` in @buoy-gg/license; redeclared here
163
+ * because the broker is a standalone node process with no client deps.
158
164
  */
159
- type BuoyTier = "anonymous" | "free" | "pro";
165
+ type BuoyTier = "free" | "pro";
166
+ /**
167
+ * What the broker knows about a device's account: its tier once the device
168
+ * has announced an admitted account, or "locked" until then. "locked" is not
169
+ * a tier — it is the broker's default for every socket, and what a device
170
+ * announces when its own account access ends.
171
+ */
172
+ type DeviceTier = BuoyTier | "locked";
160
173
  /**
161
174
  * A connected client (device or dashboard) as tracked by the broker.
162
175
  * Mirrors the desktop dashboard's `User` type; the dashboard UI carries a few
@@ -194,12 +207,12 @@ interface User {
194
207
  * The device's access tier. Absent from clients older than the tier split —
195
208
  * derive it from `isPro` in that case (see `tierFromLicensePayload`).
196
209
  */
197
- tier?: BuoyTier;
210
+ tier?: DeviceTier;
198
211
  /**
199
212
  * The device's Buoy license key, if any. Local dev connection only.
200
213
  *
201
214
  * Forwarded for free keys too, not just Pro ones, so the desktop dashboard
202
- * can mirror the device's tier instead of sitting at anonymous while the
215
+ * can mirror the device's tier instead of showing it locked while the
203
216
  * phone it is attached to is signed in.
204
217
  */
205
218
  licenseKey?: string;
package/dist/index.js CHANGED
@@ -41,10 +41,9 @@ module.exports = __toCommonJS(index_exports);
41
41
  // src/protocol.ts
42
42
  var SYNC_PROTOCOL_VERSION = 1;
43
43
  function tierFromLicensePayload(payload) {
44
- if (payload?.tier === "pro" || payload?.tier === "free" || payload?.tier === "anonymous") {
45
- return payload.tier;
46
- }
47
- return payload?.isPro === "1" || payload?.isPro === true ? "pro" : "anonymous";
44
+ if (payload?.tier === "pro" || payload?.tier === "free") return payload.tier;
45
+ if (payload?.tier === "locked" || payload?.tier === "anonymous") return "locked";
46
+ return payload?.isPro === "1" || payload?.isPro === true ? "pro" : "locked";
48
47
  }
49
48
 
50
49
  // src/watchRegistry.ts
@@ -130,19 +129,31 @@ var WatchRegistry = class {
130
129
  };
131
130
 
132
131
  // src/socketHandle.ts
133
- var users = [];
134
- var allDevices = [];
135
- var dashboardClients = [];
136
- var deviceIdMap = /* @__PURE__ */ new Map();
137
- var activeConnectionsMap = /* @__PURE__ */ new Map();
138
- var instanceIdMap = /* @__PURE__ */ new Map();
139
- var deviceCapabilities = /* @__PURE__ */ new Map();
140
- var watches = new WatchRegistry();
141
- var brokerLogBuffer = [];
142
132
  var BROKER_LOG_BUFFER_MAX = 200;
143
133
  var MAX_OFFLINE_AGE_MS = 24 * 60 * 60 * 1e3;
144
- function socketHandle({ io }) {
134
+ function redactAccountKeys(raw) {
135
+ if (!raw) return void 0;
136
+ try {
137
+ const value = JSON.parse(raw);
138
+ if (!value || typeof value !== "object" || Array.isArray(value)) return void 0;
139
+ for (const key of ["BUOY_LICENSE_KEY", "BUOY_KEY", "EXPO_PUBLIC_BUOY_KEY", "VITE_BUOY_KEY", "REACT_APP_BUOY_KEY", "licenseKey"]) delete value[key];
140
+ return JSON.stringify(value);
141
+ } catch {
142
+ return void 0;
143
+ }
144
+ }
145
+ function socketHandle({ io, canUseDesktop = () => false }) {
145
146
  const LOG_PREFIX = "[SERVER]";
147
+ let users = [];
148
+ const allDevices = [];
149
+ let dashboardClients = [];
150
+ const deviceIdMap = /* @__PURE__ */ new Map();
151
+ const socketTiers = /* @__PURE__ */ new Map();
152
+ const activeConnectionsMap = /* @__PURE__ */ new Map();
153
+ const instanceIdMap = /* @__PURE__ */ new Map();
154
+ const deviceCapabilities = /* @__PURE__ */ new Map();
155
+ let watches = new WatchRegistry();
156
+ const brokerLogBuffer = [];
146
157
  function brokerLog(level, message, data) {
147
158
  console.log(`${LOG_PREFIX} ${message}`);
148
159
  const entry = { ts: Date.now(), level, message, data };
@@ -162,6 +173,20 @@ function socketHandle({ io }) {
162
173
  return instances.size;
163
174
  }
164
175
  function emitAllDevices() {
176
+ if (!canUseDesktop()) {
177
+ io.emit("all-devices-update", []);
178
+ return;
179
+ }
180
+ for (const device of [...users, ...allDevices]) {
181
+ if (device.deviceName === "Dashboard" || !device.isConnected) continue;
182
+ const tier = socketTiers.get(device.id) ?? "locked";
183
+ device.tier = tier === "pro" ? "pro" : tier === "free" ? "free" : "locked";
184
+ device.isPro = tier === "pro";
185
+ if (tier !== "free" && tier !== "pro") {
186
+ delete device.envVariables;
187
+ delete device.extraDeviceInfo;
188
+ }
189
+ }
165
190
  const now = Date.now();
166
191
  for (const device of allDevices) {
167
192
  if (!device.deviceId) continue;
@@ -191,7 +216,7 @@ function socketHandle({ io }) {
191
216
  }
192
217
  io.emit(
193
218
  "all-devices-update",
194
- allDevices.filter((device) => device.deviceName !== "Dashboard")
219
+ allDevices.filter((device) => device.deviceName !== "Dashboard").map(({ licenseKey: _key, ...device }) => device)
195
220
  );
196
221
  }
197
222
  function handleClose(id, reason) {
@@ -199,6 +224,7 @@ function socketHandle({ io }) {
199
224
  const deviceId = deviceIdMap.get(id);
200
225
  deviceIdMap.delete(id);
201
226
  instanceIdMap.delete(id);
227
+ socketTiers.delete(id);
202
228
  let survivor;
203
229
  if (deviceId) {
204
230
  for (const [socketId, mappedDeviceId] of deviceIdMap) {
@@ -386,9 +412,11 @@ function socketHandle({ io }) {
386
412
  }
387
413
  function handleUserMessage(message, deviceName) {
388
414
  console.log(`${LOG_PREFIX} Message from ${deviceName}: ${message}`);
415
+ if (!canUseDesktop()) return;
389
416
  io.emit("message", `Device ${deviceName} sent message: ${message}`);
390
417
  }
391
418
  function forwardToDashboards(event, message) {
419
+ if (!canUseDesktop()) return;
392
420
  dashboardClients = dashboardClients.filter(
393
421
  (id) => io.sockets.sockets.has(id)
394
422
  );
@@ -400,6 +428,11 @@ function socketHandle({ io }) {
400
428
  }
401
429
  }
402
430
  function withTargetUsers(targetDeviceId, action, actionName) {
431
+ if (!canUseDesktop()) return;
432
+ const admitted = (user) => {
433
+ const tier = socketTiers.get(user.id);
434
+ return tier === "free" || tier === "pro";
435
+ };
403
436
  const deviceName = getDeviceFromDeviceId(targetDeviceId)?.deviceName;
404
437
  if (targetDeviceId === "No devices available") {
405
438
  console.log(
@@ -415,10 +448,10 @@ function socketHandle({ io }) {
415
448
  console.log(
416
449
  `${LOG_PREFIX} Sending to ${deviceUsers.length} devices: ${deviceUsers.map((u) => u.deviceName).join(", ")}`
417
450
  );
418
- deviceUsers.forEach(action);
451
+ deviceUsers.filter(admitted).forEach(action);
419
452
  } else {
420
453
  const targetUser = users.find((user) => user.deviceId === targetDeviceId);
421
- if (targetUser) {
454
+ if (targetUser && admitted(targetUser)) {
422
455
  console.log(
423
456
  `${LOG_PREFIX} Sending to target device ${targetUser.deviceName} (${targetUser.deviceId}) targetDeviceId: ${targetDeviceId}`
424
457
  );
@@ -469,6 +502,27 @@ function socketHandle({ io }) {
469
502
  }
470
503
  }
471
504
  io.on("connection", (socket) => {
505
+ const sendAccess = () => socket.emit("buoy:access", { protocol: 1, ready: canUseDesktop() });
506
+ sendAccess();
507
+ socket.on("buoy:access:request", sendAccess);
508
+ socket.use((_packet, next) => {
509
+ if (_packet[0] === "buoy:access:request") {
510
+ next();
511
+ return;
512
+ }
513
+ if (!canUseDesktop()) {
514
+ sendAccess();
515
+ return;
516
+ }
517
+ const control = _packet[0] === "device-license";
518
+ if (control) {
519
+ const payload = _packet[1] ?? {};
520
+ socketTiers.set(socket.id, tierFromLicensePayload(payload));
521
+ }
522
+ const deviceAdmitted = socketTiers.get(socket.id) === "free" || socketTiers.get(socket.id) === "pro";
523
+ if (control || dashboardClients.includes(socket.id) || deviceAdmitted) next();
524
+ else sendAccess();
525
+ });
472
526
  const {
473
527
  deviceName,
474
528
  deviceId,
@@ -482,18 +536,18 @@ function socketHandle({ io }) {
482
536
  instanceIdMap.set(socket.id, instanceId);
483
537
  }
484
538
  console.log(
485
- `${LOG_PREFIX} New connection - ID: ${socket.id}, Name: ${deviceName || "Unknown Device"}${deviceId ? `, DeviceId: ${deviceId}` : ""}${platform ? `, Platform: ${platform}` : ""}${extraDeviceInfo ? `, ExtraDeviceInfo: ${extraDeviceInfo}` : ""}${envVariables ? `, EnvVariables: ${envVariables}` : ""}`
539
+ `${LOG_PREFIX} New connection - ID: ${socket.id}, Name: ${deviceName || "Unknown Device"}${deviceId ? `, DeviceId: ${deviceId}` : ""}${platform ? `, Platform: ${platform}` : ""}`
486
540
  );
487
541
  addNewUser({
488
542
  id: socket.id,
489
543
  deviceName: deviceName || "Unknown Device Name",
490
544
  deviceId,
491
545
  platform,
492
- extraDeviceInfo,
493
- envVariables,
546
+ extraDeviceInfo: void 0,
547
+ envVariables: void 0,
494
548
  connectionNonce
495
549
  });
496
- if (deviceName === "Dashboard") {
550
+ if (canUseDesktop() && deviceName === "Dashboard") {
497
551
  for (const capabilities of deviceCapabilities.values()) {
498
552
  socket.emit("devtool-capabilities", capabilities);
499
553
  }
@@ -506,25 +560,32 @@ function socketHandle({ io }) {
506
560
  (payload) => {
507
561
  const nextTier = tierFromLicensePayload(payload ?? {});
508
562
  const nextIsPro = nextTier === "pro";
509
- const nextKey = nextTier === "anonymous" ? void 0 : payload?.licenseKey || void 0;
563
+ socketTiers.set(socket.id, nextTier);
510
564
  const user = users.find((u) => u.id === socket.id);
511
565
  if (user) {
512
566
  user.isPro = nextIsPro;
513
567
  user.tier = nextTier;
514
- user.licenseKey = nextKey;
568
+ delete user.licenseKey;
515
569
  }
516
570
  const did = user?.deviceId || deviceIdMap.get(socket.id);
571
+ if (nextTier !== "locked") {
572
+ for (const record of [...users, ...allDevices].filter((record2) => record2.id === socket.id)) {
573
+ record.extraDeviceInfo = redactAccountKeys(extraDeviceInfo);
574
+ record.envVariables = redactAccountKeys(envVariables);
575
+ }
576
+ }
517
577
  if (did) {
578
+ if (nextTier === "locked") deviceCapabilities.delete(did);
518
579
  const device = allDevices.find((d) => d.deviceId === did);
519
580
  if (device) {
520
581
  device.isPro = nextIsPro;
521
582
  device.tier = nextTier;
522
- device.licenseKey = nextKey;
583
+ delete device.licenseKey;
523
584
  }
524
585
  }
525
586
  brokerLog(
526
587
  "debug",
527
- `device-license from ${user?.deviceName || socket.id}: tier=${nextTier}${nextKey ? ", key set" : ""}`,
588
+ `device-license from ${user?.deviceName || socket.id}: tier=${nextTier}`,
528
589
  { socketId: socket.id }
529
590
  );
530
591
  emitAllDevices();
@@ -538,6 +599,7 @@ function socketHandle({ io }) {
538
599
  handleUserMessage(msg, deviceName || "Unknown");
539
600
  });
540
601
  socket.on("devtool-capabilities", (message) => {
602
+ if (!message || !Array.isArray(message.tools) || message.persistentDeviceId !== deviceIdMap.get(socket.id)) return;
541
603
  console.log(
542
604
  `${LOG_PREFIX} Capabilities from ${message.persistentDeviceId}: ${message.tools.map((tool) => `${tool.toolId}@v${tool.version}`).join(", ")}`
543
605
  );
@@ -567,11 +629,13 @@ function socketHandle({ io }) {
567
629
  }
568
630
  });
569
631
  socket.on("devtool-sync", (message) => {
632
+ if (!message || message.persistentDeviceId !== deviceIdMap.get(socket.id)) return;
570
633
  forwardToDashboards("devtool-sync", message);
571
634
  });
572
635
  socket.on(
573
636
  "devtool-action-result",
574
637
  (message) => {
638
+ if (!message || message.persistentDeviceId !== deviceIdMap.get(socket.id)) return;
575
639
  console.log(
576
640
  `${LOG_PREFIX} Action result from ${message.persistentDeviceId}: ${message.toolId}.${message.action} -> ${message.ok ? "ok" : `error: ${message.error}`}`
577
641
  );
@@ -624,6 +688,24 @@ function socketHandle({ io }) {
624
688
  );
625
689
  });
626
690
  });
691
+ return {
692
+ refreshAccess() {
693
+ const ready = canUseDesktop();
694
+ if (!ready) {
695
+ socketTiers.clear();
696
+ deviceCapabilities.clear();
697
+ brokerLogBuffer.length = 0;
698
+ watches = new WatchRegistry();
699
+ for (const device of [...users, ...allDevices]) {
700
+ delete device.licenseKey;
701
+ delete device.envVariables;
702
+ delete device.extraDeviceInfo;
703
+ }
704
+ }
705
+ io.emit("buoy:access", { protocol: 1, ready });
706
+ emitAllDevices();
707
+ }
708
+ };
627
709
  }
628
710
 
629
711
  // src/createBroker.ts
@@ -658,7 +740,7 @@ function createBroker(options = {}) {
658
740
  ...SOCKET_CONFIG,
659
741
  ...options.socketOptions
660
742
  });
661
- socketHandle({ io });
743
+ const routing = socketHandle({ io, canUseDesktop: options.canUseDesktop });
662
744
  httpServer.on("error", (error) => {
663
745
  options.onError?.(error);
664
746
  });
@@ -670,7 +752,7 @@ function createBroker(options = {}) {
670
752
  httpServer.close(() => resolve());
671
753
  });
672
754
  });
673
- return { io, httpServer, port, close };
755
+ return { io, httpServer, port, close, refreshAccess: routing.refreshAccess };
674
756
  }
675
757
  // Annotate the CommonJS export names for ESM import in node:
676
758
  0 && (module.exports = {
package/dist/index.mjs CHANGED
@@ -1,10 +1,9 @@
1
1
  // src/protocol.ts
2
2
  var SYNC_PROTOCOL_VERSION = 1;
3
3
  function tierFromLicensePayload(payload) {
4
- if (payload?.tier === "pro" || payload?.tier === "free" || payload?.tier === "anonymous") {
5
- return payload.tier;
6
- }
7
- return payload?.isPro === "1" || payload?.isPro === true ? "pro" : "anonymous";
4
+ if (payload?.tier === "pro" || payload?.tier === "free") return payload.tier;
5
+ if (payload?.tier === "locked" || payload?.tier === "anonymous") return "locked";
6
+ return payload?.isPro === "1" || payload?.isPro === true ? "pro" : "locked";
8
7
  }
9
8
 
10
9
  // src/watchRegistry.ts
@@ -90,19 +89,31 @@ var WatchRegistry = class {
90
89
  };
91
90
 
92
91
  // src/socketHandle.ts
93
- var users = [];
94
- var allDevices = [];
95
- var dashboardClients = [];
96
- var deviceIdMap = /* @__PURE__ */ new Map();
97
- var activeConnectionsMap = /* @__PURE__ */ new Map();
98
- var instanceIdMap = /* @__PURE__ */ new Map();
99
- var deviceCapabilities = /* @__PURE__ */ new Map();
100
- var watches = new WatchRegistry();
101
- var brokerLogBuffer = [];
102
92
  var BROKER_LOG_BUFFER_MAX = 200;
103
93
  var MAX_OFFLINE_AGE_MS = 24 * 60 * 60 * 1e3;
104
- function socketHandle({ io }) {
94
+ function redactAccountKeys(raw) {
95
+ if (!raw) return void 0;
96
+ try {
97
+ const value = JSON.parse(raw);
98
+ if (!value || typeof value !== "object" || Array.isArray(value)) return void 0;
99
+ for (const key of ["BUOY_LICENSE_KEY", "BUOY_KEY", "EXPO_PUBLIC_BUOY_KEY", "VITE_BUOY_KEY", "REACT_APP_BUOY_KEY", "licenseKey"]) delete value[key];
100
+ return JSON.stringify(value);
101
+ } catch {
102
+ return void 0;
103
+ }
104
+ }
105
+ function socketHandle({ io, canUseDesktop = () => false }) {
105
106
  const LOG_PREFIX = "[SERVER]";
107
+ let users = [];
108
+ const allDevices = [];
109
+ let dashboardClients = [];
110
+ const deviceIdMap = /* @__PURE__ */ new Map();
111
+ const socketTiers = /* @__PURE__ */ new Map();
112
+ const activeConnectionsMap = /* @__PURE__ */ new Map();
113
+ const instanceIdMap = /* @__PURE__ */ new Map();
114
+ const deviceCapabilities = /* @__PURE__ */ new Map();
115
+ let watches = new WatchRegistry();
116
+ const brokerLogBuffer = [];
106
117
  function brokerLog(level, message, data) {
107
118
  console.log(`${LOG_PREFIX} ${message}`);
108
119
  const entry = { ts: Date.now(), level, message, data };
@@ -122,6 +133,20 @@ function socketHandle({ io }) {
122
133
  return instances.size;
123
134
  }
124
135
  function emitAllDevices() {
136
+ if (!canUseDesktop()) {
137
+ io.emit("all-devices-update", []);
138
+ return;
139
+ }
140
+ for (const device of [...users, ...allDevices]) {
141
+ if (device.deviceName === "Dashboard" || !device.isConnected) continue;
142
+ const tier = socketTiers.get(device.id) ?? "locked";
143
+ device.tier = tier === "pro" ? "pro" : tier === "free" ? "free" : "locked";
144
+ device.isPro = tier === "pro";
145
+ if (tier !== "free" && tier !== "pro") {
146
+ delete device.envVariables;
147
+ delete device.extraDeviceInfo;
148
+ }
149
+ }
125
150
  const now = Date.now();
126
151
  for (const device of allDevices) {
127
152
  if (!device.deviceId) continue;
@@ -151,7 +176,7 @@ function socketHandle({ io }) {
151
176
  }
152
177
  io.emit(
153
178
  "all-devices-update",
154
- allDevices.filter((device) => device.deviceName !== "Dashboard")
179
+ allDevices.filter((device) => device.deviceName !== "Dashboard").map(({ licenseKey: _key, ...device }) => device)
155
180
  );
156
181
  }
157
182
  function handleClose(id, reason) {
@@ -159,6 +184,7 @@ function socketHandle({ io }) {
159
184
  const deviceId = deviceIdMap.get(id);
160
185
  deviceIdMap.delete(id);
161
186
  instanceIdMap.delete(id);
187
+ socketTiers.delete(id);
162
188
  let survivor;
163
189
  if (deviceId) {
164
190
  for (const [socketId, mappedDeviceId] of deviceIdMap) {
@@ -346,9 +372,11 @@ function socketHandle({ io }) {
346
372
  }
347
373
  function handleUserMessage(message, deviceName) {
348
374
  console.log(`${LOG_PREFIX} Message from ${deviceName}: ${message}`);
375
+ if (!canUseDesktop()) return;
349
376
  io.emit("message", `Device ${deviceName} sent message: ${message}`);
350
377
  }
351
378
  function forwardToDashboards(event, message) {
379
+ if (!canUseDesktop()) return;
352
380
  dashboardClients = dashboardClients.filter(
353
381
  (id) => io.sockets.sockets.has(id)
354
382
  );
@@ -360,6 +388,11 @@ function socketHandle({ io }) {
360
388
  }
361
389
  }
362
390
  function withTargetUsers(targetDeviceId, action, actionName) {
391
+ if (!canUseDesktop()) return;
392
+ const admitted = (user) => {
393
+ const tier = socketTiers.get(user.id);
394
+ return tier === "free" || tier === "pro";
395
+ };
363
396
  const deviceName = getDeviceFromDeviceId(targetDeviceId)?.deviceName;
364
397
  if (targetDeviceId === "No devices available") {
365
398
  console.log(
@@ -375,10 +408,10 @@ function socketHandle({ io }) {
375
408
  console.log(
376
409
  `${LOG_PREFIX} Sending to ${deviceUsers.length} devices: ${deviceUsers.map((u) => u.deviceName).join(", ")}`
377
410
  );
378
- deviceUsers.forEach(action);
411
+ deviceUsers.filter(admitted).forEach(action);
379
412
  } else {
380
413
  const targetUser = users.find((user) => user.deviceId === targetDeviceId);
381
- if (targetUser) {
414
+ if (targetUser && admitted(targetUser)) {
382
415
  console.log(
383
416
  `${LOG_PREFIX} Sending to target device ${targetUser.deviceName} (${targetUser.deviceId}) targetDeviceId: ${targetDeviceId}`
384
417
  );
@@ -429,6 +462,27 @@ function socketHandle({ io }) {
429
462
  }
430
463
  }
431
464
  io.on("connection", (socket) => {
465
+ const sendAccess = () => socket.emit("buoy:access", { protocol: 1, ready: canUseDesktop() });
466
+ sendAccess();
467
+ socket.on("buoy:access:request", sendAccess);
468
+ socket.use((_packet, next) => {
469
+ if (_packet[0] === "buoy:access:request") {
470
+ next();
471
+ return;
472
+ }
473
+ if (!canUseDesktop()) {
474
+ sendAccess();
475
+ return;
476
+ }
477
+ const control = _packet[0] === "device-license";
478
+ if (control) {
479
+ const payload = _packet[1] ?? {};
480
+ socketTiers.set(socket.id, tierFromLicensePayload(payload));
481
+ }
482
+ const deviceAdmitted = socketTiers.get(socket.id) === "free" || socketTiers.get(socket.id) === "pro";
483
+ if (control || dashboardClients.includes(socket.id) || deviceAdmitted) next();
484
+ else sendAccess();
485
+ });
432
486
  const {
433
487
  deviceName,
434
488
  deviceId,
@@ -442,18 +496,18 @@ function socketHandle({ io }) {
442
496
  instanceIdMap.set(socket.id, instanceId);
443
497
  }
444
498
  console.log(
445
- `${LOG_PREFIX} New connection - ID: ${socket.id}, Name: ${deviceName || "Unknown Device"}${deviceId ? `, DeviceId: ${deviceId}` : ""}${platform ? `, Platform: ${platform}` : ""}${extraDeviceInfo ? `, ExtraDeviceInfo: ${extraDeviceInfo}` : ""}${envVariables ? `, EnvVariables: ${envVariables}` : ""}`
499
+ `${LOG_PREFIX} New connection - ID: ${socket.id}, Name: ${deviceName || "Unknown Device"}${deviceId ? `, DeviceId: ${deviceId}` : ""}${platform ? `, Platform: ${platform}` : ""}`
446
500
  );
447
501
  addNewUser({
448
502
  id: socket.id,
449
503
  deviceName: deviceName || "Unknown Device Name",
450
504
  deviceId,
451
505
  platform,
452
- extraDeviceInfo,
453
- envVariables,
506
+ extraDeviceInfo: void 0,
507
+ envVariables: void 0,
454
508
  connectionNonce
455
509
  });
456
- if (deviceName === "Dashboard") {
510
+ if (canUseDesktop() && deviceName === "Dashboard") {
457
511
  for (const capabilities of deviceCapabilities.values()) {
458
512
  socket.emit("devtool-capabilities", capabilities);
459
513
  }
@@ -466,25 +520,32 @@ function socketHandle({ io }) {
466
520
  (payload) => {
467
521
  const nextTier = tierFromLicensePayload(payload ?? {});
468
522
  const nextIsPro = nextTier === "pro";
469
- const nextKey = nextTier === "anonymous" ? void 0 : payload?.licenseKey || void 0;
523
+ socketTiers.set(socket.id, nextTier);
470
524
  const user = users.find((u) => u.id === socket.id);
471
525
  if (user) {
472
526
  user.isPro = nextIsPro;
473
527
  user.tier = nextTier;
474
- user.licenseKey = nextKey;
528
+ delete user.licenseKey;
475
529
  }
476
530
  const did = user?.deviceId || deviceIdMap.get(socket.id);
531
+ if (nextTier !== "locked") {
532
+ for (const record of [...users, ...allDevices].filter((record2) => record2.id === socket.id)) {
533
+ record.extraDeviceInfo = redactAccountKeys(extraDeviceInfo);
534
+ record.envVariables = redactAccountKeys(envVariables);
535
+ }
536
+ }
477
537
  if (did) {
538
+ if (nextTier === "locked") deviceCapabilities.delete(did);
478
539
  const device = allDevices.find((d) => d.deviceId === did);
479
540
  if (device) {
480
541
  device.isPro = nextIsPro;
481
542
  device.tier = nextTier;
482
- device.licenseKey = nextKey;
543
+ delete device.licenseKey;
483
544
  }
484
545
  }
485
546
  brokerLog(
486
547
  "debug",
487
- `device-license from ${user?.deviceName || socket.id}: tier=${nextTier}${nextKey ? ", key set" : ""}`,
548
+ `device-license from ${user?.deviceName || socket.id}: tier=${nextTier}`,
488
549
  { socketId: socket.id }
489
550
  );
490
551
  emitAllDevices();
@@ -498,6 +559,7 @@ function socketHandle({ io }) {
498
559
  handleUserMessage(msg, deviceName || "Unknown");
499
560
  });
500
561
  socket.on("devtool-capabilities", (message) => {
562
+ if (!message || !Array.isArray(message.tools) || message.persistentDeviceId !== deviceIdMap.get(socket.id)) return;
501
563
  console.log(
502
564
  `${LOG_PREFIX} Capabilities from ${message.persistentDeviceId}: ${message.tools.map((tool) => `${tool.toolId}@v${tool.version}`).join(", ")}`
503
565
  );
@@ -527,11 +589,13 @@ function socketHandle({ io }) {
527
589
  }
528
590
  });
529
591
  socket.on("devtool-sync", (message) => {
592
+ if (!message || message.persistentDeviceId !== deviceIdMap.get(socket.id)) return;
530
593
  forwardToDashboards("devtool-sync", message);
531
594
  });
532
595
  socket.on(
533
596
  "devtool-action-result",
534
597
  (message) => {
598
+ if (!message || message.persistentDeviceId !== deviceIdMap.get(socket.id)) return;
535
599
  console.log(
536
600
  `${LOG_PREFIX} Action result from ${message.persistentDeviceId}: ${message.toolId}.${message.action} -> ${message.ok ? "ok" : `error: ${message.error}`}`
537
601
  );
@@ -584,6 +648,24 @@ function socketHandle({ io }) {
584
648
  );
585
649
  });
586
650
  });
651
+ return {
652
+ refreshAccess() {
653
+ const ready = canUseDesktop();
654
+ if (!ready) {
655
+ socketTiers.clear();
656
+ deviceCapabilities.clear();
657
+ brokerLogBuffer.length = 0;
658
+ watches = new WatchRegistry();
659
+ for (const device of [...users, ...allDevices]) {
660
+ delete device.licenseKey;
661
+ delete device.envVariables;
662
+ delete device.extraDeviceInfo;
663
+ }
664
+ }
665
+ io.emit("buoy:access", { protocol: 1, ready });
666
+ emitAllDevices();
667
+ }
668
+ };
587
669
  }
588
670
 
589
671
  // src/createBroker.ts
@@ -618,7 +700,7 @@ function createBroker(options = {}) {
618
700
  ...SOCKET_CONFIG,
619
701
  ...options.socketOptions
620
702
  });
621
- socketHandle({ io });
703
+ const routing = socketHandle({ io, canUseDesktop: options.canUseDesktop });
622
704
  httpServer.on("error", (error) => {
623
705
  options.onError?.(error);
624
706
  });
@@ -630,7 +712,7 @@ function createBroker(options = {}) {
630
712
  httpServer.close(() => resolve());
631
713
  });
632
714
  });
633
- return { io, httpServer, port, close };
715
+ return { io, httpServer, port, close, refreshAccess: routing.refreshAccess };
634
716
  }
635
717
  export {
636
718
  DEFAULT_BROKER_PORT,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@buoy-gg/sync-broker",
3
- "version": "7.0.36",
3
+ "version": "7.0.38",
4
4
  "description": "Headless Socket.IO broker for the Buoy devtool sync protocol (v1) — routes messages between devices and dashboard/MCP clients",
5
5
  "main": "dist/index.js",
6
6
  "module": "dist/index.mjs",