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,747 @@
1
+ /**
2
+ * /api/secrets/* — provider secrets catalog (not dashboard PATs; see /api/keys)
3
+ *
4
+ * SSOT: secrets store. Repo/deploy .env files are projections via export-env.
5
+ *
6
+ * - GET/POST catalog, meta, rotate, usage, import-env (explicit credential import)
7
+ * - POST /api/secrets/export-env write .env from store → path or all projects
8
+ * - GET /api/secrets/export-env dry-run / preview keys for a path
9
+ */
10
+
11
+ import express from 'express';
12
+ import path from 'node:path';
13
+ import {
14
+ listSecretsMeta,
15
+ setSecret,
16
+ getSecret,
17
+ getSecretsCatalog,
18
+ updateSecretMeta,
19
+ rotateSecret,
20
+ listRotationDue,
21
+ recordUsage,
22
+ summarizeUsage,
23
+ summarizeUsageByKey,
24
+ listUsageEvents,
25
+ deleteSecret,
26
+ } from '../secrets-store.mjs';
27
+ import { logger } from '../logger.mjs';
28
+ import { getMeshSyncAuthorization, requireMeshSyncAuth } from '../mesh-auth.mjs';
29
+ import { createMeshIdentityMiddleware, createMeshListHandler, createMeshSecretHandler } from '../mesh-secrets.mjs';
30
+ import { throttledFetch } from '../throttled-fetch.mjs';
31
+ import { listProviders, getProvider } from '../provider-catalog.mjs';
32
+ import {
33
+ scanEnvSources,
34
+ publicScanResult,
35
+ importEnvSecrets,
36
+ candidatesFromPaste,
37
+ inferProvider,
38
+ isCandidateKey,
39
+ } from '../env-import.mjs';
40
+ import {
41
+ exportEnvToProject,
42
+ exportEnvToRegistry,
43
+ buildEnvProjection,
44
+ buildDeploySecretsPayload,
45
+ } from '../secrets-env-export.mjs';
46
+
47
+ export function createSecretsRouter({ requireAuth, requireScope, verifyStepUpToken, badRequest, serverError, notFound, BRAIN_DIR, resolveVaultFromQuery, ROOT }) {
48
+ const router = express.Router();
49
+ router.use((req, _res, next) => {
50
+ if (/^\/api\/plugins\/auth\/secrets(?:[/?]|$)/.test(req.url)) req.url = req.url.replace('/api/plugins/auth/secrets', '/api/secrets');
51
+ next();
52
+ });
53
+
54
+ /**
55
+ * Provider secrets are a single host SSOT — NOT per project-vault brain.
56
+ * Selecting any project:<name> scope for memory/graph must not empty the Secrets page.
57
+ * Repo binding lives in secret metadata (repos[]), not separate secrets.enc files.
58
+ *
59
+ * Override only via TR_SECRETS_BRAIN / TR_BRAIN env (deploy/ops).
60
+ */
61
+ function secretsBrainDir(_req) {
62
+ if (process.env.TR_SECRETS_BRAIN) return process.env.TR_SECRETS_BRAIN;
63
+ return BRAIN_DIR;
64
+ }
65
+
66
+ /** @deprecated alias — all secrets routes use global SSOT */
67
+ function brainDirFromReq(req) {
68
+ return secretsBrainDir(req);
69
+ }
70
+
71
+ function meshServerPort() {
72
+ const value = Number(process.env.TR_SERVER_PORT || process.env.PORT || 3000);
73
+ return Number.isInteger(value) && value > 0 && value <= 65535 ? value : 3000;
74
+ }
75
+
76
+ function meshPeerUrl(ip, route) {
77
+ return `http://${ip}:${meshServerPort()}${route}`;
78
+ }
79
+
80
+ router.get('/api/secrets/checksum', requireMeshSyncAuth, async (req, res) => {
81
+ try {
82
+ const { getSecretsChecksum } = await import('../secrets-sync.mjs');
83
+ const checksum = getSecretsChecksum();
84
+ res.json({ checksum });
85
+ } catch (err) {
86
+ serverError(res, err);
87
+ }
88
+ });
89
+
90
+ // Mesh-identity secrets: the caller is the enrolled machine, not a shared
91
+ // token. Each node gets only the keys its grant allows; see mesh-secrets.mjs.
92
+ const requireMeshIdentity = createMeshIdentityMiddleware();
93
+ router.get(
94
+ '/api/secrets/mesh',
95
+ requireMeshIdentity,
96
+ createMeshListHandler({
97
+ brainDir: BRAIN_DIR,
98
+ keysFor: async () => (await listSecretsMeta(BRAIN_DIR)).map((entry) => entry.key),
99
+ }),
100
+ );
101
+ router.get('/api/secrets/mesh/:key', requireMeshIdentity, createMeshSecretHandler({ brainDir: BRAIN_DIR }));
102
+
103
+ router.get('/api/secrets/list', requireAuth, requireScope('keys:read', 'config:read'), async (req, res) => {
104
+ try {
105
+ const brainDir = brainDirFromReq(req);
106
+ const keys = await listSecretsMeta(brainDir);
107
+ res.json({ keys, store: path.join(brainDir, 'config', 'secrets.enc') });
108
+ } catch (err) {
109
+ serverError(res, err);
110
+ }
111
+ });
112
+
113
+ router.get('/api/secrets/sync/status', requireAuth, requireScope('keys:read', 'config:read'), async (req, res) => {
114
+ try {
115
+ const { getSecretsChecksum } = await import('../secrets-sync.mjs');
116
+ const { getMeshPeers } = await import('../mesh.mjs');
117
+
118
+ const localChecksum = getSecretsChecksum();
119
+ const peers = await getMeshPeers();
120
+ const authorization = await getMeshSyncAuthorization();
121
+
122
+ // Mesh peers over Tailscale can take multi-second RTT under load (WAN laptop↔cloud).
123
+ // 1.5s was too aggressive and reported healthy peers as unreachable.
124
+ const PEER_PROBE_TIMEOUT_MS = 10_000;
125
+ const nodes = await Promise.all(peers.map(async (peer) => {
126
+ try {
127
+ const controller = new AbortController();
128
+ const timeoutId = setTimeout(() => controller.abort(), PEER_PROBE_TIMEOUT_MS);
129
+
130
+ const response = await throttledFetch(meshPeerUrl(peer.ip, '/api/secrets/checksum'), {
131
+ signal: controller.signal,
132
+ headers: { Authorization: authorization }
133
+ }, PEER_PROBE_TIMEOUT_MS);
134
+ clearTimeout(timeoutId);
135
+
136
+ if (response.ok) {
137
+ const { checksum } = await response.json();
138
+ return {
139
+ hostname: peer.hostname,
140
+ ip: peer.ip,
141
+ status: checksum === localChecksum ? 'synced' : 'out_of_sync',
142
+ checksum
143
+ };
144
+ }
145
+ } catch (e) {
146
+ // ignore
147
+ }
148
+ return {
149
+ hostname: peer.hostname,
150
+ ip: peer.ip,
151
+ status: 'unreachable',
152
+ checksum: null
153
+ };
154
+ }));
155
+
156
+ res.json({
157
+ localChecksum,
158
+ nodes
159
+ });
160
+ } catch (err) {
161
+ serverError(res, err);
162
+ }
163
+ });
164
+
165
+ router.post('/api/secrets/sync/trigger', requireAuth, requireScope('keys:write', 'config:write'), async (req, res) => {
166
+ try {
167
+ const { getMeshPeers } = await import('../mesh.mjs');
168
+ const peers = await getMeshPeers();
169
+ const authorization = await getMeshSyncAuthorization();
170
+
171
+ const PEER_TRIGGER_TIMEOUT_MS = 10_000;
172
+ const results = await Promise.all(peers.map(async (peer) => {
173
+ try {
174
+ const controller = new AbortController();
175
+ const timeoutId = setTimeout(() => controller.abort(), PEER_TRIGGER_TIMEOUT_MS);
176
+
177
+ const response = await throttledFetch(meshPeerUrl(peer.ip, '/api/secrets/sync/trigger-pull'), {
178
+ method: 'POST',
179
+ signal: controller.signal,
180
+ headers: {
181
+ 'Content-Type': 'application/json',
182
+ Authorization: authorization
183
+ }
184
+ }, PEER_TRIGGER_TIMEOUT_MS);
185
+ clearTimeout(timeoutId);
186
+
187
+ return {
188
+ hostname: peer.hostname,
189
+ ip: peer.ip,
190
+ success: response.ok
191
+ };
192
+ } catch (e) {
193
+ return {
194
+ hostname: peer.hostname,
195
+ ip: peer.ip,
196
+ success: false,
197
+ error: e.message
198
+ };
199
+ }
200
+ }));
201
+
202
+ res.json({ success: true, results });
203
+ } catch (err) {
204
+ serverError(res, err);
205
+ }
206
+ });
207
+
208
+ router.post('/api/secrets/sync/trigger-pull', requireMeshSyncAuth, async (req, res) => {
209
+ await runPull(res);
210
+
211
+ async function runPull(response) {
212
+ try {
213
+ const { getLeaderInfo, isLeader } = await import('../leader-election.mjs');
214
+ const { syncGrantedSecrets } = await import('../secrets-sync.mjs');
215
+
216
+ if (await isLeader()) {
217
+ return response.json({ success: true, message: 'Current node is leader, skip pull' });
218
+ }
219
+
220
+ const leaderInfo = await getLeaderInfo();
221
+ if (!leaderInfo || !leaderInfo.ip) {
222
+ return response.status(400).json({ error: 'Leader IP not found' });
223
+ }
224
+
225
+ const result = await syncGrantedSecrets(leaderInfo.ip);
226
+ if (result) {
227
+ response.json({ success: true, ...result });
228
+ } else {
229
+ response.status(500).json({ error: 'Failed to pull secrets from leader' });
230
+ }
231
+ } catch (err) {
232
+ serverError(response, err);
233
+ }
234
+ }
235
+ });
236
+
237
+ router.get('/api/secrets/providers', requireAuth, requireScope('keys:read', 'config:read'), (_req, res) => {
238
+ res.json({ providers: listProviders() });
239
+ });
240
+
241
+ /**
242
+ * GET /api/secrets/tracking-health
243
+ * Hard status: any set secret without tracking_status=ok|exempt is an error.
244
+ */
245
+ router.get(
246
+ '/api/secrets/tracking-health',
247
+ requireAuth,
248
+ requireScope('keys:read', 'config:read'),
249
+ async (req, res) => {
250
+ try {
251
+ const brainDir = brainDirFromReq(req);
252
+ const { getTrackingHealth } = await import('../provider-account-sync.mjs');
253
+ const health = await getTrackingHealth(brainDir);
254
+ res.status(health.healthy ? 200 : 409).json(health);
255
+ } catch (err) {
256
+ serverError(res, err);
257
+ }
258
+ },
259
+ );
260
+
261
+ /**
262
+ * GET /api/secrets/shared-values
263
+ * Same credential material under multiple secret names / apps → ERROR (rotate per app).
264
+ */
265
+ router.get(
266
+ '/api/secrets/shared-values',
267
+ requireAuth,
268
+ requireScope('keys:read', 'config:read'),
269
+ async (req, res) => {
270
+ try {
271
+ const brainDir = brainDirFromReq(req);
272
+ const { getSharedValueHealth } = await import('../secrets-store.mjs');
273
+ const health = await getSharedValueHealth(brainDir);
274
+ res.status(health.healthy ? 200 : 409).json(health);
275
+ } catch (err) {
276
+ serverError(res, err);
277
+ }
278
+ },
279
+ );
280
+
281
+ /**
282
+ * POST /api/secrets/account-sync
283
+ * Live-probe vendor account/usage/subscription APIs and persist tracking meta.
284
+ * Body: { key?, keys?, strict?, use_ai? }
285
+ */
286
+ router.post(
287
+ '/api/secrets/account-sync',
288
+ requireAuth,
289
+ requireScope('keys:write', 'config:write'),
290
+ async (req, res) => {
291
+ try {
292
+ const brainDir = brainDirFromReq(req);
293
+ const body = req.body || {};
294
+ const {
295
+ syncSecretAccount,
296
+ syncAllSecretAccounts,
297
+ } = await import('../provider-account-sync.mjs');
298
+ const opts = {
299
+ strict: body.strict !== false,
300
+ use_ai: !!body.use_ai,
301
+ force_exempt: !!body.force_exempt,
302
+ };
303
+ if (body.key) {
304
+ const result = await syncSecretAccount(brainDir, body.key, opts);
305
+ const ok = result.tracking_status === 'ok' || result.tracking_status === 'exempt';
306
+ return res.status(ok ? 200 : 409).json(result);
307
+ }
308
+ if (Array.isArray(body.keys) && body.keys.length) {
309
+ opts.keys = body.keys;
310
+ }
311
+ const report = await syncAllSecretAccounts(brainDir, opts);
312
+ res.status(report.healthy ? 200 : 409).json(report);
313
+ } catch (err) {
314
+ serverError(res, err);
315
+ }
316
+ },
317
+ );
318
+
319
+ /**
320
+ * POST /api/secrets/:key/account-sync — sync one secret by path param
321
+ */
322
+ router.post(
323
+ '/api/secrets/:key/account-sync',
324
+ requireAuth,
325
+ requireScope('keys:write', 'config:write'),
326
+ async (req, res) => {
327
+ try {
328
+ const brainDir = brainDirFromReq(req);
329
+ const { syncSecretAccount } = await import('../provider-account-sync.mjs');
330
+ const result = await syncSecretAccount(brainDir, req.params.key, {
331
+ strict: req.body?.strict !== false,
332
+ use_ai: !!req.body?.use_ai,
333
+ force_exempt: !!req.body?.force_exempt,
334
+ });
335
+ const ok = result.tracking_status === 'ok' || result.tracking_status === 'exempt';
336
+ res.status(ok ? 200 : 409).json(result);
337
+ } catch (err) {
338
+ serverError(res, err);
339
+ }
340
+ },
341
+ );
342
+
343
+ router.get('/api/secrets/usage', requireAuth, requireScope('keys:read', 'config:read'), async (req, res) => {
344
+ try {
345
+ const brainDir = brainDirFromReq(req);
346
+ const days = Math.min(90, Math.max(1, parseInt(req.query.days, 10) || 30));
347
+ const summary = summarizeUsage(brainDir, { days });
348
+ const by = summarizeUsageByKey(brainDir, { days });
349
+ const events = listUsageEvents(brainDir, {
350
+ limit: Math.min(200, parseInt(req.query.limit, 10) || 50),
351
+ key_ref: req.query.key_ref || null,
352
+ days,
353
+ });
354
+ res.json({ summary, ...by, events });
355
+ } catch (err) {
356
+ serverError(res, err);
357
+ }
358
+ });
359
+
360
+ router.post('/api/secrets/usage', requireAuth, requireScope('keys:write', 'config:write'), async (req, res) => {
361
+ try {
362
+ const brainDir = brainDirFromReq(req);
363
+ const body = req.body || {};
364
+ const row = recordUsage(brainDir, {
365
+ provider: body.provider || 'unknown',
366
+ model: body.model,
367
+ input_tokens: body.input_tokens || 0,
368
+ output_tokens: body.output_tokens || 0,
369
+ cost_usd: body.cost_usd ?? body.cost ?? null,
370
+ key_ref: body.key_ref || body.key || null,
371
+ source: body.source || 'api',
372
+ });
373
+ res.status(201).json(row);
374
+ } catch (err) {
375
+ serverError(res, err);
376
+ }
377
+ });
378
+
379
+ router.get('/api/secrets/rotation-due', requireAuth, requireScope('keys:read', 'config:read'), async (req, res) => {
380
+ try {
381
+ const brainDir = brainDirFromReq(req);
382
+ const due = await listRotationDue(brainDir, { autoOnly: req.query.auto === '1' });
383
+ res.json({ due, count: due.length });
384
+ } catch (err) {
385
+ serverError(res, err);
386
+ }
387
+ });
388
+
389
+ router.post('/api/secrets/rotation-due/enqueue', requireAuth, requireScope('keys:write', 'config:write'), async (req, res) => {
390
+ try {
391
+ const brainDir = brainDirFromReq(req);
392
+ const { enqueueRotationDueTasks } = await import('../secrets-rotate.mjs');
393
+ const result = await enqueueRotationDueTasks(brainDir, {
394
+ autoOnly: !!req.body?.autoOnly,
395
+ });
396
+ res.json(result);
397
+ } catch (err) {
398
+ serverError(res, err);
399
+ }
400
+ });
401
+
402
+ router.get('/api/secrets', requireAuth, requireScope('keys:read', 'config:read'), async (req, res) => {
403
+ try {
404
+ const brainDir = brainDirFromReq(req);
405
+ if (req.query.catalog === '0') {
406
+ const keys = await listSecretsMeta(brainDir);
407
+ return res.json({ keys, store: path.join(brainDir, 'config', 'secrets.enc') });
408
+ }
409
+ const catalog = await getSecretsCatalog(brainDir);
410
+ res.json(catalog);
411
+ } catch (err) {
412
+ serverError(res, err);
413
+ }
414
+ });
415
+
416
+ /**
417
+ * Preview or write .env projections from secrets store.
418
+ * Body/query: { path?, all_projects?, dry_run?, filename?, include_global?, keys? }
419
+ */
420
+ router.post('/api/secrets/export-env', requireAuth, requireScope('keys:write', 'config:write'), async (req, res) => {
421
+ try {
422
+ const brainDir = brainDirFromReq(req);
423
+ const body = req.body || {};
424
+ const dryRun = !!body.dry_run || !!body.dryRun;
425
+ const exportOpts = {
426
+ filename: body.filename || '.env',
427
+ example: body.example !== false,
428
+ dryRun,
429
+ includeGlobal: body.include_global !== false && body.includeGlobal !== false,
430
+ keys: Array.isArray(body.keys) ? body.keys : undefined,
431
+ };
432
+
433
+ if (body.all_projects || body.allProjects) {
434
+ const results = await exportEnvToRegistry(brainDir, exportOpts);
435
+ return res.json({ results, store: path.join(brainDir, 'config', 'secrets.enc') });
436
+ }
437
+
438
+ const targetPath = body.path || body.project_path || ROOT;
439
+ if (body.preview_only || body.previewOnly) {
440
+ const projection = await buildEnvProjection(brainDir, {
441
+ projectPath: targetPath,
442
+ includeGlobal: exportOpts.includeGlobal,
443
+ keys: exportOpts.keys,
444
+ });
445
+ return res.json({
446
+ path: targetPath,
447
+ keys: projection.keys,
448
+ count: projection.count,
449
+ store: projection.store,
450
+ // never return dotenv body with values over API unless explicit
451
+ has_values: false,
452
+ });
453
+ }
454
+
455
+ const result = await exportEnvToProject(brainDir, targetPath, exportOpts);
456
+ res.json({
457
+ ...result,
458
+ // strip any accidental value leakage — only paths and key names
459
+ keys: result.keys,
460
+ count: result.count,
461
+ });
462
+ } catch (err) {
463
+ if (err.message?.includes('not a directory')) return badRequest(res, err.message);
464
+ serverError(res, err);
465
+ }
466
+ });
467
+
468
+ router.get('/api/secrets/export-env', requireAuth, requireScope('keys:read', 'config:read'), async (req, res) => {
469
+ try {
470
+ const brainDir = brainDirFromReq(req);
471
+ const targetPath = req.query.path || ROOT;
472
+ const projection = await buildEnvProjection(brainDir, {
473
+ projectPath: targetPath,
474
+ includeGlobal: req.query.no_global !== '1',
475
+ });
476
+ res.json({
477
+ path: targetPath,
478
+ keys: projection.keys,
479
+ count: projection.count,
480
+ store: projection.store,
481
+ });
482
+ } catch (err) {
483
+ serverError(res, err);
484
+ }
485
+ });
486
+
487
+ /** Deploy helper: returns dotenv text for host agent (auth required). */
488
+ router.post('/api/secrets/deploy-payload', requireAuth, requireScope('keys:write', 'config:write'), async (req, res) => {
489
+ try {
490
+ const brainDir = brainDirFromReq(req);
491
+ const body = req.body || {};
492
+ const payload = await buildDeploySecretsPayload(brainDir, {
493
+ projectPath: body.path,
494
+ projectSlug: body.project_slug || body.projectSlug,
495
+ includeGlobal: body.include_global !== false,
496
+ keys: body.keys,
497
+ });
498
+ // For deploy automation — values included once under auth
499
+ res.json(payload);
500
+ } catch (err) {
501
+ serverError(res, err);
502
+ }
503
+ });
504
+
505
+ router.get('/api/secrets/scan-env', requireAuth, requireScope('keys:write', 'config:write'), async (req, res) => {
506
+ try {
507
+ const brainDir = brainDirFromReq(req);
508
+ const existing = await listSecretsMeta(brainDir);
509
+ const existingSet = new Set(existing.map((e) => e.key));
510
+ const scan = scanEnvSources({
511
+ brainDir,
512
+ includeProcessEnv: true,
513
+ cwd: ROOT,
514
+ });
515
+ res.json(publicScanResult(scan, existingSet));
516
+ } catch (err) {
517
+ serverError(res, err);
518
+ }
519
+ });
520
+
521
+ router.post('/api/secrets/parse-env', requireAuth, requireScope('keys:write', 'config:write'), async (req, res) => {
522
+ try {
523
+ const text = req.body?.text;
524
+ if (!text || typeof text !== 'string') {
525
+ return badRequest(res, 'text is required (paste .env contents)');
526
+ }
527
+ if (text.length > 200_000) {
528
+ return badRequest(res, 'text too large (max 200KB)');
529
+ }
530
+ const brainDir = brainDirFromReq(req);
531
+ const existing = await listSecretsMeta(brainDir);
532
+ const existingSet = new Set(existing.map((e) => e.key));
533
+ const { candidates } = candidatesFromPaste(text);
534
+ res.json({
535
+ candidates: candidates.map((c) => ({
536
+ ...c,
537
+ already_set: existingSet.has(c.key),
538
+ })),
539
+ count: candidates.length,
540
+ });
541
+ } catch (err) {
542
+ serverError(res, err);
543
+ }
544
+ });
545
+
546
+ router.post('/api/secrets/import-env', requireAuth, requireScope('keys:write', 'config:write'), async (req, res) => {
547
+ try {
548
+ const brainDir = brainDirFromReq(req);
549
+ const body = req.body || {};
550
+ const overwrite = !!body.overwrite;
551
+
552
+ if (body.pairs && typeof body.pairs === 'object') {
553
+ let pairs = body.pairs;
554
+ if (Array.isArray(body.keys) && body.keys.length) {
555
+ const filtered = {};
556
+ for (const k of body.keys) {
557
+ if (pairs[k] != null) filtered[k] = pairs[k];
558
+ }
559
+ pairs = filtered;
560
+ }
561
+ const clean = {};
562
+ for (const [k, v] of Object.entries(pairs)) {
563
+ // Pattern-based only — any secret-shaped name, no product API whitelist
564
+ if (isCandidateKey(k) && v) clean[k] = String(v);
565
+ }
566
+ const result = await importEnvSecrets(brainDir, {
567
+ pairs: clean,
568
+ overwrite,
569
+ actor: 'dashboard-import',
570
+ });
571
+ return res.json(result);
572
+ }
573
+
574
+ const result = await importEnvSecrets(brainDir, {
575
+ keys: Array.isArray(body.keys) ? body.keys : undefined,
576
+ all: !!body.all,
577
+ overwrite,
578
+ actor: 'dashboard-import',
579
+ });
580
+ res.json(result);
581
+ } catch (err) {
582
+ serverError(res, err);
583
+ }
584
+ });
585
+
586
+ router.post('/api/secrets', requireAuth, requireScope('keys:write', 'config:write'), async (req, res) => {
587
+ try {
588
+ const body = req.body || {};
589
+ const { key, value } = body;
590
+ if (!key || !value) return badRequest(res, 'key and value are required');
591
+ const brainDir = brainDirFromReq(req);
592
+ const provider = body.provider || inferProvider(key);
593
+ const catalog = getProvider(provider);
594
+ await setSecret(brainDir, key, value, {
595
+ provider,
596
+ scope: body.scope || 'global',
597
+ repos: body.repos,
598
+ subscription_tier: body.subscription_tier,
599
+ monthly_cost_usd: body.monthly_cost_usd,
600
+ monthly_cap_usd: body.monthly_cap_usd ?? catalog?.default_monthly_cap_usd,
601
+ api_docs_url: body.api_docs_url || catalog?.docs_url,
602
+ headscale_url: body.headscale_url,
603
+ headscale_log_command: body.headscale_log_command,
604
+ rotate_every_days: body.rotate_every_days,
605
+ auto_rotate: body.auto_rotate,
606
+ notes: body.notes,
607
+ project_path: body.project_path,
608
+ label: body.label,
609
+ actor: 'dashboard',
610
+ });
611
+ const keys = await listSecretsMeta(brainDir);
612
+ res.status(201).json(keys.find((k) => k.key === key));
613
+ } catch (err) {
614
+ if (err.message?.includes('Invalid')) return badRequest(res, err.message);
615
+ serverError(res, err);
616
+ }
617
+ });
618
+
619
+ router.patch('/api/secrets/:key', requireAuth, requireScope('keys:write', 'config:write'), async (req, res) => {
620
+ try {
621
+ const brainDir = brainDirFromReq(req);
622
+ const row = await updateSecretMeta(brainDir, req.params.key, req.body || {}, {
623
+ actor: 'dashboard',
624
+ });
625
+ if (!row) return notFound(res, `Secret not found: ${req.params.key}`);
626
+ res.json(row);
627
+ } catch (err) {
628
+ if (err.message?.includes('not found')) return notFound(res, err.message);
629
+ serverError(res, err);
630
+ }
631
+ });
632
+
633
+ /**
634
+ * Reveal full secret value — requires short-lived step_up_token from passkey
635
+ * (or password re-entry). Never logged. Audit records key name only.
636
+ */
637
+ router.post(
638
+ '/api/secrets/:key/reveal',
639
+ requireAuth,
640
+ requireScope('keys:read', 'config:read'),
641
+ async (req, res) => {
642
+ try {
643
+ const token =
644
+ req.body?.step_up_token ||
645
+ req.headers['x-tr-step-up'] ||
646
+ req.headers['x-total-recall-step-up'];
647
+ const check = verifyStepUpToken(token, 'secrets:reveal');
648
+ if (!check.ok) {
649
+ return res.status(403).json({
650
+ error: check.error,
651
+ code: 'step_up_required',
652
+ hint: 'Complete passkey prompt or password step-up, then retry reveal.',
653
+ });
654
+ }
655
+
656
+ const brainDir = brainDirFromReq(req);
657
+ const key = req.params.key;
658
+ const got = await getSecret(brainDir, key, {
659
+ action: 'reveal',
660
+ actor: `step-up:${check.payload?.actor || 'dashboard'}`,
661
+ });
662
+ if (!got.found) return notFound(res, `Secret not found: ${key}`);
663
+
664
+ logger.info('secrets', 'Secret revealed (step-up)', {
665
+ key,
666
+ actor: check.payload?.actor || 'dashboard',
667
+ });
668
+
669
+ res.json({
670
+ key,
671
+ value: got.value,
672
+ // client should display briefly; server does not store this response
673
+ revealed_at: new Date().toISOString(),
674
+ step_up_actor: check.payload?.actor || null,
675
+ });
676
+ } catch (err) {
677
+ serverError(res, err);
678
+ }
679
+ },
680
+ );
681
+
682
+ router.post('/api/secrets/:key/rotate', requireAuth, requireScope('keys:write', 'config:write'), async (req, res) => {
683
+ try {
684
+ const body = req.body || {};
685
+ const { value } = body;
686
+ if (!value) return badRequest(res, 'value is required for rotate');
687
+ const brainDir = brainDirFromReq(req);
688
+
689
+ // Default: after rotate, re-project .env to bound repos / all projects when asked
690
+ const wantExport =
691
+ body.export_env === true ||
692
+ body.exportEnv === true ||
693
+ body.export_all === true ||
694
+ body.exportAll === true;
695
+
696
+ if (wantExport) {
697
+ const { rotateSecretAndExport } = await import('../secrets-rotate.mjs');
698
+ const result = await rotateSecretAndExport(brainDir, req.params.key, value, {
699
+ actor: 'dashboard',
700
+ provider: body.provider,
701
+ exportEnv: true,
702
+ exportAllProjects: body.export_all === true || body.exportAll === true,
703
+ exportCwd: false,
704
+ includeGlobal: true,
705
+ });
706
+ // Never echo secret value
707
+ const keys = await listSecretsMeta(brainDir);
708
+ return res.json({
709
+ key: result.key,
710
+ rotated: true,
711
+ next_rotate_due: result.next_rotate_due,
712
+ secret: keys.find((k) => k.key === req.params.key),
713
+ exports: (result.exports || []).map((e) => ({
714
+ ok: e.ok !== false,
715
+ envPath: e.envPath,
716
+ count: e.count,
717
+ name: e.name,
718
+ error: e.error,
719
+ })),
720
+ });
721
+ }
722
+
723
+ const result = await rotateSecret(brainDir, req.params.key, value, {
724
+ actor: 'dashboard',
725
+ provider: body.provider,
726
+ });
727
+ const keys = await listSecretsMeta(brainDir);
728
+ res.json({ ...result, secret: keys.find((k) => k.key === req.params.key) });
729
+ } catch (err) {
730
+ if (err.message?.includes('not found')) return notFound(res, err.message);
731
+ serverError(res, err);
732
+ }
733
+ });
734
+
735
+ router.delete('/api/secrets/:key', requireAuth, requireScope('keys:write', 'config:write'), async (req, res) => {
736
+ try {
737
+ const brainDir = brainDirFromReq(req);
738
+ const r = await deleteSecret(brainDir, req.params.key, { actor: 'dashboard' });
739
+ if (!r.found) return notFound(res, `Secret not found: ${req.params.key}`);
740
+ res.json(r);
741
+ } catch (err) {
742
+ serverError(res, err);
743
+ }
744
+ });
745
+
746
+ return router;
747
+ }