From f6cc1d648507851a2568831c6fc59998f3e8e209 Mon Sep 17 00:00:00 2001 From: Christian Date: Thu, 28 May 2026 14:24:08 -0500 Subject: [PATCH 1/3] Simplify JS asset proxy spec --- .../specs/2026-04-01-js-asset-proxy-design.md | 285 ++++++++++++++++++ 1 file changed, 285 insertions(+) create mode 100644 docs/superpowers/specs/2026-04-01-js-asset-proxy-design.md diff --git a/docs/superpowers/specs/2026-04-01-js-asset-proxy-design.md b/docs/superpowers/specs/2026-04-01-js-asset-proxy-design.md new file mode 100644 index 00000000..1e9a4871 --- /dev/null +++ b/docs/superpowers/specs/2026-04-01-js-asset-proxy-design.md @@ -0,0 +1,285 @@ +# JS Asset Proxy — Engineering Spec + +**Date:** 2026-04-01 +**Updated:** 2026-05-28 +**Status:** Proposed + +--- + +## Context + +Publishers often need to load JavaScript from third-party ad tech or measurement vendors. Those scripts are usually referenced directly from vendor-controlled domains, which means the publisher page depends on external script hostnames at runtime. + +The JS Asset Proxy gives Trusted Server a small, explicit way to serve configured third-party JavaScript files from first-party paths. Each proxied asset is declared in `trusted-server.toml`; at request time Trusted Server fetches the configured upstream URL and streams the response back to the browser with controlled response headers. + +This spec intentionally follows existing integration proxy patterns already used in Trusted Server. The implementation should be a focused integration-level proxy, not a new storage, build, or asset management subsystem. + +--- + +## Goals + +- Serve allowlisted third-party JavaScript assets from configured first-party paths. +- Keep configuration in `trusted-server.toml` under the existing `[integrations.*]` configuration model. +- Fetch only explicitly configured upstream URLs. +- Stream upstream JavaScript responses without server-side body transformation. +- Apply predictable downstream cache headers controlled by Trusted Server configuration. +- Reuse the existing integration registry and proxy request infrastructure. + +--- + +## Configuration + +Add a new integration configuration block: + +```toml +[integrations.js_asset_proxy] +enabled = false +cache_ttl_seconds = 3600 + +[[integrations.js_asset_proxy.assets]] +id = "vendor-loader" +path = "/assets/vendor-loader.js" +origin_url = "https://js.vendor.example.com/loader.js" + +[[integrations.js_asset_proxy.assets]] +id = "measurement-sdk" +path = "/assets/measurement-sdk.js" +origin_url = "https://cdn.vendor.example.com/sdk/measurement.js" +cache_ttl_seconds = 900 +``` + +### Fields + +| Field | Required | Description | +| ---------------------------- | -------: | ---------------------------------------------------------------- | +| `enabled` | Yes | Enables or disables the integration. | +| `cache_ttl_seconds` | No | Default downstream cache TTL for all assets. Defaults to `3600`. | +| `assets` | Yes | List of JavaScript assets the proxy may serve. | +| `assets[].id` | Yes | Stable identifier for logs, tests, and response diagnostics. | +| `assets[].path` | Yes | Exact first-party request path handled by Trusted Server. | +| `assets[].origin_url` | Yes | Exact upstream JavaScript URL to fetch. | +| `assets[].cache_ttl_seconds` | No | Per-asset downstream cache TTL override. | + +### Validation + +Configuration validation must reject: + +- enabled integration with malformed configured assets; +- empty `assets` when the integration is enabled; +- duplicate asset IDs; +- duplicate asset paths; +- asset paths that do not start with `/`; +- asset paths containing `*`; +- asset paths containing `..` path segments; +- `origin_url` values without an `https://` scheme; +- `origin_url` values with fragments; +- `cache_ttl_seconds = 0`. + +The implementation may use stricter validation if it keeps the configuration contract simple and documented. + +--- + +## Routing + +The integration registers one exact `GET` route per configured asset path using `IntegrationProxy::routes()`. + +Example registration from the configuration above: + +| Method | Path | Asset ID | Upstream URL | +| ------ | ---------------------------- | ----------------- | --------------------------------------------------- | +| `GET` | `/assets/vendor-loader.js` | `vendor-loader` | `https://js.vendor.example.com/loader.js` | +| `GET` | `/assets/measurement-sdk.js` | `measurement-sdk` | `https://cdn.vendor.example.com/sdk/measurement.js` | + +Only exact configured paths are handled. Paths not registered by the integration continue through the existing request dispatch behavior. + +The integration should rely on the existing integration registry duplicate-route checks so that an asset path cannot silently shadow another integration endpoint. + +--- + +## Request Flow + +For a matching request: + +1. Identify the configured asset by exact request path. +2. Build an upstream `GET` request to the asset's configured `origin_url`. +3. Use the existing proxy request infrastructure with streaming passthrough enabled. +4. Do not append EC IDs or any other per-user identifiers to the upstream URL. +5. Do not perform server-side JavaScript rewriting. +6. Finalize the response with the header policy below. + +### Proxy request configuration + +The JS asset proxy should use the same shape as existing script proxy integrations: + +```rust +let mut config = ProxyRequestConfig::new(origin_url) + .with_streaming() + .without_forward_headers(); +config.follow_redirects = false; +config.forward_ec_id = false; +``` + +The integration may forward this small request header allowlist from the browser request to the upstream request: + +- `Accept` +- `Accept-Language` +- `Accept-Encoding` + +It must set a fixed `User-Agent` such as `TrustedServer/1.0`. + +TLS verification follows the existing Trusted Server proxy backend policy used by `proxy_request()`. + +--- + +## Response Behavior + +### Successful upstream response + +For upstream `2xx` responses, Trusted Server streams the upstream body to the browser and constructs a response with only the headers needed for JavaScript delivery and diagnostics. + +Preserve these upstream response headers when present: + +- `Content-Type` +- `Content-Encoding` +- `ETag` +- `Last-Modified` +- `Vary` + +Set or override these response headers: + +```http +Cache-Control: public, max-age= +X-TS-JS-Asset-Proxy: true +X-TS-JS-Asset-ID: +``` + +If the response includes `Content-Encoding`, ensure `Vary` includes `Accept-Encoding` unless the upstream response uses `Vary: *`. + +Do not forward upstream `Set-Cookie` headers. + +### Upstream fetch failure + +If the upstream request cannot be completed, return: + +```http +502 Bad Gateway +X-TS-Error: js-asset-origin-unreachable +X-TS-JS-Asset-ID: +``` + +Log the asset ID and origin host at `warn` level. + +### Upstream non-success response + +If the upstream responds with a non-`2xx` status, return: + +```http +502 Bad Gateway +X-TS-Error: js-asset-origin-status +X-TS-JS-Asset-ID: +``` + +Log the upstream status, asset ID, and origin host at `warn` level. + +--- + +## Security Requirements + +- Fetch only the exact `origin_url` values declared in configuration. +- Do not accept user-provided upstream URLs at request time. +- Do not construct upstream hosts from request path segments. +- Do not forward cookies to the upstream JavaScript host. +- Do not forward upstream `Set-Cookie` headers to the browser. +- Do not forward `Referer` or `X-Forwarded-For` to the upstream JavaScript host. +- Do not append EC IDs or other Trusted Server identity values to asset requests. +- Require `https://` upstream URLs. + +--- + +## Implementation Plan + +### 1. Add integration configuration types + +Add a new `js_asset_proxy` integration module with typed config: + +```rust +#[derive(Debug, Clone, Deserialize, Serialize, Validate)] +pub struct JsAssetProxyConfig { + #[serde(default)] + pub enabled: bool, + #[serde(default = "default_cache_ttl_seconds")] + pub cache_ttl_seconds: u32, + #[serde(default)] + pub assets: Vec, +} + +#[derive(Debug, Clone, Deserialize, Serialize, Validate)] +pub struct JsAssetProxyAsset { + pub id: String, + pub path: String, + #[validate(url)] + pub origin_url: String, + pub cache_ttl_seconds: Option, +} +``` + +Implement `IntegrationConfig` for `JsAssetProxyConfig` and add custom validation for the rules in this spec. + +### 2. Register integration routes + +Add `crates/trusted-server-core/src/integrations/js_asset_proxy.rs` and register it from the integration builders list. + +`routes()` returns one exact `GET` endpoint per configured asset path. + +### 3. Implement request handling + +In `handle()`: + +1. Match `req.get_path()` to a configured asset. +2. Build the streaming proxy config. +3. Fetch the upstream response through `proxy_request()`. +4. Reject non-success upstream status codes. +5. Return a finalized response with the response header policy in this spec. + +### 4. Add sample disabled configuration + +Add a disabled sample block to `trusted-server.toml` using only `example.com` domains. + +--- + +## Files + +Expected code changes: + +- `crates/trusted-server-core/src/integrations/js_asset_proxy.rs` +- `crates/trusted-server-core/src/integrations/mod.rs` +- `trusted-server.toml` + +No adapter entry-point changes are expected if the existing integration registry dispatch is sufficient. + +--- + +## Verification + +Run the standard Rust verification for the changed integration code: + +```bash +cargo fmt --all -- --check +cargo test --workspace +cargo clippy --workspace --all-targets --all-features -- -D warnings +``` + +Add unit tests covering: + +- disabled config does not register routes; +- enabled config requires at least one asset; +- duplicate asset IDs are rejected; +- duplicate asset paths are rejected; +- invalid paths are rejected; +- non-HTTPS origins are rejected; +- exact configured routes are registered; +- request path selects the correct asset; +- upstream `2xx` response streams body and sets expected headers; +- upstream fetch failure returns `502` with `X-TS-Error: js-asset-origin-unreachable`; +- upstream non-success response returns `502` with `X-TS-Error: js-asset-origin-status`; +- `Set-Cookie`, `Referer`, `X-Forwarded-For`, and EC values are not forwarded. From 74c8211c1d1d24752631119efd0f9ed18e40fef0 Mon Sep 17 00:00:00 2001 From: Christian Date: Fri, 5 Jun 2026 11:09:58 -0500 Subject: [PATCH 2/3] update spec --- .../specs/2026-04-01-js-asset-proxy-design.md | 158 ++++++++++++------ 1 file changed, 110 insertions(+), 48 deletions(-) diff --git a/docs/superpowers/specs/2026-04-01-js-asset-proxy-design.md b/docs/superpowers/specs/2026-04-01-js-asset-proxy-design.md index 1e9a4871..1315a1b6 100644 --- a/docs/superpowers/specs/2026-04-01-js-asset-proxy-design.md +++ b/docs/superpowers/specs/2026-04-01-js-asset-proxy-design.md @@ -23,6 +23,7 @@ This spec intentionally follows existing integration proxy patterns already used - Fetch only explicitly configured upstream URLs. - Stream upstream JavaScript responses without server-side body transformation. - Apply predictable downstream cache headers controlled by Trusted Server configuration. +- Allow configured assets to be individually proxied, disabled, or blocked from publisher HTML. - Reuse the existing integration registry and proxy request infrastructure. --- @@ -34,18 +35,27 @@ Add a new integration configuration block: ```toml [integrations.js_asset_proxy] enabled = false -cache_ttl_seconds = 3600 [[integrations.js_asset_proxy.assets]] -id = "vendor-loader" path = "/assets/vendor-loader.js" origin_url = "https://js.vendor.example.com/loader.js" +proxy = "enabled" [[integrations.js_asset_proxy.assets]] -id = "measurement-sdk" path = "/assets/measurement-sdk.js" origin_url = "https://cdn.vendor.example.com/sdk/measurement.js" +proxy = "enabled" cache_ttl_seconds = 900 + +[[integrations.js_asset_proxy.assets]] +path = "/assets/blocked-sdk.js" +origin_url = "https://cdn.vendor.example.com/sdk/blocked.js" +proxy = "blocked" + +[[integrations.js_asset_proxy.assets]] +path = "/assets/inactive-sdk.js" +origin_url = "https://cdn.vendor.example.com/sdk/inactive.js" +proxy = "disabled" ``` ### Fields @@ -53,12 +63,12 @@ cache_ttl_seconds = 900 | Field | Required | Description | | ---------------------------- | -------: | ---------------------------------------------------------------- | | `enabled` | Yes | Enables or disables the integration. | -| `cache_ttl_seconds` | No | Default downstream cache TTL for all assets. Defaults to `3600`. | +| `cache_ttl_seconds` | No | Optional downstream cache TTL override for all assets. When unset, preserve the upstream cache policy. | | `assets` | Yes | List of JavaScript assets the proxy may serve. | -| `assets[].id` | Yes | Stable identifier for logs, tests, and response diagnostics. | -| `assets[].path` | Yes | Exact first-party request path handled by Trusted Server. | -| `assets[].origin_url` | Yes | Exact upstream JavaScript URL to fetch. | -| `assets[].cache_ttl_seconds` | No | Per-asset downstream cache TTL override. | +| `assets[].path` | Yes | Stable identifier for logs, tests, and response diagnostics; exact first-party request path handled by Trusted Server. | +| `assets[].origin_url` | Yes | Exact upstream JavaScript URL to fetch or match for page rewriting. | +| `assets[].proxy` | No | Per-asset proxy behavior: `enabled`, `disabled`, or `blocked`. Defaults to `enabled`. | +| `assets[].cache_ttl_seconds` | No | Per-asset downstream cache TTL override. Takes precedence over the integration-level value. | ### Validation @@ -66,31 +76,43 @@ Configuration validation must reject: - enabled integration with malformed configured assets; - empty `assets` when the integration is enabled; -- duplicate asset IDs; - duplicate asset paths; +- duplicate `origin_url` values; - asset paths that do not start with `/`; - asset paths containing `*`; - asset paths containing `..` path segments; -- `origin_url` values without an `https://` scheme; -- `origin_url` values with fragments; -- `cache_ttl_seconds = 0`. +- `proxy` values other than `enabled`, `disabled`, or `blocked`; The implementation may use stricter validation if it keeps the configuration contract simple and documented. --- +## Asset Proxy Behavior + +Each asset has a `proxy` setting that controls both page rewriting and route registration: + +| Value | Behavior | +| ---------- | -------- | +| `enabled` | Rewrite exact matching ` + + + + "#; + + let processed = process_html_with_integration(html, integration); + + assert!(processed.contains(r#""#)); + assert!(processed.contains(r#""#)); + assert!(!processed.contains("blocked()")); + assert!(processed.contains(r#""#)); + } + + #[test] + fn rejects_duplicate_asset_paths() { + let config = config_with_assets(vec![ + asset( + "/assets/vendor.js", + "https://cdn.example.com/vendor-a.js", + JsAssetProxyMode::Enabled, + ), + asset( + "/assets/vendor.js", + "https://cdn.example.com/vendor-b.js", + JsAssetProxyMode::Enabled, + ), + ]); + + assert!( + config.validate().is_err(), + "duplicate asset paths should be rejected" + ); + } + + #[test] + fn rejects_duplicate_origin_urls() { + let config = config_with_assets(vec![ + asset( + "/assets/vendor-a.js", + "https://cdn.example.com/vendor.js", + JsAssetProxyMode::Enabled, + ), + asset( + "/assets/vendor-b.js", + "https://cdn.example.com/vendor.js", + JsAssetProxyMode::Enabled, + ), + ]); + + assert!( + config.validate().is_err(), + "duplicate origin URLs should be rejected" + ); + } + + #[test] + fn rejects_invalid_paths() { + for invalid_path in [ + "assets/vendor.js", + "//cdn.example.com/vendor.js", + "/assets/*.js", + "/assets/../vendor.js", + "/assets/{vendor}.js", + "/assets/vendor.js?v=1", + "/assets/vendor.js#v1", + "/assets/vendor js", + "/assets/vendor\n.js", + ] { + let config = config_with_assets(vec![asset( + invalid_path, + "https://cdn.example.com/vendor.js", + JsAssetProxyMode::Enabled, + )]); + + assert!( + config.validate().is_err(), + "path {invalid_path} should be rejected" + ); + } + } + + #[test] + fn rejects_non_https_origins() { + let config = config_with_assets(vec![asset( + "/assets/vendor.js", + "http://cdn.example.com/vendor.js", + JsAssetProxyMode::Enabled, + )]); + + assert!( + config.validate().is_err(), + "non-HTTPS origin should be rejected" + ); + } + + #[test] + fn rejects_unknown_proxy_mode() { + let toml = r#" + [[handlers]] + path = "^/secure" + username = "user" + password = "pass" + + [[handlers]] + path = "^/_ts/admin" + username = "admin" + password = "admin-pass" + + [publisher] + domain = "test-publisher.com" + cookie_domain = ".test-publisher.com" + origin_url = "https://origin.test-publisher.com" + proxy_secret = "unit-test-proxy-secret" + + [ec] + passphrase = "test-secret-key-32-bytes-minimum" + + [request_signing] + config_store_id = "test-config-store-id" + secret_store_id = "test-secret-store-id" + + [integrations.js_asset_proxy] + enabled = true + + [[integrations.js_asset_proxy.assets]] + path = "/assets/vendor.js" + origin_url = "https://cdn.example.com/vendor.js" + proxy = "passthrough" + "#; + let settings = Settings::from_toml(toml).expect("should parse settings TOML"); + + assert!( + settings + .integration_config::(JS_ASSET_PROXY_INTEGRATION_ID) + .is_err(), + "unknown proxy mode should fail deserialization" + ); + } + + #[test] + fn exact_configured_routes_are_registered() { + let mut settings = create_test_settings(); + settings + .integrations + .insert_config( + JS_ASSET_PROXY_INTEGRATION_ID, + &json!({ + "enabled": true, + "assets": [ + { + "path": "/assets/vendor.js", + "origin_url": "https://cdn.example.com/vendor.js" + }, + { + "path": "/assets/blocked.js", + "origin_url": "https://cdn.example.com/blocked.js", + "proxy": "blocked" + } + ] + }), + ) + .expect("should insert integration config"); + + let registry = IntegrationRegistry::new(&settings).expect("should build registry"); + + assert!(registry.has_route(&Method::GET, "/assets/vendor.js")); + assert!(!registry.has_route(&Method::GET, "/assets/vendor.js/extra")); + assert!(!registry.has_route(&Method::POST, "/assets/vendor.js")); + assert!(!registry.has_route(&Method::GET, "/assets/blocked.js")); + } + + #[test] + fn request_path_selects_the_correct_asset() { + let integration = JsAssetProxyIntegration::new(config_with_assets(vec![ + asset( + "/assets/a.js", + "https://cdn.example.com/a.js", + JsAssetProxyMode::Enabled, + ), + asset( + "/assets/b.js", + "https://cdn.example.com/b.js", + JsAssetProxyMode::Enabled, + ), + ])); + + let selected = integration + .enabled_asset_for_path("/assets/b.js") + .expect("should select configured asset"); + + assert_eq!(selected.origin_url, "https://cdn.example.com/b.js"); + } + + #[test] + fn successful_response_preserves_body_and_expected_headers() { + let mut configured_asset = asset( + "/assets/vendor.js", + "https://cdn.example.com/vendor.js", + JsAssetProxyMode::Enabled, + ); + configured_asset.cache_ttl_seconds = Some(900); + let integration = + JsAssetProxyIntegration::new(config_with_assets(vec![configured_asset.clone()])); + let mut upstream = Response::from_status(StatusCode::OK).with_body("console.log('ok');"); + upstream.set_header(header::CONTENT_TYPE, "application/javascript"); + upstream.set_header(header::CONTENT_ENCODING, "gzip"); + upstream.set_header(header::ETAG, "\"asset-etag\""); + upstream.set_header(header::LAST_MODIFIED, "Tue, 10 Jun 2026 00:00:00 GMT"); + upstream.set_header(header::VARY, "Origin"); + upstream.set_header(header::CACHE_CONTROL, "private, max-age=1"); + upstream.set_header(header::SET_COOKIE, "session=1"); + + let mut response = integration.finalize_asset_response(&configured_asset, upstream); + + assert_eq!(response.get_status(), StatusCode::OK); + assert_eq!(response.take_body_str(), "console.log('ok');"); + assert_eq!( + response.get_header_str(HEADER_X_TS_JS_ASSET_PROXY), + Some("true") + ); + assert_eq!( + response.get_header_str(header::CONTENT_TYPE), + Some("application/javascript") + ); + assert_eq!( + response.get_header_str(header::CONTENT_ENCODING), + Some("gzip") + ); + assert_eq!( + response.get_header_str(header::ETAG), + Some("\"asset-etag\"") + ); + assert_eq!( + response.get_header_str(header::LAST_MODIFIED), + Some("Tue, 10 Jun 2026 00:00:00 GMT") + ); + assert_eq!( + response.get_header_str(header::VARY), + Some("Origin, Accept-Encoding") + ); + assert_eq!( + response.get_header_str(header::CACHE_CONTROL), + Some("public, max-age=900") + ); + assert!( + response.get_header(header::SET_COOKIE).is_none(), + "Set-Cookie should not be forwarded" + ); + } + + #[test] + fn preserves_upstream_cache_control_without_ttl_override() { + let configured_asset = asset( + "/assets/vendor.js", + "https://cdn.example.com/vendor.js", + JsAssetProxyMode::Enabled, + ); + let integration = + JsAssetProxyIntegration::new(config_with_assets(vec![configured_asset.clone()])); + let mut upstream = Response::from_status(StatusCode::OK).with_body("body"); + upstream.set_header(header::CACHE_CONTROL, "public, max-age=123"); + + let response = integration.finalize_asset_response(&configured_asset, upstream); + + assert_eq!( + response.get_header_str(header::CACHE_CONTROL), + Some("public, max-age=123") + ); + } + + #[test] + fn integration_cache_ttl_overrides_upstream_cache_control() { + let configured_asset = asset( + "/assets/vendor.js", + "https://cdn.example.com/vendor.js", + JsAssetProxyMode::Enabled, + ); + let mut config = config_with_assets(vec![configured_asset.clone()]); + config.cache_ttl_seconds = Some(300); + let integration = JsAssetProxyIntegration::new(config); + let mut upstream = Response::from_status(StatusCode::OK).with_body("body"); + upstream.set_header(header::CACHE_CONTROL, "private, max-age=1"); + + let response = integration.finalize_asset_response(&configured_asset, upstream); + + assert_eq!( + response.get_header_str(header::CACHE_CONTROL), + Some("public, max-age=300") + ); + } + + #[test] + fn upstream_error_responses_have_expected_headers() { + let unreachable = JsAssetProxyIntegration::origin_unreachable_response(); + assert_eq!(unreachable.get_status(), StatusCode::BAD_GATEWAY); + assert_eq!( + unreachable.get_header_str(HEADER_X_TS_ERROR), + Some(ERROR_ORIGIN_UNREACHABLE) + ); + + let origin_status = JsAssetProxyIntegration::origin_status_response(); + assert_eq!(origin_status.get_status(), StatusCode::BAD_GATEWAY); + assert_eq!( + origin_status.get_header_str(HEADER_X_TS_ERROR), + Some(ERROR_ORIGIN_STATUS) + ); + } + + #[test] + fn build_proxy_config_forwards_only_asset_header_allowlist() { + let mut req = Request::get("https://publisher.example.com/assets/vendor.js"); + req.set_header(HEADER_ACCEPT.clone(), "application/javascript"); + req.set_header(HEADER_ACCEPT_LANGUAGE.clone(), "en-US"); + req.set_header(HEADER_ACCEPT_ENCODING.clone(), "gzip, br"); + req.set_header(HEADER_REFERER.clone(), "https://publisher.example.com/page"); + req.set_header(HEADER_X_FORWARDED_FOR.clone(), "192.0.2.10"); + req.set_header(HEADER_X_TS_EC.clone(), "edge-cookie-id"); + req.set_header(header::COOKIE, "session=1"); + + let config = + JsAssetProxyIntegration::build_proxy_config("https://cdn.example.com/vendor.js", &req); + + assert!(!config.copy_request_headers); + assert!(!config.follow_redirects); + assert!(!config.forward_ec_id); + + let forwarded: Vec<(String, String)> = config + .headers + .iter() + .map(|(name, value)| { + ( + name.as_str().to_string(), + value + .to_str() + .expect("should expose header value in test") + .to_string(), + ) + }) + .collect(); + + assert_eq!( + forwarded, + vec![ + ("accept".to_string(), "application/javascript".to_string()), + ("accept-language".to_string(), "en-US".to_string()), + ("accept-encoding".to_string(), "gzip, br".to_string()), + ("user-agent".to_string(), "TrustedServer/1.0".to_string()), + ] + ); + } + + #[test] + fn vary_with_accept_encoding_preserves_wildcard_and_existing_value() { + assert_eq!( + JsAssetProxyIntegration::vary_with_accept_encoding(Some("*")), + "*" + ); + assert_eq!( + JsAssetProxyIntegration::vary_with_accept_encoding(Some("Accept-Encoding")), + "Accept-Encoding" + ); + assert_eq!( + JsAssetProxyIntegration::vary_with_accept_encoding(Some("Origin")), + "Origin, Accept-Encoding" + ); + assert_eq!( + JsAssetProxyIntegration::vary_with_accept_encoding(None), + "Accept-Encoding" + ); + } + + #[test] + fn proxy_mode_defaults_to_enabled() { + let parsed: JsAssetProxyAsset = serde_json::from_value(json!({ + "path": "/assets/vendor.js", + "origin_url": "https://cdn.example.com/vendor.js" + })) + .expect("should deserialize asset"); + + assert_eq!(parsed.proxy, JsAssetProxyMode::Enabled); + } +} diff --git a/crates/trusted-server-core/src/integrations/lockr.rs b/crates/trusted-server-core/src/integrations/lockr.rs index 9a480aec..d042ebd2 100644 --- a/crates/trusted-server-core/src/integrations/lockr.rs +++ b/crates/trusted-server-core/src/integrations/lockr.rs @@ -395,6 +395,7 @@ mod tests { fn test_context() -> IntegrationAttributeContext<'static> { IntegrationAttributeContext { attribute_name: "src", + element_name: "script", request_host: "edge.example.com", request_scheme: "https", origin_host: "origin.example.com", diff --git a/crates/trusted-server-core/src/integrations/mod.rs b/crates/trusted-server-core/src/integrations/mod.rs index e9438b32..66cf0537 100644 --- a/crates/trusted-server-core/src/integrations/mod.rs +++ b/crates/trusted-server-core/src/integrations/mod.rs @@ -11,6 +11,7 @@ pub mod datadome; pub mod didomi; pub mod google_tag_manager; pub mod gpt; +pub mod js_asset_proxy; pub mod lockr; pub mod nextjs; pub mod permutive; @@ -43,5 +44,6 @@ pub(crate) fn builders() -> &'static [IntegrationBuilder] { google_tag_manager::register, datadome::register, gpt::register, + js_asset_proxy::register, ] } diff --git a/crates/trusted-server-core/src/integrations/permutive.rs b/crates/trusted-server-core/src/integrations/permutive.rs index b3e59456..bf7cd08c 100644 --- a/crates/trusted-server-core/src/integrations/permutive.rs +++ b/crates/trusted-server-core/src/integrations/permutive.rs @@ -704,6 +704,7 @@ mod tests { let ctx = IntegrationAttributeContext { attribute_name: "src", + element_name: "script", request_host: "edge.example.com", request_scheme: "https", origin_host: "origin.example.com", @@ -737,6 +738,7 @@ mod tests { let ctx = IntegrationAttributeContext { attribute_name: "src", + element_name: "script", request_host: "edge.example.com", request_scheme: "https", origin_host: "origin.example.com", diff --git a/crates/trusted-server-core/src/integrations/prebid.rs b/crates/trusted-server-core/src/integrations/prebid.rs index 418b5b1a..e0fc2965 100644 --- a/crates/trusted-server-core/src/integrations/prebid.rs +++ b/crates/trusted-server-core/src/integrations/prebid.rs @@ -1745,6 +1745,7 @@ passphrase = "test-secret-key-32-bytes-minimum" let integration = PrebidIntegration::new(base_config()); let ctx = IntegrationAttributeContext { attribute_name: "src", + element_name: "script", request_host: "pub.example", request_scheme: "https", origin_host: "origin.example", @@ -1762,6 +1763,7 @@ passphrase = "test-secret-key-32-bytes-minimum" let integration = PrebidIntegration::new(base_config()); let ctx = IntegrationAttributeContext { attribute_name: "href", + element_name: "a", request_host: "pub.example", request_scheme: "https", origin_host: "origin.example", diff --git a/crates/trusted-server-core/src/integrations/registry.rs b/crates/trusted-server-core/src/integrations/registry.rs index 389dd50d..2599b859 100644 --- a/crates/trusted-server-core/src/integrations/registry.rs +++ b/crates/trusted-server-core/src/integrations/registry.rs @@ -81,6 +81,7 @@ impl ScriptRewriteAction { #[derive(Debug)] pub struct IntegrationAttributeContext<'a> { pub attribute_name: &'a str, + pub element_name: &'a str, pub request_host: &'a str, pub request_scheme: &'a str, pub origin_host: &'a str, @@ -821,7 +822,7 @@ impl IntegrationRegistry { #[must_use] pub fn js_module_ids(&self) -> Vec<&'static str> { // Rust-only integrations with no corresponding JS module - const JS_EXCLUDED: &[&str] = &["nextjs", "aps", "adserver_mock"]; + const JS_EXCLUDED: &[&str] = &["nextjs", "aps", "adserver_mock", "js_asset_proxy"]; // JS-only modules always included (no Rust-side registration). // Sourcepoint's JS guards cookie clearing with a Sourcepoint-owned marker. const JS_ALWAYS: &[&str] = &["creative", "sourcepoint"]; diff --git a/crates/trusted-server-core/src/integrations/sourcepoint.rs b/crates/trusted-server-core/src/integrations/sourcepoint.rs index 55fc4ee6..4f900bcd 100644 --- a/crates/trusted-server-core/src/integrations/sourcepoint.rs +++ b/crates/trusted-server-core/src/integrations/sourcepoint.rs @@ -904,6 +904,7 @@ mod tests { let integration = SourcepointIntegration::new(Arc::new(config(true))); let ctx = IntegrationAttributeContext { attribute_name: "src", + element_name: "script", request_host: "edge.example.com", request_scheme: "https", origin_host: "origin.example.com", @@ -928,6 +929,7 @@ mod tests { let integration = SourcepointIntegration::new(Arc::new(config(true))); let ctx = IntegrationAttributeContext { attribute_name: "src", + element_name: "script", request_host: "edge.example.com", request_scheme: "https", origin_host: "origin.example.com", diff --git a/crates/trusted-server-core/src/integrations/testlight.rs b/crates/trusted-server-core/src/integrations/testlight.rs index e63baea3..92ab365a 100644 --- a/crates/trusted-server-core/src/integrations/testlight.rs +++ b/crates/trusted-server-core/src/integrations/testlight.rs @@ -284,6 +284,7 @@ mod tests { let ctx = IntegrationAttributeContext { attribute_name: "src", + element_name: "script", request_host: "edge.example.com", request_scheme: "https", origin_host: "origin.example.com", @@ -313,6 +314,7 @@ mod tests { let integration = TestlightIntegration::new(config); let ctx = IntegrationAttributeContext { attribute_name: "src", + element_name: "script", request_host: "edge.example.com", request_scheme: "https", origin_host: "origin.example.com", diff --git a/trusted-server.toml b/trusted-server.toml index 5ab1a750..80692428 100644 --- a/trusted-server.toml +++ b/trusted-server.toml @@ -150,6 +150,15 @@ script_url = "https://securepubads.g.doubleclick.net/tag/js/gpt.js" cache_ttl_seconds = 3600 rewrite_script = true +[integrations.js_asset_proxy] +enabled = false +cache_ttl_seconds = 3600 + +[[integrations.js_asset_proxy.assets]] +path = "/assets/example-vendor-loader.js" +origin_url = "https://cdn.example.com/vendor-loader.js" +proxy = "enabled" + # Consent forwarding configuration # Controls how Trusted Server interprets and forwards privacy consent signals. # All values shown below are the defaults — uncomment to override.