@caelo-cms/provisioning 0.10.47 → 0.10.49

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 (321) hide show
  1. package/caddy/Caddyfile.production +1 -1
  2. package/caddy/Caddyfile.staging +1 -1
  3. package/dist/agent/apply.d.ts +45 -0
  4. package/dist/agent/apply.d.ts.map +1 -0
  5. package/dist/agent/apply.js +83 -0
  6. package/dist/agent/apply.js.map +1 -0
  7. package/dist/agent/cli-provenance.d.ts +42 -0
  8. package/dist/agent/cli-provenance.d.ts.map +1 -0
  9. package/dist/agent/cli-provenance.js +138 -0
  10. package/dist/agent/cli-provenance.js.map +1 -0
  11. package/dist/agent/command.d.ts +79 -0
  12. package/dist/agent/command.d.ts.map +1 -0
  13. package/dist/agent/command.js +454 -0
  14. package/dist/agent/command.js.map +1 -0
  15. package/dist/agent/config.d.ts +30 -0
  16. package/dist/agent/config.d.ts.map +1 -0
  17. package/dist/agent/config.js +60 -0
  18. package/dist/agent/config.js.map +1 -0
  19. package/dist/agent/lock.d.ts +13 -0
  20. package/dist/agent/lock.d.ts.map +1 -0
  21. package/dist/agent/lock.js +100 -0
  22. package/dist/agent/lock.js.map +1 -0
  23. package/dist/agent/queue.d.ts +35 -0
  24. package/dist/agent/queue.d.ts.map +1 -0
  25. package/dist/agent/queue.js +50 -0
  26. package/dist/agent/queue.js.map +1 -0
  27. package/dist/agent/run.d.ts +97 -0
  28. package/dist/agent/run.d.ts.map +1 -0
  29. package/dist/agent/run.js +198 -0
  30. package/dist/agent/run.js.map +1 -0
  31. package/dist/agent/systemd.d.ts +55 -0
  32. package/dist/agent/systemd.d.ts.map +1 -0
  33. package/dist/agent/systemd.js +171 -0
  34. package/dist/agent/systemd.js.map +1 -0
  35. package/dist/agent/units.d.ts +44 -0
  36. package/dist/agent/units.d.ts.map +1 -0
  37. package/dist/agent/units.js +127 -0
  38. package/dist/agent/units.js.map +1 -0
  39. package/dist/agent-db.js +18402 -0
  40. package/dist/cli.js +119 -16
  41. package/dist/cli.js.map +1 -1
  42. package/dist/cloudsql-iam-user.d.ts +39 -0
  43. package/dist/cloudsql-iam-user.d.ts.map +1 -0
  44. package/dist/cloudsql-iam-user.js +119 -0
  45. package/dist/cloudsql-iam-user.js.map +1 -0
  46. package/dist/compose.d.ts +16 -0
  47. package/dist/compose.d.ts.map +1 -1
  48. package/dist/compose.js +26 -1
  49. package/dist/compose.js.map +1 -1
  50. package/dist/dns/index.d.ts +2 -0
  51. package/dist/dns/index.d.ts.map +1 -1
  52. package/dist/dns/index.js +3 -2
  53. package/dist/dns/index.js.map +1 -1
  54. package/dist/dns/manual.d.ts +5 -0
  55. package/dist/dns/manual.d.ts.map +1 -1
  56. package/dist/dns/manual.js +28 -12
  57. package/dist/dns/manual.js.map +1 -1
  58. package/dist/gcloud.d.ts +2 -0
  59. package/dist/gcloud.d.ts.map +1 -1
  60. package/dist/gcloud.js +1 -1
  61. package/dist/gcloud.js.map +1 -1
  62. package/dist/gcp-names.d.ts +15 -0
  63. package/dist/gcp-names.d.ts.map +1 -1
  64. package/dist/gcp-names.js +23 -0
  65. package/dist/gcp-names.js.map +1 -1
  66. package/dist/index.d.ts +6 -0
  67. package/dist/index.d.ts.map +1 -1
  68. package/dist/index.js +4 -0
  69. package/dist/index.js.map +1 -1
  70. package/dist/infra-contract.d.ts +1 -1
  71. package/dist/infra-contract.js +7 -1
  72. package/dist/infra-contract.js.map +1 -1
  73. package/dist/install-job-runner.d.ts +13 -0
  74. package/dist/install-job-runner.d.ts.map +1 -0
  75. package/dist/install-job-runner.js +155 -0
  76. package/dist/install-job-runner.js.map +1 -0
  77. package/dist/install-job.d.ts +210 -0
  78. package/dist/install-job.d.ts.map +1 -0
  79. package/dist/install-job.js +449 -0
  80. package/dist/install-job.js.map +1 -0
  81. package/dist/install-state.d.ts +10 -1
  82. package/dist/install-state.d.ts.map +1 -1
  83. package/dist/install-state.js +10 -2
  84. package/dist/install-state.js.map +1 -1
  85. package/dist/lifecycle.d.ts.map +1 -1
  86. package/dist/lifecycle.js +19 -4
  87. package/dist/lifecycle.js.map +1 -1
  88. package/dist/migration-runner.d.ts +5 -0
  89. package/dist/migration-runner.d.ts.map +1 -1
  90. package/dist/migration-runner.js +1 -1
  91. package/dist/migration-runner.js.map +1 -1
  92. package/dist/operator-access.d.ts +14 -8
  93. package/dist/operator-access.d.ts.map +1 -1
  94. package/dist/operator-access.js +16 -102
  95. package/dist/operator-access.js.map +1 -1
  96. package/dist/release-manifest.d.ts +2 -41
  97. package/dist/release-manifest.d.ts.map +1 -1
  98. package/dist/release-manifest.js +18 -121
  99. package/dist/release-manifest.js.map +1 -1
  100. package/dist/run-mode.d.ts +27 -0
  101. package/dist/run-mode.d.ts.map +1 -0
  102. package/dist/run-mode.js +22 -0
  103. package/dist/run-mode.js.map +1 -0
  104. package/dist/runtime-secrets.d.ts +19 -0
  105. package/dist/runtime-secrets.d.ts.map +1 -1
  106. package/dist/runtime-secrets.js +44 -0
  107. package/dist/runtime-secrets.js.map +1 -1
  108. package/dist/self-hosted-roles.d.ts +22 -1
  109. package/dist/self-hosted-roles.d.ts.map +1 -1
  110. package/dist/self-hosted-roles.js +81 -12
  111. package/dist/self-hosted-roles.js.map +1 -1
  112. package/dist/self-hosted-upgrade.d.ts +17 -0
  113. package/dist/self-hosted-upgrade.d.ts.map +1 -1
  114. package/dist/self-hosted-upgrade.js +16 -1
  115. package/dist/self-hosted-upgrade.js.map +1 -1
  116. package/dist/stack-contract.d.ts +6 -0
  117. package/dist/stack-contract.d.ts.map +1 -1
  118. package/dist/stack-contract.js +10 -1
  119. package/dist/stack-contract.js.map +1 -1
  120. package/dist/upgrade/converge.d.ts +6 -0
  121. package/dist/upgrade/converge.d.ts.map +1 -1
  122. package/dist/upgrade/converge.js +59 -2
  123. package/dist/upgrade/converge.js.map +1 -1
  124. package/dist/upgrade/gcp-lookup.d.ts +6 -0
  125. package/dist/upgrade/gcp-lookup.d.ts.map +1 -1
  126. package/dist/upgrade/gcp-lookup.js +25 -0
  127. package/dist/upgrade/gcp-lookup.js.map +1 -1
  128. package/dist/upgrade/release-images.d.ts +26 -2
  129. package/dist/upgrade/release-images.d.ts.map +1 -1
  130. package/dist/upgrade/release-images.js +35 -2
  131. package/dist/upgrade/release-images.js.map +1 -1
  132. package/dist/upgrade/upgrade.d.ts +5 -0
  133. package/dist/upgrade/upgrade.d.ts.map +1 -1
  134. package/dist/upgrade/upgrade.js +28 -1
  135. package/dist/upgrade/upgrade.js.map +1 -1
  136. package/dist/upgrader-assess.d.ts +85 -0
  137. package/dist/upgrader-assess.d.ts.map +1 -0
  138. package/dist/upgrader-assess.js +122 -0
  139. package/dist/upgrader-assess.js.map +1 -0
  140. package/dist/upgrader-gcp.d.ts +17 -0
  141. package/dist/upgrader-gcp.d.ts.map +1 -0
  142. package/dist/upgrader-gcp.js +87 -0
  143. package/dist/upgrader-gcp.js.map +1 -0
  144. package/dist/upgrader-job.d.ts +133 -0
  145. package/dist/upgrader-job.d.ts.map +1 -0
  146. package/dist/upgrader-job.js +259 -0
  147. package/dist/upgrader-job.js.map +1 -0
  148. package/dist/upgrader-reporter.d.ts +35 -0
  149. package/dist/upgrader-reporter.d.ts.map +1 -0
  150. package/dist/upgrader-reporter.js +79 -0
  151. package/dist/upgrader-reporter.js.map +1 -0
  152. package/dist/upgrader-setup.d.ts +59 -0
  153. package/dist/upgrader-setup.d.ts.map +1 -0
  154. package/dist/upgrader-setup.js +308 -0
  155. package/dist/upgrader-setup.js.map +1 -0
  156. package/dist/wizard.d.ts +6 -0
  157. package/dist/wizard.d.ts.map +1 -1
  158. package/dist/wizard.js +32 -4
  159. package/dist/wizard.js.map +1 -1
  160. package/dist/wizards/gcp-firebase.d.ts +3 -0
  161. package/dist/wizards/gcp-firebase.d.ts.map +1 -1
  162. package/dist/wizards/gcp-firebase.js +1 -0
  163. package/dist/wizards/gcp-firebase.js.map +1 -1
  164. package/dist/wizards/gcp-install-job.d.ts +74 -0
  165. package/dist/wizards/gcp-install-job.d.ts.map +1 -0
  166. package/dist/wizards/gcp-install-job.js +398 -0
  167. package/dist/wizards/gcp-install-job.js.map +1 -0
  168. package/dist/wizards/gcp-prompts.d.ts +3 -0
  169. package/dist/wizards/gcp-prompts.d.ts.map +1 -0
  170. package/dist/wizards/gcp-prompts.js +25 -0
  171. package/dist/wizards/gcp-prompts.js.map +1 -0
  172. package/dist/wizards/gcp-pulumi.d.ts +4 -2
  173. package/dist/wizards/gcp-pulumi.d.ts.map +1 -1
  174. package/dist/wizards/gcp-pulumi.js +1 -1
  175. package/dist/wizards/gcp-pulumi.js.map +1 -1
  176. package/dist/wizards/gcp.d.ts +40 -0
  177. package/dist/wizards/gcp.d.ts.map +1 -1
  178. package/dist/wizards/gcp.js +223 -59
  179. package/dist/wizards/gcp.js.map +1 -1
  180. package/package.json +6 -4
  181. package/src/adapter.ts +97 -0
  182. package/src/adc-impersonation.ts +176 -0
  183. package/src/admin-domain.test.ts +254 -0
  184. package/src/admin-domain.ts +245 -0
  185. package/src/admin-memory.test.ts +151 -0
  186. package/src/agent/apply.test.ts +185 -0
  187. package/src/agent/apply.ts +124 -0
  188. package/src/agent/cli-provenance.test.ts +159 -0
  189. package/src/agent/cli-provenance.ts +191 -0
  190. package/src/agent/command.test.ts +77 -0
  191. package/src/agent/command.ts +564 -0
  192. package/src/agent/config.ts +86 -0
  193. package/src/agent/lock-config.test.ts +97 -0
  194. package/src/agent/lock.ts +110 -0
  195. package/src/agent/queue.test.ts +10 -0
  196. package/src/agent/queue.ts +91 -0
  197. package/src/agent/run.test.ts +437 -0
  198. package/src/agent/run.ts +295 -0
  199. package/src/agent/systemd.ts +209 -0
  200. package/src/agent/units.test.ts +223 -0
  201. package/src/agent/units.ts +163 -0
  202. package/src/azure-front-door.test.ts +119 -0
  203. package/src/azure-front-door.ts +56 -0
  204. package/src/bootstrap-token.ts +20 -0
  205. package/src/caddy.ts +112 -0
  206. package/src/cdn-copy.ts +91 -0
  207. package/src/cert-wait.test.ts +194 -0
  208. package/src/cert-wait.ts +163 -0
  209. package/src/checkpoint-secrets.test.ts +380 -0
  210. package/src/checkpoint-secrets.ts +283 -0
  211. package/src/cli-routing.test.ts +45 -0
  212. package/src/cli-routing.ts +43 -0
  213. package/src/cli.ts +1491 -0
  214. package/src/cloudsql-iam-user.ts +147 -0
  215. package/src/compose.ts +334 -0
  216. package/src/default-firewall.test.ts +411 -0
  217. package/src/default-firewall.ts +342 -0
  218. package/src/deployed-release.test.ts +231 -0
  219. package/src/deployed-release.ts +220 -0
  220. package/src/dns/cloudflare.ts +182 -0
  221. package/src/dns/groups.test.ts +76 -0
  222. package/src/dns/groups.ts +86 -0
  223. package/src/dns/index.ts +56 -0
  224. package/src/dns/manual.ts +130 -0
  225. package/src/dns/types.ts +27 -0
  226. package/src/gateway-credentials.test.ts +180 -0
  227. package/src/gateway-credentials.ts +144 -0
  228. package/src/gcloud-components.test.ts +76 -0
  229. package/src/gcloud-components.ts +85 -0
  230. package/src/gcloud-retry.ts +41 -0
  231. package/src/gcloud-runner.test.ts +61 -0
  232. package/src/gcloud.test.ts +99 -0
  233. package/src/gcloud.ts +322 -0
  234. package/src/gcp-names.ts +135 -0
  235. package/src/index.test.ts +332 -0
  236. package/src/index.ts +114 -0
  237. package/src/infra-contract.ts +32 -0
  238. package/src/install-job-runner.ts +166 -0
  239. package/src/install-job.test.ts +566 -0
  240. package/src/install-job.ts +542 -0
  241. package/src/install-state.test.ts +239 -0
  242. package/src/install-state.ts +583 -0
  243. package/src/internal-token.ts +35 -0
  244. package/src/lifecycle.ts +900 -0
  245. package/src/mcp-iap-stacks.test.ts +58 -0
  246. package/src/mcp-iap.test.ts +149 -0
  247. package/src/mcp-iap.ts +131 -0
  248. package/src/media-volume.test.ts +296 -0
  249. package/src/migration-runner.ts +365 -0
  250. package/src/operator-access-stacks.test.ts +55 -0
  251. package/src/operator-access.test.ts +332 -0
  252. package/src/operator-access.ts +554 -0
  253. package/src/ops-backend.test.ts +320 -0
  254. package/src/ops-backend.ts +414 -0
  255. package/src/provisioner-identity.test.ts +482 -0
  256. package/src/provisioner-identity.ts +407 -0
  257. package/src/redirects-emit.ts +175 -0
  258. package/src/region-data.ts +428 -0
  259. package/src/regions.test.ts +277 -0
  260. package/src/regions.ts +370 -0
  261. package/src/release-contract.ts +57 -0
  262. package/src/release-manifest.test.ts +300 -0
  263. package/src/release-manifest.ts +310 -0
  264. package/src/run-mode.ts +40 -0
  265. package/src/runtime-secrets.test.ts +627 -0
  266. package/src/runtime-secrets.ts +754 -0
  267. package/src/self-command.test.ts +14 -0
  268. package/src/self-command.ts +21 -0
  269. package/src/self-hosted-compose.test.ts +177 -0
  270. package/src/self-hosted-release.ts +240 -0
  271. package/src/self-hosted-roles.integration.test.ts +161 -0
  272. package/src/self-hosted-roles.test.ts +124 -0
  273. package/src/self-hosted-roles.ts +203 -0
  274. package/src/self-hosted-upgrade.test.ts +394 -0
  275. package/src/self-hosted-upgrade.ts +548 -0
  276. package/src/stack-contract-parity.test.ts +199 -0
  277. package/src/stack-contract.test.ts +391 -0
  278. package/src/stack-contract.ts +888 -0
  279. package/src/stack-converge-secrets.test.ts +421 -0
  280. package/src/stack-converge.test.ts +833 -0
  281. package/src/stack-converge.ts +909 -0
  282. package/src/staging-admin.test.ts +246 -0
  283. package/src/staging-admin.ts +324 -0
  284. package/src/state-attach.test.ts +211 -0
  285. package/src/state-attach.ts +132 -0
  286. package/src/state-backend.test.ts +145 -0
  287. package/src/state-backend.ts +262 -0
  288. package/src/state-command.ts +293 -0
  289. package/src/state-migrate.test.ts +479 -0
  290. package/src/state-migrate.ts +434 -0
  291. package/src/state-pulumi.ts +289 -0
  292. package/src/state-store.test.ts +212 -0
  293. package/src/state-store.ts +243 -0
  294. package/src/upgrade/app-roll.ts +486 -0
  295. package/src/upgrade/backup.ts +109 -0
  296. package/src/upgrade/converge.ts +437 -0
  297. package/src/upgrade/gcp-lookup.ts +167 -0
  298. package/src/upgrade/health.test.ts +279 -0
  299. package/src/upgrade/health.ts +305 -0
  300. package/src/upgrade/release-images.test.ts +119 -0
  301. package/src/upgrade/release-images.ts +393 -0
  302. package/src/upgrade/reporter.ts +46 -0
  303. package/src/upgrade/upgrade.test.ts +626 -0
  304. package/src/upgrade/upgrade.ts +249 -0
  305. package/src/upgrader-assess.test.ts +306 -0
  306. package/src/upgrader-assess.ts +250 -0
  307. package/src/upgrader-gcp.ts +123 -0
  308. package/src/upgrader-job.test.ts +411 -0
  309. package/src/upgrader-job.ts +502 -0
  310. package/src/upgrader-reporter.test.ts +46 -0
  311. package/src/upgrader-reporter.ts +105 -0
  312. package/src/upgrader-setup.test.ts +169 -0
  313. package/src/upgrader-setup.ts +355 -0
  314. package/src/wizard.ts +425 -0
  315. package/src/wizards/gcp-cost.ts +188 -0
  316. package/src/wizards/gcp-firebase.ts +72 -0
  317. package/src/wizards/gcp-install-job.ts +506 -0
  318. package/src/wizards/gcp-prompts.ts +26 -0
  319. package/src/wizards/gcp-pulumi.test.ts +246 -0
  320. package/src/wizards/gcp-pulumi.ts +516 -0
  321. package/src/wizards/gcp.ts +1935 -0
@@ -0,0 +1,380 @@
1
+ // SPDX-License-Identifier: MPL-2.0
2
+
3
+ /**
4
+ * #656 — progress.json / install.json never hold secret material, and
5
+ * `upgrade` / wizard re-runs scrub what earlier installers stored. Every
6
+ * credential below is a fake fixture.
7
+ */
8
+
9
+ import { describe, expect, it } from "bun:test";
10
+ import {
11
+ mkdirSync,
12
+ mkdtempSync,
13
+ readdirSync,
14
+ readFileSync,
15
+ rmSync,
16
+ statSync,
17
+ writeFileSync,
18
+ } from "node:fs";
19
+ import { tmpdir } from "node:os";
20
+ import { join, resolve } from "node:path";
21
+ import { CAELO_VERSION } from "@caelo-cms/shared";
22
+ import {
23
+ assertNoCheckpointSecrets,
24
+ assertNoSecrets,
25
+ checkpointableOutputs,
26
+ findCheckpointSecrets,
27
+ findSecrets,
28
+ redactCredentials,
29
+ rotationHints,
30
+ scrubProgressFile,
31
+ } from "./checkpoint-secrets.js";
32
+ import { databaseUrls } from "./stack-contract.js";
33
+
34
+ const FAKE_PW = "fakePw0rdForTests123";
35
+ const legacyAdminUrl = `postgresql://admin_role:${FAKE_PW}@10.1.2.3:5432/cms_admin?sslmode=require`;
36
+ const legacyPublicUrl = `postgresql://public_role:${FAKE_PW}@10.1.2.3:5432/cms_public?sslmode=require`;
37
+ const setupLink = "https://admin.acme.test/setup?token=fakeSetupToken0123456789";
38
+
39
+ /** What `pulumi up` returns on the current stacks, secret flags included. */
40
+ const stackOutputs = {
41
+ adminDatabaseUrlOut: { value: databaseUrls("10.1.2.3").admin, secret: false },
42
+ publicDatabaseUrlOut: { value: databaseUrls("10.1.2.3").public, secret: false },
43
+ gatewayDatabaseUrlOut: { value: databaseUrls("10.1.2.3").gateway, secret: false },
44
+ bootstrapUrlOut: { value: setupLink, secret: false },
45
+ bootstrapTokenExpiresAtOut: { value: "2026-10-13T00:00:00.000Z", secret: false },
46
+ providerOut: { value: "gcp", secret: false },
47
+ lbIpOut: { value: "203.0.113.7", secret: false },
48
+ adminDomainOut: { value: "admin.acme.test", secret: false },
49
+ adminCloudRunUrlOut: { value: "https://caelo-admin-x.a.run.app", secret: false },
50
+ dnsRecordsRequiredOut: {
51
+ value: [{ hostname: "acme.test", type: "A", value: "203.0.113.7", purpose: "site" }],
52
+ secret: false,
53
+ },
54
+ // A future output Pulumi marks secret, whatever its name or shape.
55
+ someFutureOut: { value: "opaque", secret: true },
56
+ };
57
+
58
+ /** A progress.json as installers before #576 / #656 wrote it. */
59
+ const legacyProgress = {
60
+ lastCompletedStep: "pulumi-up-acme",
61
+ updatedAt: "2026-10-01T00:00:00.000Z",
62
+ steps: {
63
+ "project-acme": { existed: true },
64
+ "sa-acme": { created: true, saEmail: "caelo-provisioner@acme.iam.gserviceaccount.com" },
65
+ "sa-key-acme": { path: "/home/op/.caelo-gcp-acme/secrets/sa-key.json" },
66
+ "pulumi-up-acme": {
67
+ outputs: {
68
+ adminDatabaseUrlOut: legacyAdminUrl,
69
+ publicDatabaseUrlOut: legacyPublicUrl,
70
+ bootstrapUrlOut: setupLink,
71
+ providerOut: "gcp",
72
+ lbIpOut: "203.0.113.7",
73
+ adminDomainOut: "admin.acme.test",
74
+ },
75
+ },
76
+ },
77
+ };
78
+
79
+ describe("findSecrets", () => {
80
+ it("flags URLs carrying a password and names the database user", () => {
81
+ const found = findSecrets(legacyProgress);
82
+ expect(found).toContainEqual({
83
+ path: "steps.pulumi-up-acme.outputs.adminDatabaseUrlOut",
84
+ kind: "credential-url",
85
+ user: "admin_role",
86
+ });
87
+ expect(found).toContainEqual({
88
+ path: "steps.pulumi-up-acme.outputs.publicDatabaseUrlOut",
89
+ kind: "credential-url",
90
+ user: "public_role",
91
+ });
92
+ expect(found).toContainEqual({
93
+ path: "steps.pulumi-up-acme.outputs.bootstrapUrlOut",
94
+ kind: "token-url",
95
+ });
96
+ // Findings name where, never what.
97
+ expect(JSON.stringify(found)).not.toContain(FAKE_PW);
98
+ });
99
+
100
+ it("does not flag the password-less database URLs the stacks export today", () => {
101
+ expect(findSecrets(databaseUrls("10.1.2.3"))).toEqual([]);
102
+ });
103
+
104
+ it("flags secret-named keys holding a value, not markers or look-alikes", () => {
105
+ expect(findSecrets({ postgresPassword: "x" })).toEqual([
106
+ { path: "postgresPassword", kind: "secret-key" },
107
+ ]);
108
+ expect(findSecrets({ caeloSecretKekOut: "x", anthropic_api_key: "y" })).toHaveLength(2);
109
+ expect(findSecrets({ bootstrapTokenExpiresAtOut: "2026-10-13", hasToken: false })).toEqual([]);
110
+ });
111
+ });
112
+
113
+ describe("checkpointableOutputs", () => {
114
+ it("drops secret-flagged outputs and the setup link, keeps what resume and DNS need", () => {
115
+ const kept = checkpointableOutputs(stackOutputs);
116
+ expect(Object.keys(kept)).not.toContain("someFutureOut");
117
+ expect(Object.keys(kept)).not.toContain("bootstrapUrlOut");
118
+ expect(kept.dnsRecordsRequiredOut).toEqual(stackOutputs.dnsRecordsRequiredOut.value);
119
+ expect(kept.adminDomainOut).toBe("admin.acme.test");
120
+ expect(kept.bootstrapTokenExpiresAtOut).toBe("2026-10-13T00:00:00.000Z");
121
+ expect(findSecrets(kept)).toEqual([]);
122
+ });
123
+
124
+ it("drops a database URL that carries a password even when Pulumi did not mark it secret", () => {
125
+ const kept = checkpointableOutputs({
126
+ adminDatabaseUrlOut: { value: legacyAdminUrl, secret: false },
127
+ });
128
+ expect(kept).toEqual({});
129
+ });
130
+ });
131
+
132
+ describe("checkpoint guard (regression #656)", () => {
133
+ it("refuses a payload with a credential URL without echoing the secret", () => {
134
+ let message = "";
135
+ try {
136
+ assertNoSecrets({ outputs: { adminDatabaseUrlOut: legacyAdminUrl } }, "progress.json");
137
+ } catch (e) {
138
+ message = e instanceof Error ? e.message : String(e);
139
+ }
140
+ expect(message).toContain("outputs.adminDatabaseUrlOut: credential-url");
141
+ expect(message).not.toContain(FAKE_PW);
142
+ });
143
+
144
+ it("accepts every payload shape the wizard checkpoints", () => {
145
+ const payloads = [
146
+ { existed: true },
147
+ { created: true },
148
+ { billingAccountId: "000000-000000-000000" },
149
+ { count: 12 },
150
+ { existed: true, saEmail: "sa@acme.iam.gserviceaccount.com" },
151
+ { granted: ["roles/run.admin"] },
152
+ { path: "/home/op/.caelo-gcp-acme/secrets/sa-key.json" },
153
+ { outputs: checkpointableOutputs(stackOutputs) },
154
+ { skipped: "exists" },
155
+ { bucket: "caelo-production-static-acme" },
156
+ { adapter: "cloudflare" },
157
+ ];
158
+ for (const p of payloads) expect(() => assertNoSecrets(p, "progress.json")).not.toThrow();
159
+ });
160
+
161
+ it("every markStepDone call site in the CLI uses a reviewed payload key", () => {
162
+ // A new checkpoint payload must be added here on purpose, after checking
163
+ // it holds no secret. `outputs` must come from pulumiUpGcp, which returns
164
+ // only checkpointableOutputs().
165
+ const reviewed = new Set([
166
+ "existed",
167
+ "created",
168
+ "billingAccountId",
169
+ "count",
170
+ "saEmail",
171
+ "granted",
172
+ "path",
173
+ "outputs",
174
+ "skipped",
175
+ "bucket",
176
+ "adapter",
177
+ ]);
178
+ const files = [
179
+ ...readdirSync(import.meta.dir).map((f) => join(import.meta.dir, f)),
180
+ ...readdirSync(join(import.meta.dir, "wizards")).map((f) =>
181
+ join(import.meta.dir, "wizards", f),
182
+ ),
183
+ ].filter((f) => f.endsWith(".ts") && !f.endsWith(".test.ts"));
184
+ const keys: string[] = [];
185
+ for (const file of files) {
186
+ const src = readFileSync(file, "utf8");
187
+ for (const m of src.matchAll(/markStepDone\([^,]+,[^,]+,\s*\{([^}]*)\}/g)) {
188
+ for (const part of (m[1] ?? "").split(",")) {
189
+ const key = part.split(":")[0]?.trim();
190
+ if (key) keys.push(key);
191
+ }
192
+ }
193
+ }
194
+ expect(keys.length).toBeGreaterThan(0);
195
+ expect(keys.filter((k) => !reviewed.has(k))).toEqual([]);
196
+ const gcp = readFileSync(join(import.meta.dir, "wizards", "gcp.ts"), "utf8");
197
+ expect(gcp).toContain("markStepDone(installId, stepName, { outputs: result.outputs })");
198
+ const up = readFileSync(join(import.meta.dir, "wizards", "gcp-pulumi.ts"), "utf8");
199
+ expect(up).toContain("outputs: checkpointableOutputs(result.outputs)");
200
+ });
201
+ });
202
+
203
+ describe("checkpoints of projects with secret-sounding ids (codex review)", () => {
204
+ it("treats step names as identifiers, not secret fields", () => {
205
+ const checkpoint = {
206
+ lastCompletedStep: "project-acme-token",
207
+ updatedAt: "t",
208
+ steps: { "project-acme-token": { existed: true }, "billing-acme-secret": { count: 1 } },
209
+ };
210
+ expect(findCheckpointSecrets(checkpoint)).toEqual([]);
211
+ expect(() => assertNoCheckpointSecrets(checkpoint, "progress.json")).not.toThrow();
212
+ // Payloads under such steps are still inspected.
213
+ expect(
214
+ findCheckpointSecrets({ steps: { "project-acme-token": { url: legacyAdminUrl } } }),
215
+ ).toEqual([
216
+ { path: "steps.project-acme-token.url", kind: "credential-url", user: "admin_role" },
217
+ ]);
218
+ });
219
+ });
220
+
221
+ describe("scrubProgressFile", () => {
222
+ const withFile = (contents: unknown, run: (path: string) => void) => {
223
+ const dir = mkdtempSync(join(tmpdir(), "caelo-scrub-"));
224
+ try {
225
+ const path = join(dir, "progress.json");
226
+ writeFileSync(path, JSON.stringify(contents), { mode: 0o644 });
227
+ run(path);
228
+ } finally {
229
+ rmSync(dir, { recursive: true, force: true });
230
+ }
231
+ };
232
+
233
+ it("removes the secrets, keeps every completed step, and leaves the file 0600", () => {
234
+ withFile(legacyProgress, (path) => {
235
+ const { removed } = scrubProgressFile(path);
236
+ expect(removed.map((f) => f.kind).sort()).toEqual([
237
+ "credential-url",
238
+ "credential-url",
239
+ "token-url",
240
+ ]);
241
+ const text = readFileSync(path, "utf8");
242
+ expect(text).not.toContain(FAKE_PW);
243
+ expect(text).not.toContain("token=");
244
+ const after = JSON.parse(text);
245
+ // Wizard re-runs resume: every step is still recorded as done.
246
+ expect(Object.keys(after.steps)).toEqual(Object.keys(legacyProgress.steps));
247
+ expect(after.lastCompletedStep).toBe("pulumi-up-acme");
248
+ expect(after.steps["pulumi-up-acme"].outputs).toEqual({
249
+ providerOut: "gcp",
250
+ lbIpOut: "203.0.113.7",
251
+ adminDomainOut: "admin.acme.test",
252
+ });
253
+ expect(statSync(path).mode & 0o777).toBe(0o600);
254
+ expect(readdirSync(join(path, "..")).filter((f) => f.includes(".tmp-"))).toEqual([]);
255
+ // Idempotent.
256
+ expect(scrubProgressFile(path).removed).toEqual([]);
257
+ });
258
+ });
259
+
260
+ it("keeps the completed-step markers of a project whose id sounds secret", () => {
261
+ const cp = {
262
+ lastCompletedStep: "pulumi-up-acme-token",
263
+ updatedAt: "t",
264
+ steps: {
265
+ "project-acme-token": { existed: true },
266
+ "pulumi-up-acme-token": { outputs: { adminDatabaseUrlOut: legacyAdminUrl, lbIpOut: "1" } },
267
+ },
268
+ };
269
+ withFile(cp, (path) => {
270
+ expect(scrubProgressFile(path).removed).toHaveLength(1);
271
+ const after = JSON.parse(readFileSync(path, "utf8"));
272
+ expect(after.steps).toEqual({
273
+ "project-acme-token": { existed: true },
274
+ "pulumi-up-acme-token": { outputs: { lbIpOut: "1" } },
275
+ });
276
+ });
277
+ });
278
+
279
+ it("leaves a clean file untouched and a missing one alone", () => {
280
+ withFile(
281
+ { lastCompletedStep: "x", steps: { x: { created: true } }, updatedAt: "t" },
282
+ (path) => {
283
+ const before = readFileSync(path, "utf8");
284
+ expect(scrubProgressFile(path).removed).toEqual([]);
285
+ expect(readFileSync(path, "utf8")).toBe(before);
286
+ expect(scrubProgressFile(`${path}.missing`).removed).toEqual([]);
287
+ },
288
+ );
289
+ });
290
+ });
291
+
292
+ describe("rotationHints", () => {
293
+ it("names the rotate-secret command for each leaked role password", () => {
294
+ const hints = rotationHints(
295
+ [
296
+ { path: "a", kind: "credential-url", user: "admin_role" },
297
+ { path: "b", kind: "credential-url", user: "public_role" },
298
+ { path: "c", kind: "credential-url", user: "gateway_role" },
299
+ { path: "d", kind: "credential-url", user: "admin_role" },
300
+ ],
301
+ "gcp-acme",
302
+ );
303
+ expect(hints).toEqual([
304
+ `admin_role's password was stored in progress.json. Rotate it: bunx @caelo-cms/provisioning@${CAELO_VERSION} rotate-secret postgres-password --install gcp-acme`,
305
+ `public_role's password was stored in progress.json. Rotate it: bunx @caelo-cms/provisioning@${CAELO_VERSION} rotate-secret public-role-password --install gcp-acme`,
306
+ `gateway_role's password was stored in progress.json. Rotate it: bunx @caelo-cms/provisioning@${CAELO_VERSION} rotate-secret gateway-role-password --install gcp-acme`,
307
+ ]);
308
+ });
309
+
310
+ it("says the setup link needs no rotation", () => {
311
+ const [hint] = rotationHints([{ path: "a", kind: "token-url" }], "gcp-acme");
312
+ expect(hint).toContain("nothing to rotate");
313
+ });
314
+ });
315
+
316
+ describe("redactCredentials", () => {
317
+ it("masks URL passwords and leaves the rest readable", () => {
318
+ const out = redactCredentials(`connect failed: ${legacyAdminUrl} (timeout)`);
319
+ expect(out).not.toContain(FAKE_PW);
320
+ expect(out).toContain("postgresql://admin_role:***@10.1.2.3:5432/cms_admin");
321
+ expect(redactCredentials("https://admin.acme.test/x")).toBe("https://admin.acme.test/x");
322
+ });
323
+ });
324
+
325
+ describe("install dir end to end (regression #656)", () => {
326
+ it("a wizard's checkpoints leave no secret outside secrets/; legacy files are scrubbed; secrets are refused", () => {
327
+ // installRoot() resolves ~ via os.homedir(), read once at process start —
328
+ // drive install-state in a child process with its own HOME.
329
+ const home = mkdtempSync(join(tmpdir(), "caelo-install-656-"));
330
+ const installId = "gcp-acme";
331
+ const root = join(home, `.caelo-${installId}`);
332
+ try {
333
+ mkdirSync(root, { recursive: true });
334
+ writeFileSync(join(root, "progress.json"), JSON.stringify(legacyProgress), {
335
+ mode: 0o600,
336
+ });
337
+ const mod = resolve(import.meta.dir, "install-state.ts");
338
+ const sec = resolve(import.meta.dir, "checkpoint-secrets.ts");
339
+ const script = `
340
+ const m = await import(${JSON.stringify(mod)});
341
+ const s = await import(${JSON.stringify(sec)});
342
+ const id = ${JSON.stringify(installId)};
343
+ const lines = m.scrubInstallProgress(id);
344
+ m.markStepDone(id, "sa-acme", { created: true, saEmail: "sa@acme.iam.gserviceaccount.com" });
345
+ m.markStepDone(id, "pulumi-up-acme", { outputs: s.checkpointableOutputs(${JSON.stringify(stackOutputs)}) });
346
+ let refused = "";
347
+ try {
348
+ m.markStepDone(id, "bad", { url: ${JSON.stringify(legacyAdminUrl)} });
349
+ } catch (e) { refused = e.message; }
350
+ console.log(JSON.stringify({ lines, refused, done: m.isStepDone(id, "project-acme") }));
351
+ `;
352
+ const child = Bun.spawnSync([process.execPath, "-e", script], {
353
+ env: { ...process.env, HOME: home },
354
+ stderr: "pipe",
355
+ });
356
+ expect(new TextDecoder().decode(child.stderr)).toBe("");
357
+ expect(child.exitCode).toBe(0);
358
+ const result = JSON.parse(new TextDecoder().decode(child.stdout));
359
+ expect(result.done).toBe(true);
360
+ expect(result.refused).toContain("credential-url");
361
+ expect(result.refused).not.toContain(FAKE_PW);
362
+ expect(result.lines.join("\n")).toContain(
363
+ "rotate-secret postgres-password --install gcp-acme",
364
+ );
365
+ expect(result.lines.join("\n")).toContain(
366
+ "rotate-secret public-role-password --install gcp-acme",
367
+ );
368
+ // Nothing under the install dir but secrets/ holds secret material.
369
+ for (const f of readdirSync(root)) {
370
+ if (f === "secrets" || f === "state") continue;
371
+ const text = readFileSync(join(root, f), "utf8");
372
+ expect(findSecrets(JSON.parse(text))).toEqual([]);
373
+ expect(text).not.toContain(FAKE_PW);
374
+ }
375
+ expect(statSync(join(root, "progress.json")).mode & 0o777).toBe(0o600);
376
+ } finally {
377
+ rmSync(home, { recursive: true, force: true });
378
+ }
379
+ }, 30_000);
380
+ });
@@ -0,0 +1,283 @@
1
+ // SPDX-License-Identifier: MPL-2.0
2
+
3
+ /**
4
+ * Keeps secret material out of the files the CLI writes under
5
+ * `~/.caelo-<install-id>/` (#656).
6
+ *
7
+ * Only `secrets/` may hold secrets (CLAUDE.md §7, §11.C); `progress.json`
8
+ * and `install.json` are plain bookkeeping that tooling, backups, sync
9
+ * clients and agents read. Installs from before #576 checkpointed the full
10
+ * Pulumi outputs, whose database URLs carried admin_role's and
11
+ * public_role's passwords, and every install so far checkpointed the Owner
12
+ * setup link with its token. This module
13
+ *
14
+ * - finds secret material in a JSON value ({@link findSecrets}), which
15
+ * `markStepDone` / `writeMetadata` use to refuse writing it;
16
+ * - removes it from an existing checkpoint ({@link scrubProgressFile}),
17
+ * which `upgrade` and wizard re-runs call, and names the secrets to
18
+ * rotate ({@link rotationHints});
19
+ * - masks credentials in text the CLI prints ({@link redactCredentials}).
20
+ *
21
+ * Detection is by shape (a URL with a password, a URL with a token-like
22
+ * query parameter) and by key name (password, secret, token, ...), so a
23
+ * future secret output is caught without being listed here.
24
+ */
25
+
26
+ import { existsSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
27
+ import { selfCommand } from "./self-command.js";
28
+ import { DATABASE_ROLE_SECRET, type DatabaseRole } from "./stack-contract.js";
29
+
30
+ /** One place a JSON value holds secret material. Never carries the secret itself. */
31
+ export interface SecretFinding {
32
+ /** Dotted path to the offending value, e.g. `steps.pulumi-up-acme.outputs.adminDatabaseUrlOut`. */
33
+ readonly path: string;
34
+ /**
35
+ * `credential-url`: a URL whose user-info carries a password.
36
+ * `token-url`: a URL with a token / secret / password query parameter.
37
+ * `secret-key`: a key whose name marks its value as secret.
38
+ */
39
+ readonly kind: "credential-url" | "token-url" | "secret-key";
40
+ /** For a credential URL: the user whose password it carries. */
41
+ readonly user?: string;
42
+ }
43
+
44
+ /** `scheme://user:password@` — the user-info of a URL that carries a password. */
45
+ const CREDENTIAL_URL = /[a-z][a-z0-9+.-]{0,31}:\/\/([^\s:@/]*):([^\s@/]+)@/gi;
46
+
47
+ /** A URL query parameter that carries a credential (the Owner setup link's `?token=`). */
48
+ const TOKEN_PARAM =
49
+ /[?&](?:token|secret|password|passwd|pwd|api[_-]?key|access[_-]?key|sig|signature)=[^&\s#]+/i;
50
+
51
+ /**
52
+ * Key names whose value is a secret, after lower-casing, dropping non
53
+ * alphanumerics and a Pulumi `Out` suffix: `postgresPassword`,
54
+ * `caeloSecretKek`, `bootstrap_token`, `anthropicApiKey`.
55
+ */
56
+ const SECRET_KEY = /(password|passwd|passphrase|secret|secretkek|kek|token|apikey|privatekey)$/;
57
+
58
+ function isSecretKey(key: string): boolean {
59
+ const k = key
60
+ .toLowerCase()
61
+ .replace(/[^a-z0-9]/g, "")
62
+ .replace(/out$/, "");
63
+ return SECRET_KEY.test(k);
64
+ }
65
+
66
+ function urlUser(raw: string): string {
67
+ try {
68
+ return decodeURIComponent(raw);
69
+ } catch {
70
+ return raw;
71
+ }
72
+ }
73
+
74
+ function stringFindings(value: string, path: string): SecretFinding[] {
75
+ const found: SecretFinding[] = [];
76
+ for (const m of value.matchAll(CREDENTIAL_URL)) {
77
+ found.push({ path, kind: "credential-url", user: urlUser(m[1] ?? "") });
78
+ }
79
+ if (found.length === 0 && /:\/\//.test(value) && TOKEN_PARAM.test(value)) {
80
+ found.push({ path, kind: "token-url" });
81
+ }
82
+ return found;
83
+ }
84
+
85
+ /**
86
+ * Every place `value` holds secret material.
87
+ *
88
+ * @param value any JSON-shaped value (a checkpoint payload, install.json)
89
+ * @param path the path prefix to report findings under
90
+ */
91
+ export function findSecrets(value: unknown, path = ""): SecretFinding[] {
92
+ if (typeof value === "string") return stringFindings(value, path || "(value)");
93
+ const join = (k: string | number) => (path ? `${path}.${k}` : String(k));
94
+ if (Array.isArray(value)) return value.flatMap((v, i) => findSecrets(v, join(i)));
95
+ if (value !== null && typeof value === "object") {
96
+ return Object.entries(value).flatMap(([k, v]) => {
97
+ // A secret-named key is only a finding when it holds something; a
98
+ // null / false / 0 marker is not secret material.
99
+ if (isSecretKey(k) && v !== null && v !== undefined && v !== "" && typeof v !== "boolean") {
100
+ return [{ path: join(k), kind: "secret-key" as const }];
101
+ }
102
+ return findSecrets(v, join(k));
103
+ });
104
+ }
105
+ return [];
106
+ }
107
+
108
+ /**
109
+ * Throw when `value` holds secret material. The message names the paths,
110
+ * never the values, so the error itself cannot leak them.
111
+ *
112
+ * @param what what was about to be written, for the message
113
+ */
114
+ export function assertNoSecrets(value: unknown, what: string): void {
115
+ refuseFindings(findSecrets(value), what);
116
+ }
117
+
118
+ /** {@link assertNoSecrets} for a whole checkpoint (see {@link findCheckpointSecrets}). */
119
+ export function assertNoCheckpointSecrets(checkpoint: unknown, what: string): void {
120
+ refuseFindings(findCheckpointSecrets(checkpoint), what);
121
+ }
122
+
123
+ function refuseFindings(found: readonly SecretFinding[], what: string): void {
124
+ if (found.length === 0) return;
125
+ throw new Error(
126
+ `Refusing to write secret material to ${what} (${found.map((f) => `${f.path}: ${f.kind}`).join(", ")}). ` +
127
+ "Secrets belong in Secret Manager or the install's secrets/ directory; read them at the moment of use instead (#656).",
128
+ );
129
+ }
130
+
131
+ function isRecord(v: unknown): v is Record<string, unknown> {
132
+ return v !== null && typeof v === "object" && !Array.isArray(v);
133
+ }
134
+
135
+ /**
136
+ * {@link findSecrets} for a progress.json checkpoint. Its step names are
137
+ * derived from operator input (`project-<project id>`), so they are
138
+ * identifiers, not field names: a project called `acme-token` must not read
139
+ * as a secret. Only the step payloads (and the other top-level fields) are
140
+ * inspected.
141
+ */
142
+ export function findCheckpointSecrets(checkpoint: unknown): SecretFinding[] {
143
+ if (!isRecord(checkpoint)) return findSecrets(checkpoint);
144
+ const { steps, ...rest } = checkpoint;
145
+ const inSteps = isRecord(steps)
146
+ ? Object.entries(steps).flatMap(([name, payload]) => findSecrets(payload, `steps.${name}`))
147
+ : findSecrets(steps, "steps");
148
+ return [...findSecrets(rest), ...inSteps];
149
+ }
150
+
151
+ /** {@link withoutSecrets} for a checkpoint: every step is kept, its payload cleaned. */
152
+ function checkpointWithoutSecrets(checkpoint: unknown): unknown {
153
+ if (!isRecord(checkpoint)) return withoutSecrets(checkpoint);
154
+ const { steps, ...rest } = checkpoint;
155
+ return {
156
+ ...(withoutSecrets(rest) as Record<string, unknown>),
157
+ steps: isRecord(steps)
158
+ ? Object.fromEntries(Object.entries(steps).map(([n, p]) => [n, withoutSecrets(p)]))
159
+ : withoutSecrets(steps),
160
+ };
161
+ }
162
+
163
+ /**
164
+ * `value` without its secret material: secret-named keys and strings that
165
+ * hold a credential or token URL are dropped. Everything else is kept.
166
+ */
167
+ export function withoutSecrets(value: unknown): unknown {
168
+ if (typeof value === "string") return value;
169
+ if (Array.isArray(value)) {
170
+ return value
171
+ .filter((v) => typeof v !== "string" || findSecrets(v).length === 0)
172
+ .map(withoutSecrets);
173
+ }
174
+ if (value !== null && typeof value === "object") {
175
+ const out: Record<string, unknown> = {};
176
+ for (const [k, v] of Object.entries(value)) {
177
+ if (findSecrets({ [k]: v }).length > 0 && (isSecretKey(k) || typeof v === "string")) continue;
178
+ out[k] = withoutSecrets(v);
179
+ }
180
+ return out;
181
+ }
182
+ return value;
183
+ }
184
+
185
+ /**
186
+ * The Pulumi outputs worth checkpointing: everything except outputs Pulumi
187
+ * marks secret and anything else that holds secret material (the Owner
188
+ * setup link). Nothing reads secrets back from the checkpoint; code that
189
+ * needs one reads the stack (`readStackOutput`) when it needs it.
190
+ *
191
+ * @param outputs the stack outputs as the Automation API returns them
192
+ */
193
+ export function checkpointableOutputs(
194
+ outputs: Readonly<Record<string, { value: unknown; secret?: boolean }>>,
195
+ ): Record<string, unknown> {
196
+ const kept: Record<string, unknown> = {};
197
+ for (const [k, o] of Object.entries(outputs)) {
198
+ if (o.secret) continue;
199
+ if (findSecrets({ [k]: o.value }).length > 0) continue;
200
+ kept[k] = o.value;
201
+ }
202
+ return kept;
203
+ }
204
+
205
+ /** Outcome of {@link scrubProgressFile}. */
206
+ export interface ScrubReport {
207
+ /** What was removed (paths + kinds, never values). Empty when the file was clean or absent. */
208
+ readonly removed: readonly SecretFinding[];
209
+ }
210
+
211
+ /**
212
+ * Remove secret material from a `progress.json` in place. The rewrite is
213
+ * atomic (temp file in the same directory, then rename) and the result is
214
+ * mode 0600, so an interrupted scrub never leaves a truncated checkpoint
215
+ * and wizard re-runs still find every completed step.
216
+ *
217
+ * @param path the checkpoint file; a missing file is a no-op
218
+ */
219
+ export function scrubProgressFile(path: string): ScrubReport {
220
+ if (!existsSync(path)) return { removed: [] };
221
+ const checkpoint: unknown = JSON.parse(readFileSync(path, "utf8"));
222
+ const removed = findCheckpointSecrets(checkpoint);
223
+ if (removed.length === 0) return { removed };
224
+ writeJsonAtomic(path, checkpointWithoutSecrets(checkpoint));
225
+ return { removed };
226
+ }
227
+
228
+ /**
229
+ * Write `value` as JSON to `path` atomically with mode 0600: a temp file in
230
+ * the same directory (so the rename stays on one filesystem), then rename.
231
+ */
232
+ export function writeJsonAtomic(path: string, value: unknown): void {
233
+ const tmp = `${path}.tmp-${process.pid}`;
234
+ try {
235
+ writeFileSync(tmp, `${JSON.stringify(value, null, 2)}\n`, { mode: 0o600 });
236
+ renameSync(tmp, path);
237
+ } catch (e) {
238
+ rmSync(tmp, { force: true });
239
+ throw e;
240
+ }
241
+ }
242
+
243
+ const ROLE_BY_USER = DATABASE_ROLE_SECRET as Readonly<Record<string, string | undefined>>;
244
+
245
+ /**
246
+ * What the operator should do about secrets a scrub removed: one line per
247
+ * affected secret, with the exact command where one exists.
248
+ *
249
+ * @param removed the scrub's findings
250
+ * @param installId the install, named in each command so it acts on the right one
251
+ */
252
+ export function rotationHints(removed: readonly SecretFinding[], installId: string): string[] {
253
+ const rotate = (secret: string) => selfCommand("rotate-secret", secret, "--install", installId);
254
+ const hints = new Set<string>();
255
+ for (const f of removed) {
256
+ if (f.kind === "credential-url") {
257
+ const secret = f.user ? ROLE_BY_USER[f.user as DatabaseRole] : undefined;
258
+ hints.add(
259
+ secret
260
+ ? `${f.user}'s password was stored in progress.json. Rotate it: ${rotate(secret)}`
261
+ : `A password for database user "${f.user ?? "?"}" was stored in progress.json (${f.path}); change it.`,
262
+ );
263
+ } else if (f.kind === "token-url") {
264
+ hints.add(
265
+ "The Owner setup link (with its one-time token) was stored in progress.json. It expires 24 hours after it was issued and stops working once the Owner account exists; nothing to rotate.",
266
+ );
267
+ } else {
268
+ hints.add(
269
+ `A secret value was stored in progress.json (${f.path}). Rotate whatever it belongs to.`,
270
+ );
271
+ }
272
+ }
273
+ return [...hints];
274
+ }
275
+
276
+ /**
277
+ * `text` with every URL password masked (`postgresql://admin_role:***@`),
278
+ * for anything the CLI prints that could carry one (errors from Pulumi or
279
+ * gcloud, for instance). Same masking as the API gateway's error log.
280
+ */
281
+ export function redactCredentials(text: string): string {
282
+ return text.replace(/([a-z][a-z0-9+.-]{0,31}:\/\/[^\s:@/]*):[^\s@/]+@/gi, "$1:***@");
283
+ }