@caelo-cms/provisioning 0.11.8 → 0.11.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (261) hide show
  1. package/dist/admin-domain.d.ts +10 -1
  2. package/dist/admin-domain.d.ts.map +1 -1
  3. package/dist/admin-domain.js +13 -3
  4. package/dist/admin-domain.js.map +1 -1
  5. package/dist/adopt-existing.d.ts +6 -1
  6. package/dist/adopt-existing.d.ts.map +1 -1
  7. package/dist/adopt-existing.js +86 -0
  8. package/dist/adopt-existing.js.map +1 -1
  9. package/dist/agent/command.d.ts +20 -0
  10. package/dist/agent/command.d.ts.map +1 -1
  11. package/dist/agent/command.js +32 -9
  12. package/dist/agent/command.js.map +1 -1
  13. package/dist/agent/run.d.ts +2 -0
  14. package/dist/agent/run.d.ts.map +1 -1
  15. package/dist/agent/run.js +5 -1
  16. package/dist/agent/run.js.map +1 -1
  17. package/dist/agent-db.js +65 -3
  18. package/dist/aws-task-roles.d.ts +4 -2
  19. package/dist/aws-task-roles.d.ts.map +1 -1
  20. package/dist/aws-task-roles.js +4 -1
  21. package/dist/aws-task-roles.js.map +1 -1
  22. package/dist/caddy.d.ts +21 -4
  23. package/dist/caddy.d.ts.map +1 -1
  24. package/dist/caddy.js +27 -3
  25. package/dist/caddy.js.map +1 -1
  26. package/dist/cli.js +12 -3
  27. package/dist/cli.js.map +1 -1
  28. package/dist/cloudsql-tls.d.ts +63 -0
  29. package/dist/cloudsql-tls.d.ts.map +1 -0
  30. package/dist/cloudsql-tls.js +289 -0
  31. package/dist/cloudsql-tls.js.map +1 -0
  32. package/dist/compose.d.ts +29 -6
  33. package/dist/compose.d.ts.map +1 -1
  34. package/dist/compose.js +95 -23
  35. package/dist/compose.js.map +1 -1
  36. package/dist/converge-checkpoint.d.ts +1 -0
  37. package/dist/converge-checkpoint.d.ts.map +1 -1
  38. package/dist/converge-checkpoint.js +8 -1
  39. package/dist/converge-checkpoint.js.map +1 -1
  40. package/dist/converge-job-gcp.d.ts.map +1 -1
  41. package/dist/converge-job-gcp.js +2 -11
  42. package/dist/converge-job-gcp.js.map +1 -1
  43. package/dist/deployed-release.d.ts +4 -1
  44. package/dist/deployed-release.d.ts.map +1 -1
  45. package/dist/deployed-release.js +18 -8
  46. package/dist/deployed-release.js.map +1 -1
  47. package/dist/gcp-names.d.ts +21 -0
  48. package/dist/gcp-names.d.ts.map +1 -1
  49. package/dist/gcp-names.js +24 -0
  50. package/dist/gcp-names.js.map +1 -1
  51. package/dist/gcp-url-map.d.ts.map +1 -1
  52. package/dist/gcp-url-map.js +6 -2
  53. package/dist/gcp-url-map.js.map +1 -1
  54. package/dist/index.d.ts +1 -1
  55. package/dist/index.d.ts.map +1 -1
  56. package/dist/index.js +1 -1
  57. package/dist/index.js.map +1 -1
  58. package/dist/infra-contract.d.ts +1 -1
  59. package/dist/infra-contract.d.ts.map +1 -1
  60. package/dist/infra-contract.js +24 -1
  61. package/dist/infra-contract.js.map +1 -1
  62. package/dist/install-state.d.ts +6 -0
  63. package/dist/install-state.d.ts.map +1 -1
  64. package/dist/install-state.js +17 -4
  65. package/dist/install-state.js.map +1 -1
  66. package/dist/lifecycle.d.ts.map +1 -1
  67. package/dist/lifecycle.js +17 -8
  68. package/dist/lifecycle.js.map +1 -1
  69. package/dist/mcp-auth-proxy-setup.d.ts +48 -0
  70. package/dist/mcp-auth-proxy-setup.d.ts.map +1 -0
  71. package/dist/mcp-auth-proxy-setup.js +280 -0
  72. package/dist/mcp-auth-proxy-setup.js.map +1 -0
  73. package/dist/release-manifest.d.ts +25 -1
  74. package/dist/release-manifest.d.ts.map +1 -1
  75. package/dist/release-manifest.js +36 -3
  76. package/dist/release-manifest.js.map +1 -1
  77. package/dist/remote-mcp.d.ts +55 -18
  78. package/dist/remote-mcp.d.ts.map +1 -1
  79. package/dist/remote-mcp.js +307 -77
  80. package/dist/remote-mcp.js.map +1 -1
  81. package/dist/runtime-secrets.d.ts +9 -0
  82. package/dist/runtime-secrets.d.ts.map +1 -1
  83. package/dist/runtime-secrets.js +21 -10
  84. package/dist/runtime-secrets.js.map +1 -1
  85. package/dist/seal-key-rotation.d.ts +7 -7
  86. package/dist/seal-key-rotation.d.ts.map +1 -1
  87. package/dist/seal-key-rotation.js +22 -22
  88. package/dist/seal-key-rotation.js.map +1 -1
  89. package/dist/self-hosted-db-tls.d.ts +37 -0
  90. package/dist/self-hosted-db-tls.d.ts.map +1 -0
  91. package/dist/self-hosted-db-tls.js +220 -0
  92. package/dist/self-hosted-db-tls.js.map +1 -0
  93. package/dist/self-hosted-release.d.ts +3 -1
  94. package/dist/self-hosted-release.d.ts.map +1 -1
  95. package/dist/self-hosted-release.js +33 -9
  96. package/dist/self-hosted-release.js.map +1 -1
  97. package/dist/self-hosted-roles.d.ts +34 -0
  98. package/dist/self-hosted-roles.d.ts.map +1 -1
  99. package/dist/self-hosted-roles.js +52 -0
  100. package/dist/self-hosted-roles.js.map +1 -1
  101. package/dist/self-hosted-start.d.ts +0 -21
  102. package/dist/self-hosted-start.d.ts.map +1 -1
  103. package/dist/self-hosted-start.js +39 -1
  104. package/dist/self-hosted-start.js.map +1 -1
  105. package/dist/self-hosted-upgrade.d.ts +15 -6
  106. package/dist/self-hosted-upgrade.d.ts.map +1 -1
  107. package/dist/self-hosted-upgrade.js +72 -23
  108. package/dist/self-hosted-upgrade.js.map +1 -1
  109. package/dist/stack-contract.d.ts +170 -25
  110. package/dist/stack-contract.d.ts.map +1 -1
  111. package/dist/stack-contract.js +314 -37
  112. package/dist/stack-contract.js.map +1 -1
  113. package/dist/stack-converge.d.ts +31 -0
  114. package/dist/stack-converge.d.ts.map +1 -1
  115. package/dist/stack-converge.js +71 -30
  116. package/dist/stack-converge.js.map +1 -1
  117. package/dist/upgrade/app-roll.d.ts +25 -4
  118. package/dist/upgrade/app-roll.d.ts.map +1 -1
  119. package/dist/upgrade/app-roll.js +63 -17
  120. package/dist/upgrade/app-roll.js.map +1 -1
  121. package/dist/upgrade/converge.d.ts.map +1 -1
  122. package/dist/upgrade/converge.js +99 -12
  123. package/dist/upgrade/converge.js.map +1 -1
  124. package/dist/upgrade/health.d.ts +31 -7
  125. package/dist/upgrade/health.d.ts.map +1 -1
  126. package/dist/upgrade/health.js +41 -17
  127. package/dist/upgrade/health.js.map +1 -1
  128. package/dist/upgrade/image-forms.d.ts +1 -1
  129. package/dist/upgrade/image-forms.d.ts.map +1 -1
  130. package/dist/upgrade/image-forms.js +24 -7
  131. package/dist/upgrade/image-forms.js.map +1 -1
  132. package/dist/upgrade/release-images.d.ts +12 -3
  133. package/dist/upgrade/release-images.d.ts.map +1 -1
  134. package/dist/upgrade/release-images.js +13 -4
  135. package/dist/upgrade/release-images.js.map +1 -1
  136. package/dist/upgrade/upgrade.d.ts +13 -3
  137. package/dist/upgrade/upgrade.d.ts.map +1 -1
  138. package/dist/upgrade/upgrade.js +34 -2
  139. package/dist/upgrade/upgrade.js.map +1 -1
  140. package/dist/upgrader-gcp.d.ts +12 -0
  141. package/dist/upgrader-gcp.d.ts.map +1 -1
  142. package/dist/upgrader-gcp.js +49 -1
  143. package/dist/upgrader-gcp.js.map +1 -1
  144. package/dist/upgrader-iam-preflight.d.ts.map +1 -1
  145. package/dist/upgrader-iam-preflight.js +6 -1
  146. package/dist/upgrader-iam-preflight.js.map +1 -1
  147. package/dist/upgrader-job.d.ts +1 -1
  148. package/dist/upgrader-job.d.ts.map +1 -1
  149. package/dist/upgrader-job.js +20 -14
  150. package/dist/upgrader-job.js.map +1 -1
  151. package/dist/upgrader-records.d.ts +1 -1
  152. package/dist/upgrader-records.d.ts.map +1 -1
  153. package/dist/upgrader-records.js +26 -7
  154. package/dist/upgrader-records.js.map +1 -1
  155. package/dist/upgrader-setup.d.ts.map +1 -1
  156. package/dist/upgrader-setup.js +10 -5
  157. package/dist/upgrader-setup.js.map +1 -1
  158. package/dist/wizards/gcp-cost.d.ts.map +1 -1
  159. package/dist/wizards/gcp-cost.js +22 -7
  160. package/dist/wizards/gcp-cost.js.map +1 -1
  161. package/dist/wizards/gcp.d.ts.map +1 -1
  162. package/dist/wizards/gcp.js +43 -17
  163. package/dist/wizards/gcp.js.map +1 -1
  164. package/package.json +2 -2
  165. package/src/admin-domain.test.ts +9 -8
  166. package/src/admin-domain.ts +13 -3
  167. package/src/admin-memory.test.ts +1 -0
  168. package/src/adopt-existing.test.ts +39 -0
  169. package/src/adopt-existing.ts +83 -1
  170. package/src/agent/command.test.ts +49 -1
  171. package/src/agent/command.ts +36 -9
  172. package/src/agent/run.test.ts +19 -0
  173. package/src/agent/run.ts +11 -2
  174. package/src/aws-task-roles.ts +5 -2
  175. package/src/caddy-redirects.docker.test.ts +2 -0
  176. package/src/caddy-redirects.test.ts +3 -0
  177. package/src/caddy-validate.test.ts +7 -1
  178. package/src/caddy.test.ts +39 -1
  179. package/src/caddy.ts +36 -4
  180. package/src/checkpoint-secrets.test.ts +14 -6
  181. package/src/cli.ts +12 -1
  182. package/src/cloudsql-tls.test.ts +225 -0
  183. package/src/cloudsql-tls.ts +352 -0
  184. package/src/compose.ts +125 -24
  185. package/src/converge-checkpoint.ts +8 -1
  186. package/src/converge-job-gcp.ts +2 -11
  187. package/src/database-tls-builders.test.ts +55 -0
  188. package/src/deployed-release.test.ts +104 -40
  189. package/src/deployed-release.ts +30 -9
  190. package/src/gateway-env-isolation.test.ts +10 -5
  191. package/src/gcp-names.ts +31 -0
  192. package/src/gcp-url-map.test.ts +6 -8
  193. package/src/gcp-url-map.ts +6 -2
  194. package/src/index.test.ts +7 -1
  195. package/src/index.ts +1 -0
  196. package/src/infra-contract.test.ts +5 -3
  197. package/src/infra-contract.ts +24 -1
  198. package/src/install-state.ts +26 -4
  199. package/src/lifecycle.ts +20 -11
  200. package/src/mcp-auth-proxy-setup.test.ts +305 -0
  201. package/src/mcp-auth-proxy-setup.ts +374 -0
  202. package/src/mcp-iap-stacks.test.ts +1 -1
  203. package/src/media-volume.test.ts +1 -1
  204. package/src/operator-access-stacks.test.ts +1 -1
  205. package/src/operator-access.test.ts +5 -4
  206. package/src/pool-budget-stacks.test.ts +1 -1
  207. package/src/release-manifest.test.ts +25 -0
  208. package/src/release-manifest.ts +59 -4
  209. package/src/remote-mcp-stack-config.test.ts +19 -9
  210. package/src/remote-mcp.test.ts +252 -57
  211. package/src/remote-mcp.ts +336 -77
  212. package/src/runtime-secrets.test.ts +20 -7
  213. package/src/runtime-secrets.ts +50 -9
  214. package/src/seal-key-rotation.test.ts +2 -2
  215. package/src/seal-key-rotation.ts +25 -25
  216. package/src/self-hosted-compose.test.ts +108 -5
  217. package/src/self-hosted-db-tls.test.ts +111 -0
  218. package/src/self-hosted-db-tls.ts +287 -0
  219. package/src/self-hosted-release-resolve.test.ts +71 -0
  220. package/src/self-hosted-release.ts +41 -11
  221. package/src/self-hosted-roles.test.ts +54 -0
  222. package/src/self-hosted-roles.ts +58 -0
  223. package/src/self-hosted-start.test.ts +34 -2
  224. package/src/self-hosted-start.ts +20 -2
  225. package/src/self-hosted-upgrade.test.ts +103 -14
  226. package/src/self-hosted-upgrade.ts +115 -29
  227. package/src/stack-contract-parity.test.ts +79 -12
  228. package/src/stack-contract.test.ts +169 -29
  229. package/src/stack-contract.ts +362 -42
  230. package/src/stack-converge-secrets.test.ts +26 -13
  231. package/src/stack-converge.test.ts +199 -26
  232. package/src/stack-converge.ts +92 -30
  233. package/src/state-store.test.ts +2 -1
  234. package/src/upgrade/app-roll.ts +97 -19
  235. package/src/upgrade/converge.ts +120 -11
  236. package/src/upgrade/health.test.ts +63 -25
  237. package/src/upgrade/health.ts +54 -22
  238. package/src/upgrade/image-forms.test.ts +34 -0
  239. package/src/upgrade/image-forms.ts +23 -8
  240. package/src/upgrade/release-images.test.ts +5 -1
  241. package/src/upgrade/release-images.ts +22 -6
  242. package/src/upgrade/upgrade.test.ts +353 -44
  243. package/src/upgrade/upgrade.ts +55 -6
  244. package/src/upgrader-gcp.ts +65 -3
  245. package/src/upgrader-iam-preflight.test.ts +19 -0
  246. package/src/upgrader-iam-preflight.ts +6 -0
  247. package/src/upgrader-job.test.ts +101 -27
  248. package/src/upgrader-job.ts +27 -14
  249. package/src/upgrader-records.test.ts +97 -12
  250. package/src/upgrader-records.ts +28 -11
  251. package/src/upgrader-setup.test.ts +2 -0
  252. package/src/upgrader-setup.ts +10 -4
  253. package/src/wizards/gcp-cost.test.ts +50 -0
  254. package/src/wizards/gcp-cost.ts +24 -7
  255. package/src/wizards/gcp.ts +55 -14
  256. package/stacks/aws/index.ts +32 -14
  257. package/stacks/azure/index.ts +57 -18
  258. package/stacks/gcp/index.ts +83 -23
  259. package/stacks/gcp-firebase/index.ts +162 -33
  260. package/stacks/self-hosted/README.md +2 -1
  261. package/stacks/self-hosted/index.ts +11 -1
@@ -30,6 +30,12 @@
30
30
 
31
31
  import { ensureAuditJob, resolveAdminImage } from "../audit-job-setup.js";
32
32
  import { ensureCaptureJob } from "../capture-job-setup.js";
33
+ import {
34
+ dialsIpAddress,
35
+ ensureCloudSqlVerifyFull,
36
+ liveDatabaseUrls,
37
+ moveServicesToDnsHost,
38
+ } from "../cloudsql-tls.js";
33
39
  import { ensureConvergeJob } from "../converge-job-setup.js";
34
40
  import { findSqlInstance, planPublicRoleSwitch } from "../gateway-credentials.js";
35
41
  import type { GcloudRunner, Sleep } from "../gcloud-retry.js";
@@ -54,6 +60,8 @@ import {
54
60
  CAPTURE_JOB_ENV_VAR,
55
61
  type CloudRunEnvVar,
56
62
  CONVERGE_JOB_ENV_VAR,
63
+ DATABASE_URL_VARS,
64
+ INTERIM_FORWARD_ORIGIN_ENV,
57
65
  MCP_ENV_VAR,
58
66
  OPERATOR_ACCESS_JOB_ENV_VAR,
59
67
  remoteMcpIdentityEnv,
@@ -68,7 +76,7 @@ import {
68
76
  import { ensureUpgraderJob, resolveUpgraderTarget } from "../upgrader-setup.js";
69
77
  import { ensureUrlMapRedirectsAccess } from "../url-map-redirects-access.js";
70
78
  import type { AppRollHooks, AppRollPlan, RollPlan, RollSlug } from "./app-roll.js";
71
- import { lookupFailure } from "./gcp-lookup.js";
79
+ import { findServiceUrl, lookupFailure } from "./gcp-lookup.js";
72
80
  import type { UpgradeReporter } from "./reporter.js";
73
81
 
74
82
  /**
@@ -139,7 +147,11 @@ export function startConverge(
139
147
  const { reporter: r, run } = deps;
140
148
  const runDeps = { run, ...(deps.sleep ? { sleep: deps.sleep } : {}) };
141
149
  const service = (slug: RollSlug) => plan.services.find((s) => s.slug === slug) as RollPlan;
142
- const services = { admin: service("admin").serviceName, gateway: service("gateway").serviceName };
150
+ const services = {
151
+ admin: service("admin").serviceName,
152
+ gateway: service("gateway").serviceName,
153
+ "mcp-auth-proxy": service("mcp-auth-proxy").serviceName,
154
+ };
143
155
  const target = {
144
156
  provider: install.provider,
145
157
  projectId: install.projectId,
@@ -371,33 +383,50 @@ export function startConverge(
371
383
  }
372
384
 
373
385
  async function remoteMcpEdge(): Promise<void> {
374
- // #806 — on `gcp` the load balancer routes the remote-MCP discovery
375
- // paths to the gateway and Cloud Armor lets agent traffic through on
376
- // /api/mcp/* (remote-mcp-edge.ts). Only remote MCP depends on it, so a
377
- // failure warns.
386
+ // #806 / #1328 — on `gcp` the load balancer routes the remote-MCP paths
387
+ // to the MCP-Auth-Proxy's own backend, whose Cloud Armor policy lets
388
+ // agent traffic through on /api/mcp/* (remote-mcp-edge.ts). Only remote
389
+ // MCP depends on it, so a failure warns. The gateway being rolled in
390
+ // this run no longer answers those paths, so until this succeeds remote
391
+ // MCP is down on the site's domain — said below.
378
392
  if (install.provider !== "gcp") return;
379
- const s = r.step("Ensuring the load balancer's remote-MCP routes and Cloud Armor rule...");
393
+ const s = r.step("Ensuring the MCP-Auth-Proxy's load-balancer route and Cloud Armor rule...");
380
394
  const edge = await ensureRemoteMcpEdge(
381
395
  {
382
396
  provider: install.provider,
383
397
  projectId: install.projectId,
398
+ region: install.region,
384
399
  env: install.env,
385
400
  domain: install.domain,
401
+ mcpAuthProxyImageRef: service("mcp-auth-proxy").imageRef,
386
402
  },
387
403
  { ...runDeps, ...(deps.http ? { http: deps.http } : {}) },
388
404
  );
389
405
  if (edge.ok) {
390
406
  for (const line of edge.done) r.info(` ${line}`);
391
- s.done("Remote-MCP load-balancer rules ready");
407
+ s.done("Remote-MCP load-balancer route ready");
392
408
  return;
393
409
  }
394
- s.warned(`Remote-MCP load-balancer rules not set up: ${edge.error}`);
410
+ s.warned(`Remote-MCP load-balancer route not set up: ${edge.error}`);
395
411
  r.warn(
396
412
  "The upgrade continues; remote MCP (Settings → MCP → Remote) cannot be reached through the site's domain until this succeeds. Re-run upgrade once the cause is fixed.",
397
413
  );
398
414
  incomplete = true;
399
415
  }
400
416
 
417
+ async function interimForwardEnv(): Promise<CloudRunEnvVar[]> {
418
+ // #1328 — on gcp-firebase the proxy is the service the current live
419
+ // release's Firebase rewrite and the staging domain mapping still
420
+ // point at; it forwards that traffic to the data gateway, whose URL is
421
+ // its origin (stack-contract.ts INTERIM_FORWARD_ORIGIN_ENV). Without it
422
+ // the proxy answers the live site's /api/** with 404 until the next
423
+ // Publish live, and the staging host with 404 — so this aborts.
424
+ if (install.provider !== "gcp-firebase") return [];
425
+ const url = await findServiceUrl(run, install.projectId, install.region, services.gateway);
426
+ if (!url) throw new Error(`could not read the URL of ${services.gateway}`);
427
+ return [{ name: INTERIM_FORWARD_ORIGIN_ENV, value: new URL(url).origin }];
428
+ }
429
+
401
430
  async function remoteMcpIdentity(): Promise<Record<RollSlug, CloudRunEnvVar[]> | null> {
402
431
  // #806 — CAELO_ADMIN_URL / CAELO_MCP_PUBLIC_URL / CAELO_IAP_AUDIENCE
403
432
  // name identities the env contract cannot (stack-contract.ts
@@ -459,6 +488,71 @@ export function startConverge(
459
488
  return name ? { kind: "backend-services", service: name } : null;
460
489
  }
461
490
 
491
+ /** #1338 — the host the database TLS step moved the services onto. */
492
+ let databaseHost: string | null = null;
493
+
494
+ async function databaseTls(): Promise<boolean> {
495
+ // #1338 — verify-full TLS needs the shared server CA, the instance's
496
+ // DNS name resolving in the VPC, and the services dialling that name
497
+ // (cloudsql-tls.ts). Before migrations: the migration job reads its host
498
+ // from the admin's live URL, and the new images refuse anything less
499
+ // than verify-full. A failure aborts — the new revisions could not
500
+ // reach the database.
501
+ const s = r.step("Setting up verify-full TLS to the database (shared CA, private DNS name)...");
502
+ const liveUrls = plan.services.map((p) => ({
503
+ serviceName: p.serviceName,
504
+ liveUrls: liveDatabaseUrls(p.liveEnv, DATABASE_URL_VARS[p.slug]),
505
+ }));
506
+ // Only this step moves the services off the instance's IP, and only
507
+ // after the instance, its certificate and the DNS name are in shape —
508
+ // so services that dial a host name already went through it.
509
+ const onIp = liveUrls.some((u) => Object.values(u.liveUrls).some((v) => dialsIpAddress(v)));
510
+ if (!onIp) {
511
+ s.done("Database TLS ready: the services already dial the instance's DNS name");
512
+ return true;
513
+ }
514
+ const lookup = await findSqlInstance(install, { run });
515
+ if (lookup.status !== "found") {
516
+ s.failed(
517
+ `${lookupFailure(lookup, "Cloud SQL instance", install.credentials)}. Aborting upgrade.`,
518
+ );
519
+ r.warn(
520
+ "No traffic was shifted and no migrations ran. Re-run upgrade once the instance is found.",
521
+ );
522
+ return false;
523
+ }
524
+ const tlsDeps = { ...runDeps, ...(deps.http ? { http: deps.http } : {}) };
525
+ const tls = await ensureCloudSqlVerifyFull(
526
+ { projectId: install.projectId, env: install.env, sqlInstance: lookup.name },
527
+ tlsDeps,
528
+ );
529
+ for (const line of tls.done) r.info(` ${line}`);
530
+ if (!tls.ok) {
531
+ s.failed(`Database TLS could not be set up: ${tls.error}. Aborting upgrade.`);
532
+ r.warn("No traffic was shifted and no migrations ran. Fix the above and re-run.");
533
+ return false;
534
+ }
535
+ const moved = await moveServicesToDnsHost(
536
+ { projectId: install.projectId, region: install.region },
537
+ liveUrls,
538
+ tls.host,
539
+ tlsDeps,
540
+ );
541
+ for (const line of moved.done) r.info(` ${line}`);
542
+ if (!moved.ok) {
543
+ s.failed(
544
+ `The services could not be moved onto ${tls.host}: ${moved.error}. Aborting upgrade.`,
545
+ );
546
+ r.warn("No migrations ran. Fix the above and re-run.");
547
+ return false;
548
+ }
549
+ databaseHost = tls.host;
550
+ s.done(
551
+ `Database TLS ready: the services dial ${tls.host} (verify-full from the new revisions)`,
552
+ );
553
+ return true;
554
+ }
555
+
462
556
  async function databaseLogins(): Promise<boolean> {
463
557
  // #613 — public_role moves onto its own password right before the
464
558
  // gateway's roll. The gateway has no other database login (#635).
@@ -628,6 +722,7 @@ export function startConverge(
628
722
  if (!(await runtimeIdentities())) return false;
629
723
  if (!(await stackInvariants())) return false;
630
724
  if (!(await publicRoleIsolation())) return false;
725
+ if (!(await databaseTls())) return false;
631
726
  await mcpAccess();
632
727
  await urlMapRedirectsAccess();
633
728
  await auditJob();
@@ -642,11 +737,25 @@ export function startConverge(
642
737
  await upgraderJob();
643
738
  await convergeJob();
644
739
  await operatorAccess();
645
- const extraEnv = await remoteMcpIdentity();
740
+ const identity = await remoteMcpIdentity();
741
+ let forward: CloudRunEnvVar[];
742
+ try {
743
+ forward = await interimForwardEnv();
744
+ } catch (e) {
745
+ r.error(
746
+ `The MCP-Auth-Proxy cannot be pointed at the data gateway: ${e instanceof Error ? e.message : String(e)}. Nothing was rolled; re-run upgrade once it is fixed.`,
747
+ );
748
+ return { ok: false };
749
+ }
750
+ const extraEnv: Partial<Record<RollSlug, CloudRunEnvVar[]>> = {
751
+ ...(identity ?? {}),
752
+ "mcp-auth-proxy": [...(identity?.["mcp-auth-proxy"] ?? []), ...forward],
753
+ };
646
754
  return {
647
755
  ok: true,
648
756
  leaveUntouched: [...leaveUntouched],
649
- ...(extraEnv ? { extraEnv } : {}),
757
+ extraEnv,
758
+ ...(databaseHost ? { databaseHost } : {}),
650
759
  };
651
760
  },
652
761
  async beforeRoll(slug) {
@@ -6,7 +6,7 @@ import type { HttpFetch } from "../runtime-secrets.js";
6
6
  import {
7
7
  createIapHealthProbe,
8
8
  evaluateAdminDeepHealth,
9
- evaluateGatewayDeepHealth,
9
+ evaluatePublicServiceDeepHealth,
10
10
  type HealthTarget,
11
11
  healthOrigins,
12
12
  IAM_PROPAGATION_WINDOW_MS,
@@ -72,27 +72,29 @@ describe("evaluateAdminDeepHealth", () => {
72
72
  });
73
73
  });
74
74
 
75
- describe("evaluateGatewayDeepHealth", () => {
76
- it("accepts a healthy gateway on the admin's version", () => {
77
- expect(
78
- evaluateGatewayDeepHealth(200, JSON.stringify({ ok: true, version: "0.11.0" }), "0.11.0"),
79
- ).toEqual({ ok: true, detail: "gateway 0.11.0" });
75
+ describe("evaluatePublicServiceDeepHealth", () => {
76
+ it("accepts a healthy gateway; its version is not public any more (#1326)", () => {
77
+ expect(evaluatePublicServiceDeepHealth("gateway", 200, JSON.stringify({ ok: true }))).toEqual({
78
+ ok: true,
79
+ detail: "gateway healthy",
80
+ });
80
81
  });
81
82
 
82
- it("refuses a gateway on another version than the admin", () => {
83
- const r = evaluateGatewayDeepHealth(
84
- 200,
85
- JSON.stringify({ ok: true, version: "0.10.0" }),
86
- "0.11.0",
87
- );
88
- expect(r.ok).toBe(false);
83
+ it("ignores a version an older gateway still reports", () => {
84
+ expect(
85
+ evaluatePublicServiceDeepHealth(
86
+ "gateway",
87
+ 200,
88
+ JSON.stringify({ ok: true, version: "0.10.0" }),
89
+ ).ok,
90
+ ).toBe(true);
89
91
  });
90
92
 
91
93
  it("refuses a gateway that can't reach its database", () => {
92
- const r = evaluateGatewayDeepHealth(
94
+ const r = evaluatePublicServiceDeepHealth(
95
+ "gateway",
93
96
  503,
94
- JSON.stringify({ ok: false, version: "0.11.0", error: { kind: "DbUnreachable" } }),
95
- "0.11.0",
97
+ JSON.stringify({ ok: false, error: { kind: "DbUnreachable" } }),
96
98
  );
97
99
  expect(r).toEqual({ ok: false, error: "gateway reports unhealthy (HTTP 503, DbUnreachable)" });
98
100
  });
@@ -103,7 +105,8 @@ const FIREBASE: HealthTarget = {
103
105
  projectId: "acme",
104
106
  domain: "acme.com",
105
107
  adminServiceUrl: "https://caelo-production-admin-aaa-xyz.a.run.app",
106
- gatewayServiceUrl: "https://caelo-production-gateway-bbb-xyz.a.run.app",
108
+ gatewayServiceUrl: "https://caelo-production-data-gateway-ddd-xyz.a.run.app",
109
+ mcpAuthProxyServiceUrl: "https://caelo-production-gateway-bbb-xyz.a.run.app",
107
110
  };
108
111
 
109
112
  describe("healthOrigins", () => {
@@ -111,13 +114,15 @@ describe("healthOrigins", () => {
111
114
  expect(healthOrigins({ ...FIREBASE, provider: "gcp" })).toEqual({
112
115
  admin: "https://admin.acme.com",
113
116
  gateway: "https://acme.com",
117
+ mcpAuthProxy: "https://acme.com",
114
118
  });
115
119
  });
116
120
 
117
121
  it("uses the run.app URLs on gcp-firebase", () => {
118
122
  expect(healthOrigins(FIREBASE)).toEqual({
119
123
  admin: "https://caelo-production-admin-aaa-xyz.a.run.app",
120
- gateway: "https://caelo-production-gateway-bbb-xyz.a.run.app",
124
+ gateway: "https://caelo-production-data-gateway-ddd-xyz.a.run.app",
125
+ mcpAuthProxy: "https://caelo-production-gateway-bbb-xyz.a.run.app",
121
126
  });
122
127
  });
123
128
  });
@@ -130,8 +135,12 @@ describe("createIapHealthProbe", () => {
130
135
  exitCode: 0,
131
136
  });
132
137
 
133
- /** A fake network: signJwt + the two health endpoints. */
134
- function network(answers: { admin: () => [number, string]; gateway: () => [number, string] }) {
138
+ /** A fake network: signJwt + the three health endpoints (the proxy answers healthy unless given). */
139
+ function network(answers: {
140
+ admin: () => [number, string];
141
+ gateway: () => [number, string];
142
+ proxy?: () => [number, string];
143
+ }) {
135
144
  const requests: { url: string; headers: Record<string, string>; body?: string }[] = [];
136
145
  const http: HttpFetch = async (url, init) => {
137
146
  requests.push({ url, headers: init.headers, ...(init.body ? { body: init.body } : {}) });
@@ -139,7 +148,9 @@ describe("createIapHealthProbe", () => {
139
148
  ? [200, JSON.stringify({ signedJwt: "signed.jwt" })]
140
149
  : url.includes("admin")
141
150
  ? answers.admin()
142
- : answers.gateway();
151
+ : url.endsWith("/api/mcp/health")
152
+ ? (answers.proxy ?? (() => [200, JSON.stringify({ ok: true, version: "0.11.0" })]))()
153
+ : answers.gateway();
143
154
  return { ok: status < 400, status, text: async () => text };
144
155
  };
145
156
  return { http, requests };
@@ -148,7 +159,7 @@ describe("createIapHealthProbe", () => {
148
159
  it("signs an IAP credential for the admin origin and sends it to the admin only", async () => {
149
160
  const net = network({
150
161
  admin: () => [200, adminBody()],
151
- gateway: () => [200, JSON.stringify({ ok: true, version: "0.11.0" })],
162
+ gateway: () => [200, JSON.stringify({ ok: true })],
152
163
  });
153
164
  const probe = createIapHealthProbe(FIREBASE, {
154
165
  run: gcloudOk,
@@ -160,7 +171,7 @@ describe("createIapHealthProbe", () => {
160
171
  expect(await probe.deep({ version: "0.11.0" })).toEqual({
161
172
  ok: true,
162
173
  detail:
163
- "admin 0.11.0, migrations at 0262_tool_approval_branch.sql / 0040_x.sql; gateway 0.11.0",
174
+ "admin 0.11.0, migrations at 0262_tool_approval_branch.sql / 0040_x.sql; gateway healthy; MCP-Auth-Proxy healthy",
164
175
  });
165
176
  const sign = net.requests[0];
166
177
  expect(sign?.url).toBe(
@@ -180,9 +191,36 @@ describe("createIapHealthProbe", () => {
180
191
  expect(admin?.headers.Authorization).toBe("Bearer signed.jwt");
181
192
  const gateway = net.requests.find((r) => r.url.endsWith("/api/_caelo/health?deep=1"));
182
193
  expect(gateway?.url).toBe(
183
- "https://caelo-production-gateway-bbb-xyz.a.run.app/api/_caelo/health?deep=1",
194
+ "https://caelo-production-data-gateway-ddd-xyz.a.run.app/api/_caelo/health?deep=1",
184
195
  );
185
196
  expect(gateway?.headers.Authorization).toBeUndefined();
197
+ // #1328 — the proxy, on the service that was the gateway, by version.
198
+ const proxy = net.requests.find((r) => r.url.endsWith("/api/mcp/health"));
199
+ expect(proxy?.url).toBe("https://caelo-production-gateway-bbb-xyz.a.run.app/api/mcp/health");
200
+ expect(proxy?.headers.Authorization).toBeUndefined();
201
+ });
202
+
203
+ it("#1328 — fails the deep check while the MCP-Auth-Proxy is unhealthy or does not answer", async () => {
204
+ const deepWith = (proxy: () => [number, string]) =>
205
+ createIapHealthProbe(FIREBASE, {
206
+ run: gcloudOk,
207
+ http: network({
208
+ admin: () => [200, adminBody()],
209
+ gateway: () => [200, JSON.stringify({ ok: true, version: "0.11.0" })],
210
+ proxy,
211
+ }).http,
212
+ sleep: async () => {},
213
+ accessJustChanged: false,
214
+ attempts: 1,
215
+ }).deep({ version: "0.11.0" });
216
+ expect(await deepWith(() => [503, JSON.stringify({ ok: false })])).toEqual({
217
+ ok: false,
218
+ error: "MCP-Auth-Proxy reports unhealthy (HTTP 503, no reason)",
219
+ });
220
+ expect(await deepWith(() => [404, "<html>Not Found</html>"])).toEqual({
221
+ ok: false,
222
+ error: "MCP-Auth-Proxy answered HTTP 404 without JSON",
223
+ });
186
224
  });
187
225
 
188
226
  it("retries while the new revisions warm up, then gives the last error", async () => {
@@ -194,7 +232,7 @@ describe("createIapHealthProbe", () => {
194
232
  ? [503, adminBody({ ok: false, migrations: migrations(1) })]
195
233
  : [200, adminBody()];
196
234
  },
197
- gateway: () => [200, JSON.stringify({ ok: true, version: "0.11.0" })],
235
+ gateway: () => [200, JSON.stringify({ ok: true })],
198
236
  });
199
237
  const probe = createIapHealthProbe(FIREBASE, {
200
238
  run: gcloudOk,
@@ -11,9 +11,12 @@
11
11
  * - admin `GET /_caelo/health?deep=1` → `{ ok, version, migrations }`:
12
12
  * both databases reachable as the expected roles, and every
13
13
  * migration the image ships recorded as applied.
14
- * - gateway `GET /api/_caelo/health?deep=1` → `{ ok, version }`: its
15
- * database logins work. Under `/api/` because that is the only
14
+ * - gateway `GET /api/_caelo/health?deep=1` → `{ ok }` (no version since
15
+ * #1326): its database login works. Under `/api/` because that is the only
16
16
  * path the gcp load balancer routes to the gateway.
17
+ * - MCP-Auth-Proxy `GET /api/mcp/health` → `{ ok }` (#1328, no version
18
+ * either): it has no database, so it answers once it runs; under
19
+ * `/api/mcp/`, the prefix the gcp load balancer routes to it.
17
20
  *
18
21
  * Both answer with no secrets and no raw database errors. The admin sits
19
22
  * behind IAP, so the probe signs an IAP credential as the install's MCP
@@ -134,29 +137,41 @@ export function evaluateAdminDeepHealth(
134
137
  };
135
138
  }
136
139
 
137
- /** Judge the gateway's deep health answer against the version the admin runs. */
138
- export function evaluateGatewayDeepHealth(
140
+ /**
141
+ * Judge a public service's deep health answer: the gateway's, or the
142
+ * MCP-Auth-Proxy's (#1328). Since #1326 the public services report no
143
+ * release version (a public version string names the flaws to try), so
144
+ * this proves only that the service answers healthy — for the gateway,
145
+ * that its database login works. Which release it runs the roll itself
146
+ * establishes: `update --image` with the pinned digest succeeded and
147
+ * traffic moved to that revision (app-roll.ts), or the roll already failed.
148
+ *
149
+ * @param service how the errors name it (`gateway`, `MCP-Auth-Proxy`)
150
+ */
151
+ export function evaluatePublicServiceDeepHealth(
152
+ service: string,
139
153
  status: number,
140
154
  text: string,
141
- expectedVersion: string,
142
155
  ): HealthOutcome {
143
- const body = parseJson<{ ok?: boolean; version?: string; error?: { kind?: string } }>(text);
144
- if (!body) return { ok: false, error: `gateway answered HTTP ${status} without JSON` };
156
+ const body = parseJson<{ ok?: boolean; error?: { kind?: string } }>(text);
157
+ if (!body) return { ok: false, error: `${service} answered HTTP ${status} without JSON` };
145
158
  if (status !== 200 || body.ok !== true) {
146
159
  return {
147
160
  ok: false,
148
- error: `gateway reports unhealthy (HTTP ${status}, ${body.error?.kind ?? "no reason"})`,
161
+ error: `${service} reports unhealthy (HTTP ${status}, ${body.error?.kind ?? "no reason"})`,
149
162
  };
150
163
  }
151
- if (body.version !== expectedVersion) {
152
- return {
153
- ok: false,
154
- error: `gateway runs ${body.version ?? "an unknown version"}, the admin runs ${expectedVersion}`,
155
- };
156
- }
157
- return { ok: true, detail: `gateway ${body.version}` };
164
+ return { ok: true, detail: `${service} healthy` };
158
165
  }
159
166
 
167
+ /**
168
+ * The MCP-Auth-Proxy's health path (apps/mcp-auth-proxy server.ts). Under
169
+ * `/api/mcp/` because on gcp that is the prefix the load balancer routes to
170
+ * the proxy; it answers before any other routing, also while remote MCP is
171
+ * off.
172
+ */
173
+ export const MCP_AUTH_PROXY_HEALTH_PATH = "/api/mcp/health";
174
+
160
175
  /** Where the probe reaches the install. */
161
176
  export interface HealthTarget {
162
177
  readonly provider: "gcp" | "gcp-firebase";
@@ -166,20 +181,32 @@ export interface HealthTarget {
166
181
  readonly adminServiceUrl: string;
167
182
  /** The gateway service's `run.app` URL (public on gcp-firebase). */
168
183
  readonly gatewayServiceUrl: string;
184
+ /** The MCP-Auth-Proxy service's `run.app` URL (public on gcp-firebase). */
185
+ readonly mcpAuthProxyServiceUrl: string;
169
186
  }
170
187
 
171
188
  /**
172
189
  * The origins the probe calls. On gcp the admin's IAP lives on the load
173
- * balancer (`admin.<domain>`) and the gateway's ingress is load-balancer-only
174
- * (`<domain>/api/*`); on gcp-firebase both `run.app` URLs are reachable — the
175
- * admin's through Cloud Run's own IAP, the gateway's publicly.
190
+ * balancer (`admin.<domain>`), and the gateway's and the proxy's ingress is
191
+ * load-balancer-only (`<domain>/api/*`, `<domain>/api/mcp/*`); on
192
+ * gcp-firebase every `run.app` URL is reachable — the admin's through Cloud
193
+ * Run's own IAP, the others publicly.
176
194
  */
177
- export function healthOrigins(target: HealthTarget): { admin: string; gateway: string } {
195
+ export function healthOrigins(target: HealthTarget): {
196
+ admin: string;
197
+ gateway: string;
198
+ mcpAuthProxy: string;
199
+ } {
178
200
  return target.provider === "gcp"
179
- ? { admin: `https://admin.${target.domain}`, gateway: `https://${target.domain}` }
201
+ ? {
202
+ admin: `https://admin.${target.domain}`,
203
+ gateway: `https://${target.domain}`,
204
+ mcpAuthProxy: `https://${target.domain}`,
205
+ }
180
206
  : {
181
207
  admin: new URL(target.adminServiceUrl).origin,
182
208
  gateway: new URL(target.gatewayServiceUrl).origin,
209
+ mcpAuthProxy: new URL(target.mcpAuthProxyServiceUrl).origin,
183
210
  };
184
211
  }
185
212
 
@@ -437,7 +464,12 @@ export function createIapHealthProbe(target: HealthTarget, deps: HealthProbeDeps
437
464
  if (!a.ok) return a;
438
465
  const gateway = await get(`${origins.gateway}/api/_caelo/health?deep=1`, {});
439
466
  if (!gateway.reached) return { ok: false, error: `gateway unreachable: ${gateway.error}` };
440
- const g = evaluateGatewayDeepHealth(gateway.status, gateway.text, a.version);
441
- return g.ok ? { ok: true, detail: `${a.detail}; ${g.detail}` } : g;
467
+ const g = evaluatePublicServiceDeepHealth("gateway", gateway.status, gateway.text);
468
+ if (!g.ok) return g;
469
+ // #1328 — the proxy holds no database login: healthy once it answers.
470
+ const proxy = await get(`${origins.mcpAuthProxy}${MCP_AUTH_PROXY_HEALTH_PATH}`, {});
471
+ if (!proxy.reached) return { ok: false, error: `MCP-Auth-Proxy unreachable: ${proxy.error}` };
472
+ const p = evaluatePublicServiceDeepHealth("MCP-Auth-Proxy", proxy.status, proxy.text);
473
+ return p.ok ? { ok: true, detail: `${a.detail}; ${g.detail}; ${p.detail}` } : p;
442
474
  }
443
475
  }
@@ -184,6 +184,40 @@ describe("platformDigestsOf", () => {
184
184
  expect(calls).toEqual([]);
185
185
  });
186
186
 
187
+ it("#1328: carries the MCP-Auth-Proxy's signed form, and skips a proxy digest install.json lacks", async () => {
188
+ const calls: string[] = [];
189
+ const forms = createReleaseImageForms(fakeRegistryFetch(calls));
190
+ const m = {
191
+ version: "0.12.0",
192
+ digests: { admin: D("a"), gateway: D("b"), ops: D("c"), "mcp-auth-proxy": D("d") },
193
+ platformDigests: { admin: D("1"), gateway: D("2"), ops: D("3"), "mcp-auth-proxy": D("4") },
194
+ };
195
+ expect(
196
+ await platformDigestsOf(
197
+ { admin: D("a"), gateway: D("b"), "mcp-auth-proxy": D("d") },
198
+ forms,
199
+ m,
200
+ ),
201
+ ).toEqual({
202
+ ok: true,
203
+ platformDigests: { admin: D("1"), gateway: D("2"), "mcp-auth-proxy": D("4") },
204
+ });
205
+ // Recorded before the proxy existed: no proxy form, and nothing looked up for it.
206
+ expect(await platformDigestsOf({ admin: D("a"), gateway: D("b") }, forms, m)).toEqual({
207
+ ok: true,
208
+ platformDigests: { admin: D("1"), gateway: D("2") },
209
+ });
210
+ expect(calls).toEqual([]);
211
+ });
212
+
213
+ it("#1328: a release from before the proxy ships no proxy image form", async () => {
214
+ const forms = createReleaseImageForms(fakeRegistryFetch([]));
215
+ const m = { version: "0.11.0", digests: { admin: D("a"), gateway: D("b"), ops: D("c") } };
216
+ const r = await forms.of(m, "mcp-auth-proxy");
217
+ expect(r).toMatchObject({ ok: false, transient: false });
218
+ expect(r.ok ? "" : r.message).toContain("ships no mcp-auth-proxy image");
219
+ });
220
+
187
221
  it("says why when an index cannot be read", async () => {
188
222
  const forms = createReleaseImageForms(fakeRegistryFetch([], { status: 503 }));
189
223
  const r = await platformDigestsOf({ admin: ADMIN_INDEX, gateway: GATEWAY_INDEX }, forms);
@@ -30,7 +30,7 @@
30
30
  import { z } from "zod";
31
31
  import type { ImageDigests } from "../install-state.js";
32
32
  import type { ReleaseManifest } from "../release-manifest.js";
33
- import type { RollSlug } from "./app-roll.js";
33
+ import { ROLL_ORDER, type RollSlug } from "./app-roll.js";
34
34
  import { fetchRegistryManifest, IMAGE_DIGEST, RELEASE_REGISTRY } from "./release-images.js";
35
35
 
36
36
  /** The digests one release image is known by. */
@@ -178,12 +178,20 @@ export function createReleaseImageForms(fetchImpl: typeof fetch = fetch): Releas
178
178
  return {
179
179
  ofIndex,
180
180
  of: async (m, slug) => {
181
+ const index = m.digests[slug];
182
+ if (!index) {
183
+ // #1328 — a release from before the MCP-Auth-Proxy signs no image of it.
184
+ return {
185
+ ok: false,
186
+ transient: false,
187
+ message: `release ${m.version} ships no ${slug} image`,
188
+ };
189
+ }
181
190
  const signed = m.platformDigests?.[slug];
182
191
  if (signed) {
183
- const index = m.digests[slug];
184
192
  return { ok: true, digests: [index, ...(signed === index ? [] : [signed])], amd64: signed };
185
193
  }
186
- return ofIndex(slug, m.digests[slug]);
194
+ return ofIndex(slug, index);
187
195
  },
188
196
  };
189
197
  }
@@ -205,22 +213,29 @@ export async function platformDigestsOf(
205
213
  | { readonly ok: false; readonly message: string }
206
214
  > {
207
215
  const out: Partial<Record<RollSlug, string>> = {};
208
- for (const slug of ["admin", "gateway"] as const) {
216
+ for (const slug of ROLL_ORDER) {
217
+ const index = digests[slug];
218
+ // #1328 — digests recorded before the MCP-Auth-Proxy existed carry none.
219
+ if (!index) continue;
209
220
  const found =
210
- manifest && manifest.digests[slug] === digests[slug]
221
+ manifest && manifest.digests[slug] === index
211
222
  ? await forms.of(manifest, slug)
212
- : await forms.ofIndex(slug, digests[slug]);
223
+ : await forms.ofIndex(slug, index);
213
224
  if (!found.ok) return { ok: false, message: found.message };
214
225
  if (!found.amd64) {
215
226
  return {
216
227
  ok: false,
217
- message: `the ${slug} image index ${digests[slug]} lists no single linux/amd64 manifest`,
228
+ message: `the ${slug} image index ${index} lists no single linux/amd64 manifest`,
218
229
  };
219
230
  }
220
231
  out[slug] = found.amd64;
221
232
  }
222
233
  const { admin, gateway } = out;
234
+ const proxy = out["mcp-auth-proxy"];
223
235
  return admin && gateway
224
- ? { ok: true, platformDigests: { admin, gateway } }
236
+ ? {
237
+ ok: true,
238
+ platformDigests: { admin, gateway, ...(proxy ? { "mcp-auth-proxy": proxy } : {}) },
239
+ }
225
240
  : { ok: false, message: "the platform digests could not be read" };
226
241
  }
@@ -70,7 +70,11 @@ describe("resolveRegistryDigest", () => {
70
70
  const missing = fakeFetch(() => new Response(null, { status: 404, statusText: "Not Found" }));
71
71
  expect(
72
72
  await resolveRegistryDigest("ghcr.io", "caelo-cms/admin", "9.9.9", missing.impl),
73
- ).toEqual({ ok: false, reason: "HTTP 404 Not Found" });
73
+ ).toEqual({ ok: false, reason: "HTTP 404 Not Found", notFound: true });
74
+ // Only a 404 is the verdict "no such tag" (#1328); an outage is not.
75
+ const down = fakeFetch(() => new Response(null, { status: 503 }));
76
+ const outage = await resolveRegistryDigest("ghcr.io", "caelo-cms/admin", "9.9.9", down.impl);
77
+ expect(outage.ok === false && "notFound" in outage).toBe(false);
74
78
  const bad = fakeFetch(
75
79
  () => new Response(null, { status: 200, headers: { "docker-content-digest": "md5:x" } }),
76
80
  );