@xtr-dev/rondevu-client 0.21.6 → 0.21.9
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/dist/connections/config.d.ts +1 -1
- package/dist/connections/config.js +1 -1
- package/dist/core/index.d.ts +1 -0
- package/dist/core/offer-pool.d.ts +13 -0
- package/dist/core/offer-pool.js +57 -5
- package/dist/core/rondevu-types.d.ts +2 -0
- package/dist/core/rondevu.d.ts +6 -0
- package/dist/core/rondevu.js +18 -4
- package/dist/meta.d.ts +221 -0
- package/dist/meta.js +213 -0
- package/package.json +1 -1
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* Advanced options use sensible defaults.
|
|
7
7
|
*/
|
|
8
8
|
export interface ConnectionOptions {
|
|
9
|
-
/** Maximum time to wait for connection (ms). Default:
|
|
9
|
+
/** Maximum time to wait for connection (ms). Default: 10000 */
|
|
10
10
|
timeout?: number;
|
|
11
11
|
/** Enable automatic reconnection on failures. Default: true */
|
|
12
12
|
reconnect?: boolean;
|
package/dist/core/index.d.ts
CHANGED
|
@@ -20,3 +20,4 @@ export { ICE_SERVER_PRESETS } from './ice-config.js';
|
|
|
20
20
|
export type { RondevuOptions, OfferOptions, OfferHandle, DiscoverOptions, DiscoverResult, } from './rondevu.js';
|
|
21
21
|
export type { PeerState, PeerOptions } from './peer.js';
|
|
22
22
|
export type { IceServerPreset } from './ice-config.js';
|
|
23
|
+
export type { KeyPair, CryptoAdapter } from '../crypto/adapter.js';
|
|
@@ -20,6 +20,12 @@ export interface OfferPoolOptions {
|
|
|
20
20
|
webrtcAdapter: WebRTCAdapter;
|
|
21
21
|
connectionConfig?: Partial<ConnectionConfig>;
|
|
22
22
|
debugEnabled?: boolean;
|
|
23
|
+
/**
|
|
24
|
+
* Delay in milliseconds between creating each offer during pool filling.
|
|
25
|
+
* Helps avoid rate limiting when creating multiple offers.
|
|
26
|
+
* Default: 100ms
|
|
27
|
+
*/
|
|
28
|
+
offerCreationThrottleMs?: number;
|
|
23
29
|
}
|
|
24
30
|
interface OfferPoolEvents {
|
|
25
31
|
'connection:opened': (offerId: string, connection: OffererConnection, matchedTags?: string[]) => void;
|
|
@@ -44,7 +50,9 @@ export declare class OfferPool extends EventEmitter<OfferPoolEvents> {
|
|
|
44
50
|
private readonly webrtcAdapter;
|
|
45
51
|
private readonly connectionConfig?;
|
|
46
52
|
private readonly debugEnabled;
|
|
53
|
+
private readonly offerCreationThrottleMs;
|
|
47
54
|
private readonly activeConnections;
|
|
55
|
+
private readonly rotatedOfferIds;
|
|
48
56
|
private readonly matchedTagsByOffer;
|
|
49
57
|
private readonly fillLock;
|
|
50
58
|
private running;
|
|
@@ -116,6 +124,11 @@ export declare class OfferPool extends EventEmitter<OfferPoolEvents> {
|
|
|
116
124
|
* Called by Rondevu when a poll:ice event is received
|
|
117
125
|
*/
|
|
118
126
|
handlePollIce(data: PollIceEvent): void;
|
|
127
|
+
/**
|
|
128
|
+
* Resolve an offerId through the rotation chain to find the current offerId
|
|
129
|
+
* Returns the final offerId or undefined if not found
|
|
130
|
+
*/
|
|
131
|
+
private resolveRotatedOfferId;
|
|
119
132
|
/**
|
|
120
133
|
* Debug logging (only if debug enabled)
|
|
121
134
|
*/
|
package/dist/core/offer-pool.js
CHANGED
|
@@ -11,6 +11,7 @@ export class OfferPool extends EventEmitter {
|
|
|
11
11
|
super();
|
|
12
12
|
// State
|
|
13
13
|
this.activeConnections = new Map();
|
|
14
|
+
this.rotatedOfferIds = new Map(); // Maps old offerId -> new offerId for late-arriving answers
|
|
14
15
|
this.matchedTagsByOffer = new Map(); // Track matchedTags from answers
|
|
15
16
|
this.fillLock = new AsyncLock();
|
|
16
17
|
this.running = false;
|
|
@@ -25,6 +26,7 @@ export class OfferPool extends EventEmitter {
|
|
|
25
26
|
this.iceTransportPolicy = options.iceTransportPolicy;
|
|
26
27
|
this.connectionConfig = options.connectionConfig;
|
|
27
28
|
this.debugEnabled = options.debugEnabled || false;
|
|
29
|
+
this.offerCreationThrottleMs = options.offerCreationThrottleMs ?? 100;
|
|
28
30
|
}
|
|
29
31
|
/**
|
|
30
32
|
* Start filling offers
|
|
@@ -56,6 +58,8 @@ export class OfferPool extends EventEmitter {
|
|
|
56
58
|
connection.close();
|
|
57
59
|
}
|
|
58
60
|
this.activeConnections.clear();
|
|
61
|
+
this.rotatedOfferIds.clear();
|
|
62
|
+
this.matchedTagsByOffer.clear();
|
|
59
63
|
}
|
|
60
64
|
/**
|
|
61
65
|
* Get count of active offers
|
|
@@ -116,6 +120,10 @@ export class OfferPool extends EventEmitter {
|
|
|
116
120
|
for (let i = 0; i < needed; i++) {
|
|
117
121
|
try {
|
|
118
122
|
await this.createOffer();
|
|
123
|
+
// Throttle between offer creations to avoid rate limiting
|
|
124
|
+
if (i < needed - 1 && this.offerCreationThrottleMs > 0) {
|
|
125
|
+
await new Promise(resolve => setTimeout(resolve, this.offerCreationThrottleMs));
|
|
126
|
+
}
|
|
119
127
|
}
|
|
120
128
|
catch (err) {
|
|
121
129
|
console.error('[OfferPool] Failed to create offer:', err);
|
|
@@ -233,6 +241,8 @@ export class OfferPool extends EventEmitter {
|
|
|
233
241
|
// Update map: remove old offerId, add new offerId with same connection
|
|
234
242
|
this.activeConnections.delete(currentOfferId);
|
|
235
243
|
this.activeConnections.set(newOfferId, connection);
|
|
244
|
+
// Track rotation so late-arriving answers for old offerId can be forwarded
|
|
245
|
+
this.rotatedOfferIds.set(currentOfferId, newOfferId);
|
|
236
246
|
this.emit('connection:rotated', currentOfferId, newOfferId, connection);
|
|
237
247
|
this.debug(`Connection rotated: ${currentOfferId} → ${newOfferId}`);
|
|
238
248
|
}
|
|
@@ -263,12 +273,25 @@ export class OfferPool extends EventEmitter {
|
|
|
263
273
|
async handlePollAnswer(data) {
|
|
264
274
|
if (!this.running)
|
|
265
275
|
return;
|
|
266
|
-
|
|
276
|
+
// Find connection - check direct mapping first, then rotated offers
|
|
277
|
+
let connection = this.activeConnections.get(data.offerId);
|
|
278
|
+
let effectiveOfferId = data.offerId;
|
|
279
|
+
if (!connection) {
|
|
280
|
+
// Check if this is a late-arriving answer for a rotated offer
|
|
281
|
+
const newOfferId = this.resolveRotatedOfferId(data.offerId);
|
|
282
|
+
if (newOfferId && newOfferId !== data.offerId) {
|
|
283
|
+
connection = this.activeConnections.get(newOfferId);
|
|
284
|
+
effectiveOfferId = newOfferId;
|
|
285
|
+
if (connection) {
|
|
286
|
+
this.debug(`Late answer for rotated offer ${data.offerId} → forwarding to ${newOfferId}`);
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
}
|
|
267
290
|
if (connection) {
|
|
268
|
-
this.debug(`Processing answer for offer ${
|
|
291
|
+
this.debug(`Processing answer for offer ${effectiveOfferId}`);
|
|
269
292
|
// Store matchedTags for when connection opens
|
|
270
293
|
if (data.matchedTags) {
|
|
271
|
-
this.matchedTagsByOffer.set(
|
|
294
|
+
this.matchedTagsByOffer.set(effectiveOfferId, data.matchedTags);
|
|
272
295
|
}
|
|
273
296
|
try {
|
|
274
297
|
await connection.processAnswer(data.sdp, data.answererPublicKey);
|
|
@@ -276,7 +299,7 @@ export class OfferPool extends EventEmitter {
|
|
|
276
299
|
this.fillOffers();
|
|
277
300
|
}
|
|
278
301
|
catch (err) {
|
|
279
|
-
this.debug(`Failed to process answer for offer ${
|
|
302
|
+
this.debug(`Failed to process answer for offer ${effectiveOfferId}:`, err);
|
|
280
303
|
}
|
|
281
304
|
}
|
|
282
305
|
// Silently ignore answers for offers we don't have - they may be for other connections
|
|
@@ -288,13 +311,42 @@ export class OfferPool extends EventEmitter {
|
|
|
288
311
|
handlePollIce(data) {
|
|
289
312
|
if (!this.running)
|
|
290
313
|
return;
|
|
291
|
-
|
|
314
|
+
// Find connection - check direct mapping first, then rotated offers
|
|
315
|
+
let connection = this.activeConnections.get(data.offerId);
|
|
316
|
+
if (!connection) {
|
|
317
|
+
// Check if this is late-arriving ICE for a rotated offer
|
|
318
|
+
const newOfferId = this.resolveRotatedOfferId(data.offerId);
|
|
319
|
+
if (newOfferId && newOfferId !== data.offerId) {
|
|
320
|
+
connection = this.activeConnections.get(newOfferId);
|
|
321
|
+
if (connection) {
|
|
322
|
+
this.debug(`Late ICE for rotated offer ${data.offerId} → forwarding to ${newOfferId}`);
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
}
|
|
292
326
|
if (connection) {
|
|
293
327
|
this.debug(`Processing ${data.candidates.length} ICE candidates for offer ${data.offerId}`);
|
|
294
328
|
connection.handleRemoteIceCandidates(data.candidates);
|
|
295
329
|
}
|
|
296
330
|
// Silently ignore ICE candidates for offers we don't have
|
|
297
331
|
}
|
|
332
|
+
/**
|
|
333
|
+
* Resolve an offerId through the rotation chain to find the current offerId
|
|
334
|
+
* Returns the final offerId or undefined if not found
|
|
335
|
+
*/
|
|
336
|
+
resolveRotatedOfferId(offerId) {
|
|
337
|
+
let currentId = offerId;
|
|
338
|
+
const visited = new Set();
|
|
339
|
+
while (this.rotatedOfferIds.has(currentId)) {
|
|
340
|
+
if (visited.has(currentId)) {
|
|
341
|
+
// Circular reference - shouldn't happen but protect against it
|
|
342
|
+
this.debug(`Circular rotation detected for ${offerId}`);
|
|
343
|
+
return undefined;
|
|
344
|
+
}
|
|
345
|
+
visited.add(currentId);
|
|
346
|
+
currentId = this.rotatedOfferIds.get(currentId);
|
|
347
|
+
}
|
|
348
|
+
return currentId !== offerId ? currentId : undefined;
|
|
349
|
+
}
|
|
298
350
|
/**
|
|
299
351
|
* Debug logging (only if debug enabled)
|
|
300
352
|
*/
|
|
@@ -59,6 +59,8 @@ export interface OfferOptions {
|
|
|
59
59
|
connectionConfig?: Partial<ConnectionConfig>;
|
|
60
60
|
/** Auto-start filling offers (default: true). Set to false to manually call startFilling() */
|
|
61
61
|
autoStart?: boolean;
|
|
62
|
+
/** Delay in ms between creating each offer during pool filling (default: 100). Helps avoid rate limiting. */
|
|
63
|
+
offerCreationThrottleMs?: number;
|
|
62
64
|
}
|
|
63
65
|
/**
|
|
64
66
|
* Handle returned by rondevu.offer() for controlling the offer lifecycle
|
package/dist/core/rondevu.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { KeyPair, IceCandidate } from '../api/client.js';
|
|
2
|
+
import { CryptoAdapter } from '../crypto/adapter.js';
|
|
2
3
|
import { WebRTCAdapter } from '../webrtc/adapter.js';
|
|
3
4
|
import { EventEmitter } from 'eventemitter3';
|
|
4
5
|
import { OffererConnection } from '../connections/offerer.js';
|
|
@@ -111,6 +112,11 @@ export declare class Rondevu extends EventEmitter {
|
|
|
111
112
|
* Used internally by offer pool and connections
|
|
112
113
|
*/
|
|
113
114
|
getWebRTCAdapter(): WebRTCAdapter;
|
|
115
|
+
/**
|
|
116
|
+
* Get the crypto adapter for signing/verification operations
|
|
117
|
+
* Used for meta.json signing and other crypto operations
|
|
118
|
+
*/
|
|
119
|
+
getCryptoAdapter(): CryptoAdapter;
|
|
114
120
|
/**
|
|
115
121
|
* Default offer factory - creates a simple data channel connection
|
|
116
122
|
* The RTCPeerConnection is created by Rondevu and passed in
|
package/dist/core/rondevu.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { RondevuAPI } from '../api/client.js';
|
|
2
|
+
import { WebCryptoAdapter } from '../crypto/web.js';
|
|
2
3
|
import { BrowserWebRTCAdapter } from '../webrtc/browser.js';
|
|
3
4
|
import { EventEmitter } from 'eventemitter3';
|
|
4
5
|
import { OfferPool } from './offer-pool.js';
|
|
@@ -122,12 +123,14 @@ export class Rondevu extends EventEmitter {
|
|
|
122
123
|
iceTransportPolicy: iceConfig.iceTransportPolicy || 'all',
|
|
123
124
|
});
|
|
124
125
|
}
|
|
126
|
+
// Ensure crypto adapter is available (default to WebCryptoAdapter for browser)
|
|
127
|
+
const cryptoAdapter = options.cryptoAdapter || new WebCryptoAdapter();
|
|
125
128
|
// Generate keypair if not provided (purely client-side, no server registration)
|
|
126
129
|
let keyPair = options.keyPair;
|
|
127
130
|
if (!keyPair) {
|
|
128
131
|
if (options.debug)
|
|
129
132
|
console.log('[Rondevu] Generating new keypair...');
|
|
130
|
-
keyPair = await
|
|
133
|
+
keyPair = await cryptoAdapter.generateKeyPair();
|
|
131
134
|
if (options.debug)
|
|
132
135
|
console.log('[Rondevu] Generated keypair, public key:', keyPair.publicKey);
|
|
133
136
|
}
|
|
@@ -136,10 +139,10 @@ export class Rondevu extends EventEmitter {
|
|
|
136
139
|
console.log('[Rondevu] Using existing keypair, public key:', keyPair.publicKey);
|
|
137
140
|
}
|
|
138
141
|
// Create API instance
|
|
139
|
-
const api = new RondevuAPI(apiUrl, keyPair,
|
|
142
|
+
const api = new RondevuAPI(apiUrl, keyPair, cryptoAdapter);
|
|
140
143
|
if (options.debug)
|
|
141
144
|
console.log('[Rondevu] Created API instance');
|
|
142
|
-
return new Rondevu(apiUrl, keyPair, api, iceConfig.iceServers || [], iceConfig.iceTransportPolicy, webrtcAdapter,
|
|
145
|
+
return new Rondevu(apiUrl, keyPair, api, iceConfig.iceServers || [], iceConfig.iceTransportPolicy, webrtcAdapter, cryptoAdapter, options.debug || false);
|
|
143
146
|
}
|
|
144
147
|
// ============================================
|
|
145
148
|
// Identity Access
|
|
@@ -170,6 +173,16 @@ export class Rondevu extends EventEmitter {
|
|
|
170
173
|
getWebRTCAdapter() {
|
|
171
174
|
return this.webrtcAdapter;
|
|
172
175
|
}
|
|
176
|
+
/**
|
|
177
|
+
* Get the crypto adapter for signing/verification operations
|
|
178
|
+
* Used for meta.json signing and other crypto operations
|
|
179
|
+
*/
|
|
180
|
+
getCryptoAdapter() {
|
|
181
|
+
if (!this.cryptoAdapter) {
|
|
182
|
+
throw new Error('Crypto adapter not available');
|
|
183
|
+
}
|
|
184
|
+
return this.cryptoAdapter;
|
|
185
|
+
}
|
|
173
186
|
// ============================================
|
|
174
187
|
// Service Publishing
|
|
175
188
|
// ============================================
|
|
@@ -202,7 +215,7 @@ export class Rondevu extends EventEmitter {
|
|
|
202
215
|
* ```
|
|
203
216
|
*/
|
|
204
217
|
async offer(options) {
|
|
205
|
-
const { tags, maxOffers, offerFactory, ttl, connectionConfig, autoStart = true } = options;
|
|
218
|
+
const { tags, maxOffers, offerFactory, ttl, connectionConfig, autoStart = true, offerCreationThrottleMs, } = options;
|
|
206
219
|
this.currentTags = tags;
|
|
207
220
|
this.connectionConfig = connectionConfig;
|
|
208
221
|
this.debug(`Creating offers with tags: ${tags.join(', ')} with maxOffers: ${maxOffers}`);
|
|
@@ -219,6 +232,7 @@ export class Rondevu extends EventEmitter {
|
|
|
219
232
|
webrtcAdapter: this.webrtcAdapter,
|
|
220
233
|
connectionConfig,
|
|
221
234
|
debugEnabled: this.debugEnabled,
|
|
235
|
+
offerCreationThrottleMs,
|
|
222
236
|
});
|
|
223
237
|
// Forward events from OfferPool
|
|
224
238
|
this.offerPool.on('connection:opened', (offerId, connection, matchedTags) => {
|
package/dist/meta.d.ts
ADDED
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* meta.json format for authenticated torrent metadata
|
|
3
|
+
*
|
|
4
|
+
* Provides cryptographic proof of authorship using Ed25519 signatures.
|
|
5
|
+
* Supports extensible metadata for audio, video, image, and document files.
|
|
6
|
+
*/
|
|
7
|
+
import { CryptoAdapter, KeyPair } from './crypto/adapter.js';
|
|
8
|
+
/**
|
|
9
|
+
* Root meta.json structure
|
|
10
|
+
*/
|
|
11
|
+
export interface TorrentMeta {
|
|
12
|
+
version: string;
|
|
13
|
+
infohash?: string;
|
|
14
|
+
created: number;
|
|
15
|
+
title: string;
|
|
16
|
+
description?: string;
|
|
17
|
+
files: MetaFile[];
|
|
18
|
+
authors: MetaAuthor[];
|
|
19
|
+
extensions?: MetaExtensions;
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* File entry with optional per-file metadata
|
|
23
|
+
*/
|
|
24
|
+
export interface MetaFile {
|
|
25
|
+
path: string;
|
|
26
|
+
size: number;
|
|
27
|
+
type?: string;
|
|
28
|
+
sha256?: string;
|
|
29
|
+
role?: FileRole;
|
|
30
|
+
meta?: FileMetaExtensions;
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Special file roles for cover art, thumbnails, etc.
|
|
34
|
+
*/
|
|
35
|
+
export type FileRole = 'cover' | 'thumbnail' | 'preview' | 'lyrics' | 'subtitles';
|
|
36
|
+
/**
|
|
37
|
+
* Author entry with signature
|
|
38
|
+
*/
|
|
39
|
+
export interface MetaAuthor {
|
|
40
|
+
publicKey: string;
|
|
41
|
+
role?: AuthorRole;
|
|
42
|
+
signedAt: number;
|
|
43
|
+
signature: string;
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Author roles
|
|
47
|
+
*/
|
|
48
|
+
export type AuthorRole = 'creator' | 'co-author' | 'endorser';
|
|
49
|
+
/**
|
|
50
|
+
* Torrent-level metadata extensions
|
|
51
|
+
*/
|
|
52
|
+
export interface MetaExtensions {
|
|
53
|
+
audio?: AudioMeta;
|
|
54
|
+
video?: VideoMeta;
|
|
55
|
+
image?: ImageMeta;
|
|
56
|
+
document?: DocumentMeta;
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Per-file metadata extensions
|
|
60
|
+
*/
|
|
61
|
+
export interface FileMetaExtensions {
|
|
62
|
+
audio?: AudioFileMeta;
|
|
63
|
+
video?: VideoMeta;
|
|
64
|
+
image?: ImageMeta;
|
|
65
|
+
document?: DocumentMeta;
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Audio metadata (torrent-level, e.g., album info)
|
|
69
|
+
*/
|
|
70
|
+
export interface AudioMeta {
|
|
71
|
+
artist?: string;
|
|
72
|
+
album?: string;
|
|
73
|
+
genre?: string;
|
|
74
|
+
year?: number;
|
|
75
|
+
totalTracks?: number;
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* Audio metadata (file-level, e.g., track info)
|
|
79
|
+
*/
|
|
80
|
+
export interface AudioFileMeta {
|
|
81
|
+
title?: string;
|
|
82
|
+
track?: number;
|
|
83
|
+
duration?: number;
|
|
84
|
+
artist?: string;
|
|
85
|
+
bpm?: number;
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* Video metadata
|
|
89
|
+
*/
|
|
90
|
+
export interface VideoMeta {
|
|
91
|
+
duration?: number;
|
|
92
|
+
width?: number;
|
|
93
|
+
height?: number;
|
|
94
|
+
codec?: string;
|
|
95
|
+
framerate?: number;
|
|
96
|
+
bitrate?: number;
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* Image metadata
|
|
100
|
+
*/
|
|
101
|
+
export interface ImageMeta {
|
|
102
|
+
width?: number;
|
|
103
|
+
height?: number;
|
|
104
|
+
format?: string;
|
|
105
|
+
camera?: string;
|
|
106
|
+
takenAt?: number;
|
|
107
|
+
gps?: {
|
|
108
|
+
lat: number;
|
|
109
|
+
lon: number;
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
/**
|
|
113
|
+
* Document metadata
|
|
114
|
+
*/
|
|
115
|
+
export interface DocumentMeta {
|
|
116
|
+
pages?: number;
|
|
117
|
+
author?: string;
|
|
118
|
+
language?: string;
|
|
119
|
+
format?: string;
|
|
120
|
+
}
|
|
121
|
+
/**
|
|
122
|
+
* The canonical payload that gets signed
|
|
123
|
+
* Includes author public keys to prevent fake co-author additions
|
|
124
|
+
* Note: infohash is optional because we may not know it before signing
|
|
125
|
+
* (torrent infohash includes meta.json, creating a chicken-egg problem)
|
|
126
|
+
* Content authenticity is ensured by sha256 hashes in the files array
|
|
127
|
+
*/
|
|
128
|
+
export interface SigningPayload {
|
|
129
|
+
created: number;
|
|
130
|
+
title: string;
|
|
131
|
+
files: MetaFile[];
|
|
132
|
+
authors: string[];
|
|
133
|
+
signedAt: number;
|
|
134
|
+
}
|
|
135
|
+
/**
|
|
136
|
+
* Canonicalize an object to JSON with sorted keys (deterministic)
|
|
137
|
+
* This ensures the same object always produces the same string for signing
|
|
138
|
+
*/
|
|
139
|
+
export declare function canonicalize(obj: unknown): string;
|
|
140
|
+
/**
|
|
141
|
+
* Build the signing payload from meta.json data
|
|
142
|
+
*/
|
|
143
|
+
export declare function buildSigningPayload(meta: Pick<TorrentMeta, 'created' | 'title' | 'files'>, authorPublicKeys: string[], signedAt: number): SigningPayload;
|
|
144
|
+
/**
|
|
145
|
+
* Sign a torrent meta.json
|
|
146
|
+
*
|
|
147
|
+
* @param crypto - Crypto adapter for signing
|
|
148
|
+
* @param keyPair - The signer's key pair
|
|
149
|
+
* @param meta - The meta.json data (without this author's signature)
|
|
150
|
+
* @param authorPublicKeys - All author public keys (including this signer)
|
|
151
|
+
* @param role - This author's role
|
|
152
|
+
* @returns MetaAuthor entry with signature
|
|
153
|
+
*/
|
|
154
|
+
export declare function signMeta(crypto: CryptoAdapter, keyPair: KeyPair, meta: Pick<TorrentMeta, 'created' | 'title' | 'files'>, authorPublicKeys: string[], role?: AuthorRole): Promise<MetaAuthor>;
|
|
155
|
+
/**
|
|
156
|
+
* Verify a single author's signature
|
|
157
|
+
*
|
|
158
|
+
* @param crypto - Crypto adapter for verification
|
|
159
|
+
* @param meta - The meta.json data
|
|
160
|
+
* @param author - The author entry to verify
|
|
161
|
+
* @returns True if signature is valid
|
|
162
|
+
*/
|
|
163
|
+
export declare function verifyAuthorSignature(crypto: CryptoAdapter, meta: Pick<TorrentMeta, 'created' | 'title' | 'files' | 'authors'>, author: MetaAuthor): Promise<boolean>;
|
|
164
|
+
/**
|
|
165
|
+
* Verify all author signatures in a meta.json
|
|
166
|
+
*
|
|
167
|
+
* @param crypto - Crypto adapter for verification
|
|
168
|
+
* @param meta - The complete meta.json
|
|
169
|
+
* @returns Object with verification results per author
|
|
170
|
+
*/
|
|
171
|
+
export declare function verifyMeta(crypto: CryptoAdapter, meta: TorrentMeta): Promise<{
|
|
172
|
+
valid: boolean;
|
|
173
|
+
results: Map<string, boolean>;
|
|
174
|
+
}>;
|
|
175
|
+
/**
|
|
176
|
+
* Create a complete meta.json with signatures from all authors
|
|
177
|
+
*
|
|
178
|
+
* @param crypto - Crypto adapter for signing
|
|
179
|
+
* @param options - Meta creation options
|
|
180
|
+
* @returns Complete TorrentMeta with all signatures
|
|
181
|
+
*/
|
|
182
|
+
export declare function createMeta(crypto: CryptoAdapter, options: {
|
|
183
|
+
title: string;
|
|
184
|
+
description?: string;
|
|
185
|
+
files: MetaFile[];
|
|
186
|
+
authors: Array<{
|
|
187
|
+
keyPair: KeyPair;
|
|
188
|
+
role?: AuthorRole;
|
|
189
|
+
}>;
|
|
190
|
+
extensions?: MetaExtensions;
|
|
191
|
+
infohash?: string;
|
|
192
|
+
}): Promise<TorrentMeta>;
|
|
193
|
+
/**
|
|
194
|
+
* Parse and validate a meta.json string
|
|
195
|
+
* Does NOT verify signatures - use verifyMeta for that
|
|
196
|
+
*
|
|
197
|
+
* @param json - JSON string to parse
|
|
198
|
+
* @returns Parsed TorrentMeta or null if invalid
|
|
199
|
+
*/
|
|
200
|
+
export declare function parseMeta(json: string): TorrentMeta | null;
|
|
201
|
+
/**
|
|
202
|
+
* Find a file with a specific role (e.g., cover art)
|
|
203
|
+
*/
|
|
204
|
+
export declare function findFileByRole(meta: TorrentMeta, role: FileRole): MetaFile | undefined;
|
|
205
|
+
/**
|
|
206
|
+
* Get the cover art file if present
|
|
207
|
+
*/
|
|
208
|
+
export declare function getCoverFile(meta: TorrentMeta): MetaFile | undefined;
|
|
209
|
+
/**
|
|
210
|
+
* Compute SHA256 hash of a file (browser-compatible)
|
|
211
|
+
*/
|
|
212
|
+
export declare function computeFileSha256(file: File | Blob | ArrayBuffer): Promise<string>;
|
|
213
|
+
/**
|
|
214
|
+
* Build MetaFile entries from File objects with SHA256 hashes
|
|
215
|
+
*/
|
|
216
|
+
export declare function buildMetaFiles(files: File[]): Promise<MetaFile[]>;
|
|
217
|
+
/**
|
|
218
|
+
* Set the infohash on a TorrentMeta after torrent creation
|
|
219
|
+
* Note: This does NOT re-sign - infohash is informational only
|
|
220
|
+
*/
|
|
221
|
+
export declare function setInfohash(meta: TorrentMeta, infohash: string): TorrentMeta;
|
package/dist/meta.js
ADDED
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* meta.json format for authenticated torrent metadata
|
|
3
|
+
*
|
|
4
|
+
* Provides cryptographic proof of authorship using Ed25519 signatures.
|
|
5
|
+
* Supports extensible metadata for audio, video, image, and document files.
|
|
6
|
+
*/
|
|
7
|
+
// =============================================================================
|
|
8
|
+
// Utility Functions
|
|
9
|
+
// =============================================================================
|
|
10
|
+
/**
|
|
11
|
+
* Canonicalize an object to JSON with sorted keys (deterministic)
|
|
12
|
+
* This ensures the same object always produces the same string for signing
|
|
13
|
+
*/
|
|
14
|
+
export function canonicalize(obj) {
|
|
15
|
+
return JSON.stringify(obj, (_, value) => {
|
|
16
|
+
if (value && typeof value === 'object' && !Array.isArray(value)) {
|
|
17
|
+
return Object.keys(value)
|
|
18
|
+
.sort()
|
|
19
|
+
.reduce((sorted, key) => {
|
|
20
|
+
sorted[key] = value[key];
|
|
21
|
+
return sorted;
|
|
22
|
+
}, {});
|
|
23
|
+
}
|
|
24
|
+
return value;
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Build the signing payload from meta.json data
|
|
29
|
+
*/
|
|
30
|
+
export function buildSigningPayload(meta, authorPublicKeys, signedAt) {
|
|
31
|
+
return {
|
|
32
|
+
created: meta.created,
|
|
33
|
+
title: meta.title,
|
|
34
|
+
files: meta.files,
|
|
35
|
+
authors: authorPublicKeys,
|
|
36
|
+
signedAt
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Sign a torrent meta.json
|
|
41
|
+
*
|
|
42
|
+
* @param crypto - Crypto adapter for signing
|
|
43
|
+
* @param keyPair - The signer's key pair
|
|
44
|
+
* @param meta - The meta.json data (without this author's signature)
|
|
45
|
+
* @param authorPublicKeys - All author public keys (including this signer)
|
|
46
|
+
* @param role - This author's role
|
|
47
|
+
* @returns MetaAuthor entry with signature
|
|
48
|
+
*/
|
|
49
|
+
export async function signMeta(crypto, keyPair, meta, authorPublicKeys, role) {
|
|
50
|
+
const signedAt = Date.now();
|
|
51
|
+
const payload = buildSigningPayload(meta, authorPublicKeys, signedAt);
|
|
52
|
+
const message = canonicalize(payload);
|
|
53
|
+
const signature = await crypto.signMessage(keyPair.privateKey, message);
|
|
54
|
+
return {
|
|
55
|
+
publicKey: keyPair.publicKey,
|
|
56
|
+
role,
|
|
57
|
+
signedAt,
|
|
58
|
+
signature
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* Verify a single author's signature
|
|
63
|
+
*
|
|
64
|
+
* @param crypto - Crypto adapter for verification
|
|
65
|
+
* @param meta - The meta.json data
|
|
66
|
+
* @param author - The author entry to verify
|
|
67
|
+
* @returns True if signature is valid
|
|
68
|
+
*/
|
|
69
|
+
export async function verifyAuthorSignature(crypto, meta, author) {
|
|
70
|
+
const authorPublicKeys = meta.authors.map(a => a.publicKey);
|
|
71
|
+
const payload = buildSigningPayload(meta, authorPublicKeys, author.signedAt);
|
|
72
|
+
const message = canonicalize(payload);
|
|
73
|
+
return crypto.verifySignature(author.publicKey, message, author.signature);
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* Verify all author signatures in a meta.json
|
|
77
|
+
*
|
|
78
|
+
* @param crypto - Crypto adapter for verification
|
|
79
|
+
* @param meta - The complete meta.json
|
|
80
|
+
* @returns Object with verification results per author
|
|
81
|
+
*/
|
|
82
|
+
export async function verifyMeta(crypto, meta) {
|
|
83
|
+
const results = new Map();
|
|
84
|
+
let allValid = true;
|
|
85
|
+
for (const author of meta.authors) {
|
|
86
|
+
const isValid = await verifyAuthorSignature(crypto, meta, author);
|
|
87
|
+
results.set(author.publicKey, isValid);
|
|
88
|
+
if (!isValid) {
|
|
89
|
+
allValid = false;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
return { valid: allValid, results };
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* Create a complete meta.json with signatures from all authors
|
|
96
|
+
*
|
|
97
|
+
* @param crypto - Crypto adapter for signing
|
|
98
|
+
* @param options - Meta creation options
|
|
99
|
+
* @returns Complete TorrentMeta with all signatures
|
|
100
|
+
*/
|
|
101
|
+
export async function createMeta(crypto, options) {
|
|
102
|
+
const created = Date.now();
|
|
103
|
+
const authorPublicKeys = options.authors.map(a => a.keyPair.publicKey);
|
|
104
|
+
const baseMeta = {
|
|
105
|
+
created,
|
|
106
|
+
title: options.title,
|
|
107
|
+
files: options.files
|
|
108
|
+
};
|
|
109
|
+
// Sign with each author
|
|
110
|
+
const signedAuthors = [];
|
|
111
|
+
for (const { keyPair, role } of options.authors) {
|
|
112
|
+
const author = await signMeta(crypto, keyPair, baseMeta, authorPublicKeys, role);
|
|
113
|
+
signedAuthors.push(author);
|
|
114
|
+
}
|
|
115
|
+
const meta = {
|
|
116
|
+
version: '1',
|
|
117
|
+
created,
|
|
118
|
+
title: options.title,
|
|
119
|
+
files: options.files,
|
|
120
|
+
authors: signedAuthors,
|
|
121
|
+
};
|
|
122
|
+
if (options.infohash) {
|
|
123
|
+
meta.infohash = options.infohash;
|
|
124
|
+
}
|
|
125
|
+
if (options.description) {
|
|
126
|
+
meta.description = options.description;
|
|
127
|
+
}
|
|
128
|
+
if (options.extensions) {
|
|
129
|
+
meta.extensions = options.extensions;
|
|
130
|
+
}
|
|
131
|
+
return meta;
|
|
132
|
+
}
|
|
133
|
+
/**
|
|
134
|
+
* Parse and validate a meta.json string
|
|
135
|
+
* Does NOT verify signatures - use verifyMeta for that
|
|
136
|
+
*
|
|
137
|
+
* @param json - JSON string to parse
|
|
138
|
+
* @returns Parsed TorrentMeta or null if invalid
|
|
139
|
+
*/
|
|
140
|
+
export function parseMeta(json) {
|
|
141
|
+
try {
|
|
142
|
+
const data = JSON.parse(json);
|
|
143
|
+
// Basic validation
|
|
144
|
+
if (typeof data.version !== 'string')
|
|
145
|
+
return null;
|
|
146
|
+
// infohash is optional
|
|
147
|
+
if (data.infohash !== undefined && typeof data.infohash !== 'string')
|
|
148
|
+
return null;
|
|
149
|
+
if (typeof data.created !== 'number')
|
|
150
|
+
return null;
|
|
151
|
+
if (typeof data.title !== 'string')
|
|
152
|
+
return null;
|
|
153
|
+
if (!Array.isArray(data.files))
|
|
154
|
+
return null;
|
|
155
|
+
if (!Array.isArray(data.authors))
|
|
156
|
+
return null;
|
|
157
|
+
return data;
|
|
158
|
+
}
|
|
159
|
+
catch {
|
|
160
|
+
return null;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
/**
|
|
164
|
+
* Find a file with a specific role (e.g., cover art)
|
|
165
|
+
*/
|
|
166
|
+
export function findFileByRole(meta, role) {
|
|
167
|
+
return meta.files.find(f => f.role === role);
|
|
168
|
+
}
|
|
169
|
+
/**
|
|
170
|
+
* Get the cover art file if present
|
|
171
|
+
*/
|
|
172
|
+
export function getCoverFile(meta) {
|
|
173
|
+
return findFileByRole(meta, 'cover');
|
|
174
|
+
}
|
|
175
|
+
/**
|
|
176
|
+
* Compute SHA256 hash of a file (browser-compatible)
|
|
177
|
+
*/
|
|
178
|
+
export async function computeFileSha256(file) {
|
|
179
|
+
let buffer;
|
|
180
|
+
if (file instanceof ArrayBuffer) {
|
|
181
|
+
buffer = file;
|
|
182
|
+
}
|
|
183
|
+
else {
|
|
184
|
+
buffer = await file.arrayBuffer();
|
|
185
|
+
}
|
|
186
|
+
const hashBuffer = await crypto.subtle.digest('SHA-256', buffer);
|
|
187
|
+
const hashArray = Array.from(new Uint8Array(hashBuffer));
|
|
188
|
+
return hashArray.map(b => b.toString(16).padStart(2, '0')).join('');
|
|
189
|
+
}
|
|
190
|
+
/**
|
|
191
|
+
* Build MetaFile entries from File objects with SHA256 hashes
|
|
192
|
+
*/
|
|
193
|
+
export async function buildMetaFiles(files) {
|
|
194
|
+
const metaFiles = [];
|
|
195
|
+
for (const file of files) {
|
|
196
|
+
const sha256 = await computeFileSha256(file);
|
|
197
|
+
const metaFile = {
|
|
198
|
+
path: file.name,
|
|
199
|
+
size: file.size,
|
|
200
|
+
type: file.type || undefined,
|
|
201
|
+
sha256
|
|
202
|
+
};
|
|
203
|
+
metaFiles.push(metaFile);
|
|
204
|
+
}
|
|
205
|
+
return metaFiles;
|
|
206
|
+
}
|
|
207
|
+
/**
|
|
208
|
+
* Set the infohash on a TorrentMeta after torrent creation
|
|
209
|
+
* Note: This does NOT re-sign - infohash is informational only
|
|
210
|
+
*/
|
|
211
|
+
export function setInfohash(meta, infohash) {
|
|
212
|
+
return { ...meta, infohash };
|
|
213
|
+
}
|
package/package.json
CHANGED