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.
- package/bin/total-recall.mjs +9 -3
- package/frontend/dist/assets/{index-BG9NkVUy.css → index-BIQmqDKP.css} +1 -1
- package/frontend/dist/assets/{index-BtuiZqwP.js → index-DFenQQEQ.js} +74 -79
- package/frontend/dist/index.html +2 -2
- package/frontend/dist/sw.js +8 -5
- package/frontend/src/api/auth.ts +0 -3
- package/frontend/src/components/plugins/PluginPanel.tsx +7 -3
- package/frontend/src/components/plugins/card/CardEngine.tsx +1 -1
- package/frontend/src/pages/LoginPage.tsx +1 -41
- package/frontend/src/pages/PluginsPage.tsx +14 -7
- package/package.json +2 -2
- package/scaffold/.agent/skills/plugin/scripts/record.mjs +6 -3
- package/scaffold/.agent/skills/total-recall/plugins/auth/README.md +10 -0
- package/scaffold/.agent/skills/total-recall/plugins/auth/cli.mjs +197 -31
- package/scaffold/.agent/skills/total-recall/plugins/auth/package-lock.json +1456 -12
- package/scaffold/.agent/skills/total-recall/plugins/auth/package.json +4 -4
- package/scaffold/.agent/skills/total-recall/plugins/auth/plugin.json +8 -1
- package/scaffold/.agent/skills/total-recall/plugins/auth/src/cli-secret.mjs +210 -27
- package/scaffold/.agent/skills/total-recall/plugins/auth/src/provider-catalog.mjs +10 -1
- package/scaffold/.agent/skills/total-recall/plugins/auth/src/rotation-capability.mjs +15 -0
- package/scaffold/.agent/skills/total-recall/plugins/auth/src/secrets-env-export.mjs +78 -6
- package/scaffold/.agent/skills/total-recall/plugins/auth/src/secrets-merge.mjs +131 -0
- package/scaffold/.agent/skills/total-recall/plugins/auth/src/secrets-remote-deploy.mjs +111 -2
- package/scaffold/.agent/skills/total-recall/plugins/auth/src/secrets-store.mjs +26 -5
- package/scaffold/.agent/skills/total-recall/plugins/auth/ui/DESIGN.md +56 -8
- package/scaffold/.agent/skills/total-recall/plugins/auth/ui/card.js +1068 -204
- package/scaffold/.agent/skills/total-recall/plugins/auth/ui/panel.js +4 -2
- package/scaffold/.agent/skills/total-recall/plugins/cli-agents/ui/DESIGN.md +32 -0
- package/scaffold/.agent/skills/total-recall/plugins/cli-agents/ui/card.js +436 -274
- package/scaffold/.agent/skills/total-recall/plugins/code-quality/package-lock.json +4 -4
- package/scaffold/.agent/skills/total-recall/plugins/code-quality/package.json +1 -1
- package/scaffold/.agent/skills/total-recall/plugins/code-quality/ui/DESIGN.md +16 -2
- package/scaffold/.agent/skills/total-recall/plugins/code-quality/ui/card.js +461 -242
- package/scaffold/.agent/skills/total-recall/plugins/composable-cli/package-lock.json +4 -4
- package/scaffold/.agent/skills/total-recall/plugins/composable-cli/package.json +1 -1
- package/scaffold/.agent/skills/total-recall/plugins/composable-cli/plugin.json +1 -7
- package/scaffold/.agent/skills/total-recall/plugins/composable-cli/ui/DESIGN.md +17 -2
- package/scaffold/.agent/skills/total-recall/plugins/composable-cli/ui/card.js +426 -300
- package/scaffold/.agent/skills/total-recall/plugins/embeddings/package-lock.json +4 -4
- package/scaffold/.agent/skills/total-recall/plugins/embeddings/ui/DESIGN.md +32 -0
- package/scaffold/.agent/skills/total-recall/plugins/embeddings/ui/card.js +350 -283
- package/scaffold/.agent/skills/total-recall/plugins/git-sentinel/package-lock.json +4 -4
- package/scaffold/.agent/skills/total-recall/plugins/git-sentinel/ui/card.js +31 -19
- package/scaffold/.agent/skills/total-recall/plugins/mesh/README.md +10 -0
- package/scaffold/.agent/skills/total-recall/plugins/mesh/{src/core/headscale-server.mjs → capability/control-server.mjs} +5 -2
- package/scaffold/.agent/skills/total-recall/plugins/mesh/capability/headscale-api.mjs +192 -0
- package/scaffold/.agent/skills/total-recall/plugins/mesh/capability/index.mjs +46 -0
- package/scaffold/.agent/skills/total-recall/plugins/mesh/cli.mjs +9 -1
- package/scaffold/.agent/skills/total-recall/plugins/mesh/package-lock.json +23 -22
- package/scaffold/.agent/skills/total-recall/plugins/mesh/package.json +6 -3
- package/scaffold/.agent/skills/total-recall/plugins/mesh/plugin.json +242 -8
- package/scaffold/.agent/skills/total-recall/plugins/mesh/src/cli/mesh.mjs +14 -9
- package/scaffold/.agent/skills/total-recall/plugins/mesh/src/cli/server.mjs +1 -1
- package/scaffold/.agent/skills/total-recall/plugins/mesh/src/core/credentials.mjs +4 -2
- package/scaffold/.agent/skills/total-recall/plugins/mesh/src/core/headscale-client.mjs +34 -181
- package/scaffold/.agent/skills/total-recall/plugins/mesh/src/core/mesh-auth.mjs +2 -2
- package/scaffold/.agent/skills/total-recall/plugins/mesh/src/core/presence.mjs +5 -5
- package/scaffold/.agent/skills/total-recall/plugins/mesh/src/core/screen-view.mjs +78 -0
- package/scaffold/.agent/skills/total-recall/plugins/mesh/ui/DESIGN.md +211 -12
- package/scaffold/.agent/skills/total-recall/plugins/mesh/ui/card.js +323 -388
- package/scaffold/.agent/skills/total-recall/plugins/openwiki/README.md +12 -1
- package/scaffold/.agent/skills/total-recall/plugins/openwiki/cli.mjs +7 -4
- package/scaffold/.agent/skills/total-recall/plugins/openwiki/package-lock.json +6 -6
- package/scaffold/.agent/skills/total-recall/plugins/openwiki/package.json +3 -2
- package/scaffold/.agent/skills/total-recall/plugins/openwiki/plugin.json +34 -1
- package/scaffold/.agent/skills/total-recall/plugins/openwiki/src/bootstrap.mjs +99 -0
- package/scaffold/.agent/skills/total-recall/plugins/openwiki/templates/openwiki/README.md +10 -0
- package/scaffold/.agent/skills/total-recall/plugins/openwiki/templates/openwiki/architecture.md +5 -0
- package/scaffold/.agent/skills/total-recall/plugins/openwiki/templates/openwiki/ide.md +10 -0
- package/scaffold/.agent/skills/total-recall/plugins/openwiki/templates/openwiki/memory.md +11 -0
- package/scaffold/.agent/skills/total-recall/plugins/openwiki/templates/openwiki/secrets.md +7 -0
- package/scaffold/.agent/skills/total-recall/plugins/openwiki/templates/openwiki/skills.md +7 -0
- package/scaffold/.agent/skills/total-recall/plugins/openwiki/ui/DESIGN.md +16 -2
- package/scaffold/.agent/skills/total-recall/plugins/openwiki/ui/card.js +380 -298
- package/scaffold/.agent/skills/total-recall/plugins/security/cli.mjs +7 -1
- package/scaffold/.agent/skills/total-recall/plugins/security/package-lock.json +6 -6
- package/scaffold/.agent/skills/total-recall/plugins/security/package.json +1 -1
- package/scaffold/.agent/skills/total-recall/plugins/security/plugin.json +1 -1
- package/scaffold/.agent/skills/total-recall/plugins/security/src/scanner.mjs +17 -3
- package/scaffold/.agent/skills/total-recall/plugins/security/ui/DESIGN.md +38 -15
- package/scaffold/.agent/skills/total-recall/plugins/security/ui/card.js +60 -34
- package/scaffold/.agent/skills/total-recall/references/auth.md +2 -0
- package/src/cli/command.mjs +23 -18
- package/src/cli/daemon.mjs +23 -1
- package/src/cli/plugin/copies.mjs +8 -5
- package/src/cli/plugin/index.mjs +25 -2
- package/src/cli/rebuild.mjs +22 -0
- package/src/cli/reset-password.mjs +3 -2
- package/src/cli/skill.mjs +8 -2
- package/src/cli/status.mjs +25 -7
- package/src/core/automatic-brain-updates.mjs +9 -3
- package/src/core/brain-updates.mjs +63 -5
- package/src/core/global-brain-updates.mjs +21 -1
- package/src/core/mesh-brain-updates.mjs +5 -2
- package/src/core/meta-harness.mjs +16 -41
- package/src/core/plugin-runner.mjs +6 -1
- package/src/core/plugin-store.mjs +67 -1
- package/src/core/plugin-updates.mjs +1 -1
- package/src/core/portable-skills-maintenance.mjs +19 -0
- package/src/core/required-plugins.mjs +7 -0
- package/src/core/skills-registry.mjs +33 -3
- package/src/server/auth.mjs +41 -1
- package/src/server/memory-app.mjs +2 -1
- package/src/server/routes/auth.mjs +25 -1
- 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
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
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: "
|
|
6
|
+
wordmark: "AUTH & SECRETS"
|
|
7
7
|
color-primary: "#10b981"
|
|
8
8
|
color-background: "#064e3b"
|
|
9
9
|
color-accent: "#34d399"
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
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
|
-
##
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
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.
|