holosphere 2.0.0-alpha11 → 2.0.0-alpha13

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 (65) hide show
  1. package/dist/{2019-D2OG2idw.js → 2019-CLMqIAfQ.js} +1722 -1668
  2. package/dist/{2019-D2OG2idw.js.map → 2019-CLMqIAfQ.js.map} +1 -1
  3. package/dist/2019-Cp3uYhyY.cjs +8 -0
  4. package/dist/{2019-EION3wKo.cjs.map → 2019-Cp3uYhyY.cjs.map} +1 -1
  5. package/dist/browser-D6cNVl0v.cjs +2 -0
  6. package/dist/{browser-Cq59Ij19.cjs.map → browser-D6cNVl0v.cjs.map} +1 -1
  7. package/dist/{browser-BSniCNqO.js → browser-nUQt1cnB.js} +2 -2
  8. package/dist/{browser-BSniCNqO.js.map → browser-nUQt1cnB.js.map} +1 -1
  9. package/dist/cjs/holosphere.cjs +1 -1
  10. package/dist/esm/holosphere.js +67 -50
  11. package/dist/{index-D-jZhliX.js → index-BN_uoxQK.js} +20324 -735
  12. package/dist/index-BN_uoxQK.js.map +1 -0
  13. package/dist/{index-Bl6rM1NW.js → index-CoAjtqsD.js} +2 -2
  14. package/dist/{index-Bl6rM1NW.js.map → index-CoAjtqsD.js.map} +1 -1
  15. package/dist/{index-Bwg3OzRM.cjs → index-Cp3tI53z.cjs} +3 -3
  16. package/dist/{index-Bwg3OzRM.cjs.map → index-Cp3tI53z.cjs.map} +1 -1
  17. package/dist/index-DJjGSwXG.cjs +13 -0
  18. package/dist/index-DJjGSwXG.cjs.map +1 -0
  19. package/dist/index-V8EHMYEY.cjs +29 -0
  20. package/dist/index-V8EHMYEY.cjs.map +1 -0
  21. package/dist/index-Z5TstN1e.js +11663 -0
  22. package/dist/index-Z5TstN1e.js.map +1 -0
  23. package/dist/indexeddb-storage-CZK5A7XH.cjs +2 -0
  24. package/dist/indexeddb-storage-CZK5A7XH.cjs.map +1 -0
  25. package/dist/{indexeddb-storage-5eiUNsHC.js → indexeddb-storage-bpA01pAU.js} +39 -2
  26. package/dist/indexeddb-storage-bpA01pAU.js.map +1 -0
  27. package/dist/{memory-storage-DMt36uZO.cjs → memory-storage-B1k8Jszd.cjs} +2 -2
  28. package/dist/{memory-storage-DMt36uZO.cjs.map → memory-storage-B1k8Jszd.cjs.map} +1 -1
  29. package/dist/{memory-storage-CI-gfmuG.js → memory-storage-BqhmytP_.js} +2 -2
  30. package/dist/{memory-storage-CI-gfmuG.js.map → memory-storage-BqhmytP_.js.map} +1 -1
  31. package/docs/FEDERATION.md +474 -0
  32. package/package.json +3 -1
  33. package/src/crypto/nostr-utils.js +7 -0
  34. package/src/crypto/secp256k1.js +104 -38
  35. package/src/federation/capabilities.js +162 -0
  36. package/src/federation/card-storage.js +376 -0
  37. package/src/federation/handshake.js +561 -9
  38. package/src/federation/hologram.js +194 -57
  39. package/src/federation/holon-registry.js +187 -0
  40. package/src/federation/index.js +68 -0
  41. package/src/federation/registry.js +164 -6
  42. package/src/federation/request-card.js +373 -0
  43. package/src/hierarchical/upcast.js +19 -3
  44. package/src/index.js +209 -75
  45. package/src/lib/federation-methods.js +527 -5
  46. package/src/storage/indexeddb-storage.js +41 -0
  47. package/src/storage/nostr-async.js +14 -5
  48. package/src/storage/nostr-client.js +471 -155
  49. package/src/storage/nostr-wrapper.js +6 -3
  50. package/dist/2019-EION3wKo.cjs +0 -8
  51. package/dist/_commonjsHelpers-C37NGDzP.cjs +0 -2
  52. package/dist/_commonjsHelpers-C37NGDzP.cjs.map +0 -1
  53. package/dist/_commonjsHelpers-CUmg6egw.js +0 -7
  54. package/dist/_commonjsHelpers-CUmg6egw.js.map +0 -1
  55. package/dist/browser-Cq59Ij19.cjs +0 -2
  56. package/dist/index-D-jZhliX.js.map +0 -1
  57. package/dist/index-Dc6Z8Aob.cjs +0 -18
  58. package/dist/index-Dc6Z8Aob.cjs.map +0 -1
  59. package/dist/indexeddb-storage-5eiUNsHC.js.map +0 -1
  60. package/dist/indexeddb-storage-FNFUVvTJ.cjs +0 -2
  61. package/dist/indexeddb-storage-FNFUVvTJ.cjs.map +0 -1
  62. package/dist/secp256k1-CEwJNcfV.js +0 -1890
  63. package/dist/secp256k1-CEwJNcfV.js.map +0 -1
  64. package/dist/secp256k1-CiEONUnj.cjs +0 -12
  65. package/dist/secp256k1-CiEONUnj.cjs.map +0 -1
@@ -15,8 +15,12 @@ import {
15
15
  createDMEvent,
16
16
  hexToNpub,
17
17
  generateNonce,
18
+ getPublicKey,
18
19
  } from '../crypto/nostr-utils.js';
19
20
  import { addFederatedPartner, storeInboundCapability } from './registry.js';
21
+ import { registerHolon } from './holon-registry.js';
22
+ import { issueCapability } from '../crypto/secp256k1.js';
23
+ import * as cardStorage from './card-storage.js';
20
24
 
21
25
  // ============================================================================
22
26
  // Types (documented for reference)
@@ -194,26 +198,59 @@ export async function sendFederationResponse(client, privateKey, recipientPubKey
194
198
 
195
199
  /**
196
200
  * Subscribe to incoming federation DMs
201
+ * Fetches all historical DMs since last sync, then subscribes to new ones.
202
+ * DMs persist on relays like email - no need for both parties to be online.
197
203
  * @param {Object} client - NostrClient instance with subscribe method
198
204
  * @param {string} privateKey - Recipient's hex private key
199
205
  * @param {string} publicKey - Recipient's hex public key
200
206
  * @param {Object} handlers
201
207
  * @param {Function} handlers.onRequest - Called with (request, senderPubKey)
202
208
  * @param {Function} handlers.onResponse - Called with (response, senderPubKey)
209
+ * @param {Function} [handlers.onUpdate] - Called with (update, senderPubKey)
210
+ * @param {Function} [handlers.onUpdateResponse] - Called with (response, senderPubKey)
211
+ * @param {Object} options - Subscription options
212
+ * @param {string} options.appname - Application namespace (for persistent storage)
203
213
  * @returns {Function} Unsubscribe function
204
214
  */
205
- export function subscribeToFederationDMs(client, privateKey, publicKey, handlers) {
206
- const { onRequest, onResponse } = handlers;
215
+ export function subscribeToFederationDMs(client, privateKey, publicKey, handlers, options = {}) {
216
+ const { onRequest, onResponse, onUpdate, onUpdateResponse } = handlers;
217
+ const { appname } = options;
207
218
  let isActive = true;
219
+
220
+ // In-memory deduplication for this session
208
221
  const processedEventIds = new Set();
209
222
 
223
+ // Track processed DMs and responses persistently to prevent duplicate notifications
224
+ let processedDMIds = new Set();
225
+ let processedResponseIds = new Set();
226
+ let lastFetchTime = 0;
227
+
228
+ // Load processed DMs and responses from storage if appname is provided
229
+ const loadPersistedState = async () => {
230
+ if (appname && client?.client) {
231
+ try {
232
+ processedDMIds = await cardStorage.getProcessedDMIds(client.client, appname);
233
+ processedResponseIds = await cardStorage.getProcessedResponseIds(client.client, appname);
234
+ lastFetchTime = await cardStorage.getLastDMFetchTime(client.client, appname);
235
+ console.log('[Handshake] Loaded persisted state - last fetch:', lastFetchTime, 'processed DMs:', processedDMIds.size);
236
+ } catch (err) {
237
+ console.warn('[Handshake] Could not load persisted state:', err.message);
238
+ }
239
+ }
240
+ };
241
+
210
242
  const handleEvent = async (event) => {
211
243
  if (!isActive) return;
212
244
 
213
- // Skip duplicates
245
+ // Skip duplicates (in-memory check for this session)
214
246
  if (processedEventIds.has(event.id)) return;
215
247
  processedEventIds.add(event.id);
216
248
 
249
+ // Skip if already processed in persistent storage
250
+ if (processedDMIds.has(event.id)) {
251
+ return;
252
+ }
253
+
217
254
  // Only kind 4 (encrypted DM) events tagged to us
218
255
  if (event.kind !== 4) return;
219
256
 
@@ -234,11 +271,83 @@ export function subscribeToFederationDMs(client, privateKey, publicKey, handlers
234
271
  const payload = JSON.parse(decrypted);
235
272
 
236
273
  if (payload.type === 'federation_request' && payload.version === '1.0') {
274
+ // Check if this request was already dismissed
275
+ if (appname && client?.client) {
276
+ const isDismissed = await cardStorage.isRequestDismissed(client.client, appname, payload.requestId);
277
+ if (isDismissed) {
278
+ console.log('[Handshake] Skipping dismissed request:', payload.requestId);
279
+ // Mark this DM as processed so we don't check again
280
+ await cardStorage.markDMProcessed(client.client, appname, event.id, 'request');
281
+ processedDMIds.add(event.id);
282
+ return;
283
+ }
284
+ }
285
+
286
+ // Mark DM as processed before calling handler
287
+ if (appname && client?.client) {
288
+ await cardStorage.markDMProcessed(client.client, appname, event.id, 'request');
289
+ processedDMIds.add(event.id);
290
+ }
291
+
237
292
  console.log('[Handshake] Received federation request from:', event.pubkey.substring(0, 8) + '...');
238
293
  onRequest?.(payload, event.pubkey);
239
294
  } else if (payload.type === 'federation_response' && payload.version === '1.0') {
295
+ // Check if this response was already processed (persistent check)
296
+ const responseKey = `${payload.requestId}_${event.pubkey}`;
297
+ if (processedResponseIds.has(responseKey)) {
298
+ console.log('[Handshake] Skipping already processed response:', responseKey);
299
+ return;
300
+ }
301
+
302
+ // Mark as processed in persistent storage
303
+ if (appname && client?.client) {
304
+ await cardStorage.markResponseProcessed(client.client, appname, payload.requestId, event.pubkey);
305
+ await cardStorage.markDMProcessed(client.client, appname, event.id, 'response');
306
+ processedResponseIds.add(responseKey);
307
+ processedDMIds.add(event.id);
308
+ }
309
+
240
310
  console.log('[Handshake] Received federation response from:', event.pubkey.substring(0, 8) + '...');
241
311
  onResponse?.(payload, event.pubkey);
312
+ } else if (payload.type === 'federation_update' && payload.version === '1.0') {
313
+ // Federation update request (lens config change)
314
+ // Check if this update was already dismissed
315
+ if (appname && client?.client) {
316
+ const isDismissed = await cardStorage.isRequestDismissed(client.client, appname, payload.updateId);
317
+ if (isDismissed) {
318
+ console.log('[Handshake] Skipping dismissed update:', payload.updateId);
319
+ await cardStorage.markDMProcessed(client.client, appname, event.id, 'update');
320
+ processedDMIds.add(event.id);
321
+ return;
322
+ }
323
+ }
324
+
325
+ // Mark DM as processed
326
+ if (appname && client?.client) {
327
+ await cardStorage.markDMProcessed(client.client, appname, event.id, 'update');
328
+ processedDMIds.add(event.id);
329
+ }
330
+
331
+ console.log('[Handshake] Received federation update from:', event.pubkey.substring(0, 8) + '...');
332
+ onUpdate?.(payload, event.pubkey);
333
+ } else if (payload.type === 'federation_update_response' && payload.version === '1.0') {
334
+ // Federation update response
335
+ const responseKey = `update_${payload.updateId}_${event.pubkey}`;
336
+ if (processedResponseIds.has(responseKey)) {
337
+ console.log('[Handshake] Skipping already processed update response:', responseKey);
338
+ return;
339
+ }
340
+
341
+ // Mark as processed
342
+ if (appname && client?.client) {
343
+ await cardStorage.markResponseProcessed(client.client, appname, payload.updateId, event.pubkey);
344
+ await cardStorage.markDMProcessed(client.client, appname, event.id, 'update_response');
345
+ processedResponseIds.add(responseKey);
346
+ processedDMIds.add(event.id);
347
+ }
348
+
349
+ console.log('[Handshake] Received federation update response from:', event.pubkey.substring(0, 8) + '...');
350
+ onUpdateResponse?.(payload, event.pubkey);
242
351
  }
243
352
  } catch (error) {
244
353
  // Silently ignore non-federation DMs
@@ -250,6 +359,9 @@ export function subscribeToFederationDMs(client, privateKey, publicKey, handlers
250
359
  let subscription = null;
251
360
 
252
361
  const startSubscription = async () => {
362
+ // Load persisted state first
363
+ await loadPersistedState();
364
+
253
365
  // Get the NostrClient from HoloSphere instance
254
366
  const nostrClient = client?.client;
255
367
  if (!nostrClient?.subscribe) {
@@ -257,16 +369,27 @@ export function subscribeToFederationDMs(client, privateKey, publicKey, handlers
257
369
  return;
258
370
  }
259
371
 
372
+ // Use last fetch time or 0 for first-time sync (fetch all historical DMs)
373
+ // This makes federation DMs work like email - they persist on relays
374
+ const sinceTime = lastFetchTime || 0;
375
+ console.log('[Handshake] Fetching DMs since:', sinceTime ? new Date(sinceTime * 1000).toISOString() : 'beginning of time');
376
+
260
377
  const filter = {
261
378
  kinds: [4],
262
379
  '#p': [publicKey],
263
- since: Math.floor(Date.now() / 1000) - 86400, // Last 24 hours
380
+ since: sinceTime,
264
381
  };
265
382
 
266
383
  try {
267
384
  // NostrClient.subscribe(filter, onEvent, options) returns { unsubscribe }
268
385
  subscription = await nostrClient.subscribe(filter, handleEvent, {});
269
386
  console.log('[Handshake] Federation DM subscription started');
387
+
388
+ // Update last fetch time to now (for next session)
389
+ if (appname && client?.client) {
390
+ const now = Math.floor(Date.now() / 1000);
391
+ await cardStorage.setLastDMFetchTime(client.client, appname, now);
392
+ }
270
393
  } catch (error) {
271
394
  console.error('[Handshake] Failed to subscribe:', error);
272
395
  }
@@ -313,16 +436,39 @@ export async function initiateFederationHandshake(holosphere, privateKey, params
313
436
 
314
437
  try {
315
438
  // Get sender's public key
316
- const { getPublicKey } = await import('../crypto/nostr-utils.js');
317
439
  const senderPubKey = getPublicKey(privateKey);
318
440
 
441
+ // Issue capabilities for outbound lenses (lenses we're sharing with partner)
442
+ const capabilities = [];
443
+ for (const lensName of (lensConfig.outbound || [])) {
444
+ try {
445
+ const token = await issueCapability(
446
+ ['read'],
447
+ { holonId, lensName, dataId: '*' },
448
+ partnerPubKey,
449
+ {
450
+ expiresIn: 365 * 24 * 60 * 60 * 1000, // 1 year
451
+ issuer: senderPubKey,
452
+ issuerKey: privateKey,
453
+ }
454
+ );
455
+ capabilities.push({
456
+ token,
457
+ scope: { holonId, lensName },
458
+ permissions: ['read'],
459
+ });
460
+ } catch (err) {
461
+ console.warn(`[Handshake] Failed to issue capability for ${lensName}:`, err.message);
462
+ }
463
+ }
464
+
319
465
  // Create federation request
320
466
  const request = createFederationRequest({
321
467
  senderHolonId: holonId,
322
468
  senderHolonName: holonName,
323
469
  senderPubKey,
324
470
  lensConfig,
325
- capabilities: [],
471
+ capabilities,
326
472
  message,
327
473
  });
328
474
 
@@ -373,7 +519,6 @@ export async function acceptFederationRequest(holosphere, privateKey, params) {
373
519
 
374
520
  try {
375
521
  // Get responder's public key
376
- const { getPublicKey } = await import('../crypto/nostr-utils.js');
377
522
  const responderPubKey = getPublicKey(privateKey);
378
523
 
379
524
  // Add sender as federated partner in Nostr registry
@@ -389,6 +534,18 @@ export async function acceptFederationRequest(holosphere, privateKey, params) {
389
534
  await storeInboundCapability(holosphere.client, holosphere.config.appName, senderPubKey, cap);
390
535
  }
391
536
  }
537
+
538
+ // Register the sender's holon -> pubkey mapping in our holon registry
539
+ // This is critical for resolveHolonToPubkey to work when navigating to their holon
540
+ if (request.senderHolonId) {
541
+ await registerHolon(holosphere.client, holosphere.config.appName, request.senderHolonId, senderPubKey, {
542
+ alias: request.senderHolonName,
543
+ });
544
+ console.log('[Handshake] Registered partner holon:', {
545
+ holonId: request.senderHolonId,
546
+ pubKey: senderPubKey?.slice(0, 12) + '...'
547
+ });
548
+ }
392
549
  }
393
550
 
394
551
  // Create the actual federation record in GunDB storage
@@ -399,6 +556,30 @@ export async function acceptFederationRequest(holosphere, privateKey, params) {
399
556
  partnerName: request.senderHolonName
400
557
  });
401
558
 
559
+ // Issue capabilities for our outbound lenses (lenses we're sharing with partner)
560
+ const capabilities = [];
561
+ for (const lensName of (lensConfig.outbound || [])) {
562
+ try {
563
+ const token = await issueCapability(
564
+ ['read'],
565
+ { holonId, lensName, dataId: '*' },
566
+ senderPubKey,
567
+ {
568
+ expiresIn: 365 * 24 * 60 * 60 * 1000, // 1 year
569
+ issuer: responderPubKey,
570
+ issuerKey: privateKey,
571
+ }
572
+ );
573
+ capabilities.push({
574
+ token,
575
+ scope: { holonId, lensName },
576
+ permissions: ['read'],
577
+ });
578
+ } catch (err) {
579
+ console.warn(`[Handshake] Failed to issue capability for ${lensName}:`, err.message);
580
+ }
581
+ }
582
+
402
583
  // Create and send response
403
584
  const response = createFederationResponse({
404
585
  requestId: request.requestId,
@@ -407,14 +588,50 @@ export async function acceptFederationRequest(holosphere, privateKey, params) {
407
588
  responderHolonName: holonName,
408
589
  responderPubKey,
409
590
  lensConfig,
410
- capabilities: [],
591
+ capabilities,
411
592
  message,
412
593
  });
413
594
 
414
595
  const sent = await sendFederationResponse(holosphere.client, privateKey, senderPubKey, response);
415
596
 
416
597
  if (sent) {
417
- return { success: true };
598
+ // Dismiss the request so it won't reappear
599
+ if (holosphere.client && request.requestId) {
600
+ await cardStorage.dismissRequest(holosphere.client, holosphere.config.appName, request.requestId);
601
+ console.log('[Handshake] Request dismissed after acceptance:', request.requestId);
602
+ }
603
+
604
+ // Auto-receive federated lens data as holograms for configured inbound lenses
605
+ // This enables the user to immediately see the partner's data in their holon
606
+ const receivedHolograms = {};
607
+ const inboundLenses = lensConfig.inbound || [];
608
+
609
+ if (inboundLenses.length > 0 && holosphere.receiveFederatedLens) {
610
+ console.log('[Handshake] Receiving federated lens data as holograms...');
611
+
612
+ for (const lensName of inboundLenses) {
613
+ try {
614
+ // The sender's holon ID comes from the request
615
+ const senderHolonId = request.senderHolonId;
616
+
617
+ const result = await holosphere.receiveFederatedLens(
618
+ senderPubKey,
619
+ senderHolonId,
620
+ lensName,
621
+ holonId,
622
+ { overwrite: false }
623
+ );
624
+
625
+ receivedHolograms[lensName] = result;
626
+ console.log(`[Handshake] Received ${result.received} holograms for lens "${lensName}"`);
627
+ } catch (err) {
628
+ console.warn(`[Handshake] Failed to receive holograms for lens "${lensName}":`, err.message);
629
+ receivedHolograms[lensName] = { error: err.message };
630
+ }
631
+ }
632
+ }
633
+
634
+ return { success: true, requestId: request.requestId, receivedHolograms };
418
635
  } else {
419
636
  return { success: false, error: 'Failed to send response DM' };
420
637
  }
@@ -452,6 +669,86 @@ export async function rejectFederationRequest(holosphere, privateKey, params) {
452
669
  }
453
670
  }
454
671
 
672
+ /**
673
+ * Process a federation response (called by initiator when receiving acceptance)
674
+ * Stores capabilities from the responder in the local registry and receives holograms
675
+ * @param {Object} holosphere - HoloSphere instance
676
+ * @param {Object} response - Federation response payload
677
+ * @param {string} responderPubKey - Responder's public key
678
+ * @param {Object} [options={}] - Processing options
679
+ * @param {string} [options.holonId] - Initiator's holon ID (for receiving holograms)
680
+ * @param {string[]} [options.inboundLenses] - Lenses to receive from responder
681
+ * @returns {Promise<Object>} Result with success, stored capability count, and received holograms
682
+ */
683
+ export async function processFederationResponse(holosphere, response, responderPubKey, options = {}) {
684
+ if (!response || response.status !== 'accepted') {
685
+ return { success: false, error: 'Response not accepted', storedCapabilities: 0 };
686
+ }
687
+
688
+ const { holonId, inboundLenses = [] } = options;
689
+ let storedCapabilities = 0;
690
+
691
+ // Store capabilities from the responder
692
+ if (holosphere.client && response.capabilities && response.capabilities.length > 0) {
693
+ for (const cap of response.capabilities) {
694
+ try {
695
+ await storeInboundCapability(holosphere.client, holosphere.config.appName, responderPubKey, cap);
696
+ storedCapabilities++;
697
+ } catch (err) {
698
+ console.warn(`[Handshake] Failed to store capability:`, err.message);
699
+ }
700
+ }
701
+ }
702
+
703
+ // Add responder as federated partner if not already
704
+ if (holosphere.client) {
705
+ await addFederatedPartner(holosphere.client, holosphere.config.appName, responderPubKey, {
706
+ alias: response.responderHolonName,
707
+ addedVia: 'handshake_accepted',
708
+ });
709
+
710
+ // Register the responder's holon -> pubkey mapping in our holon registry
711
+ // This is critical for resolveHolonToPubkey to work when navigating to their holon
712
+ if (response.responderHolonId) {
713
+ await registerHolon(holosphere.client, holosphere.config.appName, response.responderHolonId, responderPubKey, {
714
+ alias: response.responderHolonName,
715
+ });
716
+ console.log('[Handshake] Registered partner holon from response:', {
717
+ holonId: response.responderHolonId,
718
+ pubKey: responderPubKey?.slice(0, 12) + '...'
719
+ });
720
+ }
721
+ }
722
+
723
+ // Auto-receive federated lens data as holograms for configured inbound lenses
724
+ const receivedHolograms = {};
725
+ const responderHolonId = response.responderHolonId;
726
+
727
+ if (holonId && responderHolonId && inboundLenses.length > 0 && holosphere.receiveFederatedLens) {
728
+ console.log('[Handshake] Receiving federated lens data as holograms from responder...');
729
+
730
+ for (const lensName of inboundLenses) {
731
+ try {
732
+ const result = await holosphere.receiveFederatedLens(
733
+ responderPubKey,
734
+ responderHolonId,
735
+ lensName,
736
+ holonId,
737
+ { overwrite: false }
738
+ );
739
+
740
+ receivedHolograms[lensName] = result;
741
+ console.log(`[Handshake] Received ${result.received} holograms for lens "${lensName}"`);
742
+ } catch (err) {
743
+ console.warn(`[Handshake] Failed to receive holograms for lens "${lensName}":`, err.message);
744
+ receivedHolograms[lensName] = { error: err.message };
745
+ }
746
+ }
747
+ }
748
+
749
+ return { success: true, storedCapabilities, receivedHolograms };
750
+ }
751
+
455
752
  // ============================================================================
456
753
  // Payload Validation
457
754
  // ============================================================================
@@ -473,3 +770,258 @@ export function isFederationRequest(payload) {
473
770
  export function isFederationResponse(payload) {
474
771
  return payload?.type === 'federation_response' && payload?.version === '1.0';
475
772
  }
773
+
774
+ // ============================================================================
775
+ // Federation Update (Renegotiation) Protocol
776
+ // ============================================================================
777
+
778
+ /**
779
+ * @typedef {Object} FederationUpdatePayload
780
+ * @property {'federation_update'} type
781
+ * @property {'1.0'} version
782
+ * @property {string} updateId - Unique update ID
783
+ * @property {number} timestamp
784
+ * @property {string} senderHolonId
785
+ * @property {string} senderHolonName
786
+ * @property {string} senderNpub
787
+ * @property {Object} newLensConfig - New lens configuration
788
+ * @property {string[]} newLensConfig.inbound
789
+ * @property {string[]} newLensConfig.outbound
790
+ * @property {string} [message]
791
+ */
792
+
793
+ /**
794
+ * @typedef {Object} FederationUpdateResponsePayload
795
+ * @property {'federation_update_response'} type
796
+ * @property {'1.0'} version
797
+ * @property {string} updateId - References original update
798
+ * @property {number} timestamp
799
+ * @property {'accepted'|'rejected'} status
800
+ * @property {string} [message]
801
+ */
802
+
803
+ /**
804
+ * Create a federation update payload
805
+ * @param {Object} params
806
+ * @param {string} params.senderHolonId
807
+ * @param {string} params.senderHolonName
808
+ * @param {string} params.senderPubKey
809
+ * @param {Object} params.newLensConfig
810
+ * @param {string} [params.message]
811
+ * @returns {FederationUpdatePayload}
812
+ */
813
+ export function createFederationUpdate({
814
+ senderHolonId,
815
+ senderHolonName,
816
+ senderPubKey,
817
+ newLensConfig,
818
+ message,
819
+ }) {
820
+ return {
821
+ type: 'federation_update',
822
+ version: '1.0',
823
+ updateId: generateNonce(),
824
+ timestamp: Date.now(),
825
+ senderHolonId,
826
+ senderHolonName,
827
+ senderNpub: hexToNpub(senderPubKey),
828
+ newLensConfig,
829
+ message,
830
+ };
831
+ }
832
+
833
+ /**
834
+ * Create a federation update response payload
835
+ * @param {Object} params
836
+ * @param {string} params.updateId
837
+ * @param {'accepted'|'rejected'} params.status
838
+ * @param {string} [params.message]
839
+ * @returns {FederationUpdateResponsePayload}
840
+ */
841
+ export function createFederationUpdateResponse({
842
+ updateId,
843
+ status,
844
+ message,
845
+ }) {
846
+ return {
847
+ type: 'federation_update_response',
848
+ version: '1.0',
849
+ updateId,
850
+ timestamp: Date.now(),
851
+ status,
852
+ message,
853
+ };
854
+ }
855
+
856
+ /**
857
+ * Send a federation update request DM
858
+ * @param {Object} client - NostrClient instance
859
+ * @param {string} privateKey - Sender's hex private key
860
+ * @param {string} recipientPubKey - Recipient's hex public key
861
+ * @param {FederationUpdatePayload} update - Update payload
862
+ * @returns {Promise<boolean>}
863
+ */
864
+ export async function sendFederationUpdate(client, privateKey, recipientPubKey, update) {
865
+ try {
866
+ const content = JSON.stringify(update);
867
+ const encrypted = encryptNIP44(privateKey, recipientPubKey, content);
868
+ const event = createDMEvent(recipientPubKey, encrypted, privateKey);
869
+
870
+ if (client?.publish) {
871
+ await client.publish(event);
872
+ console.log('[Handshake] Federation update sent to:', recipientPubKey.substring(0, 8) + '...');
873
+ return true;
874
+ }
875
+ return false;
876
+ } catch (error) {
877
+ console.error('[Handshake] Failed to send federation update:', error);
878
+ return false;
879
+ }
880
+ }
881
+
882
+ /**
883
+ * Send a federation update response DM
884
+ * @param {Object} client - NostrClient instance
885
+ * @param {string} privateKey - Sender's hex private key
886
+ * @param {string} recipientPubKey - Recipient's hex public key
887
+ * @param {FederationUpdateResponsePayload} response - Response payload
888
+ * @returns {Promise<boolean>}
889
+ */
890
+ export async function sendFederationUpdateResponse(client, privateKey, recipientPubKey, response) {
891
+ try {
892
+ const content = JSON.stringify(response);
893
+ const encrypted = encryptNIP44(privateKey, recipientPubKey, content);
894
+ const event = createDMEvent(recipientPubKey, encrypted, privateKey);
895
+
896
+ if (client?.publish) {
897
+ await client.publish(event);
898
+ console.log('[Handshake] Federation update response sent to:', recipientPubKey.substring(0, 8) + '...');
899
+ return true;
900
+ }
901
+ return false;
902
+ } catch (error) {
903
+ console.error('[Handshake] Failed to send federation update response:', error);
904
+ return false;
905
+ }
906
+ }
907
+
908
+ /**
909
+ * Request a federation update (lens config change)
910
+ * @param {Object} holosphere - HoloSphere instance
911
+ * @param {string} privateKey - Sender's hex private key
912
+ * @param {Object} params
913
+ * @param {string} params.partnerPubKey - Partner's hex public key
914
+ * @param {string} params.holonId - Current holon ID
915
+ * @param {string} params.holonName - Current holon name
916
+ * @param {Object} params.newLensConfig - New lens configuration
917
+ * @param {string} [params.message]
918
+ * @returns {Promise<Object>}
919
+ */
920
+ export async function requestFederationUpdate(holosphere, privateKey, params) {
921
+ const { partnerPubKey, holonId, holonName, newLensConfig, message } = params;
922
+
923
+ try {
924
+ const senderPubKey = getPublicKey(privateKey);
925
+
926
+ const update = createFederationUpdate({
927
+ senderHolonId: holonId,
928
+ senderHolonName: holonName,
929
+ senderPubKey,
930
+ newLensConfig,
931
+ message,
932
+ });
933
+
934
+ const sent = await sendFederationUpdate(holosphere.client, privateKey, partnerPubKey, update);
935
+
936
+ if (sent) {
937
+ return { success: true, updateId: update.updateId };
938
+ }
939
+ return { success: false, error: 'Failed to send update DM' };
940
+ } catch (error) {
941
+ return { success: false, error: error.message };
942
+ }
943
+ }
944
+
945
+ /**
946
+ * Accept a federation update request
947
+ * @param {Object} holosphere - HoloSphere instance
948
+ * @param {string} privateKey - Responder's hex private key
949
+ * @param {Object} params
950
+ * @param {string} params.updateId - Update ID
951
+ * @param {string} params.senderPubKey - Original sender's public key
952
+ * @param {string} params.holonId - Our holon ID
953
+ * @param {Object} params.newLensConfig - The new lens config to apply
954
+ * @param {string} [params.message]
955
+ * @returns {Promise<Object>}
956
+ */
957
+ export async function acceptFederationUpdate(holosphere, privateKey, params) {
958
+ const { updateId, senderPubKey, holonId, newLensConfig, message } = params;
959
+
960
+ try {
961
+ // Update the local federation with new lens config
962
+ if (holosphere.federateHolon) {
963
+ await holosphere.federateHolon(holonId, senderPubKey, {
964
+ lensConfig: newLensConfig,
965
+ });
966
+ }
967
+
968
+ // Send acceptance response
969
+ const response = createFederationUpdateResponse({
970
+ updateId,
971
+ status: 'accepted',
972
+ message,
973
+ });
974
+
975
+ const sent = await sendFederationUpdateResponse(holosphere.client, privateKey, senderPubKey, response);
976
+
977
+ return { success: sent, error: sent ? undefined : 'Failed to send response' };
978
+ } catch (error) {
979
+ return { success: false, error: error.message };
980
+ }
981
+ }
982
+
983
+ /**
984
+ * Reject a federation update request
985
+ * @param {Object} holosphere - HoloSphere instance
986
+ * @param {string} privateKey - Responder's hex private key
987
+ * @param {Object} params
988
+ * @param {string} params.updateId - Update ID
989
+ * @param {string} params.senderPubKey - Original sender's public key
990
+ * @param {string} [params.message]
991
+ * @returns {Promise<Object>}
992
+ */
993
+ export async function rejectFederationUpdate(holosphere, privateKey, params) {
994
+ const { updateId, senderPubKey, message } = params;
995
+
996
+ try {
997
+ const response = createFederationUpdateResponse({
998
+ updateId,
999
+ status: 'rejected',
1000
+ message,
1001
+ });
1002
+
1003
+ const sent = await sendFederationUpdateResponse(holosphere.client, privateKey, senderPubKey, response);
1004
+
1005
+ return { success: sent, error: sent ? undefined : 'Failed to send response' };
1006
+ } catch (error) {
1007
+ return { success: false, error: error.message };
1008
+ }
1009
+ }
1010
+
1011
+ /**
1012
+ * Check if payload is a federation update
1013
+ * @param {*} payload
1014
+ * @returns {boolean}
1015
+ */
1016
+ export function isFederationUpdate(payload) {
1017
+ return payload?.type === 'federation_update' && payload?.version === '1.0';
1018
+ }
1019
+
1020
+ /**
1021
+ * Check if payload is a federation update response
1022
+ * @param {*} payload
1023
+ * @returns {boolean}
1024
+ */
1025
+ export function isFederationUpdateResponse(payload) {
1026
+ return payload?.type === 'federation_update_response' && payload?.version === '1.0';
1027
+ }