@caelo-cms/provisioning 0.10.60 → 0.10.62

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 (286) hide show
  1. package/caddy/Caddyfile.production +7 -0
  2. package/caddy/Caddyfile.staging +7 -0
  3. package/dist/admin-internal-answer.d.ts +12 -0
  4. package/dist/admin-internal-answer.d.ts.map +1 -0
  5. package/dist/admin-internal-answer.js +32 -0
  6. package/dist/admin-internal-answer.js.map +1 -0
  7. package/dist/admin-internal-google.d.ts +4 -0
  8. package/dist/admin-internal-google.d.ts.map +1 -0
  9. package/dist/admin-internal-google.js +161 -0
  10. package/dist/admin-internal-google.js.map +1 -0
  11. package/dist/admin-internal-self-hosted.d.ts +30 -0
  12. package/dist/admin-internal-self-hosted.d.ts.map +1 -0
  13. package/dist/admin-internal-self-hosted.js +92 -0
  14. package/dist/admin-internal-self-hosted.js.map +1 -0
  15. package/dist/admin-internal.d.ts +33 -12
  16. package/dist/admin-internal.d.ts.map +1 -1
  17. package/dist/admin-internal.js +27 -159
  18. package/dist/admin-internal.js.map +1 -1
  19. package/dist/agent/command.d.ts +7 -0
  20. package/dist/agent/command.d.ts.map +1 -1
  21. package/dist/agent/command.js +1 -0
  22. package/dist/agent/command.js.map +1 -1
  23. package/dist/agent/lock.d.ts +13 -1
  24. package/dist/agent/lock.d.ts.map +1 -1
  25. package/dist/agent/lock.js +62 -5
  26. package/dist/agent/lock.js.map +1 -1
  27. package/dist/agent/run.d.ts +5 -1
  28. package/dist/agent/run.d.ts.map +1 -1
  29. package/dist/agent/run.js +8 -1
  30. package/dist/agent/run.js.map +1 -1
  31. package/dist/agent-db.js +192 -90
  32. package/dist/caddy.d.ts.map +1 -1
  33. package/dist/caddy.js +11 -1
  34. package/dist/caddy.js.map +1 -1
  35. package/dist/cli-routing.js +1 -1
  36. package/dist/cli-routing.js.map +1 -1
  37. package/dist/cli.js +95 -38
  38. package/dist/cli.js.map +1 -1
  39. package/dist/compose.d.ts +7 -0
  40. package/dist/compose.d.ts.map +1 -1
  41. package/dist/compose.js +5 -0
  42. package/dist/compose.js.map +1 -1
  43. package/dist/converge-job-gcp.d.ts.map +1 -1
  44. package/dist/converge-job-gcp.js +10 -7
  45. package/dist/converge-job-gcp.js.map +1 -1
  46. package/dist/converge-job-preflight.d.ts +21 -0
  47. package/dist/converge-job-preflight.d.ts.map +1 -0
  48. package/dist/converge-job-preflight.js +111 -0
  49. package/dist/converge-job-preflight.js.map +1 -0
  50. package/dist/converge-job.d.ts +8 -2
  51. package/dist/converge-job.d.ts.map +1 -1
  52. package/dist/converge-job.js +14 -0
  53. package/dist/converge-job.js.map +1 -1
  54. package/dist/gateway-credentials.d.ts +5 -3
  55. package/dist/gateway-credentials.d.ts.map +1 -1
  56. package/dist/gateway-credentials.js +5 -8
  57. package/dist/gateway-credentials.js.map +1 -1
  58. package/dist/gcloud.d.ts +29 -0
  59. package/dist/gcloud.d.ts.map +1 -1
  60. package/dist/gcloud.js +66 -0
  61. package/dist/gcloud.js.map +1 -1
  62. package/dist/infra-contract.d.ts +1 -1
  63. package/dist/infra-contract.d.ts.map +1 -1
  64. package/dist/infra-contract.js +10 -1
  65. package/dist/infra-contract.js.map +1 -1
  66. package/dist/install-job.d.ts +3 -1
  67. package/dist/install-job.d.ts.map +1 -1
  68. package/dist/install-job.js.map +1 -1
  69. package/dist/install-state.d.ts +10 -0
  70. package/dist/install-state.d.ts.map +1 -1
  71. package/dist/install-state.js +12 -0
  72. package/dist/install-state.js.map +1 -1
  73. package/dist/lifecycle.d.ts +12 -0
  74. package/dist/lifecycle.d.ts.map +1 -1
  75. package/dist/lifecycle.js +113 -28
  76. package/dist/lifecycle.js.map +1 -1
  77. package/dist/mcp-iap.d.ts +2 -0
  78. package/dist/mcp-iap.d.ts.map +1 -1
  79. package/dist/mcp-iap.js +50 -17
  80. package/dist/mcp-iap.js.map +1 -1
  81. package/dist/operator-access.d.ts +1 -2
  82. package/dist/operator-access.d.ts.map +1 -1
  83. package/dist/operator-access.js +1 -2
  84. package/dist/operator-access.js.map +1 -1
  85. package/dist/owner-reset.d.ts +16 -19
  86. package/dist/owner-reset.d.ts.map +1 -1
  87. package/dist/owner-reset.js +92 -19
  88. package/dist/owner-reset.js.map +1 -1
  89. package/dist/preview-plan.d.ts +71 -0
  90. package/dist/preview-plan.d.ts.map +1 -0
  91. package/dist/preview-plan.js +168 -0
  92. package/dist/preview-plan.js.map +1 -0
  93. package/dist/public-role-isolation.d.ts +2 -0
  94. package/dist/public-role-isolation.d.ts.map +1 -1
  95. package/dist/public-role-isolation.js +9 -3
  96. package/dist/public-role-isolation.js.map +1 -1
  97. package/dist/release-contract.d.ts.map +1 -1
  98. package/dist/release-contract.js +4 -0
  99. package/dist/release-contract.js.map +1 -1
  100. package/dist/self-hosted-internal-secret.d.ts +27 -0
  101. package/dist/self-hosted-internal-secret.d.ts.map +1 -0
  102. package/dist/self-hosted-internal-secret.js +69 -0
  103. package/dist/self-hosted-internal-secret.js.map +1 -0
  104. package/dist/self-hosted-release.d.ts +3 -1
  105. package/dist/self-hosted-release.d.ts.map +1 -1
  106. package/dist/self-hosted-release.js +6 -2
  107. package/dist/self-hosted-release.js.map +1 -1
  108. package/dist/self-hosted-upgrade.d.ts +13 -1
  109. package/dist/self-hosted-upgrade.d.ts.map +1 -1
  110. package/dist/self-hosted-upgrade.js +26 -2
  111. package/dist/self-hosted-upgrade.js.map +1 -1
  112. package/dist/stack-converge.d.ts +5 -2
  113. package/dist/stack-converge.d.ts.map +1 -1
  114. package/dist/stack-converge.js +73 -8
  115. package/dist/stack-converge.js.map +1 -1
  116. package/dist/staging-admin.d.ts +4 -1
  117. package/dist/staging-admin.d.ts.map +1 -1
  118. package/dist/staging-admin.js.map +1 -1
  119. package/dist/state-backend.d.ts +3 -2
  120. package/dist/state-backend.d.ts.map +1 -1
  121. package/dist/state-backend.js +3 -2
  122. package/dist/state-backend.js.map +1 -1
  123. package/dist/state-command.d.ts +17 -0
  124. package/dist/state-command.d.ts.map +1 -1
  125. package/dist/state-command.js +34 -5
  126. package/dist/state-command.js.map +1 -1
  127. package/dist/state-migrate.d.ts +14 -3
  128. package/dist/state-migrate.d.ts.map +1 -1
  129. package/dist/state-migrate.js +31 -7
  130. package/dist/state-migrate.js.map +1 -1
  131. package/dist/state-pulumi.d.ts +0 -2
  132. package/dist/state-pulumi.d.ts.map +1 -1
  133. package/dist/state-pulumi.js +19 -20
  134. package/dist/state-pulumi.js.map +1 -1
  135. package/dist/static-security-headers.d.ts +86 -0
  136. package/dist/static-security-headers.d.ts.map +1 -0
  137. package/dist/static-security-headers.js +126 -0
  138. package/dist/static-security-headers.js.map +1 -0
  139. package/dist/static-svg-gcp.d.ts +28 -0
  140. package/dist/static-svg-gcp.d.ts.map +1 -0
  141. package/dist/static-svg-gcp.js +106 -0
  142. package/dist/static-svg-gcp.js.map +1 -0
  143. package/dist/upgrade/app-roll.d.ts +3 -0
  144. package/dist/upgrade/app-roll.d.ts.map +1 -1
  145. package/dist/upgrade/app-roll.js +12 -5
  146. package/dist/upgrade/app-roll.js.map +1 -1
  147. package/dist/upgrade/backup.d.ts +2 -0
  148. package/dist/upgrade/backup.d.ts.map +1 -1
  149. package/dist/upgrade/backup.js +5 -4
  150. package/dist/upgrade/backup.js.map +1 -1
  151. package/dist/upgrade/converge.d.ts +7 -0
  152. package/dist/upgrade/converge.d.ts.map +1 -1
  153. package/dist/upgrade/converge.js +19 -7
  154. package/dist/upgrade/converge.js.map +1 -1
  155. package/dist/upgrade/gcp-lookup.d.ts +38 -3
  156. package/dist/upgrade/gcp-lookup.d.ts.map +1 -1
  157. package/dist/upgrade/gcp-lookup.js +31 -9
  158. package/dist/upgrade/gcp-lookup.js.map +1 -1
  159. package/dist/upgrade/health.d.ts +31 -5
  160. package/dist/upgrade/health.d.ts.map +1 -1
  161. package/dist/upgrade/health.js +114 -25
  162. package/dist/upgrade/health.js.map +1 -1
  163. package/dist/upgrade/release-images.d.ts +17 -0
  164. package/dist/upgrade/release-images.d.ts.map +1 -1
  165. package/dist/upgrade/release-images.js +23 -12
  166. package/dist/upgrade/release-images.js.map +1 -1
  167. package/dist/upgrade/reporter.d.ts +2 -0
  168. package/dist/upgrade/reporter.d.ts.map +1 -1
  169. package/dist/upgrade/reporter.js +1 -0
  170. package/dist/upgrade/reporter.js.map +1 -1
  171. package/dist/upgrade/upgrade.d.ts +11 -1
  172. package/dist/upgrade/upgrade.d.ts.map +1 -1
  173. package/dist/upgrade/upgrade.js +5 -2
  174. package/dist/upgrade/upgrade.js.map +1 -1
  175. package/dist/upgrader-gcp.d.ts +12 -2
  176. package/dist/upgrader-gcp.d.ts.map +1 -1
  177. package/dist/upgrader-gcp.js +22 -13
  178. package/dist/upgrader-gcp.js.map +1 -1
  179. package/dist/upgrader-iam-preflight.d.ts +16 -3
  180. package/dist/upgrader-iam-preflight.d.ts.map +1 -1
  181. package/dist/upgrader-iam-preflight.js +9 -4
  182. package/dist/upgrader-iam-preflight.js.map +1 -1
  183. package/dist/upgrader-job.d.ts.map +1 -1
  184. package/dist/upgrader-job.js +10 -8
  185. package/dist/upgrader-job.js.map +1 -1
  186. package/dist/upgrader-reporter.d.ts.map +1 -1
  187. package/dist/upgrader-reporter.js +1 -0
  188. package/dist/upgrader-reporter.js.map +1 -1
  189. package/dist/wizards/gcp-pulumi.d.ts +70 -23
  190. package/dist/wizards/gcp-pulumi.d.ts.map +1 -1
  191. package/dist/wizards/gcp-pulumi.js +118 -39
  192. package/dist/wizards/gcp-pulumi.js.map +1 -1
  193. package/dist/wizards/gcp.d.ts.map +1 -1
  194. package/dist/wizards/gcp.js +67 -11
  195. package/dist/wizards/gcp.js.map +1 -1
  196. package/package.json +2 -2
  197. package/src/admin-internal-answer.ts +43 -0
  198. package/src/admin-internal-google.ts +187 -0
  199. package/src/admin-internal-self-hosted.test.ts +70 -0
  200. package/src/admin-internal-self-hosted.ts +128 -0
  201. package/src/admin-internal.ts +59 -174
  202. package/src/admin-memory.test.ts +1 -0
  203. package/src/agent/command.ts +3 -0
  204. package/src/agent/lock-config.test.ts +57 -2
  205. package/src/agent/lock.ts +68 -5
  206. package/src/agent/run.test.ts +18 -1
  207. package/src/agent/run.ts +17 -6
  208. package/src/caddy-redirects.test.ts +1 -0
  209. package/src/caddy.ts +12 -0
  210. package/src/cli-routing.ts +1 -1
  211. package/src/cli.ts +102 -44
  212. package/src/compose.ts +12 -0
  213. package/src/converge-job-gcp.ts +16 -11
  214. package/src/converge-job-preflight.test.ts +108 -0
  215. package/src/converge-job-preflight.ts +124 -0
  216. package/src/converge-job.test.ts +50 -0
  217. package/src/converge-job.ts +38 -2
  218. package/src/gateway-credentials.test.ts +4 -3
  219. package/src/gateway-credentials.ts +6 -10
  220. package/src/gateway-env-isolation.test.ts +1 -0
  221. package/src/gcloud.test.ts +93 -1
  222. package/src/gcloud.ts +82 -0
  223. package/src/index.test.ts +38 -1
  224. package/src/infra-contract.test.ts +4 -3
  225. package/src/infra-contract.ts +10 -1
  226. package/src/install-job.ts +5 -2
  227. package/src/install-state.ts +13 -0
  228. package/src/lifecycle.ts +134 -28
  229. package/src/mcp-iap.test.ts +66 -5
  230. package/src/mcp-iap.ts +55 -17
  231. package/src/operator-access.ts +1 -2
  232. package/src/owner-reset-providers.test.ts +370 -0
  233. package/src/owner-reset.test.ts +2 -2
  234. package/src/owner-reset.ts +108 -19
  235. package/src/preview-plan.test.ts +233 -0
  236. package/src/preview-plan.ts +226 -0
  237. package/src/public-role-isolation.test.ts +13 -2
  238. package/src/public-role-isolation.ts +14 -4
  239. package/src/release-contract.ts +4 -0
  240. package/src/self-hosted-compose.test.ts +25 -0
  241. package/src/self-hosted-internal-secret.test.ts +61 -0
  242. package/src/self-hosted-internal-secret.ts +80 -0
  243. package/src/self-hosted-release.ts +10 -3
  244. package/src/self-hosted-upgrade.test.ts +35 -1
  245. package/src/self-hosted-upgrade.ts +40 -3
  246. package/src/stack-contract.test.ts +1 -0
  247. package/src/stack-converge.test.ts +89 -5
  248. package/src/stack-converge.ts +81 -8
  249. package/src/staging-admin.test.ts +1 -1
  250. package/src/staging-admin.ts +2 -1
  251. package/src/state-backend.ts +3 -2
  252. package/src/state-command.test.ts +31 -0
  253. package/src/state-command.ts +35 -8
  254. package/src/state-migrate.test.ts +58 -7
  255. package/src/state-migrate.ts +48 -10
  256. package/src/state-pulumi.ts +20 -21
  257. package/src/static-security-headers.test.ts +124 -0
  258. package/src/static-security-headers.ts +154 -0
  259. package/src/static-svg-gcp.test.ts +133 -0
  260. package/src/static-svg-gcp.ts +140 -0
  261. package/src/upgrade/app-roll.ts +18 -7
  262. package/src/upgrade/backup.ts +14 -5
  263. package/src/upgrade/converge.ts +25 -7
  264. package/src/upgrade/gcp-lookup.test.ts +80 -1
  265. package/src/upgrade/gcp-lookup.ts +61 -11
  266. package/src/upgrade/health.test.ts +132 -8
  267. package/src/upgrade/health.ts +169 -31
  268. package/src/upgrade/release-images.test.ts +17 -0
  269. package/src/upgrade/release-images.ts +26 -14
  270. package/src/upgrade/reporter.ts +3 -0
  271. package/src/upgrade/upgrade.test.ts +137 -8
  272. package/src/upgrade/upgrade.ts +14 -2
  273. package/src/upgrader-gcp.ts +28 -13
  274. package/src/upgrader-iam-preflight.test.ts +6 -3
  275. package/src/upgrader-iam-preflight.ts +26 -5
  276. package/src/upgrader-job.ts +14 -6
  277. package/src/upgrader-reporter.ts +1 -0
  278. package/src/wizards/gcp-pulumi.test.ts +1 -1
  279. package/src/wizards/gcp-pulumi.ts +147 -38
  280. package/src/wizards/gcp.ts +89 -9
  281. package/src/wizards/stack-work-dir.test.ts +175 -0
  282. package/stacks/aws/index.ts +40 -0
  283. package/stacks/azure/index.ts +22 -1
  284. package/stacks/gcp/README.md +4 -0
  285. package/stacks/gcp/index.ts +6 -2
  286. package/stacks/gcp/Pulumi.production.yaml +0 -16
@@ -3,7 +3,8 @@
3
3
  /**
4
4
  * The real {@link StatePulumiOps} for `state migrate` (#696): the Pulumi CLI
5
5
  * (state operations) and the Automation API (previews), always in the
6
- * install's stack dir and with exactly the backend's environment. The
6
+ * install's own stack work dir (#736) and with exactly the backend's
7
+ * environment. The
7
8
  * operator never runs `pulumi` by hand (CLAUDE.md §11.C).
8
9
  *
9
10
  * Secrets: outputs are read with `--show-secrets` only to hash them; the
@@ -18,12 +19,14 @@ import { join } from "node:path";
18
19
  import * as automation from "@pulumi/pulumi/automation";
19
20
  import { redactCredentials } from "./checkpoint-secrets.js";
20
21
  import { GCP_STACK_ENV } from "./gcp-names.js";
22
+ import { collectPreviewSteps } from "./preview-plan.js";
21
23
  import type { ResolvedBackend } from "./state-backend.js";
22
24
  import type { StatePulumiOps } from "./state-migrate.js";
23
25
  import {
24
26
  alignStackSettingsFile,
25
27
  applyStackConfig,
26
- gcpStackWorkDir,
28
+ assertStackProject,
29
+ prepareStackWorkDir,
27
30
  pulumiGcpEnv,
28
31
  type StackConfigInputs,
29
32
  type StateResource,
@@ -49,13 +52,6 @@ export function canonicalJson(value: unknown): string {
49
52
  return JSON.stringify(value);
50
53
  }
51
54
 
52
- /** Steps of a preview's change summary other than "same". */
53
- export function changeCount(summary: Readonly<Record<string, number | undefined>>): number {
54
- return Object.entries(summary)
55
- .filter(([op]) => op !== "same")
56
- .reduce((n, [, c]) => n + (c ?? 0), 0);
57
- }
58
-
59
55
  interface Deployment {
60
56
  pending_operations?: unknown[];
61
57
  secrets_providers?: {
@@ -76,7 +72,7 @@ export interface RealPulumiOpsInputs {
76
72
 
77
73
  /** {@link StatePulumiOps} backed by the `pulumi` CLI + Automation API. */
78
74
  export function realStatePulumiOps(inputs: RealPulumiOpsInputs): StatePulumiOps {
79
- const workDir = gcpStackWorkDir(inputs.provider);
75
+ const workDir = prepareStackWorkDir(inputs.installRoot, inputs.provider);
80
76
 
81
77
  const cli = async (
82
78
  args: string[],
@@ -99,7 +95,7 @@ export function realStatePulumiOps(inputs: RealPulumiOpsInputs): StatePulumiOps
99
95
  return { stdout, stderr };
100
96
  };
101
97
 
102
- // The read-only steps (preflight, verification) align the package's
98
+ // The read-only steps (preflight, verification) align the install's
103
99
  // stack settings file and may set config to run a preview; the file is
104
100
  // put back exactly as it was afterwards, so a refused migration or a dry
105
101
  // run leaves it untouched.
@@ -127,7 +123,7 @@ export function realStatePulumiOps(inputs: RealPulumiOpsInputs): StatePulumiOps
127
123
  };
128
124
 
129
125
  const exportDeployment = async (b: ResolvedBackend): Promise<Deployment> => {
130
- await alignStackSettingsFile(b, inputs.provider);
126
+ await alignStackSettingsFile(workDir, b);
131
127
  const { stdout } = await cli(["stack", "export", "--stack", GCP_STACK_ENV], stateOnlyEnv(b));
132
128
  return ((JSON.parse(stdout) as { deployment?: Deployment }).deployment ?? {}) as Deployment;
133
129
  };
@@ -179,7 +175,7 @@ export function realStatePulumiOps(inputs: RealPulumiOpsInputs): StatePulumiOps
179
175
  },
180
176
 
181
177
  async previewChanges(b) {
182
- await alignStackSettingsFile(b, inputs.provider);
178
+ await alignStackSettingsFile(workDir, b);
183
179
  const stack = await automation.LocalWorkspace.selectStack(
184
180
  { stackName: GCP_STACK_ENV, workDir },
185
181
  {
@@ -191,21 +187,24 @@ export function realStatePulumiOps(inputs: RealPulumiOpsInputs): StatePulumiOps
191
187
  );
192
188
  // The stack's own config wins: an operator may have tuned a knob
193
189
  // (`pulumi config set`), and resetting it would show as drift. Only a
194
- // stack dir without the install's config (a fresh `bunx` version)
195
- // gets what the wizard sets — as does one holding another install's
196
- // config (installs of one provider share the package's stack dir).
190
+ // settings file without the install's config (an install from before
191
+ // its own work dir, #736) gets what the wizard sets.
197
192
  const config: automation.ConfigMap = await stack.getAllConfig().catch(() => ({}));
198
- if (config[`caelo-${inputs.provider}:project`]?.value !== inputs.stackConfig.projectId) {
193
+ if (config[`caelo-${inputs.provider}:project`] === undefined) {
199
194
  const resources = ((await stack.exportStack()).deployment?.resources ??
200
195
  []) as StateResource[];
201
196
  await applyStackConfig(stack, inputs.stackConfig, resources);
202
197
  }
203
- const r = await stack.preview({ refresh: true });
204
- return changeCount(r.changeSummary);
198
+ await assertStackProject(stack, inputs.provider, inputs.stackConfig.projectId);
199
+ // #737 — the steps themselves, not just the summary's count: a
200
+ // refusal lists what would change, replaces and deletes first.
201
+ const collector = collectPreviewSteps();
202
+ await stack.preview({ refresh: true, onEvent: collector.onEvent });
203
+ return collector.steps();
205
204
  },
206
205
 
207
206
  async outputsDigest(b) {
208
- await alignStackSettingsFile(b, inputs.provider);
207
+ await alignStackSettingsFile(workDir, b);
209
208
  const { stdout } = await cli(
210
209
  ["stack", "output", "--stack", GCP_STACK_ENV, "--json", "--show-secrets"],
211
210
  stateOnlyEnv(b),
@@ -216,7 +215,7 @@ export function realStatePulumiOps(inputs: RealPulumiOpsInputs): StatePulumiOps
216
215
  },
217
216
 
218
217
  async stackMigrate(from, to) {
219
- await alignStackSettingsFile(from, inputs.provider);
218
+ await alignStackSettingsFile(workDir, from);
220
219
  await cli(
221
220
  [
222
221
  "stack",
@@ -0,0 +1,124 @@
1
+ // SPDX-License-Identifier: MPL-2.0
2
+
3
+ /**
4
+ * #891 regression: the public static site served user-uploaded SVGs from
5
+ * the CDN with no CSP on every provider, so a script that got past
6
+ * `sanitizeSvg` ran on the site's origin (the one that also serves
7
+ * `/api/*`) when the SVG's URL was opened. These tests pin each provider's
8
+ * edge config and that every stack wires it (source-level for the stacks:
9
+ * the Pulumi programs cannot run in a unit test).
10
+ */
11
+
12
+ import { describe, expect, it } from "bun:test";
13
+ import { readFileSync } from "node:fs";
14
+ import { resolve } from "node:path";
15
+ import { NOSNIFF, SVG_DOCUMENT_CSP } from "@caelo-cms/shared";
16
+ import {
17
+ CLOUDFRONT_SVG_BEHAVIORS,
18
+ cloudFrontHeadersPolicy,
19
+ FRONT_DOOR_SECURITY_RULE_SET,
20
+ frontDoorSecurityRules,
21
+ GCP_STATIC_RESPONSE_HEADERS,
22
+ } from "./static-security-headers.js";
23
+
24
+ const stack = (provider: string): string =>
25
+ readFileSync(resolve(import.meta.dir, "..", "stacks", provider, "index.ts"), "utf8");
26
+
27
+ describe("AWS CloudFront response headers policies", () => {
28
+ it("site: nosniff only (a CSP with sandbox would break every page)", () => {
29
+ const p = cloudFrontHeadersPolicy("site");
30
+ expect(p.securityHeadersConfig.contentTypeOptions).toEqual({ override: true });
31
+ expect(p.securityHeadersConfig.contentSecurityPolicy).toBeUndefined();
32
+ });
33
+
34
+ it("svg: nosniff + the sandboxing CSP, overriding any origin header", () => {
35
+ const p = cloudFrontHeadersPolicy("svg");
36
+ expect(p.securityHeadersConfig.contentTypeOptions).toEqual({ override: true });
37
+ expect(p.securityHeadersConfig.contentSecurityPolicy).toEqual({
38
+ contentSecurityPolicy: SVG_DOCUMENT_CSP,
39
+ override: true,
40
+ });
41
+ });
42
+
43
+ it("SVG behaviors cover both S3 origins, the media one first (first match wins)", () => {
44
+ expect(CLOUDFRONT_SVG_BEHAVIORS).toEqual([
45
+ { pathPattern: "/media/*.svg", targetOriginId: "media" },
46
+ { pathPattern: "*.svg", targetOriginId: "static" },
47
+ ]);
48
+ });
49
+
50
+ it("the stack attaches the svg policy to the SVG behaviors ahead of /media/*, the site policy everywhere else", () => {
51
+ const src = stack("aws");
52
+ expect(src).toContain('...cloudFrontHeadersPolicy("site")');
53
+ expect(src).toContain('...cloudFrontHeadersPolicy("svg")');
54
+ expect(src).toContain("...CLOUDFRONT_SVG_BEHAVIORS.map(");
55
+ expect(src.match(/responseHeadersPolicyId: siteHeadersPolicy\.id/g)).toHaveLength(2);
56
+ expect(src).toContain("responseHeadersPolicyId: svgHeadersPolicy.id");
57
+ expect(src.indexOf("...CLOUDFRONT_SVG_BEHAVIORS.map(")).toBeLessThan(
58
+ src.indexOf('pathPattern: "/media/*"'),
59
+ );
60
+ });
61
+ });
62
+
63
+ describe("Azure Front Door security rule set", () => {
64
+ const rules = frontDoorSecurityRules();
65
+
66
+ it("nosniff on every response (no condition)", () => {
67
+ const nosniff = rules.find((r) => r.ruleName === "nosniff");
68
+ expect(nosniff && "conditions" in nosniff).toBe(false);
69
+ expect(nosniff?.actions).toEqual([
70
+ {
71
+ name: "ModifyResponseHeader",
72
+ parameters: {
73
+ typeName: "DeliveryRuleHeaderActionParameters",
74
+ headerAction: "Overwrite",
75
+ headerName: "X-Content-Type-Options",
76
+ value: NOSNIFF,
77
+ },
78
+ },
79
+ ]);
80
+ });
81
+
82
+ it("the CSP when the file extension is svg, compared lower-cased", () => {
83
+ const svg = rules.find((r) => r.ruleName === "svgCsp");
84
+ expect(svg && "conditions" in svg ? svg.conditions : []).toEqual([
85
+ {
86
+ name: "UrlFileExtension",
87
+ parameters: {
88
+ typeName: "DeliveryRuleUrlFileExtensionMatchConditionParameters",
89
+ operator: "Equal",
90
+ negateCondition: false,
91
+ matchValues: ["svg"],
92
+ transforms: ["Lowercase"],
93
+ },
94
+ },
95
+ ]);
96
+ expect(svg?.actions[0]?.parameters).toMatchObject({
97
+ headerName: "Content-Security-Policy",
98
+ value: SVG_DOCUMENT_CSP,
99
+ });
100
+ });
101
+
102
+ it("rules keep processing so both apply, with distinct orders", () => {
103
+ expect(rules.every((r) => r.matchProcessingBehavior === "Continue")).toBe(true);
104
+ expect(new Set(rules.map((r) => r.order)).size).toBe(rules.length);
105
+ });
106
+
107
+ it("the stack creates the set and attaches it to the static route next to the redirects", () => {
108
+ const src = stack("azure");
109
+ expect(src).toContain("ruleSetName: FRONT_DOOR_SECURITY_RULE_SET");
110
+ expect(src).toContain("for (const rule of frontDoorSecurityRules())");
111
+ expect(src).toContain("ruleSets: [{ id: redirectRuleSet.id }, { id: securityRuleSet.id }]");
112
+ expect(FRONT_DOOR_SECURITY_RULE_SET).not.toBe("caeloRedirects");
113
+ });
114
+ });
115
+
116
+ describe("GCP static backend bucket", () => {
117
+ it("adds nosniff to every response", () => {
118
+ expect(GCP_STATIC_RESPONSE_HEADERS).toEqual([`X-Content-Type-Options: ${NOSNIFF}`]);
119
+ });
120
+
121
+ it("the stack sets the headers on the static backend bucket", () => {
122
+ expect(stack("gcp")).toContain("customResponseHeaders: [...GCP_STATIC_RESPONSE_HEADERS]");
123
+ });
124
+ });
@@ -0,0 +1,154 @@
1
+ // SPDX-License-Identifier: MPL-2.0
2
+
3
+ /**
4
+ * #891 — the public site's security response headers at each provider's
5
+ * Tier-1 edge (CLAUDE.md §11.B): every response `nosniff`, every SVG the
6
+ * sandboxing CSP (`SVG_DOCUMENT_CSP`, @caelo-cms/shared). An uploaded SVG
7
+ * opened directly is a document that runs script in the site's origin —
8
+ * the origin that also serves `/api/*` through the same load balancer — so
9
+ * the header must come from the edge that serves the file, not from page
10
+ * HTML. `sanitizeSvg` at upload stays the first line; this is the second.
11
+ *
12
+ * The provider configs below are plain data the stacks pass to Pulumi
13
+ * (imported from `dist/`, so this file stays free of provider SDKs), and
14
+ * the unit tests pin them:
15
+ *
16
+ * - AWS: CloudFront response headers policies — one with nosniff on every
17
+ * behavior, one adding the CSP on `*.svg` behaviors of both S3 origins.
18
+ * - Azure: a stack-owned Front Door rule set next to the deploy-owned
19
+ * redirects set — nosniff on every response, the CSP when the URL file
20
+ * extension is `svg`.
21
+ * - Self-hosted: the Caddyfile generator (caddy.ts).
22
+ * - Firebase Hosting: version-config headers the publisher writes
23
+ * (admin-core static-publisher-firebase.ts).
24
+ * - GCP (`gcp` stack): the static backend bucket's custom response header
25
+ * carries nosniff; the CSP cannot be attached per file on that load
26
+ * balancer (see `gcsObjectMetadata` in admin-core for why), so SVG
27
+ * objects are stored `Content-Disposition: attachment` instead and the
28
+ * converge backfills them (static-svg-gcp.ts).
29
+ */
30
+
31
+ import { NOSNIFF, SVG_DOCUMENT_CSP } from "@caelo-cms/shared";
32
+
33
+ // ===========================================================================
34
+ // GCP — Cloud CDN backend bucket
35
+ // ===========================================================================
36
+
37
+ /**
38
+ * `customResponseHeaders` of the `gcp` stack's static backend bucket, in
39
+ * the `Name: value` form the Compute API and `gcloud compute backend-buckets
40
+ * update --custom-response-header` take. The converge (stack-converge.ts)
41
+ * brings existing installs to exactly this list.
42
+ */
43
+ export const GCP_STATIC_RESPONSE_HEADERS: readonly string[] = [
44
+ `X-Content-Type-Options: ${NOSNIFF}`,
45
+ ];
46
+
47
+ // ===========================================================================
48
+ // AWS — CloudFront
49
+ // ===========================================================================
50
+
51
+ /** The two response headers policies the AWS stack attaches. */
52
+ export type CloudFrontHeadersPolicyKind = "site" | "svg";
53
+
54
+ /**
55
+ * `aws.cloudfront.ResponseHeadersPolicy` arguments (minus `name`). `site`
56
+ * goes on every cache behavior; `svg` on the `*.svg` behaviors
57
+ * ({@link CLOUDFRONT_SVG_BEHAVIORS}). `override: true` so an origin header
58
+ * can never weaken them.
59
+ */
60
+ export function cloudFrontHeadersPolicy(kind: CloudFrontHeadersPolicyKind): {
61
+ readonly comment: string;
62
+ readonly securityHeadersConfig: {
63
+ readonly contentTypeOptions: { readonly override: true };
64
+ readonly contentSecurityPolicy?: {
65
+ readonly contentSecurityPolicy: string;
66
+ readonly override: true;
67
+ };
68
+ };
69
+ } {
70
+ if (kind === "site") {
71
+ return {
72
+ comment: "Caelo static site: nosniff on every response (#891)",
73
+ securityHeadersConfig: { contentTypeOptions: { override: true } },
74
+ };
75
+ }
76
+ return {
77
+ comment: "Caelo static site: sandboxing CSP on SVG documents (#891)",
78
+ securityHeadersConfig: {
79
+ contentTypeOptions: { override: true },
80
+ contentSecurityPolicy: { contentSecurityPolicy: SVG_DOCUMENT_CSP, override: true },
81
+ },
82
+ };
83
+ }
84
+
85
+ /**
86
+ * The SVG cache behaviors, in CloudFront precedence order (first match
87
+ * wins): `/media/*.svg` must come before the generic `*.svg` (which would
88
+ * route media SVGs to the static origin) and both before `/media/*`.
89
+ * CloudFront path patterns are case-sensitive; the static generator writes
90
+ * every media extension in lower case (media-asset-path.ts).
91
+ */
92
+ export const CLOUDFRONT_SVG_BEHAVIORS: ReadonlyArray<{
93
+ readonly pathPattern: string;
94
+ readonly targetOriginId: "media" | "static";
95
+ }> = [
96
+ { pathPattern: "/media/*.svg", targetOriginId: "media" },
97
+ { pathPattern: "*.svg", targetOriginId: "static" },
98
+ ];
99
+
100
+ // ===========================================================================
101
+ // Azure — Front Door rules engine
102
+ // ===========================================================================
103
+
104
+ /** Name of the stack-owned Front Door rule set (the redirects set is the deploy's). */
105
+ export const FRONT_DOOR_SECURITY_RULE_SET = "caeloSecurityHeaders";
106
+
107
+ /** One `ModifyResponseHeader` action that overwrites `headerName`. */
108
+ function overwriteResponseHeader(headerName: string, value: string) {
109
+ return {
110
+ name: "ModifyResponseHeader" as const,
111
+ parameters: {
112
+ typeName: "DeliveryRuleHeaderActionParameters" as const,
113
+ headerAction: "Overwrite",
114
+ headerName,
115
+ value,
116
+ },
117
+ };
118
+ }
119
+
120
+ /**
121
+ * The rules of {@link FRONT_DOOR_SECURITY_RULE_SET}, as `azure.cdn.Rule`
122
+ * arguments (minus the resource group / profile / rule set the stack
123
+ * fills in). A rule without conditions runs on every request; the SVG rule
124
+ * matches the URL's file extension lower-cased, so `.SVG` is covered.
125
+ * `Continue` lets both rules (and the redirects set) apply.
126
+ */
127
+ export function frontDoorSecurityRules() {
128
+ return [
129
+ {
130
+ ruleName: "nosniff",
131
+ order: 1,
132
+ matchProcessingBehavior: "Continue",
133
+ actions: [overwriteResponseHeader("X-Content-Type-Options", NOSNIFF)],
134
+ },
135
+ {
136
+ ruleName: "svgCsp",
137
+ order: 2,
138
+ matchProcessingBehavior: "Continue",
139
+ conditions: [
140
+ {
141
+ name: "UrlFileExtension" as const,
142
+ parameters: {
143
+ typeName: "DeliveryRuleUrlFileExtensionMatchConditionParameters" as const,
144
+ operator: "Equal",
145
+ negateCondition: false,
146
+ matchValues: ["svg"],
147
+ transforms: ["Lowercase"],
148
+ },
149
+ },
150
+ ],
151
+ actions: [overwriteResponseHeader("Content-Security-Policy", SVG_DOCUMENT_CSP)],
152
+ },
153
+ ];
154
+ }
@@ -0,0 +1,133 @@
1
+ // SPDX-License-Identifier: MPL-2.0
2
+
3
+ /**
4
+ * #891 — the converge's backfill of a `gcp` install's live SVGs: patch the
5
+ * ones not yet served as downloads, then purge Cloud CDN once.
6
+ */
7
+
8
+ import { describe, expect, it } from "bun:test";
9
+ import type { GcloudResult } from "./gcloud.js";
10
+ import type { InstallTarget } from "./stack-converge.js";
11
+ import { ensureSvgDownloads, svgObjectsMissingDisposition } from "./static-svg-gcp.js";
12
+
13
+ const ok = (stdout = ""): GcloudResult => ({ ok: true, stdout, stderr: "", exitCode: 0 });
14
+ const fail = (stderr: string): GcloudResult => ({ ok: false, stdout: "", stderr, exitCode: 1 });
15
+
16
+ const gcp: InstallTarget = {
17
+ provider: "gcp",
18
+ projectId: "acme",
19
+ region: "europe-west1",
20
+ env: "production",
21
+ services: { admin: "caelo-production-admin-1", gateway: "caelo-production-gateway-2" },
22
+ };
23
+
24
+ const URL_A = "gs://acme-caelo-production-static/_assets/a.0123456789abcdef.svg";
25
+ const URL_B = "gs://acme-caelo-production-static/_assets/brand/b.0123456789abcdef.svg";
26
+
27
+ /** Answers by the longest matching command prefix; records every call. */
28
+ function fakeGcloud(answers: Record<string, GcloudResult>) {
29
+ const calls: string[] = [];
30
+ const run = async (args: string[]) => {
31
+ const line = args.join(" ");
32
+ calls.push(line);
33
+ const key = Object.keys(answers)
34
+ .filter((k) => line.startsWith(k))
35
+ .sort((a, b) => b.length - a.length)[0];
36
+ return key ? (answers[key] as GcloudResult) : ok();
37
+ };
38
+ return { run, calls };
39
+ }
40
+
41
+ const listing = (...objects: object[]) => ok(JSON.stringify(objects));
42
+
43
+ describe("svgObjectsMissingDisposition", () => {
44
+ it("returns the objects not served as attachment", () => {
45
+ expect(
46
+ svgObjectsMissingDisposition(
47
+ JSON.stringify([
48
+ { storage_url: URL_A, content_disposition: "attachment" },
49
+ { storage_url: URL_B },
50
+ { storage_url: "gs://x/c.svg", content_disposition: "inline" },
51
+ ]),
52
+ ),
53
+ ).toEqual([URL_B, "gs://x/c.svg"]);
54
+ });
55
+
56
+ it("treats empty output as no objects and refuses anything that is not a listing", () => {
57
+ expect(svgObjectsMissingDisposition("")).toEqual([]);
58
+ expect(() => svgObjectsMissingDisposition("{}")).toThrow("not a JSON list");
59
+ expect(() => svgObjectsMissingDisposition('[{"name":"a.svg"}]')).toThrow("storage_url");
60
+ });
61
+ });
62
+
63
+ describe("ensureSvgDownloads", () => {
64
+ it("patches the SVGs a pre-#891 install serves inline, then purges the CDN once", async () => {
65
+ const { run, calls } = fakeGcloud({
66
+ "storage objects list": listing(
67
+ { storage_url: URL_A },
68
+ { storage_url: URL_B, content_disposition: "attachment" },
69
+ ),
70
+ "compute url-maps list": ok("caelo-production-url-map-1a2b3c\n"),
71
+ });
72
+ const outcome = await ensureSvgDownloads(gcp, run);
73
+ expect(outcome.status).toBe("applied");
74
+ expect(outcome.onFailure).toBe("warn");
75
+ expect(calls).toEqual([
76
+ "storage objects list gs://acme-caelo-production-static/**.svg --project=acme --format=json",
77
+ `storage objects update ${URL_A} --content-disposition=attachment --project=acme`,
78
+ "compute url-maps list --global --project=acme --filter=name~^caelo-production-url-map --format=value(name)",
79
+ "compute url-maps invalidate-cdn-cache caelo-production-url-map-1a2b3c --path=/* --global --async --project=acme --quiet",
80
+ ]);
81
+ });
82
+
83
+ it("does nothing (no purge) when every SVG is already a download", async () => {
84
+ const { run, calls } = fakeGcloud({
85
+ "storage objects list": listing({ storage_url: URL_A, content_disposition: "attachment" }),
86
+ });
87
+ expect((await ensureSvgDownloads(gcp, run)).status).toBe("present");
88
+ expect(calls).toHaveLength(1);
89
+ });
90
+
91
+ it("is present for an install without any SVG", async () => {
92
+ const { run } = fakeGcloud({
93
+ "storage objects list": fail(
94
+ "ERROR: (gcloud.storage.objects.list) One or more URLs matched no objects.",
95
+ ),
96
+ });
97
+ expect((await ensureSvgDownloads(gcp, run)).status).toBe("present");
98
+ });
99
+
100
+ it("patches in batches so no argv grows with the site", async () => {
101
+ const many = Array.from({ length: 250 }, (_, i) => ({
102
+ storage_url: `gs://acme-caelo-production-static/_assets/i${i}.svg`,
103
+ }));
104
+ const { run, calls } = fakeGcloud({
105
+ "storage objects list": ok(JSON.stringify(many)),
106
+ "compute url-maps list": ok("caelo-production-url-map-1\n"),
107
+ });
108
+ expect((await ensureSvgDownloads(gcp, run)).status).toBe("applied");
109
+ const updates = calls.filter((c) => c.startsWith("storage objects update"));
110
+ expect(updates).toHaveLength(3);
111
+ expect(updates[0]?.split(" gs://")).toHaveLength(101);
112
+ });
113
+
114
+ it("warns with the manual next step when the patch or the purge fails", async () => {
115
+ const patchFails = fakeGcloud({
116
+ "storage objects list": listing({ storage_url: URL_A }),
117
+ "storage objects update": fail("PERMISSION_DENIED"),
118
+ });
119
+ const patched = await ensureSvgDownloads(gcp, patchFails.run);
120
+ expect(patched).toMatchObject({ status: "failed", onFailure: "warn" });
121
+ expect(patched.error).toContain("PERMISSION_DENIED");
122
+ expect(patchFails.calls.some((c) => c.includes("invalidate-cdn-cache"))).toBe(false);
123
+
124
+ const purgeFails = fakeGcloud({
125
+ "storage objects list": listing({ storage_url: URL_A }),
126
+ "compute url-maps list": ok("caelo-production-url-map-1\n"),
127
+ "compute url-maps invalidate-cdn-cache": fail("rate limited"),
128
+ });
129
+ const purged = await ensureSvgDownloads(gcp, purgeFails.run);
130
+ expect(purged.status).toBe("failed");
131
+ expect(purged.error).toContain("invalidate-cdn-cache");
132
+ });
133
+ });
@@ -0,0 +1,140 @@
1
+ // SPDX-License-Identifier: MPL-2.0
2
+
3
+ /**
4
+ * #891 — backfill of the `gcp` stack's live SVG objects, run by the converge
5
+ * (`ensureStackInvariants`, stack-converge.ts) on every upgrade.
6
+ *
7
+ * On GCS the publisher stores every SVG with `Content-Disposition:
8
+ * attachment` (admin-core `gcsObjectMetadata` says why that, not a CSP), but
9
+ * the hash-skip never rewrites an unchanged object, and Cloud CDN keeps the
10
+ * responses it cached for up to a year (`STATIC_CDN_POLICY.maxTtl`). So the
11
+ * converge patches every live SVG that lacks the header and, when it patched
12
+ * any, invalidates the CDN cache once so the edge stops serving the old
13
+ * responses. A second run finds nothing to patch and purges nothing.
14
+ *
15
+ * Failures warn instead of aborting the upgrade: the publisher's
16
+ * CACHE_POLICY_VERSION bump rewrites every object on the next Publish live
17
+ * anyway, and the report names what is left to do.
18
+ */
19
+
20
+ import type { GcloudRunner } from "./gcloud-retry.js";
21
+ import { gcpBucketName, gcpNamePrefix } from "./gcp-names.js";
22
+ import type { InstallTarget, InvariantOutcome } from "./stack-converge.js";
23
+
24
+ /** `gcloud storage objects update` takes many URLs; keep each argv bounded. */
25
+ const UPDATE_BATCH = 100;
26
+
27
+ /** gcloud's answer when a wildcard matches nothing (an install with no SVGs). */
28
+ const NO_MATCH = /matched no objects|No URLs matched|One or more URLs matched no/i;
29
+
30
+ /** One object of `gcloud storage objects list --format=json` (standardised keys). */
31
+ interface ListedObject {
32
+ readonly storage_url?: unknown;
33
+ readonly content_disposition?: unknown;
34
+ }
35
+
36
+ /**
37
+ * The `gs://…` URLs in `listJson` (gcloud's standardised object listing)
38
+ * that are not yet served as downloads.
39
+ *
40
+ * @throws when the output is not the JSON list gcloud prints.
41
+ */
42
+ export function svgObjectsMissingDisposition(listJson: string): string[] {
43
+ const parsed = JSON.parse(listJson.trim() === "" ? "[]" : listJson) as unknown;
44
+ if (!Array.isArray(parsed)) throw new Error("the object listing is not a JSON list");
45
+ const out: string[] = [];
46
+ for (const o of parsed as ListedObject[]) {
47
+ if (typeof o.storage_url !== "string") {
48
+ throw new Error("an object in the listing has no storage_url");
49
+ }
50
+ if (o.content_disposition !== "attachment") out.push(o.storage_url);
51
+ }
52
+ return out;
53
+ }
54
+
55
+ /** Patch the live SVGs of a `gcp` install and purge the CDN when any changed. */
56
+ export async function ensureSvgDownloads(
57
+ install: InstallTarget,
58
+ run: GcloudRunner,
59
+ ): Promise<InvariantOutcome> {
60
+ const bucket = gcpBucketName(install.projectId, install.env, "static");
61
+ const base = {
62
+ id: `SVGs in gs://${bucket} served as downloads`,
63
+ onFailure: "warn" as const,
64
+ why: "an uploaded SVG opened on the live site downloads instead of running script on the site's origin (#891)",
65
+ };
66
+ const project = `--project=${install.projectId}`;
67
+ const failed = (error: string): InvariantOutcome => ({
68
+ ...base,
69
+ status: "failed",
70
+ error: `${error}\n The next Publish live rewrites every SVG with the header; purge the CDN afterwards (gcloud compute url-maps invalidate-cdn-cache <url-map> --path="/*" --global).`,
71
+ });
72
+
73
+ const list = await run([
74
+ "storage",
75
+ "objects",
76
+ "list",
77
+ `gs://${bucket}/**.svg`,
78
+ project,
79
+ "--format=json",
80
+ ]);
81
+ if (!list.ok) {
82
+ return NO_MATCH.test(list.stderr)
83
+ ? { ...base, status: "present" }
84
+ : failed(`list SVG objects: ${list.stderr.trim()}`);
85
+ }
86
+ let pending: string[];
87
+ try {
88
+ pending = svgObjectsMissingDisposition(list.stdout);
89
+ } catch (e) {
90
+ return failed(`list SVG objects: ${(e as Error).message}`);
91
+ }
92
+ if (pending.length === 0) return { ...base, status: "present" };
93
+
94
+ for (let i = 0; i < pending.length; i += UPDATE_BATCH) {
95
+ const update = await run([
96
+ "storage",
97
+ "objects",
98
+ "update",
99
+ ...pending.slice(i, i + UPDATE_BATCH),
100
+ "--content-disposition=attachment",
101
+ project,
102
+ ]);
103
+ if (!update.ok) return failed(`set Content-Disposition: ${update.stderr.trim()}`);
104
+ }
105
+
106
+ // The edge still holds the old responses. One site-wide purge: SVGs sit
107
+ // under many paths, and this only runs when objects were just patched.
108
+ const maps = await run([
109
+ "compute",
110
+ "url-maps",
111
+ "list",
112
+ "--global",
113
+ project,
114
+ `--filter=name~^${gcpNamePrefix(install.env)}-url-map`,
115
+ "--format=value(name)",
116
+ ]);
117
+ const urlMap = maps.ok ? maps.stdout.trim().split("\n")[0]?.trim() : "";
118
+ if (!urlMap) {
119
+ return failed(
120
+ `patched ${pending.length} SVG(s), but found no URL map to purge: ${maps.ok ? "none listed" : maps.stderr.trim()}`,
121
+ );
122
+ }
123
+ const purge = await run([
124
+ "compute",
125
+ "url-maps",
126
+ "invalidate-cdn-cache",
127
+ urlMap,
128
+ "--path=/*",
129
+ "--global",
130
+ "--async",
131
+ project,
132
+ "--quiet",
133
+ ]);
134
+ if (!purge.ok) {
135
+ return failed(
136
+ `patched ${pending.length} SVG(s), but the CDN purge failed: ${purge.stderr.trim()}`,
137
+ );
138
+ }
139
+ return { ...base, status: "applied" };
140
+ }