Skip to main content

presolve_cli/
cloudflare_deployment.rs

1//! Cloudflare Workers Static Assets projection over compiler-published routes.
2//!
3//! The adapter validates compiler artifacts and executes only a compiler-issued
4//! route table. It never reads application source or adds a framework runtime.
5
6use std::collections::BTreeSet;
7use std::fs;
8use std::path::Path;
9
10use presolve_compiler::{ApplicationPublicationArtifactV1, FileRoutePublicationManifestV1};
11use serde::Serialize;
12use sha2::{Digest as _, Sha256};
13
14pub const CLOUDFLARE_WORKERS_STATIC_DEPLOYMENT_SCHEMA_VERSION: u32 = 1;
15/// Tested Workers compatibility target for the current Presolve release train.
16pub const CLOUDFLARE_WORKERS_COMPATIBILITY_DATE_V1: &str = "2026-07-23";
17
18#[derive(Debug, Clone, PartialEq, Eq)]
19pub struct CloudflareWorkersDeploymentOptionsV1 {
20    pub worker_name: String,
21    pub compatibility_date: String,
22    pub required_secrets: Vec<String>,
23}
24
25#[derive(Debug, Clone, PartialEq, Eq)]
26pub struct CloudflareWorkersDeploymentErrorV1 {
27    pub code: &'static str,
28    pub message: String,
29}
30
31#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
32pub struct CloudflareWorkersStaticDeploymentPlanV1 {
33    pub schema_version: u32,
34    pub provider: String,
35    pub worker_name: String,
36    pub compatibility_date: String,
37    pub release_id: String,
38    pub required_secrets: Vec<String>,
39    pub assets_binding: String,
40    pub routes: Vec<CloudflareWorkersStaticRouteV1>,
41    pub artifacts: Vec<CloudflareWorkersArtifactV1>,
42}
43
44#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
45pub struct CloudflareWorkersStaticRouteV1 {
46    pub path: String,
47    pub artifact_root: String,
48}
49
50#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
51pub struct CloudflareWorkersArtifactV1 {
52    pub path: String,
53    pub digest: String,
54}
55
56/// Projects one compiler file-route manifest into immutable Cloudflare facts.
57///
58/// # Errors
59///
60/// Returns stable errors for invalid public configuration or unsafe compiler
61/// artifact paths. This does not read application source.
62pub fn build_cloudflare_workers_static_deployment_plan_v1(
63    manifest: &FileRoutePublicationManifestV1,
64    options: &CloudflareWorkersDeploymentOptionsV1,
65) -> Result<CloudflareWorkersStaticDeploymentPlanV1, CloudflareWorkersDeploymentErrorV1> {
66    if !is_worker_name(&options.worker_name) {
67        return Err(CloudflareWorkersDeploymentErrorV1 {
68            code: "PSCFL1001_WORKER_NAME_INVALID",
69            message: options.worker_name.clone(),
70        });
71    }
72    if !is_compatibility_date(&options.compatibility_date) {
73        return Err(CloudflareWorkersDeploymentErrorV1 {
74            code: "PSCFL1002_COMPATIBILITY_DATE_INVALID",
75            message: options.compatibility_date.clone(),
76        });
77    }
78    let required_secrets = canonical_secret_names(&options.required_secrets)?;
79    let mut artifacts = manifest
80        .artifacts
81        .iter()
82        .map(cloudflare_artifact)
83        .collect::<Result<Vec<_>, _>>()?;
84    artifacts.sort_by(|left, right| left.path.cmp(&right.path));
85    let routes = manifest
86        .routes
87        .iter()
88        .map(|route| CloudflareWorkersStaticRouteV1 {
89            path: route.path.clone(),
90            artifact_root: route.artifact_root.clone(),
91        })
92        .collect::<Vec<_>>();
93    if routes.is_empty() {
94        return Err(CloudflareWorkersDeploymentErrorV1 {
95            code: "PSCFL1003_ROUTE_SET_EMPTY",
96            message: "Cloudflare deployment requires at least one compiler route".into(),
97        });
98    }
99    if routes
100        .iter()
101        .any(|route| !is_route_path(&route.path) || !is_artifact_root(&route.artifact_root))
102    {
103        return Err(CloudflareWorkersDeploymentErrorV1 {
104            code: "PSCFL1004_ROUTE_PROJECTION_INVALID",
105            message: "compiler route manifest contains an unsafe Cloudflare projection".into(),
106        });
107    }
108    let release_id = format!(
109        "sha256:{:x}",
110        Sha256::digest(
111            artifacts
112                .iter()
113                .map(|artifact| format!("{}:{}\n", artifact.path, artifact.digest))
114                .collect::<String>(),
115        )
116    );
117    Ok(CloudflareWorkersStaticDeploymentPlanV1 {
118        schema_version: CLOUDFLARE_WORKERS_STATIC_DEPLOYMENT_SCHEMA_VERSION,
119        provider: "cloudflare_workers_static_assets".into(),
120        worker_name: options.worker_name.clone(),
121        compatibility_date: options.compatibility_date.clone(),
122        release_id,
123        required_secrets,
124        assets_binding: "ASSETS".into(),
125        routes,
126        artifacts,
127    })
128}
129
130/// Confirms that provider-upload bytes still equal the compiler inventory.
131///
132/// # Errors
133///
134/// Returns an error for a missing or modified artifact.
135pub fn validate_cloudflare_workers_artifacts_v1(
136    output_root: &Path,
137    plan: &CloudflareWorkersStaticDeploymentPlanV1,
138) -> Result<(), CloudflareWorkersDeploymentErrorV1> {
139    for artifact in &plan.artifacts {
140        let path = output_root.join(&artifact.path);
141        let bytes = fs::read(&path).map_err(|error| CloudflareWorkersDeploymentErrorV1 {
142            code: "PSCFL1005_ARTIFACT_READ_FAILED",
143            message: format!("{}: {error}", path.display()),
144        })?;
145        if format!("sha256:{:x}", Sha256::digest(&bytes)) != artifact.digest {
146            return Err(CloudflareWorkersDeploymentErrorV1 {
147                code: "PSCFL1006_ARTIFACT_INTEGRITY_MISMATCH",
148                message: artifact.path.clone(),
149            });
150        }
151    }
152    Ok(())
153}
154
155#[must_use]
156pub fn cloudflare_workers_static_plan_json_v1(
157    plan: &CloudflareWorkersStaticDeploymentPlanV1,
158) -> String {
159    serde_json::to_string_pretty(plan).expect("Cloudflare static plan serializes") + "\n"
160}
161
162/// Generates a Worker that consumes only the canonical route table. It is a
163/// static asset executor, not a generic server or client router.
164#[must_use]
165pub fn cloudflare_workers_static_worker_module_v1(
166    plan: &CloudflareWorkersStaticDeploymentPlanV1,
167) -> String {
168    let routes = serde_json::to_string(&plan.routes).expect("Cloudflare routes serialize");
169    format!(
170        "// Generated by Presolve. Do not edit.\n\
171const routes = {routes};\n\
172function segments(pathname) {{ return pathname.split('/').filter(Boolean); }}\n\
173function score(parts) {{ return [parts.filter((value) => !value.startsWith(':')).length, parts.length]; }}\n\
174function routeFor(requested) {{\n\
175  let selected;\n\
176  for (const route of routes) {{\n\
177    const parts = segments(route.path);\n\
178    if (requested.length < parts.length) continue;\n\
179    if (!parts.every((part, index) => part.startsWith(':') ? requested[index].length > 0 : part === requested[index])) continue;\n\
180    const candidate = score(parts);\n\
181    if (!selected || candidate[0] > selected.score[0] || (candidate[0] === selected.score[0] && candidate[1] > selected.score[1])) selected = {{ route, parts, score: candidate }};\n\
182  }}\n\
183  return selected;\n\
184}}\n\
185function safeAssetSegments(values) {{ return values.every((value) => value && value !== '.' && value !== '..' && !value.includes('\\\\')); }}\n\
186async function fetchCompilerAsset(request, env, artifactUrl) {{\n\
187  const response = await env.ASSETS.fetch(new Request(artifactUrl, request));\n\
188  if (response.status < 300 || response.status > 399) return response;\n\
189  const location = response.headers.get('Location');\n\
190  if (!location) return response;\n\
191  const redirected = new URL(location, artifactUrl);\n\
192  const internal = new URL(artifactUrl);\n\
193  if (redirected.origin !== internal.origin || !redirected.pathname.startsWith('/routes/')) return response;\n\
194  return env.ASSETS.fetch(new Request(redirected, request));\n\
195}}\n\
196export default {{ async fetch(request, env) {{\n\
197  if (request.method !== 'GET' && request.method !== 'HEAD') return new Response('Method Not Allowed\\n', {{ status: 405, headers: {{ Allow: 'GET, HEAD' }} }});\n\
198  const url = new URL(request.url);\n\
199  const direct = await env.ASSETS.fetch(request);\n\
200  if (direct.status !== 404) {{\n\
201    const location = direct.headers.get('Location');\n\
202    if (location) {{\n\
203      const redirected = new URL(location, url);\n\
204      if (redirected.origin === url.origin && redirected.pathname.startsWith('/routes/')) return fetchCompilerAsset(request, env, redirected);\n\
205    }}\n\
206    return direct;\n\
207  }}\n\
208  const requested = segments(url.pathname);\n\
209  const selected = routeFor(requested);\n\
210  if (!selected) return new Response('Not Found\\n', {{ status: 404 }});\n\
211  if (requested.length === selected.parts.length) {{\n\
212    if (selected.route.path !== '/' && !url.pathname.endsWith('/')) return Response.redirect(new URL(`${{url.pathname}}/`, url).toString(), 308);\n\
213    return fetchCompilerAsset(request, env, new URL(`/${{selected.route.artifact_root}}/index.html`, url));\n\
214  }}\n\
215  const suffix = requested.slice(selected.parts.length);\n\
216  if (!safeAssetSegments(suffix)) return new Response('Not Found\\n', {{ status: 404 }});\n\
217  return fetchCompilerAsset(request, env, new URL(`/${{selected.route.artifact_root}}/${{suffix.join('/')}}`, url));\n\
218}} }};\n"
219    )
220}
221
222#[must_use]
223pub fn cloudflare_workers_wrangler_jsonc_v1(
224    plan: &CloudflareWorkersStaticDeploymentPlanV1,
225    assets_directory: &str,
226) -> String {
227    let mut value = serde_json::json!({
228        "name": plan.worker_name,
229        "main": "worker.mjs",
230        "compatibility_date": plan.compatibility_date,
231        "assets": {
232            "directory": assets_directory,
233            "binding": plan.assets_binding,
234            "run_worker_first": true,
235        },
236    });
237    if !plan.required_secrets.is_empty() {
238        value["secrets"] = serde_json::json!({ "required": plan.required_secrets });
239    }
240    serde_json::to_string_pretty(&value).expect("Wrangler configuration serializes") + "\n"
241}
242
243fn cloudflare_artifact(
244    artifact: &ApplicationPublicationArtifactV1,
245) -> Result<CloudflareWorkersArtifactV1, CloudflareWorkersDeploymentErrorV1> {
246    if !is_artifact_path(&artifact.path) || !artifact.digest.starts_with("sha256:") {
247        return Err(CloudflareWorkersDeploymentErrorV1 {
248            code: "PSCFL1007_ARTIFACT_PROJECTION_INVALID",
249            message: artifact.path.clone(),
250        });
251    }
252    Ok(CloudflareWorkersArtifactV1 {
253        path: artifact.path.clone(),
254        digest: artifact.digest.clone(),
255    })
256}
257
258fn canonical_secret_names(
259    values: &[String],
260) -> Result<Vec<String>, CloudflareWorkersDeploymentErrorV1> {
261    let mut names = BTreeSet::new();
262    for value in values {
263        if !is_secret_name(value) {
264            return Err(CloudflareWorkersDeploymentErrorV1 {
265                code: "PSCFL1008_SECRET_NAME_INVALID",
266                message: value.clone(),
267            });
268        }
269        names.insert(value.clone());
270    }
271    Ok(names.into_iter().collect())
272}
273
274fn is_worker_name(value: &str) -> bool {
275    !value.is_empty()
276        && value.len() <= 63
277        && value.chars().all(|character| {
278            character.is_ascii_lowercase() || character.is_ascii_digit() || character == '-'
279        })
280}
281
282fn is_compatibility_date(value: &str) -> bool {
283    value.len() == 10
284        && value.as_bytes()[4] == b'-'
285        && value.as_bytes()[7] == b'-'
286        && value
287            .bytes()
288            .enumerate()
289            .all(|(index, byte)| matches!(index, 4 | 7) || byte.is_ascii_digit())
290}
291
292fn is_secret_name(value: &str) -> bool {
293    value
294        .chars()
295        .next()
296        .is_some_and(|character| character.is_ascii_uppercase() || character == '_')
297        && value.chars().all(|character| {
298            character.is_ascii_uppercase() || character.is_ascii_digit() || character == '_'
299        })
300}
301
302fn is_route_path(value: &str) -> bool {
303    value.starts_with('/') && !value.contains('?') && !value.contains('#') && !value.contains('\\')
304}
305
306fn is_artifact_root(value: &str) -> bool {
307    is_artifact_path(value) && value.starts_with("routes/")
308}
309
310fn is_artifact_path(value: &str) -> bool {
311    !value.is_empty()
312        && !value.starts_with('/')
313        && value.split('/').all(|segment| {
314            !segment.is_empty() && segment != "." && segment != ".." && !segment.contains('\\')
315        })
316}
317
318#[cfg(test)]
319mod tests {
320    use std::fs;
321
322    use presolve_compiler::{
323        ApplicationPublicationArtifactV1, FileRoutePublicationManifestV1,
324        FileRoutePublicationRouteV1,
325    };
326    use sha2::{Digest as _, Sha256};
327
328    use super::{
329        build_cloudflare_workers_static_deployment_plan_v1,
330        cloudflare_workers_static_worker_module_v1, cloudflare_workers_wrangler_jsonc_v1,
331        validate_cloudflare_workers_artifacts_v1, CloudflareWorkersDeploymentOptionsV1,
332        CLOUDFLARE_WORKERS_COMPATIBILITY_DATE_V1,
333    };
334
335    fn manifest(digest: String) -> FileRoutePublicationManifestV1 {
336        FileRoutePublicationManifestV1 {
337            schema_version: 1,
338            compiler_contract: "presolve-file-route-publication:1".into(),
339            profile: "production".into(),
340            routes: vec![FileRoutePublicationRouteV1 {
341                path: "/posts/:slug".into(),
342                entry_component_id: "component:post".into(),
343                artifact_root: "routes/segment-posts/parameter-slug".into(),
344                layout_component_ids: Vec::new(),
345            }],
346            artifacts: vec![ApplicationPublicationArtifactV1 {
347                path: "routes/segment-posts/parameter-slug/index.html".into(),
348                digest,
349            }],
350        }
351    }
352
353    #[test]
354    fn projects_only_compiler_owned_routes_artifacts_and_public_bindings() {
355        let plan = build_cloudflare_workers_static_deployment_plan_v1(
356            &manifest(
357                "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa".into(),
358            ),
359            &CloudflareWorkersDeploymentOptionsV1 {
360                worker_name: "presolve-docs".into(),
361                compatibility_date: CLOUDFLARE_WORKERS_COMPATIBILITY_DATE_V1.into(),
362                required_secrets: vec!["DOCS_TOKEN".into()],
363            },
364        )
365        .unwrap();
366        assert_eq!(plan.routes[0].path, "/posts/:slug");
367        assert_eq!(plan.required_secrets, ["DOCS_TOKEN"]);
368        let worker = cloudflare_workers_static_worker_module_v1(&plan);
369        assert!(worker.contains("routeFor"));
370        assert!(worker.contains("routes/segment-posts/parameter-slug"));
371        assert!(worker.contains("fetchCompilerAsset"));
372        assert!(worker.contains("redirected.pathname.startsWith('/routes/')"));
373        assert!(worker.contains("if (location)"));
374        assert!(!worker.contains("url.pathname === '/' && location"));
375        assert!(!worker.contains("component:post"));
376        let config = cloudflare_workers_wrangler_jsonc_v1(&plan, "../../dist");
377        assert!(config.contains("run_worker_first"));
378        assert!(config.contains("DOCS_TOKEN"));
379    }
380
381    #[test]
382    fn rejects_mutated_compiler_artifacts_before_upload() {
383        let bytes = b"<main>post</main>".to_vec();
384        let digest = format!("sha256:{:x}", Sha256::digest(&bytes));
385        let plan = build_cloudflare_workers_static_deployment_plan_v1(
386            &manifest(digest),
387            &CloudflareWorkersDeploymentOptionsV1 {
388                worker_name: "presolve-docs".into(),
389                compatibility_date: CLOUDFLARE_WORKERS_COMPATIBILITY_DATE_V1.into(),
390                required_secrets: Vec::new(),
391            },
392        )
393        .unwrap();
394        let root =
395            std::env::temp_dir().join(format!("presolve-cloudflare-plan-{}", std::process::id()));
396        let path = root.join("routes/segment-posts/parameter-slug/index.html");
397        fs::create_dir_all(path.parent().unwrap()).unwrap();
398        fs::write(&path, &bytes).unwrap();
399        assert!(validate_cloudflare_workers_artifacts_v1(&root, &plan).is_ok());
400        fs::write(&path, b"tampered").unwrap();
401        assert_eq!(
402            validate_cloudflare_workers_artifacts_v1(&root, &plan)
403                .unwrap_err()
404                .code,
405            "PSCFL1006_ARTIFACT_INTEGRITY_MISMATCH"
406        );
407        fs::remove_dir_all(root).unwrap();
408    }
409}