@matthesketh/fleet 1.2.0 → 1.7.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 (218) hide show
  1. package/README.md +183 -251
  2. package/dist/adapters/detector/index.d.ts +8 -0
  3. package/dist/adapters/detector/index.js +54 -0
  4. package/dist/adapters/notifier/index.d.ts +2 -0
  5. package/dist/adapters/notifier/index.js +2 -0
  6. package/dist/adapters/notifier/stdout.d.ts +2 -0
  7. package/dist/adapters/notifier/stdout.js +8 -0
  8. package/dist/adapters/notifier/webhook.d.ts +9 -0
  9. package/dist/adapters/notifier/webhook.js +38 -0
  10. package/dist/adapters/runner/claude-cli.d.ts +7 -0
  11. package/dist/adapters/runner/claude-cli.js +231 -0
  12. package/dist/adapters/runner/mcp-call.d.ts +8 -0
  13. package/dist/adapters/runner/mcp-call.js +82 -0
  14. package/dist/adapters/runner/shell.d.ts +2 -0
  15. package/dist/adapters/runner/shell.js +103 -0
  16. package/dist/adapters/scheduler/systemd-timer.d.ts +17 -0
  17. package/dist/adapters/scheduler/systemd-timer.js +149 -0
  18. package/dist/adapters/signals/ci-status.d.ts +2 -0
  19. package/dist/adapters/signals/ci-status.js +79 -0
  20. package/dist/adapters/signals/container-up.d.ts +5 -0
  21. package/dist/adapters/signals/container-up.js +54 -0
  22. package/dist/adapters/signals/git-clean.d.ts +2 -0
  23. package/dist/adapters/signals/git-clean.js +55 -0
  24. package/dist/adapters/signals/index.d.ts +6 -0
  25. package/dist/adapters/signals/index.js +7 -0
  26. package/dist/adapters/types.d.ts +52 -0
  27. package/dist/adapters/types.js +1 -0
  28. package/dist/cli.js +46 -2
  29. package/dist/commands/add.js +0 -6
  30. package/dist/commands/boot-start.d.ts +1 -0
  31. package/dist/commands/boot-start.js +51 -0
  32. package/dist/commands/deploy.js +13 -0
  33. package/dist/commands/deps.js +5 -0
  34. package/dist/commands/egress.d.ts +1 -0
  35. package/dist/commands/egress.js +106 -0
  36. package/dist/commands/freeze.d.ts +4 -0
  37. package/dist/commands/freeze.js +64 -0
  38. package/dist/commands/guard.d.ts +1 -0
  39. package/dist/commands/guard.js +144 -0
  40. package/dist/commands/logs.d.ts +1 -1
  41. package/dist/commands/logs.js +237 -8
  42. package/dist/commands/patch-systemd.d.ts +1 -0
  43. package/dist/commands/patch-systemd.js +126 -0
  44. package/dist/commands/rollback.d.ts +1 -0
  45. package/dist/commands/rollback.js +58 -0
  46. package/dist/commands/routine-run.d.ts +1 -0
  47. package/dist/commands/routine-run.js +122 -0
  48. package/dist/commands/routines.d.ts +1 -0
  49. package/dist/commands/routines.js +25 -0
  50. package/dist/commands/secrets.js +449 -16
  51. package/dist/commands/status.js +7 -3
  52. package/dist/commands/watchdog.d.ts +1 -1
  53. package/dist/commands/watchdog.js +16 -40
  54. package/dist/core/boot-refresh.d.ts +57 -0
  55. package/dist/core/boot-refresh.js +116 -0
  56. package/dist/core/deps/actors/pr-creator.js +11 -9
  57. package/dist/core/deps/collectors/docker-running.js +2 -2
  58. package/dist/core/deps/collectors/github-pr.js +5 -2
  59. package/dist/core/deps/collectors/npm.js +10 -5
  60. package/dist/core/deps/collectors/vulnerability.js +10 -6
  61. package/dist/core/deps/reporters/motd.js +1 -1
  62. package/dist/core/deps/reporters/telegram.js +2 -29
  63. package/dist/core/docker.js +45 -15
  64. package/dist/core/egress.d.ts +41 -0
  65. package/dist/core/egress.js +161 -0
  66. package/dist/core/exec.d.ts +7 -1
  67. package/dist/core/exec.js +25 -17
  68. package/dist/core/git.d.ts +1 -0
  69. package/dist/core/git.js +36 -23
  70. package/dist/core/github.js +27 -8
  71. package/dist/core/health.d.ts +3 -0
  72. package/dist/core/health.js +15 -3
  73. package/dist/core/logs-multi.d.ts +73 -0
  74. package/dist/core/logs-multi.js +163 -0
  75. package/dist/core/logs-policy.d.ts +55 -0
  76. package/dist/core/logs-policy.js +148 -0
  77. package/dist/core/nginx.js +8 -4
  78. package/dist/core/notify.d.ts +15 -0
  79. package/dist/core/notify.js +55 -0
  80. package/dist/core/registry.d.ts +25 -0
  81. package/dist/core/registry.js +57 -10
  82. package/dist/core/routines/cost-queries.d.ts +24 -0
  83. package/dist/core/routines/cost-queries.js +65 -0
  84. package/dist/core/routines/db.d.ts +9 -0
  85. package/dist/core/routines/db.js +126 -0
  86. package/dist/core/routines/defaults.d.ts +2 -0
  87. package/dist/core/routines/defaults.js +72 -0
  88. package/dist/core/routines/engine.d.ts +59 -0
  89. package/dist/core/routines/engine.js +175 -0
  90. package/dist/core/routines/incidents.d.ts +13 -0
  91. package/dist/core/routines/incidents.js +35 -0
  92. package/dist/core/routines/schema.d.ts +418 -0
  93. package/dist/core/routines/schema.js +113 -0
  94. package/dist/core/routines/signals-collector.d.ts +35 -0
  95. package/dist/core/routines/signals-collector.js +114 -0
  96. package/dist/core/routines/store.d.ts +316 -0
  97. package/dist/core/routines/store.js +99 -0
  98. package/dist/core/routines/test-utils.d.ts +2 -0
  99. package/dist/core/routines/test-utils.js +13 -0
  100. package/dist/core/secrets-audit.d.ts +21 -0
  101. package/dist/core/secrets-audit.js +60 -0
  102. package/dist/core/secrets-metadata.d.ts +39 -0
  103. package/dist/core/secrets-metadata.js +82 -0
  104. package/dist/core/secrets-motd.d.ts +20 -0
  105. package/dist/core/secrets-motd.js +72 -0
  106. package/dist/core/secrets-ops.d.ts +3 -1
  107. package/dist/core/secrets-ops.js +78 -13
  108. package/dist/core/secrets-providers.d.ts +50 -0
  109. package/dist/core/secrets-providers.js +291 -0
  110. package/dist/core/secrets-rotation.d.ts +52 -0
  111. package/dist/core/secrets-rotation.js +165 -0
  112. package/dist/core/secrets-snapshots.d.ts +26 -0
  113. package/dist/core/secrets-snapshots.js +95 -0
  114. package/dist/core/secrets-validate.js +2 -1
  115. package/dist/core/secrets.d.ts +12 -1
  116. package/dist/core/secrets.js +35 -24
  117. package/dist/core/self-update.d.ts +41 -0
  118. package/dist/core/self-update.js +73 -0
  119. package/dist/core/systemd.js +29 -12
  120. package/dist/core/telegram.d.ts +6 -0
  121. package/dist/core/telegram.js +32 -0
  122. package/dist/core/validate.d.ts +7 -0
  123. package/dist/core/validate.js +42 -0
  124. package/dist/index.js +0 -4
  125. package/dist/mcp/deps-tools.js +9 -1
  126. package/dist/mcp/git-tools.js +4 -4
  127. package/dist/mcp/server.js +193 -8
  128. package/dist/templates/systemd.js +3 -3
  129. package/dist/templates/unseal.js +5 -1
  130. package/dist/tui/components/KeyHint.js +10 -0
  131. package/dist/tui/exec-bridge.js +26 -12
  132. package/dist/tui/hooks/use-fleet-data.js +5 -2
  133. package/dist/tui/hooks/use-health.js +5 -2
  134. package/dist/tui/router.js +60 -7
  135. package/dist/tui/routines/RoutinesApp.d.ts +8 -0
  136. package/dist/tui/routines/RoutinesApp.js +277 -0
  137. package/dist/tui/routines/components/AlertsPanel.d.ts +7 -0
  138. package/dist/tui/routines/components/AlertsPanel.js +22 -0
  139. package/dist/tui/routines/components/AlertsPanel.test.d.ts +1 -0
  140. package/dist/tui/routines/components/AlertsPanel.test.js +52 -0
  141. package/dist/tui/routines/components/CommandPalette.d.ts +12 -0
  142. package/dist/tui/routines/components/CommandPalette.js +21 -0
  143. package/dist/tui/routines/components/LiveRunPanel.d.ts +12 -0
  144. package/dist/tui/routines/components/LiveRunPanel.js +107 -0
  145. package/dist/tui/routines/components/RoutineForm.d.ts +8 -0
  146. package/dist/tui/routines/components/RoutineForm.js +254 -0
  147. package/dist/tui/routines/components/SignalsGrid.d.ts +13 -0
  148. package/dist/tui/routines/components/SignalsGrid.js +34 -0
  149. package/dist/tui/routines/components/SignalsGrid.test.d.ts +1 -0
  150. package/dist/tui/routines/components/SignalsGrid.test.js +43 -0
  151. package/dist/tui/routines/format.d.ts +7 -0
  152. package/dist/tui/routines/format.js +51 -0
  153. package/dist/tui/routines/hooks/use-git-fleet.d.ts +33 -0
  154. package/dist/tui/routines/hooks/use-git-fleet.js +82 -0
  155. package/dist/tui/routines/hooks/use-logs-stream.d.ts +13 -0
  156. package/dist/tui/routines/hooks/use-logs-stream.js +64 -0
  157. package/dist/tui/routines/hooks/use-ops-fleet.d.ts +20 -0
  158. package/dist/tui/routines/hooks/use-ops-fleet.js +70 -0
  159. package/dist/tui/routines/hooks/use-repo-detail.d.ts +31 -0
  160. package/dist/tui/routines/hooks/use-repo-detail.js +104 -0
  161. package/dist/tui/routines/hooks/use-security.d.ts +33 -0
  162. package/dist/tui/routines/hooks/use-security.js +110 -0
  163. package/dist/tui/routines/hooks/use-signals.d.ts +9 -0
  164. package/dist/tui/routines/hooks/use-signals.js +60 -0
  165. package/dist/tui/routines/runtime.d.ts +20 -0
  166. package/dist/tui/routines/runtime.js +40 -0
  167. package/dist/tui/routines/tabs/CostTab.d.ts +7 -0
  168. package/dist/tui/routines/tabs/CostTab.js +24 -0
  169. package/dist/tui/routines/tabs/DashboardTab.d.ts +15 -0
  170. package/dist/tui/routines/tabs/DashboardTab.js +10 -0
  171. package/dist/tui/routines/tabs/GitTab.d.ts +6 -0
  172. package/dist/tui/routines/tabs/GitTab.js +39 -0
  173. package/dist/tui/routines/tabs/LogsTab.d.ts +6 -0
  174. package/dist/tui/routines/tabs/LogsTab.js +58 -0
  175. package/dist/tui/routines/tabs/OpsTab.d.ts +6 -0
  176. package/dist/tui/routines/tabs/OpsTab.js +34 -0
  177. package/dist/tui/routines/tabs/RepoDetailView.d.ts +6 -0
  178. package/dist/tui/routines/tabs/RepoDetailView.js +12 -0
  179. package/dist/tui/routines/tabs/RoutinesTab.d.ts +10 -0
  180. package/dist/tui/routines/tabs/RoutinesTab.js +58 -0
  181. package/dist/tui/routines/tabs/ScaffoldTab.d.ts +2 -0
  182. package/dist/tui/routines/tabs/ScaffoldTab.js +127 -0
  183. package/dist/tui/routines/tabs/SecurityTab.d.ts +6 -0
  184. package/dist/tui/routines/tabs/SecurityTab.js +31 -0
  185. package/dist/tui/routines/tabs/SettingsTab.d.ts +6 -0
  186. package/dist/tui/routines/tabs/SettingsTab.js +61 -0
  187. package/dist/tui/routines/tabs/TimelineTab.d.ts +7 -0
  188. package/dist/tui/routines/tabs/TimelineTab.js +26 -0
  189. package/dist/tui/state.js +1 -1
  190. package/dist/tui/tests/keyboard-integration.test.js +3 -0
  191. package/dist/tui/tests/test-app.js +1 -1
  192. package/dist/tui/types.d.ts +2 -2
  193. package/dist/tui/views/AppDetail.js +3 -4
  194. package/dist/tui/views/HealthView.js +7 -1
  195. package/dist/tui/views/LogsView.js +24 -1
  196. package/dist/tui/views/MultiLogsView.d.ts +2 -0
  197. package/dist/tui/views/MultiLogsView.js +165 -0
  198. package/dist/tui/views/SecretEdit.js +10 -3
  199. package/dist/tui/views/SecretsView.js +6 -3
  200. package/dist/ui/prompt.d.ts +52 -0
  201. package/dist/ui/prompt.js +169 -0
  202. package/package.json +34 -21
  203. package/scripts/guard/cert-expiry-watch +109 -0
  204. package/scripts/guard/cf-audit-monitor +169 -0
  205. package/scripts/guard/cf-snapshot +124 -0
  206. package/scripts/guard/cron.d-cf-protect +11 -0
  207. package/scripts/guard/dns-drift-watch +138 -0
  208. package/scripts/guard/fleet-guard +282 -0
  209. package/scripts/guard/fleet-guard-execute +197 -0
  210. package/scripts/guard/notify +108 -0
  211. package/dist/commands/motd.d.ts +0 -1
  212. package/dist/commands/motd.js +0 -10
  213. package/dist/templates/motd.d.ts +0 -1
  214. package/dist/templates/motd.js +0 -7
  215. package/dist/tui/components/AppList.d.ts +0 -12
  216. package/dist/tui/components/AppList.js +0 -32
  217. package/dist/tui/hooks/use-keyboard.d.ts +0 -1
  218. package/dist/tui/hooks/use-keyboard.js +0 -44
@@ -0,0 +1,72 @@
1
+ /**
2
+ * MOTD reporter: short summary of secret rotation health, intended for
3
+ * /etc/update-motd.d/99-fleet-secrets to print on shell login.
4
+ */
5
+ import { enumerateAllSecrets } from './secrets-metadata.js';
6
+ export function summariseSecrets() {
7
+ const all = enumerateAllSecrets();
8
+ const stale = all.filter(s => s.stale);
9
+ const bySensitivity = { critical: 0, high: 0, medium: 0, low: 0 };
10
+ for (const s of stale) {
11
+ const sens = s.provider?.sensitivity ?? 'low';
12
+ bySensitivity[sens]++;
13
+ }
14
+ const appsWithStale = Array.from(new Set(stale.map(s => s.app))).sort();
15
+ const sensRank = { critical: 0, high: 1, medium: 2, low: 3 };
16
+ const topStale = stale
17
+ .slice()
18
+ .sort((a, b) => {
19
+ const sa = sensRank[a.provider?.sensitivity ?? 'low'];
20
+ const sb = sensRank[b.provider?.sensitivity ?? 'low'];
21
+ if (sa !== sb)
22
+ return sa - sb;
23
+ return (b.ageDays ?? 0) - (a.ageDays ?? 0);
24
+ })
25
+ .slice(0, 5)
26
+ .map(s => ({
27
+ app: s.app,
28
+ name: s.name,
29
+ ageDays: s.ageDays ?? 0,
30
+ sensitivity: s.provider?.sensitivity ?? 'low',
31
+ }));
32
+ return {
33
+ totalSecrets: all.length,
34
+ staleCount: stale.length,
35
+ bySensitivity,
36
+ appsWithStale,
37
+ topStale,
38
+ };
39
+ }
40
+ export function formatSecretsMotd(summary) {
41
+ const lines = [];
42
+ lines.push('-- Fleet Secrets ' + '-'.repeat(40));
43
+ if (summary.staleCount === 0) {
44
+ lines.push(` All ${summary.totalSecrets} secrets within rotation frequency`);
45
+ lines.push(' Run: fleet secrets ages');
46
+ return lines.join('\n');
47
+ }
48
+ const parts = [];
49
+ if (summary.bySensitivity.critical > 0)
50
+ parts.push(`${summary.bySensitivity.critical} critical`);
51
+ if (summary.bySensitivity.high > 0)
52
+ parts.push(`${summary.bySensitivity.high} high`);
53
+ if (summary.bySensitivity.medium > 0)
54
+ parts.push(`${summary.bySensitivity.medium} medium`);
55
+ if (summary.bySensitivity.low > 0)
56
+ parts.push(`${summary.bySensitivity.low} low`);
57
+ lines.push(` ${summary.staleCount} secrets need rotation (${parts.join(', ')}) across ${summary.appsWithStale.length} apps`);
58
+ for (const t of summary.topStale) {
59
+ const prefix = t.sensitivity === 'critical' ? '!!' : t.sensitivity === 'high' ? ' !' : ' ';
60
+ lines.push(` ${prefix} ${t.app}: ${t.name} (${t.ageDays}d old)`);
61
+ }
62
+ lines.push(' Run: fleet secrets ages --stale-only');
63
+ lines.push(' Rotate: fleet secrets rotate <app>');
64
+ return lines.join('\n');
65
+ }
66
+ export function generateSecretsMotdScript() {
67
+ return `#!/bin/bash
68
+ # fleet secrets motd — auto-generated by 'fleet secrets motd-init'
69
+ # shows secret rotation health summary on shell login
70
+ /usr/local/bin/fleet secrets ages --motd 2>/dev/null || true
71
+ `;
72
+ }
@@ -6,7 +6,9 @@ export interface SealValidation {
6
6
  export declare function validateBeforeSeal(app: string, newContent: string): SealValidation;
7
7
  export declare function safeSealApp(app: string, content: string, sourceFile: string): SealValidation;
8
8
  export declare function safeSealDbSecrets(app: string, secretsMap: Record<string, string>, sourceDir: string): SealValidation;
9
- export declare function setSecret(app: string, key: string, value: string): void;
9
+ export declare function setSecret(app: string, key: string, value: string, opts?: {
10
+ allowWeak?: boolean;
11
+ }): void;
10
12
  export declare function getSecret(app: string, key: string): string | null;
11
13
  export declare function importEnvFile(app: string, path: string): number;
12
14
  export declare function importDbSecrets(app: string, dir: string): number;
@@ -1,10 +1,42 @@
1
- import { existsSync, readFileSync, writeFileSync, readdirSync, chmodSync, mkdirSync, rmSync, statSync } from 'node:fs';
2
- import { join } from 'node:path';
3
- import { execSync } from 'node:child_process';
1
+ import { existsSync, readFileSync, writeFileSync, readdirSync, chmodSync, mkdirSync, rmSync, statSync, copyFileSync } from 'node:fs';
2
+ import { join, basename } from 'node:path';
3
+ import { timingSafeEqual } from 'node:crypto';
4
4
  import { validateAll } from './secrets-validate.js';
5
+ import { execSafe } from './exec.js';
6
+ import { assertAppName, assertSecretKey } from './validate.js';
5
7
  import { SecretsError } from './errors.js';
8
+ import { auditLog } from './secrets-audit.js';
9
+ import { checkEntropy } from './secrets-rotation.js';
10
+ import { chownSync } from 'node:fs';
11
+ import { load as loadRegistry } from './registry.js';
12
+ /**
13
+ * Best-effort UID/GID tightening of a runtime secrets file. If the registry
14
+ * defines runtimeUid/runtimeGid for the app, chown to those values; otherwise
15
+ * leave as-is (root:root). Never throws — secret availability beats stricter
16
+ * perms (we already chmod'd 0600 so root-only is the floor).
17
+ */
18
+ function tryTightenPerms(envPath, app) {
19
+ try {
20
+ const reg = loadRegistry();
21
+ const entry = reg.apps.find(a => a.name === app);
22
+ if (!entry?.runtimeUid && !entry?.runtimeGid)
23
+ return;
24
+ chownSync(envPath, entry.runtimeUid ?? 0, entry.runtimeGid ?? 0);
25
+ }
26
+ catch (err) {
27
+ // log + continue; never block unseal
28
+ process.stderr.write(`[fleet-unseal] perm tightening skipped for ${app}: ${err}\n`);
29
+ }
30
+ }
6
31
  import { KEY_PATH, VAULT_DIR, RUNTIME_DIR, loadManifest, saveManifest, decryptApp, parseSecretsBundle, sealApp, sealDbSecrets, ageEncrypt, ageDecryptFile, getPublicKey, isInitialized, isSealed, backupVaultFile, restoreVaultFile, removeBackup, } from './secrets.js';
7
- // --- Helpers ---
32
+ // --- helpers ---
33
+ function safeEqual(a, b) {
34
+ const bufA = Buffer.from(a);
35
+ const bufB = Buffer.from(b);
36
+ if (bufA.length !== bufB.length)
37
+ return false;
38
+ return timingSafeEqual(bufA, bufB);
39
+ }
8
40
  function parseEnvKeys(content) {
9
41
  return content.split('\n')
10
42
  .filter(l => l.includes('=') && !l.startsWith('#') && l.trim())
@@ -71,7 +103,17 @@ export function safeSealDbSecrets(app, secretsMap, sourceDir) {
71
103
  }
72
104
  return validation;
73
105
  }
74
- export function setSecret(app, key, value) {
106
+ export function setSecret(app, key, value, opts = {}) {
107
+ assertAppName(app);
108
+ assertSecretKey(key);
109
+ // Entropy / placeholder check unless explicitly bypassed.
110
+ if (!opts.allowWeak) {
111
+ const entropyErr = checkEntropy(value);
112
+ if (entropyErr) {
113
+ auditLog({ op: 'set', app, secret: key, ok: false, details: `weak value rejected: ${entropyErr}` });
114
+ throw new SecretsError(`${entropyErr}. Pass --allow-weak to override (not recommended).`);
115
+ }
116
+ }
75
117
  const plaintext = decryptApp(app);
76
118
  const manifest = loadManifest();
77
119
  const entry = manifest.apps[app];
@@ -90,6 +132,7 @@ export function setSecret(app, key, value) {
90
132
  if (!found)
91
133
  updated.push(`${key}=${value}`);
92
134
  safeSealApp(app, updated.join('\n'), entry.sourceFile);
135
+ auditLog({ op: 'set', app, secret: key, ok: true });
93
136
  }
94
137
  export function getSecret(app, key) {
95
138
  const plaintext = decryptApp(app);
@@ -107,6 +150,10 @@ export function getSecret(app, key) {
107
150
  const files = parseSecretsBundle(plaintext);
108
151
  return files[key] ?? null;
109
152
  }
153
+ // Note: getSecret is read-only; we audit at the command layer to record
154
+ // human-driven reads (set/get/import/export). Programmatic reads done by
155
+ // other fleet operations (sealing, validation, drift) are not audited to
156
+ // avoid log noise.
110
157
  export function importEnvFile(app, path) {
111
158
  if (!existsSync(path))
112
159
  throw new SecretsError(`File not found: ${path}`);
@@ -119,9 +166,11 @@ export function importEnvFile(app, path) {
119
166
  }
120
167
  catch (err) {
121
168
  restoreVaultFile(app);
169
+ auditLog({ op: 'import', app, ok: false, details: `${path}: ${err}` });
122
170
  throw err;
123
171
  }
124
172
  const manifest = loadManifest();
173
+ auditLog({ op: 'import', app, ok: true, details: `${path}: ${manifest.apps[app].keyCount} keys` });
125
174
  return manifest.apps[app].keyCount;
126
175
  }
127
176
  export function importDbSecrets(app, dir) {
@@ -148,6 +197,7 @@ export function importDbSecrets(app, dir) {
148
197
  return files.length;
149
198
  }
150
199
  export function exportApp(app) {
200
+ auditLog({ op: 'export', app, ok: true });
151
201
  return decryptApp(app);
152
202
  }
153
203
  export function detectDrift(app) {
@@ -172,7 +222,7 @@ export function detectDrift(app) {
172
222
  const runtimeMap = parseEnvMap(runtimeContent);
173
223
  const addedKeys = Object.keys(runtimeMap).filter(k => !(k in vaultMap));
174
224
  const removedKeys = Object.keys(vaultMap).filter(k => !(k in runtimeMap));
175
- const changedKeys = Object.keys(vaultMap).filter(k => k in runtimeMap && vaultMap[k] !== runtimeMap[k]);
225
+ const changedKeys = Object.keys(vaultMap).filter(k => k in runtimeMap && !safeEqual(vaultMap[k], runtimeMap[k]));
176
226
  const status = (addedKeys.length || removedKeys.length || changedKeys.length) ? 'drifted' : 'in-sync';
177
227
  results.push({ app: a, status, addedKeys, removedKeys, changedKeys });
178
228
  }
@@ -191,7 +241,7 @@ export function detectDrift(app) {
191
241
  }
192
242
  const addedKeys = runtimeFiles.filter(f => !(f in vaultFiles));
193
243
  const removedKeys = Object.keys(vaultFiles).filter(f => !(f in runtimeMap));
194
- const changedKeys = Object.keys(vaultFiles).filter(f => f in runtimeMap && vaultFiles[f] !== runtimeMap[f]);
244
+ const changedKeys = Object.keys(vaultFiles).filter(f => f in runtimeMap && !safeEqual(vaultFiles[f], runtimeMap[f]));
195
245
  const status = (addedKeys.length || removedKeys.length || changedKeys.length) ? 'drifted' : 'in-sync';
196
246
  results.push({ app: a, status, addedKeys, removedKeys, changedKeys });
197
247
  }
@@ -213,6 +263,7 @@ function parseEnvMap(content) {
213
263
  // --- Phase 4: Improved unseal (validate before write) ---
214
264
  export function unsealAll() {
215
265
  const manifest = loadManifest();
266
+ auditLog({ op: 'unseal', ok: true, details: `apps=${Object.keys(manifest.apps).length}` });
216
267
  // Phase 4: Decrypt all apps first and validate BEFORE writing to runtime
217
268
  const decrypted = {};
218
269
  for (const [app, entry] of Object.entries(manifest.apps)) {
@@ -243,17 +294,29 @@ export function unsealAll() {
243
294
  const envPath = join(appDir, '.env');
244
295
  writeFileSync(envPath, plaintext);
245
296
  chmodSync(envPath, 0o600);
297
+ // Optional UID/GID tightening (registry.runtimeUid/runtimeGid). Default
298
+ // root:root if unset. Failures are non-fatal — if the UID doesn't exist
299
+ // we'd rather have the secret available than fail boot.
300
+ tryTightenPerms(envPath, app);
246
301
  }
247
302
  else if (entry.type === 'secrets-dir') {
248
303
  const secretsDir = join(RUNTIME_DIR, app, 'secrets');
249
304
  if (!existsSync(secretsDir))
250
- mkdirSync(secretsDir, { recursive: true, mode: 0o755 });
305
+ mkdirSync(secretsDir, { recursive: true, mode: 0o700 });
251
306
  const parsed = parseSecretsBundle(plaintext);
252
307
  for (const [filename, content] of Object.entries(parsed)) {
253
- const fpath = join(secretsDir, filename);
308
+ const safe = basename(filename);
309
+ if (safe !== filename || filename.includes('..')) {
310
+ throw new SecretsError(`Invalid secret filename: ${filename}`);
311
+ }
312
+ const fpath = join(secretsDir, safe);
254
313
  writeFileSync(fpath, content);
255
- // 0644: docker compose secrets bind-mounts files into containers where
256
- // non-root processes (e.g. mongodb uid 999) need read access
314
+ // 0644: docker bind-mounts these files into containers where non-root
315
+ // processes need read access. group-only (0640) breaks mongo's
316
+ // entrypoint, which reads the password file as uid 999 (mongodb)
317
+ // without first reading as root the way postgres does. host security
318
+ // still relies on the parent dir being 0700 root:root, so 0644 here
319
+ // does not widen host exposure.
257
320
  chmodSync(fpath, 0o644);
258
321
  }
259
322
  }
@@ -297,8 +360,10 @@ export function rotateKey() {
297
360
  decrypted[app] = ageDecryptFile(join(VAULT_DIR, entry.encryptedFile));
298
361
  }
299
362
  const backupPath = KEY_PATH + '.old';
300
- execSync(`cp ${KEY_PATH} ${backupPath}`);
301
- execSync(`age-keygen -o ${KEY_PATH} 2>/dev/null`);
363
+ copyFileSync(KEY_PATH, backupPath);
364
+ const keygen = execSafe('age-keygen', ['-o', KEY_PATH]);
365
+ if (!keygen.ok)
366
+ throw new SecretsError(`Failed to generate new key: ${keygen.stderr}`);
302
367
  chmodSync(KEY_PATH, 0o600);
303
368
  const newPubkey = getPublicKey();
304
369
  for (const [app, entry] of Object.entries(manifest.apps)) {
@@ -0,0 +1,50 @@
1
+ /**
2
+ * Provider registry: maps secret names to provider metadata used by
3
+ * `fleet secrets rotate` and `fleet secrets ages`.
4
+ *
5
+ * Adding a new provider: append to PROVIDERS. Order matters — the FIRST
6
+ * matching entry wins, so put more specific patterns before generic ones.
7
+ *
8
+ * Strategy reference:
9
+ * - immediate : replace value, old dies instantly. Safe for upstream API keys.
10
+ * - dual-mode : new value becomes primary, old is kept as <NAME>_PREVIOUS for a
11
+ * grace period so existing user sessions/tokens still verify.
12
+ * Requires app code to read the _PREVIOUS variant as a fallback.
13
+ * - at-rest-key : encrypts data sitting in storage. Rotating without re-encrypting
14
+ * bricks the data. Refused unless --data-migrated is passed.
15
+ * - user-issued : tokens YOU give to YOUR users. Rotating yours doesn't help —
16
+ * redirected to per-user rotation tooling.
17
+ */
18
+ export type RotationStrategy = 'immediate' | 'dual-mode' | 'at-rest-key' | 'user-issued';
19
+ export type Sensitivity = 'low' | 'medium' | 'high' | 'critical';
20
+ export interface ProviderDef {
21
+ /** Stable id for manifest persistence. */
22
+ id: string;
23
+ /** Pattern matched against the secret name (env var key). */
24
+ matches: RegExp;
25
+ /** Human label shown in the UI. */
26
+ name: string;
27
+ /** Where to go to regenerate this secret. */
28
+ url?: string;
29
+ /** Numbered, copy-pasteable rotation steps. */
30
+ instructions?: string;
31
+ /** Format the new value should match. Used to validate paste. */
32
+ format?: RegExp;
33
+ /** Severity if this leaks. Drives MOTD ordering. */
34
+ sensitivity: Sensitivity;
35
+ /** How often this secret should be rotated, in days. Drives staleness. */
36
+ rotationFrequencyDays: number;
37
+ /** How rotation should be performed. See file header. */
38
+ strategy: RotationStrategy;
39
+ /** Optional: pretty companion env var name for dual-mode rotations. */
40
+ previousVarName?: (varName: string) => string;
41
+ }
42
+ export declare const PROVIDERS: ProviderDef[];
43
+ /** Find the provider definition that matches the given secret name. */
44
+ export declare function classifySecret(name: string): ProviderDef | null;
45
+ /** Look up by stored provider id (for round-tripping after manifest persistence). */
46
+ export declare function getProviderById(id: string): ProviderDef | null;
47
+ /** Days since a timestamp. Null if invalid. */
48
+ export declare function ageInDays(iso: string | undefined): number | null;
49
+ /** True if the secret is older than its provider's rotationFrequencyDays. */
50
+ export declare function isStale(age: number | null, provider: ProviderDef | null): boolean;
@@ -0,0 +1,291 @@
1
+ /**
2
+ * Provider registry: maps secret names to provider metadata used by
3
+ * `fleet secrets rotate` and `fleet secrets ages`.
4
+ *
5
+ * Adding a new provider: append to PROVIDERS. Order matters — the FIRST
6
+ * matching entry wins, so put more specific patterns before generic ones.
7
+ *
8
+ * Strategy reference:
9
+ * - immediate : replace value, old dies instantly. Safe for upstream API keys.
10
+ * - dual-mode : new value becomes primary, old is kept as <NAME>_PREVIOUS for a
11
+ * grace period so existing user sessions/tokens still verify.
12
+ * Requires app code to read the _PREVIOUS variant as a fallback.
13
+ * - at-rest-key : encrypts data sitting in storage. Rotating without re-encrypting
14
+ * bricks the data. Refused unless --data-migrated is passed.
15
+ * - user-issued : tokens YOU give to YOUR users. Rotating yours doesn't help —
16
+ * redirected to per-user rotation tooling.
17
+ */
18
+ const previousAsSuffix = (n) => `${n}_PREVIOUS`;
19
+ export const PROVIDERS = [
20
+ // ── Stripe ───────────────────────────────────────────────────────────────
21
+ {
22
+ id: 'stripe-secret-key',
23
+ matches: /^STRIPE_SECRET_KEY$/,
24
+ name: 'Stripe Secret Key',
25
+ url: 'https://dashboard.stripe.com/apikeys',
26
+ instructions: '1. Click "Create secret key" (or "Create restricted key" — both work here)\n' +
27
+ '2. Set restrictions if desired (RESTRICTED keys are recommended for least-privilege)\n' +
28
+ '3. Copy the new sk_live_... or rk_live_... value and paste below\n' +
29
+ '4. After confirming the new key works, revoke the old one in the dashboard',
30
+ // Accept both standard (sk_) and restricted (rk_) Stripe API keys. Both are
31
+ // valid values for the STRIPE_SECRET_KEY env var; restricted keys are
32
+ // Stripe's recommended pattern for least-privilege production use.
33
+ format: /^(sk|rk)_(live|test)_[A-Za-z0-9]{40,}$/,
34
+ sensitivity: 'critical',
35
+ rotationFrequencyDays: 90,
36
+ strategy: 'immediate',
37
+ },
38
+ {
39
+ id: 'stripe-restricted-key',
40
+ matches: /^STRIPE_RESTRICTED_KEY$/,
41
+ name: 'Stripe Restricted Key',
42
+ url: 'https://dashboard.stripe.com/apikeys',
43
+ format: /^rk_(live|test)_[A-Za-z0-9]{40,}$/,
44
+ sensitivity: 'high',
45
+ rotationFrequencyDays: 90,
46
+ strategy: 'immediate',
47
+ },
48
+ {
49
+ id: 'stripe-webhook-secret',
50
+ matches: /^STRIPE_WEBHOOK_SECRET$/,
51
+ name: 'Stripe Webhook Signing Secret',
52
+ url: 'https://dashboard.stripe.com/webhooks',
53
+ instructions: '1. Click your webhook endpoint\n' +
54
+ '2. Click "Roll secret"\n' +
55
+ '3. Copy the new whsec_... value and paste below',
56
+ format: /^whsec_[A-Za-z0-9]{20,}$/,
57
+ sensitivity: 'high',
58
+ rotationFrequencyDays: 90,
59
+ strategy: 'immediate',
60
+ },
61
+ // ── GitHub ───────────────────────────────────────────────────────────────
62
+ {
63
+ id: 'github-pat-classic',
64
+ matches: /^(GITHUB_TOKEN|GH_TOKEN|GITHUB_PAT)$/,
65
+ name: 'GitHub Personal Access Token',
66
+ url: 'https://github.com/settings/tokens',
67
+ instructions: '1. Click "Generate new token (classic)" or use a fine-grained token\n' +
68
+ '2. Match the scopes/permissions of the existing token\n' +
69
+ '3. Copy the new ghp_... or github_pat_... value and paste below\n' +
70
+ '4. Delete the old token from the same page once confirmed working',
71
+ format: /^(ghp_[A-Za-z0-9]{36,}|github_pat_[A-Za-z0-9_]{50,})$/,
72
+ sensitivity: 'critical',
73
+ rotationFrequencyDays: 90,
74
+ strategy: 'immediate',
75
+ },
76
+ // ── AI providers ─────────────────────────────────────────────────────────
77
+ {
78
+ id: 'anthropic-api-key',
79
+ matches: /^ANTHROPIC_API_KEY$/,
80
+ name: 'Anthropic API Key',
81
+ url: 'https://console.anthropic.com/settings/keys',
82
+ format: /^sk-ant-[A-Za-z0-9_\-]{40,}$/,
83
+ sensitivity: 'high',
84
+ rotationFrequencyDays: 90,
85
+ strategy: 'immediate',
86
+ },
87
+ {
88
+ id: 'openai-api-key',
89
+ matches: /^OPENAI_API_KEY$/,
90
+ name: 'OpenAI API Key',
91
+ url: 'https://platform.openai.com/api-keys',
92
+ format: /^sk-(proj-)?[A-Za-z0-9_\-]{20,}$/,
93
+ sensitivity: 'high',
94
+ rotationFrequencyDays: 90,
95
+ strategy: 'immediate',
96
+ },
97
+ // ── Google ───────────────────────────────────────────────────────────────
98
+ {
99
+ id: 'google-oauth-client-secret',
100
+ matches: /^(GOOGLE_CLIENT_SECRET|GOOGLE_OAUTH_CLIENT_SECRET)$/,
101
+ name: 'Google OAuth Client Secret',
102
+ url: 'https://console.cloud.google.com/apis/credentials',
103
+ instructions: '1. Open the OAuth 2.0 Client ID for this app\n' +
104
+ '2. Add a new client secret (Google now supports multiple)\n' +
105
+ '3. Paste the new GOCSPX-... value below\n' +
106
+ '4. Delete the old secret from the same page after verifying',
107
+ format: /^GOCSPX-[A-Za-z0-9_\-]{20,}$/,
108
+ sensitivity: 'high',
109
+ rotationFrequencyDays: 180,
110
+ strategy: 'immediate',
111
+ },
112
+ {
113
+ id: 'google-api-key',
114
+ matches: /^(GOOGLE_API_KEY|GMAPS_API_KEY|GEMINI_API_KEY)$/,
115
+ name: 'Google API Key',
116
+ url: 'https://console.cloud.google.com/apis/credentials',
117
+ format: /^AIza[0-9A-Za-z_\-]{35}$/,
118
+ sensitivity: 'medium',
119
+ rotationFrequencyDays: 180,
120
+ strategy: 'immediate',
121
+ },
122
+ {
123
+ id: 'gmail-app-password',
124
+ matches: /^(EMAIL_SERVER_PASSWORD|GMAIL_APP_PASSWORD|SMTP_PASS|SMTP_PASSWORD)$/,
125
+ name: 'Gmail App Password / SMTP Password',
126
+ url: 'https://myaccount.google.com/apppasswords',
127
+ instructions: '1. Sign in and create a new App Password (e.g. "macpool-2026")\n' +
128
+ '2. Copy the 16-character value and paste below WITHOUT spaces\n' +
129
+ '3. Revoke the old App Password from the same page',
130
+ // Gmail app passwords are 16 lowercase alphanumeric chars. Google
131
+ // displays them with spaces every 4 chars for readability but expects
132
+ // them WITHOUT spaces in the SMTP password field — we accept only the
133
+ // de-spaced form so a paste-as-displayed gets rejected with a clear error.
134
+ format: /^[a-z0-9]{16}$/,
135
+ sensitivity: 'critical',
136
+ rotationFrequencyDays: 90,
137
+ strategy: 'immediate',
138
+ },
139
+ // ── App-internal cryptographic secrets (DUAL-MODE — preserves user sessions) ─
140
+ {
141
+ id: 'jwt-secret',
142
+ matches: /^(JWT_SECRET|JWT_SIGNING_SECRET|JWT_PRIVATE_KEY)$/,
143
+ name: 'JWT Signing Secret',
144
+ instructions: 'Generate a fresh high-entropy value (e.g. `openssl rand -base64 64`).\n' +
145
+ 'NOTE: Your app must read JWT_SECRET_PREVIOUS as a fallback verifier so\n' +
146
+ 'existing tokens stay valid through the grace period.',
147
+ sensitivity: 'critical',
148
+ rotationFrequencyDays: 180,
149
+ strategy: 'dual-mode',
150
+ previousVarName: previousAsSuffix,
151
+ },
152
+ {
153
+ id: 'nextauth-secret',
154
+ matches: /^(NEXTAUTH_SECRET|AUTH_SECRET)$/,
155
+ name: 'NextAuth / Auth.js Secret',
156
+ instructions: 'Generate with `openssl rand -base64 64`.\n' +
157
+ 'NextAuth supports multiple secrets in v5; configure both new and previous so\n' +
158
+ 'logged-in users remain logged in.',
159
+ sensitivity: 'critical',
160
+ rotationFrequencyDays: 180,
161
+ strategy: 'dual-mode',
162
+ previousVarName: previousAsSuffix,
163
+ },
164
+ {
165
+ id: 'session-secret',
166
+ matches: /^(SESSION_SECRET|COOKIE_SECRET|EXPRESS_SESSION_SECRET)$/,
167
+ name: 'Session / Cookie Signing Secret',
168
+ instructions: 'Generate with `openssl rand -base64 64`.',
169
+ sensitivity: 'high',
170
+ rotationFrequencyDays: 180,
171
+ strategy: 'dual-mode',
172
+ previousVarName: previousAsSuffix,
173
+ },
174
+ {
175
+ id: 'csrf-secret',
176
+ matches: /^(CSRF_SECRET|CSRF_TOKEN_SECRET)$/,
177
+ name: 'CSRF Token Secret',
178
+ instructions: 'Generate with `openssl rand -base64 64`.',
179
+ sensitivity: 'medium',
180
+ rotationFrequencyDays: 180,
181
+ strategy: 'dual-mode',
182
+ previousVarName: previousAsSuffix,
183
+ },
184
+ // ── Encryption-at-rest (REFUSED unless --data-migrated) ──────────────────
185
+ {
186
+ id: 'data-encryption-key',
187
+ matches: /^(ENCRYPTION_KEY|DATA_ENCRYPTION_KEY|FIELD_ENCRYPTION_KEY|AT_REST_KEY)$/,
188
+ name: 'At-Rest Data Encryption Key',
189
+ instructions: 'WARNING: Rotating this without re-encrypting stored data will make the data\n' +
190
+ 'unreadable. Re-encrypt all data with the new key BEFORE rotation, then run:\n' +
191
+ ' fleet secrets rotate <app> <KEY> --data-migrated',
192
+ sensitivity: 'critical',
193
+ rotationFrequencyDays: 365,
194
+ strategy: 'at-rest-key',
195
+ },
196
+ // ── Bookwhen (used by macpool) ───────────────────────────────────────────
197
+ {
198
+ id: 'bookwhen-token',
199
+ matches: /^BOOKWHEN_API_TOKEN$/,
200
+ name: 'Bookwhen API Token',
201
+ url: 'https://bookwhen.com/account/api',
202
+ sensitivity: 'medium',
203
+ rotationFrequencyDays: 180,
204
+ strategy: 'immediate',
205
+ },
206
+ // ── Database connection strings ──────────────────────────────────────────
207
+ {
208
+ id: 'database-url',
209
+ matches: /^(DATABASE_URL|MONGO_URL|REDIS_URL|POSTGRES_URL|MYSQL_URL)$/,
210
+ name: 'Database Connection String',
211
+ instructions: 'Update the password component only — keep host, port, db unchanged.\n' +
212
+ 'Rotate the underlying DB user password first, then update this URL.',
213
+ sensitivity: 'critical',
214
+ rotationFrequencyDays: 180,
215
+ strategy: 'immediate',
216
+ },
217
+ // ── AWS ──────────────────────────────────────────────────────────────────
218
+ {
219
+ id: 'aws-access-key',
220
+ matches: /^AWS_ACCESS_KEY_ID$/,
221
+ name: 'AWS Access Key ID',
222
+ url: 'https://console.aws.amazon.com/iam/home#/security_credentials',
223
+ format: /^AKIA[0-9A-Z]{16}$/,
224
+ sensitivity: 'critical',
225
+ rotationFrequencyDays: 90,
226
+ strategy: 'immediate',
227
+ },
228
+ {
229
+ id: 'aws-secret-key',
230
+ matches: /^AWS_SECRET_ACCESS_KEY$/,
231
+ name: 'AWS Secret Access Key',
232
+ url: 'https://console.aws.amazon.com/iam/home#/security_credentials',
233
+ format: /^[A-Za-z0-9/+=]{40}$/,
234
+ sensitivity: 'critical',
235
+ rotationFrequencyDays: 90,
236
+ strategy: 'immediate',
237
+ },
238
+ // ── Tokens we issue to OUR users (refused — rotate per user) ─────────────
239
+ {
240
+ id: 'user-issued-token',
241
+ matches: /^(USER_API_TOKEN|CUSTOMER_API_KEYS|TENANT_TOKENS)$/,
242
+ name: 'User-Issued Token',
243
+ instructions: 'These are tokens YOU issue to YOUR users. Rotating yours does nothing — you\n' +
244
+ 'need a per-user revocation flow in your app to invalidate them.',
245
+ sensitivity: 'high',
246
+ rotationFrequencyDays: 365,
247
+ strategy: 'user-issued',
248
+ },
249
+ // ── Generic fallback ─────────────────────────────────────────────────────
250
+ // Anything looking like a secret name but not specifically known.
251
+ // Require an explicit `_` boundary before the suffix (so `MONKEY` and
252
+ // `BROKEN_KEY` no longer match) and exclude `PUBLIC_KEY` / `PUB_KEY`
253
+ // which are not secrets despite ending in KEY.
254
+ {
255
+ id: 'generic-secret',
256
+ matches: /^(?!.*(?:PUBLIC_KEY|PUB_KEY)$).*_(SECRET|TOKEN|KEY|PASSWORD|PRIVATE)$/i,
257
+ name: 'Generic Secret',
258
+ instructions: 'Generate a fresh high-entropy value, e.g. `openssl rand -base64 32`.',
259
+ sensitivity: 'medium',
260
+ rotationFrequencyDays: 180,
261
+ strategy: 'immediate',
262
+ },
263
+ ];
264
+ /** Find the provider definition that matches the given secret name. */
265
+ export function classifySecret(name) {
266
+ for (const p of PROVIDERS) {
267
+ if (p.matches.test(name))
268
+ return p;
269
+ }
270
+ return null;
271
+ }
272
+ /** Look up by stored provider id (for round-tripping after manifest persistence). */
273
+ export function getProviderById(id) {
274
+ return PROVIDERS.find(p => p.id === id) ?? null;
275
+ }
276
+ /** Days since a timestamp. Null if invalid. */
277
+ export function ageInDays(iso) {
278
+ if (!iso)
279
+ return null;
280
+ const t = Date.parse(iso);
281
+ if (isNaN(t))
282
+ return null;
283
+ const ms = Date.now() - t;
284
+ return Math.floor(ms / (1000 * 60 * 60 * 24));
285
+ }
286
+ /** True if the secret is older than its provider's rotationFrequencyDays. */
287
+ export function isStale(age, provider) {
288
+ if (age == null || provider == null)
289
+ return false;
290
+ return age >= provider.rotationFrequencyDays;
291
+ }
@@ -0,0 +1,52 @@
1
+ /**
2
+ * Rotation engine: takes a parsed plaintext env, applies a per-secret rotation
3
+ * (immediate, dual-mode, etc.), re-seals, restarts the service, runs a health
4
+ * gate, and rolls back on failure. Pure functions where possible — I/O isolated
5
+ * to thin wrappers so tests can run without a real vault.
6
+ */
7
+ import { type ProviderDef } from './secrets-providers.js';
8
+ /** Mask a NEW (just-entered) secret value for confirmation display. */
9
+ export declare function maskNewValue(value: string): string;
10
+ /** Validate against provider format if any. Returns null on pass, error string on fail. */
11
+ export declare function validateFormat(value: string, provider: ProviderDef | null): string | null;
12
+ /** Reject obvious placeholders / low-entropy strings. */
13
+ export declare function checkEntropy(value: string): string | null;
14
+ /**
15
+ * Parse an .env-style plaintext into ordered entries. Preserves comments
16
+ * and blank lines so a re-serialised file is diff-friendly.
17
+ */
18
+ export type EnvLine = {
19
+ kind: 'kv';
20
+ key: string;
21
+ value: string;
22
+ } | {
23
+ kind: 'raw';
24
+ text: string;
25
+ };
26
+ export declare function parseEnv(plaintext: string): EnvLine[];
27
+ export declare function serialiseEnv(lines: EnvLine[]): string;
28
+ /**
29
+ * Apply a single secret update. For dual-mode strategies, the OLD value is
30
+ * preserved as <NAME>_PREVIOUS so the app can verify legacy tokens during
31
+ * the grace period. Returns the new env content.
32
+ */
33
+ export declare function applyRotation(plaintext: string, key: string, newValue: string, strategy: 'immediate' | 'dual-mode' | 'at-rest-key' | 'user-issued'): string;
34
+ export interface RotationResult {
35
+ app: string;
36
+ key: string;
37
+ strategy: string;
38
+ snapshot: string;
39
+ rolledBack: boolean;
40
+ reason?: string;
41
+ }
42
+ /**
43
+ * Full rotation pipeline. Caller is expected to have already collected and
44
+ * validated the new value via the interactive prompts. This orchestrates
45
+ * snapshot → seal → audit. Restart + health-gate are caller's responsibility
46
+ * (we want the engine pure-ish so it's easy to test).
47
+ */
48
+ export declare function performRotation(app: string, key: string, newValue: string, opts?: {
49
+ dryRun?: boolean;
50
+ notes?: string;
51
+ dataMigrated?: boolean;
52
+ }): RotationResult;