@caelo-cms/provisioning 0.11.9 → 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 +44 -2
  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
@@ -0,0 +1,287 @@
1
+ // SPDX-License-Identifier: MPL-2.0
2
+
3
+ /**
4
+ * #1338 (gateway hardening L6) — verify-full TLS between the self-hosted
5
+ * Compose services and their Postgres.
6
+ *
7
+ * The admin and the gateway reach Postgres over the Compose network as
8
+ * `postgres:5432`. With `sslmode=verify-full` they need a server
9
+ * certificate for exactly that host name, signed by a CA they trust. No
10
+ * public CA issues a certificate for `postgres`, so every install makes its
11
+ * own: an ECDSA P-256 CA, generated once and kept in `.caelo/postgres-tls/`
12
+ * (like the install's other secrets in `.caelo/`), and a server certificate
13
+ * it signs. The CA certificate is mounted read-only into the admin and the
14
+ * gateway at shared `SELF_HOSTED_CA_FILE`; the server key only into
15
+ * Postgres.
16
+ *
17
+ * Generated with `node:crypto` and a minimal DER encoder below, so an
18
+ * install needs neither openssl on the host nor a new dependency (§3).
19
+ * Postgres reads ECDSA keys; Bun's TLS verifies the chain and the host.
20
+ *
21
+ * Re-runs keep what exists (the CA never changes once made, so no client
22
+ * ever needs a new trust anchor); a server certificate that is missing,
23
+ * does not chain to the CA, does not name `postgres`, or expires within a
24
+ * year is re-issued from the stored CA key.
25
+ */
26
+
27
+ import {
28
+ createHash,
29
+ createPrivateKey,
30
+ generateKeyPairSync,
31
+ type KeyObject,
32
+ randomBytes,
33
+ sign,
34
+ X509Certificate,
35
+ } from "node:crypto";
36
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
37
+ import { join } from "node:path";
38
+
39
+ /** The directory under `.caelo/` that holds the install's database TLS files. */
40
+ export const SELF_HOSTED_TLS_DIR = "postgres-tls";
41
+
42
+ /** The host name the services dial Postgres by on the Compose network. */
43
+ export const SELF_HOSTED_DB_HOST = "postgres";
44
+
45
+ /** File names inside {@link SELF_HOSTED_TLS_DIR}. */
46
+ export const SELF_HOSTED_TLS_FILES = {
47
+ caCert: "ca.crt",
48
+ caKey: "ca.key",
49
+ serverCert: "server.crt",
50
+ serverKey: "server.key",
51
+ } as const;
52
+
53
+ const YEAR_MS = 365 * 24 * 3600 * 1000;
54
+ const CA_VALIDITY_YEARS = 30;
55
+ const SERVER_VALIDITY_YEARS = 10;
56
+ /** A server certificate closer than this to its end is re-issued. */
57
+ const RENEW_BEFORE_MS = YEAR_MS;
58
+
59
+ // ---------------------------------------------------------------------------
60
+ // Minimal DER (X.690) — only what an X.509 v3 certificate needs.
61
+ // ---------------------------------------------------------------------------
62
+
63
+ function derLength(n: number): Buffer {
64
+ if (n < 0x80) return Buffer.from([n]);
65
+ const bytes: number[] = [];
66
+ for (let v = n; v > 0; v = Math.floor(v / 256)) bytes.unshift(v & 0xff);
67
+ return Buffer.from([0x80 | bytes.length, ...bytes]);
68
+ }
69
+
70
+ function tlv(tag: number, value: Buffer): Buffer {
71
+ return Buffer.concat([Buffer.from([tag]), derLength(value.length), value]);
72
+ }
73
+
74
+ const seq = (...items: Buffer[]) => tlv(0x30, Buffer.concat(items));
75
+ const set = (...items: Buffer[]) => tlv(0x31, Buffer.concat(items));
76
+ const explicit = (n: number, inner: Buffer) => tlv(0xa0 + n, inner);
77
+ const octets = (b: Buffer) => tlv(0x04, b);
78
+ /** A BIT STRING; `unused` trailing bits of the last byte (DER: minimal for named bits). */
79
+ const bitString = (b: Buffer, unused = 0) => tlv(0x03, Buffer.concat([Buffer.from([unused]), b]));
80
+ const utf8 = (s: string) => tlv(0x0c, Buffer.from(s, "utf8"));
81
+ const bool = (v: boolean) => tlv(0x01, Buffer.from([v ? 0xff : 0]));
82
+
83
+ function integer(b: Buffer): Buffer {
84
+ let v = b;
85
+ while (v.length > 1 && v[0] === 0 && (v[1] ?? 0) < 0x80) v = v.subarray(1);
86
+ if ((v[0] ?? 0) >= 0x80) v = Buffer.concat([Buffer.from([0]), v]);
87
+ return tlv(0x02, v);
88
+ }
89
+
90
+ function oid(dotted: string): Buffer {
91
+ const parts = dotted.split(".").map(Number);
92
+ const out: number[] = [40 * (parts[0] ?? 0) + (parts[1] ?? 0)];
93
+ for (const p of parts.slice(2)) {
94
+ const chunk: number[] = [p & 0x7f];
95
+ for (let v = Math.floor(p / 128); v > 0; v = Math.floor(v / 128))
96
+ chunk.unshift(0x80 | (v & 0x7f));
97
+ out.push(...chunk);
98
+ }
99
+ return tlv(0x06, Buffer.from(out));
100
+ }
101
+
102
+ /** UTCTime until 2049, GeneralizedTime from 2050 (RFC 5280 §4.1.2.5). */
103
+ function time(d: Date): Buffer {
104
+ const iso = d.toISOString().replace(/[-:T]/g, "").slice(0, 14);
105
+ return d.getUTCFullYear() < 2050
106
+ ? tlv(0x17, Buffer.from(`${iso.slice(2)}Z`))
107
+ : tlv(0x18, Buffer.from(`${iso}Z`));
108
+ }
109
+
110
+ const ECDSA_SHA256 = seq(oid("1.2.840.10045.4.3.2"));
111
+ const name = (cn: string) => seq(set(seq(oid("2.5.4.3"), utf8(cn))));
112
+ const extension = (id: string, critical: boolean, value: Buffer) =>
113
+ seq(oid(id), ...(critical ? [bool(true)] : []), octets(value));
114
+
115
+ interface CertSpec {
116
+ readonly subject: string;
117
+ readonly issuer: string;
118
+ readonly publicKey: KeyObject;
119
+ readonly signingKey: KeyObject;
120
+ readonly notBefore: Date;
121
+ readonly notAfter: Date;
122
+ readonly ca: boolean;
123
+ /** DNS subjectAltNames (server certificates). */
124
+ readonly dnsNames?: readonly string[];
125
+ }
126
+
127
+ function certificatePem(spec: CertSpec): string {
128
+ const spki = spec.publicKey.export({ type: "spki", format: "der" });
129
+ const exts = [
130
+ extension("2.5.29.19", true, spec.ca ? seq(bool(true)) : seq()),
131
+ // keyCertSign + cRLSign for the CA; digitalSignature for the server.
132
+ extension(
133
+ "2.5.29.15",
134
+ true,
135
+ spec.ca ? bitString(Buffer.from([0x06]), 1) : bitString(Buffer.from([0x80]), 7),
136
+ ),
137
+ ...(spec.ca
138
+ ? []
139
+ : [
140
+ extension("2.5.29.37", false, seq(oid("1.3.6.1.5.5.7.3.1"))),
141
+ extension(
142
+ "2.5.29.17",
143
+ false,
144
+ seq(...(spec.dnsNames ?? []).map((n) => tlv(0x82, Buffer.from(n, "ascii")))),
145
+ ),
146
+ ]),
147
+ ];
148
+ const tbs = seq(
149
+ explicit(0, integer(Buffer.from([2]))),
150
+ integer(Buffer.concat([Buffer.from([0x01]), randomBytes(15)])),
151
+ ECDSA_SHA256,
152
+ name(spec.issuer),
153
+ seq(time(spec.notBefore), time(spec.notAfter)),
154
+ name(spec.subject),
155
+ spki,
156
+ explicit(3, seq(...exts)),
157
+ );
158
+ const signature = sign("sha256", tbs, { key: spec.signingKey, dsaEncoding: "der" });
159
+ const der = seq(tbs, ECDSA_SHA256, bitString(signature));
160
+ const b64 = der.toString("base64").replace(/.{64}/g, "$&\n").replace(/\n$/, "");
161
+ return `-----BEGIN CERTIFICATE-----\n${b64}\n-----END CERTIFICATE-----\n`;
162
+ }
163
+
164
+ /** The PEM files of an install's database TLS. */
165
+ export interface DatabaseTlsFiles {
166
+ readonly caCert: string;
167
+ readonly caKey: string;
168
+ readonly serverCert: string;
169
+ readonly serverKey: string;
170
+ }
171
+
172
+ function newKeyPair() {
173
+ return generateKeyPairSync("ec", { namedCurve: "P-256" });
174
+ }
175
+
176
+ /** A server certificate for {@link SELF_HOSTED_DB_HOST}, signed by the CA. */
177
+ function issueServer(
178
+ ca: { cert: string; key: KeyObject },
179
+ now: Date,
180
+ ): Pick<DatabaseTlsFiles, "serverCert" | "serverKey"> {
181
+ const server = newKeyPair();
182
+ const caSubject = new X509Certificate(ca.cert).subject.replace(/^CN=/, "");
183
+ return {
184
+ serverCert: certificatePem({
185
+ subject: SELF_HOSTED_DB_HOST,
186
+ issuer: caSubject,
187
+ publicKey: server.publicKey,
188
+ signingKey: ca.key,
189
+ notBefore: new Date(now.getTime() - 3600 * 1000),
190
+ notAfter: new Date(now.getTime() + SERVER_VALIDITY_YEARS * YEAR_MS),
191
+ ca: false,
192
+ dnsNames: [SELF_HOSTED_DB_HOST],
193
+ }),
194
+ serverKey: server.privateKey.export({ type: "pkcs8", format: "pem" }).toString(),
195
+ };
196
+ }
197
+
198
+ /** A fresh CA and a server certificate it signed. */
199
+ export function generateDatabaseTls(now: Date = new Date()): DatabaseTlsFiles {
200
+ const ca = newKeyPair();
201
+ const caCert = certificatePem({
202
+ subject: "Caelo self-hosted database CA",
203
+ issuer: "Caelo self-hosted database CA",
204
+ publicKey: ca.publicKey,
205
+ signingKey: ca.privateKey,
206
+ notBefore: new Date(now.getTime() - 3600 * 1000),
207
+ notAfter: new Date(now.getTime() + CA_VALIDITY_YEARS * YEAR_MS),
208
+ ca: true,
209
+ });
210
+ return {
211
+ caCert,
212
+ caKey: ca.privateKey.export({ type: "pkcs8", format: "pem" }).toString(),
213
+ ...issueServer({ cert: caCert, key: ca.privateKey }, now),
214
+ };
215
+ }
216
+
217
+ /** Why `serverCert` must be re-issued, or null when it is fine. */
218
+ export function serverCertProblem(caCert: string, serverCert: string, now: Date): string | null {
219
+ try {
220
+ const ca = new X509Certificate(caCert);
221
+ const server = new X509Certificate(serverCert);
222
+ if (!server.verify(ca.publicKey)) return "it is not signed by the install's CA";
223
+ if (server.checkHost(SELF_HOSTED_DB_HOST) !== SELF_HOSTED_DB_HOST) {
224
+ return `it does not name ${SELF_HOSTED_DB_HOST}`;
225
+ }
226
+ if (Date.parse(server.validTo) - now.getTime() < RENEW_BEFORE_MS) {
227
+ return `it expires ${server.validTo}`;
228
+ }
229
+ return null;
230
+ } catch (e) {
231
+ return `it does not parse (${e instanceof Error ? e.message : String(e)})`;
232
+ }
233
+ }
234
+
235
+ /**
236
+ * Make sure `<caeloDir>/postgres-tls/` holds the install's CA and a valid
237
+ * server certificate; returns what it did (empty when nothing changed).
238
+ * The CA is created once and never replaced.
239
+ */
240
+ export function ensureSelfHostedDbTls(caeloDir: string, now: Date = new Date()): string[] {
241
+ const dir = join(caeloDir, SELF_HOSTED_TLS_DIR);
242
+ const path = (f: keyof typeof SELF_HOSTED_TLS_FILES) => join(dir, SELF_HOSTED_TLS_FILES[f]);
243
+ // The CA key and the server key are secrets; the certificates are not,
244
+ // but every container reads them through a bind mount, so 0644.
245
+ const write = (f: keyof typeof SELF_HOSTED_TLS_FILES, content: string) =>
246
+ writeFileSync(path(f), content, { mode: f.endsWith("Key") ? 0o600 : 0o644 });
247
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
248
+ const done: string[] = [];
249
+ if (!existsSync(path("caCert")) || !existsSync(path("caKey"))) {
250
+ if (existsSync(path("caCert")) !== existsSync(path("caKey"))) {
251
+ throw new Error(
252
+ `${dir} holds only half of the database CA (${SELF_HOSTED_TLS_FILES.caCert} / ${SELF_HOSTED_TLS_FILES.caKey}). Restore the missing file from your backup of .caelo/, or delete both to make a new CA (every service is re-created with it on the next \`cms-provision up\`).`,
253
+ );
254
+ }
255
+ const files = generateDatabaseTls(now);
256
+ for (const f of Object.keys(SELF_HOSTED_TLS_FILES) as (keyof typeof SELF_HOSTED_TLS_FILES)[]) {
257
+ write(f, files[f]);
258
+ }
259
+ done.push(
260
+ `created the install's database CA and the server certificate for ${SELF_HOSTED_DB_HOST}`,
261
+ );
262
+ return done;
263
+ }
264
+ const caCert = readFileSync(path("caCert"), "utf8");
265
+ const problem = existsSync(path("serverCert"))
266
+ ? serverCertProblem(caCert, readFileSync(path("serverCert"), "utf8"), now)
267
+ : "it is missing";
268
+ if (problem === null && existsSync(path("serverKey"))) return done;
269
+ const key = createPrivateKey(readFileSync(path("caKey"), "utf8"));
270
+ const issued = issueServer({ cert: caCert, key }, now);
271
+ write("serverCert", issued.serverCert);
272
+ write("serverKey", issued.serverKey);
273
+ done.push(`re-issued the database server certificate (${problem ?? "its key was missing"})`);
274
+ return done;
275
+ }
276
+
277
+ /**
278
+ * SHA-256 (hex) of the install's database server certificate, which the
279
+ * Compose file carries on the Postgres service so a renewed certificate
280
+ * recreates it (compose.ts `dbTlsServerCertSha256`).
281
+ *
282
+ * @throws when the certificate is missing: call {@link ensureSelfHostedDbTls} first.
283
+ */
284
+ export function selfHostedDbTlsServerCertSha256(caeloDir: string): string {
285
+ const cert = readFileSync(join(caeloDir, SELF_HOSTED_TLS_DIR, SELF_HOSTED_TLS_FILES.serverCert));
286
+ return createHash("sha256").update(cert).digest("hex");
287
+ }
@@ -0,0 +1,71 @@
1
+ // SPDX-License-Identifier: MPL-2.0
2
+
3
+ /**
4
+ * #1328 — resolving a self-hosted release: the MCP-Auth-Proxy image is
5
+ * optional only for a release that has none (the registry says its tag does
6
+ * not exist), never because the registry could not be asked.
7
+ */
8
+
9
+ import { describe, expect, it } from "bun:test";
10
+ import { type HostRunner, resolveVerifiedRelease } from "./self-hosted-release.js";
11
+
12
+ const D = (c: string) => `sha256:${c.repeat(64)}`;
13
+
14
+ /** GHCR with a token challenge; `proxy` decides the MCP-Auth-Proxy tag's answer. */
15
+ function ghcr(proxy: "present" | 404 | 503): typeof fetch {
16
+ return (async (input: string | URL | Request, init?: RequestInit) => {
17
+ const url = String(input);
18
+ if (url.startsWith("https://ghcr.io/token")) return Response.json({ token: "t" });
19
+ if (!new Headers(init?.headers).get("authorization")) {
20
+ return new Response(null, {
21
+ status: 401,
22
+ headers: { "www-authenticate": 'Bearer realm="https://ghcr.io/token",service="ghcr.io"' },
23
+ });
24
+ }
25
+ if (url.includes("/caelo-cms/mcp-auth-proxy/")) {
26
+ if (proxy !== "present") return new Response(null, { status: proxy });
27
+ return new Response(null, { status: 200, headers: { "docker-content-digest": D("c") } });
28
+ }
29
+ const digest = url.includes("/caelo-cms/admin/") ? D("a") : D("b");
30
+ return new Response(null, { status: 200, headers: { "docker-content-digest": digest } });
31
+ }) as typeof fetch;
32
+ }
33
+
34
+ function runner(): HostRunner & { calls: string[] } {
35
+ const calls: string[] = [];
36
+ return {
37
+ calls,
38
+ async run(argv) {
39
+ calls.push(argv.join(" "));
40
+ return { exitCode: 0, stdout: "", stderr: "" };
41
+ },
42
+ };
43
+ }
44
+
45
+ describe("resolveVerifiedRelease (#1328)", () => {
46
+ it("resolves and verifies all three images of a release that ships the proxy", async () => {
47
+ const r = runner();
48
+ const res = await resolveVerifiedRelease("0.12.0", { runner: r, fetchImpl: ghcr("present") });
49
+ expect(res.ok).toBe(true);
50
+ if (!res.ok) return;
51
+ expect(res.images["mcp-auth-proxy"]).toBe(`ghcr.io/caelo-cms/mcp-auth-proxy@${D("c")}`);
52
+ expect(r.calls.filter((c) => c.startsWith("cosign verify"))).toHaveLength(3);
53
+ });
54
+
55
+ it("a release from before the proxy (its tag does not exist) resolves without it", async () => {
56
+ const r = runner();
57
+ const res = await resolveVerifiedRelease("0.11.0", { runner: r, fetchImpl: ghcr(404) });
58
+ expect(res.ok).toBe(true);
59
+ if (!res.ok) return;
60
+ expect("mcp-auth-proxy" in res.images).toBe(false);
61
+ expect(res.images.admin).toBe(`ghcr.io/caelo-cms/admin@${D("a")}`);
62
+ expect(r.calls.filter((c) => c.startsWith("cosign verify"))).toHaveLength(2);
63
+ });
64
+
65
+ it("a registry outage on the proxy tag fails instead of installing without it", async () => {
66
+ const res = await resolveVerifiedRelease("0.12.0", { runner: runner(), fetchImpl: ghcr(503) });
67
+ expect(res.ok).toBe(false);
68
+ if (res.ok) return;
69
+ expect(res.error).toContain("mcp-auth-proxy:0.12.0");
70
+ });
71
+ });
@@ -6,8 +6,8 @@
6
6
  *
7
7
  * `.caelo/release.json` is the install metadata for self-hosted (the cloud
8
8
  * providers keep theirs in `~/.caelo-<id>/install.json`): the release
9
- * version, the digest-pinned admin + gateway image references the Compose
10
- * file runs, and a short history of installs/upgrades/rollbacks. It holds no
9
+ * version, the digest-pinned image references the Compose file runs (admin,
10
+ * gateway, and since #1328 the MCP-Auth-Proxy), and a short history of installs/upgrades/rollbacks. It holds no
11
11
  * secrets (config.json does), so it is safe to read from tooling and
12
12
  * backups; writes refuse secret material like install.json's do.
13
13
  *
@@ -27,6 +27,7 @@ import {
27
27
  GHCR_NAMESPACE,
28
28
  GHCR_REGISTRY,
29
29
  RELEASE_SERVICES,
30
+ type ReleaseService,
30
31
  resolveRegistryDigest,
31
32
  } from "./upgrade/release-images.js";
32
33
 
@@ -103,7 +104,12 @@ export interface ReleaseHistoryEntry {
103
104
  export interface SelfHostedRelease {
104
105
  /** The release tag the images were resolved from (e.g. `0.10.43`). */
105
106
  readonly version: string;
106
- readonly images: { readonly admin: string; readonly gateway: string };
107
+ readonly images: {
108
+ readonly admin: string;
109
+ readonly gateway: string;
110
+ /** #1328 — absent in records of releases from before the MCP-Auth-Proxy. */
111
+ readonly "mcp-auth-proxy"?: string;
112
+ };
107
113
  readonly installedAt: string;
108
114
  /** Newest last; capped at {@link HISTORY_LIMIT}. */
109
115
  readonly history: readonly ReleaseHistoryEntry[];
@@ -117,7 +123,17 @@ export function releasePath(caeloDir: string): string {
117
123
 
118
124
  /** The images of a recorded release, as the Compose generator takes them. */
119
125
  export function releaseImages(release: SelfHostedRelease): ReleaseImages {
120
- return { mode: "release", admin: release.images.admin, gateway: release.images.gateway };
126
+ return { mode: "release", ...release.images };
127
+ }
128
+
129
+ /** The image references of `images`, as release.json stores them. */
130
+ function recordedImages(images: ReleaseImages): SelfHostedRelease["images"] {
131
+ const proxy = images["mcp-auth-proxy"];
132
+ return {
133
+ admin: images.admin,
134
+ gateway: images.gateway,
135
+ ...(proxy !== undefined ? { "mcp-auth-proxy": proxy } : {}),
136
+ };
121
137
  }
122
138
 
123
139
  /**
@@ -131,10 +147,12 @@ export function readRelease(caeloDir: string): SelfHostedRelease | null {
131
147
  const raw = JSON.parse(readFileSync(path, "utf8")) as Partial<SelfHostedRelease>;
132
148
  const admin = raw.images?.admin ?? "";
133
149
  const gateway = raw.images?.gateway ?? "";
150
+ const proxy = raw.images?.["mcp-auth-proxy"];
134
151
  if (
135
152
  typeof raw.version !== "string" ||
136
153
  !ghcrImageDigest("admin", admin) ||
137
- !ghcrImageDigest("gateway", gateway)
154
+ !ghcrImageDigest("gateway", gateway) ||
155
+ (proxy !== undefined && !ghcrImageDigest("mcp-auth-proxy", proxy))
138
156
  ) {
139
157
  throw new Error(
140
158
  `${path} is malformed (needs version + digest-pinned ghcr.io/caelo-cms images); fix it or re-run \`${selfCommand("upgrade", "--version", "<x.y.z>")}\`.`,
@@ -142,7 +160,7 @@ export function readRelease(caeloDir: string): SelfHostedRelease | null {
142
160
  }
143
161
  return {
144
162
  version: raw.version,
145
- images: { admin, gateway },
163
+ images: { admin, gateway, ...(proxy !== undefined ? { "mcp-auth-proxy": proxy } : {}) },
146
164
  installedAt: raw.installedAt ?? "",
147
165
  history: Array.isArray(raw.history) ? raw.history : [],
148
166
  };
@@ -162,7 +180,7 @@ export function recordRelease(
162
180
  const at = now.toISOString();
163
181
  const record: SelfHostedRelease = {
164
182
  version: next.version,
165
- images: { admin: next.images.admin, gateway: next.images.gateway },
183
+ images: recordedImages(next.images),
166
184
  installedAt:
167
185
  prior && prior.version === next.version && entry.outcome === "rolled-back"
168
186
  ? prior.installedAt
@@ -195,7 +213,7 @@ export type VerifiedRelease =
195
213
  | { readonly ok: false; readonly error: string };
196
214
 
197
215
  /**
198
- * Resolve the admin + gateway images of release `tag` in GHCR to digests,
216
+ * Resolve every release image of `tag` (RELEASE_SERVICES) in GHCR to digests,
199
217
  * then cosign-verify each digest against the Caelo release workflow
200
218
  * identity (unless `skipVerify`, the escape hatch for forks). The returned
201
219
  * references are digest-pinned, so what was verified is exactly what runs.
@@ -209,7 +227,7 @@ export async function resolveVerifiedRelease(
209
227
  readonly onProgress?: (message: string) => void;
210
228
  },
211
229
  ): Promise<VerifiedRelease> {
212
- const refs: Partial<Record<"admin" | "gateway", string>> = {};
230
+ const refs: Partial<Record<ReleaseService, string>> = {};
213
231
  for (const service of RELEASE_SERVICES) {
214
232
  deps.onProgress?.(`Resolving ${GHCR_REGISTRY}/${GHCR_NAMESPACE}/${service}:${tag}`);
215
233
  const r = await resolveRegistryDigest(
@@ -218,6 +236,14 @@ export async function resolveVerifiedRelease(
218
236
  tag,
219
237
  deps.fetchImpl,
220
238
  );
239
+ if (!r.ok && r.notFound && service === "mcp-auth-proxy") {
240
+ // #1328 — releases from before the MCP-Auth-Proxy ship no image of it,
241
+ // and they stay installable and rollback targets: their Compose file
242
+ // simply has no proxy (ReleaseImages models the absence). Only a
243
+ // registry verdict that the tag does not exist means that; an outage
244
+ // still fails below, never silently installs a release without it.
245
+ continue;
246
+ }
221
247
  if (!r.ok) {
222
248
  return {
223
249
  ok: false,
@@ -228,10 +254,12 @@ export async function resolveVerifiedRelease(
228
254
  }
229
255
  refs[service] = ghcrImageRef(service, r.digest);
230
256
  }
257
+ const proxy = refs["mcp-auth-proxy"];
231
258
  const images: ReleaseImages = {
232
259
  mode: "release",
233
260
  admin: refs.admin ?? "",
234
261
  gateway: refs.gateway ?? "",
262
+ ...(proxy ? { "mcp-auth-proxy": proxy } : {}),
235
263
  };
236
264
  if (deps.skipVerify) return { ok: true, images };
237
265
 
@@ -246,13 +274,15 @@ export async function resolveVerifiedRelease(
246
274
  };
247
275
  }
248
276
  for (const service of RELEASE_SERVICES) {
277
+ const ref = images[service];
278
+ if (ref === undefined) continue;
249
279
  deps.onProgress?.(`Verifying the signature of ${service}`);
250
- const v = await deps.runner.run(cosignVerifyArgv(images[service]));
280
+ const v = await deps.runner.run(cosignVerifyArgv(ref));
251
281
  if (v.exitCode !== 0) {
252
282
  return {
253
283
  ok: false,
254
284
  error:
255
- `cosign verify FAILED for ${images[service]}: its signature does not match the Caelo release workflow.\n` +
285
+ `cosign verify FAILED for ${ref}: its signature does not match the Caelo release workflow.\n` +
256
286
  "Either the registry was compromised, the image is a fork's, or sigstore had an outage (retry).\n" +
257
287
  `cosign: ${v.stderr.trim().slice(0, 500)}`,
258
288
  };
@@ -1,9 +1,14 @@
1
1
  // SPDX-License-Identifier: MPL-2.0
2
2
 
3
3
  import { describe, expect, it } from "bun:test";
4
+ import { readFileSync } from "node:fs";
5
+ import { resolve } from "node:path";
4
6
  import {
7
+ applyPublicRoleLimits,
5
8
  convergeSelfHostedRoles,
6
9
  decideUp,
10
+ PUBLIC_ROLE_LIMITS,
11
+ publicRoleLimitsScript,
7
12
  roleConvergenceScript,
8
13
  selfHostedBootstrapScript,
9
14
  } from "./self-hosted-roles.js";
@@ -126,3 +131,52 @@ describe("decideUp — `cms-provision up` never switches URLs onto unapplied pas
126
131
  expect(d).toMatchObject({ kind: "proceed", markConverged: false });
127
132
  });
128
133
  });
134
+
135
+ describe("public_role's limits (#1338, H3)", () => {
136
+ const statements = [
137
+ `ALTER ROLE public_role CONNECTION LIMIT ${PUBLIC_ROLE_LIMITS.connectionLimit};`,
138
+ `ALTER ROLE public_role SET statement_timeout = '${PUBLIC_ROLE_LIMITS.statementTimeout}';`,
139
+ `ALTER ROLE public_role SET idle_in_transaction_session_timeout = '${PUBLIC_ROLE_LIMITS.idleInTransactionSessionTimeout}';`,
140
+ `ALTER ROLE public_role SET work_mem = '${PUBLIC_ROLE_LIMITS.workMem}';`,
141
+ ];
142
+
143
+ it("are set on the first start, on every `up`, and alone before an upgrade's migrations", () => {
144
+ for (const script of [
145
+ selfHostedBootstrapScript({ agent: false }),
146
+ roleConvergenceScript(PASSWORDS),
147
+ publicRoleLimitsScript(),
148
+ ]) {
149
+ for (const st of statements) expect(script).toContain(st);
150
+ }
151
+ // The pre-migration script touches nothing else (no role drop, no grant).
152
+ expect(publicRoleLimitsScript().split("\n").filter(Boolean)).toEqual([
153
+ "\\set ON_ERROR_STOP on",
154
+ ...statements,
155
+ ]);
156
+ });
157
+
158
+ it("match the values packages/migrations/src/bootstrap.sh sets (dev, CI, Cloud SQL bootstrap)", () => {
159
+ const bootstrap = readFileSync(
160
+ resolve(import.meta.dir, "../../migrations/src/bootstrap.sh"),
161
+ "utf8",
162
+ );
163
+ for (const st of statements) expect(bootstrap).toContain(st);
164
+ });
165
+
166
+ it("refuse to run against a stopped database", async () => {
167
+ let ran = false;
168
+ const r = await applyPublicRoleLimits({
169
+ probe: async () => false,
170
+ psql: async () => {
171
+ ran = true;
172
+ return { exitCode: 0, stderr: "" };
173
+ },
174
+ });
175
+ expect(r).toEqual({
176
+ ok: false,
177
+ reachable: false,
178
+ error: "the Postgres container is not running",
179
+ });
180
+ expect(ran).toBe(false);
181
+ });
182
+ });
@@ -38,6 +38,46 @@ export const AGENT_DB_ROLE = "caelo_agent";
38
38
  const APP_ROLES = ["admin_role", "public_role"] as const;
39
39
  type AppRole = (typeof APP_ROLES)[number];
40
40
 
41
+ /**
42
+ * #1338 (gateway hardening H3) — what bounds public_role, the gateway's
43
+ * login, on the database: at most this many connections, no statement or
44
+ * idle transaction that runs away, little sort memory per query. The same
45
+ * values as packages/migrations/src/bootstrap.sh and the cms_admin
46
+ * migration that sets them where admin_role may (Cloud SQL); the role audit
47
+ * (packages/migrations role-audit.ts) refuses an install without them.
48
+ * Self-hosted admin_role has no CREATEROLE, so the superuser applies them
49
+ * here — on the volume's first start (bootstrap.sh), on every `up`, and
50
+ * before the migrations of every upgrade ({@link publicRoleLimitsScript}).
51
+ */
52
+ export const PUBLIC_ROLE_LIMITS = {
53
+ connectionLimit: 30,
54
+ statementTimeout: "5s",
55
+ idleInTransactionSessionTimeout: "15s",
56
+ workMem: "1MB",
57
+ } as const;
58
+
59
+ /** The statements that apply {@link PUBLIC_ROLE_LIMITS} (idempotent). */
60
+ function publicRoleLimitsSql(): string[] {
61
+ const l = PUBLIC_ROLE_LIMITS;
62
+ return [
63
+ `ALTER ROLE public_role CONNECTION LIMIT ${l.connectionLimit};`,
64
+ `ALTER ROLE public_role SET statement_timeout = '${l.statementTimeout}';`,
65
+ `ALTER ROLE public_role SET idle_in_transaction_session_timeout = '${l.idleInTransactionSessionTimeout}';`,
66
+ `ALTER ROLE public_role SET work_mem = '${l.workMem}';`,
67
+ ];
68
+ }
69
+
70
+ /**
71
+ * The psql script (superuser) that applies only {@link PUBLIC_ROLE_LIMITS}.
72
+ * The self-hosted upgrade runs it BEFORE the migrations: their role audit
73
+ * refuses public_role without the limits, and the full convergence must
74
+ * wait until after them (it drops roles a migration retired). Fails when
75
+ * public_role does not exist — the install's roles were never created.
76
+ */
77
+ export function publicRoleLimitsScript(): string {
78
+ return ["\\set ON_ERROR_STOP on", ...publicRoleLimitsSql(), ""].join("\n");
79
+ }
80
+
41
81
  /** How a role's password appears in the SQL: a literal, or a psql variable. */
42
82
  type PasswordSql = (role: AppRole | typeof AGENT_DB_ROLE) => string;
43
83
 
@@ -52,6 +92,7 @@ function convergenceSql(password: PasswordSql, agent: boolean): string {
52
92
  `SELECT 'CREATE ROLE ${role} NOINHERIT LOGIN' WHERE NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = '${role}') \\gexec`,
53
93
  `ALTER ROLE ${role} WITH LOGIN PASSWORD ${password(role)};`,
54
94
  ]),
95
+ ...publicRoleLimitsSql(),
55
96
  // The upgrader's role (migration 0267 creates it only with CREATEROLE,
56
97
  // which the self-hosted admin_role lacks).
57
98
  "SELECT 'CREATE ROLE platform_updater NOLOGIN' WHERE NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'platform_updater') \\gexec",
@@ -143,6 +184,23 @@ ${convergenceSql((role) => `:'${role}_pw'`, opts.agent)}CAELO_SQL
143
184
  /** Runs a psql script as the superuser; resolves to psql's exit code + stderr. */
144
185
  export type SuperuserPsql = (script: string) => Promise<{ exitCode: number; stderr: string }>;
145
186
 
187
+ /**
188
+ * Apply {@link publicRoleLimitsScript} through the running Postgres
189
+ * container. Unlike {@link convergeSelfHostedRoles} a stopped database is a
190
+ * failure: the upgrade runs it right before migrations that need it.
191
+ */
192
+ export async function applyPublicRoleLimits(deps: {
193
+ readonly probe: () => Promise<boolean>;
194
+ readonly psql: SuperuserPsql;
195
+ }): Promise<ConvergeOutcome> {
196
+ if (!(await deps.probe())) {
197
+ return { ok: false, reachable: false, error: "the Postgres container is not running" };
198
+ }
199
+ const r = await deps.psql(publicRoleLimitsScript());
200
+ if (r.exitCode === 0) return { ok: true };
201
+ return { ok: false, reachable: true, error: r.stderr.trim() || `psql exited ${r.exitCode}` };
202
+ }
203
+
146
204
  export type ConvergeOutcome =
147
205
  | { readonly ok: true }
148
206
  /** `reachable: false` — the database is not running, nothing was attempted. */