tr-plugin-auth 0.0.0-stage → 1.0.1

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 +302 -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 +1695 -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,437 @@
1
+ /**
2
+ * Secrets rotation orchestration.
3
+ *
4
+ * - Detect overdue keys (schedule metadata)
5
+ * - Enqueue assisted rotation tasks (optional browser-use)
6
+ * - After rotate: re-export .env to bound repos
7
+ * - TR PATs can be auto-rotated when explicitly requested
8
+ *
9
+ * Never log secret values. Never write values into vault/openwiki.
10
+ */
11
+
12
+ import fs from 'node:fs';
13
+ import path from 'node:path';
14
+ import {
15
+ listRotationDue,
16
+ listSecretsMeta,
17
+ rotateSecret,
18
+ getSecret,
19
+ updateSecretMeta,
20
+ } from './secrets-store.mjs';
21
+ import { exportEnvToProject, exportEnvToRegistry, loadProjectRegistry, projectSlugFromPath } from './secrets-env-export.mjs';
22
+ import { getProvider, providerForKeyName } from './provider-catalog.mjs';
23
+ import { atomicWrite, safeStringify } from './vault.mjs';
24
+ import { getRotationPlan, generateSecretValue } from './rotation-capability.mjs';
25
+ import { getRecipe, valueLooksValid } from './provider-rotation-recipes.mjs';
26
+ import {
27
+ launchRotationContext,
28
+ openConsole,
29
+ waitForLogin,
30
+ closeContext,
31
+ } from './browser-session.mjs';
32
+
33
+ /**
34
+ * Build a supervised browser-use prompt for rotating one key.
35
+ * Does not perform automation itself.
36
+ */
37
+ export function buildBrowserRotatePrompt(secretMeta) {
38
+ const catalog = getProvider(secretMeta.provider) || providerForKeyName(secretMeta.key);
39
+ const consoleUrl = secretMeta.console_url || catalog?.console_url || '(provider console)';
40
+ const docsUrl = secretMeta.api_docs_url || catalog?.docs_url || '';
41
+
42
+ return [
43
+ `Rotate API credential for secret key: ${secretMeta.key}`,
44
+ `Provider: ${secretMeta.provider || catalog?.id || 'unknown'}`,
45
+ '',
46
+ 'SUPERVISED browser workflow (do NOT auto-revoke until TR confirms store+export):',
47
+ `1. Open: ${consoleUrl}`,
48
+ '2. Complete login / 2FA with the human if needed.',
49
+ '3. Create a NEW API key (do not delete the old one yet).',
50
+ '4. Copy the new key value ONCE — never paste into chat logs or vault markdown.',
51
+ '5. Tell the human to run (or run via tool if value is only in a secure channel):',
52
+ ` npx total-recall secret rotate ${secretMeta.key} "<NEW_VALUE>" --export-env`,
53
+ '6. After TR confirms export succeeded, revoke/disable the OLD key in the console.',
54
+ '',
55
+ docsUrl ? `API docs: ${docsUrl}` : '',
56
+ secretMeta.repos?.length ? `Bound repos: ${secretMeta.repos.join(', ')}` : 'Repos: (unbound / global projection)',
57
+ secretMeta.next_rotate_due ? `Was due: ${secretMeta.next_rotate_due}` : '',
58
+ '',
59
+ 'Safety: never screenshot the full key; never write the value into remember/openwiki.',
60
+ ]
61
+ .filter(Boolean)
62
+ .join('\n');
63
+ }
64
+
65
+ /**
66
+ * Enqueue one queue task per overdue secret (or refresh existing pending).
67
+ * @param {string} brainDir
68
+ * @param {{ autoOnly?: boolean, queueDir?: string }} opts
69
+ */
70
+ export async function enqueueRotationDueTasks(brainDir, opts = {}) {
71
+ const due = await listRotationDue(brainDir, { autoOnly: !!opts.autoOnly });
72
+ const queueDir =
73
+ opts.queueDir || path.join(brainDir, 'scheduler', 'queue');
74
+ if (!fs.existsSync(queueDir)) fs.mkdirSync(queueDir, { recursive: true });
75
+
76
+ const created = [];
77
+ const now = new Date().toISOString();
78
+
79
+ for (const secret of due) {
80
+ const slug = `secret-rotate-${secret.key}`
81
+ .toLowerCase()
82
+ .replace(/[^a-z0-9_-]+/g, '-')
83
+ .slice(0, 80);
84
+ const filePath = path.join(queueDir, `${slug}.md`);
85
+ if (fs.existsSync(filePath)) {
86
+ // leave existing pending task
87
+ created.push({ key: secret.key, slug, status: 'exists' });
88
+ continue;
89
+ }
90
+
91
+ const prompt = buildBrowserRotatePrompt(secret);
92
+ const front = {
93
+ type: 'task',
94
+ slug,
95
+ category: 'secrets-rotation',
96
+ title: `Rotate secret: ${secret.key}`,
97
+ status: 'pending',
98
+ priority: secret.auto_rotate ? 1 : 2,
99
+ executor: 'secrets-rotation-assist',
100
+ capabilities: ['secrets:rotate', 'notify'],
101
+ intent: `Rotate overdue secret ${secret.key} (supervised browser or provider API)`,
102
+ payload: {
103
+ secret_key: secret.key,
104
+ provider: secret.provider,
105
+ auto_rotate: !!secret.auto_rotate,
106
+ console_url: secret.console_url,
107
+ browser_prompt: true,
108
+ },
109
+ created: now,
110
+ updated: now,
111
+ };
112
+
113
+ const body = [
114
+ `## Objective`,
115
+ ``,
116
+ `Rotate \`${secret.key}\` — schedule overdue.`,
117
+ ``,
118
+ `## Browser-use instructions`,
119
+ ``,
120
+ '```',
121
+ prompt,
122
+ '```',
123
+ ``,
124
+ `## After new value is obtained`,
125
+ ``,
126
+ '```bash',
127
+ `npx total-recall secret rotate ${secret.key} "<NEW_VALUE>" --export-env`,
128
+ '```',
129
+ ].join('\n');
130
+
131
+ atomicWrite(filePath, safeStringify(body, front));
132
+ created.push({ key: secret.key, slug, status: 'created', path: filePath });
133
+ }
134
+
135
+ return { due: due.length, tasks: created };
136
+ }
137
+
138
+ /**
139
+ * Rotate value then re-export .env to bound repos (and optionally all registry).
140
+ */
141
+ export async function rotateSecretAndExport(brainDir, key, newValue, opts = {}) {
142
+ const result = await rotateSecret(brainDir, key, newValue, {
143
+ actor: opts.actor || 'cli',
144
+ provider: opts.provider,
145
+ });
146
+
147
+ const metaRows = await listSecretsMeta(brainDir);
148
+ const row = metaRows.find((r) => r.key === key);
149
+ const exports = [];
150
+
151
+ if (opts.exportEnv !== false) {
152
+ const registry = loadProjectRegistry();
153
+ const repos = row?.repos || [];
154
+ const boundPaths = new Set();
155
+
156
+ for (const r of repos) {
157
+ // match registry by name/slug
158
+ for (const entry of registry) {
159
+ if (entry.name === r || projectSlugFromPath(entry.path) === r) {
160
+ if (entry.path && fs.existsSync(entry.path)) boundPaths.add(path.resolve(entry.path));
161
+ }
162
+ }
163
+ // also allow absolute path in repos
164
+ if (r.startsWith('/') && fs.existsSync(r)) boundPaths.add(path.resolve(r));
165
+ }
166
+
167
+ if (row?.project_path && fs.existsSync(row.project_path)) {
168
+ boundPaths.add(path.resolve(row.project_path));
169
+ }
170
+
171
+ if (opts.exportAllProjects) {
172
+ const all = await exportEnvToRegistry(brainDir, {
173
+ includeGlobal: true,
174
+ example: true,
175
+ });
176
+ for (const r of all) exports.push(r);
177
+ } else if (boundPaths.size) {
178
+ for (const p of boundPaths) {
179
+ try {
180
+ const r = await exportEnvToProject(brainDir, p, {
181
+ includeGlobal: opts.includeGlobal !== false,
182
+ example: true,
183
+ });
184
+ exports.push({ ok: true, ...r });
185
+ } catch (err) {
186
+ exports.push({ ok: false, path: p, error: err.message });
187
+ }
188
+ }
189
+ } else if (opts.exportCwd) {
190
+ const r = await exportEnvToProject(brainDir, process.cwd(), {
191
+ includeGlobal: true,
192
+ example: true,
193
+ });
194
+ exports.push({ ok: true, ...r });
195
+ }
196
+ }
197
+
198
+ return {
199
+ ...result,
200
+ secret: row,
201
+ exports,
202
+ };
203
+ }
204
+
205
+ /**
206
+ * Daemon executor: scan due secrets and enqueue assist tasks.
207
+ */
208
+ export async function runSecretsRotationCheck(ctx) {
209
+ const result = await enqueueRotationDueTasks(ctx.brainDir, {
210
+ autoOnly: false,
211
+ queueDir: ctx.queueDir,
212
+ });
213
+ return {
214
+ success: true,
215
+ output: `Rotation check: ${result.due} due, ${result.tasks.filter((t) => t.status === 'created').length} tasks created`,
216
+ executor: 'secrets-rotation-check',
217
+ ...result,
218
+ };
219
+ }
220
+
221
+ /**
222
+ * Daemon executor: re-export all project envs from SSOT.
223
+ */
224
+ export async function runSecretsExportAll(ctx) {
225
+ const results = await exportEnvToRegistry(ctx.brainDir, {
226
+ includeGlobal: true,
227
+ example: true,
228
+ });
229
+ const ok = results.filter((r) => r.ok).length;
230
+ return {
231
+ success: true,
232
+ output: `export-env: ${ok}/${results.length} projects updated`,
233
+ executor: 'secrets-export-env',
234
+ results,
235
+ };
236
+ }
237
+
238
+ /**
239
+ * Print browser prompt for a key (CLI helper).
240
+ */
241
+ export async function getBrowserRotateAssist(brainDir, key) {
242
+ const rows = await listSecretsMeta(brainDir);
243
+ const row = rows.find((r) => r.key === key);
244
+ if (!row) throw new Error(`Secret not found: ${key}`);
245
+ return {
246
+ key,
247
+ prompt: buildBrowserRotatePrompt(row),
248
+ console_url: row.console_url,
249
+ docs_url: row.api_docs_url,
250
+ overdue: row.rotation_overdue,
251
+ next_rotate_due: row.next_rotate_due,
252
+ };
253
+ }
254
+
255
+ // ─── Executed rotation ──────────────────────────────────────────────────────────
256
+
257
+ /**
258
+ * Read the freshly-copied credential out of the browser's own clipboard.
259
+ *
260
+ * Provider consoles show a new key exactly once behind a "copy" button. Reading
261
+ * it here keeps the value inside TR's process — it never transits a shell
262
+ * argument, an env var, or a chat transcript.
263
+ *
264
+ * @param {any} page
265
+ * @returns {Promise<string|null>}
266
+ */
267
+ async function captureFromClipboard(page) {
268
+ try {
269
+ const text = await page.evaluate(() => navigator.clipboard.readText());
270
+ return typeof text === 'string' ? text.trim() : null;
271
+ } catch {
272
+ return null;
273
+ }
274
+ }
275
+
276
+ /**
277
+ * Drive a provider console to mint a new credential, then store + export it.
278
+ *
279
+ * Verified recipes are driven autonomously. Unverified ones run supervised: TR
280
+ * opens the console and waits while the human creates and copies the key. TR
281
+ * never blind-clicks guessed selectors on a billing dashboard.
282
+ *
283
+ * The old credential is NOT revoked here — revocation only happens after the
284
+ * new value is stored, exported, and (where possible) proven to authenticate.
285
+ *
286
+ * @param {string} brainDir
287
+ * @param {string} key
288
+ * @param {{
289
+ * headless?: boolean,
290
+ * label?: string,
291
+ * timeoutMs?: number,
292
+ * onStatus?: (msg: string) => void,
293
+ * }} [opts]
294
+ */
295
+ export async function rotateViaBrowser(brainDir, key, opts = {}) {
296
+ const say = opts.onStatus || (() => {});
297
+ const rows = await listSecretsMeta(brainDir);
298
+ const row = rows.find((r) => r.key === key);
299
+ if (!row) throw new Error(`Secret not found: ${key}`);
300
+
301
+ const plan = getRotationPlan(key, row);
302
+ const recipe = getRecipe(plan.provider);
303
+ if (!recipe) {
304
+ return {
305
+ ok: false,
306
+ key,
307
+ class: plan.class,
308
+ error: `no rotation recipe for provider "${plan.provider || 'unknown'}"`,
309
+ hint: 'Add an entry to provider-rotation-recipes.mjs to enable this.',
310
+ };
311
+ }
312
+
313
+ const launched = await launchRotationContext(brainDir, {
314
+ headless: opts.headless === true && recipe.verified,
315
+ timeoutMs: opts.timeoutMs,
316
+ });
317
+ if (!launched.ok) return { ok: false, key, error: launched.error };
318
+
319
+ const { context } = launched;
320
+ try {
321
+ say(`opening ${recipe.console_url}`);
322
+ const { page, authenticated } = await openConsole(context, recipe);
323
+
324
+ if (!authenticated) {
325
+ say('not signed in — complete login/2FA in the browser window');
326
+ const ok = await waitForLogin(page, recipe, {
327
+ timeoutMs: opts.timeoutMs ?? 180_000,
328
+ onWait: (s) => say(`waiting for login… ${s}s`),
329
+ });
330
+ if (!ok) return { ok: false, key, error: 'login timed out' };
331
+ say('signed in');
332
+ }
333
+
334
+ // Allow clipboard reads for this origin so the console's copy button works.
335
+ try {
336
+ await context.grantPermissions(['clipboard-read', 'clipboard-write'], {
337
+ origin: new URL(recipe.console_url).origin,
338
+ });
339
+ } catch {
340
+ /* some engines auto-grant; failure here is not fatal */
341
+ }
342
+
343
+ let value = null;
344
+ if (recipe.verified && typeof recipe.create === 'function') {
345
+ say('creating new credential');
346
+ value = await recipe.create(page, { label: opts.label || `total-recall ${new Date().toISOString().slice(0, 10)}` });
347
+ if (!value) value = await captureFromClipboard(page);
348
+ } else {
349
+ say(`SUPERVISED — ${recipe.create_hint}`);
350
+ say('TR is watching the clipboard; copy the new value when it appears.');
351
+ const deadline = Date.now() + (opts.timeoutMs ?? 300_000);
352
+ while (Date.now() < deadline) {
353
+ const candidate = await captureFromClipboard(page);
354
+ if (candidate && valueLooksValid(recipe, candidate)) {
355
+ value = candidate;
356
+ break;
357
+ }
358
+ await page.waitForTimeout(2000);
359
+ }
360
+ }
361
+
362
+ if (!value) return { ok: false, key, error: 'no new credential captured' };
363
+ if (!valueLooksValid(recipe, value)) {
364
+ // Shape mismatch means we probably read the wrong node — refuse to
365
+ // overwrite a working credential with garbage.
366
+ return { ok: false, key, error: 'captured value failed provider shape check; nothing was stored' };
367
+ }
368
+
369
+ // Prove the new credential works BEFORE it replaces the old one.
370
+ let verified = null;
371
+ if (typeof recipe.verify === 'function') {
372
+ say('verifying new credential against provider API');
373
+ verified = await recipe.verify(value);
374
+ if (!verified) {
375
+ return { ok: false, key, error: 'new credential failed verification; nothing was stored' };
376
+ }
377
+ }
378
+
379
+ say('storing + exporting');
380
+ const result = await rotateSecretAndExport(brainDir, key, value, {
381
+ actor: 'browser-rotation',
382
+ provider: plan.provider,
383
+ exportEnv: opts.exportEnv !== false,
384
+ });
385
+ value = null; // drop the reference promptly
386
+
387
+ return {
388
+ ok: true,
389
+ key,
390
+ provider: plan.provider,
391
+ supervised: !recipe.verified,
392
+ verified,
393
+ exports: result.exports,
394
+ revoke_hint: recipe.revoke_hint || 'Revoke the old credential in the console now that the new one is live.',
395
+ };
396
+ } finally {
397
+ await closeContext(context);
398
+ }
399
+ }
400
+
401
+ /**
402
+ * Rotate any key by whichever method its class supports.
403
+ *
404
+ * self_generated keys are fully automatic — no browser, no human, no provider.
405
+ *
406
+ * @param {string} brainDir
407
+ * @param {string} key
408
+ * @param {object} [opts]
409
+ */
410
+ export async function rotateAuto(brainDir, key, opts = {}) {
411
+ const rows = await listSecretsMeta(brainDir);
412
+ const row = rows.find((r) => r.key === key);
413
+ if (!row) throw new Error(`Secret not found: ${key}`);
414
+ const plan = getRotationPlan(key, row);
415
+
416
+ if (plan.class === 'self_generated') {
417
+ const value = generateSecretValue(key);
418
+ const result = await rotateSecretAndExport(brainDir, key, value, {
419
+ actor: 'self-generated-rotation',
420
+ exportEnv: opts.exportEnv !== false,
421
+ });
422
+ return { ok: true, key, class: plan.class, method: 'self_generated', exports: result.exports };
423
+ }
424
+
425
+ if (plan.class === 'provider_browser' || plan.class === 'provider_api') {
426
+ return { ...(await rotateViaBrowser(brainDir, key, opts)), class: plan.class, method: 'browser' };
427
+ }
428
+
429
+ return {
430
+ ok: false,
431
+ key,
432
+ class: plan.class,
433
+ method: 'manual',
434
+ error: plan.reason,
435
+ console_url: plan.console_url,
436
+ };
437
+ }