diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
index fb0233b03..58e6a4ac7 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 template cache ESI local harness
+ run: BID_DELAY=3 ./scripts/template-cache-local-test.sh esi
+
+ - name: Run inline control harness
+ run: BID_DELAY=3 ./scripts/template-cache-local-test.sh inline
+
test-axum:
name: cargo test (axum native)
runs-on: ubuntu-latest
@@ -214,9 +230,14 @@ jobs:
run: |
cargo clippy --manifest-path crates/trusted-server-cli/Cargo.toml --target "$(rustc -vV | sed -n 's/host: //p')" --all-targets -- -D warnings
+ - name: Set up Chrome for browser fixture tests
+ id: setup-chrome
+ uses: browser-actions/setup-chrome@v1
+
- name: cargo test
- run: |
- cargo test --manifest-path crates/trusted-server-cli/Cargo.toml --target "$(rustc -vV | sed -n 's/host: //p')"
+ run: ./scripts/test-cli.sh
+ env:
+ CHROME: ${{ steps.setup-chrome.outputs.chrome-path }}
test-typescript:
name: vitest
diff --git a/.gitignore b/.gitignore
index 8ff935162..24b9e06aa 100644
--- a/.gitignore
+++ b/.gitignore
@@ -37,6 +37,9 @@ src/*.html
.specstory
.vscode
+# Agent implementation worktrees
+/.worktrees/
+
# Claude Code — ignore all, then whitelist shared config
.claude/*
!.claude/settings.json
diff --git a/CHANGELOG.md b/CHANGELOG.md
index c00487769..6fee6e5c5 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -9,8 +9,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Changed
+- **Breaking** — Admin Basic-auth coverage now includes `GET /_ts/admin/ec`, `GET /_ts/admin/ec/{id}`, and `GET /_ts/admin/eids`. Existing configurations whose `[[handlers]]` patterns protect only the key-management endpoints now fail startup; broaden coverage before deploying, preferably with a namespace-boundary pattern such as `^/_ts/admin(?:/|$)`. Coverage of the dynamic `/_ts/admin/ec/{id}` route is no longer inferred from ID-shaped samples: the router accepts any segment after `/_ts/admin/ec/` and Basic Auth runs on the raw path before routing, so patterns anchored to the EC ID grammar (for example `^/_ts/admin/ec/[a-f0-9]{64}[.][A-Za-z0-9]{6}$`) are rejected in favor of a prefix-level matcher. Placeholder and well-known weak handler passwords (`changeme`, `password`, `admin`, `replace-with-…`) now fail startup on every handler rather than only on handlers inferred to cover an admin endpoint, because first-match-wins handler selection lets a narrow handler shadow the admin namespace.
+- Publisher HTML uses `Cache-Control: max-age=60` for successful GET document responses when server-side ad templates are structurally inactive, while preserving origin `private`/`no-store` policies and request-scoped bot, prefetch, or consent-denied responses. Cookie-bearing responses are finalized as `private, max-age=0`; CDN-specific cache headers remain unchanged for inactive templates. Set `[creative_opportunities].enabled = false` to disable publisher HTML and SPA template delivery without disabling direct `POST /auction` callers; an absent configuration, an unmatched slot, or a disabled auction also make the stack structurally inactive. An explicit `enabled = false` is not compatible with older binaries: restore the default, re-push and finalize the config before rolling back.
- **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.
@@ -21,6 +24,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Security
+- Reserved the complete admin namespace at the publisher-fallback boundary. Percent-encoded separators (`/_ts/admin%2Fec`, `%2f`, and double-encoded forms) matched the `^/_ts/admin` Basic-auth handler but escaped the literal-slash namespace check, so an authenticated request fell through to publisher fallback and forwarded its `Authorization` header and body to the publisher origin. The reservation now spans the whole `/_ts/admin` prefix plus the retired `/admin/keys` aliases — including trailing, descendant, and encoded-separator forms — evaluated on both the raw and percent-decoded path, and applies to every adapter.
- Validate synthetic ID format on inbound values from the `x-synthetic-id` header and `synthetic_id` cookie; values that do not match the expected format (`64-hex-hmac.6-alphanumeric-suffix`) are discarded and a fresh ID is generated rather than forwarded to response headers, cookies, or third-party APIs
### Fixed
diff --git a/Cargo.lock b/Cargo.lock
index cb8f40c68..b8edbb732 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#5f3d648c3c6c38fc6e6b22b5c65c66177363aad8"
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#5f3d648c3c6c38fc6e6b22b5c65c66177363aad8"
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#5f3d648c3c6c38fc6e6b22b5c65c66177363aad8"
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#5f3d648c3c6c38fc6e6b22b5c65c66177363aad8"
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#5f3d648c3c6c38fc6e6b22b5c65c66177363aad8"
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#5f3d648c3c6c38fc6e6b22b5c65c66177363aad8"
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#5f3d648c3c6c38fc6e6b22b5c65c66177363aad8"
dependencies = [
"anyhow",
"async-compression",
@@ -1569,14 +1598,14 @@ 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#5f3d648c3c6c38fc6e6b22b5c65c66177363aad8"
dependencies = [
"log",
"proc-macro2",
"quote",
"serde",
"serde_json",
- "syn 2.0.118",
+ "syn 3.0.3",
"toml",
"validator",
]
@@ -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",
]
@@ -4769,6 +4857,17 @@ dependencies = [
"unicode-ident",
]
+[[package]]
+name = "syn"
+version = "3.0.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "unicode-ident",
+]
+
[[package]]
name = "sync_wrapper"
version = "1.0.2"
@@ -5074,6 +5173,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 +5385,11 @@ dependencies = [
"base64",
"bytes",
"chrono",
+ "derive_more",
"edgezero-adapter-fastly",
"edgezero-core",
"error-stack",
+ "esi",
"fastly",
"fern",
"futures",
@@ -5322,8 +5436,11 @@ dependencies = [
"derive_more",
"directories",
"edgezero-cli",
+ "edgezero-core",
"error-stack",
"futures",
+ "glob",
+ "http",
"http-body-util",
"hyper",
"hyper-util",
@@ -5335,12 +5452,15 @@ dependencies = [
"scraper",
"serde",
"serde_json",
+ "similar",
+ "temp-env",
"tempfile",
"time",
"tokio",
"tokio-rustls",
"toml",
- "toml_edit",
+ "toml_edit 0.23.10+spec-1.0.0",
+ "tracing",
"trusted-server-core",
"url",
"webpki-roots",
@@ -5373,6 +5493,7 @@ dependencies = [
"hex",
"hmac",
"http",
+ "httpdate",
"iab_gpp",
"jose-jwk",
"log",
@@ -5896,7 +6017,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 +6446,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..1d9281b47 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -54,14 +54,15 @@ 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"
+esi = { git = "https://github.com/stackpop/esi.git", rev = "4c53feab4d22ad9a84641b4c46f3f63bc6d197e2" }
fastly = "0.12"
fern = "0.7.1"
flate2 = "1.1"
@@ -71,6 +72,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"
@@ -94,6 +96,7 @@ scraper = "0.24.0"
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0.149"
sha2 = "0.10.9"
+similar = "2.7"
simple_logger = "5"
spin-sdk = { version = "~6.0", default-features = false, features = ["http", "key-value", "variables"] }
subtle = "2.6"
@@ -106,6 +109,7 @@ tokio-rustls = "0.26"
toml = "1.1"
toml_edit = "0.23.10"
tower = "0.4"
+tracing = "0.1"
trusted-server-core = { path = "crates/trusted-server-core" }
trusted-server-js = { path = "crates/trusted-server-js" }
trusted-server-openrtb = { path = "crates/trusted-server-openrtb" }
diff --git a/README.md b/README.md
index b87fe61ad..81794720c 100644
--- a/README.md
+++ b/README.md
@@ -41,7 +41,7 @@ ts config init
ts config validate
# Audit a public page with Chrome/Chromium to bootstrap a draft config
-ts audit https://publisher.example
+ts audit generate https://publisher.example
# Run tests (Fastly/WASM crates — requires Viceroy)
cargo test-fastly
diff --git a/crates/trusted-server-adapter-axum/src/app.rs b/crates/trusted-server-adapter-axum/src/app.rs
index 1bed830ac..4b71d07ce 100644
--- a/crates/trusted-server-adapter-axum/src/app.rs
+++ b/crates/trusted-server-adapter-axum/src/app.rs
@@ -11,7 +11,12 @@ use edgezero_core::router::RouterService;
use error_stack::Report;
use trusted_server_core::auction::endpoints::handle_auction;
use trusted_server_core::auction::{AuctionOrchestrator, build_orchestrator};
+use trusted_server_core::cache_policy::EdgeCacheHeader;
use trusted_server_core::ec::EcContext;
+use trusted_server_core::ec::admin::{
+ admin_ec_lookup_not_supported, deny_admin_diagnostic_fallback, handle_admin_eids_lookup,
+};
+use trusted_server_core::ec::registry::PartnerRegistry;
use trusted_server_core::error::{IntoHttpResponse as _, TrustedServerError};
use trusted_server_core::integrations::{IntegrationRegistry, ProxyDispatchInput};
use trusted_server_core::proxy::{
@@ -178,12 +183,16 @@ async fn dispatch_fallback(
services: &RuntimeServices,
mut req: Request,
) -> Result> {
+ if let Some(response) = deny_admin_diagnostic_fallback(&req) {
+ return Ok(response);
+ }
+
trusted_server_core::integrations::gpt_diagnostics::prepare_request(&state.settings, &mut req)?;
let path = req.uri().path().to_string();
let method = req.method().clone();
if method == Method::GET && path.starts_with("/static/tsjs=") {
- return handle_tsjs_dynamic(&req, &state.registry);
+ return handle_tsjs_dynamic(&req, &state.registry, EdgeCacheHeader::SMaxageFallback);
}
if state.registry.has_route(&method, &path) {
@@ -222,6 +231,7 @@ async fn dispatch_fallback(
&mut ec_context,
auction,
req,
+ EdgeCacheHeader::SMaxageFallback,
)
.await?;
// Async finalize so the dispatched auction is collected and its bids are
@@ -259,6 +269,8 @@ enum NamedRouteHandler {
TrustedServerDiscovery,
VerifySignature,
AdminNotSupported,
+ AdminEcNotSupported,
+ AdminEidsLookup,
/// Legacy `/admin/keys/*` aliases — denied locally with 404 so they never
/// reach the publisher fallback (which would leak admin credentials).
LegacyAdminDenied,
@@ -286,7 +298,7 @@ const LEGACY_ADMIN_DENY_METHODS: &[Method] = &[
Method::DELETE,
];
-fn named_routes() -> [NamedRoute; 13] {
+fn named_routes() -> [NamedRoute; 16] {
[
NamedRoute {
path: "/.well-known/trusted-server.json",
@@ -311,6 +323,26 @@ fn named_routes() -> [NamedRoute; 13] {
primary_methods: &[Method::POST],
handler: NamedRouteHandler::AdminNotSupported,
},
+ // Admin EC lookup routes. Registered explicitly (like the key routes
+ // above) so they never fall through to the publisher fallback, and
+ // they match `Settings::ADMIN_ENDPOINTS` for auth coverage.
+ NamedRoute {
+ path: "/_ts/admin/ec",
+ primary_methods: &[Method::GET],
+ handler: NamedRouteHandler::AdminEcNotSupported,
+ },
+ NamedRoute {
+ path: "/_ts/admin/ec/{id}",
+ primary_methods: &[Method::GET],
+ handler: NamedRouteHandler::AdminEcNotSupported,
+ },
+ // Admin EIDs echo: pure request inspection (no KV), so the dev
+ // server serves the real handler.
+ NamedRoute {
+ path: "/_ts/admin/eids",
+ primary_methods: &[Method::GET],
+ handler: NamedRouteHandler::AdminEidsLookup,
+ },
// The legacy non-`/_ts` aliases (`/admin/keys/*`) are denied locally with
// a 404, matching the Fastly and Cloudflare adapters: the production
// basic-auth handler regex `^/_ts/admin` does not match them, and letting
@@ -407,6 +439,16 @@ fn named_route_handler(
);
Ok(resp)
}
+ NamedRouteHandler::AdminEcNotSupported => {
+ // The EC identity graph is Fastly KV backed; the Axum
+ // dev server has no store to read.
+ Ok(admin_ec_lookup_not_supported())
+ }
+ NamedRouteHandler::AdminEidsLookup => {
+ let partner_registry =
+ PartnerRegistry::from_config(&state.settings.ec.partners)?;
+ handle_admin_eids_lookup(&partner_registry, &req)
+ }
NamedRouteHandler::LegacyAdminDenied => Ok(legacy_admin_alias_denied()),
NamedRouteHandler::Auction => {
// Build the geo-aware EC context so the auction consent
diff --git a/crates/trusted-server-adapter-axum/tests/routes.rs b/crates/trusted-server-adapter-axum/tests/routes.rs
index 03caa3d11..85f567a53 100644
--- a/crates/trusted-server-adapter-axum/tests/routes.rs
+++ b/crates/trusted-server-adapter-axum/tests/routes.rs
@@ -74,6 +74,9 @@ fn all_explicit_routes_are_registered() {
("POST", "/verify-signature"),
("POST", "/_ts/admin/keys/rotate"),
("POST", "/_ts/admin/keys/deactivate"),
+ ("GET", "/_ts/admin/ec"),
+ ("GET", "/_ts/admin/ec/{id}"),
+ ("GET", "/_ts/admin/eids"),
("POST", "/admin/keys/rotate"),
("POST", "/admin/keys/deactivate"),
("POST", "/auction"),
@@ -208,6 +211,42 @@ async fn tsjs_route_prefix_is_handled_not_5xx() {
);
}
+#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
+async fn tsjs_route_matching_hash_uses_s_maxage_fallback() {
+ let mut svc = make_service();
+ let src = trusted_server_core::tsjs::tsjs_script_src(&["creative"]);
+ let req = Request::builder()
+ .method("GET")
+ .uri(src)
+ .body(AxumBody::empty())
+ .expect("should build request");
+
+ let resp = svc
+ .ready()
+ .await
+ .expect("should be ready")
+ .call(req)
+ .await
+ .expect("should respond");
+
+ assert_eq!(
+ resp.status().as_u16(),
+ 200,
+ "matching TSJS hash should serve OK"
+ );
+ assert_eq!(
+ resp.headers()
+ .get("cache-control")
+ .and_then(|value| value.to_str().ok()),
+ Some("public, max-age=31536000, s-maxage=31536000, immutable"),
+ "Axum adapter should render the portable s-maxage fallback"
+ );
+ assert!(
+ resp.headers().get("surrogate-control").is_none(),
+ "s-maxage fallback must not emit Fastly Surrogate-Control"
+ );
+}
+
// ---------------------------------------------------------------------------
// Middleware tests
// ---------------------------------------------------------------------------
@@ -267,6 +306,189 @@ async fn admin_route_without_credentials_returns_401() {
);
}
+#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
+async fn authenticated_admin_ec_routes_return_501() {
+ // The EC identity graph is Fastly KV backed, so the Axum dev server
+ // answers the admin EC lookup routes locally with 501 instead of letting
+ // them fall through to the publisher fallback.
+ let sample_ec_id = format!("{}.abc123", "a".repeat(64));
+ for path in [
+ "/_ts/admin/ec".to_owned(),
+ format!("/_ts/admin/ec/{sample_ec_id}"),
+ ] {
+ let mut svc = make_service();
+ let req = Request::builder()
+ .method("GET")
+ .uri(&path)
+ .header("authorization", "Basic YWRtaW46YWRtaW4tcGFzcw==")
+ .body(AxumBody::empty())
+ .expect("should build request");
+ let resp = svc
+ .ready()
+ .await
+ .expect("should be ready")
+ .call(req)
+ .await
+ .expect("should respond");
+ assert_eq!(
+ resp.status().as_u16(),
+ 501,
+ "{path} should report that Axum EC lookup is unsupported"
+ );
+ assert_eq!(
+ resp.headers()
+ .get("content-type")
+ .and_then(|v| v.to_str().ok()),
+ Some("application/json")
+ );
+ assert_eq!(
+ resp.headers()
+ .get("cache-control")
+ .and_then(|v| v.to_str().ok()),
+ Some("no-store")
+ );
+ }
+}
+
+#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
+async fn admin_ec_route_without_credentials_returns_401() {
+ let mut svc = make_service();
+ let req = Request::builder()
+ .method("GET")
+ .uri("/_ts/admin/ec")
+ .body(AxumBody::empty())
+ .expect("should build unauthenticated admin EC request");
+ let resp = svc
+ .ready()
+ .await
+ .expect("should be ready")
+ .call(req)
+ .await
+ .expect("should respond");
+
+ assert_eq!(resp.status().as_u16(), 401);
+ assert!(
+ resp.headers().contains_key("www-authenticate"),
+ "admin EC 401 should include the Basic authentication challenge"
+ );
+}
+
+#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
+async fn authenticated_admin_eids_route_returns_200() {
+ // The EIDs echo is pure request inspection (no KV), so the dev server
+ // serves the real handler.
+ let mut svc = make_service();
+ let req = Request::builder()
+ .method("GET")
+ .uri("/_ts/admin/eids")
+ .header("authorization", "Basic YWRtaW46YWRtaW4tcGFzcw==")
+ .body(AxumBody::empty())
+ .expect("should build request");
+ let resp = svc
+ .ready()
+ .await
+ .expect("should be ready")
+ .call(req)
+ .await
+ .expect("should respond");
+ assert_eq!(
+ resp.status().as_u16(),
+ 200,
+ "/_ts/admin/eids should serve the real EIDs echo handler"
+ );
+}
+
+#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
+async fn authenticated_admin_diagnostic_fallback_is_denied_locally() {
+ let ec_id = format!("{}.abc123", "a".repeat(64));
+ let valid_paths = [
+ "/_ts/admin/ec".to_owned(),
+ format!("/_ts/admin/ec/{ec_id}"),
+ "/_ts/admin/eids".to_owned(),
+ ];
+
+ for path in valid_paths {
+ for method in ["POST", "HEAD", "OPTIONS", "PUT", "PATCH", "DELETE"] {
+ let request = Request::builder()
+ .method(method)
+ .uri(&path)
+ .header("authorization", "Basic YWRtaW46YWRtaW4tcGFzcw==")
+ .body(AxumBody::from("sensitive-admin-body"))
+ .expect("should build authenticated admin request");
+ let response = make_service()
+ .ready()
+ .await
+ .expect("should be ready")
+ .call(request)
+ .await
+ .expect("should respond");
+
+ assert_eq!(response.status().as_u16(), 405);
+ assert_eq!(
+ response
+ .headers()
+ .get("allow")
+ .and_then(|v| v.to_str().ok()),
+ Some("GET")
+ );
+ assert_eq!(
+ response
+ .headers()
+ .get("cache-control")
+ .and_then(|v| v.to_str().ok()),
+ Some("no-store")
+ );
+ }
+ }
+
+ for path in [
+ "/_ts/admin/ec/".to_owned(),
+ format!("/_ts/admin/ec/{ec_id}/extra"),
+ "/_ts/admin/eids/".to_owned(),
+ "/_ts/admin/eids/extra".to_owned(),
+ "/_ts/admin/eids.json".to_owned(),
+ "/_ts/admin/ec;foo".to_owned(),
+ format!("/_ts/admin/ec%2F{ec_id}"),
+ // Percent-encoded separators match the `^/_ts/admin` basic-auth
+ // handler but not a literal-slash namespace check, so they must be
+ // reserved before publisher fallback forwards credentials upstream.
+ "/_ts/admin%2Fec".to_owned(),
+ "/_ts/admin%2fec".to_owned(),
+ // Retired non-`/_ts` alias namespace: only the two exact paths are
+ // routed to a local deny, so descendants and encoded separators must
+ // be reserved at the shared fallback boundary.
+ "/admin/keys".to_owned(),
+ "/admin/keys/rotate/extra".to_owned(),
+ "/admin/keys%2Frotate".to_owned(),
+ "/admin%2fkeys/rotate".to_owned(),
+ ] {
+ for method in ["GET", "POST"] {
+ let request = Request::builder()
+ .method(method)
+ .uri(&path)
+ .header("authorization", "Basic YWRtaW46YWRtaW4tcGFzcw==")
+ .body(AxumBody::from("sensitive-admin-body"))
+ .expect("should build malformed admin request");
+ let response = make_service()
+ .ready()
+ .await
+ .expect("should be ready")
+ .call(request)
+ .await
+ .expect("should respond");
+
+ assert_eq!(response.status().as_u16(), 404);
+ assert_eq!(
+ response
+ .headers()
+ .get("cache-control")
+ .and_then(|v| v.to_str().ok()),
+ Some("no-store")
+ );
+ }
+ }
+}
+
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn legacy_admin_aliases_denied_locally_not_proxied_to_publisher() {
// Regression for the credential-leak finding: the production basic-auth regex
diff --git a/crates/trusted-server-adapter-cloudflare/src/app.rs b/crates/trusted-server-adapter-cloudflare/src/app.rs
index 644676fc5..86ac86987 100644
--- a/crates/trusted-server-adapter-cloudflare/src/app.rs
+++ b/crates/trusted-server-adapter-cloudflare/src/app.rs
@@ -10,9 +10,15 @@ use edgezero_core::router::RouterService;
use error_stack::Report;
use trusted_server_core::auction::endpoints::handle_auction;
use trusted_server_core::auction::{AuctionOrchestrator, build_orchestrator};
+use trusted_server_core::cache_policy::EdgeCacheHeader;
#[cfg(target_arch = "wasm32")]
use trusted_server_core::config_payload::settings_from_config_blob;
use trusted_server_core::ec::EcContext;
+use trusted_server_core::ec::admin::{
+ admin_ec_lookup_not_supported as core_admin_ec_lookup_not_supported,
+ deny_admin_diagnostic_fallback, handle_admin_eids_lookup,
+};
+use trusted_server_core::ec::registry::PartnerRegistry;
use trusted_server_core::error::{IntoHttpResponse as _, TrustedServerError};
use trusted_server_core::integrations::{IntegrationRegistry, ProxyDispatchInput};
use trusted_server_core::platform::RuntimeServices;
@@ -249,6 +255,10 @@ fn admin_key_management_not_supported() -> Response {
response
}
+fn admin_ec_lookup_not_supported() -> Response {
+ core_admin_ec_lookup_not_supported()
+}
+
/// Builds the local `404 Not Found` returned for legacy `/admin/keys/*`
/// aliases on the Cloudflare adapter.
///
@@ -368,6 +378,9 @@ fn build_router(state: &Arc) -> RouterService {
) -> Result {
let services = build_per_request_services(&ctx);
let mut req = ctx.into_request();
+ if let Some(response) = deny_admin_diagnostic_fallback(&req) {
+ return Ok(response);
+ }
if let Err(error) = trusted_server_core::integrations::gpt_diagnostics::prepare_request(
&state.settings,
&mut req,
@@ -380,7 +393,11 @@ fn build_router(state: &Arc) -> RouterService {
let allow_tsjs = method == Method::GET;
let result = if allow_tsjs && path.starts_with("/static/tsjs=") {
- handle_tsjs_dynamic(&req, &state.registry)
+ handle_tsjs_dynamic(
+ &req,
+ &state.registry,
+ EdgeCacheHeader::CloudflareCdnCacheControl,
+ )
} else if state.registry.has_route(&method, &path) {
let mut ec_context = EcContext::default();
state
@@ -414,6 +431,7 @@ fn build_router(state: &Arc) -> RouterService {
&mut ec_context,
auction,
req,
+ EdgeCacheHeader::CloudflareCdnCacheControl,
)
.await
{
@@ -474,6 +492,26 @@ fn build_router(state: &Arc) -> RouterService {
.post("/_ts/admin/keys/deactivate", |_ctx: RequestContext| async {
Ok::(admin_key_management_not_supported())
})
+ // Admin EC lookup routes. Registered explicitly (like the key
+ // routes above) so they never fall through to the publisher
+ // fallback, and they match `Settings::ADMIN_ENDPOINTS` for auth
+ // coverage. The EC identity graph is Fastly KV backed, so this
+ // adapter has no store to read.
+ .get("/_ts/admin/ec", |_ctx: RequestContext| async {
+ Ok::(admin_ec_lookup_not_supported())
+ })
+ .get("/_ts/admin/ec/{id}", |_ctx: RequestContext| async {
+ Ok::(admin_ec_lookup_not_supported())
+ })
+ // Admin EIDs echo: pure request inspection (no KV), so this
+ // adapter serves the real handler.
+ .get(
+ "/_ts/admin/eids",
+ make_handler(Arc::clone(&state), |s, _services, req| async move {
+ let partner_registry = PartnerRegistry::from_config(&s.settings.ec.partners)?;
+ handle_admin_eids_lookup(&partner_registry, &req)
+ }),
+ )
.post(
"/auction",
make_handler(Arc::clone(&state), |s, services, req| async move {
diff --git a/crates/trusted-server-adapter-cloudflare/tests/routes.rs b/crates/trusted-server-adapter-cloudflare/tests/routes.rs
index 09e3ed324..68fa48bf3 100644
--- a/crates/trusted-server-adapter-cloudflare/tests/routes.rs
+++ b/crates/trusted-server-adapter-cloudflare/tests/routes.rs
@@ -203,6 +203,43 @@ async fn tsjs_route_is_routed_not_5xx() {
assert!(status < 500, "tsjs route must not 5xx: got {status}");
}
+#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
+async fn tsjs_route_emits_cloudflare_cache_header_for_matching_hash() {
+ let router = test_router();
+ let src = trusted_server_core::tsjs::tsjs_script_src(&["creative"]);
+ let req = request_builder()
+ .method("GET")
+ .uri(src)
+ .body(edgezero_core::body::Body::empty())
+ .expect("should build request");
+
+ let resp = route(router, req).await;
+
+ assert_eq!(
+ resp.status().as_u16(),
+ 200,
+ "matching TSJS hash should serve OK"
+ );
+ assert_eq!(
+ resp.headers()
+ .get("cache-control")
+ .and_then(|value| value.to_str().ok()),
+ Some("public, max-age=31536000, immutable"),
+ "browser cache policy should be immutable for matching TSJS hash"
+ );
+ assert_eq!(
+ resp.headers()
+ .get("cloudflare-cdn-cache-control")
+ .and_then(|value| value.to_str().ok()),
+ Some("max-age=31536000"),
+ "Cloudflare adapter should emit the Cloudflare-specific edge header"
+ );
+ assert!(
+ resp.headers().get("surrogate-control").is_none(),
+ "Cloudflare adapter must not emit Fastly Surrogate-Control"
+ );
+}
+
/// Verify that every expected explicit route is registered in the route table.
///
/// Uses [`RouterService::routes()`] for introspection rather than checking
@@ -215,6 +252,9 @@ fn all_explicit_routes_are_registered() {
("POST", "/verify-signature"),
("POST", "/_ts/admin/keys/rotate"),
("POST", "/_ts/admin/keys/deactivate"),
+ ("GET", "/_ts/admin/ec"),
+ ("GET", "/_ts/admin/ec/{id}"),
+ ("GET", "/_ts/admin/eids"),
("POST", "/auction"),
// SPA re-auction endpoint, plus its deprecated `/__ts/` alias. Both
// paths are spelled out as literals rather than referencing
@@ -275,6 +315,158 @@ async fn authenticated_admin_routes_return_501() {
}
}
+#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
+async fn authenticated_admin_ec_routes_return_501() {
+ // The EC identity graph is Fastly KV backed, so Cloudflare answers the
+ // admin EC lookup routes locally with 501 instead of letting them fall
+ // through to the publisher fallback.
+ let sample_ec_id = format!("{}.abc123", "a".repeat(64));
+ for path in [
+ "/_ts/admin/ec".to_owned(),
+ format!("/_ts/admin/ec/{sample_ec_id}"),
+ ] {
+ let req = request_builder()
+ .method("GET")
+ .uri(&path)
+ .header("authorization", "Basic YWRtaW46YWRtaW4tcGFzcw==")
+ .body(edgezero_core::body::Body::empty())
+ .expect("should build request");
+ let resp = route(test_router(), req).await;
+
+ assert_eq!(
+ resp.status().as_u16(),
+ 501,
+ "{path} should report that Cloudflare EC lookup is unsupported"
+ );
+ assert_eq!(
+ resp.headers()
+ .get("content-type")
+ .and_then(|v| v.to_str().ok()),
+ Some("application/json")
+ );
+ assert_eq!(
+ resp.headers()
+ .get("cache-control")
+ .and_then(|v| v.to_str().ok()),
+ Some("no-store")
+ );
+ }
+}
+
+#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
+async fn admin_ec_route_without_credentials_returns_401() {
+ let req = request_builder()
+ .method("GET")
+ .uri("/_ts/admin/ec")
+ .body(edgezero_core::body::Body::empty())
+ .expect("should build unauthenticated admin EC request");
+ let resp = route(test_router(), req).await;
+
+ assert_eq!(resp.status().as_u16(), 401);
+ assert!(
+ resp.headers().contains_key("www-authenticate"),
+ "admin EC 401 should include the Basic authentication challenge"
+ );
+}
+
+#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
+async fn authenticated_admin_eids_route_returns_200() {
+ // The EIDs echo is pure request inspection (no KV), so this adapter
+ // serves the real handler.
+ let req = request_builder()
+ .method("GET")
+ .uri("/_ts/admin/eids")
+ .header("authorization", "Basic YWRtaW46YWRtaW4tcGFzcw==")
+ .body(edgezero_core::body::Body::empty())
+ .expect("should build request");
+ let resp = route(test_router(), req).await;
+
+ assert_eq!(
+ resp.status().as_u16(),
+ 200,
+ "/_ts/admin/eids should serve the real EIDs echo handler"
+ );
+}
+
+#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
+async fn authenticated_admin_diagnostic_fallback_is_denied_locally() {
+ let ec_id = format!("{}.abc123", "a".repeat(64));
+ let valid_paths = [
+ "/_ts/admin/ec".to_owned(),
+ format!("/_ts/admin/ec/{ec_id}"),
+ "/_ts/admin/eids".to_owned(),
+ ];
+
+ for path in valid_paths {
+ for method in ["POST", "HEAD", "OPTIONS", "PUT", "PATCH", "DELETE"] {
+ let request = request_builder()
+ .method(method)
+ .uri(&path)
+ .header("authorization", "Basic YWRtaW46YWRtaW4tcGFzcw==")
+ .body(edgezero_core::body::Body::from("sensitive-admin-body"))
+ .expect("should build authenticated admin request");
+ let response = route(test_router(), request).await;
+
+ assert_eq!(response.status().as_u16(), 405);
+ assert_eq!(
+ response
+ .headers()
+ .get("allow")
+ .and_then(|v| v.to_str().ok()),
+ Some("GET")
+ );
+ assert_eq!(
+ response
+ .headers()
+ .get("cache-control")
+ .and_then(|v| v.to_str().ok()),
+ Some("no-store")
+ );
+ }
+ }
+
+ for path in [
+ "/_ts/admin/ec/".to_owned(),
+ format!("/_ts/admin/ec/{ec_id}/extra"),
+ "/_ts/admin/eids/".to_owned(),
+ "/_ts/admin/eids/extra".to_owned(),
+ "/_ts/admin/eids.json".to_owned(),
+ "/_ts/admin/ec;foo".to_owned(),
+ format!("/_ts/admin/ec%2F{ec_id}"),
+ // Percent-encoded separators match the `^/_ts/admin` basic-auth
+ // handler but not a literal-slash namespace check, so they must be
+ // reserved before publisher fallback forwards credentials upstream.
+ "/_ts/admin%2Fec".to_owned(),
+ "/_ts/admin%2fec".to_owned(),
+ // Retired non-`/_ts` alias namespace: only the two exact paths are
+ // routed to a local deny, so descendants and encoded separators must
+ // be reserved at the shared fallback boundary.
+ "/admin/keys".to_owned(),
+ "/admin/keys/rotate/extra".to_owned(),
+ "/admin/keys%2Frotate".to_owned(),
+ "/admin%2fkeys/rotate".to_owned(),
+ ] {
+ for method in ["GET", "POST"] {
+ let request = request_builder()
+ .method(method)
+ .uri(&path)
+ .header("authorization", "Basic YWRtaW46YWRtaW4tcGFzcw==")
+ .body(edgezero_core::body::Body::from("sensitive-admin-body"))
+ .expect("should build malformed admin request");
+ let response = route(test_router(), request).await;
+
+ assert_eq!(response.status().as_u16(), 404);
+ assert_eq!(
+ response
+ .headers()
+ .get("cache-control")
+ .and_then(|v| v.to_str().ok()),
+ Some("no-store")
+ );
+ }
+ }
+}
+
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn admin_route_without_credentials_returns_401() {
let router = test_router();
diff --git a/crates/trusted-server-adapter-fastly/Cargo.toml b/crates/trusted-server-adapter-fastly/Cargo.toml
index b6bc0f1a1..47cc609b2 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 = { workspace = true }
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..41e5e65ee 100644
--- a/crates/trusted-server-adapter-fastly/src/app.rs
+++ b/crates/trusted-server-adapter-fastly/src/app.rs
@@ -22,6 +22,9 @@
//! | POST | `/verify-signature` | [`handle_verify_signature`] |
//! | POST | `/_ts/admin/keys/rotate` | [`handle_rotate_key`] |
//! | POST | `/_ts/admin/keys/deactivate` | [`handle_deactivate_key`] |
+//! | GET | `/_ts/admin/ec` | [`handle_admin_ec_lookup`] |
+//! | GET | `/_ts/admin/ec/{id}` | [`handle_admin_ec_lookup`] |
+//! | GET | `/_ts/admin/eids` | [`handle_admin_eids_lookup`] |
//! | POST | `/_ts/api/v1/batch-sync` | [`handle_batch_sync`] |
//! | GET | `/_ts/api/v1/identify` | [`handle_identify`] |
//! | GET | `/_ts/set-tester` | [`handle_set_tester`] |
@@ -49,7 +52,8 @@
//! `route_request` (tracked in issue #495):
//!
//! - [`build_ec_request_state`] runs before every dispatched route (except
-//! batch-sync, which uses Bearer auth) and reproduces the legacy
+//! batch-sync, which uses Bearer auth, and the read-only admin diagnostics)
+//! and reproduces the legacy
//! pre-routing prelude: device signals, bot gate, `ts-eids`/`sharedid`
//! cookie capture, geo lookup, [`EcContext`] creation, and KV-graph gating.
//! - `handle_auction` and integration proxy dispatch receive the same
@@ -97,8 +101,12 @@ use error_stack::Report;
use trusted_server_core::auction::AuctionTelemetrySink;
use trusted_server_core::auction::endpoints::handle_auction;
use trusted_server_core::auction::{AuctionOrchestrator, build_orchestrator};
+use trusted_server_core::cache_policy::EdgeCacheHeader;
use trusted_server_core::constants::{COOKIE_SHAREDID, COOKIE_TS_EIDS};
use trusted_server_core::ec::EcContext;
+use trusted_server_core::ec::admin::{
+ deny_admin_diagnostic_fallback, handle_admin_ec_lookup, handle_admin_eids_lookup,
+};
use trusted_server_core::ec::batch_sync::handle_batch_sync;
use trusted_server_core::ec::consent::ec_consent_withdrawn;
use trusted_server_core::ec::device::DeviceSignals;
@@ -257,6 +265,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))
@@ -525,6 +538,29 @@ async fn execute_named(
return Ok(run_batch_sync(&state, &services, req));
}
+ // These diagnostics are read-only. Running the normal EC lifecycle would
+ // attach finalization state and could ingest request cookies into KV after
+ // the handler returns, violating that contract.
+ if matches!(
+ handler,
+ NamedRouteHandler::AdminEcLookup | NamedRouteHandler::AdminEidsLookup
+ ) {
+ let response = PartnerRegistry::from_config(&state.settings.ec.partners)
+ .and_then(|registry| match handler {
+ NamedRouteHandler::AdminEcLookup => {
+ // Deliberately do not use an EC request-state graph: that
+ // copy is bot-gated, while operators use curl for this
+ // authenticated diagnostic.
+ let kv = crate::maybe_identity_graph(&state.settings);
+ handle_admin_ec_lookup(kv.as_ref(), ®istry, &req)
+ }
+ NamedRouteHandler::AdminEidsLookup => handle_admin_eids_lookup(®istry, &req),
+ _ => unreachable!("admin diagnostics should use early dispatch"),
+ })
+ .unwrap_or_else(|error| http_error(&error));
+ return Ok(response);
+ }
+
if let Err(report) = trusted_server_core::integrations::gpt_diagnostics::prepare_request(
&state.settings,
&mut req,
@@ -574,6 +610,9 @@ async fn run_named_route(
}
NamedRouteHandler::RotateKey => handle_rotate_key(&state.settings, services, req),
NamedRouteHandler::DeactivateKey => handle_deactivate_key(&state.settings, services, req),
+ NamedRouteHandler::AdminEcLookup | NamedRouteHandler::AdminEidsLookup => {
+ unreachable!("admin diagnostics should be handled before EC setup")
+ }
NamedRouteHandler::LegacyAdminDenied => Ok(legacy_admin_alias_denied()),
NamedRouteHandler::BatchSync => {
// Dispatched by execute_named before EC state is built.
@@ -708,6 +747,10 @@ async fn dispatch_fallback(
services: &RuntimeServices,
mut req: Request,
) -> Response {
+ if let Some(response) = deny_admin_diagnostic_fallback(&req) {
+ return response;
+ }
+
let path = req.uri().path().to_string();
let method = req.method().clone();
@@ -735,7 +778,7 @@ async fn dispatch_fallback(
};
let result = if uses_dynamic_tsjs_fallback(&method, &path) {
- handle_tsjs_dynamic(&req, &state.registry)
+ handle_tsjs_dynamic(&req, &state.registry, EdgeCacheHeader::SurrogateControl)
} else if state.registry.has_route(&method, &path) {
// Integration-proxy responses are not bounded by
// publisher.max_buffered_body_bytes. Publisher fallback below uses the
@@ -808,6 +851,7 @@ async fn dispatch_fallback(
&mut ec.ec_context,
auction,
req,
+ EdgeCacheHeader::SurrogateControl,
)
.await
{
@@ -1001,6 +1045,8 @@ enum NamedRouteHandler {
VerifySignature,
RotateKey,
DeactivateKey,
+ AdminEcLookup,
+ AdminEidsLookup,
/// Legacy `/admin/keys/*` aliases — denied locally with 404 so they never
/// reach the publisher fallback (which would leak admin credentials).
LegacyAdminDenied,
@@ -1053,6 +1099,25 @@ const NAMED_ROUTES: &[NamedRoute] = &[
primary_methods: &[Method::POST],
handler: NamedRouteHandler::DeactivateKey,
},
+ // Admin EC lookup: the bare route reads the EC ID from the caller's
+ // `ts-ec` cookie; the parameterized route takes an explicit EC ID.
+ NamedRoute {
+ path: "/_ts/admin/ec",
+ primary_methods: &[Method::GET],
+ handler: NamedRouteHandler::AdminEcLookup,
+ },
+ NamedRoute {
+ path: "/_ts/admin/ec/{id}",
+ primary_methods: &[Method::GET],
+ handler: NamedRouteHandler::AdminEcLookup,
+ },
+ // Admin EIDs echo: decodes the request's ts-eids/sharedId cookies with
+ // an ingestion preview. Pure request inspection — no KV access.
+ NamedRoute {
+ path: "/_ts/admin/eids",
+ primary_methods: &[Method::GET],
+ handler: NamedRouteHandler::AdminEidsLookup,
+ },
// The legacy non-`/_ts` aliases (`/admin/keys/*`) are denied locally with a
// 404 instead of executing key operations: the production basic-auth handler
// regex `^/_ts/admin` does not match them, and letting them fall through to
@@ -1239,12 +1304,16 @@ 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 base64::Engine as _;
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 +1448,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.
@@ -1651,6 +1750,44 @@ mod tests {
}
}
+ #[test]
+ fn admin_ec_lookup_routes_are_registered() {
+ // Both lookup shapes must be explicitly routed to the admin EC
+ // handler: the bare cookie-based route and the parameterized route.
+ // Leaving either unrouted would fall through to the publisher
+ // fallback, forwarding the caller's `Authorization` header to the
+ // origin.
+ for path in ["/_ts/admin/ec", "/_ts/admin/ec/{id}"] {
+ let route = NAMED_ROUTES
+ .iter()
+ .find(|route| route.path == path)
+ .unwrap_or_else(|| panic!("{path} must be a named route"));
+ assert!(
+ matches!(route.handler, NamedRouteHandler::AdminEcLookup),
+ "{path} must map to the admin EC lookup handler"
+ );
+ assert_eq!(
+ route.primary_methods,
+ &[Method::GET],
+ "{path} must have GET as its only primary method"
+ );
+ }
+
+ let eids_route = NAMED_ROUTES
+ .iter()
+ .find(|route| route.path == "/_ts/admin/eids")
+ .expect("should register /_ts/admin/eids as a named route");
+ assert!(
+ matches!(eids_route.handler, NamedRouteHandler::AdminEidsLookup),
+ "/_ts/admin/eids must map to the admin EIDs lookup handler"
+ );
+ assert_eq!(
+ eids_route.primary_methods,
+ &[Method::GET],
+ "/_ts/admin/eids must have GET as its only primary method"
+ );
+ }
+
#[test]
fn page_bids_serves_canonical_path_and_deprecated_alias() {
// The SPA re-auction endpoint lives at the canonical single-underscore
@@ -1744,6 +1881,93 @@ mod tests {
}
}
+ #[test]
+ fn authenticated_admin_diagnostic_fallback_is_denied_locally() {
+ let router = test_router();
+ let ec_id = format!("{}.abc123", "a".repeat(64));
+ let valid_paths = [
+ "/_ts/admin/ec".to_owned(),
+ format!("/_ts/admin/ec/{ec_id}"),
+ "/_ts/admin/eids".to_owned(),
+ ];
+
+ for path in valid_paths {
+ for method in [
+ Method::POST,
+ Method::HEAD,
+ Method::OPTIONS,
+ Method::PUT,
+ Method::PATCH,
+ Method::DELETE,
+ ] {
+ let request = request_builder()
+ .method(method.clone())
+ .uri(format!("https://test-publisher.com{path}"))
+ .header(header::AUTHORIZATION, "Basic YWRtaW46YWRtaW4tcGFzcw==")
+ .body(Body::from("sensitive-admin-body"))
+ .expect("should build authenticated admin request");
+ let response = route(&router, request);
+
+ assert_eq!(response.status(), StatusCode::METHOD_NOT_ALLOWED);
+ assert_eq!(
+ response
+ .headers()
+ .get(header::ALLOW)
+ .and_then(|v| v.to_str().ok()),
+ Some("GET")
+ );
+ assert_eq!(
+ response
+ .headers()
+ .get(header::CACHE_CONTROL)
+ .and_then(|v| v.to_str().ok()),
+ Some("no-store")
+ );
+ }
+ }
+
+ for path in [
+ "/_ts/admin/ec/".to_owned(),
+ format!("/_ts/admin/ec/{ec_id}/extra"),
+ "/_ts/admin/eids/".to_owned(),
+ "/_ts/admin/eids/extra".to_owned(),
+ "/_ts/admin/eids.json".to_owned(),
+ "/_ts/admin/ec;foo".to_owned(),
+ format!("/_ts/admin/ec%2F{ec_id}"),
+ // Percent-encoded separators match the `^/_ts/admin` basic-auth
+ // handler but not a literal-slash namespace check, so they must be
+ // reserved before publisher fallback forwards credentials upstream.
+ "/_ts/admin%2Fec".to_owned(),
+ "/_ts/admin%2fec".to_owned(),
+ // Retired non-`/_ts` alias namespace: only the two exact paths are
+ // routed to a local deny, so descendants and encoded separators must
+ // be reserved at the shared fallback boundary.
+ "/admin/keys".to_owned(),
+ "/admin/keys/rotate/extra".to_owned(),
+ "/admin/keys%2Frotate".to_owned(),
+ "/admin%2fkeys/rotate".to_owned(),
+ ] {
+ for method in [Method::GET, Method::POST] {
+ let request = request_builder()
+ .method(method)
+ .uri(format!("https://test-publisher.com{path}"))
+ .header(header::AUTHORIZATION, "Basic YWRtaW46YWRtaW4tcGFzcw==")
+ .body(Body::from("sensitive-admin-body"))
+ .expect("should build malformed admin request");
+ let response = route(&router, request);
+
+ assert_eq!(response.status(), StatusCode::NOT_FOUND);
+ assert_eq!(
+ response
+ .headers()
+ .get(header::CACHE_CONTROL)
+ .and_then(|v| v.to_str().ok()),
+ Some("no-store")
+ );
+ }
+ }
+ }
+
#[test]
fn dispatch_identify_options_routes_to_cors_preflight() {
// Parity guard: OPTIONS /_ts/api/v1/identify must reach
@@ -2029,6 +2253,103 @@ mod tests {
);
}
+ #[test]
+ fn admin_eids_diagnostic_skips_ec_finalization() {
+ let router = test_router();
+ let ec_id = format!("{}.abc123", "a".repeat(64));
+ let eids = serde_json::json!([{
+ "source": "example.com",
+ "uids": [{ "id": "example-uid", "atype": 1 }]
+ }]);
+ let eids_cookie = base64::engine::general_purpose::STANDARD.encode(eids.to_string());
+ let mut request = request_builder()
+ .method(Method::GET)
+ .uri("https://test-publisher.com/_ts/admin/eids")
+ .header(header::AUTHORIZATION, "Basic YWRtaW46YWRtaW4tcGFzcw==")
+ .header(
+ header::COOKIE,
+ format!("ts-ec={ec_id}; ts-eids={eids_cookie}; sharedId=example-shared-id"),
+ )
+ .body(Body::empty())
+ .expect("should build authenticated EIDs diagnostic request");
+ request.extensions_mut().insert(DeviceSignals::derive(
+ "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 \
+ (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36",
+ Some("t13d1516h2_8daaf6152771_b186095e22b6"),
+ Some("1:65536;2:0;4:6291456;6:262144"),
+ ));
+
+ let response = route(&router, request);
+
+ assert_eq!(response.status(), StatusCode::OK);
+ assert!(
+ response
+ .extensions()
+ .get::()
+ .is_none(),
+ "admin EIDs diagnostics should not attach EC finalization state"
+ );
+ }
+
+ #[test]
+ fn admin_ec_diagnostic_skips_ec_finalization() {
+ let router = test_router();
+ let ec_id = format!("{}.abc123", "a".repeat(64));
+ let eids = serde_json::json!([{
+ "source": "example.com",
+ "uids": [{ "id": "example-uid", "atype": 1 }]
+ }]);
+ let eids_cookie = base64::engine::general_purpose::STANDARD.encode(eids.to_string());
+ let mut request = request_builder()
+ .method(Method::GET)
+ .uri(format!("https://test-publisher.com/_ts/admin/ec/{ec_id}"))
+ .header(header::AUTHORIZATION, "Basic YWRtaW46YWRtaW4tcGFzcw==")
+ .header(
+ header::COOKIE,
+ format!("ts-ec={ec_id}; ts-eids={eids_cookie}; sharedId=example-shared-id"),
+ )
+ .body(Body::empty())
+ .expect("should build authenticated EC diagnostic request");
+ request.extensions_mut().insert(DeviceSignals::derive(
+ "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 \
+ (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36",
+ Some("t13d1516h2_8daaf6152771_b186095e22b6"),
+ Some("1:65536;2:0;4:6291456;6:262144"),
+ ));
+
+ let response = route(&router, request);
+
+ assert_eq!(
+ response.status(),
+ StatusCode::NOT_IMPLEMENTED,
+ "configured admin EC handler should run and report the unavailable test KV graph"
+ );
+ assert!(
+ response
+ .extensions()
+ .get::()
+ .is_none(),
+ "admin EC diagnostics should not attach EC finalization state"
+ );
+ assert!(
+ response.headers().get(header::SET_COOKIE).is_none(),
+ "admin EC diagnostics should not mutate the EC cookie"
+ );
+ }
+
+ #[test]
+ fn admin_ec_route_without_credentials_returns_401() {
+ let router = test_router();
+
+ let response = route(&router, empty_request(Method::GET, "/_ts/admin/ec"));
+
+ assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
+ assert!(
+ response.headers().contains_key(header::WWW_AUTHENTICATE),
+ "admin EC 401 should include the Basic authentication challenge"
+ );
+ }
+
#[test]
fn dispatch_head_on_named_get_route_falls_through_to_publisher_fallback() {
// Regression guard: HEAD /first-party/proxy must reach the publisher
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..f2ad29b68
--- /dev/null
+++ b/crates/trusted-server-adapter-fastly/src/esi_assembly.rs
@@ -0,0 +1,285 @@
+//! Fastly cold-response assembly backed by the repaired `stackpop/esi` parser.
+//!
+//! The shared template cache 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, contains_publisher_esi_directive,
+};
+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 template_with_synthetic_include(template: &[u8]) -> 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_publisher_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..1eac6df4b 100644
--- a/crates/trusted-server-adapter-fastly/src/main.rs
+++ b/crates/trusted-server-adapter-fastly/src/main.rs
@@ -11,6 +11,7 @@ use error_stack::Report;
use fastly::http::Method as FastlyMethod;
use fastly::{Request as FastlyRequest, Response as FastlyResponse};
+use trusted_server_core::cache_policy::EdgeCacheHeader;
use trusted_server_core::ec::device::DeviceSignals;
use trusted_server_core::ec::finalize::ec_finalize_response;
use trusted_server_core::ec::kv::KvIdentityGraph;
@@ -23,17 +24,20 @@ use trusted_server_core::integrations::RequestFilterEffects;
use trusted_server_core::platform::PlatformGeo as _;
use trusted_server_core::platform::RuntimeServices;
use trusted_server_core::proxy::{AssetProxyCachePolicy, stream_asset_body};
+use trusted_server_core::response_privacy::TerminalPrivateResponse;
use trusted_server_core::settings::Settings;
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};
@@ -202,7 +206,7 @@ fn edgezero_main(mut req: FastlyRequest) {
}
if let Some(policy) = asset_cache_policy {
- policy.apply_after_route_finalization(&mut response);
+ policy.apply_after_route_finalization(&mut response, EdgeCacheHeader::SurrogateControl);
}
if let Some(ec_state) = ec_state {
@@ -328,14 +332,8 @@ 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);
+ crate::middleware::enforce_uncacheable_cache_privacy(&mut response);
let (parts, body) = response.into_parts();
@@ -364,6 +362,30 @@ 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())
+ || response
+ .extensions()
+ .get::()
+ .is_some();
+ 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 +507,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 +580,37 @@ 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");
+ response.extensions_mut().insert(TerminalPrivateResponse);
+ 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/middleware.rs b/crates/trusted-server-adapter-fastly/src/middleware.rs
index 2c00ac2ff..83ead48d3 100644
--- a/crates/trusted-server-adapter-fastly/src/middleware.rs
+++ b/crates/trusted-server-adapter-fastly/src/middleware.rs
@@ -235,7 +235,9 @@ pub(crate) fn apply_finalize_headers(
/// entry point (`main.rs`) can re-apply it after
/// [`ec_finalize_response`](trusted_server_core::ec::finalize::ec_finalize_response)
/// writes the EC identity `Set-Cookie`, using the single shared implementation.
-pub(crate) use trusted_server_core::response_privacy::enforce_set_cookie_cache_privacy;
+pub(crate) use trusted_server_core::response_privacy::{
+ enforce_set_cookie_cache_privacy, enforce_uncacheable_cache_privacy,
+};
// ---------------------------------------------------------------------------
// Tests
@@ -429,12 +431,12 @@ mod tests {
}
#[test]
- fn enforce_set_cookie_cache_privacy_downgrades_late_cookie() {
+ fn enforce_set_cookie_cache_privacy_downgrades_inactive_cache_policy() {
// Mirrors the EdgeZero post-ec_finalize guard: a Set-Cookie added after
- // finalize headers ran (origin-public response) must be downgraded.
+ // finalize headers ran must override the inactive template cache policy.
let mut response = response_with_headers(&[
("set-cookie", "ts-ec=abc; Path=/"),
- ("cache-control", "public, max-age=600"),
+ ("cache-control", "max-age=60"),
("surrogate-control", "max-age=600"),
]);
@@ -446,11 +448,11 @@ mod tests {
.get("cache-control")
.and_then(|v| v.to_str().ok()),
Some("private, max-age=0"),
- "should downgrade a late public cookie response to private"
+ "should downgrade an inactive cache policy on a cookie response"
);
assert!(
response.headers().get("surrogate-control").is_none(),
- "should strip surrogate-control from the late cookie response"
+ "should strip surrogate-control from the inactive cookie response"
);
}
@@ -496,6 +498,29 @@ mod tests {
);
}
+ #[test]
+ fn enforce_uncacheable_cache_privacy_handles_late_filter_headers() {
+ let mut response = response_with_headers(&[
+ ("cache-control", "private, max-age=0"),
+ ("surrogate-control", "max-age=600"),
+ ]);
+
+ enforce_uncacheable_cache_privacy(&mut response);
+
+ assert_eq!(
+ response
+ .headers()
+ .get("cache-control")
+ .and_then(|value| value.to_str().ok()),
+ Some("private, max-age=0"),
+ "should preserve the late private directive"
+ );
+ assert!(
+ response.headers().get("surrogate-control").is_none(),
+ "should strip the normalized edge header after late filter effects"
+ );
+ }
+
// ---------------------------------------------------------------------------
// FinalizeResponseMiddleware::handle tests
// ---------------------------------------------------------------------------
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..fe3148cb4
--- /dev/null
+++ b/crates/trusted-server-adapter-fastly/src/template_cache.rs
@@ -0,0 +1,641 @@
+//! Fastly Core Cache backing for the shared transformed-template cache.
+//!
+//! 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,
+ TEMPLATE_CACHE_PURGE_ALL_SURROGATE_KEY, TemplateCacheError, TemplateCacheKey,
+ TemplateCacheLookup, TemplateCacheMiss, TemplateCacheReservation, TemplateEntry,
+ TemplateMetadata,
+};
+
+/// Fastly Core Cache implementation of the shared 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(),
+ }
+}
+
+fn cancel_invalid_reservation(
+ validation_error: TemplateCacheError,
+ cancel: impl FnOnce() -> Result<(), E>,
+) -> Result<(), TemplateCacheError> {
+ match cancel() {
+ Ok(()) => Err(validation_error),
+ Err(error) => Err(backend_error(format!(
+ "{validation_error}; cancelling invalid cache reservation also failed: {error:?}"
+ ))),
+ }
+}
+
+enum ReadFoundError {
+ Invalid(TemplateCacheMiss),
+ Backend(TemplateCacheError),
+}
+
+fn read_cache_body(mut reader: impl std::io::Read) -> Result, ReadFoundError> {
+ let mut body = Vec::new();
+ reader
+ .read_to_end(&mut body)
+ .map_err(|_| ReadFoundError::Invalid(TemplateCacheMiss::Truncated))?;
+ Ok(body)
+}
+
+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 stream = found.to_stream().map_err(|error| {
+ ReadFoundError::Backend(backend_error(format!(
+ "opening cached template body failed: {error:?}"
+ )))
+ })?;
+ let body = read_cache_body(stream)?;
+ 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 {
+ let validation_error = backend_error(format!(
+ "metadata body_len {} does not match the {} bytes supplied",
+ metadata.body_len,
+ body.len()
+ ));
+ return cancel_invalid_reservation(validation_error, || {
+ self.transaction.cancel_insert_or_update()
+ });
+ }
+ let encoded_metadata = match metadata.encode() {
+ Ok(encoded_metadata) => encoded_metadata,
+ Err(error) => {
+ let validation_error =
+ backend_error(format!("encoding template metadata failed: {error}"));
+ return cancel_invalid_reservation(validation_error, || {
+ self.transaction.cancel_insert_or_update()
+ });
+ }
+ };
+
+ 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(encoded_metadata.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!("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 encoded_metadata = metadata.encode().map_err(|error| {
+ backend_error(format!("encoding template metadata failed: {error}"))
+ })?;
+
+ 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))
+ .known_length(body.len() as u64)
+ .user_metadata(encoded_metadata.into())
+ .execute()
+ .map_err(|e| backend_error(format!("cache insert failed: {e:?}")))?;
+
+ if let Err(e) = writer.write_all(&body) {
+ // Deliberately do not call `finish()`. If partial content becomes
+ // observable, fallible reads and the post-read check against the declared
+ // body length reject 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(TEMPLATE_CACHE_PURGE_ALL_SURROGATE_KEY)
+ .map_err(|e| backend_error(format!("purging templates failed: {e:?}")))
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use std::io;
+ use trusted_server_core::creative_opportunities::AssemblyMode;
+ use trusted_server_core::platform::TEMPLATE_SCHEMA_VERSION;
+
+ struct FailingReader {
+ returned_prefix: bool,
+ }
+
+ impl io::Read for FailingReader {
+ fn read(&mut self, buffer: &mut [u8]) -> io::Result {
+ if self.returned_prefix {
+ return Err(io::Error::other("abandoned cache stream"));
+ }
+ self.returned_prefix = true;
+ buffer[..3].copy_from_slice(b"abc");
+ Ok(3)
+ }
+ }
+
+ /// 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
` picks this string literal, because the
+ // document has no structural close at all. Splicing a `` inside the publisher's script and corrupts the document —
+ // and, once stored, every warm reader of it. Only the parser can tell the
+ // difference, so the parser places the marker.
+ const MARKER: &str = "";
+ let source =
+ r#"