@caelo-cms/provisioning 0.10.47 → 0.10.48

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 (315) hide show
  1. package/dist/agent/apply.d.ts +45 -0
  2. package/dist/agent/apply.d.ts.map +1 -0
  3. package/dist/agent/apply.js +83 -0
  4. package/dist/agent/apply.js.map +1 -0
  5. package/dist/agent/cli-provenance.d.ts +42 -0
  6. package/dist/agent/cli-provenance.d.ts.map +1 -0
  7. package/dist/agent/cli-provenance.js +138 -0
  8. package/dist/agent/cli-provenance.js.map +1 -0
  9. package/dist/agent/command.d.ts +79 -0
  10. package/dist/agent/command.d.ts.map +1 -0
  11. package/dist/agent/command.js +454 -0
  12. package/dist/agent/command.js.map +1 -0
  13. package/dist/agent/config.d.ts +30 -0
  14. package/dist/agent/config.d.ts.map +1 -0
  15. package/dist/agent/config.js +60 -0
  16. package/dist/agent/config.js.map +1 -0
  17. package/dist/agent/lock.d.ts +13 -0
  18. package/dist/agent/lock.d.ts.map +1 -0
  19. package/dist/agent/lock.js +100 -0
  20. package/dist/agent/lock.js.map +1 -0
  21. package/dist/agent/queue.d.ts +35 -0
  22. package/dist/agent/queue.d.ts.map +1 -0
  23. package/dist/agent/queue.js +50 -0
  24. package/dist/agent/queue.js.map +1 -0
  25. package/dist/agent/run.d.ts +97 -0
  26. package/dist/agent/run.d.ts.map +1 -0
  27. package/dist/agent/run.js +198 -0
  28. package/dist/agent/run.js.map +1 -0
  29. package/dist/agent/systemd.d.ts +55 -0
  30. package/dist/agent/systemd.d.ts.map +1 -0
  31. package/dist/agent/systemd.js +171 -0
  32. package/dist/agent/systemd.js.map +1 -0
  33. package/dist/agent/units.d.ts +44 -0
  34. package/dist/agent/units.d.ts.map +1 -0
  35. package/dist/agent/units.js +127 -0
  36. package/dist/agent/units.js.map +1 -0
  37. package/dist/agent-db.js +18362 -0
  38. package/dist/cli.js +119 -16
  39. package/dist/cli.js.map +1 -1
  40. package/dist/cloudsql-iam-user.d.ts +39 -0
  41. package/dist/cloudsql-iam-user.d.ts.map +1 -0
  42. package/dist/cloudsql-iam-user.js +119 -0
  43. package/dist/cloudsql-iam-user.js.map +1 -0
  44. package/dist/compose.d.ts +16 -0
  45. package/dist/compose.d.ts.map +1 -1
  46. package/dist/compose.js +26 -1
  47. package/dist/compose.js.map +1 -1
  48. package/dist/dns/index.d.ts +2 -0
  49. package/dist/dns/index.d.ts.map +1 -1
  50. package/dist/dns/index.js +3 -2
  51. package/dist/dns/index.js.map +1 -1
  52. package/dist/dns/manual.d.ts +5 -0
  53. package/dist/dns/manual.d.ts.map +1 -1
  54. package/dist/dns/manual.js +28 -12
  55. package/dist/dns/manual.js.map +1 -1
  56. package/dist/gcloud.d.ts +2 -0
  57. package/dist/gcloud.d.ts.map +1 -1
  58. package/dist/gcloud.js +1 -1
  59. package/dist/gcloud.js.map +1 -1
  60. package/dist/gcp-names.d.ts +15 -0
  61. package/dist/gcp-names.d.ts.map +1 -1
  62. package/dist/gcp-names.js +23 -0
  63. package/dist/gcp-names.js.map +1 -1
  64. package/dist/index.d.ts +6 -0
  65. package/dist/index.d.ts.map +1 -1
  66. package/dist/index.js +4 -0
  67. package/dist/index.js.map +1 -1
  68. package/dist/infra-contract.d.ts +1 -1
  69. package/dist/infra-contract.js +7 -1
  70. package/dist/infra-contract.js.map +1 -1
  71. package/dist/install-job-runner.d.ts +13 -0
  72. package/dist/install-job-runner.d.ts.map +1 -0
  73. package/dist/install-job-runner.js +155 -0
  74. package/dist/install-job-runner.js.map +1 -0
  75. package/dist/install-job.d.ts +210 -0
  76. package/dist/install-job.d.ts.map +1 -0
  77. package/dist/install-job.js +449 -0
  78. package/dist/install-job.js.map +1 -0
  79. package/dist/install-state.d.ts +10 -1
  80. package/dist/install-state.d.ts.map +1 -1
  81. package/dist/install-state.js +10 -2
  82. package/dist/install-state.js.map +1 -1
  83. package/dist/lifecycle.d.ts.map +1 -1
  84. package/dist/lifecycle.js +19 -4
  85. package/dist/lifecycle.js.map +1 -1
  86. package/dist/migration-runner.d.ts +5 -0
  87. package/dist/migration-runner.d.ts.map +1 -1
  88. package/dist/migration-runner.js +1 -1
  89. package/dist/migration-runner.js.map +1 -1
  90. package/dist/operator-access.d.ts +14 -8
  91. package/dist/operator-access.d.ts.map +1 -1
  92. package/dist/operator-access.js +16 -102
  93. package/dist/operator-access.js.map +1 -1
  94. package/dist/run-mode.d.ts +27 -0
  95. package/dist/run-mode.d.ts.map +1 -0
  96. package/dist/run-mode.js +22 -0
  97. package/dist/run-mode.js.map +1 -0
  98. package/dist/runtime-secrets.d.ts +19 -0
  99. package/dist/runtime-secrets.d.ts.map +1 -1
  100. package/dist/runtime-secrets.js +44 -0
  101. package/dist/runtime-secrets.js.map +1 -1
  102. package/dist/self-hosted-roles.d.ts +22 -1
  103. package/dist/self-hosted-roles.d.ts.map +1 -1
  104. package/dist/self-hosted-roles.js +81 -12
  105. package/dist/self-hosted-roles.js.map +1 -1
  106. package/dist/self-hosted-upgrade.d.ts +17 -0
  107. package/dist/self-hosted-upgrade.d.ts.map +1 -1
  108. package/dist/self-hosted-upgrade.js +16 -1
  109. package/dist/self-hosted-upgrade.js.map +1 -1
  110. package/dist/stack-contract.d.ts +6 -0
  111. package/dist/stack-contract.d.ts.map +1 -1
  112. package/dist/stack-contract.js +10 -1
  113. package/dist/stack-contract.js.map +1 -1
  114. package/dist/upgrade/converge.d.ts +6 -0
  115. package/dist/upgrade/converge.d.ts.map +1 -1
  116. package/dist/upgrade/converge.js +59 -2
  117. package/dist/upgrade/converge.js.map +1 -1
  118. package/dist/upgrade/gcp-lookup.d.ts +6 -0
  119. package/dist/upgrade/gcp-lookup.d.ts.map +1 -1
  120. package/dist/upgrade/gcp-lookup.js +25 -0
  121. package/dist/upgrade/gcp-lookup.js.map +1 -1
  122. package/dist/upgrade/release-images.d.ts +12 -0
  123. package/dist/upgrade/release-images.d.ts.map +1 -1
  124. package/dist/upgrade/release-images.js +31 -0
  125. package/dist/upgrade/release-images.js.map +1 -1
  126. package/dist/upgrade/upgrade.d.ts +5 -0
  127. package/dist/upgrade/upgrade.d.ts.map +1 -1
  128. package/dist/upgrade/upgrade.js +28 -1
  129. package/dist/upgrade/upgrade.js.map +1 -1
  130. package/dist/upgrader-assess.d.ts +82 -0
  131. package/dist/upgrader-assess.d.ts.map +1 -0
  132. package/dist/upgrader-assess.js +116 -0
  133. package/dist/upgrader-assess.js.map +1 -0
  134. package/dist/upgrader-gcp.d.ts +17 -0
  135. package/dist/upgrader-gcp.d.ts.map +1 -0
  136. package/dist/upgrader-gcp.js +87 -0
  137. package/dist/upgrader-gcp.js.map +1 -0
  138. package/dist/upgrader-job.d.ts +131 -0
  139. package/dist/upgrader-job.d.ts.map +1 -0
  140. package/dist/upgrader-job.js +259 -0
  141. package/dist/upgrader-job.js.map +1 -0
  142. package/dist/upgrader-reporter.d.ts +35 -0
  143. package/dist/upgrader-reporter.d.ts.map +1 -0
  144. package/dist/upgrader-reporter.js +79 -0
  145. package/dist/upgrader-reporter.js.map +1 -0
  146. package/dist/upgrader-setup.d.ts +59 -0
  147. package/dist/upgrader-setup.d.ts.map +1 -0
  148. package/dist/upgrader-setup.js +308 -0
  149. package/dist/upgrader-setup.js.map +1 -0
  150. package/dist/wizard.d.ts +6 -0
  151. package/dist/wizard.d.ts.map +1 -1
  152. package/dist/wizard.js +32 -4
  153. package/dist/wizard.js.map +1 -1
  154. package/dist/wizards/gcp-firebase.d.ts +3 -0
  155. package/dist/wizards/gcp-firebase.d.ts.map +1 -1
  156. package/dist/wizards/gcp-firebase.js +1 -0
  157. package/dist/wizards/gcp-firebase.js.map +1 -1
  158. package/dist/wizards/gcp-install-job.d.ts +74 -0
  159. package/dist/wizards/gcp-install-job.d.ts.map +1 -0
  160. package/dist/wizards/gcp-install-job.js +398 -0
  161. package/dist/wizards/gcp-install-job.js.map +1 -0
  162. package/dist/wizards/gcp-prompts.d.ts +3 -0
  163. package/dist/wizards/gcp-prompts.d.ts.map +1 -0
  164. package/dist/wizards/gcp-prompts.js +25 -0
  165. package/dist/wizards/gcp-prompts.js.map +1 -0
  166. package/dist/wizards/gcp-pulumi.d.ts +4 -2
  167. package/dist/wizards/gcp-pulumi.d.ts.map +1 -1
  168. package/dist/wizards/gcp-pulumi.js +1 -1
  169. package/dist/wizards/gcp-pulumi.js.map +1 -1
  170. package/dist/wizards/gcp.d.ts +40 -0
  171. package/dist/wizards/gcp.d.ts.map +1 -1
  172. package/dist/wizards/gcp.js +223 -59
  173. package/dist/wizards/gcp.js.map +1 -1
  174. package/package.json +6 -4
  175. package/src/adapter.ts +97 -0
  176. package/src/adc-impersonation.ts +176 -0
  177. package/src/admin-domain.test.ts +254 -0
  178. package/src/admin-domain.ts +245 -0
  179. package/src/admin-memory.test.ts +151 -0
  180. package/src/agent/apply.test.ts +185 -0
  181. package/src/agent/apply.ts +124 -0
  182. package/src/agent/cli-provenance.test.ts +159 -0
  183. package/src/agent/cli-provenance.ts +191 -0
  184. package/src/agent/command.test.ts +77 -0
  185. package/src/agent/command.ts +564 -0
  186. package/src/agent/config.ts +86 -0
  187. package/src/agent/lock-config.test.ts +97 -0
  188. package/src/agent/lock.ts +110 -0
  189. package/src/agent/queue.test.ts +10 -0
  190. package/src/agent/queue.ts +91 -0
  191. package/src/agent/run.test.ts +382 -0
  192. package/src/agent/run.ts +295 -0
  193. package/src/agent/systemd.ts +209 -0
  194. package/src/agent/units.test.ts +223 -0
  195. package/src/agent/units.ts +163 -0
  196. package/src/azure-front-door.test.ts +119 -0
  197. package/src/azure-front-door.ts +56 -0
  198. package/src/bootstrap-token.ts +20 -0
  199. package/src/caddy.ts +112 -0
  200. package/src/cdn-copy.ts +91 -0
  201. package/src/cert-wait.test.ts +194 -0
  202. package/src/cert-wait.ts +163 -0
  203. package/src/checkpoint-secrets.test.ts +380 -0
  204. package/src/checkpoint-secrets.ts +283 -0
  205. package/src/cli-routing.test.ts +45 -0
  206. package/src/cli-routing.ts +43 -0
  207. package/src/cli.ts +1491 -0
  208. package/src/cloudsql-iam-user.ts +147 -0
  209. package/src/compose.ts +334 -0
  210. package/src/default-firewall.test.ts +411 -0
  211. package/src/default-firewall.ts +342 -0
  212. package/src/deployed-release.test.ts +231 -0
  213. package/src/deployed-release.ts +220 -0
  214. package/src/dns/cloudflare.ts +182 -0
  215. package/src/dns/groups.test.ts +76 -0
  216. package/src/dns/groups.ts +86 -0
  217. package/src/dns/index.ts +56 -0
  218. package/src/dns/manual.ts +130 -0
  219. package/src/dns/types.ts +27 -0
  220. package/src/gateway-credentials.test.ts +180 -0
  221. package/src/gateway-credentials.ts +144 -0
  222. package/src/gcloud-components.test.ts +76 -0
  223. package/src/gcloud-components.ts +85 -0
  224. package/src/gcloud-retry.ts +41 -0
  225. package/src/gcloud-runner.test.ts +61 -0
  226. package/src/gcloud.test.ts +99 -0
  227. package/src/gcloud.ts +322 -0
  228. package/src/gcp-names.ts +135 -0
  229. package/src/index.test.ts +332 -0
  230. package/src/index.ts +114 -0
  231. package/src/infra-contract.ts +32 -0
  232. package/src/install-job-runner.ts +166 -0
  233. package/src/install-job.test.ts +566 -0
  234. package/src/install-job.ts +542 -0
  235. package/src/install-state.test.ts +239 -0
  236. package/src/install-state.ts +583 -0
  237. package/src/internal-token.ts +35 -0
  238. package/src/lifecycle.ts +900 -0
  239. package/src/mcp-iap-stacks.test.ts +58 -0
  240. package/src/mcp-iap.test.ts +149 -0
  241. package/src/mcp-iap.ts +131 -0
  242. package/src/media-volume.test.ts +296 -0
  243. package/src/migration-runner.ts +365 -0
  244. package/src/operator-access-stacks.test.ts +55 -0
  245. package/src/operator-access.test.ts +332 -0
  246. package/src/operator-access.ts +554 -0
  247. package/src/ops-backend.test.ts +320 -0
  248. package/src/ops-backend.ts +414 -0
  249. package/src/provisioner-identity.test.ts +482 -0
  250. package/src/provisioner-identity.ts +407 -0
  251. package/src/redirects-emit.ts +175 -0
  252. package/src/region-data.ts +428 -0
  253. package/src/regions.test.ts +277 -0
  254. package/src/regions.ts +370 -0
  255. package/src/release-contract.ts +57 -0
  256. package/src/release-manifest.test.ts +293 -0
  257. package/src/release-manifest.ts +430 -0
  258. package/src/run-mode.ts +40 -0
  259. package/src/runtime-secrets.test.ts +627 -0
  260. package/src/runtime-secrets.ts +754 -0
  261. package/src/self-command.test.ts +14 -0
  262. package/src/self-command.ts +21 -0
  263. package/src/self-hosted-compose.test.ts +177 -0
  264. package/src/self-hosted-release.ts +240 -0
  265. package/src/self-hosted-roles.integration.test.ts +161 -0
  266. package/src/self-hosted-roles.test.ts +124 -0
  267. package/src/self-hosted-roles.ts +203 -0
  268. package/src/self-hosted-upgrade.test.ts +394 -0
  269. package/src/self-hosted-upgrade.ts +548 -0
  270. package/src/stack-contract-parity.test.ts +199 -0
  271. package/src/stack-contract.test.ts +391 -0
  272. package/src/stack-contract.ts +888 -0
  273. package/src/stack-converge-secrets.test.ts +421 -0
  274. package/src/stack-converge.test.ts +833 -0
  275. package/src/stack-converge.ts +909 -0
  276. package/src/staging-admin.test.ts +246 -0
  277. package/src/staging-admin.ts +324 -0
  278. package/src/state-attach.test.ts +211 -0
  279. package/src/state-attach.ts +132 -0
  280. package/src/state-backend.test.ts +145 -0
  281. package/src/state-backend.ts +262 -0
  282. package/src/state-command.ts +293 -0
  283. package/src/state-migrate.test.ts +479 -0
  284. package/src/state-migrate.ts +434 -0
  285. package/src/state-pulumi.ts +289 -0
  286. package/src/state-store.test.ts +212 -0
  287. package/src/state-store.ts +243 -0
  288. package/src/upgrade/app-roll.ts +486 -0
  289. package/src/upgrade/backup.ts +109 -0
  290. package/src/upgrade/converge.ts +437 -0
  291. package/src/upgrade/gcp-lookup.ts +167 -0
  292. package/src/upgrade/health.test.ts +279 -0
  293. package/src/upgrade/health.ts +305 -0
  294. package/src/upgrade/release-images.test.ts +119 -0
  295. package/src/upgrade/release-images.ts +391 -0
  296. package/src/upgrade/reporter.ts +46 -0
  297. package/src/upgrade/upgrade.test.ts +626 -0
  298. package/src/upgrade/upgrade.ts +249 -0
  299. package/src/upgrader-assess.test.ts +265 -0
  300. package/src/upgrader-assess.ts +233 -0
  301. package/src/upgrader-gcp.ts +123 -0
  302. package/src/upgrader-job.test.ts +408 -0
  303. package/src/upgrader-job.ts +499 -0
  304. package/src/upgrader-reporter.test.ts +46 -0
  305. package/src/upgrader-reporter.ts +105 -0
  306. package/src/upgrader-setup.test.ts +169 -0
  307. package/src/upgrader-setup.ts +355 -0
  308. package/src/wizard.ts +425 -0
  309. package/src/wizards/gcp-cost.ts +188 -0
  310. package/src/wizards/gcp-firebase.ts +72 -0
  311. package/src/wizards/gcp-install-job.ts +506 -0
  312. package/src/wizards/gcp-prompts.ts +26 -0
  313. package/src/wizards/gcp-pulumi.test.ts +246 -0
  314. package/src/wizards/gcp-pulumi.ts +516 -0
  315. package/src/wizards/gcp.ts +1935 -0
@@ -0,0 +1,246 @@
1
+ // SPDX-License-Identifier: MPL-2.0
2
+
3
+ /**
4
+ * #678 — `staging-domain enable` configures the admin itself: the request
5
+ * goes through IAP (a JWT signed as caelo-mcp) with the internal token in
6
+ * its own header, and the generated password is printed, never stored.
7
+ */
8
+
9
+ import { describe, expect, it } from "bun:test";
10
+ import type { GcloudResult } from "./gcloud.js";
11
+ import { mintInternalToken } from "./internal-token.js";
12
+ import {
13
+ applyStagingAdmin,
14
+ describeStagingApply,
15
+ domainEnableRetryCommand,
16
+ inspectStagingAdmin,
17
+ STAGING_PROVISION_SCOPE,
18
+ type StagingApply,
19
+ } from "./staging-admin.js";
20
+
21
+ const ok = (stdout = ""): GcloudResult => ({ ok: true, stdout, stderr: "", exitCode: 0 });
22
+ const fail = (stderr: string): GcloudResult => ({ ok: false, stdout: "", stderr, exitCode: 1 });
23
+ const SECRET = "s".repeat(40);
24
+ const ADMIN = "https://caelo-production-admin-abc-ew.a.run.app";
25
+ const target = { projectId: "proj", region: "europe-west1" };
26
+
27
+ function fakeGcloud(overrides: Record<string, GcloudResult> = {}) {
28
+ const answers: Record<string, GcloudResult> = {
29
+ "run services list": ok("caelo-production-admin-abc\n"),
30
+ "run services describe": ok(`${ADMIN}\n`),
31
+ "secrets versions access": ok(SECRET),
32
+ "auth print-access-token": ok("ya29.operator\n"),
33
+ "services enable iamcredentials.googleapis.com": ok(),
34
+ ...overrides,
35
+ };
36
+ const calls: string[][] = [];
37
+ const run = async (args: string[]) => {
38
+ calls.push(args);
39
+ const key = Object.keys(answers).find((k) => args.join(" ").startsWith(k));
40
+ return (key && answers[key]) || fail(`unexpected gcloud ${args.join(" ")}`);
41
+ };
42
+ return { run, calls };
43
+ }
44
+
45
+ interface Seen {
46
+ url: string;
47
+ headers: Record<string, string>;
48
+ body: unknown;
49
+ }
50
+
51
+ function fakeFetch(admin: (seen: Seen) => Response) {
52
+ const seen: Seen[] = [];
53
+ const doFetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
54
+ const url = String(input);
55
+ const headers = Object.fromEntries(new Headers(init?.headers).entries());
56
+ const body = init?.body ? JSON.parse(String(init.body)) : null;
57
+ const s = { url, headers, body };
58
+ seen.push(s);
59
+ if (url.startsWith("https://iamcredentials.googleapis.com/")) {
60
+ return Response.json({ signedJwt: "iap.jwt.signed" });
61
+ }
62
+ return admin(s);
63
+ }) as typeof fetch;
64
+ return { doFetch, seen };
65
+ }
66
+
67
+ const APPLIED: StagingApply = {
68
+ host: "preview.searchviu.com",
69
+ previousHost: "staging.searchviu.com",
70
+ hostChanged: true,
71
+ stagingUrl: "https://preview.searchviu.com",
72
+ basicAuth: { status: "generated", enabled: true, user: "searchviu", password: "pw-123" },
73
+ stageNeeded: "nothing-staged",
74
+ stageNote:
75
+ "Nothing is served at https://preview.searchviu.com/ until the next Stage — Stage once.",
76
+ draftPages: { count: 2, pages: [{ title: "Pricing", path: "/pricing/" }] },
77
+ };
78
+
79
+ describe("applyStagingAdmin", () => {
80
+ it("passes IAP as caelo-mcp and carries the internal token in its own header", async () => {
81
+ const { run, calls } = fakeGcloud();
82
+ const { doFetch, seen } = fakeFetch(() => Response.json({ ok: true, ...APPLIED }));
83
+ const r = await applyStagingAdmin(
84
+ target,
85
+ { host: "preview.searchviu.com" },
86
+ { run, fetch: doFetch, now: () => 1_000 },
87
+ );
88
+ expect(r).toEqual({ ok: true, value: { ok: true, ...APPLIED } as StagingApply });
89
+
90
+ const sign = seen[0]!;
91
+ expect(sign.url).toContain("caelo-mcp%40proj.iam.gserviceaccount.com:signJwt");
92
+ expect(sign.headers.authorization).toBe("Bearer ya29.operator");
93
+ expect(JSON.parse((sign.body as { payload: string }).payload)).toEqual({
94
+ iss: "caelo-mcp@proj.iam.gserviceaccount.com",
95
+ sub: "caelo-mcp@proj.iam.gserviceaccount.com",
96
+ aud: `${ADMIN}/*`,
97
+ iat: 1_000,
98
+ exp: 1_600,
99
+ });
100
+
101
+ const call = seen[1]!;
102
+ expect(call.url).toBe(`${ADMIN}/api/internal/staging/provision`);
103
+ expect(call.headers.authorization).toBe("Bearer iap.jwt.signed");
104
+ expect(call.body).toEqual({ mode: "apply", host: "preview.searchviu.com" });
105
+ const [message] = (call.headers["x-caelo-internal-token"] ?? "").split(".");
106
+ expect(Buffer.from(message ?? "", "base64url").toString()).toEndWith(
107
+ `:${STAGING_PROVISION_SCOPE}`,
108
+ );
109
+ expect(calls).toContainEqual([
110
+ "services",
111
+ "enable",
112
+ "iamcredentials.googleapis.com",
113
+ "--project=proj",
114
+ ]);
115
+ expect(calls).toContainEqual([
116
+ "secrets",
117
+ "versions",
118
+ "access",
119
+ "latest",
120
+ "--secret=caelo-production-internal-secret",
121
+ "--project=proj",
122
+ ]);
123
+ });
124
+
125
+ it("names the fix when IAP refuses, the admin predates the endpoint, or refuses the input", async () => {
126
+ const iap = await applyStagingAdmin(
127
+ target,
128
+ {},
129
+ {
130
+ run: fakeGcloud().run,
131
+ fetch: fakeFetch(
132
+ () =>
133
+ new Response("Invalid IAP credentials", {
134
+ status: 401,
135
+ headers: { "x-goog-iap-generated-response": "true" },
136
+ }),
137
+ ).doFetch,
138
+ },
139
+ );
140
+ expect(iap.ok ? "" : iap.error).toContain("cms-provision upgrade");
141
+
142
+ const old = await applyStagingAdmin(
143
+ target,
144
+ {},
145
+ {
146
+ run: fakeGcloud().run,
147
+ fetch: fakeFetch(() => new Response("nf", { status: 404 })).doFetch,
148
+ },
149
+ );
150
+ expect(old.ok ? "" : old.error).toContain("before #678");
151
+
152
+ const refused = await applyStagingAdmin(
153
+ target,
154
+ { host: "example.com" },
155
+ {
156
+ run: fakeGcloud().run,
157
+ fetch: fakeFetch(() =>
158
+ Response.json({ ok: false, error: "that is the live host" }, { status: 422 }),
159
+ ).doFetch,
160
+ },
161
+ );
162
+ expect(refused.ok ? "" : refused.error).toContain("that is the live host");
163
+ });
164
+
165
+ it("stops before calling the admin when the internal secret cannot be read", async () => {
166
+ const { run } = fakeGcloud({ "secrets versions access": fail("PERMISSION_DENIED") });
167
+ const { doFetch, seen } = fakeFetch(() => Response.json({ ok: true }));
168
+ const r = await applyStagingAdmin(target, {}, { run, fetch: doFetch });
169
+ expect(r.ok ? "" : r.error).toContain("PERMISSION_DENIED");
170
+ expect(seen).toHaveLength(0);
171
+ });
172
+ });
173
+
174
+ describe("inspectStagingAdmin", () => {
175
+ it("reads the admin's stored host", async () => {
176
+ const { doFetch, seen } = fakeFetch(() =>
177
+ Response.json({ ok: true, stagingHost: "preview.searchviu.com" }),
178
+ );
179
+ const r = await inspectStagingAdmin(target, { run: fakeGcloud().run, fetch: doFetch });
180
+ expect(r.ok && r.value.stagingHost).toBe("preview.searchviu.com");
181
+ expect(seen[1]?.body).toEqual({ mode: "inspect" });
182
+ });
183
+ });
184
+
185
+ describe("describeStagingApply", () => {
186
+ it("prints a new credential once with how to change it, the stage note and the drafts", () => {
187
+ const text = describeStagingApply(APPLIED).join("\n");
188
+ expect(text).toContain("now uses preview.searchviu.com");
189
+ expect(text).toContain("user: searchviu");
190
+ expect(text).toContain("password: pw-123");
191
+ expect(text).toContain("Settings → Staging access");
192
+ expect(text).toContain("until the next Stage");
193
+ expect(text).toContain("2 page(s) are draft");
194
+ expect(text).toContain("Pricing (/pricing/)");
195
+ });
196
+
197
+ it("never prints a password it was not given", () => {
198
+ const text = describeStagingApply({
199
+ ...APPLIED,
200
+ hostChanged: false,
201
+ basicAuth: { status: "unchanged-enabled", enabled: true, user: "searchviu", password: null },
202
+ stageNeeded: null,
203
+ stageNote: null,
204
+ draftPages: { count: 0, pages: [] },
205
+ }).join("\n");
206
+ expect(text).toContain("--show-staging-password");
207
+ expect(text).not.toContain("password:");
208
+ expect(text).not.toContain("draft");
209
+ });
210
+ });
211
+
212
+ describe("mintInternalToken", () => {
213
+ it("signs <iat>:<exp>:<scope> with the 5-minute window the admin enforces", async () => {
214
+ const token = await mintInternalToken(SECRET, "staging.provision", 1_000);
215
+ const [message, sig] = token.split(".");
216
+ expect(Buffer.from(message ?? "", "base64url").toString()).toBe(
217
+ `1000:${1_000 + 300_000}:staging.provision`,
218
+ );
219
+ const key = await crypto.subtle.importKey(
220
+ "raw",
221
+ new TextEncoder().encode(SECRET),
222
+ { name: "HMAC", hash: "SHA-256" },
223
+ false,
224
+ ["verify"],
225
+ );
226
+ expect(
227
+ await crypto.subtle.verify(
228
+ "HMAC",
229
+ key,
230
+ Buffer.from(sig ?? "", "base64url"),
231
+ new TextEncoder().encode(`1000:301000:staging.provision`),
232
+ ),
233
+ ).toBe(true);
234
+ });
235
+ });
236
+
237
+ describe("domainEnableRetryCommand", () => {
238
+ it("keeps an explicitly requested host, so a retry after verification maps it", () => {
239
+ expect(domainEnableRetryCommand("staging-domain", "preview.example.org")).toBe(
240
+ "cms-provision staging-domain enable --staging-domain preview.example.org",
241
+ );
242
+ expect(domainEnableRetryCommand("admin-domain", null)).toBe(
243
+ "cms-provision admin-domain enable",
244
+ );
245
+ });
246
+ });
@@ -0,0 +1,324 @@
1
+ // SPDX-License-Identifier: MPL-2.0
2
+
3
+ /**
4
+ * #678 — `staging-domain enable` tells the admin itself what the operator
5
+ * decided, instead of printing "now set the same host in the admin".
6
+ *
7
+ * Why an HTTP call to the admin (and not a one-shot Cloud Run job like the
8
+ * migrations): the call must hand a result BACK — above all the generated
9
+ * basic-auth password, which the CLI prints once. A job can only report
10
+ * through Cloud Logging, which would put the password into the project's
11
+ * logs. The admin endpoint (`POST /api/internal/staging/provision`) answers
12
+ * over TLS straight to this process, and the password is never written
13
+ * anywhere (CLAUDE.md §7, #659).
14
+ *
15
+ * Getting there, as the operator's own gcloud identity — the same path the
16
+ * Power-MCP shim takes (packages/mcp-server/src/ingress-auth.ts):
17
+ *
18
+ * 1. the admin Cloud Run service and its URL;
19
+ * 2. the install's internal secret from Secret Manager (in memory only),
20
+ * which signs a 5-minute token for scope `staging.provision`;
21
+ * 3. a JWT signed as `caelo-mcp@<project>` through the IAM Credentials
22
+ * API — the admin's IAP lets that service account in, and every
23
+ * IAP-allowlisted operator may sign as it (`cms-provision upgrade`
24
+ * grants it) — so the request passes IAP first, then Caelo's token.
25
+ */
26
+
27
+ import { findService } from "./admin-domain.js";
28
+ import { gcloud as defaultGcloud, type GcloudResult } from "./gcloud.js";
29
+ import { GCP_STACK_ENV, gcpSecretId, mcpIapServiceAccountEmail } from "./gcp-names.js";
30
+ import { mintInternalToken } from "./internal-token.js";
31
+
32
+ type Run = (args: string[]) => Promise<GcloudResult>;
33
+
34
+ /** Token scope the admin endpoint checks (apps/admin …/api/internal/staging/provision). */
35
+ export const STAGING_PROVISION_SCOPE = "staging.provision";
36
+ const ENDPOINT = "/api/internal/staging/provision";
37
+ /** Signed JWTs live at most 1 hour; one call needs a few seconds. */
38
+ const IAP_JWT_LIFETIME_S = 600;
39
+
40
+ export interface StagingAdminTarget {
41
+ readonly projectId: string;
42
+ readonly region: string;
43
+ }
44
+
45
+ export interface StagingAdminDeps {
46
+ readonly run?: Run;
47
+ readonly fetch?: typeof fetch;
48
+ /** Seconds since epoch (IAP JWT); tests pin it. */
49
+ readonly now?: () => number;
50
+ }
51
+
52
+ /** Main's draft pages, as the admin reports them. */
53
+ export interface DraftPages {
54
+ readonly count: number;
55
+ readonly pages: readonly { title: string; path: string }[];
56
+ }
57
+
58
+ /** `{"mode":"inspect"}` — the admin's stored host and gate state. */
59
+ export interface StagingInspect {
60
+ readonly stagingHost: string | null;
61
+ readonly stageNeeded: "nothing-staged" | "host-changed" | null;
62
+ readonly stageNote: string | null;
63
+ readonly settings: { readonly basicAuthEnabled: boolean; readonly basicAuthGenerated: boolean };
64
+ }
65
+
66
+ /** `{"mode":"apply"}` — what `staging_access.provision` did. */
67
+ export interface StagingApply {
68
+ readonly host: string;
69
+ readonly previousHost: string | null;
70
+ readonly hostChanged: boolean;
71
+ readonly stagingUrl: string;
72
+ readonly basicAuth: {
73
+ readonly status: "generated" | "rotated" | "unchanged-enabled" | "unchanged-disabled";
74
+ readonly enabled: boolean;
75
+ readonly user: string;
76
+ readonly password: string | null;
77
+ };
78
+ readonly stageNeeded: "nothing-staged" | "host-changed" | null;
79
+ readonly stageNote: string | null;
80
+ readonly draftPages: DraftPages;
81
+ }
82
+
83
+ export type StagingAdminResult<T> = { ok: true; value: T } | { ok: false; error: string };
84
+
85
+ export interface ApplyRequest {
86
+ readonly host?: string;
87
+ readonly rotateBasicAuth?: boolean;
88
+ readonly revealBasicAuth?: boolean;
89
+ }
90
+
91
+ /** Read the admin's stored staging host (the truth `staging-domain enable` maps). */
92
+ export function inspectStagingAdmin(
93
+ target: StagingAdminTarget,
94
+ deps: StagingAdminDeps = {},
95
+ ): Promise<StagingAdminResult<StagingInspect>> {
96
+ return callStagingAdmin<StagingInspect>(target, { mode: "inspect" }, deps);
97
+ }
98
+
99
+ /** Set the host / ensure password protection (see the admin op `staging_access.provision`). */
100
+ export function applyStagingAdmin(
101
+ target: StagingAdminTarget,
102
+ request: ApplyRequest,
103
+ deps: StagingAdminDeps = {},
104
+ ): Promise<StagingAdminResult<StagingApply>> {
105
+ return callStagingAdmin<StagingApply>(target, { mode: "apply", ...request }, deps);
106
+ }
107
+
108
+ async function callStagingAdmin<T>(
109
+ target: StagingAdminTarget,
110
+ body: Record<string, unknown>,
111
+ deps: StagingAdminDeps,
112
+ ): Promise<StagingAdminResult<T>> {
113
+ const run = deps.run ?? defaultGcloud;
114
+ const doFetch = deps.fetch ?? fetch;
115
+ const scope = [`--region=${target.region}`, `--project=${target.projectId}`];
116
+
117
+ const found = await findService(run, scope, "admin");
118
+ if (!found.ok) return { ok: false, error: found.error };
119
+ const url = await run([
120
+ "run",
121
+ "services",
122
+ "describe",
123
+ found.service,
124
+ ...scope,
125
+ "--format=value(status.url)",
126
+ ]);
127
+ const adminUrl = url.stdout.trim();
128
+ if (!url.ok || !adminUrl) {
129
+ return { ok: false, error: `read the admin's URL: ${url.stderr.trim() || "empty"}` };
130
+ }
131
+
132
+ const secretId = gcpSecretId(GCP_STACK_ENV, "internal-secret");
133
+ const secret = await run([
134
+ "secrets",
135
+ "versions",
136
+ "access",
137
+ "latest",
138
+ `--secret=${secretId}`,
139
+ `--project=${target.projectId}`,
140
+ ]);
141
+ const internalSecret = secret.stdout.trim();
142
+ if (!secret.ok || internalSecret.length < 32) {
143
+ return {
144
+ ok: false,
145
+ error: `read the install's internal secret (${secretId}) from Secret Manager: ${secret.stderr.trim() || "missing or too short"}. Your gcloud account needs secretmanager.versions.access on the project.`,
146
+ };
147
+ }
148
+
149
+ // Installs from before #678 may not have the IAM Credentials API on;
150
+ // enabling an enabled API is a no-op (codex review on #681).
151
+ const api = await run([
152
+ "services",
153
+ "enable",
154
+ "iamcredentials.googleapis.com",
155
+ `--project=${target.projectId}`,
156
+ ]);
157
+ if (!api.ok) {
158
+ return {
159
+ ok: false,
160
+ error: `enable the IAM Credentials API (iamcredentials.googleapis.com), needed to pass the admin's IAP: ${api.stderr.trim()}`,
161
+ };
162
+ }
163
+ const iap = await signIapJwt(run, doFetch, target.projectId, adminUrl, deps.now);
164
+ if (!iap.ok) return iap;
165
+
166
+ const token = await mintInternalToken(internalSecret, STAGING_PROVISION_SCOPE);
167
+ let res: Response;
168
+ try {
169
+ res = await doFetch(`${adminUrl}${ENDPOINT}`, {
170
+ method: "POST",
171
+ headers: {
172
+ authorization: `Bearer ${iap.value}`,
173
+ "x-caelo-internal-token": token,
174
+ "content-type": "application/json",
175
+ },
176
+ body: JSON.stringify(body),
177
+ });
178
+ } catch (e) {
179
+ return { ok: false, error: `reach the admin at ${adminUrl}: ${String(e)}` };
180
+ }
181
+ const text = await res.text();
182
+ if (res.headers.get("x-goog-iap-generated-response") === "true") {
183
+ return {
184
+ ok: false,
185
+ error: `Google IAP refused the request (HTTP ${res.status}: ${text.trim().slice(0, 200)}) — ${mcpIapServiceAccountEmail(target.projectId)} is not allowed on the admin's IAP yet. Run \`cms-provision upgrade\` (it allowlists that service account), then run this command again.`,
186
+ };
187
+ }
188
+ if (res.status === 404) {
189
+ return {
190
+ ok: false,
191
+ error: `the admin has no ${ENDPOINT} yet (it runs a release from before #678). Run \`cms-provision upgrade\`, then run this command again.`,
192
+ };
193
+ }
194
+ let parsed: { ok?: boolean; error?: string } & Record<string, unknown>;
195
+ try {
196
+ parsed = JSON.parse(text) as typeof parsed;
197
+ } catch {
198
+ return {
199
+ ok: false,
200
+ error: `the admin answered HTTP ${res.status} with a non-JSON body: ${text.slice(0, 200)}`,
201
+ };
202
+ }
203
+ if (!res.ok || parsed.ok !== true) {
204
+ return {
205
+ ok: false,
206
+ error: `the admin refused (HTTP ${res.status}): ${parsed.error ?? text.slice(0, 300)}`,
207
+ };
208
+ }
209
+ return { ok: true, value: parsed as unknown as T };
210
+ }
211
+
212
+ /** A JWT signed as the install's MCP service account, for the admin's IAP. */
213
+ async function signIapJwt(
214
+ run: Run,
215
+ doFetch: typeof fetch,
216
+ projectId: string,
217
+ adminUrl: string,
218
+ now: (() => number) | undefined,
219
+ ): Promise<StagingAdminResult<string>> {
220
+ const access = await run(["auth", "print-access-token"]);
221
+ const accessToken = access.stdout.trim();
222
+ if (!access.ok || !accessToken) {
223
+ return {
224
+ ok: false,
225
+ error: `get an access token from gcloud: ${access.stderr.trim() || "empty"}. Run \`gcloud auth login\` with the account you provisioned with.`,
226
+ };
227
+ }
228
+ const serviceAccount = mcpIapServiceAccountEmail(projectId);
229
+ const iat = now ? now() : Math.floor(Date.now() / 1000);
230
+ const res = await doFetch(
231
+ `https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/${encodeURIComponent(serviceAccount)}:signJwt`,
232
+ {
233
+ method: "POST",
234
+ headers: { authorization: `Bearer ${accessToken}`, "content-type": "application/json" },
235
+ body: JSON.stringify({
236
+ payload: JSON.stringify({
237
+ iss: serviceAccount,
238
+ sub: serviceAccount,
239
+ aud: `${new URL(adminUrl).origin}/*`,
240
+ iat,
241
+ exp: iat + IAP_JWT_LIFETIME_S,
242
+ }),
243
+ }),
244
+ },
245
+ );
246
+ if (!res.ok) {
247
+ const detail = (await res.text().catch(() => "")).slice(0, 300);
248
+ return {
249
+ ok: false,
250
+ error: `sign an IAP token as ${serviceAccount} (HTTP ${res.status}): ${detail} — your account needs roles/iam.serviceAccountTokenCreator on it; \`cms-provision upgrade\` grants it to everyone the admin's IAP lets in.`,
251
+ };
252
+ }
253
+ const { signedJwt } = (await res.json()) as { signedJwt?: string };
254
+ if (!signedJwt) return { ok: false, error: "IAM signJwt answered without a signedJwt" };
255
+ return { ok: true, value: signedJwt };
256
+ }
257
+
258
+ /**
259
+ * The lines `staging-domain enable` prints for an apply result: host,
260
+ * credentials (once), what is served and the next step. Pure — the CLI
261
+ * prints them and never persists the password.
262
+ */
263
+ export function describeStagingApply(r: StagingApply): string[] {
264
+ const lines: string[] = [];
265
+ lines.push(
266
+ r.hostChanged
267
+ ? `The admin now uses ${r.host} as the staging host (was ${r.previousHost ?? "unset"}); every Stage renders it.`
268
+ : `The admin's staging host is ${r.host}.`,
269
+ );
270
+ const b = r.basicAuth;
271
+ if (b.password !== null && (b.status === "generated" || b.status === "rotated")) {
272
+ lines.push(
273
+ "",
274
+ b.status === "generated"
275
+ ? "Password protection is on (basic auth). Credentials — shown only now, not saved anywhere:"
276
+ : "New basic-auth password — shown only now, not saved anywhere (the old one no longer works):",
277
+ ` user: ${b.user}`,
278
+ ` password: ${b.password}`,
279
+ "Give them to reviewers / crawlers. Change the user, rotate the password or add other access",
280
+ "modes in the admin: Settings → Staging access (or rerun with --rotate-staging-password).",
281
+ );
282
+ } else if (b.password !== null) {
283
+ lines.push(
284
+ "",
285
+ `Basic auth is ${b.enabled ? "on" : "OFF (switched off in the admin)"}. Current credentials — not saved anywhere:`,
286
+ ` user: ${b.user}`,
287
+ ` password: ${b.password}`,
288
+ );
289
+ } else if (b.status === "unchanged-enabled") {
290
+ lines.push(
291
+ "",
292
+ `Password protection is on (basic auth, user ${b.user}). Show the password with --show-staging-password, or in the admin under Settings → Staging access.`,
293
+ );
294
+ } else {
295
+ lines.push(
296
+ "",
297
+ "Basic auth was switched off in the admin; it stays off (admins still get in through Open staging). Turn it on under Settings → Staging access.",
298
+ );
299
+ }
300
+ if (r.stageNote) lines.push("", r.stageNote);
301
+ if (r.draftPages.count > 0) {
302
+ const listed = r.draftPages.pages.map((p) => `${p.title} (${p.path})`).join(", ");
303
+ const more =
304
+ r.draftPages.count > r.draftPages.pages.length
305
+ ? ` and ${r.draftPages.count - r.draftPages.pages.length} more`
306
+ : "";
307
+ lines.push(
308
+ "",
309
+ `${r.draftPages.count} page(s) are draft and will NOT be on staging: ${listed}${more}.`,
310
+ "Set them to published (editor status toggle, or ask the AI), then Stage.",
311
+ );
312
+ }
313
+ return lines;
314
+ }
315
+
316
+ /**
317
+ * The command to re-run after `<kind>-domain enable` stopped before the
318
+ * admin was updated (domain verification, an admin error). An explicitly
319
+ * requested staging host is part of it: without it the retry would map the
320
+ * admin's previous host instead (codex review on #681).
321
+ */
322
+ export function domainEnableRetryCommand(command: string, requestedHost: string | null): string {
323
+ return `cms-provision ${command} enable${requestedHost ? ` --staging-domain ${requestedHost}` : ""}`;
324
+ }