@caelo-cms/provisioning 0.10.52 → 0.10.53

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 (85) hide show
  1. package/dist/agent/command.js +1 -0
  2. package/dist/agent/command.js.map +1 -1
  3. package/dist/agent-db.js +9 -0
  4. package/dist/aws-task-roles.d.ts +1 -1
  5. package/dist/aws-task-roles.d.ts.map +1 -1
  6. package/dist/aws-task-roles.js +0 -1
  7. package/dist/aws-task-roles.js.map +1 -1
  8. package/dist/cli-routing.d.ts +20 -0
  9. package/dist/cli-routing.d.ts.map +1 -1
  10. package/dist/cli-routing.js +106 -0
  11. package/dist/cli-routing.js.map +1 -1
  12. package/dist/cli.js +5 -6
  13. package/dist/cli.js.map +1 -1
  14. package/dist/compose.d.ts +4 -3
  15. package/dist/compose.d.ts.map +1 -1
  16. package/dist/compose.js +3 -2
  17. package/dist/compose.js.map +1 -1
  18. package/dist/gateway-credentials.d.ts +3 -5
  19. package/dist/gateway-credentials.d.ts.map +1 -1
  20. package/dist/gateway-credentials.js +8 -18
  21. package/dist/gateway-credentials.js.map +1 -1
  22. package/dist/infra-contract.d.ts +1 -1
  23. package/dist/infra-contract.d.ts.map +1 -1
  24. package/dist/infra-contract.js +8 -1
  25. package/dist/infra-contract.js.map +1 -1
  26. package/dist/runtime-secrets.d.ts +1 -3
  27. package/dist/runtime-secrets.d.ts.map +1 -1
  28. package/dist/runtime-secrets.js +2 -6
  29. package/dist/runtime-secrets.js.map +1 -1
  30. package/dist/self-hosted-roles.d.ts +9 -8
  31. package/dist/self-hosted-roles.d.ts.map +1 -1
  32. package/dist/self-hosted-roles.js +9 -9
  33. package/dist/self-hosted-roles.js.map +1 -1
  34. package/dist/self-hosted-upgrade.d.ts +7 -1
  35. package/dist/self-hosted-upgrade.d.ts.map +1 -1
  36. package/dist/self-hosted-upgrade.js +17 -4
  37. package/dist/self-hosted-upgrade.js.map +1 -1
  38. package/dist/stack-contract.d.ts +52 -3
  39. package/dist/stack-contract.d.ts.map +1 -1
  40. package/dist/stack-contract.js +36 -36
  41. package/dist/stack-contract.js.map +1 -1
  42. package/dist/upgrade/converge.d.ts.map +1 -1
  43. package/dist/upgrade/converge.js +5 -13
  44. package/dist/upgrade/converge.js.map +1 -1
  45. package/dist/wizards/gcp.d.ts.map +1 -1
  46. package/dist/wizards/gcp.js +1 -30
  47. package/dist/wizards/gcp.js.map +1 -1
  48. package/package.json +2 -2
  49. package/src/admin-memory.test.ts +1 -1
  50. package/src/agent/command.ts +1 -0
  51. package/src/aws-task-roles.ts +0 -2
  52. package/src/caddy-redirects.test.ts +1 -1
  53. package/src/checkpoint-secrets.test.ts +0 -2
  54. package/src/cli-routing.test.ts +27 -1
  55. package/src/cli-routing.ts +131 -0
  56. package/src/cli.ts +5 -8
  57. package/src/compose.ts +13 -5
  58. package/src/gateway-credentials.test.ts +0 -29
  59. package/src/gateway-credentials.ts +11 -25
  60. package/src/gateway-env-isolation.test.ts +7 -2
  61. package/src/index.test.ts +7 -6
  62. package/src/infra-contract.test.ts +4 -3
  63. package/src/infra-contract.ts +8 -1
  64. package/src/runtime-secrets.test.ts +9 -81
  65. package/src/runtime-secrets.ts +2 -6
  66. package/src/self-hosted-compose.test.ts +1 -1
  67. package/src/self-hosted-roles.integration.test.ts +23 -38
  68. package/src/self-hosted-roles.test.ts +11 -7
  69. package/src/self-hosted-roles.ts +17 -16
  70. package/src/self-hosted-upgrade.test.ts +28 -1
  71. package/src/self-hosted-upgrade.ts +29 -4
  72. package/src/stack-contract.test.ts +15 -11
  73. package/src/stack-contract.ts +40 -5
  74. package/src/stack-converge-secrets.test.ts +2 -0
  75. package/src/stack-converge.test.ts +2 -0
  76. package/src/upgrade/converge.ts +5 -19
  77. package/src/wizards/gcp.ts +1 -31
  78. package/stacks/aws/README.md +1 -1
  79. package/stacks/aws/index.ts +1 -6
  80. package/stacks/azure/README.md +1 -1
  81. package/stacks/azure/index.ts +9 -6
  82. package/stacks/gcp/README.md +1 -1
  83. package/stacks/gcp/index.ts +1 -4
  84. package/stacks/gcp-firebase/index.ts +1 -4
  85. package/stacks/self-hosted/index.ts +4 -11
@@ -2,7 +2,6 @@
2
2
 
3
3
  import { describe, expect, it } from "bun:test";
4
4
  import {
5
- ensureGatewayRolePassword,
6
5
  findSqlInstance,
7
6
  gatewayMovesPublicRole,
8
7
  planPublicRoleSwitch,
@@ -52,7 +51,6 @@ const target = { projectId: "acme", env: "production", sqlInstance: "caelo-produ
52
51
  const SECRETS = {
53
52
  "postgres-password": "admin-pw",
54
53
  "public-role-password": "public-pw",
55
- "gateway-role-password": "gateway-pw",
56
54
  };
57
55
  const secretRef = (secret: string): LiveEnvValue => ({
58
56
  kind: "secret",
@@ -66,7 +64,6 @@ const BEFORE_613 = new Map<string, LiveEnvValue>([
66
64
  ]);
67
65
  const AFTER_613 = new Map<string, LiveEnvValue>([
68
66
  ["PUBLIC_DATABASE_PASSWORD", secretRef("public-role-password")],
69
- ["GATEWAY_DATABASE_PASSWORD", secretRef("gateway-role-password")],
70
67
  ]);
71
68
 
72
69
  describe("gatewayMovesPublicRole", () => {
@@ -85,32 +82,6 @@ describe("gatewayMovesPublicRole", () => {
85
82
  });
86
83
  });
87
84
 
88
- describe("ensureGatewayRolePassword", () => {
89
- it("sets gateway_role from its secret through the API, never on argv", async () => {
90
- const { run, lines } = fakeGcloud(SECRETS);
91
- const sql = fakeSqlApi();
92
- const r = await ensureGatewayRolePassword(target, {
93
- run,
94
- http: sql.http,
95
- sleep: async () => {},
96
- });
97
- expect(r.status).toBe("applied");
98
- expect(sql.set).toEqual(["gateway_role gateway-pw"]);
99
- expect(lines.some((l) => l.includes("gateway-pw"))).toBe(false);
100
- });
101
-
102
- it("fails, naming the secret, when the secret can't be read", async () => {
103
- const { run } = fakeGcloud({});
104
- const r = await ensureGatewayRolePassword(target, {
105
- run,
106
- http: fakeSqlApi().http,
107
- sleep: async () => {},
108
- });
109
- expect(r.status).toBe("failed");
110
- expect(r.error).toContain("caelo-production-gateway-role-password");
111
- });
112
- });
113
-
114
85
  describe("planPublicRoleSwitch", () => {
115
86
  it("first upgrade: moves public_role onto its own password before the gateway rolls", async () => {
116
87
  const { run } = fakeGcloud(SECRETS);
@@ -2,28 +2,22 @@
2
2
 
3
3
  /**
4
4
  * Moving an existing install's API gateway off admin_role (issue #613),
5
- * as `cms-provision upgrade` does it. The env side — the per-role password
6
- * secrets in, ADMIN_DATABASE_* out (and, since #635, GATEWAY_DATABASE_* out
7
- * too: the gateway has no cms_admin login at all) — rides the gateway's
8
- * roll (stack-contract.ts). gateway_role keeps getting its password until
9
- * the contract release drops the role, because the previous release's
10
- * gateway logs in with it until it is rolled. The database side is ordered around
11
- * that roll so /api/* keeps working:
5
+ * as `cms-provision upgrade` does it. The env side — public_role's own
6
+ * password secret in, every cms_admin credential out (#613, #635) — rides
7
+ * the gateway's roll (stack-contract.ts). The database side is ordered
8
+ * around that roll so /api/* keeps working:
12
9
  *
13
- * 1. after migrations (which create `gateway_role` without a password):
14
- * `gateway_role` gets its password. Nothing uses the role yet, so this
15
- * is safe at any point before the gateway rolls.
16
- * 2. right before the gateway rolls: `public_role` moves off admin_role's
10
+ * 1. right before the gateway rolls: `public_role` moves off admin_role's
17
11
  * password onto its own. Only the gateway logs in as public_role, so
18
12
  * the window in which the old revision cannot open NEW public
19
13
  * connections is the roll itself (open connections stay up).
20
- * 3. if the gateway's roll fails and it is rolled back: `public_role`
14
+ * 2. if the gateway's roll fails and it is rolled back: `public_role`
21
15
  * goes back to the password the prior revision reads, so the rollback
22
16
  * leaves a working gateway, not a locked-out one.
23
17
  *
24
18
  * On every later upgrade the gateway already reads public_role's own
25
- * secret: step 2 then re-applies the same value (a no-op for clients) and
26
- * step 3 has nothing to undo.
19
+ * secret: step 1 then re-applies the same value (a no-op for clients) and
20
+ * step 2 has nothing to undo.
27
21
  */
28
22
 
29
23
  import { gcloud as defaultGcloud } from "./gcloud.js";
@@ -60,22 +54,14 @@ export interface GatewayCredentialDeps {
60
54
  readonly http?: HttpFetch;
61
55
  }
62
56
 
63
- /** Step 1: `gateway_role`'s password from its secret. */
64
- export function ensureGatewayRolePassword(
65
- target: DatabaseRoleTarget,
66
- deps: GatewayCredentialDeps = {},
67
- ): Promise<EnsureOutcome> {
68
- return ensureDatabaseRolePassword(target, "gateway_role", deps);
69
- }
70
-
71
- /** Steps 2 and 3 for one roll of the gateway. */
57
+ /** Steps 1 and 2 for one roll of the gateway. */
72
58
  export interface PublicRoleSwitch {
73
59
  /** Whether this roll moves public_role off admin_role's password. */
74
60
  readonly moves: boolean;
75
- /** Step 2 — run right before the gateway's `services update`. */
61
+ /** Step 1 — run right before the gateway's `services update`. */
76
62
  beforeGatewayRoll(): Promise<EnsureOutcome>;
77
63
  /**
78
- * Step 3 — run when the gateway's roll failed and it goes back to its
64
+ * Step 2 — run when the gateway's roll failed and it goes back to its
79
65
  * prior revision. Returns what happened, for the operator.
80
66
  */
81
67
  afterGatewayRollFailed(): Promise<string>;
@@ -126,7 +126,7 @@ describe("self-hosted: the generated compose gateway service (#635)", () => {
126
126
  const out = generateDockerCompose({
127
127
  domain: "example.com",
128
128
  postgresPassword: "superpw",
129
- rolePasswords: { admin: "adminpw", public: "publicpw", gateway: "gatewaypw" },
129
+ rolePasswords: { admin: "adminpw", public: "publicpw" },
130
130
  caeloSecretKek: "0".repeat(64),
131
131
  diskSize: "20Gi",
132
132
  images: {
@@ -148,7 +148,12 @@ describe("self-hosted: the generated compose gateway service (#635)", () => {
148
148
  expect(gateway).not.toMatch(/^ {4}(secrets|env_file|volumes):/m);
149
149
  const env = gateway.slice(gateway.indexOf(" environment:"));
150
150
  const keys = [...env.matchAll(/^ {6}([A-Z_][A-Z0-9_]*):/gm)].map((m) => m[1]);
151
- expect(keys.sort()).toEqual(["GATEWAY_PORT", "NODE_ENV", "PUBLIC_DATABASE_URL"]);
151
+ expect(keys.sort()).toEqual([
152
+ "CAELO_TRUSTED_PROXY_HOPS",
153
+ "GATEWAY_PORT",
154
+ "NODE_ENV",
155
+ "PUBLIC_DATABASE_URL",
156
+ ]);
152
157
  expect(out).not.toMatch(/^secrets:/m);
153
158
  });
154
159
  });
package/src/index.test.ts CHANGED
@@ -115,7 +115,7 @@ describe("docker-compose generator", () => {
115
115
  const out = generateDockerCompose({
116
116
  domain: "example.com",
117
117
  postgresPassword: "supersecret",
118
- rolePasswords: { admin: "adminpw", public: "publicpw", gateway: "gatewaypw" },
118
+ rolePasswords: { admin: "adminpw", public: "publicpw" },
119
119
  caeloSecretKek: "0".repeat(64),
120
120
  anthropicApiKey: "sk-ant-…",
121
121
  diskSize: "20Gi",
@@ -136,7 +136,7 @@ describe("docker-compose generator", () => {
136
136
  const out = generateDockerCompose({
137
137
  domain: "example.com",
138
138
  postgresPassword: "supersecret",
139
- rolePasswords: { admin: "adminpw", public: "publicpw", gateway: "gatewaypw" },
139
+ rolePasswords: { admin: "adminpw", public: "publicpw" },
140
140
  caeloSecretKek: "0".repeat(64),
141
141
  diskSize: "20Gi",
142
142
  images: RELEASE_IMAGES,
@@ -150,16 +150,17 @@ describe("docker-compose generator", () => {
150
150
  expect(gateway).not.toMatch(
151
151
  /ADMIN_DATABASE|GATEWAY_DATABASE|cms_admin|admin_role|gateway_role|adminpw|gatewaypw|supersecret|CAELO_SECRET_KEK/,
152
152
  );
153
- // bootstrap.sh still creates the three roles from these (gateway_role
154
- // until the contract release drops it).
155
- expect(out).toContain('GATEWAY_ROLE_PASSWORD: "gatewaypw"');
153
+ // bootstrap.sh creates the two application roles from these (#635: no
154
+ // gateway_role).
155
+ expect(out).toContain('PUBLIC_ROLE_PASSWORD: "publicpw"');
156
+ expect(out).not.toContain("GATEWAY_ROLE_PASSWORD");
156
157
  });
157
158
 
158
159
  it("escapes secrets containing quotes safely", () => {
159
160
  const out = generateDockerCompose({
160
161
  domain: "example.com",
161
162
  postgresPassword: 'pa"ss',
162
- rolePasswords: { admin: "a", public: "b", gateway: "g" },
163
+ rolePasswords: { admin: "a", public: "b" },
163
164
  caeloSecretKek: "0".repeat(64),
164
165
  diskSize: "1Gi",
165
166
  images: RELEASE_IMAGES,
@@ -20,12 +20,13 @@ describe("infrastructure contract steps", () => {
20
20
  );
21
21
  });
22
22
 
23
- it("is at 6: public_role revoke (3), URL-map redirects role (4), no gateway cms_admin login (5), ops-bucket list (6)", () => {
24
- expect(INFRA_CONTRACT_VERSION).toBe(6);
23
+ it("is at 7: public_role revoke (3), URL-map redirects role (4), no gateway cms_admin login (5), ops-bucket list (6), gateway proxy hops (7)", () => {
24
+ expect(INFRA_CONTRACT_VERSION).toBe(7);
25
25
  expect(INFRA_CONTRACT_STEPS[3]).toContain("#633");
26
26
  expect(INFRA_CONTRACT_STEPS[4]).toContain("#587");
27
27
  expect(INFRA_CONTRACT_STEPS[5]).toContain("#635");
28
28
  expect(INFRA_CONTRACT_STEPS[6]).toContain("#799");
29
+ expect(INFRA_CONTRACT_STEPS[7]).toContain("#845");
29
30
  });
30
31
 
31
32
  it("lists the pending steps oldest first", () => {
@@ -36,6 +37,6 @@ describe("infrastructure contract steps", () => {
36
37
  });
37
38
 
38
39
  it("names a version a newer release added by number", () => {
39
- expect(infraStepsText(6, 7)).toBe("infrastructure step 7 (see that release's notes)");
40
+ expect(infraStepsText(7, 8)).toBe("infrastructure step 8 (see that release's notes)");
40
41
  });
41
42
  });
@@ -22,7 +22,7 @@
22
22
  * whose app-roll references infrastructure an install does not have yet fails
23
23
  * at roll time, after migrations ran.
24
24
  */
25
- export const INFRA_CONTRACT_VERSION = 6;
25
+ export const INFRA_CONTRACT_VERSION = 7;
26
26
 
27
27
  // History (why each bump happened — an install below a number needs the
28
28
  // converge that introduced it):
@@ -45,6 +45,12 @@ export const INFRA_CONTRACT_VERSION = 6;
45
45
  // into the bucket lists it first; without the binding the in-cloud
46
46
  // upgrader fails at its state lock, so it must not run before the
47
47
  // converge grants it.
48
+ // 7 — #845: the gateway refuses to boot without CAELO_TRUSTED_PROXY_HOPS
49
+ // (which X-Forwarded-For entry is the visitor). A plain env var, but
50
+ // the in-cloud upgrader rolls with the env contract of the release it
51
+ // runs from (the installed one), which does not set it — so the new
52
+ // gateway would not start. The operator-identity upgrade runs the new
53
+ // release's contract and sets it.
48
54
 
49
55
  /**
50
56
  * What each contract version adds, in operator terms — the upgrader's
@@ -58,6 +64,7 @@ export const INFRA_CONTRACT_STEPS: Readonly<Record<number, string>> = {
58
64
  4: "the admin's permission to serve redirects from the load balancer (#587)",
59
65
  5: "the gateway's cms_admin login removed from its env and IAM (#635)",
60
66
  6: "the upgrader's permission to list its ops bucket (#799)",
67
+ 7: "the gateway's trusted proxy hop count, CAELO_TRUSTED_PROXY_HOPS (#845)",
61
68
  };
62
69
 
63
70
  /**
@@ -115,13 +115,7 @@ describe("ensureGeneratedSecrets", () => {
115
115
  { ...secretsInstall, replication: regional },
116
116
  { run },
117
117
  );
118
- expect(outcomes.map((o) => o.status)).toEqual([
119
- "present",
120
- "present",
121
- "present",
122
- "present",
123
- "present",
124
- ]);
118
+ expect(outcomes.map((o) => o.status)).toEqual(["present", "present", "present", "present"]);
125
119
  expect(lines().some((l) => l.includes(" create ") || l.includes(" add "))).toBe(false);
126
120
  });
127
121
 
@@ -132,13 +126,7 @@ describe("ensureGeneratedSecrets", () => {
132
126
  { ...secretsInstall, replication: regional },
133
127
  { run, generate: () => `generated-value-${++n}` },
134
128
  );
135
- expect(outcomes.map((o) => o.status)).toEqual([
136
- "applied",
137
- "applied",
138
- "applied",
139
- "applied",
140
- "applied",
141
- ]);
129
+ expect(outcomes.map((o) => o.status)).toEqual(["applied", "applied", "applied", "applied"]);
142
130
  const creates = calls.filter((c) => c.line.startsWith("secrets create"));
143
131
  expect(creates).toEqual([
144
132
  {
@@ -149,19 +137,15 @@ describe("ensureGeneratedSecrets", () => {
149
137
  line: "secrets create caelo-production-tool-approval-secret --project=acme --replication-policy=user-managed --locations=europe-west1 --data-file=- --quiet",
150
138
  stdin: "generated-value-2",
151
139
  },
152
- // #613 — the gateway's role passwords: fresh values, never admin_role's.
140
+ // #613 — the gateway's role password: a fresh value, never admin_role's.
153
141
  {
154
142
  line: "secrets create caelo-production-public-role-password --project=acme --replication-policy=user-managed --locations=europe-west1 --data-file=- --quiet",
155
143
  stdin: "generated-value-3",
156
144
  },
157
- {
158
- line: "secrets create caelo-production-gateway-role-password --project=acme --replication-policy=user-managed --locations=europe-west1 --data-file=- --quiet",
159
- stdin: "generated-value-4",
160
- },
161
145
  // #650 — the staging access gate's key.
162
146
  {
163
147
  line: "secrets create caelo-production-staging-gate-secret --project=acme --replication-policy=user-managed --locations=europe-west1 --data-file=- --quiet",
164
- stdin: "generated-value-5",
148
+ stdin: "generated-value-4",
165
149
  },
166
150
  ]);
167
151
  for (const c of calls) expect(c.line).not.toContain("generated-value");
@@ -186,7 +170,6 @@ describe("ensureGeneratedSecrets", () => {
186
170
  "generated",
187
171
  "generated",
188
172
  "generated",
189
- "generated",
190
173
  ]);
191
174
  for (const c of calls) expect(c.line).not.toContain("operator-set");
192
175
  });
@@ -197,20 +180,13 @@ describe("ensureGeneratedSecrets", () => {
197
180
  "secrets versions list caelo-production-internal-secret": [ok("")],
198
181
  "secrets versions list caelo-production-tool-approval-secret": [ok("v/1")],
199
182
  "secrets versions list caelo-production-public-role-password": [ok("v/1")],
200
- "secrets versions list caelo-production-gateway-role-password": [ok("v/1")],
201
183
  "secrets versions list caelo-production-staging-gate-secret": [ok("v/1")],
202
184
  });
203
185
  const outcomes = await ensureGeneratedSecrets(
204
186
  { ...secretsInstall, replication: { kind: "automatic" } },
205
187
  { run, generate: () => "v" },
206
188
  );
207
- expect(outcomes.map((o) => o.status)).toEqual([
208
- "applied",
209
- "present",
210
- "present",
211
- "present",
212
- "present",
213
- ]);
189
+ expect(outcomes.map((o) => o.status)).toEqual(["applied", "present", "present", "present"]);
214
190
  expect(calls.filter((c) => c.line.startsWith("secrets versions add"))).toEqual([
215
191
  {
216
192
  line: "secrets versions add caelo-production-internal-secret --project=acme --data-file=-",
@@ -244,13 +220,7 @@ describe("ensureGeneratedSecrets", () => {
244
220
  { ...secretsInstall, replication: { kind: "automatic" } },
245
221
  { run, generate: () => "v" },
246
222
  );
247
- expect(outcomes.map((o) => o.status)).toEqual([
248
- "applied",
249
- "applied",
250
- "applied",
251
- "applied",
252
- "applied",
253
- ]);
223
+ expect(outcomes.map((o) => o.status)).toEqual(["applied", "applied", "applied", "applied"]);
254
224
  expect(outcomes.every((o) => o.detail?.includes("gcp.resourceLocations"))).toBe(true);
255
225
  const creates = lines().filter((l) => l.startsWith("secrets create"));
256
226
  // One refused automatic attempt; then every secret — the refused one
@@ -260,7 +230,6 @@ describe("ensureGeneratedSecrets", () => {
260
230
  "secrets create caelo-production-internal-secret --project=acme --replication-policy=user-managed --locations=europe-west1 --data-file=- --quiet",
261
231
  "secrets create caelo-production-tool-approval-secret --project=acme --replication-policy=user-managed --locations=europe-west1 --data-file=- --quiet",
262
232
  "secrets create caelo-production-public-role-password --project=acme --replication-policy=user-managed --locations=europe-west1 --data-file=- --quiet",
263
- "secrets create caelo-production-gateway-role-password --project=acme --replication-policy=user-managed --locations=europe-west1 --data-file=- --quiet",
264
233
  // #652 — the staging gate's key follows the same path.
265
234
  "secrets create caelo-production-staging-gate-secret --project=acme --replication-policy=user-managed --locations=europe-west1 --data-file=- --quiet",
266
235
  ]);
@@ -290,7 +259,7 @@ describe("ensureGeneratedSecrets", () => {
290
259
  { run, generate: () => "v" },
291
260
  );
292
261
  expect(outcomes.every((o) => o.status === "failed")).toBe(true);
293
- expect(lines().filter((l) => l.startsWith("secrets create"))).toHaveLength(5);
262
+ expect(lines().filter((l) => l.startsWith("secrets create"))).toHaveLength(4);
294
263
  });
295
264
  });
296
265
 
@@ -299,7 +268,6 @@ describe("rotationRefusal", () => {
299
268
  for (const s of [
300
269
  "postgres-password",
301
270
  "public-role-password",
302
- "gateway-role-password",
303
271
  "internal-secret",
304
272
  "tool-approval-secret",
305
273
  "staging-gate-secret",
@@ -311,6 +279,8 @@ describe("rotationRefusal", () => {
311
279
  it("refuses the KEK (re-encryption needed) and secrets nothing reads", () => {
312
280
  expect(rotationRefusal("secret-kek")).toContain("re-encryption");
313
281
  expect(rotationRefusal("csrf-secret")).toContain("Rotatable");
282
+ // #635 — the former gateway_role is gone; there is nothing to rotate.
283
+ expect(rotationRefusal("gateway-role-password")).toContain("Rotatable");
314
284
  });
315
285
  });
316
286
 
@@ -411,28 +381,6 @@ describe("rotateRuntimeSecret", () => {
411
381
  ).toBe(true);
412
382
  });
413
383
 
414
- it("gateway-role-password: changes gateway_role only and rolls no service (#613, #635)", async () => {
415
- const { run, lines } = fakeGcloud({
416
- ...converged(),
417
- "secrets versions access": [ok("old-pw")],
418
- });
419
- const sql = fakeSqlApi();
420
- const report = await rotateRuntimeSecret(target, "gateway-role-password", {
421
- run,
422
- http: sql.http,
423
- sleep: async () => {},
424
- generate: () => "new-pw",
425
- });
426
- expect(report.ok).toBe(true);
427
- expect(sql.passwords()).toEqual(["gateway_role new-pw"]);
428
- // No service logs in as gateway_role any more: the gateway reads
429
- // cms_public only (#635).
430
- expect(lines().filter((l) => l.startsWith("run services update "))).toEqual([]);
431
- expect(lines()).toContain(
432
- "secrets versions access latest --secret=caelo-production-gateway-role-password --project=acme",
433
- );
434
- });
435
-
436
384
  it("public-role-password: stores nothing and rolls nothing when public_role can't be changed", async () => {
437
385
  const { run, lines } = fakeGcloud({
438
386
  ...converged(),
@@ -474,26 +422,6 @@ describe("rotateRuntimeSecret", () => {
474
422
  expect(lines().every((l) => l.startsWith("run services describe"))).toBe(true);
475
423
  });
476
424
 
477
- it("refuses gateway-role-password while the gateway still logs in with it (before #635)", async () => {
478
- const answers = converged();
479
- answers["run services describe gw-svc"] = [
480
- service([
481
- ref("GATEWAY_DATABASE_PASSWORD", "gateway-role-password"),
482
- ref("PUBLIC_DATABASE_PASSWORD", "public-role-password"),
483
- ]),
484
- ];
485
- const { run } = fakeGcloud(answers);
486
- const sql = fakeSqlApi();
487
- const report = await rotateRuntimeSecret(target, "gateway-role-password", {
488
- run,
489
- http: sql.http,
490
- generate: () => "new-pw",
491
- });
492
- expect(report.ok).toBe(false);
493
- expect(report.error).toContain("GATEWAY_DATABASE_PASSWORD");
494
- expect(sql.requests).toHaveLength(0);
495
- });
496
-
497
425
  it("refuses before changing anything on an install upgrade hasn't moved over", async () => {
498
426
  const { run, lines } = fakeGcloud({
499
427
  "run services describe adm-svc": [
@@ -13,9 +13,8 @@
13
13
  * Secret Manager on stdin — never on argv, never to disk.
14
14
  * - {@link ensureDatabaseRolePassword}: a database role's password set
15
15
  * from its secret (`DATABASE_ROLE_SECRET`), through the Cloud SQL Admin
16
- * API — how `gateway_role` (created by migration 0248, without a
17
- * password) and `public_role` (moved off admin_role's password, #613)
18
- * get theirs.
16
+ * API — how `public_role` (moved off admin_role's password, #613) gets
17
+ * its own.
19
18
  * - {@link rotateRuntimeSecret}: `cms-provision rotate-secret`.
20
19
  *
21
20
  * Every function reports instead of throwing on a gcloud failure.
@@ -368,7 +367,6 @@ async function addVersion(
368
367
  export const ROTATABLE_SECRETS = [
369
368
  "postgres-password",
370
369
  "public-role-password",
371
- "gateway-role-password",
372
370
  "internal-secret",
373
371
  "tool-approval-secret",
374
372
  // #650 — rotating it signs every staging session out and replaces the
@@ -726,8 +724,6 @@ export async function readSecretValue(
726
724
  * the role already has is a no-op for every client, so upgrade runs this on
727
725
  * every roll and an install converges whatever state it was left in.
728
726
  *
729
- * The role must exist: `gateway_role` is created by migration 0248, so this
730
- * runs after migrations.
731
727
  */
732
728
  export async function ensureDatabaseRolePassword(
733
729
  target: DatabaseRoleTarget,
@@ -19,7 +19,7 @@ const GATEWAY = `ghcr.io/caelo-cms/gateway@sha256:${"b".repeat(64)}`;
19
19
  const base: Omit<ComposeSpec, "images"> = {
20
20
  domain: "example.com",
21
21
  postgresPassword: "pgpw",
22
- rolePasswords: { admin: "adminpw", public: "publicpw", gateway: "gatewaypw" },
22
+ rolePasswords: { admin: "adminpw", public: "publicpw" },
23
23
  caeloSecretKek: "0".repeat(64),
24
24
  anthropicApiKey: "sk-ant-x",
25
25
  diskSize: "20Gi",
@@ -1,11 +1,12 @@
1
1
  // SPDX-License-Identifier: MPL-2.0
2
2
 
3
3
  /**
4
- * #613 review — `cms-provision up` on a self-hosted volume created before
5
- * the per-role passwords: no `gateway_role`, `public_role` on another
6
- * password. The convergence script, run as the superuser exactly as `up`
7
- * runs it (psql, script on stdin), must leave the roles on the configured
8
- * passwords with the gateway's grants in place — and be safe to re-run.
4
+ * #613 review, #635 — `cms-provision up` on a self-hosted volume from
5
+ * before: `public_role` on another password, and the gateway's former
6
+ * `gateway_role` login still there with its grants. The convergence
7
+ * script, run as the superuser exactly as `up` runs it (psql, script on
8
+ * stdin), must leave the roles on the configured passwords and the former
9
+ * gateway_role gone — and be safe to re-run.
9
10
  *
10
11
  * Runs against the test database with the passwords the environment already
11
12
  * uses, so the other suites' connection URLs keep working afterwards.
@@ -25,9 +26,8 @@ const required = [
25
26
  "POSTGRES_PORT",
26
27
  "ADMIN_ROLE_PASSWORD",
27
28
  "PUBLIC_ROLE_PASSWORD",
28
- "GATEWAY_ROLE_PASSWORD",
29
29
  "ADMIN_DATABASE_URL",
30
- "GATEWAY_DATABASE_URL",
30
+ "PUBLIC_DATABASE_URL",
31
31
  ] as const;
32
32
  const missing = required.filter((name) => !env[name]);
33
33
  if (missing.length > 0) throw new Error(`${missing.join(", ")} required (see .env.example)`);
@@ -63,19 +63,20 @@ const psql: SuperuserPsql = async (script) => {
63
63
  const passwords = {
64
64
  admin: env.ADMIN_ROLE_PASSWORD as string,
65
65
  public: env.PUBLIC_ROLE_PASSWORD as string,
66
- gateway: env.GATEWAY_ROLE_PASSWORD as string,
67
66
  };
68
67
 
69
- describe("self-hosted role convergence against Postgres (#613 review)", () => {
70
- it("brings a pre-#613 volume to the configured roles, grants and passwords — twice", async () => {
71
- // The pre-#613 volume: no gateway_role, public_role on another password.
68
+ describe("self-hosted role convergence against Postgres (#613 review, #635)", () => {
69
+ it("brings an old volume to the configured roles and passwords, without gateway_role — twice", async () => {
70
+ // The old volume: the former gateway_role with a cms_admin grant, and
71
+ // public_role on another password.
72
72
  const reset = await psql(
73
73
  [
74
74
  "\\set ON_ERROR_STOP on",
75
+ "SELECT 'CREATE ROLE gateway_role NOINHERIT LOGIN' WHERE NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'gateway_role') \\gexec",
76
+ "GRANT CONNECT ON DATABASE cms_admin TO gateway_role;",
75
77
  "\\connect cms_admin",
76
- "DROP OWNED BY gateway_role;",
78
+ "GRANT SELECT (gateway_cookie_secret) ON site_settings TO gateway_role;",
77
79
  "\\connect postgres",
78
- "DROP ROLE gateway_role;",
79
80
  "ALTER ROLE public_role WITH PASSWORD 'the-old-shared-password';",
80
81
  "",
81
82
  ].join("\n"),
@@ -87,36 +88,20 @@ describe("self-hosted role convergence against Postgres (#613 review)", () => {
87
88
  expect(r).toEqual({ ok: true });
88
89
  }
89
90
 
90
- // The gateway logs in with its configured password and reads what 0248 grants.
91
- const gateway = new SQL(env.GATEWAY_DATABASE_URL as string);
91
+ // public_role logs in with its configured password again.
92
+ const pub = new SQL(env.PUBLIC_DATABASE_URL as string);
92
93
  try {
93
- const rows = (await gateway.begin(async (tx) => {
94
- await tx.unsafe("SET LOCAL caelo.actor_kind = 'system'");
95
- return tx`SELECT current_user::text AS u, length(gateway_cookie_secret) > 0 AS s FROM site_settings`;
96
- })) as { u: string; s: boolean }[];
97
- expect(rows[0]).toEqual({ u: "gateway_role", s: true });
94
+ const [who] = (await pub`SELECT current_user::text AS u`) as { u: string }[];
95
+ expect(who?.u).toBe("public_role");
98
96
  } finally {
99
- await gateway.end();
97
+ await pub.end();
100
98
  }
99
+ // The former gateway_role is gone, with its grants (#635).
101
100
  const admin = new SQL(env.ADMIN_DATABASE_URL as string);
102
101
  try {
103
- const [attrs] = (await admin`
104
- SELECT rolcanlogin, rolinherit FROM pg_roles WHERE rolname = 'gateway_role'`) as {
105
- rolcanlogin: boolean;
106
- rolinherit: boolean;
107
- }[];
108
- expect(attrs).toEqual({ rolcanlogin: true, rolinherit: false });
109
- const policies = (await admin`
110
- SELECT policyname::text AS p FROM pg_policies WHERE 'gateway_role' = ANY (roles) ORDER BY 1`) as {
111
- p: string;
112
- }[];
113
- expect(policies.map((p) => p.p)).toEqual([
114
- "audit_events_gateway_plugin_only",
115
- // #634 (migration 0252): the visitor paths' row limits. The A/B
116
- // ones went with the experiment tables (#676, migration 0259).
117
- "comment_archive_gateway_approved_only",
118
- "rate_limit_buckets_gateway_keys",
119
- ]);
102
+ const roles =
103
+ (await admin`SELECT 1 FROM pg_roles WHERE rolname = 'gateway_role'`) as unknown[];
104
+ expect(roles).toEqual([]);
120
105
  } finally {
121
106
  await admin.end();
122
107
  }
@@ -8,26 +8,30 @@ import {
8
8
  selfHostedBootstrapScript,
9
9
  } from "./self-hosted-roles.js";
10
10
 
11
- const PASSWORDS = { admin: "a'pw", public: "p-pw", gateway: "g-pw" };
11
+ const PASSWORDS = { admin: "a'pw", public: "p-pw" };
12
12
 
13
13
  describe("roleConvergenceScript (#613 review)", () => {
14
14
  const script = roleConvergenceScript(PASSWORDS);
15
15
 
16
16
  it("creates missing roles and (re)sets every role's password, quoted", () => {
17
- for (const role of ["admin_role", "public_role", "gateway_role"]) {
17
+ for (const role of ["admin_role", "public_role"]) {
18
18
  expect(script).toContain(
19
19
  `WHERE NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = '${role}')`,
20
20
  );
21
21
  }
22
22
  expect(script).toContain("ALTER ROLE admin_role WITH LOGIN PASSWORD 'a''pw';");
23
- expect(script).toContain("ALTER ROLE gateway_role WITH LOGIN PASSWORD 'g-pw';");
23
+ expect(script).not.toContain("CREATE ROLE gateway_role");
24
24
  });
25
25
 
26
- it("re-applies migration 0248's gateway grants in cms_admin, and stops on the first error", () => {
26
+ it("drops the former gateway_role with what it holds in both databases, and stops on the first error (#635)", () => {
27
27
  expect(script.startsWith("\\set ON_ERROR_STOP on")).toBe(true);
28
- expect(script.indexOf("\\connect cms_admin")).toBeLessThan(
29
- script.indexOf("PERFORM caelo_grant_gateway_role()"),
30
- );
28
+ const publicDrop = script.indexOf("\\connect cms_public");
29
+ const adminDrop = script.indexOf("\\connect cms_admin");
30
+ expect(publicDrop).toBeGreaterThan(-1);
31
+ expect(adminDrop).toBeGreaterThan(publicDrop);
32
+ expect(script.match(/DROP OWNED BY gateway_role/g)).toHaveLength(2);
33
+ expect(script.indexOf("THEN DROP ROLE gateway_role;")).toBeGreaterThan(adminDrop);
34
+ expect(script).not.toContain("caelo_grant_gateway_role");
31
35
  });
32
36
  });
33
37