@caelo-cms/provisioning 0.11.13 → 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 (159) hide show
  1. package/dist/admin-domain.d.ts +4 -46
  2. package/dist/admin-domain.d.ts.map +1 -1
  3. package/dist/admin-domain.js +4 -101
  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 +18 -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 -39
  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/stack-contract.d.ts +61 -20
  57. package/dist/stack-contract.d.ts.map +1 -1
  58. package/dist/stack-contract.js +173 -35
  59. package/dist/stack-contract.js.map +1 -1
  60. package/dist/stack-converge.d.ts.map +1 -1
  61. package/dist/stack-converge.js +4 -2
  62. package/dist/stack-converge.js.map +1 -1
  63. package/dist/staging-admin.d.ts +2 -2
  64. package/dist/staging-admin.d.ts.map +1 -1
  65. package/dist/staging-gateway-setup.d.ts +140 -0
  66. package/dist/staging-gateway-setup.d.ts.map +1 -0
  67. package/dist/staging-gateway-setup.js +848 -0
  68. package/dist/staging-gateway-setup.js.map +1 -0
  69. package/dist/upgrade/app-roll.d.ts +4 -4
  70. package/dist/upgrade/app-roll.d.ts.map +1 -1
  71. package/dist/upgrade/app-roll.js +7 -5
  72. package/dist/upgrade/app-roll.js.map +1 -1
  73. package/dist/upgrade/converge.d.ts.map +1 -1
  74. package/dist/upgrade/converge.js +36 -27
  75. package/dist/upgrade/converge.js.map +1 -1
  76. package/dist/upgrade/image-forms.d.ts +3 -3
  77. package/dist/upgrade/image-forms.d.ts.map +1 -1
  78. package/dist/upgrade/image-forms.js +2 -2
  79. package/dist/upgrade/image-forms.js.map +1 -1
  80. package/dist/upgrade/release-images.d.ts +20 -0
  81. package/dist/upgrade/release-images.d.ts.map +1 -1
  82. package/dist/upgrade/release-images.js +28 -0
  83. package/dist/upgrade/release-images.js.map +1 -1
  84. package/dist/upgrade/upgrade.d.ts +3 -1
  85. package/dist/upgrade/upgrade.d.ts.map +1 -1
  86. package/dist/upgrade/upgrade.js +22 -1
  87. package/dist/upgrade/upgrade.js.map +1 -1
  88. package/dist/upgrader-job.d.ts.map +1 -1
  89. package/dist/upgrader-job.js +30 -5
  90. package/dist/upgrader-job.js.map +1 -1
  91. package/dist/upgrader-records.d.ts +2 -2
  92. package/dist/upgrader-records.d.ts.map +1 -1
  93. package/dist/upgrader-records.js +2 -2
  94. package/dist/upgrader-records.js.map +1 -1
  95. package/dist/wizard.d.ts.map +1 -1
  96. package/dist/wizard.js +3 -3
  97. package/dist/wizard.js.map +1 -1
  98. package/dist/wizards/gcp.d.ts.map +1 -1
  99. package/dist/wizards/gcp.js +48 -9
  100. package/dist/wizards/gcp.js.map +1 -1
  101. package/package.json +2 -2
  102. package/src/admin-domain.test.ts +0 -156
  103. package/src/admin-domain.ts +4 -117
  104. package/src/admin-memory.test.ts +2 -0
  105. package/src/audit-job-setup.test.ts +11 -11
  106. package/src/audit-job-setup.ts +14 -19
  107. package/src/caddy-redirects.docker.test.ts +10 -5
  108. package/src/caddy-redirects.test.ts +7 -5
  109. package/src/caddy-validate.test.ts +1 -1
  110. package/src/caddy.test.ts +12 -6
  111. package/src/caddy.ts +23 -4
  112. package/src/cli.ts +138 -77
  113. package/src/compose.ts +67 -2
  114. package/src/converge-checkpoint.ts +1 -0
  115. package/src/database-tls-builders.test.ts +3 -1
  116. package/src/deployed-release.test.ts +21 -0
  117. package/src/deployed-release.ts +42 -7
  118. package/src/gateway-env-isolation.test.ts +2 -0
  119. package/src/gcp-names.ts +53 -0
  120. package/src/gcp-url-map.test.ts +26 -2
  121. package/src/gcp-url-map.ts +36 -5
  122. package/src/index.test.ts +9 -3
  123. package/src/infra-contract.test.ts +4 -3
  124. package/src/infra-contract.ts +18 -1
  125. package/src/lifecycle.ts +8 -52
  126. package/src/mcp-auth-proxy-setup.test.ts +15 -6
  127. package/src/mcp-auth-proxy-setup.ts +14 -2
  128. package/src/release-manifest.ts +3 -1
  129. package/src/remote-mcp-stack-config.test.ts +1 -0
  130. package/src/remote-mcp.test.ts +5 -1
  131. package/src/remote-mcp.ts +10 -1
  132. package/src/runtime-secrets.test.ts +10 -1
  133. package/src/runtime-secrets.ts +21 -0
  134. package/src/self-hosted-backup.test.ts +6 -4
  135. package/src/self-hosted-compose.test.ts +2 -0
  136. package/src/stack-contract-parity.test.ts +7 -3
  137. package/src/stack-contract.test.ts +73 -13
  138. package/src/stack-contract.ts +195 -42
  139. package/src/stack-converge-secrets.test.ts +7 -2
  140. package/src/stack-converge.test.ts +37 -9
  141. package/src/stack-converge.ts +4 -1
  142. package/src/staging-admin.ts +2 -2
  143. package/src/staging-gateway-setup.test.ts +361 -0
  144. package/src/staging-gateway-setup.ts +1083 -0
  145. package/src/upgrade/app-roll.ts +7 -4
  146. package/src/upgrade/converge.ts +40 -26
  147. package/src/upgrade/image-forms.ts +7 -7
  148. package/src/upgrade/release-images.test.ts +28 -0
  149. package/src/upgrade/release-images.ts +44 -0
  150. package/src/upgrade/upgrade.test.ts +63 -11
  151. package/src/upgrade/upgrade.ts +29 -2
  152. package/src/upgrader-job.test.ts +23 -0
  153. package/src/upgrader-job.ts +45 -11
  154. package/src/upgrader-records.ts +7 -7
  155. package/src/wizard.ts +2 -3
  156. package/src/wizards/gcp.ts +73 -9
  157. package/stacks/gcp/index.ts +47 -6
  158. package/stacks/gcp-firebase/index.ts +44 -12
  159. package/stacks/self-hosted/index.ts +9 -2
@@ -2,9 +2,9 @@
2
2
 
3
3
  /**
4
4
  * `cms-provision admin-domain enable` — serve a gcp-firebase admin at
5
- * `admin.<domain>` instead of its `*.run.app` URL — and, the same way,
6
- * `cms-provision staging-domain enable` (#650): serve the protected staging
7
- * site at `staging.<domain>` from the gateway, which gates and proxies it.
5
+ * `admin.<domain>` instead of its `*.run.app` URL. (The staging host has no
6
+ * Cloud Run domain mapping any more since #1330: it is a custom domain of
7
+ * the staging Hosting site, staging-gateway-setup.ts.)
8
8
  *
9
9
  * The gcp-firebase stack only creates that Cloud Run domain mapping behind
10
10
  * `provisionAdminDomain=true`, off by default: Google refuses a mapping
@@ -48,9 +48,6 @@ export type AdminDomainResult =
48
48
  | { status: "needs-verification"; hostname: string; verifyDomain: string }
49
49
  | { status: "failed"; error: string };
50
50
 
51
- /** What `staging-domain enable` reports; same shape as the admin's. */
52
- export type StagingDomainResult = AdminDomainResult;
53
-
54
51
  interface DomainMappingJson {
55
52
  status?: { resourceRecords?: { name?: string; type?: string; rrdata?: string }[] };
56
53
  }
@@ -80,7 +77,7 @@ function logOf(opts: DomainOpts): (message: string) => void {
80
77
  /**
81
78
  * The one deployed Cloud Run service playing `slug` on a gcp-firebase
82
79
  * install (stack-contract.ts cloudRunServicePrefix: since #1328 the visitor
83
- * gateway is `caelo-production-data-gateway`); also used by staging-admin.ts.
80
+ * gateway is `caelo-production-data-gateway`).
84
81
  */
85
82
  export async function findService(
86
83
  run: Run,
@@ -126,116 +123,6 @@ export async function enableAdminDomain(opts: DomainOpts): Promise<AdminDomainRe
126
123
  return mapDomain(run, logOf(opts), scope, opts.domain, `admin.${opts.domain}`, found.service);
127
124
  }
128
125
 
129
- /**
130
- * #650 — map the install's staging host (its setting, default
131
- * `staging.<domain>`; any FQDN) to the gateway, which serves the protected
132
- * staging site. Cloud Run refuses domain-mapped requests while a service's
133
- * ingress is internal-only, so the gateway's ingress is opened first (the
134
- * stack declares the same since #650; installs from before keep the old
135
- * value until this runs). Idempotent like the admin mapping.
136
- *
137
- * #1328 — a new mapping goes to the data gateway, which runs the gate. A
138
- * mapping made before stays on the service that was the gateway (now the
139
- * MCP-Auth-Proxy, which forwards it there) until the staging cutover moves
140
- * it (#1330, #1331); an existing mapping is only reported here.
141
- */
142
- export async function enableStagingDomain(
143
- opts: DomainOpts & { stagingHost: string },
144
- ): Promise<StagingDomainResult> {
145
- const run = opts.run ?? defaultGcloud;
146
- const scope = [`--region=${opts.region}`, `--project=${opts.projectId}`];
147
- const found = await findService(run, scope, "gateway");
148
- if (!found.ok) return { status: "failed", error: found.error };
149
- const ingress = await run([
150
- "run",
151
- "services",
152
- "describe",
153
- found.service,
154
- ...scope,
155
- '--format=value(metadata.annotations["run.googleapis.com/ingress"])',
156
- ]);
157
- if (!ingress.ok) {
158
- return { status: "failed", error: `read the gateway's ingress: ${ingress.stderr.trim()}` };
159
- }
160
- if (ingress.stdout.trim() !== "all") {
161
- const update = await run([
162
- "run",
163
- "services",
164
- "update",
165
- found.service,
166
- ...scope,
167
- "--ingress=all",
168
- "--quiet",
169
- ]);
170
- if (!update.ok) {
171
- return {
172
- status: "failed",
173
- error: `open the gateway's ingress for the domain mapping: ${update.stderr.trim()}`,
174
- };
175
- }
176
- }
177
- // Search Console verification: the install domain covers its own
178
- // subdomains; a host on another domain is verified itself.
179
- const verifyDomain =
180
- opts.stagingHost === opts.domain || opts.stagingHost.endsWith(`.${opts.domain}`)
181
- ? opts.domain
182
- : opts.stagingHost;
183
- return mapDomain(run, logOf(opts), scope, verifyDomain, opts.stagingHost, found.service);
184
- }
185
-
186
- /** What {@link stagingDomainState} found. */
187
- export type StagingDomainState =
188
- | { readonly status: "mapped" }
189
- | { readonly status: "unmapped" }
190
- | { readonly status: "unknown"; readonly reason: string };
191
-
192
- /**
193
- * Whether the install's staging host already has its Cloud Run domain
194
- * mapping, read-only. The upgrade asks before it tells the operator to run
195
- * `staging-domain enable`, so an install that serves its staging host is
196
- * not sent to set it up again. It never installs the gcloud `beta`
197
- * component (an upgrade should not pause for a download to print a hint):
198
- * without it, or on any error but gcloud's NOT_FOUND, the state is unknown
199
- * and says why.
200
- */
201
- export async function stagingDomainState(opts: {
202
- projectId: string;
203
- region: string;
204
- stagingHost: string;
205
- run?: Run;
206
- }): Promise<StagingDomainState> {
207
- const run = opts.run ?? defaultGcloud;
208
- const listed = await run([
209
- "components",
210
- "list",
211
- "--only-local-state",
212
- "--filter=id=beta",
213
- "--format=value(id)",
214
- ]);
215
- if (!listed.ok) {
216
- return {
217
- status: "unknown",
218
- reason: `check the gcloud "beta" component: ${listed.stderr.trim() || `exit ${listed.exitCode}`}`,
219
- };
220
- }
221
- if (!listed.stdout.split("\n").some((s) => s.trim() === "beta")) {
222
- return { status: "unknown", reason: 'the gcloud "beta" component is not installed' };
223
- }
224
- const described = await run([
225
- "beta",
226
- "run",
227
- "domain-mappings",
228
- "describe",
229
- `--domain=${opts.stagingHost}`,
230
- `--region=${opts.region}`,
231
- `--project=${opts.projectId}`,
232
- "--format=value(metadata.name)",
233
- ]);
234
- if (described.ok) return { status: "mapped" };
235
- if (/\bNOT_FOUND\b/.test(described.stderr)) return { status: "unmapped" };
236
- return { status: "unknown", reason: described.stderr.trim() };
237
- }
238
-
239
126
  /** What {@link setAdminStagingHostEnv} did. */
240
127
  export type AdminStagingEnvResult =
241
128
  | { readonly status: "set"; readonly service: string; readonly previous: string | null }
@@ -78,6 +78,8 @@ describe("self-hosted compose", () => {
78
78
  caeloSecretKek: "0".repeat(64),
79
79
  dbTlsServerCertSha256: "abababababababababababababababababababababababababababababababab",
80
80
  internalSecret: "i".repeat(48),
81
+ stagingHost: "staging.example.com",
82
+ stagingGateSecret: "g".repeat(64),
81
83
  diskSize: "20Gi",
82
84
  ports: { http: 80, https: 443 },
83
85
  project: "caelo-example-com",
@@ -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}