holosphere 2.0.0-alpha13 → 2.0.0-alpha15

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 (50) hide show
  1. package/dist/2019-ATLjawsU.cjs +8 -0
  2. package/dist/{2019-Cp3uYhyY.cjs.map → 2019-ATLjawsU.cjs.map} +1 -1
  3. package/dist/{2019-CLMqIAfQ.js → 2019-BfjzDRje.js} +1667 -1721
  4. package/dist/{2019-CLMqIAfQ.js.map → 2019-BfjzDRje.js.map} +1 -1
  5. package/dist/{browser-nUQt1cnB.js → browser-CKTczilW.js} +2 -2
  6. package/dist/{browser-nUQt1cnB.js.map → browser-CKTczilW.js.map} +1 -1
  7. package/dist/{browser-D6cNVl0v.cjs → browser-GOg6KKOV.cjs} +2 -2
  8. package/dist/{browser-D6cNVl0v.cjs.map → browser-GOg6KKOV.cjs.map} +1 -1
  9. package/dist/cjs/holosphere.cjs +1 -1
  10. package/dist/esm/holosphere.js +25 -21
  11. package/dist/{index-CoAjtqsD.js → index-BdnrGafX.js} +2 -2
  12. package/dist/{index-CoAjtqsD.js.map → index-BdnrGafX.js.map} +1 -1
  13. package/dist/{index-BN_uoxQK.js → index-C3Cag0SV.js} +2558 -495
  14. package/dist/index-C3Cag0SV.js.map +1 -0
  15. package/dist/index-ChpSfdYS.cjs +29 -0
  16. package/dist/index-ChpSfdYS.cjs.map +1 -0
  17. package/dist/{index-DJjGSwXG.cjs → index-D_QecZNu.cjs} +2 -2
  18. package/dist/{index-DJjGSwXG.cjs.map → index-D_QecZNu.cjs.map} +1 -1
  19. package/dist/{index-Z5TstN1e.js → index-nMC3dWZ5.js} +2 -2
  20. package/dist/{index-Z5TstN1e.js.map → index-nMC3dWZ5.js.map} +1 -1
  21. package/dist/{index-Cp3tI53z.cjs → index-z5HWfWMu.cjs} +2 -2
  22. package/dist/{index-Cp3tI53z.cjs.map → index-z5HWfWMu.cjs.map} +1 -1
  23. package/dist/{indexeddb-storage-CZK5A7XH.cjs → indexeddb-storage-DWSeL-YF.cjs} +2 -2
  24. package/dist/{indexeddb-storage-CZK5A7XH.cjs.map → indexeddb-storage-DWSeL-YF.cjs.map} +1 -1
  25. package/dist/{indexeddb-storage-bpA01pAU.js → indexeddb-storage-dx01N0ET.js} +2 -2
  26. package/dist/{indexeddb-storage-bpA01pAU.js.map → indexeddb-storage-dx01N0ET.js.map} +1 -1
  27. package/dist/{memory-storage-BqhmytP_.js → memory-storage-BPIfkpcf.js} +2 -2
  28. package/dist/{memory-storage-BqhmytP_.js.map → memory-storage-BPIfkpcf.js.map} +1 -1
  29. package/dist/{memory-storage-B1k8Jszd.cjs → memory-storage-CKUGDq2d.cjs} +2 -2
  30. package/dist/{memory-storage-B1k8Jszd.cjs.map → memory-storage-CKUGDq2d.cjs.map} +1 -1
  31. package/package.json +3 -1
  32. package/scripts/test-ndk-direct.js +104 -0
  33. package/src/crypto/key-store.js +356 -0
  34. package/src/crypto/lens-keys.js +205 -0
  35. package/src/crypto/secp256k1.js +181 -18
  36. package/src/federation/handshake.js +317 -23
  37. package/src/federation/hologram.js +25 -17
  38. package/src/federation/registry.js +779 -59
  39. package/src/index.js +416 -27
  40. package/src/lib/federation-methods.js +308 -4
  41. package/src/storage/nostr-async.js +144 -86
  42. package/src/storage/nostr-client.js +77 -18
  43. package/src/storage/nostr-wrapper.js +4 -1
  44. package/src/storage/unified-storage.js +5 -4
  45. package/src/subscriptions/manager.js +1 -1
  46. package/vitest.config.js +6 -1
  47. package/dist/2019-Cp3uYhyY.cjs +0 -8
  48. package/dist/index-BN_uoxQK.js.map +0 -1
  49. package/dist/index-V8EHMYEY.cjs +0 -29
  50. package/dist/index-V8EHMYEY.cjs.map +0 -1
@@ -17,27 +17,52 @@ import {
17
17
  generateNonce,
18
18
  getPublicKey,
19
19
  } from '../crypto/nostr-utils.js';
20
- import { addFederatedPartner, storeInboundCapability } from './registry.js';
20
+ import {
21
+ addFederatedPartner,
22
+ storeInboundCapability,
23
+ grantAccess,
24
+ convertLegacyLensConfig,
25
+ convertToLegacyLensConfig,
26
+ } from './registry.js';
21
27
  import { registerHolon } from './holon-registry.js';
22
28
  import { issueCapability } from '../crypto/secp256k1.js';
23
29
  import * as cardStorage from './card-storage.js';
30
+ import { buildLensPath } from '../crypto/key-store.js';
24
31
 
25
32
  // ============================================================================
26
33
  // Types (documented for reference)
27
34
  // ============================================================================
28
35
 
36
+ /**
37
+ * @typedef {Object} LensConfigV1 - Legacy v1.0 lens configuration format
38
+ * @property {string[]} inbound - Lenses sender will accept (read)
39
+ * @property {string[]} outbound - Lenses sender will share (read)
40
+ * @property {string[]} [writeInbound] - Lenses sender wants write access to
41
+ * @property {string[]} [writeOutbound] - Lenses sender will grant write access for
42
+ */
43
+
44
+ /**
45
+ * @typedef {Object} LensPermissions - v2.0 permissions for a single lens
46
+ * @property {string[]} receive - Permissions received from partner ('read', 'write')
47
+ * @property {string[]} share - Permissions shared with partner ('read', 'write')
48
+ */
49
+
50
+ /**
51
+ * @typedef {Object} LensConfigV2 - Unified v2.0 lens configuration format
52
+ * @property {'2.0'} version - Format version
53
+ * @property {Object.<string, LensPermissions>} permissions - Per-lens permissions
54
+ */
55
+
29
56
  /**
30
57
  * @typedef {Object} FederationRequestPayload
31
58
  * @property {'federation_request'} type
32
- * @property {'1.0'} version
59
+ * @property {'1.0'|'2.0'} version - Protocol version
33
60
  * @property {string} requestId - Unique request ID
34
61
  * @property {number} timestamp
35
62
  * @property {string} senderHolonId - Holon ID of the sender
36
63
  * @property {string} senderHolonName - Human-readable holon name
37
64
  * @property {string} senderNpub - Sender's npub
38
- * @property {Object} lensConfig - Lens configuration
39
- * @property {string[]} lensConfig.inbound - Lenses sender will accept
40
- * @property {string[]} lensConfig.outbound - Lenses sender will share
65
+ * @property {LensConfigV1|LensConfigV2} lensConfig - Lens configuration (v1 or v2 format)
41
66
  * @property {Array} capabilities - Capability tokens
42
67
  * @property {string} [message] - Optional personal message
43
68
  */
@@ -45,18 +70,87 @@ import * as cardStorage from './card-storage.js';
45
70
  /**
46
71
  * @typedef {Object} FederationResponsePayload
47
72
  * @property {'federation_response'} type
48
- * @property {'1.0'} version
73
+ * @property {'1.0'|'2.0'} version - Protocol version
49
74
  * @property {string} requestId - References original request
50
75
  * @property {number} timestamp
51
76
  * @property {'accepted'|'rejected'} status
52
77
  * @property {string} [responderHolonId]
53
78
  * @property {string} [responderHolonName]
54
79
  * @property {string} [responderNpub]
55
- * @property {Object} [lensConfig]
80
+ * @property {LensConfigV1|LensConfigV2} [lensConfig] - Lens configuration
56
81
  * @property {Array} [capabilities]
57
82
  * @property {string} [message]
58
83
  */
59
84
 
85
+ /**
86
+ * @typedef {Object} LensKeySharePayload
87
+ * @property {'lens_key_share'} type
88
+ * @property {'1.0'} version - Protocol version
89
+ * @property {string} lensPath - Path identifying the lens (appName/holonId/lensName)
90
+ * @property {string} wrappedKey - NIP-44 wrapped symmetric key
91
+ * @property {number} timestamp
92
+ */
93
+
94
+ // ============================================================================
95
+ // Lens Config Format Utilities
96
+ // ============================================================================
97
+
98
+ /**
99
+ * Check if a lensConfig is in v2.0 format
100
+ * @param {Object} lensConfig - Lens configuration
101
+ * @returns {boolean} True if v2.0 format
102
+ */
103
+ export function isV2LensConfig(lensConfig) {
104
+ return lensConfig && lensConfig.version === '2.0' && lensConfig.permissions;
105
+ }
106
+
107
+ /**
108
+ * Normalize lensConfig to ensure it has a consistent format.
109
+ * Accepts either v1.0 or v2.0 format and returns normalized v1.0 for backward compat.
110
+ *
111
+ * @param {Object} lensConfig - Input lens configuration (v1 or v2)
112
+ * @returns {Object} Normalized v1.0 format { inbound, outbound, writeInbound, writeOutbound }
113
+ */
114
+ export function normalizeLensConfig(lensConfig) {
115
+ if (!lensConfig) {
116
+ return { inbound: [], outbound: [], writeInbound: [], writeOutbound: [] };
117
+ }
118
+
119
+ // If v2.0 format, convert to v1.0
120
+ if (isV2LensConfig(lensConfig)) {
121
+ return convertToLegacyLensConfig(lensConfig);
122
+ }
123
+
124
+ // Already v1.0 format, just ensure all fields exist
125
+ return {
126
+ inbound: lensConfig.inbound || [],
127
+ outbound: lensConfig.outbound || [],
128
+ writeInbound: lensConfig.writeInbound || [],
129
+ writeOutbound: lensConfig.writeOutbound || [],
130
+ };
131
+ }
132
+
133
+ /**
134
+ * Create unified permissions from lensConfig.
135
+ * Converts any format to the unified v2.0 format.
136
+ *
137
+ * @param {Object} lensConfig - Input lens configuration (v1 or v2)
138
+ * @returns {Object} v2.0 format { version: '2.0', permissions: {...} }
139
+ */
140
+ export function toUnifiedPermissions(lensConfig) {
141
+ if (!lensConfig) {
142
+ return { version: '2.0', permissions: {} };
143
+ }
144
+
145
+ // If already v2.0, return as-is
146
+ if (isV2LensConfig(lensConfig)) {
147
+ return lensConfig;
148
+ }
149
+
150
+ // Convert v1.0 to v2.0
151
+ return convertLegacyLensConfig(lensConfig);
152
+ }
153
+
60
154
  // ============================================================================
61
155
  // Request/Response Creation
62
156
  // ============================================================================
@@ -67,28 +161,35 @@ import * as cardStorage from './card-storage.js';
67
161
  * @param {string} params.senderHolonId
68
162
  * @param {string} params.senderHolonName
69
163
  * @param {string} params.senderPubKey - Hex public key
70
- * @param {Object} params.lensConfig - { inbound: string[], outbound: string[] }
164
+ * @param {Object} params.lensConfig - Lens config (v1 or v2 format supported)
71
165
  * @param {Array} [params.capabilities] - Capability tokens to include
72
166
  * @param {string} [params.message] - Optional message
167
+ * @param {boolean} [params.useV2=false] - Send in v2.0 format
73
168
  * @returns {FederationRequestPayload}
74
169
  */
75
170
  export function createFederationRequest({
76
171
  senderHolonId,
77
172
  senderHolonName,
78
173
  senderPubKey,
79
- lensConfig = { inbound: [], outbound: [] },
174
+ lensConfig = { inbound: [], outbound: [], writeInbound: [], writeOutbound: [] },
80
175
  capabilities = [],
81
176
  message,
177
+ useV2 = false,
82
178
  }) {
179
+ // Normalize lensConfig
180
+ const normalizedLensConfig = useV2
181
+ ? toUnifiedPermissions(lensConfig)
182
+ : normalizeLensConfig(lensConfig);
183
+
83
184
  return {
84
185
  type: 'federation_request',
85
- version: '1.0',
186
+ version: useV2 ? '2.0' : '1.0',
86
187
  requestId: generateNonce(),
87
188
  timestamp: Date.now(),
88
189
  senderHolonId,
89
190
  senderHolonName,
90
191
  senderNpub: hexToNpub(senderPubKey),
91
- lensConfig,
192
+ lensConfig: normalizedLensConfig,
92
193
  capabilities,
93
194
  message,
94
195
  };
@@ -102,9 +203,10 @@ export function createFederationRequest({
102
203
  * @param {string} [params.responderHolonId]
103
204
  * @param {string} [params.responderHolonName]
104
205
  * @param {string} [params.responderPubKey] - Hex public key
105
- * @param {Object} [params.lensConfig]
206
+ * @param {Object} [params.lensConfig] - Lens config (v1 or v2 format supported)
106
207
  * @param {Array} [params.capabilities]
107
208
  * @param {string} [params.message]
209
+ * @param {boolean} [params.useV2=false] - Respond in v2.0 format
108
210
  * @returns {FederationResponsePayload}
109
211
  */
110
212
  export function createFederationResponse({
@@ -116,17 +218,26 @@ export function createFederationResponse({
116
218
  lensConfig,
117
219
  capabilities,
118
220
  message,
221
+ useV2 = false,
119
222
  }) {
223
+ // Normalize lensConfig if provided
224
+ let normalizedLensConfig;
225
+ if (lensConfig) {
226
+ normalizedLensConfig = useV2
227
+ ? toUnifiedPermissions(lensConfig)
228
+ : normalizeLensConfig(lensConfig);
229
+ }
230
+
120
231
  return {
121
232
  type: 'federation_response',
122
- version: '1.0',
233
+ version: useV2 ? '2.0' : '1.0',
123
234
  requestId,
124
235
  timestamp: Date.now(),
125
236
  status,
126
237
  responderHolonId,
127
238
  responderHolonName,
128
239
  responderNpub: responderPubKey ? hexToNpub(responderPubKey) : undefined,
129
- lensConfig,
240
+ lensConfig: normalizedLensConfig,
130
241
  capabilities,
131
242
  message,
132
243
  };
@@ -192,6 +303,45 @@ export async function sendFederationResponse(client, privateKey, recipientPubKey
192
303
  }
193
304
  }
194
305
 
306
+ /**
307
+ * Send a lens key share DM
308
+ * Used to share encrypted lens keys with federation partners
309
+ * @param {Object} client - NostrClient instance with publish method
310
+ * @param {string} privateKey - Sender's hex private key
311
+ * @param {string} recipientPubKey - Recipient's hex public key
312
+ * @param {Object} keyShare - Key share payload
313
+ * @param {string} keyShare.lensPath - Path identifying the lens
314
+ * @param {string} keyShare.wrappedKey - NIP-44 wrapped symmetric key for recipient
315
+ * @returns {Promise<boolean>} Success indicator
316
+ */
317
+ export async function sendLensKeyShare(client, privateKey, recipientPubKey, keyShare) {
318
+ try {
319
+ const payload = {
320
+ type: 'lens_key_share',
321
+ version: '1.0',
322
+ lensPath: keyShare.lensPath,
323
+ wrappedKey: keyShare.wrappedKey,
324
+ timestamp: Date.now(),
325
+ };
326
+
327
+ const content = JSON.stringify(payload);
328
+ const encrypted = encryptNIP44(privateKey, recipientPubKey, content);
329
+ const event = createDMEvent(recipientPubKey, encrypted, privateKey);
330
+
331
+ if (client?.publish) {
332
+ await client.publish(event);
333
+ console.log('[Handshake] Lens key share sent to:', recipientPubKey.substring(0, 8) + '...', 'for lens:', keyShare.lensPath);
334
+ return true;
335
+ } else {
336
+ console.error('[Handshake] No publish method on client');
337
+ return false;
338
+ }
339
+ } catch (error) {
340
+ console.error('[Handshake] Failed to send lens key share:', error);
341
+ return false;
342
+ }
343
+ }
344
+
195
345
  // ============================================================================
196
346
  // DM Subscription
197
347
  // ============================================================================
@@ -208,12 +358,13 @@ export async function sendFederationResponse(client, privateKey, recipientPubKey
208
358
  * @param {Function} handlers.onResponse - Called with (response, senderPubKey)
209
359
  * @param {Function} [handlers.onUpdate] - Called with (update, senderPubKey)
210
360
  * @param {Function} [handlers.onUpdateResponse] - Called with (response, senderPubKey)
361
+ * @param {Function} [handlers.onKeyShare] - Called with (keyShare, senderPubKey) for encrypted lens key shares
211
362
  * @param {Object} options - Subscription options
212
363
  * @param {string} options.appname - Application namespace (for persistent storage)
213
364
  * @returns {Function} Unsubscribe function
214
365
  */
215
366
  export function subscribeToFederationDMs(client, privateKey, publicKey, handlers, options = {}) {
216
- const { onRequest, onResponse, onUpdate, onUpdateResponse } = handlers;
367
+ const { onRequest, onResponse, onUpdate, onUpdateResponse, onKeyShare } = handlers;
217
368
  const { appname } = options;
218
369
  let isActive = true;
219
370
 
@@ -348,6 +499,23 @@ export function subscribeToFederationDMs(client, privateKey, publicKey, handlers
348
499
 
349
500
  console.log('[Handshake] Received federation update response from:', event.pubkey.substring(0, 8) + '...');
350
501
  onUpdateResponse?.(payload, event.pubkey);
502
+ } else if (payload.type === 'lens_key_share' && payload.version === '1.0') {
503
+ // Lens key share - receive encrypted symmetric key from partner
504
+ const keyShareKey = `key_${payload.lensPath}_${event.pubkey}`;
505
+ if (processedResponseIds.has(keyShareKey)) {
506
+ console.log('[Handshake] Skipping already processed key share:', keyShareKey);
507
+ return;
508
+ }
509
+
510
+ // Mark as processed
511
+ if (appname && client?.client) {
512
+ await cardStorage.markDMProcessed(client.client, appname, event.id, 'key_share');
513
+ processedResponseIds.add(keyShareKey);
514
+ processedDMIds.add(event.id);
515
+ }
516
+
517
+ console.log('[Handshake] Received lens key share from:', event.pubkey.substring(0, 8) + '...', 'for lens:', payload.lensPath);
518
+ onKeyShare?.(payload, event.pubkey);
351
519
  }
352
520
  } catch (error) {
353
521
  // Silently ignore non-federation DMs
@@ -430,7 +598,7 @@ export async function initiateFederationHandshake(holosphere, privateKey, params
430
598
  partnerPubKey,
431
599
  holonId,
432
600
  holonName,
433
- lensConfig = { inbound: [], outbound: [] },
601
+ lensConfig = { inbound: [], outbound: [], writeInbound: [], writeOutbound: [] },
434
602
  message,
435
603
  } = params;
436
604
 
@@ -438,9 +606,17 @@ export async function initiateFederationHandshake(holosphere, privateKey, params
438
606
  // Get sender's public key
439
607
  const senderPubKey = getPublicKey(privateKey);
440
608
 
609
+ // Normalize lensConfig
610
+ const normalizedLensConfig = {
611
+ inbound: lensConfig.inbound || [],
612
+ outbound: lensConfig.outbound || [],
613
+ writeInbound: lensConfig.writeInbound || [],
614
+ writeOutbound: lensConfig.writeOutbound || [],
615
+ };
616
+
441
617
  // Issue capabilities for outbound lenses (lenses we're sharing with partner)
442
618
  const capabilities = [];
443
- for (const lensName of (lensConfig.outbound || [])) {
619
+ for (const lensName of normalizedLensConfig.outbound) {
444
620
  try {
445
621
  const token = await issueCapability(
446
622
  ['read'],
@@ -462,12 +638,49 @@ export async function initiateFederationHandshake(holosphere, privateKey, params
462
638
  }
463
639
  }
464
640
 
641
+ // Grant write access to partner for our writeOutbound lenses
642
+ // This allows the partner to write to these lenses in our holon
643
+ if (holosphere.client) {
644
+ for (const lensName of normalizedLensConfig.writeOutbound) {
645
+ try {
646
+ // New unified grant
647
+ await grantAccess(
648
+ holosphere.client,
649
+ holosphere.config.appName,
650
+ partnerPubKey,
651
+ { holonId: '*', lensName },
652
+ ['write'],
653
+ { direction: 'inbound' }
654
+ );
655
+ console.log(`[Handshake] Pre-granted write access to partner for lens "${lensName}"`);
656
+ } catch (err) {
657
+ console.warn(`[Handshake] Failed to grant write access for ${lensName}:`, err.message);
658
+ }
659
+ }
660
+
661
+ // Also grant read access for outbound lenses using unified format
662
+ for (const lensName of normalizedLensConfig.outbound) {
663
+ try {
664
+ await grantAccess(
665
+ holosphere.client,
666
+ holosphere.config.appName,
667
+ partnerPubKey,
668
+ { holonId: '*', lensName },
669
+ ['read'],
670
+ { direction: 'outbound' }
671
+ );
672
+ } catch (err) {
673
+ console.warn(`[Handshake] Failed to grant read access for ${lensName}:`, err.message);
674
+ }
675
+ }
676
+ }
677
+
465
678
  // Create federation request
466
679
  const request = createFederationRequest({
467
680
  senderHolonId: holonId,
468
681
  senderHolonName: holonName,
469
682
  senderPubKey,
470
- lensConfig,
683
+ lensConfig: normalizedLensConfig,
471
684
  capabilities,
472
685
  message,
473
686
  });
@@ -513,7 +726,7 @@ export async function acceptFederationRequest(holosphere, privateKey, params) {
513
726
  senderPubKey,
514
727
  holonId,
515
728
  holonName,
516
- lensConfig = { inbound: [], outbound: [] },
729
+ lensConfig = { inbound: [], outbound: [], writeInbound: [], writeOutbound: [] },
517
730
  message,
518
731
  } = params;
519
732
 
@@ -521,6 +734,14 @@ export async function acceptFederationRequest(holosphere, privateKey, params) {
521
734
  // Get responder's public key
522
735
  const responderPubKey = getPublicKey(privateKey);
523
736
 
737
+ // Normalize lensConfig
738
+ const normalizedLensConfig = {
739
+ inbound: lensConfig.inbound || [],
740
+ outbound: lensConfig.outbound || [],
741
+ writeInbound: lensConfig.writeInbound || [],
742
+ writeOutbound: lensConfig.writeOutbound || [],
743
+ };
744
+
524
745
  // Add sender as federated partner in Nostr registry
525
746
  if (holosphere.client) {
526
747
  await addFederatedPartner(holosphere.client, holosphere.config.appName, senderPubKey, {
@@ -535,6 +756,48 @@ export async function acceptFederationRequest(holosphere, privateKey, params) {
535
756
  }
536
757
  }
537
758
 
759
+ // Process write grants from sender (they offered writeOutbound = we get write access)
760
+ const senderWriteOutbound = request.lensConfig?.writeOutbound || [];
761
+ if (senderWriteOutbound.length > 0) {
762
+ console.log('[Handshake] Sender is granting write access for lenses:', senderWriteOutbound);
763
+ // Note: This is tracked on the sender's side - we just acknowledge it in the response
764
+ }
765
+
766
+ // Grant write access to sender for our writeOutbound lenses
767
+ // This allows the sender to write to these lenses in our holon
768
+ for (const lensName of normalizedLensConfig.writeOutbound) {
769
+ try {
770
+ // New unified grant
771
+ await grantAccess(
772
+ holosphere.client,
773
+ holosphere.config.appName,
774
+ senderPubKey,
775
+ { holonId: '*', lensName },
776
+ ['write'],
777
+ { direction: 'inbound' }
778
+ );
779
+ console.log(`[Handshake] Granted write access to sender for lens "${lensName}"`);
780
+ } catch (err) {
781
+ console.warn(`[Handshake] Failed to grant write access for ${lensName}:`, err.message);
782
+ }
783
+ }
784
+
785
+ // Also grant read access for outbound lenses using unified format
786
+ for (const lensName of normalizedLensConfig.outbound) {
787
+ try {
788
+ await grantAccess(
789
+ holosphere.client,
790
+ holosphere.config.appName,
791
+ senderPubKey,
792
+ { holonId: '*', lensName },
793
+ ['read'],
794
+ { direction: 'outbound' }
795
+ );
796
+ } catch (err) {
797
+ console.warn(`[Handshake] Failed to grant read access for ${lensName}:`, err.message);
798
+ }
799
+ }
800
+
538
801
  // Register the sender's holon -> pubkey mapping in our holon registry
539
802
  // This is critical for resolveHolonToPubkey to work when navigating to their holon
540
803
  if (request.senderHolonId) {
@@ -552,13 +815,13 @@ export async function acceptFederationRequest(holosphere, privateKey, params) {
552
815
  // This is what makes the partner appear in getFederation() / loadFederationData()
553
816
  // Store the sender's name so we can display it without reading their settings
554
817
  await holosphere.federateHolon(holonId, senderPubKey, {
555
- lensConfig,
818
+ lensConfig: normalizedLensConfig,
556
819
  partnerName: request.senderHolonName
557
820
  });
558
821
 
559
822
  // Issue capabilities for our outbound lenses (lenses we're sharing with partner)
560
823
  const capabilities = [];
561
- for (const lensName of (lensConfig.outbound || [])) {
824
+ for (const lensName of normalizedLensConfig.outbound) {
562
825
  try {
563
826
  const token = await issueCapability(
564
827
  ['read'],
@@ -580,6 +843,37 @@ export async function acceptFederationRequest(holosphere, privateKey, params) {
580
843
  }
581
844
  }
582
845
 
846
+ // Share lens keys for encrypted outbound lenses
847
+ // This allows the partner to decrypt data from our shared lenses
848
+ if (holosphere.keyStore && holosphere.client?.privateKey) {
849
+ for (const lensName of normalizedLensConfig.outbound) {
850
+ const lensPath = buildLensPath(holosphere.config.appName, responderPubKey, lensName);
851
+ const lensKey = holosphere.keyStore.getKey(lensPath);
852
+
853
+ if (lensKey) {
854
+ try {
855
+ // Wrap the lens key for the partner
856
+ const wrappedKey = holosphere.keyStore.wrapKeyForPartner(
857
+ lensPath,
858
+ holosphere.client.privateKey,
859
+ senderPubKey
860
+ );
861
+
862
+ if (wrappedKey) {
863
+ // Send the wrapped key via encrypted DM
864
+ await sendLensKeyShare(holosphere.client, privateKey, senderPubKey, {
865
+ lensPath,
866
+ wrappedKey,
867
+ });
868
+ console.log(`[Handshake] Shared lens key for "${lensName}" with partner`);
869
+ }
870
+ } catch (err) {
871
+ console.warn(`[Handshake] Failed to share lens key for ${lensName}:`, err.message);
872
+ }
873
+ }
874
+ }
875
+ }
876
+
583
877
  // Create and send response
584
878
  const response = createFederationResponse({
585
879
  requestId: request.requestId,
@@ -587,7 +881,7 @@ export async function acceptFederationRequest(holosphere, privateKey, params) {
587
881
  responderHolonId: holonId,
588
882
  responderHolonName: holonName,
589
883
  responderPubKey,
590
- lensConfig,
884
+ lensConfig: normalizedLensConfig,
591
885
  capabilities,
592
886
  message,
593
887
  });
@@ -604,7 +898,7 @@ export async function acceptFederationRequest(holosphere, privateKey, params) {
604
898
  // Auto-receive federated lens data as holograms for configured inbound lenses
605
899
  // This enables the user to immediately see the partner's data in their holon
606
900
  const receivedHolograms = {};
607
- const inboundLenses = lensConfig.inbound || [];
901
+ const inboundLenses = normalizedLensConfig.inbound;
608
902
 
609
903
  if (inboundLenses.length > 0 && holosphere.receiveFederatedLens) {
610
904
  console.log('[Handshake] Receiving federated lens data as holograms...');
@@ -16,7 +16,7 @@
16
16
  */
17
17
 
18
18
  import { buildPath, write, read, update } from '../storage/unified-storage.js';
19
- import { verifyCapability, issueCapability } from '../crypto/secp256k1.js';
19
+ import { verifyCapability, issueCapability, verifyCapabilityIssuer, decodeCapability } from '../crypto/secp256k1.js';
20
20
  import { getCapabilityForAuthor, storeInboundCapability } from './registry.js';
21
21
 
22
22
  /** @constant {number} Maximum depth for hologram resolution chain */
@@ -134,6 +134,7 @@ export function createHologram(sourceHolon, targetHolon, lensName, dataId, appna
134
134
  capability, // Always the token string (normalized above)
135
135
  _meta: {
136
136
  created: Date.now(),
137
+ lastUpdated: Date.now(),
137
138
  sourceHolon,
138
139
  source: sourceHolon, // Alias for compatibility
139
140
  sourcePubKey: authorPubKey,
@@ -158,6 +159,7 @@ export function createHologram(sourceHolon, targetHolon, lensName, dataId, appna
158
159
  * @param {string} appname - Application namespace
159
160
  * @param {Object} options - Hologram options
160
161
  * @param {string} [options.sourceAuthorPubKey] - Source author's public key (defaults to client.publicKey)
162
+ * @param {string} [options.sourceAuthorPrivKey] - Source author's private key for signing capability (defaults to client.privateKey)
161
163
  * @param {string} [options.targetAuthorPubKey] - Target author's public key (defaults to client.publicKey)
162
164
  * @param {string} [options.capability] - Pre-issued capability (if not provided, one will be issued)
163
165
  * @param {string[]} [options.permissions=['read']] - Permissions for auto-issued capability
@@ -167,6 +169,7 @@ export function createHologram(sourceHolon, targetHolon, lensName, dataId, appna
167
169
  export async function createHologramWithCapability(client, sourceHolon, targetHolon, lensName, dataId, appname, options = {}) {
168
170
  const {
169
171
  sourceAuthorPubKey = client.publicKey,
172
+ sourceAuthorPrivKey = client.privateKey, // Allow passing author's private key for signing
170
173
  targetAuthorPubKey = client.publicKey,
171
174
  capability: providedCapability = null,
172
175
  permissions = ['read'],
@@ -187,7 +190,7 @@ export async function createHologramWithCapability(client, sourceHolon, targetHo
187
190
  {
188
191
  expiresIn: expiresIn !== null ? expiresIn : (isSelfCapability ? 365 * 24 * 60 * 60 * 1000 : 3600000), // 1 year for self, 1 hour for cross
189
192
  issuer: sourceAuthorPubKey,
190
- issuerKey: client.privateKey,
193
+ issuerKey: sourceAuthorPrivKey, // Use passed private key (holon's key when federated via holonsbot)
191
194
  }
192
195
  );
193
196
 
@@ -316,6 +319,21 @@ export async function resolveHologram(client, hologram, visited = new Set(), cha
316
319
  return null;
317
320
  }
318
321
 
322
+ // OBJECT-CAPABILITY SECURITY: Verify capability issuer matches claimed authorPubKey
323
+ // This prevents forged capabilities where someone signs a capability for data they don't own
324
+ if (authorPubKey) {
325
+ const issuerMatch = await verifyCapabilityIssuer(capability, authorPubKey);
326
+ if (!issuerMatch) {
327
+ console.warn(`❌ Capability issuer does not match authorPubKey for hologram: ${soul}`);
328
+ console.warn(` Expected author: ${authorPubKey?.slice(0, 12)}...`);
329
+ const decoded = decodeCapability(capability);
330
+ if (decoded) {
331
+ console.warn(` Actual issuer: ${decoded.issuer?.slice(0, 12)}...`);
332
+ }
333
+ return null;
334
+ }
335
+ }
336
+
319
337
  const isValid = await verifyCapability(
320
338
  capability,
321
339
  'read',
@@ -453,6 +471,7 @@ export async function setupFederation(
453
471
  * @param {string} mode - 'reference' or 'copy'
454
472
  * @param {Object} options - Propagation options
455
473
  * @param {string} [options.sourceAuthorPubKey] - Source author (defaults to client.publicKey)
474
+ * @param {string} [options.sourceAuthorPrivKey] - Source author's private key for signing capability (defaults to client.privateKey)
456
475
  * @param {string} [options.capability] - Pre-issued capability (auto-issued if not provided)
457
476
  * @returns {Promise<boolean>} Success indicator
458
477
  */
@@ -606,14 +625,6 @@ export async function propagateData(
606
625
  const isPubkey = typeof targetHolon === 'string' && /^[0-9a-f]{64}$/i.test(targetHolon);
607
626
  const targetAuthorPubKey = options.targetAuthorPubKey || (isPubkey ? targetHolon : client.publicKey);
608
627
 
609
- console.log('[propagateData] Creating hologram with capability:', {
610
- sourceHolon: sourceHolon?.slice(0, 12) + '...',
611
- targetHolon: targetHolon?.slice(0, 12) + '...',
612
- sourceAuthorPubKey: sourceAuthorPubKey?.slice(0, 12) + '...',
613
- targetAuthorPubKey: targetAuthorPubKey?.slice(0, 12) + '...',
614
- hasProvidedCapability: !!options.capability
615
- });
616
-
617
628
  const hologram = await createHologramWithCapability(
618
629
  client,
619
630
  sourceHolon,
@@ -623,6 +634,7 @@ export async function propagateData(
623
634
  appname,
624
635
  {
625
636
  sourceAuthorPubKey,
637
+ sourceAuthorPrivKey: options.sourceAuthorPrivKey, // Pass through for holon-signed capabilities
626
638
  targetAuthorPubKey,
627
639
  capability: options.capability,
628
640
  permissions: ['read'],
@@ -745,9 +757,10 @@ export async function addActiveHologram(client, appname, sourceHolon, lensName,
745
757
  let sourcePath = buildPath(currentAppname, currentHolon, currentLens, currentDataId);
746
758
 
747
759
  // Read existing source data
760
+ // Note: May return null if called immediately after write (before relay persistence)
761
+ // This is expected - the hologram still works, we just can't track activeHolograms
748
762
  let sourceData = await read(client, sourcePath);
749
763
  if (!sourceData) {
750
- console.warn(`Source data not found at ${sourcePath}`);
751
764
  return false;
752
765
  }
753
766
 
@@ -765,14 +778,10 @@ export async function addActiveHologram(client, appname, sourceHolon, lensName,
765
778
 
766
779
  sourceData = await read(client, sourcePath);
767
780
  if (!sourceData) {
768
- console.warn(`Real source data not found at ${sourcePath}`);
769
781
  return false;
770
782
  }
771
783
  }
772
784
 
773
- if (depth > 0) {
774
- console.info(`📍 Followed hologram chain (depth: ${depth}) to real source: ${currentHolon}/${currentLens}/${currentDataId}`);
775
- }
776
785
 
777
786
  // Initialize _meta and activeHolograms if needed
778
787
  if (!sourceData._meta) {
@@ -885,9 +894,9 @@ export async function updateActiveHologramPlatform(client, appname, sourceHolon,
885
894
  let sourcePath = buildPath(currentAppname, currentHolon, currentLens, currentDataId);
886
895
 
887
896
  // Read existing source data
897
+ // Note: May return null if called immediately after write (before relay persistence)
888
898
  let sourceData = await read(client, sourcePath);
889
899
  if (!sourceData) {
890
- console.warn(`Source data not found at ${sourcePath}`);
891
900
  return false;
892
901
  }
893
902
 
@@ -904,7 +913,6 @@ export async function updateActiveHologramPlatform(client, appname, sourceHolon,
904
913
 
905
914
  sourceData = await read(client, sourcePath);
906
915
  if (!sourceData) {
907
- console.warn(`Real source data not found at ${sourcePath}`);
908
916
  return false;
909
917
  }
910
918
  }