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
@@ -14,16 +14,81 @@ import { matchScope } from '../crypto/secp256k1.js';
14
14
 
15
15
  const FEDERATION_TABLE = 'federations';
16
16
 
17
+ // ============================================================================
18
+ // Registry Cache - Avoids repeated storage reads
19
+ // ============================================================================
20
+
21
+ /**
22
+ * @typedef {Object} CachedRegistry
23
+ * @property {Object} registry - The federation registry data
24
+ * @property {Map<string, Object>} partnerIndex - Index of partners by pubKey for O(1) lookup
25
+ * @property {number} timestamp - When the cache was created
26
+ */
27
+
28
+ /** @type {Map<string, CachedRegistry>} Cache keyed by `${clientPubKey}:${appname}` */
29
+ const registryCache = new Map();
30
+
31
+ /** Cache TTL in milliseconds (10 seconds) */
32
+ const CACHE_TTL_MS = 10000;
33
+
34
+ /**
35
+ * Build an index of partners by pubKey for O(1) lookups
36
+ * @param {Object[]} federatedWith - Array of partner objects
37
+ * @returns {Map<string, Object>} Partner index
38
+ */
39
+ function buildPartnerIndex(federatedWith) {
40
+ const index = new Map();
41
+ for (const partner of federatedWith) {
42
+ if (partner.pubKey) {
43
+ index.set(partner.pubKey, partner);
44
+ }
45
+ }
46
+ return index;
47
+ }
48
+
17
49
  /**
18
- * Get the federation registry for this holosphere
50
+ * Get cache key for a client/appname combination
19
51
  * @param {Object} client - NostrClient instance
20
52
  * @param {string} appname - Application namespace
53
+ * @returns {string} Cache key
54
+ */
55
+ function getCacheKey(client, appname) {
56
+ return `${client.publicKey}:${appname}`;
57
+ }
58
+
59
+ /**
60
+ * Invalidate the registry cache for a specific client/appname
61
+ * Call this after any write operation
62
+ * @param {Object} client - NostrClient instance
63
+ * @param {string} appname - Application namespace
64
+ */
65
+ export function invalidateRegistryCache(client, appname) {
66
+ const key = getCacheKey(client, appname);
67
+ registryCache.delete(key);
68
+ }
69
+
70
+ /**
71
+ * Get the federation registry for this holosphere (with caching)
72
+ * @param {Object} client - NostrClient instance
73
+ * @param {string} appname - Application namespace
74
+ * @param {Object} [options] - Options
75
+ * @param {boolean} [options.skipCache=false] - Force fresh read from storage
21
76
  * @returns {Promise<Object>} Federation registry
22
77
  */
23
- export async function getFederationRegistry(client, appname) {
24
- const registry = await readGlobal(client, appname, FEDERATION_TABLE, client.publicKey);
78
+ export async function getFederationRegistry(client, appname, options = {}) {
79
+ const cacheKey = getCacheKey(client, appname);
80
+ const now = Date.now();
81
+
82
+ // Check cache first (unless skipCache is set)
83
+ if (!options.skipCache) {
84
+ const cached = registryCache.get(cacheKey);
85
+ if (cached && (now - cached.timestamp) < CACHE_TTL_MS) {
86
+ return cached.registry;
87
+ }
88
+ }
25
89
 
26
- return registry || {
90
+ // Read from storage
91
+ const registry = await readGlobal(client, appname, FEDERATION_TABLE, client.publicKey) || {
27
92
  id: client.publicKey,
28
93
  federatedWith: [],
29
94
  discoveryEnabled: false,
@@ -31,6 +96,37 @@ export async function getFederationRegistry(client, appname) {
31
96
  defaultScope: { holonId: '*', lensName: '*' },
32
97
  defaultPermissions: ['read'],
33
98
  };
99
+
100
+ // Build partner index and cache
101
+ const partnerIndex = buildPartnerIndex(registry.federatedWith || []);
102
+ registryCache.set(cacheKey, {
103
+ registry,
104
+ partnerIndex,
105
+ timestamp: now,
106
+ });
107
+
108
+ return registry;
109
+ }
110
+
111
+ /**
112
+ * Get partner from cache index (O(1) lookup)
113
+ * @param {Object} client - NostrClient instance
114
+ * @param {string} appname - Application namespace
115
+ * @param {string} partnerPubKey - Partner's public key
116
+ * @returns {Promise<Object|null>} Partner object or null
117
+ */
118
+ async function getPartnerFromIndex(client, appname, partnerPubKey) {
119
+ const cacheKey = getCacheKey(client, appname);
120
+
121
+ // Ensure registry is loaded and cached
122
+ await getFederationRegistry(client, appname);
123
+
124
+ const cached = registryCache.get(cacheKey);
125
+ if (cached && cached.partnerIndex) {
126
+ return cached.partnerIndex.get(partnerPubKey) || null;
127
+ }
128
+
129
+ return null;
34
130
  }
35
131
 
36
132
  /**
@@ -41,6 +137,8 @@ export async function getFederationRegistry(client, appname) {
41
137
  * @returns {Promise<boolean>} Success indicator
42
138
  */
43
139
  export async function saveFederationRegistry(client, appname, registry) {
140
+ // Invalidate cache before write so next read gets fresh data
141
+ invalidateRegistryCache(client, appname);
44
142
  return writeGlobal(client, appname, FEDERATION_TABLE, registry);
45
143
  }
46
144
 
@@ -70,10 +168,18 @@ export async function addFederatedPartner(client, appname, partnerPubKey, option
70
168
  }
71
169
 
72
170
  if (options.inboundCapabilities) {
73
- existing.inboundCapabilities = [
171
+ // Merge and deduplicate capabilities by scope to prevent accumulation
172
+ const allCaps = [
74
173
  ...(existing.inboundCapabilities || []),
75
174
  ...options.inboundCapabilities
76
175
  ];
176
+ // Keep only unique capabilities (by scope), preferring newer ones (last in array)
177
+ const seen = new Map();
178
+ for (const cap of allCaps) {
179
+ const scopeKey = `${cap.scope?.holonId}:${cap.scope?.lensName}:${cap.scope?.dataId}`;
180
+ seen.set(scopeKey, cap); // Later entries overwrite earlier (newer wins)
181
+ }
182
+ existing.inboundCapabilities = Array.from(seen.values());
77
183
  }
78
184
 
79
185
  existing.updatedAt = Date.now();
@@ -125,20 +231,20 @@ export async function getFederatedAuthors(client, appname) {
125
231
  }
126
232
 
127
233
  /**
128
- * Get a specific federated partner
234
+ * Get a specific federated partner (O(1) lookup via index)
129
235
  * @param {Object} client - NostrClient instance
130
236
  * @param {string} appname - Application namespace
131
237
  * @param {string} partnerPubKey - Partner's public key
132
238
  * @returns {Promise<Object|null>} Partner info or null
133
239
  */
134
240
  export async function getFederatedPartner(client, appname, partnerPubKey) {
135
- const registry = await getFederationRegistry(client, appname);
136
- return registry.federatedWith.find(p => p.pubKey === partnerPubKey) || null;
241
+ return getPartnerFromIndex(client, appname, partnerPubKey);
137
242
  }
138
243
 
139
244
  /**
140
245
  * Get capability for a specific author and scope
141
246
  * Finds a valid, non-expired capability that covers the requested scope
247
+ * Uses O(1) partner lookup via index
142
248
  * @param {Object} client - NostrClient instance
143
249
  * @param {string} appname - Application namespace
144
250
  * @param {string} authorPubKey - Author's public key
@@ -146,62 +252,35 @@ export async function getFederatedPartner(client, appname, partnerPubKey) {
146
252
  * @returns {Promise<Object|null>} Capability entry or null
147
253
  */
148
254
  export async function getCapabilityForAuthor(client, appname, authorPubKey, scope) {
149
- const registry = await getFederationRegistry(client, appname);
150
-
151
- console.log('[Registry] 🔍 getCapabilityForAuthor called:', {
152
- authorPubKey: authorPubKey?.slice(0, 12) + '...',
153
- scope,
154
- federatedWithCount: registry.federatedWith?.length || 0,
155
- federatedPartners: registry.federatedWith?.map(p => p.pubKey?.slice(0, 12) + '...') || []
156
- });
157
-
158
- const partner = registry.federatedWith.find(p => p.pubKey === authorPubKey);
255
+ // Use O(1) index lookup instead of array.find()
256
+ const partner = await getPartnerFromIndex(client, appname, authorPubKey);
159
257
 
160
258
  if (!partner) {
161
- console.log('[Registry] ❌ Partner not found in federatedWith');
162
259
  return null;
163
260
  }
164
261
 
165
262
  if (!partner.inboundCapabilities || partner.inboundCapabilities.length === 0) {
166
- console.log('[Registry] ❌ Partner has no inboundCapabilities:', {
167
- partnerPubKey: authorPubKey?.slice(0, 12) + '...',
168
- hasInboundCaps: !!partner.inboundCapabilities,
169
- inboundCapsLength: partner.inboundCapabilities?.length || 0
170
- });
171
263
  return null;
172
264
  }
173
265
 
174
- console.log('[Registry] Partner found with inboundCapabilities:', {
175
- partnerPubKey: authorPubKey?.slice(0, 12) + '...',
176
- capsCount: partner.inboundCapabilities.length,
177
- capScopes: partner.inboundCapabilities.map(c => c.scope)
178
- });
266
+ const now = Date.now();
179
267
 
180
268
  // Find matching, non-expired capability
181
- const result = partner.inboundCapabilities.find(cap => {
269
+ // Note: We still iterate through capabilities here, but there are typically
270
+ // only a few per partner (often just one wildcard capability)
271
+ for (const cap of partner.inboundCapabilities) {
182
272
  // Check expiration
183
- if (cap.expires && cap.expires < Date.now()) {
184
- console.log('[Registry] Capability expired:', { expires: cap.expires, now: Date.now() });
185
- return false;
273
+ if (cap.expires && cap.expires < now) {
274
+ continue;
186
275
  }
187
276
 
188
277
  // Check scope match (supports wildcards)
189
- const matches = matchScope(cap.scope, scope);
190
- console.log('[Registry] Scope match check:', {
191
- capScope: cap.scope,
192
- requestedScope: scope,
193
- matches
194
- });
195
- return matches;
196
- }) || null;
197
-
198
- if (result) {
199
- console.log('[Registry] ✅ Matching capability found');
200
- } else {
201
- console.log('[Registry] ❌ No matching capability found');
278
+ if (matchScope(cap.scope, scope)) {
279
+ return cap;
280
+ }
202
281
  }
203
282
 
204
- return result;
283
+ return null;
205
284
  }
206
285
 
207
286
  /**
@@ -217,17 +296,10 @@ export async function getCapabilityForAuthor(client, appname, authorPubKey, scop
217
296
  * @returns {Promise<boolean>} Success indicator
218
297
  */
219
298
  export async function storeInboundCapability(client, appname, partnerPubKey, capabilityInfo) {
220
- console.log('[Registry] 📥 storeInboundCapability called:', {
221
- partnerPubKey: partnerPubKey?.slice(0, 12) + '...',
222
- scope: capabilityInfo?.scope,
223
- hasToken: !!capabilityInfo?.token
224
- });
225
-
226
299
  const registry = await getFederationRegistry(client, appname);
227
300
  const partner = registry.federatedWith.find(p => p.pubKey === partnerPubKey);
228
301
 
229
302
  if (!partner) {
230
- console.log('[Registry] Partner not in registry, auto-adding with capability');
231
303
  // Auto-add partner if not exists
232
304
  return addFederatedPartner(client, appname, partnerPubKey, {
233
305
  addedVia: 'capability_received',
@@ -245,14 +317,12 @@ export async function storeInboundCapability(client, appname, partnerPubKey, cap
245
317
  );
246
318
 
247
319
  if (existingIndex >= 0) {
248
- console.log('[Registry] Updating existing capability at index:', existingIndex);
249
320
  // Update existing capability
250
321
  partner.inboundCapabilities[existingIndex] = {
251
322
  ...capabilityInfo,
252
323
  updatedAt: Date.now()
253
324
  };
254
325
  } else {
255
- console.log('[Registry] Adding new capability, total will be:', partner.inboundCapabilities.length + 1);
256
326
  // Add new capability
257
327
  partner.inboundCapabilities.push({
258
328
  ...capabilityInfo,
@@ -260,9 +330,7 @@ export async function storeInboundCapability(client, appname, partnerPubKey, cap
260
330
  });
261
331
  }
262
332
 
263
- const result = await saveFederationRegistry(client, appname, registry);
264
- console.log('[Registry] ✅ Capability stored, registry saved:', result);
265
- return result;
333
+ return saveFederationRegistry(client, appname, registry);
266
334
  }
267
335
 
268
336
  /**
@@ -542,3 +610,655 @@ export async function getSelfCapability(client, appname, scope) {
542
610
  export function isSelfCapability(capabilityInfo) {
543
611
  return capabilityInfo && capabilityInfo.isSelfCapability === true;
544
612
  }
613
+
614
+ // ============================================================================
615
+ // Write Grant Functions (Cross-Holon Write Access)
616
+ // ============================================================================
617
+
618
+ /**
619
+ * Grant write access to a holon for a specific lens.
620
+ * This allows all members of the granted holon to write to the specified lens.
621
+ *
622
+ * @param {Object} client - NostrClient instance
623
+ * @param {string} appname - Application namespace
624
+ * @param {string} holonId - The holon (pubKey) being granted write access
625
+ * @param {string} lensName - The lens to grant write access to (or '*' for all lenses)
626
+ * @param {Object} [options={}] - Grant options
627
+ * @param {number} [options.expiresAt] - Optional expiration timestamp
628
+ * @returns {Promise<boolean>} Success indicator
629
+ */
630
+ export async function grantWriteAccessToHolon(client, appname, holonId, lensName, options = {}) {
631
+ console.warn('[Deprecated] grantWriteAccessToHolon() is deprecated. Use grantAccess() instead.');
632
+ const registry = await getFederationRegistry(client, appname);
633
+
634
+ // Find or create partner entry
635
+ let partner = registry.federatedWith.find(p => p.pubKey === holonId);
636
+
637
+ if (!partner) {
638
+ // Auto-add partner if not exists
639
+ partner = {
640
+ pubKey: holonId,
641
+ alias: null,
642
+ addedAt: Date.now(),
643
+ addedVia: 'write_grant',
644
+ inboundCapabilities: [],
645
+ outboundCapabilities: [],
646
+ writeGrants: [],
647
+ };
648
+ registry.federatedWith.push(partner);
649
+ }
650
+
651
+ if (!partner.writeGrants) {
652
+ partner.writeGrants = [];
653
+ }
654
+
655
+ // Check if grant already exists
656
+ const existingIndex = partner.writeGrants.findIndex(g => g.lensName === lensName);
657
+
658
+ const grant = {
659
+ lensName,
660
+ grantedAt: Date.now(),
661
+ expiresAt: options.expiresAt || null,
662
+ };
663
+
664
+ if (existingIndex >= 0) {
665
+ // Update existing grant
666
+ partner.writeGrants[existingIndex] = grant;
667
+ } else {
668
+ // Add new grant
669
+ partner.writeGrants.push(grant);
670
+ }
671
+
672
+ return saveFederationRegistry(client, appname, registry);
673
+ }
674
+
675
+ /**
676
+ * Revoke write access from a holon for a specific lens.
677
+ *
678
+ * @param {Object} client - NostrClient instance
679
+ * @param {string} appname - Application namespace
680
+ * @param {string} holonId - The holon to revoke write access from
681
+ * @param {string} lensName - The lens to revoke write access for
682
+ * @returns {Promise<boolean>} Success indicator
683
+ */
684
+ export async function revokeWriteAccess(client, appname, holonId, lensName) {
685
+ console.warn('[Deprecated] revokeWriteAccess() is deprecated. Use revokeAccess() instead.');
686
+ const registry = await getFederationRegistry(client, appname);
687
+
688
+ const partner = registry.federatedWith.find(p => p.pubKey === holonId);
689
+
690
+ if (!partner || !partner.writeGrants) {
691
+ return false; // No grants to revoke
692
+ }
693
+
694
+ const initialLength = partner.writeGrants.length;
695
+ partner.writeGrants = partner.writeGrants.filter(g => g.lensName !== lensName);
696
+
697
+ if (partner.writeGrants.length === initialLength) {
698
+ return false; // Grant not found
699
+ }
700
+
701
+ return saveFederationRegistry(client, appname, registry);
702
+ }
703
+
704
+ /**
705
+ * Get all write grants for a specific holon.
706
+ *
707
+ * @param {Object} client - NostrClient instance
708
+ * @param {string} appname - Application namespace
709
+ * @param {string} holonId - The holon to get write grants for
710
+ * @returns {Promise<Array>} Array of write grants { lensName, grantedAt, expiresAt }
711
+ */
712
+ export async function getWriteGrantsForHolon(client, appname, holonId) {
713
+ console.warn('[Deprecated] getWriteGrantsForHolon() is deprecated. Use getAccessGrants() instead.');
714
+ const registry = await getFederationRegistry(client, appname);
715
+
716
+ const partner = registry.federatedWith.find(p => p.pubKey === holonId);
717
+
718
+ if (!partner || !partner.writeGrants) {
719
+ return [];
720
+ }
721
+
722
+ // Filter out expired grants
723
+ const now = Date.now();
724
+ return partner.writeGrants.filter(g => !g.expiresAt || g.expiresAt > now);
725
+ }
726
+
727
+ /**
728
+ * Check if a holon has write access to a specific lens.
729
+ *
730
+ * @param {Object} client - NostrClient instance
731
+ * @param {string} appname - Application namespace
732
+ * @param {string} holonId - The holon to check
733
+ * @param {string} lensName - The lens to check access for
734
+ * @returns {Promise<boolean>} True if holon has write access
735
+ */
736
+ export async function hasWriteAccess(client, appname, holonId, lensName) {
737
+ console.warn('[Deprecated] hasWriteAccess() is deprecated. Use findAccessGrant() instead.');
738
+ const grants = await getWriteGrantsForHolon(client, appname, holonId);
739
+
740
+ // Check for exact lens match or wildcard
741
+ return grants.some(g => g.lensName === lensName || g.lensName === '*');
742
+ }
743
+
744
+ /**
745
+ * Get all holons that have been granted write access to any lens.
746
+ *
747
+ * @param {Object} client - NostrClient instance
748
+ * @param {string} appname - Application namespace
749
+ * @returns {Promise<Array>} Array of { holonId, writeGrants }
750
+ */
751
+ export async function getAllWriteGrants(client, appname) {
752
+ console.warn('[Deprecated] getAllWriteGrants() is deprecated. Use getAccessGrants() instead.');
753
+ const registry = await getFederationRegistry(client, appname);
754
+ const now = Date.now();
755
+ const results = [];
756
+
757
+ for (const partner of registry.federatedWith) {
758
+ if (partner.writeGrants && partner.writeGrants.length > 0) {
759
+ // Filter out expired grants
760
+ const validGrants = partner.writeGrants.filter(g => !g.expiresAt || g.expiresAt > now);
761
+
762
+ if (validGrants.length > 0) {
763
+ results.push({
764
+ holonId: partner.pubKey,
765
+ alias: partner.alias,
766
+ writeGrants: validGrants,
767
+ });
768
+ }
769
+ }
770
+ }
771
+
772
+ return results;
773
+ }
774
+
775
+ // ============================================================================
776
+ // UNIFIED ACCESS GRANTS (New Unified Model)
777
+ // ============================================================================
778
+
779
+ /**
780
+ * @typedef {Object} AccessGrant
781
+ * @property {Object} scope - Grant scope
782
+ * @property {string} [scope.holonId] - Holon ID pattern (or '*' for all)
783
+ * @property {string} [scope.lensName] - Lens name pattern (or '*' for all)
784
+ * @property {string[]} permissions - Array of permissions ('read', 'write')
785
+ * @property {number} grantedAt - When the grant was created
786
+ * @property {number|null} expiresAt - When the grant expires (null for never)
787
+ * @property {string} [capabilityToken] - Optional capability token for cryptographic verification
788
+ * @property {'inbound'|'outbound'} direction - Direction of the grant
789
+ */
790
+
791
+ /**
792
+ * Grant unified access to a partner.
793
+ * This is the new unified method that replaces separate read/write grant functions.
794
+ *
795
+ * @param {Object} client - NostrClient instance
796
+ * @param {string} appname - Application namespace
797
+ * @param {string} partnerPubKey - Partner's public key
798
+ * @param {Object} scope - Access scope { holonId, lensName }
799
+ * @param {string[]} permissions - Array of permissions ('read', 'write')
800
+ * @param {Object} [options={}] - Grant options
801
+ * @param {number} [options.expiresAt] - Expiration timestamp
802
+ * @param {string} [options.capabilityToken] - Capability token for verification
803
+ * @param {'inbound'|'outbound'} [options.direction='inbound'] - Grant direction
804
+ * @returns {Promise<boolean>} Success indicator
805
+ */
806
+ export async function grantAccess(client, appname, partnerPubKey, scope, permissions, options = {}) {
807
+ const registry = await getFederationRegistry(client, appname);
808
+ const { expiresAt = null, capabilityToken = null, direction = 'inbound' } = options;
809
+
810
+ // Find or create partner entry
811
+ let partner = registry.federatedWith.find(p => p.pubKey === partnerPubKey);
812
+
813
+ if (!partner) {
814
+ partner = {
815
+ pubKey: partnerPubKey,
816
+ alias: null,
817
+ addedAt: Date.now(),
818
+ addedVia: 'access_grant',
819
+ inboundCapabilities: [],
820
+ outboundCapabilities: [],
821
+ writeGrants: [],
822
+ accessGrants: [], // New unified array
823
+ };
824
+ registry.federatedWith.push(partner);
825
+ }
826
+
827
+ // Ensure accessGrants array exists
828
+ if (!partner.accessGrants) {
829
+ partner.accessGrants = [];
830
+ }
831
+
832
+ // Normalize scope
833
+ const normalizedScope = {
834
+ holonId: scope.holonId || '*',
835
+ lensName: scope.lensName || '*',
836
+ };
837
+
838
+ // Check if grant already exists for this scope and direction
839
+ const existingIndex = partner.accessGrants.findIndex(g =>
840
+ g.scope.holonId === normalizedScope.holonId &&
841
+ g.scope.lensName === normalizedScope.lensName &&
842
+ g.direction === direction
843
+ );
844
+
845
+ const grant = {
846
+ scope: normalizedScope,
847
+ permissions: [...permissions],
848
+ grantedAt: Date.now(),
849
+ expiresAt,
850
+ capabilityToken,
851
+ direction,
852
+ };
853
+
854
+ if (existingIndex >= 0) {
855
+ // Update existing grant
856
+ partner.accessGrants[existingIndex] = grant;
857
+ } else {
858
+ // Add new grant
859
+ partner.accessGrants.push(grant);
860
+ }
861
+
862
+ return saveFederationRegistry(client, appname, registry);
863
+ }
864
+
865
+ /**
866
+ * Revoke access from a partner.
867
+ * Removes specific permissions from an existing grant.
868
+ *
869
+ * @param {Object} client - NostrClient instance
870
+ * @param {string} appname - Application namespace
871
+ * @param {string} partnerPubKey - Partner's public key
872
+ * @param {Object} scope - Access scope { holonId, lensName }
873
+ * @param {string[]} [permissions] - Permissions to revoke (if omitted, revokes entire grant)
874
+ * @param {Object} [options={}] - Revoke options
875
+ * @param {'inbound'|'outbound'} [options.direction='inbound'] - Grant direction
876
+ * @returns {Promise<boolean>} Success indicator
877
+ */
878
+ export async function revokeAccess(client, appname, partnerPubKey, scope, permissions = null, options = {}) {
879
+ const registry = await getFederationRegistry(client, appname);
880
+ const { direction = 'inbound' } = options;
881
+
882
+ const partner = registry.federatedWith.find(p => p.pubKey === partnerPubKey);
883
+
884
+ if (!partner || !partner.accessGrants) {
885
+ return false;
886
+ }
887
+
888
+ const normalizedScope = {
889
+ holonId: scope.holonId || '*',
890
+ lensName: scope.lensName || '*',
891
+ };
892
+
893
+ const grantIndex = partner.accessGrants.findIndex(g =>
894
+ g.scope.holonId === normalizedScope.holonId &&
895
+ g.scope.lensName === normalizedScope.lensName &&
896
+ g.direction === direction
897
+ );
898
+
899
+ if (grantIndex < 0) {
900
+ return false;
901
+ }
902
+
903
+ if (permissions === null) {
904
+ // Remove entire grant
905
+ partner.accessGrants.splice(grantIndex, 1);
906
+ } else {
907
+ // Remove specific permissions
908
+ const grant = partner.accessGrants[grantIndex];
909
+ grant.permissions = grant.permissions.filter(p => !permissions.includes(p));
910
+
911
+ // If no permissions left, remove the grant entirely
912
+ if (grant.permissions.length === 0) {
913
+ partner.accessGrants.splice(grantIndex, 1);
914
+ }
915
+ }
916
+
917
+ return saveFederationRegistry(client, appname, registry);
918
+ }
919
+
920
+ /**
921
+ * Find an access grant for a partner matching the given scope and permission.
922
+ * Checks unified accessGrants first, then falls back to legacy structures for backward compatibility.
923
+ *
924
+ * @param {Object} client - NostrClient instance
925
+ * @param {string} appname - Application namespace
926
+ * @param {string} partnerPubKey - Partner's public key
927
+ * @param {Object} scope - Access scope { holonId, lensName }
928
+ * @param {string} permission - Permission to check ('read' or 'write')
929
+ * @param {Object} [options={}] - Options
930
+ * @param {'inbound'|'outbound'} [options.direction='inbound'] - Grant direction
931
+ * @returns {Promise<AccessGrant|null>} Matching grant or null
932
+ */
933
+ export async function findAccessGrant(client, appname, partnerPubKey, scope, permission, options = {}) {
934
+ const registry = await getFederationRegistry(client, appname);
935
+ const { direction = 'inbound' } = options;
936
+ const now = Date.now();
937
+
938
+ const partner = registry.federatedWith.find(p => p.pubKey === partnerPubKey);
939
+
940
+ if (!partner) {
941
+ return null;
942
+ }
943
+
944
+ // Check unified accessGrants first
945
+ if (partner.accessGrants && partner.accessGrants.length > 0) {
946
+ for (const grant of partner.accessGrants) {
947
+ // Check expiration
948
+ if (grant.expiresAt && grant.expiresAt < now) {
949
+ continue;
950
+ }
951
+
952
+ // Check direction
953
+ if (grant.direction !== direction) {
954
+ continue;
955
+ }
956
+
957
+ // Check permission
958
+ if (!grant.permissions.includes(permission)) {
959
+ continue;
960
+ }
961
+
962
+ // Check scope match
963
+ if (matchScope(grant.scope, scope)) {
964
+ return grant;
965
+ }
966
+ }
967
+ }
968
+
969
+ // Fall back to legacy structures for backward compatibility
970
+ if (permission === 'read' && direction === 'inbound') {
971
+ // Check inboundCapabilities
972
+ if (partner.inboundCapabilities) {
973
+ for (const cap of partner.inboundCapabilities) {
974
+ if (cap.expires && cap.expires < now) continue;
975
+ if (cap.permissions && !cap.permissions.includes('read')) continue;
976
+ if (matchScope(cap.scope, scope)) {
977
+ return {
978
+ scope: cap.scope,
979
+ permissions: cap.permissions || ['read'],
980
+ grantedAt: cap.receivedAt || Date.now(),
981
+ expiresAt: cap.expires || null,
982
+ capabilityToken: cap.token,
983
+ direction: 'inbound',
984
+ _legacy: true,
985
+ };
986
+ }
987
+ }
988
+ }
989
+ }
990
+
991
+ if (permission === 'write' && direction === 'inbound') {
992
+ // Check writeGrants
993
+ if (partner.writeGrants) {
994
+ for (const wg of partner.writeGrants) {
995
+ if (wg.expiresAt && wg.expiresAt < now) continue;
996
+ if (wg.lensName === scope.lensName || wg.lensName === '*') {
997
+ return {
998
+ scope: { holonId: '*', lensName: wg.lensName },
999
+ permissions: ['write'],
1000
+ grantedAt: wg.grantedAt || Date.now(),
1001
+ expiresAt: wg.expiresAt || null,
1002
+ direction: 'inbound',
1003
+ _legacy: true,
1004
+ };
1005
+ }
1006
+ }
1007
+ }
1008
+ }
1009
+
1010
+ return null;
1011
+ }
1012
+
1013
+ /**
1014
+ * Get all access grants for a partner.
1015
+ *
1016
+ * @param {Object} client - NostrClient instance
1017
+ * @param {string} appname - Application namespace
1018
+ * @param {string} partnerPubKey - Partner's public key
1019
+ * @param {Object} [options={}] - Options
1020
+ * @param {'inbound'|'outbound'|'all'} [options.direction='all'] - Filter by direction
1021
+ * @param {boolean} [options.includeLegacy=true] - Include legacy grants
1022
+ * @returns {Promise<AccessGrant[]>} Array of access grants
1023
+ */
1024
+ export async function getAccessGrants(client, appname, partnerPubKey, options = {}) {
1025
+ const registry = await getFederationRegistry(client, appname);
1026
+ const { direction = 'all', includeLegacy = true } = options;
1027
+ const now = Date.now();
1028
+
1029
+ const partner = registry.federatedWith.find(p => p.pubKey === partnerPubKey);
1030
+
1031
+ if (!partner) {
1032
+ return [];
1033
+ }
1034
+
1035
+ const results = [];
1036
+
1037
+ // Add unified accessGrants
1038
+ if (partner.accessGrants) {
1039
+ for (const grant of partner.accessGrants) {
1040
+ // Filter expired
1041
+ if (grant.expiresAt && grant.expiresAt < now) continue;
1042
+ // Filter by direction
1043
+ if (direction !== 'all' && grant.direction !== direction) continue;
1044
+ results.push(grant);
1045
+ }
1046
+ }
1047
+
1048
+ // Add legacy grants if requested
1049
+ if (includeLegacy) {
1050
+ // Convert inboundCapabilities
1051
+ if (partner.inboundCapabilities && (direction === 'all' || direction === 'inbound')) {
1052
+ for (const cap of partner.inboundCapabilities) {
1053
+ if (cap.expires && cap.expires < now) continue;
1054
+ results.push({
1055
+ scope: cap.scope,
1056
+ permissions: cap.permissions || ['read'],
1057
+ grantedAt: cap.receivedAt || Date.now(),
1058
+ expiresAt: cap.expires || null,
1059
+ capabilityToken: cap.token,
1060
+ direction: 'inbound',
1061
+ _legacy: true,
1062
+ });
1063
+ }
1064
+ }
1065
+
1066
+ // Convert writeGrants
1067
+ if (partner.writeGrants && (direction === 'all' || direction === 'inbound')) {
1068
+ for (const wg of partner.writeGrants) {
1069
+ if (wg.expiresAt && wg.expiresAt < now) continue;
1070
+ results.push({
1071
+ scope: { holonId: '*', lensName: wg.lensName },
1072
+ permissions: ['write'],
1073
+ grantedAt: wg.grantedAt || Date.now(),
1074
+ expiresAt: wg.expiresAt || null,
1075
+ direction: 'inbound',
1076
+ _legacy: true,
1077
+ });
1078
+ }
1079
+ }
1080
+ }
1081
+
1082
+ return results;
1083
+ }
1084
+
1085
+ /**
1086
+ * Migrate legacy grants to unified accessGrants structure.
1087
+ * This should be called during registry load to auto-migrate old data.
1088
+ *
1089
+ * @param {Object} client - NostrClient instance
1090
+ * @param {string} appname - Application namespace
1091
+ * @returns {Promise<Object>} Migration result { migratedPartners, migratedGrants }
1092
+ */
1093
+ export async function migrateToUnifiedGrants(client, appname) {
1094
+ const registry = await getFederationRegistry(client, appname);
1095
+ let migratedPartners = 0;
1096
+ let migratedGrants = 0;
1097
+
1098
+ for (const partner of registry.federatedWith) {
1099
+ let partnerModified = false;
1100
+
1101
+ // Initialize accessGrants if not present
1102
+ if (!partner.accessGrants) {
1103
+ partner.accessGrants = [];
1104
+ }
1105
+
1106
+ // Migrate inboundCapabilities
1107
+ if (partner.inboundCapabilities && partner.inboundCapabilities.length > 0) {
1108
+ for (const cap of partner.inboundCapabilities) {
1109
+ // Check if already migrated
1110
+ const alreadyMigrated = partner.accessGrants.some(g =>
1111
+ g.scope.holonId === cap.scope?.holonId &&
1112
+ g.scope.lensName === cap.scope?.lensName &&
1113
+ g.direction === 'inbound' &&
1114
+ g._migratedFrom === 'inboundCapability'
1115
+ );
1116
+
1117
+ if (!alreadyMigrated && cap.scope) {
1118
+ partner.accessGrants.push({
1119
+ scope: { holonId: cap.scope.holonId || '*', lensName: cap.scope.lensName || '*' },
1120
+ permissions: cap.permissions || ['read'],
1121
+ grantedAt: cap.receivedAt || Date.now(),
1122
+ expiresAt: cap.expires || null,
1123
+ capabilityToken: cap.token,
1124
+ direction: 'inbound',
1125
+ _migratedFrom: 'inboundCapability',
1126
+ });
1127
+ migratedGrants++;
1128
+ partnerModified = true;
1129
+ }
1130
+ }
1131
+ }
1132
+
1133
+ // Migrate writeGrants
1134
+ if (partner.writeGrants && partner.writeGrants.length > 0) {
1135
+ for (const wg of partner.writeGrants) {
1136
+ // Check if already migrated
1137
+ const alreadyMigrated = partner.accessGrants.some(g =>
1138
+ g.scope.lensName === wg.lensName &&
1139
+ g.permissions.includes('write') &&
1140
+ g.direction === 'inbound' &&
1141
+ g._migratedFrom === 'writeGrant'
1142
+ );
1143
+
1144
+ if (!alreadyMigrated) {
1145
+ partner.accessGrants.push({
1146
+ scope: { holonId: '*', lensName: wg.lensName },
1147
+ permissions: ['write'],
1148
+ grantedAt: wg.grantedAt || Date.now(),
1149
+ expiresAt: wg.expiresAt || null,
1150
+ direction: 'inbound',
1151
+ _migratedFrom: 'writeGrant',
1152
+ });
1153
+ migratedGrants++;
1154
+ partnerModified = true;
1155
+ }
1156
+ }
1157
+ }
1158
+
1159
+ if (partnerModified) {
1160
+ migratedPartners++;
1161
+ }
1162
+ }
1163
+
1164
+ if (migratedGrants > 0) {
1165
+ await saveFederationRegistry(client, appname, registry);
1166
+ }
1167
+
1168
+ return { migratedPartners, migratedGrants };
1169
+ }
1170
+
1171
+ /**
1172
+ * Convert legacy lensConfig format (v1.0) to unified permissions format (v2.0).
1173
+ *
1174
+ * @param {Object} legacyConfig - Legacy lens config { inbound, outbound, writeInbound, writeOutbound }
1175
+ * @returns {Object} Unified permissions format { version, permissions }
1176
+ */
1177
+ export function convertLegacyLensConfig(legacyConfig) {
1178
+ if (!legacyConfig) return { version: '2.0', permissions: {} };
1179
+
1180
+ const permissions = {};
1181
+
1182
+ // Process inbound (read receive)
1183
+ for (const lens of (legacyConfig.inbound || [])) {
1184
+ if (!permissions[lens]) {
1185
+ permissions[lens] = { receive: [], share: [] };
1186
+ }
1187
+ if (!permissions[lens].receive.includes('read')) {
1188
+ permissions[lens].receive.push('read');
1189
+ }
1190
+ }
1191
+
1192
+ // Process outbound (read share)
1193
+ for (const lens of (legacyConfig.outbound || [])) {
1194
+ if (!permissions[lens]) {
1195
+ permissions[lens] = { receive: [], share: [] };
1196
+ }
1197
+ if (!permissions[lens].share.includes('read')) {
1198
+ permissions[lens].share.push('read');
1199
+ }
1200
+ }
1201
+
1202
+ // Process writeInbound (write receive)
1203
+ for (const lens of (legacyConfig.writeInbound || [])) {
1204
+ if (!permissions[lens]) {
1205
+ permissions[lens] = { receive: [], share: [] };
1206
+ }
1207
+ if (!permissions[lens].receive.includes('write')) {
1208
+ permissions[lens].receive.push('write');
1209
+ }
1210
+ }
1211
+
1212
+ // Process writeOutbound (write share)
1213
+ for (const lens of (legacyConfig.writeOutbound || [])) {
1214
+ if (!permissions[lens]) {
1215
+ permissions[lens] = { receive: [], share: [] };
1216
+ }
1217
+ if (!permissions[lens].share.includes('write')) {
1218
+ permissions[lens].share.push('write');
1219
+ }
1220
+ }
1221
+
1222
+ return { version: '2.0', permissions };
1223
+ }
1224
+
1225
+ /**
1226
+ * Convert unified permissions format (v2.0) to legacy lensConfig format (v1.0).
1227
+ * Useful for backward compatibility with older clients.
1228
+ *
1229
+ * @param {Object} unifiedConfig - Unified permissions { version, permissions }
1230
+ * @returns {Object} Legacy lens config { inbound, outbound, writeInbound, writeOutbound }
1231
+ */
1232
+ export function convertToLegacyLensConfig(unifiedConfig) {
1233
+ if (!unifiedConfig || !unifiedConfig.permissions) {
1234
+ return { inbound: [], outbound: [], writeInbound: [], writeOutbound: [] };
1235
+ }
1236
+
1237
+ const legacy = {
1238
+ inbound: [],
1239
+ outbound: [],
1240
+ writeInbound: [],
1241
+ writeOutbound: [],
1242
+ };
1243
+
1244
+ for (const [lensName, perms] of Object.entries(unifiedConfig.permissions)) {
1245
+ // Read receive → inbound
1246
+ if (perms.receive?.includes('read')) {
1247
+ legacy.inbound.push(lensName);
1248
+ }
1249
+ // Read share → outbound
1250
+ if (perms.share?.includes('read')) {
1251
+ legacy.outbound.push(lensName);
1252
+ }
1253
+ // Write receive → writeInbound
1254
+ if (perms.receive?.includes('write')) {
1255
+ legacy.writeInbound.push(lensName);
1256
+ }
1257
+ // Write share → writeOutbound
1258
+ if (perms.share?.includes('write')) {
1259
+ legacy.writeOutbound.push(lensName);
1260
+ }
1261
+ }
1262
+
1263
+ return legacy;
1264
+ }