diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
index fb0233b03..4f0bb6cfb 100644
--- a/.github/workflows/test.yml
+++ b/.github/workflows/test.yml
@@ -27,6 +27,11 @@ jobs:
run: echo "viceroy-version=$(grep '^viceroy ' .tool-versions | awk '{print $2}')" >> $GITHUB_OUTPUT
shell: bash
+ - name: Retrieve Node.js version
+ id: node-version
+ run: echo "node-version=$(grep '^nodejs ' .tool-versions | awk '{print $2}')" >> $GITHUB_OUTPUT
+ shell: bash
+
- name: Set up Rust toolchain
uses: actions-rust-lang/setup-rust-toolchain@v1
with:
@@ -45,9 +50,20 @@ jobs:
if: steps.cache-viceroy.outputs.cache-hit != 'true'
run: cargo install viceroy --version "${{ steps.viceroy-version.outputs.viceroy-version }}" --locked --force
+ - name: Use Node.js for the served-seam contract
+ uses: actions/setup-node@v4
+ with:
+ node-version: ${{ steps.node-version.outputs.node-version }}
+
- name: Run tests
run: cargo test-fastly
+ - name: Run C2 ESI local harness
+ run: ./scripts/c2-local-test.sh esi
+
+ - name: Run inline control harness
+ run: ./scripts/c2-local-test.sh inline
+
test-axum:
name: cargo test (axum native)
runs-on: ubuntu-latest
diff --git a/CHANGELOG.md b/CHANGELOG.md
index c00487769..920b3c4df 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -11,6 +11,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- **Breaking** — Replaced the legacy APS contextual integration with APS OpenRTB at `/e/pb/bid`. APS configuration now uses canonical `account_id` (`pub_id` remains a compatibility alias), no longer requires APS-specific slot IDs, and defaults script creative eligibility off. Operators must update the endpoint, disable native APS demand for Trusted Server cohorts, and prepare GAM/Universal Creative targeting for `hb_bidder=aps` before rollout. `aps` entries in Prebid bidder lists are logged and stripped. APS renderer winners now preserve the upstream bid `id`, omit `crid` when APS omits it, and carry `ext.trusted_server.renderer` instead of `adm`; external `/auction` consumers must support this response shape.
- **Breaking** — All auction paths now forward only a validated publisher-owned page URL as `site.page`, removing query and fragment data. APS OpenRTB omits `site.ref`; the existing Prebid Server path continues to forward the browser `Referer` as `site.ref`. Query-driven sites may lose contextual targeting and per-page reporting signals that previously came from query parameters.
+- Publisher HTML now uses `Cache-Control: max-age=60` when server-side ad templates are inactive, while preserving origin `private`/`no-store` policies and CDN-specific cache headers. Set `[creative_opportunities].enabled = false` to disable publisher HTML and SPA template delivery without disabling direct `POST /auction` callers.
- **Breaking** — `bid_param_zone_overrides` inner values must now be JSON objects; previously non-object or empty values (`"header" = "x"`, `"header" = {}`) were accepted and silently produced a dead rule at runtime. They now fail at startup with a configuration error. Operators upgrading should audit their `bid_param_zone_overrides` config for non-object zone entries.
- **Breaking** — Integration configuration strings are no longer globally reinterpreted as JSON scalars. Operators upgrading should audit `[integrations.*]` settings and use native TOML/typed-config booleans and numbers (for example, `enabled = true`, not `enabled = "true"`); quoted numeric and boolean scalars now fail validation instead of silently converting.
- **Breaking** — Sourcepoint browser module inclusion now requires explicit `[integrations.sourcepoint].enabled = true`; operators relying on the previous unconditional Sourcepoint module should enable the integration before upgrading.
diff --git a/Cargo.lock b/Cargo.lock
index cb8f40c68..5c4748b41 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -146,7 +146,7 @@ dependencies = [
"asn1-rs-derive",
"asn1-rs-impl",
"displaydoc",
- "nom",
+ "nom 7.1.3",
"num-traits",
"rusticata-macros",
"thiserror 1.0.69",
@@ -254,6 +254,15 @@ dependencies = [
"tungstenite",
]
+[[package]]
+name = "atoi"
+version = "2.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f28d99ec8bfea296261ca1af174f24225171fea9664ba9003cbebee704810528"
+dependencies = [
+ "num-traits",
+]
+
[[package]]
name = "atomic-waker"
version = "1.1.2"
@@ -573,7 +582,18 @@ checksum = "c3613f74bd2eac03dad61bd53dbe620703d4371614fe0bc3b9f04dd36fe4e818"
dependencies = [
"cfg-if",
"cipher",
- "cpufeatures",
+ "cpufeatures 0.2.17",
+]
+
+[[package]]
+name = "chacha20"
+version = "0.10.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81"
+dependencies = [
+ "cfg-if",
+ "cpufeatures 0.3.0",
+ "rand_core 0.10.1",
]
[[package]]
@@ -583,7 +603,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "10cd79432192d1c0f4e1a0fef9527696cc039165d729fb41b3f4f4f354c2dc35"
dependencies = [
"aead",
- "chacha20",
+ "chacha20 0.9.1",
"cipher",
"poly1305",
"zeroize",
@@ -767,7 +787,7 @@ version = "3.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "faf9468729b8cbcea668e36183cb69d317348c2e08e994829fb56ebfdfbaac34"
dependencies = [
- "windows-sys 0.61.2",
+ "windows-sys 0.48.0",
]
[[package]]
@@ -916,6 +936,15 @@ dependencies = [
"libc",
]
+[[package]]
+name = "cpufeatures"
+version = "0.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201"
+dependencies = [
+ "libc",
+]
+
[[package]]
name = "crc32fast"
version = "1.5.0"
@@ -1041,7 +1070,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be"
dependencies = [
"cfg-if",
- "cpufeatures",
+ "cpufeatures 0.2.17",
"curve25519-dalek-derive",
"digest 0.10.7",
"fiat-crypto",
@@ -1186,7 +1215,7 @@ checksum = "5cd0a5c643689626bec213c4d8bd4d96acc8ffdb4ad4bb6bc16abf27d5f4b553"
dependencies = [
"asn1-rs",
"displaydoc",
- "nom",
+ "nom 7.1.3",
"num-bigint",
"num-traits",
"rusticata-macros",
@@ -1398,7 +1427,7 @@ dependencies = [
[[package]]
name = "edgezero-adapter"
version = "0.1.0"
-source = "git+https://github.com/stackpop/edgezero?tag=v0.0.4#9e661ae520a8130660f18fd10f42703d7f3e050b"
+source = "git+https://github.com/stackpop/edgezero?branch=feature%2Fedgezero-deploy-actions#bb4411625856472b1279a3db49aeeac5e8b1507e"
dependencies = [
"toml",
]
@@ -1406,7 +1435,7 @@ dependencies = [
[[package]]
name = "edgezero-adapter-axum"
version = "0.1.0"
-source = "git+https://github.com/stackpop/edgezero?tag=v0.0.4#9e661ae520a8130660f18fd10f42703d7f3e050b"
+source = "git+https://github.com/stackpop/edgezero?branch=feature%2Fedgezero-deploy-actions#bb4411625856472b1279a3db49aeeac5e8b1507e"
dependencies = [
"anyhow",
"async-trait",
@@ -1434,7 +1463,7 @@ dependencies = [
[[package]]
name = "edgezero-adapter-cloudflare"
version = "0.1.0"
-source = "git+https://github.com/stackpop/edgezero?tag=v0.0.4#9e661ae520a8130660f18fd10f42703d7f3e050b"
+source = "git+https://github.com/stackpop/edgezero?branch=feature%2Fedgezero-deploy-actions#bb4411625856472b1279a3db49aeeac5e8b1507e"
dependencies = [
"anyhow",
"async-trait",
@@ -1449,7 +1478,7 @@ dependencies = [
"log",
"serde_json",
"tempfile",
- "toml_edit",
+ "toml_edit 0.25.12+spec-1.1.0",
"walkdir",
"worker",
]
@@ -1457,7 +1486,7 @@ dependencies = [
[[package]]
name = "edgezero-adapter-fastly"
version = "0.1.0"
-source = "git+https://github.com/stackpop/edgezero?tag=v0.0.4#9e661ae520a8130660f18fd10f42703d7f3e050b"
+source = "git+https://github.com/stackpop/edgezero?branch=feature%2Fedgezero-deploy-actions#bb4411625856472b1279a3db49aeeac5e8b1507e"
dependencies = [
"anyhow",
"async-stream",
@@ -1479,14 +1508,14 @@ dependencies = [
"serde_json",
"sha2 0.10.9",
"thiserror 2.0.18",
- "toml_edit",
+ "toml_edit 0.25.12+spec-1.1.0",
"walkdir",
]
[[package]]
name = "edgezero-adapter-spin"
version = "0.1.0"
-source = "git+https://github.com/stackpop/edgezero?tag=v0.0.4#9e661ae520a8130660f18fd10f42703d7f3e050b"
+source = "git+https://github.com/stackpop/edgezero?branch=feature%2Fedgezero-deploy-actions#bb4411625856472b1279a3db49aeeac5e8b1507e"
dependencies = [
"anyhow",
"async-trait",
@@ -1506,14 +1535,14 @@ dependencies = [
"subtle",
"thiserror 2.0.18",
"toml",
- "toml_edit",
+ "toml_edit 0.25.12+spec-1.1.0",
"walkdir",
]
[[package]]
name = "edgezero-cli"
version = "0.1.0"
-source = "git+https://github.com/stackpop/edgezero?tag=v0.0.4#9e661ae520a8130660f18fd10f42703d7f3e050b"
+source = "git+https://github.com/stackpop/edgezero?branch=feature%2Fedgezero-deploy-actions#bb4411625856472b1279a3db49aeeac5e8b1507e"
dependencies = [
"chrono",
"clap",
@@ -1538,7 +1567,7 @@ dependencies = [
[[package]]
name = "edgezero-core"
version = "0.1.0"
-source = "git+https://github.com/stackpop/edgezero?tag=v0.0.4#9e661ae520a8130660f18fd10f42703d7f3e050b"
+source = "git+https://github.com/stackpop/edgezero?branch=feature%2Fedgezero-deploy-actions#bb4411625856472b1279a3db49aeeac5e8b1507e"
dependencies = [
"anyhow",
"async-compression",
@@ -1569,7 +1598,7 @@ dependencies = [
[[package]]
name = "edgezero-macros"
version = "0.1.0"
-source = "git+https://github.com/stackpop/edgezero?tag=v0.0.4#9e661ae520a8130660f18fd10f42703d7f3e050b"
+source = "git+https://github.com/stackpop/edgezero?branch=feature%2Fedgezero-deploy-actions#bb4411625856472b1279a3db49aeeac5e8b1507e"
dependencies = [
"log",
"proc-macro2",
@@ -1690,6 +1719,26 @@ dependencies = [
"rustc_version",
]
+[[package]]
+name = "esi"
+version = "0.7.1"
+source = "git+https://github.com/stackpop/esi.git?rev=4c53feab4d22ad9a84641b4c46f3f63bc6d197e2#4c53feab4d22ad9a84641b4c46f3f63bc6d197e2"
+dependencies = [
+ "atoi",
+ "base64",
+ "bytes",
+ "chrono",
+ "fastly",
+ "html-escape",
+ "log",
+ "md5",
+ "nom 8.0.0",
+ "percent-encoding",
+ "rand 0.10.2",
+ "regex",
+ "thiserror 2.0.18",
+]
+
[[package]]
name = "etcetera"
version = "0.10.0"
@@ -2034,6 +2083,7 @@ dependencies = [
"cfg-if",
"libc",
"r-efi 6.0.0",
+ "rand_core 0.10.1",
]
[[package]]
@@ -2188,6 +2238,12 @@ dependencies = [
"windows-sys 0.61.2",
]
+[[package]]
+name = "html-escape"
+version = "0.2.15"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c9356095b4b41197bba32173600e1582792cda618f65d12f68e2e77d273413c5"
+
[[package]]
name = "html5ever"
version = "0.35.0"
@@ -2914,6 +2970,12 @@ version = "0.9.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8863b587001c1b9a8a4e36008cebc6b3612cb1226fe2de94858e06092687b608"
+[[package]]
+name = "md5"
+version = "0.8.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7ebb8d8732c6a6df3d8f032a82911cfc747e00efb95cc46e8d0acd5b5b88570c"
+
[[package]]
name = "memchr"
version = "2.8.2"
@@ -2984,6 +3046,15 @@ dependencies = [
"minimal-lexical",
]
+[[package]]
+name = "nom"
+version = "8.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405"
+dependencies = [
+ "memchr",
+]
+
[[package]]
name = "num"
version = "0.4.3"
@@ -3477,7 +3548,7 @@ version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8159bd90725d2df49889a078b54f4f79e87f1f8a8444194cdca81d38f5393abf"
dependencies = [
- "cpufeatures",
+ "cpufeatures 0.2.17",
"opaque-debug",
"universal-hash",
]
@@ -3604,7 +3675,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "be769465445e8c1474e9c5dac2018218498557af32d9ed057325ec9a41ae81bf"
dependencies = [
"heck",
- "itertools 0.13.0",
+ "itertools 0.10.5",
"log",
"multimap",
"once_cell",
@@ -3624,7 +3695,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8a56d757972c98b346a9b766e3f02746cde6dd1cd1d1d563472929fdd74bec4d"
dependencies = [
"anyhow",
- "itertools 0.13.0",
+ "itertools 0.10.5",
"proc-macro2",
"quote",
"syn 2.0.118",
@@ -3637,7 +3708,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b570b25f7617e43d59005d0990ccb79e950a423952cea19671b7a876da390adf"
dependencies = [
"anyhow",
- "itertools 0.13.0",
+ "itertools 0.10.5",
"proc-macro2",
"quote",
"syn 2.0.118",
@@ -3775,6 +3846,17 @@ dependencies = [
"rand_core 0.9.5",
]
+[[package]]
+name = "rand"
+version = "0.10.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80"
+dependencies = [
+ "chacha20 0.10.1",
+ "getrandom 0.4.3",
+ "rand_core 0.10.1",
+]
+
[[package]]
name = "rand_chacha"
version = "0.3.1"
@@ -3813,6 +3895,12 @@ dependencies = [
"getrandom 0.3.4",
]
+[[package]]
+name = "rand_core"
+version = "0.10.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69"
+
[[package]]
name = "rcgen"
version = "0.13.2"
@@ -4079,7 +4167,7 @@ version = "4.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "faf0c4a6ece9950b9abdb62b1cfcf2a68b3b67a10ba445b3bb85be2a293d0632"
dependencies = [
- "nom",
+ "nom 7.1.3",
]
[[package]]
@@ -4494,7 +4582,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba"
dependencies = [
"cfg-if",
- "cpufeatures",
+ "cpufeatures 0.2.17",
"digest 0.10.7",
]
@@ -4506,7 +4594,7 @@ checksum = "4d58a1e1bf39749807d89cf2d98ac2dfa0ff1cb3faa38fbb64dd88ac8013d800"
dependencies = [
"block-buffer 0.9.0",
"cfg-if",
- "cpufeatures",
+ "cpufeatures 0.2.17",
"digest 0.9.0",
"opaque-debug",
]
@@ -4518,7 +4606,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
dependencies = [
"cfg-if",
- "cpufeatures",
+ "cpufeatures 0.2.17",
"digest 0.10.7",
]
@@ -5074,6 +5162,19 @@ dependencies = [
"winnow 0.7.15",
]
+[[package]]
+name = "toml_edit"
+version = "0.25.12+spec-1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d2153edc6955a6c354fad8f5efd38b6a8769bdccf9fe50f8e1329f81b0baa5d7"
+dependencies = [
+ "indexmap 2.14.0",
+ "toml_datetime 1.1.1+spec-1.1.0",
+ "toml_parser",
+ "toml_writer",
+ "winnow 1.0.3",
+]
+
[[package]]
name = "toml_parser"
version = "1.1.2+spec-1.1.0"
@@ -5273,9 +5374,11 @@ dependencies = [
"base64",
"bytes",
"chrono",
+ "derive_more",
"edgezero-adapter-fastly",
"edgezero-core",
"error-stack",
+ "esi",
"fastly",
"fern",
"futures",
@@ -5340,7 +5443,7 @@ dependencies = [
"tokio",
"tokio-rustls",
"toml",
- "toml_edit",
+ "toml_edit 0.23.10+spec-1.0.0",
"trusted-server-core",
"url",
"webpki-roots",
@@ -5373,6 +5476,7 @@ dependencies = [
"hex",
"hmac",
"http",
+ "httpdate",
"iab_gpp",
"jose-jwk",
"log",
@@ -5896,7 +6000,7 @@ version = "0.1.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
dependencies = [
- "windows-sys 0.61.2",
+ "windows-sys 0.48.0",
]
[[package]]
@@ -6325,7 +6429,7 @@ dependencies = [
"data-encoding",
"der-parser",
"lazy_static",
- "nom",
+ "nom 7.1.3",
"oid-registry",
"ring",
"rusticata-macros",
diff --git a/Cargo.toml b/Cargo.toml
index 7ca87e687..ab5638f5b 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -54,12 +54,12 @@ criterion = { version = "0.5", default-features = false, features = ["cargo_benc
derive_more = { version = "2.0", features = ["display", "error"] }
directories = "5"
ed25519-dalek = { version = "2.2", features = ["rand_core"] }
-edgezero-adapter-axum = { git = "https://github.com/stackpop/edgezero", tag = "v0.0.4", default-features = false }
-edgezero-adapter-cloudflare = { git = "https://github.com/stackpop/edgezero", tag = "v0.0.4", default-features = false }
-edgezero-adapter-fastly = { git = "https://github.com/stackpop/edgezero", tag = "v0.0.4", default-features = false }
-edgezero-adapter-spin = { git = "https://github.com/stackpop/edgezero", tag = "v0.0.4", default-features = false }
-edgezero-cli = { git = "https://github.com/stackpop/edgezero", tag = "v0.0.4" }
-edgezero-core = { git = "https://github.com/stackpop/edgezero", tag = "v0.0.4", default-features = false }
+edgezero-adapter-axum = { git = "https://github.com/stackpop/edgezero", branch = "feature/edgezero-deploy-actions", default-features = false }
+edgezero-adapter-cloudflare = { git = "https://github.com/stackpop/edgezero", branch = "feature/edgezero-deploy-actions", default-features = false }
+edgezero-adapter-fastly = { git = "https://github.com/stackpop/edgezero", branch = "feature/edgezero-deploy-actions", default-features = false }
+edgezero-adapter-spin = { git = "https://github.com/stackpop/edgezero", branch = "feature/edgezero-deploy-actions", default-features = false }
+edgezero-cli = { git = "https://github.com/stackpop/edgezero", branch = "feature/edgezero-deploy-actions" }
+edgezero-core = { git = "https://github.com/stackpop/edgezero", branch = "feature/edgezero-deploy-actions", default-features = false }
env_logger = "0.11"
error-stack = "0.6"
fastly = "0.12"
@@ -71,6 +71,7 @@ getrandom = "0.2"
hex = "0.4.3"
hmac = "0.12.1"
http = "1.4.0"
+httpdate = "1.0.3"
http-body-util = "0.1"
hyper = "1"
hyper-util = "0.1"
diff --git a/crates/trusted-server-adapter-fastly/Cargo.toml b/crates/trusted-server-adapter-fastly/Cargo.toml
index b6bc0f1a1..3d42ae388 100644
--- a/crates/trusted-server-adapter-fastly/Cargo.toml
+++ b/crates/trusted-server-adapter-fastly/Cargo.toml
@@ -15,9 +15,11 @@ async-trait = { workspace = true }
base64 = { workspace = true }
bytes = { workspace = true }
chrono = { workspace = true }
+derive_more = { workspace = true }
edgezero-adapter-fastly = { workspace = true, features = ["fastly"] }
edgezero-core = { workspace = true }
error-stack = { workspace = true }
+esi = { git = "https://github.com/stackpop/esi.git", rev = "4c53feab4d22ad9a84641b4c46f3f63bc6d197e2" }
fastly = { workspace = true }
fern = { workspace = true }
futures = { workspace = true }
diff --git a/crates/trusted-server-adapter-fastly/src/app.rs b/crates/trusted-server-adapter-fastly/src/app.rs
index d6090c983..6be93b4b3 100644
--- a/crates/trusted-server-adapter-fastly/src/app.rs
+++ b/crates/trusted-server-adapter-fastly/src/app.rs
@@ -257,6 +257,11 @@ fn build_per_request_services(state: &AppState, ctx: &RequestContext) -> Runtime
.config_store(Arc::new(FastlyPlatformConfigStore))
.secret_store(Arc::new(FastlyPlatformSecretStore))
.kv_store(Arc::clone(&state.default_kv_store))
+ // Spike-only (#1009). Constructed unconditionally, but only read when the
+ // assembly mode is a shared-template one — which defaults to Inline, so this
+ // is inert until an operator opts in.
+ .template_cache(Arc::new(crate::template_cache::FastlyTemplateCache::new()))
+ .template_assembler(Arc::new(crate::esi_assembly::FastlyTemplateAssembler))
.backend(Arc::new(FastlyPlatformBackend))
.http_client(Arc::new(FastlyPlatformHttpClient))
.geo(Arc::new(FastlyPlatformGeo))
@@ -1239,12 +1244,15 @@ mod tests {
use super::{
AppState, NAMED_ROUTES, NamedRouteHandler, PAGE_BIDS_LEGACY_PATH, PAGE_BIDS_PATH,
- TrustedServerApp, build_state_from_settings, startup_error_router,
+ TrustedServerApp, build_per_request_services, build_state_from_settings,
+ startup_error_router,
};
use bytes::Bytes;
use edgezero_core::body::Body;
+ use edgezero_core::context::RequestContext;
use edgezero_core::http::{Method, Response, StatusCode, header, request_builder};
use edgezero_core::key_value_store::NoopKvStore;
+ use edgezero_core::params::PathParams;
use edgezero_core::router::RouterService;
use std::net::{IpAddr, Ipv4Addr};
use std::sync::Mutex;
@@ -1379,6 +1387,36 @@ mod tests {
TrustedServerApp::routes_for_state(&state)
}
+ #[test]
+ fn per_request_services_register_the_fastly_template_assembler() {
+ let state = build_state_from_settings(test_settings()).expect("should build test state");
+ let context = RequestContext::new(
+ empty_request(Method::GET, "/article"),
+ PathParams::default(),
+ );
+
+ let services = build_per_request_services(&state, &context);
+ let template = format!(
+ "
article{}",
+ trusted_server_core::publisher::AD_ASSEMBLY_SEAM
+ );
+ let fragment = b"";
+ let assembled = services
+ .template_assembler()
+ .assemble(template.as_bytes(), fragment)
+ .expect("Fastly services should provide ESI assembly");
+
+ assert_eq!(
+ assembled,
+ template
+ .replace(
+ trusted_server_core::publisher::AD_ASSEMBLY_SEAM,
+ std::str::from_utf8(fragment).expect("fragment should be UTF-8")
+ )
+ .into_bytes()
+ );
+ }
+
/// Builds a router whose `AppState` uses a registry containing the given
/// request filters (and no routes), so dispatch-level request-filter
/// behavior can be exercised without a real integration.
diff --git a/crates/trusted-server-adapter-fastly/src/esi_assembly.rs b/crates/trusted-server-adapter-fastly/src/esi_assembly.rs
new file mode 100644
index 000000000..4a92c6f78
--- /dev/null
+++ b/crates/trusted-server-adapter-fastly/src/esi_assembly.rs
@@ -0,0 +1,287 @@
+//! Fastly cold-response assembly backed by the repaired `stackpop/esi` parser.
+//!
+//! C2 stores an inert marker. This module creates one synthetic ESI include only in a
+//! request-private working copy, resolves it from an already-built fragment, and never
+//! performs an HTTP request.
+
+use std::io::Cursor;
+
+use esi::{CacheConfig, Configuration, DcaMode, PendingFragmentContent, Processor};
+use fastly::http::StatusCode;
+use fastly::{Request, Response};
+use trusted_server_core::platform::{PlatformTemplateAssembler, TemplateAssemblyError};
+use trusted_server_core::publisher::AD_ASSEMBLY_SEAM;
+
+const INTERNAL_FRAGMENT_PATH: &str = "/_ts/internal/reader-ad-state";
+const SYNTHETIC_ESI_INCLUDE: &[u8] = b"";
+
+/// Why the Fastly ESI adapter refused or failed to assemble a document.
+#[derive(Debug, derive_more::Display)]
+enum EsiAssemblyError {
+ /// The inert seam marker was missing or repeated.
+ #[display("expected exactly one inert seam marker, found {count}")]
+ InvalidMarkerCount { count: usize },
+ /// Publisher bytes contained ESI instructions outside TS's synthetic seam.
+ #[display("publisher-authored ESI directives are not allowed")]
+ PublisherEsiDirective,
+ /// The parser dispatched a URL other than TS's one synthetic fragment.
+ #[display("unexpected fragment request path `{path}` (query present: {has_query})")]
+ UnexpectedFragmentRequest { path: String, has_query: bool },
+ /// The pinned parser could not process the document.
+ #[display("ESI processing failed: {message}")]
+ Processing { message: String },
+ /// The parser changed bytes outside the one synthetic include.
+ #[display("ESI output was not an exact seam substitution")]
+ OutputMismatch,
+}
+
+impl core::error::Error for EsiAssemblyError {}
+
+/// ESI configuration with every cache- and recursion-sensitive option explicit.
+fn assembly_configuration() -> Configuration {
+ Configuration::default()
+ .with_escaped(false)
+ .with_default_dca(DcaMode::None)
+ .with_inherit_parent_dca(false)
+ .with_max_include_depth(1)
+ .with_edge_control(false)
+ .with_caching(CacheConfig {
+ is_includes_cacheable: false,
+ includes_default_ttl: None,
+ includes_force_ttl: None,
+ is_rendered_cacheable: false,
+ rendered_cache_control: false,
+ rendered_ttl: None,
+ })
+}
+
+fn contains_esi_directive(bytes: &[u8]) -> bool {
+ bytes
+ .windows(b" Result<(Vec, usize), EsiAssemblyError> {
+ let marker = AD_ASSEMBLY_SEAM.as_bytes();
+ let positions = template
+ .windows(marker.len())
+ .enumerate()
+ .filter_map(|(at, window)| (window == marker).then_some(at))
+ .collect::>();
+ if positions.len() != 1 {
+ return Err(EsiAssemblyError::InvalidMarkerCount {
+ count: positions.len(),
+ });
+ }
+ if contains_esi_directive(template) {
+ return Err(EsiAssemblyError::PublisherEsiDirective);
+ }
+
+ let at = positions[0];
+ let mut working =
+ Vec::with_capacity(template.len() - marker.len() + SYNTHETIC_ESI_INCLUDE.len());
+ working.extend_from_slice(&template[..at]);
+ working.extend_from_slice(SYNTHETIC_ESI_INCLUDE);
+ working.extend_from_slice(&template[at + marker.len()..]);
+ Ok((working, at))
+}
+
+fn completed_fragment_response(
+ request: &Request,
+ fragment: &[u8],
+) -> Result {
+ let path = request.get_path().to_string();
+ let has_query = request.get_url().query().is_some();
+ if path != INTERNAL_FRAGMENT_PATH || has_query {
+ return Err(EsiAssemblyError::UnexpectedFragmentRequest { path, has_query });
+ }
+
+ Ok(PendingFragmentContent::CompletedRequest(Box::new(
+ Response::from_status(StatusCode::OK)
+ .with_header(
+ fastly::http::header::CONTENT_TYPE,
+ "text/html; charset=utf-8",
+ )
+ .with_body(fragment.to_vec()),
+ )))
+}
+
+fn assemble_with_observer(
+ template: &[u8],
+ fragment: &[u8],
+ on_dispatch: F,
+) -> Result, EsiAssemblyError>
+where
+ F: Fn() + 'static,
+{
+ let (working, seam_at) = template_with_synthetic_include(template)?;
+ let fragment_len = fragment.len();
+ let fragment_response = fragment.to_vec();
+ let dispatcher = move |request, _index| {
+ on_dispatch();
+ completed_fragment_response(&request, &fragment_response)
+ .map_err(|error| esi::ESIError::FragmentRequestError(error.to_string()))
+ };
+ let mut processor = Processor::new(None, assembly_configuration());
+ let mut output = Vec::with_capacity(template.len() + fragment_len);
+ processor
+ .process_stream(Cursor::new(working), &mut output, Some(&dispatcher), None)
+ .map_err(|error| EsiAssemblyError::Processing {
+ message: error.to_string(),
+ })?;
+ let expected_len = template.len() - AD_ASSEMBLY_SEAM.len() + fragment_len;
+ let output_tail_at = seam_at + fragment_len;
+ let template_tail_at = seam_at + AD_ASSEMBLY_SEAM.len();
+ if output.len() != expected_len
+ || output[..seam_at] != template[..seam_at]
+ || &output[seam_at..output_tail_at] != fragment
+ || output[output_tail_at..] != template[template_tail_at..]
+ {
+ return Err(EsiAssemblyError::OutputMismatch);
+ }
+ Ok(output)
+}
+
+fn assemble(template: &[u8], fragment: &[u8]) -> Result, EsiAssemblyError> {
+ assemble_with_observer(template, fragment, || {})
+}
+
+/// Fastly implementation of the core cold-response assembly boundary.
+pub struct FastlyTemplateAssembler;
+
+impl PlatformTemplateAssembler for FastlyTemplateAssembler {
+ fn assemble(&self, template: &[u8], fragment: &[u8]) -> Result, TemplateAssemblyError> {
+ assemble(template, fragment).map_err(|error| TemplateAssemblyError::Failed {
+ message: error.to_string(),
+ })
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use std::sync::Arc;
+ use std::sync::atomic::{AtomicUsize, Ordering};
+
+ use super::*;
+ use trusted_server_core::publisher::AD_ASSEMBLY_SEAM;
+
+ const FRAGMENT: &[u8] = b"";
+
+ fn template(body: &str) -> Vec {
+ format!("{body}{AD_ASSEMBLY_SEAM}").into_bytes()
+ }
+
+ #[test]
+ fn a_script_larger_than_the_parser_chunk_survives_exactly() {
+ let script = format!(
+ "",
+ "x".repeat(120_000)
+ );
+ let document = template(&script);
+ let dispatches = Arc::new(AtomicUsize::new(0));
+ let observed_dispatches = Arc::clone(&dispatches);
+
+ let assembled = assemble_with_observer(&document, FRAGMENT, move || {
+ observed_dispatches.fetch_add(1, Ordering::Relaxed);
+ })
+ .expect("should assemble a document with a large script");
+
+ let seam_at = document
+ .windows(AD_ASSEMBLY_SEAM.len())
+ .position(|window| window == AD_ASSEMBLY_SEAM.as_bytes())
+ .expect("should find seam");
+ let mut expected = Vec::new();
+ expected.extend_from_slice(&document[..seam_at]);
+ expected.extend_from_slice(FRAGMENT);
+ expected.extend_from_slice(&document[seam_at + AD_ASSEMBLY_SEAM.len()..]);
+
+ assert_eq!(
+ assembled, expected,
+ "ESI must alter only the synthetic seam"
+ );
+ assert_eq!(dispatches.load(Ordering::Relaxed), 1);
+ }
+
+ #[test]
+ fn missing_and_repeated_markers_are_rejected_before_parsing() {
+ let missing = assemble(b"plain", FRAGMENT)
+ .expect_err("should reject a missing marker");
+ let repeated = assemble(
+ format!("{AD_ASSEMBLY_SEAM}{AD_ASSEMBLY_SEAM}").as_bytes(),
+ FRAGMENT,
+ )
+ .expect_err("should reject repeated markers");
+
+ assert!(matches!(
+ missing,
+ EsiAssemblyError::InvalidMarkerCount { count: 0 }
+ ));
+ assert!(matches!(
+ repeated,
+ EsiAssemblyError::InvalidMarkerCount { count: 2 }
+ ));
+ }
+
+ #[test]
+ fn every_publisher_esi_directive_form_is_rejected_case_insensitively() {
+ for directive in [
+ "",
+ "secret",
+ "x",
+ "$(HTTP_HOST)",
+ "text",
+ "",
+ ] {
+ let error = assemble(&template(directive), FRAGMENT)
+ .expect_err("should reject publisher-authored ESI");
+
+ assert!(matches!(error, EsiAssemblyError::PublisherEsiDirective));
+ }
+ }
+
+ #[test]
+ fn fragment_esi_is_emitted_verbatim_and_never_reparsed() {
+ let fragment = b"";
+
+ let assembled = assemble(&template("article"), fragment).expect("should assemble");
+
+ assert!(
+ assembled
+ .windows(fragment.len())
+ .any(|window| window == fragment),
+ "fragment bytes must remain data"
+ );
+ }
+
+ #[test]
+ fn dispatcher_rejects_every_url_except_the_synthetic_internal_one() {
+ let unexpected = fastly::Request::get("https://example.com/not-the-seam");
+ let with_query =
+ fastly::Request::get("https://example.com/_ts/internal/reader-ad-state?publisher=1");
+
+ assert!(matches!(
+ completed_fragment_response(&unexpected, FRAGMENT),
+ Err(EsiAssemblyError::UnexpectedFragmentRequest { .. })
+ ));
+ assert!(matches!(
+ completed_fragment_response(&with_query, FRAGMENT),
+ Err(EsiAssemblyError::UnexpectedFragmentRequest { .. })
+ ));
+ }
+
+ #[test]
+ fn configuration_cannot_cache_or_reparse_reader_state() {
+ let configuration = assembly_configuration();
+
+ assert!(!configuration.cache.is_includes_cacheable);
+ assert!(configuration.cache.includes_default_ttl.is_none());
+ assert!(configuration.cache.includes_force_ttl.is_none());
+ assert!(!configuration.cache.is_rendered_cacheable);
+ assert!(!configuration.cache.rendered_cache_control);
+ assert!(configuration.cache.rendered_ttl.is_none());
+ assert_eq!(configuration.default_dca, DcaMode::None);
+ assert!(!configuration.inherit_parent_dca);
+ assert_eq!(configuration.max_include_depth, 1);
+ assert!(!configuration.enable_edge_control);
+ }
+}
diff --git a/crates/trusted-server-adapter-fastly/src/main.rs b/crates/trusted-server-adapter-fastly/src/main.rs
index 39d35b198..07c042ea8 100644
--- a/crates/trusted-server-adapter-fastly/src/main.rs
+++ b/crates/trusted-server-adapter-fastly/src/main.rs
@@ -29,11 +29,13 @@ mod app;
mod backend;
mod compat;
mod ec_kv;
+mod esi_assembly;
mod logging;
mod management_api;
mod middleware;
mod platform;
mod rate_limiter;
+mod template_cache;
mod tinybird;
use crate::app::{EcFinalizeState, TrustedServerApp, load_settings_from_config_store};
@@ -328,14 +330,7 @@ fn send_edgezero_response(
mut response: HttpResponse,
request_filter_effects: Option<&RequestFilterEffects>,
) {
- if let Some(effects) = request_filter_effects {
- effects.apply_to_response(&mut response);
- }
-
- // Final cache guard: EC finalization and request-filter effects may have
- // added a per-user Set-Cookie after `apply_finalize_headers` ran, so
- // re-apply the privacy downgrade before send.
- crate::middleware::enforce_set_cookie_cache_privacy(&mut response);
+ apply_terminal_response_effects(&mut response, request_filter_effects);
let (parts, body) = response.into_parts();
@@ -364,6 +359,26 @@ fn send_edgezero_response(
}
}
+/// Apply every late response mutation, then restore privacy invariants before headers commit.
+fn apply_terminal_response_effects(
+ response: &mut HttpResponse,
+ request_filter_effects: Option<&RequestFilterEffects>,
+) {
+ let must_remain_private =
+ trusted_server_core::response_privacy::is_private_or_no_store(response.headers());
+ if let Some(effects) = request_filter_effects {
+ effects.apply_to_response(response);
+ }
+ if must_remain_private {
+ trusted_server_core::response_privacy::enforce_private_no_store(response);
+ }
+
+ // Final cache guard: EC finalization and request-filter effects may have
+ // added a per-user Set-Cookie after `apply_finalize_headers` ran, so
+ // re-apply the privacy downgrade before send.
+ crate::middleware::enforce_set_cookie_cache_privacy(response);
+}
+
const FALLBACK_UNAVAILABLE: &str = "unavailable";
const FALLBACK_NOT_SENT: &str = "not sent";
const FALLBACK_NONE: &str = "none";
@@ -485,6 +500,7 @@ mod tests {
use edgezero_core::http::HeaderValue;
use edgezero_core::http::response_builder;
use fastly::mime;
+ use trusted_server_core::integrations::HeaderMutation;
fn test_settings() -> Settings {
Settings::from_toml(
@@ -557,6 +573,36 @@ mod tests {
);
}
+ #[test]
+ fn late_filter_effects_cannot_make_an_assembled_response_public() {
+ let mut response = response_builder()
+ .header("cache-control", "private, no-store")
+ .header("etag", "\"reader-document\"")
+ .body(EdgeBody::empty())
+ .expect("should build response");
+ let effects = RequestFilterEffects {
+ request_headers: Vec::new(),
+ response_headers: vec![
+ HeaderMutation::set("cache-control", "public, s-maxage=3600"),
+ HeaderMutation::set("surrogate-control", "max-age=3600"),
+ HeaderMutation::set("cdn-cache-control", "public, max-age=3600"),
+ ],
+ };
+
+ apply_terminal_response_effects(&mut response, Some(&effects));
+
+ assert_eq!(
+ response
+ .headers()
+ .get("cache-control")
+ .and_then(|value| value.to_str().ok()),
+ Some("private, no-store")
+ );
+ assert!(response.headers().get("surrogate-control").is_none());
+ assert!(response.headers().get("cdn-cache-control").is_none());
+ assert!(response.headers().get("etag").is_none());
+ }
+
#[test]
#[allow(clippy::panic)]
fn entry_point_finalize_skips_geo_lookup_for_401() {
diff --git a/crates/trusted-server-adapter-fastly/src/template_cache.rs b/crates/trusted-server-adapter-fastly/src/template_cache.rs
new file mode 100644
index 000000000..3f168f574
--- /dev/null
+++ b/crates/trusted-server-adapter-fastly/src/template_cache.rs
@@ -0,0 +1,505 @@
+//! Fastly Core Cache backing for the shared transformed-template cache (C2).
+//!
+//! Only the Fastly adapter implements this; every other adapter uses
+//! `UnavailableTemplateCache`, so the ESI assembly mode stays portable and only
+//! the caching is Fastly-only.
+//!
+//! **Why Core Cache and not read-through caching.** Read-through with `after_send` +
+//! `set_body_transform` looks like a better fit — it keeps HTTP semantics and derives
+//! TTL and surrogate keys from origin headers for free. It is unreachable here:
+//! Viceroy 0.17 stubs the entire HTTP Cache ABI and the SDK converts that into a
+//! *send error*, so setting `after_send` makes every publisher origin fetch fail
+//! under `fastly compute serve`, `cargo test-fastly` and the parity suite. It is also
+//! silently dead whenever the origin request is in pass mode, and its closure bounds
+//! (`Fn + Send + Sync`) are incompatible with a platform layer that is `!Send` by
+//! construction. Recorded in the spike plan's Task 3 Step 4 so nobody re-proposes it.
+//!
+//! Spike-only. Remove with the spike.
+
+use fastly::cache::core::{CacheKey, Found, Transaction};
+use std::io::Write as _;
+use std::time::Duration;
+use trusted_server_core::platform::{
+ PlatformTemplateCache, PlatformTemplateCacheReservation, TemplateCacheError, TemplateCacheKey,
+ TemplateCacheLookup, TemplateCacheMiss, TemplateCacheReservation, TemplateEntry,
+ TemplateMetadata,
+};
+
+/// Surrogate key attached to every stored template, so a single purge clears them
+/// all. This is the rollback lever: without it, backing out a bad template means
+/// waiting for the TTL.
+const PURGE_ALL_SURROGATE_KEY: &str = "ts-template";
+
+/// Fastly Core Cache implementation of the C2 template cache.
+#[derive(Default)]
+pub struct FastlyTemplateCache;
+
+impl FastlyTemplateCache {
+ /// Create the Fastly Core Cache implementation.
+ ///
+ /// Entry lifetime is supplied per insert after core validates origin freshness
+ /// and applies the operator's configured safety ceiling.
+ #[must_use]
+ pub const fn new() -> Self {
+ Self
+ }
+}
+
+fn backend_error(message: impl Into) -> TemplateCacheError {
+ TemplateCacheError::Backend {
+ message: message.into(),
+ }
+}
+
+enum ReadFoundError {
+ Invalid(TemplateCacheMiss),
+ Backend(TemplateCacheError),
+}
+
+fn read_found(found: &Found, key: &TemplateCacheKey) -> Result {
+ if found.is_stale() {
+ return Err(ReadFoundError::Invalid(TemplateCacheMiss::NotFound));
+ }
+
+ let metadata = TemplateMetadata::decode(&found.user_metadata()).ok_or(
+ ReadFoundError::Invalid(TemplateCacheMiss::UnreadableMetadata),
+ )?;
+ if metadata.schema_version != key.schema_version {
+ return Err(ReadFoundError::Invalid(TemplateCacheMiss::SchemaMismatch));
+ }
+ if found
+ .known_length()
+ .is_some_and(|length| length != metadata.body_len)
+ {
+ return Err(ReadFoundError::Invalid(TemplateCacheMiss::Truncated));
+ }
+
+ let body = found
+ .to_stream()
+ .map_err(|error| {
+ ReadFoundError::Backend(backend_error(format!(
+ "opening cached template body failed: {error:?}"
+ )))
+ })?
+ .into_bytes();
+ if body.len() as u64 != metadata.body_len {
+ return Err(ReadFoundError::Invalid(TemplateCacheMiss::Truncated));
+ }
+ Ok(TemplateEntry { metadata, body })
+}
+
+struct FastlyTemplateReservation {
+ transaction: Transaction,
+ surrogate_keys: Vec,
+}
+
+impl PlatformTemplateCacheReservation for FastlyTemplateReservation {
+ fn insert(
+ self: Box,
+ metadata: &TemplateMetadata,
+ body: Vec,
+ max_age: Duration,
+ ) -> Result<(), TemplateCacheError> {
+ if metadata.body_len != body.len() as u64 {
+ return Err(backend_error(format!(
+ "metadata body_len {} does not match the {} bytes supplied",
+ metadata.body_len,
+ body.len()
+ )));
+ }
+
+ let mut writer = self
+ .transaction
+ .insert(max_age)
+ .surrogate_keys(self.surrogate_keys.iter().map(String::as_str))
+ .known_length(body.len() as u64)
+ .user_metadata(metadata.encode().into())
+ .execute()
+ .map_err(|e| backend_error(format!("cache insert failed: {e:?}")))?;
+ writer
+ .write_all(&body)
+ .map_err(|e| backend_error(format!("writing template body failed: {e}")))?;
+ writer
+ .finish()
+ .map_err(|e| backend_error(format!("finishing the cached template failed: {e}")))?;
+ Ok(())
+ }
+
+ fn cancel(self: Box) -> Result<(), TemplateCacheError> {
+ self.transaction
+ .cancel_insert_or_update()
+ .map_err(|e| backend_error(format!("cancelling cache reservation failed: {e:?}")))
+ }
+}
+
+#[async_trait::async_trait(?Send)]
+impl PlatformTemplateCache for FastlyTemplateCache {
+ async fn lookup_or_reserve(
+ &self,
+ key: &TemplateCacheKey,
+ ) -> Result {
+ let transaction = Transaction::lookup(CacheKey::from(key.to_cache_key().into_bytes()))
+ .execute()
+ .map_err(|e| backend_error(format!("transactional lookup failed: {e:?}")))?;
+
+ if transaction.must_insert_or_update() {
+ return Ok(TemplateCacheLookup::Reserved(
+ TemplateCacheReservation::new(Box::new(FastlyTemplateReservation {
+ transaction,
+ surrogate_keys: key.surrogate_keys(),
+ })),
+ ));
+ }
+
+ let found = transaction.found().ok_or_else(|| {
+ backend_error("transaction returned neither a hit nor an insert obligation")
+ })?;
+ Ok(match read_found(&found, key) {
+ Ok(entry) => TemplateCacheLookup::Hit(entry),
+ Err(ReadFoundError::Invalid(miss)) => TemplateCacheLookup::Invalid(miss),
+ Err(ReadFoundError::Backend(error)) => return Err(error),
+ })
+ }
+
+ async fn get(&self, key: &TemplateCacheKey) -> Result {
+ let cache_key = CacheKey::from(key.to_cache_key().into_bytes());
+
+ // A plain lookup, not a transaction: a read that does not intend to insert
+ // must not take an insert obligation it will never discharge, which would
+ // block every other client waiting on the same key until they time out.
+ let found = fastly::cache::core::lookup(cache_key)
+ .execute()
+ .map_err(|_| TemplateCacheMiss::NotFound)?
+ .ok_or(TemplateCacheMiss::NotFound)?;
+
+ read_found(&found, key).map_err(|error| match error {
+ ReadFoundError::Invalid(miss) => miss,
+ ReadFoundError::Backend(error) => {
+ // This legacy method cannot expose a backend error. Production uses
+ // `lookup_or_reserve`, which preserves it for bounded diagnostics.
+ log::warn!("c2_template_cache legacy read failed: {error}");
+ TemplateCacheMiss::NotFound
+ }
+ })
+ }
+
+ async fn put(
+ &self,
+ key: &TemplateCacheKey,
+ metadata: &TemplateMetadata,
+ body: Vec,
+ max_age: Duration,
+ ) -> Result<(), TemplateCacheError> {
+ if metadata.body_len != body.len() as u64 {
+ return Err(backend_error(format!(
+ "metadata body_len {} does not match the {} bytes supplied; storing \
+ this would make every read a truncation miss",
+ metadata.body_len,
+ body.len()
+ )));
+ }
+
+ let cache_key = CacheKey::from(key.to_cache_key().into_bytes());
+
+ // Transactional insert so a cold key under load transforms once rather than
+ // once per concurrent request.
+ let tx = Transaction::lookup(cache_key)
+ .execute()
+ .map_err(|e| backend_error(format!("transactional lookup failed: {e:?}")))?;
+
+ // Order matters. A STALE entry sets *both* `found()` and
+ // `must_insert_or_update()`. Testing `found()` first would return early on
+ // the stale bytes and never discharge the obligation, leaving every
+ // concurrent waiter blocked until timeout.
+ if !tx.must_insert_or_update() {
+ // Someone else already inserted a fresh entry. Nothing to do, and
+ // nothing to discharge.
+ return Ok(());
+ }
+
+ // `Transaction::insert` takes `self`, so from here there is no handle left to
+ // cancel the insert with. A write that fails part-way therefore cannot be
+ // retracted — which is why `TemplateMetadata::body_len` exists and `get`
+ // checks it. The metadata is written before the body, so a truncated entry
+ // still carries the length it was supposed to have.
+ let surrogate_keys = key.surrogate_keys();
+ let mut writer = tx
+ .insert(max_age)
+ .surrogate_keys(surrogate_keys.iter().map(String::as_str))
+ .user_metadata(metadata.encode().into())
+ .execute()
+ .map_err(|e| backend_error(format!("cache insert failed: {e:?}")))?;
+
+ if let Err(e) = writer.write_all(&body) {
+ // Deliberately not calling `finish()`. An unfinished entry has no known
+ // length, and even if it is observable, `get`'s length check rejects it.
+ return Err(backend_error(format!("writing template body failed: {e}")));
+ }
+
+ // Required. Without it the object never completes and its length stays
+ // unknown, so readers see a partial or absent entry.
+ writer
+ .finish()
+ .map_err(|e| backend_error(format!("finishing the cached template failed: {e}")))?;
+
+ Ok(())
+ }
+
+ async fn purge_url(&self, key: &TemplateCacheKey) -> Result<(), TemplateCacheError> {
+ fastly::http::purge::purge_surrogate_key(&key.url_surrogate_key())
+ .map_err(|e| backend_error(format!("purging invalid template failed: {e:?}")))
+ }
+
+ async fn purge_all(&self) -> Result<(), TemplateCacheError> {
+ fastly::http::purge::purge_surrogate_key(PURGE_ALL_SURROGATE_KEY)
+ .map_err(|e| backend_error(format!("purging templates failed: {e:?}")))
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use trusted_server_core::creative_opportunities::AssemblyMode;
+ use trusted_server_core::platform::TEMPLATE_SCHEMA_VERSION;
+
+ /// Distinct per test, so tests sharing the process cache cannot collide.
+ fn key(url: &str) -> TemplateCacheKey {
+ TemplateCacheKey {
+ url: url.to_string(),
+ request_host: "example.com".to_string(),
+ request_scheme: "https".to_string(),
+ origin_identity: "https://origin.example.com\0origin.example.com".to_string(),
+ assembly_mode: AssemblyMode::Esi,
+ vary_values: vec![trusted_server_core::platform::VaryHeaderValues {
+ name: "rsc".to_string(),
+ values: Some(vec![b"1".to_vec()]),
+ }],
+ template_fingerprint: "fp".to_string(),
+ schema_version: TEMPLATE_SCHEMA_VERSION,
+ }
+ }
+
+ fn metadata_for(body: &[u8]) -> TemplateMetadata {
+ TemplateMetadata {
+ policy_headers: Vec::new(),
+ content_encoding: "identity".to_string(),
+ content_type: "text/html; charset=utf-8".to_string(),
+ schema_version: TEMPLATE_SCHEMA_VERSION,
+ body_len: body.len() as u64,
+ }
+ }
+
+ /// The trait is `async_trait(?Send)` and this crate has no async test runtime,
+ /// so drive the futures directly.
+ fn run(fut: impl core::future::Future