@le-space/p2pass 0.2.0 → 0.3.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.
package/dist/index.d.ts CHANGED
@@ -1,12 +1,13 @@
1
1
  export const VERSION: "0.1.0";
2
- export { default as StorachaIntegration } from "./ui/StorachaIntegration.svelte";
3
- export { default as StorachaFab } from "./ui/StorachaFab.svelte";
4
2
  export { MultiDeviceManager } from "./registry/manager.js";
3
+ import StorachaIntegration from './ui/StorachaIntegration.svelte';
4
+ import StorachaFab from './ui/StorachaFab.svelte';
5
+ export { StorachaIntegration, StorachaFab };
5
6
  export { IdentityService, hasLocalPasskeyHint } from "./identity/identity-service.js";
6
7
  export { detectSigningMode, getStoredSigningMode } from "./identity/mode-detector.js";
7
8
  export { SIGNING_PREFERENCE_STORAGE_KEY, SIGNING_PREFERENCE_LIST, isSigningPreference, readSigningPreferenceFromStorage, writeSigningPreferenceToStorage, resolveSigningPreference } from "./identity/signing-preference.js";
8
- export { createStorachaClient, parseDelegation, storeDelegation, loadStoredDelegation, clearStoredDelegation } from "./ucan/storacha-auth.js";
9
- export { openDeviceRegistry, registerDevice, listDevices, getDeviceByCredentialId, getDeviceByDID, grantDeviceWriteAccess, revokeDeviceAccess, hashCredentialId, coseToJwk, storeDelegationEntry, listDelegations, getDelegation, removeDelegation, storeArchiveEntry, getArchiveEntry, storeKeypairEntry, getKeypairEntry, listKeypairs } from "./registry/device-registry.js";
9
+ export { createStorachaClient, parseDelegation, storeDelegation, loadStoredDelegation, clearStoredDelegation, formatDelegationsTooltipSummary } from "./ucan/storacha-auth.js";
10
+ export { openDeviceRegistry, registerDevice, listDevices, getDeviceByCredentialId, getDeviceByDID, grantDeviceWriteAccess, revokeDeviceAccess, removeDeviceEntry, delegationCountForDevice, delegationsEntriesForDevice, hashCredentialId, coseToJwk, storeDelegationEntry, listDelegations, getDelegation, removeDelegation, storeArchiveEntry, getArchiveEntry, storeKeypairEntry, getKeypairEntry, listKeypairs } from "./registry/device-registry.js";
10
11
  export { LINK_DEVICE_PROTOCOL, registerLinkDeviceHandler, unregisterLinkDeviceHandler, sendPairingRequest, detectDeviceLabel, sortPairingMultiaddrs, filterPairingDialMultiaddrs, pairingFlow, PAIRING_HINT_ADDR_CAP } from "./registry/pairing-protocol.js";
11
12
  export { setupP2PStack, createLibp2pInstance, createHeliaInstance, cleanupP2PStack } from "./p2p/setup.js";
12
13
  export { listSpaces, getSpaceUsage, listStorachaFiles } from "./ui/storacha-backup.js";
package/dist/index.js CHANGED
@@ -1,9 +1,10 @@
1
1
  // @le-space/p2pass — public API
2
2
  export const VERSION = '0.1.0';
3
3
 
4
- // UI components
5
- export { default as StorachaIntegration } from './ui/StorachaIntegration.svelte';
6
- export { default as StorachaFab } from './ui/StorachaFab.svelte';
4
+ // Explicit default bindings (not `export { default as X } from`) — avoids star-export / default resolution errors in Vite.
5
+ import StorachaIntegration from './ui/StorachaIntegration.svelte';
6
+ import StorachaFab from './ui/StorachaFab.svelte';
7
+ export { StorachaIntegration, StorachaFab };
7
8
 
8
9
  // Identity
9
10
  export { IdentityService, hasLocalPasskeyHint } from './identity/identity-service.js';
@@ -24,6 +25,7 @@ export {
24
25
  storeDelegation,
25
26
  loadStoredDelegation,
26
27
  clearStoredDelegation,
28
+ formatDelegationsTooltipSummary,
27
29
  } from './ucan/storacha-auth.js';
28
30
 
29
31
  // Registry (multi-device + credential storage)
@@ -36,6 +38,9 @@ export {
36
38
  getDeviceByDID,
37
39
  grantDeviceWriteAccess,
38
40
  revokeDeviceAccess,
41
+ removeDeviceEntry,
42
+ delegationCountForDevice,
43
+ delegationsEntriesForDevice,
39
44
  hashCredentialId,
40
45
  coseToJwk,
41
46
  storeDelegationEntry,
@@ -23,7 +23,7 @@ export function openDeviceRegistry(orbitdb: Object, ownerIdentityId: string, add
23
23
  /**
24
24
  * Register a device entry in the registry.
25
25
  * @param {Object} db - OrbitDB KV database
26
- * @param {Object} entry - { credential_id, public_key, device_label, created_at, status, ed25519_did }
26
+ * @param {Object} entry - { credential_id, public_key, device_label, created_at, status, ed25519_did, passkey_kind? }
27
27
  */
28
28
  export function registerDevice(db: Object, entry: Object): Promise<void>;
29
29
  /**
@@ -58,14 +58,46 @@ export function grantDeviceWriteAccess(db: Object, did: string): Promise<void>;
58
58
  * @param {string} did - Ed25519 DID to revoke
59
59
  */
60
60
  export function revokeDeviceAccess(db: Object, did: string): Promise<void>;
61
+ /**
62
+ * Revoke a device's write access and remove its registry row (OrbitDB KV key).
63
+ *
64
+ * @param {Object} db - OrbitDB KV database
65
+ * @param {string} credentialId - device entry credential_id (same string used at registration)
66
+ * @returns {Promise<boolean>} true if an entry was removed
67
+ */
68
+ export function removeDeviceEntry(db: Object, credentialId: string): Promise<boolean>;
69
+ /**
70
+ * UCAN delegations attributed to a device (stored_by_did). Entries without stored_by_did count toward ownerDidForLegacy only.
71
+ *
72
+ * @param {Array<{ stored_by_did?: string }>} delegations
73
+ * @param {string} deviceDid
74
+ * @param {string} [ownerDidForLegacy]
75
+ * @returns {number}
76
+ */
77
+ export function delegationCountForDevice(delegations: Array<{
78
+ stored_by_did?: string;
79
+ }>, deviceDid: string, ownerDidForLegacy?: string): number;
80
+ /**
81
+ * Registry delegation rows attributed to a device (same rules as {@link delegationCountForDevice}).
82
+ *
83
+ * @param {Array<{ stored_by_did?: string, delegation?: string }>} delegations
84
+ * @param {string} deviceDid
85
+ * @param {string} [ownerDidForLegacy]
86
+ * @returns {Array<Record<string, unknown>>}
87
+ */
88
+ export function delegationsEntriesForDevice(delegations: Array<{
89
+ stored_by_did?: string;
90
+ delegation?: string;
91
+ }>, deviceDid: string, ownerDidForLegacy?: string): Array<Record<string, unknown>>;
61
92
  /**
62
93
  * Store a UCAN delegation in the registry.
63
94
  * @param {Object} db - OrbitDB KV database
64
95
  * @param {string} delegationBase64 - raw delegation string
65
96
  * @param {string} [spaceDid] - Storacha space DID
66
97
  * @param {string} [label] - human-readable label
98
+ * @param {string} [storedByDid] - Ed25519 DID of the device that stored this delegation (for per-device UI)
67
99
  */
68
- export function storeDelegationEntry(db: Object, delegationBase64: string, spaceDid?: string, label?: string): Promise<void>;
100
+ export function storeDelegationEntry(db: Object, delegationBase64: string, spaceDid?: string, label?: string, storedByDid?: string): Promise<void>;
69
101
  /**
70
102
  * List all stored UCAN delegations.
71
103
  * @param {Object} db - OrbitDB KV database
@@ -81,7 +81,7 @@ export async function openDeviceRegistry(orbitdb, ownerIdentityId, address = nul
81
81
  /**
82
82
  * Register a device entry in the registry.
83
83
  * @param {Object} db - OrbitDB KV database
84
- * @param {Object} entry - { credential_id, public_key, device_label, created_at, status, ed25519_did }
84
+ * @param {Object} entry - { credential_id, public_key, device_label, created_at, status, ed25519_did, passkey_kind? }
85
85
  */
86
86
  export async function registerDevice(db, entry) {
87
87
  const key = await hashCredentialId(entry.credential_id);
@@ -153,6 +153,72 @@ export async function revokeDeviceAccess(db, did) {
153
153
  }
154
154
  }
155
155
 
156
+ /**
157
+ * Revoke a device's write access and remove its registry row (OrbitDB KV key).
158
+ *
159
+ * @param {Object} db - OrbitDB KV database
160
+ * @param {string} credentialId - device entry credential_id (same string used at registration)
161
+ * @returns {Promise<boolean>} true if an entry was removed
162
+ */
163
+ export async function removeDeviceEntry(db, credentialId) {
164
+ const key = await hashCredentialId(credentialId);
165
+ const entry = await db.get(key);
166
+ if (!entry) return false;
167
+ const did = entry.ed25519_did;
168
+ try {
169
+ if (did) await db.access.revoke('write', did);
170
+ } catch (err) {
171
+ console.warn('[device-registry] revoke write before del:', err?.message);
172
+ }
173
+ await db.del(key);
174
+ return true;
175
+ }
176
+
177
+ /**
178
+ * UCAN delegations attributed to a device (stored_by_did). Entries without stored_by_did count toward ownerDidForLegacy only.
179
+ *
180
+ * @param {Array<{ stored_by_did?: string }>} delegations
181
+ * @param {string} deviceDid
182
+ * @param {string} [ownerDidForLegacy]
183
+ * @returns {number}
184
+ */
185
+ export function delegationCountForDevice(delegations, deviceDid, ownerDidForLegacy) {
186
+ if (!deviceDid) return 0;
187
+ let n = 0;
188
+ for (const d of delegations) {
189
+ if (d.stored_by_did === deviceDid) n++;
190
+ }
191
+ if (ownerDidForLegacy && deviceDid === ownerDidForLegacy) {
192
+ for (const d of delegations) {
193
+ if (!d.stored_by_did) n++;
194
+ }
195
+ }
196
+ return n;
197
+ }
198
+
199
+ /**
200
+ * Registry delegation rows attributed to a device (same rules as {@link delegationCountForDevice}).
201
+ *
202
+ * @param {Array<{ stored_by_did?: string, delegation?: string }>} delegations
203
+ * @param {string} deviceDid
204
+ * @param {string} [ownerDidForLegacy]
205
+ * @returns {Array<Record<string, unknown>>}
206
+ */
207
+ export function delegationsEntriesForDevice(delegations, deviceDid, ownerDidForLegacy) {
208
+ if (!deviceDid) return [];
209
+ /** @type {Array<Record<string, unknown>>} */
210
+ const out = [];
211
+ for (const d of delegations) {
212
+ if (d.stored_by_did === deviceDid) out.push(d);
213
+ }
214
+ if (ownerDidForLegacy && deviceDid === ownerDidForLegacy) {
215
+ for (const d of delegations) {
216
+ if (!d.stored_by_did) out.push(d);
217
+ }
218
+ }
219
+ return out;
220
+ }
221
+
156
222
  // ---------------------------------------------------------------------------
157
223
  // UCAN delegation entries
158
224
  // ---------------------------------------------------------------------------
@@ -163,8 +229,9 @@ export async function revokeDeviceAccess(db, did) {
163
229
  * @param {string} delegationBase64 - raw delegation string
164
230
  * @param {string} [spaceDid] - Storacha space DID
165
231
  * @param {string} [label] - human-readable label
232
+ * @param {string} [storedByDid] - Ed25519 DID of the device that stored this delegation (for per-device UI)
166
233
  */
167
- export async function storeDelegationEntry(db, delegationBase64, spaceDid, label) {
234
+ export async function storeDelegationEntry(db, delegationBase64, spaceDid, label, storedByDid) {
168
235
  const hash = await hashCredentialId(delegationBase64);
169
236
  const key = `delegation:${hash}`;
170
237
  await db.put(key, {
@@ -172,6 +239,7 @@ export async function storeDelegationEntry(db, delegationBase64, spaceDid, label
172
239
  space_did: spaceDid || '',
173
240
  label: label || 'default',
174
241
  created_at: Date.now(),
242
+ stored_by_did: storedByDid || '',
175
243
  });
176
244
  }
177
245
 
@@ -1,3 +1,3 @@
1
1
  export { MultiDeviceManager } from "./manager.js";
2
- export { openDeviceRegistry, registerDevice, listDevices, getDeviceByCredentialId, getDeviceByDID, grantDeviceWriteAccess, revokeDeviceAccess, hashCredentialId, coseToJwk, storeDelegationEntry, listDelegations, getDelegation, removeDelegation, storeArchiveEntry, getArchiveEntry, storeKeypairEntry, getKeypairEntry, listKeypairs } from "./device-registry.js";
2
+ export { openDeviceRegistry, registerDevice, listDevices, getDeviceByCredentialId, getDeviceByDID, grantDeviceWriteAccess, revokeDeviceAccess, removeDeviceEntry, delegationCountForDevice, delegationsEntriesForDevice, hashCredentialId, coseToJwk, storeDelegationEntry, listDelegations, getDelegation, removeDelegation, storeArchiveEntry, getArchiveEntry, storeKeypairEntry, getKeypairEntry, listKeypairs } from "./device-registry.js";
3
3
  export { LINK_DEVICE_PROTOCOL, registerLinkDeviceHandler, unregisterLinkDeviceHandler, sendPairingRequest, detectDeviceLabel, sortPairingMultiaddrs, filterPairingDialMultiaddrs, pairingFlow, PAIRING_HINT_ADDR_CAP } from "./pairing-protocol.js";
@@ -13,6 +13,9 @@ export {
13
13
  getDeviceByDID,
14
14
  grantDeviceWriteAccess,
15
15
  revokeDeviceAccess,
16
+ removeDeviceEntry,
17
+ delegationCountForDevice,
18
+ delegationsEntriesForDevice,
16
19
  hashCredentialId,
17
20
  coseToJwk,
18
21
  // Delegation storage
@@ -59,6 +59,11 @@ export class MultiDeviceManager {
59
59
  listDevices(): Promise<any[]>;
60
60
  syncDevices(): Promise<void>;
61
61
  revokeDevice(did: any): Promise<void>;
62
+ /**
63
+ * Remove a device row from the registry and revoke its OrbitDB write access.
64
+ * @param {string} credentialId - value stored as device entry `credential_id`
65
+ */
66
+ removeLinkedDevice(credentialId: string): Promise<boolean>;
62
67
  processIncomingPairingRequest(requestMsg: any): Promise<{
63
68
  type: string;
64
69
  orbitdbAddress: any;
@@ -13,6 +13,7 @@ import {
13
13
  getDeviceByDID,
14
14
  grantDeviceWriteAccess,
15
15
  revokeDeviceAccess,
16
+ removeDeviceEntry,
16
17
  coseToJwk,
17
18
  } from './device-registry.js';
18
19
 
@@ -140,6 +141,7 @@ export class MultiDeviceManager {
140
141
  created_at: Date.now(),
141
142
  status: 'active',
142
143
  ed25519_did: this._identity.id,
144
+ passkey_kind: this._identity.passkeyKind || null,
143
145
  });
144
146
 
145
147
  await this.syncDevices();
@@ -247,6 +249,7 @@ export class MultiDeviceManager {
247
249
  this._credential?.credentialId || this._credential?.id || this._libp2p.peerId.toString(),
248
250
  publicKey: null,
249
251
  deviceLabel: detectDeviceLabel(),
252
+ passkeyKind: this._identity.passkeyKind || null,
250
253
  },
251
254
  multiaddrs
252
255
  );
@@ -325,6 +328,15 @@ export class MultiDeviceManager {
325
328
  await revokeDeviceAccess(this._devicesDb, did);
326
329
  }
327
330
 
331
+ /**
332
+ * Remove a device row from the registry and revoke its OrbitDB write access.
333
+ * @param {string} credentialId - value stored as device entry `credential_id`
334
+ */
335
+ async removeLinkedDevice(credentialId) {
336
+ if (!this._devicesDb) throw new Error('Device registry not initialized');
337
+ return removeDeviceEntry(this._devicesDb, credentialId);
338
+ }
339
+
328
340
  async processIncomingPairingRequest(requestMsg) {
329
341
  if (!this._devicesDb) throw new Error('Device registry not initialized');
330
342
  const { identity } = requestMsg;
@@ -348,6 +360,7 @@ export class MultiDeviceManager {
348
360
  created_at: Date.now(),
349
361
  status: 'active',
350
362
  ed25519_did: identity.id,
363
+ passkey_kind: identity.passkeyKind || null,
351
364
  });
352
365
  return { type: 'granted', orbitdbAddress: this._dbAddress };
353
366
  }
@@ -42,7 +42,7 @@ export function detectDeviceLabel(): string;
42
42
  *
43
43
  * @param {Object} libp2p - libp2p instance (Device B)
44
44
  * @param {string|Object} deviceAPeerId - peerId string or PeerId object of Device A
45
- * @param {Object} identity - { id, credentialId, publicKey?, deviceLabel? }
45
+ * @param {Object} identity - { id, credentialId, publicKey?, deviceLabel?, passkeyKind? }
46
46
  * @param {string[]} [hintMultiaddrs] - Known multiaddrs for Device A (from QR payload)
47
47
  * @returns {Promise<{type: 'granted', orbitdbAddress: string}|{type: 'rejected', reason: string}>}
48
48
  */
@@ -4,7 +4,7 @@
4
4
  * Protocol: /orbitdb/link-device/1.0.0
5
5
  *
6
6
  * Message flow:
7
- * Device B → Device A: { type: 'request', identity: { id, credentialId, deviceLabel } }
7
+ * Device B → Device A: { type: 'request', identity: { id, credentialId, deviceLabel, passkeyKind?, ... } }
8
8
  * Device A → Device B: { type: 'granted', orbitdbAddress } | { type: 'rejected', reason }
9
9
  *
10
10
  * Copied from orbitdb-identity-provider-webauthn-did/src/multi-device/pairing-protocol.js
@@ -330,12 +330,14 @@ export async function registerLinkDeviceHandler(libp2p, db, onRequest, onDeviceL
330
330
  result = { type: 'granted', orbitdbAddress: db.address };
331
331
  if (isKnown && onDeviceLinked) {
332
332
  onDeviceLinked({
333
+ ...isKnown,
333
334
  credential_id: identity.credentialId,
334
- public_key: identity.publicKey || null,
335
- device_label: identity.deviceLabel || 'Linked Device',
335
+ public_key: identity.publicKey ?? isKnown.public_key ?? null,
336
+ device_label: identity.deviceLabel || isKnown.device_label || 'Linked Device',
336
337
  created_at: isKnown.created_at || Date.now(),
337
338
  status: 'active',
338
339
  ed25519_did: identity.id,
340
+ passkey_kind: identity.passkeyKind || isKnown.passkey_kind || null,
339
341
  });
340
342
  }
341
343
  } else {
@@ -365,6 +367,7 @@ export async function registerLinkDeviceHandler(libp2p, db, onRequest, onDeviceL
365
367
  created_at: Date.now(),
366
368
  status: 'active',
367
369
  ed25519_did: identity.id,
370
+ passkey_kind: identity.passkeyKind || null,
368
371
  };
369
372
  try {
370
373
  await registerDevice(db, deviceEntry);
@@ -528,7 +531,7 @@ export function detectDeviceLabel() {
528
531
  *
529
532
  * @param {Object} libp2p - libp2p instance (Device B)
530
533
  * @param {string|Object} deviceAPeerId - peerId string or PeerId object of Device A
531
- * @param {Object} identity - { id, credentialId, publicKey?, deviceLabel? }
534
+ * @param {Object} identity - { id, credentialId, publicKey?, deviceLabel?, passkeyKind? }
532
535
  * @param {string[]} [hintMultiaddrs] - Known multiaddrs for Device A (from QR payload)
533
536
  * @returns {Promise<{type: 'granted', orbitdbAddress: string}|{type: 'rejected', reason: string}>}
534
537
  */
@@ -628,6 +631,7 @@ export async function sendPairingRequest(libp2p, deviceAPeerId, identity, hintMu
628
631
  credentialId: identity.credentialId,
629
632
  publicKey: identity.publicKey || null,
630
633
  deviceLabel: identity.deviceLabel || detectDeviceLabel(),
634
+ passkeyKind: identity.passkeyKind || null,
631
635
  },
632
636
  };
633
637
  pairingFlow('BOB', 'sending link-device REQUEST (length-prefixed JSON) to Alice', {
@@ -24,8 +24,9 @@ export function parseDelegation(proofString: string): Promise<any>;
24
24
  * @param {string} delegationBase64
25
25
  * @param {Object} [registryDb] - OrbitDB registry database
26
26
  * @param {string} [spaceDid] - Storacha space DID (for registry metadata)
27
+ * @param {string} [storedByDid] - device DID that imported the delegation (per-device counts in UI)
27
28
  */
28
- export function storeDelegation(delegationBase64: string, registryDb?: Object, spaceDid?: string): Promise<void>;
29
+ export function storeDelegation(delegationBase64: string, registryDb?: Object, spaceDid?: string, storedByDid?: string): Promise<void>;
29
30
  /**
30
31
  * Load a stored delegation string.
31
32
  * Reads from registry DB if provided, otherwise localStorage.
@@ -43,3 +44,15 @@ export function loadStoredDelegation(registryDb?: Object): Promise<string | null
43
44
  * @param {string} [delegationBase64] - specific delegation to remove (if omitted, removes all)
44
45
  */
45
46
  export function clearStoredDelegation(registryDb?: Object, delegationBase64?: string): Promise<void>;
47
+ /**
48
+ * Multi-line text suitable for a native HTML `title` on the linked-device UCAN badge.
49
+ * Parses each stored delegation via {@link parseDelegation}.
50
+ *
51
+ * @param {Array<{ delegation?: string, space_did?: string, label?: string }>} entries
52
+ * @returns {Promise<string>}
53
+ */
54
+ export function formatDelegationsTooltipSummary(entries: Array<{
55
+ delegation?: string;
56
+ space_did?: string;
57
+ label?: string;
58
+ }>): Promise<string>;
@@ -98,11 +98,12 @@ export async function parseDelegation(proofString) {
98
98
  * @param {string} delegationBase64
99
99
  * @param {Object} [registryDb] - OrbitDB registry database
100
100
  * @param {string} [spaceDid] - Storacha space DID (for registry metadata)
101
+ * @param {string} [storedByDid] - device DID that imported the delegation (per-device counts in UI)
101
102
  */
102
- export async function storeDelegation(delegationBase64, registryDb, spaceDid) {
103
+ export async function storeDelegation(delegationBase64, registryDb, spaceDid, storedByDid) {
103
104
  if (registryDb) {
104
105
  try {
105
- await storeDelegationEntry(registryDb, delegationBase64, spaceDid);
106
+ await storeDelegationEntry(registryDb, delegationBase64, spaceDid, undefined, storedByDid);
106
107
  console.log('[storacha] Delegation stored in registry DB');
107
108
  return;
108
109
  } catch (err) {
@@ -162,3 +163,120 @@ export async function clearStoredDelegation(registryDb, delegationBase64) {
162
163
  localStorage.removeItem(STORAGE_KEY_DELEGATION);
163
164
  }
164
165
  }
166
+
167
+ /** Max characters for native `title` tooltips (browser-dependent display). */
168
+ const DELEGATION_TOOLTIP_MAX = 1200;
169
+
170
+ /**
171
+ * @param {string} s
172
+ * @param {number} max
173
+ */
174
+ function truncateMiddle(s, max) {
175
+ if (s.length <= max) return s;
176
+ const half = Math.floor((max - 1) / 2);
177
+ return `${s.slice(0, half)}…${s.slice(s.length - half)}`;
178
+ }
179
+
180
+ /**
181
+ * @param {unknown} p - UCANTO principal or string
182
+ */
183
+ function principalDid(p) {
184
+ if (p == null) return '—';
185
+ if (typeof p === 'string') return p;
186
+ if (typeof p === 'object' && p !== null && 'did' in p && typeof p.did === 'function') {
187
+ try {
188
+ return /** @type {{ did: () => string }} */ (p).did();
189
+ } catch {
190
+ return String(p);
191
+ }
192
+ }
193
+ return String(p);
194
+ }
195
+
196
+ /**
197
+ * @param {unknown} exp - UCAN expiration (seconds or ms since epoch)
198
+ */
199
+ function formatUcanExpiration(exp) {
200
+ if (exp == null) return null;
201
+ const n = typeof exp === 'bigint' ? Number(exp) : Number(exp);
202
+ if (!Number.isFinite(n) || n <= 0) return null;
203
+ const ms = n > 1e12 ? n : n * 1000;
204
+ try {
205
+ return new Date(ms).toUTCString();
206
+ } catch {
207
+ return String(exp);
208
+ }
209
+ }
210
+
211
+ /**
212
+ * @param {any} d - Parsed delegation from {@link parseDelegation}
213
+ * @returns {string}
214
+ */
215
+ function summarizeParsedDelegationForTooltip(d) {
216
+ const lines = [];
217
+ lines.push(`Issuer: ${truncateMiddle(principalDid(d?.issuer), 64)}`);
218
+ lines.push(`Audience: ${truncateMiddle(principalDid(d?.audience), 64)}`);
219
+ const expStr = formatUcanExpiration(d?.expiration);
220
+ if (expStr) lines.push(`Expires: ${expStr}`);
221
+ const nbStr = formatUcanExpiration(d?.notBefore);
222
+ if (nbStr) lines.push(`Not before: ${nbStr}`);
223
+ const caps = d?.capabilities;
224
+ if (Array.isArray(caps) && caps.length > 0) {
225
+ const shown = caps.slice(0, 6).map((c) => {
226
+ const can = c?.can ?? '?';
227
+ const w = c?.with != null ? String(c.with) : '—';
228
+ return `${can} → ${truncateMiddle(w, 48)}`;
229
+ });
230
+ let capBlock = shown.join('\n');
231
+ if (caps.length > 6) capBlock += `\n… +${caps.length - 6} more`;
232
+ lines.push(`Capabilities:\n${capBlock}`);
233
+ }
234
+ const facts = d?.facts;
235
+ if (Array.isArray(facts) && facts.length > 0) {
236
+ lines.push(`Facts: ${facts.length} attached`);
237
+ }
238
+ try {
239
+ if (d?.cid != null) lines.push(`Root CID: ${truncateMiddle(String(d.cid), 72)}`);
240
+ } catch {
241
+ /* ignore */
242
+ }
243
+ return lines.join('\n');
244
+ }
245
+
246
+ /**
247
+ * Multi-line text suitable for a native HTML `title` on the linked-device UCAN badge.
248
+ * Parses each stored delegation via {@link parseDelegation}.
249
+ *
250
+ * @param {Array<{ delegation?: string, space_did?: string, label?: string }>} entries
251
+ * @returns {Promise<string>}
252
+ */
253
+ export async function formatDelegationsTooltipSummary(entries) {
254
+ if (!Array.isArray(entries) || entries.length === 0) {
255
+ return 'No UCAN delegations';
256
+ }
257
+ const blocks = [];
258
+ for (let i = 0; i < entries.length; i++) {
259
+ const e = entries[i];
260
+ const raw = e?.delegation;
261
+ if (typeof raw !== 'string' || !raw.trim()) continue;
262
+ let head =
263
+ entries.length > 1 ? `Delegation ${i + 1} of ${entries.length}` : 'UCAN delegation';
264
+ if (e.space_did) head += ` · Space ${truncateMiddle(e.space_did, 40)}`;
265
+ if (e.label && e.label !== 'default') head += ` · ${e.label}`;
266
+ try {
267
+ const parsed = await parseDelegation(raw);
268
+ blocks.push(`${head}\n${summarizeParsedDelegationForTooltip(parsed)}`);
269
+ } catch (err) {
270
+ const msg =
271
+ err && typeof err === 'object' && 'message' in err
272
+ ? String(/** @type {{ message: string }} */ (err).message)
273
+ : String(err);
274
+ blocks.push(`${head}\n(parse failed: ${truncateMiddle(msg, 100)})`);
275
+ }
276
+ }
277
+ if (blocks.length === 0) return 'No valid delegation payloads';
278
+ const joined = blocks.join('\n\n────────\n\n');
279
+ return joined.length > DELEGATION_TOOLTIP_MAX
280
+ ? truncateMiddle(joined, DELEGATION_TOOLTIP_MAX)
281
+ : joined;
282
+ }
@@ -13,16 +13,41 @@
13
13
  libp2p = null,
14
14
  preferWorkerMode = false,
15
15
  signingPreference = null,
16
+ /** When false, hide the floating FAB (e.g. host app opens the panel from the footer). */
17
+ fabVisible = true,
18
+ /**
19
+ * When set, panel open state is synced with this store so the host can toggle from a footer control.
20
+ * @type {import('svelte/store').Writable<boolean> | null}
21
+ */
22
+ panelOpenStore = null,
16
23
  } = $props();
17
24
 
18
25
  let showPanel = $state(false);
19
26
  let isHovered = $state(false);
27
+
28
+ function setPanelOpen(/** @type {boolean} */ v) {
29
+ showPanel = v;
30
+ panelOpenStore?.set(v);
31
+ }
32
+
33
+ function togglePanel() {
34
+ setPanelOpen(!showPanel);
35
+ }
36
+
37
+ $effect(() => {
38
+ if (!panelOpenStore) return;
39
+ const unsub = panelOpenStore.subscribe((v) => {
40
+ showPanel = v;
41
+ });
42
+ return unsub;
43
+ });
20
44
  </script>
21
45
 
22
46
  <!-- Floating Storacha FAB Button -->
47
+ {#if fabVisible}
23
48
  <button
24
49
  data-testid="storacha-fab-toggle"
25
- onclick={() => (showPanel = !showPanel)}
50
+ onclick={() => togglePanel()}
26
51
  onmouseenter={() => (isHovered = true)}
27
52
  onmouseleave={() => (isHovered = false)}
28
53
  title={showPanel ? 'Hide P2Pass panel' : 'Open P2Pass — passkey & UCAN, peer-to-peer'}
@@ -84,13 +109,14 @@
84
109
  />
85
110
  </svg>
86
111
  </button>
112
+ {/if}
87
113
 
88
114
  <!-- Backdrop overlay -->
89
115
  {#if showPanel}
90
116
  <!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
91
117
  <div
92
- onclick={() => (showPanel = false)}
93
- onkeydown={(e) => e.key === 'Escape' && (showPanel = false)}
118
+ onclick={() => setPanelOpen(false)}
119
+ onkeydown={(e) => e.key === 'Escape' && setPanelOpen(false)}
94
120
  role="presentation"
95
121
  style="
96
122
  position: fixed;
@@ -125,7 +151,7 @@
125
151
  {onBackup}
126
152
  {onAuthenticate}
127
153
  onPairingPromptOpen={() => {
128
- showPanel = true;
154
+ setPanelOpen(true);
129
155
  }}
130
156
  {libp2p}
131
157
  {preferWorkerMode}
@@ -4,7 +4,14 @@
4
4
  readSigningPreferenceFromStorage,
5
5
  writeSigningPreferenceToStorage,
6
6
  } from '../identity/signing-preference.js';
7
- import { Upload, LogOut, Loader2, AlertCircle, CheckCircle, Download } from 'lucide-svelte';
7
+ // Per-icon entrypoints avoid the root `lucide-svelte` barrel (`export *`), which can trigger
8
+ // "Importing binding name 'default' cannot be resolved by star export entries" in strict ESM.
9
+ import Upload from 'lucide-svelte/icons/upload';
10
+ import LogOut from 'lucide-svelte/icons/log-out';
11
+ import Loader2 from 'lucide-svelte/icons/loader-2';
12
+ import AlertCircle from 'lucide-svelte/icons/alert-circle';
13
+ import CheckCircle from 'lucide-svelte/icons/check-circle';
14
+ import Download from 'lucide-svelte/icons/download';
8
15
  import { getSpaceUsage } from './storacha-backup.js';
9
16
  import { OrbitDBStorachaBridge } from 'orbitdb-storacha-bridge';
10
17
  import { IdentityService, hasLocalPasskeyHint } from '../identity/identity-service.js';
@@ -15,6 +22,7 @@
15
22
  storeDelegation,
16
23
  loadStoredDelegation,
17
24
  clearStoredDelegation,
25
+ formatDelegationsTooltipSummary,
18
26
  } from '../ucan/storacha-auth.js';
19
27
  import {
20
28
  openDeviceRegistry,
@@ -27,6 +35,9 @@
27
35
  storeArchiveEntry,
28
36
  listDelegations,
29
37
  storeDelegationEntry,
38
+ removeDeviceEntry,
39
+ delegationsEntriesForDevice,
40
+ hashCredentialId,
30
41
  } from '../registry/device-registry.js';
31
42
  import {
32
43
  createManifest,
@@ -86,6 +97,92 @@
86
97
  return '\uD83D\uDCF1';
87
98
  }
88
99
 
100
+ /** @param {{ mode?: string, algorithm?: string } | null} sm */
101
+ function passkeyKindFromSigningMode(sm) {
102
+ if (!sm) return null;
103
+ if (sm.mode === 'worker') return 'worker-ed25519';
104
+ if (sm.algorithm === 'P-256') return 'hardware-p256';
105
+ return 'hardware-ed25519';
106
+ }
107
+
108
+ /** Passkey kind label for a registry device row (stored passkey_kind, else local session). */
109
+ function linkedDevicePasskeyLabel(/** @type {Record<string, unknown>} */ device) {
110
+ const k = device.passkey_kind;
111
+ if (typeof k === 'string' && k) return k;
112
+ if (signingMode?.did && device.ed25519_did === signingMode.did) {
113
+ return passkeyKindFromSigningMode(signingMode);
114
+ }
115
+ return null;
116
+ }
117
+
118
+ /** UCAN delegation counts keyed by device DID (see {@link delegationsEntriesForDevice}). */
119
+ let ucanCountsByDid = $state(/** @type {Record<string, number>} */ ({}));
120
+ /** Parsed UCAN summary for each device row’s badge `title`. */
121
+ let ucanTooltipByDid = $state(/** @type {Record<string, string>} */ ({}));
122
+
123
+ async function refreshLinkedDeviceDelegationCounts() {
124
+ if (!registryDb) {
125
+ ucanCountsByDid = {};
126
+ ucanTooltipByDid = {};
127
+ return;
128
+ }
129
+ try {
130
+ const delegations = await listDelegations(registryDb);
131
+ const ownerDid = localStorage.getItem(OWNER_DID_KEY) || signingMode?.did || '';
132
+ /** @type {Record<string, number>} */
133
+ const next = {};
134
+ /** @type {Record<string, string>} */
135
+ const tips = {};
136
+ for (const dev of devices) {
137
+ const did = dev.ed25519_did;
138
+ if (typeof did !== 'string' || !did) continue;
139
+ const entries = delegationsEntriesForDevice(delegations, did, ownerDid);
140
+ next[did] = entries.length;
141
+ if (entries.length > 0) {
142
+ tips[did] = await formatDelegationsTooltipSummary(
143
+ /** @type {Array<{ delegation?: string, space_did?: string, label?: string }>} */ (
144
+ entries
145
+ )
146
+ );
147
+ }
148
+ }
149
+ ucanCountsByDid = next;
150
+ ucanTooltipByDid = tips;
151
+ } catch {
152
+ ucanCountsByDid = {};
153
+ ucanTooltipByDid = {};
154
+ }
155
+ }
156
+
157
+ async function confirmRemoveLinkedDevice(/** @type {Record<string, unknown>} */ device) {
158
+ const label = String(device.device_label || device.ed25519_did || 'this device');
159
+ const credId = device.credential_id;
160
+ if (typeof credId !== 'string' || !credId) {
161
+ showMessage('Cannot remove device: missing credential id.', 'error');
162
+ return;
163
+ }
164
+ if (
165
+ !confirm(
166
+ `Remove linked device "${label}" from the registry? Its OrbitDB write access will be revoked.`
167
+ )
168
+ ) {
169
+ return;
170
+ }
171
+ const db = deviceManager?.getRegistryDb?.() ?? registryDb;
172
+ if (!db) {
173
+ showMessage('Cannot remove device: registry not ready.', 'error');
174
+ return;
175
+ }
176
+ try {
177
+ await removeDeviceEntry(db, credId);
178
+ devices = devices.filter((d) => d.ed25519_did !== device.ed25519_did);
179
+ await refreshLinkedDeviceDelegationCounts();
180
+ showMessage('Device removed from linked devices.');
181
+ } catch (err) {
182
+ showMessage(`Failed to remove device: ${err?.message || err}`, 'error');
183
+ }
184
+ }
185
+
89
186
  // Component state
90
187
  let showStoracha = $state(true);
91
188
  let isLoading = $state(false);
@@ -225,6 +322,15 @@
225
322
  };
226
323
  });
227
324
 
325
+ /** Per-device UCAN delegation counts for linked-device badges. */
326
+ $effect(() => {
327
+ registryDb;
328
+ devices;
329
+ signingMode?.did;
330
+ isLoggedIn;
331
+ void refreshLinkedDeviceDelegationCounts();
332
+ });
333
+
228
334
  /** Start MultiDeviceManager once libp2p + registry exist (handles restored signingMode / late orbitdb). */
229
335
  $effect(() => {
230
336
  if (!signingMode?.did || !orbitdb || !libp2p || !registryDb || deviceManager) return;
@@ -344,7 +450,12 @@
344
450
 
345
451
  if (store) {
346
452
  const spaceDid = storeSpaceDid || client.currentSpace()?.did?.() || '';
347
- await storeDelegation(delegationStr, storeRegistryDb, spaceDid);
453
+ await storeDelegation(
454
+ delegationStr,
455
+ storeRegistryDb,
456
+ spaceDid,
457
+ signingMode?.did || ''
458
+ );
348
459
  }
349
460
 
350
461
  currentSpace = client.currentSpace();
@@ -552,7 +663,14 @@
552
663
  if (!registryDb || !signingMode?.did) return;
553
664
  try {
554
665
  const existing = await getDeviceByDID(registryDb, signingMode.did);
555
- if (existing) return;
666
+ const kind = passkeyKindFromSigningMode(signingMode);
667
+ if (existing) {
668
+ if (kind && !existing.passkey_kind) {
669
+ const k = await hashCredentialId(existing.credential_id);
670
+ await registryDb.put(k, { ...existing, passkey_kind: kind });
671
+ }
672
+ return;
673
+ }
556
674
  const credential = loadWebAuthnCredentialSafe();
557
675
  await registerDevice(registryDb, {
558
676
  credential_id:
@@ -568,6 +686,7 @@
568
686
  created_at: Date.now(),
569
687
  status: 'active',
570
688
  ed25519_did: signingMode.did,
689
+ passkey_kind: kind,
571
690
  });
572
691
  console.log('[ui] Self-registered device in registry');
573
692
  } catch (err) {
@@ -625,7 +744,7 @@
625
744
  credential,
626
745
  orbitdb,
627
746
  libp2p,
628
- identity: { id: signingMode.did },
747
+ identity: { id: signingMode.did, passkeyKind: passkeyKindFromSigningMode(signingMode) },
629
748
  onPairingRequest: async (request) => {
630
749
  pairingFlow(
631
750
  'ALICE',
@@ -733,7 +852,13 @@
733
852
  await storeArchiveEntry(newDb, did, archive.ciphertext, archive.iv);
734
853
  }
735
854
  for (const d of delegations) {
736
- await storeDelegationEntry(newDb, d.delegation, d.space_did);
855
+ await storeDelegationEntry(
856
+ newDb,
857
+ d.delegation,
858
+ d.space_did,
859
+ d.label,
860
+ d.stored_by_did
861
+ );
737
862
  }
738
863
  console.log('[ui] Registry migration complete after', Date.now() - start, 'ms');
739
864
  return true;
@@ -1132,10 +1257,11 @@
1132
1257
  {:else}
1133
1258
  <div style="display: flex; flex-direction: column; gap: 0.375rem;">
1134
1259
  {#each devices as device, i (device.credential_id || device.ed25519_did || device.device_label || i)}
1260
+ {@const passkeyBadge = linkedDevicePasskeyLabel(device)}
1135
1261
  <div
1136
1262
  data-testid="storacha-linked-device-row"
1137
1263
  data-device-label={device.device_label || ''}
1138
- style="display: flex; align-items: center; gap: 0.625rem; padding: 0.5rem; border-radius: 0.375rem; background: rgba(255, 255, 255, 0.7); border-left: 3px solid {device.status ===
1264
+ style="display: flex; align-items: flex-start; gap: 0.625rem; padding: 0.5rem; border-radius: 0.375rem; background: rgba(255, 255, 255, 0.7); border-left: 3px solid {device.status ===
1139
1265
  'active'
1140
1266
  ? '#10b981'
1141
1267
  : '#E91315'};"
@@ -1154,17 +1280,53 @@
1154
1280
  ? device.ed25519_did.slice(0, 16) + '...' + device.ed25519_did.slice(-8)
1155
1281
  : 'N/A'}
1156
1282
  </code>
1283
+ <div
1284
+ style="display: flex; flex-wrap: wrap; gap: 0.25rem; margin-top: 0.35rem; align-items: center;"
1285
+ >
1286
+ {#if passkeyBadge}
1287
+ <span
1288
+ style="font-size: 0.55rem; font-weight: 600; padding: 0.1rem 0.35rem; border-radius: 9999px; background: #e0e7ff; color: #3730a3; font-family: 'DM Mono', monospace;"
1289
+ title="Passkey signing mode for this device"
1290
+ >
1291
+ {passkeyBadge}
1292
+ </span>
1293
+ {/if}
1294
+ {#if device.ed25519_did && (ucanCountsByDid[device.ed25519_did] ?? 0) > 0}
1295
+ <span
1296
+ style="font-size: 0.55rem; font-weight: 600; padding: 0.1rem 0.35rem; border-radius: 9999px; background: #fef3c7; color: #92400e; font-family: 'DM Sans', sans-serif;"
1297
+ title={ucanTooltipByDid[device.ed25519_did]?.trim()
1298
+ ? ucanTooltipByDid[device.ed25519_did]
1299
+ : 'UCAN delegations on this device (parsing summary…)'}
1300
+ >
1301
+ {ucanCountsByDid[device.ed25519_did]}
1302
+ {ucanCountsByDid[device.ed25519_did] === 1 ? ' UCAN' : ' UCANs'}
1303
+ </span>
1304
+ {/if}
1305
+ </div>
1157
1306
  </div>
1158
- <span
1159
- style="font-size: 0.6rem; font-weight: 600; padding: 0.125rem 0.375rem; border-radius: 9999px; flex-shrink: 0; background: {device.status ===
1160
- 'active'
1161
- ? '#dcfce7'
1162
- : '#fee2e2'}; color: {device.status === 'active'
1163
- ? '#166534'
1164
- : '#991b1b'}; font-family: 'DM Sans', sans-serif;"
1307
+ <div
1308
+ style="display: flex; flex-direction: column; align-items: flex-end; gap: 0.25rem; flex-shrink: 0;"
1165
1309
  >
1166
- {device.status}
1167
- </span>
1310
+ <span
1311
+ style="font-size: 0.6rem; font-weight: 600; padding: 0.125rem 0.375rem; border-radius: 9999px; background: {device.status ===
1312
+ 'active'
1313
+ ? '#dcfce7'
1314
+ : '#fee2e2'}; color: {device.status === 'active'
1315
+ ? '#166534'
1316
+ : '#991b1b'}; font-family: 'DM Sans', sans-serif;"
1317
+ >
1318
+ {device.status}
1319
+ </span>
1320
+ <button
1321
+ type="button"
1322
+ data-testid="storacha-linked-device-remove"
1323
+ aria-label="Remove linked device"
1324
+ onclick={() => confirmRemoveLinkedDevice(device)}
1325
+ style="font-size: 0.6rem; font-weight: 600; padding: 0.15rem 0.4rem; border-radius: 0.25rem; background: transparent; color: #b91c1c; border: 1px solid #fca5a5; cursor: pointer; font-family: 'DM Sans', sans-serif;"
1326
+ >
1327
+ Remove
1328
+ </button>
1329
+ </div>
1168
1330
  </div>
1169
1331
  {/each}
1170
1332
  </div>
@@ -1548,6 +1710,7 @@
1548
1710
 
1549
1711
  <!-- Recover from backup (IPNS / manifest) -->
1550
1712
  <button
1713
+ data-testid="storacha-recover-passkey"
1551
1714
  onclick={handleRecover}
1552
1715
  disabled={isRecovering}
1553
1716
  style="display: flex; align-items: center; justify-content: center; gap: 0.5rem; border-radius: 0.375rem; background-color: transparent; padding: 0.5rem 1.25rem; color: #E91315; border: 1px solid #E91315; cursor: pointer; font-family: 'Epilogue', sans-serif; font-weight: 600; font-size: 0.75rem; opacity: {isRecovering
@@ -1714,6 +1877,7 @@
1714
1877
  <div style="display: flex; flex-direction: column; gap: 0.75rem;">
1715
1878
  <textarea
1716
1879
  class="storacha-textarea"
1880
+ data-testid="storacha-delegation-textarea"
1717
1881
  bind:value={delegationText}
1718
1882
  placeholder="Paste your UCAN delegation here (base64 encoded)..."
1719
1883
  rows="4"
@@ -1721,6 +1885,7 @@
1721
1885
  ></textarea>
1722
1886
  <button
1723
1887
  class="storacha-btn-primary"
1888
+ data-testid="storacha-delegation-import"
1724
1889
  onclick={handleImportDelegation}
1725
1890
  disabled={isLoading || !delegationText.trim()}
1726
1891
  style="display: flex; width: 100%; align-items: center; justify-content: center; gap: 0.5rem; border-radius: 0.375rem; background-color: #E91315; padding: 0.5rem 1rem; color: #ffffff; box-shadow: 0 10px 15px -3px rgba(0,0,0,0.1), 0 4px 6px -4px rgba(0,0,0,0.1); transition: color 150ms, background-color 150ms; border: none; cursor: pointer; font-family: 'Epilogue', sans-serif; font-weight: 600; opacity: {isLoading ||
@@ -1790,6 +1955,7 @@
1790
1955
  />
1791
1956
  {#if linkError}
1792
1957
  <div
1958
+ data-testid="storacha-link-error"
1793
1959
  style="font-size: 0.7rem; color: #dc2626; font-family: 'DM Sans', sans-serif;"
1794
1960
  >
1795
1961
  {linkError}
@@ -2395,6 +2561,7 @@
2395
2561
  </div>
2396
2562
  {#if linkError}
2397
2563
  <div
2564
+ data-testid="storacha-link-error"
2398
2565
  style="margin-top: 0.5rem; font-size: 0.75rem; color: #b91c1c; font-family: 'DM Sans', sans-serif;"
2399
2566
  >
2400
2567
  {linkError}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@le-space/p2pass",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "type": "module",
5
5
  "description": "P2Pass — peer-to-peer passkeys, UCANs, OrbitDB registry sync, and Storacha backup (Svelte)",
6
6
  "license": "MIT",
@@ -18,6 +18,7 @@
18
18
  "scripts": {
19
19
  "dev:example": "vite --config example/vite.config.js",
20
20
  "build:example": "vite build --config example/vite.config.js",
21
+ "kill:dev": "bash scripts/kill-relay-and-vite.sh",
21
22
  "test:e2e": "playwright test",
22
23
  "test:e2e:ui": "playwright test --ui",
23
24
  "test": "vitest run",
@@ -34,7 +35,27 @@
34
35
  "exports": {
35
36
  ".": {
36
37
  "types": "./dist/index.d.ts",
37
- "svelte": "./dist/index.js"
38
+ "svelte": "./dist/index.js",
39
+ "browser": "./dist/index.js",
40
+ "import": "./dist/index.js",
41
+ "default": "./dist/index.js"
42
+ },
43
+ "./signing-preference": {
44
+ "types": "./dist/identity/signing-preference.d.ts",
45
+ "import": "./dist/identity/signing-preference.js",
46
+ "default": "./dist/identity/signing-preference.js"
47
+ },
48
+ "./ui/StorachaFab": {
49
+ "types": "./dist/ui/StorachaFab.svelte.d.ts",
50
+ "svelte": "./dist/ui/StorachaFab.svelte",
51
+ "import": "./dist/ui/StorachaFab.svelte",
52
+ "default": "./dist/ui/StorachaFab.svelte"
53
+ },
54
+ "./ui/StorachaFab.svelte": {
55
+ "types": "./dist/ui/StorachaFab.svelte.d.ts",
56
+ "svelte": "./dist/ui/StorachaFab.svelte",
57
+ "import": "./dist/ui/StorachaFab.svelte",
58
+ "default": "./dist/ui/StorachaFab.svelte"
38
59
  }
39
60
  },
40
61
  "files": [