total-recall-brain 3.18.2 → 3.18.3

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.
Files changed (45) hide show
  1. package/frontend/dist/assets/index-CGqvdwHT.js +5742 -0
  2. package/frontend/dist/index.html +1 -1
  3. package/frontend/src/api/config.ts +16 -2
  4. package/frontend/src/api/keys.ts +111 -0
  5. package/frontend/src/api/notifications.ts +54 -6
  6. package/frontend/src/api/update.ts +12 -1
  7. package/frontend/src/components/BrainSelector.spec.tsx +71 -47
  8. package/frontend/src/components/BrainSelector.tsx +112 -35
  9. package/frontend/src/components/MobilePairing.spec.tsx +44 -14
  10. package/frontend/src/components/MobilePairing.tsx +229 -22
  11. package/frontend/src/pages/NotificationsPage.tsx +6 -3
  12. package/frontend/src/pages/RulesPage.spec.tsx +46 -15
  13. package/frontend/src/pages/RulesPage.tsx +41 -9
  14. package/frontend/src/pages/SecretsPage.tsx +316 -3
  15. package/frontend/src/pages/SettingsPage.spec.tsx +30 -6
  16. package/frontend/src/pages/SettingsPage.tsx +206 -85
  17. package/package.json +1 -1
  18. package/src/cli/agent-dir.mjs +2 -1
  19. package/src/cli/secret.mjs +180 -3
  20. package/src/core/daemon-loop.mjs +31 -5
  21. package/src/core/notifications.mjs +204 -34
  22. package/src/core/package-auto-update.mjs +99 -3
  23. package/src/core/pairing.mjs +168 -0
  24. package/src/core/provider-account-sync.mjs +1145 -0
  25. package/src/core/provider-catalog.mjs +11 -4
  26. package/src/core/research.mjs +98 -24
  27. package/src/core/scheduler.mjs +31 -9
  28. package/src/core/schema.mjs +23 -0
  29. package/src/core/secret-integration-research.mjs +325 -0
  30. package/src/core/secrets-store.mjs +278 -14
  31. package/src/core/secrets-sync.mjs +1 -1
  32. package/src/core/source-adapters.mjs +247 -22
  33. package/src/core/task-executors.mjs +161 -14
  34. package/src/core/webhook-handlers.mjs +123 -14
  35. package/src/server/auth.mjs +6 -0
  36. package/src/server/rest.mjs +2 -0
  37. package/src/server/route-manifest.json +24 -0
  38. package/src/server/routes/config.mjs +64 -23
  39. package/src/server/routes/mesh.mjs +2 -1
  40. package/src/server/routes/notifications.mjs +103 -0
  41. package/src/server/routes/rules.mjs +75 -20
  42. package/src/server/routes/secrets.mjs +110 -4
  43. package/src/server/routes/system.mjs +20 -1
  44. package/src/server/routes/update.mjs +11 -4
  45. package/frontend/dist/assets/index-D5xRw5uq.js +0 -5750
@@ -294,6 +294,25 @@ export function mergeSecretMeta(prev = {}, patch = {}) {
294
294
  assign('notes', (v) => (v == null || v === '' ? null : String(v)));
295
295
  assign('project_path', (v) => (v == null || v === '' ? null : String(v)));
296
296
  assign('headscale_url', (v) => (v == null || v === '' ? null : String(v)));
297
+ // Live provider account / usage / subscription tracking (provider-account-sync)
298
+ assign('tracking_exempt', (v) => !!v);
299
+ assign('tracking_status', (v) => {
300
+ if (v == null || v === '') return null;
301
+ const s = String(v).toLowerCase();
302
+ return ['ok', 'partial', 'error', 'exempt', 'never'].includes(s) ? s : 'error';
303
+ });
304
+ assign('tracking_error', (v) => (v == null || v === '' ? null : String(v)));
305
+ assign('tracking_synced_at', (v) => (v == null || v === '' ? null : String(v)));
306
+ assign('tracking_probe', (v) => (v == null || v === '' ? null : String(v)));
307
+ assign('tracking_account', (v) => (v == null ? null : v));
308
+ assign('tracking_usage', (v) => (v == null ? null : v));
309
+ assign('tracking_subscription', (v) => (v == null ? null : v));
310
+ assign('account_api', (v) => (v == null ? null : !!v));
311
+ assign('usage_api', (v) => (v == null ? null : !!v));
312
+ assign('subscription_api', (v) => (v == null ? null : !!v));
313
+ assign('key_valid', (v) => (v == null ? null : !!v));
314
+ // Operator waiver: intentional shared credential (rare — prefer unique keys per app)
315
+ assign('shared_value_ok', (v) => !!v);
297
316
  if (patch.updated_at) next.updated_at = patch.updated_at;
298
317
  if (patch.rotated_at) next.rotated_at = patch.rotated_at;
299
318
  if (patch.created_at && !next.created_at) next.created_at = patch.created_at;
@@ -312,8 +331,115 @@ export function nextRotateDue(meta = {}) {
312
331
  return new Date(t).toISOString();
313
332
  }
314
333
 
334
+ /** Canonical app label for repo binding (empty = developer/global). */
335
+ export function appLabelForSecret(row = {}) {
336
+ if (row.repo) return String(row.repo);
337
+ if (Array.isArray(row.repos) && row.repos.length === 1) return String(row.repos[0]);
338
+ if (Array.isArray(row.repos) && row.repos.length > 1) return row.repos.join('+');
339
+ return 'developer';
340
+ }
341
+
342
+ /**
343
+ * Full value fingerprint (never truncated — used for equality).
344
+ * @param {string} value
345
+ */
346
+ export function secretValueFingerprint(value) {
347
+ if (value == null || value === '') return null;
348
+ return crypto.createHash('sha256').update(String(value)).digest('hex');
349
+ }
350
+
351
+ /**
352
+ * Build shared-value groups from rows that already have fingerprint_full + set.
353
+ * Same credential material under multiple key names / apps must be an ERROR so
354
+ * each app can get its own rotated key.
355
+ *
356
+ * @param {Array<object>} rows - secret meta rows (with fingerprint_full)
357
+ * @returns {{ groups: object[], byKey: Map<string, object> }}
358
+ */
359
+ export function buildSharedValueIndex(rows = []) {
360
+ const byFp = new Map();
361
+ for (const row of rows) {
362
+ if (!row?.set || !row.fingerprint_full) continue;
363
+ // Skip pure internal passwords that are intentionally shared (e.g. same JWT across?)
364
+ // No — still flag; operator can mark shared_value_ok.
365
+ if (!byFp.has(row.fingerprint_full)) byFp.set(row.fingerprint_full, []);
366
+ byFp.get(row.fingerprint_full).push(row);
367
+ }
368
+
369
+ const groups = [];
370
+ const byKey = new Map();
371
+
372
+ for (const [fp, members] of byFp) {
373
+ if (members.length < 2) continue;
374
+ const apps = [...new Set(members.map((m) => appLabelForSecret(m)))].sort();
375
+ const keys = members.map((m) => m.key).sort();
376
+ const providers = [...new Set(members.map((m) => m.provider).filter(Boolean))];
377
+ const multiApp = apps.length > 1;
378
+ const multiKey = keys.length > 1;
379
+ // Error unless every member is operator-waived
380
+ const allOk = members.every((m) => m.shared_value_ok === true);
381
+ let severity = 'error';
382
+ let error = null;
383
+ if (allOk) {
384
+ severity = 'ok';
385
+ error = null;
386
+ } else if (multiApp) {
387
+ severity = 'error';
388
+ error = `SHARED CREDENTIAL across apps/repos [${apps.join(', ')}] as keys [${keys.join(', ')}]. Issue a unique API key per app and re-bind.`;
389
+ } else {
390
+ // Same app/developer but duplicated under multiple secret names — still force unique
391
+ severity = 'error';
392
+ error = `SHARED CREDENTIAL value stored under ${keys.length} secret names [${keys.join(', ')}] (app=${apps[0]}). Use one key name or issue distinct values per purpose.`;
393
+ }
394
+
395
+ const group = {
396
+ fingerprint: fp.slice(0, 12),
397
+ fingerprint_full: fp,
398
+ count: members.length,
399
+ apps,
400
+ multi_app: multiApp,
401
+ multi_key: multiKey,
402
+ keys,
403
+ providers,
404
+ severity,
405
+ error,
406
+ members: members.map((m) => ({
407
+ key: m.key,
408
+ repo: m.repo || null,
409
+ repos: m.repos || [],
410
+ app: appLabelForSecret(m),
411
+ provider: m.provider || null,
412
+ masked: m.masked || null,
413
+ shared_value_ok: !!m.shared_value_ok,
414
+ })),
415
+ };
416
+ groups.push(group);
417
+ for (const m of members) {
418
+ byKey.set(m.key, {
419
+ shared_value: true,
420
+ shared_with: members
421
+ .filter((o) => o.key !== m.key)
422
+ .map((o) => ({
423
+ key: o.key,
424
+ app: appLabelForSecret(o),
425
+ repo: o.repo || null,
426
+ provider: o.provider || null,
427
+ })),
428
+ shared_apps: apps,
429
+ shared_value_error: error,
430
+ shared_value_severity: severity,
431
+ shared_fingerprint: fp.slice(0, 12),
432
+ });
433
+ }
434
+ }
435
+
436
+ groups.sort((a, b) => b.count - a.count || a.fingerprint.localeCompare(b.fingerprint));
437
+ return { groups, byKey };
438
+ }
439
+
315
440
  /**
316
441
  * List secret keys with rich metadata only (no values).
442
+ * Annotates shared-value groups (same credential material under multiple keys/apps).
317
443
  */
318
444
  export async function listSecretsMeta(brainDir) {
319
445
  const { getProvider, providerForKeyName } = await import('./provider-catalog.mjs');
@@ -321,7 +447,7 @@ export async function listSecretsMeta(brainDir) {
321
447
  const meta = secrets[META_KEY].keys || {};
322
448
  const usageByKey = summarizeUsageByKey(brainDir, { days: 30 });
323
449
  const keys = Object.keys(secrets).filter((k) => k !== META_KEY);
324
- return keys.map((key) => {
450
+ const rows = keys.map((key) => {
325
451
  const m = meta[key] || {};
326
452
  const val = secrets[key];
327
453
  const providerId = m.provider || providerForKeyName(key)?.id || null;
@@ -336,14 +462,14 @@ export async function listSecretsMeta(brainDir) {
336
462
  output_tokens: 0,
337
463
  };
338
464
  const repos = Array.isArray(m.repos) ? m.repos.filter(Boolean) : [];
465
+ const fpFull =
466
+ typeof val === 'string' && val.length ? secretValueFingerprint(val) : null;
339
467
  return {
340
468
  key,
341
469
  set: val !== undefined && val !== null && val !== '',
342
470
  length: typeof val === 'string' ? val.length : val != null ? String(val).length : 0,
343
- fingerprint:
344
- typeof val === 'string' && val.length
345
- ? crypto.createHash('sha256').update(val).digest('hex').slice(0, 12)
346
- : null,
471
+ fingerprint: fpFull ? fpFull.slice(0, 12) : null,
472
+ fingerprint_full: fpFull,
347
473
  masked:
348
474
  typeof val === 'string' && val.length > 8
349
475
  ? `${val.slice(0, 4)}…${val.slice(-4)}`
@@ -379,10 +505,99 @@ export async function listSecretsMeta(brainDir) {
379
505
  headscale_url: m.headscale_url || null,
380
506
  usage_30d: usage,
381
507
  tiers: catalog?.tiers || [],
508
+ // Provider account/usage tracking (must be ok or exempt — else error)
509
+ tracking_exempt: !!m.tracking_exempt,
510
+ tracking_status: m.tracking_status || (m.tracking_exempt ? 'exempt' : null),
511
+ tracking_error: m.tracking_error || null,
512
+ tracking_synced_at: m.tracking_synced_at || null,
513
+ tracking_probe: m.tracking_probe || null,
514
+ tracking_account: m.tracking_account || null,
515
+ tracking_usage: m.tracking_usage || null,
516
+ tracking_subscription: m.tracking_subscription || null,
517
+ account_api: m.account_api ?? null,
518
+ usage_api: m.usage_api ?? null,
519
+ subscription_api: m.subscription_api ?? null,
520
+ key_valid: m.key_valid ?? null,
521
+ shared_value_ok: !!m.shared_value_ok,
522
+ };
523
+ });
524
+
525
+ const { byKey: sharedByKey } = buildSharedValueIndex(rows);
526
+ return rows.map((row) => {
527
+ const shared = sharedByKey.get(row.key);
528
+ const { fingerprint_full: _full, ...publicRow } = row;
529
+ if (!shared) {
530
+ return {
531
+ ...publicRow,
532
+ shared_value: false,
533
+ shared_with: [],
534
+ shared_apps: [],
535
+ shared_value_error: null,
536
+ shared_value_severity: null,
537
+ };
538
+ }
539
+ return {
540
+ ...publicRow,
541
+ shared_value: true,
542
+ shared_with: shared.shared_with,
543
+ shared_apps: shared.shared_apps,
544
+ shared_value_error: shared.shared_value_error,
545
+ shared_value_severity: shared.shared_value_severity,
546
+ shared_fingerprint: shared.shared_fingerprint,
382
547
  };
383
548
  });
384
549
  }
385
550
 
551
+ /**
552
+ * Report of credential values reused across secret names / apps.
553
+ * healthy=false when any unwaived share exists.
554
+ */
555
+ export async function getSharedValueHealth(brainDir) {
556
+ const secrets = ensureMeta(await loadSecrets(brainDir));
557
+ const meta = secrets[META_KEY].keys || {};
558
+ const rows = [];
559
+ for (const key of Object.keys(secrets)) {
560
+ if (key === META_KEY) continue;
561
+ const val = secrets[key];
562
+ if (val === undefined || val === null || val === '') continue;
563
+ const m = meta[key] || {};
564
+ const repos = Array.isArray(m.repos) ? m.repos.filter(Boolean) : [];
565
+ rows.push({
566
+ key,
567
+ set: true,
568
+ provider: m.provider || null,
569
+ repos,
570
+ repo: repos.length === 1 ? repos[0] : null,
571
+ fingerprint_full: secretValueFingerprint(String(val)),
572
+ masked:
573
+ typeof val === 'string' && val.length > 8
574
+ ? `${val.slice(0, 4)}…${val.slice(-4)}`
575
+ : '••••••••',
576
+ shared_value_ok: !!m.shared_value_ok,
577
+ });
578
+ }
579
+ const { groups } = buildSharedValueIndex(rows);
580
+ const errorGroups = groups.filter((g) => g.severity === 'error');
581
+ const multiAppGroups = errorGroups.filter((g) => g.multi_app);
582
+ return {
583
+ healthy: errorGroups.length === 0,
584
+ groups,
585
+ error_groups: errorGroups.length,
586
+ multi_app_groups: multiAppGroups.length,
587
+ shared_keys: errorGroups.reduce((n, g) => n + g.count, 0),
588
+ message:
589
+ errorGroups.length === 0
590
+ ? 'No shared credential values across secret names/apps'
591
+ : `SHARED KEY ERROR: ${errorGroups.length} credential value(s) reused across secret names/apps — issue unique keys per app`,
592
+ errors: errorGroups.map((g) => ({
593
+ fingerprint: g.fingerprint,
594
+ apps: g.apps,
595
+ keys: g.keys,
596
+ error: g.error,
597
+ })),
598
+ };
599
+ }
600
+
386
601
  /**
387
602
  * Full catalog payload for UI: keys + providers + usage totals.
388
603
  */
@@ -405,6 +620,30 @@ export async function getSecretsCatalog(brainDir) {
405
620
  byProvider[p].cost_30d += k.usage_30d?.cost_usd || 0;
406
621
  byProvider[p].monthly_cost += Number(k.monthly_cost_usd) || 0;
407
622
  }
623
+ const setKeys = keys.filter((k) => k.set);
624
+ const trackingOk = setKeys.filter((k) => k.tracking_status === 'ok');
625
+ const trackingExempt = setKeys.filter((k) => k.tracking_status === 'exempt' || k.tracking_exempt);
626
+ const trackingErrors = setKeys.filter(
627
+ (k) => k.tracking_status !== 'ok' && k.tracking_status !== 'exempt' && !k.tracking_exempt,
628
+ );
629
+ const sharedKeys = setKeys.filter((k) => k.shared_value && k.shared_value_severity === 'error');
630
+ const sharedApps = setKeys.filter(
631
+ (k) => k.shared_value && k.shared_value_severity === 'error' && (k.shared_apps?.length || 0) > 1,
632
+ );
633
+ // Build unique shared groups from key annotations
634
+ const seenFp = new Set();
635
+ const sharedGroups = [];
636
+ for (const k of sharedKeys) {
637
+ const fp = k.shared_fingerprint || k.fingerprint;
638
+ if (!fp || seenFp.has(fp)) continue;
639
+ seenFp.add(fp);
640
+ sharedGroups.push({
641
+ fingerprint: fp,
642
+ apps: k.shared_apps || [],
643
+ keys: [k.key, ...(k.shared_with || []).map((s) => s.key)],
644
+ error: k.shared_value_error,
645
+ });
646
+ }
408
647
  return {
409
648
  keys,
410
649
  providers: listProviders(),
@@ -419,6 +658,22 @@ export async function getSecretsCatalog(brainDir) {
419
658
  usage_30d: usage30,
420
659
  rotation_overdue: overdue.length,
421
660
  budget: budget.config,
661
+ tracking_healthy: trackingErrors.length === 0,
662
+ tracking_ok: trackingOk.length,
663
+ tracking_exempt: trackingExempt.length,
664
+ tracking_errors: trackingErrors.length,
665
+ tracking_error_keys: trackingErrors.map((k) => ({
666
+ key: k.key,
667
+ provider: k.provider,
668
+ tracking_status: k.tracking_status || 'never',
669
+ error: k.tracking_error || 'Never synced — run secret account-sync',
670
+ })),
671
+ // Same credential material under multiple key names / repos
672
+ shared_value_healthy: sharedKeys.length === 0,
673
+ shared_value_keys: sharedKeys.length,
674
+ shared_value_multi_app_keys: sharedApps.length,
675
+ shared_value_groups: sharedGroups.length,
676
+ shared_value_errors: sharedGroups,
422
677
  },
423
678
  by_provider: Object.values(byProvider).sort((a, b) => b.cost_30d - a.cost_30d),
424
679
  store: resolveSecretsPath(brainDir),
@@ -504,17 +759,26 @@ export async function setSecret(brainDir, key, value, opts = {}) {
504
759
  actor: opts.actor || 'cli',
505
760
  });
506
761
 
507
- if (!prev.created_at) {
762
+ // First-time set only: optionally queue product-level API research (not raw key-name scraping).
763
+ if (!prev.created_at && opts.skip_integration_research !== true) {
508
764
  try {
509
- import('./research-queue.mjs').then(({ addToQueue }) => {
510
- const topic = `Automated API Integration Build: ${key}`;
511
- const notes = `The user added a new API key named "${key}" to the vault.\nPlease do the following:\n1. Scrape the official API documentation for "${key}" to learn how it works and what the endpoints are.\n2. Add the integration into the system (e.g. usage tracking in usage-tracker.mjs, allow the key in rest.mjs allowedKeys).\n3. Wire it up so the frontend and backend can use it seamlessly.`;
512
- addToQueue({ topic, priority: 'normal', notes });
513
- });
514
- import('./logger.mjs').then(({ logger }) => {
515
- logger.info('secrets', 'Queued agentic integration builder for new key', { key });
765
+ const { maybeEnqueueIntegrationResearch } = await import('./secret-integration-research.mjs');
766
+ const result = await maybeEnqueueIntegrationResearch(brainDir, key, {
767
+ provider: secrets[META_KEY].keys[key].provider,
516
768
  });
517
- } catch (err) {
769
+ const { logger } = await import('./logger.mjs');
770
+ if (result.enqueued) {
771
+ logger.info('secrets', 'Queued product API research for new key', {
772
+ key,
773
+ topic: result.item?.topic,
774
+ });
775
+ } else {
776
+ logger.debug('secrets', 'Skipped integration research for new key', {
777
+ key,
778
+ reason: result.skipped,
779
+ });
780
+ }
781
+ } catch {
518
782
  // Ignore background task failures
519
783
  }
520
784
  }
@@ -7,7 +7,7 @@ import { replaceSecretsBufferAtomic, resolveSecretsPath } from './secrets-store.
7
7
  import { throttledFetch } from './throttled-fetch.mjs';
8
8
  import { getMeshSyncAuthorization } from './mesh-auth.mjs';
9
9
 
10
- const REQUEST_TIMEOUT_MS = 5_000;
10
+ const REQUEST_TIMEOUT_MS = 10_000;
11
11
 
12
12
  function serverPort() {
13
13
  const value = Number(process.env.TR_SERVER_PORT || process.env.PORT || 3000);