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.
Files changed (60) hide show
  1. package/README.md +45 -2
  2. package/cli.mjs +384 -0
  3. package/package.json +40 -4
  4. package/plugin.json +266 -0
  5. package/src/access-keys.mjs +225 -0
  6. package/src/agent-dir.mjs +3 -0
  7. package/src/browser-session.mjs +240 -0
  8. package/src/cli-secret.mjs +1687 -0
  9. package/src/config.mjs +4 -0
  10. package/src/configuration.mjs +19 -0
  11. package/src/credential-preservation.mjs +28 -0
  12. package/src/crypto.mjs +195 -0
  13. package/src/env-import.mjs +502 -0
  14. package/src/fact-seeker.mjs +3 -0
  15. package/src/frontmatter.mjs +3 -0
  16. package/src/google-oauth.mjs +139 -0
  17. package/src/host-runtime.mjs +7 -0
  18. package/src/leader-election.mjs +9 -0
  19. package/src/logger.mjs +3 -0
  20. package/src/mesh-auth.mjs +7 -0
  21. package/src/mesh-identity.mjs +16 -0
  22. package/src/mesh-secrets.mjs +146 -0
  23. package/src/mesh.mjs +23 -0
  24. package/src/metadata-state.mjs +17 -0
  25. package/src/project-id.mjs +3 -0
  26. package/src/provider-account-sync.mjs +1145 -0
  27. package/src/provider-catalog.mjs +515 -0
  28. package/src/provider-rotation-recipes.mjs +299 -0
  29. package/src/research-gate.mjs +3 -0
  30. package/src/research-queue.mjs +3 -0
  31. package/src/rotation-capability.mjs +189 -0
  32. package/src/routes/auth.mjs +207 -0
  33. package/src/routes/keys.mjs +61 -0
  34. package/src/routes/secrets-runtime.mjs +5 -0
  35. package/src/routes/secrets.mjs +747 -0
  36. package/src/routes/webauthn.mjs +163 -0
  37. package/src/runtime-credentials.mjs +19 -0
  38. package/src/runtime.mjs +3 -0
  39. package/src/secret-integration-research.mjs +340 -0
  40. package/src/secrets-env-diff.mjs +132 -0
  41. package/src/secrets-env-export.mjs +322 -0
  42. package/src/secrets-gateway.mjs +253 -0
  43. package/src/secrets-keychain.mjs +83 -0
  44. package/src/secrets-rekey.mjs +366 -0
  45. package/src/secrets-remote-deploy.mjs +250 -0
  46. package/src/secrets-rotate.mjs +437 -0
  47. package/src/secrets-store.mjs +756 -0
  48. package/src/secrets-sync.mjs +81 -0
  49. package/src/secure-file.mjs +71 -0
  50. package/src/server-auth.mjs +500 -0
  51. package/src/session-credentials.mjs +71 -0
  52. package/src/ssss-store.mjs +13 -0
  53. package/src/stdin.mjs +31 -0
  54. package/src/throttled-fetch.mjs +3 -0
  55. package/src/vault.mjs +3 -0
  56. package/src/vfs-documents.mjs +3 -0
  57. package/src/webauthn-store.mjs +265 -0
  58. package/ui/DESIGN.md +19 -0
  59. package/ui/card.js +274 -0
  60. 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,3 @@
1
+ import { importHostModule } from './host-runtime.mjs';
2
+ const hostModule = await importHostModule('src/core/frontmatter.mjs');
3
+ export default hostModule.default;
@@ -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
@@ -0,0 +1,3 @@
1
+ import { importHostModule } from './host-runtime.mjs';
2
+ const hostModule = await importHostModule('src/core/logger.mjs');
3
+ export const { logEvents, logger, LOG_DIR, getLogFile } = hostModule;