total-recall-brain 3.41.1 → 3.41.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 (105) hide show
  1. package/bin/total-recall.mjs +9 -3
  2. package/frontend/dist/assets/{index-BG9NkVUy.css → index-BIQmqDKP.css} +1 -1
  3. package/frontend/dist/assets/{index-BtuiZqwP.js → index-DFenQQEQ.js} +74 -79
  4. package/frontend/dist/index.html +2 -2
  5. package/frontend/dist/sw.js +8 -5
  6. package/frontend/src/api/auth.ts +0 -3
  7. package/frontend/src/components/plugins/PluginPanel.tsx +7 -3
  8. package/frontend/src/components/plugins/card/CardEngine.tsx +1 -1
  9. package/frontend/src/pages/LoginPage.tsx +1 -41
  10. package/frontend/src/pages/PluginsPage.tsx +14 -7
  11. package/package.json +2 -2
  12. package/scaffold/.agent/skills/plugin/scripts/record.mjs +6 -3
  13. package/scaffold/.agent/skills/total-recall/plugins/auth/README.md +10 -0
  14. package/scaffold/.agent/skills/total-recall/plugins/auth/cli.mjs +197 -31
  15. package/scaffold/.agent/skills/total-recall/plugins/auth/package-lock.json +1456 -12
  16. package/scaffold/.agent/skills/total-recall/plugins/auth/package.json +4 -4
  17. package/scaffold/.agent/skills/total-recall/plugins/auth/plugin.json +8 -1
  18. package/scaffold/.agent/skills/total-recall/plugins/auth/src/cli-secret.mjs +210 -27
  19. package/scaffold/.agent/skills/total-recall/plugins/auth/src/provider-catalog.mjs +10 -1
  20. package/scaffold/.agent/skills/total-recall/plugins/auth/src/rotation-capability.mjs +15 -0
  21. package/scaffold/.agent/skills/total-recall/plugins/auth/src/secrets-env-export.mjs +78 -6
  22. package/scaffold/.agent/skills/total-recall/plugins/auth/src/secrets-merge.mjs +131 -0
  23. package/scaffold/.agent/skills/total-recall/plugins/auth/src/secrets-remote-deploy.mjs +111 -2
  24. package/scaffold/.agent/skills/total-recall/plugins/auth/src/secrets-store.mjs +26 -5
  25. package/scaffold/.agent/skills/total-recall/plugins/auth/ui/DESIGN.md +56 -8
  26. package/scaffold/.agent/skills/total-recall/plugins/auth/ui/card.js +1068 -204
  27. package/scaffold/.agent/skills/total-recall/plugins/auth/ui/panel.js +4 -2
  28. package/scaffold/.agent/skills/total-recall/plugins/cli-agents/ui/DESIGN.md +32 -0
  29. package/scaffold/.agent/skills/total-recall/plugins/cli-agents/ui/card.js +436 -274
  30. package/scaffold/.agent/skills/total-recall/plugins/code-quality/package-lock.json +4 -4
  31. package/scaffold/.agent/skills/total-recall/plugins/code-quality/package.json +1 -1
  32. package/scaffold/.agent/skills/total-recall/plugins/code-quality/ui/DESIGN.md +16 -2
  33. package/scaffold/.agent/skills/total-recall/plugins/code-quality/ui/card.js +461 -242
  34. package/scaffold/.agent/skills/total-recall/plugins/composable-cli/package-lock.json +4 -4
  35. package/scaffold/.agent/skills/total-recall/plugins/composable-cli/package.json +1 -1
  36. package/scaffold/.agent/skills/total-recall/plugins/composable-cli/plugin.json +1 -7
  37. package/scaffold/.agent/skills/total-recall/plugins/composable-cli/ui/DESIGN.md +17 -2
  38. package/scaffold/.agent/skills/total-recall/plugins/composable-cli/ui/card.js +426 -300
  39. package/scaffold/.agent/skills/total-recall/plugins/embeddings/package-lock.json +4 -4
  40. package/scaffold/.agent/skills/total-recall/plugins/embeddings/ui/DESIGN.md +32 -0
  41. package/scaffold/.agent/skills/total-recall/plugins/embeddings/ui/card.js +350 -283
  42. package/scaffold/.agent/skills/total-recall/plugins/git-sentinel/package-lock.json +4 -4
  43. package/scaffold/.agent/skills/total-recall/plugins/git-sentinel/ui/card.js +31 -19
  44. package/scaffold/.agent/skills/total-recall/plugins/mesh/README.md +10 -0
  45. package/scaffold/.agent/skills/total-recall/plugins/mesh/{src/core/headscale-server.mjs → capability/control-server.mjs} +5 -2
  46. package/scaffold/.agent/skills/total-recall/plugins/mesh/capability/headscale-api.mjs +192 -0
  47. package/scaffold/.agent/skills/total-recall/plugins/mesh/capability/index.mjs +46 -0
  48. package/scaffold/.agent/skills/total-recall/plugins/mesh/cli.mjs +9 -1
  49. package/scaffold/.agent/skills/total-recall/plugins/mesh/package-lock.json +23 -22
  50. package/scaffold/.agent/skills/total-recall/plugins/mesh/package.json +6 -3
  51. package/scaffold/.agent/skills/total-recall/plugins/mesh/plugin.json +242 -8
  52. package/scaffold/.agent/skills/total-recall/plugins/mesh/src/cli/mesh.mjs +14 -9
  53. package/scaffold/.agent/skills/total-recall/plugins/mesh/src/cli/server.mjs +1 -1
  54. package/scaffold/.agent/skills/total-recall/plugins/mesh/src/core/credentials.mjs +4 -2
  55. package/scaffold/.agent/skills/total-recall/plugins/mesh/src/core/headscale-client.mjs +34 -181
  56. package/scaffold/.agent/skills/total-recall/plugins/mesh/src/core/mesh-auth.mjs +2 -2
  57. package/scaffold/.agent/skills/total-recall/plugins/mesh/src/core/presence.mjs +5 -5
  58. package/scaffold/.agent/skills/total-recall/plugins/mesh/src/core/screen-view.mjs +78 -0
  59. package/scaffold/.agent/skills/total-recall/plugins/mesh/ui/DESIGN.md +211 -12
  60. package/scaffold/.agent/skills/total-recall/plugins/mesh/ui/card.js +323 -388
  61. package/scaffold/.agent/skills/total-recall/plugins/openwiki/README.md +12 -1
  62. package/scaffold/.agent/skills/total-recall/plugins/openwiki/cli.mjs +7 -4
  63. package/scaffold/.agent/skills/total-recall/plugins/openwiki/package-lock.json +6 -6
  64. package/scaffold/.agent/skills/total-recall/plugins/openwiki/package.json +3 -2
  65. package/scaffold/.agent/skills/total-recall/plugins/openwiki/plugin.json +34 -1
  66. package/scaffold/.agent/skills/total-recall/plugins/openwiki/src/bootstrap.mjs +99 -0
  67. package/scaffold/.agent/skills/total-recall/plugins/openwiki/templates/openwiki/README.md +10 -0
  68. package/scaffold/.agent/skills/total-recall/plugins/openwiki/templates/openwiki/architecture.md +5 -0
  69. package/scaffold/.agent/skills/total-recall/plugins/openwiki/templates/openwiki/ide.md +10 -0
  70. package/scaffold/.agent/skills/total-recall/plugins/openwiki/templates/openwiki/memory.md +11 -0
  71. package/scaffold/.agent/skills/total-recall/plugins/openwiki/templates/openwiki/secrets.md +7 -0
  72. package/scaffold/.agent/skills/total-recall/plugins/openwiki/templates/openwiki/skills.md +7 -0
  73. package/scaffold/.agent/skills/total-recall/plugins/openwiki/ui/DESIGN.md +16 -2
  74. package/scaffold/.agent/skills/total-recall/plugins/openwiki/ui/card.js +380 -298
  75. package/scaffold/.agent/skills/total-recall/plugins/security/cli.mjs +7 -1
  76. package/scaffold/.agent/skills/total-recall/plugins/security/package-lock.json +6 -6
  77. package/scaffold/.agent/skills/total-recall/plugins/security/package.json +1 -1
  78. package/scaffold/.agent/skills/total-recall/plugins/security/plugin.json +1 -1
  79. package/scaffold/.agent/skills/total-recall/plugins/security/src/scanner.mjs +17 -3
  80. package/scaffold/.agent/skills/total-recall/plugins/security/ui/DESIGN.md +38 -15
  81. package/scaffold/.agent/skills/total-recall/plugins/security/ui/card.js +60 -34
  82. package/scaffold/.agent/skills/total-recall/references/auth.md +2 -0
  83. package/src/cli/command.mjs +23 -18
  84. package/src/cli/daemon.mjs +23 -1
  85. package/src/cli/plugin/copies.mjs +8 -5
  86. package/src/cli/plugin/index.mjs +25 -2
  87. package/src/cli/rebuild.mjs +22 -0
  88. package/src/cli/reset-password.mjs +3 -2
  89. package/src/cli/skill.mjs +8 -2
  90. package/src/cli/status.mjs +25 -7
  91. package/src/core/automatic-brain-updates.mjs +9 -3
  92. package/src/core/brain-updates.mjs +63 -5
  93. package/src/core/global-brain-updates.mjs +21 -1
  94. package/src/core/mesh-brain-updates.mjs +5 -2
  95. package/src/core/meta-harness.mjs +16 -41
  96. package/src/core/plugin-runner.mjs +6 -1
  97. package/src/core/plugin-store.mjs +67 -1
  98. package/src/core/plugin-updates.mjs +1 -1
  99. package/src/core/portable-skills-maintenance.mjs +19 -0
  100. package/src/core/required-plugins.mjs +7 -0
  101. package/src/core/skills-registry.mjs +33 -3
  102. package/src/server/auth.mjs +41 -1
  103. package/src/server/memory-app.mjs +2 -1
  104. package/src/server/routes/auth.mjs +25 -1
  105. package/src/server/routes/plugins.mjs +17 -4
@@ -0,0 +1,131 @@
1
+ /**
2
+ * Merge a stray secrets store into the host's single store.
3
+ *
4
+ * Per-repo stores grew when the CLI resolved its store from the cwd. This
5
+ * folds one of them back in without losing or silently replacing anything:
6
+ * a key the target lacks is imported with its metadata; a key with the same
7
+ * value only gains the source's repo binding and tags; a key whose value
8
+ * differs is a conflict and is never overwritten unless the operator names it,
9
+ * either taking the source value (`take`) or importing it under a new,
10
+ * app-specific name (`rename`). Values are never returned or logged.
11
+ */
12
+ import crypto from 'node:crypto';
13
+ import { loadSecrets, saveSecrets, ensureMeta, appendAudit, META_KEY } from './secrets-gateway.mjs';
14
+ import { mergeSecretMeta, normalizeReposBinding, normalizeTags } from './secrets-store.mjs';
15
+
16
+ const KEY_PATTERN = /^[a-zA-Z][a-zA-Z0-9_.-]*$/;
17
+
18
+ /** Values can be strings or JSON (account lists); compare by content, not reference. */
19
+ const sameValue = (a, b) => (typeof a === 'string' || typeof b === 'string' ? a === b : JSON.stringify(a) === JSON.stringify(b));
20
+
21
+ function fingerprint(value) {
22
+ return crypto.createHash('sha256').update(typeof value === 'string' ? value : JSON.stringify(value)).digest('hex').slice(0, 12);
23
+ }
24
+
25
+ /** The metadata worth showing an operator to decide a conflict. */
26
+ function summary(meta = {}, value) {
27
+ return {
28
+ fingerprint: value ? fingerprint(value) : null,
29
+ provider: meta.provider || null,
30
+ repos: Array.isArray(meta.repos) ? meta.repos : [],
31
+ tags: Array.isArray(meta.tags) ? meta.tags : [],
32
+ created_at: meta.created_at || null,
33
+ updated_at: meta.updated_at || null,
34
+ rotated_at: meta.rotated_at || null,
35
+ tracking_status: meta.tracking_status || null,
36
+ tracking_synced_at: meta.tracking_synced_at || null,
37
+ key_valid: meta.key_valid ?? null,
38
+ };
39
+ }
40
+
41
+ /**
42
+ * @param {string} targetDir brain dir of the single store
43
+ * @param {string} sourceDir brain dir of the stray store
44
+ * @param {{ apply?: boolean, bindSame?: boolean, datesFromSource?: boolean, repo?: string, tags?: string[]|string, rename?: Record<string,string>,
45
+ * take?: string[], actor?: string }} [opts]
46
+ * @returns {Promise<{ applied: boolean, imported: object[], same: object[], conflicts: object[], renamed: object[], taken: object[] }>}
47
+ */
48
+ export async function mergeStore(targetDir, sourceDir, opts = {}) {
49
+ if (!targetDir || !sourceDir) throw new Error('merge-store needs a target and a source store');
50
+ if (targetDir === sourceDir) throw new Error('The source store is the target store');
51
+ const rename = opts.rename || {};
52
+ const take = new Set(opts.take || []);
53
+ for (const [from, to] of Object.entries(rename)) {
54
+ if (!KEY_PATTERN.test(from) || !KEY_PATTERN.test(to)) throw new Error(`Invalid rename ${from}=${to}`);
55
+ if (take.has(from)) throw new Error(`${from} cannot be both renamed and taken`);
56
+ }
57
+ const bindRepo = opts.repo ? normalizeReposBinding(opts.repo) : [];
58
+ const extraTags = opts.tags ? normalizeTags(opts.tags) : [];
59
+
60
+ const source = ensureMeta(await loadSecrets(sourceDir));
61
+ const target = ensureMeta(await loadSecrets(targetDir));
62
+ const sourceKeys = Object.keys(source).filter((k) => k !== META_KEY);
63
+ for (const k of [...Object.keys(rename), ...take]) {
64
+ if (!sourceKeys.includes(k)) throw new Error(`${k} is not in the source store`);
65
+ }
66
+
67
+ const report = { applied: false, imported: [], same: [], conflicts: [], renamed: [], taken: [] };
68
+ const now = new Date().toISOString();
69
+ const withBinding = (meta) => ({
70
+ repos: [...new Set([...(meta.repos || []), ...bindRepo])],
71
+ tags: [...new Set([...(meta.tags || []), ...extraTags])],
72
+ });
73
+
74
+ for (const key of sourceKeys) {
75
+ const value = source[key];
76
+ if (value === undefined || value === null || value === '') continue;
77
+ const sMeta = source[META_KEY].keys[key] || {};
78
+ const name = rename[key] || key;
79
+ const exists = name in target && target[name] !== '' && target[name] != null;
80
+ const tMeta = target[META_KEY].keys[name] || {};
81
+
82
+ if (rename[key]) {
83
+ if (exists && !sameValue(target[name], value)) throw new Error(`Rename target ${name} already holds a different value`);
84
+ target[name] = value;
85
+ target[META_KEY].keys[name] = mergeSecretMeta({ ...sMeta, ...tMeta }, { ...withBinding(sMeta), scope: 'global', created_at: sMeta.created_at || now, updated_at: now, notes: sMeta.notes || `merged from ${key}`, provides: key });
86
+ report.renamed.push({ from: key, to: name, ...summary(sMeta, value) });
87
+ continue;
88
+ }
89
+ if (!exists) {
90
+ target[name] = value;
91
+ target[META_KEY].keys[name] = mergeSecretMeta(sMeta, { ...withBinding(sMeta), scope: 'global', created_at: sMeta.created_at || now, updated_at: sMeta.updated_at || now });
92
+ report.imported.push({ key: name, ...summary(sMeta, value) });
93
+ continue;
94
+ }
95
+ if (sameValue(target[name], value)) {
96
+ // A store that was a copy of the global one says nothing about which keys
97
+ // the repo uses, so equal keys gain its binding only when asked.
98
+ if (opts.bindSame) target[META_KEY].keys[name] = mergeSecretMeta(tMeta, {
99
+ repos: [...new Set([...(tMeta.repos || []), ...(sMeta.repos || []), ...bindRepo])],
100
+ tags: [...new Set([...(tMeta.tags || []), ...(sMeta.tags || []), ...extraTags])],
101
+ });
102
+ // The value dates belong to the value: when it is identical, an older
103
+ // source date is the true one (repairs a date a label edit bumped).
104
+ if (opts.datesFromSource) {
105
+ const cur = target[META_KEY].keys[name] || {};
106
+ const fix = {};
107
+ for (const f of ['updated_at', 'rotated_at']) if (sMeta[f] && (!cur[f] || sMeta[f] < cur[f])) fix[f] = sMeta[f];
108
+ if (Object.keys(fix).length) { target[META_KEY].keys[name] = { ...cur, ...fix }; report.redated = (report.redated || 0) + 1; }
109
+ }
110
+ report.same.push({ key: name });
111
+ continue;
112
+ }
113
+ if (take.has(key)) {
114
+ const replaced = summary(tMeta, target[name]);
115
+ target[name] = value;
116
+ target[META_KEY].keys[name] = mergeSecretMeta(tMeta, { ...withBinding(tMeta), updated_at: now, notes: tMeta.notes || 'value taken from a merged store' });
117
+ report.taken.push({ key: name, replaced, with: summary(sMeta, value) });
118
+ continue;
119
+ }
120
+ report.conflicts.push({ key: name, target: summary(tMeta, target[name]), source: summary(sMeta, value) });
121
+ }
122
+
123
+ if (opts.apply) {
124
+ await saveSecrets(targetDir, target);
125
+ for (const row of [...report.imported, ...report.renamed, ...report.taken]) {
126
+ appendAudit(targetDir, { action: 'merge', key: row.key || row.to, from_key: row.from || null, actor: opts.actor || 'cli' });
127
+ }
128
+ report.applied = true;
129
+ }
130
+ return report;
131
+ }
@@ -18,7 +18,7 @@ import fs from 'node:fs';
18
18
  import path from 'node:path';
19
19
  import { spawnSync } from 'node:child_process';
20
20
  import { buildEnvProjection, mergeEnvManagedBlock, projectSlugFromPath } from './secrets-env-export.mjs';
21
- import { resolveSecretsPath } from './secrets-store.mjs';
21
+ import { resolveSecretsPath, loadSecrets, setSecret, secretValueFingerprint } from './secrets-store.mjs';
22
22
  import { authState } from './metadata-state.mjs';
23
23
 
24
24
  /**
@@ -114,6 +114,7 @@ function runSsh(target, remoteCommand, { input } = {}) {
114
114
  input: input != null ? input : undefined,
115
115
  encoding: 'utf8',
116
116
  timeout: 20000,
117
+ maxBuffer: 16 * 1024 * 1024,
117
118
  });
118
119
  return result;
119
120
  }
@@ -128,6 +129,7 @@ function quoteRemotePath(p) {
128
129
  */
129
130
  function fetchRemoteFile(target, remoteFile) {
130
131
  const r = runSsh(target, `cat ${quoteRemotePath(remoteFile)} 2>/dev/null || true`);
132
+ if (r.error || r.status !== 0) throw new Error(`SSH read failed for target "${target.name}": ${r.error?.message || (r.stderr || '').slice(0, 300)}`);
131
133
  if (r.error) throw new Error(`SSH connect failed for target "${target.name}": ${r.error.message}`);
132
134
  return r.stdout || '';
133
135
  }
@@ -209,7 +211,7 @@ export async function deployEnvToRemote(brainDir, targetName, opts = {}) {
209
211
  }
210
212
 
211
213
  const existing = fetchRemoteFile(target, remoteFile);
212
- const merged = mergeEnvManagedBlock(existing, projection.body);
214
+ const merged = mergeEnvManagedBlock(existing, projection.body, { partial: Boolean(opts.keys?.length) });
213
215
  writeRemoteFile(target, remoteFile, merged);
214
216
 
215
217
  let restarted = false;
@@ -232,6 +234,113 @@ export async function deployEnvToRemote(brainDir, targetName, opts = {}) {
232
234
  };
233
235
  }
234
236
 
237
+ /**
238
+ * Parse dotenv text into key -> { value, count }. The last assignment wins, as
239
+ * in dotenv; `count` exposes duplicates, which loaders resolve differently.
240
+ */
241
+ export function parseDotenv(text) {
242
+ const out = new Map();
243
+ for (const raw of String(text || '').split(/\r?\n/)) {
244
+ const m = /^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)$/.exec(raw);
245
+ if (!m) continue;
246
+ let value = m[2].trim();
247
+ if (value.length >= 2 && value.startsWith('"') && value.endsWith('"')) {
248
+ try { value = JSON.parse(value); } catch { value = value.slice(1, -1); }
249
+ } else if (value.length >= 2 && value.startsWith("'") && value.endsWith("'")) {
250
+ value = value.slice(1, -1);
251
+ } else {
252
+ value = value.replace(/\s+#.*$/, '');
253
+ }
254
+ out.set(m[1], { value, count: (out.get(m[1])?.count || 0) + 1 });
255
+ }
256
+ return out;
257
+ }
258
+
259
+ function findTarget(brainDir, targetName) {
260
+ const target = loadRemoteTargets(brainDir).find((t) => t.name === targetName);
261
+ if (!target) throw new Error(`Unknown remote target "${targetName}". List them with: total-recall auth secret remote list`);
262
+ return target;
263
+ }
264
+
265
+ /**
266
+ * Compare a deployed remote file with what the store would project there, key
267
+ * by key, by value fingerprint. Values never leave this process.
268
+ *
269
+ * @param {string} brainDir
270
+ * @param {string} targetName
271
+ * @param {{ keys?: string[], projectPath?: string }} opts
272
+ */
273
+ export async function verifyRemote(brainDir, targetName, opts = {}) {
274
+ const target = findTarget(brainDir, targetName);
275
+ const projectPath = opts.projectPath || process.cwd();
276
+ const projection = await buildEnvProjection(brainDir, {
277
+ includeGlobal: target.includeGlobal !== false,
278
+ keys: opts.keys?.length ? opts.keys : target.keys,
279
+ projectPath,
280
+ projectSlug: projectSlugFromPath(projectPath),
281
+ });
282
+ const remoteFile = path.posix.join(target.remotePath, target.filename || '.env');
283
+ const remote = parseDotenv(fetchRemoteFile(target, remoteFile));
284
+ const projected = new Set(projection.entries.map(([key]) => key));
285
+ const rows = projection.entries.map(([key, value]) => {
286
+ const found = remote.get(key);
287
+ const status = !found ? 'missing'
288
+ : secretValueFingerprint(found.value) === secretValueFingerprint(value) ? 'match' : 'differs';
289
+ return { key, status, duplicates: found ? found.count - 1 : 0 };
290
+ });
291
+ return {
292
+ target: target.name,
293
+ host: target.host,
294
+ remoteFile,
295
+ rows,
296
+ remoteOnly: [...remote.keys()].filter((key) => !projected.has(key)),
297
+ ok: rows.every((row) => row.status === 'match' && !row.duplicates),
298
+ };
299
+ }
300
+
301
+ /**
302
+ * Move named keys out of a pm2 app's captured environment into the store, so
303
+ * the app can run from the store's projection instead. pm2 keeps the
304
+ * environment a process was first started with and lets it override the
305
+ * app's env file, which hides stale and unneeded credentials from the store.
306
+ * Only the keys asked for are read; nothing is printed.
307
+ *
308
+ * @param {string} brainDir
309
+ * @param {string} targetName
310
+ * @param {{ app: string, keys: string[], apply?: boolean, overwrite?: boolean, repos?: string[] }} opts
311
+ */
312
+ export async function importFromPm2(brainDir, targetName, opts = {}) {
313
+ if (!opts.app) throw new Error('import-pm2 requires --app <pm2 process name>');
314
+ if (!opts.keys?.length) throw new Error('import-pm2 requires --keys: import only what the app needs');
315
+ const target = findTarget(brainDir, targetName);
316
+ const r = runSsh(target, 'pm2 jlist');
317
+ if (r.error || r.status !== 0) throw new Error(`pm2 jlist failed on "${target.name}": ${r.error?.message || (r.stderr || '').slice(0, 300)}`);
318
+ const raw = r.stdout || '';
319
+ let list;
320
+ // pm2 may print "[PM2] …" notices before the JSON array; start where the array does.
321
+ const start = raw.search(/^\[\s*(?:\{|\])/m);
322
+ try { list = JSON.parse(raw.slice(start)); } catch { throw new Error('pm2 jlist did not return JSON'); }
323
+ if (start === -1 || !Array.isArray(list)) throw new Error('pm2 jlist did not return JSON');
324
+ const proc = list.find((p) => p.name === opts.app);
325
+ if (!proc) throw new Error(`pm2 process "${opts.app}" not found on "${target.name}"`);
326
+ const env = proc.pm2_env?.env || {};
327
+ const secrets = await loadSecrets(brainDir);
328
+ const rows = [];
329
+ for (const key of opts.keys) {
330
+ const value = env[key] ?? proc.pm2_env?.[key];
331
+ if (value == null || value === '') { rows.push({ key, status: 'absent', applied: false }); continue; }
332
+ const stored = secrets[key];
333
+ const status = stored == null || stored === '' ? 'new' : String(stored) === String(value) ? 'same' : 'conflict';
334
+ let applied = false;
335
+ if (opts.apply && (status === 'new' || (status === 'conflict' && opts.overwrite))) {
336
+ await setSecret(brainDir, key, String(value), opts.repos?.length ? { repos: opts.repos } : {});
337
+ applied = true;
338
+ }
339
+ rows.push({ key, status, applied });
340
+ }
341
+ return { target: target.name, app: opts.app, rows };
342
+ }
343
+
235
344
  /**
236
345
  * Deploy to every remote target that has this key bound via `keys`, or to
237
346
  * every target when the key list is unset (target opts into "all keys").
@@ -80,6 +80,9 @@ export function mergeSecretMeta(prev = {}, patch = {}) {
80
80
  });
81
81
  assign('auto_rotate', (v) => !!v);
82
82
  assign('notes', (v) => (v == null || v === '' ? null : String(v)));
83
+ // The app-facing name a production key stands in for: GRO_CRM_TICKETS_SIGNING_SECRET
84
+ // provides TICKETS_SIGNING_SECRET to the repos it is bound to (see resolveSecretKey).
85
+ assign('provides', (v) => (v == null || v === '' ? null : String(v)));
83
86
  assign('project_path', (v) => (v == null || v === '' ? null : String(v)));
84
87
  assign('headscale_url', (v) => (v == null || v === '' ? null : String(v)));
85
88
  // Command that prints the headscale server log. Watch mode needs it because
@@ -478,11 +481,13 @@ export async function updateSecretMeta(brainDir, key, patch = {}, opts = {}) {
478
481
  throw new Error(`Secret not found: ${key}`);
479
482
  }
480
483
  const prev = secrets[META_KEY].keys[key] || {};
481
- secrets[META_KEY].keys[key] = mergeSecretMeta(prev, {
482
- ...patch,
483
- updated_at: new Date().toISOString(),
484
- created_at: prev.created_at || new Date().toISOString(),
485
- });
484
+ // updated_at dates the value (set, rotate). A metadata write — labels, account
485
+ // sync, tracking — records meta_updated_at instead, so it never makes a stale
486
+ // value look fresh or pushes back its rotation.
487
+ secrets[META_KEY].keys[key] = {
488
+ ...mergeSecretMeta(prev, { ...patch, created_at: prev.created_at || new Date().toISOString() }),
489
+ meta_updated_at: new Date().toISOString(),
490
+ };
486
491
  await saveSecrets(brainDir, secrets);
487
492
  appendAudit(brainDir, {
488
493
  action: 'meta',
@@ -555,6 +560,22 @@ export async function setSecret(brainDir, key, value, opts = {}) {
555
560
  return { key, set: true };
556
561
  }
557
562
 
563
+ /**
564
+ * The store key that answers `key` for one repo. Production services own
565
+ * distinct credentials under app-specific names; a key bound to the repo whose
566
+ * `provides` is `key` wins over the plain (developer) key. Two such keys for the
567
+ * same repo and name are ambiguous and refused, never guessed between.
568
+ */
569
+ export async function resolveSecretKey(brainDir, key, repo) {
570
+ if (!repo) return key;
571
+ const secrets = ensureMeta(await loadSecrets(brainDir));
572
+ const meta = secrets[META_KEY].keys;
573
+ const hits = Object.keys(meta).filter((k) => k !== key && meta[k]?.provides === key
574
+ && Array.isArray(meta[k].repos) && meta[k].repos.includes(repo) && secrets[k] != null && secrets[k] !== '');
575
+ if (hits.length > 1) throw new Error(`${hits.join(' and ')} both provide ${key} for ${repo}; keep one`);
576
+ return hits[0] || key;
577
+ }
578
+
558
579
  /**
559
580
  * Get a secret value (CLI only — do not inject into surfaces).
560
581
  */
@@ -3,17 +3,65 @@
3
3
  ```yaml
4
4
  tokens:
5
5
  brand-name: "Auth & Secrets"
6
- wordmark: "ENCLAVE AUTH"
6
+ wordmark: "AUTH & SECRETS"
7
7
  color-primary: "#10b981"
8
8
  color-background: "#064e3b"
9
9
  color-accent: "#34d399"
10
- badge: "FIDO2 Enclave"
11
- font-body: "system-ui, -apple-system, sans-serif"
12
- font-mono: "'JetBrains Mono', monospace"
10
+ color-ok: "#059669"
11
+ color-warn: "#d97706"
12
+ color-danger: "#dc2626"
13
+ font-body: "Outfit, system-ui, sans-serif"
14
+ font-mono: "'JetBrains Mono', ui-monospace, monospace"
13
15
  border-radius: "8px"
16
+ density: "compact"
17
+ motion: "120ms ease-out"
14
18
  ```
15
19
 
16
- ## Brand & Typographic Wordmark
17
- - **Name**: Auth & Secrets
18
- - **Wordmark Logo**: `ENCLAVE AUTH`
19
- - **Palette**: Deep Forest `#064e3b`, Emerald `#10b981`, Mint `#34d399`. Secure biometric FIDO2 WebAuthn & OAuth2 sessions.
20
+ ## Who this card is for
21
+
22
+ The operator and autonomous agents managing access control, cryptographic secrets, and identities across the mesh.
23
+
24
+ The operator opens this card during active administration: verifying biometric passkeys, checking Google OAuth token freshness, auditing 90-day secret rotation schedules, or inspecting active session integrity.
25
+
26
+ ## The four tasks, in the order the card presents them
27
+
28
+ | Screen | Task Frequency | Operational Rationale |
29
+ | :--- | :--- | :--- |
30
+ | **Passkeys** | Daily / Routine | Biometric hardware authentication via WebAuthn FIDO2. The operator checks registered authenticators and manages credential devices. |
31
+ | **Google OAuth** | Frequent | Identity bridge accounts. The operator monitors token expiration, executes manual refreshes, and reviews account scopes. |
32
+ | **Secrets** | Continuous | Cryptographic credential vault. The operator audits key freshness, tracks rotation deadlines, and checks provider bindings. |
33
+ | **Audit & Sessions** | Incident / Review | Security baseline verification. The operator inspects live access sessions and reviews policy findings. |
34
+
35
+ Screen ordering follows task frequency and operational urgency rather than arbitrary command taxonomy.
36
+
37
+ ## The core tasks
38
+
39
+ 1. **Verify security posture instantly** — confirm that the secrets store is sealed, zero rotation schedules are overdue, and the security audit baseline passes.
40
+ 2. **Manage biometric hardware passkeys** — inspect registered platform authenticators (Face ID, Touch ID, Windows Hello) and security keys (YubiKey), register new authenticators, and safely revoke decommissioned hardware.
41
+ 3. **Supervise OAuth tokens** — verify Google OAuth connection status, monitor token lifetime, refresh expiring credentials, and safely disconnect accounts.
42
+ 4. **Audit secret hygiene** — review key expiration dates, inspect multi-repo bindings, verify remote deployment targets, and trigger rotation workflows without exposing secret values to the DOM.
43
+
44
+ ## What must be visible without a single click
45
+
46
+ - **Security Verdict Line:** A clear verdict ("Secure" or the count of active warnings) and timestamp of the latest verification.
47
+ - **Passkey Count:** Total active hardware authenticators registered on the system.
48
+ - **OAuth Status:** Connected accounts and token health status.
49
+ - **Secrets Store Telemetry:** Total stored keys and count of overdue rotations.
50
+ - **Baseline Audit State:** System security verification status (Passed or Flagged).
51
+
52
+ ## Why the layout is this layout
53
+
54
+ The card face provides a wide, responsive workspace rather than a narrow column.
55
+
56
+ - **Security Verdict at the Top:** The operator requires an immediate assessment. A prominent verdict line answers whether the authentication system is healthy before secondary details appear.
57
+ - **Horizontal Task Navigation:** Four dedicated domain screens (Passkeys, Google OAuth, Secrets, Audit & Sessions) organize operations by user intent rather than raw CLI syntax.
58
+ - **Zero Raw Secrets in DOM:** Secret values are never rendered, logged, or accepted via input fields. The interface exposes only key names, providers, fingerprints, creation timestamps, and rotation schedules.
59
+ - **Safe Revocation Guards:** Destructive actions such as credential revocation require explicit confirmation and display prominent warning styling. Deleting the final authentication method is blocked to prevent accidental administrative lockout.
60
+ - **Real Telemetry Only:** All figures render em dashes (`—`) until returned by the host. Zero mock responses, zero seeded numbers, and zero reassuring default strings appear in error branches.
61
+
62
+ ## Responsive presentation and accessibility
63
+
64
+ - **Breakpoints:** Verified across mobile (360 CSS px), tablet (768 CSS px), and desktop (1280 CSS px) viewports.
65
+ - **Host Theme Adaptation:** Injected dark-dashboard variables (`--text-primary`, `--text-secondary`, `--bg-primary`, `--bg-secondary`, `--border`, `--accent`) govern surfaces and typography.
66
+ - **Touch Targets:** All interactive controls declare a minimum height of 44 px.
67
+ - **Reduced Motion:** All transitions and indicators respect `prefers-reduced-motion` settings.