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
package/src/index.js CHANGED
@@ -28,6 +28,8 @@ import * as requestCard from './federation/request-card.js';
28
28
  import * as cardStorage from './federation/card-storage.js';
29
29
  import * as crypto from './crypto/secp256k1.js';
30
30
  import * as nostrUtils from './crypto/nostr-utils.js';
31
+ import { LensKeyStore, buildLensPath } from './crypto/key-store.js';
32
+ import * as lensKeys from './crypto/lens-keys.js';
31
33
  import * as social from './content/social-protocols.js';
32
34
  import * as subscriptions from './subscriptions/manager.js';
33
35
  import * as hierarchical from './hierarchical/upcast.js';
@@ -128,6 +130,24 @@ class HoloSphereBase extends HoloSphereCore {
128
130
  // Contracts module (lazy initialized)
129
131
  /** @type {Object|null} */
130
132
  this._contracts = null;
133
+
134
+ /**
135
+ * Lens key store for encrypted federation.
136
+ * Manages symmetric keys for encrypting/decrypting lens data.
137
+ * @type {LensKeyStore}
138
+ */
139
+ this.keyStore = new LensKeyStore();
140
+
141
+ /**
142
+ * Encryption configuration.
143
+ * @type {Object}
144
+ * @property {boolean} encryptByDefault - Whether to encrypt lens data by default
145
+ * @property {string[]} publicLenses - Lenses that should never be encrypted
146
+ */
147
+ this._encryptionConfig = {
148
+ encryptByDefault: config.encryptByDefault ?? false,
149
+ publicLenses: config.publicLenses || ['profile', 'settings', 'public'],
150
+ };
131
151
  }
132
152
 
133
153
  /**
@@ -322,6 +342,7 @@ class HoloSphereBase extends HoloSphereCore {
322
342
  * @param {Object} [options.propagationOptions] - Options for propagation
323
343
  * @param {boolean} [options.blocking=false] - If true, wait for relay confirmation before returning
324
344
  * @param {string} [options.signingKey] - Private key to sign with (hex format). If not provided, uses holosphere's default key.
345
+ * @param {boolean} [options.encrypt] - Whether to encrypt this data. Defaults to encryptByDefault config.
325
346
  * @returns {Promise<boolean>} True if write succeeded (or queued for optimistic writes)
326
347
  * @throws {ValidationError} If holonId, lensName, or data is invalid
327
348
  * @throws {AuthorizationError} If capability token is invalid
@@ -341,8 +362,81 @@ class HoloSphereBase extends HoloSphereCore {
341
362
  throw new ValidationError('ValidationError: data must be an object');
342
363
  }
343
364
 
365
+ // Check write authorization BEFORE optimistic caching
366
+ // Helper function to detect if a string is a pubkey (64-char hex)
367
+ const isPubkeyHolon = typeof holonId === 'string' && /^[0-9a-f]{64}$/i.test(holonId);
368
+
369
+ // Determine the effective writer pubkey - use signingKey's pubkey if provided
370
+ let effectiveWriterPubKey = this.client?.publicKey;
371
+ if (options.signingKey) {
372
+ try {
373
+ effectiveWriterPubKey = crypto.getPublicKey(options.signingKey);
374
+ } catch (err) {
375
+ this._log('WARN', '⚠️ Invalid signingKey provided, using client key', { error: err.message });
376
+ }
377
+ }
378
+
379
+ // For pubkey-based holons, check if it's our own holon (matches effective writer's pubkey)
380
+ // For non-pubkey holons (H3 cells, UUIDs), skip authorization for backwards compatibility
381
+ const isOwnHolon = effectiveWriterPubKey && (holonId === effectiveWriterPubKey || !isPubkeyHolon);
344
382
  const capToken = options.capabilityToken || options.capability;
345
- if (capToken) {
383
+ const actingAsHolon = options.actingAs || options.actingAsHolon;
384
+
385
+ if (!isOwnHolon) {
386
+ // Writing to non-own holon - check authorization
387
+ // Priority: 1. Explicit capability token, 2. Membership-based access
388
+
389
+ if (capToken) {
390
+ // Explicit capability token provided - verify it
391
+ const authorized = await this.verifyCapability(capToken, 'write', { holonId, lensName });
392
+ if (!authorized) {
393
+ this._log('WARN', '🚫 Write denied: Invalid capability token', { holonId, lensName });
394
+ throw new AuthorizationError(
395
+ 'Invalid capability token for write operation',
396
+ 'write'
397
+ );
398
+ }
399
+ this._log('DEBUG', '✅ Write authorized via capability token', { holonId, lensName });
400
+ } else {
401
+ // No capability token - check unified access control
402
+ // Uses canAccess() which checks: 1. owner, 2. membership, 3. federation grants (unified)
403
+ const writerPubKey = effectiveWriterPubKey;
404
+
405
+ if (!writerPubKey) {
406
+ this._log('WARN', '🚫 Write denied: No authenticated user', { holonId, lensName });
407
+ throw new AuthorizationError(
408
+ 'Authentication required for writing to federated holons',
409
+ 'write'
410
+ );
411
+ }
412
+
413
+ // Check unified access control
414
+ const accessCheck = await this.canAccess(holonId, lensName, writerPubKey, 'write', { actingAsHolon });
415
+
416
+ if (!accessCheck.allowed) {
417
+ this._log('WARN', '🚫 Write denied: No write access', {
418
+ holonId,
419
+ lensName,
420
+ reason: accessCheck.reason,
421
+ via: accessCheck.via,
422
+ writerPubKey: writerPubKey?.slice(0, 12) + '...'
423
+ });
424
+ throw new AuthorizationError(
425
+ `Write access denied: ${accessCheck.reason}`,
426
+ 'write'
427
+ );
428
+ }
429
+
430
+ this._log('DEBUG', '✅ Write authorized via unified access control', {
431
+ holonId,
432
+ lensName,
433
+ via: accessCheck.via,
434
+ reason: accessCheck.reason,
435
+ grant: accessCheck.grant ? 'yes' : 'no'
436
+ });
437
+ }
438
+ } else if (capToken) {
439
+ // Own holon with explicit token - validate it anyway
346
440
  const authorized = await this.verifyCapability(capToken, 'write', { holonId, lensName });
347
441
  if (!authorized) {
348
442
  throw new AuthorizationError('AuthorizationError: Invalid capability token for write operation', 'write');
@@ -427,8 +521,35 @@ class HoloSphereBase extends HoloSphereCore {
427
521
  return this._syncHologramWrite(existingData, data, path, lensName, options);
428
522
  }
429
523
 
430
- // Regular write to relay
431
- const writeOptions = options.signingKey ? { signingKey: options.signingKey } : {};
524
+ // Determine if encryption is needed
525
+ const shouldEncrypt = this._shouldEncryptLens(lensName, options);
526
+ let lensKey = null;
527
+
528
+ if (shouldEncrypt && this.client?.privateKey && this.client?.publicKey) {
529
+ // Get or create a key for this lens
530
+ const lensPath = buildLensPath(this.config.appName, holonId, lensName);
531
+ const keyResult = this.keyStore.getOrCreateKey(
532
+ lensPath,
533
+ this.client.privateKey,
534
+ this.client.publicKey
535
+ );
536
+ lensKey = keyResult.key;
537
+
538
+ if (keyResult.isNew) {
539
+ this._log('DEBUG', '🔑 Generated new lens key', { lensPath });
540
+ // Store the wrapped key in Nostr for persistence
541
+ this._storeWrappedKey(lensPath, keyResult.wrappedForSelf).catch(err => {
542
+ this._log('WARN', '⚠️ Failed to persist wrapped key', { lensPath, error: err.message });
543
+ });
544
+ }
545
+ }
546
+
547
+ // Regular write to relay (with optional encryption)
548
+ const writeOptions = {
549
+ ...(options.signingKey ? { signingKey: options.signingKey } : {}),
550
+ ...(lensKey ? { lensKey } : {}),
551
+ waitForRelays: true // Always wait for relay confirmation when sync is called
552
+ };
432
553
  await storage.write(this.client, path, data, writeOptions);
433
554
 
434
555
  const endTime = Date.now();
@@ -514,6 +635,26 @@ class HoloSphereBase extends HoloSphereCore {
514
635
  await storage.write(this.client, path, localData);
515
636
 
516
637
  if (Object.keys(sourceUpdates).length > 0) {
638
+ // Verify write capability before updating source
639
+ const capability = existingData.capability;
640
+ if (!capability) {
641
+ throw new Error('Hologram missing capability token for source update');
642
+ }
643
+
644
+ const hasWrite = await crypto.verifyCapability(
645
+ capability,
646
+ 'write',
647
+ {
648
+ holonId: existingData.target.holonId,
649
+ lensName: existingData.target.lensName,
650
+ dataId: existingData.target.dataId
651
+ }
652
+ );
653
+
654
+ if (!hasWrite) {
655
+ throw new Error('Write capability required to update source data through hologram');
656
+ }
657
+
517
658
  const sourcePath = storage.buildPath(
518
659
  existingData.target.appname || this.config.appName,
519
660
  existingData.target.holonId,
@@ -563,6 +704,176 @@ class HoloSphereBase extends HoloSphereCore {
563
704
  return registry.getCapabilityForAuthor(this.client, this.config.appName, authorPubKey, scope);
564
705
  }
565
706
 
707
+ /**
708
+ * Determine if a lens should be encrypted.
709
+ *
710
+ * @private
711
+ * @param {string} lensName - Name of the lens
712
+ * @param {Object} options - Write options
713
+ * @returns {boolean} True if lens should be encrypted
714
+ */
715
+ _shouldEncryptLens(lensName, options = {}) {
716
+ // Explicit encrypt option takes precedence
717
+ if (options.encrypt !== undefined) {
718
+ return options.encrypt;
719
+ }
720
+
721
+ // Public lenses are never encrypted
722
+ if (this._encryptionConfig.publicLenses.includes(lensName)) {
723
+ return false;
724
+ }
725
+
726
+ // Use default setting
727
+ return this._encryptionConfig.encryptByDefault;
728
+ }
729
+
730
+ /**
731
+ * Store a wrapped lens key in Nostr for persistence.
732
+ * This allows recovering keys after restart.
733
+ *
734
+ * @private
735
+ * @param {string} lensPath - Path identifying the lens
736
+ * @param {string} wrappedKey - NIP-44 wrapped key for self
737
+ * @returns {Promise<void>}
738
+ */
739
+ async _storeWrappedKey(lensPath, wrappedKey) {
740
+ const keyStorePath = storage.buildPath(this.config.appName, '_keys', 'lens-keys', lensPath.replace(/\//g, '_'));
741
+ await storage.write(this.client, keyStorePath, {
742
+ id: lensPath.replace(/\//g, '_'),
743
+ lensPath,
744
+ wrappedKey,
745
+ algorithm: 'aes-256-gcm',
746
+ keyWrap: 'nip44',
747
+ version: '1.0',
748
+ createdAt: Date.now()
749
+ });
750
+ }
751
+
752
+ /**
753
+ * Load stored wrapped keys from Nostr and restore them to the keyStore.
754
+ * Called during initialization to recover keys after restart.
755
+ *
756
+ * @private
757
+ * @returns {Promise<number>} Number of keys restored
758
+ */
759
+ async _loadStoredKeys() {
760
+ if (!this.client?.privateKey || !this.client?.publicKey) {
761
+ return 0;
762
+ }
763
+
764
+ const keyStorePath = storage.buildPath(this.config.appName, '_keys', 'lens-keys') + '/';
765
+ const storedKeys = await storage.readAll(this.client, keyStorePath);
766
+
767
+ let restored = 0;
768
+ for (const keyData of storedKeys) {
769
+ if (keyData && keyData.lensPath && keyData.wrappedKey) {
770
+ try {
771
+ const key = lensKeys.unwrapKey(
772
+ keyData.wrappedKey,
773
+ this.client.privateKey,
774
+ this.client.publicKey
775
+ );
776
+ this.keyStore.storeOwnKey(keyData.lensPath, key, keyData.wrappedKey);
777
+ restored++;
778
+ } catch (err) {
779
+ this._log('WARN', '⚠️ Failed to restore lens key', { lensPath: keyData.lensPath, error: err.message });
780
+ }
781
+ }
782
+ }
783
+
784
+ if (restored > 0) {
785
+ this._log('DEBUG', '🔑 Restored lens keys', { count: restored });
786
+ }
787
+
788
+ return restored;
789
+ }
790
+
791
+ /**
792
+ * Get the lens key for reading encrypted data.
793
+ * Returns the key from keyStore if available, otherwise null.
794
+ *
795
+ * @private
796
+ * @param {string} holonId - Holon identifier
797
+ * @param {string} lensName - Lens name
798
+ * @returns {Buffer|null} Lens key or null if not available
799
+ */
800
+ _getLensKey(holonId, lensName) {
801
+ const lensPath = buildLensPath(this.config.appName, holonId, lensName);
802
+ return this.keyStore.getKey(lensPath);
803
+ }
804
+
805
+ /**
806
+ * Handle a received lens key share from a federation partner.
807
+ * Call this from your onKeyShare handler in subscribeToFederationDMs.
808
+ *
809
+ * @param {Object} keyShare - Key share payload
810
+ * @param {string} keyShare.lensPath - Path identifying the lens
811
+ * @param {string} keyShare.wrappedKey - NIP-44 wrapped symmetric key
812
+ * @param {string} senderPubKey - Sender's public key
813
+ * @returns {boolean} True if key was successfully stored
814
+ */
815
+ handleReceivedKeyShare(keyShare, senderPubKey) {
816
+ if (!this.client?.privateKey) {
817
+ this._log('WARN', '⚠️ Cannot receive key share: no private key');
818
+ return false;
819
+ }
820
+
821
+ try {
822
+ const key = this.keyStore.receiveKey(
823
+ keyShare.lensPath,
824
+ keyShare.wrappedKey,
825
+ this.client.privateKey,
826
+ senderPubKey
827
+ );
828
+
829
+ this._log('DEBUG', '🔑 Received and stored lens key', {
830
+ lensPath: keyShare.lensPath,
831
+ from: senderPubKey.slice(0, 12) + '...'
832
+ });
833
+
834
+ return true;
835
+ } catch (error) {
836
+ this._log('ERROR', '❌ Failed to receive key share', {
837
+ lensPath: keyShare.lensPath,
838
+ error: error.message
839
+ });
840
+ return false;
841
+ }
842
+ }
843
+
844
+ /**
845
+ * Get encryption statistics for the key store.
846
+ *
847
+ * @returns {Object} { ownKeyCount, receivedKeyCount, totalPartnerGrants }
848
+ */
849
+ getEncryptionStats() {
850
+ return this.keyStore.getStats();
851
+ }
852
+
853
+ /**
854
+ * Check if a lens has an encryption key available.
855
+ *
856
+ * @param {string} holonId - Holon identifier
857
+ * @param {string} lensName - Lens name
858
+ * @returns {boolean} True if key is available for this lens
859
+ */
860
+ hasLensKey(holonId, lensName) {
861
+ const lensPath = buildLensPath(this.config.appName, holonId, lensName);
862
+ return this.keyStore.hasKey(lensPath);
863
+ }
864
+
865
+ /**
866
+ * Check if we own the key for a lens (vs received it from a partner).
867
+ *
868
+ * @param {string} holonId - Holon identifier
869
+ * @param {string} lensName - Lens name
870
+ * @returns {boolean} True if we own the key
871
+ */
872
+ isOwnLensKey(holonId, lensName) {
873
+ const lensPath = buildLensPath(this.config.appName, holonId, lensName);
874
+ return this.keyStore.isOwnKey(lensPath);
875
+ }
876
+
566
877
  /**
567
878
  * Recursively resolves holograms (references) to their source data.
568
879
  * Handles circular reference detection and local overrides.
@@ -719,12 +1030,22 @@ class HoloSphereBase extends HoloSphereCore {
719
1030
  }
720
1031
 
721
1032
  // Resolve holonId to public key (if registered)
722
- const targetPubkey = await this._resolveHolonToPubkey(holonId);
1033
+ // Skip resolution if explicitly requested (used for backward compat reads from old locations)
1034
+ const targetPubkey = options.skipResolve ? null : await this._resolveHolonToPubkey(holonId);
723
1035
 
724
1036
  // Determine if reading another author's data
725
1037
  // If holonId can't be resolved, treat as own data (backwards compatible for H3 holons)
726
1038
  const isOtherAuthor = targetPubkey && targetPubkey !== this.client.publicKey;
1039
+ this._log('DEBUG', '🔍 READ: author check', {
1040
+ targetPubkey: targetPubkey?.slice(0, 12),
1041
+ clientPubkey: this.client?.publicKey?.slice(0, 12),
1042
+ isOtherAuthor,
1043
+ holonId: holonId?.slice(0, 12)
1044
+ });
1045
+ // Start with caller's options that should be passed through
727
1046
  let readOptions = {};
1047
+ if (options.forceRelay) readOptions.forceRelay = true;
1048
+ if (options.timeout) readOptions.timeout = options.timeout;
728
1049
 
729
1050
  // Explicit capability token takes precedence
730
1051
  const capToken = options.capabilityToken || options.capability;
@@ -765,19 +1086,26 @@ class HoloSphereBase extends HoloSphereCore {
765
1086
 
766
1087
  // Include federated authors to see holograms written by federation partners
767
1088
  // This enables visibility of holograms created during federation
1089
+ // We include ALL federated partners, not just those whose capabilities match the current scope,
1090
+ // because partners may have written holograms to our holon that we need to see
1091
+ this._log('DEBUG', '🔍 Checking for federated authors', { isOtherAuthor, holonId: holonId?.slice(0, 12) });
768
1092
  if (!isOtherAuthor) {
769
1093
  try {
770
- const federatedAuthors = await registry.getFederatedAuthorsForScope(
1094
+ this._log('DEBUG', '🔍 Getting federated authors...');
1095
+ // Add timeout to prevent hanging on relay queries
1096
+ const fedAuthorsPromise = registry.getFederatedAuthors(
771
1097
  this.client,
772
- this.config.appName,
773
- { holonId, lensName, dataId },
774
- 'read'
1098
+ this.config.appName
775
1099
  );
1100
+ const fedAuthorsTimeout = new Promise((resolve) =>
1101
+ setTimeout(() => resolve([]), 5000) // 5 second timeout, resolve with empty array
1102
+ );
1103
+ const federatedAuthors = await Promise.race([fedAuthorsPromise, fedAuthorsTimeout]);
1104
+ this._log('DEBUG', '✅ Got federated authors', { count: federatedAuthors?.length || 0 });
776
1105
  if (federatedAuthors.length > 0) {
777
- const partnerPubkeys = federatedAuthors.map(f => f.pubKey);
778
- // Include self + all federated partners
779
- readOptions.authors = [this.client.publicKey, ...partnerPubkeys];
780
- this._log('DEBUG', 'Including federated authors', { count: partnerPubkeys.length });
1106
+ // Include self + all federated partners to see partner-written holograms
1107
+ readOptions.authors = [this.client.publicKey, ...federatedAuthors];
1108
+ this._log('DEBUG', 'Including federated authors', { count: federatedAuthors.length });
781
1109
  }
782
1110
  } catch (err) {
783
1111
  this._log('WARN', 'Failed to get federated authors', { error: err.message });
@@ -787,6 +1115,12 @@ class HoloSphereBase extends HoloSphereCore {
787
1115
  const startTime = Date.now();
788
1116
  let result;
789
1117
 
1118
+ // Get lens key for decryption (if available)
1119
+ const lensKey = this._getLensKey(holonId, lensName);
1120
+ if (lensKey) {
1121
+ readOptions.lensKey = lensKey;
1122
+ }
1123
+
790
1124
  if (dataId) {
791
1125
  const path = storage.buildPath(this.config.appName, holonId, lensName, dataId);
792
1126
 
@@ -813,7 +1147,7 @@ class HoloSphereBase extends HoloSphereCore {
813
1147
  });
814
1148
  result = cached.data;
815
1149
  } else {
816
- this._log('DEBUG', '📖 CACHE MISS: Reading from storage', { path, authors: readOptions.authors });
1150
+ this._log('DEBUG', '📖 CACHE MISS: Reading from storage', { path, authors: readOptions.authors, hasKey: !!lensKey });
817
1151
  result = await storage.read(this.client, path, readOptions);
818
1152
  this._log('DEBUG', '💾 STORAGE READ', {
819
1153
  path,
@@ -825,7 +1159,7 @@ class HoloSphereBase extends HoloSphereCore {
825
1159
  }
826
1160
  } else {
827
1161
  const path = storage.buildPath(this.config.appName, holonId, lensName);
828
- this._log('DEBUG', 'readAll', { holonId, lensName, path, authors: readOptions.authors });
1162
+ this._log('DEBUG', 'readAll', { holonId, lensName, path, authors: readOptions.authors, hasKey: !!lensKey });
829
1163
 
830
1164
  // For readAll, merge cached writes with storage results and filter deleted
831
1165
  const storageResult = await storage.readAll(this.client, path, readOptions);
@@ -916,8 +1250,33 @@ class HoloSphereBase extends HoloSphereCore {
916
1250
  throw new ValidationError('ValidationError: updates must be an object');
917
1251
  }
918
1252
 
1253
+ // Check write authorization BEFORE optimistic caching
1254
+ // Helper function to detect if a string is a pubkey (64-char hex)
1255
+ const isPubkeyHolon = typeof holonId === 'string' && /^[0-9a-f]{64}$/i.test(holonId);
1256
+ // For pubkey-based holons, check if it's our own holon (our pubkey)
1257
+ // For non-pubkey holons (H3 cells, UUIDs), skip authorization for backwards compatibility
1258
+ const isOwnHolon = this.client && (holonId === this.client.publicKey || !isPubkeyHolon);
919
1259
  const capToken = options.capabilityToken || options.capability;
920
- if (capToken) {
1260
+
1261
+ if (!isOwnHolon) {
1262
+ // Updating federated holon - require capability token
1263
+ if (!capToken) {
1264
+ this._log('WARN', '🚫 Update denied: No capability token for federated holon', { holonId, lensName, dataId });
1265
+ throw new AuthorizationError(
1266
+ 'Capability token required for writing to federated holons',
1267
+ 'write'
1268
+ );
1269
+ }
1270
+ const authorized = await this.verifyCapability(capToken, 'write', { holonId, lensName, dataId });
1271
+ if (!authorized) {
1272
+ this._log('WARN', '🚫 Update denied: Invalid capability token', { holonId, lensName, dataId });
1273
+ throw new AuthorizationError(
1274
+ 'Invalid capability token for update operation',
1275
+ 'write'
1276
+ );
1277
+ }
1278
+ } else if (capToken) {
1279
+ // Own holon with explicit token - validate it anyway
921
1280
  const authorized = await this.verifyCapability(capToken, 'write', { holonId, lensName, dataId });
922
1281
  if (!authorized) {
923
1282
  throw new AuthorizationError('AuthorizationError: Invalid capability token for update operation', 'write');
@@ -1008,7 +1367,7 @@ class HoloSphereBase extends HoloSphereCore {
1008
1367
 
1009
1368
  if (existingData._meta && existingData._meta.activeHolograms) {
1010
1369
  this._log('DEBUG', '🔗 Refreshing active holograms', { path });
1011
- federation.refreshActiveHolograms(this.client, this.config.appName, holonId, lensName, dataId)
1370
+ await federation.refreshActiveHolograms(this.client, this.config.appName, holonId, lensName, dataId)
1012
1371
  .catch(err => this._log('WARN', '⚠️ Hologram refresh failed', { path, error: err.message }));
1013
1372
  }
1014
1373
 
@@ -1057,6 +1416,23 @@ class HoloSphereBase extends HoloSphereCore {
1057
1416
  throw new ValidationError('ValidationError: dataId must be a non-empty string');
1058
1417
  }
1059
1418
 
1419
+ // Check holon-level authorization BEFORE reading data or caching
1420
+ // Helper function to detect if a string is a pubkey (64-char hex)
1421
+ const isPubkeyHolon = typeof holonId === 'string' && /^[0-9a-f]{64}$/i.test(holonId);
1422
+ // For pubkey-based holons, check if it's our own holon (our pubkey)
1423
+ // For non-pubkey holons (H3 cells, UUIDs), skip authorization for backwards compatibility
1424
+ const isOwnHolon = this.client && (holonId === this.client.publicKey || !isPubkeyHolon);
1425
+ const capToken = options.capabilityToken || options.capability;
1426
+
1427
+ if (!isOwnHolon && !capToken) {
1428
+ // Deleting from federated holon without capability token - fail fast
1429
+ this._log('WARN', '🚫 Delete denied: No capability token for federated holon', { holonId, lensName, dataId });
1430
+ throw new AuthorizationError(
1431
+ 'Capability token required for writing to federated holons',
1432
+ 'delete'
1433
+ );
1434
+ }
1435
+
1060
1436
  const path = storage.buildPath(this.config.appName, holonId, lensName, dataId);
1061
1437
 
1062
1438
  // Check write cache first, then storage
@@ -1077,13 +1453,12 @@ class HoloSphereBase extends HoloSphereCore {
1077
1453
 
1078
1454
  this._log('DEBUG', '🗑️ DELETE: Found data', { path, source: dataSource });
1079
1455
 
1080
- // Check authorization: owner can delete, others need capability token
1456
+ // Check authorization: data owner can delete, others need capability token
1081
1457
  const dataOwner = existingData.owner || existingData._creator;
1082
- const isOwner = !dataOwner || dataOwner === this.client.publicKey;
1458
+ const isDataOwner = !dataOwner || dataOwner === this.client.publicKey;
1083
1459
 
1084
- const capToken = options.capabilityToken || options.capability;
1085
- if (!isOwner) {
1086
- // Non-owner must provide a valid capability token
1460
+ if (!isDataOwner) {
1461
+ // Non-data-owner must provide a valid capability token
1087
1462
  if (!capToken) {
1088
1463
  throw new AuthorizationError('AuthorizationError: Capability token required for delete operation', 'delete');
1089
1464
  }
@@ -1092,7 +1467,7 @@ class HoloSphereBase extends HoloSphereCore {
1092
1467
  throw new AuthorizationError('AuthorizationError: Invalid capability token for delete operation', 'delete');
1093
1468
  }
1094
1469
  } else if (capToken) {
1095
- // Owner provided a token - validate it anyway
1470
+ // Data owner provided a token - validate it anyway
1096
1471
  const authorized = await this.verifyCapability(capToken, 'delete', { holonId, lensName, dataId });
1097
1472
  if (!authorized) {
1098
1473
  throw new AuthorizationError('AuthorizationError: Invalid capability token for delete operation', 'delete');
@@ -1434,15 +1809,23 @@ class HoloSphereBase extends HoloSphereCore {
1434
1809
  * @param {string} lensName - Name of the lens
1435
1810
  * @param {Object} data - Data object containing the id to reference
1436
1811
  * @param {string} [targetHolon=null] - Target holon for the hologram, defaults to sourceHolon
1437
- * @returns {Object} Hologram object structure
1812
+ * @returns {Promise<Object>} Hologram object structure
1438
1813
  */
1439
- createHologram(sourceHolon, lensName, data, targetHolon = null) {
1440
- return federation.createHologram(
1814
+ async createHologram(sourceHolon, lensName, data, targetHolon = null) {
1815
+ const target = targetHolon || sourceHolon;
1816
+ // Use createHologramWithCapability which auto-generates capability for self-operations
1817
+ return federation.createHologramWithCapability(
1818
+ this.client,
1441
1819
  sourceHolon,
1442
- targetHolon || sourceHolon,
1820
+ target,
1443
1821
  lensName,
1444
1822
  data.id,
1445
- this.config.appName
1823
+ this.config.appName,
1824
+ {
1825
+ sourceAuthorPubKey: this.client.publicKey,
1826
+ targetAuthorPubKey: this.client.publicKey,
1827
+ permissions: ['read'],
1828
+ }
1446
1829
  );
1447
1830
  }
1448
1831
 
@@ -1785,6 +2168,8 @@ class HoloSphereBase extends HoloSphereCore {
1785
2168
  federationData.lensConfig[targetHolon] = {
1786
2169
  inbound: lensConfig.inbound || [],
1787
2170
  outbound: lensConfig.outbound || [],
2171
+ writeInbound: lensConfig.writeInbound || [],
2172
+ writeOutbound: lensConfig.writeOutbound || [],
1788
2173
  timestamp: Date.now()
1789
2174
  };
1790
2175
 
@@ -2385,6 +2770,10 @@ export { capabilities, requestCard, cardStorage, holonRegistry, registry };
2385
2770
  export { matchScope } from './crypto/secp256k1.js';
2386
2771
  export { createHologram } from './federation/hologram.js';
2387
2772
 
2773
+ // Re-export lens encryption utilities
2774
+ export { LensKeyStore, buildLensPath, parseLensPath } from './crypto/key-store.js';
2775
+ export * as lensKeys from './crypto/lens-keys.js';
2776
+
2388
2777
  // Re-export federation card functions
2389
2778
  export {
2390
2779
  createFederationCard,