tr-plugin-auth 0.0.0-stage → 1.0.0
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/README.md +45 -2
- package/cli.mjs +384 -0
- package/package.json +40 -4
- package/plugin.json +266 -0
- package/src/access-keys.mjs +225 -0
- package/src/agent-dir.mjs +3 -0
- package/src/browser-session.mjs +240 -0
- package/src/cli-secret.mjs +1687 -0
- package/src/config.mjs +4 -0
- package/src/configuration.mjs +19 -0
- package/src/credential-preservation.mjs +28 -0
- package/src/crypto.mjs +195 -0
- package/src/env-import.mjs +502 -0
- package/src/fact-seeker.mjs +3 -0
- package/src/frontmatter.mjs +3 -0
- package/src/google-oauth.mjs +139 -0
- package/src/host-runtime.mjs +7 -0
- package/src/leader-election.mjs +9 -0
- package/src/logger.mjs +3 -0
- package/src/mesh-auth.mjs +7 -0
- package/src/mesh-identity.mjs +16 -0
- package/src/mesh-secrets.mjs +146 -0
- package/src/mesh.mjs +23 -0
- package/src/metadata-state.mjs +17 -0
- package/src/project-id.mjs +3 -0
- package/src/provider-account-sync.mjs +1145 -0
- package/src/provider-catalog.mjs +515 -0
- package/src/provider-rotation-recipes.mjs +299 -0
- package/src/research-gate.mjs +3 -0
- package/src/research-queue.mjs +3 -0
- package/src/rotation-capability.mjs +189 -0
- package/src/routes/auth.mjs +207 -0
- package/src/routes/keys.mjs +61 -0
- package/src/routes/secrets-runtime.mjs +5 -0
- package/src/routes/secrets.mjs +747 -0
- package/src/routes/webauthn.mjs +163 -0
- package/src/runtime-credentials.mjs +19 -0
- package/src/runtime.mjs +3 -0
- package/src/secret-integration-research.mjs +340 -0
- package/src/secrets-env-diff.mjs +132 -0
- package/src/secrets-env-export.mjs +322 -0
- package/src/secrets-gateway.mjs +253 -0
- package/src/secrets-keychain.mjs +83 -0
- package/src/secrets-rekey.mjs +366 -0
- package/src/secrets-remote-deploy.mjs +250 -0
- package/src/secrets-rotate.mjs +437 -0
- package/src/secrets-store.mjs +756 -0
- package/src/secrets-sync.mjs +81 -0
- package/src/secure-file.mjs +71 -0
- package/src/server-auth.mjs +500 -0
- package/src/session-credentials.mjs +71 -0
- package/src/ssss-store.mjs +13 -0
- package/src/stdin.mjs +31 -0
- package/src/throttled-fetch.mjs +3 -0
- package/src/vault.mjs +3 -0
- package/src/vfs-documents.mjs +3 -0
- package/src/webauthn-store.mjs +265 -0
- package/ui/DESIGN.md +19 -0
- package/ui/card.js +274 -0
- package/ui/panel.js +338 -0
|
@@ -0,0 +1,502 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Scan env sources and import keys into the secrets store.
|
|
3
|
+
*
|
|
4
|
+
* Import is pattern-based (any secret-shaped env name) — NOT a hardcoded list of
|
|
5
|
+
* product APIs. Optional provider tags are best-effort labels for the catalog UI.
|
|
6
|
+
* Never logs or returns full secret values in scan results.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import fs from 'node:fs';
|
|
10
|
+
import path from 'node:path';
|
|
11
|
+
import os from 'node:os';
|
|
12
|
+
import { setSecret, listSecretsMeta, updateSecretMeta } from './secrets-store.mjs';
|
|
13
|
+
import { providerForKeyName } from './provider-catalog.mjs';
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* @deprecated Kept as empty/export for older callers. Import does NOT use this
|
|
17
|
+
* as a whitelist — any secret-shaped key is eligible.
|
|
18
|
+
*/
|
|
19
|
+
export const KNOWN_SECRET_KEYS = [];
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Name looks like a secret: ends with KEY/TOKEN/SECRET/PASSWORD/etc.
|
|
23
|
+
* Or contains those segments. Shell noise is skipped.
|
|
24
|
+
*/
|
|
25
|
+
const SECRET_NAME_RE =
|
|
26
|
+
/(?:^|_)(API[_-]?KEY|API[_-]?TOKEN|ACCESS[_-]?TOKEN|ACCESS[_-]?KEY|SECRET[_-]?KEY|PRIVATE[_-]?KEY|AUTH[_-]?TOKEN|CLIENT[_-]?SECRET|WEBHOOK[_-]?SECRET|PASSWORD|PASSWD|TOKEN|SECRET|KEY)$/i;
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* The secrets store's own master password must never be imported into the
|
|
30
|
+
* store, and neither may anything else that controls access to it.
|
|
31
|
+
*
|
|
32
|
+
* TR_SECRETS_PASSWORD is exported into every shell by ~/.zshenv (from the
|
|
33
|
+
* macOS Keychain), so it shows up in the env scan like any other credential —
|
|
34
|
+
* and `secret import-env --all`, the command the help text recommends, would
|
|
35
|
+
* happily write it in. That is the key to the box stored inside the box: it
|
|
36
|
+
* cannot be read back without the value it holds, and it puts the one
|
|
37
|
+
* credential that protects everything else next to what it protects.
|
|
38
|
+
*/
|
|
39
|
+
const STORE_CONTROL_KEYS = new Set([
|
|
40
|
+
'TR_SECRETS_PASSWORD',
|
|
41
|
+
'TR_SECRETS_KEY_CACHE',
|
|
42
|
+
'TR_REMOTE_VAULT_TOKEN',
|
|
43
|
+
]);
|
|
44
|
+
|
|
45
|
+
/** Obvious non-secrets even if they match loosely */
|
|
46
|
+
const SKIP_KEYS = new Set([
|
|
47
|
+
'PATH',
|
|
48
|
+
'HOME',
|
|
49
|
+
'USER',
|
|
50
|
+
'SHELL',
|
|
51
|
+
'PWD',
|
|
52
|
+
'TMPDIR',
|
|
53
|
+
'TERM',
|
|
54
|
+
'LANG',
|
|
55
|
+
'LC_ALL',
|
|
56
|
+
'NODE_ENV',
|
|
57
|
+
'COLORTERM',
|
|
58
|
+
'SSH_AUTH_SOCK',
|
|
59
|
+
'DISPLAY',
|
|
60
|
+
'USERPROFILE',
|
|
61
|
+
'HOSTNAME',
|
|
62
|
+
'LOGNAME',
|
|
63
|
+
'XPC_SERVICE_NAME',
|
|
64
|
+
'COMMAND_MODE',
|
|
65
|
+
'SHLVL',
|
|
66
|
+
'OLDPWD',
|
|
67
|
+
'TERM_PROGRAM',
|
|
68
|
+
'TERM_SESSION_ID',
|
|
69
|
+
'TMP',
|
|
70
|
+
'TEMP',
|
|
71
|
+
'EDITOR',
|
|
72
|
+
'VISUAL',
|
|
73
|
+
'PAGER',
|
|
74
|
+
'MANPATH',
|
|
75
|
+
'INFOPATH',
|
|
76
|
+
'GOPATH',
|
|
77
|
+
'GOROOT',
|
|
78
|
+
'JAVA_HOME',
|
|
79
|
+
'ANDROID_HOME',
|
|
80
|
+
'NVM_DIR',
|
|
81
|
+
'PNPM_HOME',
|
|
82
|
+
'BUN_INSTALL',
|
|
83
|
+
'ZSH',
|
|
84
|
+
'FPATH',
|
|
85
|
+
'LS_COLORS',
|
|
86
|
+
'COLORFGBG',
|
|
87
|
+
'ITERM_SESSION_ID',
|
|
88
|
+
'TERM_PROGRAM_VERSION',
|
|
89
|
+
'SSH_AGENT_PID',
|
|
90
|
+
'XPC_FLAGS',
|
|
91
|
+
'LaunchInstanceID',
|
|
92
|
+
'SECURITYSESSIONID',
|
|
93
|
+
'__CFBundleIdentifier',
|
|
94
|
+
'__CF_USER_TEXT_ENCODING',
|
|
95
|
+
]);
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Infer a short provider *label* from the key name for catalog grouping.
|
|
99
|
+
* Not a whitelist — unknown keys still import; label may be null or derived.
|
|
100
|
+
*
|
|
101
|
+
* Strategy:
|
|
102
|
+
* 1. Optional match against provider-catalog key_patterns (data, not import gate)
|
|
103
|
+
* 2. Else first path segment of KEY_NAME before _API / _SECRET / _TOKEN / _KEY
|
|
104
|
+
*
|
|
105
|
+
* @param {string} key
|
|
106
|
+
* @returns {string|null}
|
|
107
|
+
*/
|
|
108
|
+
export function inferProvider(key) {
|
|
109
|
+
if (!key) return null;
|
|
110
|
+
const k = String(key);
|
|
111
|
+
|
|
112
|
+
// A known provider anywhere in the key name wins (JSN_SMTP2GO_API_KEY -> smtp2go).
|
|
113
|
+
const known = providerForKeyName(k);
|
|
114
|
+
if (known) return known.id;
|
|
115
|
+
|
|
116
|
+
// Strip common prefixes that are not the vendor
|
|
117
|
+
let base = k
|
|
118
|
+
.replace(/^(DEVELOPER_|VITE_|NEXT_PUBLIC_|PUBLIC_|NUXT_PUBLIC_)/i, '')
|
|
119
|
+
.toUpperCase();
|
|
120
|
+
|
|
121
|
+
// Take leading brand segment(s) before secret suffix
|
|
122
|
+
const stripped = base
|
|
123
|
+
.replace(
|
|
124
|
+
/(_?API_?KEY|_?API_?TOKEN|_?ACCESS_?TOKEN|_?ACCESS_?KEY|_?SECRET_?KEY|_?PRIVATE_?KEY|_?AUTH_?TOKEN|_?CLIENT_?SECRET|_?WEBHOOK_?SECRET|_?PASSWORD|_?PASSWD|_?TOKEN|_?SECRET|_?KEY)$/i,
|
|
125
|
+
'',
|
|
126
|
+
)
|
|
127
|
+
.replace(/^_+|_+$/g, '');
|
|
128
|
+
|
|
129
|
+
if (!stripped) return null;
|
|
130
|
+
|
|
131
|
+
// Normalize multi-word vendors into a slug (OPEN_ROUTER → open-router, GOOGLE_GENERATIVE_AI → google)
|
|
132
|
+
const parts = stripped.toLowerCase().split(/_+/).filter(Boolean);
|
|
133
|
+
if (!parts.length) return null;
|
|
134
|
+
|
|
135
|
+
// Prefer first 1–2 meaningful parts, drop noise words
|
|
136
|
+
const noise = new Set(['api', 'app', 'service', 'role', 'admin', 'user', 'test', 'public', 'private']);
|
|
137
|
+
const useful = parts.filter((p) => !noise.has(p));
|
|
138
|
+
const take = useful.length ? useful : parts;
|
|
139
|
+
if (take.length === 1) return take[0];
|
|
140
|
+
// e.g. google_generative → google; digital_ocean → digitalocean
|
|
141
|
+
if (take[0].length <= 3 && take.length >= 2) return `${take[0]}-${take[1]}`;
|
|
142
|
+
return take[0];
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* @param {string} value
|
|
147
|
+
*/
|
|
148
|
+
export function maskSecret(value) {
|
|
149
|
+
const s = String(value || '');
|
|
150
|
+
if (s.length <= 8) return '••••••••';
|
|
151
|
+
return `${s.slice(0, 4)}…${s.slice(-4)} (${s.length} chars)`;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Parse .env-style text into key/value map.
|
|
156
|
+
* @param {string} text
|
|
157
|
+
* @returns {Record<string, string>}
|
|
158
|
+
*/
|
|
159
|
+
export function parseEnvText(text) {
|
|
160
|
+
const out = {};
|
|
161
|
+
if (!text || typeof text !== 'string') return out;
|
|
162
|
+
for (const rawLine of text.split(/\r?\n/)) {
|
|
163
|
+
const line = rawLine.trim();
|
|
164
|
+
if (!line || line.startsWith('#')) continue;
|
|
165
|
+
const exportPrefix = line.startsWith('export ') ? line.slice(7).trim() : line;
|
|
166
|
+
const eq = exportPrefix.indexOf('=');
|
|
167
|
+
if (eq <= 0) continue;
|
|
168
|
+
const key = exportPrefix.slice(0, eq).trim();
|
|
169
|
+
let val = exportPrefix.slice(eq + 1).trim();
|
|
170
|
+
if (
|
|
171
|
+
(val.startsWith('"') && val.endsWith('"')) ||
|
|
172
|
+
(val.startsWith("'") && val.endsWith("'"))
|
|
173
|
+
) {
|
|
174
|
+
val = val.slice(1, -1);
|
|
175
|
+
}
|
|
176
|
+
// strip trailing inline comments for unquoted values
|
|
177
|
+
if (!exportPrefix.slice(eq + 1).trim().startsWith('"') && !exportPrefix.slice(eq + 1).trim().startsWith("'")) {
|
|
178
|
+
val = val.replace(/\s+#.*$/, '').trim();
|
|
179
|
+
}
|
|
180
|
+
if (key && val) out[key] = val;
|
|
181
|
+
}
|
|
182
|
+
return out;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* True if env var *name* looks secret-shaped. No product/API whitelist.
|
|
187
|
+
* @param {string} key
|
|
188
|
+
*/
|
|
189
|
+
export function isCandidateKey(key) {
|
|
190
|
+
if (!key || typeof key !== 'string') return false;
|
|
191
|
+
if (STORE_CONTROL_KEYS.has(key.toUpperCase())) return false;
|
|
192
|
+
if (SKIP_KEYS.has(key) || SKIP_KEYS.has(key.toUpperCase())) return false;
|
|
193
|
+
// skip pure PATH-like / numeric shell junk
|
|
194
|
+
if (key.length < 4 || key.length > 128) return false;
|
|
195
|
+
if (!/^[A-Za-z_][A-Za-z0-9_.-]*$/.test(key)) return false;
|
|
196
|
+
return SECRET_NAME_RE.test(key);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Discover .env* files under a directory (shallow, no node_modules).
|
|
201
|
+
* @param {string} root
|
|
202
|
+
* @param {number} maxDepth
|
|
203
|
+
* @param {string[]} out
|
|
204
|
+
* @param {number} depth
|
|
205
|
+
*/
|
|
206
|
+
function walkEnvFiles(root, maxDepth, out, depth = 0) {
|
|
207
|
+
if (depth > maxDepth || !root) return;
|
|
208
|
+
let entries;
|
|
209
|
+
try {
|
|
210
|
+
entries = fs.readdirSync(root, { withFileTypes: true });
|
|
211
|
+
} catch {
|
|
212
|
+
return;
|
|
213
|
+
}
|
|
214
|
+
for (const ent of entries) {
|
|
215
|
+
if (ent.name === 'node_modules' || ent.name === '.git' || ent.name === 'dist' || ent.name === 'build') {
|
|
216
|
+
continue;
|
|
217
|
+
}
|
|
218
|
+
const full = path.join(root, ent.name);
|
|
219
|
+
if (ent.isFile() && /^\.env(\.|$)/.test(ent.name)) {
|
|
220
|
+
out.push(full);
|
|
221
|
+
} else if (ent.isDirectory() && !ent.name.startsWith('.') || ent.name === '.agent' || ent.name === '.config') {
|
|
222
|
+
if (ent.name.startsWith('.') && ent.name !== '.agent' && ent.name !== '.config') continue;
|
|
223
|
+
walkEnvFiles(full, maxDepth, out, depth + 1);
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* Candidate env file paths — discovered, not a hardcoded product list.
|
|
230
|
+
* @param {{ brainDir?: string, projectRoot?: string, cwd?: string, extraFiles?: string[], discoverRoot?: string, maxDepth?: number }} opts
|
|
231
|
+
*/
|
|
232
|
+
export function defaultEnvFilePaths(opts = {}) {
|
|
233
|
+
const home = os.homedir();
|
|
234
|
+
const cwd = opts.cwd || process.cwd();
|
|
235
|
+
const paths = [
|
|
236
|
+
path.join(home, '.env'),
|
|
237
|
+
path.join(home, '.agent', '.env'),
|
|
238
|
+
path.join(home, '.agent', 'skills', 'total-recall', '.env'),
|
|
239
|
+
path.join(home, '.agent', 'skills', 'total-recall', 'config', '.env'),
|
|
240
|
+
path.join(home, '.config', 'total-recall', '.env'),
|
|
241
|
+
path.join(cwd, '.env'),
|
|
242
|
+
path.join(cwd, '.env.local'),
|
|
243
|
+
path.join(cwd, '.env.development'),
|
|
244
|
+
path.join(cwd, '.env.production'),
|
|
245
|
+
];
|
|
246
|
+
if (opts.brainDir) {
|
|
247
|
+
paths.push(path.join(opts.brainDir, '.env'));
|
|
248
|
+
paths.push(path.join(opts.brainDir, 'config', '.env'));
|
|
249
|
+
}
|
|
250
|
+
if (opts.projectRoot) {
|
|
251
|
+
paths.push(path.join(opts.projectRoot, '.env'));
|
|
252
|
+
paths.push(path.join(opts.projectRoot, '.env.local'));
|
|
253
|
+
}
|
|
254
|
+
if (opts.extraFiles && Array.isArray(opts.extraFiles)) {
|
|
255
|
+
paths.push(...opts.extraFiles);
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// Auto-discover under ~/Github (or discoverRoot) without naming products
|
|
259
|
+
const discoverRoot = opts.discoverRoot ?? path.join(home, 'Github');
|
|
260
|
+
const maxDepth = opts.maxDepth ?? 3;
|
|
261
|
+
if (discoverRoot && fs.existsSync(discoverRoot)) {
|
|
262
|
+
walkEnvFiles(discoverRoot, maxDepth, paths, 0);
|
|
263
|
+
}
|
|
264
|
+
// Also discover under home/.agent
|
|
265
|
+
walkEnvFiles(path.join(home, '.agent'), 4, paths, 0);
|
|
266
|
+
|
|
267
|
+
return [...new Set(paths)];
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/**
|
|
271
|
+
* @param {Record<string, string>} map
|
|
272
|
+
* @param {string} source
|
|
273
|
+
* @param {Map<string, object>} into
|
|
274
|
+
*/
|
|
275
|
+
function mergeCandidates(map, source, into) {
|
|
276
|
+
for (const [key, value] of Object.entries(map)) {
|
|
277
|
+
if (!isCandidateKey(key)) continue;
|
|
278
|
+
if (!value || !String(value).trim()) continue;
|
|
279
|
+
const existing = into.get(key);
|
|
280
|
+
// Prefer first non-process.env file sources over later process.env unless only env
|
|
281
|
+
if (existing && existing.source !== 'process.env' && source === 'process.env') continue;
|
|
282
|
+
if (existing) {
|
|
283
|
+
if (!existing.sources) existing.sources = [existing.source];
|
|
284
|
+
if (!existing.sources.includes(source)) existing.sources.push(source);
|
|
285
|
+
if (existing.source === 'process.env' && source !== 'process.env') {
|
|
286
|
+
existing.source = source;
|
|
287
|
+
}
|
|
288
|
+
continue;
|
|
289
|
+
}
|
|
290
|
+
into.set(key, {
|
|
291
|
+
key,
|
|
292
|
+
source,
|
|
293
|
+
sources: [source],
|
|
294
|
+
provider: inferProvider(key),
|
|
295
|
+
masked: maskSecret(value),
|
|
296
|
+
length: String(value).length,
|
|
297
|
+
known: true, // all pattern-matched secrets are first-class; no API whitelist
|
|
298
|
+
// internal only — stripped before API response
|
|
299
|
+
_value: String(value),
|
|
300
|
+
});
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/**
|
|
305
|
+
* Scan process.env + default env files.
|
|
306
|
+
* @param {{ brainDir?: string, projectRoot?: string, cwd?: string, includeProcessEnv?: boolean }} opts
|
|
307
|
+
*/
|
|
308
|
+
export function scanEnvSources(opts = {}) {
|
|
309
|
+
const includeProcessEnv = opts.includeProcessEnv !== false;
|
|
310
|
+
const candidates = new Map();
|
|
311
|
+
|
|
312
|
+
for (const filePath of defaultEnvFilePaths(opts)) {
|
|
313
|
+
try {
|
|
314
|
+
if (!fs.existsSync(filePath) || !fs.statSync(filePath).isFile()) continue;
|
|
315
|
+
const text = fs.readFileSync(filePath, 'utf8');
|
|
316
|
+
mergeCandidates(parseEnvText(text), filePath, candidates);
|
|
317
|
+
} catch {
|
|
318
|
+
// unreadable path — skip
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
if (includeProcessEnv) {
|
|
323
|
+
const envMap = {};
|
|
324
|
+
for (const [k, v] of Object.entries(process.env)) {
|
|
325
|
+
if (typeof v === 'string' && v) envMap[k] = v;
|
|
326
|
+
}
|
|
327
|
+
mergeCandidates(envMap, 'process.env', candidates);
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
const list = [...candidates.values()].sort((a, b) => a.key.localeCompare(b.key));
|
|
331
|
+
return {
|
|
332
|
+
candidates: list,
|
|
333
|
+
sources_scanned: [
|
|
334
|
+
...(includeProcessEnv ? ['process.env'] : []),
|
|
335
|
+
...defaultEnvFilePaths(opts),
|
|
336
|
+
],
|
|
337
|
+
};
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/**
|
|
341
|
+
* Public scan result (no raw values).
|
|
342
|
+
*/
|
|
343
|
+
export function publicScanResult(scan, existingKeys = new Set()) {
|
|
344
|
+
return {
|
|
345
|
+
candidates: scan.candidates.map(({ key, source, provider, masked, length, known }) => ({
|
|
346
|
+
key,
|
|
347
|
+
source: source === 'process.env' ? 'process.env' : path.basename(source) === source ? source : source,
|
|
348
|
+
source_label: source === 'process.env' ? 'shell environment' : shortenPath(source),
|
|
349
|
+
provider,
|
|
350
|
+
masked,
|
|
351
|
+
length,
|
|
352
|
+
known,
|
|
353
|
+
already_set: existingKeys.has(key),
|
|
354
|
+
})),
|
|
355
|
+
count: scan.candidates.length,
|
|
356
|
+
sources_scanned: scan.sources_scanned.map((s) =>
|
|
357
|
+
s === 'process.env' ? s : shortenPath(s),
|
|
358
|
+
),
|
|
359
|
+
};
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
function shortenPath(p) {
|
|
363
|
+
const home = os.homedir();
|
|
364
|
+
if (p.startsWith(home)) return `~${p.slice(home.length)}`;
|
|
365
|
+
return p;
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
/**
|
|
369
|
+
* Import selected keys into secrets store.
|
|
370
|
+
* @param {string} brainDir
|
|
371
|
+
* @param {{ keys?: string[], pairs?: Record<string,string>, overwrite?: boolean, actor?: string }} opts
|
|
372
|
+
* - keys: import from scan (must re-scan for values)
|
|
373
|
+
* - pairs: explicit key→value (from paste)
|
|
374
|
+
*/
|
|
375
|
+
export async function importEnvSecrets(brainDir, opts = {}) {
|
|
376
|
+
const overwrite = !!opts.overwrite;
|
|
377
|
+
const actor = opts.actor || 'import';
|
|
378
|
+
const existing = await listSecretsMeta(brainDir);
|
|
379
|
+
const existingSet = new Set(existing.map((e) => e.key));
|
|
380
|
+
const existingByKey = new Map(existing.map((e) => [e.key, e]));
|
|
381
|
+
|
|
382
|
+
/** @type {Record<string, string>} */
|
|
383
|
+
let values = {};
|
|
384
|
+
|
|
385
|
+
if (opts.pairs && typeof opts.pairs === 'object') {
|
|
386
|
+
for (const [k, v] of Object.entries(opts.pairs)) {
|
|
387
|
+
if (isCandidateKey(k) && v) values[k] = String(v);
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
/** @type {Record<string, string>} */
|
|
392
|
+
const sourceByKey = { ...(opts.sourceByKey || {}) };
|
|
393
|
+
/** @type {Record<string, string[]>} */
|
|
394
|
+
const sourcesByKey = {};
|
|
395
|
+
|
|
396
|
+
if (Array.isArray(opts.keys) && opts.keys.length) {
|
|
397
|
+
const scan = scanEnvSources({ brainDir, includeProcessEnv: true });
|
|
398
|
+
const byKey = new Map(scan.candidates.map((c) => [c.key, c]));
|
|
399
|
+
for (const k of opts.keys) {
|
|
400
|
+
const c = byKey.get(k);
|
|
401
|
+
if (c?._value) {
|
|
402
|
+
values[k] = c._value;
|
|
403
|
+
sourceByKey[k] = c.source;
|
|
404
|
+
sourcesByKey[k] = c.sources || [c.source];
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
// If only "import all" with empty keys and no pairs — import everything from scan
|
|
410
|
+
if (!opts.pairs && (!opts.keys || opts.keys.length === 0) && opts.all) {
|
|
411
|
+
const scan = scanEnvSources({ brainDir, includeProcessEnv: true });
|
|
412
|
+
for (const c of scan.candidates) {
|
|
413
|
+
if (c._value) {
|
|
414
|
+
values[c.key] = c._value;
|
|
415
|
+
sourceByKey[c.key] = c.source;
|
|
416
|
+
sourcesByKey[c.key] = c.sources || [c.source];
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
const imported = [];
|
|
422
|
+
const skipped = [];
|
|
423
|
+
const errors = [];
|
|
424
|
+
|
|
425
|
+
for (const [key, value] of Object.entries(values)) {
|
|
426
|
+
const isSet = existingSet.has(key);
|
|
427
|
+
|
|
428
|
+
const sources = sourcesByKey[key] || (sourceByKey[key] ? [sourceByKey[key]] : []);
|
|
429
|
+
let computedRepos = [];
|
|
430
|
+
for (const src of sources) {
|
|
431
|
+
if (src && typeof src === 'string' && src !== 'process.env' && src !== 'paste') {
|
|
432
|
+
const parts = src.replace(/\\/g, '/').split('/');
|
|
433
|
+
const envIdx = parts.findIndex((p) => p.startsWith('.env'));
|
|
434
|
+
if (envIdx > 0) computedRepos.push(parts[envIdx - 1]);
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
const existingEntry = existingByKey.get(key);
|
|
438
|
+
const existingRepos = existingEntry?.repos || [];
|
|
439
|
+
const finalRepos = [...new Set([...existingRepos, ...computedRepos])].filter(Boolean);
|
|
440
|
+
|
|
441
|
+
// If already set and not overwriting, we only append repos
|
|
442
|
+
if (isSet && !overwrite) {
|
|
443
|
+
if (finalRepos.length > existingRepos.length) {
|
|
444
|
+
try {
|
|
445
|
+
await updateSecretMeta(brainDir, key, { repos: finalRepos }, { actor });
|
|
446
|
+
imported.push({ key, provider: inferProvider(key), repos: finalRepos, meta_only: true });
|
|
447
|
+
} catch (err) {
|
|
448
|
+
errors.push({ key, error: err.message });
|
|
449
|
+
}
|
|
450
|
+
} else {
|
|
451
|
+
skipped.push({ key, reason: 'already_set' });
|
|
452
|
+
}
|
|
453
|
+
continue;
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
try {
|
|
457
|
+
await setSecret(brainDir, key, value, {
|
|
458
|
+
provider: inferProvider(key),
|
|
459
|
+
scope: 'global',
|
|
460
|
+
repos: finalRepos.length > 0 ? finalRepos : undefined,
|
|
461
|
+
actor,
|
|
462
|
+
});
|
|
463
|
+
imported.push({ key, provider: inferProvider(key), repos: finalRepos });
|
|
464
|
+
existingSet.add(key);
|
|
465
|
+
} catch (err) {
|
|
466
|
+
errors.push({ key, error: err.message });
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
return {
|
|
471
|
+
imported,
|
|
472
|
+
skipped,
|
|
473
|
+
errors,
|
|
474
|
+
imported_count: imported.length,
|
|
475
|
+
skipped_count: skipped.length,
|
|
476
|
+
};
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
/**
|
|
480
|
+
* Parse pasted .env and return public candidates + internal pairs for import.
|
|
481
|
+
* @param {string} text
|
|
482
|
+
*/
|
|
483
|
+
export function candidatesFromPaste(text) {
|
|
484
|
+
const map = parseEnvText(text);
|
|
485
|
+
const candidates = [];
|
|
486
|
+
const pairs = {};
|
|
487
|
+
for (const [key, value] of Object.entries(map)) {
|
|
488
|
+
if (!isCandidateKey(key) || !value) continue;
|
|
489
|
+
candidates.push({
|
|
490
|
+
key,
|
|
491
|
+
source: 'paste',
|
|
492
|
+
source_label: 'pasted .env',
|
|
493
|
+
provider: inferProvider(key),
|
|
494
|
+
masked: maskSecret(value),
|
|
495
|
+
length: String(value).length,
|
|
496
|
+
known: true,
|
|
497
|
+
});
|
|
498
|
+
pairs[key] = value;
|
|
499
|
+
}
|
|
500
|
+
candidates.sort((a, b) => a.key.localeCompare(b.key));
|
|
501
|
+
return { candidates, pairs };
|
|
502
|
+
}
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
import { importHostModule } from './host-runtime.mjs';
|
|
2
|
+
const hostModule = await importHostModule('src/core/fact-seeker.mjs');
|
|
3
|
+
export const { getLocalizedDateTime, formatBeautifulDate, loadAgenda, addToAgenda, CANCELLED_STATUS, cancelBogusAgendaTopics, reclassifyCancelledAgendaTopics, markTopicResearched, getNextAgendaTopic, inferTopicsFromSession, buildMultiNoteGraph, runSelfDiagnosis, runKnowledgeAcquisitionCycle, ingestSessionTopics, runResearchDeliberationCycle, runResearchImprovementCycle, runMemoryCompaction } = hostModule;
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import crypto from 'node:crypto';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { loadSecretsSync, saveSecretsSync } from './secrets-gateway.mjs';
|
|
4
|
+
import { authState, appendAuthMetadata, assertNoSecretValues } from './metadata-state.mjs';
|
|
5
|
+
|
|
6
|
+
const STORE_KEY = 'google_oauth_accounts';
|
|
7
|
+
const digest = value => crypto.createHash('sha256').update(value).digest('hex');
|
|
8
|
+
const subject = value => {
|
|
9
|
+
if (typeof value !== 'string' || !/^[A-Za-z0-9_-]{1,255}$/.test(value)) throw new Error('Invalid Google account identifier');
|
|
10
|
+
return value;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
/** Tokens remain opaque encrypted auth resources. SSSS stores lifecycle metadata only. */
|
|
14
|
+
export function createGoogleOAuth({ brainDir, load = loadSecretsSync, save = saveSecretsSync,
|
|
15
|
+
fetchImpl = fetch, now = Date.now, metadata, record } = {}) {
|
|
16
|
+
if (!brainDir) throw new Error('An explicit auth store is required');
|
|
17
|
+
const state = metadata || authState(path.join(brainDir, 'config/google-oauth-metadata.json'));
|
|
18
|
+
const audit = record || (row => appendAuthMetadata(path.join(brainDir, 'logs/google-oauth.jsonl'), row));
|
|
19
|
+
const pending = new Map();
|
|
20
|
+
const refreshes = new Map();
|
|
21
|
+
const config = () => {
|
|
22
|
+
const values = load(brainDir);
|
|
23
|
+
if (!values.GOOGLE_CLIENT_ID || !values.GOOGLE_CLIENT_SECRET) throw new Error('Google OAuth is not configured');
|
|
24
|
+
const redirect = new URL(values.GOOGLE_REDIRECT_URI || '');
|
|
25
|
+
if ((redirect.protocol !== 'https:' && !(redirect.protocol === 'http:' && ['localhost', '127.0.0.1', '[::1]'].includes(redirect.hostname))) || redirect.username || redirect.password || redirect.search || redirect.hash || redirect.pathname !== '/auth/google/callback') throw new Error('Configure a trusted GOOGLE_REDIRECT_URI ending in /auth/google/callback');
|
|
26
|
+
return { clientId: values.GOOGLE_CLIENT_ID, clientSecret: values.GOOGLE_CLIENT_SECRET, redirectUri: redirect.href };
|
|
27
|
+
};
|
|
28
|
+
const status = () => {
|
|
29
|
+
const rows = state.read({}); assertNoSecretValues(rows);
|
|
30
|
+
return Object.values(rows);
|
|
31
|
+
};
|
|
32
|
+
const commit = (id, credentials, phase) => {
|
|
33
|
+
// Read both snapshots immediately before synchronous commits; never retain a stale whole store across HTTP awaits.
|
|
34
|
+
const values = load(brainDir);
|
|
35
|
+
const previous = values[STORE_KEY]?.[id] || {};
|
|
36
|
+
const accounts = { ...(values[STORE_KEY] || {}) };
|
|
37
|
+
const rows = state.read({});
|
|
38
|
+
const timestamp = new Date(now()).toISOString();
|
|
39
|
+
if (credentials === null) delete accounts[id];
|
|
40
|
+
else accounts[id] = { ...previous, ...credentials };
|
|
41
|
+
const account = accounts[id];
|
|
42
|
+
rows[id] = { account_id: id, email: account?.email || rows[id]?.email || '',
|
|
43
|
+
status: phase, expiry: account?.expires_at || null,
|
|
44
|
+
scopes: account?.scope?.split(/\s+/).filter(Boolean) || [],
|
|
45
|
+
can_refresh: Boolean(account?.refresh_token), updated_at: timestamp };
|
|
46
|
+
assertNoSecretValues(rows);
|
|
47
|
+
// Audit intent before external credential commit; completed metadata is never reported before encryption succeeds.
|
|
48
|
+
audit({ action: 'google_oauth_intent', account_id: id, status: phase, timestamp });
|
|
49
|
+
save(brainDir, { ...values, [STORE_KEY]: accounts });
|
|
50
|
+
state.write(rows);
|
|
51
|
+
audit({ action: 'google_oauth_committed', account_id: id, status: phase, timestamp });
|
|
52
|
+
return rows[id];
|
|
53
|
+
};
|
|
54
|
+
const requestTokens = async parameters => {
|
|
55
|
+
const cfg = config();
|
|
56
|
+
const response = await fetchImpl('https://oauth2.googleapis.com/token', {
|
|
57
|
+
method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
58
|
+
body: new URLSearchParams({ client_id: cfg.clientId, client_secret: cfg.clientSecret, ...parameters }),
|
|
59
|
+
signal: AbortSignal.timeout(15000), redirect: 'error',
|
|
60
|
+
});
|
|
61
|
+
const data = await response.json();
|
|
62
|
+
if (!response.ok) {
|
|
63
|
+
const error = new Error(data.error === 'invalid_grant' ? 'Google authorization expired or revoked; reconnect required' : 'Google token exchange failed');
|
|
64
|
+
error.reconnect = data.error === 'invalid_grant'; throw error;
|
|
65
|
+
}
|
|
66
|
+
if (typeof data.access_token !== 'string' || !data.access_token || data.token_type?.toLowerCase() !== 'bearer' || !Number.isFinite(Number(data.expires_in)) || Number(data.expires_in) <= 0) throw new Error('Invalid Google token response');
|
|
67
|
+
return { access_token: data.access_token, ...(typeof data.refresh_token === 'string' && data.refresh_token ? { refresh_token: data.refresh_token } : {}),
|
|
68
|
+
...(typeof data.scope === 'string' ? { scope: data.scope } : {}), expires_at: new Date(now() + Number(data.expires_in) * 1000).toISOString() };
|
|
69
|
+
};
|
|
70
|
+
return {
|
|
71
|
+
config, status,
|
|
72
|
+
begin() {
|
|
73
|
+
const cfg = config();
|
|
74
|
+
for (const [key, value] of pending) if (value.expires <= now()) pending.delete(key);
|
|
75
|
+
if (pending.size >= 1000) throw new Error('Too many pending authorizations');
|
|
76
|
+
const stateValue = crypto.randomBytes(32).toString('base64url');
|
|
77
|
+
const browser = crypto.randomBytes(32).toString('base64url');
|
|
78
|
+
const verifier = crypto.randomBytes(48).toString('base64url');
|
|
79
|
+
pending.set(stateValue, { browser: digest(browser), verifier, expires: now() + 600000, redirectUri: cfg.redirectUri });
|
|
80
|
+
const url = new URL('https://accounts.google.com/o/oauth2/v2/auth');
|
|
81
|
+
for (const [key, value] of Object.entries({ client_id: cfg.clientId, redirect_uri: cfg.redirectUri, response_type: 'code', scope: 'openid email profile',
|
|
82
|
+
state: stateValue, access_type: 'offline', prompt: 'consent select_account', code_challenge: crypto.createHash('sha256').update(verifier).digest('base64url'), code_challenge_method: 'S256' })) url.searchParams.set(key, value);
|
|
83
|
+
return { url: url.href, redirectUri: cfg.redirectUri, browser };
|
|
84
|
+
},
|
|
85
|
+
async complete({ code, state: stateValue, browser }) {
|
|
86
|
+
const attempt = typeof stateValue === 'string' ? pending.get(stateValue) : null;
|
|
87
|
+
if (!attempt || attempt.expires <= now() || typeof browser !== 'string' || !crypto.timingSafeEqual(Buffer.from(attempt.browser), Buffer.from(digest(browser)))) throw new Error('Invalid or expired OAuth browser state');
|
|
88
|
+
pending.delete(stateValue);
|
|
89
|
+
if (typeof code !== 'string' || !code || code.length > 8192 || config().redirectUri !== attempt.redirectUri) throw new Error('Invalid OAuth callback');
|
|
90
|
+
const tokens = await requestTokens({ code, redirect_uri: attempt.redirectUri, grant_type: 'authorization_code', code_verifier: attempt.verifier });
|
|
91
|
+
const response = await fetchImpl('https://www.googleapis.com/oauth2/v3/userinfo', { headers: { Authorization: `Bearer ${tokens.access_token}` }, signal: AbortSignal.timeout(15000), redirect: 'error' });
|
|
92
|
+
if (!response.ok) throw new Error('Google identity verification failed');
|
|
93
|
+
const user = await response.json();
|
|
94
|
+
const id = subject(user.sub);
|
|
95
|
+
if (user.email_verified !== true || typeof user.email !== 'string' || !user.email.includes('@')) throw new Error('Google identity is not verified');
|
|
96
|
+
const allowed = load(brainDir).GOOGLE_ALLOWED_EMAILS;
|
|
97
|
+
const emails = Array.isArray(allowed) ? allowed : typeof allowed === 'string' ? allowed.split(',') : [];
|
|
98
|
+
if (!emails.some(email => typeof email === 'string' && email.trim().toLowerCase() === user.email.toLowerCase())) throw new Error('Google account is not authorized for this dashboard');
|
|
99
|
+
commit(id, { ...tokens, email: user.email, name: typeof user.name === 'string' ? user.name : '' }, 'connected');
|
|
100
|
+
return { sub: id, email: user.email, name: typeof user.name === 'string' ? user.name : '' };
|
|
101
|
+
},
|
|
102
|
+
async access(id) {
|
|
103
|
+
id = subject(id);
|
|
104
|
+
const account = load(brainDir)[STORE_KEY]?.[id];
|
|
105
|
+
if (!account) throw new Error('Google account is not connected');
|
|
106
|
+
if (account.access_token && Date.parse(account.expires_at) > now() + 60000) return account.access_token;
|
|
107
|
+
if (!account.refresh_token) { commit(id, null, 'reconnect_required'); throw new Error('Google account must be reconnected'); }
|
|
108
|
+
if (!refreshes.has(id)) {
|
|
109
|
+
const operation = (async () => {
|
|
110
|
+
try {
|
|
111
|
+
const tokens = await requestTokens({ refresh_token: account.refresh_token, grant_type: 'refresh_token' });
|
|
112
|
+
const current = load(brainDir)[STORE_KEY]?.[id];
|
|
113
|
+
if (!current || current.refresh_token !== account.refresh_token) throw new Error('Google connection changed while refreshing');
|
|
114
|
+
commit(id, tokens, 'connected'); return tokens.access_token;
|
|
115
|
+
} catch (error) {
|
|
116
|
+
if (error.reconnect) {
|
|
117
|
+
const current = load(brainDir)[STORE_KEY]?.[id];
|
|
118
|
+
if (current?.refresh_token === account.refresh_token) commit(id, null, 'reconnect_required');
|
|
119
|
+
}
|
|
120
|
+
throw error;
|
|
121
|
+
}
|
|
122
|
+
})().finally(() => refreshes.delete(id));
|
|
123
|
+
refreshes.set(id, operation);
|
|
124
|
+
}
|
|
125
|
+
return refreshes.get(id);
|
|
126
|
+
},
|
|
127
|
+
async disconnect(id) {
|
|
128
|
+
id = subject(id);
|
|
129
|
+
const account = load(brainDir)[STORE_KEY]?.[id];
|
|
130
|
+
if (!account) return { account_id: id, disconnected: true };
|
|
131
|
+
const response = await fetchImpl('https://oauth2.googleapis.com/revoke', { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
132
|
+
body: new URLSearchParams({ token: account.refresh_token || account.access_token }), signal: AbortSignal.timeout(15000), redirect: 'error' });
|
|
133
|
+
if (!response.ok) throw new Error('Google revocation failed; connection retained');
|
|
134
|
+
const current = load(brainDir)[STORE_KEY]?.[id];
|
|
135
|
+
if (current?.refresh_token !== account.refresh_token || current?.access_token !== account.access_token) throw new Error('Google connection changed during revocation');
|
|
136
|
+
commit(id, null, 'disconnected'); return { account_id: id, disconnected: true };
|
|
137
|
+
},
|
|
138
|
+
};
|
|
139
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { pathToFileURL } from 'node:url';
|
|
3
|
+
export function importHostModule(relative) {
|
|
4
|
+
const root = process.env.TR_PACKAGE_ROOT;
|
|
5
|
+
if (!root || !path.isAbsolute(root) || !/^src\/(core|cli|server)\/[A-Za-z0-9_./-]+\.mjs$/.test(relative) || relative.includes('..')) throw new Error('Explicit valid Total Recall host runtime required');
|
|
6
|
+
return import(pathToFileURL(path.join(root, relative)).href);
|
|
7
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { importHostModule } from './host-runtime.mjs';
|
|
2
|
+
const { loadPluginModule } = await importHostModule('src/core/plugin-runtime.mjs');
|
|
3
|
+
export async function FAILOVER_BOUND_MS(...args) { return (await loadPluginModule('mesh', 'leader-election')).FAILOVER_BOUND_MS(...args); }
|
|
4
|
+
export async function leaderPin(...args) { return (await loadPluginModule('mesh', 'leader-election')).leaderPin(...args); }
|
|
5
|
+
export async function getLeaderInfo(...args) { return (await loadPluginModule('mesh', 'leader-election')).getLeaderInfo(...args); }
|
|
6
|
+
export async function isLeader(...args) { return (await loadPluginModule('mesh', 'leader-election')).isLeader(...args); }
|
|
7
|
+
export async function tryAcquireLease(...args) { return (await loadPluginModule('mesh', 'leader-election')).tryAcquireLease(...args); }
|
|
8
|
+
export async function renewLease(...args) { return (await loadPluginModule('mesh', 'leader-election')).renewLease(...args); }
|
|
9
|
+
export async function releaseLease(...args) { return (await loadPluginModule('mesh', 'leader-election')).releaseLease(...args); }
|
package/src/logger.mjs
ADDED