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 +1 -1
- package/src/crypto/secp256k1.js +130 -139
- package/src/federation/capabilities.js +22 -138
- package/src/federation/hologram.js +60 -169
- package/src/federation/registry.js +97 -11
- package/src/federation/request-card.js +1 -1
- package/src/lib/federation-methods.js +81 -40
package/package.json
CHANGED
package/src/crypto/secp256k1.js
CHANGED
|
@@ -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
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
260
|
-
if (typeof
|
|
261
|
-
|
|
262
|
-
if (
|
|
263
|
-
|
|
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
|
-
|
|
294
|
-
|
|
295
|
-
tokenObj =
|
|
343
|
+
tokenObj = parsed.tokenObj;
|
|
344
|
+
} else if (normalized && typeof normalized === 'object') {
|
|
345
|
+
tokenObj = normalized;
|
|
296
346
|
} else {
|
|
297
|
-
console.log('[verifyCapability] ❌ Invalid token:', typeof
|
|
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
|
-
|
|
355
|
-
if (
|
|
356
|
-
|
|
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
|
-
|
|
371
|
-
|
|
372
|
-
|
|
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 -
|
|
422
|
-
|
|
423
|
-
|
|
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
|
-
|
|
443
|
-
|
|
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
|
-
|
|
454
|
-
|
|
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
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
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
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
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
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
}
|
|
18
|
+
export {
|
|
19
|
+
issueCapabilityForLens,
|
|
20
|
+
issueCapabilitiesForLenses,
|
|
21
|
+
getCapabilityForAuthor as getCapabilityForPartner,
|
|
22
|
+
storeInboundCapability,
|
|
23
|
+
storeSelfCapability,
|
|
24
|
+
} from './registry.js';
|
|
98
25
|
|
|
99
|
-
|
|
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
|
|
42
|
+
return storeSelfCapability(client, appname, capabilityInfo);
|
|
116
43
|
} else {
|
|
117
|
-
return
|
|
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
|
|
117
|
-
|
|
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
|
|
293
|
-
if (capability
|
|
294
|
-
capability = capability
|
|
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
|
-
|
|
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
|
-
*
|
|
742
|
-
*
|
|
743
|
-
*
|
|
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}
|
|
747
|
-
* @param {string}
|
|
743
|
+
* @param {string} holon - Starting holon ID
|
|
744
|
+
* @param {string} lens - Lens name
|
|
748
745
|
* @param {string} dataId - Data ID
|
|
749
|
-
* @param {
|
|
750
|
-
* @returns {Promise<
|
|
746
|
+
* @param {number} [maxDepth=10] - Maximum chain depth
|
|
747
|
+
* @returns {Promise<{ sourcePath: string, sourceData: Object }|null>} Real source or null
|
|
751
748
|
*/
|
|
752
|
-
|
|
749
|
+
async function followToRealSource(client, appname, holon, lens, dataId, maxDepth = 10) {
|
|
753
750
|
let currentAppname = appname;
|
|
754
|
-
let currentHolon =
|
|
755
|
-
let currentLens =
|
|
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
|
|
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
|
|
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
|
-
|
|
831
|
-
|
|
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
|
-
|
|
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
|
-
|
|
891
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1136
|
-
const
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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
|
+
}
|
|
@@ -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
|
-
|
|
22
|
-
|
|
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
|
-
|
|
84
|
-
const
|
|
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
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
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
|
-
|
|
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:
|
|
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:
|
|
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 &&
|
|
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 &&
|
|
191
|
-
// Propagate from target to source
|
|
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 =
|
|
243
|
-
|
|
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 {
|
|
526
|
+
* @returns {string} SHA256 hash of token
|
|
485
527
|
*/
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
return bytesToHex(sha256(encoder.encode(token)));
|
|
528
|
+
_hashToken(token) {
|
|
529
|
+
return hashToken(token);
|
|
489
530
|
}
|
|
490
531
|
|
|
491
532
|
// === Nostr Discovery Protocol ===
|