@tjamescouch/agentchat 0.11.0 → 0.13.0

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.
@@ -0,0 +1,237 @@
1
+ /**
2
+ * EscrowHooks - Event system for external escrow integration
3
+ *
4
+ * Allows external systems (blockchain, multi-sig, compliance) to hook into
5
+ * escrow lifecycle events without modifying core AgentChat code.
6
+ *
7
+ * Events:
8
+ * escrow:created - Escrow created when proposal accepted with stakes
9
+ * escrow:released - Escrow released (expired, cancelled)
10
+ * settlement:completion - Proposal completed, stakes returned
11
+ * settlement:dispute - Proposal disputed, stakes transferred/burned
12
+ */
13
+
14
+ export const EscrowEvent = {
15
+ CREATED: 'escrow:created',
16
+ RELEASED: 'escrow:released',
17
+ COMPLETION_SETTLED: 'settlement:completion',
18
+ DISPUTE_SETTLED: 'settlement:dispute'
19
+ };
20
+
21
+ export class EscrowHooks {
22
+ constructor(options = {}) {
23
+ this.handlers = new Map(); // event -> Set of handlers
24
+ this.logger = options.logger || console;
25
+ this.continueOnError = options.continueOnError !== false; // default true
26
+
27
+ // Initialize event handler sets
28
+ for (const event of Object.values(EscrowEvent)) {
29
+ this.handlers.set(event, new Set());
30
+ }
31
+ }
32
+
33
+ /**
34
+ * Register a handler for an escrow event
35
+ * @param {string} event - Event name from EscrowEvent
36
+ * @param {Function} handler - Async function(payload) to call
37
+ * @returns {Function} Unsubscribe function
38
+ */
39
+ on(event, handler) {
40
+ if (!this.handlers.has(event)) {
41
+ throw new Error(`Unknown escrow event: ${event}`);
42
+ }
43
+
44
+ if (typeof handler !== 'function') {
45
+ throw new Error('Handler must be a function');
46
+ }
47
+
48
+ this.handlers.get(event).add(handler);
49
+
50
+ // Return unsubscribe function
51
+ return () => this.off(event, handler);
52
+ }
53
+
54
+ /**
55
+ * Remove a handler for an escrow event
56
+ * @param {string} event - Event name
57
+ * @param {Function} handler - Handler to remove
58
+ */
59
+ off(event, handler) {
60
+ if (this.handlers.has(event)) {
61
+ this.handlers.get(event).delete(handler);
62
+ }
63
+ }
64
+
65
+ /**
66
+ * Remove all handlers for an event (or all events)
67
+ * @param {string} [event] - Optional event name
68
+ */
69
+ clear(event) {
70
+ if (event) {
71
+ if (this.handlers.has(event)) {
72
+ this.handlers.get(event).clear();
73
+ }
74
+ } else {
75
+ for (const handlers of this.handlers.values()) {
76
+ handlers.clear();
77
+ }
78
+ }
79
+ }
80
+
81
+ /**
82
+ * Emit an escrow event to all registered handlers
83
+ * @param {string} event - Event name
84
+ * @param {Object} payload - Event payload
85
+ * @returns {Promise<Object>} Results from all handlers
86
+ */
87
+ async emit(event, payload) {
88
+ if (!this.handlers.has(event)) {
89
+ throw new Error(`Unknown escrow event: ${event}`);
90
+ }
91
+
92
+ const handlers = this.handlers.get(event);
93
+ if (handlers.size === 0) {
94
+ return { event, handled: false, results: [] };
95
+ }
96
+
97
+ const results = [];
98
+ const errors = [];
99
+
100
+ for (const handler of handlers) {
101
+ try {
102
+ const result = await handler(payload);
103
+ results.push({ success: true, result });
104
+ } catch (err) {
105
+ const errorInfo = {
106
+ success: false,
107
+ error: err.message,
108
+ stack: err.stack
109
+ };
110
+ errors.push(errorInfo);
111
+ results.push(errorInfo);
112
+
113
+ this.logger.error?.(`[EscrowHooks] Error in ${event} handler:`, err.message);
114
+
115
+ if (!this.continueOnError) {
116
+ break;
117
+ }
118
+ }
119
+ }
120
+
121
+ return {
122
+ event,
123
+ handled: true,
124
+ results,
125
+ errors: errors.length > 0 ? errors : undefined
126
+ };
127
+ }
128
+
129
+ /**
130
+ * Check if any handlers are registered for an event
131
+ * @param {string} event - Event name
132
+ * @returns {boolean}
133
+ */
134
+ hasHandlers(event) {
135
+ return this.handlers.has(event) && this.handlers.get(event).size > 0;
136
+ }
137
+
138
+ /**
139
+ * Get count of handlers for an event
140
+ * @param {string} event - Event name
141
+ * @returns {number}
142
+ */
143
+ handlerCount(event) {
144
+ return this.handlers.has(event) ? this.handlers.get(event).size : 0;
145
+ }
146
+ }
147
+
148
+ /**
149
+ * Create payload for escrow:created event
150
+ */
151
+ export function createEscrowCreatedPayload(proposal, escrowResult) {
152
+ return {
153
+ event: EscrowEvent.CREATED,
154
+ timestamp: Date.now(),
155
+ proposal_id: proposal.id,
156
+ from_agent: proposal.from,
157
+ to_agent: proposal.to,
158
+ proposer_stake: proposal.proposer_stake || 0,
159
+ acceptor_stake: proposal.acceptor_stake || 0,
160
+ total_stake: (proposal.proposer_stake || 0) + (proposal.acceptor_stake || 0),
161
+ task: proposal.task,
162
+ amount: proposal.amount,
163
+ currency: proposal.currency,
164
+ expires: proposal.expires,
165
+ escrow_id: escrowResult.escrow?.proposal_id || proposal.id
166
+ };
167
+ }
168
+
169
+ /**
170
+ * Create payload for settlement:completion event
171
+ */
172
+ export function createCompletionPayload(proposal, ratingChanges) {
173
+ const escrowInfo = ratingChanges?._escrow || {};
174
+ return {
175
+ event: EscrowEvent.COMPLETION_SETTLED,
176
+ timestamp: Date.now(),
177
+ proposal_id: proposal.id,
178
+ from_agent: proposal.from,
179
+ to_agent: proposal.to,
180
+ completed_by: proposal.completed_by,
181
+ completion_proof: proposal.completion_proof,
182
+ settlement: 'returned',
183
+ stakes_returned: {
184
+ proposer: escrowInfo.proposer_stake || 0,
185
+ acceptor: escrowInfo.acceptor_stake || 0
186
+ },
187
+ rating_changes: {
188
+ [proposal.from]: ratingChanges?.[proposal.from],
189
+ [proposal.to]: ratingChanges?.[proposal.to]
190
+ }
191
+ };
192
+ }
193
+
194
+ /**
195
+ * Create payload for settlement:dispute event
196
+ */
197
+ export function createDisputePayload(proposal, ratingChanges) {
198
+ const escrowInfo = ratingChanges?._escrow || {};
199
+ return {
200
+ event: EscrowEvent.DISPUTE_SETTLED,
201
+ timestamp: Date.now(),
202
+ proposal_id: proposal.id,
203
+ from_agent: proposal.from,
204
+ to_agent: proposal.to,
205
+ disputed_by: proposal.disputed_by,
206
+ dispute_reason: proposal.dispute_reason,
207
+ settlement: escrowInfo.settlement || 'settled',
208
+ settlement_reason: escrowInfo.settlement_reason,
209
+ fault_determination: escrowInfo.fault_party,
210
+ stakes_transferred: escrowInfo.transferred,
211
+ stakes_burned: escrowInfo.burned,
212
+ rating_changes: {
213
+ [proposal.from]: ratingChanges?.[proposal.from],
214
+ [proposal.to]: ratingChanges?.[proposal.to]
215
+ }
216
+ };
217
+ }
218
+
219
+ /**
220
+ * Create payload for escrow:released event
221
+ */
222
+ export function createEscrowReleasedPayload(proposalId, escrow, reason) {
223
+ return {
224
+ event: EscrowEvent.RELEASED,
225
+ timestamp: Date.now(),
226
+ proposal_id: proposalId,
227
+ from_agent: escrow.from?.agent_id,
228
+ to_agent: escrow.to?.agent_id,
229
+ stakes_released: {
230
+ proposer: escrow.from?.stake || 0,
231
+ acceptor: escrow.to?.stake || 0
232
+ },
233
+ reason: reason || 'expired'
234
+ };
235
+ }
236
+
237
+ export default EscrowHooks;
package/lib/identity.js CHANGED
@@ -44,6 +44,7 @@ export class Identity {
44
44
  this.pubkey = data.pubkey; // PEM format
45
45
  this.privkey = data.privkey; // PEM format (null if loaded from export)
46
46
  this.created = data.created;
47
+ this.rotations = data.rotations || []; // Array of rotation records
47
48
 
48
49
  // Lazy-load crypto key objects
49
50
  this._publicKey = null;
@@ -85,7 +86,8 @@ export class Identity {
85
86
  name: this.name,
86
87
  pubkey: this.pubkey,
87
88
  privkey: this.privkey,
88
- created: this.created
89
+ created: this.created,
90
+ rotations: this.rotations
89
91
  };
90
92
 
91
93
  await fs.writeFile(filePath, JSON.stringify(data, null, 2), {
@@ -160,7 +162,137 @@ export class Identity {
160
162
  return {
161
163
  name: this.name,
162
164
  pubkey: this.pubkey,
163
- created: this.created
165
+ created: this.created,
166
+ rotations: this.rotations
164
167
  };
165
168
  }
169
+
170
+ /**
171
+ * Rotate to a new keypair
172
+ * Signs the new public key with the old private key for chain of custody
173
+ * @returns {object} Rotation record with old_pubkey, new_pubkey, signature, timestamp
174
+ */
175
+ rotate() {
176
+ if (!this.privkey) {
177
+ throw new Error('Private key not available - cannot rotate');
178
+ }
179
+
180
+ // Generate new keypair
181
+ const { publicKey, privateKey } = crypto.generateKeyPairSync('ed25519');
182
+ const newPubkey = publicKey.export({ type: 'spki', format: 'pem' });
183
+ const newPrivkey = privateKey.export({ type: 'pkcs8', format: 'pem' });
184
+
185
+ // Use same timestamp for both signing and record
186
+ const timestamp = new Date().toISOString();
187
+
188
+ // Create rotation record content to sign
189
+ const rotationContent = JSON.stringify({
190
+ old_pubkey: this.pubkey,
191
+ new_pubkey: newPubkey,
192
+ timestamp
193
+ });
194
+
195
+ // Sign with old private key
196
+ const signature = this.sign(rotationContent);
197
+
198
+ // Create rotation record
199
+ const rotationRecord = {
200
+ old_pubkey: this.pubkey,
201
+ old_agent_id: this.getAgentId(),
202
+ new_pubkey: newPubkey,
203
+ new_agent_id: pubkeyToAgentId(newPubkey),
204
+ signature,
205
+ timestamp
206
+ };
207
+
208
+ // Update identity with new keys
209
+ this.rotations.push(rotationRecord);
210
+ this.pubkey = newPubkey;
211
+ this.privkey = newPrivkey;
212
+ this._publicKey = null;
213
+ this._privateKey = null;
214
+
215
+ return rotationRecord;
216
+ }
217
+
218
+ /**
219
+ * Verify a rotation record
220
+ * Checks that the signature is valid using the old public key
221
+ * @param {object} record - Rotation record to verify
222
+ * @returns {boolean} True if signature is valid
223
+ */
224
+ static verifyRotation(record) {
225
+ try {
226
+ const rotationContent = JSON.stringify({
227
+ old_pubkey: record.old_pubkey,
228
+ new_pubkey: record.new_pubkey,
229
+ timestamp: record.timestamp
230
+ });
231
+
232
+ return Identity.verify(rotationContent, record.signature, record.old_pubkey);
233
+ } catch {
234
+ return false;
235
+ }
236
+ }
237
+
238
+ /**
239
+ * Verify the entire rotation chain
240
+ * @returns {object} { valid: boolean, errors: string[] }
241
+ */
242
+ verifyRotationChain() {
243
+ const errors = [];
244
+
245
+ if (this.rotations.length === 0) {
246
+ return { valid: true, errors: [] };
247
+ }
248
+
249
+ // Verify each rotation in sequence
250
+ for (let i = 0; i < this.rotations.length; i++) {
251
+ const record = this.rotations[i];
252
+
253
+ // Verify signature
254
+ if (!Identity.verifyRotation(record)) {
255
+ errors.push(`Rotation ${i + 1}: Invalid signature`);
256
+ continue;
257
+ }
258
+
259
+ // Verify chain continuity (each new_pubkey should match next old_pubkey)
260
+ if (i < this.rotations.length - 1) {
261
+ const nextRecord = this.rotations[i + 1];
262
+ if (record.new_pubkey !== nextRecord.old_pubkey) {
263
+ errors.push(`Rotation ${i + 1}: Chain break - new_pubkey doesn't match next old_pubkey`);
264
+ }
265
+ }
266
+ }
267
+
268
+ // Verify final pubkey matches current identity
269
+ const lastRotation = this.rotations[this.rotations.length - 1];
270
+ if (lastRotation.new_pubkey !== this.pubkey) {
271
+ errors.push('Final rotation new_pubkey does not match current identity pubkey');
272
+ }
273
+
274
+ return {
275
+ valid: errors.length === 0,
276
+ errors
277
+ };
278
+ }
279
+
280
+ /**
281
+ * Get the original (genesis) public key before any rotations
282
+ * @returns {string} Original public key in PEM format
283
+ */
284
+ getOriginalPubkey() {
285
+ if (this.rotations.length === 0) {
286
+ return this.pubkey;
287
+ }
288
+ return this.rotations[0].old_pubkey;
289
+ }
290
+
291
+ /**
292
+ * Get the original (genesis) agent ID
293
+ * @returns {string} Original agent ID
294
+ */
295
+ getOriginalAgentId() {
296
+ return pubkeyToAgentId(this.getOriginalPubkey());
297
+ }
166
298
  }
package/lib/protocol.js CHANGED
@@ -24,7 +24,12 @@ export const ClientMessageType = {
24
24
  DISPUTE: 'DISPUTE',
25
25
  // Skill discovery message types
26
26
  REGISTER_SKILLS: 'REGISTER_SKILLS',
27
- SEARCH_SKILLS: 'SEARCH_SKILLS'
27
+ SEARCH_SKILLS: 'SEARCH_SKILLS',
28
+ // Presence message types
29
+ SET_PRESENCE: 'SET_PRESENCE',
30
+ // Identity verification message types
31
+ VERIFY_REQUEST: 'VERIFY_REQUEST',
32
+ VERIFY_RESPONSE: 'VERIFY_RESPONSE'
28
33
  };
29
34
 
30
35
  // Server -> Client message types
@@ -47,7 +52,14 @@ export const ServerMessageType = {
47
52
  DISPUTE: 'DISPUTE',
48
53
  // Skill discovery message types
49
54
  SKILLS_REGISTERED: 'SKILLS_REGISTERED',
50
- SEARCH_RESULTS: 'SEARCH_RESULTS'
55
+ SEARCH_RESULTS: 'SEARCH_RESULTS',
56
+ // Presence message types
57
+ PRESENCE_CHANGED: 'PRESENCE_CHANGED',
58
+ // Identity verification message types
59
+ VERIFY_REQUEST: 'VERIFY_REQUEST',
60
+ VERIFY_RESPONSE: 'VERIFY_RESPONSE',
61
+ VERIFY_SUCCESS: 'VERIFY_SUCCESS',
62
+ VERIFY_FAILED: 'VERIFY_FAILED'
51
63
  };
52
64
 
53
65
  // Error codes
@@ -68,7 +80,19 @@ export const ErrorCode = {
68
80
  NOT_PROPOSAL_PARTY: 'NOT_PROPOSAL_PARTY',
69
81
  // Staking errors
70
82
  INSUFFICIENT_REPUTATION: 'INSUFFICIENT_REPUTATION',
71
- INVALID_STAKE: 'INVALID_STAKE'
83
+ INVALID_STAKE: 'INVALID_STAKE',
84
+ // Verification errors
85
+ VERIFICATION_FAILED: 'VERIFICATION_FAILED',
86
+ VERIFICATION_EXPIRED: 'VERIFICATION_EXPIRED',
87
+ NO_PUBKEY: 'NO_PUBKEY'
88
+ };
89
+
90
+ // Presence status
91
+ export const PresenceStatus = {
92
+ ONLINE: 'online',
93
+ AWAY: 'away',
94
+ BUSY: 'busy',
95
+ OFFLINE: 'offline'
72
96
  };
73
97
 
74
98
  // Proposal status
@@ -340,6 +364,50 @@ export function validateClientMessage(raw) {
340
364
  // query_id is optional but useful for tracking responses
341
365
  break;
342
366
 
367
+ case ClientMessageType.SET_PRESENCE:
368
+ // Set presence requires: status (online, away, busy, offline)
369
+ const validStatuses = ['online', 'away', 'busy', 'offline'];
370
+ if (!msg.status || !validStatuses.includes(msg.status)) {
371
+ return { valid: false, error: `Invalid presence status. Must be one of: ${validStatuses.join(', ')}` };
372
+ }
373
+ // Optional: status_text for custom message
374
+ if (msg.status_text !== undefined && typeof msg.status_text !== 'string') {
375
+ return { valid: false, error: 'status_text must be a string' };
376
+ }
377
+ if (msg.status_text && msg.status_text.length > 100) {
378
+ return { valid: false, error: 'status_text too long (max 100 chars)' };
379
+ }
380
+ break;
381
+
382
+ case ClientMessageType.VERIFY_REQUEST:
383
+ // Verify request requires: target agent and nonce
384
+ if (!msg.target) {
385
+ return { valid: false, error: 'Missing target agent' };
386
+ }
387
+ if (!isAgent(msg.target)) {
388
+ return { valid: false, error: 'Target must be an agent (@id)' };
389
+ }
390
+ if (!msg.nonce || typeof msg.nonce !== 'string') {
391
+ return { valid: false, error: 'Missing or invalid nonce' };
392
+ }
393
+ if (msg.nonce.length < 16 || msg.nonce.length > 128) {
394
+ return { valid: false, error: 'Nonce must be 16-128 characters' };
395
+ }
396
+ break;
397
+
398
+ case ClientMessageType.VERIFY_RESPONSE:
399
+ // Verify response requires: request_id, nonce, and signature
400
+ if (!msg.request_id) {
401
+ return { valid: false, error: 'Missing request_id' };
402
+ }
403
+ if (!msg.nonce || typeof msg.nonce !== 'string') {
404
+ return { valid: false, error: 'Missing or invalid nonce' };
405
+ }
406
+ if (!msg.sig || typeof msg.sig !== 'string') {
407
+ return { valid: false, error: 'Missing or invalid signature' };
408
+ }
409
+ break;
410
+
343
411
  default:
344
412
  return { valid: false, error: `Unknown message type: ${msg.type}` };
345
413
  }
@@ -395,3 +463,21 @@ export function isProposalMessage(type) {
395
463
  ClientMessageType.DISPUTE
396
464
  ].includes(type);
397
465
  }
466
+
467
+ /**
468
+ * Generate a unique verification request ID
469
+ * Format: verify_<timestamp>_<random>
470
+ */
471
+ export function generateVerifyId() {
472
+ const timestamp = Date.now().toString(36);
473
+ const random = crypto.randomBytes(4).toString('hex');
474
+ return `verify_${timestamp}_${random}`;
475
+ }
476
+
477
+ /**
478
+ * Generate a random nonce for identity verification
479
+ * Returns a 32-character hex string
480
+ */
481
+ export function generateNonce() {
482
+ return crypto.randomBytes(16).toString('hex');
483
+ }
@@ -0,0 +1,181 @@
1
+ /**
2
+ * Server Directory
3
+ * Registry of known AgentChat servers for discovery
4
+ */
5
+
6
+ import http from 'http';
7
+ import https from 'https';
8
+ import fs from 'fs/promises';
9
+ import path from 'path';
10
+
11
+ // Default public servers (can be extended)
12
+ export const DEFAULT_SERVERS = [
13
+ {
14
+ name: 'AgentChat Public',
15
+ url: 'wss://agentchat-server.fly.dev',
16
+ description: 'Official public AgentChat server',
17
+ region: 'global'
18
+ }
19
+ ];
20
+
21
+ // Default directory file path
22
+ export const DEFAULT_DIRECTORY_PATH = path.join(
23
+ process.env.HOME || process.env.USERPROFILE || '.',
24
+ '.agentchat',
25
+ 'servers.json'
26
+ );
27
+
28
+ /**
29
+ * Server Directory for discovering AgentChat servers
30
+ */
31
+ export class ServerDirectory {
32
+ constructor(options = {}) {
33
+ this.directoryPath = options.directoryPath || DEFAULT_DIRECTORY_PATH;
34
+ this.servers = [...DEFAULT_SERVERS];
35
+ this.timeout = options.timeout || 5000;
36
+ }
37
+
38
+ /**
39
+ * Load servers from directory file
40
+ */
41
+ async load() {
42
+ try {
43
+ const data = await fs.readFile(this.directoryPath, 'utf8');
44
+ const loaded = JSON.parse(data);
45
+ if (Array.isArray(loaded.servers)) {
46
+ // Merge with defaults, avoiding duplicates by URL
47
+ const urls = new Set(this.servers.map(s => s.url));
48
+ for (const server of loaded.servers) {
49
+ if (!urls.has(server.url)) {
50
+ this.servers.push(server);
51
+ urls.add(server.url);
52
+ }
53
+ }
54
+ }
55
+ } catch (err) {
56
+ // File doesn't exist or is invalid, use defaults
57
+ }
58
+ return this;
59
+ }
60
+
61
+ /**
62
+ * Save servers to directory file
63
+ */
64
+ async save() {
65
+ const dir = path.dirname(this.directoryPath);
66
+ await fs.mkdir(dir, { recursive: true });
67
+ await fs.writeFile(this.directoryPath, JSON.stringify({
68
+ version: 1,
69
+ updated_at: new Date().toISOString(),
70
+ servers: this.servers
71
+ }, null, 2));
72
+ }
73
+
74
+ /**
75
+ * Add a server to the directory
76
+ */
77
+ async addServer(server) {
78
+ const existing = this.servers.find(s => s.url === server.url);
79
+ if (existing) {
80
+ Object.assign(existing, server);
81
+ } else {
82
+ this.servers.push(server);
83
+ }
84
+ await this.save();
85
+ }
86
+
87
+ /**
88
+ * Remove a server from the directory
89
+ */
90
+ async removeServer(url) {
91
+ this.servers = this.servers.filter(s => s.url !== url);
92
+ await this.save();
93
+ }
94
+
95
+ /**
96
+ * Check health of a single server
97
+ * @param {Object} server - Server object with url
98
+ * @returns {Object} Server with health status
99
+ */
100
+ async checkHealth(server) {
101
+ const wsUrl = server.url;
102
+ // Convert ws:// or wss:// to http:// or https://
103
+ const httpUrl = wsUrl
104
+ .replace('wss://', 'https://')
105
+ .replace('ws://', 'http://');
106
+
107
+ try {
108
+ const health = await this._fetchHealth(httpUrl + '/health');
109
+ return {
110
+ ...server,
111
+ status: 'online',
112
+ health,
113
+ checked_at: new Date().toISOString()
114
+ };
115
+ } catch (err) {
116
+ return {
117
+ ...server,
118
+ status: 'offline',
119
+ error: err.message || err.code || 'Unknown error',
120
+ checked_at: new Date().toISOString()
121
+ };
122
+ }
123
+ }
124
+
125
+ /**
126
+ * Fetch health endpoint
127
+ */
128
+ _fetchHealth(url) {
129
+ return new Promise((resolve, reject) => {
130
+ const protocol = url.startsWith('https') ? https : http;
131
+ const req = protocol.get(url, { timeout: this.timeout }, (res) => {
132
+ let data = '';
133
+ res.on('data', chunk => data += chunk);
134
+ res.on('end', () => {
135
+ if (res.statusCode === 200) {
136
+ try {
137
+ resolve(JSON.parse(data));
138
+ } catch {
139
+ reject(new Error('Invalid health response'));
140
+ }
141
+ } else {
142
+ reject(new Error(`HTTP ${res.statusCode}`));
143
+ }
144
+ });
145
+ });
146
+
147
+ req.on('error', reject);
148
+ req.on('timeout', () => {
149
+ req.destroy();
150
+ reject(new Error('Timeout'));
151
+ });
152
+ });
153
+ }
154
+
155
+ /**
156
+ * Discover available servers (check health of all known servers)
157
+ * @param {Object} options
158
+ * @param {boolean} options.onlineOnly - Only return online servers
159
+ * @returns {Array} List of servers with status
160
+ */
161
+ async discover(options = {}) {
162
+ const results = await Promise.all(
163
+ this.servers.map(server => this.checkHealth(server))
164
+ );
165
+
166
+ if (options.onlineOnly) {
167
+ return results.filter(s => s.status === 'online');
168
+ }
169
+
170
+ return results;
171
+ }
172
+
173
+ /**
174
+ * Get list of known servers without health check
175
+ */
176
+ list() {
177
+ return [...this.servers];
178
+ }
179
+ }
180
+
181
+ export default ServerDirectory;