@caelo-cms/provisioning 0.10.50 → 0.10.51

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 (74) hide show
  1. package/dist/adapter.d.ts +4 -6
  2. package/dist/adapter.d.ts.map +1 -1
  3. package/dist/admin-internal.d.ts +41 -0
  4. package/dist/admin-internal.d.ts.map +1 -0
  5. package/dist/admin-internal.js +169 -0
  6. package/dist/admin-internal.js.map +1 -0
  7. package/dist/agent-db.js +89 -20
  8. package/dist/aws-task-roles.d.ts +27 -0
  9. package/dist/aws-task-roles.d.ts.map +1 -0
  10. package/dist/aws-task-roles.js +35 -0
  11. package/dist/aws-task-roles.js.map +1 -0
  12. package/dist/cli.js +34 -1
  13. package/dist/cli.js.map +1 -1
  14. package/dist/compose.js +2 -2
  15. package/dist/compose.js.map +1 -1
  16. package/dist/deployed-release.d.ts +3 -2
  17. package/dist/deployed-release.d.ts.map +1 -1
  18. package/dist/deployed-release.js +12 -5
  19. package/dist/deployed-release.js.map +1 -1
  20. package/dist/gateway-credentials.d.ts.map +1 -1
  21. package/dist/gateway-credentials.js +6 -3
  22. package/dist/gateway-credentials.js.map +1 -1
  23. package/dist/gcloud.d.ts +2 -2
  24. package/dist/gcloud.d.ts.map +1 -1
  25. package/dist/gcloud.js +3 -3
  26. package/dist/gcloud.js.map +1 -1
  27. package/dist/infra-contract.d.ts +1 -1
  28. package/dist/infra-contract.d.ts.map +1 -1
  29. package/dist/infra-contract.js +6 -1
  30. package/dist/infra-contract.js.map +1 -1
  31. package/dist/owner-reset.d.ts +37 -0
  32. package/dist/owner-reset.d.ts.map +1 -0
  33. package/dist/owner-reset.js +80 -0
  34. package/dist/owner-reset.js.map +1 -0
  35. package/dist/stack-contract.d.ts +9 -10
  36. package/dist/stack-contract.d.ts.map +1 -1
  37. package/dist/stack-contract.js +21 -10
  38. package/dist/stack-contract.js.map +1 -1
  39. package/dist/staging-admin.d.ts +12 -20
  40. package/dist/staging-admin.d.ts.map +1 -1
  41. package/dist/staging-admin.js +7 -159
  42. package/dist/staging-admin.js.map +1 -1
  43. package/dist/wizards/gcp-cost.js +1 -1
  44. package/package.json +2 -2
  45. package/src/adapter.ts +4 -6
  46. package/src/admin-internal.ts +212 -0
  47. package/src/aws-task-roles.ts +58 -0
  48. package/src/checkpoint-secrets.test.ts +0 -1
  49. package/src/cli.ts +41 -1
  50. package/src/compose.ts +2 -2
  51. package/src/deployed-release.test.ts +22 -4
  52. package/src/deployed-release.ts +14 -7
  53. package/src/gateway-credentials.ts +6 -3
  54. package/src/gateway-env-isolation.test.ts +212 -0
  55. package/src/gcloud.ts +5 -3
  56. package/src/index.test.ts +6 -6
  57. package/src/infra-contract.test.ts +4 -3
  58. package/src/infra-contract.ts +6 -1
  59. package/src/owner-reset.test.ts +165 -0
  60. package/src/owner-reset.ts +106 -0
  61. package/src/runtime-secrets.test.ts +25 -8
  62. package/src/stack-contract.test.ts +9 -18
  63. package/src/stack-contract.ts +25 -16
  64. package/src/stack-converge-secrets.test.ts +45 -10
  65. package/src/stack-converge.test.ts +19 -10
  66. package/src/staging-admin.ts +18 -181
  67. package/src/wizards/gcp-cost.ts +1 -1
  68. package/stacks/aws/README.md +1 -1
  69. package/stacks/aws/index.ts +55 -43
  70. package/stacks/azure/README.md +1 -1
  71. package/stacks/azure/index.ts +8 -14
  72. package/stacks/gcp/README.md +1 -1
  73. package/stacks/gcp/index.ts +2 -5
  74. package/stacks/gcp-firebase/index.ts +0 -3
@@ -49,12 +49,11 @@ const ADMIN_SECRETS = {
49
49
  };
50
50
 
51
51
  describe("databaseUrls", () => {
52
- it("builds password-less URLs for the four role/database pairs", () => {
52
+ it("builds password-less URLs for the three role/database pairs", () => {
53
53
  expect(databaseUrls("10.20.0.3")).toEqual({
54
54
  admin: "postgresql://admin_role@10.20.0.3:5432/cms_admin?sslmode=require",
55
55
  publicAdmin: "postgresql://admin_role@10.20.0.3:5432/cms_public?sslmode=require",
56
56
  public: "postgresql://public_role@10.20.0.3:5432/cms_public?sslmode=require",
57
- gateway: "postgresql://gateway_role@10.20.0.3:5432/cms_admin?sslmode=require",
58
57
  });
59
58
  });
60
59
  });
@@ -154,15 +153,13 @@ describe.each([
154
153
  describe("gatewayEnvContract", () => {
155
154
  const gateway = gatewayEnvContract({ ...base, provider: "gcp" });
156
155
 
157
- it("carries its two own logins and none of the admin's database or app secrets", () => {
156
+ it("carries its one own login and none of the admin's database or app secrets (#635)", () => {
158
157
  expect(asRecord(gateway)).toEqual({
159
158
  CAELO_PROVIDER: "gcp",
160
159
  CAELO_ENV: "production",
161
160
  MEDIA_STORAGE_URL: "gs://acme-caelo-production-media",
162
161
  PUBLIC_DATABASE_URL: "postgresql://public_role@10.20.0.3:5432/cms_public?sslmode=require",
163
- GATEWAY_DATABASE_URL: "postgresql://gateway_role@10.20.0.3:5432/cms_admin?sslmode=require",
164
162
  PUBLIC_DATABASE_PASSWORD: "secret:caelo-production-public-role-password:latest",
165
- GATEWAY_DATABASE_PASSWORD: "secret:caelo-production-gateway-role-password:latest",
166
163
  CAELO_STAGING_GATE_SECRET: "secret:caelo-production-staging-gate-secret:latest",
167
164
  });
168
165
  });
@@ -188,11 +185,7 @@ describe("gatewayEnvContract", () => {
188
185
  });
189
186
 
190
187
  it("the gateway's run SA reads no admin-only secret (admin password, KEK, internal, tool approval)", () => {
191
- expect(serviceSecrets("gateway")).toEqual([
192
- "gateway-role-password",
193
- "public-role-password",
194
- "staging-gate-secret",
195
- ]);
188
+ expect(serviceSecrets("gateway")).toEqual(["public-role-password", "staging-gate-secret"]);
196
189
  for (const v of gateway) {
197
190
  expect(v.name).not.toMatch(/KEK|INTERNAL|TOOL_APPROVAL|PUBLIC_ADMIN/);
198
191
  }
@@ -280,11 +273,6 @@ describe("runtime secrets", () => {
280
273
  secret: "staging-gate-secret",
281
274
  stackResource: "staging-gate-secret-binding",
282
275
  },
283
- {
284
- service: "gateway",
285
- secret: "gateway-role-password",
286
- stackResource: "gateway-gateway-role-password-binding",
287
- },
288
276
  {
289
277
  service: "gateway",
290
278
  secret: "public-role-password",
@@ -299,7 +287,11 @@ describe("runtime secrets", () => {
299
287
  });
300
288
 
301
289
  it("every CLI-generated secret is read by some service", () => {
302
- for (const s of CLI_GENERATED_SECRETS) {
290
+ // gateway-role-password is read by no service since #635: it only keeps
291
+ // the legacy gateway_role's password set until the contract release
292
+ // drops the role (the previous release's gateway logs in with it while
293
+ // an upgrade rolls).
294
+ for (const s of CLI_GENERATED_SECRETS.filter((x) => x !== "gateway-role-password")) {
303
295
  expect([...serviceSecrets("admin"), ...serviceSecrets("gateway")]).toContain(s);
304
296
  }
305
297
  });
@@ -351,13 +343,12 @@ describe("stackIamInvariants", () => {
351
343
  }
352
344
  });
353
345
 
354
- it("grants the gateway SA only its two role passwords, the staging gate key + telemetry roles", () => {
346
+ it("grants the gateway SA only its own role password, the staging gate key + telemetry roles", () => {
355
347
  for (const provider of ["gcp", "gcp-firebase"] as const) {
356
348
  const gateway = stackIamInvariants(provider).filter((i) => i.member === "gateway-sa");
357
349
  expect(gateway.map((i) => `${i.role} ${JSON.stringify(i.target)}`).sort()).toEqual([
358
350
  'roles/logging.logWriter {"kind":"project"}',
359
351
  'roles/monitoring.metricWriter {"kind":"project"}',
360
- 'roles/secretmanager.secretAccessor {"kind":"secret","name":"gateway-role-password"}',
361
352
  'roles/secretmanager.secretAccessor {"kind":"secret","name":"public-role-password"}',
362
353
  'roles/secretmanager.secretAccessor {"kind":"secret","name":"staging-gate-secret"}',
363
354
  ]);
@@ -86,14 +86,12 @@ export interface DatabaseUrls<V = string> {
86
86
  readonly admin: V;
87
87
  /** admin_role on cms_public — the admin's second pool (DDL, migrations). */
88
88
  readonly publicAdmin: V;
89
- /** public_role on cms_public — the gateway's visitor-write pool. */
90
- readonly public: V;
91
89
  /**
92
- * gateway_role on cms_admin — the gateway's narrow pool (#613): its
93
- * settings, rate limits, captcha, request log, plugin op audit and the
94
- * plugin registry, read-mostly (migration 0248).
90
+ * public_role on cms_public — the gateway's only pool. The gateway has no
91
+ * cms_admin login (#635, CLAUDE.md §2): what it needs from authoring
92
+ * state the admin publishes into cms_public.
95
93
  */
96
- readonly gateway: V;
94
+ readonly public: V;
97
95
  }
98
96
 
99
97
  /** Port Cloud SQL Postgres listens on (private IP). */
@@ -111,7 +109,6 @@ export function databaseUrls(host: string): DatabaseUrls {
111
109
  admin: url("admin_role", "cms_admin"),
112
110
  publicAdmin: url("admin_role", "cms_public"),
113
111
  public: url("public_role", "cms_public"),
114
- gateway: url("gateway_role", "cms_admin"),
115
112
  };
116
113
  }
117
114
 
@@ -251,8 +248,8 @@ export type DatabaseRole = "admin_role" | "public_role" | "gateway_role";
251
248
 
252
249
  /**
253
250
  * Each database role's password secret — one per role (#613), so the
254
- * gateway's SA, which reads only the public_role and gateway_role
255
- * passwords, can never read admin_role's. `postgres-password` keeps its
251
+ * gateway's SA, which reads only the public_role password, can never read
252
+ * admin_role's (or, since #635, gateway_role's). `postgres-password` keeps its
256
253
  * name: it has always been admin_role's (and, before #613, shared with
257
254
  * public_role). `rotate-secret` changes exactly the role(s) listed here.
258
255
  */
@@ -306,9 +303,8 @@ export const SERVICE_SECRET_ENV: Readonly<
306
303
  CAELO_STAGING_GATE_SECRET: "staging-gate-secret",
307
304
  },
308
305
  gateway: {
309
- // CLAUDE.md §2: never an admin_role credential (#613). gateway_role on
310
- // cms_admin + public_role on cms_public, each with its own password.
311
- GATEWAY_DATABASE_PASSWORD: "gateway-role-password",
306
+ // CLAUDE.md §2: no cms_admin credential of any role (#613, #635).
307
+ // public_role on cms_public is the gateway's only login.
312
308
  PUBLIC_DATABASE_PASSWORD: "public-role-password",
313
309
  // #650 — verifies staging-host requests (api-gateway staging-proxy.ts).
314
310
  CAELO_STAGING_GATE_SECRET: "staging-gate-secret",
@@ -323,12 +319,20 @@ export function serviceSecrets(service: CloudRunSlug): RuntimeSecret[] {
323
319
  /**
324
320
  * Env vars an older stack set that the contract no longer gives a service;
325
321
  * upgrade removes them. The gateway never used the KEK, and its run SA
326
- * cannot read it. Its admin_role pool went with #613 — the gateway refuses
327
- * to boot while either var is set (apps/api-gateway `gatewayDatabaseUrls`).
322
+ * cannot read it. Its admin_role pool went with #613 and its gateway_role
323
+ * pool with #635 — the gateway refuses to boot while any cms_admin
324
+ * credential is in its env (apps/api-gateway `adminDatabaseAccessInEnv`).
328
325
  */
329
326
  export const RETIRED_SERVICE_ENV: Readonly<Record<CloudRunSlug, readonly string[]>> = {
330
327
  admin: [],
331
- gateway: ["CAELO_SECRET_KEK", "ADMIN_DATABASE_URL", "ADMIN_DATABASE_PASSWORD"],
328
+ gateway: [
329
+ "CAELO_SECRET_KEK",
330
+ "ADMIN_DATABASE_URL",
331
+ "ADMIN_DATABASE_PASSWORD",
332
+ // #635 — its gateway_role login on cms_admin is gone too.
333
+ "GATEWAY_DATABASE_URL",
334
+ "GATEWAY_DATABASE_PASSWORD",
335
+ ],
332
336
  };
333
337
 
334
338
  function secretEnv(service: CloudRunSlug, env: string): CloudRunEnvVar[] {
@@ -362,7 +366,6 @@ export function gatewayEnvContract<V>(inputs: RuntimeEnvInputs<V>): CloudRunEnvV
362
366
  return [
363
367
  ...commonEnv(inputs),
364
368
  { name: "PUBLIC_DATABASE_URL", value: inputs.databaseUrls.public },
365
- { name: "GATEWAY_DATABASE_URL", value: inputs.databaseUrls.gateway },
366
369
  ...secretEnv("gateway", inputs.env),
367
370
  ];
368
371
  }
@@ -679,6 +682,12 @@ export const RETIRED_IAM_BINDINGS: readonly RetiredIamBinding[] = [
679
682
  target: { kind: "secret", name: "postgres-password" },
680
683
  why: "the gateway no longer logs in as admin_role and must not be able to read its password (CLAUDE.md §2)",
681
684
  },
685
+ {
686
+ role: SECRET_ACCESSOR,
687
+ member: "gateway-sa",
688
+ target: { kind: "secret", name: "gateway-role-password" },
689
+ why: "the gateway has no cms_admin login at all and must not be able to read gateway_role's password (CLAUDE.md §2, #635)",
690
+ },
682
691
  ];
683
692
 
684
693
  /** Every IAM binding upgrade ensures on an install of `provider`. */
@@ -38,12 +38,14 @@ const SECRET_VALUES: Record<string, string> = {
38
38
  "caelo-production-gateway-role-password": "g".repeat(64),
39
39
  };
40
40
 
41
- /** Env vars that would hand the gateway an admin_role credential (apps/api-gateway ADMIN_CREDENTIAL_ENV). */
41
+ /** Env vars that would hand the gateway a cms_admin credential (a subset of what apps/api-gateway adminDatabaseAccessInEnv refuses). */
42
42
  const ADMIN_CREDENTIAL_ENV = [
43
43
  "ADMIN_DATABASE_URL",
44
44
  "ADMIN_DATABASE_PASSWORD",
45
45
  "PUBLIC_ADMIN_DATABASE_URL",
46
46
  "PUBLIC_ADMIN_DATABASE_PASSWORD",
47
+ "GATEWAY_DATABASE_URL",
48
+ "GATEWAY_DATABASE_PASSWORD",
47
49
  ];
48
50
 
49
51
  /** Split a gcloud list flag value, honouring the `^<delim>^` escape. */
@@ -122,16 +124,15 @@ function credentials(url: string | undefined): string {
122
124
  }
123
125
 
124
126
  /**
125
- * The gateway's process env after the upgrade: gateway_role on cms_admin and
126
- * public_role on cms_public, each with its own secret, and no admin_role
127
- * credential at all (the gateway refuses to boot with one).
127
+ * The gateway's process env after the upgrade: public_role on cms_public
128
+ * with its own secret, and no cms_admin credential of any role at all
129
+ * (#613, #635 — the gateway refuses to boot with one).
128
130
  */
129
131
  function expectGatewayOnItsOwnLogins(env: Record<string, string>): void {
130
132
  for (const name of ADMIN_CREDENTIAL_ENV) expect(Object.keys(env)).not.toContain(name);
131
133
  expect(Object.values(env).join("\n")).not.toContain(PW);
132
- expect(credentials(databaseUrlFromEnv(["GATEWAY_DATABASE_URL"], env))).toBe(
133
- `gateway_role:${"g".repeat(64)}@${HOST}:5432/cms_admin?sslmode=require`,
134
- );
134
+ expect(Object.values(env).join("\n")).not.toContain("g".repeat(64));
135
+ expect(Object.values(env).join("\n")).not.toContain("cms_admin");
135
136
  expect(credentials(databaseUrlFromEnv(["PUBLIC_DATABASE_URL"], env))).toBe(
136
137
  `public_role:${"p".repeat(64)}@${HOST}:5432/cms_public?sslmode=require`,
137
138
  );
@@ -331,7 +332,7 @@ function v01035Install() {
331
332
  }
332
333
 
333
334
  describe("upgrading a v0.10.35 install: the gateway leaves admin_role (#613)", () => {
334
- it("drops the admin_role credential and adds the gateway's own logins in one revision", () => {
335
+ it("drops the admin_role credential and adds the gateway's own login in one revision", () => {
335
336
  const services = v01035Install();
336
337
  const plan = upgrade(services);
337
338
  expect(services.gateway.revisions).toHaveLength(2);
@@ -343,8 +344,6 @@ describe("upgrading a v0.10.35 install: the gateway leaves admin_role (#613)", (
343
344
  "ADMIN_DATABASE_URL",
344
345
  // #650 — the staging gate key arrives in the same roll.
345
346
  "CAELO_STAGING_GATE_SECRET",
346
- "GATEWAY_DATABASE_PASSWORD",
347
- "GATEWAY_DATABASE_URL",
348
347
  "PUBLIC_DATABASE_PASSWORD",
349
348
  ]);
350
349
  });
@@ -374,6 +373,42 @@ describe("upgrading a v0.10.35 install: the gateway leaves admin_role (#613)", (
374
373
  });
375
374
  });
376
375
 
376
+ /**
377
+ * A v0.10.50 install (#613, before #635): the gateway on gateway_role's
378
+ * cms_admin pool next to public_role's.
379
+ */
380
+ function v01050Install() {
381
+ const services = v01035Install();
382
+ upgrade(services);
383
+ const env = new Map(services.gateway.env);
384
+ env.set("GATEWAY_DATABASE_URL", {
385
+ kind: "value",
386
+ value: `postgresql://gateway_role@${HOST}:5432/cms_admin?sslmode=require`,
387
+ });
388
+ env.set("GATEWAY_DATABASE_PASSWORD", {
389
+ kind: "secret",
390
+ secret: "caelo-production-gateway-role-password",
391
+ version: "latest",
392
+ });
393
+ services.gateway = new FakeService(env, SERVICE_ACCOUNTS.gateway);
394
+ return services;
395
+ }
396
+
397
+ describe("upgrading a v0.10.50 install: the gateway leaves cms_admin entirely (#635)", () => {
398
+ it("removes the gateway_role login in one revision and keeps the public pool", () => {
399
+ const services = v01050Install();
400
+ const plan = upgrade(services);
401
+ expect(plan.ok && plan.services.gateway.changes.map((c) => c.name).sort()).toEqual([
402
+ "GATEWAY_DATABASE_PASSWORD",
403
+ "GATEWAY_DATABASE_URL",
404
+ ]);
405
+ expectGatewayOnItsOwnLogins(containerEnv(services.gateway.env));
406
+ for (const revision of services.gateway.revisions) {
407
+ expect(revision.has("PUBLIC_DATABASE_URL")).toBe(true);
408
+ }
409
+ });
410
+ });
411
+
377
412
  describe("plain → Secret Manager migration of an existing var", () => {
378
413
  it("never leaves a revision without the var", () => {
379
414
  // An operator set CAELO_INTERNAL_SECRET by hand as a plain value.
@@ -268,12 +268,7 @@ const CONVERGED_GATEWAY_DB = [
268
268
  name: "PUBLIC_DATABASE_URL",
269
269
  value: "postgresql://public_role@10.20.0.3:5432/cms_public?sslmode=require",
270
270
  },
271
- {
272
- name: "GATEWAY_DATABASE_URL",
273
- value: "postgresql://gateway_role@10.20.0.3:5432/cms_admin?sslmode=require",
274
- },
275
271
  secretRef("PUBLIC_DATABASE_PASSWORD", "public-role-password"),
276
- secretRef("GATEWAY_DATABASE_PASSWORD", "gateway-role-password"),
277
272
  ];
278
273
 
279
274
  describe("planContractEnv", () => {
@@ -776,10 +771,10 @@ describe("ensureStackInvariants", () => {
776
771
  const gatewayGrants = calls.filter(
777
772
  (c) => c.startsWith("secrets add-iam-policy-binding") && c.includes(GW_SA),
778
773
  );
779
- // #613 — never admin_role's postgres-password. #650 — the staging
780
- // gate key is the one secret the gateway shares with the admin.
774
+ // #613 — never admin_role's postgres-password; #635 — nor
775
+ // gateway_role's. #650 — the staging gate key is the one secret the
776
+ // gateway shares with the admin.
781
777
  expect(gatewayGrants).toEqual([
782
- `secrets add-iam-policy-binding caelo-production-gateway-role-password --project=acme --member=${GW_SA} --role=roles/secretmanager.secretAccessor --quiet --format=none`,
783
778
  `secrets add-iam-policy-binding caelo-production-public-role-password --project=acme --member=${GW_SA} --role=roles/secretmanager.secretAccessor --quiet --format=none`,
784
779
  `secrets add-iam-policy-binding caelo-production-staging-gate-secret --project=acme --member=${GW_SA} --role=roles/secretmanager.secretAccessor --quiet --format=none`,
785
780
  ]);
@@ -802,7 +797,8 @@ describe("removeRetiredIamBindings (#613)", () => {
802
797
  ],
803
798
  });
804
799
  const outcomes = await removeRetiredIamBindings(gcp, { run, sleep: async () => {} });
805
- expect(outcomes.map((o) => o.status)).toEqual(["removed"]);
800
+ // postgres-password removed; gateway-role-password (#635) already clean.
801
+ expect(outcomes.map((o) => o.status)).toEqual(["removed", "absent"]);
806
802
  expect(calls).toContain(REMOVE);
807
803
  // The admin's own binding on the same secret is never touched.
808
804
  expect(calls.some((c) => c.includes("remove") && c.includes(RUN_SA))).toBe(false);
@@ -815,10 +811,23 @@ describe("removeRetiredIamBindings (#613)", () => {
815
811
  ],
816
812
  });
817
813
  const outcomes = await removeRetiredIamBindings(firebase, { run, sleep: async () => {} });
818
- expect(outcomes.map((o) => o.status)).toEqual(["absent"]);
814
+ expect(outcomes.map((o) => o.status)).toEqual(["absent", "absent"]);
819
815
  expect(calls.filter((c) => c.includes("remove-iam-policy-binding"))).toEqual([]);
820
816
  });
821
817
 
818
+ it("takes the gateway SA off gateway_role's password (#635)", async () => {
819
+ const { run, calls } = fakeGcloud({
820
+ "secrets get-iam-policy caelo-production-gateway-role-password": [
821
+ policy([{ role: "roles/secretmanager.secretAccessor", members: [GW_SA] }]),
822
+ ],
823
+ });
824
+ const outcomes = await removeRetiredIamBindings(gcp, { run, sleep: async () => {} });
825
+ expect(outcomes.map((o) => o.status)).toEqual(["absent", "removed"]);
826
+ expect(calls).toContain(
827
+ `secrets remove-iam-policy-binding caelo-production-gateway-role-password --project=acme --member=${GW_SA} --role=roles/secretmanager.secretAccessor --quiet --format=none`,
828
+ );
829
+ });
830
+
822
831
  it("reports a failed removal instead of throwing", async () => {
823
832
  const { run } = fakeGcloud({
824
833
  "secrets get-iam-policy caelo-production-postgres-password": [
@@ -3,51 +3,25 @@
3
3
  /**
4
4
  * #678 — `staging-domain enable` tells the admin itself what the operator
5
5
  * decided, instead of printing "now set the same host in the admin".
6
- *
7
- * Why an HTTP call to the admin (and not a one-shot Cloud Run job like the
8
- * migrations): the call must hand a result BACK — above all the generated
9
- * basic-auth password, which the CLI prints once. A job can only report
10
- * through Cloud Logging, which would put the password into the project's
11
- * logs. The admin endpoint (`POST /api/internal/staging/provision`) answers
12
- * over TLS straight to this process, and the password is never written
13
- * anywhere (CLAUDE.md §7, #659).
14
- *
15
- * Getting there, as the operator's own gcloud identity — the same path the
16
- * Power-MCP shim takes (packages/mcp-server/src/ingress-auth.ts):
17
- *
18
- * 1. the admin Cloud Run service and its URL;
19
- * 2. the install's internal secret from Secret Manager (in memory only),
20
- * which signs a 5-minute token for scope `staging.provision`;
21
- * 3. a JWT signed as `caelo-mcp@<project>` through the IAM Credentials
22
- * API — the admin's IAP lets that service account in, and every
23
- * IAP-allowlisted operator may sign as it (`cms-provision upgrade`
24
- * grants it) — so the request passes IAP first, then Caelo's token.
6
+ * It calls `POST /api/internal/staging/provision` through admin-internal.ts
7
+ * (IAP as caelo-mcp + the internal token, scope `staging.provision`), because
8
+ * the answer carries the generated basic-auth password back to the CLI, which
9
+ * prints it once and never stores it.
25
10
  */
26
11
 
27
- import { findService } from "./admin-domain.js";
28
- import { gcloud as defaultGcloud, type GcloudResult } from "./gcloud.js";
29
- import { GCP_STACK_ENV, gcpSecretId, mcpIapServiceAccountEmail } from "./gcp-names.js";
30
- import { mintInternalToken } from "./internal-token.js";
31
-
32
- type Run = (args: string[]) => Promise<GcloudResult>;
12
+ import {
13
+ type AdminInternalDeps,
14
+ type AdminInternalResult,
15
+ type AdminInternalTarget,
16
+ callAdminInternal,
17
+ } from "./admin-internal.js";
33
18
 
34
19
  /** Token scope the admin endpoint checks (apps/admin …/api/internal/staging/provision). */
35
20
  export const STAGING_PROVISION_SCOPE = "staging.provision";
36
21
  const ENDPOINT = "/api/internal/staging/provision";
37
- /** Signed JWTs live at most 1 hour; one call needs a few seconds. */
38
- const IAP_JWT_LIFETIME_S = 600;
39
22
 
40
- export interface StagingAdminTarget {
41
- readonly projectId: string;
42
- readonly region: string;
43
- }
44
-
45
- export interface StagingAdminDeps {
46
- readonly run?: Run;
47
- readonly fetch?: typeof fetch;
48
- /** Seconds since epoch (IAP JWT); tests pin it. */
49
- readonly now?: () => number;
50
- }
23
+ export type StagingAdminTarget = AdminInternalTarget;
24
+ export type StagingAdminDeps = AdminInternalDeps;
51
25
 
52
26
  /** Main's draft pages, as the admin reports them. */
53
27
  export interface DraftPages {
@@ -80,7 +54,7 @@ export interface StagingApply {
80
54
  readonly draftPages: DraftPages;
81
55
  }
82
56
 
83
- export type StagingAdminResult<T> = { ok: true; value: T } | { ok: false; error: string };
57
+ export type StagingAdminResult<T> = AdminInternalResult<T>;
84
58
 
85
59
  export interface ApplyRequest {
86
60
  readonly host?: string;
@@ -110,149 +84,12 @@ async function callStagingAdmin<T>(
110
84
  body: Record<string, unknown>,
111
85
  deps: StagingAdminDeps,
112
86
  ): Promise<StagingAdminResult<T>> {
113
- const run = deps.run ?? defaultGcloud;
114
- const doFetch = deps.fetch ?? fetch;
115
- const scope = [`--region=${target.region}`, `--project=${target.projectId}`];
116
-
117
- const found = await findService(run, scope, "admin");
118
- if (!found.ok) return { ok: false, error: found.error };
119
- const url = await run([
120
- "run",
121
- "services",
122
- "describe",
123
- found.service,
124
- ...scope,
125
- "--format=value(status.url)",
126
- ]);
127
- const adminUrl = url.stdout.trim();
128
- if (!url.ok || !adminUrl) {
129
- return { ok: false, error: `read the admin's URL: ${url.stderr.trim() || "empty"}` };
130
- }
131
-
132
- const secretId = gcpSecretId(GCP_STACK_ENV, "internal-secret");
133
- const secret = await run([
134
- "secrets",
135
- "versions",
136
- "access",
137
- "latest",
138
- `--secret=${secretId}`,
139
- `--project=${target.projectId}`,
140
- ]);
141
- const internalSecret = secret.stdout.trim();
142
- if (!secret.ok || internalSecret.length < 32) {
143
- return {
144
- ok: false,
145
- error: `read the install's internal secret (${secretId}) from Secret Manager: ${secret.stderr.trim() || "missing or too short"}. Your gcloud account needs secretmanager.versions.access on the project.`,
146
- };
147
- }
148
-
149
- // Installs from before #678 may not have the IAM Credentials API on;
150
- // enabling an enabled API is a no-op (codex review on #681).
151
- const api = await run([
152
- "services",
153
- "enable",
154
- "iamcredentials.googleapis.com",
155
- `--project=${target.projectId}`,
156
- ]);
157
- if (!api.ok) {
158
- return {
159
- ok: false,
160
- error: `enable the IAM Credentials API (iamcredentials.googleapis.com), needed to pass the admin's IAP: ${api.stderr.trim()}`,
161
- };
162
- }
163
- const iap = await signIapJwt(run, doFetch, target.projectId, adminUrl, deps.now);
164
- if (!iap.ok) return iap;
165
-
166
- const token = await mintInternalToken(internalSecret, STAGING_PROVISION_SCOPE);
167
- let res: Response;
168
- try {
169
- res = await doFetch(`${adminUrl}${ENDPOINT}`, {
170
- method: "POST",
171
- headers: {
172
- authorization: `Bearer ${iap.value}`,
173
- "x-caelo-internal-token": token,
174
- "content-type": "application/json",
175
- },
176
- body: JSON.stringify(body),
177
- });
178
- } catch (e) {
179
- return { ok: false, error: `reach the admin at ${adminUrl}: ${String(e)}` };
180
- }
181
- const text = await res.text();
182
- if (res.headers.get("x-goog-iap-generated-response") === "true") {
183
- return {
184
- ok: false,
185
- error: `Google IAP refused the request (HTTP ${res.status}: ${text.trim().slice(0, 200)}) — ${mcpIapServiceAccountEmail(target.projectId)} is not allowed on the admin's IAP yet. Run \`cms-provision upgrade\` (it allowlists that service account), then run this command again.`,
186
- };
187
- }
188
- if (res.status === 404) {
189
- return {
190
- ok: false,
191
- error: `the admin has no ${ENDPOINT} yet (it runs a release from before #678). Run \`cms-provision upgrade\`, then run this command again.`,
192
- };
193
- }
194
- let parsed: { ok?: boolean; error?: string } & Record<string, unknown>;
195
- try {
196
- parsed = JSON.parse(text) as typeof parsed;
197
- } catch {
198
- return {
199
- ok: false,
200
- error: `the admin answered HTTP ${res.status} with a non-JSON body: ${text.slice(0, 200)}`,
201
- };
202
- }
203
- if (!res.ok || parsed.ok !== true) {
204
- return {
205
- ok: false,
206
- error: `the admin refused (HTTP ${res.status}): ${parsed.error ?? text.slice(0, 300)}`,
207
- };
208
- }
209
- return { ok: true, value: parsed as unknown as T };
210
- }
211
-
212
- /** A JWT signed as the install's MCP service account, for the admin's IAP. */
213
- async function signIapJwt(
214
- run: Run,
215
- doFetch: typeof fetch,
216
- projectId: string,
217
- adminUrl: string,
218
- now: (() => number) | undefined,
219
- ): Promise<StagingAdminResult<string>> {
220
- const access = await run(["auth", "print-access-token"]);
221
- const accessToken = access.stdout.trim();
222
- if (!access.ok || !accessToken) {
223
- return {
224
- ok: false,
225
- error: `get an access token from gcloud: ${access.stderr.trim() || "empty"}. Run \`gcloud auth login\` with the account you provisioned with.`,
226
- };
227
- }
228
- const serviceAccount = mcpIapServiceAccountEmail(projectId);
229
- const iat = now ? now() : Math.floor(Date.now() / 1000);
230
- const res = await doFetch(
231
- `https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/${encodeURIComponent(serviceAccount)}:signJwt`,
232
- {
233
- method: "POST",
234
- headers: { authorization: `Bearer ${accessToken}`, "content-type": "application/json" },
235
- body: JSON.stringify({
236
- payload: JSON.stringify({
237
- iss: serviceAccount,
238
- sub: serviceAccount,
239
- aud: `${new URL(adminUrl).origin}/*`,
240
- iat,
241
- exp: iat + IAP_JWT_LIFETIME_S,
242
- }),
243
- }),
244
- },
87
+ const r = await callAdminInternal<T>(
88
+ target,
89
+ { endpoint: ENDPOINT, scope: STAGING_PROVISION_SCOPE, body, introducedBy: "#678" },
90
+ deps,
245
91
  );
246
- if (!res.ok) {
247
- const detail = (await res.text().catch(() => "")).slice(0, 300);
248
- return {
249
- ok: false,
250
- error: `sign an IAP token as ${serviceAccount} (HTTP ${res.status}): ${detail} — your account needs roles/iam.serviceAccountTokenCreator on it; \`cms-provision upgrade\` grants it to everyone the admin's IAP lets in.`,
251
- };
252
- }
253
- const { signedJwt } = (await res.json()) as { signedJwt?: string };
254
- if (!signedJwt) return { ok: false, error: "IAM signJwt answered without a signedJwt" };
255
- return { ok: true, value: signedJwt };
92
+ return r.ok ? { ok: true, value: r.value.answer } : r;
256
93
  }
257
94
 
258
95
  /**
@@ -104,7 +104,7 @@ export function estimateGcpCost(inputs: CostEstimateInputs): {
104
104
  name: "Quality checks (Lighthouse on the admin)",
105
105
  monthlyUsd: 1,
106
106
  notes:
107
- "~1-2 min of admin CPU per audited Stage (up to 5 pages x 3 runs); ~$1/mo at a few Stages a day",
107
+ "~2-4 min of admin CPU per audited Stage (up to 5 pages x 5 runs); ~$1/mo at a few Stages a day",
108
108
  },
109
109
  {
110
110
  name: "Cloud Run gateway",
@@ -10,7 +10,7 @@ Pulumi stack provisioning Caelo on AWS managed services. Implements the shared `
10
10
  | Blob storage | Two S3 buckets (`caelo-<env>-media` + `caelo-<env>-static`) |
11
11
  | CDN | CloudFront distribution with two origins (S3 static + S3 media); static files are served as files, no edge compute on the request path |
12
12
  | Container runtime | ECS Fargate cluster + four services (admin / gateway / orchestrator / runner) |
13
- | Secret store | AWS Secrets Manager (postgres-password, public-role-password, gateway-role-password, csrf-secret, cookie-secret, anthropic-api-key, resend-api-key). The gateway gets `gatewayDatabaseUrlOut` + `publicDatabaseUrlOut`, never the admin URL (#613). |
13
+ | Secret store | AWS Secrets Manager (postgres-password, public-role-password, gateway-role-password, csrf-secret, cookie-secret, anthropic-api-key, resend-api-key). The gateway gets `publicDatabaseUrlOut` only, never a cms_admin URL of any role (#613, #635). |
14
14
  | DNS + cert | ACM cert (us-east-1, DNS-validated, supports `*.<domain>`) |
15
15
  | Edge-log sink | CloudFront access logs delivered to the CloudWatch log group `/caelo/<env>/cloudfront` in us-east-1 (queryable by P12A analytics plugin) |
16
16