@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
@@ -0,0 +1,187 @@
1
+ // SPDX-License-Identifier: MPL-2.0
2
+
3
+ /**
4
+ * admin-internal.ts on Google Cloud, as the operator's own gcloud identity —
5
+ * the same path the Power-MCP shim takes (packages/mcp-server/src/ingress-auth.ts):
6
+ *
7
+ * 1. the admin's URL: on gcp-firebase the admin Cloud Run service's own
8
+ * URL (IAP sits on the service); on gcp `https://admin.<domain>` (IAP
9
+ * sits on the load balancer's admin backend, and the service's
10
+ * run.app URL accepts only load-balancer traffic);
11
+ * 2. the install's internal secret from Secret Manager (in memory only),
12
+ * which signs a 5-minute token for the endpoint's scope;
13
+ * 3. a JWT signed as `caelo-mcp@<project>` through the IAM Credentials
14
+ * API with `aud` = `<admin origin>/*` — the audience IAP's service-
15
+ * account JWT path checks on both resource kinds
16
+ * (cloud.google.com/iap/docs/authentication-howto). The admin's IAP
17
+ * lets that service account in, and every IAP-allowlisted operator may
18
+ * sign as it (`cms-provision upgrade` grants both), so the request
19
+ * passes IAP first, then Caelo's token.
20
+ */
21
+
22
+ import { findService } from "./admin-domain.js";
23
+ import type {
24
+ AdminInternalAnswer,
25
+ AdminInternalCall,
26
+ AdminInternalDeps,
27
+ AdminInternalResult,
28
+ GoogleAdminTarget,
29
+ } from "./admin-internal.js";
30
+ import { readAdminAnswer } from "./admin-internal-answer.js";
31
+ import { gcloud as defaultGcloud, type GcloudResult } from "./gcloud.js";
32
+ import { GCP_STACK_ENV, gcpSecretId, mcpIapServiceAccountEmail } from "./gcp-names.js";
33
+ import { mintInternalToken } from "./internal-token.js";
34
+
35
+ type Run = (args: string[]) => Promise<GcloudResult>;
36
+
37
+ /** Signed JWTs live at most 1 hour; one call needs a few seconds. */
38
+ const IAP_JWT_LIFETIME_S = 600;
39
+
40
+ /** The admin call on a gcp / gcp-firebase install (see the file comment). */
41
+ export async function callGoogleAdmin<T>(
42
+ target: GoogleAdminTarget,
43
+ call: AdminInternalCall,
44
+ deps: AdminInternalDeps,
45
+ ): Promise<AdminInternalAnswer<T>> {
46
+ const run = deps.run ?? defaultGcloud;
47
+ const doFetch = deps.fetch ?? fetch;
48
+
49
+ const admin = await adminUrl(run, target);
50
+ if (!admin.ok) return admin;
51
+
52
+ const secretId = gcpSecretId(GCP_STACK_ENV, "internal-secret");
53
+ const secret = await run([
54
+ "secrets",
55
+ "versions",
56
+ "access",
57
+ "latest",
58
+ `--secret=${secretId}`,
59
+ `--project=${target.projectId}`,
60
+ ]);
61
+ const internalSecret = secret.stdout.trim();
62
+ if (!secret.ok || internalSecret.length < 32) {
63
+ return {
64
+ ok: false,
65
+ 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.`,
66
+ };
67
+ }
68
+
69
+ // Installs from before #678 may not have the IAM Credentials API on;
70
+ // enabling an enabled API is a no-op (codex review on #681).
71
+ const api = await run([
72
+ "services",
73
+ "enable",
74
+ "iamcredentials.googleapis.com",
75
+ `--project=${target.projectId}`,
76
+ ]);
77
+ if (!api.ok) {
78
+ return {
79
+ ok: false,
80
+ error: `enable the IAM Credentials API (iamcredentials.googleapis.com), needed to pass the admin's IAP: ${api.stderr.trim()}`,
81
+ };
82
+ }
83
+ const iap = await signIapJwt(run, doFetch, target.projectId, admin.value, deps.now);
84
+ if (!iap.ok) return iap;
85
+
86
+ const token = await mintInternalToken(internalSecret, call.scope);
87
+ let res: Response;
88
+ try {
89
+ res = await doFetch(`${admin.value}${call.endpoint}`, {
90
+ method: "POST",
91
+ headers: {
92
+ authorization: `Bearer ${iap.value}`,
93
+ "x-caelo-internal-token": token,
94
+ "content-type": "application/json",
95
+ },
96
+ body: JSON.stringify(call.body),
97
+ });
98
+ } catch (e) {
99
+ return {
100
+ ok: false,
101
+ error: `reach the admin at ${admin.value}: ${String(e)}${unreachableHint(target)}`,
102
+ };
103
+ }
104
+ const text = await res.text();
105
+ if (res.headers.get("x-goog-iap-generated-response") === "true") {
106
+ return {
107
+ ok: false,
108
+ 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.`,
109
+ };
110
+ }
111
+ const answer = readAdminAnswer<T>(res.status, text, call, "cms-provision upgrade");
112
+ return answer.ok ? { ok: true, value: { answer: answer.value, adminUrl: admin.value } } : answer;
113
+ }
114
+
115
+ /** Where the admin answers behind IAP (see the file comment, step 1). */
116
+ async function adminUrl(run: Run, target: GoogleAdminTarget): Promise<AdminInternalResult<string>> {
117
+ if (target.provider === "gcp") return { ok: true, value: `https://admin.${target.domain}` };
118
+ const scope = [`--region=${target.region}`, `--project=${target.projectId}`];
119
+ const found = await findService(run, scope, "admin");
120
+ if (!found.ok) return { ok: false, error: found.error };
121
+ const url = await run([
122
+ "run",
123
+ "services",
124
+ "describe",
125
+ found.service,
126
+ ...scope,
127
+ "--format=value(status.url)",
128
+ ]);
129
+ const value = url.stdout.trim();
130
+ if (!url.ok || !value) {
131
+ return { ok: false, error: `read the admin's URL: ${url.stderr.trim() || "empty"}` };
132
+ }
133
+ return { ok: true, value };
134
+ }
135
+
136
+ /** On gcp the admin's host must resolve to the load balancer and have its certificate. */
137
+ function unreachableHint(target: GoogleAdminTarget): string {
138
+ return target.provider === "gcp"
139
+ ? ` — admin.${target.domain} must resolve to the install's load balancer with an ACTIVE certificate (check \`dig +short admin.${target.domain}\`).`
140
+ : "";
141
+ }
142
+
143
+ /** A JWT signed as the install's MCP service account, for the admin's IAP. */
144
+ async function signIapJwt(
145
+ run: Run,
146
+ doFetch: typeof fetch,
147
+ projectId: string,
148
+ adminUrl: string,
149
+ now: (() => number) | undefined,
150
+ ): Promise<AdminInternalResult<string>> {
151
+ const access = await run(["auth", "print-access-token"]);
152
+ const accessToken = access.stdout.trim();
153
+ if (!access.ok || !accessToken) {
154
+ return {
155
+ ok: false,
156
+ error: `get an access token from gcloud: ${access.stderr.trim() || "empty"}. Run \`gcloud auth login\` with the account you provisioned with.`,
157
+ };
158
+ }
159
+ const serviceAccount = mcpIapServiceAccountEmail(projectId);
160
+ const iat = now ? now() : Math.floor(Date.now() / 1000);
161
+ const res = await doFetch(
162
+ `https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/${encodeURIComponent(serviceAccount)}:signJwt`,
163
+ {
164
+ method: "POST",
165
+ headers: { authorization: `Bearer ${accessToken}`, "content-type": "application/json" },
166
+ body: JSON.stringify({
167
+ payload: JSON.stringify({
168
+ iss: serviceAccount,
169
+ sub: serviceAccount,
170
+ aud: `${new URL(adminUrl).origin}/*`,
171
+ iat,
172
+ exp: iat + IAP_JWT_LIFETIME_S,
173
+ }),
174
+ }),
175
+ },
176
+ );
177
+ if (!res.ok) {
178
+ const detail = (await res.text().catch(() => "")).slice(0, 300);
179
+ return {
180
+ ok: false,
181
+ 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.`,
182
+ };
183
+ }
184
+ const { signedJwt } = (await res.json()) as { signedJwt?: string };
185
+ if (!signedJwt) return { ok: false, error: "IAM signJwt answered without a signedJwt" };
186
+ return { ok: true, value: signedJwt };
187
+ }
@@ -0,0 +1,70 @@
1
+ // SPDX-License-Identifier: MPL-2.0
2
+
3
+ /**
4
+ * #770 — the script `docker compose exec caelo-admin bun -e …` runs, executed
5
+ * here by a real bun against a stand-in admin on 127.0.0.1:$PORT: it reads
6
+ * the request from stdin, sends the token in `x-caelo-internal-token` and
7
+ * prints the admin's answer as one JSON line.
8
+ */
9
+
10
+ import { afterAll, beforeAll, describe, expect, it } from "bun:test";
11
+ import { IN_CONTAINER_FETCH } from "./admin-internal-self-hosted.js";
12
+
13
+ let server: ReturnType<typeof Bun.serve>;
14
+ const received: { path: string; token: string | null; body: unknown }[] = [];
15
+
16
+ beforeAll(() => {
17
+ server = Bun.serve({
18
+ hostname: "127.0.0.1",
19
+ port: 0,
20
+ async fetch(req) {
21
+ received.push({
22
+ path: new URL(req.url).pathname,
23
+ token: req.headers.get("x-caelo-internal-token"),
24
+ body: await req.json(),
25
+ });
26
+ return Response.json({ ok: true, echoed: true }, { status: 201 });
27
+ },
28
+ });
29
+ });
30
+ afterAll(() => server.stop(true));
31
+
32
+ async function runScript(stdin: string, port: string): Promise<string> {
33
+ const proc = Bun.spawn([process.execPath, "-e", IN_CONTAINER_FETCH], {
34
+ env: { PATH: process.env.PATH ?? "", PORT: port },
35
+ stdin: Buffer.from(stdin),
36
+ stdout: "pipe",
37
+ stderr: "pipe",
38
+ });
39
+ const out = await new Response(proc.stdout).text();
40
+ expect(await proc.exited).toBe(0);
41
+ return out;
42
+ }
43
+
44
+ describe("IN_CONTAINER_FETCH", () => {
45
+ it("POSTs the stdin request to the admin's own port and prints its answer", async () => {
46
+ const out = await runScript(
47
+ JSON.stringify({ endpoint: "/api/internal/owner-reset", token: "t.sig", body: { a: 1 } }),
48
+ String(server.port),
49
+ );
50
+ expect(JSON.parse(out)).toEqual({ status: 201, text: '{"ok":true,"echoed":true}' });
51
+ expect(received).toEqual([
52
+ { path: "/api/internal/owner-reset", token: "t.sig", body: { a: 1 } },
53
+ ]);
54
+ });
55
+
56
+ it("reports an admin that does not answer instead of failing silently", async () => {
57
+ const closed = Bun.serve({ hostname: "127.0.0.1", port: 0, fetch: () => new Response() });
58
+ const port = String(closed.port);
59
+ closed.stop(true);
60
+ const out = await runScript(
61
+ JSON.stringify({ endpoint: "/api/internal/x", token: "t", body: {} }),
62
+ port,
63
+ );
64
+ expect(Object.keys(JSON.parse(out))).toEqual(["unreachable"]);
65
+ });
66
+
67
+ it("holds no secret: it is visible in the host's process list", () => {
68
+ expect(IN_CONTAINER_FETCH).not.toMatch(/CAELO_INTERNAL_SECRET|[0-9a-f]{32}/);
69
+ });
70
+ });
@@ -0,0 +1,128 @@
1
+ // SPDX-License-Identifier: MPL-2.0
2
+
3
+ /**
4
+ * admin-internal.ts on a self-hosted install: from the install's host into
5
+ * the admin container, never over the internet.
6
+ *
7
+ * The root of trust is the host: whoever may run `docker compose` for the
8
+ * install already controls its database, its files and its secrets, so
9
+ * reaching the admin's internal endpoints grants nothing new. The CLI signs
10
+ * a 5-minute token for the endpoint's scope with `.caelo/internal-secret.json`
11
+ * (the secret the Compose file gives the admin as `CAELO_INTERNAL_SECRET`)
12
+ * and runs one `fetch` inside the admin container (`docker compose exec`)
13
+ * against the admin's own port on 127.0.0.1 — the admin image has bun but
14
+ * no curl.
15
+ *
16
+ * Nothing secret travels in process arguments, which other users of the
17
+ * host can read in `ps`: the token and the request body go in on the exec's
18
+ * stdin, the answer comes back on its stdout. `docker compose exec` output
19
+ * is not part of the container's log (`docker compose logs` shows only the
20
+ * container's main process), so the answer is written nowhere.
21
+ */
22
+
23
+ import type {
24
+ AdminInternalAnswer,
25
+ AdminInternalCall,
26
+ AdminInternalDeps,
27
+ SelfHostedAdminTarget,
28
+ } from "./admin-internal.js";
29
+ import { readAdminAnswer } from "./admin-internal-answer.js";
30
+ import { mintInternalToken } from "./internal-token.js";
31
+ import { selfCommand } from "./self-command.js";
32
+ import { internalSecretPath, readInternalSecret } from "./self-hosted-internal-secret.js";
33
+ import { bunHostRunner } from "./self-hosted-release.js";
34
+ import { compose } from "./self-hosted-upgrade.js";
35
+
36
+ /** The admin's Compose service (compose.ts). */
37
+ const ADMIN_SERVICE = "caelo-admin";
38
+
39
+ /**
40
+ * What runs inside the admin container. It reads `{ endpoint, token, body }`
41
+ * from stdin, POSTs to the admin's own port and prints one JSON line:
42
+ * `{ status, text }`, or `{ unreachable }` when the admin does not answer.
43
+ * It holds no secret itself — it is visible in `ps` on the host.
44
+ */
45
+ export const IN_CONTAINER_FETCH = `(async () => {
46
+ const out = (o) => process.stdout.write(JSON.stringify(o));
47
+ const req = JSON.parse(await Bun.stdin.text());
48
+ try {
49
+ const res = await fetch("http://127.0.0.1:" + process.env.PORT + req.endpoint, {
50
+ method: "POST",
51
+ headers: { "x-caelo-internal-token": req.token, "content-type": "application/json" },
52
+ body: JSON.stringify(req.body),
53
+ });
54
+ out({ status: res.status, text: await res.text() });
55
+ } catch (e) {
56
+ out({ unreachable: String(e) });
57
+ }
58
+ })();`;
59
+
60
+ /** The admin call on a self-hosted install (see the file comment). */
61
+ export async function callSelfHostedAdmin<T>(
62
+ target: SelfHostedAdminTarget,
63
+ call: AdminInternalCall,
64
+ deps: AdminInternalDeps,
65
+ ): Promise<AdminInternalAnswer<T>> {
66
+ const host = deps.host ?? bunHostRunner;
67
+ const restart = selfCommand("up");
68
+ let secret: string | null;
69
+ try {
70
+ secret = readInternalSecret(target.caeloDir);
71
+ } catch (e) {
72
+ return { ok: false, error: (e as Error).message };
73
+ }
74
+ if (secret === null) {
75
+ return {
76
+ ok: false,
77
+ error: `${internalSecretPath(target.caeloDir)} does not exist. Run \`${restart}\` (it creates the secret and restarts the admin with it), then run this command again.`,
78
+ };
79
+ }
80
+
81
+ const token = await mintInternalToken(secret, call.scope);
82
+ const r = await host.run(
83
+ compose(target.caeloDir, "exec", "-T", ADMIN_SERVICE, "bun", "-e", IN_CONTAINER_FETCH),
84
+ { stdinText: JSON.stringify({ endpoint: call.endpoint, token, body: call.body }) },
85
+ );
86
+ if (r.exitCode !== 0) {
87
+ return {
88
+ ok: false,
89
+ error: `run a request inside the ${ADMIN_SERVICE} container (docker compose exec, exit ${r.exitCode}): ${tail(r.stderr) || "no output"}. The admin must be running — start it with \`${restart}\`, then run this command again.`,
90
+ };
91
+ }
92
+ let reply: { status?: unknown; text?: unknown; unreachable?: unknown };
93
+ try {
94
+ reply = JSON.parse(r.stdout) as typeof reply;
95
+ } catch {
96
+ return {
97
+ ok: false,
98
+ error: `the request inside the ${ADMIN_SERVICE} container printed no answer: ${tail(r.stdout) || tail(r.stderr) || "empty"}`,
99
+ };
100
+ }
101
+ if (typeof reply.unreachable === "string") {
102
+ return {
103
+ ok: false,
104
+ error: `the admin did not answer on its own port inside the container (${reply.unreachable}). Check \`docker compose -f ${target.caeloDir}/docker-compose.yml logs ${ADMIN_SERVICE}\`, then run this command again.`,
105
+ };
106
+ }
107
+ if (typeof reply.status !== "number" || typeof reply.text !== "string") {
108
+ return {
109
+ ok: false,
110
+ error: `the request inside the ${ADMIN_SERVICE} container answered an unexpected shape`,
111
+ };
112
+ }
113
+ if (reply.status === 401) {
114
+ return {
115
+ ok: false,
116
+ error: `the admin refused the internal token (HTTP 401: ${reply.text.slice(0, 200)}) — the running admin does not hold the secret in ${internalSecretPath(target.caeloDir)} (it was started from an older Compose file). Run \`${restart}\` to restart it with that secret, then run this command again.`,
117
+ };
118
+ }
119
+ const answer = readAdminAnswer<T>(reply.status, reply.text, call, "cms-provision upgrade");
120
+ return answer.ok
121
+ ? { ok: true, value: { answer: answer.value, adminUrl: target.adminUrl } }
122
+ : answer;
123
+ }
124
+
125
+ /** The last lines of a command's output, for an error message. */
126
+ function tail(text: string): string {
127
+ return text.trim().split("\n").slice(-5).join("\n").slice(0, 500);
128
+ }
@@ -1,45 +1,67 @@
1
1
  // SPDX-License-Identifier: MPL-2.0
2
2
 
3
3
  /**
4
- * The CLI's channel to the admin's `/api/internal/*` endpoints on a
5
- * gcp-firebase install, for commands that need an answer BACK from the admin
6
- * (a generated password, a reset link). A one-shot Cloud Run job could only
7
- * report through Cloud Logging, which would put that secret into the
8
- * project's logs; this call answers over TLS straight to the CLI process, and
9
- * nothing is written anywhere (CLAUDE.md §7, #659).
4
+ * The CLI's channel to the admin's `/api/internal/*` endpoints, for commands
5
+ * that need an answer BACK from the admin (a generated password, a reset
6
+ * link). A one-shot job could only report through the platform's logs, which
7
+ * would put that secret into them; this call answers straight to the CLI
8
+ * process, and nothing is written anywhere (CLAUDE.md §7, #659).
10
9
  *
11
- * Getting there, as the operator's own gcloud identity — the same path the
12
- * Power-MCP shim takes (packages/mcp-server/src/ingress-auth.ts):
10
+ * Each provider reaches the admin its own way (CLAUDE.md §11.B), as the
11
+ * operator's own identity on that provider:
13
12
  *
14
- * 1. the admin Cloud Run service and its URL;
15
- * 2. the install's internal secret from Secret Manager (in memory only),
16
- * which signs a 5-minute token for the endpoint's scope;
17
- * 3. a JWT signed as `caelo-mcp@<project>` through the IAM Credentials
18
- * API — the admin's IAP lets that service account in, and every
19
- * IAP-allowlisted operator may sign as it (`cms-provision upgrade`
20
- * grants it) — so the request passes IAP first, then Caelo's token.
13
+ * - `gcp-firebase` / `gcp` (admin-internal-google.ts): through the admin's
14
+ * Google IAP with a JWT signed as `caelo-mcp@<project>`, plus a token
15
+ * signed with the internal secret from Secret Manager. gcp-firebase
16
+ * addresses the admin's Cloud Run URL (IAP on the service); gcp addresses
17
+ * `admin.<domain>` (IAP on the load balancer backend; the Cloud Run URL
18
+ * takes no traffic from outside the load balancer).
19
+ * - `self-hosted` (admin-internal-self-hosted.ts): from the install's host
20
+ * into the admin container (`docker compose exec`), plus a token signed
21
+ * with `.caelo/internal-secret.json`.
22
+ *
23
+ * aws and azure have no such channel yet (#952).
21
24
  */
22
25
 
23
- import { findService } from "./admin-domain.js";
24
- import { gcloud as defaultGcloud, type GcloudResult } from "./gcloud.js";
25
- import { GCP_STACK_ENV, gcpSecretId, mcpIapServiceAccountEmail } from "./gcp-names.js";
26
- import { mintInternalToken } from "./internal-token.js";
26
+ import { callGoogleAdmin } from "./admin-internal-google.js";
27
+ import { callSelfHostedAdmin } from "./admin-internal-self-hosted.js";
28
+ import type { GcloudResult } from "./gcloud.js";
29
+ import type { HostRunner } from "./self-hosted-release.js";
27
30
 
28
- type Run = (args: string[]) => Promise<GcloudResult>;
31
+ /** Which admin to call, and what the provider needs to reach it. */
32
+ export type AdminInternalTarget = GoogleAdminTarget | SelfHostedAdminTarget;
29
33
 
30
- /** Signed JWTs live at most 1 hour; one call needs a few seconds. */
31
- const IAP_JWT_LIFETIME_S = 600;
34
+ /** A Google Cloud install (IAP in front of the admin). */
35
+ export type GoogleAdminTarget =
36
+ | {
37
+ readonly provider: "gcp-firebase";
38
+ readonly projectId: string;
39
+ readonly region: string;
40
+ }
41
+ | {
42
+ readonly provider: "gcp";
43
+ readonly projectId: string;
44
+ /** The install's domain; the admin answers on `admin.<domain>`. */
45
+ readonly domain: string;
46
+ };
32
47
 
33
- export interface AdminInternalTarget {
34
- readonly projectId: string;
35
- readonly region: string;
48
+ /** A self-hosted install on this host. */
49
+ export interface SelfHostedAdminTarget {
50
+ readonly provider: "self-hosted";
51
+ /** The install's `.caelo/` directory (Compose file, internal secret). */
52
+ readonly caeloDir: string;
53
+ /** The admin's public origin, for the links the operator opens. */
54
+ readonly adminUrl: string;
36
55
  }
37
56
 
38
57
  export interface AdminInternalDeps {
39
- readonly run?: Run;
58
+ /** gcloud (Google providers). */
59
+ readonly run?: (args: string[]) => Promise<GcloudResult>;
40
60
  readonly fetch?: typeof fetch;
41
61
  /** Seconds since epoch (IAP JWT); tests pin it. */
42
62
  readonly now?: () => number;
63
+ /** `docker compose` on the install's host (self-hosted). */
64
+ readonly host?: HostRunner;
43
65
  }
44
66
 
45
67
  /** One endpoint call: where, under which token scope, with what body. */
@@ -55,158 +77,21 @@ export interface AdminInternalCall {
55
77
 
56
78
  export type AdminInternalResult<T> = { ok: true; value: T } | { ok: false; error: string };
57
79
 
80
+ /** What a transport hands back: the admin's answer and the URL it serves on. */
81
+ export type AdminInternalAnswer<T> = AdminInternalResult<{ answer: T; adminUrl: string }>;
82
+
58
83
  /**
59
84
  * POST `call.body` to the endpoint and return the admin's JSON answer and
60
- * the admin URL it was reached at (to build links the operator opens).
61
- * Every failure names the fix: an IAP refusal or a pre-endpoint admin points
62
- * at `cms-provision upgrade`, a refusal carries the admin's own message.
85
+ * the admin URL the operator opens (to build links). Every failure names
86
+ * the fix: a gate refusal or a pre-endpoint admin points at the command that
87
+ * repairs it, a refusal carries the admin's own message.
63
88
  */
64
- export async function callAdminInternal<T>(
89
+ export function callAdminInternal<T>(
65
90
  target: AdminInternalTarget,
66
91
  call: AdminInternalCall,
67
92
  deps: AdminInternalDeps = {},
68
- ): Promise<AdminInternalResult<{ answer: T; adminUrl: string }>> {
69
- const run = deps.run ?? defaultGcloud;
70
- const doFetch = deps.fetch ?? fetch;
71
- const scope = [`--region=${target.region}`, `--project=${target.projectId}`];
72
-
73
- const found = await findService(run, scope, "admin");
74
- if (!found.ok) return { ok: false, error: found.error };
75
- const url = await run([
76
- "run",
77
- "services",
78
- "describe",
79
- found.service,
80
- ...scope,
81
- "--format=value(status.url)",
82
- ]);
83
- const adminUrl = url.stdout.trim();
84
- if (!url.ok || !adminUrl) {
85
- return { ok: false, error: `read the admin's URL: ${url.stderr.trim() || "empty"}` };
86
- }
87
-
88
- const secretId = gcpSecretId(GCP_STACK_ENV, "internal-secret");
89
- const secret = await run([
90
- "secrets",
91
- "versions",
92
- "access",
93
- "latest",
94
- `--secret=${secretId}`,
95
- `--project=${target.projectId}`,
96
- ]);
97
- const internalSecret = secret.stdout.trim();
98
- if (!secret.ok || internalSecret.length < 32) {
99
- return {
100
- ok: false,
101
- 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.`,
102
- };
103
- }
104
-
105
- // Installs from before #678 may not have the IAM Credentials API on;
106
- // enabling an enabled API is a no-op (codex review on #681).
107
- const api = await run([
108
- "services",
109
- "enable",
110
- "iamcredentials.googleapis.com",
111
- `--project=${target.projectId}`,
112
- ]);
113
- if (!api.ok) {
114
- return {
115
- ok: false,
116
- error: `enable the IAM Credentials API (iamcredentials.googleapis.com), needed to pass the admin's IAP: ${api.stderr.trim()}`,
117
- };
118
- }
119
- const iap = await signIapJwt(run, doFetch, target.projectId, adminUrl, deps.now);
120
- if (!iap.ok) return iap;
121
-
122
- const token = await mintInternalToken(internalSecret, call.scope);
123
- let res: Response;
124
- try {
125
- res = await doFetch(`${adminUrl}${call.endpoint}`, {
126
- method: "POST",
127
- headers: {
128
- authorization: `Bearer ${iap.value}`,
129
- "x-caelo-internal-token": token,
130
- "content-type": "application/json",
131
- },
132
- body: JSON.stringify(call.body),
133
- });
134
- } catch (e) {
135
- return { ok: false, error: `reach the admin at ${adminUrl}: ${String(e)}` };
136
- }
137
- const text = await res.text();
138
- if (res.headers.get("x-goog-iap-generated-response") === "true") {
139
- return {
140
- ok: false,
141
- 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.`,
142
- };
143
- }
144
- if (res.status === 404) {
145
- return {
146
- ok: false,
147
- error: `the admin has no ${call.endpoint} yet (it runs a release from before ${call.introducedBy}). Run \`cms-provision upgrade\`, then run this command again.`,
148
- };
149
- }
150
- let parsed: { ok?: boolean; error?: string } & Record<string, unknown>;
151
- try {
152
- parsed = JSON.parse(text) as typeof parsed;
153
- } catch {
154
- return {
155
- ok: false,
156
- error: `the admin answered HTTP ${res.status} with a non-JSON body: ${text.slice(0, 200)}`,
157
- };
158
- }
159
- if (!res.ok || parsed.ok !== true) {
160
- return {
161
- ok: false,
162
- error: `the admin refused (HTTP ${res.status}): ${parsed.error ?? text.slice(0, 300)}`,
163
- };
164
- }
165
- return { ok: true, value: { answer: parsed as unknown as T, adminUrl } };
166
- }
167
-
168
- /** A JWT signed as the install's MCP service account, for the admin's IAP. */
169
- async function signIapJwt(
170
- run: Run,
171
- doFetch: typeof fetch,
172
- projectId: string,
173
- adminUrl: string,
174
- now: (() => number) | undefined,
175
- ): Promise<AdminInternalResult<string>> {
176
- const access = await run(["auth", "print-access-token"]);
177
- const accessToken = access.stdout.trim();
178
- if (!access.ok || !accessToken) {
179
- return {
180
- ok: false,
181
- error: `get an access token from gcloud: ${access.stderr.trim() || "empty"}. Run \`gcloud auth login\` with the account you provisioned with.`,
182
- };
183
- }
184
- const serviceAccount = mcpIapServiceAccountEmail(projectId);
185
- const iat = now ? now() : Math.floor(Date.now() / 1000);
186
- const res = await doFetch(
187
- `https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/${encodeURIComponent(serviceAccount)}:signJwt`,
188
- {
189
- method: "POST",
190
- headers: { authorization: `Bearer ${accessToken}`, "content-type": "application/json" },
191
- body: JSON.stringify({
192
- payload: JSON.stringify({
193
- iss: serviceAccount,
194
- sub: serviceAccount,
195
- aud: `${new URL(adminUrl).origin}/*`,
196
- iat,
197
- exp: iat + IAP_JWT_LIFETIME_S,
198
- }),
199
- }),
200
- },
201
- );
202
- if (!res.ok) {
203
- const detail = (await res.text().catch(() => "")).slice(0, 300);
204
- return {
205
- ok: false,
206
- 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.`,
207
- };
208
- }
209
- const { signedJwt } = (await res.json()) as { signedJwt?: string };
210
- if (!signedJwt) return { ok: false, error: "IAM signJwt answered without a signedJwt" };
211
- return { ok: true, value: signedJwt };
93
+ ): Promise<AdminInternalAnswer<T>> {
94
+ return target.provider === "self-hosted"
95
+ ? callSelfHostedAdmin<T>(target, call, deps)
96
+ : callGoogleAdmin<T>(target, call, deps);
212
97
  }
@@ -76,6 +76,7 @@ describe("self-hosted compose", () => {
76
76
  postgresPassword: "pw",
77
77
  rolePasswords: { admin: "a", public: "b" },
78
78
  caeloSecretKek: "0".repeat(64),
79
+ internalSecret: "i".repeat(48),
79
80
  diskSize: "20Gi",
80
81
  ports: { http: 80, https: 443 },
81
82
  project: "caelo-example-com",
@@ -59,6 +59,8 @@ export interface AgentCliContext {
59
59
  convergeRoles(): Promise<ConvergeOutcome>;
60
60
  /** The Compose file for `images` from config.json. */
61
61
  renderCompose(images: ReleaseImages): string;
62
+ /** Re-write the Caddyfile (config + domains table) and reload Caddy (#891). */
63
+ refreshCaddy(): Promise<{ ok: true } | { ok: false; error: string }>;
62
64
  randomSecret(): string;
63
65
  }
64
66
 
@@ -544,6 +546,7 @@ async function agentApply(argv: readonly string[], ctx: AgentCliContext): Promis
544
546
  runner: bunHostRunner,
545
547
  renderCompose: (images) => ctx.renderCompose(images),
546
548
  convergeRoles: () => ctx.convergeRoles(),
549
+ refreshCaddy: () => ctx.refreshCaddy(),
547
550
  log,
548
551
  },
549
552
  ),