From 83135c70d0dbf171ad335f16b4279f5b05a66e3b Mon Sep 17 00:00:00 2001 From: Christian Date: Thu, 28 May 2026 15:57:27 -0500 Subject: [PATCH 1/7] Add external Prebid bundle proxy spec --- ...xternal-prebid-first-party-proxy-design.md | 356 ++++++++++++++++++ 1 file changed, 356 insertions(+) create mode 100644 docs/superpowers/specs/2026-05-28-external-prebid-first-party-proxy-design.md diff --git a/docs/superpowers/specs/2026-05-28-external-prebid-first-party-proxy-design.md b/docs/superpowers/specs/2026-05-28-external-prebid-first-party-proxy-design.md new file mode 100644 index 000000000..d9b29b1fd --- /dev/null +++ b/docs/superpowers/specs/2026-05-28-external-prebid-first-party-proxy-design.md @@ -0,0 +1,356 @@ +# External Prebid Bundle First-Party Proxy Design + +> **Status:** Proposal +> **Date:** 2026-05-28 +> **Phase:** 1 only + +## Problem + +The Prebid integration currently builds Prebid.js into the Trusted Server JS +artifact. The Rust `trusted-server-js` crate embeds that JS with `include_str!`, +so publisher-specific Prebid module selections become part of the Trusted Server +binary. + +That creates a deployment and attestation problem: different publishers often +need different Prebid bidder adapters and User ID modules, but those choices +should not require different Trusted Server WASM artifacts. Trusted Server should +remain a stable, attestable runtime while Prebid remains a publisher-specific +browser asset. + +We still need the browser to load Prebid through the publisher's first-party +origin so the integration preserves the current first-party deployment model. + +## Goals + +- Remove Prebid.js runtime bytes from the Trusted Server embedded JS bundle. +- Keep the current `/auction` flow and custom `trustedServer` Prebid adapter. +- Support publisher-specific generated Prebid bundles outside the Rust/Cargo + build. +- Serve the generated Prebid bundle through a first-party Trusted Server route. +- Keep managed-mode script interception so publisher Prebid scripts are not + double-loaded. +- Use content-addressed URLs and integrity metadata so external bundles are + auditable and cacheable. +- Keep Phase 1 focused on the managed external bundle flow only. + +## Non-Goals + +- Supporting publisher-owned existing Prebid bundles in this phase. +- Supporting arbitrary runtime module selection from the Trusted Server edge. +- Replacing the custom `trustedServer` adapter with Prebid.js native S2S/PBS + configuration. +- Building an administrative UI for bundle generation. +- Removing the Prebid npm dependency from JS tooling if it is still needed by an + external bundle generator. + +## Current Architecture Summary + +Current Prebid bundling path: + +1. `crates/js/lib/build-all.mjs` generates Prebid adapter and User ID module + imports. +2. `crates/js/lib/src/integrations/prebid/index.ts` imports `prebid.js`, Prebid + modules, generated adapters, and generated User ID modules. +3. Vite emits `tsjs-prebid.js`. +4. `crates/js/build.rs` copies `tsjs-prebid.js` into Cargo `OUT_DIR`. +5. `crates/js/src/bundle.rs` embeds it with generated `include_str!` metadata. +6. `crates/trusted-server-core/src/integrations/prebid.rs` registers Prebid as a + deferred JS module with `.with_deferred_js()`. +7. `html_processor.rs` injects `/static/tsjs=tsjs-prebid.min.js` as a deferred + script. +8. `publisher.rs` serves that script from embedded bytes. + +Phase 1 replaces steps 3-8 for Prebid only. The core Trusted Server JS bundle +continues to work as it does today for non-Prebid modules. + +## Proposed Model + +Trusted Server becomes responsible for: + +- Prebid server-side configuration +- `/auction` +- HTML script interception +- injecting `window.__tsjs_prebid` +- injecting a first-party script URL for the managed external Prebid bundle +- proxying that script URL to the configured external immutable asset + +The external generated Prebid bundle becomes responsible for: + +- importing `prebid.js` +- importing selected bidder adapters +- importing selected consent and User ID modules +- registering the `trustedServer` bid adapter +- shimming `pbjs.requestBids()` as today +- calling `pbjs.processQueue()` after modules and the adapter are installed + +## Configuration + +Add managed external bundle settings under `integrations.prebid`: + +```toml +[integrations.prebid] +enabled = true +server_url = "https://prebid-server.example.com/openrtb2/auction" +timeout_ms = 1000 +bidders = ["example-bidder"] + +# Phase 1 managed external bundle mode. +bundle_mode = "managed_external" +external_bundle_url = "https://assets.example.com/prebid/trusted-prebid-abc123.js" +external_bundle_sha256 = "abc123..." +external_bundle_sri = "sha384-..." +``` + +### Field Semantics + +| Field | Required | Description | +| ------------------------ | ------------------------------------------- | ---------------------------------------------------------------------------------------------- | +| `bundle_mode` | No | Defaults to current embedded behavior during migration. Phase 1 introduces `managed_external`. | +| `external_bundle_url` | Yes when `bundle_mode = "managed_external"` | Absolute `https://` URL of the generated Prebid bundle. | +| `external_bundle_sha256` | Yes when `bundle_mode = "managed_external"` | Hex SHA-256 of the exact JS bytes. Used for cache busting and optional edge validation. | +| `external_bundle_sri` | Recommended | Browser Subresource Integrity value for the proxied first-party script response. | + +`managed_external` should fail config validation when: + +- `external_bundle_url` is missing +- `external_bundle_url` is not `https://` +- `external_bundle_sha256` is missing or malformed +- `external_bundle_sri` is present but malformed + +## First-Party Bundle Route + +Trusted Server should expose a stable first-party route for the configured +bundle: + +```text +GET /integrations/prebid/bundle.js?v= +``` + +The injected script tag should use that first-party URL, not the external asset +URL directly: + +```html + +``` + +### Why Not Use `/first-party/proxy` Directly? + +The generic first-party proxy is designed for creative assets. It may forward EC +IDs, follow creative-oriented response processing paths, and uses signed target +URLs. The Prebid bundle is a static, immutable application asset and should have +a narrower route with asset-specific behavior. + +The new route can still reuse the lower-level proxy helper, but it should call it +with asset-safe options: + +- `forward_ec_id = false` +- `copy_request_headers = false` or a minimal static-asset header set +- `stream_passthrough = true` +- redirects allowed only when every hop remains permitted +- no HTML/CSS rewriting + +## Runtime Request Flow + +```mermaid +sequenceDiagram + autonumber + participant B as Browser + participant TS as Trusted Server + participant CDN as External Bundle Host + participant PBS as Prebid Server + + B->>TS: GET publisher page + TS->>TS: remove configured publisher Prebid script tags + TS-->>B: HTML with window.__tsjs_prebid and first-party Prebid bundle script + B->>TS: GET /integrations/prebid/bundle.js?v=sha256 + TS->>CDN: GET external_bundle_url + CDN-->>TS: generated Prebid bundle bytes + TS-->>B: application/javascript, immutable cache headers + B->>B: Prebid installs trustedServer adapter and processes pbjs queue + B->>TS: POST /auction + TS->>PBS: POST OpenRTB request + PBS-->>TS: OpenRTB response + TS-->>B: auction response +``` + +## HTML Injection Behavior + +In `managed_external` mode, Prebid head injection should emit: + +1. the existing `window.pbjs` queue stub +2. `window.__tsjs_prebid` config +3. a first-party script tag for `/integrations/prebid/bundle.js?v=` + +The script tag should be injected at the same early head insertion point used by +current TSJS injection. + +The generated external Prebid bundle should be `defer`-safe. It must install all +modules and the `trustedServer` adapter before calling `pbjs.processQueue()`. + +## Script Interception Behavior + +In Phase 1, `managed_external` mode owns Prebid loading. Therefore existing +publisher Prebid script tags should continue to be removed when they match +`script_patterns`. + +Requests for intercepted publisher Prebid script URLs may continue returning the +existing empty JS response. This prevents duplicate Prebid instances when the +publisher page references its original Prebid asset. + +Publisher-existing Prebid mode is explicitly out of scope for Phase 1. + +## External Bundle Generation + +Add a generation path outside the Cargo build, for example: + +```bash +node crates/js/lib/build-prebid-external.mjs \ + --adapters exampleBidder,anotherExampleBidder \ + --user-id-modules sharedIdSystem,uid2IdSystem \ + --out dist/prebid/ +``` + +The generated bundle should include: + +- Prebid.js core +- selected bidder adapters +- consent modules required by the integration +- selected User ID modules +- the existing Trusted Server Prebid adapter/shim logic + +The generator should emit a manifest: + +```json +{ + "prebidVersion": "10.26.0", + "adapters": ["exampleBidder", "anotherExampleBidder"], + "userIdModules": ["sharedIdSystem", "uid2IdSystem"], + "sha256": "abc123...", + "sri": "sha384-...", + "filename": "trusted-prebid-abc123.js" +} +``` + +Trusted Server config should reference the generated immutable asset URL and the +manifest hash values. + +## Required Code Changes + +### JS Build + +- Stop including `src/integrations/prebid/index.ts` in the default `build-all.mjs` + embedded TSJS discovery path, or move the Prebid external entrypoint outside + `src/integrations`. +- Move reusable Trusted Server Prebid adapter/shim code into a module that can be + used by the external bundle generator. +- Keep Prebid-related generated adapter/User ID imports in the external bundle + generator, not the embedded Trusted Server build. + +### Rust Integration + +- Add `bundle_mode`, `external_bundle_url`, `external_bundle_sha256`, and + `external_bundle_sri` fields to `PrebidIntegrationConfig`. +- In `managed_external` mode, do not register Prebid with `.with_deferred_js()`. +- Register a Prebid integration GET route for `/integrations/prebid/bundle.js`. +- Implement the route as a first-party proxy to `external_bundle_url` with static + asset behavior. +- Inject the first-party script tag from the Prebid head injector. +- Preserve current script-pattern removal/empty-script behavior. + +### Publisher Static Serving + +- `/static/tsjs=tsjs-prebid.min.js` should no longer be the Prebid loading path in + `managed_external` mode. +- Existing deferred-module serving can remain for other integrations or migration + compatibility. + +## Response Headers + +For successful first-party bundle responses, Trusted Server should set or +normalize: + +```text +Content-Type: application/javascript; charset=utf-8 +Cache-Control: public, max-age=31536000, immutable +ETag: "sha256:" +``` + +If the route query `v` is present and does not match `external_bundle_sha256`, +return `404 Not Found` to avoid ambiguous cache entries. + +## Integrity and Attestation + +This design separates two attestable artifacts: + +1. Trusted Server WASM binary +2. generated external Prebid bundle + +The Trusted Server binary hash should no longer vary with Prebid module choices. +The Prebid bundle should be audited through its own manifest containing: + +- Prebid version +- module list +- bundle hash +- SRI value +- generator version or source revision when available + +Browser SRI should validate the first-party proxied response. Edge-side SHA-256 +validation is recommended when feasible; if validation fails, the route should +return an error rather than serving mismatched JS. + +## Migration Plan + +1. Add config fields and validation while preserving the current embedded default. +2. Add first-party bundle proxy route and injection for `managed_external` mode. +3. Add external bundle generation tooling and manifest output. +4. Disable embedded Prebid JS registration when `managed_external` is selected. +5. Update docs and examples to prefer `managed_external`. +6. After deployments are migrated, consider making `managed_external` the default + and removing embedded Prebid support in a later phase. + +## Test Plan + +### Rust Tests + +- Config validation accepts valid `managed_external` settings. +- Config validation rejects missing or malformed external bundle settings. +- Registry does not include `prebid` in deferred JS IDs for `managed_external`. +- Head injection emits the first-party bundle URL with the configured hash. +- Script interception still removes matching publisher Prebid scripts. +- Bundle route proxies to `external_bundle_url` without forwarding EC ID. +- Bundle route rejects mismatched `v` query values. +- Bundle route emits JavaScript content type and immutable cache headers. + +### JS Tests + +- External generated bundle registers the `trustedServer` adapter. +- External generated bundle shims `requestBids()` as the current embedded bundle + does. +- External generated bundle calls `pbjs.processQueue()` after module/adapter + registration. +- Client-side bidder adapter selection is reflected in the generated manifest. + +### Browser/Integration Tests + +- Publisher page loads no `/static/tsjs=tsjs-prebid.min.js` in + `managed_external` mode. +- Browser loads `/integrations/prebid/bundle.js?v=` from first-party + origin. +- Original publisher Prebid script tag is removed or neutralized. +- A Prebid auction still posts to `/auction`. +- No duplicate Prebid instances are created. + +## Open Questions + +- Should edge-side SHA-256 validation be mandatory in Phase 1, or is browser SRI + plus content-addressed URLs sufficient initially? +- Should `external_bundle_url` redirects be allowed, or should Phase 1 require a + direct immutable URL with no redirects? +- Should the external bundle route use the global `proxy.allowed_domains`, or a + Prebid-specific allowlist derived from `external_bundle_url`? +- Should the injected script tag include `crossorigin`, or omit it because the + browser-visible URL is same-origin? From 4991ebd74885a924af08a84a48a736f8d06f700e Mon Sep 17 00:00:00 2001 From: Christian Date: Tue, 9 Jun 2026 15:36:09 -0500 Subject: [PATCH 2/7] spec update --- ...xternal-prebid-first-party-proxy-design.md | 124 +++++++++++++----- 1 file changed, 88 insertions(+), 36 deletions(-) diff --git a/docs/superpowers/specs/2026-05-28-external-prebid-first-party-proxy-design.md b/docs/superpowers/specs/2026-05-28-external-prebid-first-party-proxy-design.md index d9b29b1fd..9cc97f656 100644 --- a/docs/superpowers/specs/2026-05-28-external-prebid-first-party-proxy-design.md +++ b/docs/superpowers/specs/2026-05-28-external-prebid-first-party-proxy-design.md @@ -29,8 +29,8 @@ origin so the integration preserves the current first-party deployment model. - Serve the generated Prebid bundle through a first-party Trusted Server route. - Keep managed-mode script interception so publisher Prebid scripts are not double-loaded. -- Use content-addressed URLs and integrity metadata so external bundles are - auditable and cacheable. +- Support content-addressed URLs and integrity metadata so external bundles can + be auditable and cacheable when hash metadata is configured. - Keep Phase 1 focused on the managed external bundle flow only. ## Non-Goals @@ -72,7 +72,7 @@ Trusted Server becomes responsible for: - HTML script interception - injecting `window.__tsjs_prebid` - injecting a first-party script URL for the managed external Prebid bundle -- proxying that script URL to the configured external immutable asset +- proxying that script URL to the configured external asset The external generated Prebid bundle becomes responsible for: @@ -97,24 +97,30 @@ bidders = ["example-bidder"] # Phase 1 managed external bundle mode. bundle_mode = "managed_external" external_bundle_url = "https://assets.example.com/prebid/trusted-prebid-abc123.js" +# Optional but recommended. Enables content-addressed URLs, immutable caching, +# and optional edge-side byte validation. external_bundle_sha256 = "abc123..." + +# Optional but recommended when sha256 is configured. external_bundle_sri = "sha384-..." ``` ### Field Semantics -| Field | Required | Description | -| ------------------------ | ------------------------------------------- | ---------------------------------------------------------------------------------------------- | -| `bundle_mode` | No | Defaults to current embedded behavior during migration. Phase 1 introduces `managed_external`. | -| `external_bundle_url` | Yes when `bundle_mode = "managed_external"` | Absolute `https://` URL of the generated Prebid bundle. | -| `external_bundle_sha256` | Yes when `bundle_mode = "managed_external"` | Hex SHA-256 of the exact JS bytes. Used for cache busting and optional edge validation. | -| `external_bundle_sri` | Recommended | Browser Subresource Integrity value for the proxied first-party script response. | +| Field | Required | Description | +| ------------------------ | ------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------- | +| `bundle_mode` | No | Defaults to current embedded behavior during migration. Phase 1 introduces `managed_external`. | +| `external_bundle_url` | Yes when `bundle_mode = "managed_external"` | Absolute `https://` URL of the generated Prebid bundle. | +| `external_bundle_sha256` | No | Hex SHA-256 of the exact JS bytes. When present, enables content-addressed URLs, immutable caching, and optional edge validation. | +| `external_bundle_sri` | Recommended when SHA-256 is configured | Browser Subresource Integrity value for the proxied first-party script response. | `managed_external` should fail config validation when: - `external_bundle_url` is missing - `external_bundle_url` is not `https://` -- `external_bundle_sha256` is missing or malformed +- `external_bundle_url` host is not permitted by `proxy.allowed_domains` when + `proxy.allowed_domains` is non-empty +- `external_bundle_sha256` is present but malformed - `external_bundle_sri` is present but malformed ## First-Party Bundle Route @@ -122,12 +128,20 @@ external_bundle_sri = "sha384-..." Trusted Server should expose a stable first-party route for the configured bundle: +```text +GET /integrations/prebid/bundle.js +``` + +When `external_bundle_sha256` is configured, Trusted Server should inject a +content-addressed URL: + ```text GET /integrations/prebid/bundle.js?v= ``` -The injected script tag should use that first-party URL, not the external asset -URL directly: +The injected script tag should use the first-party URL, not the external asset +URL directly. In content-addressed mode, it should include the hash query value +and `integrity` metadata when configured: ```html ``` +If `external_bundle_sha256` is omitted, the injected script tag should omit the +content hash query value and Trusted Server must not serve the response as an +immutable asset. + ### Why Not Use `/first-party/proxy` Directly? The generic first-party proxy is designed for creative assets. It may forward EC IDs, follow creative-oriented response processing paths, and uses signed target -URLs. The Prebid bundle is a static, immutable application asset and should have -a narrower route with asset-specific behavior. +URLs. The Prebid bundle is a static application asset and should have a narrower +route with asset-specific behavior. The new route can still reuse the lower-level proxy helper, but it should call it with asset-safe options: @@ -150,7 +168,8 @@ with asset-safe options: - `forward_ec_id = false` - `copy_request_headers = false` or a minimal static-asset header set - `stream_passthrough = true` -- redirects allowed only when every hop remains permitted +- redirects allowed only when every hop remains `https://` and the redirect + target host is permitted by `proxy.allowed_domains` - no HTML/CSS rewriting ## Runtime Request Flow @@ -166,10 +185,10 @@ sequenceDiagram B->>TS: GET publisher page TS->>TS: remove configured publisher Prebid script tags TS-->>B: HTML with window.__tsjs_prebid and first-party Prebid bundle script - B->>TS: GET /integrations/prebid/bundle.js?v=sha256 + B->>TS: GET /integrations/prebid/bundle.js[?v=sha256] TS->>CDN: GET external_bundle_url CDN-->>TS: generated Prebid bundle bytes - TS-->>B: application/javascript, immutable cache headers + TS-->>B: application/javascript with cache headers for configured mode B->>B: Prebid installs trustedServer adapter and processes pbjs queue B->>TS: POST /auction TS->>PBS: POST OpenRTB request @@ -183,7 +202,7 @@ In `managed_external` mode, Prebid head injection should emit: 1. the existing `window.pbjs` queue stub 2. `window.__tsjs_prebid` config -3. a first-party script tag for `/integrations/prebid/bundle.js?v=` +3. a first-party script tag for `/integrations/prebid/bundle.js`, with `?v=` when a SHA-256 hash is configured The script tag should be injected at the same early head insertion point used by current TSJS injection. @@ -235,8 +254,10 @@ The generator should emit a manifest: } ``` -Trusted Server config should reference the generated immutable asset URL and the -manifest hash values. +Trusted Server config should reference the generated asset URL. When the +manifest includes hash values, config should also reference those values to +enable content-addressed delivery, immutable caching, SRI, and optional +edge-side validation. ## Required Code Changes @@ -270,17 +291,38 @@ manifest hash values. ## Response Headers -For successful first-party bundle responses, Trusted Server should set or +For successful first-party bundle responses, Trusted Server should always set or normalize: ```text Content-Type: application/javascript; charset=utf-8 +``` + +Caching depends on whether the browser-visible URL is content-addressed. + +When `external_bundle_sha256` is configured, the injected URL should include +`?v=` and Trusted Server should serve the response as an +immutable asset: + +```text Cache-Control: public, max-age=31536000, immutable ETag: "sha256:" ``` -If the route query `v` is present and does not match `external_bundle_sha256`, -return `404 Not Found` to avoid ambiguous cache entries. +If the route query `v` is present, it must match `external_bundle_sha256`. If no +SHA-256 is configured, any `v` query value should return `404 Not Found`. This +avoids ambiguous cache entries. + +When `external_bundle_sha256` is omitted, the injected URL should not include a +content hash query value and Trusted Server must not use `immutable`. It should +use a short-lived revalidation-oriented policy, for example: + +```text +Cache-Control: public, max-age=300, s-maxage=300, stale-while-revalidate=60, stale-if-error=86400 +``` + +In this mode, `ETag` should be forwarded from the external asset when safe or +computed from the proxied response bytes when feasible. ## Integrity and Attestation @@ -298,9 +340,14 @@ The Prebid bundle should be audited through its own manifest containing: - SRI value - generator version or source revision when available -Browser SRI should validate the first-party proxied response. Edge-side SHA-256 -validation is recommended when feasible; if validation fails, the route should -return an error rather than serving mismatched JS. +When configured, browser SRI should validate the first-party proxied response. +Edge-side SHA-256 validation is recommended when `external_bundle_sha256` is +configured and feasible; if validation fails, the route should return an error +rather than serving mismatched JS. + +If `external_bundle_sha256` is omitted, Trusted Server cannot treat the route as +content-addressed. That mode trades stronger attestation and long-lived caching +for easier operations and must use non-immutable cache headers. ## Migration Plan @@ -317,13 +364,17 @@ return an error rather than serving mismatched JS. ### Rust Tests - Config validation accepts valid `managed_external` settings. -- Config validation rejects missing or malformed external bundle settings. +- Config validation rejects missing required external bundle settings and malformed optional hash or SRI metadata. - Registry does not include `prebid` in deferred JS IDs for `managed_external`. -- Head injection emits the first-party bundle URL with the configured hash. +- Head injection emits the first-party bundle URL with the configured hash when present and without a hash query value when absent. - Script interception still removes matching publisher Prebid scripts. - Bundle route proxies to `external_bundle_url` without forwarding EC ID. -- Bundle route rejects mismatched `v` query values. -- Bundle route emits JavaScript content type and immutable cache headers. +- Bundle route rejects mismatched `v` query values when SHA-256 is configured and rejects any `v` query value when SHA-256 is omitted. +- Bundle route blocks redirects to non-HTTPS URLs. +- Bundle route blocks redirects whose target hosts are not permitted by + `proxy.allowed_domains`. +- Bundle route emits JavaScript content type and immutable cache headers in content-addressed mode. +- Bundle route emits JavaScript content type and non-immutable short-lived cache headers when SHA-256 is omitted. ### JS Tests @@ -339,18 +390,19 @@ return an error rather than serving mismatched JS. - Publisher page loads no `/static/tsjs=tsjs-prebid.min.js` in `managed_external` mode. - Browser loads `/integrations/prebid/bundle.js?v=` from first-party - origin. + origin when SHA-256 is configured, or `/integrations/prebid/bundle.js` when it + is omitted. - Original publisher Prebid script tag is removed or neutralized. - A Prebid auction still posts to `/auction`. - No duplicate Prebid instances are created. ## Open Questions -- Should edge-side SHA-256 validation be mandatory in Phase 1, or is browser SRI - plus content-addressed URLs sufficient initially? +- When SHA-256 is configured, should edge-side validation be mandatory in Phase + 1, or is browser SRI plus content-addressed URLs sufficient initially? - Should `external_bundle_url` redirects be allowed, or should Phase 1 require a - direct immutable URL with no redirects? -- Should the external bundle route use the global `proxy.allowed_domains`, or a - Prebid-specific allowlist derived from `external_bundle_url`? + direct generated asset URL with no redirects? If redirects are allowed, every + hop must remain `https://` and the redirect target host must be permitted by + `proxy.allowed_domains`. - Should the injected script tag include `crossorigin`, or omit it because the browser-visible URL is same-origin? From 5f081ab249629bc1f2f25e54b1c790df871641c3 Mon Sep 17 00:00:00 2001 From: Christian Date: Tue, 9 Jun 2026 17:09:43 -0500 Subject: [PATCH 3/7] Add managed external Prebid bundle loading --- crates/js/lib/build-prebid-external.mjs | 266 ++++ crates/js/lib/package.json | 1 + .../src/integrations/prebid.rs | 1109 ++++++++++++++++- .../src/integrations/registry.rs | 62 +- crates/trusted-server-core/src/proxy.rs | 85 +- 5 files changed, 1493 insertions(+), 30 deletions(-) create mode 100644 crates/js/lib/build-prebid-external.mjs diff --git a/crates/js/lib/build-prebid-external.mjs b/crates/js/lib/build-prebid-external.mjs new file mode 100644 index 000000000..8cb7efd02 --- /dev/null +++ b/crates/js/lib/build-prebid-external.mjs @@ -0,0 +1,266 @@ +/** + * Build a publisher-specific external Prebid bundle. + * + * Unlike build-all.mjs, this script is intended to run outside the Cargo build. + * It produces an immutable bundle and manifest that can be hosted on an asset + * CDN, then referenced by integrations.prebid.external_bundle_url. + */ + +import crypto from 'node:crypto'; +import fs from 'node:fs'; +import { createRequire } from 'node:module'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { build } from 'vite'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const require = createRequire(import.meta.url); +const srcDir = path.resolve(__dirname, 'src'); +const integrationsDir = path.join(srcDir, 'integrations'); +const prebidDir = path.join(integrationsDir, 'prebid'); + +const DEFAULT_PREBID_ADAPTERS = 'rubicon'; +const ADAPTERS_FILE = path.join(prebidDir, '_adapters.generated.ts'); +const USER_IDS_FILE = path.join(prebidDir, '_user_ids.generated.ts'); +const USER_ID_REGISTRY_FILE = path.join(prebidDir, 'user_id_modules.json'); +const LIVE_INTENT_SHIM_ALIAS = 'prebid.js/modules/liveIntentIdSystem.js'; +const PREBID_PACKAGE_DIR = path.join(__dirname, 'node_modules', 'prebid.js'); +const PREBID_LIVE_INTENT_STANDARD = path.join( + PREBID_PACKAGE_DIR, + 'dist', + 'src', + 'libraries', + 'liveIntentId', + 'idSystem.js' +); +const PREBID_GLOBAL_MODULE = path.join(PREBID_PACKAGE_DIR, 'dist', 'src', 'src', 'prebidGlobal.js'); +const LIVE_INTENT_SHIM = path.join(prebidDir, 'prebid_modules', 'liveIntentIdSystem.ts'); + +function parseArgs(argv) { + const options = new Map(); + for (let i = 0; i < argv.length; i += 1) { + const arg = argv[i]; + if (!arg.startsWith('--')) { + throw new Error(`[build-prebid-external] Unexpected positional argument: ${arg}`); + } + + const equalsIndex = arg.indexOf('='); + const rawKey = equalsIndex === -1 ? arg.slice(2) : arg.slice(2, equalsIndex); + const inlineValue = equalsIndex === -1 ? undefined : arg.slice(equalsIndex + 1); + const value = inlineValue ?? argv[i + 1]; + if (!value || value.startsWith('--')) { + throw new Error(`[build-prebid-external] Missing value for --${rawKey}`); + } + if (inlineValue === undefined) { + i += 1; + } + options.set(rawKey, value); + } + + return { + adapters: parseList(options.get('adapters') ?? DEFAULT_PREBID_ADAPTERS), + userIdModules: options.has('user-id-modules') + ? parseList(options.get('user-id-modules')) + : null, + outDir: path.resolve(__dirname, options.get('out') ?? path.join('..', 'dist', 'prebid')), + }; +} + +function parseList(raw) { + return raw + .split(',') + .map((value) => value.trim()) + .filter(Boolean); +} + +function readIfExists(filePath) { + return fs.existsSync(filePath) ? fs.readFileSync(filePath, 'utf8') : null; +} + +function restoreFile(filePath, content) { + if (content === null) { + fs.rmSync(filePath, { force: true }); + } else { + fs.writeFileSync(filePath, content); + } +} + +function requireExistingFile(filePath, description) { + if (!fs.existsSync(filePath)) { + throw new Error(`[build-prebid-external] Missing ${description}: ${filePath}`); + } +} + +function prebidPackageVersion() { + const packageJsonPath = path.join(PREBID_PACKAGE_DIR, 'package.json'); + const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8')); + return packageJson.version; +} + +function readUserIdRegistry() { + return JSON.parse(fs.readFileSync(USER_ID_REGISTRY_FILE, 'utf8')); +} + +function validateUserIdImport(entry) { + requireExistingFile(LIVE_INTENT_SHIM, 'LiveIntent ESM shim'); + requireExistingFile(PREBID_LIVE_INTENT_STANDARD, 'Prebid LiveIntent standard ESM module'); + requireExistingFile(PREBID_GLOBAL_MODULE, 'Prebid global module'); + + if (entry.moduleName === 'liveIntentIdSystem') { + return; + } + + try { + require.resolve(entry.importPath, { paths: [__dirname] }); + } catch (error) { + throw new Error( + `[build-prebid-external] Required Prebid user ID module "${entry.moduleName}" ` + + `could not be resolved from ${entry.importPath}: ${error.message}` + ); + } +} + +function generateAdapterImports(adapterNames) { + const modulesDir = path.join(PREBID_PACKAGE_DIR, 'modules'); + const imports = []; + const validAdapters = []; + + for (const name of adapterNames) { + const moduleFile = `${name}BidAdapter.js`; + const modulePath = path.join(modulesDir, moduleFile); + if (!fs.existsSync(modulePath)) { + throw new Error( + `[build-prebid-external] Prebid adapter "${name}" not found (expected ${moduleFile})` + ); + } + imports.push(`import 'prebid.js/modules/${moduleFile}';`); + validAdapters.push(name); + } + + const content = [ + '// Auto-generated by build-prebid-external.mjs — manual edits will be overwritten.', + '//', + '// External Prebid bundle adapter imports.', + `// Modules: ${validAdapters.join(', ')}`, + '', + ...imports, + '', + ].join('\n'); + + fs.writeFileSync(ADAPTERS_FILE, content); + return validAdapters; +} + +function generateUserIdImports(requestedModules) { + const registry = readUserIdRegistry(); + const entriesByModule = new Map(registry.modules.map((entry) => [entry.moduleName, entry])); + const moduleNames = requestedModules ?? registry.defaultPreset; + const selectedEntries = moduleNames.map((moduleName) => { + const entry = entriesByModule.get(moduleName); + if (!entry) { + throw new Error(`[build-prebid-external] Unknown Prebid user ID module: ${moduleName}`); + } + validateUserIdImport(entry); + return entry; + }); + + const imports = selectedEntries.map((entry) => `import '${entry.importPath}';`); + const content = [ + '// Auto-generated by build-prebid-external.mjs — manual edits will be overwritten.', + '//', + '// External Prebid bundle User ID module imports.', + `// Modules: ${moduleNames.join(', ')}`, + '', + ...imports, + '', + ].join('\n'); + + fs.writeFileSync(USER_IDS_FILE, content); + return moduleNames; +} + +async function buildExternalBundle(outDir) { + fs.mkdirSync(outDir, { recursive: true }); + + const temporaryFile = 'trusted-prebid.tmp.js'; + const temporaryPath = path.join(outDir, temporaryFile); + fs.rmSync(temporaryPath, { force: true }); + + await build({ + configFile: false, + root: __dirname, + resolve: { + alias: { + [LIVE_INTENT_SHIM_ALIAS]: LIVE_INTENT_SHIM, + 'prebid.js/modules/liveIntentIdSystem': LIVE_INTENT_SHIM, + 'tsjs-prebid/liveIntentIdSystemStandard': PREBID_LIVE_INTENT_STANDARD, + 'tsjs-prebid/prebidGlobal': PREBID_GLOBAL_MODULE, + 'prebid.js/src/adapterManager.js': path.resolve( + __dirname, + 'node_modules/prebid.js/dist/src/src/adapterManager.js' + ), + }, + }, + build: { + emptyOutDir: false, + outDir, + assetsDir: '.', + sourcemap: false, + minify: 'esbuild', + rollupOptions: { + input: path.join(prebidDir, 'index.ts'), + output: { + format: 'iife', + dir: outDir, + entryFileNames: temporaryFile, + inlineDynamicImports: true, + extend: false, + name: 'tsjs_prebid_external', + }, + }, + }, + logLevel: 'warn', + }); + + const bundleBytes = fs.readFileSync(temporaryPath); + const sha256 = crypto.createHash('sha256').update(bundleBytes).digest('hex'); + const sri = `sha384-${crypto.createHash('sha384').update(bundleBytes).digest('base64')}`; + const filename = `trusted-prebid-${sha256}.js`; + const finalPath = path.join(outDir, filename); + + fs.rmSync(finalPath, { force: true }); + fs.renameSync(temporaryPath, finalPath); + + return { filename, sha256, sri }; +} + +const args = parseArgs(process.argv.slice(2)); +const originalAdapters = readIfExists(ADAPTERS_FILE); +const originalUserIds = readIfExists(USER_IDS_FILE); + +try { + const adapters = generateAdapterImports(args.adapters); + const userIdModules = generateUserIdImports(args.userIdModules); + const bundle = await buildExternalBundle(args.outDir); + const manifest = { + prebidVersion: prebidPackageVersion(), + adapters, + userIdModules, + sha256: bundle.sha256, + sri: bundle.sri, + filename: bundle.filename, + }; + + fs.writeFileSync( + path.join(args.outDir, 'manifest.json'), + `${JSON.stringify(manifest, null, 2)}\n` + ); + + console.log('[build-prebid-external] Built external Prebid bundle:', bundle.filename); + console.log('[build-prebid-external] SHA-256:', bundle.sha256); + console.log('[build-prebid-external] SRI:', bundle.sri); + console.log('[build-prebid-external] Manifest:', path.join(args.outDir, 'manifest.json')); +} finally { + restoreFile(ADAPTERS_FILE, originalAdapters); + restoreFile(USER_IDS_FILE, originalUserIds); +} diff --git a/crates/js/lib/package.json b/crates/js/lib/package.json index 9beca211a..2ffed57e7 100644 --- a/crates/js/lib/package.json +++ b/crates/js/lib/package.json @@ -6,6 +6,7 @@ "description": "Trusted Server tsjs TypeScript library with queue and simple banner rendering.", "scripts": { "build": "node build-all.mjs", + "build:prebid-external": "node build-prebid-external.mjs", "dev": "vite build --watch", "test": "vitest run", "test:watch": "vitest", diff --git a/crates/trusted-server-core/src/integrations/prebid.rs b/crates/trusted-server-core/src/integrations/prebid.rs index d2bceb43a..cebc023a4 100644 --- a/crates/trusted-server-core/src/integrations/prebid.rs +++ b/crates/trusted-server-core/src/integrations/prebid.rs @@ -2,16 +2,6 @@ use std::collections::HashMap; use std::sync::Arc; use std::time::Duration; -use async_trait::async_trait; -use edgezero_core::body::Body as EdgeBody; -use error_stack::{Report, ResultExt}; -use http::header::HeaderValue; -use http::{header, Method, StatusCode}; -use serde::{Deserialize, Serialize}; -use serde_json::Value as Json; -use url::Url; -use validator::Validate; - use crate::auction::provider::AuctionProvider; use crate::auction::types::{ AuctionContext, AuctionRequest, AuctionResponse, Bid as AuctionBid, MediaType, @@ -35,10 +25,32 @@ use crate::openrtb::{ use crate::platform::{ PlatformHttpRequest, PlatformPendingRequest, PlatformResponse, RuntimeServices, }; +use crate::proxy::{is_host_allowed, proxy_request, ProxyRequestConfig}; use crate::request_signing::{RequestSigner, SigningParams, SIGNING_VERSION}; use crate::settings::{IntegrationConfig, Settings}; +use async_trait::async_trait; +use base64::{ + engine::general_purpose::{ + STANDARD as BASE64_STANDARD, STANDARD_NO_PAD as BASE64_STANDARD_NO_PAD, + }, + Engine as _, +}; +use edgezero_core::body::Body as EdgeBody; +use error_stack::{Report, ResultExt}; +use http::header::HeaderValue; +use http::{header, Method, StatusCode}; +use serde::{Deserialize, Serialize}; +use serde_json::Value as Json; +use url::Url; +use validator::{Validate, ValidationError}; const PREBID_INTEGRATION_ID: &str = "prebid"; +const PREBID_BUNDLE_ROUTE: &str = "/integrations/prebid/bundle.js"; +const PREBID_BUNDLE_CONTENT_TYPE: &str = "application/javascript; charset=utf-8"; +const PREBID_BUNDLE_IMMUTABLE_CACHE_CONTROL: &str = "public, max-age=31536000, immutable"; +const PREBID_BUNDLE_REVALIDATION_CACHE_CONTROL: &str = + "public, max-age=300, s-maxage=300, stale-while-revalidate=60, stale-if-error=86400"; +const PREBID_BUNDLE_ERROR_CACHE_CONTROL: &str = "no-store"; const TRUSTED_SERVER_BIDDER: &str = "trustedServer"; const BIDDER_PARAMS_KEY: &str = "bidderParams"; const ZONE_KEY: &str = "zone"; @@ -71,6 +83,17 @@ fn prebid_body_preview(body: &[u8]) -> String { #[cfg(test)] const GPC_US_PRIVACY: &str = "1YYN"; +/// Controls how the browser loads Prebid.js for the integration. +#[derive(Debug, Default, Copy, Clone, Deserialize, Serialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum PrebidBundleMode { + /// Load the embedded TSJS Prebid bundle served from `/static/tsjs=`. + #[default] + Embedded, + /// Load a configured external Prebid bundle through a first-party route. + ManagedExternal, +} + #[derive(Debug, Clone, Deserialize, Serialize, Validate)] pub struct PrebidIntegrationConfig { #[serde(default = "default_enabled")] @@ -107,6 +130,22 @@ pub struct PrebidIntegrationConfig { deserialize_with = "crate::settings::vec_from_seq_or_map" )] pub script_patterns: Vec, + /// Controls whether Prebid loads from the embedded TSJS module or an + /// external managed bundle proxied through a first-party route. + #[serde(default)] + pub bundle_mode: PrebidBundleMode, + /// Absolute HTTPS URL of the generated external Prebid bundle. + #[serde(default)] + #[validate(custom(function = "validate_external_bundle_url"))] + pub external_bundle_url: Option, + /// Optional hex SHA-256 of the exact external bundle bytes. + #[serde(default)] + #[validate(custom(function = "validate_external_bundle_sha256"))] + pub external_bundle_sha256: Option, + /// Optional browser Subresource Integrity value for the first-party script. + #[serde(default)] + #[validate(custom(function = "validate_external_bundle_sri"))] + pub external_bundle_sri: Option, /// Bidders that should run client-side in the browser via native Prebid.js /// adapters instead of being routed through the server-side auction. /// @@ -266,6 +305,213 @@ fn default_script_patterns() -> Vec { .collect() } +fn validate_external_bundle_url(value: &str) -> Result<(), ValidationError> { + let url = Url::parse(value).map_err(|_| { + let mut err = ValidationError::new("invalid_external_bundle_url"); + err.message = Some("external_bundle_url must be a valid absolute URL".into()); + err + })?; + + if url.scheme() != "https" { + let mut err = ValidationError::new("invalid_external_bundle_scheme"); + err.message = Some("external_bundle_url must use https".into()); + return Err(err); + } + + if url.host_str().is_none() { + let mut err = ValidationError::new("missing_external_bundle_host"); + err.message = Some("external_bundle_url must include a host".into()); + return Err(err); + } + + Ok(()) +} + +fn validate_external_bundle_sha256(value: &str) -> Result<(), ValidationError> { + if value.len() == 64 && value.bytes().all(|byte| byte.is_ascii_hexdigit()) { + return Ok(()); + } + + let mut err = ValidationError::new("invalid_external_bundle_sha256"); + err.message = Some("external_bundle_sha256 must be a 64-character hex SHA-256".into()); + Err(err) +} + +#[derive(Debug, Copy, Clone, PartialEq, Eq)] +enum ExternalBundleSriAlgorithm { + Sha256, + Sha384, + Sha512, +} + +impl ExternalBundleSriAlgorithm { + fn parse(value: &str) -> Option { + match value { + "sha256" => Some(Self::Sha256), + "sha384" => Some(Self::Sha384), + "sha512" => Some(Self::Sha512), + _ => None, + } + } + + fn expected_digest_len(self) -> usize { + match self { + Self::Sha256 => 32, + Self::Sha384 => 48, + Self::Sha512 => 64, + } + } +} + +#[derive(Debug, Clone)] +struct ExternalBundleSriToken { + algorithm: ExternalBundleSriAlgorithm, + digest: Vec, +} + +fn external_bundle_sri_validation_error(message: &'static str) -> ValidationError { + let mut err = ValidationError::new("invalid_external_bundle_sri"); + err.message = Some(message.into()); + err +} + +fn parse_external_bundle_sri(value: &str) -> Result, ValidationError> { + let trimmed = value.trim(); + if trimmed.is_empty() || trimmed != value { + return Err(external_bundle_sri_validation_error( + "external_bundle_sri must be non-empty with no surrounding whitespace", + )); + } + + let mut tokens = Vec::new(); + for token in trimmed.split_ascii_whitespace() { + let Some((algorithm_raw, digest_raw)) = token.split_once('-') else { + return Err(external_bundle_sri_validation_error( + "external_bundle_sri entries must use algorithm-digest format", + )); + }; + + let Some(algorithm) = ExternalBundleSriAlgorithm::parse(algorithm_raw) else { + return Err(external_bundle_sri_validation_error( + "external_bundle_sri must use sha256, sha384, or sha512", + )); + }; + + if digest_raw.is_empty() { + return Err(external_bundle_sri_validation_error( + "external_bundle_sri digest must be non-empty", + )); + } + + let digest = BASE64_STANDARD + .decode(digest_raw) + .or_else(|_| BASE64_STANDARD_NO_PAD.decode(digest_raw)) + .map_err(|_| { + external_bundle_sri_validation_error("external_bundle_sri digest must be base64") + })?; + + if digest.len() != algorithm.expected_digest_len() { + return Err(external_bundle_sri_validation_error( + "external_bundle_sri digest length does not match its algorithm", + )); + } + + tokens.push(ExternalBundleSriToken { algorithm, digest }); + } + + Ok(tokens) +} + +fn validate_external_bundle_sri(value: &str) -> Result<(), ValidationError> { + parse_external_bundle_sri(value).map(|_| ()) +} + +fn external_bundle_sri_configuration_message(err: ValidationError) -> String { + let detail = err + .message + .map(std::borrow::Cow::into_owned) + .unwrap_or_else(|| err.code.into_owned()); + format!("integrations.prebid.external_bundle_sri is invalid: {detail}") +} + +fn validate_managed_external_bundle_config( + config: &PrebidIntegrationConfig, + allowed_domains: &[String], +) -> Result<(), Report> { + if config.bundle_mode != PrebidBundleMode::ManagedExternal { + return Ok(()); + } + + let url = config.external_bundle_url.as_deref().ok_or_else(|| { + Report::new(TrustedServerError::Configuration { + message: "integrations.prebid.external_bundle_url is required when bundle_mode is managed_external".to_string(), + }) + })?; + + let parsed = Url::parse(url).map_err(|_| { + Report::new(TrustedServerError::Configuration { + message: "integrations.prebid.external_bundle_url must be a valid absolute URL" + .to_string(), + }) + })?; + + if parsed.scheme() != "https" { + return Err(Report::new(TrustedServerError::Configuration { + message: "integrations.prebid.external_bundle_url must use https".to_string(), + })); + } + + let host = parsed.host_str().ok_or_else(|| { + Report::new(TrustedServerError::Configuration { + message: "integrations.prebid.external_bundle_url must include a host".to_string(), + }) + })?; + + if !allowed_domains.is_empty() + && !allowed_domains + .iter() + .any(|pattern| is_host_allowed(host, pattern)) + { + return Err(Report::new(TrustedServerError::Configuration { + message: format!( + "integrations.prebid.external_bundle_url host `{host}` is not permitted by proxy.allowed_domains" + ), + })); + } + + if let Some(expected_sha256) = config.external_bundle_sha256.as_deref() { + let sri = config.external_bundle_sri.as_deref().ok_or_else(|| { + Report::new(TrustedServerError::Configuration { + message: "integrations.prebid.external_bundle_sri is required when external_bundle_sha256 is configured".to_string(), + }) + })?; + + let expected_digest = hex::decode(expected_sha256).map_err(|_| { + Report::new(TrustedServerError::Configuration { + message: + "integrations.prebid.external_bundle_sha256 must be a 64-character hex SHA-256" + .to_string(), + }) + })?; + let sri_tokens = parse_external_bundle_sri(sri).map_err(|err| { + Report::new(TrustedServerError::Configuration { + message: external_bundle_sri_configuration_message(err), + }) + })?; + + if sri_tokens.iter().any(|token| { + token.algorithm == ExternalBundleSriAlgorithm::Sha256 + && token.digest.as_slice() != expected_digest.as_slice() + }) { + return Err(Report::new(TrustedServerError::Configuration { + message: "integrations.prebid.external_bundle_sri sha256 digest must match external_bundle_sha256".to_string(), + })); + } + } + + Ok(()) +} + pub struct PrebidIntegration { config: PrebidIntegrationConfig, engine: Arc, @@ -362,16 +608,185 @@ impl PrebidIntegration { http::Response::builder() .status(StatusCode::OK) - .header( - header::CONTENT_TYPE, - "application/javascript; charset=utf-8", - ) + .header(header::CONTENT_TYPE, PREBID_BUNDLE_CONTENT_TYPE) .header(header::CACHE_CONTROL, "public, max-age=31536000") .body(EdgeBody::from(body)) .change_context(TrustedServerError::Prebid { message: "Failed to build Prebid script handler response".to_string(), }) } + + fn is_managed_external(&self) -> bool { + self.config.bundle_mode == PrebidBundleMode::ManagedExternal + } + + fn external_bundle_script_src(&self) -> String { + match self.config.external_bundle_sha256.as_deref() { + Some(sha256) => format!("{PREBID_BUNDLE_ROUTE}?v={sha256}"), + None => PREBID_BUNDLE_ROUTE.to_string(), + } + } + + fn external_bundle_script_tag(&self) -> String { + let src = self.external_bundle_script_src(); + let integrity = self + .config + .external_bundle_sri + .as_deref() + .map(|value| format!(" integrity=\"{}\"", escape_html_attr(value))) + .unwrap_or_default(); + + format!("") + } + + fn external_bundle_request_cache_mode( + &self, + req: &http::Request, + ) -> Result, Report> { + let versions = req + .uri() + .query() + .map(|query| { + url::form_urlencoded::parse(query.as_bytes()) + .filter(|(key, _)| key == "v") + .map(|(_, value)| value.into_owned()) + .collect::>() + }) + .unwrap_or_default(); + + if versions.len() > 1 { + return Ok(None); + } + + let requested_version = versions.first().map(String::as_str); + match ( + self.config.external_bundle_sha256.as_deref(), + requested_version, + ) { + (None, Some(_)) => Ok(None), + (Some(expected), Some(actual)) if expected != actual => Ok(None), + (Some(_), Some(_)) if self.config.external_bundle_sri.is_some() => { + Ok(Some(ExternalBundleCacheMode::Immutable)) + } + _ => Ok(Some(ExternalBundleCacheMode::Revalidate)), + } + } + + fn apply_external_bundle_headers( + &self, + response: &mut http::Response, + mode: ExternalBundleCacheMode, + ) { + response.headers_mut().insert( + header::CONTENT_TYPE, + HeaderValue::from_static(PREBID_BUNDLE_CONTENT_TYPE), + ); + + match mode { + ExternalBundleCacheMode::Immutable => { + response.headers_mut().insert( + header::CACHE_CONTROL, + HeaderValue::from_static(PREBID_BUNDLE_IMMUTABLE_CACHE_CONTROL), + ); + if let Some(sha256) = self.config.external_bundle_sha256.as_deref() { + response.headers_mut().insert( + header::ETAG, + HeaderValue::from_str(&format!("\"sha256:{sha256}\"")) + .expect("should build etag header"), + ); + } + } + ExternalBundleCacheMode::Revalidate => { + response.headers_mut().insert( + header::CACHE_CONTROL, + HeaderValue::from_static(PREBID_BUNDLE_REVALIDATION_CACHE_CONTROL), + ); + if let Some(sha256) = self.config.external_bundle_sha256.as_deref() { + response.headers_mut().insert( + header::ETAG, + HeaderValue::from_str(&format!("\"sha256:{sha256}\"")) + .expect("should build etag header"), + ); + } + } + } + } + + fn sanitize_external_bundle_response( + &self, + response: http::Response, + mode: ExternalBundleCacheMode, + ) -> http::Response { + let status = response.status(); + let content_encoding = response.headers().get(header::CONTENT_ENCODING).cloned(); + let body = response.into_body(); + + let mut sanitized = http::Response::builder() + .status(status) + .body(body) + .expect("should build sanitized response"); + + if let Some(content_encoding) = content_encoding { + sanitized + .headers_mut() + .insert(header::CONTENT_ENCODING, content_encoding); + } + + if status == StatusCode::OK { + self.apply_external_bundle_headers(&mut sanitized, mode); + } else { + sanitized.headers_mut().insert( + header::CACHE_CONTROL, + HeaderValue::from_static(PREBID_BUNDLE_ERROR_CACHE_CONTROL), + ); + } + + sanitized + } + + async fn handle_external_bundle( + &self, + settings: &Settings, + services: &RuntimeServices, + req: http::Request, + ) -> Result, Report> { + let Some(cache_mode) = self.external_bundle_request_cache_mode(&req)? else { + return Ok(http::Response::builder() + .status(StatusCode::NOT_FOUND) + .body(EdgeBody::from("Not Found")) + .expect("should build not found response")); + }; + + let target_url = self.config.external_bundle_url.as_deref().ok_or_else(|| { + Report::new(TrustedServerError::Configuration { + message: "integrations.prebid.external_bundle_url is required when bundle_mode is managed_external".to_string(), + }) + })?; + + let proxy_config = ProxyRequestConfig::new(target_url) + .without_ec_id() + .without_forward_headers() + .with_streaming() + .with_allowed_domains(&settings.proxy.allowed_domains) + .with_https_only(); + + let response = proxy_request(settings, req, proxy_config, services).await?; + Ok(self.sanitize_external_bundle_response(response, cache_mode)) + } +} + +#[derive(Debug, Copy, Clone, PartialEq, Eq)] +enum ExternalBundleCacheMode { + Immutable, + Revalidate, +} + +fn escape_html_attr(value: &str) -> String { + value + .replace('&', "&") + .replace('"', """) + .replace('<', "<") + .replace('>', ">") } fn build( @@ -383,6 +798,8 @@ fn build( return Ok(None); }; + validate_managed_external_bundle_config(&config, &settings.proxy.allowed_domains)?; + // Warn about bidders that appear in both lists — this is likely a config // mistake. A bidder should be in either `bidders` (server-side) or // `client_side_bidders` (browser-side), not both. @@ -412,14 +829,18 @@ pub fn register( return Ok(None); }; - Ok(Some( - IntegrationRegistration::builder(PREBID_INTEGRATION_ID) - .with_proxy(integration.clone()) - .with_attribute_rewriter(integration.clone()) - .with_head_injector(integration) - .with_deferred_js() - .build(), - )) + let mut registration = IntegrationRegistration::builder(PREBID_INTEGRATION_ID) + .with_proxy(integration.clone()) + .with_attribute_rewriter(integration.clone()) + .with_head_injector(integration.clone()); + + if integration.is_managed_external() { + registration = registration.without_js(); + } else { + registration = registration.with_deferred_js(); + } + + Ok(Some(registration.build())) } #[async_trait(?Send)] @@ -431,6 +852,10 @@ impl IntegrationProxy for PrebidIntegration { fn routes(&self) -> Vec { let mut routes = vec![]; + if self.is_managed_external() { + routes.push(self.get("/bundle.js")); + } + // Register routes for script removal patterns // Patterns can be exact paths (e.g., "/prebid.min.js") or use matchit wildcards // (e.g., "/static/prebid/{*rest}") @@ -446,14 +871,17 @@ impl IntegrationProxy for PrebidIntegration { async fn handle( &self, - _settings: &Settings, - _services: &RuntimeServices, + settings: &Settings, + services: &RuntimeServices, req: http::Request, ) -> Result, Report> { let path = req.uri().path().to_string(); let method = req.method().clone(); match method { + Method::GET if self.is_managed_external() && path == PREBID_BUNDLE_ROUTE => { + self.handle_external_bundle(settings, services, req).await + } // Serve empty JS for matching script patterns Method::GET if self.matches_script_pattern(&path) => self.handle_script_handler(), _ => http::Response::builder() @@ -522,9 +950,15 @@ impl IntegrationHeadInjector for PrebidIntegration { }) .replace("window.pbjs=window.pbjs||{{}};window.pbjs.que=window.pbjs.que||[];window.pbjs.cmd=window.pbjs.cmd||[];window.__tsjs_prebid={config_json};"# - )] + )]; + + if self.is_managed_external() { + inserts.push(self.external_bundle_script_tag()); + } + + inserts } } @@ -1722,6 +2156,7 @@ mod tests { use crate::settings::Settings; use crate::streaming_processor::{Compression, PipelineConfig, StreamingPipeline}; use crate::test_support::tests::crate_test_settings_str; + use base64::engine::general_purpose::STANDARD as TEST_BASE64_STANDARD; use http::Method; use serde_json::json; use std::collections::HashMap; @@ -1742,6 +2177,10 @@ mod tests { test_mode: false, debug_query_params: None, script_patterns: default_script_patterns(), + bundle_mode: PrebidBundleMode::Embedded, + external_bundle_url: None, + external_bundle_sha256: None, + external_bundle_sri: None, client_side_bidders: Vec::new(), bid_param_zone_overrides: HashMap::default(), bid_param_overrides: HashMap::default(), @@ -1802,6 +2241,40 @@ mod tests { ); } + fn test_sri(algorithm: &str, digest: &[u8]) -> String { + format!("{algorithm}-{}", TEST_BASE64_STANDARD.encode(digest)) + } + + fn test_request(url: impl AsRef) -> http::Request { + http::Request::builder() + .method(http::Method::GET) + .uri(url.as_ref()) + .body(EdgeBody::empty()) + .expect("should build request") + } + + fn header_value_str(response: &http::Response, name: &str) -> Option { + response + .headers() + .get(name) + .and_then(|value| value.to_str().ok().map(std::string::ToString::to_string)) + } + + fn response_header_is_present(response: &http::Response, name: &str) -> bool { + response.headers().contains_key(name) + } + + fn response_body_string(response: http::Response) -> String { + String::from_utf8( + response + .into_body() + .into_bytes() + .unwrap_or_default() + .to_vec(), + ) + .expect("should parse response body as utf-8") + } + fn create_test_auction_request() -> AuctionRequest { AuctionRequest { id: "auction-123".to_string(), @@ -2126,6 +2599,247 @@ server_url = "https://prebid.example" .contains(&"/prebid.min.js".to_string())); } + #[test] + fn managed_external_config_parses_with_optional_hash_metadata() { + let config = parse_prebid_toml( + r#" +[integrations.prebid] +enabled = true +server_url = "https://prebid.example" +bundle_mode = "managed_external" +external_bundle_url = "https://assets.example/prebid/trusted-prebid.js" +"#, + ); + + assert_eq!( + config.bundle_mode, + PrebidBundleMode::ManagedExternal, + "should parse managed external mode" + ); + assert_eq!( + config.external_bundle_url.as_deref(), + Some("https://assets.example/prebid/trusted-prebid.js"), + "should preserve configured external bundle URL" + ); + assert!( + config.external_bundle_sha256.is_none(), + "SHA-256 should be optional" + ); + } + + #[test] + fn managed_external_config_rejects_malformed_hash_metadata() { + let err = parse_prebid_toml_result( + r#" +[integrations.prebid] +enabled = true +server_url = "https://prebid.example" +bundle_mode = "managed_external" +external_bundle_url = "https://assets.example/prebid/trusted-prebid.js" +external_bundle_sha256 = "not-a-sha" +"#, + ) + .expect_err("should reject malformed SHA-256"); + + assert!( + err.to_string().contains("external_bundle_sha256"), + "error should mention malformed SHA-256: {err:?}" + ); + } + + #[test] + fn managed_external_config_rejects_non_https_bundle_url() { + let err = parse_prebid_toml_result( + r#" +[integrations.prebid] +enabled = true +server_url = "https://prebid.example" +bundle_mode = "managed_external" +external_bundle_url = "http://assets.example/prebid/trusted-prebid.js" +"#, + ) + .expect_err("should reject non-HTTPS external bundle URL"); + + assert!( + err.to_string().contains("external_bundle_url"), + "error should mention external bundle URL: {err:?}" + ); + } + + #[test] + fn managed_external_config_rejects_invalid_sri_base64() { + let err = parse_prebid_toml_result( + r#" +[integrations.prebid] +enabled = true +server_url = "https://prebid.example" +bundle_mode = "managed_external" +external_bundle_url = "https://assets.example/prebid/trusted-prebid.js" +external_bundle_sri = "sha384-not-valid!!!" +"#, + ) + .expect_err("should reject invalid SRI base64"); + + assert!( + err.to_string().contains("external_bundle_sri"), + "error should mention external bundle SRI: {err:?}" + ); + } + + #[test] + fn managed_external_config_rejects_sri_with_wrong_digest_length() { + let err = parse_prebid_toml_result( + r#" +[integrations.prebid] +enabled = true +server_url = "https://prebid.example" +bundle_mode = "managed_external" +external_bundle_url = "https://assets.example/prebid/trusted-prebid.js" +external_bundle_sri = "sha384-AAAA" +"#, + ) + .expect_err("should reject SRI with wrong digest length"); + + assert!( + err.to_string().contains("external_bundle_sri"), + "error should mention external bundle SRI: {err:?}" + ); + } + + #[test] + fn managed_external_registration_requires_sri_when_sha256_is_configured() { + let mut settings = make_settings(); + settings + .integrations + .insert_config( + "prebid", + &json!({ + "enabled": true, + "server_url": "https://prebid.example/openrtb2/auction", + "bundle_mode": "managed_external", + "external_bundle_url": "https://assets.example/prebid/trusted-prebid.js", + "external_bundle_sha256": "0".repeat(64) + }), + ) + .expect("should update prebid config"); + + let err = match IntegrationRegistry::new(&settings) { + Ok(_) => panic!("should reject missing SRI for hashed bundle"), + Err(err) => err, + }; + assert!( + err.to_string().contains("external_bundle_sri"), + "error should mention required external bundle SRI: {err:?}" + ); + } + + #[test] + fn managed_external_registration_allows_sha256_with_valid_sha384_sri() { + let mut settings = make_settings(); + settings + .integrations + .insert_config( + "prebid", + &json!({ + "enabled": true, + "server_url": "https://prebid.example/openrtb2/auction", + "bundle_mode": "managed_external", + "external_bundle_url": "https://assets.example/prebid/trusted-prebid.js", + "external_bundle_sha256": "0".repeat(64), + "external_bundle_sri": test_sri("sha384", &[0; 48]) + }), + ) + .expect("should update prebid config"); + + let registry = IntegrationRegistry::new(&settings) + .expect("should create registry with valid SHA-256 and SHA-384 SRI"); + + assert!( + registry.has_route(&Method::GET, PREBID_BUNDLE_ROUTE), + "should register managed external bundle route" + ); + } + + #[test] + fn managed_external_registration_rejects_mismatched_sha256_sri() { + let mut settings = make_settings(); + settings + .integrations + .insert_config( + "prebid", + &json!({ + "enabled": true, + "server_url": "https://prebid.example/openrtb2/auction", + "bundle_mode": "managed_external", + "external_bundle_url": "https://assets.example/prebid/trusted-prebid.js", + "external_bundle_sha256": "0".repeat(64), + "external_bundle_sri": test_sri("sha256", &[1; 32]) + }), + ) + .expect("should update prebid config"); + + let err = match IntegrationRegistry::new(&settings) { + Ok(_) => panic!("should reject mismatched SHA-256 SRI"), + Err(err) => err, + }; + assert!( + err.to_string().contains("external_bundle_sri"), + "error should mention external bundle SRI mismatch: {err:?}" + ); + } + + #[test] + fn managed_external_registration_requires_bundle_url() { + let mut settings = make_settings(); + settings + .integrations + .insert_config( + "prebid", + &json!({ + "enabled": true, + "server_url": "https://prebid.example/openrtb2/auction", + "bundle_mode": "managed_external" + }), + ) + .expect("should update prebid config"); + + let err = match IntegrationRegistry::new(&settings) { + Ok(_) => panic!("should reject missing URL"), + Err(err) => err, + }; + assert!( + err.to_string().contains("external_bundle_url"), + "error should mention missing external bundle URL: {err:?}" + ); + } + + #[test] + fn managed_external_registration_uses_proxy_allowed_domains() { + let mut settings = make_settings(); + settings.proxy.allowed_domains = vec!["allowed.example".to_string()]; + settings + .integrations + .insert_config( + "prebid", + &json!({ + "enabled": true, + "server_url": "https://prebid.example/openrtb2/auction", + "bundle_mode": "managed_external", + "external_bundle_url": "https://blocked.example/prebid/trusted-prebid.js" + }), + ) + .expect("should update prebid config"); + + let err = match IntegrationRegistry::new(&settings) { + Ok(_) => panic!("should reject bundle host outside proxy.allowed_domains"), + Err(err) => err, + }; + assert!( + err.to_string().contains("proxy.allowed_domains"), + "error should mention proxy.allowed_domains: {err:?}" + ); + } + #[test] fn script_handler_returns_empty_js() { let integration = PrebidIntegration::new(base_config()); @@ -2161,6 +2875,259 @@ server_url = "https://prebid.example" assert!(body.contains("// Script overridden by Trusted Server")); } + #[test] + fn external_bundle_request_cache_mode_validates_version_query() { + let sha256 = "a".repeat(64); + let mut config = base_config(); + config.bundle_mode = PrebidBundleMode::ManagedExternal; + config.external_bundle_url = + Some("https://assets.example/prebid/trusted-prebid.js".to_string()); + config.external_bundle_sha256 = Some(sha256.clone()); + config.external_bundle_sri = Some(test_sri("sha384", &[0; 48])); + let integration = PrebidIntegration::new(config); + + let versioned_req = test_request(format!( + "https://pub.example{PREBID_BUNDLE_ROUTE}?v={sha256}" + )); + let missing_version_req = test_request(format!("https://pub.example{PREBID_BUNDLE_ROUTE}")); + let mismatched_req = test_request(format!( + "https://pub.example{PREBID_BUNDLE_ROUTE}?v={}", + "b".repeat(64) + )); + + assert_eq!( + integration + .external_bundle_request_cache_mode(&versioned_req) + .expect("should parse versioned request"), + Some(ExternalBundleCacheMode::Immutable), + "matching v query should use immutable cache mode" + ); + assert_eq!( + integration + .external_bundle_request_cache_mode(&missing_version_req) + .expect("should parse unversioned request"), + Some(ExternalBundleCacheMode::Revalidate), + "missing v query should use revalidation cache mode" + ); + assert_eq!( + integration + .external_bundle_request_cache_mode(&mismatched_req) + .expect("should parse mismatched request"), + None, + "mismatched v query should 404" + ); + } + + #[test] + fn external_bundle_request_cache_mode_rejects_version_when_hash_is_absent() { + let mut config = base_config(); + config.bundle_mode = PrebidBundleMode::ManagedExternal; + config.external_bundle_url = + Some("https://assets.example/prebid/trusted-prebid.js".to_string()); + let integration = PrebidIntegration::new(config); + + let versioned_req = test_request(format!( + "https://pub.example{PREBID_BUNDLE_ROUTE}?v={}", + "a".repeat(64) + )); + let unversioned_req = test_request(format!("https://pub.example{PREBID_BUNDLE_ROUTE}")); + + assert_eq!( + integration + .external_bundle_request_cache_mode(&versioned_req) + .expect("should parse versioned request"), + None, + "v query should 404 when SHA-256 is omitted" + ); + assert_eq!( + integration + .external_bundle_request_cache_mode(&unversioned_req) + .expect("should parse unversioned request"), + Some(ExternalBundleCacheMode::Revalidate), + "unversioned request should be served with revalidation cache mode" + ); + } + + #[test] + fn external_bundle_headers_use_cache_policy_for_mode() { + let sha256 = "a".repeat(64); + let mut config = base_config(); + config.bundle_mode = PrebidBundleMode::ManagedExternal; + config.external_bundle_url = + Some("https://assets.example/prebid/trusted-prebid.js".to_string()); + config.external_bundle_sha256 = Some(sha256.clone()); + config.external_bundle_sri = Some(test_sri("sha384", &[0; 48])); + let integration = PrebidIntegration::new(config); + + let mut immutable = http::Response::builder() + .status(StatusCode::OK) + .body(EdgeBody::empty()) + .expect("should build response"); + integration + .apply_external_bundle_headers(&mut immutable, ExternalBundleCacheMode::Immutable); + assert_eq!( + header_value_str(&immutable, "content-type"), + Some(PREBID_BUNDLE_CONTENT_TYPE.to_string()), + "should normalize JS content type" + ); + assert_eq!( + header_value_str(&immutable, "cache-control"), + Some(PREBID_BUNDLE_IMMUTABLE_CACHE_CONTROL.to_string()), + "versioned responses should be immutable" + ); + assert_eq!( + header_value_str(&immutable, "etag"), + Some(format!("\"sha256:{sha256}\"")), + "should emit configured hash ETag" + ); + + let mut revalidate = http::Response::builder() + .status(StatusCode::OK) + .body(EdgeBody::empty()) + .expect("should build response"); + integration + .apply_external_bundle_headers(&mut revalidate, ExternalBundleCacheMode::Revalidate); + assert_eq!( + header_value_str(&revalidate, "cache-control"), + Some(PREBID_BUNDLE_REVALIDATION_CACHE_CONTROL.to_string()), + "unversioned responses should use short-lived revalidation" + ); + } + + #[test] + fn external_bundle_response_sanitization_uses_header_whitelist_for_ok_response() { + let sha256 = "a".repeat(64); + let mut config = base_config(); + config.bundle_mode = PrebidBundleMode::ManagedExternal; + config.external_bundle_url = + Some("https://assets.example/prebid/trusted-prebid.js".to_string()); + config.external_bundle_sha256 = Some(sha256.clone()); + config.external_bundle_sri = Some(test_sri("sha384", &[0; 48])); + let integration = PrebidIntegration::new(config); + + let mut upstream = http::Response::builder() + .status(StatusCode::OK) + .body(EdgeBody::from("console.log('ok');")) + .expect("should build upstream response"); + upstream + .headers_mut() + .insert(header::CONTENT_TYPE, HeaderValue::from_static("text/html")); + upstream.headers_mut().insert( + header::CACHE_CONTROL, + HeaderValue::from_static("private, max-age=0"), + ); + upstream.headers_mut().insert( + header::SET_COOKIE, + HeaderValue::from_static("bad=1; Path=/"), + ); + upstream + .headers_mut() + .insert(header::CONTENT_ENCODING, HeaderValue::from_static("gzip")); + upstream + .headers_mut() + .insert(header::CONTENT_LENGTH, HeaderValue::from_static("16")); + upstream.headers_mut().insert( + header::HeaderName::from_static("x-upstream"), + HeaderValue::from_static("leak"), + ); + + let sanitized = integration + .sanitize_external_bundle_response(upstream, ExternalBundleCacheMode::Immutable); + + assert_eq!( + header_value_str(&sanitized, "content-type"), + Some(PREBID_BUNDLE_CONTENT_TYPE.to_string()), + "should normalize JS content type" + ); + assert_eq!( + header_value_str(&sanitized, "cache-control"), + Some(PREBID_BUNDLE_IMMUTABLE_CACHE_CONTROL.to_string()), + "should apply trusted cache policy" + ); + assert_eq!( + header_value_str(&sanitized, "etag"), + Some(format!("\"sha256:{sha256}\"")), + "should emit trusted ETag" + ); + assert_eq!( + header_value_str(&sanitized, "content-encoding"), + Some("gzip".to_string()), + "should preserve body encoding metadata" + ); + assert!( + !response_header_is_present(&sanitized, "content-length"), + "should strip upstream content length so the platform can derive it from the body" + ); + assert!( + !response_header_is_present(&sanitized, "set-cookie"), + "should strip upstream Set-Cookie" + ); + assert!( + !response_header_is_present(&sanitized, "x-upstream"), + "should strip arbitrary upstream headers" + ); + assert_eq!( + response_body_string(sanitized), + "console.log('ok');", + "should preserve body bytes" + ); + } + + #[test] + fn external_bundle_response_sanitization_strips_headers_for_error_response() { + let mut config = base_config(); + config.bundle_mode = PrebidBundleMode::ManagedExternal; + config.external_bundle_url = + Some("https://assets.example/prebid/trusted-prebid.js".to_string()); + let integration = PrebidIntegration::new(config); + + let mut upstream = http::Response::builder() + .status(StatusCode::NOT_FOUND) + .body(EdgeBody::from("missing")) + .expect("should build upstream response"); + upstream + .headers_mut() + .insert(header::CONTENT_TYPE, HeaderValue::from_static("text/html")); + upstream.headers_mut().insert( + header::CACHE_CONTROL, + HeaderValue::from_static("public, max-age=31536000"), + ); + upstream.headers_mut().insert( + header::SET_COOKIE, + HeaderValue::from_static("bad=1; Path=/"), + ); + upstream.headers_mut().insert( + header::HeaderName::from_static("x-upstream"), + HeaderValue::from_static("leak"), + ); + + let sanitized = integration + .sanitize_external_bundle_response(upstream, ExternalBundleCacheMode::Revalidate); + + assert_eq!( + sanitized.status(), + StatusCode::NOT_FOUND, + "should preserve upstream status" + ); + assert_eq!( + header_value_str(&sanitized, "cache-control"), + Some(PREBID_BUNDLE_ERROR_CACHE_CONTROL.to_string()), + "should prevent caching upstream error responses" + ); + assert!( + !response_header_is_present(&sanitized, "content-type"), + "should strip upstream content type on error responses" + ); + assert!( + !response_header_is_present(&sanitized, "set-cookie"), + "should strip upstream Set-Cookie on error responses" + ); + assert!( + !response_header_is_present(&sanitized, "x-upstream"), + "should strip arbitrary upstream headers on error responses" + ); + } + #[test] fn routes_include_script_patterns() { let integration = PrebidIntegration::new(base_config()); @@ -2182,6 +3149,28 @@ server_url = "https://prebid.example" has_prebid_min_js_route, "should register /prebid.min.js route" ); + assert!( + !routes.iter().any(|r| r.path == PREBID_BUNDLE_ROUTE), + "embedded mode should not register the external bundle route" + ); + } + + #[test] + fn routes_include_bundle_route_for_managed_external_mode() { + let mut config = base_config(); + config.bundle_mode = PrebidBundleMode::ManagedExternal; + config.external_bundle_url = + Some("https://assets.example/prebid/trusted-prebid.js".to_string()); + let integration = PrebidIntegration::new(config); + + let routes = integration.routes(); + + assert!( + routes + .iter() + .any(|r| r.path == PREBID_BUNDLE_ROUTE && r.method == Method::GET), + "managed external mode should register the bundle route" + ); } #[test] @@ -2247,6 +3236,74 @@ server_url = "https://prebid.example" ); } + #[test] + fn head_injector_emits_managed_external_bundle_script_with_hash_and_integrity() { + let sha256 = "a".repeat(64); + let mut config = base_config(); + config.bundle_mode = PrebidBundleMode::ManagedExternal; + config.external_bundle_url = + Some("https://assets.example/prebid/trusted-prebid.js".to_string()); + config.external_bundle_sha256 = Some(sha256.clone()); + config.external_bundle_sri = Some(test_sri("sha384", &[0; 48])); + let integration = PrebidIntegration::new(config); + let document_state = IntegrationDocumentState::default(); + let ctx = IntegrationHtmlContext { + request_host: "pub.example", + request_scheme: "https", + origin_host: "origin.example", + document_state: &document_state, + }; + + let inserts = integration.head_inserts(&ctx); + + assert_eq!(inserts.len(), 2, "should emit config and bundle scripts"); + assert!( + inserts[1].contains(&format!("src=\"{PREBID_BUNDLE_ROUTE}?v={sha256}\"")), + "bundle script should use content-addressed first-party URL: {}", + inserts[1] + ); + assert!( + inserts[1].contains("integrity=\"sha384-"), + "bundle script should include configured SRI: {}", + inserts[1] + ); + assert!( + !inserts[1].contains("crossorigin"), + "same-origin bundle script should not include crossorigin: {}", + inserts[1] + ); + } + + #[test] + fn head_injector_emits_managed_external_bundle_script_without_hash_query_when_unhashed() { + let mut config = base_config(); + config.bundle_mode = PrebidBundleMode::ManagedExternal; + config.external_bundle_url = + Some("https://assets.example/prebid/trusted-prebid.js".to_string()); + let integration = PrebidIntegration::new(config); + let document_state = IntegrationDocumentState::default(); + let ctx = IntegrationHtmlContext { + request_host: "pub.example", + request_scheme: "https", + origin_host: "origin.example", + document_state: &document_state, + }; + + let inserts = integration.head_inserts(&ctx); + + assert_eq!(inserts.len(), 2, "should emit config and bundle scripts"); + assert!( + inserts[1].contains(&format!("src=\"{PREBID_BUNDLE_ROUTE}\"")), + "bundle script should use first-party route without hash query: {}", + inserts[1] + ); + assert!( + !inserts[1].contains("?v="), + "unhashed bundle script should not include version query: {}", + inserts[1] + ); + } + #[test] fn head_injector_escapes_closing_script_tags_in_values() { let mut config = base_config(); diff --git a/crates/trusted-server-core/src/integrations/registry.rs b/crates/trusted-server-core/src/integrations/registry.rs index 9cfe24063..af1d8f350 100644 --- a/crates/trusted-server-core/src/integrations/registry.rs +++ b/crates/trusted-server-core/src/integrations/registry.rs @@ -582,6 +582,7 @@ pub trait IntegrationHeadInjector: Send + Sync { pub struct IntegrationRegistration { pub integration_id: &'static str, pub js_deferred: bool, + pub js_disabled: bool, pub proxies: Vec>, pub attribute_rewriters: Vec>, pub script_rewriters: Vec>, @@ -607,6 +608,7 @@ impl IntegrationRegistrationBuilder { registration: IntegrationRegistration { integration_id, js_deferred: false, + js_disabled: false, proxies: Vec::new(), attribute_rewriters: Vec::new(), script_rewriters: Vec::new(), @@ -667,6 +669,14 @@ impl IntegrationRegistrationBuilder { self } + /// Disable TSJS module inclusion for an integration that is handled by other assets. + #[must_use] + pub fn without_js(mut self) -> Self { + self.registration.js_disabled = true; + self.registration.js_deferred = false; + self + } + #[must_use] pub fn build(self) -> IntegrationRegistration { self.registration @@ -688,6 +698,7 @@ struct IntegrationRegistryInner { // Metadata for introspection routes: Vec<(IntegrationEndpoint, &'static str)>, deferred_js_ids: Vec<&'static str>, + disabled_js_ids: Vec<&'static str>, html_rewriters: Vec>, script_rewriters: Vec>, html_post_processors: Vec>, @@ -706,12 +717,13 @@ impl Default for IntegrationRegistryInner { head_router: Router::new(), options_router: Router::new(), routes: Vec::new(), + deferred_js_ids: Vec::new(), + disabled_js_ids: Vec::new(), html_rewriters: Vec::new(), script_rewriters: Vec::new(), html_post_processors: Vec::new(), head_injectors: Vec::new(), request_filters: Vec::new(), - deferred_js_ids: Vec::new(), } } } @@ -839,7 +851,9 @@ impl IntegrationRegistry { inner .request_filters .extend(registration.request_filters.into_iter()); - if registration.js_deferred { + if registration.js_disabled { + inner.disabled_js_ids.push(registration.integration_id); + } else if registration.js_deferred { inner.deferred_js_ids.push(registration.integration_id); } } @@ -1098,7 +1112,10 @@ impl IntegrationRegistry { let mut ids: Vec<&'static str> = JS_ALWAYS.to_vec(); for meta in self.registered_integrations() { - if !JS_EXCLUDED.contains(&meta.id) && !ids.contains(&meta.id) { + if !JS_EXCLUDED.contains(&meta.id) + && !self.inner.disabled_js_ids.contains(&meta.id) + && !ids.contains(&meta.id) + { ids.push(meta.id); } } @@ -1152,6 +1169,7 @@ impl IntegrationRegistry { head_injectors: Vec::new(), request_filters: Vec::new(), deferred_js_ids: Vec::new(), + disabled_js_ids: Vec::new(), }), } } @@ -1202,6 +1220,7 @@ impl IntegrationRegistry { head_injectors: Vec::new(), request_filters, deferred_js_ids: Vec::new(), + disabled_js_ids: Vec::new(), }), } } @@ -1265,6 +1284,7 @@ impl IntegrationRegistry { head_injectors: Vec::new(), request_filters: Vec::new(), deferred_js_ids: Vec::new(), + disabled_js_ids: Vec::new(), }), } } @@ -1980,6 +2000,42 @@ mod tests { ); } + #[test] + fn js_module_ids_exclude_prebid_when_managed_external() { + let mut settings = crate::test_support::tests::create_test_settings(); + settings + .integrations + .insert_config( + "prebid", + &serde_json::json!({ + "enabled": true, + "server_url": "https://test-prebid.com/openrtb2/auction", + "bundle_mode": "managed_external", + "external_bundle_url": "https://assets.example/prebid/trusted-prebid.js" + }), + ) + .expect("should update prebid config"); + + let registry = IntegrationRegistry::new(&settings).expect("should create registry"); + + assert!( + !registry.js_module_ids().contains(&"prebid"), + "managed external mode should not include prebid in embedded TSJS modules" + ); + assert!( + !registry.js_module_ids_immediate().contains(&"prebid"), + "managed external mode should not include prebid in immediate TSJS modules" + ); + assert!( + !registry.js_module_ids_deferred().contains(&"prebid"), + "managed external mode should not include prebid in deferred TSJS modules" + ); + assert!( + registry.has_route(&Method::GET, "/integrations/prebid/bundle.js"), + "managed external mode should register the first-party bundle route" + ); + } + #[test] fn js_module_ids_split_is_exhaustive() { let settings = crate::test_support::tests::create_test_settings(); diff --git a/crates/trusted-server-core/src/proxy.rs b/crates/trusted-server-core/src/proxy.rs index bab527139..780f32f40 100644 --- a/crates/trusted-server-core/src/proxy.rs +++ b/crates/trusted-server-core/src/proxy.rs @@ -316,6 +316,8 @@ pub struct ProxyRequestConfig<'a> { /// `&settings.proxy.allowed_domains` because it follows redirect chains that may /// originate from untrusted creative-supplied URLs. pub allowed_domains: &'a [String], + /// Require the initial target and every followed redirect hop to use HTTPS. + pub require_https: bool, } impl<'a> ProxyRequestConfig<'a> { @@ -331,6 +333,7 @@ impl<'a> ProxyRequestConfig<'a> { copy_request_headers: true, stream_passthrough: false, allowed_domains: &[], + require_https: false, } } @@ -361,6 +364,27 @@ impl<'a> ProxyRequestConfig<'a> { self.stream_passthrough = true; self } + + /// Disable EC ID query-param forwarding to the target URL. + #[must_use] + pub fn without_ec_id(mut self) -> Self { + self.forward_ec_id = false; + self + } + + /// Enforce a domain allowlist on the target URL and followed redirects. + #[must_use] + pub fn with_allowed_domains(mut self, allowed_domains: &'a [String]) -> Self { + self.allowed_domains = allowed_domains; + self + } + + /// Require HTTPS for the target URL and followed redirects. + #[must_use] + pub fn with_https_only(mut self) -> Self { + self.require_https = true; + self + } } /// Encodings we support decompressing in `finalize_proxied_response`. @@ -665,6 +689,7 @@ struct ProxyRedirectPolicy<'a> { follow_redirects: bool, stream_passthrough: bool, allowed_domains: &'a [String], + require_https: bool, } /// Proxy a request to a clear target URL while reusing creative rewrite logic. @@ -692,6 +717,7 @@ pub async fn proxy_request( copy_request_headers, stream_passthrough, allowed_domains, + require_https, } = config; let mut target_url_parsed = url::Url::parse(target_url).map_err(|_| { @@ -718,6 +744,7 @@ pub async fn proxy_request( follow_redirects, stream_passthrough, allowed_domains, + require_https, }, ) .await @@ -1195,7 +1222,7 @@ fn redirect_is_permitted>(allowed_domains: &[S], host: &str) -> bo /// /// Comparison is case-insensitive. The wildcard check requires a dot boundary, /// so `"*.example.com"` does **not** match `"evil-example.com"`. -fn is_host_allowed(host: &str, pattern: &str) -> bool { +pub(crate) fn is_host_allowed(host: &str, pattern: &str) -> bool { let host = host.to_ascii_lowercase(); let pattern = pattern.to_ascii_lowercase(); @@ -1235,6 +1262,12 @@ async fn proxy_with_redirects( message: "unsupported scheme".to_string(), })); } + if request_headers.require_https && scheme != "https" { + log::warn!("request to `{}` blocked: HTTPS is required", current_url); + return Err(Report::new(TrustedServerError::Forbidden { + message: "non-HTTPS proxy target blocked".to_string(), + })); + } let host = parsed_url.host_str().unwrap_or(""); if host.is_empty() { @@ -1379,6 +1412,12 @@ async fn proxy_with_redirects( let next_scheme = next_url.scheme().to_ascii_lowercase(); if next_scheme != "http" && next_scheme != "https" { + if request_headers.require_https { + log::warn!("redirect to `{}` blocked: HTTPS is required", next_url); + return Err(Report::new(TrustedServerError::Forbidden { + message: "non-HTTPS redirect blocked".to_string(), + })); + } return finalize_response( settings, req, @@ -1387,6 +1426,12 @@ async fn proxy_with_redirects( redirect_policy.stream_passthrough, ); } + if request_headers.require_https && next_scheme != "https" { + log::warn!("redirect to `{}` blocked: HTTPS is required", next_url); + return Err(Report::new(TrustedServerError::Forbidden { + message: "non-HTTPS redirect blocked".to_string(), + })); + } let next_host = match next_url.host_str() { Some(h) if !h.is_empty() => h, @@ -1461,6 +1506,7 @@ pub async fn handle_first_party_proxy( copy_request_headers: true, stream_passthrough: false, allowed_domains: &settings.proxy.allowed_domains, + require_https: false, }, services, ) @@ -2988,6 +3034,7 @@ mod tests { copy_request_headers: false, stream_passthrough: false, allowed_domains: &[], + require_https: false, }, &services, ) @@ -3028,6 +3075,7 @@ mod tests { copy_request_headers: false, stream_passthrough: false, allowed_domains: &[], + require_https: false, }, &services, ) @@ -3073,6 +3121,7 @@ mod tests { copy_request_headers: false, stream_passthrough: false, allowed_domains: &[], + require_https: false, }, &services, ) @@ -3121,6 +3170,7 @@ mod tests { copy_request_headers: true, stream_passthrough: false, allowed_domains: &[], + require_https: false, }, &services, ) @@ -3185,6 +3235,7 @@ mod tests { copy_request_headers: false, stream_passthrough: false, allowed_domains: &[], + require_https: false, }, &services, ) @@ -4530,6 +4581,38 @@ mod tests { // below verify that proxy_request threads config.allowed_domains through // the initial target check and redirect hops. + #[tokio::test] + async fn proxy_request_blocks_non_https_target_when_https_only() { + let settings = create_test_settings(); + let services = crate::platform::test_support::build_services_with_http_client(Arc::new( + StreamingResponseHttpClient, + ) + as Arc); + let req = build_http_request( + Method::GET, + "https://edge.example/integrations/prebid/bundle.js", + ); + let config = ProxyRequestConfig::new("http://assets.example/prebid/trusted-prebid.js") + .without_ec_id() + .without_forward_headers() + .with_streaming() + .with_https_only(); + + let err = proxy_request(&settings, req, config, &services) + .await + .expect_err("should block non-HTTPS target before proxying"); + + assert_eq!( + err.current_context().status_code(), + StatusCode::FORBIDDEN, + "HTTPS-only proxy requests should reject http targets" + ); + assert!( + matches!(err.current_context(), TrustedServerError::Forbidden { .. }), + "should return a forbidden error" + ); + } + #[test] fn proxy_initial_target_blocked_by_allowlist() { futures::executor::block_on(async { From 03f2795229c6be59d8e5e0a7d6c38a7c1a60d917 Mon Sep 17 00:00:00 2001 From: Christian Date: Tue, 9 Jun 2026 18:24:11 -0500 Subject: [PATCH 4/7] Hard cut Prebid to external bundle flow --- crates/js/lib/build-all.mjs | 245 +--------------- .../prebid/_adapters.generated.ts | 10 +- .../prebid/_user_ids.generated.ts | 6 +- .../js/lib/src/integrations/prebid/index.ts | 20 +- .../src/route_tests.rs | 5 +- crates/trusted-server-core/README.md | 2 +- .../src/integrations/prebid.rs | 254 +++++------------ .../src/integrations/registry.rs | 26 +- crates/trusted-server-core/src/publisher.rs | 28 +- .../trusted-server-core/src/test_support.rs | 3 +- docs/guide/creative-processing.md | 2 +- docs/guide/integration-guide.md | 19 +- docs/guide/integrations/prebid.md | 67 +++-- ...xternal-prebid-first-party-proxy-design.md | 107 ++++--- trusted-server.toml | 261 ++++++++++++++++++ 15 files changed, 476 insertions(+), 579 deletions(-) create mode 100644 trusted-server.toml diff --git a/crates/js/lib/build-all.mjs b/crates/js/lib/build-all.mjs index 61892df60..ce465a72d 100644 --- a/crates/js/lib/build-all.mjs +++ b/crates/js/lib/build-all.mjs @@ -8,242 +8,24 @@ * tsjs-core.js — core API (always included) * tsjs-.js — one per discovered integration * - * Environment variables: - * TSJS_PREBID_ADAPTERS — Comma-separated list of Prebid.js bid adapter - * names to include in the bundle (e.g. "rubicon,appnexus,openx"). - * Each name must have a corresponding {name}BidAdapter.js module in - * the prebid.js package. Default: no adapters. - * - * TSJS_PREBID_USER_ID_MODULES — Ignored for production builds. User ID - * modules are selected from src/integrations/prebid/user_id_modules.json - * so attested bundles are deterministic. For local experiments only, use - * TSJS_PREBID_USER_ID_MODULES_DEV_OVERRIDE. + * Prebid is intentionally excluded from this embedded build. Use + * build-prebid-external.mjs to generate publisher-specific Prebid bundles + * outside the Cargo build. */ -import crypto from 'node:crypto'; import fs from 'node:fs'; -import { createRequire } from 'node:module'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { build } from 'vite'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); -const require = createRequire(import.meta.url); const srcDir = path.resolve(__dirname, 'src'); const distDir = path.resolve(__dirname, '..', 'dist'); const integrationsDir = path.join(srcDir, 'integrations'); -// --------------------------------------------------------------------------- -// Prebid adapter generation -// --------------------------------------------------------------------------- - -const DEFAULT_PREBID_ADAPTERS = ''; -const DEFAULT_PREBID_ADAPTERS_DESCRIPTION = DEFAULT_PREBID_ADAPTERS || 'no adapters'; -const ADAPTERS_FILE = path.join(integrationsDir, 'prebid', '_adapters.generated.ts'); -const USER_IDS_FILE = path.join(integrationsDir, 'prebid', '_user_ids.generated.ts'); - -const USER_ID_REGISTRY_FILE = path.join(integrationsDir, 'prebid', 'user_id_modules.json'); -const USER_IDS_MANIFEST_FILE = path.join(distDir, 'prebid-user-id-modules.json'); -const LIVE_INTENT_SHIM_ALIAS = 'prebid.js/modules/liveIntentIdSystem.js'; -const PREBID_PACKAGE_DIR = path.join(__dirname, 'node_modules', 'prebid.js'); -const PREBID_LIVE_INTENT_STANDARD = path.join( - PREBID_PACKAGE_DIR, - 'dist', - 'src', - 'libraries', - 'liveIntentId', - 'idSystem.js' -); -const PREBID_GLOBAL_MODULE = path.join(PREBID_PACKAGE_DIR, 'dist', 'src', 'src', 'prebidGlobal.js'); -const LIVE_INTENT_SHIM = path.join( - integrationsDir, - 'prebid', - 'prebid_modules', - 'liveIntentIdSystem.ts' -); - -/** - * Generate `_adapters.generated.ts` with import statements for each adapter - * listed in the TSJS_PREBID_ADAPTERS environment variable. - * - * Invalid adapter names (those without a matching module in prebid.js) are - * logged and skipped. - */ -function generatePrebidAdapters() { - const raw = process.env.TSJS_PREBID_ADAPTERS ?? DEFAULT_PREBID_ADAPTERS; - const names = raw - .split(',') - .map((s) => s.trim()) - .filter(Boolean); - - const modulesDir = path.join(__dirname, 'node_modules', 'prebid.js', 'modules'); - - // Validate each adapter and build import lines - const imports = []; - for (const name of names) { - const moduleFile = `${name}BidAdapter.js`; - const modulePath = path.join(modulesDir, moduleFile); - if (!fs.existsSync(modulePath)) { - console.error( - `[build-all] WARNING: Prebid adapter "${name}" not found (expected ${moduleFile}), skipping` - ); - continue; - } - imports.push(`import 'prebid.js/modules/${moduleFile}';`); - } - - if (imports.length === 0) { - if (names.length === 0) { - console.log( - '[build-all] No Prebid adapters configured; bundle will have no client-side adapters' - ); - } else { - console.error( - '[build-all] WARNING: No valid Prebid adapters found, bundle will have no client-side adapters' - ); - } - } - - const header = [ - '// Auto-generated by build-all.mjs — manual edits will be overwritten at build time.', - '//', - '// Controls which Prebid.js bid adapters are included in the bundle.', - '// Set the TSJS_PREBID_ADAPTERS environment variable to a comma-separated list', - '// of adapter names (e.g. "rubicon,appnexus,openx") before building.', - `// Default: ${DEFAULT_PREBID_ADAPTERS_DESCRIPTION}`, - ].join('\n'); - const content = imports.length === 0 ? `${header}\n` : `${header}\n\n${imports.join('\n')}\n`; - - fs.writeFileSync(ADAPTERS_FILE, content); - - const adapterNames = names.filter((name) => - fs.existsSync(path.join(modulesDir, `${name}BidAdapter.js`)) - ); - console.log('[build-all] Prebid adapters:', adapterNames); -} - -function readUserIdRegistry() { - return JSON.parse(fs.readFileSync(USER_ID_REGISTRY_FILE, 'utf8')); -} - -function requireExistingFile(filePath, description) { - if (!fs.existsSync(filePath)) { - throw new Error(`[build-all] Missing ${description}: ${filePath}`); - } -} - -function prebidPackageVersion() { - const packageJsonPath = path.join(PREBID_PACKAGE_DIR, 'package.json'); - const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8')); - return packageJson.version; -} - -function sourceToModuleMap(entries) { - const map = {}; - for (const entry of entries) { - for (const source of entry.eidSources ?? []) { - map[source] = entry.moduleName; - } - } - return map; -} - -function validateUserIdImport(entry) { - requireExistingFile(LIVE_INTENT_SHIM, 'LiveIntent ESM shim'); - requireExistingFile(PREBID_LIVE_INTENT_STANDARD, 'Prebid LiveIntent standard ESM module'); - requireExistingFile(PREBID_GLOBAL_MODULE, 'Prebid global module'); - - if (entry.moduleName === 'liveIntentIdSystem') { - return; - } - - try { - require.resolve(entry.importPath, { paths: [__dirname] }); - } catch (error) { - throw new Error( - `[build-all] Required Prebid user ID module "${entry.moduleName}" could not be resolved from ${entry.importPath}: ${error.message}` - ); - } -} - -/** - * Generate `_user_ids.generated.ts` with deterministic User ID imports. - * - * Production builds intentionally ignore TSJS_PREBID_USER_ID_MODULES so the - * attested JS artifact does not vary per publisher. A dev-only override exists - * for local experiments and should not be used for trusted deployments. - */ -function generatePrebidUserIdModules() { - const registry = readUserIdRegistry(); - const entriesByModule = new Map(registry.modules.map((entry) => [entry.moduleName, entry])); - const override = process.env.TSJS_PREBID_USER_ID_MODULES_DEV_OVERRIDE; - const moduleNames = override - ? override - .split(',') - .map((s) => s.trim()) - .filter(Boolean) - : registry.defaultPreset; - - if (process.env.TSJS_PREBID_USER_ID_MODULES && !override) { - console.warn( - '[build-all] TSJS_PREBID_USER_ID_MODULES is ignored for deterministic attested builds. ' + - 'Use TSJS_PREBID_USER_ID_MODULES_DEV_OVERRIDE only for local experiments.' - ); - } - - if (override) { - console.warn( - '[build-all] WARNING: using TSJS_PREBID_USER_ID_MODULES_DEV_OVERRIDE. ' + - 'This changes the Prebid bundle and breaks production attestation assumptions.' - ); - } - - const selectedEntries = moduleNames.map((moduleName) => { - const entry = entriesByModule.get(moduleName); - if (!entry) { - throw new Error(`[build-all] Unknown Prebid user ID module in preset: ${moduleName}`); - } - validateUserIdImport(entry); - return entry; - }); - - const imports = selectedEntries.map((entry) => `import '${entry.importPath}';`); - - const content = [ - '// Auto-generated by build-all.mjs — manual edits will be overwritten at build time.', - '//', - '// Deterministic Prebid.js user ID module preset for attested builds.', - '// TSJS_PREBID_USER_ID_MODULES is intentionally ignored in production builds.', - '// Use TSJS_PREBID_USER_ID_MODULES_DEV_OVERRIDE only for local experiments.', - `// Modules: ${moduleNames.join(', ')}`, - '', - ...imports, - '', - ].join('\n'); - - fs.writeFileSync(USER_IDS_FILE, content); - - const manifest = { - prebidVersion: prebidPackageVersion(), - deterministic: !override, - modules: moduleNames, - sourceToModule: sourceToModuleMap(registry.modules), - generatedFileHash: crypto.createHash('sha256').update(content).digest('hex'), - }; - - console.log('[build-all] Prebid user ID modules:', moduleNames); - return manifest; -} - -generatePrebidAdapters(); -const prebidUserIdManifest = generatePrebidUserIdModules(); - -// --------------------------------------------------------------------------- - // Clean dist directory fs.rmSync(distDir, { recursive: true, force: true }); fs.mkdirSync(distDir, { recursive: true }); -fs.writeFileSync(USER_IDS_MANIFEST_FILE, `${JSON.stringify(prebidUserIdManifest, null, 2)}\n`); // Discover integration modules: directories in src/integrations/ with index.ts const integrationModules = fs.existsSync(integrationsDir) @@ -252,11 +34,12 @@ const integrationModules = fs.existsSync(integrationsDir) .filter((name) => { const fullPath = path.join(integrationsDir, name); return ( - fs.statSync(fullPath).isDirectory() && fs.existsSync(path.join(fullPath, 'index.ts')) + name !== 'prebid' && + fs.statSync(fullPath).isDirectory() && + fs.existsSync(path.join(fullPath, 'index.ts')) ); }) - .sort() - : []; + : []; console.log('[build-all] Discovered integrations:', integrationModules); @@ -268,20 +51,6 @@ async function buildModule(name, entryPath) { await build({ configFile: false, root: __dirname, - resolve: { - alias: { - [LIVE_INTENT_SHIM_ALIAS]: LIVE_INTENT_SHIM, - 'prebid.js/modules/liveIntentIdSystem': LIVE_INTENT_SHIM, - 'tsjs-prebid/liveIntentIdSystemStandard': PREBID_LIVE_INTENT_STANDARD, - 'tsjs-prebid/prebidGlobal': PREBID_GLOBAL_MODULE, - // prebid.js doesn't expose src/adapterManager.js via its package - // "exports" map, but we need it for client-side bidder validation. - 'prebid.js/src/adapterManager.js': path.resolve( - __dirname, - 'node_modules/prebid.js/dist/src/src/adapterManager.js' - ), - }, - }, build: { emptyOutDir: false, outDir: distDir, diff --git a/crates/js/lib/src/integrations/prebid/_adapters.generated.ts b/crates/js/lib/src/integrations/prebid/_adapters.generated.ts index e73f6aeaa..b48904b96 100644 --- a/crates/js/lib/src/integrations/prebid/_adapters.generated.ts +++ b/crates/js/lib/src/integrations/prebid/_adapters.generated.ts @@ -1,6 +1,6 @@ -// Auto-generated by build-all.mjs — manual edits will be overwritten at build time. +// Auto-generated by build-prebid-external.mjs — manual edits will be overwritten. // -// Controls which Prebid.js bid adapters are included in the bundle. -// Set the TSJS_PREBID_ADAPTERS environment variable to a comma-separated list -// of adapter names (e.g. "rubicon,appnexus,openx") before building. -// Default: no adapters +// External Prebid bundle adapter imports. +// Modules: rubicon + +import 'prebid.js/modules/rubiconBidAdapter.js'; diff --git a/crates/js/lib/src/integrations/prebid/_user_ids.generated.ts b/crates/js/lib/src/integrations/prebid/_user_ids.generated.ts index 24e802850..8911dbd25 100644 --- a/crates/js/lib/src/integrations/prebid/_user_ids.generated.ts +++ b/crates/js/lib/src/integrations/prebid/_user_ids.generated.ts @@ -1,8 +1,6 @@ -// Auto-generated by build-all.mjs — manual edits will be overwritten at build time. +// Auto-generated by build-prebid-external.mjs — manual edits will be overwritten. // -// Deterministic Prebid.js user ID module preset for attested builds. -// TSJS_PREBID_USER_ID_MODULES is intentionally ignored in production builds. -// Use TSJS_PREBID_USER_ID_MODULES_DEV_OVERRIDE only for local experiments. +// External Prebid bundle User ID module imports. // Modules: connectIdSystem, criteoIdSystem, id5IdSystem, identityLinkIdSystem, liveIntentIdSystem, pubProvidedIdSystem, sharedIdSystem, uid2IdSystem, unifiedIdSystem import 'prebid.js/modules/connectIdSystem.js'; diff --git a/crates/js/lib/src/integrations/prebid/index.ts b/crates/js/lib/src/integrations/prebid/index.ts index 6038f42a4..156efbbeb 100644 --- a/crates/js/lib/src/integrations/prebid/index.ts +++ b/crates/js/lib/src/integrations/prebid/index.ts @@ -19,10 +19,8 @@ import 'prebid.js/modules/consentManagementUsp.js'; import 'prebid.js/modules/userId.js'; // Client-side bid adapters — self-register with prebid.js on import. -// The set of adapters is controlled by the TSJS_PREBID_ADAPTERS env var at -// build time. See _adapters.generated.ts (written by build-all.mjs). -// User ID submodules come from the deterministic attested preset in -// user_id_modules.json. See _user_ids.generated.ts. +// The external bundle generator writes _adapters.generated.ts and +// _user_ids.generated.ts from its --adapters and --user-id-modules options. // When a bidder is listed in `client_side_bidders` in trusted-server.toml, // the requestBids shim leaves its bids untouched and the corresponding // adapter handles them natively in the browser. @@ -156,7 +154,9 @@ function recordUserIdModuleDiagnostics(): PrebidUserIdDiagnostics { } for (const name of missingConfiguredUserIdNames) { - log.warn(`[tsjs-prebid] configured User ID module "${name}" is not included in TSJS`); + log.warn( + `[tsjs-prebid] configured User ID module "${name}" is not included in the external bundle` + ); } return diagnostics; @@ -462,21 +462,21 @@ export function installPrebidNpm(config?: Partial): typeof pbjs // Validate that every client-side bidder has its adapter registered. // Adapters self-register on import, so a missing adapter means the bidder - // was listed in client_side_bidders but not in TSJS_PREBID_ADAPTERS at - // build time. Without the adapter the bidder is silently dropped from both - // server-side and client-side auctions. + // was listed in client_side_bidders but not included in the generated + // external Prebid bundle. Without the adapter the bidder is silently dropped + // from both server-side and client-side auctions. for (const bidder of clientSideBidders) { try { if (!adapterManager.getBidAdapter(bidder)) { log.error( `[tsjs-prebid] client-side bidder "${bidder}" has no adapter loaded. ` + - `Add it to TSJS_PREBID_ADAPTERS at build time.` + `Add it to build-prebid-external.mjs --adapters.` ); } } catch { log.error( `[tsjs-prebid] client-side bidder "${bidder}" has no adapter loaded. ` + - `Add it to TSJS_PREBID_ADAPTERS at build time.` + `Add it to build-prebid-external.mjs --adapters.` ); } } diff --git a/crates/trusted-server-adapter-fastly/src/route_tests.rs b/crates/trusted-server-adapter-fastly/src/route_tests.rs index 7863cbfec..5a95c93b6 100644 --- a/crates/trusted-server-adapter-fastly/src/route_tests.rs +++ b/crates/trusted-server-adapter-fastly/src/route_tests.rs @@ -408,7 +408,8 @@ fn prebid_integration_toml() -> &'static str { [integrations.prebid] enabled = true server_url = "https://test-prebid.com/openrtb2/auction" - "# + external_bundle_url = "https://assets.example/prebid/trusted-prebid.js" + "# } fn create_test_settings() -> Settings { @@ -423,7 +424,7 @@ fn create_test_settings() -> Settings { enabled = true providers = ["prebid"] timeout_ms = 2000 - "#, + "#, ); let settings = Settings::from_toml(&config).expect("should parse adapter route test settings"); diff --git a/crates/trusted-server-core/README.md b/crates/trusted-server-core/README.md index 6f323456f..3049a1115 100644 --- a/crates/trusted-server-core/README.md +++ b/crates/trusted-server-core/README.md @@ -41,7 +41,7 @@ Helpers: JS bundles (served by publisher module): - Dynamic endpoint: `/static/tsjs=tsjs-unified.min.js?v=` - - At build time, each integration is compiled as a separate IIFE (`tsjs-core.js`, `tsjs-prebid.js`, `tsjs-creative.js`, etc.) + - At build time, embedded integrations are compiled as separate IIFEs (`tsjs-core.js`, `tsjs-creative.js`, etc.); Prebid is generated externally and served through `/integrations/prebid/bundle.js`. - At runtime, the server concatenates `tsjs-core.js` + enabled integration modules based on `IntegrationRegistry` config - The URL filename is fixed for backward compatibility; the `?v=` hash changes when modules change diff --git a/crates/trusted-server-core/src/integrations/prebid.rs b/crates/trusted-server-core/src/integrations/prebid.rs index cebc023a4..50baa794c 100644 --- a/crates/trusted-server-core/src/integrations/prebid.rs +++ b/crates/trusted-server-core/src/integrations/prebid.rs @@ -83,17 +83,6 @@ fn prebid_body_preview(body: &[u8]) -> String { #[cfg(test)] const GPC_US_PRIVACY: &str = "1YYN"; -/// Controls how the browser loads Prebid.js for the integration. -#[derive(Debug, Default, Copy, Clone, Deserialize, Serialize, PartialEq, Eq)] -#[serde(rename_all = "snake_case")] -pub enum PrebidBundleMode { - /// Load the embedded TSJS Prebid bundle served from `/static/tsjs=`. - #[default] - Embedded, - /// Load a configured external Prebid bundle through a first-party route. - ManagedExternal, -} - #[derive(Debug, Clone, Deserialize, Serialize, Validate)] pub struct PrebidIntegrationConfig { #[serde(default = "default_enabled")] @@ -130,10 +119,6 @@ pub struct PrebidIntegrationConfig { deserialize_with = "crate::settings::vec_from_seq_or_map" )] pub script_patterns: Vec, - /// Controls whether Prebid loads from the embedded TSJS module or an - /// external managed bundle proxied through a first-party route. - #[serde(default)] - pub bundle_mode: PrebidBundleMode, /// Absolute HTTPS URL of the generated external Prebid bundle. #[serde(default)] #[validate(custom(function = "validate_external_bundle_url"))] @@ -363,19 +348,13 @@ impl ExternalBundleSriAlgorithm { } } -#[derive(Debug, Clone)] -struct ExternalBundleSriToken { - algorithm: ExternalBundleSriAlgorithm, - digest: Vec, -} - fn external_bundle_sri_validation_error(message: &'static str) -> ValidationError { let mut err = ValidationError::new("invalid_external_bundle_sri"); err.message = Some(message.into()); err } -fn parse_external_bundle_sri(value: &str) -> Result, ValidationError> { +fn parse_external_bundle_sri(value: &str) -> Result<(), ValidationError> { let trimmed = value.trim(); if trimmed.is_empty() || trimmed != value { return Err(external_bundle_sri_validation_error( @@ -383,7 +362,6 @@ fn parse_external_bundle_sri(value: &str) -> Result, )); } - let mut tokens = Vec::new(); for token in trimmed.split_ascii_whitespace() { let Some((algorithm_raw, digest_raw)) = token.split_once('-') else { return Err(external_bundle_sri_validation_error( @@ -415,36 +393,23 @@ fn parse_external_bundle_sri(value: &str) -> Result, "external_bundle_sri digest length does not match its algorithm", )); } - - tokens.push(ExternalBundleSriToken { algorithm, digest }); } - Ok(tokens) + Ok(()) } fn validate_external_bundle_sri(value: &str) -> Result<(), ValidationError> { - parse_external_bundle_sri(value).map(|_| ()) + parse_external_bundle_sri(value) } -fn external_bundle_sri_configuration_message(err: ValidationError) -> String { - let detail = err - .message - .map(std::borrow::Cow::into_owned) - .unwrap_or_else(|| err.code.into_owned()); - format!("integrations.prebid.external_bundle_sri is invalid: {detail}") -} - -fn validate_managed_external_bundle_config( +fn validate_external_bundle_config( config: &PrebidIntegrationConfig, allowed_domains: &[String], ) -> Result<(), Report> { - if config.bundle_mode != PrebidBundleMode::ManagedExternal { - return Ok(()); - } - let url = config.external_bundle_url.as_deref().ok_or_else(|| { Report::new(TrustedServerError::Configuration { - message: "integrations.prebid.external_bundle_url is required when bundle_mode is managed_external".to_string(), + message: "integrations.prebid.external_bundle_url is required when prebid is enabled" + .to_string(), }) })?; @@ -479,36 +444,6 @@ fn validate_managed_external_bundle_config( })); } - if let Some(expected_sha256) = config.external_bundle_sha256.as_deref() { - let sri = config.external_bundle_sri.as_deref().ok_or_else(|| { - Report::new(TrustedServerError::Configuration { - message: "integrations.prebid.external_bundle_sri is required when external_bundle_sha256 is configured".to_string(), - }) - })?; - - let expected_digest = hex::decode(expected_sha256).map_err(|_| { - Report::new(TrustedServerError::Configuration { - message: - "integrations.prebid.external_bundle_sha256 must be a 64-character hex SHA-256" - .to_string(), - }) - })?; - let sri_tokens = parse_external_bundle_sri(sri).map_err(|err| { - Report::new(TrustedServerError::Configuration { - message: external_bundle_sri_configuration_message(err), - }) - })?; - - if sri_tokens.iter().any(|token| { - token.algorithm == ExternalBundleSriAlgorithm::Sha256 - && token.digest.as_slice() != expected_digest.as_slice() - }) { - return Err(Report::new(TrustedServerError::Configuration { - message: "integrations.prebid.external_bundle_sri sha256 digest must match external_bundle_sha256".to_string(), - })); - } - } - Ok(()) } @@ -616,10 +551,6 @@ impl PrebidIntegration { }) } - fn is_managed_external(&self) -> bool { - self.config.bundle_mode == PrebidBundleMode::ManagedExternal - } - fn external_bundle_script_src(&self) -> String { match self.config.external_bundle_sha256.as_deref() { Some(sha256) => format!("{PREBID_BUNDLE_ROUTE}?v={sha256}"), @@ -665,9 +596,7 @@ impl PrebidIntegration { ) { (None, Some(_)) => Ok(None), (Some(expected), Some(actual)) if expected != actual => Ok(None), - (Some(_), Some(_)) if self.config.external_bundle_sri.is_some() => { - Ok(Some(ExternalBundleCacheMode::Immutable)) - } + (Some(_), Some(_)) => Ok(Some(ExternalBundleCacheMode::Immutable)), _ => Ok(Some(ExternalBundleCacheMode::Revalidate)), } } @@ -759,7 +688,9 @@ impl PrebidIntegration { let target_url = self.config.external_bundle_url.as_deref().ok_or_else(|| { Report::new(TrustedServerError::Configuration { - message: "integrations.prebid.external_bundle_url is required when bundle_mode is managed_external".to_string(), + message: + "integrations.prebid.external_bundle_url is required when prebid is enabled" + .to_string(), }) })?; @@ -798,7 +729,7 @@ fn build( return Ok(None); }; - validate_managed_external_bundle_config(&config, &settings.proxy.allowed_domains)?; + validate_external_bundle_config(&config, &settings.proxy.allowed_domains)?; // Warn about bidders that appear in both lists — this is likely a config // mistake. A bidder should be in either `bidders` (server-side) or @@ -829,18 +760,14 @@ pub fn register( return Ok(None); }; - let mut registration = IntegrationRegistration::builder(PREBID_INTEGRATION_ID) - .with_proxy(integration.clone()) - .with_attribute_rewriter(integration.clone()) - .with_head_injector(integration.clone()); - - if integration.is_managed_external() { - registration = registration.without_js(); - } else { - registration = registration.with_deferred_js(); - } - - Ok(Some(registration.build())) + Ok(Some( + IntegrationRegistration::builder(PREBID_INTEGRATION_ID) + .with_proxy(integration.clone()) + .with_attribute_rewriter(integration.clone()) + .with_head_injector(integration) + .without_js() + .build(), + )) } #[async_trait(?Send)] @@ -852,9 +779,7 @@ impl IntegrationProxy for PrebidIntegration { fn routes(&self) -> Vec { let mut routes = vec![]; - if self.is_managed_external() { - routes.push(self.get("/bundle.js")); - } + routes.push(self.get("/bundle.js")); // Register routes for script removal patterns // Patterns can be exact paths (e.g., "/prebid.min.js") or use matchit wildcards @@ -954,9 +879,7 @@ impl IntegrationHeadInjector for PrebidIntegration { r#""# )]; - if self.is_managed_external() { - inserts.push(self.external_bundle_script_tag()); - } + inserts.push(self.external_bundle_script_tag()); inserts } @@ -2177,8 +2100,9 @@ mod tests { test_mode: false, debug_query_params: None, script_patterns: default_script_patterns(), - bundle_mode: PrebidBundleMode::Embedded, - external_bundle_url: None, + external_bundle_url: Some( + "https://assets.example/prebid/trusted-prebid.js".to_string(), + ), external_bundle_sha256: None, external_bundle_sri: None, client_side_bidders: Vec::new(), @@ -2467,6 +2391,7 @@ passphrase = "test-secret-key-32-bytes-minimum" &json!({ "enabled": true, "server_url": "https://test-prebid.com/openrtb2/auction", + "external_bundle_url": "https://assets.example/prebid/trusted-prebid.js", "timeout_ms": 1000, "bidders": ["mocktioneer"], "script_patterns": [], @@ -2517,6 +2442,7 @@ passphrase = "test-secret-key-32-bytes-minimum" &json!({ "enabled": true, "server_url": "https://test-prebid.com/openrtb2/auction", + "external_bundle_url": "https://assets.example/prebid/trusted-prebid.js", "timeout_ms": 1000, "bidders": ["mocktioneer"], "script_patterns": ["/prebid.js", "/prebid.min.js"], @@ -2551,8 +2477,12 @@ passphrase = "test-secret-key-32-bytes-minimum" "Prebid preload should be removed when auto-config is enabled" ); assert!( - processed.contains("tsjs-prebid.min.js"), - "Deferred prebid bundle should be injected" + processed.contains(PREBID_BUNDLE_ROUTE), + "External prebid bundle route should be injected" + ); + assert!( + !processed.contains("tsjs-prebid.min.js"), + "Embedded deferred prebid bundle should not be injected" ); } @@ -2600,22 +2530,16 @@ server_url = "https://prebid.example" } #[test] - fn managed_external_config_parses_with_optional_hash_metadata() { + fn external_bundle_config_parses_with_optional_hash_metadata() { let config = parse_prebid_toml( r#" [integrations.prebid] enabled = true server_url = "https://prebid.example" -bundle_mode = "managed_external" external_bundle_url = "https://assets.example/prebid/trusted-prebid.js" "#, ); - assert_eq!( - config.bundle_mode, - PrebidBundleMode::ManagedExternal, - "should parse managed external mode" - ); assert_eq!( config.external_bundle_url.as_deref(), Some("https://assets.example/prebid/trusted-prebid.js"), @@ -2628,13 +2552,12 @@ external_bundle_url = "https://assets.example/prebid/trusted-prebid.js" } #[test] - fn managed_external_config_rejects_malformed_hash_metadata() { + fn external_bundle_config_rejects_malformed_hash_metadata() { let err = parse_prebid_toml_result( r#" [integrations.prebid] enabled = true server_url = "https://prebid.example" -bundle_mode = "managed_external" external_bundle_url = "https://assets.example/prebid/trusted-prebid.js" external_bundle_sha256 = "not-a-sha" "#, @@ -2648,13 +2571,12 @@ external_bundle_sha256 = "not-a-sha" } #[test] - fn managed_external_config_rejects_non_https_bundle_url() { + fn external_bundle_config_rejects_non_https_bundle_url() { let err = parse_prebid_toml_result( r#" [integrations.prebid] enabled = true server_url = "https://prebid.example" -bundle_mode = "managed_external" external_bundle_url = "http://assets.example/prebid/trusted-prebid.js" "#, ) @@ -2667,13 +2589,12 @@ external_bundle_url = "http://assets.example/prebid/trusted-prebid.js" } #[test] - fn managed_external_config_rejects_invalid_sri_base64() { + fn external_bundle_config_rejects_invalid_sri_base64() { let err = parse_prebid_toml_result( r#" [integrations.prebid] enabled = true server_url = "https://prebid.example" -bundle_mode = "managed_external" external_bundle_url = "https://assets.example/prebid/trusted-prebid.js" external_bundle_sri = "sha384-not-valid!!!" "#, @@ -2687,13 +2608,12 @@ external_bundle_sri = "sha384-not-valid!!!" } #[test] - fn managed_external_config_rejects_sri_with_wrong_digest_length() { + fn external_bundle_config_rejects_sri_with_wrong_digest_length() { let err = parse_prebid_toml_result( r#" [integrations.prebid] enabled = true server_url = "https://prebid.example" -bundle_mode = "managed_external" external_bundle_url = "https://assets.example/prebid/trusted-prebid.js" external_bundle_sri = "sha384-AAAA" "#, @@ -2707,7 +2627,7 @@ external_bundle_sri = "sha384-AAAA" } #[test] - fn managed_external_registration_requires_sri_when_sha256_is_configured() { + fn external_bundle_registration_allows_sha256_without_sri() { let mut settings = make_settings(); settings .integrations @@ -2716,25 +2636,23 @@ external_bundle_sri = "sha384-AAAA" &json!({ "enabled": true, "server_url": "https://prebid.example/openrtb2/auction", - "bundle_mode": "managed_external", "external_bundle_url": "https://assets.example/prebid/trusted-prebid.js", "external_bundle_sha256": "0".repeat(64) }), ) .expect("should update prebid config"); - let err = match IntegrationRegistry::new(&settings) { - Ok(_) => panic!("should reject missing SRI for hashed bundle"), - Err(err) => err, - }; + let registry = IntegrationRegistry::new(&settings) + .expect("should create registry with valid SHA-256 and no SRI"); + assert!( - err.to_string().contains("external_bundle_sri"), - "error should mention required external bundle SRI: {err:?}" + registry.has_route(&Method::GET, PREBID_BUNDLE_ROUTE), + "should register external bundle route" ); } #[test] - fn managed_external_registration_allows_sha256_with_valid_sha384_sri() { + fn external_bundle_registration_allows_sha256_with_valid_sha384_sri() { let mut settings = make_settings(); settings .integrations @@ -2743,7 +2661,6 @@ external_bundle_sri = "sha384-AAAA" &json!({ "enabled": true, "server_url": "https://prebid.example/openrtb2/auction", - "bundle_mode": "managed_external", "external_bundle_url": "https://assets.example/prebid/trusted-prebid.js", "external_bundle_sha256": "0".repeat(64), "external_bundle_sri": test_sri("sha384", &[0; 48]) @@ -2756,40 +2673,12 @@ external_bundle_sri = "sha384-AAAA" assert!( registry.has_route(&Method::GET, PREBID_BUNDLE_ROUTE), - "should register managed external bundle route" - ); - } - - #[test] - fn managed_external_registration_rejects_mismatched_sha256_sri() { - let mut settings = make_settings(); - settings - .integrations - .insert_config( - "prebid", - &json!({ - "enabled": true, - "server_url": "https://prebid.example/openrtb2/auction", - "bundle_mode": "managed_external", - "external_bundle_url": "https://assets.example/prebid/trusted-prebid.js", - "external_bundle_sha256": "0".repeat(64), - "external_bundle_sri": test_sri("sha256", &[1; 32]) - }), - ) - .expect("should update prebid config"); - - let err = match IntegrationRegistry::new(&settings) { - Ok(_) => panic!("should reject mismatched SHA-256 SRI"), - Err(err) => err, - }; - assert!( - err.to_string().contains("external_bundle_sri"), - "error should mention external bundle SRI mismatch: {err:?}" + "should register external bundle route" ); } #[test] - fn managed_external_registration_requires_bundle_url() { + fn external_bundle_registration_requires_bundle_url() { let mut settings = make_settings(); settings .integrations @@ -2797,8 +2686,7 @@ external_bundle_sri = "sha384-AAAA" "prebid", &json!({ "enabled": true, - "server_url": "https://prebid.example/openrtb2/auction", - "bundle_mode": "managed_external" + "server_url": "https://prebid.example/openrtb2/auction" }), ) .expect("should update prebid config"); @@ -2814,7 +2702,7 @@ external_bundle_sri = "sha384-AAAA" } #[test] - fn managed_external_registration_uses_proxy_allowed_domains() { + fn external_bundle_registration_uses_proxy_allowed_domains() { let mut settings = make_settings(); settings.proxy.allowed_domains = vec!["allowed.example".to_string()]; settings @@ -2824,7 +2712,6 @@ external_bundle_sri = "sha384-AAAA" &json!({ "enabled": true, "server_url": "https://prebid.example/openrtb2/auction", - "bundle_mode": "managed_external", "external_bundle_url": "https://blocked.example/prebid/trusted-prebid.js" }), ) @@ -2879,11 +2766,9 @@ external_bundle_sri = "sha384-AAAA" fn external_bundle_request_cache_mode_validates_version_query() { let sha256 = "a".repeat(64); let mut config = base_config(); - config.bundle_mode = PrebidBundleMode::ManagedExternal; config.external_bundle_url = Some("https://assets.example/prebid/trusted-prebid.js".to_string()); config.external_bundle_sha256 = Some(sha256.clone()); - config.external_bundle_sri = Some(test_sri("sha384", &[0; 48])); let integration = PrebidIntegration::new(config); let versioned_req = test_request(format!( @@ -2921,7 +2806,6 @@ external_bundle_sri = "sha384-AAAA" #[test] fn external_bundle_request_cache_mode_rejects_version_when_hash_is_absent() { let mut config = base_config(); - config.bundle_mode = PrebidBundleMode::ManagedExternal; config.external_bundle_url = Some("https://assets.example/prebid/trusted-prebid.js".to_string()); let integration = PrebidIntegration::new(config); @@ -2952,7 +2836,6 @@ external_bundle_sri = "sha384-AAAA" fn external_bundle_headers_use_cache_policy_for_mode() { let sha256 = "a".repeat(64); let mut config = base_config(); - config.bundle_mode = PrebidBundleMode::ManagedExternal; config.external_bundle_url = Some("https://assets.example/prebid/trusted-prebid.js".to_string()); config.external_bundle_sha256 = Some(sha256.clone()); @@ -2998,7 +2881,6 @@ external_bundle_sri = "sha384-AAAA" fn external_bundle_response_sanitization_uses_header_whitelist_for_ok_response() { let sha256 = "a".repeat(64); let mut config = base_config(); - config.bundle_mode = PrebidBundleMode::ManagedExternal; config.external_bundle_url = Some("https://assets.example/prebid/trusted-prebid.js".to_string()); config.external_bundle_sha256 = Some(sha256.clone()); @@ -3076,7 +2958,6 @@ external_bundle_sri = "sha384-AAAA" #[test] fn external_bundle_response_sanitization_strips_headers_for_error_response() { let mut config = base_config(); - config.bundle_mode = PrebidBundleMode::ManagedExternal; config.external_bundle_url = Some("https://assets.example/prebid/trusted-prebid.js".to_string()); let integration = PrebidIntegration::new(config); @@ -3149,27 +3030,11 @@ external_bundle_sri = "sha384-AAAA" has_prebid_min_js_route, "should register /prebid.min.js route" ); - assert!( - !routes.iter().any(|r| r.path == PREBID_BUNDLE_ROUTE), - "embedded mode should not register the external bundle route" - ); - } - - #[test] - fn routes_include_bundle_route_for_managed_external_mode() { - let mut config = base_config(); - config.bundle_mode = PrebidBundleMode::ManagedExternal; - config.external_bundle_url = - Some("https://assets.example/prebid/trusted-prebid.js".to_string()); - let integration = PrebidIntegration::new(config); - - let routes = integration.routes(); - assert!( routes .iter() .any(|r| r.path == PREBID_BUNDLE_ROUTE && r.method == Method::GET), - "managed external mode should register the bundle route" + "should register the bundle route" ); } @@ -3185,7 +3050,7 @@ external_bundle_sri = "sha384-AAAA" }; let inserts = integration.head_inserts(&ctx); - assert_eq!(inserts.len(), 1, "should produce exactly one head insert"); + assert_eq!(inserts.len(), 2, "should produce config and bundle inserts"); let script = &inserts[0]; assert!( @@ -3237,10 +3102,9 @@ external_bundle_sri = "sha384-AAAA" } #[test] - fn head_injector_emits_managed_external_bundle_script_with_hash_and_integrity() { + fn head_injector_emits_external_bundle_script_with_hash_and_integrity() { let sha256 = "a".repeat(64); let mut config = base_config(); - config.bundle_mode = PrebidBundleMode::ManagedExternal; config.external_bundle_url = Some("https://assets.example/prebid/trusted-prebid.js".to_string()); config.external_bundle_sha256 = Some(sha256.clone()); @@ -3275,9 +3139,8 @@ external_bundle_sri = "sha384-AAAA" } #[test] - fn head_injector_emits_managed_external_bundle_script_without_hash_query_when_unhashed() { + fn head_injector_emits_external_bundle_script_without_hash_query_when_unhashed() { let mut config = base_config(); - config.bundle_mode = PrebidBundleMode::ManagedExternal; config.external_bundle_url = Some("https://assets.example/prebid/trusted-prebid.js".to_string()); let integration = PrebidIntegration::new(config); @@ -4471,8 +4334,17 @@ external_bundle_sri = "sha384-AAAA" let routes = integration.routes(); - // Should have 0 routes when no script patterns configured - assert_eq!(routes.len(), 0); + assert_eq!( + routes.len(), + 1, + "should keep bundle route when no script patterns configured" + ); + assert!( + routes + .iter() + .any(|route| route.path == PREBID_BUNDLE_ROUTE && route.method == Method::GET), + "should register the bundle route" + ); } #[test] diff --git a/crates/trusted-server-core/src/integrations/registry.rs b/crates/trusted-server-core/src/integrations/registry.rs index af1d8f350..6c45221aa 100644 --- a/crates/trusted-server-core/src/integrations/registry.rs +++ b/crates/trusted-server-core/src/integrations/registry.rs @@ -1931,7 +1931,7 @@ mod tests { } #[test] - fn js_module_ids_immediate_excludes_prebid_and_includes_js_only_modules() { + fn js_module_ids_exclude_prebid_and_include_js_only_modules() { let settings = crate::test_support::tests::create_test_settings(); let mut settings_with_prebid = settings; settings_with_prebid @@ -1941,6 +1941,7 @@ mod tests { &serde_json::json!({ "enabled": true, "server_url": "https://test-prebid.com/openrtb2/auction", + "external_bundle_url": "https://assets.example/prebid/trusted-prebid.js", "timeout_ms": 1000, "bidders": ["mocktioneer"], "debug": false @@ -1956,8 +1957,8 @@ mod tests { let deferred = registry.js_module_ids_deferred(); assert!( - all.contains(&"prebid"), - "should include prebid in full list" + !all.contains(&"prebid"), + "should not include prebid in embedded TSJS module IDs" ); assert!( immediate.contains(&"creative"), @@ -1972,8 +1973,8 @@ mod tests { "should not include prebid in immediate IDs" ); assert!( - deferred.contains(&"prebid"), - "should include prebid in deferred IDs" + !deferred.contains(&"prebid"), + "should not include prebid in deferred IDs" ); } @@ -1986,7 +1987,8 @@ mod tests { "prebid", &serde_json::json!({ "enabled": false, - "server_url": "https://test-prebid.com/openrtb2/auction" + "server_url": "https://test-prebid.com/openrtb2/auction", + "external_bundle_url": "https://assets.example/prebid/trusted-prebid.js", }), ) .expect("should update prebid config"); @@ -2001,7 +2003,7 @@ mod tests { } #[test] - fn js_module_ids_exclude_prebid_when_managed_external() { + fn js_module_ids_exclude_prebid_when_external_bundle_is_configured() { let mut settings = crate::test_support::tests::create_test_settings(); settings .integrations @@ -2010,7 +2012,6 @@ mod tests { &serde_json::json!({ "enabled": true, "server_url": "https://test-prebid.com/openrtb2/auction", - "bundle_mode": "managed_external", "external_bundle_url": "https://assets.example/prebid/trusted-prebid.js" }), ) @@ -2020,19 +2021,19 @@ mod tests { assert!( !registry.js_module_ids().contains(&"prebid"), - "managed external mode should not include prebid in embedded TSJS modules" + "external bundle mode should not include prebid in embedded TSJS modules" ); assert!( !registry.js_module_ids_immediate().contains(&"prebid"), - "managed external mode should not include prebid in immediate TSJS modules" + "external bundle mode should not include prebid in immediate TSJS modules" ); assert!( !registry.js_module_ids_deferred().contains(&"prebid"), - "managed external mode should not include prebid in deferred TSJS modules" + "external bundle mode should not include prebid in deferred TSJS modules" ); assert!( registry.has_route(&Method::GET, "/integrations/prebid/bundle.js"), - "managed external mode should register the first-party bundle route" + "external bundle mode should register the first-party bundle route" ); } @@ -2047,6 +2048,7 @@ mod tests { &serde_json::json!({ "enabled": true, "server_url": "https://test-prebid.com/openrtb2/auction", + "external_bundle_url": "https://assets.example/prebid/trusted-prebid.js", "timeout_ms": 1000, "bidders": ["mocktioneer"], "debug": false diff --git a/crates/trusted-server-core/src/publisher.rs b/crates/trusted-server-core/src/publisher.rs index 794500954..6ffa2cdd1 100644 --- a/crates/trusted-server-core/src/publisher.rs +++ b/crates/trusted-server-core/src/publisher.rs @@ -173,7 +173,7 @@ pub fn handle_tsjs_dynamic( Ok(not_found_response()) } -/// Extract a module ID from a deferred-module filename like `tsjs-prebid.min.js`. +/// Extract a module ID from a deferred-module filename like `tsjs-sourcepoint.min.js`. /// /// Returns `Some(&'static str)` if the filename matches a known JS module ID, /// `None` otherwise. The caller must additionally verify that the module is @@ -1476,14 +1476,14 @@ mod tests { #[test] fn parse_deferred_module_filename_extracts_known_id() { assert_eq!( - parse_deferred_module_filename("tsjs-prebid.min.js"), - Some("prebid"), - "should extract prebid from minified filename" + parse_deferred_module_filename("tsjs-sourcepoint.min.js"), + Some("sourcepoint"), + "should extract sourcepoint from minified filename" ); assert_eq!( - parse_deferred_module_filename("tsjs-prebid.js"), - Some("prebid"), - "should extract prebid from unminified filename" + parse_deferred_module_filename("tsjs-sourcepoint.js"), + Some("sourcepoint"), + "should extract sourcepoint from unminified filename" ); } @@ -1505,15 +1505,14 @@ mod tests { "should reject without tsjs- prefix" ); assert_eq!( - parse_deferred_module_filename("tsjs-prebid.txt"), + parse_deferred_module_filename("tsjs-sourcepoint.txt"), None, "should reject non-js extension" ); } #[test] - fn tsjs_dynamic_serves_deferred_prebid_when_enabled() { - // Default test settings include prebid enabled + fn tsjs_dynamic_does_not_serve_embedded_prebid() { let settings = create_test_settings(); let registry = IntegrationRegistry::new(&settings).expect("should create integration registry"); @@ -1524,9 +1523,9 @@ mod tests { let response = handle_tsjs_dynamic(&req, ®istry).expect("should handle tsjs request"); assert_eq!( - response.status(), - StatusCode::OK, - "should serve deferred prebid module when enabled" + response.get_status(), + StatusCode::NOT_FOUND, + "should not serve embedded prebid module" ); } @@ -1539,7 +1538,8 @@ mod tests { "prebid", &serde_json::json!({ "enabled": false, - "server_url": "https://test-prebid.com/openrtb2/auction" + "server_url": "https://test-prebid.com/openrtb2/auction", + "external_bundle_url": "https://assets.example/prebid/trusted-prebid.js", }), ) .expect("should update prebid config"); diff --git a/crates/trusted-server-core/src/test_support.rs b/crates/trusted-server-core/src/test_support.rs index 3b91886c4..d343ac969 100644 --- a/crates/trusted-server-core/src/test_support.rs +++ b/crates/trusted-server-core/src/test_support.rs @@ -23,7 +23,8 @@ pub mod tests { [integrations.prebid] enabled = true - server_url = "https://test-prebid.com/openrtb2/auction" + server_url = "https://test-prebid.com/openrtb2/auction" + external_bundle_url = "https://assets.example/prebid/trusted-prebid.js" [integrations.nextjs] enabled = false diff --git a/docs/guide/creative-processing.md b/docs/guide/creative-processing.md index 7a8977a99..cad9a5cf2 100644 --- a/docs/guide/creative-processing.md +++ b/docs/guide/creative-processing.md @@ -748,7 +748,7 @@ Each integration is built as a separate IIFE at compile time (`crates/js/lib/dis - `tsjs-core.js` — Core API (always included) - `tsjs-creative.js` — Creative click-guard and tracking -- `tsjs-prebid.js` — Prebid.js NPM bundle with trustedServer adapter +- Prebid is built externally with `build-prebid-external.mjs` and served through `/integrations/prebid/bundle.js` - `tsjs-lockr.js`, `tsjs-permutive.js`, `tsjs-didomi.js`, `tsjs-datadome.js`, `tsjs-testlight.js` — Other integrations At runtime, the server concatenates `tsjs-core.js` + the modules for enabled integrations. The URL stays `/static/tsjs=tsjs-unified.min.js?v=` for backward compatibility. diff --git a/docs/guide/integration-guide.md b/docs/guide/integration-guide.md index 79576b8bd..e3b6a34cb 100644 --- a/docs/guide/integration-guide.md +++ b/docs/guide/integration-guide.md @@ -212,7 +212,7 @@ impl IntegrationScriptRewriter for MyIntegration { `html_processor.rs` calls these hooks after applying the standard origin→first-party rewrite, so you can simply swap URLs, append query parameters, or mutate inline JSON. Use this to point `") } + fn is_managed_external(&self) -> bool { + self.config.external_bundle_url.is_some() + } + fn external_bundle_request_cache_mode( &self, req: &http::Request, diff --git a/crates/trusted-server-core/src/publisher.rs b/crates/trusted-server-core/src/publisher.rs index 6ffa2cdd1..482e141a3 100644 --- a/crates/trusted-server-core/src/publisher.rs +++ b/crates/trusted-server-core/src/publisher.rs @@ -1523,7 +1523,7 @@ mod tests { let response = handle_tsjs_dynamic(&req, ®istry).expect("should handle tsjs request"); assert_eq!( - response.get_status(), + response.status(), StatusCode::NOT_FOUND, "should not serve embedded prebid module" ); diff --git a/crates/trusted-server-core/src/tsjs.rs b/crates/trusted-server-core/src/tsjs.rs index 07c0c97e3..45aee02eb 100644 --- a/crates/trusted-server-core/src/tsjs.rs +++ b/crates/trusted-server-core/src/tsjs.rs @@ -113,10 +113,10 @@ mod tests { #[test] fn tsjs_script_src_hash_changes_with_module_set() { let creative_src = tsjs_script_src(&["creative"]); - let creative_prebid_src = tsjs_script_src(&["creative", "prebid"]); + let creative_datadome_src = tsjs_script_src(&["creative", "datadome"]); assert_ne!( - creative_src, creative_prebid_src, + creative_src, creative_datadome_src, "should include requested modules in cache-busting hash" ); } @@ -124,8 +124,8 @@ mod tests { #[test] fn tsjs_script_src_hash_depends_on_module_order() { assert_ne!( - tsjs_script_src(&["creative", "prebid"]), - tsjs_script_src(&["prebid", "creative"]), + tsjs_script_src(&["creative", "datadome"]), + tsjs_script_src(&["datadome", "creative"]), "should include module order in cache-busting hash" ); } @@ -133,8 +133,8 @@ mod tests { #[test] fn tsjs_script_src_deduplicates_core_module() { assert_eq!( - tsjs_script_src(&["core", "prebid"]), - tsjs_script_src(&["prebid"]), + tsjs_script_src(&["core", "datadome"]), + tsjs_script_src(&["datadome"]), "should not hash core twice when requested explicitly" ); } @@ -182,17 +182,22 @@ mod tests { #[test] fn tsjs_deferred_script_src_formats_known_module_url_with_hash() { - let src = tsjs_deferred_script_src("prebid"); + let src = tsjs_deferred_script_src("creative"); assert!( - src.starts_with("/static/tsjs=tsjs-prebid.min.js?v="), + src.starts_with("/static/tsjs=tsjs-creative.min.js?v="), "should use per-module static bundle path" ); assert_sha256_hex_hash(hash_query_value(&src)); } #[test] - fn tsjs_deferred_script_src_uses_empty_hash_for_unknown_module() { + fn tsjs_deferred_script_src_uses_empty_hash_for_external_or_unknown_module() { + assert_eq!( + tsjs_deferred_script_src("prebid"), + "/static/tsjs=tsjs-prebid.min.js?v=", + "prebid now ships as an external bundle and has no local hash" + ); assert_eq!( tsjs_deferred_script_src("unknown-module"), "/static/tsjs=tsjs-unknown-module.min.js?v=", From 3fe29d63759c86922fa74ba503b22932b6079ab7 Mon Sep 17 00:00:00 2001 From: Christian Date: Thu, 18 Jun 2026 18:57:17 -0500 Subject: [PATCH 6/7] Add Prebid bundle CLI command --- .github/workflows/test.yml | 2 +- .gitignore | 1 + CLAUDE.md | 2 + Cargo.lock | 1 + Cargo.toml | 1 + crates/trusted-server-cli/Cargo.toml | 1 + crates/trusted-server-cli/src/args.rs | 67 ++ crates/trusted-server-cli/src/lib.rs | 2 + .../trusted-server-cli/src/prebid_bundle.rs | 898 ++++++++++++++++++ crates/trusted-server-cli/src/run.rs | 130 ++- docs/guide/cli.md | 34 + docs/guide/integrations/prebid.md | 22 + .../2026-06-17-prebid-bundle-cli-design.md | 412 ++++++++ scripts/test-cli.sh | 21 + trusted-server.example.toml | 4 + 15 files changed, 1596 insertions(+), 2 deletions(-) create mode 100644 crates/trusted-server-cli/src/prebid_bundle.rs create mode 100644 docs/superpowers/specs/2026-06-17-prebid-bundle-cli-design.md create mode 100755 scripts/test-cli.sh diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index ee0eec56e..9904eae30 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -52,7 +52,7 @@ jobs: run: cargo test --workspace - name: Run host-target CLI tests - run: cargo test --package trusted-server-cli --target x86_64-unknown-linux-gnu + run: ./scripts/test-cli.sh - name: Verify Fastly WASM release build env: diff --git a/.gitignore b/.gitignore index 901c9ce15..00c2179a3 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,7 @@ /pkg /target /crates/integration-tests/target +/dist/prebid/ # env .env* diff --git a/CLAUDE.md b/CLAUDE.md index 9c2d75492..177298483 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -62,6 +62,8 @@ cargo test --workspace # Run host-target CLI tests (workspace default target is wasm32-wasip1) # Use your host triple, for example x86_64-unknown-linux-gnu on CI/Linux # or aarch64-apple-darwin on Apple Silicon macOS. +# Use the local helper (recommended): +# ./scripts/test-cli.sh cargo test --package trusted-server-cli --target # Format diff --git a/Cargo.lock b/Cargo.lock index 3e5747c4a..e138ccc8e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4210,6 +4210,7 @@ dependencies = [ "tempfile", "tokio", "toml", + "toml_edit", "trusted-server-core", "url", "validator", diff --git a/Cargo.toml b/Cargo.toml index 5beb2cf03..c50edffcf 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -91,6 +91,7 @@ temp-env = "0.3.6" tempfile = "3.24" tokio = { version = "1.49", features = ["sync", "macros", "io-util", "rt", "time"] } toml = "1.1" +toml_edit = "0.23.10" trusted-server-core = { path = "crates/trusted-server-core" } url = "2.5.8" urlencoding = "2.1" diff --git a/crates/trusted-server-cli/Cargo.toml b/crates/trusted-server-cli/Cargo.toml index b355fa12e..d0716d47f 100644 --- a/crates/trusted-server-cli/Cargo.toml +++ b/crates/trusted-server-cli/Cargo.toml @@ -30,6 +30,7 @@ serde_json = { workspace = true } tempfile = { workspace = true } tokio = { workspace = true } toml = { workspace = true } +toml_edit = { workspace = true } trusted-server-core = { workspace = true } url = { workspace = true } validator = { workspace = true } diff --git a/crates/trusted-server-cli/src/args.rs b/crates/trusted-server-cli/src/args.rs index 670fe8351..9417421ca 100644 --- a/crates/trusted-server-cli/src/args.rs +++ b/crates/trusted-server-cli/src/args.rs @@ -22,6 +22,8 @@ pub enum Command { Config(ConfigCommand), /// Deploy the project through a target adapter. Deploy(DelegateArgs), + /// Trusted Server Prebid commands. + Prebid(PrebidArgs), /// Provision platform resources through a target adapter. Provision(DelegateArgs), /// Serve the project locally through a target adapter. @@ -95,6 +97,28 @@ pub enum ConfigCommand { Push(ConfigPushArgs), } +#[derive(Debug, clap::Args)] +pub struct PrebidArgs { + #[command(subcommand)] + pub command: PrebidCommand, +} + +#[derive(Debug, Subcommand)] +pub enum PrebidCommand { + /// Generate a local external Prebid bundle and update config metadata. + Bundle(PrebidBundleArgs), +} + +#[derive(Debug, clap::Args)] +pub struct PrebidBundleArgs { + /// Trusted Server config path. + #[arg(long, default_value = "trusted-server.toml")] + pub config: PathBuf, + /// Local output directory for generated Prebid bundle artifacts. + #[arg(long, default_value = "dist/prebid")] + pub out: PathBuf, +} + #[derive(Debug, clap::Args)] pub struct ConfigInitArgs { /// Target config path. @@ -259,4 +283,47 @@ mod tests { assert!(!push.local); assert!(!push.dry_run); } + + #[test] + fn prebid_bundle_defaults_match_spec() { + let args = Args::try_parse_from(["ts", "prebid", "bundle"]) + .expect("should parse prebid bundle command"); + let Command::Prebid(prebid) = args.command else { + panic!("expected prebid command"); + }; + let PrebidCommand::Bundle(bundle) = prebid.command; + assert_eq!(bundle.config, PathBuf::from("trusted-server.toml")); + assert_eq!(bundle.out, PathBuf::from("dist/prebid")); + } + + #[test] + fn prebid_bundle_accepts_custom_paths() { + let args = Args::try_parse_from([ + "ts", + "prebid", + "bundle", + "--config", + "publisher.toml", + "--out", + "build/prebid", + ]) + .expect("should parse prebid bundle command"); + let Command::Prebid(prebid) = args.command else { + panic!("expected prebid command"); + }; + let PrebidCommand::Bundle(bundle) = prebid.command; + assert_eq!(bundle.config, PathBuf::from("publisher.toml")); + assert_eq!(bundle.out, PathBuf::from("build/prebid")); + } + + #[test] + fn prebid_bundle_does_not_accept_adapter_option() { + let error = Args::try_parse_from(["ts", "prebid", "bundle", "--adapter", "fastly"]) + .expect_err("should reject prebid adapter option"); + assert!( + error.to_string().contains("unexpected argument") + || error.to_string().contains("Found argument"), + "error should explain unsupported option" + ); + } } diff --git a/crates/trusted-server-cli/src/lib.rs b/crates/trusted-server-cli/src/lib.rs index 26c1c37d2..147f77ff0 100644 --- a/crates/trusted-server-cli/src/lib.rs +++ b/crates/trusted-server-cli/src/lib.rs @@ -20,6 +20,8 @@ mod edgezero_delegate; #[cfg(not(target_arch = "wasm32"))] mod error; #[cfg(not(target_arch = "wasm32"))] +mod prebid_bundle; +#[cfg(not(target_arch = "wasm32"))] mod run; #[cfg(not(target_arch = "wasm32"))] diff --git a/crates/trusted-server-cli/src/prebid_bundle.rs b/crates/trusted-server-cli/src/prebid_bundle.rs new file mode 100644 index 000000000..f5aa088cb --- /dev/null +++ b/crates/trusted-server-cli/src/prebid_bundle.rs @@ -0,0 +1,898 @@ +use std::env; +use std::fs::{self, OpenOptions}; +use std::io::Write; +use std::path::{Path, PathBuf}; +use std::process::{Command, Stdio}; + +use serde::Deserialize; +use toml_edit::{table, value, DocumentMut, Item}; + +use crate::args::PrebidBundleArgs; +use crate::error::{cli_error, report_error, CliResult}; + +const NODE_MODULES_MISSING_HELP: &str = + "Prebid bundling dependencies are missing. Run `cd crates/js/lib && npm ci`, then retry `ts prebid bundle`."; + +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) struct PrebidBundleConfig { + pub adapters: Vec, + pub user_id_modules: Option>, + pub external_bundle_url: Option, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) struct PrebidBundleGenerateRequest { + pub js_lib_dir: PathBuf, + pub out_dir: PathBuf, + pub adapters: Vec, + pub user_id_modules: Option>, +} + +pub(crate) trait PrebidBundleGenerator { + fn generate( + &mut self, + request: &PrebidBundleGenerateRequest, + out: &mut dyn Write, + err: &mut dyn Write, + ) -> CliResult<()>; +} + +#[derive(Default)] +pub(crate) struct NpmPrebidBundleGenerator; + +impl PrebidBundleGenerator for NpmPrebidBundleGenerator { + fn generate( + &mut self, + request: &PrebidBundleGenerateRequest, + out: &mut dyn Write, + err: &mut dyn Write, + ) -> CliResult<()> { + ensure_local_build_prerequisites(&request.js_lib_dir)?; + + let args = npm_prebid_bundle_args(request); + + let output = Command::new("npm") + .args(&args) + .current_dir(&request.js_lib_dir) + .stdin(Stdio::null()) + .output() + .map_err(|error| { + report_error(format!( + "failed to run Prebid bundle generator with npm: {error}" + )) + })?; + + if !output.stdout.is_empty() { + out.write_all(&output.stdout).map_err(|error| { + report_error(format!("failed to forward generator stdout: {error}")) + })?; + } + + if !output.stderr.is_empty() { + err.write_all(&output.stderr).map_err(|error| { + report_error(format!("failed to forward generator stderr: {error}")) + })?; + } + + if output.status.success() { + Ok(()) + } else { + cli_error(format!( + "Prebid bundle generator exited with status {}", + output.status + )) + } + } +} + +fn npm_prebid_bundle_args(request: &PrebidBundleGenerateRequest) -> Vec { + let mut args = vec![ + "run".to_string(), + "build:prebid-external".to_string(), + "--".to_string(), + "--adapters".to_string(), + request.adapters.join(","), + ]; + if let Some(user_id_modules) = &request.user_id_modules { + args.push("--user-id-modules".to_string()); + args.push(user_id_modules.join(",")); + } + args.push("--out".to_string()); + args.push(request.out_dir.display().to_string()); + args +} + +#[derive(Debug, Deserialize)] +struct PrebidBundleManifest { + sha256: String, + sri: String, + filename: String, +} + +pub(crate) fn run_bundle( + args: &PrebidBundleArgs, + generator: &mut dyn PrebidBundleGenerator, + out: &mut dyn Write, + err: &mut dyn Write, +) -> CliResult<()> { + let config = load_bundle_config(&args.config)?; + let current_dir = env::current_dir() + .map_err(|error| report_error(format!("failed to read current directory: {error}")))?; + let js_lib_dir = find_js_lib_dir(¤t_dir)?; + let out_dir = resolve_output_dir(¤t_dir, &args.out); + ensure_output_dir_writable(&out_dir)?; + + let request = PrebidBundleGenerateRequest { + js_lib_dir, + out_dir: out_dir.clone(), + adapters: config.adapters, + user_id_modules: config.user_id_modules, + }; + + generator.generate(&request, out, err)?; + + let manifest_path = out_dir.join("manifest.json"); + let manifest = load_manifest(&manifest_path)?; + patch_config_metadata(&args.config, &manifest.sha256, &manifest.sri)?; + + writeln!( + out, + "Built Prebid bundle: {}", + out_dir.join(&manifest.filename).display() + ) + .map_err(|error| report_error(format!("failed to write command output: {error}")))?; + writeln!(out, "Manifest: {}", manifest_path.display()) + .map_err(|error| report_error(format!("failed to write command output: {error}")))?; + writeln!(out, "Updated config: {}", args.config.display()) + .map_err(|error| report_error(format!("failed to write command output: {error}")))?; + + if config.external_bundle_url.is_none() { + writeln!( + out, + "Next: upload the bundle and set integrations.prebid.external_bundle_url to its HTTPS URL." + ) + } else { + writeln!( + out, + "Next: upload the bundle and update integrations.prebid.external_bundle_url if the hosted filename changed." + ) + } + .map_err(|error| report_error(format!("failed to write command output: {error}")))?; + + Ok(()) +} + +pub(crate) fn load_bundle_config(config_path: &Path) -> CliResult { + let contents = fs::read_to_string(config_path).map_err(|error| { + report_error(format!( + "missing {}: run `ts config init` or pass --config : {error}", + config_path.display() + )) + })?; + let root: toml::Value = toml::from_str(&contents).map_err(|error| { + report_error(format!( + "invalid TOML in {}: {error}", + config_path.display() + )) + })?; + + let prebid = root + .get("integrations") + .and_then(|integrations| integrations.get("prebid")) + .ok_or_else(|| { + report_error(format!( + "{} is missing [integrations.prebid]", + config_path.display() + )) + })?; + let bundle = prebid.get("bundle").ok_or_else(|| { + report_error(format!( + "{} is missing [integrations.prebid.bundle]", + config_path.display() + )) + })?; + + let adapters = read_required_string_array( + bundle, + "adapters", + "integrations.prebid.bundle.adapters", + config_path, + )?; + if adapters.is_empty() { + return cli_error(format!( + "{} must define at least one integrations.prebid.bundle.adapters entry", + config_path.display() + )); + } + + let user_id_modules = read_optional_string_array( + bundle, + "user_id_modules", + "integrations.prebid.bundle.user_id_modules", + config_path, + )?; + if matches!(user_id_modules.as_ref(), Some(modules) if modules.is_empty()) { + return cli_error(format!( + "{} integrations.prebid.bundle.user_id_modules must not be empty when present", + config_path.display() + )); + } + + let external_bundle_url = prebid + .get("external_bundle_url") + .and_then(toml::Value::as_str) + .map(str::to_string); + + Ok(PrebidBundleConfig { + adapters, + user_id_modules, + external_bundle_url, + }) +} + +fn read_required_string_array( + table: &toml::Value, + key: &str, + field_name: &str, + config_path: &Path, +) -> CliResult> { + let value = table.get(key).ok_or_else(|| { + report_error(format!( + "{} is missing required {field_name}", + config_path.display() + )) + })?; + read_string_array(value, field_name, config_path) +} + +fn read_optional_string_array( + table: &toml::Value, + key: &str, + field_name: &str, + config_path: &Path, +) -> CliResult>> { + table + .get(key) + .map(|value| read_string_array(value, field_name, config_path)) + .transpose() +} + +fn read_string_array( + value: &toml::Value, + field_name: &str, + config_path: &Path, +) -> CliResult> { + let Some(items) = value.as_array() else { + return cli_error(format!( + "{} {field_name} must be an array of non-empty strings", + config_path.display() + )); + }; + + let mut strings = Vec::with_capacity(items.len()); + for item in items { + let Some(raw) = item.as_str() else { + return cli_error(format!( + "{} {field_name} must be an array of non-empty strings", + config_path.display() + )); + }; + let trimmed = raw.trim(); + if trimmed.is_empty() { + return cli_error(format!( + "{} {field_name} must not contain empty strings", + config_path.display() + )); + } + strings.push(trimmed.to_string()); + } + + Ok(strings) +} + +fn ensure_local_build_prerequisites(js_lib_dir: &Path) -> CliResult<()> { + which::which("npm").map_err(|error| { + report_error(format!( + "npm is required to build the Prebid bundle but was not found on PATH: {error}" + )) + })?; + + ensure_file_exists( + &js_lib_dir.join("package.json"), + "Prebid bundle package manifest", + )?; + ensure_file_exists( + &js_lib_dir.join("build-prebid-external.mjs"), + "Prebid external bundle generator", + )?; + + let node_modules = js_lib_dir.join("node_modules"); + if !node_modules.is_dir() { + return cli_error(NODE_MODULES_MISSING_HELP); + } + + Ok(()) +} + +fn ensure_file_exists(path: &Path, description: &str) -> CliResult<()> { + if path.is_file() { + Ok(()) + } else { + cli_error(format!("missing {description}: {}", path.display())) + } +} + +fn find_js_lib_dir(start: &Path) -> CliResult { + for ancestor in start.ancestors() { + let candidate = ancestor.join("crates/js/lib"); + if is_js_lib_dir(&candidate) { + return Ok(candidate); + } + } + + let manifest_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")); + let candidate = manifest_dir.join("../..").join("crates/js/lib"); + if is_js_lib_dir(&candidate) { + return candidate.canonicalize().map_err(|error| { + report_error(format!( + "failed to resolve JS library directory {}: {error}", + candidate.display() + )) + }); + } + + cli_error( + "failed to locate crates/js/lib; run `ts prebid bundle` from the Trusted Server repository", + ) +} + +fn is_js_lib_dir(path: &Path) -> bool { + path.join("package.json").is_file() && path.join("build-prebid-external.mjs").is_file() +} + +fn resolve_output_dir(current_dir: &Path, out_dir: &Path) -> PathBuf { + if out_dir.is_absolute() { + out_dir.to_path_buf() + } else { + current_dir.join(out_dir) + } +} + +fn ensure_output_dir_writable(out_dir: &Path) -> CliResult<()> { + if out_dir.exists() && !out_dir.is_dir() { + return cli_error(format!( + "Prebid bundle output path {} exists but is not a directory", + out_dir.display() + )); + } + + fs::create_dir_all(out_dir).map_err(|error| { + report_error(format!( + "failed to create Prebid bundle output directory {}: {error}", + out_dir.display() + )) + })?; + + let probe = out_dir.join(format!( + ".ts-prebid-bundle-write-test-{}", + std::process::id() + )); + OpenOptions::new() + .write(true) + .create_new(true) + .open(&probe) + .map_err(|error| { + report_error(format!( + "Prebid bundle output directory {} is not writable: {error}", + out_dir.display() + )) + })?; + fs::remove_file(&probe).map_err(|error| { + report_error(format!( + "failed to remove Prebid bundle output probe {}: {error}", + probe.display() + )) + })?; + + Ok(()) +} + +fn load_manifest(path: &Path) -> CliResult { + let contents = fs::read_to_string(path).map_err(|error| { + report_error(format!( + "failed to read generated Prebid manifest {}: {error}", + path.display() + )) + })?; + let manifest: PrebidBundleManifest = serde_json::from_str(&contents).map_err(|error| { + report_error(format!( + "failed to parse generated Prebid manifest {}: {error}", + path.display() + )) + })?; + + if manifest.filename.trim().is_empty() { + return cli_error(format!( + "generated Prebid manifest {} is missing filename", + path.display() + )); + } + if manifest.sha256.len() != 64 || !manifest.sha256.bytes().all(|byte| byte.is_ascii_hexdigit()) + { + return cli_error(format!( + "generated Prebid manifest {} has invalid sha256", + path.display() + )); + } + if !manifest.sri.starts_with("sha384-") { + return cli_error(format!( + "generated Prebid manifest {} has invalid sri", + path.display() + )); + } + + Ok(manifest) +} + +fn patch_config_metadata(config_path: &Path, sha256: &str, sri: &str) -> CliResult<()> { + let contents = fs::read_to_string(config_path).map_err(|error| { + report_error(format!( + "failed to read config {} for metadata update: {error}", + config_path.display() + )) + })?; + let mut document = contents.parse::().map_err(|error| { + report_error(format!( + "failed to parse config {} for metadata update: {error}", + config_path.display() + )) + })?; + + if !document.contains_key("integrations") { + document.insert("integrations", table()); + } + let integrations = table_like_mut( + document + .get_mut("integrations") + .expect("should have integrations table"), + "integrations", + config_path, + )?; + + if !integrations.contains_key("prebid") { + integrations.insert("prebid", table()); + } + let prebid = table_like_mut( + integrations + .get_mut("prebid") + .expect("should have prebid table"), + "integrations.prebid", + config_path, + )?; + + prebid.insert("external_bundle_sha256", value(sha256)); + prebid.insert("external_bundle_sri", value(sri)); + + write_atomic(config_path, &document.to_string()) +} + +fn table_like_mut<'a>( + item: &'a mut Item, + field_name: &str, + config_path: &Path, +) -> CliResult<&'a mut dyn toml_edit::TableLike> { + item.as_table_like_mut().ok_or_else(|| { + report_error(format!( + "{} {field_name} must be a TOML table", + config_path.display() + )) + }) +} + +fn write_atomic(path: &Path, contents: &str) -> CliResult<()> { + let parent = path + .parent() + .filter(|parent| !parent.as_os_str().is_empty()) + .unwrap_or_else(|| Path::new(".")); + fs::create_dir_all(parent).map_err(|error| { + report_error(format!( + "failed to create config parent directory {}: {error}", + parent.display() + )) + })?; + + let filename = path + .file_name() + .and_then(|name| name.to_str()) + .unwrap_or("trusted-server.toml"); + let tmp_path = parent.join(format!(".{filename}.tmp-{}", std::process::id())); + + fs::write(&tmp_path, contents).map_err(|error| { + report_error(format!( + "failed to write temporary config {}: {error}", + tmp_path.display() + )) + })?; + fs::rename(&tmp_path, path).map_err(|error| { + let _ = fs::remove_file(&tmp_path); + report_error(format!( + "failed to replace config {} with {}: {error}", + path.display(), + tmp_path.display() + )) + })?; + + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn write_config(contents: &str) -> (tempfile::TempDir, PathBuf) { + let temp = tempfile::TempDir::new().expect("should create temp dir"); + let path = temp.path().join("trusted-server.toml"); + fs::write(&path, contents).expect("should write config"); + (temp, path) + } + + fn valid_config() -> String { + r#" +[integrations.prebid] +enabled = true +server_url = "https://prebid.example.com/openrtb2/auction" +external_bundle_url = "https://assets.example.com/prebid/trusted-prebid-old.js" + +[integrations.prebid.bundle] +adapters = ["rubicon", "kargo"] +user_id_modules = ["sharedIdSystem", "uid2IdSystem"] +"# + .to_string() + } + + #[test] + fn bundle_config_loader_accepts_valid_settings() { + let (_temp, path) = write_config(&valid_config()); + + let config = load_bundle_config(&path).expect("should load bundle config"); + + assert_eq!(config.adapters, ["rubicon", "kargo"]); + assert_eq!( + config.user_id_modules, + Some(vec![ + "sharedIdSystem".to_string(), + "uid2IdSystem".to_string() + ]) + ); + assert_eq!( + config.external_bundle_url.as_deref(), + Some("https://assets.example.com/prebid/trusted-prebid-old.js") + ); + } + + #[test] + fn bundle_config_loader_allows_missing_user_id_modules() { + let (_temp, path) = write_config( + r#" +[integrations.prebid] +enabled = true +server_url = "https://prebid.example.com/openrtb2/auction" + +[integrations.prebid.bundle] +adapters = ["rubicon"] +"#, + ); + + let config = load_bundle_config(&path).expect("should load bundle config"); + + assert_eq!(config.adapters, ["rubicon"]); + assert_eq!(config.user_id_modules, None); + } + + #[test] + fn bundle_config_loader_rejects_missing_prebid_block() { + let (_temp, path) = write_config("[publisher]\ndomain = \"example.com\"\n"); + + let error = load_bundle_config(&path).expect_err("should reject missing prebid block"); + + assert!( + error.to_string().contains("missing [integrations.prebid]"), + "error should explain missing prebid block: {error:?}" + ); + } + + #[test] + fn bundle_config_loader_rejects_missing_bundle_block() { + let (_temp, path) = write_config( + r#" +[integrations.prebid] +enabled = true +server_url = "https://prebid.example.com/openrtb2/auction" +"#, + ); + + let error = load_bundle_config(&path).expect_err("should reject missing bundle block"); + + assert!( + error + .to_string() + .contains("missing [integrations.prebid.bundle]"), + "error should explain missing bundle block: {error:?}" + ); + } + + #[test] + fn bundle_config_loader_rejects_empty_adapters() { + let (_temp, path) = write_config( + r#" +[integrations.prebid] +enabled = true +server_url = "https://prebid.example.com/openrtb2/auction" + +[integrations.prebid.bundle] +adapters = [] +"#, + ); + + let error = load_bundle_config(&path).expect_err("should reject empty adapters"); + + assert!( + error.to_string().contains("at least one"), + "error should explain empty adapters: {error:?}" + ); + } + + #[test] + fn bundle_config_loader_rejects_malformed_adapters() { + let (_temp, path) = write_config( + r#" +[integrations.prebid] +enabled = true +server_url = "https://prebid.example.com/openrtb2/auction" + +[integrations.prebid.bundle] +adapters = ["rubicon", 123] +"#, + ); + + let error = load_bundle_config(&path).expect_err("should reject malformed adapters"); + + assert!( + error.to_string().contains("array of non-empty strings"), + "error should explain malformed adapters: {error:?}" + ); + } + + #[test] + fn output_dir_validation_rejects_existing_file() { + let temp = tempfile::TempDir::new().expect("should create temp dir"); + let out_path = temp.path().join("prebid"); + fs::write(&out_path, "not a directory").expect("should write file"); + + let error = + ensure_output_dir_writable(&out_path).expect_err("should reject output path file"); + + assert!( + error.to_string().contains("not a directory"), + "error should explain invalid output path: {error:?}" + ); + } + + #[test] + fn output_dir_validation_creates_writable_directory() { + let temp = tempfile::TempDir::new().expect("should create temp dir"); + let out_path = temp.path().join("dist/prebid"); + + ensure_output_dir_writable(&out_path).expect("should create output dir"); + + assert!(out_path.is_dir(), "should create output directory"); + } + + #[test] + fn npm_prebid_bundle_args_include_user_id_modules_when_configured() { + let request = PrebidBundleGenerateRequest { + js_lib_dir: PathBuf::from("crates/js/lib"), + out_dir: PathBuf::from("/tmp/prebid"), + adapters: vec!["rubicon".to_string(), "kargo".to_string()], + user_id_modules: Some(vec!["sharedIdSystem".to_string()]), + }; + + assert_eq!( + npm_prebid_bundle_args(&request), + [ + "run", + "build:prebid-external", + "--", + "--adapters", + "rubicon,kargo", + "--user-id-modules", + "sharedIdSystem", + "--out", + "/tmp/prebid", + ], + "should pass configured adapters, user ID modules, and output path" + ); + } + + #[test] + fn npm_prebid_bundle_args_omit_user_id_modules_when_not_configured() { + let request = PrebidBundleGenerateRequest { + js_lib_dir: PathBuf::from("crates/js/lib"), + out_dir: PathBuf::from("/tmp/prebid"), + adapters: vec!["rubicon".to_string()], + user_id_modules: None, + }; + + assert_eq!( + npm_prebid_bundle_args(&request), + [ + "run", + "build:prebid-external", + "--", + "--adapters", + "rubicon", + "--out", + "/tmp/prebid", + ], + "should omit user ID module flag so the JS generator uses its default preset" + ); + } + + #[test] + fn patch_config_metadata_writes_hash_and_sri() { + let (_temp, path) = write_config(&valid_config()); + let sha256 = "a".repeat(64); + let sri = "sha384-abc"; + + patch_config_metadata(&path, &sha256, sri).expect("should patch config metadata"); + + let contents = fs::read_to_string(&path).expect("should read patched config"); + let value: toml::Value = toml::from_str(&contents).expect("should parse patched config"); + let prebid = value + .get("integrations") + .and_then(|integrations| integrations.get("prebid")) + .expect("should have prebid table"); + assert_eq!( + prebid + .get("external_bundle_url") + .and_then(toml::Value::as_str), + Some("https://assets.example.com/prebid/trusted-prebid-old.js"), + "should preserve external bundle URL" + ); + assert_eq!( + prebid + .get("external_bundle_sha256") + .and_then(toml::Value::as_str), + Some(sha256.as_str()), + "should write sha256" + ); + assert_eq!( + prebid + .get("external_bundle_sri") + .and_then(toml::Value::as_str), + Some(sri), + "should write SRI" + ); + } + + struct FakeGenerator { + generate_error: Option, + generate_calls: Vec, + write_manifest: bool, + } + + impl PrebidBundleGenerator for FakeGenerator { + fn generate( + &mut self, + request: &PrebidBundleGenerateRequest, + out: &mut dyn Write, + err: &mut dyn Write, + ) -> CliResult<()> { + self.generate_calls.push(request.clone()); + + out.write_all(b"generator stdout\n") + .expect("should capture generator stdout"); + err.write_all(b"generator stderr\n") + .expect("should capture generator stderr"); + + if self.write_manifest { + fs::create_dir_all(&request.out_dir).expect("should create output dir"); + fs::write( + request.out_dir.join("manifest.json"), + serde_json::json!({ + "prebidVersion": "10.26.0", + "adapters": request.adapters, + "userIdModules": request.user_id_modules.clone().unwrap_or_default(), + "sha256": "b".repeat(64), + "sri": "sha384-test", + "filename": format!("trusted-prebid-{}.js", "b".repeat(64)) + }) + .to_string(), + ) + .expect("should write fake manifest"); + } + + if let Some(error) = &self.generate_error { + cli_error(error.clone()) + } else { + Ok(()) + } + } + } + + #[test] + fn run_bundle_forwards_generator_output_to_stdio() { + let (_temp, config_path) = write_config(&valid_config()); + let _out_root = tempfile::tempdir().expect("should create temp dir"); + let out_dir = _out_root.path().join("prebid"); + + let mut generator = FakeGenerator { + generate_error: None, + generate_calls: Vec::new(), + write_manifest: true, + }; + let mut out = Vec::new(); + let mut err = Vec::new(); + let args = PrebidBundleArgs { + config: config_path, + out: out_dir.clone(), + }; + + run_bundle(&args, &mut generator, &mut out, &mut err).expect("should run bundle command"); + + let output = String::from_utf8(out).expect("stdout should be valid utf8"); + assert!(output.contains("generator stdout")); + let stderr = String::from_utf8(err).expect("stderr should be valid utf8"); + assert!(stderr.contains("generator stderr")); + + assert_eq!(generator.generate_calls.len(), 1); + assert_eq!(generator.generate_calls[0].adapters, ["rubicon", "kargo"]); + + let patched = fs::read_to_string(&args.config).expect("should read patched config"); + assert!(patched.contains(&format!("external_bundle_sha256 = \"{}\"", "b".repeat(64)))); + assert!(patched.contains("external_bundle_sri = \"sha384-test\"")); + } + + #[test] + fn run_bundle_does_not_patch_config_when_generation_fails() { + let (_temp, config_path) = write_config(&valid_config()); + let original_config = + fs::read_to_string(&config_path).expect("should read baseline config"); + let _out_root = tempfile::tempdir().expect("should create temp dir"); + let out_dir = _out_root.path().join("prebid"); + + let mut generator = FakeGenerator { + generate_error: Some("builder failed".to_string()), + generate_calls: Vec::new(), + write_manifest: false, + }; + let mut out = Vec::new(); + let mut err = Vec::new(); + let args = PrebidBundleArgs { + config: config_path, + out: out_dir, + }; + + let error = run_bundle(&args, &mut generator, &mut out, &mut err) + .expect_err("should propagate generator failure"); + + assert!(error.to_string().contains("builder failed")); + assert!(fs::read_to_string(&args.config).expect("should read config") == original_config); + } + + #[test] + fn missing_node_modules_fails_with_npm_ci_instruction() { + let temp = tempfile::TempDir::new().expect("should create temp dir"); + fs::write(temp.path().join("package.json"), "{}").expect("should write package manifest"); + fs::write(temp.path().join("build-prebid-external.mjs"), "") + .expect("should write generator"); + + let error = ensure_local_build_prerequisites(temp.path()) + .expect_err("should reject missing node modules"); + + assert!( + error.to_string().contains("npm ci"), + "error should instruct npm ci: {error:?}" + ); + } +} diff --git a/crates/trusted-server-cli/src/run.rs b/crates/trusted-server-cli/src/run.rs index 8582b671c..3cbb014f9 100644 --- a/crates/trusted-server-cli/src/run.rs +++ b/crates/trusted-server-cli/src/run.rs @@ -2,7 +2,7 @@ use std::io::Write; use clap::Parser as _; -use crate::args::{Args, AuthCommand, Command, ConfigCommand}; +use crate::args::{Args, AuthCommand, Command, ConfigCommand, PrebidCommand}; use crate::audit::browser_collector::BrowserAuditCollector; use crate::audit::collector::AuditCollector; use crate::config_command::{load_config, run_init, run_validate}; @@ -10,6 +10,7 @@ use crate::edgezero_delegate::{ ConfigPushRequest, EdgeZeroDelegate, LifecycleCommand, ProductionEdgeZeroDelegate, }; use crate::error::CliResult; +use crate::prebid_bundle::{run_bundle, NpmPrebidBundleGenerator, PrebidBundleGenerator}; /// Run the CLI using process arguments and standard output streams. /// @@ -23,9 +24,11 @@ pub fn run_from_env() -> CliResult<()> { let mut stderr = std::io::stderr(); let mut delegate = ProductionEdgeZeroDelegate; let audit = BrowserAuditCollector; + let mut prebid_bundler = NpmPrebidBundleGenerator; let mut services = CliServices { edgezero: &mut delegate, audit: &audit, + prebid_bundler: &mut prebid_bundler, }; dispatch(args, &mut services, &mut stdout, &mut stderr) } @@ -46,9 +49,11 @@ where })?; let mut delegate = ProductionEdgeZeroDelegate; let audit = BrowserAuditCollector; + let mut prebid_bundler = NpmPrebidBundleGenerator; let mut services = CliServices { edgezero: &mut delegate, audit: &audit, + prebid_bundler: &mut prebid_bundler, }; dispatch(parsed, &mut services, out, err) } @@ -56,6 +61,7 @@ where struct CliServices<'a> { edgezero: &'a mut dyn EdgeZeroDelegate, audit: &'a dyn AuditCollector, + prebid_bundler: &'a mut dyn PrebidBundleGenerator, } fn dispatch( @@ -110,6 +116,9 @@ fn dispatch( &deploy.adapter, &deploy.edgezero_args, ), + Command::Prebid(prebid) => match prebid.command { + PrebidCommand::Bundle(bundle) => run_bundle(&bundle, services.prebid_bundler, out, err), + }, Command::Provision(provision) => services.edgezero.run_lifecycle( LifecycleCommand::Provision, &provision.adapter, @@ -134,6 +143,7 @@ mod tests { use super::*; use crate::audit::collector::{CollectedPage, CollectedRequest, CollectedScriptTag}; use crate::edgezero_delegate::tests::FakeEdgeZeroDelegate; + use crate::prebid_bundle::PrebidBundleGenerateRequest; fn valid_config() -> String { r#" @@ -158,6 +168,40 @@ password = "production-admin-password-32-bytes" calls: Cell, } + #[derive(Default)] + struct FakePrebidBundleGenerator { + calls: Vec, + write_manifest: bool, + } + + impl PrebidBundleGenerator for FakePrebidBundleGenerator { + fn generate( + &mut self, + request: &PrebidBundleGenerateRequest, + _out: &mut dyn Write, + _err: &mut dyn Write, + ) -> CliResult<()> { + self.calls.push(request.clone()); + if self.write_manifest { + fs::create_dir_all(&request.out_dir).expect("should create fake output dir"); + fs::write( + request.out_dir.join("manifest.json"), + serde_json::json!({ + "prebidVersion": "10.26.0", + "adapters": request.adapters, + "userIdModules": request.user_id_modules.clone().unwrap_or_default(), + "sha256": "a".repeat(64), + "sri": "sha384-test", + "filename": format!("trusted-prebid-{}.js", "a".repeat(64)) + }) + .to_string(), + ) + .expect("should write fake manifest"); + } + Ok(()) + } + } + impl AuditCollector for FakeAuditCollector { fn collect_page(&self, _target_url: &Url) -> CliResult { self.calls.set(self.calls.get() + 1); @@ -194,9 +238,11 @@ password = "production-admin-password-32-bytes" let audit = FakeAuditCollector { calls: Cell::new(0), }; + let mut prebid_bundler = FakePrebidBundleGenerator::default(); let mut services = CliServices { edgezero: delegate, audit: &audit, + prebid_bundler: &mut prebid_bundler, }; dispatch(args, &mut services, out, err) } @@ -243,9 +289,11 @@ password = "production-admin-password-32-bytes" let audit = FakeAuditCollector { calls: Cell::new(0), }; + let mut prebid_bundler = FakePrebidBundleGenerator::default(); let mut services = CliServices { edgezero: &mut delegate, audit: &audit, + prebid_bundler: &mut prebid_bundler, }; let mut out = Vec::new(); @@ -263,6 +311,86 @@ password = "production-admin-password-32-bytes" assert!(assets_path.exists(), "should write audit artifact"); } + #[test] + fn prebid_bundle_invokes_generator_and_patches_config() { + let temp = TempDir::new().expect("should create temp dir"); + let config_path = temp.path().join("trusted-server.toml"); + let out_dir = temp.path().join("dist/prebid"); + fs::write( + &config_path, + r#" +[integrations.prebid] +enabled = true +server_url = "https://prebid.example.com/openrtb2/auction" +external_bundle_url = "https://assets.example.com/prebid/trusted-prebid-old.js" + +[integrations.prebid.bundle] +adapters = ["rubicon", "kargo"] +user_id_modules = ["sharedIdSystem"] +"#, + ) + .expect("should write config"); + let args = Args::try_parse_from([ + "ts", + "prebid", + "bundle", + "--config", + config_path.to_str().expect("path should be UTF-8"), + "--out", + out_dir.to_str().expect("path should be UTF-8"), + ]) + .expect("should parse prebid bundle args"); + let mut delegate = FakeEdgeZeroDelegate::default(); + let audit = FakeAuditCollector { + calls: Cell::new(0), + }; + let mut prebid_bundler = FakePrebidBundleGenerator { + calls: Vec::new(), + write_manifest: true, + }; + let mut services = CliServices { + edgezero: &mut delegate, + audit: &audit, + prebid_bundler: &mut prebid_bundler, + }; + let mut out = Vec::new(); + + dispatch(args, &mut services, &mut out, &mut Vec::new()) + .expect("should dispatch prebid bundle"); + + assert_eq!(prebid_bundler.calls.len(), 1); + assert_eq!(prebid_bundler.calls[0].adapters, ["rubicon", "kargo"]); + assert_eq!( + prebid_bundler.calls[0].user_id_modules, + Some(vec!["sharedIdSystem".to_string()]) + ); + assert_eq!(prebid_bundler.calls[0].out_dir, out_dir); + assert!( + delegate.lifecycle_calls.is_empty(), + "should not call EdgeZero lifecycle delegate" + ); + assert!( + delegate.push_calls.is_empty(), + "should not call EdgeZero config push delegate" + ); + + let patched = fs::read_to_string(&config_path).expect("should read patched config"); + assert!( + patched.contains( + "external_bundle_url = \"https://assets.example.com/prebid/trusted-prebid-old.js\"" + ), + "should preserve configured external bundle URL" + ); + assert!( + patched.contains(&format!("external_bundle_sha256 = \"{}\"", "a".repeat(64))), + "should patch sha256 from manifest" + ); + assert!( + patched.contains("external_bundle_sri = \"sha384-test\""), + "should patch SRI from manifest" + ); + } + #[test] fn config_push_validates_and_forwards_entries() { let temp = TempDir::new().expect("should create temp dir"); diff --git a/docs/guide/cli.md b/docs/guide/cli.md index 6bf0f681b..0920c937c 100644 --- a/docs/guide/cli.md +++ b/docs/guide/cli.md @@ -75,3 +75,37 @@ ts audit https://publisher.example --force `ts audit` is not an EdgeZero adapter command. It has no `--adapter` option and it does not provision resources, push config, build, deploy, or contact platform APIs. + +## Generate an external Prebid bundle + +`ts prebid bundle` builds the local external Prebid browser bundle configured in +`trusted-server.toml`. + +```toml +[integrations.prebid.bundle] +adapters = ["rubicon", "kargo"] +user_id_modules = ["sharedIdSystem"] +``` + +Run the command after installing JS dependencies: + +```bash +cd crates/js/lib && npm ci +cd ../../.. +ts prebid bundle +``` + +By default, generated artifacts are written to `dist/prebid/`, and the command +updates `integrations.prebid.external_bundle_sha256` and +`integrations.prebid.external_bundle_sri` in `trusted-server.toml`. Upload the +generated JavaScript file yourself and set `external_bundle_url` to its HTTPS +asset URL before running `ts config validate` or `ts config push`. + +Use custom paths when needed: + +```bash +ts prebid bundle --config publisher-a.toml --out build/prebid +``` + +`ts prebid bundle` is local-only. It has no `--adapter` option and does not +upload, provision, deploy, or push config. diff --git a/docs/guide/integrations/prebid.md b/docs/guide/integrations/prebid.md index dbeb72f71..452cf4038 100644 --- a/docs/guide/integrations/prebid.md +++ b/docs/guide/integrations/prebid.md @@ -35,6 +35,11 @@ client_side_bidders = ["rubicon"] # Script interception patterns (optional - defaults shown below) script_patterns = ["/prebid.js", "/prebid.min.js", "/prebidjs.js", "/prebidjs.min.js"] +# External bundle generation inputs used by `ts prebid bundle`. +[integrations.prebid.bundle] +adapters = ["rubicon"] +user_id_modules = ["sharedIdSystem"] + # Optional static per-bidder param overrides (shallow merge) [integrations.prebid.bid_param_overrides.criteo] networkId = 99999 @@ -71,6 +76,23 @@ set = { placementId = "_s2sHeaderPlacement" } | `debug_query_params` | String | `None` | Extra query params appended for debugging | | `client_side_bidders` | Array[String] | `[]` | Bidders that run client-side via native Prebid.js adapters instead of server-side. See [Client-Side Bidders](#client-side-bidders) | | `script_patterns` | Array[String] | `["/prebid.js", "/prebid.min.js", "/prebidjs.js", "/prebidjs.min.js"]` | URL patterns for Prebid script interception | +| `bundle.adapters` | Array[String] | Required for `ts prebid bundle` | Prebid.js bidder adapter modules imported into the generated external browser bundle | +| `bundle.user_id_modules` | Array[String] | Generator default preset when omitted | Prebid User ID modules imported into the generated external browser bundle | + +## External Bundle Generation + +Use `ts prebid bundle` to build the publisher-specific browser bundle from +`[integrations.prebid.bundle]` selections: + +```bash +ts prebid bundle +``` + +The command writes generated artifacts to `dist/prebid/` by default and updates +`external_bundle_sha256` and `external_bundle_sri` in `trusted-server.toml` from +the generated manifest. Upload the generated JavaScript file manually and set +`external_bundle_url` to the hosted HTTPS asset URL before running +`ts config validate` or `ts config push`. ## Debug Mode diff --git a/docs/superpowers/specs/2026-06-17-prebid-bundle-cli-design.md b/docs/superpowers/specs/2026-06-17-prebid-bundle-cli-design.md new file mode 100644 index 000000000..ee73e9b28 --- /dev/null +++ b/docs/superpowers/specs/2026-06-17-prebid-bundle-cli-design.md @@ -0,0 +1,412 @@ +# Trusted Server CLI — Prebid Bundle Generation + +**Date:** 2026-06-17 +**Status:** Implemented +**Scope:** `ts prebid bundle` local external Prebid bundle generation +**Related context:** + +- `docs/superpowers/specs/2026-05-28-external-prebid-first-party-proxy-design.md` +- `docs/superpowers/specs/2026-06-16-edgezero-based-ts-cli-design.md` +- `crates/js/lib/build-prebid-external.mjs` +- `crates/js/lib/package.json` + +--- + +## 1. Goal + +Add a Trusted Server-specific CLI command for generating the external Prebid +browser bundle used by the first-party Prebid proxy flow: + +```bash +ts prebid bundle +``` + +The command should make the existing external bundle generation path ergonomic for +operators by reading bundle selections from `trusted-server.toml`, running the +existing JS/Vite generator, writing local build artifacts to `dist/prebid` by +default, and updating `trusted-server.toml` with the generated bundle integrity +metadata. + +The command is intentionally a Trusted Server product command, not an EdgeZero +lifecycle command. It does not require `--adapter`, does not upload assets, and +does not provision or deploy platform resources. + +The external Prebid runtime model remains the one defined by the first-party +proxy spec: + +1. Prebid is not embedded in the Trusted Server WASM/TSJS bundle. +2. A generated external Prebid bundle is hosted by the operator. +3. Trusted Server injects `/integrations/prebid/bundle.js[?v=]`. +4. Trusted Server proxies that first-party URL to `integrations.prebid.external_bundle_url`. + +--- + +## 2. Non-goals + +The initial `ts prebid bundle` command does **not** do any of the following: + +- upload generated bundles to an asset host or CDN; +- infer or construct the public `external_bundle_url`; +- accept an asset base URL and derive hosted URLs; +- call EdgeZero adapter lifecycle commands; +- require or accept `--adapter`; +- push `trusted-server.toml` to a config store; +- run `npm install`, `npm ci`, or otherwise mutate JS dependencies; +- port the Prebid bundler from Node/Vite into Rust; +- change the generated `manifest.json` schema; +- change the first-party proxy runtime behavior; +- generate arbitrary Prebid runtime module choices at the edge; +- support remote or platform-hosted bundling. + +--- + +## 3. Command surface + +```bash +ts prebid bundle [--config ] [--out ] +``` + +Defaults: + +| Option | Default | Description | +| ---------- | --------------------- | ---------------------------------------------- | +| `--config` | `trusted-server.toml` | Trusted Server app config to read and update | +| `--out` | `dist/prebid` | Local output directory for generated artifacts | + +Examples: + +```bash +# Generate from trusted-server.toml into dist/prebid +ts prebid bundle + +# Generate from a draft config +ts prebid bundle --config ./publisher-a.trusted-server.toml + +# Generate into a custom local directory +ts prebid bundle --out ./build/prebid +``` + +Successful output should be concise and actionable, for example: + +```text +Built Prebid bundle: dist/prebid/trusted-prebid-.js +Manifest: dist/prebid/manifest.json +Updated config: trusted-server.toml +Next: upload the bundle and set integrations.prebid.external_bundle_url to its HTTPS URL if needed. +``` + +The default output directory `dist/prebid` is a local generated-artifact path and +must be git-ignored at the repository root. + +--- + +## 4. Trusted Server config schema + +Bundle-generation selections live in `trusted-server.toml` under the Prebid +integration block: + +```toml +[integrations.prebid] +enabled = true +server_url = "https://prebid-server.example.com/openrtb2/auction" +external_bundle_url = "https://assets.example.com/prebid/trusted-prebid.js" + +[integrations.prebid.bundle] +adapters = ["rubicon", "kargo"] +user_id_modules = ["sharedIdSystem", "uid2IdSystem"] +``` + +### 4.1 Field semantics + +| Field | Required | Description | +| -------------------------------------------- | -------- | ---------------------------------------------------------------------------------------------------------------------------- | +| `integrations.prebid.bundle.adapters` | Yes | Prebid bidder adapter module names passed to the external bundle generator, e.g. `rubicon` -> `rubiconBidAdapter.js` | +| `integrations.prebid.bundle.user_id_modules` | No | Prebid User ID module names passed to the external bundle generator. When omitted, the JS generator's default preset is used | + +The bundle config is intentionally separate from existing runtime fields: + +- `integrations.prebid.bidders` controls server-side bidders routed through the + Trusted Server / Prebid Server auction flow. +- `integrations.prebid.client_side_bidders` controls browser-side bidder behavior + in the injected Prebid client config. +- `integrations.prebid.bundle.adapters` controls which native Prebid.js adapter + modules are statically imported into the generated external browser bundle. + +Operators may choose to keep `client_side_bidders` and `bundle.adapters` aligned, +but the CLI should not infer one from the other in this initial design. + +### 4.2 Config compatibility + +The existing runtime fields remain unchanged: + +```toml +[integrations.prebid] +external_bundle_url = "https://assets.example.com/prebid/trusted-prebid.js" +external_bundle_sha256 = "..." +external_bundle_sri = "sha384-..." +``` + +`external_bundle_url` remains manually authored by the operator. The CLI does not +infer it from `--out` and does not know where the operator will upload the local +bundle. + +--- + +## 5. Config update behavior + +After a successful local bundle build, `ts prebid bundle` must read the generated +`manifest.json` and update the same `trusted-server.toml` file with: + +```toml +[integrations.prebid] +external_bundle_sha256 = "" +external_bundle_sri = "" +``` + +The command must preserve `external_bundle_url` as-is. It must not overwrite, +derive, or remove the URL. + +If `external_bundle_url` is absent, the command may still generate the bundle and +write `external_bundle_sha256` / `external_bundle_sri`, but it must report a clear +next step telling the operator to set `integrations.prebid.external_bundle_url` to +the hosted HTTPS URL before validation/push/deploy. + +If `external_bundle_url` points at an old content-addressed filename, the command +must not guess the replacement. It should print the generated filename and remind +the operator to update `external_bundle_url` manually after upload. + +Config writes should be atomic: write to a temporary file next to the config and +rename into place after serialization succeeds. The implementation should prefer +a TOML editing library such as `toml_edit` so comments, ordering, and unrelated +formatting are preserved as much as practical. + +--- + +## 6. Local build behavior + +The Rust CLI should shell out to the existing JS bundler instead of reimplementing +Prebid/Vite bundling in Rust. + +Expected invocation model: + +1. Locate `crates/js/lib/package.json` and `crates/js/lib/build-prebid-external.mjs` + relative to the repository root/current working tree. +2. Read `[integrations.prebid.bundle]` from the selected config file. +3. Convert configured lists to the existing generator's CSV arguments. +4. Run the existing npm script from `crates/js/lib`: + +```bash +npm run build:prebid-external -- \ + --adapters rubicon,kargo \ + --user-id-modules sharedIdSystem,uid2IdSystem \ + --out +``` + +If `user_id_modules` is omitted, the CLI should omit `--user-id-modules` so the +JS generator uses its existing default preset. + +The generated output remains the current JS generator output: + +```text +dist/prebid/ + trusted-prebid-.js + manifest.json +``` + +The manifest schema remains unchanged: + +```json +{ + "prebidVersion": "10.26.0", + "adapters": ["rubicon", "kargo"], + "userIdModules": ["sharedIdSystem", "uid2IdSystem"], + "sha256": "abc123...", + "sri": "sha384-...", + "filename": "trusted-prebid-abc123.js" +} +``` + +--- + +## 7. Dependency and environment handling + +`ts prebid bundle` should fail fast with actionable diagnostics when local JS +build prerequisites are missing. + +Minimum checks before shelling out: + +- `npm` is available on `PATH`; +- `crates/js/lib/package.json` exists; +- `crates/js/lib/build-prebid-external.mjs` exists; +- `crates/js/lib/node_modules` exists. + +If `node_modules` is missing, the command must not run dependency installation. +It should fail with an instruction like: + +```text +Prebid bundling dependencies are missing. Run `cd crates/js/lib && npm ci`, then retry `ts prebid bundle`. +``` + +Errors from the JS generator, including unknown adapter names or unknown User ID +module names, should be surfaced without hiding the original generator message. +The CLI may add a short Trusted Server context prefix, but should preserve stdout +and stderr enough for debugging. + +--- + +## 8. Config loading and validation + +`ts prebid bundle` should not require full production config validity. It is a +local artifact-generation command, and operators may run it before the config is +ready for `ts config validate` or `ts config push`. + +The command should perform focused validation only for the fields it needs: + +- selected config file exists and parses as TOML; +- `[integrations.prebid]` exists; +- `[integrations.prebid.bundle]` exists; +- `bundle.adapters` is a non-empty array of non-empty strings; +- `bundle.user_id_modules`, when present, is an array of non-empty strings; +- `--out` resolves to a writable local directory path. + +The JS generator remains responsible for validating that adapter and User ID +module names correspond to available Prebid package modules. + +After the command updates hash/SRI metadata, `ts config validate` remains the +source of truth for full deployment readiness, including `external_bundle_url` +requirements, placeholder secret rejection, and runtime config validation. + +--- + +## 9. Integration with existing CLI design + +This spec extends the `ts` product CLI command surface with a new Trusted +Server-specific command group: + +```text +ts prebid bundle +``` + +The resulting CLI command enum should conceptually become: + +```text +ts audit ... +ts config ... +ts prebid bundle ... +ts auth ... +ts provision ... +ts serve ... +ts build ... +ts deploy ... +``` + +`ts prebid bundle` is similar to `ts audit` and `ts config` in that it owns +Trusted Server behavior directly. It is unlike `ts build` / `ts deploy`, which +are EdgeZero lifecycle delegates. + +--- + +## 10. Required code changes + +### CLI argument parsing + +- Add `Command::Prebid(PrebidArgs)`. +- Add `PrebidCommand::Bundle(PrebidBundleArgs)`. +- Add options: + - `--config ` defaulting to `trusted-server.toml`; + - `--out ` defaulting to `dist/prebid`. +- Add parser tests for defaults and custom paths. +- Reject `--adapter` for `ts prebid bundle`. + +### CLI implementation + +- Add a Prebid bundle command module, for example + `crates/trusted-server-cli/src/prebid_bundle.rs`. +- Parse focused bundle config from TOML. +- Check local JS dependency prerequisites. +- Shell out to `npm run build:prebid-external -- ...` in `crates/js/lib`. +- Read generated `manifest.json`. +- Atomically update `external_bundle_sha256` and `external_bundle_sri` in the + selected config file. +- Print concise success output and next steps. + +### JS tooling + +No manifest format change is required. + +The existing `crates/js/lib/build-prebid-external.mjs` should remain the source +of truth for generating the bundle, validating adapter module files, validating +User ID module names, hashing bundle bytes, and writing `manifest.json`. + +### Git ignore + +- Add `/dist/prebid/` to the repository root `.gitignore`. + +--- + +## 11. Test plan + +### CLI parser tests + +- `ts prebid bundle` parses with defaults: + - config: `trusted-server.toml` + - out: `dist/prebid` +- `ts prebid bundle --config publisher.toml --out build/prebid` parses custom paths. +- `ts prebid bundle --adapter fastly` is rejected. + +### Unit tests + +- Bundle config loader accepts valid `[integrations.prebid.bundle]` settings. +- Bundle config loader rejects missing Prebid block. +- Bundle config loader rejects missing bundle block. +- Bundle config loader rejects empty or malformed adapter arrays. +- Config patcher writes `external_bundle_sha256` and `external_bundle_sri`. +- Config patcher preserves existing `external_bundle_url`. +- Config patcher creates missing `external_bundle_sha256` / `external_bundle_sri` + fields when absent. +- Missing `node_modules` fails with an instruction to run `cd crates/js/lib && npm ci`. + +### Integration-style CLI tests + +- With a fake shell delegate/process runner, the command invokes: + - program: `npm` + - cwd: `crates/js/lib` + - args: `run build:prebid-external -- --adapters ... --out ...` +- When `user_id_modules` is omitted, `--user-id-modules` is not passed. +- When the fake generator writes `manifest.json`, the selected config is patched + from that manifest. +- Generator failure returns a CLI error and does not update config. + +### Manual smoke test + +```bash +cd crates/js/lib +npm ci +cd ../../.. + +ts prebid bundle +ls dist/prebid +rg 'external_bundle_sha256|external_bundle_sri' trusted-server.toml +``` + +Then upload the generated JS file manually, set or verify +`integrations.prebid.external_bundle_url`, and run: + +```bash +ts config validate +``` + +--- + +## 12. Open follow-up work + +These are intentionally outside the initial local-only command, but the design +should not preclude them later: + +- optional upload support through EdgeZero/platform asset primitives; +- optional asset URL/base URL handling; +- manifest generator metadata such as Trusted Server CLI version or source + revision; +- stronger checks that `external_bundle_url` corresponds to the generated + content-addressed filename; +- richer JSON output for CI automation. diff --git a/scripts/test-cli.sh b/scripts/test-cli.sh new file mode 100755 index 000000000..eef9e2f7d --- /dev/null +++ b/scripts/test-cli.sh @@ -0,0 +1,21 @@ +#!/usr/bin/env bash +set -euo pipefail + +HOST_TARGET="${1:-$(rustc -vV | awk '/host:/ { print $2 }')}" +if [ -z "$HOST_TARGET" ]; then + echo "Failed to detect host target" >&2 + exit 1 +fi + +if ! command -v rustup >/dev/null 2>&1; then + echo "rustup not found; cannot ensure host target $HOST_TARGET is installed" >&2 + echo "Run: cargo test --package trusted-server-cli --target $HOST_TARGET" >&2 + exit 1 +fi + +if ! rustup target list --installed | awk -v target="$HOST_TARGET" '$0 == target { found = 1 } END { exit found ? 0 : 1 }'; then + echo "Installing Rust target: $HOST_TARGET" + rustup target add "$HOST_TARGET" +fi + +cargo test --package trusted-server-cli --target "$HOST_TARGET" diff --git a/trusted-server.example.toml b/trusted-server.example.toml index 0e8226efb..bcc450ba0 100644 --- a/trusted-server.example.toml +++ b/trusted-server.example.toml @@ -45,6 +45,10 @@ bidders = [] debug = false client_side_bidders = [] +[integrations.prebid.bundle] +adapters = ["rubicon"] +# user_id_modules = ["sharedIdSystem"] + [integrations.nextjs] enabled = false rewrite_attributes = ["href", "link", "siteBaseUrl", "siteProductionDomain", "url"] From fc56059fbb9af24d031712856e0dcf8c123ed8e3 Mon Sep 17 00:00:00 2001 From: Christian Date: Mon, 22 Jun 2026 12:36:56 -0500 Subject: [PATCH 7/7] Fix Prebid rebase conflict fallout --- .../trusted-server-adapter-fastly/src/app.rs | 2 + .../src/auction/endpoints.rs | 45 --- .../src/auction/formats.rs | 2 +- .../src/integrations/prebid.rs | 20 +- .../src/integrations/registry.rs | 1 + crates/trusted-server-core/src/proxy.rs | 77 +++--- crates/trusted-server-core/src/publisher.rs | 14 +- .../src/request_signing/endpoints.rs | 10 +- trusted-server.toml | 261 ------------------ 9 files changed, 50 insertions(+), 382 deletions(-) delete mode 100644 trusted-server.toml diff --git a/crates/trusted-server-adapter-fastly/src/app.rs b/crates/trusted-server-adapter-fastly/src/app.rs index 8e0ff68a5..81d2c78f4 100644 --- a/crates/trusted-server-adapter-fastly/src/app.rs +++ b/crates/trusted-server-adapter-fastly/src/app.rs @@ -1144,6 +1144,7 @@ mod tests { [integrations.prebid] enabled = true server_url = "https://test-prebid.com/openrtb2/auction" + external_bundle_url = "https://assets.example/prebid/trusted-prebid.js" [integrations.datadome] enabled = true @@ -1202,6 +1203,7 @@ mod tests { [integrations.prebid] enabled = true server_url = "https://test-prebid.com/openrtb2/auction" + external_bundle_url = "https://assets.example/prebid/trusted-prebid.js" [auction] enabled = true diff --git a/crates/trusted-server-core/src/auction/endpoints.rs b/crates/trusted-server-core/src/auction/endpoints.rs index fe9004edf..000ef669c 100644 --- a/crates/trusted-server-core/src/auction/endpoints.rs +++ b/crates/trusted-server-core/src/auction/endpoints.rs @@ -803,49 +803,4 @@ mod tests { ); }); } - - #[tokio::test] - async fn auction_rejects_streaming_body_instead_of_treating_as_empty() { - use bytes::Bytes; - use edgezero_core::body::Body as EdgeBody; - use http::{Method, Request as HttpRequest}; - - use crate::auction::build_orchestrator; - use crate::consent::ConsentContext; - use crate::ec::EcContext; - use crate::error::TrustedServerError; - use crate::platform::test_support::noop_services; - use crate::test_support::tests::create_test_settings; - - let settings = create_test_settings(); - let orchestrator = build_orchestrator(&settings).expect("should build orchestrator"); - let services = noop_services(); - let ec_context = EcContext::new_for_test(None, ConsentContext::default()); - let stream = futures::stream::iter([Bytes::from_static(br#"{}"#)]); - let req = HttpRequest::builder() - .method(Method::POST) - .uri("https://test.com/auction") - .body(EdgeBody::stream(stream)) - .expect("should build request"); - - let result = handle_auction( - &settings, - &orchestrator, - None, - None, - &ec_context, - &services, - req, - ) - .await; - - let err = match result { - Ok(_) => panic!("streaming body should be rejected"), - Err(err) => err, - }; - assert!( - matches!(err.current_context(), TrustedServerError::BadRequest { .. }), - "streaming request body should fail as bad request" - ); - } } diff --git a/crates/trusted-server-core/src/auction/formats.rs b/crates/trusted-server-core/src/auction/formats.rs index f6e6b43f4..a00af8d08 100644 --- a/crates/trusted-server-core/src/auction/formats.rs +++ b/crates/trusted-server-core/src/auction/formats.rs @@ -441,7 +441,7 @@ mod tests { } fn response_json(response: Response) -> JsonValue { - serde_json::from_slice(&response.into_body().into_bytes().unwrap_or_default()) + serde_json::from_slice(&response.into_body().into_bytes()) .expect("should parse JSON response") } diff --git a/crates/trusted-server-core/src/integrations/prebid.rs b/crates/trusted-server-core/src/integrations/prebid.rs index 5cec6757f..82290094f 100644 --- a/crates/trusted-server-core/src/integrations/prebid.rs +++ b/crates/trusted-server-core/src/integrations/prebid.rs @@ -2193,14 +2193,8 @@ mod tests { } fn response_body_string(response: http::Response) -> String { - String::from_utf8( - response - .into_body() - .into_bytes() - .unwrap_or_default() - .to_vec(), - ) - .expect("should parse response body as utf-8") + String::from_utf8(response.into_body().into_bytes().to_vec()) + .expect("should parse response body as utf-8") } fn create_test_auction_request() -> AuctionRequest { @@ -2755,14 +2749,8 @@ external_bundle_sri = "sha384-AAAA" .expect("should have cache-control"); assert!(cache_control.contains("max-age=31536000")); - let body = String::from_utf8( - response - .into_body() - .into_bytes() - .unwrap_or_default() - .to_vec(), - ) - .expect("should parse script body as utf-8"); + let body = String::from_utf8(response.into_body().into_bytes().to_vec()) + .expect("should parse script body as utf-8"); assert!(body.contains("// Script overridden by Trusted Server")); } diff --git a/crates/trusted-server-core/src/integrations/registry.rs b/crates/trusted-server-core/src/integrations/registry.rs index 6c45221aa..7ed01ee48 100644 --- a/crates/trusted-server-core/src/integrations/registry.rs +++ b/crates/trusted-server-core/src/integrations/registry.rs @@ -1197,6 +1197,7 @@ impl IntegrationRegistry { head_injectors, request_filters: Vec::new(), deferred_js_ids: Vec::new(), + disabled_js_ids: Vec::new(), }), } } diff --git a/crates/trusted-server-core/src/proxy.rs b/crates/trusted-server-core/src/proxy.rs index 780f32f40..e474a4328 100644 --- a/crates/trusted-server-core/src/proxy.rs +++ b/crates/trusted-server-core/src/proxy.rs @@ -1262,7 +1262,7 @@ async fn proxy_with_redirects( message: "unsupported scheme".to_string(), })); } - if request_headers.require_https && scheme != "https" { + if redirect_policy.require_https && scheme != "https" { log::warn!("request to `{}` blocked: HTTPS is required", current_url); return Err(Report::new(TrustedServerError::Forbidden { message: "non-HTTPS proxy target blocked".to_string(), @@ -1412,7 +1412,7 @@ async fn proxy_with_redirects( let next_scheme = next_url.scheme().to_ascii_lowercase(); if next_scheme != "http" && next_scheme != "https" { - if request_headers.require_https { + if redirect_policy.require_https { log::warn!("redirect to `{}` blocked: HTTPS is required", next_url); return Err(Report::new(TrustedServerError::Forbidden { message: "non-HTTPS redirect blocked".to_string(), @@ -1426,7 +1426,7 @@ async fn proxy_with_redirects( redirect_policy.stream_passthrough, ); } - if request_headers.require_https && next_scheme != "https" { + if redirect_policy.require_https && next_scheme != "https" { log::warn!("redirect to `{}` blocked: HTTPS is required", next_url); return Err(Report::new(TrustedServerError::Forbidden { message: "non-HTTPS redirect blocked".to_string(), @@ -2096,14 +2096,8 @@ mod tests { } fn response_body_string(response: http::Response) -> String { - String::from_utf8( - response - .into_body() - .into_bytes() - .unwrap_or_default() - .to_vec(), - ) - .expect("response body should be valid UTF-8") + String::from_utf8(response.into_body().into_bytes().to_vec()) + .expect("response body should be valid UTF-8") } struct QueuedHttpResponse { @@ -2903,7 +2897,7 @@ mod tests { assert_eq!(ct, "text/html; charset=utf-8"); // Decompress output to verify content was rewritten - let compressed_output = out.into_body().into_bytes().unwrap_or_default(); + let compressed_output = out.into_body().into_bytes(); let mut decoder = GzDecoder::new(&compressed_output[..]); let mut decompressed = String::new(); decoder @@ -2959,7 +2953,7 @@ mod tests { assert_eq!(ct, "text/css; charset=utf-8"); // Decompress output to verify content was rewritten - let compressed_output = out.into_body().into_bytes().unwrap_or_default(); + let compressed_output = out.into_body().into_bytes(); let mut decoder = Decompressor::new(&compressed_output[..], 4096); let mut decompressed = String::new(); decoder @@ -4581,36 +4575,37 @@ mod tests { // below verify that proxy_request threads config.allowed_domains through // the initial target check and redirect hops. - #[tokio::test] - async fn proxy_request_blocks_non_https_target_when_https_only() { - let settings = create_test_settings(); - let services = crate::platform::test_support::build_services_with_http_client(Arc::new( - StreamingResponseHttpClient, - ) - as Arc); - let req = build_http_request( - Method::GET, - "https://edge.example/integrations/prebid/bundle.js", - ); - let config = ProxyRequestConfig::new("http://assets.example/prebid/trusted-prebid.js") - .without_ec_id() - .without_forward_headers() - .with_streaming() - .with_https_only(); + #[test] + fn proxy_request_blocks_non_https_target_when_https_only() { + futures::executor::block_on(async { + let settings = create_test_settings(); + let services = crate::platform::test_support::build_services_with_http_client( + Arc::new(StreamingResponseHttpClient) as Arc, + ); + let req = build_http_request( + Method::GET, + "https://edge.example/integrations/prebid/bundle.js", + ); + let config = ProxyRequestConfig::new("http://assets.example/prebid/trusted-prebid.js") + .without_ec_id() + .without_forward_headers() + .with_streaming() + .with_https_only(); - let err = proxy_request(&settings, req, config, &services) - .await - .expect_err("should block non-HTTPS target before proxying"); + let err = proxy_request(&settings, req, config, &services) + .await + .expect_err("should block non-HTTPS target before proxying"); - assert_eq!( - err.current_context().status_code(), - StatusCode::FORBIDDEN, - "HTTPS-only proxy requests should reject http targets" - ); - assert!( - matches!(err.current_context(), TrustedServerError::Forbidden { .. }), - "should return a forbidden error" - ); + assert_eq!( + err.current_context().status_code(), + StatusCode::FORBIDDEN, + "HTTPS-only proxy requests should reject http targets" + ); + assert!( + matches!(err.current_context(), TrustedServerError::Forbidden { .. }), + "should return a forbidden error" + ); + }); } #[test] diff --git a/crates/trusted-server-core/src/publisher.rs b/crates/trusted-server-core/src/publisher.rs index 482e141a3..c7bc0f663 100644 --- a/crates/trusted-server-core/src/publisher.rs +++ b/crates/trusted-server-core/src/publisher.rs @@ -852,14 +852,8 @@ mod tests { } fn response_body_string(response: http::Response) -> String { - String::from_utf8( - response - .into_body() - .into_bytes() - .unwrap_or_default() - .to_vec(), - ) - .expect("response body should be valid UTF-8") + String::from_utf8(response.into_body().into_bytes().to_vec()) + .expect("response body should be valid UTF-8") } #[test] @@ -1225,7 +1219,7 @@ mod tests { // Reattach and verify body content *response.body_mut() = body; let (_, final_body) = response.into_parts(); - let output = final_body.into_bytes().unwrap_or_default(); + let output = final_body.into_bytes(); assert_eq!( output, image_bytes, "pass-through should preserve body byte-for-byte" @@ -1847,7 +1841,7 @@ mod tests { "2048" ); let (_, final_body) = response.into_parts(); - let round_trip = final_body.into_bytes().unwrap_or_default(); + let round_trip = final_body.into_bytes(); assert_eq!( round_trip, image_bytes, "pass-through reattach must preserve bytes exactly" diff --git a/crates/trusted-server-core/src/request_signing/endpoints.rs b/crates/trusted-server-core/src/request_signing/endpoints.rs index c7040b60d..1b4d703ae 100644 --- a/crates/trusted-server-core/src/request_signing/endpoints.rs +++ b/crates/trusted-server-core/src/request_signing/endpoints.rs @@ -468,14 +468,8 @@ mod tests { } fn response_body_string(response: http::Response) -> String { - String::from_utf8( - response - .into_body() - .into_bytes() - .unwrap_or_default() - .to_vec(), - ) - .expect("should decode response body") + String::from_utf8(response.into_body().into_bytes().to_vec()) + .expect("should decode response body") } fn assert_json_content_type(response: &http::Response) { diff --git a/trusted-server.toml b/trusted-server.toml deleted file mode 100644 index ef3bd39f0..000000000 --- a/trusted-server.toml +++ /dev/null @@ -1,261 +0,0 @@ -[[handlers]] -path = "^/secure" -username = "user" -password = "pass" - -[[handlers]] -path = "^/_ts/admin" -username = "admin" -password = "replace-with-admin-password-32-bytes" - -[publisher] -domain = "test-publisher.com" -cookie_domain = ".test-publisher.com" -origin_url = "https://origin.test-publisher.com" -proxy_secret = "change-me-proxy-secret" - -[ec] -passphrase = "local-dev-passphrase-32-bytes-min" -ec_store = "ec_identity_store" -pull_sync_concurrency = 3 -# cluster_trust_threshold = 10 # Entries with cluster_size <= this are individual users -# cluster_recheck_secs = 3600 # Re-evaluate cluster_size after this many seconds - -# [[ec.partners]] -# name = "LiveRamp" -# source_domain = "liveramp.com" -# openrtb_atype = 3 -# bidstream_enabled = true -# api_token = "partner-api-token-32-bytes-minimum" -# batch_rate_limit = 60 -# pull_sync_enabled = false - -# Configure real partners via private build-time config or environment -# overrides. Do not commit deployable partner API tokens in this placeholder -# config; the integration-test partners are injected by test scripts. -# -# [[ec.partners]] -# name = "Prebid SharedID" -# source_domain = "sharedid.org" -# openrtb_atype = 1 -# bidstream_enabled = true -# api_token = "replace-with-partner-api-token-32-bytes-minimum" - -# Custom headers to be included in every response -# Allows publishers to include tags such as X-Robots-Tag: noindex -# [response_headers] -# X-Custom-Header = "custom header value" -# -# Or via environment variable (JSON preserves header name casing and hyphens): -# TRUSTED_SERVER__RESPONSE_HEADERS='{"X-Robots-Tag": "noindex", "X-Custom-Header": "custom value"}' - -# Request Signing Configuration -# Enable signing of OpenRTB requests and other API calls -[request_signing] -enabled = false # Set to true to enable request signing -config_store_id = "" # set config/secret store ids for key rotation -secret_store_id = "" - -[integrations.prebid] -enabled = true -server_url = "http://68.183.113.79:8000" -timeout_ms = 1000 -bidders = ["kargo", "appnexus", "openx"] -debug = false -# test_mode = false -# debug_query_params = "" -# script_patterns = ["/prebid.js"] -# Generated external Prebid bundle served through /integrations/prebid/bundle.js. -external_bundle_url = "https://assets.example/prebid/trusted-prebid.js" -# external_bundle_sha256 = "..." -# external_bundle_sri = "sha384-..." - -# Bidders that run client-side via native Prebid.js adapters instead of -# being routed through the server-side auction. Their adapter modules must -# be statically imported in the JS bundle. -client_side_bidders = ["rubicon"] - -# Compatibility sugar for static per-bidder params merged into every outgoing -# PBS request. These normalize into bid_param_override_rules internally. -# Example: -# [integrations.prebid.bid_param_overrides.bidder-name] -# param1 = 12345 -# param2 = "value" - -# Compatibility sugar for zone-specific bid param overrides. -# The JS adapter reads the zone from mediaTypes.banner.name on each ad unit and -# includes it in the request. These normalize into bid_param_override_rules -# internally. -# [integrations.prebid.bid_param_zone_overrides.kargo] -# header = {placementId = "_abc"} - -# Preferred canonical override format for future rules. -# Rules run in order with exact-match conditions and shallow last-write-wins merge. -# [[integrations.prebid.bid_param_override_rules]] -# when.bidder = "kargo" -# when.zone = "header" -# set = { placementId = "_abc" } - -[integrations.nextjs] -enabled = false -rewrite_attributes = ["href", "link", "siteBaseUrl", "siteProductionDomain", "url"] -# Maximum combined payload size for cross-script RSC processing (bytes). Default is 10 MB. -max_combined_payload_bytes = 10485760 - -[integrations.testlight] -endpoint = "https://testlight.example/openrtb2/auction" -timeout_ms = 1200 -rewrite_scripts = true - -[integrations.didomi] -enabled = false -sdk_origin = "https://sdk.privacy-center.org" -api_origin = "https://api.privacy-center.org" - -[integrations.sourcepoint] -enabled = false -rewrite_sdk = true -cdn_origin = "https://cdn.privacy-mgmt.com" -# Optional: forward a custom Sourcepoint authCookie name upstream. -# auth_cookie_name = "sp_auth" -cache_ttl_seconds = 3600 - -[integrations.permutive] -enabled = false -organization_id = "" -workspace_id = "" -project_id = "" -api_endpoint = "https://api.permutive.com" -secure_signals_endpoint = "https://secure-signals.permutive.app" - -[integrations.lockr] -enabled = false -app_id = "" -api_endpoint = "https://identity.loc.kr" -sdk_url = "https://aim.loc.kr/identity-lockr-trust-server.js" -cache_ttl_seconds = 3600 -rewrite_sdk = true - -# DataDome bot protection integration -# Proxies tags.js and signal collection API through first-party context -# Endpoints: -# GET /integrations/datadome/tags.js - Proxied SDK script -# ANY /integrations/datadome/js/* - Signal collection API -[integrations.datadome] -enabled = false -sdk_origin = "https://js.datadome.co" -api_origin = "https://api-js.datadome.co" -cache_ttl_seconds = 3600 -rewrite_sdk = true - -[integrations.gpt] -enabled = false -script_url = "https://securepubads.g.doubleclick.net/tag/js/gpt.js" -cache_ttl_seconds = 3600 -rewrite_script = true - -# Consent forwarding configuration -# Controls how Trusted Server interprets and forwards privacy consent signals. -# All values shown below are the defaults — uncomment to override. -# [consent] -# mode = "interpreter" # "interpreter" (decode + forward) or "proxy" (raw passthrough) -# check_expiration = true # Check TCF consent freshness -# max_consent_age_days = 395 # Max age before consent is treated as expired (~13 months) - -# [consent.gdpr] -# applies_in = ["AT","BE","BG","HR","CY","CZ","DK","EE","FI","FR","DE","GR","HU","IE","IT","LV","LT","LU","MT","NL","PL","PT","RO","SK","SI","ES","SE","IS","LI","NO","GB"] - -# [consent.us_states] -# privacy_states = ["CA","VA","CO","CT","UT","MT","OR","TX","FL","DE","IA","NE","NH","NJ","TN","MN","MD","IN","KY","RI"] - -# [consent.us_privacy_defaults] -# notice_given = true # Has publisher actually shown CCPA notice? -# lspa_covered = false # Is publisher subject to LSPA? -# gpc_implies_optout = true # Should Sec-GPC: 1 trigger opt-out? - -# [consent.conflict_resolution] -# mode = "restrictive" # "restrictive" | "newest" | "permissive" -# freshness_threshold_days = 30 - -# Consent is interpreted from request cookies, headers, geolocation, and these -# policy settings. EC identity lifecycle state and withdrawal tombstones are -# stored in the KV store configured by [ec].ec_store. - -# Rewrite configuration for creative HTML/CSS processing -# [rewrite] -# Domains to exclude from first-party rewriting (supports wildcards like "*.example.com") -# URLs from these domains will be left as-is and not proxied -# exclude_domains = [ -# "*.edgecompute.app", -# ] - - -# Proxy configuration -[proxy] -# Enable TLS certificate verification when proxying to HTTPS origins. -# Defaults to true. Set to false only for local development with self-signed certificates. -# certificate_check = true - -# Restrict redirect destinations for the first-party proxy to an explicit domain allowlist. -# Supports exact match ("example.com") and subdomain wildcard prefix ("*.example.com"). -# Wildcard prefix also matches the apex domain ("*.example.com" matches "example.com"). -# Matching is case-insensitive. A dot-boundary check prevents "*.example.com" from -# matching "evil-example.com". -# When omitted or empty, redirect destinations are unrestricted — configure this in -# production to prevent SSRF via signed URLs that redirect to internal services. -# Note: this list governs only the first-party proxy redirect chain, not integration -# endpoints defined under [integrations.*]. -# allowed_domains = [ - # "ad.example.com", - # "*.doubleclick.net", - # "*.googlesyndication.com", -# ] - -[auction] -enabled = true -providers = ["prebid"] -# mediator = "adserver_mock" # will use mediator when set -timeout_ms = 2000 -# Context keys the JS client is allowed to forward into auction requests. -# Keys not in this list are silently dropped. An empty list blocks all keys. -allowed_context_keys = ["permutive_segments"] - -[integrations.aps] -enabled = false -pub_id = "your-aps-publisher-id" -endpoint = "https://origin-mocktioneer.cdintel.com/e/dtb/bid" -timeout_ms = 1000 - -[integrations.google_tag_manager] -enabled = false -container_id = "GTM-XXXXXX" -# upstream_url = "https://www.googletagmanager.com" - -[integrations.adserver_mock] -enabled = false -endpoint = "https://origin-mocktioneer.cdintel.com/adserver/mediate" -timeout_ms = 1000 - -# Debug configuration (all flags default to false — do not enable in production) -# [debug] -# Enable the JA4/TLS fingerprint debug endpoint at GET /_ts/debug/ja4. -# Returns a plain-text response with the following fields (Fastly-observed values): -# ja4 — JA4 TLS client fingerprint -# h2_fp — HTTP/2 client fingerprint -# cipher — TLS cipher suite (OpenSSL name) -# tls_version — TLS protocol version -# user-agent — User-Agent request header -# ch-mobile — Sec-CH-UA-Mobile client hint -# ch-platform — Sec-CH-UA-Platform client hint -# Fastly TLS/fingerprint fields fall back to "unavailable"; client hints fall back -# to "not sent"; user-agent falls back to "none" when absent. -# Response always carries Cache-Control: no-store, private. -# IMPORTANT: This endpoint reflects TLS details that browser JS cannot normally read. -# Disable after investigation is complete. -# ja4_endpoint_enabled = false - -# Map auction-request context keys to mediation URL query parameters. -# Each key is a context key from the JS client; the value becomes the -# query parameter name. Arrays are joined with commas. -[integrations.adserver_mock.context_query_params] -permutive_segments = "permutive"