viveworker 0.7.0 → 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 +1324 -103
  19. package/scripts/viveworker.mjs +27 -6
  20. package/web/app.css +634 -9
  21. package/web/app.js +1731 -187
  22. package/web/i18n.js +187 -9
  23. package/web/index.html +32 -2
  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,237 @@
1
+ /**
2
+ * web/remote-pairing/keys.js — IndexedDB-backed identity key store (browser).
3
+ *
4
+ * Mirrors the Node-side `scripts/lib/remote-pairing/keys.mjs` API
5
+ * (load / save / ensure / clear) so the rest of the remote-pairing
6
+ * client can consume the same shape regardless of platform. Pure crypto
7
+ * helpers (`generateIdentityKeypair`, `publicFromPrivate`,
8
+ * `fingerprintIdentity`, hex helpers) are re-exported from the shared
9
+ * bundle.
10
+ *
11
+ * Storage model:
12
+ * DB: viveworker-remote-pairing
13
+ * Store: keys
14
+ * Key: "identity"
15
+ * Value: { priv: Uint8Array(32), pub: Uint8Array(32), createdAtMs: number }
16
+ *
17
+ * Threat model (Phase 2 MVP):
18
+ * - IndexedDB is origin-scoped and survives PWA restarts. A user
19
+ * "clear site data" wipes it; we treat that as the device losing
20
+ * its pairing identity (re-pair from LAN required).
21
+ * - Private bytes are stored extractable. Migrating to Web Crypto
22
+ * non-extractable CryptoKey requires switching the handshake's DH
23
+ * calls to Web Crypto's X25519 (Chrome 100+, Safari 17+, Firefox 100+).
24
+ * That's tracked as a future hardening pass — for now filesystem-style
25
+ * origin scoping is what we lean on.
26
+ *
27
+ * The async API tolerates "no IndexedDB" (e.g., Safari private mode in
28
+ * some configs): callers get a thrown error with a clear message and can
29
+ * decide whether to surface it to the user or fall back to in-memory only.
30
+ */
31
+
32
+ // Relative path so the same source resolves correctly under both the bridge
33
+ // (which serves /remote-pairing/keys.js → /remote-pairing.bundle.js) and
34
+ // Node-driven unit tests (which load it from the filesystem).
35
+ import {
36
+ generateIdentityKeypair as bundleGenerateIdentityKeypair,
37
+ publicFromPrivate,
38
+ fingerprintIdentity,
39
+ bytesToHex,
40
+ hexToBytes,
41
+ IDENTITY_KEY_BYTES,
42
+ } from "../remote-pairing.bundle.js";
43
+
44
+ // Re-export the pure helpers so callers don't have to import the bundle
45
+ // directly for everyday operations.
46
+ export {
47
+ publicFromPrivate,
48
+ fingerprintIdentity,
49
+ bytesToHex,
50
+ hexToBytes,
51
+ IDENTITY_KEY_BYTES,
52
+ };
53
+
54
+ const DB_NAME = "viveworker-remote-pairing";
55
+ const DB_VERSION = 1;
56
+ const STORE_NAME = "keys";
57
+ const RECORD_KEY = "identity";
58
+
59
+ // ---------------------------------------------------------------------------
60
+ // IndexedDB plumbing
61
+ // ---------------------------------------------------------------------------
62
+
63
+ /**
64
+ * Open (or upgrade) the database. Cached after first open; safe to call
65
+ * concurrently — only one upgrade transaction will run.
66
+ *
67
+ * @param {{ indexedDB?: IDBFactory }} [options]
68
+ * @returns {Promise<IDBDatabase>}
69
+ */
70
+ function openDB({ indexedDB = globalThis.indexedDB } = {}) {
71
+ if (!indexedDB) {
72
+ return Promise.reject(new Error("IndexedDB not available in this environment"));
73
+ }
74
+ return new Promise((resolve, reject) => {
75
+ const req = indexedDB.open(DB_NAME, DB_VERSION);
76
+ req.onupgradeneeded = () => {
77
+ const db = req.result;
78
+ if (!db.objectStoreNames.contains(STORE_NAME)) {
79
+ db.createObjectStore(STORE_NAME);
80
+ }
81
+ };
82
+ req.onsuccess = () => resolve(req.result);
83
+ req.onerror = () => reject(req.error ?? new Error("indexedDB.open failed"));
84
+ req.onblocked = () => reject(new Error("indexedDB.open blocked by another tab"));
85
+ });
86
+ }
87
+
88
+ /**
89
+ * Run a single object-store transaction. The callback receives the store
90
+ * and returns the IDBRequest whose `result` we should resolve with.
91
+ */
92
+ function runTx(db, mode, fn) {
93
+ return new Promise((resolve, reject) => {
94
+ const tx = db.transaction(STORE_NAME, mode);
95
+ const store = tx.objectStore(STORE_NAME);
96
+ let req;
97
+ try {
98
+ req = fn(store);
99
+ } catch (err) {
100
+ reject(err);
101
+ return;
102
+ }
103
+ tx.oncomplete = () => resolve(req?.result);
104
+ tx.onerror = () => reject(tx.error ?? new Error("transaction failed"));
105
+ tx.onabort = () => reject(tx.error ?? new Error("transaction aborted"));
106
+ });
107
+ }
108
+
109
+ // ---------------------------------------------------------------------------
110
+ // Public API
111
+ // ---------------------------------------------------------------------------
112
+
113
+ /**
114
+ * Generate a fresh keypair (does NOT persist; pair with `saveIdentityKeypair`
115
+ * or use `ensureIdentityKeypair` for the load-or-create path).
116
+ *
117
+ * @returns {{ priv: Uint8Array, pub: Uint8Array }}
118
+ */
119
+ export function generateIdentityKeypair() {
120
+ return bundleGenerateIdentityKeypair();
121
+ }
122
+
123
+ /**
124
+ * Load the persisted keypair, or null if none has been saved yet.
125
+ *
126
+ * @param {{ indexedDB?: IDBFactory }} [options]
127
+ * @returns {Promise<{ priv: Uint8Array, pub: Uint8Array, createdAtMs: number } | null>}
128
+ */
129
+ export async function loadIdentityKeypair(options) {
130
+ const db = await openDB(options);
131
+ try {
132
+ const record = await runTx(db, "readonly", (store) => store.get(RECORD_KEY));
133
+ if (!record) return null;
134
+ const priv = coerceBytes(record.priv);
135
+ const pub = coerceBytes(record.pub);
136
+ if (priv.length !== IDENTITY_KEY_BYTES || pub.length !== IDENTITY_KEY_BYTES) {
137
+ return null; // corrupt; let caller decide whether to reset
138
+ }
139
+ // Sanity check: derived pub should match stored pub. If it diverges
140
+ // (shouldn't happen barring storage corruption), trust the priv.
141
+ const derivedPub = publicFromPrivate(priv);
142
+ if (!equalBytes(derivedPub, pub)) {
143
+ // Best we can do client-side; surface via a console warning so devs
144
+ // notice during testing. Production callers will eventually want to
145
+ // wipe + regenerate.
146
+ console.warn("[remote-pairing/keys] stored pub disagrees with derived pub; trusting private key");
147
+ return { priv, pub: derivedPub, createdAtMs: numberOr(record.createdAtMs, Date.now()) };
148
+ }
149
+ return { priv, pub, createdAtMs: numberOr(record.createdAtMs, Date.now()) };
150
+ } finally {
151
+ db.close();
152
+ }
153
+ }
154
+
155
+ /**
156
+ * Persist a keypair. Overwrites any existing record.
157
+ *
158
+ * @param {{ priv: Uint8Array, pub: Uint8Array }} keypair
159
+ * @param {{ indexedDB?: IDBFactory }} [options]
160
+ */
161
+ export async function saveIdentityKeypair(keypair, options) {
162
+ if (!keypair?.priv || !keypair?.pub) throw new TypeError("keypair { priv, pub } required");
163
+ if (keypair.priv.length !== IDENTITY_KEY_BYTES) throw new RangeError("priv must be 32 bytes");
164
+ if (keypair.pub.length !== IDENTITY_KEY_BYTES) throw new RangeError("pub must be 32 bytes");
165
+
166
+ const db = await openDB(options);
167
+ try {
168
+ const record = {
169
+ priv: copyBytes(keypair.priv),
170
+ pub: copyBytes(keypair.pub),
171
+ createdAtMs: Date.now(),
172
+ };
173
+ await runTx(db, "readwrite", (store) => store.put(record, RECORD_KEY));
174
+ } finally {
175
+ db.close();
176
+ }
177
+ }
178
+
179
+ /**
180
+ * Load the persisted keypair, generating + saving a fresh one if none exists.
181
+ *
182
+ * @param {{ indexedDB?: IDBFactory }} [options]
183
+ * @returns {Promise<{ priv: Uint8Array, pub: Uint8Array, createdAtMs: number }>}
184
+ */
185
+ export async function ensureIdentityKeypair(options) {
186
+ const existing = await loadIdentityKeypair(options);
187
+ if (existing) return existing;
188
+ const fresh = generateIdentityKeypair();
189
+ await saveIdentityKeypair(fresh, options);
190
+ return { ...fresh, createdAtMs: Date.now() };
191
+ }
192
+
193
+ /**
194
+ * Wipe the persisted keypair. Used by the "unpair this device" flow.
195
+ *
196
+ * @param {{ indexedDB?: IDBFactory }} [options]
197
+ */
198
+ export async function clearIdentityKeypair(options) {
199
+ const db = await openDB(options);
200
+ try {
201
+ await runTx(db, "readwrite", (store) => store.delete(RECORD_KEY));
202
+ } finally {
203
+ db.close();
204
+ }
205
+ }
206
+
207
+ // ---------------------------------------------------------------------------
208
+ // Internals
209
+ // ---------------------------------------------------------------------------
210
+
211
+ function coerceBytes(value) {
212
+ if (value instanceof Uint8Array) return value;
213
+ if (value instanceof ArrayBuffer) return new Uint8Array(value);
214
+ if (ArrayBuffer.isView(value)) return new Uint8Array(value.buffer, value.byteOffset, value.byteLength);
215
+ if (Array.isArray(value)) return new Uint8Array(value);
216
+ throw new TypeError(`unsupported byte container: ${typeof value}`);
217
+ }
218
+
219
+ function copyBytes(bytes) {
220
+ // Defensive copy so a caller mutating its array after save doesn't
221
+ // poison the persisted record. (IndexedDB structured-clones, so this
222
+ // is partially redundant — but it costs ~64B and removes a footgun.)
223
+ const u8 = coerceBytes(bytes);
224
+ const out = new Uint8Array(u8.length);
225
+ out.set(u8);
226
+ return out;
227
+ }
228
+
229
+ function equalBytes(a, b) {
230
+ if (a.length !== b.length) return false;
231
+ for (let i = 0; i < a.length; i++) if (a[i] !== b[i]) return false;
232
+ return true;
233
+ }
234
+
235
+ function numberOr(value, fallback) {
236
+ return Number.isFinite(value) ? value : fallback;
237
+ }
@@ -0,0 +1,313 @@
1
+ /**
2
+ * web/remote-pairing/pairing-state.js — localStorage-backed pairing record.
3
+ *
4
+ * After a successful LAN pairing the phone calls
5
+ * `POST /api/remote-pairing/lan-enroll` and gets back enough information
6
+ * to reach the same bridge over the relay when off-LAN:
7
+ *
8
+ * - pairingId (relay slot identifier; opaque to the phone)
9
+ * - relayToken (capability required by the public relay)
10
+ * - bridgePubHex (bridge's X25519 static pubkey — the phone uses
11
+ * it as the responder static for Noise IK)
12
+ * - bridgeFingerprint (canonical "AB:CD:EF…" form for display)
13
+ * - relayUrl (wss://… — destination of the WebSocket dial)
14
+ * - phonePub (echoed; useful for "is this still my keypair?"
15
+ * sanity checks against IndexedDB on bootstrap)
16
+ * - label (whatever the phone sent at enroll time)
17
+ * - addedAtMs (server-side timestamp; phones with skewed clocks
18
+ * still see a coherent "added on" date)
19
+ * - relayTokenUpdatedAtMs (server-side timestamp of the latest relayToken)
20
+ *
21
+ * Why localStorage and not IndexedDB:
22
+ * - The X25519 *private* key lives in IndexedDB (keys.js) because
23
+ * it's the secret half of the identity. This file stores routing
24
+ * capability metadata — including `relayToken`, which can reach the
25
+ * correct relay room but still cannot decrypt or authenticate the Noise
26
+ * channel without the IndexedDB private key. Treat XSS on this origin as
27
+ * device compromise, just like an attacker reading same-origin session
28
+ * state.
29
+ * - localStorage is synchronous, which is convenient on the bootstrap
30
+ * hot path where we want to know "is this phone enrolled?" before
31
+ * we even have a chance to await anything.
32
+ *
33
+ * Single-record assumption:
34
+ * The phone is paired with at most ONE bridge at a time (current PWA
35
+ * design — `bridge URL = origin`). If we later support multi-bridge
36
+ * bookmarking, this becomes an array keyed by origin and the helpers
37
+ * grow a `bridgeOrigin` parameter. Keep callers using the helpers
38
+ * here so that future migration is local.
39
+ *
40
+ * Schema versioning:
41
+ * The `version` field lets us reject + clear unknown shapes on bootstrap.
42
+ * When the format evolves, bump the constant and write a migrator.
43
+ */
44
+
45
+ const STORAGE_KEY = "viveworker.remote-pairing.state";
46
+ const SCHEMA_VERSION = 2;
47
+ const LEGACY_SCHEMA_VERSION = 1;
48
+
49
+ /**
50
+ * @typedef {Object} RemotePairingState
51
+ * @property {number} version schema version (== SCHEMA_VERSION)
52
+ * @property {string} pairingId relay slot identifier
53
+ * @property {string} relayToken relay capability token
54
+ * @property {string} phonePub lowercase 64-hex of the phone's X25519 pub
55
+ * @property {string} phoneFingerprint canonical "AB:CD:EF…" of phonePub
56
+ * @property {string} bridgePubHex lowercase 64-hex of the bridge's X25519 pub
57
+ * @property {string} bridgeFingerprint canonical fingerprint of bridgePubHex
58
+ * @property {string} relayUrl ws:// or wss:// URL the bridge is on
59
+ * @property {string} label user-visible device label
60
+ * @property {number} addedAtMs server-side enroll time
61
+ * @property {number} relayTokenUpdatedAtMs server-side token update time, 0 for legacy records
62
+ */
63
+
64
+ /**
65
+ * Resolve the localStorage instance. Wrapped so tests can pass a stub
66
+ * (e.g. a fake { getItem, setItem, removeItem } object) without monkey-
67
+ * patching globalThis.
68
+ *
69
+ * @returns {Storage | null}
70
+ */
71
+ function getStorage(opts) {
72
+ if (opts?.storage) return opts.storage;
73
+ if (typeof globalThis === "undefined") return null;
74
+ // localStorage may be missing entirely (older Node-based test contexts)
75
+ // or throw on access (some private-mode browsers). Treat both as "no
76
+ // persistent storage" — the caller can decide whether to surface the
77
+ // failure or fall back to in-memory only.
78
+ try {
79
+ return globalThis.localStorage ?? null;
80
+ } catch {
81
+ return null;
82
+ }
83
+ }
84
+
85
+ /**
86
+ * Read the persisted pairing state, or null if there isn't one (or it's
87
+ * malformed / wrong version — both treated identically: caller should
88
+ * re-enroll).
89
+ *
90
+ * @param {{ storage?: Storage }} [opts]
91
+ * @returns {RemotePairingState | null}
92
+ */
93
+ export function loadPairingState(opts) {
94
+ const store = getStorage(opts);
95
+ if (!store) return null;
96
+ let raw;
97
+ try {
98
+ raw = store.getItem(STORAGE_KEY);
99
+ } catch {
100
+ return null;
101
+ }
102
+ if (!raw) return null;
103
+ let parsed;
104
+ try {
105
+ parsed = JSON.parse(raw);
106
+ } catch {
107
+ return null;
108
+ }
109
+ if (!parsed || typeof parsed !== "object") return null;
110
+ if (parsed.version !== SCHEMA_VERSION) return null;
111
+ // Light shape validation — every required string field must be present
112
+ // and non-empty. We don't re-validate hex format here; the bridge owns
113
+ // that and any garbage we'd find here came from a corrupted localStorage.
114
+ const required = [
115
+ "pairingId",
116
+ "relayToken",
117
+ "phonePub",
118
+ "phoneFingerprint",
119
+ "bridgePubHex",
120
+ "bridgeFingerprint",
121
+ "relayUrl",
122
+ ];
123
+ for (const key of required) {
124
+ if (typeof parsed[key] !== "string" || parsed[key].length === 0) {
125
+ return null;
126
+ }
127
+ }
128
+ return {
129
+ version: SCHEMA_VERSION,
130
+ pairingId: parsed.pairingId,
131
+ relayToken: parsed.relayToken,
132
+ phonePub: parsed.phonePub.toLowerCase(),
133
+ phoneFingerprint: parsed.phoneFingerprint,
134
+ bridgePubHex: parsed.bridgePubHex.toLowerCase(),
135
+ bridgeFingerprint: parsed.bridgeFingerprint,
136
+ relayUrl: parsed.relayUrl,
137
+ label: typeof parsed.label === "string" ? parsed.label : "",
138
+ addedAtMs: Number.isFinite(parsed.addedAtMs) ? parsed.addedAtMs : 0,
139
+ relayTokenUpdatedAtMs: Number.isFinite(parsed.relayTokenUpdatedAtMs)
140
+ ? parsed.relayTokenUpdatedAtMs
141
+ : 0,
142
+ };
143
+ }
144
+
145
+ /**
146
+ * Inspect the raw stored pairing state without silently collapsing every
147
+ * non-v2 shape to null. This lets the app distinguish "not enrolled yet"
148
+ * from "legacy v1 record needs a LAN refresh after relayToken hardening".
149
+ *
150
+ * @param {{ storage?: Storage }} [opts]
151
+ * @returns {{
152
+ * status: "ready" | "missing" | "legacy-v1" | "missing-token" | "malformed" | "unsupported-version" | "storage-unavailable",
153
+ * needsEnrollment: boolean,
154
+ * record: RemotePairingState | null,
155
+ * legacyRecord?: object | null,
156
+ * }}
157
+ */
158
+ export function inspectPairingState(opts) {
159
+ const store = getStorage(opts);
160
+ if (!store) {
161
+ return stateStatus("storage-unavailable", false);
162
+ }
163
+
164
+ let raw;
165
+ try {
166
+ raw = store.getItem(STORAGE_KEY);
167
+ } catch {
168
+ return stateStatus("storage-unavailable", false);
169
+ }
170
+ if (!raw) {
171
+ return stateStatus("missing", true);
172
+ }
173
+
174
+ let parsed;
175
+ try {
176
+ parsed = JSON.parse(raw);
177
+ } catch {
178
+ return stateStatus("malformed", true);
179
+ }
180
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
181
+ return stateStatus("malformed", true);
182
+ }
183
+
184
+ if (parsed.version === LEGACY_SCHEMA_VERSION) {
185
+ return {
186
+ ...stateStatus("legacy-v1", true),
187
+ legacyRecord: normalizeLegacyRecordForInspection(parsed),
188
+ };
189
+ }
190
+
191
+ if (parsed.version !== SCHEMA_VERSION) {
192
+ return stateStatus("unsupported-version", true);
193
+ }
194
+
195
+ if (typeof parsed.relayToken !== "string" || parsed.relayToken.length === 0) {
196
+ return stateStatus("missing-token", true);
197
+ }
198
+
199
+ const record = loadPairingState(opts);
200
+ if (!record) {
201
+ return stateStatus("malformed", true);
202
+ }
203
+ return {
204
+ ...stateStatus("ready", false),
205
+ record,
206
+ };
207
+ }
208
+
209
+ /**
210
+ * Persist a pairing state record. Overwrites any existing record (the
211
+ * single-record assumption above — re-enrolling under the same origin
212
+ * replaces the previous bridge).
213
+ *
214
+ * @param {Omit<RemotePairingState, "version"> & { version?: number }} record
215
+ * @param {{ storage?: Storage }} [opts]
216
+ * @returns {boolean} true if written, false if storage is unavailable.
217
+ */
218
+ export function savePairingState(record, opts) {
219
+ const store = getStorage(opts);
220
+ if (!store) return false;
221
+ if (!record || typeof record !== "object") {
222
+ throw new TypeError("savePairingState: record must be an object");
223
+ }
224
+ // Validate inputs synchronously so a buggy caller fails loudly rather
225
+ // than persisting "undefined" strings.
226
+ for (const key of [
227
+ "pairingId",
228
+ "relayToken",
229
+ "phonePub",
230
+ "phoneFingerprint",
231
+ "bridgePubHex",
232
+ "bridgeFingerprint",
233
+ "relayUrl",
234
+ ]) {
235
+ if (typeof record[key] !== "string" || record[key].length === 0) {
236
+ throw new TypeError(`savePairingState: ${key} must be a non-empty string`);
237
+ }
238
+ }
239
+ const out = {
240
+ version: SCHEMA_VERSION,
241
+ pairingId: record.pairingId,
242
+ relayToken: record.relayToken,
243
+ phonePub: record.phonePub.toLowerCase(),
244
+ phoneFingerprint: record.phoneFingerprint,
245
+ bridgePubHex: record.bridgePubHex.toLowerCase(),
246
+ bridgeFingerprint: record.bridgeFingerprint,
247
+ relayUrl: record.relayUrl,
248
+ label: typeof record.label === "string" ? record.label : "",
249
+ addedAtMs: Number.isFinite(record.addedAtMs) ? record.addedAtMs : Date.now(),
250
+ relayTokenUpdatedAtMs: Number.isFinite(record.relayTokenUpdatedAtMs)
251
+ ? record.relayTokenUpdatedAtMs
252
+ : 0,
253
+ };
254
+ try {
255
+ store.setItem(STORAGE_KEY, JSON.stringify(out));
256
+ return true;
257
+ } catch {
258
+ // Storage quota / disabled-by-user / private-mode-throws → swallow.
259
+ // The user can re-trigger enrollment from the settings page later.
260
+ return false;
261
+ }
262
+ }
263
+
264
+ /**
265
+ * Clear the pairing state. Use cases:
266
+ * - User logged out / unpaired the phone
267
+ * - Bridge identity changed (we detect it via bridgePubHex mismatch
268
+ * and need to force a re-enroll)
269
+ * - localStorage held a malformed record (shouldn't happen but
270
+ * `loadPairingState` returning null while `getItem` returns
271
+ * non-empty is a signal we want gone)
272
+ *
273
+ * @param {{ storage?: Storage }} [opts]
274
+ */
275
+ export function clearPairingState(opts) {
276
+ const store = getStorage(opts);
277
+ if (!store) return;
278
+ try {
279
+ store.removeItem(STORAGE_KEY);
280
+ } catch {
281
+ // ignore
282
+ }
283
+ }
284
+
285
+ function stateStatus(status, needsEnrollment) {
286
+ return {
287
+ status,
288
+ needsEnrollment,
289
+ record: null,
290
+ legacyRecord: null,
291
+ };
292
+ }
293
+
294
+ function normalizeLegacyRecordForInspection(parsed) {
295
+ return {
296
+ version: LEGACY_SCHEMA_VERSION,
297
+ pairingId: typeof parsed.pairingId === "string" ? parsed.pairingId : "",
298
+ phonePub: typeof parsed.phonePub === "string" ? parsed.phonePub.toLowerCase() : "",
299
+ phoneFingerprint: typeof parsed.phoneFingerprint === "string" ? parsed.phoneFingerprint : "",
300
+ bridgePubHex: typeof parsed.bridgePubHex === "string" ? parsed.bridgePubHex.toLowerCase() : "",
301
+ bridgeFingerprint: typeof parsed.bridgeFingerprint === "string" ? parsed.bridgeFingerprint : "",
302
+ relayUrl: typeof parsed.relayUrl === "string" ? parsed.relayUrl : "",
303
+ label: typeof parsed.label === "string" ? parsed.label : "",
304
+ addedAtMs: Number.isFinite(parsed.addedAtMs) ? parsed.addedAtMs : 0,
305
+ relayTokenUpdatedAtMs: Number.isFinite(parsed.relayTokenUpdatedAtMs)
306
+ ? parsed.relayTokenUpdatedAtMs
307
+ : 0,
308
+ };
309
+ }
310
+
311
+ // Test-visible constants for tests that want to assert raw key shape.
312
+ export const __STORAGE_KEY = STORAGE_KEY;
313
+ export const __SCHEMA_VERSION = SCHEMA_VERSION;