a2acalling 0.6.0 → 0.6.2

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.
@@ -13,9 +13,11 @@ const fs = require('fs');
13
13
  const path = require('path');
14
14
  const { TokenStore } = require('../lib/tokens');
15
15
  const { ConversationStore } = require('../lib/conversations');
16
+ const { A2AClient } = require('../lib/client');
16
17
  const { A2AConfig } = require('../lib/config');
17
18
  const { loadManifest, saveManifest } = require('../lib/disclosure');
18
19
  const { resolveInviteHost } = require('../lib/invite-host');
20
+ const { CallbookStore } = require('../lib/callbook');
19
21
  const { createLogger } = require('../lib/logger');
20
22
 
21
23
  const DASHBOARD_STATIC_DIR = path.join(__dirname, '..', 'dashboard', 'public');
@@ -28,6 +30,63 @@ function isLoopbackAddress(ip) {
28
30
  return ip.startsWith('::ffff:127.');
29
31
  }
30
32
 
33
+ function parseCookieHeader(headerValue) {
34
+ const raw = String(headerValue || '').trim();
35
+ if (!raw) return {};
36
+ const cookies = {};
37
+ for (const part of raw.split(';')) {
38
+ const idx = part.indexOf('=');
39
+ if (idx === -1) continue;
40
+ const key = part.slice(0, idx).trim();
41
+ const value = part.slice(idx + 1).trim();
42
+ if (!key) continue;
43
+ cookies[key] = decodeURIComponent(value);
44
+ }
45
+ return cookies;
46
+ }
47
+
48
+ function isDirectLocalRequest(req) {
49
+ const ip = (req && req.socket && req.socket.remoteAddress) ? req.socket.remoteAddress : req.ip;
50
+ if (!isLoopbackAddress(ip)) return false;
51
+ const host = String(req.headers.host || '').toLowerCase();
52
+ const isLocalHost = host.startsWith('localhost') ||
53
+ host.startsWith('127.0.0.1') ||
54
+ host.startsWith('[::1]') ||
55
+ host.startsWith('::1');
56
+ if (!isLocalHost) return false;
57
+ // Avoid treating proxy-forwarded traffic as "local".
58
+ const forwarded = req.headers['x-forwarded-for'] ||
59
+ req.headers['x-forwarded-proto'] ||
60
+ req.headers['x-forwarded-host'] ||
61
+ req.headers['cf-connecting-ip'] ||
62
+ req.headers['x-forwarded-by'];
63
+ if (forwarded) return false;
64
+ return true;
65
+ }
66
+
67
+ function isHttpsRequest(req) {
68
+ const proto = String(req.headers['x-forwarded-proto'] || '')
69
+ .split(',')[0]
70
+ .trim()
71
+ .toLowerCase();
72
+ if (proto === 'https') return true;
73
+ if (req && req.socket && req.socket.encrypted) return true;
74
+ return false;
75
+ }
76
+
77
+ function buildSetCookie(name, value, options = {}) {
78
+ const parts = [];
79
+ parts.push(`${name}=${encodeURIComponent(String(value || ''))}`);
80
+ parts.push('Path=/');
81
+ if (options.httpOnly !== false) parts.push('HttpOnly');
82
+ if (options.sameSite) parts.push(`SameSite=${options.sameSite}`);
83
+ if (options.secure) parts.push('Secure');
84
+ if (Number.isFinite(options.maxAgeSeconds)) {
85
+ parts.push(`Max-Age=${Math.max(0, Math.floor(options.maxAgeSeconds))}`);
86
+ }
87
+ return parts.join('; ');
88
+ }
89
+
31
90
  function sanitizeString(value, maxLength = 200) {
32
91
  return String(value || '')
33
92
  .replace(/\s+/g, ' ')
@@ -35,6 +94,30 @@ function sanitizeString(value, maxLength = 200) {
35
94
  .slice(0, maxLength);
36
95
  }
37
96
 
97
+ function parseBoolean(value) {
98
+ if (value === null || value === undefined) return false;
99
+ if (typeof value === 'boolean') return value;
100
+ if (typeof value === 'number') return value !== 0;
101
+ const s = String(value).trim().toLowerCase();
102
+ if (['true', '1', 'yes', 'y', 'on'].includes(s)) return true;
103
+ if (['false', '0', 'no', 'n', 'off', ''].includes(s)) return false;
104
+ return Boolean(value);
105
+ }
106
+
107
+ function resolveAgentContext(options = {}) {
108
+ const provided = options.agentContext && typeof options.agentContext === 'object' ? options.agentContext : null;
109
+ const name = sanitizeString(
110
+ provided?.name || process.env.A2A_AGENT_NAME || process.env.AGENT_NAME || 'a2a-agent',
111
+ 80
112
+ ) || 'a2a-agent';
113
+ const owner = sanitizeString(
114
+ provided?.owner || process.env.A2A_OWNER_NAME || process.env.USER || 'Agent Owner',
115
+ 120
116
+ ) || 'Agent Owner';
117
+
118
+ return { name, owner };
119
+ }
120
+
38
121
  function normalizeTierId(value) {
39
122
  return sanitizeString(value, 80)
40
123
  .toLowerCase()
@@ -108,6 +191,8 @@ function buildContext(options = {}) {
108
191
  const tokenStore = options.tokenStore || new TokenStore();
109
192
  const config = options.config || new A2AConfig();
110
193
  const logger = options.logger || createLogger({ component: 'a2a.dashboard' });
194
+ const callbookStore = options.callbookStore || new CallbookStore(tokenStore.configDir);
195
+ const agentContext = resolveAgentContext(options);
111
196
  let convStore = options.convStore || null;
112
197
  if (!convStore) {
113
198
  try {
@@ -124,7 +209,9 @@ function buildContext(options = {}) {
124
209
  tokenStore,
125
210
  config,
126
211
  convStore,
212
+ callbookStore,
127
213
  logger,
214
+ agentContext,
128
215
  staticDir: DASHBOARD_STATIC_DIR
129
216
  };
130
217
  }
@@ -165,24 +252,95 @@ function resolveConversationContact(conversation, contactIndex) {
165
252
  return null;
166
253
  }
167
254
 
168
- function ensureDashboardAccess(req, res, next) {
169
- const adminToken = process.env.A2A_ADMIN_TOKEN;
170
- const headerToken = req.headers['x-admin-token'];
171
- const queryToken = req.query?.admin_token;
172
- if (isLoopbackAddress(req.ip)) {
173
- return next();
174
- }
175
- if (!adminToken) {
255
+ function toDashboardContact(contact) {
256
+ const canCall = Boolean(
257
+ contact &&
258
+ String(contact.host || '').trim() &&
259
+ contact.token_enc &&
260
+ contact.token_hash
261
+ );
262
+
263
+ return {
264
+ id: contact.id,
265
+ owner: contact.owner || null,
266
+ name: contact.name || null,
267
+ host: contact.host || null,
268
+ web_address: contact.host || null,
269
+ is_mine: Boolean(contact.is_mine),
270
+ server_name: contact.server_name || null,
271
+ notes: contact.notes || null,
272
+ tags: Array.isArray(contact.tags) ? contact.tags : [],
273
+ fields: (contact.fields && typeof contact.fields === 'object' && !Array.isArray(contact.fields)) ? contact.fields : {},
274
+ linked_token_id: contact.linked_token_id || null,
275
+ status: contact.status || 'unknown',
276
+ last_seen: contact.last_seen || null,
277
+ last_check: contact.last_check || null,
278
+ last_error: contact.last_error || null,
279
+ added_at: contact.added_at || null,
280
+ updated_at: contact.updated_at || null,
281
+ can_call: canCall
282
+ };
283
+ }
284
+
285
+ function makeEnsureDashboardAccess(context) {
286
+ return function ensureDashboardAccess(req, res, next) {
287
+ const adminToken = process.env.A2A_ADMIN_TOKEN;
288
+ const headerToken = req.headers['x-admin-token'];
289
+ const queryToken = req.query?.admin_token;
290
+
291
+ if (isDirectLocalRequest(req)) {
292
+ return next();
293
+ }
294
+
295
+ if (adminToken && (headerToken === adminToken || queryToken === adminToken)) {
296
+ return next();
297
+ }
298
+
299
+ const cookies = parseCookieHeader(req.headers.cookie || '');
300
+ const sessionToken = cookies.a2a_callbook_session || null;
301
+ if (sessionToken && context.callbookStore && context.callbookStore.isAvailable()) {
302
+ const session = context.callbookStore.validateSession(sessionToken);
303
+ if (session && session.valid) {
304
+ req.callbook = session;
305
+ return next();
306
+ }
307
+ }
308
+
309
+ const wantsHtml = String(req.headers.accept || '').includes('text/html');
310
+ if (wantsHtml) {
311
+ return res.status(401).send(`<!doctype html>
312
+ <html>
313
+ <head>
314
+ <meta charset="utf-8">
315
+ <meta name="viewport" content="width=device-width, initial-scale=1">
316
+ <title>A2A Dashboard</title>
317
+ </head>
318
+ <body style="font-family: system-ui, -apple-system, Segoe UI, sans-serif; padding: 2rem;">
319
+ <h1>A2A Dashboard Locked</h1>
320
+ <p>This dashboard requires owner access.</p>
321
+ <ul>
322
+ <li>Local access: open <code>http://127.0.0.1:PORT/dashboard/</code> on the server</li>
323
+ <li>Remote access: generate a Callbook Remote install link on the server, then open it on your Mac</li>
324
+ <li>Break-glass: set <code>A2A_ADMIN_TOKEN</code> and send <code>x-admin-token</code> header</li>
325
+ </ul>
326
+ </body>
327
+ </html>`);
328
+ }
329
+
330
+ if (!adminToken && !(context.callbookStore && context.callbookStore.isAvailable())) {
331
+ return res.status(401).json({
332
+ success: false,
333
+ error: 'admin_token_required',
334
+ message: 'Set A2A_ADMIN_TOKEN (or enable callbook session storage) to access dashboard from non-local addresses'
335
+ });
336
+ }
337
+
176
338
  return res.status(401).json({
177
339
  success: false,
178
- error: 'admin_token_required',
179
- message: 'Set A2A_ADMIN_TOKEN to access dashboard from non-local addresses'
340
+ error: 'unauthorized',
341
+ message: 'Admin token or callbook session required'
180
342
  });
181
- }
182
- if (headerToken === adminToken || queryToken === adminToken) {
183
- return next();
184
- }
185
- return res.status(401).json({ success: false, error: 'unauthorized', message: 'Admin token required' });
343
+ };
186
344
  }
187
345
 
188
346
  function summarizeDebugLogs(logs) {
@@ -252,17 +410,213 @@ function createDashboardApiRouter(options = {}) {
252
410
  const router = express.Router();
253
411
  const context = buildContext(options);
254
412
  router.use(express.json());
413
+ const ensureDashboardAccess = makeEnsureDashboardAccess(context);
414
+
415
+ // Callbook Remote: exchange a short-lived provisioning code for a long-lived session cookie.
416
+ // This route must be reachable BEFORE dashboard access is established.
417
+ router.post('/callbook/exchange', (req, res) => {
418
+ const body = req.body || {};
419
+ const code = sanitizeString(body.code || '', 500);
420
+ const label = sanitizeString(body.label || '', 120) || null;
421
+
422
+ if (!context.callbookStore || !context.callbookStore.isAvailable()) {
423
+ return res.status(500).json({
424
+ success: false,
425
+ error: 'callbook_storage_unavailable',
426
+ message: 'Callbook session storage is not available on this server.',
427
+ hint: context.callbookStore ? context.callbookStore.getDbError() : 'missing_callbook_store'
428
+ });
429
+ }
430
+
431
+ const result = context.callbookStore.exchangeProvisionCode(code, { label });
432
+ if (!result.success) {
433
+ return res.status(401).json({
434
+ success: false,
435
+ error: result.error || 'invalid_code',
436
+ message: 'Callbook install code is invalid, expired, or already used.'
437
+ });
438
+ }
439
+
440
+ // "Never expires" in DB; for browsers we set a very long cookie lifetime.
441
+ // (Browsers may still evict cookies; owner can always re-provision.)
442
+ const maxAgeSeconds = 10 * 365 * 24 * 60 * 60; // ~10 years
443
+ const cookie = buildSetCookie('a2a_callbook_session', result.sessionToken, {
444
+ httpOnly: true,
445
+ sameSite: 'Lax',
446
+ secure: isHttpsRequest(req),
447
+ maxAgeSeconds
448
+ });
449
+ res.setHeader('Set-Cookie', cookie);
450
+
451
+ return res.json({
452
+ success: true,
453
+ device: result.device,
454
+ dashboard_path: '/dashboard/'
455
+ });
456
+ });
457
+
458
+ // All other dashboard API routes require owner access.
255
459
  router.use(ensureDashboardAccess);
256
460
 
257
- router.get('/status', (req, res) => {
461
+ router.get('/status', async (req, res) => {
258
462
  context.logger.debug('Dashboard status requested', { event: 'dashboard_status' });
259
- res.json({
463
+ const refreshIp = String(req.query.refresh_ip || 'false') === 'true';
464
+ let resolvedHost = null;
465
+ let warnings = [];
466
+ let inviteResolution = null;
467
+ try {
468
+ const resolved = await resolveInviteHost({
469
+ config: context.config,
470
+ fallbackHost: req.headers.host || process.env.HOSTNAME || 'localhost',
471
+ defaultPort: process.env.PORT || process.env.A2A_PORT || 3001,
472
+ refreshExternalIp: refreshIp,
473
+ forceRefreshExternalIp: refreshIp,
474
+ alwaysLookupExternalIp: true,
475
+ warnOnExternalIpFailure: refreshIp
476
+ });
477
+ inviteResolution = resolved;
478
+ resolvedHost = resolved.host;
479
+ warnings = resolved.warnings || [];
480
+ } catch (err) {
481
+ // Non-fatal: still return base status.
482
+ }
483
+
484
+ const schemeOverride = String(process.env.A2A_PUBLIC_SCHEME || '').trim();
485
+ const inferredScheme = schemeOverride || (isHttpsRequest(req) ? 'https' : 'http');
486
+ const publicBaseUrl = resolvedHost ? `${inferredScheme}://${resolvedHost}` : null;
487
+
488
+ const devices = (context.callbookStore && context.callbookStore.isAvailable())
489
+ ? context.callbookStore.listDevices({ includeRevoked: true, limit: 200 }).devices
490
+ : [];
491
+
492
+ return res.json({
260
493
  success: true,
261
494
  dashboard: true,
262
495
  conversations_enabled: Boolean(context.convStore),
496
+ agent: {
497
+ name: context.agentContext?.name || null,
498
+ owner_name: context.agentContext?.owner || null,
499
+ server_name: sanitizeString(context.config.getAgent?.().server_name || context.config.getAgent?.().serverName || '', 120) || null
500
+ },
263
501
  config_file: require('../lib/config').CONFIG_FILE,
264
- manifest_file: require('../lib/disclosure').MANIFEST_FILE
502
+ manifest_file: require('../lib/disclosure').MANIFEST_FILE,
503
+ public_base_url: publicBaseUrl,
504
+ public_dashboard_url: publicBaseUrl ? `${publicBaseUrl}/dashboard/` : null,
505
+ callbook_install_base: publicBaseUrl ? `${publicBaseUrl}/callbook/install` : null,
506
+ invite_host: inviteResolution ? {
507
+ host: inviteResolution.host,
508
+ source: inviteResolution.source || null,
509
+ original_host: inviteResolution.originalHost || null
510
+ } : null,
511
+ external_ip: inviteResolution && inviteResolution.externalIpInfo ? {
512
+ ip: inviteResolution.externalIpInfo.ip || null,
513
+ checked_at: inviteResolution.externalIpInfo.checkedAt || null,
514
+ source: inviteResolution.externalIpInfo.source || null,
515
+ from_cache: Boolean(inviteResolution.externalIpInfo.fromCache),
516
+ stale: Boolean(inviteResolution.externalIpInfo.stale),
517
+ error: inviteResolution.externalIpInfo.error || null,
518
+ attempts: inviteResolution.externalIpInfo.attempts || null
519
+ } : null,
520
+ warnings,
521
+ callbook: {
522
+ enabled: Boolean(context.callbookStore && context.callbookStore.isAvailable()),
523
+ device_count: Array.isArray(devices) ? devices.length : 0
524
+ }
525
+ });
526
+ });
527
+
528
+ // Callbook Remote: create a short-lived install link (24h by default).
529
+ router.post('/callbook/provision', async (req, res) => {
530
+ const body = req.body || {};
531
+ const label = sanitizeString(body.label || 'Callbook Remote', 120) || null;
532
+ const ttlHoursRaw = body.ttl_hours !== undefined ? body.ttl_hours : body.ttlHours;
533
+ const ttlHours = Math.max(1, Math.min(168, Number.parseInt(String(ttlHoursRaw || '24'), 10) || 24));
534
+ const ttlMs = ttlHours * 60 * 60 * 1000;
535
+
536
+ if (!context.callbookStore || !context.callbookStore.isAvailable()) {
537
+ return res.status(500).json({
538
+ success: false,
539
+ error: 'callbook_storage_unavailable',
540
+ message: 'Callbook session storage is not available on this server.',
541
+ hint: context.callbookStore ? context.callbookStore.getDbError() : 'missing_callbook_store'
542
+ });
543
+ }
544
+
545
+ const created = context.callbookStore.createProvisionCode({ label, ttlMs });
546
+ if (!created.success) {
547
+ return res.status(500).json({
548
+ success: false,
549
+ error: created.error || 'callbook_provision_failed',
550
+ message: created.message || 'Failed to create Callbook Remote install link.'
551
+ });
552
+ }
553
+
554
+ let resolvedHost = null;
555
+ let warnings = [];
556
+ try {
557
+ const resolved = await resolveInviteHost({
558
+ config: context.config,
559
+ fallbackHost: req.headers.host || process.env.HOSTNAME || 'localhost',
560
+ defaultPort: process.env.PORT || process.env.A2A_PORT || 3001,
561
+ refreshExternalIp: true,
562
+ forceRefreshExternalIp: true
563
+ });
564
+ resolvedHost = resolved.host;
565
+ warnings = resolved.warnings || [];
566
+ } catch (err) {
567
+ // Non-fatal: we can still return the code; owner can assemble URL manually.
568
+ }
569
+
570
+ const schemeOverride = String(process.env.A2A_PUBLIC_SCHEME || '').trim();
571
+ const scheme = schemeOverride || (isHttpsRequest(req) ? 'https' : 'http');
572
+ const baseUrl = resolvedHost ? `${scheme}://${resolvedHost}` : null;
573
+ const installUrl = baseUrl ? `${baseUrl}/callbook/install#code=${created.code}` : null;
574
+
575
+ return res.json({
576
+ success: true,
577
+ install_url: installUrl,
578
+ expires_at: created.record.expires_at,
579
+ token: {
580
+ id: created.record.id,
581
+ label: created.record.label
582
+ },
583
+ warnings
584
+ });
585
+ });
586
+
587
+ router.get('/callbook/devices', (req, res) => {
588
+ if (!context.callbookStore || !context.callbookStore.isAvailable()) {
589
+ return res.json({ success: true, devices: [], message: 'Callbook storage not available' });
590
+ }
591
+ const includeRevoked = String(req.query.include_revoked || 'false') === 'true';
592
+ const result = context.callbookStore.listDevices({ includeRevoked, limit: req.query.limit || 200 });
593
+ if (!result.success) {
594
+ return res.status(500).json({ success: false, error: result.error || 'callbook_list_failed', message: result.message });
595
+ }
596
+ return res.json({ success: true, devices: result.devices });
597
+ });
598
+
599
+ router.post('/callbook/devices/:deviceId/revoke', (req, res) => {
600
+ if (!context.callbookStore || !context.callbookStore.isAvailable()) {
601
+ return res.status(500).json({ success: false, error: 'callbook_storage_unavailable' });
602
+ }
603
+ const deviceId = sanitizeString(req.params.deviceId, 120);
604
+ const result = context.callbookStore.revokeDevice(deviceId);
605
+ if (!result.success) {
606
+ return res.status(404).json({ success: false, error: result.error || 'device_not_found' });
607
+ }
608
+ return res.json({ success: true, device: result.device });
609
+ });
610
+
611
+ router.post('/callbook/logout', (req, res) => {
612
+ const cookie = buildSetCookie('a2a_callbook_session', '', {
613
+ httpOnly: true,
614
+ sameSite: 'Lax',
615
+ secure: isHttpsRequest(req),
616
+ maxAgeSeconds: 0
265
617
  });
618
+ res.setHeader('Set-Cookie', cookie);
619
+ return res.json({ success: true });
266
620
  });
267
621
 
268
622
  router.get('/logs', (req, res) => {
@@ -362,7 +716,7 @@ function createDashboardApiRouter(options = {}) {
362
716
  });
363
717
 
364
718
  router.get('/contacts', (req, res) => {
365
- const contacts = context.tokenStore.listRemotes();
719
+ const contacts = context.tokenStore.listContacts({ includeLinkedToken: true, includeSecrets: true });
366
720
  const contactIndex = buildContactIndex(contacts);
367
721
  const conversations = context.convStore
368
722
  ? context.convStore.listConversations({
@@ -389,10 +743,12 @@ function createDashboardApiRouter(options = {}) {
389
743
  return String(b.last_message_at || '').localeCompare(String(a.last_message_at || ''));
390
744
  });
391
745
  const latest = calls[0] || null;
746
+
392
747
  return {
393
- ...contact,
748
+ ...toDashboardContact(contact),
394
749
  call_count: calls.length,
395
750
  last_call_at: latest?.last_message_at || null,
751
+ last_call_id: latest?.id || null,
396
752
  last_summary: latest?.summary || null,
397
753
  last_owner_summary: latest?.owner_summary || null
398
754
  };
@@ -401,13 +757,187 @@ function createDashboardApiRouter(options = {}) {
401
757
  res.json({ success: true, contacts: result });
402
758
  });
403
759
 
760
+ router.post('/contacts', (req, res) => {
761
+ const body = req.body || {};
762
+ const inviteUrl = sanitizeString(
763
+ body.invite_url || body.inviteUrl || body.web_address || body.webAddress || body.url || '',
764
+ 600
765
+ );
766
+ const name = sanitizeString(body.name || '', 120) || null;
767
+ const owner = sanitizeString(body.owner || '', 120) || null;
768
+ const isMine = parseBoolean(body.is_mine !== undefined ? body.is_mine : body.isMine);
769
+ const serverName = sanitizeString(body.server_name || body.serverName || '', 120) || null;
770
+ const notes = sanitizeString(body.notes || '', 800) || null;
771
+ const tags = sanitizeStringArray(body.tags || [], 30, 40);
772
+ const fields = (body.fields && typeof body.fields === 'object' && !Array.isArray(body.fields)) ? body.fields : {};
773
+
774
+ if (!inviteUrl) {
775
+ return res.status(400).json({ success: false, error: 'invite_url_required' });
776
+ }
777
+
778
+ try {
779
+ const result = context.tokenStore.addContact(inviteUrl, {
780
+ name: name || undefined,
781
+ owner: owner || undefined,
782
+ is_mine: isMine,
783
+ server_name: serverName || undefined,
784
+ notes: notes || undefined,
785
+ tags: tags || undefined,
786
+ fields: fields || undefined
787
+ });
788
+ if (!result.success) {
789
+ return res.status(409).json({ success: false, error: result.error || 'contact_add_failed', contact: result.existing || null });
790
+ }
791
+ const stored = context.tokenStore.listContacts({ includeLinkedToken: false, includeSecrets: true })
792
+ .find(c => c.id === result.contact.id);
793
+ return res.json({ success: true, contact: stored ? toDashboardContact(stored) : toDashboardContact(result.contact) });
794
+ } catch (err) {
795
+ return res.status(400).json({
796
+ success: false,
797
+ error: 'invalid_invite_url',
798
+ message: err.message || 'Invalid invite URL'
799
+ });
800
+ }
801
+ });
802
+
803
+ router.put('/contacts/:contactId', (req, res) => {
804
+ const contactId = sanitizeString(req.params.contactId, 120);
805
+ if (!contactId) {
806
+ return res.status(400).json({ success: false, error: 'contact_id_required' });
807
+ }
808
+
809
+ const body = req.body || {};
810
+ const updates = {};
811
+ if (body.name !== undefined) updates.name = sanitizeString(body.name, 120);
812
+ if (body.owner !== undefined) updates.owner = sanitizeString(body.owner, 120);
813
+ if (body.is_mine !== undefined) updates.is_mine = parseBoolean(body.is_mine);
814
+ if (body.isMine !== undefined) updates.is_mine = parseBoolean(body.isMine);
815
+ if (body.server_name !== undefined) updates.server_name = sanitizeString(body.server_name, 120);
816
+ if (body.serverName !== undefined) updates.server_name = sanitizeString(body.serverName, 120);
817
+ if (body.notes !== undefined) updates.notes = sanitizeString(body.notes, 800);
818
+ if (body.tags !== undefined) updates.tags = sanitizeStringArray(body.tags, 30, 40);
819
+ if (body.fields !== undefined) updates.fields = body.fields;
820
+ if (body.linked_token_id !== undefined) updates.linked_token_id = sanitizeString(body.linked_token_id, 80);
821
+ if (body.linkedTokenId !== undefined) updates.linked_token_id = sanitizeString(body.linkedTokenId, 80);
822
+
823
+ const result = context.tokenStore.updateContact(contactId, updates);
824
+ if (!result.success) {
825
+ return res.status(404).json({ success: false, error: result.error || 'contact_not_found' });
826
+ }
827
+
828
+ const stored = context.tokenStore.listContacts({ includeLinkedToken: false, includeSecrets: true })
829
+ .find(c => c.id === contactId);
830
+ return res.json({ success: true, contact: stored ? toDashboardContact(stored) : toDashboardContact(result.contact) });
831
+ });
832
+
833
+ router.delete('/contacts/:contactId', (req, res) => {
834
+ const contactId = sanitizeString(req.params.contactId, 120);
835
+ if (!contactId) {
836
+ return res.status(400).json({ success: false, error: 'contact_id_required' });
837
+ }
838
+
839
+ const result = context.tokenStore.removeContact(contactId);
840
+ if (!result.success) {
841
+ return res.status(404).json({ success: false, error: result.error || 'contact_not_found' });
842
+ }
843
+
844
+ return res.json({ success: true, contact: toDashboardContact(result.contact) });
845
+ });
846
+
847
+ router.post('/contacts/:contactId/call', async (req, res) => {
848
+ const contactId = sanitizeString(req.params.contactId, 120);
849
+ if (!contactId) {
850
+ return res.status(400).json({ success: false, error: 'contact_id_required' });
851
+ }
852
+
853
+ const body = req.body || {};
854
+ const message = sanitizeString(body.message || body.msg || '', 10000);
855
+ const timeoutSeconds = Math.max(5, Math.min(300, Number.parseInt(String(body.timeout_seconds || body.timeoutSeconds || '60'), 10) || 60));
856
+ if (!message) {
857
+ return res.status(400).json({ success: false, error: 'message_required', message: 'Message is required' });
858
+ }
859
+
860
+ const contact = context.tokenStore.getContact(contactId);
861
+ if (!contact) {
862
+ return res.status(404).json({ success: false, error: 'contact_not_found' });
863
+ }
864
+ if (!contact.host || !contact.token) {
865
+ return res.status(400).json({ success: false, error: 'contact_not_callable', message: 'This contact has no callable A2A endpoint stored.' });
866
+ }
867
+
868
+ const conversationId = ConversationStore.generateConversationId();
869
+ const client = new A2AClient({
870
+ caller: {
871
+ name: context.agentContext?.name || 'Dashboard',
872
+ owner: context.agentContext?.owner || 'Agent Owner',
873
+ instance: context.config.getAgent?.().hostname || null
874
+ }
875
+ });
876
+
877
+ // Track in local conversation DB (if enabled).
878
+ if (context.convStore) {
879
+ try {
880
+ context.convStore.startConversation({
881
+ id: conversationId,
882
+ contactId: contact.id,
883
+ contactName: contact.name || contact.host,
884
+ tokenId: null,
885
+ direction: 'outbound'
886
+ });
887
+ context.convStore.addMessage(conversationId, {
888
+ direction: 'outbound',
889
+ role: 'user',
890
+ content: message
891
+ });
892
+ } catch (err) {
893
+ // Best effort; call should still go through.
894
+ }
895
+ }
896
+
897
+ const url = `a2a://${contact.host}/${contact.token}`;
898
+ try {
899
+ const result = await client.call(url, message, { conversationId, timeoutSeconds });
900
+ context.tokenStore.updateContactStatus(contact.id, 'online');
901
+
902
+ if (context.convStore) {
903
+ try {
904
+ context.convStore.addMessage(conversationId, {
905
+ direction: 'inbound',
906
+ role: 'assistant',
907
+ content: String(result?.response || '')
908
+ });
909
+ // One-shot outbound dashboard calls should be considered concluded.
910
+ await context.convStore.concludeConversation(conversationId, {});
911
+ } catch (err) {
912
+ // ignore
913
+ }
914
+ }
915
+
916
+ return res.json({
917
+ success: true,
918
+ conversation_id: conversationId,
919
+ response: result?.response || '',
920
+ remote_trace_id: result?.trace_id || null,
921
+ remote_request_id: result?.request_id || null,
922
+ can_continue: result?.can_continue !== false
923
+ });
924
+ } catch (err) {
925
+ context.tokenStore.updateContactStatus(contact.id, 'offline', err.message);
926
+ return res.status(502).json({
927
+ success: false,
928
+ error: 'contact_call_failed',
929
+ message: err.message || 'Call failed'
930
+ });
931
+ }
932
+ });
933
+
404
934
  router.get('/contacts/:contactId/calls', (req, res) => {
405
935
  if (!context.convStore) {
406
936
  return res.json({ success: true, calls: [], message: 'Conversation storage not enabled' });
407
937
  }
408
938
 
409
939
  const contactId = req.params.contactId;
410
- const contacts = context.tokenStore.listRemotes();
940
+ const contacts = context.tokenStore.listContacts({ includeLinkedToken: false, includeSecrets: false });
411
941
  const contactIndex = buildContactIndex(contacts);
412
942
  const contact = contactIndex.byId.get(contactId);
413
943
  if (!contact) {
@@ -446,7 +976,7 @@ function createDashboardApiRouter(options = {}) {
446
976
  includeMessages: false
447
977
  });
448
978
 
449
- const contacts = context.tokenStore.listRemotes();
979
+ const contacts = context.tokenStore.listContacts({ includeLinkedToken: false, includeSecrets: false });
450
980
  const contactIndex = buildContactIndex(contacts);
451
981
  const enriched = calls.map(conv => ({
452
982
  ...conv,
@@ -473,7 +1003,7 @@ function createDashboardApiRouter(options = {}) {
473
1003
  return res.status(404).json({ success: false, error: 'conversation_not_found' });
474
1004
  }
475
1005
 
476
- const contacts = context.tokenStore.listRemotes();
1006
+ const contacts = context.tokenStore.listContacts({ includeLinkedToken: false, includeSecrets: false });
477
1007
  const contactIndex = buildContactIndex(contacts);
478
1008
  const contact = resolveConversationContact({
479
1009
  contact_name: contextData.contact
@@ -511,7 +1041,7 @@ function createDashboardApiRouter(options = {}) {
511
1041
 
512
1042
  res.json({
513
1043
  success: true,
514
- onboarding_complete: cfg.onboardingComplete === true,
1044
+ onboarding_complete: context.config.isOnboarded(),
515
1045
  defaults: cfg.defaults || {},
516
1046
  agent: cfg.agent || {},
517
1047
  tiers,
@@ -672,8 +1202,9 @@ function createDashboardApiRouter(options = {}) {
672
1202
 
673
1203
  const resolvedHost = await resolveInviteHost({
674
1204
  config: context.config,
1205
+ fallbackHost: req.headers.host || process.env.HOSTNAME || 'localhost',
675
1206
  defaultPort: process.env.PORT || process.env.A2A_PORT || 3001,
676
- preferQuickTunnel: true
1207
+ refreshExternalIp: true
677
1208
  });
678
1209
  const host = resolvedHost.host;
679
1210
  const inviteUrl = `a2a://${host}/${token}`;
@@ -716,6 +1247,7 @@ function createDashboardApiRouter(options = {}) {
716
1247
  function createDashboardUiRouter(options = {}) {
717
1248
  const router = express.Router();
718
1249
  const context = buildContext(options);
1250
+ const ensureDashboardAccess = makeEnsureDashboardAccess(context);
719
1251
  router.use(ensureDashboardAccess);
720
1252
 
721
1253
  router.use((req, res, next) => {