@caelo-cms/provisioning 0.11.8 → 0.11.10

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 (261) hide show
  1. package/dist/admin-domain.d.ts +10 -1
  2. package/dist/admin-domain.d.ts.map +1 -1
  3. package/dist/admin-domain.js +13 -3
  4. package/dist/admin-domain.js.map +1 -1
  5. package/dist/adopt-existing.d.ts +6 -1
  6. package/dist/adopt-existing.d.ts.map +1 -1
  7. package/dist/adopt-existing.js +86 -0
  8. package/dist/adopt-existing.js.map +1 -1
  9. package/dist/agent/command.d.ts +20 -0
  10. package/dist/agent/command.d.ts.map +1 -1
  11. package/dist/agent/command.js +32 -9
  12. package/dist/agent/command.js.map +1 -1
  13. package/dist/agent/run.d.ts +2 -0
  14. package/dist/agent/run.d.ts.map +1 -1
  15. package/dist/agent/run.js +5 -1
  16. package/dist/agent/run.js.map +1 -1
  17. package/dist/agent-db.js +65 -3
  18. package/dist/aws-task-roles.d.ts +4 -2
  19. package/dist/aws-task-roles.d.ts.map +1 -1
  20. package/dist/aws-task-roles.js +4 -1
  21. package/dist/aws-task-roles.js.map +1 -1
  22. package/dist/caddy.d.ts +21 -4
  23. package/dist/caddy.d.ts.map +1 -1
  24. package/dist/caddy.js +27 -3
  25. package/dist/caddy.js.map +1 -1
  26. package/dist/cli.js +12 -3
  27. package/dist/cli.js.map +1 -1
  28. package/dist/cloudsql-tls.d.ts +63 -0
  29. package/dist/cloudsql-tls.d.ts.map +1 -0
  30. package/dist/cloudsql-tls.js +289 -0
  31. package/dist/cloudsql-tls.js.map +1 -0
  32. package/dist/compose.d.ts +29 -6
  33. package/dist/compose.d.ts.map +1 -1
  34. package/dist/compose.js +95 -23
  35. package/dist/compose.js.map +1 -1
  36. package/dist/converge-checkpoint.d.ts +1 -0
  37. package/dist/converge-checkpoint.d.ts.map +1 -1
  38. package/dist/converge-checkpoint.js +8 -1
  39. package/dist/converge-checkpoint.js.map +1 -1
  40. package/dist/converge-job-gcp.d.ts.map +1 -1
  41. package/dist/converge-job-gcp.js +2 -11
  42. package/dist/converge-job-gcp.js.map +1 -1
  43. package/dist/deployed-release.d.ts +4 -1
  44. package/dist/deployed-release.d.ts.map +1 -1
  45. package/dist/deployed-release.js +18 -8
  46. package/dist/deployed-release.js.map +1 -1
  47. package/dist/gcp-names.d.ts +21 -0
  48. package/dist/gcp-names.d.ts.map +1 -1
  49. package/dist/gcp-names.js +24 -0
  50. package/dist/gcp-names.js.map +1 -1
  51. package/dist/gcp-url-map.d.ts.map +1 -1
  52. package/dist/gcp-url-map.js +6 -2
  53. package/dist/gcp-url-map.js.map +1 -1
  54. package/dist/index.d.ts +1 -1
  55. package/dist/index.d.ts.map +1 -1
  56. package/dist/index.js +1 -1
  57. package/dist/index.js.map +1 -1
  58. package/dist/infra-contract.d.ts +1 -1
  59. package/dist/infra-contract.d.ts.map +1 -1
  60. package/dist/infra-contract.js +24 -1
  61. package/dist/infra-contract.js.map +1 -1
  62. package/dist/install-state.d.ts +6 -0
  63. package/dist/install-state.d.ts.map +1 -1
  64. package/dist/install-state.js +17 -4
  65. package/dist/install-state.js.map +1 -1
  66. package/dist/lifecycle.d.ts.map +1 -1
  67. package/dist/lifecycle.js +17 -8
  68. package/dist/lifecycle.js.map +1 -1
  69. package/dist/mcp-auth-proxy-setup.d.ts +48 -0
  70. package/dist/mcp-auth-proxy-setup.d.ts.map +1 -0
  71. package/dist/mcp-auth-proxy-setup.js +280 -0
  72. package/dist/mcp-auth-proxy-setup.js.map +1 -0
  73. package/dist/release-manifest.d.ts +25 -1
  74. package/dist/release-manifest.d.ts.map +1 -1
  75. package/dist/release-manifest.js +36 -3
  76. package/dist/release-manifest.js.map +1 -1
  77. package/dist/remote-mcp.d.ts +55 -18
  78. package/dist/remote-mcp.d.ts.map +1 -1
  79. package/dist/remote-mcp.js +307 -77
  80. package/dist/remote-mcp.js.map +1 -1
  81. package/dist/runtime-secrets.d.ts +9 -0
  82. package/dist/runtime-secrets.d.ts.map +1 -1
  83. package/dist/runtime-secrets.js +21 -10
  84. package/dist/runtime-secrets.js.map +1 -1
  85. package/dist/seal-key-rotation.d.ts +7 -7
  86. package/dist/seal-key-rotation.d.ts.map +1 -1
  87. package/dist/seal-key-rotation.js +22 -22
  88. package/dist/seal-key-rotation.js.map +1 -1
  89. package/dist/self-hosted-db-tls.d.ts +37 -0
  90. package/dist/self-hosted-db-tls.d.ts.map +1 -0
  91. package/dist/self-hosted-db-tls.js +220 -0
  92. package/dist/self-hosted-db-tls.js.map +1 -0
  93. package/dist/self-hosted-release.d.ts +3 -1
  94. package/dist/self-hosted-release.d.ts.map +1 -1
  95. package/dist/self-hosted-release.js +33 -9
  96. package/dist/self-hosted-release.js.map +1 -1
  97. package/dist/self-hosted-roles.d.ts +34 -0
  98. package/dist/self-hosted-roles.d.ts.map +1 -1
  99. package/dist/self-hosted-roles.js +52 -0
  100. package/dist/self-hosted-roles.js.map +1 -1
  101. package/dist/self-hosted-start.d.ts +0 -21
  102. package/dist/self-hosted-start.d.ts.map +1 -1
  103. package/dist/self-hosted-start.js +39 -1
  104. package/dist/self-hosted-start.js.map +1 -1
  105. package/dist/self-hosted-upgrade.d.ts +15 -6
  106. package/dist/self-hosted-upgrade.d.ts.map +1 -1
  107. package/dist/self-hosted-upgrade.js +72 -23
  108. package/dist/self-hosted-upgrade.js.map +1 -1
  109. package/dist/stack-contract.d.ts +170 -25
  110. package/dist/stack-contract.d.ts.map +1 -1
  111. package/dist/stack-contract.js +314 -37
  112. package/dist/stack-contract.js.map +1 -1
  113. package/dist/stack-converge.d.ts +31 -0
  114. package/dist/stack-converge.d.ts.map +1 -1
  115. package/dist/stack-converge.js +71 -30
  116. package/dist/stack-converge.js.map +1 -1
  117. package/dist/upgrade/app-roll.d.ts +25 -4
  118. package/dist/upgrade/app-roll.d.ts.map +1 -1
  119. package/dist/upgrade/app-roll.js +63 -17
  120. package/dist/upgrade/app-roll.js.map +1 -1
  121. package/dist/upgrade/converge.d.ts.map +1 -1
  122. package/dist/upgrade/converge.js +99 -12
  123. package/dist/upgrade/converge.js.map +1 -1
  124. package/dist/upgrade/health.d.ts +31 -7
  125. package/dist/upgrade/health.d.ts.map +1 -1
  126. package/dist/upgrade/health.js +41 -17
  127. package/dist/upgrade/health.js.map +1 -1
  128. package/dist/upgrade/image-forms.d.ts +1 -1
  129. package/dist/upgrade/image-forms.d.ts.map +1 -1
  130. package/dist/upgrade/image-forms.js +24 -7
  131. package/dist/upgrade/image-forms.js.map +1 -1
  132. package/dist/upgrade/release-images.d.ts +12 -3
  133. package/dist/upgrade/release-images.d.ts.map +1 -1
  134. package/dist/upgrade/release-images.js +13 -4
  135. package/dist/upgrade/release-images.js.map +1 -1
  136. package/dist/upgrade/upgrade.d.ts +13 -3
  137. package/dist/upgrade/upgrade.d.ts.map +1 -1
  138. package/dist/upgrade/upgrade.js +34 -2
  139. package/dist/upgrade/upgrade.js.map +1 -1
  140. package/dist/upgrader-gcp.d.ts +12 -0
  141. package/dist/upgrader-gcp.d.ts.map +1 -1
  142. package/dist/upgrader-gcp.js +49 -1
  143. package/dist/upgrader-gcp.js.map +1 -1
  144. package/dist/upgrader-iam-preflight.d.ts.map +1 -1
  145. package/dist/upgrader-iam-preflight.js +6 -1
  146. package/dist/upgrader-iam-preflight.js.map +1 -1
  147. package/dist/upgrader-job.d.ts +1 -1
  148. package/dist/upgrader-job.d.ts.map +1 -1
  149. package/dist/upgrader-job.js +20 -14
  150. package/dist/upgrader-job.js.map +1 -1
  151. package/dist/upgrader-records.d.ts +1 -1
  152. package/dist/upgrader-records.d.ts.map +1 -1
  153. package/dist/upgrader-records.js +26 -7
  154. package/dist/upgrader-records.js.map +1 -1
  155. package/dist/upgrader-setup.d.ts.map +1 -1
  156. package/dist/upgrader-setup.js +10 -5
  157. package/dist/upgrader-setup.js.map +1 -1
  158. package/dist/wizards/gcp-cost.d.ts.map +1 -1
  159. package/dist/wizards/gcp-cost.js +22 -7
  160. package/dist/wizards/gcp-cost.js.map +1 -1
  161. package/dist/wizards/gcp.d.ts.map +1 -1
  162. package/dist/wizards/gcp.js +43 -17
  163. package/dist/wizards/gcp.js.map +1 -1
  164. package/package.json +2 -2
  165. package/src/admin-domain.test.ts +9 -8
  166. package/src/admin-domain.ts +13 -3
  167. package/src/admin-memory.test.ts +1 -0
  168. package/src/adopt-existing.test.ts +39 -0
  169. package/src/adopt-existing.ts +83 -1
  170. package/src/agent/command.test.ts +49 -1
  171. package/src/agent/command.ts +36 -9
  172. package/src/agent/run.test.ts +19 -0
  173. package/src/agent/run.ts +11 -2
  174. package/src/aws-task-roles.ts +5 -2
  175. package/src/caddy-redirects.docker.test.ts +2 -0
  176. package/src/caddy-redirects.test.ts +3 -0
  177. package/src/caddy-validate.test.ts +7 -1
  178. package/src/caddy.test.ts +39 -1
  179. package/src/caddy.ts +36 -4
  180. package/src/checkpoint-secrets.test.ts +14 -6
  181. package/src/cli.ts +12 -1
  182. package/src/cloudsql-tls.test.ts +225 -0
  183. package/src/cloudsql-tls.ts +352 -0
  184. package/src/compose.ts +125 -24
  185. package/src/converge-checkpoint.ts +8 -1
  186. package/src/converge-job-gcp.ts +2 -11
  187. package/src/database-tls-builders.test.ts +55 -0
  188. package/src/deployed-release.test.ts +104 -40
  189. package/src/deployed-release.ts +30 -9
  190. package/src/gateway-env-isolation.test.ts +10 -5
  191. package/src/gcp-names.ts +31 -0
  192. package/src/gcp-url-map.test.ts +6 -8
  193. package/src/gcp-url-map.ts +6 -2
  194. package/src/index.test.ts +7 -1
  195. package/src/index.ts +1 -0
  196. package/src/infra-contract.test.ts +5 -3
  197. package/src/infra-contract.ts +24 -1
  198. package/src/install-state.ts +26 -4
  199. package/src/lifecycle.ts +20 -11
  200. package/src/mcp-auth-proxy-setup.test.ts +305 -0
  201. package/src/mcp-auth-proxy-setup.ts +374 -0
  202. package/src/mcp-iap-stacks.test.ts +1 -1
  203. package/src/media-volume.test.ts +1 -1
  204. package/src/operator-access-stacks.test.ts +1 -1
  205. package/src/operator-access.test.ts +5 -4
  206. package/src/pool-budget-stacks.test.ts +1 -1
  207. package/src/release-manifest.test.ts +25 -0
  208. package/src/release-manifest.ts +59 -4
  209. package/src/remote-mcp-stack-config.test.ts +19 -9
  210. package/src/remote-mcp.test.ts +252 -57
  211. package/src/remote-mcp.ts +336 -77
  212. package/src/runtime-secrets.test.ts +20 -7
  213. package/src/runtime-secrets.ts +50 -9
  214. package/src/seal-key-rotation.test.ts +2 -2
  215. package/src/seal-key-rotation.ts +25 -25
  216. package/src/self-hosted-compose.test.ts +108 -5
  217. package/src/self-hosted-db-tls.test.ts +111 -0
  218. package/src/self-hosted-db-tls.ts +287 -0
  219. package/src/self-hosted-release-resolve.test.ts +71 -0
  220. package/src/self-hosted-release.ts +41 -11
  221. package/src/self-hosted-roles.test.ts +54 -0
  222. package/src/self-hosted-roles.ts +58 -0
  223. package/src/self-hosted-start.test.ts +34 -2
  224. package/src/self-hosted-start.ts +20 -2
  225. package/src/self-hosted-upgrade.test.ts +103 -14
  226. package/src/self-hosted-upgrade.ts +115 -29
  227. package/src/stack-contract-parity.test.ts +79 -12
  228. package/src/stack-contract.test.ts +169 -29
  229. package/src/stack-contract.ts +362 -42
  230. package/src/stack-converge-secrets.test.ts +26 -13
  231. package/src/stack-converge.test.ts +199 -26
  232. package/src/stack-converge.ts +92 -30
  233. package/src/state-store.test.ts +2 -1
  234. package/src/upgrade/app-roll.ts +97 -19
  235. package/src/upgrade/converge.ts +120 -11
  236. package/src/upgrade/health.test.ts +63 -25
  237. package/src/upgrade/health.ts +54 -22
  238. package/src/upgrade/image-forms.test.ts +34 -0
  239. package/src/upgrade/image-forms.ts +23 -8
  240. package/src/upgrade/release-images.test.ts +5 -1
  241. package/src/upgrade/release-images.ts +22 -6
  242. package/src/upgrade/upgrade.test.ts +353 -44
  243. package/src/upgrade/upgrade.ts +55 -6
  244. package/src/upgrader-gcp.ts +65 -3
  245. package/src/upgrader-iam-preflight.test.ts +19 -0
  246. package/src/upgrader-iam-preflight.ts +6 -0
  247. package/src/upgrader-job.test.ts +101 -27
  248. package/src/upgrader-job.ts +27 -14
  249. package/src/upgrader-records.test.ts +97 -12
  250. package/src/upgrader-records.ts +28 -11
  251. package/src/upgrader-setup.test.ts +2 -0
  252. package/src/upgrader-setup.ts +10 -4
  253. package/src/wizards/gcp-cost.test.ts +50 -0
  254. package/src/wizards/gcp-cost.ts +24 -7
  255. package/src/wizards/gcp.ts +55 -14
  256. package/stacks/aws/index.ts +32 -14
  257. package/stacks/azure/index.ts +57 -18
  258. package/stacks/gcp/index.ts +83 -23
  259. package/stacks/gcp-firebase/index.ts +162 -33
  260. package/stacks/self-hosted/README.md +2 -1
  261. package/stacks/self-hosted/index.ts +11 -1
@@ -5,7 +5,8 @@
5
5
  * Pulumi) creates and maintains, through gcloud:
6
6
  *
7
7
  * - {@link ensureGatewayServiceAccount}: the gateway's own run SA, so it
8
- * can be granted only the secrets the gateway reads.
8
+ * can be granted only the secrets the gateway reads; the same for the
9
+ * MCP-Auth-Proxy ({@link ensureMcpAuthProxyServiceAccount}, #1328).
9
10
  * - {@link ensureGeneratedSecrets}: the secrets in
10
11
  * `CLI_GENERATED_SECRETS` (stack-contract.ts). The wizard runs it before
11
12
  * `pulumi up`, upgrade on every roll, so installs that predate a secret
@@ -23,9 +24,16 @@
23
24
  import { randomBytes } from "node:crypto";
24
25
  import { gcloud as defaultGcloud } from "./gcloud.js";
25
26
  import { type GcloudRunner, realSleep, runWithRetry, type Sleep } from "./gcloud-retry.js";
26
- import { gatewayServiceAccountEmail, gatewayServiceAccountId, gcpSecretId } from "./gcp-names.js";
27
+ import {
28
+ gatewayServiceAccountEmail,
29
+ gatewayServiceAccountId,
30
+ gcpSecretId,
31
+ mcpAuthProxyServiceAccountEmail,
32
+ mcpAuthProxyServiceAccountId,
33
+ } from "./gcp-names.js";
27
34
  import {
28
35
  CLI_GENERATED_SECRETS,
36
+ CLOUD_RUN_SLUGS,
29
37
  type CloudRunSlug,
30
38
  DATABASE_ROLE_SECRET,
31
39
  type DatabaseRole,
@@ -84,14 +92,47 @@ const ALREADY_EXISTS = /ALREADY_EXISTS|already exists/i;
84
92
  // ===========================================================================
85
93
 
86
94
  /** Create the gateway's run SA unless it exists. */
87
- export async function ensureGatewayServiceAccount(
95
+ export function ensureGatewayServiceAccount(
96
+ install: { readonly projectId: string; readonly env: string },
97
+ deps: RuntimeDeps = {},
98
+ ): Promise<EnsureOutcome> {
99
+ return ensureServiceAccount(
100
+ install.projectId,
101
+ gatewayServiceAccountId(install.env),
102
+ gatewayServiceAccountEmail(install.projectId, install.env),
103
+ `Caelo ${install.env} gateway`,
104
+ deps,
105
+ );
106
+ }
107
+
108
+ /**
109
+ * #1328 — create the MCP-Auth-Proxy's run SA unless it exists. It gets no
110
+ * role here or anywhere: only `secretAccessor` on its two secrets
111
+ * (stack-contract.ts runtimeSecretBindings).
112
+ */
113
+ export function ensureMcpAuthProxyServiceAccount(
88
114
  install: { readonly projectId: string; readonly env: string },
89
115
  deps: RuntimeDeps = {},
116
+ ): Promise<EnsureOutcome> {
117
+ return ensureServiceAccount(
118
+ install.projectId,
119
+ mcpAuthProxyServiceAccountId(install.env),
120
+ mcpAuthProxyServiceAccountEmail(install.projectId, install.env),
121
+ `Caelo ${install.env} MCP-Auth-Proxy`,
122
+ deps,
123
+ );
124
+ }
125
+
126
+ async function ensureServiceAccount(
127
+ projectId: string,
128
+ accountId: string,
129
+ email: string,
130
+ displayName: string,
131
+ deps: RuntimeDeps,
90
132
  ): Promise<EnsureOutcome> {
91
133
  const run = deps.run ?? defaultGcloud;
92
- const email = gatewayServiceAccountEmail(install.projectId, install.env);
93
134
  const id = `service account ${email}`;
94
- const project = `--project=${install.projectId}`;
135
+ const project = `--project=${projectId}`;
95
136
  const describe = await run([
96
137
  "iam",
97
138
  "service-accounts",
@@ -108,9 +149,9 @@ export async function ensureGatewayServiceAccount(
108
149
  "iam",
109
150
  "service-accounts",
110
151
  "create",
111
- gatewayServiceAccountId(install.env),
152
+ accountId,
112
153
  project,
113
- `--display-name=Caelo ${install.env} gateway`,
154
+ `--display-name=${displayName}`,
114
155
  "--quiet",
115
156
  ]);
116
157
  if (create.ok || ALREADY_EXISTS.test(create.stderr)) return { id, status: "applied" };
@@ -470,7 +511,7 @@ async function checkRotationReaders(
470
511
  const isSecret = (live: { kind: string; secret?: string } | undefined) =>
471
512
  live?.kind === "secret" &&
472
513
  (live.secret === secretId || (live.secret?.endsWith(`/secrets/${secretId}`) ?? false));
473
- for (const service of ["admin", "gateway"] as const) {
514
+ for (const service of CLOUD_RUN_SLUGS) {
474
515
  const vars = Object.entries(SERVICE_SECRET_ENV[service])
475
516
  .filter(([, s]) => s === secret)
476
517
  .map(([name]) => name);
@@ -696,7 +737,7 @@ export async function rotateRuntimeSecret(
696
737
  steps.push(`added a new version to ${secretId}`);
697
738
 
698
739
  const failures: string[] = [];
699
- for (const service of ["admin", "gateway"] as const) {
740
+ for (const service of CLOUD_RUN_SLUGS) {
700
741
  if (!serviceSecrets(service).includes(secret)) continue;
701
742
  const name = target.services[service];
702
743
  const [update, flip] = rollArgs(name, target.region, target.projectId) as [string[], string[]];
@@ -15,7 +15,7 @@ const T = {
15
15
  projectId: "acme",
16
16
  region: "europe-west1",
17
17
  env: "production",
18
- gatewayService: "caelo-production-gateway-abc",
18
+ proxyService: "caelo-production-gateway-abc",
19
19
  };
20
20
  const DAY = 24 * 60 * 60 * 1000;
21
21
  const NOW = Date.UTC(2026, 9, 20);
@@ -122,7 +122,7 @@ describe("seal-key rotation (#806)", () => {
122
122
 
123
123
  it("keeps the window equal to the broker's refresh-token lifetime", async () => {
124
124
  const src = await Bun.file(
125
- new URL("../../../apps/api-gateway/src/mcp/token.ts", import.meta.url),
125
+ new URL("../../../apps/mcp-auth-proxy/src/token.ts", import.meta.url),
126
126
  ).text();
127
127
  expect(src).toContain(
128
128
  `REFRESH_MAX_LIFETIME_SECONDS = ${SEAL_KEY_ROTATION_WINDOW_DAYS} * 24 * 60 * 60`,
@@ -5,7 +5,7 @@
5
5
  * every connected MCP client out.
6
6
  *
7
7
  * The broker seals its OAuth state, codes and refresh tokens under
8
- * `CAELO_MCP_OAUTH_SEAL_KEY` (api-gateway mcp/seal.ts, envelope v2 with a
8
+ * `CAELO_MCP_OAUTH_SEAL_KEY` (apps/mcp-auth-proxy seal.ts, envelope v2 with a
9
9
  * key id). While `CAELO_MCP_OAUTH_SEAL_KEY_PREVIOUS` is set it also opens
10
10
  * values sealed under that key, and seals everything new under the current
11
11
  * one. Refresh tokens live at most {@link SEAL_KEY_ROTATION_WINDOW_DAYS}
@@ -14,7 +14,7 @@
14
14
  *
15
15
  * Two steps, both `cms-provision rotate-secret mcp-oauth-seal-key`:
16
16
  *
17
- * start — add a new version to the secret, then roll the gateway with
17
+ * start — add a new version to the secret, then roll the MCP-Auth-Proxy with
18
18
  * the current key at `latest` and the previous key PINNED to
19
19
  * the version that was latest before (a pinned version never
20
20
  * follows the new one), labelled with the start time.
@@ -27,7 +27,7 @@
27
27
  * progress (a second start would pin the CURRENT key as "previous" and drop
28
28
  * the real previous one early), and finishing before the window ends.
29
29
  *
30
- * A `cms-provision upgrade` whose `pulumi up` rewrites the gateway template
30
+ * A `cms-provision upgrade` whose `pulumi up` rewrites the MCP-Auth-Proxy template
31
31
  * during the window drops the previous key (the stack does not declare it):
32
32
  * clients sealed under it sign in again. That fails safe — no token lives
33
33
  * longer than intended — and is documented in the rotate output.
@@ -39,27 +39,27 @@ import { gcpSecretId } from "./gcp-names.js";
39
39
  import { initialSecretValue } from "./runtime-secrets.js";
40
40
  import { liveContainerEnv } from "./stack-converge.js";
41
41
 
42
- /** The gateway env var holding the previous key during a rotation. */
42
+ /** The MCP-Auth-Proxy env var holding the previous key during a rotation. */
43
43
  export const SEAL_KEY_PREVIOUS_ENV = "CAELO_MCP_OAUTH_SEAL_KEY_PREVIOUS";
44
- /** The gateway env var holding the current key (stack-contract SERVICE_SECRET_ENV). */
44
+ /** The MCP-Auth-Proxy env var holding the current key (stack-contract SERVICE_SECRET_ENV). */
45
45
  export const SEAL_KEY_ENV = "CAELO_MCP_OAUTH_SEAL_KEY";
46
- /** Label on the gateway service: when the rotation started (epoch ms). */
46
+ /** Label on the MCP-Auth-Proxy service: when the rotation started (epoch ms). */
47
47
  export const SEAL_ROTATION_LABEL = "caelo-seal-rotation-started";
48
48
  /**
49
49
  * Days the previous key must stay: the broker's refresh-token lifetime
50
- * (api-gateway mcp/token.ts REFRESH_MAX_LIFETIME_SECONDS, 30 days).
50
+ * (apps/mcp-auth-proxy token.ts REFRESH_MAX_LIFETIME_SECONDS, 30 days).
51
51
  */
52
52
  export const SEAL_KEY_ROTATION_WINDOW_DAYS = 30;
53
53
 
54
54
  const DAY_MS = 24 * 60 * 60 * 1000;
55
55
 
56
- /** The deployed gateway a rotation works on. */
56
+ /** The deployed MCP-Auth-Proxy a rotation works on. */
57
57
  export interface SealRotationTarget {
58
58
  readonly projectId: string;
59
59
  readonly region: string;
60
60
  readonly env: string;
61
- /** Deployed (Pulumi-suffixed) gateway Cloud Run service name. */
62
- readonly gatewayService: string;
61
+ /** Deployed (Pulumi-suffixed or fixed) MCP-Auth-Proxy Cloud Run service name. */
62
+ readonly proxyService: string;
63
63
  }
64
64
 
65
65
  export interface SealRotationReport {
@@ -80,7 +80,7 @@ interface GatewayState {
80
80
  readonly startedAtMs: number | null;
81
81
  }
82
82
 
83
- async function gatewayState(
83
+ async function proxyState(
84
84
  run: GcloudRunner,
85
85
  t: SealRotationTarget,
86
86
  ): Promise<{ ok: true; state: GatewayState } | { ok: false; error: string }> {
@@ -88,13 +88,13 @@ async function gatewayState(
88
88
  "run",
89
89
  "services",
90
90
  "describe",
91
- t.gatewayService,
91
+ t.proxyService,
92
92
  "--region",
93
93
  t.region,
94
94
  `--project=${t.projectId}`,
95
95
  "--format=json",
96
96
  ]);
97
- if (!r.ok) return { ok: false, error: `read the gateway service: ${r.stderr.trim()}` };
97
+ if (!r.ok) return { ok: false, error: `read the MCP-Auth-Proxy service: ${r.stderr.trim()}` };
98
98
  const env = liveContainerEnv(r.stdout);
99
99
  const labels =
100
100
  (JSON.parse(r.stdout) as { metadata?: { labels?: Record<string, string> } }).metadata?.labels ??
@@ -105,7 +105,7 @@ async function gatewayState(
105
105
  if (current?.kind !== "secret") {
106
106
  return {
107
107
  ok: false,
108
- error: `${SEAL_KEY_ENV} on ${t.gatewayService} is not a Secret Manager reference — run \`cms-provision upgrade\` first (it installs remote MCP's secrets).`,
108
+ error: `${SEAL_KEY_ENV} on ${t.proxyService} is not a Secret Manager reference — run \`cms-provision upgrade\` first (it installs remote MCP's secrets).`,
109
109
  };
110
110
  }
111
111
  return {
@@ -119,7 +119,7 @@ function rollFlip(t: SealRotationTarget): string[] {
119
119
  "run",
120
120
  "services",
121
121
  "update-traffic",
122
- t.gatewayService,
122
+ t.proxyService,
123
123
  "--region",
124
124
  t.region,
125
125
  `--project=${t.projectId}`,
@@ -140,7 +140,7 @@ export async function startSealKeyRotation(
140
140
  const project = `--project=${t.projectId}`;
141
141
  const steps: string[] = [];
142
142
 
143
- const live = await gatewayState(run, t);
143
+ const live = await proxyState(run, t);
144
144
  if (!live.ok) return { ok: false, steps, error: live.error };
145
145
  if (live.state.previousPinned) {
146
146
  const since = live.state.startedAtMs;
@@ -181,7 +181,7 @@ export async function startSealKeyRotation(
181
181
  "run",
182
182
  "services",
183
183
  "update",
184
- t.gatewayService,
184
+ t.proxyService,
185
185
  "--region",
186
186
  t.region,
187
187
  project,
@@ -195,11 +195,11 @@ export async function startSealKeyRotation(
195
195
  return {
196
196
  ok: false,
197
197
  steps,
198
- error: `The new key is stored but the gateway does not use it yet. Do not rotate again — roll the gateway:\n gcloud ${update.join(" ")}\n gcloud ${rollFlip(t).join(" ")}\n(${flipped.stderr.trim()})`,
198
+ error: `The new key is stored but the MCP-Auth-Proxy does not use it yet. Do not rotate again — roll the MCP-Auth-Proxy:\n gcloud ${update.join(" ")}\n gcloud ${rollFlip(t).join(" ")}\n(${flipped.stderr.trim()})`,
199
199
  };
200
200
  }
201
201
  steps.push(
202
- `rolled the gateway onto the new key; tokens sealed under version ${version} keep working`,
202
+ `rolled the MCP-Auth-Proxy onto the new key; tokens sealed under version ${version} keep working`,
203
203
  `finish after ${new Date(now + SEAL_KEY_ROTATION_WINDOW_DAYS * DAY_MS).toISOString()} with: cms-provision rotate-secret mcp-oauth-seal-key --finish`,
204
204
  "a `cms-provision upgrade` before then drops the previous key early: clients sealed under it sign in again",
205
205
  );
@@ -215,13 +215,13 @@ export async function finishSealKeyRotation(
215
215
  const run = deps.run ?? defaultGcloud;
216
216
  const now = (deps.nowMs ?? Date.now)();
217
217
  const steps: string[] = [];
218
- const live = await gatewayState(run, t);
218
+ const live = await proxyState(run, t);
219
219
  if (!live.ok) return { ok: false, steps, error: live.error };
220
220
  if (!live.state.previousPinned) {
221
221
  return {
222
222
  ok: false,
223
223
  steps,
224
- error: `No seal-key rotation is in progress on ${t.gatewayService} (${SEAL_KEY_PREVIOUS_ENV} is not set). Start one with \`cms-provision rotate-secret mcp-oauth-seal-key\`.`,
224
+ error: `No seal-key rotation is in progress on ${t.proxyService} (${SEAL_KEY_PREVIOUS_ENV} is not set). Start one with \`cms-provision rotate-secret mcp-oauth-seal-key\`.`,
225
225
  };
226
226
  }
227
227
  const started = live.state.startedAtMs;
@@ -232,7 +232,7 @@ export async function finishSealKeyRotation(
232
232
  steps,
233
233
  error:
234
234
  due === null
235
- ? `The gateway has ${SEAL_KEY_PREVIOUS_ENV} but no ${SEAL_ROTATION_LABEL} label, so the rotation's start is unknown. Wait ${SEAL_KEY_ROTATION_WINDOW_DAYS} days from when it started, then run --finish --now.`
235
+ ? `The MCP-Auth-Proxy has ${SEAL_KEY_PREVIOUS_ENV} but no ${SEAL_ROTATION_LABEL} label, so the rotation's start is unknown. Wait ${SEAL_KEY_ROTATION_WINDOW_DAYS} days from when it started, then run --finish --now.`
236
236
  : `The previous key still opens refresh tokens until ${new Date(due).toISOString()} (${SEAL_KEY_ROTATION_WINDOW_DAYS}-day lifetime). Finishing now would sign those clients out; run --finish after that time, or --finish --now if the old key leaked.`,
237
237
  };
238
238
  }
@@ -240,7 +240,7 @@ export async function finishSealKeyRotation(
240
240
  "run",
241
241
  "services",
242
242
  "update",
243
- t.gatewayService,
243
+ t.proxyService,
244
244
  "--region",
245
245
  t.region,
246
246
  `--project=${t.projectId}`,
@@ -251,8 +251,8 @@ export async function finishSealKeyRotation(
251
251
  const rolled = await run(update);
252
252
  const flipped = rolled.ok ? await run(rollFlip(t)) : rolled;
253
253
  if (!flipped.ok) {
254
- return { ok: false, steps, error: `roll the gateway: ${flipped.stderr.trim()}` };
254
+ return { ok: false, steps, error: `roll the MCP-Auth-Proxy: ${flipped.stderr.trim()}` };
255
255
  }
256
- steps.push("removed the previous seal key from the gateway; the rotation is complete");
256
+ steps.push("removed the previous seal key from the MCP-Auth-Proxy; the rotation is complete");
257
257
  return { ok: true, steps };
258
258
  }
@@ -6,6 +6,8 @@ import {
6
6
  COMPOSE_ADMIN_UPSTREAM,
7
7
  COMPOSE_APP_SERVICES,
8
8
  COMPOSE_GATEWAY_UPSTREAM,
9
+ COMPOSE_MCP_AUTH_PROXY_SERVICE,
10
+ COMPOSE_MCP_AUTH_PROXY_UPSTREAM,
9
11
  COMPOSE_POOL_BUDGET,
10
12
  type ComposeSpec,
11
13
  composeProjectName,
@@ -22,6 +24,7 @@ const base: Omit<ComposeSpec, "images"> = {
22
24
  postgresPassword: "pgpw",
23
25
  rolePasswords: { admin: "adminpw", public: "publicpw" },
24
26
  caeloSecretKek: "0".repeat(64),
27
+ dbTlsServerCertSha256: "abababababababababababababababababababababababababababababababab",
25
28
  internalSecret: "i".repeat(48),
26
29
  anthropicApiKey: "sk-ant-x",
27
30
  diskSize: "20Gi",
@@ -53,6 +56,7 @@ describe("self-hosted Compose — internal secret (#770)", () => {
53
56
  expect(() =>
54
57
  generateDockerCompose({
55
58
  ...base,
59
+ dbTlsServerCertSha256: "abababababababababababababababababababababababababababababababab",
56
60
  internalSecret: "short",
57
61
  images: { mode: "release", admin: ADMIN, gateway: GATEWAY },
58
62
  }),
@@ -68,10 +72,23 @@ describe("self-hosted Compose — connection budget (#1254)", () => {
68
72
 
69
73
  it("starts Postgres with the max_connections the budget is computed from", () => {
70
74
  expect(service(yaml, "postgres")).toContain(
71
- 'command: ["postgres", "-c", "max_connections=100"]',
75
+ 'command: ["postgres", "-c", "max_connections=100", ',
72
76
  );
73
77
  });
74
78
 
79
+ it("serves TLS with the install's own certificate, never a key it does not own (#1338)", () => {
80
+ const pg = service(yaml, "postgres");
81
+ expect(pg).toContain('"-c", "ssl=on"');
82
+ expect(pg).toContain("- ./postgres-tls/server.crt:/caelo-tls/server.crt:ro");
83
+ expect(pg).toContain("- ./postgres-tls/server.key:/caelo-tls/server.key:ro");
84
+ // The bind mount keeps the host's owner; Postgres only reads its own key.
85
+ expect(pg).toContain("install -o postgres -g postgres -m 0600 /caelo-tls/server.key");
86
+ expect(pg).toContain('exec docker-entrypoint.sh \\"$$@\\"');
87
+ // Only Postgres sees the server key; the CA key never leaves .caelo/.
88
+ expect(yaml.replace(pg, "")).not.toContain("server.key");
89
+ expect(yaml).not.toContain("ca.key");
90
+ });
91
+
75
92
  it("sizes the admin's two pools and the gateway's one", () => {
76
93
  expect(service(yaml, "caelo-admin")).toContain(
77
94
  ` CAELO_DB_POOL_MAX: "${COMPOSE_POOL_BUDGET.budget.adminPoolMax}"`,
@@ -82,6 +99,26 @@ describe("self-hosted Compose — connection budget (#1254)", () => {
82
99
  });
83
100
  });
84
101
 
102
+ describe("self-hosted Compose — database certificate renewal (#1338)", () => {
103
+ const render = (digest: string) =>
104
+ generateDockerCompose({
105
+ ...base,
106
+ dbTlsServerCertSha256: digest,
107
+ images: { mode: "release", admin: ADMIN, gateway: GATEWAY },
108
+ });
109
+
110
+ it("carries the server certificate's digest on Postgres, so a renewal recreates it", () => {
111
+ const before = service(render("a".repeat(64)), "postgres");
112
+ const after = service(render("c".repeat(64)), "postgres");
113
+ expect(before).toContain(`CAELO_DB_TLS_SERVER_CERT_SHA256: "${"a".repeat(64)}"`);
114
+ expect(after).not.toBe(before);
115
+ });
116
+
117
+ it("refuses a value that is no SHA-256 digest", () => {
118
+ expect(() => render("not-a-digest")).toThrow("dbTlsServerCertSha256");
119
+ });
120
+ });
121
+
85
122
  describe("self-hosted Compose — release images (#696)", () => {
86
123
  const yaml = generateDockerCompose({
87
124
  ...base,
@@ -104,10 +141,13 @@ describe("self-hosted Compose — release images (#696)", () => {
104
141
  it("mounts the bootstrap-token, site output + media dirs, never .caelo/ with its secrets", () => {
105
142
  const admin = service(yaml, "caelo-admin");
106
143
  expect(admin).toContain(
107
- " volumes:\n - ./bootstrap:/app/.caelo\n - ./output:/srv/caelo\n - ./media:/data/media",
144
+ " volumes:\n - ./bootstrap:/app/.caelo\n - ./output:/srv/caelo\n - ./media:/data/media\n - ./postgres-tls/ca.crt:/etc/caelo/db-ca/self-hosted-ca.pem:ro",
108
145
  );
109
146
  expect(yaml).not.toMatch(/- \.\/?:/);
110
- expect(service(yaml, "caelo-gateway")).not.toContain("volumes:");
147
+ // The gateway mounts the database CA (#1338) and nothing else.
148
+ expect(service(yaml, "caelo-gateway")).toContain(
149
+ " volumes:\n - ./postgres-tls/ca.crt:/etc/caelo/db-ca/self-hosted-ca.pem:ro\n restart:",
150
+ );
111
151
  expect(admin).toContain('CAELO_OUTPUT_ROOT: "/srv/caelo"');
112
152
  expect(admin).toContain('MEDIA_ROOT_DIR: "/data/media"');
113
153
  expect(service(yaml, "caddy")).toContain("- ./output:/srv/caelo:ro");
@@ -116,10 +156,10 @@ describe("self-hosted Compose — release images (#696)", () => {
116
156
  it("keeps the env + secret wiring of each service", () => {
117
157
  const admin = service(yaml, "caelo-admin");
118
158
  expect(admin).toContain(
119
- 'ADMIN_DATABASE_URL: "postgres://admin_role:adminpw@postgres:5432/cms_admin"',
159
+ 'ADMIN_DATABASE_URL: "postgres://admin_role:adminpw@postgres:5432/cms_admin?sslmode=verify-full&sslrootcert=/etc/caelo/db-ca/self-hosted-ca.pem"',
120
160
  );
121
161
  expect(admin).toContain(
122
- 'PUBLIC_ADMIN_DATABASE_URL: "postgres://admin_role:adminpw@postgres:5432/cms_public"',
162
+ 'PUBLIC_ADMIN_DATABASE_URL: "postgres://admin_role:adminpw@postgres:5432/cms_public?sslmode=verify-full&sslrootcert=/etc/caelo/db-ca/self-hosted-ca.pem"',
123
163
  );
124
164
  expect(admin).toContain(`CAELO_SECRET_KEK: "${"0".repeat(64)}"`);
125
165
  expect(admin).toContain('CAELO_SITE_URL: "https://example.com"');
@@ -307,3 +347,66 @@ describe("self-hosted Compose — the host update agent (#696 Phase 4c)", () =>
307
347
  ).toThrow(/between 1024 and 65535/);
308
348
  });
309
349
  });
350
+
351
+ describe("self-hosted Compose — the MCP-Auth-Proxy (#1328)", () => {
352
+ const PROXY = `ghcr.io/caelo-cms/mcp-auth-proxy@sha256:${"c".repeat(64)}`;
353
+ const yaml = generateDockerCompose({
354
+ ...base,
355
+ images: { mode: "release", admin: ADMIN, gateway: GATEWAY, "mcp-auth-proxy": PROXY },
356
+ });
357
+ const proxy = service(yaml, COMPOSE_MCP_AUTH_PROXY_SERVICE);
358
+
359
+ it("runs the digest-pinned release image on the port Caddy proxies to", () => {
360
+ expect(proxy).toContain(`image: ${PROXY}`);
361
+ expect(proxy).toContain('MCP_AUTH_PROXY_PORT: "8095"');
362
+ expect(COMPOSE_MCP_AUTH_PROXY_UPSTREAM).toBe("caelo-mcp-auth-proxy:8095");
363
+ });
364
+
365
+ it("says remote MCP is unavailable here and trusts Caddy as the one hop", () => {
366
+ expect(proxy).toContain('CAELO_REMOTE_MCP: "unavailable"');
367
+ expect(proxy).toContain('CAELO_TRUSTED_PROXY_HOPS: "1"');
368
+ expect(proxy).toContain('CAELO_PROVIDER: "self-hosted"');
369
+ });
370
+
371
+ it("gets no database URL, no secret, no mount and no dependency on Postgres", () => {
372
+ expect(proxy).not.toMatch(/DATABASE|PASSWORD|SECRET|KEK|postgres:\/\/|volumes:|depends_on/);
373
+ expect(proxy).not.toContain("adminpw");
374
+ expect(proxy).not.toContain("publicpw");
375
+ });
376
+
377
+ it("runs locked down: read-only root, no capabilities, no privilege escalation", () => {
378
+ expect(proxy).toContain("read_only: true");
379
+ expect(proxy).toContain('cap_drop: ["ALL"]');
380
+ expect(proxy).toContain('security_opt: ["no-new-privileges:true"]');
381
+ });
382
+
383
+ it("is absent for a release recorded before the proxy shipped", () => {
384
+ const old = generateDockerCompose({
385
+ ...base,
386
+ images: { mode: "release", admin: ADMIN, gateway: GATEWAY },
387
+ });
388
+ expect(old).not.toContain(COMPOSE_MCP_AUTH_PROXY_SERVICE);
389
+ expect(old).toContain("\n\nvolumes:\n");
390
+ });
391
+
392
+ it("runs the checkout's proxy in dev mode", () => {
393
+ const dev = generateDockerCompose({ ...base, images: { mode: "dev" } });
394
+ expect(service(dev, COMPOSE_MCP_AUTH_PROXY_SERVICE)).toContain(
395
+ "command: bun run apps/mcp-auth-proxy/src/server.ts",
396
+ );
397
+ });
398
+
399
+ it("refuses a proxy image that is not a digest-pinned GHCR reference", () => {
400
+ expect(() =>
401
+ generateDockerCompose({
402
+ ...base,
403
+ images: {
404
+ mode: "release",
405
+ admin: ADMIN,
406
+ gateway: GATEWAY,
407
+ "mcp-auth-proxy": "ghcr.io/caelo-cms/mcp-auth-proxy:latest",
408
+ },
409
+ }),
410
+ ).toThrow(/mcp-auth-proxy image/);
411
+ });
412
+ });
@@ -0,0 +1,111 @@
1
+ // SPDX-License-Identifier: MPL-2.0
2
+
3
+ import { afterEach, describe, expect, it } from "bun:test";
4
+ import { X509Certificate } from "node:crypto";
5
+ import { mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
6
+ import { tmpdir } from "node:os";
7
+ import { join } from "node:path";
8
+ import {
9
+ ensureSelfHostedDbTls,
10
+ generateDatabaseTls,
11
+ SELF_HOSTED_DB_HOST,
12
+ SELF_HOSTED_TLS_DIR,
13
+ serverCertProblem,
14
+ } from "./self-hosted-db-tls.js";
15
+
16
+ const dirs: string[] = [];
17
+ afterEach(() => {
18
+ for (const d of dirs.splice(0)) rmSync(d, { recursive: true, force: true });
19
+ });
20
+ function tempDir(): string {
21
+ const d = mkdtempSync(join(tmpdir(), "caelo-db-tls-"));
22
+ dirs.push(d);
23
+ return d;
24
+ }
25
+
26
+ describe("generateDatabaseTls", () => {
27
+ it("makes a CA and a server certificate for `postgres` that chains to it", () => {
28
+ const files = generateDatabaseTls();
29
+ const ca = new X509Certificate(files.caCert);
30
+ const server = new X509Certificate(files.serverCert);
31
+ expect(ca.ca).toBe(true);
32
+ expect(server.ca).toBe(false);
33
+ expect(ca.verify(ca.publicKey)).toBe(true);
34
+ expect(server.verify(ca.publicKey)).toBe(true);
35
+ expect(server.checkHost(SELF_HOSTED_DB_HOST)).toBe(SELF_HOSTED_DB_HOST);
36
+ expect(server.checkHost("evil.example")).toBeUndefined();
37
+ expect(server.issuer).toBe(ca.subject);
38
+ expect(serverCertProblem(files.caCert, files.serverCert, new Date())).toBeNull();
39
+ });
40
+
41
+ it("passes a real TLS handshake with host-name verification (Bun's TLS)", async () => {
42
+ // The same check a verify-full Postgres client makes: the chain must end
43
+ // at the CA we pass, and the certificate must name the host dialled.
44
+ // `localhost` stands in for `postgres` (resolvable here), via the same
45
+ // generator path.
46
+ const files = generateDatabaseTls();
47
+ using server = Bun.serve({
48
+ port: 0,
49
+ hostname: "127.0.0.1",
50
+ tls: { cert: files.serverCert, key: files.serverKey },
51
+ fetch: () => new Response("ok"),
52
+ });
53
+ const url = `https://127.0.0.1:${server.port}/`;
54
+ // Right CA + the certificate's host name (SNI/verify against `postgres`).
55
+ const ok = await fetch(url, {
56
+ tls: {
57
+ ca: files.caCert,
58
+ checkServerIdentity: (_host, cert) =>
59
+ cert.subjectaltname === `DNS:${SELF_HOSTED_DB_HOST}`
60
+ ? undefined
61
+ : new Error(`unexpected SAN ${cert.subjectaltname}`),
62
+ },
63
+ });
64
+ expect(await ok.text()).toBe("ok");
65
+ // Another CA: the chain does not verify.
66
+ await expect(fetch(url, { tls: { ca: generateDatabaseTls().caCert } })).rejects.toThrow();
67
+ });
68
+ });
69
+
70
+ describe("ensureSelfHostedDbTls", () => {
71
+ it("creates the files once, keys 0600, and keeps them on a re-run", () => {
72
+ const caelo = tempDir();
73
+ expect(ensureSelfHostedDbTls(caelo)).toHaveLength(1);
74
+ const dir = join(caelo, SELF_HOSTED_TLS_DIR);
75
+ expect(statSync(join(dir, "ca.key")).mode & 0o777).toBe(0o600);
76
+ expect(statSync(join(dir, "server.key")).mode & 0o777).toBe(0o600);
77
+ const ca = readFileSync(join(dir, "ca.crt"), "utf8");
78
+ const server = readFileSync(join(dir, "server.crt"), "utf8");
79
+ expect(ensureSelfHostedDbTls(caelo)).toEqual([]);
80
+ expect(readFileSync(join(dir, "ca.crt"), "utf8")).toBe(ca);
81
+ expect(readFileSync(join(dir, "server.crt"), "utf8")).toBe(server);
82
+ });
83
+
84
+ it("re-issues a server certificate near its end from the same CA", () => {
85
+ const caelo = tempDir();
86
+ ensureSelfHostedDbTls(caelo);
87
+ const dir = join(caelo, SELF_HOSTED_TLS_DIR);
88
+ const ca = readFileSync(join(dir, "ca.crt"), "utf8");
89
+ const later = new Date(Date.now() + 9.5 * 365 * 24 * 3600 * 1000);
90
+ const done = ensureSelfHostedDbTls(caelo, later);
91
+ expect(done[0]).toMatch(/re-issued.*expires/);
92
+ expect(readFileSync(join(dir, "ca.crt"), "utf8")).toBe(ca);
93
+ const fresh = readFileSync(join(dir, "server.crt"), "utf8");
94
+ expect(serverCertProblem(ca, fresh, later)).toBeNull();
95
+ });
96
+
97
+ it("re-issues a server certificate another CA signed", () => {
98
+ const caelo = tempDir();
99
+ ensureSelfHostedDbTls(caelo);
100
+ const dir = join(caelo, SELF_HOSTED_TLS_DIR);
101
+ writeFileSync(join(dir, "server.crt"), generateDatabaseTls().serverCert);
102
+ expect(ensureSelfHostedDbTls(caelo)[0]).toMatch(/not signed by the install's CA/);
103
+ });
104
+
105
+ it("refuses half a CA instead of silently replacing the trust anchor", () => {
106
+ const caelo = tempDir();
107
+ ensureSelfHostedDbTls(caelo);
108
+ rmSync(join(caelo, SELF_HOSTED_TLS_DIR, "ca.key"));
109
+ expect(() => ensureSelfHostedDbTls(caelo)).toThrow(/only half of the database CA/);
110
+ });
111
+ });