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,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
|
+
}
|