viveworker 0.7.0-beta.3 → 0.8.0

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 (34) hide show
  1. package/README.md +33 -4
  2. package/package.json +7 -3
  3. package/scripts/lib/remote-pairing/README.md +164 -0
  4. package/scripts/lib/remote-pairing/_browser-bundle-entry.mjs +86 -0
  5. package/scripts/lib/remote-pairing/audit.mjs +122 -0
  6. package/scripts/lib/remote-pairing/bridge-relay-client.mjs +737 -0
  7. package/scripts/lib/remote-pairing/control.mjs +156 -0
  8. package/scripts/lib/remote-pairing/envelope.mjs +224 -0
  9. package/scripts/lib/remote-pairing/http-dispatch.mjs +530 -0
  10. package/scripts/lib/remote-pairing/keys-core.mjs +110 -0
  11. package/scripts/lib/remote-pairing/keys.mjs +181 -0
  12. package/scripts/lib/remote-pairing/noise.mjs +436 -0
  13. package/scripts/lib/remote-pairing/orchestrator.mjs +356 -0
  14. package/scripts/lib/remote-pairing/pairings.mjs +446 -0
  15. package/scripts/lib/remote-pairing/rpc.mjs +381 -0
  16. package/scripts/moltbook-scout-auto.sh +16 -8
  17. package/scripts/share-cli.mjs +14 -130
  18. package/scripts/viveworker-bridge.mjs +1696 -223
  19. package/scripts/viveworker.mjs +27 -6
  20. package/web/app.css +727 -9
  21. package/web/app.js +1810 -232
  22. package/web/i18n.js +207 -17
  23. package/web/index.html +115 -1
  24. package/web/remote-pairing/api-router.js +873 -0
  25. package/web/remote-pairing/keys.js +237 -0
  26. package/web/remote-pairing/pairing-state.js +313 -0
  27. package/web/remote-pairing/rpc-client.js +765 -0
  28. package/web/remote-pairing/transport.js +804 -0
  29. package/web/remote-pairing/wake.js +149 -0
  30. package/web/remote-pairing-test.html +400 -0
  31. package/web/remote-pairing.bundle.js +3 -0
  32. package/web/remote-pairing.bundle.js.LEGAL.txt +15 -0
  33. package/web/remote-pairing.bundle.js.map +7 -0
  34. package/web/sw.js +190 -20
@@ -0,0 +1,356 @@
1
+ /**
2
+ * orchestrator.mjs — High-level startup glue for remote pairing on the bridge.
3
+ *
4
+ * Wires the four pieces:
5
+ * 1. ensureIdentityKeypair (`keys.mjs`) — bridge's static X25519 key
6
+ * 2. loadPairings (`pairings.mjs`) — paired-phones allowlist
7
+ * 3. createHttpDispatch (`http-dispatch.mjs`) — adapt RPC → bridge listener
8
+ * 4. BridgeRelayClient (`bridge-relay-client.mjs`) — open relay connections
9
+ *
10
+ * Plus a cheap fs.watch on the pairings file so adding/removing a pairing
11
+ * via the LAN-pairing flow is picked up live (no bridge restart needed).
12
+ *
13
+ * Usage from `viveworker-bridge.mjs`:
14
+ *
15
+ * import { startRemotePairingRelay } from "./lib/remote-pairing/orchestrator.mjs";
16
+ *
17
+ * const remotePairingHandle = await startRemotePairingRelay({
18
+ * relayUrl: process.env.REMOTE_PAIRING_RELAY_URL,
19
+ * requestListener: bridgeRequestHandler,
20
+ * logger: console,
21
+ * });
22
+ *
23
+ * // …on shutdown:
24
+ * remotePairingHandle.close();
25
+ *
26
+ * The handle exposes `.getStatus()` for the bridge's `/health` or settings
27
+ * UI, and `.reloadNow()` for explicit reloads (e.g. right after the LAN
28
+ * pairing flow writes to the file — saves waiting on the fs.watch debounce).
29
+ */
30
+
31
+ import { promises as fs } from "node:fs";
32
+ import { watch as fsWatch } from "node:fs";
33
+
34
+ import { ensureIdentityKeypair, REMOTE_PAIRING_ENV_FILE } from "./keys.mjs";
35
+ import { loadPairings, markSeenPersisted, REMOTE_PAIRINGS_FILE } from "./pairings.mjs";
36
+ import { createHttpDispatch } from "./http-dispatch.mjs";
37
+ import { BridgeRelayClient } from "./bridge-relay-client.mjs";
38
+ import { bytesToHex } from "./keys-core.mjs";
39
+
40
+ // ---------------------------------------------------------------------------
41
+ // Defaults
42
+ // ---------------------------------------------------------------------------
43
+
44
+ /** Default relay URL — overridable via env or option. */
45
+ export const DEFAULT_RELAY_URL = "wss://pairing.viveworker.com";
46
+
47
+ /** Debounce for fs.watch — multiple events per save are common on macOS. */
48
+ const RELOAD_DEBOUNCE_MS = 250;
49
+
50
+ // ---------------------------------------------------------------------------
51
+ // Public API
52
+ // ---------------------------------------------------------------------------
53
+
54
+ /**
55
+ * @typedef {Object} StartRemotePairingRelayOptions
56
+ * @property {string} [relayUrl] defaults to DEFAULT_RELAY_URL or env
57
+ * @property {import("./http-dispatch.mjs").RequestListener} requestListener
58
+ * @property {string} [identityKeypairFile] overrides REMOTE_PAIRING_ENV_FILE
59
+ * @property {string} [pairingsFile] overrides REMOTE_PAIRINGS_FILE
60
+ * @property {boolean} [enabled] if false, returns a dormant handle
61
+ * that does nothing. Useful for "feature-flag off" without callers
62
+ * having to scatter `if` checks.
63
+ * @property {string} [remoteAddressPrefix] forwarded to http-dispatch
64
+ * @property {number} [responseTimeoutMs] forwarded to http-dispatch
65
+ * @property {{debug?:Function, warn?:Function, error?:Function, info?:Function}} [logger]
66
+ * @property {typeof WebSocket} [WebSocketImpl] defaults to require("ws")
67
+ * @property {number} [pingIntervalMs] forwarded to BridgeRelayClient
68
+ * @property {number[]} [backoffMs] forwarded to BridgeRelayClient
69
+ * @property {number} [handshakeTimeoutMs] forwarded to BridgeRelayClient
70
+ * @property {Uint8Array} [prologue] forwarded to BridgeRelayClient
71
+ * @property {boolean} [watchPairingsFile] defaults to true
72
+ * @property {(event: object) => void | Promise<void>} [auditEventSink]
73
+ */
74
+
75
+ /**
76
+ * @typedef {Object} RemotePairingHandle
77
+ * @property {BridgeRelayClient | null} client
78
+ * @property {{priv: Uint8Array, pub: Uint8Array} | null} identityKeypair
79
+ * @property {() => Promise<void>} reloadNow
80
+ * @property {() => void} kick
81
+ * @property {(topic: string, data?: unknown) => void} broadcast
82
+ * @property {() => RemotePairingStatus} getStatus
83
+ * @property {() => void} close
84
+ */
85
+
86
+ /**
87
+ * @typedef {Object} RemotePairingStatus
88
+ * @property {boolean} enabled
89
+ * @property {string} relayUrl
90
+ * @property {string | null} identityFingerprint
91
+ * @property {string | null} identityPubHex
92
+ * @property {Array<{ pairingId: string, label: string, phonePub: string, state: string,
93
+ * lastSeenAtMs: number | null, channelBindingHex: string | null,
94
+ * phoneFingerprint: string }>} sessions
95
+ */
96
+
97
+ /**
98
+ * @param {StartRemotePairingRelayOptions} opts
99
+ * @returns {Promise<RemotePairingHandle>}
100
+ */
101
+ export async function startRemotePairingRelay(opts) {
102
+ const log = normalizeLogger(opts.logger);
103
+ const enabled = opts.enabled !== false;
104
+
105
+ const handle = {
106
+ client: null,
107
+ identityKeypair: null,
108
+ reloadNow: async () => {},
109
+ kick: () => {},
110
+ broadcast: () => {},
111
+ getStatus: () => ({
112
+ enabled: false,
113
+ relayUrl: "",
114
+ identityFingerprint: null,
115
+ identityPubHex: null,
116
+ sessions: [],
117
+ }),
118
+ close: () => {},
119
+ };
120
+
121
+ if (!enabled) {
122
+ log.info?.("[remote-pairing] disabled — orchestrator returning dormant handle");
123
+ return handle;
124
+ }
125
+
126
+ if (typeof opts.requestListener !== "function") {
127
+ throw new TypeError("startRemotePairingRelay: requestListener required");
128
+ }
129
+
130
+ const relayUrl = (opts.relayUrl ?? process.env.REMOTE_PAIRING_RELAY_URL ?? DEFAULT_RELAY_URL).trim();
131
+ if (!relayUrl) throw new Error("startRemotePairingRelay: empty relayUrl");
132
+ const audit = createAuditEmitter(opts.auditEventSink, log);
133
+
134
+ const identityKeypairFile = opts.identityKeypairFile ?? REMOTE_PAIRING_ENV_FILE;
135
+ const pairingsFile = opts.pairingsFile ?? REMOTE_PAIRINGS_FILE;
136
+
137
+ // 1. Ensure the bridge has a static keypair.
138
+ const identityKeypair = await ensureIdentityKeypair(identityKeypairFile);
139
+ log.info?.(`[remote-pairing] identity pub=${bytesToHex(identityKeypair.pub)}`);
140
+
141
+ // 2. Build the dispatch (adapter from RPC → bridge HTTP handler).
142
+ const dispatch = createHttpDispatch({
143
+ requestListener: opts.requestListener,
144
+ remoteAddressPrefix: opts.remoteAddressPrefix,
145
+ responseTimeoutMs: opts.responseTimeoutMs,
146
+ logger: log,
147
+ });
148
+
149
+ // 3. Resolve a default WebSocketImpl if the caller didn't pass one.
150
+ const WebSocketImpl = opts.WebSocketImpl ?? (await defaultWebSocketImpl());
151
+
152
+ // 4. Construct the relay client. We pre-load the pairings here so the
153
+ // initial sessions spawn at start() time without the client doing its
154
+ // own (potentially-throwing) load.
155
+ const initialPairings = await loadPairings(pairingsFile).catch((err) => {
156
+ log.warn?.(`[remote-pairing] failed to load ${pairingsFile}: ${err.message}`);
157
+ return [];
158
+ });
159
+ log.info?.(`[remote-pairing] loaded ${initialPairings.length} paired phone(s) from ${pairingsFile}`);
160
+
161
+ const client = new BridgeRelayClient({
162
+ relayUrl,
163
+ identityKeypair,
164
+ pairings: initialPairings,
165
+ pairingsFile, // for reload()
166
+ dispatch,
167
+ onSessionState: ({ pairingId, phoneFingerprint, label, deviceId, state, prev }) => {
168
+ if (state === prev) return;
169
+ log.debug?.(`[remote-pairing] ${redactPairingId(pairingId)} ${prev} → ${state}`);
170
+ if (state === "connected") {
171
+ audit({
172
+ type: "session_connected",
173
+ outcome: "success",
174
+ pairingId: redactPairingId(pairingId),
175
+ phoneFingerprint,
176
+ label,
177
+ deviceId,
178
+ state,
179
+ previousState: prev,
180
+ });
181
+ } else if (state === "failed") {
182
+ audit({
183
+ type: "session_failed",
184
+ outcome: "failure",
185
+ pairingId: redactPairingId(pairingId),
186
+ phoneFingerprint,
187
+ label,
188
+ deviceId,
189
+ state,
190
+ previousState: prev,
191
+ });
192
+ } else if (prev === "connected" && state === "disconnected") {
193
+ audit({
194
+ type: "session_disconnected",
195
+ outcome: "info",
196
+ pairingId: redactPairingId(pairingId),
197
+ phoneFingerprint,
198
+ label,
199
+ deviceId,
200
+ state,
201
+ previousState: prev,
202
+ });
203
+ }
204
+ },
205
+ onError: (err, ctx) => {
206
+ log.warn?.(
207
+ `[remote-pairing] error${ctx?.pairingId ? ` (${redactPairingId(ctx.pairingId)})` : ""}: ${err?.message}`,
208
+ );
209
+ audit({
210
+ type: "session_error",
211
+ outcome: "failure",
212
+ pairingId: ctx?.pairingId ? redactPairingId(ctx.pairingId) : "",
213
+ phoneFingerprint: ctx?.phoneFingerprint,
214
+ label: ctx?.label,
215
+ deviceId: ctx?.deviceId,
216
+ reason: err?.message || "remote pairing error",
217
+ });
218
+ },
219
+ onSeen: ({ pairing, atMs, channelBinding }) => {
220
+ log.debug?.(`[remote-pairing] ${redactPairingId(pairing.pairingId)} seen at ${new Date(atMs).toISOString()}`);
221
+ markSeenPersisted(pairing.phonePub, { atMs, channelBinding }, pairingsFile).catch((err) => {
222
+ log.warn?.(`[remote-pairing] last-seen persist failed for ${redactPairingId(pairing.pairingId)}: ${err?.message}`);
223
+ });
224
+ },
225
+ WebSocketImpl,
226
+ pingIntervalMs: opts.pingIntervalMs,
227
+ backoffMs: opts.backoffMs,
228
+ handshakeTimeoutMs: opts.handshakeTimeoutMs,
229
+ prologue: opts.prologue,
230
+ logger: log,
231
+ });
232
+
233
+ await client.start();
234
+
235
+ // 5. Optional fs.watch. Some filesystems / Docker volumes don't fire
236
+ // events; the LAN-pair handler should still call reloadNow() after
237
+ // writing. fs.watch is just a convenience for interactive editing.
238
+ let watcher = null;
239
+ let reloadTimer = null;
240
+ const triggerReload = () => {
241
+ if (reloadTimer) clearTimeout(reloadTimer);
242
+ reloadTimer = setTimeout(async () => {
243
+ reloadTimer = null;
244
+ try {
245
+ await client.reload();
246
+ log.debug?.(`[remote-pairing] reloaded — ${client.getSessions().length} session(s) live`);
247
+ } catch (err) {
248
+ log.warn?.(`[remote-pairing] reload failed: ${err?.message}`);
249
+ }
250
+ }, RELOAD_DEBOUNCE_MS);
251
+ };
252
+
253
+ if (opts.watchPairingsFile !== false) {
254
+ try {
255
+ watcher = fsWatch(pairingsFile, { persistent: false }, () => triggerReload());
256
+ // Some platforms fire ENOENT on first events; a no-op error handler
257
+ // keeps the bridge from crashing if the file is rotated mid-run.
258
+ watcher.on?.("error", (err) => {
259
+ log.warn?.(`[remote-pairing] pairings watcher error: ${err?.message}`);
260
+ });
261
+ } catch (err) {
262
+ log.debug?.(`[remote-pairing] fs.watch unavailable for ${pairingsFile}: ${err?.message}`);
263
+ }
264
+ }
265
+
266
+ // ---- Populate the handle now that everything's wired ----
267
+
268
+ handle.client = client;
269
+ handle.identityKeypair = identityKeypair;
270
+ handle.reloadNow = async () => {
271
+ if (reloadTimer) {
272
+ clearTimeout(reloadTimer);
273
+ reloadTimer = null;
274
+ }
275
+ await client.reload();
276
+ };
277
+ handle.kick = () => client.kick();
278
+ handle.broadcast = (topic, data) => client.broadcast(topic, data);
279
+ handle.getStatus = () => ({
280
+ enabled: true,
281
+ relayUrl,
282
+ identityFingerprint: identityKeypair ? fingerprintPub(identityKeypair.pub) : null,
283
+ identityPubHex: identityKeypair ? bytesToHex(identityKeypair.pub) : null,
284
+ sessions: client.getSessions(),
285
+ });
286
+ handle.close = () => {
287
+ if (reloadTimer) clearTimeout(reloadTimer);
288
+ if (watcher) {
289
+ try { watcher.close(); } catch {}
290
+ }
291
+ client.close();
292
+ };
293
+
294
+ return handle;
295
+ }
296
+
297
+ // ---------------------------------------------------------------------------
298
+ // Helpers
299
+ // ---------------------------------------------------------------------------
300
+
301
+ async function defaultWebSocketImpl() {
302
+ // Lazy-import `ws` so consumers that supply their own implementation (or
303
+ // run in environments without the package — like tests) don't pay the
304
+ // resolution cost.
305
+ try {
306
+ const mod = await import("ws");
307
+ return mod.default ?? mod.WebSocket ?? mod;
308
+ } catch (err) {
309
+ throw new Error(
310
+ `startRemotePairingRelay: couldn't load 'ws' (${err.message}). ` +
311
+ `Install it (npm i ws) or pass WebSocketImpl explicitly.`,
312
+ );
313
+ }
314
+ }
315
+
316
+ function normalizeLogger(logger) {
317
+ if (!logger) return {};
318
+ return {
319
+ debug: typeof logger.debug === "function" ? logger.debug.bind(logger) : undefined,
320
+ warn: typeof logger.warn === "function" ? logger.warn.bind(logger) : undefined,
321
+ error: typeof logger.error === "function" ? logger.error.bind(logger) : undefined,
322
+ info: typeof logger.info === "function" ? logger.info.bind(logger) : undefined,
323
+ };
324
+ }
325
+
326
+ function redactPairingId(pairingId) {
327
+ const value = String(pairingId || "");
328
+ return value ? `pairing:${value.slice(0, 6)}…` : "pairing:unknown";
329
+ }
330
+
331
+ function createAuditEmitter(sink, log) {
332
+ if (typeof sink !== "function") return () => {};
333
+ return (event) => {
334
+ try {
335
+ const maybe = sink(event);
336
+ if (maybe && typeof maybe.then === "function") {
337
+ maybe.catch((err) => {
338
+ log.warn?.(`[remote-pairing] audit write failed: ${err?.message}`);
339
+ });
340
+ }
341
+ } catch (err) {
342
+ log.warn?.(`[remote-pairing] audit write failed: ${err?.message}`);
343
+ }
344
+ };
345
+ }
346
+
347
+ function fingerprintPub(pub) {
348
+ // Reuse the existing fingerprint helper — keeps the hex form consistent
349
+ // with how pairings.mjs labels phones.
350
+ // (Kept inline to avoid a circular import via keys.mjs which itself uses
351
+ // it; orchestrator.mjs is a top-level wiring module so it's fine.)
352
+ return bytesToHex(pub).slice(0, 12).toUpperCase().replace(/(.{4})/g, "$1-").replace(/-$/, "");
353
+ }
354
+
355
+ // Expose this so unit tests can poke at the debounce without sleeping.
356
+ export const __RELOAD_DEBOUNCE_MS = RELOAD_DEBOUNCE_MS;