@caelo-cms/provisioning 0.11.12 → 0.11.14

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 (165) hide show
  1. package/dist/admin-domain.d.ts +4 -22
  2. package/dist/admin-domain.d.ts.map +1 -1
  3. package/dist/admin-domain.js +4 -58
  4. package/dist/admin-domain.js.map +1 -1
  5. package/dist/agent-db.js +29 -5
  6. package/dist/audit-job-setup.d.ts.map +1 -1
  7. package/dist/audit-job-setup.js +14 -19
  8. package/dist/audit-job-setup.js.map +1 -1
  9. package/dist/caddy.d.ts +2 -1
  10. package/dist/caddy.d.ts.map +1 -1
  11. package/dist/caddy.js +21 -3
  12. package/dist/caddy.js.map +1 -1
  13. package/dist/cli.js +111 -62
  14. package/dist/cli.js.map +1 -1
  15. package/dist/compose.d.ts +22 -2
  16. package/dist/compose.d.ts.map +1 -1
  17. package/dist/compose.js +53 -3
  18. package/dist/compose.js.map +1 -1
  19. package/dist/converge-checkpoint.d.ts +1 -0
  20. package/dist/converge-checkpoint.d.ts.map +1 -1
  21. package/dist/converge-checkpoint.js +1 -0
  22. package/dist/converge-checkpoint.js.map +1 -1
  23. package/dist/deployed-release.d.ts.map +1 -1
  24. package/dist/deployed-release.js +40 -8
  25. package/dist/deployed-release.js.map +1 -1
  26. package/dist/gcp-names.d.ts +33 -14
  27. package/dist/gcp-names.d.ts.map +1 -1
  28. package/dist/gcp-names.js +43 -0
  29. package/dist/gcp-names.js.map +1 -1
  30. package/dist/gcp-url-map.d.ts +19 -4
  31. package/dist/gcp-url-map.d.ts.map +1 -1
  32. package/dist/gcp-url-map.js +33 -5
  33. package/dist/gcp-url-map.js.map +1 -1
  34. package/dist/infra-contract.d.ts +1 -1
  35. package/dist/infra-contract.d.ts.map +1 -1
  36. package/dist/infra-contract.js +22 -1
  37. package/dist/infra-contract.js.map +1 -1
  38. package/dist/lifecycle.d.ts.map +1 -1
  39. package/dist/lifecycle.js +5 -29
  40. package/dist/lifecycle.js.map +1 -1
  41. package/dist/mcp-auth-proxy-setup.d.ts +8 -2
  42. package/dist/mcp-auth-proxy-setup.d.ts.map +1 -1
  43. package/dist/mcp-auth-proxy-setup.js +4 -0
  44. package/dist/mcp-auth-proxy-setup.js.map +1 -1
  45. package/dist/release-manifest.d.ts +1 -1
  46. package/dist/release-manifest.d.ts.map +1 -1
  47. package/dist/release-manifest.js +2 -0
  48. package/dist/release-manifest.js.map +1 -1
  49. package/dist/remote-mcp.d.ts.map +1 -1
  50. package/dist/remote-mcp.js +11 -1
  51. package/dist/remote-mcp.js.map +1 -1
  52. package/dist/runtime-secrets.d.ts +10 -0
  53. package/dist/runtime-secrets.d.ts.map +1 -1
  54. package/dist/runtime-secrets.js +10 -1
  55. package/dist/runtime-secrets.js.map +1 -1
  56. package/dist/scheduler-setup.d.ts +5 -0
  57. package/dist/scheduler-setup.d.ts.map +1 -1
  58. package/dist/scheduler-setup.js +7 -0
  59. package/dist/scheduler-setup.js.map +1 -1
  60. package/dist/stack-contract.d.ts +61 -20
  61. package/dist/stack-contract.d.ts.map +1 -1
  62. package/dist/stack-contract.js +173 -35
  63. package/dist/stack-contract.js.map +1 -1
  64. package/dist/stack-converge.d.ts.map +1 -1
  65. package/dist/stack-converge.js +4 -2
  66. package/dist/stack-converge.js.map +1 -1
  67. package/dist/staging-admin.d.ts +2 -2
  68. package/dist/staging-admin.d.ts.map +1 -1
  69. package/dist/staging-gateway-setup.d.ts +140 -0
  70. package/dist/staging-gateway-setup.d.ts.map +1 -0
  71. package/dist/staging-gateway-setup.js +848 -0
  72. package/dist/staging-gateway-setup.js.map +1 -0
  73. package/dist/upgrade/app-roll.d.ts +4 -4
  74. package/dist/upgrade/app-roll.d.ts.map +1 -1
  75. package/dist/upgrade/app-roll.js +7 -5
  76. package/dist/upgrade/app-roll.js.map +1 -1
  77. package/dist/upgrade/converge.d.ts.map +1 -1
  78. package/dist/upgrade/converge.js +36 -27
  79. package/dist/upgrade/converge.js.map +1 -1
  80. package/dist/upgrade/image-forms.d.ts +3 -3
  81. package/dist/upgrade/image-forms.d.ts.map +1 -1
  82. package/dist/upgrade/image-forms.js +2 -2
  83. package/dist/upgrade/image-forms.js.map +1 -1
  84. package/dist/upgrade/release-images.d.ts +20 -0
  85. package/dist/upgrade/release-images.d.ts.map +1 -1
  86. package/dist/upgrade/release-images.js +28 -0
  87. package/dist/upgrade/release-images.js.map +1 -1
  88. package/dist/upgrade/upgrade.d.ts +3 -1
  89. package/dist/upgrade/upgrade.d.ts.map +1 -1
  90. package/dist/upgrade/upgrade.js +22 -1
  91. package/dist/upgrade/upgrade.js.map +1 -1
  92. package/dist/upgrader-job.d.ts.map +1 -1
  93. package/dist/upgrader-job.js +30 -5
  94. package/dist/upgrader-job.js.map +1 -1
  95. package/dist/upgrader-records.d.ts +2 -2
  96. package/dist/upgrader-records.d.ts.map +1 -1
  97. package/dist/upgrader-records.js +2 -2
  98. package/dist/upgrader-records.js.map +1 -1
  99. package/dist/wizard.d.ts.map +1 -1
  100. package/dist/wizard.js +3 -3
  101. package/dist/wizard.js.map +1 -1
  102. package/dist/wizards/gcp.d.ts.map +1 -1
  103. package/dist/wizards/gcp.js +48 -9
  104. package/dist/wizards/gcp.js.map +1 -1
  105. package/package.json +2 -2
  106. package/src/admin-domain.test.ts +0 -91
  107. package/src/admin-domain.ts +4 -64
  108. package/src/admin-memory.test.ts +2 -0
  109. package/src/audit-job-setup.test.ts +11 -11
  110. package/src/audit-job-setup.ts +14 -19
  111. package/src/caddy-redirects.docker.test.ts +10 -5
  112. package/src/caddy-redirects.test.ts +7 -5
  113. package/src/caddy-validate.test.ts +1 -1
  114. package/src/caddy.test.ts +12 -6
  115. package/src/caddy.ts +23 -4
  116. package/src/cli.ts +138 -77
  117. package/src/compose.ts +67 -2
  118. package/src/converge-checkpoint.ts +1 -0
  119. package/src/database-tls-builders.test.ts +3 -1
  120. package/src/deployed-release.test.ts +21 -0
  121. package/src/deployed-release.ts +42 -7
  122. package/src/gateway-env-isolation.test.ts +2 -0
  123. package/src/gcp-names.ts +53 -0
  124. package/src/gcp-url-map.test.ts +26 -2
  125. package/src/gcp-url-map.ts +36 -5
  126. package/src/index.test.ts +9 -3
  127. package/src/infra-contract.test.ts +5 -3
  128. package/src/infra-contract.ts +22 -1
  129. package/src/lifecycle.ts +6 -40
  130. package/src/mcp-auth-proxy-setup.test.ts +15 -6
  131. package/src/mcp-auth-proxy-setup.ts +14 -2
  132. package/src/release-manifest.ts +3 -1
  133. package/src/remote-mcp-stack-config.test.ts +1 -0
  134. package/src/remote-mcp.test.ts +5 -1
  135. package/src/remote-mcp.ts +10 -1
  136. package/src/runtime-secrets.test.ts +10 -1
  137. package/src/runtime-secrets.ts +21 -0
  138. package/src/scheduler-setup.test.ts +6 -0
  139. package/src/scheduler-setup.ts +7 -0
  140. package/src/self-hosted-backup.test.ts +6 -4
  141. package/src/self-hosted-compose.test.ts +2 -0
  142. package/src/stack-contract-parity.test.ts +7 -3
  143. package/src/stack-contract.test.ts +73 -13
  144. package/src/stack-contract.ts +195 -42
  145. package/src/stack-converge-secrets.test.ts +7 -2
  146. package/src/stack-converge.test.ts +37 -9
  147. package/src/stack-converge.ts +4 -1
  148. package/src/staging-admin.ts +2 -2
  149. package/src/staging-gateway-setup.test.ts +361 -0
  150. package/src/staging-gateway-setup.ts +1083 -0
  151. package/src/upgrade/app-roll.ts +7 -4
  152. package/src/upgrade/converge.ts +40 -26
  153. package/src/upgrade/image-forms.ts +7 -7
  154. package/src/upgrade/release-images.test.ts +28 -0
  155. package/src/upgrade/release-images.ts +44 -0
  156. package/src/upgrade/upgrade.test.ts +63 -11
  157. package/src/upgrade/upgrade.ts +29 -2
  158. package/src/upgrader-job.test.ts +23 -0
  159. package/src/upgrader-job.ts +45 -11
  160. package/src/upgrader-records.ts +7 -7
  161. package/src/wizard.ts +2 -3
  162. package/src/wizards/gcp.ts +73 -9
  163. package/stacks/gcp/index.ts +47 -6
  164. package/stacks/gcp-firebase/index.ts +44 -12
  165. package/stacks/self-hosted/index.ts +9 -2
@@ -91,7 +91,7 @@ describe("ensureAuditJob", () => {
91
91
  for (const c of bucketGrants) expect(flag(c, "condition")).toBe("None");
92
92
  });
93
93
 
94
- it("grants the job only results-create, build-read (gcp) and logs", async () => {
94
+ it("grants the job only results-create, read of the staging bucket and logs", async () => {
95
95
  const { run, calls } = fakeGcloud();
96
96
  const r = await ensureAuditJob(TARGET, { run, sleep: async () => {} });
97
97
  expect(r.ok).toBe(true);
@@ -102,10 +102,6 @@ describe("ensureAuditJob", () => {
102
102
  "storage buckets add-iam-policy-binding gs://acme-caelo-production-staging",
103
103
  "roles/storage.objectViewer",
104
104
  ],
105
- [
106
- "storage buckets add-iam-policy-binding gs://acme-caelo-production-static",
107
- "roles/storage.objectViewer",
108
- ],
109
105
  ["projects add-iam-policy-binding acme", "roles/logging.logWriter"],
110
106
  ]);
111
107
  for (const g of job) {
@@ -151,7 +147,7 @@ describe("ensureAuditJob", () => {
151
147
  expect(flag(deploy, "max-retries")).toBe("0");
152
148
  expect(flag(deploy, "parallelism")).toBe("15");
153
149
  expect(flag(deploy, "set-env-vars")).toBe(
154
- `CAELO_PROVIDER=gcp,CAELO_AUDIT_RESULTS_BUCKET=${RESULTS},CAELO_STAGING_BUCKET=acme-caelo-production-staging,CAELO_STATIC_BUCKET=acme-caelo-production-static`,
150
+ `CAELO_PROVIDER=gcp,CAELO_AUDIT_RESULTS_BUCKET=${RESULTS},CAELO_STAGING_BUCKET=acme-caelo-production-staging`,
155
151
  );
156
152
  const line = deploy.join(" ");
157
153
  for (const banned of ["--network", "--subnet", "--vpc", "--set-secrets", "--update-secrets"]) {
@@ -206,16 +202,20 @@ describe("ensureAuditJob", () => {
206
202
  expect(deployOf(calls)).toBeUndefined();
207
203
  });
208
204
 
209
- it("on gcp-firebase reads no bucket: the task audits the public preview channel", async () => {
205
+ it("#1330 — on gcp-firebase reads the staging bucket too: no preview channel any more", async () => {
210
206
  const { run, calls } = fakeGcloud();
211
207
  await ensureAuditJob({ ...TARGET, provider: "gcp-firebase" }, { run, sleep: async () => {} });
212
208
  const job = grants(calls).filter((g) => g.member === AUDIT);
213
- expect(job.map((g) => g.role)).toEqual([
214
- "roles/storage.objectCreator",
215
- "roles/logging.logWriter",
209
+ expect(job.map((g) => [g.where, g.role])).toEqual([
210
+ [`storage buckets add-iam-policy-binding gs://${RESULTS}`, "roles/storage.objectCreator"],
211
+ [
212
+ "storage buckets add-iam-policy-binding gs://acme-caelo-production-staging",
213
+ "roles/storage.objectViewer",
214
+ ],
215
+ ["projects add-iam-policy-binding acme", "roles/logging.logWriter"],
216
216
  ]);
217
217
  expect(flag(deployOf(calls), "set-env-vars")).toBe(
218
- `CAELO_PROVIDER=gcp-firebase,CAELO_AUDIT_RESULTS_BUCKET=${RESULTS}`,
218
+ `CAELO_PROVIDER=gcp-firebase,CAELO_AUDIT_RESULTS_BUCKET=${RESULTS},CAELO_STAGING_BUCKET=acme-caelo-production-staging`,
219
219
  );
220
220
  });
221
221
 
@@ -22,10 +22,12 @@
22
22
  *
23
23
  * - `roles/storage.objectCreator` on the audit results bucket — create
24
24
  * only: it can neither read results back nor overwrite one;
25
- * - on `gcp` only, `roles/storage.objectViewer` on the staging and static
26
- * buckets: it serves the staged build to its own browser, as the admin's
27
- * audit does (admin-core quality/staged-origin.ts). gcp-firebase audits
28
- * the public preview channel and needs no bucket;
25
+ * - `roles/storage.objectViewer` on the staging bucket: it serves the
26
+ * staged build to its own browser from the served build slot, which
27
+ * holds the complete build (#1330, admin-core quality/staged-origin.ts),
28
+ * so Lighthouse never goes through the staging gateway (#875). Both
29
+ * providers since #1330 (gcp-firebase had no bucket and audited the
30
+ * preview channel before); no read on the static bucket any more;
29
31
  * - `roles/logging.logWriter`.
30
32
  *
31
33
  * Nothing else: no Secret Manager, no Cloud SQL, and no VPC egress, so the
@@ -231,18 +233,16 @@ export async function ensureAuditJob(
231
233
  }
232
234
  done.push(`bucket ${resultsUrl} (results deleted after 1 day)`);
233
235
 
234
- // 3. Bucket grants: the job creates results and (gcp) reads the build;
236
+ // 3. Bucket grants: the job creates results and reads the staged build;
235
237
  // the admin reads results.
236
238
  const bucketGrants: { bucket: string; member: string; role: string; why: string }[] = [
237
239
  { bucket: results, member, role: "roles/storage.objectCreator", why: "tasks write results" },
238
- ...(provider === "gcp"
239
- ? (["staging", "static"] as const).map((b) => ({
240
- bucket: gcpBucketName(projectId, env, b),
241
- member,
242
- role: "roles/storage.objectViewer",
243
- why: "tasks serve the staged build to their browser",
244
- }))
245
- : []),
240
+ {
241
+ bucket: gcpBucketName(projectId, env, "staging"),
242
+ member,
243
+ role: "roles/storage.objectViewer",
244
+ why: "tasks serve the staged build to their browser",
245
+ },
246
246
  {
247
247
  bucket: results,
248
248
  member: runSaMember,
@@ -295,12 +295,7 @@ export async function ensureAuditJob(
295
295
  const envVars = [
296
296
  `CAELO_PROVIDER=${provider}`,
297
297
  `CAELO_AUDIT_RESULTS_BUCKET=${results}`,
298
- ...(provider === "gcp"
299
- ? [
300
- `CAELO_STAGING_BUCKET=${gcpBucketName(projectId, env, "staging")}`,
301
- `CAELO_STATIC_BUCKET=${gcpBucketName(projectId, env, "static")}`,
302
- ]
303
- : []),
298
+ `CAELO_STAGING_BUCKET=${gcpBucketName(projectId, env, "staging")}`,
304
299
  ];
305
300
  const deploy = await run([
306
301
  "run",
@@ -9,9 +9,10 @@
9
9
  * imported;
10
10
  * - a container started like the Compose service (same Caddyfile shape,
11
11
  * same `/srv/caelo` mount, same reload-loop command and snippet glob)
12
- * answers redirected paths on the live and the staging host with the
13
- * planned status + Location — query kept, case-sensitive, the live page
14
- * wins, 410 for gone;
12
+ * answers redirected paths on the live host with the planned status +
13
+ * Location — query kept, case-sensitive, the live page wins, 410 for
14
+ * gone — and serves nothing of output/staging on the staging host (#1330:
15
+ * that is the staging gateway's);
15
16
  * - the reload loop picks up a deploy's new snippet, a snippet Caddy
16
17
  * rejects leaves the previous redirects live, and the next good snippet
17
18
  * is picked up again.
@@ -147,7 +148,7 @@ describe.skipIf(!dockerUp)(`self-hosted redirects through ${CADDY_IMAGE}`, () =>
147
148
  ownerEmail: "owner@example.test",
148
149
  tls: "internal",
149
150
  publicSiteRoot: `${SITE_OUTPUT_ROOT}/output/production/current`,
150
- stagingSiteRoot: `${SITE_OUTPUT_ROOT}/output/staging/current`,
151
+ stagingGatewayUpstream: "caelo-staging-gateway:8091",
151
152
  adminUpstream: COMPOSE_ADMIN_UPSTREAM,
152
153
  gatewayUpstream: COMPOSE_GATEWAY_UPSTREAM,
153
154
  mcpAuthProxyUpstream: COMPOSE_MCP_AUTH_PROXY_UPSTREAM,
@@ -246,7 +247,11 @@ describe.skipIf(!dockerUp)(`self-hosted redirects through ${CADDY_IMAGE}`, () =>
246
247
  expectRedirect("/hop1", 301, "/blog/");
247
248
  expectRedirect("/Case", 301, "/about/");
248
249
  expectRedirect("/q?dropped=1", 301, "/about?ref=old");
249
- expectRedirect("/staged-old", 301, "/blog/", STAGING);
250
+ // #1330 — Caddy serves no staged file and answers no staging redirect:
251
+ // the staging host goes to the staging gateway (not running here, so
252
+ // Caddy answers 502), whatever lies in output/staging.
253
+ expect(probe(STAGING, "/staged-old").status).toBe(502);
254
+ expect(probe(STAGING, "/index.html").status).toBe(502);
250
255
  expect(probe(LIVE, "/gone").status).toBe(410);
251
256
  // Exact, case-sensitive: /case is not /Case.
252
257
  expect(probe(LIVE, "/case").status).toBe(404);
@@ -24,7 +24,7 @@ const caddyfile = generateCaddyfile({
24
24
  ownerEmail: "owner@example.com",
25
25
  tls: "acme",
26
26
  publicSiteRoot: "/srv/caelo/output/production/current",
27
- stagingSiteRoot: "/srv/caelo/output/staging/current",
27
+ stagingGatewayUpstream: "caelo-staging-gateway:8091",
28
28
  adminUpstream: COMPOSE_ADMIN_UPSTREAM,
29
29
  gatewayUpstream: COMPOSE_GATEWAY_UPSTREAM,
30
30
  mcpAuthProxyUpstream: COMPOSE_MCP_AUTH_PROXY_UPSTREAM,
@@ -41,13 +41,13 @@ function block(host: string): string {
41
41
  }
42
42
 
43
43
  describe("Caddyfile — redirects (#587)", () => {
44
- it("each public vhost imports the redirect snippet of the build it serves", () => {
44
+ it("the live vhost imports the redirect snippet of the build it serves", () => {
45
45
  expect(block("example.com")).toContain(
46
46
  " root * /srv/caelo/output/production/current\n import /srv/caelo/output/production/current/_redirects.caddy*\n",
47
47
  );
48
- expect(block("staging.example.com")).toContain(
49
- "import /srv/caelo/output/staging/current/_redirects.caddy*",
50
- );
48
+ // #1330 — staging's redirects are the staging gateway's (from the
49
+ // staging projection), not Caddy's.
50
+ expect(block("staging.example.com")).not.toContain("_redirects.caddy");
51
51
  expect(block("admin.example.com")).not.toContain("_redirects.caddy");
52
52
  });
53
53
 
@@ -67,6 +67,8 @@ describe("Compose — Caddy picks up new redirects (#587)", () => {
67
67
  caeloSecretKek: "0".repeat(64),
68
68
  dbTlsServerCertSha256: "abababababababababababababababababababababababababababababababab",
69
69
  internalSecret: "i".repeat(48),
70
+ stagingHost: "staging.example.com",
71
+ stagingGateSecret: "g".repeat(64),
70
72
  diskSize: "20Gi",
71
73
  ports: { http: 80, https: 443 },
72
74
  project: "caelo-example-com",
@@ -64,7 +64,7 @@ const base: CaddyfileSpec = {
64
64
  ownerEmail: "owner@example.com",
65
65
  tls: "acme",
66
66
  publicSiteRoot: "/srv/caelo/output/production/current",
67
- stagingSiteRoot: "/srv/caelo/output/staging/current",
67
+ stagingGatewayUpstream: "caelo-staging-gateway:8091",
68
68
  adminUpstream: COMPOSE_ADMIN_UPSTREAM,
69
69
  gatewayUpstream: COMPOSE_GATEWAY_UPSTREAM,
70
70
  mcpAuthProxyUpstream: COMPOSE_MCP_AUTH_PROXY_UPSTREAM,
package/src/caddy.test.ts CHANGED
@@ -20,7 +20,7 @@ const spec: CaddyfileSpec = {
20
20
  ownerEmail: "owner@example.com",
21
21
  tls: "acme",
22
22
  publicSiteRoot: "/srv/caelo/output/production/current",
23
- stagingSiteRoot: "/srv/caelo/output/staging/current",
23
+ stagingGatewayUpstream: "caelo-staging-gateway:8091",
24
24
  adminUpstream: COMPOSE_ADMIN_UPSTREAM,
25
25
  gatewayUpstream: COMPOSE_GATEWAY_UPSTREAM,
26
26
  mcpAuthProxyUpstream: COMPOSE_MCP_AUTH_PROXY_UPSTREAM,
@@ -96,9 +96,12 @@ describe("Caddyfile upstreams (#707)", () => {
96
96
  expect(out).not.toContain("localhost");
97
97
  expect(out).not.toContain("127.0.0.1");
98
98
  expect(block(out, "admin.example.com")).toContain("reverse_proxy caelo-admin:5173");
99
- for (const host of ["example.com", "staging.example.com"]) {
100
- expect(block(out, host)).toContain("handle /api/* {\n reverse_proxy caelo-gateway:8090\n");
101
- }
99
+ expect(block(out, "example.com")).toContain(
100
+ "handle /api/* {\n reverse_proxy caelo-gateway:8090\n",
101
+ );
102
+ // #1330 — the staging host goes to the staging gateway, every path.
103
+ expect(block(out, "staging.example.com")).toContain("reverse_proxy caelo-staging-gateway:8091");
104
+ expect(block(out, "staging.example.com")).not.toContain("caelo-gateway:8090");
102
105
  });
103
106
 
104
107
  it("does not route /admin/* on the public site (the admin routes from / on its own host)", () => {
@@ -123,12 +126,15 @@ describe("Caddyfile static site (#566)", () => {
123
126
  );
124
127
  });
125
128
 
126
- it("serves production and staging from their own roots; only staging is noindex", () => {
129
+ it("serves production from its root; staging only through the staging gateway, noindex", () => {
127
130
  const out = generateCaddyfile(spec);
128
131
  expect(block(out, "example.com")).toContain("root * /srv/caelo/output/production/current");
129
132
  expect(block(out, "example.com")).not.toContain("X-Robots-Tag");
130
133
  const staging = block(out, "staging.example.com");
131
- expect(staging).toContain("root * /srv/caelo/output/staging/current");
134
+ // #1330 — no second path to the staged files: Caddy serves none of them.
135
+ expect(staging).not.toContain("root *");
136
+ expect(staging).not.toContain("file_server");
137
+ expect(staging).not.toContain("output/staging");
132
138
  expect(staging).toContain('header X-Robots-Tag "noindex"');
133
139
  });
134
140
  });
package/src/caddy.ts CHANGED
@@ -15,6 +15,9 @@
15
15
  * # MCP-Auth-Proxy on its own host, never the site's origin)
16
16
  * # public → /api/* → <gatewayUpstream>; everything else is the
17
17
  * # static site (try_files + file_server, 404 → /404.html)
18
+ * # staging → reverse_proxy <stagingGatewayUpstream> for every path
19
+ * # (#1330: the staging gateway serves the staged build
20
+ * # behind the staging gate, and staging's own /api)
18
21
  * }
19
22
  *
20
23
  * Upstreams are `host:port` as seen FROM CADDY. In the Compose install
@@ -27,7 +30,10 @@
27
30
  * run in CI) — issue #566 shipped a `try_files` nested inside
28
31
  * `file_server { }`, which Caddy 2.11 refuses, so no vhost came up.
29
32
  *
30
- * Staging vhosts force `X-Robots-Tag: noindex`.
33
+ * Staging vhosts force `X-Robots-Tag: noindex`. Caddy serves no staging
34
+ * file itself (#1330): the staging gateway mounts the staged builds
35
+ * read-only, and Caddy has no other path to them, so nothing reaches a
36
+ * staged page without passing the gate.
31
37
  *
32
38
  * Public vhosts import the redirect snippet of the build they serve
33
39
  * (`_redirects.caddy`, #587); the Caddy container reloads itself when a
@@ -91,7 +97,8 @@ export interface CaddyfileSpec {
91
97
  readonly ownerEmail: string;
92
98
  readonly tls: CaddyTlsMode;
93
99
  readonly publicSiteRoot: string; // absolute path inside the Caddy container
94
- readonly stagingSiteRoot: string;
100
+ /** #1330 — `host:port` of the staging gateway as Caddy reaches it (Compose: `caelo-staging-gateway:8091`). */
101
+ readonly stagingGatewayUpstream: string;
95
102
  /** `host:port` of the admin as Caddy reaches it (Compose: `caelo-admin:5173`). */
96
103
  readonly adminUpstream: string;
97
104
  /** `host:port` of the API gateway as Caddy reaches it (Compose: `caelo-gateway:8090`). */
@@ -173,7 +180,7 @@ function assertSafeCaddyToken(label: string, value: string, kind: "address" | "t
173
180
  export function generateCaddyfile(spec: CaddyfileSpec): string {
174
181
  assertSafeCaddyToken("ownerEmail", spec.ownerEmail, "token");
175
182
  assertSafeCaddyToken("publicSiteRoot", spec.publicSiteRoot, "token");
176
- assertSafeCaddyToken("stagingSiteRoot", spec.stagingSiteRoot, "token");
183
+ assertSafeCaddyToken("stagingGatewayUpstream", spec.stagingGatewayUpstream, "token");
177
184
  assertSafeCaddyToken("adminUpstream", spec.adminUpstream, "token");
178
185
  assertSafeCaddyToken("gatewayUpstream", spec.gatewayUpstream, "token");
179
186
  assertSafeCaddyToken("mcpAuthProxyUpstream", spec.mcpAuthProxyUpstream, "token");
@@ -232,12 +239,24 @@ function vhost(d: CaddyDomainSpec, spec: CaddyfileSpec): string {
232
239
  header X-Content-Type-Options "${NOSNIFF}"
233
240
  reverse_proxy ${spec.mcpAuthProxyUpstream}
234
241
  }
242
+ `;
243
+ }
244
+ if (d.env === "staging") {
245
+ // #1330 — every path to the staging gateway: it runs the gate first and
246
+ // serves the staged build and staging's /api itself. The #891 security
247
+ // headers stay on as a second layer over the gateway's own.
248
+ return `${d.hostname} {${noindex}
249
+ header X-Content-Type-Options "${NOSNIFF}"
250
+ @svg path *.svg
251
+ header @svg Content-Security-Policy "${SVG_DOCUMENT_CSP}"
252
+ reverse_proxy ${spec.stagingGatewayUpstream}
253
+ }
235
254
  `;
236
255
  }
237
256
  // public — API routes go to the gateway, the rest is the static site.
238
257
  // `try_files` is its own directive (a rewrite), never a file_server
239
258
  // subdirective (#566).
240
- const root = d.env === "staging" ? spec.stagingSiteRoot : spec.publicSiteRoot;
259
+ const root = spec.publicSiteRoot;
241
260
  // #587 — the redirects table of the build this vhost serves.
242
261
  return `${d.hostname} {${noindex}
243
262
  root * ${root}
package/src/cli.ts CHANGED
@@ -64,6 +64,7 @@ import {
64
64
  COMPOSE_ADMIN_UPSTREAM,
65
65
  COMPOSE_GATEWAY_UPSTREAM,
66
66
  COMPOSE_MCP_AUTH_PROXY_UPSTREAM,
67
+ COMPOSE_STAGING_GATEWAY_UPSTREAM,
67
68
  type ComposeImages,
68
69
  composeProjectName,
69
70
  generateDockerCompose,
@@ -111,6 +112,14 @@ interface CaeloConfig {
111
112
  * generated before P18 still load — emitConfig() back-fills if missing.
112
113
  */
113
114
  caeloSecretKek?: string;
115
+ /**
116
+ * #650 / #1330 — the staging gate's key (32 hex bytes): signs "Open
117
+ * staging" links, derives the crawler credentials. Generated once and
118
+ * persisted here; never re-minted, or every crawler credential stops
119
+ * working. The admin gets it as CAELO_STAGING_GATE_SECRET and publishes it
120
+ * to the staging gateway through the staging projection.
121
+ */
122
+ stagingGateSecret?: string;
114
123
  /**
115
124
  * #613 — one password per application role. Optional in the type so
116
125
  * config.json files from before still load; `up` back-fills them and
@@ -319,7 +328,7 @@ function writeCaddyfile(cfg: CaeloConfig, extraDomains: CaddyDomainSpec[]): void
319
328
  ownerEmail: cfg.ownerEmail,
320
329
  tls: cfg.tls,
321
330
  publicSiteRoot: `${SITE_OUTPUT_ROOT}/output/production/current`,
322
- stagingSiteRoot: `${SITE_OUTPUT_ROOT}/output/staging/current`,
331
+ stagingGatewayUpstream: COMPOSE_STAGING_GATEWAY_UPSTREAM,
323
332
  // Caddy runs in its own container: the Compose service names, never
324
333
  // localhost (#707).
325
334
  adminUpstream: COMPOSE_ADMIN_UPSTREAM,
@@ -359,6 +368,16 @@ function renderCompose(cfg: CaeloConfig, images: ComposeImages): string {
359
368
  throw new Error("config has no rolePasswords — run `cms-provision up`");
360
369
  }
361
370
  if (!cfg.caeloSecretKek) throw new Error("config has no caeloSecretKek — run `cms-provision up`");
371
+ // #1330 — back-filled once for installs from before the staging gate
372
+ // came to self-hosted; persisted before anything uses it.
373
+ if (!cfg.stagingGateSecret) {
374
+ cfg.stagingGateSecret = randomSecret(32);
375
+ saveConfig(cfg);
376
+ }
377
+ if (!cfg.stagingDomain) {
378
+ cfg.stagingDomain = defaultStagingHost(cfg.domain);
379
+ saveConfig(cfg);
380
+ }
362
381
  if (!cfg.ports || !cfg.composeProject) {
363
382
  throw new Error(
364
383
  "config has no ports / composeProject — they are back-filled on load; this is a bug",
@@ -369,6 +388,8 @@ function renderCompose(cfg: CaeloConfig, images: ComposeImages): string {
369
388
  for (const line of ensureSelfHostedDbTls(CAELO_DIR)) console.log(` database TLS: ${line}`);
370
389
  return generateDockerCompose({
371
390
  domain: cfg.domain,
391
+ stagingHost: cfg.stagingDomain,
392
+ stagingGateSecret: cfg.stagingGateSecret,
372
393
  postgresPassword: cfg.postgresPassword,
373
394
  rolePasswords: cfg.rolePasswords,
374
395
  caeloSecretKek: cfg.caeloSecretKek,
@@ -1420,7 +1441,7 @@ async function domainMappingCommand(kind: "admin" | "staging"): Promise<void> {
1420
1441
  selectInstall,
1421
1442
  writeMetadata,
1422
1443
  } = await import("./install-state.js");
1423
- const { enableAdminDomain, enableStagingDomain } = await import("./admin-domain.js");
1444
+ const { enableAdminDomain } = await import("./admin-domain.js");
1424
1445
  // Guessing among several installs could map the domain in the wrong project.
1425
1446
  const selected = selectInstall(
1426
1447
  listInstalls().filter((m) => m.projectId),
@@ -1448,13 +1469,10 @@ async function domainMappingCommand(kind: "admin" | "staging"): Promise<void> {
1448
1469
  const requested = arg("staging-domain");
1449
1470
  const { normalizeStagingHost, stagingHostProblem } = await import("@caelo-cms/shared");
1450
1471
  let adminHost: string | null = null;
1451
- if (!requested && meta.provider === "gcp-firebase" && meta.projectId && meta.region) {
1472
+ const google = googleAdminTarget(meta);
1473
+ if (!requested && google) {
1452
1474
  const { inspectStagingAdmin } = await import("./staging-admin.js");
1453
- const inspected = await inspectStagingAdmin({
1454
- provider: "gcp-firebase",
1455
- projectId: meta.projectId,
1456
- region: meta.region,
1457
- });
1475
+ const inspected = await inspectStagingAdmin(google);
1458
1476
  if (!inspected.ok) {
1459
1477
  console.error(
1460
1478
  `${command}: could not read the admin's staging host: ${inspected.error}\n` +
@@ -1465,11 +1483,10 @@ async function domainMappingCommand(kind: "admin" | "staging"): Promise<void> {
1465
1483
  adminHost = inspected.value.stagingHost;
1466
1484
  }
1467
1485
  const host = requested ?? adminHost ?? installStagingHost(meta);
1468
- const problem = stagingHostProblem(
1469
- host,
1470
- { liveHosts: [meta.domain, `www.${meta.domain}`], adminHosts: [`admin.${meta.domain}`] },
1471
- { provider: meta.provider },
1472
- );
1486
+ const problem = stagingHostProblem(host, {
1487
+ liveHosts: [meta.domain, `www.${meta.domain}`],
1488
+ adminHosts: [`admin.${meta.domain}`],
1489
+ });
1473
1490
  if (problem) {
1474
1491
  console.error(
1475
1492
  requested
@@ -1498,23 +1515,20 @@ async function domainMappingCommand(kind: "admin" | "staging"): Promise<void> {
1498
1515
  );
1499
1516
  process.exit(1);
1500
1517
  }
1518
+ if (kind === "staging") {
1519
+ await serveStagingHost(meta, command, retry, { rotatePassword, showPassword });
1520
+ return;
1521
+ }
1501
1522
  if (meta.provider !== "gcp-firebase") {
1502
1523
  console.log(
1503
- kind === "admin"
1504
- ? meta.provider === "gcp"
1505
- ? `Nothing to do: on gcp the load balancer already serves admin.${meta.domain}.`
1506
- : `admin-domain is for gcp-firebase installs; this one is ${meta.provider}.`
1507
- : meta.provider === "self-hosted"
1508
- ? `Recorded staging host ${installStagingHost(meta)}; Caddy serves it after \`cms-provision regenerate-caddy\`.`
1509
- : `staging-domain is for gcp-firebase installs; on ${meta.provider} the protected staging domain is not available yet (issue #650 follow-up).`,
1524
+ meta.provider === "gcp"
1525
+ ? `Nothing to do: on gcp the load balancer already serves admin.${meta.domain}.`
1526
+ : `admin-domain is for gcp-firebase installs; this one is ${meta.provider}.`,
1510
1527
  );
1511
1528
  return;
1512
1529
  }
1513
1530
  const target = { projectId: meta.projectId, region: meta.region, domain: meta.domain };
1514
- const r =
1515
- kind === "admin"
1516
- ? await enableAdminDomain(target)
1517
- : await enableStagingDomain({ ...target, stagingHost: installStagingHost(meta) });
1531
+ const r = await enableAdminDomain(target);
1518
1532
  if (r.status === "failed") {
1519
1533
  console.error(`${command}: ${r.error}`);
1520
1534
  process.exit(1);
@@ -1527,56 +1541,7 @@ async function domainMappingCommand(kind: "admin" | "staging"): Promise<void> {
1527
1541
  );
1528
1542
  process.exit(3);
1529
1543
  }
1530
- const mappedTo = kind === "admin" ? "the admin" : "the gateway (protected staging site)";
1531
- console.log(
1532
- `${r.hostname} ${r.created ? "is now mapped" : "was already mapped"} to ${mappedTo}.`,
1533
- );
1534
- if (kind === "staging") {
1535
- // #678 — the operator's decision reaches the admin here: the host is
1536
- // set there, password protection is switched on the first time, and
1537
- // the password is printed once (never stored by the CLI).
1538
- const { applyStagingAdmin, describeStagingApply } = await import("./staging-admin.js");
1539
- const applied = await applyStagingAdmin(
1540
- { provider: "gcp-firebase", projectId: meta.projectId, region: meta.region },
1541
- {
1542
- host: r.hostname,
1543
- ...(rotatePassword ? { rotateBasicAuth: true } : {}),
1544
- ...(showPassword ? { revealBasicAuth: true } : {}),
1545
- },
1546
- );
1547
- if (!applied.ok) {
1548
- console.error(
1549
- `${command}: ${r.hostname} is mapped, but the admin could not be updated: ${applied.error}\n` +
1550
- `Run \`${retry}\` again once that is fixed (it is idempotent).`,
1551
- );
1552
- process.exit(5);
1553
- }
1554
- // Prints the staging basic-auth password ON PURPOSE (#678): the operator
1555
- // asked for it to be shown once; it is never written to disk or logs.
1556
- console.log(describeStagingApply(applied.value).join("\n"));
1557
- // #1058 — and the admin's CAELO_STAGING_HOST, so the provisioned value
1558
- // agrees with install.json and the stored host (the converge job
1559
- // refuses an install whose records and admin disagree). After the
1560
- // provision call: the update starts a new admin revision.
1561
- const { setAdminStagingHostEnv } = await import("./admin-domain.js");
1562
- const env = await setAdminStagingHostEnv({
1563
- projectId: meta.projectId,
1564
- region: meta.region,
1565
- stagingHost: r.hostname,
1566
- });
1567
- if (env.status === "failed") {
1568
- console.error(
1569
- `${command}: ${r.hostname} is mapped and stored in the admin, but its CAELO_STAGING_HOST could not be updated: ${env.error}\n` +
1570
- `Run \`${retry}\` again once that is fixed (it is idempotent).`,
1571
- );
1572
- process.exit(5);
1573
- }
1574
- if (env.status === "set") {
1575
- console.log(
1576
- `The admin (${env.service}) now runs with CAELO_STAGING_HOST=${r.hostname}${env.previous ? ` (was ${env.previous})` : ""}.`,
1577
- );
1578
- }
1579
- }
1544
+ console.log(`${r.hostname} ${r.created ? "is now mapped" : "was already mapped"} to the admin.`);
1580
1545
  if (r.records.length === 0) {
1581
1546
  console.log(
1582
1547
  `Google has not published the DNS records yet; run \`cms-provision ${command} enable\` again in a minute.`,
@@ -1591,10 +1556,106 @@ async function domainMappingCommand(kind: "admin" | "staging"): Promise<void> {
1591
1556
  region: meta.region,
1592
1557
  });
1593
1558
  if (!waited) process.exit(4);
1559
+ console.log(`Admin login: https://${r.hostname}/login`);
1560
+ }
1561
+
1562
+ /** The admin-internal target of a Google Cloud install, or null for another provider. */
1563
+ function googleAdminTarget(
1564
+ meta: import("./install-state.js").InstallMetadata,
1565
+ ): import("./staging-admin.js").StagingAdminTarget | null {
1566
+ if (!meta.projectId || !meta.region) return null;
1567
+ if (meta.provider === "gcp-firebase") {
1568
+ return { provider: "gcp-firebase", projectId: meta.projectId, region: meta.region };
1569
+ }
1570
+ if (meta.provider === "gcp") {
1571
+ return { provider: "gcp", projectId: meta.projectId, domain: meta.domain };
1572
+ }
1573
+ return null;
1574
+ }
1575
+
1576
+ /**
1577
+ * #1330 — `staging-domain enable` on a Google Cloud install: route the
1578
+ * staging host to the staging gateway (staging-gateway-setup.ts — the same
1579
+ * step every converge runs), tell the admin the host and switch on password
1580
+ * protection the first time (#678, printing the password once), keep the
1581
+ * admin's CAELO_STAGING_HOST in step (#1058), and print the DNS records.
1582
+ */
1583
+ async function serveStagingHost(
1584
+ meta: import("./install-state.js").InstallMetadata,
1585
+ command: string,
1586
+ retry: string,
1587
+ opts: { readonly rotatePassword: boolean; readonly showPassword: boolean },
1588
+ ): Promise<void> {
1589
+ const { installStagingHost } = await import("./install-state.js");
1590
+ const host = installStagingHost(meta);
1591
+ const google = googleAdminTarget(meta);
1592
+ if (!google || !meta.projectId || !meta.region) {
1593
+ console.log(
1594
+ meta.provider === "self-hosted"
1595
+ ? `Recorded staging host ${host}; the staging gateway serves it behind Caddy after \`cms-provision regenerate-caddy\`.`
1596
+ : `On ${meta.provider} the protected staging site is not available yet: it needs the staging gateway this provider does not run yet (#749 / #750).`,
1597
+ );
1598
+ return;
1599
+ }
1600
+ const { ensureStagingGatewayEdge } = await import("./staging-gateway-setup.js");
1601
+ const { GCP_STACK_ENV } = await import("./gcp-names.js");
1602
+ const edge = await ensureStagingGatewayEdge({
1603
+ provider: meta.provider as "gcp" | "gcp-firebase",
1604
+ projectId: meta.projectId,
1605
+ region: meta.region,
1606
+ env: GCP_STACK_ENV,
1607
+ domain: meta.domain,
1608
+ stagingHost: host,
1609
+ });
1610
+ for (const line of edge.done) console.log(` ${line}`);
1611
+ if (!edge.ok) {
1612
+ console.error(
1613
+ `${command}: ${host} could not be routed to the staging gateway: ${edge.error}\nRun \`${retry}\` again once that is fixed (it is idempotent); \`cms-provision upgrade\` sets up the staging gateway itself if it is missing.`,
1614
+ );
1615
+ process.exit(1);
1616
+ }
1617
+ const { applyStagingAdmin, describeStagingApply } = await import("./staging-admin.js");
1618
+ const applied = await applyStagingAdmin(google, {
1619
+ host,
1620
+ ...(opts.rotatePassword ? { rotateBasicAuth: true } : {}),
1621
+ ...(opts.showPassword ? { revealBasicAuth: true } : {}),
1622
+ });
1623
+ if (!applied.ok) {
1624
+ console.error(
1625
+ `${command}: ${host} is routed to the staging gateway, but the admin could not be updated: ${applied.error}\nRun \`${retry}\` again once that is fixed (it is idempotent).`,
1626
+ );
1627
+ process.exit(5);
1628
+ }
1629
+ // Prints the staging basic-auth password ON PURPOSE (#678): the operator
1630
+ // asked for it to be shown once; it is never written to disk or logs.
1631
+ console.log(describeStagingApply(applied.value).join("\n"));
1632
+ const { setAdminStagingHostEnv } = await import("./admin-domain.js");
1633
+ const env = await setAdminStagingHostEnv({
1634
+ projectId: meta.projectId,
1635
+ region: meta.region,
1636
+ stagingHost: host,
1637
+ });
1638
+ if (env.status === "failed") {
1639
+ console.error(
1640
+ `${command}: ${host} is routed and stored in the admin, but its CAELO_STAGING_HOST could not be updated: ${env.error}\nRun \`${retry}\` again once that is fixed (it is idempotent).`,
1641
+ );
1642
+ process.exit(5);
1643
+ }
1644
+ if (env.status === "set") {
1645
+ console.log(
1646
+ `The admin (${env.service}) now runs with CAELO_STAGING_HOST=${host}${env.previous ? ` (was ${env.previous})` : ""}.`,
1647
+ );
1648
+ }
1649
+ if (edge.dnsRecords.length > 0) {
1650
+ const width = Math.max(...edge.dnsRecords.map((d) => d.name.length));
1651
+ console.log(
1652
+ `\nSet these DNS records at your registrar (if you have not already; Settings → Updates shows them too):\n${edge.dnsRecords
1653
+ .map((d) => ` ${d.name.padEnd(width)} ${d.type.padEnd(5)} ${d.value}`)
1654
+ .join("\n")}`,
1655
+ );
1656
+ }
1594
1657
  console.log(
1595
- kind === "admin"
1596
- ? `Admin login: https://${r.hostname}/login`
1597
- : `Protected staging site: https://${r.hostname}/`,
1658
+ `Protected staging site: https://${host}/ (its certificate is issued once the records resolve)`,
1598
1659
  );
1599
1660
  }
1600
1661