@le-space/p2pass 0.1.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 (46) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +258 -0
  3. package/dist/backup/registry-backup.d.ts +26 -0
  4. package/dist/backup/registry-backup.js +51 -0
  5. package/dist/identity/identity-service.d.ts +116 -0
  6. package/dist/identity/identity-service.js +524 -0
  7. package/dist/identity/mode-detector.d.ts +29 -0
  8. package/dist/identity/mode-detector.js +124 -0
  9. package/dist/identity/signing-preference.d.ts +30 -0
  10. package/dist/identity/signing-preference.js +55 -0
  11. package/dist/index.d.ts +15 -0
  12. package/dist/index.js +91 -0
  13. package/dist/p2p/setup.d.ts +48 -0
  14. package/dist/p2p/setup.js +283 -0
  15. package/dist/recovery/ipns-key.d.ts +41 -0
  16. package/dist/recovery/ipns-key.js +127 -0
  17. package/dist/recovery/manifest.d.ts +106 -0
  18. package/dist/recovery/manifest.js +243 -0
  19. package/dist/registry/device-registry.d.ts +122 -0
  20. package/dist/registry/device-registry.js +275 -0
  21. package/dist/registry/index.d.ts +3 -0
  22. package/dist/registry/index.js +46 -0
  23. package/dist/registry/manager.d.ts +76 -0
  24. package/dist/registry/manager.js +376 -0
  25. package/dist/registry/pairing-protocol.d.ts +61 -0
  26. package/dist/registry/pairing-protocol.js +653 -0
  27. package/dist/ucan/storacha-auth.d.ts +45 -0
  28. package/dist/ucan/storacha-auth.js +164 -0
  29. package/dist/ui/StorachaFab.svelte +134 -0
  30. package/dist/ui/StorachaFab.svelte.d.ts +23 -0
  31. package/dist/ui/StorachaIntegration.svelte +2467 -0
  32. package/dist/ui/StorachaIntegration.svelte.d.ts +23 -0
  33. package/dist/ui/fonts/dm-mono-400.ttf +0 -0
  34. package/dist/ui/fonts/dm-mono-500.ttf +0 -0
  35. package/dist/ui/fonts/dm-sans-400.ttf +0 -0
  36. package/dist/ui/fonts/dm-sans-500.ttf +0 -0
  37. package/dist/ui/fonts/dm-sans-600.ttf +0 -0
  38. package/dist/ui/fonts/dm-sans-700.ttf +0 -0
  39. package/dist/ui/fonts/epilogue-400.ttf +0 -0
  40. package/dist/ui/fonts/epilogue-500.ttf +0 -0
  41. package/dist/ui/fonts/epilogue-600.ttf +0 -0
  42. package/dist/ui/fonts/epilogue-700.ttf +0 -0
  43. package/dist/ui/fonts/storacha-fonts.css +152 -0
  44. package/dist/ui/storacha-backup.d.ts +44 -0
  45. package/dist/ui/storacha-backup.js +218 -0
  46. package/package.json +112 -0
@@ -0,0 +1,653 @@
1
+ /**
2
+ * libp2p Pairing Protocol for Multi-Device Linking
3
+ *
4
+ * Protocol: /orbitdb/link-device/1.0.0
5
+ *
6
+ * Message flow:
7
+ * Device B → Device A: { type: 'request', identity: { id, credentialId, deviceLabel } }
8
+ * Device A → Device B: { type: 'granted', orbitdbAddress } | { type: 'rejected', reason }
9
+ *
10
+ * Copied from orbitdb-identity-provider-webauthn-did/src/multi-device/pairing-protocol.js
11
+ */
12
+
13
+ import { lpStream } from 'it-length-prefixed-stream';
14
+ import {
15
+ getDeviceByCredentialId,
16
+ getDeviceByDID,
17
+ grantDeviceWriteAccess,
18
+ registerDevice,
19
+ } from './device-registry.js';
20
+ import { peerIdFromString } from '@libp2p/peer-id';
21
+
22
+ export const LINK_DEVICE_PROTOCOL = '/orbitdb/link-device/1.0.0';
23
+
24
+ /**
25
+ * Max multiaddrs to advertise and to try per pairing (sorted best-first). Avoids huge paste blobs,
26
+ * redundant dials, and tripping libp2p {@link https://github.com/libp2p/js-libp2p/blob/main/doc/CONFIGURATION.md connectionManager.maxPeerAddrsToDial}.
27
+ */
28
+ export const PAIRING_HINT_ADDR_CAP = 8;
29
+
30
+ function takeTopPairingMultiaddrs(parsed) {
31
+ const sorted = sortPairingMultiaddrs(filterPairingDialMultiaddrs(parsed));
32
+ if (sorted.length <= PAIRING_HINT_ADDR_CAP) return sorted;
33
+ console.log(
34
+ `[pairing] Using top ${PAIRING_HINT_ADDR_CAP} of ${sorted.length} multiaddrs (rest omitted)`
35
+ );
36
+ return sorted.slice(0, PAIRING_HINT_ADDR_CAP);
37
+ }
38
+
39
+ /**
40
+ * Structured pairing debug — filter the browser console by `[pairing-flow]`.
41
+ * @param {'ALICE'|'BOB'} role
42
+ * @param {string} phase
43
+ * @param {Record<string, unknown>|string} [detail]
44
+ */
45
+ export function pairingFlow(role, phase, detail) {
46
+ if (detail === undefined) {
47
+ console.log(`[pairing-flow] ${role} — ${phase}`);
48
+ } else if (typeof detail === 'string') {
49
+ console.log(`[pairing-flow] ${role} — ${phase}: ${detail}`);
50
+ } else {
51
+ console.log(`[pairing-flow] ${role} — ${phase}`, detail);
52
+ }
53
+ }
54
+
55
+ /**
56
+ * Relay / DCUtR paths often use a limited connection; libp2p requires this to open app streams.
57
+ * Use default negotiateFully (true): half-open negotiation (`negotiateFully: false`) can leave the
58
+ * remote without a registered protocol handler until first I/O — on circuit relays that often
59
+ * ends in stream reset before Alice’s `handle()` runs.
60
+ */
61
+ const LINK_DEVICE_STREAM_OPTS = {
62
+ runOnLimitedConnection: true,
63
+ };
64
+
65
+ /**
66
+ * Must align with {@link LINK_DEVICE_STREAM_OPTS}. Circuit paths are "limited" connections; libp2p’s
67
+ * inbound path rejects the app handler unless `runOnLimitedConnection` is set on the registered
68
+ * protocol — otherwise Bob gets an immediate stream reset with no Alice logs.
69
+ */
70
+ const LINK_DEVICE_HANDLER_OPTS = {
71
+ runOnLimitedConnection: true,
72
+ };
73
+
74
+ /**
75
+ * Order dial candidates so cross-browser linking usually tries stable paths first:
76
+ * public DNS + WSS, then WS/TCP via relay; webrtc-direct and LAN-only last.
77
+ * @param {import('@multiformats/multiaddr').Multiaddr[]} parsed
78
+ */
79
+ /**
80
+ * Strip WebRTC-based multiaddrs from pairing. Browser WebRTC-direct dials often hit
81
+ * "signal timed out" across NATs; relay + WSS/WS is reliable for link-device.
82
+ * @param {import('@multiformats/multiaddr').Multiaddr[]} parsed
83
+ */
84
+ export function filterPairingDialMultiaddrs(parsed) {
85
+ const noWebrtc = parsed.filter((ma) => !ma.toString().toLowerCase().includes('/webrtc'));
86
+ if (noWebrtc.length < parsed.length) {
87
+ console.log(
88
+ `[pairing] Omitting ${parsed.length - noWebrtc.length} WebRTC multiaddr(s); using relay/WebSocket only`
89
+ );
90
+ }
91
+ return noWebrtc.length > 0 ? noWebrtc : parsed;
92
+ }
93
+
94
+ export function sortPairingMultiaddrs(parsed) {
95
+ const score = (ma) => {
96
+ const s = ma.toString().toLowerCase();
97
+ let n = 0;
98
+ if (s.includes('/webrtc')) n -= 100;
99
+ /** Browser↔browser linking is usually relay/circuit; prefer over raw LAN IPs in the path. */
100
+ if (s.includes('/p2p-circuit')) n += 60;
101
+ /** Same-host relay: IPv4 loopback often more reliable than ::1 for WS/circuit in browsers. */
102
+ if (s.includes('/p2p-circuit') && s.includes('/ip4/127.0.0.1/')) n += 35;
103
+ if (s.includes('/p2p-circuit') && s.includes('/ip6/::1/')) n += 12;
104
+ if (s.includes('/wss/')) n += 80;
105
+ if (s.includes('/tcp/443/')) n += 30;
106
+ if (s.includes('/ws/') && !s.includes('wss')) n += 20;
107
+ if (s.includes('/dns4/') || s.includes('/dns6/') || s.includes('/dnsaddr/')) n += 15;
108
+ if (/\/ip4\/(10\.|192\.168\.)/.test(s)) n -= 50;
109
+ if (/\/ip4\/127\./.test(s) && !s.includes('/p2p-circuit')) n -= 50;
110
+ if (/\/ip6\/f[cd][0-9a-f]{2}:/i.test(s)) n -= 50;
111
+ return n;
112
+ };
113
+ return [...parsed].sort((a, b) => score(b) - score(a));
114
+ }
115
+
116
+ /**
117
+ * Open the link-device stream on the connection we just dialed, or dial(peerId) then newStream.
118
+ *
119
+ * After `dial(multiaddr)` succeeds on relay/WS, `dialProtocol(peerId)` calls `dial(peerId)` again
120
+ * and may open a different path (e.g. WebRTC). Prefer `connection.newStream` on the established
121
+ * connection. With peer-id only, `dial(peerId)` + `newStream` matches connection-manager routing
122
+ * but keeps stream options (negotiateFully, runOnLimitedConnection) explicit.
123
+ */
124
+ async function newLinkDeviceStreamWithRetry(
125
+ libp2p,
126
+ deviceAPeerId,
127
+ existingConnection = null,
128
+ maxAttempts = 6
129
+ ) {
130
+ const OPEN_CONN_MS = 20_000;
131
+ let lastErr;
132
+ for (let i = 0; i < maxAttempts; i++) {
133
+ try {
134
+ if (i > 0) {
135
+ const delay = 300 + 400 * (i - 1);
136
+ console.log(
137
+ `[pairing] Retrying link-device stream (${i + 1}/${maxAttempts}) after ${delay}ms...`
138
+ );
139
+ await new Promise((r) => setTimeout(r, delay));
140
+ } else {
141
+ await new Promise((r) => setTimeout(r, existingConnection ? 120 : 200));
142
+ }
143
+ if (existingConnection) {
144
+ return await existingConnection.newStream(LINK_DEVICE_PROTOCOL, LINK_DEVICE_STREAM_OPTS);
145
+ }
146
+ const openSignal =
147
+ typeof AbortSignal !== 'undefined' && AbortSignal.timeout
148
+ ? AbortSignal.timeout(OPEN_CONN_MS)
149
+ : undefined;
150
+ const conn = await libp2p.dial(deviceAPeerId, openSignal ? { signal: openSignal } : {});
151
+ return await conn.newStream(LINK_DEVICE_PROTOCOL, LINK_DEVICE_STREAM_OPTS);
152
+ } catch (e) {
153
+ lastErr = e;
154
+ const msg = e?.message ?? String(e);
155
+ const name = e?.name ?? '';
156
+ const retryable =
157
+ name === 'AbortError' ||
158
+ /abort|aborted|reset|closed|not readable|mux|eof|unexpected end|UnexpectedEOF/i.test(msg);
159
+ if (!retryable || i === maxAttempts - 1) {
160
+ throw e;
161
+ }
162
+ console.warn('[pairing] link-device stream attempt failed:', name || msg);
163
+ }
164
+ }
165
+ throw lastErr;
166
+ }
167
+
168
+ /**
169
+ * Try each multiaddr in order. Avoids a single bad path (e.g. long WebRTC) stalling a batch dial.
170
+ * @param {import('@libp2p/interface').Libp2p} libp2p
171
+ * @param {import('@multiformats/multiaddr').Multiaddr[]} ordered
172
+ */
173
+ async function dialPairingSequential(libp2p, ordered) {
174
+ const PER_ATTEMPT_MS = 25_000;
175
+ let lastErr;
176
+ for (let i = 0; i < ordered.length; i++) {
177
+ const ma = ordered[i];
178
+ try {
179
+ console.log(`[pairing] Dial ${i + 1}/${ordered.length}:`, ma.toString());
180
+ const signal =
181
+ typeof AbortSignal !== 'undefined' && AbortSignal.timeout
182
+ ? AbortSignal.timeout(PER_ATTEMPT_MS)
183
+ : undefined;
184
+ return await libp2p.dial(ma, signal ? { signal } : {});
185
+ } catch (e) {
186
+ lastErr = e;
187
+ console.warn('[pairing] Dial attempt failed:', e?.name, e?.message);
188
+ }
189
+ }
190
+ throw lastErr ?? new Error('No multiaddrs to dial');
191
+ }
192
+
193
+ /**
194
+ * Prefer circuit path when multiple connections exist (browser relay case).
195
+ * @param {import('@libp2p/interface').Libp2p} libp2p
196
+ * @param {import('@libp2p/interface').PeerId} peerId
197
+ */
198
+ function pickExistingConnection(libp2p, peerId) {
199
+ const list = libp2p.getConnections(peerId);
200
+ if (!list?.length) return null;
201
+ const circuit = list.find((c) => (c.remoteAddr?.toString() || '').includes('/p2p-circuit'));
202
+ return circuit ?? list[0];
203
+ }
204
+
205
+ /**
206
+ * Peer-id-only linking: reuse live connection, or dial addresses from peer store (pubsub/identify),
207
+ * then open link-device on that connection. Avoids fragile `dial(peerId)` when discovery failed but
208
+ * addrs are known, or when a connection already exists.
209
+ * @param {import('@libp2p/interface').Libp2p} libp2p
210
+ * @param {import('@libp2p/interface').PeerId} peerId
211
+ */
212
+ async function openLinkDeviceStreamPeerIdOnly(libp2p, peerId) {
213
+ const existing = pickExistingConnection(libp2p, peerId);
214
+ if (existing) {
215
+ pairingFlow('BOB', 'peer-id mode: reusing existing libp2p connection', {
216
+ remoteAddr: existing.remoteAddr?.toString?.(),
217
+ });
218
+ console.log('[pairing] peer-id mode: reusing existing connection to', peerId.toString());
219
+ return await newLinkDeviceStreamWithRetry(libp2p, peerId, existing);
220
+ }
221
+
222
+ let storeMultiaddrs = [];
223
+ try {
224
+ const peerData = await libp2p.peerStore.get(peerId);
225
+ storeMultiaddrs = peerData.addresses.map((a) => a.multiaddr);
226
+ } catch (e) {
227
+ if (e?.name !== 'NotFoundError') {
228
+ console.warn('[pairing] peerStore.get:', e?.message);
229
+ }
230
+ }
231
+
232
+ const forDial = takeTopPairingMultiaddrs(storeMultiaddrs);
233
+ if (forDial.length > 0) {
234
+ pairingFlow('BOB', 'peer-id mode: dialing peer store address(es)', { count: forDial.length });
235
+ console.log('[pairing] peer-id mode: dialing', forDial.length, 'address(es) from peer store');
236
+ let connection;
237
+ try {
238
+ connection = await dialPairingSequential(libp2p, forDial);
239
+ } catch (e) {
240
+ throw new Error(
241
+ `Could not dial Device A from peer store: ${e.message}. ` +
242
+ 'Wait until both sides show a P2P connection, or paste peer info including multiaddrs.'
243
+ );
244
+ }
245
+ return await newLinkDeviceStreamWithRetry(libp2p, peerId, connection);
246
+ }
247
+
248
+ pairingFlow(
249
+ 'BOB',
250
+ 'peer-id mode: no connection and no dialable addrs in peer store — trying dial(peerId)'
251
+ );
252
+ console.warn(
253
+ '[pairing] peer-id mode: no existing connection and no dialable addresses in peer store'
254
+ );
255
+ return await newLinkDeviceStreamWithRetry(libp2p, peerId, null);
256
+ }
257
+
258
+ function decodeMessage(bytes) {
259
+ const raw = typeof bytes.subarray === 'function' ? bytes.subarray() : bytes;
260
+ return JSON.parse(new TextDecoder().decode(raw));
261
+ }
262
+
263
+ function encodeMessage(msg) {
264
+ return new TextEncoder().encode(JSON.stringify(msg));
265
+ }
266
+
267
+ /**
268
+ * Register the link-device handler on Device A (the established device).
269
+ *
270
+ * @param {Object} libp2p - libp2p instance
271
+ * @param {Object} db - The device registry KV database
272
+ * @param {Function} onRequest - async (requestMsg) => 'granted' | 'rejected'
273
+ * @param {Function} [onDeviceLinked] - (deviceEntry) => void
274
+ */
275
+ export async function registerLinkDeviceHandler(libp2p, db, onRequest, onDeviceLinked) {
276
+ console.log(
277
+ '[pairing] Registering handler for protocol:',
278
+ LINK_DEVICE_PROTOCOL,
279
+ 'on peer:',
280
+ libp2p.peerId.toString()
281
+ );
282
+ await libp2p.handle(
283
+ LINK_DEVICE_PROTOCOL,
284
+ async ({ stream, connection }) => {
285
+ const remotePeer = connection?.remotePeer?.toString?.() ?? '?';
286
+ const remoteAddr = connection?.remoteAddr?.toString?.() ?? '?';
287
+ pairingFlow('ALICE', 'libp2p: inbound stream on link-device protocol', {
288
+ ourPeerId: libp2p.peerId.toString(),
289
+ remotePeer,
290
+ remoteAddr,
291
+ direction: connection?.direction,
292
+ });
293
+ console.log('[pairing] Received incoming connection from:', remotePeer);
294
+ const lp = lpStream(stream);
295
+ let result;
296
+
297
+ try {
298
+ console.log('[pairing] Waiting for request message...');
299
+ const request = decodeMessage(await lp.read());
300
+ console.log('[pairing] Received request:', request.type);
301
+
302
+ if (request.type !== 'request') {
303
+ pairingFlow('ALICE', 'ignored: first message was not type=request', {
304
+ type: request.type,
305
+ });
306
+ await stream.close();
307
+ return;
308
+ }
309
+
310
+ const { identity } = request;
311
+ pairingFlow('ALICE', 'decoded request from Bob (length-prefixed JSON)', {
312
+ did: identity?.id,
313
+ deviceLabel: identity?.deviceLabel,
314
+ credentialId: identity?.credentialId
315
+ ? `${String(identity.credentialId).slice(0, 12)}…`
316
+ : null,
317
+ orbitdbIdentityId: identity?.orbitdbIdentityId || null,
318
+ });
319
+ console.log('[pairing] Request identity DID:', identity.id);
320
+ const isKnown =
321
+ (await getDeviceByCredentialId(db, identity.credentialId)) ||
322
+ (await getDeviceByDID(db, identity.id));
323
+
324
+ console.log('[pairing] Is known device:', !!isKnown);
325
+ if (isKnown) {
326
+ pairingFlow('ALICE', 'device already in registry — auto-grant (no approval UI)', {
327
+ did: identity.id,
328
+ });
329
+ console.log('[pairing] Device is known, granting access and triggering callback');
330
+ result = { type: 'granted', orbitdbAddress: db.address };
331
+ if (isKnown && onDeviceLinked) {
332
+ onDeviceLinked({
333
+ credential_id: identity.credentialId,
334
+ public_key: identity.publicKey || null,
335
+ device_label: identity.deviceLabel || 'Linked Device',
336
+ created_at: isKnown.created_at || Date.now(),
337
+ status: 'active',
338
+ ed25519_did: identity.id,
339
+ });
340
+ }
341
+ } else {
342
+ pairingFlow(
343
+ 'ALICE',
344
+ 'unknown device — waiting for onPairingRequest (UI must resolve granted/rejected)'
345
+ );
346
+ const decision = await onRequest(request);
347
+ pairingFlow('ALICE', 'onPairingRequest resolved', { decision });
348
+ console.log('[pairing] Pairing request decision:', decision);
349
+ if (decision === 'granted') {
350
+ console.log('[pairing] Granting write access for DID:', identity.id);
351
+ await grantDeviceWriteAccess(db, identity.id);
352
+ // Also grant write access for Device B's OrbitDB identity (may differ from DID)
353
+ if (identity.orbitdbIdentityId && identity.orbitdbIdentityId !== identity.id) {
354
+ console.log(
355
+ '[pairing] Granting write access for OrbitDB identity:',
356
+ identity.orbitdbIdentityId
357
+ );
358
+ await grantDeviceWriteAccess(db, identity.orbitdbIdentityId);
359
+ }
360
+ console.log('[pairing] Write access granted, registering device...');
361
+ const deviceEntry = {
362
+ credential_id: identity.credentialId,
363
+ public_key: identity.publicKey || null,
364
+ device_label: identity.deviceLabel || 'Unknown Device',
365
+ created_at: Date.now(),
366
+ status: 'active',
367
+ ed25519_did: identity.id,
368
+ };
369
+ try {
370
+ await registerDevice(db, deviceEntry);
371
+ console.log('[pairing] Device registered successfully');
372
+ result = { type: 'granted', orbitdbAddress: db.address };
373
+ onDeviceLinked?.(deviceEntry);
374
+ } catch (registerErr) {
375
+ console.error('[pairing] Failed to register device:', registerErr.message);
376
+ console.log('[pairing] Retrying device registration...');
377
+ await new Promise((resolve) => setTimeout(resolve, 500));
378
+ await registerDevice(db, deviceEntry);
379
+ console.log('[pairing] Device registered successfully on retry');
380
+ result = { type: 'granted', orbitdbAddress: db.address };
381
+ onDeviceLinked?.(deviceEntry);
382
+ }
383
+ } else {
384
+ result = { type: 'rejected', reason: 'User cancelled' };
385
+ }
386
+ }
387
+ } catch (err) {
388
+ console.error('[pairing-protocol] handler error:', err);
389
+ pairingFlow('ALICE', 'handler exception — sending rejected to Bob', {
390
+ error: err?.message,
391
+ });
392
+ result = { type: 'rejected', reason: err.message };
393
+ }
394
+
395
+ try {
396
+ pairingFlow('ALICE', 'writing response on same stream (length-prefixed) → Bob', {
397
+ type: result?.type,
398
+ orbitdbAddress:
399
+ result?.type === 'granted'
400
+ ? String(result.orbitdbAddress || '').slice(0, 48) + '…'
401
+ : undefined,
402
+ reason: result?.reason,
403
+ });
404
+ await lp.write(encodeMessage(result));
405
+ pairingFlow('ALICE', 'response sent; closing stream');
406
+ await stream.close();
407
+ } catch (writeErr) {
408
+ console.warn('[pairing-protocol] error writing response:', writeErr);
409
+ pairingFlow('ALICE', 'failed to write response or close stream', {
410
+ error: writeErr?.message,
411
+ });
412
+ }
413
+ },
414
+ LINK_DEVICE_HANDLER_OPTS
415
+ );
416
+ }
417
+
418
+ /**
419
+ * Unregister the link-device handler from libp2p.
420
+ * @param {Object} libp2p - libp2p instance
421
+ */
422
+ export async function unregisterLinkDeviceHandler(libp2p) {
423
+ await libp2p.unhandle(LINK_DEVICE_PROTOCOL);
424
+ }
425
+
426
+ /**
427
+ * Best-effort OS name when Client Hints are missing (uses UA + navigator.platform).
428
+ * @param {string} ua
429
+ * @param {string} navPlatform
430
+ * @returns {string}
431
+ */
432
+ function guessOsName(ua, navPlatform) {
433
+ if (/iPhone/.test(ua)) return 'iOS';
434
+ if (/iPad/.test(ua)) return 'iPadOS';
435
+ if (/Android/.test(ua)) return 'Android';
436
+ if (/Mac/.test(ua) || /^Mac/i.test(navPlatform)) return 'macOS';
437
+ if (/Windows/.test(ua) || /^Win/i.test(navPlatform)) return 'Windows';
438
+ if (/CrOS/.test(ua)) return 'Chrome OS';
439
+ if (/Linux/.test(ua) || /Linux/i.test(navPlatform)) return 'Linux';
440
+ return '';
441
+ }
442
+
443
+ /**
444
+ * Browser name + major (or Safari minor) version from the User-Agent string.
445
+ * Order matters (e.g. Edge and Opera both contain "Chrome").
446
+ * @param {string} ua
447
+ * @returns {string}
448
+ */
449
+ function parseBrowserLabelFromUserAgent(ua) {
450
+ if (!ua) return '';
451
+ let m = ua.match(/\sEdgiOS\/(\d+)/i);
452
+ if (m) return `Edge ${m[1]}`;
453
+ m = ua.match(/\sEdgA\/(\d+)/i);
454
+ if (m) return `Edge ${m[1]}`;
455
+ m = ua.match(/\sEdg\/(\d+)/);
456
+ if (m) return `Edge ${m[1]}`;
457
+ m = ua.match(/\sEdge\/(\d+)/);
458
+ if (m) return `Edge ${m[1]}`;
459
+ m = ua.match(/\sOPR\/(\d+)/);
460
+ if (m) return `Opera ${m[1]}`;
461
+ m = ua.match(/\sSamsungBrowser\/(\d+)/);
462
+ if (m) return `Samsung Internet ${m[1]}`;
463
+ m = ua.match(/\sCriOS\/(\d+)/);
464
+ if (m) return `Chrome ${m[1]}`;
465
+ m = ua.match(/\sFxiOS\/(\d+)/);
466
+ if (m) return `Firefox ${m[1]}`;
467
+ m = ua.match(/\sBrave\/(\d+)/i);
468
+ if (m) return `Brave ${m[1]}`;
469
+ m = ua.match(/\sChrome\/(\d+)/);
470
+ if (m) return `Chrome ${m[1]}`;
471
+ m = ua.match(/\sFirefox\/(\d+)/);
472
+ if (m) return `Firefox ${m[1]}`;
473
+ if (/Safari\//.test(ua) && !/Chrome|Chromium|Edg|OPR|Brave/i.test(ua)) {
474
+ m = ua.match(/Version\/(\d+(?:\.\d+)?)/);
475
+ if (m) return `Safari ${m[1]}`;
476
+ return 'Safari';
477
+ }
478
+ return '';
479
+ }
480
+
481
+ /**
482
+ * Human-readable device label from navigator (Client Hints OS + legacy platform), UA OS fallback,
483
+ * and browser name/version from {@link navigator.userAgent}.
484
+ * @returns {string}
485
+ */
486
+ export function detectDeviceLabel() {
487
+ if (typeof navigator === 'undefined') return 'Unknown Device';
488
+
489
+ const osFromHints =
490
+ typeof navigator.userAgentData?.platform === 'string'
491
+ ? navigator.userAgentData.platform.trim()
492
+ : '';
493
+ const platRaw = typeof navigator.platform === 'string' ? navigator.platform.trim() : '';
494
+ const navPlatform = platRaw && platRaw !== 'Unknown' ? platRaw : '';
495
+ const ua = navigator.userAgent || '';
496
+
497
+ /** @type {string} */
498
+ let base;
499
+ if (osFromHints && navPlatform) {
500
+ const ol = osFromHints.toLowerCase();
501
+ const pl = navPlatform.toLowerCase();
502
+ if (pl.includes(ol) || ol.includes(pl)) base = osFromHints;
503
+ else base = `${osFromHints} · ${navPlatform}`;
504
+ } else if (osFromHints) {
505
+ base = osFromHints;
506
+ } else if (navPlatform) {
507
+ const osGuess = guessOsName(ua, navPlatform);
508
+ if (osGuess && osGuess.toLowerCase() !== navPlatform.toLowerCase()) {
509
+ base = `${osGuess} · ${navPlatform}`;
510
+ } else {
511
+ base = navPlatform;
512
+ }
513
+ } else if (/iPhone/.test(ua)) base = 'iPhone';
514
+ else if (/iPad/.test(ua)) base = 'iPad';
515
+ else if (/Android/.test(ua)) base = 'Android';
516
+ else if (/Mac/.test(ua)) base = 'Mac';
517
+ else if (/Windows/.test(ua)) base = 'Windows';
518
+ else if (/Linux/.test(ua)) base = 'Linux';
519
+ else base = 'Unknown Device';
520
+
521
+ const browser = parseBrowserLabelFromUserAgent(ua);
522
+ if (browser) return `${base} · ${browser}`;
523
+ return base;
524
+ }
525
+
526
+ /**
527
+ * Device B side: dial Device A and send a pairing request.
528
+ *
529
+ * @param {Object} libp2p - libp2p instance (Device B)
530
+ * @param {string|Object} deviceAPeerId - peerId string or PeerId object of Device A
531
+ * @param {Object} identity - { id, credentialId, publicKey?, deviceLabel? }
532
+ * @param {string[]} [hintMultiaddrs] - Known multiaddrs for Device A (from QR payload)
533
+ * @returns {Promise<{type: 'granted', orbitdbAddress: string}|{type: 'rejected', reason: string}>}
534
+ */
535
+ export async function sendPairingRequest(libp2p, deviceAPeerId, identity, hintMultiaddrs = []) {
536
+ let stream;
537
+
538
+ let peerId;
539
+ if (typeof deviceAPeerId === 'string') {
540
+ peerId = peerIdFromString(deviceAPeerId);
541
+ } else if (deviceAPeerId?.toMultihash) {
542
+ peerId = deviceAPeerId;
543
+ } else if (deviceAPeerId?.id) {
544
+ peerId = peerIdFromString(deviceAPeerId.id);
545
+ } else {
546
+ throw new Error(`Invalid deviceAPeerId: ${JSON.stringify(deviceAPeerId)}`);
547
+ }
548
+
549
+ if (hintMultiaddrs.length > 0) {
550
+ try {
551
+ console.log('[pairing] Attempting to dial with hint multiaddrs:', hintMultiaddrs);
552
+ const { multiaddr } = await import('@multiformats/multiaddr');
553
+ const parsedMultiaddrs = hintMultiaddrs
554
+ .map((a) => {
555
+ try {
556
+ return multiaddr(a);
557
+ } catch (e) {
558
+ console.warn('[pairing] failed to parse multiaddr:', a, e.message);
559
+ return null;
560
+ }
561
+ })
562
+ .filter(Boolean);
563
+
564
+ if (parsedMultiaddrs.length === 0) {
565
+ throw new Error(`No valid multiaddrs for deviceAPeerId: ${deviceAPeerId}`);
566
+ }
567
+
568
+ const forDial = takeTopPairingMultiaddrs(parsedMultiaddrs);
569
+ console.log(
570
+ '[pairing] Dial order (best first):',
571
+ forDial.map((m) => m.toString())
572
+ );
573
+
574
+ let connection;
575
+ try {
576
+ connection = await dialPairingSequential(libp2p, forDial);
577
+ } catch (e) {
578
+ console.error('[pairing] All dial attempts failed. Last error:', e?.name, e?.message);
579
+ throw new Error(`Failed to dial Device A: ${e.message}`);
580
+ }
581
+
582
+ const remoteStr = connection.remotePeer.toString();
583
+ console.log('[pairing] Dial successful, remote:', remoteStr);
584
+ if (remoteStr !== peerId.toString()) {
585
+ console.warn(
586
+ '[pairing] Connection remote peer does not match pasted Device A peerId — check the peer id.',
587
+ { expected: peerId.toString(), actual: remoteStr }
588
+ );
589
+ }
590
+
591
+ try {
592
+ stream = await newLinkDeviceStreamWithRetry(libp2p, peerId, connection);
593
+ console.log('[pairing] Link-device stream ready');
594
+ } catch (e) {
595
+ console.error('[pairing] link-device stream failed (dial OK):', e?.name, e?.message);
596
+ throw new Error(`Failed to open link-device stream: ${e.message}`);
597
+ }
598
+ } catch (e) {
599
+ if (
600
+ e.message?.startsWith('Failed to dial') ||
601
+ e.message?.startsWith('Failed to open link-device')
602
+ ) {
603
+ throw e;
604
+ }
605
+ console.error('[pairing] Pairing transport error:', e?.message);
606
+ throw new Error(`Failed to connect to Device A: ${e.message}`);
607
+ }
608
+ } else {
609
+ try {
610
+ stream = await openLinkDeviceStreamPeerIdOnly(libp2p, peerId);
611
+ console.log('[pairing] Link-device stream ready (peer-id mode)');
612
+ } catch (e) {
613
+ console.error('[pairing] peer-id mode failed:', e?.name, e?.message);
614
+ throw new Error(
615
+ e.message?.startsWith('Could not dial Device A')
616
+ ? e.message
617
+ : `Failed to open link-device stream: ${e.message}`
618
+ );
619
+ }
620
+ }
621
+
622
+ const lp = lpStream(stream);
623
+ const outbound = {
624
+ type: 'request',
625
+ identity: {
626
+ id: identity.id,
627
+ orbitdbIdentityId: identity.orbitdbIdentityId || null,
628
+ credentialId: identity.credentialId,
629
+ publicKey: identity.publicKey || null,
630
+ deviceLabel: identity.deviceLabel || detectDeviceLabel(),
631
+ },
632
+ };
633
+ pairingFlow('BOB', 'sending link-device REQUEST (length-prefixed JSON) to Alice', {
634
+ alicePeerId: peerId.toString(),
635
+ myDid: identity.id,
636
+ deviceLabel: outbound.identity.deviceLabel,
637
+ });
638
+ await lp.write(encodeMessage(outbound));
639
+
640
+ pairingFlow('BOB', 'waiting for RESPONSE from Alice on same stream…');
641
+ const result = decodeMessage(await lp.read());
642
+ pairingFlow('BOB', 'received RESPONSE from Alice', {
643
+ type: result?.type,
644
+ orbitdbAddress:
645
+ result?.type === 'granted'
646
+ ? String(result.orbitdbAddress || '').slice(0, 48) + '…'
647
+ : undefined,
648
+ reason: result?.reason,
649
+ });
650
+ await stream.close();
651
+ pairingFlow('BOB', 'stream closed after pairing RPC complete');
652
+ return result;
653
+ }
@@ -0,0 +1,45 @@
1
+ /**
2
+ * Create a Storacha client using a UCAN principal and delegation proof.
3
+ *
4
+ * @param {any} principal - UCAN-compatible signer (from IdentityService.getPrincipal())
5
+ * @param {any} delegation - Parsed delegation object (from parseDelegation())
6
+ * @returns {Promise<any>} Storacha Client instance with space set
7
+ */
8
+ export function createStorachaClient(principal: any, delegation: any): Promise<any>;
9
+ /**
10
+ * Parse a delegation proof string. Supports multiple formats:
11
+ * - Multibase base64url (u prefix)
12
+ * - Multibase base64 (m prefix)
13
+ * - Raw base64
14
+ * - CAR bytes
15
+ *
16
+ * @param {string} proofString - The delegation proof as a string
17
+ * @returns {Promise<any>} Parsed delegation object
18
+ */
19
+ export function parseDelegation(proofString: string): Promise<any>;
20
+ /**
21
+ * Store a raw delegation string.
22
+ * Uses registry DB if provided, otherwise localStorage fallback.
23
+ *
24
+ * @param {string} delegationBase64
25
+ * @param {Object} [registryDb] - OrbitDB registry database
26
+ * @param {string} [spaceDid] - Storacha space DID (for registry metadata)
27
+ */
28
+ export function storeDelegation(delegationBase64: string, registryDb?: Object, spaceDid?: string): Promise<void>;
29
+ /**
30
+ * Load a stored delegation string.
31
+ * Reads from registry DB if provided, otherwise localStorage.
32
+ *
33
+ * @param {Object} [registryDb] - OrbitDB registry database
34
+ * @returns {Promise<string|null>}
35
+ */
36
+ export function loadStoredDelegation(registryDb?: Object): Promise<string | null>;
37
+ /**
38
+ * Clear stored delegation(s).
39
+ * If registryDb is provided, removes all delegations from it.
40
+ * Otherwise clears from localStorage.
41
+ *
42
+ * @param {Object} [registryDb] - OrbitDB registry database
43
+ * @param {string} [delegationBase64] - specific delegation to remove (if omitted, removes all)
44
+ */
45
+ export function clearStoredDelegation(registryDb?: Object, delegationBase64?: string): Promise<void>;