viveworker 0.7.0 → 0.8.1

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 (54) hide show
  1. package/.agents/plugins/marketplace.json +20 -0
  2. package/README.md +115 -4
  3. package/package.json +13 -3
  4. package/plugins/viveworker-control-plane/.codex-plugin/plugin.json +49 -0
  5. package/plugins/viveworker-control-plane/.mcp.json +11 -0
  6. package/plugins/viveworker-control-plane/assets/viveworker-logo-v2.png +0 -0
  7. package/plugins/viveworker-control-plane/assets/viveworker-logo-v2.svg +39 -0
  8. package/plugins/viveworker-control-plane/skills/viveworker-control-plane/SKILL.md +83 -0
  9. package/scripts/lib/markdown-render.mjs +128 -1
  10. package/scripts/lib/remote-pairing/README.md +164 -0
  11. package/scripts/lib/remote-pairing/_browser-bundle-entry.mjs +86 -0
  12. package/scripts/lib/remote-pairing/audit.mjs +122 -0
  13. package/scripts/lib/remote-pairing/bridge-relay-client.mjs +737 -0
  14. package/scripts/lib/remote-pairing/control.mjs +156 -0
  15. package/scripts/lib/remote-pairing/envelope.mjs +224 -0
  16. package/scripts/lib/remote-pairing/http-dispatch.mjs +530 -0
  17. package/scripts/lib/remote-pairing/keys-core.mjs +110 -0
  18. package/scripts/lib/remote-pairing/keys.mjs +181 -0
  19. package/scripts/lib/remote-pairing/noise.mjs +436 -0
  20. package/scripts/lib/remote-pairing/orchestrator.mjs +356 -0
  21. package/scripts/lib/remote-pairing/pairings.mjs +446 -0
  22. package/scripts/lib/remote-pairing/rpc.mjs +381 -0
  23. package/scripts/mcp-server.mjs +891 -0
  24. package/scripts/moltbook-scout-auto.sh +16 -8
  25. package/scripts/share-cli.mjs +14 -130
  26. package/scripts/stats-cli.mjs +683 -0
  27. package/scripts/viveworker-bridge.mjs +1676 -127
  28. package/scripts/viveworker.mjs +289 -7
  29. package/skills/viveworker-control-plane/SKILL.md +104 -0
  30. package/skills/viveworker-control-plane/agents/openai.yaml +12 -0
  31. package/templates/CLAUDE.viveworker.md +67 -0
  32. package/web/app.css +683 -9
  33. package/web/app.js +1780 -187
  34. package/web/build-id.js +1 -0
  35. package/web/i18n.js +187 -9
  36. package/web/icons/apple-touch-icon.png +0 -0
  37. package/web/icons/viveworker-icon-192.png +0 -0
  38. package/web/icons/viveworker-icon-512.png +0 -0
  39. package/web/icons/viveworker-v-pulse.svg +16 -1
  40. package/web/index.html +32 -2
  41. package/web/remote-pairing/api-router.js +873 -0
  42. package/web/remote-pairing/keys.js +237 -0
  43. package/web/remote-pairing/pairing-state.js +313 -0
  44. package/web/remote-pairing/rpc-client.js +765 -0
  45. package/web/remote-pairing/transport.js +804 -0
  46. package/web/remote-pairing/wake.js +149 -0
  47. package/web/remote-pairing-test.html +400 -0
  48. package/web/remote-pairing.bundle.js +3 -0
  49. package/web/remote-pairing.bundle.js.LEGAL.txt +15 -0
  50. package/web/remote-pairing.bundle.js.map +7 -0
  51. package/web/sw.js +190 -20
  52. package/web/icons/viveworker-beacon-v.svg +0 -19
  53. package/web/icons/viveworker-icon-1024.png +0 -0
  54. package/web/icons/viveworker-v-check.svg +0 -19
@@ -0,0 +1,530 @@
1
+ /**
2
+ * http-dispatch.mjs — Adapt RPC requests to a Node `(req, res) => void`
3
+ * request listener.
4
+ *
5
+ * Why this exists:
6
+ * The bridge already has one big request handler that knows how to serve
7
+ * every PWA endpoint (auth, approvals, threads, share, …). Re-implementing
8
+ * that handler for relay-tunneled traffic would duplicate ~20k lines and
9
+ * guarantee drift. Instead, we synthesize Node IncomingMessage /
10
+ * ServerResponse objects from an RPC request, run the existing handler
11
+ * against them, and capture whatever the handler writes as the RPC
12
+ * response. The handler can't tell the difference between a relay-tunneled
13
+ * call and a LAN-HTTPS one — same headers, same cookies, same body.
14
+ *
15
+ * What the bridge handler actually touches (audited via grep):
16
+ * req.method, req.url, req.headers, req.on('data'|'end'),
17
+ * req.socket?.remoteAddress, req.destroy()
18
+ * res.statusCode, res.setHeader(), res.end()
19
+ *
20
+ * That's the surface this adapter implements. If the bridge starts using
21
+ * other Node http APIs (writeHead, write streaming, getHeader, etc.) we'll
22
+ * extend the synthetic objects rather than fall back to a real TCP loop —
23
+ * the loopback-fetch alternative would force this module to know the
24
+ * bridge's listening port + cert + auth scheme, all of which couples too
25
+ * tightly.
26
+ *
27
+ * Body handling:
28
+ * RPC bodies arrive as a single string (utf8 or base64). We feed the
29
+ * bytes synchronously via one `data` event followed by `end`, which is
30
+ * what `parseFormBody` and similar helpers expect. The body is fully
31
+ * buffered before dispatch — chunked streaming is out of scope until
32
+ * the relay supports it (PWA payloads are JSON / form-encoded, all small).
33
+ */
34
+
35
+ import { Readable } from "node:stream";
36
+
37
+ // ---------------------------------------------------------------------------
38
+ // Types
39
+ // ---------------------------------------------------------------------------
40
+
41
+ /**
42
+ * @typedef {(req: import("node:http").IncomingMessage, res: import("node:http").ServerResponse) => (void | Promise<void>)} RequestListener
43
+ */
44
+
45
+ /**
46
+ * @typedef {Object} HttpDispatchOptions
47
+ * @property {RequestListener} requestListener bridge's HTTP handler
48
+ * @property {string} [remoteAddressPrefix] prepended to the synthetic
49
+ * remoteAddress (e.g. "remote-pair:") so log lines / rate
50
+ * limiters can distinguish relay traffic from LAN traffic.
51
+ * @property {number} [responseTimeoutMs] if > 0, return 504 if the
52
+ * listener doesn't call res.end() within this many ms. Default 0
53
+ * (wait forever; rely on the phone-side AbortSignal).
54
+ * @property {{warn?: Function, error?: Function}} [logger]
55
+ */
56
+
57
+ // ---------------------------------------------------------------------------
58
+ // Path gate — defense in depth
59
+ // ---------------------------------------------------------------------------
60
+ //
61
+ // Noise IK + the phonePub allowlist already authenticate the peer, so in
62
+ // principle the bridge can trust anything that arrives with `fromRelay:
63
+ // true`. We still gate the path here as a belt-and-braces measure: if a
64
+ // phone identity key ever leaks (extractable IndexedDB record on a stolen
65
+ // device, supply-chain XSS in the PWA host, etc.), the blast radius is the
66
+ // reachable HTTP surface, and there's no reason that surface should
67
+ // include LAN-bootstrap or pairing-management endpoints that only make
68
+ // sense over a local-only origin.
69
+ //
70
+ // The rules are intentionally minimal:
71
+ // 1. Only `/api/...` paths can ride the relay. The bridge serves the PWA
72
+ // shell, the SW, static assets, and a few diagnostic pages on other
73
+ // paths — none of those need to be reachable through the relay (the
74
+ // PWA is hosted by the phone OS, not by the bridge over the relay).
75
+ // 2. A short deny list inside `/api/` blocks endpoints whose threat
76
+ // model only makes sense over LAN: pairing enrollment / revoke /
77
+ // LAN session bootstrap. These would already 403 in practice (no
78
+ // session cookie, etc.), but locking them out at the dispatch layer
79
+ // keeps a future relaxation of cookie auth from accidentally opening
80
+ // them.
81
+ //
82
+ // Both checks return a synthetic 403 RPC response — they never invoke the
83
+ // bridge's request listener.
84
+
85
+ const RELAY_DENIED_PATHS = new Set([
86
+ "/api/remote-pairing/lan-enroll",
87
+ "/api/remote-pairing/revoke",
88
+ "/api/remote-pairing/rotate-token",
89
+ "/api/session/pair",
90
+ ]);
91
+
92
+ const RELAY_DENIED_PREFIXES = [
93
+ "/admin/",
94
+ "/internal/",
95
+ "/__",
96
+ "/api/internal/",
97
+ "/api/admin/",
98
+ ];
99
+
100
+ /**
101
+ * @param {string} rawPath
102
+ * @returns {{ allowed: boolean, reason?: string }}
103
+ */
104
+ export function classifyRelayPath(rawPath) {
105
+ const path = String(rawPath || "");
106
+ let pathname;
107
+ try {
108
+ pathname = new URL(path, "http://relay.local/").pathname;
109
+ } catch {
110
+ return { allowed: false, reason: "invalid-path" };
111
+ }
112
+ if (!pathname.startsWith("/api/")) {
113
+ return { allowed: false, reason: "non-api-path" };
114
+ }
115
+ for (const prefix of RELAY_DENIED_PREFIXES) {
116
+ if (pathname.startsWith(prefix)) {
117
+ return { allowed: false, reason: "denied-prefix" };
118
+ }
119
+ }
120
+ if (RELAY_DENIED_PATHS.has(pathname)) {
121
+ return { allowed: false, reason: "denied-path" };
122
+ }
123
+ return { allowed: true };
124
+ }
125
+
126
+ // ---------------------------------------------------------------------------
127
+ // Public API
128
+ // ---------------------------------------------------------------------------
129
+
130
+ /**
131
+ * Build a `dispatch` function suitable for `BridgeRelayClient`. The returned
132
+ * function takes the unwrapped RPC request fields (method, path, headers,
133
+ * body, signal, pairing) and returns `{ status, headers, body, bodyEncoding }`.
134
+ *
135
+ * @param {HttpDispatchOptions} opts
136
+ */
137
+ export function createHttpDispatch(opts) {
138
+ if (typeof opts?.requestListener !== "function") {
139
+ throw new TypeError("createHttpDispatch: requestListener function required");
140
+ }
141
+ const listener = opts.requestListener;
142
+ const addrPrefix = opts.remoteAddressPrefix ?? "remote-pair:";
143
+ const responseTimeoutMs = Math.max(0, Number(opts.responseTimeoutMs ?? 0) || 0);
144
+ const log = opts.logger ?? {};
145
+
146
+ /**
147
+ * @param {{
148
+ * method: string,
149
+ * path: string,
150
+ * headers: Record<string,string>,
151
+ * body?: string,
152
+ * bodyEncoding?: "utf8" | "base64",
153
+ * signal: AbortSignal,
154
+ * pairing: import("./pairings.mjs").Pairing,
155
+ * channelBinding: Uint8Array,
156
+ * }} rpcReq
157
+ */
158
+ return async function httpDispatch(rpcReq) {
159
+ // Defense-in-depth path gate — see RELAY_DENIED_* above.
160
+ const gate = classifyRelayPath(rpcReq.path);
161
+ if (!gate.allowed) {
162
+ log.warn?.(
163
+ `[http-dispatch] denied relay request ` +
164
+ `(${gate.reason}) ${String(rpcReq.method || "").toUpperCase()} ${redactPathForLog(rpcReq.path)}`,
165
+ );
166
+ return {
167
+ status: 403,
168
+ headers: { "content-type": "application/json" },
169
+ body: JSON.stringify({ error: "path-not-allowed-via-relay" }),
170
+ };
171
+ }
172
+
173
+ const bodyBuf = decodeBody(rpcReq.body, rpcReq.bodyEncoding);
174
+ const remoteAddress = `${addrPrefix}${rpcReq.pairing.phoneFingerprint}`;
175
+
176
+ const req = buildSyntheticRequest({
177
+ method: rpcReq.method,
178
+ url: rpcReq.path,
179
+ headers: rpcReq.headers ?? {},
180
+ body: bodyBuf,
181
+ remoteAddress,
182
+ });
183
+
184
+ // Attach relay context so the bridge's auth gates can recognize this
185
+ // as a trusted off-LAN call. The Noise channel binding + pairing
186
+ // identity is the auth here:
187
+ // - HttpOnly session cookies don't ride the relay (the PWA's JS
188
+ // can't read them, the RPC layer doesn't carry them), so cookie
189
+ // auth alone would 401 every relay request.
190
+ // - There's no browser-side CSRF surface — an attacker can't
191
+ // establish a Noise channel without the pairing's static key,
192
+ // so Origin / Referer checks have nothing to add.
193
+ // Bridge code reads `req.viveworker.fromRelay` to take the relay
194
+ // path through readSession() / requireTrustedMutationOrigin().
195
+ req.viveworker = {
196
+ fromRelay: true,
197
+ pairing: rpcReq.pairing,
198
+ channelBinding: rpcReq.channelBinding,
199
+ };
200
+
201
+ const res = new SyntheticResponse(req);
202
+
203
+ // If the caller cancels, surface it to the handler as a destroyed
204
+ // request — most Node-style handlers stop processing on `req.destroy()`.
205
+ const onAbort = () => {
206
+ // Pass no error to destroy() — listeners that care can check
207
+ // req.destroyed; we don't want a stray "error" event for what's
208
+ // really a cooperative cancel.
209
+ try { req.destroy(); } catch {}
210
+ try { res._abort(); } catch {}
211
+ };
212
+ if (rpcReq.signal?.aborted) onAbort();
213
+ else rpcReq.signal?.addEventListener("abort", onAbort, { once: true });
214
+
215
+ // Invoke the listener. Let it throw — the BridgeRelayClient's catch
216
+ // block already converts dispatcher errors into 500s. We do NOT swallow
217
+ // the rejection here.
218
+ let listenerResult;
219
+ try {
220
+ listenerResult = listener(req, res);
221
+ } catch (err) {
222
+ // Synchronous throw before the listener could write anything.
223
+ throw err;
224
+ }
225
+
226
+ // Wait for the listener's response. Two completion paths:
227
+ // - res.end() runs (normal case) → donePromise resolves
228
+ // - listener's own promise rejects → propagate the rejection
229
+ // A listener that returns synchronously hasn't necessarily called
230
+ // res.end() yet (the body may arrive via async req.on('data') events),
231
+ // so we MUST wait on donePromise rather than the listener's return.
232
+ //
233
+ // To detect a misbehaving listener that returns without ever calling
234
+ // res.end(), the caller can pass `responseTimeoutMs`. Without it, we
235
+ // wait indefinitely; the phone-side AbortSignal is the real backstop.
236
+ const listenerPromise = Promise.resolve(listenerResult).catch((err) => {
237
+ // Capture and rethrow once we know the response state. We can't
238
+ // throw from here because that would race with the await below.
239
+ res._listenerError = err;
240
+ });
241
+
242
+ try {
243
+ const racers = [res.donePromise, listenerPromise.then(() => {
244
+ // After the listener settles, give it one microtask to flush any
245
+ // synchronous res.end() inside callbacks, then yield. If donePromise
246
+ // wins by then, great. If not, we fall through to the next check.
247
+ })];
248
+ if (responseTimeoutMs > 0) {
249
+ racers.push(new Promise((resolve) => setTimeout(resolve, responseTimeoutMs)).then(() => {
250
+ res._timedOut = true;
251
+ }));
252
+ }
253
+ // First settle.
254
+ await Promise.race(racers);
255
+
256
+ // If the listener errored and nothing was written, propagate.
257
+ if (res._listenerError && !res._ended) {
258
+ throw res._listenerError;
259
+ }
260
+
261
+ // If the listener returned a value (sync or async) and we didn't
262
+ // get a res.end yet, wait for donePromise — the listener may still
263
+ // be running async work.
264
+ if (!res._ended && !res._timedOut) {
265
+ await res.donePromise;
266
+ }
267
+
268
+ if (!res._ended && res._timedOut) {
269
+ log.warn?.(`[http-dispatch] listener did not respond within ${responseTimeoutMs}ms (path=${rpcReq.path})`);
270
+ return {
271
+ status: 504,
272
+ headers: { "content-type": "application/json" },
273
+ body: JSON.stringify({ error: "bridge-listener-timeout" }),
274
+ };
275
+ }
276
+
277
+ // If the listener errored AFTER writing a partial response, prefer
278
+ // the response (the listener wrote something deliberately); if the
279
+ // error happened before any write, propagate.
280
+ if (res._listenerError && !res._headersSent && !res._chunks.length && !res._ended) {
281
+ throw res._listenerError;
282
+ }
283
+ } finally {
284
+ rpcReq.signal?.removeEventListener?.("abort", onAbort);
285
+ }
286
+
287
+ return res._toRpcResponse();
288
+ };
289
+ }
290
+
291
+ function redactPathForLog(rawPath) {
292
+ const value = String(rawPath || "");
293
+ try {
294
+ const parsed = new URL(value, "http://relay.local/");
295
+ const segments = parsed.pathname.split("/").filter(Boolean).slice(0, 3);
296
+ return `/${segments.join("/")}`;
297
+ } catch {
298
+ return value.split("?")[0].slice(0, 64);
299
+ }
300
+ }
301
+
302
+ // ---------------------------------------------------------------------------
303
+ // Internals — synthetic IncomingMessage / ServerResponse
304
+ // ---------------------------------------------------------------------------
305
+
306
+ /**
307
+ * Build a Readable that quacks like http.IncomingMessage. We use Readable
308
+ * directly (instead of subclassing IncomingMessage) because IncomingMessage
309
+ * insists on a real Socket. The handler only reads:
310
+ * .method .url .headers .socket?.remoteAddress
311
+ * .on('data', ...), .on('end', ...), .on('error', ...)
312
+ * .destroy()
313
+ * which are all available on a Readable + a few attached properties.
314
+ */
315
+ function buildSyntheticRequest({ method, url, headers, body, remoteAddress }) {
316
+ // One-shot Readable that yields the buffered body then ends.
317
+ const stream = Readable.from(body.length > 0 ? [body] : [], { objectMode: false });
318
+ stream.method = method;
319
+ stream.url = url;
320
+ // Lowercase by convention — matches what real http.IncomingMessage does.
321
+ // Our RPC encoder already lowercases, but we don't want to depend on that
322
+ // from inside the dispatch path.
323
+ const lower = {};
324
+ for (const [k, v] of Object.entries(headers)) lower[k.toLowerCase()] = v;
325
+ stream.headers = lower;
326
+ // rawHeaders pairs [k1, v1, k2, v2, …] — some libraries inspect it. We
327
+ // produce it from the lowercased map for consistency.
328
+ const rawHeaders = [];
329
+ for (const [k, v] of Object.entries(lower)) rawHeaders.push(k, v);
330
+ stream.rawHeaders = rawHeaders;
331
+ stream.httpVersion = "1.1";
332
+ stream.httpVersionMajor = 1;
333
+ stream.httpVersionMinor = 1;
334
+ stream.complete = false;
335
+ stream.socket = {
336
+ remoteAddress,
337
+ remoteFamily: "IPv4",
338
+ remotePort: 0,
339
+ localAddress: "127.0.0.1",
340
+ localPort: 0,
341
+ encrypted: true, // Noise channel is encrypted; some auth code checks this
342
+ destroy: () => {},
343
+ };
344
+ // Some handlers use this for keep-alive heuristics; null is harmless.
345
+ stream.connection = stream.socket;
346
+
347
+ // When the stream ends naturally, mark complete so any code that polls
348
+ // for it sees the right state.
349
+ stream.once("end", () => { stream.complete = true; });
350
+
351
+ // Default no-op `error` handler. Real http.IncomingMessage is paired with
352
+ // an HTTP server that catches its errors; ours stands alone, and a
353
+ // listener-less stream that destroy(err)'s would surface as an uncaught
354
+ // exception. We don't want abort-on-cancel to crash the process.
355
+ stream.on("error", () => {});
356
+
357
+ return stream;
358
+ }
359
+
360
+ /**
361
+ * Synthetic ServerResponse. Captures statusCode + headers + body, exposes
362
+ * a `donePromise` that resolves when the listener calls `res.end()`.
363
+ */
364
+ class SyntheticResponse {
365
+ constructor(req) {
366
+ this._req = req;
367
+ this.statusCode = 200;
368
+ this.statusMessage = "";
369
+ /** @type {Record<string,string>} */
370
+ this._headers = {};
371
+ /** @type {Buffer[]} */
372
+ this._chunks = [];
373
+ this._ended = false;
374
+ this._aborted = false;
375
+ this._headersSent = false;
376
+
377
+ this.donePromise = new Promise((resolve) => {
378
+ this._resolveDone = resolve;
379
+ });
380
+ }
381
+
382
+ // ----- Header API -----
383
+
384
+ setHeader(name, value) {
385
+ if (this._ended) return;
386
+ if (Array.isArray(value)) value = value.join(", ");
387
+ this._headers[String(name).toLowerCase()] = String(value);
388
+ }
389
+
390
+ getHeader(name) {
391
+ return this._headers[String(name).toLowerCase()];
392
+ }
393
+
394
+ hasHeader(name) {
395
+ return Object.prototype.hasOwnProperty.call(this._headers, String(name).toLowerCase());
396
+ }
397
+
398
+ removeHeader(name) {
399
+ delete this._headers[String(name).toLowerCase()];
400
+ }
401
+
402
+ getHeaders() {
403
+ return { ...this._headers };
404
+ }
405
+
406
+ writeHead(statusCode, statusMessageOrHeaders, headersMaybe) {
407
+ if (this._ended) return this;
408
+ this.statusCode = statusCode;
409
+ let headers = null;
410
+ if (typeof statusMessageOrHeaders === "string") {
411
+ this.statusMessage = statusMessageOrHeaders;
412
+ headers = headersMaybe ?? null;
413
+ } else {
414
+ headers = statusMessageOrHeaders ?? null;
415
+ }
416
+ if (headers && typeof headers === "object") {
417
+ for (const [k, v] of Object.entries(headers)) this.setHeader(k, v);
418
+ }
419
+ this._headersSent = true;
420
+ return this;
421
+ }
422
+
423
+ flushHeaders() {
424
+ this._headersSent = true;
425
+ }
426
+
427
+ // ----- Body API -----
428
+
429
+ write(chunk, encoding) {
430
+ if (this._ended) return false;
431
+ this._chunks.push(toBuffer(chunk, encoding));
432
+ return true;
433
+ }
434
+
435
+ end(chunk, encoding) {
436
+ if (this._ended) return this;
437
+ if (chunk != null) this._chunks.push(toBuffer(chunk, encoding));
438
+ this._ended = true;
439
+ this._headersSent = true;
440
+ this._resolveDone();
441
+ return this;
442
+ }
443
+
444
+ // ----- Adapter glue -----
445
+
446
+ _abort() {
447
+ // The peer cancelled before the listener wrote res.end(). Mark ended so
448
+ // the wrapper stops waiting; the BridgeRelayClient won't send a
449
+ // response anyway because the AbortSignal already fired.
450
+ if (this._ended) return;
451
+ this._aborted = true;
452
+ this._ended = true;
453
+ this._resolveDone();
454
+ }
455
+
456
+ _toRpcResponse() {
457
+ const buf = Buffer.concat(this._chunks);
458
+ const status = this.statusCode;
459
+ // We always lowercase header keys (matches RPC convention; the
460
+ // RemotePairingTransport doesn't care, the phone-side adapter prefers
461
+ // it).
462
+ const headers = { ...this._headers };
463
+
464
+ // Pick body encoding: utf8 if the bytes are valid UTF-8 AND the
465
+ // content-type isn't obviously binary; otherwise base64. This keeps
466
+ // the wire compact for JSON/HTML responses and round-trips binaries
467
+ // (PDFs, images) safely.
468
+ if (buf.length === 0) {
469
+ return { status, headers };
470
+ }
471
+ const ct = (headers["content-type"] ?? "").toLowerCase();
472
+ const isBinary = isBinaryContentType(ct) || !isValidUtf8(buf);
473
+ if (isBinary) {
474
+ return {
475
+ status,
476
+ headers,
477
+ body: buf.toString("base64"),
478
+ bodyEncoding: "base64",
479
+ };
480
+ }
481
+ return { status, headers, body: buf.toString("utf8"), bodyEncoding: "utf8" };
482
+ }
483
+ }
484
+
485
+ // ---------------------------------------------------------------------------
486
+ // Helpers
487
+ // ---------------------------------------------------------------------------
488
+
489
+ function decodeBody(body, encoding) {
490
+ if (body == null || body === "") return Buffer.alloc(0);
491
+ if (typeof body !== "string") {
492
+ throw new TypeError("rpc body must be a string");
493
+ }
494
+ if (encoding === "base64") return Buffer.from(body, "base64");
495
+ // Default utf8 — the RPC layer already validates this.
496
+ return Buffer.from(body, "utf8");
497
+ }
498
+
499
+ function toBuffer(chunk, encoding) {
500
+ if (chunk == null) return Buffer.alloc(0);
501
+ if (Buffer.isBuffer(chunk)) return chunk;
502
+ if (chunk instanceof Uint8Array) return Buffer.from(chunk.buffer, chunk.byteOffset, chunk.byteLength);
503
+ if (typeof chunk === "string") return Buffer.from(chunk, encoding ?? "utf8");
504
+ throw new TypeError(`unsupported response chunk type: ${typeof chunk}`);
505
+ }
506
+
507
+ function isBinaryContentType(ct) {
508
+ if (!ct) return false;
509
+ // Anything explicitly text/* or json or xml is text. application/octet-stream,
510
+ // image/*, application/pdf, etc. are binary.
511
+ if (ct.startsWith("text/")) return false;
512
+ if (ct.includes("json") || ct.includes("xml") || ct.includes("javascript") ||
513
+ ct.includes("urlencoded") || ct.includes("html")) {
514
+ return false;
515
+ }
516
+ return true;
517
+ }
518
+
519
+ /**
520
+ * Lightweight UTF-8 validation. Buffer doesn't expose a "did this round-trip"
521
+ * helper, so we use TextDecoder in fatal mode and catch the error.
522
+ */
523
+ function isValidUtf8(buf) {
524
+ try {
525
+ new TextDecoder("utf-8", { fatal: true }).decode(buf);
526
+ return true;
527
+ } catch {
528
+ return false;
529
+ }
530
+ }
@@ -0,0 +1,110 @@
1
+ /**
2
+ * keys-core.mjs — Identity key helpers that run in both Node and the browser.
3
+ *
4
+ * Sibling of `keys.mjs` (Node-only persistence). Anything that touches
5
+ * `node:fs`, `node:os`, or `node:path` belongs in keys.mjs; anything pure
6
+ * (encoding, fingerprinting, key derivation) belongs here so the same
7
+ * code path serves the PWA bundle and the bridge.
8
+ *
9
+ * Layered key model (recap — see keys.mjs comment for the full table):
10
+ *
11
+ * Wallet (secp256k1) — wallet UI [other module]
12
+ * Identity (X25519 long-term) — Noise IK static `s` [this file + keys.mjs]
13
+ * Session (X25519 ephemeral) — derived in Noise [noise.mjs]
14
+ *
15
+ * The browser implementation (`web/remote-pairing/keys.js`) layers
16
+ * IndexedDB / non-extractable CryptoKey storage *over* this module.
17
+ */
18
+
19
+ import { x25519 } from "@noble/curves/ed25519";
20
+ import { sha256 } from "@noble/hashes/sha256";
21
+
22
+ // ---------------------------------------------------------------------------
23
+ // Constants
24
+ // ---------------------------------------------------------------------------
25
+
26
+ export const IDENTITY_KEY_BYTES = 32;
27
+
28
+ // ---------------------------------------------------------------------------
29
+ // Encoding helpers
30
+ // ---------------------------------------------------------------------------
31
+
32
+ const HEX_RE = /^[0-9a-fA-F]+$/;
33
+
34
+ export function bytesToHex(bytes) {
35
+ if (!(bytes instanceof Uint8Array)) {
36
+ throw new TypeError("bytesToHex expects Uint8Array");
37
+ }
38
+ let hex = "";
39
+ for (let i = 0; i < bytes.length; i++) {
40
+ hex += bytes[i].toString(16).padStart(2, "0");
41
+ }
42
+ return hex;
43
+ }
44
+
45
+ export function hexToBytes(hex) {
46
+ if (typeof hex !== "string" || hex.length === 0 || hex.length % 2 !== 0 || !HEX_RE.test(hex)) {
47
+ throw new TypeError(`invalid hex: ${typeof hex === "string" ? hex.slice(0, 16) : typeof hex}…`);
48
+ }
49
+ const out = new Uint8Array(hex.length / 2);
50
+ for (let i = 0; i < out.length; i++) {
51
+ out[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);
52
+ }
53
+ return out;
54
+ }
55
+
56
+ // ---------------------------------------------------------------------------
57
+ // Generation
58
+ // ---------------------------------------------------------------------------
59
+
60
+ /**
61
+ * Generate a fresh X25519 identity keypair. Caller owns persistence.
62
+ */
63
+ export function generateIdentityKeypair() {
64
+ const priv = x25519.utils.randomPrivateKey();
65
+ const pub = x25519.getPublicKey(priv);
66
+ return { priv, pub };
67
+ }
68
+
69
+ /**
70
+ * Derive the public key from a stored private key. Microseconds — fine to
71
+ * call on every load instead of persisting `pub` separately.
72
+ */
73
+ export function publicFromPrivate(priv) {
74
+ return x25519.getPublicKey(priv);
75
+ }
76
+
77
+ // ---------------------------------------------------------------------------
78
+ // Fingerprint — short user-visible identifier for the pub key
79
+ // ---------------------------------------------------------------------------
80
+
81
+ /**
82
+ * 12-character base32-ish fingerprint of the public key, formatted as
83
+ * `XXXX-XXXX-XXXX`. Designed for the pairing screen so the human can
84
+ * eyeball-confirm both sides see the same key (defends against MitM
85
+ * during pairing-code flows).
86
+ *
87
+ * Encoding: SHA-256(pub) → first 60 bits → 12 chars from a Crockford-like
88
+ * alphabet (no easily-confused glyphs).
89
+ */
90
+ export function fingerprintIdentity(pub) {
91
+ if (!(pub instanceof Uint8Array)) throw new TypeError("pub must be Uint8Array");
92
+ if (pub.length !== IDENTITY_KEY_BYTES) throw new RangeError("pub must be 32 bytes");
93
+
94
+ const digest = sha256(pub);
95
+ const alphabet = "0123456789ABCDEFGHJKMNPQRSTVWXYZ"; // Crockford-ish (no I/L/O/U)
96
+
97
+ let acc = 0n;
98
+ for (let i = 0; i < 8; i++) acc = (acc << 8n) | BigInt(digest[i]);
99
+ // Take the high 60 bits → 12 base32 chars
100
+ acc >>= 4n;
101
+
102
+ let out = "";
103
+ for (let i = 0; i < 12; i++) {
104
+ const idx = Number(acc & 31n);
105
+ out = alphabet[idx] + out;
106
+ acc >>= 5n;
107
+ }
108
+ // Insert a dash every 4 chars for legibility: XXXX-XXXX-XXXX
109
+ return `${out.slice(0, 4)}-${out.slice(4, 8)}-${out.slice(8, 12)}`;
110
+ }