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.
package/src/lib/tokens.js CHANGED
@@ -15,6 +15,47 @@ const DEFAULT_CONFIG_DIR = process.env.A2A_CONFIG_DIR ||
15
15
  const DB_FILENAME = 'a2a.json';
16
16
  const logger = createLogger({ component: 'a2a.tokens' });
17
17
 
18
+ function sanitizeCustomFields(fields, options = {}) {
19
+ const maxFields = Number.isFinite(options.maxFields) ? options.maxFields : 200;
20
+ const keyMaxLength = Number.isFinite(options.keyMaxLength) ? options.keyMaxLength : 80;
21
+ const valMaxLength = Number.isFinite(options.valMaxLength) ? options.valMaxLength : 800;
22
+
23
+ if (!fields || typeof fields !== 'object' || Array.isArray(fields)) {
24
+ return {};
25
+ }
26
+
27
+ const cleaned = {};
28
+ const keys = Object.keys(fields);
29
+ let count = 0;
30
+
31
+ for (const rawKey of keys) {
32
+ if (count >= maxFields) break;
33
+ const key = String(rawKey || '').replace(/\s+/g, ' ').trim().slice(0, keyMaxLength);
34
+ if (!key) continue;
35
+
36
+ const rawVal = fields[rawKey];
37
+ let value = '';
38
+ if (rawVal === null || rawVal === undefined) {
39
+ value = '';
40
+ } else if (typeof rawVal === 'string') {
41
+ value = rawVal;
42
+ } else if (typeof rawVal === 'number' || typeof rawVal === 'boolean') {
43
+ value = String(rawVal);
44
+ } else {
45
+ try {
46
+ value = JSON.stringify(rawVal);
47
+ } catch (err) {
48
+ value = String(rawVal);
49
+ }
50
+ }
51
+
52
+ cleaned[key] = String(value).replace(/\s+/g, ' ').trim().slice(0, valMaxLength);
53
+ count++;
54
+ }
55
+
56
+ return cleaned;
57
+ }
58
+
18
59
  class TokenStore {
19
60
  constructor(configDir = DEFAULT_CONFIG_DIR) {
20
61
  this.configDir = configDir;
@@ -31,7 +72,42 @@ class TokenStore {
31
72
  _load() {
32
73
  if (fs.existsSync(this.dbPath)) {
33
74
  try {
34
- return JSON.parse(fs.readFileSync(this.dbPath, 'utf8'));
75
+ const parsed = JSON.parse(fs.readFileSync(this.dbPath, 'utf8'));
76
+ const db = parsed && typeof parsed === 'object' ? parsed : {};
77
+ db.tokens = Array.isArray(db.tokens) ? db.tokens : [];
78
+ db.calls = Array.isArray(db.calls) ? db.calls : [];
79
+ db.contacts = Array.isArray(db.contacts) ? db.contacts : [];
80
+
81
+ // Backward compat: legacy "remotes" is now "contacts".
82
+ const legacyRemotes = Array.isArray(db.remotes) ? db.remotes : [];
83
+ if (legacyRemotes.length) {
84
+ const keyFor = (row) => {
85
+ if (!row || typeof row !== 'object') return null;
86
+ if (row.id) return `id:${row.id}`;
87
+ const host = row.host ? String(row.host) : '';
88
+ const hash = row.token_hash ? String(row.token_hash) : '';
89
+ if (host && hash) return `hosthash:${host}#${hash}`;
90
+ if (host) return `host:${host}`;
91
+ return null;
92
+ };
93
+
94
+ const merged = db.contacts.slice();
95
+ const seen = new Set();
96
+ for (const row of merged) {
97
+ const key = keyFor(row);
98
+ if (key) seen.add(key);
99
+ }
100
+ for (const row of legacyRemotes) {
101
+ const key = keyFor(row);
102
+ if (key && seen.has(key)) continue;
103
+ if (key) seen.add(key);
104
+ merged.push(row);
105
+ }
106
+ db.contacts = merged;
107
+ }
108
+
109
+ // Persisted schema is intentionally minimal during prototyping.
110
+ return { tokens: db.tokens, contacts: db.contacts, calls: db.calls };
35
111
  } catch (e) {
36
112
  // Corrupted file - backup and start fresh
37
113
  const backupPath = `${this.dbPath}.corrupt.${Date.now()}`;
@@ -46,16 +122,22 @@ class TokenStore {
46
122
  backup_path: backupPath
47
123
  }
48
124
  });
49
- return { tokens: [], remotes: [], calls: [] };
125
+ return { tokens: [], contacts: [], calls: [] };
50
126
  }
51
127
  }
52
- return { tokens: [], remotes: [], calls: [] };
128
+ return { tokens: [], contacts: [], calls: [] };
53
129
  }
54
130
 
55
131
  _save(db) {
132
+ const persisted = {
133
+ tokens: Array.isArray(db.tokens) ? db.tokens : [],
134
+ contacts: Array.isArray(db.contacts) ? db.contacts : [],
135
+ calls: Array.isArray(db.calls) ? db.calls : []
136
+ };
137
+
56
138
  // Atomic write: write to temp file, then rename
57
139
  const tmpPath = `${this.dbPath}.tmp.${process.pid}`;
58
- fs.writeFileSync(tmpPath, JSON.stringify(db, null, 2), { mode: 0o600 });
140
+ fs.writeFileSync(tmpPath, JSON.stringify(persisted, null, 2), { mode: 0o600 });
59
141
  fs.renameSync(tmpPath, this.dbPath);
60
142
  try {
61
143
  fs.chmodSync(this.dbPath, 0o600);
@@ -117,8 +199,10 @@ class TokenStore {
117
199
  tierSettings = null // Object with tier-specific settings
118
200
  } = options;
119
201
 
120
- // Map legacy tier values to labels
121
- const tier = TokenStore.LEGACY_TIER_MAP[permissions] || permissions;
202
+ const tier = String(permissions || 'public').trim() || 'public';
203
+ if (!TokenStore.VALID_TIERS.includes(tier)) {
204
+ throw new Error(`Invalid permissions tier: ${tier}. Expected: ${TokenStore.VALID_TIERS.join('|')}`);
205
+ }
122
206
 
123
207
  const token = TokenStore.generateToken();
124
208
  const tokenHash = TokenStore.hashToken(token);
@@ -144,14 +228,16 @@ class TokenStore {
144
228
  const defaultTopics = {
145
229
  'public': configTiers.public?.topics || ['chat'],
146
230
  'friends': configTiers.friends?.topics || ['chat', 'calendar.read', 'email.read', 'search'],
147
- 'family': configTiers.family?.topics || ['chat', 'calendar', 'email', 'search', 'tools']
231
+ 'family': configTiers.family?.topics || ['chat', 'calendar', 'email', 'search', 'tools'],
232
+ 'custom': configTiers.custom?.topics || ['chat']
148
233
  };
149
234
 
150
235
  // Default goals based on tier label (snapshot at creation)
151
236
  const defaultGoals = {
152
237
  'public': configTiers.public?.goals || [],
153
238
  'friends': configTiers.friends?.goals || [],
154
- 'family': configTiers.family?.goals || []
239
+ 'family': configTiers.family?.goals || [],
240
+ 'custom': configTiers.custom?.goals || []
155
241
  };
156
242
 
157
243
  // Resolve capabilities: explicit > config > defaults
@@ -231,8 +317,10 @@ class TokenStore {
231
317
  record.last_used = new Date().toISOString();
232
318
  this._save(db);
233
319
 
234
- // Map legacy tier values to labels for old records
235
- const tier = TokenStore.LEGACY_TIER_MAP[record.tier] || record.tier || record.permissions || 'public';
320
+ const tier = record.tier || 'public';
321
+ if (!TokenStore.VALID_TIERS.includes(tier)) {
322
+ return { valid: false, error: 'invalid_token_tier' };
323
+ }
236
324
 
237
325
  // Resolve capabilities: stored > defaults
238
326
  const capabilities = record.capabilities
@@ -280,100 +368,155 @@ class TokenStore {
280
368
  * @param {object} options - Contact metadata
281
369
  * @param {string} options.name - Agent name
282
370
  * @param {string} options.owner - Human owner name
371
+ * @param {string} options.server_name - Server label (owner-local / optional)
283
372
  * @param {string} options.notes - Freeform notes
284
373
  * @param {string[]} options.tags - Grouping tags
374
+ * @param {object} options.fields - Flexible CRM-like fields (key/value)
285
375
  * @param {string} options.trust - Trust level (trusted, verified, unknown)
286
376
  */
287
- addRemote(inviteUrl, options = {}) {
288
- // Handle legacy signature: addRemote(url, name)
289
- if (typeof options === 'string') {
290
- options = { name: options };
291
- }
292
-
293
- const match = inviteUrl.match(/^(?:a2a|oclaw):\/\/([^/]+)\/(.+)$/);
377
+ addContact(inviteUrl, options = {}) {
378
+ const match = String(inviteUrl || '').match(/^a2a:\/\/([^/]+)\/(.+)$/);
294
379
  if (!match) {
295
380
  throw new Error(`Invalid invite URL: ${inviteUrl}. Expected format: a2a://host/token`);
296
381
  }
297
382
 
298
- const [, host, token] = match;
299
- const remoteName = options.name || host;
383
+ const [, host, token] = match;
384
+ const agentName = options.name || host;
385
+ const rawMine = options.is_mine !== undefined ? options.is_mine : options.isMine;
386
+ const isMine = (() => {
387
+ if (rawMine === null || rawMine === undefined) return false;
388
+ if (typeof rawMine === 'boolean') return rawMine;
389
+ if (typeof rawMine === 'string') {
390
+ const s = rawMine.trim().toLowerCase();
391
+ if (['true', '1', 'yes', 'y', 'on'].includes(s)) return true;
392
+ if (['false', '0', 'no', 'n', 'off', ''].includes(s)) return false;
393
+ }
394
+ return Boolean(rawMine);
395
+ })();
300
396
 
301
397
  const db = this._load();
302
-
398
+
303
399
  // Check for duplicate by host + token hash
304
400
  const tokenHash = TokenStore.hashToken(token);
305
- const existing = db.remotes.find(r => r.host === host && r.token_hash === tokenHash);
401
+ const existing = (db.contacts || []).find(r => r.host === host && r.token_hash === tokenHash);
306
402
  if (existing) {
307
403
  return { success: false, error: 'duplicate', existing };
308
404
  }
309
405
 
310
- // Encrypt token for storage (simple XOR with derived key - not production-grade but better than plaintext)
311
- const encryptionKey = crypto.createHash('sha256').update(this.dbPath + 'remote-key').digest();
406
+ // Encrypt token for storage (simple XOR with derived key - not production-grade but better than plaintext).
407
+ // Keep using the legacy key suffix so older "remotes" records remain decryptable.
408
+ const encryptionKey = crypto.createHash('sha256').update(this.dbPath + TokenStore.LEGACY_REMOTE_KEY_SUFFIX).digest();
312
409
  const tokenBuffer = Buffer.from(token, 'utf8');
313
410
  const encrypted = Buffer.alloc(tokenBuffer.length);
314
411
  for (let i = 0; i < tokenBuffer.length; i++) {
315
412
  encrypted[i] = tokenBuffer[i] ^ encryptionKey[i % encryptionKey.length];
316
413
  }
317
414
 
318
- const remote = {
319
- id: crypto.randomBytes(8).toString('hex'),
320
- name: remoteName,
321
- owner: options.owner || null,
322
- host,
323
- token_hash: tokenHash,
324
- token_enc: encrypted.toString('base64'),
325
- notes: options.notes || null,
326
- tags: options.tags || [],
327
- linked_token_id: options.linkedTokenId || null, // Token you gave them
415
+ const contact = {
416
+ id: crypto.randomBytes(8).toString('hex'),
417
+ name: agentName,
418
+ owner: options.owner || null,
419
+ is_mine: isMine,
420
+ host,
421
+ token_hash: tokenHash,
422
+ token_enc: encrypted.toString('base64'),
423
+ server_name: options.server_name || options.serverName || null,
424
+ notes: options.notes || null,
425
+ tags: Array.isArray(options.tags) ? options.tags : [],
426
+ fields: sanitizeCustomFields(options.fields || options.custom_fields || options.customFields),
427
+ linked_token_id: options.linkedTokenId || options.linked_token_id || null, // Token you gave them
328
428
  status: 'unknown',
329
429
  last_seen: null,
330
- added_at: new Date().toISOString()
430
+ added_at: new Date().toISOString(),
431
+ updated_at: null
331
432
  };
332
433
 
333
- db.remotes.push(remote);
434
+ db.contacts = db.contacts || [];
435
+ db.contacts.push(contact);
334
436
  this._save(db);
335
437
 
336
- return { success: true, remote: { ...remote, token: undefined, token_enc: undefined } };
438
+ return { success: true, contact: { ...contact, token: undefined, token_enc: undefined } };
439
+ }
440
+
441
+ // Legacy wrapper.
442
+ addRemote(inviteUrl, options = {}) {
443
+ const result = this.addContact(inviteUrl, options);
444
+ if (!result.success) return result;
445
+ return { success: true, remote: result.contact };
337
446
  }
338
447
 
339
448
  /**
340
- * Decrypt a remote token
449
+ * Decrypt a contact token
341
450
  */
342
- _decryptRemoteToken(remote) {
343
- if (remote.token) return remote.token; // Legacy plaintext
344
- if (!remote.token_enc) return null;
345
-
346
- const encryptionKey = crypto.createHash('sha256').update(this.dbPath + 'remote-key').digest();
347
- const encrypted = Buffer.from(remote.token_enc, 'base64');
348
- const decrypted = Buffer.alloc(encrypted.length);
349
- for (let i = 0; i < encrypted.length; i++) {
350
- decrypted[i] = encrypted[i] ^ encryptionKey[i % encryptionKey.length];
451
+ _decryptContactToken(contact) {
452
+ if (!contact.token_enc) return null;
453
+
454
+ let encrypted;
455
+ try {
456
+ encrypted = Buffer.from(contact.token_enc, 'base64');
457
+ } catch (err) {
458
+ return null;
459
+ }
460
+
461
+ // Try legacy key first, then the newer "contact-key" suffix (in case we ever wrote it).
462
+ const suffixes = [
463
+ TokenStore.LEGACY_REMOTE_KEY_SUFFIX,
464
+ TokenStore.CONTACT_KEY_SUFFIX
465
+ ].filter(Boolean);
466
+
467
+ for (const suffix of [...new Set(suffixes)]) {
468
+ const encryptionKey = crypto.createHash('sha256').update(this.dbPath + suffix).digest();
469
+ const decrypted = Buffer.alloc(encrypted.length);
470
+ for (let i = 0; i < encrypted.length; i++) {
471
+ decrypted[i] = encrypted[i] ^ encryptionKey[i % encryptionKey.length];
472
+ }
473
+ const token = decrypted.toString('utf8');
474
+ if (/^fed_[A-Za-z0-9_-]{10,}$/.test(token)) {
475
+ return token;
476
+ }
351
477
  }
352
- return decrypted.toString('utf8');
478
+
479
+ return null;
353
480
  }
354
481
 
355
482
  /**
356
- * List remote agents with linked token info
483
+ * List contacts (optionally with linked token info / secrets)
357
484
  */
358
- listRemotes() {
485
+ listContacts(options = {}) {
486
+ const includeLinkedToken = options.includeLinkedToken !== false;
487
+ const includeSecrets = options.includeSecrets === true;
359
488
  const db = this._load();
360
- return db.remotes.map(r => {
361
- if (r.linked_token_id) {
362
- const token = db.tokens.find(t => t.id === r.linked_token_id);
489
+
490
+ const contacts = Array.isArray(db.contacts) ? db.contacts : [];
491
+ return contacts.map(row => {
492
+ const base = { ...row };
493
+ if (!includeSecrets) {
494
+ base.token_hash = undefined;
495
+ base.token_enc = undefined;
496
+ }
497
+
498
+ if (includeLinkedToken && base.linked_token_id) {
499
+ const token = (db.tokens || []).find(t => t.id === base.linked_token_id);
363
500
  if (token) {
364
- return { ...r, linked_token: token };
501
+ base.linked_token = token;
365
502
  }
366
503
  }
367
- return r;
504
+
505
+ return base;
368
506
  });
369
507
  }
370
508
 
509
+ // Legacy wrapper.
510
+ listRemotes(options = {}) {
511
+ return this.listContacts(options);
512
+ }
513
+
371
514
  /**
372
515
  * Link a token to a contact
373
516
  */
374
517
  linkTokenToContact(contactNameOrId, tokenId) {
375
518
  const db = this._load();
376
- const remote = db.remotes.find(r =>
519
+ const remote = (db.contacts || []).find(r =>
377
520
  r.name === contactNameOrId || r.id === contactNameOrId
378
521
  );
379
522
  const token = db.tokens.find(t =>
@@ -385,15 +528,15 @@ class TokenStore {
385
528
 
386
529
  remote.linked_token_id = token.id;
387
530
  this._save(db);
388
- return { success: true, remote, token };
531
+ return { success: true, contact: remote, remote, token };
389
532
  }
390
533
 
391
534
  /**
392
- * Get a remote by name or host (with decrypted token)
535
+ * Get a contact by name/host/id (with decrypted token)
393
536
  */
394
- getRemote(nameOrHost) {
537
+ getContact(nameOrHost) {
395
538
  const db = this._load();
396
- const remote = db.remotes.find(r =>
539
+ const remote = (db.contacts || []).find(r =>
397
540
  r.name === nameOrHost ||
398
541
  r.host === nameOrHost ||
399
542
  r.id === nameOrHost
@@ -403,45 +546,84 @@ class TokenStore {
403
546
  // Return with decrypted token
404
547
  return {
405
548
  ...remote,
406
- token: this._decryptRemoteToken(remote),
549
+ token: this._decryptContactToken(remote),
407
550
  token_enc: undefined
408
551
  };
409
552
  }
410
553
 
411
554
  /**
412
- * Update a remote agent's metadata
555
+ * Legacy wrapper.
413
556
  */
414
- updateRemote(nameOrHost, updates) {
415
- const db = this._load();
416
- const remote = db.remotes.find(r =>
417
- r.name === nameOrHost ||
418
- r.host === nameOrHost ||
419
- r.id === nameOrHost
420
- );
557
+ getRemote(nameOrHost) {
558
+ return this.getContact(nameOrHost);
559
+ }
560
+
561
+ /**
562
+ * Update a contact's metadata
563
+ */
564
+ updateContact(nameOrHost, updates) {
565
+ const db = this._load();
566
+ const remote = (db.contacts || []).find(r =>
567
+ r.name === nameOrHost ||
568
+ r.host === nameOrHost ||
569
+ r.id === nameOrHost
570
+ );
421
571
 
422
572
  if (!remote) {
423
573
  return { success: false, error: 'not_found' };
424
- }
425
-
426
- // Only allow updating specific fields
427
- const allowed = ['name', 'owner', 'notes', 'tags', 'linked_token_id'];
428
- for (const key of allowed) {
429
- if (updates[key] !== undefined) {
430
- remote[key] = updates[key];
431
- }
432
- }
574
+ }
575
+
576
+ // Only allow updating specific fields
577
+ const allowed = ['name', 'owner', 'is_mine', 'notes', 'tags', 'linked_token_id', 'server_name', 'fields'];
578
+ for (const key of allowed) {
579
+ if (updates[key] !== undefined) {
580
+ if (key === 'fields') {
581
+ if (updates.fields === null) {
582
+ remote.fields = {};
583
+ } else {
584
+ remote.fields = {
585
+ ...(remote.fields && typeof remote.fields === 'object' ? remote.fields : {}),
586
+ ...sanitizeCustomFields(updates.fields)
587
+ };
588
+ }
589
+ } else if (key === 'is_mine') {
590
+ const raw = updates.is_mine;
591
+ if (raw === null) {
592
+ remote.is_mine = false;
593
+ } else if (typeof raw === 'boolean') {
594
+ remote.is_mine = raw;
595
+ } else if (typeof raw === 'string') {
596
+ const s = raw.trim().toLowerCase();
597
+ if (['true', '1', 'yes', 'y', 'on'].includes(s)) remote.is_mine = true;
598
+ else if (['false', '0', 'no', 'n', 'off', ''].includes(s)) remote.is_mine = false;
599
+ else remote.is_mine = Boolean(raw);
600
+ } else {
601
+ remote.is_mine = Boolean(raw);
602
+ }
603
+ } else {
604
+ remote[key] = updates[key];
605
+ }
606
+ }
607
+ }
433
608
  remote.updated_at = new Date().toISOString();
434
609
 
435
610
  this._save(db);
436
- return { success: true, remote };
611
+ return { success: true, contact: remote, remote };
612
+ }
613
+
614
+ /**
615
+ * Legacy wrapper.
616
+ */
617
+ updateRemote(nameOrHost, updates) {
618
+ return this.updateContact(nameOrHost, updates);
437
619
  }
438
620
 
439
621
  /**
440
622
  * Update contact status after ping/call
441
623
  */
442
- updateRemoteStatus(nameOrHost, status, error = null) {
624
+ updateContactStatus(nameOrHost, status, error = null) {
443
625
  const db = this._load();
444
- const remote = db.remotes.find(r =>
626
+ const remote = (db.contacts || []).find(r =>
445
627
  r.name === nameOrHost ||
446
628
  r.host === nameOrHost ||
447
629
  r.id === nameOrHost
@@ -458,11 +640,18 @@ class TokenStore {
458
640
  }
459
641
 
460
642
  /**
461
- * Remove a remote agent
643
+ * Legacy wrapper.
462
644
  */
463
- removeRemote(nameOrHost) {
645
+ updateRemoteStatus(nameOrHost, status, error = null) {
646
+ return this.updateContactStatus(nameOrHost, status, error);
647
+ }
648
+
649
+ /**
650
+ * Remove a contact
651
+ */
652
+ removeContact(nameOrHost) {
464
653
  const db = this._load();
465
- const idx = db.remotes.findIndex(r =>
654
+ const idx = (db.contacts || []).findIndex(r =>
466
655
  r.name === nameOrHost ||
467
656
  r.host === nameOrHost ||
468
657
  r.id === nameOrHost
@@ -472,9 +661,14 @@ class TokenStore {
472
661
  return { success: false, error: 'not_found' };
473
662
  }
474
663
 
475
- const [removed] = db.remotes.splice(idx, 1);
664
+ const [removed] = db.contacts.splice(idx, 1);
476
665
  this._save(db);
477
- return { success: true, remote: removed };
666
+ return { success: true, contact: removed, remote: removed };
667
+ }
668
+
669
+ // Legacy wrapper.
670
+ removeRemote(nameOrHost) {
671
+ return this.removeContact(nameOrHost);
478
672
  }
479
673
 
480
674
  /**
@@ -489,7 +683,7 @@ class TokenStore {
489
683
  * This allows mapping call history (SQLite contact_id=tok_...) to a contact
490
684
  * row in the dashboard via linked_token_id.
491
685
  */
492
- ensureInboundContact(caller, tokenId) {
686
+ ensureInboundContact(caller, tokenId) {
493
687
  if (!caller || !caller.name) {
494
688
  return null;
495
689
  }
@@ -498,70 +692,77 @@ class TokenStore {
498
692
  const owner = caller.owner ? String(caller.owner).trim().slice(0, 120) : null;
499
693
 
500
694
  const db = this._load();
501
- db.remotes = db.remotes || [];
695
+ db.contacts = db.contacts || [];
502
696
 
503
697
  // Prefer stable linking by the token used for inbound auth.
504
698
  let remote = tokenId
505
- ? db.remotes.find(r => r.linked_token_id === tokenId)
699
+ ? db.contacts.find(r => r.linked_token_id === tokenId)
506
700
  : null;
507
701
 
508
702
  // Fallback match by agent name/owner (less reliable, but helpful).
509
703
  if (!remote) {
510
- remote = db.remotes.find(r => r.name === name || (owner && r.owner === owner));
704
+ remote = db.contacts.find(r => r.name === name || (owner && r.owner === owner));
511
705
  }
512
706
 
513
- if (remote) {
514
- remote.name = remote.name || name;
515
- if (owner && !remote.owner) {
516
- remote.owner = owner;
517
- }
518
- if (tokenId && !remote.linked_token_id) {
519
- remote.linked_token_id = tokenId;
520
- }
707
+ if (remote) {
708
+ remote.name = remote.name || name;
709
+ if (owner && !remote.owner) {
710
+ remote.owner = owner;
711
+ }
712
+ if (remote.is_mine === undefined) {
713
+ remote.is_mine = false;
714
+ }
715
+ if (tokenId && !remote.linked_token_id) {
716
+ remote.linked_token_id = tokenId;
717
+ }
521
718
  remote.host = remote.host || 'inbound';
522
719
  remote.tags = Array.isArray(remote.tags) ? remote.tags : [];
523
720
  if (!remote.tags.includes('inbound')) {
524
721
  remote.tags.push('inbound');
525
722
  }
723
+ remote.fields = remote.fields && typeof remote.fields === 'object' ? remote.fields : {};
724
+ remote.server_name = remote.server_name || null;
526
725
  remote.status = remote.status || 'unknown';
527
726
  remote.updated_at = new Date().toISOString();
528
727
  this._save(db);
529
728
  return remote;
530
729
  }
531
730
 
532
- const contact = {
533
- id: crypto.randomBytes(8).toString('hex'),
534
- name,
535
- owner,
536
- host: 'inbound',
537
- token_hash: null,
538
- token_enc: null,
731
+ const contact = {
732
+ id: crypto.randomBytes(8).toString('hex'),
733
+ name,
734
+ owner,
735
+ is_mine: false,
736
+ host: 'inbound',
737
+ token_hash: null,
738
+ token_enc: null,
739
+ server_name: null,
539
740
  notes: tokenId ? `Inbound caller via token ${tokenId}` : 'Inbound caller',
540
741
  tags: ['inbound'],
742
+ fields: {},
541
743
  linked_token_id: tokenId || null,
542
744
  status: 'unknown',
543
745
  last_seen: null,
544
- added_at: new Date().toISOString()
746
+ added_at: new Date().toISOString(),
747
+ updated_at: null
545
748
  };
546
749
 
547
- db.remotes.push(contact);
750
+ db.contacts.push(contact);
548
751
  this._save(db);
549
752
  return contact;
550
753
  }
551
754
  }
552
755
 
553
- // Legacy tier values from old records → label mapping
554
- TokenStore.LEGACY_TIER_MAP = {
555
- 'chat-only': 'public',
556
- 'tools-read': 'friends',
557
- 'tools-write': 'family'
558
- };
756
+ TokenStore.VALID_TIERS = ['public', 'friends', 'family', 'custom'];
757
+ TokenStore.LEGACY_REMOTE_KEY_SUFFIX = 'remote-key';
758
+ TokenStore.CONTACT_KEY_SUFFIX = 'contact-key';
559
759
 
560
760
  // Default capabilities per tier label (used when config has none)
561
761
  TokenStore.DEFAULT_CAPABILITIES = {
562
762
  'public': ['context-read'],
563
763
  'friends': ['context-read', 'calendar.read', 'email.read', 'search'],
564
- 'family': ['context-read', 'calendar', 'email', 'search', 'tools', 'memory']
764
+ 'family': ['context-read', 'calendar', 'email', 'search', 'tools', 'memory'],
765
+ 'custom': ['context-read']
565
766
  };
566
767
 
567
768
  module.exports = { TokenStore };
package/src/routes/a2a.js CHANGED
@@ -340,13 +340,22 @@ function createRoutes(options = {}) {
340
340
  request_id: requestId
341
341
  };
342
342
 
343
+ // Ensure inbound caller exists as a contact (best-effort).
344
+ let ensuredContact = null;
345
+ try {
346
+ ensuredContact = tokenStore.ensureInboundContact(sanitizedCaller, validation.id);
347
+ } catch (err) {
348
+ ensuredContact = null;
349
+ }
350
+
343
351
  // Track conversation if store available
344
352
  if (convStore) {
345
353
  try {
346
354
  convStore.startConversation({
347
355
  id: a2aContext.conversation_id,
348
- contactId: validation.id,
349
- contactName: sanitizedCaller.name || validation.name,
356
+ // Standardize: store the local contact.id when available (fallback to token id otherwise).
357
+ contactId: ensuredContact?.id || validation.id,
358
+ contactName: ensuredContact?.name || sanitizedCaller.name || validation.name,
350
359
  tokenId: validation.id,
351
360
  direction: 'inbound'
352
361
  });