holosphere 2.0.0-alpha20 → 2.0.0-alpha21

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "holosphere",
3
- "version": "2.0.0-alpha20",
3
+ "version": "2.0.0-alpha21",
4
4
  "description": "Holonic geospatial communication infrastructure combining H3 hexagonal indexing with distributed P2P storage",
5
5
  "type": "module",
6
6
  "bin": {
@@ -12,6 +12,15 @@ import { sha256 } from '@noble/hashes/sha256';
12
12
  import { bytesToHex, hexToBytes } from '@noble/hashes/utils';
13
13
  import { secp256k1, schnorr } from '@noble/curves/secp256k1';
14
14
 
15
+ /**
16
+ * Check if a string is a 64-char hex public key (x-only schnorr format).
17
+ * @param {string} str - String to check
18
+ * @returns {boolean} True if string is a valid public key format
19
+ */
20
+ export function isPubkey(str) {
21
+ return typeof str === 'string' && /^[0-9a-f]{64}$/i.test(str);
22
+ }
23
+
15
24
  /**
16
25
  * Get public key from private key (x-only / schnorr format for Nostr compatibility)
17
26
  * @param {string} privateKey - Private key (hex string)
@@ -74,6 +83,98 @@ export async function verify(content, signature, publicKey) {
74
83
  }
75
84
  }
76
85
 
86
+ /**
87
+ * Normalize a capability token to a plain string.
88
+ * Handles: object wrappers ({ token: "..." }), Buffer serialization, comma-separated byte strings.
89
+ * @param {string|Object} token - Token in any supported format
90
+ * @returns {string|null} Normalized token string, or null if invalid
91
+ */
92
+ export function normalizeTokenString(token) {
93
+ // Handle capability object wrapper { token, scope, permissions }
94
+ if (token && typeof token === 'object' && token.token) {
95
+ token = token.token;
96
+ }
97
+
98
+ // Handle Buffer serialization format {"type":"Buffer","data":[...]}
99
+ if (token && typeof token === 'object' && token.type === 'Buffer' && Array.isArray(token.data)) {
100
+ try {
101
+ token = String.fromCharCode.apply(null, token.data);
102
+ } catch (e) {
103
+ return null;
104
+ }
105
+ }
106
+
107
+ // Handle comma-separated byte string (e.g., "123,34,116,...")
108
+ if (typeof token === 'string' && /^\d+(,\d+)+$/.test(token.substring(0, 50))) {
109
+ try {
110
+ const bytes = token.split(',').map(Number);
111
+ token = String.fromCharCode.apply(null, bytes);
112
+ } catch (e) {
113
+ return null;
114
+ }
115
+ }
116
+
117
+ if (typeof token === 'string') {
118
+ return token;
119
+ }
120
+
121
+ return null;
122
+ }
123
+
124
+ /**
125
+ * Parse a capability token string into its components.
126
+ * Handles base64-encoded, signed (payload.signature), and raw JSON formats.
127
+ * @private
128
+ * @param {string} token - Normalized token string
129
+ * @returns {{ tokenObj: Object, payload: string, signature: string|null }|null} Parsed token or null
130
+ */
131
+ function _parseToken(token) {
132
+ try {
133
+ if (typeof token !== 'string') return null;
134
+
135
+ let payload;
136
+ let signature = null;
137
+ let tokenObj;
138
+
139
+ if (token.startsWith('ey') || (!token.includes('.') && !token.startsWith('{'))) {
140
+ // Base64 encoded (with or without signature)
141
+ if (token.includes('.')) {
142
+ const parts = token.split('.');
143
+ signature = parts[1];
144
+ const decoded = typeof atob === 'function'
145
+ ? atob(parts[0])
146
+ : Buffer.from(parts[0], 'base64').toString('utf8');
147
+ payload = decoded;
148
+ } else {
149
+ const decoded = typeof atob === 'function'
150
+ ? atob(token)
151
+ : Buffer.from(token, 'base64').toString('utf8');
152
+ payload = decoded;
153
+ }
154
+ tokenObj = JSON.parse(payload);
155
+ } else if (token.startsWith('{')) {
156
+ payload = token;
157
+ tokenObj = JSON.parse(token);
158
+ } else {
159
+ return null;
160
+ }
161
+
162
+ return { tokenObj, payload, signature };
163
+ } catch (e) {
164
+ return null;
165
+ }
166
+ }
167
+
168
+ /**
169
+ * Hash a capability token using SHA-256.
170
+ * @param {string} token - Capability token string
171
+ * @returns {string} Hex-encoded SHA-256 hash
172
+ */
173
+ export function hashToken(token) {
174
+ const encoder = new TextEncoder();
175
+ return bytesToHex(sha256(encoder.encode(token)));
176
+ }
177
+
77
178
  /**
78
179
  * Match token scope against requested scope with wildcard support
79
180
  * Supports:
@@ -224,77 +325,26 @@ export async function issueSelfCapability(permissions, scope, authorPubKey, opti
224
325
  */
225
326
  export async function verifyCapability(token, requiredPermission, scope) {
226
327
  try {
227
- let tokenObj;
228
-
229
- // Handle capability object wrapper { token, scope, permissions }
230
- // This normalizes both formats: raw token string and capability info object
231
- if (token && typeof token === 'object' && token.token) {
232
- token = token.token; // Extract the actual token string
233
- }
234
-
235
- // Handle Buffer serialization format {"type":"Buffer","data":[...]}
236
- // This can happen when Buffer is polyfilled and JSON serialized
237
- if (token && typeof token === 'object' && token.type === 'Buffer' && Array.isArray(token.data)) {
238
- try {
239
- // Convert Buffer data array back to string
240
- token = String.fromCharCode.apply(null, token.data);
241
- } catch (e) {
242
- console.log('[verifyCapability] ❌ Failed to decode Buffer object:', e.message);
243
- return false;
244
- }
328
+ const normalized = normalizeTokenString(token);
329
+ if (!normalized) {
330
+ console.log('[verifyCapability] Failed to normalize token');
331
+ return false;
245
332
  }
246
333
 
247
- // Handle comma-separated byte string (e.g., "123,34,116,...")
248
- // This can happen when Uint8Array.toString() is called
249
- if (typeof token === 'string' && /^\d+(,\d+)+$/.test(token.substring(0, 50))) {
250
- try {
251
- const bytes = token.split(',').map(Number);
252
- token = String.fromCharCode.apply(null, bytes);
253
- } catch (e) {
254
- console.log('[verifyCapability] ❌ Failed to decode byte string:', e.message);
255
- return false;
256
- }
257
- }
334
+ let tokenObj;
258
335
 
259
- // Decode if string
260
- if (typeof token === 'string') {
261
- // Check if it's a base64-encoded token (starts with "ey" for JSON base64)
262
- if (token.startsWith('ey') || (!token.includes('.') && !token.startsWith('{'))) {
263
- // Base64 encoded (with or without signature)
264
- try {
265
- const payload = token.includes('.') ? token.split('.')[0] : token;
266
- // Prefer browser-native atob to avoid Buffer issues
267
- const decoded = typeof atob === 'function'
268
- ? atob(payload)
269
- : Buffer.from(payload, 'base64').toString('utf8');
270
- tokenObj = JSON.parse(decoded);
271
- } catch (e) {
272
- console.log('[verifyCapability] ❌ Token is not valid base64 JSON:', {
273
- preview: token.substring(0, 50),
274
- error: e.message
275
- });
276
- return false;
277
- }
278
- } else if (token.startsWith('{')) {
279
- // Already JSON string
280
- try {
281
- tokenObj = JSON.parse(token);
282
- } catch (e) {
283
- console.log('[verifyCapability] ❌ Token is not valid JSON:', {
284
- preview: token.substring(0, 50),
285
- error: e.message
286
- });
287
- return false;
288
- }
289
- } else {
290
- console.log('[verifyCapability] ❌ Unknown token format:', token.substring(0, 50));
336
+ // If it's a string, parse it
337
+ if (typeof normalized === 'string') {
338
+ const parsed = _parseToken(normalized);
339
+ if (!parsed) {
340
+ console.log('[verifyCapability] Failed to parse token');
291
341
  return false;
292
342
  }
293
- } else if (token && typeof token === 'object') {
294
- // Already decoded token object
295
- tokenObj = token;
343
+ tokenObj = parsed.tokenObj;
344
+ } else if (normalized && typeof normalized === 'object') {
345
+ tokenObj = normalized;
296
346
  } else {
297
- console.log('[verifyCapability] ❌ Invalid token:', typeof token);
347
+ console.log('[verifyCapability] ❌ Invalid token:', typeof normalized);
298
348
  return false;
299
349
  }
300
350
 
@@ -351,55 +401,20 @@ export async function verifyCapability(token, requiredPermission, scope) {
351
401
  */
352
402
  export async function verifyCapabilityIssuer(token, expectedIssuer) {
353
403
  try {
354
- // Handle capability object wrapper
355
- if (token && typeof token === 'object' && token.token) {
356
- token = token.token;
357
- }
358
-
359
- // Handle Buffer serialization format
360
- if (token && typeof token === 'object' && token.type === 'Buffer' && Array.isArray(token.data)) {
361
- token = String.fromCharCode.apply(null, token.data);
362
- }
363
-
364
- // Must be a string at this point
365
- if (typeof token !== 'string') {
366
- console.log('[verifyCapabilityIssuer] ❌ Token is not a string');
404
+ const normalized = normalizeTokenString(token);
405
+ if (!normalized) {
406
+ console.log('[verifyCapabilityIssuer] Failed to normalize token');
367
407
  return false;
368
408
  }
369
409
 
370
- // Extract payload and signature from token
371
- let payload;
372
- let signature;
373
- let tokenObj;
374
-
375
- if (token.includes('.')) {
376
- // Token has signature: payload.signature
377
- const parts = token.split('.');
378
- const encodedPayload = parts[0];
379
- signature = parts[1];
380
-
381
- // Decode payload
382
- const decoded = typeof atob === 'function'
383
- ? atob(encodedPayload)
384
- : Buffer.from(encodedPayload, 'base64').toString('utf8');
385
- payload = decoded;
386
- tokenObj = JSON.parse(decoded);
387
- } else if (token.startsWith('ey')) {
388
- // Base64 encoded without signature
389
- const decoded = typeof atob === 'function'
390
- ? atob(token)
391
- : Buffer.from(token, 'base64').toString('utf8');
392
- payload = decoded;
393
- tokenObj = JSON.parse(decoded);
394
- } else if (token.startsWith('{')) {
395
- // Already JSON string
396
- payload = token;
397
- tokenObj = JSON.parse(token);
398
- } else {
399
- console.log('[verifyCapabilityIssuer] ❌ Unknown token format');
410
+ const parsed = _parseToken(normalized);
411
+ if (!parsed) {
412
+ console.log('[verifyCapabilityIssuer] ❌ Failed to parse token');
400
413
  return false;
401
414
  }
402
415
 
416
+ const { tokenObj, payload, signature } = parsed;
417
+
403
418
  // Verify issuer field matches expected
404
419
  if (tokenObj.issuer !== expectedIssuer) {
405
420
  console.log('[verifyCapabilityIssuer] ❌ Issuer mismatch:', {
@@ -418,9 +433,9 @@ export async function verifyCapabilityIssuer(token, expectedIssuer) {
418
433
  return false;
419
434
  }
420
435
  } else {
421
- // No signature - for backwards compatibility, allow unsigned tokens
422
- // but log a warning
423
- console.log('[verifyCapabilityIssuer] ⚠️ Token has no signature - cannot verify cryptographically');
436
+ // No signature - cannot cryptographically verify issuer identity
437
+ console.log('[verifyCapabilityIssuer] Token has no signature - cannot verify issuer');
438
+ return false;
424
439
  }
425
440
 
426
441
  console.log('[verifyCapabilityIssuer] ✅ Issuer verified');
@@ -439,35 +454,11 @@ export async function verifyCapabilityIssuer(token, expectedIssuer) {
439
454
  * @returns {Object|null} Decoded token payload or null if invalid
440
455
  */
441
456
  export function decodeCapability(token) {
442
- try {
443
- // Handle capability object wrapper
444
- if (token && typeof token === 'object' && token.token) {
445
- token = token.token;
446
- }
447
-
448
- // Handle Buffer serialization format
449
- if (token && typeof token === 'object' && token.type === 'Buffer' && Array.isArray(token.data)) {
450
- token = String.fromCharCode.apply(null, token.data);
451
- }
457
+ const normalized = normalizeTokenString(token);
458
+ if (!normalized) return null;
452
459
 
453
- if (typeof token !== 'string') {
454
- return null;
455
- }
456
-
457
- if (token.startsWith('ey') || (!token.includes('.') && !token.startsWith('{'))) {
458
- const payload = token.includes('.') ? token.split('.')[0] : token;
459
- const decoded = typeof atob === 'function'
460
- ? atob(payload)
461
- : Buffer.from(payload, 'base64').toString('utf8');
462
- return JSON.parse(decoded);
463
- } else if (token.startsWith('{')) {
464
- return JSON.parse(token);
465
- }
466
-
467
- return null;
468
- } catch (error) {
469
- return null;
470
- }
460
+ const parsed = _parseToken(normalized);
461
+ return parsed ? parsed.tokenObj : null;
471
462
  }
472
463
 
473
464
  /**
@@ -1,162 +1,46 @@
1
1
  /**
2
- * @fileoverview Federation Capabilities Module
2
+ * @fileoverview Federation Capabilities Module — Re-exports
3
3
  *
4
- * Handles capability token issuance, verification, and management
5
- * for federation operations. Extracted from federation-methods.js
6
- * for better modularity.
4
+ * This module re-exports capability functions from their canonical locations
5
+ * (crypto/secp256k1.js and federation/registry.js) for backward compatibility.
6
+ *
7
+ * Prefer importing directly from the canonical modules in new code.
7
8
  *
8
9
  * @module federation/capabilities
9
10
  */
10
11
 
11
- import * as crypto from '../crypto/secp256k1.js';
12
- import * as registry from './registry.js';
13
- import { sha256 } from '@noble/hashes/sha256';
14
- import { bytesToHex } from '@noble/hashes/utils';
15
-
16
- /**
17
- * Issue a capability token for federation
18
- * @param {Object} client - NostrClient instance
19
- * @param {string} targetPubKey - Target public key to grant access to
20
- * @param {Object} scope - Access scope { holonId, lensName, dataId }
21
- * @param {string[]} permissions - Array of permissions ('read', 'write')
22
- * @param {Object} options - Capability options
23
- * @param {number} [options.expiresIn=3600000] - Expiration time in milliseconds
24
- * @param {boolean} [options.isSelfCapability=false] - Whether this is a self-capability
25
- * @returns {Promise<string>} Capability token
26
- */
27
- export async function issueCapability(client, targetPubKey, scope, permissions, options = {}) {
28
- const {
29
- expiresIn = 3600000,
30
- isSelfCapability = false,
31
- } = options;
32
-
33
- const token = await crypto.issueCapability(
34
- permissions,
35
- scope,
36
- targetPubKey,
37
- {
38
- expiresIn,
39
- issuer: client.publicKey,
40
- issuerKey: client.privateKey,
41
- isSelfCapability,
42
- }
43
- );
44
-
45
- return token;
46
- }
47
-
48
- /**
49
- * Issue capability for a specific lens
50
- * @param {Object} client - NostrClient instance
51
- * @param {string} holonId - Holon ID
52
- * @param {string} lensName - Lens name
53
- * @param {string} targetPubKey - Target public key
54
- * @param {Object} options - Options
55
- * @returns {Promise<Object>} Capability info { token, scope, permissions }
56
- */
57
- export async function issueCapabilityForLens(client, holonId, lensName, targetPubKey, options = {}) {
58
- const {
59
- permissions = ['read'],
60
- expiresIn = 365 * 24 * 60 * 60 * 1000, // 1 year default
61
- } = options;
62
-
63
- const scope = { holonId, lensName, dataId: '*' };
64
-
65
- const token = await issueCapability(client, targetPubKey, scope, permissions, {
66
- expiresIn,
67
- isSelfCapability: client.publicKey === targetPubKey,
68
- });
69
-
70
- return {
71
- token,
72
- scope,
73
- permissions,
74
- expiresAt: Date.now() + expiresIn,
75
- };
76
- }
77
-
78
- /**
79
- * Issue capabilities for multiple lenses
80
- * @param {Object} client - NostrClient instance
81
- * @param {string} holonId - Holon ID
82
- * @param {string[]} lensNames - Array of lens names
83
- * @param {string} targetPubKey - Target public key
84
- * @param {Object} options - Options
85
- * @returns {Promise<Object[]>} Array of capability info objects
86
- */
87
- export async function issueCapabilitiesForLenses(client, holonId, lensNames, targetPubKey, options = {}) {
88
- const capabilities = [];
12
+ export {
13
+ issueCapability,
14
+ verifyCapability,
15
+ hashToken,
16
+ } from '../crypto/secp256k1.js';
89
17
 
90
- for (const lensName of lensNames) {
91
- try {
92
- const capInfo = await issueCapabilityForLens(client, holonId, lensName, targetPubKey, options);
93
- capabilities.push(capInfo);
94
- } catch (err) {
95
- console.warn(`[Capabilities] Failed to issue capability for ${lensName}:`, err.message);
96
- }
97
- }
18
+ export {
19
+ issueCapabilityForLens,
20
+ issueCapabilitiesForLenses,
21
+ getCapabilityForAuthor as getCapabilityForPartner,
22
+ storeInboundCapability,
23
+ storeSelfCapability,
24
+ } from './registry.js';
98
25
 
99
- return capabilities;
100
- }
26
+ import { storeSelfCapability, storeInboundCapability } from './registry.js';
101
27
 
102
28
  /**
103
- * Store a capability in the registry
29
+ * Store a capability in the registry (convenience wrapper)
104
30
  * @param {Object} client - NostrClient instance
105
31
  * @param {string} appname - Application namespace
106
32
  * @param {string} partnerPubKey - Partner's public key
107
33
  * @param {Object} capabilityInfo - Capability info to store
108
34
  * @param {Object} options - Options
35
+ * @param {boolean} [options.isSelf=false] - Whether this is a self-capability
109
36
  * @returns {Promise<boolean>} Success indicator
110
37
  */
111
38
  export async function storeCapability(client, appname, partnerPubKey, capabilityInfo, options = {}) {
112
39
  const { isSelf = false } = options;
113
40
 
114
41
  if (isSelf) {
115
- return registry.storeSelfCapability(client, appname, capabilityInfo);
42
+ return storeSelfCapability(client, appname, capabilityInfo);
116
43
  } else {
117
- return registry.storeInboundCapability(client, appname, partnerPubKey, capabilityInfo);
44
+ return storeInboundCapability(client, appname, partnerPubKey, capabilityInfo);
118
45
  }
119
46
  }
120
-
121
- /**
122
- * Hash a capability token for storage
123
- * @param {string} token - Capability token to hash
124
- * @returns {string} SHA256 hash of token
125
- */
126
- export function hashToken(token) {
127
- const encoder = new TextEncoder();
128
- return bytesToHex(sha256(encoder.encode(token)));
129
- }
130
-
131
- /**
132
- * Verify a capability token
133
- * @param {string} token - Capability token
134
- * @param {string} permission - Required permission
135
- * @param {Object} scope - Requested scope
136
- * @returns {Promise<boolean>} True if valid
137
- */
138
- export async function verifyCapability(token, permission, scope) {
139
- return crypto.verifyCapability(token, permission, scope);
140
- }
141
-
142
- /**
143
- * Get capability for accessing a partner's data
144
- * @param {Object} client - NostrClient instance
145
- * @param {string} appname - Application namespace
146
- * @param {string} partnerPubKey - Partner's public key
147
- * @param {Object} scope - Requested scope
148
- * @returns {Promise<Object|null>} Capability entry or null
149
- */
150
- export async function getCapabilityForPartner(client, appname, partnerPubKey, scope) {
151
- return registry.getCapabilityForAuthor(client, appname, partnerPubKey, scope);
152
- }
153
-
154
- export default {
155
- issueCapability,
156
- issueCapabilityForLens,
157
- issueCapabilitiesForLenses,
158
- storeCapability,
159
- hashToken,
160
- verifyCapability,
161
- getCapabilityForPartner,
162
- };
@@ -16,7 +16,7 @@
16
16
  */
17
17
 
18
18
  import { buildPath, write, read, update } from '../storage/unified-storage.js';
19
- import { verifyCapability, issueCapability, verifyCapabilityIssuer, decodeCapability } from '../crypto/secp256k1.js';
19
+ import { verifyCapability, issueCapability, verifyCapabilityIssuer, decodeCapability, normalizeTokenString, isPubkey } from '../crypto/secp256k1.js';
20
20
  import { getCapabilityForAuthor, storeInboundCapability } from './registry.js';
21
21
 
22
22
  /** @constant {number} Maximum depth for hologram resolution chain */
@@ -113,10 +113,8 @@ export function createHologram(sourceHolon, targetHolon, lensName, dataId, appna
113
113
  throw new Error('capability is required for hologram creation (unified model)');
114
114
  }
115
115
 
116
- // Normalize capability - ensure it's the token string, not a capability object
117
- if (typeof capability === 'object' && capability.token) {
118
- capability = capability.token;
119
- }
116
+ // Normalize capability to plain token string
117
+ capability = normalizeTokenString(capability) || capability;
120
118
 
121
119
  const soul = buildPath(appname, sourceHolon, lensName, dataId);
122
120
 
@@ -289,9 +287,9 @@ export async function resolveHologram(client, hologram, visited = new Set(), cha
289
287
  let capability = hologram.capability;
290
288
  const authorPubKey = target.authorPubKey;
291
289
 
292
- // Normalize capability - extract token string if it's a capability object
293
- if (capability && typeof capability === 'object' && capability.token) {
294
- capability = capability.token;
290
+ // Normalize capability to plain token string
291
+ if (capability) {
292
+ capability = normalizeTokenString(capability) || capability;
295
293
  }
296
294
 
297
295
  if (!capability && options.appname && authorPubKey) {
@@ -621,9 +619,7 @@ export async function propagateData(
621
619
 
622
620
  // Determine targetAuthorPubKey - the person who should be able to read the hologram
623
621
  // If targetHolon is a 64-char hex pubkey, use it directly as the target author
624
- // Otherwise, this needs to be resolved via holon registry (TODO: add resolution)
625
- const isPubkey = typeof targetHolon === 'string' && /^[0-9a-f]{64}$/i.test(targetHolon);
626
- const targetAuthorPubKey = options.targetAuthorPubKey || (isPubkey ? targetHolon : client.publicKey);
622
+ const targetAuthorPubKey = options.targetAuthorPubKey || (isPubkey(targetHolon) ? targetHolon : client.publicKey);
627
623
 
628
624
  const hologram = await createHologramWithCapability(
629
625
  client,
@@ -738,36 +734,31 @@ export function getHologramSource(data) {
738
734
  }
739
735
 
740
736
  /**
741
- * Add a hologram reference to the source data's _meta.activeHolograms
742
- * This tracks all holograms pointing to this specific data item
743
- * If the source is itself a hologram, follows the chain to find the real source
737
+ * Follow a hologram chain to find the real (non-hologram) source data.
738
+ * If the data at the given location is a hologram, follows .target references
739
+ * until real data is found or max depth is reached.
740
+ * @private
744
741
  * @param {Object} client - Nostr client instance
745
742
  * @param {string} appname - Application namespace
746
- * @param {string} sourceHolon - Source holon ID (where original data lives)
747
- * @param {string} lensName - Lens name
743
+ * @param {string} holon - Starting holon ID
744
+ * @param {string} lens - Lens name
748
745
  * @param {string} dataId - Data ID
749
- * @param {string} targetHolon - Target holon ID (where hologram lives)
750
- * @returns {Promise<boolean>} Success indicator
746
+ * @param {number} [maxDepth=10] - Maximum chain depth
747
+ * @returns {Promise<{ sourcePath: string, sourceData: Object }|null>} Real source or null
751
748
  */
752
- export async function addActiveHologram(client, appname, sourceHolon, lensName, dataId, targetHolon) {
749
+ async function followToRealSource(client, appname, holon, lens, dataId, maxDepth = 10) {
753
750
  let currentAppname = appname;
754
- let currentHolon = sourceHolon;
755
- let currentLens = lensName;
751
+ let currentHolon = holon;
752
+ let currentLens = lens;
756
753
  let currentDataId = dataId;
757
754
  let sourcePath = buildPath(currentAppname, currentHolon, currentLens, currentDataId);
758
755
 
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
762
756
  let sourceData = await read(client, sourcePath);
763
757
  if (!sourceData) {
764
- return false;
758
+ return null;
765
759
  }
766
760
 
767
- // If source is a hologram, follow the chain to find the real source
768
- // This ensures activeHolograms is always tracked on the actual data, not on holograms
769
761
  let depth = 0;
770
- const maxDepth = 10;
771
762
  while (sourceData.hologram === true && sourceData.target && depth < maxDepth) {
772
763
  depth++;
773
764
  currentAppname = sourceData.target.appname || currentAppname;
@@ -778,10 +769,34 @@ export async function addActiveHologram(client, appname, sourceHolon, lensName,
778
769
 
779
770
  sourceData = await read(client, sourcePath);
780
771
  if (!sourceData) {
781
- return false;
772
+ return null;
782
773
  }
783
774
  }
784
775
 
776
+ return { sourcePath, sourceData };
777
+ }
778
+
779
+ /**
780
+ * Add a hologram reference to the source data's _meta.activeHolograms
781
+ * This tracks all holograms pointing to this specific data item
782
+ * If the source is itself a hologram, follows the chain to find the real source
783
+ * @param {Object} client - Nostr client instance
784
+ * @param {string} appname - Application namespace
785
+ * @param {string} sourceHolon - Source holon ID (where original data lives)
786
+ * @param {string} lensName - Lens name
787
+ * @param {string} dataId - Data ID
788
+ * @param {string} targetHolon - Target holon ID (where hologram lives)
789
+ * @returns {Promise<boolean>} Success indicator
790
+ */
791
+ export async function addActiveHologram(client, appname, sourceHolon, lensName, dataId, targetHolon) {
792
+ // Note: May return null if called immediately after write (before relay persistence)
793
+ // This is expected - the hologram still works, we just can't track activeHolograms
794
+ const result = await followToRealSource(client, appname, sourceHolon, lensName, dataId);
795
+ if (!result) {
796
+ return false;
797
+ }
798
+
799
+ const { sourcePath, sourceData } = result;
785
800
 
786
801
  // Initialize _meta and activeHolograms if needed
787
802
  if (!sourceData._meta) {
@@ -827,34 +842,12 @@ export async function addActiveHologram(client, appname, sourceHolon, lensName,
827
842
  * @returns {Promise<boolean>} Success indicator
828
843
  */
829
844
  export async function removeActiveHologram(client, appname, sourceHolon, lensName, dataId, targetHolon) {
830
- let currentAppname = appname;
831
- let currentHolon = sourceHolon;
832
- let currentLens = lensName;
833
- let currentDataId = dataId;
834
- let sourcePath = buildPath(currentAppname, currentHolon, currentLens, currentDataId);
835
-
836
- // Read existing source data
837
- let sourceData = await read(client, sourcePath);
838
- if (!sourceData) {
845
+ const result = await followToRealSource(client, appname, sourceHolon, lensName, dataId);
846
+ if (!result) {
839
847
  return false;
840
848
  }
841
849
 
842
- // If source is a hologram, follow the chain to find the real source
843
- let depth = 0;
844
- const maxDepth = 10;
845
- while (sourceData.hologram === true && sourceData.target && depth < maxDepth) {
846
- depth++;
847
- currentAppname = sourceData.target.appname || currentAppname;
848
- currentHolon = sourceData.target.holonId;
849
- currentLens = sourceData.target.lensName || currentLens;
850
- currentDataId = sourceData.target.dataId || currentDataId;
851
- sourcePath = buildPath(currentAppname, currentHolon, currentLens, currentDataId);
852
-
853
- sourceData = await read(client, sourcePath);
854
- if (!sourceData) {
855
- return false;
856
- }
857
- }
850
+ const { sourcePath, sourceData } = result;
858
851
 
859
852
  if (!sourceData._meta || !Array.isArray(sourceData._meta.activeHolograms)) {
860
853
  return false;
@@ -887,35 +880,12 @@ export async function removeActiveHologram(client, appname, sourceHolon, lensNam
887
880
  * @returns {Promise<boolean>} Success indicator
888
881
  */
889
882
  export async function updateActiveHologramPlatform(client, appname, sourceHolon, lensName, dataId, targetHolon, platform, platformData) {
890
- let currentAppname = appname;
891
- let currentHolon = sourceHolon;
892
- let currentLens = lensName;
893
- let currentDataId = dataId;
894
- let sourcePath = buildPath(currentAppname, currentHolon, currentLens, currentDataId);
895
-
896
- // Read existing source data
897
- // Note: May return null if called immediately after write (before relay persistence)
898
- let sourceData = await read(client, sourcePath);
899
- if (!sourceData) {
883
+ const result = await followToRealSource(client, appname, sourceHolon, lensName, dataId);
884
+ if (!result) {
900
885
  return false;
901
886
  }
902
887
 
903
- // If source is a hologram, follow the chain to find the real source
904
- let depth = 0;
905
- const maxDepth = 10;
906
- while (sourceData.hologram === true && sourceData.target && depth < maxDepth) {
907
- depth++;
908
- currentAppname = sourceData.target.appname || currentAppname;
909
- currentHolon = sourceData.target.holonId;
910
- currentLens = sourceData.target.lensName || currentLens;
911
- currentDataId = sourceData.target.dataId || currentDataId;
912
- sourcePath = buildPath(currentAppname, currentHolon, currentLens, currentDataId);
913
-
914
- sourceData = await read(client, sourcePath);
915
- if (!sourceData) {
916
- return false;
917
- }
918
- }
888
+ const { sourcePath, sourceData } = result;
919
889
 
920
890
  if (!sourceData._meta || !Array.isArray(sourceData._meta.activeHolograms)) {
921
891
  console.warn(`No activeHolograms found for ${sourcePath}`);
@@ -1132,96 +1102,17 @@ export async function deleteHologram(client, appname, holonId, lensName, dataId,
1132
1102
  * @returns {Promise<Object>} Result with cleanup info
1133
1103
  */
1134
1104
  export async function cleanupCircularHolograms(client, appname, holonId, lensName, options = {}) {
1135
- const { dryRun = false } = options;
1136
- const result = {
1137
- scanned: 0,
1138
- circularFound: 0,
1139
- deleted: 0,
1140
- errors: [],
1141
- details: []
1142
- };
1143
-
1144
- console.info(`🧹 ${dryRun ? '[DRY RUN] ' : ''}Cleaning up circular holograms in ${holonId}/${lensName}...`);
1145
-
1146
- try {
1147
- // Get all data in this lens
1148
- const basePath = buildPath(appname, holonId, lensName);
1149
- // We need to iterate through all items - this requires listing capability
1150
- // For now, we'll rely on the caller to provide a list of IDs to check
1151
- // or we can use a different approach
1152
-
1153
- // Alternative: Read the lens index if it exists
1154
- const lensIndexPath = buildPath(appname, holonId, lensName, '_index');
1155
- const lensIndex = await read(client, lensIndexPath);
1156
-
1157
- if (!lensIndex || !Array.isArray(lensIndex.items)) {
1158
- console.warn(` No index found for ${holonId}/${lensName}, cannot scan for circular references`);
1159
- console.info(` Tip: Provide specific IDs to check using cleanupCircularHologramsByIds()`);
1160
- return result;
1161
- }
1162
-
1163
- for (const itemId of lensIndex.items) {
1164
- result.scanned++;
1165
- const itemPath = buildPath(appname, holonId, lensName, itemId);
1166
- const item = await read(client, itemPath);
1167
-
1168
- if (!item || item.hologram !== true || !item.target) {
1169
- continue; // Not a hologram, skip
1170
- }
1171
-
1172
- // This is a hologram - check if it creates a circular reference
1173
- const sourceHolon = item.target.holonId;
1174
- const sourceDataId = item.target.dataId || itemId;
1175
- const sourceLens = item.target.lensName || lensName;
1176
-
1177
- // Read the source data
1178
- const sourcePath = buildPath(item.target.appname || appname, sourceHolon, sourceLens, sourceDataId);
1179
- const sourceData = await read(client, sourcePath);
1180
-
1181
- if (sourceData && sourceData.hologram === true && sourceData.target) {
1182
- // Source is also a hologram - check if it points back
1183
- if (sourceData.target.holonId === holonId) {
1184
- result.circularFound++;
1185
- const detail = {
1186
- itemId,
1187
- path: itemPath,
1188
- pointsTo: `${sourceHolon}/${sourceLens}/${sourceDataId}`,
1189
- circularWith: `${sourceData.target.holonId}/${sourceData.target.lensName}/${sourceData.target.dataId}`
1190
- };
1191
- result.details.push(detail);
1192
-
1193
- console.warn(` 🔄 Found circular hologram:`);
1194
- console.warn(` ${holonId}/${lensName}/${itemId} → ${sourceHolon}/${sourceLens}/${sourceDataId} → ${holonId}`);
1195
-
1196
- if (!dryRun) {
1197
- // Delete this hologram
1198
- const deleteResult = await deleteHologram(client, appname, holonId, lensName, itemId);
1199
- if (deleteResult.success) {
1200
- result.deleted++;
1201
- console.info(` ✓ Deleted circular hologram`);
1202
- } else {
1203
- result.errors.push({ itemId, error: deleteResult.error });
1204
- console.error(` ❌ Failed to delete: ${deleteResult.error}`);
1205
- }
1206
- }
1207
- }
1208
- }
1209
- }
1210
-
1211
- console.info(`🧹 Cleanup complete for ${holonId}/${lensName}:`);
1212
- console.info(` Scanned: ${result.scanned}`);
1213
- console.info(` Circular found: ${result.circularFound}`);
1214
- console.info(` Deleted: ${result.deleted}`);
1215
- if (result.errors.length > 0) {
1216
- console.warn(` Errors: ${result.errors.length}`);
1217
- }
1218
-
1219
- return result;
1220
- } catch (error) {
1221
- console.error(`❌ Error during cleanup:`, error);
1222
- result.errors.push({ error: error.message });
1223
- return result;
1105
+ // Read the lens index to get all item IDs
1106
+ const lensIndexPath = buildPath(appname, holonId, lensName, '_index');
1107
+ const lensIndex = await read(client, lensIndexPath);
1108
+
1109
+ if (!lensIndex || !Array.isArray(lensIndex.items)) {
1110
+ console.warn(` No index found for ${holonId}/${lensName}, cannot scan for circular references`);
1111
+ console.info(` Tip: Provide specific IDs to check using cleanupCircularHologramsByIds()`);
1112
+ return { scanned: 0, circularFound: 0, deleted: 0, errors: [], details: [] };
1224
1113
  }
1114
+
1115
+ return cleanupCircularHologramsByIds(client, appname, holonId, lensName, lensIndex.items, options);
1225
1116
  }
1226
1117
 
1227
1118
  /**
@@ -10,7 +10,7 @@
10
10
  */
11
11
 
12
12
  import { writeGlobal, readGlobal } from '../storage/global-tables.js';
13
- import { matchScope } from '../crypto/secp256k1.js';
13
+ import { matchScope, issueCapability as cryptoIssueCapability } from '../crypto/secp256k1.js';
14
14
 
15
15
  const FEDERATION_TABLE = 'federations';
16
16
 
@@ -135,6 +135,27 @@ async function getPartnerFromIndex(client, appname, partnerPubKey) {
135
135
  return null;
136
136
  }
137
137
 
138
+ /**
139
+ * Find a partner in the registry using the cached O(1) index.
140
+ * Must be called AFTER getFederationRegistry() has been called (which populates the cache).
141
+ * Falls back to array.find() if cache is not available.
142
+ * @private
143
+ * @param {Object} client - NostrClient instance
144
+ * @param {string} appname - Application namespace
145
+ * @param {Object} registryObj - The already-loaded registry object
146
+ * @param {string} partnerPubKey - Partner's public key
147
+ * @returns {Object|null} Partner object or null
148
+ */
149
+ function findPartner(client, appname, registryObj, partnerPubKey) {
150
+ const cacheKey = getCacheKey(client, appname);
151
+ const cached = registryCache.get(cacheKey);
152
+ if (cached && cached.partnerIndex) {
153
+ return cached.partnerIndex.get(partnerPubKey) || null;
154
+ }
155
+ // Fallback to linear scan if index not available
156
+ return registryObj.federatedWith.find(p => p.pubKey === partnerPubKey) || null;
157
+ }
158
+
138
159
  /**
139
160
  * Save the federation registry
140
161
  * @param {Object} client - NostrClient instance
@@ -367,7 +388,7 @@ export async function getCapabilityForAuthor(client, appname, authorPubKey, scop
367
388
  */
368
389
  export async function storeInboundCapability(client, appname, partnerPubKey, capabilityInfo) {
369
390
  const registry = await getFederationRegistry(client, appname);
370
- const partner = registry.federatedWith.find(p => p.pubKey === partnerPubKey);
391
+ const partner = findPartner(client, appname, registry, partnerPubKey);
371
392
 
372
393
  if (!partner) {
373
394
  // Auto-add partner if not exists
@@ -418,7 +439,7 @@ export async function storeInboundCapability(client, appname, partnerPubKey, cap
418
439
  */
419
440
  export async function storeOutboundCapability(client, appname, partnerPubKey, capabilityInfo) {
420
441
  const registry = await getFederationRegistry(client, appname);
421
- const partner = registry.federatedWith.find(p => p.pubKey === partnerPubKey);
442
+ const partner = findPartner(client, appname, registry, partnerPubKey);
422
443
 
423
444
  if (!partner) {
424
445
  throw new Error(`Partner ${partnerPubKey} not found in federation registry`);
@@ -449,7 +470,7 @@ export async function storeOutboundCapability(client, appname, partnerPubKey, ca
449
470
  */
450
471
  export async function revokeOutboundCapability(client, appname, partnerPubKey, tokenHash) {
451
472
  const registry = await getFederationRegistry(client, appname);
452
- const partner = registry.federatedWith.find(p => p.pubKey === partnerPubKey);
473
+ const partner = findPartner(client, appname, registry, partnerPubKey);
453
474
 
454
475
  if (!partner || !partner.outboundCapabilities) {
455
476
  return false;
@@ -702,7 +723,7 @@ export async function grantWriteAccessToHolon(client, appname, holonId, lensName
702
723
  const registry = await getFederationRegistry(client, appname);
703
724
 
704
725
  // Find or create partner entry
705
- let partner = registry.federatedWith.find(p => p.pubKey === holonId);
726
+ let partner = findPartner(client, appname, registry, holonId);
706
727
 
707
728
  if (!partner) {
708
729
  // Auto-add partner if not exists
@@ -755,7 +776,7 @@ export async function revokeWriteAccess(client, appname, holonId, lensName) {
755
776
  console.warn('[Deprecated] revokeWriteAccess() is deprecated. Use revokeAccess() instead.');
756
777
  const registry = await getFederationRegistry(client, appname);
757
778
 
758
- const partner = registry.federatedWith.find(p => p.pubKey === holonId);
779
+ const partner = findPartner(client, appname, registry, holonId);
759
780
 
760
781
  if (!partner || !partner.writeGrants) {
761
782
  return false; // No grants to revoke
@@ -783,7 +804,7 @@ export async function getWriteGrantsForHolon(client, appname, holonId) {
783
804
  console.warn('[Deprecated] getWriteGrantsForHolon() is deprecated. Use getAccessGrants() instead.');
784
805
  const registry = await getFederationRegistry(client, appname);
785
806
 
786
- const partner = registry.federatedWith.find(p => p.pubKey === holonId);
807
+ const partner = findPartner(client, appname, registry, holonId);
787
808
 
788
809
  if (!partner || !partner.writeGrants) {
789
810
  return [];
@@ -878,7 +899,7 @@ export async function grantAccess(client, appname, partnerPubKey, scope, permiss
878
899
  const { expiresAt = null, capabilityToken = null, direction = 'inbound' } = options;
879
900
 
880
901
  // Find or create partner entry
881
- let partner = registry.federatedWith.find(p => p.pubKey === partnerPubKey);
902
+ let partner = findPartner(client, appname, registry, partnerPubKey);
882
903
 
883
904
  if (!partner) {
884
905
  partner = {
@@ -949,7 +970,7 @@ export async function revokeAccess(client, appname, partnerPubKey, scope, permis
949
970
  const registry = await getFederationRegistry(client, appname);
950
971
  const { direction = 'inbound' } = options;
951
972
 
952
- const partner = registry.federatedWith.find(p => p.pubKey === partnerPubKey);
973
+ const partner = findPartner(client, appname, registry, partnerPubKey);
953
974
 
954
975
  if (!partner || !partner.accessGrants) {
955
976
  return false;
@@ -1005,7 +1026,7 @@ export async function findAccessGrant(client, appname, partnerPubKey, scope, per
1005
1026
  const { direction = 'inbound' } = options;
1006
1027
  const now = Date.now();
1007
1028
 
1008
- const partner = registry.federatedWith.find(p => p.pubKey === partnerPubKey);
1029
+ const partner = findPartner(client, appname, registry, partnerPubKey);
1009
1030
 
1010
1031
  if (!partner) {
1011
1032
  return null;
@@ -1096,7 +1117,7 @@ export async function getAccessGrants(client, appname, partnerPubKey, options =
1096
1117
  const { direction = 'all', includeLegacy = true } = options;
1097
1118
  const now = Date.now();
1098
1119
 
1099
- const partner = registry.federatedWith.find(p => p.pubKey === partnerPubKey);
1120
+ const partner = findPartner(client, appname, registry, partnerPubKey);
1100
1121
 
1101
1122
  if (!partner) {
1102
1123
  return [];
@@ -1332,3 +1353,68 @@ export function convertToLegacyLensConfig(unifiedConfig) {
1332
1353
 
1333
1354
  return legacy;
1334
1355
  }
1356
+
1357
+ // ============================================================================
1358
+ // Capability Issuance Helpers (moved from capabilities.js)
1359
+ // ============================================================================
1360
+
1361
+ /**
1362
+ * Issue capability for a specific lens
1363
+ * @param {Object} client - NostrClient instance
1364
+ * @param {string} holonId - Holon ID
1365
+ * @param {string} lensName - Lens name
1366
+ * @param {string} targetPubKey - Target public key
1367
+ * @param {Object} options - Options
1368
+ * @returns {Promise<Object>} Capability info { token, scope, permissions }
1369
+ */
1370
+ export async function issueCapabilityForLens(client, holonId, lensName, targetPubKey, options = {}) {
1371
+ const {
1372
+ permissions = ['read'],
1373
+ expiresIn = 365 * 24 * 60 * 60 * 1000, // 1 year default
1374
+ } = options;
1375
+
1376
+ const scope = { holonId, lensName, dataId: '*' };
1377
+
1378
+ const token = await cryptoIssueCapability(
1379
+ permissions,
1380
+ scope,
1381
+ targetPubKey,
1382
+ {
1383
+ expiresIn,
1384
+ issuer: client.publicKey,
1385
+ issuerKey: client.privateKey,
1386
+ isSelfCapability: client.publicKey === targetPubKey,
1387
+ }
1388
+ );
1389
+
1390
+ return {
1391
+ token,
1392
+ scope,
1393
+ permissions,
1394
+ expiresAt: Date.now() + expiresIn,
1395
+ };
1396
+ }
1397
+
1398
+ /**
1399
+ * Issue capabilities for multiple lenses
1400
+ * @param {Object} client - NostrClient instance
1401
+ * @param {string} holonId - Holon ID
1402
+ * @param {string[]} lensNames - Array of lens names
1403
+ * @param {string} targetPubKey - Target public key
1404
+ * @param {Object} options - Options
1405
+ * @returns {Promise<Object[]>} Array of capability info objects
1406
+ */
1407
+ export async function issueCapabilitiesForLenses(client, holonId, lensNames, targetPubKey, options = {}) {
1408
+ const capabilities = [];
1409
+
1410
+ for (const lensName of lensNames) {
1411
+ try {
1412
+ const capInfo = await issueCapabilityForLens(client, holonId, lensName, targetPubKey, options);
1413
+ capabilities.push(capInfo);
1414
+ } catch (err) {
1415
+ console.warn(`[Registry] Failed to issue capability for ${lensName}:`, err.message);
1416
+ }
1417
+ }
1418
+
1419
+ return capabilities;
1420
+ }
@@ -8,7 +8,7 @@
8
8
  * @module federation/request-card
9
9
  */
10
10
 
11
- import { issueCapabilitiesForLenses } from './capabilities.js';
11
+ import { issueCapabilitiesForLenses } from './registry.js';
12
12
 
13
13
  /**
14
14
  * @typedef {Object} LensConfig
@@ -17,9 +17,25 @@ import * as federation from '../federation/hologram.js';
17
17
  import * as storage from '../storage/unified-storage.js';
18
18
  import * as nostrAsync from '../storage/nostr-async.js';
19
19
  import * as crypto from '../crypto/secp256k1.js';
20
+ import { hashToken, isPubkey } from '../crypto/secp256k1.js';
20
21
  import { ValidationError, AuthorizationError } from './errors.js';
21
- import { sha256 } from '@noble/hashes/sha256';
22
- import { bytesToHex } from '@noble/hashes/utils';
22
+
23
+ /**
24
+ * Normalize a holon target (string or object) to { holonId, authorPubKey }.
25
+ * If the holonId is a 64-char hex pubkey, uses it as the authorPubKey.
26
+ * @param {string|Object} input - Holon ID string or { holonId, authorPubKey }
27
+ * @param {string} defaultPubKey - Default author public key (typically client.publicKey)
28
+ * @returns {{ holonId: string, authorPubKey: string }}
29
+ */
30
+ function normalizeHolonTarget(input, defaultPubKey) {
31
+ if (typeof input === 'string') {
32
+ return { holonId: input, authorPubKey: isPubkey(input) ? input : defaultPubKey };
33
+ }
34
+ return {
35
+ holonId: input.holonId,
36
+ authorPubKey: input.authorPubKey || (isPubkey(input.holonId) ? input.holonId : defaultPubKey),
37
+ };
38
+ }
23
39
 
24
40
  /**
25
41
  * Mixin that adds federation methods to a HoloSphere class.
@@ -80,17 +96,8 @@ export function withFederationMethods(Base) {
80
96
  } = options;
81
97
 
82
98
  // Normalize source and target to { holonId, authorPubKey } format
83
- // Helper function to detect if a string is a pubkey (64-char hex)
84
- const isPubkey = (str) => typeof str === 'string' && /^[0-9a-f]{64}$/i.test(str);
85
-
86
- // When the holon ID is a pubkey, use it as the authorPubKey for cross-holosphere federation
87
- const normalizedSource = typeof source === 'string'
88
- ? { holonId: source, authorPubKey: isPubkey(source) ? source : this.client.publicKey }
89
- : { holonId: source.holonId, authorPubKey: source.authorPubKey || (isPubkey(source.holonId) ? source.holonId : this.client.publicKey) };
90
-
91
- const normalizedTarget = typeof target === 'string'
92
- ? { holonId: target, authorPubKey: isPubkey(target) ? target : this.client.publicKey }
93
- : { holonId: target.holonId, authorPubKey: target.authorPubKey || (isPubkey(target.holonId) ? target.holonId : this.client.publicKey) };
99
+ const normalizedSource = normalizeHolonTarget(source, this.client.publicKey);
100
+ const normalizedTarget = normalizeHolonTarget(target, this.client.publicKey);
94
101
 
95
102
  // Validation
96
103
  if (normalizedSource.holonId === normalizedTarget.holonId &&
@@ -105,14 +112,20 @@ export function withFederationMethods(Base) {
105
112
  // Determine if this is self-federation or cross-federation
106
113
  const isSelfFederation = normalizedSource.authorPubKey === normalizedTarget.authorPubKey;
107
114
 
108
- // Issue or use provided capability
109
- let capability = providedCapability;
110
- if (!capability) {
111
- const expiresIn = isSelfFederation
112
- ? 365 * 24 * 60 * 60 * 1000 // 1 year for self
113
- : 24 * 60 * 60 * 1000; // 24 hours for cross
115
+ // Issue capabilities per direction.
116
+ // Outbound: capability scoped to source holon (data lives in source, hologram points to source)
117
+ // Inbound: capability scoped to target holon (data lives in target, hologram points to target)
118
+ const expiresIn = isSelfFederation
119
+ ? 365 * 24 * 60 * 60 * 1000 // 1 year for self
120
+ : 24 * 60 * 60 * 1000; // 24 hours for cross
121
+
122
+ const needsOutbound = direction === 'outbound' || direction === 'bidirectional';
123
+ const needsInbound = direction === 'inbound' || direction === 'bidirectional';
114
124
 
115
- capability = await crypto.issueCapability(
125
+ // Outbound capability: scoped to source holon
126
+ let outboundCapability = providedCapability;
127
+ if (!outboundCapability && needsOutbound) {
128
+ outboundCapability = await crypto.issueCapability(
116
129
  permissions,
117
130
  { holonId: normalizedSource.holonId, lensName, dataId: '*' },
118
131
  normalizedTarget.authorPubKey,
@@ -124,10 +137,9 @@ export function withFederationMethods(Base) {
124
137
  }
125
138
  );
126
139
 
127
- // Store capability in registry
128
140
  if (isSelfFederation) {
129
141
  await registry.storeSelfCapability(this.client, this.config.appName, {
130
- token: capability,
142
+ token: outboundCapability,
131
143
  scope: { holonId: normalizedSource.holonId, lensName },
132
144
  permissions,
133
145
  });
@@ -137,7 +149,7 @@ export function withFederationMethods(Base) {
137
149
  this.config.appName,
138
150
  normalizedSource.authorPubKey,
139
151
  {
140
- token: capability,
152
+ token: outboundCapability,
141
153
  scope: { holonId: normalizedSource.holonId, lensName },
142
154
  permissions,
143
155
  }
@@ -145,6 +157,41 @@ export function withFederationMethods(Base) {
145
157
  }
146
158
  }
147
159
 
160
+ // Inbound capability: scoped to target holon
161
+ let inboundCapability = providedCapability;
162
+ if (!inboundCapability && needsInbound) {
163
+ inboundCapability = await crypto.issueCapability(
164
+ permissions,
165
+ { holonId: normalizedTarget.holonId, lensName, dataId: '*' },
166
+ normalizedSource.authorPubKey,
167
+ {
168
+ expiresIn,
169
+ issuer: normalizedTarget.authorPubKey,
170
+ issuerKey: this.client.privateKey,
171
+ isSelfCapability: isSelfFederation,
172
+ }
173
+ );
174
+
175
+ if (isSelfFederation) {
176
+ await registry.storeSelfCapability(this.client, this.config.appName, {
177
+ token: inboundCapability,
178
+ scope: { holonId: normalizedTarget.holonId, lensName },
179
+ permissions,
180
+ });
181
+ } else {
182
+ await registry.storeInboundCapability(
183
+ this.client,
184
+ this.config.appName,
185
+ normalizedTarget.authorPubKey,
186
+ {
187
+ token: inboundCapability,
188
+ scope: { holonId: normalizedTarget.holonId, lensName },
189
+ permissions,
190
+ }
191
+ );
192
+ }
193
+ }
194
+
148
195
  // Setup federation based on direction
149
196
  const results = {
150
197
  source: normalizedSource,
@@ -153,13 +200,13 @@ export function withFederationMethods(Base) {
153
200
  direction,
154
201
  mode,
155
202
  propagate,
156
- capability,
203
+ capability: outboundCapability || inboundCapability,
157
204
  isSelfFederation,
158
205
  propagated: { outbound: 0, inbound: 0 },
159
206
  };
160
207
 
161
208
  // Handle data propagation (skip if propagate: false)
162
- if (propagate && (direction === 'outbound' || direction === 'bidirectional')) {
209
+ if (propagate && needsOutbound) {
163
210
  // Propagate existing data from source to target
164
211
  const sourceData = await this.read(normalizedSource.holonId, lensName, null, {
165
212
  resolveHolograms: false,
@@ -179,7 +226,7 @@ export function withFederationMethods(Base) {
179
226
  mode,
180
227
  {
181
228
  sourceAuthorPubKey: normalizedSource.authorPubKey,
182
- capability,
229
+ capability: outboundCapability,
183
230
  }
184
231
  );
185
232
  results.propagated.outbound++;
@@ -187,8 +234,8 @@ export function withFederationMethods(Base) {
187
234
  }
188
235
  }
189
236
 
190
- if (propagate && (direction === 'inbound' || direction === 'bidirectional')) {
191
- // Propagate from target to source (need capability from target author)
237
+ if (propagate && needsInbound) {
238
+ // Propagate from target to source
192
239
  const targetData = await this.read(normalizedTarget.holonId, lensName, null, {
193
240
  resolveHolograms: false,
194
241
  });
@@ -207,7 +254,7 @@ export function withFederationMethods(Base) {
207
254
  mode,
208
255
  {
209
256
  sourceAuthorPubKey: normalizedTarget.authorPubKey,
210
- capability,
257
+ capability: inboundCapability,
211
258
  }
212
259
  );
213
260
  results.propagated.inbound++;
@@ -239,13 +286,8 @@ export function withFederationMethods(Base) {
239
286
  * @returns {Promise<boolean>} True if unfederation succeeded
240
287
  */
241
288
  async unfederate(source, target, lensName) {
242
- const normalizedSource = typeof source === 'string'
243
- ? { holonId: source, authorPubKey: this.client.publicKey }
244
- : { holonId: source.holonId, authorPubKey: source.authorPubKey || this.client.publicKey };
245
-
246
- const normalizedTarget = typeof target === 'string'
247
- ? { holonId: target, authorPubKey: this.client.publicKey }
248
- : { holonId: target.holonId, authorPubKey: target.authorPubKey || this.client.publicKey };
289
+ const normalizedSource = normalizeHolonTarget(source, this.client.publicKey);
290
+ const normalizedTarget = normalizeHolonTarget(target, this.client.publicKey);
249
291
 
250
292
  // Remove federation config
251
293
  const configPath = storage.buildPath(this.config.appName, normalizedSource.holonId, lensName, '_federation');
@@ -481,11 +523,10 @@ export function withFederationMethods(Base) {
481
523
  * Hash a capability token for storage.
482
524
  * @private
483
525
  * @param {string} token - Capability token to hash
484
- * @returns {Promise<string>} SHA256 hash of token
526
+ * @returns {string} SHA256 hash of token
485
527
  */
486
- async _hashToken(token) {
487
- const encoder = new TextEncoder();
488
- return bytesToHex(sha256(encoder.encode(token)));
528
+ _hashToken(token) {
529
+ return hashToken(token);
489
530
  }
490
531
 
491
532
  // === Nostr Discovery Protocol ===